#!/usr/bin/env node /** * @file Detached worker for the dashboard's "Update all" button. Runs the whole * batch and writes each step's outcome to `cc-update-all.json` in the runtime * directory as it goes, so the UI can follow along by polling. * * Order, and why: * 1. `claude plugin marketplace update` (no name = every marketplace) — a * plugin update can only see a version its marketplace already knows about. * 2. `claude plugin update -y` for every installed plugin EXCEPT the one * the dashboard itself runs from. * 3. That one last, followed by `plugin-bootstrap.js --force` from the NEW * cache directory — this stops the dashboard server and starts the * replacement. Anything ordered after it would run while the server is * down, which is exactly why it is last. * 4. `/reload-plugins` + `/reload-skills` typed into the live lane panes. * Deliberately after everything: one reload picks up every pending change. * * Spawned detached precisely because step 3 kills its parent. It must never * throw its way out without a terminal journal entry — a silent death would * leave the UI polling a batch that says "running" forever. * @author Nguyễn Ngọc Trí Vĩ */ const fs = require("fs"); const path = require("path"); const { execFileSync, spawnSync } = require("child_process"); const [, , runtimeDirArg, planJson] = process.argv; const JOURNAL = path.join(runtimeDirArg || ".", "cc-update-all.json"); const LOG_FILE = path.join(runtimeDirArg || ".", "cc-update-all.log"); // Ids reach an argv, so they are re-validated here rather than trusted across // the process boundary — same reasoning as scripts/plugin-update-helper.js. const PLUGIN_SPEC_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*(@[A-Za-z0-9][A-Za-z0-9._-]*)?$/; const SAFE_NAME = /^[A-Za-z0-9][\w.-]*$/; const SHELL_COMMANDS = new Set(["sh", "bash", "zsh", "fish", "dash", "ksh", "csh", "tcsh"]); /** @type {{status: string, started_at: string, finished_at?: string, steps: object[]}} */ const state = { status: "running", started_at: new Date().toISOString(), steps: [] }; function log(line) { try { fs.appendFileSync(LOG_FILE, `[${new Date().toISOString()}] ${line}\n`); } catch { /* best effort */ } } /** Persist after every transition — the UI polls this file, and the process can * be SIGTERMed at step 3 without warning. */ function flush() { try { fs.mkdirSync(runtimeDirArg, { recursive: true }); fs.writeFileSync(JOURNAL, JSON.stringify(state, null, 2)); } catch { /* best effort */ } } function addStep(key, label) { const step = { key, label, status: "running", started_at: new Date().toISOString() }; state.steps.push(step); flush(); return step; } function finishStep(step, status, extra = {}) { step.status = status; step.finished_at = new Date().toISOString(); Object.assign(step, extra); flush(); } function resolveClaude() { const candidates = ["claude", path.join(require("os").homedir(), ".local", "bin", "claude")]; for (const cmd of candidates) { const probe = spawnSync(cmd, ["--version"], { stdio: "ignore", timeout: 20_000 }); if (!probe.error && probe.status === 0) return cmd; } return null; } function run(cmd, args, timeout) { try { return runOnce(cmd, args, timeout); } catch (err) { // An older Claude Code rejects `-y` ("unknown option") and never prompts, // so the same command without the flag is the right retry. if (!args.includes("-y") || !/unknown option '-y'/.test(String(err.stderr || ""))) throw err; return runOnce( cmd, args.filter((a) => a !== "-y"), timeout ); } } function runOnce(cmd, args, timeout) { log(`$ ${cmd} ${args.join(" ")}`); const out = execFileSync(cmd, args, { encoding: "utf8", timeout, maxBuffer: 10_000_000, stdio: ["ignore", "pipe", "pipe"], }); if (out) log(out.trim().slice(0, 2000)); return out; } /** Newest cache dir for the self plugin, excluding the one being replaced. */ function findNewRoot(marketplace, plugin, exclude) { const base = path.join( require("os").homedir(), ".claude", "plugins", "cache", marketplace, plugin ); let entries; try { entries = fs.readdirSync(base, { withFileTypes: true }).filter((e) => e.isDirectory()); } catch { return null; } const candidates = entries .map((e) => path.join(base, e.name)) .filter((p) => p !== exclude) .filter((p) => fs.existsSync(path.join(p, "scripts", "plugin-bootstrap.js"))) .map((p) => ({ p, mtime: fs.statSync(p).mtimeMs })) .sort((a, b) => b.mtime - a.mtime); return candidates.length ? candidates[0].p : null; } /** * Type the reload commands into every live `claude` lane pane. Uses tmux * directly rather than the server's lib: by this point the server may be a * different process than the one that started the batch. */ function reloadPanes() { const step = addStep("reload", "Reload open Claude Code sessions"); let names; try { const out = execFileSync("tmux", ["list-sessions", "-F", "#{session_name}"], { encoding: "utf8", timeout: 15_000, stdio: ["ignore", "pipe", "pipe"], }); names = out .split("\n") .map((s) => s.trim()) .filter((s) => s.startsWith("agenthub-lane-")); } catch { // No tmux server at all means no open sessions — nothing to reload, which // is a normal outcome and not a batch failure. finishStep(step, "skipped", { note: "no tmux sessions are open" }); return; } const reloaded = []; for (const name of names) { let paneCmd = null; try { paneCmd = execFileSync( "tmux", ["display-message", "-p", "-t", name, "#{pane_current_command}"], { encoding: "utf8", timeout: 15_000, stdio: ["ignore", "pipe", "pipe"] } ).trim(); } catch { continue; // pane vanished between listing and probe } // A pane at a shell prompt has no REPL to reload; the text would be run as // a shell command instead. if (SHELL_COMMANDS.has(paneCmd)) continue; try { let first = true; for (const command of ["/reload-plugins", "/reload-skills"]) { // Let the first command actually submit before typing the second, or // both land on one composer line and Claude Code runs the pair as // `/reload-plugins /reload-skills` — see SETTLE_MS in // server/lib/cc-reload.js. Blocking is fine here: this worker is // detached and serves no requests. if (!first) execFileSync("sleep", ["1"], { timeout: 15_000 }); first = false; execFileSync("tmux", ["send-keys", "-t", name, "-l", command], { timeout: 15_000 }); execFileSync("tmux", ["send-keys", "-t", name, "Enter"], { timeout: 15_000 }); } reloaded.push(name); } catch { /* pane died mid-send; the rest of the batch stands */ } } finishStep(step, "ok", { lanes: reloaded, lanes_total: reloaded.length }); } function main() { let plan; try { plan = JSON.parse(planJson); } catch { state.status = "failed"; state.error = "unreadable plan"; state.finished_at = new Date().toISOString(); flush(); return; } const claude = resolveClaude(); if (!claude) { const step = addStep("resolve_claude", "Locate the claude CLI"); finishStep(step, "failed", { error: "the `claude` CLI is not on this server's PATH; run the update from a terminal", }); state.status = "failed"; state.finished_at = new Date().toISOString(); flush(); return; } // 1. Every marketplace, in one call (no name = all). const mktStep = addStep("marketplaces", "Update all marketplaces"); try { run(claude, ["plugin", "marketplace", "update"], 300_000); finishStep(mktStep, "ok"); } catch (err) { // A marketplace that can't be refreshed (offline, auth) does not stop the // batch: the plugins may still have updates from the marketplaces that did. finishStep(mktStep, "failed", { error: String(err.stderr || err.message).slice(0, 2000) }); } const self = plan.selfPlugin; const selfId = self ? `${self.plugin}@${self.marketplace}` : null; const others = (plan.plugins || []).filter((id) => PLUGIN_SPEC_RE.test(id) && id !== selfId); // 2. Every other plugin. for (const id of others) { const step = addStep(`plugin:${id}`, id); try { run(claude, ["plugin", "update", id, "-y"], 600_000); finishStep(step, "ok"); } catch (err) { finishStep(step, "failed", { error: String(err.stderr || err.message).slice(0, 2000) }); } } // 3. AgentHub itself, last — its bootstrap stops this server. if (self && SAFE_NAME.test(self.marketplace) && SAFE_NAME.test(self.plugin)) { const step = addStep(`plugin:${selfId}`, `${selfId} (this dashboard)`); try { run(claude, ["plugin", "update", selfId, "-y"], 600_000); } catch (err) { finishStep(step, "failed", { error: String(err.stderr || err.message).slice(0, 2000) }); reloadPanes(); state.status = "failed"; state.finished_at = new Date().toISOString(); flush(); return; } const newRoot = findNewRoot(self.marketplace, self.plugin, plan.selfRoot); if (!newRoot) { // Nothing newer was published: the running server is correct as-is. finishStep(step, "ok", { note: "already on the newest version; server left running" }); } else { // Mark the step done BEFORE the restart: bootstrap SIGTERMs us mid-call, // so a journal entry written afterwards may never be written at all. finishStep(step, "ok", { note: `restarting the dashboard from ${newRoot}`, restarted: true }); reloadPanes(); state.status = "done"; state.finished_at = new Date().toISOString(); flush(); try { run( process.execPath, [path.join(newRoot, "scripts", "plugin-bootstrap.js"), "--force"], 900_000 ); } catch (err) { log(`bootstrap failed: ${err.message}`); state.status = "failed"; state.bootstrap_error = String(err.stderr || err.message).slice(0, 2000); state.note = "the old server was stopped; run /agenthub-update to recover"; flush(); } return; } } // 4. Reload, once, after every update. if (plan.reload !== false) reloadPanes(); state.status = state.steps.some((s) => s.status === "failed") ? "partial" : "done"; state.finished_at = new Date().toISOString(); flush(); } try { main(); } catch (err) { // A worker that dies without a terminal entry leaves the UI polling forever. state.status = "failed"; state.error = err.message || String(err); state.finished_at = new Date().toISOString(); flush(); }