Skip to content

Commit 2b641dd

Browse files
fix(cli): reserve stdout for the --json payload across the bootSchemaStack family (#6217) (#6524)
`--json` has exactly one audience — a program — and every subcommand that boots a kernel was handing that program a stream it could not parse. `ObjectLogger` routes `debug`/`info`/`warn` to stdout and only `error`/`fatal` to stderr, so `os migrate recorded-by --json` produced "~60 INFO lines + payload + 2 shutdown lines" on stdout while stderr stayed empty. The only recourse was a "find the last lone `{` and its matching `}`" extractor — #4873 was forced to write one to assert its own payload — and that heuristic silently picks the wrong text as soon as a log line looks like JSON. This takes route 1 from the issue (redirect to stderr), not route 2 (drop the kernel to `logLevel: 'silent'`): route 2 throws the operator's diagnostics away, warnings included, and could not quiet the one line that never goes through the logger at all (`console.log` in `loadArtifactBundle`: `[StandaloneStack] no compiled artifact …`). Route 3 (make the logger default to stderr in `packages/core`) would change `os serve` / `os dev` output for every existing user and is a maintainer call — untouched here. The fix lands on the family's shared boot seam: `bootSchemaStack` gains a REQUIRED `jsonOutput` option. Before the boot can print its first byte it takes over `process.stdout.write` and forwards everything the kernel and its plugins write to stderr — nothing is discarded — while the payload goes out through `writeStdoutDirect` on the real stdout. `shutdown()` gives stdout back only after the kernel is fully down, so the two shutdown lines cannot land under the payload either. A failed boot deliberately keeps the reservation: the command's next act on that path is to emit its error payload, and a half-started kernel can still log. The option is required so a family member added later has to decide at compile time instead of inheriting the bug. All nine members covered: `os migrate plan` / `apply` / `resume` / `recorded-by` / `summary-nulls` / `value-shapes` / `files-to-references`, `os migrate meta --stored`, and `os meta resync`. Human-mode output is unchanged (verified: stdout identical, stderr still empty). Tests: - `packages/cli/test/json-stdout-purity.e2e.test.ts` — the shared family expectation. Discovers the members from source (calls `bootSchemaStack` AND declares a `--json` flag), reconciles that set against the driven list, then runs each as a real child process asserting a bare `JSON.parse(stdout)`, no logger record anywhere on stdout, and the boot diagnostics still present on stderr (so a regression toward silencing goes red too). A new member that is not driven goes red. - `packages/cli/test/migrate-exit-code.e2e.test.ts` — the `jsonPayload()` heuristic extractor #4873 wrote under duress is deleted; it is now a bare `JSON.parse(stdout)`. - `packages/cli/src/utils/json-stdout.test.ts` — the mechanism at unit level. Reverse verification (predicted red, and red): with the reservation disabled, 27 of the family e2e's 28 cases fail — the one that stays green is the member-inventory case, which asserts a set rather than behaviour — and the exit-code pin fails with `SyntaxError: Unexpected token 'S', "[Standalone"...`, exactly the error recorded in the issue. Fixes #6217 Claude-Session: https://claude.ai/code/session_017uFVNMmTxLpmfQYiuKM1Yx Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8e13ca8 commit 2b641dd

20 files changed

Lines changed: 637 additions & 39 deletions

.changeset/lucky-pears-arrive.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
'@objectstack/cli': patch
3+
---
4+
5+
fix(cli): `--json` now owns stdout — kernel boot logs move to stderr (#6217)
6+
7+
Every `os migrate` / `os meta` subcommand that boots a kernel wrote its
8+
machine-readable payload into a stream it shared with ~60 INFO lines. The
9+
kernel logger routes `debug`/`info`/`warn` to stdout and only `error`/`fatal`
10+
to stderr, so `os migrate recorded-by --json | jq .` failed with `parse error:
11+
Invalid numeric literal` while stderr sat completely empty — a `--json` flag
12+
whose only audience is a program, handing that program something it cannot
13+
parse.
14+
15+
With this change, a `--json` run reserves stdout for its payload: everything
16+
the kernel and its plugins write goes to **stderr** instead, including the
17+
`[StandaloneStack] no compiled artifact …` notice that never went through the
18+
logger at all. `JSON.parse(<entire stdout>)` now succeeds with no heuristic
19+
extraction, and no diagnostic is lost — every line an operator used to see is
20+
still printed, on the stream diagnostics belong on.
21+
22+
Covers the whole family that shares the boot seam: `os migrate plan` / `apply`
23+
/ `resume` / `recorded-by` / `summary-nulls` / `value-shapes` /
24+
`files-to-references`, `os migrate meta --stored`, and `os meta resync`.
25+
Human-mode runs are unchanged.

packages/cli/src/commands/meta/resync.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ export default class MetaResync extends Command {
8383

8484
let stack;
8585
try {
86-
stack = await bootSchemaStack({ databaseUrl: flags['database-url'] });
86+
stack = await bootSchemaStack({ jsonOutput: flags.json, databaseUrl: flags['database-url'] });
8787
} catch (error: any) {
8888
if (flags.json) await emitJson({ error: error.message }, 0, { compact: true });
8989
else printError(error.message || String(error));

packages/cli/src/commands/migrate/apply.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ export default class MigrateApply extends Command {
126126
try {
127127
// `deferSchemaDdl` is what makes the prompt below meaningful: without it
128128
// the boot has already created tables and added columns by this point.
129-
stack = await bootSchemaStack({ databaseUrl: flags['database-url'], deferSchemaDdl: true });
129+
stack = await bootSchemaStack({ jsonOutput: flags.json, databaseUrl: flags['database-url'], deferSchemaDdl: true });
130130
} catch (error: any) {
131131
if (flags.json) { await emitJson({ error: error.message }, 0, { compact: true }); this.exit(1); }
132132
printError(error.message || String(error));

packages/cli/src/commands/migrate/files-to-references.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,7 @@ export default class MigrateFilesToReferences extends Command {
161161
let stack;
162162
try {
163163
stack = await bootSchemaStack({
164+
jsonOutput: flags.json,
164165
databaseUrl: flags['database-url'],
165166
extraPlugins: await buildDataMigrationPlugins({ storage: true }),
166167
});

packages/cli/src/commands/migrate/meta.stored-flow-resolution.integration.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ describe('os migrate meta --stored — the protocol resolves the engine itself (
9494

9595
it('rewrites a pre-17 flow row with NO canonicalizeFlow passed by the command', async () => {
9696
const stack = await bootSchemaStack({
97+
jsonOutput: false,
9798
databaseUrl: `file:${dbFile}`,
9899
projectRoot: dir,
99100
extraPlugins: await buildDataMigrationPlugins({ automation: true }),
@@ -163,6 +164,7 @@ describe('os migrate meta --stored — the protocol resolves the engine itself (
163164
// verbatim and leave the row `pending` forever; `saveMetaItem` now
164165
// canonicalizes flow bodies before its schema gate.
165166
const stack = await bootSchemaStack({
167+
jsonOutput: false,
166168
databaseUrl: `file:${dbFile}`,
167169
projectRoot: dir,
168170
extraPlugins: await buildDataMigrationPlugins({ automation: true }),
@@ -223,6 +225,7 @@ describe('os migrate meta --stored — the protocol resolves the engine itself (
223225
// The honest negative: the coverage comes from the engine being present,
224226
// not from the report defaulting to optimistic.
225227
const stack = await bootSchemaStack({
228+
jsonOutput: false,
226229
databaseUrl: `file:${dbFile}`,
227230
projectRoot: dir,
228231
extraPlugins: await buildDataMigrationPlugins(),

packages/cli/src/commands/migrate/meta.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -518,6 +518,7 @@ export default class MigrateMeta extends Command {
518518
// nothing. No storage adapter: unlike the file migration, nothing here
519519
// reads bytes.
520520
stack = await bootSchemaStack({
521+
jsonOutput: flags.json,
521522
...(flags['database-url'] ? { databaseUrl: flags['database-url'] } : {}),
522523
extraPlugins: await buildDataMigrationPlugins({ automation: true }),
523524
});

packages/cli/src/commands/migrate/plan.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ export default class MigratePlan extends Command {
7777

7878
let stack;
7979
try {
80-
stack = await bootSchemaStack({ databaseUrl: flags['database-url'], deferSchemaDdl: true });
80+
stack = await bootSchemaStack({ jsonOutput: flags.json, databaseUrl: flags['database-url'], deferSchemaDdl: true });
8181
} catch (error: any) {
8282
if (flags.json) { await emitJson({ error: error.message }, 0, { compact: true }); this.exit(1); }
8383
printError(error.message || String(error));

packages/cli/src/commands/migrate/recorded-by.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ export default class MigrateRecordedBy extends Command {
9595
let stack;
9696
try {
9797
stack = await bootSchemaStack({
98+
jsonOutput: flags.json,
9899
databaseUrl: flags['database-url'],
99100
extraPlugins: await buildDataMigrationPlugins(),
100101
});

packages/cli/src/commands/migrate/resume.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ export default class MigrateResume extends Command {
105105
let stack;
106106
try {
107107
stack = await bootSchemaStack({
108+
jsonOutput: flags.json,
108109
databaseUrl: flags['database-url'],
109110
extraPlugins: await buildDataMigrationPlugins(),
110111
});

packages/cli/src/commands/migrate/summary-nulls.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,7 @@ export default class MigrateSummaryNulls extends Command {
167167
let stack;
168168
try {
169169
stack = await bootSchemaStack({
170+
jsonOutput: flags.json,
170171
databaseUrl: flags['database-url'],
171172
extraPlugins: await buildDataMigrationPlugins(),
172173
});

0 commit comments

Comments
 (0)