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/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, diff --git a/packages/cli-engine/src/execution/engine.ts b/packages/cli-engine/src/execution/engine.ts index 55fb57b9..fec829e5 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, + helpFlagGiven, + preParseColorEnabled, + 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), @@ -297,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, @@ -353,6 +375,25 @@ 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, + preParseColorEnabled( + argv, + runtime, + format === "human" ? "stdout" : "stderr", + ), + 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..a5be2275 --- /dev/null +++ b/packages/cli-engine/src/execution/help.ts @@ -0,0 +1,539 @@ +/** + * 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", + ); +} + +/** 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 }; + }, + stream: "stdout" | "stderr", +): 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 runtime.isTty[stream]; +} + +/** 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`, 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 true; + } + 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..c93aeda8 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) @@ -309,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 @@ -319,16 +412,41 @@ 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)}\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)); + sections.push(diagnosticSection(withDocsUrl(state, diagnostic), paint)); } - for (const line of presented.presentation.stdout) { - runtime.stdout.write(`${line}\n`); + 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 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/execution/settlement.ts b/packages/cli-engine/src/execution/settlement.ts index e5bfb619..498f816a 100644 --- a/packages/cli-engine/src/execution/settlement.ts +++ b/packages/cli-engine/src/execution/settlement.ts @@ -13,12 +13,14 @@ import { } from "../protocol"; 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"; @@ -239,7 +241,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)); @@ -313,11 +317,13 @@ export function emitErrored( }); return; } - const stderr = invocation.runtime.stderr; - writeDiagnostic(stderr, envelope.error); - for (const diagnostic of envelope.diagnostics) { - writeDiagnostic(stderr, diagnostic); - } + const paint = makePaint(invocation.state.colorEnabled); + 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 @@ -427,21 +433,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/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-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..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({ @@ -1081,7 +1120,7 @@ describe("help examples", () => { expect(result.exitCode).toBe(0); expect(result.stdout).toBe(""); - expect(result.stderr).toContain("USAGE"); + expect(result.stderr).toContain("Usage"); }); }); @@ -1184,12 +1223,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/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..81c0f34b 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,82 @@ export interface ScratchHandle { * Registers the create/remove lifecycle for the calling test file. * Call at file top level, outside any `describe`. */ +/** 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. */ +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 { + 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; + } + 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/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/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), 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..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,9 +111,10 @@ describe("golden rendering", () => { expect(result.exitCode).toBe(0); expect(result.stderr).toBe( "ℹ Listing your workspace sessions on this machine.\n" + - "name id status\n" + + "\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"); }); @@ -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,9 +206,10 @@ 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" + + "\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: [], }); 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", diff --git a/scripts/output-gallery/README.md b/scripts/output-gallery/README.md new file mode 100644 index 00000000..4c6cd616 --- /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 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 new file mode 100644 index 00000000..7be0585d --- /dev/null +++ b/scripts/output-gallery/build.mjs @@ -0,0 +1,206 @@ +// 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 = + process.env.GALLERY_DIR ?? + fileURLToPath(new URL("../../wip/gallery/", import.meta.url)); +const AFTER = join(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(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 new file mode 100644 index 00000000..955c56df --- /dev/null +++ b/scripts/output-gallery/capture.zsh @@ -0,0 +1,47 @@ +#!/bin/zsh +set -u +ROOT=$(git rev-parse --show-toplevel) +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) + +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..24deeea2 --- /dev/null +++ b/scripts/output-gallery/page.mjs @@ -0,0 +1,70 @@ +import { readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const GALLERY_DIR = + process.env.GALLERY_DIR ?? + fileURLToPath(new URL("../../wip/gallery/", import.meta.url)); +const body = readFileSync(join(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 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(join(GALLERY_DIR, "gallery.html"), html); +console.log("wrote gallery.html");