Files
AgentHub/scripts/systemd-service.js
nntrivi2001 29a5da3e4e fix(bootstrap): stop a retired plugin build from downgrading the dashboard
`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/.
2026-09-15 10:30:00 +07:00

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,
};