diff --git a/docs/product/cli-style-guide.md b/docs/product/cli-style-guide.md index ba11f880..d50a1476 100644 --- a/docs/product/cli-style-guide.md +++ b/docs/product/cli-style-guide.md @@ -175,7 +175,7 @@ Shared flag rules: Shared global flags, defined by the engine in `SHARED_FLAG_PARAMETERS` (`packages/cli-engine/src/execution/shared-flags.ts`, the source of truth for this list): -- `--format ` +- `--format ` - `--json` (shorthand for `--format json`) - `--log-level ` - `-v`, `--verbose` (shorthand for `--log-level verbose`) diff --git a/docs/product/output-conventions.md b/docs/product/output-conventions.md index 31ef993c..d10a552e 100644 --- a/docs/product/output-conventions.md +++ b/docs/product/output-conventions.md @@ -471,6 +471,10 @@ Rules: - human-oriented decoration should be suppressed in JSON mode - missing values should be `null`, not placeholder strings +## `--format markdown` + +`--format markdown` renders the same blocks a command describes for human output as plain Markdown: a summary line, `label: value` rows, GFM pipe tables, bullet lists, nested bullets for trees, and fenced code for drawings, followed by `### Next` for the suggested next actions and `### Diagnostics` for any findings. It exists for an agent that reads CLI output as text rather than parsing JSON: every value is labelled, nothing is padded, wrapped, aligned, or coloured, and no tokens go to envelope keys. Every part of the run's output — blocks, next actions, diagnostics, structured errors, help, `--version`, and live events — lands on stdout, and the engine writes nothing to stderr. The format is only ever explicit: without `--format markdown` a terminal gets human output and a pipe gets JSON. + ## Non-Streaming JSON Shape Commands that return one final result should emit one JSON object to stdout. diff --git a/packages/cli-engine/README.md b/packages/cli-engine/README.md index 11c9f56a..e4f29223 100644 --- a/packages/cli-engine/README.md +++ b/packages/cli-engine/README.md @@ -2,6 +2,8 @@ The execution engine of the unified Prisma CLI: it owns the path from argv to exit code — parsing, execution, rendering, and error handling. +Every command describes its output once, as blocks, and the engine renders it in one of three formats chosen by `--format`: `human` for a person at a terminal (padded, aligned, coloured, on stderr, with the machine-usable data lines on stdout), `json` for a program (a stream of frames ending in a result envelope on stdout), and `markdown` for an agent that reads the output as text (plain Markdown with every value labelled, everything on stdout, nothing on stderr). A terminal gets `human` and a pipe gets `json` unless a format is named; `markdown` is only ever explicit. + ## Entry points - `@prisma/cli-engine` — the engine: command definitions, context, and the runner. diff --git a/packages/cli-engine/package.json b/packages/cli-engine/package.json index 47537fd1..04cf495a 100644 --- a/packages/cli-engine/package.json +++ b/packages/cli-engine/package.json @@ -1,6 +1,6 @@ { "name": "@prisma/cli-engine", - "version": "0.3.0", + "version": "0.4.0", "description": "The execution engine of the unified Prisma CLI.", "type": "module", "exports": { diff --git a/packages/cli-engine/src/execution/command-context.ts b/packages/cli-engine/src/execution/command-context.ts index 1062b8d9..bac5f6df 100644 --- a/packages/cli-engine/src/execution/command-context.ts +++ b/packages/cli-engine/src/execution/command-context.ts @@ -40,10 +40,20 @@ function availableWidth(stream: OutputStream): number { * are stderr's. `width` is a getter because the contract reads it per * render rather than caching it. */ export function makeUi(colorEnabled: boolean, stderr: OutputStream): Ui { + return uiWith(colorEnabled, () => availableWidth(stderr)); +} + +/** Markdown is read as text, never on a terminal: no colour, no + * width. */ +export function unboundedUi(): Ui { + return uiWith(false, () => Number.POSITIVE_INFINITY); +} + +function uiWith(colorEnabled: boolean, width: () => number): Ui { const paint = makePaint(colorEnabled); return { get width() { - return availableWidth(stderr); + return width(); }, emphasize: (text) => paint("emphasis", text), dim: (text) => paint("muted", text), @@ -86,6 +96,14 @@ function materializePresentation( next: presentations.next?.() ?? [], }; } + if (state.format === "markdown") { + return { + human: presentations.human(ui), + stdout: [], + json: undefined, + next: presentations.next?.() ?? [], + }; + } return { human: presentations.human(ui), stdout: presentations.stdout?.() ?? [], @@ -108,7 +126,10 @@ export function makeContext( capabilities: CommandCapabilities, ): CommandContext { const state = invocation.state; - const ui = makeUi(state.colorEnabled, invocation.runtime.stderr); + const ui = + state.format === "markdown" + ? unboundedUi() + : makeUi(state.colorEnabled, invocation.runtime.stderr); const present = ( outcome: { readonly data: T; diff --git a/packages/cli-engine/src/execution/engine.ts b/packages/cli-engine/src/execution/engine.ts index 5491496f..2bed1cc6 100644 --- a/packages/cli-engine/src/execution/engine.ts +++ b/packages/cli-engine/src/execution/engine.ts @@ -386,16 +386,19 @@ export class EngineImpl implements Engine { /** 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; + const stream = format === "json" ? runtime.stderr : runtime.stdout; renderHelp( this.spec, this.tree, argv, - preParseColorEnabled( - argv, - runtime, - format === "human" ? "stdout" : "stderr", - ), + { + format, + colorEnabled: preParseColorEnabled( + argv, + runtime, + format === "json" ? "stderr" : "stdout", + ), + }, stream, ); return 0; @@ -405,7 +408,7 @@ export class EngineImpl implements Engine { * exactly the frame stream, so help prose goes to stderr instead. */ stdout: { write: (text: string) => - (state.format === "human" ? runtime.stdout : runtime.stderr).write( + (state.format === "json" ? runtime.stderr : runtime.stdout).write( text, ), }, diff --git a/packages/cli-engine/src/execution/help.ts b/packages/cli-engine/src/execution/help.ts index b4b45337..b09a5faa 100644 --- a/packages/cli-engine/src/execution/help.ts +++ b/packages/cli-engine/src/execution/help.ts @@ -13,9 +13,12 @@ import { positionalRuntime, } from "../args"; import type { AnyCommand, WorkflowStep } from "../commands"; +import type { Format } from "../presentation"; import type { CommandTreeEntry, CommandTreeNode } from "./command-tree"; import type { EngineSpec } from "./engine"; +import { renderHelpMarkdown } from "./markdown"; import { makePaint, type Paint, textWidth } from "./palette"; +import { formatFlagGiven, withoutFormatFlags } from "./pre-parse-argv"; import { SHARED_ALIASES, SHARED_FLAG_PARAMETERS } from "./shared-flags"; import { resolveExample } from "./stricli-adapter"; @@ -47,6 +50,9 @@ export function preParseColorEnabled( }, stream: "stdout" | "stderr", ): boolean { + if (formatFlagGiven(argv) === "markdown") { + return false; + } const tokens = flagTokens(argv); if (tokens.includes("--no-color")) { return false; @@ -104,11 +110,14 @@ function resolveTarget( * 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. */ + * command run and is left alone. The format-selection flags do not + * count: `cli project --format markdown` asks for the group's help in + * Markdown. */ export function bareGroupInvocation( root: CommandTreeNode, - argv: readonly string[], + rawArgv: readonly string[], ): boolean { + const argv = withoutFormatFlags(rawArgv); const segments = helpPath(argv); if (segments.length !== argv.length) { return false; @@ -124,36 +133,208 @@ interface HelpWriter { write(text: string): void; } +export interface HelpRow { + readonly name: string; + readonly brief: string; + /** Allowed values, `required`, a default, `(optional)`: appended + * after the brief, exactly as the source spelled it. */ + readonly suffix?: string; +} + +export interface HelpStep { + readonly run: string; + readonly brief: string; +} + +/** Everything a help card says, with no rendering decided: the two + * renderers draw the same card. */ +export interface HelpCard { + readonly kind: "root" | "group" | "leaf"; + /** `prisma-test project link` */ + readonly name: string; + /** The root tagline, the group brief, or the leaf summary. */ + readonly tagline: string | undefined; + /** Leaf only. */ + readonly usage: string | undefined; + readonly description: string | undefined; + readonly commands: readonly HelpRow[]; + readonly workflow: readonly HelpStep[]; + readonly arguments: readonly HelpRow[]; + readonly options: readonly HelpRow[]; + /** Root only. */ + readonly globalOptions: readonly HelpRow[]; + /** The leaf's "Global options also apply" line, or the group's + * "Run '… --help'" line. */ + readonly note: string | undefined; + readonly examples: readonly string[]; + readonly docsUrl: string | undefined; +} + export function renderHelp( spec: EngineSpec, root: CommandTreeNode, argv: readonly string[], - colorEnabled: boolean, + options: { readonly format: Format; readonly colorEnabled: boolean }, out: HelpWriter, ): void { - const paint = makePaint(colorEnabled); + const card = helpCard(spec, root, argv); + if (options.format === "markdown") { + out.write(renderHelpMarkdown(card)); + return; + } + out.write(renderHelpTerminal(card, makePaint(options.colorEnabled))); +} + +export function helpCard( + spec: EngineSpec, + root: CommandTreeNode, + argv: readonly string[], +): HelpCard { const { target, path } = resolveTarget(root, helpPath(argv)); + return target.kind === "leaf" + ? leafCard(spec, target.entry, path) + : nodeCard(spec, target.node, path); +} + +function nodeCard( + spec: EngineSpec, + node: CommandTreeNode, + path: readonly string[], +): HelpCard { + const atRoot = path.length === 0; + const groupPath = path.join(" "); + const group = spec.groups[groupPath]; + return { + kind: atRoot ? "root" : "group", + name: [spec.name, ...path].join(" "), + tagline: atRoot ? spec.help?.tagline : group?.brief, + usage: undefined, + description: atRoot ? spec.help?.description : group?.description, + commands: nodeRows(spec, node, path), + workflow: resolvedSteps( + (atRoot ? spec.help?.workflow : group?.workflow) ?? [], + spec.name, + ), + arguments: [], + options: [], + globalOptions: atRoot ? sharedFlagRows() : [], + note: atRoot + ? undefined + : `Run '${spec.name} ${groupPath} --help' for details on a command.`, + examples: resolvedExamples(atRoot ? spec.help?.examples : [], spec.name), + docsUrl: atRoot ? spec.help?.docsUrl : undefined, + }; +} + +function leafCard( + spec: EngineSpec, + entry: CommandTreeEntry, + path: readonly string[], +): HelpCard { + const def = entry.def; + const usage = [ + spec.name, + ...path, + requiredFlagUsage(def), + "[options]", + positionalUsage(def), + ] + .filter((part) => part !== "") + .join(" "); + const sharedNames = Object.keys(SHARED_FLAG_PARAMETERS) + .map((key) => `--${kebabCase(key)}`) + .join(", "); + return { + kind: "leaf", + name: [spec.name, ...path].join(" "), + tagline: def.help.summary, + usage, + description: def.help.description, + commands: [], + workflow: [], + arguments: Object.values>(def.args.positionals) + .map((spec) => positionalRuntime(spec)) + .map((runtime) => ({ + name: runtime.placeholder, + brief: runtime.brief, + suffix: runtime.type === "optionalString" ? "(optional)" : undefined, + })), + options: declaredFlagRows(def), + globalOptions: [], + note: + def.kind === "server-command" + ? undefined + : `Global options also apply: ${sharedNames}. Run '${spec.name} --help' for details.`, + examples: resolvedExamples(def.help.examples, spec.name), + docsUrl: entry.docsBaseUrl, + }; +} + +function resolvedSteps( + steps: readonly WorkflowStep[], + cliName: string, +): readonly HelpStep[] { + return steps.map((step) => ({ + run: resolveExample(step.run, cliName), + brief: step.brief, + })); +} + +function resolvedExamples( + examples: readonly string[] | undefined, + cliName: string, +): readonly string[] { + return (examples ?? []).map((example) => resolveExample(example, cliName)); +} + +function renderHelpTerminal(card: HelpCard, paint: Paint): string { const lines: string[] = []; - if (target.kind === "leaf") { - renderLeafHelp(spec, target.entry, path, paint, lines); + lines.push(header(card, paint)); + lines.push(""); + if (card.kind === "leaf") { + lines.push(sectionLabel(paint, "Usage")); + lines.push( + rail( + paint, + `${GAP}${paint("muted", "$")} ${paint("emphasis", card.usage ?? "")}`, + ), + ); } else { - renderNodeHelp(spec, target.node, path, paint, lines); + railRows(card.commands, paint, lines); + } + if (card.description !== undefined) { + lines.push(rail(paint)); + proseLines(card.description, paint, lines); + } + workflowLines(card.workflow, paint, lines); + rowSection("Arguments", card.arguments, paint, lines); + rowSection("Options", card.options, paint, lines); + if (card.kind === "root") { + lines.push(rail(paint)); + lines.push(sectionLabel(paint, "Global options")); + railRows(card.globalOptions, paint, lines); + } + if (card.note !== undefined) { + lines.push(rail(paint)); + if (card.kind === "leaf") { + proseLines(card.note, paint, lines, "muted"); + } else { + lines.push(rail(paint, paint("muted", card.note))); + } } - out.write(`${lines.join("\n")}\n`); + exampleLines(card.examples, paint, lines); + docsLine(card.docsUrl, paint, lines); + lines.push(""); + return `${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 === "") { +function header(card: HelpCard, paint: Paint): string { + const name = paint("emphasis", card.name); + if (card.tagline === undefined || card.tagline === "") { return name; } - return `${name} ${paint("muted", `→ ${tagline}`)}`; + return `${name} ${paint("muted", `→ ${card.tagline}`)}`; } function rail(paint: Paint, rest = ""): string { @@ -166,9 +347,23 @@ function sectionLabel(paint: Paint, label: string): string { return rail(paint, paint("muted", label)); } +function rowSection( + label: string, + rows: readonly HelpRow[], + paint: Paint, + lines: string[], +): void { + if (rows.length === 0) { + return; + } + lines.push(rail(paint)); + lines.push(sectionLabel(paint, label)); + railRows(rows, paint, lines); +} + /** Two-column rows under the rail: name in the accent, brief plain. */ function railRows( - rows: ReadonlyArray<{ name: string; brief: string; suffix?: string }>, + rows: readonly HelpRow[], paint: Paint, lines: string[], ): void { @@ -224,7 +419,6 @@ function proseLines( function exampleLines( examples: readonly string[], - cliName: string, paint: Paint, lines: string[], ): void { @@ -234,32 +428,22 @@ function exampleLines( lines.push(rail(paint)); lines.push(sectionLabel(paint, "Examples")); for (const example of examples) { - lines.push( - rail( - paint, - `${GAP}${paint("muted", "$")} ${resolveExample(example, cliName)}`, - ), - ); + lines.push(rail(paint, `${GAP}${paint("muted", "$")} ${example}`)); } } /** The group's common path: `$`-prefixed copy-pastable steps in mount * order, purpose column muted, aligned like every other row block. */ function workflowLines( - workflow: readonly WorkflowStep[] | undefined, - cliName: string, + steps: readonly HelpStep[], paint: Paint, lines: string[], ): void { - if (workflow === undefined || workflow.length === 0) { + if (steps.length === 0) { return; } lines.push(rail(paint)); lines.push(sectionLabel(paint, "Workflow")); - const steps = workflow.map((step) => ({ - run: resolveExample(step.run, cliName), - brief: step.brief, - })); const width = Math.max(...steps.map((step) => textWidth(step.run))); for (const step of steps) { const pad = " ".repeat(width - textWidth(step.run)); @@ -307,11 +491,7 @@ function flagLabel( return `${alias} --${kebab}${negated}${placeholder}${repeat}`; } -function sharedFlagRows(): ReadonlyArray<{ - name: string; - brief: string; - suffix?: string; -}> { +function sharedFlagRows(): readonly HelpRow[] { const aliasByKey = new Map( Object.entries(SHARED_ALIASES).map(([alias, key]) => [key, alias]), ); @@ -337,9 +517,7 @@ function sharedFlagRows(): ReadonlyArray<{ ]; } -function declaredFlagRows( - def: AnyCommand, -): ReadonlyArray<{ name: string; brief: string; suffix?: string }> { +function declaredFlagRows(def: AnyCommand): readonly HelpRow[] { return Object.entries(def.args.flags).map(([key, spec]) => { const runtime: FlagRuntimeSpec = flagRuntime(spec); return { @@ -404,11 +582,11 @@ function nodeRows( spec: EngineSpec, node: CommandTreeNode, path: readonly string[], -): Array<{ name: string; brief: string }> { +): HelpRow[] { const groupPath = path.join(" "); const depth = path.length; const seen = new Set(); - const rows: Array<{ name: string; brief: string }> = []; + const rows: HelpRow[] = []; for (const mounted of Object.keys(spec.commands)) { const segments = mounted.split(" "); if ( @@ -436,131 +614,9 @@ function nodeRows( 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); - } - - const workflow = atRoot - ? spec.help?.workflow - : spec.groups[groupPath]?.workflow; - workflowLines(workflow, spec.name, 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); - } - 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/markdown.ts b/packages/cli-engine/src/execution/markdown.ts new file mode 100644 index 00000000..edb7c96c --- /dev/null +++ b/packages/cli-engine/src/execution/markdown.ts @@ -0,0 +1,359 @@ +import type { ErroredEnvelope } from "../commands"; +import type { EngineEvent } from "../events"; +import type { Block, PresentedResult, Text, TreeNode } from "../presentation"; +import type { Diagnostic, NextAction } from "../protocol"; +import type { Invocation } from "./engine"; +import type { HelpCard, HelpRow } from "./help"; +import { plainText } from "./palette"; +import { + commentaryLine, + MASK, + PLACEHOLDER, + sentenceCase, + withDocsUrl, +} from "./rendering"; + +const BACKSLASH = /\\/g; +const PIPE = /\|/g; +const NEWLINE = /\n/g; +const BACKTICK_RUN = /`+/g; + +function longestBacktickRun(text: string): number { + let longest = 0; + for (const run of text.match(BACKTICK_RUN) ?? []) { + longest = Math.max(longest, run.length); + } + return longest; +} + +/** An inline code span whose delimiter is one backtick longer than any + * run inside it, padded when the content starts or ends with one. */ +export function codeSpan(text: string): string { + const delimiter = "`".repeat(longestBacktickRun(text) + 1); + const padded = + text.startsWith("`") || text.endsWith("`") ? ` ${text} ` : text; + return `${delimiter}${padded}${delimiter}`; +} + +/** A fenced block whose fence is one backtick longer than any run in + * its lines, and at least three. */ +export function fenced(lines: readonly string[], language = ""): string[] { + const longest = Math.max(0, ...lines.map(longestBacktickRun)); + const delimiter = "`".repeat(Math.max(3, longest + 1)); + return [`${delimiter}${language}`, ...lines, delimiter]; +} + +function orPlaceholder(text: Text): string { + const plain = plainText(text); + return plain === "" ? PLACEHOLDER : plain; +} + +/** + * Plain Markdown, for a reader that is a model rather than a terminal: + * every value labelled, nothing padded, wrapped, aligned, or coloured. + */ +export function renderBlockMarkdown(block: Block): string[] { + switch (block.kind) { + case "summary": + return [`[${block.status}] ${plainText(block.text)}`]; + case "fields": + return block.rows.map( + (row) => + `${plainText(row.label)}: ${row.sensitive === true ? MASK : orPlaceholder(row.value)}`, + ); + case "table": + return renderTable(block.columns, block.rows); + case "list": + return block.items.map((item) => `- ${plainText(item)}`); + case "tree": + return block.roots.flatMap((root) => renderTreeNode(root, 0)); + case "drawing": + return fenced(block.lines.map(plainText)); + } +} + +function escapeCell(text: string): string { + return text + .replace(BACKSLASH, "\\\\") + .replace(PIPE, "\\|") + .replace(NEWLINE, " "); +} + +function cell(text: Text): string { + return escapeCell(orPlaceholder(text)); +} + +function pipeRow(cells: readonly string[]): string { + return `| ${cells.join(" | ")} |`; +} + +function renderTable( + columns: readonly Text[], + rows: ReadonlyArray, +): string[] { + const lines = [ + pipeRow(columns.map((column) => cell(sentenceCase(column)))), + pipeRow(columns.map(() => "---")), + ...rows.map((row) => pipeRow(row.map(cell))), + ]; + return rows.length === 0 ? [...lines, "(no rows)"] : lines; +} + +function renderTreeNode(node: TreeNode, depth: number): string[] { + const indent = " ".repeat(depth); + const label = plainText(node.label); + const line = + node.status === undefined + ? `${indent}- ${label}` + : `${indent}- [${node.status}] ${label}`; + return [ + line, + ...(node.children ?? []).flatMap((child) => + renderTreeNode(child, depth + 1), + ), + ]; +} + +export function renderNextActionMarkdown(action: NextAction): string[] { + const target = action.command ?? action.url; + if (target === undefined && action.commands !== undefined) { + return [ + `- ${action.label}`, + ...action.commands.map((command) => ` - ${codeSpan(command)}`), + ]; + } + if (target === undefined || target === action.label) { + return [ + action.command !== undefined + ? `- ${codeSpan(action.label)}` + : `- ${action.label}`, + ]; + } + return [ + action.command !== undefined + ? `- ${action.label}: ${codeSpan(target)}` + : `- ${action.label}: ${target}`, + ]; +} + +function whereLine(where: Diagnostic["where"]): string | undefined { + if (where === undefined) { + return undefined; + } + if (where.path !== undefined && where.line !== undefined) { + return `where: ${where.path}:${where.line}`; + } + if (where.path !== undefined) { + return `where: ${where.path}`; + } + if (where.line !== undefined) { + return `where: line ${where.line}`; + } + return undefined; +} + +export function renderDiagnosticMarkdown(diagnostic: Diagnostic): string[] { + const lines = [ + `[${diagnostic.severity}] ${diagnostic.code}: ${diagnostic.summary}`, + ]; + if (diagnostic.why !== undefined) { + lines.push(`why: ${diagnostic.why}`); + } + const where = whereLine(diagnostic.where); + if (where !== undefined) { + lines.push(where); + } + for (const action of diagnostic.nextActions) { + lines.push(...renderNextActionMarkdown(action)); + } + if (diagnostic.docsUrl !== undefined) { + lines.push(`docs: ${diagnostic.docsUrl}`); + } + return lines; +} + +/** Joins sections with one blank line each, dropping empty ones, and + * ends with exactly one newline; nothing when there is nothing. */ +export function joinSections( + sections: ReadonlyArray, +): string { + const kept = sections.filter((section) => section.length > 0); + if (kept.length === 0) { + return ""; + } + return `${kept.map((section) => section.join("\n")).join("\n\n")}\n`; +} + +function diagnosticsSection(diagnostics: readonly Diagnostic[]): string[] { + if (diagnostics.length === 0) { + return []; + } + return [ + "### Diagnostics", + ...diagnostics.flatMap((diagnostic, index) => [ + ...(index === 0 ? [] : [""]), + ...renderDiagnosticMarkdown(diagnostic), + ]), + ]; +} + +/** Everything on stdout: the blocks, then `### Next`, then + * `### Diagnostics`. The `stdout` presentation lines are never + * printed; the table already carries them. */ +export function renderCompletedMarkdown( + invocation: Invocation, + presented: PresentedResult, +): void { + const { runtime, state } = invocation; + const sections: string[][] = + presented.presentation.human.map(renderBlockMarkdown); + if (presented.presentation.next.length > 0) { + sections.push([ + "### Next", + ...presented.presentation.next.flatMap(renderNextActionMarkdown), + ]); + } + sections.push( + diagnosticsSection( + presented.diagnostics.map((diagnostic) => withDocsUrl(state, diagnostic)), + ), + ); + runtime.stdout.write(joinSections(sections)); +} + +/** The error in the diagnostic shape, then the accompanying findings + * under `### Diagnostics`. The envelope's top-level `nextActions` + * duplicate the error's and are not printed again. */ +export function renderErroredMarkdown( + invocation: Invocation, + envelope: ErroredEnvelope, +): void { + invocation.runtime.stdout.write( + joinSections([ + renderDiagnosticMarkdown(envelope.error), + diagnosticsSection(envelope.diagnostics), + ]), + ); +} + +/** Config-section warnings of an OK run, ahead of whatever the run + * prints next, with one blank line between. */ +export function renderWarningsMarkdown( + invocation: Invocation, + diagnostics: readonly Diagnostic[], +): void { + const section = joinSections(diagnostics.map(renderDiagnosticMarkdown)); + if (section !== "") { + invocation.runtime.stdout.write(`${section}\n`); + } +} + +export function renderChildNextActionsMarkdown( + invocation: Invocation, + actions: readonly NextAction[], +): void { + invocation.runtime.stdout.write( + joinSections([actions.flatMap(renderNextActionMarkdown)]), + ); +} + +/** One line per event on stdout as it happens. A step starting, its + * progress, and a remediation are not printed. */ +export function renderEventMarkdown( + invocation: Invocation, + event: EngineEvent, +): void { + const { stdout } = invocation.runtime; + switch (event.kind) { + case "message": + stdout.write(`${event.text}\n`); + return; + case "output": + stdout.write(`${event.line}\n`); + return; + case "step-finished": + stdout.write(`[${event.outcome}] ${event.step}\n`); + return; + case "endpoint": + case "status": + case "artifact": + stdout.write(`${commentaryLine(event)}\n`); + return; + case "step-started": + case "progress": + case "remediation": + return; + } +} + +function parenthesized(suffix: string): string { + return suffix.startsWith("(") ? suffix : `(${suffix})`; +} + +function helpTable( + headers: readonly [string, string], + rows: readonly HelpRow[], +): string[] { + return [ + pipeRow(headers), + pipeRow(["---", "---"]), + ...rows.map((row) => + pipeRow([ + escapeCell(codeSpan(row.name.trimStart())), + escapeCell( + row.suffix === undefined || row.suffix === "" + ? row.brief + : `${row.brief} ${parenthesized(row.suffix)}`, + ), + ]), + ), + ]; +} + +function helpSection( + heading: string, + headers: readonly [string, string], + rows: readonly HelpRow[], +): string[][] { + return rows.length === 0 ? [] : [[`## ${heading}`], helpTable(headers, rows)]; +} + +/** The help card as Markdown: headings, paragraphs, pipe tables, and + * bash fences, one blank line between everything. */ +export function renderHelpMarkdown(card: HelpCard): string { + const sections: string[][] = [[`# ${card.name}`]]; + if (card.tagline !== undefined && card.tagline !== "") { + sections.push([card.tagline]); + } + if (card.usage !== undefined) { + sections.push(["## Usage"], fenced([card.usage], "bash")); + } + if (card.description !== undefined) { + sections.push([card.description]); + } + sections.push( + ...helpSection("Commands", ["Command", "Description"], card.commands), + ...helpSection( + "Workflow", + ["Run", "Purpose"], + card.workflow.map((step) => ({ name: step.run, brief: step.brief })), + ), + ...helpSection("Arguments", ["Argument", "Description"], card.arguments), + ...helpSection("Options", ["Flag", "Description"], card.options), + ...helpSection( + "Global options", + ["Flag", "Description"], + card.globalOptions, + ), + ); + if (card.note !== undefined) { + sections.push([card.note]); + } + if (card.examples.length > 0) { + sections.push(["## Examples"], fenced(card.examples, "bash")); + } + if (card.docsUrl !== undefined) { + sections.push([`Docs: ${card.docsUrl}`]); + } + return joinSections(sections); +} diff --git a/packages/cli-engine/src/execution/needs.ts b/packages/cli-engine/src/execution/needs.ts index 8bfec8a3..b45b5825 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 { renderWarningsMarkdown } from "./markdown"; import { makePaint } from "./palette"; import { withDocsUrl, writeDiagnostic } from "./rendering"; import { SEVERITY_RANK } from "./reporting"; @@ -327,13 +328,20 @@ function writeSectionWarnings( diagnostics: readonly Diagnostic[], ): void { const state = invocation.state; - for (const diagnostic of diagnostics) { - if (SEVERITY_RANK[diagnostic.severity] > SEVERITY_RANK[state.logLevel]) { - continue; - } + const shown = diagnostics + .filter( + (diagnostic) => + SEVERITY_RANK[diagnostic.severity] <= SEVERITY_RANK[state.logLevel], + ) + .map((diagnostic) => withDocsUrl(state, diagnostic)); + if (state.format === "markdown") { + renderWarningsMarkdown(invocation, shown); + return; + } + for (const diagnostic of shown) { writeDiagnostic( invocation.runtime.stderr, - withDocsUrl(state, diagnostic), + diagnostic, makePaint(state.colorEnabled), ); } diff --git a/packages/cli-engine/src/execution/pre-parse-argv.ts b/packages/cli-engine/src/execution/pre-parse-argv.ts index 8b827846..375e13dd 100644 --- a/packages/cli-engine/src/execution/pre-parse-argv.ts +++ b/packages/cli-engine/src/execution/pre-parse-argv.ts @@ -39,23 +39,59 @@ export function configFlagGivenNoValue(argv: readonly string[]): boolean { return flagTokens(argv).includes("--config="); } +const FORMATS: readonly Format[] = ["human", "json", "markdown"]; + +function isFormat(value: string | undefined): value is Format { + return value !== undefined && FORMATS.includes(value as Format); +} + +/** argv with the format-selection tokens removed: `--json`, + * `--format=`, and `--format` with the value after it, only + * when the value is a recognised format. Nothing after a bare `--` is + * a flag, so the scan stops there and keeps the rest. */ +export function withoutFormatFlags(argv: readonly string[]): string[] { + const terminator = argv.indexOf("--"); + const tokens = terminator === -1 ? argv : argv.slice(0, terminator); + const rest = terminator === -1 ? [] : argv.slice(terminator); + const kept: string[] = []; + for (let index = 0; index < tokens.length; index += 1) { + const token = tokens[index]; + if (token === "--json") { + continue; + } + if ( + token.startsWith("--format=") && + isFormat(token.slice("--format=".length)) + ) { + continue; + } + if (token === "--format" && isFormat(tokens[index + 1])) { + index += 1; + continue; + } + kept.push(token); + } + return [...kept, ...rest]; +} + /** The format requested by --json / --format / --format=, if - * any. */ + * any. An explicit `--format` wins over `--json` whichever comes + * first, as applySharedFlags decides it after parsing. */ export function formatFlagGiven(argv: readonly string[]): Format | undefined { const tokens = flagTokens(argv); for (const [index, token] of tokens.entries()) { - if (token === "--json" || token === "--format=json") { - return "json"; - } - if (token === "--format=human") { - return "human"; + if (token.startsWith("--format=")) { + const value = token.slice("--format=".length); + if (isFormat(value)) { + return value; + } } if (token === "--format") { const value = tokens[index + 1]; - if (value === "json" || value === "human") { + if (isFormat(value)) { return value; } } } - return undefined; + return tokens.includes("--json") ? "json" : undefined; } diff --git a/packages/cli-engine/src/execution/rendering.ts b/packages/cli-engine/src/execution/rendering.ts index c93aeda8..730aa81e 100644 --- a/packages/cli-engine/src/execution/rendering.ts +++ b/packages/cli-engine/src/execution/rendering.ts @@ -55,21 +55,28 @@ export function renderEventHuman( case "remediation": return; case "endpoint": - stderr.write(`${event.name}: ${event.url}\n`); - return; case "status": - stderr.write( - `${event.subject}: ${event.from === undefined ? "" : `${event.from} → `}${event.status}\n`, - ); - return; case "artifact": - stderr.write( - `${event.path}${event.description === undefined ? "" : ` — ${event.description}`}\n`, - ); + stderr.write(`${commentaryLine(event)}\n`); return; } } +/** The one line an endpoint, status, or artifact event prints, in + * every text format. */ +export function commentaryLine( + event: Extract, +): string { + switch (event.kind) { + case "endpoint": + return `${event.name}: ${event.url}`; + case "status": + return `${event.subject}: ${event.from === undefined ? "" : `${event.from} → `}${event.status}`; + case "artifact": + return `${event.path}${event.description === undefined ? "" : ` — ${event.description}`}`; + } +} + /** * The four Status glyphs. The step-outcome map above and the * diagnostic-severity map below answer different questions, but the @@ -83,7 +90,7 @@ const STATUS_SYMBOL: Readonly> = { info: "ℹ", }; -const MASK = "********"; +export const MASK = "********"; const COLUMN_GAP = " "; const RAIL = "│"; const BRANCH = "├─"; @@ -200,14 +207,14 @@ function writeFields( */ /** 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 { +export function sentenceCase(text: Text): Text { if (typeof text !== "string" || text === "") { return text; } return `${text[0].toUpperCase()}${text.slice(1)}`; } -const PLACEHOLDER = "—"; +export const PLACEHOLDER = "—"; /** An absent value renders as a dim em dash rather than invented prose * ("none", "n/a") in data tone. */ diff --git a/packages/cli-engine/src/execution/reporting.ts b/packages/cli-engine/src/execution/reporting.ts index da68d706..0d957349 100644 --- a/packages/cli-engine/src/execution/reporting.ts +++ b/packages/cli-engine/src/execution/reporting.ts @@ -1,5 +1,6 @@ import type { EngineEvent, Severity, StreamEvent } from "../events"; import type { Invocation } from "./engine"; +import { renderEventMarkdown } from "./markdown"; import { renderEventHuman } from "./rendering"; import type { DelegatedTerminal } from "./spawn"; @@ -73,6 +74,10 @@ export function reportEvent(invocation: Invocation, event: EngineEvent): void { }); return; } + if (state.format === "markdown") { + renderEventMarkdown(invocation, event); + return; + } renderEventHuman(invocation, event); } diff --git a/packages/cli-engine/src/execution/settlement.ts b/packages/cli-engine/src/execution/settlement.ts index 76804f8e..2ae47a6f 100644 --- a/packages/cli-engine/src/execution/settlement.ts +++ b/packages/cli-engine/src/execution/settlement.ts @@ -13,6 +13,11 @@ import { } from "../protocol"; import { type ChildStatusSettlement, childExitCode } from "../spawn"; import type { EngineSpec, Invocation } from "./engine"; +import { + renderChildNextActionsMarkdown, + renderCompletedMarkdown, + renderErroredMarkdown, +} from "./markdown"; import { makePaint } from "./palette"; import { diagnosticSection, @@ -88,6 +93,10 @@ export function settleCompleted( }); return; } + if (state.format === "markdown") { + renderCompletedMarkdown(invocation, presented); + return; + } renderCompletedHuman(invocation, presented); } @@ -240,15 +249,25 @@ export function settleChildStatus( return; } if (child.signal === null) { - for (const action of settlement.nextActions) { - invocation.runtime.stderr.write( - `${renderNextAction(action, makePaint(invocation.state.colorEnabled))}\n`, - ); - } + renderChildNextActions(invocation, settlement.nextActions); } settleVerbatimExitCode(invocation, exitCode); } +function renderChildNextActions( + invocation: Invocation, + actions: readonly NextAction[], +): void { + if (invocation.state.format === "markdown") { + renderChildNextActionsMarkdown(invocation, actions); + return; + } + const paint = makePaint(invocation.state.colorEnabled); + for (const action of actions) { + invocation.runtime.stderr.write(`${renderNextAction(action, paint)}\n`); + } +} + function settleStructuredChildStatus( invocation: Invocation, settlement: ChildStatusSettlement, @@ -361,6 +380,10 @@ export function emitErrored( }); return; } + if (state.format === "markdown") { + renderErroredMarkdown(invocation, envelope); + return; + } const paint = makePaint(invocation.state.colorEnabled); writeSections( [envelope.error, ...envelope.diagnostics].map((diagnostic) => @@ -377,7 +400,7 @@ export function settleVersion( invocation: Invocation, ): number { const { runtime, state } = invocation; - if (state.format === "human") { + if (state.format !== "json") { runtime.stdout.write(`${spec.version}\n`); return 0; } diff --git a/packages/cli-engine/src/execution/shared-flags.ts b/packages/cli-engine/src/execution/shared-flags.ts index 4f041026..097dc036 100644 --- a/packages/cli-engine/src/execution/shared-flags.ts +++ b/packages/cli-engine/src/execution/shared-flags.ts @@ -34,7 +34,7 @@ export const RESERVED_ALIASES: ReadonlySet = new Set([ export const SHARED_FLAG_PARAMETERS = { format: { kind: "enum", - values: ["human", "json"], + values: ["human", "json", "markdown"], optional: true, brief: "Output format", }, @@ -154,7 +154,8 @@ export function applySharedFlags( state.confirmValues = [...(shared.confirm ?? [])]; state.interactive = shared.interactive ?? defaultInteractive(runtime); state.logLevel = resolveLogLevel(shared); - state.colorEnabled = resolveColorEnabled(shared, runtime); + state.colorEnabled = + state.format === "markdown" ? false : resolveColorEnabled(shared, runtime); state.configPath = shared.config; } diff --git a/packages/cli-engine/src/presentation.ts b/packages/cli-engine/src/presentation.ts index 8c563d94..fc743f14 100644 --- a/packages/cli-engine/src/presentation.ts +++ b/packages/cli-engine/src/presentation.ts @@ -1,6 +1,6 @@ import type { Diagnostic, NextAction } from "./protocol"; -export type Format = "human" | "json"; +export type Format = "human" | "json" | "markdown"; /** * What a command concluded, stated at the return site. `exitCode` is diff --git a/packages/cli-engine/tests/fixtures/help-cards.ts b/packages/cli-engine/tests/fixtures/help-cards.ts new file mode 100644 index 00000000..5861ed34 --- /dev/null +++ b/packages/cli-engine/tests/fixtures/help-cards.ts @@ -0,0 +1,120 @@ +/** + * Three help cards that together touch every element a card can carry: + * a root with tagline, description, workflow, examples, and docs; a + * group with a brief, description, and workflow; a leaf with every + * positional and flag kind, examples, and a family docs base URL. + */ +import { + defineCommand, + defineCommandFamily, + flag, + positional, +} from "@prisma/cli-engine"; +import { ok } from "@prisma/cli-engine/protocol"; +import { createTestCli } from "@prisma/cli-engine/testing"; + +const presentNothing = { + human: () => [], + stdout: () => [], + json: () => null, + next: () => [], +}; + +const link = defineCommand({ + help: { + summary: "Link this directory to a project", + description: + "Writes the project id into prisma.config.ts so later commands know which project you mean.\n\nRun it once per checkout. Re-running replaces the link.", + examples: [ + "project link", + '{bin} project link "Acme Dashboard" --region eu', + ], + }, + args: { + positionals: { + idOrName: positional.optionalString({ + brief: "The project id or its display name", + placeholder: "id-or-name", + }), + tags: positional.variadic({ + brief: "Tags to record on the link", + placeholder: "tag", + }), + }, + flags: { + region: flag.string({ + brief: "Region to prefer", + placeholder: "region", + alias: "r", + default: "us", + }), + workspace: flag.requiredString({ + brief: "Workspace the project lives in", + placeholder: "workspace-id", + }), + force: flag.boolean({ brief: "Overwrite an existing link", alias: "f" }), + confirmLink: flag.optionalBoolean({ + brief: "Confirm or skip the link prompt", + }), + mode: flag.enum({ + brief: "How to link", + values: ["copy", "reference"], + default: "copy", + }), + label: flag.repeated({ + brief: "Labels, a|b style", + placeholder: "label", + }), + retries: flag.number({ brief: "How many attempts" }), + }, + }, + handler: async (_args, ctx) => + ok(ctx.present({ data: null }, presentNothing)), +}); + +const list = defineCommand({ + help: { summary: "List projects in the workspace" }, + handler: async (_args, ctx) => + ok(ctx.present({ data: null }, presentNothing)), +}); + +const whoami = defineCommand({ + help: { summary: "Show who is signed in" }, + handler: async (_args, ctx) => + ok(ctx.present({ data: null }, presentNothing)), +}); + +const family = defineCommandFamily({ + commands: { link, list }, + docsBaseUrl: "https://pris.ly/cli/errors", +}); + +export function helpCardsCli() { + return createTestCli({ + commandFamilies: [family], + commands: { "project link": link, "project list": list, whoami }, + groups: { + project: { + brief: "Manage projects", + description: + "A project is a named container for databases and their settings.", + workflow: [ + { run: "project link", brief: "Pick the project this checkout uses" }, + { run: "{bin} project list | head", brief: "See what else exists" }, + ], + }, + }, + help: { + tagline: "The Prisma test CLI", + description: + "Works against a workspace you are signed in to.\n\nEvery command prints Markdown with --format markdown.", + workflow: [ + { run: "whoami", brief: "Check the session" }, + { run: "project link", brief: "Link a project" }, + ], + examples: ["whoami", "{bin} project list --json"], + docsUrl: "https://pris.ly/cli", + }, + now: () => new Date(0), + }); +} diff --git a/packages/cli-engine/tests/help-markdown.test.ts b/packages/cli-engine/tests/help-markdown.test.ts new file mode 100644 index 00000000..f569a938 --- /dev/null +++ b/packages/cli-engine/tests/help-markdown.test.ts @@ -0,0 +1,157 @@ +/** + * Help under `--format markdown`, byte for byte, per the slice spec's + * help shape (.drive/projects/prisma-cli-v8/specs/markdown-format.md): + * everything on stdout, stderr empty, colour off. + */ +import { describe, expect, test } from "vitest"; +import { helpCardsCli } from "./fixtures/help-cards"; + +describe("markdown help", () => { + test("root card via --help", async () => { + const result = await helpCardsCli().run( + ["--help", "--format", "markdown"], + { + isTty: { stdout: true, stderr: true }, + }, + ); + + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(""); + expect(result.stdout).toBe( + "# prisma-test\n\nThe Prisma test CLI\n\nWorks against a workspace you are signed in to.\n\nEvery command prints Markdown with --format markdown.\n\n## Commands\n\n| Command | Description |\n| --- | --- |\n| `project` | Manage projects |\n| `whoami` | Show who is signed in |\n\n## Workflow\n\n| Run | Purpose |\n| --- | --- |\n| `prisma-test whoami` | Check the session |\n| `prisma-test project link` | Link a project |\n\n## Global options\n\n| Flag | Description |\n| --- | --- |\n| `--format` | Output format (human\\|json\\|markdown) |\n| `--json` | Shorthand for --format json |\n| `--log-level` | Commentary verbosity (error\\|warn\\|info\\|verbose) |\n| `-v, --verbose` | Shorthand for --log-level verbose |\n| `-q, --quiet` | Shorthand for --log-level error |\n| `-y, --yes` | Accept prompt defaults without asking |\n| `--confirm ...` | Grant a consent prompt non-interactively by typing its token (repeatable) |\n| `--interactive/--no-interactive` | Force interactive prompts on or off |\n| `--color/--no-color` | Force ANSI color on or off |\n| `--config ` | Read this config file instead of ./prisma.config.ts |\n| `-h, --help` | Print help for a command |\n| `--version` | Print the CLI version and exit |\n\n## Examples\n\n```bash\nprisma-test whoami\nprisma-test project list --json\n```\n\nDocs: https://pris.ly/cli\n", + ); + }); + + test("root card via no argv", async () => { + const result = await helpCardsCli().run(["--format", "markdown"], { + isTty: { stdout: true, stderr: true }, + }); + + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(""); + expect(result.stdout).toBe( + "# prisma-test\n\nThe Prisma test CLI\n\nWorks against a workspace you are signed in to.\n\nEvery command prints Markdown with --format markdown.\n\n## Commands\n\n| Command | Description |\n| --- | --- |\n| `project` | Manage projects |\n| `whoami` | Show who is signed in |\n\n## Workflow\n\n| Run | Purpose |\n| --- | --- |\n| `prisma-test whoami` | Check the session |\n| `prisma-test project link` | Link a project |\n\n## Global options\n\n| Flag | Description |\n| --- | --- |\n| `--format` | Output format (human\\|json\\|markdown) |\n| `--json` | Shorthand for --format json |\n| `--log-level` | Commentary verbosity (error\\|warn\\|info\\|verbose) |\n| `-v, --verbose` | Shorthand for --log-level verbose |\n| `-q, --quiet` | Shorthand for --log-level error |\n| `-y, --yes` | Accept prompt defaults without asking |\n| `--confirm ...` | Grant a consent prompt non-interactively by typing its token (repeatable) |\n| `--interactive/--no-interactive` | Force interactive prompts on or off |\n| `--color/--no-color` | Force ANSI color on or off |\n| `--config ` | Read this config file instead of ./prisma.config.ts |\n| `-h, --help` | Print help for a command |\n| `--version` | Print the CLI version and exit |\n\n## Examples\n\n```bash\nprisma-test whoami\nprisma-test project list --json\n```\n\nDocs: https://pris.ly/cli\n", + ); + }); + + test("root card via --help-all, --color ignored", async () => { + const result = await helpCardsCli().run( + ["--help-all", "--format=markdown", "--color"], + { + isTty: { stdout: true, stderr: true }, + }, + ); + + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(""); + expect(result.stdout).toBe( + "# prisma-test\n\nThe Prisma test CLI\n\nWorks against a workspace you are signed in to.\n\nEvery command prints Markdown with --format markdown.\n\n## Commands\n\n| Command | Description |\n| --- | --- |\n| `project` | Manage projects |\n| `whoami` | Show who is signed in |\n\n## Workflow\n\n| Run | Purpose |\n| --- | --- |\n| `prisma-test whoami` | Check the session |\n| `prisma-test project link` | Link a project |\n\n## Global options\n\n| Flag | Description |\n| --- | --- |\n| `--format` | Output format (human\\|json\\|markdown) |\n| `--json` | Shorthand for --format json |\n| `--log-level` | Commentary verbosity (error\\|warn\\|info\\|verbose) |\n| `-v, --verbose` | Shorthand for --log-level verbose |\n| `-q, --quiet` | Shorthand for --log-level error |\n| `-y, --yes` | Accept prompt defaults without asking |\n| `--confirm ...` | Grant a consent prompt non-interactively by typing its token (repeatable) |\n| `--interactive/--no-interactive` | Force interactive prompts on or off |\n| `--color/--no-color` | Force ANSI color on or off |\n| `--config ` | Read this config file instead of ./prisma.config.ts |\n| `-h, --help` | Print help for a command |\n| `--version` | Print the CLI version and exit |\n\n## Examples\n\n```bash\nprisma-test whoami\nprisma-test project list --json\n```\n\nDocs: https://pris.ly/cli\n", + ); + }); + + test("group card via --help", async () => { + const result = await helpCardsCli().run( + ["project", "--help", "--format", "markdown"], + { + isTty: { stdout: true, stderr: true }, + }, + ); + + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(""); + expect(result.stdout).toBe( + "# prisma-test project\n\nManage projects\n\nA project is a named container for databases and their settings.\n\n## Commands\n\n| Command | Description |\n| --- | --- |\n| `link [id-or-name] [tag...]` | Link this directory to a project |\n| `list` | List projects in the workspace |\n\n## Workflow\n\n| Run | Purpose |\n| --- | --- |\n| `prisma-test project link` | Pick the project this checkout uses |\n| `prisma-test project list \\| head` | See what else exists |\n\nRun 'prisma-test project --help' for details on a command.\n", + ); + }); + + test("group card via a bare group invocation", async () => { + const result = await helpCardsCli().run( + ["project", "--format", "markdown"], + { + isTty: { stdout: true, stderr: true }, + }, + ); + + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(""); + expect(result.stdout).toBe( + "# prisma-test project\n\nManage projects\n\nA project is a named container for databases and their settings.\n\n## Commands\n\n| Command | Description |\n| --- | --- |\n| `link [id-or-name] [tag...]` | Link this directory to a project |\n| `list` | List projects in the workspace |\n\n## Workflow\n\n| Run | Purpose |\n| --- | --- |\n| `prisma-test project link` | Pick the project this checkout uses |\n| `prisma-test project list \\| head` | See what else exists |\n\nRun 'prisma-test project --help' for details on a command.\n", + ); + }); + + test("group card via a bare group invocation with --format=markdown", async () => { + const result = await helpCardsCli().run(["project", "--format=markdown"], { + isTty: { stdout: true, stderr: true }, + }); + + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(""); + expect(result.stdout).toBe( + "# prisma-test project\n\nManage projects\n\nA project is a named container for databases and their settings.\n\n## Commands\n\n| Command | Description |\n| --- | --- |\n| `link [id-or-name] [tag...]` | Link this directory to a project |\n| `list` | List projects in the workspace |\n\n## Workflow\n\n| Run | Purpose |\n| --- | --- |\n| `prisma-test project link` | Pick the project this checkout uses |\n| `prisma-test project list \\| head` | See what else exists |\n\nRun 'prisma-test project --help' for details on a command.\n", + ); + }); + + test("leaf card via --help", async () => { + const result = await helpCardsCli().run( + ["project", "link", "--help", "--format", "markdown"], + { + isTty: { stdout: true, stderr: true }, + }, + ); + + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(""); + expect(result.stdout).toBe( + "# prisma-test project link\n\nLink this directory to a project\n\n## Usage\n\n```bash\nprisma-test project link --workspace [options] [id-or-name] [tag...]\n```\n\nWrites the project id into prisma.config.ts so later commands know which project you mean.\n\nRun it once per checkout. Re-running replaces the link.\n\n## Arguments\n\n| Argument | Description |\n| --- | --- |\n| `id-or-name` | The project id or its display name (optional) |\n| `tag` | Tags to record on the link |\n\n## Options\n\n| Flag | Description |\n| --- | --- |\n| `-r, --region ` | Region to prefer (default: us) |\n| `--workspace ` | Workspace the project lives in (required) |\n| `-f, --force` | Overwrite an existing link |\n| `--confirm-link/--no-confirm-link` | Confirm or skip the link prompt |\n| `--mode ` | How to link (copy\\|reference; default: copy) |\n| `--label