Skip to content

Commit 5b9dbc6

Browse files
AmanVarshney01wmadden-electricclaude
authored
feat(engine): support structured delegated output (#184)
## What `maySpawn` currently forces human mode and rejects `--json`. That means `create-prisma` has to capture Composer's decorated output and regex a `.prisma.build` URL out of it. This change lets delegated commands use the engine's normal structured-output contract: - human/TTY mode is unchanged: the child inherits the terminal - JSON or non-TTY mode keeps stdin inherited but pipes both child output streams to diagnostic stderr - stdout remains framed NDJSON with exactly one terminal result - a non-zero child status keeps its verbatim process exit code and emits `CLI.CHILD_PROCESS_FAILED` with `{ exitCode, signal }` in `error.meta` - signal-killed children still drop reproduce guidance The pinned Composer family already presents deploy success as: ```json { "summary": { "app": "...", "nodes": [ { "address": "https://....prisma.build", "entities": [] } ] } } ``` With this change that result reaches a piped caller directly. A follow-up in `create-prisma` can parse the terminal frame and stop regexing human logs; it can also keep captured diagnostic logs hidden behind its deployment spinner and reveal them only on failure. ## Verification - `pnpm lint` - `pnpm -r --if-present typecheck` - `pnpm --filter @prisma/cli-engine exec vitest run --exclude tests/clack-prompts.test.ts` — 795 passed - `pnpm --filter @prisma/cli test` — 942 passed, 1 skipped - focused fake-child, real-child, shipped spawn-adapter, telemetry, and bin tests — 93 passed, 1 skipped - built CLI smoke test: `prisma composer deploy module.ts --json` accepts structured mode and emits a `composer.deploy` result frame `packages/cli-engine/tests/clack-prompts.test.ts` has one unrelated interactive prompt test that times out locally on current main as well; none of the changed code is in that path. --------- Signed-off-by: Aman Varshney <amanvarshney.work@gmail.com> Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io> Co-authored-by: willbot <w.a.madden+machine@gmail.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 1f6d49c commit 5b9dbc6

20 files changed

Lines changed: 725 additions & 179 deletions

File tree

.drive/projects/prisma-cli-v8/assets/engine/engine-interface-draft.ts

Lines changed: 11 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -33,11 +33,13 @@
3333
* Amended 2026-08-11 for S3 (the TERMINAL HANDOFF, contract
3434
* s3-composer.md): §4 gains ctx.spawn and §4c its shapes +
3535
* exitWithChildStatus; §6 gains the SpawnDeclarations (maySpawn) and
36-
* the two kind amendments (a maySpawn command rejects --json as soon
37-
* as the command is known — after routing, before the needs check and
38-
* before anything runs; a session settles non-zero through
36+
* the two kind amendments (a maySpawn command owns structured stdout
37+
* while routing child output to diagnostics in json mode; a session settles non-zero through
3938
* exitWithChildStatus and no other way); §10 gains Runtime.spawn. D1
4039
* rulings: abort-ladder grace 5s; near-expiry refusal threshold 5min.
40+
* Amended 2026-08-14: maySpawn commands now support json. Human mode
41+
* still delegates the terminal; json mode routes child output to
42+
* diagnostic stderr and preserves framed stdout through settlement.
4143
* Re-amended after the PR-136 review round: handing credentials to the
4244
* child is a PRECONDITION, `needs: { credentials: 'child' }` — the
4345
* separate credentialsForSpawn declaration is gone, and the entailment
@@ -1117,19 +1119,14 @@ export type Handler<
11171119
* S3: the terminal-handoff declaration, accepted by defineCommand and
11181120
* defineSessionCommand and normalized onto every definition (server
11191121
* commands normalize maySpawn to false: they own stdio already).
1120-
* `maySpawn` unlocks ctx.spawn and makes the command reject `--json`
1121-
* as soon as the command is known — after routing, before the needs
1122-
* check and before anything runs (the rule depends on which command
1123-
* was selected, so "parse time" was loose wording) — exit 2, stated
1124-
* in help (delegated terminal output cannot be framed). Handing
1122+
* `maySpawn` unlocks ctx.spawn. Human mode hands over the terminal;
1123+
* json mode routes the child's stdout and stderr to diagnostics while
1124+
* the engine retains framed stdout and emits a terminal result. Handing
11251125
* credentials to the child is a PRECONDITION, not a declaration:
11261126
* `needs: { credentials: 'child' }` (see NeedsSpec).
1127-
* Naming note (PR-136 review, considered and rejected): renaming
1128-
* maySpawn to delegatesTerminal would state the --json rule's premise
1129-
* directly, but the identifier is already woven through the S3 stack
1130-
* (D2/D3 handlers, tests, this draft) and the rename's churn was
1131-
* judged to outweigh the clarity gain. Do not re-open without new
1132-
* evidence.
1127+
* Naming note (PR-136 review): `maySpawn` remains the capability name;
1128+
* structured mode no longer delegates stdout, so `delegatesTerminal`
1129+
* would now be actively misleading.
11331130
*/
11341131
export interface SpawnDeclarations {
11351132
readonly maySpawn?: boolean

.drive/projects/prisma-cli-v8/assets/s2/parity-divergences-s3.md

Lines changed: 12 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -95,24 +95,18 @@ instead. Three things change for the user:
9595
handler asked for. Legacy collapsed a signalled child's status
9696
(`run-alchemy.ts:61`) and printed the hint anyway.
9797

98-
## `--json` is refused on `deploy`, `destroy` and `dev`; `log` gains it
99-
100-
No composer command had a json mode at all. The engine gives every
101-
command one, and these three give it back: they hand the terminal to a
102-
child process whose output cannot be framed, so `--json` (and
103-
`--format json`) is rejected **at parse time**, before the command does
104-
any work — `CLI.JSON_UNSUPPORTED`, exit 2, with the reason stated in the
105-
command's own help ("This command hands the terminal to another program
106-
and does not support --json.").
107-
108-
`log` never spawns and keeps json: its lines are its payload. So a
109-
composer user gains a machine-readable `log` and gains an explicit,
110-
early refusal on the other three, where before there was no flag to
111-
pass.
112-
113-
Note the interaction with format auto-selection: a piped
114-
`prisma composer deploy <entry>` does not silently become a json run —
115-
it stays human, because json is refused rather than auto-selected.
98+
## `deploy`, `destroy`, `dev`, and `log` support structured output
99+
100+
Amended 2026-08-14: `maySpawn` no longer disables JSON. In human mode the
101+
child still inherits the terminal. In JSON mode its stdout and stderr are
102+
routed to diagnostic stderr while the engine retains framed NDJSON stdout and
103+
emits the command family's terminal result. A failed child keeps its verbatim
104+
process exit code and emits `CLI.CHILD_PROCESS_FAILED` with the child status in
105+
`error.meta`.
106+
107+
This also restores normal format auto-selection: a piped
108+
`prisma composer deploy <entry>` produces structured output, including
109+
Composer's deployment summary, without requiring an explicit flag.
116110

117111
## Usage, parse errors and bare invocation
118112

.drive/projects/prisma-cli-v8/specs/s3-composer.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,8 +100,9 @@ const child = await ctx.spawn({ command, args, cwd, env });
100100
through this path (it used to hard-code 0, and now also settles
101101
130/143 on its own — below),
102102
and the session-kind "always supports json" guarantee is amended:
103-
a command that may spawn rejects `--json` at PARSE time
104-
(delegated terminal output cannot be framed; stated in help).
103+
a command that may spawn keeps stdout framed in JSON mode by routing the
104+
child's output to diagnostic stderr; the session then emits its normal
105+
terminal result frame.
105106
- **Exit codes are the engine's** (operator ruling, 2026-08-11): a
106107
run a delivered signal terminated settles 128+signal from the
107108
ENGINE's own record of that signal, for both command kinds and

docs/product/output-conventions.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,23 @@ Rules:
2929
- never write decorative or human-only output to stdout
3030
- when `--json` is active, stdout must contain only structured output
3131

32+
### Delegated child processes
33+
34+
Commands that declare `maySpawn` use the same format rules as every other
35+
command:
36+
37+
- in human mode, the child inherits stdin, stdout, and stderr
38+
- in JSON mode, stdin remains inherited while the child's stdout and stderr
39+
are routed to the CLI's diagnostic stderr stream
40+
- JSON stdout remains an NDJSON event stream with exactly one terminal result
41+
frame
42+
- a non-zero child status is preserved as the process exit code and is
43+
represented by `CLI.CHILD_PROCESS_FAILED`, with `exitCode` and `signal` in
44+
`error.meta`
45+
46+
This lets automation consume a command family's structured result without
47+
having to parse the delegated tool's human output.
48+
3249
## TTY and Piped Behavior
3350

3451
Interactive TTY behavior:

packages/cli-engine/src/commands.ts

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -123,10 +123,9 @@ function normalizeArgs<
123123
* The terminal-handoff declaration, normalized onto every definition
124124
* (server commands normalize to false: they own stdio already).
125125
*
126-
* `maySpawn` unlocks ctx.spawn and makes the command reject `--json`
127-
* as soon as the command is known, before anything runs: output
128-
* delegated to a child process cannot be framed. Handing credentials
129-
* to the child is a precondition, declared as
126+
* `maySpawn` unlocks ctx.spawn. In human mode the child inherits the
127+
* terminal; in json mode its output is routed to diagnostics so stdout
128+
* remains framed. Handing credentials to the child is a precondition, declared as
130129
* `needs: { credentials: "child" }`.
131130
*/
132131
export interface SpawnDeclarations {
@@ -297,10 +296,9 @@ export function defineCommand<
297296
* speaks entirely through events, returns Result<void>. No
298297
* presentation, no exit-code set.
299298
*
300-
* A session supports json mode — the event stream is its json surface —
301-
* UNLESS it declares `maySpawn`, in which case it rejects --json as soon
302-
* as the command is known, before anything runs (S3): output delegated
303-
* to a child process cannot be framed. A session that returns
299+
* A session supports json mode — the event stream is its json surface.
300+
* When it declares `maySpawn`, child output is routed to diagnostics while
301+
* the engine retains ownership of structured stdout. A session that returns
304302
* ok(undefined) exits 0 — or 130/143 when a signal ended the run, which
305303
* the engine settles from its own record of that signal, not from
306304
* anything the handler returns; one that returns

packages/cli-engine/src/execution/engine.ts

Lines changed: 4 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import type { ActiveCredential } from "../credential-manager";
1515
import type { EngineEvent, Severity, StreamEvent } from "../events";
1616
import type { ManagementApiClient } from "../management-api";
1717
import type { Format, PresentedResult } from "../presentation";
18-
import { CliStructuredError, type Result } from "../protocol";
18+
import type { CliStructuredError, Result } from "../protocol";
1919
import type { EngineCommandSnapshot, RunSummary } from "../run-summary";
2020
import type { InputStream, Runtime } from "../runtime";
2121
import {
@@ -45,11 +45,7 @@ import {
4545
renderHelp,
4646
} from "./help";
4747
import { checkNeeds, type NeedsOutcome } from "./needs";
48-
import {
49-
configFlagGivenNoValue,
50-
formatFlagGiven,
51-
versionFlagGiven,
52-
} from "./pre-parse-argv";
48+
import { configFlagGivenNoValue, versionFlagGiven } from "./pre-parse-argv";
5349
import {
5450
commandSegments,
5551
settleBug,
@@ -520,10 +516,8 @@ export class EngineImpl implements Engine {
520516
}
521517
}
522518

523-
/** The refusals a maySpawn command can hit before anything runs: a
524-
* child's terminal output cannot be framed as a json stream, and a
525-
* host that mounts the command without a spawn adapter is
526-
* misconfigured. Returns whether the run was settled here. */
519+
/** A host that mounts a maySpawn command without a spawn adapter is
520+
* misconfigured. Refuse before needs or handler side effects. */
527521
private refuseUnspawnable(
528522
invocation: Invocation,
529523
entry: CommandTreeEntry,
@@ -532,11 +526,6 @@ export class EngineImpl implements Engine {
532526
if (!entry.def.maySpawn) {
533527
return false;
534528
}
535-
if (formatFlagGiven(state.argv) === "json") {
536-
state.format = "human";
537-
settleErrored(invocation, jsonUnsupportedError(entry.id));
538-
return true;
539-
}
540529
if (invocation.runtime.spawn === undefined) {
541530
// The run is doomed, and refuses here, before the needs check,
542531
// rather than mid-handler after side effects.
@@ -632,9 +621,6 @@ export class EngineImpl implements Engine {
632621
let needsOutcome: NeedsOutcome;
633622
try {
634623
applySharedFlags(state, rawFlags as SharedFlags, invocation.runtime);
635-
if (entry.def.maySpawn) {
636-
state.format = "human";
637-
}
638624
needsOutcome = await checkNeeds(entry.def, invocation);
639625
} catch (cause) {
640626
// The child preflight can be awaiting the token endpoint when the
@@ -794,25 +780,6 @@ function declaredConfigSections(spec: EngineSpec): readonly string[] {
794780
];
795781
}
796782

797-
/** A command that may hand the terminal to a child cannot frame its
798-
* output, so json is refused as soon as the command is known — before
799-
* the needs check and before anything runs. */
800-
function jsonUnsupportedError(commandId: string): CliStructuredError {
801-
return new CliStructuredError(
802-
"CLI.JSON_UNSUPPORTED",
803-
`The '${commandId.replaceAll(".", " ")}' command does not support json output.`,
804-
{
805-
why: "It hands the terminal to another program, whose output cannot be framed as a json stream.",
806-
nextActions: [
807-
{
808-
kind: "user-choice",
809-
label: "Run it without --json or --format json.",
810-
},
811-
],
812-
},
813-
);
814-
}
815-
816783
function declaredCapabilities(def: AnyCommand): CommandCapabilities {
817784
if (def.kind !== "result-command") {
818785
return { managesCredentials: false, installsPackages: false };

packages/cli-engine/src/execution/help.ts

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import type { CommandTreeEntry, CommandTreeNode } from "./command-tree";
1717
import type { EngineSpec } from "./engine";
1818
import { makePaint, type Paint, textWidth } from "./palette";
1919
import { SHARED_ALIASES, SHARED_FLAG_PARAMETERS } from "./shared-flags";
20-
import { NO_JSON_NOTE, resolveExample } from "./stricli-adapter";
20+
import { resolveExample } from "./stricli-adapter";
2121

2222
const RAIL = "│";
2323
const GAP = " ";
@@ -487,13 +487,6 @@ function renderLeafHelp(
487487
lines.push(rail(paint));
488488
proseLines(def.help.description, paint, lines);
489489
}
490-
if (def.maySpawn) {
491-
lines.push(rail(paint));
492-
// One line on purpose: the sentence is the contract several tests
493-
// and consumers grep for, so it never wraps.
494-
lines.push(rail(paint, paint("muted", NO_JSON_NOTE)));
495-
}
496-
497490
const positionalEntries = Object.values<PositionalSpec<unknown>>(
498491
def.args.positionals,
499492
).map((spec) => positionalRuntime(spec));

packages/cli-engine/src/execution/reporting.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,10 +44,12 @@ export function reportEvent(invocation: Invocation, event: EngineEvent): void {
4444
reportAfterResolution(invocation);
4545
return;
4646
}
47-
// No engine write may interleave with a delegated terminal's child
48-
// output; the buffer is flushed in order when the child ends.
47+
// In human mode the child owns the terminal, so no engine write may
48+
// interleave with its output; the buffer is flushed in order when the
49+
// child ends. In json mode the engine keeps stdout (the child is
50+
// routed to diagnostic stderr), so frames stream live.
4951
const terminal = state.delegatedTerminal;
50-
if (terminal !== undefined) {
52+
if (terminal !== undefined && state.format !== "json") {
5153
if (terminal.buffered.length >= SPAWN_COMMENTARY_BUFFER_CAP) {
5254
terminal.dropped += 1;
5355
return;

packages/cli-engine/src/execution/settlement.ts

Lines changed: 52 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -211,9 +211,8 @@ export function settleVerbatimExitCode(
211211
* The status comes from the engine's own record of the child, never
212212
* from the handler, so there is nothing here for a handler to state.
213213
* Two conditions fence it, both construction errors: the command must
214-
* hand the terminal to another program — reachable from a
215-
* non-declaring handler this would also end a json stream without its
216-
* terminal result frame — and a child must actually have run.
214+
* declare that it can hand execution to another program, and a child must
215+
* actually have run.
217216
*
218217
* A signal-killed child overrules whatever the handler asked for. The
219218
* user stopped the run: it settles 128 + the signal number, with no
@@ -235,15 +234,63 @@ export function settleChildStatus(
235234
`@prisma/cli-engine: command '${invocation.state.commandId}' returned exitWithChildStatus without a child having run — that settlement reports the status of a child ctx.spawn started, and this run started none`,
236235
);
237236
}
238-
// Only human format is reachable here: maySpawn forces it.
237+
const exitCode = childExitCode(child);
238+
if (invocation.state.format === "json") {
239+
settleStructuredChildStatus(invocation, settlement, child, exitCode);
240+
return;
241+
}
239242
if (child.signal === null) {
240243
for (const action of settlement.nextActions) {
241244
invocation.runtime.stderr.write(
242245
`${renderNextAction(action, makePaint(invocation.state.colorEnabled))}\n`,
243246
);
244247
}
245248
}
246-
settleVerbatimExitCode(invocation, childExitCode(child));
249+
settleVerbatimExitCode(invocation, exitCode);
250+
}
251+
252+
function settleStructuredChildStatus(
253+
invocation: Invocation,
254+
settlement: ChildStatusSettlement,
255+
child: { readonly exitCode: number | null; readonly signal: string | null },
256+
exitCode: number,
257+
): void {
258+
settleVerbatimExitCode(invocation, exitCode);
259+
const nextActions = child.signal === null ? settlement.nextActions : [];
260+
if (exitCode === 0) {
261+
const envelope: CompletedEnvelope = {
262+
ok: true,
263+
commandId: invocation.state.commandId,
264+
result: null,
265+
exitCode,
266+
diagnostics: [],
267+
nextActions,
268+
};
269+
emitFrame(invocation, {
270+
kind: "result",
271+
envelope,
272+
commandId: invocation.state.commandId,
273+
timestamp: invocation.now().toISOString(),
274+
});
275+
return;
276+
}
277+
const how =
278+
child.signal === null
279+
? `exited with code ${String(child.exitCode ?? "unknown")}`
280+
: `was terminated by ${child.signal}`;
281+
emitErrored(invocation, {
282+
ok: false,
283+
commandId: invocation.state.commandId,
284+
error: {
285+
code: "CLI.CHILD_PROCESS_FAILED",
286+
severity: "error",
287+
summary: `The delegated process ${how}.`,
288+
nextActions,
289+
meta: { exitCode: child.exitCode, signal: child.signal },
290+
},
291+
diagnostics: [],
292+
nextActions,
293+
});
247294
}
248295

249296
/** A session command that returned ok shut down cleanly: no

packages/cli-engine/src/execution/spawn.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,7 @@ async function runDelegated(
138138
args: options.args ?? [],
139139
cwd: options.cwd ?? invocation.runtime.cwd,
140140
env: await composeChildEnv(invocation, def, options.env),
141+
output: state.format === "json" ? "diagnostic" : "inherit",
141142
};
142143
debug(
143144
`spawn: ${request.command} ${request.args.join(" ")} (cwd ${request.cwd})`,

0 commit comments

Comments
 (0)