From b65201b3958cd8eef27caee9db21b87bdb5e244d Mon Sep 17 00:00:00 2001 From: dtzp555 Date: Mon, 11 May 2026 03:45:34 +1000 Subject: [PATCH] feat(doctor): add ocp doctor with --json + next_action contract Implements scripts/doctor.mjs with semver-aware path selection (noop/update/upgrade/fresh_install/fix_oauth/fix_service) and the JSON contract documented in the design spec. Service health + OAuth checks integrated; mockable via opts.mockHealth for unit tests. 8 unit tests cover the kind dispatch tree and the next_action shape for each kind. No cli.js citation needed: this is OCP-internal tooling with no corresponding cli.js operation. Co-Authored-By: Claude Sonnet 4.6 --- scripts/doctor.mjs | 190 +++++++++++++++++++++++++++++++++++++++++++++ test-features.mjs | 65 ++++++++++++++++ 2 files changed, 255 insertions(+) create mode 100644 scripts/doctor.mjs diff --git a/scripts/doctor.mjs b/scripts/doctor.mjs new file mode 100644 index 0000000..d1da648 --- /dev/null +++ b/scripts/doctor.mjs @@ -0,0 +1,190 @@ +#!/usr/bin/env node +/** + * scripts/doctor.mjs — OCP health & upgrade-readiness check. + * + * Usage: + * ocp doctor human-readable PASS/WARN/FAIL + * ocp doctor --json machine-readable JSON for AI agents + ocp update + * ocp doctor --check oauth fast path: only OAuth check + * + * Exit codes: + * 0 all PASS or WARN-only + * 1 any FAIL + */ +import { readFileSync, existsSync } from "node:fs"; +import { join } from "node:path"; +import { homedir } from "node:os"; +import { execSync } from "node:child_process"; + +const SCHEMA_VERSION = "1"; +const KIND_ENUM = ["noop", "update", "upgrade", "fresh_install", "fix_oauth", "fix_service"]; + +function semverParts(v) { + const m = String(v).replace(/^v/, "").match(/^(\d+)\.(\d+)\.(\d+)/); + if (!m) return null; + return { major: +m[1], minor: +m[2], patch: +m[3] }; +} + +function semverCompare(a, b) { + const A = semverParts(a), B = semverParts(b); + if (!A || !B) return 0; + if (A.major !== B.major) return A.major - B.major; + if (A.minor !== B.minor) return A.minor - B.minor; + return A.patch - B.patch; +} + +export async function runDoctor(opts = {}) { + const checks = []; + const push = (id, level, message, extra = {}) => + checks.push({ id, level, message, ...extra }); + + // --- version detection --- + const ocpDir = opts.ocpDir || join(homedir(), "ocp"); + let currentVersion = opts.mockVersion; + if (!currentVersion) { + try { + const pkg = JSON.parse(readFileSync(join(ocpDir, "package.json"), "utf8")); + currentVersion = `v${pkg.version}`; + } catch { + currentVersion = "unknown"; + } + } + const latestVersion = opts.mockLatest || "v3.14.0"; + push("current_version", "PASS", `current=${currentVersion}`); + + // --- from-version supported? --- + const fromSupported = semverCompare(currentVersion, "v3.4.0") >= 0; + push("from_version_supported", fromSupported ? "PASS" : "FAIL", + fromSupported ? "≥ v3.4.0" : `${currentVersion} < v3.4.0; in-place upgrade not supported`); + + // --- service health check (mockable) --- + let healthOk = true, oauthOk = true; + if (!opts.skipNetwork) { + let health; + if (opts.mockHealth !== undefined) { + health = opts.mockHealth; + } else { + try { + const port = process.env.CLAUDE_PROXY_PORT || "3478"; + const out = execSync(`curl -sf --max-time 3 http://127.0.0.1:${port}/health`, { stdio: ["pipe", "pipe", "pipe"] }).toString(); + health = { status: 200, body: JSON.parse(out) }; + } catch (e) { + health = { error: String(e.message || e) }; + } + } + if (health.error || health.status !== 200) { + healthOk = false; + push("service_running", "FAIL", `service unreachable: ${health.error || `status ${health.status}`}`); + } else { + push("service_running", "PASS", "service responding on /health"); + const authOk = health.body?.auth?.ok; + if (!authOk) { + oauthOk = false; + push("oauth_ok", "FAIL", `auth.ok=false: ${health.body?.auth?.message || "unknown"}`); + } else { + push("oauth_ok", "PASS", "OAuth token valid"); + } + } + } + + // --- determine next_action.kind (priority: fresh_install > fix_service > fix_oauth > noop > update > upgrade) --- + let kind; + if (!fromSupported) { + kind = "fresh_install"; + } else if (!opts.skipNetwork && !healthOk) { + kind = "fix_service"; + } else if (!opts.skipNetwork && !oauthOk) { + kind = "fix_oauth"; + } else if (semverCompare(currentVersion, latestVersion) === 0) { + kind = "noop"; + } else { + const cur = semverParts(currentVersion), lat = semverParts(latestVersion); + if (cur && lat && cur.major === lat.major && cur.minor === lat.minor) { + kind = "update"; + } else { + kind = "upgrade"; + } + } + + // --- next_action shape --- + let next_action; + if (kind === "fresh_install") { + next_action = { + kind, + human_required: ["claude auth login (only if OAuth becomes invalid after reinstall)"], + ai_executable: [ + `launchctl bootout gui/$(id -u)/ai.openclaw.proxy 2>/dev/null || true`, + `launchctl bootout gui/$(id -u)/dev.ocp.proxy 2>/dev/null || true`, + `mv ${join(homedir(), ".ocp")} ${join(homedir(), ".ocp.backup-")}$(date +%s) 2>/dev/null || true`, + `rm -rf ${ocpDir}`, + `git clone https://github.com/dtzp555-max/ocp ${ocpDir}`, + `cd ${ocpDir} && npm install --no-audit --no-fund && node setup.mjs`, + `${ocpDir}/ocp doctor` + ], + verify: "ocp doctor expects PASS on all checks" + }; + } else if (kind === "noop") { + next_action = { kind, human_required: [], ai_executable: [], verify: "already at latest" }; + } else if (kind === "fix_oauth") { + next_action = { + kind, + human_required: [], + ai_executable: [ + `cd "$(npm root -g)/@anthropic-ai/claude-code" && node install.cjs`, + `launchctl bootout gui/$(id -u)/dev.ocp.proxy 2>/dev/null || true`, + `launchctl bootstrap gui/$(id -u) ${join(homedir(), "Library", "LaunchAgents", "dev.ocp.proxy.plist")}`, + `${ocpDir}/ocp doctor` + ], + verify: "ocp doctor expects oauth_ok=PASS", + reference: "~/.cc-rules/memory/learnings/ocp_claude_native_binary_postinstall.md" + }; + } else if (kind === "fix_service") { + next_action = { + kind, + human_required: [], + ai_executable: [ + `launchctl bootout gui/$(id -u)/dev.ocp.proxy 2>/dev/null || true`, + `launchctl bootstrap gui/$(id -u) ${join(homedir(), "Library", "LaunchAgents", "dev.ocp.proxy.plist")}`, + `${ocpDir}/ocp doctor` + ], + verify: "ocp doctor expects service_running=PASS" + }; + } else { + next_action = { + kind, + human_required: [], + ai_executable: [`${ocpDir}/ocp update --yes`], + verify: "ocp doctor expects PASS on all checks" + }; + } + + const fail_count = checks.filter(c => c.level === "FAIL").length; + const warn_count = checks.filter(c => c.level === "WARN").length; + return { + schema_version: SCHEMA_VERSION, + timestamp: new Date().toISOString(), + ready_to_upgrade: fail_count === 0, + current_version: currentVersion, + latest_version: latestVersion, + from_version_supported: fromSupported, + fail_count, + warn_count, + checks, + next_action + }; +} + +// CLI entrypoint +if (import.meta.url === `file://${process.argv[1]}`) { + const wantJson = process.argv.includes("--json"); + const result = await runDoctor(); + if (wantJson) { + console.log(JSON.stringify(result, null, 2)); + } else { + console.log(`OCP doctor — ${result.current_version} → ${result.latest_version}`); + for (const c of result.checks) console.log(` [${c.level}] ${c.id}: ${c.message}`); + console.log(`\nSummary: ${result.fail_count} FAIL, ${result.warn_count} WARN`); + console.log(`Next action: ${result.next_action.kind}`); + } + process.exit(result.fail_count === 0 ? 0 : 1); +} diff --git a/test-features.mjs b/test-features.mjs index 5d9b2b6..b970fac 100644 --- a/test-features.mjs +++ b/test-features.mjs @@ -574,6 +574,71 @@ test("mergeSystemdEnv is idempotent", () => { assert.equal(mergeSystemdEnv(r1, SAMPLE_TEMPLATE_SYSTEMD), r1); }); +// ── Doctor JSON Contract Tests ── +import { runDoctor } from "./scripts/doctor.mjs"; + +console.log("\nDoctor:"); + +test("doctor --json shape: required top-level keys", async () => { + const result = await runDoctor({ skipNetwork: true, mockVersion: "v3.10.0", mockLatest: "v3.14.0" }); + for (const k of ["schema_version", "ready_to_upgrade", "current_version", "latest_version", + "from_version_supported", "fail_count", "warn_count", "checks", "next_action"]) { + assert.ok(k in result, `missing key: ${k}`); + } + assert.equal(result.schema_version, "1"); +}); + +test("doctor detects from-version < v3.4.0 → fresh_install", async () => { + const result = await runDoctor({ skipNetwork: true, mockVersion: "v3.2.0", mockLatest: "v3.14.0" }); + assert.equal(result.from_version_supported, false); + assert.equal(result.next_action.kind, "fresh_install"); + assert.ok(Array.isArray(result.next_action.ai_executable)); + assert.ok(result.next_action.ai_executable.length > 0); +}); + +test("doctor next_action.kind enum is one of allowed values", async () => { + const result = await runDoctor({ skipNetwork: true, mockVersion: "v3.10.0", mockLatest: "v3.14.0" }); + const ALLOWED = ["noop", "update", "upgrade", "fresh_install", "fix_oauth", "fix_service"]; + assert.ok(ALLOWED.includes(result.next_action.kind), `kind=${result.next_action.kind} not in enum`); +}); + +test("doctor noop when current==latest", async () => { + const result = await runDoctor({ skipNetwork: true, mockVersion: "v3.14.0", mockLatest: "v3.14.0" }); + assert.equal(result.next_action.kind, "noop"); + assert.equal(result.ready_to_upgrade, true); +}); + +test("doctor patch-bump same minor → update kind", async () => { + const result = await runDoctor({ skipNetwork: true, mockVersion: "v3.14.0", mockLatest: "v3.14.1" }); + assert.equal(result.next_action.kind, "update"); +}); + +test("doctor cross-minor → upgrade kind", async () => { + const result = await runDoctor({ skipNetwork: true, mockVersion: "v3.10.0", mockLatest: "v3.14.0" }); + assert.equal(result.next_action.kind, "upgrade"); +}); + +test("doctor OAuth FAIL → fix_oauth kind", async () => { + const result = await runDoctor({ + skipNetwork: false, + mockVersion: "v3.10.0", + mockLatest: "v3.14.0", + mockHealth: { status: 200, body: { auth: { ok: false, message: "ENOEXEC" } } } + }); + assert.equal(result.next_action.kind, "fix_oauth"); + assert.ok(result.next_action.ai_executable.some(c => c.includes("install.cjs"))); +}); + +test("doctor service down → fix_service kind", async () => { + const result = await runDoctor({ + skipNetwork: false, + mockVersion: "v3.10.0", + mockLatest: "v3.14.0", + mockHealth: { error: "ECONNREFUSED" } + }); + assert.equal(result.next_action.kind, "fix_service"); +}); + // ── Cleanup ── closeDb();