diff --git a/.changeset/wrangler-public-types.md b/.changeset/wrangler-public-types.md new file mode 100644 index 00000000000..f8de469aaab --- /dev/null +++ b/.changeset/wrangler-public-types.md @@ -0,0 +1,8 @@ +--- +"wrangler": patch +"@cloudflare/workers-utils": patch +--- + +Fix unresolved imports in Wrangler's published TypeScript declarations + +Projects that check dependency declarations no longer need Wrangler's private build dependencies to resolve its public types. Inherited bindings remain compatible between Wrangler and the shared utilities used by the Vite and Vitest plugins. diff --git a/packages/workers-utils/src/construct-wrangler-config.ts b/packages/workers-utils/src/construct-wrangler-config.ts index ff780df72bf..22a958117b2 100644 --- a/packages/workers-utils/src/construct-wrangler-config.ts +++ b/packages/workers-utils/src/construct-wrangler-config.ts @@ -9,7 +9,6 @@ import type { } from "./config/environment"; import type { WorkerMetadata } from "./types"; import type { AssetConfig } from "@cloudflare/workers-shared"; -import type { Cloudflare } from "cloudflare"; type RoutesRes = { id: string; @@ -18,6 +17,8 @@ type RoutesRes = { script: string; }[]; +// Keep SDK response shapes structural so consumers do not need the SDK's +// declarations. The type assertions in the tests track changes to these shapes. interface APIWorkerConfig { /* sourced from https://developers.cloudflare.com/api/resources/workers/subresources/scripts/methods/list/ */ name: string; // property renamed from `id`... @@ -31,16 +32,37 @@ interface APIWorkerConfig { migration_tag?: string; /* sourced from https://developers.cloudflare.com/api/resources/workers/subresources/domains/methods/list/ */ - domains: Cloudflare.Workers.Domain[]; + domains: { + id?: string; + environment?: string; + hostname?: string; + service?: string; + zone_id?: string; + zone_name?: string; + }[]; /* sourced from https://developers.cloudflare.com/api/resources/workers/subresources/scripts/subresources/schedules/methods/get/ */ - schedules: Cloudflare.Workers.Scripts.Schedules.ScheduleGetResponse.Schedule[]; + schedules: { + cron: string; + created_on?: string; + modified_on?: string; + }[]; /* sourced from https://developers.cloudflare.com/api/resources/workers/subresources/beta/subresources/workers/subresources/versions/methods/get/ using `{version_id}` of `latest` */ assets?: AssetConfig; bindings: WorkerMetadata["bindings"]; - observability: Cloudflare.Workers.Beta.Worker.Observability | undefined; + observability: + | { + enabled?: boolean; + head_sampling_rate?: number; + logs?: { + enabled?: boolean; + head_sampling_rate?: number; + invocation_logs?: boolean; + }; + } + | undefined; limits: { cpu_ms?: number; subrequests?: number } | undefined; - placement: Cloudflare.Workers.Beta.Workers.Version.Placement | undefined; + placement: { mode?: "smart" } | undefined; subdomain: { enabled: boolean; previews_enabled: boolean; diff --git a/packages/workers-utils/tests/construct-wrangler-config.test.ts b/packages/workers-utils/tests/construct-wrangler-config.test.ts index 071cc057d76..678a54160eb 100644 --- a/packages/workers-utils/tests/construct-wrangler-config.test.ts +++ b/packages/workers-utils/tests/construct-wrangler-config.test.ts @@ -1,5 +1,6 @@ -import { describe, it } from "vitest"; +import { describe, expectTypeOf, it } from "vitest"; import { constructWranglerConfig } from "../src/construct-wrangler-config"; +import type { Cloudflare } from "cloudflare"; type APIWorkerConfig = Parameters[0]; @@ -34,6 +35,21 @@ function makeWorkerConfig( } describe("constructWranglerConfig", () => { + it("preserves the SDK response types without exposing the SDK", () => { + expectTypeOf().toEqualTypeOf< + Cloudflare.Workers.Domain[] + >(); + expectTypeOf().toEqualTypeOf< + Cloudflare.Workers.Scripts.Schedules.ScheduleGetResponse.Schedule[] + >(); + expectTypeOf().toEqualTypeOf< + Cloudflare.Workers.Beta.Worker.Observability | undefined + >(); + expectTypeOf().toEqualTypeOf< + Cloudflare.Workers.Beta.Workers.Version.Placement | undefined + >(); + }); + describe("basic field mapping", () => { it("maps core fields correctly", ({ expect }) => { const config = makeWorkerConfig({ diff --git a/packages/workers-utils/tsup.config.ts b/packages/workers-utils/tsup.config.ts index 3e79a489832..23ca3a69211 100644 --- a/packages/workers-utils/tsup.config.ts +++ b/packages/workers-utils/tsup.config.ts @@ -1,6 +1,19 @@ import { defineConfig } from "tsup"; import { EXTERNAL_DEPENDENCIES } from "./scripts/deps"; +const declarationOptions = { + resolve: ["@cloudflare/workers-shared", "jsonc-parser", "zod", /^\./], + compilerOptions: { + baseUrl: ".", + paths: { + "@cloudflare/workers-shared": ["../workers-shared/index.ts"], + // tsup 8.3 does not load Zod's default .d.cts entry point. Resolve its + // published ESM declarations without changing the runtime build. + zod: ["./node_modules/zod/index.d.ts"], + }, + }, +}; + export default defineConfig(() => [ { treeshake: true, @@ -9,7 +22,7 @@ export default defineConfig(() => [ entry: ["src/browser.ts"], platform: "node", format: "esm", - dts: true, + dts: declarationOptions, outDir: "dist", tsconfig: "tsconfig.json", metafile: true, @@ -44,7 +57,7 @@ export default defineConfig(() => [ js: 'import { createRequire as __createRequire } from "node:module"; const require = __createRequire(import.meta.url || (typeof __filename === "string" ? __filename : "/"));', }, format: "esm", - dts: true, + dts: declarationOptions, outDir: "dist", tsconfig: "tsconfig.json", metafile: true, diff --git a/packages/wrangler/package.json b/packages/wrangler/package.json index 3221829bc7b..c8b4e6b7946 100644 --- a/packages/wrangler/package.json +++ b/packages/wrangler/package.json @@ -79,15 +79,17 @@ "test:debug": "pnpm run test --silent=false --verbose=true", "test:e2e": "dotenv -- vitest run -c ./e2e/vitest.config.mts", "test:watch": "pnpm run test --testTimeout=50000 --watch", - "type:tests": "tsc -p ./src/__tests__/tsconfig.json && tsc -p ./e2e/tsconfig.json" + "type:tests": "tsc -p ./src/__tests__/tsconfig.json && tsc -p ./e2e/tsconfig.json && node -r esbuild-register scripts/check-published-declarations.ts" }, "dependencies": { "@cloudflare/kv-asset-handler": "workspace:*", "@cloudflare/unenv-preset": "workspace:*", + "@cloudflare/workers-utils": "workspace:*", "blake3-wasm": "2.1.5", "esbuild": "catalog:default", "miniflare": "workspace:*", "path-to-regexp": "6.3.0", + "undici": "catalog:default", "unenv": "2.0.0-rc.24", "workerd": "catalog:default" }, @@ -111,7 +113,6 @@ "@cloudflare/workers-shared": "workspace:*", "@cloudflare/workers-tsconfig": "workspace:*", "@cloudflare/workers-types": "catalog:default", - "@cloudflare/workers-utils": "workspace:*", "@cloudflare/workflows-shared": "workspace:*", "@cspotcode/source-map-support": "0.8.1", "@netlify/build-info": "10.5.1", @@ -184,7 +185,6 @@ "tsdown": "0.16.3", "tsup": "8.3.0", "typescript": "catalog:default", - "undici": "catalog:default", "vitest": "catalog:default", "vitest-websocket-mock": "^0.4.0", "ws": "catalog:default", diff --git a/packages/wrangler/scripts/check-published-declarations.ts b/packages/wrangler/scripts/check-published-declarations.ts new file mode 100644 index 00000000000..adc1b5bb050 --- /dev/null +++ b/packages/wrangler/scripts/check-published-declarations.ts @@ -0,0 +1,116 @@ +import fs from "node:fs"; +import path from "node:path"; +import ts from "typescript"; +import { + getEntryPointPaths, + getPackageNameFromSpecifier, + isBareSpecifier, + validateDistImports, + type PackageJSON, +} from "../../../tools/deployments/validate-package-dependencies"; + +type PublishedPackageJSON = PackageJSON & { types?: string }; + +if (require.main === module) { + checkPublishedDeclarations(path.resolve(__dirname, "..")); + // Wrangler keeps workers-utils' binding types external to preserve their unique + // symbol identity. Its installed declarations must also resolve without private + // workspace packages or development dependencies. + checkPublishedDeclarations(path.resolve(__dirname, "../../workers-utils")); +} + +/** Validate the declaration files shipped at a package's public type entry points. */ +export function checkPublishedDeclarations(packageRoot: string): void { + const packageJson = JSON.parse( + fs.readFileSync(path.join(packageRoot, "package.json"), "utf-8") + ) as PublishedPackageJSON; + + const typeEntryPoints = new Set( + [ + packageJson.types, + ...getEntryPointPaths(packageJson).filter(isDeclarationPath), + ].filter((value): value is string => value !== undefined) + ); + + const declarationFiles = new Set(); + for (const entryPoint of typeEntryPoints) { + const absoluteEntryPoint = path.resolve(packageRoot, entryPoint); + if (!fs.existsSync(absoluteEntryPoint)) { + throw new Error("Missing built type entry point: " + entryPoint); + } + collectDeclarationFiles(path.dirname(absoluteEntryPoint), declarationFiles); + } + + const importedPackages = new Set(); + const errors: string[] = []; + for (const declarationFile of declarationFiles) { + const preprocessed = ts.preProcessFile( + fs.readFileSync(declarationFile, "utf-8"), + true, + true + ); + const references = [ + ...preprocessed.importedFiles, + ...preprocessed.typeReferenceDirectives, + ...preprocessed.referencedFiles, + ]; + for (const { fileName } of references) { + if (isBareSpecifier(fileName)) { + importedPackages.add(getPackageNameFromSpecifier(fileName)); + } else if (fileName.startsWith(".")) { + const { resolvedModule } = ts.resolveModuleName( + fileName, + declarationFile, + { moduleResolution: ts.ModuleResolutionKind.Bundler }, + ts.sys + ); + if ( + resolvedModule === undefined || + !declarationFiles.has(path.resolve(resolvedModule.resolvedFileName)) + ) { + errors.push( + path.relative(packageRoot, declarationFile) + + " references an unpublished declaration: " + + fileName + ); + } + } + } + } + + errors.push( + ...validateDistImports(packageJson.name, packageJson, importedPackages) + ); + if (errors.length > 0) { + throw new Error( + packageJson.name + + "'s published declarations have unresolved package boundaries:\n" + + errors.map((error) => "- " + error).join("\n") + ); + } + + process.stdout.write( + "Checked " + + declarationFiles.size + + " published declaration file(s) across " + + typeEntryPoints.size + + " public type entry point(s) for " + + packageJson.name + + ".\n" + ); +} + +function isDeclarationPath(filePath: string): boolean { + return /\.d\.(?:ts|mts|cts)$/.test(filePath); +} + +function collectDeclarationFiles(directory: string, files: Set): void { + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + const absolutePath = path.join(directory, entry.name); + if (entry.isDirectory()) { + collectDeclarationFiles(absolutePath, files); + } else if (isDeclarationPath(entry.name)) { + files.add(absolutePath); + } + } +} diff --git a/packages/wrangler/scripts/deps.ts b/packages/wrangler/scripts/deps.ts index 2f90199540d..85c0dd72ea9 100644 --- a/packages/wrangler/scripts/deps.ts +++ b/packages/wrangler/scripts/deps.ts @@ -14,6 +14,10 @@ export const EXTERNAL_DEPENDENCIES = [ // Wrangler depends on a pinned version of Miniflare. "miniflare", + // Public fetch options expose Undici's MockAgent type. Keep its class identity + // shared with consumers; tsup's noExternal override still bundles its runtime. + "undici", + // @cloudflare/workers-types is an optional peer dependency of wrangler, so users can // get the types by installing the package (to what version they prefer) themselves "@cloudflare/workers-types", diff --git a/packages/wrangler/src/__tests__/experimental-commands-api.test.ts b/packages/wrangler/src/__tests__/experimental-commands-api.test.ts index c758c8981b4..0eb4814c845 100644 --- a/packages/wrangler/src/__tests__/experimental-commands-api.test.ts +++ b/packages/wrangler/src/__tests__/experimental-commands-api.test.ts @@ -1,8 +1,33 @@ -import { assert, describe, test } from "vitest"; +import { assert, describe, expectTypeOf, test } from "vitest"; import { experimental_getWranglerCommands } from "../experimental-commands-api"; -import type { DefinitionTreeNode } from "../core/types"; +import type { CommandDefinitionTreeNode } from "../command-types"; +import type { + Command, + DefinitionTreeNode, + InternalDefinition, + Metadata, +} from "../core/types"; +import type { + ExperimentalCommandDefinition, + ExperimentalCommandMetadata, + ExperimentalDefinitionTreeNode, +} from "../experimental-commands-api"; describe("experimental_getWranglerCommands", () => { + test("shares registry metadata and recursive structure with internal commands", () => { + expectTypeOf().toExtend(); + expectTypeOf().not.toExtend(); + expectTypeOf< + ExperimentalCommandDefinition["command"] + >().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf< + CommandDefinitionTreeNode + >(); + expectTypeOf().toEqualTypeOf< + CommandDefinitionTreeNode + >(); + }); + test("returns global flags", ({ expect }) => { const commandTree = experimental_getWranglerCommands().globalFlags; @@ -109,6 +134,38 @@ describe("experimental_getWranglerCommands", () => { expect(initCommand.definition.metadata.owner).toBeDefined(); }); + test("preserves public command argument metadata", ({ expect }) => { + const commandTree = experimental_getWranglerCommands().registry; + const quickStart = commandTree.subtree + .get("tunnel") + ?.subtree.get("quick-start"); + assert(quickStart?.definition?.type === "command"); + + const urlArg = quickStart.definition.args?.url; + const allowedMailArg = quickStart.definition.args?.["allowed-mail"]; + assert(urlArg); + assert(allowedMailArg); + + expect(urlArg.description).toBe( + "The local URL to expose (e.g., http://localhost:3000)" + ); + expect(allowedMailArg.nargs).toBe(1); + + // These fields are part of the public structural type even when a + // particular command does not use all of them. + const publicArgMetadata = { + description: urlArg.description, + nargs: allowedMailArg.nargs, + group: "group", + conflicts: ["other"], + implies: "required", + } satisfies NonNullable; + + expect(publicArgMetadata.group).toBe("group"); + expect(publicArgMetadata.conflicts).toEqual(["other"]); + expect(publicArgMetadata.implies).toBe("required"); + }); + test("includes namespace commands", ({ expect }) => { const commandTree = experimental_getWranglerCommands().registry; @@ -142,7 +199,7 @@ describe("experimental_getWranglerCommands", () => { expect, }) => { const supporting: string[] = []; - const walk = (node: DefinitionTreeNode) => { + const walk = (node: ExperimentalDefinitionTreeNode) => { if ( node.definition?.type === "command" && node.definition.behaviour?.supportTemporary diff --git a/packages/wrangler/src/__tests__/published-declarations.test.ts b/packages/wrangler/src/__tests__/published-declarations.test.ts new file mode 100644 index 00000000000..2abf63968d6 --- /dev/null +++ b/packages/wrangler/src/__tests__/published-declarations.test.ts @@ -0,0 +1,75 @@ +import { mkdirSync, writeFileSync } from "node:fs"; +import { INHERIT_SYMBOL } from "@cloudflare/workers-utils"; +import { runInTempDir } from "@cloudflare/workers-utils/test-helpers"; +import { beforeEach, describe, expectTypeOf, it } from "vitest"; +import { checkPublishedDeclarations } from "../../scripts/check-published-declarations"; +import type { Binding, unstable_startWorker } from "../../wrangler-dist/cli"; +import type { Binding as SharedBinding } from "@cloudflare/workers-utils"; +import type { MockAgent } from "undici"; + +type WorkerInput = Parameters[0]; + +it("preserves shared binding identity in the published declarations", () => { + expectTypeOf().toEqualTypeOf(); + + type InheritedId = Extract< + Extract["id"], + symbol + >; + expectTypeOf().toEqualTypeOf(); + + const binding = { + type: "kv_namespace", + id: INHERIT_SYMBOL, + } satisfies SharedBinding; + expectTypeOf(binding).toExtend(); + expectTypeOf({ + bindings: { CACHE: binding }, + }).toExtend(); + + const differentSymbol = Symbol("different binding"); + expectTypeOf(differentSymbol).not.toExtend(); +}); + +it("preserves Undici's MockAgent type in the published worker options", () => { + type MockFetch = NonNullable["mockFetch"]>; + expectTypeOf().toEqualTypeOf(); +}); + +describe("published declaration boundaries", () => { + runInTempDir(); + beforeEach(() => { + mkdirSync("dist"); + writeFileSync( + "package.json", + JSON.stringify({ name: "declaration-fixture", types: "dist/index.d.ts" }) + ); + }); + + it.for([ + { + declaration: + 'import type { AssetConfig } from "@cloudflare/workers-shared";', + missing: "@cloudflare/workers-shared", + }, + { + declaration: 'import type { RequestInfo } from "./types/index";', + missing: "./types/index", + }, + { + declaration: '/// ', + missing: "private-types", + }, + ])("rejects unresolved $missing", ({ declaration, missing }, { expect }) => { + writeFileSync("dist/index.d.ts", declaration); + expect(() => checkPublishedDeclarations(process.cwd())).toThrow(missing); + }); + + it("accepts relative imports to declarations shipped in the package", ({ + expect, + }) => { + writeFileSync("dist/index.d.ts", 'export type { Binding } from "./types";'); + writeFileSync("dist/types.d.ts", 'export type Binding = { type: "test" };'); + expect(() => checkPublishedDeclarations(process.cwd())).not.toThrow(); + }); +}); diff --git a/packages/wrangler/src/command-types.ts b/packages/wrangler/src/command-types.ts new file mode 100644 index 00000000000..85c66a0adac --- /dev/null +++ b/packages/wrangler/src/command-types.ts @@ -0,0 +1,87 @@ +/** + * Shared command metadata shape. Keep this module independent of runtime + * command definitions so generated public declarations remain self-contained. + * + * Internal commands supply their constrained Teams and category unions; + * the experimental inspection API intentionally exposes string values. + */ +export type CommandMetadata = { + description: string; + status: "experimental" | "alpha" | "private beta" | "open beta" | "stable"; + statusMessage?: string; + deprecated?: boolean; + deprecatedMessage?: string; + hidden?: boolean; + owner: Owner; + /** Prints something at the bottom of the help. */ + epilogue?: string; + examples?: { command: string; description: string }[]; + hideGlobalFlags?: string[]; + /** + * Optional category for grouping commands in the help output. + * Commands without a category appear under the default "COMMANDS" group. + */ + category?: Category; +}; + +/** Single command identity for internal definitions and public registry views. */ +export type Command = `wrangler${string}`; +export type CommandIdentity = { + type: Kind; + command: Command; +}; + +/** Fields shared by executable commands and public structural projections. */ +export type CommandDefinitionData = { + metadata: Metadata; + behaviour?: Behaviour; + args?: Args; + positionalArgs?: Positionals; +}; +export type CommandNamespaceData = { metadata: Metadata }; +export type CommandAliasData = { + aliasOf: Command; + metadata?: Partial; +}; +export type CommandDefinitionTreeNode = { + definition?: Definition; + subtree: Map>; +}; + +/** + * Shared behaviour supported by both the executable command definitions and + * the experimental command registry. Both views use this single field source. + */ +export type CommandBehaviour = { + /** + * By default, Wrangler's version banner is printed before the handler. + * @default true + */ + printBanner?: boolean | ((args: Args) => boolean); + /** + * By default, Wrangler reads and provides its configuration. + * @default true + */ + provideConfig?: boolean; + /** + * By default, Wrangler prints warnings about the configuration file. + * @default true + */ + printConfigWarnings?: boolean; + /** Check for a Wrangler configuration redirect file. */ + useConfigRedirectIfAvailable?: boolean; + /** + * Opt out of sending metrics for this command. + * @default true + */ + sendMetrics?: boolean; + /** + * Whether this command can use a temporary preview account with the hidden + * `--temporary` or `--event-code` flags. R2 and Containers commands work + * only with event accounts created using `--event-code`. + * @default false + */ + supportTemporary?: boolean; + /** Offer Cloudflare skills after a successful handler invocation. */ + suggestSkillsAfterHandler?: boolean | ((args: Args) => boolean); +}; diff --git a/packages/wrangler/src/complete.ts b/packages/wrangler/src/complete.ts index 97cecf971e0..f8495cb187d 100644 --- a/packages/wrangler/src/complete.ts +++ b/packages/wrangler/src/complete.ts @@ -2,7 +2,7 @@ import t from "@bomb.sh/tab"; import { CommandLineArgsError } from "@cloudflare/workers-utils"; import { createCommand } from "./core/create-command"; import { experimental_getWranglerCommands } from "./experimental-commands-api"; -import type { DefinitionTreeNode } from "./core/types"; +import type { ExperimentalDefinitionTreeNode } from "./experimental-commands-api"; function setupCompletions(): void { const { registry, globalFlags } = experimental_getWranglerCommands(); @@ -28,7 +28,7 @@ function setupCompletions(): void { // Recursively add commands from the registry tree function addCommandsFromTree( - node: DefinitionTreeNode, + node: ExperimentalDefinitionTreeNode, parentPath: string[] = [] ): void { for (const [name, childNode] of node.subtree.entries()) { diff --git a/packages/wrangler/src/core/types.ts b/packages/wrangler/src/core/types.ts index 2bdc4e29827..097aaf096df 100644 --- a/packages/wrangler/src/core/types.ts +++ b/packages/wrangler/src/core/types.ts @@ -4,6 +4,16 @@ import type { fetchListResult, fetchPagedListResult, } from "../cfetch"; +import type { + CommandAliasData, + CommandBehaviour, + CommandDefinitionData, + CommandDefinitionTreeNode, + CommandIdentity, + CommandMetadata, + CommandNamespaceData, + Command as SharedCommand, +} from "../command-types"; import type { confirm, prompt } from "../dialogs"; import type { ExperimentalFlags } from "../experimental-flags"; import type { Logger } from "../logger"; @@ -60,29 +70,8 @@ export type MetadataCategory = | "Storage & databases" | "Networking & security"; -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; - /** Prints something at the bottom of the help */ - epilogue?: string; - examples?: { - command: string; - description: string; - }[]; - hideGlobalFlags?: string[]; - /** - * Optional category for grouping commands in the help output. - * Commands with the same category will be grouped together under a shared heading. - * Commands without a category will appear under the default "COMMANDS" group. - */ - category?: MetadataCategory; -}; +export type Command = SharedCommand; +export type Metadata = CommandMetadata; export type ArgDefinition = Omit & Pick; @@ -153,127 +142,33 @@ export type HandlerContext = { export type CommandDefinition< NamedArgDefs extends NamedArgDefinitions = NamedArgDefinitions, -> = { - /** - * 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: Metadata; - /** - * Controls shared behaviour across all commands. - * This will allow wrangler commands to remain consistent and only diverge intentionally. - */ - behaviour?: { - /** - * 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 | ((args: HandlerArgs) => boolean); - - /** - * Opt-in to printing a metrics banner for this command. - * @default false - */ +> = CommandDefinitionData< + Metadata, + NamedArgDefs, + CommandBehaviour> & { + /** Opt in to the metrics banner for this command. */ printMetricsBanner?: boolean; - - /** - * By default, wrangler will print warnings about the Wrangler configuration file. - * Set this value to `false` to skip printing these warnings. - */ - printConfigWarnings?: boolean; - - /** - * By default, wrangler will read & provide the wrangler.toml/wrangler.json configuration. - * Set this value to `false` to skip this. - */ - provideConfig?: boolean; - /** - * By default, wrangler will provide experimental flags in the handler context, - * according to the default values in register-yargs.command.ts - * Use this to override those defaults per command. + * Override experimental flags supplied to a command's handler context. */ overrideExperimentalFlags?: ( args: HandlerArgs ) => ExperimentalFlags; - - /** - * If true, then look for a redirect file at `.wrangler/deploy/config.json` and use that to find the Wrangler configuration file. - */ - useConfigRedirectIfAvailable?: boolean; - - /** - * If true, print a message about whether the command is operating on a local or remote resource - */ + /** Print whether this command operates on local or remote resources. */ printResourceLocation?: | ((args: HandlerArgs) => boolean) | boolean; - - /** - * If true, check for environments in the wrangler config, if there are some and the user hasn't specified an environment - * using the `-e|--env` cli flag, show a warning suggesting that one should instead be specified. - */ + /** Warn when environments are defined but none has been selected. */ warnIfMultipleEnvsConfiguredButNoneSpecified?: boolean; - - /** - * Opt out of sending metrics for this command - * @default true - */ - sendMetrics?: boolean; - /** - * After the command handler completes successfully, suggest installing - * Cloudflare skills for detected AI coding agents that don't have them. - * - * When set to `true`, the suggestion always runs after the handler. - * When set to a function, it receives the parsed args and should return - * `true` to enable the suggestion — use this to skip the prompt in modes - * where interactive output is inappropriate (e.g. `--json`). - * - * @default false - */ - suggestSkillsAfterHandler?: - | boolean - | ((args: HandlerArgs) => boolean); - - /** - * Whether this command can authenticate with a temporary preview account - * (via the hidden `--temporary` and `--event-code` flags) when no real - * credentials are available. Only enable this for commands whose API calls - * are covered by the temporary preview-account deploy token (Workers, KV, - * D1, Hyperdrive, Queues, SSL/Certs). R2 and Containers commands are also - * enabled, but only work on event accounts created with `--event-code`. - * @default false - */ - supportTemporary?: boolean; - - /** - * By default, the banner will print "Active profile: " when a non-default - * profile is resolved. Set to `false` to suppress this line while still printing - * the rest of the banner. - * + * By default, the banner will print the active profile name. + * Set to false to suppress that detail while retaining the main banner. * @default true */ printActiveProfile?: boolean; - }; - - /** - * 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?: 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>; - + }, + 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. @@ -297,36 +192,19 @@ export type CommandDefinition< ) => void | Promise; }; -export type NamespaceDefinition = { - metadata: Metadata; -}; - -export type AliasDefinition = { - aliasOf: Command; - metadata?: Partial; -}; +export type NamespaceDefinition = CommandNamespaceData; +export type AliasDefinition = CommandAliasData; -export type InternalCommandDefinition = { - type: "command"; - command: Command; -} & CommandDefinition; - -export type InternalNamespaceDefinition = { - type: "namespace"; - command: Command; -} & NamespaceDefinition; - -export type InternalAliasDefinition = { - type: "alias"; - command: Command; -} & AliasDefinition; +export type InternalCommandDefinition = CommandIdentity<"command"> & + CommandDefinition; +export type InternalNamespaceDefinition = CommandIdentity<"namespace"> & + NamespaceDefinition; +export type InternalAliasDefinition = CommandIdentity<"alias"> & + AliasDefinition; export type InternalDefinition = | InternalCommandDefinition | InternalNamespaceDefinition | InternalAliasDefinition; -export type DefinitionTreeNode = { - definition?: InternalDefinition; - subtree: DefinitionTree; -}; +export type DefinitionTreeNode = CommandDefinitionTreeNode; export type DefinitionTree = Map; diff --git a/packages/wrangler/src/experimental-commands-api.ts b/packages/wrangler/src/experimental-commands-api.ts index 73d8864fdb9..f3e899b9d5e 100644 --- a/packages/wrangler/src/experimental-commands-api.ts +++ b/packages/wrangler/src/experimental-commands-api.ts @@ -1,16 +1,115 @@ import { createCLIParser } from "./index"; -import type { DefinitionTreeNode } from "./core/types"; +import type { + CommandAliasData, + CommandBehaviour, + CommandDefinitionData, + CommandDefinitionTreeNode, + CommandIdentity, + CommandMetadata, + CommandNamespaceData, +} from "./command-types"; + +export type ExperimentalCommandMetadata = CommandMetadata; + +type ExperimentalStringRequirement = + | string + | readonly string[] + | Record; + +export type ExperimentalCommandArgDefinition = { + alias?: string | readonly string[]; + array?: boolean; + boolean?: boolean; + choices?: ReadonlyArray; + coerce?: (arg: unknown) => unknown; + config?: boolean; + configParser?: (configPath: string) => object; + conflicts?: ExperimentalStringRequirement; + count?: boolean; + default?: unknown; + defaultDescription?: string; + demand?: boolean | string; + deprecate?: boolean | string; + deprecated?: boolean | string; + demandOption?: boolean | string; + desc?: string; + describe?: string; + description?: string; + global?: boolean; + group?: string; + hidden?: boolean; + implies?: ExperimentalStringRequirement; + nargs?: number; + normalize?: boolean; + number?: boolean; + require?: boolean | string; + required?: boolean | string; + requiresArg?: boolean; + skipValidation?: boolean; + string?: boolean; + type?: "array" | "count" | "boolean" | "number" | "string"; +}; + +/** + * Yargs's internal ArgDefinition is intentionally projected into public + * option metadata: Yargs is not a published Wrangler dependency. + */ +export type ExperimentalCommandBehaviour = CommandBehaviour & { + [key: string]: unknown; +}; + +export type ExperimentalCommandCommandDefinition = CommandIdentity<"command"> & + CommandDefinitionData< + ExperimentalCommandMetadata, + Record, + ExperimentalCommandBehaviour, + string[] + > & { + validateArgs?: (...args: unknown[]) => unknown; + handler?: (...args: unknown[]) => unknown; + }; + +export type ExperimentalCommandNamespaceDefinition = + CommandIdentity<"namespace"> & + CommandNamespaceData; + +export type ExperimentalCommandAliasDefinition = CommandIdentity<"alias"> & + CommandAliasData; + +export type ExperimentalCommandDefinition = + | ExperimentalCommandCommandDefinition + | ExperimentalCommandNamespaceDefinition + | ExperimentalCommandAliasDefinition; + +export type ExperimentalDefinitionTreeNode = + CommandDefinitionTreeNode; + +export type ExperimentalGlobalFlags = Record< + string, + ExperimentalCommandArgDefinition +>; + +export type ExperimentalWranglerCommands = { + registry: ExperimentalDefinitionTreeNode; + globalFlags: ExperimentalGlobalFlags; +}; /** * EXPERIMENTAL: Get all registered Wrangler commands for documentation generation. * This API is experimental and may change without notice. * + * The published return type is a dependency-free structural view of the + * command registry. Runtime definitions can contain additional internal + * properties, but implementation-only dependency types are intentionally kept + * out of Wrangler's public declaration boundary. + * * @returns An object containing the command tree structure and global flags */ -export function experimental_getWranglerCommands(): { - registry: DefinitionTreeNode; - globalFlags: ReturnType["globalFlags"]; -} { +export function experimental_getWranglerCommands(): ExperimentalWranglerCommands { const { registry, globalFlags } = createCLIParser([]); - return { registry: registry.getDefinitionTreeRoot(), globalFlags }; + return { + registry: + registry.getDefinitionTreeRoot() as ExperimentalDefinitionTreeNode, + globalFlags: globalFlags as ExperimentalGlobalFlags, + }; } diff --git a/packages/wrangler/tsup.config.ts b/packages/wrangler/tsup.config.ts index c9289d35f4e..d4673aff64d 100644 --- a/packages/wrangler/tsup.config.ts +++ b/packages/wrangler/tsup.config.ts @@ -108,11 +108,41 @@ export default defineConfig((options) => [ platform: "node", format: "cjs", dts: { - resolve: ["@cloudflare/workflows-shared/src/types"], + compilerOptions: { + baseUrl: ".", + paths: { + "@cloudflare/containers-shared": ["../containers-shared/index.ts"], + "@cloudflare/remote-bindings": ["../remote-bindings/src/index.ts"], + "@cloudflare/workers-shared": ["../workers-shared/index.ts"], + // Resolve Zod's ESM declarations; tsup 8.3 cannot load .d.cts. + zod: ["./node_modules/zod/index.d.ts"], + }, + }, + // Resolve only private type dependencies that are safe for tsup's + // declaration bundler to inline. @cloudflare/workers-utils stays + // external in declarations because its Binding types contain unique + // symbols whose identity must be shared with downstream consumers. + // @cloudflare/remote-bindings uses a declaration-only source path + // because it publishes through conditional .d.mts exports, which + // tsup 8.3's resolver does not probe. + resolve: [ + // Follow imports within the resolved packages as well, so their + // private relative paths cannot escape into Wrangler's output. + /^\./, + "@cloudflare/workflows-shared/src/types", + "@cloudflare/containers-shared", + "@cloudflare/workers-shared", + "devtools-protocol", + "devtools-protocol/types/protocol-mapping", + "zod", + ], }, outDir: "wrangler-dist", tsconfig: "tsconfig.json", external: EXTERNAL_DEPENDENCIES, + // Preserve the bundled CJS runtime while keeping dependency-owned public + // types external. In particular, workers-utils' package root is ESM-only. + noExternal: ["@cloudflare/workers-utils", "undici"], sourcemap: process.env.SOURCEMAPS !== "false", inject: [path.join(__dirname, "import_meta_url.js")], // mainFields: ["module", "main"], diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5259c881b6f..0ef6bb69aed 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4550,6 +4550,9 @@ importers: '@cloudflare/unenv-preset': specifier: workspace:* version: link:../unenv-preset + '@cloudflare/workers-utils': + specifier: workspace:* + version: link:../workers-utils blake3-wasm: specifier: 2.1.5 version: 2.1.5 @@ -4562,6 +4565,9 @@ importers: path-to-regexp: specifier: 6.3.0 version: 6.3.0 + undici: + specifier: catalog:default + version: 7.29.1 unenv: specifier: 2.0.0-rc.24 version: 2.0.0-rc.24 @@ -4626,9 +4632,6 @@ importers: '@cloudflare/workers-types': specifier: catalog:default version: 5.20261016.1 - '@cloudflare/workers-utils': - specifier: workspace:* - version: link:../workers-utils '@cloudflare/workflows-shared': specifier: workspace:* version: link:../workflows-shared @@ -4845,9 +4848,6 @@ importers: typescript: specifier: catalog:default version: 5.8.3 - undici: - specifier: catalog:default - version: 7.29.1 vitest: specifier: catalog:default version: 4.1.0(@opentelemetry/api@1.9.1)(@types/node@22.15.17)(@vitest/ui@4.1.0)(msw@2.14.6(@types/node@22.15.17)(typescript@5.8.3))(vite@8.2.0(@types/node@22.15.17)(esbuild@0.28.2)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.1))