Repository navigation
fix: format the account-selection config hint consistently #242
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
petebacondarwin
merged 1 commit into
cloudflare:main
from
khoinguyenpham04:noah/cloudflare-issues-review-de6a1b
Oct 11, 2026
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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. |
172 changes: 172 additions & 0 deletions
172
packages/cli/src/__tests__/commands/account-selection.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<typeof clack>(); | ||
| const { PassThrough } = await import("node:stream"); | ||
| return { | ||
| ...prompts, | ||
| select: (options: Parameters<typeof clack.select>[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<typeof interactive>()), | ||
| 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"); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
| } | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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` | ||
| ); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.