diff --git a/.changeset/hyperdrive-planetscale-signature.md b/.changeset/hyperdrive-planetscale-signature.md new file mode 100644 index 00000000000..5c9daf08f9b --- /dev/null +++ b/.changeset/hyperdrive-planetscale-signature.md @@ -0,0 +1,17 @@ +--- +"wrangler": minor +--- + +Add experimental `wrangler hyperdrive planetscale signature` command + +Generates a signed authorization for creating a Cloudflare-billed PlanetScale database and prints it as JSON to stdout, so it can be piped into PlanetScale's own CLI, e.g.: + +```sh +SIG=$(wrangler hyperdrive planetscale signature) +pscale database create --org \ + --cloudflare-account-id "$(jq -r .account_id <<<"$SIG")" \ + --cloudflare-timestamp "$(jq -r .timestamp <<<"$SIG")" \ + --cloudflare-signature "$(jq -r .signature <<<"$SIG")" +``` + +This command is experimental and its interface may change. diff --git a/packages/wrangler/src/__tests__/hyperdrive.test.ts b/packages/wrangler/src/__tests__/hyperdrive.test.ts index 57c22a744fb..2fb63e5b2c1 100644 --- a/packages/wrangler/src/__tests__/hyperdrive.test.ts +++ b/packages/wrangler/src/__tests__/hyperdrive.test.ts @@ -38,6 +38,7 @@ describe("hyperdrive help", () => { wrangler hyperdrive delete Delete a Hyperdrive config wrangler hyperdrive get Get a Hyperdrive config wrangler hyperdrive list List Hyperdrive configs + wrangler hyperdrive planetscale Provision Cloudflare-billed PlanetScale databases [experimental] wrangler hyperdrive update Update a Hyperdrive config GLOBAL FLAGS @@ -75,6 +76,7 @@ describe("hyperdrive help", () => { wrangler hyperdrive delete Delete a Hyperdrive config wrangler hyperdrive get Get a Hyperdrive config wrangler hyperdrive list List Hyperdrive configs + wrangler hyperdrive planetscale Provision Cloudflare-billed PlanetScale databases [experimental] wrangler hyperdrive update Update a Hyperdrive config GLOBAL FLAGS @@ -90,6 +92,116 @@ describe("hyperdrive help", () => { }); }); +describe("hyperdrive planetscale signature", () => { + mockAccountId(); + mockApiToken(); + runInTempDir(); + + const std = mockConsoleMethods(); + + function mockCreateDatabaseSignature(): Promise<{ + accountId: string; + integration: string; + method: string; + }> { + return new Promise((resolve) => { + msw.use( + http.post( + "*/accounts/:accountId/hyperdrive/integrationsOperations/:integration/createDatabaseSignature", + async ({ params, request }) => { + resolve({ + accountId: String(params.accountId), + integration: String(params.integration), + method: request.method, + }); + return HttpResponse.json( + createFetchResult({ + account_id: "some-account-id", + timestamp: "1700000000", + signature: "deadbeef", + }) + ); + } + ) + ); + }); + } + + it("should show the planetscale namespace help", async ({ expect }) => { + await runWrangler("hyperdrive planetscale"); + await endEventLoop(); + + expect(std.err).toMatchInlineSnapshot(`""`); + expect(std.out).toMatchInlineSnapshot(` + "wrangler hyperdrive planetscale + + Provision Cloudflare-billed PlanetScale databases [experimental] + + COMMANDS + wrangler hyperdrive planetscale signature Generate a signed authorization for creating a Cloudflare-billed PlanetScale database [experimental] + + GLOBAL FLAGS + -c, --config Path to Wrangler configuration file [string] + --cwd Run as if Wrangler was started in the specified directory instead of the current working directory [string] + -e, --env Environment to use for operations, and for selecting .env and .dev.vars files [string] + --env-file Path to an .env file to load - can be specified multiple times - values from earlier files are overridden by values in later files [array] + -h, --help Show help [boolean] + --install-skills Install Cloudflare skills for detected AI coding agents before running the command [boolean] [default: false] + --profile Use a specific auth profile [string] + -v, --version Show version number [boolean]" + `); + }); + + it("should show the signature command help", async ({ expect }) => { + await runWrangler("hyperdrive planetscale signature --help"); + await endEventLoop(); + + expect(std.err).toMatchInlineSnapshot(`""`); + expect(std.out).toMatchInlineSnapshot(` + "wrangler hyperdrive planetscale signature + + Generate a signed authorization for creating a Cloudflare-billed PlanetScale database [experimental] + + GLOBAL FLAGS + -c, --config Path to Wrangler configuration file [string] + --cwd Run as if Wrangler was started in the specified directory instead of the current working directory [string] + -e, --env Environment to use for operations, and for selecting .env and .dev.vars files [string] + --env-file Path to an .env file to load - can be specified multiple times - values from earlier files are overridden by values in later files [array] + -h, --help Show help [boolean] + --install-skills Install Cloudflare skills for detected AI coding agents before running the command [boolean] [default: false] + --profile Use a specific auth profile [string] + -v, --version Show version number [boolean]" + `); + }); + + it("should POST to the planetScale integration endpoint", async ({ + expect, + }) => { + const reqProm = mockCreateDatabaseSignature(); + await runWrangler("hyperdrive planetscale signature"); + + await expect(reqProm).resolves.toEqual({ + accountId: "some-account-id", + integration: "planetScale", + method: "POST", + }); + }); + + it("should print only the signature JSON, so it can be piped", async ({ + expect, + }) => { + void mockCreateDatabaseSignature(); + await runWrangler("hyperdrive planetscale signature"); + + expect(std.out).not.toContain("wrangler x.x.x"); + expect(JSON.parse(std.out)).toEqual({ + account_id: "some-account-id", + timestamp: "1700000000", + signature: "deadbeef", + }); + }); +}); + describe("hyperdrive commands", () => { mockAccountId(); mockApiToken(); diff --git a/packages/wrangler/src/hyperdrive/client.ts b/packages/wrangler/src/hyperdrive/client.ts index f9b421ff8c7..37339417e50 100644 --- a/packages/wrangler/src/hyperdrive/client.ts +++ b/packages/wrangler/src/hyperdrive/client.ts @@ -178,3 +178,25 @@ export async function patchConfig( } ); } + +// Passed as flags to the partner's own CLI to authorize a Cloudflare-billed +// database creation. +export type CreateDatabaseSignature = { + account_id: string; + timestamp: string; + signature: string; +}; + +export async function createDatabaseSignature( + config: Config, + integration: string +): Promise { + const accountId = await requireAuth(config); + return await fetchResult( + config, + `/accounts/${accountId}/hyperdrive/integrationsOperations/${integration}/createDatabaseSignature`, + { + method: "POST", + } + ); +} diff --git a/packages/wrangler/src/hyperdrive/planetscale.ts b/packages/wrangler/src/hyperdrive/planetscale.ts new file mode 100644 index 00000000000..e4c3a0944fb --- /dev/null +++ b/packages/wrangler/src/hyperdrive/planetscale.ts @@ -0,0 +1,27 @@ +import { createCommand, createNamespace } from "../core/create-command"; +import { logger } from "../logger"; +import { createDatabaseSignature } from "./client"; + +export const hyperdrivePlanetscaleNamespace = createNamespace({ + metadata: { + description: "Provision Cloudflare-billed PlanetScale databases", + status: "experimental", + owner: "Product: Hyperdrive", + }, +}); + +export const hyperdrivePlanetscaleSignatureCommand = createCommand({ + metadata: { + description: + "Generate a signed authorization for creating a Cloudflare-billed PlanetScale database", + status: "experimental", + owner: "Product: Hyperdrive", + }, + // Print only JSON, so the output can be piped into `pscale database create`. + behaviour: { printBanner: false }, + args: {}, + async handler(_args, { config }) { + const signature = await createDatabaseSignature(config, "planetScale"); + logger.log(JSON.stringify(signature, null, 2)); + }, +}); diff --git a/packages/wrangler/src/index.ts b/packages/wrangler/src/index.ts index 2501578b7e7..cc5058622a9 100644 --- a/packages/wrangler/src/index.ts +++ b/packages/wrangler/src/index.ts @@ -222,6 +222,10 @@ import { hyperdriveDeleteCommand } from "./hyperdrive/delete"; import { hyperdriveGetCommand } from "./hyperdrive/get"; import { hyperdriveNamespace } from "./hyperdrive/index"; import { hyperdriveListCommand } from "./hyperdrive/list"; +import { + hyperdrivePlanetscaleNamespace, + hyperdrivePlanetscaleSignatureCommand, +} from "./hyperdrive/planetscale"; import { hyperdriveUpdateCommand } from "./hyperdrive/update"; import { init } from "./init"; import { @@ -1558,6 +1562,14 @@ export function createCLIParser(argv: string[]) { }, { command: "wrangler hyperdrive get", definition: hyperdriveGetCommand }, { command: "wrangler hyperdrive list", definition: hyperdriveListCommand }, + { + command: "wrangler hyperdrive planetscale", + definition: hyperdrivePlanetscaleNamespace, + }, + { + command: "wrangler hyperdrive planetscale signature", + definition: hyperdrivePlanetscaleSignatureCommand, + }, { command: "wrangler hyperdrive update", definition: hyperdriveUpdateCommand,