Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
0196179
refactor: add stderr message helpers and share the success line
khoinguyenpham04 Oct 19, 2026
b643ad9
feat: write hand-written command messages to stderr
khoinguyenpham04 Oct 19, 2026
d448ce4
fix: check for an env token before stored OAuth state in cf auth login
khoinguyenpham04 Oct 19, 2026
dcf08d0
refactor: keep clack off the success line and pin stderr in tests
khoinguyenpham04 Oct 19, 2026
8135082
test: cover the remaining auth message branches and check migrate stdout
khoinguyenpham04 Oct 19, 2026
0a1ab35
docs: note the success line is terminal-only and phrase the message r…
khoinguyenpham04 Oct 19, 2026
30276b5
fix: don't report removed OAuth tokens when cf auth logout keeps them
khoinguyenpham04 Oct 19, 2026
8f1c8db
test: expect empty stdout from auth commands in the Wrangler compatib…
khoinguyenpham04 Oct 19, 2026
8720d9d
docs: say which warning cf auth login now prints once
khoinguyenpham04 Oct 19, 2026
9a82bbc
docs: note which output has not moved to stderr yet
khoinguyenpham04 Oct 19, 2026
9a58fb0
test: assert auth messages on stderr in the Wrangler compatibility tests
khoinguyenpham04 Oct 19, 2026
87f1d41
docs: link the workers-auth logout behaviour cf relies on
khoinguyenpham04 Oct 19, 2026
22e164f
docs: drop the version-pinned workers-auth link from the logout comment
khoinguyenpham04 Oct 19, 2026
075faa9
Merge remote-tracking branch 'upstream/main' into noah/stdout-output
khoinguyenpham04 Oct 20, 2026
05eb492
test: expect the keyring login notice on stderr
khoinguyenpham04 Oct 20, 2026
971c883
test: drop the clack log helper from the Wrangler compatibility tests
khoinguyenpham04 Oct 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .changeset/hand-written-messages-stderr.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
"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 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 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.
11 changes: 9 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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; `✓ <successLabel>` on TTY stderr); `--quiet` knob |
| `output.ts` | `formatOutput` (silent on null; `✓ <successLabel>` 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 |
Expand All @@ -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` and `printCommands` (copyable next steps); not re-exported from `ui/index.ts` |
| `input-validation.ts` | yargs-time arg coercion + `resolveFileToken(value, fieldName, format)` for `@<path>` ingestion; runtime body/path validation |
| `resolve.ts` | URL templating (path-param substitution), `isId`, `isValidDomainName` |

Expand Down Expand Up @@ -651,6 +652,12 @@ These have stabilised this repo's voice; preserve them.
- **Secret prompt:** clack.password (masks input).
- **Success line:** `✓ <successLabel>` on stderr; null/undefined API
results suppress stdout output entirely.
- **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.
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
Expand Down
81 changes: 64 additions & 17 deletions packages/cli/src/__tests__/commands/auth/device-login.test.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
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(() => ({
activeProfile: "default",
credentials: undefined as { scopes: string[] } | undefined,
credentials: undefined as
| { oauth_token?: string; scopes: string[] }
| undefined,
login: vi.fn(),
}));

Expand All @@ -25,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,
}),
}));
Expand All @@ -40,7 +42,7 @@ vi.mock("../../../lib/telemetry/dispatcher.js", () => ({

describe("cf auth device login", () => {
runInTempDir();
const std = mockConsoleMethods();
const std = mockOutputStreams();

beforeEach(() => {
sharedAuth.activeProfile = "default";
Expand Down Expand Up @@ -99,15 +101,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();
});

Expand Down Expand Up @@ -158,6 +159,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",
Expand Down Expand Up @@ -213,8 +226,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 () => {
Expand All @@ -224,8 +238,41 @@ 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."
`);
});

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"
`);
});
});
11 changes: 5 additions & 6 deletions packages/cli/src/__tests__/commands/auth/keyring-login.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => ({
Expand Down Expand Up @@ -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" };

Expand Down Expand Up @@ -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.");
}
);

Expand Down
78 changes: 78 additions & 0 deletions packages/cli/src/__tests__/commands/auth/logout.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
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";
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 });

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."`
);
});

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 <cwd>/home/.config/cloudflare/config/default.json"
);
});

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(
"Unset the CLOUDFLARE_API_TOKEN in the environment to log out."
);
expect(std.err).not.toContain("✓");
});
});
Loading
Loading