Files
AgentHub/scripts/systemd-service.js
T
nntrivi2001 1cb7ac0a4b feat(install): auto-start the dashboard on boot via systemd, no Claude Code needed
Adds ccam service install/uninstall/status for checkout installs and wires
the same systemd --user unit into plugin-bootstrap.js, so plugin-installed
colleagues get boot persistence too instead of needing a Claude Code
session to (re)start the server after every reboot.
2026-08-28 16:15:45 +07:00

118 lines
3.6 KiB
JavaScript

#!/usr/bin/env node
/**
* @file systemd-service.js
* @description Shared helper for installing/removing a systemd `--user`
* service that keeps the CCAM dashboard server running across reboots,
* without needing Claude Code (or any other tool) to be started first.
* Used by both `ccam 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 = "# ccam-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,
};