a65ee1512e
resolveAllDashboardPorts() falls back to [DEFAULT_PORT] when the discovery file has no live entry — a reasonable guess for the CLI/hook handler, but wrong for the bootstrap's own liveness check: with no server running at all, the bootstrap believed one was already up and never called startDashboard(), confirmed against a real plugin install where the dashboard never started. plugin-doctor.js's "Server" row had the same bug. Both now read the discovery file directly and check PID liveness via the new liveServers() (livePids() reused it instead of duplicating the read).
141 lines
4.3 KiB
JavaScript
141 lines
4.3 KiB
JavaScript
#!/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ĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
const fs = require("fs");
|
|
const os = require("os");
|
|
const path = require("path");
|
|
|
|
const boot = require("./plugin-bootstrap");
|
|
const { isOurEntry } = require("./install-hooks");
|
|
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;
|
|
return Object.values(settings.hooks)
|
|
.filter(Array.isArray)
|
|
.reduce((n, entries) => n + entries.filter(isOurEntry).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 };
|