diff --git a/docs/cli/doctor.md b/docs/cli/doctor.md index 1da605d5e13a..7dd3a196f2c6 100644 --- a/docs/cli/doctor.md +++ b/docs/cli/doctor.md @@ -32,17 +32,18 @@ Doctor has five postures: | ------------------------- | ----------------------------------------- | ------------------------------------------------------------------------------- | | Inspect | `openclaw doctor` | Human-oriented checks and guided prompts. | | Repair | `openclaw doctor --fix` | Applies supported repairs, using prompts unless non-interactive repair is safe. | -| Lint | `openclaw doctor --lint` | Read-only structured findings for CI, preflight, and review gates. | +| Lint | `openclaw doctor --json` | Read-only JSON findings for deployment preflight and CI gates. | | Shared SQLite maintenance | `openclaw doctor --state-sqlite compact` | Explicitly checkpoints, compacts, and verifies the canonical shared state DB. | | Session SQLite migration | `openclaw doctor --session-sqlite ` | Inspects, imports, validates, compacts, recovers, or restores session state. | -Prefer `--lint` when automation needs a stable result. Prefer `--fix` when a human operator wants doctor to edit config or state. +Use `openclaw doctor --json` as the machine-readable deployment preflight. It runs the same read-only checks, JSON output, and exit codes as `openclaw doctor --lint --json`. Prefer `--fix` when a human operator wants doctor to edit config or state. ## Examples ```bash openclaw doctor openclaw doctor --lint +openclaw doctor --json openclaw doctor --lint --json openclaw doctor --lint --severity-min warning openclaw doctor --lint --all @@ -93,19 +94,20 @@ openclaw channels status --probe | `--session-sqlite-agent ` | With `--session-sqlite`: select one configured agent. | | `--session-sqlite-all-agents` | With `--session-sqlite`: select configured and discovered agent stores. | | `--github-issue` | With `--session-sqlite recover`: prepare a sanitized openclaw/openclaw issue report; doctor creates it with `gh` after `--yes` or interactive confirmation. | -| `--json` | With `--lint`: JSON findings. With `--post-upgrade`: `{ probesRun, findings }`. With `--state-sqlite` or `--session-sqlite`: the maintenance report as JSON. | +| `--json` | Run lint checks in read-only mode and emit JSON. With another machine mode, emit that mode's existing JSON report. | | `--severity-min ` | With `--lint`: drop findings below `info`, `warning`, or `error`. | | `--all` | With `--lint`: run all registered checks, including opt-in checks excluded from the default set. | | `--skip ` | With `--lint`: skip a check id. Repeatable. | | `--only ` | With `--lint`: run only the given check id(s). Repeatable. | -`--severity-min`, `--all`, `--only`, and `--skip` are only accepted together with `--lint`; `--json` is accepted with `--lint`, `--post-upgrade`, `--state-sqlite`, and `--session-sqlite`. +`--severity-min`, `--all`, `--only`, and `--skip` are only accepted together with `--lint`. Bare `--json` implies lint mode. It cannot be combined with `--repair`, `--fix`, or `--force` unless another machine mode owns the command. ## Lint mode -`openclaw doctor --lint` is read-only: no prompts, no repair, no config/state rewrites. +`openclaw doctor --json` is the deployment-preflight form of lint mode. It is read-only and non-interactive: no prompts, repairs, or config/state rewrites. `openclaw doctor --lint --json` remains an equivalent explicit spelling. ```bash +openclaw doctor --json openclaw doctor --lint openclaw doctor --lint --severity-min warning openclaw doctor --lint --json diff --git a/src/cli/doctor-output-mode.ts b/src/cli/doctor-output-mode.ts index c060e9a79ada..052b8aad5bea 100644 --- a/src/cli/doctor-output-mode.ts +++ b/src/cli/doctor-output-mode.ts @@ -1,10 +1,15 @@ import type { MachineOutputResolverParams } from "./machine-output-argv.js"; import { hasMachineOutputOption } from "./machine-output-argv.js"; -/** Doctor lint follows Unix convention and emits JSON when stdout is not a terminal. */ +/** Bare doctor JSON and non-TTY lint runs own machine-readable stdout. */ export function isDoctorMachineOutput(params: MachineOutputResolverParams): boolean { - return ( - hasMachineOutputOption(params.argv, "--lint") && - (hasMachineOutputOption(params.argv, "--json") || !params.stdoutIsTTY) - ); + const lint = hasMachineOutputOption(params.argv, "--lint"); + if (lint) { + return hasMachineOutputOption(params.argv, "--json") || !params.stdoutIsTTY; + } + const existingMachineMode = + hasMachineOutputOption(params.argv, "--post-upgrade") || + hasMachineOutputOption(params.argv, "--state-sqlite") || + hasMachineOutputOption(params.argv, "--session-sqlite"); + return hasMachineOutputOption(params.argv, "--json") && !existingMachineMode; } diff --git a/src/cli/machine-output-modes.test.ts b/src/cli/machine-output-modes.test.ts index 020f4b068d28..31207c5aefbf 100644 --- a/src/cli/machine-output-modes.test.ts +++ b/src/cli/machine-output-modes.test.ts @@ -30,6 +30,23 @@ describe("built-in machine-output resolvers", () => { expect(isDoctorMachineOutput({ argv, stdoutIsTTY: true })).toBe(false); }); + it("reserves doctor JSON output with or without explicit lint mode", () => { + for (const argv of [ + ["node", "openclaw", "doctor", "--json"], + ["node", "openclaw", "doctor", "--lint", "--json"], + ]) { + expect(isDoctorMachineOutput({ argv, stdoutIsTTY: true })).toBe(true); + } + }); + + it.each(["--post-upgrade", "--state-sqlite=compact", "--session-sqlite=dry-run"])( + "preserves registered-command JSON handling for doctor %s", + (mode) => { + const argv = ["node", "openclaw", "doctor", mode, "--json"]; + expect(isDoctorMachineOutput({ argv, stdoutIsTTY: true })).toBe(false); + }, + ); + it.each(["blob", "coverage", "purge", "query", "sessions"])( "detects proxy %s output", (command) => { diff --git a/src/cli/program/register.maintenance.test.ts b/src/cli/program/register.maintenance.test.ts index 7572e7936272..f7858cf3f730 100644 --- a/src/cli/program/register.maintenance.test.ts +++ b/src/cli/program/register.maintenance.test.ts @@ -231,10 +231,14 @@ describe("registerMaintenanceCommands doctor action", () => { expect(runtime.exit).toHaveBeenCalledWith(2); }); - it("rejects session sqlite selectors without session sqlite mode", async () => { - await runMaintenanceCli(["doctor", "--session-sqlite-agent", "main"]); + it.each([ + ["without JSON", ["--session-sqlite-agent", "main"]], + ["with JSON", ["--json", "--session-sqlite-agent", "main"]], + ])("rejects session sqlite selectors without session sqlite mode %s", async (_label, args) => { + await runMaintenanceCli(["doctor", ...args]); expect(doctorCommand).not.toHaveBeenCalled(); + expect(runDoctorLintCli).not.toHaveBeenCalled(); expect(runtime.error).toHaveBeenCalledWith( "doctor session SQLite options require --session-sqlite. Use `openclaw doctor --session-sqlite dry-run ...`.", ); @@ -271,6 +275,54 @@ describe("registerMaintenanceCommands doctor action", () => { expect(runtime.exit).toHaveBeenCalledWith(1); }); + it("treats bare --json as lint mode and emits machine-readable output", async () => { + const output: string[] = []; + const writeSpy = vi.spyOn(process.stdout, "write").mockImplementation((chunk) => { + output.push(String(chunk)); + return true; + }); + runDoctorLintCli.mockImplementationOnce(async () => { + process.stdout.write('{"ok":true,"checksRun":1,"checksSkipped":0,"findings":[]}\n'); + return 0; + }); + + try { + await runMaintenanceCli(["doctor", "--json"]); + + expect(doctorCommand).not.toHaveBeenCalled(); + expect(runDoctorLintCli).toHaveBeenCalledWith(runtime, { + json: true, + severityMin: undefined, + includeAllChecks: false, + skipIds: [], + onlyIds: [], + allowExec: false, + deep: false, + }); + expect(JSON.parse(output.join(""))).toEqual({ + ok: true, + checksRun: 1, + checksSkipped: 0, + findings: [], + }); + expect(runtime.exit).toHaveBeenCalledWith(0); + } finally { + writeSpy.mockRestore(); + runDoctorLintCli.mockReset(); + } + }); + + it("rejects JSON repair mode before running doctor", async () => { + await runMaintenanceCli(["doctor", "--json", "--repair"]); + + expect(doctorCommand).not.toHaveBeenCalled(); + expect(runDoctorLintCli).not.toHaveBeenCalled(); + expect(runtime.error).toHaveBeenCalledWith( + "doctor --json runs read-only lint checks and cannot be combined with --repair, --fix, or --force.", + ); + expect(runtime.exit).toHaveBeenCalledWith(2); + }); + it("rejects lint selectors outside doctor lint mode", async () => { await runMaintenanceCli(["doctor", "--fix", "--only", "policy/channels-denied-provider"]); diff --git a/src/cli/program/register.maintenance.ts b/src/cli/program/register.maintenance.ts index edcf5d164659..84491ee89246 100644 --- a/src/cli/program/register.maintenance.ts +++ b/src/cli/program/register.maintenance.ts @@ -79,7 +79,7 @@ export function registerMaintenanceCommands(program: Command) { ) .option( "--json", - "With --lint, --post-upgrade, --state-sqlite, or --session-sqlite: emit machine-readable JSON output", + "Run read-only lint checks as JSON (or emit JSON for another machine mode)", false, ) .option( @@ -110,7 +110,21 @@ export function registerMaintenanceCommands(program: Command) { defaultRuntime.exit(2); return; } - if (opts.lint === true) { + const jsonImpliesLint = + opts.json === true && + opts.lint !== true && + opts.postUpgrade !== true && + typeof opts.stateSqlite !== "string" && + typeof opts.sessionSqlite !== "string" && + !hasSessionSqliteOnlyDoctorOptions(opts); + if (jsonImpliesLint && (opts.repair === true || opts.fix === true || opts.force === true)) { + defaultRuntime.error( + "doctor --json runs read-only lint checks and cannot be combined with --repair, --fix, or --force.", + ); + defaultRuntime.exit(2); + return; + } + if (opts.lint === true || jsonImpliesLint) { await runCommandWithRuntime( defaultRuntime, async () => { @@ -258,20 +272,12 @@ export function registerMaintenanceCommands(program: Command) { } function hasLintOnlyDoctorOptions(opts: { - readonly json?: boolean; - readonly postUpgrade?: boolean; - readonly stateSqlite?: unknown; - readonly sessionSqlite?: unknown; readonly severityMin?: unknown; readonly all?: boolean; readonly skip?: unknown; readonly only?: unknown; }): boolean { return ( - (opts.json === true && - opts.postUpgrade !== true && - typeof opts.stateSqlite !== "string" && - typeof opts.sessionSqlite !== "string") || typeof opts.severityMin === "string" || opts.all === true || (Array.isArray(opts.skip) && opts.skip.length > 0) ||