Skip to content
Open
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
10 changes: 10 additions & 0 deletions .changeset/account-or-zone-help.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"cf": patch
---

Explain how account-or-zone commands pick their scope

Commands that serve either an account or a zone, such as `cf rulesets list`
and `cf load-balancers account list`, now say in `--help` that they run
against your account unless you pass `--zone`, and that they ignore
`CLOUDFLARE_ZONE_ID`.
11 changes: 11 additions & 0 deletions .changeset/empty-zone-flag.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"cf": patch
---

Reject `--zone` without a value

An empty `--zone` used to fall back to `CLOUDFLARE_ZONE_ID`, and `--dry-run`
previewed a `/zones//` path. On commands that serve both an account and a
zone, it also switched to zone scope. cf now stops with
`--zone requires a non-empty value.` and, as with `--mode`, rejects a repeated
`--zone`.
5 changes: 5 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -440,6 +440,11 @@ to know before editing `generator/plugin/`.
- `account_id` / `zone_id` are never positional — `account_id` is
resolved via `getAccountId` (local mode substitutes `LOCAL_ACCOUNT_ID`), `zone_id`
reuses the global `--zone` flag.
- A `{account_or_zone}/{account_or_zone_id}` path pair is one command, not
two: it runs against the account unless `--zone` is passed, and
`CLOUDFLARE_ZONE_ID` is ignored. Its help says so (`ACCOUNT_OR_ZONE_HELP`).
`--dry-run` shows the chosen scope; like other generated dry runs, it makes
no request, so a zone name stays a name in the previewed path.
- `paramOverride.positional: true` (forge schema) only promotes
top-level body fields to positionals — does NOT affect path params.

Expand Down
4 changes: 4 additions & 0 deletions packages/cli/generator/plugin/emit/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,10 @@ export function dryRun(ctx: Ctx): string[] {
return lines;
}

/** How `auth` picks an account-or-zone command's scope, as its help says it. */
export const ACCOUNT_OR_ZONE_HELP =
"Runs against your account. Pass --zone to run against a zone instead. CLOUDFLARE_ZONE_ID is ignored.";

function auth(ctx: Ctx): string[] {
const lines = [" const client = await createCommandClient(argv);"];
if (ctx.hasAccountOrZoneScope) {
Expand Down
21 changes: 16 additions & 5 deletions packages/cli/generator/plugin/files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import {
scalar,
} from "./args.js";
import { builderLines } from "./emit/builder.js";
import { handler, imports } from "./emit/handler.js";
import { ACCOUNT_OR_ZONE_HELP, handler, imports } from "./emit/handler.js";
import { context, escapeSingle, outputKind } from "./emit/index.js";
import { operationLiteral, operationPath, usesPathTag } from "./emit/sdk.js";
import {
Expand Down Expand Up @@ -91,17 +91,22 @@ export function summary(method: Method): string {
return first === "" ? description : first;
}

/** `getLeafUsage`: the usage of a leaf command, followed by its full description. */
/**
* `getLeafUsage`: the usage of a leaf command, followed by its full
* description and, for an account-or-zone command, how it picks its scope.
*/
export function leafUsage(
method: Method,
resource: string,
group: string | undefined,
command: string
command: string,
accountOrZone = false
): string {
const segments =
group === undefined ? [] : group.split("/").filter((s) => s !== "");
const path = ["$0", resource, ...segments, command];
return `${path.join(" ")}\n\n${method.description.trim()}`;
const usage = `${path.join(" ")}\n\n${method.description.trim()}`;
return accountOrZone ? `${usage}\n\n${ACCOUNT_OR_ZONE_HELP}` : usage;
}

/**
Expand Down Expand Up @@ -417,7 +422,13 @@ class Walk {
const handlerLines = handler(ctx);
const importSet = imports(ctx);
const builder = builderLines(ctx);
const usage = leafUsage(method, resource, group, command);
const usage = leafUsage(
method,
resource,
group,
command,
ctx.hasAccountOrZoneScope
);
const builderBody = `return yargs\n .usage(${quote(usage)})\n${builder.join("\n")};`;
const declarations = "type Args = InferArgs<typeof builder>;\n";
const telemetryPath =
Expand Down
118 changes: 104 additions & 14 deletions packages/cli/src/__tests__/commands/account-or-zone.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,12 @@ import {
vi,
} from "vite-plus/test";
import { captureOutput } from "../helpers/capture-output.js";
import { server, setupMsw, TEST_BASE_URL } from "../helpers/msw.js";
import {
createFetchResult,
server,
setupMsw,
TEST_BASE_URL,
} from "../helpers/msw.js";
import { runCf } from "../helpers/run-cf.js";

/**
Expand Down Expand Up @@ -42,40 +47,51 @@ describe("combined account-or-zone scope", () => {

afterEach(() => vi.restoreAllMocks());

it("uses the account route by default", async () => {
let requested = false;
function loadBalancerRequests(): string[] {
const requests: string[] = [];
server.use(
http.get(`${TEST_BASE_URL}/accounts/test-account/load_balancers`, () => {
requested = true;
http.get(`${TEST_BASE_URL}/:scope/:id/load_balancers`, ({ params }) => {
requests.push(`${String(params.scope)}/${String(params.id)}`);
return HttpResponse.json({ success: true, result: [] });
})
);
return requests;
}

it("uses the account route by default", async () => {
const requests = loadBalancerRequests();

const { exitCode } = await runCf(
["load-balancers", "account", "list"],
ENV
);

expect(exitCode).toBe(0);
expect(requested).toBe(true);
expect(requests).toEqual(["accounts/test-account"]);
});

it("uses the zone route when --zone is explicitly provided", async () => {
let requested = false;
server.use(
http.get(`${TEST_BASE_URL}/zones/${ZONE_ID}/load_balancers`, () => {
requested = true;
return HttpResponse.json({ success: true, result: [] });
})
);
const requests = loadBalancerRequests();

const { exitCode } = await runCf(
["load-balancers", "account", "list", "--zone", ZONE_ID],
ENV
);

expect(exitCode).toBe(0);
expect(requested).toBe(true);
expect(requests).toEqual([`zones/${ZONE_ID}`]);
});

it("keeps the account route when only CLOUDFLARE_ZONE_ID is set", async () => {
const requests = loadBalancerRequests();

const { exitCode } = await runCf(["load-balancers", "account", "list"], {
...ENV,
CLOUDFLARE_ZONE_ID: ZONE_ID,
});

expect(exitCode).toBe(0);
expect(requests).toEqual(["accounts/test-account"]);
});

it.each([
Expand Down Expand Up @@ -132,6 +148,80 @@ describe("combined account-or-zone scope", () => {
});
});

it("explains how the scope is picked in help", async () => {
const help = async (command: string[]) => {
output.clear();
await runCf([...command, "--help"]);
return output.stdout().replace(/\s+/g, " ");
};
const scope =
"Runs against your account. Pass --zone to run against a zone instead. CLOUDFLARE_ZONE_ID is ignored.";

expect(await help(["load-balancers", "account", "list"])).toContain(scope);
expect(await help(["zones", "list"])).not.toContain(scope);
});

it.each([
[["--zone", "--dry-run"]],
[["--zone=", "--dry-run"]],
[["--zone", "", "--dry-run"]],
])(
"rejects an empty zone in %j instead of switching scope",
async (flags) => {
await expect(
runCf(["load-balancers", "account", "list", ...flags], ENV)
).rejects.toThrow("--zone requires a non-empty value.");
expect(output.stdout()).toBe("");
}
);

// A zone name needs an account too: names are looked up in its zones.
it.each([[[]], [["--zone", "example.com"]]])(
"fails before any request when no account can be picked, with %j",
async (flags) => {
const accounts = [
{ id: "023e105f4ecef8ad9ca31a8372d0c353", name: "Acme Corp" },
{ id: "01a7362d577a6c3019a474fd6f485823", name: "Acme Staging" },
];
server.use(
http.get(`${TEST_BASE_URL}/accounts`, () =>
HttpResponse.json(createFetchResult(accounts))
),
http.get(`${TEST_BASE_URL}/memberships`, () =>
HttpResponse.json(
createFetchResult(accounts.map((account) => ({ account })))
)
)
);
const requests = loadBalancerRequests();

await expect(
runCf(["load-balancers", "account", "list", ...flags], {
...ENV,
CLOUDFLARE_ACCOUNT_ID: undefined,
})
).rejects.toThrow("CLOUDFLARE_ACCOUNT_ID");
expect(requests).toEqual([]);
}
);

it("fails before any request when the --zone name isn't found", async () => {
server.use(
http.get(`${TEST_BASE_URL}/zones`, () =>
HttpResponse.json(createFetchResult([]))
)
);
const requests = loadBalancerRequests();

await expect(
runCf(
["load-balancers", "account", "list", "--zone", "missing.example.com"],
ENV
)
).rejects.toThrow("Zone not found: missing.example.com");
expect(requests).toEqual([]);
});

it("does not expose either template placeholder as a command argument", () => {
const metadata = JSON.parse(
readFileSync(
Expand Down
9 changes: 9 additions & 0 deletions packages/cli/src/__tests__/generator/plugin/emit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { describe, expect, it } from "vite-plus/test";
import yargs, { type Argv } from "yargs";
import { builderLines } from "../../../../generator/plugin/emit/builder.js";
import {
ACCOUNT_OR_ZONE_HELP,
bodyObject,
bodyPrompts,
dryRun,
Expand Down Expand Up @@ -80,6 +81,14 @@ describe("descriptions", () => {
"$0 test records nested list <id>\n\nFull first line.\nFull second line."
);
});

it("follows the description with how an account-or-zone command picks its scope", () => {
expect(
leafUsage(list(undefined, description), "test", undefined, "list", true)
).toBe(
`$0 test list\n\nFull first line.\nFull second line.\n\n${ACCOUNT_OR_ZONE_HELP}`
);
});
});

describe("delete confirmation", () => {
Expand Down
62 changes: 62 additions & 0 deletions packages/cli/src/__tests__/zone-flag.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { runInTempDir } from "@cloudflare/workers-utils/test-helpers";
import {
afterEach,
beforeEach,
describe,
expect,
it,
vi,
} from "vite-plus/test";
import { captureOutput } from "./helpers/capture-output.js";
import { runCf } from "./helpers/run-cf.js";

describe("--zone global flag", () => {
runInTempDir();

const ENV = { CI: "true", CLOUDFLARE_ACCOUNT_ID: "account-id" };

let output: ReturnType<typeof captureOutput>;

beforeEach(() => {
output = captureOutput();
});

afterEach(() => vi.restoreAllMocks());

it("passes a zone name to zone-scoped commands", async () => {
await runCf(
["dns", "records", "list", "-z", "example.com", "--dry-run"],
ENV
);

expect(output.stdout()).toContain("/zones/example.com/dns_records");
});

it.each([[["-z"]], [["--zone="]], [["--no-zone"]]])(
"rejects %j without a value",
async (flags) => {
await expect(
runCf(["dns", "records", "list", ...flags, "--dry-run"], ENV)
).rejects.toThrow("--zone requires a non-empty value.");
expect(output.stdout()).toBe("");
}
);

it("rejects repeated values", async () => {
await expect(
runCf(
[
"dns",
"records",
"list",
"--zone",
"a.example.com",
"--zone",
"b.example.com",
"--dry-run",
],
ENV
)
).rejects.toThrow("--zone can only be specified once.");
});
});

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/cli/src/commands/_generated/custom-csrs/get.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/cli/src/commands/_generated/custom-csrs/list.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading