diff --git a/.changeset/base-config-secrets.md b/.changeset/base-config-secrets.md new file mode 100644 index 00000000000..8d21e78adcf --- /dev/null +++ b/.changeset/base-config-secrets.md @@ -0,0 +1,7 @@ +--- +"wrangler": minor +--- + +Add Preview base config secret commands + +Wrangler now manages Worker Preview base config secrets with `wrangler preview base-config secret put`, `delete`, `list`, and `bulk`. These commands update the Worker's `previews_base_config.env`, keeping shared defaults scoped to all of that Worker's Previews. `wrangler preview base-config secret list` reads from the Worker's Preview base config and prints secret names with values masked. `wrangler preview base-config secret bulk` deletes a secret when its value is `null`, matching `wrangler secret bulk`. 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..32d02051e22 100644 --- a/packages/deploy-helpers/src/preview/api.ts +++ b/packages/deploy-helpers/src/preview/api.ts @@ -145,6 +145,24 @@ type WorkerPreviewDefaultsResource = { preview_defaults?: PreviewDefaults; }; +export type PreviewBaseConfig = { + observability?: Observability; + logpush?: boolean; + limits?: CfUserLimits; + placement?: CfPlacement; + cache?: CacheOptions; + tail_consumers?: Array<{ name: string }>; + env?: EnvBindings; +}; + +export type PreviewBaseConfigPatch = Partial> & { + env?: Record; +}; + +type WorkerPreviewBaseConfigResource = { + previews_base_config?: PreviewBaseConfig; +}; + export async function getPreview( config: Config, accountId: string, @@ -230,7 +248,7 @@ export async function getPreviewDeployment( accountId: string, workerName: string, previewIdentifier: string, - deploymentIdentifier: string + deploymentIdentifier = "latest" ): Promise { return fetchResult( config, @@ -266,6 +284,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, @@ -296,3 +339,34 @@ export async function editWorkerPreviewDefaults( return worker.preview_defaults ?? {}; } + +export async function getPreviewBaseConfig( + config: Config, + accountId: string, + workerName: string +): Promise { + const worker = await fetchResult( + config, + `/accounts/${accountId}/workers/workers/${workerName}` + ); + return worker.previews_base_config ?? {}; +} + +export async function patchPreviewBaseConfig( + config: Config, + accountId: string, + workerName: string, + previewBaseConfig: PreviewBaseConfigPatch +): Promise { + const worker = await fetchResult( + config, + `/accounts/${accountId}/workers/workers/${workerName}`, + { + method: "PATCH", + headers: { "Content-Type": "application/merge-patch+json" }, + body: JSON.stringify({ previews_base_config: previewBaseConfig }), + } + ); + + return worker.previews_base_config ?? {}; +} diff --git a/packages/wrangler/src/__tests__/preview.base-config.secret.test.ts b/packages/wrangler/src/__tests__/preview.base-config.secret.test.ts new file mode 100644 index 00000000000..361e9bba49d --- /dev/null +++ b/packages/wrangler/src/__tests__/preview.base-config.secret.test.ts @@ -0,0 +1,744 @@ +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"; +import { mockAccountId, mockApiToken } from "./helpers/mock-account-id"; +import { mockConsoleMethods } from "./helpers/mock-console"; +import { clearDialogs, mockConfirm, mockPrompt } from "./helpers/mock-dialogs"; +import { useMockIsTTY } from "./helpers/mock-istty"; +import { useMockStdin } from "./helpers/mock-stdin"; +import { msw } from "./helpers/msw"; +import { runWrangler } from "./helpers/run-wrangler"; + +type PreviewBaseConfigPatchBody = { + previews_base_config?: { + env?: Record; + }; +}; + +type PreviewBaseConfigWorkerResult = { + previews_base_config?: { + env?: Record; + }; + preview_defaults?: { + env?: Record; + }; +}; + +function mockPatchWorker( + onRequest?: (info: { + url: string; + contentType: string | null; + body: PreviewBaseConfigPatchBody; + }) => void +) { + msw.use( + http.patch( + `*/accounts/:accountId/workers/workers/:workerId`, + async ({ request }) => { + onRequest?.({ + url: request.url, + contentType: request.headers.get("content-type"), + body: (await request.json()) as PreviewBaseConfigPatchBody, + }); + return HttpResponse.json({ success: true, result: {} }); + } + ) + ); +} + +function mockGetWorker( + env: Record, + onRequest?: (info: { url: string }) => void +) { + mockGetWorkerResult({ previews_base_config: { env } }, onRequest); +} + +function mockGetWorkerResult( + result: PreviewBaseConfigWorkerResult, + onRequest?: (info: { url: string }) => void +) { + msw.use( + http.get( + `*/accounts/:accountId/workers/workers/:workerId`, + ({ request }) => { + onRequest?.({ url: request.url }); + return HttpResponse.json({ + success: true, + result, + }); + } + ) + ); +} + +function mockReadlineInput(input: string) { + vi.spyOn(readline, "createInterface").mockImplementation( + () => input.split(/\r?\n/) as unknown as readline.Interface + ); +} + +describe("wrangler preview", () => { + const std = mockConsoleMethods(); + runInTempDir(); + mockApiToken(); + mockAccountId(); + + describe("preview base-config secret", () => { + beforeEach(() => { + mkdirSync("src", { recursive: true }); + writeFileSync( + "src/index.ts", + "export default { fetch() { return new Response('ok'); } };" + ); + writeFileSync( + "wrangler.json", + JSON.stringify({ + name: "test-worker", + main: "src/index.ts", + compatibility_date: "2025-01-01", + }) + ); + msw.resetHandlers(); + }); + + afterEach(() => { + clearDialogs(); + vi.unstubAllEnvs(); + }); + + test.for([ + { command: "put API_KEY", flag: "--name my-preview" }, + { command: "put API_KEY", flag: '--message "add secret"' }, + { command: "put API_KEY", flag: "--tag v1" }, + { command: "put API_KEY", flag: "--ignore-defaults" }, + { command: "delete REMOVE_ME", flag: "--name my-preview" }, + { command: "delete REMOVE_ME", flag: '--message "delete secret"' }, + { command: "delete REMOVE_ME", flag: "--tag v1" }, + { command: "delete REMOVE_ME", flag: "--ignore-defaults" }, + { command: "list", flag: "--name my-preview" }, + { command: "list", flag: '--message "list secrets"' }, + { command: "list", flag: "--tag v1" }, + { command: "list", flag: "--ignore-defaults" }, + { command: "bulk", flag: "--name my-preview" }, + { command: "bulk", flag: '--message "bulk secrets"' }, + { command: "bulk", flag: "--tag v1" }, + { command: "bulk", flag: "--ignore-defaults" }, + ])( + "rejects Preview deployment flag $flag for $command", + async ({ command, flag }, { expect }) => { + let requested = false; + mockPatchWorker(() => { + requested = true; + }); + mockGetWorker({}, () => { + requested = true; + }); + + await expect( + runWrangler(`preview base-config secret ${command} ${flag}`) + ).rejects.toThrow(/Unknown argument/); + expect(requested).toBe(false); + } + ); + + describe("put", () => { + const mockStdIn = useMockStdin({ isTTY: false }); + + test("patches the base config with the secret", async ({ expect }) => { + mockStdIn.send("base-config-secret"); + let requestUrl: string | undefined; + let contentType: string | null | undefined; + let requestBody: PreviewBaseConfigPatchBody | undefined; + mockPatchWorker(({ url, contentType: ct, body }) => { + requestUrl = url; + contentType = ct; + requestBody = body; + }); + + await runWrangler("preview base-config secret put API_KEY"); + + expect(requestUrl).toContain("/workers/workers/test-worker"); + expect(requestUrl).not.toContain("/previews/"); + expect(contentType).toBe("application/merge-patch+json"); + expect(requestBody).toEqual({ + previews_base_config: { + env: { + API_KEY: { type: "secret_text", text: "base-config-secret" }, + }, + }, + }); + expect(std.out).toContain("test-worker"); + expect(std.out).toContain("API_KEY"); + expect(std.out).toContain( + 'Creating the secret for the Preview base config on the Worker "test-worker"' + ); + expect(std.out).toContain( + 'Updated Preview base config for the Worker "test-worker" with secret API_KEY.' + ); + expect(std.out).not.toContain("base-config-secret"); + }); + + describe("(interactive)", () => { + const { setIsTTY } = useMockIsTTY(); + + test("reads the secret from a masked prompt", async ({ expect }) => { + setIsTTY(true); + mockPrompt({ + text: "Enter a secret value:", + options: { isSecret: true }, + result: "prompt-secret", + }); + let requestBody: PreviewBaseConfigPatchBody | undefined; + mockPatchWorker(({ body }) => { + requestBody = body; + }); + + await runWrangler("preview base-config secret put API_KEY"); + + expect(requestBody).toEqual({ + previews_base_config: { + env: { + API_KEY: { type: "secret_text", text: "prompt-secret" }, + }, + }, + }); + expect(std.out).not.toContain("prompt-secret"); + }); + }); + + test("respects env-specific worker name when using --env", async ({ + expect, + }) => { + mockStdIn.send("env-secret"); + writeFileSync( + "wrangler.json", + JSON.stringify({ + name: "top-worker", + main: "src/index.ts", + compatibility_date: "2025-01-01", + env: { staging: { name: "staging-worker" } }, + }) + ); + let requestUrl: string | undefined; + mockPatchWorker(({ url }) => { + requestUrl = url; + }); + + await runWrangler( + "preview base-config secret put API_KEY --env staging" + ); + + expect(requestUrl).toContain("/workers/workers/staging-worker"); + }); + + test("supports --worker-name", async ({ expect }) => { + mockStdIn.send("override-secret"); + let requestUrl: string | undefined; + mockPatchWorker(({ url }) => { + requestUrl = url; + }); + + await runWrangler( + "preview base-config secret put API_KEY --worker-name override-worker" + ); + + expect(requestUrl).toContain("/workers/workers/override-worker"); + }); + + test("fails before making API calls when Worker name is missing", async ({ + expect, + }) => { + mockStdIn.send("base-config-secret"); + writeFileSync( + "wrangler.json", + JSON.stringify({ + main: "src/index.ts", + compatibility_date: "2025-01-01", + }) + ); + let requested = false; + mockPatchWorker(() => { + requested = true; + }); + + await expect( + runWrangler("preview base-config secret put API_KEY") + ).rejects.toThrow(/Required Worker name missing/); + expect(requested).toBe(false); + }); + }); + + describe("delete", () => { + const { setIsTTY } = useMockIsTTY(); + + test("patches the base config removing the secret", async ({ + expect, + }) => { + let requestUrl: string | undefined; + let contentType: string | null | undefined; + let requestBody: PreviewBaseConfigPatchBody | undefined; + mockPatchWorker(({ url, contentType: ct, body }) => { + requestUrl = url; + contentType = ct; + requestBody = body; + }); + + await runWrangler( + "preview base-config secret delete REMOVE_ME --skip-confirmation" + ); + + expect(requestUrl).toContain("/workers/workers/test-worker"); + expect(contentType).toBe("application/merge-patch+json"); + expect(requestBody).toEqual({ + previews_base_config: { env: { REMOVE_ME: null } }, + }); + expect(std.out).toContain("REMOVE_ME"); + expect(std.out).toContain("test-worker"); + expect(std.out).toContain( + "Deleting the secret REMOVE_ME on the Preview base config for the Worker test-worker" + ); + expect(std.out).toContain( + 'Updated Preview base config for the Worker "test-worker" with deleted secret REMOVE_ME.' + ); + }); + + test("confirms before deleting a secret interactively", async ({ + expect, + }) => { + setIsTTY(true); + mockConfirm({ + text: "Are you sure you want to permanently delete the secret REMOVE_ME on the Preview base config for the Worker test-worker?", + result: true, + }); + let requestBody: PreviewBaseConfigPatchBody | undefined; + mockPatchWorker(({ body }) => { + requestBody = body; + }); + + await runWrangler("preview base-config secret delete REMOVE_ME"); + + expect(requestBody).toEqual({ + previews_base_config: { env: { REMOVE_ME: null } }, + }); + }); + + test("makes no API call when the user declines confirmation", async ({ + expect, + }) => { + setIsTTY(true); + mockConfirm({ + text: "Are you sure you want to permanently delete the secret REMOVE_ME on the Preview base config for the Worker test-worker?", + result: false, + }); + let requested = false; + mockPatchWorker(() => { + requested = true; + }); + + await runWrangler("preview base-config secret delete REMOVE_ME"); + + expect(requested).toBe(false); + expect(std.out).not.toContain("Deleting the secret REMOVE_ME"); + }); + + test("respects env-specific worker name when deleting a secret", async ({ + expect, + }) => { + writeFileSync( + "wrangler.json", + JSON.stringify({ + name: "top-worker", + main: "src/index.ts", + compatibility_date: "2025-01-01", + env: { staging: { name: "staging-worker" } }, + }) + ); + let requestUrl: string | undefined; + mockPatchWorker(({ url }) => { + requestUrl = url; + }); + + await runWrangler( + "preview base-config secret delete REMOVE_ME --env staging --skip-confirmation" + ); + + expect(requestUrl).toContain("/workers/workers/staging-worker"); + }); + + test("supports --worker-name when deleting a secret", async ({ + expect, + }) => { + let requestUrl: string | undefined; + mockPatchWorker(({ url }) => { + requestUrl = url; + }); + + await runWrangler( + "preview base-config secret delete REMOVE_ME --worker-name override-worker --skip-confirmation" + ); + + expect(requestUrl).toContain("/workers/workers/override-worker"); + }); + }); + + describe("list", () => { + test("reads the base config", async ({ expect }) => { + let requestUrl: string | undefined; + mockGetWorker({}, ({ url }) => { + requestUrl = url; + }); + + await runWrangler("preview base-config secret list --json"); + + expect(requestUrl).toContain("/workers/workers/test-worker"); + expect(requestUrl).not.toContain("/previews/"); + }); + + // 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. + test.for([ + { + 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 }, { expect }) => { + mockGetWorker({ + MY_SECRET: + text === undefined + ? { type: "secret_text" } + : { type: "secret_text", text }, + PLAIN: { type: "plain_text", text: "not-a-secret" }, + }); + + await runWrangler( + `preview base-config secret list ${json ? "--json" : ""}` + ); + + expect(std.out).toContain("MY_SECRET"); + expect(std.out).not.toContain("PLAIN"); + expect(std.out).not.toContain("not-a-secret"); + 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("Preview base config"); + expect(std.out).toContain("Secrets"); + expect(std.out).toContain("********"); + } + } + ); + + test("shows (none) when the base config has no secrets", async ({ + expect, + }) => { + mockGetWorker({}); + + await runWrangler("preview base-config secret list"); + + expect(std.out).toContain("(none)"); + }); + + test("does not fall back to preview defaults when base config is missing", async ({ + expect, + }) => { + mockGetWorkerResult({ + preview_defaults: { + env: { + DEFAULT_SECRET: { + type: "secret_text", + text: "preview-default-secret", + }, + }, + }, + }); + + await runWrangler("preview base-config secret list"); + + expect(std.out).toContain("(none)"); + expect(std.out).not.toContain("DEFAULT_SECRET"); + expect(std.out).not.toContain("preview-default-secret"); + }); + + test("respects env-specific worker name when listing secrets", async ({ + expect, + }) => { + writeFileSync( + "wrangler.json", + JSON.stringify({ + name: "top-worker", + main: "src/index.ts", + compatibility_date: "2025-01-01", + env: { staging: { name: "staging-worker" } }, + }) + ); + let requestUrl: string | undefined; + mockGetWorker({}, ({ url }) => { + requestUrl = url; + }); + + await runWrangler("preview base-config secret list --env staging"); + + expect(requestUrl).toContain("/workers/workers/staging-worker"); + }); + + test("supports --worker-name when listing secrets", async ({ + expect, + }) => { + let requestUrl: string | undefined; + mockGetWorker({}, ({ url }) => { + requestUrl = url; + }); + + await runWrangler( + "preview base-config secret list --worker-name override-worker" + ); + + expect(requestUrl).toContain("/workers/workers/override-worker"); + }); + }); + + describe("bulk", () => { + test("patches the base config with all secrets", async ({ expect }) => { + writeFileSync("secrets.env", "FIRST_KEY=one\nSECOND_KEY=two\n"); + let requestUrl: string | undefined; + let requestBody: PreviewBaseConfigPatchBody | undefined; + mockPatchWorker(({ url, body }) => { + requestUrl = url; + requestBody = body; + }); + + await runWrangler("preview base-config secret bulk secrets.env"); + + expect(requestUrl).toContain("/workers/workers/test-worker"); + expect(requestBody).toEqual({ + previews_base_config: { + env: { + FIRST_KEY: { type: "secret_text", text: "one" }, + SECOND_KEY: { type: "secret_text", text: "two" }, + }, + }, + }); + 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( + 'Processing the secrets for the Preview base config on the Worker "test-worker"' + ); + expect(std.out).toContain("with 2 created and 0 deleted secrets"); + expect(std.out).toContain( + 'Updated Preview base config for the Worker "test-worker" with 2 created and 0 deleted secrets.' + ); + expect(std.out).not.toContain("one"); + expect(std.out).not.toContain("two"); + }); + + test("creates secrets for empty dotenv values", async ({ expect }) => { + writeFileSync( + "secrets.env", + 'KEEP_ME=value\nREMOVE_ME=\nALSO_GONE=""\n' + ); + let requestBody: PreviewBaseConfigPatchBody | undefined; + mockPatchWorker(({ body }) => { + requestBody = body; + }); + + await runWrangler("preview base-config secret bulk secrets.env"); + + expect(requestBody).toEqual({ + previews_base_config: { + env: { + KEEP_ME: { type: "secret_text", text: "value" }, + REMOVE_ME: { type: "secret_text", text: "" }, + ALSO_GONE: { type: "secret_text", text: "" }, + }, + }, + }); + expect(std.out).toContain( + "Successfully created secret for key: KEEP_ME" + ); + expect(std.out).toContain( + "Successfully created secret for key: REMOVE_ME" + ); + expect(std.out).toContain( + "Successfully created secret for key: ALSO_GONE" + ); + expect(std.out).toContain("with 3 created and 0 deleted secrets"); + }); + + test("uploads JSON secrets from stdin", async ({ expect }) => { + mockReadlineInput( + JSON.stringify({ + FIRST_KEY: "one", + SECOND_KEY: "two", + }) + ); + let requestBody: PreviewBaseConfigPatchBody | undefined; + mockPatchWorker(({ body }) => { + requestBody = body; + }); + + await runWrangler("preview base-config secret bulk"); + + expect(requestBody).toEqual({ + previews_base_config: { + env: { + FIRST_KEY: { type: "secret_text", text: "one" }, + SECOND_KEY: { type: "secret_text", text: "two" }, + }, + }, + }); + expect(std.out).toContain("with 2 created and 0 deleted secrets"); + expect(std.out).not.toContain("one"); + expect(std.out).not.toContain("two"); + }); + + test("uploads dotenv secrets from stdin", async ({ expect }) => { + mockReadlineInput("FIRST_KEY=one\nSECOND_KEY=two"); + let requestBody: PreviewBaseConfigPatchBody | undefined; + mockPatchWorker(({ body }) => { + requestBody = body; + }); + + await runWrangler("preview base-config secret bulk"); + + expect(requestBody).toEqual({ + previews_base_config: { + env: { + FIRST_KEY: { type: "secret_text", text: "one" }, + SECOND_KEY: { type: "secret_text", text: "two" }, + }, + }, + }); + expect(std.out).toContain("with 2 created and 0 deleted secrets"); + expect(std.out).not.toContain("one"); + expect(std.out).not.toContain("two"); + }); + + test("respects env-specific worker name when bulk uploading secrets", async ({ + expect, + }) => { + writeFileSync("secrets.env", "API_KEY=one\n"); + writeFileSync( + "wrangler.json", + JSON.stringify({ + name: "top-worker", + main: "src/index.ts", + compatibility_date: "2025-01-01", + env: { staging: { name: "staging-worker" } }, + }) + ); + let requestUrl: string | undefined; + mockPatchWorker(({ url }) => { + requestUrl = url; + }); + + await runWrangler( + "preview base-config secret bulk secrets.env --env staging" + ); + + expect(requestUrl).toContain("/workers/workers/staging-worker"); + }); + + test("supports --worker-name when bulk uploading secrets", async ({ + expect, + }) => { + writeFileSync("secrets.env", "API_KEY=one\n"); + let requestUrl: string | undefined; + mockPatchWorker(({ url }) => { + requestUrl = url; + }); + + await runWrangler( + "preview base-config secret bulk secrets.env --worker-name override-worker" + ); + + expect(requestUrl).toContain("/workers/workers/override-worker"); + }); + + 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: PreviewBaseConfigPatchBody | undefined; + mockPatchWorker(({ body }) => { + requestBody = body; + }); + + await runWrangler("preview base-config secret bulk secrets.json"); + + expect(requestBody).toEqual({ + previews_base_config: { + env: { + KEEP_ME: { type: "secret_text", text: "value" }, + REMOVE_ME: null, + ALSO_GONE: null, + }, + }, + }); + 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("patches an empty env for empty input", async ({ expect }) => { + writeFileSync("secrets.json", JSON.stringify({})); + let requestBody: PreviewBaseConfigPatchBody | undefined; + mockPatchWorker(({ body }) => { + requestBody = body; + }); + + await runWrangler("preview base-config secret bulk secrets.json"); + + expect(requestBody).toEqual({ + previews_base_config: { env: {} }, + }); + expect(std.out).toContain("with 0 created and 0 deleted secrets"); + }); + + test("makes no API call when there is no input", async ({ expect }) => { + let requested = false; + mockPatchWorker(() => { + requested = true; + }); + vi.spyOn(readline, "createInterface").mockImplementation( + () => null as unknown as readline.Interface + ); + + await runWrangler("preview base-config secret bulk"); + + expect(requested).toBe(false); + expect(std.err).toContain( + "🚨 No content found in file, or piped input." + ); + }); + }); + }); +}); diff --git a/packages/wrangler/src/__tests__/preview.secret.test.ts b/packages/wrangler/src/__tests__/preview.secret.test.ts index f57c40624a5..cc0b01b1f5b 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,140 @@ 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. + test.for([ + { + 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 }, { expect }) => { + 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("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("********"); + } + } + ); + + 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 +513,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 +597,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/index.ts b/packages/wrangler/src/index.ts index 2501578b7e7..2dea1aefd75 100644 --- a/packages/wrangler/src/index.ts +++ b/packages/wrangler/src/index.ts @@ -311,17 +311,23 @@ 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 { previewBaseConfigNamespace } from "./preview/base-config"; +import { previewBaseConfigSecretNamespace } from "./preview/base-config/secrets"; +import { previewBaseConfigSecretBulkCommand } from "./preview/base-config/secrets/bulk"; +import { previewBaseConfigSecretDeleteCommand } from "./preview/base-config/secrets/delete"; +import { previewBaseConfigSecretListCommand } from "./preview/base-config/secrets/list"; +import { previewBaseConfigSecretPutCommand } from "./preview/base-config/secrets/put"; +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"; @@ -931,6 +937,30 @@ export function createCLIParser(argv: string[]) { command: "wrangler preview secret bulk", definition: previewSecretBulkCommand, }, + { + command: "wrangler preview base-config", + definition: previewBaseConfigNamespace, + }, + { + command: "wrangler preview base-config secret", + definition: previewBaseConfigSecretNamespace, + }, + { + command: "wrangler preview base-config secret put", + definition: previewBaseConfigSecretPutCommand, + }, + { + command: "wrangler preview base-config secret delete", + definition: previewBaseConfigSecretDeleteCommand, + }, + { + command: "wrangler preview base-config secret list", + definition: previewBaseConfigSecretListCommand, + }, + { + command: "wrangler preview base-config secret bulk", + definition: previewBaseConfigSecretBulkCommand, + }, ]); registry.registerNamespace("preview"); diff --git a/packages/wrangler/src/preview/base-config/index.ts b/packages/wrangler/src/preview/base-config/index.ts new file mode 100644 index 00000000000..0500a1256e4 --- /dev/null +++ b/packages/wrangler/src/preview/base-config/index.ts @@ -0,0 +1,10 @@ +import { createNamespace } from "../../core/create-command"; + +export const previewBaseConfigNamespace = createNamespace({ + metadata: { + description: "Manage the Preview base config shared by Worker Previews", + owner: "Workers: Deploy and Config", + category: "Compute & AI", + status: "private beta", + }, +}); diff --git a/packages/wrangler/src/preview/base-config/secrets/bulk.ts b/packages/wrangler/src/preview/base-config/secrets/bulk.ts new file mode 100644 index 00000000000..3461b16c5ec --- /dev/null +++ b/packages/wrangler/src/preview/base-config/secrets/bulk.ts @@ -0,0 +1,74 @@ +import { + patchPreviewBaseConfig, + resolveWorkerName, +} from "@cloudflare/deploy-helpers"; +import { createCommand } from "../../../core/create-command"; +import { logger } from "../../../logger"; +import { parseBulkInputToObject } from "../../../secret"; +import { requireAuth } from "../../../user"; +import { toSecretBindingsPatch } from "../../secrets"; +import { rejectUnsupportedPreviewArgs } from "."; + +export const previewBaseConfigSecretBulkCommand = createCommand({ + metadata: { + description: "Upload multiple secrets to the Preview base config", + 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, + }, + validateArgs: rejectUnsupportedPreviewArgs, + handler: async function previewBaseConfigSecretBulkHandler(args, { config }) { + const workerName = resolveWorkerName(args, config); + const accountId = await requireAuth(config); + + logger.log( + `🌀 Processing the secrets for the Preview base config on the Worker "${workerName}"${args.env ? ` (${args.env})` : ""}` + ); + + 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 + ); + + await patchPreviewBaseConfig(config, accountId, workerName, { + env: toSecretBindingsPatch(content), + }); + + 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}`); + } + + logger.log( + `✨ Success! Updated Preview base config for the Worker "${workerName}" with ${created.length} created and ${deleted.length} deleted secrets.` + ); + }, +}); diff --git a/packages/wrangler/src/preview/base-config/secrets/delete.ts b/packages/wrangler/src/preview/base-config/secrets/delete.ts new file mode 100644 index 00000000000..272e4275602 --- /dev/null +++ b/packages/wrangler/src/preview/base-config/secrets/delete.ts @@ -0,0 +1,68 @@ +import { + patchPreviewBaseConfig, + resolveWorkerName, +} from "@cloudflare/deploy-helpers"; +import { createCommand } from "../../../core/create-command"; +import { confirm } from "../../../dialogs"; +import { logger } from "../../../logger"; +import { requireAuth } from "../../../user"; +import { rejectUnsupportedPreviewArgs } from "."; + +export const previewBaseConfigSecretDeleteCommand = createCommand({ + metadata: { + description: "Delete a secret variable from the Preview base config", + 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, + }, + validateArgs: rejectUnsupportedPreviewArgs, + handler: async function previewBaseConfigSecretDeleteHandler( + args, + { config } + ) { + const workerName = resolveWorkerName(args, config); + 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 base config for the Worker ${workerName}${args.env ? ` (${args.env})` : ""}?` + )) + ) { + logger.log( + `🌀 Deleting the secret ${args.key} on the Preview base config for the Worker ${workerName}${args.env ? ` (${args.env})` : ""}` + ); + + await patchPreviewBaseConfig(config, accountId, workerName, { + env: { [args.key]: null }, + }); + + logger.log( + `✨ Success! Updated Preview base config for the Worker "${workerName}" with deleted secret ${args.key}.` + ); + } + }, +}); diff --git a/packages/wrangler/src/preview/base-config/secrets/index.ts b/packages/wrangler/src/preview/base-config/secrets/index.ts new file mode 100644 index 00000000000..da3622ee67d --- /dev/null +++ b/packages/wrangler/src/preview/base-config/secrets/index.ts @@ -0,0 +1,30 @@ +import { CommandLineArgsError } from "@cloudflare/workers-utils"; +import { createNamespace } from "../../../core/create-command"; + +export const previewBaseConfigSecretNamespace = createNamespace({ + metadata: { + description: "Manage secrets on the Preview base config", + owner: "Workers: Deploy and Config", + category: "Compute & AI", + status: "private beta", + }, +}); + +export function rejectUnsupportedPreviewArgs(args: Record) { + for (const [key, flag] of [ + ["name", "name"], + ["tag", "tag"], + ["message", "message"], + ] as const) { + if (args[key] !== undefined) { + throw new CommandLineArgsError(`Unknown argument: ${flag}`, { + telemetryMessage: "preview base-config unsupported flag", + }); + } + } + if (args.ignoreDefaults === true) { + throw new CommandLineArgsError("Unknown argument: ignore-defaults", { + telemetryMessage: "preview base-config unsupported flag", + }); + } +} diff --git a/packages/wrangler/src/preview/base-config/secrets/list.ts b/packages/wrangler/src/preview/base-config/secrets/list.ts new file mode 100644 index 00000000000..fdf207aea4a --- /dev/null +++ b/packages/wrangler/src/preview/base-config/secrets/list.ts @@ -0,0 +1,115 @@ +import { + drawBox, + getBindingValue, + getPreviewBaseConfig, + padToVisibleWidth, + resolveWorkerName, + visibleLength, +} from "@cloudflare/deploy-helpers"; +import { getBindingTypeFriendlyName } from "@cloudflare/workers-utils"; +import chalk from "chalk"; +import { createCommand } from "../../../core/create-command"; +import { logger } from "../../../logger"; +import { requireAuth } from "../../../user"; +import { rejectUnsupportedPreviewArgs } from "."; +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 formatBaseConfigSecrets( + 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("Preview base config")}`); + 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 previewBaseConfigSecretListCommand = createCommand({ + metadata: { + description: "List all secrets on the Preview base config", + 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, + }, + validateArgs: rejectUnsupportedPreviewArgs, + handler: async function previewBaseConfigSecretListHandler(args, { config }) { + const workerName = resolveWorkerName(args, config); + const accountId = await requireAuth(config); + + const baseConfig = await getPreviewBaseConfig( + config, + accountId, + workerName + ); + const secrets = extractSecretSummaries(baseConfig.env); + + if (args.json) { + logger.log(JSON.stringify(secrets, null, 2)); + return; + } + + logger.log(formatBaseConfigSecrets(workerName, baseConfig.env)); + }, +}); diff --git a/packages/wrangler/src/preview/base-config/secrets/put.ts b/packages/wrangler/src/preview/base-config/secrets/put.ts new file mode 100644 index 00000000000..24147c3aada --- /dev/null +++ b/packages/wrangler/src/preview/base-config/secrets/put.ts @@ -0,0 +1,60 @@ +import { + patchPreviewBaseConfig, + resolveWorkerName, +} from "@cloudflare/deploy-helpers"; +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 { toSecretBindingsPatch } from "../../secrets"; +import { rejectUnsupportedPreviewArgs } from "."; + +export const previewBaseConfigSecretPutCommand = createCommand({ + metadata: { + description: + "Create or update a secret variable on the Preview base config", + 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, + }, + validateArgs: rejectUnsupportedPreviewArgs, + handler: async function previewBaseConfigSecretPutHandler(args, { 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() + ); + + logger.log( + `🌀 Creating the secret for the Preview base config on the Worker "${workerName}"${args.env ? ` (${args.env})` : ""}` + ); + + await patchPreviewBaseConfig(config, accountId, workerName, { + env: toSecretBindingsPatch({ [args.key]: secretValue }), + }); + + logger.log( + `✨ Success! Updated Preview base config for the Worker "${workerName}" with secret ${args.key}.` + ); + }, +}); 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 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..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/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..270cf6fc3a0 --- /dev/null +++ b/packages/wrangler/src/preview/secrets/bulk.ts @@ -0,0 +1,112 @@ +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", + }, + 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..bd7afb7cf8c --- /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 and create a new 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..2c7e759cd8b --- /dev/null +++ b/packages/wrangler/src/preview/secrets/index.ts @@ -0,0 +1,98 @@ +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", + }, +}); + +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 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, + 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..cfca1e8887a --- /dev/null +++ b/packages/wrangler/src/preview/secrets/list.ts @@ -0,0 +1,150 @@ +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, + previewName: 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(`${chalk.bold.hex("#FFA500")("Preview:")} ${previewName}`); + lines.push(""); + lines.push(` ${chalk.bold.underline("Latest Preview deployment")}`); + 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, previewName, 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..c1818bf22bb --- /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 and create a new 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(", ")}` + : "") + ); + }, +}); 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); + }, +});