From db843d18edc4b7da0214c72cecaf6b29611e2dbf Mon Sep 17 00:00:00 2001 From: Pete Bacon Darwin Date: Fri, 23 Jan 2026 09:21:39 +0000 Subject: [PATCH 1/3] refactor(wrangler): convert containers and cloudchamber commands to createCommand Migrate containers and cloudchamber CLI commands from the legacy yargs defineCommand approach to the new createCommand pattern. This aligns these commands with the updated telemetry handling where events are dispatched directly from command handlers rather than middleware. --- .../src/__tests__/cloudchamber/create.test.ts | 130 ++++++-- .../src/__tests__/cloudchamber/curl.test.ts | 2 +- .../src/__tests__/cloudchamber/delete.test.ts | 18 +- .../src/__tests__/cloudchamber/images.test.ts | 74 +++-- .../src/__tests__/cloudchamber/list.test.ts | 222 ++++++------- .../src/__tests__/cloudchamber/modify.test.ts | 89 +++-- .../src/__tests__/containers/delete.test.ts | 6 +- .../src/__tests__/containers/info.test.ts | 13 +- .../src/__tests__/containers/list.test.ts | 7 +- .../src/__tests__/containers/push.test.ts | 6 +- .../__tests__/containers/registries.test.ts | 22 +- .../src/__tests__/containers/ssh.test.ts | 4 +- packages/wrangler/src/cloudchamber/apply.ts | 29 +- packages/wrangler/src/cloudchamber/build.ts | 77 +++++ packages/wrangler/src/cloudchamber/create.ts | 93 ++++++ packages/wrangler/src/cloudchamber/curl.ts | 59 ++++ packages/wrangler/src/cloudchamber/delete.ts | 25 ++ .../src/cloudchamber/images/images.ts | 62 +++- .../src/cloudchamber/images/registries.ts | 303 ++++++++++-------- packages/wrangler/src/cloudchamber/index.ts | 172 +++------- packages/wrangler/src/cloudchamber/list.ts | 60 +++- packages/wrangler/src/cloudchamber/modify.ts | 78 +++++ packages/wrangler/src/cloudchamber/ssh/ssh.ts | 157 +++++---- packages/wrangler/src/containers/build.ts | 86 ++++- .../wrangler/src/containers/containers.ts | 58 ++++ packages/wrangler/src/containers/images.ts | 270 ++++++++++++++++ packages/wrangler/src/containers/index.ts | 140 ++------ .../wrangler/src/containers/registries.ts | 162 +++++++--- packages/wrangler/src/containers/ssh.ts | 78 ++++- packages/wrangler/src/index.ts | 179 ++++++++++- 30 files changed, 1956 insertions(+), 725 deletions(-) create mode 100644 packages/wrangler/src/containers/images.ts diff --git a/packages/wrangler/src/__tests__/cloudchamber/create.test.ts b/packages/wrangler/src/__tests__/cloudchamber/create.test.ts index 870fbcccf65c..a7fa516569cf 100644 --- a/packages/wrangler/src/__tests__/cloudchamber/create.test.ts +++ b/packages/wrangler/src/__tests__/cloudchamber/create.test.ts @@ -13,30 +13,6 @@ import { runWrangler } from "../helpers/run-wrangler"; import { mockAccount, setWranglerConfig } from "./utils"; import type { SSHPublicKeyItem } from "@cloudflare/containers-shared"; -const MOCK_DEPLOYMENTS_COMPLEX_RESPONSE = ` - "{ - \\"id\\": \\"1\\", - \\"type\\": \\"default\\", - \\"created_at\\": \\"123\\", - \\"account_id\\": \\"123\\", - \\"vcpu\\": 4, - \\"memory\\": \\"400MB\\", - \\"memory_mib\\": 400, - \\"version\\": 1, - \\"image\\": \\"hello\\", - \\"location\\": { - \\"name\\": \\"sfo06\\", - \\"enabled\\": true - }, - \\"network\\": { - \\"mode\\": \\"public\\", - \\"ipv4\\": \\"1.1.1.1\\" - }, - \\"placements_ref\\": \\"http://ref\\", - \\"node_group\\": \\"metal\\" - }" - `; - function mockDeploymentPost() { msw.use( http.post( @@ -103,7 +79,7 @@ describe("cloudchamber create", () => { expect(std.out).toMatchInlineSnapshot(` "wrangler cloudchamber create - Create a new deployment + Create a new deployment [alpha] GLOBAL FLAGS -c, --config Path to Wrangler configuration file [string] @@ -230,7 +206,32 @@ describe("cloudchamber create", () => { ); // so testing the actual UI will be harder than expected // TODO: think better on how to test UI actions - expect(std.out).toMatchInlineSnapshot(MOCK_DEPLOYMENTS_COMPLEX_RESPONSE); + expect(std.out).toMatchInlineSnapshot(` + " + ⛅️ wrangler x.x.x + ────────────────── + { + \\"id\\": \\"1\\", + \\"type\\": \\"default\\", + \\"created_at\\": \\"123\\", + \\"account_id\\": \\"123\\", + \\"vcpu\\": 4, + \\"memory\\": \\"400MB\\", + \\"memory_mib\\": 400, + \\"version\\": 1, + \\"image\\": \\"hello\\", + \\"location\\": { + \\"name\\": \\"sfo06\\", + \\"enabled\\": true + }, + \\"network\\": { + \\"mode\\": \\"public\\", + \\"ipv4\\": \\"1.1.1.1\\" + }, + \\"placements_ref\\": \\"http://ref\\", + \\"node_group\\": \\"metal\\" + }" + `); }); it("should create deployment with instance type (detects no interactivity)", async () => { @@ -253,7 +254,12 @@ describe("cloudchamber create", () => { await runWrangler( "cloudchamber create --image hello:world --location sfo06 --var HELLO:WORLD --var YOU:CONQUERED --instance-type lite --ipv4 true" ); - expect(std.out).toMatchInlineSnapshot(`"{}"`); + expect(std.out).toMatchInlineSnapshot(` + " + ⛅️ wrangler x.x.x + ────────────────── + {}" + `); }); it("properly reads wrangler config", async () => { @@ -274,7 +280,32 @@ describe("cloudchamber create", () => { await runWrangler( "cloudchamber create --var HELLO:WORLD --var YOU:CONQUERED" ); - expect(std.out).toMatchInlineSnapshot(MOCK_DEPLOYMENTS_COMPLEX_RESPONSE); + expect(std.out).toMatchInlineSnapshot(` + " + ⛅️ wrangler x.x.x + ────────────────── + { + \\"id\\": \\"1\\", + \\"type\\": \\"default\\", + \\"created_at\\": \\"123\\", + \\"account_id\\": \\"123\\", + \\"vcpu\\": 4, + \\"memory\\": \\"400MB\\", + \\"memory_mib\\": 400, + \\"version\\": 1, + \\"image\\": \\"hello\\", + \\"location\\": { + \\"name\\": \\"sfo06\\", + \\"enabled\\": true + }, + \\"network\\": { + \\"mode\\": \\"public\\", + \\"ipv4\\": \\"1.1.1.1\\" + }, + \\"placements_ref\\": \\"http://ref\\", + \\"node_group\\": \\"metal\\" + }" + `); expect(std.err).toMatchInlineSnapshot(`""`); }); @@ -306,7 +337,12 @@ describe("cloudchamber create", () => { await runWrangler( "cloudchamber create --var HELLO:WORLD --var YOU:CONQUERED" ); - expect(std.out).toMatchInlineSnapshot(`"{}"`); + expect(std.out).toMatchInlineSnapshot(` + " + ⛅️ wrangler x.x.x + ────────────────── + {}" + `); expect(std.err).toMatchInlineSnapshot(`""`); }); @@ -349,7 +385,32 @@ describe("cloudchamber create", () => { await runWrangler( "cloudchamber create --image hello:world --location sfo06 --var HELLO:WORLD --var YOU:CONQUERED --all-ssh-keys --ipv4" ); - expect(std.out).toMatchInlineSnapshot(MOCK_DEPLOYMENTS_COMPLEX_RESPONSE); + expect(std.out).toMatchInlineSnapshot(` + " + ⛅️ wrangler x.x.x + ────────────────── + { + \\"id\\": \\"1\\", + \\"type\\": \\"default\\", + \\"created_at\\": \\"123\\", + \\"account_id\\": \\"123\\", + \\"vcpu\\": 4, + \\"memory\\": \\"400MB\\", + \\"memory_mib\\": 400, + \\"version\\": 1, + \\"image\\": \\"hello\\", + \\"location\\": { + \\"name\\": \\"sfo06\\", + \\"enabled\\": true + }, + \\"network\\": { + \\"mode\\": \\"public\\", + \\"ipv4\\": \\"1.1.1.1\\" + }, + \\"placements_ref\\": \\"http://ref\\", + \\"node_group\\": \\"metal\\" + }" + `); }); it("can't create deployment due to lack of fields (json)", async () => { @@ -364,8 +425,11 @@ describe("cloudchamber create", () => { // so testing the actual UI will be harder than expected // TODO: think better on how to test UI actions expect(std.out).toMatchInlineSnapshot(` - " - If you think this is a bug then please create an issue at https://github.com/cloudflare/workers-sdk/issues/new/choose" - `); + " + ⛅️ wrangler x.x.x + ────────────────── + + If you think this is a bug then please create an issue at https://github.com/cloudflare/workers-sdk/issues/new/choose" + `); }); }); diff --git a/packages/wrangler/src/__tests__/cloudchamber/curl.test.ts b/packages/wrangler/src/__tests__/cloudchamber/curl.test.ts index 777bce43fbea..4d86a39cf8c1 100644 --- a/packages/wrangler/src/__tests__/cloudchamber/curl.test.ts +++ b/packages/wrangler/src/__tests__/cloudchamber/curl.test.ts @@ -34,7 +34,7 @@ describe("cloudchamber curl", () => { expect(helpStd.out).toMatchInlineSnapshot(` "wrangler cloudchamber curl - Send a request to an arbitrary Cloudchamber endpoint + Send a request to an arbitrary Cloudchamber endpoint [alpha] POSITIONALS path [string] [required] [default: \\"/\\"] diff --git a/packages/wrangler/src/__tests__/cloudchamber/delete.test.ts b/packages/wrangler/src/__tests__/cloudchamber/delete.test.ts index 3565aef2bff0..d9337c2bb0ad 100644 --- a/packages/wrangler/src/__tests__/cloudchamber/delete.test.ts +++ b/packages/wrangler/src/__tests__/cloudchamber/delete.test.ts @@ -30,10 +30,10 @@ describe("cloudchamber delete", () => { expect(std.out).toMatchInlineSnapshot(` "wrangler cloudchamber delete [deploymentId] - Delete an existing deployment that is running in the Cloudflare edge + Delete an existing deployment that is running in the Cloudflare edge [alpha] POSITIONALS - deploymentId deployment you want to delete [string] + deploymentId Deployment you want to delete [string] GLOBAL FLAGS -c, --config Path to Wrangler configuration file [string] @@ -62,7 +62,10 @@ describe("cloudchamber delete", () => { // TODO: think better on how to test UI actions expect(std.out).toMatchInlineSnapshot( ` - "{ + " + ⛅️ wrangler x.x.x + ────────────────── + { \\"id\\": \\"1\\", \\"type\\": \\"default\\", \\"created_at\\": \\"123\\", @@ -98,8 +101,11 @@ describe("cloudchamber delete", () => { // so testing the actual UI will be harder than expected // TODO: think better on how to test UI actions expect(std.out).toMatchInlineSnapshot(` - " - If you think this is a bug then please create an issue at https://github.com/cloudflare/workers-sdk/issues/new/choose" - `); + " + ⛅️ wrangler x.x.x + ────────────────── + + If you think this is a bug then please create an issue at https://github.com/cloudflare/workers-sdk/issues/new/choose" + `); }); }); diff --git a/packages/wrangler/src/__tests__/cloudchamber/images.test.ts b/packages/wrangler/src/__tests__/cloudchamber/images.test.ts index 1919c6cbb136..5a14161ae059 100644 --- a/packages/wrangler/src/__tests__/cloudchamber/images.test.ts +++ b/packages/wrangler/src/__tests__/cloudchamber/images.test.ts @@ -29,13 +29,7 @@ describe("cloudchamber image", () => { expect(std.out).toMatchInlineSnapshot(` "wrangler cloudchamber registries - Configure registries via Cloudchamber - - COMMANDS - wrangler cloudchamber registries configure Configure Cloudchamber to pull from specific registries - wrangler cloudchamber registries credentials [domain] get a temporary password for a specific domain - wrangler cloudchamber registries remove [domain] removes the registry at the given domain - wrangler cloudchamber registries list list registries configured for this account + Configure registries via Cloudchamber [alpha] GLOBAL FLAGS -c, --config Path to Wrangler configuration file [string] @@ -73,10 +67,13 @@ describe("cloudchamber image", () => { // so testing the actual UI will be harder than expected // TODO: think better on how to test UI actions expect(std.out).toMatchInlineSnapshot(` - "{ - \\"domain\\": \\"docker.io\\" - }" - `); + " + ⛅️ wrangler x.x.x + ────────────────── + { + \\"domain\\": \\"docker.io\\" + }" + `); }); it("should create an image registry (no interactivity)", async () => { @@ -100,7 +97,12 @@ describe("cloudchamber image", () => { expect(std.err).toMatchInlineSnapshot(`""`); // so testing the actual UI will be harder than expected // TODO: think better on how to test UI actions - expect(std.out).toMatchInlineSnapshot(`"jwt"`); + expect(std.out).toMatchInlineSnapshot(` + " + ⛅️ wrangler x.x.x + ────────────────── + jwt" + `); }); it("should remove an image registry (no interactivity)", async () => { @@ -118,7 +120,12 @@ describe("cloudchamber image", () => { ); await runWrangler("cloudchamber registries remove docker.io"); expect(std.err).toMatchInlineSnapshot(`""`); - expect(std.out).toMatchInlineSnapshot(`"{}"`); + expect(std.out).toMatchInlineSnapshot(` + " + ⛅️ wrangler x.x.x + ────────────────── + {}" + `); }); it("should list registries (no interactivity)", async () => { @@ -145,7 +152,10 @@ describe("cloudchamber image", () => { await runWrangler("cloudchamber registries list"); expect(std.err).toMatchInlineSnapshot(`""`); expect(std.out).toMatchInlineSnapshot(` - "[ + " + ⛅️ wrangler x.x.x + ────────────────── + [ { \\"public_key\\": \\"\\", \\"domain\\": \\"docker.io\\" @@ -182,8 +192,6 @@ describe("cloudchamber image list", () => { expect(std.out).toMatchInlineSnapshot(` "wrangler cloudchamber images list - List images in the Cloudflare managed registry - GLOBAL FLAGS -c, --config Path to Wrangler configuration file [string] --cwd Run as if Wrangler was started in the specified directory instead of the current working directory [string] @@ -224,7 +232,10 @@ describe("cloudchamber image list", () => { await runWrangler("cloudchamber images list"); expect(std.err).toMatchInlineSnapshot(`""`); expect(std.out).toMatchInlineSnapshot(` - "REPOSITORY TAG + " + ⛅️ wrangler x.x.x + ────────────────── + REPOSITORY TAG one hundred one ten two thousand @@ -260,7 +271,10 @@ describe("cloudchamber image list", () => { await runWrangler("cloudchamber images list --filter '^two$'"); expect(std.err).toMatchInlineSnapshot(`""`); expect(std.out).toMatchInlineSnapshot(` - "REPOSITORY TAG + " + ⛅️ wrangler x.x.x + ────────────────── + REPOSITORY TAG two thousand two twenty" `); @@ -294,7 +308,10 @@ describe("cloudchamber image list", () => { await runWrangler("cloudchamber images list"); expect(std.err).toMatchInlineSnapshot(`""`); expect(std.out).toMatchInlineSnapshot(` - "REPOSITORY TAG + " + ⛅️ wrangler x.x.x + ────────────────── + REPOSITORY TAG one hundred one ten two thousand @@ -330,7 +347,10 @@ describe("cloudchamber image list", () => { await runWrangler("cloudchamber images list --json"); expect(std.err).toMatchInlineSnapshot(`""`); expect(std.out).toMatchInlineSnapshot(` - "[ + " + ⛅️ wrangler x.x.x + ────────────────── + [ { \\"name\\": \\"one\\", \\"tags\\": [ @@ -384,7 +404,10 @@ describe("cloudchamber image list", () => { await runWrangler("cloudchamber images list --json"); expect(std.err).toMatchInlineSnapshot(`""`); expect(std.out).toMatchInlineSnapshot(` - "[ + " + ⛅️ wrangler x.x.x + ────────────────── + [ { \\"name\\": \\"one\\", \\"tags\\": [ @@ -434,8 +457,6 @@ describe("cloudchamber image delete", () => { expect(std.out).toMatchInlineSnapshot(` "wrangler cloudchamber images delete - Remove an image from the Cloudflare managed registry - POSITIONALS image Image and tag to delete, of the form IMAGE:TAG [string] [required] @@ -488,7 +509,12 @@ describe("cloudchamber image delete", () => { await runWrangler("cloudchamber images delete one:hundred"); expect(std.err).toMatchInlineSnapshot(`""`); expect(std.out).toMatchInlineSnapshot( - `"Deleted one:hundred (some-digest)"` + ` + " + ⛅️ wrangler x.x.x + ────────────────── + Deleted one:hundred (some-digest)" + ` ); }); diff --git a/packages/wrangler/src/__tests__/cloudchamber/list.test.ts b/packages/wrangler/src/__tests__/cloudchamber/list.test.ts index aab9cc9bd697..8636fbb18b9f 100644 --- a/packages/wrangler/src/__tests__/cloudchamber/list.test.ts +++ b/packages/wrangler/src/__tests__/cloudchamber/list.test.ts @@ -30,11 +30,10 @@ describe("cloudchamber list", () => { expect(std.out).toMatchInlineSnapshot(` "wrangler cloudchamber list [deploymentIdPrefix] - List and view status of deployments + List and view status of deployments [alpha] POSITIONALS - deploymentIdPrefix Optional deploymentId to filter deployments - This means that 'list' will only showcase deployments that contain this ID prefix [string] + deploymentIdPrefix Optional deploymentId to filter deployments. This means that 'list' will only showcase deployments that contain this ID prefix [string] GLOBAL FLAGS -c, --config Path to Wrangler configuration file [string] @@ -71,112 +70,115 @@ describe("cloudchamber list", () => { // so testing the actual UI will be harder than expected // TODO: think better on how to test UI actions expect(std.out).toMatchInlineSnapshot(` - "[ - { - \\"id\\": \\"1\\", - \\"type\\": \\"default\\", - \\"created_at\\": \\"123\\", - \\"account_id\\": \\"123\\", - \\"vcpu\\": 4, - \\"memory\\": \\"400MB\\", - \\"memory_mib\\": 400, - \\"version\\": 1, - \\"image\\": \\"hello\\", - \\"location\\": { - \\"name\\": \\"sfo06\\", - \\"enabled\\": true - }, - \\"network\\": { - \\"mode\\": \\"public\\", - \\"ipv4\\": \\"1.1.1.1\\" - }, - \\"placements_ref\\": \\"http://ref\\", - \\"node_group\\": \\"metal\\" - }, - { - \\"id\\": \\"2\\", - \\"type\\": \\"default\\", - \\"created_at\\": \\"1234\\", - \\"account_id\\": \\"123\\", - \\"vcpu\\": 4, - \\"memory\\": \\"400MB\\", - \\"memory_mib\\": 400, - \\"version\\": 2, - \\"image\\": \\"hello\\", - \\"location\\": { - \\"name\\": \\"sfo06\\", - \\"enabled\\": true - }, - \\"network\\": { - \\"mode\\": \\"public\\", - \\"ipv4\\": \\"1.1.1.2\\" - }, - \\"current_placement\\": { - \\"deployment_version\\": 2, - \\"status\\": { - \\"health\\": \\"running\\" - }, - \\"deployment_id\\": \\"2\\", - \\"terminate\\": false, - \\"created_at\\": \\"123\\", - \\"id\\": \\"1\\" - }, - \\"placements_ref\\": \\"http://ref\\", - \\"node_group\\": \\"metal\\" - }, - { - \\"id\\": \\"3\\", - \\"type\\": \\"default\\", - \\"created_at\\": \\"123\\", - \\"account_id\\": \\"123\\", - \\"vcpu\\": 4, - \\"memory\\": \\"400MB\\", - \\"memory_mib\\": 400, - \\"version\\": 1, - \\"image\\": \\"hello\\", - \\"location\\": { - \\"name\\": \\"sfo06\\", - \\"enabled\\": true - }, - \\"network\\": { - \\"mode\\": \\"public\\", - \\"ipv4\\": \\"1.1.1.1\\" - }, - \\"placements_ref\\": \\"http://ref\\", - \\"node_group\\": \\"metal\\" - }, - { - \\"id\\": \\"4\\", - \\"type\\": \\"default\\", - \\"created_at\\": \\"1234\\", - \\"account_id\\": \\"123\\", - \\"vcpu\\": 4, - \\"memory\\": \\"400MB\\", - \\"memory_mib\\": 400, - \\"version\\": 2, - \\"image\\": \\"hello\\", - \\"location\\": { - \\"name\\": \\"sfo06\\", - \\"enabled\\": true - }, - \\"network\\": { - \\"mode\\": \\"public\\", - \\"ipv4\\": \\"1.1.1.2\\" - }, - \\"current_placement\\": { - \\"deployment_version\\": 2, - \\"status\\": { - \\"health\\": \\"running\\" - }, - \\"deployment_id\\": \\"2\\", - \\"terminate\\": false, - \\"created_at\\": \\"123\\", - \\"id\\": \\"1\\" - }, - \\"placements_ref\\": \\"http://ref\\", - \\"node_group\\": \\"metal\\" - } - ]" - `); + " + ⛅️ wrangler x.x.x + ────────────────── + [ + { + \\"id\\": \\"1\\", + \\"type\\": \\"default\\", + \\"created_at\\": \\"123\\", + \\"account_id\\": \\"123\\", + \\"vcpu\\": 4, + \\"memory\\": \\"400MB\\", + \\"memory_mib\\": 400, + \\"version\\": 1, + \\"image\\": \\"hello\\", + \\"location\\": { + \\"name\\": \\"sfo06\\", + \\"enabled\\": true + }, + \\"network\\": { + \\"mode\\": \\"public\\", + \\"ipv4\\": \\"1.1.1.1\\" + }, + \\"placements_ref\\": \\"http://ref\\", + \\"node_group\\": \\"metal\\" + }, + { + \\"id\\": \\"2\\", + \\"type\\": \\"default\\", + \\"created_at\\": \\"1234\\", + \\"account_id\\": \\"123\\", + \\"vcpu\\": 4, + \\"memory\\": \\"400MB\\", + \\"memory_mib\\": 400, + \\"version\\": 2, + \\"image\\": \\"hello\\", + \\"location\\": { + \\"name\\": \\"sfo06\\", + \\"enabled\\": true + }, + \\"network\\": { + \\"mode\\": \\"public\\", + \\"ipv4\\": \\"1.1.1.2\\" + }, + \\"current_placement\\": { + \\"deployment_version\\": 2, + \\"status\\": { + \\"health\\": \\"running\\" + }, + \\"deployment_id\\": \\"2\\", + \\"terminate\\": false, + \\"created_at\\": \\"123\\", + \\"id\\": \\"1\\" + }, + \\"placements_ref\\": \\"http://ref\\", + \\"node_group\\": \\"metal\\" + }, + { + \\"id\\": \\"3\\", + \\"type\\": \\"default\\", + \\"created_at\\": \\"123\\", + \\"account_id\\": \\"123\\", + \\"vcpu\\": 4, + \\"memory\\": \\"400MB\\", + \\"memory_mib\\": 400, + \\"version\\": 1, + \\"image\\": \\"hello\\", + \\"location\\": { + \\"name\\": \\"sfo06\\", + \\"enabled\\": true + }, + \\"network\\": { + \\"mode\\": \\"public\\", + \\"ipv4\\": \\"1.1.1.1\\" + }, + \\"placements_ref\\": \\"http://ref\\", + \\"node_group\\": \\"metal\\" + }, + { + \\"id\\": \\"4\\", + \\"type\\": \\"default\\", + \\"created_at\\": \\"1234\\", + \\"account_id\\": \\"123\\", + \\"vcpu\\": 4, + \\"memory\\": \\"400MB\\", + \\"memory_mib\\": 400, + \\"version\\": 2, + \\"image\\": \\"hello\\", + \\"location\\": { + \\"name\\": \\"sfo06\\", + \\"enabled\\": true + }, + \\"network\\": { + \\"mode\\": \\"public\\", + \\"ipv4\\": \\"1.1.1.2\\" + }, + \\"current_placement\\": { + \\"deployment_version\\": 2, + \\"status\\": { + \\"health\\": \\"running\\" + }, + \\"deployment_id\\": \\"2\\", + \\"terminate\\": false, + \\"created_at\\": \\"123\\", + \\"id\\": \\"1\\" + }, + \\"placements_ref\\": \\"http://ref\\", + \\"node_group\\": \\"metal\\" + } + ]" + `); }); }); diff --git a/packages/wrangler/src/__tests__/cloudchamber/modify.test.ts b/packages/wrangler/src/__tests__/cloudchamber/modify.test.ts index c4009dfb3ecf..71b9db97ae0d 100644 --- a/packages/wrangler/src/__tests__/cloudchamber/modify.test.ts +++ b/packages/wrangler/src/__tests__/cloudchamber/modify.test.ts @@ -25,30 +25,6 @@ function mockDeployment() { ); } -const EXPECTED_RESULT = ` - "{ - \\"id\\": \\"1\\", - \\"type\\": \\"default\\", - \\"created_at\\": \\"123\\", - \\"account_id\\": \\"123\\", - \\"vcpu\\": 4, - \\"memory\\": \\"400MB\\", - \\"memory_mib\\": 400, - \\"version\\": 1, - \\"image\\": \\"hello\\", - \\"location\\": { - \\"name\\": \\"sfo06\\", - \\"enabled\\": true - }, - \\"network\\": { - \\"mode\\": \\"public\\", - \\"ipv4\\": \\"1.1.1.1\\" - }, - \\"placements_ref\\": \\"http://ref\\", - \\"node_group\\": \\"metal\\" - }" - `; - describe("cloudchamber modify", () => { const std = mockConsoleMethods(); const { setIsTTY } = useMockIsTTY(); @@ -68,7 +44,7 @@ describe("cloudchamber modify", () => { expect(std.out).toMatchInlineSnapshot(` "wrangler cloudchamber modify [deploymentId] - Modify an existing deployment + Modify an existing deployment [alpha] POSITIONALS deploymentId The deployment you want to modify [string] @@ -103,7 +79,32 @@ describe("cloudchamber modify", () => { expect(std.err).toMatchInlineSnapshot(`""`); // so testing the actual UI will be harder than expected // TODO: think better on how to test UI actions - expect(std.out).toMatchInlineSnapshot(EXPECTED_RESULT); + expect(std.out).toMatchInlineSnapshot(` + " + ⛅️ wrangler x.x.x + ────────────────── + { + \\"id\\": \\"1\\", + \\"type\\": \\"default\\", + \\"created_at\\": \\"123\\", + \\"account_id\\": \\"123\\", + \\"vcpu\\": 4, + \\"memory\\": \\"400MB\\", + \\"memory_mib\\": 400, + \\"version\\": 1, + \\"image\\": \\"hello\\", + \\"location\\": { + \\"name\\": \\"sfo06\\", + \\"enabled\\": true + }, + \\"network\\": { + \\"mode\\": \\"public\\", + \\"ipv4\\": \\"1.1.1.1\\" + }, + \\"placements_ref\\": \\"http://ref\\", + \\"node_group\\": \\"metal\\" + }" + `); }); it("should modify deployment with wrangler args (detects no interactivity)", async () => { @@ -119,7 +120,32 @@ describe("cloudchamber modify", () => { "cloudchamber modify 1234 --var HELLO:WORLD --var YOU:CONQUERED --label appname:helloworld --label region:wnam" ); expect(std.err).toMatchInlineSnapshot(`""`); - expect(std.out).toMatchInlineSnapshot(EXPECTED_RESULT); + expect(std.out).toMatchInlineSnapshot(` + " + ⛅️ wrangler x.x.x + ────────────────── + { + \\"id\\": \\"1\\", + \\"type\\": \\"default\\", + \\"created_at\\": \\"123\\", + \\"account_id\\": \\"123\\", + \\"vcpu\\": 4, + \\"memory\\": \\"400MB\\", + \\"memory_mib\\": 400, + \\"version\\": 1, + \\"image\\": \\"hello\\", + \\"location\\": { + \\"name\\": \\"sfo06\\", + \\"enabled\\": true + }, + \\"network\\": { + \\"mode\\": \\"public\\", + \\"ipv4\\": \\"1.1.1.1\\" + }, + \\"placements_ref\\": \\"http://ref\\", + \\"node_group\\": \\"metal\\" + }" + `); }); it("can't modify deployment due to lack of deploymentId (json)", async () => { @@ -134,8 +160,11 @@ describe("cloudchamber modify", () => { // so testing the actual UI will be harder than expected // TODO: think better on how to test UI actions expect(std.out).toMatchInlineSnapshot(` - " - If you think this is a bug then please create an issue at https://github.com/cloudflare/workers-sdk/issues/new/choose" - `); + " + ⛅️ wrangler x.x.x + ────────────────── + + If you think this is a bug then please create an issue at https://github.com/cloudflare/workers-sdk/issues/new/choose" + `); }); }); diff --git a/packages/wrangler/src/__tests__/containers/delete.test.ts b/packages/wrangler/src/__tests__/containers/delete.test.ts index 2c6175d3aed9..7ec1aa9a929e 100644 --- a/packages/wrangler/src/__tests__/containers/delete.test.ts +++ b/packages/wrangler/src/__tests__/containers/delete.test.ts @@ -28,12 +28,12 @@ describe("containers delete", () => { await runWrangler("containers delete --help"); expect(std.err).toMatchInlineSnapshot(`""`); expect(std.out).toMatchInlineSnapshot(` - "wrangler containers delete ID + "wrangler containers delete - Delete a container + Delete a container [open beta] POSITIONALS - ID id of the containers to delete [string] [required] + ID ID of the container to delete [string] [required] GLOBAL FLAGS -c, --config Path to Wrangler configuration file [string] diff --git a/packages/wrangler/src/__tests__/containers/info.test.ts b/packages/wrangler/src/__tests__/containers/info.test.ts index 990993222536..e98d10c05b51 100644 --- a/packages/wrangler/src/__tests__/containers/info.test.ts +++ b/packages/wrangler/src/__tests__/containers/info.test.ts @@ -28,12 +28,12 @@ describe("containers info", () => { await runWrangler("containers info --help"); expect(std.err).toMatchInlineSnapshot(`""`); expect(std.out).toMatchInlineSnapshot(` - "wrangler containers info ID + "wrangler containers info - Get information about a specific container + Get information about a specific container [open beta] POSITIONALS - ID id of the containers to view [string] [required] + ID ID of the container to view [string] [required] GLOBAL FLAGS -c, --config Path to Wrangler configuration file [string] @@ -75,7 +75,12 @@ describe("containers info", () => { ); expect(std.err).toMatchInlineSnapshot(`""`); await runWrangler("containers info asdf"); - expect(std.out).toMatchInlineSnapshot(`"{}"`); + expect(std.out).toMatchInlineSnapshot(` + " + ⛅️ wrangler x.x.x + ────────────────── + {}" + `); }); it("should error when not given an ID", async () => { diff --git a/packages/wrangler/src/__tests__/containers/list.test.ts b/packages/wrangler/src/__tests__/containers/list.test.ts index 25e92ffe4e62..a349a89966f0 100644 --- a/packages/wrangler/src/__tests__/containers/list.test.ts +++ b/packages/wrangler/src/__tests__/containers/list.test.ts @@ -28,7 +28,7 @@ describe("containers list", () => { expect(std.out).toMatchInlineSnapshot(` "wrangler containers list - List containers + List containers [open beta] GLOBAL FLAGS -c, --config Path to Wrangler configuration file [string] @@ -58,7 +58,10 @@ describe("containers list", () => { expect(std.err).toMatchInlineSnapshot(`""`); await runWrangler("containers list"); expect(std.out).toMatchInlineSnapshot(` - "[ + " + ⛅️ wrangler x.x.x + ────────────────── + [ { \\"id\\": \\"asdf-2\\", \\"created_at\\": \\"123\\", diff --git a/packages/wrangler/src/__tests__/containers/push.test.ts b/packages/wrangler/src/__tests__/containers/push.test.ts index dbe63ddc6abd..2719c45d2e39 100644 --- a/packages/wrangler/src/__tests__/containers/push.test.ts +++ b/packages/wrangler/src/__tests__/containers/push.test.ts @@ -36,12 +36,12 @@ describe("containers push", () => { await runWrangler("containers push --help"); expect(std.err).toMatchInlineSnapshot(`""`); expect(std.out).toMatchInlineSnapshot(` - "wrangler containers push TAG + "wrangler containers push - Push a tagged image to a Cloudflare managed registry + Push a local image to the Cloudflare managed registry [open beta] POSITIONALS - TAG [string] [required] + TAG The tag of the local image to push [string] [required] GLOBAL FLAGS -c, --config Path to Wrangler configuration file [string] diff --git a/packages/wrangler/src/__tests__/containers/registries.test.ts b/packages/wrangler/src/__tests__/containers/registries.test.ts index 6711eb585d7b..dd624803eb1b 100644 --- a/packages/wrangler/src/__tests__/containers/registries.test.ts +++ b/packages/wrangler/src/__tests__/containers/registries.test.ts @@ -37,12 +37,12 @@ describe("containers registries configure", () => { 📦 Manage Containers [open beta] COMMANDS - wrangler containers build PATH Build a container image - wrangler containers push TAG Push a tagged image to a Cloudflare managed registry - wrangler containers images Perform operations on images in your Cloudflare managed registry - wrangler containers info ID Get information about a specific container - wrangler containers list List containers - wrangler containers delete ID Delete a container + wrangler containers list List containers [open beta] + wrangler containers info Get information about a specific container [open beta] + wrangler containers delete Delete a container [open beta] + wrangler containers build Build a container image [open beta] + wrangler containers push Push a local image to the Cloudflare managed registry [open beta] + wrangler containers images Manage images in the Cloudflare managed registry [open beta] GLOBAL FLAGS -c, --config Path to Wrangler configuration file [string] @@ -434,7 +434,10 @@ describe("containers registries list", () => { mockListRegistries(mockRegistries); await runWrangler("containers registries list --json"); expect(std.out).toMatchInlineSnapshot(` - "[ + " + ⛅️ wrangler x.x.x + ────────────────── + [ { \\"domain\\": \\"123456789012.dkr.ecr.us-west-2.amazonaws.com\\" } @@ -501,7 +504,10 @@ describe("containers registries delete", () => { " `); expect(std.out).toMatchInlineSnapshot(` - "? Are you sure you want to delete the registry credentials for 123456789012.dkr.ecr.us-west-2.amazonaws.com? This action cannot be undone. + " + ⛅️ wrangler x.x.x + ────────────────── + ? Are you sure you want to delete the registry credentials for 123456789012.dkr.ecr.us-west-2.amazonaws.com? This action cannot be undone. 🤖 Using fallback value in non-interactive context: yes" `); }); diff --git a/packages/wrangler/src/__tests__/containers/ssh.test.ts b/packages/wrangler/src/__tests__/containers/ssh.test.ts index 8b069a35ed9a..b4d8441d358d 100644 --- a/packages/wrangler/src/__tests__/containers/ssh.test.ts +++ b/packages/wrangler/src/__tests__/containers/ssh.test.ts @@ -24,7 +24,7 @@ describe("containers ssh", () => { await runWrangler("containers ssh --help"); expect(std.err).toMatchInlineSnapshot(`""`); expect(std.out).toMatchInlineSnapshot(` - "wrangler containers ssh ID + "wrangler containers ssh POSITIONALS ID ID of the container instance [string] [required] @@ -40,7 +40,7 @@ describe("containers ssh", () => { OPTIONS --cipher Sets \`ssh -c\`: Select the cipher specification for encrypting the session [string] --log-file Sets \`ssh -E\`: Append debug logs to log_file instead of standard error [string] - --escape-char Sets \`ssh -e\`: Set the escape character for sessions with a pty (default: ‘~’) [string] + --escape-char Sets \`ssh -e\`: Set the escape character for sessions with a pty (default: '~') [string] -F, --config-file Sets \`ssh -F\`: Specify an alternative per-user ssh configuration file [string] --pkcs11 Sets \`ssh -I\`: Specify the PKCS#11 shared library ssh should use to communicate with a PKCS#11 token providing keys for user authentication [string] -i, --identity-file Sets \`ssh -i\`: Select a file from which the identity (private key) for public key authentication is read [string] diff --git a/packages/wrangler/src/cloudchamber/apply.ts b/packages/wrangler/src/cloudchamber/apply.ts index 132b805a15be..0a64cb07e7b3 100644 --- a/packages/wrangler/src/cloudchamber/apply.ts +++ b/packages/wrangler/src/cloudchamber/apply.ts @@ -29,13 +29,18 @@ import { UserError, } from "@cloudflare/workers-utils"; import { configRolloutStepsToAPI } from "../containers/deploy"; +import { createCommand } from "../core/create-command"; import { getAccountId } from "../user"; import { Diff } from "../utils/diff"; import { sortObjectRecursive, stripUndefined, } from "../utils/sortObjectRecursive"; -import { promiseSpinner } from "./common"; +import { + cloudchamberScope, + fillOpenAPIConfiguration, + promiseSpinner, +} from "./common"; import { cleanForInstanceType } from "./instance-type/instance-type"; import type { CommonYargsArgv, @@ -667,3 +672,25 @@ export async function applyCommand( config ); } + +// --- New defineCommand-based command --- + +export const cloudchamberApplyCommand = createCommand({ + metadata: { + description: "Apply the changes in the container applications to deploy", + status: "alpha", + owner: "Product: Cloudchamber", + }, + args: { + "skip-defaults": { + requiresArg: true, + type: "boolean", + demandOption: false, + describe: "Skips recommended defaults added by apply", + }, + }, + async handler(args, { config }) { + await fillOpenAPIConfiguration(config, cloudchamberScope); + await applyCommand(args, config); + }, +}); diff --git a/packages/wrangler/src/cloudchamber/build.ts b/packages/wrangler/src/cloudchamber/build.ts index b55569d491e3..0668726937af 100644 --- a/packages/wrangler/src/cloudchamber/build.ts +++ b/packages/wrangler/src/cloudchamber/build.ts @@ -16,8 +16,10 @@ import { getDockerPath, UserError, } from "@cloudflare/workers-utils"; +import { createCommand } from "../core/create-command"; import { logger } from "../logger"; import { getAccountId } from "../user"; +import { cloudchamberScope, fillOpenAPIConfiguration } from "./common"; import { ensureContainerLimits } from "./limits"; import { loadAccount } from "./locations"; import type { @@ -327,3 +329,78 @@ async function checkImagePlatform( ); } } + +// --- New createCommand-based commands --- + +export const cloudchamberBuildCommand = createCommand({ + metadata: { + description: "Build a container image", + status: "alpha", + owner: "Product: Cloudchamber", + }, + args: { + PATH: { + type: "string", + describe: "Path for the directory containing the Dockerfile to build", + demandOption: true, + }, + tag: { + alias: "t", + type: "string", + demandOption: true, + describe: 'Name and optionally a tag (format: "name:tag")', + }, + "path-to-docker": { + type: "string", + default: "docker", + describe: "Path to your docker binary if it's not on $PATH", + demandOption: false, + }, + push: { + alias: "p", + type: "boolean", + describe: "Push the built image to Cloudflare's managed registry", + default: false, + }, + platform: { + type: "string", + default: "linux/amd64", + describe: + "Platform to build for. Defaults to the architecture support by Workers (linux/amd64)", + demandOption: false, + hidden: true, + deprecated: true, + }, + }, + positionalArgs: ["PATH"], + async handler(args, { config }) { + await fillOpenAPIConfiguration(config, cloudchamberScope); + await buildCommand(args); + }, +}); + +export const cloudchamberPushCommand = createCommand({ + metadata: { + description: "Push a local image to the Cloudflare managed registry", + status: "alpha", + owner: "Product: Cloudchamber", + }, + args: { + TAG: { + type: "string", + demandOption: true, + describe: "The tag of the local image to push", + }, + "path-to-docker": { + type: "string", + default: "docker", + describe: "Path to your docker binary if it's not on $PATH", + demandOption: false, + }, + }, + positionalArgs: ["TAG"], + async handler(args, { config }) { + await fillOpenAPIConfiguration(config, cloudchamberScope); + await pushCommand(args, config); + }, +}); diff --git a/packages/wrangler/src/cloudchamber/create.ts b/packages/wrangler/src/cloudchamber/create.ts index 0b14cfaf323c..3f5070c1c5e9 100644 --- a/packages/wrangler/src/cloudchamber/create.ts +++ b/packages/wrangler/src/cloudchamber/create.ts @@ -14,14 +14,17 @@ import { DeploymentsService, } from "@cloudflare/containers-shared"; import { parseByteSize } from "@cloudflare/workers-utils"; +import { createCommand as defineCommand } from "../core/create-command"; import { isNonInteractiveOrCI } from "../is-interactive"; import { logger } from "../logger"; import { pollSSHKeysUntilCondition, waitForPlacement } from "./cli"; import { getLocation } from "./cli/locations"; import { checkEverythingIsSet, + cloudchamberScope, collectEnvironmentVariables, collectLabels, + fillOpenAPIConfiguration, parseImageName, promptForEnvironmentVariables, promptForLabels, @@ -386,3 +389,93 @@ async function handleCreateCommand( } const whichImageQuestion = "Which image should we use for your container?"; + +// --- New defineCommand-based command --- + +export const cloudchamberCreateCommand = defineCommand({ + metadata: { + description: "Create a new deployment", + status: "alpha", + owner: "Product: Cloudchamber", + }, + args: { + image: { + requiresArg: true, + type: "string", + demandOption: false, + describe: "Image to use for your deployment", + }, + location: { + requiresArg: true, + type: "string", + demandOption: false, + describe: + "Location on Cloudflare's network where your deployment will run", + }, + var: { + requiresArg: true, + type: "string", + array: true, + demandOption: false, + describe: "Container environment variables", + coerce: (arg: unknown[]) => arg.map((a) => a?.toString() ?? ""), + }, + label: { + requiresArg: true, + type: "array", + demandOption: false, + describe: "Deployment labels", + coerce: (arg: unknown[]) => arg.map((a) => a?.toString() ?? ""), + }, + "all-ssh-keys": { + requiresArg: false, + type: "boolean", + demandOption: false, + describe: + "To add all SSH keys configured on your account to be added to this deployment, set this option to true", + }, + "ssh-key-id": { + requiresArg: false, + type: "string", + array: true, + demandOption: false, + describe: "ID of the SSH key to add to the deployment", + }, + "instance-type": { + requiresArg: true, + choices: [ + "lite", + "basic", + "standard-1", + "standard-2", + "standard-3", + "standard-4", + ] as const, + demandOption: false, + describe: "Instance type to allocate to this deployment", + }, + vcpu: { + requiresArg: true, + type: "number", + demandOption: false, + describe: "Number of vCPUs to allocate to this deployment.", + }, + memory: { + requiresArg: true, + type: "string", + demandOption: false, + describe: + "Amount of memory (GiB, MiB...) to allocate to this deployment. Ex: 4GiB.", + }, + ipv4: { + requiresArg: false, + type: "boolean", + demandOption: false, + describe: "Include an IPv4 in the deployment", + }, + }, + async handler(args, { config }) { + await fillOpenAPIConfiguration(config, cloudchamberScope); + await createCommand(args, config); + }, +}); diff --git a/packages/wrangler/src/cloudchamber/curl.ts b/packages/wrangler/src/cloudchamber/curl.ts index 1509d5c38cc3..11fca24122b5 100644 --- a/packages/wrangler/src/cloudchamber/curl.ts +++ b/packages/wrangler/src/cloudchamber/curl.ts @@ -3,7 +3,9 @@ import { logRaw } from "@cloudflare/cli"; import { bold, brandColor, cyanBright, yellow } from "@cloudflare/cli/colors"; import { ApiError, OpenAPI } from "@cloudflare/containers-shared"; import { request } from "@cloudflare/containers-shared/src/client/core/request"; +import { createCommand } from "../core/create-command"; import formatLabelledValues from "../utils/render-labelled-values"; +import { cloudchamberScope, fillOpenAPIConfiguration } from "./common"; import type { CommonYargsOptions, StrictYargsOptionsToInterface, @@ -163,3 +165,60 @@ async function requestFromCmd( } } } + +// --- New defineCommand-based command --- + +export const cloudchamberCurlCommand = createCommand({ + metadata: { + description: "Send a request to an arbitrary Cloudchamber endpoint", + status: "alpha", + owner: "Product: Cloudchamber", + }, + args: { + path: { + type: "string", + default: "/", + demandOption: true, + }, + header: { + type: "array", + alias: "H", + describe: "Add headers in the form of --header :", + }, + data: { + type: "string", + describe: "Add a JSON body to the request", + alias: "d", + }, + "data-deprecated": { + type: "string", + hidden: true, + alias: "D", + }, + method: { + type: "string", + alias: "X", + default: "GET", + }, + silent: { + describe: "Only output response", + type: "boolean", + alias: "s", + }, + verbose: { + describe: "Print everything, like request id, or headers", + type: "boolean", + alias: "v", + }, + "use-stdin": { + describe: "Equivalent of using --data-binary @- in curl", + type: "boolean", + alias: "stdin", + }, + }, + positionalArgs: ["path"], + async handler(args, { config }) { + await fillOpenAPIConfiguration(config, cloudchamberScope); + await curlCommand(args, config); + }, +}); diff --git a/packages/wrangler/src/cloudchamber/delete.ts b/packages/wrangler/src/cloudchamber/delete.ts index e18430d4e893..d4319ad667df 100644 --- a/packages/wrangler/src/cloudchamber/delete.ts +++ b/packages/wrangler/src/cloudchamber/delete.ts @@ -2,9 +2,11 @@ import { cancel, endSection, startSection } from "@cloudflare/cli"; import { inputPrompt } from "@cloudflare/cli/interactive"; import { DeploymentsService } from "@cloudflare/containers-shared"; import { UserError } from "@cloudflare/workers-utils"; +import { createCommand } from "../core/create-command"; import { isNonInteractiveOrCI } from "../is-interactive"; import { logger } from "../logger"; import { logDeployment, pickDeployment } from "./cli/deployments"; +import { cloudchamberScope, fillOpenAPIConfiguration } from "./common"; import { wrap } from "./helpers/wrap"; import type { CommonYargsArgv, @@ -68,3 +70,26 @@ async function handleDeleteCommand( } endSection("Your container has been deleted"); } + +// --- New defineCommand-based command --- + +export const cloudchamberDeleteCommand = createCommand({ + metadata: { + description: + "Delete an existing deployment that is running in the Cloudflare edge", + status: "alpha", + owner: "Product: Cloudchamber", + }, + args: { + deploymentId: { + type: "string", + demandOption: false, + describe: "Deployment you want to delete", + }, + }, + positionalArgs: ["deploymentId"], + async handler(args, { config }) { + await fillOpenAPIConfiguration(config, cloudchamberScope); + await deleteCommand(args, config); + }, +}); diff --git a/packages/wrangler/src/cloudchamber/images/images.ts b/packages/wrangler/src/cloudchamber/images/images.ts index a67361ab4b8e..bbc3412e1789 100644 --- a/packages/wrangler/src/cloudchamber/images/images.ts +++ b/packages/wrangler/src/cloudchamber/images/images.ts @@ -3,16 +3,21 @@ import { ImageRegistriesService, } from "@cloudflare/containers-shared"; import { fetch } from "undici"; +import { createCommand, createNamespace } from "../../core/create-command"; import { logger } from "../../logger"; import { getAccountId } from "../../user"; -import { handleFailure, promiseSpinner } from "../common"; +import { + cloudchamberScope, + fillOpenAPIConfiguration, + handleFailure, + promiseSpinner, +} from "../common"; import type { containersScope } from "../../containers"; import type { CommonYargsArgv, CommonYargsArgvSanitized, StrictYargsOptionsToInterface, } from "../../yargs-types"; -import type { cloudchamberScope } from "../common"; import type { ImageRegistryPermissions } from "@cloudflare/containers-shared"; import type { Config } from "@cloudflare/workers-utils"; @@ -267,3 +272,56 @@ async function getCreds(): Promise { return Buffer.from(`v1:${credentials.password}`).toString("base64"); } + +// --- New createCommand-based commands --- + +export const cloudchamberImagesNamespace = createNamespace({ + metadata: { + description: "Manage images in the Cloudflare managed registry", + status: "alpha", + owner: "Product: Cloudchamber", + }, +}); + +export const cloudchamberImagesListCommand = createCommand({ + metadata: { + description: "List images in the Cloudflare managed registry", + status: "alpha", + owner: "Product: Cloudchamber", + }, + args: { + filter: { + type: "string", + description: "Regex to filter results", + }, + json: { + type: "boolean", + description: "Format output as JSON", + default: false, + }, + }, + async handler(args, { config }) { + await fillOpenAPIConfiguration(config, cloudchamberScope); + await handleListImagesCommand(args, config); + }, +}); + +export const cloudchamberImagesDeleteCommand = createCommand({ + metadata: { + description: "Remove an image from the Cloudflare managed registry", + status: "alpha", + owner: "Product: Cloudchamber", + }, + args: { + image: { + type: "string", + description: "Image and tag to delete, of the form IMAGE:TAG", + demandOption: true, + }, + }, + positionalArgs: ["image"], + async handler(args, { config }) { + await fillOpenAPIConfiguration(config, cloudchamberScope); + await handleDeleteImageCommand(args, config); + }, +}); diff --git a/packages/wrangler/src/cloudchamber/images/registries.ts b/packages/wrangler/src/cloudchamber/images/registries.ts index 1eddc9700bfd..11d139a4e45b 100644 --- a/packages/wrangler/src/cloudchamber/images/registries.ts +++ b/packages/wrangler/src/cloudchamber/images/registries.ts @@ -13,22 +13,26 @@ import { ImageRegistryNotAllowedError, } from "@cloudflare/containers-shared"; import { UserError } from "@cloudflare/workers-utils"; +import { createCommand, createNamespace } from "../../core/create-command"; import { isNonInteractiveOrCI } from "../../is-interactive"; import { logger } from "../../logger"; import { pollRegistriesUntilCondition } from "../cli"; -import { checkEverythingIsSet, handleFailure, promiseSpinner } from "../common"; +import { + checkEverythingIsSet, + cloudchamberScope, + fillOpenAPIConfiguration, + promiseSpinner, +} from "../common"; import { wrap } from "../helpers/wrap"; -import type { containersScope } from "../../containers"; import type { CommonYargsArgv, CommonYargsArgvSanitized, StrictYargsOptionsToInterface, } from "../../yargs-types"; -import type { cloudchamberScope } from "../common"; import type { ImageRegistryPermissions } from "@cloudflare/containers-shared"; import type { Config } from "@cloudflare/workers-utils"; -function configureImageRegistryOptionalYargs(yargs: CommonYargsArgv) { +function _configureImageRegistryOptionalYargs(yargs: CommonYargsArgv) { return yargs .option("domain", { description: @@ -42,140 +46,74 @@ function configureImageRegistryOptionalYargs(yargs: CommonYargsArgv) { }); } -export const registriesCommand = ( - yargs: CommonYargsArgv, - scope: typeof containersScope | typeof cloudchamberScope -) => { - return yargs - .command( - "configure", - "Configure Cloudchamber to pull from specific registries", - (args) => configureImageRegistryOptionalYargs(args), - (args) => - handleFailure( - `wrangler cloudchamber registries configure`, - async ( - imageArgs: StrictYargsOptionsToInterface< - typeof configureImageRegistryOptionalYargs - >, - config - ) => { - // check we are in CI or if the user wants to just use JSON - if (isNonInteractiveOrCI()) { - const body = checkEverythingIsSet(imageArgs, [ - "domain", - "public", - ]); - const registry = await ImageRegistriesService.createImageRegistry( - { - domain: body.domain, - is_public: body.public, - } - ); - logger.log(JSON.stringify(registry, null, 4)); - return; - } +async function registriesConfigureHandler( + imageArgs: StrictYargsOptionsToInterface< + typeof _configureImageRegistryOptionalYargs + >, + config: Config +) { + // check we are in CI or if the user wants to just use JSON + if (isNonInteractiveOrCI()) { + const body = checkEverythingIsSet(imageArgs, ["domain", "public"]); + const registry = await ImageRegistriesService.createImageRegistry({ + domain: body.domain, + is_public: body.public, + }); + logger.log(JSON.stringify(registry, null, 4)); + return; + } - await handleConfigureImageRegistryCommand(args, config); - }, - scope - )(args) - ) - .command( - "credentials [domain]", - "get a temporary password for a specific domain", - (args) => - args - .positional("domain", { - type: "string", - demandOption: true, - }) - .option("expiration-minutes", { - type: "number", - default: 15, - }) - .option("push", { - type: "boolean", - description: "If you want these credentials to be able to push", - }) - .option("pull", { - type: "boolean", - description: "If you want these credentials to be able to pull", - }), - (args) => { - // we don't want any kind of spinners - args.json = true; - return handleFailure( - `wrangler cloudchamber registries credentials`, - async (imageArgs: typeof args, _config) => { - if (!imageArgs.pull && !imageArgs.push) { - throw new UserError( - "You have to specify either --push or --pull in the command." - ); - } + await handleConfigureImageRegistryCommand(imageArgs, config); +} - const credentials = - await ImageRegistriesService.generateImageRegistryCredentials( - imageArgs.domain, - { - expiration_minutes: imageArgs.expirationMinutes, - permissions: [ - ...(imageArgs.push ? ["push"] : []), - ...(imageArgs.pull ? ["pull"] : []), - ] as ImageRegistryPermissions[], - } - ); - logger.log(credentials.password); - }, - scope - )(args); - } - ) - .command( - "remove [domain]", - "removes the registry at the given domain", - (args) => removeImageRegistryYargs(args), - (args) => { - args.json = true; - return handleFailure( - `wrangler cloudchamber registries remove`, - async ( - imageArgs: StrictYargsOptionsToInterface< - typeof removeImageRegistryYargs - >, - _config - ) => { - const registry = await ImageRegistriesService.deleteImageRegistry( - imageArgs.domain - ); - logger.log(JSON.stringify(registry, null, 4)); - }, - scope - )(args); +async function registriesCredentialsHandler(imageArgs: { + domain: string; + expirationMinutes: number; + push?: boolean; + pull?: boolean; +}) { + if (!imageArgs.pull && !imageArgs.push) { + throw new UserError( + "You have to specify either --push or --pull in the command." + ); + } + + const credentials = + await ImageRegistriesService.generateImageRegistryCredentials( + imageArgs.domain, + { + expiration_minutes: imageArgs.expirationMinutes, + permissions: [ + ...(imageArgs.push ? ["push"] : []), + ...(imageArgs.pull ? ["pull"] : []), + ] as ImageRegistryPermissions[], } - ) - .command( - "list", - "list registries configured for this account", - (args) => args, - (args) => - handleFailure( - `wrangler cloudchamber registries list`, - async (_: CommonYargsArgvSanitized, config) => { - if (isNonInteractiveOrCI()) { - const registries = - await ImageRegistriesService.listImageRegistries(); - logger.log(JSON.stringify(registries, null, 4)); - return; - } - await handleListImageRegistriesCommand(args, config); - }, - scope - )(args) ); -}; + logger.log(credentials.password); +} + +async function registriesRemoveHandler( + imageArgs: StrictYargsOptionsToInterface +) { + const registry = await ImageRegistriesService.deleteImageRegistry( + imageArgs.domain + ); + logger.log(JSON.stringify(registry, null, 4)); +} -function removeImageRegistryYargs(yargs: CommonYargsArgv) { +async function registriesListHandler( + _args: CommonYargsArgvSanitized, + config: Config +) { + if (isNonInteractiveOrCI()) { + const registries = await ImageRegistriesService.listImageRegistries(); + logger.log(JSON.stringify(registries, null, 4)); + return; + } + await handleListImageRegistriesCommand(_args, config); +} + +function _removeImageRegistryYargs(yargs: CommonYargsArgv) { return yargs.positional("domain", { type: "string", demandOption: true, @@ -219,7 +157,7 @@ async function handleListImageRegistriesCommand( async function handleConfigureImageRegistryCommand( args: StrictYargsOptionsToInterface< - typeof configureImageRegistryOptionalYargs + typeof _configureImageRegistryOptionalYargs >, _config: Config ) { @@ -281,3 +219,100 @@ async function handleConfigureImageRegistryCommand( registry?.public_key ); } + +// --- New defineCommand-based commands --- + +export const cloudchamberRegistriesNamespace = createNamespace({ + metadata: { + description: "Configure registries via Cloudchamber", + status: "alpha", + owner: "Product: Cloudchamber", + }, +}); + +export const cloudchamberRegistriesConfigureCommand = createCommand({ + metadata: { + description: "Configure Cloudchamber to pull from specific registries", + status: "alpha", + owner: "Product: Cloudchamber", + }, + args: { + domain: { + description: + "Domain of your registry. Don't include the proto part of the URL, like 'http://'", + type: "string", + }, + public: { + description: + "If the registry is public and you don't want credentials configured, set this to true", + type: "boolean", + }, + }, + async handler(args, { config }) { + await fillOpenAPIConfiguration(config, cloudchamberScope); + await registriesConfigureHandler(args, config); + }, +}); + +export const cloudchamberRegistriesCredentialsCommand = createCommand({ + metadata: { + description: "Get a temporary password for a specific domain", + status: "alpha", + owner: "Product: Cloudchamber", + }, + args: { + domain: { + type: "string", + demandOption: true, + }, + "expiration-minutes": { + type: "number", + default: 15, + }, + push: { + type: "boolean", + description: "If you want these credentials to be able to push", + }, + pull: { + type: "boolean", + description: "If you want these credentials to be able to pull", + }, + }, + positionalArgs: ["domain"], + async handler(args, { config }) { + await fillOpenAPIConfiguration(config, cloudchamberScope); + await registriesCredentialsHandler(args); + }, +}); + +export const cloudchamberRegistriesRemoveCommand = createCommand({ + metadata: { + description: "Remove the registry at the given domain", + status: "alpha", + owner: "Product: Cloudchamber", + }, + args: { + domain: { + type: "string", + demandOption: true, + }, + }, + positionalArgs: ["domain"], + async handler(args, { config }) { + await fillOpenAPIConfiguration(config, cloudchamberScope); + await registriesRemoveHandler(args); + }, +}); + +export const cloudchamberRegistriesListCommand = createCommand({ + metadata: { + description: "List registries configured for this account", + status: "alpha", + owner: "Product: Cloudchamber", + }, + args: {}, + async handler(args, { config }) { + await fillOpenAPIConfiguration(config, cloudchamberScope); + await registriesListHandler(args, config); + }, +}); diff --git a/packages/wrangler/src/cloudchamber/index.ts b/packages/wrangler/src/cloudchamber/index.ts index 363e64a1af53..119a31bdb218 100644 --- a/packages/wrangler/src/cloudchamber/index.ts +++ b/packages/wrangler/src/cloudchamber/index.ts @@ -1,131 +1,45 @@ -import { applyCommand, applyCommandOptionalYargs } from "./apply"; -import { buildCommand, buildYargs, pushCommand, pushYargs } from "./build"; -import { cloudchamberScope, handleFailure } from "./common"; -import { createCommand, createCommandOptionalYargs } from "./create"; -import { curlCommand, yargsCurl } from "./curl"; -import { deleteCommand, deleteCommandOptionalYargs } from "./delete"; -import { imagesCommand } from "./images/images"; -import { registriesCommand } from "./images/registries"; -import { listCommand, listDeploymentsYargs } from "./list"; -import { modifyCommand, modifyCommandOptionalYargs } from "./modify"; -import { sshCommand } from "./ssh/ssh"; -import type { CommonYargsArgv, CommonYargsOptions } from "../yargs-types"; -import type { CommandModule } from "yargs"; +import { createNamespace } from "../core/create-command"; -function internalCommands(args: CommonYargsArgv) { - try { - // Add dynamically an internal module that we can attach internal commands - // eslint-disable-next-line @typescript-eslint/no-require-imports - const cloudchamberInternalRequireEntry = require("./internal/index"); - return cloudchamberInternalRequireEntry.internalCommands(args); - } catch { - return args; - } -} +// --- Namespace definition --- +export const cloudchamberNamespace = createNamespace({ + metadata: { + description: "Manage Cloudchamber", + status: "alpha", + owner: "Product: Cloudchamber", + hidden: true, + }, +}); -export const cloudchamber = ( - yargs: CommonYargsArgv, - subHelp: CommandModule -) => { - yargs = internalCommands(yargs); - return yargs - .command( - "delete [deploymentId]", - "Delete an existing deployment that is running in the Cloudflare edge", - (args) => deleteCommandOptionalYargs(args), - (args) => - handleFailure( - `wrangler cloudchamber delete`, - deleteCommand, - cloudchamberScope - )(args) - ) - .command( - "create", - "Create a new deployment", - (args) => createCommandOptionalYargs(args), - (args) => - handleFailure( - `wrangler cloudchamber create`, - createCommand, - cloudchamberScope - )(args) - ) - .command( - "list [deploymentIdPrefix]", - "List and view status of deployments", - (args) => listDeploymentsYargs(args), - (args) => - handleFailure( - `wrangler cloudchamber list`, - listCommand, - cloudchamberScope - )(args) - ) - .command( - "modify [deploymentId]", - "Modify an existing deployment", - (args) => modifyCommandOptionalYargs(args), - (args) => - handleFailure( - `wrangler cloudchamber modify`, - modifyCommand, - cloudchamberScope - )(args) - ) - .command("ssh", "Manage the ssh keys of your account", (args) => - sshCommand(args, cloudchamberScope).command(subHelp) - ) - .command("registries", "Configure registries via Cloudchamber", (args) => - registriesCommand(args, cloudchamberScope).command(subHelp) - ) - .command( - "curl ", - "Send a request to an arbitrary Cloudchamber endpoint", - (args) => yargsCurl(args), - (args) => - handleFailure( - `wrangler cloudchamber curl`, - curlCommand, - cloudchamberScope - )(args) - ) - .command( - "apply", - "Apply the changes in the container applications to deploy", - (args) => applyCommandOptionalYargs(args), - (args) => - handleFailure( - `wrangler cloudchamber apply`, - applyCommand, - cloudchamberScope - )(args) - ) - .command( - "build [PATH]", - "Build a container image", - (args) => buildYargs(args), - (args) => - handleFailure( - `wrangler cloudchamber build`, - buildCommand, - cloudchamberScope - )(args) - ) - .command( - "push [TAG]", - "Push a tagged image to a Cloudflare managed registry", - (args) => pushYargs(args), - (args) => - handleFailure( - `wrangler cloudchamber push`, - pushCommand, - cloudchamberScope - )(args) - ) - .command( - "images", - "Perform operations on images in your Cloudchamber registry", - (args) => imagesCommand(args, cloudchamberScope).command(subHelp) - ); -}; +// --- Re-export commands from their respective files --- +export { cloudchamberListCommand } from "./list"; +export { cloudchamberCreateCommand } from "./create"; +export { cloudchamberDeleteCommand } from "./delete"; +export { cloudchamberModifyCommand } from "./modify"; +export { cloudchamberApplyCommand } from "./apply"; +export { cloudchamberCurlCommand } from "./curl"; + +// Build and push commands +export { cloudchamberBuildCommand, cloudchamberPushCommand } from "./build"; + +// SSH subcommands +export { + cloudchamberSshNamespace, + cloudchamberSshListCommand, + cloudchamberSshCreateCommand, +} from "./ssh/ssh"; + +// Registries subcommands +export { + cloudchamberRegistriesNamespace, + cloudchamberRegistriesConfigureCommand, + cloudchamberRegistriesCredentialsCommand, + cloudchamberRegistriesRemoveCommand, + cloudchamberRegistriesListCommand, +} from "./images/registries"; + +// Images subcommands +export { + cloudchamberImagesNamespace, + cloudchamberImagesListCommand, + cloudchamberImagesDeleteCommand, +} from "./images/images"; diff --git a/packages/wrangler/src/cloudchamber/list.ts b/packages/wrangler/src/cloudchamber/list.ts index 95eabdf1853c..1fb3dd0874fd 100644 --- a/packages/wrangler/src/cloudchamber/list.ts +++ b/packages/wrangler/src/cloudchamber/list.ts @@ -14,12 +14,17 @@ import { DeploymentsService, PlacementsService, } from "@cloudflare/containers-shared"; +import { createCommand } from "../core/create-command"; import { isNonInteractiveOrCI } from "../is-interactive"; import { logger } from "../logger"; import { capitalize } from "../utils/strings"; import { listDeploymentsAndChoose, loadDeployments } from "./cli/deployments"; import { statusToColored } from "./cli/util"; -import { promiseSpinner } from "./common"; +import { + cloudchamberScope, + fillOpenAPIConfiguration, + promiseSpinner, +} from "./common"; import type { CommonYargsArgv, StrictYargsOptionsToInterface, @@ -187,3 +192,56 @@ const listCommandHandle = async ( stop(); } }; + +// --- New defineCommand-based command --- + +export const cloudchamberListCommand = createCommand({ + metadata: { + description: "List and view status of deployments", + status: "alpha", + owner: "Product: Cloudchamber", + }, + args: { + deploymentIdPrefix: { + describe: + "Optional deploymentId to filter deployments. This means that 'list' will only showcase deployments that contain this ID prefix", + type: "string", + }, + location: { + requiresArg: true, + type: "string", + demandOption: false, + describe: "Filter deployments by location", + }, + image: { + requiresArg: true, + type: "string", + demandOption: false, + describe: "Filter deployments by image", + }, + state: { + requiresArg: true, + type: "string", + demandOption: false, + describe: "Filter deployments by deployment state", + }, + ipv4: { + requiresArg: true, + type: "string", + demandOption: false, + describe: "Filter deployments by ipv4 address", + }, + label: { + requiresArg: true, + type: "array", + demandOption: false, + describe: "Filter deployments by labels", + coerce: (arg: unknown[]) => arg.map((a) => a?.toString() ?? ""), + }, + }, + positionalArgs: ["deploymentIdPrefix"], + async handler(args, { config }) { + await fillOpenAPIConfiguration(config, cloudchamberScope); + await listCommand(args, config); + }, +}); diff --git a/packages/wrangler/src/cloudchamber/modify.ts b/packages/wrangler/src/cloudchamber/modify.ts index 34843d9f4210..820f9cfb3edf 100644 --- a/packages/wrangler/src/cloudchamber/modify.ts +++ b/packages/wrangler/src/cloudchamber/modify.ts @@ -2,14 +2,17 @@ import { cancel, startSection } from "@cloudflare/cli"; import { processArgument } from "@cloudflare/cli/args"; import { inputPrompt, spinner } from "@cloudflare/cli/interactive"; import { DeploymentsService } from "@cloudflare/containers-shared"; +import { createCommand } from "../core/create-command"; import { isNonInteractiveOrCI } from "../is-interactive"; import { logger } from "../logger"; import { pollSSHKeysUntilCondition, waitForPlacement } from "./cli"; import { pickDeployment } from "./cli/deployments"; import { getLocation } from "./cli/locations"; import { + cloudchamberScope, collectEnvironmentVariables, collectLabels, + fillOpenAPIConfiguration, parseImageName, promptForEnvironmentVariables, promptForLabels, @@ -308,3 +311,78 @@ async function handleModifyCommand( } const modifyImageQuestion = "URL of the image to use in your deployment"; + +// --- New defineCommand-based command --- + +export const cloudchamberModifyCommand = createCommand({ + metadata: { + description: "Modify an existing deployment", + status: "alpha", + owner: "Product: Cloudchamber", + }, + args: { + deploymentId: { + type: "string", + demandOption: false, + describe: "The deployment you want to modify", + }, + var: { + requiresArg: true, + type: "array", + demandOption: false, + describe: "Container environment variables", + coerce: (arg: unknown[]) => arg.map((a) => a?.toString() ?? ""), + }, + label: { + requiresArg: true, + type: "array", + demandOption: false, + describe: "Deployment labels", + coerce: (arg: unknown[]) => arg.map((a) => a?.toString() ?? ""), + }, + "ssh-public-key-id": { + requiresArg: true, + type: "string", + array: true, + demandOption: false, + describe: + "Public SSH key IDs to include in this container. You can add one to your account with `wrangler cloudchamber ssh create", + }, + image: { + requiresArg: true, + type: "string", + demandOption: false, + describe: "The new image that the deployment will have from now on", + }, + location: { + requiresArg: true, + type: "string", + demandOption: false, + describe: "The new location that the deployment will have from now on", + }, + "instance-type": { + requiresArg: true, + choices: ["dev", "basic", "standard"] as const, + demandOption: false, + describe: + "The new instance type that the deployment will have from now on", + }, + vcpu: { + requiresArg: true, + type: "number", + demandOption: false, + describe: "The new vcpu that the deployment will have from now on", + }, + memory: { + requiresArg: true, + type: "string", + demandOption: false, + describe: "The new memory that the deployment will have from now on", + }, + }, + positionalArgs: ["deploymentId"], + async handler(args, { config }) { + await fillOpenAPIConfiguration(config, cloudchamberScope); + await modifyCommand(args, config); + }, +}); diff --git a/packages/wrangler/src/cloudchamber/ssh/ssh.ts b/packages/wrangler/src/cloudchamber/ssh/ssh.ts index 1585ecee08b4..cd9f6a535b38 100644 --- a/packages/wrangler/src/cloudchamber/ssh/ssh.ts +++ b/packages/wrangler/src/cloudchamber/ssh/ssh.ts @@ -14,19 +14,22 @@ import { brandColor, dim } from "@cloudflare/cli/colors"; import { inputPrompt, spinner } from "@cloudflare/cli/interactive"; import { SshPublicKeysService } from "@cloudflare/containers-shared"; import { UserError } from "@cloudflare/workers-utils"; +import { createCommand, createNamespace } from "../../core/create-command"; import { isNonInteractiveOrCI } from "../../is-interactive"; import { logger } from "../../logger"; import { pollSSHKeysUntilCondition } from "../cli"; -import { checkEverythingIsSet, handleFailure } from "../common"; +import { + checkEverythingIsSet, + cloudchamberScope, + fillOpenAPIConfiguration, +} from "../common"; import { wrap } from "../helpers/wrap"; import { validatePublicSSHKeyCLI, validateSSHKey } from "./validate"; -import type { containersScope } from "../../containers"; import type { CommonYargsArgv, CommonYargsArgvSanitized, StrictYargsOptionsToInterface, } from "../../yargs-types"; -import type { cloudchamberScope } from "../common"; import type { ListSSHPublicKeys, SSHPublicKeyID, @@ -34,7 +37,7 @@ import type { } from "@cloudflare/containers-shared"; import type { Config } from "@cloudflare/workers-utils"; -function createSSHPublicKeyOptionalYargs(yargs: CommonYargsArgv) { +function _createSSHPublicKeyOptionalYargs(yargs: CommonYargsArgv) { return yargs .option("name", { type: "string", @@ -105,66 +108,41 @@ export async function sshPrompts( return key || undefined; } -export const sshCommand = ( - yargs: CommonYargsArgv, - scope: typeof cloudchamberScope | typeof containersScope -) => { - return yargs - .command( - "list", - "list the ssh keys added to your account", - (args) => args, - (args) => - handleFailure( - `wrangler cloudchamber ssh list`, - async (sshArgs: CommonYargsArgvSanitized, config) => { - // check we are in CI or if the user wants to just use JSON - if (isNonInteractiveOrCI()) { - const sshKeys = await SshPublicKeysService.listSshPublicKeys(); - logger.json(sshKeys); - return; - } - - await handleListSSHKeysCommand(sshArgs, config); - }, - scope - )(args) - ) - .command( - "create", - "create an ssh key", - (args) => createSSHPublicKeyOptionalYargs(args), - (args) => - handleFailure( - `wrangler cloudchamber ssh create`, - async ( - sshArgs: StrictYargsOptionsToInterface< - typeof createSSHPublicKeyOptionalYargs - >, - _config - ) => { - // check we are in CI or if the user wants to just use JSON - if (isNonInteractiveOrCI()) { - const body = checkEverythingIsSet(sshArgs, ["publicKey", "name"]); - const sshKey = await retrieveSSHKey(body.publicKey, { - json: true, - }); - const addedSSHKey = await SshPublicKeysService.createSshPublicKey( - { - ...body, - public_key: sshKey.trim(), - } - ); - logger.json(addedSSHKey); - return; - } - - await handleCreateSSHPublicKeyCommand(sshArgs); - }, - scope - )(args) - ); -}; +async function sshListHandler( + sshArgs: CommonYargsArgvSanitized, + config: Config +) { + // check we are in CI or if the user wants to just use JSON + if (isNonInteractiveOrCI()) { + const sshKeys = await SshPublicKeysService.listSshPublicKeys(); + logger.json(sshKeys); + return; + } + + await handleListSSHKeysCommand(sshArgs, config); +} + +async function sshCreateHandler( + sshArgs: StrictYargsOptionsToInterface< + typeof _createSSHPublicKeyOptionalYargs + > +) { + // check we are in CI or if the user wants to just use JSON + if (isNonInteractiveOrCI()) { + const body = checkEverythingIsSet(sshArgs, ["publicKey", "name"]); + const sshKey = await retrieveSSHKey(body.publicKey, { + json: true, + }); + const addedSSHKey = await SshPublicKeysService.createSshPublicKey({ + ...body, + public_key: sshKey.trim(), + }); + logger.json(addedSSHKey); + return; + } + + await handleCreateSSHPublicKeyCommand(sshArgs); +} async function tryToRetrieveAllDefaultSSHKeyPaths(): Promise { const HOME = homedir(); @@ -305,7 +283,7 @@ async function handleListSSHKeysCommand(_args: unknown, _config: Config) { * */ async function handleCreateSSHPublicKeyCommand( - args: StrictYargsOptionsToInterface + args: StrictYargsOptionsToInterface ) { startSection( "Choose an ssh key to add", @@ -324,7 +302,7 @@ async function handleCreateSSHPublicKeyCommand( } async function promptForSSHKey( - args: StrictYargsOptionsToInterface + args: StrictYargsOptionsToInterface ): Promise { const { username } = userInfo(); const name = await inputPrompt({ @@ -390,3 +368,50 @@ async function promptForSSHKey( return res; } + +// --- New defineCommand-based commands --- + +export const cloudchamberSshNamespace = createNamespace({ + metadata: { + description: "Manage the ssh keys of your account", + status: "alpha", + owner: "Product: Cloudchamber", + }, +}); + +export const cloudchamberSshListCommand = createCommand({ + metadata: { + description: "List the ssh keys added to your account", + status: "alpha", + owner: "Product: Cloudchamber", + }, + args: {}, + async handler(args, { config }) { + await fillOpenAPIConfiguration(config, cloudchamberScope); + await sshListHandler(args, config); + }, +}); + +export const cloudchamberSshCreateCommand = createCommand({ + metadata: { + description: "Create an ssh key", + status: "alpha", + owner: "Product: Cloudchamber", + }, + args: { + name: { + type: "string", + describe: + "The alias to your ssh key, you can put a recognisable name for you here", + }, + "public-key": { + type: "string", + describe: + "An SSH public key, you can specify either a path or the ssh key directly here", + }, + }, + async handler(args, { config }) { + await fillOpenAPIConfiguration(config, cloudchamberScope); + await sshCreateHandler(args); + }, +}); diff --git a/packages/wrangler/src/containers/build.ts b/packages/wrangler/src/containers/build.ts index bf62d9d1a96b..2314c20e8112 100644 --- a/packages/wrangler/src/containers/build.ts +++ b/packages/wrangler/src/containers/build.ts @@ -1,11 +1,95 @@ -import { buildAndMaybePush } from "../cloudchamber/build"; +import { + buildAndMaybePush, + buildCommand, + pushCommand, +} from "../cloudchamber/build"; +import { fillOpenAPIConfiguration } from "../cloudchamber/common"; +import { createCommand } from "../core/create-command"; import { logger } from "../logger"; +import { containersScope } from "."; import type { ImageRef } from "../cloudchamber/build"; import type { ContainerNormalizedConfig, ImageURIConfig, } from "@cloudflare/containers-shared"; +// --- Command definitions --- + +export const containersBuildCommand = createCommand({ + metadata: { + description: "Build a container image", + status: "open beta", + owner: "Product: Cloudchamber", + }, + args: { + PATH: { + type: "string", + describe: "Path for the directory containing the Dockerfile to build", + demandOption: true, + }, + tag: { + alias: "t", + type: "string", + demandOption: true, + describe: 'Name and optionally a tag (format: "name:tag")', + }, + "path-to-docker": { + type: "string", + default: "docker", + describe: "Path to your docker binary if it's not on $PATH", + demandOption: false, + }, + push: { + alias: "p", + type: "boolean", + describe: "Push the built image to Cloudflare's managed registry", + default: false, + }, + platform: { + type: "string", + default: "linux/amd64", + describe: + "Platform to build for. Defaults to the architecture support by Workers (linux/amd64)", + demandOption: false, + hidden: true, + deprecated: true, + }, + }, + positionalArgs: ["PATH"], + async handler(args, { config }) { + await fillOpenAPIConfiguration(config, containersScope); + await buildCommand(args); + }, +}); + +export const containersPushCommand = createCommand({ + metadata: { + description: "Push a local image to the Cloudflare managed registry", + status: "open beta", + owner: "Product: Cloudchamber", + }, + args: { + TAG: { + type: "string", + demandOption: true, + describe: "The tag of the local image to push", + }, + "path-to-docker": { + type: "string", + default: "docker", + describe: "Path to your docker binary if it's not on $PATH", + demandOption: false, + }, + }, + positionalArgs: ["TAG"], + async handler(args, { config }) { + await fillOpenAPIConfiguration(config, containersScope); + await pushCommand(args, config); + }, +}); + +// --- Helper functions --- + export async function buildContainer( containerConfig: Exclude, /** just the tag component. will be prefixed with the container name */ diff --git a/packages/wrangler/src/containers/containers.ts b/packages/wrangler/src/containers/containers.ts index d989d0746953..f4347b00c73c 100644 --- a/packages/wrangler/src/containers/containers.ts +++ b/packages/wrangler/src/containers/containers.ts @@ -11,9 +11,12 @@ import { inputPrompt, spinner } from "@cloudflare/cli/interactive"; import { ApiError, ApplicationsService } from "@cloudflare/containers-shared"; import { UserError } from "@cloudflare/workers-utils"; import YAML from "yaml"; +import { fillOpenAPIConfiguration } from "../cloudchamber/common"; import { wrap } from "../cloudchamber/helpers/wrap"; +import { createCommand } from "../core/create-command"; import { isNonInteractiveOrCI } from "../is-interactive"; import { logger } from "../logger"; +import { containersScope } from "./index"; import type { CommonYargsArgv, StrictYargsOptionsToInterface, @@ -247,3 +250,58 @@ async function listContainersAndChoose( // eslint-disable-next-line @typescript-eslint/no-non-null-assertion return applications.find((a) => a.id === application)!; } + +// --- New defineCommand-based commands --- + +export const containersListCommand = createCommand({ + metadata: { + description: "List containers", + status: "open beta", + owner: "Product: Cloudchamber", + }, + args: {}, + async handler(args, { config }) { + await fillOpenAPIConfiguration(config, containersScope); + await listCommand(args, config); + }, +}); + +export const containersInfoCommand = createCommand({ + metadata: { + description: "Get information about a specific container", + status: "open beta", + owner: "Product: Cloudchamber", + }, + args: { + ID: { + describe: "ID of the container to view", + type: "string", + demandOption: true, + }, + }, + positionalArgs: ["ID"], + async handler(args, { config }) { + await fillOpenAPIConfiguration(config, containersScope); + await infoCommand(args, config); + }, +}); + +export const containersDeleteCommand = createCommand({ + metadata: { + description: "Delete a container", + status: "open beta", + owner: "Product: Cloudchamber", + }, + args: { + ID: { + describe: "ID of the container to delete", + type: "string", + demandOption: true, + }, + }, + positionalArgs: ["ID"], + async handler(args, { config }) { + await fillOpenAPIConfiguration(config, containersScope); + await deleteCommand(args, config); + }, +}); diff --git a/packages/wrangler/src/containers/images.ts b/packages/wrangler/src/containers/images.ts new file mode 100644 index 000000000000..ff9bd0dc40f5 --- /dev/null +++ b/packages/wrangler/src/containers/images.ts @@ -0,0 +1,270 @@ +import { + getCloudflareContainerRegistry, + ImageRegistriesService, +} from "@cloudflare/containers-shared"; +import { fetch } from "undici"; +import { + fillOpenAPIConfiguration, + promiseSpinner, +} from "../cloudchamber/common"; +import { createCommand, createNamespace } from "../core/create-command"; +import { logger } from "../logger"; +import { getAccountId } from "../user"; +import { containersScope } from "."; +import type { ImageRegistryPermissions } from "@cloudflare/containers-shared"; +import type { Config } from "@cloudflare/workers-utils"; + +interface Repository { + name: string; + tags: string[]; +} + +// --- Namespace definition --- + +export const containersImagesNamespace = createNamespace({ + metadata: { + description: "Manage images in the Cloudflare managed registry", + status: "open beta", + owner: "Product: Cloudchamber", + }, +}); + +// --- Command definitions --- + +export const containersImagesListCommand = createCommand({ + metadata: { + description: "List images in the Cloudflare managed registry", + status: "open beta", + owner: "Product: Cloudchamber", + }, + args: { + filter: { + type: "string", + description: "Regex to filter results", + }, + json: { + type: "boolean", + description: "Format output as JSON", + default: false, + }, + }, + async handler(args, { config }) { + await fillOpenAPIConfiguration(config, containersScope); + await handleListImagesCommand(args, config); + }, +}); + +export const containersImagesDeleteCommand = createCommand({ + metadata: { + description: "Remove an image from the Cloudflare managed registry", + status: "open beta", + owner: "Product: Cloudchamber", + }, + args: { + image: { + type: "string", + description: "Image and tag to delete, of the form IMAGE:TAG", + demandOption: true, + }, + }, + positionalArgs: ["image"], + async handler(args, { config }) { + await fillOpenAPIConfiguration(config, containersScope); + await handleDeleteImageCommand(args, config); + }, +}); + +// --- Handler functions --- + +async function handleDeleteImageCommand( + args: { image: string }, + config: Config +) { + if (!args.image.includes(":")) { + throw new Error("Invalid image format. Expected IMAGE:TAG"); + } + + const digest = await promiseSpinner( + getCreds().then(async (creds) => { + const accountId = await getAccountId(config); + const url = new URL(`https://${getCloudflareContainerRegistry()}`); + const baseUrl = `${url.protocol}//${url.host}`; + const [image, tag] = args.image.split(":"); + const digest_ = await deleteTag(baseUrl, accountId, image, tag, creds); + + // trigger gc + const gcUrl = `${baseUrl}/v2/gc/layers`; + const gcResponse = await fetch(gcUrl, { + method: "PUT", + headers: { + Authorization: `Basic ${creds}`, + "Content-Type": "application/json", + }, + }); + if (!gcResponse.ok) { + throw new Error( + `Failed to delete image ${args.image}: ${gcResponse.status} ${gcResponse.statusText}` + ); + } + + return digest_; + }), + { message: `Deleting ${args.image}` } + ); + + logger.log(`Deleted ${args.image} (${digest})`); +} + +async function handleListImagesCommand( + args: { filter?: string; json: boolean }, + config: Config +) { + const responses = await promiseSpinner( + getCreds().then(async (creds) => { + const repos = await listReposWithTags(creds); + const processed: Repository[] = []; + const accountId = await getAccountId(config); + const accountIdPrefix = new RegExp(`^${accountId}/`); + const filter = new RegExp(args.filter ?? ""); + for (const [repo, tags] of Object.entries(repos)) { + const stripped = repo.replace(/^\/+/, ""); + if (filter.test(stripped)) { + const name = stripped.replace(accountIdPrefix, ""); + processed.push({ name, tags }); + } + } + + return processed; + }), + { message: "Listing" } + ); + + await listImages(responses, false, args.json); +} + +async function listImages( + responses: Repository[], + digests: boolean = false, + json: boolean = false +) { + if (!digests) { + responses = responses.map((resp) => { + return { + name: resp.name, + tags: resp.tags.filter((t) => !t.startsWith("sha256")), + }; + }); + } + // Remove any repos with no tags + responses = responses.filter((resp) => { + return resp.tags !== undefined && resp.tags.length != 0; + }); + if (json) { + logger.log(JSON.stringify(responses, null, 2)); + } else { + const rows = responses.flatMap((r) => r.tags.map((t) => [r.name, t])); + const headers = ["REPOSITORY", "TAG"]; + const widths = new Array(headers.length).fill(0); + + // Find the maximum length of each column (except for the last) + for (let i = 0; i < widths.length - 1; i++) { + widths[i] = rows + .map((r) => r[i].length) + .reduce((a, b) => Math.max(a, b), headers[i].length); + } + + logger.log(headers.map((h, i) => h.padEnd(widths[i], " ")).join(" ")); + for (const row of rows) { + logger.log(row.map((v, i) => v.padEnd(widths[i], " ")).join(" ")); + } + } +} + +interface CatalogWithTagsResponse { + repositories: Record; + cursor?: string; +} + +async function listReposWithTags( + creds: string +): Promise> { + const url = new URL(`https://${getCloudflareContainerRegistry()}`); + const catalogUrl = `${url.protocol}//${url.host}/v2/_catalog?tags=true`; + + const response = await fetch(catalogUrl, { + method: "GET", + headers: { + Authorization: `Basic ${creds}`, + }, + }); + if (!response.ok) { + logger.log(JSON.stringify(response)); + throw new Error( + `Failed to fetch repository catalog: ${response.status} ${response.statusText}` + ); + } + + const data = (await response.json()) as CatalogWithTagsResponse; + + return data.repositories ?? {}; +} + +async function deleteTag( + baseUrl: string, + accountId: string, + image: string, + tag: string, + creds: string +): Promise { + const manifestAcceptHeader = + "application/vnd.oci.image.manifest.v1+json, application/vnd.docker.distribution.manifest.v2+json"; + const manifestUrl = `${baseUrl}/v2/${accountId}/${image}/manifests/${tag}`; + // grab the digest for this tag + const headResponse = await fetch(manifestUrl, { + method: "HEAD", + headers: { + Authorization: `Basic ${creds}`, + Accept: manifestAcceptHeader, + }, + }); + if (!headResponse.ok) { + throw new Error( + `Failed to retrieve info for ${image}:${tag}: ${headResponse.status} ${headResponse.statusText}` + ); + } + + const digest = headResponse.headers.get("Docker-Content-Digest"); + if (!digest) { + throw new Error(`Digest not found for ${image}:${tag}.`); + } + + const deleteUrl = `${baseUrl}/v2/${accountId}/${image}/manifests/${tag}`; + const deleteResponse = await fetch(deleteUrl, { + method: "DELETE", + headers: { + Authorization: `Basic ${creds}`, + Accept: manifestAcceptHeader, + }, + }); + + if (!deleteResponse.ok) { + throw new Error( + `Failed to delete ${image}:${tag} (digest: ${digest}): ${deleteResponse.status} ${deleteResponse.statusText}` + ); + } + + return digest; +} + +async function getCreds(): Promise { + const credentials = + await ImageRegistriesService.generateImageRegistryCredentials( + getCloudflareContainerRegistry(), + { + expiration_minutes: 5, + permissions: ["pull", "push"] as ImageRegistryPermissions[], + } + ); + + return Buffer.from(`v1:${credentials.password}`).toString("base64"); +} diff --git a/packages/wrangler/src/containers/index.ts b/packages/wrangler/src/containers/index.ts index df89e0637b4d..ba0e61975e5b 100644 --- a/packages/wrangler/src/containers/index.ts +++ b/packages/wrangler/src/containers/index.ts @@ -1,108 +1,38 @@ -import { - buildCommand, - buildYargs, - pushCommand, - pushYargs, -} from "../cloudchamber/build"; -import { handleFailure } from "../cloudchamber/common"; -import { imagesCommand } from "../cloudchamber/images/images"; -import { - deleteCommand, - deleteYargs, - infoCommand, - infoYargs, - listCommand, - listYargs, -} from "./containers"; -import { registryCommands } from "./registries"; -import { sshCommand, sshYargs } from "./ssh"; -import type { CommonYargsArgv, CommonYargsOptions } from "../yargs-types"; -import type { CommandModule } from "yargs"; +import { createNamespace } from "../core/create-command"; export const containersScope = "containers:write" as const; -export const containers = ( - yargs: CommonYargsArgv, - subHelp: CommandModule -) => { - return yargs - .command( - "build PATH", - "Build a container image", - (args) => buildYargs(args), - (args) => - handleFailure( - `wrangler containers build`, - buildCommand, - containersScope - )(args) - ) - .command( - "push TAG", - "Push a tagged image to a Cloudflare managed registry", - (args) => pushYargs(args), - (args) => - handleFailure( - `wrangler containers push`, - pushCommand, - containersScope - )(args) - ) - .command( - "images", - "Perform operations on images in your Cloudflare managed registry", - (args) => imagesCommand(args, containersScope).command(subHelp) - ) - .command( - "info ID", - "Get information about a specific container", - (args) => infoYargs(args), - (args) => - handleFailure( - `wrangler containers info`, - infoCommand, - containersScope - )(args) - ) - .command( - "list", - "List containers", - (args) => listYargs(args), - (args) => - handleFailure( - `wrangler containers list`, - listCommand, - containersScope - )(args) - ) - .command( - "delete ID", - "Delete a container", - (args) => deleteYargs(args), - (args) => - handleFailure( - `wrangler containers delete`, - deleteCommand, - containersScope - )(args) - ) - .command( - "ssh ID", - // "SSH into a container", - false, // hides it for now so it doesn't show up in help until it is ready - (args) => sshYargs(args), - (args) => - handleFailure( - `wrangler containers ssh`, - sshCommand, - containersScope - )(args) - ) - .command( - "registries", - // hide for now so it doesn't show up in help while not publicly available - // "Configure and manage non-Cloudflare registries", - false, - (args) => registryCommands(args).command(subHelp) - ); -}; +// --- Namespace definition --- +export const containersNamespace = createNamespace({ + metadata: { + description: "📦 Manage Containers", + status: "open beta", + owner: "Product: Cloudchamber", + }, +}); + +// --- Re-export commands from their respective files --- +export { + containersListCommand, + containersInfoCommand, + containersDeleteCommand, +} from "./containers"; + +export { containersSshCommand } from "./ssh"; + +export { + containersRegistriesNamespace, + containersRegistriesConfigureCommand, + containersRegistriesListCommand, + containersRegistriesDeleteCommand, +} from "./registries"; + +// Build and push commands +export { containersBuildCommand, containersPushCommand } from "./build"; + +// Images commands +export { + containersImagesNamespace, + containersImagesListCommand, + containersImagesDeleteCommand, +} from "./images"; diff --git a/packages/wrangler/src/containers/registries.ts b/packages/wrangler/src/containers/registries.ts index 054081da45ba..d91b76b9ad27 100644 --- a/packages/wrangler/src/containers/registries.ts +++ b/packages/wrangler/src/containers/registries.ts @@ -15,7 +15,11 @@ import { getCloudflareComplianceRegion, UserError, } from "@cloudflare/workers-utils"; -import { handleFailure, promiseSpinner } from "../cloudchamber/common"; +import { + fillOpenAPIConfiguration, + promiseSpinner, +} from "../cloudchamber/common"; +import { createCommand, createNamespace } from "../core/create-command"; import { confirm, prompt } from "../dialogs"; import { isNonInteractiveOrCI } from "../is-interactive"; import { logger } from "../logger"; @@ -32,43 +36,7 @@ import type { import type { ImageRegistryAuth } from "@cloudflare/containers-shared/src/client/models/ImageRegistryAuth"; import type { Config } from "@cloudflare/workers-utils"; -export const registryCommands = (yargs: CommonYargsArgv) => { - return yargs - .command( - "configure ", - "Configure credentials for a non-Cloudflare container registry", - (args) => registryConfigureYargs(args), - (args) => - handleFailure( - `wrangler containers registries configure`, - registryConfigureCommand, - containersScope - )(args) - ) - .command( - "list", - "List all configured container registries", - (args) => registryListYargs(args), - (args) => - handleFailure( - `wrangler containers registries list`, - registryListCommand, - containersScope - )(args) - ) - .command( - "delete ", - "Delete a configured container registry", - (args) => registryDeleteYargs(args), - (args) => - handleFailure( - `wrangler containers registries delete`, - registryDeleteCommand, - containersScope - )(args) - ); -}; -function registryConfigureYargs(args: CommonYargsArgv) { +function _registryConfigureYargs(args: CommonYargsArgv) { return ( args .positional("DOMAIN", { @@ -110,7 +78,7 @@ function registryConfigureYargs(args: CommonYargsArgv) { } async function registryConfigureCommand( - configureArgs: StrictYargsOptionsToInterface, + configureArgs: StrictYargsOptionsToInterface, config: Config ) { startSection("Configure a container registry"); @@ -272,7 +240,7 @@ async function getSecret(secretType?: string): Promise { return secret; } -function registryListYargs(args: CommonYargsArgv) { +function _registryListYargs(args: CommonYargsArgv) { return args.option("json", { type: "boolean", description: "Format output as JSON", @@ -281,7 +249,7 @@ function registryListYargs(args: CommonYargsArgv) { } async function registryListCommand( - listArgs: StrictYargsOptionsToInterface + listArgs: StrictYargsOptionsToInterface ) { if (!listArgs.json && !isNonInteractiveOrCI()) { startSection("List configured container registries"); @@ -313,7 +281,7 @@ async function registryListCommand( } } -const registryDeleteYargs = (yargs: CommonYargsArgv) => { +const _registryDeleteYargs = (yargs: CommonYargsArgv) => { return yargs .positional("DOMAIN", { describe: "domain of the registry to delete", @@ -328,7 +296,7 @@ const registryDeleteYargs = (yargs: CommonYargsArgv) => { }); }; async function registryDeleteCommand( - deleteArgs: StrictYargsOptionsToInterface + deleteArgs: StrictYargsOptionsToInterface ) { startSection(`Delete registry ${deleteArgs.DOMAIN}`); @@ -364,3 +332,111 @@ async function registryDeleteCommand( endSection(`Deleted registry ${deleteArgs.DOMAIN}\n`); } + +// --- New defineCommand-based commands --- + +export const containersRegistriesNamespace = createNamespace({ + metadata: { + description: "Configure and manage non-Cloudflare registries", + status: "open beta", + owner: "Product: Cloudchamber", + hidden: true, + }, +}); + +export const containersRegistriesConfigureCommand = createCommand({ + metadata: { + description: + "Configure credentials for a non-Cloudflare container registry", + status: "open beta", + owner: "Product: Cloudchamber", + hidden: true, + }, + args: { + DOMAIN: { + describe: "Domain to configure for the registry", + type: "string", + demandOption: true, + }, + "public-credential": { + type: "string", + description: + "The public part of the registry credentials, e.g. `AWS_ACCESS_KEY_ID` for ECR", + demandOption: true, + alias: "aws-access-key-id", + }, + "secret-store-id": { + type: "string", + description: + "The ID of the secret store to use to store the registry credentials.", + demandOption: false, + conflicts: "disableSecretsStore", + }, + "secret-name": { + type: "string", + description: + "The name for the secret the private registry credentials should be stored under.", + demandOption: false, + conflicts: "disableSecretsStore", + }, + disableSecretsStore: { + type: "boolean", + description: + "Whether to disable secrets store integration. This should be set iff the compliance region is FedRAMP High.", + demandOption: false, + conflicts: ["secret-store-id", "secret-name"], + }, + }, + positionalArgs: ["DOMAIN"], + async handler(args, { config }) { + await fillOpenAPIConfiguration(config, containersScope); + await registryConfigureCommand(args, config); + }, +}); + +export const containersRegistriesListCommand = createCommand({ + metadata: { + description: "List all configured container registries", + status: "open beta", + owner: "Product: Cloudchamber", + hidden: true, + }, + args: { + json: { + type: "boolean", + description: "Format output as JSON", + default: false, + }, + }, + async handler(args, { config }) { + await fillOpenAPIConfiguration(config, containersScope); + await registryListCommand(args); + }, +}); + +export const containersRegistriesDeleteCommand = createCommand({ + metadata: { + description: "Delete a configured container registry", + status: "open beta", + owner: "Product: Cloudchamber", + hidden: true, + }, + args: { + DOMAIN: { + describe: "Domain of the registry to delete", + type: "string", + demandOption: true, + }, + "skip-confirmation": { + type: "boolean", + description: "Skip confirmation prompt", + alias: "y", + default: false, + }, + }, + positionalArgs: ["DOMAIN"], + async handler(args, { config }) { + await fillOpenAPIConfiguration(config, containersScope); + await registryDeleteCommand(args); + }, +}); diff --git a/packages/wrangler/src/containers/ssh.ts b/packages/wrangler/src/containers/ssh.ts index 9babc86777cc..c5eac87d49a8 100644 --- a/packages/wrangler/src/containers/ssh.ts +++ b/packages/wrangler/src/containers/ssh.ts @@ -5,8 +5,13 @@ import { bold } from "@cloudflare/cli/colors"; import { ApiError, DeploymentsService } from "@cloudflare/containers-shared"; import { UserError } from "@cloudflare/workers-utils"; import { WebSocket } from "ws"; -import { promiseSpinner } from "../cloudchamber/common"; +import { + fillOpenAPIConfiguration, + promiseSpinner, +} from "../cloudchamber/common"; +import { createCommand } from "../core/create-command"; import { logger } from "../logger"; +import { containersScope } from "./index"; import type { CommonYargsArgv, StrictYargsOptionsToInterface, @@ -321,3 +326,74 @@ function buildSshArgs( return flags; } + +// --- New defineCommand-based command --- + +export const containersSshCommand = createCommand({ + metadata: { + description: "SSH into a container", + status: "open beta", + owner: "Product: Cloudchamber", + hidden: true, + }, + args: { + ID: { + describe: "ID of the container instance", + type: "string", + demandOption: true, + }, + cipher: { + describe: + "Sets `ssh -c`: Select the cipher specification for encrypting the session", + type: "string", + }, + "log-file": { + describe: + "Sets `ssh -E`: Append debug logs to log_file instead of standard error", + type: "string", + }, + "escape-char": { + describe: + "Sets `ssh -e`: Set the escape character for sessions with a pty (default: '~')", + type: "string", + }, + "config-file": { + alias: "F", + describe: + "Sets `ssh -F`: Specify an alternative per-user ssh configuration file", + type: "string", + }, + pkcs11: { + describe: + "Sets `ssh -I`: Specify the PKCS#11 shared library ssh should use to communicate with a PKCS#11 token providing keys for user authentication", + type: "string", + }, + "identity-file": { + alias: "i", + describe: + "Sets `ssh -i`: Select a file from which the identity (private key) for public key authentication is read", + type: "string", + }, + "mac-spec": { + describe: + "Sets `ssh -m`: A comma-separated list of MAC (message authentication code) algorithms, specified in order of preference", + type: "string", + }, + option: { + alias: "o", + describe: + "Sets `ssh -o`: Set options in the format used in the ssh configuration file. May be repeated", + type: "string", + }, + tag: { + describe: + "Sets `ssh -P`: Specify a tag name that may be used to select configuration in ssh_config", + type: "string", + }, + }, + positionalArgs: ["ID"], + async handler(args, { config }) { + await fillOpenAPIConfiguration(config, containersScope); + await sshCommand(args, config); + }, +}); diff --git a/packages/wrangler/src/index.ts b/packages/wrangler/src/index.ts index bc14d9dd4e26..cd7932a62a63 100644 --- a/packages/wrangler/src/index.ts +++ b/packages/wrangler/src/index.ts @@ -25,10 +25,46 @@ import { certUploadNamespace, } from "./cert/cert"; import { checkNamespace, checkStartupCommand } from "./check/commands"; -import { cloudchamber } from "./cloudchamber"; +import { + cloudchamberApplyCommand, + cloudchamberBuildCommand, + cloudchamberCreateCommand, + cloudchamberCurlCommand, + cloudchamberDeleteCommand, + cloudchamberImagesDeleteCommand, + cloudchamberImagesListCommand, + cloudchamberImagesNamespace, + cloudchamberListCommand, + cloudchamberModifyCommand, + cloudchamberNamespace, + cloudchamberPushCommand, + cloudchamberRegistriesConfigureCommand, + cloudchamberRegistriesCredentialsCommand, + cloudchamberRegistriesListCommand, + cloudchamberRegistriesNamespace, + cloudchamberRegistriesRemoveCommand, + cloudchamberSshCreateCommand, + cloudchamberSshListCommand, + cloudchamberSshNamespace, +} from "./cloudchamber"; import { completionsCommand } from "./complete"; import { getDefaultEnvFiles, loadDotEnv } from "./config/dot-env"; -import { containers } from "./containers"; +import { + containersBuildCommand, + containersDeleteCommand, + containersImagesDeleteCommand, + containersImagesListCommand, + containersImagesNamespace, + containersInfoCommand, + containersListCommand, + containersNamespace, + containersPushCommand, + containersRegistriesConfigureCommand, + containersRegistriesDeleteCommand, + containersRegistriesListCommand, + containersRegistriesNamespace, + containersSshCommand, +} from "./containers"; import { demandSingleValue } from "./core"; import { CommandHandledError } from "./core/CommandHandledError"; import { CommandRegistry } from "./core/CommandRegistry"; @@ -1357,18 +1393,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( @@ -1727,7 +1878,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(); @@ -1776,9 +1926,6 @@ export async function main(argv: string[]): Promise { // Record command as Sentry breadcrumb const command = `wrangler ${args._.join(" ")}`; addBreadcrumb(command); - - // TODO: Legacy commands (cloudchamber, containers) don't use defineCommand - // and won't emit telemetry events. Migrate them to defineCommand to enable telemetry. }, /* applyBeforeValidation */ true); const startTime = Date.now(); From cf514ded63f47cfa8838d843860c5268c55398b6 Mon Sep 17 00:00:00 2001 From: Pete Bacon Darwin Date: Fri, 23 Jan 2026 19:36:30 +0000 Subject: [PATCH 2/3] fixup: add printBanner and hidden:false to cloudchamber commands Thanks to @vicb's feedback on GitHub, this: - Adds printBanner behaviour to suppress the Wrangler version banner in non-interactive/CI mode and when using --json flag - Adds hidden:false to all cloudchamber child commands/namespaces to override the inherited hidden:true from cloudchamberNamespace, which was preventing COMMANDS from showing in --help output --- .../src/__tests__/cloudchamber/create.test.ts | 32 ++------- .../src/__tests__/cloudchamber/delete.test.ts | 8 +-- .../src/__tests__/cloudchamber/images.test.ts | 72 ++++++------------- .../src/__tests__/cloudchamber/list.test.ts | 5 +- .../src/__tests__/cloudchamber/modify.test.ts | 13 +--- .../src/__tests__/containers/info.test.ts | 7 +- .../src/__tests__/containers/list.test.ts | 5 +- .../__tests__/containers/registries.test.ts | 5 +- packages/wrangler/src/cloudchamber/apply.ts | 1 + packages/wrangler/src/cloudchamber/build.ts | 2 + packages/wrangler/src/cloudchamber/create.ts | 4 ++ packages/wrangler/src/cloudchamber/curl.ts | 1 + packages/wrangler/src/cloudchamber/delete.ts | 4 ++ .../src/cloudchamber/images/images.ts | 10 +++ .../src/cloudchamber/images/registries.ts | 17 +++++ packages/wrangler/src/cloudchamber/list.ts | 4 ++ packages/wrangler/src/cloudchamber/modify.ts | 4 ++ packages/wrangler/src/cloudchamber/ssh/ssh.ts | 9 +++ .../wrangler/src/containers/containers.ts | 6 ++ .../wrangler/src/containers/registries.ts | 3 + 20 files changed, 100 insertions(+), 112 deletions(-) diff --git a/packages/wrangler/src/__tests__/cloudchamber/create.test.ts b/packages/wrangler/src/__tests__/cloudchamber/create.test.ts index a7fa516569cf..f1e503861eaa 100644 --- a/packages/wrangler/src/__tests__/cloudchamber/create.test.ts +++ b/packages/wrangler/src/__tests__/cloudchamber/create.test.ts @@ -207,10 +207,7 @@ 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(` - " - ⛅️ wrangler x.x.x - ────────────────── - { + "{ \\"id\\": \\"1\\", \\"type\\": \\"default\\", \\"created_at\\": \\"123\\", @@ -254,12 +251,7 @@ 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(` - " - ⛅️ wrangler x.x.x - ────────────────── - {}" - `); + expect(std.out).toMatchInlineSnapshot(`"{}"`); }); it("properly reads wrangler config", async () => { @@ -281,10 +273,7 @@ describe("cloudchamber create", () => { "cloudchamber create --var HELLO:WORLD --var YOU:CONQUERED" ); expect(std.out).toMatchInlineSnapshot(` - " - ⛅️ wrangler x.x.x - ────────────────── - { + "{ \\"id\\": \\"1\\", \\"type\\": \\"default\\", \\"created_at\\": \\"123\\", @@ -337,12 +326,7 @@ describe("cloudchamber create", () => { await runWrangler( "cloudchamber create --var HELLO:WORLD --var YOU:CONQUERED" ); - expect(std.out).toMatchInlineSnapshot(` - " - ⛅️ wrangler x.x.x - ────────────────── - {}" - `); + expect(std.out).toMatchInlineSnapshot(`"{}"`); expect(std.err).toMatchInlineSnapshot(`""`); }); @@ -386,10 +370,7 @@ describe("cloudchamber create", () => { "cloudchamber create --image hello:world --location sfo06 --var HELLO:WORLD --var YOU:CONQUERED --all-ssh-keys --ipv4" ); expect(std.out).toMatchInlineSnapshot(` - " - ⛅️ wrangler x.x.x - ────────────────── - { + "{ \\"id\\": \\"1\\", \\"type\\": \\"default\\", \\"created_at\\": \\"123\\", @@ -426,9 +407,6 @@ describe("cloudchamber create", () => { // TODO: think better on how to test UI actions expect(std.out).toMatchInlineSnapshot(` " - ⛅️ 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/delete.test.ts b/packages/wrangler/src/__tests__/cloudchamber/delete.test.ts index d9337c2bb0ad..df24b5d67f6d 100644 --- a/packages/wrangler/src/__tests__/cloudchamber/delete.test.ts +++ b/packages/wrangler/src/__tests__/cloudchamber/delete.test.ts @@ -62,10 +62,7 @@ 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\\", @@ -102,9 +99,6 @@ describe("cloudchamber delete", () => { // TODO: think better on how to test UI actions expect(std.out).toMatchInlineSnapshot(` " - ⛅️ 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 5a14161ae059..1acf57f274d6 100644 --- a/packages/wrangler/src/__tests__/cloudchamber/images.test.ts +++ b/packages/wrangler/src/__tests__/cloudchamber/images.test.ts @@ -31,6 +31,12 @@ describe("cloudchamber image", () => { Configure registries via Cloudchamber [alpha] + COMMANDS + wrangler cloudchamber registries configure Configure Cloudchamber to pull from specific registries [alpha] + wrangler cloudchamber registries credentials Get a temporary password for a specific domain [alpha] + wrangler cloudchamber registries remove Remove the registry at the given domain [alpha] + wrangler cloudchamber registries list List registries configured for this account [alpha] + 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] @@ -67,13 +73,10 @@ 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(` - " - ⛅️ wrangler x.x.x - ────────────────── - { - \\"domain\\": \\"docker.io\\" - }" - `); + "{ + \\"domain\\": \\"docker.io\\" + }" + `); }); it("should create an image registry (no interactivity)", async () => { @@ -97,12 +100,7 @@ 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(` - " - ⛅️ wrangler x.x.x - ────────────────── - jwt" - `); + expect(std.out).toMatchInlineSnapshot(`"jwt"`); }); it("should remove an image registry (no interactivity)", async () => { @@ -120,12 +118,7 @@ describe("cloudchamber image", () => { ); await runWrangler("cloudchamber registries remove docker.io"); expect(std.err).toMatchInlineSnapshot(`""`); - expect(std.out).toMatchInlineSnapshot(` - " - ⛅️ wrangler x.x.x - ────────────────── - {}" - `); + expect(std.out).toMatchInlineSnapshot(`"{}"`); }); it("should list registries (no interactivity)", async () => { @@ -152,10 +145,7 @@ 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\\" @@ -192,6 +182,8 @@ describe("cloudchamber image list", () => { expect(std.out).toMatchInlineSnapshot(` "wrangler cloudchamber images list + List images in the Cloudflare managed registry [alpha] + 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] @@ -232,10 +224,7 @@ describe("cloudchamber image list", () => { await runWrangler("cloudchamber images list"); expect(std.err).toMatchInlineSnapshot(`""`); expect(std.out).toMatchInlineSnapshot(` - " - ⛅️ wrangler x.x.x - ────────────────── - REPOSITORY TAG + "REPOSITORY TAG one hundred one ten two thousand @@ -271,10 +260,7 @@ describe("cloudchamber image list", () => { await runWrangler("cloudchamber images list --filter '^two$'"); expect(std.err).toMatchInlineSnapshot(`""`); expect(std.out).toMatchInlineSnapshot(` - " - ⛅️ wrangler x.x.x - ────────────────── - REPOSITORY TAG + "REPOSITORY TAG two thousand two twenty" `); @@ -308,10 +294,7 @@ describe("cloudchamber image list", () => { await runWrangler("cloudchamber images list"); expect(std.err).toMatchInlineSnapshot(`""`); expect(std.out).toMatchInlineSnapshot(` - " - ⛅️ wrangler x.x.x - ────────────────── - REPOSITORY TAG + "REPOSITORY TAG one hundred one ten two thousand @@ -347,10 +330,7 @@ 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\\": [ @@ -404,10 +384,7 @@ 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\\": [ @@ -457,6 +434,8 @@ describe("cloudchamber image delete", () => { expect(std.out).toMatchInlineSnapshot(` "wrangler cloudchamber images delete + Remove an image from the Cloudflare managed registry [alpha] + POSITIONALS image Image and tag to delete, of the form IMAGE:TAG [string] [required] @@ -509,12 +488,7 @@ describe("cloudchamber image delete", () => { await runWrangler("cloudchamber images delete one:hundred"); expect(std.err).toMatchInlineSnapshot(`""`); expect(std.out).toMatchInlineSnapshot( - ` - " - ⛅️ wrangler x.x.x - ────────────────── - Deleted one:hundred (some-digest)" - ` + `"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 8636fbb18b9f..b26701079308 100644 --- a/packages/wrangler/src/__tests__/cloudchamber/list.test.ts +++ b/packages/wrangler/src/__tests__/cloudchamber/list.test.ts @@ -70,10 +70,7 @@ 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(` - " - ⛅️ wrangler x.x.x - ────────────────── - [ + "[ { \\"id\\": \\"1\\", \\"type\\": \\"default\\", diff --git a/packages/wrangler/src/__tests__/cloudchamber/modify.test.ts b/packages/wrangler/src/__tests__/cloudchamber/modify.test.ts index 71b9db97ae0d..076c421505ff 100644 --- a/packages/wrangler/src/__tests__/cloudchamber/modify.test.ts +++ b/packages/wrangler/src/__tests__/cloudchamber/modify.test.ts @@ -80,10 +80,7 @@ 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(` - " - ⛅️ wrangler x.x.x - ────────────────── - { + "{ \\"id\\": \\"1\\", \\"type\\": \\"default\\", \\"created_at\\": \\"123\\", @@ -121,10 +118,7 @@ describe("cloudchamber modify", () => { ); expect(std.err).toMatchInlineSnapshot(`""`); expect(std.out).toMatchInlineSnapshot(` - " - ⛅️ wrangler x.x.x - ────────────────── - { + "{ \\"id\\": \\"1\\", \\"type\\": \\"default\\", \\"created_at\\": \\"123\\", @@ -161,9 +155,6 @@ describe("cloudchamber modify", () => { // TODO: think better on how to test UI actions expect(std.out).toMatchInlineSnapshot(` " - ⛅️ 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/info.test.ts b/packages/wrangler/src/__tests__/containers/info.test.ts index e98d10c05b51..8dbcec9450f6 100644 --- a/packages/wrangler/src/__tests__/containers/info.test.ts +++ b/packages/wrangler/src/__tests__/containers/info.test.ts @@ -75,12 +75,7 @@ describe("containers info", () => { ); expect(std.err).toMatchInlineSnapshot(`""`); await runWrangler("containers info asdf"); - expect(std.out).toMatchInlineSnapshot(` - " - ⛅️ wrangler x.x.x - ────────────────── - {}" - `); + expect(std.out).toMatchInlineSnapshot(`"{}"`); }); 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 a349a89966f0..ba7f256bca37 100644 --- a/packages/wrangler/src/__tests__/containers/list.test.ts +++ b/packages/wrangler/src/__tests__/containers/list.test.ts @@ -58,10 +58,7 @@ 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/registries.test.ts b/packages/wrangler/src/__tests__/containers/registries.test.ts index dd624803eb1b..f09a931ad280 100644 --- a/packages/wrangler/src/__tests__/containers/registries.test.ts +++ b/packages/wrangler/src/__tests__/containers/registries.test.ts @@ -434,10 +434,7 @@ 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\\" } diff --git a/packages/wrangler/src/cloudchamber/apply.ts b/packages/wrangler/src/cloudchamber/apply.ts index 0a64cb07e7b3..be41fe01ec95 100644 --- a/packages/wrangler/src/cloudchamber/apply.ts +++ b/packages/wrangler/src/cloudchamber/apply.ts @@ -680,6 +680,7 @@ export const cloudchamberApplyCommand = createCommand({ description: "Apply the changes in the container applications to deploy", status: "alpha", owner: "Product: Cloudchamber", + hidden: false, }, args: { "skip-defaults": { diff --git a/packages/wrangler/src/cloudchamber/build.ts b/packages/wrangler/src/cloudchamber/build.ts index 0668726937af..b2e12b868b3e 100644 --- a/packages/wrangler/src/cloudchamber/build.ts +++ b/packages/wrangler/src/cloudchamber/build.ts @@ -337,6 +337,7 @@ export const cloudchamberBuildCommand = createCommand({ description: "Build a container image", status: "alpha", owner: "Product: Cloudchamber", + hidden: false, }, args: { PATH: { @@ -384,6 +385,7 @@ export const cloudchamberPushCommand = createCommand({ description: "Push a local image to the Cloudflare managed registry", status: "alpha", owner: "Product: Cloudchamber", + hidden: false, }, args: { TAG: { diff --git a/packages/wrangler/src/cloudchamber/create.ts b/packages/wrangler/src/cloudchamber/create.ts index 3f5070c1c5e9..830e80e65756 100644 --- a/packages/wrangler/src/cloudchamber/create.ts +++ b/packages/wrangler/src/cloudchamber/create.ts @@ -397,6 +397,10 @@ export const cloudchamberCreateCommand = defineCommand({ description: "Create a new deployment", status: "alpha", owner: "Product: Cloudchamber", + hidden: false, + }, + behaviour: { + printBanner: () => !isNonInteractiveOrCI(), }, args: { image: { diff --git a/packages/wrangler/src/cloudchamber/curl.ts b/packages/wrangler/src/cloudchamber/curl.ts index 11fca24122b5..cc9e39f84974 100644 --- a/packages/wrangler/src/cloudchamber/curl.ts +++ b/packages/wrangler/src/cloudchamber/curl.ts @@ -173,6 +173,7 @@ export const cloudchamberCurlCommand = createCommand({ description: "Send a request to an arbitrary Cloudchamber endpoint", status: "alpha", owner: "Product: Cloudchamber", + hidden: false, }, args: { path: { diff --git a/packages/wrangler/src/cloudchamber/delete.ts b/packages/wrangler/src/cloudchamber/delete.ts index d4319ad667df..9f7cc5328c82 100644 --- a/packages/wrangler/src/cloudchamber/delete.ts +++ b/packages/wrangler/src/cloudchamber/delete.ts @@ -79,6 +79,10 @@ export const cloudchamberDeleteCommand = createCommand({ "Delete an existing deployment that is running in the Cloudflare edge", status: "alpha", owner: "Product: Cloudchamber", + hidden: false, + }, + behaviour: { + printBanner: () => !isNonInteractiveOrCI(), }, args: { deploymentId: { diff --git a/packages/wrangler/src/cloudchamber/images/images.ts b/packages/wrangler/src/cloudchamber/images/images.ts index bbc3412e1789..8f28ba196d1b 100644 --- a/packages/wrangler/src/cloudchamber/images/images.ts +++ b/packages/wrangler/src/cloudchamber/images/images.ts @@ -4,6 +4,7 @@ import { } from "@cloudflare/containers-shared"; import { fetch } from "undici"; import { createCommand, createNamespace } from "../../core/create-command"; +import { isNonInteractiveOrCI } from "../../is-interactive"; import { logger } from "../../logger"; import { getAccountId } from "../../user"; import { @@ -280,6 +281,7 @@ export const cloudchamberImagesNamespace = createNamespace({ description: "Manage images in the Cloudflare managed registry", status: "alpha", owner: "Product: Cloudchamber", + hidden: false, }, }); @@ -288,6 +290,10 @@ export const cloudchamberImagesListCommand = createCommand({ description: "List images in the Cloudflare managed registry", status: "alpha", owner: "Product: Cloudchamber", + hidden: false, + }, + behaviour: { + printBanner: (args) => !args.json && !isNonInteractiveOrCI(), }, args: { filter: { @@ -311,6 +317,10 @@ export const cloudchamberImagesDeleteCommand = createCommand({ description: "Remove an image from the Cloudflare managed registry", status: "alpha", owner: "Product: Cloudchamber", + hidden: false, + }, + behaviour: { + printBanner: () => !isNonInteractiveOrCI(), }, args: { image: { diff --git a/packages/wrangler/src/cloudchamber/images/registries.ts b/packages/wrangler/src/cloudchamber/images/registries.ts index 11d139a4e45b..10cc8a1134a8 100644 --- a/packages/wrangler/src/cloudchamber/images/registries.ts +++ b/packages/wrangler/src/cloudchamber/images/registries.ts @@ -227,6 +227,7 @@ export const cloudchamberRegistriesNamespace = createNamespace({ description: "Configure registries via Cloudchamber", status: "alpha", owner: "Product: Cloudchamber", + hidden: false, }, }); @@ -235,6 +236,10 @@ export const cloudchamberRegistriesConfigureCommand = createCommand({ description: "Configure Cloudchamber to pull from specific registries", status: "alpha", owner: "Product: Cloudchamber", + hidden: false, + }, + behaviour: { + printBanner: () => !isNonInteractiveOrCI(), }, args: { domain: { @@ -259,6 +264,10 @@ export const cloudchamberRegistriesCredentialsCommand = createCommand({ description: "Get a temporary password for a specific domain", status: "alpha", owner: "Product: Cloudchamber", + hidden: false, + }, + behaviour: { + printBanner: () => !isNonInteractiveOrCI(), }, args: { domain: { @@ -290,6 +299,10 @@ export const cloudchamberRegistriesRemoveCommand = createCommand({ description: "Remove the registry at the given domain", status: "alpha", owner: "Product: Cloudchamber", + hidden: false, + }, + behaviour: { + printBanner: () => !isNonInteractiveOrCI(), }, args: { domain: { @@ -309,6 +322,10 @@ export const cloudchamberRegistriesListCommand = createCommand({ description: "List registries configured for this account", status: "alpha", owner: "Product: Cloudchamber", + hidden: false, + }, + behaviour: { + printBanner: () => !isNonInteractiveOrCI(), }, args: {}, async handler(args, { config }) { diff --git a/packages/wrangler/src/cloudchamber/list.ts b/packages/wrangler/src/cloudchamber/list.ts index 1fb3dd0874fd..409e5792cf19 100644 --- a/packages/wrangler/src/cloudchamber/list.ts +++ b/packages/wrangler/src/cloudchamber/list.ts @@ -200,6 +200,10 @@ export const cloudchamberListCommand = createCommand({ description: "List and view status of deployments", status: "alpha", owner: "Product: Cloudchamber", + hidden: false, + }, + behaviour: { + printBanner: () => !isNonInteractiveOrCI(), }, args: { deploymentIdPrefix: { diff --git a/packages/wrangler/src/cloudchamber/modify.ts b/packages/wrangler/src/cloudchamber/modify.ts index 820f9cfb3edf..821e127157e4 100644 --- a/packages/wrangler/src/cloudchamber/modify.ts +++ b/packages/wrangler/src/cloudchamber/modify.ts @@ -319,6 +319,10 @@ export const cloudchamberModifyCommand = createCommand({ description: "Modify an existing deployment", status: "alpha", owner: "Product: Cloudchamber", + hidden: false, + }, + behaviour: { + printBanner: () => !isNonInteractiveOrCI(), }, args: { deploymentId: { diff --git a/packages/wrangler/src/cloudchamber/ssh/ssh.ts b/packages/wrangler/src/cloudchamber/ssh/ssh.ts index cd9f6a535b38..29a4e2a4b5b3 100644 --- a/packages/wrangler/src/cloudchamber/ssh/ssh.ts +++ b/packages/wrangler/src/cloudchamber/ssh/ssh.ts @@ -376,6 +376,7 @@ export const cloudchamberSshNamespace = createNamespace({ description: "Manage the ssh keys of your account", status: "alpha", owner: "Product: Cloudchamber", + hidden: false, }, }); @@ -384,6 +385,10 @@ export const cloudchamberSshListCommand = createCommand({ description: "List the ssh keys added to your account", status: "alpha", owner: "Product: Cloudchamber", + hidden: false, + }, + behaviour: { + printBanner: () => !isNonInteractiveOrCI(), }, args: {}, async handler(args, { config }) { @@ -397,6 +402,10 @@ export const cloudchamberSshCreateCommand = createCommand({ description: "Create an ssh key", status: "alpha", owner: "Product: Cloudchamber", + hidden: false, + }, + behaviour: { + printBanner: () => !isNonInteractiveOrCI(), }, args: { name: { diff --git a/packages/wrangler/src/containers/containers.ts b/packages/wrangler/src/containers/containers.ts index f4347b00c73c..037af18c0cdd 100644 --- a/packages/wrangler/src/containers/containers.ts +++ b/packages/wrangler/src/containers/containers.ts @@ -259,6 +259,9 @@ export const containersListCommand = createCommand({ status: "open beta", owner: "Product: Cloudchamber", }, + behaviour: { + printBanner: () => !isNonInteractiveOrCI(), + }, args: {}, async handler(args, { config }) { await fillOpenAPIConfiguration(config, containersScope); @@ -272,6 +275,9 @@ export const containersInfoCommand = createCommand({ status: "open beta", owner: "Product: Cloudchamber", }, + behaviour: { + printBanner: () => !isNonInteractiveOrCI(), + }, args: { ID: { describe: "ID of the container to view", diff --git a/packages/wrangler/src/containers/registries.ts b/packages/wrangler/src/containers/registries.ts index d91b76b9ad27..b7a72cb830c0 100644 --- a/packages/wrangler/src/containers/registries.ts +++ b/packages/wrangler/src/containers/registries.ts @@ -401,6 +401,9 @@ export const containersRegistriesListCommand = createCommand({ owner: "Product: Cloudchamber", hidden: true, }, + behaviour: { + printBanner: (args) => !args.json && !isNonInteractiveOrCI(), + }, args: { json: { type: "boolean", From 7dee593a5ea91597e6b704c4a5aa6b8c0baa863a Mon Sep 17 00:00:00 2001 From: Pete Bacon Darwin Date: Sat, 24 Jan 2026 13:09:25 +0000 Subject: [PATCH 3/3] fixup: add printBanner behaviour to containers images commands After feedback from Devin AI review, this adds the missing behaviour.printBanner configuration to containersImagesListCommand and containersImagesDeleteCommand to match their cloudchamber equivalents. This ensures the banner is suppressed when using --json flag or in non-interactive/CI environments. --- .../src/__tests__/containers/images.test.ts | 289 ++++++++++++++++++ packages/wrangler/src/containers/images.ts | 7 + 2 files changed, 296 insertions(+) create mode 100644 packages/wrangler/src/__tests__/containers/images.test.ts diff --git a/packages/wrangler/src/__tests__/containers/images.test.ts b/packages/wrangler/src/__tests__/containers/images.test.ts new file mode 100644 index 000000000000..61d18affccbc --- /dev/null +++ b/packages/wrangler/src/__tests__/containers/images.test.ts @@ -0,0 +1,289 @@ +import { getCloudflareContainerRegistry } from "@cloudflare/containers-shared"; +import { http, HttpResponse } from "msw"; +import patchConsole from "patch-console"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { mockAccount, setWranglerConfig } from "../cloudchamber/utils"; +import { mockAccountId, mockApiToken } from "../helpers/mock-account-id"; +import { mockConsoleMethods } from "../helpers/mock-console"; +import { useMockIsTTY } from "../helpers/mock-istty"; +import { msw } from "../helpers/msw"; +import { runInTempDir } from "../helpers/run-in-tmp"; +import { runWrangler } from "../helpers/run-wrangler"; + +// Helper to wrap responses in v4 API schema format for containers endpoint +function wrapV4Response(result: T) { + return { success: true, result }; +} + +describe("containers images list", () => { + const std = mockConsoleMethods(); + const { setIsTTY } = useMockIsTTY(); + + const REGISTRY = getCloudflareContainerRegistry(); + + mockAccountId(); + mockApiToken(); + beforeEach(mockAccount); + runInTempDir(); + afterEach(() => { + patchConsole(() => {}); + msw.resetHandlers(); + }); + + it("should help", async () => { + setIsTTY(false); + setWranglerConfig({}); + await runWrangler("containers images list --help"); + expect(std.err).toMatchInlineSnapshot(`""`); + expect(std.out).toMatchInlineSnapshot(` + "wrangler containers images list + + List images in the Cloudflare managed registry [open beta] + + 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] + -e, --env Environment to use for operations, and for selecting .env and .dev.vars files [string] + --env-file Path to an .env file to load - can be specified multiple times - values from earlier files are overridden by values in later files [array] + -h, --help Show help [boolean] + -v, --version Show version number [boolean] + + OPTIONS + --filter Regex to filter results [string] + --json Format output as JSON [boolean] [default: false]" + `); + }); + + it("should list images", async () => { + setIsTTY(false); + setWranglerConfig({}); + const tags = { + one: ["hundred", "ten", "sha256:239a0dfhasdfui235"], + two: ["thousand", "twenty", "sha256:badfga4mag0vhjakf"], + three: ["million", "thirty", "sha256:23f0adfgbja0f0jf0"], + }; + + msw.use( + http.post("*/registries/:domain/credentials", async ({ params }) => { + expect(params.domain).toEqual(REGISTRY); + return HttpResponse.json( + wrapV4Response({ + account_id: "1234", + registry_host: REGISTRY, + username: "foo", + password: "bar", + }) + ); + }), + http.get("*/v2/_catalog?tags=true", async () => { + return HttpResponse.json({ repositories: tags }); + }) + ); + + await runWrangler("containers images list"); + expect(std.err).toMatchInlineSnapshot(`""`); + expect(std.out).toMatchInlineSnapshot(` + "REPOSITORY TAG + one hundred + one ten + two thousand + two twenty + three million + three thirty" + `); + }); + + it("should list images with a filter", async () => { + setIsTTY(false); + setWranglerConfig({}); + const tags = { + one: ["hundred", "ten", "sha256:239a0dfhasdfui235"], + two: ["thousand", "twenty", "sha256:badfga4mag0vhjakf"], + three: ["million", "thirty", "sha256:23f0adfgbja0f0jf0"], + }; + + msw.use( + http.post("*/registries/:domain/credentials", async ({ params }) => { + expect(params.domain).toEqual(REGISTRY); + return HttpResponse.json( + wrapV4Response({ + account_id: "1234", + registry_host: REGISTRY, + username: "foo", + password: "bar", + }) + ); + }), + http.get("*/v2/_catalog?tags=true", async () => { + return HttpResponse.json({ repositories: tags }); + }) + ); + await runWrangler("containers images list --filter '^two$'"); + expect(std.err).toMatchInlineSnapshot(`""`); + expect(std.out).toMatchInlineSnapshot(` + "REPOSITORY TAG + two thousand + two twenty" + `); + }); + + it("should list repos with json flag set", async () => { + setWranglerConfig({}); + const tags = { + one: ["hundred", "ten", "sha256:239a0dfhasdfui235"], + two: ["thousand", "twenty", "sha256:badfga4mag0vhjakf"], + three: ["million", "thirty", "sha256:23f0adfgbja0f0jf0"], + }; + + msw.use( + http.post("*/registries/:domain/credentials", async ({ params }) => { + expect(params.domain).toEqual(REGISTRY); + return HttpResponse.json( + wrapV4Response({ + account_id: "1234", + registry_host: REGISTRY, + username: "foo", + password: "bar", + }) + ); + }), + http.get("*/v2/_catalog?tags=true", async () => { + return HttpResponse.json({ repositories: tags }); + }) + ); + await runWrangler("containers images list --json"); + expect(std.err).toMatchInlineSnapshot(`""`); + expect(std.out).toMatchInlineSnapshot(` + "[ + { + \\"name\\": \\"one\\", + \\"tags\\": [ + \\"hundred\\", + \\"ten\\" + ] + }, + { + \\"name\\": \\"two\\", + \\"tags\\": [ + \\"thousand\\", + \\"twenty\\" + ] + }, + { + \\"name\\": \\"three\\", + \\"tags\\": [ + \\"million\\", + \\"thirty\\" + ] + } + ]" + `); + }); +}); + +describe("containers images delete", () => { + const std = mockConsoleMethods(); + const { setIsTTY } = useMockIsTTY(); + + const REGISTRY = getCloudflareContainerRegistry(); + + mockAccountId(); + mockApiToken(); + beforeEach(mockAccount); + runInTempDir(); + afterEach(() => { + patchConsole(() => {}); + msw.resetHandlers(); + }); + + it("should help", async () => { + setIsTTY(false); + setWranglerConfig({}); + await runWrangler("containers images delete --help"); + expect(std.err).toMatchInlineSnapshot(`""`); + expect(std.out).toMatchInlineSnapshot(` + "wrangler containers images delete + + Remove an image from the Cloudflare managed registry [open beta] + + POSITIONALS + image Image and tag to delete, of the form IMAGE:TAG [string] [required] + + 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] + -e, --env Environment to use for operations, and for selecting .env and .dev.vars files [string] + --env-file Path to an .env file to load - can be specified multiple times - values from earlier files are overridden by values in later files [array] + -h, --help Show help [boolean] + -v, --version Show version number [boolean]" + `); + }); + + it("should delete images", async () => { + setIsTTY(false); + setWranglerConfig({}); + + msw.use( + http.post("*/registries/:domain/credentials", async ({ params }) => { + expect(params.domain).toEqual(REGISTRY); + return HttpResponse.json( + wrapV4Response({ + account_id: "1234", + registry_host: REGISTRY, + username: "foo", + password: "bar", + }) + ); + }), + http.head("*/v2/:accountId/:image/manifests/:tag", async ({ params }) => { + expect(params.accountId).toEqual("some-account-id"); + expect(params.image).toEqual("one"); + expect(params.tag).toEqual("hundred"); + return new HttpResponse("", { + status: 200, + headers: { "Docker-Content-Digest": "some-digest" }, + }); + }), + http.delete( + "*/v2/:accountId/:image/manifests/:tag", + async ({ params }) => { + expect(params.accountId).toEqual("some-account-id"); + expect(params.image).toEqual("one"); + expect(params.tag).toEqual("hundred"); + return new HttpResponse("", { status: 200 }); + } + ), + http.put("*/v2/gc/layers", async () => { + return new HttpResponse("", { status: 200 }); + }) + ); + await runWrangler("containers images delete one:hundred"); + expect(std.err).toMatchInlineSnapshot(`""`); + expect(std.out).toMatchInlineSnapshot( + `"Deleted one:hundred (some-digest)"` + ); + }); + + it("should error when provided a repo without a tag", async () => { + setIsTTY(false); + setWranglerConfig({}); + + msw.use( + http.post("*/registries/:domain/credentials", async ({ params }) => { + expect(params.domain).toEqual(REGISTRY); + return HttpResponse.json( + wrapV4Response({ + account_id: "1234", + registry_host: REGISTRY, + username: "foo", + password: "bar", + }) + ); + }) + ); + await expect(runWrangler("containers images delete one")).rejects + .toThrowErrorMatchingInlineSnapshot(` + [Error: Invalid image format. Expected IMAGE:TAG] + `); + }); +}); diff --git a/packages/wrangler/src/containers/images.ts b/packages/wrangler/src/containers/images.ts index ff9bd0dc40f5..cda8b78556a8 100644 --- a/packages/wrangler/src/containers/images.ts +++ b/packages/wrangler/src/containers/images.ts @@ -8,6 +8,7 @@ import { promiseSpinner, } from "../cloudchamber/common"; import { createCommand, createNamespace } from "../core/create-command"; +import { isNonInteractiveOrCI } from "../is-interactive"; import { logger } from "../logger"; import { getAccountId } from "../user"; import { containersScope } from "."; @@ -37,6 +38,9 @@ export const containersImagesListCommand = createCommand({ status: "open beta", owner: "Product: Cloudchamber", }, + behaviour: { + printBanner: (args) => !args.json && !isNonInteractiveOrCI(), + }, args: { filter: { type: "string", @@ -60,6 +64,9 @@ export const containersImagesDeleteCommand = createCommand({ status: "open beta", owner: "Product: Cloudchamber", }, + behaviour: { + printBanner: () => !isNonInteractiveOrCI(), + }, args: { image: { type: "string",