From 6df0b69cd628c3af286618f46a7ed1f9ec58affb Mon Sep 17 00:00:00 2001 From: willbot Date: Sat, 12 Sep 2026 10:05:34 +0200 Subject: [PATCH 1/7] feat(engine): `--format markdown` renders a completed run Adds a third output format. Under markdown the engine calls the same `human(ui)` thunk once, never calls the `stdout` thunk, renders every block as plain Markdown on stdout, and writes nothing to stderr. Colour is off even with `--color`, `Ui.width` is unbounded, and every `=== "human"` check in `engine.ts` and `settleVersion` now sends markdown down the human path. The renderer lives in `execution/markdown.ts` beside `rendering.ts`; `markdown.test.ts` pins every block kind, next-action bullet, the diagnostic shape, the sections, and the blank-line rule byte-for-byte. Errored runs, `--version`, child status, live events, help, docs, and the engine version bump follow in later dispatches. Co-Authored-By: Claude Fable 5.1 Signed-off-by: willbot Signed-off-by: Will Madden --- .../src/execution/command-context.ts | 25 +- packages/cli-engine/src/execution/engine.ts | 6 +- packages/cli-engine/src/execution/help.ts | 4 + packages/cli-engine/src/execution/markdown.ts | 177 +++++++ .../src/execution/pre-parse-argv.ts | 17 +- .../cli-engine/src/execution/rendering.ts | 6 +- .../cli-engine/src/execution/settlement.ts | 7 +- .../cli-engine/src/execution/shared-flags.ts | 5 +- packages/cli-engine/src/presentation.ts | 2 +- packages/cli-engine/tests/markdown.test.ts | 501 ++++++++++++++++++ 10 files changed, 734 insertions(+), 16 deletions(-) create mode 100644 packages/cli-engine/src/execution/markdown.ts create mode 100644 packages/cli-engine/tests/markdown.test.ts 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..293482da 100644 --- a/packages/cli-engine/src/execution/engine.ts +++ b/packages/cli-engine/src/execution/engine.ts @@ -386,7 +386,7 @@ 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, @@ -394,7 +394,7 @@ export class EngineImpl implements Engine { preParseColorEnabled( argv, runtime, - format === "human" ? "stdout" : "stderr", + format === "json" ? "stderr" : "stdout", ), stream, ); @@ -405,7 +405,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..fd7e5760 100644 --- a/packages/cli-engine/src/execution/help.ts +++ b/packages/cli-engine/src/execution/help.ts @@ -16,6 +16,7 @@ import type { AnyCommand, WorkflowStep } from "../commands"; import type { CommandTreeEntry, CommandTreeNode } from "./command-tree"; import type { EngineSpec } from "./engine"; import { makePaint, type Paint, textWidth } from "./palette"; +import { formatFlagGiven } from "./pre-parse-argv"; import { SHARED_ALIASES, SHARED_FLAG_PARAMETERS } from "./shared-flags"; import { resolveExample } from "./stricli-adapter"; @@ -47,6 +48,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; diff --git a/packages/cli-engine/src/execution/markdown.ts b/packages/cli-engine/src/execution/markdown.ts new file mode 100644 index 00000000..de58614f --- /dev/null +++ b/packages/cli-engine/src/execution/markdown.ts @@ -0,0 +1,177 @@ +import type { Block, PresentedResult, Text, TreeNode } from "../presentation"; +import type { Diagnostic, NextAction } from "../protocol"; +import type { Invocation } from "./engine"; +import { plainText } from "./palette"; +import { MASK, PLACEHOLDER, sentenceCase, withDocsUrl } from "./rendering"; + +const FENCE = "```"; +const LONG_FENCE = "````"; +const PIPE = /\|/g; +const NEWLINE = /\n/g; + +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": { + const lines = block.lines.map(plainText); + const fence = lines.some((line) => line.includes(FENCE)) + ? LONG_FENCE + : FENCE; + return [fence, ...lines, fence]; + } + } +} + +function cell(text: Text): string { + return orPlaceholder(text).replace(PIPE, "\\|").replace(NEWLINE, " "); +} + +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) => ` - \`${command}\``), + ]; + } + if (target === undefined || target === action.label) { + return [ + action.command !== undefined + ? `- \`${action.label}\`` + : `- ${action.label}`, + ]; + } + return [ + action.command !== undefined + ? `- ${action.label}: \`${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`; +} + +/** 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), + ]); + } + if (presented.diagnostics.length > 0) { + sections.push([ + "### Diagnostics", + ...presented.diagnostics.flatMap((diagnostic, index) => [ + ...(index === 0 ? [] : [""]), + ...renderDiagnosticMarkdown(withDocsUrl(state, diagnostic)), + ]), + ]); + } + runtime.stdout.write(joinSections(sections)); +} diff --git a/packages/cli-engine/src/execution/pre-parse-argv.ts b/packages/cli-engine/src/execution/pre-parse-argv.ts index 8b827846..ccb7183d 100644 --- a/packages/cli-engine/src/execution/pre-parse-argv.ts +++ b/packages/cli-engine/src/execution/pre-parse-argv.ts @@ -39,20 +39,29 @@ 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); +} + /** The format requested by --json / --format / --format=, if * any. */ 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") { + if (token === "--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; } } diff --git a/packages/cli-engine/src/execution/rendering.ts b/packages/cli-engine/src/execution/rendering.ts index c93aeda8..8acf56e6 100644 --- a/packages/cli-engine/src/execution/rendering.ts +++ b/packages/cli-engine/src/execution/rendering.ts @@ -83,7 +83,7 @@ const STATUS_SYMBOL: Readonly> = { info: "ℹ", }; -const MASK = "********"; +export const MASK = "********"; const COLUMN_GAP = " "; const RAIL = "│"; const BRANCH = "├─"; @@ -200,14 +200,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/settlement.ts b/packages/cli-engine/src/execution/settlement.ts index 76804f8e..cd67b83d 100644 --- a/packages/cli-engine/src/execution/settlement.ts +++ b/packages/cli-engine/src/execution/settlement.ts @@ -13,6 +13,7 @@ import { } from "../protocol"; import { type ChildStatusSettlement, childExitCode } from "../spawn"; import type { EngineSpec, Invocation } from "./engine"; +import { renderCompletedMarkdown } from "./markdown"; import { makePaint } from "./palette"; import { diagnosticSection, @@ -88,6 +89,10 @@ export function settleCompleted( }); return; } + if (state.format === "markdown") { + renderCompletedMarkdown(invocation, presented); + return; + } renderCompletedHuman(invocation, presented); } @@ -377,7 +382,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/markdown.test.ts b/packages/cli-engine/tests/markdown.test.ts new file mode 100644 index 00000000..7fd92fc7 --- /dev/null +++ b/packages/cli-engine/tests/markdown.test.ts @@ -0,0 +1,501 @@ +/** + * `--format markdown`: the same blocks a command describes for human, + * rendered as plain Markdown on stdout for a reader that is a model. + * Every byte here is pinned by the slice spec + * (.drive/projects/prisma-cli-v8/specs/markdown-format.md). + */ +import { + type Block, + defineCommand, + defineCommandFamily, + type Ui, +} from "@prisma/cli-engine"; +import { + type Diagnostic, + type NextAction, + ok, +} from "@prisma/cli-engine/protocol"; +import { createTestCli } from "@prisma/cli-engine/testing"; +import { describe, expect, test } from "vitest"; + +interface Fixture { + readonly blocks?: readonly Block[] | ((ui: Ui) => readonly Block[]); + readonly next?: readonly NextAction[]; + readonly diagnostics?: readonly Diagnostic[]; + readonly stdout?: readonly string[]; +} + +function show(fixture: Fixture, calls?: { human: number; stdout: number }) { + return defineCommand({ + help: { summary: "Render the fixture" }, + handler: async (_args, ctx) => + ok( + ctx.present( + { data: null, diagnostics: fixture.diagnostics }, + { + human: (ui) => { + if (calls !== undefined) { + calls.human += 1; + } + const blocks = fixture.blocks ?? []; + return typeof blocks === "function" ? blocks(ui) : blocks; + }, + stdout: () => { + if (calls !== undefined) { + calls.stdout += 1; + } + return fixture.stdout ?? ["raw data line"]; + }, + json: () => null, + next: () => fixture.next ?? [], + }, + ), + ), + }); +} + +async function run(fixture: Fixture, argv: readonly string[] = []) { + return createTestCli({ commands: { show: show(fixture) } }).run( + ["show", "--format", "markdown", ...argv], + { isTty: { stdout: true, stderr: true }, columns: { stderr: 40 } }, + ); +} + +async function render(blocks: readonly Block[]): Promise { + return (await run({ blocks })).stdout; +} + +describe("block kinds", () => { + test("summary is `[status] text`, tone ignored", async () => { + expect( + await render([ + { kind: "summary", status: "ok", tone: "warn", text: "Signed in" }, + ]), + ).toBe("[ok] Signed in\n"); + }); + + test("fields are `label: value`; empty is the dash, sensitive is the mask, multi-line is verbatim, rail is ignored", async () => { + expect( + await render([ + { + kind: "fields", + rail: true, + rows: [ + { label: "status", value: "signed in" }, + { label: "workspace", value: "" }, + { label: "token", value: "tok_secret", sensitive: true }, + { label: "note", value: "first\nsecond" }, + ], + }, + ]), + ).toBe( + "status: signed in\nworkspace: —\ntoken: ********\nnote: first\nsecond\n", + ); + }); + + test("an empty fields block renders nothing", async () => { + expect(await render([{ kind: "fields", rows: [] }])).toBe(""); + }); + + test("table is a GFM pipe table with sentence-cased headers and the dash for an empty cell", async () => { + expect( + await render([ + { + kind: "table", + columns: ["name", "id", "status"], + rows: [ + ["Acme Inc", "ws_1", "current"], + ["Globex", "ws_2", ""], + ], + }, + ]), + ).toBe( + "| Name | Id | Status |\n" + + "| --- | --- | --- |\n" + + "| Acme Inc | ws_1 | current |\n" + + "| Globex | ws_2 | — |\n", + ); + }); + + test("a pipe in a cell is escaped and a newline becomes a space", async () => { + expect( + await render([ + { + kind: "table", + columns: ["expr", "note"], + rows: [["a | b", "line one\nline two"]], + }, + ]), + ).toBe("| Expr | Note |\n| --- | --- |\n| a \\| b | line one line two |\n"); + }); + + test("a table with columns but no rows prints the header, the separator, then `(no rows)`", async () => { + expect( + await render([{ kind: "table", columns: ["name", "id"], rows: [] }]), + ).toBe("| Name | Id |\n| --- | --- |\n(no rows)\n"); + }); + + test("list is one `- item` per entry, multi-line verbatim", async () => { + expect(await render([{ kind: "list", items: ["one", "two\nmore"] }])).toBe( + "- one\n- two\nmore\n", + ); + }); + + test("tree is nested bullets, two spaces per depth, status in brackets", async () => { + expect( + await render([ + { + kind: "tree", + roots: [ + { + label: "root", + status: "ok", + children: [ + { label: "child" }, + { + label: "failing", + status: "error", + children: [{ label: "leaf", status: "warn" }], + }, + ], + }, + { label: "second" }, + ], + }, + ]), + ).toBe( + "- [ok] root\n" + + " - child\n" + + " - [error] failing\n" + + " - [warn] leaf\n" + + "- second\n", + ); + }); + + test("drawing is a fenced code block with no language", async () => { + expect(await render([{ kind: "drawing", lines: ["│ a", "└─ b"] }])).toBe( + "```\n│ a\n└─ b\n```\n", + ); + }); + + test("a drawing containing three backticks uses a four-backtick fence", async () => { + expect( + await render([{ kind: "drawing", lines: ["```", "x", "```"] }]), + ).toBe("````\n```\nx\n```\n````\n"); + }); + + test("spans render as their plain text with tones dropped", async () => { + expect( + await render([ + { + kind: "summary", + status: "info", + text: [ + { text: "Acme", tone: "identifier" }, + { text: " is current", tone: "muted" }, + ], + }, + ]), + ).toBe("[info] Acme is current\n"); + }); +}); + +describe("sections", () => { + test("blocks, then `### Next`, then `### Diagnostics`, one blank line between every block and section, one trailing newline", async () => { + const result = await run({ + blocks: [ + { kind: "summary", status: "ok", text: "Done" }, + { kind: "fields", rows: [] }, + { kind: "list", items: ["a", "b"] }, + ], + next: [ + { kind: "run-command", label: "Deploy", command: "prisma deploy" }, + ], + diagnostics: [ + { + code: "TOY.SLOW", + severity: "warn", + summary: "That took a while", + nextActions: [], + }, + { + code: "TOY.NOTE", + severity: "info", + summary: "Just so you know", + nextActions: [], + }, + ], + }); + + expect(result.stdout).toBe( + "[ok] Done\n" + + "\n" + + "- a\n" + + "- b\n" + + "\n" + + "### Next\n" + + "- Deploy: `prisma deploy`\n" + + "\n" + + "### Diagnostics\n" + + "[warn] TOY.SLOW: That took a while\n" + + "\n" + + "[info] TOY.NOTE: Just so you know\n", + ); + expect(result.exitCode).toBe(0); + }); + + test("no next actions and no diagnostics means no headings", async () => { + expect( + (await run({ blocks: [{ kind: "summary", status: "ok", text: "Done" }] })) + .stdout, + ).toBe("[ok] Done\n"); + }); +}); + +describe("next action bullets", () => { + async function bullets(next: readonly NextAction[]): Promise { + return (await run({ next })).stdout; + } + + test("a command whose label differs: `- label: `command``", async () => { + expect( + await bullets([ + { kind: "run-command", label: "Deploy it", command: "prisma deploy" }, + ]), + ).toBe("### Next\n- Deploy it: `prisma deploy`\n"); + }); + + test("a url whose label differs: `- label: url`, verbatim", async () => { + expect( + await bullets([ + { + kind: "open-url", + label: "Open the console", + url: "https://console.example.test/x?y=1", + }, + ]), + ).toBe( + "### Next\n- Open the console: https://console.example.test/x?y=1\n", + ); + }); + + test("a label equal to its command is printed once, in backticks", async () => { + expect( + await bullets([ + { + kind: "run-command", + label: "prisma deploy", + command: "prisma deploy", + }, + ]), + ).toBe("### Next\n- `prisma deploy`\n"); + }); + + test("a label equal to its url is printed once, plain", async () => { + expect( + await bullets([ + { kind: "open-url", label: "https://x.test", url: "https://x.test" }, + ]), + ).toBe("### Next\n- https://x.test\n"); + }); + + test("no target: `- label`; reason is not rendered", async () => { + expect( + await bullets([ + { kind: "user-choice", label: "Pick a region", reason: "latency" }, + ]), + ).toBe("### Next\n- Pick a region\n"); + }); + + test("plural commands: the label, then one nested bullet per command", async () => { + expect( + await bullets([ + { + kind: "run-command", + label: "Run both", + commands: ["prisma generate", "prisma migrate deploy"], + }, + ]), + ).toBe( + "### Next\n- Run both\n - `prisma generate`\n - `prisma migrate deploy`\n", + ); + }); +}); + +describe("diagnostic shape", () => { + async function diagnostic(diagnostic: Diagnostic): Promise { + return (await run({ diagnostics: [diagnostic] })).stdout; + } + + test("severity, code, summary, why, where, next actions, docs", async () => { + expect( + await diagnostic({ + code: "TOY.STALE", + severity: "warn", + summary: "Schema is stale", + why: "The file changed after the last generate", + where: { path: "prisma/schema.prisma", line: 12 }, + nextActions: [ + { + kind: "run-command", + label: "Regenerate", + command: "prisma generate", + }, + { kind: "user-choice", label: "Or ignore it" }, + ], + docsUrl: "https://docs.test/stale", + }), + ).toBe( + "### Diagnostics\n" + + "[warn] TOY.STALE: Schema is stale\n" + + "why: The file changed after the last generate\n" + + "where: prisma/schema.prisma:12\n" + + "- Regenerate: `prisma generate`\n" + + "- Or ignore it\n" + + "docs: https://docs.test/stale\n", + ); + }); + + test("where with a path alone", async () => { + expect( + await diagnostic({ + code: "TOY.A", + severity: "info", + summary: "s", + where: { path: "a.ts" }, + nextActions: [], + }), + ).toBe("### Diagnostics\n[info] TOY.A: s\nwhere: a.ts\n"); + }); + + test("where with a line alone", async () => { + expect( + await diagnostic({ + code: "TOY.A", + severity: "info", + summary: "s", + where: { line: 7 }, + nextActions: [], + }), + ).toBe("### Diagnostics\n[info] TOY.A: s\nwhere: line 7\n"); + }); + + test("an empty where renders no line", async () => { + expect( + await diagnostic({ + code: "TOY.A", + severity: "info", + summary: "s", + where: {}, + nextActions: [], + }), + ).toBe("### Diagnostics\n[info] TOY.A: s\n"); + }); + + test("docsUrl is derived from the family's docsBaseUrl", async () => { + const finding = show({ + diagnostics: [ + { + code: "TOY.FOUND", + severity: "info", + summary: "Found", + nextActions: [], + }, + ], + }); + const family = defineCommandFamily({ + commands: { finding }, + docsBaseUrl: "https://pris.ly/cli/errors", + }); + const result = await createTestCli({ + commandFamilies: [family], + commands: { finding }, + }).run(["finding", "--format", "markdown"]); + + expect(result.stdout).toBe( + "### Diagnostics\n[info] TOY.FOUND: Found\ndocs: https://pris.ly/cli/errors/TOY.FOUND\n", + ); + }); +}); + +describe("selection and channels", () => { + test("`--format=markdown` is accepted too", async () => { + const result = await createTestCli({ + commands: { + show: show({ blocks: [{ kind: "summary", status: "ok", text: "Hi" }] }), + }, + }).run(["show", "--format=markdown"]); + + expect(result.stdout).toBe("[ok] Hi\n"); + }); + + test("everything goes to stdout and stderr stays empty; the stdout lines are never printed", async () => { + const result = await run({ + blocks: [{ kind: "summary", status: "ok", text: "Done" }], + next: [{ kind: "user-choice", label: "Next" }], + diagnostics: [ + { code: "TOY.N", severity: "info", summary: "n", nextActions: [] }, + ], + stdout: ["raw data line"], + }); + + expect(result.stderr).toBe(""); + expect(result.stdout).not.toContain("raw data line"); + expect(result.presented?.presentation.stdout).toEqual([]); + expect(result.presented?.presentation.json).toBeUndefined(); + }); + + test("the human thunk is called once and the stdout thunk never; the blocks equal the human run's", async () => { + const blocks: readonly Block[] = [ + { kind: "summary", status: "ok", text: "Done" }, + { + kind: "table", + columns: ["name"], + rows: [[[{ text: "Acme", tone: "identifier" }]]], + }, + ]; + const markdownCalls = { human: 0, stdout: 0 }; + const humanCalls = { human: 0, stdout: 0 }; + const markdown = await createTestCli({ + commands: { show: show({ blocks }, markdownCalls) }, + }).run(["show", "--format", "markdown"]); + const human = await createTestCli({ + commands: { show: show({ blocks }, humanCalls) }, + }).run(["show", "--format", "human"]); + + expect(markdownCalls).toEqual({ human: 1, stdout: 0 }); + expect(humanCalls.human).toBe(1); + expect(markdown.presented?.presentation.human).toEqual( + human.presented?.presentation.human, + ); + expect(markdown.presented?.presentation.next).toEqual([]); + }); + + test("colour is off even with --color, and Ui.code keeps its backticks", async () => { + const result = await run( + { + blocks: (ui) => [ + { + kind: "summary", + status: "ok", + text: `${ui.tone("error", "red")} ${ui.emphasize("bold")} ${ui.dim("dim")} ${ui.code("x")}`, + }, + ], + }, + ["--color"], + ); + + expect(result.stdout).toBe("[ok] red bold dim `x`\n"); + expect(result.stderr).toBe(""); + }); + + test("Ui.width is unbounded even when stderr is a sized terminal", async () => { + const result = await run({ + blocks: (ui) => [ + { kind: "summary", status: "info", text: String(ui.width) }, + ], + }); + + expect(result.stdout).toBe("[info] Infinity\n"); + }); +}); From 18224067880a48da25b0f0454c79f402828ab4b0 Mon Sep 17 00:00:00 2001 From: willbot Date: Sat, 12 Sep 2026 10:12:53 +0200 Subject: [PATCH 2/7] feat(engine): markdown renders errors, version, child status, events, and config warnings Under `--format markdown` every remaining non-help surface now prints on stdout and stderr stays empty. An errored run prints the error in the diagnostic shape, then `### Diagnostics` for the accompanying findings; `--version` prints the bare version; a child-status settlement prints its next actions as bullets; live events print one line each (`step-started`, `progress`, and `remediation` dropped, `step-finished` as `[outcome] step`, the rest as human); config-section warnings of an OK run print in the diagnostic shape before the blocks. `commentaryLine` in `rendering.ts` is now the one source for the endpoint, status, and artifact lines, so the two renderers cannot drift. Co-Authored-By: Claude Fable 5.1 Signed-off-by: willbot Signed-off-by: Will Madden --- packages/cli-engine/src/execution/markdown.ts | 100 ++++- packages/cli-engine/src/execution/needs.ts | 18 +- .../cli-engine/src/execution/rendering.ts | 25 +- .../cli-engine/src/execution/reporting.ts | 5 + .../cli-engine/src/execution/settlement.ts | 30 +- packages/cli-engine/tests/markdown.test.ts | 345 ++++++++++++++++++ 6 files changed, 493 insertions(+), 30 deletions(-) diff --git a/packages/cli-engine/src/execution/markdown.ts b/packages/cli-engine/src/execution/markdown.ts index de58614f..cc7f30da 100644 --- a/packages/cli-engine/src/execution/markdown.ts +++ b/packages/cli-engine/src/execution/markdown.ts @@ -1,8 +1,16 @@ +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 { plainText } from "./palette"; -import { MASK, PLACEHOLDER, sentenceCase, withDocsUrl } from "./rendering"; +import { + commentaryLine, + MASK, + PLACEHOLDER, + sentenceCase, + withDocsUrl, +} from "./rendering"; const FENCE = "```"; const LONG_FENCE = "````"; @@ -148,6 +156,19 @@ export function joinSections( 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. */ @@ -164,14 +185,73 @@ export function renderCompletedMarkdown( ...presented.presentation.next.flatMap(renderNextActionMarkdown), ]); } - if (presented.diagnostics.length > 0) { - sections.push([ - "### Diagnostics", - ...presented.diagnostics.flatMap((diagnostic, index) => [ - ...(index === 0 ? [] : [""]), - ...renderDiagnosticMarkdown(withDocsUrl(state, diagnostic)), - ]), - ]); - } + 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 the blocks. */ +export function renderWarningsMarkdown( + invocation: Invocation, + diagnostics: readonly Diagnostic[], +): void { + invocation.runtime.stdout.write( + joinSections(diagnostics.map(renderDiagnosticMarkdown)), + ); +} + +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; + } +} 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/rendering.ts b/packages/cli-engine/src/execution/rendering.ts index 8acf56e6..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 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 cd67b83d..2ae47a6f 100644 --- a/packages/cli-engine/src/execution/settlement.ts +++ b/packages/cli-engine/src/execution/settlement.ts @@ -13,7 +13,11 @@ import { } from "../protocol"; import { type ChildStatusSettlement, childExitCode } from "../spawn"; import type { EngineSpec, Invocation } from "./engine"; -import { renderCompletedMarkdown } from "./markdown"; +import { + renderChildNextActionsMarkdown, + renderCompletedMarkdown, + renderErroredMarkdown, +} from "./markdown"; import { makePaint } from "./palette"; import { diagnosticSection, @@ -245,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, @@ -366,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) => diff --git a/packages/cli-engine/tests/markdown.test.ts b/packages/cli-engine/tests/markdown.test.ts index 7fd92fc7..e89daaa7 100644 --- a/packages/cli-engine/tests/markdown.test.ts +++ b/packages/cli-engine/tests/markdown.test.ts @@ -8,11 +8,15 @@ import { type Block, defineCommand, defineCommandFamily, + defineConfigSection, + exitWithChildStatus, type Ui, } from "@prisma/cli-engine"; import { + CliStructuredError, type Diagnostic, type NextAction, + notOk, ok, } from "@prisma/cli-engine/protocol"; import { createTestCli } from "@prisma/cli-engine/testing"; @@ -499,3 +503,344 @@ describe("selection and channels", () => { expect(result.stdout).toBe("[info] Infinity\n"); }); }); + +describe("an errored run", () => { + const failing = defineCommand({ + help: { summary: "Always fails" }, + handler: async () => + notOk( + new CliStructuredError("TOY.BROKEN", "It broke", { + why: "The toy always breaks.", + where: { path: "toy.ts", line: 3 }, + nextActions: [ + { kind: "run-command", label: "Retry", command: "prisma toy" }, + { kind: "open-url", label: "Read more", url: "https://x.test/a" }, + ], + diagnostics: [ + { + code: "TOY.FIRST", + severity: "warn", + summary: "First finding", + nextActions: [], + }, + { + code: "TOY.SECOND", + severity: "info", + summary: "Second finding", + why: "Because", + nextActions: [], + }, + ], + }), + ), + }); + + test("the error shape, a blank line, `### Diagnostics`, on stdout, exit 2, stderr empty", async () => { + const family = defineCommandFamily({ + commands: { failing }, + docsBaseUrl: "https://pris.ly/cli/errors/", + }); + const result = await createTestCli({ + commandFamilies: [family], + commands: { failing }, + }).run(["failing", "--format", "markdown", "--color"], { + isTty: { stdout: true, stderr: true }, + }); + + expect(result.exitCode).toBe(2); + expect(result.stderr).toBe(""); + expect(result.stdout).toBe( + "[error] TOY.BROKEN: It broke\n" + + "why: The toy always breaks.\n" + + "where: toy.ts:3\n" + + "- Retry: `prisma toy`\n" + + "- Read more: https://x.test/a\n" + + "docs: https://pris.ly/cli/errors/TOY.BROKEN\n" + + "\n" + + "### Diagnostics\n" + + "[warn] TOY.FIRST: First finding\n" + + "docs: https://pris.ly/cli/errors/TOY.FIRST\n" + + "\n" + + "[info] TOY.SECOND: Second finding\n" + + "why: Because\n" + + "docs: https://pris.ly/cli/errors/TOY.SECOND\n", + ); + }); + + test("an error with no accompanying diagnostics prints the error shape alone", async () => { + const bare = defineCommand({ + help: { summary: "Fails plainly" }, + handler: async () => notOk(new CliStructuredError("TOY.PLAIN", "Nope")), + }); + const result = await createTestCli({ commands: { bare } }).run([ + "bare", + "--format", + "markdown", + ]); + + expect(result.exitCode).toBe(2); + expect(result.stdout).toBe("[error] TOY.PLAIN: Nope\n"); + expect(result.stderr).toBe(""); + }); + + test("an unknown command prints the engine's usage error on stdout", async () => { + const result = await createTestCli({ commands: { show: show({}) } }).run([ + "shw", + "--format", + "markdown", + ]); + + expect(result.exitCode).toBe(2); + expect(result.stderr).toBe(""); + expect(result.stdout).toBe( + "[error] CLI.UNKNOWN_COMMAND: No command registered for `shw`, did you mean `show`?\n" + + "- Did you mean: `prisma-test show`\n" + + "- List every command: `prisma-test --help`\n", + ); + }); +}); + +describe("--version", () => { + test("prints the bare version on stdout", async () => { + const result = await createTestCli({ commands: { show: show({}) } }).run( + ["--version", "--format", "markdown"], + { isTty: { stdout: true } }, + ); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe("0.0.0\n"); + expect(result.stderr).toBe(""); + }); +}); + +describe("a child-status settlement", () => { + const hinting = defineCommand({ + help: { summary: "A converge that asks for a reproduce hint" }, + maySpawn: true, + handler: async (_args, ctx) => { + await ctx.spawn({ command: "alchemy" }); + return ok( + exitWithChildStatus({ + nextActions: [ + { + kind: "run-command", + label: "Reproduce the failed converge", + command: "alchemy deploy ./entry.ts", + }, + { kind: "user-choice", label: "Or give up" }, + ], + }), + ); + }, + }); + + test("prints its next actions as bullets on stdout and exits with the child's code", async () => { + const result = await createTestCli({ + commands: { hinting }, + spawnScript: () => ({ exitCode: 3, signal: null }), + }).run(["hinting", "--format", "markdown"], { isTty: { stdout: true } }); + + expect(result.exitCode).toBe(3); + expect(result.stderr).toBe(""); + expect(result.stdout).toBe( + "- Reproduce the failed converge: `alchemy deploy ./entry.ts`\n- Or give up\n", + ); + }); + + test("a signal-killed child prints nothing", async () => { + const result = await createTestCli({ + commands: { hinting }, + spawnScript: () => ({ exitCode: null, signal: "SIGINT" }), + }).run(["hinting", "--format", "markdown"], { isTty: { stdout: true } }); + + expect(result.exitCode).toBe(130); + expect(result.stdout).toBe(""); + expect(result.stderr).toBe(""); + }); +}); + +describe("live events", () => { + const noisy = defineCommand({ + help: { summary: "Emits the whole vocabulary" }, + handler: async (_args, ctx) => { + ctx.report({ kind: "step-started", step: "compile", id: "s1" }); + ctx.report({ kind: "progress", step: "compile", completed: 1, total: 2 }); + ctx.report({ + kind: "step-finished", + step: "compile", + id: "s1", + outcome: "ok", + }); + ctx.report({ kind: "step-finished", step: "lint", outcome: "warning" }); + ctx.report({ kind: "step-finished", step: "test", outcome: "failed" }); + ctx.report({ kind: "step-finished", step: "docs", outcome: "skipped" }); + ctx.report({ kind: "message", severity: "warn", text: "heads up" }); + ctx.report({ kind: "message", severity: "info", text: "fyi" }); + ctx.report({ kind: "message", severity: "verbose", text: "chatter" }); + ctx.report({ + kind: "output", + source: "generator", + channel: "data", + line: "generated 3 files", + }); + ctx.report({ + kind: "output", + source: "generator", + channel: "diagnostic", + line: "generator warmed up", + }); + ctx.report({ + kind: "remediation", + action: { kind: "run-command", label: "Review", command: "demo show" }, + }); + ctx.report({ + kind: "endpoint", + name: "studio", + url: "http://localhost:5555", + }); + ctx.report({ + kind: "status", + subject: "db", + status: "ready", + from: "starting", + }); + ctx.report({ kind: "status", subject: "cache", status: "warm" }); + ctx.report({ + kind: "artifact", + path: "out/contract.json", + description: "the contract", + data: { bytes: 42 }, + }); + ctx.report({ kind: "artifact", path: "out/plain.json" }); + return ok( + ctx.present( + { data: null }, + { + human: () => [{ kind: "summary", status: "ok", text: "Done" }], + stdout: () => [], + json: () => null, + next: () => [], + }, + ), + ); + }, + }); + + test("started and progress are dropped, finished carries the outcome word, the rest print as human, all on stdout", async () => { + const result = await createTestCli({ commands: { noisy } }).run( + ["noisy", "--format", "markdown"], + { isTty: { stdout: true, stderr: true } }, + ); + + expect(result.stderr).toBe(""); + expect(result.stdout).toBe( + "[ok] compile\n" + + "[warning] lint\n" + + "[failed] test\n" + + "[skipped] docs\n" + + "heads up\n" + + "fyi\n" + + "generated 3 files\n" + + "generator warmed up\n" + + "studio: http://localhost:5555\n" + + "db: starting → ready\n" + + "cache: warm\n" + + "out/contract.json — the contract\n" + + "out/plain.json\n" + + "[ok] Done\n", + ); + }); + + test("the log-level filter applies as under human", async () => { + const result = await createTestCli({ commands: { noisy } }).run([ + "noisy", + "--format", + "markdown", + "--quiet", + ]); + + expect(result.stderr).toBe(""); + expect(result.stdout).toBe("generated 3 files\n[ok] Done\n"); + }); +}); + +describe("config-section warnings", () => { + interface ToyConfig { + readonly greeting: string; + } + const warningSection = defineConfigSection({ + name: "toy", + validate: () => ({ + ok: true, + value: { greeting: "hi" }, + diagnostics: [ + { + code: "TOY.LEGACY_GREETING", + severity: "warn", + summary: "toy.legacy is deprecated.", + why: "Use toy.greeting.", + nextActions: [], + }, + { + code: "TOY.FYI", + severity: "info", + summary: "Nothing to do.", + nextActions: [], + }, + ], + }), + }); + const warned = defineCommand({ + help: { summary: "Show the validated toy config" }, + needs: { config: warningSection }, + handler: async (_args, ctx) => + ok( + ctx.present( + { data: ctx.config }, + { + human: () => [ + { kind: "summary", status: "ok", text: ctx.config.greeting }, + ], + stdout: () => [], + json: () => ctx.config, + next: () => [], + }, + ), + ), + }); + + function cli() { + return createTestCli({ commands: { warned }, config: { toy: {} } }); + } + + test("print on stdout in the diagnostic shape, a blank line between them, before the blocks", async () => { + const result = await cli().run(["warned", "--format", "markdown"], { + isTty: { stdout: true, stderr: true }, + }); + + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(""); + expect(result.stdout).toBe( + "[warn] TOY.LEGACY_GREETING: toy.legacy is deprecated.\n" + + "why: Use toy.greeting.\n" + + "\n" + + "[info] TOY.FYI: Nothing to do.\n" + + "[ok] hi\n", + ); + }); + + test("the log level filters them as under human", async () => { + const result = await cli().run([ + "warned", + "--format", + "markdown", + "--log-level", + "warn", + ]); + + expect(result.stderr).toBe(""); + expect(result.stdout).toBe( + "[warn] TOY.LEGACY_GREETING: toy.legacy is deprecated.\nwhy: Use toy.greeting.\n[ok] hi\n", + ); + }); +}); From 9fa3cae4a13c93cfd1184de027c82be199ea9ffa Mon Sep 17 00:00:00 2001 From: willbot Date: Sat, 12 Sep 2026 10:14:04 +0200 Subject: [PATCH 3/7] fix(engine): markdown config warnings end with one blank line The warnings section is separated from whatever the run prints next (blocks, an error, or nothing) by exactly one blank line, per the spec's config-section warnings rule. Co-Authored-By: Claude Fable 5.1 Signed-off-by: willbot Signed-off-by: Will Madden --- packages/cli-engine/src/execution/markdown.ts | 10 ++++++---- packages/cli-engine/tests/markdown.test.ts | 5 +++-- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/packages/cli-engine/src/execution/markdown.ts b/packages/cli-engine/src/execution/markdown.ts index cc7f30da..770323bb 100644 --- a/packages/cli-engine/src/execution/markdown.ts +++ b/packages/cli-engine/src/execution/markdown.ts @@ -208,14 +208,16 @@ export function renderErroredMarkdown( ); } -/** Config-section warnings of an OK run, ahead of the blocks. */ +/** 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 { - invocation.runtime.stdout.write( - joinSections(diagnostics.map(renderDiagnosticMarkdown)), - ); + const section = joinSections(diagnostics.map(renderDiagnosticMarkdown)); + if (section !== "") { + invocation.runtime.stdout.write(`${section}\n`); + } } export function renderChildNextActionsMarkdown( diff --git a/packages/cli-engine/tests/markdown.test.ts b/packages/cli-engine/tests/markdown.test.ts index e89daaa7..05cbe43a 100644 --- a/packages/cli-engine/tests/markdown.test.ts +++ b/packages/cli-engine/tests/markdown.test.ts @@ -813,7 +813,7 @@ describe("config-section warnings", () => { return createTestCli({ commands: { warned }, config: { toy: {} } }); } - test("print on stdout in the diagnostic shape, a blank line between them, before the blocks", async () => { + test("print on stdout in the diagnostic shape, a blank line between them and one after, before the blocks", async () => { const result = await cli().run(["warned", "--format", "markdown"], { isTty: { stdout: true, stderr: true }, }); @@ -825,6 +825,7 @@ describe("config-section warnings", () => { "why: Use toy.greeting.\n" + "\n" + "[info] TOY.FYI: Nothing to do.\n" + + "\n" + "[ok] hi\n", ); }); @@ -840,7 +841,7 @@ describe("config-section warnings", () => { expect(result.stderr).toBe(""); expect(result.stdout).toBe( - "[warn] TOY.LEGACY_GREETING: toy.legacy is deprecated.\nwhy: Use toy.greeting.\n[ok] hi\n", + "[warn] TOY.LEGACY_GREETING: toy.legacy is deprecated.\nwhy: Use toy.greeting.\n\n[ok] hi\n", ); }); }); From dc2d1ea3de3d5f42ee560d32cc36030297f6984e Mon Sep 17 00:00:00 2001 From: willbot Date: Sat, 12 Sep 2026 10:22:42 +0200 Subject: [PATCH 4/7] feat(engine): help in Markdown under `--format markdown` `help.ts` is now a `HelpCard` data model (header, usage, description, and every section as rows) plus the terminal renderer, byte-identical to before; `markdown.ts` gains `renderHelpMarkdown`, which draws the same card as headings, paragraphs, pipe tables, and bash fences per the spec's help shape. `--help`, `-h`, `--help-all`, and bare group invocations route to it under `--format markdown`, on stdout with stderr empty. The bareness check now ignores the format-selection flags, so `cli project --format markdown` is the group's help rather than an unknown-command error. Terminal help for a root, a group, and a leaf card was captured before the split and is pinned in `help-terminal.test.ts`; the Markdown output for the same cards is pinned in `help-markdown.test.ts`. Co-Authored-By: Claude Fable 5.1 Signed-off-by: willbot Signed-off-by: Will Madden --- packages/cli-engine/src/execution/engine.ts | 13 +- packages/cli-engine/src/execution/help.ts | 382 ++++++++++-------- packages/cli-engine/src/execution/markdown.ts | 81 +++- .../src/execution/pre-parse-argv.ts | 22 + .../cli-engine/tests/fixtures/help-cards.ts | 120 ++++++ .../cli-engine/tests/help-markdown.test.ts | 123 ++++++ .../cli-engine/tests/help-terminal.test.ts | 83 ++++ 7 files changed, 653 insertions(+), 171 deletions(-) create mode 100644 packages/cli-engine/tests/fixtures/help-cards.ts create mode 100644 packages/cli-engine/tests/help-markdown.test.ts create mode 100644 packages/cli-engine/tests/help-terminal.test.ts diff --git a/packages/cli-engine/src/execution/engine.ts b/packages/cli-engine/src/execution/engine.ts index 293482da..2bed1cc6 100644 --- a/packages/cli-engine/src/execution/engine.ts +++ b/packages/cli-engine/src/execution/engine.ts @@ -391,11 +391,14 @@ export class EngineImpl implements Engine { this.spec, this.tree, argv, - preParseColorEnabled( - argv, - runtime, - format === "json" ? "stderr" : "stdout", - ), + { + format, + colorEnabled: preParseColorEnabled( + argv, + runtime, + format === "json" ? "stderr" : "stdout", + ), + }, stream, ); return 0; diff --git a/packages/cli-engine/src/execution/help.ts b/packages/cli-engine/src/execution/help.ts index fd7e5760..b09a5faa 100644 --- a/packages/cli-engine/src/execution/help.ts +++ b/packages/cli-engine/src/execution/help.ts @@ -13,10 +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 } from "./pre-parse-argv"; +import { formatFlagGiven, withoutFormatFlags } from "./pre-parse-argv"; import { SHARED_ALIASES, SHARED_FLAG_PARAMETERS } from "./shared-flags"; import { resolveExample } from "./stricli-adapter"; @@ -108,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; @@ -128,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); } - out.write(`${lines.join("\n")}\n`); + 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))); + } + } + 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 { @@ -170,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 { @@ -228,7 +419,6 @@ function proseLines( function exampleLines( examples: readonly string[], - cliName: string, paint: Paint, lines: string[], ): void { @@ -238,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)); @@ -311,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]), ); @@ -341,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 { @@ -408,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 ( @@ -440,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 index 770323bb..242ad855 100644 --- a/packages/cli-engine/src/execution/markdown.ts +++ b/packages/cli-engine/src/execution/markdown.ts @@ -3,6 +3,7 @@ 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, @@ -51,8 +52,12 @@ export function renderBlockMarkdown(block: Block): string[] { } } +function escapeCell(text: string): string { + return text.replace(PIPE, "\\|").replace(NEWLINE, " "); +} + function cell(text: Text): string { - return orPlaceholder(text).replace(PIPE, "\\|").replace(NEWLINE, " "); + return escapeCell(orPlaceholder(text)); } function pipeRow(cells: readonly string[]): string { @@ -257,3 +262,77 @@ export function renderEventMarkdown( return; } } + +const BASH_FENCE = "```bash"; + +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(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"], [BASH_FENCE, card.usage, FENCE]); + } + 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"], [BASH_FENCE, ...card.examples, FENCE]); + } + if (card.docsUrl !== undefined) { + sections.push([`Docs: ${card.docsUrl}`]); + } + return joinSections(sections); +} diff --git a/packages/cli-engine/src/execution/pre-parse-argv.ts b/packages/cli-engine/src/execution/pre-parse-argv.ts index ccb7183d..a0e66453 100644 --- a/packages/cli-engine/src/execution/pre-parse-argv.ts +++ b/packages/cli-engine/src/execution/pre-parse-argv.ts @@ -45,6 +45,28 @@ 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. */ +export function withoutFormatFlags(argv: readonly string[]): string[] { + const kept: string[] = []; + let skipValue = false; + for (const token of argv) { + if (skipValue) { + skipValue = false; + continue; + } + if (token === "--json" || token.startsWith("--format=")) { + continue; + } + if (token === "--format") { + skipValue = true; + continue; + } + kept.push(token); + } + return kept; +} + /** The format requested by --json / --format / --format=, if * any. */ export function formatFlagGiven(argv: readonly string[]): Format | undefined { 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..2d69b277 --- /dev/null +++ b/packages/cli-engine/tests/help-markdown.test.ts @@ -0,0 +1,123 @@ +/** + * 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