From f1099e767ed0e3a7e83b24566346de2b3498a20e Mon Sep 17 00:00:00 2001 From: Samuel Macleod Date: Sun, 7 Apr 2024 00:17:45 +0100 Subject: [PATCH 01/11] Support `pages secret put|delete|list|bulk` --- .../src/__tests__/pages/secret.test.ts | 693 ++++++++++++++++++ packages/wrangler/src/index.ts | 2 +- packages/wrangler/src/metrics/send-event.ts | 3 + .../wrangler/src/pages/download-config.ts | 3 +- packages/wrangler/src/pages/index.ts | 14 +- packages/wrangler/src/pages/secret/index.ts | 405 ++++++++++ 6 files changed, 1116 insertions(+), 4 deletions(-) create mode 100644 packages/wrangler/src/__tests__/pages/secret.test.ts create mode 100644 packages/wrangler/src/pages/secret/index.ts diff --git a/packages/wrangler/src/__tests__/pages/secret.test.ts b/packages/wrangler/src/__tests__/pages/secret.test.ts new file mode 100644 index 000000000000..1906c2af204f --- /dev/null +++ b/packages/wrangler/src/__tests__/pages/secret.test.ts @@ -0,0 +1,693 @@ +import { Blob } from "node:buffer"; +import * as fs from "node:fs"; +import { mkdirSync, rmdirSync, writeFileSync } from "node:fs"; +import readline from "node:readline"; +import * as TOML from "@iarna/toml"; +import { MockedRequest, rest } from "msw"; +import { FormData } from "undici"; +import { saveToConfigCache } from "../../config-cache"; +import { PAGES_CONFIG_CACHE_FILENAME } from "../../pages/constants"; +import { PagesProject } from "../../pages/download-config"; +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 { mockGetMembershipsFail } from "../helpers/mock-oauth-flow"; +import { useMockStdin } from "../helpers/mock-stdin"; +import { msw } from "../helpers/msw"; +import { FileReaderSync } from "../helpers/msw/read-file-sync"; +import { runInTempDir } from "../helpers/run-in-tmp"; +import { runWrangler } from "../helpers/run-wrangler"; +import type { RestRequest } from "msw"; +import type { Interface } from "node:readline"; + +function createFetchResult(result: unknown, success = true) { + return { + success, + errors: [], + messages: [], + result, + }; +} + +export function mockGetMemberships( + accounts: { id: string; account: { id: string; name: string } }[] +) { + msw.use( + rest.get("*/memberships", (req, res, ctx) => { + return res.once(ctx.json(createFetchResult(accounts))); + }) + ); +} + +describe("wrangler pages secret", () => { + const std = mockConsoleMethods(); + const { setIsTTY } = useMockIsTTY(); + runInTempDir(); + mockAccountId(); + mockApiToken(); + afterEach(() => { + clearDialogs(); + }); + + describe("put", () => { + function mockProjectRequests( + input: { name: string; text: string }, + env: "production" | "preview" = "production" + ) { + msw.use( + rest.patch( + `*/accounts/:accountId/pages/projects/:project`, + async (req, res, ctx) => { + expect(req.params.project).toEqual("some-project-name"); + const project = await req.json(); + expect( + project.deployment_configs[env].env_vars?.[input.name] + ).toEqual({ type: "secret_text", value: input.text }); + expect( + project.deployment_configs[env].wrangler_config_hash + ).toEqual(env === "production" ? "wch" : undefined); + return res.once(ctx.json(createFetchResult(project))); + } + ), + rest.get( + "*/accounts/:accountId/pages/projects/:project", + async (req, res, ctx) => { + return res( + ctx.status(200), + ctx.json({ + success: true, + errors: [], + messages: [], + result: { + name: "some-project-name", + deployment_configs: { + production: { wrangler_config_hash: "wch" }, + preview: {}, + }, + }, + }) + ); + } + ) + ); + } + + describe("interactive", () => { + beforeEach(() => { + setIsTTY(true); + }); + + it("should trim stdin secret value", async () => { + mockPrompt({ + text: "Enter a secret value:", + options: { isSecret: true }, + result: `hunter2 + `, + }); + + mockProjectRequests({ name: `secret-name`, text: `hunter2` }); + await runWrangler( + "pages secret put secret-name --project some-project-name" + ); + expect(std.out).toMatchInlineSnapshot(` + "🌀 Creating the secret for the Pages project \\"some-project-name\\" (production) + ✨ Success! Uploaded secret secret-name" + `); + }); + + it("should create a secret", async () => { + mockPrompt({ + text: "Enter a secret value:", + options: { isSecret: true }, + result: "the-secret", + }); + + mockProjectRequests({ name: "the-key", text: "the-secret" }); + await runWrangler( + "pages secret put the-key --project some-project-name" + ); + + expect(std.out).toMatchInlineSnapshot(` + "🌀 Creating the secret for the Pages project \\"some-project-name\\" (production) + ✨ Success! Uploaded secret the-key" + `); + expect(std.err).toMatchInlineSnapshot(`""`); + }); + + it("should create a secret: preview", async () => { + mockPrompt({ + text: "Enter a secret value:", + options: { isSecret: true }, + result: "the-secret", + }); + + mockProjectRequests({ name: "the-key", text: "the-secret" }, "preview"); + await runWrangler( + "pages secret put the-key --project some-project-name --env preview" + ); + + expect(std.out).toMatchInlineSnapshot(` + "🌀 Creating the secret for the Pages project \\"some-project-name\\" (preview) + ✨ Success! Uploaded secret the-key" + `); + expect(std.err).toMatchInlineSnapshot(`""`); + }); + + it("should error with invalid env", async () => { + mockProjectRequests( + { name: "the-key", text: "the-secret" }, + // @ts-expect-error + "some-env" + ); + await expect( + runWrangler( + "pages secret put the-key --project some-project-name --env some-env" + ) + ).rejects.toMatchInlineSnapshot( + `[Error: Pages does not support the "some-env" named environment. Please specify "production" (default) or "preview"]` + ); + }); + + it("should error without a project name", async () => { + await expect( + runWrangler("pages secret put the-key") + ).rejects.toMatchInlineSnapshot( + `[Error: Must specify a project name.]` + ); + }); + }); + + describe("non-interactive", () => { + beforeEach(() => { + setIsTTY(false); + }); + const mockStdIn = useMockStdin({ isTTY: false }); + + it("should trim stdin secret value, from piped input", async () => { + mockProjectRequests({ name: "the-key", text: "the-secret" }); + // Pipe the secret in as three chunks to test that we reconstitute it correctly. + mockStdIn.send( + `the`, + `-`, + `secret + ` // whitespace & newline being removed + ); + await runWrangler( + "pages secret put the-key --project some-project-name" + ); + + expect(std.out).toMatchInlineSnapshot(` + "🌀 Creating the secret for the Pages project \\"some-project-name\\" (production) + ✨ Success! Uploaded secret the-key" + `); + expect(std.warn).toMatchInlineSnapshot(`""`); + expect(std.err).toMatchInlineSnapshot(`""`); + }); + + it("should create a secret, from piped input", async () => { + mockProjectRequests({ name: "the-key", text: "the-secret" }); + // Pipe the secret in as three chunks to test that we reconstitute it correctly. + mockStdIn.send("the", "-", "secret"); + await runWrangler( + "pages secret put the-key --project some-project-name" + ); + + expect(std.out).toMatchInlineSnapshot(` + "🌀 Creating the secret for the Pages project \\"some-project-name\\" (production) + ✨ Success! Uploaded secret the-key" + `); + expect(std.warn).toMatchInlineSnapshot(`""`); + expect(std.err).toMatchInlineSnapshot(`""`); + }); + + it("should error if the piped input fails", async () => { + mockProjectRequests({ name: "the-key", text: "the-secret" }); + mockStdIn.throwError(new Error("Error in stdin stream")); + await expect( + runWrangler("pages secret put the-key --project some-project-name") + ).rejects.toThrowErrorMatchingInlineSnapshot(`"Error in stdin stream"`); + + expect(std.out).toMatchInlineSnapshot(` + " + If you think this is a bug then please create an issue at https://github.com/cloudflare/workers-sdk/issues/new/choose" + `); + expect(std.warn).toMatchInlineSnapshot(`""`); + }); + + describe("with accountId", () => { + mockAccountId({ accountId: null }); + + it("should error if request for memberships fails", async () => { + mockGetMembershipsFail(); + await expect( + runWrangler("pages secret put the-key --project some-project-name") + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"A request to the Cloudflare API (/memberships) failed."` + ); + }); + + it("should error if a user has no account", async () => { + mockGetMemberships([]); + await expect( + runWrangler("pages secret put the-key --project some-project-name") + ).rejects.toThrowErrorMatchingInlineSnapshot(` + "Failed to automatically retrieve account IDs for the logged in user. + In a non-interactive environment, it is mandatory to specify an account ID, either by assigning its value to CLOUDFLARE_ACCOUNT_ID, or as \`account_id\` in your \`wrangler.toml\` file." + `); + }); + + it("should error if a user has multiple accounts, and has not specified an account", async () => { + mockGetMemberships([ + { + id: "1", + account: { id: "account-id-1", name: "account-name-1" }, + }, + { + id: "2", + account: { id: "account-id-2", name: "account-name-2" }, + }, + { + id: "3", + account: { id: "account-id-3", name: "account-name-3" }, + }, + ]); + + await expect( + runWrangler("pages secret put the-key --project some-project-name") + ).rejects.toThrowErrorMatchingInlineSnapshot(` + "More than one account available but unable to select one in non-interactive mode. + Please set the appropriate \`account_id\` in your \`wrangler.toml\` file. + Available accounts are (\`\`: \`\`): + \`account-name-1\`: \`account-id-1\` + \`account-name-2\`: \`account-id-2\` + \`account-name-3\`: \`account-id-3\`" + `); + }); + }); + }); + }); + + describe("delete", () => { + beforeEach(() => { + setIsTTY(true); + }); + function mockDeleteRequest( + name: string, + env: "production" | "preview" = "production" + ) { + msw.use( + rest.patch( + `*/accounts/:accountId/pages/projects/:project`, + async (req, res, ctx) => { + expect(req.params.project).toEqual("some-project-name"); + const project = await req.json(); + expect(project.deployment_configs[env].env_vars?.[name]).toEqual( + null + ); + expect( + project.deployment_configs[env].wrangler_config_hash + ).toEqual(env === "production" ? "wch" : undefined); + + return res.once(ctx.json(createFetchResult(project))); + } + ), + rest.get( + "*/accounts/:accountId/pages/projects/:project", + async (req, res, ctx) => { + return res( + ctx.status(200), + ctx.json({ + success: true, + errors: [], + messages: [], + result: { + name: "some-project-name", + deployment_configs: { + production: { wrangler_config_hash: "wch" }, + preview: {}, + }, + }, + }) + ); + } + ) + ); + } + + it("should delete a secret", async () => { + mockDeleteRequest("the-key"); + mockConfirm({ + text: "Are you sure you want to permanently delete the secret the-key on the Pages project some-project-name (production)?", + result: true, + }); + await runWrangler( + "pages secret delete the-key --project some-project-name" + ); + expect(std.out).toMatchInlineSnapshot(` + "🌀 Deleting the secret the-key on the Pages project some-project-name (production) + ✨ Success! Deleted secret the-key" + `); + expect(std.err).toMatchInlineSnapshot(`""`); + }); + + it("should delete a secret: preview", async () => { + mockDeleteRequest("the-key", "preview"); + mockConfirm({ + text: "Are you sure you want to permanently delete the secret the-key on the Pages project some-project-name (preview)?", + result: true, + }); + await runWrangler( + "pages secret delete the-key --project some-project-name --env preview" + ); + expect(std.out).toMatchInlineSnapshot(` + "🌀 Deleting the secret the-key on the Pages project some-project-name (preview) + ✨ Success! Deleted secret the-key" + `); + expect(std.err).toMatchInlineSnapshot(`""`); + }); + + it("should fail to delete with invalid env", async () => { + await expect( + runWrangler( + "pages secret delete the-key --project some-project-name --env some-env" + ) + ).rejects.toMatchInlineSnapshot( + `[Error: Pages does not support the "some-env" named environment. Please specify "production" (default) or "preview"]` + ); + }); + + it("should error without a project name", async () => { + await expect( + runWrangler("pages secret delete the-key") + ).rejects.toMatchInlineSnapshot(`[Error: Must specify a project name.]`); + }); + }); + + describe("list", () => { + beforeEach(() => { + setIsTTY(true); + }); + function mockListRequest(env: "production" | "preview" = "production") { + msw.use( + rest.get( + "*/accounts/:accountId/pages/projects/:project", + async (req, res, ctx) => { + return res( + ctx.status(200), + ctx.json({ + success: true, + errors: [], + messages: [], + result: { + name: "some-project-name", + deployment_configs: { + production: { + wrangler_config_hash: "wch", + env_vars: { + "the-secret-name": { + type: "secret_text", + }, + "the-secret-name-2": { + type: "secret_text", + }, + }, + }, + preview: { + env_vars: { + "the-secret-name-preview": { + type: "secret_text", + }, + }, + }, + }, + }, + }) + ); + } + ) + ); + } + + it("should list secrets", async () => { + mockListRequest(); + await runWrangler("pages secret list --project some-project-name"); + expect(std.out).toMatchInlineSnapshot(` + "The \\"production\\" environment of your Pages project \\"some-project-name\\" has access to the following secrets: + - the-secret-name: Value Encrypted + - the-secret-name-2: Value Encrypted" + `); + expect(std.err).toMatchInlineSnapshot(`""`); + }); + + it("should list secrets: preview", async () => { + mockListRequest(); + await runWrangler( + "pages secret list --project some-project-name --env preview" + ); + expect(std.out).toMatchInlineSnapshot(` + "The \\"preview\\" environment of your Pages project \\"some-project-name\\" has access to the following secrets: + - the-secret-name-preview: Value Encrypted" + `); + expect(std.err).toMatchInlineSnapshot(`""`); + }); + + it("should fail with invalid env", async () => { + mockListRequest(); + await expect( + runWrangler( + "pages secret list --project some-project-name --env some-env" + ) + ).rejects.toMatchInlineSnapshot( + `[Error: Pages does not support the "some-env" named environment. Please specify "production" (default) or "preview"]` + ); + }); + + it("should error without a project name", async () => { + await expect( + runWrangler("pages secret list") + ).rejects.toMatchInlineSnapshot(`[Error: Must specify a project name.]`); + }); + }); + + describe("secret bulk", () => { + function mockProjectRequests( + vars: { name: string; text: string }[], + env: "production" | "preview" = "production" + ) { + msw.use( + rest.patch( + `*/accounts/:accountId/pages/projects/:project`, + async (req, res, ctx) => { + expect(req.params.project).toEqual("some-project-name"); + const project = await req.json(); + for (const variable of vars) { + expect( + project.deployment_configs[env].env_vars?.[variable.name] + ).toEqual({ type: "secret_text", value: variable.text }); + } + + expect( + project.deployment_configs[env].wrangler_config_hash + ).toEqual(env === "production" ? "wch" : undefined); + return res.once(ctx.json(createFetchResult(project))); + } + ), + rest.get( + "*/accounts/:accountId/pages/projects/:project", + async (req, res, ctx) => { + return res( + ctx.status(200), + ctx.json({ + success: true, + errors: [], + messages: [], + result: { + name: "some-project-name", + deployment_configs: { + production: { wrangler_config_hash: "wch" }, + preview: {}, + }, + }, + }) + ); + } + ) + ); + } + it("should fail secret bulk w/ no pipe or JSON input", async () => { + mockProjectRequests([]); + jest + .spyOn(readline, "createInterface") + .mockImplementation(() => null as unknown as Interface); + await expect( + runWrangler(`pages secret bulk --project some-project-name`) + ).rejects.toMatchInlineSnapshot( + `[Error: 🚨 Please provide a JSON file or valid JSON pipe]` + ); + }); + + it("should use secret bulk w/ pipe input", async () => { + jest.spyOn(readline, "createInterface").mockImplementation( + () => + // `readline.Interface` is an async iterator: `[Symbol.asyncIterator](): AsyncIterableIterator` + JSON.stringify({ + secret1: "secret-value", + password: "hunter2", + }) as unknown as Interface + ); + + mockProjectRequests([ + { + name: "secret1", + text: "secret-value", + }, + { + name: "password", + text: "hunter2", + }, + ]); + + await runWrangler(`pages secret bulk --project some-project-name`); + expect(std.out).toMatchInlineSnapshot(` + "🌀 Creating the secrets for the Pages project \\"some-project-name\\" (production) + Finished processing secrets JSON file: + ✨ 2 secrets successfully uploaded" + `); + expect(std.err).toMatchInlineSnapshot(`""`); + }); + + it("should create secret bulk", async () => { + writeFileSync( + "secret.json", + JSON.stringify({ + "secret-name-1": "secret_text", + "secret-name-2": "secret_text", + }) + ); + + mockProjectRequests([ + { + name: "secret-name-1", + text: "secret_text", + }, + { + name: "secret-name-2", + text: "secret_text", + }, + ]); + + await runWrangler( + "pages secret bulk ./secret.json --project some-project-name" + ); + + expect(std.out).toMatchInlineSnapshot(` + "🌀 Creating the secrets for the Pages project \\"some-project-name\\" (production) + Finished processing secrets JSON file: + ✨ 2 secrets successfully uploaded" + `); + expect(std.err).toMatchInlineSnapshot(`""`); + }); + + it("should create secret bulk: preview", async () => { + writeFileSync( + "secret.json", + JSON.stringify({ + "secret-name-1": "secret_text", + "secret-name-2": "secret_text", + }) + ); + + mockProjectRequests( + [ + { + name: "secret-name-1", + text: "secret_text", + }, + { + name: "secret-name-2", + text: "secret_text", + }, + ], + "preview" + ); + + await runWrangler( + "pages secret bulk ./secret.json --project some-project-name --env preview" + ); + + expect(std.out).toMatchInlineSnapshot(` + "🌀 Creating the secrets for the Pages project \\"some-project-name\\" (preview) + Finished processing secrets JSON file: + ✨ 2 secrets successfully uploaded" + `); + expect(std.err).toMatchInlineSnapshot(`""`); + }); + + it("should count success and network failure on secret bulk", async () => { + writeFileSync( + "secret.json", + JSON.stringify({ + "secret-name-1": "secret_text", + "secret-name-2": "secret_text", + "secret-name-3": "secret_text", + "secret-name-4": "secret_text", + "secret-name-5": "secret_text", + "secret-name-6": "secret_text", + "secret-name-7": "secret_text", + }) + ); + + msw.use( + rest.get( + "*/accounts/:accountId/pages/projects/:project", + async (req, res, ctx) => { + return res( + ctx.status(200), + ctx.json({ + success: true, + errors: [], + messages: [], + result: { + name: "some-project-name", + deployment_configs: { + production: { wrangler_config_hash: "wch" }, + preview: {}, + }, + }, + }) + ); + } + ) + ); + msw.use( + rest.patch( + "*/accounts/:accountId/pages/projects/:project", + async (req, res, ctx) => { + return res.networkError(`Failed to create secret`); + } + ) + ); + + await expect( + runWrangler( + "pages secret bulk ./secret.json --project some-project-name" + ) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"🚨 7 secrets failed to upload"` + ); + + expect(std.out).toMatchInlineSnapshot(` + "🌀 Creating the secrets for the Pages project \\"some-project-name\\" (production) + Finished processing secrets JSON file: + ✨ 0 secrets successfully uploaded + " + `); + expect(std.err).toMatchInlineSnapshot(` + "X [ERROR] 🚨 7 secrets failed to upload + + " + `); + }); + }); +}); diff --git a/packages/wrangler/src/index.ts b/packages/wrangler/src/index.ts index dbe677e99cb6..985300623ea0 100644 --- a/packages/wrangler/src/index.ts +++ b/packages/wrangler/src/index.ts @@ -430,7 +430,7 @@ export function createCLIParser(argv: string[]) { // pages wrangler.command("pages", "⚡️ Configure Cloudflare Pages", (pagesYargs) => { - return pages(pagesYargs.command(subHelp)); + return pages(pagesYargs, subHelp); }); // queues diff --git a/packages/wrangler/src/metrics/send-event.ts b/packages/wrangler/src/metrics/send-event.ts index 4e44349ed6d9..cd894b7a3bcb 100644 --- a/packages/wrangler/src/metrics/send-event.ts +++ b/packages/wrangler/src/metrics/send-event.ts @@ -15,6 +15,9 @@ export type EventNames = | "create encrypted variable" | "delete encrypted variable" | "list encrypted variables" + | "create pages encrypted variable" + | "delete pages encrypted variable" + | "list pages encrypted variables" | "create kv namespace" | "list kv namespaces" | "delete kv namespace" diff --git a/packages/wrangler/src/pages/download-config.ts b/packages/wrangler/src/pages/download-config.ts index 40f93b22eb15..bf1aa042d969 100644 --- a/packages/wrangler/src/pages/download-config.ts +++ b/packages/wrangler/src/pages/download-config.ts @@ -51,9 +51,10 @@ interface PagesDeploymentConfig extends DeploymentConfig { } >; ai_bindings: Record>; + wrangler_config_hash?: string; } -interface PagesProject extends Project { +export interface PagesProject extends Project { deployment_configs: { production: PagesDeploymentConfig; preview: PagesDeploymentConfig; diff --git a/packages/wrangler/src/pages/index.ts b/packages/wrangler/src/pages/index.ts index af8ad5fc2e9a..b7ffe41232ee 100644 --- a/packages/wrangler/src/pages/index.ts +++ b/packages/wrangler/src/pages/index.ts @@ -9,10 +9,11 @@ import * as Dev from "./dev"; import * as DownloadConfig from "./download-config"; import * as Functions from "./functions"; import * as Projects from "./projects"; +import { secret } from "./secret"; import * as Upload from "./upload"; import { CLEANUP } from "./utils"; import * as Validate from "./validate"; -import type { CommonYargsArgv } from "../yargs-types"; +import type { CommonYargsArgv, SubHelp } from "../yargs-types"; process.on("SIGINT", () => { CLEANUP(); @@ -23,9 +24,10 @@ process.on("SIGTERM", () => { process.exit(); }); -export function pages(yargs: CommonYargsArgv) { +export function pages(yargs: CommonYargsArgv, subHelp: SubHelp) { return ( yargs + .command(subHelp) .command( "dev [directory] [-- command..]", "🧑‍💻 Develop your full-stack Pages application locally", @@ -38,6 +40,7 @@ export function pages(yargs: CommonYargsArgv) { */ .command("functions", false, (args) => args + .command(subHelp) .command( "build [directory]", "Compile a folder of Cloudflare Pages Functions into a single Worker", @@ -59,6 +62,7 @@ export function pages(yargs: CommonYargsArgv) { ) .command("project", "⚡️ Interact with your Pages projects", (args) => args + .command(subHelp) .command( "list", "List your Cloudflare Pages projects", @@ -90,6 +94,7 @@ export function pages(yargs: CommonYargsArgv) { "🚀 Interact with the deployments of a project", (args) => args + .command(subHelp) .command( "list", "List deployments in your Cloudflare Pages project", @@ -116,6 +121,11 @@ export function pages(yargs: CommonYargsArgv) { Deploy.Options, Deploy.Handler ) + .command( + "secret", + "🤫 Generate a secret that can be referenced in a Pages project", + (secretYargs) => secret(secretYargs, subHelp) + ) .command("download", "⚡️ Download settings from your project", (args) => args.command( "config [projectName]", diff --git a/packages/wrangler/src/pages/secret/index.ts b/packages/wrangler/src/pages/secret/index.ts new file mode 100644 index 000000000000..6a4ab95da753 --- /dev/null +++ b/packages/wrangler/src/pages/secret/index.ts @@ -0,0 +1,405 @@ +import path from "node:path"; +import readline from "node:readline"; +import chalk from "chalk"; +import { fetchResult } from "../../cfetch"; +import { findWranglerToml, readConfig } from "../../config"; +import { getConfigCache } from "../../config-cache"; +import { confirm, prompt } from "../../dialogs"; +import { FatalError } from "../../errors"; +import { printWranglerBanner } from "../../index"; +import isInteractive from "../../is-interactive"; +import { logger } from "../../logger"; +import * as metrics from "../../metrics"; +import { parseJSON, readFileSync } from "../../parse"; +import { requireAuth } from "../../user"; +import { PAGES_CONFIG_CACHE_FILENAME } from "../constants"; +import { PagesProject } from "../download-config"; +import { EXIT_CODE_INVALID_PAGES_CONFIG } from "../errors"; +import { PagesConfigCache } from "../types"; +import type { Config } from "../../config"; +import type { CommonYargsArgv, SubHelp } from "../../yargs-types"; + +function isPagesEnv(env: string): env is "production" | "preview" { + return ["production", "preview"].includes(env); +} + +/** + * Remove trailing white space from inputs. + * Matching Wrangler legacy behavior with handling inputs + */ +function trimTrailingWhitespace(str: string) { + return str.trimEnd(); +} + +/** + * Get a promise to the streamed input from stdin. + * + * This function can be used to grab the incoming stream of data from, say, + * piping the output of another process into the wrangler process. + */ +function readFromStdin(): Promise { + return new Promise((resolve, reject) => { + const stdin = process.stdin; + const chunks: string[] = []; + + // When there is data ready to be read, the `readable` event will be triggered. + // In the handler for `readable` we call `read()` over and over until all the available data has been read. + stdin.on("readable", () => { + let chunk; + while (null !== (chunk = stdin.read())) { + chunks.push(chunk); + } + }); + + // When the streamed data is complete the `end` event will be triggered. + // In the handler for `end` we join the chunks together and resolve the promise. + stdin.on("end", () => { + resolve(chunks.join("")); + }); + + // If there is an `error` event then the handler will reject the promise. + stdin.on("error", (err) => { + reject(err); + }); + }); +} + +async function pagesProject( + env: string | undefined, + cliProjectName: string | undefined +): Promise<{ + env: "production" | "preview"; + project: PagesProject; + accountId: string; + config: Config | undefined; +}> { + env ??= "production"; + if (!isPagesEnv(env)) { + throw new FatalError( + `Pages does not support the "${env}" named environment. Please specify "production" (default) or "preview"`, + 1 + ); + } + let config: Config | undefined; + const configPath = findWranglerToml(process.cwd(), false); + + try { + /* + * this reads the config file with `env` set to `undefined`, which will + * return the top-level config. This contains all the information we + * need. + */ + config = readConfig( + configPath, + { env: undefined, experimentalJsonConfig: false }, + true + ); + } catch (err) { + if ( + !( + err instanceof FatalError && err.code === EXIT_CODE_INVALID_PAGES_CONFIG + ) + ) { + throw err; + } + } + + /* + * If we found a `wrangler.toml` config file that doesn't specify + * `pages_build_output_dir`, we'll ignore the file, but inform users + * that we did find one, just not valid for Pages. + */ + if (configPath && config === undefined) { + logger.warn( + `Pages now has wrangler.toml support.\n` + + `We detected a configuration file at ${configPath} but it is missing the "pages_build_output_dir" field, required by Pages.\n` + + `If you would like to use this configuration file for your project, please use "pages_build_output_dir" to specify the directory of static files to upload.\n` + + `Ignoring configuration file for now.` + ); + } + + const configCache = getConfigCache( + PAGES_CONFIG_CACHE_FILENAME + ); + const accountId = await requireAuth(configCache); + + const projectName = + cliProjectName ?? config?.name ?? configCache.project_name; + + let project: PagesProject; + + if (projectName) { + try { + project = await fetchResult( + `/accounts/${accountId}/pages/projects/${projectName}` + ); + } catch (err) { + // code `8000007` corresponds to project not found + if ((err as { code: number }).code !== 8000007) { + throw err; + } else { + throw new FatalError(`Project "${projectName}" does not exist.`, 1); + } + } + } else { + throw new FatalError("Must specify a project name.", 1); + } + return { env, project, accountId, config }; +} + +export const secret = (secretYargs: CommonYargsArgv, subHelp: SubHelp) => { + return secretYargs + .command(subHelp) + .command( + "put ", + "Create or update a secret variable for a Worker", + (yargs) => { + return yargs + .positional("key", { + describe: "The variable name to be accessible in the Worker", + type: "string", + }) + .option("project-name", { + type: "string", + alias: ["project"], + description: "The name of your Pages project", + }); + }, + async (args) => { + await printWranglerBanner(); + const { env, project, accountId, config } = await pagesProject( + args.env, + args.projectName + ); + + const secretValue = trimTrailingWhitespace( + isInteractive() + ? await prompt("Enter a secret value:", { isSecret: true }) + : await readFromStdin() + ); + + logger.log( + `🌀 Creating the secret for the Pages project "${project.name}" (${env})` + ); + + await fetchResult( + `/accounts/${accountId}/pages/projects/${project.name}`, + { + method: "PATCH", + body: JSON.stringify({ + deployment_configs: { + [env]: { + env_vars: { + [args.key as string]: { + value: secretValue, + type: "secret_text", + }, + }, + wrangler_config_hash: + project.deployment_configs[env].wrangler_config_hash, + }, + }, + }), + } + ); + + await metrics.sendMetricsEvent("create pages encrypted variable", { + sendMetrics: config?.send_metrics, + }); + + logger.log(`✨ Success! Uploaded secret ${args.key}`); + } + ) + .command( + "bulk [json]", + "🗄️ Bulk upload secrets for a Pages project", + (yargs) => { + return yargs + .positional("json", { + describe: `The JSON file of key-value pairs to upload, in form {"key": value, ...}`, + type: "string", + }) + .option("project-name", { + type: "string", + alias: ["project"], + description: "The name of your Pages project", + }); + }, + async (args) => { + await printWranglerBanner(); + const { env, project, accountId } = await pagesProject( + args.env, + args.projectName + ); + + logger.log( + `🌀 Creating the secrets for the Pages project "${project.name}" (${env})` + ); + + let content: Record; + if (args.json) { + const jsonFilePath = path.resolve(args.json); + content = parseJSON>( + readFileSync(jsonFilePath), + jsonFilePath + ); + } else { + try { + const rl = readline.createInterface({ input: process.stdin }); + let pipedInput = ""; + for await (const line of rl) { + pipedInput += line; + } + content = parseJSON>(pipedInput); + } catch { + throw new FatalError( + `🚨 Please provide a JSON file or valid JSON pipe` + ); + } + } + + if (!content) { + throw new FatalError( + `🚨 No content found in JSON file or piped input.` + ); + } + + const upsertBindings = Object.fromEntries( + Object.entries(content).map(([key, value]) => { + return [ + key, + { + type: "secret_text", + value: value, + }, + ]; + }) + ); + try { + await fetchResult( + `/accounts/${accountId}/pages/projects/${project.name}`, + { + method: "PATCH", + body: JSON.stringify({ + deployment_configs: { + [env]: { + env_vars: { + ...upsertBindings, + }, + wrangler_config_hash: + project.deployment_configs[env].wrangler_config_hash, + }, + }, + }), + } + ); + logger.log("Finished processing secrets JSON file:"); + logger.log( + `✨ ${ + Object.keys(upsertBindings).length + } secrets successfully uploaded` + ); + } catch (err) { + logger.log("Finished processing secrets JSON file:"); + logger.log(`✨ 0 secrets successfully uploaded`); + throw new FatalError( + `🚨 ${Object.keys(upsertBindings).length} secrets failed to upload` + ); + } + } + ) + .command( + "delete ", + "Delete a secret variable from a Worker", + async (yargs) => { + return yargs + .positional("key", { + describe: "The variable name to be accessible in the Worker", + type: "string", + }) + .option("project-name", { + type: "string", + alias: ["project"], + description: "The name of your Pages project", + }); + }, + async (args) => { + await printWranglerBanner(); + const { env, project, accountId, config } = await pagesProject( + args.env, + args.projectName + ); + + if ( + await confirm( + `Are you sure you want to permanently delete the secret ${args.key} on the Pages project ${project.name} (${env})?` + ) + ) { + logger.log( + `🌀 Deleting the secret ${args.key} on the Pages project ${project.name} (${env})` + ); + + await fetchResult( + `/accounts/${accountId}/pages/projects/${project.name}`, + { + method: "PATCH", + body: JSON.stringify({ + deployment_configs: { + [env]: { + env_vars: { + [args.key as string]: null, + }, + wrangler_config_hash: + project.deployment_configs[env].wrangler_config_hash, + }, + }, + }), + } + ); + await metrics.sendMetricsEvent("delete pages encrypted variable", { + sendMetrics: config?.send_metrics, + }); + logger.log(`✨ Success! Deleted secret ${args.key}`); + } + } + ) + .command( + "list", + "List all secrets for a Worker", + (yargs) => { + return yargs.option("project-name", { + type: "string", + alias: ["project"], + description: "The name of your Pages project", + }); + }, + async (args) => { + await printWranglerBanner(); + const { env, project, accountId, config } = await pagesProject( + args.env, + args.projectName + ); + + const secrets = Object.entries( + project.deployment_configs[env].env_vars ?? {} + ).filter(([_, val]) => val?.type === "secret_text"); + + const message = [ + `The "${chalk.blue( + env + )}" environment of your Pages project "${chalk.blue( + project.name + )}" has access to the following secrets:`, + ...secrets.map( + ([name]) => ` - ${name}: ${chalk.italic("Value Encrypted")}` + ), + ].join("\n"); + + logger.log(message); + + await metrics.sendMetricsEvent("list pages encrypted variables", { + sendMetrics: config?.send_metrics, + }); + } + ); +}; From bd93e2447f382c5ace193f881bbc415aa238e4cd Mon Sep 17 00:00:00 2001 From: Samuel Macleod Date: Tue, 9 Apr 2024 14:11:16 +0100 Subject: [PATCH 02/11] fix snapshots --- packages/wrangler/src/__tests__/pages/pages.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/wrangler/src/__tests__/pages/pages.test.ts b/packages/wrangler/src/__tests__/pages/pages.test.ts index 9225e53415cf..9225f1ece43a 100644 --- a/packages/wrangler/src/__tests__/pages/pages.test.ts +++ b/packages/wrangler/src/__tests__/pages/pages.test.ts @@ -27,6 +27,7 @@ describe("pages", () => { wrangler pages project ⚡️ Interact with your Pages projects wrangler pages deployment 🚀 Interact with the deployments of a project wrangler pages deploy [directory] 🆙 Deploy a directory of static assets as a Pages deployment [aliases: publish] + wrangler pages secret 🤫 Generate a secret that can be referenced in a Pages project wrangler pages download ⚡️ Download settings from your project Flags: From fe3bc6b726ee30fe71dd88e68f1165a9371c9344 Mon Sep 17 00:00:00 2001 From: Samuel Macleod Date: Tue, 9 Apr 2024 16:05:37 +0100 Subject: [PATCH 03/11] fix lint --- .../wrangler/src/__tests__/pages/secret.test.ts | 14 +++----------- packages/wrangler/src/pages/secret/index.ts | 6 +++--- 2 files changed, 6 insertions(+), 14 deletions(-) diff --git a/packages/wrangler/src/__tests__/pages/secret.test.ts b/packages/wrangler/src/__tests__/pages/secret.test.ts index 1906c2af204f..806ea3d82b61 100644 --- a/packages/wrangler/src/__tests__/pages/secret.test.ts +++ b/packages/wrangler/src/__tests__/pages/secret.test.ts @@ -1,13 +1,6 @@ -import { Blob } from "node:buffer"; -import * as fs from "node:fs"; -import { mkdirSync, rmdirSync, writeFileSync } from "node:fs"; +import { writeFileSync } from "node:fs"; import readline from "node:readline"; -import * as TOML from "@iarna/toml"; -import { MockedRequest, rest } from "msw"; -import { FormData } from "undici"; -import { saveToConfigCache } from "../../config-cache"; -import { PAGES_CONFIG_CACHE_FILENAME } from "../../pages/constants"; -import { PagesProject } from "../../pages/download-config"; +import { rest } from "msw"; import { mockAccountId, mockApiToken } from "../helpers/mock-account-id"; import { mockConsoleMethods } from "../helpers/mock-console"; import { clearDialogs, mockConfirm, mockPrompt } from "../helpers/mock-dialogs"; @@ -15,10 +8,9 @@ import { useMockIsTTY } from "../helpers/mock-istty"; import { mockGetMembershipsFail } from "../helpers/mock-oauth-flow"; import { useMockStdin } from "../helpers/mock-stdin"; import { msw } from "../helpers/msw"; -import { FileReaderSync } from "../helpers/msw/read-file-sync"; import { runInTempDir } from "../helpers/run-in-tmp"; import { runWrangler } from "../helpers/run-wrangler"; -import type { RestRequest } from "msw"; +import type { PagesProject } from "../../pages/download-config"; import type { Interface } from "node:readline"; function createFetchResult(result: unknown, success = true) { diff --git a/packages/wrangler/src/pages/secret/index.ts b/packages/wrangler/src/pages/secret/index.ts index 6a4ab95da753..36d98485e99e 100644 --- a/packages/wrangler/src/pages/secret/index.ts +++ b/packages/wrangler/src/pages/secret/index.ts @@ -13,11 +13,11 @@ import * as metrics from "../../metrics"; import { parseJSON, readFileSync } from "../../parse"; import { requireAuth } from "../../user"; import { PAGES_CONFIG_CACHE_FILENAME } from "../constants"; -import { PagesProject } from "../download-config"; import { EXIT_CODE_INVALID_PAGES_CONFIG } from "../errors"; -import { PagesConfigCache } from "../types"; import type { Config } from "../../config"; import type { CommonYargsArgv, SubHelp } from "../../yargs-types"; +import type { PagesProject } from "../download-config"; +import type { PagesConfigCache } from "../types"; function isPagesEnv(env: string): env is "production" | "preview" { return ["production", "preview"].includes(env); @@ -375,7 +375,7 @@ export const secret = (secretYargs: CommonYargsArgv, subHelp: SubHelp) => { }, async (args) => { await printWranglerBanner(); - const { env, project, accountId, config } = await pagesProject( + const { env, project, config } = await pagesProject( args.env, args.projectName ); From a6ac32c905afcc7d5d2c697a568731d2c40cbe59 Mon Sep 17 00:00:00 2001 From: Samuel Macleod Date: Tue, 9 Apr 2024 17:37:58 +0100 Subject: [PATCH 04/11] fix lint --- packages/wrangler/src/__tests__/pages/secret.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/wrangler/src/__tests__/pages/secret.test.ts b/packages/wrangler/src/__tests__/pages/secret.test.ts index 806ea3d82b61..79d20b9ada41 100644 --- a/packages/wrangler/src/__tests__/pages/secret.test.ts +++ b/packages/wrangler/src/__tests__/pages/secret.test.ts @@ -149,7 +149,7 @@ describe("wrangler pages secret", () => { it("should error with invalid env", async () => { mockProjectRequests( { name: "the-key", text: "the-secret" }, - // @ts-expect-error + // @ts-expect-error This is intentionally invalid "some-env" ); await expect( @@ -380,7 +380,7 @@ describe("wrangler pages secret", () => { beforeEach(() => { setIsTTY(true); }); - function mockListRequest(env: "production" | "preview" = "production") { + function mockListRequest() { msw.use( rest.get( "*/accounts/:accountId/pages/projects/:project", @@ -655,7 +655,7 @@ describe("wrangler pages secret", () => { msw.use( rest.patch( "*/accounts/:accountId/pages/projects/:project", - async (req, res, ctx) => { + async (_, res) => { return res.networkError(`Failed to create secret`); } ) From 24732be29119cc5894c2854d09f6e394a1aa0dc9 Mon Sep 17 00:00:00 2001 From: Samuel Macleod Date: Tue, 9 Apr 2024 17:42:15 +0100 Subject: [PATCH 05/11] address comments --- packages/wrangler/src/pages/secret/index.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/wrangler/src/pages/secret/index.ts b/packages/wrangler/src/pages/secret/index.ts index 36d98485e99e..91306f417a4d 100644 --- a/packages/wrangler/src/pages/secret/index.ts +++ b/packages/wrangler/src/pages/secret/index.ts @@ -135,11 +135,10 @@ async function pagesProject( ); } catch (err) { // code `8000007` corresponds to project not found - if ((err as { code: number }).code !== 8000007) { - throw err; - } else { + if ((err as { code: number }).code === 8000007) { throw new FatalError(`Project "${projectName}" does not exist.`, 1); } + throw err; } } else { throw new FatalError("Must specify a project name.", 1); From 91168fdb9650cfc615dfa34bcd6ae767b4d06776 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Somhairle=20MacLe=C3=B2id?= Date: Tue, 9 Apr 2024 18:10:40 +0100 Subject: [PATCH 06/11] Create chilly-pillows-melt.md --- .changeset/chilly-pillows-melt.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/chilly-pillows-melt.md diff --git a/.changeset/chilly-pillows-melt.md b/.changeset/chilly-pillows-melt.md new file mode 100644 index 000000000000..03ee8557f9bb --- /dev/null +++ b/.changeset/chilly-pillows-melt.md @@ -0,0 +1,5 @@ +--- +"wrangler": minor +--- + +feat: Support `wrangler pages secret put|delete|list|bulk` From e45ff065e4aab211770aa217785a808df630c351 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Somhairle=20MacLe=C3=B2id?= Date: Tue, 9 Apr 2024 20:26:33 +0100 Subject: [PATCH 07/11] Update index.ts Co-authored-by: Adam Murray --- packages/wrangler/src/pages/secret/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/wrangler/src/pages/secret/index.ts b/packages/wrangler/src/pages/secret/index.ts index 91306f417a4d..7737439fbe95 100644 --- a/packages/wrangler/src/pages/secret/index.ts +++ b/packages/wrangler/src/pages/secret/index.ts @@ -151,7 +151,7 @@ export const secret = (secretYargs: CommonYargsArgv, subHelp: SubHelp) => { .command(subHelp) .command( "put ", - "Create or update a secret variable for a Worker", + "Create or update a secret variable for a Pages project", (yargs) => { return yargs .positional("key", { From 76c0c0687af760c26b60043874ed6417f04823e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Somhairle=20MacLe=C3=B2id?= Date: Tue, 9 Apr 2024 20:26:43 +0100 Subject: [PATCH 08/11] Update index.ts Co-authored-by: Adam Murray --- packages/wrangler/src/pages/secret/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/wrangler/src/pages/secret/index.ts b/packages/wrangler/src/pages/secret/index.ts index 7737439fbe95..f2e02d39db34 100644 --- a/packages/wrangler/src/pages/secret/index.ts +++ b/packages/wrangler/src/pages/secret/index.ts @@ -155,7 +155,7 @@ export const secret = (secretYargs: CommonYargsArgv, subHelp: SubHelp) => { (yargs) => { return yargs .positional("key", { - describe: "The variable name to be accessible in the Worker", + describe: "The variable name to be accessible in the Pages project", type: "string", }) .option("project-name", { From 06029fba5ee70d72e7dfe5792550cf82bc336c90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Somhairle=20MacLe=C3=B2id?= Date: Tue, 9 Apr 2024 20:26:50 +0100 Subject: [PATCH 09/11] Update index.ts Co-authored-by: Adam Murray --- packages/wrangler/src/pages/secret/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/wrangler/src/pages/secret/index.ts b/packages/wrangler/src/pages/secret/index.ts index f2e02d39db34..66a32ff1e00c 100644 --- a/packages/wrangler/src/pages/secret/index.ts +++ b/packages/wrangler/src/pages/secret/index.ts @@ -309,7 +309,7 @@ export const secret = (secretYargs: CommonYargsArgv, subHelp: SubHelp) => { ) .command( "delete ", - "Delete a secret variable from a Worker", + "Delete a secret variable from a Pages project", async (yargs) => { return yargs .positional("key", { From eba2a8a718453f22de53f884244c3b2a982d5b63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Somhairle=20MacLe=C3=B2id?= Date: Tue, 9 Apr 2024 20:26:57 +0100 Subject: [PATCH 10/11] Update index.ts Co-authored-by: Adam Murray --- packages/wrangler/src/pages/secret/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/wrangler/src/pages/secret/index.ts b/packages/wrangler/src/pages/secret/index.ts index 66a32ff1e00c..b4abd44c2cf5 100644 --- a/packages/wrangler/src/pages/secret/index.ts +++ b/packages/wrangler/src/pages/secret/index.ts @@ -313,7 +313,7 @@ export const secret = (secretYargs: CommonYargsArgv, subHelp: SubHelp) => { async (yargs) => { return yargs .positional("key", { - describe: "The variable name to be accessible in the Worker", + describe: "The variable name to be accessible in the Pages project", type: "string", }) .option("project-name", { From aa583ce7bcc4ff05e03a1918670c8175da19596f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Somhairle=20MacLe=C3=B2id?= Date: Tue, 9 Apr 2024 20:27:04 +0100 Subject: [PATCH 11/11] Update index.ts Co-authored-by: Adam Murray --- packages/wrangler/src/pages/secret/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/wrangler/src/pages/secret/index.ts b/packages/wrangler/src/pages/secret/index.ts index b4abd44c2cf5..151b9f10ccb2 100644 --- a/packages/wrangler/src/pages/secret/index.ts +++ b/packages/wrangler/src/pages/secret/index.ts @@ -364,7 +364,7 @@ export const secret = (secretYargs: CommonYargsArgv, subHelp: SubHelp) => { ) .command( "list", - "List all secrets for a Worker", + "List all secrets for a Pages project", (yargs) => { return yargs.option("project-name", { type: "string",