From 7c0e1a2ef6dda28647dcaed07e834893fbd0f1f9 Mon Sep 17 00:00:00 2001 From: Luan van der Westhuizen Date: Tue, 9 Jun 2026 10:45:35 +0200 Subject: [PATCH] fix(cli): refine database create output --- docs/product/command-spec.md | 11 +- docs/product/output-conventions.md | 7 +- packages/cli/src/adapters/mock-api.ts | 2 +- packages/cli/src/commands/database/index.ts | 4 +- packages/cli/src/presenters/database.ts | 86 +++++++++++++- packages/cli/src/shell/command-runner.ts | 21 ++-- packages/cli/tests/command-runner.test.ts | 117 +++++++++++++++++++- packages/cli/tests/database.test.ts | 114 ++++++++++++++++++- 8 files changed, 338 insertions(+), 24 deletions(-) diff --git a/docs/product/command-spec.md b/docs/product/command-spec.md index 1d3dd8f..4374bd2 100644 --- a/docs/product/command-spec.md +++ b/docs/product/command-spec.md @@ -610,8 +610,11 @@ Behavior: - `--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 +- in default human mode, stderr shows a short creation summary with the resolved Project and Branch when a Branch is present +- in default human mode, stdout contains exactly one line: the raw connection URL - human stderr does not repeat, label, or wrap the connection URL +- `--verbose` adds human-only metadata rows such as Workspace, Project, Branch, Database, region, status, and first connection id on stderr before the URL is written to stdout +- `--quiet` suppresses successful stderr output and leaves stdout as exactly the raw 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 @@ -689,8 +692,12 @@ Behavior: - 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 +- in default human mode, stderr shows a short creation summary with the resolved Project and Branch when a Branch is present +- in default human mode, stdout contains exactly one line: the raw connection URL - human stderr does not repeat, label, or wrap the connection URL +- default human stderr does not show generated connection names; use `--verbose` or `--json` for connection metadata +- `--verbose` adds human-only metadata rows such as Workspace, Project, Branch, Database, and connection id on stderr before the URL is written to stdout +- `--quiet` suppresses successful stderr output and leaves stdout as exactly the raw 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 diff --git a/docs/product/output-conventions.md b/docs/product/output-conventions.md index 0cd7f09..ae4f7a3 100644 --- a/docs/product/output-conventions.md +++ b/docs/product/output-conventions.md @@ -106,10 +106,10 @@ Current MVP commands map to patterns like this: | `branch list` | `list` | | `database list` | `list` | | `database show` | `show` | -| `database create` | raw secret stdout + JSON envelope | +| `database create` | compact mutate stderr + raw secret stdout + JSON envelope | | `database remove` | `mutate` | | `database connection list` | `list` | -| `database connection create` | raw secret stdout + JSON envelope | +| `database connection create` | compact mutate stderr + 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. @@ -123,8 +123,11 @@ human output. Rules: - write exactly one raw secret value per successful create command +- write human creation summaries to stderr before writing the raw secret to stdout - do not repeat the secret on stderr - do not wrap the secret in labels such as `DATABASE_URL=` +- use `--verbose` for human metadata such as resource ids; keep generated names and opaque ids out of default human output unless they are the user-selected target +- `--quiet` suppresses successful human stderr output and still writes the raw secret to stdout - list and show commands must never print or return secret values - in `--json`, include the secret exactly once in the result object diff --git a/packages/cli/src/adapters/mock-api.ts b/packages/cli/src/adapters/mock-api.ts index 21af8b7..6ac694f 100644 --- a/packages/cli/src/adapters/mock-api.ts +++ b/packages/cli/src/adapters/mock-api.ts @@ -188,7 +188,7 @@ export class MockApi { const connection: DatabaseConnectionRecord = { id: `conn_${this.data.databaseConnections.length + 1_000}`, databaseId: database.id, - name: database.name, + name: "primary", createdAt: "2026-06-09T00:00:00.000Z", connectionString, }; diff --git a/packages/cli/src/commands/database/index.ts b/packages/cli/src/commands/database/index.ts index 824a74f..d83e355 100644 --- a/packages/cli/src/commands/database/index.ts +++ b/packages/cli/src/commands/database/index.ts @@ -133,7 +133,7 @@ function createDatabaseCreateCommand(runtime: CliRuntime): Command { (context) => runDatabaseCreate(context, name, { projectRef, branchName, region }), { renderStdout: (context, descriptor, result) => renderDatabaseCreateStdout(context, descriptor, result), - renderHuman: () => renderDatabaseCreate(), + renderHuman: (context, descriptor, result) => renderDatabaseCreate(context, descriptor, result), renderJson: (result) => serializeDatabaseCreate(result), }, ); @@ -230,7 +230,7 @@ function createDatabaseConnectionCreateCommand(runtime: CliRuntime): Command { (context) => runDatabaseConnectionCreate(context, databaseRef, { projectRef, branchName, name }), { renderStdout: (context, descriptor, result) => renderDatabaseConnectionCreateStdout(context, descriptor, result), - renderHuman: () => renderDatabaseConnectionCreate(), + renderHuman: (context, descriptor, result) => renderDatabaseConnectionCreate(context, descriptor, result), renderJson: (result) => serializeDatabaseConnectionCreate(result), }, ); diff --git a/packages/cli/src/presenters/database.ts b/packages/cli/src/presenters/database.ts index 42dbfd0..4642baf 100644 --- a/packages/cli/src/presenters/database.ts +++ b/packages/cli/src/presenters/database.ts @@ -1,7 +1,7 @@ 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 { formatColumns, renderSummaryLine } from "../shell/ui"; import { renderMutate, renderShow, serializeList } from "../output/patterns"; import type { DatabaseConnectionCreateResult, @@ -115,8 +115,24 @@ export function renderDatabaseCreateStdout(_context: CommandContext, _descriptor return [result.connectionString]; } -export function renderDatabaseCreate(): string[] { - return []; +export function renderDatabaseCreate( + context: CommandContext, + _descriptor: CommandDescriptor, + result: DatabaseCreateResult, +): string[] { + const ui = context.ui; + const lines = [ + "Creating database...", + renderSummaryLine(ui, "success", `Created database "${result.database.name}" in ${formatDatabaseTarget(result.projectName, result.database.branchName)}.`), + " The connection URL below is shown once, so save it now.", + ]; + + if (ui.verbose) { + lines.push(""); + lines.push(...renderDatabaseCreateVerboseRows(context, result)); + } + + return lines; } export function serializeDatabaseCreate(result: DatabaseCreateResult) { @@ -219,8 +235,24 @@ export function renderDatabaseConnectionCreateStdout( return [result.connectionString]; } -export function renderDatabaseConnectionCreate(): string[] { - return []; +export function renderDatabaseConnectionCreate( + context: CommandContext, + _descriptor: CommandDescriptor, + result: DatabaseConnectionCreateResult, +): string[] { + const ui = context.ui; + const lines = [ + "Creating connection...", + renderSummaryLine(ui, "success", `Added a connection to "${result.database.name}" in ${formatDatabaseTarget(result.projectName, result.database.branchName)}.`), + " The connection URL below is shown once, so save it now.", + ]; + + if (ui.verbose) { + lines.push(""); + lines.push(...renderDatabaseConnectionCreateVerboseRows(context, result)); + } + + return lines; } export function serializeDatabaseConnectionCreate(result: DatabaseConnectionCreateResult) { @@ -254,3 +286,47 @@ export function serializeDatabaseConnectionRemove(result: DatabaseConnectionRemo function formatStatus(database: DatabaseSummary): string { return database.status ?? (database.isDefault ? "default" : "unknown"); } + +function formatDatabaseTarget(projectName: string, branchName: string | null): string { + return branchName ? `${projectName} / ${branchName}` : projectName; +} + +function renderDatabaseCreateVerboseRows(context: CommandContext, result: DatabaseCreateResult): string[] { + const rows = [ + ...renderWorkspaceProjectRows(result), + ["branch", result.database.branchName ?? "unscoped"], + ["database", formatResourceWithId(context, result.database.name, result.database.id)], + ["region", result.database.region ?? "unknown"], + ["status", formatStatus(result.database)], + ["connection", formatResourceWithId(context, result.connection.name, result.connection.id)], + ]; + + return renderMetadataRows(rows); +} + +function renderDatabaseConnectionCreateVerboseRows(context: CommandContext, result: DatabaseConnectionCreateResult): string[] { + const rows = [ + ...renderWorkspaceProjectRows(result), + ["branch", result.database.branchName ?? "unscoped"], + ["database", formatResourceWithId(context, result.database.name, result.database.id)], + ["connection", formatResourceWithId(context, result.connection.name, result.connection.id)], + ]; + + return renderMetadataRows(rows); +} + +function renderWorkspaceProjectRows(result: DatabaseCreateResult | DatabaseConnectionCreateResult): string[][] { + return [ + ...(result.verboseContext ? [["workspace", result.verboseContext.workspace.name]] : []), + ["project", result.projectName], + ]; +} + +function formatResourceWithId(context: CommandContext, name: string, id: string): string { + return `${name} ${context.ui.dim(`(${id})`)}`; +} + +function renderMetadataRows(rows: string[][]): string[] { + const widths = [Math.max(...rows.map((row) => row[0].length)), 0]; + return rows.map(([key, value]) => ` ${formatColumns([key, value], widths)}`); +} diff --git a/packages/cli/src/shell/command-runner.ts b/packages/cli/src/shell/command-runner.ts index 29af81a..07f9d05 100644 --- a/packages/cli/src/shell/command-runner.ts +++ b/packages/cli/src/shell/command-runner.ts @@ -69,11 +69,10 @@ export async function runCommand( } const stdout = presenter.renderStdout?.(context, descriptor, success.result) ?? []; - if (stdout.length > 0) { - context.output.stdout.write(`${stdout.join("\n")}\n`); - } - if (flags.quiet) { + if (stdout.length > 0) { + context.output.stdout.write(`${stdout.join("\n")}\n`); + } return; } @@ -82,11 +81,19 @@ export async function runCommand( enabled: flags.verbose && rendered.length > 0, durationMs: Date.now() - startedAt, }); - - writeHumanLines(context.output, [ + const humanLines = [ ...rendered, ...diagnostics, - ]); + ]; + if (stdout.length > 0 && humanLines.length > 0) { + humanLines.push(""); + } + + writeHumanLines(context.output, humanLines); + + if (stdout.length > 0) { + context.output.stdout.write(`${stdout.join("\n")}\n`); + } } catch (error) { const cliError = toCliError(error, runtime); if (cliError) { diff --git a/packages/cli/tests/command-runner.test.ts b/packages/cli/tests/command-runner.test.ts index adf0ae7..21858ad 100644 --- a/packages/cli/tests/command-runner.test.ts +++ b/packages/cli/tests/command-runner.test.ts @@ -5,14 +5,27 @@ import { runCommand } from "../src/shell/command-runner"; import type { CliRuntime } from "../src/shell/runtime"; import { createTempCwd } from "./helpers"; +interface CapturedWrite { + stream: "stdout" | "stderr"; + chunk: string; +} + class CaptureStream extends Writable { buffer = ""; declare isTTY?: boolean; declare columns?: number; declare rows?: number; + constructor(private readonly streamName?: "stdout" | "stderr", private readonly writes?: CapturedWrite[]) { + super(); + } + _write(chunk: Buffer | string, _encoding: BufferEncoding, callback: (error?: Error | null) => void) { - this.buffer += chunk.toString(); + const text = chunk.toString(); + this.buffer += text; + if (this.streamName && this.writes) { + this.writes.push({ stream: this.streamName, chunk: text }); + } callback(); } } @@ -29,9 +42,11 @@ async function createRuntime(argv: string[]): Promise<{ controller: AbortController; stdout: CaptureStream; stderr: CaptureStream; + writes: CapturedWrite[]; }> { - const stdout = new CaptureStream(); - const stderr = new CaptureStream(); + const writes: CapturedWrite[] = []; + const stdout = new CaptureStream("stdout", writes); + const stderr = new CaptureStream("stderr", writes); stdout.isTTY = false; stderr.isTTY = false; stdout.columns = 80; @@ -57,6 +72,7 @@ async function createRuntime(argv: string[]): Promise<{ controller, stdout, stderr, + writes, }; } @@ -147,4 +163,99 @@ describe("command runner success output", () => { }); expect(stdout.buffer).not.toContain("Local context"); }); + + it("writes human stderr before raw stdout when both are rendered", async () => { + const { runtime, stdout, stderr, writes } = await createRuntime(["project", "show"]); + + await runCommand( + runtime, + "project.show", + {}, + async () => ({ + command: "project.show", + result: { ok: true }, + warnings: [], + nextSteps: [], + }), + { + renderStdout: () => ["raw-value"], + renderHuman: () => ["Created resource"], + }, + ); + + expect(process.exitCode).toBeUndefined(); + expect(stderr.buffer).toBe("Created resource\n\n"); + expect(stdout.buffer).toBe("raw-value\n"); + expect(writes.map((write) => write.stream)).toEqual(["stderr", "stdout"]); + }); + + it("suppresses human output in quiet mode while preserving raw stdout", async () => { + const { runtime, stdout, stderr, writes } = await createRuntime(["project", "show", "--quiet"]); + let renderHumanCalled = false; + + await runCommand( + runtime, + "project.show", + { quiet: true }, + async () => ({ + command: "project.show", + result: { ok: true }, + warnings: [], + nextSteps: [], + }), + { + renderStdout: () => ["raw-value"], + renderHuman: () => { + renderHumanCalled = true; + return ["Created resource"]; + }, + }, + ); + + expect(process.exitCode).toBeUndefined(); + expect(renderHumanCalled).toBe(false); + expect(stderr.buffer).toBe(""); + expect(stdout.buffer).toBe("raw-value\n"); + expect(writes.map((write) => write.stream)).toEqual(["stdout"]); + }); + + it("bypasses raw stdout and human output in JSON mode", async () => { + const { runtime, stdout, stderr } = await createRuntime(["project", "show", "--json"]); + let renderStdoutCalled = false; + let renderHumanCalled = false; + + await runCommand( + runtime, + "project.show", + { json: true }, + async () => ({ + command: "project.show", + result: { ok: true }, + warnings: [], + nextSteps: [], + }), + { + renderStdout: () => { + renderStdoutCalled = true; + return ["raw-value"]; + }, + renderHuman: () => { + renderHumanCalled = true; + return ["Created resource"]; + }, + }, + ); + + expect(process.exitCode).toBeUndefined(); + expect(renderStdoutCalled).toBe(false); + expect(renderHumanCalled).toBe(false); + expect(stderr.buffer).toBe(""); + expect(JSON.parse(stdout.buffer)).toMatchObject({ + ok: true, + command: "project.show", + result: { ok: true }, + }); + expect(stdout.buffer).not.toContain("raw-value"); + expect(stdout.buffer).not.toContain("Created resource"); + }); }); diff --git a/packages/cli/tests/database.test.ts b/packages/cli/tests/database.test.ts index f690bf5..4af07f8 100644 --- a/packages/cli/tests/database.test.ts +++ b/packages/cli/tests/database.test.ts @@ -193,9 +193,71 @@ describe("database commands", () => { }); expect(result.exitCode).toBe(0); - expect(result.stderr).toBe(""); + expect(stripAnsi(result.stderr)).toBe( + "Creating database...\n" + + "✔ Created database \"scratch\" in Acme Dashboard / preview.\n" + + " The connection URL below is shown once, so save it now.\n" + + "\n", + ); expect(result.stdout).toBe("postgresql://db_1003.example.prisma.io/postgres\n"); expect(result.stdout).not.toContain("DATABASE_URL="); + expect(`${result.stdout}${result.stderr}`.split("postgresql://db_1003")).toHaveLength(2); + }); + + it("omits the branch breadcrumb when creating an unscoped database", async () => { + const { cwd, stateDir } = await setupLinkedProject(); + + const result = await executeCli({ + argv: ["database", "create", "scratch"], + cwd, + stateDir, + fixturePath, + }); + + expect(result.exitCode).toBe(0); + expect(stripAnsi(result.stderr)).toContain("✔ Created database \"scratch\" in Acme Dashboard.\n"); + expect(stripAnsi(result.stderr)).not.toContain("Acme Dashboard /"); + expect(result.stdout).toBe("postgresql://db_1003.example.prisma.io/postgres\n"); + }); + + it("prints database create metadata only in verbose human output", async () => { + const { cwd, stateDir } = await setupLinkedProject(); + + const result = await executeCli({ + argv: ["database", "create", "scratch", "--branch", "preview", "--region", "eu-central-1", "--verbose"], + cwd, + stateDir, + fixturePath, + }); + const stderr = stripAnsi(result.stderr); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe("postgresql://db_1003.example.prisma.io/postgres\n"); + expect(stderr).toContain("✔ Created database \"scratch\" in Acme Dashboard / preview.\n"); + expect(stderr).toContain(" workspace Acme Inc\n"); + expect(stderr).toContain(" project Acme Dashboard\n"); + expect(stderr).toContain(" branch preview\n"); + expect(stderr).toContain(" database scratch (db_1003)\n"); + expect(stderr).toContain(" region eu-central-1\n"); + expect(stderr).toContain(" status ready\n"); + expect(stderr).toContain(" connection primary (conn_1002)\n"); + expect(stderr).toContain("Local context:"); + expect(stderr).not.toContain("postgresql://db_1003"); + }); + + it("prints only the raw connection URL when creating a database quietly", async () => { + const { cwd, stateDir } = await setupLinkedProject(); + + const result = await executeCli({ + argv: ["database", "create", "scratch", "--branch", "preview", "--quiet"], + cwd, + stateDir, + fixturePath, + }); + + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(""); + expect(result.stdout).toBe("postgresql://db_1003.example.prisma.io/postgres\n"); }); it("returns the one-time database connection URL once in JSON", async () => { @@ -210,6 +272,7 @@ describe("database commands", () => { const payload = JSON.parse(result.stdout); expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(""); expect(payload).toMatchObject({ ok: true, command: "database.create", @@ -234,9 +297,55 @@ describe("database commands", () => { }); expect(result.exitCode).toBe(0); - expect(result.stderr).toBe(""); + expect(stripAnsi(result.stderr)).toBe( + "Creating connection...\n" + + "✔ Added a connection to \"acme-preview\" in Acme Dashboard / preview.\n" + + " The connection URL below is shown once, so save it now.\n" + + "\n", + ); expect(result.stdout).toBe("postgresql://db_123-3.example.prisma.io/postgres\n"); expect(result.stdout).not.toContain("DATABASE_URL="); + expect(stripAnsi(result.stderr)).not.toContain("cli-"); + expect(stripAnsi(result.stderr)).not.toContain("conn_"); + expect(`${result.stdout}${result.stderr}`.split("postgresql://db_123-3")).toHaveLength(2); + }); + + it("prints database connection create metadata only in verbose human output", async () => { + const { cwd, stateDir } = await setupLinkedProject(); + + const result = await executeCli({ + argv: ["database", "connection", "create", "db_123", "--name", "readonly", "--verbose"], + cwd, + stateDir, + fixturePath, + }); + const stderr = stripAnsi(result.stderr); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe("postgresql://db_123-3.example.prisma.io/postgres\n"); + expect(stderr).toContain("✔ Added a connection to \"acme-preview\" in Acme Dashboard / preview.\n"); + expect(stderr).toContain(" workspace Acme Inc\n"); + expect(stderr).toContain(" project Acme Dashboard\n"); + expect(stderr).toContain(" branch preview\n"); + expect(stderr).toContain(" database acme-preview (db_123)\n"); + expect(stderr).toContain(" connection readonly (conn_1002)\n"); + expect(stderr).toContain("Local context:"); + expect(stderr).not.toContain("postgresql://db_123-3"); + }); + + it("prints only the raw connection URL when creating a database connection quietly", async () => { + const { cwd, stateDir } = await setupLinkedProject(); + + const result = await executeCli({ + argv: ["database", "connection", "create", "db_123", "--quiet"], + 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"); }); it("returns the one-time database connection URL once in JSON", async () => { @@ -251,6 +360,7 @@ describe("database commands", () => { const payload = JSON.parse(result.stdout); expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(""); expect(payload).toMatchObject({ ok: true, command: "database.connection.create",