From 4d33e3905d750d2450e3b40c89795797abd0f870 Mon Sep 17 00:00:00 2001 From: Patrick O'Donnell Date: Tue, 4 Aug 2026 13:46:44 -0400 Subject: [PATCH 1/5] WC-5233 Use PATCH APIs for wrangler preview secret put and delete --- .changeset/preview-secret-per-preview.md | 9 + packages/deploy-helpers/src/preview/api.ts | 25 ++ .../src/__tests__/preview.secret.test.ts | 364 ++++++++++++------ packages/wrangler/src/preview/index.ts | 36 +- packages/wrangler/src/preview/secret.ts | 150 ++++++-- 5 files changed, 446 insertions(+), 138 deletions(-) create mode 100644 .changeset/preview-secret-per-preview.md diff --git a/.changeset/preview-secret-per-preview.md b/.changeset/preview-secret-per-preview.md new file mode 100644 index 00000000000..d79013478c0 --- /dev/null +++ b/.changeset/preview-secret-per-preview.md @@ -0,0 +1,9 @@ +--- +"wrangler": minor +--- + +Add `wrangler preview secret put` and `wrangler preview secret delete` for managing secrets on a named Worker Preview + +These private-beta commands manage secrets on a single named Preview by creating a new Preview deployment from its latest one, without affecting production, other Previews, or the Worker's Previews base configuration. + +The new deployment goes live at 100% immediately, and the command reports the live Preview URL on success. diff --git a/packages/deploy-helpers/src/preview/api.ts b/packages/deploy-helpers/src/preview/api.ts index 6359b250ee1..0224b0f4a2a 100644 --- a/packages/deploy-helpers/src/preview/api.ts +++ b/packages/deploy-helpers/src/preview/api.ts @@ -266,6 +266,31 @@ export async function createPreviewDeployment( ); } +export async function patchPreviewDeployment( + config: Config, + accountId: string, + workerName: string, + previewIdentifier: string, + env: Record, + annotations?: { + "workers/message"?: string; + "workers/tag"?: string; + }, + deploymentIdentifier = "latest" +): Promise { + return fetchResult( + config, + `/accounts/${accountId}/workers/workers/${workerName}/previews/${encodeURIComponent( + previewIdentifier + )}/deployments/${encodeURIComponent(deploymentIdentifier)}`, + { + method: "PATCH", + headers: { "Content-Type": "application/merge-patch+json" }, + body: JSON.stringify({ env, annotations }), + } + ); +} + export async function getWorkerPreviewDefaults( config: Config, accountId: string, diff --git a/packages/wrangler/src/__tests__/preview.secret.test.ts b/packages/wrangler/src/__tests__/preview.secret.test.ts index f57c40624a5..d53eb686d85 100644 --- a/packages/wrangler/src/__tests__/preview.secret.test.ts +++ b/packages/wrangler/src/__tests__/preview.secret.test.ts @@ -1,13 +1,62 @@ import { mkdirSync, writeFileSync } from "node:fs"; import { runInTempDir } from "@cloudflare/workers-utils/test-helpers"; import { http, HttpResponse } from "msw"; -import { beforeEach, describe, test } from "vitest"; +import { afterEach, beforeEach, describe, test, vi } from "vitest"; import { mockAccountId, mockApiToken } from "./helpers/mock-account-id"; import { mockConsoleMethods } from "./helpers/mock-console"; import { useMockStdin } from "./helpers/mock-stdin"; import { msw } from "./helpers/msw"; import { runWrangler } from "./helpers/run-wrangler"; +type PreviewDeploymentPatchBody = { + env?: Record; + annotations?: Record; +}; + +function mockPatchLatestPreviewDeployment( + onRequest?: (info: { url: string; body: PreviewDeploymentPatchBody }) => void +) { + msw.use( + http.patch( + `*/accounts/:accountId/workers/workers/:workerId/previews/:previewId/deployments/latest`, + async ({ request, params }) => { + onRequest?.({ + url: request.url, + body: (await request.json()) as PreviewDeploymentPatchBody, + }); + return HttpResponse.json({ + success: true, + result: { + id: "deployment-1", + preview_id: "preview-1", + preview_name: String(params.previewId), + urls: ["https://test-preview.example.workers.dev"], + created_on: "2025-01-01T00:00:00Z", + }, + }); + } + ) + ); +} + +function mockPreviewDeploymentNotFound(code: number) { + msw.use( + http.patch( + `*/accounts/:accountId/workers/workers/:workerId/previews/:previewId/deployments/latest`, + () => + HttpResponse.json( + { + success: false, + errors: [{ code, message: "no preview deployment" }], + messages: [], + result: null, + }, + { status: 404 } + ) + ) + ); +} + describe("wrangler preview", () => { const std = mockConsoleMethods(); runInTempDir(); @@ -31,56 +80,71 @@ describe("wrangler preview", () => { msw.resetHandlers(); }); + afterEach(() => { + vi.unstubAllEnvs(); + }); + describe("put", () => { const mockStdIn = useMockStdin({ isTTY: false }); - test("should add a secret to Previews settings", async ({ expect }) => { - mockStdIn.send("defaults-secret"); - let patchRequestBody: - | { - preview_defaults?: { - env?: Record; - }; - } - | undefined; + test("creates a new Preview deployment with the secret", async ({ + expect, + }) => { + mockStdIn.send("preview-secret"); + let patchedPreviewDefaults = false; msw.use( - http.patch( - `*/accounts/:accountId/workers/workers/:workerId`, - async ({ request }) => { - patchRequestBody = - (await request.json()) as typeof patchRequestBody; - return HttpResponse.json({ - success: true, - result: { - preview_defaults: { - env: patchRequestBody?.preview_defaults?.env ?? {}, - }, - }, - }); - } - ) + http.patch(`*/accounts/:accountId/workers/workers/:workerId`, () => { + patchedPreviewDefaults = true; + return HttpResponse.json({ success: true, result: {} }); + }) ); + let requestUrl: string | undefined; + let requestBody: PreviewDeploymentPatchBody | undefined; + mockPatchLatestPreviewDeployment(({ url, body }) => { + requestUrl = url; + requestBody = body; + }); + await runWrangler( - "preview secret put API_KEY --worker-name test-worker" + "preview secret put API_KEY --name test-preview --worker-name test-worker" ); - expect(patchRequestBody?.preview_defaults?.env?.API_KEY).toMatchObject({ - type: "secret_text", - text: "defaults-secret", - }); - expect(patchRequestBody?.preview_defaults?.env).toEqual({ - API_KEY: { type: "secret_text", text: "defaults-secret" }, + + expect(requestUrl).toContain( + "/workers/workers/test-worker/previews/test-preview/deployments/latest" + ); + expect(requestBody?.env).toEqual({ + API_KEY: { type: "secret_text", text: "preview-secret" }, }); + expect(patchedPreviewDefaults).toBe(false); + expect(std.out).toContain('Preview "test-preview"'); + expect(std.out).toContain("test-worker"); + expect(std.out).toContain("Preview deployment"); expect(std.out).toContain( - 'Secret "API_KEY" added to Previews settings for Worker test-worker.' + 'is now live at https://test-preview.example.workers.dev' ); - expect(std.out).toContain("Worker: test-worker"); - expect(std.out).toContain("Previews settings"); - expect(std.out).toContain("Secrets"); - expect(std.out).toContain("API_KEY"); - expect(std.out).toContain("********"); + expect(std.out).not.toContain("preview-secret"); }); - test("should respect env-specific worker name when using --env", async ({ + test("defaults the Preview name to the current git branch", async ({ + expect, + }) => { + vi.stubEnv("WORKERS_CI_BRANCH", "branch-preview"); + mockStdIn.send("preview-secret"); + let requestUrl: string | undefined; + mockPatchLatestPreviewDeployment(({ url }) => { + requestUrl = url; + }); + + await runWrangler( + "preview secret put API_KEY --worker-name test-worker" + ); + + expect(requestUrl).toContain( + "/previews/branch-preview/deployments/latest" + ); + }); + + test("respects env-specific worker name when using --env", async ({ expect, }) => { mockStdIn.send("env-secret"); @@ -90,35 +154,87 @@ describe("wrangler preview", () => { name: "top-worker", main: "src/index.ts", compatibility_date: "2025-01-01", - env: { - staging: { - name: "staging-worker", - }, - }, + env: { staging: { name: "staging-worker" } }, }) ); + let requestUrl: string | undefined; + mockPatchLatestPreviewDeployment(({ url }) => { + requestUrl = url; + }); - let patchUrl: string | undefined; + await runWrangler( + "preview secret put API_KEY --name test-preview --env staging" + ); - msw.use( - http.patch( - `*/accounts/:accountId/workers/workers/:workerId`, - ({ request }) => { - patchUrl = request.url; - return HttpResponse.json({ success: true, result: {} }); - } - ) + expect(requestUrl).toContain( + "/workers/workers/staging-worker/previews/test-preview/deployments/latest" ); + }); - await runWrangler("preview secret put API_KEY --env staging"); + test("sends --message and --tag as deployment annotations", async ({ + expect, + }) => { + mockStdIn.send("preview-secret"); + let requestBody: PreviewDeploymentPatchBody | undefined; + mockPatchLatestPreviewDeployment(({ body }) => { + requestBody = body; + }); - expect(patchUrl).toContain("/workers/workers/staging-worker"); - expect(std.out).toContain( - 'Secret "API_KEY" added to Previews settings for Worker staging-worker.' + await runWrangler( + 'preview secret put API_KEY --name test-preview --worker-name test-worker --message "add a secret" --tag v1' + ); + + expect(requestBody?.annotations).toMatchObject({ + "workers/message": "add a secret", + "workers/tag": "v1", + }); + }); + + test("uses the default annotation message when none is provided", async ({ + expect, + }) => { + mockStdIn.send("preview-secret"); + let requestBody: PreviewDeploymentPatchBody | undefined; + mockPatchLatestPreviewDeployment(({ body }) => { + requestBody = body; + }); + + await runWrangler( + "preview secret put API_KEY --name test-preview --worker-name test-worker" + ); + + expect(requestBody?.annotations?.["workers/message"]).toBe( + 'Updated secret "API_KEY"' ); }); - test("should fail before making API calls when env-specific previews config is invalid", async ({ + test("fails clearly when the Preview has no deployments", async ({ + expect, + }) => { + mockStdIn.send("preview-secret"); + mockPreviewDeploymentNotFound(10032); + + await expect( + runWrangler( + "preview secret put API_KEY --name test-preview --worker-name test-worker" + ) + ).rejects.toThrow(/no deployments for the Preview/); + }); + + test("fails clearly when the Preview is not found", async ({ + expect, + }) => { + mockStdIn.send("preview-secret"); + mockPreviewDeploymentNotFound(10025); + + await expect( + runWrangler( + "preview secret put API_KEY --name test-preview --worker-name test-worker" + ) + ).rejects.toThrow(/Preview "test-preview" was not found/); + }); + + test("fails before making API calls when env-specific previews config is invalid", async ({ expect, }) => { writeFileSync( @@ -138,64 +254,55 @@ describe("wrangler preview", () => { ); let requested = false; - msw.use( - http.patch(`*/accounts/:accountId/workers/workers/:workerId`, () => { - requested = true; - return HttpResponse.json({ success: true, result: {} }); - }) - ); + mockPatchLatestPreviewDeployment(() => { + requested = true; + }); await expect( - runWrangler("preview secret put API_KEY --env staging") + runWrangler( + "preview secret put API_KEY --name test-preview --env staging" + ) ).rejects.toThrow(/previews\.browser/); expect(requested).toBe(false); }); }); describe("delete", () => { - test("should delete a secret from Previews settings", async ({ + test("creates a new Preview deployment removing the secret", async ({ expect, }) => { - let patchRequestBody: - | { - preview_defaults?: { - env?: Record; - }; - } - | undefined; + let patchedPreviewDefaults = false; msw.use( - http.patch( - `*/accounts/:accountId/workers/workers/:workerId`, - async ({ request }) => { - patchRequestBody = - (await request.json()) as typeof patchRequestBody; - return HttpResponse.json({ - success: true, - result: { - preview_defaults: { - env: patchRequestBody?.preview_defaults?.env ?? {}, - }, - }, - }); - } - ) + http.patch(`*/accounts/:accountId/workers/workers/:workerId`, () => { + patchedPreviewDefaults = true; + return HttpResponse.json({ success: true, result: {} }); + }) ); + let requestUrl: string | undefined; + let requestBody: PreviewDeploymentPatchBody | undefined; + mockPatchLatestPreviewDeployment(({ url, body }) => { + requestUrl = url; + requestBody = body; + }); + await runWrangler( - "preview secret delete REMOVE_ME --skip-confirmation --worker-name test-worker" + "preview secret delete REMOVE_ME --name test-preview --skip-confirmation --worker-name test-worker" ); - expect(patchRequestBody?.preview_defaults?.env).toEqual({ - REMOVE_ME: null, - }); + + expect(requestUrl).toContain( + "/workers/workers/test-worker/previews/test-preview/deployments/latest" + ); + expect(requestBody?.env).toEqual({ REMOVE_ME: null }); + expect(patchedPreviewDefaults).toBe(false); + expect(std.out).toContain('Preview "test-preview"'); + expect(std.out).toContain("test-worker"); + expect(std.out).toContain("Preview deployment"); expect(std.out).toContain( - 'Secret "REMOVE_ME" deleted from Previews settings for Worker test-worker.' + 'is now live at https://test-preview.example.workers.dev' ); - expect(std.out).toContain("Worker: test-worker"); - expect(std.out).toContain("Previews settings"); - expect(std.out).toContain("Secrets"); - expect(std.out).toContain("(none)"); }); - test("should respect env-specific worker name when deleting a secret", async ({ + test("respects env-specific worker name when deleting a secret", async ({ expect, }) => { writeFileSync( @@ -207,20 +314,59 @@ describe("wrangler preview", () => { env: { staging: { name: "staging-worker" } }, }) ); - let patchUrl: string | undefined; - msw.use( - http.patch( - `*/accounts/:accountId/workers/workers/:workerId`, - ({ request }) => { - patchUrl = request.url; - return HttpResponse.json({ success: true, result: {} }); - } - ) + let requestUrl: string | undefined; + mockPatchLatestPreviewDeployment(({ url }) => { + requestUrl = url; + }); + + await runWrangler( + "preview secret delete REMOVE_ME --name test-preview --env staging --skip-confirmation" ); + + expect(requestUrl).toContain( + "/workers/workers/staging-worker/previews/test-preview/deployments/latest" + ); + }); + + test("uses the default annotation message when none is provided", async ({ + expect, + }) => { + let requestBody: PreviewDeploymentPatchBody | undefined; + mockPatchLatestPreviewDeployment(({ body }) => { + requestBody = body; + }); + await runWrangler( - "preview secret delete REMOVE_ME --env staging --skip-confirmation" + "preview secret delete REMOVE_ME --name test-preview --skip-confirmation --worker-name test-worker" ); - expect(patchUrl).toContain("/workers/workers/staging-worker"); + + expect(requestBody?.annotations?.["workers/message"]).toBe( + 'Deleted secret "REMOVE_ME"' + ); + }); + + test("fails clearly when the Preview has no deployments", async ({ + expect, + }) => { + mockPreviewDeploymentNotFound(10032); + + await expect( + runWrangler( + "preview secret delete REMOVE_ME --name test-preview --skip-confirmation --worker-name test-worker" + ) + ).rejects.toThrow(/no deployments for the Preview/); + }); + + test("fails clearly when the Preview is not found", async ({ + expect, + }) => { + mockPreviewDeploymentNotFound(10025); + + await expect( + runWrangler( + "preview secret delete REMOVE_ME --name test-preview --skip-confirmation --worker-name test-worker" + ) + ).rejects.toThrow(/Preview "test-preview" was not found/); }); }); diff --git a/packages/wrangler/src/preview/index.ts b/packages/wrangler/src/preview/index.ts index 97561683cf7..8c043b602ec 100644 --- a/packages/wrangler/src/preview/index.ts +++ b/packages/wrangler/src/preview/index.ts @@ -165,7 +165,8 @@ export const previewSecretNamespace = createNamespace({ export const previewSecretPutCommand = createCommand({ metadata: { - description: "Create or update a secret in the Worker's Previews settings", + description: + "Create or update a secret variable on a Worker Preview's latest deployment", owner: "Workers: Deploy and Config", category: "Compute & AI", status: "private beta", @@ -177,6 +178,21 @@ export const previewSecretPutCommand = createCommand({ type: "string", demandOption: true, }, + name: { + describe: "Name of the Preview (defaults to current git branch)", + type: "string", + requiresArg: true, + }, + message: { + describe: "A descriptive message for this Preview deployment", + type: "string", + requiresArg: true, + }, + tag: { + describe: "A tag for this Preview deployment", + type: "string", + requiresArg: true, + }, "worker-name": { describe: "Name of the Worker to target (defaults to the name in your local config file)", @@ -192,7 +208,8 @@ export const previewSecretPutCommand = createCommand({ export const previewSecretDeleteCommand = createCommand({ metadata: { - description: "Delete a secret from the Worker's Previews settings", + description: + "Delete a secret variable from a Worker Preview's latest deployment", owner: "Workers: Deploy and Config", category: "Compute & AI", status: "private beta", @@ -204,6 +221,21 @@ export const previewSecretDeleteCommand = createCommand({ type: "string", demandOption: true, }, + name: { + describe: "Name of the Preview (defaults to current git branch)", + type: "string", + requiresArg: true, + }, + message: { + describe: "A descriptive message for this Preview deployment", + type: "string", + requiresArg: true, + }, + tag: { + describe: "A tag for this Preview deployment", + type: "string", + requiresArg: true, + }, "skip-confirmation": { describe: "Skip the confirmation prompt", type: "boolean", diff --git a/packages/wrangler/src/preview/secret.ts b/packages/wrangler/src/preview/secret.ts index 7cdb4d99f77..e8a87b6aa7b 100644 --- a/packages/wrangler/src/preview/secret.ts +++ b/packages/wrangler/src/preview/secret.ts @@ -2,12 +2,18 @@ import { drawBox, editWorkerPreviewDefaults, getBindingValue, + getBranchName, getWorkerPreviewDefaults, padToVisibleWidth, + patchPreviewDeployment, resolveWorkerName, visibleLength, } from "@cloudflare/deploy-helpers"; -import { getBindingTypeFriendlyName } from "@cloudflare/workers-utils"; +import { + APIError, + getBindingTypeFriendlyName, + UserError, +} from "@cloudflare/workers-utils"; import chalk from "chalk"; import { confirm, prompt } from "../dialogs"; import { logger } from "../logger"; @@ -81,15 +87,77 @@ function formatPreviewSecrets( return drawBox(lines); } +function resolvePreviewName(args: { name?: string }): string { + const previewName = args.name ?? getBranchName(); + if (!previewName) { + throw new UserError( + "Could not determine Preview name. No git branch detected. " + + "Please provide a Preview name using --name .", + { telemetryMessage: "preview secret command missing preview name" } + ); + } + return previewName; +} + +const NO_PREVIEW_DEPLOYMENT_ERR_CODE = 10032; +const PREVIEW_NOT_FOUND_ERR_CODE = 10025; +const noPreviewDeploymentMessage = (previewName: string) => + `There are currently no deployments for the Preview "${previewName}". Please create a Preview deployment before modifying a secret.`; +const previewNotFoundMessage = (previewName: string) => + `The Preview "${previewName}" was not found. Please check the Preview name, or create it with \`wrangler preview\`.`; + +async function patchPreviewDeploymentSecrets( + config: Config, + accountId: string, + workerName: string, + previewName: string, + env: Record, + annotation: { message: string; tag?: string }, + telemetryMessages: { noDeployment: string; previewNotFound: string } +) { + try { + return await patchPreviewDeployment( + config, + accountId, + workerName, + previewName, + env, + { + "workers/message": annotation.message, + "workers/tag": annotation.tag, + } + ); + } catch (e) { + if (e instanceof APIError) { + if (e.code === NO_PREVIEW_DEPLOYMENT_ERR_CODE) { + throw new UserError(noPreviewDeploymentMessage(previewName), { + telemetryMessage: telemetryMessages.noDeployment, + }); + } + if (e.code === PREVIEW_NOT_FOUND_ERR_CODE) { + throw new UserError(previewNotFoundMessage(previewName), { + telemetryMessage: telemetryMessages.previewNotFound, + }); + } + } + throw e; + } +} + export async function handlePreviewSecretPutCommand( args: { key: string; + name?: string; + message?: string; + tag?: string; + env?: string; workerName?: string; "worker-name"?: string; }, { config }: { config: Config } ) { const workerName = resolveWorkerName(args, config); + const previewName = resolvePreviewName(args); const accountId = await requireAuth(config); const secretValue = trimTrailingWhitespace( process.stdin.isTTY @@ -97,56 +165,84 @@ export async function handlePreviewSecretPutCommand( : await readFromStdin() ); - const updatedPreviewDefaults = await editWorkerPreviewDefaults( + logger.log( + `🌀 Creating the secret for the Preview "${previewName}" on the Worker "${workerName}"${args.env ? ` (${args.env})` : ""}` + ); + + const deployment = await patchPreviewDeploymentSecrets( config, accountId, workerName, + previewName, + toSecretBindingsPatch({ [args.key]: secretValue }), + { message: args.message ?? `Updated secret "${args.key}"`, tag: args.tag }, { - env: toSecretBindingsPatch({ [args.key]: secretValue }), + noDeployment: "preview secret put no preview deployment", + previewNotFound: "preview secret put preview not found", } ); + + const liveUrls = deployment.urls ?? []; logger.log( - `\n✨ Secret "${args.key}" added to Previews settings for Worker ${chalk.bold.cyan(workerName)}.` + `✨ Success! Created Preview deployment ${deployment.id} with secret ${args.key}.` + + (liveUrls.length > 0 + ? `\n➡️ Your Preview "${previewName}" is now live at ${liveUrls + .map((url) => chalk.bold.underline(url)) + .join(", ")}` + : "") ); - logger.log(formatPreviewSecrets(workerName, updatedPreviewDefaults.env)); } export async function handlePreviewSecretDeleteCommand( args: { key: string; + name?: string; + message?: string; + tag?: string; skipConfirmation?: boolean; + env?: string; workerName?: string; "worker-name"?: string; }, { config }: { config: Config } ) { const workerName = resolveWorkerName(args, config); + const previewName = resolvePreviewName(args); const accountId = await requireAuth(config); - if (!args.skipConfirmation) { - const confirmed = await confirm( - `Are you sure you want to delete the secret "${args.key}" from Previews settings for Worker ${chalk.bold.cyan(workerName)}?` + if ( + args.skipConfirmation || + (await confirm( + `Are you sure you want to permanently delete the secret ${args.key} on the Preview "${previewName}" for the Worker ${workerName}${args.env ? ` (${args.env})` : ""}?` + )) + ) { + logger.log( + `🌀 Deleting the secret ${args.key} on the Preview "${previewName}" for the Worker ${workerName}${args.env ? ` (${args.env})` : ""}` ); - if (!confirmed) { - logger.log("Aborted."); - return; - } - } - const updatedPreviewDefaults = await editWorkerPreviewDefaults( - config, - accountId, - workerName, - { - env: { - [args.key]: null, - }, - } - ); - logger.log( - `\n✨ Secret "${args.key}" deleted from Previews settings for Worker ${chalk.bold.cyan(workerName)}.` - ); - logger.log(formatPreviewSecrets(workerName, updatedPreviewDefaults.env)); + const deployment = await patchPreviewDeploymentSecrets( + config, + accountId, + workerName, + previewName, + { [args.key]: null }, + { message: args.message ?? `Deleted secret "${args.key}"`, tag: args.tag }, + { + noDeployment: "preview secret delete no preview deployment", + previewNotFound: "preview secret delete preview not found", + } + ); + + const liveUrls = deployment.urls ?? []; + logger.log( + `✨ Success! Created Preview deployment ${deployment.id} with deleted secret ${args.key}.` + + (liveUrls.length > 0 + ? `\n➡️ Your Preview "${previewName}" is now live at ${liveUrls + .map((url) => chalk.bold.underline(url)) + .join(", ")}` + : "") + ); + } } export async function handlePreviewSecretListCommand( From 4125f0c32285f9b2e981c1825e67d36705809609 Mon Sep 17 00:00:00 2001 From: Patrick O'Donnell Date: Wed, 5 Aug 2026 10:20:24 -0400 Subject: [PATCH 2/5] WC-5233 Add preview secret list and bulk --- .changeset/preview-secret-per-preview.md | 6 +- packages/deploy-helpers/src/preview/api.ts | 2 +- .../src/__tests__/preview.secret.test.ts | 337 +++++++++++++----- packages/wrangler/src/index.ts | 10 +- packages/wrangler/src/preview/index.ts | 162 +-------- packages/wrangler/src/preview/secret.ts | 307 ---------------- packages/wrangler/src/preview/secrets/bulk.ts | 101 ++++++ .../wrangler/src/preview/secrets/delete.ts | 97 +++++ .../wrangler/src/preview/secrets/index.ts | 91 +++++ packages/wrangler/src/preview/secrets/list.ts | 148 ++++++++ packages/wrangler/src/preview/secrets/put.ts | 94 +++++ 11 files changed, 784 insertions(+), 571 deletions(-) delete mode 100644 packages/wrangler/src/preview/secret.ts create mode 100644 packages/wrangler/src/preview/secrets/bulk.ts create mode 100644 packages/wrangler/src/preview/secrets/delete.ts create mode 100644 packages/wrangler/src/preview/secrets/index.ts create mode 100644 packages/wrangler/src/preview/secrets/list.ts create mode 100644 packages/wrangler/src/preview/secrets/put.ts diff --git a/.changeset/preview-secret-per-preview.md b/.changeset/preview-secret-per-preview.md index d79013478c0..04743692f99 100644 --- a/.changeset/preview-secret-per-preview.md +++ b/.changeset/preview-secret-per-preview.md @@ -2,8 +2,6 @@ "wrangler": minor --- -Add `wrangler preview secret put` and `wrangler preview secret delete` for managing secrets on a named Worker Preview +Add `wrangler preview secret put`, `delete`, `list`, and `bulk` for managing secrets on a named Worker Preview -These private-beta commands manage secrets on a single named Preview by creating a new Preview deployment from its latest one, without affecting production, other Previews, or the Worker's Previews base configuration. - -The new deployment goes live at 100% immediately, and the command reports the live Preview URL on success. +These private-beta commands manage secrets on a single named Preview's latest deployment, without affecting production, other Previews, or the Worker's Previews base configuration. `put`, `delete`, and `bulk` create a new Preview deployment that goes live at 100% immediately and report the live Preview URL on success; `list` reads the latest deployment and prints secret names with values masked. Each command accepts `--name` to target a Preview (defaulting to the current git branch). diff --git a/packages/deploy-helpers/src/preview/api.ts b/packages/deploy-helpers/src/preview/api.ts index 0224b0f4a2a..84bd84c1541 100644 --- a/packages/deploy-helpers/src/preview/api.ts +++ b/packages/deploy-helpers/src/preview/api.ts @@ -230,7 +230,7 @@ export async function getPreviewDeployment( accountId: string, workerName: string, previewIdentifier: string, - deploymentIdentifier: string + deploymentIdentifier = "latest" ): Promise { return fetchResult( config, diff --git a/packages/wrangler/src/__tests__/preview.secret.test.ts b/packages/wrangler/src/__tests__/preview.secret.test.ts index d53eb686d85..9b6886613f0 100644 --- a/packages/wrangler/src/__tests__/preview.secret.test.ts +++ b/packages/wrangler/src/__tests__/preview.secret.test.ts @@ -1,4 +1,5 @@ import { mkdirSync, writeFileSync } from "node:fs"; +import readline from "node:readline"; import { runInTempDir } from "@cloudflare/workers-utils/test-helpers"; import { http, HttpResponse } from "msw"; import { afterEach, beforeEach, describe, test, vi } from "vitest"; @@ -57,6 +58,48 @@ function mockPreviewDeploymentNotFound(code: number) { ); } +function mockGetLatestPreviewDeployment( + env: Record, + onRequest?: (info: { url: string }) => void +) { + msw.use( + http.get( + `*/accounts/:accountId/workers/workers/:workerId/previews/:previewId/deployments/latest`, + ({ request, params }) => { + onRequest?.({ url: request.url }); + return HttpResponse.json({ + success: true, + result: { + id: "deployment-1", + preview_id: "preview-1", + preview_name: String(params.previewId), + env, + created_on: "2025-01-01T00:00:00Z", + }, + }); + } + ) + ); +} + +function mockGetPreviewDeploymentError(code: number) { + msw.use( + http.get( + `*/accounts/:accountId/workers/workers/:workerId/previews/:previewId/deployments/latest`, + () => + HttpResponse.json( + { + success: false, + errors: [{ code, message: "no preview deployment" }], + messages: [], + result: null, + }, + { status: 404 } + ) + ) + ); +} + describe("wrangler preview", () => { const std = mockConsoleMethods(); runInTempDir(); @@ -120,7 +163,7 @@ describe("wrangler preview", () => { expect(std.out).toContain("test-worker"); expect(std.out).toContain("Preview deployment"); expect(std.out).toContain( - 'is now live at https://test-preview.example.workers.dev' + "is now live at https://test-preview.example.workers.dev" ); expect(std.out).not.toContain("preview-secret"); }); @@ -298,7 +341,7 @@ describe("wrangler preview", () => { expect(std.out).toContain("test-worker"); expect(std.out).toContain("Preview deployment"); expect(std.out).toContain( - 'is now live at https://test-preview.example.workers.dev' + "is now live at https://test-preview.example.workers.dev" ); }); @@ -371,54 +414,62 @@ describe("wrangler preview", () => { }); describe("list", () => { - test("should list secrets as JSON", async ({ expect }) => { - msw.use( - http.get(`*/accounts/:accountId/workers/workers/:workerId`, () => - HttpResponse.json({ - success: true, - result: { - preview_defaults: { - env: { - DB_PASSWORD: { type: "secret_text" }, - API_KEY: { type: "secret_text" }, - PUBLIC_VAR: { type: "plain_text", text: "visible" }, - }, - }, - }, - }) - ) + test("reads the latest Preview deployment and lists secrets as JSON", async ({ + expect, + }) => { + let requestUrl: string | undefined; + mockGetLatestPreviewDeployment( + { + DB_PASSWORD: { type: "secret_text" }, + API_KEY: { type: "secret_text" }, + PUBLIC_VAR: { type: "plain_text", text: "visible" }, + }, + ({ url }) => { + requestUrl = url; + } ); await runWrangler( - "preview secret list --json --worker-name test-worker" + "preview secret list --json --name test-preview --worker-name test-worker" + ); + expect(requestUrl).toContain( + "/workers/workers/test-worker/previews/test-preview/deployments/latest" ); expect(std.out).toContain('"name": "DB_PASSWORD"'); expect(std.out).toContain('"name": "API_KEY"'); + expect(std.out).toContain('"type": "secret_text"'); expect(std.out).not.toContain("PUBLIC_VAR"); }); - test("should list secrets in pretty format", async ({ expect }) => { - msw.use( - http.get(`*/accounts/:accountId/workers/workers/:workerId`, () => - HttpResponse.json({ - success: true, - result: { - preview_defaults: { - env: { - MY_SECRET: { type: "secret_text" }, - PLAIN: { type: "plain_text", text: "not-a-secret" }, - }, - }, - }, - }) - ) + test("should list secrets in pretty format with values masked", async ({ + expect, + }) => { + mockGetLatestPreviewDeployment({ + MY_SECRET: { type: "secret_text", text: "super-secret-value" }, + PLAIN: { type: "plain_text", text: "not-a-secret" }, + }); + await runWrangler( + "preview secret list --name test-preview --worker-name test-worker" ); - await runWrangler("preview secret list --worker-name test-worker"); expect(std.out).toContain("Worker: test-worker"); - expect(std.out).toContain("Previews settings"); expect(std.out).toContain("Secrets"); expect(std.out).toContain("MY_SECRET"); expect(std.out).not.toContain("PLAIN"); expect(std.out).toContain("********"); + expect(std.out).not.toContain("super-secret-value"); + }); + + test("defaults the Preview name to the current git branch", async ({ + expect, + }) => { + vi.stubEnv("WORKERS_CI_BRANCH", "branch-preview"); + let requestUrl: string | undefined; + mockGetLatestPreviewDeployment({}, ({ url }) => { + requestUrl = url; + }); + await runWrangler("preview secret list --worker-name test-worker"); + expect(requestUrl).toContain( + "/previews/branch-preview/deployments/latest" + ); }); test("should respect env-specific worker name when listing secrets", async ({ @@ -433,64 +484,75 @@ describe("wrangler preview", () => { env: { staging: { name: "staging-worker" } }, }) ); - let getUrl: string | undefined; - msw.use( - http.get( - `*/accounts/:accountId/workers/workers/:workerId`, - ({ request }) => { - getUrl = request.url; - return HttpResponse.json({ - success: true, - result: { preview_defaults: { env: {} } }, - }); - } - ) + let requestUrl: string | undefined; + mockGetLatestPreviewDeployment({}, ({ url }) => { + requestUrl = url; + }); + await runWrangler( + "preview secret list --name test-preview --env staging" + ); + expect(requestUrl).toContain( + "/workers/workers/staging-worker/previews/test-preview/deployments/latest" ); - await runWrangler("preview secret list --env staging"); - expect(getUrl).toContain("/workers/workers/staging-worker"); + }); + + test("fails clearly when the Preview has no deployments", async ({ + expect, + }) => { + mockGetPreviewDeploymentError(10222); + await expect( + runWrangler( + "preview secret list --name test-preview --worker-name test-worker" + ) + ).rejects.toThrow(/no deployments for the Preview/); + }); + + test("fails clearly when the Preview is not found", async ({ + expect, + }) => { + mockGetPreviewDeploymentError(10025); + await expect( + runWrangler( + "preview secret list --name test-preview --worker-name test-worker" + ) + ).rejects.toThrow(/Preview "test-preview" was not found/); }); }); describe("bulk", () => { - test("should bulk upload secrets to Previews settings", async ({ + test("creates a new Preview deployment with all secrets", async ({ expect, }) => { writeFileSync("secrets.env", "FIRST_KEY=one\nSECOND_KEY=two\n"); - let patchRequestBody: - | { - preview_defaults?: { - env?: Record; - }; - } - | undefined; - msw.use( - http.patch( - `*/accounts/:accountId/workers/workers/:workerId`, - async ({ request }) => { - patchRequestBody = - (await request.json()) as typeof patchRequestBody; - return HttpResponse.json({ - success: true, - result: { - preview_defaults: { - env: patchRequestBody?.preview_defaults?.env ?? {}, - }, - }, - }); - } - ) + let requestUrl: string | undefined; + let requestBody: PreviewDeploymentPatchBody | undefined; + mockPatchLatestPreviewDeployment(({ url, body }) => { + requestUrl = url; + requestBody = body; + }); + await runWrangler( + "preview secret bulk secrets.env --name test-preview --worker-name test-worker" + ); + expect(requestUrl).toContain( + "/workers/workers/test-worker/previews/test-preview/deployments/latest" ); - await runWrangler("preview secret bulk secrets.env"); - const env = patchRequestBody?.preview_defaults?.env ?? {}; - expect(env).toEqual({ + expect(requestBody?.env).toEqual({ FIRST_KEY: { type: "secret_text", text: "one" }, SECOND_KEY: { type: "secret_text", text: "two" }, }); - expect(std.out).toContain("Worker: test-worker"); - expect(std.out).toContain("Secrets"); - expect(std.out).toContain("FIRST_KEY"); - expect(std.out).toContain("SECOND_KEY"); - expect(std.out).toContain("********"); + expect(std.out).toContain( + "Successfully created secret for key: FIRST_KEY" + ); + expect(std.out).toContain( + "Successfully created secret for key: SECOND_KEY" + ); + expect(std.out).toContain("Created Preview deployment deployment-1"); + expect(std.out).toContain("with 2 secrets"); + expect(std.out).toContain( + "is now live at https://test-preview.example.workers.dev" + ); + expect(std.out).not.toContain("one"); + expect(std.out).not.toContain("two"); }); test("should respect env-specific worker name when bulk uploading secrets", async ({ @@ -506,18 +568,107 @@ describe("wrangler preview", () => { env: { staging: { name: "staging-worker" } }, }) ); - let patchUrl: string | undefined; - msw.use( - http.patch( - `*/accounts/:accountId/workers/workers/:workerId`, - ({ request }) => { - patchUrl = request.url; - return HttpResponse.json({ success: true, result: {} }); - } - ) + let requestUrl: string | undefined; + mockPatchLatestPreviewDeployment(({ url }) => { + requestUrl = url; + }); + await runWrangler( + "preview secret bulk secrets.env --name test-preview --env staging" + ); + expect(requestUrl).toContain( + "/workers/workers/staging-worker/previews/test-preview/deployments/latest" + ); + }); + + test("sends --message and --tag as deployment annotations", async ({ + expect, + }) => { + writeFileSync("secrets.env", "API_KEY=one\n"); + let requestBody: PreviewDeploymentPatchBody | undefined; + mockPatchLatestPreviewDeployment(({ body }) => { + requestBody = body; + }); + await runWrangler( + 'preview secret bulk secrets.env --name test-preview --worker-name test-worker --message "add secrets" --tag v1' + ); + expect(requestBody?.annotations).toMatchObject({ + "workers/message": "add secrets", + "workers/tag": "v1", + }); + }); + + test("uses the default annotation message when none is provided", async ({ + expect, + }) => { + writeFileSync("secrets.env", "FIRST_KEY=one\nSECOND_KEY=two\n"); + let requestBody: PreviewDeploymentPatchBody | undefined; + mockPatchLatestPreviewDeployment(({ body }) => { + requestBody = body; + }); + await runWrangler( + "preview secret bulk secrets.env --name test-preview --worker-name test-worker" + ); + expect(requestBody?.annotations?.["workers/message"]).toBe( + "Bulk updated 2 secrets" + ); + }); + + test("creates a deployment with an empty patch for null-only input", async ({ + expect, + }) => { + writeFileSync( + "secrets.json", + JSON.stringify({ REMOVE_ME: null, ALSO_GONE: null }) + ); + let requestBody: PreviewDeploymentPatchBody | undefined; + mockPatchLatestPreviewDeployment(({ body }) => { + requestBody = body; + }); + await runWrangler( + "preview secret bulk secrets.json --name test-preview --worker-name test-worker" + ); + expect(requestBody?.env).toEqual({}); + expect(std.out).toContain("Created Preview deployment deployment-1"); + expect(std.out).toContain("with 0 secrets"); + }); + + test("makes no API call when there is no input", async ({ expect }) => { + let requested = false; + mockPatchLatestPreviewDeployment(() => { + requested = true; + }); + vi.spyOn(readline, "createInterface").mockImplementation( + () => null as unknown as readline.Interface ); - await runWrangler("preview secret bulk secrets.env --env staging"); - expect(patchUrl).toContain("/workers/workers/staging-worker"); + await runWrangler( + "preview secret bulk --name test-preview --worker-name test-worker" + ); + expect(requested).toBe(false); + expect(std.err).toContain("No content found in file or piped input."); + }); + + test("fails clearly when the Preview has no deployments", async ({ + expect, + }) => { + writeFileSync("secrets.env", "API_KEY=one\n"); + mockPreviewDeploymentNotFound(10032); + await expect( + runWrangler( + "preview secret bulk secrets.env --name test-preview --worker-name test-worker" + ) + ).rejects.toThrow(/no deployments for the Preview/); + }); + + test("fails clearly when the Preview is not found", async ({ + expect, + }) => { + writeFileSync("secrets.env", "API_KEY=one\n"); + mockPreviewDeploymentNotFound(10025); + await expect( + runWrangler( + "preview secret bulk secrets.env --name test-preview --worker-name test-worker" + ) + ).rejects.toThrow(/Preview "test-preview" was not found/); }); }); }); diff --git a/packages/wrangler/src/index.ts b/packages/wrangler/src/index.ts index 2501578b7e7..e959b82a421 100644 --- a/packages/wrangler/src/index.ts +++ b/packages/wrangler/src/index.ts @@ -314,14 +314,14 @@ import { pipelinesUpdateCommand } from "./pipelines/cli/update"; import { previewCommand, previewDeleteCommand, - previewSecretBulkCommand, - previewSecretDeleteCommand, - previewSecretListCommand, - previewSecretNamespace, - previewSecretPutCommand, previewSettingsCommand, previewSettingsUpdateCommand, } from "./preview"; +import { previewSecretNamespace } from "./preview/secrets"; +import { previewSecretBulkCommand } from "./preview/secrets/bulk"; +import { previewSecretDeleteCommand } from "./preview/secrets/delete"; +import { previewSecretListCommand } from "./preview/secrets/list"; +import { previewSecretPutCommand } from "./preview/secrets/put"; import { queuesNamespace } from "./queues/cli/commands"; import { queuesConsumerNamespace } from "./queues/cli/commands/consumer"; import { queuesConsumerHttpNamespace } from "./queues/cli/commands/consumer/http-pull"; diff --git a/packages/wrangler/src/preview/index.ts b/packages/wrangler/src/preview/index.ts index 8c043b602ec..6b2cc7d8969 100644 --- a/packages/wrangler/src/preview/index.ts +++ b/packages/wrangler/src/preview/index.ts @@ -1,11 +1,5 @@ -import { createCommand, createNamespace } from "../core/create-command"; +import { createCommand } from "../core/create-command"; import { handlePreviewCommand, handlePreviewDeleteCommand } from "./commands"; -import { - handlePreviewSecretBulkCommand, - handlePreviewSecretDeleteCommand, - handlePreviewSecretListCommand, - handlePreviewSecretPutCommand, -} from "./secret"; import { handlePreviewSettingsCommand, handlePreviewSettingsUpdateCommand, @@ -153,157 +147,3 @@ export const previewSettingsCommand = createCommand({ }, handler: handlePreviewSettingsCommand, }); - -export const previewSecretNamespace = createNamespace({ - metadata: { - description: "Manage secrets for Worker Previews", - owner: "Workers: Deploy and Config", - category: "Compute & AI", - status: "private beta", - }, -}); - -export const previewSecretPutCommand = createCommand({ - metadata: { - description: - "Create or update a secret variable on a Worker Preview's latest deployment", - owner: "Workers: Deploy and Config", - category: "Compute & AI", - status: "private beta", - }, - positionalArgs: ["key"], - args: { - key: { - describe: "The secret name to be accessible in the Worker", - type: "string", - demandOption: true, - }, - name: { - describe: "Name of the Preview (defaults to current git branch)", - type: "string", - requiresArg: true, - }, - message: { - describe: "A descriptive message for this Preview deployment", - type: "string", - requiresArg: true, - }, - tag: { - describe: "A tag for this Preview deployment", - type: "string", - requiresArg: true, - }, - "worker-name": { - describe: - "Name of the Worker to target (defaults to the name in your local config file)", - type: "string", - requiresArg: true, - }, - }, - behaviour: { - suggestSkillsAfterHandler: true, - }, - handler: handlePreviewSecretPutCommand, -}); - -export const previewSecretDeleteCommand = createCommand({ - metadata: { - description: - "Delete a secret variable from a Worker Preview's latest deployment", - owner: "Workers: Deploy and Config", - category: "Compute & AI", - status: "private beta", - }, - positionalArgs: ["key"], - args: { - key: { - describe: "The secret name to delete", - type: "string", - demandOption: true, - }, - name: { - describe: "Name of the Preview (defaults to current git branch)", - type: "string", - requiresArg: true, - }, - message: { - describe: "A descriptive message for this Preview deployment", - type: "string", - requiresArg: true, - }, - tag: { - describe: "A tag for this Preview deployment", - type: "string", - requiresArg: true, - }, - "skip-confirmation": { - describe: "Skip the confirmation prompt", - type: "boolean", - default: false, - alias: "y", - }, - "worker-name": { - describe: - "Name of the Worker to target (defaults to the name in your local config file)", - type: "string", - requiresArg: true, - }, - }, - behaviour: { - suggestSkillsAfterHandler: true, - }, - handler: handlePreviewSecretDeleteCommand, -}); - -export const previewSecretListCommand = createCommand({ - metadata: { - description: "List all secrets in the Worker's Previews settings", - owner: "Workers: Deploy and Config", - category: "Compute & AI", - status: "private beta", - }, - args: { - json: { - describe: "Return output as JSON", - type: "boolean", - default: false, - }, - "worker-name": { - describe: - "Name of the Worker to target (defaults to the name in your local config file)", - type: "string", - requiresArg: true, - }, - }, - behaviour: { - printBanner: (args) => args.json !== true, - suggestSkillsAfterHandler: (args) => args.json !== true, - }, - handler: handlePreviewSecretListCommand, -}); - -export const previewSecretBulkCommand = createCommand({ - metadata: { - description: "Upload multiple secrets to the Worker's Previews settings", - owner: "Workers: Deploy and Config", - category: "Compute & AI", - status: "private beta", - }, - positionalArgs: ["file"], - args: { - file: { - describe: "The file of key-value pairs to upload, as JSON or .env format", - type: "string", - }, - "worker-name": { - describe: - "Name of the Worker to target (defaults to the name in your local config file)", - type: "string", - requiresArg: true, - }, - }, - behaviour: { - suggestSkillsAfterHandler: true, - }, - handler: handlePreviewSecretBulkCommand, -}); diff --git a/packages/wrangler/src/preview/secret.ts b/packages/wrangler/src/preview/secret.ts deleted file mode 100644 index e8a87b6aa7b..00000000000 --- a/packages/wrangler/src/preview/secret.ts +++ /dev/null @@ -1,307 +0,0 @@ -import { - drawBox, - editWorkerPreviewDefaults, - getBindingValue, - getBranchName, - getWorkerPreviewDefaults, - padToVisibleWidth, - patchPreviewDeployment, - resolveWorkerName, - visibleLength, -} from "@cloudflare/deploy-helpers"; -import { - APIError, - getBindingTypeFriendlyName, - UserError, -} from "@cloudflare/workers-utils"; -import chalk from "chalk"; -import { confirm, prompt } from "../dialogs"; -import { logger } from "../logger"; -import { parseBulkInputToObject } from "../secret"; -import { requireAuth } from "../user"; -import { readFromStdin, trimTrailingWhitespace } from "../utils/std"; -import type { Binding, EnvBindings } from "@cloudflare/deploy-helpers"; -import type { Config } from "@cloudflare/workers-utils"; - -type SecretSummary = { - name: string; - type: "secret_text"; -}; - -function isSecretBinding(binding: Binding): binding is Binding & { - type: "secret_text"; - text?: string; -} { - return binding.type === "secret_text"; -} - -function toSecretBindingsPatch(secrets: Record): EnvBindings { - return Object.fromEntries( - Object.entries(secrets).map(([name, text]) => [ - name, - { type: "secret_text", text }, - ]) - ); -} - -function extractSecretSummaries(env: EnvBindings | undefined): SecretSummary[] { - return Object.entries(env ?? {}) - .filter(([, binding]) => binding !== null && isSecretBinding(binding)) - .map(([name]) => ({ name, type: "secret_text" })); -} - -function formatPreviewSecrets( - workerName: string, - env: EnvBindings | undefined -): string { - const secrets = Object.entries(env ?? {}).filter( - ([, binding]) => binding !== null && isSecretBinding(binding) - ); - const lines: string[] = []; - lines.push(`${chalk.bold.hex("#FFA500")("Worker:")} ${workerName}`); - lines.push(""); - lines.push(` ${chalk.bold.underline("Previews settings")}`); - lines.push(""); - lines.push(chalk.bold(" Secrets")); - - if (secrets.length === 0) { - lines.push(` ${chalk.dim("(none)")}`); - lines.push(""); - return drawBox(lines); - } - - const typeLabel = getBindingTypeFriendlyName("secret_text"); - const nameWidth = Math.max(...secrets.map(([name]) => name.length)); - const typeWidth = visibleLength(typeLabel); - const valueWidth = Math.max( - ...secrets.map(([, binding]) => getBindingValue(binding).length) - ); - - for (const [name, binding] of secrets) { - lines.push( - ` ${chalk.cyan(padToVisibleWidth(name, nameWidth))} ${chalk.dim(padToVisibleWidth(typeLabel, typeWidth))} ${padToVisibleWidth(getBindingValue(binding), valueWidth)}` - ); - } - - lines.push(""); - return drawBox(lines); -} - -function resolvePreviewName(args: { name?: string }): string { - const previewName = args.name ?? getBranchName(); - if (!previewName) { - throw new UserError( - "Could not determine Preview name. No git branch detected. " + - "Please provide a Preview name using --name .", - { telemetryMessage: "preview secret command missing preview name" } - ); - } - return previewName; -} - -const NO_PREVIEW_DEPLOYMENT_ERR_CODE = 10032; -const PREVIEW_NOT_FOUND_ERR_CODE = 10025; -const noPreviewDeploymentMessage = (previewName: string) => - `There are currently no deployments for the Preview "${previewName}". Please create a Preview deployment before modifying a secret.`; -const previewNotFoundMessage = (previewName: string) => - `The Preview "${previewName}" was not found. Please check the Preview name, or create it with \`wrangler preview\`.`; - -async function patchPreviewDeploymentSecrets( - config: Config, - accountId: string, - workerName: string, - previewName: string, - env: Record, - annotation: { message: string; tag?: string }, - telemetryMessages: { noDeployment: string; previewNotFound: string } -) { - try { - return await patchPreviewDeployment( - config, - accountId, - workerName, - previewName, - env, - { - "workers/message": annotation.message, - "workers/tag": annotation.tag, - } - ); - } catch (e) { - if (e instanceof APIError) { - if (e.code === NO_PREVIEW_DEPLOYMENT_ERR_CODE) { - throw new UserError(noPreviewDeploymentMessage(previewName), { - telemetryMessage: telemetryMessages.noDeployment, - }); - } - if (e.code === PREVIEW_NOT_FOUND_ERR_CODE) { - throw new UserError(previewNotFoundMessage(previewName), { - telemetryMessage: telemetryMessages.previewNotFound, - }); - } - } - throw e; - } -} - -export async function handlePreviewSecretPutCommand( - args: { - key: string; - name?: string; - message?: string; - tag?: string; - env?: string; - workerName?: string; - "worker-name"?: string; - }, - { config }: { config: Config } -) { - const workerName = resolveWorkerName(args, config); - const previewName = resolvePreviewName(args); - const accountId = await requireAuth(config); - const secretValue = trimTrailingWhitespace( - process.stdin.isTTY - ? await prompt("Enter a secret value:", { isSecret: true }) - : await readFromStdin() - ); - - logger.log( - `🌀 Creating the secret for the Preview "${previewName}" on the Worker "${workerName}"${args.env ? ` (${args.env})` : ""}` - ); - - const deployment = await patchPreviewDeploymentSecrets( - config, - accountId, - workerName, - previewName, - toSecretBindingsPatch({ [args.key]: secretValue }), - { message: args.message ?? `Updated secret "${args.key}"`, tag: args.tag }, - { - noDeployment: "preview secret put no preview deployment", - previewNotFound: "preview secret put preview not found", - } - ); - - const liveUrls = deployment.urls ?? []; - logger.log( - `✨ Success! Created Preview deployment ${deployment.id} with secret ${args.key}.` + - (liveUrls.length > 0 - ? `\n➡️ Your Preview "${previewName}" is now live at ${liveUrls - .map((url) => chalk.bold.underline(url)) - .join(", ")}` - : "") - ); -} - -export async function handlePreviewSecretDeleteCommand( - args: { - key: string; - name?: string; - message?: string; - tag?: string; - skipConfirmation?: boolean; - env?: string; - workerName?: string; - "worker-name"?: string; - }, - { config }: { config: Config } -) { - const workerName = resolveWorkerName(args, config); - const previewName = resolvePreviewName(args); - const accountId = await requireAuth(config); - - if ( - args.skipConfirmation || - (await confirm( - `Are you sure you want to permanently delete the secret ${args.key} on the Preview "${previewName}" for the Worker ${workerName}${args.env ? ` (${args.env})` : ""}?` - )) - ) { - logger.log( - `🌀 Deleting the secret ${args.key} on the Preview "${previewName}" for the Worker ${workerName}${args.env ? ` (${args.env})` : ""}` - ); - - const deployment = await patchPreviewDeploymentSecrets( - config, - accountId, - workerName, - previewName, - { [args.key]: null }, - { message: args.message ?? `Deleted secret "${args.key}"`, tag: args.tag }, - { - noDeployment: "preview secret delete no preview deployment", - previewNotFound: "preview secret delete preview not found", - } - ); - - const liveUrls = deployment.urls ?? []; - logger.log( - `✨ Success! Created Preview deployment ${deployment.id} with deleted secret ${args.key}.` + - (liveUrls.length > 0 - ? `\n➡️ Your Preview "${previewName}" is now live at ${liveUrls - .map((url) => chalk.bold.underline(url)) - .join(", ")}` - : "") - ); - } -} - -export async function handlePreviewSecretListCommand( - args: { - json?: boolean; - workerName?: string; - "worker-name"?: string; - }, - { config }: { config: Config } -) { - const workerName = resolveWorkerName(args, config); - const accountId = await requireAuth(config); - - const previewDefaults = await getWorkerPreviewDefaults( - config, - accountId, - workerName - ); - const secrets = extractSecretSummaries(previewDefaults.env); - - if (args.json) { - logger.log(JSON.stringify(secrets, null, 2)); - return; - } - - logger.log(formatPreviewSecrets(workerName, previewDefaults.env)); -} - -export async function handlePreviewSecretBulkCommand( - args: { - file?: string; - workerName?: string; - "worker-name"?: string; - }, - { config }: { config: Config } -) { - const workerName = resolveWorkerName(args, config); - const accountId = await requireAuth(config); - const result = await parseBulkInputToObject(args.file); - - if (!result) { - logger.error("No content found in file, or piped input."); - return; - } - - const { content } = result; - const secretCount = Object.keys(content).length; - const source = args.file ? `file "${args.file}"` : "stdin"; - - const updatedPreviewDefaults = await editWorkerPreviewDefaults( - config, - accountId, - workerName, - { - env: toSecretBindingsPatch(content), - } - ); - logger.log( - `\n✨ Uploaded ${secretCount} secrets from ${source} to Previews settings for Worker ${chalk.bold.cyan(workerName)}.` - ); - logger.log(formatPreviewSecrets(workerName, updatedPreviewDefaults.env)); -} diff --git a/packages/wrangler/src/preview/secrets/bulk.ts b/packages/wrangler/src/preview/secrets/bulk.ts new file mode 100644 index 00000000000..b4c65a2e5db --- /dev/null +++ b/packages/wrangler/src/preview/secrets/bulk.ts @@ -0,0 +1,101 @@ +import { resolveWorkerName } from "@cloudflare/deploy-helpers"; +import chalk from "chalk"; +import { createCommand } from "../../core/create-command"; +import { logger } from "../../logger"; +import { parseBulkInputToObject } from "../../secret"; +import { requireAuth } from "../../user"; +import { + patchPreviewDeploymentSecrets, + resolvePreviewName, + toSecretBindingsPatch, +} from "./index"; + +export const previewSecretBulkCommand = createCommand({ + metadata: { + description: + "Upload multiple secrets to a Worker Preview's latest deployment", + owner: "Workers: Deploy and Config", + category: "Compute & AI", + status: "private beta", + }, + positionalArgs: ["file"], + args: { + file: { + describe: "The file of key-value pairs to upload, as JSON or .env format", + type: "string", + }, + name: { + describe: "Name of the Preview (defaults to current git branch)", + type: "string", + requiresArg: true, + }, + message: { + describe: "A descriptive message for this Preview deployment", + type: "string", + requiresArg: true, + }, + tag: { + describe: "A tag for this Preview deployment", + type: "string", + requiresArg: true, + }, + "worker-name": { + describe: + "Name of the Worker to target (defaults to the name in your local config file)", + type: "string", + requiresArg: true, + }, + }, + behaviour: { + suggestSkillsAfterHandler: true, + }, + handler: async function previewSecretBulkHandler(args, { config }) { + const workerName = resolveWorkerName(args, config); + const previewName = resolvePreviewName(args); + const accountId = await requireAuth(config); + + logger.log( + `🌀 Creating the secrets for the Preview "${previewName}" on the Worker "${workerName}"${args.env ? ` (${args.env})` : ""}` + ); + + const result = await parseBulkInputToObject(args.file); + + if (!result) { + logger.error("No content found in file or piped input."); + return; + } + + const { content } = result; + const secretEntries = Object.entries(content); + + const deployment = await patchPreviewDeploymentSecrets( + config, + accountId, + workerName, + previewName, + toSecretBindingsPatch(content), + { + message: args.message ?? `Bulk updated ${secretEntries.length} secrets`, + tag: args.tag, + }, + { + noDeployment: "preview secret bulk no preview deployment", + previewNotFound: "preview secret bulk preview not found", + } + ); + + for (const [name] of secretEntries) { + logger.log(`✨ Successfully created secret for key: ${name}`); + } + + const liveUrls = deployment.urls ?? []; + logger.log( + `✨ Success! Created Preview deployment ${deployment.id} with ${secretEntries.length} secrets.` + + (liveUrls.length > 0 + ? `\n➡️ Your Preview "${previewName}" is now live at ${liveUrls + .map((url) => chalk.bold.underline(url)) + .join(", ")}` + : "") + ); + }, +}); diff --git a/packages/wrangler/src/preview/secrets/delete.ts b/packages/wrangler/src/preview/secrets/delete.ts new file mode 100644 index 00000000000..9360334bc46 --- /dev/null +++ b/packages/wrangler/src/preview/secrets/delete.ts @@ -0,0 +1,97 @@ +import { resolveWorkerName } from "@cloudflare/deploy-helpers"; +import chalk from "chalk"; +import { createCommand } from "../../core/create-command"; +import { confirm } from "../../dialogs"; +import { logger } from "../../logger"; +import { requireAuth } from "../../user"; +import { patchPreviewDeploymentSecrets, resolvePreviewName } from "./index"; + +export const previewSecretDeleteCommand = createCommand({ + metadata: { + description: + "Delete a secret variable from a Worker Preview's latest deployment", + owner: "Workers: Deploy and Config", + category: "Compute & AI", + status: "private beta", + }, + positionalArgs: ["key"], + args: { + key: { + describe: "The secret name to delete", + type: "string", + demandOption: true, + }, + name: { + describe: "Name of the Preview (defaults to current git branch)", + type: "string", + requiresArg: true, + }, + message: { + describe: "A descriptive message for this Preview deployment", + type: "string", + requiresArg: true, + }, + tag: { + describe: "A tag for this Preview deployment", + type: "string", + requiresArg: true, + }, + "skip-confirmation": { + describe: "Skip the confirmation prompt", + type: "boolean", + default: false, + alias: "y", + }, + "worker-name": { + describe: + "Name of the Worker to target (defaults to the name in your local config file)", + type: "string", + requiresArg: true, + }, + }, + behaviour: { + suggestSkillsAfterHandler: true, + }, + handler: async function previewSecretDeleteHandler(args, { config }) { + const workerName = resolveWorkerName(args, config); + const previewName = resolvePreviewName(args); + const accountId = await requireAuth(config); + + if ( + args.skipConfirmation || + (await confirm( + `Are you sure you want to permanently delete the secret ${args.key} on the Preview "${previewName}" for the Worker ${workerName}${args.env ? ` (${args.env})` : ""}?` + )) + ) { + logger.log( + `🌀 Deleting the secret ${args.key} on the Preview "${previewName}" for the Worker ${workerName}${args.env ? ` (${args.env})` : ""}` + ); + + const deployment = await patchPreviewDeploymentSecrets( + config, + accountId, + workerName, + previewName, + { [args.key]: null }, + { + message: args.message ?? `Deleted secret "${args.key}"`, + tag: args.tag, + }, + { + noDeployment: "preview secret delete no preview deployment", + previewNotFound: "preview secret delete preview not found", + } + ); + + const liveUrls = deployment.urls ?? []; + logger.log( + `✨ Success! Created Preview deployment ${deployment.id} with deleted secret ${args.key}.` + + (liveUrls.length > 0 + ? `\n➡️ Your Preview "${previewName}" is now live at ${liveUrls + .map((url) => chalk.bold.underline(url)) + .join(", ")}` + : "") + ); + } + }, +}); diff --git a/packages/wrangler/src/preview/secrets/index.ts b/packages/wrangler/src/preview/secrets/index.ts new file mode 100644 index 00000000000..5de3b3962d4 --- /dev/null +++ b/packages/wrangler/src/preview/secrets/index.ts @@ -0,0 +1,91 @@ +import { + getBranchName, + patchPreviewDeployment, +} from "@cloudflare/deploy-helpers"; +import { APIError, UserError } from "@cloudflare/workers-utils"; +import { createNamespace } from "../../core/create-command"; +import type { Binding, EnvBindings } from "@cloudflare/deploy-helpers"; +import type { Config } from "@cloudflare/workers-utils"; + +export const previewSecretNamespace = createNamespace({ + metadata: { + description: "Manage secrets for Worker Previews", + owner: "Workers: Deploy and Config", + category: "Compute & AI", + status: "private beta", + }, +}); + +export function resolvePreviewName(args: { name?: string }): string { + const previewName = args.name ?? getBranchName(); + if (!previewName) { + throw new UserError( + "Could not determine Preview name. No git branch detected. " + + "Please provide a Preview name using --name .", + { telemetryMessage: "preview secret command missing preview name" } + ); + } + return previewName; +} + +export function toSecretBindingsPatch( + secrets: Record +): EnvBindings { + return Object.fromEntries( + Object.entries(secrets).map(([name, text]) => [ + name, + { type: "secret_text", text }, + ]) + ); +} + +// The PATCH (put/delete/bulk) and GET (list) paths report different +// no-deployment error codes; not-found is shared. +export const NO_PREVIEW_DEPLOYMENT_PATCH_ERR_CODE = 10032; +export const NO_PREVIEW_DEPLOYMENT_GET_ERR_CODE = 10222; +export const PREVIEW_NOT_FOUND_ERR_CODE = 10025; + +export const noPreviewDeploymentPatchMessage = (previewName: string) => + `There are currently no deployments for the Preview "${previewName}". Please create a Preview deployment before modifying a secret.`; +export const noPreviewDeploymentListMessage = (previewName: string) => + `There are currently no deployments for the Preview "${previewName}" - please create a Preview deployment.`; +export const previewNotFoundMessage = (previewName: string) => + `The Preview "${previewName}" was not found. Please check the Preview name, or create it with \`wrangler preview\`.`; + +export async function patchPreviewDeploymentSecrets( + config: Config, + accountId: string, + workerName: string, + previewName: string, + env: Record, + annotation: { message: string; tag?: string }, + telemetryMessages: { noDeployment: string; previewNotFound: string } +) { + try { + return await patchPreviewDeployment( + config, + accountId, + workerName, + previewName, + env, + { + "workers/message": annotation.message, + "workers/tag": annotation.tag, + } + ); + } catch (e) { + if (e instanceof APIError) { + if (e.code === NO_PREVIEW_DEPLOYMENT_PATCH_ERR_CODE) { + throw new UserError(noPreviewDeploymentPatchMessage(previewName), { + telemetryMessage: telemetryMessages.noDeployment, + }); + } + if (e.code === PREVIEW_NOT_FOUND_ERR_CODE) { + throw new UserError(previewNotFoundMessage(previewName), { + telemetryMessage: telemetryMessages.previewNotFound, + }); + } + } + throw e; + } +} diff --git a/packages/wrangler/src/preview/secrets/list.ts b/packages/wrangler/src/preview/secrets/list.ts new file mode 100644 index 00000000000..b8ec8d53c73 --- /dev/null +++ b/packages/wrangler/src/preview/secrets/list.ts @@ -0,0 +1,148 @@ +import { + drawBox, + getBindingValue, + getPreviewDeployment, + padToVisibleWidth, + resolveWorkerName, + visibleLength, +} from "@cloudflare/deploy-helpers"; +import { + APIError, + getBindingTypeFriendlyName, + UserError, +} from "@cloudflare/workers-utils"; +import chalk from "chalk"; +import { createCommand } from "../../core/create-command"; +import { logger } from "../../logger"; +import { requireAuth } from "../../user"; +import { + NO_PREVIEW_DEPLOYMENT_GET_ERR_CODE, + noPreviewDeploymentListMessage, + PREVIEW_NOT_FOUND_ERR_CODE, + previewNotFoundMessage, + resolvePreviewName, +} from "./index"; +import type { Binding, EnvBindings } from "@cloudflare/deploy-helpers"; + +type SecretSummary = { + name: string; + type: "secret_text"; +}; + +function isSecretBinding(binding: Binding): binding is Binding & { + type: "secret_text"; + text?: string; +} { + return binding.type === "secret_text"; +} + +function extractSecretSummaries(env: EnvBindings | undefined): SecretSummary[] { + return Object.entries(env ?? {}) + .filter(([, binding]) => binding !== null && isSecretBinding(binding)) + .map(([name]) => ({ name, type: "secret_text" })); +} + +function formatPreviewSecrets( + workerName: string, + env: EnvBindings | undefined +): string { + const secrets = Object.entries(env ?? {}).filter( + ([, binding]) => binding !== null && isSecretBinding(binding) + ); + const lines: string[] = []; + lines.push(`${chalk.bold.hex("#FFA500")("Worker:")} ${workerName}`); + lines.push(""); + lines.push(` ${chalk.bold.underline("Previews settings")}`); + lines.push(""); + lines.push(chalk.bold(" Secrets")); + + if (secrets.length === 0) { + lines.push(` ${chalk.dim("(none)")}`); + lines.push(""); + return drawBox(lines); + } + + const typeLabel = getBindingTypeFriendlyName("secret_text"); + const nameWidth = Math.max(...secrets.map(([name]) => name.length)); + const typeWidth = visibleLength(typeLabel); + const valueWidth = Math.max( + ...secrets.map(([, binding]) => getBindingValue(binding).length) + ); + + for (const [name, binding] of secrets) { + lines.push( + ` ${chalk.cyan(padToVisibleWidth(name, nameWidth))} ${chalk.dim(padToVisibleWidth(typeLabel, typeWidth))} ${padToVisibleWidth(getBindingValue(binding), valueWidth)}` + ); + } + + lines.push(""); + return drawBox(lines); +} + +export const previewSecretListCommand = createCommand({ + metadata: { + description: "List all secrets on a Worker Preview's latest deployment", + owner: "Workers: Deploy and Config", + category: "Compute & AI", + status: "private beta", + }, + args: { + name: { + describe: "Name of the Preview (defaults to current git branch)", + type: "string", + requiresArg: true, + }, + json: { + describe: "Return output as JSON", + type: "boolean", + default: false, + }, + "worker-name": { + describe: + "Name of the Worker to target (defaults to the name in your local config file)", + type: "string", + requiresArg: true, + }, + }, + behaviour: { + printBanner: (args) => args.json !== true, + suggestSkillsAfterHandler: (args) => args.json !== true, + }, + handler: async function previewSecretListHandler(args, { config }) { + const workerName = resolveWorkerName(args, config); + const previewName = resolvePreviewName(args); + const accountId = await requireAuth(config); + + let deployment; + try { + deployment = await getPreviewDeployment( + config, + accountId, + workerName, + previewName + ); + } catch (e) { + if (e instanceof APIError) { + if (e.code === NO_PREVIEW_DEPLOYMENT_GET_ERR_CODE) { + throw new UserError(noPreviewDeploymentListMessage(previewName), { + telemetryMessage: "preview secret list no preview deployment", + }); + } + if (e.code === PREVIEW_NOT_FOUND_ERR_CODE) { + throw new UserError(previewNotFoundMessage(previewName), { + telemetryMessage: "preview secret list preview not found", + }); + } + } + throw e; + } + const secrets = extractSecretSummaries(deployment.env); + + if (args.json) { + logger.log(JSON.stringify(secrets, null, 2)); + return; + } + + logger.log(formatPreviewSecrets(workerName, deployment.env)); + }, +}); diff --git a/packages/wrangler/src/preview/secrets/put.ts b/packages/wrangler/src/preview/secrets/put.ts new file mode 100644 index 00000000000..26a43ed4abd --- /dev/null +++ b/packages/wrangler/src/preview/secrets/put.ts @@ -0,0 +1,94 @@ +import { resolveWorkerName } from "@cloudflare/deploy-helpers"; +import chalk from "chalk"; +import { createCommand } from "../../core/create-command"; +import { prompt } from "../../dialogs"; +import { logger } from "../../logger"; +import { requireAuth } from "../../user"; +import { readFromStdin, trimTrailingWhitespace } from "../../utils/std"; +import { + patchPreviewDeploymentSecrets, + resolvePreviewName, + toSecretBindingsPatch, +} from "./index"; + +export const previewSecretPutCommand = createCommand({ + metadata: { + description: + "Create or update a secret variable on a Worker Preview's latest deployment", + owner: "Workers: Deploy and Config", + category: "Compute & AI", + status: "private beta", + }, + positionalArgs: ["key"], + args: { + key: { + describe: "The secret name to be accessible in the Worker", + type: "string", + demandOption: true, + }, + name: { + describe: "Name of the Preview (defaults to current git branch)", + type: "string", + requiresArg: true, + }, + message: { + describe: "A descriptive message for this Preview deployment", + type: "string", + requiresArg: true, + }, + tag: { + describe: "A tag for this Preview deployment", + type: "string", + requiresArg: true, + }, + "worker-name": { + describe: + "Name of the Worker to target (defaults to the name in your local config file)", + type: "string", + requiresArg: true, + }, + }, + behaviour: { + suggestSkillsAfterHandler: true, + }, + handler: async function previewSecretPutHandler(args, { config }) { + const workerName = resolveWorkerName(args, config); + const previewName = resolvePreviewName(args); + const accountId = await requireAuth(config); + const secretValue = trimTrailingWhitespace( + process.stdin.isTTY + ? await prompt("Enter a secret value:", { isSecret: true }) + : await readFromStdin() + ); + + logger.log( + `🌀 Creating the secret for the Preview "${previewName}" on the Worker "${workerName}"${args.env ? ` (${args.env})` : ""}` + ); + + const deployment = await patchPreviewDeploymentSecrets( + config, + accountId, + workerName, + previewName, + toSecretBindingsPatch({ [args.key]: secretValue }), + { + message: args.message ?? `Updated secret "${args.key}"`, + tag: args.tag, + }, + { + noDeployment: "preview secret put no preview deployment", + previewNotFound: "preview secret put preview not found", + } + ); + + const liveUrls = deployment.urls ?? []; + logger.log( + `✨ Success! Created Preview deployment ${deployment.id} with secret ${args.key}.` + + (liveUrls.length > 0 + ? `\n➡️ Your Preview "${previewName}" is now live at ${liveUrls + .map((url) => chalk.bold.underline(url)) + .join(", ")}` + : "") + ); + }, +}); From ac323cc14604165f0d1c2d9b85f297cea2b8910a Mon Sep 17 00:00:00 2001 From: Patrick O'Donnell Date: Wed, 5 Aug 2026 10:45:48 -0400 Subject: [PATCH 3/5] WC-5233 restructure commands --- .changeset/preview-secret-per-preview.md | 4 +- packages/wrangler/src/index.ts | 12 +- packages/wrangler/src/preview/commands.ts | 89 ------------- packages/wrangler/src/preview/delete.ts | 39 ++++++ packages/wrangler/src/preview/index.ts | 149 ---------------------- packages/wrangler/src/preview/preview.ts | 117 +++++++++++++++++ packages/wrangler/src/preview/settings.ts | 79 +++++++++--- 7 files changed, 222 insertions(+), 267 deletions(-) delete mode 100644 packages/wrangler/src/preview/commands.ts create mode 100644 packages/wrangler/src/preview/delete.ts delete mode 100644 packages/wrangler/src/preview/index.ts create mode 100644 packages/wrangler/src/preview/preview.ts diff --git a/.changeset/preview-secret-per-preview.md b/.changeset/preview-secret-per-preview.md index 04743692f99..ad56821d8a4 100644 --- a/.changeset/preview-secret-per-preview.md +++ b/.changeset/preview-secret-per-preview.md @@ -2,6 +2,6 @@ "wrangler": minor --- -Add `wrangler preview secret put`, `delete`, `list`, and `bulk` for managing secrets on a named Worker Preview +Use Preview deployment PATCH APIs for Preview secret commands -These private-beta commands manage secrets on a single named Preview's latest deployment, without affecting production, other Previews, or the Worker's Previews base configuration. `put`, `delete`, and `bulk` create a new Preview deployment that goes live at 100% immediately and report the live Preview URL on success; `list` reads the latest deployment and prints secret names with values masked. Each command accepts `--name` to target a Preview (defaulting to the current git branch). +Wrangler now updates Worker Preview secrets by patching the named Preview's latest deployment instead of patching the Worker's Previews settings. This keeps secret changes scoped to one Preview, avoids affecting production or other Previews, and creates a new Preview deployment that goes live at 100% immediately. `list` now reads from the named Preview's latest deployment and prints secret names with values masked. diff --git a/packages/wrangler/src/index.ts b/packages/wrangler/src/index.ts index e959b82a421..98472b63e11 100644 --- a/packages/wrangler/src/index.ts +++ b/packages/wrangler/src/index.ts @@ -311,17 +311,17 @@ import { pipelinesStreamsDeleteCommand } from "./pipelines/cli/streams/delete"; import { pipelinesStreamsGetCommand } from "./pipelines/cli/streams/get"; import { pipelinesStreamsListCommand } from "./pipelines/cli/streams/list"; import { pipelinesUpdateCommand } from "./pipelines/cli/update"; -import { - previewCommand, - previewDeleteCommand, - previewSettingsCommand, - previewSettingsUpdateCommand, -} from "./preview"; +import { previewDeleteCommand } from "./preview/delete"; +import { previewCommand } from "./preview/preview"; import { previewSecretNamespace } from "./preview/secrets"; import { previewSecretBulkCommand } from "./preview/secrets/bulk"; import { previewSecretDeleteCommand } from "./preview/secrets/delete"; import { previewSecretListCommand } from "./preview/secrets/list"; import { previewSecretPutCommand } from "./preview/secrets/put"; +import { + previewSettingsCommand, + previewSettingsUpdateCommand, +} from "./preview/settings"; import { queuesNamespace } from "./queues/cli/commands"; import { queuesConsumerNamespace } from "./queues/cli/commands/consumer"; import { queuesConsumerHttpNamespace } from "./queues/cli/commands/consumer/http-pull"; diff --git a/packages/wrangler/src/preview/commands.ts b/packages/wrangler/src/preview/commands.ts deleted file mode 100644 index dcb08090a53..00000000000 --- a/packages/wrangler/src/preview/commands.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { preview, previewDelete } from "@cloudflare/deploy-helpers"; -import { getWranglerTmpDir } from "@cloudflare/workers-utils"; -import { getAssetsOptions } from "../assets"; -import { getEntry } from "../deployment-bundle/entry"; -import { buildWorker } from "../deployment-bundle/maybe-build-worker"; -import { cleanupDestination } from "../deployment-bundle/merge-config-args"; -import { writeOutput } from "../output"; -import { requireAuth } from "../user"; -import type { Config } from "@cloudflare/workers-utils"; - -export async function handlePreviewCommand( - args: { - script?: string; - name?: string; - tag?: string; - message?: string; - json?: boolean; - ignoreDefaults: boolean; - workerName?: string; - "worker-name"?: string; - }, - { config }: { config: Config } -) { - const accountId = await requireAuth(config); - - const entry = await getEntry({ script: args.script }, config, "deploy"); - const destination = getWranglerTmpDir(entry.projectRoot, "preview"); - const buildResult = await buildWorker( - { - entry, - name: config.name, - compatibilityDate: config.compatibility_date, - compatibilityFlags: config.compatibility_flags, - uploadSourceMaps: config.upload_source_maps, - jsxFactory: config.jsx_factory, - jsxFragment: config.jsx_fragment, - tsconfig: config.tsconfig, - minify: config.minify, - noBundle: config.no_bundle ?? false, - defines: config.previews?.define ?? {}, - alias: { ...config.alias }, - doBindings: config.previews?.durable_objects?.bindings ?? [], - workflowBindings: config.previews?.workflows ?? [], - destination, - outdir: undefined, - metafile: undefined, - }, - config - ); - - const assetsOptions = getAssetsOptions({ - args: { assets: undefined, script: args.script }, - config, - }); - - const { preview: previewResource, deployment } = await preview( - accountId, - args, - config, - buildResult, - assetsOptions - ); - cleanupDestination(destination); - - writeOutput({ - type: "preview", - version: 1, - worker_name: previewResource.worker_name, - preview_id: previewResource.id, - preview_name: previewResource.name, - preview_slug: previewResource.slug, - preview_urls: previewResource.urls, - deployment_id: deployment.id, - deployment_urls: deployment.urls, - }); -} - -export async function handlePreviewDeleteCommand( - args: { - name?: string; - skipConfirmation?: boolean; - workerName?: string; - "worker-name"?: string; - }, - { config }: { config: Config } -) { - const accountId = await requireAuth(config); - await previewDelete(accountId, args, config); -} diff --git a/packages/wrangler/src/preview/delete.ts b/packages/wrangler/src/preview/delete.ts new file mode 100644 index 00000000000..395887c5a26 --- /dev/null +++ b/packages/wrangler/src/preview/delete.ts @@ -0,0 +1,39 @@ +import { previewDelete } from "@cloudflare/deploy-helpers"; +import { createCommand } from "../core/create-command"; +import { requireAuth } from "../user"; + +export const previewDeleteCommand = createCommand({ + metadata: { + description: "Delete a Preview and all its deployments", + owner: "Workers: Deploy and Config", + category: "Compute & AI", + status: "private beta", + }, + args: { + name: { + describe: + "Name of the Preview to delete (defaults to current git branch)", + type: "string", + requiresArg: true, + }, + "skip-confirmation": { + describe: "Skip the confirmation prompt", + type: "boolean", + default: false, + alias: "y", + }, + "worker-name": { + describe: + "Name of the Worker to target (defaults to the name in your local config file)", + type: "string", + requiresArg: true, + }, + }, + behaviour: { + suggestSkillsAfterHandler: true, + }, + handler: async function previewDeleteHandler(args, { config }) { + const accountId = await requireAuth(config); + await previewDelete(accountId, args, config); + }, +}); diff --git a/packages/wrangler/src/preview/index.ts b/packages/wrangler/src/preview/index.ts deleted file mode 100644 index 6b2cc7d8969..00000000000 --- a/packages/wrangler/src/preview/index.ts +++ /dev/null @@ -1,149 +0,0 @@ -import { createCommand } from "../core/create-command"; -import { handlePreviewCommand, handlePreviewDeleteCommand } from "./commands"; -import { - handlePreviewSettingsCommand, - handlePreviewSettingsUpdateCommand, -} from "./settings"; - -export const previewCommand = createCommand({ - metadata: { - description: "👀 Create a Preview deployment of the current Worker", - owner: "Workers: Deploy and Config", - category: "Compute & AI", - status: "private beta", - }, - positionalArgs: ["script"], - args: { - script: { - describe: "The path to an entry point for your Worker", - type: "string", - requiresArg: true, - }, - name: { - describe: "Name of the Preview (defaults to current git branch)", - type: "string", - requiresArg: true, - }, - tag: { - describe: "A tag for this Preview deployment", - type: "string", - requiresArg: true, - }, - message: { - describe: "A descriptive message for this Preview deployment", - type: "string", - requiresArg: true, - }, - json: { - describe: "Return output as JSON", - type: "boolean", - default: false, - }, - "ignore-defaults": { - describe: - "Only use settings from your config file, ignoring any Previews settings configured in the Cloudflare dashboard", - type: "boolean", - default: false, - }, - "worker-name": { - describe: - "Name of the Worker to target (defaults to the name in your local config file)", - type: "string", - requiresArg: true, - }, - }, - behaviour: { - useConfigRedirectIfAvailable: true, - printBanner: (args) => args.json !== true, - suggestSkillsAfterHandler: (args) => args.json !== true, - }, - handler: handlePreviewCommand, -}); - -export const previewDeleteCommand = createCommand({ - metadata: { - description: "Delete a Preview and all its deployments", - owner: "Workers: Deploy and Config", - category: "Compute & AI", - status: "private beta", - }, - args: { - name: { - describe: - "Name of the Preview to delete (defaults to current git branch)", - type: "string", - requiresArg: true, - }, - "skip-confirmation": { - describe: "Skip the confirmation prompt", - type: "boolean", - default: false, - alias: "y", - }, - "worker-name": { - describe: - "Name of the Worker to target (defaults to the name in your local config file)", - type: "string", - requiresArg: true, - }, - }, - behaviour: { - suggestSkillsAfterHandler: true, - }, - handler: handlePreviewDeleteCommand, -}); - -export const previewSettingsUpdateCommand = createCommand({ - metadata: { - description: - "Update the Worker's Previews settings using the contents of the Wrangler config file", - owner: "Workers: Deploy and Config", - category: "Compute & AI", - status: "private beta", - }, - args: { - "worker-name": { - describe: - "Name of the Worker to target (defaults to the name in your local config file)", - type: "string", - requiresArg: true, - }, - "skip-confirmation": { - describe: "Skip the confirmation prompt", - type: "boolean", - default: false, - alias: "y", - }, - }, - behaviour: { - suggestSkillsAfterHandler: true, - }, - handler: handlePreviewSettingsUpdateCommand, -}); - -export const previewSettingsCommand = createCommand({ - metadata: { - description: "Show the current Previews settings for a Worker", - owner: "Workers: Deploy and Config", - category: "Compute & AI", - status: "private beta", - }, - args: { - "worker-name": { - describe: - "Name of the Worker to target (defaults to the name in your local config file)", - type: "string", - requiresArg: true, - }, - json: { - describe: "Return output as JSON", - type: "boolean", - default: false, - }, - }, - behaviour: { - printBanner: (args) => args.json !== true, - suggestSkillsAfterHandler: (args) => args.json !== true, - }, - handler: handlePreviewSettingsCommand, -}); diff --git a/packages/wrangler/src/preview/preview.ts b/packages/wrangler/src/preview/preview.ts new file mode 100644 index 00000000000..694d426ba25 --- /dev/null +++ b/packages/wrangler/src/preview/preview.ts @@ -0,0 +1,117 @@ +import { preview } from "@cloudflare/deploy-helpers"; +import { getWranglerTmpDir } from "@cloudflare/workers-utils"; +import { getAssetsOptions } from "../assets"; +import { createCommand } from "../core/create-command"; +import { getEntry } from "../deployment-bundle/entry"; +import { buildWorker } from "../deployment-bundle/maybe-build-worker"; +import { cleanupDestination } from "../deployment-bundle/merge-config-args"; +import { writeOutput } from "../output"; +import { requireAuth } from "../user"; + +export const previewCommand = createCommand({ + metadata: { + description: "👀 Create a Preview deployment of the current Worker", + owner: "Workers: Deploy and Config", + category: "Compute & AI", + status: "private beta", + }, + positionalArgs: ["script"], + args: { + script: { + describe: "The path to an entry point for your Worker", + type: "string", + requiresArg: true, + }, + name: { + describe: "Name of the Preview (defaults to current git branch)", + type: "string", + requiresArg: true, + }, + tag: { + describe: "A tag for this Preview deployment", + type: "string", + requiresArg: true, + }, + message: { + describe: "A descriptive message for this Preview deployment", + type: "string", + requiresArg: true, + }, + json: { + describe: "Return output as JSON", + type: "boolean", + default: false, + }, + "ignore-defaults": { + describe: + "Only use settings from your config file, ignoring any Previews settings configured in the Cloudflare dashboard", + type: "boolean", + default: false, + }, + "worker-name": { + describe: + "Name of the Worker to target (defaults to the name in your local config file)", + type: "string", + requiresArg: true, + }, + }, + behaviour: { + useConfigRedirectIfAvailable: true, + printBanner: (args) => args.json !== true, + suggestSkillsAfterHandler: (args) => args.json !== true, + }, + handler: async function previewHandler(args, { config }) { + const accountId = await requireAuth(config); + + const entry = await getEntry({ script: args.script }, config, "deploy"); + const destination = getWranglerTmpDir(entry.projectRoot, "preview"); + const buildResult = await buildWorker( + { + entry, + name: config.name, + compatibilityDate: config.compatibility_date, + compatibilityFlags: config.compatibility_flags, + uploadSourceMaps: config.upload_source_maps, + jsxFactory: config.jsx_factory, + jsxFragment: config.jsx_fragment, + tsconfig: config.tsconfig, + minify: config.minify, + noBundle: config.no_bundle ?? false, + defines: config.previews?.define ?? {}, + alias: { ...config.alias }, + doBindings: config.previews?.durable_objects?.bindings ?? [], + workflowBindings: config.previews?.workflows ?? [], + destination, + outdir: undefined, + metafile: undefined, + }, + config + ); + + const assetsOptions = getAssetsOptions({ + args: { assets: undefined, script: args.script }, + config, + }); + + const { preview: previewResource, deployment } = await preview( + accountId, + args, + config, + buildResult, + assetsOptions + ); + cleanupDestination(destination); + + writeOutput({ + type: "preview", + version: 1, + worker_name: previewResource.worker_name, + preview_id: previewResource.id, + preview_name: previewResource.name, + preview_slug: previewResource.slug, + preview_urls: previewResource.urls, + deployment_id: deployment.id, + deployment_urls: deployment.urls, + }); + }, +}); diff --git a/packages/wrangler/src/preview/settings.ts b/packages/wrangler/src/preview/settings.ts index d80ae2c9f58..5dd97b3bf16 100644 --- a/packages/wrangler/src/preview/settings.ts +++ b/packages/wrangler/src/preview/settings.ts @@ -2,29 +2,66 @@ import { previewSettingsGet, previewSettingsUpdate, } from "@cloudflare/deploy-helpers"; +import { createCommand } from "../core/create-command"; import { requireAuth } from "../user"; -import type { Config } from "@cloudflare/workers-utils"; -export async function handlePreviewSettingsUpdateCommand( +export const previewSettingsUpdateCommand = createCommand({ + metadata: { + description: + "Update the Worker's Previews settings using the contents of the Wrangler config file", + owner: "Workers: Deploy and Config", + category: "Compute & AI", + status: "private beta", + }, args: { - skipConfirmation?: boolean; - workerName?: string; - "worker-name"?: string; - }, - { config }: { config: Config } -) { - const accountId = await requireAuth(config); - await previewSettingsUpdate(accountId, args, config); -} + "worker-name": { + describe: + "Name of the Worker to target (defaults to the name in your local config file)", + type: "string", + requiresArg: true, + }, + "skip-confirmation": { + describe: "Skip the confirmation prompt", + type: "boolean", + default: false, + alias: "y", + }, + }, + behaviour: { + suggestSkillsAfterHandler: true, + }, + handler: async function previewSettingsUpdateHandler(args, { config }) { + const accountId = await requireAuth(config); + await previewSettingsUpdate(accountId, args, config); + }, +}); -export async function handlePreviewSettingsCommand( +export const previewSettingsCommand = createCommand({ + metadata: { + description: "Show the current Previews settings for a Worker", + owner: "Workers: Deploy and Config", + category: "Compute & AI", + status: "private beta", + }, args: { - json?: boolean; - workerName?: string; - "worker-name"?: string; - }, - { config }: { config: Config } -) { - const accountId = await requireAuth(config); - await previewSettingsGet(accountId, args, config); -} + "worker-name": { + describe: + "Name of the Worker to target (defaults to the name in your local config file)", + type: "string", + requiresArg: true, + }, + json: { + describe: "Return output as JSON", + type: "boolean", + default: false, + }, + }, + behaviour: { + printBanner: (args) => args.json !== true, + suggestSkillsAfterHandler: (args) => args.json !== true, + }, + handler: async function previewSettingsHandler(args, { config }) { + const accountId = await requireAuth(config); + await previewSettingsGet(accountId, args, config); + }, +}); From 9b9bf6582eb9cba72acaa4d06a4cd22c41f80c81 Mon Sep 17 00:00:00 2001 From: Patrick O'Donnell Date: Thu, 6 Aug 2026 13:22:50 -0400 Subject: [PATCH 4/5] WC-5233 enhance messages, add delete on empty for bulk, fix tests --- .changeset/preview-secret-per-preview.md | 2 +- .../src/__tests__/preview.secret.test.ts | 148 ++++++++++++------ packages/wrangler/src/preview/secrets/bulk.ts | 27 +++- .../wrangler/src/preview/secrets/delete.ts | 2 +- .../wrangler/src/preview/secrets/index.ts | 12 +- packages/wrangler/src/preview/secrets/put.ts | 2 +- 6 files changed, 125 insertions(+), 68 deletions(-) diff --git a/.changeset/preview-secret-per-preview.md b/.changeset/preview-secret-per-preview.md index ad56821d8a4..b2b1c290103 100644 --- a/.changeset/preview-secret-per-preview.md +++ b/.changeset/preview-secret-per-preview.md @@ -4,4 +4,4 @@ Use Preview deployment PATCH APIs for Preview secret commands -Wrangler now updates Worker Preview secrets by patching the named Preview's latest deployment instead of patching the Worker's Previews settings. This keeps secret changes scoped to one Preview, avoids affecting production or other Previews, and creates a new Preview deployment that goes live at 100% immediately. `list` now reads from the named Preview's latest deployment and prints secret names with values masked. +Wrangler now updates Worker Preview secrets by patching the named Preview's latest deployment instead of patching the Worker's Previews settings. This keeps secret changes scoped to one Preview, avoids affecting production or other Previews, and creates a new Preview deployment that goes live at 100% immediately. `wrangler preview secret list` now reads from the named Preview's latest deployment and prints secret names with values masked. `wrangler preview secret bulk` now deletes a secret when its value is `null`, matching `wrangler secret bulk`. diff --git a/packages/wrangler/src/__tests__/preview.secret.test.ts b/packages/wrangler/src/__tests__/preview.secret.test.ts index 9b6886613f0..23583411b51 100644 --- a/packages/wrangler/src/__tests__/preview.secret.test.ts +++ b/packages/wrangler/src/__tests__/preview.secret.test.ts @@ -40,7 +40,7 @@ function mockPatchLatestPreviewDeployment( ); } -function mockPreviewDeploymentNotFound(code: number) { +function mockPatchPreviewDeploymentError(code: number) { msw.use( http.patch( `*/accounts/:accountId/workers/workers/:workerId/previews/:previewId/deployments/latest`, @@ -134,13 +134,6 @@ describe("wrangler preview", () => { expect, }) => { mockStdIn.send("preview-secret"); - let patchedPreviewDefaults = false; - msw.use( - http.patch(`*/accounts/:accountId/workers/workers/:workerId`, () => { - patchedPreviewDefaults = true; - return HttpResponse.json({ success: true, result: {} }); - }) - ); let requestUrl: string | undefined; let requestBody: PreviewDeploymentPatchBody | undefined; mockPatchLatestPreviewDeployment(({ url, body }) => { @@ -158,7 +151,6 @@ describe("wrangler preview", () => { expect(requestBody?.env).toEqual({ API_KEY: { type: "secret_text", text: "preview-secret" }, }); - expect(patchedPreviewDefaults).toBe(false); expect(std.out).toContain('Preview "test-preview"'); expect(std.out).toContain("test-worker"); expect(std.out).toContain("Preview deployment"); @@ -187,6 +179,22 @@ describe("wrangler preview", () => { ); }); + test("fails clearly when no name is given and there is no git branch", async ({ + expect, + }) => { + // `runInTempDir` puts us in an `os.tmpdir()` directory that is not a + // git worktree, so with no CI branch env vars the Preview name + // cannot be inferred. + vi.stubEnv("WORKERS_CI_BRANCH", undefined); + vi.stubEnv("GITHUB_HEAD_REF", undefined); + vi.stubEnv("GITHUB_REF_NAME", undefined); + vi.stubEnv("CI_COMMIT_REF_NAME", undefined); + + await expect( + runWrangler("preview secret put API_KEY --worker-name test-worker") + ).rejects.toThrow(/Could not determine Preview name/); + }); + test("respects env-specific worker name when using --env", async ({ expect, }) => { @@ -255,7 +263,7 @@ describe("wrangler preview", () => { expect, }) => { mockStdIn.send("preview-secret"); - mockPreviewDeploymentNotFound(10032); + mockPatchPreviewDeploymentError(10032); await expect( runWrangler( @@ -268,7 +276,7 @@ describe("wrangler preview", () => { expect, }) => { mockStdIn.send("preview-secret"); - mockPreviewDeploymentNotFound(10025); + mockPatchPreviewDeploymentError(10025); await expect( runWrangler( @@ -391,7 +399,7 @@ describe("wrangler preview", () => { test("fails clearly when the Preview has no deployments", async ({ expect, }) => { - mockPreviewDeploymentNotFound(10032); + mockPatchPreviewDeploymentError(10032); await expect( runWrangler( @@ -403,7 +411,7 @@ describe("wrangler preview", () => { test("fails clearly when the Preview is not found", async ({ expect, }) => { - mockPreviewDeploymentNotFound(10025); + mockPatchPreviewDeploymentError(10025); await expect( runWrangler( @@ -414,16 +422,10 @@ describe("wrangler preview", () => { }); describe("list", () => { - test("reads the latest Preview deployment and lists secrets as JSON", async ({ - expect, - }) => { + test("reads the latest Preview deployment", async ({ expect }) => { let requestUrl: string | undefined; mockGetLatestPreviewDeployment( - { - DB_PASSWORD: { type: "secret_text" }, - API_KEY: { type: "secret_text" }, - PUBLIC_VAR: { type: "plain_text", text: "visible" }, - }, + { API_KEY: { type: "secret_text" } }, ({ url }) => { requestUrl = url; } @@ -434,29 +436,53 @@ describe("wrangler preview", () => { expect(requestUrl).toContain( "/workers/workers/test-worker/previews/test-preview/deployments/latest" ); - expect(std.out).toContain('"name": "DB_PASSWORD"'); - expect(std.out).toContain('"name": "API_KEY"'); - expect(std.out).toContain('"type": "secret_text"'); - expect(std.out).not.toContain("PUBLIC_VAR"); }); - test("should list secrets in pretty format with values masked", async ({ - expect, - }) => { - mockGetLatestPreviewDeployment({ - MY_SECRET: { type: "secret_text", text: "super-secret-value" }, - PLAIN: { type: "plain_text", text: "not-a-secret" }, - }); - await runWrangler( - "preview secret list --name test-preview --worker-name test-worker" - ); - expect(std.out).toContain("Worker: test-worker"); - expect(std.out).toContain("Secrets"); - expect(std.out).toContain("MY_SECRET"); - expect(std.out).not.toContain("PLAIN"); - expect(std.out).toContain("********"); - expect(std.out).not.toContain("super-secret-value"); - }); + // Matrix over output format (json vs. pretty) and whether the API + // returns a text value for the secret. In every combination we only + // list secret bindings (never plain_text) and never print the value. + it.each([ + { + name: "json, value provided", + json: true, + text: "super-secret-value", + }, + { name: "json, no value", json: true, text: undefined }, + { + name: "pretty, value provided", + json: false, + text: "super-secret-value", + }, + { name: "pretty, no value", json: false, text: undefined }, + ])( + "lists only secrets and never leaks their values ($name)", + async ({ json, text }) => { + mockGetLatestPreviewDeployment({ + MY_SECRET: + text === undefined + ? { type: "secret_text" } + : { type: "secret_text", text }, + PLAIN: { type: "plain_text", text: "not-a-secret" }, + }); + await runWrangler( + `preview secret list ${json ? "--json " : ""}--name test-preview --worker-name test-worker` + ); + // The secret name is always listed + expect(std.out).toContain("MY_SECRET"); + // Non-secret bindings are never listed + expect(std.out).not.toContain("PLAIN"); + // The secret value is never printed, even when the API returns it + expect(std.out).not.toContain("super-secret-value"); + if (json) { + expect(std.out).toContain('"name": "MY_SECRET"'); + expect(std.out).toContain('"type": "secret_text"'); + } else { + expect(std.out).toContain("Worker: test-worker"); + expect(std.out).toContain("Secrets"); + expect(std.out).toContain("********"); + } + } + ); test("defaults the Preview name to the current git branch", async ({ expect, @@ -547,7 +573,7 @@ describe("wrangler preview", () => { "Successfully created secret for key: SECOND_KEY" ); expect(std.out).toContain("Created Preview deployment deployment-1"); - expect(std.out).toContain("with 2 secrets"); + expect(std.out).toContain("with 2 created and 0 deleted secrets"); expect(std.out).toContain( "is now live at https://test-preview.example.workers.dev" ); @@ -609,16 +635,16 @@ describe("wrangler preview", () => { "preview secret bulk secrets.env --name test-preview --worker-name test-worker" ); expect(requestBody?.annotations?.["workers/message"]).toBe( - "Bulk updated 2 secrets" + "Created 2 and deleted 0 secrets" ); }); - test("creates a deployment with an empty patch for null-only input", async ({ + test("deletes secrets for null values, like `wrangler secret bulk`", async ({ expect, }) => { writeFileSync( "secrets.json", - JSON.stringify({ REMOVE_ME: null, ALSO_GONE: null }) + JSON.stringify({ KEEP_ME: "value", REMOVE_ME: null, ALSO_GONE: null }) ); let requestBody: PreviewDeploymentPatchBody | undefined; mockPatchLatestPreviewDeployment(({ body }) => { @@ -627,9 +653,25 @@ describe("wrangler preview", () => { await runWrangler( "preview secret bulk secrets.json --name test-preview --worker-name test-worker" ); - expect(requestBody?.env).toEqual({}); - expect(std.out).toContain("Created Preview deployment deployment-1"); - expect(std.out).toContain("with 0 secrets"); + // `null` maps to `null` in the merge-patch body, which deletes the secret + expect(requestBody?.env).toEqual({ + KEEP_ME: { type: "secret_text", text: "value" }, + REMOVE_ME: null, + ALSO_GONE: null, + }); + expect(requestBody?.annotations?.["workers/message"]).toBe( + "Created 1 and deleted 2 secrets" + ); + expect(std.out).toContain( + "Successfully created secret for key: KEEP_ME" + ); + expect(std.out).toContain( + "Successfully deleted secret for key: REMOVE_ME" + ); + expect(std.out).toContain( + "Successfully deleted secret for key: ALSO_GONE" + ); + expect(std.out).toContain("with 1 created and 2 deleted secrets"); }); test("makes no API call when there is no input", async ({ expect }) => { @@ -644,14 +686,16 @@ describe("wrangler preview", () => { "preview secret bulk --name test-preview --worker-name test-worker" ); expect(requested).toBe(false); - expect(std.err).toContain("No content found in file or piped input."); + expect(std.err).toContain( + "🚨 No content found in file, or piped input." + ); }); test("fails clearly when the Preview has no deployments", async ({ expect, }) => { writeFileSync("secrets.env", "API_KEY=one\n"); - mockPreviewDeploymentNotFound(10032); + mockPatchPreviewDeploymentError(10032); await expect( runWrangler( "preview secret bulk secrets.env --name test-preview --worker-name test-worker" @@ -663,7 +707,7 @@ describe("wrangler preview", () => { expect, }) => { writeFileSync("secrets.env", "API_KEY=one\n"); - mockPreviewDeploymentNotFound(10025); + mockPatchPreviewDeploymentError(10025); await expect( runWrangler( "preview secret bulk secrets.env --name test-preview --worker-name test-worker" diff --git a/packages/wrangler/src/preview/secrets/bulk.ts b/packages/wrangler/src/preview/secrets/bulk.ts index b4c65a2e5db..270cf6fc3a0 100644 --- a/packages/wrangler/src/preview/secrets/bulk.ts +++ b/packages/wrangler/src/preview/secrets/bulk.ts @@ -13,7 +13,7 @@ import { export const previewSecretBulkCommand = createCommand({ metadata: { description: - "Upload multiple secrets to a Worker Preview's latest deployment", + "Upload multiple secrets to a Worker Preview and create a new deployment", owner: "Workers: Deploy and Config", category: "Compute & AI", status: "private beta", @@ -55,18 +55,24 @@ export const previewSecretBulkCommand = createCommand({ const accountId = await requireAuth(config); logger.log( - `🌀 Creating the secrets for the Preview "${previewName}" on the Worker "${workerName}"${args.env ? ` (${args.env})` : ""}` + `🌀 Processing the secrets for the Preview "${previewName}" on the Worker "${workerName}"${args.env ? ` (${args.env})` : ""}` ); - const result = await parseBulkInputToObject(args.file); + // includeNull: true to delete empty secrets - matches wrangler secret bulk + const result = await parseBulkInputToObject(args.file, true); if (!result) { - logger.error("No content found in file or piped input."); + logger.error("🚨 No content found in file, or piped input."); return; } const { content } = result; - const secretEntries = Object.entries(content); + const created = Object.keys(content).filter( + (name) => content[name] !== null + ); + const deleted = Object.keys(content).filter( + (name) => content[name] === null + ); const deployment = await patchPreviewDeploymentSecrets( config, @@ -75,7 +81,9 @@ export const previewSecretBulkCommand = createCommand({ previewName, toSecretBindingsPatch(content), { - message: args.message ?? `Bulk updated ${secretEntries.length} secrets`, + message: + args.message ?? + `Created ${created.length} and deleted ${deleted.length} secrets`, tag: args.tag, }, { @@ -84,13 +92,16 @@ export const previewSecretBulkCommand = createCommand({ } ); - for (const [name] of secretEntries) { + for (const name of deleted) { + logger.log(`💥 Successfully deleted secret for key: ${name}`); + } + for (const name of created) { logger.log(`✨ Successfully created secret for key: ${name}`); } const liveUrls = deployment.urls ?? []; logger.log( - `✨ Success! Created Preview deployment ${deployment.id} with ${secretEntries.length} secrets.` + + `✨ Success! Created Preview deployment ${deployment.id} with ${created.length} created and ${deleted.length} deleted secrets.` + (liveUrls.length > 0 ? `\n➡️ Your Preview "${previewName}" is now live at ${liveUrls .map((url) => chalk.bold.underline(url)) diff --git a/packages/wrangler/src/preview/secrets/delete.ts b/packages/wrangler/src/preview/secrets/delete.ts index 9360334bc46..bd7afb7cf8c 100644 --- a/packages/wrangler/src/preview/secrets/delete.ts +++ b/packages/wrangler/src/preview/secrets/delete.ts @@ -9,7 +9,7 @@ import { patchPreviewDeploymentSecrets, resolvePreviewName } from "./index"; export const previewSecretDeleteCommand = createCommand({ metadata: { description: - "Delete a secret variable from a Worker Preview's latest deployment", + "Delete a secret variable from a Worker Preview and create a new deployment", owner: "Workers: Deploy and Config", category: "Compute & AI", status: "private beta", diff --git a/packages/wrangler/src/preview/secrets/index.ts b/packages/wrangler/src/preview/secrets/index.ts index 5de3b3962d4..e6e71455480 100644 --- a/packages/wrangler/src/preview/secrets/index.ts +++ b/packages/wrangler/src/preview/secrets/index.ts @@ -4,7 +4,7 @@ import { } from "@cloudflare/deploy-helpers"; import { APIError, UserError } from "@cloudflare/workers-utils"; import { createNamespace } from "../../core/create-command"; -import type { Binding, EnvBindings } from "@cloudflare/deploy-helpers"; +import type { Binding } from "@cloudflare/deploy-helpers"; import type { Config } from "@cloudflare/workers-utils"; export const previewSecretNamespace = createNamespace({ @@ -28,13 +28,15 @@ export function resolvePreviewName(args: { name?: string }): string { return previewName; } +// A `null` value maps to `null` in the merge-patch body, which deletes the +// secret from the deployment — matching `wrangler secret bulk` semantics. export function toSecretBindingsPatch( - secrets: Record -): EnvBindings { + secrets: Record +): Record { return Object.fromEntries( Object.entries(secrets).map(([name, text]) => [ name, - { type: "secret_text", text }, + text === null ? null : { type: "secret_text", text }, ]) ); } @@ -48,7 +50,7 @@ export const PREVIEW_NOT_FOUND_ERR_CODE = 10025; export const noPreviewDeploymentPatchMessage = (previewName: string) => `There are currently no deployments for the Preview "${previewName}". Please create a Preview deployment before modifying a secret.`; export const noPreviewDeploymentListMessage = (previewName: string) => - `There are currently no deployments for the Preview "${previewName}" - please create a Preview deployment.`; + `There are currently no deployments for the Preview "${previewName}". Please create a Preview deployment.`; export const previewNotFoundMessage = (previewName: string) => `The Preview "${previewName}" was not found. Please check the Preview name, or create it with \`wrangler preview\`.`; diff --git a/packages/wrangler/src/preview/secrets/put.ts b/packages/wrangler/src/preview/secrets/put.ts index 26a43ed4abd..c1818bf22bb 100644 --- a/packages/wrangler/src/preview/secrets/put.ts +++ b/packages/wrangler/src/preview/secrets/put.ts @@ -14,7 +14,7 @@ import { export const previewSecretPutCommand = createCommand({ metadata: { description: - "Create or update a secret variable on a Worker Preview's latest deployment", + "Create or update a secret variable on a Worker Preview and create a new deployment", owner: "Workers: Deploy and Config", category: "Compute & AI", status: "private beta", From 98ecb4549f066deda7bb2183808df922c4af9833 Mon Sep 17 00:00:00 2001 From: Patrick O'Donnell Date: Fri, 7 Aug 2026 11:08:14 -0400 Subject: [PATCH 5/5] WC-5233 match repo conventions, adjust preview list message --- .../src/__tests__/preview.secret.test.ts | 7 +++++-- packages/wrangler/src/preview/secrets/index.ts | 17 +++++++++++------ packages/wrangler/src/preview/secrets/list.ts | 6 ++++-- 3 files changed, 20 insertions(+), 10 deletions(-) diff --git a/packages/wrangler/src/__tests__/preview.secret.test.ts b/packages/wrangler/src/__tests__/preview.secret.test.ts index 23583411b51..cc0b01b1f5b 100644 --- a/packages/wrangler/src/__tests__/preview.secret.test.ts +++ b/packages/wrangler/src/__tests__/preview.secret.test.ts @@ -441,7 +441,7 @@ describe("wrangler preview", () => { // Matrix over output format (json vs. pretty) and whether the API // returns a text value for the secret. In every combination we only // list secret bindings (never plain_text) and never print the value. - it.each([ + test.for([ { name: "json, value provided", json: true, @@ -456,7 +456,7 @@ describe("wrangler preview", () => { { name: "pretty, no value", json: false, text: undefined }, ])( "lists only secrets and never leaks their values ($name)", - async ({ json, text }) => { + async ({ json, text }, { expect }) => { mockGetLatestPreviewDeployment({ MY_SECRET: text === undefined @@ -478,6 +478,9 @@ describe("wrangler preview", () => { expect(std.out).toContain('"type": "secret_text"'); } else { expect(std.out).toContain("Worker: test-worker"); + expect(std.out).toContain("Preview: test-preview"); + expect(std.out).toContain("Latest Preview deployment"); + expect(std.out).not.toContain("Previews settings"); expect(std.out).toContain("Secrets"); expect(std.out).toContain("********"); } diff --git a/packages/wrangler/src/preview/secrets/index.ts b/packages/wrangler/src/preview/secrets/index.ts index e6e71455480..2c7e759cd8b 100644 --- a/packages/wrangler/src/preview/secrets/index.ts +++ b/packages/wrangler/src/preview/secrets/index.ts @@ -47,12 +47,17 @@ export const NO_PREVIEW_DEPLOYMENT_PATCH_ERR_CODE = 10032; export const NO_PREVIEW_DEPLOYMENT_GET_ERR_CODE = 10222; export const PREVIEW_NOT_FOUND_ERR_CODE = 10025; -export const noPreviewDeploymentPatchMessage = (previewName: string) => - `There are currently no deployments for the Preview "${previewName}". Please create a Preview deployment before modifying a secret.`; -export const noPreviewDeploymentListMessage = (previewName: string) => - `There are currently no deployments for the Preview "${previewName}". Please create a Preview deployment.`; -export const previewNotFoundMessage = (previewName: string) => - `The Preview "${previewName}" was not found. Please check the Preview name, or create it with \`wrangler preview\`.`; +export function noPreviewDeploymentPatchMessage(previewName: string) { + return `There are currently no deployments for the Preview "${previewName}". Please create a Preview deployment before modifying a secret.`; +} + +export function noPreviewDeploymentListMessage(previewName: string) { + return `There are currently no deployments for the Preview "${previewName}". Please create a Preview deployment.`; +} + +export function previewNotFoundMessage(previewName: string) { + return `The Preview "${previewName}" was not found. Please check the Preview name, or create it with \`wrangler preview\`.`; +} export async function patchPreviewDeploymentSecrets( config: Config, diff --git a/packages/wrangler/src/preview/secrets/list.ts b/packages/wrangler/src/preview/secrets/list.ts index b8ec8d53c73..cfca1e8887a 100644 --- a/packages/wrangler/src/preview/secrets/list.ts +++ b/packages/wrangler/src/preview/secrets/list.ts @@ -44,6 +44,7 @@ function extractSecretSummaries(env: EnvBindings | undefined): SecretSummary[] { function formatPreviewSecrets( workerName: string, + previewName: string, env: EnvBindings | undefined ): string { const secrets = Object.entries(env ?? {}).filter( @@ -51,8 +52,9 @@ function formatPreviewSecrets( ); const lines: string[] = []; lines.push(`${chalk.bold.hex("#FFA500")("Worker:")} ${workerName}`); + lines.push(`${chalk.bold.hex("#FFA500")("Preview:")} ${previewName}`); lines.push(""); - lines.push(` ${chalk.bold.underline("Previews settings")}`); + lines.push(` ${chalk.bold.underline("Latest Preview deployment")}`); lines.push(""); lines.push(chalk.bold(" Secrets")); @@ -143,6 +145,6 @@ export const previewSecretListCommand = createCommand({ return; } - logger.log(formatPreviewSecrets(workerName, deployment.env)); + logger.log(formatPreviewSecrets(workerName, previewName, deployment.env)); }, });