From 0196179b3ec706587809cf6179986bf955774cf6 Mon Sep 17 00:00:00 2001 From: "Noah (Nguyen Pham)" Date: Mon, 19 Oct 2026 12:59:53 +0100 Subject: [PATCH 01/15] refactor: add stderr message helpers and share the success line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add lib/ui/log.ts with clack log lines on stderr, the ✓ success line and copyable command lines. formatOutput now prints its success line through the shared helper, and the account hint reuses the guide width. Add mockOutputStreams, which captures console calls and direct stream writes per stream so tests can assert that stdout holds only output. --- .../src/__tests__/helpers/capture-output.ts | 90 ++++++++++++++++++- packages/cli/src/lib/oauth/account-hint.ts | 4 +- packages/cli/src/lib/output.ts | 5 +- packages/cli/src/lib/ui/log.ts | 48 ++++++++++ 4 files changed, 141 insertions(+), 6 deletions(-) create mode 100644 packages/cli/src/lib/ui/log.ts diff --git a/packages/cli/src/__tests__/helpers/capture-output.ts b/packages/cli/src/__tests__/helpers/capture-output.ts index 4bd4c33f01..1b7531f87a 100644 --- a/packages/cli/src/__tests__/helpers/capture-output.ts +++ b/packages/cli/src/__tests__/helpers/capture-output.ts @@ -1,4 +1,6 @@ -import { vi } from "vite-plus/test"; +import { format } from "node:util"; +import { normalizeString } from "@cloudflare/workers-utils/test-helpers"; +import { afterEach, beforeEach, onTestFinished, vi } from "vite-plus/test"; /** * Capture a command's output streams. @@ -27,3 +29,89 @@ export function captureOutput(): { }, }; } + +/** + * Capture everything written to each stream, like `mockConsoleMethods`. + * + * Console calls and direct stream writes (clack lines, `✓` success lines, + * raw payloads) share one buffer per stream, so a test can assert that + * stdout holds only the command's output. Call at `describe` level; output + * is normalized the same way as `mockConsoleMethods`. + */ +export function mockOutputStreams(): { + readonly out: string; + readonly err: string; + getAndClearOut: () => string; + getAndClearErr: () => string; +} { + let out = ""; + let err = ""; + let spies: { mockRestore: () => void }[] = []; + const toOut = (...args: unknown[]) => { + out += `${format(...args)}\n`; + }; + const toErr = (...args: unknown[]) => { + err += `${format(...args)}\n`; + }; + + beforeEach(() => { + out = ""; + err = ""; + spies = [ + vi.spyOn(console, "log").mockImplementation(toOut), + vi.spyOn(console, "info").mockImplementation(toOut), + vi.spyOn(console, "debug").mockImplementation(toOut), + vi.spyOn(console, "warn").mockImplementation(toErr), + vi.spyOn(console, "error").mockImplementation(toErr), + vi.spyOn(process.stdout, "write").mockImplementation((chunk) => { + out += String(chunk); + return true; + }), + vi.spyOn(process.stderr, "write").mockImplementation((chunk) => { + err += String(chunk); + return true; + }), + ]; + }); + afterEach(() => { + for (const spy of spies) { + spy.mockRestore(); + } + }); + + const read = (buffer: string) => normalizeString(buffer).replace(/\n+$/, ""); + return { + get out() { + return read(out); + }, + get err() { + return read(err); + }, + getAndClearOut: () => { + const value = read(out); + out = ""; + return value; + }, + getAndClearErr: () => { + const value = read(err); + err = ""; + return value; + }, + }; +} + +/** Treat stderr as a terminal for the rest of the test. */ +export function stubStderrTTY(): void { + const original = Object.getOwnPropertyDescriptor(process.stderr, "isTTY"); + Object.defineProperty(process.stderr, "isTTY", { + configurable: true, + value: true, + }); + onTestFinished(() => { + if (original) { + Object.defineProperty(process.stderr, "isTTY", original); + } else { + Reflect.deleteProperty(process.stderr, "isTTY"); + } + }); +} diff --git a/packages/cli/src/lib/oauth/account-hint.ts b/packages/cli/src/lib/oauth/account-hint.ts index e467f027f0..acede08832 100644 --- a/packages/cli/src/lib/oauth/account-hint.ts +++ b/packages/cli/src/lib/oauth/account-hint.ts @@ -10,12 +10,10 @@ import { findCloudflareConfig, } from "../project-settings.js"; import { wrapPlain } from "../ui/blocks.js"; +import { GUIDE_WIDTH } from "../ui/log.js"; import { theme } from "../ui/theme.js"; import type { Writable } from "node:stream"; -/** clack prefixes message lines with a glyph and two spaces (`│ `). */ -const GUIDE_WIDTH = 3; - export function printAccountConfigHint( accountId: string, options: { diff --git a/packages/cli/src/lib/output.ts b/packages/cli/src/lib/output.ts index 13fbe0aa64..6fadee1969 100644 --- a/packages/cli/src/lib/output.ts +++ b/packages/cli/src/lib/output.ts @@ -1,4 +1,5 @@ import { isNonInteractiveOrCI } from "./interactive.js"; +import { printSuccess } from "./ui/log.js"; import { supportsColor, theme } from "./ui/theme.js"; /** Maximum JSON size (in characters) for syntax highlighting - prevents performance issues */ @@ -46,8 +47,8 @@ export function formatOutput(data: unknown, options: OutputOptions = {}): void { // the literal string 'null'), and — on TTYs only — write a brief ✓ // line to stderr so humans know it worked. if (data === null || data === undefined) { - if (options.successLabel && process.stderr.isTTY) { - process.stderr.write(`${theme.success("✓")} ${options.successLabel}\n`); + if (options.successLabel) { + printSuccess(options.successLabel); } return; } diff --git a/packages/cli/src/lib/ui/log.ts b/packages/cli/src/lib/ui/log.ts new file mode 100644 index 0000000000..8985bb8525 --- /dev/null +++ b/packages/cli/src/lib/ui/log.ts @@ -0,0 +1,48 @@ +/** + * Messages for people. + * + * stdout carries only a command's output (JSON or a raw payload), so every + * message goes to stderr: clack log lines for notices and warnings, the `✓` + * success line, and commands for the user to run next. + */ + +import * as clack from "@clack/prompts"; +import { theme } from "./theme.js"; +import type { LogMessageOptions } from "@clack/prompts"; + +/** clack prefixes message lines with a glyph and two spaces (`│ `). */ +export const GUIDE_WIDTH = 3; + +function toStderr(options?: LogMessageOptions): LogMessageOptions { + return { output: process.stderr, spacing: 0, ...options }; +} + +/** clack log lines on stderr, without a spacer line above by default. */ +export const log = { + info: (message: string, options?: LogMessageOptions) => + clack.log.info(message, toStderr(options)), + warn: (message: string, options?: LogMessageOptions) => + clack.log.warn(message, toStderr(options)), +}; + +/** + * The `✓` line confirming that a command finished. It prints only when + * stderr is a terminal; scripts read the exit code instead. + */ +export function printSuccess(message: string): void { + if (process.stderr.isTTY) { + process.stderr.write(`${theme.success("✓")} ${message}\n`); + } +} + +/** + * Commands for the user to run next, each on its own line. They are indented + * to the guide's text column without its glyphs, so copying one from the + * terminal yields exactly the command. + */ +export function printCommands(...commands: string[]): void { + const indent = " ".repeat(GUIDE_WIDTH); + process.stderr.write( + `\n${commands.map((command) => indent + theme.code(command)).join("\n")}\n\n` + ); +} From b643ad99b583b0acbdc1a1f242f092c885c10316 Mon Sep 17 00:00:00 2001 From: "Noah (Nguyen Pham)" Date: Mon, 19 Oct 2026 12:59:53 +0100 Subject: [PATCH 02/15] feat: write hand-written command messages to stderr MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cf auth login/logout, the auth profile commands, cf cli telemetry enable/disable and cf migrate printed notices, warnings and results on stdout. They now go to stderr as clack log lines and ✓ success lines, so redirected stdout holds only command output, as with generated commands. A command to run next is printed on its own line, and cf auth login with CLOUDFLARE_API_TOKEN set prints a single warning. Refs cloudflare/cf#53 --- .changeset/hand-written-messages-stderr.md | 13 ++++ AGENTS.md | 6 +- .../commands/auth/device-login.test.ts | 33 ++++---- .../__tests__/commands/auth/logout.test.ts | 29 +++++++ .../__tests__/commands/auth/profiles.test.ts | 71 +++++++++-------- .../src/__tests__/commands/migrate.test.ts | 23 +++--- .../src/__tests__/commands/telemetry.test.ts | 71 +++++++++++------ packages/cli/src/commands/auth/login.ts | 76 +++++++++---------- packages/cli/src/commands/auth/logout.ts | 40 +++------- packages/cli/src/commands/auth/profiles.ts | 66 +++++++--------- packages/cli/src/commands/login/index.ts | 9 +-- packages/cli/src/commands/migrate/index.ts | 3 +- .../cli/src/commands/telemetry/disable.ts | 17 +++-- packages/cli/src/commands/telemetry/enable.ts | 19 +++-- packages/cli/src/lib/ui/format.ts | 11 --- packages/cli/src/lib/ui/index.ts | 2 +- 16 files changed, 268 insertions(+), 221 deletions(-) create mode 100644 .changeset/hand-written-messages-stderr.md create mode 100644 packages/cli/src/__tests__/commands/auth/logout.test.ts diff --git a/.changeset/hand-written-messages-stderr.md b/.changeset/hand-written-messages-stderr.md new file mode 100644 index 0000000000..9bd09d64c6 --- /dev/null +++ b/.changeset/hand-written-messages-stderr.md @@ -0,0 +1,13 @@ +--- +"cf": minor +--- + +Write hand-written command messages to stderr + +Breaking change during the beta: `cf auth login`, `cf auth logout`, the auth +profile commands, `cf cli telemetry enable` / `disable` and `cf migrate` now +print their notices, warnings and success messages on stderr, so redirected or +piped stdout holds only command output, as with generated commands. The +messages use clack's log lines and the `✓` success line, and a command to run +next is printed on its own line so it copies cleanly. `cf auth login` with +`CLOUDFLARE_API_TOKEN` set now prints a single warning. diff --git a/AGENTS.md b/AGENTS.md index 850c5afb99..37cd298fca 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -269,7 +269,8 @@ the complete generated and hand-written `_meta/commands.json` catalogue (see | `ui/blocks.ts` | `errorBlock(...)` left-gutter / clack-style block rendering (replaced the old boxen-based `ui/box.ts`); `wrapPlain` per-line wrap | | `ui/banner.ts` | slim `renderPromptIntro(version)` headline and `formatCommand`; the old gradient/ASCII splash is removed | | `ui/theme.ts` | background-neutral semantic colour palette and Chalk wrapper | -| `ui/format.ts` | one-line `success`, `warning`, `info`, and `hint` helpers | +| `ui/format.ts` | one-line `success`, `warning`, and `hint` helpers | +| `ui/log.ts` | stderr messages: clack `log.info` / `log.warn`, `printSuccess` (`✓` line), `printCommands` (copyable next steps) | | `input-validation.ts` | yargs-time arg coercion + `resolveFileToken(value, fieldName, format)` for `@` ingestion; runtime body/path validation | | `resolve.ts` | URL templating (path-param substitution), `isId`, `isValidDomainName` | @@ -651,6 +652,9 @@ These have stabilised this repo's voice; preserve them. - **Secret prompt:** clack.password (masks input). - **Success line:** `✓ ` on stderr; null/undefined API results suppress stdout output entirely. +- **Messages:** stdout carries only command output. Hand-written commands + print notices, warnings and success lines to stderr through `lib/ui/log.ts`, + with a command to run next on its own line (`printCommands`). - **Splash screen:** bare `cf` prints the headline, a short getting-started list, and the docs link. Yargs owns root, group, and command help rendering. ## Global Flags diff --git a/packages/cli/src/__tests__/commands/auth/device-login.test.ts b/packages/cli/src/__tests__/commands/auth/device-login.test.ts index e015cbec82..f443018c74 100644 --- a/packages/cli/src/__tests__/commands/auth/device-login.test.ts +++ b/packages/cli/src/__tests__/commands/auth/device-login.test.ts @@ -1,9 +1,7 @@ import { writeFile } from "node:fs/promises"; -import { - mockConsoleMethods, - runInTempDir, -} from "@cloudflare/workers-utils/test-helpers"; +import { runInTempDir } from "@cloudflare/workers-utils/test-helpers"; import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { mockOutputStreams } from "../../helpers/capture-output.js"; import { runCf } from "../../helpers/run-cf.js"; const sharedAuth = vi.hoisted(() => ({ @@ -40,7 +38,7 @@ vi.mock("../../../lib/telemetry/dispatcher.js", () => ({ describe("cf auth device login", () => { runInTempDir(); - const std = mockConsoleMethods(); + const std = mockOutputStreams(); beforeEach(() => { sharedAuth.activeProfile = "default"; @@ -99,15 +97,14 @@ describe("cf auth device login", () => { it("loads file-sourced Cloudflare variables for login", async () => { await writeFile(".env", "CLOUDFLARE_API_TOKEN=file-token"); - let observedToken: string | undefined; - sharedAuth.login.mockImplementationOnce(async () => { - observedToken = process.env.CLOUDFLARE_API_TOKEN; - return false; - }); await runCf(["auth", "login", "--no-browser"]); - expect(observedToken).toBe("file-token"); + expect(sharedAuth.login).not.toHaveBeenCalled(); + expect(std.out).toBe(""); + expect(std.err).toMatchInlineSnapshot( + `"▲ CLOUDFLARE_API_TOKEN is set and takes precedence. Unset it to log in via OAuth."` + ); expect(process.env.CLOUDFLARE_API_TOKEN).toBeUndefined(); }); @@ -213,8 +210,9 @@ describe("cf auth device login", () => { await runCf(["auth", "login", "--no-browser"]); - expect(std.out).toContain("Scopes: one, two, three, four, five"); - expect(std.out).not.toContain("cf auth whoami"); + expect(std.out).toBe(""); + expect(std.err).toContain("Scopes: one, two, three, four, five"); + expect(std.err).not.toContain("cf auth whoami"); }); it("summarizes more than five selected scopes after login", async () => { @@ -224,8 +222,11 @@ describe("cf auth device login", () => { await runCf(["auth", "login", "--no-browser"]); - expect(std.out).toContain("Scopes: 6 selected"); - expect(std.out).toContain("Run cf auth whoami to see the full list."); - expect(std.out).not.toContain("one, two, three, four, five, six"); + expect(std.out).toBe(""); + expect(std.err).toMatchInlineSnapshot(` + "● Starting OAuth login flow... + ● Config file: /test/auth.json + │ Scopes: 6 selected. Run cf auth whoami to see the full list." + `); }); }); diff --git a/packages/cli/src/__tests__/commands/auth/logout.test.ts b/packages/cli/src/__tests__/commands/auth/logout.test.ts new file mode 100644 index 0000000000..718d5285e4 --- /dev/null +++ b/packages/cli/src/__tests__/commands/auth/logout.test.ts @@ -0,0 +1,29 @@ +import { runInTempDir } from "@cloudflare/workers-utils/test-helpers"; +import { describe, expect, it } from "vite-plus/test"; +import { mockOutputStreams } from "../../helpers/capture-output.js"; +import { runCf } from "../../helpers/run-cf.js"; + +describe("cf auth logout", () => { + runInTempDir(); + const std = mockOutputStreams(); + + it("explains how to log in when not logged in", async () => { + await runCf(["auth", "logout"], { CLOUDFLARE_API_TOKEN: undefined }); + + expect(std.out).toBe(""); + expect(std.err).toMatchInlineSnapshot(` + "● You are not currently logged in. To authenticate, run: + + cf auth login" + `); + }); + + it("points at the environment token when only that is set", async () => { + await runCf(["auth", "logout"], { CLOUDFLARE_API_TOKEN: "env-token" }); + + expect(std.out).toBe(""); + expect(std.err).toMatchInlineSnapshot( + `"● No cf OAuth credentials found, but CLOUDFLARE_API_TOKEN is set. Unset it to log out."` + ); + }); +}); diff --git a/packages/cli/src/__tests__/commands/auth/profiles.test.ts b/packages/cli/src/__tests__/commands/auth/profiles.test.ts index 0e7c8b97c4..ec93b86f61 100644 --- a/packages/cli/src/__tests__/commands/auth/profiles.test.ts +++ b/packages/cli/src/__tests__/commands/auth/profiles.test.ts @@ -3,19 +3,16 @@ import { createCfProfileStore, writeAuthConfigFile, } from "@cloudflare/workers-auth/cf"; -import { - mockConsoleMethods, - runInTempDir, -} from "@cloudflare/workers-utils/test-helpers"; +import { runInTempDir } from "@cloudflare/workers-utils/test-helpers"; import { http, HttpResponse } from "msw/http"; import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { + mockOutputStreams, + stubStderrTTY, +} from "../../helpers/capture-output.js"; import { server, setupMsw, TEST_BASE_URL } from "../../helpers/msw.js"; import { runCf } from "../../helpers/run-cf.js"; -function normalizeProfileOutput(output: string): string { - return output.replace(/^(i|ℹ) /gm, "ℹ ").replace(/^(‼|⚠) /gm, "⚠ "); -} - function createProfile(name: string, token = `${name}-token`): void { writeAuthConfigFile( { oauth_token: token, expiration_time: "2999-01-01T00:00:00.000Z" }, @@ -26,7 +23,7 @@ function createProfile(name: string, token = `${name}-token`): void { describe("cf auth profiles", () => { runInTempDir(); setupMsw(); - const std = mockConsoleMethods(); + const std = mockOutputStreams(); const store = createCfProfileStore({ logger: { debug: () => {}, @@ -50,11 +47,13 @@ describe("cf auth profiles", () => { const cwd = process.cwd(); createProfile("work"); + stubStderrTTY(); await runCf(["auth", "activate", "work"]); expect(store.bindings.getProfileForDirectory(cwd)).toBe("work"); - expect(normalizeProfileOutput(std.getAndClearOut())).toMatchInlineSnapshot( - `"ℹ Profile "work" activated for ""."` + expect(std.getAndClearOut()).toBe(""); + expect(std.getAndClearErr()).toContain( + '✓ Profile "work" activated for "".' ); await runCf(["auth", "list"]); @@ -64,12 +63,14 @@ describe("cf auth profiles", () => { JSON.parse(String(vi.mocked(console.log).mock.lastCall?.[0])) ).toEqual([{ name: "work", boundDirectories: [cwd] }]); std.getAndClearOut(); + std.getAndClearErr(); await runCf(["auth", "deactivate"]); expect(store.bindings.getProfileForDirectory(cwd)).toBeUndefined(); - expect(normalizeProfileOutput(std.getAndClearOut())).toMatchInlineSnapshot(` - "ℹ Profile "work" deactivated from "". - → Run cf auth login to set up the default profile, or cf auth create to create a named profile." + expect(std.getAndClearOut()).toBe(""); + expect(std.getAndClearErr()).toMatchInlineSnapshot(` + "✓ Profile "work" deactivated from "". + ● Run cf auth login to set up the default profile, or cf auth create to create a named profile." `); }); @@ -126,18 +127,19 @@ describe("cf auth profiles", () => { expect(store.configs.exists("work")).toBe(false); expect(store.bindings.getBindingsForProfile("work")).toEqual([]); - expect(normalizeProfileOutput(std.out)).toMatchInlineSnapshot(` - "ℹ Removed directory bindings: - - ℹ Profile "work" deleted. - → No active profile for this directory. Run cf auth login to set up the default profile, or cf auth create to create a named profile." + expect(std.out).toBe(""); + expect(std.err).toMatchInlineSnapshot(` + "● Removed directory bindings: + │ + Successfully logged out. + ● No active profile for this directory. Run cf auth login to set up the default profile, or cf auth create to create a named profile." `); }); it("does not duplicate the active profile in whoami output", async () => { await runCf(["auth", "whoami", "--profile", "work"]); - expect(normalizeProfileOutput(std.out)).toMatchInlineSnapshot(` + expect(std.out).toMatchInlineSnapshot(` "{ "authenticated": false, "error": "Not logged in" @@ -181,21 +183,26 @@ describe("cf auth profiles", () => { store.bindings.activate("work", process.cwd()); await runCf(["auth", "login"]); - expect(normalizeProfileOutput(std.getAndClearOut())).toMatchInlineSnapshot(` - "⚠ This directory has profile "work" active. \`cf auth login\` updates the default profile, not "work". - To re-authenticate "work", run \`cf auth create work\`. - ℹ You are already logged in. - Config file: /home/.config/cloudflare/config/default.json - Token expires: 1/1/2999, 12:00:00 AM - → Run cf auth login --force to re-authenticate, or cf auth logout to log out." + expect(std.getAndClearOut()).toBe(""); + expect(std.getAndClearErr()).toMatchInlineSnapshot(` + "▲ This directory has profile "work" active. cf auth login updates the default profile, not "work". To re-authenticate "work", run: + + cf auth create work + + ● You are already logged in. + │ Config file: /home/.config/cloudflare/config/default.json + │ Token expires: 1/1/2999, 12:00:00 AM + │ Run cf auth login --force to re-authenticate, or cf auth logout to log out." `); await runCf(["auth", "logout"]); - expect(normalizeProfileOutput(std.getAndClearOut())).toMatchInlineSnapshot(` - "⚠ This directory has profile "work" active. \`cf auth logout\` removes the default profile's token, not "work". - To delete "work", run \`cf auth delete work\`. - ℹ Logging out... - Removed: OAuth tokens from /home/.config/cloudflare/config/default.json" + expect(std.getAndClearOut()).toBe(""); + expect(std.getAndClearErr()).toMatchInlineSnapshot(` + "▲ This directory has profile "work" active. cf auth logout removes the default profile's token, not "work". To delete "work", run: + + cf auth delete work + + Successfully logged out." `); }); }); diff --git a/packages/cli/src/__tests__/commands/migrate.test.ts b/packages/cli/src/__tests__/commands/migrate.test.ts index abd2bf9ed3..fbb137f3a7 100644 --- a/packages/cli/src/__tests__/commands/migrate.test.ts +++ b/packages/cli/src/__tests__/commands/migrate.test.ts @@ -43,6 +43,7 @@ describe("cf migrate", () => { const result = await runCf(["migrate"]); expect(result.exitCode).toBe(0); + expect(std.out).toBe(""); expect(migrateWranglerToCf).toHaveBeenCalledWith( path.join(process.cwd(), configFile), { @@ -52,12 +53,12 @@ describe("cf migrate", () => { installDependencies: true, } ); - expect(std.out).toContain( + expect(std.err).toContain( "Using the Wrangler bundler because @cloudflare/vite-plugin is not declared." ); - expect(std.out).toContain("Updated 1 file(s):"); - expect(std.out).toContain("cloudflare.config.ts"); - expect(std.out).toContain("Migration complete."); + expect(std.err).toContain("Updated 1 file(s):"); + expect(std.err).toContain("cloudflare.config.ts"); + expect(std.err).toContain("Migration complete."); }); it.each(["dependencies", "devDependencies"])( @@ -76,7 +77,7 @@ describe("cf migrate", () => { path.join(process.cwd(), "wrangler.jsonc"), expect.objectContaining({ bundler: "vite" }) ); - expect(std.out).toContain( + expect(std.err).toContain( "Using the Vite bundler because @cloudflare/vite-plugin is declared." ); } @@ -96,7 +97,7 @@ describe("cf migrate", () => { path.join(process.cwd(), "config/wrangler.toml"), expect.objectContaining({ bundler: "vite" }) ); - expect(std.out).toContain( + expect(std.err).toContain( "Using the Vite bundler because @cloudflare/vite-plugin is declared." ); }); @@ -110,7 +111,7 @@ describe("cf migrate", () => { path.join(process.cwd(), "wrangler.json"), expect.objectContaining({ bundler: "vite" }) ); - expect(std.out).not.toContain("Using the"); + expect(std.err).not.toContain("Using the"); }); it("accepts an explicit config path and migration options", async () => { @@ -140,10 +141,10 @@ describe("cf migrate", () => { installDependencies: false, } ); - expect(std.out).toContain( + expect(std.err).toContain( "Would update 2 file(s):\n├─ config/cloudflare.config.ts\n└─ config/package.json" ); - expect(std.out).toContain("Migration preview complete."); + expect(std.err).toContain("Migration preview complete."); }); it("requires an explicit path when no Wrangler config is found", async () => { @@ -298,9 +299,9 @@ describe("cf migrate", () => { const result = await runCf(["migrate"]); expect(result.exitCode).toBe(1); - expect(std.out).toContain( + expect(std.err).toContain( "Follow-up work:\n└─ [required] durable_objects.migrations: Migrate this setting manually.\n └─ https://developers.cloudflare.com/workers/" ); - expect(std.out).toContain("Migration requires follow-up work."); + expect(std.err).toContain("Migration requires follow-up work."); }); }); diff --git a/packages/cli/src/__tests__/commands/telemetry.test.ts b/packages/cli/src/__tests__/commands/telemetry.test.ts index 0e7e3829e3..57662113c2 100644 --- a/packages/cli/src/__tests__/commands/telemetry.test.ts +++ b/packages/cli/src/__tests__/commands/telemetry.test.ts @@ -1,28 +1,49 @@ -import { - mockConsoleMethods, - runInTempDir, -} from "@cloudflare/workers-utils/test-helpers"; +import { runInTempDir } from "@cloudflare/workers-utils/test-helpers"; import { describe, expect, it } from "vite-plus/test"; +import { mockOutputStreams, stubStderrTTY } from "../helpers/capture-output.js"; import { runCf } from "../helpers/run-cf.js"; describe("cf cli telemetry", () => { runInTempDir(); - const std = mockConsoleMethods(); + const std = mockOutputStreams(); it("reports the opt-out default and updates the stored preference", async () => { await runCf(["cli", "telemetry", "status"]); expect(std.getAndClearOut()).toContain("Status: Enabled"); await runCf(["cli", "telemetry", "disable"]); - expect(std.getAndClearOut()).toContain("Status: Disabled"); - await runCf(["cli", "telemetry", "status"]); expect(std.getAndClearOut()).toContain("Status: Disabled"); await runCf(["cli", "telemetry", "enable"]); + await runCf(["cli", "telemetry", "status"]); expect(std.getAndClearOut()).toContain("Status: Enabled"); }); + it("confirms each change on stderr and keeps stdout empty", async () => { + stubStderrTTY(); + + await runCf(["cli", "telemetry", "disable"]); + expect(std.out).toBe(""); + expect(std.getAndClearErr()).toContain( + "✓ cf is no longer collecting usage telemetry." + ); + + await runCf(["cli", "telemetry", "enable"]); + expect(std.out).toBe(""); + expect(std.getAndClearErr()).toContain( + "✓ cf is now collecting anonymous usage telemetry. Thank you for helping make cf better." + ); + }); + + it("prints nothing when a change succeeds outside a terminal", async () => { + await runCf(["cli", "telemetry", "disable"]); + await runCf(["cli", "telemetry", "enable"]); + + expect(std.out).toBe(""); + expect(std.err).toBe(""); + }); + it("shows when the environment overrides stored state", async () => { await runCf(["cli", "telemetry", "status"], { CF_SEND_TELEMETRY: "false", @@ -32,29 +53,32 @@ describe("cf cli telemetry", () => { ); }); - it("reports an environment override after enabling telemetry", async () => { + it("warns when the environment overrides enabling telemetry", async () => { await runCf(["cli", "telemetry", "enable"], { CF_SEND_TELEMETRY: "false", }); - const output = std.getAndClearOut(); - expect(output).toContain("Status: Disabled (set by CF_SEND_TELEMETRY)"); - expect(output).toContain("CF_SEND_TELEMETRY=false overrides this setting"); + expect(std.out).toBe(""); + expect(std.err).toMatchInlineSnapshot( + `"▲ cf is not collecting usage telemetry because CF_SEND_TELEMETRY=false overrides this setting."` + ); }); - it("reports an environment override after disabling telemetry", async () => { + it("warns when the environment overrides disabling telemetry", async () => { await runCf(["cli", "telemetry", "disable"], { CF_SEND_TELEMETRY: "true", }); - const output = std.getAndClearOut(); - expect(output).toContain("Status: Enabled (set by CF_SEND_TELEMETRY)"); - expect(output).toContain("CF_SEND_TELEMETRY=true overrides this setting"); + expect(std.out).toBe(""); + expect(std.err).toMatchInlineSnapshot( + `"▲ cf is collecting usage telemetry because CF_SEND_TELEMETRY=true overrides this setting."` + ); }); - it("shows DO_NOT_TRACK as the effective source after enabling", async () => { + it("names DO_NOT_TRACK when it overrides enabling telemetry", async () => { await runCf(["cli", "telemetry", "enable"], { DO_NOT_TRACK: "1" }); - const output = std.getAndClearOut(); - expect(output).toContain("Status: Disabled (set by DO_NOT_TRACK)"); - expect(output).toContain("DO_NOT_TRACK overrides this setting"); + expect(std.out).toBe(""); + expect(std.err).toMatchInlineSnapshot( + `"▲ cf is not collecting usage telemetry because DO_NOT_TRACK overrides this setting."` + ); }); it("shows the Wrangler environment alias as the source", async () => { @@ -66,14 +90,13 @@ describe("cf cli telemetry", () => { ); }); - it("reports the alias when it overrides enabling telemetry", async () => { + it("names the alias when it overrides enabling telemetry", async () => { await runCf(["cli", "telemetry", "enable"], { WRANGLER_SEND_METRICS: "false", }); - const output = std.getAndClearOut(); - expect(output).toContain("Status: Disabled (set by WRANGLER_SEND_METRICS)"); - expect(output).toContain( - "WRANGLER_SEND_METRICS=false overrides this setting" + expect(std.out).toBe(""); + expect(std.err).toMatchInlineSnapshot( + `"▲ cf is not collecting usage telemetry because WRANGLER_SEND_METRICS=false overrides this setting."` ); }); }); diff --git a/packages/cli/src/commands/auth/login.ts b/packages/cli/src/commands/auth/login.ts index 5d6bef3554..4a73acb4b4 100644 --- a/packages/cli/src/commands/auth/login.ts +++ b/packages/cli/src/commands/auth/login.ts @@ -1,3 +1,4 @@ +import { getAuthFromEnv } from "@cloudflare/workers-auth"; import { getActiveProfile, getConfigPath, @@ -7,7 +8,8 @@ import { readAuthCredentials, setProfile, } from "../../lib/oauth/index.js"; -import { hint, info, theme, warning } from "../../lib/ui/index.js"; +import { theme } from "../../lib/ui/index.js"; +import { log, printCommands } from "../../lib/ui/log.js"; import { assertNoProfileFlag } from "./profiles.js"; import type { CommonYargsOptions } from "../../lib/cli-types.js"; import type { ArgClassification } from "../../lib/telemetry/index.js"; @@ -70,12 +72,10 @@ const loginCommand: CommandModule = { const activeProfile = getActiveProfile(); if (activeProfile !== "default") { - console.log( - warning( - `This directory has profile "${activeProfile}" active. \`cf auth login\` updates the default profile, not "${activeProfile}".\n` + - `To re-authenticate "${activeProfile}", run \`cf auth create ${activeProfile}\`.` - ) + log.warn( + `This directory has profile "${activeProfile}" active. ${theme.code("cf auth login")} updates the default profile, not "${activeProfile}". To re-authenticate "${activeProfile}", run:` ); + printCommands(`cf auth create ${activeProfile}`); } setProfile("default"); @@ -85,36 +85,42 @@ const loginCommand: CommandModule = { const validToken = await getValidToken(); if (validToken) { const state = readAuthCredentials(); - console.log(info("You are already logged in.")); - console.log( - `${theme.info("Config file:")} ${theme.muted(getConfigPath())}` - ); + const lines = [ + "You are already logged in.", + `${theme.info("Config file:")} ${theme.muted(getConfigPath())}`, + ]; if (state?.expiration_time) { - console.log( + lines.push( `${theme.info("Token expires:")} ${new Date( state.expiration_time ).toLocaleString()}` ); } - console.log( - hint( - `Run ${theme.code("cf auth login --force")} to re-authenticate, or ${theme.code("cf auth logout")} to log out.` - ) + lines.push( + `Run ${theme.code("cf auth login --force")} to re-authenticate, or ${theme.code("cf auth logout")} to log out.` ); + log.info(lines.join("\n")); return; } // Token refresh failed — proceed with a fresh login. - console.log( - warning( - "Existing tokens are invalid or expired. Starting fresh login...\n" - ) + log.warn( + "Existing tokens are invalid or expired. Starting fresh login..." ); } else if (argv.force && isOAuthLoggedIn()) { - console.log(info("Re-authenticating...\n")); + log.info("Re-authenticating..."); + } + + // The OAuth flow refuses to start while env credentials are present, + // so say so once instead of starting it. + if (getAuthFromEnv({ allowGlobalAuthKey: false }) !== undefined) { + log.warn( + `${theme.code("CLOUDFLARE_API_TOKEN")} is set and takes precedence. Unset it to log in via OAuth.` + ); + return; } - console.log(info("Starting OAuth login flow...\n")); + log.info("Starting OAuth login flow..."); const ok = await login({ browser: argv.browser, @@ -123,36 +129,28 @@ const loginCommand: CommandModule = { profile: "default", }); if (!ok) { - // Env credentials are present; the OAuth flow refuses to start. - console.log( - warning( - "CLOUDFLARE_API_TOKEN is set and takes precedence. Unset it to log in via OAuth." - ) - ); return; } const state = readAuthCredentials(); - console.log( - `${theme.info("Config file:")} ${theme.muted(getConfigPath())}` - ); + const lines = [ + `${theme.info("Config file:")} ${theme.muted(getConfigPath())}`, + ]; if (state?.scopes) { - if (state.scopes.length > MAX_INLINE_SCOPES) { - console.log(`${theme.info("Scopes:")} ${state.scopes.length} selected`); - console.log( - hint(`Run ${theme.code("cf auth whoami")} to see the full list.`) - ); - } else { - console.log(`${theme.info("Scopes:")} ${state.scopes.join(", ")}`); - } + lines.push( + state.scopes.length > MAX_INLINE_SCOPES + ? `${theme.info("Scopes:")} ${state.scopes.length} selected. Run ${theme.code("cf auth whoami")} to see the full list.` + : `${theme.info("Scopes:")} ${state.scopes.join(", ")}` + ); } if (state?.expiration_time) { - console.log( + lines.push( `${theme.info("Token expires:")} ${new Date( state.expiration_time ).toLocaleString()}` ); } + log.info(lines.join("\n")); }, }; diff --git a/packages/cli/src/commands/auth/logout.ts b/packages/cli/src/commands/auth/logout.ts index 2514fc06d6..8f1c49f076 100644 --- a/packages/cli/src/commands/auth/logout.ts +++ b/packages/cli/src/commands/auth/logout.ts @@ -5,7 +5,8 @@ import { logout, setProfile, } from "../../lib/oauth/index.js"; -import { hint, info, theme, warning } from "../../lib/ui/index.js"; +import { theme } from "../../lib/ui/index.js"; +import { log, printCommands, printSuccess } from "../../lib/ui/log.js"; import { assertNoProfileFlag } from "./profiles.js"; import type { CommonYargsOptions } from "../../lib/cli-types.js"; import type { CommandModule } from "yargs"; @@ -24,12 +25,10 @@ const logoutCommand: CommandModule = { const activeProfile = getActiveProfile(); if (activeProfile !== "default") { - console.log( - warning( - `This directory has profile "${activeProfile}" active. \`cf auth logout\` removes the default profile's token, not "${activeProfile}".\n` + - `To delete "${activeProfile}", run \`cf auth delete ${activeProfile}\`.` - ) + log.warn( + `This directory has profile "${activeProfile}" active. ${theme.code("cf auth logout")} removes the default profile's token, not "${activeProfile}". To delete "${activeProfile}", run:` ); + printCommands(`cf auth delete ${activeProfile}`); } setProfile("default"); @@ -39,38 +38,23 @@ const logoutCommand: CommandModule = { if (!hasOAuthTokens) { if (hasEnvToken) { - console.log( - info( - "No cf OAuth credentials found, but CLOUDFLARE_API_TOKEN environment variable is set." - ) + log.info( + `No cf OAuth credentials found, but ${theme.code("CLOUDFLARE_API_TOKEN")} is set. Unset it to log out.` ); - console.log(info("Unset it with: unset CLOUDFLARE_API_TOKEN")); } else { - console.log(info("You are not currently logged in.")); - console.log( - hint(`Run ${theme.code("cf auth login")} to authenticate.`) - ); + log.info("You are not currently logged in. To authenticate, run:"); + printCommands("cf auth login"); } return; } - console.log(info("Logging out...")); - await logout("default"); - console.log( - `${theme.info("Removed:")} OAuth tokens from ${theme.muted(getConfigPath())}` - ); + printSuccess(`Removed OAuth tokens from ${theme.muted(getConfigPath())}`); if (hasEnvToken) { - console.log(""); - console.log( - warning("CLOUDFLARE_API_TOKEN environment variable is still set.") - ); - console.log( - info( - "You will remain authenticated via the env var. Unset it with: unset CLOUDFLARE_API_TOKEN" - ) + log.warn( + `${theme.code("CLOUDFLARE_API_TOKEN")} is still set, so you remain authenticated. Unset it to log out.` ); } }, diff --git a/packages/cli/src/commands/auth/profiles.ts b/packages/cli/src/commands/auth/profiles.ts index 668d7ef450..d7aeab3f6d 100644 --- a/packages/cli/src/commands/auth/profiles.ts +++ b/packages/cli/src/commands/auth/profiles.ts @@ -2,7 +2,8 @@ import path from "node:path"; import { getAuthFromEnv, validateProfileName } from "@cloudflare/workers-auth"; import { getProfileStore, login, logout } from "../../lib/oauth/index.js"; import { formatOutput } from "../../lib/output.js"; -import { hint, info, theme } from "../../lib/ui/index.js"; +import { theme } from "../../lib/ui/index.js"; +import { log, printCommands, printSuccess } from "../../lib/ui/log.js"; import type { CommonYargsOptions } from "../../lib/cli-types.js"; import type { ArgumentsCamelCase, Argv, CommandModule } from "yargs"; @@ -98,18 +99,13 @@ export const createCommand: CommandModule = { profile: argv.name, }); - console.log( - info( - isUpdate - ? `Profile "${argv.name}" re-authenticated.` - : `Profile "${argv.name}" created.` - ) - ); - console.log( - hint( - `Run ${theme.code(`cf auth activate ${argv.name}`)} to use this profile in a directory.` - ) + printSuccess( + isUpdate + ? `Profile "${argv.name}" re-authenticated.` + : `Profile "${argv.name}" created.` ); + log.info("To use this profile in a directory, run:"); + printCommands(`cf auth activate ${argv.name}`); }, }; @@ -140,30 +136,28 @@ export const deleteCommand: CommandModule = { argv.name ); if (removedBindings.length > 0) { - console.log(info("Removed directory bindings:")); - for (const dir of removedBindings) { - console.log(` ${dir}`); - } + log.info( + [ + "Removed directory bindings:", + ...removedBindings.map((dir) => ` ${dir}`), + ].join("\n") + ); } await logout(argv.name); profiles.configs.delete(argv.name); - console.log(info(`Profile "${argv.name}" deleted.`)); + printSuccess(`Profile "${argv.name}" deleted.`); const currentProfile = profiles.bindings.getProfileForDirectory( process.cwd() ); if (currentProfile) { - console.log( - info(`Active profile for this directory: ${currentProfile}.`) - ); + log.info(`Active profile for this directory: ${currentProfile}.`); } else if (profiles.configs.exists("default")) { - console.log(info("This directory now uses the default profile.")); + log.info("This directory now uses the default profile."); } else { - console.log( - hint( - `No active profile for this directory. Run ${theme.code("cf auth login")} to set up the default profile, or ${theme.code("cf auth create ")} to create a named profile.` - ) + log.info( + `No active profile for this directory. Run ${theme.code("cf auth login")} to set up the default profile, or ${theme.code("cf auth create ")} to create a named profile.` ); } }, @@ -204,8 +198,8 @@ export const activateCommand: CommandModule = { const targetDir = argv.dir ?? process.cwd(); profiles.bindings.activate(argv.name, targetDir); - console.log( - info(`Profile "${argv.name}" activated for "${path.resolve(targetDir)}".`) + printSuccess( + `Profile "${argv.name}" activated for "${path.resolve(targetDir)}".` ); }, }; @@ -230,22 +224,18 @@ export const deactivateCommand: CommandModule = { const { removedProfile, newResolution } = profiles.bindings.deactivate(targetDir); - console.log( - info( - `Profile "${removedProfile}" deactivated from "${path.resolve(targetDir)}".` - ) + printSuccess( + `Profile "${removedProfile}" deactivated from "${path.resolve(targetDir)}".` ); if (newResolution.profile === undefined) { - console.log( - hint( - `Run ${theme.code("cf auth login")} to set up the default profile, or ${theme.code("cf auth create ")} to create a named profile.` - ) + log.info( + `Run ${theme.code("cf auth login")} to set up the default profile, or ${theme.code("cf auth create ")} to create a named profile.` ); } else if (newResolution.profile === "default") { - console.log(info("This directory now uses the default profile.")); + log.info("This directory now uses the default profile."); } else { - console.log( - info(`Now using: ${newResolution.profile} (${newResolution.source}).`) + log.info( + `Now using: ${newResolution.profile} (${newResolution.source}).` ); } }, diff --git a/packages/cli/src/commands/login/index.ts b/packages/cli/src/commands/login/index.ts index e24867d9a6..4f7c503907 100644 --- a/packages/cli/src/commands/login/index.ts +++ b/packages/cli/src/commands/login/index.ts @@ -1,5 +1,6 @@ import { withTelemetry } from "../../lib/telemetry/index.js"; -import { info, theme } from "../../lib/ui/index.js"; +import { theme } from "../../lib/ui/index.js"; +import { log } from "../../lib/ui/log.js"; import loginCommand, { loginTelemetryClassification } from "../auth/login.js"; /** @@ -11,10 +12,8 @@ const loginAliasCommand: typeof loginCommand = { describe: false, handler: async (argv) => { if (!argv.quiet) { - console.error( - info( - `${theme.code("cf login")} is an alias for ${theme.code("cf auth login")}. Authentication commands are under ${theme.code("cf auth")}.` - ) + log.info( + `${theme.code("cf login")} is an alias for ${theme.code("cf auth login")}. Authentication commands are under ${theme.code("cf auth")}.` ); } await loginCommand.handler(argv); diff --git a/packages/cli/src/commands/migrate/index.ts b/packages/cli/src/commands/migrate/index.ts index 69dccbb8bf..5553e8dfa4 100644 --- a/packages/cli/src/commands/migrate/index.ts +++ b/packages/cli/src/commands/migrate/index.ts @@ -38,7 +38,7 @@ type MigrateArgs = InferArgs; type MigrationBundler = NonNullable; function printDetectedBundler(bundler: MigrationBundler): void { - console.log( + console.error( theme.muted( bundler === "vite" ? "Using the Vite bundler because @cloudflare/vite-plugin is declared. Pass --bundler wrangler to override." @@ -63,6 +63,7 @@ const migrateCommand: CommandModule = { dryRun: args["dry-run"], force: args.force, installDependencies: args.install, + output: "stderr", }); }, }; diff --git a/packages/cli/src/commands/telemetry/disable.ts b/packages/cli/src/commands/telemetry/disable.ts index ebeb66f98e..3c6caa82b8 100644 --- a/packages/cli/src/commands/telemetry/disable.ts +++ b/packages/cli/src/commands/telemetry/disable.ts @@ -3,7 +3,8 @@ import { resolveTelemetry, setTelemetryPermission, } from "../../lib/telemetry/config.js"; -import { telemetryStatusLine } from "./shared.js"; +import { theme } from "../../lib/ui/index.js"; +import { log, printSuccess } from "../../lib/ui/log.js"; import type { CommandModule } from "yargs"; const command: CommandModule = { @@ -12,12 +13,14 @@ const command: CommandModule = { handler: () => { setTelemetryPermission(false); const { enabled, source } = resolveTelemetry(); - console.log(telemetryStatusLine({ enabled, source })); - console.log( - enabled - ? `cf is collecting usage telemetry because ${getTelemetrySourceLabel(source)}=true overrides this setting.\n` - : "cf is no longer collecting usage telemetry.\n" - ); + if (enabled) { + const override = `${getTelemetrySourceLabel(source)}=true`; + log.warn( + `cf is collecting usage telemetry because ${theme.code(override)} overrides this setting.` + ); + } else { + printSuccess("cf is no longer collecting usage telemetry."); + } }, }; diff --git a/packages/cli/src/commands/telemetry/enable.ts b/packages/cli/src/commands/telemetry/enable.ts index e712e56f8f..84b6f2b84e 100644 --- a/packages/cli/src/commands/telemetry/enable.ts +++ b/packages/cli/src/commands/telemetry/enable.ts @@ -3,7 +3,8 @@ import { resolveTelemetry, setTelemetryPermission, } from "../../lib/telemetry/config.js"; -import { telemetryStatusLine } from "./shared.js"; +import { theme } from "../../lib/ui/index.js"; +import { log, printSuccess } from "../../lib/ui/log.js"; import type { CommandModule } from "yargs"; const command: CommandModule = { @@ -12,12 +13,16 @@ const command: CommandModule = { handler: () => { setTelemetryPermission(true); const { enabled, source } = resolveTelemetry(); - console.log(telemetryStatusLine({ enabled, source })); - console.log( - enabled - ? "cf is now collecting anonymous usage telemetry. Thank you for helping make cf better.\n" - : `cf is not collecting usage telemetry because ${getTelemetrySourceLabel(source)}${source === "env" || source === "wrangler-env" ? "=false" : ""} overrides this setting.\n` - ); + if (enabled) { + printSuccess( + "cf is now collecting anonymous usage telemetry. Thank you for helping make cf better." + ); + } else { + const override = `${getTelemetrySourceLabel(source)}${source === "env" || source === "wrangler-env" ? "=false" : ""}`; + log.warn( + `cf is not collecting usage telemetry because ${theme.code(override)} overrides this setting.` + ); + } }, }; diff --git a/packages/cli/src/lib/ui/format.ts b/packages/cli/src/lib/ui/format.ts index d56d9ca0bc..6aa604b06d 100644 --- a/packages/cli/src/lib/ui/format.ts +++ b/packages/cli/src/lib/ui/format.ts @@ -33,17 +33,6 @@ export function warning(message: string): string { return `${theme.warning(symbols.warning)} ${message}`; } -/** - * Format an info message with info symbol. - * - * @example - * console.log(info('Use --help for more options')); - * // ℹ Use --help for more options - */ -export function info(message: string): string { - return `${theme.info(symbols.info)} ${message}`; -} - /** * Format a hint/tip message with arrow prefix. * diff --git a/packages/cli/src/lib/ui/index.ts b/packages/cli/src/lib/ui/index.ts index 6ae6c2e1e7..ebc10bf496 100644 --- a/packages/cli/src/lib/ui/index.ts +++ b/packages/cli/src/lib/ui/index.ts @@ -8,5 +8,5 @@ export { formatCommand, renderPromptIntro } from "./banner.js"; export { errorBlock } from "./blocks.js"; -export { hint, info, success, warning } from "./format.js"; +export { hint, success, warning } from "./format.js"; export { colorPalette, supportsColor, symbols, theme } from "./theme.js"; From d448ce455e8d885598d222a9bdc3075e7af5fccb Mon Sep 17 00:00:00 2001 From: "Noah (Nguyen Pham)" Date: Mon, 19 Oct 2026 13:12:34 +0100 Subject: [PATCH 03/15] fix: check for an env token before stored OAuth state in cf auth login With CLOUDFLARE_API_TOKEN set, login printed messages about stored or expired OAuth tokens before refusing. Warn about the token first. --- packages/cli/src/commands/auth/login.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/cli/src/commands/auth/login.ts b/packages/cli/src/commands/auth/login.ts index 4a73acb4b4..5b9e400fb7 100644 --- a/packages/cli/src/commands/auth/login.ts +++ b/packages/cli/src/commands/auth/login.ts @@ -80,6 +80,15 @@ const loginCommand: CommandModule = { setProfile("default"); + // Env credentials take precedence over OAuth, and the OAuth flow refuses + // to start while they are set. + if (getAuthFromEnv({ allowGlobalAuthKey: false }) !== undefined) { + log.warn( + `${theme.code("CLOUDFLARE_API_TOKEN")} is set and takes precedence. Unset it to log in via OAuth.` + ); + return; + } + if (!argv.force && isOAuthLoggedIn()) { // Verify the stored tokens actually work, refreshing if needed. const validToken = await getValidToken(); @@ -111,15 +120,6 @@ const loginCommand: CommandModule = { log.info("Re-authenticating..."); } - // The OAuth flow refuses to start while env credentials are present, - // so say so once instead of starting it. - if (getAuthFromEnv({ allowGlobalAuthKey: false }) !== undefined) { - log.warn( - `${theme.code("CLOUDFLARE_API_TOKEN")} is set and takes precedence. Unset it to log in via OAuth.` - ); - return; - } - log.info("Starting OAuth login flow..."); const ok = await login({ From dcf08d081b2d60e92ada5afa01d698b8ced0211e Mon Sep 17 00:00:00 2001 From: "Noah (Nguyen Pham)" Date: Mon, 19 Oct 2026 13:12:34 +0100 Subject: [PATCH 04/15] refactor: keep clack off the success line and pin stderr in tests printSuccess moves to lib/output.ts so commands that only print a success line do not load @clack/prompts. mockOutputStreams treats stderr as a non-terminal unless a test opts in, and cf auth create gains a test. --- AGENTS.md | 9 +++-- .../commands/auth/device-login.test.ts | 12 ++++++ .../__tests__/commands/auth/profiles.test.ts | 7 +--- .../src/__tests__/commands/telemetry.test.ts | 4 +- .../src/__tests__/helpers/capture-output.ts | 40 +++++++++++-------- packages/cli/src/commands/auth/logout.ts | 3 +- packages/cli/src/commands/auth/profiles.ts | 4 +- .../cli/src/commands/telemetry/disable.ts | 3 +- packages/cli/src/commands/telemetry/enable.ts | 3 +- packages/cli/src/lib/output.ts | 11 ++++- packages/cli/src/lib/ui/log.ts | 18 +++------ 11 files changed, 68 insertions(+), 46 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 37cd298fca..5c5d20ed57 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -248,7 +248,7 @@ the complete generated and hand-written `_meta/commands.json` catalogue (see | `request-headers.ts` | User-Agent plus `X-CF-CLI-Mode` / detected-agent headers; authentication is owned by the API client/raw-fetch/auth layers | | `dry-run.ts` | `--dry-run` body-preview formatting (`bodyKind: 'json' \| 'multipart' \| 'octet-stream' \| 'none'`) | | `errors.ts` | `handleError`; APIError box; status-level hints (no per-code knowledge in `src/`) | -| `output.ts` | `formatOutput` (silent on null; `✓ ` on TTY stderr); `--quiet` knob | +| `output.ts` | `formatOutput` (silent on null; `✓ ` on TTY stderr via `printSuccess`); `--quiet` knob | | `progress.ts` | `withProgress(label, task)` clack braille/timer spinner; active only with color + stdout TTY and no `CF_QUIET=1` | | `prompt.ts` | required text/enum/acknowledgement prompts; sensitivity is explicit OpenAPI/forge metadata, not a name regex; secret-only stdin ingestion; destructive confirmation | | `session.ts` | `openSession(version)` — idempotent once-per-process banner emission at interactive invocation startup; prompt helpers call it defensively | @@ -270,7 +270,7 @@ the complete generated and hand-written `_meta/commands.json` catalogue (see | `ui/banner.ts` | slim `renderPromptIntro(version)` headline and `formatCommand`; the old gradient/ASCII splash is removed | | `ui/theme.ts` | background-neutral semantic colour palette and Chalk wrapper | | `ui/format.ts` | one-line `success`, `warning`, and `hint` helpers | -| `ui/log.ts` | stderr messages: clack `log.info` / `log.warn`, `printSuccess` (`✓` line), `printCommands` (copyable next steps) | +| `ui/log.ts` | stderr messages: clack `log.info` / `log.warn` and `printCommands` (copyable next steps); not re-exported from `ui/index.ts` | | `input-validation.ts` | yargs-time arg coercion + `resolveFileToken(value, fieldName, format)` for `@` ingestion; runtime body/path validation | | `resolve.ts` | URL templating (path-param substitution), `isId`, `isValidDomainName` | @@ -653,8 +653,9 @@ These have stabilised this repo's voice; preserve them. - **Success line:** `✓ ` on stderr; null/undefined API results suppress stdout output entirely. - **Messages:** stdout carries only command output. Hand-written commands - print notices, warnings and success lines to stderr through `lib/ui/log.ts`, - with a command to run next on its own line (`printCommands`). + print notices and warnings to stderr through `lib/ui/log.ts` and success + lines through `printSuccess`. A next step or fix goes on its own line + (`printCommands`); other commands in a message are styled as code. - **Splash screen:** bare `cf` prints the headline, a short getting-started list, and the docs link. Yargs owns root, group, and command help rendering. ## Global Flags diff --git a/packages/cli/src/__tests__/commands/auth/device-login.test.ts b/packages/cli/src/__tests__/commands/auth/device-login.test.ts index f443018c74..3321c61d82 100644 --- a/packages/cli/src/__tests__/commands/auth/device-login.test.ts +++ b/packages/cli/src/__tests__/commands/auth/device-login.test.ts @@ -155,6 +155,18 @@ describe("cf auth device login", () => { ); }); + it("confirms a new named profile and shows how to activate it", async () => { + std.setStderrTTY(true); + + await runCf(["auth", "create", "work", "--no-browser"]); + + expect(std.out).toBe(""); + expect(std.err).toContain('✓ Profile "work" created.'); + expect(std.err).toContain( + "● To use this profile in a directory, run:\n\n cf auth activate work" + ); + }); + it("can use the localhost callback flow for named profiles", async () => { await runCf([ "auth", diff --git a/packages/cli/src/__tests__/commands/auth/profiles.test.ts b/packages/cli/src/__tests__/commands/auth/profiles.test.ts index ec93b86f61..f448b0a371 100644 --- a/packages/cli/src/__tests__/commands/auth/profiles.test.ts +++ b/packages/cli/src/__tests__/commands/auth/profiles.test.ts @@ -6,10 +6,7 @@ import { import { runInTempDir } from "@cloudflare/workers-utils/test-helpers"; import { http, HttpResponse } from "msw/http"; import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; -import { - mockOutputStreams, - stubStderrTTY, -} from "../../helpers/capture-output.js"; +import { mockOutputStreams } from "../../helpers/capture-output.js"; import { server, setupMsw, TEST_BASE_URL } from "../../helpers/msw.js"; import { runCf } from "../../helpers/run-cf.js"; @@ -47,7 +44,7 @@ describe("cf auth profiles", () => { const cwd = process.cwd(); createProfile("work"); - stubStderrTTY(); + std.setStderrTTY(true); await runCf(["auth", "activate", "work"]); expect(store.bindings.getProfileForDirectory(cwd)).toBe("work"); diff --git a/packages/cli/src/__tests__/commands/telemetry.test.ts b/packages/cli/src/__tests__/commands/telemetry.test.ts index 57662113c2..51bba32e16 100644 --- a/packages/cli/src/__tests__/commands/telemetry.test.ts +++ b/packages/cli/src/__tests__/commands/telemetry.test.ts @@ -1,6 +1,6 @@ import { runInTempDir } from "@cloudflare/workers-utils/test-helpers"; import { describe, expect, it } from "vite-plus/test"; -import { mockOutputStreams, stubStderrTTY } from "../helpers/capture-output.js"; +import { mockOutputStreams } from "../helpers/capture-output.js"; import { runCf } from "../helpers/run-cf.js"; describe("cf cli telemetry", () => { @@ -21,7 +21,7 @@ describe("cf cli telemetry", () => { }); it("confirms each change on stderr and keeps stdout empty", async () => { - stubStderrTTY(); + std.setStderrTTY(true); await runCf(["cli", "telemetry", "disable"]); expect(std.out).toBe(""); diff --git a/packages/cli/src/__tests__/helpers/capture-output.ts b/packages/cli/src/__tests__/helpers/capture-output.ts index 1b7531f87a..fb38536aca 100644 --- a/packages/cli/src/__tests__/helpers/capture-output.ts +++ b/packages/cli/src/__tests__/helpers/capture-output.ts @@ -1,6 +1,6 @@ import { format } from "node:util"; import { normalizeString } from "@cloudflare/workers-utils/test-helpers"; -import { afterEach, beforeEach, onTestFinished, vi } from "vite-plus/test"; +import { afterEach, beforeEach, vi } from "vite-plus/test"; /** * Capture a command's output streams. @@ -37,16 +37,27 @@ export function captureOutput(): { * raw payloads) share one buffer per stream, so a test can assert that * stdout holds only the command's output. Call at `describe` level; output * is normalized the same way as `mockConsoleMethods`. + * + * stderr is not a terminal unless a test calls `setStderrTTY(true)`, so + * terminal-only output does not depend on how the suite was launched. */ export function mockOutputStreams(): { readonly out: string; readonly err: string; getAndClearOut: () => string; getAndClearErr: () => string; + setStderrTTY: (isTTY: boolean) => void; } { let out = ""; let err = ""; let spies: { mockRestore: () => void }[] = []; + let originalStderrTTY: PropertyDescriptor | undefined; + const setStderrTTY = (isTTY: boolean) => { + Object.defineProperty(process.stderr, "isTTY", { + configurable: true, + value: isTTY, + }); + }; const toOut = (...args: unknown[]) => { out += `${format(...args)}\n`; }; @@ -57,6 +68,11 @@ export function mockOutputStreams(): { beforeEach(() => { out = ""; err = ""; + originalStderrTTY = Object.getOwnPropertyDescriptor( + process.stderr, + "isTTY" + ); + setStderrTTY(false); spies = [ vi.spyOn(console, "log").mockImplementation(toOut), vi.spyOn(console, "info").mockImplementation(toOut), @@ -77,6 +93,11 @@ export function mockOutputStreams(): { for (const spy of spies) { spy.mockRestore(); } + if (originalStderrTTY) { + Object.defineProperty(process.stderr, "isTTY", originalStderrTTY); + } else { + Reflect.deleteProperty(process.stderr, "isTTY"); + } }); const read = (buffer: string) => normalizeString(buffer).replace(/\n+$/, ""); @@ -97,21 +118,6 @@ export function mockOutputStreams(): { err = ""; return value; }, + setStderrTTY, }; } - -/** Treat stderr as a terminal for the rest of the test. */ -export function stubStderrTTY(): void { - const original = Object.getOwnPropertyDescriptor(process.stderr, "isTTY"); - Object.defineProperty(process.stderr, "isTTY", { - configurable: true, - value: true, - }); - onTestFinished(() => { - if (original) { - Object.defineProperty(process.stderr, "isTTY", original); - } else { - Reflect.deleteProperty(process.stderr, "isTTY"); - } - }); -} diff --git a/packages/cli/src/commands/auth/logout.ts b/packages/cli/src/commands/auth/logout.ts index 8f1c49f076..a9ee7dd6dd 100644 --- a/packages/cli/src/commands/auth/logout.ts +++ b/packages/cli/src/commands/auth/logout.ts @@ -5,8 +5,9 @@ import { logout, setProfile, } from "../../lib/oauth/index.js"; +import { printSuccess } from "../../lib/output.js"; import { theme } from "../../lib/ui/index.js"; -import { log, printCommands, printSuccess } from "../../lib/ui/log.js"; +import { log, printCommands } from "../../lib/ui/log.js"; import { assertNoProfileFlag } from "./profiles.js"; import type { CommonYargsOptions } from "../../lib/cli-types.js"; import type { CommandModule } from "yargs"; diff --git a/packages/cli/src/commands/auth/profiles.ts b/packages/cli/src/commands/auth/profiles.ts index d7aeab3f6d..9fa4704de2 100644 --- a/packages/cli/src/commands/auth/profiles.ts +++ b/packages/cli/src/commands/auth/profiles.ts @@ -1,9 +1,9 @@ import path from "node:path"; import { getAuthFromEnv, validateProfileName } from "@cloudflare/workers-auth"; import { getProfileStore, login, logout } from "../../lib/oauth/index.js"; -import { formatOutput } from "../../lib/output.js"; +import { formatOutput, printSuccess } from "../../lib/output.js"; import { theme } from "../../lib/ui/index.js"; -import { log, printCommands, printSuccess } from "../../lib/ui/log.js"; +import { log, printCommands } from "../../lib/ui/log.js"; import type { CommonYargsOptions } from "../../lib/cli-types.js"; import type { ArgumentsCamelCase, Argv, CommandModule } from "yargs"; diff --git a/packages/cli/src/commands/telemetry/disable.ts b/packages/cli/src/commands/telemetry/disable.ts index 3c6caa82b8..370d296ede 100644 --- a/packages/cli/src/commands/telemetry/disable.ts +++ b/packages/cli/src/commands/telemetry/disable.ts @@ -1,10 +1,11 @@ +import { printSuccess } from "../../lib/output.js"; import { getTelemetrySourceLabel, resolveTelemetry, setTelemetryPermission, } from "../../lib/telemetry/config.js"; import { theme } from "../../lib/ui/index.js"; -import { log, printSuccess } from "../../lib/ui/log.js"; +import { log } from "../../lib/ui/log.js"; import type { CommandModule } from "yargs"; const command: CommandModule = { diff --git a/packages/cli/src/commands/telemetry/enable.ts b/packages/cli/src/commands/telemetry/enable.ts index 84b6f2b84e..7b1b774b8f 100644 --- a/packages/cli/src/commands/telemetry/enable.ts +++ b/packages/cli/src/commands/telemetry/enable.ts @@ -1,10 +1,11 @@ +import { printSuccess } from "../../lib/output.js"; import { getTelemetrySourceLabel, resolveTelemetry, setTelemetryPermission, } from "../../lib/telemetry/config.js"; import { theme } from "../../lib/ui/index.js"; -import { log, printSuccess } from "../../lib/ui/log.js"; +import { log } from "../../lib/ui/log.js"; import type { CommandModule } from "yargs"; const command: CommandModule = { diff --git a/packages/cli/src/lib/output.ts b/packages/cli/src/lib/output.ts index 6fadee1969..80f6b3a611 100644 --- a/packages/cli/src/lib/output.ts +++ b/packages/cli/src/lib/output.ts @@ -1,5 +1,4 @@ import { isNonInteractiveOrCI } from "./interactive.js"; -import { printSuccess } from "./ui/log.js"; import { supportsColor, theme } from "./ui/theme.js"; /** Maximum JSON size (in characters) for syntax highlighting - prevents performance issues */ @@ -24,6 +23,16 @@ function outputPayload(data: unknown): unknown { return data; } +/** + * The `✓` line confirming that a command finished. It prints only when + * stderr is a terminal; scripts read the exit code instead. + */ +export function printSuccess(message: string): void { + if (process.stderr.isTTY) { + process.stderr.write(`${theme.success("✓")} ${message}\n`); + } +} + /** * Format and output data as JSON. * diff --git a/packages/cli/src/lib/ui/log.ts b/packages/cli/src/lib/ui/log.ts index 8985bb8525..1c6eb62df9 100644 --- a/packages/cli/src/lib/ui/log.ts +++ b/packages/cli/src/lib/ui/log.ts @@ -2,8 +2,12 @@ * Messages for people. * * stdout carries only a command's output (JSON or a raw payload), so every - * message goes to stderr: clack log lines for notices and warnings, the `✓` - * success line, and commands for the user to run next. + * message goes to stderr: clack log lines for notices and warnings, and + * commands for the user to run next. The `✓` success line is `printSuccess` + * in `../output.ts`. + * + * Not re-exported from `./index.ts`: the entry point imports that barrel, and + * loading clack there would slow every invocation. */ import * as clack from "@clack/prompts"; @@ -25,16 +29,6 @@ export const log = { clack.log.warn(message, toStderr(options)), }; -/** - * The `✓` line confirming that a command finished. It prints only when - * stderr is a terminal; scripts read the exit code instead. - */ -export function printSuccess(message: string): void { - if (process.stderr.isTTY) { - process.stderr.write(`${theme.success("✓")} ${message}\n`); - } -} - /** * Commands for the user to run next, each on its own line. They are indented * to the guide's text column without its glyphs, so copying one from the From 8135082d368f5546141320000b5f9f362bb76a02 Mon Sep 17 00:00:00 2001 From: "Noah (Nguyen Pham)" Date: Mon, 19 Oct 2026 15:14:10 +0100 Subject: [PATCH 05/15] test: cover the remaining auth message branches and check migrate stdout --- .../commands/auth/device-login.test.ts | 36 ++++++++++++++- .../__tests__/commands/auth/logout.test.ts | 45 ++++++++++++++++++- .../__tests__/commands/auth/profiles.test.ts | 31 +++++++++++++ .../src/__tests__/commands/migrate.test.ts | 11 +++-- 4 files changed, 115 insertions(+), 8 deletions(-) diff --git a/packages/cli/src/__tests__/commands/auth/device-login.test.ts b/packages/cli/src/__tests__/commands/auth/device-login.test.ts index 3321c61d82..dbfc51401a 100644 --- a/packages/cli/src/__tests__/commands/auth/device-login.test.ts +++ b/packages/cli/src/__tests__/commands/auth/device-login.test.ts @@ -6,7 +6,9 @@ import { runCf } from "../../helpers/run-cf.js"; const sharedAuth = vi.hoisted(() => ({ activeProfile: "default", - credentials: undefined as { scopes: string[] } | undefined, + credentials: undefined as + | { oauth_token?: string; scopes: string[] } + | undefined, login: vi.fn(), })); @@ -23,6 +25,8 @@ vi.mock("@cloudflare/workers-auth/cf", async (importOriginal) => ({ getActiveProfile: () => sharedAuth.activeProfile, getCredentialStore: () => ({ path: () => "/test/auth.json" }), readAuthCredentials: () => sharedAuth.credentials, + // Stored tokens never refresh, so they read as invalid or expired. + getOAuthTokenFromLocalState: async () => undefined, login: sharedAuth.login, }), })); @@ -241,4 +245,34 @@ describe("cf auth device login", () => { │ Scopes: 6 selected. Run cf auth whoami to see the full list." `); }); + + it("starts a fresh login when the stored tokens are invalid", async () => { + sharedAuth.credentials = { oauth_token: "expired", scopes: ["one"] }; + + await runCf(["auth", "login", "--no-browser"]); + + expect(sharedAuth.login).toHaveBeenCalledOnce(); + expect(std.out).toBe(""); + expect(std.err).toMatchInlineSnapshot(` + "▲ Existing tokens are invalid or expired. Starting fresh login... + ● Starting OAuth login flow... + ● Config file: /test/auth.json + │ Scopes: one" + `); + }); + + it("re-authenticates with --force when already logged in", async () => { + sharedAuth.credentials = { oauth_token: "token", scopes: ["one"] }; + + await runCf(["auth", "login", "--force", "--no-browser"]); + + expect(sharedAuth.login).toHaveBeenCalledOnce(); + expect(std.out).toBe(""); + expect(std.err).toMatchInlineSnapshot(` + "● Re-authenticating... + ● Starting OAuth login flow... + ● Config file: /test/auth.json + │ Scopes: one" + `); + }); }); diff --git a/packages/cli/src/__tests__/commands/auth/logout.test.ts b/packages/cli/src/__tests__/commands/auth/logout.test.ts index 718d5285e4..a859b544c2 100644 --- a/packages/cli/src/__tests__/commands/auth/logout.test.ts +++ b/packages/cli/src/__tests__/commands/auth/logout.test.ts @@ -1,12 +1,32 @@ +import { writeAuthConfigFile } from "@cloudflare/workers-auth/cf"; import { runInTempDir } from "@cloudflare/workers-utils/test-helpers"; -import { describe, expect, it } from "vite-plus/test"; +import { http, HttpResponse } from "msw/http"; +import { beforeEach, describe, expect, it } from "vite-plus/test"; import { mockOutputStreams } from "../../helpers/capture-output.js"; +import { server, setupMsw } from "../../helpers/msw.js"; import { runCf } from "../../helpers/run-cf.js"; +function createDefaultProfile(): void { + writeAuthConfigFile( + { oauth_token: "token", expiration_time: "2999-01-01T00:00:00.000Z" }, + "default" + ); +} + describe("cf auth logout", () => { runInTempDir(); + setupMsw(); const std = mockOutputStreams(); + beforeEach(() => { + server.use( + http.post( + "https://dash.cloudflare.com/oauth2/revoke", + () => new HttpResponse("") + ) + ); + }); + it("explains how to log in when not logged in", async () => { await runCf(["auth", "logout"], { CLOUDFLARE_API_TOKEN: undefined }); @@ -26,4 +46,27 @@ describe("cf auth logout", () => { `"● No cf OAuth credentials found, but CLOUDFLARE_API_TOKEN is set. Unset it to log out."` ); }); + + it("confirms which file the OAuth tokens were removed from", async () => { + createDefaultProfile(); + std.setStderrTTY(true); + + await runCf(["auth", "logout"], { CLOUDFLARE_API_TOKEN: undefined }); + + expect(std.out).toBe(""); + expect(std.err).toContain( + "✓ Removed OAuth tokens from /home/.config/cloudflare/config/default.json" + ); + }); + + it("warns that the environment token still authenticates after logout", async () => { + createDefaultProfile(); + + await runCf(["auth", "logout"], { CLOUDFLARE_API_TOKEN: "env-token" }); + + expect(std.out).toBe(""); + expect(std.err).toContain( + "▲ CLOUDFLARE_API_TOKEN is still set, so you remain authenticated. Unset it to log out." + ); + }); }); diff --git a/packages/cli/src/__tests__/commands/auth/profiles.test.ts b/packages/cli/src/__tests__/commands/auth/profiles.test.ts index f448b0a371..3bce6dd62e 100644 --- a/packages/cli/src/__tests__/commands/auth/profiles.test.ts +++ b/packages/cli/src/__tests__/commands/auth/profiles.test.ts @@ -133,6 +133,37 @@ describe("cf auth profiles", () => { `); }); + it("keeps the directory's active profile when deleting another", async () => { + createProfile("work"); + createProfile("other"); + store.bindings.activate("work", process.cwd()); + + await runCf(["auth", "delete", "other"]); + + expect(store.configs.exists("other")).toBe(false); + expect(std.out).toBe(""); + expect(std.err).toMatchInlineSnapshot(` + "Successfully logged out. + ● Active profile for this directory: work." + `); + }); + + it("falls back to the default profile after deactivating", async () => { + createProfile("default"); + createProfile("work"); + store.bindings.activate("work", process.cwd()); + + await runCf(["auth", "deactivate"]); + + expect( + store.bindings.getProfileForDirectory(process.cwd()) + ).toBeUndefined(); + expect(std.out).toBe(""); + expect(std.err).toMatchInlineSnapshot( + `"● This directory now uses the default profile."` + ); + }); + it("does not duplicate the active profile in whoami output", async () => { await runCf(["auth", "whoami", "--profile", "work"]); diff --git a/packages/cli/src/__tests__/commands/migrate.test.ts b/packages/cli/src/__tests__/commands/migrate.test.ts index fbb137f3a7..831a8f2cce 100644 --- a/packages/cli/src/__tests__/commands/migrate.test.ts +++ b/packages/cli/src/__tests__/commands/migrate.test.ts @@ -1,14 +1,11 @@ import path from "node:path"; -import { - mockConsoleMethods, - runInTempDir, - seed, -} from "@cloudflare/workers-utils/test-helpers"; +import { runInTempDir, seed } from "@cloudflare/workers-utils/test-helpers"; import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; import { findWranglerConfig, maybeMigrateWranglerProject, } from "../../lib/wrangler-migration.js"; +import { mockOutputStreams } from "../helpers/capture-output.js"; import { runCf } from "../helpers/run-cf.js"; const { migrateWranglerToCf } = vi.hoisted(() => ({ @@ -22,7 +19,7 @@ vi.mock("@cloudflare/codemods", () => ({ describe("cf migrate", () => { runInTempDir(); - const std = mockConsoleMethods(); + const std = mockOutputStreams(); beforeEach(() => { migrateWranglerToCf.mockReset(); @@ -141,6 +138,7 @@ describe("cf migrate", () => { installDependencies: false, } ); + expect(std.out).toBe(""); expect(std.err).toContain( "Would update 2 file(s):\n├─ config/cloudflare.config.ts\n└─ config/package.json" ); @@ -299,6 +297,7 @@ describe("cf migrate", () => { const result = await runCf(["migrate"]); expect(result.exitCode).toBe(1); + expect(std.out).toBe(""); expect(std.err).toContain( "Follow-up work:\n└─ [required] durable_objects.migrations: Migrate this setting manually.\n └─ https://developers.cloudflare.com/workers/" ); From 0a1ab35dedf1075a59f5cecb9c8d063fb710ae77 Mon Sep 17 00:00:00 2001 From: "Noah (Nguyen Pham)" Date: Mon, 19 Oct 2026 15:14:10 +0100 Subject: [PATCH 06/15] docs: note the success line is terminal-only and phrase the message rule as guidance --- .changeset/hand-written-messages-stderr.md | 9 +++++---- AGENTS.md | 6 +++--- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/.changeset/hand-written-messages-stderr.md b/.changeset/hand-written-messages-stderr.md index 9bd09d64c6..491b1614f9 100644 --- a/.changeset/hand-written-messages-stderr.md +++ b/.changeset/hand-written-messages-stderr.md @@ -7,7 +7,8 @@ Write hand-written command messages to stderr Breaking change during the beta: `cf auth login`, `cf auth logout`, the auth profile commands, `cf cli telemetry enable` / `disable` and `cf migrate` now print their notices, warnings and success messages on stderr, so redirected or -piped stdout holds only command output, as with generated commands. The -messages use clack's log lines and the `✓` success line, and a command to run -next is printed on its own line so it copies cleanly. `cf auth login` with -`CLOUDFLARE_API_TOKEN` set now prints a single warning. +piped stdout holds only command output, as with generated commands. The auth +and telemetry commands use clack's log lines and the `✓` success line, which, +as with generated commands, appears only when stderr is a terminal. A command +to run next is printed on its own line so it copies cleanly. `cf auth login` +with `CLOUDFLARE_API_TOKEN` set now prints a single warning. diff --git a/AGENTS.md b/AGENTS.md index 5c5d20ed57..90541d66dc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -652,9 +652,9 @@ These have stabilised this repo's voice; preserve them. - **Secret prompt:** clack.password (masks input). - **Success line:** `✓ ` on stderr; null/undefined API results suppress stdout output entirely. -- **Messages:** stdout carries only command output. Hand-written commands - print notices and warnings to stderr through `lib/ui/log.ts` and success - lines through `printSuccess`. A next step or fix goes on its own line +- **Messages:** stdout carries only command output; every message goes to + stderr. Use `lib/ui/log.ts` for notices and warnings and `printSuccess` + for success lines. A next step or fix goes on its own line (`printCommands`); other commands in a message are styled as code. - **Splash screen:** bare `cf` prints the headline, a short getting-started list, and the docs link. Yargs owns root, group, and command help rendering. From 30276b5b7a07036486fe192d729e5a55a82c5f11 Mon Sep 17 00:00:00 2001 From: "Noah (Nguyen Pham)" Date: Mon, 19 Oct 2026 15:18:05 +0100 Subject: [PATCH 07/15] fix: don't report removed OAuth tokens when cf auth logout keeps them --- .changeset/hand-written-messages-stderr.md | 4 +++- .../cli/src/__tests__/commands/auth/logout.test.ts | 12 +++++++++--- packages/cli/src/commands/auth/logout.ts | 14 ++++++++------ 3 files changed, 20 insertions(+), 10 deletions(-) diff --git a/.changeset/hand-written-messages-stderr.md b/.changeset/hand-written-messages-stderr.md index 491b1614f9..60c7061acb 100644 --- a/.changeset/hand-written-messages-stderr.md +++ b/.changeset/hand-written-messages-stderr.md @@ -11,4 +11,6 @@ piped stdout holds only command output, as with generated commands. The auth and telemetry commands use clack's log lines and the `✓` success line, which, as with generated commands, appears only when stderr is a terminal. A command to run next is printed on its own line so it copies cleanly. `cf auth login` -with `CLOUDFLARE_API_TOKEN` set now prints a single warning. +with `CLOUDFLARE_API_TOKEN` set now prints a single warning, and `cf auth +logout` no longer reports removing OAuth tokens that it keeps while +`CLOUDFLARE_API_TOKEN` is set. diff --git a/packages/cli/src/__tests__/commands/auth/logout.test.ts b/packages/cli/src/__tests__/commands/auth/logout.test.ts index a859b544c2..ee10957d73 100644 --- a/packages/cli/src/__tests__/commands/auth/logout.test.ts +++ b/packages/cli/src/__tests__/commands/auth/logout.test.ts @@ -1,4 +1,7 @@ -import { writeAuthConfigFile } from "@cloudflare/workers-auth/cf"; +import { + readAuthConfigFile, + writeAuthConfigFile, +} from "@cloudflare/workers-auth/cf"; import { runInTempDir } from "@cloudflare/workers-utils/test-helpers"; import { http, HttpResponse } from "msw/http"; import { beforeEach, describe, expect, it } from "vite-plus/test"; @@ -59,14 +62,17 @@ describe("cf auth logout", () => { ); }); - it("warns that the environment token still authenticates after logout", async () => { + it("keeps the OAuth tokens while the environment token is set", async () => { createDefaultProfile(); + std.setStderrTTY(true); await runCf(["auth", "logout"], { CLOUDFLARE_API_TOKEN: "env-token" }); + expect(readAuthConfigFile("default")?.oauth_token).toBe("token"); expect(std.out).toBe(""); expect(std.err).toContain( - "▲ CLOUDFLARE_API_TOKEN is still set, so you remain authenticated. Unset it to log out." + "Unset the CLOUDFLARE_API_TOKEN in the environment to log out." ); + expect(std.err).not.toContain("✓"); }); }); diff --git a/packages/cli/src/commands/auth/logout.ts b/packages/cli/src/commands/auth/logout.ts index a9ee7dd6dd..8a3a2f3de9 100644 --- a/packages/cli/src/commands/auth/logout.ts +++ b/packages/cli/src/commands/auth/logout.ts @@ -1,3 +1,4 @@ +import { getAuthFromEnv } from "@cloudflare/workers-auth"; import { getActiveProfile, getConfigPath, @@ -35,7 +36,8 @@ const logoutCommand: CommandModule = { setProfile("default"); const hasOAuthTokens = isOAuthLoggedIn(); - const hasEnvToken = !!process.env.CLOUDFLARE_API_TOKEN; + const hasEnvToken = + getAuthFromEnv({ allowGlobalAuthKey: false }) !== undefined; if (!hasOAuthTokens) { if (hasEnvToken) { @@ -51,13 +53,13 @@ const logoutCommand: CommandModule = { await logout("default"); - printSuccess(`Removed OAuth tokens from ${theme.muted(getConfigPath())}`); - + // workers-auth keeps the stored tokens, and says why, while env + // credentials are set. if (hasEnvToken) { - log.warn( - `${theme.code("CLOUDFLARE_API_TOKEN")} is still set, so you remain authenticated. Unset it to log out.` - ); + return; } + + printSuccess(`Removed OAuth tokens from ${theme.muted(getConfigPath())}`); }, }; From 8f1c8dbf580376ffd35235e67d586f185d904d7d Mon Sep 17 00:00:00 2001 From: "Noah (Nguyen Pham)" Date: Mon, 19 Oct 2026 15:21:41 +0100 Subject: [PATCH 08/15] test: expect empty stdout from auth commands in the Wrangler compatibility tests --- .../wrangler-tests/src/__tests__/logout.test.ts | 14 ++++++-------- .../src/__tests__/profiles.test.ts | 16 ++++++++-------- 2 files changed, 14 insertions(+), 16 deletions(-) diff --git a/packages/wrangler-tests/src/__tests__/logout.test.ts b/packages/wrangler-tests/src/__tests__/logout.test.ts index 2772b5f8b2..75c12b2cdd 100644 --- a/packages/wrangler-tests/src/__tests__/logout.test.ts +++ b/packages/wrangler-tests/src/__tests__/logout.test.ts @@ -35,19 +35,17 @@ describe("logout", () => { expect, }) => { await runWrangler("auth logout", { CLOUDFLARE_API_TOKEN: undefined }); - expect(std.out).toContain("You are not currently logged in."); + // cf prints the hint on stderr through clack, which this suite mocks. + expect(std.out).toBe(""); }); // Env `CLOUDFLARE_API_TOKEN` set with no on-disk credentials in the - // stubbed HOME: cf prints the env-var hint and exits cleanly. + // stubbed HOME: cf prints the env-var hint on stderr and exits cleanly. it("should exit with a message stating the user logged in via API token", async ({ expect, }) => { await runWrangler("auth logout", { CLOUDFLARE_API_TOKEN: "DUMMY_TOKEN" }); - expect(std.out).toContain( - "No cf OAuth credentials found, but CLOUDFLARE_API_TOKEN environment variable is set." - ); - expect(std.out).toContain("Unset it with: unset CLOUDFLARE_API_TOKEN"); + expect(std.out).toBe(""); }); // Ported now that token paths resolve via `homedir()` at call time @@ -88,8 +86,8 @@ describe("logout", () => { await runWrangler("auth logout", { CLOUDFLARE_API_TOKEN: undefined }); - // cf reports the credential kind and path that it removed. - expect(std.out).toContain("Removed: OAuth tokens from"); + // cf confirms the removal on stderr, and only in a terminal. + expect(std.out).toBe(""); // The refresh token was sent to the revocation endpoint. expect(revoked).toBe(true); // And the on-disk token file was removed. diff --git a/packages/wrangler-tests/src/__tests__/profiles.test.ts b/packages/wrangler-tests/src/__tests__/profiles.test.ts index f5075b058e..c60a0667f4 100644 --- a/packages/wrangler-tests/src/__tests__/profiles.test.ts +++ b/packages/wrangler-tests/src/__tests__/profiles.test.ts @@ -208,9 +208,7 @@ describe("Profiles", () => { expect(profiles().configs.exists("client-a")).toBe(false); expect(profiles().bindings.getBindingsForProfile("client-a")).toEqual([]); - expect(std.out).toContain("Removed directory bindings:"); - expect(std.out).toContain("projects/client-a"); - expect(std.out).toContain("projects/client-a-v2"); + expect(std.out).toBe(""); }); it("deletes a profile file when logout returns before clearing storage", async ({ @@ -219,7 +217,7 @@ describe("Profiles", () => { createDeprecatedProfileFile("client-a"); await runWrangler("auth delete client-a"); expect(profiles().configs.exists("client-a")).toBe(false); - expect(std.out).toContain('Profile "client-a" deleted.'); + expect(std.out).toBe(""); }); it("falls back to ancestor profile after deleting a child profile", async ({ @@ -340,7 +338,7 @@ describe("Profiles", () => { expect( profiles().bindings.getProfileForDirectory(process.cwd()) ).toBeUndefined(); - expect(std.out).toContain("This directory now uses the default profile."); + expect(std.out).toBe(""); }); it("shows no active profile when logged out globally", async ({ @@ -349,9 +347,11 @@ describe("Profiles", () => { createProfileFile("client-a"); profiles().bindings.activate("client-a", process.cwd()); await runWrangler("auth deactivate"); - expect(std.out).toContain( - "Run cf auth login to set up the default profile" - ); + expect( + profiles().bindings.getProfileForDirectory(process.cwd()) + ).toBeUndefined(); + expect(profiles().configs.exists("default")).toBe(false); + expect(std.out).toBe(""); }); }); From 8720d9d888d717b48e2494138e9cbc9fc53df551 Mon Sep 17 00:00:00 2001 From: "Noah (Nguyen Pham)" Date: Mon, 19 Oct 2026 15:22:33 +0100 Subject: [PATCH 09/15] docs: say which warning cf auth login now prints once --- .changeset/hand-written-messages-stderr.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.changeset/hand-written-messages-stderr.md b/.changeset/hand-written-messages-stderr.md index 60c7061acb..ea84a4c818 100644 --- a/.changeset/hand-written-messages-stderr.md +++ b/.changeset/hand-written-messages-stderr.md @@ -11,6 +11,6 @@ piped stdout holds only command output, as with generated commands. The auth and telemetry commands use clack's log lines and the `✓` success line, which, as with generated commands, appears only when stderr is a terminal. A command to run next is printed on its own line so it copies cleanly. `cf auth login` -with `CLOUDFLARE_API_TOKEN` set now prints a single warning, and `cf auth -logout` no longer reports removing OAuth tokens that it keeps while -`CLOUDFLARE_API_TOKEN` is set. +with `CLOUDFLARE_API_TOKEN` set now warns once that the token takes +precedence, and `cf auth logout` no longer reports removing OAuth tokens that +it keeps while `CLOUDFLARE_API_TOKEN` is set. From 9a82bbce16cd366d5583b85e84d49381aeb5c287 Mon Sep 17 00:00:00 2001 From: "Noah (Nguyen Pham)" Date: Mon, 19 Oct 2026 15:42:54 +0100 Subject: [PATCH 10/15] docs: note which output has not moved to stderr yet --- AGENTS.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 90541d66dc..aed4fe580c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -656,6 +656,8 @@ These have stabilised this repo's voice; preserve them. stderr. Use `lib/ui/log.ts` for notices and warnings and `printSuccess` for success lines. A next step or fix goes on its own line (`printCommands`); other commands in a message are styled as code. + Prompts, spinners and the init, build and deploy flows still write to + stdout until they move. - **Splash screen:** bare `cf` prints the headline, a short getting-started list, and the docs link. Yargs owns root, group, and command help rendering. ## Global Flags From 9a58fb01b04c8f2587498fd8bb252dbc82f206e8 Mon Sep 17 00:00:00 2001 From: "Noah (Nguyen Pham)" Date: Mon, 19 Oct 2026 17:45:12 +0100 Subject: [PATCH 11/15] test: assert auth messages on stderr in the Wrangler compatibility tests --- .../src/__tests__/helpers/mock-clack-log.ts | 27 +++++++++++++++++++ .../src/__tests__/logout.test.ts | 9 +++++-- .../src/__tests__/profiles.test.ts | 14 +++++++--- 3 files changed, 44 insertions(+), 6 deletions(-) create mode 100644 packages/wrangler-tests/src/__tests__/helpers/mock-clack-log.ts diff --git a/packages/wrangler-tests/src/__tests__/helpers/mock-clack-log.ts b/packages/wrangler-tests/src/__tests__/helpers/mock-clack-log.ts new file mode 100644 index 0000000000..5059b0f729 --- /dev/null +++ b/packages/wrangler-tests/src/__tests__/helpers/mock-clack-log.ts @@ -0,0 +1,27 @@ +import { log } from "@clack/prompts"; +import { afterEach, beforeEach, vi } from "vite-plus/test"; + +/** + * The clack mock drops log lines. Record the ones cf sends to stderr through + * console.error, so a test can assert them on `std.err`. + * + * Call at `describe` level, after `mockConsoleMethods()`. + */ +export function mockClackStderrLog(): void { + let spies: { mockRestore: () => void }[] = []; + + beforeEach(() => { + spies = (["info", "warn"] as const).map((method) => + vi.spyOn(log, method).mockImplementation((message, options) => { + if (options?.output === process.stderr) { + console.error(message); + } + }) + ); + }); + afterEach(() => { + for (const spy of spies) { + spy.mockRestore(); + } + }); +} diff --git a/packages/wrangler-tests/src/__tests__/logout.test.ts b/packages/wrangler-tests/src/__tests__/logout.test.ts index 75c12b2cdd..65a68b8b0f 100644 --- a/packages/wrangler-tests/src/__tests__/logout.test.ts +++ b/packages/wrangler-tests/src/__tests__/logout.test.ts @@ -3,6 +3,7 @@ import { dirname, join } from "node:path"; import { getGlobalConfigPath } from "@cloudflare/workers-utils"; import { http, HttpResponse } from "msw/http"; import { describe, it } from "vite-plus/test"; +import { mockClackStderrLog } from "./helpers/mock-clack-log"; import { mockConsoleMethods } from "./helpers/mock-console"; import { msw } from "./helpers/msw"; import { runInTempDir } from "./helpers/run-in-tmp"; @@ -27,6 +28,7 @@ function cfAuthFilePath(): string { describe("logout", () => { runInTempDir(); const std = mockConsoleMethods(); + mockClackStderrLog(); // cf's `loadTokens()` / `loadApiToken()` now resolve their paths via // `homedir()` at call time, so `runInTempDir()`'s HOME stub correctly @@ -35,8 +37,8 @@ describe("logout", () => { expect, }) => { await runWrangler("auth logout", { CLOUDFLARE_API_TOKEN: undefined }); - // cf prints the hint on stderr through clack, which this suite mocks. expect(std.out).toBe(""); + expect(std.err).toContain("You are not currently logged in."); }); // Env `CLOUDFLARE_API_TOKEN` set with no on-disk credentials in the @@ -46,6 +48,9 @@ describe("logout", () => { }) => { await runWrangler("auth logout", { CLOUDFLARE_API_TOKEN: "DUMMY_TOKEN" }); expect(std.out).toBe(""); + expect(std.err).toContain( + "No cf OAuth credentials found, but CLOUDFLARE_API_TOKEN is set. Unset it to log out." + ); }); // Ported now that token paths resolve via `homedir()` at call time @@ -86,8 +91,8 @@ describe("logout", () => { await runWrangler("auth logout", { CLOUDFLARE_API_TOKEN: undefined }); - // cf confirms the removal on stderr, and only in a terminal. expect(std.out).toBe(""); + expect(std.err).toContain("Successfully logged out."); // The refresh token was sent to the revocation endpoint. expect(revoked).toBe(true); // And the on-disk token file was removed. diff --git a/packages/wrangler-tests/src/__tests__/profiles.test.ts b/packages/wrangler-tests/src/__tests__/profiles.test.ts index c60a0667f4..6fce66cf41 100644 --- a/packages/wrangler-tests/src/__tests__/profiles.test.ts +++ b/packages/wrangler-tests/src/__tests__/profiles.test.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { http, HttpResponse } from "msw/http"; import { beforeEach, describe, it, vi } from "vite-plus/test"; import { getProfileStore } from "../../../cli/src/lib/oauth/index"; +import { mockClackStderrLog } from "./helpers/mock-clack-log"; import { mockConsoleMethods } from "./helpers/mock-console"; import { msw } from "./helpers/msw"; import { runInTempDir } from "./helpers/run-in-tmp"; @@ -42,6 +43,7 @@ function createDeprecatedProfileFile(name: string): void { describe("Profiles", () => { runInTempDir(); const std = mockConsoleMethods(); + mockClackStderrLog(); beforeEach(() => { // Profile management is unavailable when environment credentials are @@ -209,6 +211,9 @@ describe("Profiles", () => { expect(profiles().configs.exists("client-a")).toBe(false); expect(profiles().bindings.getBindingsForProfile("client-a")).toEqual([]); expect(std.out).toBe(""); + expect(std.err).toContain("Removed directory bindings:"); + expect(std.err).toContain("projects/client-a"); + expect(std.err).toContain("projects/client-a-v2"); }); it("deletes a profile file when logout returns before clearing storage", async ({ @@ -218,6 +223,7 @@ describe("Profiles", () => { await runWrangler("auth delete client-a"); expect(profiles().configs.exists("client-a")).toBe(false); expect(std.out).toBe(""); + expect(std.err).toContain("No active profile for this directory."); }); it("falls back to ancestor profile after deleting a child profile", async ({ @@ -339,6 +345,7 @@ describe("Profiles", () => { profiles().bindings.getProfileForDirectory(process.cwd()) ).toBeUndefined(); expect(std.out).toBe(""); + expect(std.err).toContain("This directory now uses the default profile."); }); it("shows no active profile when logged out globally", async ({ @@ -347,11 +354,10 @@ describe("Profiles", () => { createProfileFile("client-a"); profiles().bindings.activate("client-a", process.cwd()); await runWrangler("auth deactivate"); - expect( - profiles().bindings.getProfileForDirectory(process.cwd()) - ).toBeUndefined(); - expect(profiles().configs.exists("default")).toBe(false); expect(std.out).toBe(""); + expect(std.err).toContain( + "Run cf auth login to set up the default profile" + ); }); }); From 87f1d413321d02a66aae146babe705f4914a2b8b Mon Sep 17 00:00:00 2001 From: "Noah (Nguyen Pham)" Date: Mon, 19 Oct 2026 17:45:13 +0100 Subject: [PATCH 12/15] docs: link the workers-auth logout behaviour cf relies on --- packages/cli/src/commands/auth/logout.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/commands/auth/logout.ts b/packages/cli/src/commands/auth/logout.ts index 8a3a2f3de9..02c96d1af8 100644 --- a/packages/cli/src/commands/auth/logout.ts +++ b/packages/cli/src/commands/auth/logout.ts @@ -54,7 +54,8 @@ const logoutCommand: CommandModule = { await logout("default"); // workers-auth keeps the stored tokens, and says why, while env - // credentials are set. + // credentials are set. See `logout()` in + // https://github.com/cloudflare/workers-sdk/blob/%40cloudflare/workers-auth%400.14.0/packages/workers-auth/src/flow.ts if (hasEnvToken) { return; } From 22e164f2a1b2bb07054eb4334dfd85f78b44bf68 Mon Sep 17 00:00:00 2001 From: "Noah (Nguyen Pham)" Date: Mon, 19 Oct 2026 18:06:08 +0100 Subject: [PATCH 13/15] docs: drop the version-pinned workers-auth link from the logout comment --- packages/cli/src/commands/auth/logout.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/cli/src/commands/auth/logout.ts b/packages/cli/src/commands/auth/logout.ts index 02c96d1af8..8a3a2f3de9 100644 --- a/packages/cli/src/commands/auth/logout.ts +++ b/packages/cli/src/commands/auth/logout.ts @@ -54,8 +54,7 @@ const logoutCommand: CommandModule = { await logout("default"); // workers-auth keeps the stored tokens, and says why, while env - // credentials are set. See `logout()` in - // https://github.com/cloudflare/workers-sdk/blob/%40cloudflare/workers-auth%400.14.0/packages/workers-auth/src/flow.ts + // credentials are set. if (hasEnvToken) { return; } From 05eb49219902105e23a919c3a92cff8a7e4c29c4 Mon Sep 17 00:00:00 2001 From: "Noah (Nguyen Pham)" Date: Tue, 20 Oct 2026 12:13:29 +0100 Subject: [PATCH 14/15] test: expect the keyring login notice on stderr --- .../src/__tests__/commands/auth/keyring-login.test.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/__tests__/commands/auth/keyring-login.test.ts b/packages/cli/src/__tests__/commands/auth/keyring-login.test.ts index a15c542647..cfe97909c9 100644 --- a/packages/cli/src/__tests__/commands/auth/keyring-login.test.ts +++ b/packages/cli/src/__tests__/commands/auth/keyring-login.test.ts @@ -2,11 +2,9 @@ import { readUserPreferences, updateUserPreferences, } from "@cloudflare/workers-auth/cf"; -import { - mockConsoleMethods, - runInTempDir, -} from "@cloudflare/workers-utils/test-helpers"; +import { runInTempDir } from "@cloudflare/workers-utils/test-helpers"; import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { mockOutputStreams } from "../../helpers/capture-output.js"; import { runCf } from "../../helpers/run-cf.js"; const sharedAuth = vi.hoisted(() => ({ @@ -46,7 +44,7 @@ vi.mock("../../../lib/telemetry/dispatcher.js", () => ({ describe("cf auth login keyring preference", () => { runInTempDir(); - const std = mockConsoleMethods(); + const std = mockOutputStreams(); const credentials = { oauth_token: "test-token" }; const encryptedStore = { kind: "encrypted-file", path: () => "auth.enc" }; @@ -80,7 +78,8 @@ describe("cf auth login keyring preference", () => { expect(readUserPreferences().keyring_enabled).toBe(true); expect(sharedAuth.login).not.toHaveBeenCalled(); - expect(std.out).toContain("You are already logged in."); + expect(std.out).toBe(""); + expect(std.err).toContain("You are already logged in."); } ); From 971c883372c1aa301e7735cff6b4b41b6828397e Mon Sep 17 00:00:00 2001 From: "Noah (Nguyen Pham)" Date: Mon, 19 Oct 2026 18:21:39 +0100 Subject: [PATCH 15/15] test: drop the clack log helper from the Wrangler compatibility tests --- .../src/__tests__/helpers/mock-clack-log.ts | 27 ------------------- .../src/__tests__/logout.test.ts | 8 ++---- .../src/__tests__/profiles.test.ts | 16 +++++------ 3 files changed, 8 insertions(+), 43 deletions(-) delete mode 100644 packages/wrangler-tests/src/__tests__/helpers/mock-clack-log.ts diff --git a/packages/wrangler-tests/src/__tests__/helpers/mock-clack-log.ts b/packages/wrangler-tests/src/__tests__/helpers/mock-clack-log.ts deleted file mode 100644 index 5059b0f729..0000000000 --- a/packages/wrangler-tests/src/__tests__/helpers/mock-clack-log.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { log } from "@clack/prompts"; -import { afterEach, beforeEach, vi } from "vite-plus/test"; - -/** - * The clack mock drops log lines. Record the ones cf sends to stderr through - * console.error, so a test can assert them on `std.err`. - * - * Call at `describe` level, after `mockConsoleMethods()`. - */ -export function mockClackStderrLog(): void { - let spies: { mockRestore: () => void }[] = []; - - beforeEach(() => { - spies = (["info", "warn"] as const).map((method) => - vi.spyOn(log, method).mockImplementation((message, options) => { - if (options?.output === process.stderr) { - console.error(message); - } - }) - ); - }); - afterEach(() => { - for (const spy of spies) { - spy.mockRestore(); - } - }); -} diff --git a/packages/wrangler-tests/src/__tests__/logout.test.ts b/packages/wrangler-tests/src/__tests__/logout.test.ts index 65a68b8b0f..f9726313e4 100644 --- a/packages/wrangler-tests/src/__tests__/logout.test.ts +++ b/packages/wrangler-tests/src/__tests__/logout.test.ts @@ -3,7 +3,6 @@ import { dirname, join } from "node:path"; import { getGlobalConfigPath } from "@cloudflare/workers-utils"; import { http, HttpResponse } from "msw/http"; import { describe, it } from "vite-plus/test"; -import { mockClackStderrLog } from "./helpers/mock-clack-log"; import { mockConsoleMethods } from "./helpers/mock-console"; import { msw } from "./helpers/msw"; import { runInTempDir } from "./helpers/run-in-tmp"; @@ -28,7 +27,8 @@ function cfAuthFilePath(): string { describe("logout", () => { runInTempDir(); const std = mockConsoleMethods(); - mockClackStderrLog(); + // The clack mock drops log lines, so cf's own messages can't be asserted + // here. The cf package's auth tests check them with real clack. // cf's `loadTokens()` / `loadApiToken()` now resolve their paths via // `homedir()` at call time, so `runInTempDir()`'s HOME stub correctly @@ -38,7 +38,6 @@ describe("logout", () => { }) => { await runWrangler("auth logout", { CLOUDFLARE_API_TOKEN: undefined }); expect(std.out).toBe(""); - expect(std.err).toContain("You are not currently logged in."); }); // Env `CLOUDFLARE_API_TOKEN` set with no on-disk credentials in the @@ -48,9 +47,6 @@ describe("logout", () => { }) => { await runWrangler("auth logout", { CLOUDFLARE_API_TOKEN: "DUMMY_TOKEN" }); expect(std.out).toBe(""); - expect(std.err).toContain( - "No cf OAuth credentials found, but CLOUDFLARE_API_TOKEN is set. Unset it to log out." - ); }); // Ported now that token paths resolve via `homedir()` at call time diff --git a/packages/wrangler-tests/src/__tests__/profiles.test.ts b/packages/wrangler-tests/src/__tests__/profiles.test.ts index 6fce66cf41..5fc5779c3a 100644 --- a/packages/wrangler-tests/src/__tests__/profiles.test.ts +++ b/packages/wrangler-tests/src/__tests__/profiles.test.ts @@ -4,7 +4,6 @@ import path from "node:path"; import { http, HttpResponse } from "msw/http"; import { beforeEach, describe, it, vi } from "vite-plus/test"; import { getProfileStore } from "../../../cli/src/lib/oauth/index"; -import { mockClackStderrLog } from "./helpers/mock-clack-log"; import { mockConsoleMethods } from "./helpers/mock-console"; import { msw } from "./helpers/msw"; import { runInTempDir } from "./helpers/run-in-tmp"; @@ -43,7 +42,8 @@ function createDeprecatedProfileFile(name: string): void { describe("Profiles", () => { runInTempDir(); const std = mockConsoleMethods(); - mockClackStderrLog(); + // The clack mock drops log lines, so cf's own messages can't be asserted + // here. The cf package's auth tests check them with real clack. beforeEach(() => { // Profile management is unavailable when environment credentials are @@ -211,9 +211,6 @@ describe("Profiles", () => { expect(profiles().configs.exists("client-a")).toBe(false); expect(profiles().bindings.getBindingsForProfile("client-a")).toEqual([]); expect(std.out).toBe(""); - expect(std.err).toContain("Removed directory bindings:"); - expect(std.err).toContain("projects/client-a"); - expect(std.err).toContain("projects/client-a-v2"); }); it("deletes a profile file when logout returns before clearing storage", async ({ @@ -223,7 +220,6 @@ describe("Profiles", () => { await runWrangler("auth delete client-a"); expect(profiles().configs.exists("client-a")).toBe(false); expect(std.out).toBe(""); - expect(std.err).toContain("No active profile for this directory."); }); it("falls back to ancestor profile after deleting a child profile", async ({ @@ -345,7 +341,6 @@ describe("Profiles", () => { profiles().bindings.getProfileForDirectory(process.cwd()) ).toBeUndefined(); expect(std.out).toBe(""); - expect(std.err).toContain("This directory now uses the default profile."); }); it("shows no active profile when logged out globally", async ({ @@ -354,10 +349,11 @@ describe("Profiles", () => { createProfileFile("client-a"); profiles().bindings.activate("client-a", process.cwd()); await runWrangler("auth deactivate"); + expect( + profiles().bindings.getProfileForDirectory(process.cwd()) + ).toBeUndefined(); + expect(profiles().configs.exists("default")).toBe(false); expect(std.out).toBe(""); - expect(std.err).toContain( - "Run cf auth login to set up the default profile" - ); }); });