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/__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..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", @@ -336,10 +319,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 +459,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 +564,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 +599,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/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/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..f4f04fe0f61b 100644 --- a/packages/wrangler/src/index.ts +++ b/packages/wrangler/src/index.ts @@ -25,13 +25,50 @@ 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"; -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"; @@ -90,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, @@ -492,7 +529,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 @@ -1355,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( @@ -1725,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(); @@ -1742,8 +1893,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 +1905,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 +1921,23 @@ 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); + }, /* 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 +1945,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,8 +2011,9 @@ 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 ?? []), + 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-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..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)); @@ -114,8 +125,7 @@ export function getMetricsDispatcher(options: MetricsConfigOptions) { properties: Omit< Extract["properties"], keyof CommonEventProperties - >, - argv?: string[] + > ) { try { if (properties.command?.startsWith("wrangler login")) { @@ -136,7 +146,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, @@ -179,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 }) { @@ -239,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() {