#!/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ĩ */ 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 { 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() { // NOT resolveAllDashboardPorts(): it falls back to [DEFAULT_PORT] when the // discovery file has no live entry (a reasonable guess for the CLI/hook // handler), which would make bootstrap believe a server is already running // when none is. livePids() only counts entries with a PID that is actually // alive. return livePids().length > 0; } /** 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 */ /** * `claude plugin install` materializes the plugin's inline hooks into * `~/.claude/settings.json` itself, with `${CLAUDE_PLUGIN_ROOT}` already * resolved to the cache path — confirmed by installing this plugin for real * and inspecting the file. Those entries also contain `hook-handler.js`, so * `isOurEntry()` alone (checkout vs. plugin, both match the same substring) * cannot tell a legitimate plugin-installed hook from a leftover * `npm run install-hooks` entry. Only entries whose command does NOT resolve * under the plugin cache are the legacy, checkout-installed kind. * * @returns {(entry: object) => boolean} */ function isCheckoutHookEntry(claudeHome = getClaudeHome()) { const cacheRoot = path.join(claudeHome, "plugins", "cache"); return (entry) => { if (!isOurEntry(entry)) return false; const commands = [ entry.command, ...(Array.isArray(entry.hooks) ? entry.hooks.map((h) => h.command) : []), ]; return !commands.some((c) => typeof c === "string" && c.includes(cacheRoot)); }; } /** * 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. * Never touches the plugin's own cache-resolved entries (see * `isCheckoutHookEntry`) — only genuine leftover checkout paths. 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; const isLegacy = isCheckoutHookEntry(); let removed = 0; for (const [type, entries] of Object.entries(settings.hooks)) { if (!Array.isArray(entries)) continue; const kept = entries.filter((e) => !isLegacy(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 */ /** * Discovery-file entries whose PID is actually alive right now. Reads the file * directly rather than server-info's `resolveAllDashboardPorts()`, which falls * back to `[DEFAULT_PORT]` when nothing is live — a reasonable guess for the * CLI/hook handler, but wrong here: it would make the bootstrap believe a * server is already running when none is. * * @returns {{port:number, pid:number}[]} */ function liveServers() { try { const parsed = JSON.parse(fs.readFileSync(getServerInfoPath(), "utf8")); const servers = Array.isArray(parsed.servers) ? parsed.servers : [parsed]; return servers.filter((s) => s && isPidAlive(s.pid)); } catch { return []; } } function livePids() { return liveServers().map((s) => s.pid); } /** * 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, isCheckoutHookEntry, stripLegacyHooks, linkCli, installDeps, serverIsLive, liveServers, livePids, stopDashboard, startDashboard, bootstrap, };