9dd97a369c
- 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.
127 lines
5.0 KiB
JavaScript
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,
|
|
};
|