Skip to content
Draft
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
5 changes: 5 additions & 0 deletions .changeset/previews-command-group.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"cf": patch
---

Keep `cf previews deploy` available in a generated command group so future schema updates can add Preview API commands without a root registration collision.
8 changes: 5 additions & 3 deletions packages/cli/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,8 @@ second consumer.
| Task | Location |
| --------------------------- | ------------------------------------------------------------------------------------------------------------ |
| Fix a generated command | Overlay in `@cloudflare/forge` → regenerate |
| Hand-written root command | `src/commands/{auth,build,cli,completions,dev,deploy,init,previews}/`, `schema.ts`, or `tools.ts` |
| Generated-tree exception | `src/commands/{access,ai,registrar,d1,tunnels,workers}/` plus the central registry and generator integration |
| Hand-written root command | `src/commands/{auth,build,cli,completions,dev,deploy,init}/`, `schema.ts`, or `tools.ts` |
| Generated-tree exception | `src/commands/{access,ai,registrar,d1,previews,tunnels,workers}/` plus the central registry and generator integration |
| Hand-written registry/meta | `src/commands/hand-written.ts`, per-command `meta.json`, `generator/hand-written-overrides.ts` |
| Auth / token / OAuth | `src/lib/auth.ts`, `src/lib/oauth/` |
| Runtime value resolution | `src/lib/context.ts`, `src/lib/resolve.ts`, `src/lib/project-settings.ts` |
Expand Down Expand Up @@ -77,7 +77,9 @@ Forge from the finalized OpenAPI, then runs

## Hand-Written Commands

`auth`, `complete`, `dev`, `build`, `deploy`, `migrate`, `previews`, `cli`, `schema`, and `tools` are root entries in `src/commands/hand-written.ts`, registered lazily by `src/index.ts`. `cf cli telemetry` is the telemetry-settings path. `cf migrate` uses `@cloudflare/codemods` to convert a Wrangler configuration to `cloudflare.config.ts` and report manual follow-up work. `cf dev` analyzes and, when needed, configures a project before running its canonical framework command, with an installed Cloudflare implementation as fallback. `cf build` follows the same routing and validates the resulting Build Output. `cf previews deploy [preview-name]` builds with Preview context and uploads the default Worker from the resulting Preview Build Output, or the one selected with `--worker`. `cf deploy`, `cf workers versions create`, and `cf workers triggers deploy` build first unless `--prebuilt` is passed, then read Build Output and call `@cloudflare/deploy-helpers`; trigger deployment applies only the configured triggers. Deploy-helper provisioning is enabled for supported bindings.
`auth`, `complete`, `dev`, `build`, `deploy`, `migrate`, `cli`, `schema`, and `tools` are root entries in `src/commands/hand-written.ts`, registered lazily by `src/index.ts`. `cf cli telemetry` is the telemetry-settings path. `cf migrate` uses `@cloudflare/codemods` to convert a Wrangler configuration to `cloudflare.config.ts` and report manual follow-up work. `cf dev` analyzes and, when needed, configures a project before running its canonical framework command, with an installed Cloudflare implementation as fallback. `cf build` follows the same routing and validates the resulting Build Output. `cf previews deploy [preview-name]` builds with Preview context and uploads the default Worker from the resulting Preview Build Output, or the one selected with `--worker`. `cf deploy`, `cf workers versions create`, and `cf workers triggers deploy` build first unless `--prebuilt` is passed, then read Build Output and call `@cloudflare/deploy-helpers`; trigger deployment applies only the configured triggers. Deploy-helper provisioning is enabled for supported bindings.

`previews` uses a generated group containing the hand-written `previews deploy` workflow. Its parent override opts into `createIfMissing`, so generation creates an empty API parent when the pinned schema has no Preview CLI operations. Future schema operations join the same group without colliding with a hand-written root.

The registry's other entries are spliced into the generated tree via `generator/hand-written-overrides.ts`: leaf overrides for `cf ai run` and `cf registrar registrations create`; added leaves for `cf workers check`, `cf workers types`, nested `cf workers versions create`, `cf tunnels quick-start`, `cf tunnels run`, and the process-backed `cf access` workflows; and subgroups for `cf d1 migrations` and `cf workers triggers`. The cloudflared-backed Tunnel and Access leaves delegate to the cf-managed cloudflared binary alongside their generated API-backed operations. `cf workers check` profiles the default Worker emitted through Build Output, or the one selected with `--worker`. `cf workers types` is the bounded source-config exception: it validates the Worker in the nearest `cloudflare.config.ts` and writes `.cloudflare/types/index.d.ts`. Version creation remains a project-aware Build Output workflow while the raw upload operations are SDK-only. D1 migration apply targets a database identified by ID only, with `--dir` / `--pattern` / `--table` as flags rather than config; its bookkeeping is wire-compatible with `wrangler d1 migrations apply`.

Expand Down
25 changes: 25 additions & 0 deletions packages/cli/generator/command-roots.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import type { ParentOverrideHandWrittenCommand } from "../src/commands/hand-written.js";
import type { Schema } from "@cloudflare/forge";

export function withHandWrittenCommandRoots(
commands: ReadonlyMap<string, Schema.command>,
overrides: readonly ParentOverrideHandWrittenCommand[]
): Map<string, Schema.command> {
const roots = new Map(commands);
for (const override of overrides) {
if (!override.createIfMissing) continue;
if (override.parent.includes("/")) {
throw new Error("createIfMissing only supports top-level command roots.");
}
if (roots.has(override.parent)) continue;
// Keep local workflows available before their API operations opt into CLI generation.
roots.set(override.parent, {
name: override.parent,
description: override.describe,
methods: [],
globalCliArgs: [],
hideCommand: !override.expose,
});
}
return roots;
}
2 changes: 1 addition & 1 deletion packages/cli/generator/generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
typedBuilderDeclaration,
typedSdkTypeAliases,
} from "./emit/sdk-path.js";
import { generateCommandMeta, type GeneratedCommandMeta } from "./metadata";
import { generateCommandMeta, type GeneratedCommandMeta } from "./metadata.js";
import { getTelemetrySafeFlags } from "./telemetry.js";
import { escapeForSingleQuote } from "./util.js";
import type { Schema } from "@cloudflare/forge";
Expand Down
13 changes: 9 additions & 4 deletions packages/cli/generator/index.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
import {
leafOverrideHandWrittenCommands,
leafHandWrittenCommands,
parentOverrideHandWrittenCommands,
rootCommandName,
rootHandWrittenCommands,
subGroupHandWrittenCommands,
} from "../src/commands/hand-written.js";
import { withHandWrittenCommandRoots } from "./command-roots.js";
import {
generateCommandFile,
generateGroupIndexFile,
generateResourceIndexFile,
isMethodGroup,
} from "./generator";
} from "./generator.js";
import {
handWrittenLeafCommands,
handWrittenLeafOverrideDir,
Expand All @@ -27,7 +29,7 @@ import {
generateSchemaInfo,
type HandWrittenCommandMeta,
type SchemaInfo,
} from "./metadata";
} from "./metadata.js";
import { errorMessage, escapeForSingleQuote } from "./util.js";
/**
* CLI Transformer
Expand Down Expand Up @@ -283,7 +285,10 @@ export const transformer: TransformerFn = async (forge: Forge) => {
hideCommand: false,
};
const commandSchemas: Array<[string, Schema.command]> = [
...forge.commands,
...withHandWrittenCommandRoots(
forge.commands,
parentOverrideHandWrittenCommands()
),
["access", accessSchema],
];

Expand Down Expand Up @@ -513,7 +518,7 @@ ${entries}
}
}
for (const command of rootHandWrittenCommands()) {
const name = command.command.split(/\s+/)[0];
const name = rootCommandName(command);
descriptions[name] = command.describe === false ? "" : command.describe;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -247,9 +247,10 @@ describe("hand-written-only command metadata", () => {
dir: "auth",
});
expect(commands.get("cf previews deploy")?.handWritten).toEqual({
kind: "root",
kind: "leaf",
overrides: false,
dir: "previews",
dir: "previews/deploy",
parent: "previews",
});
expect(commands.get("cf d1 migrations create")?.handWritten).toEqual({
kind: "subgroup",
Expand Down
29 changes: 29 additions & 0 deletions packages/cli/src/__tests__/commands/previews-root.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { runCf } from "../helpers/run-cf.js";

describe("cf previews group", () => {
let out: ReturnType<typeof vi.spyOn>;
let log: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
out = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
log = vi.spyOn(console, "log").mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
});

it("keeps the deployment workflow available in group help", async () => {
await runCf(["previews", "--help"]);
const output =
out.mock.calls.map((call: unknown[]) => String(call[0])).join("") +
log.mock.calls.map((call: unknown[]) => String(call[0])).join("\n");
expect(output).toContain("Manage Worker Previews");
expect(output).toContain("previews deploy [preview-name]");
});

it("keeps local mode unavailable for Preview deployment", async () => {
await expect(
runCf(["previews", "deploy", "feature", "--local"])
).rejects.toThrow("--local is not supported by cf previews deploy.");
});
});
59 changes: 59 additions & 0 deletions packages/cli/src/__tests__/generator/command-roots.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { describe, expect, it } from "vitest";
import { withHandWrittenCommandRoots } from "../../../generator/command-roots.js";
import { parentOverrideHandWrittenCommands } from "../../commands/hand-written.js";
import type { Schema } from "@cloudflare/forge";

describe("hand-written command roots", () => {
it("creates an opted-in parent before its API operations exist", () => {
const source = new Map<string, Schema.command>();
const roots = withHandWrittenCommandRoots(
source,
parentOverrideHandWrittenCommands()
);
expect(roots.get("previews")).toEqual({
name: "previews",
description: "Manage Worker Previews",
methods: [],
globalCliArgs: [],
hideCommand: false,
});
expect(roots.has("access")).toBe(false);
expect(source.size).toBe(0);
});

it("preserves the schema-provided parent when it arrives", () => {
const schema: Schema.command = {
name: "previews",
description: "Preview API",
methods: [
{
name: "delete",
operationId: "fixture.previews.delete",
status: "generally-available",
},
],
globalCliArgs: [],
hideCommand: false,
};
const roots = withHandWrittenCommandRoots(
new Map([[schema.name, schema]]),
parentOverrideHandWrittenCommands()
);
expect(roots.size).toBe(1);
expect(roots.get("previews")).toBe(schema);
});

it("rejects creating a nested parent as a top-level root", () => {
expect(() =>
withHandWrittenCommandRoots(new Map(), [
{
kind: "parentOverride",
parent: "example/nested",
describe: "Nested group",
expose: true,
createIfMissing: true,
},
])
).toThrow("createIfMissing only supports top-level command roots.");
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,22 @@ const accessSchema = {
} as Parameters<typeof generateResourceIndexFile>[0];

describe("hand-written leaf commands", () => {
it("uses an explicit hand-written override for a schema-provided leaf", () => {
const generated = generateResourceIndexFile(
{
name: "ai",
description: "AI",
} as Parameters<typeof generateResourceIndexFile>[0],
["run"],
[]
);
expect(generated).toContain(
"import $run from '#commands/ai/run/index.js';"
);
expect(generated).not.toContain("import $run from './run.js';");
expect(generated).toContain(".command($run)");
});

it("registers a leaf command against its generated product", () => {
expect(handWrittenLeafCommands("workers")).toEqual([
{
Expand Down Expand Up @@ -196,6 +212,48 @@ describe("hand-written leaf commands", () => {
);
});

it.each([{ leaves: [] }, { leaves: ["delete"] }])(
"keeps Preview deployment with API leaves $leaves",
({ leaves }) => {
const [registered] = handWrittenLeafCommands("previews");
expect(registered).toEqual({
kind: "leaf",
parent: "previews",
name: "deploy",
dir: "previews/deploy",
});
if (registered === undefined) {
throw new Error("previews deploy is not registered");
}
expect(
readHandWrittenLeafCommandMeta("previews", registered)
).toMatchObject({
command: "cf previews deploy",
fullPath: ["previews", "deploy"],
});
const generated = generateResourceIndexFile(
{
name: "previews",
description: "Manage Worker Previews",
methods: [],
globalCliArgs: [],
hideCommand: false,
},
leaves,
[]
);
expect(generated).toContain(
"import $deploy from '#commands/previews/deploy/index.js';"
);
if (leaves.length > 0) {
expect(generated).toContain(".command($delete)");
} else {
expect(generated).not.toContain(".command($delete)");
}
expect(generated).toContain(".command($deploy)");
}
);

it("binds sidecar identity to the registered command", () => {
const [registered] = handWrittenLeafCommands("workers");
if (registered === undefined) {
Expand Down
63 changes: 63 additions & 0 deletions packages/cli/src/__tests__/generator/parent-overrides.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { Forge } from "@cloudflare/forge";
import { describe, expect, it, vi } from "vitest";
import { transformer } from "../../../generator/index.js";
import type * as HandWrittenRegistry from "../../commands/hand-written.js";

const settings = vi.hoisted(() => ({ expose: true }));

// Isolate parent presentation from the registry's unrelated command sidecars.
vi.mock("../../commands/hand-written.js", async (importOriginal) => {
const actual = await importOriginal<typeof HandWrittenRegistry>();
return {
...actual,
handWrittenCommands: [],
rootHandWrittenCommands: () => [],
leafHandWrittenCommands: () => [],
leafOverrideHandWrittenCommands: () => [],
subGroupHandWrittenCommands: () => [],
parentOverrideHandWrittenCommands: () => [
{
kind: "parentOverride",
parent: "fixture",
describe: "Local description",
expose: settings.expose,
},
],
};
});

describe("parent presentation overrides", () => {
it.each([true, false])(
"applies expose=%s to a hidden schema root",
async (expose) => {
settings.expose = expose;
const forge = new Forge({
openapi: "3.0.0",
info: { title: "Fixture", version: "1" },
paths: {},
});
forge.commands.set("fixture", {
name: "fixture",
description: "Schema description",
methods: [],
globalCliArgs: [],
hideCommand: true,
});
const files = new Map(
(await forge.transform(transformer)).map((file) => [
file.path,
file.content,
])
);
expect(files.get("index.ts")).toContain(
`lazyCommand<CommonYargsOptions>('fixture', 'Local description', () => import('./fixture/index.js'), null), hideCommand: ${!expose}`
);
expect(files.get("fixture/index.ts")).toContain(
"describe: 'Local description'"
);
expect(
JSON.parse(files.get("_meta/commands.json") ?? "").descriptions
).toMatchObject({ fixture: "Local description" });
}
);
});
2 changes: 1 addition & 1 deletion packages/cli/src/commands/_generated/_meta/commands.json

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.

Loading
Loading