diff --git a/README.md b/README.md index 28827d9..85f2201 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ This monorepo contains tools and services that enable developers to quickly prov | `--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` | +| `--quiet`, `-q` | Output only the connection string (claim URL on stderr) | `--quiet` | | `--open`, `-o` | Open claim URL in browser | `--open` | | `--help`, `-h` | Show help information | `--help` | diff --git a/packages/create-db/README.md b/packages/create-db/README.md index 7477835..24fcc53 100644 --- a/packages/create-db/README.md +++ b/packages/create-db/README.md @@ -40,7 +40,7 @@ npx create-db regions # List available regions | `--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 | +| `--quiet` | `-q` | Output only the connection string (the claim URL goes to stderr) | | `--open` | `-o` | Open claim URL in browser | | `--help` | `-h` | Show help message | | `--version` | | Show version | @@ -116,10 +116,15 @@ When using `--json` with `create`, the output includes: "deletionDate": "2025-12-13T12:00:00.000Z", "region": "us-east-1", "name": "2025-12-12T12:00:00.000Z", - "projectId": "proj_..." + "projectId": "proj_...", + "nextStep": "The database is deleted at 2025-12-13T12:00:00.000Z unless the user claims it, which keeps it for free. Give the user this claim URL: https://create-db.prisma.io/claim?projectID=..." } ``` +`nextStep` is for agents and scripts that run `create-db` for someone else: the database is deleted at `deletionDate` unless that person opens `claimUrl` and claims it. + +Add `--env ` to also write `DATABASE_URL` and `CLAIM_URL` to that file. If the file can't be written, the JSON is still printed and the command exits with code 1. + When using `--json` with `regions`, the output is an array of region objects: ```json @@ -141,6 +146,8 @@ DATABASE_URL="postgresql://user:pass@host:5432/postgres?sslmode=require" CLAIM_URL="https://create-db.prisma.io/claim?projectID=..." ``` +The CLI also prints the claim URL and deletion time, so the person running it can claim the database before it is deleted. + ## Programmatic API You can also use `create-db` as a library in your Node.js applications: diff --git a/packages/create-db/__tests__/cli.test.ts b/packages/create-db/__tests__/cli.test.ts index 2fe5578..18fd039 100644 --- a/packages/create-db/__tests__/cli.test.ts +++ b/packages/create-db/__tests__/cli.test.ts @@ -2,9 +2,13 @@ import { describe, it, expect } from "vitest"; import { execa } from "execa"; import { fileURLToPath } from "url"; import path from "path"; +import fs from "fs"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const CLI_PATH = path.resolve(__dirname, "../dist/cli.mjs"); +const { version: PACKAGE_VERSION } = JSON.parse( + fs.readFileSync(path.resolve(__dirname, "../package.json"), "utf8") +); const runCli = async ( args: string[] = [], @@ -43,13 +47,13 @@ describe("CLI help and version", () => { it("displays version with --version flag", async () => { const result = await runCli(["--version"]); expect(result.exitCode).toBe(0); - expect(result.stdout).toMatch(/\d+\.\d+\.\d+/); + expect(result.stdout.trim()).toBe(PACKAGE_VERSION); }); it("displays version with -V flag", async () => { const result = await runCli(["-V"]); expect(result.exitCode).toBe(0); - expect(result.stdout).toMatch(/\d+\.\d+\.\d+/); + expect(result.stdout.trim()).toBe(PACKAGE_VERSION); }); it("displays create command help", async () => { diff --git a/packages/create-db/__tests__/create.test.ts b/packages/create-db/__tests__/create.test.ts index 0bb113e..02a3827 100644 --- a/packages/create-db/__tests__/create.test.ts +++ b/packages/create-db/__tests__/create.test.ts @@ -2,6 +2,8 @@ import { describe, it, expect } from "vitest"; import { execa } from "execa"; import { fileURLToPath } from "url"; import path from "path"; +import fs from "fs"; +import os from "os"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const CLI_PATH = path.resolve(__dirname, "../dist/cli.mjs"); @@ -62,6 +64,24 @@ describe("CLI database creation", () => { expect(parsed.success).toBe(true); }, 30000); + it("writes the env file and prints JSON with --json --env", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "create-db-json-env-")); + const envPath = path.join(dir, ".env"); + try { + const result = await runCli(["--json", "--env", envPath]); + expect(result.exitCode).toBe(0); + const parsed = JSON.parse(result.stdout.trim()); + expect(parsed.success).toBe(true); + expect(parsed.nextStep).toContain(parsed.claimUrl); + + const content = fs.readFileSync(envPath, "utf8"); + expect(content).toContain(`CLAIM_URL="${parsed.claimUrl}"`); + expect(content).toContain("DATABASE_URL="); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }, 30000); + it("lists regions with regions command", async () => { const result = await runCli(["regions"]); if (result.exitCode !== 0) { diff --git a/packages/create-db/__tests__/output.test.ts b/packages/create-db/__tests__/output.test.ts index b03f3a1..3862030 100644 --- a/packages/create-db/__tests__/output.test.ts +++ b/packages/create-db/__tests__/output.test.ts @@ -2,7 +2,14 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import fs from "fs"; import os from "os"; import path from "path"; -import { writeEnvFile } from "../src/cli/output.js"; +import { + writeEnvFile, + withOutputMode, + formatDeletionDate, + formatJsonResult, + printClaimReminder, +} from "../src/cli/output.js"; +import type { DatabaseResult } from "../src/types.js"; describe("writeEnvFile", () => { let tempDir: string; @@ -142,3 +149,87 @@ describe("writeEnvFile", () => { expect(content).toContain(`DATABASE_URL="${connectionString}"`); }); }); + +const sampleResult: DatabaseResult = { + success: true, + connectionString: "postgresql://user:pass@host:5432/postgres", + claimUrl: + "https://create-db.prisma.io/claim?projectID=proj_123&utm_source=create-db&utm_medium=cli", + deletionDate: "2026-10-29T12:00:00.000Z", + region: "us-east-1", + name: "2026-10-28T12:00:00.000Z", + projectId: "proj_123", +}; + +describe("withOutputMode", () => { + it("appends the output mode to the claim URL as utm_content", () => { + const tagged = withOutputMode(sampleResult, "json"); + expect(tagged.claimUrl).toBe(`${sampleResult.claimUrl}&utm_content=json`); + expect(new URL(tagged.claimUrl).searchParams.get("projectID")).toBe("proj_123"); + }); + + it("starts a query string when the claim URL has none", () => { + const tagged = withOutputMode( + { ...sampleResult, claimUrl: "https://create-db.prisma.io/claim/proj_123" }, + "env" + ); + expect(tagged.claimUrl).toBe("https://create-db.prisma.io/claim/proj_123?utm_content=env"); + }); + + it("keeps the encoding of existing params", () => { + const claimUrl = + "https://create-db.prisma.io/claim?projectID=proj_123&utm_source=claude-code/my-app&utm_medium=cli"; + const tagged = withOutputMode({ ...sampleResult, claimUrl }, "default"); + expect(tagged.claimUrl).toBe(`${claimUrl}&utm_content=default`); + }); + + it("leaves the input result unchanged", () => { + withOutputMode(sampleResult, "quiet"); + expect(sampleResult.claimUrl).not.toContain("utm_content"); + }); +}); + +describe("formatDeletionDate", () => { + it("includes the time zone", () => { + const date = new Date(sampleResult.deletionDate); + const zone = new Intl.DateTimeFormat(undefined, { timeZoneName: "short" }) + .formatToParts(date) + .find((part) => part.type === "timeZoneName")?.value; + expect(zone).toBeTruthy(); + expect(formatDeletionDate(sampleResult.deletionDate)).toContain(zone); + }); +}); + +describe("formatJsonResult", () => { + it("keeps every result field and adds a nextStep with the claim URL and deletion time", () => { + const json = formatJsonResult(sampleResult); + expect(json).toMatchObject(sampleResult); + expect(json.nextStep).toContain(sampleResult.claimUrl); + expect(json.nextStep).toContain(sampleResult.deletionDate); + expect(json.nextStep.endsWith(sampleResult.claimUrl)).toBe(true); + }); +}); + +describe("printClaimReminder", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("prints the claim URL to stdout by default", () => { + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + printClaimReminder(sampleResult); + expect(log).toHaveBeenCalledTimes(1); + expect(log.mock.calls[0][0]).toContain(sampleResult.claimUrl); + expect(error).not.toHaveBeenCalled(); + }); + + it("prints to stderr when asked, keeping stdout clean", () => { + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + printClaimReminder(sampleResult, "stderr"); + expect(error).toHaveBeenCalledTimes(1); + expect(error.mock.calls[0][0]).toContain(sampleResult.claimUrl); + expect(log).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/create-db/package.json b/packages/create-db/package.json index 26f715d..baf386a 100644 --- a/packages/create-db/package.json +++ b/packages/create-db/package.json @@ -1,6 +1,6 @@ { "name": "create-db", - "version": "1.2.0", + "version": "1.3.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": { diff --git a/packages/create-db/src/cli/commands/create.ts b/packages/create-db/src/cli/commands/create.ts index e157fa8..f20c2ee 100644 --- a/packages/create-db/src/cli/commands/create.ts +++ b/packages/create-db/src/cli/commands/create.ts @@ -23,6 +23,9 @@ import { printJson, printError, printSuccess, + printClaimReminder, + formatJsonResult, + withOutputMode, writeEnvFile, copyToClipboard, openUrlInBrowser, @@ -57,6 +60,20 @@ function applyOpenFlag(result: DatabaseResult, quiet: boolean) { } } +/** + * Exit once stdout and stderr have flushed, so JSON written to a pipe isn't + * cut off. trpc-cli exits with 0 after the handler returns, so setting + * `process.exitCode` and returning would lose the failure code. + */ +async function exitWhenFlushed(code: number): Promise { + await Promise.all( + [process.stdout, process.stderr].map( + (stream) => new Promise((resolve) => stream.write("", () => resolve())) + ) + ); + process.exit(code); +} + export async function handleCreate(input: CreateFlagsInput): Promise { const cliRunId = randomUUID(); const CLI_NAME = getCommandName(); @@ -141,7 +158,7 @@ export async function handleCreate(input: CreateFlagsInput): Promise { } await ensureOnline(); - const result = await createDatabase( + const created = await createDatabase( region, userAgent, cliRunId, @@ -151,18 +168,34 @@ export async function handleCreate(input: CreateFlagsInput): Promise { await flushAnalytics(); if (input.json) { - printJson(result); - if (!result.success) { - process.exit(1); + if (!created.success) { + printJson(created); + return exitWhenFlushed(1); + } + + const result = withOutputMode(created, "json"); + const writeResult = envEnabled + ? writeEnvFile(envPath!, result.connectionString, result.claimUrl) + : null; + + // Print the JSON even if the env write failed, so the connection + // string and claim URL aren't lost. + printJson(formatJsonResult(result)); + + if (writeResult && !writeResult.success) { + printError(`Failed to write environment variables to ${envPath}: ${writeResult.error}`); + return exitWhenFlushed(1); } return; } - if (!result.success) { - printError(result.message); + if (!created.success) { + printError(created.message); process.exit(1); } + const result = withOutputMode(created, input.quiet ? "quiet" : "env"); + if (envEnabled) { const writeResult = writeEnvFile(envPath!, result.connectionString, result.claimUrl); if (!writeResult.success) { @@ -172,11 +205,13 @@ export async function handleCreate(input: CreateFlagsInput): Promise { if (!input.quiet) { printSuccess(`Wrote DATABASE_URL and CLAIM_URL to ${envPath}`); + printClaimReminder(result); } } if (input.quiet) { console.log(result.connectionString ?? ""); + printClaimReminder(result, "stderr"); } if (input.copy) { @@ -228,7 +263,7 @@ export async function handleCreate(input: CreateFlagsInput): Promise { const spinner = createSpinner(); spinner.start(region); - const result = await createDatabase( + const created = await createDatabase( region, userAgent, cliRunId, @@ -236,12 +271,14 @@ export async function handleCreate(input: CreateFlagsInput): Promise { ttlMs ); - if (!result.success) { - spinner.error(result.message); + if (!created.success) { + spinner.error(created.message); await flushAnalytics(); process.exit(1); } + const result = withOutputMode(created, "default"); + spinner.success(); printDatabaseResult(result); diff --git a/packages/create-db/src/cli/output.ts b/packages/create-db/src/cli/output.ts index 6cd9313..d7487dc 100644 --- a/packages/create-db/src/cli/output.ts +++ b/packages/create-db/src/cli/output.ts @@ -36,12 +36,24 @@ export function createSpinner() { }; } +/** + * Format the deletion date in local time with the time zone, so the time + * stays unambiguous when it's relayed from a sandbox in another zone. + * @param deletionDate - ISO deletion date from the database result + * @returns The formatted date, e.g. "10/30/2026, 9:02:10 AM UTC" + */ +export function formatDeletionDate(deletionDate: string) { + return new Date(deletionDate).toLocaleString(undefined, { + timeZoneName: "short", + }); +} + /** * Print formatted database creation result to the console. * @param result - The successful database creation result */ export function printDatabaseResult(result: DatabaseResult) { - const expiryFormatted = new Date(result.deletionDate).toLocaleString(); + const expiryFormatted = formatDeletionDate(result.deletionDate); const clickableUrl = terminalLink(result.claimUrl, result.claimUrl, { fallback: false, }); @@ -69,6 +81,60 @@ export function printDatabaseResult(result: DatabaseResult) { ); } +/** How the CLI printed its result; recorded on the claim URL as `utm_content`. */ +export type OutputMode = "default" | "json" | "env" | "quiet"; + +/** + * Tag the claim URL with the output mode, so claim page visits can be + * attributed to the mode that produced them. The existing query string is + * left as is rather than re-serialized, so other params keep their encoding. + * @param result - The successful database creation result + * @param mode - The output mode in use + * @returns The result with a tagged claim URL + */ +export function withOutputMode( + result: DatabaseResult, + mode: OutputMode +): DatabaseResult { + const separator = result.claimUrl.includes("?") ? "&" : "?"; + return { + ...result, + claimUrl: `${result.claimUrl}${separator}utm_content=${mode}`, + }; +} + +/** + * Build the JSON printed by `--json`: the result plus a `nextStep` telling the + * agent or script to pass the claim URL on, since the database is deleted + * unless someone claims it. + * @param result - The successful database creation result + * @returns The JSON payload + */ +export function formatJsonResult(result: DatabaseResult) { + return { + ...result, + nextStep: `The database is deleted at ${result.deletionDate} unless the user claims it, which keeps it for free. Give the user this claim URL: ${result.claimUrl}`, + }; +} + +/** + * Print a one-line claim reminder for the non-interactive modes. + * @param result - The successful database creation result + * @param stream - Where to print; `stderr` keeps stdout clean for `--quiet` + */ +export function printClaimReminder( + result: DatabaseResult, + stream: "stdout" | "stderr" = "stdout" +) { + const expiryFormatted = formatDeletionDate(result.deletionDate); + const line = `Keep your database for free: ${result.claimUrl} (deleted on ${expiryFormatted} if not claimed)`; + if (stream === "stderr") { + console.error(line); + } else { + console.log(line); + } +} + /** * Print data as formatted JSON. * @param data - Data to serialize and print diff --git a/packages/create-db/src/index.ts b/packages/create-db/src/index.ts index e4d788e..87c7cd9 100644 --- a/packages/create-db/src/index.ts +++ b/packages/create-db/src/index.ts @@ -10,6 +10,7 @@ import { CreateFlags, RegionsFlags, } from "./cli/flags.js"; +import { version } from "../package.json"; import { getCommandName } from "./core/database.js"; import { handleCreate, handleRegions } from "./cli/commands/index.js"; import { createDatabase, fetchRegions } from "./core/services.js"; @@ -55,7 +56,7 @@ export function createDbCli() { return createCli({ router, name: getCommandName(), - version: "1.1.0", + version, description: "Instantly create a temporary Prisma Postgres database", }); } diff --git a/packages/create-db/tsconfig.json b/packages/create-db/tsconfig.json index 228bbeb..4f6a8b4 100644 --- a/packages/create-db/tsconfig.json +++ b/packages/create-db/tsconfig.json @@ -4,6 +4,7 @@ "module": "ESNext", "moduleResolution": "bundler", "esModuleInterop": true, + "resolveJsonModule": true, "verbatimModuleSyntax": true, "strict": true, "skipLibCheck": true, diff --git a/packages/create-pg/package.json b/packages/create-pg/package.json index e676fba..8b3f123 100644 --- a/packages/create-pg/package.json +++ b/packages/create-pg/package.json @@ -1,6 +1,6 @@ { "name": "create-pg", - "version": "1.2.0", + "version": "1.3.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/packages/create-postgres/package.json b/packages/create-postgres/package.json index 8849371..e6a6909 100644 --- a/packages/create-postgres/package.json +++ b/packages/create-postgres/package.json @@ -1,6 +1,6 @@ { "name": "create-postgres", - "version": "1.2.0", + "version": "1.3.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": {