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.
This commit is contained in:
2026-08-28 16:15:45 +07:00
parent 8bd74a57e6
commit 1cb7ac0a4b
5 changed files with 303 additions and 7 deletions
+91 -1
View File
@@ -9,7 +9,7 @@
* directly from any shell as `ccam <command>`, or interactively at the
* `ccam ›` prompt via `ccam repl`.
*
* Server status · start · repl (interactive shell)
* Server status · start · service (install/uninstall/status) · repl
* Monitoring health · stats · kanban · tail
* Data browsing sessions · session <id> · agents · events
* Insights analytics · workflows · runs · cost
@@ -1433,6 +1433,88 @@ async function cmdStart(flags) {
process.exit(1);
}
/**
* Installs/removes a systemd `--user` service so the dashboard survives
* reboots without anything (Claude Code included) needing to start it first.
* Linux/systemd only; other platforms are told to keep using `ccam start`.
*/
async function cmdService(sub, flags) {
const svc = require(path.join(REPO_ROOT, "scripts", "systemd-service.js"));
if (!svc.systemctlAvailable()) {
console.error(c.red("✖ systemd --user is not available on this machine."));
console.error(
c.dim(" Auto-start on boot needs Linux + systemd. Use `ccam start` manually instead.")
);
process.exit(1);
}
const name = "ccam";
if (sub === "install") {
const clientDist = path.join(REPO_ROOT, "client", "dist", "index.html");
if (!fs.existsSync(clientDist)) {
console.error(c.red("✖ client/dist is missing — build it first: npm run build"));
process.exit(1);
}
const env = { NODE_ENV: "production" };
if (flags.port) env.DASHBOARD_PORT = String(flags.port);
const result = svc.installUserService({
name,
description: "CCAM dashboard server",
workingDir: REPO_ROOT,
execStart: `${process.execPath} ${path.join(REPO_ROOT, "server", "index.js")}`,
env,
});
if (result === "foreign") {
console.error(
c.red(`✖ ~/.config/systemd/user/${name}.service already exists and wasn't written by ccam.`)
);
console.error(c.dim(" Remove or rename it, then re-run `ccam service install`."));
process.exit(1);
}
if (result === "failed") {
console.error(c.red("✖ Failed to write/enable the systemd unit."));
process.exit(1);
}
console.log(
`${c.green("✔")} systemd --user service '${name}' ${result === "unchanged" ? "already up to date" : "installed"} and enabled.`
);
const lingered = svc.enableLinger();
if (lingered) {
console.log(
c.dim(" Linger enabled — the dashboard will start even on a cold boot with no login.")
);
} else {
console.log(
c.dim(" Could not enable linger automatically. For it to survive a reboot with nobody")
);
console.log(
c.dim(
` logged in yet, run: sudo loginctl enable-linger ${require("os").userInfo().username}`
)
);
}
return;
}
if (sub === "uninstall") {
const result = svc.uninstallUserService(name);
if (result === "foreign") {
console.error(
c.red(`✖ ~/.config/systemd/user/${name}.service wasn't written by ccam — not touching it.`)
);
process.exit(1);
}
console.log(
`${c.green("✔")} systemd service ${result === "absent" ? "was not installed" : "removed"}.`
);
return;
}
if (sub === "status") {
spawn("systemctl", ["--user", "status", `${name}.service`, "--no-pager"], { stdio: "inherit" });
return;
}
console.error(c.red("✖ Usage: ccam service install|uninstall|status [--port N]"));
process.exit(1);
}
/** Up/down indicator without exiting non-zero noise — the at-a-glance check. */
async function cmdStatus() {
if (await serverIsUp()) {
@@ -2589,6 +2671,11 @@ const COMMAND_GROUPS = [
[
["status", "", "Up/down indicator for the dashboard server"],
["start", "[--port N]", "Start the server in the background and wait for healthy"],
[
"service",
"install|uninstall|status [--port N]",
"Auto-start on boot via systemd --user (Linux)",
],
["repl", "", "Open the interactive shell (also: shell, i)"],
],
],
@@ -3490,6 +3577,7 @@ const COMMANDS = (() => {
/** Subcommand tokens per command, used by the REPL's tab completer. */
const SUBCOMMANDS = {
alerts: ["ack", "ack-all"],
service: ["install", "uninstall", "status"],
pricing: ["set", "delete", "reset"],
webhooks: ["test"],
import: ["rescan", "path"],
@@ -3509,6 +3597,8 @@ async function runCommand(argv) {
return cmdStatus();
case "start":
return cmdStart(flags);
case "service":
return cmdService(positional[0], flags);
case "stats":
return cmdStats();
case "kanban":