diff --git a/README.md b/README.md index 0a5fcdb..28827d9 100644 --- a/README.md +++ b/README.md @@ -15,14 +15,17 @@ This monorepo contains tools and services that enable developers to quickly prov ### Available Flags -| Flag | Description | Example | -| ---------------- | ------------------------------------------------- | -------------------- | -| `--region` | Specify database region | `--region us-east-1` | -| `--list-regions` | List available regions | `--list-regions` | -| `--interactive` | Enable interactive region selection | `--interactive` | -| `--help` | Show help information | `--help` | -| `--json` | Output the info in a JSON format | `--json` | -| `--env`, `-e` | Print DATABASE_URL to stdout; claim URL to stderr | `--env` | +| Flag | Description | Example | +| --------------------- | ------------------------------------------------------------ | ---------------------- | +| `--region`, `-r` | Specify database region | `--region us-east-1` | +| `--interactive`, `-i` | Enable interactive region selection | `--interactive` | +| `--json`, `-j` | Output machine-readable JSON | `--json` | +| `--env`, `-e` | Write `DATABASE_URL` and `CLAIM_URL` to the specified file | `--env .env` | +| `--ttl`, `-t` | Set auto-delete TTL (`30m`, `1h` ... `24h`) | `--ttl 5h` | +| `--copy`, `-c` | Copy connection string to clipboard | `--copy` | +| `--quiet`, `-q` | Output only the connection string | `--quiet` | +| `--open`, `-o` | Open claim URL in browser | `--open` | +| `--help`, `-h` | Show help information | `--help` | ### Examples diff --git a/create-db-worker/src/delete-workflow.ts b/create-db-worker/src/delete-workflow.ts index 8bbadc1..41db5a8 100644 --- a/create-db-worker/src/delete-workflow.ts +++ b/create-db-worker/src/delete-workflow.ts @@ -1,7 +1,9 @@ import { WorkflowEntrypoint, WorkflowEvent, WorkflowStep } from 'cloudflare:workers'; +import { parseTtlMsInput, clampTtlMs } from './ttl'; type Params = { projectID: string; + ttlMs?: number; }; type Env = { @@ -10,13 +12,17 @@ type Env = { export class DeleteDbWorkflow extends WorkflowEntrypoint { async run(event: WorkflowEvent, step: WorkflowStep): Promise { - const { projectID } = event.payload; + const { projectID, ttlMs } = event.payload; if (!projectID) { throw new Error('No projectID provided.'); } - await step.sleep('wait 24 hours', '24 hours'); + const rawTtlMs = parseTtlMsInput(ttlMs); + const effectiveTtlMs = clampTtlMs(rawTtlMs); + const effectiveTtlSeconds = Math.ceil(effectiveTtlMs / 1000); + + await step.sleep(`wait ${effectiveTtlSeconds} seconds`, `${effectiveTtlSeconds} seconds`); const res = await fetch(`https://api.prisma.io/v1/projects/${projectID}`, { method: 'DELETE', @@ -29,6 +35,7 @@ export class DeleteDbWorkflow extends WorkflowEntrypoint { if (!res.ok) { throw new Error(`Failed to delete project: ${res.statusText}`); } + } } diff --git a/create-db-worker/src/index.ts b/create-db-worker/src/index.ts index 72c5d8a..3ab1962 100644 --- a/create-db-worker/src/index.ts +++ b/create-db-worker/src/index.ts @@ -1,6 +1,7 @@ import DeleteDbWorkflow from './delete-workflow'; import DeleteStaleProjectsWorkflow from './delete-stale-workflow'; import { PosthogEventCapture } from './analytics'; +import { parseTtlMsInput, isTtlMsInRange } from './ttl'; interface Env { INTEGRATION_TOKEN: string; DELETE_DB_WORKFLOW: Workflow; @@ -132,6 +133,7 @@ export default { analytics?: { eventName?: string; properties?: Record }; userAgent?: string; source?: 'programmatic' | 'cli'; + ttlMs?: unknown; }; let body: CreateDbBody = {}; @@ -142,7 +144,16 @@ export default { return new Response('Invalid JSON body', { status: 400 }); } - const { region, name, analytics: analyticsData, userAgent, source } = body; + const { region, name, analytics: analyticsData, userAgent, source, ttlMs } = body; + const parsedTtlMs = parseTtlMsInput(ttlMs); + + if (ttlMs !== undefined && parsedTtlMs === undefined) { + return new Response('Invalid ttlMs in request body', { status: 400 }); + } + + if (parsedTtlMs !== undefined && !isTtlMsInRange(parsedTtlMs)) { + return new Response('Invalid ttlMs in request body', { status: 400 }); + } // Apply stricter rate limiting for programmatic requests if (source === 'programmatic') { @@ -211,7 +222,9 @@ export default { const response = JSON.parse(prismaText); const projectID = response.data ? response.data.id : response.id; - const workflowPromise = env.DELETE_DB_WORKFLOW.create({ params: { projectID } }); + const workflowPromise = env.DELETE_DB_WORKFLOW.create({ + params: { projectID, ttlMs: parsedTtlMs }, + }); const analyticsPromise = env.CREATE_DB_DATASET.writeDataPoint({ blobs: ['database_created'], diff --git a/create-db-worker/src/ttl.ts b/create-db-worker/src/ttl.ts new file mode 100644 index 0000000..388e5f3 --- /dev/null +++ b/create-db-worker/src/ttl.ts @@ -0,0 +1,22 @@ +export const MIN_TTL_MS = 30 * 60 * 1000; +export const MAX_TTL_MS = 24 * 60 * 60 * 1000; + +export function parseTtlMsInput(value: unknown): number | undefined { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return undefined; + } + + return Math.floor(value); +} + +export function isTtlMsInRange(value: number): boolean { + return value >= MIN_TTL_MS && value <= MAX_TTL_MS; +} + +export function clampTtlMs(value: number | undefined): number { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return MAX_TTL_MS; + } + + return Math.max(MIN_TTL_MS, Math.min(MAX_TTL_MS, value)); +} diff --git a/create-db/README.md b/create-db/README.md index a308958..7477835 100644 --- a/create-db/README.md +++ b/create-db/README.md @@ -38,6 +38,10 @@ npx create-db regions # List available regions | `--interactive` | `-i` | Interactive mode to select a region | | `--json` | `-j` | Output machine-readable JSON | | `--env ` | `-e` | Write DATABASE_URL and CLAIM_URL to specified .env file | +| `--ttl ` | `-t` | Custom database TTL (`30m`, `1h` ... `24h`) | +| `--copy` | `-c` | Copy connection string to clipboard | +| `--quiet` | `-q` | Output only the connection string | +| `--open` | `-o` | Open claim URL in browser | | `--help` | `-h` | Show help message | | `--version` | | Show version | @@ -72,9 +76,26 @@ npx create-db -j npx create-db --env .env npx create-db -e .env.local +# Set custom TTL +npx create-db --ttl 1h +npx create-db -t 12h + +# Copy connection string to clipboard +npx create-db --copy +npx create-db -c + +# Only print connection string +npx create-db --quiet +npx create-db -q + +# Open claim URL in browser +npx create-db --open +npx create-db -o + # Combine flags npx create-db -r eu-central-1 -j npx create-db -i -e .env +npx create-db -t 24h -c -o # List available regions npx create-db regions @@ -154,6 +175,7 @@ if (result.success) { |--------|------|-------------| | `region` | `RegionId` | AWS region for the database (optional, defaults to `us-east-1`) | | `userAgent` | `string` | Custom user agent string for tracking (optional) | +| `ttl` | `string` | TTL string (`30m`, `1h` ... `24h`) | ### `regions()` diff --git a/create-db/__tests__/cli.test.ts b/create-db/__tests__/cli.test.ts index aa0e6b8..2fe5578 100644 --- a/create-db/__tests__/cli.test.ts +++ b/create-db/__tests__/cli.test.ts @@ -59,6 +59,10 @@ describe("CLI help and version", () => { expect(result.all).toContain("--interactive"); expect(result.all).toContain("--json"); expect(result.all).toContain("--env"); + expect(result.all).toContain("--ttl"); + expect(result.all).toContain("--copy"); + expect(result.all).toContain("--quiet"); + expect(result.all).toContain("--open"); }); it("displays regions command help", async () => { @@ -75,6 +79,18 @@ describe("CLI error handling", () => { expect(result.exitCode).not.toBe(0); }, 10000); + it("fails validation when --ttl is provided without a value", async () => { + const result = await runCli(["--ttl"]); + expect(result.exitCode).not.toBe(0); + expect(result.all).toContain("Input validation failed"); + }); + + it("fails validation when --ttl value is invalid", async () => { + const result = await runCli(["--ttl", "25h"]); + expect(result.exitCode).not.toBe(0); + expect(result.all).toContain("Input validation failed"); + }); + it("shows error for unknown command", async () => { const result = await runCli(["unknown-command"]); expect(result.exitCode).not.toBe(0); diff --git a/create-db/__tests__/flags.test.ts b/create-db/__tests__/flags.test.ts index f133cff..4923210 100644 --- a/create-db/__tests__/flags.test.ts +++ b/create-db/__tests__/flags.test.ts @@ -112,6 +112,102 @@ describe("CreateFlags schema", () => { }); }); + describe("ttl field", () => { + it("accepts valid ttl strings", () => { + const validTtls = [ + { input: "30m", expectedMs: 1_800_000 }, + { input: "1h", expectedMs: 3_600_000 }, + { input: "6h", expectedMs: 21_600_000 }, + { input: "12h", expectedMs: 43_200_000 }, + { input: "24h", expectedMs: 86_400_000 }, + ]; + + for (const { input, expectedMs } of validTtls) { + const result = CreateFlags.safeParse({ ttl: input }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.ttl).toBe(expectedMs); + } + } + }); + + it("rejects invalid ttl strings", () => { + const ttlInputs = ["25h", "7d", "10s", "45s", "one-hour", "24"]; + + for (const ttl of ttlInputs) { + const result = CreateFlags.safeParse({ ttl }); + expect(result.success).toBe(false); + } + }); + + it("rejects missing ttl values", () => { + const result = CreateFlags.safeParse({ ttl: true }); + expect(result.success).toBe(false); + }); + + it("allows undefined", () => { + const result = CreateFlags.safeParse({}); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.ttl).toBeUndefined(); + } + }); + }); + + describe("copy field", () => { + it("defaults to false", () => { + const result = CreateFlags.safeParse({}); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.copy).toBe(false); + } + }); + + it("accepts true", () => { + const result = CreateFlags.safeParse({ copy: true }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.copy).toBe(true); + } + }); + }); + + describe("quiet field", () => { + it("defaults to false", () => { + const result = CreateFlags.safeParse({}); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.quiet).toBe(false); + } + }); + + it("accepts true", () => { + const result = CreateFlags.safeParse({ quiet: true }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.quiet).toBe(true); + } + }); + }); + + describe("open field", () => { + it("defaults to false", () => { + const result = CreateFlags.safeParse({}); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.open).toBe(false); + } + }); + + it("accepts true", () => { + const result = CreateFlags.safeParse({ open: true }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.open).toBe(true); + } + }); + }); + describe("userAgent field", () => { it("accepts custom user agent string", () => { const result = CreateFlags.safeParse({ userAgent: "myapp/1.0.0" }); @@ -137,6 +233,10 @@ describe("CreateFlags schema", () => { interactive: true, json: false, env: ".env.local", + ttl: "12h", + copy: true, + quiet: false, + open: true, userAgent: "test/2.0.0", }; @@ -148,6 +248,10 @@ describe("CreateFlags schema", () => { interactive: true, json: false, env: ".env.local", + ttl: 43_200_000, + copy: true, + quiet: false, + open: true, userAgent: "test/2.0.0", }); } @@ -161,6 +265,10 @@ describe("CreateFlags schema", () => { expect(result.data.interactive).toBe(false); expect(result.data.json).toBe(false); expect(result.data.env).toBeUndefined(); + expect(result.data.ttl).toBeUndefined(); + expect(result.data.copy).toBe(false); + expect(result.data.quiet).toBe(false); + expect(result.data.open).toBe(false); expect(result.data.userAgent).toBeUndefined(); } }); @@ -168,20 +276,28 @@ describe("CreateFlags schema", () => { describe("type inference", () => { it("CreateFlagsInput type matches schema output", () => { - const input: CreateFlagsInput = { + const result = CreateFlags.parse({ region: "us-east-1", interactive: false, json: true, env: ".env", + ttl: "24h", + copy: true, + quiet: false, + open: true, userAgent: "test/1.0", - }; + }); - const result = CreateFlags.parse(input); - expect(result.region).toBe(input.region); - expect(result.interactive).toBe(input.interactive); - expect(result.json).toBe(input.json); - expect(result.env).toBe(input.env); - expect(result.userAgent).toBe(input.userAgent); + const typedResult: CreateFlagsInput = result; + expect(typedResult.region).toBe("us-east-1"); + expect(typedResult.interactive).toBe(false); + expect(typedResult.json).toBe(true); + expect(typedResult.env).toBe(".env"); + expect(typedResult.ttl).toBe(86_400_000); + expect(typedResult.copy).toBe(true); + expect(typedResult.quiet).toBe(false); + expect(typedResult.open).toBe(true); + expect(typedResult.userAgent).toBe("test/1.0"); }); }); }); diff --git a/create-db/__tests__/ttl.test.ts b/create-db/__tests__/ttl.test.ts new file mode 100644 index 0000000..a6bb91b --- /dev/null +++ b/create-db/__tests__/ttl.test.ts @@ -0,0 +1,29 @@ +import { describe, it, expect } from "vitest"; +import { parseTtlToMilliseconds } from "../src/utils/ttl.js"; + +describe("parseTtlToMilliseconds", () => { + it("parses supported ttl values", () => { + expect(parseTtlToMilliseconds("30m")).toBe(1_800_000); + expect(parseTtlToMilliseconds("1h")).toBe(3_600_000); + expect(parseTtlToMilliseconds("6h")).toBe(21_600_000); + expect(parseTtlToMilliseconds("24h")).toBe(86_400_000); + }); + + it("is case-insensitive", () => { + expect(parseTtlToMilliseconds("30M")).toBe(1_800_000); + expect(parseTtlToMilliseconds("2H")).toBe(7_200_000); + expect(parseTtlToMilliseconds("24H")).toBe(86_400_000); + }); + + it("rejects values outside the allowed range", () => { + expect(parseTtlToMilliseconds("")).toBeNull(); + expect(parseTtlToMilliseconds("0h")).toBeNull(); + expect(parseTtlToMilliseconds("9s")).toBeNull(); + expect(parseTtlToMilliseconds("10s")).toBeNull(); + expect(parseTtlToMilliseconds("45s")).toBeNull(); + expect(parseTtlToMilliseconds("1d")).toBeNull(); + expect(parseTtlToMilliseconds("25h")).toBeNull(); + expect(parseTtlToMilliseconds("7d")).toBeNull(); + expect(parseTtlToMilliseconds("abc")).toBeNull(); + }); +}); diff --git a/create-db/package.json b/create-db/package.json index b934eb2..26f715d 100644 --- a/create-db/package.json +++ b/create-db/package.json @@ -1,6 +1,6 @@ { "name": "create-db", - "version": "1.1.4", + "version": "1.2.0", "description": "Instantly create a temporary Prisma Postgres database with one command, then claim and persist it in your Prisma Data Platform project when ready.", "type": "module", "exports": { @@ -52,11 +52,11 @@ "@clack/prompts": "^0.11.0", "@orpc/server": "^1.12.2", "dotenv": "^17.2.3", + "execa": "^9.6.1", "picocolors": "^1.1.1", "terminal-link": "^5.0.0", - "trpc-cli": "^0.12.1", - "zod": "^4.1.13", - "execa": "^9.6.1" + "trpc-cli": "^0.12.4", + "zod": "^4.1.13" }, "publishConfig": { "access": "public" diff --git a/create-db/src/cli.ts b/create-db/src/cli.ts index 8c21feb..b47fb0f 100644 --- a/create-db/src/cli.ts +++ b/create-db/src/cli.ts @@ -1,3 +1,39 @@ import { createDbCli } from "./index.js"; -createDbCli().run(); +type OptionLike = { + flags?: string; + long?: string; +}; + +type CommandLike = { + options?: OptionLike[]; + commands?: CommandLike[]; +}; + +function simplifyHelpFlags(command: CommandLike) { + for (const option of command.options ?? []) { + if (!option.flags) { + continue; + } + + option.flags = option.flags + .replace(" [boolean]", "") + .replace(" [string]", " [value]") + .replace(" ", " "); + + if (option.long === "--ttl" || option.flags.includes("--ttl")) { + option.flags = option.flags + .replace("", "") + .replace("[value]", "[duration]"); + } + } + + for (const subcommand of command.commands ?? []) { + simplifyHelpFlags(subcommand); + } +} + +const cli = createDbCli(); +const program = cli.buildProgram() as unknown as CommandLike; +simplifyHelpFlags(program); +void cli.run(undefined, program as never); diff --git a/create-db/src/cli/commands/create.ts b/create-db/src/cli/commands/create.ts index eae4e8b..e157fa8 100644 --- a/create-db/src/cli/commands/create.ts +++ b/create-db/src/cli/commands/create.ts @@ -2,7 +2,7 @@ import { select, isCancel } from "@clack/prompts"; import { randomUUID } from "crypto"; import type { CreateFlagsInput } from "../flags.js"; -import type { RegionId } from "../../types.js"; +import type { RegionId, DatabaseResult } from "../../types.js"; import { getCommandName } from "../../core/database.js"; import { readUserEnvFile } from "../../utils/env-utils.js"; import { detectUserLocation, getRegionClosestToLocation } from "../../utils/geolocation.js"; @@ -24,11 +24,45 @@ import { printError, printSuccess, writeEnvFile, + copyToClipboard, + openUrlInBrowser, } from "../output.js"; +function applyCopyFlag(result: DatabaseResult, quiet: boolean) { + if (!result.connectionString) { + printError("Connection string is unavailable, cannot copy to clipboard."); + process.exit(1); + } + + const copyResult = copyToClipboard(result.connectionString); + if (!copyResult.success) { + printError(`Failed to copy connection string: ${copyResult.error}`); + process.exit(1); + } + + if (!quiet) { + printSuccess("Copied connection string to clipboard"); + } +} + +function applyOpenFlag(result: DatabaseResult, quiet: boolean) { + const openResult = openUrlInBrowser(result.claimUrl); + if (!openResult.success) { + printError(`Failed to open claim URL: ${openResult.error}`); + process.exit(1); + } + + if (!quiet) { + printSuccess("Opened claim URL in browser"); + } +} + export async function handleCreate(input: CreateFlagsInput): Promise { const cliRunId = randomUUID(); const CLI_NAME = getCommandName(); + const ttlMs = input.ttl; + + const interactiveMode = input.interactive && !input.quiet; let userAgent: string | undefined = input.userAgent; if (!userAgent) { @@ -46,6 +80,11 @@ export async function handleCreate(input: CreateFlagsInput): Promise { "has-interactive-flag": input.interactive, "has-json-flag": input.json, "has-env-flag": !!input.env, + "has-ttl-flag": input.ttl !== undefined, + "has-copy-flag": input.copy, + "has-quiet-flag": input.quiet, + "has-open-flag": input.open, + "ttl-ms": ttlMs, "user-agent": userAgent || undefined, "node-version": process.version, platform: process.platform, @@ -65,8 +104,8 @@ export async function handleCreate(input: CreateFlagsInput): Promise { const envEnabled = typeof envPath === "string" && envPath.trim().length > 0; - if (input.json || envEnabled) { - if (input.interactive) { + if (input.json || envEnabled || input.quiet) { + if (interactiveMode) { await ensureOnline(); const regions = await fetchRegions(); @@ -102,7 +141,13 @@ export async function handleCreate(input: CreateFlagsInput): Promise { } await ensureOnline(); - const result = await createDatabase(region, userAgent, cliRunId); + const result = await createDatabase( + region, + userAgent, + cliRunId, + "cli", + ttlMs + ); await flushAnalytics(); if (input.json) { @@ -118,13 +163,30 @@ export async function handleCreate(input: CreateFlagsInput): Promise { process.exit(1); } - const writeResult = writeEnvFile(envPath!, result.connectionString, result.claimUrl); - if (!writeResult.success) { - printError(`Failed to write environment variables to ${envPath}: ${writeResult.error}`); - process.exit(1); + if (envEnabled) { + const writeResult = writeEnvFile(envPath!, result.connectionString, result.claimUrl); + if (!writeResult.success) { + printError(`Failed to write environment variables to ${envPath}: ${writeResult.error}`); + process.exit(1); + } + + if (!input.quiet) { + printSuccess(`Wrote DATABASE_URL and CLAIM_URL to ${envPath}`); + } + } + + if (input.quiet) { + console.log(result.connectionString ?? ""); + } + + if (input.copy) { + applyCopyFlag(result, input.quiet); + } + + if (input.open) { + applyOpenFlag(result, input.quiet); } - printSuccess(`Wrote DATABASE_URL and CLAIM_URL to ${envPath}`); return; } @@ -166,7 +228,13 @@ export async function handleCreate(input: CreateFlagsInput): Promise { const spinner = createSpinner(); spinner.start(region); - const result = await createDatabase(region, userAgent, cliRunId); + const result = await createDatabase( + region, + userAgent, + cliRunId, + "cli", + ttlMs + ); if (!result.success) { spinner.error(result.message); @@ -176,6 +244,15 @@ export async function handleCreate(input: CreateFlagsInput): Promise { spinner.success(); printDatabaseResult(result); + + if (input.copy) { + applyCopyFlag(result, false); + } + + if (input.open) { + applyOpenFlag(result, false); + } + showOutro(); await flushAnalytics(); } diff --git a/create-db/src/cli/flags.ts b/create-db/src/cli/flags.ts index dc90549..f47d60e 100644 --- a/create-db/src/cli/flags.ts +++ b/create-db/src/cli/flags.ts @@ -1,5 +1,43 @@ import { z } from "zod"; import { RegionSchema } from "../types.js"; +import { + TTL_HELP_DESCRIPTION, + buildTtlCliError, + parseTtlToMilliseconds, +} from "../utils/ttl.js"; + +const TTL_MISSING_VALUE_SENTINEL = "__create_db_ttl_missing_value__"; + +const TtlFlag = z + .preprocess( + (value) => (value === true ? TTL_MISSING_VALUE_SENTINEL : value), + z + .string() + .superRefine((value, ctx) => { + if (value === TTL_MISSING_VALUE_SENTINEL) { + ctx.addIssue({ + code: "custom", + message: buildTtlCliError( + "Could not create database: --ttl was provided without a value." + ), + }); + return; + } + + if (parseTtlToMilliseconds(value) === null) { + ctx.addIssue({ + code: "custom", + message: buildTtlCliError( + `Could not create database: --ttl value "${value}" is invalid.` + ), + }); + } + }) + .transform((value) => parseTtlToMilliseconds(value)!) + ) + .optional() + .describe(TTL_HELP_DESCRIPTION) + .meta({ alias: "t" }); /** * Zod schema for CLI flags used by the `create` command. @@ -25,11 +63,30 @@ export const CreateFlags = z.object({ .optional() .describe("Write DATABASE_URL and CLAIM_URL to the specified .env file") .meta({ alias: "e" }), + ttl: TtlFlag, + copy: z + .boolean() + .optional() + .default(false) + .describe("Copy the connection string to your clipboard") + .meta({ alias: "c" }), + quiet: z + .boolean() + .optional() + .default(false) + .describe("Only output the connection string") + .meta({ alias: "q" }), + open: z + .boolean() + .optional() + .default(false) + .describe("Open the claim URL in your browser") + .meta({ alias: "o" }), userAgent: z .string() .optional() .describe("Custom user agent string (e.g. 'test/test')") - .meta({ alias: "u" }), + .meta({ alias: "u", hidden: true }), }); /** Inferred type from CreateFlags schema. */ diff --git a/create-db/src/cli/output.ts b/create-db/src/cli/output.ts index a8494e1..6cd9313 100644 --- a/create-db/src/cli/output.ts +++ b/create-db/src/cli/output.ts @@ -1,10 +1,13 @@ import { intro, outro, cancel, log, spinner as clackSpinner } from "@clack/prompts"; +import { spawnSync } from "child_process"; import fs from "fs"; import pc from "picocolors"; import terminalLink from "terminal-link"; import type { DatabaseResult } from "../types.js"; +type OperationResult = { success: true } | { success: false; error: string }; + /** Display the CLI intro message. */ export function showIntro() { intro(pc.bold(pc.cyan("🚀 Creating a Prisma Postgres database"))); @@ -126,3 +129,73 @@ export function writeEnvFile( }; } } + +/** + * Copy text to the system clipboard. + * @param text - Text to copy + */ +export function copyToClipboard(text: string): OperationResult { + const commands = + process.platform === "darwin" + ? [{ command: "pbcopy", args: [] as string[] }] + : process.platform === "win32" + ? [{ command: "cmd", args: ["/c", "clip"] }] + : [ + { command: "wl-copy", args: [] as string[] }, + { command: "xclip", args: ["-selection", "clipboard"] }, + { command: "xsel", args: ["--clipboard", "--input"] }, + ]; + + const errors: string[] = []; + + for (const { command, args } of commands) { + const result = spawnSync(command, args, { + input: text, + encoding: "utf8", + stdio: ["pipe", "ignore", "pipe"], + }); + + if (result.status === 0) { + return { success: true }; + } + + if (result.error) { + errors.push(`${command}: ${result.error.message}`); + } else if (result.stderr?.trim()) { + errors.push(`${command}: ${result.stderr.trim()}`); + } + } + + return { + success: false, + error: errors[0] || "No clipboard command is available on this system.", + }; +} + +/** + * Open a URL in the user's default browser. + * @param url - URL to open + */ +export function openUrlInBrowser(url: string): OperationResult { + const command = + process.platform === "darwin" + ? { command: "open", args: [url] } + : process.platform === "win32" + ? { command: "cmd", args: ["/c", "start", "", url] } + : { command: "xdg-open", args: [url] }; + + const result = spawnSync(command.command, command.args, { stdio: "ignore" }); + + if (result.status === 0) { + return { success: true }; + } + + if (result.error) { + return { success: false, error: result.error.message }; + } + + return { + success: false, + error: `Command failed: ${command.command}`, + }; +} diff --git a/create-db/src/core/database.ts b/create-db/src/core/database.ts index ab6263f..f0f2117 100644 --- a/create-db/src/core/database.ts +++ b/create-db/src/core/database.ts @@ -15,21 +15,28 @@ export async function createDatabaseCore( claimDbWorkerUrl: string, userAgent?: string, cliRunId?: string, - source?: "programmatic" | "cli" + source?: "programmatic" | "cli", + ttlMs?: number ): Promise { const name = new Date().toISOString(); const runId = cliRunId ?? randomUUID(); + const payload: Record = { + region, + name, + utm_source: getCommandName(), + userAgent, + source: source || "cli", + }; + + if (typeof ttlMs === "number" && Number.isFinite(ttlMs) && ttlMs > 0) { + payload.ttlMs = Math.floor(ttlMs); + } + const resp = await fetch(`${createDbWorkerUrl}/create`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - region, - name, - utm_source: getCommandName(), - userAgent, - source: source || "cli", - }), + body: JSON.stringify(payload), }); if (resp.status === 429) { @@ -132,7 +139,12 @@ export async function createDatabaseCore( : null; const claimUrl = `${claimDbWorkerUrl}/claim?projectID=${projectId}&utm_source=${userAgent || getCommandName()}&utm_medium=cli`; - const expiryDate = new Date(Date.now() + 24 * 60 * 60 * 1000); + + const ttlMsToUse = + typeof ttlMs === "number" && Number.isFinite(ttlMs) && ttlMs > 0 + ? Math.floor(ttlMs) + : 24 * 60 * 60 * 1000; + const expiryDate = new Date(Date.now() + ttlMsToUse); void sendAnalytics( "create_db:database_created", diff --git a/create-db/src/core/services.ts b/create-db/src/core/services.ts index 5889ec8..9298dd0 100644 --- a/create-db/src/core/services.ts +++ b/create-db/src/core/services.ts @@ -61,13 +61,15 @@ export function validateRegionId(region: string) { * @param userAgent - Optional custom user agent string * @param cliRunId - Optional unique identifier for this CLI run * @param source - Whether called from CLI or programmatic API + * @param ttlMs - Optional database lifetime in milliseconds * @returns A promise resolving to the database creation result */ export function createDatabase( region: string, userAgent?: string, cliRunId?: string, - source?: "programmatic" | "cli" + source?: "programmatic" | "cli", + ttlMs?: number ) { return createDatabaseCore( region, @@ -75,6 +77,7 @@ export function createDatabase( CLAIM_DB_WORKER_URL, userAgent, cliRunId, - source + source, + ttlMs ); } diff --git a/create-db/src/index.ts b/create-db/src/index.ts index b854318..e4d788e 100644 --- a/create-db/src/index.ts +++ b/create-db/src/index.ts @@ -13,6 +13,7 @@ import { import { getCommandName } from "./core/database.js"; import { handleCreate, handleRegions } from "./cli/commands/index.js"; import { createDatabase, fetchRegions } from "./core/services.js"; +import { parseTtlToMilliseconds, TTL_RANGE_TEXT } from "./utils/ttl.js"; export type { Region, @@ -74,11 +75,25 @@ export function createDbCli() { export async function create( options?: ProgrammaticCreateOptions ): Promise { + const ttlMs = + typeof options?.ttl === "string" + ? parseTtlToMilliseconds(options.ttl) + : undefined; + + if (typeof options?.ttl === "string" && ttlMs === null) { + return { + success: false, + error: "invalid_ttl", + message: `Invalid ttl "${options.ttl}". Allowed range is ${TTL_RANGE_TEXT}.`, + }; + } + return createDatabase( options?.region || "us-east-1", options?.userAgent, undefined, - "programmatic" + "programmatic", + ttlMs ?? undefined ); } diff --git a/create-db/src/types.ts b/create-db/src/types.ts index b8a6a08..33375c4 100644 --- a/create-db/src/types.ts +++ b/create-db/src/types.ts @@ -131,4 +131,5 @@ export type RegionsResponse = Region[] | RegionsApiResponse; export interface ProgrammaticCreateOptions { region?: RegionId; userAgent?: string; + ttl?: string; } diff --git a/create-db/src/utils/ttl.ts b/create-db/src/utils/ttl.ts new file mode 100644 index 0000000..82f59ad --- /dev/null +++ b/create-db/src/utils/ttl.ts @@ -0,0 +1,37 @@ +const HOUR_TTL_PATTERN = /^([1-9]|1\d|2[0-4])h$/; +export const MIN_TTL_MS = 30 * 60 * 1000; +export const MAX_TTL_MS = 24 * 60 * 60 * 1000; +const ONE_HOUR_MS = 60 * 60 * 1000; +export const TTL_HELP_DESCRIPTION = "Auto-delete after (30m, 1h-24h)"; +export const TTL_ALLOWED_VALUES_TEXT = "30m, or 1h-24h"; +export const TTL_RANGE_TEXT = "30m to 24h"; +export const TTL_EXAMPLES_TEXT = [ + "Examples:", + "npx create-db --ttl 24h", + "npx create-db --ttl 8h", + "npx create-db --ttl 1h", + "npx create-db --ttl 30m", +].join("\n"); + +export function parseTtlToMilliseconds(value: string): number | null { + const normalized = value.trim().toLowerCase(); + if (normalized === "30m") { + return MIN_TTL_MS; + } + + const match = HOUR_TTL_PATTERN.exec(normalized); + if (!match) { + return null; + } + + return Number(match[1]) * ONE_HOUR_MS; +} + +export function buildTtlCliError(message: string): string { + return [ + message, + `Allowed values are ${TTL_ALLOWED_VALUES_TEXT}.`, + "", + TTL_EXAMPLES_TEXT, + ].join("\n"); +} diff --git a/create-pg/package.json b/create-pg/package.json index ba686d6..e676fba 100644 --- a/create-pg/package.json +++ b/create-pg/package.json @@ -1,6 +1,6 @@ { "name": "create-pg", - "version": "1.1.4", + "version": "1.2.0", "description": "Instantly create a temporary Prisma Postgres database with one command, then claim and persist it in your Prisma Data Platform project when ready.", "author": "prisma", "repository": { diff --git a/create-postgres/package.json b/create-postgres/package.json index 123db43..8849371 100644 --- a/create-postgres/package.json +++ b/create-postgres/package.json @@ -1,6 +1,6 @@ { "name": "create-postgres", - "version": "1.1.4", + "version": "1.2.0", "description": "Instantly create a temporary Prisma Postgres database with one command, then claim and persist it in your Prisma Data Platform project when ready.", "author": "prisma", "repository": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5c0cdeb..6274eb7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -133,8 +133,8 @@ importers: specifier: ^5.0.0 version: 5.0.0 trpc-cli: - specifier: ^0.12.1 - version: 0.12.1(@orpc/server@1.12.2(ws@8.18.3))(effect@3.18.4)(valibot@1.2.0(typescript@5.9.3))(zod@4.1.13) + specifier: ^0.12.4 + version: 0.12.4(@orpc/server@1.12.2(ws@8.18.3))(effect@3.18.4)(valibot@1.2.0(typescript@5.9.3))(zod@4.1.13) zod: specifier: ^4.1.13 version: 4.1.13 @@ -5644,8 +5644,8 @@ packages: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true - trpc-cli@0.12.1: - resolution: {integrity: sha512-/D/mIQf3tUrS7ZKJZ1gmSPJn2psAABJfkC5Eevm55SZ4s6KwANOUNlwhAGXN9HT4VSJVfoF2jettevE9vHPQlg==} + trpc-cli@0.12.4: + resolution: {integrity: sha512-Yo2Ob5J7hUZSWZ2A1M9Kb+0qfSxwmcmYIs3kkQyLd3sn0qU4ryGzNsySfrY3+urqp6FnDnIIdbCSqC9BKxK6Ag==} engines: {node: '>=18'} hasBin: true peerDependencies: @@ -12482,7 +12482,7 @@ snapshots: tree-kill@1.2.2: {} - trpc-cli@0.12.1(@orpc/server@1.12.2(ws@8.18.3))(effect@3.18.4)(valibot@1.2.0(typescript@5.9.3))(zod@4.1.13): + trpc-cli@0.12.4(@orpc/server@1.12.2(ws@8.18.3))(effect@3.18.4)(valibot@1.2.0(typescript@5.9.3))(zod@4.1.13): dependencies: commander: 14.0.2 optionalDependencies: