diff --git a/create-db/__tests__/cli.test.ts b/create-db/__tests__/cli.test.ts new file mode 100644 index 0000000..a5b0c62 --- /dev/null +++ b/create-db/__tests__/cli.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect } from "vitest"; +import { execa } from "execa"; +import { fileURLToPath } from "url"; +import path from "path"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const CLI_PATH = path.resolve(__dirname, "../dist/cli.mjs"); + +const runCli = async ( + args: string[] = [], + options: { env?: Record; timeout?: number } = {} +) => { + const result = await execa("node", [CLI_PATH, ...args], { + env: { ...process.env, ...options.env }, + reject: false, + timeout: options.timeout ?? 20000, + }); + return { + ...result, + all: result.stdout + result.stderr, + }; +}; + +// ============================================================================ +// UNIT TESTS - No database creation, tests CLI structure and help only +// ============================================================================ + +describe("CLI help and version", () => { + it("displays help with --help flag", async () => { + const result = await runCli(["--help"]); + expect(result.exitCode).toBe(0); + expect(result.all).toContain("create-db"); + expect(result.all).toContain("Create a new Prisma Postgres database"); + expect(result.all).toContain("regions"); + }); + + it("displays help with -h flag", async () => { + const result = await runCli(["-h"]); + expect(result.exitCode).toBe(0); + expect(result.all).toContain("create-db"); + }); + + it("displays version with --version flag", async () => { + const result = await runCli(["--version"]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toMatch(/\d+\.\d+\.\d+/); + }); + + it("displays version with -V flag", async () => { + const result = await runCli(["-V"]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toMatch(/\d+\.\d+\.\d+/); + }); + + it("displays create command help", async () => { + const result = await runCli(["create", "--help"]); + expect(result.exitCode).toBe(0); + expect(result.all).toContain("--region"); + expect(result.all).toContain("--interactive"); + expect(result.all).toContain("--json"); + expect(result.all).toContain("--env"); + }); + + it("displays regions command help", async () => { + const result = await runCli(["regions", "--help"]); + expect(result.exitCode).toBe(0); + expect(result.all).toContain("List available Prisma Postgres regions"); + }); +}); + +describe("CLI error handling", () => { + it("fails with invalid region (no DB created)", async () => { + const result = await runCli(["--region", "invalid-region"]); + expect(result.exitCode).not.toBe(0); + }, 10000); + + it("shows error for unknown command", async () => { + const result = await runCli(["unknown-command"]); + expect(result.exitCode).not.toBe(0); + }); + + it("shows error for unknown flag", async () => { + const result = await runCli(["--unknown-flag"]); + expect(result.exitCode).not.toBe(0); + }); +}); diff --git a/create-db/__tests__/flags.test.ts b/create-db/__tests__/flags.test.ts new file mode 100644 index 0000000..8071562 --- /dev/null +++ b/create-db/__tests__/flags.test.ts @@ -0,0 +1,221 @@ +import { describe, it, expect } from "vitest"; +import { CreateFlags, type CreateFlagsInput } from "../src/cli/flags.js"; +import { RegionSchema } from "../src/types.js"; + +describe("CreateFlags schema", () => { + describe("region field", () => { + it("accepts valid region IDs", () => { + const validRegions = [ + "us-east-1", + "us-west-1", + "eu-central-1", + "eu-west-3", + "ap-southeast-1", + "ap-northeast-1", + ]; + + for (const region of validRegions) { + const result = CreateFlags.safeParse({ region }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.region).toBe(region); + } + } + }); + + it("rejects invalid region IDs", () => { + const result = CreateFlags.safeParse({ region: "invalid-region" }); + expect(result.success).toBe(false); + }); + + it("allows undefined region", () => { + const result = CreateFlags.safeParse({}); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.region).toBeUndefined(); + } + }); + }); + + describe("interactive field", () => { + it("defaults to false", () => { + const result = CreateFlags.safeParse({}); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.interactive).toBe(false); + } + }); + + it("accepts true", () => { + const result = CreateFlags.safeParse({ interactive: true }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.interactive).toBe(true); + } + }); + + it("accepts false", () => { + const result = CreateFlags.safeParse({ interactive: false }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.interactive).toBe(false); + } + }); + }); + + describe("json field", () => { + it("defaults to false", () => { + const result = CreateFlags.safeParse({}); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.json).toBe(false); + } + }); + + it("accepts true", () => { + const result = CreateFlags.safeParse({ json: true }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.json).toBe(true); + } + }); + }); + + describe("env field", () => { + it("accepts string path", () => { + const result = CreateFlags.safeParse({ env: ".env" }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.env).toBe(".env"); + } + }); + + it("accepts full path", () => { + const result = CreateFlags.safeParse({ env: "/path/to/.env.local" }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.env).toBe("/path/to/.env.local"); + } + }); + + it("allows undefined", () => { + const result = CreateFlags.safeParse({}); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.env).toBeUndefined(); + } + }); + }); + + describe("userAgent field", () => { + it("accepts custom user agent string", () => { + const result = CreateFlags.safeParse({ userAgent: "myapp/1.0.0" }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.userAgent).toBe("myapp/1.0.0"); + } + }); + + it("allows undefined", () => { + const result = CreateFlags.safeParse({}); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.userAgent).toBeUndefined(); + } + }); + }); + + describe("combined fields", () => { + it("parses all fields together", () => { + const input = { + region: "eu-central-1", + interactive: true, + json: false, + env: ".env.local", + userAgent: "test/2.0.0", + }; + + const result = CreateFlags.safeParse(input); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toEqual({ + region: "eu-central-1", + interactive: true, + json: false, + env: ".env.local", + userAgent: "test/2.0.0", + }); + } + }); + + it("applies defaults for missing optional fields", () => { + const result = CreateFlags.safeParse({ region: "us-east-1" }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.region).toBe("us-east-1"); + expect(result.data.interactive).toBe(false); + expect(result.data.json).toBe(false); + expect(result.data.env).toBeUndefined(); + expect(result.data.userAgent).toBeUndefined(); + } + }); + }); + + describe("type inference", () => { + it("CreateFlagsInput type matches schema output", () => { + const input: CreateFlagsInput = { + region: "us-east-1", + interactive: false, + json: true, + env: ".env", + 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); + }); + }); +}); + +describe("RegionSchema", () => { + it("validates all supported regions", () => { + const regions = [ + "us-east-1", + "us-west-1", + "eu-central-1", + "eu-west-3", + "ap-southeast-1", + "ap-northeast-1", + ]; + + for (const region of regions) { + expect(RegionSchema.safeParse(region).success).toBe(true); + } + }); + + it("rejects unsupported regions", () => { + const invalidRegions = [ + "us-east-2", + "eu-west-1", + "ap-south-1", + "sa-east-1", + "", + "invalid", + ]; + + for (const region of invalidRegions) { + expect(RegionSchema.safeParse(region).success).toBe(false); + } + }); + + it("rejects non-string values", () => { + expect(RegionSchema.safeParse(123).success).toBe(false); + expect(RegionSchema.safeParse(null).success).toBe(false); + expect(RegionSchema.safeParse(undefined).success).toBe(false); + expect(RegionSchema.safeParse({}).success).toBe(false); + }); +}); diff --git a/create-db/__tests__/output.test.ts b/create-db/__tests__/output.test.ts new file mode 100644 index 0000000..b03f3a1 --- /dev/null +++ b/create-db/__tests__/output.test.ts @@ -0,0 +1,144 @@ +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"; + +describe("writeEnvFile", () => { + let tempDir: string; + let envFilePath: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "output-test-")); + envFilePath = path.join(tempDir, ".env"); + }); + + afterEach(() => { + if (fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it("creates new env file with DATABASE_URL and CLAIM_URL", () => { + const result = writeEnvFile( + envFilePath, + "postgresql://user:pass@host:5432/db", + "https://example.com/claim" + ); + + expect(result.success).toBe(true); + expect(fs.existsSync(envFilePath)).toBe(true); + + const content = fs.readFileSync(envFilePath, "utf8"); + expect(content).toContain('DATABASE_URL="postgresql://user:pass@host:5432/db"'); + expect(content).toContain('CLAIM_URL="https://example.com/claim"'); + }); + + it("handles null connectionString", () => { + const result = writeEnvFile(envFilePath, null, "https://example.com/claim"); + + expect(result.success).toBe(true); + + const content = fs.readFileSync(envFilePath, "utf8"); + expect(content).toContain('DATABASE_URL=""'); + expect(content).toContain('CLAIM_URL="https://example.com/claim"'); + }); + + it("appends to existing file with trailing newline", () => { + fs.writeFileSync(envFilePath, "EXISTING=value\n"); + + const result = writeEnvFile( + envFilePath, + "postgresql://test", + "https://claim" + ); + + expect(result.success).toBe(true); + + const content = fs.readFileSync(envFilePath, "utf8"); + expect(content).toBe( + 'EXISTING=value\nDATABASE_URL="postgresql://test"\nCLAIM_URL="https://claim"\n' + ); + }); + + it("appends newline prefix when existing file lacks trailing newline", () => { + fs.writeFileSync(envFilePath, "EXISTING=value"); + + const result = writeEnvFile( + envFilePath, + "postgresql://test", + "https://claim" + ); + + expect(result.success).toBe(true); + + const content = fs.readFileSync(envFilePath, "utf8"); + expect(content).toBe( + 'EXISTING=value\nDATABASE_URL="postgresql://test"\nCLAIM_URL="https://claim"\n' + ); + }); + + it("handles empty existing file", () => { + fs.writeFileSync(envFilePath, ""); + + const result = writeEnvFile( + envFilePath, + "postgresql://test", + "https://claim" + ); + + expect(result.success).toBe(true); + + const content = fs.readFileSync(envFilePath, "utf8"); + expect(content).toBe('DATABASE_URL="postgresql://test"\nCLAIM_URL="https://claim"\n'); + }); + + it("returns error for invalid path", () => { + const invalidPath = path.join(tempDir, "nonexistent", "deep", "path", ".env"); + + const result = writeEnvFile( + invalidPath, + "postgresql://test", + "https://claim" + ); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error).toBeTruthy(); + expect(typeof result.error).toBe("string"); + } + }); + + it.skipIf(process.platform === "win32")("returns error for read-only directory", () => { + const readOnlyDir = path.join(tempDir, "readonly"); + fs.mkdirSync(readOnlyDir); + fs.chmodSync(readOnlyDir, 0o444); + + const readOnlyPath = path.join(readOnlyDir, ".env"); + const result = writeEnvFile( + readOnlyPath, + "postgresql://test", + "https://claim" + ); + + // Restore permissions for cleanup + fs.chmodSync(readOnlyDir, 0o755); + + expect(result.success).toBe(false); + }); + + it("preserves special characters in connection string", () => { + const connectionString = "postgresql://user:p@ss=word!@host:5432/db?ssl=true"; + + const result = writeEnvFile( + envFilePath, + connectionString, + "https://claim" + ); + + expect(result.success).toBe(true); + + const content = fs.readFileSync(envFilePath, "utf8"); + expect(content).toContain(`DATABASE_URL="${connectionString}"`); + }); +}); diff --git a/create-db/__tests__/services.test.ts b/create-db/__tests__/services.test.ts new file mode 100644 index 0000000..60f9ebc --- /dev/null +++ b/create-db/__tests__/services.test.ts @@ -0,0 +1,33 @@ +import { describe, it, expect } from "vitest"; +import { fetchRegions } from "../src/core/services.js"; + +// These tests hit the real API but do NOT create databases +// Set RUN_INTEGRATION_TESTS=true to run these tests + +const runIntegration = process.env.RUN_INTEGRATION_TESTS === "true"; + +describe.skipIf(!runIntegration)("services (integration)", () => { + describe("fetchRegions", () => { + it("returns an array of regions with required properties", async () => { + const regions = await fetchRegions(); + + expect(Array.isArray(regions)).toBe(true); + expect(regions.length).toBeGreaterThan(0); + + // Check structure + for (const region of regions) { + expect(region).toHaveProperty("id"); + expect(region).toHaveProperty("status"); + expect(typeof region.id).toBe("string"); + expect(typeof region.status).toBe("string"); + } + + // Optionally check for a specific region if EXPECTED_REGION is set + const expectedRegion = process.env.EXPECTED_REGION; + if (expectedRegion) { + const regionIds = regions.map((r) => r.id); + expect(regionIds).toContain(expectedRegion); + } + }, 10000); + }); +}); diff --git a/create-db/package.json b/create-db/package.json index 6f03c7c..b934eb2 100644 --- a/create-db/package.json +++ b/create-db/package.json @@ -1,6 +1,6 @@ { "name": "create-db", - "version": "1.1.3", + "version": "1.1.4", "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": { @@ -41,6 +41,7 @@ "build": "tsdown", "dev": "tsdown --watch", "typecheck": "tsc --noEmit", + "pretest": "npm run build", "test": "vitest run --reporter=verbose", "test:watch": "vitest watch", "test:coverage": "vitest run --coverage", diff --git a/create-db/src/cli/commands/create.ts b/create-db/src/cli/commands/create.ts new file mode 100644 index 0000000..eae4e8b --- /dev/null +++ b/create-db/src/cli/commands/create.ts @@ -0,0 +1,181 @@ +import { select, isCancel } from "@clack/prompts"; +import { randomUUID } from "crypto"; + +import type { CreateFlagsInput } from "../flags.js"; +import type { RegionId } from "../../types.js"; +import { getCommandName } from "../../core/database.js"; +import { readUserEnvFile } from "../../utils/env-utils.js"; +import { detectUserLocation, getRegionClosestToLocation } from "../../utils/geolocation.js"; +import { + sendAnalyticsEvent, + flushAnalytics, + ensureOnline, + fetchRegions, + validateRegionId, + createDatabase, +} from "../../core/services.js"; +import { + showIntro, + showOutro, + showCancelled, + createSpinner, + printDatabaseResult, + printJson, + printError, + printSuccess, + writeEnvFile, +} from "../output.js"; + +export async function handleCreate(input: CreateFlagsInput): Promise { + const cliRunId = randomUUID(); + const CLI_NAME = getCommandName(); + + let userAgent: string | undefined = input.userAgent; + if (!userAgent) { + const userEnvVars = readUserEnvFile(); + if (userEnvVars.PRISMA_ACTOR_NAME && userEnvVars.PRISMA_ACTOR_PROJECT) { + userAgent = `${userEnvVars.PRISMA_ACTOR_NAME}/${userEnvVars.PRISMA_ACTOR_PROJECT}`; + } + } + + void sendAnalyticsEvent( + "create_db:cli_command_ran", + { + command: CLI_NAME, + "has-region-flag": !!input.region, + "has-interactive-flag": input.interactive, + "has-json-flag": input.json, + "has-env-flag": !!input.env, + "user-agent": userAgent || undefined, + "node-version": process.version, + platform: process.platform, + arch: process.arch, + }, + cliRunId + ); + + let region: RegionId = input.region ?? "us-east-1"; + + if (!input.region) { + const userLocation = await detectUserLocation(); + region = getRegionClosestToLocation(userLocation) ?? region; + } + + const envPath = input.env; + const envEnabled = + typeof envPath === "string" && envPath.trim().length > 0; + + if (input.json || envEnabled) { + if (input.interactive) { + await ensureOnline(); + const regions = await fetchRegions(); + + const selectedRegion = await select({ + message: "Choose a region:", + options: regions.map((r) => ({ + value: r.id, + label: r.name || r.id, + })), + initialValue: + regions.find((r) => r.id === region)?.id || regions[0]?.id, + }); + + if (isCancel(selectedRegion)) { + showCancelled(); + await flushAnalytics(); + process.exit(0); + } + + region = selectedRegion as RegionId; + void sendAnalyticsEvent( + "create_db:region_selected", + { region, "selection-method": "interactive" }, + cliRunId + ); + } else if (input.region) { + await validateRegionId(region); + void sendAnalyticsEvent( + "create_db:region_selected", + { region, "selection-method": "flag" }, + cliRunId + ); + } + + await ensureOnline(); + const result = await createDatabase(region, userAgent, cliRunId); + await flushAnalytics(); + + if (input.json) { + printJson(result); + if (!result.success) { + process.exit(1); + } + return; + } + + if (!result.success) { + printError(result.message); + 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); + } + + printSuccess(`Wrote DATABASE_URL and CLAIM_URL to ${envPath}`); + return; + } + + await ensureOnline(); + + showIntro(); + + if (input.interactive) { + const regions = await fetchRegions(); + + const selectedRegion = await select({ + message: "Choose a region:", + options: regions.map((r) => ({ value: r.id, label: r.name || r.id })), + initialValue: + regions.find((r) => r.id === region)?.id || regions[0]?.id, + }); + + if (isCancel(selectedRegion)) { + showCancelled(); + await flushAnalytics(); + process.exit(0); + } + + region = selectedRegion as RegionId; + void sendAnalyticsEvent( + "create_db:region_selected", + { region, "selection-method": "interactive" }, + cliRunId + ); + } else if (input.region) { + await validateRegionId(region); + void sendAnalyticsEvent( + "create_db:region_selected", + { region, "selection-method": "flag" }, + cliRunId + ); + } + + const spinner = createSpinner(); + spinner.start(region); + + const result = await createDatabase(region, userAgent, cliRunId); + + if (!result.success) { + spinner.error(result.message); + await flushAnalytics(); + process.exit(1); + } + + spinner.success(); + printDatabaseResult(result); + showOutro(); + await flushAnalytics(); +} diff --git a/create-db/src/cli/commands/index.ts b/create-db/src/cli/commands/index.ts new file mode 100644 index 0000000..ba74b30 --- /dev/null +++ b/create-db/src/cli/commands/index.ts @@ -0,0 +1,2 @@ +export { handleCreate } from "./create.js"; +export { handleRegions } from "./regions.js"; diff --git a/create-db/src/cli/commands/regions.ts b/create-db/src/cli/commands/regions.ts new file mode 100644 index 0000000..2049148 --- /dev/null +++ b/create-db/src/cli/commands/regions.ts @@ -0,0 +1,16 @@ +import { log } from "@clack/prompts"; +import pc from "picocolors"; + +import { fetchRegions } from "../../core/services.js"; + +export async function handleRegions(): Promise { + const regions = await fetchRegions(); + + log.message(""); + log.info(pc.bold(pc.cyan("Available Prisma Postgres regions:"))); + log.message(""); + for (const r of regions) { + log.message(` ${pc.green(r.id)} - ${r.name || r.id}`); + } + log.message(""); +} diff --git a/create-db/src/cli/flags.ts b/create-db/src/cli/flags.ts new file mode 100644 index 0000000..356fca5 --- /dev/null +++ b/create-db/src/cli/flags.ts @@ -0,0 +1,38 @@ +import { z } from "zod"; +import { RegionSchema } from "../types.js"; + +/** + * Zod schema for CLI flags used by the `create` command. + */ +export const CreateFlags = z.object({ + region: RegionSchema.optional() + .describe("AWS region for the database") + .meta({ alias: "r" }), + interactive: z + .boolean() + .optional() + .default(false) + .describe("Run in interactive mode to select a region") + .meta({ alias: "i" }), + json: z + .boolean() + .optional() + .default(false) + .describe("Output machine-readable JSON") + .meta({ alias: "j" }), + env: z + .string() + .optional() + .describe("Write DATABASE_URL and CLAIM_URL to the specified .env file") + .meta({ alias: "e" }), + userAgent: z + .string() + .optional() + .describe("Custom user agent string (e.g. 'test/test')") + .meta({ alias: "u" }), +}); + +/** Inferred type from CreateFlags schema. */ +export type CreateFlagsInput = z.infer; + +// GitHub issue to suppress the Alias in the help text: https://github.com/mmkal/trpc-cli/issues/154 \ No newline at end of file diff --git a/create-db/src/cli/output.ts b/create-db/src/cli/output.ts new file mode 100644 index 0000000..a8494e1 --- /dev/null +++ b/create-db/src/cli/output.ts @@ -0,0 +1,128 @@ +import { intro, outro, cancel, log, spinner as clackSpinner } from "@clack/prompts"; +import fs from "fs"; +import pc from "picocolors"; +import terminalLink from "terminal-link"; + +import type { DatabaseResult } from "../types.js"; + +/** Display the CLI intro message. */ +export function showIntro() { + intro(pc.bold(pc.cyan("🚀 Creating a Prisma Postgres database"))); +} + +/** Display the CLI outro message. */ +export function showOutro() { + outro(pc.dim("Done!")); +} + +/** Display a cancellation message. */ +export function showCancelled() { + cancel(pc.red("Operation cancelled.")); +} + +/** + * Create a spinner for database creation progress. + * @returns An object with start, success, and error methods + */ +export function createSpinner() { + const s = clackSpinner(); + return { + start: (region: string) => s.start(`Creating database in ${pc.cyan(region)}...`), + success: () => s.stop(pc.green("Database created successfully!")), + error: (message: string) => s.stop(pc.red(`Error: ${message}`)), + }; +} + +/** + * 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 clickableUrl = terminalLink(result.claimUrl, result.claimUrl, { + fallback: false, + }); + + log.message(""); + log.info(pc.bold("Database Connection")); + log.message(""); + + if (result.connectionString) { + log.message(pc.cyan(" Connection String:")); + log.message(" " + pc.yellow(result.connectionString)); + log.message(""); + } else { + log.warning(pc.yellow(" Connection details are not available.")); + log.message(""); + } + + log.success(pc.bold("Claim Your Database")); + log.message(pc.cyan(" Keep your database for free:")); + log.message(" " + pc.yellow(clickableUrl)); + log.message( + pc.italic( + pc.dim(` Database will be deleted on ${expiryFormatted} if not claimed.`) + ) + ); +} + +/** + * Print data as formatted JSON. + * @param data - Data to serialize and print + */ +export function printJson(data: unknown) { + console.log(JSON.stringify(data, null, 2)); +} + +/** + * Print an error message in red. + * @param message - Error message to display + */ +export function printError(message: string) { + console.error(pc.red(message)); +} + +/** + * Print a success message in green. + * @param message - Success message to display + */ +export function printSuccess(message: string) { + console.log(pc.green(message)); +} + +/** + * Write database credentials to an env file. + * @param envPath - Path to the env file + * @param connectionString - Database connection string + * @param claimUrl - URL to claim the database + * @returns Success or failure with error message + */ +export function writeEnvFile( + envPath: string, + connectionString: string | null, + claimUrl: string +): { success: true } | { success: false; error: string } { + try { + const lines = [ + `DATABASE_URL="${connectionString ?? ""}"`, + `CLAIM_URL="${claimUrl}"`, + "", + ]; + + let prefix = ""; + if (fs.existsSync(envPath)) { + const existing = fs.readFileSync(envPath, "utf8"); + if (existing.length > 0 && !existing.endsWith("\n")) { + prefix = "\n"; + } + } + + fs.appendFileSync(envPath, prefix + lines.join("\n"), { encoding: "utf8" }); + return { success: true }; + } catch (err) { + return { + success: false, + error: err instanceof Error ? err.message : String(err), + }; + } +} diff --git a/create-db/src/database.ts b/create-db/src/core/database.ts similarity index 97% rename from create-db/src/database.ts rename to create-db/src/core/database.ts index 904b1ee..ab6263f 100644 --- a/create-db/src/database.ts +++ b/create-db/src/core/database.ts @@ -1,6 +1,6 @@ import { randomUUID } from "crypto"; -import type { CreateDatabaseResult, ApiResponse } from "./types.js"; -import { sendAnalytics } from "./analytics.js"; +import type { CreateDatabaseResult, ApiResponse } from "../types.js"; +import { sendAnalytics } from "../utils/analytics.js"; export function getCommandName(): string { const executable = process.argv[1] || "create-db"; diff --git a/create-db/src/regions.ts b/create-db/src/core/regions.ts similarity index 95% rename from create-db/src/regions.ts rename to create-db/src/core/regions.ts index d781a86..24d1efa 100644 --- a/create-db/src/regions.ts +++ b/create-db/src/core/regions.ts @@ -1,5 +1,5 @@ import pc from "picocolors"; -import type { Region, RegionsResponse } from "./types.js"; +import type { Region, RegionsResponse } from "../types.js"; export async function checkOnline(workerUrl: string): Promise { try { diff --git a/create-db/src/core/services.ts b/create-db/src/core/services.ts new file mode 100644 index 0000000..5889ec8 --- /dev/null +++ b/create-db/src/core/services.ts @@ -0,0 +1,80 @@ +import dotenv from "dotenv"; +import { sendAnalytics, flushAnalytics } from "../utils/analytics.js"; +import { createDatabaseCore } from "./database.js"; +import { checkOnline, getRegions, validateRegion } from "./regions.js"; + +dotenv.config({ quiet: true }); + +const CREATE_DB_WORKER_URL = + process.env.CREATE_DB_WORKER_URL || "https://create-db-temp.prisma.io"; +const CLAIM_DB_WORKER_URL = + process.env.CLAIM_DB_WORKER_URL || "https://create-db.prisma.io"; + +export { flushAnalytics }; + +/** + * Send an analytics event to the create-db worker. + * @param eventName - Name of the event to track + * @param properties - Event properties + * @param cliRunId - Unique identifier for this CLI run + */ +export function sendAnalyticsEvent( + eventName: string, + properties: Record, + cliRunId: string +) { + return sendAnalytics(eventName, properties, cliRunId, CREATE_DB_WORKER_URL); +} + +/** + * Check if the create-db worker is online. Exits the process if offline. + */ +export async function ensureOnline() { + try { + await checkOnline(CREATE_DB_WORKER_URL); + } catch { + await flushAnalytics(); + process.exit(1); + } +} + +/** + * Fetch available Prisma Postgres regions from the worker. + * @returns A promise resolving to an array of available regions + */ +export function fetchRegions() { + return getRegions(CREATE_DB_WORKER_URL); +} + +/** + * Validate that a region ID is valid. + * @param region - The region ID to validate + * @throws If the region is invalid + */ +export function validateRegionId(region: string) { + return validateRegion(region, CREATE_DB_WORKER_URL); +} + +/** + * Create a new Prisma Postgres database. + * @param region - AWS region for the database + * @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 + * @returns A promise resolving to the database creation result + */ +export function createDatabase( + region: string, + userAgent?: string, + cliRunId?: string, + source?: "programmatic" | "cli" +) { + return createDatabaseCore( + region, + CREATE_DB_WORKER_URL, + CLAIM_DB_WORKER_URL, + userAgent, + cliRunId, + source + ); +} diff --git a/create-db/src/index.ts b/create-db/src/index.ts index baed48d..0214b14 100644 --- a/create-db/src/index.ts +++ b/create-db/src/index.ts @@ -1,37 +1,15 @@ -import { - intro, - outro, - cancel, - select, - spinner, - log, - isCancel, -} from "@clack/prompts"; -import { createRouterClient, os } from "@orpc/server"; -import { randomUUID } from "crypto"; -import dotenv from "dotenv"; -import fs from "fs"; -import pc from "picocolors"; -import terminalLink from "terminal-link"; +import { os } from "@orpc/server"; import { createCli } from "trpc-cli"; -import { z } from "zod"; import { type Region, type CreateDatabaseResult, - type DatabaseResult, type ProgrammaticCreateOptions, - type RegionId, - RegionSchema, } from "./types.js"; -import { sendAnalytics, flushAnalytics } from "./analytics.js"; -import { createDatabaseCore, getCommandName } from "./database.js"; -import { readUserEnvFile } from "./env-utils.js"; -import { - detectUserLocation, - getRegionClosestToLocation, -} from "./geolocation.js"; -import { checkOnline, getRegions, validateRegion } from "./regions.js"; +import { CreateFlags } from "./cli/flags.js"; +import { getCommandName } from "./core/database.js"; +import { handleCreate, handleRegions } from "./cli/commands/index.js"; +import { createDatabase, fetchRegions } from "./core/services.js"; export type { Region, @@ -43,51 +21,7 @@ export type { } from "./types.js"; export { isDatabaseError, isDatabaseSuccess, RegionSchema } from "./types.js"; - -dotenv.config({ - quiet: true, -}); - -const CREATE_DB_WORKER_URL = - process.env.CREATE_DB_WORKER_URL || "https://create-db-temp.prisma.io"; -const CLAIM_DB_WORKER_URL = - process.env.CLAIM_DB_WORKER_URL || "https://create-db.prisma.io"; - -// Wrapper functions that include worker URLs -const sendAnalyticsWithUrl = ( - eventName: string, - properties: Record, - cliRunId: string -) => sendAnalytics(eventName, properties, cliRunId, CREATE_DB_WORKER_URL); - -const checkOnlineWithUrl = async () => { - try { - await checkOnline(CREATE_DB_WORKER_URL); - } catch { - await flushAnalytics(); - process.exit(1); - } -}; - -const getRegionsWithUrl = () => getRegions(CREATE_DB_WORKER_URL); - -const validateRegionWithUrl = (region: string) => - validateRegion(region, CREATE_DB_WORKER_URL); - -const createDatabaseCoreWithUrl = ( - region: string, - userAgent?: string, - cliRunId?: string, - source?: "programmatic" | "cli" -) => - createDatabaseCore( - region, - CREATE_DB_WORKER_URL, - CLAIM_DB_WORKER_URL, - userAgent, - cliRunId, - source - ); +export { CreateFlags, type CreateFlagsInput } from "./cli/flags.js"; const router = os.router({ create: os @@ -95,267 +29,18 @@ const router = os.router({ description: "Create a new Prisma Postgres database", default: true, }) - .input( - z.object({ - region: RegionSchema.optional() - .describe("AWS region for the database") - .meta({ alias: "r" }), - interactive: z - .boolean() - .optional() - .default(false) - .describe("Run in interactive mode to select a region") - .meta({ alias: "i" }), - json: z - .boolean() - .optional() - .default(false) - .describe("Output machine-readable JSON") - .meta({ alias: "j" }), - env: z - .string() - .optional() - .describe( - "Write DATABASE_URL and CLAIM_URL to the specified .env file" - ) - .meta({ alias: "e" }), - userAgent: z - .string() - .optional() - .describe("Custom user agent string (e.g. 'test/test')") - .meta({ alias: "u" }), - }) - ) - .handler(async ({ input }) => { - const cliRunId = randomUUID(); - const CLI_NAME = getCommandName(); - - let userAgent: string | undefined = input.userAgent; - if (!userAgent) { - const userEnvVars = readUserEnvFile(); - if (userEnvVars.PRISMA_ACTOR_NAME && userEnvVars.PRISMA_ACTOR_PROJECT) { - userAgent = `${userEnvVars.PRISMA_ACTOR_NAME}/${userEnvVars.PRISMA_ACTOR_PROJECT}`; - } - } - - void sendAnalyticsWithUrl( - "create_db:cli_command_ran", - { - command: CLI_NAME, - "has-region-flag": !!input.region, - "has-interactive-flag": input.interactive, - "has-json-flag": input.json, - "has-env-flag": !!input.env, - "user-agent": userAgent || undefined, - "node-version": process.version, - platform: process.platform, - arch: process.arch, - }, - cliRunId - ); - - let region: RegionId = input.region ?? "us-east-1"; - - if (!input.region) { - const userLocation = await detectUserLocation(); - region = getRegionClosestToLocation(userLocation) ?? region; - } - - const envPath = input.env; - const envEnabled = - typeof envPath === "string" && envPath.trim().length > 0; - - if (input.json || envEnabled) { - if (input.interactive) { - await checkOnlineWithUrl(); - const regions = await getRegionsWithUrl(); - - const selectedRegion = await select({ - message: "Choose a region:", - options: regions.map((r) => ({ - value: r.id, - label: r.name || r.id, - })), - initialValue: - regions.find((r) => r.id === region)?.id || regions[0]?.id, - }); - - if (isCancel(selectedRegion)) { - cancel(pc.red("Operation cancelled.")); - await flushAnalytics(); - process.exit(0); - } - - region = selectedRegion as RegionId; - void sendAnalyticsWithUrl( - "create_db:region_selected", - { region, "selection-method": "interactive" }, - cliRunId - ); - } else if (input.region) { - await validateRegionWithUrl(region); - void sendAnalyticsWithUrl( - "create_db:region_selected", - { region, "selection-method": "flag" }, - cliRunId - ); - } - - await checkOnlineWithUrl(); - const result = await createDatabaseCoreWithUrl( - region, - userAgent, - cliRunId - ); - await flushAnalytics(); - - if (input.json) { - console.log(JSON.stringify(result, null, 2)); - return; - } - - if (!result.success) { - console.error(result.message); - process.exit(1); - } - - try { - const targetEnvPath = envPath!; - const lines = [ - `DATABASE_URL="${result.connectionString ?? ""}"`, - `CLAIM_URL="${result.claimUrl}"`, - "", - ]; - - let prefix = ""; - if (fs.existsSync(targetEnvPath)) { - const existing = fs.readFileSync(targetEnvPath, "utf8"); - if (existing.length > 0 && !existing.endsWith("\n")) { - prefix = "\n"; - } - } - - fs.appendFileSync(targetEnvPath, prefix + lines.join("\n"), { - encoding: "utf8", - }); - - console.log( - pc.green(`Wrote DATABASE_URL and CLAIM_URL to ${targetEnvPath}`) - ); - } catch (err) { - console.error( - pc.red( - `Failed to write environment variables to ${envPath}: ${ - err instanceof Error ? err.message : String(err) - }` - ) - ); - process.exit(1); - } - - return; - } - - await checkOnlineWithUrl(); - - intro(pc.bold(pc.cyan("🚀 Creating a Prisma Postgres database"))); - - if (input.interactive) { - const regions = await getRegionsWithUrl(); - - const selectedRegion = await select({ - message: "Choose a region:", - options: regions.map((r) => ({ value: r.id, label: r.name || r.id })), - initialValue: - regions.find((r) => r.id === region)?.id || regions[0]?.id, - }); - - if (isCancel(selectedRegion)) { - cancel(pc.red("Operation cancelled.")); - await flushAnalytics(); - process.exit(0); - } - - region = selectedRegion as RegionId; - void sendAnalyticsWithUrl( - "create_db:region_selected", - { region, "selection-method": "interactive" }, - cliRunId - ); - } else if (input.region) { - await validateRegionWithUrl(region); - void sendAnalyticsWithUrl( - "create_db:region_selected", - { region, "selection-method": "flag" }, - cliRunId - ); - } - - const s = spinner(); - s.start(`Creating database in ${pc.cyan(region)}...`); - - const result = await createDatabaseCoreWithUrl( - region, - userAgent, - cliRunId - ); - - if (!result.success) { - s.stop(pc.red(`Error: ${result.message}`)); - await flushAnalytics(); - process.exit(1); - } - - s.stop(pc.green("Database created successfully!")); - - const expiryFormatted = new Date(result.deletionDate).toLocaleString(); - const clickableUrl = terminalLink(result.claimUrl, result.claimUrl, { - fallback: false, - }); - - log.message(""); - log.info(pc.bold("Database Connection")); - log.message(""); - - if (result.connectionString) { - log.message(pc.cyan(" Connection String:")); - log.message(" " + pc.yellow(result.connectionString)); - log.message(""); - } else { - log.warning(pc.yellow(" Connection details are not available.")); - log.message(""); - } - - log.success(pc.bold("Claim Your Database")); - log.message(pc.cyan(" Keep your database for free:")); - log.message(" " + pc.yellow(clickableUrl)); - log.message( - pc.italic( - pc.dim( - ` Database will be deleted on ${expiryFormatted} if not claimed.` - ) - ) - ); - - outro(pc.dim("Done!")); - await flushAnalytics(); - }), + .input(CreateFlags) + .handler(async ({ input }) => handleCreate(input)), regions: os .meta({ description: "List available Prisma Postgres regions" }) - .handler(async (): Promise => { - const regions = await getRegionsWithUrl(); - - log.message(""); - log.info(pc.bold(pc.cyan("Available Prisma Postgres regions:"))); - log.message(""); - for (const r of regions) { - log.message(` ${pc.green(r.id)} - ${r.name || r.id}`); - } - log.message(""); - }), + .handler(async () => handleRegions()), }); +/** + * Create and return the CLI instance for create-db. + * @returns The configured CLI instance + */ export function createDbCli() { return createCli({ router, @@ -365,34 +50,22 @@ export function createDbCli() { }); } -const caller = createRouterClient(router, { context: {} }); - /** * Create a new Prisma Postgres database programmatically. - * * @param options - Options for creating the database - * @param options.region - The AWS region for the database (optional) - * @param options.userAgent - Custom user agent string (optional) - * @returns A promise that resolves to either a {@link DatabaseResult} or {@link DatabaseError} - * + * @returns A promise resolving to either a DatabaseResult or DatabaseError * @example * ```typescript - * import { create } from "create-db"; - * * const result = await create({ region: "us-east-1" }); - * * if (result.success) { - * console.log(`Connection string: ${result.connectionString}`); - * console.log(`Claim URL: ${result.claimUrl}`); - * } else { - * console.error(`Error: ${result.message}`); + * console.log(result.connectionString); * } * ``` */ export async function create( options?: ProgrammaticCreateOptions ): Promise { - return createDatabaseCoreWithUrl( + return createDatabase( options?.region || "us-east-1", options?.userAgent, undefined, @@ -402,15 +75,13 @@ export async function create( /** * List available Prisma Postgres regions programmatically. - * + * @returns A promise resolving to an array of available regions * @example * ```typescript - * import { regions } from "create-db"; - * * const availableRegions = await regions(); * console.log(availableRegions); * ``` */ export async function regions(): Promise { - return getRegionsWithUrl(); + return fetchRegions(); } diff --git a/create-db/src/analytics.ts b/create-db/src/utils/analytics.ts similarity index 100% rename from create-db/src/analytics.ts rename to create-db/src/utils/analytics.ts diff --git a/create-db/src/env-utils.ts b/create-db/src/utils/env-utils.ts similarity index 100% rename from create-db/src/env-utils.ts rename to create-db/src/utils/env-utils.ts diff --git a/create-db/src/geolocation.ts b/create-db/src/utils/geolocation.ts similarity index 99% rename from create-db/src/geolocation.ts rename to create-db/src/utils/geolocation.ts index f358d4d..32d751b 100644 --- a/create-db/src/geolocation.ts +++ b/create-db/src/utils/geolocation.ts @@ -3,7 +3,7 @@ import type { RegionId, RegionCoordinates, GeoLocationResponse, -} from "./types.js"; +} from "../types.js"; // Test locations for geolocation testing // Set TEST_LOCATION to one of these to simulate being in that location diff --git a/create-pg/package.json b/create-pg/package.json index 8ffec8b..ba686d6 100644 --- a/create-pg/package.json +++ b/create-pg/package.json @@ -1,6 +1,6 @@ { "name": "create-pg", - "version": "1.1.3", + "version": "1.1.4", "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 ec05ef9..123db43 100644 --- a/create-postgres/package.json +++ b/create-postgres/package.json @@ -1,6 +1,6 @@ { "name": "create-postgres", - "version": "1.1.3", + "version": "1.1.4", "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": {