diff --git a/.changeset/preview-secret-per-preview.md b/.changeset/preview-secret-per-preview.md new file mode 100644 index 00000000000..b2b1c290103 --- /dev/null +++ b/.changeset/preview-secret-per-preview.md @@ -0,0 +1,7 @@ +--- +"wrangler": minor +--- + +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. `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/deploy-helpers/src/preview/api.ts b/packages/deploy-helpers/src/preview/api.ts index 6359b250ee1..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, @@ -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..23583411b51 100644 --- a/packages/wrangler/src/__tests__/preview.secret.test.ts +++ b/packages/wrangler/src/__tests__/preview.secret.test.ts @@ -1,13 +1,105 @@ 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 { 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 mockPatchPreviewDeploymentError(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 } + ) + ) + ); +} + +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(); @@ -31,56 +123,79 @@ 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; - 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 ?? {}, - }, - }, - }); - } - ) - ); + test("creates a new Preview deployment with the secret", async ({ + expect, + }) => { + mockStdIn.send("preview-secret"); + 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(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).not.toContain("preview-secret"); + }); + + 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" ); - 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("********"); }); - test("should respect env-specific worker name when using --env", async ({ + 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, }) => { mockStdIn.send("env-secret"); @@ -90,35 +205,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("should fail before making API calls when env-specific previews config is invalid", async ({ + 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("fails clearly when the Preview has no deployments", async ({ + expect, + }) => { + mockStdIn.send("preview-secret"); + mockPatchPreviewDeploymentError(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"); + mockPatchPreviewDeploymentError(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 +305,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,72 +365,137 @@ 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(requestBody?.annotations?.["workers/message"]).toBe( + 'Deleted secret "REMOVE_ME"' ); - expect(patchUrl).toContain("/workers/workers/staging-worker"); + }); + + test("fails clearly when the Preview has no deployments", async ({ + expect, + }) => { + mockPatchPreviewDeploymentError(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, + }) => { + mockPatchPreviewDeploymentError(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/); }); }); 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", async ({ expect }) => { + let requestUrl: string | undefined; + mockGetLatestPreviewDeployment( + { API_KEY: { type: "secret_text" } }, + ({ 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).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" }, - }, - }, - }, - }) - ) - ); + // 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, + }) => { + 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(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(requestUrl).toContain( + "/previews/branch-preview/deployments/latest" + ); }); test("should respect env-specific worker name when listing secrets", async ({ @@ -287,64 +510,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" ); - await runWrangler("preview secret bulk secrets.env"); - const env = patchRequestBody?.preview_defaults?.env ?? {}; - expect(env).toEqual({ + expect(requestUrl).toContain( + "/workers/workers/test-worker/previews/test-preview/deployments/latest" + ); + 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 created and 0 deleted 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 ({ @@ -360,18 +594,125 @@ 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" ); - await runWrangler("preview secret bulk secrets.env --env staging"); - expect(patchUrl).toContain("/workers/workers/staging-worker"); + }); + + 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( + "Created 2 and deleted 0 secrets" + ); + }); + + test("deletes secrets for null values, like `wrangler secret bulk`", async ({ + expect, + }) => { + writeFileSync( + "secrets.json", + JSON.stringify({ KEEP_ME: "value", 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" + ); + // `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 }) => { + let requested = false; + mockPatchLatestPreviewDeployment(() => { + requested = true; + }); + vi.spyOn(readline, "createInterface").mockImplementation( + () => null as unknown as readline.Interface + ); + 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"); + mockPatchPreviewDeploymentError(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"); + mockPatchPreviewDeploymentError(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/__tests__/preview.test.ts b/packages/wrangler/src/__tests__/preview.test.ts index 6165c3785b7..0fd2cfad06f 100644 --- a/packages/wrangler/src/__tests__/preview.test.ts +++ b/packages/wrangler/src/__tests__/preview.test.ts @@ -46,6 +46,39 @@ describe("wrangler preview", () => { clearOutputFilePath(); }); + test.for([ + { + cmd: "preview secret --help", + forbidden: ["[script]", "--tag", "--message", "--ignore-defaults"], + }, + { + cmd: "preview secret put --help", + forbidden: ["[script]", "--ignore-defaults"], + }, + { + cmd: "preview settings --help", + forbidden: ["[script]", "--name", "--tag", "--message", "--ignore-defaults"], + }, + { + cmd: "preview settings update --help", + forbidden: [ + "[script]", + "--name", + "--tag", + "--message", + "--json", + "--ignore-defaults", + ], + }, + ])("should not show preview deployment flags in $cmd", async ({ cmd, forbidden }, { expect }) => { + await runWrangler(cmd); + + const help = stripVTControlCharacters(std.out); + for (const flag of forbidden) { + expect(help).not.toContain(flag); + } + }); + describe("getBranchName", () => { beforeEach(() => { vi.unstubAllEnvs(); diff --git a/packages/wrangler/src/core/types.ts b/packages/wrangler/src/core/types.ts index 515735f3c1f..6ee006eaa88 100644 --- a/packages/wrangler/src/core/types.ts +++ b/packages/wrangler/src/core/types.ts @@ -85,7 +85,7 @@ export type Metadata = { }; export type ArgDefinition = Omit & - Pick; + Pick; export type NamedArgDefinitions = { [key: string]: ArgDefinition }; export type OnlyCamelCase> = { diff --git a/packages/wrangler/src/index.ts b/packages/wrangler/src/index.ts index 2501578b7e7..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 { 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 { - previewCommand, - previewDeleteCommand, - previewSecretBulkCommand, - previewSecretDeleteCommand, - previewSecretListCommand, - previewSecretNamespace, - previewSecretPutCommand, previewSettingsCommand, previewSettingsUpdateCommand, -} from "./preview"; +} 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..e5478264fc5 --- /dev/null +++ b/packages/wrangler/src/preview/delete.ts @@ -0,0 +1,40 @@ +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", + hideGlobalFlags: ["script"], + }, + 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 97561683cf7..00000000000 --- a/packages/wrangler/src/preview/index.ts +++ /dev/null @@ -1,277 +0,0 @@ -import { createCommand, createNamespace } from "../core/create-command"; -import { handlePreviewCommand, handlePreviewDeleteCommand } from "./commands"; -import { - handlePreviewSecretBulkCommand, - handlePreviewSecretDeleteCommand, - handlePreviewSecretListCommand, - handlePreviewSecretPutCommand, -} from "./secret"; -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, -}); - -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 in the Worker's Previews settings", - 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, - }, - "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 from the Worker's Previews settings", - 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, - }, - "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/preview.ts b/packages/wrangler/src/preview/preview.ts new file mode 100644 index 00000000000..f5dc84b4c6a --- /dev/null +++ b/packages/wrangler/src/preview/preview.ts @@ -0,0 +1,124 @@ +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, + global: false, + }, + name: { + describe: "Name of the Preview (defaults to current git branch)", + type: "string", + requiresArg: true, + global: false, + }, + tag: { + describe: "A tag for this Preview deployment", + type: "string", + requiresArg: true, + global: false, + }, + message: { + describe: "A descriptive message for this Preview deployment", + type: "string", + requiresArg: true, + global: false, + }, + json: { + describe: "Return output as JSON", + type: "boolean", + default: false, + global: false, + }, + "ignore-defaults": { + describe: + "Only use settings from your config file, ignoring any Previews settings configured in the Cloudflare dashboard", + type: "boolean", + default: false, + global: false, + }, + "worker-name": { + describe: + "Name of the Worker to target (defaults to the name in your local config file)", + type: "string", + requiresArg: true, + global: false, + }, + }, + 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/secret.ts b/packages/wrangler/src/preview/secret.ts deleted file mode 100644 index 7cdb4d99f77..00000000000 --- a/packages/wrangler/src/preview/secret.ts +++ /dev/null @@ -1,211 +0,0 @@ -import { - drawBox, - editWorkerPreviewDefaults, - getBindingValue, - getWorkerPreviewDefaults, - padToVisibleWidth, - resolveWorkerName, - visibleLength, -} from "@cloudflare/deploy-helpers"; -import { getBindingTypeFriendlyName } 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); -} - -export async function handlePreviewSecretPutCommand( - args: { - key: string; - workerName?: string; - "worker-name"?: string; - }, - { config }: { config: Config } -) { - const workerName = resolveWorkerName(args, config); - const accountId = await requireAuth(config); - const secretValue = trimTrailingWhitespace( - process.stdin.isTTY - ? await prompt("Enter a secret value:", { isSecret: true }) - : await readFromStdin() - ); - - const updatedPreviewDefaults = await editWorkerPreviewDefaults( - config, - accountId, - workerName, - { - env: toSecretBindingsPatch({ [args.key]: secretValue }), - } - ); - logger.log( - `\n✨ Secret "${args.key}" added to Previews settings for Worker ${chalk.bold.cyan(workerName)}.` - ); - logger.log(formatPreviewSecrets(workerName, updatedPreviewDefaults.env)); -} - -export async function handlePreviewSecretDeleteCommand( - args: { - key: string; - skipConfirmation?: boolean; - workerName?: string; - "worker-name"?: string; - }, - { config }: { config: Config } -) { - const workerName = resolveWorkerName(args, config); - 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 (!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)); -} - -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..02b0f454506 --- /dev/null +++ b/packages/wrangler/src/preview/secrets/bulk.ts @@ -0,0 +1,113 @@ +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 and create a new deployment", + owner: "Workers: Deploy and Config", + category: "Compute & AI", + status: "private beta", + hideGlobalFlags: ["script"], + }, + 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( + `🌀 Processing the secrets for the Preview "${previewName}" on the Worker "${workerName}"${args.env ? ` (${args.env})` : ""}` + ); + + // 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."); + return; + } + + const { content } = result; + 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, + accountId, + workerName, + previewName, + toSecretBindingsPatch(content), + { + message: + args.message ?? + `Created ${created.length} and deleted ${deleted.length} secrets`, + tag: args.tag, + }, + { + noDeployment: "preview secret bulk no preview deployment", + previewNotFound: "preview secret bulk preview not found", + } + ); + + 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 ${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)) + .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..17c7a4529e9 --- /dev/null +++ b/packages/wrangler/src/preview/secrets/delete.ts @@ -0,0 +1,98 @@ +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 and create a new deployment", + owner: "Workers: Deploy and Config", + category: "Compute & AI", + status: "private beta", + hideGlobalFlags: ["script"], + }, + 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..bbb1b1f5a12 --- /dev/null +++ b/packages/wrangler/src/preview/secrets/index.ts @@ -0,0 +1,94 @@ +import { + getBranchName, + patchPreviewDeployment, +} from "@cloudflare/deploy-helpers"; +import { APIError, UserError } from "@cloudflare/workers-utils"; +import { createNamespace } from "../../core/create-command"; +import type { Binding } 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", + hideGlobalFlags: ["script"], + }, +}); + +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; +} + +// 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 +): Record { + return Object.fromEntries( + Object.entries(secrets).map(([name, text]) => [ + name, + text === null ? null : { 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..187360a6998 --- /dev/null +++ b/packages/wrangler/src/preview/secrets/list.ts @@ -0,0 +1,149 @@ +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", + hideGlobalFlags: ["script"], + }, + 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..26a631e9154 --- /dev/null +++ b/packages/wrangler/src/preview/secrets/put.ts @@ -0,0 +1,95 @@ +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 and create a new deployment", + owner: "Workers: Deploy and Config", + category: "Compute & AI", + status: "private beta", + hideGlobalFlags: ["script"], + }, + 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(", ")}` + : "") + ); + }, +}); diff --git a/packages/wrangler/src/preview/settings.ts b/packages/wrangler/src/preview/settings.ts index d80ae2c9f58..d519f9da209 100644 --- a/packages/wrangler/src/preview/settings.ts +++ b/packages/wrangler/src/preview/settings.ts @@ -2,29 +2,68 @@ 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", + hideGlobalFlags: ["script"], + }, 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", + hideGlobalFlags: ["script"], + }, 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); + }, +});