/** * @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"); /** 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" } } /** * 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 }); } 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, runtimeDir };