diff --git a/src/components/WranglerCLI.astro b/src/components/WranglerCLI.astro new file mode 100644 index 00000000000000..d73980db6d05ef --- /dev/null +++ b/src/components/WranglerCLI.astro @@ -0,0 +1,105 @@ +--- +import { z } from "astro:schema"; +import { PackageManagers } from "starlight-package-managers"; +import { commands, getCommand } from "~/util/wrangler"; +import WranglerArg from "./WranglerArg.astro"; +import Details from "./Details.astro"; + +function validateArg(value: any, expected: string): boolean { + if (Array.isArray(expected)) { + for (const choice of expected) { + if (value === choice) { + return true; + } + } + + return false; + } + + return typeof value === expected; +} + +type Props = z.input; + +const props = z.object({ + command: z.string(), + positionals: z.array(z.string()).optional(), + flags: z.record(z.string(), z.any()).optional(), + showArgs: z.boolean().default(false), +}); + +const { command, positionals, flags, showArgs } = props.parse(Astro.props); + +const definition = getCommand(command); + +const { globalFlags } = commands; + +let args = []; + +if (flags) { + for (const [key, value] of Object.entries(flags)) { + const flagDef = definition.args?.[key]; + + if (!flagDef) { + throw new Error( + `[WranglerCLI] Received "${key}" for "${command}" but no such arg exists`, + ); + } + + const type = flagDef.type ?? flagDef.choices; + const valid = validateArg(value, type); + + if (!valid) { + throw new Error( + `[WranglerCLI] Expected "${type}" for "${key}" but got "${typeof value}"`, + ); + } + + args.push(...[`--${key}`, value]); + } +} + +if (positionals) { + const positionalsDef = definition.positionalArgs ?? []; + + if (positionalsDef.length === 0) { + throw new Error( + `[WranglerCLI] Expected 0 positional arguments for "${command}" but received ${positionals.length}`, + ); + } + + args.push(...positionals); +} +--- + + + +{ + showArgs && definition.args && ( +
+

+ Command flags +

+
    + {Object.entries(definition.args) + .filter(([_, value]) => !value.hidden) + .map(([key, value]) => { + return ; + })} +
+ +

+ Global flags +

+
    + {Object.entries(globalFlags).map(([key, value]) => { + return ; + })} +
+
+ ) +} diff --git a/src/components/WranglerCLI.astro.test.ts b/src/components/WranglerCLI.astro.test.ts new file mode 100644 index 00000000000000..6b411973a2cb24 --- /dev/null +++ b/src/components/WranglerCLI.astro.test.ts @@ -0,0 +1,82 @@ +import { experimental_AstroContainer as AstroContainer } from "astro/container"; +import { expect, test, describe } from "vitest"; +import WranglerCLI from "./WranglerCLI.astro"; + +type Options = Parameters<(typeof container)["renderToString"]>[1]; + +const container = await AstroContainer.create(); + +const renderWithOptions = (options?: Options) => { + return container.renderToString(WranglerCLI, options); +}; + +describe("WranglerCLI", () => { + test("succeeds with valid input", async () => { + await expect( + renderWithOptions({ + props: { + command: "deploy", + }, + }), + ).resolves.toContain("pnpm wrangler deploy"); + }); + + test("errors with no props", async () => { + await expect(renderWithOptions()).rejects + .toThrowErrorMatchingInlineSnapshot(` + [ZodError: [ + { + "code": "invalid_type", + "expected": "string", + "received": "undefined", + "path": [ + "command" + ], + "message": "Required" + } + ]] + `); + }); + + test("errors with non-existent command", async () => { + await expect( + renderWithOptions({ + props: { + command: "not-a-valid-command", + }, + }), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `[Error: [wrangler.ts] Command "not-a-valid-command" not found]`, + ); + }); + + test("errors with bad flags for 'deploy'", async () => { + await expect( + renderWithOptions({ + props: { + command: "deploy", + flags: { + foo: "bar", + }, + }, + }), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `[Error: [WranglerCLI] Received "foo" for "deploy" but no such arg exists]`, + ); + }); + + test("errors with bad value for 'container-rollout' flag", async () => { + await expect( + renderWithOptions({ + props: { + command: "deploy", + flags: { + "containers-rollout": "not-a-valid-option", + }, + }, + }), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `[Error: [WranglerCLI] Expected "immediate,gradual" for "containers-rollout" but got "string"]`, + ); + }); +}); diff --git a/src/components/WranglerCommand.astro b/src/components/WranglerCommand.astro index 399473f414bb42..501374e3cafddd 100644 --- a/src/components/WranglerCommand.astro +++ b/src/components/WranglerCommand.astro @@ -1,32 +1,11 @@ --- import { z } from "astro:schema"; -import { experimental_getWranglerCommands } from "wrangler"; import AnchorHeading from "./AnchorHeading.astro"; import { PackageManagers } from "starlight-package-managers"; import WranglerArg from "./WranglerArg.astro"; import Details from "./Details.astro"; import { marked } from "marked"; - -function getCommand(path: string) { - const segments = path.trim().split(/\s+/); - - const { registry } = experimental_getWranglerCommands(); - - let node = registry.subtree; - for (const segment of segments) { - const next = node.get(segment); - - if (!next) break; - - if (next.subtree.size === 0 && next.definition?.type === "command") { - return next.definition; - } - - node = next.subtree; - } - - throw new Error(`[WranglerCommand] Command "${path}" not found`); -} +import { commands, getCommand } from "~/util/wrangler"; const props = z.object({ command: z.string(), @@ -44,7 +23,7 @@ if (!definition.args) { throw new Error(`[WranglerCommand] "${command}" has no arguments`); } -const { globalFlags } = experimental_getWranglerCommands(); +const { globalFlags } = commands; const positionals = definition.positionalArgs ?.map((p) => `[${p.toUpperCase()}]`) diff --git a/src/components/index.ts b/src/components/index.ts index 0a984a493a12c4..f355cc3cc79fbd 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -58,6 +58,7 @@ export { default as TagsUsage } from "./TagsUsage.astro"; export { default as TunnelCalculator } from "./TunnelCalculator.astro"; export { default as Type } from "./Type.astro"; export { default as TypeScriptExample } from "./TypeScriptExample.astro"; +export { default as WranglerCLI } from "./WranglerCLI.astro"; export { default as WranglerCommand } from "./WranglerCommand.astro"; export { default as WranglerNamespace } from "./WranglerNamespace.astro"; export { default as WranglerConfig } from "./WranglerConfig.astro"; diff --git a/src/content/docs/style-guide/components/wrangler-cli.mdx b/src/content/docs/style-guide/components/wrangler-cli.mdx new file mode 100644 index 00000000000000..20e811e94d8a64 --- /dev/null +++ b/src/content/docs/style-guide/components/wrangler-cli.mdx @@ -0,0 +1,47 @@ +--- +title: WranglerCLI +styleGuide: + component: WranglerCLI +--- + +import { Type, MetaInfo } from "~/components"; + +The `WranglerCLI` component validates your Wrangler command & wraps it in the [`PackageManagers`](/style-guide/components/package-managers/) component. + +This is generated using the Wrangler version in the [`cloudflare-docs` repository](https://github.com/cloudflare/cloudflare-docs/blob/production/package.json). + +## Import + +{/* prettier-ignore */} +```mdx +import { WranglerCLI } from "~/components"; +``` + +## Usage + +```mdx live +import { WranglerCLI } from "~/components"; + + +``` + +## Arguments + +- `command` + - The name of the command, i.e `d1 execute`. + +- `positionals` + - Any positional argument values, i.e `{["src/index.mjs]}"` for the optional `[SCRIPT]` positional argument on `deploy`. + +- `flags` + - Any named argument values, i.e `name: "my-worker"` for the optional `name` argument on `deploy`. + +- `showArgs` + - Show the available arguments in a [`Details` component](/style-guide/components/details/) below the command. diff --git a/src/util/wrangler.ts b/src/util/wrangler.ts new file mode 100644 index 00000000000000..d792aa46f344c2 --- /dev/null +++ b/src/util/wrangler.ts @@ -0,0 +1,24 @@ +import { experimental_getWranglerCommands } from "wrangler"; + +export const commands = experimental_getWranglerCommands(); + +export function getCommand(path: string) { + const segments = path.trim().split(/\s+/); + + const { registry } = commands; + + let node = registry.subtree; + for (const segment of segments) { + const next = node.get(segment); + + if (!next) break; + + if (next.subtree.size === 0 && next.definition?.type === "command") { + return next.definition; + } + + node = next.subtree; + } + + throw new Error(`[wrangler.ts] Command "${path}" not found`); +} diff --git a/vitest.workspace.ts b/vitest.workspace.ts index be5ce77bd996aa..4f1f01b9f8ed02 100644 --- a/vitest.workspace.ts +++ b/vitest.workspace.ts @@ -1,5 +1,6 @@ import { defineWorkspace, defineProject } from "vitest/config"; import { defineWorkersProject } from "@cloudflare/vitest-pool-workers/config"; +import { getViteConfig } from "astro/config"; import tsconfigPaths from "vite-tsconfig-paths"; @@ -30,6 +31,13 @@ const workspace = defineWorkspace([ }, plugins: [tsconfigPaths()], }), + getViteConfig({ + test: { + name: "Astro", + include: ["**/*.astro.test.ts"], + }, + plugins: [tsconfigPaths()], + }), ]); export default workspace;