diff --git a/.changeset/famous-pets-complain.md b/.changeset/famous-pets-complain.md new file mode 100644 index 000000000000..e9e4850f9016 --- /dev/null +++ b/.changeset/famous-pets-complain.md @@ -0,0 +1,16 @@ +--- +"wrangler": patch +--- + +feat: support adding secrets in non-interactive mode + +Now the user can pipe in the secret value to the `wrangler secret put` command. +For example: + +``` +cat my-secret.txt | wrangler secret put secret-key --name worker-name +``` + +This requires that the user is logged in, and has only one account, or that the `account_id` has been set in `wrangler.toml`. + +Fixes #170 diff --git a/packages/wrangler/src/__tests__/helpers/mock-account-id.ts b/packages/wrangler/src/__tests__/helpers/mock-account-id.ts index 5ecad0a483d5..2c32d069ba78 100644 --- a/packages/wrangler/src/__tests__/helpers/mock-account-id.ts +++ b/packages/wrangler/src/__tests__/helpers/mock-account-id.ts @@ -3,12 +3,19 @@ const ORIGINAL_CLOUDFLARE_ACCOUNT_ID = process.env.CLOUDFLARE_ACCOUNT_ID; /** * Mock the API token so that we don't need to read it from user configuration files. + * + * Note that you can remove any API token from the environment by setting the value to `null`. + * This is useful if a higher `describe()` block has already called `mockApiToken()`. */ export function mockApiToken({ apiToken = "some-api-token", -}: { apiToken?: string } = {}) { +}: { apiToken?: string | null } = {}) { beforeEach(() => { - process.env.CLOUDFLARE_API_TOKEN = apiToken; + if (apiToken === null) { + delete process.env.CLOUDFLARE_API_TOKEN; + } else { + process.env.CLOUDFLARE_API_TOKEN = apiToken; + } }); afterEach(() => { process.env.CLOUDFLARE_API_TOKEN = ORIGINAL_CLOUDFLARE_API_TOKEN; @@ -17,12 +24,19 @@ export function mockApiToken({ /** * Mock the current account ID so that we don't need to read it from configuration files. + * + * Note that you can remove any account ID from the environment by setting the value to `null`. + * This is useful if a higher `describe()` block has already called `mockAccountId()`. */ export function mockAccountId({ accountId = "some-account-id", -}: { accountId?: string } = {}) { +}: { accountId?: string | null } = {}) { beforeEach(() => { - process.env.CLOUDFLARE_ACCOUNT_ID = accountId; + if (accountId === null) { + delete process.env.CLOUDFLARE_ACCOUNT_ID; + } else { + process.env.CLOUDFLARE_ACCOUNT_ID = accountId; + } }); afterEach(() => { process.env.CLOUDFLARE_ACCOUNT_ID = ORIGINAL_CLOUDFLARE_ACCOUNT_ID; diff --git a/packages/wrangler/src/__tests__/helpers/mock-stdin.ts b/packages/wrangler/src/__tests__/helpers/mock-stdin.ts new file mode 100644 index 000000000000..cc739cf9b2f2 --- /dev/null +++ b/packages/wrangler/src/__tests__/helpers/mock-stdin.ts @@ -0,0 +1,103 @@ +const ORIGINAL_STDIN = process.stdin; + +/** + * Mock process.stdin so that we can pipe in text for non-interactive mode tests. + */ +export function useMockStdin({ isTTY }: { isTTY: boolean }) { + const mockStdin = new MockStdIn(isTTY); + + beforeEach(() => { + mockStdin.reset(); + Object.defineProperty(process, "stdin", { + value: mockStdin, + configurable: true, + writable: false, + }); + }); + + afterEach(() => { + Object.defineProperty(process, "stdin", { + value: ORIGINAL_STDIN, + configurable: true, + writable: false, + }); + }); + + return mockStdin; +} + +const failCallback: (value: unknown) => void = () => { + throw new Error( + "TEST FAILURE: stdin callback called before being initialized." + ); +}; + +/** + * A mock version of `process.std`, which can be used to simulate piping data + * into the wrangler process in non-interactive mode. + */ +class MockStdIn { + private endCallback = failCallback; + private errorCallback = failCallback; + private chunks: string[] = []; + private error: Error | undefined; + + /** + * Set this to true if you want the stdin stream to error. + */ + throwError(error: Error) { + this.error = error; + } + + /** + * Call this to clean out the chunks that are queued for sending. + */ + reset() { + this.chunks.length = 0; + this.error = undefined; + } + + /** + * Queue up some chunks to be sent. + */ + send(...chunks: string[]) { + this.chunks.push(...chunks); + } + + constructor( + /** + * Used by wrangler to check whether stdin is interactive. + */ + readonly isTTY: boolean + ) {} + + /** + * Used by wrangler to add event listeners. + */ + on(eventName: string, callback: () => void) { + switch (eventName) { + case "readable": + setImmediate(callback); + break; + case "end": + this.endCallback = callback; + break; + case "error": + this.errorCallback = callback; + break; + } + } + + /** + * Used by wrangler to get the next chunk of data in the stream. + */ + read() { + if (this.error) { + setImmediate(() => this.errorCallback(this.error)); + } + if (this.chunks.length === 0) { + setImmediate(this.endCallback); + } + return this.chunks.shift() ?? null; + } +} diff --git a/packages/wrangler/src/__tests__/secret.test.ts b/packages/wrangler/src/__tests__/secret.test.ts index d9dd1ce2b84e..27f286a8770e 100644 --- a/packages/wrangler/src/__tests__/secret.test.ts +++ b/packages/wrangler/src/__tests__/secret.test.ts @@ -1,7 +1,11 @@ +import * as fs from "node:fs"; +import * as TOML from "@iarna/toml"; +import fetchMock from "jest-fetch-mock"; import { mockAccountId, mockApiToken } from "./helpers/mock-account-id"; import { setMockResponse, unsetAllMocks } from "./helpers/mock-cfetch"; import { mockConsoleMethods } from "./helpers/mock-console"; import { mockConfirm, mockPrompt } from "./helpers/mock-dialogs"; +import { useMockStdin } from "./helpers/mock-stdin"; import { runInTempDir } from "./helpers/run-in-tmp"; import { runWrangler } from "./helpers/run-wrangler"; @@ -44,83 +48,181 @@ describe("wrangler secret", () => { ); } - it("should create a secret", async () => { - mockPrompt({ - text: "Enter a secret value:", - type: "password", - result: "the-secret", + describe("interactive", () => { + useMockStdin({ isTTY: true }); + + it("should create a secret", async () => { + mockPrompt({ + text: "Enter a secret value:", + type: "password", + result: "the-secret", + }); + + mockPutRequest({ name: "the-key", text: "the-secret" }); + await runWrangler("secret put the-key --name script-name"); + + expect(std.out).toMatchInlineSnapshot(` + "🌀 Creating the secret for script script-name + ✨ Success! Uploaded secret the-key" + `); + expect(std.err).toMatchInlineSnapshot(`""`); }); - mockPutRequest({ name: "the-secret-name", text: "the-secret" }); - await runWrangler("secret put the-key --name script-name"); + it("should create a secret: legacy envs", async () => { + mockPrompt({ + text: "Enter a secret value:", + type: "password", + result: "the-secret", + }); - expect(std.out).toMatchInlineSnapshot(` - "🌀 Creating the secret for script script-name - ✨ Success! Uploaded secret the-key" - `); - expect(std.err).toMatchInlineSnapshot(`""`); - }); + mockPutRequest( + { name: "the-key", text: "the-secret" }, + "some-env", + true + ); + await runWrangler( + "secret put the-key --name script-name --env some-env --legacy-env" + ); - it("should create a secret: legacy envs", async () => { - mockPrompt({ - text: "Enter a secret value:", - type: "password", - result: "the-secret", + expect(std.out).toMatchInlineSnapshot(` + "🌀 Creating the secret for script script-name-some-env + ✨ Success! Uploaded secret the-key" + `); + expect(std.err).toMatchInlineSnapshot(`""`); }); - mockPutRequest( - { name: "the-secret-name", text: "the-secret" }, - "some-env", - true - ); - await runWrangler( - "secret put the-key --name script-name --env some-env --legacy-env" - ); + it("should create a secret: service envs", async () => { + mockPrompt({ + text: "Enter a secret value:", + type: "password", + result: "the-secret", + }); - expect(std.out).toMatchInlineSnapshot(` - "🌀 Creating the secret for script script-name-some-env - ✨ Success! Uploaded secret the-key" - `); - expect(std.err).toMatchInlineSnapshot(`""`); - }); + mockPutRequest( + { name: "the-key", text: "the-secret" }, + "some-env", + false + ); + await runWrangler( + "secret put the-key --name script-name --env some-env --legacy-env false" + ); - it("should create a secret: service envs", async () => { - mockPrompt({ - text: "Enter a secret value:", - type: "password", - result: "the-secret", + expect(std.out).toMatchInlineSnapshot(` + "🌀 Creating the secret for script script-name (some-env) + ✨ Success! Uploaded secret the-key" + `); + expect(std.err).toMatchInlineSnapshot(`""`); }); - mockPutRequest( - { name: "the-secret-name", text: "the-secret" }, - "some-env", - false - ); - await runWrangler( - "secret put the-key --name script-name --env some-env --legacy-env false" - ); + it("should error without a script name", async () => { + let error: Error | undefined; + try { + await runWrangler("secret put the-key"); + } catch (e) { + error = e as Error; + } + expect(std.out).toMatchInlineSnapshot(`""`); + expect(std.err).toMatchInlineSnapshot(` + "Missing script name - expect(std.out).toMatchInlineSnapshot(` - "🌀 Creating the secret for script script-name (some-env) - ✨ Success! Uploaded secret the-key" - `); - expect(std.err).toMatchInlineSnapshot(`""`); + %s If you think this is a bug then please create an issue at https://github.com/cloudflare/wrangler2/issues/new." + `); + expect(error).toMatchInlineSnapshot(`[Error: Missing script name]`); + }); }); - it("should error without a script name", async () => { - let error: Error | undefined; - try { - await runWrangler("secret put the-key"); - } catch (e) { - error = e as Error; - } - expect(std.out).toMatchInlineSnapshot(`""`); - expect(std.err).toMatchInlineSnapshot(` - "Missing script name + describe("non-interactive", () => { + const mockStdIn = useMockStdin({ isTTY: false }); - %s If you think this is a bug then please create an issue at https://github.com/cloudflare/wrangler2/issues/new." - `); - expect(error).toMatchInlineSnapshot(`[Error: Missing script name]`); + it("should create a secret, from piped input", async () => { + mockPutRequest({ 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("secret put the-key --name script-name"); + + expect(std.out).toMatchInlineSnapshot(` + "🌀 Creating the secret for script script-name + ✨ Success! Uploaded secret the-key" + `); + expect(std.warn).toMatchInlineSnapshot(`""`); + expect(std.err).toMatchInlineSnapshot(`""`); + }); + + it("should error if the piped input fails", async () => { + mockPutRequest({ name: "the-key", text: "the-secret" }); + mockStdIn.throwError(new Error("Error in stdin stream")); + await expect( + runWrangler("secret put the-key --name script-name") + ).rejects.toThrowErrorMatchingInlineSnapshot(`"Error in stdin stream"`); + + expect(std.out).toMatchInlineSnapshot(`""`); + expect(std.warn).toMatchInlineSnapshot(`""`); + }); + + describe("with accountId", () => { + mockAccountId({ accountId: null }); + + it("should error if a user has no account", async () => { + await expect( + runWrangler("secret put the-key --name script-name") + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"No account id found, quitting..."` + ); + }); + + it("should use the account from wrangler.toml", async () => { + fs.writeFileSync( + "wrangler.toml", + TOML.stringify({ + account_id: "some-account-id", + }), + "utf-8" + ); + mockStdIn.send("the-secret"); + mockPutRequest({ name: "the-key", text: "the-secret" }); + await runWrangler("secret put the-key --name script-name"); + expect(std.out).toMatchInlineSnapshot(` + "🌀 Creating the secret for script script-name + ✨ Success! Uploaded secret the-key" + `); + expect(std.warn).toMatchInlineSnapshot(`""`); + expect(std.err).toMatchInlineSnapshot(`""`); + }); + + it("should error if a user has multiple accounts, and has not specified an account in wrangler.toml", async () => { + // This is a mock response for the request to the CF API memberships of the current user. + fetchMock.doMockOnce(async () => { + return { + body: JSON.stringify({ + success: true, + result: [ + { + 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("secret put the-key --name script-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\\")" + `); + }); + }); }); }); diff --git a/packages/wrangler/src/index.tsx b/packages/wrangler/src/index.tsx index 3cb17c60c24e..20d21d5be5bb 100644 --- a/packages/wrangler/src/index.tsx +++ b/packages/wrangler/src/index.tsx @@ -102,13 +102,16 @@ function getScriptName( : shortScriptName; } -async function requireAuth(config: Config): Promise { - const loggedIn = await loginOrRefreshIfRequired(); +async function requireAuth( + config: Config, + isInteractive = true +): Promise { + const loggedIn = await loginOrRefreshIfRequired(isInteractive); if (!loggedIn) { // didn't login, let's just quit throw new Error("Did not login, quitting..."); } - const accountId = config.account_id || (await getAccountId()); + const accountId = config.account_id || (await getAccountId(isInteractive)); if (!accountId) { throw new Error("No account id found, quitting..."); } @@ -116,6 +119,39 @@ async function requireAuth(config: Config): Promise { return accountId; } +/** + * 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); + }); + }); +} + // a helper to demand one of a set of options // via https://github.com/yargs/yargs/issues/1093#issuecomment-491299261 function demandOneOfOption(...options: string[]) { @@ -1400,12 +1436,12 @@ export async function main(argv: string[]): Promise { throw new Error("Missing script name"); } - const accountId = await requireAuth(config); + const isInteractive = process.stdin.isTTY; + const accountId = await requireAuth(config, isInteractive); - const secretValue = await prompt( - "Enter a secret value:", - "password" - ); + const secretValue = isInteractive + ? await prompt("Enter a secret value:", "password") + : await readFromStdin(); console.log( `🌀 Creating the secret for script ${scriptName} ${ @@ -1430,49 +1466,61 @@ export async function main(argv: string[]): Promise { }); } + const createDraftWorker = async () => { + // TODO: log a warning + await fetchResult( + !args["legacy-env"] && args.env + ? `/accounts/${accountId}/workers/services/${scriptName}/environments/${args.env}` + : `/accounts/${accountId}/workers/scripts/${scriptName}`, + { + method: "PUT", + body: toFormData({ + name: scriptName, + main: { + name: scriptName, + content: `export default { fetch() {} }`, + type: "esm", + }, + bindings: { + kv_namespaces: [], + vars: {}, + durable_objects: { bindings: [] }, + r2_buckets: [], + wasm_modules: {}, + text_blobs: {}, + unsafe: [], + }, + modules: [], + migrations: undefined, + compatibility_date: undefined, + compatibility_flags: undefined, + usage_model: undefined, + }), + } + ); + }; + + function isMissingWorkerError(e: unknown): e is { code: 10007 } { + return ( + typeof e === "object" && + e !== null && + (e as { code: 10007 }).code === 10007 + ); + } + try { await submitSecret(); } catch (e) { - // @ts-expect-error non-standard property on Error - if (e.code === 10007) { - // upload a draft worker - // TODO: log a warning - await fetchResult( - !args["legacy-env"] && args.env - ? `/accounts/${accountId}/workers/services/${scriptName}/environments/${args.env}` - : `/accounts/${accountId}/workers/scripts/${scriptName}`, - { - method: "PUT", - body: toFormData({ - name: scriptName, - main: { - name: scriptName, - content: `export default { fetch() {} }`, - type: "esm", - }, - bindings: { - kv_namespaces: [], - vars: {}, - durable_objects: { bindings: [] }, - r2_buckets: [], - wasm_modules: {}, - text_blobs: {}, - unsafe: [], - }, - modules: [], - migrations: undefined, - compatibility_date: undefined, - compatibility_flags: undefined, - usage_model: undefined, - }), - } - ); - - // and then try again + if (isMissingWorkerError(e)) { + // create a draft worker and try again + await createDraftWorker(); await submitSecret(); // TODO: delete the draft worker if this failed too? + } else { + throw e; } } + console.log(`✨ Success! Uploaded secret ${args.key}`); } ) diff --git a/packages/wrangler/src/user.tsx b/packages/wrangler/src/user.tsx index ef930f32c7c1..5ea91b510d83 100644 --- a/packages/wrangler/src/user.tsx +++ b/packages/wrangler/src/user.tsx @@ -817,12 +817,15 @@ type LoginProps = { scopes?: Scope[]; }; -export async function loginOrRefreshIfRequired(): Promise { +export async function loginOrRefreshIfRequired( + isInteractive = true +): Promise { // TODO: if there already is a token, then try refreshing // TODO: ask permission before opening browser if (!LocalState.accessToken) { - // not logged in. - return await login(); + // Not logged in. + // If we are not interactive, we cannot ask the user to login + return isInteractive && (await login()); } else if (isAccessTokenExpired()) { return await refreshToken(); } else { @@ -976,7 +979,9 @@ export function listScopes(): void { // TODO: maybe a good idea to show usage here } -export async function getAccountId(): Promise { +export async function getAccountId( + isInteractive = true +): Promise { const apiToken = getAPIToken(); if (!apiToken) return; @@ -1007,7 +1012,7 @@ export async function getAccountId(): Promise { if (responseJSON.success === true) { if (responseJSON.result.length === 1) { accountId = responseJSON.result[0].account.id; - } else { + } else if (isInteractive) { accountId = await new Promise((resolve) => { const accounts = responseJSON.result.map((x) => x.account); const { unmount } = render( @@ -1020,6 +1025,15 @@ export async function getAccountId(): Promise { /> ); }); + } else { + throw new Error( + "More than one account available but unable to select one in non-interactive mode.\n" + + `Please set the appropriate \`account_id\` in your \`wrangler.toml\` file.\n` + + `Available accounts are ("" - ""):\n` + + responseJSON.result + .map((x) => ` "${x.account.name}" - "${x.account.id}")`) + .join("\n") + ); } } return accountId;