17d56d0e01
Clean break, no compatibility alias. Renames the product name, the CLI binary (`ccam` -> `agenthub`), the env var prefix (`CCAM_` -> `AGENTHUB_`), the Prometheus metric namespace (`ccam_*` -> `agenthub_*`), the tmux session prefix (`ccam-lane-<id>` -> `agenthub-lane-<id>`), the per-repo runtime profile directory (`.ccam/profile` -> `.agenthub/profile`), the lanes state root (`~/.claude/ccam-lanes` -> `~/.claude/agenthub-lanes`), the bundled plugins (`plugins/ccam*` -> `plugins/agenthub*`) and their slash commands (`/ccam-doctor` -> `/agenthub-doctor`, `/ccam:*` -> `/agenthub:*`). Because there is no alias, an existing install has to be updated by hand: rename its `.ccam/profile` directory and any `CCAM_*` variables, and reinstall the plugin under its new name. Two other name families are deliberately left alone here, since they are not just strings: the `Claude-Code-Monitor` repository/marketplace name, and the `agent-dashboard` npm package plus its `~/.claude/agent-dashboard` runtime directory, which holds the live database and needs a migration rather than a rename. Verified: server 1406/1406, client 459/459, `grep -i ccam` clean across tracked files, `openapi.yaml` regenerated (95 paths, unchanged count).
118 lines
3.6 KiB
JavaScript
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 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`);
|
|
}
|
|
|
|
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,
|
|
};
|