diff --git a/docs/product/output-conventions.md b/docs/product/output-conventions.md index 9a059653..2b5168bf 100644 --- a/docs/product/output-conventions.md +++ b/docs/product/output-conventions.md @@ -42,6 +42,9 @@ command: - a non-zero child status is preserved as the process exit code and is represented by `CLI.CHILD_PROCESS_FAILED`, with `exitCode` and `signal` in `error.meta` +- a command may attach its own structured error (`exitWithChildStatus({ error })`); + for a child that exited non-zero the JSON result carries it in place of + `CLI.CHILD_PROCESS_FAILED`, keeping the child's exit code and those two `meta` keys This lets automation consume a command family's structured result without having to parse the delegated tool's human output. diff --git a/docs/reference/error-reference.md b/docs/reference/error-reference.md index 75964ad0..a0ed5d89 100644 --- a/docs/reference/error-reference.md +++ b/docs/reference/error-reference.md @@ -106,7 +106,7 @@ A `ctx.prompt.browserWait` flow (the command opened a URL and polled for the use ### CLI.CHILD_PROCESS_FAILED -Emitted only as a json-mode error envelope when a command that handed the terminal to a child process (`exitWithChildStatus`) saw that child exit non-zero or die on a signal; the run's exit code is the child's own status verbatim, not the CLI's usual 2. Meta: `exitCode`, `signal`. +Emitted only as a json-mode error envelope when a command that handed the terminal to a child process (`exitWithChildStatus`) saw that child exit non-zero or die on a signal; the run's exit code is the child's own status verbatim, not the CLI's usual 2. A command that attaches its own error to that settlement replaces this code for a child that exited non-zero. Meta: `exitCode`, `signal`. ### CLI.COMMAND_MOVED diff --git a/packages/cli-engine/package.json b/packages/cli-engine/package.json index 35b995b5..4ec40fd6 100644 --- a/packages/cli-engine/package.json +++ b/packages/cli-engine/package.json @@ -1,6 +1,6 @@ { "name": "@prisma/cli-engine", - "version": "0.4.0", + "version": "0.4.1", "description": "The execution engine of the unified Prisma CLI.", "type": "module", "exports": { diff --git a/packages/cli-engine/src/execution/settlement.ts b/packages/cli-engine/src/execution/settlement.ts index 2ae47a6f..11dd8205 100644 --- a/packages/cli-engine/src/execution/settlement.ts +++ b/packages/cli-engine/src/execution/settlement.ts @@ -293,6 +293,26 @@ function settleStructuredChildStatus( }); return; } + const status = { exitCode: child.exitCode, signal: child.signal }; + // A signal-killed child drops the command's error with its next actions. + const attached = child.signal === null ? settlement.error : undefined; + if (attached !== undefined) { + const error = diagnosticOf(attached); + const actions = [...error.nextActions, ...nextActions]; + emitErrored(invocation, { + ok: false, + commandId: invocation.state.commandId, + // The engine's record of the child wins over the handler's meta. + error: { + ...error, + nextActions: actions, + meta: { ...error.meta, ...status }, + }, + diagnostics: accompanyingFindings(attached.diagnostics), + nextActions: actions, + }); + return; + } const how = child.signal === null ? `exited with code ${String(child.exitCode ?? "unknown")}` @@ -305,7 +325,7 @@ function settleStructuredChildStatus( severity: "error", summary: `The delegated process ${how}.`, nextActions, - meta: { exitCode: child.exitCode, signal: child.signal }, + meta: status, }, diagnostics: [], nextActions, diff --git a/packages/cli-engine/src/spawn.ts b/packages/cli-engine/src/spawn.ts index bd36f396..7ff3f733 100644 --- a/packages/cli-engine/src/spawn.ts +++ b/packages/cli-engine/src/spawn.ts @@ -3,7 +3,7 @@ * the child-status settlement are built from. The engine never imports * node:child_process — the bin injects an adapter satisfying SpawnChild. */ -import type { NextAction } from "./protocol"; +import type { CliStructuredError, NextAction } from "./protocol"; /** A fully composed child invocation. `env` is the child's COMPLETE * environment: the engine has already merged the invocation @@ -76,6 +76,7 @@ export const CHILD_STATUS: unique symbol = Symbol.for( export interface ChildStatusSettlement { readonly [CHILD_STATUS]: true; readonly nextActions: readonly NextAction[]; + readonly error: CliStructuredError | undefined; } export interface ExitWithChildStatusOptions { @@ -84,6 +85,10 @@ export interface ExitWithChildStatusOptions { * a signal-killed child drops these entirely: the user stopped the * run, so there is nothing to reproduce. */ readonly nextActions?: readonly NextAction[]; + /** The command's own structured error for a failed child. It replaces + * CLI.CHILD_PROCESS_FAILED in the json envelope only; the exit code + * stays the child's. Ignored when the child exited 0 or was signalled. */ + readonly error?: CliStructuredError; } /** Signal numbers shared by Linux, macOS and the BSDs. Numbers that @@ -133,6 +138,7 @@ export function exitWithChildStatus( return Object.freeze({ [CHILD_STATUS]: true as const, nextActions: Object.freeze([...(options?.nextActions ?? [])]), + error: options?.error, }); } diff --git a/packages/cli-engine/tests/spawn.test.ts b/packages/cli-engine/tests/spawn.test.ts index 2430a362..61388048 100644 --- a/packages/cli-engine/tests/spawn.test.ts +++ b/packages/cli-engine/tests/spawn.test.ts @@ -11,7 +11,7 @@ import { exitWithChildStatus, type Runtime, } from "@prisma/cli-engine"; -import { ok } from "@prisma/cli-engine/protocol"; +import { CliStructuredError, ok } from "@prisma/cli-engine/protocol"; import { createTestCli, mintTestJwt, @@ -1423,6 +1423,75 @@ describe("next actions on a child-status settlement", () => { }); }); +describe("a structured error on a child-status settlement", () => { + const deploy = defineCommand({ + help: { summary: "A converge that knows why its child failed" }, + maySpawn: true, + handler: async (_args, ctx) => { + await ctx.spawn({ command: "alchemy" }); + return ok( + exitWithChildStatus({ + error: new CliStructuredError("DEPLOY.ENGINE_FAILED", "Failed.", { + meta: { stackFilePath: "/app/stack.ts", exitCode: 99 }, + }), + }), + ); + }, + }); + + async function settle(child: { + readonly exitCode: number | null; + readonly signal: string | null; + }) { + const cli = createTestCli({ + commands: { deploy }, + now: CLOCK, + spawnScript: () => child, + }); + return cli.run(["deploy", "--json"]); + } + + test("json carries the command's error and exits with the child's code", async () => { + const result = await settle({ exitCode: 3, signal: null }); + + expect(result.exitCode).toBe(3); + expect(result.json.at(-1)).toMatchObject({ + envelope: { + ok: false, + error: { + code: "DEPLOY.ENGINE_FAILED", + summary: "Failed.", + meta: { stackFilePath: "/app/stack.ts", exitCode: 3, signal: null }, + }, + }, + }); + }); + + test("a signal-killed child is still CLI.CHILD_PROCESS_FAILED", async () => { + const result = await settle({ exitCode: null, signal: "SIGINT" }); + + expect(result.exitCode).toBe(130); + expect(result.json.at(-1)).toMatchObject({ + envelope: { + ok: false, + error: { + code: "CLI.CHILD_PROCESS_FAILED", + meta: { exitCode: null, signal: "SIGINT" }, + }, + }, + }); + }); + + test("a child that exited 0 settles ok", async () => { + const result = await settle({ exitCode: 0, signal: null }); + + expect(result.exitCode).toBe(0); + expect(result.json.at(-1)).toMatchObject({ + envelope: { ok: true, result: null, exitCode: 0 }, + }); + }); +}); + describe("unknown terminations are never success", () => { test("an adapter that cannot say how the child ended settles 1", async () => { const cli = createTestCli({ diff --git a/packages/cli/package.json b/packages/cli/package.json index 20c240fe..4881c190 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.4.0", + "@prisma/cli-engine": "workspace:0.4.1", "@prisma/composer-cli": "0.20.0", "@prisma/compute-sdk": "0.42.0", "@prisma/management-api-sdk": "1.69.0", diff --git a/packages/prisma/package.json b/packages/prisma/package.json index 2f69d6a1..1fd64508 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.4.0", + "@prisma/cli-engine": "workspace:0.4.1", "@prisma/composer-cli": "0.20.0", "@prisma/compute-sdk": "0.42.0", "@prisma/management-api-sdk": "1.69.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8aadcbc8..de8474fd 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.4.0 + specifier: workspace:0.4.1 version: link:../cli-engine '@prisma/composer-cli': specifier: 0.20.0 @@ -207,7 +207,7 @@ importers: specifier: ^2.1.2 version: 2.1.2 '@prisma/cli-engine': - specifier: workspace:0.4.0 + specifier: workspace:0.4.1 version: link:../cli-engine '@prisma/composer-cli': specifier: 0.20.0