From 64b7a24b05dcf0060e21e5edd862fbb331b14f4b Mon Sep 17 00:00:00 2001 From: Pete Bacon Darwin Date: Thu, 22 Jan 2026 18:07:16 +0000 Subject: [PATCH 1/3] refactor(wrangler): move telemetry events from yargs middleware to command handlers This refactoring moves the telemetry events (wrangler command started/completed/errored) from the yargs middleware in index.ts to the command handler in register-yargs-command.ts. Benefits: - Telemetry is now sent after config is loaded, allowing access to send_metrics and hasAssets - Telemetry is only sent for commands that use defineCommand Key changes: - Add CommandHandledError wrapper class to signal when telemetry has been sent - Add getErrorType() function to classify errors for telemetry - Send telemetry events in register-yargs-command.ts after config is loaded - Preserve fallback telemetry in index.ts for yargs validation errors - Handle nested wrangler.parse() calls by not double-wrapping CommandHandledError - Properly unwrap CommandHandledError when writing command-failed output --- .../src/__tests__/core/handle-errors.test.ts | 293 +++++++++++++++--- .../wrangler/src/__tests__/metrics.test.ts | 16 +- .../wrangler/src/core/CommandHandledError.ts | 15 + packages/wrangler/src/core/handle-errors.ts | 53 +++- .../src/core/register-yargs-command.ts | 97 +++++- packages/wrangler/src/index.ts | 104 ++++--- .../wrangler/src/metrics/metrics-config.ts | 4 + .../src/metrics/metrics-dispatcher.ts | 8 +- 8 files changed, 460 insertions(+), 130 deletions(-) create mode 100644 packages/wrangler/src/core/CommandHandledError.ts diff --git a/packages/wrangler/src/__tests__/core/handle-errors.test.ts b/packages/wrangler/src/__tests__/core/handle-errors.test.ts index 7b52c9aa69da..ac1cbf35b1e6 100644 --- a/packages/wrangler/src/__tests__/core/handle-errors.test.ts +++ b/packages/wrangler/src/__tests__/core/handle-errors.test.ts @@ -1,7 +1,244 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { handleError } from "../../core/handle-errors"; +import { getErrorType, handleError } from "../../core/handle-errors"; import { mockConsoleMethods } from "../helpers/mock-console"; +describe("getErrorType", () => { + describe("DNS errors", () => { + it("should return 'DNSError' for ENOTFOUND to api.cloudflare.com", () => { + const error = Object.assign( + new Error("getaddrinfo ENOTFOUND api.cloudflare.com"), + { + code: "ENOTFOUND", + hostname: "api.cloudflare.com", + syscall: "getaddrinfo", + } + ); + + expect(getErrorType(error)).toBe("DNSError"); + }); + + it("should return 'DNSError' for ENOTFOUND to dash.cloudflare.com", () => { + const error = Object.assign( + new Error("getaddrinfo ENOTFOUND dash.cloudflare.com"), + { + code: "ENOTFOUND", + hostname: "dash.cloudflare.com", + } + ); + + expect(getErrorType(error)).toBe("DNSError"); + }); + + it("should return 'DNSError' for DNS errors in error cause", () => { + const cause = Object.assign( + new Error("getaddrinfo ENOTFOUND api.cloudflare.com"), + { + code: "ENOTFOUND", + hostname: "api.cloudflare.com", + } + ); + const error = new Error("Request failed", { cause }); + + expect(getErrorType(error)).toBe("DNSError"); + }); + + it("should NOT return 'DNSError' for non-Cloudflare hostnames", () => { + const error = Object.assign( + new Error("getaddrinfo ENOTFOUND example.com"), + { + code: "ENOTFOUND", + hostname: "example.com", + } + ); + + expect(getErrorType(error)).not.toBe("DNSError"); + }); + }); + + describe("Connection timeout errors", () => { + it("should return 'ConnectionTimeout' for api.cloudflare.com timeouts", () => { + const error = Object.assign( + new Error("Connect Timeout Error: https://api.cloudflare.com/endpoint"), + { code: "UND_ERR_CONNECT_TIMEOUT" } + ); + + expect(getErrorType(error)).toBe("ConnectionTimeout"); + }); + + it("should return 'ConnectionTimeout' for dash.cloudflare.com timeouts", () => { + const error = Object.assign( + new Error("Connect Timeout Error: https://dash.cloudflare.com/api"), + { code: "UND_ERR_CONNECT_TIMEOUT" } + ); + + expect(getErrorType(error)).toBe("ConnectionTimeout"); + }); + + it("should return 'ConnectionTimeout' for timeout errors in error cause", () => { + const cause = Object.assign( + new Error("timeout connecting to api.cloudflare.com"), + { code: "UND_ERR_CONNECT_TIMEOUT" } + ); + const error = new Error("Request failed", { cause }); + + expect(getErrorType(error)).toBe("ConnectionTimeout"); + }); + + it("should return 'ConnectionTimeout' when Cloudflare URL is in parent message", () => { + const cause = Object.assign(new Error("connect timeout"), { + code: "UND_ERR_CONNECT_TIMEOUT", + }); + const error = new Error( + "Failed to connect to https://api.cloudflare.com/client/v4/accounts", + { cause } + ); + + expect(getErrorType(error)).toBe("ConnectionTimeout"); + }); + + it("should NOT return 'ConnectionTimeout' for non-Cloudflare URLs", () => { + const error = Object.assign( + new Error("Connect Timeout Error: https://example.com/api"), + { code: "UND_ERR_CONNECT_TIMEOUT" } + ); + + expect(getErrorType(error)).not.toBe("ConnectionTimeout"); + }); + + it("should NOT return 'ConnectionTimeout' for user's dev server timeouts", () => { + const cause = Object.assign( + new Error("timeout connecting to localhost:8787"), + { code: "UND_ERR_CONNECT_TIMEOUT" } + ); + const error = new Error("Request failed", { cause }); + + expect(getErrorType(error)).not.toBe("ConnectionTimeout"); + }); + }); + + describe("Permission errors", () => { + it("should return 'PermissionError' for EPERM errors", () => { + const error = Object.assign( + new Error( + "EPERM: operation not permitted, open '/Users/user/.wrangler/logs/wrangler.log'" + ), + { + code: "EPERM", + errno: -1, + syscall: "open", + path: "/Users/user/.wrangler/logs/wrangler.log", + } + ); + + expect(getErrorType(error)).toBe("PermissionError"); + }); + + it("should return 'PermissionError' for EACCES errors", () => { + const error = Object.assign( + new Error( + "EACCES: permission denied, open '/Users/user/Library/Preferences/.wrangler/config/default.toml'" + ), + { + code: "EACCES", + errno: -13, + syscall: "open", + path: "/Users/user/Library/Preferences/.wrangler/config/default.toml", + } + ); + + expect(getErrorType(error)).toBe("PermissionError"); + }); + + it("should return 'PermissionError' for EPERM errors without path", () => { + const error = Object.assign( + new Error("EPERM: operation not permitted, mkdir"), + { + code: "EPERM", + } + ); + + expect(getErrorType(error)).toBe("PermissionError"); + }); + + it("should return 'PermissionError' for EPERM errors in error cause", () => { + const cause = Object.assign( + new Error( + "EPERM: operation not permitted, open '/var/logs/wrangler.log'" + ), + { + code: "EPERM", + path: "/var/logs/wrangler.log", + } + ); + const error = new Error("Failed to write to log file", { cause }); + + expect(getErrorType(error)).toBe("PermissionError"); + }); + + it("should NOT return 'PermissionError' for non-EPERM/EACCES errors", () => { + const error = Object.assign(new Error("ENOENT: file not found"), { + code: "ENOENT", + }); + + expect(getErrorType(error)).not.toBe("PermissionError"); + }); + }); + + describe("File not found errors", () => { + it("should return 'FileNotFoundError' for ENOENT errors with path", () => { + const error = Object.assign( + new Error("ENOENT: no such file or directory, open 'wrangler.toml'"), + { + code: "ENOENT", + errno: -2, + syscall: "open", + path: "wrangler.toml", + } + ); + + expect(getErrorType(error)).toBe("FileNotFoundError"); + }); + + it("should return 'FileNotFoundError' for ENOENT errors without path", () => { + const error = Object.assign( + new Error("ENOENT: no such file or directory"), + { + code: "ENOENT", + } + ); + + expect(getErrorType(error)).toBe("FileNotFoundError"); + }); + + it("should return 'FileNotFoundError' for ENOENT errors in error cause", () => { + const cause = Object.assign( + new Error("ENOENT: no such file or directory, stat '.wrangler'"), + { + code: "ENOENT", + path: ".wrangler", + } + ); + const error = new Error("Failed to read directory", { cause }); + + expect(getErrorType(error)).toBe("FileNotFoundError"); + }); + }); + + describe("Fallback behavior", () => { + it("should return constructor name for unknown Error types", () => { + const error = new TypeError("Something went wrong"); + + expect(getErrorType(error)).toBe("TypeError"); + }); + + it("should return undefined for non-Error values", () => { + expect(getErrorType("string error")).toBe(undefined); + expect(getErrorType(null)).toBe(undefined); + expect(getErrorType(undefined)).toBe(undefined); + }); + }); +}); + describe("handleError", () => { const std = mockConsoleMethods(); @@ -96,9 +333,8 @@ describe("handleError", () => { { code: "UND_ERR_CONNECT_TIMEOUT" } ); - const errorType = await handleError(error, {}, []); + await handleError(error, {}, []); - expect(errorType).toBe("ConnectionTimeout"); expect(std.err).toContain("The request to Cloudflare's API timed out"); expect(std.err).toContain("network connectivity issues"); expect(std.err).toContain("Please check your internet connection"); @@ -110,9 +346,8 @@ describe("handleError", () => { { code: "UND_ERR_CONNECT_TIMEOUT" } ); - const errorType = await handleError(error, {}, []); + await handleError(error, {}, []); - expect(errorType).toBe("ConnectionTimeout"); expect(std.err).toContain("The request to Cloudflare's API timed out"); }); @@ -123,9 +358,8 @@ describe("handleError", () => { ); const error = new Error("Request failed", { cause }); - const errorType = await handleError(error, {}, []); + await handleError(error, {}, []); - expect(errorType).toBe("ConnectionTimeout"); expect(std.err).toContain("The request to Cloudflare's API timed out"); }); @@ -138,9 +372,8 @@ describe("handleError", () => { { cause } ); - const errorType = await handleError(error, {}, []); + await handleError(error, {}, []); - expect(errorType).toBe("ConnectionTimeout"); expect(std.err).toContain("The request to Cloudflare's API timed out"); }); @@ -150,9 +383,8 @@ describe("handleError", () => { { code: "UND_ERR_CONNECT_TIMEOUT" } ); - const errorType = await handleError(error, {}, []); + await handleError(error, {}, []); - expect(errorType).not.toBe("ConnectionTimeout"); expect(std.err).not.toContain( "The request to Cloudflare's API timed out" ); @@ -165,9 +397,8 @@ describe("handleError", () => { ); const error = new Error("Request failed", { cause }); - const errorType = await handleError(error, {}, []); + await handleError(error, {}, []); - expect(errorType).not.toBe("ConnectionTimeout"); expect(std.err).not.toContain( "The request to Cloudflare's API timed out" ); @@ -188,9 +419,8 @@ describe("handleError", () => { } ); - const errorType = await handleError(error, {}, []); + await handleError(error, {}, []); - expect(errorType).toBe("PermissionError"); expect(std.err).toContain( "A permission error occurred while accessing the file system" ); @@ -213,9 +443,8 @@ describe("handleError", () => { } ); - const errorType = await handleError(error, {}, []); + await handleError(error, {}, []); - expect(errorType).toBe("PermissionError"); expect(std.err).toContain( "A permission error occurred while accessing the file system" ); @@ -233,9 +462,8 @@ describe("handleError", () => { } ); - const errorType = await handleError(error, {}, []); + await handleError(error, {}, []); - expect(errorType).toBe("PermissionError"); expect(std.err).toContain( "A permission error occurred while accessing the file system" ); @@ -255,9 +483,8 @@ describe("handleError", () => { ); const error = new Error("Failed to write to log file", { cause }); - const errorType = await handleError(error, {}, []); + await handleError(error, {}, []); - expect(errorType).toBe("PermissionError"); expect(std.err).toContain( "A permission error occurred while accessing the file system" ); @@ -269,9 +496,8 @@ describe("handleError", () => { code: "ENOENT", }); - const errorType = await handleError(error, {}, []); + await handleError(error, {}, []); - expect(errorType).not.toBe("PermissionError"); expect(std.err).not.toContain( "A permission error occurred while accessing the file system" ); @@ -289,9 +515,8 @@ describe("handleError", () => { } ); - const errorType = await handleError(error, {}, []); + await handleError(error, {}, []); - expect(errorType).toBe("DNSError"); expect(std.err).toContain("Unable to resolve Cloudflare's API hostname"); expect(std.err).toContain("api.cloudflare.com or dash.cloudflare.com"); expect(std.err).toContain("No internet connection"); @@ -307,9 +532,8 @@ describe("handleError", () => { } ); - const errorType = await handleError(error, {}, []); + await handleError(error, {}, []); - expect(errorType).toBe("DNSError"); expect(std.err).toContain("Unable to resolve Cloudflare's API hostname"); }); @@ -323,9 +547,8 @@ describe("handleError", () => { ); const error = new Error("Request failed", { cause }); - const errorType = await handleError(error, {}, []); + await handleError(error, {}, []); - expect(errorType).toBe("DNSError"); expect(std.err).toContain("Unable to resolve Cloudflare's API hostname"); }); @@ -338,9 +561,8 @@ describe("handleError", () => { } ); - const errorType = await handleError(error, {}, []); + await handleError(error, {}, []); - expect(errorType).not.toBe("DNSError"); expect(std.err).not.toContain( "Unable to resolve Cloudflare's API hostname" ); @@ -359,9 +581,8 @@ describe("handleError", () => { } ); - const errorType = await handleError(error, {}, []); + await handleError(error, {}, []); - expect(errorType).toBe("FileNotFoundError"); expect(std.err).toContain("A file or directory could not be found"); expect(std.err).toContain("Missing file or directory: wrangler.toml"); expect(std.err).toContain("The file or directory does not exist"); @@ -375,9 +596,8 @@ describe("handleError", () => { } ); - const errorType = await handleError(error, {}, []); + await handleError(error, {}, []); - expect(errorType).toBe("FileNotFoundError"); expect(std.err).toContain("A file or directory could not be found"); expect(std.err).toContain("Error: ENOENT: no such file or directory"); expect(std.err).not.toContain("Missing file or directory:"); @@ -393,9 +613,8 @@ describe("handleError", () => { ); const error = new Error("Failed to read directory", { cause }); - const errorType = await handleError(error, {}, []); + await handleError(error, {}, []); - expect(errorType).toBe("FileNotFoundError"); expect(std.err).toContain("A file or directory could not be found"); expect(std.err).toContain("Missing file or directory: .wrangler"); }); diff --git a/packages/wrangler/src/__tests__/metrics.test.ts b/packages/wrangler/src/__tests__/metrics.test.ts index 248fc9ac69b6..c40f096ef417 100644 --- a/packages/wrangler/src/__tests__/metrics.test.ts +++ b/packages/wrangler/src/__tests__/metrics.test.ts @@ -336,10 +336,10 @@ describe("metrics", () => { ); expect(std.out).toMatchInlineSnapshot(` " - Cloudflare collects anonymous telemetry about your usage of Wrangler. Learn more at https://github.com/cloudflare/workers-sdk/tree/main/packages/wrangler/telemetry.md - ⛅️ wrangler x.x.x ────────────────── + + Cloudflare collects anonymous telemetry about your usage of Wrangler. Learn more at https://github.com/cloudflare/workers-sdk/tree/main/packages/wrangler/telemetry.md Opening a link in your default browser: FAKE_DOCS_URL:{\\"params\\":\\"query=arg&hitsPerPage=1&getRankingInfo=0\\"}" `); expect(std.warn).toMatchInlineSnapshot(`""`); @@ -476,10 +476,10 @@ describe("metrics", () => { ); expect(std.out).toMatchInlineSnapshot(` " - Cloudflare collects anonymous telemetry about your usage of Wrangler. Learn more at https://github.com/cloudflare/workers-sdk/tree/main/packages/wrangler/telemetry.md - ⛅️ wrangler x.x.x ────────────────── + + Cloudflare collects anonymous telemetry about your usage of Wrangler. Learn more at https://github.com/cloudflare/workers-sdk/tree/main/packages/wrangler/telemetry.md Opening a link in your default browser: FAKE_DOCS_URL:{\\"params\\":\\"query=arg&hitsPerPage=1&getRankingInfo=0\\"}" `); expect(std.warn).toMatchInlineSnapshot(`""`); @@ -581,10 +581,10 @@ describe("metrics", () => { await runWrangler("docs arg"); expect(std.out).toMatchInlineSnapshot(` " - Cloudflare collects anonymous telemetry about your usage of Wrangler. Learn more at https://github.com/cloudflare/workers-sdk/tree/main/packages/wrangler/telemetry.md - ⛅️ wrangler x.x.x ────────────────── + + Cloudflare collects anonymous telemetry about your usage of Wrangler. Learn more at https://github.com/cloudflare/workers-sdk/tree/main/packages/wrangler/telemetry.md Opening a link in your default browser: FAKE_DOCS_URL:{\\"params\\":\\"query=arg&hitsPerPage=1&getRankingInfo=0\\"}" `); @@ -616,10 +616,10 @@ describe("metrics", () => { await runWrangler("docs arg"); expect(std.out).toMatchInlineSnapshot(` " - Cloudflare collects anonymous telemetry about your usage of Wrangler. Learn more at https://github.com/cloudflare/workers-sdk/tree/main/packages/wrangler/telemetry.md - ⛅️ wrangler x.x.x ────────────────── + + Cloudflare collects anonymous telemetry about your usage of Wrangler. Learn more at https://github.com/cloudflare/workers-sdk/tree/main/packages/wrangler/telemetry.md Opening a link in your default browser: FAKE_DOCS_URL:{\\"params\\":\\"query=arg&hitsPerPage=1&getRankingInfo=0\\"}" `); expect(requests.count).toBe(2); diff --git a/packages/wrangler/src/core/CommandHandledError.ts b/packages/wrangler/src/core/CommandHandledError.ts new file mode 100644 index 000000000000..509576184f07 --- /dev/null +++ b/packages/wrangler/src/core/CommandHandledError.ts @@ -0,0 +1,15 @@ +/** + * A wrapper that indicates the original error was thrown from a command handler + * that has already sent telemetry (started + errored events). + * + * When this error is caught in index.ts, the outer error handler should: + * 1. NOT send fallback telemetry (it's already been sent) + * 2. Unwrap and rethrow the original error for proper error handling/display + * + * This is used to distinguish between: + * - Errors from command handlers (telemetry sent by handler) + * - Yargs validation errors (telemetry needs to be sent by fallback handler) + */ +export class CommandHandledError { + constructor(public readonly originalError: unknown) {} +} diff --git a/packages/wrangler/src/core/handle-errors.ts b/packages/wrangler/src/core/handle-errors.ts index 4752a0702c5e..a0bbe5defb6e 100644 --- a/packages/wrangler/src/core/handle-errors.ts +++ b/packages/wrangler/src/core/handle-errors.ts @@ -247,6 +247,38 @@ function isNetworkFetchFailedError(e: unknown): boolean { return false; } +/** + * Determines the error type for telemetry purposes. + * This is a pure function that doesn't log or have side effects. + */ +export function getErrorType(e: unknown): string | undefined { + if (isCloudflareAPIDNSError(e)) { + return "DNSError"; + } + if (isPermissionError(e)) { + return "PermissionError"; + } + if (isFileNotFoundError(e)) { + return "FileNotFoundError"; + } + if (isCloudflareAPIConnectionTimeoutError(e)) { + return "ConnectionTimeout"; + } + if ( + isAuthenticationError(e) || + (e instanceof UserError && + e.cause instanceof ApiError && + e.cause.status === 403) + ) { + return "AuthenticationError"; + } + if (isBuildFailure(e) || isBuildFailureFromCause(e)) { + return "BuildFailure"; + } + // Fallback to constructor name + return e instanceof Error ? e.constructor.name : undefined; +} + /** * Handles an error thrown during command execution. * @@ -256,9 +288,8 @@ export async function handleError( e: unknown, args: ReadConfigCommandArgs, subCommandParts: string[] -) { +): Promise { let mayReport = true; - let errorType: string | undefined; let loggableException = e; logger.log(""); // Just adds a bit of space @@ -275,7 +306,6 @@ export async function handleError( // Handle DNS resolution errors to Cloudflare API with a user-friendly message if (isCloudflareAPIDNSError(e)) { mayReport = false; - errorType = "DNSError"; logger.error(dedent` Unable to resolve Cloudflare's API hostname (api.cloudflare.com or dash.cloudflare.com). @@ -287,13 +317,12 @@ export async function handleError( Please check your network connection and DNS settings. `); - return errorType; + return; } // Handle permission errors with a user-friendly message if (isPermissionError(e)) { mayReport = false; - errorType = "PermissionError"; // Extract the error message and path, checking both the error and its cause const errorMessage = e instanceof Error ? e.message : String(e); @@ -339,13 +368,12 @@ export async function handleError( Please check the file permissions and try again. `); - return errorType; + return; } // Handle file not found errors with a user-friendly message if (isFileNotFoundError(e)) { mayReport = false; - errorType = "FileNotFoundError"; // Extract the error message and path, checking both the error and its cause const errorMessage = e instanceof Error ? e.message : String(e); @@ -390,19 +418,18 @@ export async function handleError( Please check the file path and try again. `); - return errorType; + return; } // Handle connection timeout errors to Cloudflare API with a user-friendly message if (isCloudflareAPIConnectionTimeoutError(e)) { mayReport = false; - errorType = "ConnectionTimeout"; logger.error( "The request to Cloudflare's API timed out.\n" + "This is likely due to network connectivity issues or slow network speeds.\n" + "Please check your internet connection and try again." ); - return errorType; + return; } // Handle generic "fetch failed" / "Failed to fetch" network errors @@ -457,7 +484,6 @@ export async function handleError( e.cause.status === 403) ) { mayReport = false; - errorType = "AuthenticationError"; if (e.cause instanceof ApiError) { logger.error(e.cause); } else { @@ -519,12 +545,9 @@ export async function handleError( ); } else if (isBuildFailure(e)) { mayReport = false; - errorType = "BuildFailure"; - logBuildFailure(e.errors, e.warnings); } else if (isBuildFailureFromCause(e)) { mayReport = false; - errorType = "BuildFailure"; logBuildFailure(e.cause.errors, e.cause.warnings); } else if (e instanceof Cloudflare.APIError) { const error = new APIError({ @@ -576,6 +599,4 @@ export async function handleError( ) { await captureGlobalException(loggableException); } - - return errorType; } diff --git a/packages/wrangler/src/core/register-yargs-command.ts b/packages/wrangler/src/core/register-yargs-command.ts index 517ef62494d3..609d3b767f60 100644 --- a/packages/wrangler/src/core/register-yargs-command.ts +++ b/packages/wrangler/src/core/register-yargs-command.ts @@ -1,3 +1,4 @@ +import { UserError as ContainersUserError } from "@cloudflare/containers-shared/src/error"; import { defaultWranglerConfig, FatalError, @@ -11,10 +12,13 @@ import { createCloudflareClient } from "../cfetch/internal"; import { readConfig } from "../config"; import { run } from "../experimental-flags"; import { logger } from "../logger"; +import { getMetricsDispatcher } from "../metrics"; import { writeOutput } from "../output"; import { dedent } from "../utils/dedent"; import { isLocal, printResourceLocation } from "../utils/is-local"; import { printWranglerBanner } from "../wrangler-banner"; +import { CommandHandledError } from "./CommandHandledError"; +import { getErrorType } from "./handle-errors"; import { demandSingleValue } from "./helpers"; import type { CommonYargsArgv, SubHelp } from "../yargs-types"; import type { @@ -30,7 +34,8 @@ import type { PositionalOptions } from "yargs"; */ export function createRegisterYargsCommand( yargs: CommonYargsArgv, - subHelp: SubHelp + subHelp: SubHelp, + argv: string[] ) { return function registerCommand( segment: string, @@ -97,13 +102,19 @@ export function createRegisterYargsCommand( registerSubTreeCallback(); }, // Only attach the handler for commands, not namespaces - def.type === "command" ? createHandler(def, def.command) : undefined + def.type === "command" ? createHandler(def, def.command, argv) : undefined ); }; } -function createHandler(def: CommandDefinition, commandName: string) { +function createHandler( + def: CommandDefinition, + commandName: string, + argv: string[] +) { return async function handler(args: HandlerArgs) { + const startTime = Date.now(); + try { const shouldPrintBanner = def.behaviour?.printBanner; @@ -165,7 +176,7 @@ function createHandler(def: CommandDefinition, commandName: string) { AUTOCREATE_RESOURCES: args.experimentalAutoCreate, }; - await run(experimentalFlags, () => { + await run(experimentalFlags, async () => { const config = def.behaviour?.provideConfig ?? true ? readConfig(args, { @@ -175,6 +186,14 @@ function createHandler(def: CommandDefinition, commandName: string) { }) : defaultWranglerConfig; + // Create metrics dispatcher with config info + const dispatcher = getMetricsDispatcher({ + sendMetrics: config.send_metrics, + hasAssets: !!config.assets?.directory, + configPath: config.configPath, + argv, + }); + if (def.behaviour?.warnIfMultipleEnvsConfiguredButNoneSpecified) { if (!("env" in args) && config.configPath) { const { rawConfig } = experimental_readRawConfig( @@ -196,23 +215,67 @@ function createHandler(def: CommandDefinition, commandName: string) { } } - return def.handler(args, { - sdk: createCloudflareClient(config), - config, - errors: { UserError, FatalError }, - logger, - fetchResult, + // Send "started" event + dispatcher.sendCommandEvent("wrangler command started", { + command: commandName, + args, }); - }); - // TODO(telemetry): send command completed event - } catch (err) { - // TODO(telemetry): send command errored event + try { + const result = await def.handler(args, { + sdk: createCloudflareClient(config), + config, + errors: { UserError, FatalError }, + logger, + fetchResult, + }); + // Send "completed" event + const durationMs = Date.now() - startTime; + dispatcher.sendCommandEvent("wrangler command completed", { + command: commandName, + args, + durationMs, + durationSeconds: durationMs / 1000, + durationMinutes: durationMs / 1000 / 60, + }); + + return result; + } catch (err) { + // If the error is already a CommandHandledError (e.g., from a nested wrangler.parse() call), + // don't wrap it again - just rethrow it + if (err instanceof CommandHandledError) { + throw err; + } + + // Send "errored" event + const durationMs = Date.now() - startTime; + dispatcher.sendCommandEvent("wrangler command errored", { + command: commandName, + args, + durationMs, + durationSeconds: durationMs / 1000, + durationMinutes: durationMs / 1000 / 60, + errorType: getErrorType(err), + errorMessage: + err instanceof UserError || err instanceof ContainersUserError + ? err.telemetryMessage + : undefined, + }); + // Wrap the error to signal that telemetry has been sent + throw new CommandHandledError(err); + } + }); + } catch (err) { // Write handler failure to output file if one exists - if (err instanceof Error) { - const code = "code" in err ? (err.code as number) : undefined; - const message = "message" in err ? (err.message as string) : undefined; + // Unwrap CommandHandledError to get the original error for output + const outputErr = + err instanceof CommandHandledError ? err.originalError : err; + if (outputErr instanceof Error) { + const code = + "code" in outputErr ? (outputErr.code as number) : undefined; + const message = + "message" in outputErr ? (outputErr.message as string) : undefined; writeOutput({ type: "command-failed", version: 1, diff --git a/packages/wrangler/src/index.ts b/packages/wrangler/src/index.ts index 12684a58e541..a5d716c2b8c8 100644 --- a/packages/wrangler/src/index.ts +++ b/packages/wrangler/src/index.ts @@ -30,8 +30,9 @@ import { completionsCommand } from "./complete"; import { getDefaultEnvFiles, loadDotEnv } from "./config/dot-env"; import { containers } from "./containers"; import { demandSingleValue } from "./core"; +import { CommandHandledError } from "./core/CommandHandledError"; import { CommandRegistry } from "./core/CommandRegistry"; -import { handleError } from "./core/handle-errors"; +import { getErrorType, handleError } from "./core/handle-errors"; import { createRegisterYargsCommand } from "./core/register-yargs-command"; import { d1Namespace } from "./d1"; import { d1CreateCommand } from "./d1/create"; @@ -492,7 +493,7 @@ export function createCLIParser(argv: string[]) { }, }; - const registerCommand = createRegisterYargsCommand(wrangler, subHelp); + const registerCommand = createRegisterYargsCommand(wrangler, subHelp, argv); const registry = new CommandRegistry(registerCommand); // Helper to show help with command categories @@ -1742,8 +1743,6 @@ export async function main(argv: string[]): Promise { checkMacOSVersion({ shouldThrow: false }); - const startTime = Date.now(); - // Check if this is a root-level help request (--help or -h with no subcommand) // In this case, we use our custom help formatter to show command categories const isRootHelpRequest = @@ -1756,10 +1755,8 @@ export async function main(argv: string[]): Promise { await showHelpWithCategories(); return; } - let command: string | undefined; - let metricsArgs: Record | undefined; - let dispatcher: ReturnType | undefined; - // Register Yargs middleware to record command as Sentry breadcrumb + + // Register Yargs middleware to record command as Sentry breadcrumb and set logger level let recordedCommand = false; const wranglerWithMiddleware = wrangler.middleware((args) => { // Update logger level, before we do any logging @@ -1774,8 +1771,26 @@ export async function main(argv: string[]): Promise { return; } recordedCommand = true; - // `args._` doesn't include any positional arguments (e.g. script name, - // key to fetch) or flags + + // Record command as Sentry breadcrumb + const command = `wrangler ${args._.join(" ")}`; + addBreadcrumb(command); + + // TODO: Legacy commands (cloudchamber, containers) don't use defineCommand + // and won't emit telemetry events. Migrate them to defineCommand to enable telemetry. + }, /* applyBeforeValidation */ true); + + const startTime = Date.now(); + let command: string | undefined; + let metricsArgs: Record | undefined; + let dispatcher: ReturnType | undefined; + + // Register middleware to capture command info for fallback telemetry + const wranglerWithTelemetry = wranglerWithMiddleware.middleware((args) => { + // Capture command and args for potential fallback telemetry + // (used when yargs validation errors occur before handler runs) + command = `wrangler ${args._.join(" ")}`; + metricsArgs = args; try { const { rawConfig, configPath } = experimental_readRawConfig(args); @@ -1783,65 +1798,55 @@ export async function main(argv: string[]): Promise { sendMetrics: rawConfig.send_metrics, hasAssets: !!rawConfig.assets?.directory, configPath, + argv, }); } catch (e) { - // If we can't parse the config, we can't send metrics - logger.debug("Failed to parse config. Disabling metrics dispatcher.", e); + // If we can't parse the config, we can still send metrics with defaults + logger.debug("Failed to parse config for metrics. Using defaults.", e); + dispatcher = getMetricsDispatcher({ argv }); } - - command = `wrangler ${args._.join(" ")}`; - metricsArgs = args; - addBreadcrumb(command); - // NB despite 'applyBeforeValidation = true', this runs *after* yargs 'validates' options, - // e.g. if a required arg is missing, yargs will error out before we send any events :/ - dispatcher?.sendCommandEvent( - "wrangler command started", - { - command, - args, - }, - argv - ); }, /* applyBeforeValidation */ true); let cliHandlerThrew = false; try { - await wranglerWithMiddleware.parse(); + await wranglerWithTelemetry.parse(); + } catch (e) { + cliHandlerThrew = true; + + // Check if this is a CommandHandledError (telemetry already sent by handler) + if (e instanceof CommandHandledError) { + // Unwrap and handle the original error + await handleError(e.originalError, metricsArgs ?? {}, argv); + throw e.originalError; + } - const durationMs = Date.now() - startTime; + // Fallback telemetry for errors that occurred before handler ran + // (e.g., yargs validation errors like unknown commands or invalid arguments) + if (dispatcher && command && metricsArgs) { + const durationMs = Date.now() - startTime; - dispatcher?.sendCommandEvent( - "wrangler command completed", - { + // Send "started" event (since handler never got to send it) + dispatcher.sendCommandEvent("wrangler command started", { command, args: metricsArgs, - durationMs, - durationSeconds: durationMs / 1000, - durationMinutes: durationMs / 1000 / 60, - }, - argv - ); - } catch (e) { - cliHandlerThrew = true; - const errorType = await handleError(e, wrangler.arguments, argv); - const durationMs = Date.now() - startTime; - dispatcher?.sendCommandEvent( - "wrangler command errored", - { + }); + + // Send "errored" event + dispatcher.sendCommandEvent("wrangler command errored", { command, args: metricsArgs, durationMs, durationSeconds: durationMs / 1000, durationMinutes: durationMs / 1000 / 60, - errorType: - errorType ?? (e instanceof Error ? e.constructor.name : undefined), + errorType: getErrorType(e), errorMessage: e instanceof UserError || e instanceof ContainersUserError ? e.telemetryMessage : undefined, - }, - argv - ); + }); + } + + await handleError(e, metricsArgs ?? {}, argv); throw e; } finally { try { @@ -1859,6 +1864,7 @@ export async function main(argv: string[]): Promise { await closeSentry(); + // Wait for any pending telemetry requests to complete (with timeout) await Promise.race([ Promise.allSettled(dispatcher?.requests ?? []), setTimeout(1000, undefined, { ref: false }), diff --git a/packages/wrangler/src/metrics/metrics-config.ts b/packages/wrangler/src/metrics/metrics-config.ts index 9ec7dd52d6e5..7f56e6bf7280 100644 --- a/packages/wrangler/src/metrics/metrics-config.ts +++ b/packages/wrangler/src/metrics/metrics-config.ts @@ -19,6 +19,10 @@ import { logger } from "../logger"; export const CURRENT_METRICS_DATE = new Date(2022, 6, 4); export interface MetricsConfigOptions { + /** + * The argv passed to the `main()` function. + */ + argv?: string[]; /** * Defines whether to send metrics to Cloudflare: * If defined, then use this value for whether the dispatch is enabled. diff --git a/packages/wrangler/src/metrics/metrics-dispatcher.ts b/packages/wrangler/src/metrics/metrics-dispatcher.ts index 782a8179c1c6..9a947b18daf4 100644 --- a/packages/wrangler/src/metrics/metrics-dispatcher.ts +++ b/packages/wrangler/src/metrics/metrics-dispatcher.ts @@ -114,8 +114,7 @@ export function getMetricsDispatcher(options: MetricsConfigOptions) { properties: Omit< Extract["properties"], keyof CommonEventProperties - >, - argv?: string[] + > ) { try { if (properties.command?.startsWith("wrangler login")) { @@ -136,7 +135,10 @@ export function getMetricsDispatcher(options: MetricsConfigOptions) { printMetricsBanner(); } - const sanitizedArgs = sanitizeArgKeys(properties.args ?? {}, argv); + const sanitizedArgs = sanitizeArgKeys( + properties.args ?? {}, + options.argv + ); const sanitizedArgsKeys = Object.keys(sanitizedArgs).sort(); const commonEventProperties: CommonEventProperties = { amplitude_session_id, From c2cdad21f4a16fe82b96d7708e4849168f251997 Mon Sep 17 00:00:00 2001 From: Pete Bacon Darwin Date: Fri, 23 Jan 2026 09:21:39 +0000 Subject: [PATCH 2/3] refactor(wrangler): convert containers and cloudchamber commands to createCommand Migrate containers and cloudchamber CLI commands from the legacy yargs defineCommand approach to the new createCommand pattern. This aligns these commands with the updated telemetry handling where events are dispatched directly from command handlers rather than middleware. --- .../src/__tests__/cloudchamber/create.test.ts | 130 ++++++-- .../src/__tests__/cloudchamber/curl.test.ts | 2 +- .../src/__tests__/cloudchamber/delete.test.ts | 18 +- .../src/__tests__/cloudchamber/images.test.ts | 74 +++-- .../src/__tests__/cloudchamber/list.test.ts | 222 ++++++------- .../src/__tests__/cloudchamber/modify.test.ts | 89 +++-- .../src/__tests__/containers/delete.test.ts | 6 +- .../src/__tests__/containers/info.test.ts | 13 +- .../src/__tests__/containers/list.test.ts | 7 +- .../src/__tests__/containers/push.test.ts | 6 +- .../__tests__/containers/registries.test.ts | 22 +- .../src/__tests__/containers/ssh.test.ts | 4 +- packages/wrangler/src/cloudchamber/apply.ts | 29 +- packages/wrangler/src/cloudchamber/build.ts | 77 +++++ packages/wrangler/src/cloudchamber/create.ts | 93 ++++++ packages/wrangler/src/cloudchamber/curl.ts | 59 ++++ packages/wrangler/src/cloudchamber/delete.ts | 25 ++ .../src/cloudchamber/images/images.ts | 62 +++- .../src/cloudchamber/images/registries.ts | 303 ++++++++++-------- packages/wrangler/src/cloudchamber/index.ts | 172 +++------- packages/wrangler/src/cloudchamber/list.ts | 60 +++- packages/wrangler/src/cloudchamber/modify.ts | 78 +++++ packages/wrangler/src/cloudchamber/ssh/ssh.ts | 157 +++++---- packages/wrangler/src/containers/build.ts | 86 ++++- .../wrangler/src/containers/containers.ts | 58 ++++ packages/wrangler/src/containers/images.ts | 270 ++++++++++++++++ packages/wrangler/src/containers/index.ts | 140 ++------ .../wrangler/src/containers/registries.ts | 162 +++++++--- packages/wrangler/src/containers/ssh.ts | 78 ++++- packages/wrangler/src/index.ts | 179 ++++++++++- 30 files changed, 1956 insertions(+), 725 deletions(-) create mode 100644 packages/wrangler/src/containers/images.ts diff --git a/packages/wrangler/src/__tests__/cloudchamber/create.test.ts b/packages/wrangler/src/__tests__/cloudchamber/create.test.ts index 870fbcccf65c..a7fa516569cf 100644 --- a/packages/wrangler/src/__tests__/cloudchamber/create.test.ts +++ b/packages/wrangler/src/__tests__/cloudchamber/create.test.ts @@ -13,30 +13,6 @@ import { runWrangler } from "../helpers/run-wrangler"; import { mockAccount, setWranglerConfig } from "./utils"; import type { SSHPublicKeyItem } from "@cloudflare/containers-shared"; -const MOCK_DEPLOYMENTS_COMPLEX_RESPONSE = ` - "{ - \\"id\\": \\"1\\", - \\"type\\": \\"default\\", - \\"created_at\\": \\"123\\", - \\"account_id\\": \\"123\\", - \\"vcpu\\": 4, - \\"memory\\": \\"400MB\\", - \\"memory_mib\\": 400, - \\"version\\": 1, - \\"image\\": \\"hello\\", - \\"location\\": { - \\"name\\": \\"sfo06\\", - \\"enabled\\": true - }, - \\"network\\": { - \\"mode\\": \\"public\\", - \\"ipv4\\": \\"1.1.1.1\\" - }, - \\"placements_ref\\": \\"http://ref\\", - \\"node_group\\": \\"metal\\" - }" - `; - function mockDeploymentPost() { msw.use( http.post( @@ -103,7 +79,7 @@ describe("cloudchamber create", () => { expect(std.out).toMatchInlineSnapshot(` "wrangler cloudchamber create - Create a new deployment + Create a new deployment [alpha] GLOBAL FLAGS -c, --config Path to Wrangler configuration file [string] @@ -230,7 +206,32 @@ describe("cloudchamber create", () => { ); // so testing the actual UI will be harder than expected // TODO: think better on how to test UI actions - expect(std.out).toMatchInlineSnapshot(MOCK_DEPLOYMENTS_COMPLEX_RESPONSE); + expect(std.out).toMatchInlineSnapshot(` + " + ⛅️ wrangler x.x.x + ────────────────── + { + \\"id\\": \\"1\\", + \\"type\\": \\"default\\", + \\"created_at\\": \\"123\\", + \\"account_id\\": \\"123\\", + \\"vcpu\\": 4, + \\"memory\\": \\"400MB\\", + \\"memory_mib\\": 400, + \\"version\\": 1, + \\"image\\": \\"hello\\", + \\"location\\": { + \\"name\\": \\"sfo06\\", + \\"enabled\\": true + }, + \\"network\\": { + \\"mode\\": \\"public\\", + \\"ipv4\\": \\"1.1.1.1\\" + }, + \\"placements_ref\\": \\"http://ref\\", + \\"node_group\\": \\"metal\\" + }" + `); }); it("should create deployment with instance type (detects no interactivity)", async () => { @@ -253,7 +254,12 @@ describe("cloudchamber create", () => { await runWrangler( "cloudchamber create --image hello:world --location sfo06 --var HELLO:WORLD --var YOU:CONQUERED --instance-type lite --ipv4 true" ); - expect(std.out).toMatchInlineSnapshot(`"{}"`); + expect(std.out).toMatchInlineSnapshot(` + " + ⛅️ wrangler x.x.x + ────────────────── + {}" + `); }); it("properly reads wrangler config", async () => { @@ -274,7 +280,32 @@ describe("cloudchamber create", () => { await runWrangler( "cloudchamber create --var HELLO:WORLD --var YOU:CONQUERED" ); - expect(std.out).toMatchInlineSnapshot(MOCK_DEPLOYMENTS_COMPLEX_RESPONSE); + expect(std.out).toMatchInlineSnapshot(` + " + ⛅️ wrangler x.x.x + ────────────────── + { + \\"id\\": \\"1\\", + \\"type\\": \\"default\\", + \\"created_at\\": \\"123\\", + \\"account_id\\": \\"123\\", + \\"vcpu\\": 4, + \\"memory\\": \\"400MB\\", + \\"memory_mib\\": 400, + \\"version\\": 1, + \\"image\\": \\"hello\\", + \\"location\\": { + \\"name\\": \\"sfo06\\", + \\"enabled\\": true + }, + \\"network\\": { + \\"mode\\": \\"public\\", + \\"ipv4\\": \\"1.1.1.1\\" + }, + \\"placements_ref\\": \\"http://ref\\", + \\"node_group\\": \\"metal\\" + }" + `); expect(std.err).toMatchInlineSnapshot(`""`); }); @@ -306,7 +337,12 @@ describe("cloudchamber create", () => { await runWrangler( "cloudchamber create --var HELLO:WORLD --var YOU:CONQUERED" ); - expect(std.out).toMatchInlineSnapshot(`"{}"`); + expect(std.out).toMatchInlineSnapshot(` + " + ⛅️ wrangler x.x.x + ────────────────── + {}" + `); expect(std.err).toMatchInlineSnapshot(`""`); }); @@ -349,7 +385,32 @@ describe("cloudchamber create", () => { await runWrangler( "cloudchamber create --image hello:world --location sfo06 --var HELLO:WORLD --var YOU:CONQUERED --all-ssh-keys --ipv4" ); - expect(std.out).toMatchInlineSnapshot(MOCK_DEPLOYMENTS_COMPLEX_RESPONSE); + expect(std.out).toMatchInlineSnapshot(` + " + ⛅️ wrangler x.x.x + ────────────────── + { + \\"id\\": \\"1\\", + \\"type\\": \\"default\\", + \\"created_at\\": \\"123\\", + \\"account_id\\": \\"123\\", + \\"vcpu\\": 4, + \\"memory\\": \\"400MB\\", + \\"memory_mib\\": 400, + \\"version\\": 1, + \\"image\\": \\"hello\\", + \\"location\\": { + \\"name\\": \\"sfo06\\", + \\"enabled\\": true + }, + \\"network\\": { + \\"mode\\": \\"public\\", + \\"ipv4\\": \\"1.1.1.1\\" + }, + \\"placements_ref\\": \\"http://ref\\", + \\"node_group\\": \\"metal\\" + }" + `); }); it("can't create deployment due to lack of fields (json)", async () => { @@ -364,8 +425,11 @@ describe("cloudchamber create", () => { // so testing the actual UI will be harder than expected // TODO: think better on how to test UI actions expect(std.out).toMatchInlineSnapshot(` - " - If you think this is a bug then please create an issue at https://github.com/cloudflare/workers-sdk/issues/new/choose" - `); + " + ⛅️ wrangler x.x.x + ────────────────── + + If you think this is a bug then please create an issue at https://github.com/cloudflare/workers-sdk/issues/new/choose" + `); }); }); diff --git a/packages/wrangler/src/__tests__/cloudchamber/curl.test.ts b/packages/wrangler/src/__tests__/cloudchamber/curl.test.ts index 777bce43fbea..4d86a39cf8c1 100644 --- a/packages/wrangler/src/__tests__/cloudchamber/curl.test.ts +++ b/packages/wrangler/src/__tests__/cloudchamber/curl.test.ts @@ -34,7 +34,7 @@ describe("cloudchamber curl", () => { expect(helpStd.out).toMatchInlineSnapshot(` "wrangler cloudchamber curl - Send a request to an arbitrary Cloudchamber endpoint + Send a request to an arbitrary Cloudchamber endpoint [alpha] POSITIONALS path [string] [required] [default: \\"/\\"] diff --git a/packages/wrangler/src/__tests__/cloudchamber/delete.test.ts b/packages/wrangler/src/__tests__/cloudchamber/delete.test.ts index 3565aef2bff0..d9337c2bb0ad 100644 --- a/packages/wrangler/src/__tests__/cloudchamber/delete.test.ts +++ b/packages/wrangler/src/__tests__/cloudchamber/delete.test.ts @@ -30,10 +30,10 @@ describe("cloudchamber delete", () => { expect(std.out).toMatchInlineSnapshot(` "wrangler cloudchamber delete [deploymentId] - Delete an existing deployment that is running in the Cloudflare edge + Delete an existing deployment that is running in the Cloudflare edge [alpha] POSITIONALS - deploymentId deployment you want to delete [string] + deploymentId Deployment you want to delete [string] GLOBAL FLAGS -c, --config Path to Wrangler configuration file [string] @@ -62,7 +62,10 @@ describe("cloudchamber delete", () => { // TODO: think better on how to test UI actions expect(std.out).toMatchInlineSnapshot( ` - "{ + " + ⛅️ wrangler x.x.x + ────────────────── + { \\"id\\": \\"1\\", \\"type\\": \\"default\\", \\"created_at\\": \\"123\\", @@ -98,8 +101,11 @@ describe("cloudchamber delete", () => { // so testing the actual UI will be harder than expected // TODO: think better on how to test UI actions expect(std.out).toMatchInlineSnapshot(` - " - If you think this is a bug then please create an issue at https://github.com/cloudflare/workers-sdk/issues/new/choose" - `); + " + ⛅️ wrangler x.x.x + ────────────────── + + If you think this is a bug then please create an issue at https://github.com/cloudflare/workers-sdk/issues/new/choose" + `); }); }); diff --git a/packages/wrangler/src/__tests__/cloudchamber/images.test.ts b/packages/wrangler/src/__tests__/cloudchamber/images.test.ts index 1919c6cbb136..5a14161ae059 100644 --- a/packages/wrangler/src/__tests__/cloudchamber/images.test.ts +++ b/packages/wrangler/src/__tests__/cloudchamber/images.test.ts @@ -29,13 +29,7 @@ describe("cloudchamber image", () => { expect(std.out).toMatchInlineSnapshot(` "wrangler cloudchamber registries - Configure registries via Cloudchamber - - COMMANDS - wrangler cloudchamber registries configure Configure Cloudchamber to pull from specific registries - wrangler cloudchamber registries credentials [domain] get a temporary password for a specific domain - wrangler cloudchamber registries remove [domain] removes the registry at the given domain - wrangler cloudchamber registries list list registries configured for this account + Configure registries via Cloudchamber [alpha] GLOBAL FLAGS -c, --config Path to Wrangler configuration file [string] @@ -73,10 +67,13 @@ describe("cloudchamber image", () => { // so testing the actual UI will be harder than expected // TODO: think better on how to test UI actions expect(std.out).toMatchInlineSnapshot(` - "{ - \\"domain\\": \\"docker.io\\" - }" - `); + " + ⛅️ wrangler x.x.x + ────────────────── + { + \\"domain\\": \\"docker.io\\" + }" + `); }); it("should create an image registry (no interactivity)", async () => { @@ -100,7 +97,12 @@ describe("cloudchamber image", () => { expect(std.err).toMatchInlineSnapshot(`""`); // so testing the actual UI will be harder than expected // TODO: think better on how to test UI actions - expect(std.out).toMatchInlineSnapshot(`"jwt"`); + expect(std.out).toMatchInlineSnapshot(` + " + ⛅️ wrangler x.x.x + ────────────────── + jwt" + `); }); it("should remove an image registry (no interactivity)", async () => { @@ -118,7 +120,12 @@ describe("cloudchamber image", () => { ); await runWrangler("cloudchamber registries remove docker.io"); expect(std.err).toMatchInlineSnapshot(`""`); - expect(std.out).toMatchInlineSnapshot(`"{}"`); + expect(std.out).toMatchInlineSnapshot(` + " + ⛅️ wrangler x.x.x + ────────────────── + {}" + `); }); it("should list registries (no interactivity)", async () => { @@ -145,7 +152,10 @@ describe("cloudchamber image", () => { await runWrangler("cloudchamber registries list"); expect(std.err).toMatchInlineSnapshot(`""`); expect(std.out).toMatchInlineSnapshot(` - "[ + " + ⛅️ wrangler x.x.x + ────────────────── + [ { \\"public_key\\": \\"\\", \\"domain\\": \\"docker.io\\" @@ -182,8 +192,6 @@ describe("cloudchamber image list", () => { expect(std.out).toMatchInlineSnapshot(` "wrangler cloudchamber images list - List images in the Cloudflare managed registry - GLOBAL FLAGS -c, --config Path to Wrangler configuration file [string] --cwd Run as if Wrangler was started in the specified directory instead of the current working directory [string] @@ -224,7 +232,10 @@ describe("cloudchamber image list", () => { await runWrangler("cloudchamber images list"); expect(std.err).toMatchInlineSnapshot(`""`); expect(std.out).toMatchInlineSnapshot(` - "REPOSITORY TAG + " + ⛅️ wrangler x.x.x + ────────────────── + REPOSITORY TAG one hundred one ten two thousand @@ -260,7 +271,10 @@ describe("cloudchamber image list", () => { await runWrangler("cloudchamber images list --filter '^two$'"); expect(std.err).toMatchInlineSnapshot(`""`); expect(std.out).toMatchInlineSnapshot(` - "REPOSITORY TAG + " + ⛅️ wrangler x.x.x + ────────────────── + REPOSITORY TAG two thousand two twenty" `); @@ -294,7 +308,10 @@ describe("cloudchamber image list", () => { await runWrangler("cloudchamber images list"); expect(std.err).toMatchInlineSnapshot(`""`); expect(std.out).toMatchInlineSnapshot(` - "REPOSITORY TAG + " + ⛅️ wrangler x.x.x + ────────────────── + REPOSITORY TAG one hundred one ten two thousand @@ -330,7 +347,10 @@ describe("cloudchamber image list", () => { await runWrangler("cloudchamber images list --json"); expect(std.err).toMatchInlineSnapshot(`""`); expect(std.out).toMatchInlineSnapshot(` - "[ + " + ⛅️ wrangler x.x.x + ────────────────── + [ { \\"name\\": \\"one\\", \\"tags\\": [ @@ -384,7 +404,10 @@ describe("cloudchamber image list", () => { await runWrangler("cloudchamber images list --json"); expect(std.err).toMatchInlineSnapshot(`""`); expect(std.out).toMatchInlineSnapshot(` - "[ + " + ⛅️ wrangler x.x.x + ────────────────── + [ { \\"name\\": \\"one\\", \\"tags\\": [ @@ -434,8 +457,6 @@ describe("cloudchamber image delete", () => { expect(std.out).toMatchInlineSnapshot(` "wrangler cloudchamber images delete - Remove an image from the Cloudflare managed registry - POSITIONALS image Image and tag to delete, of the form IMAGE:TAG [string] [required] @@ -488,7 +509,12 @@ describe("cloudchamber image delete", () => { await runWrangler("cloudchamber images delete one:hundred"); expect(std.err).toMatchInlineSnapshot(`""`); expect(std.out).toMatchInlineSnapshot( - `"Deleted one:hundred (some-digest)"` + ` + " + ⛅️ wrangler x.x.x + ────────────────── + Deleted one:hundred (some-digest)" + ` ); }); diff --git a/packages/wrangler/src/__tests__/cloudchamber/list.test.ts b/packages/wrangler/src/__tests__/cloudchamber/list.test.ts index aab9cc9bd697..8636fbb18b9f 100644 --- a/packages/wrangler/src/__tests__/cloudchamber/list.test.ts +++ b/packages/wrangler/src/__tests__/cloudchamber/list.test.ts @@ -30,11 +30,10 @@ describe("cloudchamber list", () => { expect(std.out).toMatchInlineSnapshot(` "wrangler cloudchamber list [deploymentIdPrefix] - List and view status of deployments + List and view status of deployments [alpha] POSITIONALS - deploymentIdPrefix Optional deploymentId to filter deployments - This means that 'list' will only showcase deployments that contain this ID prefix [string] + deploymentIdPrefix Optional deploymentId to filter deployments. This means that 'list' will only showcase deployments that contain this ID prefix [string] GLOBAL FLAGS -c, --config Path to Wrangler configuration file [string] @@ -71,112 +70,115 @@ describe("cloudchamber list", () => { // so testing the actual UI will be harder than expected // TODO: think better on how to test UI actions expect(std.out).toMatchInlineSnapshot(` - "[ - { - \\"id\\": \\"1\\", - \\"type\\": \\"default\\", - \\"created_at\\": \\"123\\", - \\"account_id\\": \\"123\\", - \\"vcpu\\": 4, - \\"memory\\": \\"400MB\\", - \\"memory_mib\\": 400, - \\"version\\": 1, - \\"image\\": \\"hello\\", - \\"location\\": { - \\"name\\": \\"sfo06\\", - \\"enabled\\": true - }, - \\"network\\": { - \\"mode\\": \\"public\\", - \\"ipv4\\": \\"1.1.1.1\\" - }, - \\"placements_ref\\": \\"http://ref\\", - \\"node_group\\": \\"metal\\" - }, - { - \\"id\\": \\"2\\", - \\"type\\": \\"default\\", - \\"created_at\\": \\"1234\\", - \\"account_id\\": \\"123\\", - \\"vcpu\\": 4, - \\"memory\\": \\"400MB\\", - \\"memory_mib\\": 400, - \\"version\\": 2, - \\"image\\": \\"hello\\", - \\"location\\": { - \\"name\\": \\"sfo06\\", - \\"enabled\\": true - }, - \\"network\\": { - \\"mode\\": \\"public\\", - \\"ipv4\\": \\"1.1.1.2\\" - }, - \\"current_placement\\": { - \\"deployment_version\\": 2, - \\"status\\": { - \\"health\\": \\"running\\" - }, - \\"deployment_id\\": \\"2\\", - \\"terminate\\": false, - \\"created_at\\": \\"123\\", - \\"id\\": \\"1\\" - }, - \\"placements_ref\\": \\"http://ref\\", - \\"node_group\\": \\"metal\\" - }, - { - \\"id\\": \\"3\\", - \\"type\\": \\"default\\", - \\"created_at\\": \\"123\\", - \\"account_id\\": \\"123\\", - \\"vcpu\\": 4, - \\"memory\\": \\"400MB\\", - \\"memory_mib\\": 400, - \\"version\\": 1, - \\"image\\": \\"hello\\", - \\"location\\": { - \\"name\\": \\"sfo06\\", - \\"enabled\\": true - }, - \\"network\\": { - \\"mode\\": \\"public\\", - \\"ipv4\\": \\"1.1.1.1\\" - }, - \\"placements_ref\\": \\"http://ref\\", - \\"node_group\\": \\"metal\\" - }, - { - \\"id\\": \\"4\\", - \\"type\\": \\"default\\", - \\"created_at\\": \\"1234\\", - \\"account_id\\": \\"123\\", - \\"vcpu\\": 4, - \\"memory\\": \\"400MB\\", - \\"memory_mib\\": 400, - \\"version\\": 2, - \\"image\\": \\"hello\\", - \\"location\\": { - \\"name\\": \\"sfo06\\", - \\"enabled\\": true - }, - \\"network\\": { - \\"mode\\": \\"public\\", - \\"ipv4\\": \\"1.1.1.2\\" - }, - \\"current_placement\\": { - \\"deployment_version\\": 2, - \\"status\\": { - \\"health\\": \\"running\\" - }, - \\"deployment_id\\": \\"2\\", - \\"terminate\\": false, - \\"created_at\\": \\"123\\", - \\"id\\": \\"1\\" - }, - \\"placements_ref\\": \\"http://ref\\", - \\"node_group\\": \\"metal\\" - } - ]" - `); + " + ⛅️ wrangler x.x.x + ────────────────── + [ + { + \\"id\\": \\"1\\", + \\"type\\": \\"default\\", + \\"created_at\\": \\"123\\", + \\"account_id\\": \\"123\\", + \\"vcpu\\": 4, + \\"memory\\": \\"400MB\\", + \\"memory_mib\\": 400, + \\"version\\": 1, + \\"image\\": \\"hello\\", + \\"location\\": { + \\"name\\": \\"sfo06\\", + \\"enabled\\": true + }, + \\"network\\": { + \\"mode\\": \\"public\\", + \\"ipv4\\": \\"1.1.1.1\\" + }, + \\"placements_ref\\": \\"http://ref\\", + \\"node_group\\": \\"metal\\" + }, + { + \\"id\\": \\"2\\", + \\"type\\": \\"default\\", + \\"created_at\\": \\"1234\\", + \\"account_id\\": \\"123\\", + \\"vcpu\\": 4, + \\"memory\\": \\"400MB\\", + \\"memory_mib\\": 400, + \\"version\\": 2, + \\"image\\": \\"hello\\", + \\"location\\": { + \\"name\\": \\"sfo06\\", + \\"enabled\\": true + }, + \\"network\\": { + \\"mode\\": \\"public\\", + \\"ipv4\\": \\"1.1.1.2\\" + }, + \\"current_placement\\": { + \\"deployment_version\\": 2, + \\"status\\": { + \\"health\\": \\"running\\" + }, + \\"deployment_id\\": \\"2\\", + \\"terminate\\": false, + \\"created_at\\": \\"123\\", + \\"id\\": \\"1\\" + }, + \\"placements_ref\\": \\"http://ref\\", + \\"node_group\\": \\"metal\\" + }, + { + \\"id\\": \\"3\\", + \\"type\\": \\"default\\", + \\"created_at\\": \\"123\\", + \\"account_id\\": \\"123\\", + \\"vcpu\\": 4, + \\"memory\\": \\"400MB\\", + \\"memory_mib\\": 400, + \\"version\\": 1, + \\"image\\": \\"hello\\", + \\"location\\": { + \\"name\\": \\"sfo06\\", + \\"enabled\\": true + }, + \\"network\\": { + \\"mode\\": \\"public\\", + \\"ipv4\\": \\"1.1.1.1\\" + }, + \\"placements_ref\\": \\"http://ref\\", + \\"node_group\\": \\"metal\\" + }, + { + \\"id\\": \\"4\\", + \\"type\\": \\"default\\", + \\"created_at\\": \\"1234\\", + \\"account_id\\": \\"123\\", + \\"vcpu\\": 4, + \\"memory\\": \\"400MB\\", + \\"memory_mib\\": 400, + \\"version\\": 2, + \\"image\\": \\"hello\\", + \\"location\\": { + \\"name\\": \\"sfo06\\", + \\"enabled\\": true + }, + \\"network\\": { + \\"mode\\": \\"public\\", + \\"ipv4\\": \\"1.1.1.2\\" + }, + \\"current_placement\\": { + \\"deployment_version\\": 2, + \\"status\\": { + \\"health\\": \\"running\\" + }, + \\"deployment_id\\": \\"2\\", + \\"terminate\\": false, + \\"created_at\\": \\"123\\", + \\"id\\": \\"1\\" + }, + \\"placements_ref\\": \\"http://ref\\", + \\"node_group\\": \\"metal\\" + } + ]" + `); }); }); diff --git a/packages/wrangler/src/__tests__/cloudchamber/modify.test.ts b/packages/wrangler/src/__tests__/cloudchamber/modify.test.ts index c4009dfb3ecf..71b9db97ae0d 100644 --- a/packages/wrangler/src/__tests__/cloudchamber/modify.test.ts +++ b/packages/wrangler/src/__tests__/cloudchamber/modify.test.ts @@ -25,30 +25,6 @@ function mockDeployment() { ); } -const EXPECTED_RESULT = ` - "{ - \\"id\\": \\"1\\", - \\"type\\": \\"default\\", - \\"created_at\\": \\"123\\", - \\"account_id\\": \\"123\\", - \\"vcpu\\": 4, - \\"memory\\": \\"400MB\\", - \\"memory_mib\\": 400, - \\"version\\": 1, - \\"image\\": \\"hello\\", - \\"location\\": { - \\"name\\": \\"sfo06\\", - \\"enabled\\": true - }, - \\"network\\": { - \\"mode\\": \\"public\\", - \\"ipv4\\": \\"1.1.1.1\\" - }, - \\"placements_ref\\": \\"http://ref\\", - \\"node_group\\": \\"metal\\" - }" - `; - describe("cloudchamber modify", () => { const std = mockConsoleMethods(); const { setIsTTY } = useMockIsTTY(); @@ -68,7 +44,7 @@ describe("cloudchamber modify", () => { expect(std.out).toMatchInlineSnapshot(` "wrangler cloudchamber modify [deploymentId] - Modify an existing deployment + Modify an existing deployment [alpha] POSITIONALS deploymentId The deployment you want to modify [string] @@ -103,7 +79,32 @@ describe("cloudchamber modify", () => { expect(std.err).toMatchInlineSnapshot(`""`); // so testing the actual UI will be harder than expected // TODO: think better on how to test UI actions - expect(std.out).toMatchInlineSnapshot(EXPECTED_RESULT); + expect(std.out).toMatchInlineSnapshot(` + " + ⛅️ wrangler x.x.x + ────────────────── + { + \\"id\\": \\"1\\", + \\"type\\": \\"default\\", + \\"created_at\\": \\"123\\", + \\"account_id\\": \\"123\\", + \\"vcpu\\": 4, + \\"memory\\": \\"400MB\\", + \\"memory_mib\\": 400, + \\"version\\": 1, + \\"image\\": \\"hello\\", + \\"location\\": { + \\"name\\": \\"sfo06\\", + \\"enabled\\": true + }, + \\"network\\": { + \\"mode\\": \\"public\\", + \\"ipv4\\": \\"1.1.1.1\\" + }, + \\"placements_ref\\": \\"http://ref\\", + \\"node_group\\": \\"metal\\" + }" + `); }); it("should modify deployment with wrangler args (detects no interactivity)", async () => { @@ -119,7 +120,32 @@ describe("cloudchamber modify", () => { "cloudchamber modify 1234 --var HELLO:WORLD --var YOU:CONQUERED --label appname:helloworld --label region:wnam" ); expect(std.err).toMatchInlineSnapshot(`""`); - expect(std.out).toMatchInlineSnapshot(EXPECTED_RESULT); + expect(std.out).toMatchInlineSnapshot(` + " + ⛅️ wrangler x.x.x + ────────────────── + { + \\"id\\": \\"1\\", + \\"type\\": \\"default\\", + \\"created_at\\": \\"123\\", + \\"account_id\\": \\"123\\", + \\"vcpu\\": 4, + \\"memory\\": \\"400MB\\", + \\"memory_mib\\": 400, + \\"version\\": 1, + \\"image\\": \\"hello\\", + \\"location\\": { + \\"name\\": \\"sfo06\\", + \\"enabled\\": true + }, + \\"network\\": { + \\"mode\\": \\"public\\", + \\"ipv4\\": \\"1.1.1.1\\" + }, + \\"placements_ref\\": \\"http://ref\\", + \\"node_group\\": \\"metal\\" + }" + `); }); it("can't modify deployment due to lack of deploymentId (json)", async () => { @@ -134,8 +160,11 @@ describe("cloudchamber modify", () => { // so testing the actual UI will be harder than expected // TODO: think better on how to test UI actions expect(std.out).toMatchInlineSnapshot(` - " - If you think this is a bug then please create an issue at https://github.com/cloudflare/workers-sdk/issues/new/choose" - `); + " + ⛅️ wrangler x.x.x + ────────────────── + + If you think this is a bug then please create an issue at https://github.com/cloudflare/workers-sdk/issues/new/choose" + `); }); }); diff --git a/packages/wrangler/src/__tests__/containers/delete.test.ts b/packages/wrangler/src/__tests__/containers/delete.test.ts index 2c6175d3aed9..7ec1aa9a929e 100644 --- a/packages/wrangler/src/__tests__/containers/delete.test.ts +++ b/packages/wrangler/src/__tests__/containers/delete.test.ts @@ -28,12 +28,12 @@ describe("containers delete", () => { await runWrangler("containers delete --help"); expect(std.err).toMatchInlineSnapshot(`""`); expect(std.out).toMatchInlineSnapshot(` - "wrangler containers delete ID + "wrangler containers delete - Delete a container + Delete a container [open beta] POSITIONALS - ID id of the containers to delete [string] [required] + ID ID of the container to delete [string] [required] GLOBAL FLAGS -c, --config Path to Wrangler configuration file [string] diff --git a/packages/wrangler/src/__tests__/containers/info.test.ts b/packages/wrangler/src/__tests__/containers/info.test.ts index 990993222536..e98d10c05b51 100644 --- a/packages/wrangler/src/__tests__/containers/info.test.ts +++ b/packages/wrangler/src/__tests__/containers/info.test.ts @@ -28,12 +28,12 @@ describe("containers info", () => { await runWrangler("containers info --help"); expect(std.err).toMatchInlineSnapshot(`""`); expect(std.out).toMatchInlineSnapshot(` - "wrangler containers info ID + "wrangler containers info - Get information about a specific container + Get information about a specific container [open beta] POSITIONALS - ID id of the containers to view [string] [required] + ID ID of the container to view [string] [required] GLOBAL FLAGS -c, --config Path to Wrangler configuration file [string] @@ -75,7 +75,12 @@ describe("containers info", () => { ); expect(std.err).toMatchInlineSnapshot(`""`); await runWrangler("containers info asdf"); - expect(std.out).toMatchInlineSnapshot(`"{}"`); + expect(std.out).toMatchInlineSnapshot(` + " + ⛅️ wrangler x.x.x + ────────────────── + {}" + `); }); it("should error when not given an ID", async () => { diff --git a/packages/wrangler/src/__tests__/containers/list.test.ts b/packages/wrangler/src/__tests__/containers/list.test.ts index 25e92ffe4e62..a349a89966f0 100644 --- a/packages/wrangler/src/__tests__/containers/list.test.ts +++ b/packages/wrangler/src/__tests__/containers/list.test.ts @@ -28,7 +28,7 @@ describe("containers list", () => { expect(std.out).toMatchInlineSnapshot(` "wrangler containers list - List containers + List containers [open beta] GLOBAL FLAGS -c, --config Path to Wrangler configuration file [string] @@ -58,7 +58,10 @@ describe("containers list", () => { expect(std.err).toMatchInlineSnapshot(`""`); await runWrangler("containers list"); expect(std.out).toMatchInlineSnapshot(` - "[ + " + ⛅️ wrangler x.x.x + ────────────────── + [ { \\"id\\": \\"asdf-2\\", \\"created_at\\": \\"123\\", diff --git a/packages/wrangler/src/__tests__/containers/push.test.ts b/packages/wrangler/src/__tests__/containers/push.test.ts index dbe63ddc6abd..2719c45d2e39 100644 --- a/packages/wrangler/src/__tests__/containers/push.test.ts +++ b/packages/wrangler/src/__tests__/containers/push.test.ts @@ -36,12 +36,12 @@ describe("containers push", () => { await runWrangler("containers push --help"); expect(std.err).toMatchInlineSnapshot(`""`); expect(std.out).toMatchInlineSnapshot(` - "wrangler containers push TAG + "wrangler containers push - Push a tagged image to a Cloudflare managed registry + Push a local image to the Cloudflare managed registry [open beta] POSITIONALS - TAG [string] [required] + TAG The tag of the local image to push [string] [required] GLOBAL FLAGS -c, --config Path to Wrangler configuration file [string] diff --git a/packages/wrangler/src/__tests__/containers/registries.test.ts b/packages/wrangler/src/__tests__/containers/registries.test.ts index 6711eb585d7b..dd624803eb1b 100644 --- a/packages/wrangler/src/__tests__/containers/registries.test.ts +++ b/packages/wrangler/src/__tests__/containers/registries.test.ts @@ -37,12 +37,12 @@ describe("containers registries configure", () => { 📦 Manage Containers [open beta] COMMANDS - wrangler containers build PATH Build a container image - wrangler containers push TAG Push a tagged image to a Cloudflare managed registry - wrangler containers images Perform operations on images in your Cloudflare managed registry - wrangler containers info ID Get information about a specific container - wrangler containers list List containers - wrangler containers delete ID Delete a container + wrangler containers list List containers [open beta] + wrangler containers info Get information about a specific container [open beta] + wrangler containers delete Delete a container [open beta] + wrangler containers build Build a container image [open beta] + wrangler containers push Push a local image to the Cloudflare managed registry [open beta] + wrangler containers images Manage images in the Cloudflare managed registry [open beta] GLOBAL FLAGS -c, --config Path to Wrangler configuration file [string] @@ -434,7 +434,10 @@ describe("containers registries list", () => { mockListRegistries(mockRegistries); await runWrangler("containers registries list --json"); expect(std.out).toMatchInlineSnapshot(` - "[ + " + ⛅️ wrangler x.x.x + ────────────────── + [ { \\"domain\\": \\"123456789012.dkr.ecr.us-west-2.amazonaws.com\\" } @@ -501,7 +504,10 @@ describe("containers registries delete", () => { " `); expect(std.out).toMatchInlineSnapshot(` - "? Are you sure you want to delete the registry credentials for 123456789012.dkr.ecr.us-west-2.amazonaws.com? This action cannot be undone. + " + ⛅️ wrangler x.x.x + ────────────────── + ? Are you sure you want to delete the registry credentials for 123456789012.dkr.ecr.us-west-2.amazonaws.com? This action cannot be undone. 🤖 Using fallback value in non-interactive context: yes" `); }); diff --git a/packages/wrangler/src/__tests__/containers/ssh.test.ts b/packages/wrangler/src/__tests__/containers/ssh.test.ts index 8b069a35ed9a..b4d8441d358d 100644 --- a/packages/wrangler/src/__tests__/containers/ssh.test.ts +++ b/packages/wrangler/src/__tests__/containers/ssh.test.ts @@ -24,7 +24,7 @@ describe("containers ssh", () => { await runWrangler("containers ssh --help"); expect(std.err).toMatchInlineSnapshot(`""`); expect(std.out).toMatchInlineSnapshot(` - "wrangler containers ssh ID + "wrangler containers ssh POSITIONALS ID ID of the container instance [string] [required] @@ -40,7 +40,7 @@ describe("containers ssh", () => { OPTIONS --cipher Sets \`ssh -c\`: Select the cipher specification for encrypting the session [string] --log-file Sets \`ssh -E\`: Append debug logs to log_file instead of standard error [string] - --escape-char Sets \`ssh -e\`: Set the escape character for sessions with a pty (default: ‘~’) [string] + --escape-char Sets \`ssh -e\`: Set the escape character for sessions with a pty (default: '~') [string] -F, --config-file Sets \`ssh -F\`: Specify an alternative per-user ssh configuration file [string] --pkcs11 Sets \`ssh -I\`: Specify the PKCS#11 shared library ssh should use to communicate with a PKCS#11 token providing keys for user authentication [string] -i, --identity-file Sets \`ssh -i\`: Select a file from which the identity (private key) for public key authentication is read [string] diff --git a/packages/wrangler/src/cloudchamber/apply.ts b/packages/wrangler/src/cloudchamber/apply.ts index 132b805a15be..0a64cb07e7b3 100644 --- a/packages/wrangler/src/cloudchamber/apply.ts +++ b/packages/wrangler/src/cloudchamber/apply.ts @@ -29,13 +29,18 @@ import { UserError, } from "@cloudflare/workers-utils"; import { configRolloutStepsToAPI } from "../containers/deploy"; +import { createCommand } from "../core/create-command"; import { getAccountId } from "../user"; import { Diff } from "../utils/diff"; import { sortObjectRecursive, stripUndefined, } from "../utils/sortObjectRecursive"; -import { promiseSpinner } from "./common"; +import { + cloudchamberScope, + fillOpenAPIConfiguration, + promiseSpinner, +} from "./common"; import { cleanForInstanceType } from "./instance-type/instance-type"; import type { CommonYargsArgv, @@ -667,3 +672,25 @@ export async function applyCommand( config ); } + +// --- New defineCommand-based command --- + +export const cloudchamberApplyCommand = createCommand({ + metadata: { + description: "Apply the changes in the container applications to deploy", + status: "alpha", + owner: "Product: Cloudchamber", + }, + args: { + "skip-defaults": { + requiresArg: true, + type: "boolean", + demandOption: false, + describe: "Skips recommended defaults added by apply", + }, + }, + async handler(args, { config }) { + await fillOpenAPIConfiguration(config, cloudchamberScope); + await applyCommand(args, config); + }, +}); diff --git a/packages/wrangler/src/cloudchamber/build.ts b/packages/wrangler/src/cloudchamber/build.ts index b55569d491e3..0668726937af 100644 --- a/packages/wrangler/src/cloudchamber/build.ts +++ b/packages/wrangler/src/cloudchamber/build.ts @@ -16,8 +16,10 @@ import { getDockerPath, UserError, } from "@cloudflare/workers-utils"; +import { createCommand } from "../core/create-command"; import { logger } from "../logger"; import { getAccountId } from "../user"; +import { cloudchamberScope, fillOpenAPIConfiguration } from "./common"; import { ensureContainerLimits } from "./limits"; import { loadAccount } from "./locations"; import type { @@ -327,3 +329,78 @@ async function checkImagePlatform( ); } } + +// --- New createCommand-based commands --- + +export const cloudchamberBuildCommand = createCommand({ + metadata: { + description: "Build a container image", + status: "alpha", + owner: "Product: Cloudchamber", + }, + args: { + PATH: { + type: "string", + describe: "Path for the directory containing the Dockerfile to build", + demandOption: true, + }, + tag: { + alias: "t", + type: "string", + demandOption: true, + describe: 'Name and optionally a tag (format: "name:tag")', + }, + "path-to-docker": { + type: "string", + default: "docker", + describe: "Path to your docker binary if it's not on $PATH", + demandOption: false, + }, + push: { + alias: "p", + type: "boolean", + describe: "Push the built image to Cloudflare's managed registry", + default: false, + }, + platform: { + type: "string", + default: "linux/amd64", + describe: + "Platform to build for. Defaults to the architecture support by Workers (linux/amd64)", + demandOption: false, + hidden: true, + deprecated: true, + }, + }, + positionalArgs: ["PATH"], + async handler(args, { config }) { + await fillOpenAPIConfiguration(config, cloudchamberScope); + await buildCommand(args); + }, +}); + +export const cloudchamberPushCommand = createCommand({ + metadata: { + description: "Push a local image to the Cloudflare managed registry", + status: "alpha", + owner: "Product: Cloudchamber", + }, + args: { + TAG: { + type: "string", + demandOption: true, + describe: "The tag of the local image to push", + }, + "path-to-docker": { + type: "string", + default: "docker", + describe: "Path to your docker binary if it's not on $PATH", + demandOption: false, + }, + }, + positionalArgs: ["TAG"], + async handler(args, { config }) { + await fillOpenAPIConfiguration(config, cloudchamberScope); + await pushCommand(args, config); + }, +}); diff --git a/packages/wrangler/src/cloudchamber/create.ts b/packages/wrangler/src/cloudchamber/create.ts index 0b14cfaf323c..3f5070c1c5e9 100644 --- a/packages/wrangler/src/cloudchamber/create.ts +++ b/packages/wrangler/src/cloudchamber/create.ts @@ -14,14 +14,17 @@ import { DeploymentsService, } from "@cloudflare/containers-shared"; import { parseByteSize } from "@cloudflare/workers-utils"; +import { createCommand as defineCommand } from "../core/create-command"; import { isNonInteractiveOrCI } from "../is-interactive"; import { logger } from "../logger"; import { pollSSHKeysUntilCondition, waitForPlacement } from "./cli"; import { getLocation } from "./cli/locations"; import { checkEverythingIsSet, + cloudchamberScope, collectEnvironmentVariables, collectLabels, + fillOpenAPIConfiguration, parseImageName, promptForEnvironmentVariables, promptForLabels, @@ -386,3 +389,93 @@ async function handleCreateCommand( } const whichImageQuestion = "Which image should we use for your container?"; + +// --- New defineCommand-based command --- + +export const cloudchamberCreateCommand = defineCommand({ + metadata: { + description: "Create a new deployment", + status: "alpha", + owner: "Product: Cloudchamber", + }, + args: { + image: { + requiresArg: true, + type: "string", + demandOption: false, + describe: "Image to use for your deployment", + }, + location: { + requiresArg: true, + type: "string", + demandOption: false, + describe: + "Location on Cloudflare's network where your deployment will run", + }, + var: { + requiresArg: true, + type: "string", + array: true, + demandOption: false, + describe: "Container environment variables", + coerce: (arg: unknown[]) => arg.map((a) => a?.toString() ?? ""), + }, + label: { + requiresArg: true, + type: "array", + demandOption: false, + describe: "Deployment labels", + coerce: (arg: unknown[]) => arg.map((a) => a?.toString() ?? ""), + }, + "all-ssh-keys": { + requiresArg: false, + type: "boolean", + demandOption: false, + describe: + "To add all SSH keys configured on your account to be added to this deployment, set this option to true", + }, + "ssh-key-id": { + requiresArg: false, + type: "string", + array: true, + demandOption: false, + describe: "ID of the SSH key to add to the deployment", + }, + "instance-type": { + requiresArg: true, + choices: [ + "lite", + "basic", + "standard-1", + "standard-2", + "standard-3", + "standard-4", + ] as const, + demandOption: false, + describe: "Instance type to allocate to this deployment", + }, + vcpu: { + requiresArg: true, + type: "number", + demandOption: false, + describe: "Number of vCPUs to allocate to this deployment.", + }, + memory: { + requiresArg: true, + type: "string", + demandOption: false, + describe: + "Amount of memory (GiB, MiB...) to allocate to this deployment. Ex: 4GiB.", + }, + ipv4: { + requiresArg: false, + type: "boolean", + demandOption: false, + describe: "Include an IPv4 in the deployment", + }, + }, + async handler(args, { config }) { + await fillOpenAPIConfiguration(config, cloudchamberScope); + await createCommand(args, config); + }, +}); diff --git a/packages/wrangler/src/cloudchamber/curl.ts b/packages/wrangler/src/cloudchamber/curl.ts index 1509d5c38cc3..11fca24122b5 100644 --- a/packages/wrangler/src/cloudchamber/curl.ts +++ b/packages/wrangler/src/cloudchamber/curl.ts @@ -3,7 +3,9 @@ import { logRaw } from "@cloudflare/cli"; import { bold, brandColor, cyanBright, yellow } from "@cloudflare/cli/colors"; import { ApiError, OpenAPI } from "@cloudflare/containers-shared"; import { request } from "@cloudflare/containers-shared/src/client/core/request"; +import { createCommand } from "../core/create-command"; import formatLabelledValues from "../utils/render-labelled-values"; +import { cloudchamberScope, fillOpenAPIConfiguration } from "./common"; import type { CommonYargsOptions, StrictYargsOptionsToInterface, @@ -163,3 +165,60 @@ async function requestFromCmd( } } } + +// --- New defineCommand-based command --- + +export const cloudchamberCurlCommand = createCommand({ + metadata: { + description: "Send a request to an arbitrary Cloudchamber endpoint", + status: "alpha", + owner: "Product: Cloudchamber", + }, + args: { + path: { + type: "string", + default: "/", + demandOption: true, + }, + header: { + type: "array", + alias: "H", + describe: "Add headers in the form of --header :", + }, + data: { + type: "string", + describe: "Add a JSON body to the request", + alias: "d", + }, + "data-deprecated": { + type: "string", + hidden: true, + alias: "D", + }, + method: { + type: "string", + alias: "X", + default: "GET", + }, + silent: { + describe: "Only output response", + type: "boolean", + alias: "s", + }, + verbose: { + describe: "Print everything, like request id, or headers", + type: "boolean", + alias: "v", + }, + "use-stdin": { + describe: "Equivalent of using --data-binary @- in curl", + type: "boolean", + alias: "stdin", + }, + }, + positionalArgs: ["path"], + async handler(args, { config }) { + await fillOpenAPIConfiguration(config, cloudchamberScope); + await curlCommand(args, config); + }, +}); diff --git a/packages/wrangler/src/cloudchamber/delete.ts b/packages/wrangler/src/cloudchamber/delete.ts index e18430d4e893..d4319ad667df 100644 --- a/packages/wrangler/src/cloudchamber/delete.ts +++ b/packages/wrangler/src/cloudchamber/delete.ts @@ -2,9 +2,11 @@ import { cancel, endSection, startSection } from "@cloudflare/cli"; import { inputPrompt } from "@cloudflare/cli/interactive"; import { DeploymentsService } from "@cloudflare/containers-shared"; import { UserError } from "@cloudflare/workers-utils"; +import { createCommand } from "../core/create-command"; import { isNonInteractiveOrCI } from "../is-interactive"; import { logger } from "../logger"; import { logDeployment, pickDeployment } from "./cli/deployments"; +import { cloudchamberScope, fillOpenAPIConfiguration } from "./common"; import { wrap } from "./helpers/wrap"; import type { CommonYargsArgv, @@ -68,3 +70,26 @@ async function handleDeleteCommand( } endSection("Your container has been deleted"); } + +// --- New defineCommand-based command --- + +export const cloudchamberDeleteCommand = createCommand({ + metadata: { + description: + "Delete an existing deployment that is running in the Cloudflare edge", + status: "alpha", + owner: "Product: Cloudchamber", + }, + args: { + deploymentId: { + type: "string", + demandOption: false, + describe: "Deployment you want to delete", + }, + }, + positionalArgs: ["deploymentId"], + async handler(args, { config }) { + await fillOpenAPIConfiguration(config, cloudchamberScope); + await deleteCommand(args, config); + }, +}); diff --git a/packages/wrangler/src/cloudchamber/images/images.ts b/packages/wrangler/src/cloudchamber/images/images.ts index a67361ab4b8e..bbc3412e1789 100644 --- a/packages/wrangler/src/cloudchamber/images/images.ts +++ b/packages/wrangler/src/cloudchamber/images/images.ts @@ -3,16 +3,21 @@ import { ImageRegistriesService, } from "@cloudflare/containers-shared"; import { fetch } from "undici"; +import { createCommand, createNamespace } from "../../core/create-command"; import { logger } from "../../logger"; import { getAccountId } from "../../user"; -import { handleFailure, promiseSpinner } from "../common"; +import { + cloudchamberScope, + fillOpenAPIConfiguration, + handleFailure, + promiseSpinner, +} from "../common"; import type { containersScope } from "../../containers"; import type { CommonYargsArgv, CommonYargsArgvSanitized, StrictYargsOptionsToInterface, } from "../../yargs-types"; -import type { cloudchamberScope } from "../common"; import type { ImageRegistryPermissions } from "@cloudflare/containers-shared"; import type { Config } from "@cloudflare/workers-utils"; @@ -267,3 +272,56 @@ async function getCreds(): Promise { return Buffer.from(`v1:${credentials.password}`).toString("base64"); } + +// --- New createCommand-based commands --- + +export const cloudchamberImagesNamespace = createNamespace({ + metadata: { + description: "Manage images in the Cloudflare managed registry", + status: "alpha", + owner: "Product: Cloudchamber", + }, +}); + +export const cloudchamberImagesListCommand = createCommand({ + metadata: { + description: "List images in the Cloudflare managed registry", + status: "alpha", + owner: "Product: Cloudchamber", + }, + args: { + filter: { + type: "string", + description: "Regex to filter results", + }, + json: { + type: "boolean", + description: "Format output as JSON", + default: false, + }, + }, + async handler(args, { config }) { + await fillOpenAPIConfiguration(config, cloudchamberScope); + await handleListImagesCommand(args, config); + }, +}); + +export const cloudchamberImagesDeleteCommand = createCommand({ + metadata: { + description: "Remove an image from the Cloudflare managed registry", + status: "alpha", + owner: "Product: Cloudchamber", + }, + args: { + image: { + type: "string", + description: "Image and tag to delete, of the form IMAGE:TAG", + demandOption: true, + }, + }, + positionalArgs: ["image"], + async handler(args, { config }) { + await fillOpenAPIConfiguration(config, cloudchamberScope); + await handleDeleteImageCommand(args, config); + }, +}); diff --git a/packages/wrangler/src/cloudchamber/images/registries.ts b/packages/wrangler/src/cloudchamber/images/registries.ts index 1eddc9700bfd..11d139a4e45b 100644 --- a/packages/wrangler/src/cloudchamber/images/registries.ts +++ b/packages/wrangler/src/cloudchamber/images/registries.ts @@ -13,22 +13,26 @@ import { ImageRegistryNotAllowedError, } from "@cloudflare/containers-shared"; import { UserError } from "@cloudflare/workers-utils"; +import { createCommand, createNamespace } from "../../core/create-command"; import { isNonInteractiveOrCI } from "../../is-interactive"; import { logger } from "../../logger"; import { pollRegistriesUntilCondition } from "../cli"; -import { checkEverythingIsSet, handleFailure, promiseSpinner } from "../common"; +import { + checkEverythingIsSet, + cloudchamberScope, + fillOpenAPIConfiguration, + promiseSpinner, +} from "../common"; import { wrap } from "../helpers/wrap"; -import type { containersScope } from "../../containers"; import type { CommonYargsArgv, CommonYargsArgvSanitized, StrictYargsOptionsToInterface, } from "../../yargs-types"; -import type { cloudchamberScope } from "../common"; import type { ImageRegistryPermissions } from "@cloudflare/containers-shared"; import type { Config } from "@cloudflare/workers-utils"; -function configureImageRegistryOptionalYargs(yargs: CommonYargsArgv) { +function _configureImageRegistryOptionalYargs(yargs: CommonYargsArgv) { return yargs .option("domain", { description: @@ -42,140 +46,74 @@ function configureImageRegistryOptionalYargs(yargs: CommonYargsArgv) { }); } -export const registriesCommand = ( - yargs: CommonYargsArgv, - scope: typeof containersScope | typeof cloudchamberScope -) => { - return yargs - .command( - "configure", - "Configure Cloudchamber to pull from specific registries", - (args) => configureImageRegistryOptionalYargs(args), - (args) => - handleFailure( - `wrangler cloudchamber registries configure`, - async ( - imageArgs: StrictYargsOptionsToInterface< - typeof configureImageRegistryOptionalYargs - >, - config - ) => { - // check we are in CI or if the user wants to just use JSON - if (isNonInteractiveOrCI()) { - const body = checkEverythingIsSet(imageArgs, [ - "domain", - "public", - ]); - const registry = await ImageRegistriesService.createImageRegistry( - { - domain: body.domain, - is_public: body.public, - } - ); - logger.log(JSON.stringify(registry, null, 4)); - return; - } +async function registriesConfigureHandler( + imageArgs: StrictYargsOptionsToInterface< + typeof _configureImageRegistryOptionalYargs + >, + config: Config +) { + // check we are in CI or if the user wants to just use JSON + if (isNonInteractiveOrCI()) { + const body = checkEverythingIsSet(imageArgs, ["domain", "public"]); + const registry = await ImageRegistriesService.createImageRegistry({ + domain: body.domain, + is_public: body.public, + }); + logger.log(JSON.stringify(registry, null, 4)); + return; + } - await handleConfigureImageRegistryCommand(args, config); - }, - scope - )(args) - ) - .command( - "credentials [domain]", - "get a temporary password for a specific domain", - (args) => - args - .positional("domain", { - type: "string", - demandOption: true, - }) - .option("expiration-minutes", { - type: "number", - default: 15, - }) - .option("push", { - type: "boolean", - description: "If you want these credentials to be able to push", - }) - .option("pull", { - type: "boolean", - description: "If you want these credentials to be able to pull", - }), - (args) => { - // we don't want any kind of spinners - args.json = true; - return handleFailure( - `wrangler cloudchamber registries credentials`, - async (imageArgs: typeof args, _config) => { - if (!imageArgs.pull && !imageArgs.push) { - throw new UserError( - "You have to specify either --push or --pull in the command." - ); - } + await handleConfigureImageRegistryCommand(imageArgs, config); +} - const credentials = - await ImageRegistriesService.generateImageRegistryCredentials( - imageArgs.domain, - { - expiration_minutes: imageArgs.expirationMinutes, - permissions: [ - ...(imageArgs.push ? ["push"] : []), - ...(imageArgs.pull ? ["pull"] : []), - ] as ImageRegistryPermissions[], - } - ); - logger.log(credentials.password); - }, - scope - )(args); - } - ) - .command( - "remove [domain]", - "removes the registry at the given domain", - (args) => removeImageRegistryYargs(args), - (args) => { - args.json = true; - return handleFailure( - `wrangler cloudchamber registries remove`, - async ( - imageArgs: StrictYargsOptionsToInterface< - typeof removeImageRegistryYargs - >, - _config - ) => { - const registry = await ImageRegistriesService.deleteImageRegistry( - imageArgs.domain - ); - logger.log(JSON.stringify(registry, null, 4)); - }, - scope - )(args); +async function registriesCredentialsHandler(imageArgs: { + domain: string; + expirationMinutes: number; + push?: boolean; + pull?: boolean; +}) { + if (!imageArgs.pull && !imageArgs.push) { + throw new UserError( + "You have to specify either --push or --pull in the command." + ); + } + + const credentials = + await ImageRegistriesService.generateImageRegistryCredentials( + imageArgs.domain, + { + expiration_minutes: imageArgs.expirationMinutes, + permissions: [ + ...(imageArgs.push ? ["push"] : []), + ...(imageArgs.pull ? ["pull"] : []), + ] as ImageRegistryPermissions[], } - ) - .command( - "list", - "list registries configured for this account", - (args) => args, - (args) => - handleFailure( - `wrangler cloudchamber registries list`, - async (_: CommonYargsArgvSanitized, config) => { - if (isNonInteractiveOrCI()) { - const registries = - await ImageRegistriesService.listImageRegistries(); - logger.log(JSON.stringify(registries, null, 4)); - return; - } - await handleListImageRegistriesCommand(args, config); - }, - scope - )(args) ); -}; + logger.log(credentials.password); +} + +async function registriesRemoveHandler( + imageArgs: StrictYargsOptionsToInterface +) { + const registry = await ImageRegistriesService.deleteImageRegistry( + imageArgs.domain + ); + logger.log(JSON.stringify(registry, null, 4)); +} -function removeImageRegistryYargs(yargs: CommonYargsArgv) { +async function registriesListHandler( + _args: CommonYargsArgvSanitized, + config: Config +) { + if (isNonInteractiveOrCI()) { + const registries = await ImageRegistriesService.listImageRegistries(); + logger.log(JSON.stringify(registries, null, 4)); + return; + } + await handleListImageRegistriesCommand(_args, config); +} + +function _removeImageRegistryYargs(yargs: CommonYargsArgv) { return yargs.positional("domain", { type: "string", demandOption: true, @@ -219,7 +157,7 @@ async function handleListImageRegistriesCommand( async function handleConfigureImageRegistryCommand( args: StrictYargsOptionsToInterface< - typeof configureImageRegistryOptionalYargs + typeof _configureImageRegistryOptionalYargs >, _config: Config ) { @@ -281,3 +219,100 @@ async function handleConfigureImageRegistryCommand( registry?.public_key ); } + +// --- New defineCommand-based commands --- + +export const cloudchamberRegistriesNamespace = createNamespace({ + metadata: { + description: "Configure registries via Cloudchamber", + status: "alpha", + owner: "Product: Cloudchamber", + }, +}); + +export const cloudchamberRegistriesConfigureCommand = createCommand({ + metadata: { + description: "Configure Cloudchamber to pull from specific registries", + status: "alpha", + owner: "Product: Cloudchamber", + }, + args: { + domain: { + description: + "Domain of your registry. Don't include the proto part of the URL, like 'http://'", + type: "string", + }, + public: { + description: + "If the registry is public and you don't want credentials configured, set this to true", + type: "boolean", + }, + }, + async handler(args, { config }) { + await fillOpenAPIConfiguration(config, cloudchamberScope); + await registriesConfigureHandler(args, config); + }, +}); + +export const cloudchamberRegistriesCredentialsCommand = createCommand({ + metadata: { + description: "Get a temporary password for a specific domain", + status: "alpha", + owner: "Product: Cloudchamber", + }, + args: { + domain: { + type: "string", + demandOption: true, + }, + "expiration-minutes": { + type: "number", + default: 15, + }, + push: { + type: "boolean", + description: "If you want these credentials to be able to push", + }, + pull: { + type: "boolean", + description: "If you want these credentials to be able to pull", + }, + }, + positionalArgs: ["domain"], + async handler(args, { config }) { + await fillOpenAPIConfiguration(config, cloudchamberScope); + await registriesCredentialsHandler(args); + }, +}); + +export const cloudchamberRegistriesRemoveCommand = createCommand({ + metadata: { + description: "Remove the registry at the given domain", + status: "alpha", + owner: "Product: Cloudchamber", + }, + args: { + domain: { + type: "string", + demandOption: true, + }, + }, + positionalArgs: ["domain"], + async handler(args, { config }) { + await fillOpenAPIConfiguration(config, cloudchamberScope); + await registriesRemoveHandler(args); + }, +}); + +export const cloudchamberRegistriesListCommand = createCommand({ + metadata: { + description: "List registries configured for this account", + status: "alpha", + owner: "Product: Cloudchamber", + }, + args: {}, + async handler(args, { config }) { + await fillOpenAPIConfiguration(config, cloudchamberScope); + await registriesListHandler(args, config); + }, +}); diff --git a/packages/wrangler/src/cloudchamber/index.ts b/packages/wrangler/src/cloudchamber/index.ts index 363e64a1af53..119a31bdb218 100644 --- a/packages/wrangler/src/cloudchamber/index.ts +++ b/packages/wrangler/src/cloudchamber/index.ts @@ -1,131 +1,45 @@ -import { applyCommand, applyCommandOptionalYargs } from "./apply"; -import { buildCommand, buildYargs, pushCommand, pushYargs } from "./build"; -import { cloudchamberScope, handleFailure } from "./common"; -import { createCommand, createCommandOptionalYargs } from "./create"; -import { curlCommand, yargsCurl } from "./curl"; -import { deleteCommand, deleteCommandOptionalYargs } from "./delete"; -import { imagesCommand } from "./images/images"; -import { registriesCommand } from "./images/registries"; -import { listCommand, listDeploymentsYargs } from "./list"; -import { modifyCommand, modifyCommandOptionalYargs } from "./modify"; -import { sshCommand } from "./ssh/ssh"; -import type { CommonYargsArgv, CommonYargsOptions } from "../yargs-types"; -import type { CommandModule } from "yargs"; +import { createNamespace } from "../core/create-command"; -function internalCommands(args: CommonYargsArgv) { - try { - // Add dynamically an internal module that we can attach internal commands - // eslint-disable-next-line @typescript-eslint/no-require-imports - const cloudchamberInternalRequireEntry = require("./internal/index"); - return cloudchamberInternalRequireEntry.internalCommands(args); - } catch { - return args; - } -} +// --- Namespace definition --- +export const cloudchamberNamespace = createNamespace({ + metadata: { + description: "Manage Cloudchamber", + status: "alpha", + owner: "Product: Cloudchamber", + hidden: true, + }, +}); -export const cloudchamber = ( - yargs: CommonYargsArgv, - subHelp: CommandModule -) => { - yargs = internalCommands(yargs); - return yargs - .command( - "delete [deploymentId]", - "Delete an existing deployment that is running in the Cloudflare edge", - (args) => deleteCommandOptionalYargs(args), - (args) => - handleFailure( - `wrangler cloudchamber delete`, - deleteCommand, - cloudchamberScope - )(args) - ) - .command( - "create", - "Create a new deployment", - (args) => createCommandOptionalYargs(args), - (args) => - handleFailure( - `wrangler cloudchamber create`, - createCommand, - cloudchamberScope - )(args) - ) - .command( - "list [deploymentIdPrefix]", - "List and view status of deployments", - (args) => listDeploymentsYargs(args), - (args) => - handleFailure( - `wrangler cloudchamber list`, - listCommand, - cloudchamberScope - )(args) - ) - .command( - "modify [deploymentId]", - "Modify an existing deployment", - (args) => modifyCommandOptionalYargs(args), - (args) => - handleFailure( - `wrangler cloudchamber modify`, - modifyCommand, - cloudchamberScope - )(args) - ) - .command("ssh", "Manage the ssh keys of your account", (args) => - sshCommand(args, cloudchamberScope).command(subHelp) - ) - .command("registries", "Configure registries via Cloudchamber", (args) => - registriesCommand(args, cloudchamberScope).command(subHelp) - ) - .command( - "curl ", - "Send a request to an arbitrary Cloudchamber endpoint", - (args) => yargsCurl(args), - (args) => - handleFailure( - `wrangler cloudchamber curl`, - curlCommand, - cloudchamberScope - )(args) - ) - .command( - "apply", - "Apply the changes in the container applications to deploy", - (args) => applyCommandOptionalYargs(args), - (args) => - handleFailure( - `wrangler cloudchamber apply`, - applyCommand, - cloudchamberScope - )(args) - ) - .command( - "build [PATH]", - "Build a container image", - (args) => buildYargs(args), - (args) => - handleFailure( - `wrangler cloudchamber build`, - buildCommand, - cloudchamberScope - )(args) - ) - .command( - "push [TAG]", - "Push a tagged image to a Cloudflare managed registry", - (args) => pushYargs(args), - (args) => - handleFailure( - `wrangler cloudchamber push`, - pushCommand, - cloudchamberScope - )(args) - ) - .command( - "images", - "Perform operations on images in your Cloudchamber registry", - (args) => imagesCommand(args, cloudchamberScope).command(subHelp) - ); -}; +// --- Re-export commands from their respective files --- +export { cloudchamberListCommand } from "./list"; +export { cloudchamberCreateCommand } from "./create"; +export { cloudchamberDeleteCommand } from "./delete"; +export { cloudchamberModifyCommand } from "./modify"; +export { cloudchamberApplyCommand } from "./apply"; +export { cloudchamberCurlCommand } from "./curl"; + +// Build and push commands +export { cloudchamberBuildCommand, cloudchamberPushCommand } from "./build"; + +// SSH subcommands +export { + cloudchamberSshNamespace, + cloudchamberSshListCommand, + cloudchamberSshCreateCommand, +} from "./ssh/ssh"; + +// Registries subcommands +export { + cloudchamberRegistriesNamespace, + cloudchamberRegistriesConfigureCommand, + cloudchamberRegistriesCredentialsCommand, + cloudchamberRegistriesRemoveCommand, + cloudchamberRegistriesListCommand, +} from "./images/registries"; + +// Images subcommands +export { + cloudchamberImagesNamespace, + cloudchamberImagesListCommand, + cloudchamberImagesDeleteCommand, +} from "./images/images"; diff --git a/packages/wrangler/src/cloudchamber/list.ts b/packages/wrangler/src/cloudchamber/list.ts index 95eabdf1853c..1fb3dd0874fd 100644 --- a/packages/wrangler/src/cloudchamber/list.ts +++ b/packages/wrangler/src/cloudchamber/list.ts @@ -14,12 +14,17 @@ import { DeploymentsService, PlacementsService, } from "@cloudflare/containers-shared"; +import { createCommand } from "../core/create-command"; import { isNonInteractiveOrCI } from "../is-interactive"; import { logger } from "../logger"; import { capitalize } from "../utils/strings"; import { listDeploymentsAndChoose, loadDeployments } from "./cli/deployments"; import { statusToColored } from "./cli/util"; -import { promiseSpinner } from "./common"; +import { + cloudchamberScope, + fillOpenAPIConfiguration, + promiseSpinner, +} from "./common"; import type { CommonYargsArgv, StrictYargsOptionsToInterface, @@ -187,3 +192,56 @@ const listCommandHandle = async ( stop(); } }; + +// --- New defineCommand-based command --- + +export const cloudchamberListCommand = createCommand({ + metadata: { + description: "List and view status of deployments", + status: "alpha", + owner: "Product: Cloudchamber", + }, + args: { + deploymentIdPrefix: { + describe: + "Optional deploymentId to filter deployments. This means that 'list' will only showcase deployments that contain this ID prefix", + type: "string", + }, + location: { + requiresArg: true, + type: "string", + demandOption: false, + describe: "Filter deployments by location", + }, + image: { + requiresArg: true, + type: "string", + demandOption: false, + describe: "Filter deployments by image", + }, + state: { + requiresArg: true, + type: "string", + demandOption: false, + describe: "Filter deployments by deployment state", + }, + ipv4: { + requiresArg: true, + type: "string", + demandOption: false, + describe: "Filter deployments by ipv4 address", + }, + label: { + requiresArg: true, + type: "array", + demandOption: false, + describe: "Filter deployments by labels", + coerce: (arg: unknown[]) => arg.map((a) => a?.toString() ?? ""), + }, + }, + positionalArgs: ["deploymentIdPrefix"], + async handler(args, { config }) { + await fillOpenAPIConfiguration(config, cloudchamberScope); + await listCommand(args, config); + }, +}); diff --git a/packages/wrangler/src/cloudchamber/modify.ts b/packages/wrangler/src/cloudchamber/modify.ts index 34843d9f4210..820f9cfb3edf 100644 --- a/packages/wrangler/src/cloudchamber/modify.ts +++ b/packages/wrangler/src/cloudchamber/modify.ts @@ -2,14 +2,17 @@ import { cancel, startSection } from "@cloudflare/cli"; import { processArgument } from "@cloudflare/cli/args"; import { inputPrompt, spinner } from "@cloudflare/cli/interactive"; import { DeploymentsService } from "@cloudflare/containers-shared"; +import { createCommand } from "../core/create-command"; import { isNonInteractiveOrCI } from "../is-interactive"; import { logger } from "../logger"; import { pollSSHKeysUntilCondition, waitForPlacement } from "./cli"; import { pickDeployment } from "./cli/deployments"; import { getLocation } from "./cli/locations"; import { + cloudchamberScope, collectEnvironmentVariables, collectLabels, + fillOpenAPIConfiguration, parseImageName, promptForEnvironmentVariables, promptForLabels, @@ -308,3 +311,78 @@ async function handleModifyCommand( } const modifyImageQuestion = "URL of the image to use in your deployment"; + +// --- New defineCommand-based command --- + +export const cloudchamberModifyCommand = createCommand({ + metadata: { + description: "Modify an existing deployment", + status: "alpha", + owner: "Product: Cloudchamber", + }, + args: { + deploymentId: { + type: "string", + demandOption: false, + describe: "The deployment you want to modify", + }, + var: { + requiresArg: true, + type: "array", + demandOption: false, + describe: "Container environment variables", + coerce: (arg: unknown[]) => arg.map((a) => a?.toString() ?? ""), + }, + label: { + requiresArg: true, + type: "array", + demandOption: false, + describe: "Deployment labels", + coerce: (arg: unknown[]) => arg.map((a) => a?.toString() ?? ""), + }, + "ssh-public-key-id": { + requiresArg: true, + type: "string", + array: true, + demandOption: false, + describe: + "Public SSH key IDs to include in this container. You can add one to your account with `wrangler cloudchamber ssh create", + }, + image: { + requiresArg: true, + type: "string", + demandOption: false, + describe: "The new image that the deployment will have from now on", + }, + location: { + requiresArg: true, + type: "string", + demandOption: false, + describe: "The new location that the deployment will have from now on", + }, + "instance-type": { + requiresArg: true, + choices: ["dev", "basic", "standard"] as const, + demandOption: false, + describe: + "The new instance type that the deployment will have from now on", + }, + vcpu: { + requiresArg: true, + type: "number", + demandOption: false, + describe: "The new vcpu that the deployment will have from now on", + }, + memory: { + requiresArg: true, + type: "string", + demandOption: false, + describe: "The new memory that the deployment will have from now on", + }, + }, + positionalArgs: ["deploymentId"], + async handler(args, { config }) { + await fillOpenAPIConfiguration(config, cloudchamberScope); + await modifyCommand(args, config); + }, +}); diff --git a/packages/wrangler/src/cloudchamber/ssh/ssh.ts b/packages/wrangler/src/cloudchamber/ssh/ssh.ts index 1585ecee08b4..cd9f6a535b38 100644 --- a/packages/wrangler/src/cloudchamber/ssh/ssh.ts +++ b/packages/wrangler/src/cloudchamber/ssh/ssh.ts @@ -14,19 +14,22 @@ import { brandColor, dim } from "@cloudflare/cli/colors"; import { inputPrompt, spinner } from "@cloudflare/cli/interactive"; import { SshPublicKeysService } from "@cloudflare/containers-shared"; import { UserError } from "@cloudflare/workers-utils"; +import { createCommand, createNamespace } from "../../core/create-command"; import { isNonInteractiveOrCI } from "../../is-interactive"; import { logger } from "../../logger"; import { pollSSHKeysUntilCondition } from "../cli"; -import { checkEverythingIsSet, handleFailure } from "../common"; +import { + checkEverythingIsSet, + cloudchamberScope, + fillOpenAPIConfiguration, +} from "../common"; import { wrap } from "../helpers/wrap"; import { validatePublicSSHKeyCLI, validateSSHKey } from "./validate"; -import type { containersScope } from "../../containers"; import type { CommonYargsArgv, CommonYargsArgvSanitized, StrictYargsOptionsToInterface, } from "../../yargs-types"; -import type { cloudchamberScope } from "../common"; import type { ListSSHPublicKeys, SSHPublicKeyID, @@ -34,7 +37,7 @@ import type { } from "@cloudflare/containers-shared"; import type { Config } from "@cloudflare/workers-utils"; -function createSSHPublicKeyOptionalYargs(yargs: CommonYargsArgv) { +function _createSSHPublicKeyOptionalYargs(yargs: CommonYargsArgv) { return yargs .option("name", { type: "string", @@ -105,66 +108,41 @@ export async function sshPrompts( return key || undefined; } -export const sshCommand = ( - yargs: CommonYargsArgv, - scope: typeof cloudchamberScope | typeof containersScope -) => { - return yargs - .command( - "list", - "list the ssh keys added to your account", - (args) => args, - (args) => - handleFailure( - `wrangler cloudchamber ssh list`, - async (sshArgs: CommonYargsArgvSanitized, config) => { - // check we are in CI or if the user wants to just use JSON - if (isNonInteractiveOrCI()) { - const sshKeys = await SshPublicKeysService.listSshPublicKeys(); - logger.json(sshKeys); - return; - } - - await handleListSSHKeysCommand(sshArgs, config); - }, - scope - )(args) - ) - .command( - "create", - "create an ssh key", - (args) => createSSHPublicKeyOptionalYargs(args), - (args) => - handleFailure( - `wrangler cloudchamber ssh create`, - async ( - sshArgs: StrictYargsOptionsToInterface< - typeof createSSHPublicKeyOptionalYargs - >, - _config - ) => { - // check we are in CI or if the user wants to just use JSON - if (isNonInteractiveOrCI()) { - const body = checkEverythingIsSet(sshArgs, ["publicKey", "name"]); - const sshKey = await retrieveSSHKey(body.publicKey, { - json: true, - }); - const addedSSHKey = await SshPublicKeysService.createSshPublicKey( - { - ...body, - public_key: sshKey.trim(), - } - ); - logger.json(addedSSHKey); - return; - } - - await handleCreateSSHPublicKeyCommand(sshArgs); - }, - scope - )(args) - ); -}; +async function sshListHandler( + sshArgs: CommonYargsArgvSanitized, + config: Config +) { + // check we are in CI or if the user wants to just use JSON + if (isNonInteractiveOrCI()) { + const sshKeys = await SshPublicKeysService.listSshPublicKeys(); + logger.json(sshKeys); + return; + } + + await handleListSSHKeysCommand(sshArgs, config); +} + +async function sshCreateHandler( + sshArgs: StrictYargsOptionsToInterface< + typeof _createSSHPublicKeyOptionalYargs + > +) { + // check we are in CI or if the user wants to just use JSON + if (isNonInteractiveOrCI()) { + const body = checkEverythingIsSet(sshArgs, ["publicKey", "name"]); + const sshKey = await retrieveSSHKey(body.publicKey, { + json: true, + }); + const addedSSHKey = await SshPublicKeysService.createSshPublicKey({ + ...body, + public_key: sshKey.trim(), + }); + logger.json(addedSSHKey); + return; + } + + await handleCreateSSHPublicKeyCommand(sshArgs); +} async function tryToRetrieveAllDefaultSSHKeyPaths(): Promise { const HOME = homedir(); @@ -305,7 +283,7 @@ async function handleListSSHKeysCommand(_args: unknown, _config: Config) { * */ async function handleCreateSSHPublicKeyCommand( - args: StrictYargsOptionsToInterface + args: StrictYargsOptionsToInterface ) { startSection( "Choose an ssh key to add", @@ -324,7 +302,7 @@ async function handleCreateSSHPublicKeyCommand( } async function promptForSSHKey( - args: StrictYargsOptionsToInterface + args: StrictYargsOptionsToInterface ): Promise { const { username } = userInfo(); const name = await inputPrompt({ @@ -390,3 +368,50 @@ async function promptForSSHKey( return res; } + +// --- New defineCommand-based commands --- + +export const cloudchamberSshNamespace = createNamespace({ + metadata: { + description: "Manage the ssh keys of your account", + status: "alpha", + owner: "Product: Cloudchamber", + }, +}); + +export const cloudchamberSshListCommand = createCommand({ + metadata: { + description: "List the ssh keys added to your account", + status: "alpha", + owner: "Product: Cloudchamber", + }, + args: {}, + async handler(args, { config }) { + await fillOpenAPIConfiguration(config, cloudchamberScope); + await sshListHandler(args, config); + }, +}); + +export const cloudchamberSshCreateCommand = createCommand({ + metadata: { + description: "Create an ssh key", + status: "alpha", + owner: "Product: Cloudchamber", + }, + args: { + name: { + type: "string", + describe: + "The alias to your ssh key, you can put a recognisable name for you here", + }, + "public-key": { + type: "string", + describe: + "An SSH public key, you can specify either a path or the ssh key directly here", + }, + }, + async handler(args, { config }) { + await fillOpenAPIConfiguration(config, cloudchamberScope); + await sshCreateHandler(args); + }, +}); diff --git a/packages/wrangler/src/containers/build.ts b/packages/wrangler/src/containers/build.ts index bf62d9d1a96b..2314c20e8112 100644 --- a/packages/wrangler/src/containers/build.ts +++ b/packages/wrangler/src/containers/build.ts @@ -1,11 +1,95 @@ -import { buildAndMaybePush } from "../cloudchamber/build"; +import { + buildAndMaybePush, + buildCommand, + pushCommand, +} from "../cloudchamber/build"; +import { fillOpenAPIConfiguration } from "../cloudchamber/common"; +import { createCommand } from "../core/create-command"; import { logger } from "../logger"; +import { containersScope } from "."; import type { ImageRef } from "../cloudchamber/build"; import type { ContainerNormalizedConfig, ImageURIConfig, } from "@cloudflare/containers-shared"; +// --- Command definitions --- + +export const containersBuildCommand = createCommand({ + metadata: { + description: "Build a container image", + status: "open beta", + owner: "Product: Cloudchamber", + }, + args: { + PATH: { + type: "string", + describe: "Path for the directory containing the Dockerfile to build", + demandOption: true, + }, + tag: { + alias: "t", + type: "string", + demandOption: true, + describe: 'Name and optionally a tag (format: "name:tag")', + }, + "path-to-docker": { + type: "string", + default: "docker", + describe: "Path to your docker binary if it's not on $PATH", + demandOption: false, + }, + push: { + alias: "p", + type: "boolean", + describe: "Push the built image to Cloudflare's managed registry", + default: false, + }, + platform: { + type: "string", + default: "linux/amd64", + describe: + "Platform to build for. Defaults to the architecture support by Workers (linux/amd64)", + demandOption: false, + hidden: true, + deprecated: true, + }, + }, + positionalArgs: ["PATH"], + async handler(args, { config }) { + await fillOpenAPIConfiguration(config, containersScope); + await buildCommand(args); + }, +}); + +export const containersPushCommand = createCommand({ + metadata: { + description: "Push a local image to the Cloudflare managed registry", + status: "open beta", + owner: "Product: Cloudchamber", + }, + args: { + TAG: { + type: "string", + demandOption: true, + describe: "The tag of the local image to push", + }, + "path-to-docker": { + type: "string", + default: "docker", + describe: "Path to your docker binary if it's not on $PATH", + demandOption: false, + }, + }, + positionalArgs: ["TAG"], + async handler(args, { config }) { + await fillOpenAPIConfiguration(config, containersScope); + await pushCommand(args, config); + }, +}); + +// --- Helper functions --- + export async function buildContainer( containerConfig: Exclude, /** just the tag component. will be prefixed with the container name */ diff --git a/packages/wrangler/src/containers/containers.ts b/packages/wrangler/src/containers/containers.ts index d989d0746953..f4347b00c73c 100644 --- a/packages/wrangler/src/containers/containers.ts +++ b/packages/wrangler/src/containers/containers.ts @@ -11,9 +11,12 @@ import { inputPrompt, spinner } from "@cloudflare/cli/interactive"; import { ApiError, ApplicationsService } from "@cloudflare/containers-shared"; import { UserError } from "@cloudflare/workers-utils"; import YAML from "yaml"; +import { fillOpenAPIConfiguration } from "../cloudchamber/common"; import { wrap } from "../cloudchamber/helpers/wrap"; +import { createCommand } from "../core/create-command"; import { isNonInteractiveOrCI } from "../is-interactive"; import { logger } from "../logger"; +import { containersScope } from "./index"; import type { CommonYargsArgv, StrictYargsOptionsToInterface, @@ -247,3 +250,58 @@ async function listContainersAndChoose( // eslint-disable-next-line @typescript-eslint/no-non-null-assertion return applications.find((a) => a.id === application)!; } + +// --- New defineCommand-based commands --- + +export const containersListCommand = createCommand({ + metadata: { + description: "List containers", + status: "open beta", + owner: "Product: Cloudchamber", + }, + args: {}, + async handler(args, { config }) { + await fillOpenAPIConfiguration(config, containersScope); + await listCommand(args, config); + }, +}); + +export const containersInfoCommand = createCommand({ + metadata: { + description: "Get information about a specific container", + status: "open beta", + owner: "Product: Cloudchamber", + }, + args: { + ID: { + describe: "ID of the container to view", + type: "string", + demandOption: true, + }, + }, + positionalArgs: ["ID"], + async handler(args, { config }) { + await fillOpenAPIConfiguration(config, containersScope); + await infoCommand(args, config); + }, +}); + +export const containersDeleteCommand = createCommand({ + metadata: { + description: "Delete a container", + status: "open beta", + owner: "Product: Cloudchamber", + }, + args: { + ID: { + describe: "ID of the container to delete", + type: "string", + demandOption: true, + }, + }, + positionalArgs: ["ID"], + async handler(args, { config }) { + await fillOpenAPIConfiguration(config, containersScope); + await deleteCommand(args, config); + }, +}); diff --git a/packages/wrangler/src/containers/images.ts b/packages/wrangler/src/containers/images.ts new file mode 100644 index 000000000000..ff9bd0dc40f5 --- /dev/null +++ b/packages/wrangler/src/containers/images.ts @@ -0,0 +1,270 @@ +import { + getCloudflareContainerRegistry, + ImageRegistriesService, +} from "@cloudflare/containers-shared"; +import { fetch } from "undici"; +import { + fillOpenAPIConfiguration, + promiseSpinner, +} from "../cloudchamber/common"; +import { createCommand, createNamespace } from "../core/create-command"; +import { logger } from "../logger"; +import { getAccountId } from "../user"; +import { containersScope } from "."; +import type { ImageRegistryPermissions } from "@cloudflare/containers-shared"; +import type { Config } from "@cloudflare/workers-utils"; + +interface Repository { + name: string; + tags: string[]; +} + +// --- Namespace definition --- + +export const containersImagesNamespace = createNamespace({ + metadata: { + description: "Manage images in the Cloudflare managed registry", + status: "open beta", + owner: "Product: Cloudchamber", + }, +}); + +// --- Command definitions --- + +export const containersImagesListCommand = createCommand({ + metadata: { + description: "List images in the Cloudflare managed registry", + status: "open beta", + owner: "Product: Cloudchamber", + }, + args: { + filter: { + type: "string", + description: "Regex to filter results", + }, + json: { + type: "boolean", + description: "Format output as JSON", + default: false, + }, + }, + async handler(args, { config }) { + await fillOpenAPIConfiguration(config, containersScope); + await handleListImagesCommand(args, config); + }, +}); + +export const containersImagesDeleteCommand = createCommand({ + metadata: { + description: "Remove an image from the Cloudflare managed registry", + status: "open beta", + owner: "Product: Cloudchamber", + }, + args: { + image: { + type: "string", + description: "Image and tag to delete, of the form IMAGE:TAG", + demandOption: true, + }, + }, + positionalArgs: ["image"], + async handler(args, { config }) { + await fillOpenAPIConfiguration(config, containersScope); + await handleDeleteImageCommand(args, config); + }, +}); + +// --- Handler functions --- + +async function handleDeleteImageCommand( + args: { image: string }, + config: Config +) { + if (!args.image.includes(":")) { + throw new Error("Invalid image format. Expected IMAGE:TAG"); + } + + const digest = await promiseSpinner( + getCreds().then(async (creds) => { + const accountId = await getAccountId(config); + const url = new URL(`https://${getCloudflareContainerRegistry()}`); + const baseUrl = `${url.protocol}//${url.host}`; + const [image, tag] = args.image.split(":"); + const digest_ = await deleteTag(baseUrl, accountId, image, tag, creds); + + // trigger gc + const gcUrl = `${baseUrl}/v2/gc/layers`; + const gcResponse = await fetch(gcUrl, { + method: "PUT", + headers: { + Authorization: `Basic ${creds}`, + "Content-Type": "application/json", + }, + }); + if (!gcResponse.ok) { + throw new Error( + `Failed to delete image ${args.image}: ${gcResponse.status} ${gcResponse.statusText}` + ); + } + + return digest_; + }), + { message: `Deleting ${args.image}` } + ); + + logger.log(`Deleted ${args.image} (${digest})`); +} + +async function handleListImagesCommand( + args: { filter?: string; json: boolean }, + config: Config +) { + const responses = await promiseSpinner( + getCreds().then(async (creds) => { + const repos = await listReposWithTags(creds); + const processed: Repository[] = []; + const accountId = await getAccountId(config); + const accountIdPrefix = new RegExp(`^${accountId}/`); + const filter = new RegExp(args.filter ?? ""); + for (const [repo, tags] of Object.entries(repos)) { + const stripped = repo.replace(/^\/+/, ""); + if (filter.test(stripped)) { + const name = stripped.replace(accountIdPrefix, ""); + processed.push({ name, tags }); + } + } + + return processed; + }), + { message: "Listing" } + ); + + await listImages(responses, false, args.json); +} + +async function listImages( + responses: Repository[], + digests: boolean = false, + json: boolean = false +) { + if (!digests) { + responses = responses.map((resp) => { + return { + name: resp.name, + tags: resp.tags.filter((t) => !t.startsWith("sha256")), + }; + }); + } + // Remove any repos with no tags + responses = responses.filter((resp) => { + return resp.tags !== undefined && resp.tags.length != 0; + }); + if (json) { + logger.log(JSON.stringify(responses, null, 2)); + } else { + const rows = responses.flatMap((r) => r.tags.map((t) => [r.name, t])); + const headers = ["REPOSITORY", "TAG"]; + const widths = new Array(headers.length).fill(0); + + // Find the maximum length of each column (except for the last) + for (let i = 0; i < widths.length - 1; i++) { + widths[i] = rows + .map((r) => r[i].length) + .reduce((a, b) => Math.max(a, b), headers[i].length); + } + + logger.log(headers.map((h, i) => h.padEnd(widths[i], " ")).join(" ")); + for (const row of rows) { + logger.log(row.map((v, i) => v.padEnd(widths[i], " ")).join(" ")); + } + } +} + +interface CatalogWithTagsResponse { + repositories: Record; + cursor?: string; +} + +async function listReposWithTags( + creds: string +): Promise> { + const url = new URL(`https://${getCloudflareContainerRegistry()}`); + const catalogUrl = `${url.protocol}//${url.host}/v2/_catalog?tags=true`; + + const response = await fetch(catalogUrl, { + method: "GET", + headers: { + Authorization: `Basic ${creds}`, + }, + }); + if (!response.ok) { + logger.log(JSON.stringify(response)); + throw new Error( + `Failed to fetch repository catalog: ${response.status} ${response.statusText}` + ); + } + + const data = (await response.json()) as CatalogWithTagsResponse; + + return data.repositories ?? {}; +} + +async function deleteTag( + baseUrl: string, + accountId: string, + image: string, + tag: string, + creds: string +): Promise { + const manifestAcceptHeader = + "application/vnd.oci.image.manifest.v1+json, application/vnd.docker.distribution.manifest.v2+json"; + const manifestUrl = `${baseUrl}/v2/${accountId}/${image}/manifests/${tag}`; + // grab the digest for this tag + const headResponse = await fetch(manifestUrl, { + method: "HEAD", + headers: { + Authorization: `Basic ${creds}`, + Accept: manifestAcceptHeader, + }, + }); + if (!headResponse.ok) { + throw new Error( + `Failed to retrieve info for ${image}:${tag}: ${headResponse.status} ${headResponse.statusText}` + ); + } + + const digest = headResponse.headers.get("Docker-Content-Digest"); + if (!digest) { + throw new Error(`Digest not found for ${image}:${tag}.`); + } + + const deleteUrl = `${baseUrl}/v2/${accountId}/${image}/manifests/${tag}`; + const deleteResponse = await fetch(deleteUrl, { + method: "DELETE", + headers: { + Authorization: `Basic ${creds}`, + Accept: manifestAcceptHeader, + }, + }); + + if (!deleteResponse.ok) { + throw new Error( + `Failed to delete ${image}:${tag} (digest: ${digest}): ${deleteResponse.status} ${deleteResponse.statusText}` + ); + } + + return digest; +} + +async function getCreds(): Promise { + const credentials = + await ImageRegistriesService.generateImageRegistryCredentials( + getCloudflareContainerRegistry(), + { + expiration_minutes: 5, + permissions: ["pull", "push"] as ImageRegistryPermissions[], + } + ); + + return Buffer.from(`v1:${credentials.password}`).toString("base64"); +} diff --git a/packages/wrangler/src/containers/index.ts b/packages/wrangler/src/containers/index.ts index df89e0637b4d..ba0e61975e5b 100644 --- a/packages/wrangler/src/containers/index.ts +++ b/packages/wrangler/src/containers/index.ts @@ -1,108 +1,38 @@ -import { - buildCommand, - buildYargs, - pushCommand, - pushYargs, -} from "../cloudchamber/build"; -import { handleFailure } from "../cloudchamber/common"; -import { imagesCommand } from "../cloudchamber/images/images"; -import { - deleteCommand, - deleteYargs, - infoCommand, - infoYargs, - listCommand, - listYargs, -} from "./containers"; -import { registryCommands } from "./registries"; -import { sshCommand, sshYargs } from "./ssh"; -import type { CommonYargsArgv, CommonYargsOptions } from "../yargs-types"; -import type { CommandModule } from "yargs"; +import { createNamespace } from "../core/create-command"; export const containersScope = "containers:write" as const; -export const containers = ( - yargs: CommonYargsArgv, - subHelp: CommandModule -) => { - return yargs - .command( - "build PATH", - "Build a container image", - (args) => buildYargs(args), - (args) => - handleFailure( - `wrangler containers build`, - buildCommand, - containersScope - )(args) - ) - .command( - "push TAG", - "Push a tagged image to a Cloudflare managed registry", - (args) => pushYargs(args), - (args) => - handleFailure( - `wrangler containers push`, - pushCommand, - containersScope - )(args) - ) - .command( - "images", - "Perform operations on images in your Cloudflare managed registry", - (args) => imagesCommand(args, containersScope).command(subHelp) - ) - .command( - "info ID", - "Get information about a specific container", - (args) => infoYargs(args), - (args) => - handleFailure( - `wrangler containers info`, - infoCommand, - containersScope - )(args) - ) - .command( - "list", - "List containers", - (args) => listYargs(args), - (args) => - handleFailure( - `wrangler containers list`, - listCommand, - containersScope - )(args) - ) - .command( - "delete ID", - "Delete a container", - (args) => deleteYargs(args), - (args) => - handleFailure( - `wrangler containers delete`, - deleteCommand, - containersScope - )(args) - ) - .command( - "ssh ID", - // "SSH into a container", - false, // hides it for now so it doesn't show up in help until it is ready - (args) => sshYargs(args), - (args) => - handleFailure( - `wrangler containers ssh`, - sshCommand, - containersScope - )(args) - ) - .command( - "registries", - // hide for now so it doesn't show up in help while not publicly available - // "Configure and manage non-Cloudflare registries", - false, - (args) => registryCommands(args).command(subHelp) - ); -}; +// --- Namespace definition --- +export const containersNamespace = createNamespace({ + metadata: { + description: "📦 Manage Containers", + status: "open beta", + owner: "Product: Cloudchamber", + }, +}); + +// --- Re-export commands from their respective files --- +export { + containersListCommand, + containersInfoCommand, + containersDeleteCommand, +} from "./containers"; + +export { containersSshCommand } from "./ssh"; + +export { + containersRegistriesNamespace, + containersRegistriesConfigureCommand, + containersRegistriesListCommand, + containersRegistriesDeleteCommand, +} from "./registries"; + +// Build and push commands +export { containersBuildCommand, containersPushCommand } from "./build"; + +// Images commands +export { + containersImagesNamespace, + containersImagesListCommand, + containersImagesDeleteCommand, +} from "./images"; diff --git a/packages/wrangler/src/containers/registries.ts b/packages/wrangler/src/containers/registries.ts index 054081da45ba..d91b76b9ad27 100644 --- a/packages/wrangler/src/containers/registries.ts +++ b/packages/wrangler/src/containers/registries.ts @@ -15,7 +15,11 @@ import { getCloudflareComplianceRegion, UserError, } from "@cloudflare/workers-utils"; -import { handleFailure, promiseSpinner } from "../cloudchamber/common"; +import { + fillOpenAPIConfiguration, + promiseSpinner, +} from "../cloudchamber/common"; +import { createCommand, createNamespace } from "../core/create-command"; import { confirm, prompt } from "../dialogs"; import { isNonInteractiveOrCI } from "../is-interactive"; import { logger } from "../logger"; @@ -32,43 +36,7 @@ import type { import type { ImageRegistryAuth } from "@cloudflare/containers-shared/src/client/models/ImageRegistryAuth"; import type { Config } from "@cloudflare/workers-utils"; -export const registryCommands = (yargs: CommonYargsArgv) => { - return yargs - .command( - "configure ", - "Configure credentials for a non-Cloudflare container registry", - (args) => registryConfigureYargs(args), - (args) => - handleFailure( - `wrangler containers registries configure`, - registryConfigureCommand, - containersScope - )(args) - ) - .command( - "list", - "List all configured container registries", - (args) => registryListYargs(args), - (args) => - handleFailure( - `wrangler containers registries list`, - registryListCommand, - containersScope - )(args) - ) - .command( - "delete ", - "Delete a configured container registry", - (args) => registryDeleteYargs(args), - (args) => - handleFailure( - `wrangler containers registries delete`, - registryDeleteCommand, - containersScope - )(args) - ); -}; -function registryConfigureYargs(args: CommonYargsArgv) { +function _registryConfigureYargs(args: CommonYargsArgv) { return ( args .positional("DOMAIN", { @@ -110,7 +78,7 @@ function registryConfigureYargs(args: CommonYargsArgv) { } async function registryConfigureCommand( - configureArgs: StrictYargsOptionsToInterface, + configureArgs: StrictYargsOptionsToInterface, config: Config ) { startSection("Configure a container registry"); @@ -272,7 +240,7 @@ async function getSecret(secretType?: string): Promise { return secret; } -function registryListYargs(args: CommonYargsArgv) { +function _registryListYargs(args: CommonYargsArgv) { return args.option("json", { type: "boolean", description: "Format output as JSON", @@ -281,7 +249,7 @@ function registryListYargs(args: CommonYargsArgv) { } async function registryListCommand( - listArgs: StrictYargsOptionsToInterface + listArgs: StrictYargsOptionsToInterface ) { if (!listArgs.json && !isNonInteractiveOrCI()) { startSection("List configured container registries"); @@ -313,7 +281,7 @@ async function registryListCommand( } } -const registryDeleteYargs = (yargs: CommonYargsArgv) => { +const _registryDeleteYargs = (yargs: CommonYargsArgv) => { return yargs .positional("DOMAIN", { describe: "domain of the registry to delete", @@ -328,7 +296,7 @@ const registryDeleteYargs = (yargs: CommonYargsArgv) => { }); }; async function registryDeleteCommand( - deleteArgs: StrictYargsOptionsToInterface + deleteArgs: StrictYargsOptionsToInterface ) { startSection(`Delete registry ${deleteArgs.DOMAIN}`); @@ -364,3 +332,111 @@ async function registryDeleteCommand( endSection(`Deleted registry ${deleteArgs.DOMAIN}\n`); } + +// --- New defineCommand-based commands --- + +export const containersRegistriesNamespace = createNamespace({ + metadata: { + description: "Configure and manage non-Cloudflare registries", + status: "open beta", + owner: "Product: Cloudchamber", + hidden: true, + }, +}); + +export const containersRegistriesConfigureCommand = createCommand({ + metadata: { + description: + "Configure credentials for a non-Cloudflare container registry", + status: "open beta", + owner: "Product: Cloudchamber", + hidden: true, + }, + args: { + DOMAIN: { + describe: "Domain to configure for the registry", + type: "string", + demandOption: true, + }, + "public-credential": { + type: "string", + description: + "The public part of the registry credentials, e.g. `AWS_ACCESS_KEY_ID` for ECR", + demandOption: true, + alias: "aws-access-key-id", + }, + "secret-store-id": { + type: "string", + description: + "The ID of the secret store to use to store the registry credentials.", + demandOption: false, + conflicts: "disableSecretsStore", + }, + "secret-name": { + type: "string", + description: + "The name for the secret the private registry credentials should be stored under.", + demandOption: false, + conflicts: "disableSecretsStore", + }, + disableSecretsStore: { + type: "boolean", + description: + "Whether to disable secrets store integration. This should be set iff the compliance region is FedRAMP High.", + demandOption: false, + conflicts: ["secret-store-id", "secret-name"], + }, + }, + positionalArgs: ["DOMAIN"], + async handler(args, { config }) { + await fillOpenAPIConfiguration(config, containersScope); + await registryConfigureCommand(args, config); + }, +}); + +export const containersRegistriesListCommand = createCommand({ + metadata: { + description: "List all configured container registries", + status: "open beta", + owner: "Product: Cloudchamber", + hidden: true, + }, + args: { + json: { + type: "boolean", + description: "Format output as JSON", + default: false, + }, + }, + async handler(args, { config }) { + await fillOpenAPIConfiguration(config, containersScope); + await registryListCommand(args); + }, +}); + +export const containersRegistriesDeleteCommand = createCommand({ + metadata: { + description: "Delete a configured container registry", + status: "open beta", + owner: "Product: Cloudchamber", + hidden: true, + }, + args: { + DOMAIN: { + describe: "Domain of the registry to delete", + type: "string", + demandOption: true, + }, + "skip-confirmation": { + type: "boolean", + description: "Skip confirmation prompt", + alias: "y", + default: false, + }, + }, + positionalArgs: ["DOMAIN"], + async handler(args, { config }) { + await fillOpenAPIConfiguration(config, containersScope); + await registryDeleteCommand(args); + }, +}); diff --git a/packages/wrangler/src/containers/ssh.ts b/packages/wrangler/src/containers/ssh.ts index 9babc86777cc..c5eac87d49a8 100644 --- a/packages/wrangler/src/containers/ssh.ts +++ b/packages/wrangler/src/containers/ssh.ts @@ -5,8 +5,13 @@ import { bold } from "@cloudflare/cli/colors"; import { ApiError, DeploymentsService } from "@cloudflare/containers-shared"; import { UserError } from "@cloudflare/workers-utils"; import { WebSocket } from "ws"; -import { promiseSpinner } from "../cloudchamber/common"; +import { + fillOpenAPIConfiguration, + promiseSpinner, +} from "../cloudchamber/common"; +import { createCommand } from "../core/create-command"; import { logger } from "../logger"; +import { containersScope } from "./index"; import type { CommonYargsArgv, StrictYargsOptionsToInterface, @@ -321,3 +326,74 @@ function buildSshArgs( return flags; } + +// --- New defineCommand-based command --- + +export const containersSshCommand = createCommand({ + metadata: { + description: "SSH into a container", + status: "open beta", + owner: "Product: Cloudchamber", + hidden: true, + }, + args: { + ID: { + describe: "ID of the container instance", + type: "string", + demandOption: true, + }, + cipher: { + describe: + "Sets `ssh -c`: Select the cipher specification for encrypting the session", + type: "string", + }, + "log-file": { + describe: + "Sets `ssh -E`: Append debug logs to log_file instead of standard error", + type: "string", + }, + "escape-char": { + describe: + "Sets `ssh -e`: Set the escape character for sessions with a pty (default: '~')", + type: "string", + }, + "config-file": { + alias: "F", + describe: + "Sets `ssh -F`: Specify an alternative per-user ssh configuration file", + type: "string", + }, + pkcs11: { + describe: + "Sets `ssh -I`: Specify the PKCS#11 shared library ssh should use to communicate with a PKCS#11 token providing keys for user authentication", + type: "string", + }, + "identity-file": { + alias: "i", + describe: + "Sets `ssh -i`: Select a file from which the identity (private key) for public key authentication is read", + type: "string", + }, + "mac-spec": { + describe: + "Sets `ssh -m`: A comma-separated list of MAC (message authentication code) algorithms, specified in order of preference", + type: "string", + }, + option: { + alias: "o", + describe: + "Sets `ssh -o`: Set options in the format used in the ssh configuration file. May be repeated", + type: "string", + }, + tag: { + describe: + "Sets `ssh -P`: Specify a tag name that may be used to select configuration in ssh_config", + type: "string", + }, + }, + positionalArgs: ["ID"], + async handler(args, { config }) { + await fillOpenAPIConfiguration(config, containersScope); + await sshCommand(args, config); + }, +}); diff --git a/packages/wrangler/src/index.ts b/packages/wrangler/src/index.ts index a5d716c2b8c8..da24df24a5e6 100644 --- a/packages/wrangler/src/index.ts +++ b/packages/wrangler/src/index.ts @@ -25,10 +25,46 @@ import { certUploadNamespace, } from "./cert/cert"; import { checkNamespace, checkStartupCommand } from "./check/commands"; -import { cloudchamber } from "./cloudchamber"; +import { + cloudchamberApplyCommand, + cloudchamberBuildCommand, + cloudchamberCreateCommand, + cloudchamberCurlCommand, + cloudchamberDeleteCommand, + cloudchamberImagesDeleteCommand, + cloudchamberImagesListCommand, + cloudchamberImagesNamespace, + cloudchamberListCommand, + cloudchamberModifyCommand, + cloudchamberNamespace, + cloudchamberPushCommand, + cloudchamberRegistriesConfigureCommand, + cloudchamberRegistriesCredentialsCommand, + cloudchamberRegistriesListCommand, + cloudchamberRegistriesNamespace, + cloudchamberRegistriesRemoveCommand, + cloudchamberSshCreateCommand, + cloudchamberSshListCommand, + cloudchamberSshNamespace, +} from "./cloudchamber"; import { completionsCommand } from "./complete"; import { getDefaultEnvFiles, loadDotEnv } from "./config/dot-env"; -import { containers } from "./containers"; +import { + containersBuildCommand, + containersDeleteCommand, + containersImagesDeleteCommand, + containersImagesListCommand, + containersImagesNamespace, + containersInfoCommand, + containersListCommand, + containersNamespace, + containersPushCommand, + containersRegistriesConfigureCommand, + containersRegistriesDeleteCommand, + containersRegistriesListCommand, + containersRegistriesNamespace, + containersSshCommand, +} from "./containers"; import { demandSingleValue } from "./core"; import { CommandHandledError } from "./core/CommandHandledError"; import { CommandRegistry } from "./core/CommandRegistry"; @@ -1356,18 +1392,133 @@ export function createCLIParser(argv: string[]) { registry.registerNamespace("mtls-certificate"); // cloudchamber - wrangler.command("cloudchamber", false, (cloudchamberArgs) => { - return cloudchamber(cloudchamberArgs.command(subHelp), subHelp); - }); + registry.define([ + { command: "wrangler cloudchamber", definition: cloudchamberNamespace }, + { + command: "wrangler cloudchamber list", + definition: cloudchamberListCommand, + }, + { + command: "wrangler cloudchamber create", + definition: cloudchamberCreateCommand, + }, + { + command: "wrangler cloudchamber delete", + definition: cloudchamberDeleteCommand, + }, + { + command: "wrangler cloudchamber modify", + definition: cloudchamberModifyCommand, + }, + { + command: "wrangler cloudchamber apply", + definition: cloudchamberApplyCommand, + }, + { + command: "wrangler cloudchamber curl", + definition: cloudchamberCurlCommand, + }, + { + command: "wrangler cloudchamber build", + definition: cloudchamberBuildCommand, + }, + { + command: "wrangler cloudchamber push", + definition: cloudchamberPushCommand, + }, + { + command: "wrangler cloudchamber ssh", + definition: cloudchamberSshNamespace, + }, + { + command: "wrangler cloudchamber ssh list", + definition: cloudchamberSshListCommand, + }, + { + command: "wrangler cloudchamber ssh create", + definition: cloudchamberSshCreateCommand, + }, + { + command: "wrangler cloudchamber registries", + definition: cloudchamberRegistriesNamespace, + }, + { + command: "wrangler cloudchamber registries configure", + definition: cloudchamberRegistriesConfigureCommand, + }, + { + command: "wrangler cloudchamber registries credentials", + definition: cloudchamberRegistriesCredentialsCommand, + }, + { + command: "wrangler cloudchamber registries remove", + definition: cloudchamberRegistriesRemoveCommand, + }, + { + command: "wrangler cloudchamber registries list", + definition: cloudchamberRegistriesListCommand, + }, + { + command: "wrangler cloudchamber images", + definition: cloudchamberImagesNamespace, + }, + { + command: "wrangler cloudchamber images list", + definition: cloudchamberImagesListCommand, + }, + { + command: "wrangler cloudchamber images delete", + definition: cloudchamberImagesDeleteCommand, + }, + ]); + registry.registerNamespace("cloudchamber"); // containers - wrangler.command( - "containers", - `📦 Manage Containers ${chalk.hex(betaCmdColor)("[open beta]")}`, - (containersArgs) => { - return containers(containersArgs.command(subHelp), subHelp); - } - ); + registry.define([ + { command: "wrangler containers", definition: containersNamespace }, + { command: "wrangler containers list", definition: containersListCommand }, + { command: "wrangler containers info", definition: containersInfoCommand }, + { + command: "wrangler containers delete", + definition: containersDeleteCommand, + }, + { command: "wrangler containers ssh", definition: containersSshCommand }, + { + command: "wrangler containers build", + definition: containersBuildCommand, + }, + { command: "wrangler containers push", definition: containersPushCommand }, + { + command: "wrangler containers registries", + definition: containersRegistriesNamespace, + }, + { + command: "wrangler containers registries configure", + definition: containersRegistriesConfigureCommand, + }, + { + command: "wrangler containers registries list", + definition: containersRegistriesListCommand, + }, + { + command: "wrangler containers registries delete", + definition: containersRegistriesDeleteCommand, + }, + { + command: "wrangler containers images", + definition: containersImagesNamespace, + }, + { + command: "wrangler containers images list", + definition: containersImagesListCommand, + }, + { + command: "wrangler containers images delete", + definition: containersImagesDeleteCommand, + }, + ]); + registry.registerNamespace("containers"); + registry.registerLegacyCommandCategory("containers", "Compute & AI"); // [PRIVATE BETA] pubsub wrangler.command( @@ -1726,7 +1877,6 @@ export function createCLIParser(argv: string[]) { // This set to false to allow overwrite of default behaviour wrangler.version(false); - registry.registerLegacyCommandCategory("containers", "Compute & AI"); registry.registerLegacyCommandCategory("pubsub", "Compute & AI"); registry.registerAll(); @@ -1775,9 +1925,6 @@ export async function main(argv: string[]): Promise { // Record command as Sentry breadcrumb const command = `wrangler ${args._.join(" ")}`; addBreadcrumb(command); - - // TODO: Legacy commands (cloudchamber, containers) don't use defineCommand - // and won't emit telemetry events. Migrate them to defineCommand to enable telemetry. }, /* applyBeforeValidation */ true); const startTime = Date.now(); From 7e9344a8f3543d83b0261c030c4fed348edbf634 Mon Sep 17 00:00:00 2001 From: Pete Bacon Darwin Date: Fri, 23 Jan 2026 14:16:30 +0000 Subject: [PATCH 3/3] refactor(wrangler): share pending metrics requests across all dispatchers Move the requests tracking from per-dispatcher instance to module-level scope so that all dispatchers share the same Set of pending requests. This ensures all metrics requests are awaited before Wrangler exits, regardless of which dispatcher created them. - Add module-level pendingRequests Set with auto-cleanup via .finally() - Export waitForAllMetricsDispatches() to await all pending requests - Remove the per-dispatcher requests getter --- .../wrangler/src/__tests__/metrics.test.ts | 39 ++++++------------- packages/wrangler/src/index.ts | 4 +- packages/wrangler/src/metrics/index.ts | 5 ++- .../src/metrics/metrics-dispatcher.ts | 22 ++++++++--- 4 files changed, 33 insertions(+), 37 deletions(-) diff --git a/packages/wrangler/src/__tests__/metrics.test.ts b/packages/wrangler/src/__tests__/metrics.test.ts index c40f096ef417..02ecdecacf30 100644 --- a/packages/wrangler/src/__tests__/metrics.test.ts +++ b/packages/wrangler/src/__tests__/metrics.test.ts @@ -18,7 +18,10 @@ import { readMetricsConfig, writeMetricsConfig, } from "../metrics/metrics-config"; -import { getMetricsDispatcher } from "../metrics/metrics-dispatcher"; +import { + getMetricsDispatcher, + waitForAllMetricsDispatches, +} from "../metrics/metrics-dispatcher"; import { sniffUserAgent } from "../package-manager"; import { mockConsoleMethods } from "./helpers/mock-console"; import { useMockIsTTY } from "./helpers/mock-istty"; @@ -79,7 +82,8 @@ describe("metrics", () => { }); }); - afterEach(() => { + afterEach(async () => { + await waitForAllMetricsDispatches(); vi.useRealTimers(); }); @@ -101,7 +105,7 @@ describe("metrics", () => { sendMetrics: true, }); dispatcher.sendAdhocEvent("some-event", { a: 1, b: 2 }); - await Promise.all(dispatcher.requests); + await waitForAllMetricsDispatches(); expect(requests.count).toBe(1); expect(std.debug).toMatchInlineSnapshot( `"Metrics dispatcher: Posting data {\\"deviceId\\":\\"f82b1f46-eb7b-4154-aa9f-ce95f23b2288\\",\\"event\\":\\"some-event\\",\\"timestamp\\":1733961600000,\\"properties\\":{\\"category\\":\\"Workers\\",\\"wranglerVersion\\":\\"1.2.3\\",\\"wranglerMajorVersion\\":1,\\"wranglerMinorVersion\\":2,\\"wranglerPatchVersion\\":3,\\"os\\":\\"foo:bar\\",\\"agent\\":null,\\"a\\":1,\\"b\\":2}}"` @@ -118,7 +122,7 @@ describe("metrics", () => { sendMetrics: true, }); dispatcher.sendAdhocEvent("version-test"); - await Promise.all(dispatcher.requests); + await waitForAllMetricsDispatches(); expect(requests.count).toBe(1); expect(std.debug).toContain('"wranglerVersion":"1.2.3"'); expect(std.debug).toContain('"wranglerMajorVersion":1'); @@ -152,7 +156,7 @@ describe("metrics", () => { sendMetrics: true, }); dispatcher.sendAdhocEvent("some-event", { a: 1, b: 2 }); - await Promise.all(dispatcher.requests); + await waitForAllMetricsDispatches(); expect(std.debug).toMatchInlineSnapshot(` "Metrics dispatcher: Posting data {\\"deviceId\\":\\"f82b1f46-eb7b-4154-aa9f-ce95f23b2288\\",\\"event\\":\\"some-event\\",\\"timestamp\\":1733961600000,\\"properties\\":{\\"category\\":\\"Workers\\",\\"wranglerVersion\\":\\"1.2.3\\",\\"wranglerMajorVersion\\":1,\\"wranglerMinorVersion\\":2,\\"wranglerPatchVersion\\":3,\\"os\\":\\"foo:bar\\",\\"agent\\":null,\\"a\\":1,\\"b\\":2}} @@ -194,7 +198,7 @@ describe("metrics", () => { sendMetrics: true, }); dispatcher.sendAdhocEvent("some-event", { a: 1 }); - await Promise.all(dispatcher.requests); + await waitForAllMetricsDispatches(); expect(requests.count).toBe(1); expect(std.debug).toContain('"agent":"claude-code"'); @@ -210,34 +214,13 @@ describe("metrics", () => { sendMetrics: true, }); dispatcher.sendAdhocEvent("some-event", { a: 1 }); - await Promise.all(dispatcher.requests); + await waitForAllMetricsDispatches(); expect(requests.count).toBe(1); expect(std.debug).toContain('"agent":null'); }); }); - it("should keep track of all requests made", async () => { - const requests = mockMetricRequest(); - const dispatcher = getMetricsDispatcher({ - sendMetrics: true, - }); - - dispatcher.sendAdhocEvent("some-event", { a: 1, b: 2 }); - expect(dispatcher.requests.length).toBe(1); - - expect(requests.count).toBe(0); - await Promise.allSettled(dispatcher.requests); - expect(requests.count).toBe(1); - - dispatcher.sendAdhocEvent("another-event", { c: 3, d: 4 }); - expect(dispatcher.requests.length).toBe(2); - - expect(requests.count).toBe(1); - await Promise.allSettled(dispatcher.requests); - expect(requests.count).toBe(2); - }); - describe("sendCommandEvent()", () => { const reused = { wranglerVersion: "1.2.3", diff --git a/packages/wrangler/src/index.ts b/packages/wrangler/src/index.ts index da24df24a5e6..f4f04fe0f61b 100644 --- a/packages/wrangler/src/index.ts +++ b/packages/wrangler/src/index.ts @@ -127,7 +127,7 @@ import { kvNamespaceRenameCommand, } from "./kv"; import { logger, LOGGER_LEVELS } from "./logger"; -import { getMetricsDispatcher } from "./metrics"; +import { getMetricsDispatcher, waitForAllMetricsDispatches } from "./metrics"; import { metricsAlias, telemetryDisableCommand, @@ -2013,7 +2013,7 @@ export async function main(argv: string[]): Promise { // Wait for any pending telemetry requests to complete (with timeout) await Promise.race([ - Promise.allSettled(dispatcher?.requests ?? []), + waitForAllMetricsDispatches(), setTimeout(1000, undefined, { ref: false }), ]); } catch (e) { diff --git a/packages/wrangler/src/metrics/index.ts b/packages/wrangler/src/metrics/index.ts index 20405cf2dad1..7aee0451da19 100644 --- a/packages/wrangler/src/metrics/index.ts +++ b/packages/wrangler/src/metrics/index.ts @@ -1,4 +1,7 @@ -export { getMetricsDispatcher } from "./metrics-dispatcher"; +export { + getMetricsDispatcher, + waitForAllMetricsDispatches, +} from "./metrics-dispatcher"; export { getMetricsConfig } from "./metrics-config"; export * from "./send-event"; export { getMetricsUsageHeaders } from "./metrics-usage-headers"; diff --git a/packages/wrangler/src/metrics/metrics-dispatcher.ts b/packages/wrangler/src/metrics/metrics-dispatcher.ts index 9a947b18daf4..86cf2454ad56 100644 --- a/packages/wrangler/src/metrics/metrics-dispatcher.ts +++ b/packages/wrangler/src/metrics/metrics-dispatcher.ts @@ -30,6 +30,18 @@ import type { CommonEventProperties, Events } from "./types"; const SPARROW_URL = "https://sparrow.cloudflare.com"; +// Module-level Set to track all pending requests across all dispatchers. +// Promises are automatically removed from this Set once they settle. +const pendingRequests = new Set>(); + +/** + * Wait for all pending metrics requests to complete. + * This should be called before the process exits to ensure all metrics are sent. + */ +export function waitForAllMetricsDispatches(): Promise { + return Promise.allSettled(pendingRequests).then(() => {}); +} + /** * A list of all the command args that can be included in the event. * @@ -58,7 +70,6 @@ export function getMetricsDispatcher(options: MetricsConfigOptions) { // The SPARROW_SOURCE_KEY will be provided at build time through esbuild's `define` option // No events will be sent if the env `SPARROW_SOURCE_KEY` is not provided and the value will be set to an empty string instead. const SPARROW_SOURCE_KEY = process.env.SPARROW_SOURCE_KEY ?? ""; - const requests: Array> = []; const wranglerVersion = getWranglerVersion(); const [wranglerMajorVersion, wranglerMinorVersion, wranglerPatchVersion] = wranglerVersion.split(".").map((v) => parseInt(v, 10)); @@ -181,10 +192,6 @@ export function getMetricsDispatcher(options: MetricsConfigOptions) { logger.debug("Error sending metrics event", err); } }, - - get requests() { - return requests; - }, }; function dispatch(event: { name: string; properties: Properties }) { @@ -241,9 +248,12 @@ export function getMetricsDispatcher(options: MetricsConfigOptions) { "Metrics dispatcher: Failed to send request:", (e as Error).message ); + }) + .finally(() => { + pendingRequests.delete(request); }); - requests.push(request); + pendingRequests.add(request); } function printMetricsBanner() {