From ea1055776d4f5d6175a97f623d2f8ab3cd9d4f67 Mon Sep 17 00:00:00 2001 From: "Noah (Nguyen Pham)" Date: Fri, 9 Oct 2026 12:35:01 +0100 Subject: [PATCH] fix: format the account-selection config hint consistently The tip printed after the account picker now joins clack's guide, wraps to the terminal width, and prints its snippet indented but without guide glyphs, so copying it from the terminal yields valid TypeScript. When a cloudflare.config.ts already exists, the tip names that file and shows only the accountId line to add. --quiet omits the tip. Fixes #42 --- .changeset/account-config-hint.md | 11 ++ .../commands/account-selection.test.ts | 172 ++++++++++++++++++ .../src/__tests__/lib/account-hint.test.ts | 64 +++++++ packages/cli/src/lib/oauth/account-hint.ts | 68 +++++++ packages/cli/src/lib/oauth/index.ts | 17 +- packages/cli/src/lib/ui/blocks.ts | 2 +- 6 files changed, 323 insertions(+), 11 deletions(-) create mode 100644 .changeset/account-config-hint.md create mode 100644 packages/cli/src/__tests__/commands/account-selection.test.ts create mode 100644 packages/cli/src/__tests__/lib/account-hint.test.ts create mode 100644 packages/cli/src/lib/oauth/account-hint.ts diff --git a/.changeset/account-config-hint.md b/.changeset/account-config-hint.md new file mode 100644 index 000000000..e990e3b90 --- /dev/null +++ b/.changeset/account-config-hint.md @@ -0,0 +1,11 @@ +--- +"cf": patch +--- + +Format the account-selection config hint consistently + +After you pick an account, the tip for making it the project's default now +joins the prompt's guide, wraps to the terminal width, and keeps its snippet +free of guide characters so it copies cleanly. When the project already has a +`cloudflare.config.ts`, the tip names that file and shows only the `accountId` +line to add. `--quiet` now omits the tip. diff --git a/packages/cli/src/__tests__/commands/account-selection.test.ts b/packages/cli/src/__tests__/commands/account-selection.test.ts new file mode 100644 index 000000000..6da5934cf --- /dev/null +++ b/packages/cli/src/__tests__/commands/account-selection.test.ts @@ -0,0 +1,172 @@ +import { format } from "node:util"; +import { runInTempDir } from "@cloudflare/workers-utils/test-helpers"; +import { http, HttpResponse } from "msw"; +import { getGlobalDispatcher, MockAgent, setGlobalDispatcher } from "undici"; +import { + afterEach, + beforeEach, + describe, + expect, + it, + vi, +} from "vite-plus/test"; +import { + createFetchResult, + server, + setupMsw, + TEST_BASE_URL, +} from "../helpers/msw.js"; +import { runCf } from "../helpers/run-cf.js"; +import type * as interactive from "../../lib/interactive.js"; +import type * as clack from "@clack/prompts"; + +// Drive the real picker, answering it with Enter. +vi.mock("@clack/prompts", async (importOriginal) => { + const prompts = await importOriginal(); + const { PassThrough } = await import("node:stream"); + return { + ...prompts, + select: (options: Parameters[0]) => { + const input = new PassThrough(); + const selection = prompts.select({ ...options, input }); + input.write("\r"); + return selection; + }, + }; +}); + +vi.mock("../../lib/interactive.js", async (importOriginal) => ({ + ...(await importOriginal()), + isNonInteractiveOrCI: () => false, +})); + +const accounts = [ + { id: "023e105f4ecef8ad9ca31a8372d0c353", name: "Acme Corp" }, + { id: "01a7362d577a6c3019a474fd6f485823", name: "Acme Staging" }, +]; + +describe("account selection", () => { + runInTempDir(); + setupMsw(); + + const previousDispatcher = getGlobalDispatcher(); + let accountsApi: MockAgent; + let terminal: string; + + beforeEach(() => { + // workers-auth lists accounts through undici, which MSW doesn't intercept. + accountsApi = new MockAgent(); + accountsApi.disableNetConnect(); + const api = accountsApi.get("https://api.test"); + const page = { page: 1, total_pages: 1 }; + api + .intercept({ path: "/client/v4/accounts?page=1" }) + .reply(200, { success: true, result: accounts, result_info: page }); + api.intercept({ path: "/client/v4/memberships?page=1" }).reply(200, { + success: true, + result: accounts.map((account) => ({ account })), + result_info: page, + }); + setGlobalDispatcher(accountsApi); + + server.use( + http.get(`${TEST_BASE_URL}/accounts/:accountId/pages/projects`, () => + HttpResponse.json(createFetchResult([])) + ) + ); + + // What a terminal shows: the streams and the console (which Vitest + // takes over), interleaved in the order they were written. + terminal = ""; + const write = (chunk: unknown) => { + terminal += String(chunk); + return true; + }; + vi.spyOn(process.stdout, "write").mockImplementation(write); + vi.spyOn(process.stderr, "write").mockImplementation(write); + for (const method of ["log", "warn", "error"] as const) { + vi.spyOn(console, method).mockImplementation((...args) => { + write(`${format(...args)}\n`); + }); + } + }); + + afterEach(async () => { + vi.restoreAllMocks(); + setGlobalDispatcher(previousDispatcher); + await accountsApi.close(); + }); + + function run(...flags: string[]) { + return runCf(["pages", "list", ...flags], { + CLOUDFLARE_API_BASE_URL: TEST_BASE_URL, + CLOUDFLARE_API_TOKEN: "test-token", + CLOUDFLARE_ACCOUNT_ID: undefined, + }); + } + + it("prints the config hint between the picker and the command's output", async () => { + await expect(run()).resolves.toEqual({ exitCode: 0 }); + + expect(screen(terminal)).toMatchInlineSnapshot(` + "│ + ◇ Select an account + │ Acme Corp + │ + ● To make this the project's default account, create cloudflare.config.ts: + + import { defineConfig } from "cf/config"; + + export default defineConfig({ + accountId: "023e105f4ecef8ad9ca31a8372d0c353", + }); + + [] + " + `); + }); + + it("omits the config hint with --quiet", async () => { + await expect(run("--quiet")).resolves.toEqual({ exitCode: 0 }); + + expect(screen(terminal)).toMatchInlineSnapshot(` + "│ + ◇ Select an account + │ Acme Corp + [] + " + `); + }); +}); + +/** + * The picker redraws itself in place once an account is chosen. Replay the + * cursor moves it uses (up, down, erase below) so the snapshot shows what is + * left on screen. + */ +function screen(output: string): string { + const rows = [""]; + let row = 0; + const write = (text: string) => { + const [first = "", ...rest] = text.split("\n"); + rows[row] = (rows[row] ?? "") + first; + for (const line of rest) { + rows[++row] = line; + } + }; + const [head = "", ...sequences] = output.split("\u001b["); + write(head); + for (const sequence of sequences) { + const [, count, command, text = ""] = + /^\??(\d*)[\d;]*([A-Za-z])([\s\S]*)$/.exec(sequence) ?? []; + if (command === "A") { + row -= Number(count || 1); + } else if (command === "B") { + row += Number(count || 1); + } else if (command === "J") { + rows.length = row; + } + write(text); + } + return rows.join("\n"); +} diff --git a/packages/cli/src/__tests__/lib/account-hint.test.ts b/packages/cli/src/__tests__/lib/account-hint.test.ts new file mode 100644 index 000000000..d0aefc01a --- /dev/null +++ b/packages/cli/src/__tests__/lib/account-hint.test.ts @@ -0,0 +1,64 @@ +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { Writable } from "node:stream"; +import { stripVTControlCharacters } from "node:util"; +import { runInTempDir } from "@cloudflare/workers-utils/test-helpers"; +import chalk from "chalk"; +import { describe, expect, it } from "vite-plus/test"; +import { printAccountConfigHint } from "../../lib/oauth/account-hint.js"; + +const ACCOUNT_ID = "023e105f4ecef8ad9ca31a8372d0c353"; + +function render(options: { cwd?: string; columns?: number } = {}): string { + let text = ""; + const output = new Writable({ + write(chunk, _encoding, callback) { + text += String(chunk); + callback(); + }, + }); + printAccountConfigHint(ACCOUNT_ID, { columns: 100, ...options, output }); + // Bold is shown as **…**. Relative config paths use native separators. + return stripVTControlCharacters( + text.replaceAll("\u001b[1m", "**").replaceAll("\u001b[22m", "**") + ).replaceAll("\\", "/"); +} + +describe("printAccountConfigHint", () => { + runInTempDir(); + + it("shows only the property to add to the project's config", () => { + writeFileSync(join(process.cwd(), "cloudflare.config.ts"), ""); + const nested = join(process.cwd(), "src"); + mkdirSync(nested); + + expect(render({ cwd: nested })).toMatchInlineSnapshot(` + "│ + ● To make this the project's default account, add accountId to ../cloudflare.config.ts: + + accountId: "023e105f4ecef8ad9ca31a8372d0c353", + + " + `); + }); + + it("wraps inside clack's guide, keeping names and the account ID emphasized", () => { + writeFileSync(join(process.cwd(), "cloudflare.config.ts"), ""); + // Below truecolor, the theme emphasizes with bold. + const level = chalk.level; + chalk.level = 1; + try { + expect(render({ columns: 50 })).toMatchInlineSnapshot(` + "│ + ● To make this the project's default account, add + │ **accountId** to **cloudflare.config.ts**: + + accountId: **"023e105f4ecef8ad9ca31a8372d0c353"**, + + " + `); + } finally { + chalk.level = level; + } + }); +}); diff --git a/packages/cli/src/lib/oauth/account-hint.ts b/packages/cli/src/lib/oauth/account-hint.ts new file mode 100644 index 000000000..e467f027f --- /dev/null +++ b/packages/cli/src/lib/oauth/account-hint.ts @@ -0,0 +1,68 @@ +/** + * The tip printed after the interactive account picker. Its sentence joins + * clack's guide; the snippet is indented to the same column but carries no + * guide glyphs, so copying it from the terminal yields valid TypeScript. + */ +import { relative } from "node:path"; +import * as clack from "@clack/prompts"; +import { + CLOUDFLARE_CONFIG_FILENAME, + findCloudflareConfig, +} from "../project-settings.js"; +import { wrapPlain } from "../ui/blocks.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: { + cwd?: string; + output?: Writable & { columns?: number }; + columns?: number; + } = {} +): void { + const { cwd = process.cwd(), output = process.stderr } = options; + const columns = options.columns ?? (output.columns || 80); + const configPath = findCloudflareConfig(cwd); + const target = configPath + ? relative(cwd, configPath) + : CLOUDFLARE_CONFIG_FILENAME; + + const sentence = configPath + ? `To make this the project's default account, add accountId to ${target}:` + : `To make this the project's default account, create ${target}:`; + const codeTokens = new Set(["accountId", target]); + const lines = wrapPlain(sentence, Math.max(20, columns - GUIDE_WIDTH)).map( + (line) => + line + .split(" ") + .map((word) => { + const token = word.replace(/:$/, ""); + return codeTokens.has(token) + ? `${theme.code(token)}${word.slice(token.length)}` + : word; + }) + .join(" ") + ); + clack.log.info(lines.join("\n"), { output }); + + // Only the account ID is styled: below truecolor the theme falls back to + // bold, which would otherwise scatter emphasis across the snippet. + const property = `accountId: ${theme.jsonString(JSON.stringify(accountId))},`; + const snippet = configPath + ? [property] + : [ + `import { defineConfig } from "cf/config";`, + "", + "export default defineConfig({", + ` ${property}`, + "});", + ]; + const indent = " ".repeat(GUIDE_WIDTH); + output.write( + `\n${snippet.map((line) => (line ? indent + line : line)).join("\n")}\n\n` + ); +} diff --git a/packages/cli/src/lib/oauth/index.ts b/packages/cli/src/lib/oauth/index.ts index 668ecad13..8b2c4781b 100644 --- a/packages/cli/src/lib/oauth/index.ts +++ b/packages/cli/src/lib/oauth/index.ts @@ -14,10 +14,12 @@ import { type AuthContext, } from "@cloudflare/workers-auth/cf"; import { USER_AGENT, VERSION } from "../../version.js"; +import { hasQuietFlag } from "../args.js"; import { CliExit } from "../cli-exit.js"; import { getComplianceRegion, type ComplianceRegion } from "../context.js"; import { isNonInteractiveOrCI } from "../interactive.js"; import { openSession } from "../session.js"; +import { printAccountConfigHint } from "./account-hint.js"; const logger: AuthContext["logger"] = { debug: (...args) => { @@ -64,16 +66,11 @@ async function select( throw new CliExit(130, { cancelled: true }); } - logger.log( - `To have cf automatically use this account for future commands in this project, you can update your \`cloudflare.config.ts\`: - -import { defineConfig } from "cf/config"; - -export default defineConfig({ - accountId: "${result}", -}); -` - ); + // workers-auth only asks cf to select an account, so the result is + // always an account ID. + if (!hasQuietFlag()) { + printAccountConfigHint(result); + } return result; } diff --git a/packages/cli/src/lib/ui/blocks.ts b/packages/cli/src/lib/ui/blocks.ts index 1e705132e..84fa26619 100644 --- a/packages/cli/src/lib/ui/blocks.ts +++ b/packages/cli/src/lib/ui/blocks.ts @@ -127,7 +127,7 @@ export function errorBlock( * Word-wrap a plain (un-colored) string to `width` columns. Preserves * explicit newlines in the source. */ -function wrapPlain(text: string, width: number): string[] { +export function wrapPlain(text: string, width: number): string[] { const out: string[] = []; for (const para of text.split("\n")) { if (para.length <= width) {