From 71a5d8499c65fb6f095961984829924314575ac4 Mon Sep 17 00:00:00 2001 From: willbot Date: Sun, 27 Sep 2026 17:31:24 +0200 Subject: [PATCH 1/6] fix(engine): substitute {bin} in next actions, diagnostics, and presentation prose Command families write {bin} and expect the renderer to name the binary the user ran. The engine substituted it only in help examples and redirect replacements, so hints such as '{bin} db migrate' were printed as written. The engine now substitutes when a run settles, in next actions (command, commands), diagnostic and error summary and why, config section warnings, and summary and list blocks. Table, fields, tree, and drawing blocks, the json result, stdout lines, and meta are left as written. The engine moves to 0.6.2. pnpm check:conformance fails until the two engine-pin exceptions for the 0.6.2 transition are added to packages/cli/scripts/conformance.ts. Co-Authored-By: Claude Fable 5.1 Signed-off-by: willbot Signed-off-by: Will Madden --- packages/cli-engine/package.json | 2 +- packages/cli-engine/src/execution/bin-name.ts | 90 +++++++ packages/cli-engine/src/execution/engine.ts | 3 + packages/cli-engine/src/execution/needs.ts | 5 +- .../cli-engine/src/execution/settlement.ts | 36 ++- .../src/execution/stricli-adapter.ts | 6 +- .../cli-engine/tests/bin-placeholder.test.ts | 247 ++++++++++++++++++ packages/cli/package.json | 2 +- packages/prisma/package.json | 2 +- pnpm-lock.yaml | 4 +- 10 files changed, 378 insertions(+), 19 deletions(-) create mode 100644 packages/cli-engine/src/execution/bin-name.ts create mode 100644 packages/cli-engine/tests/bin-placeholder.test.ts diff --git a/packages/cli-engine/package.json b/packages/cli-engine/package.json index 46d0b700..ac725cf5 100644 --- a/packages/cli-engine/package.json +++ b/packages/cli-engine/package.json @@ -1,6 +1,6 @@ { "name": "@prisma/cli-engine", - "version": "0.6.1", + "version": "0.6.2", "description": "The execution engine of the unified Prisma CLI.", "type": "module", "exports": { diff --git a/packages/cli-engine/src/execution/bin-name.ts b/packages/cli-engine/src/execution/bin-name.ts new file mode 100644 index 00000000..678a1b9a --- /dev/null +++ b/packages/cli-engine/src/execution/bin-name.ts @@ -0,0 +1,90 @@ +import { + type Block, + PRESENTED, + type PresentedResult, + type Text, +} from "../presentation"; +import type { Diagnostic, NextAction } from "../protocol"; +import { substituteBinName } from "./stricli-adapter"; + +function inText(text: Text, cliName: string): Text { + return typeof text === "string" + ? substituteBinName(text, cliName) + : text.map((span) => ({ + ...span, + text: substituteBinName(span.text, cliName), + })); +} + +function inBlock(block: Block, cliName: string): Block { + switch (block.kind) { + case "summary": + return { ...block, text: inText(block.text, cliName) }; + case "list": + return { + ...block, + items: block.items.map((item) => inText(item, cliName)), + }; + case "fields": + case "table": + case "tree": + case "drawing": + return block; + } +} + +export function nextActionsWithBinName( + actions: readonly NextAction[], + cliName: string, +): readonly NextAction[] { + return actions.map((action) => ({ + ...action, + ...(action.command === undefined + ? {} + : { command: substituteBinName(action.command, cliName) }), + ...(action.commands === undefined + ? {} + : { + commands: action.commands.map((command) => + substituteBinName(command, cliName), + ), + }), + })); +} + +export function diagnosticWithBinName( + diagnostic: Diagnostic, + cliName: string, +): Diagnostic { + return { + ...diagnostic, + summary: substituteBinName(diagnostic.summary, cliName), + ...(diagnostic.why === undefined + ? {} + : { why: substituteBinName(diagnostic.why, cliName) }), + nextActions: nextActionsWithBinName(diagnostic.nextActions, cliName), + }; +} + +/** Only summary and list blocks are rewritten. Every other block, the + * data, the json result and the stdout lines can hold user data and + * pass through unchanged. */ +export function presentedWithBinName( + presented: PresentedResult, + cliName: string, +): PresentedResult { + return { + ...presented, + [PRESENTED]: true, + diagnostics: presented.diagnostics.map((diagnostic) => + diagnosticWithBinName(diagnostic, cliName), + ), + presentation: { + ...presented.presentation, + human: presented.presentation.human.map((block) => + inBlock(block, cliName), + ), + next: nextActionsWithBinName(presented.presentation.next, cliName), + }, + }; +} diff --git a/packages/cli-engine/src/execution/engine.ts b/packages/cli-engine/src/execution/engine.ts index 00091426..637795fc 100644 --- a/packages/cli-engine/src/execution/engine.ts +++ b/packages/cli-engine/src/execution/engine.ts @@ -198,6 +198,8 @@ export interface RunState { } export interface Invocation { + /** The name of the binary the user ran, substituted for `{bin}`. */ + readonly cliName: string; readonly runtime: Runtime; readonly hooks: RunHooks; readonly now: () => Date; @@ -376,6 +378,7 @@ export class EngineImpl implements Engine { }; const unsubscribe = runtime.onSignal(deliverSignal); const invocation: Invocation = { + cliName: this.spec.name, runtime, hooks: { ...hooks }, now: this.now, diff --git a/packages/cli-engine/src/execution/needs.ts b/packages/cli-engine/src/execution/needs.ts index 058acb77..e4cf2642 100644 --- a/packages/cli-engine/src/execution/needs.ts +++ b/packages/cli-engine/src/execution/needs.ts @@ -13,6 +13,7 @@ import { } from "../package-manager"; import { CliStructuredError, type Diagnostic } from "../protocol"; import type { LoadedConfig, LoadedConfigFile } from "../runtime"; +import { diagnosticWithBinName } from "./bin-name"; import type { Invocation } from "./engine"; import { renderWarningsMarkdown } from "./markdown"; import { makePaint } from "./palette"; @@ -413,7 +414,9 @@ function writeSectionWarnings( (diagnostic) => SEVERITY_RANK[diagnostic.severity] <= SEVERITY_RANK[state.logLevel], ) - .map((diagnostic) => withDocsUrl(state, diagnostic)); + .map((diagnostic) => + withDocsUrl(state, diagnosticWithBinName(diagnostic, invocation.cliName)), + ); if (state.format === "markdown") { renderWarningsMarkdown(invocation, shown); return; diff --git a/packages/cli-engine/src/execution/settlement.ts b/packages/cli-engine/src/execution/settlement.ts index 2ae47a6f..45cfacbb 100644 --- a/packages/cli-engine/src/execution/settlement.ts +++ b/packages/cli-engine/src/execution/settlement.ts @@ -12,6 +12,11 @@ import { type NextAction, } from "../protocol"; import { type ChildStatusSettlement, childExitCode } from "../spawn"; +import { + diagnosticWithBinName, + nextActionsWithBinName, + presentedWithBinName, +} from "./bin-name"; import type { EngineSpec, Invocation } from "./engine"; import { renderChildNextActionsMarkdown, @@ -50,12 +55,12 @@ function undocumentedExitCode( export function settleCompleted( invocation: Invocation, def: AnyCommand, - presented: PresentedResult, + returned: PresentedResult, ): void { if ( - typeof presented !== "object" || - presented === null || - (presented as unknown as Record)[PRESENTED] !== true + typeof returned !== "object" || + returned === null || + (returned as unknown as Record)[PRESENTED] !== true ) { settleBug( invocation, @@ -65,11 +70,12 @@ export function settleCompleted( ); return; } - const violation = undocumentedExitCode(def, presented.exitCode); + const violation = undocumentedExitCode(def, returned.exitCode); if (violation !== undefined) { settleBug(invocation, violation); return; } + const presented = presentedWithBinName(returned, invocation.cliName); const state = invocation.state; invocation.hooks.onPresented?.(presented); const exitCode = runExitCode(invocation, presented.exitCode); @@ -127,9 +133,11 @@ export function settleErrored( emitErrored(invocation, { ok: false, commandId: state.commandId, - error: diagnosticOf(error), - diagnostics: accompanyingFindings(diagnostics), - nextActions: error.nextActions, + error: diagnosticWithBinName(diagnosticOf(error), invocation.cliName), + diagnostics: accompanyingFindings(diagnostics).map((diagnostic) => + diagnosticWithBinName(diagnostic, invocation.cliName), + ), + nextActions: nextActionsWithBinName(error.nextActions, invocation.cliName), }); } @@ -244,12 +252,16 @@ export function settleChildStatus( ); } const exitCode = childExitCode(child); + const nextActions = nextActionsWithBinName( + settlement.nextActions, + invocation.cliName, + ); if (invocation.state.format === "json") { - settleStructuredChildStatus(invocation, settlement, child, exitCode); + settleStructuredChildStatus(invocation, nextActions, child, exitCode); return; } if (child.signal === null) { - renderChildNextActions(invocation, settlement.nextActions); + renderChildNextActions(invocation, nextActions); } settleVerbatimExitCode(invocation, exitCode); } @@ -270,12 +282,12 @@ function renderChildNextActions( function settleStructuredChildStatus( invocation: Invocation, - settlement: ChildStatusSettlement, + actions: readonly NextAction[], child: { readonly exitCode: number | null; readonly signal: string | null }, exitCode: number, ): void { settleVerbatimExitCode(invocation, exitCode); - const nextActions = child.signal === null ? settlement.nextActions : []; + const nextActions = child.signal === null ? actions : []; if (exitCode === 0) { const envelope: CompletedEnvelope = { ok: true, diff --git a/packages/cli-engine/src/execution/stricli-adapter.ts b/packages/cli-engine/src/execution/stricli-adapter.ts index 17572b07..d0fe5d88 100644 --- a/packages/cli-engine/src/execution/stricli-adapter.ts +++ b/packages/cli-engine/src/execution/stricli-adapter.ts @@ -177,10 +177,14 @@ function commandParameters(def: AnyCommand): Record { * written the same way and rendered by the same rule. */ export function resolveExample(example: string, cliName: string): string { return example.includes("{bin}") - ? example.replaceAll("{bin}", cliName) + ? substituteBinName(example, cliName) : `${cliName} ${example}`; } +export function substituteBinName(text: string, cliName: string): string { + return text.replaceAll("{bin}", cliName); +} + function commandDocs( def: AnyCommand, cliName: string, diff --git a/packages/cli-engine/tests/bin-placeholder.test.ts b/packages/cli-engine/tests/bin-placeholder.test.ts new file mode 100644 index 00000000..31721a10 --- /dev/null +++ b/packages/cli-engine/tests/bin-placeholder.test.ts @@ -0,0 +1,247 @@ +/** + * A command family never names the binary: it writes `{bin}`, and the + * engine substitutes the name of the CLI the user ran in everything it + * prints or serializes from the command. + */ +import { defineCommand, type StreamEvent } from "@prisma/cli-engine"; +import { + CliStructuredError, + type Diagnostic, + ok, +} from "@prisma/cli-engine/protocol"; +import { createTestCli } from "@prisma/cli-engine/testing"; +import { describe, expect, test } from "vitest"; + +const FINDING: Diagnostic = { + code: "MIGRATION.DRIFT", + severity: "warn", + summary: "Run `{bin} migration status` to see the drift.", + why: "`{bin} db migrate` was interrupted.", + nextActions: [ + { kind: "run-command", label: "Inspect", command: "{bin} db inspect" }, + ], +}; + +const DATA = { note: "the row says {bin}" }; + +const plan = defineCommand({ + help: { summary: "Plan a migration" }, + args: {}, + handler: async (_args, ctx) => + ok( + ctx.present( + { data: DATA, diagnostics: [FINDING] }, + { + human: () => [ + { + kind: "summary", + status: "info", + text: "Run `{bin} migration plan --name ` to author one.", + }, + { + kind: "list", + items: [[{ text: "{bin} migration graph", tone: "identifier" }]], + }, + { + kind: "table", + columns: ["name"], + rows: [["{bin}"]], + }, + { + kind: "fields", + rows: [{ label: "value", value: "{bin}" }], + }, + ], + stdout: () => ["{bin}"], + json: () => DATA, + next: () => [ + { + kind: "run-command", + label: "Apply the migration", + command: "{bin} db migrate", + }, + { + kind: "run-command", + label: "Or step by step", + commands: ["{bin} db migrate --dry-run", "{bin} db migrate"], + }, + { kind: "run-command", label: "No placeholder", command: "ls" }, + ], + }, + ), + ), +}); + +const failing = defineCommand({ + help: { summary: "Fail" }, + args: {}, + handler: async () => { + throw new CliStructuredError( + "MIGRATION.NOT_FOUND", + "Run `{bin} migration plan` first.", + { + why: "`{bin} db migrate` found no migration.", + nextActions: [ + { + kind: "run-command", + label: "Plan one", + command: "{bin} migration plan", + }, + ], + diagnostics: [FINDING], + meta: { echoed: "{bin}" }, + }, + ); + }, +}); + +const HUMAN = { isTty: { stdout: true } }; + +const cli = () => createTestCli({ commands: { plan, failing } }); + +function lastEnvelope(frames: readonly StreamEvent[]) { + const last = frames[frames.length - 1]; + if (last.kind !== "result") { + throw new Error("expected a result frame"); + } + return last.envelope; +} + +describe("a completed command", () => { + test("human output names the binary and leaves table cells and field values alone", async () => { + const result = await cli().run(["plan"], HUMAN); + + expect(result.stderr).toBe( + "ℹ Run `prisma-test migration plan --name ` to author one.\n" + + "\n" + + "- prisma-test migration graph\n" + + "\n" + + "Name\n" + + "{bin}\n" + + "\n" + + "value: {bin}\n" + + "\n" + + "→ Apply the migration: prisma-test db migrate\n" + + "→ Or step by step\n" + + "→ No placeholder: ls\n" + + "\n" + + "⚠ [MIGRATION.DRIFT] Run `prisma-test migration status` to see the drift.\n" + + " why: `prisma-test db migrate` was interrupted.\n" + + "→ Inspect: prisma-test db inspect\n", + ); + }); + + test("the machine lines on stdout are left as the command wrote them", async () => { + const result = await cli().run(["plan"], HUMAN); + + expect(result.stdout).toBe("{bin}\n"); + }); + + test("markdown output names the binary and leaves table cells and field values alone", async () => { + const result = await cli().run(["plan", "--format", "markdown"]); + + expect(result.stdout).toBe( + "[info] Run `prisma-test migration plan --name ` to author one.\n" + + "\n" + + "- prisma-test migration graph\n" + + "\n" + + "| Name |\n" + + "| --- |\n" + + "| {bin} |\n" + + "\n" + + "value: {bin}\n" + + "\n" + + "### Next\n" + + "- Apply the migration: `prisma-test db migrate`\n" + + "- Or step by step\n" + + " - `prisma-test db migrate --dry-run`\n" + + " - `prisma-test db migrate`\n" + + "- No placeholder: `ls`\n" + + "\n" + + "### Diagnostics\n" + + "[warn] MIGRATION.DRIFT: Run `prisma-test migration status` to see the drift.\n" + + "why: `prisma-test db migrate` was interrupted.\n" + + "- Inspect: `prisma-test db inspect`\n", + ); + }); + + test("the json envelope names the binary and leaves the result alone", async () => { + const result = await cli().run(["plan", "--json"]); + + expect(lastEnvelope(result.json)).toMatchObject({ + ok: true, + result: { note: "the row says {bin}" }, + nextActions: [ + { label: "Apply the migration", command: "prisma-test db migrate" }, + { + label: "Or step by step", + commands: [ + "prisma-test db migrate --dry-run", + "prisma-test db migrate", + ], + }, + { label: "No placeholder", command: "ls" }, + ], + diagnostics: [ + { + summary: "Run `prisma-test migration status` to see the drift.", + why: "`prisma-test db migrate` was interrupted.", + nextActions: [{ command: "prisma-test db inspect" }], + }, + ], + }); + }); +}); + +describe("a failed command", () => { + test("human output names the binary", async () => { + const result = await cli().run(["failing"], HUMAN); + + expect(result.stderr).toBe( + "✘ [MIGRATION.NOT_FOUND] Run `prisma-test migration plan` first.\n" + + " why: `prisma-test db migrate` found no migration.\n" + + "→ Plan one: prisma-test migration plan\n" + + "\n" + + "⚠ [MIGRATION.DRIFT] Run `prisma-test migration status` to see the drift.\n" + + " why: `prisma-test db migrate` was interrupted.\n" + + "→ Inspect: prisma-test db inspect\n", + ); + }); + + test("markdown output names the binary", async () => { + const result = await cli().run(["failing", "--format", "markdown"]); + + expect(result.stdout).not.toContain("{bin}"); + expect(result.stdout).toContain( + "[error] MIGRATION.NOT_FOUND: Run `prisma-test migration plan` first.\n" + + "why: `prisma-test db migrate` found no migration.\n" + + "- Plan one: `prisma-test migration plan`\n", + ); + }); + + test("the json envelope names the binary and leaves meta alone", async () => { + const result = await cli().run(["failing", "--json"]); + + const planOne = { + label: "Plan one", + command: "prisma-test migration plan", + }; + expect(lastEnvelope(result.json)).toMatchObject({ + ok: false, + error: { + summary: "Run `prisma-test migration plan` first.", + why: "`prisma-test db migrate` found no migration.", + nextActions: [planOne], + meta: { echoed: "{bin}" }, + }, + diagnostics: [ + { + summary: "Run `prisma-test migration status` to see the drift.", + why: "`prisma-test db migrate` was interrupted.", + nextActions: [{ command: "prisma-test db inspect" }], + }, + ], + nextActions: [planOne], + }); + }); +}); diff --git a/packages/cli/package.json b/packages/cli/package.json index 5e0e59cd..c772e015 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -49,7 +49,7 @@ }, "dependencies": { "@manypkg/tools": "^2.1.2", - "@prisma/cli-engine": "workspace:0.6.1", + "@prisma/cli-engine": "workspace:0.6.2", "@prisma/composer-cli": "0.23.0", "@prisma/compute-sdk": "0.43.0", "@prisma/management-api-sdk": "1.79.0", diff --git a/packages/prisma/package.json b/packages/prisma/package.json index e2da8e57..756ba9c9 100644 --- a/packages/prisma/package.json +++ b/packages/prisma/package.json @@ -50,7 +50,7 @@ }, "dependencies": { "@manypkg/tools": "^2.1.2", - "@prisma/cli-engine": "workspace:0.6.1", + "@prisma/cli-engine": "workspace:0.6.2", "@prisma/composer-cli": "0.23.0", "@prisma/compute-sdk": "0.43.0", "@prisma/management-api-sdk": "1.79.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index aa18215b..56c7cd9b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -27,7 +27,7 @@ importers: specifier: ^2.1.2 version: 2.1.2 '@prisma/cli-engine': - specifier: workspace:0.6.1 + specifier: workspace:0.6.2 version: link:../cli-engine '@prisma/composer-cli': specifier: 0.23.0 @@ -210,7 +210,7 @@ importers: specifier: ^2.1.2 version: 2.1.2 '@prisma/cli-engine': - specifier: workspace:0.6.1 + specifier: workspace:0.6.2 version: link:../cli-engine '@prisma/composer-cli': specifier: 0.23.0 From fb1c03d1af4fedaab3dcaefd7231a56088e7af4a Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 28 Sep 2026 09:07:50 +0200 Subject: [PATCH 2/6] chore(cli): allow the engine 0.6.2 transition in the conformance check composer-cli and orm-toolchain still peer engine 0.6.1. Delete both entries once both release peering 0.6.2 and prisma-cli pins those releases. Co-Authored-By: Claude Fable 5.1 Signed-off-by: willbot Signed-off-by: Will Madden --- .drive/projects/prisma-cli-v8/deferred.md | 2 ++ packages/cli/scripts/conformance.ts | 19 ++++++++++++++++++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/.drive/projects/prisma-cli-v8/deferred.md b/.drive/projects/prisma-cli-v8/deferred.md index 63e75e7a..d77be106 100644 --- a/.drive/projects/prisma-cli-v8/deferred.md +++ b/.drive/projects/prisma-cli-v8/deferred.md @@ -8,6 +8,8 @@ Nothing here is tracked outside this file. - **Engine version transitions — CLOSED through 0.6.1 (2026-09-27).** The 0.3.0 transition (the Management API SDK became a peer of the engine, so an SDK bump no longer changes the engine) closed 2026-08-26. The transitions to 0.4.0, 0.5.0 and 0.6.1 followed the same order: engine publishes, both families release peering it, prisma-cli pins those releases and empties the `exceptions` list in `packages/cli/scripts/conformance.ts`. The 0.6.1 exceptions were deleted once `@prisma/composer-cli` 0.23.0 and `@prisma/orm-toolchain` 8.0.0-rc.12 shipped in `prisma` 8.0.0-rc.17. +- **Engine 0.6.2 transition — IN FLIGHT (2026-09-28).** Engine 0.6.2 substitutes `{bin}` in next actions, diagnostics, errors, and summary and list blocks. `@prisma/composer-cli` and `@prisma/orm-toolchain` still peer 0.6.1, so `exceptions` in `packages/cli/scripts/conformance.ts` holds one entry for each. Delete both entries once both families release peering 0.6.2 and prisma-cli pins those releases. + - **A stale product `dev` dist-tag can block a release publish.** The publish run checks the dev channel before the release leg, and the dev channel resolves each product's `dev` tag with no fallback — so diff --git a/packages/cli/scripts/conformance.ts b/packages/cli/scripts/conformance.ts index 3403120d..0aad3038 100644 --- a/packages/cli/scripts/conformance.ts +++ b/packages/cli/scripts/conformance.ts @@ -109,7 +109,24 @@ async function tarball(): Promise { shellPackage: "@prisma/cli", enginePackage: "@prisma/cli-engine", familyPackages: ["@prisma/composer-cli", "@prisma/orm-toolchain"], - exceptions: [], + exceptions: [ + { + familyPackage: "@prisma/composer-cli", + familyPin: "0.6.1", + shellPin: "0.6.2", + reason: "engine 0.6.2 must publish before composer-cli can peer it", + removeWhen: + "composer-cli releases peering 0.6.2 and the follow-up bump PR pins that release", + }, + { + familyPackage: "@prisma/orm-toolchain", + familyPin: "0.6.1", + shellPin: "0.6.2", + reason: "engine 0.6.2 must publish before orm-toolchain can peer it", + removeWhen: + "orm-toolchain releases peering 0.6.2 and the follow-up bump PR pins that release", + }, + ], channel: CHANNEL, sandboxDir: join(WORK_DIR, "sandbox"), }, From c30deb75c6ead44b8adc3692939f0cf3a19250d1 Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 28 Sep 2026 09:23:59 +0200 Subject: [PATCH 3/6] fix(engine): substitute {bin} in next action label and reason A next action's label and reason are prose the command family wrote, so they get the same substitution as command and commands. The url is left as written. Co-Authored-By: Claude Fable 5.1 Signed-off-by: willbot Signed-off-by: Will Madden --- packages/cli-engine/src/execution/bin-name.ts | 4 ++++ packages/cli-engine/tests/bin-placeholder.test.ts | 13 +++++++++++++ 2 files changed, 17 insertions(+) diff --git a/packages/cli-engine/src/execution/bin-name.ts b/packages/cli-engine/src/execution/bin-name.ts index 678a1b9a..6fab826e 100644 --- a/packages/cli-engine/src/execution/bin-name.ts +++ b/packages/cli-engine/src/execution/bin-name.ts @@ -39,6 +39,10 @@ export function nextActionsWithBinName( ): readonly NextAction[] { return actions.map((action) => ({ ...action, + label: substituteBinName(action.label, cliName), + ...(action.reason === undefined + ? {} + : { reason: substituteBinName(action.reason, cliName) }), ...(action.command === undefined ? {} : { command: substituteBinName(action.command, cliName) }), diff --git a/packages/cli-engine/tests/bin-placeholder.test.ts b/packages/cli-engine/tests/bin-placeholder.test.ts index 31721a10..315aced6 100644 --- a/packages/cli-engine/tests/bin-placeholder.test.ts +++ b/packages/cli-engine/tests/bin-placeholder.test.ts @@ -66,6 +66,12 @@ const plan = defineCommand({ commands: ["{bin} db migrate --dry-run", "{bin} db migrate"], }, { kind: "run-command", label: "No placeholder", command: "ls" }, + { + kind: "open-url", + label: "Read about `{bin} db migrate`", + url: "https://example.com/{bin}", + reason: "`{bin} db migrate` changes the database.", + }, ], }, ), @@ -124,6 +130,7 @@ describe("a completed command", () => { "→ Apply the migration: prisma-test db migrate\n" + "→ Or step by step\n" + "→ No placeholder: ls\n" + + "→ Read about `prisma-test db migrate`: https://example.com/{bin}\n" + "\n" + "⚠ [MIGRATION.DRIFT] Run `prisma-test migration status` to see the drift.\n" + " why: `prisma-test db migrate` was interrupted.\n" + @@ -157,6 +164,7 @@ describe("a completed command", () => { " - `prisma-test db migrate --dry-run`\n" + " - `prisma-test db migrate`\n" + "- No placeholder: `ls`\n" + + "- Read about `prisma-test db migrate`: https://example.com/{bin}\n" + "\n" + "### Diagnostics\n" + "[warn] MIGRATION.DRIFT: Run `prisma-test migration status` to see the drift.\n" + @@ -181,6 +189,11 @@ describe("a completed command", () => { ], }, { label: "No placeholder", command: "ls" }, + { + label: "Read about `prisma-test db migrate`", + url: "https://example.com/{bin}", + reason: "`prisma-test db migrate` changes the database.", + }, ], diagnostics: [ { From 5a434d16cf4fb536b916a93f179b56c10d0884c2 Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 28 Sep 2026 10:12:10 +0200 Subject: [PATCH 4/6] fix(engine): substituting {bin} never fails a run on an unvalidated error Settlement receives errors nothing has validated: one built by another copy of the engine, or a handler's notOk failure. A missing next action list or a text field that is not a string is now returned as it came, so the run settles with the original error as it did before. Co-Authored-By: Claude Fable 5.1 Signed-off-by: willbot Signed-off-by: Will Madden --- packages/cli-engine/src/execution/bin-name.ts | 13 +- .../src/execution/stricli-adapter.ts | 8 +- .../tests/bin-placeholder-unvalidated.test.ts | 138 ++++++++++++++++++ 3 files changed, 153 insertions(+), 6 deletions(-) create mode 100644 packages/cli-engine/tests/bin-placeholder-unvalidated.test.ts diff --git a/packages/cli-engine/src/execution/bin-name.ts b/packages/cli-engine/src/execution/bin-name.ts index 6fab826e..e03782df 100644 --- a/packages/cli-engine/src/execution/bin-name.ts +++ b/packages/cli-engine/src/execution/bin-name.ts @@ -37,7 +37,10 @@ export function nextActionsWithBinName( actions: readonly NextAction[], cliName: string, ): readonly NextAction[] { - return actions.map((action) => ({ + if (!Array.isArray(actions)) { + return actions; + } + return actions.map((action: NextAction) => ({ ...action, label: substituteBinName(action.label, cliName), ...(action.reason === undefined @@ -49,9 +52,11 @@ export function nextActionsWithBinName( ...(action.commands === undefined ? {} : { - commands: action.commands.map((command) => - substituteBinName(command, cliName), - ), + commands: Array.isArray(action.commands) + ? action.commands.map((command: string) => + substituteBinName(command, cliName), + ) + : action.commands, }), })); } diff --git a/packages/cli-engine/src/execution/stricli-adapter.ts b/packages/cli-engine/src/execution/stricli-adapter.ts index d0fe5d88..8fb440d1 100644 --- a/packages/cli-engine/src/execution/stricli-adapter.ts +++ b/packages/cli-engine/src/execution/stricli-adapter.ts @@ -181,8 +181,12 @@ export function resolveExample(example: string, cliName: string): string { : `${cliName} ${example}`; } -export function substituteBinName(text: string, cliName: string): string { - return text.replaceAll("{bin}", cliName); +/** Settlement hands this values nothing has validated, so anything that + * is not a string is returned as it came. */ +export function substituteBinName(text: T, cliName: string): T { + return typeof text === "string" + ? (text.replaceAll("{bin}", cliName) as T) + : text; } function commandDocs( diff --git a/packages/cli-engine/tests/bin-placeholder-unvalidated.test.ts b/packages/cli-engine/tests/bin-placeholder-unvalidated.test.ts new file mode 100644 index 00000000..304606c4 --- /dev/null +++ b/packages/cli-engine/tests/bin-placeholder-unvalidated.test.ts @@ -0,0 +1,138 @@ +/** + * Settlement receives errors nothing has validated: one built by another + * copy of the engine, or a handler's notOk failure. Substituting `{bin}` + * must never be the reason such a run fails. + */ +import { defineCommand, type StreamEvent } from "@prisma/cli-engine"; +import { + type CliStructuredError, + type Diagnostic, + notOk, +} from "@prisma/cli-engine/protocol"; +import { createTestCli } from "@prisma/cli-engine/testing"; +import { describe, expect, test } from "vitest"; + +function foreignError(fields: Record): CliStructuredError { + return { + name: "CliStructuredError", + code: "FOREIGN.FAILED", + message: "Run `{bin} status`.", + toEnvelope: () => ({ + ok: false, + code: "FOREIGN.FAILED", + severity: "error", + summary: "Run `{bin} status`.", + nextActions: [], + }), + ...fields, + } as unknown as CliStructuredError; +} + +const CASES: Record CliStructuredError> = { + "an error without nextActions": () => + foreignError({ nextActions: undefined }), + "an envelope without nextActions": () => + foreignError({ + nextActions: [], + toEnvelope: () => ({ + ok: false, + code: "FOREIGN.FAILED", + severity: "error", + summary: "Run `{bin} status`.", + }), + }), + "an accompanying finding without nextActions": () => + foreignError({ + nextActions: [], + diagnostics: [ + { code: "FOREIGN.FINDING", severity: "warn", summary: "{bin}" }, + ] as unknown as Diagnostic[], + }), + "an accompanying finding whose why is not a string": () => + foreignError({ + nextActions: [], + diagnostics: [ + { + code: "FOREIGN.FINDING", + severity: "warn", + summary: "{bin}", + why: 42, + nextActions: [], + }, + ] as unknown as Diagnostic[], + }), +}; + +function cli(build: () => CliStructuredError) { + return createTestCli({ + commands: { + thrown: defineCommand({ + help: { summary: "Throw" }, + args: {}, + handler: async () => { + throw build(); + }, + }), + returned: defineCommand({ + help: { summary: "Return" }, + args: {}, + handler: async () => notOk(build()), + }), + }, + }); +} + +function errorOf(frames: readonly StreamEvent[]) { + const last = frames[frames.length - 1]; + if (last?.kind !== "result" || last.envelope.ok) { + throw new Error("expected an errored result frame"); + } + return last.envelope.error; +} + +/** A list of next actions that is missing has never rendered in human + * or markdown output, so those cases are asserted in json alone. */ +const RENDERABLE = [ + "an error without nextActions", + "an accompanying finding whose why is not a string", +]; + +describe.each(Object.entries(CASES))("%s", (name, build) => { + describe.each(["thrown", "returned"])("%s by the handler", (command) => { + test("json settles with the original error", async () => { + const result = await cli(build).run([command, "--json"]); + + expect(result.exitCode).toBe(2); + expect(errorOf(result.json)).toMatchObject({ + code: "FOREIGN.FAILED", + summary: "Run `prisma-test status`.", + }); + }); + + test.runIf(RENDERABLE.includes(name))( + "human settles with the original error", + async () => { + const result = await cli(build).run([command], { + isTty: { stdout: true }, + }); + + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain( + "[FOREIGN.FAILED] Run `prisma-test status`.", + ); + }, + ); + + test.runIf(RENDERABLE.includes(name))( + "markdown settles with the original error", + async () => { + const result = await cli(build).run([command, "--format", "markdown"]); + + expect(result.exitCode).toBe(2); + expect(result.stdout).toContain( + "FOREIGN.FAILED: Run `prisma-test status`.", + ); + }, + ); + }); +}); From 22bce140e6559ab220982b05a488249eb070db91 Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 28 Sep 2026 10:21:09 +0200 Subject: [PATCH 5/6] fix(engine): substituting {bin} passes malformed entries through unchanged A next action, diagnostic, or span that is null or not an object, and a field of the wrong type, is returned as it came. Substitution cannot stop the error envelope from being emitted. Co-Authored-By: Claude Fable 5.1 Signed-off-by: willbot Signed-off-by: Will Madden --- packages/cli-engine/src/execution/bin-name.ts | 63 ++++++++++++------- .../tests/bin-placeholder-unvalidated.test.ts | 16 ++++- 2 files changed, 55 insertions(+), 24 deletions(-) diff --git a/packages/cli-engine/src/execution/bin-name.ts b/packages/cli-engine/src/execution/bin-name.ts index e03782df..f6569e6a 100644 --- a/packages/cli-engine/src/execution/bin-name.ts +++ b/packages/cli-engine/src/execution/bin-name.ts @@ -2,18 +2,25 @@ import { type Block, PRESENTED, type PresentedResult, + type Span, type Text, } from "../presentation"; import type { Diagnostic, NextAction } from "../protocol"; import { substituteBinName } from "./stricli-adapter"; +function isRecord(value: unknown): boolean { + return typeof value === "object" && value !== null; +} + function inText(text: Text, cliName: string): Text { - return typeof text === "string" - ? substituteBinName(text, cliName) - : text.map((span) => ({ - ...span, - text: substituteBinName(span.text, cliName), - })); + if (!Array.isArray(text)) { + return substituteBinName(text, cliName); + } + return text.map((span: Span) => + isRecord(span) + ? { ...span, text: substituteBinName(span.text, cliName) } + : span, + ); } function inBlock(block: Block, cliName: string): Block { @@ -33,14 +40,14 @@ function inBlock(block: Block, cliName: string): Block { } } -export function nextActionsWithBinName( - actions: readonly NextAction[], +function nextActionWithBinName( + action: NextAction, cliName: string, -): readonly NextAction[] { - if (!Array.isArray(actions)) { - return actions; +): NextAction { + if (!isRecord(action)) { + return action; } - return actions.map((action: NextAction) => ({ + return { ...action, label: substituteBinName(action.label, cliName), ...(action.reason === undefined @@ -49,22 +56,34 @@ export function nextActionsWithBinName( ...(action.command === undefined ? {} : { command: substituteBinName(action.command, cliName) }), - ...(action.commands === undefined - ? {} - : { - commands: Array.isArray(action.commands) - ? action.commands.map((command: string) => - substituteBinName(command, cliName), - ) - : action.commands, - }), - })); + ...(Array.isArray(action.commands) + ? { + commands: action.commands.map((command: string) => + substituteBinName(command, cliName), + ), + } + : {}), + }; +} + +export function nextActionsWithBinName( + actions: readonly NextAction[], + cliName: string, +): readonly NextAction[] { + return Array.isArray(actions) + ? actions.map((action: NextAction) => + nextActionWithBinName(action, cliName), + ) + : actions; } export function diagnosticWithBinName( diagnostic: Diagnostic, cliName: string, ): Diagnostic { + if (!isRecord(diagnostic)) { + return diagnostic; + } return { ...diagnostic, summary: substituteBinName(diagnostic.summary, cliName), diff --git a/packages/cli-engine/tests/bin-placeholder-unvalidated.test.ts b/packages/cli-engine/tests/bin-placeholder-unvalidated.test.ts index 304606c4..802692f4 100644 --- a/packages/cli-engine/tests/bin-placeholder-unvalidated.test.ts +++ b/packages/cli-engine/tests/bin-placeholder-unvalidated.test.ts @@ -61,6 +61,16 @@ const CASES: Record CliStructuredError> = { }, ] as unknown as Diagnostic[], }), + "a next action that is null": () => + foreignError({ nextActions: [null, { kind: "done", label: "{bin}" }] }), + "a next action with fields of the wrong type": () => + foreignError({ + nextActions: [ + { kind: "run-command", label: 42, reason: 42, command: 42 }, + { kind: "run-command", label: "{bin}", commands: "{bin} status" }, + { kind: "run-command", label: "{bin}", commands: [42, null] }, + ], + }), }; function cli(build: () => CliStructuredError) { @@ -90,11 +100,13 @@ function errorOf(frames: readonly StreamEvent[]) { return last.envelope.error; } -/** A list of next actions that is missing has never rendered in human - * or markdown output, so those cases are asserted in json alone. */ +/** A list of next actions that is missing, or holds a null entry, has + * never rendered in human or markdown output, so those cases are + * asserted in json alone. */ const RENDERABLE = [ "an error without nextActions", "an accompanying finding whose why is not a string", + "a next action with fields of the wrong type", ]; describe.each(Object.entries(CASES))("%s", (name, build) => { From a9660924b5f306820087a613ad6d5c186f2867e6 Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 28 Sep 2026 10:29:19 +0200 Subject: [PATCH 6/6] fix(engine): human and markdown output tolerate malformed next actions A diagnostic or error reaches the renderers unvalidated. A missing next action list now renders as no next actions, an entry that is not an object is skipped, and markdown no longer fails on a command or commands of the wrong type. The json envelope still passes the original values through. This fault predates the {bin} substitution. Co-Authored-By: Claude Fable 5.1 Signed-off-by: willbot Signed-off-by: Will Madden --- packages/cli-engine/src/execution/markdown.ts | 8 +- .../cli-engine/src/execution/rendering.ts | 11 ++- .../tests/bin-placeholder-unvalidated.test.ts | 77 +++++++------------ 3 files changed, 44 insertions(+), 52 deletions(-) diff --git a/packages/cli-engine/src/execution/markdown.ts b/packages/cli-engine/src/execution/markdown.ts index edb7c96c..34c475b6 100644 --- a/packages/cli-engine/src/execution/markdown.ts +++ b/packages/cli-engine/src/execution/markdown.ts @@ -9,6 +9,7 @@ import { commentaryLine, MASK, PLACEHOLDER, + renderableNextActions, sentenceCase, withDocsUrl, } from "./rendering"; @@ -28,7 +29,8 @@ function longestBacktickRun(text: string): number { /** 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 { +export function codeSpan(value: string): string { + const text = String(value); const delimiter = "`".repeat(longestBacktickRun(text) + 1); const padded = text.startsWith("`") || text.endsWith("`") ? ` ${text} ` : text; @@ -116,7 +118,7 @@ function renderTreeNode(node: TreeNode, depth: number): string[] { export function renderNextActionMarkdown(action: NextAction): string[] { const target = action.command ?? action.url; - if (target === undefined && action.commands !== undefined) { + if (target === undefined && Array.isArray(action.commands)) { return [ `- ${action.label}`, ...action.commands.map((command) => ` - ${codeSpan(command)}`), @@ -163,7 +165,7 @@ export function renderDiagnosticMarkdown(diagnostic: Diagnostic): string[] { if (where !== undefined) { lines.push(where); } - for (const action of diagnostic.nextActions) { + for (const action of renderableNextActions(diagnostic.nextActions)) { lines.push(...renderNextActionMarkdown(action)); } if (diagnostic.docsUrl !== undefined) { diff --git a/packages/cli-engine/src/execution/rendering.ts b/packages/cli-engine/src/execution/rendering.ts index 730aa81e..dbf98e8d 100644 --- a/packages/cli-engine/src/execution/rendering.ts +++ b/packages/cli-engine/src/execution/rendering.ts @@ -310,7 +310,7 @@ export function writeDiagnostic( if (diagnostic.why !== undefined) { stream.write(` ${paint("muted", `why: ${diagnostic.why}`)}\n`); } - for (const action of diagnostic.nextActions) { + for (const action of renderableNextActions(diagnostic.nextActions)) { stream.write(`${renderNextAction(action, paint)}\n`); } if (diagnostic.docsUrl !== undefined) { @@ -320,6 +320,15 @@ export function writeDiagnostic( } } +/** A diagnostic reaches the renderers unvalidated: a list that is + * missing renders as no next actions, and an entry that is not an + * object is skipped. */ +export function renderableNextActions(actions: unknown): NextAction[] { + return Array.isArray(actions) + ? actions.filter((action) => typeof action === "object" && action !== null) + : []; +} + /** `label` is required, so a mapper building an action out of a bare * command string — a legacy error's follow-up step, with no prose * beside it — has nothing to put in the label but the command itself. diff --git a/packages/cli-engine/tests/bin-placeholder-unvalidated.test.ts b/packages/cli-engine/tests/bin-placeholder-unvalidated.test.ts index 802692f4..c252c09c 100644 --- a/packages/cli-engine/tests/bin-placeholder-unvalidated.test.ts +++ b/packages/cli-engine/tests/bin-placeholder-unvalidated.test.ts @@ -17,30 +17,26 @@ function foreignError(fields: Record): CliStructuredError { name: "CliStructuredError", code: "FOREIGN.FAILED", message: "Run `{bin} status`.", - toEnvelope: () => ({ - ok: false, - code: "FOREIGN.FAILED", - severity: "error", - summary: "Run `{bin} status`.", - nextActions: [], - }), + toEnvelope: () => envelope({ nextActions: fields.nextActions ?? [] }), ...fields, } as unknown as CliStructuredError; } +function envelope(fields: Record) { + return { + ok: false, + code: "FOREIGN.FAILED", + severity: "error", + summary: "Run `{bin} status`.", + ...fields, + }; +} + const CASES: Record CliStructuredError> = { "an error without nextActions": () => foreignError({ nextActions: undefined }), "an envelope without nextActions": () => - foreignError({ - nextActions: [], - toEnvelope: () => ({ - ok: false, - code: "FOREIGN.FAILED", - severity: "error", - summary: "Run `{bin} status`.", - }), - }), + foreignError({ nextActions: [], toEnvelope: () => envelope({}) }), "an accompanying finding without nextActions": () => foreignError({ nextActions: [], @@ -100,16 +96,7 @@ function errorOf(frames: readonly StreamEvent[]) { return last.envelope.error; } -/** A list of next actions that is missing, or holds a null entry, has - * never rendered in human or markdown output, so those cases are - * asserted in json alone. */ -const RENDERABLE = [ - "an error without nextActions", - "an accompanying finding whose why is not a string", - "a next action with fields of the wrong type", -]; - -describe.each(Object.entries(CASES))("%s", (name, build) => { +describe.each(Object.entries(CASES))("%s", (_name, build) => { describe.each(["thrown", "returned"])("%s by the handler", (command) => { test("json settles with the original error", async () => { const result = await cli(build).run([command, "--json"]); @@ -121,30 +108,24 @@ describe.each(Object.entries(CASES))("%s", (name, build) => { }); }); - test.runIf(RENDERABLE.includes(name))( - "human settles with the original error", - async () => { - const result = await cli(build).run([command], { - isTty: { stdout: true }, - }); + test("human settles with the original error", async () => { + const result = await cli(build).run([command], { + isTty: { stdout: true }, + }); - expect(result.exitCode).toBe(2); - expect(result.stderr).toContain( - "[FOREIGN.FAILED] Run `prisma-test status`.", - ); - }, - ); + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain( + "[FOREIGN.FAILED] Run `prisma-test status`.", + ); + }); - test.runIf(RENDERABLE.includes(name))( - "markdown settles with the original error", - async () => { - const result = await cli(build).run([command, "--format", "markdown"]); + test("markdown settles with the original error", async () => { + const result = await cli(build).run([command, "--format", "markdown"]); - expect(result.exitCode).toBe(2); - expect(result.stdout).toContain( - "FOREIGN.FAILED: Run `prisma-test status`.", - ); - }, - ); + expect(result.exitCode).toBe(2); + expect(result.stdout).toContain( + "FOREIGN.FAILED: Run `prisma-test status`.", + ); + }); }); });