feat(plugins): make CCAM installable straight from a Claude Code plugin
Adds a root `ccam` plugin (`.claude-plugin/plugin.json`, `"source": "./"`) so
`/plugin marketplace add` + `/plugin install ccam@...` is enough on a machine
with nothing but Claude Code: no clone, no npm run setup, no manual npm start.
- scripts/plugin-bootstrap.js: SessionStart hook. Fast-path exit, Node >=22.5
gate (node:sqlite), mkdir lock with stale reclaim, deps installed into
~/.claude/agent-dashboard/runtime/ (never the plugin cache), legacy
checkout-hook cleanup (backed up), ~/.local/bin/ccam launcher, eager UI
build so client routes like /run work immediately, detached server spawn.
- scripts/plugin-open.js, scripts/plugin-doctor.js: /ccam-open, /ccam-doctor.
- server/index.js: DASHBOARD_CLIENT_DIST override (plugin cache is read-only).
- mcp/build/ is committed (plugin MCP servers start before any bootstrap could
build them) and kept honest by scripts/check-mcp-build.js (content hash,
not mtime), enforced by pre-commit when mcp/src changes.
- plugins/ccam-dashboard/.mcp.json moved under plugins/ccam/ with a working
${CLAUDE_PLUGIN_ROOT} path (the old relative path never resolved from a
marketplace-cached subdir).
- Docs: README, INSTALL, SETUP, ARCHITECTURE, CLAUDE.md, docs/PLUGINS.md,
docs/MCP.md, docs/CLI.md, docs/HOOKS.md.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* @file check-mcp-build.js
|
||||
* @description `mcp/build/` is committed so a plugin install has a working MCP
|
||||
* server the instant the session opens — Claude Code starts plugin MCP servers
|
||||
* immediately and offers no "not ready yet" retry, so an async bootstrap cannot
|
||||
* win that race. The cost is that the artifact can drift from `mcp/src`.
|
||||
*
|
||||
* Freshness is tracked by hashing the CONTENT of `mcp/src` (plus the manifests
|
||||
* and tsconfig) into `mcp/build/.srchash`. Modification times are useless here:
|
||||
* a fresh clone or checkout stamps every file with the same time in arbitrary
|
||||
* order.
|
||||
*
|
||||
* Run by the pre-commit hook and by `/ccam-doctor`.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const crypto = require("crypto");
|
||||
|
||||
const HASH_FILE = ".srchash";
|
||||
|
||||
/** Every file whose content the build depends on, in a stable order. */
|
||||
function sourceFiles(mcpDir) {
|
||||
const files = [];
|
||||
const walk = (dir) => {
|
||||
let entries;
|
||||
try {
|
||||
entries = fs
|
||||
.readdirSync(dir, { withFileTypes: true })
|
||||
.sort((a, b) => (a.name < b.name ? -1 : 1));
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const e of entries) {
|
||||
const full = path.join(dir, e.name);
|
||||
if (e.isDirectory()) walk(full);
|
||||
else files.push(full);
|
||||
}
|
||||
};
|
||||
walk(path.join(mcpDir, "src"));
|
||||
for (const f of ["package.json", "package-lock.json", "tsconfig.json"]) {
|
||||
const full = path.join(mcpDir, f);
|
||||
if (fs.existsSync(full)) files.push(full);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
function sourceHash(root = path.resolve(__dirname, "..")) {
|
||||
const mcpDir = path.join(root, "mcp");
|
||||
const h = crypto.createHash("sha256");
|
||||
for (const f of sourceFiles(mcpDir)) {
|
||||
h.update(path.relative(mcpDir, f).replace(/\\/g, "/"));
|
||||
h.update(fs.readFileSync(f));
|
||||
}
|
||||
return h.digest("hex");
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {{ok:boolean, reason:string, expected:string, recorded:string|null}}
|
||||
*/
|
||||
function mcpBuildStatus(root = path.resolve(__dirname, "..")) {
|
||||
const buildDir = path.join(root, "mcp", "build");
|
||||
const expected = sourceHash(root);
|
||||
if (!fs.existsSync(path.join(buildDir, "index.js"))) {
|
||||
return { ok: false, reason: "mcp/build/index.js is missing", expected, recorded: null };
|
||||
}
|
||||
let recorded = null;
|
||||
try {
|
||||
recorded = fs.readFileSync(path.join(buildDir, HASH_FILE), "utf8").trim();
|
||||
} catch {
|
||||
return { ok: false, reason: "mcp/build has no recorded source hash", expected, recorded: null };
|
||||
}
|
||||
return recorded === expected
|
||||
? { ok: true, reason: "up to date", expected, recorded }
|
||||
: {
|
||||
ok: false,
|
||||
reason: "mcp/build is stale — mcp/src has changed since it was built",
|
||||
expected,
|
||||
recorded,
|
||||
};
|
||||
}
|
||||
|
||||
/** Stamp the current source hash into the build directory. */
|
||||
function writeHash(root = path.resolve(__dirname, "..")) {
|
||||
const buildDir = path.join(root, "mcp", "build");
|
||||
fs.mkdirSync(buildDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(buildDir, HASH_FILE), sourceHash(root) + "\n", "utf8");
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
if (process.argv.includes("--write")) {
|
||||
writeHash();
|
||||
console.log("mcp/build/.srchash updated");
|
||||
} else {
|
||||
const status = mcpBuildStatus();
|
||||
if (status.ok) {
|
||||
console.log("mcp/build is up to date");
|
||||
} else {
|
||||
console.error(`${status.reason}\nRun: npm run mcp:build`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { sourceHash, mcpBuildStatus, writeHash };
|
||||
@@ -14,6 +14,23 @@ const { getSettingsPath } = require("../server/lib/claude-home");
|
||||
const SETTINGS_PATH = getSettingsPath();
|
||||
const HOOK_HANDLER = path.resolve(__dirname, "hook-handler.js").replace(/\\/g, "/");
|
||||
|
||||
/**
|
||||
* True when the plugin bootstrap has run on this machine — i.e. the `ccam`
|
||||
* plugin is (or was) installed and supplies its own hook entries.
|
||||
* Read-only and never throws; `plugin-bootstrap.js` is not required here to
|
||||
* keep this module free of a circular dependency.
|
||||
*
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function pluginBootstrapRan() {
|
||||
try {
|
||||
const { getDataDir } = require("../server/lib/claude-home");
|
||||
return fs.existsSync(path.join(getDataDir(), "runtime", "state.json"));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function envFlag(name) {
|
||||
return ["1", "true", "yes", "on"].includes(String(process.env[name] || "").toLowerCase());
|
||||
}
|
||||
@@ -126,6 +143,17 @@ function installHooks(silent = false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// The `ccam` plugin installs the same eight hooks itself. Running both means
|
||||
// every event is POSTed twice and token/cost figures double — warn loudly
|
||||
// rather than silently double-count. (`/ccam-doctor` reports the same state.)
|
||||
if (!silent && pluginBootstrapRan()) {
|
||||
console.warn(
|
||||
"WARNING: the ccam plugin is installed and already provides these hooks.\n" +
|
||||
" Installing them again double-counts every event. Uninstall the\n" +
|
||||
" plugin first, or skip this step. Run /ccam-doctor to check."
|
||||
);
|
||||
}
|
||||
|
||||
let settings = {};
|
||||
if (fs.existsSync(SETTINGS_PATH)) {
|
||||
try {
|
||||
@@ -176,4 +204,6 @@ if (require.main === module) {
|
||||
if (!installHooks(false)) process.exitCode = 1;
|
||||
}
|
||||
|
||||
module.exports = { installHooks, isInsideContainer };
|
||||
// isOurEntry is also used by scripts/plugin-bootstrap.js to strip hook entries
|
||||
// a previous checkout install left behind (they would double-count events).
|
||||
module.exports = { installHooks, isInsideContainer, isOurEntry };
|
||||
|
||||
@@ -0,0 +1,508 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* @file plugin-bootstrap.js
|
||||
* @description Makes CCAM usable straight from a Claude Code plugin install.
|
||||
* Runs from the plugin's `SessionStart` hook and must NEVER block a session:
|
||||
* the foreground pass only checks a fast path, then hands the real work to a
|
||||
* detached worker copy of itself.
|
||||
*
|
||||
* The worker installs runtime dependencies, removes hook entries left behind by
|
||||
* an older `npm run install-hooks` (they would double-count every event), puts
|
||||
* the `ccam` CLI on PATH, and starts the dashboard server detached.
|
||||
*
|
||||
* Everything writable lives under `~/.claude/agent-dashboard/runtime/` — NOT in
|
||||
* the plugin cache, which Claude Code garbage-collects and replaces wholesale
|
||||
* on every plugin update.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const fs = require("fs");
|
||||
const os = require("os");
|
||||
const path = require("path");
|
||||
const crypto = require("crypto");
|
||||
const { spawn, spawnSync } = require("child_process");
|
||||
|
||||
const { getDataDir, getClaudeHome, getSettingsPath } = require("../server/lib/claude-home");
|
||||
const { resolveAllDashboardPorts, getServerInfoPath } = require("../server/lib/server-info");
|
||||
const { isOurEntry } = require("./install-hooks");
|
||||
|
||||
const PLUGIN_ROOT = path.resolve(__dirname, "..");
|
||||
// node:sqlite — the only SQLite driver a `--omit=dev --ignore-scripts` install
|
||||
// leaves us with, since better-sqlite3 is not a runtime dependency and its
|
||||
// native addon would need a build step.
|
||||
const MIN_NODE = [22, 5, 0];
|
||||
const LOCK_STALE_MS = 10 * 60 * 1000;
|
||||
|
||||
function runtimeDir() {
|
||||
return path.join(getDataDir(), "runtime");
|
||||
}
|
||||
|
||||
function pluginVersion() {
|
||||
try {
|
||||
return require(path.join(PLUGIN_ROOT, "package.json")).version;
|
||||
} catch {
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ state */
|
||||
|
||||
function statePath(rt = runtimeDir()) {
|
||||
return path.join(rt, "state.json");
|
||||
}
|
||||
|
||||
function readState(rt = runtimeDir()) {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(statePath(rt), "utf8"));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeState(state, rt = runtimeDir()) {
|
||||
fs.mkdirSync(rt, { recursive: true });
|
||||
fs.writeFileSync(statePath(rt), JSON.stringify(state, null, 2) + "\n", "utf8");
|
||||
}
|
||||
|
||||
/** A dashboard server that is actually listening (the discovery file is PID-checked). */
|
||||
function serverIsLive() {
|
||||
try {
|
||||
return resolveAllDashboardPorts().length > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Hash of the dependency manifest — changes mean the runtime tree must be reinstalled. */
|
||||
function depsHash(root = PLUGIN_ROOT) {
|
||||
const h = crypto.createHash("sha256");
|
||||
for (const f of ["package.json", "package-lock.json"]) {
|
||||
try {
|
||||
h.update(fs.readFileSync(path.join(root, f)));
|
||||
} catch {
|
||||
h.update(f); // absent counts as its own state
|
||||
}
|
||||
}
|
||||
return h.digest("hex").slice(0, 16);
|
||||
}
|
||||
|
||||
/**
|
||||
* Nothing to do when the recorded state matches this plugin build AND a server
|
||||
* is already up. Deliberately cheap: this runs on every single SessionStart.
|
||||
*/
|
||||
function fastPathOk(rt = runtimeDir()) {
|
||||
const state = readState(rt);
|
||||
if (!state) return false;
|
||||
if (state.pluginVersion !== pluginVersion()) return false;
|
||||
// A plugin update lands in a NEW cache directory; the server still running
|
||||
// from the old one has to be replaced, so this is not a fast path.
|
||||
if (state.pluginRoot !== PLUGIN_ROOT) return false;
|
||||
if (state.depsHash !== depsHash()) return false;
|
||||
if (!fs.existsSync(path.join(rt, "node_modules"))) return false;
|
||||
return serverIsLive();
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------- node version gate */
|
||||
|
||||
function nodeVersionOk(version = process.versions.node) {
|
||||
const parts = String(version)
|
||||
.split(".")
|
||||
.map((n) => parseInt(n, 10) || 0);
|
||||
for (let i = 0; i < MIN_NODE.length; i++) {
|
||||
if (parts[i] > MIN_NODE[i]) return true;
|
||||
if (parts[i] < MIN_NODE[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------- lock */
|
||||
|
||||
function lockPath(rt = runtimeDir()) {
|
||||
return path.join(rt, ".bootstrap.lock");
|
||||
}
|
||||
|
||||
function isPidAlive(pid) {
|
||||
if (!pid) return false;
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch (err) {
|
||||
return err.code === "EPERM";
|
||||
}
|
||||
}
|
||||
|
||||
function lockIsStale(dir, now = Date.now()) {
|
||||
let stat;
|
||||
try {
|
||||
stat = fs.statSync(dir);
|
||||
} catch {
|
||||
return false; // gone already
|
||||
}
|
||||
if (now - stat.mtimeMs > LOCK_STALE_MS) return true;
|
||||
let pid = 0;
|
||||
try {
|
||||
pid = parseInt(fs.readFileSync(path.join(dir, "pid"), "utf8").trim(), 10);
|
||||
} catch {
|
||||
return true; // lock dir without a readable pid is debris
|
||||
}
|
||||
return !isPidAlive(pid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomic `mkdir` lock so two sessions starting at once cannot race the install
|
||||
* or spawn two servers (the second would die on EADDRINUSE). A lock whose owner
|
||||
* is dead, or older than LOCK_STALE_MS, is reclaimed.
|
||||
*
|
||||
* @returns {boolean} true when this process owns the lock
|
||||
*/
|
||||
function acquireLock(rt = runtimeDir()) {
|
||||
const dir = lockPath(rt);
|
||||
fs.mkdirSync(rt, { recursive: true });
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
try {
|
||||
fs.mkdirSync(dir);
|
||||
fs.writeFileSync(path.join(dir, "pid"), String(process.pid), "utf8");
|
||||
return true;
|
||||
} catch (err) {
|
||||
if (err.code !== "EEXIST") return false;
|
||||
if (!lockIsStale(dir)) return false;
|
||||
try {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function releaseLock(rt = runtimeDir()) {
|
||||
try {
|
||||
fs.rmSync(lockPath(rt), { recursive: true, force: true });
|
||||
} catch {
|
||||
/* best effort */
|
||||
}
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------- legacy cleanup */
|
||||
|
||||
/**
|
||||
* Remove hook entries a previous `npm run install-hooks` wrote into
|
||||
* ~/.claude/settings.json. The plugin installs its own hooks, and events carry
|
||||
* no id — two handlers mean every token and cost figure is counted twice.
|
||||
* The original file is copied aside before any write.
|
||||
*
|
||||
* @returns {number} how many entries were removed
|
||||
*/
|
||||
function stripLegacyHooks(settingsPath = getSettingsPath()) {
|
||||
let settings;
|
||||
try {
|
||||
settings = JSON.parse(fs.readFileSync(settingsPath, "utf8"));
|
||||
} catch {
|
||||
return 0; // no settings file, or not ours to touch
|
||||
}
|
||||
if (!settings.hooks || typeof settings.hooks !== "object") return 0;
|
||||
|
||||
let removed = 0;
|
||||
for (const [type, entries] of Object.entries(settings.hooks)) {
|
||||
if (!Array.isArray(entries)) continue;
|
||||
const kept = entries.filter((e) => !isOurEntry(e));
|
||||
removed += entries.length - kept.length;
|
||||
if (kept.length) settings.hooks[type] = kept;
|
||||
else delete settings.hooks[type];
|
||||
}
|
||||
if (!removed) return 0;
|
||||
|
||||
fs.copyFileSync(settingsPath, `${settingsPath}.ccam-bak`);
|
||||
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n", "utf8");
|
||||
return removed;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ CLI on PATH */
|
||||
|
||||
function cliDir() {
|
||||
return path.join(os.homedir(), ".local", "bin");
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a launcher for `ccam` into ~/.local/bin. A launcher rather than a
|
||||
* symlink into the plugin cache: that directory is replaced on every plugin
|
||||
* update, which would leave a dangling link until the next session.
|
||||
*
|
||||
* Never overwrites a `ccam` this bootstrap did not write — a developer's
|
||||
* `npm link`ed checkout CLI must keep winning.
|
||||
*
|
||||
* @returns {"written"|"exists"|"foreign"|"failed"}
|
||||
*/
|
||||
function linkCli(dir = cliDir(), root = PLUGIN_ROOT) {
|
||||
const marker = "# ccam-plugin-launcher";
|
||||
const target = path.join(dir, process.platform === "win32" ? "ccam.cmd" : "ccam");
|
||||
const body =
|
||||
process.platform === "win32"
|
||||
? `@rem ccam-plugin-launcher\r\n@node "${path.join(root, "bin", "ccam.js")}" %*\r\n`
|
||||
: `#!/bin/sh\n${marker}\nexec "${process.execPath}" "${path.join(root, "bin", "ccam.js")}" "$@"\n`;
|
||||
try {
|
||||
if (fs.existsSync(target)) {
|
||||
const current = fs.readFileSync(target, "utf8");
|
||||
if (!current.includes("ccam-plugin-launcher")) return "foreign";
|
||||
if (current === body) return "exists";
|
||||
}
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(target, body, "utf8");
|
||||
fs.chmodSync(target, 0o755);
|
||||
return "written";
|
||||
} catch {
|
||||
return "failed";
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------- dependencies */
|
||||
|
||||
/**
|
||||
* Install runtime dependencies into the runtime dir. `--ignore-scripts` keeps
|
||||
* the root postinstall from pulling the whole Vite client toolchain; the
|
||||
* manifests are copied out of the (read-only) plugin cache so npm has a project
|
||||
* to install for.
|
||||
*/
|
||||
function installDeps(rt = runtimeDir(), root = PLUGIN_ROOT) {
|
||||
fs.mkdirSync(rt, { recursive: true });
|
||||
for (const f of ["package.json", "package-lock.json"]) {
|
||||
const src = path.join(root, f);
|
||||
if (fs.existsSync(src)) fs.copyFileSync(src, path.join(rt, f));
|
||||
}
|
||||
const npm = process.platform === "win32" ? "npm.cmd" : "npm";
|
||||
const res = spawnSync(
|
||||
npm,
|
||||
["install", "--omit=dev", "--ignore-scripts", "--no-audit", "--no-fund"],
|
||||
{ cwd: rt, stdio: "inherit", shell: process.platform === "win32" }
|
||||
);
|
||||
return res.status === 0;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ server */
|
||||
|
||||
/**
|
||||
* PIDs of dashboard servers recorded in the discovery file that are still
|
||||
* running. Reads the file directly because server-info exposes ports only.
|
||||
*
|
||||
* @returns {number[]}
|
||||
*/
|
||||
function livePids() {
|
||||
try {
|
||||
const parsed = JSON.parse(fs.readFileSync(getServerInfoPath(), "utf8"));
|
||||
const servers = Array.isArray(parsed.servers) ? parsed.servers : [parsed];
|
||||
return servers.map((s) => s && s.pid).filter((pid) => isPidAlive(pid));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop running dashboard servers. Needed after a plugin update: the old server
|
||||
* runs from a cache directory Claude Code has already replaced, so it must be
|
||||
* retired before the new one can take the port.
|
||||
*
|
||||
* @returns {number} how many processes were signalled
|
||||
*/
|
||||
function stopDashboard() {
|
||||
let stopped = 0;
|
||||
for (const pid of livePids()) {
|
||||
try {
|
||||
process.kill(pid, "SIGTERM");
|
||||
stopped++;
|
||||
} catch {
|
||||
/* already gone, or not ours to signal */
|
||||
}
|
||||
}
|
||||
return stopped;
|
||||
}
|
||||
|
||||
/**
|
||||
* Block until no dashboard PID is alive, so the replacement server does not
|
||||
* race the old one for the port. Bounded — the worker is detached, but it must
|
||||
* not hang forever if a process refuses to die.
|
||||
*/
|
||||
function waitForPortsFree(timeoutMs = 10000) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
const sleeper = new Int32Array(new SharedArrayBuffer(4));
|
||||
while (livePids().length && Date.now() < deadline) {
|
||||
Atomics.wait(sleeper, 0, 0, 200);
|
||||
}
|
||||
return livePids().length === 0;
|
||||
}
|
||||
|
||||
function startDashboard(rt = runtimeDir(), root = PLUGIN_ROOT) {
|
||||
const logFile = fs.openSync(path.join(rt, "server.log"), "a");
|
||||
const child = spawn(process.execPath, [path.join(root, "server", "index.js")], {
|
||||
cwd: root,
|
||||
detached: true,
|
||||
stdio: ["ignore", logFile, logFile],
|
||||
env: {
|
||||
...process.env,
|
||||
NODE_PATH: path.join(rt, "node_modules"),
|
||||
// The plugin cache is read-only; /ccam-open builds the bundle here.
|
||||
DASHBOARD_CLIENT_DIST: path.join(rt, "client-dist"),
|
||||
},
|
||||
});
|
||||
child.unref();
|
||||
return child.pid;
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------- worker run */
|
||||
|
||||
function log(rt, line) {
|
||||
const stamped = `[${new Date().toISOString()}] ${line}\n`;
|
||||
try {
|
||||
fs.appendFileSync(path.join(rt, "bootstrap.log"), stamped);
|
||||
} catch {
|
||||
/* best effort */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The real work. Runs detached from the SessionStart hook, so its only channel
|
||||
* is the log file; `/ccam-update` runs it in the foreground with `force`, which
|
||||
* reinstalls dependencies and restarts the server unconditionally.
|
||||
*
|
||||
* @returns {"ok"|"locked"|"node-too-old"|"install-failed"}
|
||||
*/
|
||||
function bootstrap(rt = runtimeDir(), { force = false } = {}) {
|
||||
fs.mkdirSync(rt, { recursive: true });
|
||||
|
||||
if (!nodeVersionOk()) {
|
||||
log(
|
||||
rt,
|
||||
`Node ${process.versions.node} is too old — CCAM needs Node >= ${MIN_NODE.join(".")} ` +
|
||||
`(the server stores data through node:sqlite). Upgrade Node, then start a new session.`
|
||||
);
|
||||
return "node-too-old";
|
||||
}
|
||||
|
||||
if (!acquireLock(rt)) {
|
||||
log(rt, "another bootstrap holds the lock — nothing to do");
|
||||
return "locked";
|
||||
}
|
||||
|
||||
try {
|
||||
const wantDeps = depsHash();
|
||||
const state = readState(rt) || {};
|
||||
if (force || state.depsHash !== wantDeps || !fs.existsSync(path.join(rt, "node_modules"))) {
|
||||
log(rt, "installing runtime dependencies (first run takes a few minutes)");
|
||||
if (!installDeps(rt)) {
|
||||
log(rt, "npm install failed — see the output above; run /ccam-doctor after fixing it");
|
||||
return "install-failed";
|
||||
}
|
||||
}
|
||||
|
||||
const removed = stripLegacyHooks();
|
||||
if (removed) {
|
||||
log(
|
||||
rt,
|
||||
`removed ${removed} hook entr${removed === 1 ? "y" : "ies"} left by npm run install-hooks ` +
|
||||
`(backup: ${getSettingsPath()}.ccam-bak) — the plugin installs its own`
|
||||
);
|
||||
}
|
||||
|
||||
log(rt, `ccam CLI launcher: ${linkCli()}`);
|
||||
|
||||
// Built eagerly (not lazily behind /ccam-open) so the dashboard, including
|
||||
// client-only routes like /run, works the instant `claude` is started —
|
||||
// same trigger as the dependency install above: missing, or this plugin
|
||||
// version has not built one yet.
|
||||
const { buildClient } = require("./plugin-open");
|
||||
const uiResult = buildClient({
|
||||
rt,
|
||||
root: PLUGIN_ROOT,
|
||||
force: force || state.depsHash !== wantDeps,
|
||||
logPath: path.join(rt, "client-build.log"),
|
||||
});
|
||||
if (uiResult === "failed") {
|
||||
log(rt, "dashboard UI build failed — API and MCP tools still work; run /ccam-open to retry");
|
||||
} else if (uiResult === "built") {
|
||||
log(rt, "built the dashboard UI bundle");
|
||||
}
|
||||
|
||||
// After a plugin update the running server executes code from a cache
|
||||
// directory Claude Code has already discarded — retire it first.
|
||||
const movedRoot = force || (state.pluginRoot && state.pluginRoot !== PLUGIN_ROOT);
|
||||
if (movedRoot && stopDashboard()) {
|
||||
log(rt, "stopped the server started from the previous plugin version");
|
||||
waitForPortsFree();
|
||||
}
|
||||
|
||||
if (movedRoot || !serverIsLive()) {
|
||||
const pid = startDashboard(rt);
|
||||
log(rt, `started the dashboard server (pid ${pid})`);
|
||||
}
|
||||
|
||||
writeState(
|
||||
{
|
||||
pluginVersion: pluginVersion(),
|
||||
depsHash: wantDeps,
|
||||
pluginRoot: PLUGIN_ROOT,
|
||||
claudeHome: getClaudeHome(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
rt
|
||||
);
|
||||
return "ok";
|
||||
} finally {
|
||||
releaseLock(rt);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Foreground pass, invoked by the SessionStart hook. Returns within
|
||||
* milliseconds in the steady state, and otherwise hands off to a detached
|
||||
* worker so the session never waits on an install.
|
||||
*/
|
||||
function main() {
|
||||
if (fastPathOk()) return;
|
||||
const child = spawn(process.execPath, [__filename, "--worker"], {
|
||||
detached: true,
|
||||
stdio: "ignore",
|
||||
});
|
||||
child.unref();
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
const force = process.argv.includes("--force");
|
||||
if (force || process.argv.includes("--worker")) {
|
||||
// `--force` (from /ccam-update) runs in the foreground so the user sees the
|
||||
// npm output and the result.
|
||||
const result = bootstrap(runtimeDir(), { force });
|
||||
if (force) {
|
||||
console.log(result === "ok" ? "CCAM runtime refreshed." : `bootstrap: ${result}`);
|
||||
if (result !== "ok") process.exitCode = 1;
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
main();
|
||||
} catch {
|
||||
// A bootstrap failure must never break a Claude Code session.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
MIN_NODE,
|
||||
LOCK_STALE_MS,
|
||||
runtimeDir,
|
||||
statePath,
|
||||
readState,
|
||||
writeState,
|
||||
depsHash,
|
||||
fastPathOk,
|
||||
nodeVersionOk,
|
||||
lockPath,
|
||||
lockIsStale,
|
||||
acquireLock,
|
||||
releaseLock,
|
||||
stripLegacyHooks,
|
||||
linkCli,
|
||||
installDeps,
|
||||
livePids,
|
||||
stopDashboard,
|
||||
startDashboard,
|
||||
bootstrap,
|
||||
};
|
||||
@@ -0,0 +1,147 @@
|
||||
#!/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 { resolveAllDashboardPorts } = require("../server/lib/server-info");
|
||||
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 ports = (() => {
|
||||
try {
|
||||
return resolveAllDashboardPorts();
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
})();
|
||||
add(
|
||||
ports.length ? "ok" : "fail",
|
||||
"Server",
|
||||
ports.length
|
||||
? `listening on ${ports.map((p) => `http://localhost:${p}`).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 };
|
||||
@@ -0,0 +1,87 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* @file plugin-open.js
|
||||
* @description Builds the dashboard UI bundle for a plugin install and prints
|
||||
* the dashboard URL. `scripts/plugin-bootstrap.js` calls `buildClient` eagerly
|
||||
* on session start, so client-only routes (e.g. `/run`) work the moment
|
||||
* `claude` is started, not just the API and MCP tools. `/ccam-open` re-runs
|
||||
* this standalone — a no-op when the bundle already matches — as a fallback
|
||||
* for `--force` rebuilds or a first build that failed during bootstrap.
|
||||
*
|
||||
* The client source is copied out of the (replaced-on-update) plugin cache into
|
||||
* the runtime dir before building, so neither the cache nor the checkout is
|
||||
* written to, and the output lands where the server already serves from
|
||||
* (`DASHBOARD_CLIENT_DIST`).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { spawnSync } = require("child_process");
|
||||
|
||||
const boot = require("./plugin-bootstrap");
|
||||
const { resolveDashboardPort } = require("../server/lib/server-info");
|
||||
|
||||
const PLUGIN_ROOT = path.resolve(__dirname, "..");
|
||||
const NPM = process.platform === "win32" ? "npm.cmd" : "npm";
|
||||
|
||||
function run(cmd, args, cwd, stdio) {
|
||||
const res = spawnSync(cmd, args, { cwd, stdio, shell: process.platform === "win32" });
|
||||
return res.status === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the bundle into `<runtime>/client-dist`. Idempotent: an existing bundle
|
||||
* is left alone unless `force` is set.
|
||||
*
|
||||
* `logPath`, when given, redirects npm's output there instead of inheriting the
|
||||
* caller's stdio — used when this runs inside the detached bootstrap worker,
|
||||
* whose own stdout is discarded, so a build failure is still debuggable.
|
||||
*
|
||||
* @returns {"ready"|"built"|"failed"}
|
||||
*/
|
||||
function buildClient({ rt = boot.runtimeDir(), root = PLUGIN_ROOT, force = false, logPath } = {}) {
|
||||
const dist = path.join(rt, "client-dist");
|
||||
if (!force && fs.existsSync(path.join(dist, "index.html"))) return "ready";
|
||||
|
||||
const logFd = logPath ? fs.openSync(logPath, "a") : null;
|
||||
const stdio = logFd === null ? "inherit" : ["ignore", logFd, logFd];
|
||||
try {
|
||||
const src = path.join(rt, "client-src");
|
||||
fs.rmSync(src, { recursive: true, force: true });
|
||||
fs.cpSync(path.join(root, "client"), src, {
|
||||
recursive: true,
|
||||
filter: (p) => !/[\\/](node_modules|dist)$/.test(p),
|
||||
});
|
||||
|
||||
if (!run(NPM, ["install", "--no-audit", "--no-fund"], src, stdio)) return "failed";
|
||||
if (!run(NPM, ["run", "build"], src, stdio)) return "failed";
|
||||
|
||||
fs.rmSync(dist, { recursive: true, force: true });
|
||||
fs.cpSync(path.join(src, "dist"), dist, { recursive: true });
|
||||
return "built";
|
||||
} finally {
|
||||
if (logFd !== null) fs.closeSync(logFd);
|
||||
}
|
||||
}
|
||||
|
||||
function dashboardUrl() {
|
||||
try {
|
||||
return `http://localhost:${resolveDashboardPort()}`;
|
||||
} catch {
|
||||
return "http://localhost:4820";
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
const result = buildClient({ force: process.argv.includes("--force") });
|
||||
if (result === "failed") {
|
||||
console.error("Client build failed — the API and MCP tools still work.");
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
if (result === "built") console.log("Dashboard UI built.");
|
||||
console.log(dashboardUrl());
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { buildClient, dashboardUrl };
|
||||
Reference in New Issue
Block a user