From 13853a3fe0c093e18e337cc2bd55c3000b9c4ed9 Mon Sep 17 00:00:00 2001 From: Samuel Macleod Date: Wed, 30 Sep 2026 23:36:30 +0100 Subject: [PATCH 1/8] fix: print plain version to stdout --- .changeset/plain-version-output.md | 8 ++++ AGENTS.md | 4 +- .../__tests__/update-check-runtime.test.ts | 38 +++++++++++++++---- packages/cli/src/index.ts | 22 ++++------- packages/cli/src/lib/ui/banner.ts | 6 +-- 5 files changed, 51 insertions(+), 27 deletions(-) create mode 100644 .changeset/plain-version-output.md diff --git a/.changeset/plain-version-output.md b/.changeset/plain-version-output.md new file mode 100644 index 000000000..c3bcd1261 --- /dev/null +++ b/.changeset/plain-version-output.md @@ -0,0 +1,8 @@ +--- +"cf": minor +--- + +Print a parseable version from `cf --version` + +Write the package version to stdout for `--version` and `-v`, so scripts can +parse it directly. Interactive use keeps the branded banner on stderr. diff --git a/AGENTS.md b/AGENTS.md index 172123f94..a5160cfe9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -586,7 +586,7 @@ hand-written design or remain deliberately unsupported. These have stabilised this repo's voice; preserve them. -- **Banner:** `renderPromptIntro(version)` supplies the compact branded headline. `openSession` prints it once for an interactive command; bare cf and `--version` use the compact presentation directly. +- **Banner:** `renderPromptIntro(version)` supplies the compact branded headline. `openSession` prints it once to stderr for an interactive command, including `--version`; bare cf uses the compact presentation directly. `--version` also prints the plain version to stdout. - **Terminal styling:** Use the semantic helpers in `lib/ui/theme.ts` (`theme.brand`, `theme.info`, `theme.muted`, etc.) rather than importing Chalk directly, calling explicit color methods, or hard-coding RGB/ANSI @@ -615,7 +615,7 @@ Live globals (`packages/cli/src/index.ts:buildCli`): | Flag | Alias | Purpose | | -------------- | ----- | --------------------------------------------------------------- | | `--help` | `-h` | Show help | -| `--version` | `-v` | Show version (branded banner) | +| `--version` | `-v` | Show branded banner on stderr and plain version on stdout | | `--quiet` | `-q` | Suppress non-essential output | | `--zone` | `-z` | Zone ID or domain (overrides `CLOUDFLARE_ZONE_ID`) | | `--profile` | β€” | Use a specific auth profile | diff --git a/packages/cli/src/__tests__/update-check-runtime.test.ts b/packages/cli/src/__tests__/update-check-runtime.test.ts index 1252cfa3f..8a43fe16e 100644 --- a/packages/cli/src/__tests__/update-check-runtime.test.ts +++ b/packages/cli/src/__tests__/update-check-runtime.test.ts @@ -1,5 +1,6 @@ import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; import * as updateCheck from "../lib/update-check.js"; +import { VERSION } from "../version.js"; import { captureOutput } from "./helpers/capture-output.js"; import { runCf } from "./helpers/run-cf.js"; @@ -59,7 +60,7 @@ describe("update check runtime", () => { isMajor: false, }); - await runCf(["--version"]); + await runCf([]); expect(output.stdout()).toContain("update available: v1.3.0"); expect(updateCheck.getUpdateNotice).toHaveBeenCalledOnce(); @@ -70,20 +71,43 @@ describe("update check runtime", () => { setTTY(false, false); const output = captureOutput(); - await runCf(["--version"]); + await runCf([]); expect(output.stdout()).not.toContain("update available"); expect(updateCheck.getUpdateNotice).not.toHaveBeenCalled(); expect(updateCheck.maybeStartBackgroundUpdateCheck).not.toHaveBeenCalled(); }); - it("does not load or refresh update state when quiet", async () => { + it("prints the interactive version banner to stderr and the number to stdout", async () => { setTTY(true, true); const output = captureOutput(); + vi.mocked(updateCheck.getUpdateNotice).mockReturnValue({ + latestVersion: "1.3.0", + isMajor: false, + }); - await runCf(["--version", "--quiet"]); + await runCf(["--version"]); - expect(output.stdout()).not.toContain("update available"); + expect(output.stdout()).toBe(VERSION); + expect(output.stderr()).toContain("🍊☁️"); + expect(output.stderr()).toContain(`v${VERSION}`); + expect(output.stderr()).toContain("update available: v1.3.0"); + expect(updateCheck.getUpdateNotice).toHaveBeenCalledOnce(); + expect(updateCheck.maybeStartBackgroundUpdateCheck).toHaveBeenCalledOnce(); + }); + + it.each([ + ["-v", ["-v"], false], + ["--version --quiet", ["--version", "--quiet"], true], + ["-q -v", ["-q", "-v"], true], + ])("keeps stdout parseable for %s", async (_label, args, tty) => { + setTTY(tty, tty); + const output = captureOutput(); + + await runCf(args); + + expect(output.stdout()).toBe(VERSION); + expect(output.stderr()).toBe(""); expect(updateCheck.getUpdateNotice).not.toHaveBeenCalled(); expect(updateCheck.maybeStartBackgroundUpdateCheck).not.toHaveBeenCalled(); }); @@ -93,7 +117,7 @@ describe("update check runtime", () => { setTTY(true, true); const output = captureOutput(); - await runCf(["--version"]); + await runCf([]); expect(output.stdout()).not.toContain("update available"); expect(updateCheck.getUpdateNotice).not.toHaveBeenCalled(); @@ -115,7 +139,7 @@ describe("update check runtime", () => { setTTY(true, true); captureOutput(); - await runCf(["--profile", "complete", "--version"]); + await runCf(["--profile", "complete", "--help"]); expect(updateCheck.getUpdateNotice).toHaveBeenCalledOnce(); expect(updateCheck.maybeStartBackgroundUpdateCheck).toHaveBeenCalledOnce(); diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 2e7e04c1b..43c38aafd 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -339,14 +339,7 @@ async function reportHelpShown(command: string | undefined): Promise { } } -/** - * Check if --version flag is present and show branded version. - * - * Yargs has its own `--version` handling but renders monochrome - * `0.0.5` with no branding. The slim headline (orange + emoji) is - * the same banner cf uses everywhere else, so intercepting `-v` / - * `--version` here keeps that consistent. - */ +/** Print the branded banner to stderr and the parseable version to stdout. */ function maybeHandleVersionEarly(update?: UpdateNotice): boolean { const rawArgs = process.argv.slice(2); const separator = rawArgs.indexOf("--"); @@ -354,7 +347,8 @@ function maybeHandleVersionEarly(update?: UpdateNotice): boolean { if (!args.includes("--version") && !args.includes("-v")) { return false; } - console.log(renderPromptIntro(version, update)); + openSession(version, { quiet: hasQuietFlag(args), update }); + console.log(version); return true; } @@ -401,9 +395,8 @@ function maybeShowSplash(update?: UpdateNotice): boolean { * must be parseable, no decoration). Covers both the init-script * form (`cf complete bash`) and the runtime callback form * (`cf complete -- `). - * - the command is bare `cf` or `cf --version` (those render the - * slim `🍊☁️ cf Β· v…` headline themselves via `renderPromptIntro` - * and we'd double up) + * - the command is bare `cf` (the splash renders its own headline) + * or `cf --version` (handled before normal command dispatch) */ function maybeOpenSession(quiet = false, update?: UpdateNotice): void { const rawArgs = process.argv.slice(2); @@ -444,8 +437,7 @@ async function prepareUpdateCheck(): Promise { if (commandName(args) === "complete") { return undefined; } - const bannerWritesToStdout = - args.length === 0 || args.includes("--version") || args.includes("-v"); + const bannerWritesToStdout = args.length === 0; if ( bannerWritesToStdout ? process.stdout.isTTY !== true @@ -489,7 +481,7 @@ async function prepareUpdateCheck(): Promise { * ## Global flags (live) * * --help, -h Show help - * --version, -v Show version (branded banner) + * --version, -v Show banner on stderr, plain version on stdout * --quiet, -q Suppress non-essential output * --zone, -z Zone ID or domain * --profile Use a specific auth profile diff --git a/packages/cli/src/lib/ui/banner.ts b/packages/cli/src/lib/ui/banner.ts index cb824fbdb..8f4f6fc4e 100644 --- a/packages/cli/src/lib/ui/banner.ts +++ b/packages/cli/src/lib/ui/banner.ts @@ -4,9 +4,9 @@ * command-line formatter shared by splash / help screens. * * Previously this module also shipped a full ASCII cloud logo plus - * gradient dividers for `cf --version` / `cf` splash. Both surfaces - * now use the slim headline (see `renderPromptIntro`) so the splash - * art was removed along with its `gradient-string` dependency. + * gradient dividers for the bare `cf` splash. The splash now uses the + * slim headline (see `renderPromptIntro`), so the splash art and its + * `gradient-string` dependency were removed. */ import { stripVTControlCharacters } from "node:util"; From c1700b7cc348a05cce4996516ea818a18afd442c Mon Sep 17 00:00:00 2001 From: Samuel Macleod Date: Wed, 30 Sep 2026 23:44:20 +0100 Subject: [PATCH 2/8] chore: ignore local cf account cache --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index d6a4440f6..3e7ede4e4 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,9 @@ fixtures/*/.wrangler/ # boots a runtime, including when cf is run from inside this repo. .wrangler/ +# Local cf account cache is generated state, not repository source. +.cloudflare/cache/ + .opencode/plans fixtures/*/.cloudflare/ From 744ec430da1df173da58bca31bb64cd438116c20 Mon Sep 17 00:00:00 2001 From: Samuel Macleod Date: Fri, 2 Oct 2026 12:26:00 +0100 Subject: [PATCH 3/8] chore: ignore generated .cloudflare directories --- .gitignore | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/.gitignore b/.gitignore index 3e7ede4e4..cb606196c 100644 --- a/.gitignore +++ b/.gitignore @@ -21,10 +21,6 @@ fixtures/*/.wrangler/ # Miniflare drops a `cf.json` cache beside the cwd whenever `--local` # boots a runtime, including when cf is run from inside this repo. .wrangler/ - -# Local cf account cache is generated state, not repository source. -.cloudflare/cache/ +.cloudflare/ .opencode/plans - -fixtures/*/.cloudflare/ From a914d4ea61e6cd9126839b180ffd84963345764a Mon Sep 17 00:00:00 2001 From: Samuel Macleod Date: Fri, 2 Oct 2026 12:26:13 +0100 Subject: [PATCH 4/8] docs: describe current cf banner behavior --- packages/cli/src/lib/ui/banner.ts | 31 ++++++++----------------------- 1 file changed, 8 insertions(+), 23 deletions(-) diff --git a/packages/cli/src/lib/ui/banner.ts b/packages/cli/src/lib/ui/banner.ts index 8f4f6fc4e..c26c1cfb4 100644 --- a/packages/cli/src/lib/ui/banner.ts +++ b/packages/cli/src/lib/ui/banner.ts @@ -1,12 +1,6 @@ /** - * Banner module β€” the slim `🍊☁️ cf Β· v…` headline cf renders at the - * top of every interactive invocation, plus the `cf ` - * command-line formatter shared by splash / help screens. - * - * Previously this module also shipped a full ASCII cloud logo plus - * gradient dividers for the bare `cf` splash. The splash now uses the - * slim headline (see `renderPromptIntro`), so the splash art and its - * `gradient-string` dependency were removed. + * Formats the slim `🍊☁️ cf Β· v…` headline used by interactive commands + * and the bare `cf` splash, plus `cf ` labels for command lists. */ import { stripVTControlCharacters } from "node:util"; @@ -16,34 +10,25 @@ import { theme } from "./theme.js"; import type { UpdateNotice } from "../update-check.js"; /** - * Slim wrangler-style intro banner with a plain horizontal underline: + * Slim cf banner with a horizontal underline: * * 🍊☁️ cf Β· v0.0.5 * ────────────────── * - * When this process was reached by a *global* cf delegating to a - * project-pinned copy (Wrangler-2 style), a dim `Β· delegated` tag is - * appended to the headline so it's clear why the running version may - * differ from the global cf the user invoked: + * A project-pinned cf reached through global cf delegation adds a dim + * `Β· delegated` tag so the running version is clear: * * 🍊☁️ cf Β· v1.2.3 Β· delegated * ───────────────────────────── * - * Underline length tracks the visible width of the headline (ANSI - * stripped). Mirrors wrangler's `── ` rule below `⛅️ wrangler vX.Y.Z` - * without trying to connect down into a prompt gutter β€” earlier - * iterations bent a `β”Œ` corner into the gutter via `clack.intro()`, - * but the bare corner above an emoji headline read as a stray glyph, - * not a frame. - * - * Color gracefully degrades to plain ASCII when NO_COLOR/non-TTY. + * Underline length tracks the visible width of the headline after ANSI + * styling is stripped. Color styling follows the terminal settings. */ export function renderPromptIntro( version: string, update?: UpdateNotice ): string { - // 🍊☁️ β€” "orange cloud" is an old internal nickname for Cloudflare. - // U+FE0F after ☁ forces emoji presentation rather than monochrome glyph. + // U+FE0F after ☁ keeps the mark in emoji presentation. const mark = "🍊☁️ "; // A delegated child is spawned with DELEGATION_SENTINEL set (see From 0c8925eef4bc3189dd83cdc14127d445a0fb0fdc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Somhairle=20MacLe=C3=B2id?= Date: Fri, 2 Oct 2026 14:28:15 +0100 Subject: [PATCH 5/8] Update AGENTS.md Co-authored-by: Pete Bacon Darwin --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index a5160cfe9..1eb143495 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -615,7 +615,7 @@ Live globals (`packages/cli/src/index.ts:buildCli`): | Flag | Alias | Purpose | | -------------- | ----- | --------------------------------------------------------------- | | `--help` | `-h` | Show help | -| `--version` | `-v` | Show branded banner on stderr and plain version on stdout | +| `--version` | `-v` | Show plain version on stdout (branded banner on stderr) | | `--quiet` | `-q` | Suppress non-essential output | | `--zone` | `-z` | Zone ID or domain (overrides `CLOUDFLARE_ZONE_ID`) | | `--profile` | β€” | Use a specific auth profile | From 147ee51f5fed3ebd9c5e20cc35215e036fe98a91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Somhairle=20MacLe=C3=B2id?= Date: Fri, 2 Oct 2026 14:30:19 +0100 Subject: [PATCH 6/8] Update packages/cli/src/index.ts Co-authored-by: Pete Bacon Darwin --- packages/cli/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 43c38aafd..800b99a7c 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -481,7 +481,7 @@ async function prepareUpdateCheck(): Promise { * ## Global flags (live) * * --help, -h Show help - * --version, -v Show banner on stderr, plain version on stdout + * --version, -v Show plain version on stdout (banner on stderr) * --quiet, -q Suppress non-essential output * --zone, -z Zone ID or domain * --profile Use a specific auth profile From 9310cc57c2929f7b3a689f264d1ab04bb5ac8acf Mon Sep 17 00:00:00 2001 From: Samuel Macleod Date: Fri, 2 Oct 2026 14:54:47 +0100 Subject: [PATCH 7/8] revert: restore original gitignore rules --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index cb606196c..d6a4440f6 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,7 @@ fixtures/*/.wrangler/ # Miniflare drops a `cf.json` cache beside the cwd whenever `--local` # boots a runtime, including when cf is run from inside this repo. .wrangler/ -.cloudflare/ .opencode/plans + +fixtures/*/.cloudflare/ From b7985f49b94134b21545048c3eeb23ad095503dc Mon Sep 17 00:00:00 2001 From: Samuel Macleod Date: Fri, 2 Oct 2026 15:00:48 +0100 Subject: [PATCH 8/8] docs: format version flag description --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 1eb143495..2b25c3614 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -615,7 +615,7 @@ Live globals (`packages/cli/src/index.ts:buildCli`): | Flag | Alias | Purpose | | -------------- | ----- | --------------------------------------------------------------- | | `--help` | `-h` | Show help | -| `--version` | `-v` | Show plain version on stdout (branded banner on stderr) | +| `--version` | `-v` | Show plain version on stdout (branded banner on stderr) | | `--quiet` | `-q` | Suppress non-essential output | | `--zone` | `-z` | Zone ID or domain (overrides `CLOUDFLARE_ZONE_ID`) | | `--profile` | β€” | Use a specific auth profile |