From 1f9f6ce726af5e79e4a1ce4ad15f6ae4f8434014 Mon Sep 17 00:00:00 2001 From: willbot Date: Wed, 12 Aug 2026 20:00:05 +0200 Subject: [PATCH 1/4] refactor(cli-engine): every presentation a command declares is required MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Presentations` made `stdout`, `json` and `next` optional, so a command that omitted `json` got its `--json` contract by accident: the engine published the handler's internal `data` object as the envelope's `result`. The surface machine consumers depend on was set by omission rather than by choice. All four are now required, and every command states what it publishes on each surface. Behaviour is preserved exactly: where a command had no `json` it now returns what the handler passes as `data`, because that is what the envelope carried; where it had no `stdout` or `next` it returns an empty array, because that is what it wrote. The engine's settlement fallback is gone — `result` is the json presentation, not a substitution. One fallback stays, and it is commented where it lives: `materializePresentation` still calls `next` optionally, because @prisma/orm-toolchain@8.0.0-rc.1-dev.40 is compiled against an earlier engine and declares no `next` for `migration list`. Requiring it makes that command exit 2. `stdout` and `json` are required at runtime, which both published families already satisfy. Co-Authored-By: Claude Opus 5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../src/execution/command-context.ts | 17 +- .../cli-engine/src/execution/settlement.ts | 5 +- packages/cli-engine/src/presentation.ts | 22 +-- packages/cli-engine/src/telemetry/commands.ts | 2 + packages/cli-engine/tests/blocks.test.ts | 18 ++- .../cli-engine/tests/clack-isolation.test.ts | 3 + .../cli-engine/tests/clack-prompts.test.ts | 3 + .../tests/command-capabilities.test-d.ts | 126 +++++++++++++-- .../cli-engine/tests/command-families.test.ts | 19 ++- packages/cli-engine/tests/config.test.ts | 39 ++++- .../tests/credential-manager.test.ts | 65 +++++++- packages/cli-engine/tests/engine.type-test.ts | 21 ++- packages/cli-engine/tests/events.test.ts | 5 + packages/cli-engine/tests/execution.test.ts | 122 +++++++++++++-- .../tests/interaction-affordances.test.ts | 3 + packages/cli-engine/tests/lifetimes.test.ts | 83 +++++++++- .../cli-engine/tests/management-api.test.ts | 12 +- .../tests/package-install-matrix.test.ts | 9 +- .../tests/package-operations.test.ts | 96 +++++++++++- packages/cli-engine/tests/palette.test.ts | 3 + packages/cli-engine/tests/prompts.test.ts | 6 + packages/cli-engine/tests/redirects.test.ts | 19 ++- packages/cli-engine/tests/run-hooks.test.ts | 7 +- packages/cli-engine/tests/spawn.test.ts | 147 ++++++++++++++++-- .../tests/telemetry-payload.test.ts | 7 +- .../cli-engine/tests/telemetry-run.test.ts | 28 +++- .../cli/src/commands/agent/presentation.ts | 4 + packages/cli/src/commands/auth/login.ts | 27 ++-- packages/cli/src/commands/auth/logout.ts | 1 + packages/cli/src/commands/auth/whoami.ts | 17 +- .../cli/src/commands/auth/workspace-logout.ts | 27 ++-- .../cli/src/commands/auth/workspace-use.ts | 27 ++-- packages/cli/src/commands/branch/list.ts | 2 + packages/cli/src/commands/bucket/create.ts | 3 + packages/cli/src/commands/bucket/delete.ts | 3 + .../cli/src/commands/bucket/key-create.ts | 2 + .../cli/src/commands/bucket/key-delete.ts | 3 + packages/cli/src/commands/bucket/key-list.ts | 1 + packages/cli/src/commands/bucket/list.ts | 1 + packages/cli/src/commands/feedback.ts | 3 + packages/cli/src/commands/git/connect.ts | 3 + packages/cli/src/commands/git/disconnect.ts | 3 + .../cli/src/commands/init/presentation.ts | 1 + .../cli/src/commands/postgres/backup-list.ts | 1 + .../commands/postgres/connection-create.ts | 2 + .../src/commands/postgres/connection-list.ts | 1 + .../commands/postgres/connection-remove.ts | 2 + .../commands/postgres/connection-rotate.ts | 2 + packages/cli/src/commands/postgres/create.ts | 2 + packages/cli/src/commands/postgres/list.ts | 1 + packages/cli/src/commands/postgres/remove.ts | 3 + packages/cli/src/commands/postgres/restore.ts | 2 + packages/cli/src/commands/postgres/show.ts | 2 + packages/cli/src/commands/postgres/usage.ts | 2 + packages/cli/src/commands/project/env-add.ts | 20 ++- .../cli/src/commands/project/env-remove.ts | 3 + .../cli/src/commands/project/env-shared.ts | 20 ++- .../cli/src/commands/project/env-update.ts | 20 ++- .../cli/src/commands/project/presentation.ts | 1 + packages/cli/src/commands/project/remove.ts | 3 + packages/cli/src/commands/project/rename.ts | 3 + packages/cli/src/commands/project/show.ts | 1 + packages/cli/src/commands/project/transfer.ts | 2 + .../cli/src/commands/service/presentation.ts | 35 +++++ packages/cli/tests/auth.test.ts | 26 +++- packages/cli/tests/golden-rendering.test.ts | 12 +- packages/cli/tests/mount-coverage.test.ts | 12 +- packages/cli/tests/whoami.test.ts | 7 +- 68 files changed, 1044 insertions(+), 156 deletions(-) diff --git a/packages/cli-engine/src/execution/command-context.ts b/packages/cli-engine/src/execution/command-context.ts index 44c53d63..faa1dd57 100644 --- a/packages/cli-engine/src/execution/command-context.ts +++ b/packages/cli-engine/src/execution/command-context.ts @@ -53,7 +53,18 @@ export function makeUi(colorEnabled: boolean, stderr: OutputStream): Ui { } /** Materializes ONLY the active format's presentation functions, at the - * return site: human → human + stdout + next; json → json + next. */ + * return site: human → human + stdout + next; json → json + next. + * + * `next` is still called optionally, and it is the one field here that + * is. `Presentations` requires all four, so nothing compiled against + * this engine can omit one — but a family compiled against an earlier + * engine can, and one does: `@prisma/orm-toolchain@8.0.0-rc.1-dev.40` + * declares `human`, `stdout` and `json` for `migration list` and no + * `next`. Calling it unconditionally makes that command exit 2. + * `stdout` and `json` are called unconditionally because both published + * families already declare them. Drop the `?? []` when the toolchain and + * composer pins converge on this engine — the tandem release + * `deferred.md` describes. */ function materializePresentation( state: RunState, ui: Ui, @@ -63,13 +74,13 @@ function materializePresentation( return { human: [], stdout: [], - json: presentations.json?.(), + json: presentations.json(), next: presentations.next?.() ?? [], }; } return { human: presentations.human(ui), - stdout: presentations.stdout?.() ?? [], + stdout: presentations.stdout(), json: undefined, next: presentations.next?.() ?? [], }; diff --git a/packages/cli-engine/src/execution/settlement.ts b/packages/cli-engine/src/execution/settlement.ts index e5bfb619..e654d6d1 100644 --- a/packages/cli-engine/src/execution/settlement.ts +++ b/packages/cli-engine/src/execution/settlement.ts @@ -71,10 +71,7 @@ export function settleCompleted( const envelope: CompletedEnvelope = { ok: true, commandId: state.commandId, - result: - presented.presentation.json === undefined - ? presented.data - : presented.presentation.json, + result: presented.presentation.json, exitCode, diagnostics: presented.diagnostics.map((diagnostic) => withDocsUrl(state, diagnostic), diff --git a/packages/cli-engine/src/presentation.ts b/packages/cli-engine/src/presentation.ts index 5570ed58..8c563d94 100644 --- a/packages/cli-engine/src/presentation.ts +++ b/packages/cli-engine/src/presentation.ts @@ -40,9 +40,8 @@ export interface PresentedResult { readonly diagnostics: readonly Diagnostic[]; /** * Only the active format's presentation is materialized; the other - * format's fields are normalized to empty. `json` stays undefined - * when the handler supplied no json presentation — the envelope's - * `result` then falls back to `data`. + * format's fields are normalized to empty. In human mode `json` is + * undefined because the json presentation was never invoked. */ readonly presentation: { readonly human: readonly Block[]; @@ -54,16 +53,19 @@ export interface PresentedResult { /** * The per-format presentation functions a handler supplies to - * ctx.present. Only the active format's functions are invoked, at the - * return site. `human` composes engine primitives, rendered to stderr; - * `stdout` is the machine-consumable data lines — what a pipe - * receives, the human mode's only stdout writes. + * ctx.present. Every one is required: a command states each output + * surface it publishes rather than inheriting one by omission. Only the + * active format's functions are invoked, at the return site. `human` + * composes engine primitives, rendered to stderr; `stdout` is the + * machine-consumable data lines — what a pipe receives, the human + * mode's only stdout writes; `json` is the `--json` envelope's + * `result`; `next` is the suggested follow-up actions. */ export interface Presentations { readonly human: (ui: Ui) => readonly Block[]; - readonly stdout?: () => readonly string[]; - readonly json?: () => unknown; - readonly next?: () => readonly NextAction[]; + readonly stdout: () => readonly string[]; + readonly json: () => unknown; + readonly next: () => readonly NextAction[]; } /** diff --git a/packages/cli-engine/src/telemetry/commands.ts b/packages/cli-engine/src/telemetry/commands.ts index 8ddf7629..ae433402 100644 --- a/packages/cli-engine/src/telemetry/commands.ts +++ b/packages/cli-engine/src/telemetry/commands.ts @@ -99,6 +99,7 @@ function statusPresentations(status: TelemetryStatus): Presentations { ], stdout: () => [...formatTelemetryStatusLines(status)], json: () => status, + next: () => [], }; } @@ -109,6 +110,7 @@ function consentPresentations(line: string, json: unknown): Presentations { human: () => [{ kind: "summary", status: "ok", text: line }], stdout: () => [line], json: () => json, + next: () => [], }; } diff --git a/packages/cli-engine/tests/blocks.test.ts b/packages/cli-engine/tests/blocks.test.ts index ae249302..edf64d42 100644 --- a/packages/cli-engine/tests/blocks.test.ts +++ b/packages/cli-engine/tests/blocks.test.ts @@ -25,7 +25,17 @@ async function render( const show = defineCommand({ help: { summary: "Render the fixture blocks" }, handler: async (_args, ctx) => - ok(ctx.present({ data: null }, { human: () => blocks })), + ok( + ctx.present( + { data: null }, + { + human: () => blocks, + stdout: () => [], + json: () => null, + next: () => [], + }, + ), + ), }); const result = await createTestCli({ commands: { show } }).run( [ @@ -323,6 +333,9 @@ describe("one character per meaning", () => { human: () => [ { kind: "summary", status: "error", text: "Failed." }, ], + stdout: () => [], + json: () => null, + next: () => [], }, ), ); @@ -371,6 +384,9 @@ describe("one character per meaning", () => { { kind: "summary", status: "warn", text: "Slowly." }, { kind: "summary", status: "info", text: "Noted." }, ], + stdout: () => [], + json: () => null, + next: () => [], }, ), ); diff --git a/packages/cli-engine/tests/clack-isolation.test.ts b/packages/cli-engine/tests/clack-isolation.test.ts index c90d4790..9eb40e7b 100644 --- a/packages/cli-engine/tests/clack-isolation.test.ts +++ b/packages/cli-engine/tests/clack-isolation.test.ts @@ -37,6 +37,9 @@ function promptCommand(run: (prompt: PromptSurface) => Promise) { human: (): readonly Block[] => [ { kind: "summary", status: "ok", text: `answer=${answer}` }, ], + stdout: () => [], + json: () => ({ answer }), + next: () => [], }, ), ); diff --git a/packages/cli-engine/tests/clack-prompts.test.ts b/packages/cli-engine/tests/clack-prompts.test.ts index 70717d4f..dfca2c26 100644 --- a/packages/cli-engine/tests/clack-prompts.test.ts +++ b/packages/cli-engine/tests/clack-prompts.test.ts @@ -64,6 +64,9 @@ function promptCli(run: (prompt: PromptSurface) => Promise) { text: `answer=${JSON.stringify(answer)}`, }, ], + stdout: () => [], + json: () => ({ answer }), + next: () => [], }, ), ); diff --git a/packages/cli-engine/tests/command-capabilities.test-d.ts b/packages/cli-engine/tests/command-capabilities.test-d.ts index 5957dbd9..35c04226 100644 --- a/packages/cli-engine/tests/command-capabilities.test-d.ts +++ b/packages/cli-engine/tests/command-capabilities.test-d.ts @@ -36,7 +36,17 @@ test("installsPackages alone puts ctx.packages on the context and nothing else", expectTypeOf(ctx.packages).toEqualTypeOf(); // @ts-expect-error managesCredentials was not declared void ctx.credentialManager; - return ok(ctx.present({ data: null }, { human: () => [] })); + return ok( + ctx.present( + { data: null }, + { + human: () => [], + stdout: () => [], + json: () => null, + next: () => [], + }, + ), + ); }, }); @@ -51,7 +61,12 @@ test("installsPackages alone puts ctx.packages on the context and nothing else", expectTypeOf(ctx.packages).toEqualTypeOf(); // @ts-expect-error managesCredentials was not declared void ctx.credentialManager; - return ok(ctx.present({ data: null }, { human: () => [] })); + return ok( + ctx.present( + { data: null }, + { human: () => [], stdout: () => [], json: () => null, next: () => [] }, + ), + ); }; expectTypeOf(annotated).toEqualTypeOf(); }); @@ -64,7 +79,17 @@ test("managesCredentials alone puts ctx.credentialManager on the context and not expectTypeOf(ctx.credentialManager).toEqualTypeOf(); // @ts-expect-error installsPackages was not declared void ctx.packages; - return ok(ctx.present({ data: null }, { human: () => [] })); + return ok( + ctx.present( + { data: null }, + { + human: () => [], + stdout: () => [], + json: () => null, + next: () => [], + }, + ), + ); }, }); @@ -79,7 +104,12 @@ test("managesCredentials alone puts ctx.credentialManager on the context and not expectTypeOf(ctx.credentialManager).toEqualTypeOf(); // @ts-expect-error installsPackages was not declared void ctx.packages; - return ok(ctx.present({ data: null }, { human: () => [] })); + return ok( + ctx.present( + { data: null }, + { human: () => [], stdout: () => [], json: () => null, next: () => [] }, + ), + ); }; expectTypeOf(annotated).toEqualTypeOf(); }); @@ -92,7 +122,17 @@ test("both capabilities declared: both surfaces, and the shared context intact", handler: async (_args, ctx) => { expectTypeOf(ctx.credentialManager).toEqualTypeOf(); expectTypeOf(ctx.packages).toEqualTypeOf(); - return ok(ctx.present({ data: null }, { human: () => [] })); + return ok( + ctx.present( + { data: null }, + { + human: () => [], + stdout: () => [], + json: () => null, + next: () => [], + }, + ), + ); }, }); @@ -110,7 +150,12 @@ test("both capabilities declared: both surfaces, and the shared context intact", const annotated: CommandHandler = async (_args, ctx) => { expectTypeOf(ctx.credentialManager).toEqualTypeOf(); expectTypeOf(ctx.packages).toEqualTypeOf(); - return ok(ctx.present({ data: null }, { human: () => [] })); + return ok( + ctx.present( + { data: null }, + { human: () => [], stdout: () => [], json: () => null, next: () => [] }, + ), + ); }; expectTypeOf(annotated).toEqualTypeOf(); }); @@ -123,7 +168,17 @@ test("neither capability declared: neither surface", () => { void ctx.packages; // @ts-expect-error managesCredentials was not declared void ctx.credentialManager; - return ok(ctx.present({ data: null }, { human: () => [] })); + return ok( + ctx.present( + { data: null }, + { + human: () => [], + stdout: () => [], + json: () => null, + next: () => [], + }, + ), + ); }, }); @@ -137,7 +192,12 @@ test("neither capability declared: neither surface", () => { void ctx.packages; // @ts-expect-error managesCredentials was not declared void ctx.credentialManager; - return ok(ctx.present({ data: null }, { human: () => [] })); + return ok( + ctx.present( + { data: null }, + { human: () => [], stdout: () => [], json: () => null, next: () => [] }, + ), + ); }; expectTypeOf(annotated).toEqualTypeOf(); }); @@ -177,7 +237,17 @@ test("a flag that is not a literal infers boolean and loses its surface", () => handler: async (_args, ctx) => { // @ts-expect-error the flag is not the literal true void ctx.packages; - return ok(ctx.present({ data: null }, { human: () => [] })); + return ok( + ctx.present( + { data: null }, + { + human: () => [], + stdout: () => [], + json: () => null, + next: () => [], + }, + ), + ); }, }); @@ -205,7 +275,17 @@ test("an explicit type argument can claim a capability the declaration omits", ( help: { summary: "Claims installsPackages without declaring it" }, handler: async (_args, ctx) => { expectTypeOf(ctx.packages).toEqualTypeOf(); - return ok(ctx.present({ data: null }, { human: () => [] })); + return ok( + ctx.present( + { data: null }, + { + human: () => [], + stdout: () => [], + json: () => null, + next: () => [], + }, + ), + ); }, }); @@ -227,9 +307,17 @@ test("the capability generics leave the exit-code catalogue alone", () => { const annotated: CommandHandler = async (_args, ctx) => { expectTypeOf(ctx.credentialManager).toEqualTypeOf(); expectTypeOf(ctx.packages).toEqualTypeOf(); - // @ts-expect-error 7 is outside the command's catalogue - ctx.present({ data: null, exitCode: 7 }, { human: () => [] }); - return ok(ctx.present({ data: null, exitCode: 4 }, { human: () => [] })); + ctx.present( + // @ts-expect-error 7 is outside the command's catalogue + { data: null, exitCode: 7 }, + { human: () => [], stdout: () => [], json: () => null, next: () => [] }, + ); + return ok( + ctx.present( + { data: null, exitCode: 4 }, + { human: () => [], stdout: () => [], json: () => null, next: () => [] }, + ), + ); }; expectTypeOf(annotated).toEqualTypeOf(); }); @@ -243,7 +331,17 @@ test("maySpawn sits beside the capability flags without widening them", () => { handler: async (_args, ctx) => { expectTypeOf(ctx.credentialManager).toEqualTypeOf(); expectTypeOf(ctx.packages).toEqualTypeOf(); - return ok(ctx.present({ data: null }, { human: () => [] })); + return ok( + ctx.present( + { data: null }, + { + human: () => [], + stdout: () => [], + json: () => null, + next: () => [], + }, + ), + ); }, }); diff --git a/packages/cli-engine/tests/command-families.test.ts b/packages/cli-engine/tests/command-families.test.ts index 3829f2f8..05c20cf3 100644 --- a/packages/cli-engine/tests/command-families.test.ts +++ b/packages/cli-engine/tests/command-families.test.ts @@ -29,7 +29,17 @@ function configCommand(section: ConfigSection) { help: { summary: "Reads a config section" }, needs: { config: section }, handler: async (_args, ctx) => - ok(ctx.present({ data: ctx.config }, { human: () => [] })), + ok( + ctx.present( + { data: ctx.config }, + { + human: () => [], + stdout: () => [], + json: () => ctx.config, + next: () => [], + }, + ), + ), }); } @@ -159,7 +169,12 @@ describe("docs-URL derivation", () => { }, ], }, - { human: () => [] }, + { + human: () => [], + stdout: () => [], + json: () => null, + next: () => [], + }, ), ), }); diff --git a/packages/cli-engine/tests/config.test.ts b/packages/cli-engine/tests/config.test.ts index 43e373e8..02855d7c 100644 --- a/packages/cli-engine/tests/config.test.ts +++ b/packages/cli-engine/tests/config.test.ts @@ -701,6 +701,9 @@ function showCommand( human: () => [ { kind: "summary", status: "ok", text: ctx.config.greeting }, ], + stdout: () => [], + json: () => ctx.config, + next: () => [], }, ), ); @@ -878,7 +881,17 @@ describe("needs.config", { timeout: 60_000 }, () => { const plain = defineCommand({ help: { summary: "No needs at all" }, handler: async (_args, ctx) => - ok(ctx.present({ data: null }, { human: () => [] })), + ok( + ctx.present( + { data: null }, + { + human: () => [], + stdout: () => [], + json: () => null, + next: () => [], + }, + ), + ), }); const cli = createCli({ name: "t", @@ -1037,7 +1050,17 @@ describe("--config on the command line", { timeout: 60_000 }, () => { help: { summary: "Echoes a message" }, args: { flags: { message: flag.string({ brief: "message" }) } }, handler: async (args, ctx) => - ok(ctx.present({ data: args.flags.message }, { human: () => [] })), + ok( + ctx.present( + { data: args.flags.message }, + { + human: () => [], + stdout: () => [], + json: () => args.flags.message, + next: () => [], + }, + ), + ), }); const cli = createTestCli({ commands: { echo } }); const run = await cli.run(["echo", "--message", "--config="]); @@ -1061,7 +1084,17 @@ describe("--config on the command line", { timeout: 60_000 }, () => { }, }, handler: async (args, ctx) => - ok(ctx.present({ data: args.positionals.rest }, { human: () => [] })), + ok( + ctx.present( + { data: args.positionals.rest }, + { + human: () => [], + stdout: () => [], + json: () => args.positionals.rest, + next: () => [], + }, + ), + ), }); const cli = createTestCli({ commands: { passthrough } }); const run = await cli.run(["passthrough", "--", "--config="]); diff --git a/packages/cli-engine/tests/credential-manager.test.ts b/packages/cli-engine/tests/credential-manager.test.ts index 661086ee..9d05c646 100644 --- a/packages/cli-engine/tests/credential-manager.test.ts +++ b/packages/cli-engine/tests/credential-manager.test.ts @@ -71,7 +71,17 @@ const credentialReader = () => { help: { summary: "Reads the active credential" }, handler: async (_args, ctx) => { seen = await ctx.activeCredential(); - return ok(ctx.present({ data: seen }, { human: () => [] })); + return ok( + ctx.present( + { data: seen }, + { + human: () => [], + stdout: () => [], + json: () => seen, + next: () => [], + }, + ), + ); }, }); return { command, seen: () => seen }; @@ -197,7 +207,17 @@ describe("the managesCredentials capability", () => { managesCredentials: true, handler: async (_args, ctx) => { sameInstance = ctx.credentialManager === cli.credentialManager; - return ok(ctx.present({ data: null }, { human: () => [] })); + return ok( + ctx.present( + { data: null }, + { + human: () => [], + stdout: () => [], + json: () => null, + next: () => [], + }, + ), + ); }, }); const cli = createTestCli({ commands: { toy } }); @@ -212,7 +232,17 @@ describe("the managesCredentials capability", () => { help: { summary: "Does not manage credentials" }, handler: async (_args, ctx) => { present = "credentialManager" in ctx; - return ok(ctx.present({ data: null }, { human: () => [] })); + return ok( + ctx.present( + { data: null }, + { + human: () => [], + stdout: () => [], + json: () => null, + next: () => [], + }, + ), + ); }, }); const cli = createTestCli({ commands: { toy } }); @@ -226,7 +256,12 @@ const needsCredentials = defineCommand({ help: { summary: "Needs credentials" }, needs: { credentials: true }, handler: async (_args, ctx) => - ok(ctx.present({ data: null }, { human: () => [] })), + ok( + ctx.present( + { data: null }, + { human: () => [], stdout: () => [], json: () => null, next: () => [] }, + ), + ), }); describe("the manager-backed needs check", () => { @@ -282,7 +317,17 @@ describe("the manager-backed needs check", () => { } catch (cause) { return notOk(cause as CliStructuredError); } - return ok(ctx.present({ data: null }, { human: () => [] })); + return ok( + ctx.present( + { data: null }, + { + human: () => [], + stdout: () => [], + json: () => null, + next: () => [], + }, + ), + ); }, }); @@ -630,7 +675,15 @@ describe("token material never leaves", () => { const stored = await ctx.credentialManager.sessions(); const active = await ctx.activeCredential(); return ok( - ctx.present({ data: { stored, active } }, { human: () => [] }), + ctx.present( + { data: { stored, active } }, + { + human: () => [], + stdout: () => [], + json: () => ({ stored, active }), + next: () => [], + }, + ), ); }, }); diff --git a/packages/cli-engine/tests/engine.type-test.ts b/packages/cli-engine/tests/engine.type-test.ts index b5d84e0a..86d31a60 100644 --- a/packages/cli-engine/tests/engine.type-test.ts +++ b/packages/cli-engine/tests/engine.type-test.ts @@ -181,7 +181,12 @@ const diagnostic: Diagnostic = { nextActions: [], }; -const presentations: Presentations = { human: () => [] }; +const presentations: Presentations = { + human: () => [], + stdout: () => [], + json: () => null, + next: () => [], +}; export const runCheck: CommandHandler = async ( args, @@ -536,7 +541,12 @@ export const managedCommand = defineCommand({ const active: ActiveCredential | null = await ctx.activeCredential(); void manager; void active; - return ok(ctx.present({ data: null }, { human: () => [] })); + return ok( + ctx.present( + { data: null }, + { human: () => [], stdout: () => [], json: () => null, next: () => [] }, + ), + ); }, }); export const managedIsDeclared: true = managedCommand.managesCredentials; @@ -548,7 +558,12 @@ export const unmanagedCommand = defineCommand({ void active; // @ts-expect-error the capability was not declared, so the context carries no credentialManager void ctx.credentialManager; - return ok(ctx.present({ data: null }, { human: () => [] })); + return ok( + ctx.present( + { data: null }, + { human: () => [], stdout: () => [], json: () => null, next: () => [] }, + ), + ); }, }); export const unmanagedIsUndeclared: false = unmanagedCommand.managesCredentials; diff --git a/packages/cli-engine/tests/events.test.ts b/packages/cli-engine/tests/events.test.ts index 5682813d..527d14ff 100644 --- a/packages/cli-engine/tests/events.test.ts +++ b/packages/cli-engine/tests/events.test.ts @@ -72,6 +72,7 @@ const noisy = defineCommand({ { kind: "summary", status: "ok", text: "done" }, ], stdout: () => ["done"], + json: () => ({ done: true }), next: () => [{ kind: "done", label: "Nothing else" }], }, ), @@ -114,6 +115,8 @@ describe("human rendering", () => { { data: null }, { human: () => [], + stdout: () => [], + json: () => null, next: () => [ { kind: "open-url", @@ -150,6 +153,8 @@ describe("human rendering", () => { { data: null }, { human: () => [], + stdout: () => [], + json: () => null, next: () => [ { kind: "run-command", diff --git a/packages/cli-engine/tests/execution.test.ts b/packages/cli-engine/tests/execution.test.ts index 1e5927f5..346dd1af 100644 --- a/packages/cli-engine/tests/execution.test.ts +++ b/packages/cli-engine/tests/execution.test.ts @@ -105,6 +105,9 @@ const check = defineCommand({ }, { human: () => [{ kind: "summary", status: "warn", text: "1 finding" }], + stdout: () => [], + json: () => ({ findings: 1 }), + next: () => [], }, ), ), @@ -133,6 +136,9 @@ const whoami = defineCommand({ text: `Signed in (${active?.workspaceId})`, }, ], + stdout: () => [], + json: () => ({ workspaceId: active?.workspaceId }), + next: () => [], }, ), ); @@ -517,7 +523,17 @@ describe("needs preconditions", () => { }), }, handler: async (_args, ctx) => - ok(ctx.present({ data: null }, { human: () => [] })), + ok( + ctx.present( + { data: null }, + { + human: () => [], + stdout: () => [], + json: () => null, + next: () => [], + }, + ), + ), }); } @@ -626,7 +642,12 @@ describe("undocumented completion exit codes", () => { ok( ctx.present( { data: null, exitCode: 7 } as unknown as { data: null }, - { human: () => [] }, + { + human: () => [], + stdout: () => [], + json: () => null, + next: () => [], + }, ), ), }); @@ -647,7 +668,17 @@ describe("undocumented completion exit codes", () => { help: { summary: "Documents 4 but returns 5" }, exitCodes: { 4: "findings" }, handler: async (_args, ctx) => - ok(ctx.present({ data: null, exitCode: 5 as 4 }, { human: () => [] })), + ok( + ctx.present( + { data: null, exitCode: 5 as 4 }, + { + human: () => [], + stdout: () => [], + json: () => null, + next: () => [], + }, + ), + ), }); const cli = createTestCli({ commands: { rogue }, now: EPOCH }); const result = await cli.run(["rogue", "--json"]); @@ -683,6 +714,9 @@ describe("sensitive field rows", () => { ], }, ], + stdout: () => [], + json: () => ({ token: "tok_secret" }), + next: () => [], }, ), ), @@ -715,7 +749,17 @@ describe("report() after the handler resolved", () => { help: { summary: "Leaks its report function" }, handler: async (_args, ctx) => { smuggled = ctx.report; - return ok(ctx.present({ data: null }, { human: () => [] })); + return ok( + ctx.present( + { data: null }, + { + human: () => [], + stdout: () => [], + json: () => null, + next: () => [], + }, + ), + ); }, }); const cli = createCli({ @@ -776,7 +820,17 @@ describe("credentials that cannot be read", () => { help: { summary: "Needs credentials" }, needs: { credentials: true }, handler: async (_args, ctx) => - ok(ctx.present({ data: null }, { human: () => [] })), + ok( + ctx.present( + { data: null }, + { + human: () => [], + stdout: () => [], + json: () => null, + next: () => [], + }, + ), + ), }); const cli = createCli({ name: "t", @@ -887,7 +941,17 @@ describe("parse and route failures", () => { }, }, handler: async (_args, ctx) => - ok(ctx.present({ data: null }, { human: () => [] })), + ok( + ctx.present( + { data: null }, + { + human: () => [], + stdout: () => [], + json: () => null, + next: () => [], + }, + ), + ), }); const cli = createTestCli({ commands: { strict }, now: EPOCH }); const result = await cli.run(["strict", "--mode", "z", "--count", "q"], { @@ -910,7 +974,17 @@ describe("parse and route failures", () => { flags: { count: flag.number({ brief: "how many", placeholder: "n" }) }, }, handler: async (_args, ctx) => - ok(ctx.present({ data: null }, { human: () => [] })), + ok( + ctx.present( + { data: null }, + { + human: () => [], + stdout: () => [], + json: () => null, + next: () => [], + }, + ), + ), }); const cli = createTestCli({ commands: { counting }, now: EPOCH }); const result = await cli.run(["counting", "--count", ""], { @@ -954,7 +1028,12 @@ describe("parse and route failures", () => { ok( ctx.present( { data: { bang: args.flags.withBang } }, - { human: () => [] }, + { + human: () => [], + stdout: () => [], + json: () => ({ bang: args.flags.withBang }), + next: () => [], + }, ), ), }); @@ -970,7 +1049,17 @@ describe("ctx.host", () => { const reporting = defineCommand({ help: { summary: "Report the host" }, handler: async (_args, ctx) => - ok(ctx.present({ data: ctx.host }, { human: () => [] })), + ok( + ctx.present( + { data: ctx.host }, + { + human: () => [], + stdout: () => [], + json: () => ctx.host, + next: () => [], + }, + ), + ), }); test("a command reads the runtime, platform and arch from the context", async () => { @@ -1016,6 +1105,9 @@ describe("flag.optionalBoolean", () => { { data: { link: args.flags.link ?? null } }, { human: () => [], + stdout: () => [], + json: () => ({ link: args.flags.link ?? null }), + next: () => [], }, ), ), @@ -1062,7 +1154,17 @@ describe("help examples", () => { examples: ["greet world --loud", "{bin} greet world | cat"], }, handler: async (_args, ctx) => - ok(ctx.present({ data: null }, { human: () => [] })), + ok( + ctx.present( + { data: null }, + { + human: () => [], + stdout: () => [], + json: () => null, + next: () => [], + }, + ), + ), }); const cli = createTestCli({ commands: { greet: exemplified }, now: EPOCH }); const result = await cli.run(["greet", "--help"], { diff --git a/packages/cli-engine/tests/interaction-affordances.test.ts b/packages/cli-engine/tests/interaction-affordances.test.ts index 8ed52350..41d5d5e5 100644 --- a/packages/cli-engine/tests/interaction-affordances.test.ts +++ b/packages/cli-engine/tests/interaction-affordances.test.ts @@ -41,6 +41,9 @@ function probeCommand(run: (ctx: CommandContext) => Promise) { human: (): readonly Block[] => [ { kind: "summary", status: "ok", text: `answer=${answer}` }, ], + stdout: () => [], + json: () => ({ answer }), + next: () => [], }, ), ); diff --git a/packages/cli-engine/tests/lifetimes.test.ts b/packages/cli-engine/tests/lifetimes.test.ts index 5a4cb8d0..842219ea 100644 --- a/packages/cli-engine/tests/lifetimes.test.ts +++ b/packages/cli-engine/tests/lifetimes.test.ts @@ -194,7 +194,15 @@ describe("signal exit codes", () => { handler: async (_args, ctx) => { await signalDone(ctx.signal); return ok( - ctx.present({ data: { cleanedUp: true } }, { human: () => [] }), + ctx.present( + { data: { cleanedUp: true } }, + { + human: () => [], + stdout: () => [], + json: () => ({ cleanedUp: true }), + next: () => [], + }, + ), ); }, }); @@ -220,7 +228,15 @@ describe("signal exit codes", () => { handler: async (_args, ctx) => { await signalDone(ctx.signal); return ok( - ctx.present({ data: null, exitCode: 4 }, { human: () => [] }), + ctx.present( + { data: null, exitCode: 4 }, + { + human: () => [], + stdout: () => [], + json: () => null, + next: () => [], + }, + ), ); }, }); @@ -320,7 +336,17 @@ describe("the engine owns the double-signal policy", () => { const quick = defineCommand({ help: { summary: "Completes immediately" }, handler: async (_args, ctx) => - ok(ctx.present({ data: null }, { human: () => [] })), + ok( + ctx.present( + { data: null }, + { + human: () => [], + stdout: () => [], + json: () => null, + next: () => [], + }, + ), + ), }); const cli = createCli({ name: "t", @@ -376,6 +402,9 @@ describe("optional dependencies", () => { { data: { resolvable: probe.ok } }, { human: (): readonly Block[] => [], + stdout: () => [], + json: () => ({ resolvable: probe.ok }), + next: () => [], }, ), ); @@ -395,7 +424,17 @@ describe("optional dependencies", () => { help: { summary: "Probes a dependency" }, needs: { dependencies: ["typescript"] }, handler: async (_args, ctx) => - ok(ctx.present({ data: null }, { human: (): readonly Block[] => [] })), + ok( + ctx.present( + { data: null }, + { + human: (): readonly Block[] => [], + stdout: () => [], + json: () => null, + next: () => [], + }, + ), + ), }); const cli = createTestCli({ commands: { command }, now: EPOCH }); const result = await cli.run(["command", "--json"], { cwd: "." }); @@ -458,7 +497,17 @@ describe("optional dependencies", () => { needs: { dependencies: [MISSING] }, handler: async (_args, ctx) => { ran = true; - return ok(ctx.present({ data: null }, { human: () => [] })); + return ok( + ctx.present( + { data: null }, + { + human: () => [], + stdout: () => [], + json: () => null, + next: () => [], + }, + ), + ); }, }); const cli = createTestCli({ @@ -500,7 +549,17 @@ describe("optional dependencies", () => { help: { summary: "Unconditionally needs a missing dependency" }, needs: { dependencies: [MISSING] }, handler: async (_args, ctx) => - ok(ctx.present({ data: null }, { human: () => [] })), + ok( + ctx.present( + { data: null }, + { + human: () => [], + stdout: () => [], + json: () => null, + next: () => [], + }, + ), + ), }); const cli = createTestCli({ commands: { command }, now: EPOCH }); const result = await cli.run(["command", "--json"], { cwd: dir }); @@ -527,7 +586,17 @@ describe("optional dependencies", () => { help: { summary: "Needs installed dependencies" }, needs: { dependencies: ["typescript", "vitest"] }, handler: async (_args, ctx) => - ok(ctx.present({ data: null }, { human: (): readonly Block[] => [] })), + ok( + ctx.present( + { data: null }, + { + human: (): readonly Block[] => [], + stdout: () => [], + json: () => null, + next: () => [], + }, + ), + ), }); const cli = createTestCli({ commands: { command }, now: EPOCH }); const result = await cli.run(["command", "--json"], { diff --git a/packages/cli-engine/tests/management-api.test.ts b/packages/cli-engine/tests/management-api.test.ts index f518ce45..224e89cf 100644 --- a/packages/cli-engine/tests/management-api.test.ts +++ b/packages/cli-engine/tests/management-api.test.ts @@ -147,7 +147,17 @@ const succeed = ( help: { summary: "toy" }, handler: async (_args, ctx) => { await body?.(ctx); - return ok(ctx.present({ data: null }, { human: () => [] })); + return ok( + ctx.present( + { data: null }, + { + human: () => [], + stdout: () => [], + json: () => null, + next: () => [], + }, + ), + ); }, }); diff --git a/packages/cli-engine/tests/package-install-matrix.test.ts b/packages/cli-engine/tests/package-install-matrix.test.ts index 618e5c8c..29e78418 100644 --- a/packages/cli-engine/tests/package-install-matrix.test.ts +++ b/packages/cli-engine/tests/package-install-matrix.test.ts @@ -121,7 +121,9 @@ const init = defineCommand({ { data: { warnings } }, { human: () => warnings.map(warnBlock), + stdout: () => [], json: () => ({ warnings }), + next: () => [], }, ), ); @@ -168,7 +170,12 @@ const vendored = defineCommand({ if (!installed.ok) { return notOk(installed.failure); } - return ok(ctx.present({ data: null }, { human: () => [] })); + return ok( + ctx.present( + { data: null }, + { human: () => [], stdout: () => [], json: () => null, next: () => [] }, + ), + ); }, }); diff --git a/packages/cli-engine/tests/package-operations.test.ts b/packages/cli-engine/tests/package-operations.test.ts index c616ceec..50ca618b 100644 --- a/packages/cli-engine/tests/package-operations.test.ts +++ b/packages/cli-engine/tests/package-operations.test.ts @@ -212,7 +212,17 @@ function installer( if (!outcome.ok) { return notOk(outcome.failure); } - return ok(ctx.present({ data: null }, { human: () => [] })); + return ok( + ctx.present( + { data: null }, + { + human: () => [], + stdout: () => [], + json: () => null, + next: () => [], + }, + ), + ); }, }); } @@ -225,7 +235,17 @@ describe("the installsPackages capability", () => { installsPackages: true, handler: async (_args, ctx) => { present = "packages" in ctx; - return ok(ctx.present({ data: null }, { human: () => [] })); + return ok( + ctx.present( + { data: null }, + { + human: () => [], + stdout: () => [], + json: () => null, + next: () => [], + }, + ), + ); }, }); const cli = createTestCli({ commands: { toy } }); @@ -242,7 +262,17 @@ describe("the installsPackages capability", () => { help: { summary: "Installs nothing" }, handler: async (_args, ctx) => { present = "packages" in ctx; - return ok(ctx.present({ data: null }, { human: () => [] })); + return ok( + ctx.present( + { data: null }, + { + human: () => [], + stdout: () => [], + json: () => null, + next: () => [], + }, + ), + ); }, }); const cli = createTestCli({ commands: { toy } }); @@ -615,7 +645,17 @@ describe("the two ways an operation does not resolve notOk", () => { if (rejected !== undefined) { throw rejected.reason; } - return ok(ctx.present({ data: null }, { human: () => [] })); + return ok( + ctx.present( + { data: null }, + { + human: () => [], + stdout: () => [], + json: () => null, + next: () => [], + }, + ), + ); }, }); const cli = createTestCli({ @@ -677,7 +717,17 @@ describe("the two ways an operation does not resolve notOk", () => { const installing = ctx.packages.install({ packages: ["prisma"] }); controller.abort(); await installing; - return ok(ctx.present({ data: null }, { human: () => [] })); + return ok( + ctx.present( + { data: null }, + { + human: () => [], + stdout: () => [], + json: () => null, + next: () => [], + }, + ), + ); }, }); // No packageManager seed, so detection actually runs. @@ -713,7 +763,17 @@ describe("the two ways an operation does not resolve notOk", () => { (cause: unknown) => String(cause), ); await child; - return ok(ctx.present({ data: failure }, { human: () => [] })); + return ok( + ctx.present( + { data: failure }, + { + human: () => [], + stdout: () => [], + json: () => failure, + next: () => [], + }, + ), + ); }, }); const cli = createTestCli({ @@ -743,7 +803,17 @@ describe("the two ways an operation does not resolve notOk", () => { (cause: unknown) => String(cause), ); await installing; - return ok(ctx.present({ data: failure }, { human: () => [] })); + return ok( + ctx.present( + { data: failure }, + { + human: () => [], + stdout: () => [], + json: () => failure, + next: () => [], + }, + ), + ); }, }); const cli = createTestCli({ @@ -774,7 +844,17 @@ describe("the two ways an operation does not resolve notOk", () => { handler: async (_args, ctx) => { await ctx.packages.install({ packages: ["prisma"] }); sameSignal = captured === ctx.signal; - return ok(ctx.present({ data: null }, { human: () => [] })); + return ok( + ctx.present( + { data: null }, + { + human: () => [], + stdout: () => [], + json: () => null, + next: () => [], + }, + ), + ); }, }); const cli = createTestCli({ diff --git a/packages/cli-engine/tests/palette.test.ts b/packages/cli-engine/tests/palette.test.ts index a7270bb2..6962d02e 100644 --- a/packages/cli-engine/tests/palette.test.ts +++ b/packages/cli-engine/tests/palette.test.ts @@ -128,6 +128,9 @@ const styled = defineCommand({ { kind: "summary", status: "ok", text: [HEADING_SPAN] }, { kind: "list", items: [`width=${ui.width}`] }, ], + stdout: () => [], + json: () => ({}), + next: () => [], }, ), ), diff --git a/packages/cli-engine/tests/prompts.test.ts b/packages/cli-engine/tests/prompts.test.ts index 24d9d656..bb0044c7 100644 --- a/packages/cli-engine/tests/prompts.test.ts +++ b/packages/cli-engine/tests/prompts.test.ts @@ -34,6 +34,9 @@ function promptCommand(run: (prompt: PromptSurface) => Promise) { human: (): readonly Block[] => [ { kind: "summary", status: "ok", text: `answer=${answer}` }, ], + stdout: () => [], + json: () => ({ answer }), + next: () => [], }, ), ); @@ -429,6 +432,9 @@ describe("needs.interaction", () => { human: (): readonly Block[] => [ { kind: "summary", status: "ok", text: "ran" }, ], + stdout: () => [], + json: () => null, + next: () => [], }, ), ); diff --git a/packages/cli-engine/tests/redirects.test.ts b/packages/cli-engine/tests/redirects.test.ts index c9b5f6a6..84ddef20 100644 --- a/packages/cli-engine/tests/redirects.test.ts +++ b/packages/cli-engine/tests/redirects.test.ts @@ -22,7 +22,12 @@ const status = defineCommand({ help: { summary: "Show migration status" }, args: { flags: { detailed: flag.boolean({ brief: "more detail" }) } }, handler: async (_args, ctx) => - ok(ctx.present({ data: null }, { human: () => [] })), + ok( + ctx.present( + { data: null }, + { human: () => [], stdout: () => [], json: () => null, next: () => [] }, + ), + ), }); const MIGRATION_GROUP = { migration: { brief: "Migrations" } }; @@ -590,7 +595,17 @@ describe("retired flags", () => { const list = defineCommand({ help: { summary: "List migrations" }, handler: async (_args, ctx) => - ok(ctx.present({ data: null }, { human: () => [] })), + ok( + ctx.present( + { data: null }, + { + human: () => [], + stdout: () => [], + json: () => null, + next: () => [], + }, + ), + ), }); const cli = createTestCli({ commandFamilies: [ diff --git a/packages/cli-engine/tests/run-hooks.test.ts b/packages/cli-engine/tests/run-hooks.test.ts index 2bcc2a47..c697ac19 100644 --- a/packages/cli-engine/tests/run-hooks.test.ts +++ b/packages/cli-engine/tests/run-hooks.test.ts @@ -28,7 +28,12 @@ const deploy = defineCommand({ ok( ctx.present( { data: null }, - { human: () => [{ kind: "summary", status: "ok", text: "deployed" }] }, + { + human: () => [{ kind: "summary", status: "ok", text: "deployed" }], + stdout: () => [], + json: () => null, + next: () => [], + }, ), ), }); diff --git a/packages/cli-engine/tests/spawn.test.ts b/packages/cli-engine/tests/spawn.test.ts index f1909c20..e25d5b4a 100644 --- a/packages/cli-engine/tests/spawn.test.ts +++ b/packages/cli-engine/tests/spawn.test.ts @@ -182,7 +182,15 @@ describe("the run records its most recent child", () => { maySpawn: true, handler: async (_args, ctx) => ok( - ctx.present({ data: ctx.lastChild() ?? "none" }, { human: () => [] }), + ctx.present( + { data: ctx.lastChild() ?? "none" }, + { + human: () => [], + stdout: () => [], + json: () => ctx.lastChild() ?? "none", + next: () => [], + }, + ), ), }); const cli = createTestCli({ commands: { asking }, now: CLOCK }); @@ -199,7 +207,17 @@ describe("the run records its most recent child", () => { maySpawn: true, handler: async (_args, ctx) => { await ctx.spawn({ command: "alchemy" }); - return ok(ctx.present({ data: ctx.lastChild() }, { human: () => [] })); + return ok( + ctx.present( + { data: ctx.lastChild() }, + { + human: () => [], + stdout: () => [], + json: () => ctx.lastChild(), + next: () => [], + }, + ), + ); }, }); const cli = createTestCli({ @@ -224,7 +242,12 @@ describe("the run records its most recent child", () => { return ok( ctx.present( { data: { afterFirst, afterSecond: ctx.lastChild() } }, - { human: () => [] }, + { + human: () => [], + stdout: () => [], + json: () => ({ afterFirst, afterSecond: ctx.lastChild() }), + next: () => [], + }, ), ); }, @@ -306,7 +329,17 @@ describe("the spawn request", () => { .spawn({ command: "second" }) .then(() => "no error", String); await first; - return ok(ctx.present({ data: second }, { human: () => [] })); + return ok( + ctx.present( + { data: second }, + { + human: () => [], + stdout: () => [], + json: () => second, + next: () => [], + }, + ), + ); }, }); const cli = createTestCli({ commands: { twice }, now: CLOCK }); @@ -322,7 +355,17 @@ describe("the spawn request", () => { help: { summary: "Spawns without declaring it" }, handler: async (_args, ctx) => { await ctx.spawn({ command: "alchemy" }); - return ok(ctx.present({ data: null }, { human: () => [] })); + return ok( + ctx.present( + { data: null }, + { + human: () => [], + stdout: () => [], + json: () => null, + next: () => [], + }, + ), + ); }, }); const cli = createTestCli({ commands: { undeclared }, now: CLOCK }); @@ -338,7 +381,17 @@ describe("the spawn request", () => { help: { summary: "Wants credentials it cannot hand over" }, needs: { credentials: "child" }, handler: async (_args, ctx) => - ok(ctx.present({ data: null }, { human: () => [] })), + ok( + ctx.present( + { data: null }, + { + human: () => [], + stdout: () => [], + json: () => null, + next: () => [], + }, + ), + ), }); expect(() => @@ -393,7 +446,17 @@ describe("output while a child owns the terminal", () => { ctx.report({ kind: "message", severity: "info", text: "during-2" }); await child; ctx.report({ kind: "message", severity: "info", text: "after" }); - return ok(ctx.present({ data: null }, { human: () => [] })); + return ok( + ctx.present( + { data: null }, + { + human: () => [], + stdout: () => [], + json: () => null, + next: () => [], + }, + ), + ); }, }); const cli = createTestCli({ @@ -425,14 +488,32 @@ describe("output while a child owns the terminal", () => { const child = ctx.spawn({ command: "alchemy" }); const failure = (() => { try { - ctx.present({ data: null }, { human: () => [] }); + ctx.present( + { data: null }, + { + human: () => [], + stdout: () => [], + json: () => null, + next: () => [], + }, + ); return "no error"; } catch (cause) { return String(cause); } })(); await child; - return ok(ctx.present({ data: failure }, { human: () => [] })); + return ok( + ctx.present( + { data: failure }, + { + human: () => [], + stdout: () => [], + json: () => failure, + next: () => [], + }, + ), + ); }, }); const cli = createTestCli({ commands: { presenting }, now: CLOCK }); @@ -458,7 +539,17 @@ describe("signals", () => { abortedNow = () => ctx.signal.aborted; await ctx.spawn({ command: "alchemy" }); abortedAfterChild = ctx.signal.aborted; - return ok(ctx.present({ data: null }, { human: () => [] })); + return ok( + ctx.present( + { data: null }, + { + human: () => [], + stdout: () => [], + json: () => null, + next: () => [], + }, + ), + ); }, }); const cli = createTestCli({ @@ -1126,7 +1217,17 @@ describe("the commentary buffer is bounded", () => { }); } await child; - return ok(ctx.present({ data: null }, { human: () => [] })); + return ok( + ctx.present( + { data: null }, + { + human: () => [], + stdout: () => [], + json: () => null, + next: () => [], + }, + ), + ); }, }); const messages: string[] = []; @@ -1161,7 +1262,17 @@ describe("the terminal has one owner at a time", () => { .confirm("Proceed?", { default: true }) .then(() => "no error", String); await child; - return ok(ctx.present({ data: failure }, { human: () => [] })); + return ok( + ctx.present( + { data: failure }, + { + human: () => [], + stdout: () => [], + json: () => failure, + next: () => [], + }, + ), + ); }, }); const cli = createTestCli({ commands: { prompting }, now: CLOCK }); @@ -1185,7 +1296,17 @@ describe("the terminal has one owner at a time", () => { .spawn({ command: "alchemy" }) .then(() => "no error", String); await answer; - return ok(ctx.present({ data: failure }, { human: () => [] })); + return ok( + ctx.present( + { data: failure }, + { + human: () => [], + stdout: () => [], + json: () => failure, + next: () => [], + }, + ), + ); }, }); const cli = createTestCli({ commands: { prompting }, now: CLOCK }); diff --git a/packages/cli-engine/tests/telemetry-payload.test.ts b/packages/cli-engine/tests/telemetry-payload.test.ts index d79539db..8627dfd1 100644 --- a/packages/cli-engine/tests/telemetry-payload.test.ts +++ b/packages/cli-engine/tests/telemetry-payload.test.ts @@ -32,7 +32,12 @@ const deploy = defineCommand({ ok( ctx.present( { data: null }, - { human: () => [{ kind: "summary", status: "ok", text: "deployed" }] }, + { + human: () => [{ kind: "summary", status: "ok", text: "deployed" }], + stdout: () => [], + json: () => null, + next: () => [], + }, ), ), }); diff --git a/packages/cli-engine/tests/telemetry-run.test.ts b/packages/cli-engine/tests/telemetry-run.test.ts index 0303923a..5d28b7d9 100644 --- a/packages/cli-engine/tests/telemetry-run.test.ts +++ b/packages/cli-engine/tests/telemetry-run.test.ts @@ -69,7 +69,12 @@ const deploy = defineCommand({ return ok( ctx.present( { data: null }, - { human: () => [{ kind: "summary", status: "ok", text: "deployed" }] }, + { + human: () => [{ kind: "summary", status: "ok", text: "deployed" }], + stdout: () => [], + json: () => null, + next: () => [], + }, ), ); }, @@ -86,7 +91,12 @@ const telemetryStatus = defineCommand({ ok( ctx.present( { data: null }, - { human: () => [{ kind: "summary", status: "ok", text: "enabled" }] }, + { + human: () => [{ kind: "summary", status: "ok", text: "enabled" }], + stdout: () => [], + json: () => null, + next: () => [], + }, ), ), }); @@ -101,7 +111,12 @@ const signedOut = defineCommand({ return ok( ctx.present( { data: null }, - { human: () => [{ kind: "summary", status: "ok", text: "signed in" }] }, + { + human: () => [{ kind: "summary", status: "ok", text: "signed in" }], + stdout: () => [], + json: () => null, + next: () => [], + }, ), ); }, @@ -116,7 +131,12 @@ const spawning = defineCommand({ return ok( ctx.present( { data: null }, - { human: () => [{ kind: "summary", status: "ok", text: "spawned" }] }, + { + human: () => [{ kind: "summary", status: "ok", text: "spawned" }], + stdout: () => [], + json: () => null, + next: () => [], + }, ), ); }, diff --git a/packages/cli/src/commands/agent/presentation.ts b/packages/cli/src/commands/agent/presentation.ts index ec8a1201..d28fd380 100644 --- a/packages/cli/src/commands/agent/presentation.ts +++ b/packages/cli/src/commands/agent/presentation.ts @@ -61,6 +61,8 @@ export function installPresentations( statusCommand: string | null, ): Presentations { return { + stdout: () => [], + json: () => result, human: () => [ { kind: "summary", @@ -90,6 +92,8 @@ export function statusPresentations( installCommand: string | null, ): Presentations { return { + stdout: () => [], + json: () => result, human: () => [ title(`Checking ${result.statusScope} Prisma skills.`), fields([ diff --git a/packages/cli/src/commands/auth/login.ts b/packages/cli/src/commands/auth/login.ts index 4d73d904..dccbbaee 100644 --- a/packages/cli/src/commands/auth/login.ts +++ b/packages/cli/src/commands/auth/login.ts @@ -69,16 +69,20 @@ function nextActionsFor( ]; } -function presentationsFor(spec: { - readonly session: Session; - readonly environmentCredentialInForce: boolean; - readonly agentSetupTipCommand: string | null; -}): Presentations { +function presentationsFor( + spec: { + readonly session: Session; + readonly environmentCredentialInForce: boolean; + readonly agentSetupTipCommand: string | null; + }, + result: LoginResult, +): Presentations { const rows = [ { label: "status", value: "signed in" }, { label: "workspace", value: sessionLabel(spec.session) }, ]; return { + json: () => result, human: () => [ { kind: "summary", status: "info", text: TITLE }, { kind: "fields", rows }, @@ -152,11 +156,14 @@ export const authLoginCommand = defineCommand({ return ok( ctx.present( { data: result }, - presentationsFor({ - session, - environmentCredentialInForce: environmentSession, - agentSetupTipCommand, - }), + presentationsFor( + { + session, + environmentCredentialInForce: environmentSession, + agentSetupTipCommand, + }, + result, + ), ), ); }, diff --git a/packages/cli/src/commands/auth/logout.ts b/packages/cli/src/commands/auth/logout.ts index 17791ed3..170c0f9a 100644 --- a/packages/cli/src/commands/auth/logout.ts +++ b/packages/cli/src/commands/auth/logout.ts @@ -25,6 +25,7 @@ function presentationsFor( : `Ended ${result.endedCount} workspace ${result.endedCount === 1 ? "session" : "sessions"}.`; const rows = [{ label: "ended", value: String(result.endedCount) }]; return { + json: () => result, human: () => [ { kind: "summary", diff --git a/packages/cli/src/commands/auth/whoami.ts b/packages/cli/src/commands/auth/whoami.ts index 27607556..07879567 100644 --- a/packages/cli/src/commands/auth/whoami.ts +++ b/packages/cli/src/commands/auth/whoami.ts @@ -97,13 +97,17 @@ function mergedIdentity( }; } -function presentationsFor(spec: { - readonly credential: ActiveCredential | null; - readonly identity: CredentialIdentity | null; -}): Presentations { +function presentationsFor( + spec: { + readonly credential: ActiveCredential | null; + readonly identity: CredentialIdentity | null; + }, + result: WhoamiResult, +): Presentations { const rows = credentialFieldRows(spec); const fromEnvironment = spec.credential?.origin.source === "environment"; return { + json: () => result, human: () => [ { kind: "summary", status: "info", text: TITLE }, { kind: "fields", rows }, @@ -157,7 +161,10 @@ export const authWhoamiCommand = defineCommand({ expiresAt: credential?.expiresAt?.toISOString() ?? null, }; return ok( - ctx.present({ data: result }, presentationsFor({ credential, identity })), + ctx.present( + { data: result }, + presentationsFor({ credential, identity }, result), + ), ); }, }); diff --git a/packages/cli/src/commands/auth/workspace-logout.ts b/packages/cli/src/commands/auth/workspace-logout.ts index e9a7d656..8ab41e81 100644 --- a/packages/cli/src/commands/auth/workspace-logout.ts +++ b/packages/cli/src/commands/auth/workspace-logout.ts @@ -15,13 +15,17 @@ export interface WorkspaceLogoutResult { readonly wasSelected: boolean; } -function logoutPresentations(spec: { - readonly label: string; - readonly wasSelected: boolean; - readonly environmentCredentialInForce: boolean; -}): Presentations { +function logoutPresentations( + spec: { + readonly label: string; + readonly wasSelected: boolean; + readonly environmentCredentialInForce: boolean; + }, + result: WorkspaceLogoutResult, +): Presentations { const rows = [{ label: "workspace", value: spec.label }]; return { + json: () => result, human: () => [ { kind: "summary", @@ -95,11 +99,14 @@ export const authWorkspaceLogoutCommand = defineCommand({ return ok( ctx.present( { data: result }, - logoutPresentations({ - label: sessionLabel(session), - wasSelected, - environmentCredentialInForce: environmentCredentialInForce(ctx.env), - }), + logoutPresentations( + { + label: sessionLabel(session), + wasSelected, + environmentCredentialInForce: environmentCredentialInForce(ctx.env), + }, + result, + ), ), ); }, diff --git a/packages/cli/src/commands/auth/workspace-use.ts b/packages/cli/src/commands/auth/workspace-use.ts index b2f610e9..a8a1a03c 100644 --- a/packages/cli/src/commands/auth/workspace-use.ts +++ b/packages/cli/src/commands/auth/workspace-use.ts @@ -34,11 +34,14 @@ function noWorkspaceSessionsError(): CliStructuredError { ); } -function usePresentations(spec: { - readonly session: Session; - readonly previous: Session | undefined; - readonly environmentCredentialInForce: boolean; -}): Presentations { +function usePresentations( + spec: { + readonly session: Session; + readonly previous: Session | undefined; + readonly environmentCredentialInForce: boolean; + }, + result: WorkspaceUseResult, +): Presentations { const rows = [ ...(spec.previous === undefined ? [] @@ -46,6 +49,7 @@ function usePresentations(spec: { { label: "workspace", value: sessionLabel(spec.session) }, ]; return { + json: () => result, human: () => [ { kind: "summary", @@ -124,11 +128,14 @@ export const authWorkspaceUseCommand = defineCommand({ return ok( ctx.present( { data: result }, - usePresentations({ - session, - previous, - environmentCredentialInForce: environmentCredentialInForce(ctx.env), - }), + usePresentations( + { + session, + previous, + environmentCredentialInForce: environmentCredentialInForce(ctx.env), + }, + result, + ), ), ); }, diff --git a/packages/cli/src/commands/branch/list.ts b/packages/cli/src/commands/branch/list.ts index ad2125d3..02ecca64 100644 --- a/packages/cli/src/commands/branch/list.ts +++ b/packages/cli/src/commands/branch/list.ts @@ -24,6 +24,8 @@ function listPresentations(result: BranchListResult): Presentations { branch.envMap, ]); return { + json: () => result, + next: () => [], human: (): Block[] => [ { kind: "summary", status: "info", text: TITLE }, { diff --git a/packages/cli/src/commands/bucket/create.ts b/packages/cli/src/commands/bucket/create.ts index a02c28eb..8ebc3c90 100644 --- a/packages/cli/src/commands/bucket/create.ts +++ b/packages/cli/src/commands/bucket/create.ts @@ -52,6 +52,9 @@ export const bucketCreateCommand = defineCommand({ text: `Created bucket "${bucket.name}" in ${bucketTargetLabel(projectName, bucket.branchId)}.`, }, ], + stdout: () => [], + json: () => result, + next: () => [], }, ), ); diff --git a/packages/cli/src/commands/bucket/delete.ts b/packages/cli/src/commands/bucket/delete.ts index 26ec1fba..0198341e 100644 --- a/packages/cli/src/commands/bucket/delete.ts +++ b/packages/cli/src/commands/bucket/delete.ts @@ -16,6 +16,9 @@ const CONSENT_QUESTION = function deletePresentations(result: BucketDeleteResult): Presentations { return { + stdout: () => [], + json: () => result, + next: () => [], human: (): Block[] => [ { kind: "summary", status: "ok", text: "Deleting object-store bucket." }, { kind: "fields", rows: [{ label: "bucket", value: result.bucket.id }] }, diff --git a/packages/cli/src/commands/bucket/key-create.ts b/packages/cli/src/commands/bucket/key-create.ts index 79dbeda8..af7bd58d 100644 --- a/packages/cli/src/commands/bucket/key-create.ts +++ b/packages/cli/src/commands/bucket/key-create.ts @@ -19,6 +19,8 @@ function resolveKeyRole(role: string | undefined): "read" | "read_write" { function createPresentations(result: BucketKeyCreateResult): Presentations { return { + json: () => result, + next: () => [], human: (): Block[] => [ { kind: "summary", diff --git a/packages/cli/src/commands/bucket/key-delete.ts b/packages/cli/src/commands/bucket/key-delete.ts index 30690a37..058d3497 100644 --- a/packages/cli/src/commands/bucket/key-delete.ts +++ b/packages/cli/src/commands/bucket/key-delete.ts @@ -13,6 +13,9 @@ import { mapBucketOperationError } from "./errors"; function deletePresentations(result: BucketKeyDeleteResult): Presentations { return { + stdout: () => [], + json: () => result, + next: () => [], human: (): Block[] => [ { kind: "summary", status: "ok", text: "Deleting bucket access key." }, { kind: "fields", rows: [{ label: "key", value: result.key.id }] }, diff --git a/packages/cli/src/commands/bucket/key-list.ts b/packages/cli/src/commands/bucket/key-list.ts index 9b0a29a1..6cbb042b 100644 --- a/packages/cli/src/commands/bucket/key-list.ts +++ b/packages/cli/src/commands/bucket/key-list.ts @@ -17,6 +17,7 @@ const TITLE = "Listing access keys for bucket."; function listPresentations(result: BucketKeyListResult): Presentations { const rows = bucketKeyRows(result.keys); return { + next: () => [], human: (): Block[] => [ { kind: "summary", status: "info", text: TITLE }, { kind: "fields", rows: [{ label: "bucket", value: result.bucketId }] }, diff --git a/packages/cli/src/commands/bucket/list.ts b/packages/cli/src/commands/bucket/list.ts index e73c19ff..c92201fa 100644 --- a/packages/cli/src/commands/bucket/list.ts +++ b/packages/cli/src/commands/bucket/list.ts @@ -17,6 +17,7 @@ function listPresentations(result: BucketListResult): Presentations { const rows = bucketRows(result.buckets); const stdoutRows = bucketStdoutRows(result.buckets); return { + next: () => [], human: (): Block[] => [ { kind: "summary", status: "info", text: TITLE }, { diff --git a/packages/cli/src/commands/feedback.ts b/packages/cli/src/commands/feedback.ts index be2200c8..3033fe59 100644 --- a/packages/cli/src/commands/feedback.ts +++ b/packages/cli/src/commands/feedback.ts @@ -92,6 +92,9 @@ function emailInvalidError(value: string): CliStructuredError { function feedbackPresentations(result: FeedbackResult): Presentations { return { + stdout: () => [], + json: () => result, + next: () => [], human: () => [ { kind: "summary", status: "ok", text: "Feedback sent. Thank you!" }, { diff --git a/packages/cli/src/commands/git/connect.ts b/packages/cli/src/commands/git/connect.ts index 510cc1d6..7f8f6241 100644 --- a/packages/cli/src/commands/git/connect.ts +++ b/packages/cli/src/commands/git/connect.ts @@ -126,6 +126,9 @@ function connectPresentations( ): Presentations { const connection = result.repositoryConnection; return { + stdout: () => [], + json: () => result, + next: () => [], human: (): Block[] => [ { kind: "summary", diff --git a/packages/cli/src/commands/git/disconnect.ts b/packages/cli/src/commands/git/disconnect.ts index 0297c33d..e4d1d2d9 100644 --- a/packages/cli/src/commands/git/disconnect.ts +++ b/packages/cli/src/commands/git/disconnect.ts @@ -19,6 +19,9 @@ function disconnectPresentations( result: ProjectRepositoryConnectionResult, ): Presentations { return { + stdout: () => [], + json: () => result, + next: () => [], human: (): Block[] => [ { kind: "summary", diff --git a/packages/cli/src/commands/init/presentation.ts b/packages/cli/src/commands/init/presentation.ts index 6d723284..0710dfcf 100644 --- a/packages/cli/src/commands/init/presentation.ts +++ b/packages/cli/src/commands/init/presentation.ts @@ -73,6 +73,7 @@ export function initPresentations( formatCommand: PrismaCliPackageCommandFormatter, ): Presentations { return { + json: () => result, human: () => [ { diff --git a/packages/cli/src/commands/postgres/backup-list.ts b/packages/cli/src/commands/postgres/backup-list.ts index 1c24240e..a0f66304 100644 --- a/packages/cli/src/commands/postgres/backup-list.ts +++ b/packages/cli/src/commands/postgres/backup-list.ts @@ -27,6 +27,7 @@ function backupListPresentations( const rows = backupRows(result.backups); const stdoutRows = backupStdoutRows(result.backups); return { + next: () => [], human: (): Block[] => [ { kind: "summary", status: "info", text: TITLE }, { diff --git a/packages/cli/src/commands/postgres/connection-create.ts b/packages/cli/src/commands/postgres/connection-create.ts index b093593e..d4fae79e 100644 --- a/packages/cli/src/commands/postgres/connection-create.ts +++ b/packages/cli/src/commands/postgres/connection-create.ts @@ -71,6 +71,8 @@ export const postgresConnectionCreateCommand = defineCommand({ result.connectionString, ), stdout: () => [result.connectionString], + json: () => result, + next: () => [], }, ), ); diff --git a/packages/cli/src/commands/postgres/connection-list.ts b/packages/cli/src/commands/postgres/connection-list.ts index f650106c..de76c4e4 100644 --- a/packages/cli/src/commands/postgres/connection-list.ts +++ b/packages/cli/src/commands/postgres/connection-list.ts @@ -43,6 +43,7 @@ function listPresentations( const rows = connectionRows(result); const stdoutRows = connectionStdoutRows(result); return { + next: () => [], human: (): Block[] => [ { kind: "summary", status: "info", text: TITLE }, { diff --git a/packages/cli/src/commands/postgres/connection-remove.ts b/packages/cli/src/commands/postgres/connection-remove.ts index 3728a068..29cf76d5 100644 --- a/packages/cli/src/commands/postgres/connection-remove.ts +++ b/packages/cli/src/commands/postgres/connection-remove.ts @@ -68,7 +68,9 @@ export const postgresConnectionRemoveCommand = defineCommand({ ], }, ], + stdout: () => [], json: () => ({ connection: result.connection }), + next: () => [], }, ), ); diff --git a/packages/cli/src/commands/postgres/connection-rotate.ts b/packages/cli/src/commands/postgres/connection-rotate.ts index 25144e09..72946c15 100644 --- a/packages/cli/src/commands/postgres/connection-rotate.ts +++ b/packages/cli/src/commands/postgres/connection-rotate.ts @@ -72,6 +72,8 @@ export const postgresConnectionRotateCommand = defineCommand({ result.connectionString, ), stdout: () => [result.connectionString], + json: () => result, + next: () => [], }, ), ); diff --git a/packages/cli/src/commands/postgres/create.ts b/packages/cli/src/commands/postgres/create.ts index 2d0bf270..f36dca93 100644 --- a/packages/cli/src/commands/postgres/create.ts +++ b/packages/cli/src/commands/postgres/create.ts @@ -78,6 +78,8 @@ export const postgresCreateCommand = defineCommand({ result.connectionString, ), stdout: () => [result.connectionString], + json: () => result, + next: () => [], }, ), ); diff --git a/packages/cli/src/commands/postgres/list.ts b/packages/cli/src/commands/postgres/list.ts index b4aaf5c8..ea854eba 100644 --- a/packages/cli/src/commands/postgres/list.ts +++ b/packages/cli/src/commands/postgres/list.ts @@ -40,6 +40,7 @@ function listPresentations(result: DatabaseListResult): Presentations { const rows = databaseRows(result); const stdoutRows = databaseStdoutRows(result); return { + next: () => [], human: (): Block[] => [ { kind: "summary", status: "info", text: TITLE }, { diff --git a/packages/cli/src/commands/postgres/remove.ts b/packages/cli/src/commands/postgres/remove.ts index 5f78b472..3d79e15f 100644 --- a/packages/cli/src/commands/postgres/remove.ts +++ b/packages/cli/src/commands/postgres/remove.ts @@ -20,6 +20,9 @@ const CONSENT_QUESTION = function removePresentations(result: DatabaseRemoveResult): Presentations { return { + stdout: () => [], + json: () => result, + next: () => [], human: (): Block[] => [ { kind: "summary", status: "ok", text: "Removing database." }, { diff --git a/packages/cli/src/commands/postgres/restore.ts b/packages/cli/src/commands/postgres/restore.ts index fcdc2ac8..960f9177 100644 --- a/packages/cli/src/commands/postgres/restore.ts +++ b/packages/cli/src/commands/postgres/restore.ts @@ -37,6 +37,8 @@ function restorePresentations( ]; return { + stdout: () => [], + json: () => result, human: (): Block[] => [ { kind: "summary", diff --git a/packages/cli/src/commands/postgres/show.ts b/packages/cli/src/commands/postgres/show.ts index b0eba814..3954dbfc 100644 --- a/packages/cli/src/commands/postgres/show.ts +++ b/packages/cli/src/commands/postgres/show.ts @@ -53,6 +53,8 @@ function stdoutFieldRows(result: DatabaseShowResult): FieldRow[] { function showPresentations(result: DatabaseShowResult): Presentations { const rows = fieldRows(result); return { + json: () => result, + next: () => [], human: (): Block[] => [ { kind: "summary", status: "info", text: TITLE }, { kind: "fields", rows }, diff --git a/packages/cli/src/commands/postgres/usage.ts b/packages/cli/src/commands/postgres/usage.ts index e34d59ac..d8287f6f 100644 --- a/packages/cli/src/commands/postgres/usage.ts +++ b/packages/cli/src/commands/postgres/usage.ts @@ -67,6 +67,8 @@ function stdoutFieldRows(result: DatabaseUsageResult): FieldRow[] { function usagePresentations(result: DatabaseUsageResult): Presentations { const rows = fieldRows(result); return { + json: () => result, + next: () => [], human: (): Block[] => [ { kind: "summary", status: "info", text: TITLE }, { kind: "fields", rows }, diff --git a/packages/cli/src/commands/project/env-add.ts b/packages/cli/src/commands/project/env-add.ts index d1d889df..75494e18 100644 --- a/packages/cli/src/commands/project/env-add.ts +++ b/packages/cli/src/commands/project/env-add.ts @@ -39,6 +39,9 @@ const TITLE = "Setting a new environment variable."; function singlePresentations(result: EnvAddResult): Presentations { return { + stdout: () => [], + json: () => result, + next: () => [], human: (): Block[] => [ { kind: "summary", status: "info", text: TITLE }, { @@ -127,13 +130,16 @@ export const projectEnvAddCommand = defineCommand({ data: result, diagnostics: previewDefaultDiagnostics(written.warnings), }, - fileWritePresentations({ - title: "Setting new environment variables from file.", - emptyMessage: "No environment variables imported.", - scope: result.scope, - filePath: result.file.path, - variables: result.variables, - }), + fileWritePresentations( + { + title: "Setting new environment variables from file.", + emptyMessage: "No environment variables imported.", + scope: result.scope, + filePath: result.file.path, + variables: result.variables, + }, + result, + ), ), ); } diff --git a/packages/cli/src/commands/project/env-remove.ts b/packages/cli/src/commands/project/env-remove.ts index a679f91d..138d2e46 100644 --- a/packages/cli/src/commands/project/env-remove.ts +++ b/packages/cli/src/commands/project/env-remove.ts @@ -28,6 +28,9 @@ const TITLE = "Removing the environment variable from the scope."; function removePresentations(result: EnvRmResult): Presentations { return { + stdout: () => [], + json: () => result, + next: () => [], human: (): Block[] => [ { kind: "summary", status: "info", text: TITLE }, { diff --git a/packages/cli/src/commands/project/env-shared.ts b/packages/cli/src/commands/project/env-shared.ts index 24bad5b6..bb74f8b3 100644 --- a/packages/cli/src/commands/project/env-shared.ts +++ b/packages/cli/src/commands/project/env-shared.ts @@ -138,15 +138,21 @@ export function variableFieldRows( ]; } -export function fileWritePresentations(input: { - readonly title: string; - readonly emptyMessage: string; - readonly scope: EnvScopeDescriptor; - readonly filePath: string; - readonly variables: readonly EnvVariableMetadata[]; -}): Presentations { +export function fileWritePresentations( + input: { + readonly title: string; + readonly emptyMessage: string; + readonly scope: EnvScopeDescriptor; + readonly filePath: string; + readonly variables: readonly EnvVariableMetadata[]; + }, + result: unknown, +): Presentations { const rows = variableRows(input.variables); return { + stdout: () => [], + json: () => result, + next: () => [], human: (): Block[] => [ { kind: "summary", status: "info", text: input.title }, { diff --git a/packages/cli/src/commands/project/env-update.ts b/packages/cli/src/commands/project/env-update.ts index a2a29042..7a724bd0 100644 --- a/packages/cli/src/commands/project/env-update.ts +++ b/packages/cli/src/commands/project/env-update.ts @@ -38,6 +38,9 @@ const TITLE = "Replacing the environment variable's value."; function singlePresentations(result: EnvUpdateResult): Presentations { return { + stdout: () => [], + json: () => result, + next: () => [], human: (): Block[] => [ { kind: "summary", status: "info", text: TITLE }, { @@ -121,13 +124,16 @@ export const projectEnvUpdateCommand = defineCommand({ return ok( ctx.present( { data: result }, - fileWritePresentations({ - title: "Replacing environment variable values from file.", - emptyMessage: "No environment variables updated.", - scope: result.scope, - filePath: result.file.path, - variables: result.variables, - }), + fileWritePresentations( + { + title: "Replacing environment variable values from file.", + emptyMessage: "No environment variables updated.", + scope: result.scope, + filePath: result.file.path, + variables: result.variables, + }, + result, + ), ), ); } diff --git a/packages/cli/src/commands/project/presentation.ts b/packages/cli/src/commands/project/presentation.ts index 19a91436..6deecf93 100644 --- a/packages/cli/src/commands/project/presentation.ts +++ b/packages/cli/src/commands/project/presentation.ts @@ -43,6 +43,7 @@ export function toNextActions( export function setupPresentations(result: ProjectSetupResult): Presentations { return { + stdout: () => [], human: () => [ ...(result.action === "created" ? [ diff --git a/packages/cli/src/commands/project/remove.ts b/packages/cli/src/commands/project/remove.ts index b579e8dd..90c9ef57 100644 --- a/packages/cli/src/commands/project/remove.ts +++ b/packages/cli/src/commands/project/remove.ts @@ -24,6 +24,9 @@ const CONSENT_QUESTION = function removePresentations(result: ProjectRemoveResult): Presentations { return { + stdout: () => [], + json: () => result, + next: () => [], human: (): Block[] => [ { kind: "summary", status: "ok", text: "Removing project." }, { diff --git a/packages/cli/src/commands/project/rename.ts b/packages/cli/src/commands/project/rename.ts index 6969254c..31936821 100644 --- a/packages/cli/src/commands/project/rename.ts +++ b/packages/cli/src/commands/project/rename.ts @@ -18,6 +18,9 @@ import { mapProjectOperationError } from "./errors"; function renamePresentations(result: ProjectRenameResult): Presentations { return { + stdout: () => [], + json: () => result, + next: () => [], human: () => [ { kind: "summary", status: "ok", text: "Renaming project." }, { diff --git a/packages/cli/src/commands/project/show.ts b/packages/cli/src/commands/project/show.ts index 29feb7e1..9f216afd 100644 --- a/packages/cli/src/commands/project/show.ts +++ b/packages/cli/src/commands/project/show.ts @@ -72,6 +72,7 @@ function showPresentations( ): Presentations { const rows = fieldRows(result, cwd, env); return { + json: () => result, human: () => [ result.project === null ? { diff --git a/packages/cli/src/commands/project/transfer.ts b/packages/cli/src/commands/project/transfer.ts index 33b98e91..68025859 100644 --- a/packages/cli/src/commands/project/transfer.ts +++ b/packages/cli/src/commands/project/transfer.ts @@ -122,6 +122,8 @@ function transferPresentations( toWorkspace: string | undefined, ): Presentations { return { + stdout: () => [], + json: () => result, human: (): Block[] => [ { kind: "summary", status: "ok", text: "Transferring project." }, { diff --git a/packages/cli/src/commands/service/presentation.ts b/packages/cli/src/commands/service/presentation.ts index 510d5234..161109d9 100644 --- a/packages/cli/src/commands/service/presentation.ts +++ b/packages/cli/src/commands/service/presentation.ts @@ -86,6 +86,7 @@ function domainFailureRows(domain: ServiceDomainSummary): FieldRow[] { export function listPresentations(result: ServiceListResult): Presentations { return { + json: () => result, human: () => [ title("Listing services for the selected project."), fields([ @@ -150,6 +151,8 @@ export function createPresentations( result: ServiceCreateResult, ): Presentations { return { + stdout: () => [], + json: () => result, human: () => [ result.existing ? title( @@ -195,6 +198,8 @@ export function showPresentations(result: ServiceShowResult): Presentations { ); } return { + stdout: () => [], + json: () => result, human: () => [ title("Showing the selected service state."), fields([ @@ -219,6 +224,8 @@ export function deploymentListPresentations( result: ServiceDeploymentListResult, ): Presentations { return { + stdout: () => [], + json: () => result, human: () => [ title("Listing deployments for the selected service."), fields([ @@ -262,6 +269,9 @@ export function deploymentShowPresentations( result: ServiceDeploymentShowResult, ): Presentations { return { + stdout: () => [], + json: () => result, + next: () => [], human: () => [ title("Showing deployment details."), fields([ @@ -287,6 +297,7 @@ export function openPresentations( liveDeploymentId: string, ): Presentations { return { + json: () => result, human: () => [ result.opened ? completed("Opened the live URL for the selected service.") @@ -324,6 +335,8 @@ export function promotePresentations( alreadyLive: boolean, ): Presentations { return { + stdout: () => [], + json: () => result, human: () => [ completed( alreadyLive @@ -349,6 +362,8 @@ export function rollbackPresentations( alreadyLive: boolean, ): Presentations { return { + stdout: () => [], + json: () => result, human: () => [ completed( alreadyLive @@ -377,6 +392,8 @@ export function deploymentStartPresentations( result: ServiceDeploymentRunStateResult, ): Presentations { return { + stdout: () => [], + json: () => result, human: () => [ completed( result.alreadyInState @@ -401,6 +418,8 @@ export function deploymentStopPresentations( result: ServiceDeploymentRunStateResult, ): Presentations { return { + stdout: () => [], + json: () => result, human: () => [ completed( result.alreadyInState @@ -422,6 +441,8 @@ export function deploymentDeletePresentations( result: ServiceDeploymentDeleteResult, ): Presentations { return { + stdout: () => [], + json: () => result, human: () => [ completed(`Deleted ${result.deploymentId} from ${result.service.name}.`), fields([ @@ -441,6 +462,8 @@ export function removePresentations( result: ServiceRemoveResult, ): Presentations { return { + stdout: () => [], + json: () => result, human: () => [ completed( `Removed ${result.service.name} and every deployment it owned.`, @@ -461,6 +484,8 @@ export function domainAddPresentations( result: ServiceDomainAddResult, ): Presentations { return { + stdout: () => [], + json: () => result, human: () => [ result.existing ? title("Showing the existing custom domain for the selected service.") @@ -491,6 +516,8 @@ export function domainShowPresentations( result: ServiceDomainShowResult, ): Presentations { return { + stdout: () => [], + json: () => result, human: () => [ title("Showing custom domain status."), fields([ @@ -532,6 +559,9 @@ export function domainRemovePresentations( result: ServiceDomainRemoveResult, ): Presentations { return { + stdout: () => [], + json: () => result, + next: () => [], human: () => [ completed(`Removed ${result.hostname} from ${result.service.name}.`), fields([ @@ -547,6 +577,8 @@ export function domainRetryPresentations( result: ServiceDomainRetryResult, ): Presentations { return { + stdout: () => [], + json: () => result, human: () => [ completed(`Retried verification for ${result.domain.hostname}.`), fields([ @@ -570,6 +602,9 @@ export function domainWaitPresentations( result: ServiceDomainWaitResult, ): Presentations { return { + stdout: () => [], + json: () => result, + next: () => [], human: () => [ completed(`${result.hostname} is live at ${result.liveUrl}`), fields([ diff --git a/packages/cli/tests/auth.test.ts b/packages/cli/tests/auth.test.ts index bbb98dff..069c2c2d 100644 --- a/packages/cli/tests/auth.test.ts +++ b/packages/cli/tests/auth.test.ts @@ -373,7 +373,12 @@ describe("sessions held with none selected", () => { ok( ctx.present( { data: null }, - { human: () => [{ kind: "summary", status: "ok", text: "ran" }] }, + { + human: () => [{ kind: "summary", status: "ok", text: "ran" }], + stdout: () => [], + json: () => null, + next: () => [], + }, ), ), }); @@ -385,7 +390,12 @@ describe("sessions held with none selected", () => { return ok( ctx.present( { data: null }, - { human: () => [{ kind: "summary", status: "ok", text: "ran" }] }, + { + human: () => [{ kind: "summary", status: "ok", text: "ran" }], + stdout: () => [], + json: () => null, + next: () => [], + }, ), ); }, @@ -742,7 +752,17 @@ describe("the environment credential carries no refresh token", () => { help: { summary: "Issues one management API request" }, handler: async (_args, ctx) => { await ctx.api.GET("/v1/me", {}); - return ok(ctx.present({ data: null }, { human: () => [] })); + return ok( + ctx.present( + { data: null }, + { + human: () => [], + stdout: () => [], + json: () => null, + next: () => [], + }, + ), + ); }, }); diff --git a/packages/cli/tests/golden-rendering.test.ts b/packages/cli/tests/golden-rendering.test.ts index 6ed0468e..ad4d31fd 100644 --- a/packages/cli/tests/golden-rendering.test.ts +++ b/packages/cli/tests/golden-rendering.test.ts @@ -238,7 +238,17 @@ describe("the restored card matches the shell it replaced", () => { const show = defineCommand({ help: { summary: "Draw the card" }, handler: async (_args, ctx) => - ok(ctx.present({ data: null }, { human: () => [card] })), + ok( + ctx.present( + { data: null }, + { + human: () => [card], + stdout: () => [], + json: () => null, + next: () => [], + }, + ), + ), }); const result = await createTestCli({ commands: { show } }).run(["show"], { isTty: { stdout: true, stderr: true }, diff --git a/packages/cli/tests/mount-coverage.test.ts b/packages/cli/tests/mount-coverage.test.ts index 9024bf63..abfc7ebf 100644 --- a/packages/cli/tests/mount-coverage.test.ts +++ b/packages/cli/tests/mount-coverage.test.ts @@ -216,7 +216,17 @@ describe("the completeness comparisons report what is wrong", () => { defineCommand({ help: { summary }, handler: async (_args, ctx) => - ok(ctx.present({ data: null, exitCode: 0 }, { human: () => [] })), + ok( + ctx.present( + { data: null, exitCode: 0 }, + { + human: () => [], + stdout: () => [], + json: () => null, + next: () => [], + }, + ), + ), }); const kept = toy("kept"); diff --git a/packages/cli/tests/whoami.test.ts b/packages/cli/tests/whoami.test.ts index a6700df8..65902e8d 100644 --- a/packages/cli/tests/whoami.test.ts +++ b/packages/cli/tests/whoami.test.ts @@ -48,7 +48,12 @@ const requiresCredentials = defineCommand({ ok( ctx.present( { data: null }, - { human: () => [{ kind: "summary", status: "ok", text: "ran" }] }, + { + human: () => [{ kind: "summary", status: "ok", text: "ran" }], + stdout: () => [], + json: () => null, + next: () => [], + }, ), ), needs: { credentials: true }, From 06abf70d9c7eef03b90f90bf36ad84278e45311a Mon Sep 17 00:00:00 2001 From: willbot Date: Wed, 12 Aug 2026 20:03:08 +0200 Subject: [PATCH 2/4] docs(drive): record the concurrent-rebuild race in npm test Both package test scripts now begin with pnpm run build, so turbo runs the engine's build while the CLI's vitest imports the dist it is rewriting. The failure is "Cannot find package '@prisma/cli-engine/testing'" at file level, and the count varies: 13, 34 and 43 across three runs of one branch. Serial turbo and a direct vitest run both pass all 60. The existing entry said the CLI script was a bare vitest run and that turbo was therefore honest. The build step has since been added to both scripts, which is what created the race. Co-Authored-By: Claude Opus 5 Signed-off-by: willbot Signed-off-by: Will Madden --- .drive/projects/prisma-cli-v8/deferred.md | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/.drive/projects/prisma-cli-v8/deferred.md b/.drive/projects/prisma-cli-v8/deferred.md index fed5bb82..4cff17b8 100644 --- a/.drive/projects/prisma-cli-v8/deferred.md +++ b/.drive/projects/prisma-cli-v8/deferred.md @@ -165,21 +165,7 @@ CLI does not do, and each restarts as engine work if wanted: (`assets/s2/parity-divergences-s3.md`), which also corrects this item's claim that legacy rejected non-integers — legacy truncated them silently, and rejected only negatives and `NaN`. -- **`pnpm --filter @prisma/cli test` can report green against a stale - engine build.** Vitest resolves `@prisma/cli-engine` through the - package's own `exports` map, which points at `./dist`; the `paths` - entries in `tsconfig.json` are read by `tsc`, not vitest, and no - path-resolving plugin is configured. `packages/cli`'s `test` script is - a bare `vitest run` with no build step, so run on its own it exercises - whatever engine `dist` happens to be on disk. The engine's own `test` - script builds first, so a gate that runs the engine suite before the - CLI suite — as every gate in this project does — is honest, and - `turbo run test` is honest too because `turbo.json` declares `test` as - `dependsOn: ["^build"]`. The trap is running one filter in isolation - after editing engine source. Surfaced in the engine-colour slice when - a deliberately introduced defect failed to fail. The mechanism to fix - it already exists in `turbo.json`; the change is to `packages/cli`'s - test script. +- **Two packages rebuild the same `dist` while the other reads it, so `npm test` fails at random.** Vitest resolves `@prisma/cli-engine` through the package's own `exports` map, which points at `./dist`; the `paths` entries in `tsconfig.json` are read by `tsc`, not vitest, and no path-resolving plugin is configured. Both `test` scripts now begin with `pnpm run build` — the engine's is `pnpm run build && pnpm run typecheck && vitest run`, the CLI's is `pnpm run build && vitest run` — so when turbo runs them concurrently the engine rewrites `packages/cli-engine/dist` while the CLI's vitest is importing from it. The failure is `Cannot find package '@prisma/cli-engine/testing'`, reported at file level, and the count varies run to run: 13, 34 and 43 files across three runs of one branch, with `npx turbo run test --concurrency=1` passing all 60 every time and `npx vitest run` inside `packages/cli` passing all 60 too. Nothing is wrong with the code when this fires, which is the danger — it looks like a mass breakage. This entry previously said the CLI's script was a bare `vitest run` and that `turbo run test` was therefore honest; the build step has since been added to both, which is what created the race. The fix is to stop the two tasks racing on one directory: let `turbo.json`'s `dependsOn: ["^build"]` do the sequencing and drop the build from the package `test` scripts, or give each package its own build output to read. - **`spawn-real-child.test.ts` also fails under load, and is a different test from the one below.** In `packages/cli-engine/tests/spawn-real-child.test.ts`, the case "native Ctrl-C reaches the child through the shared process group" failed twice during the engine-colour slice, both times on a machine running the engine and CLI suites concurrently — on the second sighting that run's import phase took 92s against a normal 3–7s. It passed on every isolated and sequential run either side. Nothing in that slice goes near spawn or signals, so this is not its doing. Two independent sightings under load make it worth diagnosing rather than watching: the likely shape is the same as the entry below, a test that waits on a marker the child writes before it is actually ready for the signal. Third sighting, 2026-08-12: it failed on a GitHub runner during #158, a PR that changes no engine file, and passed on a re-run of the same commit and on the same machine in isolation. That moves it from a loaded-laptop annoyance to a test that reddens the shared `Test` check on unrelated work, which teaches people to re-run a red check rather than read it. Worth fixing before the next slice rather than after. - **`v8-spawn-adapter.test.ts` has a race that fails under load.** In `packages/cli/tests/v8-spawn-adapter.test.ts`, the "kill From 4c0c8645bf0da34dc625d4fbc8606b2fa37986a4 Mon Sep 17 00:00:00 2001 From: willbot Date: Wed, 12 Aug 2026 20:20:25 +0200 Subject: [PATCH 3/4] test(cli-engine): pin the envelope's result to the json presentation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every fixture passed the same value as `data` and as the json presentation, so none of them could tell which one the envelope published. A new fixture makes them differ, and the test fails if settlement goes back to publishing `data`. Verified by making settlement publish `data` and watching it fail, after rebuilding — the engine's own tests resolve the engine through `dist`, so a source edit alone proves nothing. Also restores the deferred entry about a single filter reporting green against a stale engine build, which the previous commit deleted by mistake. The concurrency race it was replaced with was already recorded under S6, in more detail; that entry gains the second error message and the varying failure count seen here. Co-Authored-By: Claude Opus 5 Signed-off-by: willbot Signed-off-by: Will Madden --- .drive/projects/prisma-cli-v8/deferred.md | 24 +++++++++++- .../src/execution/command-context.ts | 26 ++++++++----- packages/cli-engine/tests/execution.test.ts | 38 +++++++++++++++++++ 3 files changed, 77 insertions(+), 11 deletions(-) diff --git a/.drive/projects/prisma-cli-v8/deferred.md b/.drive/projects/prisma-cli-v8/deferred.md index 4cff17b8..b4a922a5 100644 --- a/.drive/projects/prisma-cli-v8/deferred.md +++ b/.drive/projects/prisma-cli-v8/deferred.md @@ -165,7 +165,21 @@ CLI does not do, and each restarts as engine work if wanted: (`assets/s2/parity-divergences-s3.md`), which also corrects this item's claim that legacy rejected non-integers — legacy truncated them silently, and rejected only negatives and `NaN`. -- **Two packages rebuild the same `dist` while the other reads it, so `npm test` fails at random.** Vitest resolves `@prisma/cli-engine` through the package's own `exports` map, which points at `./dist`; the `paths` entries in `tsconfig.json` are read by `tsc`, not vitest, and no path-resolving plugin is configured. Both `test` scripts now begin with `pnpm run build` — the engine's is `pnpm run build && pnpm run typecheck && vitest run`, the CLI's is `pnpm run build && vitest run` — so when turbo runs them concurrently the engine rewrites `packages/cli-engine/dist` while the CLI's vitest is importing from it. The failure is `Cannot find package '@prisma/cli-engine/testing'`, reported at file level, and the count varies run to run: 13, 34 and 43 files across three runs of one branch, with `npx turbo run test --concurrency=1` passing all 60 every time and `npx vitest run` inside `packages/cli` passing all 60 too. Nothing is wrong with the code when this fires, which is the danger — it looks like a mass breakage. This entry previously said the CLI's script was a bare `vitest run` and that `turbo run test` was therefore honest; the build step has since been added to both, which is what created the race. The fix is to stop the two tasks racing on one directory: let `turbo.json`'s `dependsOn: ["^build"]` do the sequencing and drop the build from the package `test` scripts, or give each package its own build output to read. +- **`pnpm --filter @prisma/cli test` can report green against a stale + engine build.** Vitest resolves `@prisma/cli-engine` through the + package's own `exports` map, which points at `./dist`; the `paths` + entries in `tsconfig.json` are read by `tsc`, not vitest, and no + path-resolving plugin is configured. `packages/cli`'s `test` script is + a bare `vitest run` with no build step, so run on its own it exercises + whatever engine `dist` happens to be on disk. The engine's own `test` + script builds first, so a gate that runs the engine suite before the + CLI suite — as every gate in this project does — is honest, and + `turbo run test` is honest too because `turbo.json` declares `test` as + `dependsOn: ["^build"]`. The trap is running one filter in isolation + after editing engine source. Surfaced in the engine-colour slice when + a deliberately introduced defect failed to fail. The mechanism to fix + it already exists in `turbo.json`; the change is to `packages/cli`'s + test script. - **`spawn-real-child.test.ts` also fails under load, and is a different test from the one below.** In `packages/cli-engine/tests/spawn-real-child.test.ts`, the case "native Ctrl-C reaches the child through the shared process group" failed twice during the engine-colour slice, both times on a machine running the engine and CLI suites concurrently — on the second sighting that run's import phase took 92s against a normal 3–7s. It passed on every isolated and sequential run either side. Nothing in that slice goes near spawn or signals, so this is not its doing. Two independent sightings under load make it worth diagnosing rather than watching: the likely shape is the same as the entry below, a test that waits on a marker the child writes before it is actually ready for the signal. Third sighting, 2026-08-12: it failed on a GitHub runner during #158, a PR that changes no engine file, and passed on a re-run of the same commit and on the same machine in isolation. That moves it from a loaded-laptop annoyance to a test that reddens the shared `Test` check on unrelated work, which teaches people to re-run a red check rather than read it. Worth fixing before the next slice rather than after. - **`v8-spawn-adapter.test.ts` has a race that fails under load.** In `packages/cli/tests/v8-spawn-adapter.test.ts`, the "kill @@ -350,6 +364,14 @@ CLI does not do, and each restarts as engine work if wanted: build` from the engine's `test` script and let turbo's `^build` dependency do that work, or stop the engine's build cleaning a directory another package reads while it runs. + Seen again on the presentations branch (2026-08-12) with a second + message for the same cause — `Cannot find package + '@prisma/cli-engine/testing'` — and a failure count that varied 13, + 34 and 43 files across three runs of one commit, while + `--concurrency=1` and a direct `npx vitest run` in `packages/cli` + both passed all 60 every time. The varying count is the tell: a + change that touches many files shifts the timing and makes it fire + more often, which reads as "this branch broke everything". - **The packed shell manifest carries `devDependencies` on private packages at versions no registry has** — `@repo/cli-telemetry` and `@repo/tsconfig`, both at `8.0.0-rc.1`. Harmless when a consumer diff --git a/packages/cli-engine/src/execution/command-context.ts b/packages/cli-engine/src/execution/command-context.ts index faa1dd57..b15378ef 100644 --- a/packages/cli-engine/src/execution/command-context.ts +++ b/packages/cli-engine/src/execution/command-context.ts @@ -55,16 +55,22 @@ export function makeUi(colorEnabled: boolean, stderr: OutputStream): Ui { /** Materializes ONLY the active format's presentation functions, at the * return site: human → human + stdout + next; json → json + next. * - * `next` is still called optionally, and it is the one field here that - * is. `Presentations` requires all four, so nothing compiled against - * this engine can omit one — but a family compiled against an earlier - * engine can, and one does: `@prisma/orm-toolchain@8.0.0-rc.1-dev.40` - * declares `human`, `stdout` and `json` for `migration list` and no - * `next`. Calling it unconditionally makes that command exit 2. - * `stdout` and `json` are called unconditionally because both published - * families already declare them. Drop the `?? []` when the toolchain and - * composer pins converge on this engine — the tandem release - * `deferred.md` describes. */ + * `next` may be absent at runtime, so it alone is called with `?.()`. + * `Presentations` requires all four, so no command compiled against + * this engine can omit one — but a command family compiled against an + * earlier engine can, and one shipped does: + * `@prisma/orm-toolchain@8.0.0-rc.1-dev.40` declares `human`, `stdout` + * and `json` for `migration list` and no `next`, so calling `next` + * unconditionally makes that command exit 2. + * + * `stdout` and `json` are called unconditionally, which was checked + * rather than assumed: both published families declare both, and + * requiring them breaks no test. They stay strict because a missing + * `json` is the defect this change exists to remove — it used to make + * the envelope publish the handler's internal `data`. + * + * Delete the `?.()` when the toolchain and composer pins converge on + * this engine, in the tandem release `deferred.md` describes. */ function materializePresentation( state: RunState, ui: Ui, diff --git a/packages/cli-engine/tests/execution.test.ts b/packages/cli-engine/tests/execution.test.ts index 346dd1af..cbffe4c4 100644 --- a/packages/cli-engine/tests/execution.test.ts +++ b/packages/cli-engine/tests/execution.test.ts @@ -120,6 +120,34 @@ const throwing = defineCommand({ }, }); +/** Its `data` and its json presentation deliberately differ. Every other + * fixture here passes the same value to both, so none of them can tell + * which one the envelope published — `data` is what the handler worked + * with, `result` is what the command chose to publish, and the engine + * used to publish `data` for any command that declared no json. + * + * What this catches: settlement publishing `data` rather than the json + * presentation. What it does not catch: the old conditional fallback + * returning, because this command declares a json presentation, so the + * fallback's branch is never taken. Nothing compiled against this + * engine can omit `json`, so that branch is unreachable rather than + * untested. */ +const divergent = defineCommand({ + help: { summary: "Publishes something other than its working data" }, + handler: async (_args, ctx) => + ok( + ctx.present( + { data: { internal: "the handler's own object" } }, + { + human: () => [], + stdout: () => [], + json: () => ({ published: "what the command states" }), + next: () => [], + }, + ), + ), +}); + const whoami = defineCommand({ help: { summary: "Show the signed-in user" }, needs: { credentials: true }, @@ -152,6 +180,7 @@ function makeCli() { failing, check, throwing, + divergent, "tool greet": greet, "auth whoami": whoami, }, @@ -204,6 +233,15 @@ describe("completed commands", () => { ]); }); + test("the envelope's result is the json presentation, never the handler's data", async () => { + const result = await makeCli().run(["divergent", "--json"]); + + const last = result.json[result.json.length - 1]; + expect( + last.kind === "result" && last.envelope.ok && last.envelope.result, + ).toEqual({ published: "what the command states" }); + }); + test("commandId is the full dotted mount path", async () => { const result = await makeCli().run(["tool", "greet", "world", "--json"]); From 2a66b84fb1f92b29c96ab0f313b4dced4b577639 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 13 Aug 2026 08:41:53 +0200 Subject: [PATCH 4/4] fix(cli-engine): stdout may be absent at runtime too, and prove it in human mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I reported that requiring `stdout` at runtime was safe because the ORM mount test passed. That test runs in json mode, and json mode never calls `stdout` — so it could not have detected the failure it was cited as ruling out. orm-toolchain's `migration list` declares `human` and `json` and neither of the other two. In human mode, the default for a user at a terminal, the strict call made it exit 2. `stdout` now uses the same optional call as `next`. `json` stays strict: every ORM command declares one, and a missing json presentation is the defect this change removes. The mount test gains a human-mode run of the same command, because the two formats call different presentation functions and a run in one proves nothing about the other. The real fix is not this shim. orm-toolchain is our code; declaring the missing presentations there and building it against this engine makes the type refuse to compile without them. Both `?.()` come out when that version is pinned here. Co-Authored-By: Claude Opus 5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../src/execution/command-context.ts | 32 ++++++++++--------- packages/cli/tests/orm-mount.test.ts | 23 +++++++++++++ 2 files changed, 40 insertions(+), 15 deletions(-) diff --git a/packages/cli-engine/src/execution/command-context.ts b/packages/cli-engine/src/execution/command-context.ts index b15378ef..1062b8d9 100644 --- a/packages/cli-engine/src/execution/command-context.ts +++ b/packages/cli-engine/src/execution/command-context.ts @@ -55,22 +55,24 @@ export function makeUi(colorEnabled: boolean, stderr: OutputStream): Ui { /** Materializes ONLY the active format's presentation functions, at the * return site: human → human + stdout + next; json → json + next. * - * `next` may be absent at runtime, so it alone is called with `?.()`. - * `Presentations` requires all four, so no command compiled against - * this engine can omit one — but a command family compiled against an - * earlier engine can, and one shipped does: - * `@prisma/orm-toolchain@8.0.0-rc.1-dev.40` declares `human`, `stdout` - * and `json` for `migration list` and no `next`, so calling `next` - * unconditionally makes that command exit 2. + * `stdout` and `next` may be absent at runtime, so both are called with + * `?.()`. `Presentations` requires all four, so no command compiled + * against this engine can omit one — but `@prisma/orm-toolchain` is + * built against engine `0.0.9`, where three of the four were optional, + * and its published commands took that up: `migration list` declares + * `human` and `json` and neither of the others. Calling them + * unconditionally makes it exit 2 — `stdout` in human mode, `next` in + * both. * - * `stdout` and `json` are called unconditionally, which was checked - * rather than assumed: both published families declare both, and - * requiring them breaks no test. They stay strict because a missing - * `json` is the defect this change exists to remove — it used to make - * the envelope publish the handler's internal `data`. + * `json` is called unconditionally, and stays that way: a missing json + * presentation is the defect this change removes, and every ORM command + * already declares one. * - * Delete the `?.()` when the toolchain and composer pins converge on - * this engine, in the tandem release `deferred.md` describes. */ + * This is version skew in our own code, not a foreign contract. The fix + * is in prisma/prisma: declare the missing presentations in the ORM + * commands and build orm-toolchain against this engine, where the type + * refuses to compile without them. Delete both `?.()` when that + * version is pinned here. */ function materializePresentation( state: RunState, ui: Ui, @@ -86,7 +88,7 @@ function materializePresentation( } return { human: presentations.human(ui), - stdout: presentations.stdout(), + stdout: presentations.stdout?.() ?? [], json: undefined, next: presentations.next?.() ?? [], }; diff --git a/packages/cli/tests/orm-mount.test.ts b/packages/cli/tests/orm-mount.test.ts index 9faa5272..cfa07f09 100644 --- a/packages/cli/tests/orm-mount.test.ts +++ b/packages/cli/tests/orm-mount.test.ts @@ -87,6 +87,29 @@ describe("the ORM family answers from the assembled tree", () => { }); }); + /** + * The same command in the other format, because the two formats call + * different presentation functions: json mode calls `json` and `next` + * and never touches `stdout`, human mode calls `human`, `stdout` and + * `next` and never touches `json`. A run in one format therefore + * proves nothing about the other. + * + * This exists because it was missed. #171 made the engine call every + * presentation a command declares, was checked against the json test + * above, and was reported safe for `stdout` on that basis — while + * orm-toolchain's `migration list` declares no `stdout` at all, so + * human mode would have exited 2 for every user at a terminal. + */ + it("runs migration list in human mode, which calls different presentations", async () => { + const result = await shell({ orm: ORM_SECTION }).run( + ["migration", "list", "--format", "human"], + { cwd: ORM_PROJECT_DIR }, + ); + + expect(result.exitCode).toBe(0); + expect(result.stderr).toContain("migration"); + }); + it("validates the family's config section before running a command", async () => { const result = await shell({}).run(["migration", "list", "--json"], { cwd: ORM_PROJECT_DIR,