29a5da3e4e
`claude plugin update` unpacks the new version into a new cache directory and says "Restart to apply changes". A Claude Code process that was already running does not: it keeps loading the OLD directory, hooks and all. Every session such a process opened then ran the OLD plugin-bootstrap, which saw a state.json naming a different root, concluded the server had to be replaced, repointed agenthub.service at itself and restarted the dashboard onto the version the user had just upgraded away from. The dashboard reported the old version and offered the update again — once per session, forever, while installed_plugins.json and the cache both said the new version was installed. A /agenthub-update from that session "succeeded" and changed nothing. - `supersededBy()` reads Claude Code's installed_plugins.json. When a SIBLING cache directory is the recorded install, the bootstrap logs one line and returns "superseded" — no deps, no unit, no server. `--force` does not override it: the self-update chain forces from the NEW directory, which is never superseded, so the only --force refused is a deliberate downgrade. It cannot hand over either — the process it would replace is the one loading it — so the log line says to restart Claude Code. - Matched by sibling directory, not by the <plugin>@<marketplace> key: correct without parsing a cache path or trusting a name. A phantom entry (installPath that no longer exists) is ignored rather than wedging the bootstrap shut. Also adds `pruneRetiredPluginRoots()`, since the same updates leave a full copy of the repo — node_modules and all — per release; twelve of them measured 1.7 GB on one machine. It runs after writeState, so a bad prune cannot cost the record of a good bootstrap, and it protects four independent readings of "in use": this build, every installPath Claude Code records, the directory agenthub.service names (`unitRoot`, foreign unit included), and any directory still named in the user's settings.json hooks — that last one is the un-restarted process above, seen from the other side. Plus the 2 newest survivors as a rollback cushion, and nothing at all outside ~/.claude/plugins/cache/.
241 lines
8.0 KiB
JavaScript
241 lines
8.0 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* @file systemd-service.js
|
|
* @description Shared helper for installing/removing a systemd `--user`
|
|
* service that keeps the AgentHub dashboard server running across reboots,
|
|
* without needing Claude Code (or any other tool) to be started first.
|
|
* Used by both `agenthub service install` (checkout installs) and
|
|
* `plugin-bootstrap.js` (plugin installs) so the two paths stay in sync.
|
|
* Linux/systemd only — callers fall back to their existing manual-start
|
|
* path when `systemctlAvailable()` is false.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
const fs = require("fs");
|
|
const os = require("os");
|
|
const path = require("path");
|
|
const { spawnSync } = require("child_process");
|
|
|
|
const MARKER = "# agenthub-managed-unit — regenerated on install, do not edit by hand";
|
|
|
|
function unitDir() {
|
|
return path.join(os.homedir(), ".config", "systemd", "user");
|
|
}
|
|
|
|
function unitPath(name) {
|
|
return path.join(unitDir(), `${name}.service`);
|
|
}
|
|
|
|
// Test seam: swap the systemctl implementation so unit tests never talk to a
|
|
// real service manager. Mirrors tmux.js's __setExecImpl/__reset style.
|
|
let execImpl = (args, opts) => spawnSync("systemctl", ["--user", ...args], opts);
|
|
|
|
function __setExecImpl(fn) {
|
|
execImpl = fn;
|
|
}
|
|
function __reset() {
|
|
execImpl = (args, opts) => spawnSync("systemctl", ["--user", ...args], opts);
|
|
}
|
|
|
|
function systemctlAvailable() {
|
|
if (process.platform !== "linux") return false;
|
|
return execImpl(["--version"], { stdio: "ignore" }).status === 0;
|
|
}
|
|
|
|
/**
|
|
* Is there a unit by this name, and did WE write it? The distinction decides
|
|
* whether the dashboard may be stopped by hand: a unit of ours means systemd
|
|
* owns the process, and SIGTERM-ing it out of band is the one thing that must
|
|
* never happen (see `restartUserService`).
|
|
*/
|
|
function unitIsOurs(name) {
|
|
try {
|
|
return fs.readFileSync(unitPath(name), "utf8").includes(MARKER);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* `Restart=always`, not `on-failure`: a graceful SIGTERM exits 0, which
|
|
* `on-failure` reads as "meant to stop". One stray kill — or an out-of-band
|
|
* stop during a plugin self-update — then left the dashboard down until some
|
|
* later Claude Code session happened to run the bootstrap. An explicit
|
|
* `systemctl stop` (what uninstall does) still stops it for good.
|
|
*/
|
|
/**
|
|
* Does our unit still point at `root`? A plugin update moves the install to a
|
|
* new cache directory, and a unit left naming the old one is a failure that
|
|
* shows NOTHING until the next reboot — the dashboard simply does not come
|
|
* back, from a path Claude Code garbage-collected weeks earlier.
|
|
*
|
|
* Pure fs, no `systemctl`: this is called from the bootstrap's fast path, which
|
|
* runs on every single SessionStart and may not spawn a process.
|
|
*
|
|
* A foreign unit is never "stale" — it is not ours to judge or rewrite.
|
|
*/
|
|
function unitIsStale(name, root) {
|
|
let body;
|
|
try {
|
|
body = fs.readFileSync(unitPath(name), "utf8");
|
|
} catch {
|
|
return false; // no unit at all: nothing to be stale
|
|
}
|
|
if (!body.includes(MARKER)) return false;
|
|
return !body.includes(`\nWorkingDirectory=${root}\n`);
|
|
}
|
|
|
|
/**
|
|
* The directory our unit actually runs from, or null when there is no unit.
|
|
*
|
|
* Deliberately does NOT require the marker: a FOREIGN unit named `agenthub` is
|
|
* not ours to rewrite, but whatever directory it runs from is still a directory
|
|
* nothing here may delete. The one caller is the plugin-cache pruner.
|
|
*/
|
|
function unitRoot(name) {
|
|
let body;
|
|
try {
|
|
body = fs.readFileSync(unitPath(name), "utf8");
|
|
} catch {
|
|
return null;
|
|
}
|
|
const match = body.match(/^WorkingDirectory=(.*)$/m);
|
|
return match ? match[1].trim() : null;
|
|
}
|
|
|
|
/** Is the unit currently running? Costs a `systemctl` call — never call it
|
|
* from the fast path. */
|
|
function unitIsActive(name) {
|
|
if (!systemctlAvailable()) return false;
|
|
const res = execImpl(["is-active", `${name}.service`], { encoding: "utf8", stdio: "pipe" });
|
|
return String(res.stdout || "").trim() === "active";
|
|
}
|
|
|
|
function unitContent({ description, workingDir, execStart, env }) {
|
|
const envLines = Object.entries(env)
|
|
.map(([k, v]) => `Environment=${k}=${v}`)
|
|
.join("\n");
|
|
return `[Unit]
|
|
${MARKER}
|
|
Description=${description}
|
|
After=network.target
|
|
|
|
[Service]
|
|
Type=simple
|
|
WorkingDirectory=${workingDir}
|
|
ExecStart=${execStart}
|
|
Restart=always
|
|
RestartSec=3
|
|
${envLines}
|
|
|
|
[Install]
|
|
WantedBy=default.target
|
|
`;
|
|
}
|
|
|
|
function systemctl(args) {
|
|
return execImpl(args, { stdio: "inherit" });
|
|
}
|
|
|
|
/**
|
|
* Writes (or refreshes) the unit file and enables it for boot — but starts
|
|
* nothing. Split out of `installUserService` for the plugin self-update, which
|
|
* must be able to point the unit at the new cache directory *before* anything
|
|
* stops the running server, and defer the restart to its very last act.
|
|
*
|
|
* @returns {"written"|"unchanged"|"foreign"|"unavailable"|"failed"}
|
|
*/
|
|
function writeUserService({ name, description, workingDir, execStart, env = {} }) {
|
|
if (!systemctlAvailable()) return "unavailable";
|
|
const body = unitContent({ description, workingDir, execStart, env });
|
|
const target = unitPath(name);
|
|
try {
|
|
if (fs.existsSync(target)) {
|
|
const current = fs.readFileSync(target, "utf8");
|
|
if (!current.includes(MARKER)) return "foreign";
|
|
if (current === body) return "unchanged";
|
|
}
|
|
fs.mkdirSync(unitDir(), { recursive: true });
|
|
fs.writeFileSync(target, body, "utf8");
|
|
systemctl(["daemon-reload"]);
|
|
systemctl(["enable", `${name}.service`]);
|
|
return "written";
|
|
} catch {
|
|
return "failed";
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Ask systemd to restart the unit and do NOT wait for it.
|
|
*
|
|
* `--no-block` is the whole point, not a nicety. The caller is the bootstrap
|
|
* spawned by the running server, so it lives in that server's cgroup; the
|
|
* moment systemd stops the old main process, `KillMode=control-group` reaps
|
|
* the caller too. Blocking here means being killed mid-call, which is why the
|
|
* restart must be the last thing the bootstrap does — by then there is nothing
|
|
* left to lose.
|
|
*/
|
|
function restartUserService(name) {
|
|
if (!systemctlAvailable()) return "unavailable";
|
|
const res = systemctl(["restart", "--no-block", `${name}.service`]);
|
|
return res.status === 0 ? "restarted" : "failed";
|
|
}
|
|
|
|
/**
|
|
* Writes (or refreshes) the unit file and enables + starts it. Idempotent:
|
|
* an unchanged unit is left running as-is; a changed one (e.g. a plugin
|
|
* cache path after an update) is reloaded and restarted.
|
|
*
|
|
* Safe for `agenthub service install`, which is invoked from a user's own
|
|
* shell and is therefore not inside the unit's cgroup. The plugin bootstrap
|
|
* is, and uses `writeUserService` + `restartUserService` instead.
|
|
*
|
|
* @returns {"written"|"unchanged"|"foreign"|"unavailable"|"failed"}
|
|
*/
|
|
function installUserService(opts) {
|
|
const result = writeUserService(opts);
|
|
if (result === "unchanged") {
|
|
systemctl(["enable", "--now", `${opts.name}.service`]);
|
|
} else if (result === "written") {
|
|
systemctl(["enable", "--now", `${opts.name}.service`]);
|
|
systemctl(["restart", `${opts.name}.service`]);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/** @returns {"removed"|"absent"|"foreign"|"unavailable"} */
|
|
function uninstallUserService(name) {
|
|
if (!systemctlAvailable()) return "unavailable";
|
|
const target = unitPath(name);
|
|
if (!fs.existsSync(target)) return "absent";
|
|
if (!fs.readFileSync(target, "utf8").includes(MARKER)) return "foreign";
|
|
systemctl(["disable", "--now", `${name}.service`]);
|
|
fs.rmSync(target, { force: true });
|
|
systemctl(["daemon-reload"]);
|
|
return "removed";
|
|
}
|
|
|
|
/** Lets the user service start on cold boot, not just after login. Best
|
|
* effort — some setups require an admin to grant this, so a failure here
|
|
* is reported, never thrown. */
|
|
function enableLinger() {
|
|
const user = os.userInfo().username;
|
|
return spawnSync("loginctl", ["enable-linger", user], { stdio: "ignore" }).status === 0;
|
|
}
|
|
|
|
module.exports = {
|
|
systemctlAvailable,
|
|
unitIsOurs,
|
|
unitIsStale,
|
|
unitRoot,
|
|
unitIsActive,
|
|
writeUserService,
|
|
restartUserService,
|
|
installUserService,
|
|
uninstallUserService,
|
|
enableLinger,
|
|
unitPath,
|
|
__setExecImpl,
|
|
__reset,
|
|
};
|