#!/usr/bin/env node /** * @file plugin-doctor.js * @description Reports the health of a plugin-installed CCAM: Node version, * bootstrap state, runtime dependencies, server liveness, duplicate hook * entries (the one failure that silently doubles every token and cost figure), * the `ccam` CLI launcher and its PATH, and whether the committed MCP build * still matches `mcp/src`. Read-only — it diagnoses, it never repairs. * @author Nguyễn Ngọc Trí Vĩ */ const fs = require("fs"); const os = require("os"); const path = require("path"); const boot = require("./plugin-bootstrap"); const { getSettingsPath, getDataDir } = require("../server/lib/claude-home"); const { mcpBuildStatus } = require("./check-mcp-build"); const PLUGIN_ROOT = path.resolve(__dirname, ".."); /** @returns {{level:"ok"|"warn"|"fail", label:string, detail:string}[]} */ function diagnose() { const rt = boot.runtimeDir(); const out = []; const add = (level, label, detail) => out.push({ level, label, detail }); add( boot.nodeVersionOk() ? "ok" : "fail", "Node", boot.nodeVersionOk() ? `v${process.versions.node}` : `v${process.versions.node} — CCAM needs >= ${boot.MIN_NODE.join(".")} (node:sqlite)` ); add("ok", "Plugin root", PLUGIN_ROOT); add("ok", "Data dir", getDataDir()); const state = boot.readState(rt); if (!state) { add( "warn", "Bootstrap", `no state recorded — start a new session, or check ${rt}/bootstrap.log` ); } else { const moved = state.pluginRoot !== PLUGIN_ROOT; add( moved ? "warn" : "ok", "Bootstrap", moved ? `recorded against a previous plugin version (${state.pluginRoot}) — run /ccam-update` : `last run ${state.updatedAt}` ); } const deps = fs.existsSync(path.join(rt, "node_modules")); add( deps ? "ok" : "fail", "Runtime deps", deps ? path.join(rt, "node_modules") : "missing — run /ccam-update" ); const live = boot.liveServers(); add( live.length ? "ok" : "fail", "Server", live.length ? `listening on ${live.map((s) => `http://localhost:${s.port}`).join(", ")}` : `not running — see ${path.join(rt, "server.log")}` ); const dup = countLegacyHookEntries(); add( dup ? "fail" : "ok", "Hooks", dup ? `${dup} entr${dup === 1 ? "y" : "ies"} in ${getSettingsPath()} duplicate the plugin's hooks — ` + `every event is counted twice. Remove them (a new session does it automatically).` : "provided by the plugin only" ); add(...cliStatus()); const mcp = mcpBuildStatus(PLUGIN_ROOT); add( mcp.ok ? "ok" : "fail", "MCP build", mcp.ok ? "matches mcp/src" : `${mcp.reason} — run npm run mcp:build` ); const dist = process.env.DASHBOARD_CLIENT_DIST || path.join(rt, "client-dist"); add( fs.existsSync(path.join(dist, "index.html")) ? "ok" : "warn", "Dashboard UI", fs.existsSync(path.join(dist, "index.html")) ? dist : "not built yet — run /ccam-open" ); return out; } function countLegacyHookEntries() { try { const settings = JSON.parse(fs.readFileSync(getSettingsPath(), "utf8")); if (!settings.hooks) return 0; const isLegacy = boot.isCheckoutHookEntry(); return Object.values(settings.hooks) .filter(Array.isArray) .reduce((n, entries) => n + entries.filter(isLegacy).length, 0); } catch { return 0; } } function cliStatus() { const dir = path.join(os.homedir(), ".local", "bin"); const file = path.join(dir, process.platform === "win32" ? "ccam.cmd" : "ccam"); if (!fs.existsSync(file)) return ["warn", "ccam CLI", `no launcher at ${file}`]; const onPath = (process.env.PATH || "") .split(path.delimiter) .some((p) => path.resolve(p) === path.resolve(dir)); return onPath ? ["ok", "ccam CLI", file] : [ "warn", "ccam CLI", `${file} exists but ${dir} is not on PATH — add: export PATH="${dir}:$PATH"`, ]; } function report() { const marks = { ok: "OK ", warn: "WARN", fail: "FAIL" }; const rows = diagnose(); for (const r of rows) console.log(`${marks[r.level]} ${r.label.padEnd(14)} ${r.detail}`); return rows.some((r) => r.level === "fail") ? 1 : 0; } if (require.main === module) process.exitCode = report(); module.exports = { diagnose, report, countLegacyHookEntries };