refactor(cli): separate json payload output from logging

This commit is contained in:
Peter Steinberger
2026-03-22 23:19:14 +00:00
parent 274af0486a
commit 4ee41cc6f3
89 changed files with 710 additions and 693 deletions

View File

@@ -9,6 +9,7 @@ import {
setConsoleTimestampPrefix,
setLoggerOverride,
} from "../logging.js";
import { defaultRuntime } from "../runtime.js";
import { loggingState } from "./state.js";
import {
captureConsoleSnapshot,
@@ -101,7 +102,7 @@ describe("enableConsoleCapture", () => {
expect(warn).toHaveBeenCalledWith("12:34:56 [exec] hello");
});
it("leaves JSON output unchanged when timestamp prefix is enabled", () => {
it("prefixes JSON console output when timestamp prefix is enabled", () => {
setLoggerOverride({ level: "info", file: tempLogPath() });
const log = vi.fn();
console.log = log;
@@ -109,7 +110,24 @@ describe("enableConsoleCapture", () => {
enableConsoleCapture();
const payload = JSON.stringify({ ok: true });
console.log(payload);
expect(log).toHaveBeenCalledWith(payload);
expect(log).toHaveBeenCalledTimes(1);
const firstArg = String(log.mock.calls[0]?.[0] ?? "");
expect(firstArg).toMatch(/^(?:\d{2}:\d{2}:\d{2}|\d{4}-\d{2}-\d{2}T)/);
expect(firstArg.endsWith(` ${payload}`)).toBe(true);
});
it("keeps diagnostics on stderr while runtime JSON stays on stdout", () => {
setLoggerOverride({ level: "info", file: tempLogPath() });
const stdoutWrite = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
const stderrWrite = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
routeLogsToStderr();
enableConsoleCapture();
console.log("diag");
defaultRuntime.writeJson({ ok: true });
expect(stderrWrite).toHaveBeenCalledWith("diag\n");
expect(stdoutWrite).toHaveBeenCalledWith('{\n "ok": true\n}\n');
});
it.each([

View File

@@ -189,19 +189,6 @@ function hasTimestampPrefix(value: string): boolean {
);
}
function isJsonPayload(value: string): boolean {
const trimmed = value.trim();
if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) {
return false;
}
try {
JSON.parse(trimmed);
return true;
} catch {
return false;
}
}
/**
* Route console.* calls through file logging while still emitting to stdout/stderr.
* This keeps user-facing output unchanged but guarantees every console call is captured in log files.
@@ -262,10 +249,7 @@ export function enableConsoleCapture(): void {
}
const trimmed = stripAnsi(formatted).trimStart();
const shouldPrefixTimestamp =
loggingState.consoleTimestampPrefix &&
trimmed.length > 0 &&
!hasTimestampPrefix(trimmed) &&
!isJsonPayload(trimmed);
loggingState.consoleTimestampPrefix && trimmed.length > 0 && !hasTimestampPrefix(trimmed);
const timestamp = shouldPrefixTimestamp
? formatConsoleTimestamp(getConsoleSettings().style)
: "";
@@ -288,9 +272,8 @@ export function enableConsoleCapture(): void {
} catch {
// never block console output on logging failures
}
if (loggingState.forceConsoleToStderr && !isJsonPayload(trimmed)) {
// In --json mode, route diagnostic logs to stderr but let JSON
// payloads (the actual command output) through to stdout via orig().
if (loggingState.forceConsoleToStderr) {
// In --json mode, all console.* writes are diagnostics and should stay off stdout.
try {
const line = timestamp ? `${timestamp} ${formatted}` : formatted;
process.stderr.write(`${line}\n`);

View File

@@ -1,7 +1,7 @@
import { Chalk } from "chalk";
import type { Logger as TsLogger } from "tslog";
import { isVerbose } from "../globals.js";
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
import { defaultRuntime, type OutputRuntimeEnv, type RuntimeEnv } from "../runtime.js";
import { clearActiveProgressLine } from "../terminal/progress-line.js";
import {
formatConsoleTimestamp,
@@ -404,7 +404,7 @@ export function createSubsystemLogger(subsystem: string): SubsystemLogger {
export function runtimeForLogger(
logger: SubsystemLogger,
exit: RuntimeEnv["exit"] = defaultRuntime.exit,
): RuntimeEnv {
): OutputRuntimeEnv {
const formatArgs = (...args: unknown[]) =>
args
.map((arg) => formatRuntimeArg(arg))
@@ -413,6 +413,10 @@ export function runtimeForLogger(
return {
log: (...args: unknown[]) => logger.info(formatArgs(...args)),
error: (...args: unknown[]) => logger.error(formatArgs(...args)),
writeStdout: (value: string) => logger.info(value),
writeJson: (value: unknown, space = 2) => {
logger.info(JSON.stringify(value, null, space > 0 ? space : undefined));
},
exit,
};
}
@@ -420,6 +424,6 @@ export function runtimeForLogger(
export function createSubsystemRuntime(
subsystem: string,
exit: RuntimeEnv["exit"] = defaultRuntime.exit,
): RuntimeEnv {
): OutputRuntimeEnv {
return runtimeForLogger(createSubsystemLogger(subsystem), exit);
}