Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions .changeset/lucky-pears-arrive.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
---
'@objectstack/cli': patch
---

fix(cli): `--json` now owns stdout — kernel boot logs move to stderr (#6217)

Every `os migrate` / `os meta` subcommand that boots a kernel wrote its
machine-readable payload into a stream it shared with ~60 INFO lines. The
kernel logger routes `debug`/`info`/`warn` to stdout and only `error`/`fatal`
to stderr, so `os migrate recorded-by --json | jq .` failed with `parse error:
Invalid numeric literal` while stderr sat completely empty — a `--json` flag
whose only audience is a program, handing that program something it cannot
parse.

With this change, a `--json` run reserves stdout for its payload: everything
the kernel and its plugins write goes to **stderr** instead, including the
`[StandaloneStack] no compiled artifact …` notice that never went through the
logger at all. `JSON.parse(<entire stdout>)` now succeeds with no heuristic
extraction, and no diagnostic is lost — every line an operator used to see is
still printed, on the stream diagnostics belong on.

Covers the whole family that shares the boot seam: `os migrate plan` / `apply`
/ `resume` / `recorded-by` / `summary-nulls` / `value-shapes` /
`files-to-references`, `os migrate meta --stored`, and `os meta resync`.
Human-mode runs are unchanged.
2 changes: 1 addition & 1 deletion packages/cli/src/commands/meta/resync.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -83,7 +83,7 @@ export default class MetaResync extends Command {

let stack;
try {
stack = await bootSchemaStack({ databaseUrl: flags['database-url'] });
stack = await bootSchemaStack({ jsonOutput: flags.json, databaseUrl: flags['database-url'] });
} catch (error: any) {
if (flags.json) await emitJson({ error: error.message }, 0, { compact: true });
else printError(error.message || String(error));
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/commands/migrate/apply.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -126,7 +126,7 @@ export default class MigrateApply extends Command {
try {
// `deferSchemaDdl` is what makes the prompt below meaningful: without it
// the boot has already created tables and added columns by this point.
stack = await bootSchemaStack({ databaseUrl: flags['database-url'], deferSchemaDdl: true });
stack = await bootSchemaStack({ jsonOutput: flags.json, databaseUrl: flags['database-url'], deferSchemaDdl: true });
} catch (error: any) {
if (flags.json) { await emitJson({ error: error.message }, 0, { compact: true }); this.exit(1); }
printError(error.message || String(error));
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/commands/migrate/files-to-references.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -161,6 +161,7 @@ export default class MigrateFilesToReferences extends Command {
let stack;
try {
stack = await bootSchemaStack({
jsonOutput: flags.json,
databaseUrl: flags['database-url'],
extraPlugins: await buildDataMigrationPlugins({ storage: true }),
});
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -94,6 +94,7 @@ describe('os migrate meta --stored — the protocol resolves the engine itself (

it('rewrites a pre-17 flow row with NO canonicalizeFlow passed by the command', async () => {
const stack = await bootSchemaStack({
jsonOutput: false,
databaseUrl: `file:${dbFile}`,
projectRoot: dir,
extraPlugins: await buildDataMigrationPlugins({ automation: true }),
Expand DownExpand Up@@ -163,6 +164,7 @@ describe('os migrate meta --stored — the protocol resolves the engine itself (
// verbatim and leave the row `pending` forever; `saveMetaItem` now
// canonicalizes flow bodies before its schema gate.
const stack = await bootSchemaStack({
jsonOutput: false,
databaseUrl: `file:${dbFile}`,
projectRoot: dir,
extraPlugins: await buildDataMigrationPlugins({ automation: true }),
Expand DownExpand Up@@ -223,6 +225,7 @@ describe('os migrate meta --stored — the protocol resolves the engine itself (
// The honest negative: the coverage comes from the engine being present,
// not from the report defaulting to optimistic.
const stack = await bootSchemaStack({
jsonOutput: false,
databaseUrl: `file:${dbFile}`,
projectRoot: dir,
extraPlugins: await buildDataMigrationPlugins(),
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/commands/migrate/meta.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -518,6 +518,7 @@ export default class MigrateMeta extends Command {
// nothing. No storage adapter: unlike the file migration, nothing here
// reads bytes.
stack = await bootSchemaStack({
jsonOutput: flags.json,
...(flags['database-url'] ? { databaseUrl: flags['database-url'] } : {}),
extraPlugins: await buildDataMigrationPlugins({ automation: true }),
});
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/commands/migrate/plan.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -77,7 +77,7 @@ export default class MigratePlan extends Command {

let stack;
try {
stack = await bootSchemaStack({ databaseUrl: flags['database-url'], deferSchemaDdl: true });
stack = await bootSchemaStack({ jsonOutput: flags.json, databaseUrl: flags['database-url'], deferSchemaDdl: true });
} catch (error: any) {
if (flags.json) { await emitJson({ error: error.message }, 0, { compact: true }); this.exit(1); }
printError(error.message || String(error));
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/commands/migrate/recorded-by.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -95,6 +95,7 @@ export default class MigrateRecordedBy extends Command {
let stack;
try {
stack = await bootSchemaStack({
jsonOutput: flags.json,
databaseUrl: flags['database-url'],
extraPlugins: await buildDataMigrationPlugins(),
});
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/commands/migrate/resume.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -105,6 +105,7 @@ export default class MigrateResume extends Command {
let stack;
try {
stack = await bootSchemaStack({
jsonOutput: flags.json,
databaseUrl: flags['database-url'],
extraPlugins: await buildDataMigrationPlugins(),
});
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/commands/migrate/summary-nulls.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -167,6 +167,7 @@ export default class MigrateSummaryNulls extends Command {
let stack;
try {
stack = await bootSchemaStack({
jsonOutput: flags.json,
databaseUrl: flags['database-url'],
extraPlugins: await buildDataMigrationPlugins(),
});
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/commands/migrate/value-shapes.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -134,6 +134,7 @@ export default class MigrateValueShapes extends Command {
let stack;
try {
stack = await bootSchemaStack({
jsonOutput: flags.json,
databaseUrl: flags['database-url'],
extraPlugins: await buildDataMigrationPlugins(),
});
Expand Down
9 changes: 6 additions & 3 deletions packages/cli/src/utils/format.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,6 +4,7 @@ import chalk from 'chalk';
import type { ZodError } from 'zod';
import { formatZodIssue } from '@objectstack/spec';
import type { TenancyPosture } from '@objectstack/spec/security';
import { writeStdoutDirect } from './json-stdout.js';

// ─── Constants ──────────────────────────────────────────────────────
export const CLI_NAME = 'objectstack';
Expand DownExpand Up@@ -129,9 +130,11 @@ export function isExitSignal(error: unknown): boolean {
* applies here too.
*/
export async function emitText(text: string, exitCode: CliExitCode = 0): Promise<void> {
await new Promise<void>((resolve, reject) => {
process.stdout.write(text + '\n', (err) => (err ? reject(err) : resolve()));
});
// `writeStdoutDirect`, not `process.stdout.write`: a `--json` command that
// boots a kernel reserves stdout so the kernel's INFO stream goes to stderr
// (#6217), and the payload is the one thing that must still reach the real
// stdout. Outside a reservation this is `process.stdout.write` verbatim.
await writeStdoutDirect(text + '\n');
if (exitCode !== 0) process.exitCode = exitCode;
}

Expand Down
161 changes: 161 additions & 0 deletions packages/cli/src/utils/json-stdout.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* The stdout reservation `--json` is built on (#6217), at the unit level.
*
* `packages/cli/test/json-stdout-purity.e2e.test.ts` pins the contract this
* serves — every `--json` command in the `bootSchemaStack` family emitting one
* parseable JSON document — over real child processes. This file pins the
* mechanism underneath it, including the two properties that make the contract
* hold rather than merely look held:
*
* 1. a reservation catches EVERY stdout writer, not just `ObjectLogger`
* (`console.log` is how `[StandaloneStack] no compiled artifact …`
* reaches stdout, and it never passes through the kernel logger at all);
* 2. the payload writer still reaches the REAL stdout while a reservation is
* in force — a reservation that also swallowed the payload would produce
* an empty stdout, which `JSON.parse` rejects just as loudly.
*/

import { describe, it, expect, afterEach, vi } from 'vitest';
import { Console } from 'node:console';
import { isStdoutReserved, reserveStdoutForJson, writeStdoutDirect } from './json-stdout.js';

/**
* A `console` bound to the process streams, because the GLOBAL one is not
* usable as evidence here: vitest replaces `globalThis.console` with its own
* reporter sink, so a `console.log` inside a worker never reaches
* `process.stdout.write` at all and would prove nothing either way.
*
* This is the same mechanism Node's own global console uses — hold the stream,
* call `stream.write(...)` per record — so it exercises exactly the property
* the reservation replaces. The end-to-end proof that the REAL global console
* is covered is `packages/cli/test/json-stdout-purity.e2e.test.ts`, which runs
* the CLI as a child process and pins `[StandaloneStack] no compiled artifact`
* (a bare `console.log` in `@objectstack/runtime`) onto stderr.
*/
const streamConsole = new Console(process.stdout, process.stderr);

/**
* Spy on both streams and hand back what each received.
*
* The stdout spy is installed BEFORE the reservation on purpose: a reservation
* captures whatever `process.stdout.write` is at that moment, so the spy is
* what {@link writeStdoutDirect} ends up calling, and "reached the real stdout"
* becomes an assertion instead of an inference.
*/
function spyStreams() {
const stdout = vi.spyOn(process.stdout, 'write').mockImplementation(((chunk: any, ...rest: any[]) => {
const cb = rest.find((a) => typeof a === 'function');
if (cb) cb();
return true;
}) as typeof process.stdout.write);
const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(((chunk: any, ...rest: any[]) => {
const cb = rest.find((a) => typeof a === 'function');
if (cb) cb();
return true;
}) as typeof process.stderr.write);
const text = (spy: typeof stdout) => spy.mock.calls.map((c) => String(c[0])).join('');
return { stdout, stderr, stdoutText: () => text(stdout), stderrText: () => text(stderr) };
}

afterEach(() => {
vi.restoreAllMocks();
});

describe('reserveStdoutForJson — stdout goes to stderr, nothing is destroyed', () => {
it('forwards direct writes AND console.log/info/debug to stderr', () => {
const streams = spyStreams();
const release = reserveStdoutForJson();
try {
process.stdout.write('direct write\n');
// The three console methods Node binds to stdout. `console.log` is the
// one that matters most here: `loadArtifactBundle` announces a missing
// compiled artifact through it, so a fix that only reached the kernel
// logger would still leave that line on stdout.
streamConsole.log('console.log');
streamConsole.info('console.info');
streamConsole.debug('console.debug');
} finally {
release();
}

const onStderr = streams.stderrText();
for (const line of ['direct write', 'console.log', 'console.info', 'console.debug']) {
expect(onStderr).toContain(line);
expect(streams.stdoutText()).not.toContain(line);
}
});

it('keeps the drain callback, so a caller awaiting the write still resumes', async () => {
spyStreams();
const release = reserveStdoutForJson();
try {
await new Promise<void>((resolve, reject) => {
process.stdout.write('needs a callback\n', (err) => (err ? reject(err) : resolve()));
});
} finally {
release();
}
// Reaching here at all is the assertion: `emitJson` awaits exactly this
// callback, and a forwarder that dropped it would hang the CLI forever
// rather than print a wrong byte.
expect(true).toBe(true);
});

it('lets the payload through to the real stdout while the reservation holds', async () => {
const streams = spyStreams();
const release = reserveStdoutForJson();
try {
streamConsole.log('boot chatter');
await writeStdoutDirect('{"payload":true}\n');
} finally {
release();
}

expect(streams.stdoutText()).toBe('{"payload":true}\n');
expect(streams.stderrText()).toContain('boot chatter');
// The whole point, restated as the consumer sees it.
expect(JSON.parse(streams.stdoutText())).toEqual({ payload: true });
});

it('releases back to the exact write it took', () => {
const before = process.stdout.write;
expect(isStdoutReserved()).toBe(false);
const release = reserveStdoutForJson();
expect(isStdoutReserved()).toBe(true);
expect(process.stdout.write).not.toBe(before);
release();
expect(isStdoutReserved()).toBe(false);
// `.bind()` makes a new function object, so identity cannot be asserted;
// behaviour can — the stream is stdout's again.
const streams = spyStreams();
process.stdout.write('after release\n');
expect(streams.stdoutText()).toContain('after release');
expect(streams.stderrText()).not.toContain('after release');
});

it('an inner reservation releases nothing — the outer one owns the stream', () => {
const streams = spyStreams();
const outer = reserveStdoutForJson();
const inner = reserveStdoutForJson();
try {
inner(); // must be a no-op: the outer reservation is still in force
process.stdout.write('still reserved\n');
expect(streams.stderrText()).toContain('still reserved');
expect(streams.stdoutText()).not.toContain('still reserved');
} finally {
outer();
}
expect(isStdoutReserved()).toBe(false);
});
});

describe('writeStdoutDirect — outside a reservation it is plain stdout', () => {
it('writes to stdout when nothing is reserved', async () => {
const streams = spyStreams();
await writeStdoutDirect('unreserved\n');
expect(streams.stdoutText()).toContain('unreserved');
expect(streams.stderrText()).toBe('');
});
});
Loading
Loading