From 749692ec3787f5aca568ce509488e4a282121761 Mon Sep 17 00:00:00 2001 From: Gio Della-Libera Date: Sun, 24 May 2026 18:11:38 -0700 Subject: [PATCH] fix(cli): route node status hints to stdout (#85780) --- CHANGELOG.md | 1 + src/cli/node-cli/daemon.test.ts | 116 ++++++++++++++++++++++++++++++++ src/cli/node-cli/daemon.ts | 4 +- 3 files changed, 119 insertions(+), 2 deletions(-) create mode 100644 src/cli/node-cli/daemon.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 66d36516663..c8691c01dc0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -152,6 +152,7 @@ Docs: https://docs.openclaw.ai - CLI/plugins: tighten timeout, numeric option, media payload, permission, profile/TLS, plugin metadata, JSON, and remote URL handling; prevent stuck progress/app-server/IRC/Synology/Twitch waits; and keep imported chat history ordering stable. - Telegram/config: suppress the missing `accounts.default` warning when `channels.telegram.defaultAccount` names a configured account that also sorts first. Fixes #83948. Thanks @crypto86m. - Telegram: serialize visible topic replies through core reply-lane admission so heartbeat and queued follow-up turns cannot continue ownerless or misroute responses. (#85709) Thanks @jalehman. +- CLI/node: print node status recovery hints on stdout consistently while keeping status errors on stderr. Fixes #83925. Thanks @davinci282828. - WebChat: summarize internal message-tool source replies so tool cards no longer duplicate the visible reply body. (#84773) Thanks @jason-allen-oneal. - Gateway/WebChat: hide duplicate `gateway-injected` assistant rows when Cursor ACP already persisted the same `acp-runtime` reply. Fixes #85741. Thanks @lxf-lxf. - WebChat: scope the visible attachment button to its own composer file input so clicking Upload reliably opens the file picker. (#83952, fixes #47983) Thanks @jason-allen-oneal. diff --git a/src/cli/node-cli/daemon.test.ts b/src/cli/node-cli/daemon.test.ts new file mode 100644 index 00000000000..aa06f7e98ae --- /dev/null +++ b/src/cli/node-cli/daemon.test.ts @@ -0,0 +1,116 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { GatewayServiceRuntime } from "../../daemon/service-runtime.js"; +import { runNodeDaemonStatus } from "./daemon.js"; + +const mocks = vi.hoisted(() => { + const service = { + label: "Node service", + loadedText: "loaded", + notLoadedText: "not loaded", + stage: vi.fn(), + install: vi.fn(), + uninstall: vi.fn(), + stop: vi.fn(), + restart: vi.fn(), + isLoaded: vi.fn(async () => true), + readCommand: vi.fn(async () => null), + readRuntime: vi.fn<() => Promise>(async () => ({ status: "running" })), + }; + return { + runtime: { + log: vi.fn<(line: string) => void>(), + error: vi.fn<(line: string) => void>(), + writeJson: vi.fn(), + exit: vi.fn(), + }, + service, + }; +}); + +vi.mock("../../runtime.js", () => ({ + defaultRuntime: mocks.runtime, +})); + +vi.mock("../../daemon/node-service.js", () => ({ + resolveNodeService: () => mocks.service, +})); + +vi.mock("../../daemon/runtime-hints.js", () => ({ + buildPlatformRuntimeLogHints: () => [ + "Logs: node service log", + "Restart attempts: node restart log", + ], + buildPlatformServiceStartHints: () => ["openclaw node install", "openclaw node start"], +})); + +vi.mock("../../terminal/theme.js", async () => { + const actual = + await vi.importActual("../../terminal/theme.js"); + return { + ...actual, + colorize: (_rich: boolean, _theme: unknown, text: string) => text, + }; +}); + +vi.mock("../daemon-cli/shared.js", async () => { + const actual = + await vi.importActual("../daemon-cli/shared.js"); + return { + ...actual, + createCliStatusTextStyles: () => ({ + rich: false, + label: (text: string) => text, + accent: (text: string) => text, + infoText: (text: string) => text, + okText: (text: string) => text, + warnText: (text: string) => text, + errorText: (text: string) => text, + }), + formatRuntimeStatus: (runtime: GatewayServiceRuntime | undefined) => runtime?.status ?? "", + resolveRuntimeStatusColor: () => "", + }; +}); + +describe("runNodeDaemonStatus", () => { + function stdout(): string { + return mocks.runtime.log.mock.calls.map(([line]) => line).join("\n"); + } + + function stderr(): string { + return mocks.runtime.error.mock.calls.map(([line]) => line).join("\n"); + } + + beforeEach(() => { + mocks.runtime.log.mockClear(); + mocks.runtime.error.mockClear(); + mocks.runtime.writeJson.mockClear(); + mocks.runtime.exit.mockClear(); + mocks.service.isLoaded.mockReset().mockResolvedValue(true); + mocks.service.readCommand.mockReset().mockResolvedValue(null); + mocks.service.readRuntime.mockReset().mockResolvedValue({ status: "running" }); + }); + + it("keeps missing service-unit status on stderr and prints recovery hints on stdout", async () => { + mocks.service.readRuntime.mockResolvedValue({ status: "stopped", missingUnit: true }); + + await runNodeDaemonStatus(); + + expect(stderr()).toContain("Service unit not found."); + expect(stdout()).toContain("Logs: node service log"); + expect(stdout()).toContain("Restart attempts: node restart log"); + expect(stderr()).not.toContain("Logs: node service log"); + expect(stderr()).not.toContain("Restart attempts: node restart log"); + }); + + it("keeps stopped status on stderr and prints recovery hints on stdout", async () => { + mocks.service.readRuntime.mockResolvedValue({ status: "stopped" }); + + await runNodeDaemonStatus(); + + expect(stderr()).toContain("Service is loaded but not running."); + expect(stdout()).toContain("Logs: node service log"); + expect(stdout()).toContain("Restart attempts: node restart log"); + expect(stderr()).not.toContain("Logs: node service log"); + expect(stderr()).not.toContain("Restart attempts: node restart log"); + }); +}); diff --git a/src/cli/node-cli/daemon.ts b/src/cli/node-cli/daemon.ts index 57f48e5c806..3be819d2ad5 100644 --- a/src/cli/node-cli/daemon.ts +++ b/src/cli/node-cli/daemon.ts @@ -277,7 +277,7 @@ export async function runNodeDaemonStatus(opts: NodeDaemonStatusOptions = {}) { if (runtime?.missingUnit) { defaultRuntime.error(errorText("Service unit not found.")); for (const hint of buildNodeRuntimeHints(hintEnv)) { - defaultRuntime.error(errorText(hint)); + defaultRuntime.log(errorText(hint)); } return; } @@ -285,7 +285,7 @@ export async function runNodeDaemonStatus(opts: NodeDaemonStatusOptions = {}) { if (runtime?.status === "stopped") { defaultRuntime.error(errorText("Service is loaded but not running.")); for (const hint of buildNodeRuntimeHints(hintEnv)) { - defaultRuntime.error(errorText(hint)); + defaultRuntime.log(errorText(hint)); } } }