/** * @file Starts and reports on the "Update all" batch: every registered * marketplace, then every installed plugin, then a reload of the Claude Code * sessions the dashboard has open. * * Why a detached worker with an on-disk journal instead of a plain request * handler: the batch includes AgentHub ITSELF when the dashboard runs from the * plugin cache. Updating that plugin ends in `plugin-bootstrap.js --force`, * which SIGTERMs this very server, so an in-process loop would die partway * through with the remaining steps unrun and no way to report anything. The * worker outlives the swap, keeps going, and writes each step's outcome to * `cc-update-all.json` under the runtime directory — outside the plugin cache, * which the update replaces — so whichever server is alive can serve progress * from it. AgentHub is ordered LAST for the same reason: every other plugin is * already done by the time the restart happens. * * The reload step comes after all updates, not per-plugin, because that is what * "update everything, then reload" means for a session: one `/reload-plugins` * picks up every pending change at once, where N of them would just re-scan the * same directory N times. A single-plugin update reloads immediately instead — * see the `reload` flag on `POST /api/cc-config/plugins/action`. * @author Nguyễn Ngọc Trí Vĩ */ const fs = require("fs"); const path = require("path"); const { spawn } = require("child_process"); const { getDataDir } = require("./claude-home"); const JOURNAL_NAME = "cc-update-all.json"; /** A batch still "running" after this long is treated as abandoned, so a worker * killed by a reboot can't wedge the button forever. */ const STALE_MS = 60 * 60 * 1000; function runtimeDir() { return path.join(getDataDir(), "runtime"); } function journalPath(rt = runtimeDir()) { return path.join(rt, JOURNAL_NAME); } /** * Current (or last) batch state, or null when none has ever run. * * A `running` journal older than `STALE_MS` is reported as `abandoned` rather * than `running`: the worker is detached, so a host reboot leaves no one to * write the terminal entry and the UI would otherwise show a spinner forever. * * @returns {{status: string, steps: object[], started_at?: string, * finished_at?: string} | null} */ function readUpdateAll(rt = runtimeDir()) { let parsed; try { parsed = JSON.parse(fs.readFileSync(journalPath(rt), "utf8")); } catch { return null; // never run, unreadable, or malformed — all "nothing to report" } if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.steps)) return null; if (parsed.status === "running") { const started = Date.parse(parsed.started_at || ""); if (Number.isFinite(started) && Date.now() - started > STALE_MS) { return { ...parsed, status: "abandoned" }; } } return parsed; } /** True when a batch is in flight — used to refuse a concurrent start rather * than run two `claude plugin update` chains over the same config dir. */ function isRunning(rt = runtimeDir()) { return readUpdateAll(rt)?.status === "running"; } /** * Spawn the detached batch worker. * * The caller must have written its HTTP response first: when `selfPlugin` is * set, this process is going to be stopped before the batch finishes. * * @param {{plugins: string[], selfPlugin: {marketplace: string, plugin: string}|null, * selfRoot: string|null, reload: boolean}} plan * `plugins` are `name@marketplace` ids, already validated by the caller. * @returns {{spawned: true, runtime_dir: string}} */ function startUpdateAll(plan) { const rt = runtimeDir(); fs.mkdirSync(rt, { recursive: true }); // Clear the previous batch so a stale terminal state is never read as this // run's progress in the window before the worker writes its first entry. try { fs.rmSync(journalPath(rt), { force: true }); } catch { /* best effort */ } const helper = path.join(__dirname, "..", "..", "scripts", "cc-update-all-helper.js"); const child = spawn(process.execPath, [helper, rt, JSON.stringify(plan)], { // Never the plugin root: when selfPlugin is set that directory is replaced // mid-run, and a cwd inside a replaced directory can wedge the process. cwd: rt, detached: true, stdio: "ignore", env: process.env, }); child.unref(); return { spawned: true, runtime_dir: rt }; } module.exports = { startUpdateAll, readUpdateAll, isRunning, runtimeDir, journalPath, STALE_MS };