c90dd7b147
The residue an interrupted update leaves behind is invisible: the server
runs, every doctor row reads OK, and the unit quietly still names a cache
directory Claude Code deleted during the update. Nothing surfaces it until
a reboot, when the dashboard does not come back and nothing recorded why.
- fastPathOk now rejects a stale unit, so the next SessionStart repairs it
instead of short-circuiting. `unitIsStale` is pure fs on purpose: that
path runs on every session and may not spawn a process.
- Repairing the unit is not restarting the server. A current, healthy
server keeps running and the corrected unit takes effect at the next
start (`pendingSystemdRestart = systemdOwned && needsServer`), and the
detached-spawn fallback is guarded by needsServer so a stale unit alone
cannot start a second server onto a bound port.
- systemdOwned now also requires the unit to be ACTIVE. Installed-but-
stopped owns nothing: skipping the stop there and asking systemd to start
would bind an occupied port and crash-loop under Restart=always.
- /agenthub-doctor gains a systemd row naming the consequence ("will NOT
come back after a reboot") and the fix. WARN, never FAIL — the dashboard
in front of you works, and having no unit at all is a normal setup.
The fast-path test pins the regression against an otherwise fully healthy
install, live server included; without a live server it would have passed
against the unfixed code either way.
222 lines
7.4 KiB
JavaScript
222 lines
7.4 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`);
|
|
}
|
|
|
|
/** 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,
|
|
unitIsActive,
|
|
writeUserService,
|
|
restartUserService,
|
|
installUserService,
|
|
uninstallUserService,
|
|
enableLinger,
|
|
unitPath,
|
|
__setExecImpl,
|
|
__reset,
|
|
};
|