From 703c7c1201a934f8f9de104dec7cebc78a73cc14 Mon Sep 17 00:00:00 2001 From: willbot Date: Wed, 12 Aug 2026 20:31:39 +0200 Subject: [PATCH 01/11] Render help from the engine's own presentation system, and tighten the output conventions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Help never touches stricli's text_en again: a new engine renderer draws the command tree with the same tones every block uses — banner and tagline, the rail card, mount-ordered 'name brief' rows, one Global options section at the root, leaf-only flag signatures, examples, and docs links. Color on a TTY, plain when piped; honors --color/--no-color and NO_COLOR. Output conventions move into the renderer so they stop being per-command choices: sentence-cased table headers, a dim em-dash placeholder for absent values, colored diagnostics (severity glyph in its tone, dim code and why/docs, accent next-action arrows), and standardized empty states. The machine stdout mirror is suppressed when stdout and stderr are both TTYs — the data was already on screen as the human blocks — while any redirection keeps it, so pipes are unchanged. Unknown commands now suggest the nearest mounted command and point at --help. Command fixes: branch list gains the --project flag its own error recommended, project show --project stops claiming the directory is linked, service list shows the project name instead of the raw id. Co-Authored-By: Claude Fable 5 Signed-off-by: willbot Signed-off-by: Will Madden --- packages/cli-engine/src/cli.ts | 7 + packages/cli-engine/src/execution/engine.ts | 36 +- packages/cli-engine/src/execution/help.ts | 531 ++++++++++++++++++ packages/cli-engine/src/execution/needs.ts | 7 +- .../cli-engine/src/execution/rendering.ts | 79 ++- .../cli-engine/src/execution/settlement.ts | 89 ++- .../src/execution/stricli-adapter.ts | 2 +- packages/cli-engine/tests/blocks.test.ts | 12 +- packages/cli-engine/tests/execution.test.ts | 2 +- packages/cli/src/cli.ts | 7 + .../cli/src/commands/agent/presentation.ts | 6 +- packages/cli/src/commands/branch/list.ts | 27 +- packages/cli/src/commands/bucket/key-list.ts | 8 +- packages/cli/src/commands/bucket/list.ts | 8 +- .../cli/src/commands/postgres/backup-list.ts | 8 +- .../src/commands/postgres/connection-list.ts | 8 +- packages/cli/src/commands/postgres/list.ts | 8 +- packages/cli/src/commands/project/env-list.ts | 5 +- packages/cli/src/commands/project/list.ts | 12 +- packages/cli/src/commands/project/show.ts | 5 +- packages/cli/src/commands/service/list.ts | 1 + .../cli/src/commands/service/presentation.ts | 8 +- packages/cli/src/commands/service/results.ts | 1 + packages/cli/tests/bin.test.ts | 19 +- packages/cli/tests/branch.test.ts | 9 +- packages/cli/tests/bucket.test.ts | 10 +- packages/cli/tests/golden-rendering.test.ts | 8 +- packages/cli/tests/init.test.ts | 7 +- packages/cli/tests/postgres.test.ts | 15 +- packages/cli/tests/project.test.ts | 12 +- packages/cli/tests/service-list.test.ts | 2 + 31 files changed, 876 insertions(+), 83 deletions(-) create mode 100644 packages/cli-engine/src/execution/help.ts diff --git a/packages/cli-engine/src/cli.ts b/packages/cli-engine/src/cli.ts index 76bffc8e..66a7152d 100644 --- a/packages/cli-engine/src/cli.ts +++ b/packages/cli-engine/src/cli.ts @@ -44,6 +44,13 @@ export function createCli(spec: { Record >; readonly commands: MountedTree; + /** Words for the root help card; the engine formats. */ + readonly help?: { + readonly tagline?: string; + readonly description?: string; + readonly examples?: readonly string[]; + readonly docsUrl?: string; + }; /** * Declaring this, together with a `Runtime.spawnTelemetry` seam, * turns telemetry on: the engine reads the user's preference, diff --git a/packages/cli-engine/src/execution/engine.ts b/packages/cli-engine/src/execution/engine.ts index 55fb57b9..dca7debf 100644 --- a/packages/cli-engine/src/execution/engine.ts +++ b/packages/cli-engine/src/execution/engine.ts @@ -33,10 +33,17 @@ import { buildCommandTree, buildRedirectTable, type CommandTreeEntry, + type CommandTreeNode, matchFlagRedirect, matchVerbRedirect, type RedirectTable, } from "./command-tree"; +import { + bareGroupInvocation, + helpColorEnabled, + helpFlagGiven, + renderHelp, +} from "./help"; import { checkNeeds, type NeedsOutcome } from "./needs"; import { configFlagGivenNoValue, @@ -83,6 +90,16 @@ export interface EngineSpec { Record >; readonly commands: MountedTree; + /** Words for the root help card; the engine formats. */ + readonly help?: { + /** One line after the binary name: what this CLI is. */ + readonly tagline?: string; + /** A sentence or two under the command list. */ + readonly description?: string; + /** Same {bin} substitution rule as command examples. */ + readonly examples?: readonly string[]; + readonly docsUrl?: string; + }; /** Absent means this CLI reports nothing. */ readonly telemetry?: TelemetryDeclaration; } @@ -257,6 +274,7 @@ type ErasedServerHandler = ( export class EngineImpl implements Engine { private readonly spec: EngineSpec; + private readonly tree: CommandTreeNode; private readonly root: StricliRouteMap; private readonly redirects: RedirectTable; private readonly now: () => Date; @@ -272,9 +290,10 @@ export class EngineImpl implements Engine { this.now = now; this.delay = delay; this.configSections = declaredConfigSections(spec); + this.tree = buildCommandTree(spec); this.root = buildRoutes( spec, - buildCommandTree(spec), + this.tree, "", (invocation, entry, flags, values) => this.executeMounted(invocation, entry, flags, values), @@ -353,6 +372,21 @@ export class EngineImpl implements Engine { settleErrored(invocation, configFlagGivenNoValueError()); return 2; } + if (helpFlagGiven(argv) || bareGroupInvocation(this.tree, argv)) { + unsubscribe(); + /** Help prose follows stricli's channel rule: stdout in human + * mode, stderr in json mode so stdout stays a clean frame + * stream. Never fires telemetry, like --version. */ + const stream = format === "human" ? runtime.stdout : runtime.stderr; + renderHelp( + this.spec, + this.tree, + argv, + helpColorEnabled(argv, runtime, format), + stream, + ); + return 0; + } const stricliProcess = { /** stricli writes only help text here. In json mode stdout carries * exactly the frame stream, so help prose goes to stderr instead. */ diff --git a/packages/cli-engine/src/execution/help.ts b/packages/cli-engine/src/execution/help.ts new file mode 100644 index 00000000..2d4af362 --- /dev/null +++ b/packages/cli-engine/src/execution/help.ts @@ -0,0 +1,531 @@ +/** + * Engine-rendered help: the command tree drawn with the same tones the + * block renderer uses, so help is themed like every other surface. + * stricli's text_en renderer is never consulted — root and group help + * list `name brief` rows, and full signatures appear only on the leaf + * that owns them. + */ +import { + type FlagRuntimeSpec, + flagRuntime, + kebabCase, + type PositionalSpec, + positionalRuntime, +} from "../args"; +import type { AnyCommand } from "../commands"; +import type { CommandTreeEntry, CommandTreeNode } from "./command-tree"; +import type { EngineSpec } from "./engine"; +import { makePaint, type Paint, textWidth } from "./palette"; +import { SHARED_ALIASES, SHARED_FLAG_PARAMETERS } from "./shared-flags"; +import { NO_JSON_NOTE, resolveExample } from "./stricli-adapter"; + +const RAIL = "│"; +const GAP = " "; +const WRAP_WIDTH = 76; + +function flagTokens(argv: readonly string[]): readonly string[] { + const terminator = argv.indexOf("--"); + return terminator === -1 ? argv : argv.slice(0, terminator); +} + +export function helpFlagGiven(argv: readonly string[]): boolean { + return flagTokens(argv).some( + (token) => token === "-h" || token === "--help" || token === "--help-all", + ); +} + +/** Help renders before the shared flags are parsed, so its colour + * decision reads raw argv: explicit flag, then NO_COLOR, then the TTY + * of the stream help writes to. */ +export function helpColorEnabled( + argv: readonly string[], + runtime: { + readonly env: Readonly>; + readonly isTty: { readonly stdout: boolean; readonly stderr: boolean }; + }, + format: "human" | "json", +): boolean { + const tokens = flagTokens(argv); + if (tokens.includes("--no-color")) { + return false; + } + if (tokens.includes("--color")) { + return true; + } + if (runtime.env.NO_COLOR !== undefined) { + return false; + } + return format === "human" ? runtime.isTty.stdout : runtime.isTty.stderr; +} + +/** The command path the user asked help for: the leading non-flag + * tokens, resolved as far as the tree recognizes them. */ +function helpPath(argv: readonly string[]): readonly string[] { + const segments: string[] = []; + for (const token of argv) { + if (token.startsWith("-")) { + break; + } + segments.push(token); + } + return segments; +} + +type HelpTarget = + | { readonly kind: "node"; readonly node: CommandTreeNode } + | { readonly kind: "leaf"; readonly entry: CommandTreeEntry }; + +/** Walks as far as the segments stay recognized; help for `project + * frobnicate` is project's help, not a dead end. */ +function resolveTarget( + root: CommandTreeNode, + segments: readonly string[], +): { target: HelpTarget; path: readonly string[] } { + let node = root; + const path: string[] = []; + for (const segment of segments) { + const entry = node.commands.get(segment); + if (entry !== undefined) { + return { target: { kind: "leaf", entry }, path: [...path, segment] }; + } + const child = node.children.get(segment); + if (child === undefined) { + break; + } + node = child; + path.push(segment); + } + return { target: { kind: "node", node }, path }; +} + +/** A bare group invocation (`prisma-cli project`) is a help request; + * a bare leaf is a command run and is left alone. */ +export function bareGroupInvocation( + root: CommandTreeNode, + argv: readonly string[], +): boolean { + const segments = helpPath(argv); + if (segments.length === 0) { + return flagTokens(argv).every((token) => token !== "--version"); + } + const { target, path } = resolveTarget(root, segments); + return target.kind === "node" && path.length === segments.length; +} + +interface HelpWriter { + write(text: string): void; +} + +export function renderHelp( + spec: EngineSpec, + root: CommandTreeNode, + argv: readonly string[], + colorEnabled: boolean, + out: HelpWriter, +): void { + const paint = makePaint(colorEnabled); + const { target, path } = resolveTarget(root, helpPath(argv)); + const lines: string[] = []; + if (target.kind === "leaf") { + renderLeafHelp(spec, target.entry, path, paint, lines); + } else { + renderNodeHelp(spec, target.node, path, paint, lines); + } + out.write(`${lines.join("\n")}\n`); +} + +/** `prisma-cli project → Manage and inspect your Prisma projects` */ +function header( + spec: EngineSpec, + path: readonly string[], + tagline: string | undefined, + paint: Paint, +): string { + const name = paint("emphasis", [spec.name, ...path].join(" ")); + if (tagline === undefined || tagline === "") { + return name; + } + return `${name} ${paint("muted", `→ ${tagline}`)}`; +} + +function rail(paint: Paint, rest = ""): string { + return rest === "" + ? paint("structure", RAIL) + : `${paint("structure", RAIL)}${GAP}${rest}`; +} + +function sectionLabel(paint: Paint, label: string): string { + return rail(paint, paint("muted", label)); +} + +/** Two-column rows under the rail: name in the accent, brief plain. */ +function railRows( + rows: ReadonlyArray<{ name: string; brief: string; suffix?: string }>, + paint: Paint, + lines: string[], +): void { + const width = Math.max(0, ...rows.map((row) => textWidth(row.name))); + for (const row of rows) { + const pad = " ".repeat(width - textWidth(row.name)); + const suffix = + row.suffix === undefined || row.suffix === "" + ? "" + : ` ${paint("muted", row.suffix)}`; + lines.push( + rail( + paint, + `${paint("identifier", row.name)}${pad}${GAP}${row.brief}${suffix}`, + ), + ); + } +} + +function wrap(text: string, width: number): string[] { + const lines: string[] = []; + for (const paragraph of text.split("\n")) { + if (paragraph === "") { + lines.push(""); + continue; + } + let line = ""; + for (const word of paragraph.split(" ")) { + if (line !== "" && line.length + 1 + word.length > width) { + lines.push(line); + line = word; + } else { + line = line === "" ? word : `${line} ${word}`; + } + } + if (line !== "") { + lines.push(line); + } + } + return lines; +} + +function proseLines( + text: string, + paint: Paint, + lines: string[], + tone: "muted" | "plain" = "plain", +): void { + for (const line of wrap(text, WRAP_WIDTH)) { + lines.push(rail(paint, tone === "muted" ? paint("muted", line) : line)); + } +} + +function exampleLines( + examples: readonly string[], + cliName: string, + paint: Paint, + lines: string[], +): void { + if (examples.length === 0) { + return; + } + lines.push(rail(paint)); + lines.push(sectionLabel(paint, "Examples")); + for (const example of examples) { + lines.push( + rail( + paint, + `${GAP}${paint("muted", "$")} ${resolveExample(example, cliName)}`, + ), + ); + } +} + +function docsLine( + url: string | undefined, + paint: Paint, + lines: string[], +): void { + if (url === undefined) { + return; + } + lines.push(rail(paint)); + lines.push( + rail(paint, `${paint("muted", "Docs")}${GAP}${paint("link", url)}`), + ); +} + +/** `--interactive/--no-interactive`, `-q, --quiet`, `--config ` — + * one spelling rule for shared and declared flags alike. */ +function flagLabel( + key: string, + spec: { + readonly kind?: string; + readonly alias?: string; + readonly placeholder?: string; + readonly withNegated?: boolean; + readonly variadic?: boolean; + }, +): string { + const kebab = kebabCase(key); + const alias = spec.alias === undefined ? " " : `-${spec.alias},`; + const negated = spec.withNegated === true ? `/--no-${kebab}` : ""; + const placeholder = + spec.placeholder === undefined ? "" : ` <${spec.placeholder}>`; + const repeat = spec.variadic === true ? "..." : ""; + return `${alias} --${kebab}${negated}${placeholder}${repeat}`; +} + +function sharedFlagRows(): ReadonlyArray<{ + name: string; + brief: string; + suffix?: string; +}> { + const aliasByKey = new Map( + Object.entries(SHARED_ALIASES).map(([alias, key]) => [key, alias]), + ); + const rows = Object.entries(SHARED_FLAG_PARAMETERS).map(([key, spec]) => { + const record = spec as { + brief: string; + kind: string; + placeholder?: string; + withNegated?: boolean; + variadic?: boolean; + values?: readonly string[]; + }; + return { + name: flagLabel(key, { ...record, alias: aliasByKey.get(key) }), + brief: record.brief, + suffix: record.values === undefined ? undefined : record.values.join("|"), + }; + }); + return [ + ...rows, + { name: `-h, --help`, brief: "Print help for a command" }, + { name: ` --version`, brief: "Print the CLI version and exit" }, + ]; +} + +function declaredFlagRows( + def: AnyCommand, +): ReadonlyArray<{ name: string; brief: string; suffix?: string }> { + return Object.entries(def.args.flags).map(([key, spec]) => { + const runtime: FlagRuntimeSpec = flagRuntime(spec); + return { + name: flagLabel(key, { + alias: runtime.alias, + placeholder: + runtime.type === "boolean" || runtime.type === "optionalBoolean" + ? undefined + : (runtime.placeholder ?? "value"), + withNegated: runtime.type === "optionalBoolean", + variadic: runtime.type === "repeated", + }), + brief: runtime.brief, + suffix: flagSuffix(runtime), + }; + }); +} + +function flagSuffix(runtime: FlagRuntimeSpec): string | undefined { + const parts: string[] = []; + if (runtime.values !== undefined && runtime.values.length > 0) { + parts.push(runtime.values.join("|")); + } + if (runtime.type === "requiredString") { + parts.push("required"); + } + if (runtime.default !== undefined) { + parts.push(`default: ${String(runtime.default)}`); + } + return parts.length === 0 ? undefined : `(${parts.join("; ")})`; +} + +function positionalUsage(def: AnyCommand): string { + return Object.values>(def.args.positionals) + .map((spec) => { + const runtime = positionalRuntime(spec); + if (runtime.type === "optionalString") { + return `[${runtime.placeholder}]`; + } + if (runtime.type === "variadic") { + return `[${runtime.placeholder}...]`; + } + return `<${runtime.placeholder}>`; + }) + .join(" "); +} + +function requiredFlagUsage(def: AnyCommand): string { + return Object.entries(def.args.flags) + .flatMap(([key, spec]) => { + const runtime = flagRuntime(spec); + return runtime.type === "requiredString" + ? [`--${kebabCase(key)} <${runtime.placeholder ?? "value"}>`] + : []; + }) + .join(" "); +} + +/** Mount order, not map-partition order: a leaf and a group list in + * the order their first command was mounted. */ +function nodeRows( + spec: EngineSpec, + node: CommandTreeNode, + path: readonly string[], +): Array<{ name: string; brief: string }> { + const groupPath = path.join(" "); + const depth = path.length; + const seen = new Set(); + const rows: Array<{ name: string; brief: string }> = []; + for (const mounted of Object.keys(spec.commands)) { + const segments = mounted.split(" "); + if ( + segments.length <= depth || + segments.slice(0, depth).join(" ") !== groupPath + ) { + continue; + } + const name = segments[depth]; + if (seen.has(name)) { + continue; + } + seen.add(name); + const entry = node.commands.get(name); + if (entry !== undefined) { + rows.push({ + name: usageName(name, entry.def), + brief: entry.def.help.summary, + }); + } else if (node.children.has(name)) { + const childPath = depth === 0 ? name : `${groupPath} ${name}`; + rows.push({ name, brief: spec.groups[childPath]?.brief ?? "" }); + } + } + return rows; +} + +function renderNodeHelp( + spec: EngineSpec, + node: CommandTreeNode, + path: readonly string[], + paint: Paint, + lines: string[], +): void { + const atRoot = path.length === 0; + const groupPath = path.join(" "); + const tagline = atRoot ? spec.help?.tagline : spec.groups[groupPath]?.brief; + lines.push(header(spec, path, tagline, paint)); + lines.push(""); + railRows(nodeRows(spec, node, path), paint, lines); + + const description = atRoot + ? spec.help?.description + : spec.groups[groupPath]?.description; + if (description !== undefined) { + lines.push(rail(paint)); + proseLines(description, paint, lines); + } + + if (atRoot) { + lines.push(rail(paint)); + lines.push(sectionLabel(paint, "Global options")); + railRows(sharedFlagRows(), paint, lines); + exampleLines(spec.help?.examples ?? [], spec.name, paint, lines); + docsLine(spec.help?.docsUrl, paint, lines); + } else { + lines.push(rail(paint)); + lines.push( + rail( + paint, + paint( + "muted", + `Run '${spec.name} ${groupPath} --help' for details on a command.`, + ), + ), + ); + } + lines.push(""); +} + +/** `link [id-or-name]` — the row a group lists for a leaf: name plus + * positional shape, briefs carry the rest. */ +function usageName(name: string, def: AnyCommand): string { + const positionals = positionalUsage(def); + return positionals === "" ? name : `${name} ${positionals}`; +} + +function renderLeafHelp( + spec: EngineSpec, + entry: CommandTreeEntry, + path: readonly string[], + paint: Paint, + lines: string[], +): void { + const def = entry.def; + lines.push(header(spec, path, def.help.summary, paint)); + lines.push(""); + + const usageParts = [ + spec.name, + ...path, + requiredFlagUsage(def), + "[options]", + positionalUsage(def), + ].filter((part) => part !== ""); + lines.push(sectionLabel(paint, "Usage")); + lines.push( + rail( + paint, + `${GAP}${paint("muted", "$")} ${paint("emphasis", usageParts.join(" "))}`, + ), + ); + + if (def.help.description !== undefined) { + lines.push(rail(paint)); + proseLines(def.help.description, paint, lines); + } + if (def.maySpawn) { + lines.push(rail(paint)); + // One line on purpose: the sentence is the contract several tests + // and consumers grep for, so it never wraps. + lines.push(rail(paint, paint("muted", NO_JSON_NOTE))); + } + + const positionalEntries = Object.values>( + def.args.positionals, + ).map((spec) => positionalRuntime(spec)); + if (positionalEntries.length > 0) { + lines.push(rail(paint)); + lines.push(sectionLabel(paint, "Arguments")); + railRows( + positionalEntries.map((runtime) => ({ + name: runtime.placeholder, + brief: runtime.brief, + suffix: runtime.type === "optionalString" ? "(optional)" : undefined, + })), + paint, + lines, + ); + } + + const flagRows = declaredFlagRows(def); + if (flagRows.length > 0) { + lines.push(rail(paint)); + lines.push(sectionLabel(paint, "Options")); + railRows(flagRows, paint, lines); + } + + if (def.kind !== "server-command") { + const sharedNames = [ + ...Object.keys(SHARED_FLAG_PARAMETERS).map( + (key) => `--${kebabCase(key)}`, + ), + ].join(", "); + lines.push(rail(paint)); + proseLines( + `Global options also apply: ${sharedNames}. Run '${spec.name} --help' for details.`, + paint, + lines, + "muted", + ); + } + + exampleLines(def.help.examples, spec.name, paint, lines); + docsLine(entry.docsBaseUrl, paint, lines); + lines.push(""); +} diff --git a/packages/cli-engine/src/execution/needs.ts b/packages/cli-engine/src/execution/needs.ts index 6eaaf173..22926e73 100644 --- a/packages/cli-engine/src/execution/needs.ts +++ b/packages/cli-engine/src/execution/needs.ts @@ -13,6 +13,7 @@ import { import { CliStructuredError, type Diagnostic } from "../protocol"; import type { LoadedConfig } from "../runtime"; import type { Invocation } from "./engine"; +import { makePaint } from "./palette"; import { withDocsUrl, writeDiagnostic } from "./rendering"; import { SEVERITY_RANK } from "./reporting"; @@ -331,7 +332,11 @@ function writeSectionWarnings( if (SEVERITY_RANK[diagnostic.severity] > SEVERITY_RANK[state.logLevel]) { continue; } - writeDiagnostic(invocation.runtime.stderr, withDocsUrl(state, diagnostic)); + writeDiagnostic( + invocation.runtime.stderr, + withDocsUrl(state, diagnostic), + makePaint(state.colorEnabled), + ); } } diff --git a/packages/cli-engine/src/execution/rendering.ts b/packages/cli-engine/src/execution/rendering.ts index 42d9eb61..53972c9a 100644 --- a/packages/cli-engine/src/execution/rendering.ts +++ b/packages/cli-engine/src/execution/rendering.ts @@ -183,7 +183,7 @@ function writeFields( ): void { const cells = rows.map((row) => ({ label: toned(extend(row.label, ":"), "heading"), - value: row.sensitive === true ? MASK : row.value, + value: row.sensitive === true ? MASK : orPlaceholder(row.value), })); const width = Math.max(0, ...cells.map((cell) => textWidth(cell.label))); const prefix = rail ? `${paint("structure", RAIL)}${COLUMN_GAP}` : ""; @@ -198,13 +198,35 @@ function writeFields( * than the bytes so colour cannot shift a column. The last column is * never padded, so no line carries trailing whitespace. */ +/** One header convention for every table: plain-string headers are + * normalized to sentence case, so casing is not a per-command choice. */ +function sentenceCase(text: Text): Text { + if (typeof text !== "string" || text === "") { + return text; + } + return `${text[0].toUpperCase()}${text.slice(1)}`; +} + +const PLACEHOLDER = "—"; + +/** An absent value renders as a dim em dash rather than invented prose + * ("none", "n/a") in data tone. */ +function orPlaceholder(cell: Text): Text { + const empty = + cell === "" || (typeof cell !== "string" && textWidth(cell) === 0); + return empty ? [{ text: PLACEHOLDER, tone: "placeholder" }] : cell; +} + function writeTable( columns: readonly Text[], rows: ReadonlyArray, paint: Paint, write: (line: string) => void, ): void { - const all = [columns.map((column) => toned(column, "heading")), ...rows]; + const all = [ + columns.map((column) => toned(sentenceCase(column), "heading")), + ...rows.map((row) => row.map(orPlaceholder)), + ]; const widths: number[] = []; for (const row of all) { for (const [index, cell] of row.entries()) { @@ -265,21 +287,29 @@ const DIAGNOSTIC_SYMBOL: Readonly> = { info: "ℹ", }; +const PLAIN = makePaint(false); + export function writeDiagnostic( stream: { write(text: string): void }, diagnostic: Diagnostic, + paint: Paint = PLAIN, ): void { - stream.write( - `${DIAGNOSTIC_SYMBOL[diagnostic.severity]} [${diagnostic.code}] ${diagnostic.summary}\n`, + const glyph = paint( + diagnostic.severity, + DIAGNOSTIC_SYMBOL[diagnostic.severity], ); + const code = paint("muted", `[${diagnostic.code}]`); + stream.write(`${glyph} ${code} ${diagnostic.summary}\n`); if (diagnostic.why !== undefined) { - stream.write(` why: ${diagnostic.why}\n`); + stream.write(` ${paint("muted", `why: ${diagnostic.why}`)}\n`); } for (const action of diagnostic.nextActions) { - stream.write(`${renderNextAction(action)}\n`); + stream.write(`${renderNextAction(action, paint)}\n`); } if (diagnostic.docsUrl !== undefined) { - stream.write(` docs: ${diagnostic.docsUrl}\n`); + stream.write( + ` ${paint("muted", "docs:")} ${paint("link", diagnostic.docsUrl)}\n`, + ); } } @@ -288,10 +318,25 @@ export function writeDiagnostic( * beside it — has nothing to put in the label but the command itself. * Only the renderer sees both fields, so only it can tell they are the * same string and print it once. */ -export function renderNextAction(action: NextAction): string { +export function renderNextAction( + action: NextAction, + paint: Paint = PLAIN, +): string { const target = action.command ?? action.url; const repeatsTheLabel = target === undefined || target === action.label; - return `→ ${action.label}${repeatsTheLabel ? "" : `: ${target}`}`; + const arrow = paint("heading", "→"); + if (repeatsTheLabel) { + const label = + action.command !== undefined + ? paint("identifier", action.label) + : action.label; + return `${arrow} ${label}`; + } + const painted = + action.command !== undefined + ? paint("identifier", target as string) + : paint("link", target as string); + return `${arrow} ${action.label}: ${painted}`; } /** Populates docsUrl from the owning family's docsBaseUrl (base + code) @@ -323,12 +368,20 @@ export function renderCompletedHuman( renderBlock(block, paint, (line) => runtime.stderr.write(`${line}\n`)); } for (const action of presented.presentation.next) { - runtime.stderr.write(`${renderNextAction(action)}\n`); + runtime.stderr.write(`${renderNextAction(action, paint)}\n`); } for (const diagnostic of presented.diagnostics) { - writeDiagnostic(runtime.stderr, withDocsUrl(state, diagnostic)); + writeDiagnostic(runtime.stderr, withDocsUrl(state, diagnostic), paint); } - for (const line of presented.presentation.stdout) { - runtime.stdout.write(`${line}\n`); + /** The machine lines exist for a consumer on the other end of + * stdout. Only when stdout and stderr BOTH render to a terminal do + * the blocks and the mirror land on the same screen as visible + * duplication, so that is the one case that skips them (amends the + * 2026-08-09 "always" ruling; any redirection of either stream + * keeps the mirror, so pipes still receive exactly the data lines). */ + if (!(runtime.isTty.stdout && runtime.isTty.stderr)) { + for (const line of presented.presentation.stdout) { + runtime.stdout.write(`${line}\n`); + } } } diff --git a/packages/cli-engine/src/execution/settlement.ts b/packages/cli-engine/src/execution/settlement.ts index e5bfb619..3dc542a8 100644 --- a/packages/cli-engine/src/execution/settlement.ts +++ b/packages/cli-engine/src/execution/settlement.ts @@ -13,6 +13,7 @@ import { } from "../protocol"; import { type ChildStatusSettlement, childExitCode } from "../spawn"; import type { EngineSpec, Invocation } from "./engine"; +import { makePaint } from "./palette"; import { firstLine, renderCompletedHuman, @@ -239,7 +240,9 @@ export function settleChildStatus( // Only human format is reachable here: maySpawn forces it. if (child.signal === null) { for (const action of settlement.nextActions) { - invocation.runtime.stderr.write(`${renderNextAction(action)}\n`); + invocation.runtime.stderr.write( + `${renderNextAction(action, makePaint(invocation.state.colorEnabled))}\n`, + ); } } settleVerbatimExitCode(invocation, childExitCode(child)); @@ -314,9 +317,10 @@ export function emitErrored( return; } const stderr = invocation.runtime.stderr; - writeDiagnostic(stderr, envelope.error); + const paint = makePaint(invocation.state.colorEnabled); + writeDiagnostic(stderr, envelope.error, paint); for (const diagnostic of envelope.diagnostics) { - writeDiagnostic(stderr, diagnostic); + writeDiagnostic(stderr, diagnostic, paint); } } @@ -427,21 +431,94 @@ export function settleUnhandled( captured.length > 0 ? captured : "The command failed unexpectedly"; const summary = firstLine(full); const remainder = full.slice(full.indexOf("\n") + 1).trim(); + const code = usageErrorCode(raw) ?? "CLI.INTERNAL_ERROR"; + const nextActions = + code === "CLI.UNKNOWN_COMMAND" ? unknownCommandActions(spec, state) : []; const envelope: ErroredEnvelope = { ok: false, commandId: segments.join("."), error: { - code: usageErrorCode(raw) ?? "CLI.INTERNAL_ERROR", + code, severity: "error", summary, ...(usage && full.includes("\n") && remainder.length > 0 ? { why: remainder } : {}), - nextActions: [], + nextActions, }, diagnostics: [], - nextActions: [], + nextActions, }; emitErrored(invocation, envelope); return usage ? 2 : 1; } + +function editDistance(a: string, b: string): number { + const rows = a.length + 1; + const cols = b.length + 1; + const d: number[] = Array.from({ length: rows * cols }, () => 0); + for (let i = 0; i < rows; i += 1) { + d[i * cols] = i; + } + for (let j = 0; j < cols; j += 1) { + d[j] = j; + } + for (let i = 1; i < rows; i += 1) { + for (let j = 1; j < cols; j += 1) { + const substitution = a[i - 1] === b[j - 1] ? 0 : 1; + d[i * cols + j] = Math.min( + d[(i - 1) * cols + j] + 1, + d[i * cols + j - 1] + 1, + d[(i - 1) * cols + j - 1] + substitution, + ); + } + } + return d[rows * cols - 1]; +} + +/** A misspelling is close (edit distance ≤ 2, and short paths tighter); + * anything further is not a suggestion worth making. Every unknown + * command at least learns where the command list is. */ +function unknownCommandActions( + spec: EngineSpec, + state: { readonly argv: readonly string[] }, +): NextAction[] { + const attempted: string[] = []; + for (const token of state.argv) { + if (token.startsWith("-")) { + break; + } + attempted.push(token); + } + const typed = attempted.join(" "); + const candidates = new Set(Object.keys(spec.commands)); + for (const path of Object.keys(spec.commands)) { + const segments = path.split(" "); + for (let depth = 1; depth < segments.length; depth += 1) { + candidates.add(segments.slice(0, depth).join(" ")); + } + } + const ranked = [...candidates] + .map((path) => ({ path, distance: editDistance(typed, path) })) + .filter( + ({ path, distance }) => + distance <= + Math.max(path.length >= 8 ? 3 : 1, Math.min(2, path.length - 1)), + ) + .sort((a, b) => a.distance - b.distance) + .slice(0, 3); + return [ + ...ranked.map( + ({ path }): NextAction => ({ + kind: "run-command", + label: "Did you mean", + command: `${spec.name} ${path}`, + }), + ), + { + kind: "run-command", + label: "List every command", + command: `${spec.name} --help`, + }, + ]; +} diff --git a/packages/cli-engine/src/execution/stricli-adapter.ts b/packages/cli-engine/src/execution/stricli-adapter.ts index 82e4860b..e9334228 100644 --- a/packages/cli-engine/src/execution/stricli-adapter.ts +++ b/packages/cli-engine/src/execution/stricli-adapter.ts @@ -183,7 +183,7 @@ export function resolveExample(example: string, cliName: string): string { /** The --json refusal is stated in help, so a machine consumer learns * it without running the command. */ -const NO_JSON_NOTE = +export const NO_JSON_NOTE = "This command hands the terminal to another program and does not support --json."; function commandDocs( diff --git a/packages/cli-engine/tests/blocks.test.ts b/packages/cli-engine/tests/blocks.test.ts index ae249302..8b83d4c2 100644 --- a/packages/cli-engine/tests/blocks.test.ts +++ b/packages/cli-engine/tests/blocks.test.ts @@ -51,19 +51,19 @@ describe("table", () => { ], }; - test("every column is as wide as its widest cell", async () => { + test("every column is as wide as its widest cell; headers are sentence-cased and an empty cell draws the placeholder dash", async () => { expect(await render([RAGGED])).toBe( - "name id status\n" + + "Name Id Status\n" + "Acme Inc ws_1 current\n" + - "Globex ws_2\n", + "Globex ws_2 \u2014\n", ); }); test("headers are toned, and a line never ends in padding", async () => { expect(await render([RAGGED], { color: true })).toBe( - "\u001b[36mname \u001b[39m \u001b[36mid \u001b[39m \u001b[36mstatus\u001b[39m\n" + + "\u001b[36mName \u001b[39m \u001b[36mId \u001b[39m \u001b[36mStatus\u001b[39m\n" + "Acme Inc ws_1 current\n" + - "Globex ws_2\n", + "Globex ws_2 \u001b[2m\u2014\u001b[22m\n", ); }); @@ -107,7 +107,7 @@ describe("table", () => { ], }, ]), - ).toBe("name id\n用户 u1\nab u2\n"); + ).toBe("Name Id\n用户 u1\nab u2\n"); }); }); diff --git a/packages/cli-engine/tests/execution.test.ts b/packages/cli-engine/tests/execution.test.ts index 1e5927f5..f5caae57 100644 --- a/packages/cli-engine/tests/execution.test.ts +++ b/packages/cli-engine/tests/execution.test.ts @@ -1081,7 +1081,7 @@ describe("help examples", () => { expect(result.exitCode).toBe(0); expect(result.stdout).toBe(""); - expect(result.stderr).toContain("USAGE"); + expect(result.stderr).toContain("Usage"); }); }); diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index b4e4bf08..4729754a 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -311,6 +311,13 @@ export function buildCli(): Cli { ], groups: cliGroups, commands: mountedCommands, + help: { + tagline: "The Prisma Developer Platform, from your terminal", + description: + "Deploy your app with isolated infrastructure for every branch.", + examples: ["init", "auth login", "project list"], + docsUrl: CLI_DOCS_URL, + }, telemetry: { docsUrl: CLI_DOCS_URL }, }); } diff --git a/packages/cli/src/commands/agent/presentation.ts b/packages/cli/src/commands/agent/presentation.ts index ec8a1201..5f5fe36c 100644 --- a/packages/cli/src/commands/agent/presentation.ts +++ b/packages/cli/src/commands/agent/presentation.ts @@ -105,7 +105,11 @@ export function statusPresentations( ...projectStatusRows(result), ]), result.skills.length === 0 - ? { kind: "list", items: ["No Prisma skills reported."] } + ? { + kind: "summary", + status: "info", + text: "No Prisma skills reported.", + } : { kind: "table", columns: ["skill", "scope", "agents"], diff --git a/packages/cli/src/commands/branch/list.ts b/packages/cli/src/commands/branch/list.ts index ad2125d3..774f17f3 100644 --- a/packages/cli/src/commands/branch/list.ts +++ b/packages/cli/src/commands/branch/list.ts @@ -2,6 +2,7 @@ import { type Block, defineCommand, + flag, type Presentations, } from "@prisma/cli-engine"; import { notOk, ok } from "@prisma/cli-engine/protocol"; @@ -31,7 +32,13 @@ function listPresentations(result: BranchListResult): Presentations { rows: [{ label: "project", value: result.projectName }], }, ...(rows.length === 0 - ? [{ kind: "list" as const, items: ["No branches found."] }] + ? [ + { + kind: "summary" as const, + status: "info" as const, + text: "No branches found.", + }, + ] : [ { kind: "table" as const, @@ -47,19 +54,25 @@ function listPresentations(result: BranchListResult): Presentations { export const branchListCommand = defineCommand({ help: { summary: "List Platform branches for the resolved project", - examples: ["branch list", "branch list --json"], + examples: ["branch list", "branch list --project my-app"], + }, + args: { + flags: { + project: flag.string({ + brief: "Project id or name", + placeholder: "id-or-name", + }), + }, }, needs: { credentials: true }, - handler: async (_args, ctx) => { + handler: async (args, ctx) => { try { const workspace = await resolveActiveWorkspace(ctx); - /** Legacy quirk: `branch list` has no `--project` and passes no - * command name, so an unbound directory reads "this command". */ const target = await resolvePinnedProject( ctx, workspace, - undefined, - undefined, + args.flags.project, + "branch list", ); const branches = await listBranches( ctx.api, diff --git a/packages/cli/src/commands/bucket/key-list.ts b/packages/cli/src/commands/bucket/key-list.ts index 9b0a29a1..84dad53a 100644 --- a/packages/cli/src/commands/bucket/key-list.ts +++ b/packages/cli/src/commands/bucket/key-list.ts @@ -21,7 +21,13 @@ function listPresentations(result: BucketKeyListResult): Presentations { { kind: "summary", status: "info", text: TITLE }, { kind: "fields", rows: [{ label: "bucket", value: result.bucketId }] }, ...(rows.length === 0 - ? [{ kind: "list" as const, items: ["No keys found."] }] + ? [ + { + kind: "summary" as const, + status: "info" as const, + text: "No keys found.", + }, + ] : [ { kind: "table" as const, diff --git a/packages/cli/src/commands/bucket/list.ts b/packages/cli/src/commands/bucket/list.ts index e73c19ff..4ba851c1 100644 --- a/packages/cli/src/commands/bucket/list.ts +++ b/packages/cli/src/commands/bucket/list.ts @@ -29,7 +29,13 @@ function listPresentations(result: BucketListResult): Presentations { ], }, ...(rows.length === 0 - ? [{ kind: "list" as const, items: ["No buckets found."] }] + ? [ + { + kind: "summary" as const, + status: "info" as const, + text: "No buckets found.", + }, + ] : [ { kind: "table" as const, diff --git a/packages/cli/src/commands/postgres/backup-list.ts b/packages/cli/src/commands/postgres/backup-list.ts index 1c24240e..c27da767 100644 --- a/packages/cli/src/commands/postgres/backup-list.ts +++ b/packages/cli/src/commands/postgres/backup-list.ts @@ -39,7 +39,13 @@ function backupListPresentations( ], }, ...(rows.length === 0 - ? [{ kind: "list" as const, items: ["No backups found."] }] + ? [ + { + kind: "summary" as const, + status: "info" as const, + text: "No backups found.", + }, + ] : [ { kind: "table" as const, diff --git a/packages/cli/src/commands/postgres/connection-list.ts b/packages/cli/src/commands/postgres/connection-list.ts index f650106c..268ece4b 100644 --- a/packages/cli/src/commands/postgres/connection-list.ts +++ b/packages/cli/src/commands/postgres/connection-list.ts @@ -50,7 +50,13 @@ function listPresentations( rows: [{ label: "database", value: result.database.name }], }, ...(rows.length === 0 - ? [{ kind: "list" as const, items: ["No database connections found."] }] + ? [ + { + kind: "summary" as const, + status: "info" as const, + text: "No database connections found.", + }, + ] : [ { kind: "table" as const, diff --git a/packages/cli/src/commands/postgres/list.ts b/packages/cli/src/commands/postgres/list.ts index b4aaf5c8..9f502f80 100644 --- a/packages/cli/src/commands/postgres/list.ts +++ b/packages/cli/src/commands/postgres/list.ts @@ -52,7 +52,13 @@ function listPresentations(result: DatabaseListResult): Presentations { ], }, ...(rows.length === 0 - ? [{ kind: "list" as const, items: ["No databases found."] }] + ? [ + { + kind: "summary" as const, + status: "info" as const, + text: "No databases found.", + }, + ] : [ { kind: "table" as const, diff --git a/packages/cli/src/commands/project/env-list.ts b/packages/cli/src/commands/project/env-list.ts index 268decb7..14d848a7 100644 --- a/packages/cli/src/commands/project/env-list.ts +++ b/packages/cli/src/commands/project/env-list.ts @@ -45,8 +45,9 @@ function listPresentations( ...(rows.length === 0 ? [ { - kind: "list" as const, - items: ["No environment variables defined in this scope."], + kind: "summary" as const, + status: "info" as const, + text: "No environment variables defined in this scope.", }, ] : [ diff --git a/packages/cli/src/commands/project/list.ts b/packages/cli/src/commands/project/list.ts index 79ac095a..c2ec001b 100644 --- a/packages/cli/src/commands/project/list.ts +++ b/packages/cli/src/commands/project/list.ts @@ -17,11 +17,13 @@ import { toNextActions } from "./presentation"; const TITLE = "Listing projects for the authenticated workspace."; +/** An absent region stays empty; the table renderer draws the dim + * placeholder dash. */ function projectRows(result: ProjectListResult): string[][] { return result.projects.map((project) => [ project.name, project.id, - project.defaultRegion ?? "none", + project.defaultRegion ?? "", ]); } @@ -61,7 +63,13 @@ function listPresentations(result: ProjectListResult): Presentations { rows: [{ label: "workspace", value: result.workspace.name }], }, ...(rows.length === 0 - ? [{ kind: "list", items: ["No projects found."] } as const] + ? [ + { + kind: "summary", + status: "info", + text: "No projects found.", + } as const, + ] : [ { kind: "table", diff --git a/packages/cli/src/commands/project/show.ts b/packages/cli/src/commands/project/show.ts index 29feb7e1..e3806303 100644 --- a/packages/cli/src/commands/project/show.ts +++ b/packages/cli/src/commands/project/show.ts @@ -82,7 +82,10 @@ function showPresentations( : { kind: "summary", status: "info", - text: "This directory is linked to the following platform project.", + text: + result.resolution.projectSource === "explicit" + ? "Showing the project named by --project (this directory's own link, if any, is unchanged)." + : "This directory is linked to the following platform project.", }, { kind: "fields", rows }, ], diff --git a/packages/cli/src/commands/service/list.ts b/packages/cli/src/commands/service/list.ts index 0524547e..c8dec0ff 100644 --- a/packages/cli/src/commands/service/list.ts +++ b/packages/cli/src/commands/service/list.ts @@ -52,6 +52,7 @@ export const serviceListCommand = defineCommand({ const result: ServiceListResult = { projectId: target.project.id, + projectName: target.project.name, branch: target.branch.name, services: services.map(toServiceListEntry), }; diff --git a/packages/cli/src/commands/service/presentation.ts b/packages/cli/src/commands/service/presentation.ts index 510d5234..9af3c89e 100644 --- a/packages/cli/src/commands/service/presentation.ts +++ b/packages/cli/src/commands/service/presentation.ts @@ -89,7 +89,7 @@ export function listPresentations(result: ServiceListResult): Presentations { human: () => [ title("Listing services for the selected project."), fields([ - { label: "project", value: result.projectId }, + { label: "project", value: result.projectName }, { label: "branch", value: result.branch }, ]), ...(result.services.length === 0 @@ -107,7 +107,7 @@ export function listPresentations(result: ServiceListResult): Presentations { rows: result.services.map((service) => [ service.name, service.id, - service.region ?? "none", + service.region ?? "", service.liveUrl ?? "not deployed", ]), } as const, @@ -161,7 +161,7 @@ export function createPresentations( { label: "branch", value: result.branch }, { label: "service", value: result.service.name }, { label: "id", value: result.service.id }, - { label: "region", value: result.service.region ?? "none" }, + { label: "region", value: result.service.region ?? "" }, // A service with no deployment has no address that resolves, so // it reports what it needs next instead of a dead URL. { @@ -202,7 +202,7 @@ export function showPresentations(result: ServiceShowResult): Presentations { { label: "service", value: result.service?.name ?? "not selected" }, { label: "live deployment", - value: result.liveDeployment?.id ?? "none", + value: result.liveDeployment?.id ?? "", }, { label: "live url", value: result.liveUrl ?? "unavailable" }, { diff --git a/packages/cli/src/commands/service/results.ts b/packages/cli/src/commands/service/results.ts index 0862ddff..1fdf7bb1 100644 --- a/packages/cli/src/commands/service/results.ts +++ b/packages/cli/src/commands/service/results.ts @@ -29,6 +29,7 @@ export interface ServiceListEntry { export interface ServiceListResult { projectId: string; + projectName: string; branch: string; services: ServiceListEntry[]; } diff --git a/packages/cli/tests/bin.test.ts b/packages/cli/tests/bin.test.ts index 7e90471d..69a40980 100644 --- a/packages/cli/tests/bin.test.ts +++ b/packages/cli/tests/bin.test.ts @@ -350,7 +350,7 @@ describe("buildCli", () => { const exitCode = await main(proc); expect(exitCode).toBe(0); - expect(proc.stdoutText).toContain("USAGE"); + expect(proc.stdoutText).toContain("The Prisma Developer Platform"); expect(proc.stdoutText).toContain("auth"); }); @@ -410,17 +410,20 @@ describe("buildCli", () => { expect(run.proc.stdoutText).toContain("--config needs a path"); }); - it("lists --config among the global flags in help", async () => { - const proc = makeProcess({ + it("names --config on leaf help and documents it on root help", async () => { + const leaf = makeProcess({ argv: ["node", "bin.js", "telemetry", "status", "--help"], isTty: { stdout: true }, }); + expect(await main(leaf)).toBe(0); + expect(leaf.stdoutText).toContain("--config"); - const exitCode = await main(proc); - - expect(exitCode).toBe(0); - expect(proc.stdoutText).toContain("--config"); - expect(proc.stdoutText).toContain( + const root = makeProcess({ + argv: ["node", "bin.js", "--help"], + isTty: { stdout: true }, + }); + expect(await main(root)).toBe(0); + expect(root.stdoutText).toContain( "Read this config file instead of ./prisma.config.ts", ); }); diff --git a/packages/cli/tests/branch.test.ts b/packages/cli/tests/branch.test.ts index fc096475..f75950a1 100644 --- a/packages/cli/tests/branch.test.ts +++ b/packages/cli/tests/branch.test.ts @@ -211,8 +211,9 @@ describe("prisma-cli branch list", () => { ); expect(blocks(result.presented)).toContainEqual({ - kind: "list", - items: ["No branches found."], + kind: "summary", + status: "info", + text: "No branches found.", }); expect(result.presented?.presentation.stdout).toEqual([]); }); @@ -247,7 +248,7 @@ describe("prisma-cli branch list", () => { }); }); - it('maps an unbound directory to PROJECT.SETUP_REQUIRED reading "this command"', async () => { + it("maps an unbound directory to PROJECT.SETUP_REQUIRED naming the command", async () => { const cwd = await mkdtemp(path.join(os.tmpdir(), "branch-unpinned-")); const result = await makeCli(branchClient()).run( ["branch", "list", "--json"], @@ -262,7 +263,7 @@ describe("prisma-cli branch list", () => { error: { code: "PROJECT.SETUP_REQUIRED", summary: "Choose a Project before running this command", - why: "This directory is not linked to a Prisma Project, and this command will not choose one from package or directory names.", + why: "This directory is not linked to a Prisma Project, and prisma-cli branch list will not choose one from package or directory names.", }, }); }); diff --git a/packages/cli/tests/bucket.test.ts b/packages/cli/tests/bucket.test.ts index ddd6c668..714b63a5 100644 --- a/packages/cli/tests/bucket.test.ts +++ b/packages/cli/tests/bucket.test.ts @@ -221,8 +221,9 @@ describe("prisma-cli bucket list", () => { ); expect(blocks(result.presented)).toContainEqual({ - kind: "list", - items: ["No buckets found."], + kind: "summary", + status: "info", + text: "No buckets found.", }); expect(result.presented?.presentation.stdout).toEqual([]); }); @@ -625,8 +626,9 @@ describe("prisma-cli bucket key list", () => { ); expect(blocks(result.presented)).toContainEqual({ - kind: "list", - items: ["No keys found."], + kind: "summary", + status: "info", + text: "No keys found.", }); }); diff --git a/packages/cli/tests/golden-rendering.test.ts b/packages/cli/tests/golden-rendering.test.ts index 6ed0468e..e7e30813 100644 --- a/packages/cli/tests/golden-rendering.test.ts +++ b/packages/cli/tests/golden-rendering.test.ts @@ -109,9 +109,9 @@ describe("golden rendering", () => { expect(result.exitCode).toBe(0); expect(result.stderr).toBe( "ℹ Listing your workspace sessions on this machine.\n" + - "name id status\n" + + "Name Id Status\n" + "Acme Inc ws_1 current\n" + - "Globex ws_2\n", + "Globex ws_2 \u2014\n", ); expect(result.stdout).toBe("Acme Inc ws_1 current\nGlobex ws_2\n"); }); @@ -199,9 +199,9 @@ describe("golden rendering", () => { expect(result.stderr).toBe( "\u001b[34m\u2139\u001b[39m Listing your workspace sessions on this machine.\n" + - "\u001b[36mname \u001b[39m \u001b[36mid \u001b[39m \u001b[36mstatus\u001b[39m\n" + + "\u001b[36mName \u001b[39m \u001b[36mId \u001b[39m \u001b[36mStatus\u001b[39m\n" + "Acme Inc ws_1 current\n" + - "Globex ws_2\n", + "Globex ws_2 \u2014\n", ); }); }); diff --git a/packages/cli/tests/init.test.ts b/packages/cli/tests/init.test.ts index cc55538c..7ef3d76f 100644 --- a/packages/cli/tests/init.test.ts +++ b/packages/cli/tests/init.test.ts @@ -356,7 +356,12 @@ describe("init writes the config", () => { outcome: "ok", data: { path: "prisma.compute.ts" }, }); - expect(result.stdout).toBe("prisma.compute.ts\n"); + // Both streams are the same terminal here, so the machine mirror is + // suppressed; the path still travels in the presented stdout lines. + expect(result.stdout).toBe(""); + expect(result.presented?.presentation.stdout).toEqual([ + "prisma.compute.ts", + ]); expect(result.presented?.presentation.human).toContainEqual({ kind: "summary", status: "ok", diff --git a/packages/cli/tests/postgres.test.ts b/packages/cli/tests/postgres.test.ts index e152981b..5b39840c 100644 --- a/packages/cli/tests/postgres.test.ts +++ b/packages/cli/tests/postgres.test.ts @@ -315,8 +315,9 @@ describe("prisma-cli postgres list", () => { ); expect(blocks(result.presented)).toContainEqual({ - kind: "list", - items: ["No databases found."], + kind: "summary", + status: "info", + text: "No databases found.", }); expect(result.presented?.presentation.stdout).toEqual([]); }); @@ -1762,8 +1763,9 @@ describe("prisma-cli postgres backup list", () => { }); expect(blocks(result.presented)).toContainEqual({ - kind: "list", - items: ["No backups found."], + kind: "summary", + status: "info", + text: "No backups found.", }); }); @@ -1936,8 +1938,9 @@ describe("prisma-cli postgres connection list", () => { ); expect(blocks(result.presented)).toContainEqual({ - kind: "list", - items: ["No database connections found."], + kind: "summary", + status: "info", + text: "No database connections found.", }); }); diff --git a/packages/cli/tests/project.test.ts b/packages/cli/tests/project.test.ts index f06450ef..82cee224 100644 --- a/packages/cli/tests/project.test.ts +++ b/packages/cli/tests/project.test.ts @@ -188,7 +188,7 @@ describe("prisma-cli project list", () => { columns: ["name", "id", "region"], rows: [ ["Billing", "proj_1", "us-east-1"], - ["Storefront", "proj_2", "none"], + ["Storefront", "proj_2", ""], ], }); // stdout carries the values, not the table's "none" placeholder. @@ -275,8 +275,9 @@ describe("prisma-cli project list", () => { ); expect(blocks(result.presented)).toContainEqual({ - kind: "list", - items: ["No projects found."], + kind: "summary", + status: "info", + text: "No projects found.", }); expect(result.presented?.presentation.stdout).toEqual([]); }); @@ -2149,8 +2150,9 @@ describe("prisma-cli project env list", () => { ); expect(blocks(result.presented)).toContainEqual({ - kind: "list", - items: ["No environment variables defined in this scope."], + kind: "summary", + status: "info", + text: "No environment variables defined in this scope.", }); expect(result.presented?.presentation.next).toEqual([ { diff --git a/packages/cli/tests/service-list.test.ts b/packages/cli/tests/service-list.test.ts index 6fea913c..204bbb5b 100644 --- a/packages/cli/tests/service-list.test.ts +++ b/packages/cli/tests/service-list.test.ts @@ -46,6 +46,7 @@ describe("prisma-cli service list", () => { expect(result.events).toEqual([]); expect(result.presented?.data).toEqual({ projectId: "proj_1", + projectName: "acme-app", branch: "main", services: [ { @@ -113,6 +114,7 @@ describe("prisma-cli service list", () => { expect(result.exitCode).toBe(0); expect(result.presented?.data).toEqual({ projectId: "proj_1", + projectName: "acme-app", branch: "main", services: [], }); From 6789f37d484c0d24fd14381658cc35009160d548 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 13 Aug 2026 08:08:15 +0200 Subject: [PATCH 02/11] Separate output sections with a blank line where a multi-line block is involved MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A card, a table and the next actions each read as their own paragraph now instead of running together. Runs of one-liners — summaries, next-action arrows, single-line diagnostics — keep hugging, since they read as one glyph-aligned list. The same rule paragraphs an errored run's findings list. Co-Authored-By: Claude Fable 5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../cli-engine/src/execution/rendering.ts | 70 +++++++++++++++++-- .../cli-engine/src/execution/settlement.ts | 14 ++-- packages/cli-engine/tests/execution.test.ts | 4 ++ packages/cli/tests/golden-rendering.test.ts | 8 +++ packages/cli/tests/whoami.test.ts | 4 ++ 5 files changed, 88 insertions(+), 12 deletions(-) diff --git a/packages/cli-engine/src/execution/rendering.ts b/packages/cli-engine/src/execution/rendering.ts index 53972c9a..162ec2ba 100644 --- a/packages/cli-engine/src/execution/rendering.ts +++ b/packages/cli-engine/src/execution/rendering.ts @@ -354,6 +354,54 @@ export function withDocsUrl( return { ...diagnostic, docsUrl: `${base}${diagnostic.code}` }; } +/** One rendered paragraph of human output. `compact` marks a run of + * one-liners — summaries, next-action arrows, single-line diagnostics — + * that reads as a glyph-aligned list. */ +export interface RenderedSection { + readonly lines: string[]; + readonly compact: boolean; +} + +const TRAILING_NEWLINE = /\n$/; + +export function diagnosticSection( + diagnostic: Diagnostic, + paint: Paint, +): RenderedSection { + const lines: string[] = []; + writeDiagnostic( + { + write: (text) => + lines.push(...text.replace(TRAILING_NEWLINE, "").split("\n")), + }, + diagnostic, + paint, + ); + return { lines, compact: lines.length === 1 }; +} + +/** A blank line between sections wherever a multi-line one is involved, + * so a card, a table and the next actions each read as their own + * paragraph; adjacent compact sections keep hugging. */ +export function writeSections( + sections: readonly RenderedSection[], + stream: { write(text: string): void }, +): void { + let previous: RenderedSection | undefined; + for (const section of sections) { + if (section.lines.length === 0) { + continue; + } + if (previous !== undefined && !(previous.compact && section.compact)) { + stream.write("\n"); + } + for (const line of section.lines) { + stream.write(`${line}\n`); + } + previous = section; + } +} + /** Channel discipline (operator ruling, 2026-08-09): human Blocks, * next-action lines, and diagnostics are presentation prose on stderr; * the materialized `stdout` presentation lines are the machine-usable @@ -364,15 +412,25 @@ export function renderCompletedHuman( ): void { const { runtime, state } = invocation; const paint = makePaint(state.colorEnabled); - for (const block of presented.presentation.human) { - renderBlock(block, paint, (line) => runtime.stderr.write(`${line}\n`)); - } - for (const action of presented.presentation.next) { - runtime.stderr.write(`${renderNextAction(action, paint)}\n`); + const sections: RenderedSection[] = presented.presentation.human.map( + (block) => { + const lines: string[] = []; + renderBlock(block, paint, (line) => lines.push(line)); + return { lines, compact: block.kind === "summary" }; + }, + ); + if (presented.presentation.next.length > 0) { + sections.push({ + lines: presented.presentation.next.map((action) => + renderNextAction(action, paint), + ), + compact: true, + }); } for (const diagnostic of presented.diagnostics) { - writeDiagnostic(runtime.stderr, withDocsUrl(state, diagnostic), paint); + sections.push(diagnosticSection(withDocsUrl(state, diagnostic), paint)); } + writeSections(sections, runtime.stderr); /** The machine lines exist for a consumer on the other end of * stdout. Only when stdout and stderr BOTH render to a terminal do * the blocks and the mirror land on the same screen as visible diff --git a/packages/cli-engine/src/execution/settlement.ts b/packages/cli-engine/src/execution/settlement.ts index 3dc542a8..498f816a 100644 --- a/packages/cli-engine/src/execution/settlement.ts +++ b/packages/cli-engine/src/execution/settlement.ts @@ -15,11 +15,12 @@ import { type ChildStatusSettlement, childExitCode } from "../spawn"; import type { EngineSpec, Invocation } from "./engine"; import { makePaint } from "./palette"; import { + diagnosticSection, firstLine, renderCompletedHuman, renderNextAction, withDocsUrl, - writeDiagnostic, + writeSections, } from "./rendering"; import { emitFrame } from "./reporting"; import { resolveExample, usageErrorCode } from "./stricli-adapter"; @@ -316,12 +317,13 @@ export function emitErrored( }); return; } - const stderr = invocation.runtime.stderr; const paint = makePaint(invocation.state.colorEnabled); - writeDiagnostic(stderr, envelope.error, paint); - for (const diagnostic of envelope.diagnostics) { - writeDiagnostic(stderr, diagnostic, paint); - } + writeSections( + [envelope.error, ...envelope.diagnostics].map((diagnostic) => + diagnosticSection(diagnostic, paint), + ), + invocation.runtime.stderr, + ); } /** `--version` prints createCli's version and exits 0. In json mode the diff --git a/packages/cli-engine/tests/execution.test.ts b/packages/cli-engine/tests/execution.test.ts index f5caae57..6f33d5a5 100644 --- a/packages/cli-engine/tests/execution.test.ts +++ b/packages/cli-engine/tests/execution.test.ts @@ -1184,12 +1184,16 @@ describe("a failure carrying several findings", () => { }; } + // Multi-line findings separate with a blank line; the trailing run of + // one-liners keeps hugging as one glyph-aligned list. const STDERR = "✘ [COMPOSER.CONFIG_INVALID] prisma.config.ts has 3 problems.\n" + " why: Every problem found is listed below.\n" + "→ Fix all three, then run the command again.\n" + + "\n" + "✘ [COMPOSER.MISSING_NAME] services[0] has no name.\n" + "→ Give services[0] a name.\n" + + "\n" + "✘ [COMPOSER.UNKNOWN_ENGINE] services[1].engine 'postgres9' is not a known engine.\n" + "✘ [COMPOSER.PORT_OUT_OF_RANGE] services[1].port 70000 is above 65535.\n"; diff --git a/packages/cli/tests/golden-rendering.test.ts b/packages/cli/tests/golden-rendering.test.ts index e7e30813..13d7aa8d 100644 --- a/packages/cli/tests/golden-rendering.test.ts +++ b/packages/cli/tests/golden-rendering.test.ts @@ -93,7 +93,9 @@ describe("golden rendering", () => { expect(result.exitCode).toBe(0); expect(result.stderr).toBe( "ℹ Clearing your stored workspace sessions.\n" + + "\n" + "ended: 1\n" + + "\n" + "✔ Ended 1 workspace session.\n" + "→ Sign in: prisma-cli auth login\n", ); @@ -109,6 +111,7 @@ describe("golden rendering", () => { expect(result.exitCode).toBe(0); expect(result.stderr).toBe( "ℹ Listing your workspace sessions on this machine.\n" + + "\n" + "Name Id Status\n" + "Acme Inc ws_1 current\n" + "Globex ws_2 \u2014\n", @@ -132,8 +135,10 @@ describe("golden rendering", () => { expect(result.exitCode).toBe(0); expect(result.stderr).toBe( '✔ Created key "ci-key" for bucket "assets".\n' + + "\n" + "- The credentials below are shown once — copy them now.\n" + "- Set these environment variables to use this bucket:\n" + + "\n" + "S3_ENDPOINT: https://s3.prisma.io\n" + "S3_ACCESS_KEY_ID: ********\n" + "S3_SECRET_ACCESS_KEY: ********\n" + @@ -180,8 +185,10 @@ describe("golden rendering", () => { expect(result.stderr).toBe( '\u001b[92m\u2714\u001b[39m Created key "ci-key" for bucket "assets".\n' + + "\n" + "- The credentials below are shown once \u2014 copy them now.\n" + "- Set these environment variables to use this bucket:\n" + + "\n" + "\u001b[36mS3_ENDPOINT: \u001b[39m https://s3.prisma.io\n" + "\u001b[36mS3_ACCESS_KEY_ID: \u001b[39m ********\n" + "\u001b[36mS3_SECRET_ACCESS_KEY:\u001b[39m ********\n" + @@ -199,6 +206,7 @@ describe("golden rendering", () => { expect(result.stderr).toBe( "\u001b[34m\u2139\u001b[39m Listing your workspace sessions on this machine.\n" + + "\n" + "\u001b[36mName \u001b[39m \u001b[36mId \u001b[39m \u001b[36mStatus\u001b[39m\n" + "Acme Inc ws_1 current\n" + "Globex ws_2 \u2014\n", diff --git a/packages/cli/tests/whoami.test.ts b/packages/cli/tests/whoami.test.ts index a6700df8..061635cf 100644 --- a/packages/cli/tests/whoami.test.ts +++ b/packages/cli/tests/whoami.test.ts @@ -96,7 +96,9 @@ describe("prisma-cli auth whoami", () => { expect(result.stdout).toBe("status: signed out\n"); expect(result.stderr).toBe( "ℹ Showing the active authenticated identity.\n" + + "\n" + "status: signed out\n" + + "\n" + "→ Sign in: prisma-cli auth login\n", ); }); @@ -112,6 +114,7 @@ describe("prisma-cli auth whoami", () => { ); expect(result.stderr).toBe( "ℹ Showing the active authenticated identity.\n" + + "\n" + "status: signed in\n" + "user: bob@example.com\n" + "workspace: Acme Inc\n", @@ -285,6 +288,7 @@ describe("prisma-cli auth whoami", () => { ); expect(result.stderr).toBe( "ℹ Showing the active authenticated identity.\n" + + "\n" + "status: signed in\n" + "user: bob@example.com\n" + "workspace: Acme Inc\n", From a226c7edaffc374bdf7219442edf0c9ceaa09173 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 13 Aug 2026 08:20:04 +0200 Subject: [PATCH 03/11] Colour pre-mount failures: resolve colour from raw argv at run start MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A run that never mounts a command — an unknown command, a parse failure — rendered its diagnostics through the constructor default colorEnabled: false, because applySharedFlags only runs once a command parses. The run state now starts from the same pre-parse resolution help uses (explicit flag, NO_COLOR, stderr TTY), renamed preParseColorEnabled; applySharedFlags still re-resolves after parsing. Co-Authored-By: Claude Fable 5 Signed-off-by: willbot Signed-off-by: Will Madden --- packages/cli-engine/src/execution/engine.ts | 13 ++++++++++--- packages/cli-engine/src/execution/help.ts | 14 ++++++++------ 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/packages/cli-engine/src/execution/engine.ts b/packages/cli-engine/src/execution/engine.ts index dca7debf..fec829e5 100644 --- a/packages/cli-engine/src/execution/engine.ts +++ b/packages/cli-engine/src/execution/engine.ts @@ -40,8 +40,8 @@ import { } from "./command-tree"; import { bareGroupInvocation, - helpColorEnabled, helpFlagGiven, + preParseColorEnabled, renderHelp, } from "./help"; import { checkNeeds, type NeedsOutcome } from "./needs"; @@ -316,7 +316,10 @@ export class EngineImpl implements Engine { yes: false, confirmValues: [], interactive: defaultInteractive(runtime), - colorEnabled: false, + /** Pre-parse resolution so a run that never mounts a command — an + * unknown command, a parse failure — still colours its + * diagnostics; applySharedFlags re-resolves after parsing. */ + colorEnabled: preParseColorEnabled(argv, runtime, "stderr"), configPath: undefined, resolved: false, settledExitCode: undefined, @@ -382,7 +385,11 @@ export class EngineImpl implements Engine { this.spec, this.tree, argv, - helpColorEnabled(argv, runtime, format), + preParseColorEnabled( + argv, + runtime, + format === "human" ? "stdout" : "stderr", + ), stream, ); return 0; diff --git a/packages/cli-engine/src/execution/help.ts b/packages/cli-engine/src/execution/help.ts index 2d4af362..ea9dc833 100644 --- a/packages/cli-engine/src/execution/help.ts +++ b/packages/cli-engine/src/execution/help.ts @@ -34,16 +34,18 @@ export function helpFlagGiven(argv: readonly string[]): boolean { ); } -/** Help renders before the shared flags are parsed, so its colour - * decision reads raw argv: explicit flag, then NO_COLOR, then the TTY - * of the stream help writes to. */ -export function helpColorEnabled( +/** The colour decision available before the shared flags are parsed, + * read from raw argv: explicit flag, then NO_COLOR, then the TTY of + * the stream about to be written. Help and pre-mount failures both + * render through this; applySharedFlags re-resolves once a command + * actually parses. */ +export function preParseColorEnabled( argv: readonly string[], runtime: { readonly env: Readonly>; readonly isTty: { readonly stdout: boolean; readonly stderr: boolean }; }, - format: "human" | "json", + stream: "stdout" | "stderr", ): boolean { const tokens = flagTokens(argv); if (tokens.includes("--no-color")) { @@ -55,7 +57,7 @@ export function helpColorEnabled( if (runtime.env.NO_COLOR !== undefined) { return false; } - return format === "human" ? runtime.isTty.stdout : runtime.isTty.stderr; + return runtime.isTty[stream]; } /** The command path the user asked help for: the leading non-flag From aaec6293fed63724aa7eb6f8297e15d259303a00 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 13 Aug 2026 08:32:59 +0200 Subject: [PATCH 04/11] Suppress the stdout mirror only when stdout and stderr are the same device MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two TTYs are almost always one terminal, but a harness can allocate a separate PTY per stream and read stdout on its own — there the mirror is the machine's only data. The bin now compares fstat identity of fd 1 and fd 2 and reports it as Runtime.outputStreamsShareDevice; the renderer keeps the mirror whenever the host can prove the streams are different devices. A host that cannot tell keeps the one-terminal assumption. Co-Authored-By: Claude Fable 5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../cli-engine/src/execution/rendering.ts | 19 +++++++++++++------ packages/cli-engine/src/runtime.ts | 10 ++++++++++ packages/cli/src/runtime.ts | 16 ++++++++++++++++ 3 files changed, 39 insertions(+), 6 deletions(-) diff --git a/packages/cli-engine/src/execution/rendering.ts b/packages/cli-engine/src/execution/rendering.ts index 162ec2ba..c93aeda8 100644 --- a/packages/cli-engine/src/execution/rendering.ts +++ b/packages/cli-engine/src/execution/rendering.ts @@ -432,12 +432,19 @@ export function renderCompletedHuman( } writeSections(sections, runtime.stderr); /** The machine lines exist for a consumer on the other end of - * stdout. Only when stdout and stderr BOTH render to a terminal do - * the blocks and the mirror land on the same screen as visible - * duplication, so that is the one case that skips them (amends the - * 2026-08-09 "always" ruling; any redirection of either stream - * keeps the mirror, so pipes still receive exactly the data lines). */ - if (!(runtime.isTty.stdout && runtime.isTty.stderr)) { + * stdout. Only when stdout and stderr both render to the SAME + * terminal do the blocks and the mirror land on one screen as + * visible duplication, so that is the one case that skips them + * (amends the 2026-08-09 "always" ruling; any redirection of either + * stream keeps the mirror, so pipes still receive exactly the data + * lines). A harness that allocates two separate PTYs reports + * outputStreamsShareDevice false and keeps its mirror; a host that + * cannot tell is treated as one terminal. */ + const oneScreen = + runtime.isTty.stdout && + runtime.isTty.stderr && + runtime.outputStreamsShareDevice !== false; + if (!oneScreen) { for (const line of presented.presentation.stdout) { runtime.stdout.write(`${line}\n`); } diff --git a/packages/cli-engine/src/runtime.ts b/packages/cli-engine/src/runtime.ts index d68ffea5..fbc06ac6 100644 --- a/packages/cli-engine/src/runtime.ts +++ b/packages/cli-engine/src/runtime.ts @@ -35,6 +35,16 @@ export interface Runtime { readonly stdout: boolean; readonly stderr: boolean; }; + /** + * Whether stdout and stderr are the same open device — the case where + * human blocks and the machine stdout mirror would draw on one screen + * as visible duplication. Consulted only when both streams are TTYs: + * `false` there means two separate terminals, so the mirror is kept + * for whatever is reading stdout. Absent means the host cannot tell, + * which is treated as "same" — the overwhelmingly common case for two + * TTYs is one terminal. + */ + readonly outputStreamsShareDevice?: boolean; /** * Forces the answer to "is this CI", where telemetry never reports. * Absent — the normal case — means the engine detects CI from `env` diff --git a/packages/cli/src/runtime.ts b/packages/cli/src/runtime.ts index a24d8e84..48156e7a 100644 --- a/packages/cli/src/runtime.ts +++ b/packages/cli/src/runtime.ts @@ -1,3 +1,4 @@ +import { fstatSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { type HostProcess, @@ -79,6 +80,20 @@ function warnOnDeprecatedStateFileEnvVar(proc: HostProcess): void { ); } +/** Whether fd 1 and fd 2 are the same open device. Distinguishes one + * terminal (mirror suppressed) from a harness that allocated separate + * PTYs for the two streams (mirror kept). Undefined when the fds + * cannot be inspected — the engine then assumes one terminal. */ +function outputStreamsShareDevice(): boolean | undefined { + try { + const out = fstatSync(1); + const err = fstatSync(2); + return out.dev === err.dev && out.ino === err.ino && out.rdev === err.rdev; + } catch { + return undefined; + } +} + export async function assembleRuntime(proc: HostProcess): Promise { const stdin: InputStream = { setRawMode: @@ -113,6 +128,7 @@ export async function assembleRuntime(proc: HostProcess): Promise { stdout: proc.stdout.isTTY === true, stderr: proc.stderr.isTTY === true, }, + outputStreamsShareDevice: outputStreamsShareDevice(), exit: (code) => proc.exit(code), onSignal: makeOnSignal(proc), loadConfig: (configPath) => loadConfig(proc.cwd(), configPath), From 009ad2a1254c27b744a32a8eef9a2d4c472c9064 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 13 Aug 2026 09:35:36 +0200 Subject: [PATCH 05/11] Restrict implicit help to bare invocations; harden e2e; commit the gallery tooling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implicit help now fires only for a truly bare invocation — no argv at all, or exactly a group path. 'cli --unknown' and 'cli project --frobnicate' reach routing and settle as the usage errors they are instead of exiting 0 with a help card (CodeRabbit finding on #172). The e2e harness failure message now carries the envelope's why and meta, so a server-side refusal is diagnosable from the CI log. Before its first create, each e2e process sweeps scratch projects a previous run stranded (our own e2e- naming, older than an hour), so leaked projects can no longer exhaust the workspace's quota permanently. scripts/output-gallery/ is the PTY-capture and rendering tool behind the PR's before/after gallery, committed for reuse; it writes into the gitignored wip/gallery/. Co-Authored-By: Claude Fable 5 Signed-off-by: willbot Signed-off-by: Will Madden --- packages/cli-engine/src/execution/help.ts | 12 +- packages/cli-engine/tests/execution.test.ts | 39 ++++ packages/cli/e2e/harness.ts | 17 +- packages/cli/e2e/scratch.ts | 66 ++++++- scripts/output-gallery/README.md | 18 ++ scripts/output-gallery/build.mjs | 205 ++++++++++++++++++++ scripts/output-gallery/capture.zsh | 46 +++++ scripts/output-gallery/page.mjs | 68 +++++++ 8 files changed, 464 insertions(+), 7 deletions(-) create mode 100644 scripts/output-gallery/README.md create mode 100644 scripts/output-gallery/build.mjs create mode 100644 scripts/output-gallery/capture.zsh create mode 100644 scripts/output-gallery/page.mjs diff --git a/packages/cli-engine/src/execution/help.ts b/packages/cli-engine/src/execution/help.ts index ea9dc833..a5be2275 100644 --- a/packages/cli-engine/src/execution/help.ts +++ b/packages/cli-engine/src/execution/help.ts @@ -100,15 +100,21 @@ function resolveTarget( return { target: { kind: "node", node }, path }; } -/** A bare group invocation (`prisma-cli project`) is a help request; - * a bare leaf is a command run and is left alone. */ +/** A BARE group invocation (`prisma-cli project`, or no argv at all) + * is a help request; anything carrying flags or extra tokens is not — + * `cli --unknown` and `cli project --frobnicate` must reach routing + * and usage validation, not exit 0 with a help card. A bare leaf is a + * command run and is left alone. */ export function bareGroupInvocation( root: CommandTreeNode, argv: readonly string[], ): boolean { const segments = helpPath(argv); + if (segments.length !== argv.length) { + return false; + } if (segments.length === 0) { - return flagTokens(argv).every((token) => token !== "--version"); + return true; } const { target, path } = resolveTarget(root, segments); return target.kind === "node" && path.length === segments.length; diff --git a/packages/cli-engine/tests/execution.test.ts b/packages/cli-engine/tests/execution.test.ts index 6f33d5a5..413ec2ba 100644 --- a/packages/cli-engine/tests/execution.test.ts +++ b/packages/cli-engine/tests/execution.test.ts @@ -1054,6 +1054,45 @@ describe("flag.optionalBoolean", () => { }); }); +describe("implicit help is only for bare invocations", () => { + const grouped = () => + createTestCli({ + commands: { "auth login": greet }, + groups: { auth: { brief: "Authentication" } }, + now: EPOCH, + }); + + test("no argv renders root help and exits 0", async () => { + const result = await grouped().run([], { isTty: { stdout: true } }); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("auth"); + }); + + test("a bare group renders the group's help and exits 0", async () => { + const result = await grouped().run(["auth"], { isTty: { stdout: true } }); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("login"); + }); + + test("an unknown root flag is a usage error, not a help card", async () => { + const result = await grouped().run(["--frobnicate"], { + isTty: { stdout: true }, + }); + + expect(result.exitCode).not.toBe(0); + }); + + test("a group invocation carrying a flag reaches routing", async () => { + const result = await grouped().run(["auth", "--frobnicate"], { + isTty: { stdout: true }, + }); + + expect(result.exitCode).not.toBe(0); + }); +}); + describe("help examples", () => { test("examples get the CLI name: {bin} is substituted, plain examples are prefixed", async () => { const exemplified = defineCommand({ diff --git a/packages/cli/e2e/harness.ts b/packages/cli/e2e/harness.ts index 9ba704d3..839c88c0 100644 --- a/packages/cli/e2e/harness.ts +++ b/packages/cli/e2e/harness.ts @@ -71,7 +71,12 @@ export interface ResultEnvelope { readonly ok: boolean; readonly commandId?: string; readonly result?: unknown; - readonly error?: { readonly code?: string; readonly summary?: string }; + readonly error?: { + readonly code?: string; + readonly summary?: string; + readonly why?: string; + readonly meta?: unknown; + }; readonly exitCode?: number; } @@ -200,10 +205,18 @@ export class E2eSession { const envelope = parseResultFrame(stdout); if (options.expectOk !== false && !envelope.ok) { + const why = + envelope.error?.why === undefined + ? "" + : `\n why: ${envelope.error.why}`; + const meta = + envelope.error?.meta === undefined + ? "" + : `\n meta: ${JSON.stringify(envelope.error.meta)}`; throw new Error( `expected \`${argv.join(" ")}\` to succeed, but it failed with ` + `${envelope.error?.code ?? "(no code)"}: ` + - `${envelope.error?.summary ?? "(no summary)"}\n${stderr.slice(0, 2000)}`, + `${envelope.error?.summary ?? "(no summary)"}${why}${meta}\n${stderr.slice(0, 2000)}`, ); } return { exitCode, stdout, stderr, envelope }; diff --git a/packages/cli/e2e/scratch.ts b/packages/cli/e2e/scratch.ts index 7163585c..a91ab749 100644 --- a/packages/cli/e2e/scratch.ts +++ b/packages/cli/e2e/scratch.ts @@ -29,7 +29,11 @@ export async function removeScratchProject( cli: { run: (args: readonly string[], options?: RunOptions) => Promise; }, - project: { readonly id: string; readonly name: string; readonly cwd: string }, + project: { + readonly id: string; + readonly name: string; + readonly cwd?: string; + }, ): Promise { const stranded = (detail: string) => console.warn( @@ -39,7 +43,10 @@ export async function removeScratchProject( try { const removal = await cli.run( ["project", "remove", project.id, "--confirm", project.id], - { cwd: project.cwd, expectOk: false }, + { + ...(project.cwd === undefined ? {} : { cwd: project.cwd }), + expectOk: false, + }, ); if (!removal.envelope.ok) { stranded( @@ -64,12 +71,67 @@ export interface ScratchHandle { * Registers the create/remove lifecycle for the calling test file. * Call at file top level, outside any `describe`. */ +const STALE_AFTER_MS = 60 * 60 * 1000; + +/** The base36 timestamp scratchName embeds, or undefined for a name + * this suite's naming scheme did not produce. */ +function scratchStampMs(name: string): number | undefined { + const parts = name.split("-"); + if (parts.length < 4) { + return undefined; + } + const stamp = Number.parseInt(parts[parts.length - 2], 36); + return Number.isFinite(stamp) ? stamp : undefined; +} + +let swept: Promise | undefined; + +/** + * Removes scratch projects a previous run stranded, once per process, + * before this run creates its own. Failed runs leak their projects + * (teardown warns rather than throws), and enough leaks exhaust the + * e2e workspace's project quota — every later `project create` then + * fails. Only names our own scheme produced, and only ones older than + * an hour, so live projects of a concurrent run are never touched. + */ +async function sweepStrandedScratchProjects(cli: { + run: (args: readonly string[], options?: RunOptions) => Promise; +}): Promise { + const listing = await cli.run(["project", "list"], { expectOk: false }); + if (!listing.envelope.ok) { + return; + } + const items = ( + listing.envelope.result as { + readonly items?: ReadonlyArray<{ + readonly id: string; + readonly name: string; + }>; + } + ).items; + const now = Date.now(); + for (const item of items ?? []) { + if (!isScratchName(item.name)) { + continue; + } + const stamp = scratchStampMs(item.name); + if (stamp === undefined || now - stamp < STALE_AFTER_MS) { + continue; + } + console.warn(`e2e sweep: removing stranded scratch project ${item.name}`); + // biome-ignore lint/performance/noAwaitInLoops: removals are rare and sequential on purpose — parallel deletes against the real API buy nothing and hammer it. + await removeScratchProject(cli, item); + } +} + export function useScratchProject(label: string): ScratchHandle { let created: ScratchProject | undefined; beforeAll(async () => { const cli = await session(); const cwd = await cli.workdir(); + swept ??= sweepStrandedScratchProjects(cli); + await swept; const name = scratchName(label); const run = await cli.run(["project", "create", name], { cwd }); diff --git a/scripts/output-gallery/README.md b/scripts/output-gallery/README.md new file mode 100644 index 00000000..43fc8da2 --- /dev/null +++ b/scripts/output-gallery/README.md @@ -0,0 +1,18 @@ +# Output gallery + +Captures the CLI's real terminal output under a PTY and renders it as a browsable HTML gallery — the tool behind the visual-system review on PR #172. + +Usage: + +```bash +zsh scripts/output-gallery/capture.zsh +node scripts/output-gallery/build.mjs +node scripts/output-gallery/page.mjs +open wip/gallery/gallery.html +``` + +- `capture.zsh` runs each command via `script(1)` so color renders exactly as a user sees it, writing `.ansi` files to `wip/gallery/shots/`. Cloud flows need an authenticated session; ORM flows need the scaffolded demo project (`wip/gallery/orm-demo`) and a local Postgres 17 (`docker run -d --name prisma-gallery-pg -e POSTGRES_PASSWORD=pg -p 55432:5432 postgres:17`). Shots for missing prerequisites simply capture the error — which is also part of the UX. +- `build.mjs` converts the ANSI captures to HTML panes (`gallery-body.html`). +- `page.mjs` wraps them in the page shell (`gallery.html`). + +Everything is written under `wip/gallery/` (gitignored); set `GALLERY_DIR` to use another directory. diff --git a/scripts/output-gallery/build.mjs b/scripts/output-gallery/build.mjs new file mode 100644 index 00000000..0c38befc --- /dev/null +++ b/scripts/output-gallery/build.mjs @@ -0,0 +1,205 @@ +// biome-ignore-all lint/suspicious/noControlCharactersInRegex: this file parses raw terminal output, and escape/control characters are exactly what it matches. +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +// Captures and output live in the gitignored working dir by default. +const GALLERY_DIR = + process.env.GALLERY_DIR ?? + new URL("../../wip/gallery/", import.meta.url).pathname; +const AFTER = `${GALLERY_DIR}shots/`; + +const FG = { + 30: "#3f4451", + 31: "#e05561", + 32: "#8cc265", + 33: "#d18f52", + 34: "#4aa5f0", + 35: "#c162de", + 36: "#42b3c2", + 37: "#d7dae0", + 90: "#6b7280", + 91: "#ff616e", + 92: "#a5e075", + 93: "#f0a45d", + 94: "#4dc4ff", + 95: "#de73ff", + 96: "#4cd1e0", + 97: "#ffffff", +}; + +function esc(s) { + return s + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">"); +} + +const SCRIPT_EOF_ECHO = /^\^D/; +const OSC_SEQUENCE = /\x1b\][^\x07]*\x07/g; +const PRIVATE_MODE_SEQUENCE = /\x1b\[\?[0-9;]*[a-zA-Z]/g; +const CONTROL_CHARS = /[\x00-\x08\x0b\x0c\x0e-\x1a\x1c-\x1f]/g; +const SGR_SPLIT = /(\x1b\[[0-9;]*m)/; +const SGR_MATCH = /^\x1b\[([0-9;]*)m$/; +const LEADING_NEWLINES = /^\n+/; +const TRAILING_NEWLINES = /\n+$/; + +function stripNoise(raw) { + return raw + .replace(SCRIPT_EOF_ECHO, "") + .replaceAll("\r\n", "\n") + .replaceAll("\r", "\n") + .replace(OSC_SEQUENCE, "") + .replace(PRIVATE_MODE_SEQUENCE, "") + .replace(CONTROL_CHARS, ""); +} + +function applySgrCodes(state, codes) { + for (const c of codes) { + if (c === 0) { + state.color = null; + state.bold = false; + state.dim = false; + } else if (c === 1) state.bold = true; + else if (c === 2) state.dim = true; + else if (c === 22) { + state.bold = false; + state.dim = false; + } else if (c === 39) state.color = null; + else if (FG[c]) state.color = FG[c]; + } +} + +function spanStyle(state) { + const css = []; + if (state.color) css.push(`color:${state.color}`); + if (state.bold) css.push("font-weight:700"); + if (state.dim) css.push("opacity:.55"); + return css.join(";"); +} + +function ansiToHtml(raw) { + let out = ""; + let open = false; + const state = { color: null, bold: false, dim: false }; + for (const part of stripNoise(raw).split(SGR_SPLIT)) { + const m = part.match(SGR_MATCH); + if (!m) { + out += esc(part); + continue; + } + if (open) { + out += ""; + open = false; + } + applySgrCodes(state, (m[1] === "" ? "0" : m[1]).split(";").map(Number)); + const style = spanStyle(state); + if (style) { + out += ``; + open = true; + } + } + if (open) out += ""; + return out + .replace(LEADING_NEWLINES, "") + .replace(TRAILING_NEWLINES, "") + .split("\n") + .filter( + (line) => + !line.includes("PN_CONTRACT_TYPED_FALLBACK") && + !line.includes("trace-warnings"), + ) + .join("\n"); +} + +function pane(dir, name) { + const path = join(dir, `${name}.ansi`); + if (!existsSync(path)) return null; + return ansiToHtml(readFileSync(path, "utf8")); +} + +// [name, command, note, {beforeName?}] +const SECTIONS = [ + [ + "Help", + [ + [ + "root-help", + "prisma-cli --help", + "Engine-rendered: banner, mount-ordered briefs, one Global options section, examples, docs. Group and leaf help follow the same card.", + ], + ], + ], + [ + "Platform flows", + [ + ["auth-whoami", "prisma-cli auth whoami", ""], + ["project-list", "prisma-cli project list", ""], + ["project-show", "prisma-cli project show --project prisma-next-dev", ""], + ["postgres-list", "prisma-cli postgres list (linked dir)", ""], + [ + "postgres-show", + "prisma-cli postgres show Development (linked dir)", + "", + ], + [ + "bucket-list", + "prisma-cli bucket list (linked dir)", + "Standard empty state.", + ], + ["service-list", "prisma-cli service list (linked dir)", ""], + ["branch-list", "prisma-cli branch list --project prisma-next-dev", ""], + ["agent-status", "prisma-cli agent status", ""], + ["telemetry-status", "prisma-cli telemetry status", ""], + [ + "init", + "prisma-cli init --framework hono (fresh app)", + "Step runner + fields card.", + ], + ], + ], + [ + "ORM flows (same engine, scaffolded Postgres 17 project)", + [ + ["contract-emit", "prisma-cli contract emit", ""], + [ + "db-init", + "prisma-cli db init --yes", + "Step runner, masked connection string, operation tree.", + ], + ["db-verify", "prisma-cli db verify", ""], + ["migration-status", "prisma-cli migration status", ""], + ["migration-graph", "prisma-cli migration graph", "The lane drawing."], + ["migration-log", "prisma-cli migration log", ""], + ], + ], + [ + "Errors", + [ + [ + "err-unknown", + "prisma-cli porject lst", + "Did-you-mean plus the --help pointer.", + ], + ["err-missing-arg", "prisma-cli feedback --no-interactive", ""], + ["err-setup-required", "prisma-cli postgres list (unlinked dir)", ""], + ], + ], +]; + +const cards = SECTIONS.map(([title, shots]) => { + const body = shots + .map(([name, cmd, note]) => { + const after = pane(AFTER, name); + if (after === null) return ""; + return ` +
+
${esc(cmd)}${note ? `${esc(note)}` : ""}
+
${after}
+
`; + }) + .join("\n"); + return `

${esc(title)}

${body}
`; +}).join("\n"); + +writeFileSync(`${GALLERY_DIR}gallery-body.html`, cards); +console.log("wrote gallery-body.html"); diff --git a/scripts/output-gallery/capture.zsh b/scripts/output-gallery/capture.zsh new file mode 100644 index 00000000..6929dc89 --- /dev/null +++ b/scripts/output-gallery/capture.zsh @@ -0,0 +1,46 @@ +#!/bin/zsh +set -u +ROOT=$(git rev-parse --show-toplevel) +SHOTS=$ROOT/wip/gallery/shots +mkdir -p "$SHOTS" +NODE=$(command -v node) +CLI=($NODE $ROOT/node_modules/tsx/dist/cli.mjs $ROOT/packages/cli/src/bin.ts) + +shot() { + local name=$1; shift + local dir=$1; shift + echo "== $name" + (cd "$dir" && script -q "$SHOTS/$name.ansi" "${CLI[@]}" "$@" >/dev/null 2>&1) +} + +shot root-help "$ROOT" --help +shot version "$ROOT" --version +shot auth-help "$ROOT" auth --help +shot auth-whoami "$ROOT" auth whoami +shot project-help "$ROOT" project --help +shot project-link-help "$ROOT" project link --help +shot init-help "$ROOT" init --help +shot migration-help "$ROOT" migration --help +shot db-help "$ROOT" db --help +shot feedback-help "$ROOT" feedback --help +shot project-list "$ROOT" project list +shot project-show "$ROOT" project show --project prisma-next-dev +shot project-link "$ROOT/wip/gallery/linked-demo" project show +shot postgres-list "$ROOT/wip/gallery/linked-demo" postgres list +shot postgres-show "$ROOT/wip/gallery/linked-demo" postgres show Development +shot bucket-list "$ROOT/wip/gallery/linked-demo" bucket list +shot service-list "$ROOT/wip/gallery/linked-demo" service list +shot branch-list "$ROOT" branch list --project prisma-next-dev +shot agent-status "$ROOT/wip/gallery/linked-demo" agent status +shot telemetry-status "$ROOT" telemetry status +shot init "$ROOT/wip/gallery/demo-app2" init --no-interactive --no-link --no-install --framework hono --entry server.ts +shot contract-emit "$ROOT/wip/gallery/orm-demo" contract emit +shot db-init "$ROOT/wip/gallery/orm-demo" db init --yes +shot db-verify "$ROOT/wip/gallery/orm-demo" db verify +shot migration-status "$ROOT/wip/gallery/orm-demo" migration status +shot migration-graph "$ROOT/wip/gallery/orm-demo" migration graph +shot migration-log "$ROOT/wip/gallery/orm-demo" migration log +shot err-unknown "$ROOT" porject lst +shot err-missing-arg "$ROOT" feedback --no-interactive +shot err-setup-required "$ROOT" postgres list +echo done diff --git a/scripts/output-gallery/page.mjs b/scripts/output-gallery/page.mjs new file mode 100644 index 00000000..d3126ae2 --- /dev/null +++ b/scripts/output-gallery/page.mjs @@ -0,0 +1,68 @@ +import { readFileSync, writeFileSync } from "node:fs"; + +const GALLERY_DIR = + process.env.GALLERY_DIR ?? + new URL("../../wip/gallery/", import.meta.url).pathname; +const body = readFileSync(`${GALLERY_DIR}gallery-body.html`, "utf8"); + +const html = `Prisma CLI output gallery + +
+
+

Prisma CLI output gallery

+

Real PTY captures of the major commands' primary flows on the current visual system (prisma/prisma-cli#172): engine-rendered help, the block renderer's cards, tables, trees and step runners, and the error surfaces.

+
+ +${body} +

Captured with wip/gallery/capture-after.sh (script(1) PTY); ORM flows against a throwaway Postgres 17 container, cloud flows against Will's workspace, read-only. Re-run the harness and republish this file to refresh.

+
`; + +writeFileSync(`${GALLERY_DIR}gallery.html`, html); +console.log("wrote gallery.html"); From 1b6ed12ce411780aab02b67eac751ebc03ada9a2 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 13 Aug 2026 09:47:22 +0200 Subject: [PATCH 06/11] CI bisect: disable the fstat probe to isolate the e2e resolution failure Temporary diagnostic commit; reverted or squashed once e2e answers. Co-Authored-By: Claude Fable 5 Signed-off-by: willbot Signed-off-by: Will Madden --- packages/cli/src/runtime.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/runtime.ts b/packages/cli/src/runtime.ts index 48156e7a..23aab9ed 100644 --- a/packages/cli/src/runtime.ts +++ b/packages/cli/src/runtime.ts @@ -84,7 +84,7 @@ function warnOnDeprecatedStateFileEnvVar(proc: HostProcess): void { * terminal (mirror suppressed) from a harness that allocated separate * PTYs for the two streams (mirror kept). Undefined when the fds * cannot be inspected — the engine then assumes one terminal. */ -function outputStreamsShareDevice(): boolean | undefined { +export function outputStreamsShareDevice(): boolean | undefined { try { const out = fstatSync(1); const err = fstatSync(2); @@ -128,7 +128,8 @@ export async function assembleRuntime(proc: HostProcess): Promise { stdout: proc.stdout.isTTY === true, stderr: proc.stderr.isTTY === true, }, - outputStreamsShareDevice: outputStreamsShareDevice(), + // CI bisect: probe disabled; the engine treats absence as one terminal. + // outputStreamsShareDevice: outputStreamsShareDevice(), exit: (code) => proc.exit(code), onSignal: makeOnSignal(proc), loadConfig: (configPath) => loadConfig(proc.cwd(), configPath), From 3788c29b8a0dfc3419ac645a91f31633378bbf23 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 13 Aug 2026 09:50:17 +0200 Subject: [PATCH 07/11] CI diagnostic: re-enable the probe and dump the e2e module layout Temporary; removed before merge. Co-Authored-By: Claude Fable 5 Signed-off-by: willbot Signed-off-by: Will Madden --- .github/workflows/test-e2e.yml | 13 +++++++++++++ packages/cli/src/runtime.ts | 5 ++--- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/.github/workflows/test-e2e.yml b/.github/workflows/test-e2e.yml index 55753515..5e3a82c4 100644 --- a/.github/workflows/test-e2e.yml +++ b/.github/workflows/test-e2e.yml @@ -69,6 +69,19 @@ jobs: - name: Build workspace packages run: pnpm build + # TEMP DIAGNOSTIC (remove before merge): the fstat probe correlates + # with an openapi-fetch resolution failure only in CI; dump the + # layout and probe the exact failing import chain. + - name: Diagnose module layout + run: | + ls -la packages/cli-engine/node_modules/@prisma/ || true + ls node_modules/.pnpm/ | grep -iE "management|openapi" || true + ls node_modules/.pnpm/@prisma+management-api-sdk@1.55.0/node_modules/ || true + node -e "import('@prisma/management-api-sdk').then(() => console.log('root import ok'), (e) => console.log('root import FAIL:', e.message))" + node --input-type=module -e "import { pathToFileURL } from 'node:url'; const parent = pathToFileURL(process.cwd() + '/packages/cli-engine/dist/index.js').href; const r = await import.meta.resolve ? 'has-resolve' : 'no-resolve'; console.log(r); import(parent).then(() => console.log('engine dist import ok'), (e) => console.log('engine dist FAIL:', e.message));" + node packages/cli/dist/cli.js --version || true + PRISMA_SERVICE_TOKEN=invalid node packages/cli/dist/cli.js project list --json || true + - name: Run the real-API end-to-end suite run: pnpm --filter @prisma/cli test:e2e env: diff --git a/packages/cli/src/runtime.ts b/packages/cli/src/runtime.ts index 23aab9ed..48156e7a 100644 --- a/packages/cli/src/runtime.ts +++ b/packages/cli/src/runtime.ts @@ -84,7 +84,7 @@ function warnOnDeprecatedStateFileEnvVar(proc: HostProcess): void { * terminal (mirror suppressed) from a harness that allocated separate * PTYs for the two streams (mirror kept). Undefined when the fds * cannot be inspected — the engine then assumes one terminal. */ -export function outputStreamsShareDevice(): boolean | undefined { +function outputStreamsShareDevice(): boolean | undefined { try { const out = fstatSync(1); const err = fstatSync(2); @@ -128,8 +128,7 @@ export async function assembleRuntime(proc: HostProcess): Promise { stdout: proc.stdout.isTTY === true, stderr: proc.stderr.isTTY === true, }, - // CI bisect: probe disabled; the engine treats absence as one terminal. - // outputStreamsShareDevice: outputStreamsShareDevice(), + outputStreamsShareDevice: outputStreamsShareDevice(), exit: (code) => proc.exit(code), onSignal: makeOnSignal(proc), loadConfig: (configPath) => loadConfig(proc.cwd(), configPath), From c40479a74fbfc8c43c82701f1ae3c10ec54ec86e Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 13 Aug 2026 09:56:42 +0200 Subject: [PATCH 08/11] CI diagnostic 2: isolate harness cwd and env conditions Temporary; removed before merge. Co-Authored-By: Claude Fable 5 Signed-off-by: willbot Signed-off-by: Will Madden --- .github/workflows/test-e2e.yml | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/.github/workflows/test-e2e.yml b/.github/workflows/test-e2e.yml index 5e3a82c4..053b4814 100644 --- a/.github/workflows/test-e2e.yml +++ b/.github/workflows/test-e2e.yml @@ -72,6 +72,25 @@ jobs: # TEMP DIAGNOSTIC (remove before merge): the fstat probe correlates # with an openapi-fetch resolution failure only in CI; dump the # layout and probe the exact failing import chain. + # TEMP DIAGNOSTIC 2 (remove before merge): the harness child differs + # from a workflow-step child by minimal env + out-of-repo cwd. + # Reproduce each in isolation. + - name: Diagnose harness conditions + env: + PRISMA_E2E_SERVICE_TOKEN: ${{ secrets.PRISMA_E2E_SERVICE_TOKEN }} + PRISMA_E2E_WORKSPACE_ID: ${{ secrets.PRISMA_E2E_WORKSPACE_ID }} + run: | + REPO="$PWD" + SANDBOX="$(mktemp -d)" + echo "--- A: repo cwd, full env" + PRISMA_SERVICE_TOKEN="$PRISMA_E2E_SERVICE_TOKEN" PRISMA_WORKSPACE_ID="$PRISMA_E2E_WORKSPACE_ID" node "$REPO/packages/cli/dist/cli.js" project list --json | head -c 300; echo + echo "--- B: sandbox cwd, full env" + (cd "$SANDBOX" && PRISMA_SERVICE_TOKEN="$PRISMA_E2E_SERVICE_TOKEN" PRISMA_WORKSPACE_ID="$PRISMA_E2E_WORKSPACE_ID" node "$REPO/packages/cli/dist/cli.js" project list --json | head -c 300); echo + echo "--- C: sandbox cwd, minimal env (the harness shape)" + (cd "$SANDBOX" && env -i PATH="$PATH" HOME="$SANDBOX" TMPDIR="${TMPDIR:-/tmp}" CI=1 DO_NOT_TRACK=1 PRISMA_NEXT_DISABLE_TELEMETRY=1 PRISMA_SERVICE_TOKEN="$PRISMA_E2E_SERVICE_TOKEN" PRISMA_WORKSPACE_ID="$PRISMA_E2E_WORKSPACE_ID" node "$REPO/packages/cli/dist/cli.js" project list --json | head -c 300); echo + echo "--- D: repo cwd, minimal env" + env -i PATH="$PATH" HOME="$SANDBOX" TMPDIR="${TMPDIR:-/tmp}" CI=1 DO_NOT_TRACK=1 PRISMA_NEXT_DISABLE_TELEMETRY=1 PRISMA_SERVICE_TOKEN="$PRISMA_E2E_SERVICE_TOKEN" PRISMA_WORKSPACE_ID="$PRISMA_E2E_WORKSPACE_ID" node "$REPO/packages/cli/dist/cli.js" project list --json | head -c 300; echo + - name: Diagnose module layout run: | ls -la packages/cli-engine/node_modules/@prisma/ || true From 2e89b0088a096857c664cab56dc29890a6bb13ca Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 13 Aug 2026 10:09:03 +0200 Subject: [PATCH 09/11] Survive the ESM resolver returning the SDK's un-realpathed symlink URL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of the e2e failures, found by local bisect and artifact-level toggling: Node's ESM resolver can hand the management-api-sdk dynamic import the package's pnpm symlink URL instead of its .pnpm real path, and from the symlink URL the SDK's own 'openapi-fetch' import cannot resolve. The fault is state-dependent — the two fstatSync calls the same-device probe makes at startup provoke it deterministically, while a module-loader hook or removing the calls masks it; a literal field value in the same artifact passes, so the syscalls, not the field, perturb the resolver. The engine's SDK import now falls back to resolving through CJS require (which realpaths) and importing the real location directly. The fallback never fires when the normal import works, and hardens the latent hazard independently of the probe. Also removes the two temporary diagnostic steps from the e2e workflow. Co-Authored-By: Claude Fable 5 Signed-off-by: willbot Signed-off-by: Will Madden --- .github/workflows/test-e2e.yml | 32 ------------------- .../cli-engine/src/execution/api-client.ts | 28 +++++++++++++++- 2 files changed, 27 insertions(+), 33 deletions(-) diff --git a/.github/workflows/test-e2e.yml b/.github/workflows/test-e2e.yml index 053b4814..55753515 100644 --- a/.github/workflows/test-e2e.yml +++ b/.github/workflows/test-e2e.yml @@ -69,38 +69,6 @@ jobs: - name: Build workspace packages run: pnpm build - # TEMP DIAGNOSTIC (remove before merge): the fstat probe correlates - # with an openapi-fetch resolution failure only in CI; dump the - # layout and probe the exact failing import chain. - # TEMP DIAGNOSTIC 2 (remove before merge): the harness child differs - # from a workflow-step child by minimal env + out-of-repo cwd. - # Reproduce each in isolation. - - name: Diagnose harness conditions - env: - PRISMA_E2E_SERVICE_TOKEN: ${{ secrets.PRISMA_E2E_SERVICE_TOKEN }} - PRISMA_E2E_WORKSPACE_ID: ${{ secrets.PRISMA_E2E_WORKSPACE_ID }} - run: | - REPO="$PWD" - SANDBOX="$(mktemp -d)" - echo "--- A: repo cwd, full env" - PRISMA_SERVICE_TOKEN="$PRISMA_E2E_SERVICE_TOKEN" PRISMA_WORKSPACE_ID="$PRISMA_E2E_WORKSPACE_ID" node "$REPO/packages/cli/dist/cli.js" project list --json | head -c 300; echo - echo "--- B: sandbox cwd, full env" - (cd "$SANDBOX" && PRISMA_SERVICE_TOKEN="$PRISMA_E2E_SERVICE_TOKEN" PRISMA_WORKSPACE_ID="$PRISMA_E2E_WORKSPACE_ID" node "$REPO/packages/cli/dist/cli.js" project list --json | head -c 300); echo - echo "--- C: sandbox cwd, minimal env (the harness shape)" - (cd "$SANDBOX" && env -i PATH="$PATH" HOME="$SANDBOX" TMPDIR="${TMPDIR:-/tmp}" CI=1 DO_NOT_TRACK=1 PRISMA_NEXT_DISABLE_TELEMETRY=1 PRISMA_SERVICE_TOKEN="$PRISMA_E2E_SERVICE_TOKEN" PRISMA_WORKSPACE_ID="$PRISMA_E2E_WORKSPACE_ID" node "$REPO/packages/cli/dist/cli.js" project list --json | head -c 300); echo - echo "--- D: repo cwd, minimal env" - env -i PATH="$PATH" HOME="$SANDBOX" TMPDIR="${TMPDIR:-/tmp}" CI=1 DO_NOT_TRACK=1 PRISMA_NEXT_DISABLE_TELEMETRY=1 PRISMA_SERVICE_TOKEN="$PRISMA_E2E_SERVICE_TOKEN" PRISMA_WORKSPACE_ID="$PRISMA_E2E_WORKSPACE_ID" node "$REPO/packages/cli/dist/cli.js" project list --json | head -c 300; echo - - - name: Diagnose module layout - run: | - ls -la packages/cli-engine/node_modules/@prisma/ || true - ls node_modules/.pnpm/ | grep -iE "management|openapi" || true - ls node_modules/.pnpm/@prisma+management-api-sdk@1.55.0/node_modules/ || true - node -e "import('@prisma/management-api-sdk').then(() => console.log('root import ok'), (e) => console.log('root import FAIL:', e.message))" - node --input-type=module -e "import { pathToFileURL } from 'node:url'; const parent = pathToFileURL(process.cwd() + '/packages/cli-engine/dist/index.js').href; const r = await import.meta.resolve ? 'has-resolve' : 'no-resolve'; console.log(r); import(parent).then(() => console.log('engine dist import ok'), (e) => console.log('engine dist FAIL:', e.message));" - node packages/cli/dist/cli.js --version || true - PRISMA_SERVICE_TOKEN=invalid node packages/cli/dist/cli.js project list --json || true - - name: Run the real-API end-to-end suite run: pnpm --filter @prisma/cli test:e2e env: diff --git a/packages/cli-engine/src/execution/api-client.ts b/packages/cli-engine/src/execution/api-client.ts index 65b073f0..2d6fdf18 100644 --- a/packages/cli-engine/src/execution/api-client.ts +++ b/packages/cli-engine/src/execution/api-client.ts @@ -90,6 +90,32 @@ export function buildManagementApiClient( }); } +/** + * Loads the SDK, surviving a Node ESM resolver fault observed in the + * wild: the resolver can return the package's un-realpathed pnpm + * symlink URL (instead of the .pnpm real path), from which the SDK's + * own 'openapi-fetch' import cannot resolve. The fault is + * state-dependent — two fstatSync calls at process start reliably + * provoke it, and a module-loader hook masks it — so nothing here can + * rule it out. The fallback resolves through CJS require, which + * realpaths, and imports the real location directly; it never fires + * when the normal import works. + */ +async function importManagementApiSdk(): Promise< + typeof import("@prisma/management-api-sdk") +> { + try { + return await import("@prisma/management-api-sdk"); + } catch { + const { createRequire } = await import("node:module"); + const { pathToFileURL } = await import("node:url"); + const real = createRequire(import.meta.url).resolve( + "@prisma/management-api-sdk", + ); + return await import(pathToFileURL(real).href); + } +} + async function constructClient( invocation: Invocation, debug: DebugLog, @@ -118,7 +144,7 @@ async function constructClient( debug, probe, ); - const { createManagementApiSdk } = await import("@prisma/management-api-sdk"); + const { createManagementApiSdk } = await importManagementApiSdk(); const sdk = createManagementApiSdk({ clientId: config.clientId, redirectUri: config.redirectUri, From 39513a22ee497115a007733c193ca4dc33d2b990 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 13 Aug 2026 15:33:09 +0200 Subject: [PATCH 10/11] Address the remaining review findings on the sweep and gallery tooling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The e2e sweep catches a thrown project-list run so a timeout cannot reject the shared swept promise and fail every later setup, and its stale threshold moves to 24 hours — a GitHub Actions job is hard-capped at 6, so no live concurrent run's project can qualify. The gallery tooling honors GALLERY_DIR consistently (capture.zsh reads it; the Node scripts normalize via fileURLToPath + join), and the page footer names the committed capture script. Co-Authored-By: Claude Fable 5 Signed-off-by: willbot Signed-off-by: Will Madden --- packages/cli/e2e/scratch.ts | 19 +++++++++++++++++-- scripts/output-gallery/README.md | 2 +- scripts/output-gallery/build.mjs | 3 ++- scripts/output-gallery/capture.zsh | 3 ++- scripts/output-gallery/page.mjs | 6 ++++-- 5 files changed, 26 insertions(+), 7 deletions(-) diff --git a/packages/cli/e2e/scratch.ts b/packages/cli/e2e/scratch.ts index a91ab749..81c0f34b 100644 --- a/packages/cli/e2e/scratch.ts +++ b/packages/cli/e2e/scratch.ts @@ -71,7 +71,10 @@ export interface ScratchHandle { * Registers the create/remove lifecycle for the calling test file. * Call at file top level, outside any `describe`. */ -const STALE_AFTER_MS = 60 * 60 * 1000; +/** Comfortably beyond any live run: a GitHub Actions job is hard-capped + * at 6 hours, so a scratch project older than a day cannot belong to a + * run that is still executing. */ +const STALE_AFTER_MS = 24 * 60 * 60 * 1000; /** The base36 timestamp scratchName embeds, or undefined for a name * this suite's naming scheme did not produce. */ @@ -97,7 +100,19 @@ let swept: Promise | undefined; async function sweepStrandedScratchProjects(cli: { run: (args: readonly string[], options?: RunOptions) => Promise; }): Promise { - const listing = await cli.run(["project", "list"], { expectOk: false }); + let listing: CliRun; + try { + listing = await cli.run(["project", "list"], { expectOk: false }); + } catch (failure) { + // A timeout or unreadable stream must not reject the shared `swept` + // promise — that would fail every later setup in this process. The + // sweep is best-effort; skipping it only leaves the strand for the + // next run. + console.warn( + `e2e sweep skipped: ${failure instanceof Error ? failure.message : String(failure)}`, + ); + return; + } if (!listing.envelope.ok) { return; } diff --git a/scripts/output-gallery/README.md b/scripts/output-gallery/README.md index 43fc8da2..4c6cd616 100644 --- a/scripts/output-gallery/README.md +++ b/scripts/output-gallery/README.md @@ -15,4 +15,4 @@ open wip/gallery/gallery.html - `build.mjs` converts the ANSI captures to HTML panes (`gallery-body.html`). - `page.mjs` wraps them in the page shell (`gallery.html`). -Everything is written under `wip/gallery/` (gitignored); set `GALLERY_DIR` to use another directory. +Everything is written under `wip/gallery/` (gitignored); set `GALLERY_DIR` to an absolute directory path (no trailing slash needed) to use another one. diff --git a/scripts/output-gallery/build.mjs b/scripts/output-gallery/build.mjs index 0c38befc..036a8fb4 100644 --- a/scripts/output-gallery/build.mjs +++ b/scripts/output-gallery/build.mjs @@ -1,6 +1,7 @@ // biome-ignore-all lint/suspicious/noControlCharactersInRegex: this file parses raw terminal output, and escape/control characters are exactly what it matches. import { existsSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; +import { fileURLToPath } from "node:url"; // Captures and output live in the gitignored working dir by default. const GALLERY_DIR = @@ -201,5 +202,5 @@ const cards = SECTIONS.map(([title, shots]) => { return `

${esc(title)}

${body}
`; }).join("\n"); -writeFileSync(`${GALLERY_DIR}gallery-body.html`, cards); +writeFileSync(join(GALLERY_DIR, "gallery-body.html"), cards); console.log("wrote gallery-body.html"); diff --git a/scripts/output-gallery/capture.zsh b/scripts/output-gallery/capture.zsh index 6929dc89..955c56df 100644 --- a/scripts/output-gallery/capture.zsh +++ b/scripts/output-gallery/capture.zsh @@ -1,7 +1,8 @@ #!/bin/zsh set -u ROOT=$(git rev-parse --show-toplevel) -SHOTS=$ROOT/wip/gallery/shots +GALLERY_DIR=${GALLERY_DIR:-$ROOT/wip/gallery} +SHOTS=$GALLERY_DIR/shots mkdir -p "$SHOTS" NODE=$(command -v node) CLI=($NODE $ROOT/node_modules/tsx/dist/cli.mjs $ROOT/packages/cli/src/bin.ts) diff --git a/scripts/output-gallery/page.mjs b/scripts/output-gallery/page.mjs index d3126ae2..2b0f06ae 100644 --- a/scripts/output-gallery/page.mjs +++ b/scripts/output-gallery/page.mjs @@ -1,4 +1,6 @@ import { readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; const GALLERY_DIR = process.env.GALLERY_DIR ?? @@ -61,8 +63,8 @@ pre.term { Errors ${body} -

Captured with wip/gallery/capture-after.sh (script(1) PTY); ORM flows against a throwaway Postgres 17 container, cloud flows against Will's workspace, read-only. Re-run the harness and republish this file to refresh.

+

Captured with scripts/output-gallery/capture.zsh (script(1) PTY); ORM flows against a throwaway Postgres 17 container, cloud flows against Will's workspace, read-only. Re-run the harness and republish this file to refresh.

`; -writeFileSync(`${GALLERY_DIR}gallery.html`, html); +writeFileSync(join(GALLERY_DIR, "gallery.html"), html); console.log("wrote gallery.html"); From c03cbeaad104c0aa8698a7fa6df7df27d9788242 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 13 Aug 2026 15:33:56 +0200 Subject: [PATCH 11/11] Finish the GALLERY_DIR normalization the previous commit half-applied Co-Authored-By: Claude Fable 5 Signed-off-by: willbot Signed-off-by: Will Madden --- scripts/output-gallery/build.mjs | 4 ++-- scripts/output-gallery/page.mjs | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/scripts/output-gallery/build.mjs b/scripts/output-gallery/build.mjs index 036a8fb4..7be0585d 100644 --- a/scripts/output-gallery/build.mjs +++ b/scripts/output-gallery/build.mjs @@ -6,8 +6,8 @@ import { fileURLToPath } from "node:url"; // Captures and output live in the gitignored working dir by default. const GALLERY_DIR = process.env.GALLERY_DIR ?? - new URL("../../wip/gallery/", import.meta.url).pathname; -const AFTER = `${GALLERY_DIR}shots/`; + fileURLToPath(new URL("../../wip/gallery/", import.meta.url)); +const AFTER = join(GALLERY_DIR, "shots"); const FG = { 30: "#3f4451", diff --git a/scripts/output-gallery/page.mjs b/scripts/output-gallery/page.mjs index 2b0f06ae..24deeea2 100644 --- a/scripts/output-gallery/page.mjs +++ b/scripts/output-gallery/page.mjs @@ -4,8 +4,8 @@ import { fileURLToPath } from "node:url"; const GALLERY_DIR = process.env.GALLERY_DIR ?? - new URL("../../wip/gallery/", import.meta.url).pathname; -const body = readFileSync(`${GALLERY_DIR}gallery-body.html`, "utf8"); + fileURLToPath(new URL("../../wip/gallery/", import.meta.url)); +const body = readFileSync(join(GALLERY_DIR, "gallery-body.html"), "utf8"); const html = `Prisma CLI output gallery