#!/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`); } function systemctlAvailable() { if (process.platform !== "linux") return false; return spawnSync("systemctl", ["--user", "--version"], { stdio: "ignore" }).status === 0; } 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=on-failure RestartSec=3 ${envLines} [Install] WantedBy=default.target `; } function systemctl(args) { return spawnSync("systemctl", ["--user", ...args], { stdio: "inherit" }); } /** * 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. * * @returns {"written"|"unchanged"|"foreign"|"unavailable"|"failed"} */ function installUserService({ 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) { systemctl(["enable", "--now", `${name}.service`]); return "unchanged"; } } fs.mkdirSync(unitDir(), { recursive: true }); fs.writeFileSync(target, body, "utf8"); systemctl(["daemon-reload"]); systemctl(["enable", "--now", `${name}.service`]); systemctl(["restart", `${name}.service`]); return "written"; } catch { return "failed"; } } /** @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, installUserService, uninstallUserService, enableLinger, unitPath, };