发现自 #4873 的实现过程(修那条 issue 时必须绕开本问题才能断言 payload)。未认领。 与 #4873 是不同的 bug —— #4873 修的是退出码,修好之后本条照旧复现。
现象
--json 是给程序消费的,但 os migrate recorded-by --json 的 stdout 上除了 JSON payload,还夹着内核启动的 INFO 日志,所以整个 stdout 不是一份合法 JSON:
os migrate recorded-by --json 2>/dev/null | jq .
# parse error: Invalid numeric literal at line 1, column 13
实测(丢弃 stderr 后把 stdout 整份喂给 JSON.parse):
JSON.parse FAILED: Unexpected token 'S', "[Standalone"... is not valid JSON
stdout 的实际形状是这样(payload 上下都有日志):
[StandaloneStack] no compiled artifact at '…' — booting without one (run 'os compile' to build it)
2026-08-07T10:09:22.887Z INFO Registered metadata loader: filesystem (file:)
…约 60 行 INFO…
2026-08-07T10:09:23.387Z INFO ✅ Bootstrap complete
{
"planId": "metadata.recorded-by-sentinel-to-null",
"sentinel": "system",
"pending": 0,
"applied": false,
"duration": 504
}
2026-08-07T10:09:23.388Z INFO Graceful shutdown started
2026-08-07T10:09:23.394Z INFO ✅ Graceful shutdown complete
注意 stderr 是空的 —— 日志没走 stderr,是实打实占了 stdout。
归因(已定位到行,未修)
packages/core/src/logger.ts:343:
const stream = proc ? (isErrorLevel ? proc.stderr : proc.stdout) : undefined;
即 error/fatal 走 stderr,info/debug/warn 走 stdout。凡是 --json 且要 boot 内核的子命令都会被这一条命中,不止 migrate recorded-by:bootSchemaStack 系(migrate plan / apply / resume / summary-nulls / value-shapes / files-to-references / meta resync)都在同一条路径上。不 boot 内核的命令(如 os migrate meta)不受影响 —— packages/cli/test/migrate-meta.e2e.test.ts 至今可以直接 JSON.parse(stdout),这也是为什么这个问题一直没被 e2e 测出来。
为什么值得修
--json 这个 flag 的唯一受众是程序。一个必须先靠启发式把日志行剔掉才能解析的 --json,等于没有 --json:消费者要么写一段"找最后一个独占一行的 { 到配对 }"的提取器(#4873 的 e2e pin 里就被迫写了一份,packages/cli/test/migrate-exit-code.e2e.test.ts 的 jsonPayload()),要么就解析失败。而这类启发式在 payload 恰好紧邻某行日志、或日志里出现形似 JSON 的内容时会静默取错。
与 #4873 是同一受众的同一类伤害(成功的命令,程序侧却拿不到可用结果),但落点完全不同,所以单开一条。
可能的修法(未决,留给分诊)
--json 时把内核 logger 的输出重定向到 stderr(payload 独占 stdout);
--json 时把日志级别压到 error(信息量损失更大,且 warn 仍会丢);
- logger 默认全部走 stderr,stdout 只留给命令的正式输出(POSIX 惯例,影响面最大)。
三者对 os serve / os dev 的日志观感影响不同,建议由维护者定方向后再动手 —— 本条只负责把事实和落点记清楚。
复现
cd examples/app-crm
rm -f /tmp/t.db*
OS_DATABASE_URL="file:/tmp/t.db" node ../../packages/cli/bin/run.js migrate recorded-by --json 2>/dev/null | jq .
Refs #4873。
发现自 #4873 的实现过程(修那条 issue 时必须绕开本问题才能断言 payload)。未认领。 与 #4873 是不同的 bug —— #4873 修的是退出码,修好之后本条照旧复现。
现象
--json是给程序消费的,但os migrate recorded-by --json的 stdout 上除了 JSON payload,还夹着内核启动的 INFO 日志,所以整个 stdout 不是一份合法 JSON:实测(丢弃 stderr 后把 stdout 整份喂给
JSON.parse):stdout 的实际形状是这样(payload 上下都有日志):
注意 stderr 是空的 —— 日志没走 stderr,是实打实占了 stdout。
归因(已定位到行,未修)
packages/core/src/logger.ts:343:即
error/fatal走 stderr,info/debug/warn走 stdout。凡是--json且要 boot 内核的子命令都会被这一条命中,不止migrate recorded-by:bootSchemaStack系(migrate plan/apply/resume/summary-nulls/value-shapes/files-to-references/meta resync)都在同一条路径上。不 boot 内核的命令(如os migrate meta)不受影响 ——packages/cli/test/migrate-meta.e2e.test.ts至今可以直接JSON.parse(stdout),这也是为什么这个问题一直没被 e2e 测出来。为什么值得修
--json这个 flag 的唯一受众是程序。一个必须先靠启发式把日志行剔掉才能解析的--json,等于没有--json:消费者要么写一段"找最后一个独占一行的{到配对}"的提取器(#4873 的 e2e pin 里就被迫写了一份,packages/cli/test/migrate-exit-code.e2e.test.ts的jsonPayload()),要么就解析失败。而这类启发式在 payload 恰好紧邻某行日志、或日志里出现形似 JSON 的内容时会静默取错。与 #4873 是同一受众的同一类伤害(成功的命令,程序侧却拿不到可用结果),但落点完全不同,所以单开一条。
可能的修法(未决,留给分诊)
--json时把内核 logger 的输出重定向到 stderr(payload 独占 stdout);--json时把日志级别压到error(信息量损失更大,且 warn 仍会丢);三者对
os serve/os dev的日志观感影响不同,建议由维护者定方向后再动手 —— 本条只负责把事实和落点记清楚。复现
Refs #4873。