diff --git a/.changeset/migrate-json-exit-code.md b/.changeset/migrate-json-exit-code.md new file mode 100644 index 0000000000..f5329b6de6 --- /dev/null +++ b/.changeset/migrate-json-exit-code.md @@ -0,0 +1,39 @@ +--- +"@objectstack/cli": patch +--- + +fix(cli): `os migrate --json` no longer exits with its own runtime as the status code (#4873) + +A **successful** `os migrate recorded-by --json` returned a different non-zero +exit code on every invocation — 208, 171, 176, 163, 62, 19, 48, 57 — while +printing correct JSON, printing `✅ Graceful shutdown complete`, and leaving +stderr completely empty. `os migrate resume --json` had it too. Nothing that an +author reads was wrong; the only thing that was wrong is the only thing a CI +step, a `set -e` script, a Makefile, or a container entrypoint reads. `--json` +exists for programs, and the first thing a program consumes is the exit status. + +**Root cause.** `emitJson(payload, exitCode, opts)` takes its exit code as the +second positional argument, and both commands were passing `timer.elapsed()` +there — a duration in milliseconds. So a run that took 531 ms set +`process.exitCode = 531`, and the shell saw `531 & 0xFF` = 19. The codes looked +random because they *were* the run's duration, and no two runs take the same +number of milliseconds. + +It was not what it looked like from the outside: no native `abort` during +teardown, no libsql/sqlite handle, no `safeExit`, and not a leftover of #4813 +(whose 120-second hang is fixed and unrelated — the random codes predate and +survive it). + +**What changed.** + +- Both commands now report their duration where every other `--json` command in + this CLI already reports it — inside the payload, as `duration`. A successful + run exits `0`; a failing one still exits `1`, unchanged. +- `emitJson` / `emitText` narrow that parameter from `number` to + `CliExitCode = 0 | 1`, so handing a duration (or any other stray number) to + the exit-code slot is now a compile error instead of a silent false failure. + +**Payload change.** `os migrate recorded-by --json` and `os migrate resume +--json` gained a `duration` key (milliseconds). Consumers that were reading the +exit status of these two commands should note that a zero now means what it +says. diff --git a/packages/cli/src/commands/migrate/recorded-by.ts b/packages/cli/src/commands/migrate/recorded-by.ts index c53573cb4f..793f059b92 100644 --- a/packages/cli/src/commands/migrate/recorded-by.ts +++ b/packages/cli/src/commands/migrate/recorded-by.ts @@ -126,7 +126,7 @@ export default class MigrateRecordedBy extends Command { // ── dry run (default): read-only ───────────────────────────────── if (!flags.apply) { if (flags.json) { - await emitJson({ planId: RECORDED_BY_SENTINEL_PLAN_ID, sentinel: RECORDED_BY_SENTINEL, pending: pending.length, applied: false }, timer.elapsed()); + await emitJson({ planId: RECORDED_BY_SENTINEL_PLAN_ID, sentinel: RECORDED_BY_SENTINEL, pending: pending.length, applied: false, duration: timer.elapsed() }); return; } if (pending.length === 0) { @@ -141,7 +141,7 @@ export default class MigrateRecordedBy extends Command { // ── apply ──────────────────────────────────────────────────────── if (pending.length === 0) { const msg = `No sys_metadata_history row holds the '${RECORDED_BY_SENTINEL}' sentinel — nothing to convert.`; - if (flags.json) { await emitJson({ planId: RECORDED_BY_SENTINEL_PLAN_ID, pending: 0, applied: true, status: 'completed', chunksCommitted: 0 }, timer.elapsed()); return; } + if (flags.json) { await emitJson({ planId: RECORDED_BY_SENTINEL_PLAN_ID, pending: 0, applied: true, status: 'completed', chunksCommitted: 0, duration: timer.elapsed() }); return; } printSuccess(msg); return; } @@ -149,7 +149,7 @@ export default class MigrateRecordedBy extends Command { if (!flags.yes) { const summary = `Rewrite recorded_by '${RECORDED_BY_SENTINEL}' → NULL on ${pending.length} row(s)`; if (flags.json || !process.stdin.isTTY) { - if (flags.json) { await emitJson({ error: 'confirmation_required', hint: 'pass --yes', summary }, timer.elapsed(), { compact: true }); this.exit(1); return; } + if (flags.json) { await emitJson({ error: 'confirmation_required', hint: 'pass --yes', summary, duration: timer.elapsed() }, 0, { compact: true }); this.exit(1); return; } printWarning(`Confirmation required: ${summary}. Re-run with --yes.`); this.exit(1); return; @@ -162,7 +162,7 @@ export default class MigrateRecordedBy extends Command { const result = await runMigrationJournal(engine, plan); if (flags.json) { - await emitJson({ ...result, pending: pending.length, applied: true, error: result.error ? String(result.error) : undefined }, timer.elapsed()); + await emitJson({ ...result, pending: pending.length, applied: true, error: result.error ? String(result.error) : undefined, duration: timer.elapsed() }); this.exit(result.status === 'completed' ? 0 : 1); return; } @@ -188,7 +188,7 @@ export default class MigrateRecordedBy extends Command { const msg = error instanceof MigrationJournalRefusal ? `Refused (${error.code}): ${error.message}` : (error?.message || String(error)); - if (flags.json) { await emitJson({ error: msg }, timer.elapsed(), { compact: true }); this.exit(1); return; } + if (flags.json) { await emitJson({ error: msg, duration: timer.elapsed() }, 0, { compact: true }); this.exit(1); return; } printError(msg); this.exit(1); } finally { diff --git a/packages/cli/src/commands/migrate/resume.ts b/packages/cli/src/commands/migrate/resume.ts index 1bfd3c153a..1c16b1c006 100644 --- a/packages/cli/src/commands/migrate/resume.ts +++ b/packages/cli/src/commands/migrate/resume.ts @@ -137,13 +137,11 @@ export default class MigrateResume extends Command { // ── list mode (no --run): read-only ────────────────────────────── if (!flags.run) { if (flags.json) { - await emitJson( - { - interrupted: interrupted.map((r) => ({ ...r, resumable: Boolean(plans?.get(r.planId)) })), - count: interrupted.length, - }, - timer.elapsed(), - ); + await emitJson({ + interrupted: interrupted.map((r) => ({ ...r, resumable: Boolean(plans?.get(r.planId)) })), + count: interrupted.length, + duration: timer.elapsed(), + }); return; } if (interrupted.length === 0) { @@ -167,7 +165,7 @@ export default class MigrateResume extends Command { : `Run '${flags.run}' is not interrupted — it already concluded (${ events.some((e) => e.kind === 'run_done') ? 'run_done' : 'fully compensated' }). Nothing to do.`; - if (flags.json) { await emitJson({ error: msg, runId: flags.run }, timer.elapsed(), { compact: true }); this.exit(events.length === 0 ? 1 : 0); return; } + if (flags.json) { await emitJson({ error: msg, runId: flags.run, duration: timer.elapsed() }, 0, { compact: true }); this.exit(events.length === 0 ? 1 : 0); return; } if (events.length === 0) { printError(msg); this.exit(1); return; } printSuccess(msg); return; @@ -179,7 +177,7 @@ export default class MigrateResume extends Command { `Run '${target.runId}' belongs to plan '${target.planId}', which no loaded package registers. ` + `A resume needs the plan's code — the journal stores its hash, not its callbacks. ` + `Load the package that owns this migration and re-run.`; - if (flags.json) { await emitJson({ error: msg, runId: target.runId, planId: target.planId }, timer.elapsed(), { compact: true }); this.exit(1); return; } + if (flags.json) { await emitJson({ error: msg, runId: target.runId, planId: target.planId, duration: timer.elapsed() }, 0, { compact: true }); this.exit(1); return; } printError(msg); this.exit(1); return; @@ -190,7 +188,7 @@ export default class MigrateResume extends Command { const summary = `${policy === 'compensate' ? 'UNWIND' : 'RESUME FORWARD'} run '${target.runId}' (plan '${plan.id}')`; if (flags.json || !process.stdin.isTTY) { const msg = `Confirmation required: ${summary}. Re-run with --yes.`; - if (flags.json) { await emitJson({ error: 'confirmation_required', hint: 'pass --yes', summary }, timer.elapsed(), { compact: true }); this.exit(1); return; } + if (flags.json) { await emitJson({ error: 'confirmation_required', hint: 'pass --yes', summary, duration: timer.elapsed() }, 0, { compact: true }); this.exit(1); return; } printWarning(msg); this.exit(1); return; @@ -206,7 +204,7 @@ export default class MigrateResume extends Command { const result = await resumeMigrationJournal(engine, plan, target.runId); if (flags.json) { - await emitJson({ ...result, error: result.error ? String(result.error) : undefined }, timer.elapsed()); + await emitJson({ ...result, error: result.error ? String(result.error) : undefined, duration: timer.elapsed() }); // A run that ended `failed` left the database in a state no clean story // covers, so the exit code has to say so — a zero here would let a // scripted recovery move on from a migration that needs a human. @@ -237,7 +235,7 @@ export default class MigrateResume extends Command { // A refusal is the runner working, not breaking — say what it refused. ? `Refused (${error.code}): ${error.message}` : (error?.message || String(error)); - if (flags.json) { await emitJson({ error: msg }, timer.elapsed(), { compact: true }); this.exit(1); return; } + if (flags.json) { await emitJson({ error: msg, duration: timer.elapsed() }, 0, { compact: true }); this.exit(1); return; } printError(msg); this.exit(1); } finally { diff --git a/packages/cli/src/utils/format.exit-code.test.ts b/packages/cli/src/utils/format.exit-code.test.ts new file mode 100644 index 0000000000..3d1437a326 --- /dev/null +++ b/packages/cli/src/utils/format.exit-code.test.ts @@ -0,0 +1,117 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `emitJson` / `emitText` write exactly two things: the payload, and + * `process.exitCode`. This pins the second one (#4873). + * + * The defect these tests exist for was not a wrong value computed somewhere — + * it was an ARGUMENT IN THE WRONG SLOT. `emitJson(payload, exitCode, opts)` + * takes its exit code second, positionally, and `os migrate recorded-by --json` + * / `os migrate resume --json` passed `timer.elapsed()` there: a duration in + * milliseconds. A fully successful run therefore ended with + * `process.exitCode = 531`, which the shell reports as `531 & 0xFF` = 19 — a + * different non-zero code on every run, on a command whose JSON was correct and + * whose stderr was empty. + * + * Two things are pinned here, and the second is the one that lasts: + * + * 1. the runtime contract — silence unless a caller asks for a failure code; + * 2. that a `number` can no longer reach that slot AT ALL (`CliExitCode`), + * so the same mistake is a compile error rather than a false failure for + * every scripted caller. + * + * (2) lives in `src/` deliberately: `packages/cli/tsconfig.json` includes + * `src`, so `pnpm typecheck` compiles this file and its `@ts-expect-error` + * directives are real. The same test under `packages/cli/test/` would be a + * phantom check — no tsc program reads that directory, so every directive in + * it would evaluate never and deleting them would leave every gate green. + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { emitJson, emitText, createTimer } from './format.js'; + +/** Whatever the runner was holding before this file ran — restored after each case. */ +const OUTER_EXIT_CODE = process.exitCode; + +describe('emitJson / emitText — process.exitCode (#4873)', () => { + let written: string[]; + let writeSpy: ReturnType; + + beforeEach(() => { + written = []; + // The real write must still invoke its callback: `emitText` awaits it, and + // that await is the whole point of the function (the #3512 pipe-truncation + // fix). A mock that swallows the callback hangs the test instead of failing + // it. + writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation((( + chunk: unknown, + encodingOrCb: unknown, + maybeCb: unknown, + ) => { + written.push(String(chunk)); + const done = typeof encodingOrCb === 'function' ? encodingOrCb : maybeCb; + if (typeof done === 'function') done(); + return true; + }) as never); + process.exitCode = 0; + }); + + afterEach(() => { + writeSpy.mockRestore(); + process.exitCode = OUTER_EXIT_CODE; + }); + + it('leaves the exit code alone on the success path', async () => { + await emitJson({ planId: 'x', pending: 0, applied: false, duration: 531 }); + + expect(JSON.parse(written.join(''))).toMatchObject({ pending: 0, duration: 531 }); + expect(process.exitCode).toBe(0); + }); + + it('still records a failure when one is asked for — the other direction', async () => { + await emitJson({ error: 'confirmation_required' }, 1, { compact: true }); + + expect(process.exitCode).toBe(1); + // Compact is a formatting choice; it must not change the exit contract. + expect(written.join('')).toBe('{"error":"confirmation_required"}\n'); + }); + + it('emitText carries the same contract — silent by default, 1 on request', async () => { + await emitText('hello'); + expect(process.exitCode).toBe(0); + + await emitText('goodbye', 1); + expect(process.exitCode).toBe(1); + }); + + /** + * The regression pin, written as the mistake itself. + * + * Both `@ts-expect-error`s below are the gate: if someone widens + * `CliExitCode` back to `number`, the directives become unused and tsc fails + * on THEM — which is the only way a repo-wide guarantee like this can be + * enforced from one file. + * + * The runtime half is kept because it is the evidence: with the type check + * suppressed, the exact call `recorded-by.ts` used to make still reproduces + * the defect verbatim, so this test states what the type is preventing + * rather than merely asserting that it prevents something. + */ + it('a duration can no longer reach the exit-code slot (#4873)', async () => { + const timer = createTimer(); + const durationMs = timer.elapsed() + 531; // a plausible `os migrate` run + + // @ts-expect-error — a `number` is not a `CliExitCode`. This is exactly the + // call `migrate/recorded-by.ts` and `migrate/resume.ts` used to make. + const asExitCode: Parameters[1] = durationMs; + expect(asExitCode).toBe(durationMs); + + // @ts-expect-error — same rejection at the call site, which is where it bit. + await emitJson({ pending: 0, applied: false }, durationMs); + + // And this is why the reported codes looked random rather than wrong: Node + // truncates the exit status to 8 bits, so 531 leaves the process as 19. + expect(process.exitCode).toBe(durationMs); + expect(durationMs & 0xff).toBe(19); + }); +}); diff --git a/packages/cli/src/utils/format.ts b/packages/cli/src/utils/format.ts index 4b40331675..a22b6cd0db 100644 --- a/packages/cli/src/utils/format.ts +++ b/packages/cli/src/utils/format.ts @@ -11,6 +11,34 @@ export const CLI_ALIAS = 'os'; // ─── Machine-readable output ──────────────────────────────────────── +/** + * The only two exit codes this CLI has: `0` success, `1` failure. + * + * Deliberately a narrow union rather than `number`, and that narrowness is the + * whole point. The value it types sits in the SECOND POSITIONAL slot of + * {@link emitJson} / {@link emitText} — immediately after a payload — where + * `number` accepted whatever numeric the caller happened to be holding. + * `os migrate recorded-by --json` and `os migrate resume --json` were holding + * `timer.elapsed()`, a DURATION in milliseconds, and passed it there (#4873). + * + * The result was invisible in every way an author checks: correct JSON on + * stdout, `✅ Graceful shutdown complete`, empty stderr — and + * `process.exitCode = 531`, which the shell reports as `531 & 0xFF` = 19. A + * different non-zero code on every run, because the code WAS the run's + * duration, so every caller that judges success by exit status (CI steps, + * `set -e`, Makefiles, container entrypoints) saw a random failure from a + * command that had just succeeded — the one audience `--json` exists for. + * + * Every other `--json` site in this CLI reports its duration INSIDE the + * payload (`{ ...report, duration: timer.elapsed() }` — `os lint`, + * `os migrate meta`, `os migrate summary-nulls`, `os meta resync`), which is + * what those two meant to do as well. With this union a duration in the exit + * slot is a compile error, so the mistake cannot be made silently again. + * + * Widening it is a deliberate act: a third code needs a meaning first. + */ +export type CliExitCode = 0 | 1; + export interface EmitJsonOptions { /** * Emit on a single line instead of 2-space-indented. @@ -60,7 +88,7 @@ export interface EmitJsonOptions { */ export async function emitJson( payload: unknown, - exitCode = 0, + exitCode: CliExitCode = 0, opts: EmitJsonOptions = {}, ): Promise { const text = opts.compact ? JSON.stringify(payload) : JSON.stringify(payload, null, 2); @@ -100,7 +128,7 @@ export function isExitSignal(error: unknown): boolean { * why this cannot be fixed at the exit, or globally via blocking stdout, * applies here too. */ -export async function emitText(text: string, exitCode = 0): Promise { +export async function emitText(text: string, exitCode: CliExitCode = 0): Promise { await new Promise((resolve, reject) => { process.stdout.write(text + '\n', (err) => (err ? reject(err) : resolve())); }); diff --git a/packages/cli/test/migrate-exit-code.e2e.test.ts b/packages/cli/test/migrate-exit-code.e2e.test.ts new file mode 100644 index 0000000000..52e01ce886 --- /dev/null +++ b/packages/cli/test/migrate-exit-code.e2e.test.ts @@ -0,0 +1,212 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `os migrate recorded-by --json` — the EXIT STATUS, over the real CLI process + * (#4873). + * + * A successful run of this command returned a different non-zero code every + * time — 208, 171, 176, 163, 62, and on the tree this test was written against + * 19, 48, 49, 57, 6. Correct JSON on stdout, `✅ Graceful shutdown complete`, + * empty stderr, process gone in ~3 s: nothing an author looks at was wrong. + * Only the one thing no author looks at, and the only thing a CI step, a + * `set -e` script, a Makefile or a container entrypoint looks at. + * + * The cause was `emitJson(payload, timer.elapsed())` — a DURATION handed to the + * parameter that means `exitCode`, so the process exited with its own runtime + * in milliseconds, truncated to 8 bits. `packages/cli/src/utils/format.exit-code.test.ts` + * pins that at the unit and the type level. This file pins the only statement + * that matters to the audience `--json` exists for: what the SHELL sees. + * + * It has to be a real child process. `process.exitCode` set inside a vitest + * worker is not an exit status — the number the defect produced only exists + * once Node has exited and the kernel has masked it to `& 0xFF`. Spawned + * through `bin/run-dev.js` + tsx (the pattern `migrate-meta.e2e.test.ts` and + * `emit-json-pipe.test.ts` already use) so the suite does not depend on + * `packages/cli/dist` having been built. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { execFile } from 'node:child_process'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const HERE = resolve(fileURLToPath(import.meta.url), '..'); +const CLI = resolve(HERE, '../bin/run-dev.js'); +const TSX = resolve(HERE, '../../../node_modules/.bin/tsx'); + +/** Minimal project: the command boots a stack, it does not need much of one. */ +const CONFIG = ` +export default { + name: 'exit_code_e2e', + label: 'Exit Code E2E', + objects: [{ + name: 'ec_ticket', + label: 'Ticket', + fields: { title: { type: 'text', label: 'Title' } }, + }], +}; +`; + +/** + * Comfortably below the 122.4 s a pre-#4813 run took, and ~10x above the ~4-9 s + * a healthy run takes here. + * + * #4813 was the OTHER defect on this exit path: the kernel's init/start timeout + * guards were never cleared, so the process sat idle for the guard's full 120 s + * after the work was done. That is fixed; this bound is what notices if it + * comes back, since a re-armed guard is invisible to every other assertion in + * this file (the exit code was random both before and after #4813 — that is the + * observation the issue was filed on). + */ +const SLOW_RUN_MS = 90_000; + +interface Run { + code: number; + stdout: string; + stderr: string; + wallMs: number; +} + +function runCli(args: string[], cwd: string, env: Record = {}): Promise { + const started = Date.now(); + return new Promise((resolvePromise) => { + execFile( + TSX, + [CLI, ...args], + { cwd, maxBuffer: 16 * 1024 * 1024, env: { ...process.env, NO_COLOR: '1', ...env } }, + (err, stdout, stderr) => { + resolvePromise({ + // `err.code` is the real exit status; `null`/undefined means the + // child was signalled, which is a failure of a different kind and + // must not be reported as 0. + code: err ? (typeof (err as { code?: unknown }).code === 'number' ? (err as unknown as { code: number }).code : 1) : 0, + stdout: String(stdout), + stderr: String(stderr), + wallMs: Date.now() - started, + }); + }, + ); + }); +} + +/** + * The `--json` payload, dug out of stdout. + * + * This command boots a kernel and the kernel's INFO logger writes to STDOUT, so + * the machine payload arrives with human log lines above AND below it — the + * whole stdout is not valid JSON. That is its own defect for the same audience + * and is filed as #6217; it is not this test's subject, and working around it + * here is exactly what a consumer has to do today. + * + * Structural rather than a regex over the buffer: `emitJson` pretty-prints, so + * the payload is a lone `{` through a lone `}` at column 0; `{ compact: true }` + * puts one object on one line. A log line that merely looks like JSON fails + * `JSON.parse` and the scan continues. + */ +function jsonPayload(stdout: string): Record { + const lines = stdout.split('\n'); + for (let i = lines.length - 1; i >= 0; i--) { + const line = lines[i]; + if (line === '}') { + for (let j = i; j >= 0; j--) { + if (lines[j] === '{') return JSON.parse(lines.slice(j, i + 1).join('\n')); + } + } + if (line.startsWith('{') && line.endsWith('}')) { + try { return JSON.parse(line); } catch { /* a log line shaped like an object */ } + } + } + throw new Error(`no --json payload on stdout:\n${stdout}`); +} + +const RUNS = 3; +let dir: string; +let runs: Run[]; + +beforeAll(async () => { + dir = mkdtempSync(join(tmpdir(), 'os-exit-code-e2e-')); + writeFileSync(join(dir, 'objectstack.config.ts'), CONFIG); + + // Sequential, each against its own fresh database — the same shape as the + // issue's repro loop, which is the only way "a DIFFERENT non-zero code every + // time" can be shown to be gone. + runs = []; + for (let i = 0; i < RUNS; i++) { + runs.push( + await runCli(['migrate', 'recorded-by', '--json'], dir, { + OS_DATABASE_URL: `file:${join(dir, `run-${i}.db`)}`, + }), + ); + } +}, 600_000); + +afterAll(() => { + try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ } +}); + +describe('os migrate recorded-by --json — a successful run exits 0 (#4873)', () => { + it('exits 0 on every run, not a fresh non-zero code each time', () => { + // Reported as the whole vector: `toBe(0)` on run 1 would pass on a tree + // where only the first run happened to land on a multiple of 256. + expect(runs.map((r) => r.code)).toEqual(Array(RUNS).fill(0)); + }); + + it('really did the work it is reporting success for — this is not a masked exit', () => { + // The exit code means something only if the command actually ran. A + // `process.exit(0)` bolted onto the end of `run()` would satisfy the + // assertion above while turning every real failure into a false success; + // these are the receipts that it did not. + for (const run of runs) { + const payload = jsonPayload(run.stdout); + expect(payload).toMatchObject({ + planId: 'metadata.recorded-by-sentinel-to-null', + sentinel: 'system', + pending: 0, + applied: false, + }); + expect(run.stdout).toContain('Graceful shutdown complete'); + expect(run.stderr).toBe(''); + } + }); + + it('reports its duration IN THE PAYLOAD — the value that used to be the exit code', () => { + const durations = runs.map((r) => jsonPayload(r.stdout).duration as number); + + for (const d of durations) { + expect(typeof d).toBe('number'); + expect(d).toBeGreaterThan(0); + } + + // The assertion that ties this file to the defect. Under the old code the + // exit status WAS this number masked to 8 bits, so any run whose duration + // is not a multiple of 256 is a run that used to exit non-zero. Requiring + // it of the SET rather than of each run keeps the pin deterministic: a + // single duration could legitimately land on a multiple of 256, all three + // doing so has probability ~6e-8. + expect(durations.some((d) => d % 256 !== 0)).toBe(true); + }); + + it('still exits promptly — #4813 stays fixed', () => { + // Wall time of the whole child, tsx compile included. + for (const run of runs) expect(run.wallMs).toBeLessThan(SLOW_RUN_MS); + }); +}); + +describe('os migrate recorded-by --json — a failing run still exits non-zero (#4873)', () => { + it('reports a boot failure as exit 1, not as a clean 0', async () => { + // The inverse defect this fix must not introduce. An unsupported URL scheme + // is refused by `createStandaloneStack` before anything is opened, so it is + // fast and deterministic — and it leaves through the exact `emitJson(..., + // 0, { compact: true })` + `this.exit(1)` path that #4873 edited. + const run = await runCli( + ['migrate', 'recorded-by', '--json', '--database-url', 'wat://nope'], + dir, + ); + + expect(run.code).toBe(1); + const payload = jsonPayload(run.stdout); + expect(String(payload.error)).toContain('Unsupported database URL scheme'); + }, 300_000); +});