Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions docs/product/command-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -610,8 +610,11 @@ Behavior:
- `--branch <git-name>` targets the created database to a Branch when supplied
- `--region <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

Expand Down Expand Up @@ -689,8 +692,12 @@ Behavior:
- resolves `<database>` by exact database id or exact database name inside the resolved project
- supports `--branch <git-name>` to narrow database name resolution
- `--name <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

Expand Down
7 changes: 5 additions & 2 deletions docs/product/output-conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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

Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/adapters/mock-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/commands/database/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
},
);
Expand Down Expand Up @@ -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),
},
);
Expand Down
86 changes: 81 additions & 5 deletions packages/cli/src/presenters/database.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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)}`);
}
21 changes: 14 additions & 7 deletions packages/cli/src/shell/command-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,11 +69,10 @@ export async function runCommand<T>(
}

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;
}

Expand All @@ -82,11 +81,19 @@ export async function runCommand<T>(
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) {
Expand Down
117 changes: 114 additions & 3 deletions packages/cli/tests/command-runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
}
Expand All @@ -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;
Expand All @@ -57,6 +72,7 @@ async function createRuntime(argv: string[]): Promise<{
controller,
stdout,
stderr,
writes,
};
}

Expand Down Expand Up @@ -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");
});
});
Loading
Loading