Files
AgentHub/server/lib/plugin-update.js
T
nntrivi2001 9dd97a369c chore: v2.8.21 — update progress steps, clone in background, extra SSH keys inline, today's spend
- Plugin update reports its running step (plugin-update-progress.json →
  plugin_update_progress); a hidden update or clone leaves a progress chip.
- Clone here can run in the background.
- Git identity: extra SSH keys are added inline per host (no More keys tab).
- Users: spend today (viewer's local day, ?tz_offset=).
- Prettier on the files touched since v2.8.20.
2026-10-23 10:44:11 +07:00

127 lines
5.0 KiB
JavaScript

/**
* @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ĩ <vinnt@smartgift.vn>
*/
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,
};