mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-18 00:23:25 -06:00
fix(cli): emit one JSON failure contract for --json invocations (#124849)
* fix(cli): unify JSON failure output * test(cli): update skills verify failure envelope
This commit is contained in:
committed by
GitHub
parent
0b5bb09510
commit
6c66f48a7c
@@ -65,6 +65,26 @@ state directories and config paths remain unchanged.
|
||||
report to return.
|
||||
- Long-running commands show a progress indicator (OSC 9;4 when supported).
|
||||
|
||||
### JSON failures
|
||||
|
||||
Successful JSON payloads remain command-specific. When a command in JSON output
|
||||
mode fails, it exits nonzero and writes one JSON document to stdout with this
|
||||
envelope:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": false,
|
||||
"error": {
|
||||
"type": "cli_error",
|
||||
"message": "Description of the failure"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
A command may add domain-specific fields, such as per-item results, beside this
|
||||
envelope. Failure messages are sanitized. Human-readable diagnostics may also be
|
||||
written to stderr, so scripts should parse stdout and still check the exit status.
|
||||
|
||||
## Color palette
|
||||
|
||||
OpenClaw uses a lobster palette for CLI output:
|
||||
|
||||
@@ -4,6 +4,10 @@ import { describe, expect, it, vi } from "vitest";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import { runCommandWithRuntime } from "./cli-utils.js";
|
||||
import { registerDnsCli } from "./dns-cli.js";
|
||||
import {
|
||||
applyResolvedCommandOutputMode,
|
||||
withConsoleLogsRoutedToStderrForJson,
|
||||
} from "./json-output-mode.js";
|
||||
import { parseByteSize } from "./parse-bytes.js";
|
||||
import { parseDurationMs } from "./parse-duration.js";
|
||||
import {
|
||||
@@ -61,6 +65,27 @@ describe("runCommandWithRuntime", () => {
|
||||
expect(messages[0]).toContain("UND_ERR_INVALID_ARG");
|
||||
expect(exits).toEqual([1]);
|
||||
});
|
||||
|
||||
it("bubbles JSON-mode failures to the process-level owner", async () => {
|
||||
const originalArgv = process.argv;
|
||||
const runtime = { error: vi.fn(), exit: vi.fn() };
|
||||
process.argv = ["node", "openclaw", "backup", "verify", "missing.tgz", "--json"];
|
||||
try {
|
||||
await withConsoleLogsRoutedToStderrForJson(process.argv, async () => {
|
||||
applyResolvedCommandOutputMode(true);
|
||||
await expect(
|
||||
runCommandWithRuntime(runtime, async () => {
|
||||
throw new Error("archive missing");
|
||||
}),
|
||||
).rejects.toThrow("archive missing");
|
||||
});
|
||||
} finally {
|
||||
process.argv = originalArgv;
|
||||
}
|
||||
|
||||
expect(runtime.error).not.toHaveBeenCalled();
|
||||
expect(runtime.exit).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldSkipRespawnForArgv", () => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Shared CLI execution wrappers and inherited Commander option lookup.
|
||||
import type { Command } from "commander";
|
||||
import { formatErrorMessage } from "../infra/errors.js";
|
||||
import { isJsonOutputModeActive } from "./json-output-mode.js";
|
||||
|
||||
export { formatErrorMessage };
|
||||
|
||||
@@ -40,6 +41,9 @@ export async function runCommandWithRuntime(
|
||||
try {
|
||||
await action();
|
||||
} catch (err) {
|
||||
if (isJsonOutputModeActive(process.argv)) {
|
||||
throw err;
|
||||
}
|
||||
if (onError) {
|
||||
onError(err);
|
||||
return;
|
||||
|
||||
@@ -31,6 +31,7 @@ import { formatCliCommand } from "./command-format.js";
|
||||
import type { ConfigSetOperation } from "./config-cli-input.js";
|
||||
import { formatPluginPackagingRuntimeOutputRecoveryHint } from "./config-recovery-hints.js";
|
||||
import type { ConfigSetDryRunError } from "./config-set-dryrun.js";
|
||||
import { formatCliJsonFailure } from "./failure-output.js";
|
||||
|
||||
function formatInvalidConfigRepairHint(
|
||||
snapshot: Pick<ConfigFileSnapshot, "valid" | "issues" | "warnings" | "legacyIssues">,
|
||||
@@ -54,7 +55,7 @@ export async function loadValidConfig(
|
||||
}
|
||||
if (options.json) {
|
||||
writeRuntimeJson(runtime, {
|
||||
error: `OpenClaw config is invalid: ${shortenHomePath(snapshot.path)}`,
|
||||
...formatCliJsonFailure(`OpenClaw config is invalid: ${shortenHomePath(snapshot.path)}`),
|
||||
issues: normalizeConfigIssues(snapshot.issues),
|
||||
});
|
||||
runtime.exit(1);
|
||||
|
||||
@@ -1355,8 +1355,11 @@ describe("config cli", () => {
|
||||
).rejects.toThrow(ExitError);
|
||||
|
||||
expect(mockError).not.toHaveBeenCalled();
|
||||
const payload = parseLastLogPayload() as { error: string };
|
||||
expect(payload.error).toBe("Config path not found: nonexistent.path");
|
||||
const payload = parseLastLogPayload() as { error: { type: string; message: string } };
|
||||
expect(payload.error).toEqual({
|
||||
type: "cli_error",
|
||||
message: "Config path not found: nonexistent.path",
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
@@ -1386,7 +1389,11 @@ describe("config cli", () => {
|
||||
expect(mockReadConfigFileSnapshot).not.toHaveBeenCalled();
|
||||
expect(mockError).not.toHaveBeenCalled();
|
||||
expect(parseLastLogPayload()).toMatchObject({
|
||||
error: expect.stringContaining(testCase.error),
|
||||
ok: false,
|
||||
error: {
|
||||
type: "cli_error",
|
||||
message: expect.stringContaining(testCase.error),
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -1405,7 +1412,11 @@ describe("config cli", () => {
|
||||
expect(mockReadConfigFileSnapshot).toHaveBeenCalledWith({ observe: false });
|
||||
expect(mockError).not.toHaveBeenCalled();
|
||||
expect(parseLastLogPayload()).toMatchObject({
|
||||
error: expect.stringContaining("OpenClaw config is invalid"),
|
||||
ok: false,
|
||||
error: {
|
||||
type: "cli_error",
|
||||
message: expect.stringContaining("OpenClaw config is invalid"),
|
||||
},
|
||||
issues: [{ path: "gateway.bind", message: "Invalid enum value" }],
|
||||
});
|
||||
});
|
||||
|
||||
+15
-5
@@ -54,6 +54,7 @@ import {
|
||||
type ConfigSetOptions,
|
||||
} from "./config-set-input.js";
|
||||
import { resolveConfigSetMode } from "./config-set-parser.js";
|
||||
import { formatCliJsonFailure } from "./failure-output.js";
|
||||
import { setCommandJsonMode } from "./program/json-mode.js";
|
||||
|
||||
export { parseConfigSetPath } from "./config-cli-path.js";
|
||||
@@ -155,7 +156,7 @@ export async function runConfigGet(opts: { path: string; json?: boolean; runtime
|
||||
const res = getAtPath(redactConfigObject(snapshot.config), parsedPath);
|
||||
if (!res.found) {
|
||||
if (opts.json) {
|
||||
writeRuntimeJson(runtime, { error: `Config path not found: ${opts.path}` });
|
||||
writeRuntimeJson(runtime, formatCliJsonFailure(`Config path not found: ${opts.path}`));
|
||||
runtime.exit(1);
|
||||
return;
|
||||
}
|
||||
@@ -183,7 +184,7 @@ export async function runConfigGet(opts: { path: string; json?: boolean; runtime
|
||||
throw err;
|
||||
}
|
||||
if (opts.json) {
|
||||
writeRuntimeJson(runtime, { error: formatErrorMessage(err) });
|
||||
writeRuntimeJson(runtime, formatCliJsonFailure(err));
|
||||
runtime.exit(1);
|
||||
return;
|
||||
}
|
||||
@@ -317,7 +318,11 @@ async function runConfigValidate(opts: { json?: boolean; runtime?: RuntimeEnv }
|
||||
const shortPath = shortenHomePath(outputPath);
|
||||
if (!snapshot.exists) {
|
||||
if (opts.json) {
|
||||
writeRuntimeJson(runtime, { valid: false, path: outputPath, error: "file not found" }, 0);
|
||||
writeRuntimeJson(
|
||||
runtime,
|
||||
{ ...formatCliJsonFailure("file not found"), valid: false, path: outputPath },
|
||||
0,
|
||||
);
|
||||
} else {
|
||||
runtime.error(danger(`Config file not found: ${shortPath}`));
|
||||
runtime.error(
|
||||
@@ -330,7 +335,12 @@ async function runConfigValidate(opts: { json?: boolean; runtime?: RuntimeEnv }
|
||||
if (!snapshot.valid) {
|
||||
const issues = normalizeConfigIssues(snapshot.issues);
|
||||
if (opts.json) {
|
||||
writeRuntimeJson(runtime, { valid: false, path: outputPath, issues });
|
||||
writeRuntimeJson(runtime, {
|
||||
...formatCliJsonFailure(`OpenClaw config is invalid: ${shortPath}`),
|
||||
valid: false,
|
||||
path: outputPath,
|
||||
issues,
|
||||
});
|
||||
} else {
|
||||
runtime.error(danger(`OpenClaw config is invalid: ${shortPath}`));
|
||||
for (const line of renderConfigValidationIssueLines(snapshot, danger("×"))) {
|
||||
@@ -361,7 +371,7 @@ async function runConfigValidate(opts: { json?: boolean; runtime?: RuntimeEnv }
|
||||
if (opts.json) {
|
||||
writeRuntimeJson(
|
||||
runtime,
|
||||
{ valid: false, path: outputPath, error: formatErrorMessage(err) },
|
||||
{ ...formatCliJsonFailure(err), valid: false, path: outputPath },
|
||||
0,
|
||||
);
|
||||
} else {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { createMockCronStateForJobs } from "../../cron/service.test-harness.js";
|
||||
import { listPage } from "../../cron/service/ops-read.js";
|
||||
import type { CronJob } from "../../cron/types.js";
|
||||
import { cronHandlers } from "../../gateway/server-methods/cron.js";
|
||||
import { withConsoleLogsRoutedToStderrForJson } from "../json-output-mode.js";
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const runtime = {
|
||||
@@ -131,6 +132,16 @@ async function runCron(args: string[]): Promise<void> {
|
||||
await program.parseAsync(["cron", ...args], { from: "user" });
|
||||
}
|
||||
|
||||
async function runCronWithJsonOwner(args: string[]): Promise<void> {
|
||||
const originalArgv = process.argv;
|
||||
process.argv = ["node", "openclaw", "cron", ...args];
|
||||
try {
|
||||
await withConsoleLogsRoutedToStderrForJson(process.argv, () => runCron(args));
|
||||
} finally {
|
||||
process.argv = originalArgv;
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
@@ -278,11 +289,11 @@ describe("cron CLI with the real Gateway pagination contract", () => {
|
||||
);
|
||||
disableCronGetForProtocolV4Gateway();
|
||||
|
||||
await expect(runCron(["list", "--json"])).rejects.toThrow("exit 1");
|
||||
|
||||
expect(mocks.runtime.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining("inventory changed repeatedly"),
|
||||
await expect(runCronWithJsonOwner(["list", "--json"])).rejects.toThrow(
|
||||
"inventory changed repeatedly",
|
||||
);
|
||||
|
||||
expect(mocks.runtime.error).not.toHaveBeenCalled();
|
||||
expect(mocks.runtime.writeJson).not.toHaveBeenCalled();
|
||||
expect(
|
||||
mocks.callGatewayFromCli.mock.calls.filter(([method]) => method === "cron.list"),
|
||||
|
||||
@@ -25,6 +25,7 @@ import { defaultRuntime, type RuntimeEnv } from "../../runtime.js";
|
||||
import { formatLookupMiss } from "../error-format.js";
|
||||
import type { GatewayRpcOpts } from "../gateway-rpc.js";
|
||||
import { callGatewayFromCli } from "../gateway-rpc.js";
|
||||
import { isJsonOutputModeActive } from "../json-output-mode.js";
|
||||
import { parseDurationMs as parseSharedDurationMs } from "../parse-duration.js";
|
||||
|
||||
function parseCronArgv(value: unknown, flag: string): string[] | undefined {
|
||||
@@ -213,6 +214,9 @@ export function handleCronCliError(err: unknown) {
|
||||
valueLabel: "automation id",
|
||||
})
|
||||
: formatErrorMessage(err);
|
||||
if (isJsonOutputModeActive(process.argv)) {
|
||||
throw new Error(message);
|
||||
}
|
||||
defaultRuntime.error(danger(message));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
|
||||
@@ -131,8 +131,11 @@ describe("runDaemonStatus", () => {
|
||||
expect(gatherDaemonStatus).not.toHaveBeenCalled();
|
||||
expect(defaultRuntime.writeJson).toHaveBeenCalledWith({
|
||||
ok: false,
|
||||
error:
|
||||
"Gateway status failed: --require-rpc needs probing enabled. Remove --no-probe or drop --require-rpc.",
|
||||
error: {
|
||||
type: "cli_error",
|
||||
message:
|
||||
"Gateway status failed: --require-rpc needs probing enabled. Remove --no-probe or drop --require-rpc.",
|
||||
},
|
||||
});
|
||||
expect(defaultRuntime.error).not.toHaveBeenCalled();
|
||||
expect(defaultRuntime.exit).toHaveBeenCalledTimes(1);
|
||||
@@ -156,7 +159,10 @@ describe("runDaemonStatus", () => {
|
||||
expect(printDaemonStatus).not.toHaveBeenCalled();
|
||||
expect(defaultRuntime.writeJson).toHaveBeenCalledWith({
|
||||
ok: false,
|
||||
error: expect.stringContaining("Gateway status failed: service manager unavailable"),
|
||||
error: {
|
||||
type: "cli_error",
|
||||
message: expect.stringContaining("Gateway status failed: service manager unavailable"),
|
||||
},
|
||||
});
|
||||
expect(JSON.stringify(defaultRuntime.writeJson.mock.calls)).not.toContain(error.name);
|
||||
expect(JSON.stringify(defaultRuntime.writeJson.mock.calls)).not.toContain(secret);
|
||||
|
||||
@@ -2,13 +2,14 @@
|
||||
import { colorize, isRich, theme } from "../../../packages/terminal-core/src/theme.js";
|
||||
import { formatErrorMessage } from "../../infra/errors.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import { formatCliJsonFailure } from "../failure-output.js";
|
||||
import { gatherDaemonStatus } from "./status.gather.js";
|
||||
import { printDaemonStatus } from "./status.print.js";
|
||||
import type { DaemonStatusOptions } from "./types.js";
|
||||
|
||||
function failDaemonStatus(opts: DaemonStatusOptions, message: string): void {
|
||||
if (opts.json) {
|
||||
defaultRuntime.writeJson({ ok: false, error: message });
|
||||
defaultRuntime.writeJson(formatCliJsonFailure(message));
|
||||
} else {
|
||||
defaultRuntime.error(colorize(isRich(), theme.error, message));
|
||||
}
|
||||
|
||||
@@ -499,7 +499,7 @@ describe("registerDirectoryCli", () => {
|
||||
],
|
||||
"Channel demo-directory does not support group members listing",
|
||||
],
|
||||
])("writes JSON errors for unsupported directory %s", async (_label, args, expectedError) => {
|
||||
])("bubbles JSON errors for unsupported directory %s", async (_label, args, expectedError) => {
|
||||
mocks.resolveInstallableChannelPlugin.mockResolvedValue({
|
||||
cfg: { channels: { "demo-directory": {} } },
|
||||
channelId: "demo-directory",
|
||||
@@ -513,12 +513,11 @@ describe("registerDirectoryCli", () => {
|
||||
const program = new Command().name("openclaw");
|
||||
registerDirectoryCli(program);
|
||||
|
||||
await expect(program.parseAsync(args, { from: "user" })).rejects.toThrow("exit:1");
|
||||
await expect(program.parseAsync(args, { from: "user" })).rejects.toThrow(expectedError);
|
||||
|
||||
expect(runtimeState.defaultRuntime.writeJson).toHaveBeenCalledOnce();
|
||||
expect(runtimeState.defaultRuntime.writeJson).toHaveBeenCalledWith({ error: expectedError });
|
||||
expect(runtimeState.defaultRuntime.writeJson).not.toHaveBeenCalled();
|
||||
expect(runtimeState.defaultRuntime.error).not.toHaveBeenCalled();
|
||||
expect(runtimeState.defaultRuntime.exit).toHaveBeenCalledWith(1);
|
||||
expect(runtimeState.defaultRuntime.exit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
@@ -541,18 +540,18 @@ describe("registerDirectoryCli", () => {
|
||||
const program = new Command().name("openclaw");
|
||||
registerDirectoryCli(program);
|
||||
|
||||
await expect(program.parseAsync(args, { from: "user" })).rejects.toThrow("exit:1");
|
||||
|
||||
if (mode === "JSON") {
|
||||
const payload = JSON.parse(runtimeState.runtimeLogs.at(-1) ?? "");
|
||||
expect(payload).toEqual({ error: error.message });
|
||||
await expect(program.parseAsync(args, { from: "user" })).rejects.toThrow(error.message);
|
||||
expect(runtimeState.defaultRuntime.writeJson).not.toHaveBeenCalled();
|
||||
expect(runtimeState.defaultRuntime.error).not.toHaveBeenCalled();
|
||||
expect(runtimeState.defaultRuntime.exit).not.toHaveBeenCalled();
|
||||
} else {
|
||||
await expect(program.parseAsync(args, { from: "user" })).rejects.toThrow("exit:1");
|
||||
expect(runtimeErrors()).toEqual([error.message]);
|
||||
expect(runtimeState.defaultRuntime.writeJson).not.toHaveBeenCalled();
|
||||
expect(runtimeState.defaultRuntime.exit).toHaveBeenCalledWith(1);
|
||||
}
|
||||
expect([...runtimeState.runtimeLogs, ...runtimeErrors()].join("\n")).not.toContain(error.name);
|
||||
expect(runtimeState.defaultRuntime.exit).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it.each([
|
||||
|
||||
@@ -206,12 +206,10 @@ export function registerDirectoryCli(program: Command) {
|
||||
try {
|
||||
await action();
|
||||
} catch (err) {
|
||||
const message = formatErrorMessage(err);
|
||||
if (opts.json) {
|
||||
defaultRuntime.writeJson({ error: message });
|
||||
} else {
|
||||
defaultRuntime.error(danger(message));
|
||||
throw err;
|
||||
}
|
||||
defaultRuntime.error(danger(formatErrorMessage(err)));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -273,17 +273,16 @@ describe("exec approvals pending and resolve CLI", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("writes pending approval failures as JSON", async () => {
|
||||
it("bubbles pending approval failures to the JSON owner", async () => {
|
||||
callGatewayFromCli.mockRejectedValue(new Error("gateway unavailable"));
|
||||
|
||||
await expect(runApprovalsCommand(["approvals", "pending", "--json"])).rejects.toThrow(
|
||||
"__exit__:1",
|
||||
"gateway unavailable",
|
||||
);
|
||||
|
||||
expect(defaultRuntime.writeJson).toHaveBeenCalledOnce();
|
||||
expect(defaultRuntime.writeJson).toHaveBeenCalledWith({ error: "gateway unavailable" }, 0);
|
||||
expect(defaultRuntime.writeJson).not.toHaveBeenCalled();
|
||||
expect(defaultRuntime.error).not.toHaveBeenCalled();
|
||||
expect(defaultRuntime.exit).toHaveBeenCalledWith(1);
|
||||
expect(defaultRuntime.exit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("preserves whitespace-bearing ids verbatim and keeps them distinct", async () => {
|
||||
|
||||
@@ -943,9 +943,11 @@ describe("exec approvals CLI", () => {
|
||||
const filePath = path.join(dir, "oversized.json");
|
||||
fs.writeFileSync(filePath, Buffer.alloc(1024 * 1024 + 1, "x"));
|
||||
|
||||
await expect(runNativeApprovalsFileCommand(filePath)).rejects.toThrow("__exit__:1");
|
||||
await expect(runNativeApprovalsFileCommand(filePath)).rejects.toThrow(
|
||||
"File exceeds 1048576 bytes",
|
||||
);
|
||||
|
||||
expect(writtenJson().error).toContain("File exceeds 1048576 bytes");
|
||||
expect(defaultRuntime.writeJson).not.toHaveBeenCalled();
|
||||
expect(runtimeErrors).toHaveLength(0);
|
||||
expect(callGatewayFromCli).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
@@ -953,9 +955,9 @@ describe("exec approvals CLI", () => {
|
||||
it("preserves the directory read error", async () => {
|
||||
const dir = tempDirs.make("openclaw-approvals-file-directory-");
|
||||
|
||||
await expect(runNativeApprovalsFileCommand(dir)).rejects.toThrow("__exit__:1");
|
||||
await expect(runNativeApprovalsFileCommand(dir)).rejects.toThrow(/EISDIR|directory/i);
|
||||
|
||||
expect(writtenJson().error).toMatch(/EISDIR|directory/i);
|
||||
expect(defaultRuntime.writeJson).not.toHaveBeenCalled();
|
||||
expect(runtimeErrors).toHaveLength(0);
|
||||
expect(callGatewayFromCli).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
@@ -987,12 +989,14 @@ describe("exec approvals CLI", () => {
|
||||
});
|
||||
|
||||
try {
|
||||
await expect(runNativeApprovalsFileCommand(filePath)).rejects.toThrow("__exit__:1");
|
||||
await expect(runNativeApprovalsFileCommand(filePath)).rejects.toThrow(
|
||||
"File exceeds 1048576 bytes",
|
||||
);
|
||||
} finally {
|
||||
openSpy.mockRestore();
|
||||
}
|
||||
|
||||
expect(writtenJson().error).toContain("File exceeds 1048576 bytes");
|
||||
expect(defaultRuntime.writeJson).not.toHaveBeenCalled();
|
||||
expect(runtimeErrors).toHaveLength(0);
|
||||
expect(callGatewayFromCli).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -383,10 +383,9 @@ function formatCliError(err: unknown): string {
|
||||
function failApprovalsCommand(err: unknown, opts: ExecApprovalsCliOpts): void {
|
||||
const message = formatCliError(err);
|
||||
if (opts.json) {
|
||||
defaultRuntime.writeJson({ error: message }, 0);
|
||||
} else {
|
||||
defaultRuntime.error(message);
|
||||
throw new Error(message);
|
||||
}
|
||||
defaultRuntime.error(message);
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,22 @@
|
||||
// Failure output tests cover CLI error formatting and failure summaries.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { formatCliFailureLines } from "./failure-output.js";
|
||||
import { formatCliFailureLines, formatCliJsonFailure } from "./failure-output.js";
|
||||
|
||||
describe("formatCliJsonFailure", () => {
|
||||
it("uses the canonical typed envelope and redacts the message", () => {
|
||||
const token = "sk-abcdefghijklmnopqrstuv";
|
||||
const payload = formatCliJsonFailure(new Error(`Authorization: Bearer ${token}`));
|
||||
|
||||
expect(payload).toEqual({
|
||||
ok: false,
|
||||
error: {
|
||||
type: "cli_error",
|
||||
message: expect.stringContaining("Authorization: Bearer"),
|
||||
},
|
||||
});
|
||||
expect(payload.error.message).not.toContain(token);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatCliFailureLines", () => {
|
||||
it("shows a concise reason and recovery commands by default", () => {
|
||||
|
||||
@@ -11,6 +11,25 @@ type FormatCliFailureOptions = {
|
||||
includeDoctorHint?: boolean;
|
||||
};
|
||||
|
||||
export type CliJsonFailure = {
|
||||
ok: false;
|
||||
error: {
|
||||
type: "cli_error";
|
||||
message: string;
|
||||
};
|
||||
};
|
||||
|
||||
/** Canonical machine-readable failure envelope for CLI-owned errors. */
|
||||
export function formatCliJsonFailure(error: unknown): CliJsonFailure {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
type: "cli_error",
|
||||
message: formatErrorMessage(error),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function hasDebugArg(argv: string[] | undefined): boolean {
|
||||
for (const arg of argv ?? []) {
|
||||
// Arguments after the terminator belong to the child, not root stack-trace policy.
|
||||
|
||||
@@ -4,6 +4,7 @@ import { loggingState } from "../logging/state.js";
|
||||
import {
|
||||
applyResolvedCommandOutputMode,
|
||||
hasJsonOutputFlag,
|
||||
isJsonOutputModeActive,
|
||||
withConsoleLogsRoutedToStderrForJson,
|
||||
} from "./json-output-mode.js";
|
||||
|
||||
@@ -62,10 +63,19 @@ describe("json output mode", () => {
|
||||
expect(loggingState.forceConsoleToStderr).toBe(true);
|
||||
applyResolvedCommandOutputMode(false);
|
||||
expect(loggingState.forceConsoleToStderr).toBe(false);
|
||||
expect(
|
||||
isJsonOutputModeActive(["node", "openclaw", "config", "set", "x", "1", "--json"]),
|
||||
).toBe(false);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("does not treat config set's parser alias as JSON output before Commander resolves it", () => {
|
||||
expect(isJsonOutputModeActive(["node", "openclaw", "config", "set", "x", "1", "--json"])).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves inherited stderr routing when resolved metadata is parse-only", async () => {
|
||||
loggingState.forceConsoleToStderr = true;
|
||||
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
// Early JSON-output detection and console-log routing for parseable CLI stdout.
|
||||
import { loggingState } from "../logging/state.js";
|
||||
import { resolveCliArgvInvocation } from "./argv-invocation.js";
|
||||
import { isConfigSetJsonParseOnly } from "./config-output-mode.js";
|
||||
|
||||
let resolvedJsonOutputMode: boolean | null = null;
|
||||
|
||||
/** Detects CLI JSON mode before Commander parses options, stopping at the argv sentinel. */
|
||||
export function hasJsonOutputFlag(argv: readonly string[]): boolean {
|
||||
@@ -14,6 +18,14 @@ export function hasJsonOutputFlag(argv: readonly string[]): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Uses Commander-resolved output ownership when available, then falls back to argv. */
|
||||
export function isJsonOutputModeActive(argv: readonly string[]): boolean {
|
||||
const commandPath = resolveCliArgvInvocation([...argv]).commandPath;
|
||||
const parseOnlyJson =
|
||||
commandPath[0] === "config" && commandPath[1] === "set" && isConfigSetJsonParseOnly(argv);
|
||||
return resolvedJsonOutputMode ?? (hasJsonOutputFlag(argv) && !parseOnlyJson);
|
||||
}
|
||||
|
||||
/** Keeps structured JSON stdout clean by routing incidental console logs to stderr. */
|
||||
export async function withConsoleLogsRoutedToStderrForJson<T>(
|
||||
argv: readonly string[],
|
||||
@@ -30,6 +42,8 @@ export async function withConsoleLogsRoutedToStderrForJson<T>(
|
||||
}
|
||||
const previousForceStderr = loggingState.forceConsoleToStderr;
|
||||
const previousEarlyRestore = loggingState.earlyConsoleRoutingRestore;
|
||||
const previousJsonOutputMode = resolvedJsonOutputMode;
|
||||
resolvedJsonOutputMode = null;
|
||||
if (forceStderr) {
|
||||
loggingState.earlyConsoleRoutingRestore = previousForceStderr;
|
||||
loggingState.forceConsoleToStderr = true;
|
||||
@@ -41,12 +55,14 @@ export async function withConsoleLogsRoutedToStderrForJson<T>(
|
||||
// Restore the process-wide logging switch so nested/serial CLI calls keep their own output mode.
|
||||
loggingState.forceConsoleToStderr = previousForceStderr;
|
||||
loggingState.earlyConsoleRoutingRestore = previousEarlyRestore;
|
||||
resolvedJsonOutputMode = previousJsonOutputMode;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Let resolved command metadata override conservative early literal-flag routing. */
|
||||
export function applyResolvedCommandOutputMode(machineOutput: boolean): void {
|
||||
resolvedJsonOutputMode = machineOutput;
|
||||
const restore = loggingState.earlyConsoleRoutingRestore;
|
||||
if (!machineOutput && restore !== null) {
|
||||
loggingState.forceConsoleToStderr = restore;
|
||||
|
||||
@@ -440,14 +440,12 @@ describe("runNodeDaemonStatus", () => {
|
||||
error.name = "ServiceManagerError";
|
||||
mocks.service.isLoaded.mockRejectedValue(error);
|
||||
|
||||
await runNodeDaemonStatus({ json: true });
|
||||
await expect(runNodeDaemonStatus({ json: true })).rejects.toThrow(
|
||||
"Node service check failed: systemd unavailable",
|
||||
);
|
||||
|
||||
expect(mocks.runtime.writeJson).toHaveBeenCalledWith({
|
||||
error: expect.stringContaining("Node service check failed: systemd unavailable"),
|
||||
});
|
||||
expect(JSON.stringify(mocks.runtime.writeJson.mock.calls)).not.toContain(error.name);
|
||||
expect(JSON.stringify(mocks.runtime.writeJson.mock.calls)).not.toContain(secret);
|
||||
expect(mocks.runtime.exit).toHaveBeenCalledWith(1);
|
||||
expect(mocks.runtime.writeJson).not.toHaveBeenCalled();
|
||||
expect(mocks.runtime.exit).not.toHaveBeenCalled();
|
||||
expect(mocks.runtime.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -262,10 +262,9 @@ export async function runNodeDaemonStatus(opts: NodeDaemonStatusOptions = {}) {
|
||||
} catch (error) {
|
||||
const message = `Node service check failed: ${formatErrorMessage(error)}`;
|
||||
if (json) {
|
||||
defaultRuntime.writeJson({ error: message });
|
||||
} else {
|
||||
defaultRuntime.error(message);
|
||||
throw new Error(message, { cause: error });
|
||||
}
|
||||
defaultRuntime.error(message);
|
||||
defaultRuntime.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import { shortenHomeInString, shortenHomePath } from "../utils.js";
|
||||
import { formatMissingPluginMessage } from "./error-format.js";
|
||||
import { formatCliJsonFailure } from "./failure-output.js";
|
||||
import { quietPluginJsonLogger } from "./plugins-json-logger.js";
|
||||
import { formatPluginBundleFormat } from "./plugins-list-format.js";
|
||||
|
||||
@@ -20,6 +21,15 @@ export type PluginInspectOptions = {
|
||||
runtime?: boolean;
|
||||
};
|
||||
|
||||
function failPluginInspect(message: string, json: boolean | undefined): void {
|
||||
if (json) {
|
||||
defaultRuntime.writeJson(formatCliJsonFailure(message));
|
||||
} else {
|
||||
defaultRuntime.error(message);
|
||||
}
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
|
||||
function formatInspectSection(title: string, lines: string[]): string[] {
|
||||
if (lines.length === 0) {
|
||||
return [];
|
||||
@@ -131,8 +141,8 @@ export async function runPluginsInspectCommand(
|
||||
const runtimeInspect = opts.runtime === true;
|
||||
if (opts.all) {
|
||||
if (id) {
|
||||
defaultRuntime.error("Pass either a plugin id or --all, not both.");
|
||||
return defaultRuntime.exit(1);
|
||||
failPluginInspect("Pass either a plugin id or --all, not both.", opts.json);
|
||||
return;
|
||||
}
|
||||
const report = runtimeInspect
|
||||
? tracePluginLifecyclePhase(
|
||||
@@ -212,8 +222,8 @@ export async function runPluginsInspectCommand(
|
||||
}
|
||||
|
||||
if (!id) {
|
||||
defaultRuntime.error("Provide a plugin id or use --all.");
|
||||
return defaultRuntime.exit(1);
|
||||
failPluginInspect("Provide a plugin id or use --all.", opts.json);
|
||||
return;
|
||||
}
|
||||
|
||||
const snapshotReport = tracePluginLifecyclePhase(
|
||||
@@ -242,11 +252,11 @@ export async function runPluginsInspectCommand(
|
||||
if (diagnostic) {
|
||||
lines.push(diagnostic.message);
|
||||
}
|
||||
defaultRuntime.error(lines.join("\n"));
|
||||
return defaultRuntime.exit(1);
|
||||
failPluginInspect(lines.join("\n"), opts.json);
|
||||
return;
|
||||
}
|
||||
defaultRuntime.error(formatMissingPluginMessage({ id, includeSearch: true }));
|
||||
return defaultRuntime.exit(1);
|
||||
failPluginInspect(formatMissingPluginMessage({ id, includeSearch: true }), opts.json);
|
||||
return;
|
||||
}
|
||||
const report = runtimeInspect
|
||||
? tracePluginLifecyclePhase(
|
||||
@@ -267,10 +277,11 @@ export async function runPluginsInspectCommand(
|
||||
report,
|
||||
});
|
||||
if (!inspect) {
|
||||
defaultRuntime.error(
|
||||
failPluginInspect(
|
||||
formatMissingPluginMessage({ id, listCommand: "openclaw plugins list --json" }),
|
||||
opts.json,
|
||||
);
|
||||
return defaultRuntime.exit(1);
|
||||
return;
|
||||
}
|
||||
const install = installRecords[inspect.plugin.id];
|
||||
|
||||
|
||||
@@ -58,6 +58,10 @@ function commandCall(mock: ReturnType<typeof vi.fn>): [typeof runtime, Record<st
|
||||
return call;
|
||||
}
|
||||
|
||||
function jsonFailure(message: string) {
|
||||
return { ok: false, error: { type: "cli_error", message } };
|
||||
}
|
||||
|
||||
describe("registerMaintenanceCommands doctor action", () => {
|
||||
async function runMaintenanceCli(args: string[]) {
|
||||
const program = new Command();
|
||||
@@ -121,7 +125,11 @@ describe("registerMaintenanceCommands doctor action", () => {
|
||||
await runMaintenanceCli(["doctor", "--state-sqlite", "compact", "--json"]);
|
||||
|
||||
expect(runtime.writeJson).toHaveBeenCalledWith({
|
||||
error: expect.stringContaining("maintenance failed: Authorization: Bearer"),
|
||||
ok: false,
|
||||
error: {
|
||||
type: "cli_error",
|
||||
message: expect.stringContaining("maintenance failed: Authorization: Bearer"),
|
||||
},
|
||||
});
|
||||
expect(JSON.stringify(runtime.writeJson.mock.calls)).not.toContain(token);
|
||||
expect(runtime.error).not.toHaveBeenCalled();
|
||||
@@ -225,7 +233,7 @@ describe("registerMaintenanceCommands doctor action", () => {
|
||||
"--json",
|
||||
]);
|
||||
|
||||
expect(runtime.writeJson).toHaveBeenCalledWith({ error: message });
|
||||
expect(runtime.writeJson).toHaveBeenCalledWith(jsonFailure(message));
|
||||
expect(runtime.error).not.toHaveBeenCalled();
|
||||
expect(runtime.exit).toHaveBeenCalledWith(2);
|
||||
});
|
||||
@@ -279,7 +287,7 @@ describe("registerMaintenanceCommands doctor action", () => {
|
||||
expect(doctorCommand).not.toHaveBeenCalled();
|
||||
expect(runDoctorLintCli).not.toHaveBeenCalled();
|
||||
if (json) {
|
||||
expect(runtime.writeJson).toHaveBeenCalledWith({ error: message });
|
||||
expect(runtime.writeJson).toHaveBeenCalledWith(jsonFailure(message));
|
||||
expect(runtime.error).not.toHaveBeenCalled();
|
||||
} else {
|
||||
expect(runtime.error).toHaveBeenCalledWith(message);
|
||||
@@ -372,7 +380,7 @@ describe("registerMaintenanceCommands doctor action", () => {
|
||||
|
||||
expect(doctorCommand).not.toHaveBeenCalled();
|
||||
expect(runDoctorLintCli).not.toHaveBeenCalled();
|
||||
expect(runtime.writeJson).toHaveBeenCalledWith({ error: message });
|
||||
expect(runtime.writeJson).toHaveBeenCalledWith(jsonFailure(message));
|
||||
expect(runtime.error).not.toHaveBeenCalled();
|
||||
expect(runtime.exit).toHaveBeenCalledWith(2);
|
||||
});
|
||||
@@ -394,7 +402,7 @@ describe("registerMaintenanceCommands doctor action", () => {
|
||||
|
||||
expect(doctorCommand).not.toHaveBeenCalled();
|
||||
expect(runDoctorLintCli).not.toHaveBeenCalled();
|
||||
expect(runtime.writeJson).toHaveBeenCalledWith({ error: message });
|
||||
expect(runtime.writeJson).toHaveBeenCalledWith(jsonFailure(message));
|
||||
expect(runtime.error).not.toHaveBeenCalled();
|
||||
expect(runtime.exit).toHaveBeenCalledWith(2);
|
||||
});
|
||||
@@ -415,7 +423,7 @@ describe("registerMaintenanceCommands doctor action", () => {
|
||||
|
||||
await runMaintenanceCli(["doctor", "--json"]);
|
||||
|
||||
expect(runtime.writeJson).toHaveBeenCalledWith({ error: "lint failed" });
|
||||
expect(runtime.writeJson).toHaveBeenCalledWith(jsonFailure("lint failed"));
|
||||
expect(runtime.error).not.toHaveBeenCalled();
|
||||
expect(runtime.exit).toHaveBeenCalledWith(2);
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import { defaultRuntime } from "../../runtime.js";
|
||||
import { formatErrorMessage as formatError, runCommandWithRuntime } from "../cli-utils.js";
|
||||
import { hasExplicitOptions } from "../command-options.js";
|
||||
import { isDoctorMachineOutput } from "../doctor-output-mode.js";
|
||||
import { formatCliJsonFailure } from "../failure-output.js";
|
||||
import { setCommandJsonMode } from "./json-mode.js";
|
||||
|
||||
const STATE_SQLITE_CONFLICTING_OPTION_NAMES = [
|
||||
@@ -33,7 +34,7 @@ const STATE_SQLITE_CONFLICTING_OPTION_NAMES = [
|
||||
|
||||
function exitDoctorError(message: string, json: boolean): void {
|
||||
if (json) {
|
||||
defaultRuntime.writeJson({ error: message });
|
||||
defaultRuntime.writeJson(formatCliJsonFailure(message));
|
||||
} else {
|
||||
defaultRuntime.error(message);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { cliCommandCatalog } from "../command-catalog.js";
|
||||
import { isReservedNonPluginCommandRoot } from "../command-registration-policy.js";
|
||||
import { collectShellCompletionCommandTree } from "../completion-command-tree.js";
|
||||
import { formatCliJsonFailure } from "../failure-output.js";
|
||||
import { runCliWithExitFinalization } from "../one-shot-exit.js";
|
||||
import { getCoreCliCommandNames, registerCoreCliByName } from "./command-registry-core.js";
|
||||
import { createProgramContext } from "./context.js";
|
||||
import { getCoreCliCommandDescriptors } from "./core-command-descriptors.js";
|
||||
@@ -248,6 +250,29 @@ function supportsJsonOutput(path: string, command: Command): boolean {
|
||||
return hasOwnJsonOption(command) || JSON_OUTPUT_ROUTE_FIRST.has(path);
|
||||
}
|
||||
|
||||
function requiredCommandArgs(command: Command): string[] {
|
||||
const args = command.registeredArguments.flatMap((argument) => {
|
||||
if (!argument.required) {
|
||||
return [];
|
||||
}
|
||||
return argument.variadic ? ["guard-value"] : ["guard-value"];
|
||||
});
|
||||
for (const option of command.options) {
|
||||
if (!option.mandatory) {
|
||||
continue;
|
||||
}
|
||||
const flag = option.long ?? option.short;
|
||||
if (!flag) {
|
||||
continue;
|
||||
}
|
||||
args.push(flag);
|
||||
if (option.required || option.optional) {
|
||||
args.push(option.argChoices?.[0] ?? "guard-value");
|
||||
}
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
function collectRegisteredCommandPaths(...programs: Command[]): Set<string> {
|
||||
return new Set(
|
||||
programs.flatMap((program) =>
|
||||
@@ -403,4 +428,39 @@ describe("root command descriptions", () => {
|
||||
"route-first JSON entries must exist and remain absent from Commander options",
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("routes every registered JSON command failure through the canonical envelope", async () => {
|
||||
const program = await registerAllBuiltInCommands();
|
||||
const contexts = collectShellCompletionCommandTree(program).descendants.filter((context) => {
|
||||
const path = context.pathVariants[0]?.join(" ") ?? "";
|
||||
return supportsJsonOutput(path, context.command);
|
||||
});
|
||||
const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() };
|
||||
|
||||
expect(contexts.length).toBeGreaterThan(0);
|
||||
for (const context of contexts) {
|
||||
const path = context.pathVariants[0]?.join(" ") ?? "";
|
||||
const failure = new Error(`synthetic failure for ${path}`);
|
||||
const payloads: unknown[] = [];
|
||||
context.command.action(async () => {
|
||||
throw failure;
|
||||
});
|
||||
const args = requiredCommandArgs(context.command);
|
||||
if (hasOwnJsonOption(context.command)) {
|
||||
args.push("--json");
|
||||
}
|
||||
|
||||
await runCliWithExitFinalization({
|
||||
runtime,
|
||||
run: async () => {
|
||||
await context.command.parseAsync(args, { from: "user" });
|
||||
},
|
||||
onError: (error) => {
|
||||
payloads.push(formatCliJsonFailure(error));
|
||||
},
|
||||
});
|
||||
|
||||
expect(payloads, path).toEqual([formatCliJsonFailure(failure)]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
+9
-2
@@ -44,6 +44,7 @@ import {
|
||||
} from "./gateway-run-argv.js";
|
||||
import {
|
||||
hasJsonOutputFlag,
|
||||
isJsonOutputModeActive,
|
||||
withConsoleLogsRoutedToStderr,
|
||||
withConsoleLogsRoutedToStderrForJson,
|
||||
} from "./json-output-mode.js";
|
||||
@@ -1520,14 +1521,14 @@ async function runCliWithPreparedOutputMode(
|
||||
const [
|
||||
{ buildProgram },
|
||||
{ formatUncaughtError },
|
||||
{ formatCliFailureLines },
|
||||
{ formatCliFailureLines, formatCliJsonFailure },
|
||||
{ runFatalErrorHooks },
|
||||
{
|
||||
installUnhandledRejectionHandler,
|
||||
isBenignUncaughtExceptionError,
|
||||
isUncaughtExceptionHandled,
|
||||
},
|
||||
{ restoreRuntimeTerminalState },
|
||||
{ defaultRuntime, restoreRuntimeTerminalState },
|
||||
] = await startupTrace.measure("core-imports", () =>
|
||||
Promise.all([
|
||||
import("./program.js"),
|
||||
@@ -1555,6 +1556,9 @@ async function runCliWithPreparedOutputMode(
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (isJsonOutputModeActive(normalizedArgv)) {
|
||||
defaultRuntime.writeJson(formatCliJsonFailure(error));
|
||||
}
|
||||
for (const line of formatCliFailureLines({
|
||||
title: "OpenClaw hit an unexpected runtime error.",
|
||||
error,
|
||||
@@ -1656,6 +1660,9 @@ async function runCliWithPreparedOutputMode(
|
||||
if (!isCommanderParseExit(error)) {
|
||||
throw error;
|
||||
}
|
||||
if (isJsonOutputModeActive(parseArgv) && error.exitCode !== 0) {
|
||||
throw error;
|
||||
}
|
||||
process.exitCode = error.exitCode;
|
||||
completedHelpOrVersion = isHelpOrVersionInvocation && error.exitCode === 0;
|
||||
}
|
||||
|
||||
@@ -1343,7 +1343,11 @@ describe("skills cli commands", () => {
|
||||
await runCommand(["skills", "verify", "agentreceipt", "--global", "--agent", "main"]);
|
||||
|
||||
expect(JSON.parse(runtimeStdout.at(-1) ?? "{}")).toEqual({
|
||||
error: "Use either --global or --agent, not both.",
|
||||
ok: false,
|
||||
error: {
|
||||
type: "cli_error",
|
||||
message: "Use either --global or --agent, not both.",
|
||||
},
|
||||
});
|
||||
expect(runtimeErrors).toStrictEqual([]);
|
||||
expect(defaultRuntime.exit).toHaveBeenCalledWith(1);
|
||||
@@ -1426,7 +1430,15 @@ describe("skills cli commands", () => {
|
||||
{
|
||||
label: "JSON",
|
||||
argv: ["skills", "info", "missing-skill", "--json"],
|
||||
expected: JSON.stringify({ error: "not found", skill: "missing-skill" }, null, 2),
|
||||
expected: JSON.stringify(
|
||||
{
|
||||
ok: false,
|
||||
error: { type: "cli_error", message: 'Skill "missing-skill" not found.' },
|
||||
skill: "missing-skill",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
},
|
||||
])("exits nonzero for missing skill info in $label mode", async ({ argv, expected }) => {
|
||||
vi.stubEnv("OPENCLAW_PROFILE", "");
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from "../skills/discovery/status.js";
|
||||
import { shortenHomePath } from "../utils.js";
|
||||
import { formatCliCommand } from "./command-format.js";
|
||||
import { formatCliJsonFailure } from "./failure-output.js";
|
||||
|
||||
/** Options for rendering the skill list command. */
|
||||
export type SkillsListOptions = {
|
||||
@@ -204,7 +205,10 @@ export function formatSkillInfo(
|
||||
if (!skill) {
|
||||
if (opts.json) {
|
||||
return JSON.stringify(
|
||||
sanitizeJsonValue({ error: "not found", skill: requestedName }),
|
||||
sanitizeJsonValue({
|
||||
...formatCliJsonFailure(`Skill "${requestedName}" not found.`),
|
||||
skill: requestedName,
|
||||
}),
|
||||
null,
|
||||
2,
|
||||
);
|
||||
|
||||
@@ -584,9 +584,17 @@ describe("skills-cli", () => {
|
||||
it("sanitizes user-supplied skill name in not-found JSON output", () => {
|
||||
const report = createMockReport([]);
|
||||
const output = formatSkillInfo(report, "evil\u001b[31m\u009f", { json: true });
|
||||
const parsed = JSON.parse(output) as { error: string; skill: string };
|
||||
const parsed = JSON.parse(output) as {
|
||||
ok: boolean;
|
||||
error: { type: string; message: string };
|
||||
skill: string;
|
||||
};
|
||||
|
||||
expect(parsed.error).toBe("not found");
|
||||
expect(parsed.ok).toBe(false);
|
||||
expect(parsed.error).toEqual({
|
||||
type: "cli_error",
|
||||
message: 'Skill "evil" not found.',
|
||||
});
|
||||
expect(parsed.skill).toBe("evil");
|
||||
expect(output).not.toContain("\u001b");
|
||||
});
|
||||
|
||||
@@ -69,6 +69,7 @@ import { CONFIG_DIR } from "../utils.js";
|
||||
import { resolveClawHubRiskAcknowledgementCliOptions } from "./clawhub-risk-acknowledgement.js";
|
||||
import { resolveOptionFromCommand } from "./cli-utils.js";
|
||||
import { inheritOptionFromParent } from "./command-options.js";
|
||||
import { formatCliJsonFailure } from "./failure-output.js";
|
||||
import { resolveInstallPolicyWarningAcknowledgementCliOptions } from "./install-policy-warning-acknowledgement.js";
|
||||
import { parseStrictPositiveIntOption } from "./program/helpers.js";
|
||||
import { setCommandJsonMode } from "./program/json-mode.js";
|
||||
@@ -875,7 +876,7 @@ export function registerSkillsCli(program: Command) {
|
||||
let exitCode: number | undefined;
|
||||
const reportError =
|
||||
hasJsonOutput(opts) || opts.card !== true
|
||||
? (message: string) => defaultRuntime.writeJson({ error: message })
|
||||
? (message: string) => defaultRuntime.writeJson(formatCliJsonFailure(message))
|
||||
: defaultRuntime.error;
|
||||
try {
|
||||
const workspace = resolveClawHubTargetWorkspace(command, opts, reportError);
|
||||
|
||||
@@ -266,7 +266,11 @@ describe("skills verify CLI", () => {
|
||||
).rejects.toThrow("__exit__:1");
|
||||
|
||||
expect(JSON.parse(mocks.runtimeStdout.at(-1) ?? "{}")).toEqual({
|
||||
error: 'Skill "html" is not tracked from skills-sh:owner-b/repo-b/html.',
|
||||
ok: false,
|
||||
error: {
|
||||
type: "cli_error",
|
||||
message: 'Skill "html" is not tracked from skills-sh:owner-b/repo-b/html.',
|
||||
},
|
||||
});
|
||||
expect(mocks.runtimeErrors).toStrictEqual([]);
|
||||
expect(mocks.fetchClawHubSkillVerificationMock).not.toHaveBeenCalled();
|
||||
@@ -285,7 +289,11 @@ describe("skills verify CLI", () => {
|
||||
).rejects.toThrow("__exit__:1");
|
||||
|
||||
expect(JSON.parse(mocks.runtimeStdout.at(-1) ?? "{}")).toEqual({
|
||||
error: "ClawHub verification unavailable",
|
||||
ok: false,
|
||||
error: {
|
||||
type: "cli_error",
|
||||
message: "ClawHub verification unavailable",
|
||||
},
|
||||
});
|
||||
expect(mocks.runtimeErrors).toStrictEqual([]);
|
||||
});
|
||||
|
||||
@@ -31,6 +31,10 @@ function gatewayCall(callIndex = 0): ReadonlyArray<unknown> {
|
||||
return call;
|
||||
}
|
||||
|
||||
function jsonFailure(message: string) {
|
||||
return { ok: false, error: { type: "cli_error", message } };
|
||||
}
|
||||
|
||||
describe("system-cli", () => {
|
||||
async function runCli(args: string[]) {
|
||||
const program = new Command();
|
||||
@@ -114,9 +118,9 @@ describe("system-cli", () => {
|
||||
|
||||
await runCli(args);
|
||||
|
||||
expect(runtimeLogs).toEqual([JSON.stringify({ error: expectedError }, null, 2)]);
|
||||
expect(runtimeLogs).toEqual([JSON.stringify(jsonFailure(expectedError), null, 2)]);
|
||||
expect(runtimeErrors).toEqual([]);
|
||||
expect(defaultRuntime.writeJson).toHaveBeenCalledWith({ error: expectedError });
|
||||
expect(defaultRuntime.writeJson).toHaveBeenCalledWith(jsonFailure(expectedError));
|
||||
expect(defaultRuntime.exit).toHaveBeenCalledWith(1);
|
||||
expect(callGatewayFromCli).toHaveBeenCalledTimes(gatewayCalls);
|
||||
},
|
||||
@@ -134,7 +138,7 @@ describe("system-cli", () => {
|
||||
|
||||
if (mode === "JSON") {
|
||||
const payload = JSON.parse(runtimeLogs.at(-1) ?? "");
|
||||
expect(payload).toEqual({ error: error.message });
|
||||
expect(payload).toEqual(jsonFailure(error.message));
|
||||
expect(runtimeErrors).toEqual([]);
|
||||
} else {
|
||||
expect(runtimeErrors).toEqual([error.message]);
|
||||
@@ -194,9 +198,9 @@ describe("system-cli", () => {
|
||||
expect(params).toBeUndefined();
|
||||
expect(requestOptions).toEqual({ expectFinal: false });
|
||||
const expectedError = "Gateway unavailable";
|
||||
expect(runtimeLogs).toEqual([JSON.stringify({ error: expectedError }, null, 2)]);
|
||||
expect(runtimeLogs).toEqual([JSON.stringify(jsonFailure(expectedError), null, 2)]);
|
||||
expect(runtimeErrors).toEqual([]);
|
||||
expect(defaultRuntime.writeJson).toHaveBeenCalledWith({ error: expectedError });
|
||||
expect(defaultRuntime.writeJson).toHaveBeenCalledWith(jsonFailure(expectedError));
|
||||
expect(defaultRuntime.exit).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { danger } from "../globals.js";
|
||||
import { formatErrorMessage } from "../infra/errors.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import { formatCliCommand } from "./command-format.js";
|
||||
import { formatCliJsonFailure } from "./failure-output.js";
|
||||
import type { GatewayRpcOpts } from "./gateway-rpc.js";
|
||||
import { addGatewayClientOptions, callGatewayFromCli } from "./gateway-rpc.js";
|
||||
import { setCommandJsonMode } from "./program/json-mode.js";
|
||||
@@ -47,7 +48,7 @@ async function runSystemGatewayCommand(
|
||||
} catch (err) {
|
||||
const message = formatErrorMessage(err);
|
||||
if (machineOutput) {
|
||||
defaultRuntime.writeJson({ error: message });
|
||||
defaultRuntime.writeJson(formatCliJsonFailure(message));
|
||||
} else {
|
||||
defaultRuntime.error(danger(message));
|
||||
}
|
||||
|
||||
+17
-11
@@ -6,6 +6,7 @@ import { formatErrorMessage } from "../infra/errors.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import { inheritOptionFromParent } from "./command-options.js";
|
||||
import { formatHelpExamples } from "./help-format.js";
|
||||
import { isJsonOutputModeActive } from "./json-output-mode.js";
|
||||
import type {
|
||||
UpdateCommandOptions,
|
||||
UpdateFinalizeOptions,
|
||||
@@ -28,6 +29,14 @@ function inheritedUpdateJson(command?: Command): boolean {
|
||||
return Boolean(inheritOptionFromParent<boolean>(command, "json"));
|
||||
}
|
||||
|
||||
function handleUpdateCommandError(error: unknown): void {
|
||||
if (isJsonOutputModeActive(process.argv)) {
|
||||
throw error;
|
||||
}
|
||||
defaultRuntime.error(formatErrorMessage(error));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
|
||||
function inheritedUpdateTimeout(
|
||||
opts: { timeout?: unknown },
|
||||
command?: Command,
|
||||
@@ -67,10 +76,11 @@ function rejectUnsupportedInheritedUpdateDryRun(command: Command): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
defaultRuntime.error(
|
||||
`--dry-run is not supported for \`openclaw update ${command.name()}\`. Run \`openclaw update --dry-run\` instead.`,
|
||||
handleUpdateCommandError(
|
||||
new Error(
|
||||
`--dry-run is not supported for \`openclaw update ${command.name()}\`. Run \`openclaw update --dry-run\` instead.`,
|
||||
),
|
||||
);
|
||||
defaultRuntime.exit(1);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -119,8 +129,7 @@ function registerUpdateFinalizationCommand(update: Command, name: string, hidden
|
||||
normalizeCommanderClawHubRiskOption(opts) || inheritedUpdateClawHubRisk(actionCommand),
|
||||
});
|
||||
} catch (err) {
|
||||
defaultRuntime.error(formatErrorMessage(err));
|
||||
defaultRuntime.exit(1);
|
||||
handleUpdateCommandError(err);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -210,8 +219,7 @@ ${theme.muted("Docs:")} ${formatDocsLink("/cli/update", "docs.openclaw.ai/cli/up
|
||||
acknowledgeClawHubRisk: normalizeCommanderClawHubRiskOption(opts),
|
||||
});
|
||||
} catch (err) {
|
||||
defaultRuntime.error(formatErrorMessage(err));
|
||||
defaultRuntime.exit(1);
|
||||
handleUpdateCommandError(err);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -236,8 +244,7 @@ ${theme.muted("Docs:")} ${formatDocsLink("/cli/update", "docs.openclaw.ai/cli/up
|
||||
timeout: inheritedUpdateTimeout(opts, command),
|
||||
});
|
||||
} catch (err) {
|
||||
defaultRuntime.error(formatErrorMessage(err));
|
||||
defaultRuntime.exit(1);
|
||||
handleUpdateCommandError(err);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -266,8 +273,7 @@ ${theme.muted("Docs:")} ${formatDocsLink("/cli/update", "docs.openclaw.ai/cli/up
|
||||
timeout: inheritedUpdateTimeout(opts, command),
|
||||
});
|
||||
} catch (err) {
|
||||
defaultRuntime.error(formatErrorMessage(err));
|
||||
defaultRuntime.exit(1);
|
||||
handleUpdateCommandError(err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ import { runCommandWithTimeout } from "../../process/exec.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import { pathExists } from "../../utils.js";
|
||||
import { COMPLETION_SKIP_PLUGIN_COMMANDS_ENV } from "../completion-runtime.js";
|
||||
import { isJsonOutputModeActive } from "../json-output-mode.js";
|
||||
|
||||
export type UpdateCommandOptions = {
|
||||
json?: boolean;
|
||||
@@ -67,6 +68,9 @@ export function parseTimeoutMsOrExit(timeout?: string): number | undefined | nul
|
||||
const trimmed = timeout.trim();
|
||||
const seconds = parseStrictPositiveInteger(trimmed);
|
||||
if (seconds === undefined || seconds > MAX_SAFE_TIMEOUT_SECONDS) {
|
||||
if (isJsonOutputModeActive(process.argv)) {
|
||||
throw new Error(INVALID_TIMEOUT_ERROR);
|
||||
}
|
||||
defaultRuntime.error(INVALID_TIMEOUT_ERROR);
|
||||
defaultRuntime.exit(1);
|
||||
return null;
|
||||
|
||||
@@ -60,14 +60,15 @@ describe("webhooks cli", () => {
|
||||
args.push("--json");
|
||||
}
|
||||
|
||||
await expect(program.parseAsync(args, { from: "user" })).rejects.toThrow("__exit__:1");
|
||||
|
||||
if (json) {
|
||||
expect(mocks.defaultRuntime.writeJson).toHaveBeenCalledWith({
|
||||
error: `${flag} must be a positive integer.`,
|
||||
});
|
||||
await expect(program.parseAsync(args, { from: "user" })).rejects.toThrow(
|
||||
`${flag} must be a positive integer.`,
|
||||
);
|
||||
expect(mocks.defaultRuntime.writeJson).not.toHaveBeenCalled();
|
||||
expect(mocks.defaultRuntime.error).not.toHaveBeenCalled();
|
||||
expect(mocks.defaultRuntime.exit).not.toHaveBeenCalled();
|
||||
} else {
|
||||
await expect(program.parseAsync(args, { from: "user" })).rejects.toThrow("__exit__:1");
|
||||
expect(runtimeErrors().join("\n")).toContain(`${flag} must be a positive integer.`);
|
||||
}
|
||||
expect(mocks.runGmailSetup).not.toHaveBeenCalled();
|
||||
@@ -93,24 +94,24 @@ describe("webhooks cli", () => {
|
||||
args.push("--json");
|
||||
}
|
||||
|
||||
await expect(program.parseAsync(args, { from: "user" })).rejects.toThrow("__exit__:1");
|
||||
|
||||
expect(runner).toHaveBeenCalledOnce();
|
||||
if (json) {
|
||||
const payload = JSON.parse(mocks.runtimeLogs.at(-1) ?? "");
|
||||
expect(payload).toEqual({
|
||||
error: expect.stringContaining("Gmail failed: Authorization: Bearer"),
|
||||
});
|
||||
await expect(program.parseAsync(args, { from: "user" })).rejects.toThrow(
|
||||
"Gmail failed: Authorization: Bearer",
|
||||
);
|
||||
expect(mocks.defaultRuntime.writeJson).not.toHaveBeenCalled();
|
||||
expect(mocks.defaultRuntime.error).not.toHaveBeenCalled();
|
||||
expect(mocks.defaultRuntime.exit).not.toHaveBeenCalled();
|
||||
} else {
|
||||
await expect(program.parseAsync(args, { from: "user" })).rejects.toThrow("__exit__:1");
|
||||
expect(runtimeErrors()).toEqual([
|
||||
expect.stringContaining("Gmail failed: Authorization: Bearer"),
|
||||
]);
|
||||
expect(mocks.defaultRuntime.writeJson).not.toHaveBeenCalled();
|
||||
expect(mocks.defaultRuntime.exit).toHaveBeenCalledWith(1);
|
||||
}
|
||||
expect(runner).toHaveBeenCalledOnce();
|
||||
expect([...mocks.runtimeLogs, ...runtimeErrors()].join("\n")).not.toContain(error.name);
|
||||
expect([...mocks.runtimeLogs, ...runtimeErrors()].join("\n")).not.toContain(secret);
|
||||
expect(mocks.defaultRuntime.exit).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it.each([
|
||||
|
||||
@@ -72,12 +72,10 @@ export function registerWebhooksCli(program: Command) {
|
||||
const parsed = parseGmailSetupOptions(opts);
|
||||
await runGmailSetup(parsed);
|
||||
} catch (err) {
|
||||
const message = formatErrorMessage(err);
|
||||
if (opts.json) {
|
||||
defaultRuntime.writeJson({ error: message });
|
||||
} else {
|
||||
defaultRuntime.error(danger(message));
|
||||
throw new Error(formatErrorMessage(err), { cause: err });
|
||||
}
|
||||
defaultRuntime.error(danger(formatErrorMessage(err)));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
prepareWorkspaceStateDeletion,
|
||||
} from "../agents/workspace-state-store.js";
|
||||
import { formatCliCommand } from "../cli/command-format.js";
|
||||
import { formatCliJsonFailure } from "../cli/failure-output.js";
|
||||
import { replaceConfigFile } from "../config/config.js";
|
||||
import { logConfigUpdated } from "../config/logging.js";
|
||||
import {
|
||||
@@ -66,7 +67,7 @@ type AgentsDeleteGatewayResult = {
|
||||
|
||||
function failAgentsDelete(opts: AgentsDeleteOptions, runtime: RuntimeEnv, message: string): void {
|
||||
if (opts.json) {
|
||||
writeRuntimeJson(runtime, { error: message });
|
||||
writeRuntimeJson(runtime, formatCliJsonFailure(message));
|
||||
runtime.exit(1, { resetStream: process.stderr });
|
||||
} else {
|
||||
runtime.error(message);
|
||||
@@ -202,8 +203,7 @@ export async function agentsDeleteCommand(
|
||||
|
||||
if (!opts.force) {
|
||||
if (!process.stdin.isTTY) {
|
||||
runtime.error("Non-interactive session. Re-run with --force.");
|
||||
runtime.exit(1);
|
||||
failAgentsDelete(opts, runtime, "Non-interactive session. Re-run with --force.");
|
||||
return;
|
||||
}
|
||||
const prompter = createClackPrompter();
|
||||
|
||||
@@ -240,8 +240,12 @@ describe("agents delete command", () => {
|
||||
expect(runtime.error).not.toHaveBeenCalled();
|
||||
expect(readJsonLogs()).toEqual([
|
||||
{
|
||||
error:
|
||||
'Agent "main" owns the legacy shared auth store and cannot be deleted. Run openclaw doctor --fix to migrate shared auth, then retry.',
|
||||
ok: false,
|
||||
error: {
|
||||
type: "cli_error",
|
||||
message:
|
||||
'Agent "main" owns the legacy shared auth store and cannot be deleted. Run openclaw doctor --fix to migrate shared auth, then retry.',
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(runtime.exit).toHaveBeenCalledWith(1, { resetStream: process.stderr });
|
||||
@@ -645,7 +649,13 @@ describe("agents delete command", () => {
|
||||
|
||||
expect(runtime.error).not.toHaveBeenCalled();
|
||||
expect(readJsonLogs()).toEqual([
|
||||
{ error: 'Agent "ops" is the only configured agent and cannot be deleted.' },
|
||||
{
|
||||
ok: false,
|
||||
error: {
|
||||
type: "cli_error",
|
||||
message: 'Agent "ops" is the only configured agent and cannot be deleted.',
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(runtime.exit).toHaveBeenCalledWith(1, { resetStream: process.stderr });
|
||||
expectSessionStore(cfg, {
|
||||
|
||||
@@ -433,6 +433,7 @@ describe("channelsStatusCommand SecretRef fallback flow", () => {
|
||||
expect(announceRequest?.config?.secretResolved).toBe(true);
|
||||
expect(announceRequest?.activationSourceConfig?.secretResolved).toBe(false);
|
||||
const payload = JSON.parse(logs.at(-1) ?? "{}");
|
||||
expect(errors).toEqual([]);
|
||||
expect(errors.join("\n")).not.toContain("user:pass");
|
||||
expect(errors.join("\n")).not.toContain("secret-token");
|
||||
expect(errors.join("\n")).not.toContain("fallback-user:fallback-pass");
|
||||
|
||||
@@ -228,9 +228,11 @@ export async function renderChannelsStatusFallback(params: {
|
||||
const fallbackReason = gatewayAuthUnavailable
|
||||
? "Gateway auth unavailable; showing config-only status."
|
||||
: "Gateway not reachable; showing config-only status.";
|
||||
runtime.error(
|
||||
`${gatewayAuthUnavailable ? "Gateway auth unavailable" : "Gateway not reachable"}: ${safeError}`,
|
||||
);
|
||||
if (!opts.json) {
|
||||
runtime.error(
|
||||
`${gatewayAuthUnavailable ? "Gateway auth unavailable" : "Gateway not reachable"}: ${safeError}`,
|
||||
);
|
||||
}
|
||||
const cfg = await requireValidConfig(runtime, { observe: false });
|
||||
if (!cfg) {
|
||||
return;
|
||||
|
||||
@@ -153,6 +153,10 @@ describe("sessions lifecycle commands", () => {
|
||||
expect(runtime.writeJson).toHaveBeenCalledWith(
|
||||
{
|
||||
ok: false,
|
||||
error: {
|
||||
type: "cli_error",
|
||||
message: `Session ${_operation} did not complete for every requested key.`,
|
||||
},
|
||||
operation: _operation,
|
||||
dryRun: false,
|
||||
results: [
|
||||
@@ -315,6 +319,10 @@ describe("sessions lifecycle commands", () => {
|
||||
expect(runtime.writeJson).toHaveBeenCalledWith(
|
||||
{
|
||||
ok: false,
|
||||
error: {
|
||||
type: "cli_error",
|
||||
message: "Session delete did not complete for every requested key.",
|
||||
},
|
||||
operation: "delete",
|
||||
dryRun: false,
|
||||
results: [
|
||||
@@ -354,6 +362,10 @@ describe("sessions lifecycle commands", () => {
|
||||
expect(runtime.writeJson).toHaveBeenCalledWith(
|
||||
{
|
||||
ok: false,
|
||||
error: {
|
||||
type: "cli_error",
|
||||
message: "Session delete did not complete for every requested key.",
|
||||
},
|
||||
operation: "delete",
|
||||
dryRun: false,
|
||||
results: [
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/** Gateway-backed archive and delete commands for stored sessions. */
|
||||
import { formatCliCommand } from "../cli/command-format.js";
|
||||
import { formatCliJsonFailure } from "../cli/failure-output.js";
|
||||
import { callGatewayFromCliWithTransport } from "../cli/gateway-rpc.js";
|
||||
import { formatErrorMessage } from "../infra/errors.js";
|
||||
import { type RuntimeEnv, writeRuntimeJson } from "../runtime.js";
|
||||
@@ -138,7 +139,19 @@ function outputLifecycleResults(
|
||||
): void {
|
||||
const ok = results.every((result) => result.ok);
|
||||
if (json) {
|
||||
writeRuntimeJson(runtime, { ok, operation, dryRun, results });
|
||||
writeRuntimeJson(
|
||||
runtime,
|
||||
ok
|
||||
? { ok, operation, dryRun, results }
|
||||
: {
|
||||
...formatCliJsonFailure(
|
||||
`Session ${operation} did not complete for every requested key.`,
|
||||
),
|
||||
operation,
|
||||
dryRun,
|
||||
results,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
for (const result of results) {
|
||||
switch (result.status) {
|
||||
|
||||
@@ -801,6 +801,16 @@ describe("tasks commands", () => {
|
||||
const lookupRuntime = createRuntime();
|
||||
await tasksShowCommand({ lookup: `missing${unsafe}` }, lookupRuntime);
|
||||
expectSafeTaskOutput(lookupRuntime, "error");
|
||||
|
||||
const jsonLookupRuntime = createRuntime();
|
||||
await tasksShowCommand({ lookup: `missing${unsafe}`, json: true }, jsonLookupRuntime);
|
||||
expect(readFirstJsonLog(jsonLookupRuntime)).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
type: "cli_error",
|
||||
message: expect.stringContaining("Task not found: missing"),
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import { isRich, theme } from "../../packages/terminal-core/src/theme.js";
|
||||
import { formatCliCommand } from "../cli/command-format.js";
|
||||
import { parseCliEnumFilter } from "../cli/enum-filter.js";
|
||||
import { formatLookupMiss } from "../cli/error-format.js";
|
||||
import { formatCliJsonFailure } from "../cli/failure-output.js";
|
||||
import { getRuntimeConfig } from "../config/config.js";
|
||||
import { type RuntimeEnv, writeRuntimeJson } from "../runtime.js";
|
||||
import { getTaskById, updateTaskNotifyPolicyById } from "../tasks/runtime-internal.js";
|
||||
@@ -323,7 +324,12 @@ export async function tasksShowCommand(
|
||||
) {
|
||||
const task = reconcileTaskLookupToken(opts.lookup);
|
||||
if (!task) {
|
||||
runtime.error(formatTaskLookupMiss(opts.lookup));
|
||||
const message = formatTaskLookupMiss(opts.lookup);
|
||||
if (opts.json) {
|
||||
writeRuntimeJson(runtime, formatCliJsonFailure(message));
|
||||
} else {
|
||||
runtime.error(message);
|
||||
}
|
||||
runtime.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
+12
-1
@@ -47,6 +47,13 @@ async function writeCapturedCliArgumentError(message: string): Promise<void> {
|
||||
await configureGatewayStartupTraceConsoleFormatting(gatewayEntryStartupTrace);
|
||||
const { enableConsoleCapture } = await import("./logging.js");
|
||||
enableConsoleCapture();
|
||||
const [{ formatCliJsonFailure }, { isJsonOutputModeActive }] = await Promise.all([
|
||||
import("./cli/failure-output.js"),
|
||||
import("./cli/json-output-mode.js"),
|
||||
]);
|
||||
if (isJsonOutputModeActive(process.argv)) {
|
||||
defaultRuntime.writeJson(formatCliJsonFailure(message));
|
||||
}
|
||||
console.error(`[openclaw] ${message}`);
|
||||
}
|
||||
|
||||
@@ -307,7 +314,11 @@ export async function runMainOrRootHelp(
|
||||
await configureGatewayStartupTraceConsoleFormatting(gatewayEntryStartupTrace);
|
||||
const { enableConsoleCapture } = await import("./logging.js");
|
||||
enableConsoleCapture();
|
||||
const { formatCliFailureLines } = await import("./cli/failure-output.js");
|
||||
const [{ formatCliFailureLines, formatCliJsonFailure }, { isJsonOutputModeActive }] =
|
||||
await Promise.all([import("./cli/failure-output.js"), import("./cli/json-output-mode.js")]);
|
||||
if (isJsonOutputModeActive(argv)) {
|
||||
defaultRuntime.writeJson(formatCliJsonFailure(error));
|
||||
}
|
||||
for (const line of formatCliFailureLines({
|
||||
title: "Could not start the CLI.",
|
||||
error,
|
||||
|
||||
+9
-2
@@ -3,7 +3,8 @@
|
||||
// Package executable entrypoint that forwards to the CLI bootstrap.
|
||||
import process from "node:process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { formatCliFailureLines } from "./cli/failure-output.js";
|
||||
import { formatCliFailureLines, formatCliJsonFailure } from "./cli/failure-output.js";
|
||||
import { isJsonOutputModeActive } from "./cli/json-output-mode.js";
|
||||
import { runCliWithExitFinalization } from "./cli/one-shot-exit.js";
|
||||
import { tryHandleRootVersionFastPath } from "./entry.version-fast-path.js";
|
||||
import { formatUncaughtError } from "./infra/errors.js";
|
||||
@@ -99,7 +100,7 @@ if (!isMain) {
|
||||
}
|
||||
|
||||
if (isMain && !handledRootVersion) {
|
||||
const { restoreRuntimeTerminalState } = await import("./runtime.js");
|
||||
const { defaultRuntime, restoreRuntimeTerminalState } = await import("./runtime.js");
|
||||
|
||||
// Global error handlers to prevent silent crashes from unhandled rejections/exceptions.
|
||||
// These log the error and exit gracefully instead of crashing without trace.
|
||||
@@ -116,6 +117,9 @@ if (isMain && !handledRootVersion) {
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (isJsonOutputModeActive(process.argv)) {
|
||||
defaultRuntime.writeJson(formatCliJsonFailure(error));
|
||||
}
|
||||
for (const line of formatCliFailureLines({
|
||||
title: "OpenClaw hit an unexpected runtime error.",
|
||||
error,
|
||||
@@ -137,6 +141,9 @@ if (isMain && !handledRootVersion) {
|
||||
retainConsoleRoutingUntilProcessExit: true,
|
||||
}),
|
||||
onError: (err) => {
|
||||
if (isJsonOutputModeActive(process.argv)) {
|
||||
defaultRuntime.writeJson(formatCliJsonFailure(err));
|
||||
}
|
||||
for (const line of formatCliFailureLines({
|
||||
title: "The CLI command failed.",
|
||||
error: err,
|
||||
|
||||
@@ -111,7 +111,11 @@ describe("cli json stdout contract", () => {
|
||||
|
||||
expect(result.status, result.stderr).toBe(1);
|
||||
expect(JSON.parse(result.stdout)).toMatchObject({
|
||||
error: expect.stringContaining("Invalid path segment: __proto__"),
|
||||
ok: false,
|
||||
error: {
|
||||
type: "cli_error",
|
||||
message: expect.stringContaining("Invalid path segment: __proto__"),
|
||||
},
|
||||
});
|
||||
expect(result.stderr).toBe("");
|
||||
await expect(
|
||||
@@ -149,7 +153,11 @@ describe("cli json stdout contract", () => {
|
||||
|
||||
expect(result.status, result.stderr).toBe(1);
|
||||
expect(JSON.parse(result.stdout)).toMatchObject({
|
||||
error: expect.stringContaining("OpenClaw config is invalid"),
|
||||
ok: false,
|
||||
error: {
|
||||
type: "cli_error",
|
||||
message: expect.stringContaining("OpenClaw config is invalid"),
|
||||
},
|
||||
issues: expect.arrayContaining([
|
||||
expect.objectContaining({ path: "gateway.bind", message: expect.any(String) }),
|
||||
]),
|
||||
@@ -255,13 +263,84 @@ describe("cli json stdout contract", () => {
|
||||
const result = runSourceCli(tempHome, ["update", "status", "--json", "--timeout", ""]);
|
||||
|
||||
expect(result.status, result.stderr).toBe(1);
|
||||
expect(result.stdout).toBe("");
|
||||
expect(JSON.parse(result.stdout)).toEqual({
|
||||
ok: false,
|
||||
error: {
|
||||
type: "cli_error",
|
||||
message: "--timeout must be a positive integer (seconds)",
|
||||
},
|
||||
});
|
||||
expect(result.stderr).toContain("--timeout must be a positive integer (seconds)");
|
||||
},
|
||||
{ prefix: "openclaw-update-empty-timeout-e2e-" },
|
||||
);
|
||||
});
|
||||
|
||||
it("returns one canonical document for a command that previously failed on stderr only", async () => {
|
||||
await withTempHome(
|
||||
async (tempHome) => {
|
||||
const missingArchive = path.join(tempHome, "missing-backup.tar.gz");
|
||||
const result = runSourceCli(tempHome, ["backup", "verify", missingArchive, "--json"]);
|
||||
|
||||
expect(result.status).toBe(1);
|
||||
expect(JSON.parse(result.stdout)).toEqual({
|
||||
ok: false,
|
||||
error: {
|
||||
type: "cli_error",
|
||||
message: expect.stringContaining("missing-backup.tar.gz"),
|
||||
},
|
||||
});
|
||||
},
|
||||
{ prefix: "openclaw-json-failure-e2e-" },
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps Commander parse failures machine-readable in JSON mode", async () => {
|
||||
await withTempHome(
|
||||
async (tempHome) => {
|
||||
const result = runSourceCli(tempHome, [
|
||||
"config",
|
||||
"get",
|
||||
"gateway.port",
|
||||
"--json",
|
||||
"--not-a-real-option",
|
||||
]);
|
||||
|
||||
expect(result.status).toBe(1);
|
||||
expect(JSON.parse(result.stdout)).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
type: "cli_error",
|
||||
message: expect.stringContaining("--not-a-real-option"),
|
||||
},
|
||||
});
|
||||
expect(result.stderr).toContain("--not-a-real-option");
|
||||
},
|
||||
{ prefix: "openclaw-json-parse-failure-e2e-" },
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps representative success payload bytes unchanged", async () => {
|
||||
await withTempHome(
|
||||
async (tempHome) => {
|
||||
const configPath = path.join(tempHome, "openclaw.json");
|
||||
await fs.writeFile(configPath, '{"gateway":{"port":28789}}\n', "utf8");
|
||||
const env = { OPENCLAW_CONFIG_PATH: configPath };
|
||||
|
||||
const getResult = runSourceCli(tempHome, ["config", "get", "gateway.port", "--json"], env);
|
||||
const validateResult = runSourceCli(tempHome, ["config", "validate", "--json"], env);
|
||||
|
||||
expect(getResult.status, getResult.stderr).toBe(0);
|
||||
expect(getResult.stdout).toBe("28789\n");
|
||||
expect(validateResult.status, validateResult.stderr).toBe(0);
|
||||
expect(validateResult.stdout).toBe(
|
||||
`${JSON.stringify({ valid: true, path: configPath, warnings: [] })}\n`,
|
||||
);
|
||||
},
|
||||
{ prefix: "openclaw-json-success-bytes-e2e-" },
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps `config schema` stdout parseable at debug log level", async () => {
|
||||
await withTempHome(
|
||||
async (tempHome) => {
|
||||
|
||||
Reference in New Issue
Block a user