Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
11 changes: 11 additions & 0 deletions .changeset/account-config-hint.md
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 packages/cli/src/__tests__/commands/account-selection.test.ts
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.
Comment thread
petebacondarwin marked this conversation as resolved.
*/
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");
}
64 changes: 64 additions & 0 deletions packages/cli/src/__tests__/lib/account-hint.test.ts
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;
}
});
});
68 changes: 68 additions & 0 deletions packages/cli/src/lib/oauth/account-hint.ts
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`
);
}
17 changes: 7 additions & 10 deletions packages/cli/src/lib/oauth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -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;
}
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/lib/ui/blocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading