#!/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ĩ */ 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 our unit enabled to start at boot/login — the Settings "start * automatically" switch. Costs a `systemctl` call. */ function unitIsEnabled(name) { if (!systemctlAvailable() || !unitIsOurs(name)) return false; const res = execImpl(["is-enabled", `${name}.service`], { encoding: "utf8", stdio: "pipe" }); return String(res.stdout || "").trim() === "enabled"; } /** Enable or disable our unit for boot — never starts or stops it, so the * server answering the switch stays up either way. */ function setUnitEnabled(name, on) { if (!systemctlAvailable()) return "unavailable"; if (!unitIsOurs(name)) return "foreign"; return systemctl([on ? "enable" : "disable", `${name}.service`]).status === 0 ? "ok" : "failed"; } /** 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"; } /** `KillMode=process`: a stop or an update's restart ends the server alone. * tmux forks its server from whichever process first asks for a session — * the dashboard itself, after a boot with autostart on — so it can sit in * this unit's cgroup, and the default `control-group` would kill it and every * lane pane with it. Panes close only on Quit (`server/lib/quit.js`), which * kills them itself. The launchd twin is `AbandonProcessGroup`. */ 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 KillMode=process ${envLines} [Install] WantedBy=default.target `; } function systemctl(args) { return execImpl(args, { stdio: "inherit" }); } /** * Writes (or refreshes) the unit file and enables it for boot (unless * `enable: false`, which leaves the enabled state as it is) — 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 = {}, enable = true }) { 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"]); if (enable) 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 = { kind: "systemd", label: "systemd --user service", systemctlAvailable, unitIsOurs, unitIsStale, unitRoot, unitIsActive, unitIsEnabled, setUnitEnabled, writeUserService, restartUserService, installUserService, uninstallUserService, enableLinger, unitPath, __setExecImpl, __reset, };