From 81a0f03305f2a20215bbbb05e95cc050a216f6cf Mon Sep 17 00:00:00 2001 From: Rahul Sethi <5822355+RamIdeas@users.noreply.github.com> Date: Mon, 19 Aug 2024 12:46:44 +0100 Subject: [PATCH 01/31] defineCommand interface --- packages/wrangler/src/core/define-command.ts | 151 +++++++++++++++++++ packages/wrangler/src/core/teams.d.ts | 17 +++ 2 files changed, 168 insertions(+) create mode 100644 packages/wrangler/src/core/define-command.ts create mode 100644 packages/wrangler/src/core/teams.d.ts diff --git a/packages/wrangler/src/core/define-command.ts b/packages/wrangler/src/core/define-command.ts new file mode 100644 index 000000000000..b62f35fcb5ce --- /dev/null +++ b/packages/wrangler/src/core/define-command.ts @@ -0,0 +1,151 @@ +import { OnlyCamelCase } from "../config/config"; +import { FatalError, UserError } from "../errors"; +import { CommonYargsOptions } from "../yargs-types"; +import { Teams } from "./teams"; +import type { Config } from "../config"; +import type { Logger } from "../logger"; +import type { + Alias, + ArgumentsCamelCase, + InferredOptionTypes, + Options, + PositionalOptions, +} from "yargs"; + +export type CommandDefinition = Input; + +export type YargsOptionSubset = PositionalOptions & Pick; +export type BasedNamedArgs = { [key: string]: YargsOptionSubset }; // TODO(consider): refining value to subset of Options type +type StringKeyOf = Extract; +export type HandlerArgs = OnlyCamelCase< + ArgumentsCamelCase< + CommonYargsOptions & InferredOptionTypes & Alias + > +>; + +export type HandlerContext = { + /** + * The wrangler config file read from disk and parsed. + * If no config file can be found, this value will undefined. + * Set `behaviour.requireConfig` to refine this type and + * throw if it cannot be found. + */ + config: RequireConfig extends true ? Config : Config | undefined; + /** + * The logger instance provided to the command implementor as a convenience. + */ + logger: Logger; + /** + * Error classes provided to the command implementor as a convenience + * to aid discoverability and to encourage their usage. + */ + errors: { + UserError: typeof UserError; + FatalError: typeof FatalError; + + // TODO: extend with other categories of error + }; + + // TODO: experiments + // TODO(future): API SDK + + // TODO: prompt (cli package) + // TODO: accountId +}; + +type Input< + NamedArgs extends BasedNamedArgs, + RequireConfig extends boolean = boolean, +> = { + /** + * The full command as it would be written by the user. + */ + command: `wrangler ${string}`; + + /** + * Descriptive information about the command which does not affect behaviour. + * This is used for the CLI --help and subcommand --help output. + * This should be used as the source-of-truth for status and ownership. + */ + metadata: { + description: string; + status: "exprimental" | "alpha" | "private-beta" | "open-beta" | "stable"; + statusMessage?: string; + deprecated?: boolean; + deprecatedMessage?: string; + hidden?: boolean; + owner: Teams; + }; + /** + * Controls shared behaviour across all commands. + * This will allow wrangler commands to remain consistent and only diverge intentionally. + */ + behaviour: { + /** + * If true, throw error if a config file cannot be found. + */ + requireConfig?: RequireConfig; // boolean type which affects the HandlerContext type + /** + * By default, metrics are sent if the user has opted-in. + * This allows metrics to be disabled unconditionally. + */ + sendMetrics?: false; + sharedArgs?: { + /** + * Enable the --config arg which allows the user to override the default config file path + */ + config?: boolean; + /** + * Enable the --account-id arg which allows the user to override the CLOUDFLARE_ACCOUNT_ID env var and accountId config property + */ + accountId?: boolean; + /** + * Enable the --json arg which enables + */ + json?: boolean; + + // TODO: experimental flags + }; + }; + + /** + * A plain key-value object describing the CLI args for this command. + * Shared args can be defined as another plain object and spread into this. + */ + args: NamedArgs; + /** + * Optionally declare some of the named args as positional args. + * The order of this array is the order they are expected in the command. + * Use args[key].demandOption and args[key].array to declare required and variadic + * positional args, respectively. + */ + positionalArgs?: Array>; + + /** + * The implementation of the command which is given camelCase'd args + * and a ctx object of convenience properties + */ + handler: ( + args: HandlerArgs, + ctx: HandlerContext + ) => void | Promise; +}; + +export const COMMAND_DEFINITIONS: CommandDefinition[] = []; + +export function defineCommand< + NamedArgs extends BasedNamedArgs, + RequireConfig extends boolean, +>(input: Input) { + COMMAND_DEFINITIONS.push(input); + + return { + input, + get args(): HandlerArgs { + throw new Error(); + }, + }; +} + +// TODO: defineCommandAlias +// TODO: defineCommandGroup diff --git a/packages/wrangler/src/core/teams.d.ts b/packages/wrangler/src/core/teams.d.ts new file mode 100644 index 000000000000..b2cda5d5fee4 --- /dev/null +++ b/packages/wrangler/src/core/teams.d.ts @@ -0,0 +1,17 @@ +// Team names from https://wiki.cfdata.org/display/EW/Developer+Platform+Components+and+Pillar+Ownership +export type Teams = + | "Workers: Onboarding & Integrations" + | "Workers: Builds and Automation" + | "Workers: Deploy and Config" + | "Workers: Authoring and Testing" + | "Workers: Frameworks and Runtime APIs" + | "Workers: Runtime Platform" + | "Workers: Workers Observability" + | "Product: KV" + | "Product: R2" + | "Product: D1" + | "Product: Queues" + | "Produce: AI" + | "Product: Hyperdrive" + | "Product: Vectorize" + | "Product: Cloudchamber"; From 2e904cabf647146531925aca3dc92c902b42a4b9 Mon Sep 17 00:00:00 2001 From: Rahul Sethi <5822355+RamIdeas@users.noreply.github.com> Date: Wed, 21 Aug 2024 20:46:18 +0100 Subject: [PATCH 02/31] implement registerAllCommands util --- packages/wrangler/src/core/define-command.ts | 65 +++++--- .../wrangler/src/core/register-commands.ts | 142 ++++++++++++++++++ packages/wrangler/src/core/wrap-command.ts | 103 +++++++++++++ 3 files changed, 286 insertions(+), 24 deletions(-) create mode 100644 packages/wrangler/src/core/register-commands.ts create mode 100644 packages/wrangler/src/core/wrap-command.ts diff --git a/packages/wrangler/src/core/define-command.ts b/packages/wrangler/src/core/define-command.ts index b62f35fcb5ce..ecb39adca5ba 100644 --- a/packages/wrangler/src/core/define-command.ts +++ b/packages/wrangler/src/core/define-command.ts @@ -12,12 +12,21 @@ import type { PositionalOptions, } from "yargs"; -export type CommandDefinition = Input; +export type Command = `wrangler ${string}`; +export type Metadata = { + description: string; + status: "experimental" | "alpha" | "private-beta" | "open-beta" | "stable"; + statusMessage?: string; + deprecated?: boolean; + deprecatedMessage?: string; + hidden?: boolean; + owner: Teams; +}; -export type YargsOptionSubset = PositionalOptions & Pick; -export type BasedNamedArgs = { [key: string]: YargsOptionSubset }; // TODO(consider): refining value to subset of Options type +export type ArgDefinition = PositionalOptions & Pick; +export type BaseNamedArgDefinitions = { [key: string]: ArgDefinition }; type StringKeyOf = Extract; -export type HandlerArgs = OnlyCamelCase< +export type HandlerArgs = OnlyCamelCase< ArgumentsCamelCase< CommonYargsOptions & InferredOptionTypes & Alias > @@ -53,34 +62,26 @@ export type HandlerContext = { // TODO: accountId }; -type Input< - NamedArgs extends BasedNamedArgs, +export type CommandDefinition< + NamedArgs extends BaseNamedArgDefinitions = BaseNamedArgDefinitions, RequireConfig extends boolean = boolean, > = { /** * The full command as it would be written by the user. */ - command: `wrangler ${string}`; + command: Command; /** * Descriptive information about the command which does not affect behaviour. * This is used for the CLI --help and subcommand --help output. * This should be used as the source-of-truth for status and ownership. */ - metadata: { - description: string; - status: "exprimental" | "alpha" | "private-beta" | "open-beta" | "stable"; - statusMessage?: string; - deprecated?: boolean; - deprecatedMessage?: string; - hidden?: boolean; - owner: Teams; - }; + metadata: Metadata; /** * Controls shared behaviour across all commands. * This will allow wrangler commands to remain consistent and only diverge intentionally. */ - behaviour: { + behaviour?: { /** * If true, throw error if a config file cannot be found. */ @@ -131,21 +132,37 @@ type Input< ) => void | Promise; }; -export const COMMAND_DEFINITIONS: CommandDefinition[] = []; +export const COMMAND_DEFINITIONS: Array< + CommandDefinition | NamespaceDefinition | AliasDefinition +> = []; export function defineCommand< - NamedArgs extends BasedNamedArgs, + NamedArgs extends BaseNamedArgDefinitions, RequireConfig extends boolean, ->(input: Input) { - COMMAND_DEFINITIONS.push(input); +>(definition: CommandDefinition) { + COMMAND_DEFINITIONS.push(definition as unknown as CommandDefinition); return { - input, + definition, get args(): HandlerArgs { throw new Error(); }, }; } -// TODO: defineCommandAlias -// TODO: defineCommandGroup +export type NamespaceDefinition = { + command: Command; + metadata: Metadata; +}; +export function defineNamespace(definition: NamespaceDefinition) { + COMMAND_DEFINITIONS.push(definition); +} + +export type AliasDefinition = { + command: Command; + aliasOf: Command; + metadata?: Partial; +}; +export function defineAlias(definition: AliasDefinition) { + COMMAND_DEFINITIONS.push(definition); +} diff --git a/packages/wrangler/src/core/register-commands.ts b/packages/wrangler/src/core/register-commands.ts new file mode 100644 index 000000000000..b94bfffd01c7 --- /dev/null +++ b/packages/wrangler/src/core/register-commands.ts @@ -0,0 +1,142 @@ +import { CommonYargsArgv, SubHelp } from "../yargs-types"; +import { COMMAND_DEFINITIONS } from "./define-command"; +import { wrapCommandDefinition } from "./wrap-command"; +import type { + AliasDefinition, + Command, + CommandDefinition, + NamespaceDefinition, +} from "./define-command"; + +export default function registerAllCommands( + yargs: CommonYargsArgv, + subHelp: SubHelp +) { + const [root, commands] = createCommandTree("wrangler"); + + for (const [segment, node] of root.subtree.entries()) { + walkTreeAndRegister(segment, node, commands, yargs, subHelp); + } +} + +type DefinitionTreeNode = { + definition?: CommandDefinition | NamespaceDefinition | AliasDefinition; + subtree: DefinitionTree; +}; +type DefinitionTree = Map; + +/** + * Converts a flat list of COMMAND_DEFINITIONS into a tree of defintions + * which can be passed to yargs builder api + * + * For example, + * wrangler dev + * wrangler deploy + * wrangler versions upload + * wrangler versions deploy + * + * Will be transformed into: + * wrangler + * dev + * deploy + * versions + * upload + * deploy + */ +function createCommandTree(prefix: string) { + const root: DefinitionTreeNode = { subtree: new Map() }; + const commands = new Map(); + + for (const def of COMMAND_DEFINITIONS) { + const segments = def.command.split(" ").slice(1); // eg. ["versions", "secret", "put"] + + let node = root; + for (const segment of segments) { + const subtree = node.subtree; + node = subtree.get(segment) ?? { + definition: undefined, + subtree: new Map(), + }; + subtree.set(segment, node); + } + + if (node.definition) { + throw new Error(`Duplicate definition for "${def.command}"`); + } + node.definition = def; + + if ("handler" in def) { + commands.set(def.command, def); + } + } + + return [root, commands] as const; +} + +function walkTreeAndRegister( + segment: string, + { definition, subtree }: DefinitionTreeNode, + commands: Map, + yargs: CommonYargsArgv, + subHelp: SubHelp +) { + if (!definition) { + // TODO: make error message clearer which command is missing namespace definition + throw new Error( + `Missing namespace definition for 'wrangler ... ${segment} ...'` + ); + } + + // rewrite `definition` to copy behaviour/implementation from the (runnable) `real` command + if ("aliasOf" in definition) { + const real = commands.get(definition.aliasOf); + if (!real) { + throw new Error( + `No command definition for "${real}" (to alias from "${definition.command}")` + ); + } + + // this rewrites definition and narrows its type + // from: CommandDefinition | NamespaceDefinition | AliasDefintion + // to: CommandDefinition | NamespaceDefinition + definition = { + ...definition, + metadata: { + ...real.metadata, + ...definition.metadata, + description: + definition.metadata?.description ?? // use description override + `Alias for ${real.command}. ${real.metadata.description}`, // or add prefix to real description + hidden: definition.metadata?.hidden ?? true, // hide aliases by default + }, + behaviour: real.behaviour, + args: real.args, + positionalArgs: real.positionalArgs, + handler: real.handler, + }; + } + + // convert our definition into something we can pass to yargs.command + const def = wrapCommandDefinition(definition); + + // register command + yargs + .command( + segment + def.commandSuffix, + (def.hidden ? false : def.description) as string, // cast to satisfy typescript overload selection + (subYargs) => { + def.defineArgs?.(subYargs); + + for (const [segment, node] of subtree.entries()) { + walkTreeAndRegister(segment, node, commands, subYargs, subHelp); + } + + return subYargs; + }, + def.handler, // TODO: will be possibly undefined when groups/aliases are implemented + undefined, + def.deprecatedMessage + ) + // .epilog(def.statusMessage) + .command(subHelp); +} diff --git a/packages/wrangler/src/core/wrap-command.ts b/packages/wrangler/src/core/wrap-command.ts new file mode 100644 index 000000000000..1b9451a45a53 --- /dev/null +++ b/packages/wrangler/src/core/wrap-command.ts @@ -0,0 +1,103 @@ +import chalk from "chalk"; +import { CommandBuilder } from "yargs"; +import { readConfig } from "../config"; +import { FatalError, UserError } from "../errors"; +import { logger } from "../logger"; +import { printWranglerBanner } from "../update-check"; +import { CommonYargsOptions } from "../yargs-types"; +import { + BaseNamedArgDefinitions, + CommandDefinition, + HandlerArgs, + NamespaceDefinition, +} from "./define-command"; + +const betaCmdColor = "#BD5B08"; + +export function wrapCommandDefinition( + def: CommandDefinition | NamespaceDefinition +) { + let commandSuffix = ""; + let description = def.metadata.description; + let statusMessage = ""; + let defaultDeprecatedMessage = `Deprecated: "${def.command}" is deprecated`; // TODO: improve + let defineArgs: undefined | CommandBuilder = undefined; + let handler: + | undefined + | ((args: HandlerArgs) => Promise) = + undefined; + + console.log(def.metadata.status); + if (def.metadata.status !== "stable") { + description += chalk.hex(betaCmdColor)(` [${def.metadata.status}]`); + + statusMessage = + def.metadata.statusMessage ?? + `🚧 \`${def.command}\` is a ${def.metadata.status} command. Please report any issues to https://github.com/cloudflare/workers-sdk/issues/new/choose`; + } + + if ("positionalArgs" in def) { + const commandPositionalArgsSuffix = def.positionalArgs + ?.map((key) => { + const { demandOption, array } = def.args[key]; + if (demandOption) return `<${key}${array ? ".." : ""}>`; // or + return `[${key}${array ? ".." : ""}]`; // [key] or [key..] + }) + .join(" "); + + if (commandPositionalArgsSuffix) { + commandSuffix += " " + commandPositionalArgsSuffix; + } + } + + if ("args" in def) { + defineArgs = (yargs) => { + if ("args" in def) { + yargs.options(def.args); + + for (const key of def.positionalArgs ?? []) { + yargs.positional(key, def.args[key]); + } + } + + if (def.metadata.statusMessage) { + yargs.epilogue(def.metadata.statusMessage); + } + + return yargs; + }; + } + + if ("handler" in def) { + handler = async (args: HandlerArgs) => { + try { + await printWranglerBanner(); + + // TODO(telemetry): send command started event + + await def.handler(args, { + config: readConfig(args.config, args), + errors: { UserError, FatalError }, + logger, + }); + + // TODO(telemetry): send command completed event + } catch (err) { + // TODO(telemetry): send command errored event + throw err; + } + }; + } + + return { + commandSuffix, + description: description, + hidden: def.metadata.hidden, + deprecatedMessage: def.metadata.deprecated + ? def.metadata.deprecatedMessage ?? defaultDeprecatedMessage + : undefined, + statusMessage, + defineArgs, + handler, + }; +} From 4df126b9e04d145f5550261845eb13fa4e976487 Mon Sep 17 00:00:00 2001 From: Rahul Sethi <5822355+RamIdeas@users.noreply.github.com> Date: Thu, 22 Aug 2024 12:18:48 +0100 Subject: [PATCH 03/31] log deprecation/status message before running command handler --- packages/wrangler/src/core/wrap-command.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/packages/wrangler/src/core/wrap-command.ts b/packages/wrangler/src/core/wrap-command.ts index 1b9451a45a53..3581b3088142 100644 --- a/packages/wrangler/src/core/wrap-command.ts +++ b/packages/wrangler/src/core/wrap-command.ts @@ -21,13 +21,15 @@ export function wrapCommandDefinition( let description = def.metadata.description; let statusMessage = ""; let defaultDeprecatedMessage = `Deprecated: "${def.command}" is deprecated`; // TODO: improve + let deprecatedMessage = def.metadata.deprecated + ? def.metadata.deprecatedMessage ?? defaultDeprecatedMessage + : undefined; let defineArgs: undefined | CommandBuilder = undefined; let handler: | undefined | ((args: HandlerArgs) => Promise) = undefined; - console.log(def.metadata.status); if (def.metadata.status !== "stable") { description += chalk.hex(betaCmdColor)(` [${def.metadata.status}]`); @@ -73,6 +75,13 @@ export function wrapCommandDefinition( try { await printWranglerBanner(); + if (deprecatedMessage) { + logger.warn(deprecatedMessage); + } + if (statusMessage) { + logger.warn(statusMessage); + } + // TODO(telemetry): send command started event await def.handler(args, { @@ -93,9 +102,7 @@ export function wrapCommandDefinition( commandSuffix, description: description, hidden: def.metadata.hidden, - deprecatedMessage: def.metadata.deprecated - ? def.metadata.deprecatedMessage ?? defaultDeprecatedMessage - : undefined, + deprecatedMessage, statusMessage, defineArgs, handler, From 85ddfab1fd258436f749b78459a23efeb7820a50 Mon Sep 17 00:00:00 2001 From: Rahul Sethi <5822355+RamIdeas@users.noreply.github.com> Date: Thu, 22 Aug 2024 17:56:06 +0100 Subject: [PATCH 04/31] add tests --- .../core/command-registration.test.ts | 460 ++++++++++++++++++ .../wrangler/src/core/register-commands.ts | 143 +++--- packages/wrangler/src/core/wrap-command.ts | 7 +- 3 files changed, 546 insertions(+), 64 deletions(-) create mode 100644 packages/wrangler/src/__tests__/core/command-registration.test.ts diff --git a/packages/wrangler/src/__tests__/core/command-registration.test.ts b/packages/wrangler/src/__tests__/core/command-registration.test.ts new file mode 100644 index 000000000000..1700ce7c9275 --- /dev/null +++ b/packages/wrangler/src/__tests__/core/command-registration.test.ts @@ -0,0 +1,460 @@ +import { normalizeOutput } from "../../../e2e/helpers/normalize"; +import { + COMMAND_DEFINITIONS, + defineAlias, + defineCommand, + defineNamespace, +} from "../../core/define-command"; +import { mockConsoleMethods } from "../helpers/mock-console"; +import { runInTempDir } from "../helpers/run-in-tmp"; +import { runWrangler } from "../helpers/run-wrangler"; + +describe("Command Registration", () => { + runInTempDir(); + const std = mockConsoleMethods(); + + beforeEach(() => { + COMMAND_DEFINITIONS.length = 0; // clears commands definitions so tests do not conflict with eachother + + // To make these tests less verbose, we will define + // a bunch of commands that *use* all features + // but test each feature independently (mockConsoleMethods requires a separate test to reset the log) + // rather than verbosely define commands per test + + defineCommand({ + command: "wrangler my-test-command", + metadata: { + description: "My test command", + owner: "Workers: Authoring and Testing", + status: "stable", + }, + args: { + str: { type: "string", demandOption: true }, + num: { type: "number", demandOption: true }, + bool: { type: "boolean", demandOption: true }, + arr: { type: "string", array: true, demandOption: true }, + optional: { type: "string" }, + }, + handler(args, ctx) { + ctx.logger.log(args); + }, + }); + + defineNamespace({ + command: "wrangler one", + metadata: { + description: "namespace 1", + owner: "Workers: Authoring and Testing", + status: "stable", + }, + }); + + defineCommand({ + command: "wrangler one one", + metadata: { + description: "command 1 1", + owner: "Workers: Authoring and Testing", + status: "stable", + }, + args: {}, + handler(args, ctx) { + ctx.logger.log("Ran command 1 1"); + }, + }); + + defineCommand({ + command: "wrangler one two", + metadata: { + description: "command 1 2", + owner: "Workers: Authoring and Testing", + status: "stable", + }, + args: {}, + handler(args, ctx) { + ctx.logger.log("Ran command 1 2"); + }, + }); + + defineCommand({ + command: "wrangler one two three", + metadata: { + description: "command 1 2 3", + owner: "Workers: Authoring and Testing", + status: "stable", + }, + args: {}, + handler(args, ctx) { + ctx.logger.log("Ran command 1 2 3"); + }, + }); + + defineNamespace({ + command: "wrangler two", + metadata: { + description: "namespace 2", + owner: "Workers: Authoring and Testing", + status: "stable", + }, + }); + + defineCommand({ + command: "wrangler two one", + metadata: { + description: "command 2 1", + owner: "Workers: Authoring and Testing", + status: "stable", + }, + args: {}, + handler(args, ctx) { + ctx.logger.log("Ran command 2 1"); + }, + }); + }); + + test("can define a command and run it", async () => { + await runWrangler( + "my-test-command --str foo --num 2 --bool --arr first second --arr third" + ); + + expect(std.out).toMatchInlineSnapshot(` + "{ + _: [ 'my-test-command' ], + str: 'foo', + num: 2, + bool: true, + arr: [ 'first', 'second', 'third' ], + '$0': 'wrangler' + }" + `); + }); + + test("can define multiple commands and run them", async () => { + await runWrangler("one one"); + await runWrangler("one two"); + await runWrangler("two one"); + + expect(std.out).toMatchInlineSnapshot(` + "Ran command 1 1 + Ran command 1 2 + Ran command 2 1" + `); + }); + + test("displays commands in top-level --help", async () => { + await runWrangler("--help"); + + // TODO: fix ordering in top-level --help output + // The current ordering is hackily built on top of yargs default output + // This abstraction will enable us to completely customise the --help output + expect(std.out).toMatchInlineSnapshot(` + "wrangler + + COMMANDS + wrangler my-test-command My test command + wrangler one namespace 1 + wrangler two namespace 2 + wrangler docs [command] 📚 Open Wrangler's command documentation in your browser + + wrangler init [name] 📥 Initialize a basic Worker + wrangler dev [script] 👂 Start a local server for developing your Worker + wrangler deploy [script] 🆙 Deploy a Worker to Cloudflare [aliases: publish] + wrangler deployments 🚢 List and view the current and past deployments for your Worker [open beta] + wrangler rollback [deployment-id] 🔙 Rollback a deployment for a Worker [open beta] + wrangler delete [script] 🗑 Delete a Worker from Cloudflare + wrangler tail [worker] 🦚 Start a log tailing session for a Worker + wrangler secret 🤫 Generate a secret that can be referenced in a Worker + wrangler types [path] 📝 Generate types from bindings and module rules in configuration + + wrangler kv 🗂️ Manage Workers KV Namespaces + wrangler queues 🇶 Manage Workers Queues + wrangler r2 📦 Manage R2 buckets & objects + wrangler d1 🗄 Manage Workers D1 databases + wrangler vectorize 🧮 Manage Vectorize indexes [open beta] + wrangler hyperdrive 🚀 Manage Hyperdrive databases + wrangler pages ⚡️ Configure Cloudflare Pages + wrangler mtls-certificate 🪪 Manage certificates used for mTLS connections + wrangler pubsub 📮 Manage Pub/Sub brokers [private beta] + wrangler dispatch-namespace 🏗️ Manage dispatch namespaces + wrangler ai 🤖 Manage AI models + + wrangler login 🔓 Login to Cloudflare + wrangler logout 🚪 Logout from Cloudflare + wrangler whoami 🕵️ Retrieve your user information + + GLOBAL FLAGS + -j, --experimental-json-config Experimental: support wrangler.json [boolean] + -c, --config Path to .toml configuration file [string] + -e, --env Environment to use for operations and .env files [string] + -h, --help Show help [boolean] + -v, --version Show version number [boolean] + + Please report any issues to https://github.com/cloudflare/workers-sdk/issues/new/choose" + `); + }); + + test("displays namespace level 1 --help", async () => { + await runWrangler("one --help"); + + expect(std.out).toMatchInlineSnapshot(` + "wrangler one + + namespace 1 + + COMMANDS + wrangler one one command 1 1 + wrangler one two command 1 2 + + GLOBAL FLAGS + -j, --experimental-json-config Experimental: support wrangler.json [boolean] + -c, --config Path to .toml configuration file [string] + -e, --env Environment to use for operations and .env files [string] + -h, --help Show help [boolean] + -v, --version Show version number [boolean]" + `); + }); + + test("displays namespace level 2 --help", async () => { + await runWrangler("one two --help"); + + expect(std.out).toMatchInlineSnapshot(` + "wrangler one two + + command 1 2 + + COMMANDS + wrangler one two three command 1 2 3 + + GLOBAL FLAGS + -j, --experimental-json-config Experimental: support wrangler.json [boolean] + -c, --config Path to .toml configuration file [string] + -e, --env Environment to use for operations and .env files [string] + -h, --help Show help [boolean] + -v, --version Show version number [boolean]" + `); + }); + + test("displays namespace level 3 --help", async () => { + await runWrangler("one two three --help"); + + expect(std.out).toMatchInlineSnapshot(` + "wrangler one two three + + command 1 2 3 + + GLOBAL FLAGS + -j, --experimental-json-config Experimental: support wrangler.json [boolean] + -c, --config Path to .toml configuration file [string] + -e, --env Environment to use for operations and .env files [string] + -h, --help Show help [boolean] + -v, --version Show version number [boolean]" + `); + }); + + test("can alias a command to any other command", async () => { + defineAlias({ + command: "wrangler my-test-alias", + aliasOf: "wrangler my-test-command", + }); + + await runWrangler( + "my-test-alias --str bar --num 3 --bool --arr 1st 2nd --arr 3rd" + ); + + expect(std.out).toMatchInlineSnapshot(` + "{ + _: [ 'my-test-alias' ], + str: 'bar', + num: 3, + bool: true, + arr: [ '1st', '2nd', '3rd' ], + '$0': 'wrangler' + }" + `); + }); + test("can alias a command to another alias", async () => { + defineAlias({ + command: "wrangler my-test-alias-alias", + aliasOf: "wrangler my-test-alias", + }); + + defineAlias({ + command: "wrangler my-test-alias", + aliasOf: "wrangler one two three", + }); + + await runWrangler("my-test-alias-alias"); + + expect(std.out).toMatchInlineSnapshot(`"Ran command 1 2 3"`); + }); + test("can alias a namespace to another namespace", async () => { + defineAlias({ + command: "wrangler 1", + aliasOf: "wrangler one", + }); + + await runWrangler("1 two"); + + expect(std.out).toMatchInlineSnapshot(`"Ran command 1 2"`); + }); + test("aliases are explained in --help", async () => { + defineAlias({ + command: "wrangler my-test-alias", + aliasOf: "wrangler my-test-command", + metadata: { + hidden: false, + }, + }); + + await runWrangler("my-test-alias --help"); + + expect(std.out).toContain(`Alias for "wrangler my-test-command".`); + expect(std.out).toMatchInlineSnapshot(` + "wrangler my-test-alias + + Alias for \\"wrangler my-test-command\\". My test command + + GLOBAL FLAGS + -j, --experimental-json-config Experimental: support wrangler.json [boolean] + -c, --config Path to .toml configuration file [string] + -e, --env Environment to use for operations and .env files [string] + -h, --help Show help [boolean] + -v, --version Show version number [boolean] + + OPTIONS + --str [string] [required] + --num [number] [required] + --bool [boolean] [required] + --arr [array] [required] + --optional [string]" + `); + }); + + test("auto log status message", async () => { + defineCommand({ + command: "wrangler alpha-command", + metadata: { + description: + "Description without status expecting it added autotmatically", + owner: "Workers: Authoring and Testing", + status: "alpha", + }, + args: {}, + handler(args, ctx) { + ctx.logger.log("Ran command"); + }, + }); + + await runWrangler("alpha-command"); + + expect(std.out).toMatchInlineSnapshot(`"Ran command"`); + expect(normalizeOutput(std.warn)).toMatchInlineSnapshot( + `"▲ [WARNING] 🚧 \`wrangler alpha-command\` is a alpha command. Please report any issues to https://github.com/cloudflare/workers-sdk/issues/new/choose"` + ); + }); + test("auto log deprecation message", async () => { + defineCommand({ + command: "wrangler deprecated-stable-command", + metadata: { + description: + "Description without status expecting it added autotmatically", + owner: "Workers: Authoring and Testing", + status: "stable", + deprecated: true, + }, + args: {}, + handler(args, ctx) { + ctx.logger.log("Ran command"); + }, + }); + + await runWrangler("deprecated-stable-command"); + + expect(std.out).toMatchInlineSnapshot(`"Ran command"`); + expect(normalizeOutput(std.warn)).toMatchInlineSnapshot( + `"▲ [WARNING] Deprecated: \\"wrangler deprecated-stable-command\\" is deprecated"` + ); + }); + test("auto log status+deprecation message", async () => { + defineCommand({ + command: "wrangler deprecated-beta-command", + metadata: { + description: + "Description without status expecting it added autotmatically", + owner: "Workers: Authoring and Testing", + status: "private-beta", + deprecated: true, + }, + args: {}, + handler(args, ctx) { + ctx.logger.log("Ran command"); + }, + }); + + await runWrangler("deprecated-beta-command"); + + expect(std.out).toMatchInlineSnapshot(`"Ran command"`); + expect(normalizeOutput(std.warn)).toMatchInlineSnapshot(` + "▲ [WARNING] Deprecated: \\"wrangler deprecated-beta-command\\" is deprecated + ▲ [WARNING] 🚧 \`wrangler deprecated-beta-command\` is a private-beta command. Please report any issues to https://github.com/cloudflare/workers-sdk/issues/new/choose" + `); + }); + + describe("registration errors", () => { + test("throws upon duplicate command definition", async () => { + defineCommand({ + command: "wrangler my-test-command", + metadata: { + description: "", + owner: "Workers: Authoring and Testing", + status: "stable", + }, + args: {}, + handler() {}, + }); + + expect(runWrangler("my-test-command")).rejects.toMatchInlineSnapshot( + `[Error: Duplicate definition for "wrangler my-test-command"]` + ); + }); + test("throws upon duplicate namespace definition", async () => { + defineNamespace({ + command: "wrangler one two", + metadata: { + description: "", + owner: "Workers: Authoring and Testing", + status: "stable", + }, + }); + + expect(runWrangler("my-test-command")).rejects.toMatchInlineSnapshot( + `[Error: Duplicate definition for "wrangler one two"]` + ); + }); + test("throws upon missing namespace definition", async () => { + defineCommand({ + command: "wrangler missing-namespace subcommand", + metadata: { + description: "", + owner: "Workers: Authoring and Testing", + status: "stable", + }, + args: {}, + handler() {}, + }); + + expect(runWrangler("my-test-command")).rejects.toMatchInlineSnapshot( + `[Error: Missing namespace definition for 'wrangler ... missing-namespace ...']` + ); + }); + test("throws upon alias to undefined command", async () => { + defineAlias({ + command: "wrangler my-alias-command", + aliasOf: "wrangler undefined-command", + }); + + expect(runWrangler("my-test-command")).rejects.toMatchInlineSnapshot( + `[Error: Alias of alias encountered greater than 5 hops]` + ); + }); + }); +}); diff --git a/packages/wrangler/src/core/register-commands.ts b/packages/wrangler/src/core/register-commands.ts index b94bfffd01c7..3a4e3d051147 100644 --- a/packages/wrangler/src/core/register-commands.ts +++ b/packages/wrangler/src/core/register-commands.ts @@ -1,21 +1,23 @@ -import { CommonYargsArgv, SubHelp } from "../yargs-types"; +import assert from "assert"; +import { CommonYargsArgv } from "../yargs-types"; import { COMMAND_DEFINITIONS } from "./define-command"; import { wrapCommandDefinition } from "./wrap-command"; import type { AliasDefinition, + BaseNamedArgDefinitions, Command, CommandDefinition, + HandlerArgs, NamespaceDefinition, } from "./define-command"; -export default function registerAllCommands( - yargs: CommonYargsArgv, - subHelp: SubHelp -) { +export class CommandRegistrationError extends Error {} + +export default function registerAllCommands(yargs: CommonYargsArgv) { const [root, commands] = createCommandTree("wrangler"); for (const [segment, node] of root.subtree.entries()) { - walkTreeAndRegister(segment, node, commands, yargs, subHelp); + yargs = walkTreeAndRegister(segment, node, commands, yargs); } } @@ -46,9 +48,10 @@ type DefinitionTree = Map; function createCommandTree(prefix: string) { const root: DefinitionTreeNode = { subtree: new Map() }; const commands = new Map(); + const aliases = new Set(); - for (const def of COMMAND_DEFINITIONS) { - const segments = def.command.split(" ").slice(1); // eg. ["versions", "secret", "put"] + function getNodeFor(command: Command) { + const segments = command.split(" ").slice(1); // eg. ["versions", "secret", "put"] let node = root; for (const segment of segments) { @@ -60,16 +63,59 @@ function createCommandTree(prefix: string) { subtree.set(segment, node); } + return node; + } + + for (const def of COMMAND_DEFINITIONS) { + const node = getNodeFor(def.command); + if (node.definition) { - throw new Error(`Duplicate definition for "${def.command}"`); + throw new CommandRegistrationError( + `Duplicate definition for "${def.command}"` + ); } node.definition = def; - if ("handler" in def) { - commands.set(def.command, def); + if ("aliasOf" in def) { + aliases.add(def); } } + // reloop to allow aliases of aliases + const MAX_HOPS = 5; + for (let hops = 0; hops < MAX_HOPS && aliases.size > 0; hops++) { + for (const def of aliases) { + const realNode = getNodeFor(def.aliasOf); // TODO: this might be creating unnecessary undefined definitions + const real = realNode.definition; + if (!real || "aliasOf" in real) continue; + + const node = getNodeFor(def.command); + + node.definition = { + ...real, + command: def.command, + metadata: { + ...real.metadata, + ...def.metadata, + description: + def.metadata?.description ?? // use description override + `Alias for "${real.command}". ${real.metadata.description}`, // or add prefix to real description + hidden: def.metadata?.hidden ?? true, // hide aliases by default + }, + }; + + node.subtree = realNode.subtree; + + aliases.delete(def); + } + } + + if (aliases.size > 0) { + throw new CommandRegistrationError( + `Alias of alias encountered greater than ${MAX_HOPS} hops` + ); + } + return [root, commands] as const; } @@ -77,66 +123,41 @@ function walkTreeAndRegister( segment: string, { definition, subtree }: DefinitionTreeNode, commands: Map, - yargs: CommonYargsArgv, - subHelp: SubHelp + yargs: CommonYargsArgv ) { if (!definition) { // TODO: make error message clearer which command is missing namespace definition - throw new Error( + throw new CommandRegistrationError( `Missing namespace definition for 'wrangler ... ${segment} ...'` ); } - // rewrite `definition` to copy behaviour/implementation from the (runnable) `real` command - if ("aliasOf" in definition) { - const real = commands.get(definition.aliasOf); - if (!real) { - throw new Error( - `No command definition for "${real}" (to alias from "${definition.command}")` - ); - } - - // this rewrites definition and narrows its type - // from: CommandDefinition | NamespaceDefinition | AliasDefintion - // to: CommandDefinition | NamespaceDefinition - definition = { - ...definition, - metadata: { - ...real.metadata, - ...definition.metadata, - description: - definition.metadata?.description ?? // use description override - `Alias for ${real.command}. ${real.metadata.description}`, // or add prefix to real description - hidden: definition.metadata?.hidden ?? true, // hide aliases by default - }, - behaviour: real.behaviour, - args: real.args, - positionalArgs: real.positionalArgs, - handler: real.handler, - }; - } + // cannot be AliasDefinition anymore + assert( + !("aliasOf" in definition), + `Unexpected AliasDefinition for "${definition.command}"` + ); // convert our definition into something we can pass to yargs.command const def = wrapCommandDefinition(definition); // register command - yargs - .command( - segment + def.commandSuffix, - (def.hidden ? false : def.description) as string, // cast to satisfy typescript overload selection - (subYargs) => { - def.defineArgs?.(subYargs); - - for (const [segment, node] of subtree.entries()) { - walkTreeAndRegister(segment, node, commands, subYargs, subHelp); - } - - return subYargs; - }, - def.handler, // TODO: will be possibly undefined when groups/aliases are implemented - undefined, - def.deprecatedMessage - ) - // .epilog(def.statusMessage) - .command(subHelp); + yargs.command( + segment + def.commandSuffix, + (def.hidden ? false : def.description) as string, // cast to satisfy typescript overload selection + (subYargs) => { + if (def.defineArgs) { + subYargs = def.defineArgs(subYargs); + } + + for (const [segment, node] of subtree.entries()) { + subYargs = walkTreeAndRegister(segment, node, commands, subYargs); + } + + return subYargs; + }, + def.handler // TODO: subHelp (def.handler will be undefined for namespaces, so set default handler to print subHelp) + ); + + return yargs; } diff --git a/packages/wrangler/src/core/wrap-command.ts b/packages/wrangler/src/core/wrap-command.ts index 3581b3088142..72c80bd7447e 100644 --- a/packages/wrangler/src/core/wrap-command.ts +++ b/packages/wrangler/src/core/wrap-command.ts @@ -4,7 +4,7 @@ import { readConfig } from "../config"; import { FatalError, UserError } from "../errors"; import { logger } from "../logger"; import { printWranglerBanner } from "../update-check"; -import { CommonYargsOptions } from "../yargs-types"; +import { CommonYargsArgv, CommonYargsOptions } from "../yargs-types"; import { BaseNamedArgDefinitions, CommandDefinition, @@ -24,7 +24,8 @@ export function wrapCommandDefinition( let deprecatedMessage = def.metadata.deprecated ? def.metadata.deprecatedMessage ?? defaultDeprecatedMessage : undefined; - let defineArgs: undefined | CommandBuilder = undefined; + let defineArgs: undefined | ((yargs: CommonYargsArgv) => CommonYargsArgv) = + undefined; let handler: | undefined | ((args: HandlerArgs) => Promise) = @@ -100,7 +101,7 @@ export function wrapCommandDefinition( return { commandSuffix, - description: description, + description, hidden: def.metadata.hidden, deprecatedMessage, statusMessage, From 120306ce5aaa79d1af383fa720595897b97dfe18 Mon Sep 17 00:00:00 2001 From: Rahul Sethi <5822355+RamIdeas@users.noreply.github.com> Date: Fri, 23 Aug 2024 11:27:04 +0100 Subject: [PATCH 05/31] chore --- .../wrangler/src/core/register-commands.ts | 57 +++++++++++-------- 1 file changed, 33 insertions(+), 24 deletions(-) diff --git a/packages/wrangler/src/core/register-commands.ts b/packages/wrangler/src/core/register-commands.ts index 3a4e3d051147..b64ae6983cbc 100644 --- a/packages/wrangler/src/core/register-commands.ts +++ b/packages/wrangler/src/core/register-commands.ts @@ -4,10 +4,8 @@ import { COMMAND_DEFINITIONS } from "./define-command"; import { wrapCommandDefinition } from "./wrap-command"; import type { AliasDefinition, - BaseNamedArgDefinitions, Command, CommandDefinition, - HandlerArgs, NamespaceDefinition, } from "./define-command"; @@ -50,24 +48,8 @@ function createCommandTree(prefix: string) { const commands = new Map(); const aliases = new Set(); - function getNodeFor(command: Command) { - const segments = command.split(" ").slice(1); // eg. ["versions", "secret", "put"] - - let node = root; - for (const segment of segments) { - const subtree = node.subtree; - node = subtree.get(segment) ?? { - definition: undefined, - subtree: new Map(), - }; - subtree.set(segment, node); - } - - return node; - } - for (const def of COMMAND_DEFINITIONS) { - const node = getNodeFor(def.command); + const node = createNodeFor(def.command); if (node.definition) { throw new CommandRegistrationError( @@ -81,15 +63,14 @@ function createCommandTree(prefix: string) { } } - // reloop to allow aliases of aliases - const MAX_HOPS = 5; + const MAX_HOPS = 5; // reloop to allow aliases of aliases (to avoid infinite loop, limit to 5 hops) for (let hops = 0; hops < MAX_HOPS && aliases.size > 0; hops++) { for (const def of aliases) { - const realNode = getNodeFor(def.aliasOf); // TODO: this might be creating unnecessary undefined definitions - const real = realNode.definition; + const realNode = findNodeFor(def.aliasOf); + const real = realNode?.definition; if (!real || "aliasOf" in real) continue; - const node = getNodeFor(def.command); + const node = createNodeFor(def.command); node.definition = { ...real, @@ -117,6 +98,34 @@ function createCommandTree(prefix: string) { } return [root, commands] as const; + + // utils to + function createNodeFor(command: Command) { + const segments = command.split(" ").slice(1); // eg. ["versions", "secret", "put"] + + let node = root; + for (const segment of segments) { + const subtree = node.subtree; + node = subtree.get(segment) ?? { + definition: undefined, + subtree: new Map(), + }; + subtree.set(segment, node); + } + + return node; + } + function findNodeFor(command: Command) { + const segments = command.split(" ").slice(1); // eg. ["versions", "secret", "put"] + + let node: DefinitionTreeNode | undefined = root; + for (const segment of segments) { + node = node?.subtree.get(segment); + if (!node) return undefined; + } + + return node; + } } function walkTreeAndRegister( From c2001789b0d5bebf25c33cc91077c49259517e7b Mon Sep 17 00:00:00 2001 From: Rahul Sethi <5822355+RamIdeas@users.noreply.github.com> Date: Fri, 23 Aug 2024 17:58:37 +0100 Subject: [PATCH 06/31] cleanup --- .../core/command-registration.test.ts | 27 +++- packages/wrangler/src/core/define-command.ts | 10 +- .../wrangler/src/core/register-commands.ts | 128 +++++++++++------- 3 files changed, 108 insertions(+), 57 deletions(-) diff --git a/packages/wrangler/src/__tests__/core/command-registration.test.ts b/packages/wrangler/src/__tests__/core/command-registration.test.ts index 1700ce7c9275..9980c478e100 100644 --- a/packages/wrangler/src/__tests__/core/command-registration.test.ts +++ b/packages/wrangler/src/__tests__/core/command-registration.test.ts @@ -412,7 +412,9 @@ describe("Command Registration", () => { handler() {}, }); - expect(runWrangler("my-test-command")).rejects.toMatchInlineSnapshot( + await expect( + runWrangler("my-test-command") + ).rejects.toMatchInlineSnapshot( `[Error: Duplicate definition for "wrangler my-test-command"]` ); }); @@ -426,11 +428,22 @@ describe("Command Registration", () => { }, }); - expect(runWrangler("my-test-command")).rejects.toMatchInlineSnapshot( + await expect( + runWrangler("my-test-command") + ).rejects.toMatchInlineSnapshot( `[Error: Duplicate definition for "wrangler one two"]` ); }); test("throws upon missing namespace definition", async () => { + defineNamespace({ + command: "wrangler known-namespace", + metadata: { + description: "", + owner: "Workers: Authoring and Testing", + status: "stable", + }, + }); + defineCommand({ command: "wrangler missing-namespace subcommand", metadata: { @@ -442,8 +455,10 @@ describe("Command Registration", () => { handler() {}, }); - expect(runWrangler("my-test-command")).rejects.toMatchInlineSnapshot( - `[Error: Missing namespace definition for 'wrangler ... missing-namespace ...']` + await expect( + runWrangler("known-namespace missing-namespace subcommand") + ).rejects.toMatchInlineSnapshot( + `[Error: Missing namespace definition for 'wrangler missing-namespace']` ); }); test("throws upon alias to undefined command", async () => { @@ -452,7 +467,9 @@ describe("Command Registration", () => { aliasOf: "wrangler undefined-command", }); - expect(runWrangler("my-test-command")).rejects.toMatchInlineSnapshot( + await expect( + runWrangler("my-test-command") + ).rejects.toMatchInlineSnapshot( `[Error: Alias of alias encountered greater than 5 hops]` ); }); diff --git a/packages/wrangler/src/core/define-command.ts b/packages/wrangler/src/core/define-command.ts index ecb39adca5ba..7a46ab3652b5 100644 --- a/packages/wrangler/src/core/define-command.ts +++ b/packages/wrangler/src/core/define-command.ts @@ -1,9 +1,9 @@ -import { OnlyCamelCase } from "../config/config"; -import { FatalError, UserError } from "../errors"; -import { CommonYargsOptions } from "../yargs-types"; -import { Teams } from "./teams"; import type { Config } from "../config"; +import type { OnlyCamelCase } from "../config/config"; +import type { FatalError, UserError } from "../errors"; import type { Logger } from "../logger"; +import type { CommonYargsOptions } from "../yargs-types"; +import type { Teams } from "./teams"; import type { Alias, ArgumentsCamelCase, @@ -12,7 +12,7 @@ import type { PositionalOptions, } from "yargs"; -export type Command = `wrangler ${string}`; +export type Command = `wrangler${string}`; export type Metadata = { description: string; status: "experimental" | "alpha" | "private-beta" | "open-beta" | "stable"; diff --git a/packages/wrangler/src/core/register-commands.ts b/packages/wrangler/src/core/register-commands.ts index b64ae6983cbc..cb7d5ccbf81d 100644 --- a/packages/wrangler/src/core/register-commands.ts +++ b/packages/wrangler/src/core/register-commands.ts @@ -1,7 +1,6 @@ -import assert from "assert"; -import { CommonYargsArgv } from "../yargs-types"; import { COMMAND_DEFINITIONS } from "./define-command"; import { wrapCommandDefinition } from "./wrap-command"; +import type { CommonYargsArgv } from "../yargs-types"; import type { AliasDefinition, Command, @@ -12,10 +11,10 @@ import type { export class CommandRegistrationError extends Error {} export default function registerAllCommands(yargs: CommonYargsArgv) { - const [root, commands] = createCommandTree("wrangler"); + const tree = createCommandTree(); - for (const [segment, node] of root.subtree.entries()) { - yargs = walkTreeAndRegister(segment, node, commands, yargs); + for (const [segment, node] of tree.entries()) { + yargs = walkTreeAndRegister(segment, node, yargs); } } @@ -25,6 +24,12 @@ type DefinitionTreeNode = { }; type DefinitionTree = Map; +type ResolvedDefinitionTreeNode = { + definition?: CommandDefinition | NamespaceDefinition; + subtree: ResolvedDefinitionTree; +}; +type ResolvedDefinitionTree = Map; + /** * Converts a flat list of COMMAND_DEFINITIONS into a tree of defintions * which can be passed to yargs builder api @@ -43,13 +48,14 @@ type DefinitionTree = Map; * upload * deploy */ -function createCommandTree(prefix: string) { +function createCommandTree() { const root: DefinitionTreeNode = { subtree: new Map() }; - const commands = new Map(); const aliases = new Set(); + // STEP 1: Create tree from flat definitions array + for (const def of COMMAND_DEFINITIONS) { - const node = createNodeFor(def.command); + const node = createNodeFor(def.command, root); if (node.definition) { throw new CommandRegistrationError( @@ -63,14 +69,18 @@ function createCommandTree(prefix: string) { } } + // STEP 2: Resolve all aliases to their real definitions + const MAX_HOPS = 5; // reloop to allow aliases of aliases (to avoid infinite loop, limit to 5 hops) for (let hops = 0; hops < MAX_HOPS && aliases.size > 0; hops++) { for (const def of aliases) { - const realNode = findNodeFor(def.aliasOf); + const realNode = findNodeFor(def.aliasOf, root); const real = realNode?.definition; - if (!real || "aliasOf" in real) continue; + if (!real || "aliasOf" in real) { + continue; + } - const node = createNodeFor(def.command); + const node = createNodeFor(def.command, root); node.definition = { ...real, @@ -97,56 +107,32 @@ function createCommandTree(prefix: string) { ); } - return [root, commands] as const; + // STEP 3: validate missing namespace definitions - // utils to - function createNodeFor(command: Command) { - const segments = command.split(" ").slice(1); // eg. ["versions", "secret", "put"] - - let node = root; - for (const segment of segments) { - const subtree = node.subtree; - node = subtree.get(segment) ?? { - definition: undefined, - subtree: new Map(), - }; - subtree.set(segment, node); + for (const [command, node] of walk("wrangler", root)) { + if (!node.definition) { + throw new CommandRegistrationError( + `Missing namespace definition for '${command}'` + ); } - - return node; } - function findNodeFor(command: Command) { - const segments = command.split(" ").slice(1); // eg. ["versions", "secret", "put"] - let node: DefinitionTreeNode | undefined = root; - for (const segment of segments) { - node = node?.subtree.get(segment); - if (!node) return undefined; - } + // STEP 4: return the resolved tree - return node; - } + return root.subtree as ResolvedDefinitionTree; } function walkTreeAndRegister( segment: string, - { definition, subtree }: DefinitionTreeNode, - commands: Map, + { definition, subtree }: ResolvedDefinitionTreeNode, yargs: CommonYargsArgv ) { if (!definition) { - // TODO: make error message clearer which command is missing namespace definition throw new CommandRegistrationError( - `Missing namespace definition for 'wrangler ... ${segment} ...'` + `Missing namespace definition for '${segment}'` ); } - // cannot be AliasDefinition anymore - assert( - !("aliasOf" in definition), - `Unexpected AliasDefinition for "${definition.command}"` - ); - // convert our definition into something we can pass to yargs.command const def = wrapCommandDefinition(definition); @@ -159,8 +145,8 @@ function walkTreeAndRegister( subYargs = def.defineArgs(subYargs); } - for (const [segment, node] of subtree.entries()) { - subYargs = walkTreeAndRegister(segment, node, commands, subYargs); + for (const [nextSegment, nextNode] of subtree.entries()) { + subYargs = walkTreeAndRegister(nextSegment, nextNode, subYargs); } return subYargs; @@ -170,3 +156,51 @@ function walkTreeAndRegister( return yargs; } + +// #region utils +function createNodeFor(command: Command, root: DefinitionTreeNode) { + const segments = command.split(" ").slice(1); // eg. ["versions", "secret", "put"] + + let node = root; + for (const segment of segments) { + const subtree = node.subtree; + let child = subtree.get(segment); + if (!child) { + child = { + definition: undefined, + subtree: new Map(), + }; + subtree.set(segment, child); + } + + node = child; + } + + return node; +} +function findNodeFor(command: Command, root: DefinitionTreeNode) { + const segments = command.split(" ").slice(1); // eg. ["versions", "secret", "put"] + + let node = root; + for (const segment of segments) { + const subtree = node.subtree; + const child = subtree.get(segment); + if (!child) { + return undefined; + } + + node = child; + } + + return node; +} +function* walk( + command: Command, + parent: DefinitionTreeNode +): IterableIterator<[Command, DefinitionTreeNode]> { + for (const [segment, node] of parent.subtree) { + yield [`${command} ${segment}`, node]; + yield* walk(`${command} ${segment}`, node); + } +} +// #endregion From 6853900eec0f7b0cb3ebe7ae9242e653dfd78116 Mon Sep 17 00:00:00 2001 From: Rahul Sethi <5822355+RamIdeas@users.noreply.github.com> Date: Wed, 4 Sep 2024 17:41:26 +0100 Subject: [PATCH 07/31] implement registerNamespace --- .../wrangler/src/core/register-commands.ts | 20 +++++++++++++++++++ packages/wrangler/src/core/wrap-command.ts | 15 +++++++------- 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/packages/wrangler/src/core/register-commands.ts b/packages/wrangler/src/core/register-commands.ts index cb7d5ccbf81d..c4afa5485a6a 100644 --- a/packages/wrangler/src/core/register-commands.ts +++ b/packages/wrangler/src/core/register-commands.ts @@ -16,6 +16,26 @@ export default function registerAllCommands(yargs: CommonYargsArgv) { for (const [segment, node] of tree.entries()) { yargs = walkTreeAndRegister(segment, node, yargs); } + + return yargs; +} +/** + * Ideally we would just use registerAllCommands, but we need to be able to + * hook into the way wrangler (hackily) does --help text with yargs right now. + * Once all commands are registered using this utility, we can completely + * take over rendering help text without yargs and use registerAllCommands. + */ +export function registerNamespace(namespace: string, yargs: CommonYargsArgv) { + const tree = createCommandTree(); + const node = tree.get(namespace); + + if (!node) { + throw new CommandRegistrationError( + `No definition found for namespace '${namespace}'` + ); + } + + return walkTreeAndRegister(namespace, node, yargs); } type DefinitionTreeNode = { diff --git a/packages/wrangler/src/core/wrap-command.ts b/packages/wrangler/src/core/wrap-command.ts index 72c80bd7447e..3686042d4426 100644 --- a/packages/wrangler/src/core/wrap-command.ts +++ b/packages/wrangler/src/core/wrap-command.ts @@ -1,11 +1,10 @@ import chalk from "chalk"; -import { CommandBuilder } from "yargs"; import { readConfig } from "../config"; import { FatalError, UserError } from "../errors"; import { logger } from "../logger"; import { printWranglerBanner } from "../update-check"; -import { CommonYargsArgv, CommonYargsOptions } from "../yargs-types"; -import { +import type { CommonYargsArgv } from "../yargs-types"; +import type { BaseNamedArgDefinitions, CommandDefinition, HandlerArgs, @@ -20,8 +19,8 @@ export function wrapCommandDefinition( let commandSuffix = ""; let description = def.metadata.description; let statusMessage = ""; - let defaultDeprecatedMessage = `Deprecated: "${def.command}" is deprecated`; // TODO: improve - let deprecatedMessage = def.metadata.deprecated + const defaultDeprecatedMessage = `Deprecated: "${def.command}" is deprecated`; // TODO: improve + const deprecatedMessage = def.metadata.deprecated ? def.metadata.deprecatedMessage ?? defaultDeprecatedMessage : undefined; let defineArgs: undefined | ((yargs: CommonYargsArgv) => CommonYargsArgv) = @@ -43,8 +42,9 @@ export function wrapCommandDefinition( const commandPositionalArgsSuffix = def.positionalArgs ?.map((key) => { const { demandOption, array } = def.args[key]; - if (demandOption) return `<${key}${array ? ".." : ""}>`; // or - return `[${key}${array ? ".." : ""}]`; // [key] or [key..] + return demandOption + ? `<${key}${array ? ".." : ""}>` // or + : `[${key}${array ? ".." : ""}]`; // [key] or [key..] }) .join(" "); @@ -73,6 +73,7 @@ export function wrapCommandDefinition( if ("handler" in def) { handler = async (args: HandlerArgs) => { + // eslint-disable-next-line no-useless-catch try { await printWranglerBanner(); From 80ddba27034af7856216c8362b1d7f51168af960 Mon Sep 17 00:00:00 2001 From: Rahul Sethi <5822355+RamIdeas@users.noreply.github.com> Date: Wed, 4 Sep 2024 21:08:53 +0100 Subject: [PATCH 08/31] add contrib guide --- packages/wrangler/CONTRIBUTING.md | 156 ++++++++++++++++++++++++++++++ 1 file changed, 156 insertions(+) create mode 100644 packages/wrangler/CONTRIBUTING.md diff --git a/packages/wrangler/CONTRIBUTING.md b/packages/wrangler/CONTRIBUTING.md new file mode 100644 index 000000000000..4c25939f1281 --- /dev/null +++ b/packages/wrangler/CONTRIBUTING.md @@ -0,0 +1,156 @@ +# Wrangler Contributing Guidelines for Internal Teams + +## What you will learn + +- How to add a new command to Wrangler +- How to specify arguments for a command +- How to use experimental flags +- How to read from the config +- How to implement a command +- How to test a command + +## Defining your new command to Wrangler + +1. define the command with the util defineCommand + +```ts + import { defineCommand } from './util'; + + // Namespaces are the prefix before the subcommand + // eg "wrangler kv" in "wrangler kv put" + // eg "wrangler kv key" in "wrangler kv key put" + defineNamespace({ + command: "wrangler kv", + metadata: { + description: "Commands for interacting with Workers KV", + status: "stable", + }, + }); + // Every level of namespaces must be defined + // eg "wrangler kv key" in "wrangler kv key put" + defineNamespace({ + command: "wrangler kv", + metadata: { + description: "Commands for interacting with Workers KV", + status: "stable", + }, + }); + + // Define the command args, implementation and metadata + const command = defineCommand({ + command: "wrangler kv key put", // the full command including the namespace + metadata: { + description: "Put a key-value pair into a Workers KV namespace", + status: "stable", + }, + args: { + key: { + type: "string", + description: "The key to put into the KV namespace", + required: true, + }, + value: { + type: "string", + description: "The value to put into the KV namespace", + required: true, + }, + "namespace-id": { + type: "string", + description: "The namespace to put the key-value pair into", + required: true, + }, + }, + // the positionalArgs defines which of the args are positional and in what order + positionalArgs: ["key", "value"], + handler(args) { + // implementation here + }, + }); +``` + +2. global vs shared vs command specific (named + positional) args + +- Command-specific args are defined in the `args` field of the command definition. Command handlers receive these as a typed object automatically. To make any of these positional, add the key to the `positionalArgs` array. +- You can share args between commands by declaring a separate object and spreading it into the `args` field. Feel free to import from another file. +- Global args are shared across all commands and defined in `src/commands/global-args.ts` (same schema as command-specific args). They are passed to every command handler. + +3. (get a type for the args) + +You may want to pass your args to other functions. These functions will need to be typed. To get a type of your args, you can use `typeof command.args`. + +4. implement the command handler + +A command handler is just a function that receives the args as the first param. This is where you will want to do API calls, I/O, logging, etc. + +- api calls + +Define API response type. Use `fetchResult` to make API calls. `fetchResult` will throw an error if the response is not 2xx. + +```ts + await fetchResult( + `/accounts/${accountId}/workers/services/${scriptName}`, + { method: "DELETE" }, + new URLSearchParams({ force: needsForceDelete.toString() }) + ); +``` + +- logging + +Do not use `console.*` methods to log. You must import and use the `logger` singleton. + +- error handling - UserError vs Error + +Throw `UserError` for errors _caused_ by the user -- these are not sent to Sentry whereas regular `Error` are and show be used for unexpected exceptions. + +Errors are caught at the top-level and formatted for the console. + +## Best Practices + +### Status / Deprecation + +Status can be alpha, private-beta, open-beta, or stable. Breaking changes can freely be made in alpha or private-beta. Try avoid breaking changes in open-beta but are acceptable and should be called out in changeset. + +Stable commands should never have breaking changes. + +### Changesets + +Run `npx changesets` from the top of the repo. New commands warrant a "minor" bump. Please explain the functionality with examples. + +### Experimental Flags + +If you have a stable command, new features should be added behind an experimental flag. By convention, these are named `--experimental-` and have an alias `--x-`. These should be boolean, defaulting to false (off by default). + +To stabilise a feature, flip the default to true while keeping the flag to allow users to disable the feature. + +After a bedding period, you can mark the flag as deprecated and hidden. And remove all code paths using the flag. + +### Documentation + +Add documentation for the command in the [`cloudflare-docs`](https://github.com/cloudflare/cloudflare-docs) repo. + +### PR Best Practices + +- link to a ticket or issue +- add a description of what the PR does _and why_ +- add a description of how to test the PR manually +- test manually with prelease (automatically published by PR bot) +- lint/check before push +- add "e2e" label if you need e2e tests to run + +## Testing + +### Unit/Integration Tests + +These tests are in the `workers-sdk/packages/wrangler/src/__tests__/` directory. + +Write these tests when you need to mock out the API or any module. + +### Fixture Tests + +These tests are in the `workers-sdk/fixtures/` directory. + +Write these when you want to test your feature on a real Workers project. + +### E2E Tests + +Write these when you want to test your feature against the production API. Use describe.each to write the same test against multiple combinations of flags for your command. From 91552882c37de5f1e7e81ad2e22e768e39fb11f4 Mon Sep 17 00:00:00 2001 From: Rahul Sethi <5822355+RamIdeas@users.noreply.github.com> Date: Thu, 5 Sep 2024 16:55:07 +0100 Subject: [PATCH 09/31] add validateArgs --- packages/wrangler/src/core/define-command.ts | 41 +++++++------------- packages/wrangler/src/core/wrap-command.ts | 2 + 2 files changed, 15 insertions(+), 28 deletions(-) diff --git a/packages/wrangler/src/core/define-command.ts b/packages/wrangler/src/core/define-command.ts index 7a46ab3652b5..b8b60f18680e 100644 --- a/packages/wrangler/src/core/define-command.ts +++ b/packages/wrangler/src/core/define-command.ts @@ -32,14 +32,14 @@ export type HandlerArgs = OnlyCamelCase< > >; -export type HandlerContext = { +export type HandlerContext = { /** * The wrangler config file read from disk and parsed. * If no config file can be found, this value will undefined. * Set `behaviour.requireConfig` to refine this type and * throw if it cannot be found. */ - config: RequireConfig extends true ? Config : Config | undefined; + config: Config; /** * The logger instance provided to the command implementor as a convenience. */ @@ -64,7 +64,6 @@ export type HandlerContext = { export type CommandDefinition< NamedArgs extends BaseNamedArgDefinitions = BaseNamedArgDefinitions, - RequireConfig extends boolean = boolean, > = { /** * The full command as it would be written by the user. @@ -82,31 +81,11 @@ export type CommandDefinition< * This will allow wrangler commands to remain consistent and only diverge intentionally. */ behaviour?: { - /** - * If true, throw error if a config file cannot be found. - */ - requireConfig?: RequireConfig; // boolean type which affects the HandlerContext type /** * By default, metrics are sent if the user has opted-in. * This allows metrics to be disabled unconditionally. */ sendMetrics?: false; - sharedArgs?: { - /** - * Enable the --config arg which allows the user to override the default config file path - */ - config?: boolean; - /** - * Enable the --account-id arg which allows the user to override the CLOUDFLARE_ACCOUNT_ID env var and accountId config property - */ - accountId?: boolean; - /** - * Enable the --json arg which enables - */ - json?: boolean; - - // TODO: experimental flags - }; }; /** @@ -122,13 +101,20 @@ export type CommandDefinition< */ positionalArgs?: Array>; + /** + * A hook to implement custom validation of the args before the handler is called. + * Throw `CommandLineArgsError` with actionable error message if args are invalid. + * The return value is ignored. + */ + validateArgs?: (args: HandlerArgs) => void | Promise; + /** * The implementation of the command which is given camelCase'd args * and a ctx object of convenience properties */ handler: ( args: HandlerArgs, - ctx: HandlerContext + ctx: HandlerContext ) => void | Promise; }; @@ -136,10 +122,9 @@ export const COMMAND_DEFINITIONS: Array< CommandDefinition | NamespaceDefinition | AliasDefinition > = []; -export function defineCommand< - NamedArgs extends BaseNamedArgDefinitions, - RequireConfig extends boolean, ->(definition: CommandDefinition) { +export function defineCommand( + definition: CommandDefinition +) { COMMAND_DEFINITIONS.push(definition as unknown as CommandDefinition); return { diff --git a/packages/wrangler/src/core/wrap-command.ts b/packages/wrangler/src/core/wrap-command.ts index 3686042d4426..21891ce6d705 100644 --- a/packages/wrangler/src/core/wrap-command.ts +++ b/packages/wrangler/src/core/wrap-command.ts @@ -86,6 +86,8 @@ export function wrapCommandDefinition( // TODO(telemetry): send command started event + await def.validateArgs?.(args); + await def.handler(args, { config: readConfig(args.config, args), errors: { UserError, FatalError }, From 50196fd6f2c3f8a2da9b79177d60912c85fa0781 Mon Sep 17 00:00:00 2001 From: Rahul Sethi <5822355+RamIdeas@users.noreply.github.com> Date: Fri, 6 Sep 2024 15:25:40 +0100 Subject: [PATCH 10/31] fix: don't register commands more than once per run + implement subhelp --- .../core/command-registration.test.ts | 88 ++++++++++++------- .../wrangler/src/core/register-commands.ts | 62 +++++++------ 2 files changed, 90 insertions(+), 60 deletions(-) diff --git a/packages/wrangler/src/__tests__/core/command-registration.test.ts b/packages/wrangler/src/__tests__/core/command-registration.test.ts index 9980c478e100..54c050a325ae 100644 --- a/packages/wrangler/src/__tests__/core/command-registration.test.ts +++ b/packages/wrangler/src/__tests__/core/command-registration.test.ts @@ -13,8 +13,18 @@ describe("Command Registration", () => { runInTempDir(); const std = mockConsoleMethods(); + let originalDefinitions: typeof COMMAND_DEFINITIONS = []; + beforeAll(() => { + originalDefinitions = COMMAND_DEFINITIONS.slice(); + }); + beforeEach(() => { - COMMAND_DEFINITIONS.length = 0; // clears commands definitions so tests do not conflict with eachother + // resets the commands definitions so the tests do not conflict with eachother + COMMAND_DEFINITIONS.splice( + 0, + COMMAND_DEFINITIONS.length, + ...originalDefinitions + ); // To make these tests less verbose, we will define // a bunch of commands that *use* all features @@ -34,7 +44,10 @@ describe("Command Registration", () => { bool: { type: "boolean", demandOption: true }, arr: { type: "string", array: true, demandOption: true }, optional: { type: "string" }, + pos: { type: "string" }, + posNum: { type: "number" }, }, + positionalArgs: ["pos", "posNum"], handler(args, ctx) { ctx.logger.log(args); }, @@ -113,7 +126,7 @@ describe("Command Registration", () => { test("can define a command and run it", async () => { await runWrangler( - "my-test-command --str foo --num 2 --bool --arr first second --arr third" + "my-test-command positionalFoo 5 --str foo --num 2 --bool --arr first second --arr third" ); expect(std.out).toMatchInlineSnapshot(` @@ -123,7 +136,10 @@ describe("Command Registration", () => { num: 2, bool: true, arr: [ 'first', 'second', 'third' ], - '$0': 'wrangler' + '$0': 'wrangler', + pos: 'positionalFoo', + posNum: 5, + 'pos-num': 5 }" `); }); @@ -150,36 +166,36 @@ describe("Command Registration", () => { "wrangler COMMANDS - wrangler my-test-command My test command - wrangler one namespace 1 - wrangler two namespace 2 - wrangler docs [command] 📚 Open Wrangler's command documentation in your browser - - wrangler init [name] 📥 Initialize a basic Worker - wrangler dev [script] 👂 Start a local server for developing your Worker - wrangler deploy [script] 🆙 Deploy a Worker to Cloudflare [aliases: publish] - wrangler deployments 🚢 List and view the current and past deployments for your Worker [open beta] - wrangler rollback [deployment-id] 🔙 Rollback a deployment for a Worker [open beta] - wrangler delete [script] 🗑 Delete a Worker from Cloudflare - wrangler tail [worker] 🦚 Start a log tailing session for a Worker - wrangler secret 🤫 Generate a secret that can be referenced in a Worker - wrangler types [path] 📝 Generate types from bindings and module rules in configuration - - wrangler kv 🗂️ Manage Workers KV Namespaces - wrangler queues 🇶 Manage Workers Queues - wrangler r2 📦 Manage R2 buckets & objects - wrangler d1 🗄 Manage Workers D1 databases - wrangler vectorize 🧮 Manage Vectorize indexes [open beta] - wrangler hyperdrive 🚀 Manage Hyperdrive databases - wrangler pages ⚡️ Configure Cloudflare Pages - wrangler mtls-certificate 🪪 Manage certificates used for mTLS connections - wrangler pubsub 📮 Manage Pub/Sub brokers [private beta] - wrangler dispatch-namespace 🏗️ Manage dispatch namespaces - wrangler ai 🤖 Manage AI models - - wrangler login 🔓 Login to Cloudflare - wrangler logout 🚪 Logout from Cloudflare - wrangler whoami 🕵️ Retrieve your user information + wrangler docs [command] 📚 Open Wrangler's command documentation in your browser + + wrangler init [name] 📥 Initialize a basic Worker + wrangler dev [script] 👂 Start a local server for developing your Worker + wrangler deploy [script] 🆙 Deploy a Worker to Cloudflare [aliases: publish] + wrangler deployments 🚢 List and view the current and past deployments for your Worker [open beta] + wrangler rollback [deployment-id] 🔙 Rollback a deployment for a Worker [open beta] + wrangler delete [script] 🗑 Delete a Worker from Cloudflare + wrangler tail [worker] 🦚 Start a log tailing session for a Worker + wrangler secret 🤫 Generate a secret that can be referenced in a Worker + wrangler types [path] 📝 Generate types from bindings and module rules in configuration + + wrangler kv 🗂️ Manage Workers KV Namespaces + wrangler queues 🇶 Manage Workers Queues + wrangler r2 📦 Manage R2 buckets & objects + wrangler d1 🗄 Manage Workers D1 databases + wrangler vectorize 🧮 Manage Vectorize indexes [open beta] + wrangler hyperdrive 🚀 Manage Hyperdrive databases + wrangler pages ⚡️ Configure Cloudflare Pages + wrangler mtls-certificate 🪪 Manage certificates used for mTLS connections + wrangler pubsub 📮 Manage Pub/Sub brokers [private beta] + wrangler dispatch-namespace 🏗️ Manage dispatch namespaces + wrangler ai 🤖 Manage AI models + + wrangler login 🔓 Login to Cloudflare + wrangler logout 🚪 Logout from Cloudflare + wrangler whoami 🕵️ Retrieve your user information + wrangler my-test-command [pos] [posNum] My test command + wrangler one namespace 1 + wrangler two namespace 2 GLOBAL FLAGS -j, --experimental-json-config Experimental: support wrangler.json [boolean] @@ -309,10 +325,14 @@ describe("Command Registration", () => { expect(std.out).toContain(`Alias for "wrangler my-test-command".`); expect(std.out).toMatchInlineSnapshot(` - "wrangler my-test-alias + "wrangler my-test-alias [pos] [posNum] Alias for \\"wrangler my-test-command\\". My test command + POSITIONALS + pos [string] + posNum [number] + GLOBAL FLAGS -j, --experimental-json-config Experimental: support wrangler.json [boolean] -c, --config Path to .toml configuration file [string] diff --git a/packages/wrangler/src/core/register-commands.ts b/packages/wrangler/src/core/register-commands.ts index c4afa5485a6a..5c947ce9ce10 100644 --- a/packages/wrangler/src/core/register-commands.ts +++ b/packages/wrangler/src/core/register-commands.ts @@ -1,6 +1,6 @@ import { COMMAND_DEFINITIONS } from "./define-command"; import { wrapCommandDefinition } from "./wrap-command"; -import type { CommonYargsArgv } from "../yargs-types"; +import type { CommonYargsArgv, SubHelp } from "../yargs-types"; import type { AliasDefinition, Command, @@ -10,32 +10,32 @@ import type { export class CommandRegistrationError extends Error {} -export default function registerAllCommands(yargs: CommonYargsArgv) { +export function createCommandRegister( + yargs: CommonYargsArgv, + subHelp: SubHelp +) { const tree = createCommandTree(); - for (const [segment, node] of tree.entries()) { - yargs = walkTreeAndRegister(segment, node, yargs); - } - - return yargs; -} -/** - * Ideally we would just use registerAllCommands, but we need to be able to - * hook into the way wrangler (hackily) does --help text with yargs right now. - * Once all commands are registered using this utility, we can completely - * take over rendering help text without yargs and use registerAllCommands. - */ -export function registerNamespace(namespace: string, yargs: CommonYargsArgv) { - const tree = createCommandTree(); - const node = tree.get(namespace); + return { + registerAll() { + for (const [segment, node] of tree.entries()) { + yargs = walkTreeAndRegister(segment, node, yargs, subHelp); + tree.delete(segment); + } + }, + registerNamespace(namespace: string) { + const node = tree.get(namespace); - if (!node) { - throw new CommandRegistrationError( - `No definition found for namespace '${namespace}'` - ); - } + if (!node) { + throw new CommandRegistrationError( + `No definition found for namespace '${namespace}'` + ); + } - return walkTreeAndRegister(namespace, node, yargs); + tree.delete(namespace); + return walkTreeAndRegister(namespace, node, yargs, subHelp); + }, + }; } type DefinitionTreeNode = { @@ -145,7 +145,8 @@ function createCommandTree() { function walkTreeAndRegister( segment: string, { definition, subtree }: ResolvedDefinitionTreeNode, - yargs: CommonYargsArgv + yargs: CommonYargsArgv, + subHelp: SubHelp ) { if (!definition) { throw new CommandRegistrationError( @@ -163,15 +164,24 @@ function walkTreeAndRegister( (subYargs) => { if (def.defineArgs) { subYargs = def.defineArgs(subYargs); + } else { + // this is our hacky way of printing --help text for incomplete commands + // eg `wrangler kv namespace` will run `wrangler kv namespace --help` + subYargs = subYargs.command(subHelp); } for (const [nextSegment, nextNode] of subtree.entries()) { - subYargs = walkTreeAndRegister(nextSegment, nextNode, subYargs); + subYargs = walkTreeAndRegister( + nextSegment, + nextNode, + subYargs, + subHelp + ); } return subYargs; }, - def.handler // TODO: subHelp (def.handler will be undefined for namespaces, so set default handler to print subHelp) + def.handler // TODO: replace hacky subHelp with default handler impl (def.handler will be undefined for namespaces, so set default handler to print subHelp) ); return yargs; From b5ed6a3f6a015f6faa3d85eb37cc9fb9beb65b16 Mon Sep 17 00:00:00 2001 From: Rahul Sethi <5822355+RamIdeas@users.noreply.github.com> Date: Fri, 6 Sep 2024 15:43:43 +0100 Subject: [PATCH 11/31] refactor KV command registration --- packages/wrangler/src/core/wrap-command.ts | 2 +- packages/wrangler/src/index.ts | 53 +- packages/wrangler/src/kv/index.ts | 1545 +++++++++++--------- 3 files changed, 825 insertions(+), 775 deletions(-) diff --git a/packages/wrangler/src/core/wrap-command.ts b/packages/wrangler/src/core/wrap-command.ts index 21891ce6d705..9df901e3832f 100644 --- a/packages/wrangler/src/core/wrap-command.ts +++ b/packages/wrangler/src/core/wrap-command.ts @@ -72,7 +72,7 @@ export function wrapCommandDefinition( } if ("handler" in def) { - handler = async (args: HandlerArgs) => { + handler = async (args) => { // eslint-disable-next-line no-useless-catch try { await printWranglerBanner(); diff --git a/packages/wrangler/src/index.ts b/packages/wrangler/src/index.ts index a8c3ec5a79f5..b6b1422beed3 100644 --- a/packages/wrangler/src/index.ts +++ b/packages/wrangler/src/index.ts @@ -8,6 +8,7 @@ import { version as wranglerVersion } from "../package.json"; import { ai } from "./ai"; import { cloudchamber } from "./cloudchamber"; import { loadDotEnv, readConfig } from "./config"; +import { createCommandRegister } from "./core/register-commands"; import { d1 } from "./d1"; import { deleteHandler, deleteOptions } from "./delete"; import { deployHandler, deployOptions } from "./deploy"; @@ -41,7 +42,7 @@ import { JsonFriendlyFatalError, UserError } from "./errors"; import { generateHandler, generateOptions } from "./generate"; import { hyperdrive } from "./hyperdrive/index"; import { initHandler, initOptions } from "./init"; -import { kvBulk, kvKey, kvNamespace, registerKvSubcommands } from "./kv"; +import "./kv"; import { logBuildFailure, logger, LOGGER_LEVELS } from "./logger"; import * as metrics from "./metrics"; import { mTlsCertificateCommands } from "./mtls-certificate/cli"; @@ -80,7 +81,6 @@ import { asJson } from "./yargs-types"; import type { Config } from "./config"; import type { LoggerLevel } from "./logger"; import type { CommonYargsArgv, SubHelp } from "./yargs-types"; -import type { Arguments } from "yargs"; const resetColor = "\x1b[0m"; const fgGreenColor = "\x1b[32m"; @@ -163,7 +163,7 @@ export function getLegacyScriptName( * via https://github.com/yargs/yargs/issues/1093#issuecomment-491299261 */ export function demandOneOfOption(...options: string[]) { - return function (argv: Arguments) { + return function (argv: { [key: string]: unknown }) { const count = options.filter((option) => argv[option]).length; const lastOption = options.pop(); @@ -323,6 +323,8 @@ export function createCLIParser(argv: string[]) { } ); + const register = createCommandRegister(wrangler, subHelp); + /* * You will note that we use the form for all commands where we use the builder function * to define options and subcommands. @@ -525,9 +527,7 @@ export function createCLIParser(argv: string[]) { /******************** CMD GROUP ***********************/ // kv - wrangler.command("kv", `🗂️ Manage Workers KV Namespaces`, (kvYargs) => { - return registerKvSubcommands(kvYargs, subHelp); - }); + register.registerNamespace("kv"); // queues wrangler.command("queues", "🇶 Manage Workers Queues", (queuesYargs) => { @@ -753,45 +753,6 @@ export function createCLIParser(argv: string[]) { secretBulkHandler ); - // [DEPRECATED] kv:namespace - wrangler.command( - "kv:namespace", - false, // deprecated, don't show - (namespaceYargs) => { - logger.warn( - "The `wrangler kv:namespace` command is deprecated and will be removed in a future major version. Please use `wrangler kv namespace` instead which behaves the same." - ); - - return kvNamespace(namespaceYargs.command(subHelp)); - } - ); - - // [DEPRECATED] kv:key - wrangler.command( - "kv:key", - false, // deprecated, don't show - (keyYargs) => { - logger.warn( - "The `wrangler kv:key` command is deprecated and will be removed in a future major version. Please use `wrangler kv key` instead which behaves the same." - ); - - return kvKey(keyYargs.command(subHelp)); - } - ); - - // [DEPRECATED] kv:bulk - wrangler.command( - "kv:bulk", - false, // deprecated, don't show - (bulkYargs) => { - logger.warn( - "The `wrangler kv:bulk` command is deprecated and will be removed in a future major version. Please use `wrangler kv bulk` instead which behaves the same." - ); - - return kvBulk(bulkYargs.command(subHelp)); - } - ); - // [DEPRECATED] generate wrangler.command( "generate [name] [template]", @@ -821,6 +782,8 @@ export function createCLIParser(argv: string[]) { } ); + register.registerAll(); + wrangler.exitProcess(false); return wrangler; diff --git a/packages/wrangler/src/kv/index.ts b/packages/wrangler/src/kv/index.ts index a2bc7655ee54..798e58690121 100644 --- a/packages/wrangler/src/kv/index.ts +++ b/packages/wrangler/src/kv/index.ts @@ -2,13 +2,14 @@ import { Blob } from "node:buffer"; import { arrayBuffer } from "node:stream/consumers"; import { StringDecoder } from "node:string_decoder"; import { readConfig } from "../config"; +import { + defineAlias, + defineCommand, + defineNamespace, +} from "../core/define-command"; import { confirm } from "../dialogs"; import { UserError } from "../errors"; -import { - CommandLineArgsError, - demandOneOfOption, - printWranglerBanner, -} from "../index"; +import { CommandLineArgsError, demandOneOfOption } from "../index"; import { logger } from "../logger"; import * as metrics from "../metrics"; import { parseJSON, readFileSync, readFileSyncToBuffer } from "../parse"; @@ -30,754 +31,840 @@ import { usingLocalNamespace, } from "./helpers"; import type { EventNames } from "../metrics"; -import type { CommonYargsArgv, SubHelp } from "../yargs-types"; import type { KeyValue, NamespaceKeyInfo } from "./helpers"; -export function registerKvSubcommands( - kvYargs: CommonYargsArgv, - subHelp: SubHelp -) { - return kvYargs - .command(subHelp) - .command( - "namespace", - `Interact with your Workers KV Namespaces`, - (namespaceYargs) => { - return kvNamespace(namespaceYargs.command(subHelp)); - } - ) - .command( - "key", - `Individually manage Workers KV key-value pairs`, - (keyYargs) => { - return kvKey(keyYargs.command(subHelp)); - } - ) - .command( - "bulk", - `Interact with multiple Workers KV key-value pairs at once`, - (bulkYargs) => { - return kvBulk(bulkYargs.command(subHelp)); - } +defineAlias({ + command: "wrangler kv:key", + aliasOf: "wrangler kv key", + metadata: { deprecated: true }, +}); +defineAlias({ + command: "wrangler kv:namespace", + aliasOf: "wrangler kv namespace", + metadata: { deprecated: true }, +}); +defineAlias({ + command: "wrangler kv:bulk", + aliasOf: "wrangler kv bulk", + metadata: { deprecated: true }, +}); + +defineNamespace({ + command: "wrangler kv", + metadata: { + description: "🗂️ Manage Workers KV Namespaces", + status: "stable", + owner: "Product: KV", + }, +}); + +defineNamespace({ + command: "wrangler kv namespace", + metadata: { + description: `Interact with your Workers KV Namespaces`, + status: "stable", + owner: "Product: KV", + }, +}); + +defineNamespace({ + command: "wrangler kv key", + metadata: { + description: `Individually manage Workers KV key-value pairs`, + status: "stable", + owner: "Product: KV", + }, +}); + +defineNamespace({ + command: "wrangler kv bulk", + metadata: { + description: `Interact with multiple Workers KV key-value pairs at once`, + status: "stable", + owner: "Product: KV", + }, +}); + +defineCommand({ + command: "wrangler kv namespace create", + + metadata: { + description: "Create a new namespace", + status: "stable", + owner: "Product: KV", + }, + + args: { + namespace: { + describe: "The name of the new namespace", + type: "string", + demandOption: true, + }, + preview: { + type: "boolean", + describe: "Interact with a preview namespace", + }, + }, + positionalArgs: ["namespace"], + + async handler(args) { + const config = readConfig(args.config, args); + if (!config.name) { + logger.warn( + "No configured name present, using `worker` as a prefix for the title" + ); + } + + const name = config.name || "worker"; + const environment = args.env ? `-${args.env}` : ""; + const preview = args.preview ? "_preview" : ""; + const title = `${name}${environment}-${args.namespace}${preview}`; + + const accountId = await requireAuth(config); + + // TODO: generate a binding name stripping non alphanumeric chars + + logger.log(`🌀 Creating namespace with title "${title}"`); + const namespaceId = await createKVNamespace(accountId, title); + await metrics.sendMetricsEvent("create kv namespace", { + sendMetrics: config.send_metrics, + }); + + logger.log("✨ Success!"); + const envString = args.env ? ` under [env.${args.env}]` : ""; + const previewString = args.preview ? "preview_" : ""; + logger.log( + `Add the following to your configuration file in your kv_namespaces array${envString}:` ); -} - -export function kvNamespace(kvYargs: CommonYargsArgv) { - return kvYargs - .demandCommand() - .command( - "create ", - "Create a new namespace", - (yargs) => { - return yargs - .positional("namespace", { - describe: "The name of the new namespace", - type: "string", - demandOption: true, - }) - .option("preview", { - type: "boolean", - describe: "Interact with a preview namespace", - }); - }, - async (args) => { - await printWranglerBanner(); + logger.log(`[[kv_namespaces]]`); + logger.log(`binding = "${getValidBindingName(args.namespace, "KV")}"`); + logger.log(`${previewString}id = "${namespaceId}"`); - const config = readConfig(args.config, args); - if (!config.name) { - logger.warn( - "No configured name present, using `worker` as a prefix for the title" - ); - } + // TODO: automatically write this block to the wrangler.toml config file?? + }, +}); - const name = config.name || "worker"; - const environment = args.env ? `-${args.env}` : ""; - const preview = args.preview ? "_preview" : ""; - const title = `${name}${environment}-${args.namespace}${preview}`; +defineCommand({ + command: "wrangler kv namespace list", - const accountId = await requireAuth(config); - - // TODO: generate a binding name stripping non alphanumeric chars - - logger.log(`🌀 Creating namespace with title "${title}"`); - const namespaceId = await createKVNamespace(accountId, title); - await metrics.sendMetricsEvent("create kv namespace", { - sendMetrics: config.send_metrics, - }); - - logger.log("✨ Success!"); - const envString = args.env ? ` under [env.${args.env}]` : ""; - const previewString = args.preview ? "preview_" : ""; - logger.log( - `Add the following to your configuration file in your kv_namespaces array${envString}:` - ); - logger.log(`[[kv_namespaces]]`); - logger.log(`binding = "${getValidBindingName(args.namespace, "KV")}"`); - logger.log(`${previewString}id = "${namespaceId}"`); - - // TODO: automatically write this block to the wrangler.toml config file?? - } - ) - .command( - "list", + metadata: { + description: "Output a list of all KV namespaces associated with your account id", - (listArgs) => listArgs, - async (args) => { - const config = readConfig(args.config, args); - - const accountId = await requireAuth(config); - - // TODO: we should show bindings if they exist for given ids - - logger.log( - JSON.stringify(await listKVNamespaces(accountId), null, " ") - ); - await metrics.sendMetricsEvent("list kv namespaces", { - sendMetrics: config.send_metrics, - }); - } - ) - .command( - "delete", - "Delete a given namespace.", - (yargs) => { - return yargs - .option("binding", { - type: "string", - requiresArg: true, - describe: "The name of the namespace to delete", - }) - .option("namespace-id", { - type: "string", - requiresArg: true, - describe: "The id of the namespace to delete", - }) - .check(demandOneOfOption("binding", "namespace-id")) - .option("preview", { - type: "boolean", - describe: "Interact with a preview namespace", - }); - }, - async (args) => { - await printWranglerBanner(); - const config = readConfig(args.config, args); - - let id; + status: "stable", + owner: "Product: KV", + }, + + args: {}, + + async handler(args) { + const config = readConfig(args.config, args); + + const accountId = await requireAuth(config); + + // TODO: we should show bindings if they exist for given ids + + logger.log(JSON.stringify(await listKVNamespaces(accountId), null, " ")); + await metrics.sendMetricsEvent("list kv namespaces", { + sendMetrics: config.send_metrics, + }); + }, +}); + +defineCommand({ + command: "wrangler kv namespace delete", + + metadata: { + description: "Delete a given namespace.", + status: "stable", + owner: "Product: KV", + }, + + args: { + binding: { + type: "string", + requiresArg: true, + describe: "The name of the namespace to delete", + }, + "namespace-id": { + type: "string", + requiresArg: true, + describe: "The id of the namespace to delete", + }, + preview: { + type: "boolean", + describe: "Interact with a preview namespace", + }, + }, + + validateArgs(args) { + demandOneOfOption("binding", "namespace-id")(args); + }, + + async handler(args) { + const config = readConfig(args.config, args); + + let id; + try { + id = getKVNamespaceId(args, config); + } catch (e) { + throw new CommandLineArgsError( + "Not able to delete namespace.\n" + ((e as Error).message ?? e) + ); + } + + const accountId = await requireAuth(config); + + logger.log(`Deleting KV namespace ${id}.`); + await deleteKVNamespace(accountId, id); + logger.log(`Deleted KV namespace ${id}.`); + await metrics.sendMetricsEvent("delete kv namespace", { + sendMetrics: config.send_metrics, + }); + + // TODO: recommend they remove it from wrangler.toml + + // test-mf wrangler kv:namespace delete --namespace-id 2a7d3d8b23fc4159b5afa489d6cfd388 + // Are you sure you want to delete namespace 2a7d3d8b23fc4159b5afa489d6cfd388? [y/n] + // n + // 💁 Not deleting namespace 2a7d3d8b23fc4159b5afa489d6cfd388 + // ➜ test-mf wrangler kv:namespace delete --namespace-id 2a7d3d8b23fc4159b5afa489d6cfd388 + // Are you sure you want to delete namespace 2a7d3d8b23fc4159b5afa489d6cfd388? [y/n] + // y + // 🌀 Deleting namespace 2a7d3d8b23fc4159b5afa489d6cfd388 + // ✨ Success + // ⚠️ Make sure to remove this "kv-namespace" entry from your configuration file! + // ➜ test-mf + + // TODO: do it automatically + + // TODO: delete the preview namespace as well? + }, +}); + +defineCommand({ + command: "wrangler kv key put", + + metadata: { + description: "Write a single key/value pair to the given namespace", + status: "stable", + owner: "Product: KV", + }, + + positionalArgs: ["key", "value"], + args: { + key: { + type: "string", + describe: "The key to write to", + demandOption: true, + }, + value: { + type: "string", + describe: "The value to write", + }, + binding: { + type: "string", + requiresArg: true, + describe: "The binding of the namespace to write to", + }, + "namespace-id": { + type: "string", + requiresArg: true, + describe: "The id of the namespace to write to", + }, + preview: { + type: "boolean", + describe: "Interact with a preview namespace", + }, + ttl: { + type: "number", + describe: "Time for which the entries should be visible", + }, + expiration: { + type: "number", + describe: "Time since the UNIX epoch after which the entry expires", + }, + metadata: { + type: "string", + describe: "Arbitrary JSON that is associated with a key", + coerce: (jsonStr: string): KeyValue["metadata"] => { try { - id = getKVNamespaceId(args, config); - } catch (e) { - throw new CommandLineArgsError( - "Not able to delete namespace.\n" + ((e as Error).message ?? e) - ); - } - - const accountId = await requireAuth(config); - - logger.log(`Deleting KV namespace ${id}.`); - await deleteKVNamespace(accountId, id); - logger.log(`Deleted KV namespace ${id}.`); - await metrics.sendMetricsEvent("delete kv namespace", { - sendMetrics: config.send_metrics, - }); - - // TODO: recommend they remove it from wrangler.toml - - // test-mf wrangler kv:namespace delete --namespace-id 2a7d3d8b23fc4159b5afa489d6cfd388 - // Are you sure you want to delete namespace 2a7d3d8b23fc4159b5afa489d6cfd388? [y/n] - // n - // 💁 Not deleting namespace 2a7d3d8b23fc4159b5afa489d6cfd388 - // ➜ test-mf wrangler kv:namespace delete --namespace-id 2a7d3d8b23fc4159b5afa489d6cfd388 - // Are you sure you want to delete namespace 2a7d3d8b23fc4159b5afa489d6cfd388? [y/n] - // y - // 🌀 Deleting namespace 2a7d3d8b23fc4159b5afa489d6cfd388 - // ✨ Success - // ⚠️ Make sure to remove this "kv-namespace" entry from your configuration file! - // ➜ test-mf - - // TODO: do it automatically - - // TODO: delete the preview namespace as well? - } - ); -} - -export const kvKey = (kvYargs: CommonYargsArgv) => { - return kvYargs - .demandCommand() - .command( - "put [value]", - "Write a single key/value pair to the given namespace", - (yargs) => { - return yargs - .positional("key", { - type: "string", - describe: "The key to write to", - demandOption: true, - }) - .positional("value", { - type: "string", - describe: "The value to write", - }) - .option("binding", { - type: "string", - requiresArg: true, - describe: "The binding of the namespace to write to", - }) - .option("namespace-id", { - type: "string", - requiresArg: true, - describe: "The id of the namespace to write to", - }) - .check(demandOneOfOption("binding", "namespace-id")) - .option("preview", { - type: "boolean", - describe: "Interact with a preview namespace", - }) - .option("ttl", { - type: "number", - describe: "Time for which the entries should be visible", - }) - .option("expiration", { - type: "number", - describe: "Time since the UNIX epoch after which the entry expires", - }) - .option("metadata", { - type: "string", - describe: "Arbitrary JSON that is associated with a key", - coerce: (jsonStr: string): KeyValue["metadata"] => { - try { - return JSON.parse(jsonStr); - } catch (_) {} - }, - }) - .option("path", { - type: "string", - requiresArg: true, - describe: "Read value from the file at a given path", - }) - .option("local", { - type: "boolean", - describe: "Interact with local storage", - }) - .option("persist-to", { - type: "string", - describe: "Directory for local persistence", - }) - .check(demandOneOfOption("value", "path")); + return JSON.parse(jsonStr); + } catch (_) {} }, - async ({ key, ttl, expiration, metadata, ...args }) => { - await printWranglerBanner(); - const config = readConfig(args.config, args); - const namespaceId = getKVNamespaceId(args, config); - // One of `args.path` and `args.value` must be defined - const value = args.path - ? readFileSyncToBuffer(args.path) - : // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - args.value!; - - const metadataLog = metadata - ? ` with metadata "${JSON.stringify(metadata)}"` - : ""; - - if (args.path) { - logger.log( - `Writing the contents of ${args.path} to the key "${key}" on namespace ${namespaceId}${metadataLog}.` - ); - } else { - logger.log( - `Writing the value "${value}" to key "${key}" on namespace ${namespaceId}${metadataLog}.` - ); - } - - let metricEvent: EventNames; - if (args.local) { - await usingLocalNamespace( - args.persistTo, - config.configPath, - namespaceId, - (namespace) => - namespace.put(key, new Blob([value]).stream(), { - expiration, - expirationTtl: ttl, - metadata, - }) - ); - - metricEvent = "write kv key-value (local)"; - } else { - const accountId = await requireAuth(config); - - await putKVKeyValue(accountId, namespaceId, { - key, - value, + }, + path: { + type: "string", + requiresArg: true, + describe: "Read value from the file at a given path", + }, + local: { + type: "boolean", + describe: "Interact with local storage", + }, + "persist-to": { + type: "string", + describe: "Directory for local persistence", + }, + }, + validateArgs(args) { + demandOneOfOption("binding", "namespace-id")(args); + demandOneOfOption("value", "path")(args); + }, + + async handler({ key, ttl, expiration, metadata, ...args }) { + const config = readConfig(args.config, args); + const namespaceId = getKVNamespaceId(args, config); + // One of `args.path` and `args.value` must be defined + const value = args.path + ? readFileSyncToBuffer(args.path) + : // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + args.value!; + + const metadataLog = metadata + ? ` with metadata "${JSON.stringify(metadata)}"` + : ""; + + if (args.path) { + logger.log( + `Writing the contents of ${args.path} to the key "${key}" on namespace ${namespaceId}${metadataLog}.` + ); + } else { + logger.log( + `Writing the value "${value}" to key "${key}" on namespace ${namespaceId}${metadataLog}.` + ); + } + + let metricEvent: EventNames; + if (args.local) { + await usingLocalNamespace( + args.persistTo, + config.configPath, + namespaceId, + (namespace) => + namespace.put(key, new Blob([value]).stream(), { expiration, - expiration_ttl: ttl, - metadata: metadata as KeyValue["metadata"], - }); - - metricEvent = "write kv key-value"; + expirationTtl: ttl, + metadata, + }) + ); + + metricEvent = "write kv key-value (local)"; + } else { + const accountId = await requireAuth(config); + + await putKVKeyValue(accountId, namespaceId, { + key, + value, + expiration, + expiration_ttl: ttl, + metadata: metadata as KeyValue["metadata"], + }); + + metricEvent = "write kv key-value"; + } + + await metrics.sendMetricsEvent(metricEvent, { + sendMetrics: config.send_metrics, + }); + }, +}); + +defineCommand({ + command: "wrangler kv key list", + + metadata: { + description: "Output a list of all keys in a given namespace", + status: "stable", + owner: "Product: KV", + }, + + args: { + binding: { + type: "string", + requiresArg: true, + describe: "The name of the namespace to list", + }, + "namespace-id": { + type: "string", + requiresArg: true, + describe: "The id of the namespace to list", + }, + preview: { + type: "boolean", + // In the case of listing keys we will default to non-preview mode + default: false, + describe: "Interact with a preview namespace", + }, + prefix: { + type: "string", + requiresArg: true, + describe: "A prefix to filter listed keys", + }, + local: { + type: "boolean", + describe: "Interact with local storage", + }, + "persist-to": { + type: "string", + describe: "Directory for local persistence", + }, + }, + validateArgs(args) { + demandOneOfOption("binding", "namespace-id")(args); + }, + + async handler({ prefix, ...args }) { + // TODO: support for limit+cursor (pagination) + const config = readConfig(args.config, args); + const namespaceId = getKVNamespaceId(args, config); + + let result: NamespaceKeyInfo[]; + let metricEvent: EventNames; + if (args.local) { + const listResult = await usingLocalNamespace( + args.persistTo, + config.configPath, + namespaceId, + (namespace) => namespace.list({ prefix }) + ); + result = listResult.keys as NamespaceKeyInfo[]; + + metricEvent = "list kv keys (local)"; + } else { + const accountId = await requireAuth(config); + + result = await listKVNamespaceKeys(accountId, namespaceId, prefix); + metricEvent = "list kv keys"; + } + + logger.log(JSON.stringify(result, undefined, 2)); + await metrics.sendMetricsEvent(metricEvent, { + sendMetrics: config.send_metrics, + }); + }, +}); + +defineCommand({ + command: "wrangler kv key get", + + metadata: { + description: "Read a single value by key from the given namespace", + status: "stable", + owner: "Product: KV", + }, + + positionalArgs: ["key"], + args: { + key: { + describe: "The key value to get.", + type: "string", + demandOption: true, + }, + binding: { + type: "string", + requiresArg: true, + describe: "The name of the namespace to get from", + }, + "namespace-id": { + type: "string", + requiresArg: true, + describe: "The id of the namespace to get from", + }, + preview: { + type: "boolean", + // In the case of getting key values we will default to non-preview mode + default: false, + describe: "Interact with a preview namespace", + }, + text: { + type: "boolean", + default: false, + describe: "Decode the returned value as a utf8 string", + }, + local: { + type: "boolean", + describe: "Interact with local storage", + }, + "persist-to": { + type: "string", + describe: "Directory for local persistence", + }, + }, + validateArgs(args) { + demandOneOfOption("binding", "namespace-id")(args); + }, + + async handler({ key, ...args }) { + const config = readConfig(args.config, args); + const namespaceId = getKVNamespaceId(args, config); + + let bufferKVValue; + let metricEvent: EventNames; + if (args.local) { + const val = await usingLocalNamespace( + args.persistTo, + config.configPath, + namespaceId, + async (namespace) => { + const stream = await namespace.get(key, "stream"); + // Note `stream` is only valid inside this closure + return stream === null ? null : await arrayBuffer(stream); } + ); - await metrics.sendMetricsEvent(metricEvent, { - sendMetrics: config.send_metrics, - }); + if (val === null) { + logger.log("Value not found"); + return; } - ) - .command( - "list", - "Output a list of all keys in a given namespace", - (yargs) => { - return yargs - .option("binding", { - type: "string", - requiresArg: true, - describe: "The name of the namespace to list", - }) - .option("namespace-id", { - type: "string", - requiresArg: true, - describe: "The id of the namespace to list", - }) - .check(demandOneOfOption("binding", "namespace-id")) - .option("preview", { - type: "boolean", - // In the case of listing keys we will default to non-preview mode - default: false, - describe: "Interact with a preview namespace", - }) - .option("prefix", { - type: "string", - requiresArg: true, - describe: "A prefix to filter listed keys", - }) - .option("local", { - type: "boolean", - describe: "Interact with local storage", - }) - .option("persist-to", { - type: "string", - describe: "Directory for local persistence", - }); - }, - async ({ prefix, ...args }) => { - // TODO: support for limit+cursor (pagination) - const config = readConfig(args.config, args); - const namespaceId = getKVNamespaceId(args, config); - - let result: NamespaceKeyInfo[]; - let metricEvent: EventNames; - if (args.local) { - const listResult = await usingLocalNamespace( - args.persistTo, - config.configPath, - namespaceId, - (namespace) => namespace.list({ prefix }) - ); - result = listResult.keys as NamespaceKeyInfo[]; - metricEvent = "list kv keys (local)"; - } else { - const accountId = await requireAuth(config); - - result = await listKVNamespaceKeys(accountId, namespaceId, prefix); - metricEvent = "list kv keys"; - } - - logger.log(JSON.stringify(result, undefined, 2)); - await metrics.sendMetricsEvent(metricEvent, { - sendMetrics: config.send_metrics, - }); - } - ) - .command( - "get ", - "Read a single value by key from the given namespace", - (yargs) => { - return yargs - .positional("key", { - describe: "The key value to get.", - type: "string", - demandOption: true, - }) - .option("binding", { - type: "string", - requiresArg: true, - describe: "The name of the namespace to get from", - }) - .option("namespace-id", { - type: "string", - requiresArg: true, - describe: "The id of the namespace to get from", - }) - .check(demandOneOfOption("binding", "namespace-id")) - .option("preview", { - type: "boolean", - describe: "Interact with a preview namespace", - }) - .option("preview", { - type: "boolean", - // In the case of getting key values we will default to non-preview mode - default: false, - describe: "Interact with a preview namespace", - }) - .option("text", { - type: "boolean", - default: false, - describe: "Decode the returned value as a utf8 string", - }) - .option("local", { - type: "boolean", - describe: "Interact with local storage", - }) - .option("persist-to", { - type: "string", - describe: "Directory for local persistence", - }); - }, - async ({ key, ...args }) => { - const config = readConfig(args.config, args); - const namespaceId = getKVNamespaceId(args, config); - - let bufferKVValue; - let metricEvent: EventNames; - if (args.local) { - const val = await usingLocalNamespace( - args.persistTo, - config.configPath, - namespaceId, - async (namespace) => { - const stream = await namespace.get(key, "stream"); - // Note `stream` is only valid inside this closure - return stream === null ? null : await arrayBuffer(stream); - } - ); - - if (val === null) { - logger.log("Value not found"); - return; - } - - bufferKVValue = Buffer.from(val); - metricEvent = "read kv value (local)"; - } else { - const accountId = await requireAuth(config); - bufferKVValue = Buffer.from( - await getKVKeyValue(accountId, namespaceId, key) - ); - - metricEvent = "read kv value"; - } - - if (args.text) { - const decoder = new StringDecoder("utf8"); - logger.log(decoder.write(bufferKVValue)); - } else { - process.stdout.write(bufferKVValue); - } - await metrics.sendMetricsEvent(metricEvent, { - sendMetrics: config.send_metrics, - }); - } - ) - .command( - "delete ", - "Remove a single key value pair from the given namespace", - (yargs) => { - return yargs - .positional("key", { - describe: "The key value to delete", - type: "string", - demandOption: true, - }) - .option("binding", { - type: "string", - requiresArg: true, - describe: "The name of the namespace to delete from", - }) - .option("namespace-id", { - type: "string", - requiresArg: true, - describe: "The id of the namespace to delete from", - }) - .check(demandOneOfOption("binding", "namespace-id")) - .option("preview", { - type: "boolean", - describe: "Interact with a preview namespace", - }) - .option("local", { - type: "boolean", - describe: "Interact with local storage", - }) - .option("persist-to", { - type: "string", - describe: "Directory for local persistence", - }); + bufferKVValue = Buffer.from(val); + metricEvent = "read kv value (local)"; + } else { + const accountId = await requireAuth(config); + bufferKVValue = Buffer.from( + await getKVKeyValue(accountId, namespaceId, key) + ); + + metricEvent = "read kv value"; + } + + if (args.text) { + const decoder = new StringDecoder("utf8"); + logger.log(decoder.write(bufferKVValue)); + } else { + process.stdout.write(bufferKVValue); + } + await metrics.sendMetricsEvent(metricEvent, { + sendMetrics: config.send_metrics, + }); + }, +}); + +defineCommand({ + command: "wrangler kv key delete", + + metadata: { + description: "Remove a single key value pair from the given namespace", + status: "stable", + owner: "Product: KV", + }, + + positionalArgs: ["key"], + args: { + key: { + describe: "The key value to delete.", + type: "string", + demandOption: true, + }, + binding: { + type: "string", + requiresArg: true, + describe: "The name of the namespace to delete from", + }, + "namespace-id": { + type: "string", + requiresArg: true, + describe: "The id of the namespace to delete from", + }, + preview: { + type: "boolean", + describe: "Interact with a preview namespace", + }, + local: { + type: "boolean", + describe: "Interact with local storage", + }, + "persist-to": { + type: "string", + describe: "Directory for local persistence", + }, + }, + + async handler({ key, ...args }) { + const config = readConfig(args.config, args); + const namespaceId = getKVNamespaceId(args, config); + + logger.log(`Deleting the key "${key}" on namespace ${namespaceId}.`); + + let metricEvent: EventNames; + if (args.local) { + await usingLocalNamespace( + args.persistTo, + config.configPath, + namespaceId, + (namespace) => namespace.delete(key) + ); + + metricEvent = "delete kv key-value (local)"; + } else { + const accountId = await requireAuth(config); + + await deleteKVKeyValue(accountId, namespaceId, key); + metricEvent = "delete kv key-value"; + } + await metrics.sendMetricsEvent(metricEvent, { + sendMetrics: config.send_metrics, + }); + }, +}); + +defineCommand({ + command: "wrangler kv bulk put", + + metadata: { + description: "Upload multiple key-value pairs to a namespace", + status: "stable", + owner: "Product: KV", + }, + + positionalArgs: ["filename"], + args: { + filename: { + describe: "The file containing the key/value pairs to write", + type: "string", + demandOption: true, + }, + binding: { + type: "string", + requiresArg: true, + describe: "The name of the namespace to write to", + }, + "namespace-id": { + type: "string", + requiresArg: true, + describe: "The id of the namespace to write to", + }, + preview: { + type: "boolean", + describe: "Interact with a preview namespace", + }, + ttl: { + type: "number", + describe: "Time for which the entries should be visible", + }, + expiration: { + type: "number", + describe: "Time since the UNIX epoch after which the entry expires", + }, + metadata: { + type: "string", + describe: "Arbitrary JSON that is associated with a key", + coerce: (jsonStr: string): KeyValue["metadata"] => { + try { + return JSON.parse(jsonStr); + } catch (_) {} }, - async ({ key, ...args }) => { - await printWranglerBanner(); - const config = readConfig(args.config, args); - const namespaceId = getKVNamespaceId(args, config); - - logger.log(`Deleting the key "${key}" on namespace ${namespaceId}.`); - - let metricEvent: EventNames; - if (args.local) { - await usingLocalNamespace( - args.persistTo, - config.configPath, - namespaceId, - (namespace) => namespace.delete(key) + }, + local: { + type: "boolean", + describe: "Interact with local storage", + }, + "persist-to": { + type: "string", + describe: "Directory for local persistence", + }, + }, + + async handler({ filename, ...args }) { + // The simplest implementation I could think of. + // This could be made more efficient with a streaming parser/uploader + // but we'll do that in the future if needed. + + const config = readConfig(args.config, args); + const namespaceId = getKVNamespaceId(args, config); + const content = parseJSON(readFileSync(filename), filename); + + if (!Array.isArray(content)) { + throw new UserError( + `Unexpected JSON input from "${filename}".\n` + + `Expected an array of key-value objects but got type "${typeof content}".` + ); + } + + const errors: string[] = []; + const warnings: string[] = []; + for (let i = 0; i < content.length; i++) { + const keyValue = content[i]; + if (!isKVKeyValue(keyValue)) { + errors.push(`The item at index ${i} is ${JSON.stringify(keyValue)}`); + } else { + const props = unexpectedKVKeyValueProps(keyValue); + if (props.length > 0) { + warnings.push( + `The item at index ${i} contains unexpected properties: ${JSON.stringify( + props + )}.` ); - - metricEvent = "delete kv key-value (local)"; - } else { - const accountId = await requireAuth(config); - - await deleteKVKeyValue(accountId, namespaceId, key); - metricEvent = "delete kv key-value"; } - await metrics.sendMetricsEvent(metricEvent, { - sendMetrics: config.send_metrics, - }); } - ); -}; - -export const kvBulk = (kvYargs: CommonYargsArgv) => { - return kvYargs - .demandCommand() - .command( - "put ", - "Upload multiple key-value pairs to a namespace", - (yargs) => { - return yargs - .positional("filename", { - describe: `The JSON file of key-value pairs to upload, in form [{"key":..., "value":...}"...]`, - type: "string", - demandOption: true, - }) - .option("binding", { - type: "string", - requiresArg: true, - describe: "The name of the namespace to insert values into", - }) - .option("namespace-id", { - type: "string", - requiresArg: true, - describe: "The id of the namespace to insert values into", - }) - .check(demandOneOfOption("binding", "namespace-id")) - .option("preview", { - type: "boolean", - describe: "Interact with a preview namespace", - }) - .option("local", { - type: "boolean", - describe: "Interact with local storage", - }) - .option("persist-to", { - type: "string", - describe: "Directory for local persistence", - }); - }, - async ({ filename, ...args }) => { - await printWranglerBanner(); - // The simplest implementation I could think of. - // This could be made more efficient with a streaming parser/uploader - // but we'll do that in the future if needed. - - const config = readConfig(args.config, args); - const namespaceId = getKVNamespaceId(args, config); - const content = parseJSON(readFileSync(filename), filename); - - if (!Array.isArray(content)) { - throw new UserError( - `Unexpected JSON input from "${filename}".\n` + - `Expected an array of key-value objects but got type "${typeof content}".` - ); - } - - const errors: string[] = []; - const warnings: string[] = []; - for (let i = 0; i < content.length; i++) { - const keyValue = content[i]; - if (!isKVKeyValue(keyValue)) { - errors.push( - `The item at index ${i} is ${JSON.stringify(keyValue)}` - ); - } else { - const props = unexpectedKVKeyValueProps(keyValue); - if (props.length > 0) { - warnings.push( - `The item at index ${i} contains unexpected properties: ${JSON.stringify( - props - )}.` - ); - } + } + if (warnings.length > 0) { + logger.warn( + `Unexpected key-value properties in "${filename}".\n` + + warnings.join("\n") + ); + } + if (errors.length > 0) { + throw new UserError( + `Unexpected JSON input from "${filename}".\n` + + `Each item in the array should be an object that matches:\n\n` + + `interface KeyValue {\n` + + ` key: string;\n` + + ` value: string;\n` + + ` expiration?: number;\n` + + ` expiration_ttl?: number;\n` + + ` metadata?: object;\n` + + ` base64?: boolean;\n` + + `}\n\n` + + errors.join("\n") + ); + } + + let metricEvent: EventNames; + if (args.local) { + await usingLocalNamespace( + args.persistTo, + config.configPath, + namespaceId, + async (namespace) => { + for (const value of content) { + await namespace.put(value.key, value.value, { + expiration: value.expiration, + expirationTtl: value.expiration_ttl, + metadata: value.metadata, + }); } } - if (warnings.length > 0) { - logger.warn( - `Unexpected key-value properties in "${filename}".\n` + - warnings.join("\n") - ); - } - if (errors.length > 0) { - throw new UserError( - `Unexpected JSON input from "${filename}".\n` + - `Each item in the array should be an object that matches:\n\n` + - `interface KeyValue {\n` + - ` key: string;\n` + - ` value: string;\n` + - ` expiration?: number;\n` + - ` expiration_ttl?: number;\n` + - ` metadata?: object;\n` + - ` base64?: boolean;\n` + - `}\n\n` + - errors.join("\n") - ); - } - - let metricEvent: EventNames; - if (args.local) { - await usingLocalNamespace( - args.persistTo, - config.configPath, - namespaceId, - async (namespace) => { - for (const value of content) { - await namespace.put(value.key, value.value, { - expiration: value.expiration, - expirationTtl: value.expiration_ttl, - metadata: value.metadata, - }); - } - } - ); - - metricEvent = "write kv key-values (bulk) (local)"; - } else { - const accountId = await requireAuth(config); - - await putKVBulkKeyValue(accountId, namespaceId, content); - metricEvent = "write kv key-values (bulk)"; - } - - await metrics.sendMetricsEvent(metricEvent, { - sendMetrics: config.send_metrics, - }); - logger.log("Success!"); + ); + + metricEvent = "write kv key-values (bulk) (local)"; + } else { + const accountId = await requireAuth(config); + + await putKVBulkKeyValue(accountId, namespaceId, content); + metricEvent = "write kv key-values (bulk)"; + } + + await metrics.sendMetricsEvent(metricEvent, { + sendMetrics: config.send_metrics, + }); + logger.log("Success!"); + }, +}); + +defineCommand({ + command: "wrangler kv bulk delete", + + metadata: { + description: "Delete multiple key-value pairs from a namespace", + status: "stable", + owner: "Product: KV", + }, + + positionalArgs: ["filename"], + args: { + filename: { + describe: "The file containing the keys to delete", + type: "string", + demandOption: true, + }, + binding: { + type: "string", + requiresArg: true, + describe: "The name of the namespace to delete from", + }, + "namespace-id": { + type: "string", + requiresArg: true, + describe: "The id of the namespace to delete from", + }, + preview: { + type: "boolean", + describe: "Interact with a preview namespace", + }, + force: { + type: "boolean", + alias: "f", + describe: "Do not ask for confirmation before deleting", + }, + local: { + type: "boolean", + describe: "Interact with local storage", + }, + "persist-to": { + type: "string", + describe: "Directory for local persistence", + }, + }, + + async handler({ filename, ...args }) { + const config = readConfig(args.config, args); + const namespaceId = getKVNamespaceId(args, config); + + if (!args.force) { + const result = await confirm( + `Are you sure you want to delete all the keys read from "${filename}" from kv-namespace with id "${namespaceId}"?` + ); + if (!result) { + logger.log(`Not deleting keys read from "${filename}".`); + return; } - ) - .command( - "delete ", - "Delete multiple key-value pairs from a namespace", - (yargs) => { - return yargs - .positional("filename", { - describe: `The JSON file of keys to delete, in the form ["key1", "key2", ...]`, - type: "string", - demandOption: true, - }) - .option("binding", { - type: "string", - requiresArg: true, - describe: "The name of the namespace to delete from", - }) - .option("namespace-id", { - type: "string", - requiresArg: true, - describe: "The id of the namespace to delete from", - }) - .check(demandOneOfOption("binding", "namespace-id")) - .option("preview", { - type: "boolean", - describe: "Interact with a preview namespace", - }) - .option("force", { - type: "boolean", - alias: "f", - describe: "Do not ask for confirmation before deleting", - }) - .option("local", { - type: "boolean", - describe: "Interact with local storage", - }) - .option("persist-to", { - type: "string", - describe: "Directory for local persistence", - }); - }, - async ({ filename, ...args }) => { - await printWranglerBanner(); - const config = readConfig(args.config, args); - const namespaceId = getKVNamespaceId(args, config); - - if (!args.force) { - const result = await confirm( - `Are you sure you want to delete all the keys read from "${filename}" from kv-namespace with id "${namespaceId}"?` - ); - if (!result) { - logger.log(`Not deleting keys read from "${filename}".`); - return; - } - } - - const content = parseJSON(readFileSync(filename), filename) as string[]; - - if (!Array.isArray(content)) { - throw new UserError( - `Unexpected JSON input from "${filename}".\n` + - `Expected an array of strings but got:\n${content}` - ); - } - - const errors: string[] = []; - for (let i = 0; i < content.length; i++) { - const key = content[i]; - if (typeof key !== "string") { - errors.push( - `The item at index ${i} is type: "${typeof key}" - ${JSON.stringify( - key - )}` - ); + } + + const content = parseJSON(readFileSync(filename), filename) as string[]; + + if (!Array.isArray(content)) { + throw new UserError( + `Unexpected JSON input from "${filename}".\n` + + `Expected an array of strings but got:\n${content}` + ); + } + + const errors: string[] = []; + for (let i = 0; i < content.length; i++) { + const key = content[i]; + if (typeof key !== "string") { + errors.push( + `The item at index ${i} is type: "${typeof key}" - ${JSON.stringify( + key + )}` + ); + } + } + + if (errors.length > 0) { + throw new UserError( + `Unexpected JSON input from "${filename}".\n` + + `Expected an array of strings.\n` + + errors.join("\n") + ); + } + + let metricEvent: EventNames; + if (args.local) { + await usingLocalNamespace( + args.persistTo, + config.configPath, + namespaceId, + async (namespace) => { + for (const key of content) { + await namespace.delete(key); } } + ); - if (errors.length > 0) { - throw new UserError( - `Unexpected JSON input from "${filename}".\n` + - `Expected an array of strings.\n` + - errors.join("\n") - ); - } - - let metricEvent: EventNames; - if (args.local) { - await usingLocalNamespace( - args.persistTo, - config.configPath, - namespaceId, - async (namespace) => { - for (const key of content) { - await namespace.delete(key); - } - } - ); + metricEvent = "delete kv key-values (bulk) (local)"; + } else { + const accountId = await requireAuth(config); - metricEvent = "delete kv key-values (bulk) (local)"; - } else { - const accountId = await requireAuth(config); + await deleteKVBulkKeyValue(accountId, namespaceId, content); + metricEvent = "delete kv key-values (bulk)"; + } - await deleteKVBulkKeyValue(accountId, namespaceId, content); - metricEvent = "delete kv key-values (bulk)"; - } - - await metrics.sendMetricsEvent(metricEvent, { - sendMetrics: config.send_metrics, - }); + await metrics.sendMetricsEvent(metricEvent, { + sendMetrics: config.send_metrics, + }); - logger.log("Success!"); - } - ); -}; + logger.log("Success!"); + }, +}); From 7ac47ad19a82155a63a6d80698f87faf0f7e030a Mon Sep 17 00:00:00 2001 From: Rahul Sethi <5822355+RamIdeas@users.noreply.github.com> Date: Fri, 6 Sep 2024 16:04:24 +0100 Subject: [PATCH 12/31] update snapshots --- .../core/command-registration.test.ts | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/packages/wrangler/src/__tests__/core/command-registration.test.ts b/packages/wrangler/src/__tests__/core/command-registration.test.ts index 54c050a325ae..51844b63fe99 100644 --- a/packages/wrangler/src/__tests__/core/command-registration.test.ts +++ b/packages/wrangler/src/__tests__/core/command-registration.test.ts @@ -136,6 +136,12 @@ describe("Command Registration", () => { num: 2, bool: true, arr: [ 'first', 'second', 'third' ], + 'experimental-versions': true, + 'x-versions': true, + 'experimental-gradual-rollouts': true, + xVersions: true, + experimentalGradualRollouts: true, + experimentalVersions: true, '$0': 'wrangler', pos: 'positionalFoo', posNum: 5, @@ -171,8 +177,10 @@ describe("Command Registration", () => { wrangler init [name] 📥 Initialize a basic Worker wrangler dev [script] 👂 Start a local server for developing your Worker wrangler deploy [script] 🆙 Deploy a Worker to Cloudflare [aliases: publish] - wrangler deployments 🚢 List and view the current and past deployments for your Worker [open beta] - wrangler rollback [deployment-id] 🔙 Rollback a deployment for a Worker [open beta] + wrangler deployments 🚢 List and view the current and past deployments for your Worker + wrangler rollback [version-id] 🔙 Rollback a deployment for a Worker + wrangler versions 🫧 List, view, upload and deploy Versions of your Worker to Cloudflare + wrangler triggers 🎯 Updates the triggers of your current deployment wrangler delete [script] 🗑 Delete a Worker from Cloudflare wrangler tail [worker] 🦚 Start a log tailing session for a Worker wrangler secret 🤫 Generate a secret that can be referenced in a Worker @@ -283,6 +291,12 @@ describe("Command Registration", () => { num: 3, bool: true, arr: [ '1st', '2nd', '3rd' ], + 'experimental-versions': true, + 'x-versions': true, + 'experimental-gradual-rollouts': true, + xVersions: true, + experimentalGradualRollouts: true, + experimentalVersions: true, '$0': 'wrangler' }" `); From 23af2339de437246eebaa00bed385240bef76f54 Mon Sep 17 00:00:00 2001 From: Rahul Sethi <5822355+RamIdeas@users.noreply.github.com> Date: Fri, 6 Sep 2024 16:06:18 +0100 Subject: [PATCH 13/31] prettier --- packages/wrangler/CONTRIBUTING.md | 114 +++++++++++++++--------------- 1 file changed, 57 insertions(+), 57 deletions(-) diff --git a/packages/wrangler/CONTRIBUTING.md b/packages/wrangler/CONTRIBUTING.md index 4c25939f1281..90fa82e52006 100644 --- a/packages/wrangler/CONTRIBUTING.md +++ b/packages/wrangler/CONTRIBUTING.md @@ -14,58 +14,58 @@ 1. define the command with the util defineCommand ```ts - import { defineCommand } from './util'; - - // Namespaces are the prefix before the subcommand - // eg "wrangler kv" in "wrangler kv put" - // eg "wrangler kv key" in "wrangler kv key put" - defineNamespace({ - command: "wrangler kv", - metadata: { - description: "Commands for interacting with Workers KV", - status: "stable", - }, - }); - // Every level of namespaces must be defined - // eg "wrangler kv key" in "wrangler kv key put" - defineNamespace({ - command: "wrangler kv", - metadata: { - description: "Commands for interacting with Workers KV", - status: "stable", - }, - }); - - // Define the command args, implementation and metadata - const command = defineCommand({ - command: "wrangler kv key put", // the full command including the namespace - metadata: { - description: "Put a key-value pair into a Workers KV namespace", - status: "stable", - }, - args: { - key: { - type: "string", - description: "The key to put into the KV namespace", - required: true, - }, - value: { - type: "string", - description: "The value to put into the KV namespace", - required: true, - }, - "namespace-id": { - type: "string", - description: "The namespace to put the key-value pair into", - required: true, - }, - }, - // the positionalArgs defines which of the args are positional and in what order - positionalArgs: ["key", "value"], - handler(args) { - // implementation here - }, - }); +import { defineCommand } from "./util"; + +// Namespaces are the prefix before the subcommand +// eg "wrangler kv" in "wrangler kv put" +// eg "wrangler kv key" in "wrangler kv key put" +defineNamespace({ + command: "wrangler kv", + metadata: { + description: "Commands for interacting with Workers KV", + status: "stable", + }, +}); +// Every level of namespaces must be defined +// eg "wrangler kv key" in "wrangler kv key put" +defineNamespace({ + command: "wrangler kv", + metadata: { + description: "Commands for interacting with Workers KV", + status: "stable", + }, +}); + +// Define the command args, implementation and metadata +const command = defineCommand({ + command: "wrangler kv key put", // the full command including the namespace + metadata: { + description: "Put a key-value pair into a Workers KV namespace", + status: "stable", + }, + args: { + key: { + type: "string", + description: "The key to put into the KV namespace", + required: true, + }, + value: { + type: "string", + description: "The value to put into the KV namespace", + required: true, + }, + "namespace-id": { + type: "string", + description: "The namespace to put the key-value pair into", + required: true, + }, + }, + // the positionalArgs defines which of the args are positional and in what order + positionalArgs: ["key", "value"], + handler(args) { + // implementation here + }, +}); ``` 2. global vs shared vs command specific (named + positional) args @@ -87,11 +87,11 @@ A command handler is just a function that receives the args as the first param. Define API response type. Use `fetchResult` to make API calls. `fetchResult` will throw an error if the response is not 2xx. ```ts - await fetchResult( - `/accounts/${accountId}/workers/services/${scriptName}`, - { method: "DELETE" }, - new URLSearchParams({ force: needsForceDelete.toString() }) - ); +await fetchResult( + `/accounts/${accountId}/workers/services/${scriptName}`, + { method: "DELETE" }, + new URLSearchParams({ force: needsForceDelete.toString() }) +); ``` - logging From 021182acf11987a62e9a9788008b40c84fd3d588 Mon Sep 17 00:00:00 2001 From: Rahul Sethi <5822355+RamIdeas@users.noreply.github.com> Date: Mon, 9 Sep 2024 12:44:18 +0100 Subject: [PATCH 14/31] implement behaviour.printBanner option --- packages/wrangler/src/core/define-command.ts | 14 ++++++++++++-- packages/wrangler/src/core/wrap-command.ts | 4 +++- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/packages/wrangler/src/core/define-command.ts b/packages/wrangler/src/core/define-command.ts index b8b60f18680e..a9be53988372 100644 --- a/packages/wrangler/src/core/define-command.ts +++ b/packages/wrangler/src/core/define-command.ts @@ -83,9 +83,19 @@ export type CommandDefinition< behaviour?: { /** * By default, metrics are sent if the user has opted-in. - * This allows metrics to be disabled unconditionally. + * Set this value to `false` to disable metrics unconditionally. + * + * @default true */ - sendMetrics?: false; + sendMetrics?: boolean; + + /** + * By default, wrangler's version banner will be printed before the handler is executed. + * Set this value to `false` to skip printing the banner. + * + * @default true + */ + printBanner?: boolean; }; /** diff --git a/packages/wrangler/src/core/wrap-command.ts b/packages/wrangler/src/core/wrap-command.ts index 9df901e3832f..aef51f5a18bd 100644 --- a/packages/wrangler/src/core/wrap-command.ts +++ b/packages/wrangler/src/core/wrap-command.ts @@ -75,7 +75,9 @@ export function wrapCommandDefinition( handler = async (args) => { // eslint-disable-next-line no-useless-catch try { - await printWranglerBanner(); + if (def.behaviour?.printBanner !== false) { + await printWranglerBanner(); + } if (deprecatedMessage) { logger.warn(deprecatedMessage); From b29e451ef32722b874cdf0cb6c9d6c0929c8734d Mon Sep 17 00:00:00 2001 From: Rahul Sethi <5822355+RamIdeas@users.noreply.github.com> Date: Mon, 9 Sep 2024 12:44:46 +0100 Subject: [PATCH 15/31] disable printBanner for some kv commands to match existing behaviour --- packages/wrangler/src/kv/index.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/wrangler/src/kv/index.ts b/packages/wrangler/src/kv/index.ts index 798e58690121..467d0c913013 100644 --- a/packages/wrangler/src/kv/index.ts +++ b/packages/wrangler/src/kv/index.ts @@ -156,6 +156,7 @@ defineCommand({ args: {}, + behaviour: { printBanner: false }, async handler(args) { const config = readConfig(args.config, args); @@ -413,6 +414,7 @@ defineCommand({ demandOneOfOption("binding", "namespace-id")(args); }, + behaviour: { printBanner: false }, async handler({ prefix, ...args }) { // TODO: support for limit+cursor (pagination) const config = readConfig(args.config, args); @@ -494,6 +496,7 @@ defineCommand({ demandOneOfOption("binding", "namespace-id")(args); }, + behaviour: { printBanner: false }, async handler({ key, ...args }) { const config = readConfig(args.config, args); const namespaceId = getKVNamespaceId(args, config); From e1ba44f7b79fe09b3b140c875629b523331909c1 Mon Sep 17 00:00:00 2001 From: Rahul Sethi <5822355+RamIdeas@users.noreply.github.com> Date: Mon, 9 Sep 2024 16:23:32 +0100 Subject: [PATCH 16/31] add custom deprecation messages to match existing behaviour --- packages/wrangler/src/kv/index.ts | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/packages/wrangler/src/kv/index.ts b/packages/wrangler/src/kv/index.ts index 467d0c913013..ffbd56fffbbe 100644 --- a/packages/wrangler/src/kv/index.ts +++ b/packages/wrangler/src/kv/index.ts @@ -36,17 +36,29 @@ import type { KeyValue, NamespaceKeyInfo } from "./helpers"; defineAlias({ command: "wrangler kv:key", aliasOf: "wrangler kv key", - metadata: { deprecated: true }, + metadata: { + deprecated: true, + deprecatedMessage: + "The `wrangler kv:key` command is deprecated and will be removed in a future major version. Please use `wrangler kv key` instead which behaves the same.", + }, }); defineAlias({ command: "wrangler kv:namespace", aliasOf: "wrangler kv namespace", - metadata: { deprecated: true }, + metadata: { + deprecated: true, + deprecatedMessage: + "The `wrangler kv:namespace` command is deprecated and will be removed in a future major version. Please use `wrangler kv namespace` instead which behaves the same.", + }, }); defineAlias({ command: "wrangler kv:bulk", aliasOf: "wrangler kv bulk", - metadata: { deprecated: true }, + metadata: { + deprecated: true, + deprecatedMessage: + "The `wrangler kv:bulk` command is deprecated and will be removed in a future major version. Please use `wrangler kv bulk` instead which behaves the same.", + }, }); defineNamespace({ From b570b2fb506d4facf129932d521fb845dbc5a460 Mon Sep 17 00:00:00 2001 From: Rahul Sethi <5822355+RamIdeas@users.noreply.github.com> Date: Mon, 9 Sep 2024 16:26:47 +0100 Subject: [PATCH 17/31] chore --- packages/wrangler/CONTRIBUTING.md | 6 +++--- packages/wrangler/src/core/register-commands.ts | 4 ++-- packages/wrangler/src/core/teams.d.ts | 2 +- packages/wrangler/src/core/wrap-command.ts | 1 + 4 files changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/wrangler/CONTRIBUTING.md b/packages/wrangler/CONTRIBUTING.md index 90fa82e52006..825b3a660f0a 100644 --- a/packages/wrangler/CONTRIBUTING.md +++ b/packages/wrangler/CONTRIBUTING.md @@ -14,7 +14,7 @@ 1. define the command with the util defineCommand ```ts -import { defineCommand } from "./util"; +import { defineCommand, defineNamespace } from "./util"; // Namespaces are the prefix before the subcommand // eg "wrangler kv" in "wrangler kv put" @@ -29,9 +29,9 @@ defineNamespace({ // Every level of namespaces must be defined // eg "wrangler kv key" in "wrangler kv key put" defineNamespace({ - command: "wrangler kv", + command: "wrangler kv key", metadata: { - description: "Commands for interacting with Workers KV", + description: "Commands for interacting with Workers KV data", status: "stable", }, }); diff --git a/packages/wrangler/src/core/register-commands.ts b/packages/wrangler/src/core/register-commands.ts index 5c947ce9ce10..3b17af083688 100644 --- a/packages/wrangler/src/core/register-commands.ts +++ b/packages/wrangler/src/core/register-commands.ts @@ -19,7 +19,7 @@ export function createCommandRegister( return { registerAll() { for (const [segment, node] of tree.entries()) { - yargs = walkTreeAndRegister(segment, node, yargs, subHelp); + walkTreeAndRegister(segment, node, yargs, subHelp); tree.delete(segment); } }, @@ -33,7 +33,7 @@ export function createCommandRegister( } tree.delete(namespace); - return walkTreeAndRegister(namespace, node, yargs, subHelp); + walkTreeAndRegister(namespace, node, yargs, subHelp); }, }; } diff --git a/packages/wrangler/src/core/teams.d.ts b/packages/wrangler/src/core/teams.d.ts index b2cda5d5fee4..82499b0fab84 100644 --- a/packages/wrangler/src/core/teams.d.ts +++ b/packages/wrangler/src/core/teams.d.ts @@ -11,7 +11,7 @@ export type Teams = | "Product: R2" | "Product: D1" | "Product: Queues" - | "Produce: AI" + | "Product: AI" | "Product: Hyperdrive" | "Product: Vectorize" | "Product: Cloudchamber"; diff --git a/packages/wrangler/src/core/wrap-command.ts b/packages/wrangler/src/core/wrap-command.ts index aef51f5a18bd..d13ba1e17e66 100644 --- a/packages/wrangler/src/core/wrap-command.ts +++ b/packages/wrangler/src/core/wrap-command.ts @@ -79,6 +79,7 @@ export function wrapCommandDefinition( await printWranglerBanner(); } + // TODO: log deprecation/status message of parent namespace(s) if (deprecatedMessage) { logger.warn(deprecatedMessage); } From 56e274f6b5fb569c69eb23e5b469414b91987b19 Mon Sep 17 00:00:00 2001 From: Rahul Sethi <5822355+RamIdeas@users.noreply.github.com> Date: Tue, 10 Sep 2024 16:52:25 +0100 Subject: [PATCH 18/31] update docs --- packages/wrangler/CONTRIBUTING.md | 78 ++++++++++++++------ packages/wrangler/src/core/define-command.ts | 3 - 2 files changed, 54 insertions(+), 27 deletions(-) diff --git a/packages/wrangler/CONTRIBUTING.md b/packages/wrangler/CONTRIBUTING.md index 825b3a660f0a..73c440e3737d 100644 --- a/packages/wrangler/CONTRIBUTING.md +++ b/packages/wrangler/CONTRIBUTING.md @@ -9,9 +9,9 @@ - How to implement a command - How to test a command -## Defining your new command to Wrangler +## Defining your new command in Wrangler -1. define the command with the util defineCommand +1. Define the command structure with the utils `defineNamespace()` & `defineCommand()` ```ts import { defineCommand, defineNamespace } from "./util"; @@ -47,60 +47,72 @@ const command = defineCommand({ key: { type: "string", description: "The key to put into the KV namespace", - required: true, + demandOption: true, }, value: { type: "string", description: "The value to put into the KV namespace", - required: true, + demandOption: true, }, "namespace-id": { type: "string", description: "The namespace to put the key-value pair into", - required: true, }, }, // the positionalArgs defines which of the args are positional and in what order positionalArgs: ["key", "value"], - handler(args) { + handler(args, ctx) { // implementation here }, }); ``` -2. global vs shared vs command specific (named + positional) args +2. Command specific (named + positional) args vs Shared args vs Global args - Command-specific args are defined in the `args` field of the command definition. Command handlers receive these as a typed object automatically. To make any of these positional, add the key to the `positionalArgs` array. - You can share args between commands by declaring a separate object and spreading it into the `args` field. Feel free to import from another file. -- Global args are shared across all commands and defined in `src/commands/global-args.ts` (same schema as command-specific args). They are passed to every command handler. +- Global args are shared across all commands and defined in `src/commands/global-args.ts` (same schema as command-specific args). They are available in every command handler. -3. (get a type for the args) +3. Optionally, get a type for the args You may want to pass your args to other functions. These functions will need to be typed. To get a type of your args, you can use `typeof command.args`. -4. implement the command handler +4. Implement the command handler -A command handler is just a function that receives the args as the first param. This is where you will want to do API calls, I/O, logging, etc. +A command handler is just a function that receives the `args` as the first param and `ctx` as the second param. This is where you will want to do API calls, I/O, logging, etc. -- api calls +- API calls -Define API response type. Use `fetchResult` to make API calls. `fetchResult` will throw an error if the response is not 2xx. +Define API response type. Use `fetchResult` to make authenticated API calls. Import it from `src/cfetch` or use `ctx.fetchResult`. `fetchResult` will throw an error if the response is not 2xx. ```ts -await fetchResult( - `/accounts/${accountId}/workers/services/${scriptName}`, - { method: "DELETE" }, - new URLSearchParams({ force: needsForceDelete.toString() }) +type UploadResponse = { + jwt?: string; +}; + +const res = await fetchResult( + `/accounts/${accountId}/workers/assets/upload`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: payload, + } ); ``` -- logging +- Logging + +Do not use `console.*` methods to log. You must the `logger` singleton (imported from `src/logger`) or use `ctx.logger`. + +- Error handling - UserError vs Error -Do not use `console.*` methods to log. You must import and use the `logger` singleton. +These classes can be imported from `src/errors` or found on `ctx`, eg. `ctx.errors.UserError`. -- error handling - UserError vs Error +Throw `UserError` for errors _caused_ by the user -- these are not sent to Sentry whereas regular `Error`s are and should be used for unexpected exceptions. -Throw `UserError` for errors _caused_ by the user -- these are not sent to Sentry whereas regular `Error` are and show be used for unexpected exceptions. +For example, if an exception was encountered because the user provided an invalid SQL statement in a D1 command, a `UserError` should be thrown. Whereas, if the D1 local DB crashed for another reason or there was a network error, a regular `Error` would be thrown. Errors are caught at the top-level and formatted for the console. @@ -108,7 +120,7 @@ Errors are caught at the top-level and formatted for the console. ### Status / Deprecation -Status can be alpha, private-beta, open-beta, or stable. Breaking changes can freely be made in alpha or private-beta. Try avoid breaking changes in open-beta but are acceptable and should be called out in changeset. +Status can be alpha, private-beta, open-beta, or stable. Breaking changes can freely be made in alpha or private-beta. Try avoid breaking changes in open-beta but are acceptable and should be called out in [a changeset](../../CONTRIBUTING.md#Changesets). Stable commands should never have breaking changes. @@ -116,13 +128,31 @@ Stable commands should never have breaking changes. Run `npx changesets` from the top of the repo. New commands warrant a "minor" bump. Please explain the functionality with examples. +For example: + +```md +feat: implement the `wrangler versions deploy` command + +This command allows users to deploy a multiple versions of their Worker. + +Note: while in open-beta, the `--experimental-versions` flag is required. + +For interactive use (to be prompted for all options), run: + +- `wrangler versions deploy --x-versions` + +For non-interactive use, run with CLI args (and `--yes` to accept defaults): + +- `wrangler versions deploy --version-id $v1 --percentage 90 --version-id $v2 --percentage 10 --yes` +``` + ### Experimental Flags If you have a stable command, new features should be added behind an experimental flag. By convention, these are named `--experimental-` and have an alias `--x-`. These should be boolean, defaulting to false (off by default). -To stabilise a feature, flip the default to true while keeping the flag to allow users to disable the feature. +To stabilise a feature, flip the default to true while keeping the flag to allow users to disable the feature with `--no-x-`. -After a bedding period, you can mark the flag as deprecated and hidden. And remove all code paths using the flag. +After a validation period with no issues reported, you can mark the flag as deprecated and hidden, and remove all code paths using the flag. ### Documentation diff --git a/packages/wrangler/src/core/define-command.ts b/packages/wrangler/src/core/define-command.ts index a9be53988372..a363df7bf2f3 100644 --- a/packages/wrangler/src/core/define-command.ts +++ b/packages/wrangler/src/core/define-command.ts @@ -35,9 +35,6 @@ export type HandlerArgs = OnlyCamelCase< export type HandlerContext = { /** * The wrangler config file read from disk and parsed. - * If no config file can be found, this value will undefined. - * Set `behaviour.requireConfig` to refine this type and - * throw if it cannot be found. */ config: Config; /** From d00593caaa18c0e65387656268dabeef26ca55b8 Mon Sep 17 00:00:00 2001 From: Rahul Sethi <5822355+RamIdeas@users.noreply.github.com> Date: Wed, 11 Sep 2024 15:57:31 +0100 Subject: [PATCH 19/31] fix: arg types --- packages/wrangler/src/core/define-command.ts | 20 +++++++++++++++----- packages/wrangler/src/yargs-types.ts | 2 +- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/packages/wrangler/src/core/define-command.ts b/packages/wrangler/src/core/define-command.ts index a363df7bf2f3..ce2774f54d00 100644 --- a/packages/wrangler/src/core/define-command.ts +++ b/packages/wrangler/src/core/define-command.ts @@ -2,7 +2,7 @@ import type { Config } from "../config"; import type { OnlyCamelCase } from "../config/config"; import type { FatalError, UserError } from "../errors"; import type { Logger } from "../logger"; -import type { CommonYargsOptions } from "../yargs-types"; +import type { CommonYargsOptions, RemoveIndex } from "../yargs-types"; import type { Teams } from "./teams"; import type { Alias, @@ -24,14 +24,24 @@ export type Metadata = { }; export type ArgDefinition = PositionalOptions & Pick; -export type BaseNamedArgDefinitions = { [key: string]: ArgDefinition }; +export type BaseNamedArgDefinitions = { + [key: string]: ArgDefinition; +}; type StringKeyOf = Extract; -export type HandlerArgs = OnlyCamelCase< - ArgumentsCamelCase< - CommonYargsOptions & InferredOptionTypes & Alias +export type HandlerArgs = DeepFlatten< + OnlyCamelCase< + RemoveIndex< + ArgumentsCamelCase< + CommonYargsOptions & InferredOptionTypes & Alias + > + > > >; +type DeepFlatten = T extends object + ? { [K in keyof T]: DeepFlatten } + : T; + export type HandlerContext = { /** * The wrangler config file read from disk and parsed. diff --git a/packages/wrangler/src/yargs-types.ts b/packages/wrangler/src/yargs-types.ts index b6bb31d4f201..43eda01e60c1 100644 --- a/packages/wrangler/src/yargs-types.ts +++ b/packages/wrangler/src/yargs-types.ts @@ -30,7 +30,7 @@ export type YargvToInterface = T extends Argv ? ArgumentsCamelCase

: never; // See http://stackoverflow.com/questions/51465182/how-to-remove-index-signature-using-mapped-types -type RemoveIndex = { +export type RemoveIndex = { [K in keyof T as string extends K ? never : number extends K From 034fa4b551640d6603bb44a370b2584221d41345 Mon Sep 17 00:00:00 2001 From: Rahul Sethi <5822355+RamIdeas@users.noreply.github.com> Date: Wed, 11 Sep 2024 15:58:46 +0100 Subject: [PATCH 20/31] chore: rename --- packages/wrangler/src/core/register-commands.ts | 4 ++-- packages/wrangler/src/core/wrap-command.ts | 4 +--- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/wrangler/src/core/register-commands.ts b/packages/wrangler/src/core/register-commands.ts index 3b17af083688..19fa4e2bcda2 100644 --- a/packages/wrangler/src/core/register-commands.ts +++ b/packages/wrangler/src/core/register-commands.ts @@ -1,5 +1,5 @@ import { COMMAND_DEFINITIONS } from "./define-command"; -import { wrapCommandDefinition } from "./wrap-command"; +import { wrapDefinition } from "./wrap-command"; import type { CommonYargsArgv, SubHelp } from "../yargs-types"; import type { AliasDefinition, @@ -155,7 +155,7 @@ function walkTreeAndRegister( } // convert our definition into something we can pass to yargs.command - const def = wrapCommandDefinition(definition); + const def = wrapDefinition(definition); // register command yargs.command( diff --git a/packages/wrangler/src/core/wrap-command.ts b/packages/wrangler/src/core/wrap-command.ts index d13ba1e17e66..bd0d540b2c5c 100644 --- a/packages/wrangler/src/core/wrap-command.ts +++ b/packages/wrangler/src/core/wrap-command.ts @@ -13,9 +13,7 @@ import type { const betaCmdColor = "#BD5B08"; -export function wrapCommandDefinition( - def: CommandDefinition | NamespaceDefinition -) { +export function wrapDefinition(def: CommandDefinition | NamespaceDefinition) { let commandSuffix = ""; let description = def.metadata.description; let statusMessage = ""; From 4d8e70942261b9b5fefe5b928c8601e9d57b3777 Mon Sep 17 00:00:00 2001 From: Rahul Sethi <5822355+RamIdeas@users.noreply.github.com> Date: Wed, 11 Sep 2024 16:44:17 +0100 Subject: [PATCH 21/31] chore: no need to return yargs --- packages/wrangler/src/core/register-commands.ts | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/packages/wrangler/src/core/register-commands.ts b/packages/wrangler/src/core/register-commands.ts index 19fa4e2bcda2..0d49028b6d17 100644 --- a/packages/wrangler/src/core/register-commands.ts +++ b/packages/wrangler/src/core/register-commands.ts @@ -163,28 +163,19 @@ function walkTreeAndRegister( (def.hidden ? false : def.description) as string, // cast to satisfy typescript overload selection (subYargs) => { if (def.defineArgs) { - subYargs = def.defineArgs(subYargs); + def.defineArgs(subYargs); } else { // this is our hacky way of printing --help text for incomplete commands // eg `wrangler kv namespace` will run `wrangler kv namespace --help` - subYargs = subYargs.command(subHelp); + subYargs.command(subHelp); } for (const [nextSegment, nextNode] of subtree.entries()) { - subYargs = walkTreeAndRegister( - nextSegment, - nextNode, - subYargs, - subHelp - ); + walkTreeAndRegister(nextSegment, nextNode, subYargs, subHelp); } - - return subYargs; }, def.handler // TODO: replace hacky subHelp with default handler impl (def.handler will be undefined for namespaces, so set default handler to print subHelp) ); - - return yargs; } // #region utils From f411ef17ce8e28cc16eb337384fa85a8afb9b1f6 Mon Sep 17 00:00:00 2001 From: Rahul Sethi <5822355+RamIdeas@users.noreply.github.com> Date: Wed, 11 Sep 2024 16:48:32 +0100 Subject: [PATCH 22/31] chore: rename --- packages/wrangler/src/core/register-commands.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/wrangler/src/core/register-commands.ts b/packages/wrangler/src/core/register-commands.ts index 0d49028b6d17..251de7eaa2e8 100644 --- a/packages/wrangler/src/core/register-commands.ts +++ b/packages/wrangler/src/core/register-commands.ts @@ -75,7 +75,7 @@ function createCommandTree() { // STEP 1: Create tree from flat definitions array for (const def of COMMAND_DEFINITIONS) { - const node = createNodeFor(def.command, root); + const node = upsertNodeFor(def.command, root); if (node.definition) { throw new CommandRegistrationError( @@ -100,7 +100,7 @@ function createCommandTree() { continue; } - const node = createNodeFor(def.command, root); + const node = upsertNodeFor(def.command, root); node.definition = { ...real, @@ -179,7 +179,7 @@ function walkTreeAndRegister( } // #region utils -function createNodeFor(command: Command, root: DefinitionTreeNode) { +function upsertNodeFor(command: Command, root: DefinitionTreeNode) { const segments = command.split(" ").slice(1); // eg. ["versions", "secret", "put"] let node = root; From dfbaad70e7a381bb0ebdfed944b5641d96ba35d4 Mon Sep 17 00:00:00 2001 From: Rahul Sethi <5822355+RamIdeas@users.noreply.github.com> Date: Wed, 11 Sep 2024 19:42:56 +0100 Subject: [PATCH 23/31] update snapshot --- .../wrangler/src/__tests__/core/command-registration.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/wrangler/src/__tests__/core/command-registration.test.ts b/packages/wrangler/src/__tests__/core/command-registration.test.ts index 51844b63fe99..f766072e5152 100644 --- a/packages/wrangler/src/__tests__/core/command-registration.test.ts +++ b/packages/wrangler/src/__tests__/core/command-registration.test.ts @@ -197,6 +197,7 @@ describe("Command Registration", () => { wrangler pubsub 📮 Manage Pub/Sub brokers [private beta] wrangler dispatch-namespace 🏗️ Manage dispatch namespaces wrangler ai 🤖 Manage AI models + wrangler pipelines 🚰 Manage Worker Pipelines [open beta] wrangler login 🔓 Login to Cloudflare wrangler logout 🚪 Logout from Cloudflare From 0c7738b102ae9f374dd5d3eac3736ccf429a7064 Mon Sep 17 00:00:00 2001 From: Rahul Sethi <5822355+RamIdeas@users.noreply.github.com> Date: Wed, 11 Sep 2024 22:00:41 +0100 Subject: [PATCH 24/31] chore: DefineCommandResult --- packages/wrangler/src/core/define-command.ts | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/packages/wrangler/src/core/define-command.ts b/packages/wrangler/src/core/define-command.ts index ce2774f54d00..6febef13e8dd 100644 --- a/packages/wrangler/src/core/define-command.ts +++ b/packages/wrangler/src/core/define-command.ts @@ -139,17 +139,20 @@ export const COMMAND_DEFINITIONS: Array< CommandDefinition | NamespaceDefinition | AliasDefinition > = []; +type DefineCommandResult = + DeepFlatten<{ + args: HandlerArgs; // used for type inference only + }>; export function defineCommand( definition: CommandDefinition -) { +): DefineCommandResult; +export function defineCommand( + definition: CommandDefinition +): DefineCommandResult { COMMAND_DEFINITIONS.push(definition as unknown as CommandDefinition); - return { - definition, - get args(): HandlerArgs { - throw new Error(); - }, - }; + // @ts-ignore return type is used for type inference only + return {}; } export type NamespaceDefinition = { From 81f7cb732837ee21fbe2bf10aa0abb5ce2d6cf05 Mon Sep 17 00:00:00 2001 From: Rahul Sethi <5822355+RamIdeas@users.noreply.github.com> Date: Wed, 11 Sep 2024 22:01:19 +0100 Subject: [PATCH 25/31] remove behaviour.sendMetrics --- packages/wrangler/src/core/define-command.ts | 8 -------- 1 file changed, 8 deletions(-) diff --git a/packages/wrangler/src/core/define-command.ts b/packages/wrangler/src/core/define-command.ts index 6febef13e8dd..4ea947113f35 100644 --- a/packages/wrangler/src/core/define-command.ts +++ b/packages/wrangler/src/core/define-command.ts @@ -88,14 +88,6 @@ export type CommandDefinition< * This will allow wrangler commands to remain consistent and only diverge intentionally. */ behaviour?: { - /** - * By default, metrics are sent if the user has opted-in. - * Set this value to `false` to disable metrics unconditionally. - * - * @default true - */ - sendMetrics?: boolean; - /** * By default, wrangler's version banner will be printed before the handler is executed. * Set this value to `false` to skip printing the banner. From d39580bbb71f726a0f70c2e591b285333991b93d Mon Sep 17 00:00:00 2001 From: Rahul Sethi <5822355+RamIdeas@users.noreply.github.com> Date: Thu, 12 Sep 2024 09:27:15 +0100 Subject: [PATCH 26/31] Update define-command.ts --- packages/wrangler/src/core/define-command.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/wrangler/src/core/define-command.ts b/packages/wrangler/src/core/define-command.ts index 4ea947113f35..6f15feab4491 100644 --- a/packages/wrangler/src/core/define-command.ts +++ b/packages/wrangler/src/core/define-command.ts @@ -143,7 +143,7 @@ export function defineCommand( ): DefineCommandResult { COMMAND_DEFINITIONS.push(definition as unknown as CommandDefinition); - // @ts-ignore return type is used for type inference only + // @ts-expect-error return type is used for type inference only return {}; } From 09a65e54c83412062e3950b60a89ad748fd568e7 Mon Sep 17 00:00:00 2001 From: Rahul Sethi <5822355+RamIdeas@users.noreply.github.com> Date: Thu, 19 Sep 2024 17:06:43 +0100 Subject: [PATCH 27/31] refactor --- .../core/command-registration.test.ts | 75 ++-- packages/wrangler/src/core/define-command.ts | 98 +++-- packages/wrangler/src/core/index.ts | 1 + .../wrangler/src/core/register-commands.ts | 377 +++++++++++------- packages/wrangler/src/core/wrap-command.ts | 115 ------ packages/wrangler/src/kv/index.ts | 9 +- 6 files changed, 332 insertions(+), 343 deletions(-) create mode 100644 packages/wrangler/src/core/index.ts delete mode 100644 packages/wrangler/src/core/wrap-command.ts diff --git a/packages/wrangler/src/__tests__/core/command-registration.test.ts b/packages/wrangler/src/__tests__/core/command-registration.test.ts index f766072e5152..39815b4ed681 100644 --- a/packages/wrangler/src/__tests__/core/command-registration.test.ts +++ b/packages/wrangler/src/__tests__/core/command-registration.test.ts @@ -1,9 +1,10 @@ import { normalizeOutput } from "../../../e2e/helpers/normalize"; import { - COMMAND_DEFINITIONS, defineAlias, defineCommand, defineNamespace, + DefinitionTreeNode, + DefinitionTreeRoot, } from "../../core/define-command"; import { mockConsoleMethods } from "../helpers/mock-console"; import { runInTempDir } from "../helpers/run-in-tmp"; @@ -13,18 +14,14 @@ describe("Command Registration", () => { runInTempDir(); const std = mockConsoleMethods(); - let originalDefinitions: typeof COMMAND_DEFINITIONS = []; + let originalDefinitions: [string, DefinitionTreeNode][]; beforeAll(() => { - originalDefinitions = COMMAND_DEFINITIONS.slice(); + originalDefinitions = [...DefinitionTreeRoot.subtree.entries()]; }); beforeEach(() => { // resets the commands definitions so the tests do not conflict with eachother - COMMAND_DEFINITIONS.splice( - 0, - COMMAND_DEFINITIONS.length, - ...originalDefinitions - ); + DefinitionTreeRoot.subtree = new Map(originalDefinitions); // To make these tests less verbose, we will define // a bunch of commands that *use* all features @@ -165,9 +162,6 @@ describe("Command Registration", () => { test("displays commands in top-level --help", async () => { await runWrangler("--help"); - // TODO: fix ordering in top-level --help output - // The current ordering is hackily built on top of yargs default output - // This abstraction will enable us to completely customise the --help output expect(std.out).toMatchInlineSnapshot(` "wrangler @@ -331,9 +325,6 @@ describe("Command Registration", () => { defineAlias({ command: "wrangler my-test-alias", aliasOf: "wrangler my-test-command", - metadata: { - hidden: false, - }, }); await runWrangler("my-test-alias --help"); @@ -342,7 +333,9 @@ describe("Command Registration", () => { expect(std.out).toMatchInlineSnapshot(` "wrangler my-test-alias [pos] [posNum] - Alias for \\"wrangler my-test-command\\". My test command + My test command + + Alias for \\"wrangler my-test-command\\". POSITIONALS pos [string] @@ -383,7 +376,7 @@ describe("Command Registration", () => { expect(std.out).toMatchInlineSnapshot(`"Ran command"`); expect(normalizeOutput(std.warn)).toMatchInlineSnapshot( - `"▲ [WARNING] 🚧 \`wrangler alpha-command\` is a alpha command. Please report any issues to https://github.com/cloudflare/workers-sdk/issues/new/choose"` + `"▲ [WARNING] 🚧 \`wrangler alpha-command\` is an alpha command. Please report any issues to https://github.com/cloudflare/workers-sdk/issues/new/choose"` ); }); test("auto log deprecation message", async () => { @@ -436,36 +429,32 @@ describe("Command Registration", () => { describe("registration errors", () => { test("throws upon duplicate command definition", async () => { - defineCommand({ - command: "wrangler my-test-command", - metadata: { - description: "", - owner: "Workers: Authoring and Testing", - status: "stable", - }, - args: {}, - handler() {}, - }); - - await expect( - runWrangler("my-test-command") - ).rejects.toMatchInlineSnapshot( + await expect(() => { + defineCommand({ + command: "wrangler my-test-command", + metadata: { + description: "", + owner: "Workers: Authoring and Testing", + status: "stable", + }, + args: {}, + handler() {}, + }); + }).toThrowErrorMatchingInlineSnapshot( `[Error: Duplicate definition for "wrangler my-test-command"]` ); }); test("throws upon duplicate namespace definition", async () => { - defineNamespace({ - command: "wrangler one two", - metadata: { - description: "", - owner: "Workers: Authoring and Testing", - status: "stable", - }, - }); - - await expect( - runWrangler("my-test-command") - ).rejects.toMatchInlineSnapshot( + await expect(() => { + defineNamespace({ + command: "wrangler one two", + metadata: { + description: "", + owner: "Workers: Authoring and Testing", + status: "stable", + }, + }); + }).toThrowErrorMatchingInlineSnapshot( `[Error: Duplicate definition for "wrangler one two"]` ); }); @@ -505,7 +494,7 @@ describe("Command Registration", () => { await expect( runWrangler("my-test-command") ).rejects.toMatchInlineSnapshot( - `[Error: Alias of alias encountered greater than 5 hops]` + `[Error: Missing definition for "wrangler undefined-command" (resolving from "wrangler my-alias-command")]` ); }); }); diff --git a/packages/wrangler/src/core/define-command.ts b/packages/wrangler/src/core/define-command.ts index 6f15feab4491..16ccd1b1d1ea 100644 --- a/packages/wrangler/src/core/define-command.ts +++ b/packages/wrangler/src/core/define-command.ts @@ -1,3 +1,4 @@ +import type { fetchResult } from "../cfetch"; import type { Config } from "../config"; import type { OnlyCamelCase } from "../config/config"; import type { FatalError, UserError } from "../errors"; @@ -12,6 +13,13 @@ import type { PositionalOptions, } from "yargs"; +export class CommandRegistrationError extends Error {} + +type StringKeyOf = Extract; +export type DeepFlatten = T extends object + ? { [K in keyof T]: DeepFlatten } + : T; + export type Command = `wrangler${string}`; export type Metadata = { description: string; @@ -23,12 +31,10 @@ export type Metadata = { owner: Teams; }; -export type ArgDefinition = PositionalOptions & Pick; -export type BaseNamedArgDefinitions = { - [key: string]: ArgDefinition; -}; -type StringKeyOf = Extract; -export type HandlerArgs = DeepFlatten< +export type ArgDefinition = PositionalOptions & + Pick; +export type NamedArgDefinitions = { [key: string]: ArgDefinition }; +export type HandlerArgs = DeepFlatten< OnlyCamelCase< RemoveIndex< ArgumentsCamelCase< @@ -38,10 +44,6 @@ export type HandlerArgs = DeepFlatten< > >; -type DeepFlatten = T extends object - ? { [K in keyof T]: DeepFlatten } - : T; - export type HandlerContext = { /** * The wrangler config file read from disk and parsed. @@ -51,6 +53,10 @@ export type HandlerContext = { * The logger instance provided to the command implementor as a convenience. */ logger: Logger; + /** + * Use fetchResult to make *auth'd* requests to the Cloudflare API. + */ + fetchResult: typeof fetchResult; /** * Error classes provided to the command implementor as a convenience * to aid discoverability and to encourage their usage. @@ -70,7 +76,7 @@ export type HandlerContext = { }; export type CommandDefinition< - NamedArgs extends BaseNamedArgDefinitions = BaseNamedArgDefinitions, + NamedArgDefs extends NamedArgDefinitions = NamedArgDefinitions, > = { /** * The full command as it would be written by the user. @@ -101,47 +107,43 @@ export type CommandDefinition< * A plain key-value object describing the CLI args for this command. * Shared args can be defined as another plain object and spread into this. */ - args: NamedArgs; + args: NamedArgDefs; /** * Optionally declare some of the named args as positional args. * The order of this array is the order they are expected in the command. * Use args[key].demandOption and args[key].array to declare required and variadic * positional args, respectively. */ - positionalArgs?: Array>; + positionalArgs?: Array>; /** * A hook to implement custom validation of the args before the handler is called. * Throw `CommandLineArgsError` with actionable error message if args are invalid. * The return value is ignored. */ - validateArgs?: (args: HandlerArgs) => void | Promise; + validateArgs?: (args: HandlerArgs) => void | Promise; /** * The implementation of the command which is given camelCase'd args * and a ctx object of convenience properties */ handler: ( - args: HandlerArgs, + args: HandlerArgs, ctx: HandlerContext ) => void | Promise; }; -export const COMMAND_DEFINITIONS: Array< - CommandDefinition | NamespaceDefinition | AliasDefinition -> = []; - -type DefineCommandResult = +type DefineCommandResult = DeepFlatten<{ - args: HandlerArgs; // used for type inference only + args: HandlerArgs; // used for type inference only }>; -export function defineCommand( - definition: CommandDefinition -): DefineCommandResult; +export function defineCommand( + definition: CommandDefinition +): DefineCommandResult; export function defineCommand( definition: CommandDefinition -): DefineCommandResult { - COMMAND_DEFINITIONS.push(definition as unknown as CommandDefinition); +): DefineCommandResult { + upsertDefinition({ type: "command", ...definition }); // @ts-expect-error return type is used for type inference only return {}; @@ -152,7 +154,7 @@ export type NamespaceDefinition = { metadata: Metadata; }; export function defineNamespace(definition: NamespaceDefinition) { - COMMAND_DEFINITIONS.push(definition); + upsertDefinition({ type: "namespace", ...definition }); } export type AliasDefinition = { @@ -161,5 +163,45 @@ export type AliasDefinition = { metadata?: Partial; }; export function defineAlias(definition: AliasDefinition) { - COMMAND_DEFINITIONS.push(definition); + upsertDefinition({ type: "alias", ...definition }); +} + +export type InternalDefinition = + | ({ type: "command" } & CommandDefinition) + | ({ type: "namespace" } & NamespaceDefinition) + | ({ type: "alias" } & AliasDefinition); +export type DefinitionTreeNode = { + definition?: InternalDefinition; + subtree: DefinitionTree; +}; +export type DefinitionTree = Map; + +export const DefinitionTreeRoot: DefinitionTreeNode = { subtree: new Map() }; +function upsertDefinition(def: InternalDefinition, root = DefinitionTreeRoot) { + const segments = def.command.split(" ").slice(1); // eg. ["versions", "secret", "put"] + + let node = root; + for (const segment of segments) { + const subtree = node.subtree; + let child = subtree.get(segment); + if (!child) { + child = { + definition: undefined, + subtree: new Map(), + }; + subtree.set(segment, child); + } + + node = child; + } + + if (node.definition) { + throw new CommandRegistrationError( + `Duplicate definition for "${def.command}"` + ); + } + + node.definition = def; + + return node; } diff --git a/packages/wrangler/src/core/index.ts b/packages/wrangler/src/core/index.ts new file mode 100644 index 000000000000..70dc50053d0c --- /dev/null +++ b/packages/wrangler/src/core/index.ts @@ -0,0 +1 @@ +export { defineAlias, defineCommand, defineNamespace } from "./define-command"; diff --git a/packages/wrangler/src/core/register-commands.ts b/packages/wrangler/src/core/register-commands.ts index 251de7eaa2e8..7e33d03b6efb 100644 --- a/packages/wrangler/src/core/register-commands.ts +++ b/packages/wrangler/src/core/register-commands.ts @@ -1,211 +1,292 @@ -import { COMMAND_DEFINITIONS } from "./define-command"; -import { wrapDefinition } from "./wrap-command"; +import assert from "node:assert"; +import chalk from "chalk"; +import { fetchResult } from "../cfetch"; +import { readConfig } from "../config"; +import { FatalError, UserError } from "../errors"; +import { logger } from "../logger"; +import { printWranglerBanner } from "../update-check"; +import { CommandRegistrationError, DefinitionTreeRoot } from "./define-command"; import type { CommonYargsArgv, SubHelp } from "../yargs-types"; import type { - AliasDefinition, Command, CommandDefinition, - NamespaceDefinition, + DefinitionTreeNode, + HandlerArgs, + InternalDefinition, + NamedArgDefinitions, } from "./define-command"; -export class CommandRegistrationError extends Error {} +const betaCmdColor = "#BD5B08"; export function createCommandRegister( yargs: CommonYargsArgv, subHelp: SubHelp ) { - const tree = createCommandTree(); + const tree = DefinitionTreeRoot.subtree; + const registeredNamespaces = new Set(); return { registerAll() { for (const [segment, node] of tree.entries()) { - walkTreeAndRegister(segment, node, yargs, subHelp); - tree.delete(segment); + if (registeredNamespaces.has(segment)) { + continue; + } + registeredNamespaces.add(segment); + walkTreeAndRegister( + segment, + node, + yargs, + subHelp, + `wrangler ${segment}` + ); } }, registerNamespace(namespace: string) { + if (registeredNamespaces.has(namespace)) { + return; + } + const node = tree.get(namespace); - if (!node) { + if (!node?.definition) { throw new CommandRegistrationError( - `No definition found for namespace '${namespace}'` + `Missing namespace definition for 'wrangler ${namespace}'` ); } - tree.delete(namespace); - walkTreeAndRegister(namespace, node, yargs, subHelp); + registeredNamespaces.add(namespace); + walkTreeAndRegister( + namespace, + node, + yargs, + subHelp, + `wrangler ${namespace}` + ); }, }; } -type DefinitionTreeNode = { - definition?: CommandDefinition | NamespaceDefinition | AliasDefinition; - subtree: DefinitionTree; -}; -type DefinitionTree = Map; +function walkTreeAndRegister( + segment: string, + node: DefinitionTreeNode, + yargs: CommonYargsArgv, + subHelp: SubHelp, + fullCommand: Command +) { + if (!node.definition) { + throw new CommandRegistrationError( + `Missing namespace definition for '${fullCommand}'` + ); + } -type ResolvedDefinitionTreeNode = { - definition?: CommandDefinition | NamespaceDefinition; - subtree: ResolvedDefinitionTree; -}; -type ResolvedDefinitionTree = Map; + const aliasOf = node.definition.type === "alias" && node.definition.aliasOf; + const { definition: def, subtree } = resolveDefinitionNode(node); -/** - * Converts a flat list of COMMAND_DEFINITIONS into a tree of defintions - * which can be passed to yargs builder api - * - * For example, - * wrangler dev - * wrangler deploy - * wrangler versions upload - * wrangler versions deploy - * - * Will be transformed into: - * wrangler - * dev - * deploy - * versions - * upload - * deploy - */ -function createCommandTree() { - const root: DefinitionTreeNode = { subtree: new Map() }; - const aliases = new Set(); + if (aliasOf) { + def.metadata.description += `\n\nAlias for "${aliasOf}".`; + } - // STEP 1: Create tree from flat definitions array + if (def.metadata.deprecated) { + def.metadata.deprecatedMessage ??= `Deprecated: "${def.command}" is deprecated`; + } - for (const def of COMMAND_DEFINITIONS) { - const node = upsertNodeFor(def.command, root); + if (def.metadata.status !== "stable") { + def.metadata.description += chalk.hex(betaCmdColor)( + ` [${def.metadata.status}]` + ); - if (node.definition) { - throw new CommandRegistrationError( - `Duplicate definition for "${def.command}"` - ); - } - node.definition = def; + const indefiniteArticle = "aeiou".includes(def.metadata.status[0]) + ? "an" + : "a"; + def.metadata.statusMessage ??= `🚧 \`${def.command}\` is ${indefiniteArticle} ${def.metadata.status} command. Please report any issues to https://github.com/cloudflare/workers-sdk/issues/new/choose`; + } - if ("aliasOf" in def) { - aliases.add(def); + if (def.type === "command") { + // inference from positionalArgs + const commandPositionalArgsSuffix = def.positionalArgs + ?.map((key) => { + const { demandOption, array } = def.args[key]; + return demandOption + ? `<${key}${array ? ".." : ""}>` // or + : `[${key}${array ? ".." : ""}]`; // [key] or [key..] + }) + .join(" "); + + if (commandPositionalArgsSuffix) { + segment += " " + commandPositionalArgsSuffix; } } - // STEP 2: Resolve all aliases to their real definitions - - const MAX_HOPS = 5; // reloop to allow aliases of aliases (to avoid infinite loop, limit to 5 hops) - for (let hops = 0; hops < MAX_HOPS && aliases.size > 0; hops++) { - for (const def of aliases) { - const realNode = findNodeFor(def.aliasOf, root); - const real = realNode?.definition; - if (!real || "aliasOf" in real) { - continue; + // register command + yargs.command( + segment, + (def.metadata.hidden ? false : def.metadata.description) as string, // cast to satisfy typescript overload selection + function builder(subYargs) { + if (def.type === "command") { + yargs.options(def.args); + + for (const key of def.positionalArgs ?? []) { + yargs.positional(key, def.args[key]); + } + } else if (def.type === "namespace") { + // this is our hacky way of printing --help text for incomplete commands + // eg `wrangler kv namespace` will run `wrangler kv namespace --help` + subYargs.command(subHelp); } - const node = upsertNodeFor(def.command, root); + for (const [nextSegment, nextNode] of subtree.entries()) { + walkTreeAndRegister( + nextSegment, + nextNode, + subYargs, + subHelp, + `${fullCommand} ${nextSegment}` + ); + } + }, + def.type === "command" ? createHandler(def) : undefined + ); +} - node.definition = { - ...real, - command: def.command, - metadata: { - ...real.metadata, - ...def.metadata, - description: - def.metadata?.description ?? // use description override - `Alias for "${real.command}". ${real.metadata.description}`, // or add prefix to real description - hidden: def.metadata?.hidden ?? true, // hide aliases by default - }, - }; +function createHandler(def: CommandDefinition) { + return async function handler(args: HandlerArgs) { + // eslint-disable-next-line no-useless-catch + try { + if (def.behaviour?.printBanner !== false) { + await printWranglerBanner(); + } - node.subtree = realNode.subtree; + if (def.metadata.deprecated) { + logger.warn(def.metadata.deprecatedMessage); + } + if (def.metadata.statusMessage) { + logger.warn(def.metadata.statusMessage); + } - aliases.delete(def); - } - } + // TODO(telemetry): send command started event - if (aliases.size > 0) { - throw new CommandRegistrationError( - `Alias of alias encountered greater than ${MAX_HOPS} hops` - ); - } + await def.validateArgs?.(args); - // STEP 3: validate missing namespace definitions + await def.handler(args, { + config: readConfig(args.config, args), + errors: { UserError, FatalError }, + logger, + fetchResult, + }); - for (const [command, node] of walk("wrangler", root)) { - if (!node.definition) { - throw new CommandRegistrationError( - `Missing namespace definition for '${command}'` - ); + // TODO(telemetry): send command completed event + } catch (err) { + // TODO(telemetry): send command errored event + throw err; } - } + }; +} - // STEP 4: return the resolved tree +// #region utils - return root.subtree as ResolvedDefinitionTree; +/** + * Returns a non-alias (resolved) definition and subtree with inherited metadata values + * + * Inheriting metadata values means deprecated namespaces also automatically + * deprecates its subcommands unless the subcommand overrides it. + * The same inheritiance applies to deprecation-/status-messages, hidden, etc... + */ +function resolveDefinitionNode( + node: DefinitionTreeNode, + root = DefinitionTreeRoot +) { + assert(node.definition); + const chain = resolveDefinitionChain(node.definition, root); + + // get non-alias (resolved) definition + const resolvedDef = chain.find((def) => def.type !== "alias"); + assert(resolvedDef); + + // get subtree for the resolved node + const { subtree } = + node.definition.type !== "alias" + ? node + : findNodeFor(resolvedDef.command, root) ?? node; + + const definition: InternalDefinition = { + // take all properties from the resolved alias + ...resolvedDef, + // keep the original command + command: node.definition.command, + // flatten metadata from entire chain (decreasing precedence) + metadata: Object.assign({}, ...chain.map((def) => def.metadata).reverse()), + }; + + return { definition, subtree }; } -function walkTreeAndRegister( - segment: string, - { definition, subtree }: ResolvedDefinitionTreeNode, - yargs: CommonYargsArgv, - subHelp: SubHelp +/** + * Returns a list of definitions starting from `def` + * walking "up" the tree to the `root` and hopping "across" the tree for aliases + * + * eg. `wrangler versions secret put` => `wrangler versions secret` => `wrangler versions` + * eg. `wrangler kv:key put` => `wrangler kv:key` => `wrangler kv key` => `wrangler kv` + */ +function resolveDefinitionChain( + def: InternalDefinition, + root = DefinitionTreeRoot ) { - if (!definition) { - throw new CommandRegistrationError( - `Missing namespace definition for '${segment}'` - ); - } + const chain: InternalDefinition[] = []; + const stringifyChain = (...extra: InternalDefinition[]) => + [...chain, ...extra].map((def) => `"${def.command}"`).join(" => "); - // convert our definition into something we can pass to yargs.command - const def = wrapDefinition(definition); + while (true) { + if (chain.includes(def)) { + throw new CommandRegistrationError( + `Circular reference detected for alias definition: "${def.command}" (resolving from ${stringifyChain(def)})` + ); + } - // register command - yargs.command( - segment + def.commandSuffix, - (def.hidden ? false : def.description) as string, // cast to satisfy typescript overload selection - (subYargs) => { - if (def.defineArgs) { - def.defineArgs(subYargs); - } else { - // this is our hacky way of printing --help text for incomplete commands - // eg `wrangler kv namespace` will run `wrangler kv namespace --help` - subYargs.command(subHelp); - } + chain.push(def); - for (const [nextSegment, nextNode] of subtree.entries()) { - walkTreeAndRegister(nextSegment, nextNode, subYargs, subHelp); - } - }, - def.handler // TODO: replace hacky subHelp with default handler impl (def.handler will be undefined for namespaces, so set default handler to print subHelp) - ); -} + const node = + def.type === "alias" + ? findNodeFor(def.aliasOf, root) + : findParentFor(def.command, root); -// #region utils -function upsertNodeFor(command: Command, root: DefinitionTreeNode) { - const segments = command.split(" ").slice(1); // eg. ["versions", "secret", "put"] + if (node === root) { + return chain; + } - let node = root; - for (const segment of segments) { - const subtree = node.subtree; - let child = subtree.get(segment); - if (!child) { - child = { - definition: undefined, - subtree: new Map(), - }; - subtree.set(segment, child); + if (!node?.definition) { + throw new CommandRegistrationError( + `Missing definition for "${def.type === "alias" ? def.aliasOf : def.command}" (resolving from ${stringifyChain()})` + ); } - node = child; + def = node.definition; } +} - return node; +/** + * Finds the parent node for a command by removing the last segment of the command and calling findNodeFor + * + * eg. findParentFor("wrangler kv key") => findNodeFor("wrangler kv") + */ +function findParentFor(command: Command, root = DefinitionTreeRoot) { + const parentCommand = command.split(" ").slice(0, -2).join(" ") as Command; + + return findNodeFor(parentCommand, root); } -function findNodeFor(command: Command, root: DefinitionTreeNode) { + +/** + * Finds a node by segmenting the command and indexing through the subtrees. + * + * eg. findNodeFor("wrangler versions secret put") => root.subtree.get("versions").subtree.get("secret").subtree.get("put") + * + * Returns `undefined` if the node does not exist. + */ +function findNodeFor(command: Command, root = DefinitionTreeRoot) { const segments = command.split(" ").slice(1); // eg. ["versions", "secret", "put"] let node = root; for (const segment of segments) { - const subtree = node.subtree; - const child = subtree.get(segment); + const child = node.subtree.get(segment); if (!child) { return undefined; } @@ -215,13 +296,5 @@ function findNodeFor(command: Command, root: DefinitionTreeNode) { return node; } -function* walk( - command: Command, - parent: DefinitionTreeNode -): IterableIterator<[Command, DefinitionTreeNode]> { - for (const [segment, node] of parent.subtree) { - yield [`${command} ${segment}`, node]; - yield* walk(`${command} ${segment}`, node); - } -} + // #endregion diff --git a/packages/wrangler/src/core/wrap-command.ts b/packages/wrangler/src/core/wrap-command.ts deleted file mode 100644 index bd0d540b2c5c..000000000000 --- a/packages/wrangler/src/core/wrap-command.ts +++ /dev/null @@ -1,115 +0,0 @@ -import chalk from "chalk"; -import { readConfig } from "../config"; -import { FatalError, UserError } from "../errors"; -import { logger } from "../logger"; -import { printWranglerBanner } from "../update-check"; -import type { CommonYargsArgv } from "../yargs-types"; -import type { - BaseNamedArgDefinitions, - CommandDefinition, - HandlerArgs, - NamespaceDefinition, -} from "./define-command"; - -const betaCmdColor = "#BD5B08"; - -export function wrapDefinition(def: CommandDefinition | NamespaceDefinition) { - let commandSuffix = ""; - let description = def.metadata.description; - let statusMessage = ""; - const defaultDeprecatedMessage = `Deprecated: "${def.command}" is deprecated`; // TODO: improve - const deprecatedMessage = def.metadata.deprecated - ? def.metadata.deprecatedMessage ?? defaultDeprecatedMessage - : undefined; - let defineArgs: undefined | ((yargs: CommonYargsArgv) => CommonYargsArgv) = - undefined; - let handler: - | undefined - | ((args: HandlerArgs) => Promise) = - undefined; - - if (def.metadata.status !== "stable") { - description += chalk.hex(betaCmdColor)(` [${def.metadata.status}]`); - - statusMessage = - def.metadata.statusMessage ?? - `🚧 \`${def.command}\` is a ${def.metadata.status} command. Please report any issues to https://github.com/cloudflare/workers-sdk/issues/new/choose`; - } - - if ("positionalArgs" in def) { - const commandPositionalArgsSuffix = def.positionalArgs - ?.map((key) => { - const { demandOption, array } = def.args[key]; - return demandOption - ? `<${key}${array ? ".." : ""}>` // or - : `[${key}${array ? ".." : ""}]`; // [key] or [key..] - }) - .join(" "); - - if (commandPositionalArgsSuffix) { - commandSuffix += " " + commandPositionalArgsSuffix; - } - } - - if ("args" in def) { - defineArgs = (yargs) => { - if ("args" in def) { - yargs.options(def.args); - - for (const key of def.positionalArgs ?? []) { - yargs.positional(key, def.args[key]); - } - } - - if (def.metadata.statusMessage) { - yargs.epilogue(def.metadata.statusMessage); - } - - return yargs; - }; - } - - if ("handler" in def) { - handler = async (args) => { - // eslint-disable-next-line no-useless-catch - try { - if (def.behaviour?.printBanner !== false) { - await printWranglerBanner(); - } - - // TODO: log deprecation/status message of parent namespace(s) - if (deprecatedMessage) { - logger.warn(deprecatedMessage); - } - if (statusMessage) { - logger.warn(statusMessage); - } - - // TODO(telemetry): send command started event - - await def.validateArgs?.(args); - - await def.handler(args, { - config: readConfig(args.config, args), - errors: { UserError, FatalError }, - logger, - }); - - // TODO(telemetry): send command completed event - } catch (err) { - // TODO(telemetry): send command errored event - throw err; - } - }; - } - - return { - commandSuffix, - description, - hidden: def.metadata.hidden, - deprecatedMessage, - statusMessage, - defineArgs, - handler, - }; -} diff --git a/packages/wrangler/src/kv/index.ts b/packages/wrangler/src/kv/index.ts index ffbd56fffbbe..3f8e19600ae1 100644 --- a/packages/wrangler/src/kv/index.ts +++ b/packages/wrangler/src/kv/index.ts @@ -2,11 +2,7 @@ import { Blob } from "node:buffer"; import { arrayBuffer } from "node:stream/consumers"; import { StringDecoder } from "node:string_decoder"; import { readConfig } from "../config"; -import { - defineAlias, - defineCommand, - defineNamespace, -} from "../core/define-command"; +import { defineAlias, defineCommand, defineNamespace } from "../core"; import { confirm } from "../dialogs"; import { UserError } from "../errors"; import { CommandLineArgsError, demandOneOfOption } from "../index"; @@ -40,6 +36,7 @@ defineAlias({ deprecated: true, deprecatedMessage: "The `wrangler kv:key` command is deprecated and will be removed in a future major version. Please use `wrangler kv key` instead which behaves the same.", + hidden: true, }, }); defineAlias({ @@ -49,6 +46,7 @@ defineAlias({ deprecated: true, deprecatedMessage: "The `wrangler kv:namespace` command is deprecated and will be removed in a future major version. Please use `wrangler kv namespace` instead which behaves the same.", + hidden: true, }, }); defineAlias({ @@ -58,6 +56,7 @@ defineAlias({ deprecated: true, deprecatedMessage: "The `wrangler kv:bulk` command is deprecated and will be removed in a future major version. Please use `wrangler kv bulk` instead which behaves the same.", + hidden: true, }, }); From 3d20c43b23e52586bbefa8432209f96495fe1482 Mon Sep 17 00:00:00 2001 From: Rahul Sethi <5822355+RamIdeas@users.noreply.github.com> Date: Mon, 23 Sep 2024 15:58:37 +0100 Subject: [PATCH 28/31] lints --- .../wrangler/src/__tests__/core/command-registration.test.ts | 2 -- packages/wrangler/src/core/register-commands.ts | 3 ++- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/wrangler/src/__tests__/core/command-registration.test.ts b/packages/wrangler/src/__tests__/core/command-registration.test.ts index 39815b4ed681..650efae95c61 100644 --- a/packages/wrangler/src/__tests__/core/command-registration.test.ts +++ b/packages/wrangler/src/__tests__/core/command-registration.test.ts @@ -191,8 +191,6 @@ describe("Command Registration", () => { wrangler pubsub 📮 Manage Pub/Sub brokers [private beta] wrangler dispatch-namespace 🏗️ Manage dispatch namespaces wrangler ai 🤖 Manage AI models - wrangler pipelines 🚰 Manage Worker Pipelines [open beta] - wrangler login 🔓 Login to Cloudflare wrangler logout 🚪 Logout from Cloudflare wrangler whoami 🕵️ Retrieve your user information diff --git a/packages/wrangler/src/core/register-commands.ts b/packages/wrangler/src/core/register-commands.ts index 7e33d03b6efb..d1ecfdffaab3 100644 --- a/packages/wrangler/src/core/register-commands.ts +++ b/packages/wrangler/src/core/register-commands.ts @@ -233,8 +233,9 @@ function resolveDefinitionChain( ) { const chain: InternalDefinition[] = []; const stringifyChain = (...extra: InternalDefinition[]) => - [...chain, ...extra].map((def) => `"${def.command}"`).join(" => "); + [...chain, ...extra].map(({ command }) => `"${command}"`).join(" => "); + // eslint-disable-next-line no-constant-condition while (true) { if (chain.includes(def)) { throw new CommandRegistrationError( From 21e9315419df98044f40133e707fd70288f33379 Mon Sep 17 00:00:00 2001 From: Rahul Sethi <5822355+RamIdeas@users.noreply.github.com> Date: Tue, 1 Oct 2024 10:59:21 +0100 Subject: [PATCH 29/31] fix lint --- .../wrangler/src/__tests__/core/command-registration.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/wrangler/src/__tests__/core/command-registration.test.ts b/packages/wrangler/src/__tests__/core/command-registration.test.ts index 650efae95c61..d81a7f97b0a1 100644 --- a/packages/wrangler/src/__tests__/core/command-registration.test.ts +++ b/packages/wrangler/src/__tests__/core/command-registration.test.ts @@ -3,12 +3,12 @@ import { defineAlias, defineCommand, defineNamespace, - DefinitionTreeNode, DefinitionTreeRoot, } from "../../core/define-command"; import { mockConsoleMethods } from "../helpers/mock-console"; import { runInTempDir } from "../helpers/run-in-tmp"; import { runWrangler } from "../helpers/run-wrangler"; +import type { DefinitionTreeNode } from "../../core/define-command"; describe("Command Registration", () => { runInTempDir(); From 2bf7afbc41f6895f30673a04d27b2bddea979007 Mon Sep 17 00:00:00 2001 From: Rahul Sethi <5822355+RamIdeas@users.noreply.github.com> Date: Wed, 16 Oct 2024 15:48:20 +0100 Subject: [PATCH 30/31] Update packages/wrangler/CONTRIBUTING.md Co-authored-by: Andy Jessop --- packages/wrangler/CONTRIBUTING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/wrangler/CONTRIBUTING.md b/packages/wrangler/CONTRIBUTING.md index 73c440e3737d..609dd342d8c3 100644 --- a/packages/wrangler/CONTRIBUTING.md +++ b/packages/wrangler/CONTRIBUTING.md @@ -67,7 +67,7 @@ const command = defineCommand({ }); ``` -2. Command specific (named + positional) args vs Shared args vs Global args +2. Command-specific (named + positional) args vs shared args vs global args - Command-specific args are defined in the `args` field of the command definition. Command handlers receive these as a typed object automatically. To make any of these positional, add the key to the `positionalArgs` array. - You can share args between commands by declaring a separate object and spreading it into the `args` field. Feel free to import from another file. From b03a10f3945e9ff637b99536726f26bebf55a8c6 Mon Sep 17 00:00:00 2001 From: Rahul Sethi <5822355+RamIdeas@users.noreply.github.com> Date: Thu, 17 Oct 2024 11:48:50 +0100 Subject: [PATCH 31/31] update snapshot --- .../wrangler/src/__tests__/core/command-registration.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/wrangler/src/__tests__/core/command-registration.test.ts b/packages/wrangler/src/__tests__/core/command-registration.test.ts index d81a7f97b0a1..12f24a560120 100644 --- a/packages/wrangler/src/__tests__/core/command-registration.test.ts +++ b/packages/wrangler/src/__tests__/core/command-registration.test.ts @@ -166,7 +166,7 @@ describe("Command Registration", () => { "wrangler COMMANDS - wrangler docs [command] 📚 Open Wrangler's command documentation in your browser + wrangler docs [search..] 📚 Open Wrangler's command documentation in your browser wrangler init [name] 📥 Initialize a basic Worker wrangler dev [script] 👂 Start a local server for developing your Worker