From a8601a6d30f1499e23ab5b5857c4c9ff17052b2c Mon Sep 17 00:00:00 2001 From: dtzp555-max Date: Mon, 11 May 2026 07:25:40 +1000 Subject: [PATCH] feat(doctor): --check oauth fast path (#93) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(doctor): --check oauth fast path Implements the --check oauth fast path documented in cmd_doctor_help but previously unimplemented. Skips version detection, from-version check, git operations, and models endpoint — runs only the curl against /health + auth.ok extraction. Use cases: - After `claude auth login`, fast verify OCP can spawn cli.js - After a known service blip, quick health gate before larger ops - AI agent's setup-repair loop: ./ocp doctor --check oauth in a retry-after-fix step 3 unit tests cover: PASS path, OAuth FAIL → fix_oauth, service down → fix_service. No cli.js citation needed: this is OCP-internal doctor logic with no corresponding cli.js operation. Co-Authored-By: Claude Sonnet 4.6 * chore(doctor): nit fixes for --check oauth (N3 body=null test + N4 skipped sentinel comment) --------- Co-authored-by: dtzp555 Co-authored-by: Claude Sonnet 4.6 --- scripts/doctor.mjs | 87 +++++++++++++++++++++++++++++++++++++++++++++- test-features.mjs | 51 +++++++++++++++++++++++++++ 2 files changed, 137 insertions(+), 1 deletion(-) diff --git a/scripts/doctor.mjs b/scripts/doctor.mjs index 9928e9f..abe70b6 100644 --- a/scripts/doctor.mjs +++ b/scripts/doctor.mjs @@ -37,6 +37,11 @@ export async function runDoctor(opts = {}) { const push = (id, level, message, extra = {}) => checks.push({ id, level, message, ...extra }); + // --- fast path: --check oauth --- + if (opts.checkOnly === "oauth") { + return runOauthOnly(opts, checks, push); + } + // --- version detection --- const ocpDir = opts.ocpDir || join(homedir(), "ocp"); let currentVersion = opts.mockVersion; @@ -190,6 +195,84 @@ export async function runDoctor(opts = {}) { }; } +function runOauthOnly(opts, checks, push) { + let healthOk = true, oauthOk = true; + 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("oauth_ok", "FAIL", `service unreachable: ${health.error || `status ${health.status}`}`); + } else if (!health.body || typeof health.body !== "object") { + healthOk = false; + push("oauth_ok", "FAIL", "service /health returned 200 but empty/non-JSON body"); + } else if (!health.body?.auth?.ok) { + oauthOk = false; + push("oauth_ok", "FAIL", `auth.ok=false: ${health.body?.auth?.message || "unknown"}`); + } else { + push("oauth_ok", "PASS", "OAuth token valid"); + } + + const kind = !healthOk ? "fix_service" : !oauthOk ? "fix_oauth" : "noop"; + + let next_action; + const ocpDir = opts.ocpDir || join(homedir(), "ocp"); + if (kind === "noop") { + next_action = { kind, human_required: [], ai_executable: [], verify: "OAuth healthy" }; + } 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 --check oauth` + ], + verify: "ocp doctor --check oauth expects PASS", + reference: "~/.cc-rules/memory/learnings/ocp_claude_native_binary_postinstall.md" + }; + } else { + 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 --check oauth` + ], + verify: "ocp doctor --check oauth expects service_running=PASS" + }; + } + + const fail_count = checks.filter(c => c.level === "FAIL").length; + // "skipped" = --check oauth fast path intentionally omits version detection. + // AI agents should NOT semver-compare against current_version/latest_version when + // either equals "skipped"; the full path provides those fields when needed. + return { + schema_version: SCHEMA_VERSION, + timestamp: new Date().toISOString(), + ready_to_upgrade: fail_count === 0, + current_version: opts.mockVersion || "skipped", + latest_version: opts.mockLatest || "skipped", + from_version_supported: true, + fail_count, + warn_count: 0, + checks, + next_action + }; +} + // CLI entrypoint — use fileURLToPath + realpath to handle symlinked install paths // (e.g. /tmp/ → /private/tmp/ on macOS would otherwise miss the guard). import { fileURLToPath } from "node:url"; @@ -202,7 +285,9 @@ function _isMain() { } if (_isMain()) { const wantJson = process.argv.includes("--json"); - const result = await runDoctor(); + const checkIdx = process.argv.indexOf("--check"); + const checkOnly = checkIdx !== -1 ? process.argv[checkIdx + 1] : undefined; + const result = await runDoctor({ checkOnly }); if (wantJson) { console.log(JSON.stringify(result, null, 2)); } else { diff --git a/test-features.mjs b/test-features.mjs index 404f4fc..d7fa038 100644 --- a/test-features.mjs +++ b/test-features.mjs @@ -843,6 +843,57 @@ test("rollback latest snapshot restores files (mockExec)", async () => { assert.ok(result.phases.some(p => p.name === "git-checkout")); }); +// ── Doctor --check oauth fast path tests ── +console.log("\nDoctor --check oauth:"); + +await asyncTest("doctor --check oauth runs only oauth check (skips version/from-version)", async () => { + const result = await runDoctor({ + checkOnly: "oauth", + mockVersion: "v3.10.0", + mockLatest: "v3.14.0", + mockHealth: { status: 200, body: { auth: { ok: true, message: "authenticated" } } } + }); + // Should still produce a valid result object + assert.equal(result.schema_version, "1"); + // checks[] should only contain oauth_ok (no current_version, no from_version_supported) + const ids = result.checks.map(c => c.id); + assert.deepEqual(ids, ["oauth_ok"]); + assert.equal(result.next_action.kind, "noop"); +}); + +await asyncTest("doctor --check oauth + OAuth FAIL → fix_oauth", async () => { + const result = await runDoctor({ + checkOnly: "oauth", + mockHealth: { status: 200, body: { auth: { ok: false, message: "ENOEXEC" } } } + }); + const ids = result.checks.map(c => c.id); + assert.deepEqual(ids, ["oauth_ok"]); + assert.equal(result.next_action.kind, "fix_oauth"); + assert.equal(result.fail_count, 1); +}); + +await asyncTest("doctor --check oauth + service down → fix_service", async () => { + const result = await runDoctor({ + checkOnly: "oauth", + mockHealth: { error: "ECONNREFUSED" } + }); + const ids = result.checks.map(c => c.id); + assert.deepEqual(ids, ["oauth_ok"]); + assert.equal(result.next_action.kind, "fix_service"); + assert.equal(result.fail_count, 1); +}); + +await asyncTest("doctor --check oauth + 200 with null body → fix_service", async () => { + const result = await runDoctor({ + checkOnly: "oauth", + mockHealth: { status: 200, body: null } + }); + const ids = result.checks.map(c => c.id); + assert.deepEqual(ids, ["oauth_ok"]); + assert.equal(result.next_action.kind, "fix_service"); + assert.equal(result.fail_count, 1); +}); + // ── Cleanup ── closeDb();