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).
519 lines
16 KiB
JavaScript
519 lines
16 KiB
JavaScript
#!/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 { 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 */
|
|
|
|
/**
|
|
* 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 */
|
|
|
|
/**
|
|
* 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,
|
|
stripLegacyHooks,
|
|
linkCli,
|
|
installDeps,
|
|
serverIsLive,
|
|
liveServers,
|
|
livePids,
|
|
stopDashboard,
|
|
startDashboard,
|
|
bootstrap,
|
|
};
|