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) {