6cb7f4f843
The ship-feature pipelines drive `/superpowers:*` commands as their per-node drivers, so a fresh install had a dependency it was only told about — the `/ccam-doctor` WARN row and a line in the README. `server/lib/skill-plugins.js` now carries the whole thing, shared by the doctor (reports) and the bootstrap (installs) so the two cannot disagree about what is missing. The bootstrap installs at the very end of its DETACHED worker — the install clones from the network and nothing in the 10 s SessionStart hook may wait on it — retrying once behind a `marketplace add`, logging a failure with the manual command, and never throwing: the dashboard, hooks, CLI and MCP all work without these plugins. The outcome is recorded as `state.skillPluginsSettled`, which `fastPathOk()` now also requires, so the check runs exactly once per install and `/ccam-update` is the retry. `CCAM_AUTO_INSTALL_PLUGINS=0|false|off` opts out. Reporting is derived, installing is allowlisted. Requirements come from the loaded templates, which is right for a report; installing from that same source would not be, because a template is a file anyone can drop into `DASHBOARD_PIPELINES_DIR` — it would turn "edit a JSON file" into "run this vendor's install command on the next session". Auto-install consults `AUTO_INSTALLABLE`, a constant in code (`superpowers` today), and reports every other requirement as skipped. Tests inject a `run` seam so no case reaches a real `claude plugin install`, and cover the refusal explicitly.
175 lines
5.7 KiB
JavaScript
175 lines
5.7 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, whether the committed MCP build still
|
|
* matches `mcp/src`, and whether the slash commands the pipeline templates name
|
|
* as node drivers are actually resolvable (the `superpowers` plugin, today).
|
|
* 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 { 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());
|
|
add(...skillPluginStatus());
|
|
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* WARN (never FAIL) when a plugin the pipelines depend on cannot be resolved:
|
|
* the dashboard, hooks, CLI and MCP all work without it — only the pipeline
|
|
* skills that drive it stop partway, so this must not fail a doctor run.
|
|
*
|
|
* The requirement derivation and the missing/satisfied rules live in
|
|
* `server/lib/skill-plugins.js`, shared with the bootstrap that installs them.
|
|
*/
|
|
function skillPluginStatus() {
|
|
const skillPlugins = require("../server/lib/skill-plugins");
|
|
const required = skillPlugins.requiredSkillPlugins();
|
|
if (!required.size) return ["ok", "Skill plugins", "no pipeline names an external plugin"];
|
|
|
|
let missing;
|
|
try {
|
|
missing = skillPlugins.missingSkillPlugins();
|
|
} catch {
|
|
return ["warn", "Skill plugins", "could not read the installed-plugin manifest"];
|
|
}
|
|
if (!missing.length) {
|
|
return ["ok", "Skill plugins", `${[...required.keys()].sort().join(", ")} available`];
|
|
}
|
|
return [
|
|
"warn",
|
|
"Skill plugins",
|
|
`${missing.map((m) => `${m.name} (needed for ${m.skills.join(", ")})`).join("; ")} — ` +
|
|
`the pipelines name these commands; install with ` +
|
|
`claude plugin install ${missing.map((m) => m.autoInstall?.spec || m.name).join(" ")}`,
|
|
];
|
|
}
|
|
|
|
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 };
|