From 1cb7ac0a4b7b3e4943403f74655aabc6be81ef4a Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Fri, 28 Aug 2026 16:15:45 +0700 Subject: [PATCH] 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. --- README.md | 22 +++++++ bin/ccam.js | 92 +++++++++++++++++++++++++++- docs/PLUGINS.md | 40 +++++++++++- scripts/plugin-bootstrap.js | 39 +++++++++++- scripts/systemd-service.js | 117 ++++++++++++++++++++++++++++++++++++ 5 files changed, 303 insertions(+), 7 deletions(-) create mode 100644 scripts/systemd-service.js diff --git a/README.md b/README.md index ab276812..4a9ff2e5 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,27 @@ at its own runtime directory). `postinstall` writes the Claude Code hook entries that feed the dashboard — do not run it when the `ccam` plugin is installed, or every event is counted twice. +### Run as a service (auto-start on boot) + +`npm start` only runs while that terminal (or `ccam start`'s background +process) is alive — a reboot loses it. On Linux, `ccam service install` sets +up a systemd `--user` service instead, so `:4820` is up again on its own after +every reboot, with no manual step and no Claude Code (or any AI tool) needed +to trigger it: + +```bash +npm run build # the service serves the built client, same as npm start +ccam service install # writes + enables ~/.config/systemd/user/ccam.service +ccam service status # systemctl --user status ccam +ccam service uninstall # disable and remove it +``` + +`ccam service install` also tries `loginctl enable-linger` so the service +starts on a cold boot, before anyone logs in; if that needs elevated rights on +your machine it prints the `sudo loginctl enable-linger ` command to run +once yourself. Plugin installs get the same persistence automatically — see +[Auto-start on boot](docs/PLUGINS.md#auto-start-on-boot) in `docs/PLUGINS.md`. + ## The CLI `ccam` is linked by `npm run setup`; otherwise call `node bin/ccam.js`. @@ -78,6 +99,7 @@ every event is counted twice. ```bash ccam status # is the dashboard up ccam start # start it in the background and wait for healthy +ccam service install # auto-start on boot via systemd --user (Linux) ccam sessions # recent sessions ccam lanes # lanes with stage and progress ccam lanes pipeline # this lane's pipeline template, or switch it diff --git a/bin/ccam.js b/bin/ccam.js index 76f83d72..b7426be2 100755 --- a/bin/ccam.js +++ b/bin/ccam.js @@ -9,7 +9,7 @@ * directly from any shell as `ccam `, 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 · 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": diff --git a/docs/PLUGINS.md b/docs/PLUGINS.md index 12ddbdab..566838f3 100644 --- a/docs/PLUGINS.md +++ b/docs/PLUGINS.md @@ -133,7 +133,14 @@ waits on an install: 5. **Legacy hook cleanup** — see above. 6. **CLI** — write the `~/.local/bin/ccam` launcher, never clobbering a `ccam` the bootstrap did not write. 7. **UI build** — `client/` is copied into the runtime dir and built there (`npm install && npm run build`), landing in `runtime/client-dist` — the same directory the server serves from. Skipped when a bundle for the current version already exists; a failure here does not fail the bootstrap (API and MCP still work, and `/ccam-open` can retry) but does mean `/run` 404s until it's fixed. -8. **Server** — spawn `server/index.js` detached, with `NODE_PATH` at the runtime `node_modules` and `DASHBOARD_CLIENT_DIST` at the runtime `client-dist`. +8. **Server** — on Linux with `systemd --user` available, install/refresh a + `ccam.service` unit (see [Auto-start on boot](#auto-start-on-boot)) instead + of a bare spawn, so the server is supervised and comes back after a reboot + with no Claude Code session involved. Everywhere else (macOS, Windows, or + no systemd), spawn `server/index.js` detached, with `NODE_PATH` at the + runtime `node_modules` and `DASHBOARD_CLIENT_DIST` at the runtime + `client-dist` — scoped to this session, same as before this fell back to + systemd. 9. **Record state** — `runtime/state.json`. The first run takes a few minutes — most of it is the UI build, which is what @@ -143,6 +150,26 @@ goes to `~/.claude/agent-dashboard/runtime/bootstrap.log`; `npm run build`'s own output goes to `client-build.log` next to it; the server's own output goes to `server.log`. +### Auto-start on boot + +On Linux, every plugin bootstrap (not just the first) installs/refreshes a +systemd `--user` service (`~/.config/systemd/user/ccam.service`, managed via +`scripts/systemd-service.js` — shared with the checkout path's +`ccam service install`, see the README). Its `ExecStart`/`WorkingDirectory` +point at the current plugin cache path, so a plugin update that moves the +cache directory rewrites and restarts the unit on the next session start, the +same self-healing pattern the CLI launcher already uses. The bootstrap also +tries `loginctl enable-linger` so the service starts on a cold boot before +anyone logs in; when that needs elevated rights, it's skipped silently (check +`~/.claude/agent-dashboard/runtime/bootstrap.log`, or run +`sudo loginctl enable-linger ` once by hand). A pre-existing +`ccam.service` unit not written by this bootstrap is left untouched, and the +server falls back to the old per-session detached spawn instead. + +On macOS, Windows, or a Linux box without systemd, nothing changes: the +server is a detached spawn started fresh on the next `SessionStart` after a +reboot, same as always. + ### Commands | Command | Does | @@ -171,6 +198,9 @@ the plugin from the enabled list — verified against a real install/uninstall, not assumed. It does **not**: - stop the running server (it keeps serving from the now-uninstalled cache dir) +- disable or remove the `ccam.service` systemd `--user` unit (Linux) — it + will keep restarting the old server on every reboot, since `systemctl` + knows nothing about `claude plugin uninstall` - remove the cached source under `~/.claude/plugins/cache/.../ccam//` - remove the hook entries `claude plugin install` wrote into `~/.claude/settings.json` (still pointing at that cache dir — they keep @@ -181,8 +211,12 @@ not assumed. It does **not**: For a clean machine, do all of this yourself: ```bash -# 1. Stop the server (skip if ~/.claude/.agent-dashboard.json is already gone) -kill "$(node -e 'console.log(JSON.parse(require("fs").readFileSync(require("os").homedir()+"/.claude/.agent-dashboard.json","utf8")).pid)')" +# 1. Stop the server. On Linux, check for the systemd unit first — a bare +# `kill` just gets restarted by it (Restart=on-failure). +systemctl --user disable --now ccam.service 2>/dev/null +rm -f ~/.config/systemd/user/ccam.service && systemctl --user daemon-reload 2>/dev/null +# Fallback for a detached (non-systemd) server, or if the unit above was absent: +kill "$(node -e 'console.log(JSON.parse(require("fs").readFileSync(require("os").homedir()+"/.claude/.agent-dashboard.json","utf8")).pid)')" 2>/dev/null # 2. Runtime state and the CLI launcher rm -rf ~/.claude/agent-dashboard/runtime # deps, UI bundle, logs, state diff --git a/scripts/plugin-bootstrap.js b/scripts/plugin-bootstrap.js index cdf435c5..8b001673 100644 --- a/scripts/plugin-bootstrap.js +++ b/scripts/plugin-bootstrap.js @@ -8,7 +8,10 @@ * * The worker installs runtime dependencies, removes hook entries left behind by * an older `npm run install-hooks` (they would double-count every event), puts - * the `ccam` CLI on PATH, and starts the dashboard server detached. + * the `ccam` CLI on PATH, and starts the dashboard server — as a systemd + * `--user` service when available (see `systemd-service.js`), so it survives + * reboots without Claude Code being opened first; otherwise a detached spawn + * scoped to this session, same as before. * * Everything writable lives under `~/.claude/agent-dashboard/runtime/` — NOT in * the plugin cache, which Claude Code garbage-collects and replaces wholesale @@ -466,8 +469,38 @@ function bootstrap(rt = runtimeDir(), { force = false } = {}) { } if (movedRoot || !serverIsLive()) { - const pid = startDashboard(rt); - log(rt, `started the dashboard server (pid ${pid})`); + const svc = require("./systemd-service"); + const result = svc.systemctlAvailable() + ? svc.installUserService({ + name: "ccam", + description: "CCAM dashboard server (Claude Code plugin)", + workingDir: PLUGIN_ROOT, + execStart: `${process.execPath} ${path.join(PLUGIN_ROOT, "server", "index.js")}`, + env: { + NODE_ENV: "production", + NODE_PATH: path.join(rt, "node_modules"), + DASHBOARD_CLIENT_DIST: path.join(rt, "client-dist"), + }, + }) + : "unavailable"; + if (result === "written" || result === "unchanged") { + if (result === "written") svc.enableLinger(); + log( + rt, + result === "written" + ? "installed a systemd --user service — the dashboard now survives reboots without Claude Code" + : "systemd --user service already up to date" + ); + } else { + // No systemd (macOS/Windows/WSL without it), or the unit exists but + // wasn't ours to touch — same detached-spawn fallback as before, + // scoped to this session only. + const pid = startDashboard(rt); + log( + rt, + `started the dashboard server (pid ${pid})${result === "foreign" ? " — a foreign systemd unit named 'ccam' already exists" : ""}` + ); + } } writeState( diff --git a/scripts/systemd-service.js b/scripts/systemd-service.js new file mode 100644 index 00000000..7a79aafb --- /dev/null +++ b/scripts/systemd-service.js @@ -0,0 +1,117 @@ +#!/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ĩ + */ + +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, +};