/** * @file Launches and reports on the plugin-cache update chain — the path that * makes "Update now" work for an install running out of * `~/.claude/plugins/cache/` instead of a plain git checkout. * * All the work happens in the DETACHED `scripts/plugin-update-helper.js`, for a * structural reason: its last step runs `plugin-bootstrap.js --force`, which * SIGTERMs the dashboard server that asked for the update. An in-process * version would kill itself halfway through and leave no server to finish the * job or report what happened. Same split as `self-restart.js`, one step * further: there the helper only waits and respawns, here it also pulls the new * plugin version first. * * This module deliberately does NOT run `/reload-plugins`. That is a Claude Code * REPL action, and it re-resolves plugins only for the session that runs it — a * background process cannot perform it on an interactive session's behalf, and a * throwaway one-shot session would reload its own plugin view and exit. The * dashboard does not need it: `plugin-bootstrap.js` derives `PLUGIN_ROOT` from * its own `__dirname`, so being executed out of the new cache directory is * sufficient to move the server onto the new code. Only new * skills/commands/hooks in an already-open Claude Code session still require the * user to reload. * @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"); /** Longer than any one step's timeout in the helper (bootstrap: 15 min). */ const PROGRESS_STALE_MS = 20 * 60 * 1000; /** Runtime directory the helper writes its verdict into. Outside the plugin * cache on purpose — that directory is replaced by the update itself. */ function runtimeDir() { return path.join(getDataDir(), "runtime"); } /** * Result of the most recent plugin update attempt, or null when there has never * been one. Surfaced on the update-status payload as `last_plugin_update` so a * failed chain is visible instead of looking like a server that is merely slow * to come back. * * @returns {{ok: boolean, step: string, error?: string, note?: string, * plugin_root?: string, finished_at?: string} | null} */ function readLastPluginUpdate(rt = runtimeDir()) { try { const raw = fs.readFileSync(path.join(rt, "plugin-update.json"), "utf8"); const parsed = JSON.parse(raw); return parsed && typeof parsed === "object" ? parsed : null; } catch { return null; // never run, unreadable, or malformed — all "nothing to report" } } /** * The step a running chain is on (`plugin-update-progress.json`, written by the * helper as each step starts and removed with its final verdict), or null. * Surfaced as `plugin_update_progress` so the dashboard can show how far an * update got. A file older than the chain's longest run is left over from a * helper that died, and reads as nothing. * * @returns {{step: string, index: number, total: number, started_at: string} | null} */ function readPluginUpdateProgress(rt = runtimeDir()) { try { const parsed = JSON.parse( fs.readFileSync(path.join(rt, "plugin-update-progress.json"), "utf8") ); if (!parsed || typeof parsed.step !== "string") return null; const started = Date.parse(parsed.started_at); if (!Number.isFinite(started) || Date.now() - started > PROGRESS_STALE_MS) return null; return { step: parsed.step, index: Number(parsed.index) || 0, total: Number(parsed.total) || 0, started_at: parsed.started_at, }; } catch { return null; } } /** * Spawn the detached update chain. Returns immediately; the caller must have * already written its HTTP response, because this process is about to be * stopped by the helper's final step. * * Clears any previous verdict first, so a stale success can never be read back * as the outcome of THIS run while the chain is still in flight. * * @param {{marketplace: string, plugin: string, root: string}} install * @returns {{spawned: true, runtime_dir: string}} */ function schedulePluginUpdate({ marketplace, plugin, root }) { const rt = runtimeDir(); try { fs.mkdirSync(rt, { recursive: true }); fs.rmSync(path.join(rt, "plugin-update.json"), { force: true }); fs.rmSync(path.join(rt, "plugin-update-progress.json"), { force: true }); } catch { /* best effort — a stale verdict is better than failing the update */ } const helper = path.join(root, "scripts", "plugin-update-helper.js"); const child = spawn(process.execPath, [helper, marketplace, plugin, root, rt], { // cwd is the runtime dir, NOT the plugin root: the root is about to be // superseded, 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 = { schedulePluginUpdate, readLastPluginUpdate, readPluginUpdateProgress, runtimeDir, };