From 3d7e8af7286ca4957ec34284bd8db06582b83830 Mon Sep 17 00:00:00 2001 From: Aidan McAlister Date: Thu, 15 Jan 2026 14:02:37 -0500 Subject: [PATCH 1/9] chore: flags destructured into `flags.ts` --- create-db/src/flags.ts | 32 ++++++++++++++++++++++++++++++++ create-db/src/index.ts | 36 +++--------------------------------- 2 files changed, 35 insertions(+), 33 deletions(-) create mode 100644 create-db/src/flags.ts diff --git a/create-db/src/flags.ts b/create-db/src/flags.ts new file mode 100644 index 0000000..5c390c8 --- /dev/null +++ b/create-db/src/flags.ts @@ -0,0 +1,32 @@ +import { z } from "zod"; +import { RegionSchema } from "./types.js"; + +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" }), +}); + +export type CreateFlagsInput = z.infer; diff --git a/create-db/src/index.ts b/create-db/src/index.ts index baed48d..c2dacab 100644 --- a/create-db/src/index.ts +++ b/create-db/src/index.ts @@ -14,7 +14,6 @@ import fs from "fs"; import pc from "picocolors"; import terminalLink from "terminal-link"; import { createCli } from "trpc-cli"; -import { z } from "zod"; import { type Region, @@ -22,8 +21,8 @@ import { type DatabaseResult, type ProgrammaticCreateOptions, type RegionId, - RegionSchema, } from "./types.js"; +import { CreateFlags } from "./flags.js"; import { sendAnalytics, flushAnalytics } from "./analytics.js"; import { createDatabaseCore, getCommandName } from "./database.js"; import { readUserEnvFile } from "./env-utils.js"; @@ -43,6 +42,7 @@ export type { } from "./types.js"; export { isDatabaseError, isDatabaseSuccess, RegionSchema } from "./types.js"; +export { CreateFlags, type CreateFlagsInput } from "./flags.js"; dotenv.config({ quiet: true, @@ -95,37 +95,7 @@ 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" }), - }) - ) + .input(CreateFlags) .handler(async ({ input }) => { const cliRunId = randomUUID(); const CLI_NAME = getCommandName(); From a5a6f22d005fac555fccdb476acf886f2121935f Mon Sep 17 00:00:00 2001 From: Aidan McAlister Date: Thu, 15 Jan 2026 14:06:12 -0500 Subject: [PATCH 2/9] chore: extract command handlers into dedicated modules --- create-db/src/commands/create.ts | 234 ++++++++++++++++++++ create-db/src/commands/index.ts | 2 + create-db/src/commands/regions.ts | 16 ++ create-db/src/index.ts | 340 +----------------------------- create-db/src/services.ts | 54 +++++ 5 files changed, 314 insertions(+), 332 deletions(-) create mode 100644 create-db/src/commands/create.ts create mode 100644 create-db/src/commands/index.ts create mode 100644 create-db/src/commands/regions.ts create mode 100644 create-db/src/services.ts diff --git a/create-db/src/commands/create.ts b/create-db/src/commands/create.ts new file mode 100644 index 0000000..1b7e8cd --- /dev/null +++ b/create-db/src/commands/create.ts @@ -0,0 +1,234 @@ +import { + intro, + outro, + cancel, + select, + spinner, + log, + isCancel, +} from "@clack/prompts"; +import { randomUUID } from "crypto"; +import fs from "fs"; +import pc from "picocolors"; +import terminalLink from "terminal-link"; + +import type { CreateFlagsInput } from "../flags.js"; +import type { RegionId } from "../types.js"; +import { getCommandName } from "../database.js"; +import { readUserEnvFile } from "../env-utils.js"; +import { detectUserLocation, getRegionClosestToLocation } from "../geolocation.js"; +import { + sendAnalyticsEvent, + flushAnalytics, + ensureOnline, + fetchRegions, + validateRegionId, + createDatabase, +} from "../services.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)) { + cancel(pc.red("Operation cancelled.")); + 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) { + 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 ensureOnline(); + + intro(pc.bold(pc.cyan("🚀 Creating a Prisma Postgres database"))); + + 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)) { + cancel(pc.red("Operation cancelled.")); + 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 s = spinner(); + s.start(`Creating database in ${pc.cyan(region)}...`); + + const result = await createDatabase(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(); +} diff --git a/create-db/src/commands/index.ts b/create-db/src/commands/index.ts new file mode 100644 index 0000000..ba74b30 --- /dev/null +++ b/create-db/src/commands/index.ts @@ -0,0 +1,2 @@ +export { handleCreate } from "./create.js"; +export { handleRegions } from "./regions.js"; diff --git a/create-db/src/commands/regions.ts b/create-db/src/commands/regions.ts new file mode 100644 index 0000000..7334282 --- /dev/null +++ b/create-db/src/commands/regions.ts @@ -0,0 +1,16 @@ +import { log } from "@clack/prompts"; +import pc from "picocolors"; + +import { fetchRegions } from "../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/index.ts b/create-db/src/index.ts index c2dacab..01bd8fc 100644 --- a/create-db/src/index.ts +++ b/create-db/src/index.ts @@ -1,36 +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 { type Region, type CreateDatabaseResult, - type DatabaseResult, type ProgrammaticCreateOptions, - type RegionId, } from "./types.js"; import { CreateFlags } from "./flags.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 { getCommandName } from "./database.js"; +import { handleCreate, handleRegions } from "./commands/index.js"; +import { createDatabase, fetchRegions } from "./services.js"; export type { Region, @@ -44,51 +23,6 @@ export type { export { isDatabaseError, isDatabaseSuccess, RegionSchema } from "./types.js"; export { CreateFlags, type CreateFlagsInput } from "./flags.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 - ); - const router = os.router({ create: os .meta({ @@ -96,234 +30,11 @@ const router = os.router({ default: true, }) .input(CreateFlags) - .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(); - }), + .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()), }); export function createDbCli() { @@ -335,34 +46,10 @@ 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} - * - * @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}`); - * } - * ``` - */ export async function create( options?: ProgrammaticCreateOptions ): Promise { - return createDatabaseCoreWithUrl( + return createDatabase( options?.region || "us-east-1", options?.userAgent, undefined, @@ -370,17 +57,6 @@ export async function create( ); } -/** - * List available Prisma Postgres regions programmatically. - * - * @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/services.ts b/create-db/src/services.ts new file mode 100644 index 0000000..b6d83e7 --- /dev/null +++ b/create-db/src/services.ts @@ -0,0 +1,54 @@ +import dotenv from "dotenv"; +import { sendAnalytics, flushAnalytics } from "./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 }; + +export function sendAnalyticsEvent( + eventName: string, + properties: Record, + cliRunId: string +) { + return sendAnalytics(eventName, properties, cliRunId, CREATE_DB_WORKER_URL); +} + +export async function ensureOnline() { + try { + await checkOnline(CREATE_DB_WORKER_URL); + } catch { + await flushAnalytics(); + process.exit(1); + } +} + +export function fetchRegions() { + return getRegions(CREATE_DB_WORKER_URL); +} + +export function validateRegionId(region: string) { + return validateRegion(region, CREATE_DB_WORKER_URL); +} + +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 + ); +} From 106e839c494d8ce97417c58a6ac26a56b2c5ab1e Mon Sep 17 00:00:00 2001 From: Aidan McAlister Date: Thu, 15 Jan 2026 14:13:23 -0500 Subject: [PATCH 3/9] chore: consolidate output formatting logic --- create-db/src/commands/create.ts | 110 ++++++++----------------------- create-db/src/output.ts | 98 +++++++++++++++++++++++++++ 2 files changed, 125 insertions(+), 83 deletions(-) create mode 100644 create-db/src/output.ts diff --git a/create-db/src/commands/create.ts b/create-db/src/commands/create.ts index 1b7e8cd..c247a8a 100644 --- a/create-db/src/commands/create.ts +++ b/create-db/src/commands/create.ts @@ -1,16 +1,5 @@ -import { - intro, - outro, - cancel, - select, - spinner, - log, - isCancel, -} from "@clack/prompts"; +import { select, isCancel } from "@clack/prompts"; import { randomUUID } from "crypto"; -import fs from "fs"; -import pc from "picocolors"; -import terminalLink from "terminal-link"; import type { CreateFlagsInput } from "../flags.js"; import type { RegionId } from "../types.js"; @@ -25,6 +14,17 @@ import { validateRegionId, createDatabase, } from "../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(); @@ -81,7 +81,7 @@ export async function handleCreate(input: CreateFlagsInput): Promise { }); if (isCancel(selectedRegion)) { - cancel(pc.red("Operation cancelled.")); + showCancelled(); await flushAnalytics(); process.exit(0); } @@ -106,55 +106,28 @@ export async function handleCreate(input: CreateFlagsInput): Promise { await flushAnalytics(); if (input.json) { - console.log(JSON.stringify(result, null, 2)); + printJson(result); return; } if (!result.success) { - console.error(result.message); + printError(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) - }` - ) - ); + 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(); - intro(pc.bold(pc.cyan("🚀 Creating a Prisma Postgres database"))); + showIntro(); if (input.interactive) { const regions = await fetchRegions(); @@ -167,7 +140,7 @@ export async function handleCreate(input: CreateFlagsInput): Promise { }); if (isCancel(selectedRegion)) { - cancel(pc.red("Operation cancelled.")); + showCancelled(); await flushAnalytics(); process.exit(0); } @@ -187,48 +160,19 @@ export async function handleCreate(input: CreateFlagsInput): Promise { ); } - const s = spinner(); - s.start(`Creating database in ${pc.cyan(region)}...`); + const spinner = createSpinner(); + spinner.start(region); const result = await createDatabase(region, userAgent, cliRunId); if (!result.success) { - s.stop(pc.red(`Error: ${result.message}`)); + spinner.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!")); + spinner.success(); + printDatabaseResult(result); + showOutro(); await flushAnalytics(); } diff --git a/create-db/src/output.ts b/create-db/src/output.ts new file mode 100644 index 0000000..bcaab38 --- /dev/null +++ b/create-db/src/output.ts @@ -0,0 +1,98 @@ +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"; + +export function showIntro() { + intro(pc.bold(pc.cyan("🚀 Creating a Prisma Postgres database"))); +} + +export function showOutro() { + outro(pc.dim("Done!")); +} + +export function showCancelled() { + cancel(pc.red("Operation cancelled.")); +} + +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}`)), + }; +} + +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.`) + ) + ); +} + +export function printJson(data: unknown) { + console.log(JSON.stringify(data, null, 2)); +} + +export function printError(message: string) { + console.error(pc.red(message)); +} + +export function printSuccess(message: string) { + console.log(pc.green(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), + }; + } +} From ac61937fbba15a04a0addddaaf3cd6ed7d1c6da5 Mon Sep 17 00:00:00 2001 From: Aidan McAlister Date: Thu, 15 Jan 2026 14:20:15 -0500 Subject: [PATCH 4/9] chore: added JSDoc comments --- create-db/src/flags.ts | 4 ++++ create-db/src/index.ts | 25 +++++++++++++++++++++++++ create-db/src/output.ts | 30 ++++++++++++++++++++++++++++++ create-db/src/services.ts | 26 ++++++++++++++++++++++++++ 4 files changed, 85 insertions(+) diff --git a/create-db/src/flags.ts b/create-db/src/flags.ts index 5c390c8..083fdee 100644 --- a/create-db/src/flags.ts +++ b/create-db/src/flags.ts @@ -1,6 +1,9 @@ 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") @@ -29,4 +32,5 @@ export const CreateFlags = z.object({ .meta({ alias: "u" }), }); +/** Inferred type from CreateFlags schema. */ export type CreateFlagsInput = z.infer; diff --git a/create-db/src/index.ts b/create-db/src/index.ts index 01bd8fc..7ac1498 100644 --- a/create-db/src/index.ts +++ b/create-db/src/index.ts @@ -37,6 +37,10 @@ const router = os.router({ .handler(async () => handleRegions()), }); +/** + * Create and return the CLI instance for create-db. + * @returns The configured CLI instance + */ export function createDbCli() { return createCli({ router, @@ -46,6 +50,18 @@ export function createDbCli() { }); } +/** + * Create a new Prisma Postgres database programmatically. + * @param options - Options for creating the database + * @returns A promise resolving to either a DatabaseResult or DatabaseError + * @example + * ```typescript + * const result = await create({ region: "us-east-1" }); + * if (result.success) { + * console.log(result.connectionString); + * } + * ``` + */ export async function create( options?: ProgrammaticCreateOptions ): Promise { @@ -57,6 +73,15 @@ export async function create( ); } +/** + * List available Prisma Postgres regions programmatically. + * @returns A promise resolving to an array of available regions + * @example + * ```typescript + * const availableRegions = await regions(); + * console.log(availableRegions); + * ``` + */ export async function regions(): Promise { return fetchRegions(); } diff --git a/create-db/src/output.ts b/create-db/src/output.ts index bcaab38..cd4ec4e 100644 --- a/create-db/src/output.ts +++ b/create-db/src/output.ts @@ -5,18 +5,25 @@ 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 { @@ -26,6 +33,10 @@ export function createSpinner() { }; } +/** + * 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, { @@ -55,18 +66,37 @@ export function printDatabaseResult(result: DatabaseResult) { ); } +/** + * 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, diff --git a/create-db/src/services.ts b/create-db/src/services.ts index b6d83e7..17964df 100644 --- a/create-db/src/services.ts +++ b/create-db/src/services.ts @@ -12,6 +12,12 @@ const CLAIM_DB_WORKER_URL = 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, @@ -20,6 +26,9 @@ export function sendAnalyticsEvent( 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); @@ -29,14 +38,31 @@ export async function ensureOnline() { } } +/** + * 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, From 9d394ef859cc5da7173b184d21b0ef90d055f941 Mon Sep 17 00:00:00 2001 From: Aidan McAlister Date: Thu, 15 Jan 2026 14:27:23 -0500 Subject: [PATCH 5/9] fix: flag comment github issue added --- create-db/src/flags.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/create-db/src/flags.ts b/create-db/src/flags.ts index 083fdee..7d37ca8 100644 --- a/create-db/src/flags.ts +++ b/create-db/src/flags.ts @@ -34,3 +34,5 @@ export const CreateFlags = z.object({ /** 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 From 209b247666ca85e79f2c02a881a6e6792fe54519 Mon Sep 17 00:00:00 2001 From: Aidan McAlister Date: Thu, 15 Jan 2026 14:32:29 -0500 Subject: [PATCH 6/9] feat: more rigorous testing with minimal db creations --- create-db/__tests__/cli.test.ts | 86 +++++++++++ create-db/__tests__/flags.test.ts | 221 +++++++++++++++++++++++++++ create-db/__tests__/output.test.ts | 144 +++++++++++++++++ create-db/__tests__/services.test.ts | 27 ++++ 4 files changed, 478 insertions(+) create mode 100644 create-db/__tests__/cli.test.ts create mode 100644 create-db/__tests__/flags.test.ts create mode 100644 create-db/__tests__/output.test.ts create mode 100644 create-db/__tests__/services.test.ts 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..b05f1ba --- /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/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..2e3a902 --- /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/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("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..ef2cf3c --- /dev/null +++ b/create-db/__tests__/services.test.ts @@ -0,0 +1,27 @@ +import { describe, it, expect } from "vitest"; +import { fetchRegions } from "../src/services.js"; + +// These tests hit the real API but do NOT create databases + +describe("services", () => { + 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"); + } + + // Check known region exists + const regionIds = regions.map((r) => r.id); + expect(regionIds).toContain("us-east-1"); + }, 10000); + }); +}); From 86ba8845518b085323b6234b013709f8fb1e8f8e Mon Sep 17 00:00:00 2001 From: Aidan McAlister Date: Thu, 15 Jan 2026 14:36:23 -0500 Subject: [PATCH 7/9] chore: folder restructure --- create-db/__tests__/flags.test.ts | 2 +- create-db/__tests__/output.test.ts | 2 +- create-db/__tests__/services.test.ts | 2 +- create-db/src/{ => cli}/commands/create.ts | 10 +++++----- create-db/src/{ => cli}/commands/index.ts | 0 create-db/src/{ => cli}/commands/regions.ts | 2 +- create-db/src/{ => cli}/flags.ts | 2 +- create-db/src/{ => cli}/output.ts | 2 +- create-db/src/{ => core}/database.ts | 4 ++-- create-db/src/{ => core}/regions.ts | 2 +- create-db/src/{ => core}/services.ts | 2 +- create-db/src/index.ts | 10 +++++----- create-db/src/{ => utils}/analytics.ts | 0 create-db/src/{ => utils}/env-utils.ts | 0 create-db/src/{ => utils}/geolocation.ts | 2 +- 15 files changed, 21 insertions(+), 21 deletions(-) rename create-db/src/{ => cli}/commands/create.ts (95%) rename create-db/src/{ => cli}/commands/index.ts (100%) rename create-db/src/{ => cli}/commands/regions.ts (87%) rename create-db/src/{ => cli}/flags.ts (95%) rename create-db/src/{ => cli}/output.ts (98%) rename create-db/src/{ => core}/database.ts (97%) rename create-db/src/{ => core}/regions.ts (95%) rename create-db/src/{ => core}/services.ts (96%) rename create-db/src/{ => utils}/analytics.ts (100%) rename create-db/src/{ => utils}/env-utils.ts (100%) rename create-db/src/{ => utils}/geolocation.ts (99%) diff --git a/create-db/__tests__/flags.test.ts b/create-db/__tests__/flags.test.ts index b05f1ba..8071562 100644 --- a/create-db/__tests__/flags.test.ts +++ b/create-db/__tests__/flags.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { CreateFlags, type CreateFlagsInput } from "../src/flags.js"; +import { CreateFlags, type CreateFlagsInput } from "../src/cli/flags.js"; import { RegionSchema } from "../src/types.js"; describe("CreateFlags schema", () => { diff --git a/create-db/__tests__/output.test.ts b/create-db/__tests__/output.test.ts index 2e3a902..c86b065 100644 --- a/create-db/__tests__/output.test.ts +++ b/create-db/__tests__/output.test.ts @@ -2,7 +2,7 @@ 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/output.js"; +import { writeEnvFile } from "../src/cli/output.js"; describe("writeEnvFile", () => { let tempDir: string; diff --git a/create-db/__tests__/services.test.ts b/create-db/__tests__/services.test.ts index ef2cf3c..9f163f7 100644 --- a/create-db/__tests__/services.test.ts +++ b/create-db/__tests__/services.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { fetchRegions } from "../src/services.js"; +import { fetchRegions } from "../src/core/services.js"; // These tests hit the real API but do NOT create databases diff --git a/create-db/src/commands/create.ts b/create-db/src/cli/commands/create.ts similarity index 95% rename from create-db/src/commands/create.ts rename to create-db/src/cli/commands/create.ts index c247a8a..46a7e3f 100644 --- a/create-db/src/commands/create.ts +++ b/create-db/src/cli/commands/create.ts @@ -2,10 +2,10 @@ 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 "../database.js"; -import { readUserEnvFile } from "../env-utils.js"; -import { detectUserLocation, getRegionClosestToLocation } from "../geolocation.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, @@ -13,7 +13,7 @@ import { fetchRegions, validateRegionId, createDatabase, -} from "../services.js"; +} from "../../core/services.js"; import { showIntro, showOutro, diff --git a/create-db/src/commands/index.ts b/create-db/src/cli/commands/index.ts similarity index 100% rename from create-db/src/commands/index.ts rename to create-db/src/cli/commands/index.ts diff --git a/create-db/src/commands/regions.ts b/create-db/src/cli/commands/regions.ts similarity index 87% rename from create-db/src/commands/regions.ts rename to create-db/src/cli/commands/regions.ts index 7334282..2049148 100644 --- a/create-db/src/commands/regions.ts +++ b/create-db/src/cli/commands/regions.ts @@ -1,7 +1,7 @@ import { log } from "@clack/prompts"; import pc from "picocolors"; -import { fetchRegions } from "../services.js"; +import { fetchRegions } from "../../core/services.js"; export async function handleRegions(): Promise { const regions = await fetchRegions(); diff --git a/create-db/src/flags.ts b/create-db/src/cli/flags.ts similarity index 95% rename from create-db/src/flags.ts rename to create-db/src/cli/flags.ts index 7d37ca8..356fca5 100644 --- a/create-db/src/flags.ts +++ b/create-db/src/cli/flags.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { RegionSchema } from "./types.js"; +import { RegionSchema } from "../types.js"; /** * Zod schema for CLI flags used by the `create` command. diff --git a/create-db/src/output.ts b/create-db/src/cli/output.ts similarity index 98% rename from create-db/src/output.ts rename to create-db/src/cli/output.ts index cd4ec4e..a8494e1 100644 --- a/create-db/src/output.ts +++ b/create-db/src/cli/output.ts @@ -3,7 +3,7 @@ import fs from "fs"; import pc from "picocolors"; import terminalLink from "terminal-link"; -import type { DatabaseResult } from "./types.js"; +import type { DatabaseResult } from "../types.js"; /** Display the CLI intro message. */ export function showIntro() { 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/services.ts b/create-db/src/core/services.ts similarity index 96% rename from create-db/src/services.ts rename to create-db/src/core/services.ts index 17964df..5889ec8 100644 --- a/create-db/src/services.ts +++ b/create-db/src/core/services.ts @@ -1,5 +1,5 @@ import dotenv from "dotenv"; -import { sendAnalytics, flushAnalytics } from "./analytics.js"; +import { sendAnalytics, flushAnalytics } from "../utils/analytics.js"; import { createDatabaseCore } from "./database.js"; import { checkOnline, getRegions, validateRegion } from "./regions.js"; diff --git a/create-db/src/index.ts b/create-db/src/index.ts index 7ac1498..0214b14 100644 --- a/create-db/src/index.ts +++ b/create-db/src/index.ts @@ -6,10 +6,10 @@ import { type CreateDatabaseResult, type ProgrammaticCreateOptions, } from "./types.js"; -import { CreateFlags } from "./flags.js"; -import { getCommandName } from "./database.js"; -import { handleCreate, handleRegions } from "./commands/index.js"; -import { createDatabase, fetchRegions } from "./services.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, @@ -21,7 +21,7 @@ export type { } from "./types.js"; export { isDatabaseError, isDatabaseSuccess, RegionSchema } from "./types.js"; -export { CreateFlags, type CreateFlagsInput } from "./flags.js"; +export { CreateFlags, type CreateFlagsInput } from "./cli/flags.js"; const router = os.router({ create: os 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 From 0ce245c21a671f6960e751ade219687b1a153520 Mon Sep 17 00:00:00 2001 From: Aidan McAlister Date: Fri, 16 Jan 2026 12:50:07 -0500 Subject: [PATCH 8/9] chore: version updates --- create-db/package.json | 2 +- create-pg/package.json | 2 +- create-postgres/package.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/create-db/package.json b/create-db/package.json index 6f03c7c..6703a28 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": { 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": { From 2df4a28ecdf29747fe1dfd00097a1a7671d58bbd Mon Sep 17 00:00:00 2001 From: Aidan McAlister Date: Fri, 16 Jan 2026 13:15:54 -0500 Subject: [PATCH 9/9] fix: coderabbit comments resolved --- create-db/__tests__/output.test.ts | 2 +- create-db/__tests__/services.test.ts | 14 ++++++++++---- create-db/package.json | 1 + create-db/src/cli/commands/create.ts | 3 +++ 4 files changed, 15 insertions(+), 5 deletions(-) diff --git a/create-db/__tests__/output.test.ts b/create-db/__tests__/output.test.ts index c86b065..b03f3a1 100644 --- a/create-db/__tests__/output.test.ts +++ b/create-db/__tests__/output.test.ts @@ -109,7 +109,7 @@ describe("writeEnvFile", () => { } }); - it("returns error for read-only directory", () => { + it.skipIf(process.platform === "win32")("returns error for read-only directory", () => { const readOnlyDir = path.join(tempDir, "readonly"); fs.mkdirSync(readOnlyDir); fs.chmodSync(readOnlyDir, 0o444); diff --git a/create-db/__tests__/services.test.ts b/create-db/__tests__/services.test.ts index 9f163f7..60f9ebc 100644 --- a/create-db/__tests__/services.test.ts +++ b/create-db/__tests__/services.test.ts @@ -2,8 +2,11 @@ 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 -describe("services", () => { +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(); @@ -19,9 +22,12 @@ describe("services", () => { expect(typeof region.status).toBe("string"); } - // Check known region exists - const regionIds = regions.map((r) => r.id); - expect(regionIds).toContain("us-east-1"); + // 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 6703a28..b934eb2 100644 --- a/create-db/package.json +++ b/create-db/package.json @@ -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 index 46a7e3f..eae4e8b 100644 --- a/create-db/src/cli/commands/create.ts +++ b/create-db/src/cli/commands/create.ts @@ -107,6 +107,9 @@ export async function handleCreate(input: CreateFlagsInput): Promise { if (input.json) { printJson(result); + if (!result.success) { + process.exit(1); + } return; }