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:
@@ -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 <user>` 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
|
||||
|
||||
+91
-1
@@ -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":
|
||||
|
||||
+37
-3
@@ -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 <user>` 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/<version>/`
|
||||
- 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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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ĩ <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,
|
||||
};
|
||||
Reference in New Issue
Block a user