diff --git a/src/cli/daemon-cli/lifecycle.ts b/src/cli/daemon-cli/lifecycle.ts index 02ec6d33d8c2..cbccf995ff63 100644 --- a/src/cli/daemon-cli/lifecycle.ts +++ b/src/cli/daemon-cli/lifecycle.ts @@ -36,7 +36,7 @@ import { writeGatewayRestartIntentSync, } from "../../infra/restart-intent.js"; import { resolveGatewayRestartDeferralTimeoutMs } from "../../infra/restart.js"; -import { defaultRuntime } from "../../runtime.js"; +import { defaultRuntime, writeRuntimeJson } from "../../runtime.js"; import { formatCliCommand } from "../command-format.js"; import { parseDurationMs } from "../parse-duration.js"; import { recoverInstalledLaunchAgent } from "./launchd-recovery.js"; @@ -293,7 +293,7 @@ async function requestSafeGatewayRestart(opts: DaemonLifecycleOptions): Promise< warnings: formatSafeRestartWarnings(result), }; if (opts.json) { - defaultRuntime.log(JSON.stringify(payload, null, 2)); + writeRuntimeJson(defaultRuntime, payload); } else { defaultRuntime.log(message); if (result.preflight.blockers.length > 0) { diff --git a/src/cli/node-cli/identity.test.ts b/src/cli/node-cli/identity.test.ts index 9d4dc1510b35..ebb0fce20880 100644 --- a/src/cli/node-cli/identity.test.ts +++ b/src/cli/node-cli/identity.test.ts @@ -13,17 +13,27 @@ import { runNodeIdentityShow } from "./identity.js"; describe("runNodeIdentityShow", () => { let stateDir: string; let prevStateDir: string | undefined; + let stdout: string[]; let logSpy: ReturnType; let errorSpy: ReturnType; let exitSpy: ReturnType; + let writeJsonSpy: ReturnType; + let writeStdoutSpy: ReturnType; beforeEach(() => { stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-node-identity-")); prevStateDir = process.env.OPENCLAW_STATE_DIR; process.env.OPENCLAW_STATE_DIR = stateDir; + stdout = []; logSpy = vi.spyOn(defaultRuntime, "log").mockImplementation(() => {}); errorSpy = vi.spyOn(defaultRuntime, "error").mockImplementation(() => {}); exitSpy = vi.spyOn(defaultRuntime, "exit").mockImplementation(() => {}); + writeStdoutSpy = vi + .spyOn(defaultRuntime, "writeStdout") + .mockImplementation((value) => stdout.push(value)); + writeJsonSpy = vi.spyOn(defaultRuntime, "writeJson").mockImplementation((value, space = 2) => { + defaultRuntime.writeStdout(JSON.stringify(value, null, space > 0 ? space : undefined)); + }); }); afterEach(() => { @@ -35,6 +45,8 @@ describe("runNodeIdentityShow", () => { logSpy.mockRestore(); errorSpy.mockRestore(); exitSpy.mockRestore(); + writeJsonSpy.mockRestore(); + writeStdoutSpy.mockRestore(); fs.rmSync(stateDir, { recursive: true, force: true }); }); @@ -45,12 +57,14 @@ describe("runNodeIdentityShow", () => { expect(fs.existsSync(path.join(stateDir, "identity", "device.json"))).toBe(false); }); - it("prints deviceId and raw public key as JSON", () => { + it("writes deviceId and raw public key JSON to stdout", () => { const identity = loadOrCreateDeviceIdentity(path.join(stateDir, "identity", "device.json")); runNodeIdentityShow({ json: true }); expect(exitSpy).not.toHaveBeenCalled(); - expect(logSpy).toHaveBeenCalledOnce(); - const parsed = JSON.parse(String(logSpy.mock.calls[0]?.[0])) as { + expect(logSpy).not.toHaveBeenCalled(); + expect(errorSpy).not.toHaveBeenCalled(); + expect(writeStdoutSpy).toHaveBeenCalledOnce(); + const parsed = JSON.parse(stdout.join("")) as { deviceId: string; publicKey: string; }; diff --git a/src/cli/node-cli/identity.ts b/src/cli/node-cli/identity.ts index b6636e2abbea..f3f3da785905 100644 --- a/src/cli/node-cli/identity.ts +++ b/src/cli/node-cli/identity.ts @@ -3,7 +3,7 @@ import { loadDeviceIdentityIfPresent, publicKeyRawBase64UrlFromPem, } from "../../infra/device-identity.js"; -import { defaultRuntime } from "../../runtime.js"; +import { defaultRuntime, writeRuntimeJson } from "../../runtime.js"; /** * Read-only by design: the SSH-verified pairing probe calls this remotely and @@ -23,7 +23,7 @@ export function runNodeIdentityShow(opts: { json?: boolean }) { publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem), }; if (opts.json) { - defaultRuntime.log(JSON.stringify(payload)); + writeRuntimeJson(defaultRuntime, payload, 0); return; } defaultRuntime.log(`deviceId: ${payload.deviceId}`); diff --git a/src/commands/promos/list.test.ts b/src/commands/promos/list.test.ts index 24f71c3407f2..32b5a7467ee3 100644 --- a/src/commands/promos/list.test.ts +++ b/src/commands/promos/list.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { RuntimeEnv } from "../../runtime.js"; +import type { OutputRuntimeEnv } from "../../runtime.js"; const mocks = vi.hoisted(() => ({ fetchClawHubPromotions: vi.fn(), @@ -19,12 +19,22 @@ const { promosListCommand } = await import("./list.js"); function makeRuntime() { const lines: string[] = []; - const runtime = { - log: vi.fn((line: string) => lines.push(line)), + const stdout: string[] = []; + const writeStdout = vi.fn((value: string): void => { + stdout.push(value); + }); + const runtime: OutputRuntimeEnv = { + log: vi.fn((...args: unknown[]): void => { + lines.push(args.map(String).join(" ")); + }), error: vi.fn(), exit: vi.fn(), - } as unknown as RuntimeEnv; - return { runtime, lines }; + writeStdout, + writeJson: vi.fn((value: unknown, space = 2) => { + writeStdout(JSON.stringify(value, null, space > 0 ? space : undefined)); + }), + }; + return { runtime, lines, stdout }; } const promotion = { @@ -83,11 +93,14 @@ describe("promosListCommand", () => { mocks.fetchClawHubPromotions.mockRejectedValue( new ClawHubRequestError({ path: "/api/v1/promotions", status: 404, body: "not found" }), ); - const { runtime, lines } = makeRuntime(); + const { runtime, lines, stdout } = makeRuntime(); await promosListCommand({ json: true }, runtime); - expect(JSON.parse(lines.join("\n"))).toEqual({ promotions: [] }); + expect(lines).toEqual([]); + expect(runtime.error).not.toHaveBeenCalled(); + expect(runtime.writeStdout).toHaveBeenCalledOnce(); + expect(JSON.parse(stdout.join(""))).toEqual({ promotions: [] }); }); it("strips terminal control sequences from remote promotion text", async () => { @@ -107,13 +120,16 @@ describe("promosListCommand", () => { expect(output).toContain("Free"); }); - it("emits JSON with --json", async () => { + it("writes JSON to stdout with --json", async () => { mocks.fetchClawHubPromotions.mockResolvedValue([promotion]); - const { runtime, lines } = makeRuntime(); + const { runtime, lines, stdout } = makeRuntime(); await promosListCommand({ json: true }, runtime); - const parsed = JSON.parse(lines.join("\n")) as { promotions: Array<{ slug: string }> }; + expect(lines).toEqual([]); + expect(runtime.error).not.toHaveBeenCalled(); + expect(runtime.writeStdout).toHaveBeenCalledOnce(); + const parsed = JSON.parse(stdout.join("")) as { promotions: Array<{ slug: string }> }; expect(parsed.promotions[0]?.slug).toBe("spring-models"); }); }); diff --git a/src/commands/promos/list.ts b/src/commands/promos/list.ts index ef388e1c66ef..9ab6c06884cd 100644 --- a/src/commands/promos/list.ts +++ b/src/commands/promos/list.ts @@ -7,7 +7,7 @@ import { type ClawHubPromotion, } from "../../infra/clawhub.js"; import { markPromotionSlugsNotified } from "../../infra/promotions-feed.js"; -import type { RuntimeEnv } from "../../runtime.js"; +import { type RuntimeEnv, writeRuntimeJson } from "../../runtime.js"; function formatWindowEnd(promotion: ClawHubPromotion): string { const daysLeft = Math.max(0, Math.ceil((promotion.endsAt - Date.now()) / 86_400_000)); @@ -25,18 +25,18 @@ export async function promosListCommand(opts: { json?: boolean }, runtime: Runti if (!(error instanceof ClawHubRequestError) || error.status !== 404) { throw error; } - runtime.log( - opts.json - ? JSON.stringify({ promotions: [] }, null, 2) - : "Promotions are not available from ClawHub yet.", - ); + if (opts.json) { + writeRuntimeJson(runtime, { promotions: [] }); + } else { + runtime.log("Promotions are not available from ClawHub yet."); + } return; } // The user has now seen these offers; suppress the one-time passive // discovery notice for them (`models list` reads the same markers). markPromotionSlugsNotified(promotions.map((promotion) => promotion.slug)); if (opts.json) { - runtime.log(JSON.stringify({ promotions }, null, 2)); + writeRuntimeJson(runtime, { promotions }); return; } if (promotions.length === 0) { diff --git a/src/commands/tasks.ts b/src/commands/tasks.ts index 2446e0a9077d..066d889c9e2c 100644 --- a/src/commands/tasks.ts +++ b/src/commands/tasks.ts @@ -14,7 +14,7 @@ import { } from "../config/sessions.js"; import { normalizeCronLaneSegment } from "../cron/service/task-runs.js"; import { loadCronJobsStoreSync, resolveCronJobsStorePath } from "../cron/store.js"; -import type { RuntimeEnv } from "../runtime.js"; +import { type RuntimeEnv, writeRuntimeJson } from "../runtime.js"; import { getTaskById, updateTaskNotifyPolicyById } from "../tasks/runtime-internal.js"; import { cancelDetachedTaskRunById } from "../tasks/task-executor.js"; import { listTaskFlowAuditFindings } from "../tasks/task-flow-registry.audit.js"; @@ -360,18 +360,12 @@ export async function tasksListCommand( }); if (opts.json) { - runtime.log( - JSON.stringify( - { - count: tasks.length, - runtime: runtimeFilter ?? null, - status: statusFilter ?? null, - tasks, - }, - null, - 2, - ), - ); + writeRuntimeJson(runtime, { + count: tasks.length, + runtime: runtimeFilter ?? null, + status: statusFilter ?? null, + tasks, + }); return; } @@ -408,7 +402,7 @@ export async function tasksShowCommand( } if (opts.json) { - runtime.log(JSON.stringify(task, null, 2)); + writeRuntimeJson(runtime, task); return; } @@ -535,16 +529,13 @@ export async function tasksAuditCommand( const displayed = limit ? filteredFindings.slice(0, limit) : filteredFindings; if (opts.json) { - runtime.log( - JSON.stringify( - buildTaskSystemAuditJsonPayload(auditResult, { - severityFilter, - codeFilter, - limit: opts.limit, - }), - null, - 2, - ), + writeRuntimeJson( + runtime, + buildTaskSystemAuditJsonPayload(auditResult, { + severityFilter, + codeFilter, + limit: opts.limit, + }), ); return; } @@ -606,30 +597,24 @@ export async function tasksMaintenanceCommand( ); if (opts.json) { - runtime.log( - JSON.stringify( - { - mode: opts.apply ? "apply" : "preview", - maintenance: { - tasks: taskMaintenance, - taskFlows: flowMaintenance, - sessions: sessionMaintenance, - }, - tasks: summary, - diagnostics, - auditBefore: { - ...auditBefore, - taskFlows: flowAuditBefore, - }, - auditAfter: { - ...auditAfter, - taskFlows: flowAuditAfter, - }, - }, - null, - 2, - ), - ); + writeRuntimeJson(runtime, { + mode: opts.apply ? "apply" : "preview", + maintenance: { + tasks: taskMaintenance, + taskFlows: flowMaintenance, + sessions: sessionMaintenance, + }, + tasks: summary, + diagnostics, + auditBefore: { + ...auditBefore, + taskFlows: flowAuditBefore, + }, + auditAfter: { + ...auditAfter, + taskFlows: flowAuditAfter, + }, + }); return; }