diff --git a/docs/product/command-principles.md b/docs/product/command-principles.md index 450d90a..74c6ddf 100644 --- a/docs/product/command-principles.md +++ b/docs/product/command-principles.md @@ -38,7 +38,7 @@ The long-term command surface grows through workflow groups such as: - `app` - `git` -The preview implements only `auth`, `project`, `git`, `branch`, and `app`. +The preview implements only `auth`, `project`, `git`, `branch`, `database`, and `app`. ## Stable Nouns diff --git a/docs/product/command-spec.md b/docs/product/command-spec.md index c4c4c46..1d3dd8f 100644 --- a/docs/product/command-spec.md +++ b/docs/product/command-spec.md @@ -14,6 +14,7 @@ The beta package includes these command groups: - `project` (includes `project env` subgroup) - `git` - `branch` +- `database` (includes `database connection` subgroup) - `app` The beta package also includes one top-level utility command: @@ -29,7 +30,6 @@ Out of scope for the current beta: - `init` - `schema` -- `database` - `migrate` - product-specific namespaces such as `compute` @@ -551,6 +551,177 @@ prisma-cli branch list prisma-cli branch list --json ``` +## `prisma-cli database list --project --branch ` + +Purpose: + +- list Prisma Postgres databases for the resolved project + +Behavior: + +- requires auth and resolved project context; accepts `--project ` as an explicit fallback +- lists database metadata only: name, id, branch, region, status, and created timestamp when available +- `--branch ` narrows the list to databases attached to that Branch +- never prints or returns connection strings, passwords, or endpoint secrets +- does not create, delete, or mutate remote state +- uses the standard list JSON envelope with redacted database metadata + +Examples: + +```bash +prisma-cli database list +prisma-cli database list --branch feature/foo +prisma-cli database list --json +``` + +## `prisma-cli database show --project --branch ` + +Purpose: + +- show metadata for one Prisma Postgres database + +Behavior: + +- requires auth and resolved project context; accepts `--project ` as an explicit fallback +- resolves `` by exact database id or exact database name inside the resolved project +- `--branch ` narrows name resolution when the same database name exists on multiple Branches +- returns database metadata and connection metadata only +- never prints or returns connection strings, passwords, or endpoint secrets +- fails with `DATABASE_NOT_FOUND` or `DATABASE_AMBIGUOUS` when the target cannot be selected safely + +Examples: + +```bash +prisma-cli database show db_123 +prisma-cli database show acme-preview --branch preview +prisma-cli database show db_123 --json +``` + +## `prisma-cli database create --project --branch --region ` + +Purpose: + +- create a Prisma Postgres database and return its first one-time connection URL + +Behavior: + +- requires auth and resolved project context; accepts `--project ` as an explicit fallback +- creates an empty Prisma Postgres database in the resolved project +- `--branch ` targets the created database to a Branch when supplied +- `--region ` passes the Prisma Postgres region id when supplied +- the Management API returns the first connection as a one-time-view secret +- in human mode, stdout contains exactly one line: the raw connection URL +- human stderr does not repeat, label, or wrap the connection URL +- in `--json`, `result.connectionString` contains the raw one-time URL exactly once +- no `DATABASE_URL=` or `DIRECT_URL=` formatting is added; consumers decide how to store the URL + +Examples: + +```bash +prisma-cli database create my-db +prisma-cli database create my-db --branch feature/foo --region eu-central-1 +prisma-cli database create my-db --json +``` + +## `prisma-cli database remove --confirm ` + +Purpose: + +- remove a Prisma Postgres database + +Behavior: + +- requires auth and resolved project context; accepts `--project ` as an explicit fallback +- resolves `` by exact database id or exact database name inside the resolved project +- requires `--confirm ` where the value exactly matches the resolved database id +- `--yes` does not satisfy this confirmation +- removes the database and its connection metadata through the Management API +- never prints or returns connection strings, passwords, or endpoint secrets + +Examples: + +```bash +prisma-cli database remove db_123 --confirm db_123 +``` + +## `prisma-cli database connection` + +Manage one-time-view connection strings for a database. + +`connection` is nested under `database` because connection strings are only +valid in the context of a Prisma Postgres database. The subgroup mirrors the +`project env ` shape: the parent command names the resource family, +the nested noun names the subordinate resource, and the final token is the +action. There is no `database connection show` command because connection +strings are one-time-view secrets. + +### `prisma-cli database connection list ` + +Purpose: + +- list connection metadata for a database + +Behavior: + +- requires auth and resolved project context; accepts `--project ` as an explicit fallback +- resolves `` by exact database id or exact database name inside the resolved project +- supports `--branch ` to narrow database name resolution +- lists connection names, ids, and created timestamps when available +- never prints or returns connection strings, passwords, or endpoint secrets + +Examples: + +```bash +prisma-cli database connection list db_123 +prisma-cli database connection list acme-preview --branch preview +prisma-cli database connection list db_123 --json +``` + +### `prisma-cli database connection create --name ` + +Purpose: + +- create a new one-time-view connection URL for a database + +Behavior: + +- requires auth and resolved project context; accepts `--project ` as an explicit fallback +- resolves `` by exact database id or exact database name inside the resolved project +- supports `--branch ` to narrow database name resolution +- `--name ` sets the connection metadata name; when omitted, the CLI generates a `cli-YYYYMMDDhhmmssSSS-xxxx` name +- in human mode, stdout contains exactly one line: the raw connection URL +- human stderr does not repeat, label, or wrap the connection URL +- in `--json`, `result.connectionString` contains the raw one-time URL exactly once +- no `DATABASE_URL=` or `DIRECT_URL=` formatting is added; consumers decide how to store the URL + +Examples: + +```bash +prisma-cli database connection create db_123 +prisma-cli database connection create db_123 --name readonly +prisma-cli database connection create db_123 --json +``` + +### `prisma-cli database connection remove --confirm ` + +Purpose: + +- remove a database connection + +Behavior: + +- requires auth +- treats `` as the connection id to remove +- requires `--confirm ` where the value exactly matches the connection id +- `--yes` does not satisfy this confirmation +- never prints or returns connection strings, passwords, or endpoint secrets + +Examples: + +```bash +prisma-cli database connection remove conn_123 --confirm conn_123 +``` + ## `prisma-cli app build --entry --build-type ` Purpose: diff --git a/docs/product/error-conventions.md b/docs/product/error-conventions.md index 411e7b4..19a3f5d 100644 --- a/docs/product/error-conventions.md +++ b/docs/product/error-conventions.md @@ -143,7 +143,7 @@ Rules: - `ok` is always `false` - `command` is always present - `error.code` is stable and machine-readable -- `error.domain` is a stable logical area such as `cli`, `auth`, `project`, `branch`, or `app` +- `error.domain` is a stable logical area such as `cli`, `auth`, `project`, `branch`, `app`, or `database` - `error.severity` is stable and machine-readable - `error.summary` is the short human-readable headline - `error.why` explains the immediate cause when known @@ -197,6 +197,12 @@ These codes are the minimum stable set for the MVP: - `BUILD_FAILED` - `BRANCH_DATABASE_SETUP_FAILED` - `SCHEMA_SETUP_FAILED` +- `DATABASE_NOT_FOUND` +- `DATABASE_AMBIGUOUS` +- `DATABASE_CONNECTION_NOT_FOUND` +- `DATABASE_CONNECTION_MISSING` +- `DATABASE_CONNECTION_STRING_MISSING` +- `DATABASE_API_ERROR` - `RUN_FAILED` - `DEPLOY_FAILED` - `VERSION_UNAVAILABLE` @@ -243,6 +249,12 @@ Recommended meanings: - `BUILD_FAILED`: build failed before a healthy deployment existed - `BRANCH_DATABASE_SETUP_FAILED`: database creation or env-var wiring failed before deployment started - `SCHEMA_SETUP_FAILED`: local Prisma schema source setup against a newly created database failed before deployment started +- `DATABASE_NOT_FOUND`: requested database id or name does not exist in the resolved project scope +- `DATABASE_AMBIGUOUS`: requested database name matches multiple databases and needs an id or branch filter +- `DATABASE_CONNECTION_NOT_FOUND`: requested database connection id does not exist or is not accessible +- `DATABASE_CONNECTION_MISSING`: database creation succeeded but the API response did not include the first one-time connection payload +- `DATABASE_CONNECTION_STRING_MISSING`: connection creation succeeded but the API response did not include the one-time connection string +- `DATABASE_API_ERROR`: database Management API request failed without a more specific CLI error code - `RUN_FAILED`: local framework run command could not be started or exited unsuccessfully - `DEPLOY_FAILED`: deployment or post-build health failed - `VERSION_UNAVAILABLE`: CLI could not read its own bundled package metadata to report a version (defensive; not expected in normal installs) diff --git a/docs/product/output-conventions.md b/docs/product/output-conventions.md index 2cc8fb4..0cd7f09 100644 --- a/docs/product/output-conventions.md +++ b/docs/product/output-conventions.md @@ -104,9 +104,30 @@ Current MVP commands map to patterns like this: | `git connect` | `mutate` | | `git disconnect` | `mutate` | | `branch list` | `list` | +| `database list` | `list` | +| `database show` | `show` | +| `database create` | raw secret stdout + JSON envelope | +| `database remove` | `mutate` | +| `database connection list` | `list` | +| `database connection create` | raw secret stdout + JSON envelope | +| `database connection remove` | `mutate` | No current MVP command uses `verify` or `inspect`, but new commands must still choose one existing pattern rather than inventing a new one casually. +### One-Time Secret Output + +Commands that create one-time-view secrets may write the raw secret value to +stdout in human mode. This is still machine-readable output, not decorative +human output. + +Rules: + +- write exactly one raw secret value per successful create command +- do not repeat the secret on stderr +- do not wrap the secret in labels such as `DATABASE_URL=` +- list and show commands must never print or return secret values +- in `--json`, include the secret exactly once in the result object + ### Shared Patterns #### `list` diff --git a/docs/product/resource-model.md b/docs/product/resource-model.md index 18cdd43..5debe8f 100644 --- a/docs/product/resource-model.md +++ b/docs/product/resource-model.md @@ -154,14 +154,36 @@ top-level target-context group is `branch`, not `env`. `schema` stays a local code artifact. `database` stays a branch-bound remote resource. -The beta package does not expose a standalone database command group yet. The -current database surface is limited to `app deploy --db`, which can create an -empty Prisma Postgres database, apply a supported local Prisma schema source -when available, and write normal environment variables for the deploy target. +The beta package exposes `database` as the canonical database management group. +The first slice manages Prisma Postgres database metadata and one-time-view +connection strings: + +- `database list` +- `database show ` +- `database create ` +- `database remove ` +- `database connection list ` +- `database connection create ` +- `database connection remove ` + +The `database connection` subgroup is nested because connection strings exist +only for databases. It follows the same parent-noun/subordinate-noun/action +shape as `project env `. There is no `database connection show` command: +connection strings are secrets and the platform returns them only during create +operations. Rules: +- `database` is the canonical public group; Public Beta does not add public + `db` or `postgres` aliases +- `database create` prints the first returned connection URL exactly once +- `database connection create` prints the created connection URL exactly once +- create commands print raw URLs only, never `DATABASE_URL=` or `DIRECT_URL=` - database wiring uses the existing environment-variable model +- `database list`, `database show`, and `database connection list` never print + or return secret values +- database and database connection removal require exact id confirmation with + `--confirm `; `--yes` is not sufficient - preview Branch setup writes branch-scoped `DATABASE_URL` and `DIRECT_URL` overrides, not separate app bindings - first production deploy setup writes production `DATABASE_URL` and `DIRECT_URL` env vars before the App has a live deployment - database setup never overwrites an existing branch-scoped `DATABASE_URL` diff --git a/packages/cli/fixtures/mock-api.json b/packages/cli/fixtures/mock-api.json index fc8e3b6..ebc0b83 100644 --- a/packages/cli/fixtures/mock-api.json +++ b/packages/cli/fixtures/mock-api.json @@ -132,5 +132,56 @@ "status": "ready", "url": "https://preview.billing-api.prisma.app" } + ], + "databases": [ + { + "id": "db_123", + "projectId": "proj_123", + "branchId": "br_123", + "branchName": "preview", + "name": "acme-preview", + "region": "eu-central-1", + "status": "ready", + "isDefault": true, + "createdAt": "2026-06-01T00:00:00.000Z" + }, + { + "id": "db_456", + "projectId": "proj_123", + "branchId": "br_456", + "branchName": "production", + "name": "acme-production", + "region": "us-east-1", + "status": "ready", + "isDefault": false, + "createdAt": "2026-06-02T00:00:00.000Z" + }, + { + "id": "db_789", + "projectId": "proj_456", + "branchId": "br_789", + "branchName": "preview", + "name": "billing-preview", + "region": "eu-central-1", + "status": "ready", + "isDefault": false, + "createdAt": "2026-06-03T00:00:00.000Z" + } + ], + "databaseConnections": [ + { + "id": "conn_123", + "databaseId": "db_123", + "name": "primary", + "createdAt": "2026-06-01T00:00:00.000Z", + "connectionString": "postgresql://secret-preview.example.prisma.io/postgres" + }, + { + "id": "conn_456", + "databaseId": "db_456", + "name": "primary", + "createdAt": "2026-06-02T00:00:00.000Z", + "connectionString": "postgresql://secret-production.example.prisma.io/postgres" + } ] } diff --git a/packages/cli/src/adapters/mock-api.ts b/packages/cli/src/adapters/mock-api.ts index 6b15594..21af8b7 100644 --- a/packages/cli/src/adapters/mock-api.ts +++ b/packages/cli/src/adapters/mock-api.ts @@ -49,6 +49,26 @@ interface DeploymentRecord { url: string | null; } +interface DatabaseRecord { + id: string; + projectId: string; + branchId: string | null; + branchName: string | null; + name: string; + region: string | null; + status: string | null; + isDefault: boolean | null; + createdAt: string | null; +} + +interface DatabaseConnectionRecord { + id: string; + databaseId: string; + name: string; + createdAt: string | null; + connectionString?: string; +} + interface MockApiData { providers: ProviderRecord[]; users: UserRecord[]; @@ -57,6 +77,8 @@ interface MockApiData { projects: ProjectRecord[]; branches: BranchRecord[]; deployments: DeploymentRecord[]; + databases?: DatabaseRecord[]; + databaseConnections?: DatabaseConnectionRecord[]; } export class MockApi { @@ -131,9 +153,110 @@ export class MockApi { getDeployment(deploymentId: string): DeploymentRecord | undefined { return this.data.deployments.find((deployment) => deployment.id === deploymentId); } + + listDatabasesForProject(projectId: string, branchName?: string): DatabaseRecord[] { + return (this.data.databases ?? []).filter((database) => + database.projectId === projectId && (!branchName || database.branchName === branchName) + ); + } + + getDatabase(databaseId: string): DatabaseRecord | undefined { + return (this.data.databases ?? []).find((database) => database.id === databaseId); + } + + createDatabase(input: { + projectId: string; + name: string; + branchName?: string; + region?: string; + }): { database: DatabaseRecord; connection: DatabaseConnectionRecord; connectionString: string } { + this.data.databases ??= []; + this.data.databaseConnections ??= []; + + const database: DatabaseRecord = { + id: `db_${this.data.databases.length + 1_000}`, + projectId: input.projectId, + branchId: input.branchName ? this.getBranchForProject(input.projectId, input.branchName)?.id ?? null : null, + branchName: input.branchName ?? null, + name: input.name, + region: input.region ?? null, + status: "ready", + isDefault: false, + createdAt: "2026-06-09T00:00:00.000Z", + }; + const connectionString = `postgresql://${database.id}.example.prisma.io/postgres`; + const connection: DatabaseConnectionRecord = { + id: `conn_${this.data.databaseConnections.length + 1_000}`, + databaseId: database.id, + name: database.name, + createdAt: "2026-06-09T00:00:00.000Z", + connectionString, + }; + + this.data.databases.push(database); + this.data.databaseConnections.push(connection); + + return { database, connection, connectionString }; + } + + removeDatabase(databaseId: string): DatabaseRecord | undefined { + this.data.databases ??= []; + this.data.databaseConnections ??= []; + const database = this.getDatabase(databaseId); + if (!database) { + return undefined; + } + + this.data.databases = this.data.databases.filter((candidate) => candidate.id !== databaseId); + this.data.databaseConnections = this.data.databaseConnections.filter((connection) => connection.databaseId !== databaseId); + return database; + } + + listDatabaseConnections(databaseId: string): DatabaseConnectionRecord[] { + return (this.data.databaseConnections ?? []).filter((connection) => connection.databaseId === databaseId); + } + + getDatabaseConnection(connectionId: string): DatabaseConnectionRecord | undefined { + return (this.data.databaseConnections ?? []).find((connection) => connection.id === connectionId); + } + + createDatabaseConnection(input: { + databaseId: string; + name: string; + }): { connection: DatabaseConnectionRecord; connectionString: string } | undefined { + const database = this.getDatabase(input.databaseId); + if (!database) { + return undefined; + } + + this.data.databaseConnections ??= []; + const connectionString = `postgresql://${input.databaseId}-${this.data.databaseConnections.length + 1}.example.prisma.io/postgres`; + const connection: DatabaseConnectionRecord = { + id: `conn_${this.data.databaseConnections.length + 1_000}`, + databaseId: input.databaseId, + name: input.name, + createdAt: "2026-06-09T00:00:00.000Z", + connectionString, + }; + this.data.databaseConnections.push(connection); + return { connection, connectionString }; + } + + removeDatabaseConnection(connectionId: string): DatabaseConnectionRecord | undefined { + this.data.databaseConnections ??= []; + const connection = this.getDatabaseConnection(connectionId); + if (!connection) { + return undefined; + } + + this.data.databaseConnections = this.data.databaseConnections.filter((candidate) => candidate.id !== connectionId); + return connection; + } } export type { + DatabaseConnectionRecord, + DatabaseRecord, DeploymentRecord, BranchRecord, ProjectRecord, diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index f4824bc..099ff35 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -5,6 +5,7 @@ import { Command, CommanderError, Option } from "commander"; import { createAppCommand } from "./commands/app"; import { createAuthCommand } from "./commands/auth"; import { createBranchCommand } from "./commands/branch"; +import { createDatabaseCommand } from "./commands/database"; import { createGitCommand } from "./commands/git"; import { createProjectCommand } from "./commands/project"; import { createVersionCommand } from "./commands/version"; @@ -72,6 +73,7 @@ export function createProgram(runtime: CliRuntime): Command { program.addCommand(createProjectCommand(runtime)); program.addCommand(createGitCommand(runtime)); program.addCommand(createBranchCommand(runtime)); + program.addCommand(createDatabaseCommand(runtime)); program.addCommand(createAppCommand(runtime)); return program; diff --git a/packages/cli/src/commands/database/index.ts b/packages/cli/src/commands/database/index.ts new file mode 100644 index 0000000..824a74f --- /dev/null +++ b/packages/cli/src/commands/database/index.ts @@ -0,0 +1,266 @@ +import { Command, Option } from "commander"; + +import { + runDatabaseConnectionCreate, + runDatabaseConnectionList, + runDatabaseConnectionRemove, + runDatabaseCreate, + runDatabaseList, + runDatabaseRemove, + runDatabaseShow, +} from "../../controllers/database"; +import { + renderDatabaseConnectionCreate, + renderDatabaseConnectionCreateStdout, + renderDatabaseConnectionList, + renderDatabaseConnectionRemove, + renderDatabaseCreate, + renderDatabaseCreateStdout, + renderDatabaseList, + renderDatabaseRemove, + renderDatabaseShow, + serializeDatabaseConnectionCreate, + serializeDatabaseConnectionList, + serializeDatabaseConnectionRemove, + serializeDatabaseCreate, + serializeDatabaseList, + serializeDatabaseRemove, + serializeDatabaseShow, +} from "../../presenters/database"; +import { attachCommandDescriptor } from "../../shell/command-meta"; +import { runCommand } from "../../shell/command-runner"; +import { addCompactGlobalFlags, addGlobalFlags } from "../../shell/global-flags"; +import { configureRuntimeCommand, type CliRuntime } from "../../shell/runtime"; +import type { + DatabaseConnectionCreateResult, + DatabaseConnectionListResult, + DatabaseConnectionRemoveResult, + DatabaseCreateResult, + DatabaseListResult, + DatabaseRemoveResult, + DatabaseShowResult, +} from "../../types/database"; + +export function createDatabaseCommand(runtime: CliRuntime): Command { + const database = attachCommandDescriptor(configureRuntimeCommand(new Command("database"), runtime), "database"); + + addCompactGlobalFlags(database); + + database.addCommand(createDatabaseListCommand(runtime)); + database.addCommand(createDatabaseShowCommand(runtime)); + database.addCommand(createDatabaseCreateCommand(runtime)); + database.addCommand(createDatabaseRemoveCommand(runtime)); + database.addCommand(createDatabaseConnectionCommand(runtime)); + + return database; +} + +function addProjectAndBranchOptions(command: Command): Command { + return command + .addOption(new Option("--project ", "Project id or name")) + .addOption(new Option("--branch ", "Branch git name")); +} + +function createDatabaseListCommand(runtime: CliRuntime): Command { + const command = attachCommandDescriptor(configureRuntimeCommand(new Command("list"), runtime), "database.list"); + + addProjectAndBranchOptions(command); + addGlobalFlags(command); + + command.action(async (options) => { + const projectRef = (options as { project?: string }).project; + const branchName = (options as { branch?: string }).branch; + + await runCommand( + runtime, + "database.list", + options as Record, + (context) => runDatabaseList(context, { projectRef, branchName }), + { + renderHuman: (context, descriptor, result) => renderDatabaseList(context, descriptor, result), + renderJson: (result) => serializeDatabaseList(result), + }, + ); + }); + + return command; +} + +function createDatabaseShowCommand(runtime: CliRuntime): Command { + const command = attachCommandDescriptor(configureRuntimeCommand(new Command("show"), runtime), "database.show"); + + command.argument("", "Database id or name"); + addProjectAndBranchOptions(command); + addGlobalFlags(command); + + command.action(async (databaseRef: string, options) => { + const projectRef = (options as { project?: string }).project; + const branchName = (options as { branch?: string }).branch; + + await runCommand( + runtime, + "database.show", + options as Record, + (context) => runDatabaseShow(context, databaseRef, { projectRef, branchName }), + { + renderHuman: (context, descriptor, result) => renderDatabaseShow(context, descriptor, result), + renderJson: (result) => serializeDatabaseShow(result), + }, + ); + }); + + return command; +} + +function createDatabaseCreateCommand(runtime: CliRuntime): Command { + const command = attachCommandDescriptor(configureRuntimeCommand(new Command("create"), runtime), "database.create"); + + command + .argument("", "Database name") + .addOption(new Option("--region ", "Prisma Postgres region id")); + addProjectAndBranchOptions(command); + addGlobalFlags(command); + + command.action(async (name: string, options) => { + const projectRef = (options as { project?: string }).project; + const branchName = (options as { branch?: string }).branch; + const region = (options as { region?: string }).region; + + await runCommand( + runtime, + "database.create", + options as Record, + (context) => runDatabaseCreate(context, name, { projectRef, branchName, region }), + { + renderStdout: (context, descriptor, result) => renderDatabaseCreateStdout(context, descriptor, result), + renderHuman: () => renderDatabaseCreate(), + renderJson: (result) => serializeDatabaseCreate(result), + }, + ); + }); + + return command; +} + +function createDatabaseRemoveCommand(runtime: CliRuntime): Command { + const command = attachCommandDescriptor(configureRuntimeCommand(new Command("remove"), runtime), "database.remove"); + + command + .argument("", "Database id or name") + .addOption(new Option("--confirm ", "Exact database id required to remove")); + addProjectAndBranchOptions(command); + addGlobalFlags(command); + + command.action(async (databaseRef: string, options) => { + const projectRef = (options as { project?: string }).project; + const branchName = (options as { branch?: string }).branch; + const confirm = (options as { confirm?: string }).confirm; + + await runCommand( + runtime, + "database.remove", + options as Record, + (context) => runDatabaseRemove(context, databaseRef, { projectRef, branchName, confirm }), + { + renderHuman: (context, descriptor, result) => renderDatabaseRemove(context, descriptor, result), + renderJson: (result) => serializeDatabaseRemove(result), + }, + ); + }); + + return command; +} + +function createDatabaseConnectionCommand(runtime: CliRuntime): Command { + const connection = attachCommandDescriptor(configureRuntimeCommand(new Command("connection"), runtime), "database.connection"); + + addCompactGlobalFlags(connection); + + connection.addCommand(createDatabaseConnectionListCommand(runtime)); + connection.addCommand(createDatabaseConnectionCreateCommand(runtime)); + connection.addCommand(createDatabaseConnectionRemoveCommand(runtime)); + + return connection; +} + +function createDatabaseConnectionListCommand(runtime: CliRuntime): Command { + const command = attachCommandDescriptor(configureRuntimeCommand(new Command("list"), runtime), "database.connection.list"); + + command.argument("", "Database id or name"); + addProjectAndBranchOptions(command); + addGlobalFlags(command); + + command.action(async (databaseRef: string, options) => { + const projectRef = (options as { project?: string }).project; + const branchName = (options as { branch?: string }).branch; + + await runCommand( + runtime, + "database.connection.list", + options as Record, + (context) => runDatabaseConnectionList(context, databaseRef, { projectRef, branchName }), + { + renderHuman: (context, descriptor, result) => renderDatabaseConnectionList(context, descriptor, result), + renderJson: (result) => serializeDatabaseConnectionList(result), + }, + ); + }); + + return command; +} + +function createDatabaseConnectionCreateCommand(runtime: CliRuntime): Command { + const command = attachCommandDescriptor(configureRuntimeCommand(new Command("create"), runtime), "database.connection.create"); + + command + .argument("", "Database id or name") + .addOption(new Option("--name ", "Connection name")); + addProjectAndBranchOptions(command); + addGlobalFlags(command); + + command.action(async (databaseRef: string, options) => { + const projectRef = (options as { project?: string }).project; + const branchName = (options as { branch?: string }).branch; + const name = (options as { name?: string }).name; + + await runCommand( + runtime, + "database.connection.create", + options as Record, + (context) => runDatabaseConnectionCreate(context, databaseRef, { projectRef, branchName, name }), + { + renderStdout: (context, descriptor, result) => renderDatabaseConnectionCreateStdout(context, descriptor, result), + renderHuman: () => renderDatabaseConnectionCreate(), + renderJson: (result) => serializeDatabaseConnectionCreate(result), + }, + ); + }); + + return command; +} + +function createDatabaseConnectionRemoveCommand(runtime: CliRuntime): Command { + const command = attachCommandDescriptor(configureRuntimeCommand(new Command("remove"), runtime), "database.connection.remove"); + + command + .argument("", "Connection id") + .addOption(new Option("--confirm ", "Exact connection id required to remove")); + addGlobalFlags(command); + + command.action(async (connectionRef: string, options) => { + const confirm = (options as { confirm?: string }).confirm; + + await runCommand( + runtime, + "database.connection.remove", + options as Record, + (context) => runDatabaseConnectionRemove(context, connectionRef, { confirm }), + { + renderHuman: (context, descriptor, result) => renderDatabaseConnectionRemove(context, descriptor, result), + renderJson: (result) => serializeDatabaseConnectionRemove(result), + }, + ); + }); + + return command; +} diff --git a/packages/cli/src/controllers/database.ts b/packages/cli/src/controllers/database.ts new file mode 100644 index 0000000..bd6a0b4 --- /dev/null +++ b/packages/cli/src/controllers/database.ts @@ -0,0 +1,514 @@ +import { randomBytes } from "node:crypto"; + +import { requireComputeAuth } from "../lib/auth/guard"; +import { + createManagementDatabaseProvider, + normalizeConnection, + normalizeDatabase, + type DatabaseProvider, +} from "../lib/database/provider"; +import { resolveProjectTarget, type ResolvedProjectTarget } from "../lib/project/resolution"; +import { authRequiredError, CliError, usageError, workspaceRequiredError } from "../shell/errors"; +import type { CommandSuccess } from "../shell/output"; +import type { CommandContext } from "../shell/runtime"; +import type { + DatabaseConnectionCreateResult, + DatabaseConnectionListResult, + DatabaseConnectionRemoveResult, + DatabaseCreateResult, + DatabaseListResult, + DatabaseRemoveResult, + DatabaseShowResult, + DatabaseSummary, +} from "../types/database"; +import { requireAuthenticatedAuthState } from "./auth"; +import { listFixtureWorkspaceProjects, listRealWorkspaceProjects } from "./project"; + +interface DatabaseCommandFlags { + projectRef?: string; + branchName?: string; +} + +interface DatabaseCreateFlags extends DatabaseCommandFlags { + region?: string; +} + +interface DatabaseRemoveFlags extends DatabaseCommandFlags { + confirm?: string; +} + +interface DatabaseConnectionCreateFlags extends DatabaseCommandFlags { + name?: string; +} + +interface DatabaseConnectionRemoveFlags { + confirm?: string; +} + +interface ResolvedDatabaseContext { + provider: DatabaseProvider; + target: ResolvedProjectTarget; +} + +function isRealMode(context: CommandContext): boolean { + return !context.runtime.fixturePath && !context.runtime.env.PRISMA_CLI_MOCK_FIXTURE_PATH; +} + +export async function runDatabaseList( + context: CommandContext, + flags: DatabaseCommandFlags, +): Promise> { + const { provider, target } = await requireDatabaseContext(context, flags, "database list"); + const databases = sortDatabases(await provider.listDatabases({ + projectId: target.project.id, + branchName: flags.branchName, + signal: context.runtime.signal, + })); + + return { + command: "database.list", + result: { + projectId: target.project.id, + projectName: target.project.name, + branchName: flags.branchName ?? null, + verboseContext: target, + databases, + }, + warnings: [], + nextSteps: [], + }; +} + +export async function runDatabaseShow( + context: CommandContext, + databaseRef: string, + flags: DatabaseCommandFlags, +): Promise> { + const { provider, target } = await requireDatabaseContext(context, flags, "database show"); + const database = await resolveDatabase(provider, target, databaseRef, flags.branchName, context.runtime.signal); + const connections = await provider.listConnections(database.id, { signal: context.runtime.signal }); + + return { + command: "database.show", + result: { + projectId: target.project.id, + projectName: target.project.name, + verboseContext: target, + database, + connections, + }, + warnings: [], + nextSteps: [], + }; +} + +export async function runDatabaseCreate( + context: CommandContext, + name: string, + flags: DatabaseCreateFlags, +): Promise> { + const databaseName = name.trim(); + if (!databaseName) { + throw usageError( + "Database name required", + "Database create needs a non-empty name.", + "Pass a database name.", + ["prisma-cli database create "], + "database", + ); + } + + const { provider, target } = await requireDatabaseContext(context, flags, "database create"); + const created = await provider.createDatabase({ + projectId: target.project.id, + name: databaseName, + branchName: flags.branchName, + region: flags.region, + signal: context.runtime.signal, + }); + + return { + command: "database.create", + result: { + projectId: target.project.id, + projectName: target.project.name, + verboseContext: target, + database: ensureProjectId(created.database, target.project.id), + connection: created.connection, + connectionString: created.connectionString, + }, + warnings: [], + nextSteps: [], + }; +} + +export async function runDatabaseRemove( + context: CommandContext, + databaseRef: string, + flags: DatabaseRemoveFlags, +): Promise> { + const { provider, target } = await requireDatabaseContext(context, flags, "database remove"); + const database = await resolveDatabase(provider, target, databaseRef, flags.branchName, context.runtime.signal); + requireExactConfirmation({ + resourceName: "database", + commandName: "database remove", + id: database.id, + confirm: flags.confirm, + }); + + await provider.removeDatabase(database.id, { signal: context.runtime.signal }); + + return { + command: "database.remove", + result: { + projectId: target.project.id, + projectName: target.project.name, + verboseContext: target, + database, + }, + warnings: [], + nextSteps: [], + }; +} + +export async function runDatabaseConnectionList( + context: CommandContext, + databaseRef: string, + flags: DatabaseCommandFlags, +): Promise> { + const { provider, target } = await requireDatabaseContext(context, flags, "database connection list"); + const database = await resolveDatabase(provider, target, databaseRef, flags.branchName, context.runtime.signal); + const connections = await provider.listConnections(database.id, { signal: context.runtime.signal }); + + return { + command: "database.connection.list", + result: { + projectId: target.project.id, + projectName: target.project.name, + verboseContext: target, + database, + connections, + }, + warnings: [], + nextSteps: [], + }; +} + +export async function runDatabaseConnectionCreate( + context: CommandContext, + databaseRef: string, + flags: DatabaseConnectionCreateFlags, +): Promise> { + const { provider, target } = await requireDatabaseContext(context, flags, "database connection create"); + const database = await resolveDatabase(provider, target, databaseRef, flags.branchName, context.runtime.signal); + const created = await provider.createConnection({ + databaseId: database.id, + name: flags.name?.trim() || defaultConnectionName(), + signal: context.runtime.signal, + }); + + return { + command: "database.connection.create", + result: { + projectId: target.project.id, + projectName: target.project.name, + verboseContext: target, + database, + connection: created.connection, + connectionString: created.connectionString, + }, + warnings: [], + nextSteps: [], + }; +} + +export async function runDatabaseConnectionRemove( + context: CommandContext, + connectionRef: string, + flags: DatabaseConnectionRemoveFlags, +): Promise> { + const connectionId = connectionRef.trim(); + if (!connectionId) { + throw usageError( + "Connection id required", + "Database connection removal needs a connection id.", + "Pass the connection id to remove.", + ["prisma-cli database connection remove --confirm "], + "database", + ); + } + + requireExactConfirmation({ + resourceName: "database connection", + commandName: "database connection remove", + id: connectionId, + confirm: flags.confirm, + }); + + const provider = await requireDatabaseProviderOnly(context); + await provider.removeConnection(connectionId, { signal: context.runtime.signal }); + + return { + command: "database.connection.remove", + result: { + connection: { + id: connectionId, + }, + }, + warnings: [], + nextSteps: [], + }; +} + +async function requireDatabaseContext( + context: CommandContext, + flags: DatabaseCommandFlags, + commandName: string, +): Promise { + const authState = await requireAuthenticatedAuthState(context); + const workspace = authState.workspace; + if (!workspace) { + throw workspaceRequiredError(); + } + + if (isRealMode(context)) { + const client = await requireComputeAuth(context.runtime.env, context.runtime.signal); + if (!client) { + throw authRequiredError(); + } + + const target = await resolveProjectTarget({ + context, + workspace, + explicitProject: flags.projectRef, + listProjects: () => listRealWorkspaceProjects(client, workspace, context.runtime.signal), + commandName, + }); + + return { + provider: createManagementDatabaseProvider(client), + target, + }; + } + + const target = await resolveProjectTarget({ + context, + workspace, + explicitProject: flags.projectRef, + listProjects: async () => listFixtureWorkspaceProjects(context, workspace), + commandName, + }); + + return { + provider: createFixtureDatabaseProvider(context), + target, + }; +} + +async function requireDatabaseProviderOnly(context: CommandContext): Promise { + await requireAuthenticatedAuthState(context); + + if (isRealMode(context)) { + const client = await requireComputeAuth(context.runtime.env, context.runtime.signal); + if (!client) { + throw authRequiredError(); + } + return createManagementDatabaseProvider(client); + } + + return createFixtureDatabaseProvider(context); +} + +function createFixtureDatabaseProvider(context: CommandContext): DatabaseProvider { + return { + async listDatabases(options) { + return context.api + .listDatabasesForProject(options.projectId, options.branchName) + .map((database) => normalizeDatabase(database, database.projectId)); + }, + + async showDatabase(databaseId) { + const database = context.api.getDatabase(databaseId); + return database ? normalizeDatabase(database, database.projectId) : null; + }, + + async createDatabase(options) { + const created = context.api.createDatabase(options); + return { + database: normalizeDatabase(created.database, created.database.projectId), + connection: normalizeConnection(created.connection, created.connection.databaseId), + connectionString: created.connectionString, + }; + }, + + async removeDatabase(databaseId) { + const removed = context.api.removeDatabase(databaseId); + if (!removed) { + throw databaseNotFoundError(databaseId); + } + }, + + async listConnections(databaseId) { + if (!context.api.getDatabase(databaseId)) { + throw databaseNotFoundError(databaseId); + } + return context.api + .listDatabaseConnections(databaseId) + .map((connection) => normalizeConnection(connection, connection.databaseId)); + }, + + async createConnection(options) { + const created = context.api.createDatabaseConnection(options); + if (!created) { + throw databaseNotFoundError(options.databaseId); + } + return { + connection: normalizeConnection(created.connection, created.connection.databaseId), + connectionString: created.connectionString, + }; + }, + + async removeConnection(connectionId) { + const removed = context.api.removeDatabaseConnection(connectionId); + if (!removed) { + throw connectionNotFoundError(connectionId); + } + }, + }; +} + +async function resolveDatabase( + provider: DatabaseProvider, + target: ResolvedProjectTarget, + databaseRef: string, + branchName: string | undefined, + signal: AbortSignal, +): Promise { + const ref = databaseRef.trim(); + if (!ref) { + throw usageError( + "Database id or name required", + "This command needs a database id or name.", + "Pass a database id or name.", + ["prisma-cli database list"], + "database", + ); + } + + const databases = await provider.listDatabases({ + projectId: target.project.id, + branchName, + signal, + }); + const matches = databases.filter((database) => database.id === ref || database.name === ref); + + if (matches.length === 0) { + throw databaseNotFoundError(ref, target.project.name, branchName); + } + + if (matches.length > 1) { + throw databaseAmbiguousError(ref, matches, branchName); + } + + const selected = matches[0]; + const shown = await provider.showDatabase(selected.id, { + projectId: target.project.id, + signal, + }); + return ensureProjectId(shown ?? selected, target.project.id); +} + +function ensureProjectId(database: DatabaseSummary, projectId: string): DatabaseSummary { + return database.projectId ? database : { ...database, projectId }; +} + +function sortDatabases(databases: DatabaseSummary[]): DatabaseSummary[] { + return databases.slice().sort((left, right) => { + const branchOrder = (left.branchName ?? "").localeCompare(right.branchName ?? ""); + if (branchOrder !== 0) { + return branchOrder; + } + + const nameOrder = left.name.localeCompare(right.name); + return nameOrder !== 0 ? nameOrder : left.id.localeCompare(right.id); + }); +} + +function requireExactConfirmation(options: { + resourceName: string; + commandName: string; + id: string; + confirm: string | undefined; +}): void { + if (options.confirm === options.id) { + return; + } + + throw new CliError({ + code: "CONFIRMATION_REQUIRED", + domain: "database", + summary: `Confirm ${options.resourceName} removal`, + why: `Removing this ${options.resourceName} is destructive and requires the exact id.`, + fix: `Rerun with --confirm ${options.id}.`, + exitCode: 2, + nextSteps: [`prisma-cli ${options.commandName} ${options.id} --confirm ${options.id}`], + meta: { + expectedConfirm: options.id, + receivedConfirm: options.confirm ?? null, + }, + }); +} + +function defaultConnectionName(): string { + const timestamp = new Date().toISOString().replace(/[-:.TZ]/g, "").slice(0, 17); + const suffix = randomBytes(2).toString("hex"); + return `cli-${timestamp}-${suffix}`; +} + +function databaseNotFoundError(databaseRef: string, projectName?: string, branchName?: string): CliError { + const scope = projectName + ? ` in project "${projectName}"${branchName ? ` on branch "${branchName}"` : ""}` + : ""; + return new CliError({ + code: "DATABASE_NOT_FOUND", + domain: "database", + summary: "Database not found", + why: `No database matched "${databaseRef}"${scope}.`, + fix: "Pass a database id or name from prisma-cli database list.", + exitCode: 1, + nextSteps: ["prisma-cli database list"], + }); +} + +function databaseAmbiguousError(databaseRef: string, matches: DatabaseSummary[], branchName: string | undefined): CliError { + return new CliError({ + code: "DATABASE_AMBIGUOUS", + domain: "database", + summary: "Database resolution is ambiguous", + why: branchName + ? `Multiple databases matched "${databaseRef}" on branch "${branchName}".` + : `Multiple databases matched "${databaseRef}".`, + fix: "Pass the database id, or pass --branch to narrow the match.", + exitCode: 1, + nextSteps: ["prisma-cli database list"], + meta: { + matches: matches.map((database) => ({ + id: database.id, + name: database.name, + branchName: database.branchName, + })), + }, + }); +} + +function connectionNotFoundError(connectionId: string): CliError { + return new CliError({ + code: "DATABASE_CONNECTION_NOT_FOUND", + domain: "database", + summary: "Database connection not found", + why: `No database connection matched "${connectionId}".`, + fix: "Pass a connection id from prisma-cli database connection list .", + exitCode: 1, + nextSteps: ["prisma-cli database connection list "], + }); +} diff --git a/packages/cli/src/lib/database/provider.ts b/packages/cli/src/lib/database/provider.ts new file mode 100644 index 0000000..cef3456 --- /dev/null +++ b/packages/cli/src/lib/database/provider.ts @@ -0,0 +1,342 @@ +import type { ManagementApiClient } from "@prisma/management-api-sdk"; + +import { CliError } from "../../shell/errors"; +import type { DatabaseConnectionSummary, DatabaseSummary } from "../../types/database"; + +export interface DatabaseCreateInput { + projectId: string; + name: string; + branchName?: string; + region?: string; + signal?: AbortSignal; +} + +export interface DatabaseConnectionCreateInput { + databaseId: string; + name: string; + signal?: AbortSignal; +} + +export interface DatabaseCreateRecord { + database: DatabaseSummary; + connection: DatabaseConnectionSummary; + connectionString: string; +} + +export interface DatabaseConnectionCreateRecord { + connection: DatabaseConnectionSummary; + connectionString: string; +} + +export interface DatabaseProvider { + listDatabases(options: { + projectId: string; + branchName?: string; + signal?: AbortSignal; + }): Promise; + showDatabase(databaseId: string, options?: { + projectId?: string; + signal?: AbortSignal; + }): Promise; + createDatabase(options: DatabaseCreateInput): Promise; + removeDatabase(databaseId: string, options?: { signal?: AbortSignal }): Promise; + listConnections(databaseId: string, options?: { signal?: AbortSignal }): Promise; + createConnection(options: DatabaseConnectionCreateInput): Promise; + removeConnection(connectionId: string, options?: { signal?: AbortSignal }): Promise; +} + +interface RawApiErrorBody { + error?: { + code?: string; + message?: string; + hint?: string; + }; +} + +interface RawRegion { + id?: string | null; + name?: string | null; +} + +interface RawBranch { + id?: string | null; + gitName?: string | null; + name?: string | null; +} + +interface RawConnectionEndpoint { + connectionString?: string | null; +} + +interface RawDatabaseConnectionRecord { + id: string; + name?: string | null; + databaseId?: string | null; + createdAt?: string | null; + connectionString?: string | null; + endpoints?: { + pooled?: RawConnectionEndpoint | null; + direct?: RawConnectionEndpoint | null; + accelerate?: RawConnectionEndpoint | null; + } | null; +} + +interface RawDatabaseRecord { + id: string; + name: string; + projectId?: string | null; + branchId?: string | null; + branchGitName?: string | null; + branchName?: string | null; + branch?: RawBranch | null; + region?: RawRegion | string | null; + regionId?: string | null; + status?: string | null; + isDefault?: boolean | null; + createdAt?: string | null; + connections?: RawDatabaseConnectionRecord[] | null; +} + +export function createManagementDatabaseProvider(client: ManagementApiClient): DatabaseProvider { + return { + async listDatabases(options) { + const databases: RawDatabaseRecord[] = []; + let cursor: string | undefined; + + // eslint-disable-next-line no-constant-condition + while (true) { + const result = await client.GET("/v1/databases", { + params: { + query: { + projectId: options.projectId, + branchGitName: options.branchName, + cursor, + }, + }, + signal: options.signal, + }); + if (result.error || !result.data) { + throw databaseApiError("Failed to list databases", result.response, result.error); + } + + databases.push(...result.data.data as RawDatabaseRecord[]); + + if (!result.data.pagination.hasMore || !result.data.pagination.nextCursor) { + break; + } + cursor = result.data.pagination.nextCursor; + } + + return databases.map((database) => normalizeDatabase(database, options.projectId)); + }, + + async showDatabase(databaseId, options) { + const result = await client.GET("/v1/databases/{databaseId}", { + params: { + path: { databaseId }, + }, + signal: options?.signal, + }); + if (result.response?.status === 404) { + return null; + } + if (result.error || !result.data) { + throw databaseApiError("Failed to show database", result.response, result.error); + } + + const database = result.data.data as RawDatabaseRecord; + return normalizeDatabase(database, requireDatabaseProjectId(database, options?.projectId)); + }, + + async createDatabase(options) { + const result = await client.POST("/v1/databases", { + body: { + projectId: options.projectId, + name: options.name, + source: { type: "empty" }, + ...(options.branchName ? { branchGitName: options.branchName } : {}), + ...(options.region ? { region: options.region } : {}), + } as never, + signal: options.signal, + }); + if (result.error || !result.data) { + throw databaseApiError("Failed to create database", result.response, result.error); + } + + return normalizeCreatedDatabase(result.data.data as RawDatabaseRecord, options.projectId); + }, + + async removeDatabase(databaseId, options) { + const result = await client.DELETE("/v1/databases/{databaseId}", { + params: { + path: { databaseId }, + }, + signal: options?.signal, + }); + if (result.error) { + throw databaseApiError("Failed to remove database", result.response, result.error); + } + }, + + async listConnections(databaseId, options) { + const result = await client.GET("/v1/databases/{databaseId}/connections", { + params: { + path: { databaseId }, + }, + signal: options?.signal, + }); + if (result.error || !result.data) { + throw databaseApiError("Failed to list database connections", result.response, result.error); + } + + return (result.data.data as RawDatabaseConnectionRecord[]).map((connection) => normalizeConnection(connection, databaseId)); + }, + + async createConnection(options) { + const result = await client.POST("/v1/databases/{databaseId}/connections", { + params: { + path: { databaseId: options.databaseId }, + }, + body: { + name: options.name, + } as never, + signal: options.signal, + }); + if (result.error || !result.data) { + throw databaseApiError("Failed to create database connection", result.response, result.error); + } + + return normalizeCreatedConnection(result.data.data as RawDatabaseConnectionRecord, options.databaseId); + }, + + async removeConnection(connectionId, options) { + const result = await client.DELETE("/v1/connections/{id}", { + params: { + path: { id: connectionId }, + }, + signal: options?.signal, + }); + if (result.error) { + throw databaseApiError("Failed to remove database connection", result.response, result.error); + } + }, + }; +} + +export function normalizeDatabase(database: RawDatabaseRecord, fallbackProjectId: string): DatabaseSummary { + return { + id: database.id, + name: database.name, + projectId: database.projectId ?? fallbackProjectId, + branchId: database.branchId ?? database.branch?.id ?? null, + branchName: database.branchGitName ?? database.branchName ?? database.branch?.gitName ?? database.branch?.name ?? null, + region: normalizeRegion(database), + status: database.status ?? null, + isDefault: database.isDefault ?? null, + createdAt: database.createdAt ?? null, + }; +} + +export function normalizeConnection( + connection: RawDatabaseConnectionRecord, + fallbackDatabaseId: string, +): DatabaseConnectionSummary { + return { + id: connection.id, + name: connection.name ?? connection.id, + databaseId: connection.databaseId ?? fallbackDatabaseId, + createdAt: connection.createdAt ?? null, + }; +} + +export function normalizeCreatedDatabase(database: RawDatabaseRecord, fallbackProjectId: string): DatabaseCreateRecord { + const rawConnection = database.connections?.[0]; + if (!rawConnection) { + throw new CliError({ + code: "DATABASE_CONNECTION_MISSING", + domain: "database", + summary: "Created database did not return a connection string", + why: "The Management API created the database but did not include the one-time connection payload.", + fix: "Create a connection explicitly with prisma-cli database connection create .", + exitCode: 1, + nextSteps: [`prisma-cli database connection create ${database.id}`], + }); + } + + return { + database: normalizeDatabase(database, fallbackProjectId), + ...normalizeCreatedConnection(rawConnection, database.id), + }; +} + +export function normalizeCreatedConnection( + connection: RawDatabaseConnectionRecord, + fallbackDatabaseId: string, +): DatabaseConnectionCreateRecord { + const connectionString = extractConnectionString(connection); + if (!connectionString) { + throw new CliError({ + code: "DATABASE_CONNECTION_STRING_MISSING", + domain: "database", + summary: "Created connection did not return a connection string", + why: "Database connection strings are one-time-view secrets, but the Management API did not include one in this create response.", + fix: "Create another database connection and store the returned URL immediately.", + exitCode: 1, + nextSteps: [`prisma-cli database connection create ${fallbackDatabaseId}`], + }); + } + + return { + connection: normalizeConnection(connection, fallbackDatabaseId), + connectionString, + }; +} + +function normalizeRegion(database: RawDatabaseRecord): string | null { + if (typeof database.region === "string") { + return database.region; + } + return database.region?.id ?? database.regionId ?? null; +} + +function requireDatabaseProjectId(database: RawDatabaseRecord, fallbackProjectId: string | undefined): string { + const projectId = database.projectId ?? fallbackProjectId; + if (projectId) { + return projectId; + } + + throw new CliError({ + code: "DATABASE_API_ERROR", + domain: "database", + summary: "Database response did not include a project id", + why: "The Management API returned database metadata without project context.", + fix: "Re-run with --trace for the underlying API response details.", + exitCode: 1, + nextSteps: [], + }); +} + +function extractConnectionString(connection: RawDatabaseConnectionRecord): string | null { + return connection.endpoints?.pooled?.connectionString + ?? connection.connectionString + ?? connection.endpoints?.direct?.connectionString + ?? connection.endpoints?.accelerate?.connectionString + ?? null; +} + +function databaseApiError( + summary: string, + response: Response | undefined, + error: RawApiErrorBody | undefined, +): CliError { + const status = response?.status ?? 0; + return new CliError({ + code: error?.error?.code ?? "DATABASE_API_ERROR", + domain: "database", + summary, + why: error?.error?.message ?? `The Management API returned status ${status || "unknown"}.`, + fix: error?.error?.hint ?? "Re-run with --trace for the underlying API response details.", + exitCode: 1, + nextSteps: [], + }); +} diff --git a/packages/cli/src/presenters/database.ts b/packages/cli/src/presenters/database.ts new file mode 100644 index 0000000..42dbfd0 --- /dev/null +++ b/packages/cli/src/presenters/database.ts @@ -0,0 +1,256 @@ +import type { CommandDescriptor } from "../shell/command-meta"; +import { formatDescriptorLabel } from "../shell/command-meta"; +import type { CommandContext } from "../shell/runtime"; +import { formatColumns } from "../shell/ui"; +import { renderMutate, renderShow, serializeList } from "../output/patterns"; +import type { + DatabaseConnectionCreateResult, + DatabaseConnectionListResult, + DatabaseConnectionRemoveResult, + DatabaseCreateResult, + DatabaseListResult, + DatabaseRemoveResult, + DatabaseShowResult, + DatabaseSummary, +} from "../types/database"; +import { renderResolvedProjectContextBlock, stripVerboseContext } from "./verbose-context"; + +export function renderDatabaseList( + context: CommandContext, + descriptor: CommandDescriptor, + result: DatabaseListResult, +): string[] { + const ui = context.ui; + const lines = [ + `${ui.strong(formatDescriptorLabel(descriptor))} ${ui.dim("→")} ${ui.dim("Listing databases for the resolved project.")}`, + "", + ]; + const rail = ui.dim("│"); + lines.push(`${rail} ${ui.accent("project:")} ${result.projectName}`); + if (result.branchName) { + lines.push(`${rail} ${ui.accent("branch:")} ${result.branchName}`); + } + lines.push(rail); + + if (result.databases.length === 0) { + lines.push(`${rail} ${ui.dim("No databases found.")}`); + lines.push(...renderResolvedProjectContextBlock(context.ui, result.verboseContext)); + return lines; + } + + const rows = result.databases.map((database) => [ + database.name, + database.branchName ?? "unscoped", + database.region ?? "unknown", + formatStatus(database), + database.id, + ]); + const widths = [ + Math.max("Name".length, ...rows.map((row) => row[0].length)), + Math.max("Branch".length, ...rows.map((row) => row[1].length)), + Math.max("Region".length, ...rows.map((row) => row[2].length)), + Math.max("Status".length, ...rows.map((row) => row[3].length)), + Math.max("Id".length, ...rows.map((row) => row[4].length)), + ]; + + lines.push(`${rail} ${ui.accent(formatColumns(["Name", "Branch", "Region", "Status", "Id"], widths))}`); + for (const row of rows) { + lines.push(`${rail} ${formatColumns(row, widths)}`); + } + + lines.push(...renderResolvedProjectContextBlock(context.ui, result.verboseContext)); + return lines; +} + +export function serializeDatabaseList(result: DatabaseListResult) { + return { + ...serializeList({ + context: { + project: result.projectName, + ...(result.branchName ? { branch: result.branchName } : {}), + }, + items: result.databases.map((database) => ({ + noun: "database", + label: database.name, + id: database.id, + status: database.isDefault ? "default" : null, + })), + }), + projectId: result.projectId, + branchName: result.branchName, + databases: result.databases, + }; +} + +export function renderDatabaseShow( + context: CommandContext, + descriptor: CommandDescriptor, + result: DatabaseShowResult, +): string[] { + const lines = renderShow( + { + title: "Showing database metadata.", + descriptor, + fields: [ + { key: "project", value: result.projectName }, + { key: "database", value: result.database.name }, + { key: "id", value: result.database.id, tone: "dim" }, + { key: "branch", value: result.database.branchName ?? "unscoped", tone: result.database.branchName ? "default" : "dim" }, + { key: "region", value: result.database.region ?? "unknown", tone: result.database.region ? "default" : "dim" }, + { key: "status", value: formatStatus(result.database) }, + { key: "connections", value: String(result.connections.length) }, + ], + }, + context.ui, + ); + lines.push(...renderResolvedProjectContextBlock(context.ui, result.verboseContext)); + return lines; +} + +export function serializeDatabaseShow(result: DatabaseShowResult) { + return stripVerboseContext(result); +} + +export function renderDatabaseCreateStdout(_context: CommandContext, _descriptor: CommandDescriptor, result: DatabaseCreateResult): string[] { + return [result.connectionString]; +} + +export function renderDatabaseCreate(): string[] { + return []; +} + +export function serializeDatabaseCreate(result: DatabaseCreateResult) { + return stripVerboseContext(result); +} + +export function renderDatabaseRemove( + context: CommandContext, + descriptor: CommandDescriptor, + result: DatabaseRemoveResult, +): string[] { + const lines = renderMutate( + { + title: "Removing database.", + descriptor, + context: [ + { key: "project", value: result.projectName }, + { key: "database", value: result.database.name }, + { key: "id", value: result.database.id, tone: "dim" }, + ], + operationDescription: "Removing database", + operationCount: 1, + details: ["Database and its connection metadata were removed."], + }, + context.ui, + ); + lines.push(...renderResolvedProjectContextBlock(context.ui, result.verboseContext)); + return lines; +} + +export function serializeDatabaseRemove(result: DatabaseRemoveResult) { + return stripVerboseContext(result); +} + +export function renderDatabaseConnectionList( + context: CommandContext, + descriptor: CommandDescriptor, + result: DatabaseConnectionListResult, +): string[] { + const ui = context.ui; + const lines = [ + `${ui.strong(formatDescriptorLabel(descriptor))} ${ui.dim("→")} ${ui.dim("Listing database connection metadata.")}`, + "", + ]; + const rail = ui.dim("│"); + lines.push(`${rail} ${ui.accent("database:")} ${result.database.name}`); + lines.push(rail); + + if (result.connections.length === 0) { + lines.push(`${rail} ${ui.dim("No database connections found.")}`); + lines.push(...renderResolvedProjectContextBlock(context.ui, result.verboseContext)); + return lines; + } + + const rows = result.connections.map((connection) => [ + connection.name, + connection.id, + connection.createdAt ?? "unknown", + ]); + const widths = [ + Math.max("Name".length, ...rows.map((row) => row[0].length)), + Math.max("Id".length, ...rows.map((row) => row[1].length)), + Math.max("Created".length, ...rows.map((row) => row[2].length)), + ]; + + lines.push(`${rail} ${ui.accent(formatColumns(["Name", "Id", "Created"], widths))}`); + for (const row of rows) { + lines.push(`${rail} ${formatColumns(row, widths)}`); + } + + lines.push(...renderResolvedProjectContextBlock(context.ui, result.verboseContext)); + return lines; +} + +export function serializeDatabaseConnectionList(result: DatabaseConnectionListResult) { + return { + ...serializeList({ + context: { + project: result.projectName, + database: result.database.name, + }, + items: result.connections.map((connection) => ({ + noun: "connection", + label: connection.name, + id: connection.id, + status: null, + })), + }), + projectId: result.projectId, + database: result.database, + connections: result.connections, + }; +} + +export function renderDatabaseConnectionCreateStdout( + _context: CommandContext, + _descriptor: CommandDescriptor, + result: DatabaseConnectionCreateResult, +): string[] { + return [result.connectionString]; +} + +export function renderDatabaseConnectionCreate(): string[] { + return []; +} + +export function serializeDatabaseConnectionCreate(result: DatabaseConnectionCreateResult) { + return stripVerboseContext(result); +} + +export function renderDatabaseConnectionRemove( + context: CommandContext, + descriptor: CommandDescriptor, + result: DatabaseConnectionRemoveResult, +): string[] { + return renderMutate( + { + title: "Removing database connection.", + descriptor, + context: [ + { key: "connection", value: result.connection.id, tone: "dim" }, + ], + operationDescription: "Removing database connection", + operationCount: 1, + details: ["The connection metadata was removed. Existing one-time secrets were not shown."], + }, + context.ui, + ); +} + +export function serializeDatabaseConnectionRemove(result: DatabaseConnectionRemoveResult) { + return result; +} + +function formatStatus(database: DatabaseSummary): string { + return database.status ?? (database.isDefault ? "default" : "unknown"); +} diff --git a/packages/cli/src/shell/command-meta.ts b/packages/cli/src/shell/command-meta.ts index 9e191e9..5e40068 100644 --- a/packages/cli/src/shell/command-meta.ts +++ b/packages/cli/src/shell/command-meta.ts @@ -71,6 +71,12 @@ const DESCRIPTORS: CommandDescriptor[] = [ description: "View your Platform branches", examples: ["prisma-cli branch list"], }, + { + id: "database", + path: ["prisma", "database"], + description: "Manage Prisma Postgres databases for a project", + examples: ["prisma-cli database list", "prisma-cli database create my-db", "prisma-cli database connection create db_123"], + }, { id: "git", path: ["prisma", "git"], @@ -123,6 +129,58 @@ const DESCRIPTORS: CommandDescriptor[] = [ description: "List Platform branches for the resolved project", examples: ["prisma-cli branch list", "prisma-cli branch list --json"], }, + { + id: "database.list", + path: ["prisma", "database", "list"], + description: "List Prisma Postgres databases for the resolved project", + examples: ["prisma-cli database list", "prisma-cli database list --branch feature/foo", "prisma-cli database list --json"], + }, + { + id: "database.show", + path: ["prisma", "database", "show"], + description: "Show database metadata without secret values", + examples: ["prisma-cli database show db_123", "prisma-cli database show acme-preview --branch preview --json"], + }, + { + id: "database.create", + path: ["prisma", "database", "create"], + description: "Create a Prisma Postgres database and print its one-time connection URL", + examples: ["prisma-cli database create my-db", "prisma-cli database create my-db --branch feature/foo --region eu-central-1"], + }, + { + id: "database.remove", + path: ["prisma", "database", "remove"], + description: "Remove a database after exact id confirmation", + examples: ["prisma-cli database remove db_123 --confirm db_123"], + }, + { + id: "database.connection", + path: ["prisma", "database", "connection"], + description: "Manage one-time-view database connection strings", + examples: [ + "prisma-cli database connection list db_123", + "prisma-cli database connection create db_123", + "prisma-cli database connection remove conn_123 --confirm conn_123", + ], + }, + { + id: "database.connection.list", + path: ["prisma", "database", "connection", "list"], + description: "List database connection metadata without secret values", + examples: ["prisma-cli database connection list db_123", "prisma-cli database connection list acme-preview --branch preview --json"], + }, + { + id: "database.connection.create", + path: ["prisma", "database", "connection", "create"], + description: "Create a database connection and print its one-time connection URL", + examples: ["prisma-cli database connection create db_123", "prisma-cli database connection create db_123 --name readonly"], + }, + { + id: "database.connection.remove", + path: ["prisma", "database", "connection", "remove"], + description: "Remove a database connection after exact id confirmation", + examples: ["prisma-cli database connection remove conn_123 --confirm conn_123"], + }, { id: "app.build", path: ["prisma", "app", "build"], diff --git a/packages/cli/src/shell/command-runner.ts b/packages/cli/src/shell/command-runner.ts index 40e7838..29af81a 100644 --- a/packages/cli/src/shell/command-runner.ts +++ b/packages/cli/src/shell/command-runner.ts @@ -10,6 +10,11 @@ import { cliErrorToJson, writeHumanError, writeHumanLines, writeJsonError, write import { createCommandContext, type CliRuntime } from "./runtime"; interface CommandPresenter { + renderStdout?: ( + context: Awaited>, + descriptor: CommandDescriptor, + result: T, + ) => string[]; renderHuman: ( context: Awaited>, descriptor: CommandDescriptor, @@ -63,6 +68,11 @@ export async function runCommand( return; } + const stdout = presenter.renderStdout?.(context, descriptor, success.result) ?? []; + if (stdout.length > 0) { + context.output.stdout.write(`${stdout.join("\n")}\n`); + } + if (flags.quiet) { return; } diff --git a/packages/cli/src/shell/errors.ts b/packages/cli/src/shell/errors.ts index a96c031..be923b1 100644 --- a/packages/cli/src/shell/errors.ts +++ b/packages/cli/src/shell/errors.ts @@ -1,6 +1,6 @@ import type { NextAction } from "./next-actions"; -export type ErrorDomain = "cli" | "auth" | "project" | "branch" | "app"; +export type ErrorDomain = "cli" | "auth" | "project" | "branch" | "app" | "database"; export type ErrorSeverity = "error"; export interface CliErrorOptions { diff --git a/packages/cli/src/types/database.ts b/packages/cli/src/types/database.ts new file mode 100644 index 0000000..96023b1 --- /dev/null +++ b/packages/cli/src/types/database.ts @@ -0,0 +1,82 @@ +import type { AuthWorkspace } from "./auth"; +import type { ProjectResolution, ProjectSummary } from "./project"; + +export interface DatabaseResolvedContext { + workspace: AuthWorkspace; + project: ProjectSummary; + resolution: ProjectResolution; +} + +export interface DatabaseSummary { + id: string; + name: string; + projectId: string; + branchId: string | null; + branchName: string | null; + region: string | null; + status: string | null; + isDefault: boolean | null; + createdAt: string | null; +} + +export interface DatabaseConnectionSummary { + id: string; + name: string; + databaseId: string; + createdAt: string | null; +} + +export interface DatabaseListResult { + projectId: string; + projectName: string; + branchName: string | null; + verboseContext?: DatabaseResolvedContext; + databases: DatabaseSummary[]; +} + +export interface DatabaseShowResult { + projectId: string; + projectName: string; + verboseContext?: DatabaseResolvedContext; + database: DatabaseSummary; + connections: DatabaseConnectionSummary[]; +} + +export interface DatabaseCreateResult { + projectId: string; + projectName: string; + verboseContext?: DatabaseResolvedContext; + database: DatabaseSummary; + connection: DatabaseConnectionSummary; + connectionString: string; +} + +export interface DatabaseRemoveResult { + projectId: string; + projectName: string; + verboseContext?: DatabaseResolvedContext; + database: DatabaseSummary; +} + +export interface DatabaseConnectionListResult { + projectId: string; + projectName: string; + verboseContext?: DatabaseResolvedContext; + database: DatabaseSummary; + connections: DatabaseConnectionSummary[]; +} + +export interface DatabaseConnectionCreateResult { + projectId: string; + projectName: string; + verboseContext?: DatabaseResolvedContext; + database: DatabaseSummary; + connection: DatabaseConnectionSummary; + connectionString: string; +} + +export interface DatabaseConnectionRemoveResult { + connection: { + id: string; + }; +} diff --git a/packages/cli/tests/app-build.test.ts b/packages/cli/tests/app-build.test.ts index 6b2e45e..6d1d4ef 100644 --- a/packages/cli/tests/app-build.test.ts +++ b/packages/cli/tests/app-build.test.ts @@ -1,4 +1,4 @@ -import { chmod, lstat, mkdir, readFile, symlink, writeFile } from "node:fs/promises"; +import { lstat, mkdir, readFile, symlink, writeFile } from "node:fs/promises"; import { createRequire } from "node:module"; import path from "node:path"; @@ -462,8 +462,7 @@ describe("preview build strategy", () => { await mkdir(path.dirname(nextBin), { recursive: true }); await writeFile(path.join(appPath, "next.config.ts"), "export default { output: 'standalone' };\n", "utf8"); await writeFile(path.join(standaloneDir, "server.js"), "console.log('next');\n", "utf8"); - await writeFile(nextBin, "#!/bin/sh\nexit 0\n", "utf8"); - await chmod(nextBin, 0o755); + await symlink("/usr/bin/true", nextBin); const { executePreviewBuild } = await import("../src/lib/app/preview-build"); const result = await executePreviewBuild({ diff --git a/packages/cli/tests/database.test.ts b/packages/cli/tests/database.test.ts new file mode 100644 index 0000000..f690bf5 --- /dev/null +++ b/packages/cli/tests/database.test.ts @@ -0,0 +1,357 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import stripAnsi from "strip-ansi"; + +import { createTempCwd, executeCli } from "./helpers"; + +const fixturePath = path.resolve("fixtures/mock-api.json"); + +async function login(cwd: string, stateDir: string) { + await executeCli({ + argv: ["auth", "login", "--provider", "github", "--user", "usr_456"], + cwd, + stateDir, + fixturePath, + }); +} + +async function writeLocalPin(cwd: string, projectId = "proj_123") { + await mkdir(path.join(cwd, ".prisma"), { recursive: true }); + await writeFile( + path.join(cwd, ".prisma/local.json"), + `${JSON.stringify({ workspaceId: "ws_123", projectId }, null, 2)}\n`, + "utf8", + ); +} + +async function setupLinkedProject() { + const cwd = await createTempCwd(); + const stateDir = path.join(cwd, ".state"); + await login(cwd, stateDir); + await writeLocalPin(cwd); + return { cwd, stateDir }; +} + +describe("database commands", () => { + it("renders database and connection help without aliases or connection show", async () => { + const cwd = await createTempCwd(); + const stateDir = path.join(cwd, ".state"); + + const root = await executeCli({ + argv: ["--help"], + cwd, + stateDir, + fixturePath, + }); + const database = await executeCli({ + argv: ["database", "--help"], + cwd, + stateDir, + fixturePath, + }); + const connection = await executeCli({ + argv: ["database", "connection", "--help"], + cwd, + stateDir, + fixturePath, + }); + + expect(root.exitCode).toBe(0); + expect(root.stderr).toContain("database Manage Prisma Postgres databases for a project"); + + expect(database.exitCode).toBe(0); + const databaseHelp = stripAnsi(database.stderr).replace(/[ \t]+\n/g, "\n"); + expect(databaseHelp).toMatchInlineSnapshot(` + "database → Manage Prisma Postgres databases for a project + + │ list List Prisma Postgres databases for the resolved project + │ show Show database metadata without secret values + │ create Create a Prisma Postgres database and print its one-time + │ connection URL + │ remove Remove a database after exact id confirmation + │ connection Manage one-time-view database connection strings + │ + │ Global options: + │ --json Emit structured JSON output. + │ -q, --quiet Reduce human-oriented output. + │ -v, --verbose Increase human-oriented output detail. + │ --trace Show deeper diagnostics for failures. + │ --no-interactive Disable interactive behavior and prompts. + │ -y, --yes Accept supported confirmation prompts. + │ + │ Examples: + │ $ prisma-cli database list + │ $ prisma-cli database create my-db + │ $ prisma-cli database connection create db_123 + " + `); + expect(databaseHelp).not.toContain("db "); + expect(databaseHelp).not.toContain("postgres "); + + expect(connection.exitCode).toBe(0); + const connectionHelp = stripAnsi(connection.stderr).replace(/[ \t]+\n/g, "\n"); + expect(connectionHelp).toMatchInlineSnapshot(` + "database connection → Manage one-time-view database connection strings + + │ list List database connection metadata without secret + │ values + │ create Create a database connection and print its one-time + │ connection URL + │ remove Remove a database connection after exact id + │ confirmation + │ + │ Global options: + │ --json Emit structured JSON output. + │ -q, --quiet Reduce human-oriented output. + │ -v, --verbose Increase human-oriented output detail. + │ --trace Show deeper diagnostics for failures. + │ --no-interactive Disable interactive behavior and prompts. + │ -y, --yes Accept supported confirmation prompts. + │ + │ Examples: + │ $ prisma-cli database connection list db_123 + │ $ prisma-cli database connection create db_123 + │ $ prisma-cli database connection remove conn_123 --confirm conn_123 + " + `); + expect(connectionHelp).not.toContain("show"); + }); + + it("lists databases with the standard JSON envelope and redacts secrets", async () => { + const { cwd, stateDir } = await setupLinkedProject(); + + const result = await executeCli({ + argv: ["database", "list", "--json"], + cwd, + stateDir, + fixturePath, + }); + const payload = JSON.parse(result.stdout); + + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(""); + expect(payload).toMatchObject({ + ok: true, + command: "database.list", + result: { + context: { + project: "Acme Dashboard", + }, + items: [ + { name: "acme-preview", id: "db_123", status: "default" }, + { name: "acme-production", id: "db_456", status: null }, + ], + count: 2, + projectId: "proj_123", + }, + warnings: [], + nextSteps: [], + nextActions: [], + }); + expect(JSON.stringify(payload)).not.toContain("postgresql://secret-preview"); + expect(JSON.stringify(payload)).not.toContain("connectionString"); + }); + + it("shows database metadata and connection metadata without secret values", async () => { + const { cwd, stateDir } = await setupLinkedProject(); + + const result = await executeCli({ + argv: ["database", "show", "db_123", "--json"], + cwd, + stateDir, + fixturePath, + }); + const payload = JSON.parse(result.stdout); + + expect(result.exitCode).toBe(0); + expect(payload.result).toMatchObject({ + database: { + id: "db_123", + name: "acme-preview", + }, + connections: [ + { + id: "conn_123", + name: "primary", + databaseId: "db_123", + }, + ], + }); + expect(JSON.stringify(payload)).not.toContain("postgresql://secret-preview"); + expect(JSON.stringify(payload)).not.toContain("connectionString"); + }); + + it("prints exactly one raw connection URL when creating a database", async () => { + const { cwd, stateDir } = await setupLinkedProject(); + + const result = await executeCli({ + argv: ["database", "create", "scratch", "--branch", "preview"], + cwd, + stateDir, + fixturePath, + }); + + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(""); + expect(result.stdout).toBe("postgresql://db_1003.example.prisma.io/postgres\n"); + expect(result.stdout).not.toContain("DATABASE_URL="); + }); + + it("returns the one-time database connection URL once in JSON", async () => { + const { cwd, stateDir } = await setupLinkedProject(); + + const result = await executeCli({ + argv: ["database", "create", "scratch", "--json"], + cwd, + stateDir, + fixturePath, + }); + const payload = JSON.parse(result.stdout); + + expect(result.exitCode).toBe(0); + expect(payload).toMatchObject({ + ok: true, + command: "database.create", + result: { + database: { + name: "scratch", + }, + connectionString: "postgresql://db_1003.example.prisma.io/postgres", + }, + }); + expect(JSON.stringify(payload).split("postgresql://db_1003")).toHaveLength(2); + }); + + it("prints exactly one raw connection URL when creating a database connection", async () => { + const { cwd, stateDir } = await setupLinkedProject(); + + const result = await executeCli({ + argv: ["database", "connection", "create", "db_123"], + cwd, + stateDir, + fixturePath, + }); + + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(""); + expect(result.stdout).toBe("postgresql://db_123-3.example.prisma.io/postgres\n"); + expect(result.stdout).not.toContain("DATABASE_URL="); + }); + + it("returns the one-time database connection URL once in JSON", async () => { + const { cwd, stateDir } = await setupLinkedProject(); + + const result = await executeCli({ + argv: ["database", "connection", "create", "db_123", "--json"], + cwd, + stateDir, + fixturePath, + }); + const payload = JSON.parse(result.stdout); + + expect(result.exitCode).toBe(0); + expect(payload).toMatchObject({ + ok: true, + command: "database.connection.create", + result: { + database: { + id: "db_123", + }, + connectionString: "postgresql://db_123-3.example.prisma.io/postgres", + }, + }); + expect(JSON.stringify(payload).split("postgresql://db_123-3")).toHaveLength(2); + }); + + it("requires exact database id confirmation before removal", async () => { + const { cwd, stateDir } = await setupLinkedProject(); + + const wrongConfirm = await executeCli({ + argv: ["database", "remove", "acme-preview", "--confirm", "acme-preview", "--json"], + cwd, + stateDir, + fixturePath, + }); + const wrongPayload = JSON.parse(wrongConfirm.stdout); + + expect(wrongConfirm.exitCode).toBe(2); + expect(wrongPayload).toMatchObject({ + ok: false, + command: "database.remove", + error: { + code: "CONFIRMATION_REQUIRED", + domain: "database", + meta: { + expectedConfirm: "db_123", + receivedConfirm: "acme-preview", + }, + }, + }); + + const exactConfirm = await executeCli({ + argv: ["database", "remove", "acme-preview", "--confirm", "db_123", "--json"], + cwd, + stateDir, + fixturePath, + }); + const exactPayload = JSON.parse(exactConfirm.stdout); + + expect(exactConfirm.exitCode).toBe(0); + expect(exactPayload).toMatchObject({ + ok: true, + command: "database.remove", + result: { + database: { + id: "db_123", + name: "acme-preview", + }, + }, + }); + }); + + it("requires exact connection id confirmation before removal", async () => { + const { cwd, stateDir } = await setupLinkedProject(); + + const wrongConfirm = await executeCli({ + argv: ["database", "connection", "remove", "conn_123", "--confirm", "primary", "--json"], + cwd, + stateDir, + fixturePath, + }); + const wrongPayload = JSON.parse(wrongConfirm.stdout); + + expect(wrongConfirm.exitCode).toBe(2); + expect(wrongPayload).toMatchObject({ + ok: false, + command: "database.connection.remove", + error: { + code: "CONFIRMATION_REQUIRED", + domain: "database", + meta: { + expectedConfirm: "conn_123", + receivedConfirm: "primary", + }, + }, + }); + + const exactConfirm = await executeCli({ + argv: ["database", "connection", "remove", "conn_123", "--confirm", "conn_123", "--json"], + cwd, + stateDir, + fixturePath, + }); + const exactPayload = JSON.parse(exactConfirm.stdout); + + expect(exactConfirm.exitCode).toBe(0); + expect(exactPayload).toMatchObject({ + ok: true, + command: "database.connection.remove", + result: { + connection: { + id: "conn_123", + }, + }, + }); + }); +}); diff --git a/packages/cli/tests/shell.test.ts b/packages/cli/tests/shell.test.ts index 25ab1f9..88a7d91 100644 --- a/packages/cli/tests/shell.test.ts +++ b/packages/cli/tests/shell.test.ts @@ -50,7 +50,8 @@ describe("shell behavior", () => { expect(result.stderr).not.toContain("--color"); expect(result.stderr).toContain("$ prisma-cli auth login"); - const commandIndex = result.stderr.indexOf("app Manage apps and deployments for a project"); + const commandMatch = result.stderr.match(/app\s+Manage apps and deployments for a project/); + const commandIndex = commandMatch?.index ?? -1; const descriptionIndex = result.stderr.indexOf("Deploy your app with isolated infrastructure for every branch"); const globalOptionsIndex = result.stderr.indexOf("Global options:"); const examplesIndex = result.stderr.indexOf("Examples:"); @@ -89,6 +90,12 @@ describe("shell behavior", () => { stateDir, fixturePath, }); + const databaseResult = await executeCli({ + argv: ["database"], + cwd, + stateDir, + fixturePath, + }); expect(rootResult.exitCode).toBe(0); expect(rootResult.stderr).toContain("prisma → The Prisma Developer Platform, from your terminal"); @@ -108,6 +115,10 @@ describe("shell behavior", () => { expect(branchResult.exitCode).toBe(0); expect(branchResult.stderr).toContain("branch → View your Platform branches"); expect(branchResult.stderr).toContain("Global options:"); + + expect(databaseResult.exitCode).toBe(0); + expect(databaseResult.stderr).toContain("database → Manage Prisma Postgres databases for a project"); + expect(databaseResult.stderr).toContain("Global options:"); }); it("accepts global flags before the command path", async () => { diff --git a/packages/cli/tests/version.test.ts b/packages/cli/tests/version.test.ts index a3c6a48..f9d75aa 100644 --- a/packages/cli/tests/version.test.ts +++ b/packages/cli/tests/version.test.ts @@ -149,7 +149,7 @@ describe("version", () => { }); expect(result.exitCode).toBe(0); - expect(result.stderr).toContain("version Show CLI build and environment"); + expect(result.stderr).toMatch(/version\s+Show CLI build and environment/); expect(result.stderr).toContain("--version"); expect(result.stderr).toContain("Print the CLI version and exit."); });