diff --git a/CLAUDE.md b/CLAUDE.md index 4b0bf816..1aa78d89 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -109,6 +109,8 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph | CI-equivalent test sweep | `npm run test:ci` (full suite minus browser/perf — see Testing) | | Production start | `npm run start` | | Production logs | `journalctl --user -u codeman-web -f` | +| Detached server | `codeman web -d` (`--status`, `--stop`; pidfile+log at `dataPath('web.pid'/'web.log')`). ⚠ Refuses to start a 2nd server on one data dir — see Instance isolation | +| Install/remove the service | `codeman service install` / `status` / `uninstall` (systemd user unit on Linux, LaunchAgent on macOS; names from `config/service-names.ts`) | **CI**: `.github/workflows/ci.yml` (push to master/main + PRs, Node 22) runs two jobs: **(1)** `check:lockfile`, `typecheck`, `lint`, `check:frontend-syntax`, `format:check`, then a **server boot smoke test** (`tsx src/index.ts web --port 3151` must answer `/api/status` within 30s); **(2)** the **unit/integration test suite** via `npm run test:ci` (`config/vitest.ci.config.ts` — excludes the browser-driven `test/mobile/**` suite, `perf-*` benchmarks, and 3 Playwright tests). Tests are tmux-safe in CI: `TmuxManager` no-ops all shell commands under `VITEST` (see Testing). @@ -141,7 +143,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph | Domain | Key files | Notes | | ---------------- | -------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | -| **Entry** | `src/index.ts`, `src/cli.ts` | | +| **Entry** | `src/index.ts`, `src/cli.ts`, `daemon-control`, `service-installer`, `config/service-names` | The last three back `web -d` / `service install` | | **Session** | `src/session.ts` ★, `session-manager`, `session-auto-ops`, `session-cli-builder`, `session-task-cache`, `session-order` (pure), `session-pty-exit-breaker`, `usage-limit-patterns`, `usage-telemetry`; `src/services/unified-session-service.ts` | Pure/unit-tested helpers are split out of `session.ts` on purpose | | **Mux** | `src/mux-interface.ts`, `src/mux-factory.ts`, `src/tmux-manager.ts` ★ | | | **Respawn** | `src/respawn-controller.ts` ★ + 4 helpers (`-adaptive-timing`, `-health`, `-metrics`, `-patterns`) | Read `docs/respawn-state-machine.md` first | @@ -212,6 +214,8 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Terminal scrollback strip + wheel/touch forwarding** (#205): codex/claude/gemini get the FULL strip (alt-screen, `3J`, mouse DECSETs); tmux-backed shell/opencode/antigravity get a NARROW strip (alt-screen toggles only — it removes tmux's own attach-time `smcup`, which otherwise parks xterm in the scrollback-less alt buffer and turns the wheel into arrow keys). ⚠️ Gated on `useMux`: direct-PTY fallback sessions must keep the alt screen for vim/less/htop. Wheel AND touch forward to the CLI transcript for codex/claude ≥ 2.1.187 at ANY scroll position (snap-to-bottom first); Shift+wheel and the `terminalWheelLocalScrollback` setting stay local. `_wheelScrollLines()` reads `ev.deltaMode` (Firefox = LINE units). ⚠️ When that gate is FALSE on a claude session whose local buffer is hollow (`baseY === 0`), the gesture becomes coalesced PageUp/PageDown key sends (`_maybePageCliTranscript`) instead of a no-op; ⚠️ and `getClaudeCliVersion()` must never cache a FAILED probe (one timeout used to disable forwarding process-wide until restart). `_logScrollRouting()` prints the routing decision and its inputs once per session — read it before diagnosing a scroll report. → [architecture-invariants#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding](docs/architecture-invariants.md#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding) +**Detached start + service install** (issue #231): `codeman web -d` relaunches the SAME entry script with `detached:true` (setsid), so there is no controlling terminal and no shell job entry. ⚠️ `nohup` is NOT what makes this work: Node re-arms SIGHUP to its default disposition even when it inherits "ignore", and `cli.ts` handles SIGHUP with a graceful shutdown, so a delivered HUP still stops the server. ⚠️ Both `-d` and `service install` must REFUSE when a server is already up on this data dir (pidfile check + `/api/status` probe): a second instance on the shared tmux socket attaches PTYs to the first one's live sessions. ⚠️ Neither may report success it has not observed — the parent polls `/api/status` until the child answers or dies, since `launchctl load` and a clean spawn are both silent about a server that starts and immediately exits. `--stop` verifies the pid still LOOKS like a Codeman server (`ps -o command=`) before signalling, because pids get recycled. Unit/label names live in `config/service-names.ts` so install.sh, `detectSupervisor()` and `service install` cannot drift into supervising two copies; they are instance-scoped, and identical to the historical names for the default instance. `service install` bakes the installing shell's PATH into the unit (launchd gives a job `/usr/bin:/bin:/usr/sbin:/sbin`, which finds neither a Homebrew/nvm `node` nor `tmux`/`claude`) and never writes `CODEMAN_PASSWORD` into it. → [architecture-invariants#detached-start-and-service-install](docs/architecture-invariants.md#detached-start-and-service-install) + **Self-update** (App Settings → Updates): in-app updater for git-clone installs supervised by systemd/launchd (`systemd`, `launchd`, `launchd-daemon`, else `none` → "restart manually"). The update restarts the very process running it, so the real work runs in a DETACHED `scripts/self-update.sh` that outlives the restart and writes progress to `update-status.json`, which the browser polls across the connection drop. `src/web/self-update.ts` splits pure helpers (unit-tested) from IO wrappers. npm installs report as non-updatable. → [architecture-invariants#self-update](docs/architecture-invariants.md#self-update) **Attachments** (live external document references; all wiring in `file-routes.ts`): a **registry** maps a stable `attachmentId` to a realpath-resolved, extension-allowlisted absolute path, so browser requests never carry arbitrary absolute paths. ⚠️ The **magic-link scanner** (`codeman://attach?...` in terminal output) is **prompt-injectable**, so its scan path is force-confined to the session workspace; a hostile prompt could otherwise exfiltrate arbitrary host files over SSE. The security gate is an extension **allowlist**, not a blocklist. `document-conversion-limiter.ts` caps converter spawns globally: without it, N large docs detected at once fork N multi-minute processes, which is a resource-exhaustion vector. → [architecture-invariants#attachments](docs/architecture-invariants.md#attachments) diff --git a/README.md b/README.md index b6c03b94..4d010ad0 100644 --- a/README.md +++ b/README.md @@ -85,9 +85,29 @@ codeman web --multiuser # named logins + per-user case spaces Details in [Multi-User Mode](#multi-user-mode-opt-in) below.
-Run as a background service +Keep it running in the background -The installer's final menu sets this up for you (option 2) and verifies the service actually comes up before claiming success. To configure it manually instead: +To outlive the shell you started it in, without setting anything up: + +```bash +codeman web -d # detach; logs to ~/.codeman/web.log +codeman web --status # is it up, and on which pid +codeman web --stop # graceful SIGTERM; agents keep running in tmux +``` + +`-d` waits until the server actually answers before reporting success, and refuses to start a second one on the same data dir (two servers sharing a tmux socket attach to each other's sessions). + +To have it come back after a reboot, install it as a service instead. The installer's final menu does this for you (option 2); `codeman service` is the equivalent for an `npm i -g aicodeman` install: + +```bash +codeman service install # systemd user unit (Linux) or LaunchAgent (macOS) +codeman service status +codeman service uninstall +``` + +`service install` writes the unit with your current PATH baked in, which matters more than it sounds: launchd hands a job `/usr/bin:/bin:/usr/sbin:/sbin`, so a Homebrew or nvm `node`, `tmux` or `claude` is invisible to a hand-written plist. It never copies `CODEMAN_PASSWORD` into the unit file; add that yourself if the service needs auth. + +To write the unit by hand instead: **Linux (systemd):** diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index c865c431..4f00ef94 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -140,6 +140,22 @@ Tests: `test/file-editing-policy.test.ts` (pure policy), `test/routes/file-write **Away digest** (COD-41/#136): `GET /api/away-digest?range=&since=&until=&lastViewed=` aggregates "what happened while you were away" from the lifecycle log + run-summary events + live sessions + daily token stats + recently-completed subagents into needs-attention/completed/still-running/idle/informational sections. Pure aggregator in `web/away-digest.ts` (`resolveAwayDigestRange()` validates the window — `since-last-visit`/`1h`/`today`/`24h`/`custom`, server-local TZ; `buildAwayDigest()` classifies). Header-button modal in `panels-ui.js` (button hidden on phones — regression-guarded). ⚠️ Returns `{success:true,digest}` (a legacy raw-ish shape, consistent with the other raw GET handlers in `system-routes.ts` — `{entries}`/`{config}`/`{files}`/`getSystemStats()`); frontend + tests read `.digest`. Subagent lookback is a fixed 60-min window regardless of range. +### Detached start and service install + +**`codeman web -d` / `codeman service install`** (issue #231). Two answers to "keep it running", split by how long: `-d` survives the shell, the service survives a reboot. `src/daemon-control.ts` and `src/service-installer.ts`, both splitting pure builders (argv, URLs, pidfile parsing, unit-file text) from the IO. + +Why a flag at all, when `nohup codeman web &` looks like it should work: it does not reliably. **Node re-arms SIGHUP to its default disposition even when it inherits "ignore" from `nohup`** (verified: `nohup node script.js &` then `kill -HUP` prints "Hangup" and dies; `/proc//status` shows SIGHUP absent from `SigIgn`, where `nohup sleep` has it set). `cli.ts` then adds a SIGHUP handler that shuts the server down gracefully, so a delivered HUP always stops it. What actually works is removing the shell's ability to send one: `disown` in the user's shell, or `detached: true` (setsid) here. zsh HUPs running jobs on exit by default, bash does not on a clean `exit` but does when it receives SIGHUP itself, which is why "does `&` survive?" gets opposite answers on macOS and Linux. + +Invariants: + +- **Never a second server on one data dir.** `-d` and `service install` both check the pidfile AND probe `/api/status` first, and refuse. This is the instance-isolation hazard, not politeness: a second instance on the shared `tmux -L codeman` socket discovers the first one's live sessions, attaches PTYs to them and resizes them (see [Instance isolation](#instance-isolation-and-the-multi-instance-attach-danger)). +- **Never report success that was not observed.** The parent polls `/api/status` until the child answers or exits, then prints the URL or the tail of `web.log`. A `launchctl load`, a `systemctl enable --now` and a plain spawn are all silent about a server that starts and dies half a second later, which is why `install.sh` verifies too. The log is append-only across launches, so each start writes a separator line and the failure tail begins there. +- **A 401 counts as up.** `CODEMAN_PASSWORD` gates `/api/status`, so requiring a 200 would make readiness detection fail on exactly the installs that took security advice. The body is still checked for `"success"` so an unrelated service squatting on the port is not mistaken for Codeman. +- **`--stop` verifies identity before signalling.** Pids are recycled; a stale pidfile plus a blind `process.kill` is how a tool SIGTERMs someone's database. `ps -o command=` (portable to macOS) must still look like a Codeman web process. When `ps` itself fails, the pid is treated as ours rather than orphaning the pidfile. +- **One source of truth for the job name.** `src/config/service-names.ts` holds the systemd unit name and launchd label used by `install.sh`, `detectSupervisor()` in self-update, and `service install`. Drift here is silent and bad: `service install` would supervise a SECOND copy alongside the installer's. The names are instance-scoped (`CODEMAN_INSTANCE=beta` → `codeman-web-beta.service` / `com.codeman.beta.web`) so a beta cannot overwrite the production unit, and are byte-identical to the historical names for the default instance. +- **PATH is the reason hand-written units fail.** launchd hands a job `/usr/bin:/bin:/usr/sbin:/sbin` and systemd's user manager is nearly as bare, so a Homebrew or nvm `node`, `tmux` or `claude` is simply absent. The unit therefore carries the installing shell's PATH with the running node's directory in front; `node_modules/.bin` entries are dropped, since npx injects those for one command and they would outlive the checkout. +- **No secrets in unit files.** `CODEMAN_PASSWORD` present in the installing shell is NOT copied into the plist/unit; the operator is told to add it. `CODEMAN_INSTANCE` IS copied, because without it a supervised beta would silently run against the production data dir and tmux socket. + ### Self-update **Self-update** (App Settings → Updates): in-app updater for **git-clone installs** supervised by systemd/launchd. Supervisors: `systemd` (user unit), `launchd` (GUI LaunchAgent, gui-domain kickstart), `launchd-daemon` (KeepAlive system LaunchDaemon on headless Macs — restarts rootlessly by killing the server PID and letting launchd respawn it; detected only when the daemon is bootstrapped AND KeepAlive), else `none` → "restart manually" message; on next boot a manual-restart status auto-completes when the running version matches the target. The update restarts the very process running it, so the real work runs in a DETACHED `scripts/self-update.sh` (`git checkout && npm install && npm run build && restart`) that outlives the restart; it writes progress to `dataPath('update-status.json')`, which the browser polls across the connection drop. Channel = latest `codeman@X.Y.Z` release tag; dirty trees are auto-stashed. `src/web/self-update.ts` splits PURE helpers (semver/tag parsing, reconcile decision — unit-tested) from IO wrappers (`getInstallInfo`/`checkForUpdate`/`startUpdate`/`reconcileUpdateOnBoot`). Routes: `GET /api/system/update/check`, `POST /api/system/update`, `GET /api/system/update/status`. Types: `src/types/update.ts`. npm installs report as non-updatable. diff --git a/src/cli.ts b/src/cli.ts index c27e8ded..522989bc 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -21,6 +21,9 @@ import { getRalphLoop } from './ralph-loop.js'; import { getStore } from './state-store.js'; import { getErrorMessage } from './types.js'; import { isSupportedAttachmentExtension } from './attachment-registry.js'; +import { daemonStatus, startDaemon, stopDaemon, type WebLaunchOptions } from './daemon-control.js'; +import { installService, serviceStatus, uninstallService } from './service-installer.js'; +import { isLoopbackBindHost, isUnauthenticatedNetworkAcknowledged } from './web/network-auth-policy.js'; const require = createRequire(import.meta.url); const pkg = require('../package.json') as { version: string }; @@ -572,64 +575,224 @@ program console.log(''); }); +// ============ Web / daemon / service Commands ============ + +/** Shared option set for the commands that can launch a web server. */ +function addWebLaunchOptions(cmd: Command): Command { + return cmd + .option('-H, --host ', 'Host to bind to', process.env.CODEMAN_HOST || '127.0.0.1') + .option('-p, --port ', 'Port to listen on (env: CODEMAN_PORT)', process.env.CODEMAN_PORT || '3000') + .option('--https', 'Enable HTTPS with self-signed certificate (only needed for remote access, not localhost)') + .option('--title-hostname ', 'Override the hostname shown in the browser title') + .option( + '--allow-unauthenticated-network', + 'Allow non-loopback web access without CODEMAN_PASSWORD (dangerous; terminal control is exposed)' + ) + .option( + '--multiuser', + 'Enable opt-in multi-user mode (named users in ~/.codeman/users.json; env: CODEMAN_MULTIUSER)' + ); +} + +/** Normalize commander's strings into the shape daemon-control/service-installer take. */ +function toWebLaunchOptions(options: { + host: string; + port: string; + https?: boolean; + titleHostname?: string; + allowUnauthenticatedNetwork?: boolean; + multiuser?: boolean; +}): WebLaunchOptions { + const port = parseInt(options.port, 10); + if (!Number.isInteger(port) || port <= 0 || port > 65535) { + console.error(chalk.red(`✗ Invalid port: ${options.port}`)); + process.exit(1); + } + return { + host: options.host, + port, + https: !!options.https, + titleHostname: options.titleHostname, + allowUnauthenticatedNetwork: !!options.allowUnauthenticatedNetwork, + multiuser: !!options.multiuser, + }; +} + +/** + * The server prints this itself, but into a log file nobody reads when it is + * detached or supervised. Repeat it where the operator is actually looking. + */ +function warnIfUnauthenticatedNetwork(launch: WebLaunchOptions): void { + if (isLoopbackBindHost(launch.host)) return; + if (isUnauthenticatedNetworkAcknowledged(launch.allowUnauthenticatedNetwork)) return; + console.log( + chalk.yellow( + `⚠ Binding ${launch.host} without CODEMAN_PASSWORD: anyone who can reach this port gets terminal control.` + ) + ); + console.log(chalk.yellow(' Set CODEMAN_PASSWORD, or bind 127.0.0.1 and front it with tailscale serve.')); +} + // Web interface command -program - .command('web') - .description('Start the web interface') - .option('-H, --host ', 'Host to bind to', process.env.CODEMAN_HOST || '127.0.0.1') - .option('-p, --port ', 'Port to listen on (env: CODEMAN_PORT)', process.env.CODEMAN_PORT || '3000') - .option('--https', 'Enable HTTPS with self-signed certificate (only needed for remote access, not localhost)') - .option('--title-hostname ', 'Override the hostname shown in the browser title') - .option( - '--allow-unauthenticated-network', - 'Allow non-loopback web access without CODEMAN_PASSWORD (dangerous; terminal control is exposed)' - ) - .option('--multiuser', 'Enable opt-in multi-user mode (named users in ~/.codeman/users.json; env: CODEMAN_MULTIUSER)') - .action(async (options) => { - // The flag is surfaced to the rest of the process via the env var so - // isMultiUserMode() has a single source of truth (see config/multiuser.ts). - if (options.multiuser) process.env.CODEMAN_MULTIUSER = '1'; - const { startWebServer } = await import('./web/server.js'); - const host = options.host; - const port = parseInt(options.port, 10); - const https = !!options.https; - const titleHostname = options.titleHostname; - const allowUnauthenticatedNetwork = !!options.allowUnauthenticatedNetwork; - const protocol = https ? 'https' : 'http'; - const displayHost = host === '0.0.0.0' ? 'localhost' : host; +const webCmd = addWebLaunchOptions(program.command('web').description('Start the web interface')) + .option('-d, --daemon', 'Run detached in the background; survives the shell, logs to /web.log') + .option('--stop', 'Stop a server started with --daemon') + .option('--status', 'Report whether a detached server is running'); - console.log(chalk.cyan(`Starting Codeman web interface on ${displayHost}:${port}${https ? ' (HTTPS)' : ''}...`)); +webCmd.action(async (options) => { + // The flag is surfaced to the rest of the process via the env var so + // isMultiUserMode() has a single source of truth (see config/multiuser.ts). + if (options.multiuser) process.env.CODEMAN_MULTIUSER = '1'; + const launch = toWebLaunchOptions(options); - try { - const server = await startWebServer(port, https, false, host, titleHostname, allowUnauthenticatedNetwork); - console.log(chalk.green(`\n✓ Web interface running at ${protocol}://${displayHost}:${port}`)); - if (https) { - console.log(chalk.yellow(' Note: Accept the self-signed certificate in your browser on first visit')); + if (options.stop) { + const result = await stopDaemon(launch); + if (result.ok && result.reason === 'not-running') { + console.log(chalk.gray(`○ ${result.message}`)); + return; + } + if (result.ok) { + console.log(chalk.green(`✓ ${result.message ?? `Stopped Codeman (pid ${result.pid})`}`)); + console.log(chalk.gray(' Your agents keep running in tmux.')); + return; + } + console.error(chalk.red(`✗ ${result.message ?? 'Could not stop the server'}`)); + process.exit(1); + } + + if (options.status) { + const status = await daemonStatus(launch); + if (status.responding) { + const version = status.version ? ` (v${status.version})` : ''; + console.log(chalk.green(`✓ Responding at ${status.url}${version}`)); + } else { + console.log(chalk.yellow(`○ Nothing answering at ${status.url}`)); + } + console.log(` Daemon pid: ${status.running ? chalk.green(String(status.pid)) : chalk.gray('not running')}`); + console.log(chalk.gray(` Pidfile: ${status.pidFile}`)); + console.log(chalk.gray(` Log: ${status.logPath}`)); + if (!status.running && status.responding) { + console.log(chalk.gray(' (running, but not started with --daemon: probably a service or a foreground run)')); + } + return; + } + + if (options.daemon) { + warnIfUnauthenticatedNetwork(launch); + console.log(chalk.cyan('Starting Codeman in the background...')); + const result = await startDaemon(launch); + if (result.ok) { + console.log(chalk.green(`\n✓ Codeman is running at ${result.url} (pid ${result.pid})`)); + console.log(chalk.gray(` Logs: ${result.logPath}`)); + console.log(chalk.gray(' Stop it with: codeman web --stop')); + console.log(chalk.gray(' Want it back after a reboot? codeman service install')); + return; + } + console.error(chalk.red(`\n✗ ${result.message ?? 'Failed to start'}`)); + process.exit(1); + } + + const { startWebServer } = await import('./web/server.js'); + const host = launch.host; + const port = launch.port; + const https = launch.https; + const titleHostname = options.titleHostname; + const allowUnauthenticatedNetwork = launch.allowUnauthenticatedNetwork ?? false; + const protocol = https ? 'https' : 'http'; + const displayHost = host === '0.0.0.0' ? 'localhost' : host; + + console.log(chalk.cyan(`Starting Codeman web interface on ${displayHost}:${port}${https ? ' (HTTPS)' : ''}...`)); + + try { + const server = await startWebServer(port, https, false, host, titleHostname, allowUnauthenticatedNetwork); + console.log(chalk.green(`\n✓ Web interface running at ${protocol}://${displayHost}:${port}`)); + if (https) { + console.log(chalk.yellow(' Note: Accept the self-signed certificate in your browser on first visit')); + } + console.log(chalk.gray(' Press Ctrl+C to stop\n')); + + // Graceful shutdown handler — flush state and clean up on SIGTERM/SIGINT + let shuttingDown = false; + const shutdown = async (signal: string) => { + if (shuttingDown) return; + shuttingDown = true; + console.log(chalk.yellow(`\n${signal} received, shutting down gracefully...`)); + try { + await server.stop(); + } catch (err) { + console.error(chalk.red(`Error during shutdown: ${getErrorMessage(err)}`)); } - console.log(chalk.gray(' Press Ctrl+C to stop\n')); + process.exit(0); + }; + process.on('SIGTERM', () => shutdown('SIGTERM')); + process.on('SIGINT', () => shutdown('SIGINT')); + process.on('SIGHUP', () => shutdown('SIGHUP')); + } catch (err) { + console.error(chalk.red(`✗ Failed to start web server: ${getErrorMessage(err)}`)); + process.exit(1); + } +}); - // Graceful shutdown handler — flush state and clean up on SIGTERM/SIGINT - let shuttingDown = false; - const shutdown = async (signal: string) => { - if (shuttingDown) return; - shuttingDown = true; - console.log(chalk.yellow(`\n${signal} received, shutting down gracefully...`)); - try { - await server.stop(); - } catch (err) { - console.error(chalk.red(`Error during shutdown: ${getErrorMessage(err)}`)); - } - process.exit(0); - }; - process.on('SIGTERM', () => shutdown('SIGTERM')); - process.on('SIGINT', () => shutdown('SIGINT')); - process.on('SIGHUP', () => shutdown('SIGHUP')); - } catch (err) { - console.error(chalk.red(`✗ Failed to start web server: ${getErrorMessage(err)}`)); +// Supervised service: the "still there after a reboot" answer, where `web -d` is +// the "still there after I close this shell" one (issue #231). +const serviceCmd = program + .command('service') + .description('Manage the background service (systemd user unit on Linux, LaunchAgent on macOS)'); + +addWebLaunchOptions( + serviceCmd.command('install').description('Install and start the service, then verify it answers') +).action(async (options) => { + const launch = toWebLaunchOptions(options); + warnIfUnauthenticatedNetwork(launch); + console.log(chalk.cyan('Installing the Codeman service...')); + + const result = await installService(launch); + for (const warning of result.warnings ?? []) console.log(chalk.yellow(`⚠ ${warning}`)); + + if (!result.ok) { + console.error(chalk.red(`✗ ${result.message}`)); + process.exit(1); + } + console.log(chalk.green(`✓ ${result.message}`)); + console.log(chalk.gray(` Unit: ${result.unitPath}`)); + if (process.env.CODEMAN_PASSWORD) { + console.log( + chalk.yellow( + ' Note: CODEMAN_PASSWORD was NOT copied into the unit file. Add it there yourself if the service needs auth.' + ) + ); + } +}); + +serviceCmd + .command('uninstall') + .description('Stop the service and remove its unit file') + .action(() => { + const result = uninstallService(); + if (!result.ok) { + console.error(chalk.red(`✗ ${result.message}`)); process.exit(1); } + console.log(chalk.green(`✓ ${result.message}`)); }); +addWebLaunchOptions( + serviceCmd.command('status').description('Show whether the service is installed and running') +).action(async (options) => { + const status = await serviceStatus(toWebLaunchOptions(options)); + if (!status.kind) { + console.log(chalk.yellow(`No supported supervisor on ${process.platform}. Use \`codeman web -d\` instead.`)); + return; + } + console.log(` Supervisor: ${status.kind} (${status.name})`); + console.log(` Unit file: ${status.installed ? chalk.green(status.unitPath) : chalk.gray('not installed')}`); + console.log(` Loaded: ${status.loaded ? chalk.green('yes') : chalk.gray('no')}`); + const version = status.version ? ` (v${status.version})` : ''; + console.log( + ` Responding: ${status.responding ? chalk.green(`yes at ${status.url}${version}`) : chalk.gray(`no at ${status.url}`)}` + ); +}); + // ============ Multi-user Commands ============ // // Operate directly on ~/.codeman/users.json (via user-store) with NO running diff --git a/src/config/service-names.ts b/src/config/service-names.ts new file mode 100644 index 00000000..8c710531 --- /dev/null +++ b/src/config/service-names.ts @@ -0,0 +1,32 @@ +/** + * @fileoverview Supervisor identity (systemd unit name / launchd job label). + * + * Three things now write or look for the same supervisor job: `install.sh`, the + * in-app self-updater (`web/self-update.ts` detects it to decide how to restart), + * and `codeman service install`. The names live here so they cannot drift apart, + * because a mismatch is silent in the worst way: `service install` would happily + * create a SECOND job alongside the installer's, and two servers sharing one data + * dir and one tmux socket attach PTYs to each other's live sessions + * (see config/instance.ts). + * + * The names are instance-scoped for exactly that reason: a `CODEMAN_INSTANCE=beta` + * build writing `com.codeman.web` would overwrite the production LaunchAgent. The + * DEFAULT instance keeps the historical names byte-identical, so existing installs + * and every unit install.sh has already written are unaffected. + * + * @module config/service-names + */ + +import { CODEMAN_INSTANCE } from './instance.js'; + +/** + * Instance name reduced to characters that are safe in a filename and in a + * launchd label. `CODEMAN_INSTANCE` is arbitrary operator input. + */ +const SAFE_INSTANCE = CODEMAN_INSTANCE.replace(/[^A-Za-z0-9_-]/g, '').slice(0, 32); + +/** systemd user unit: `codeman-web.service`, or `codeman-web-beta.service` for a beta. */ +export const SYSTEMD_UNIT = `codeman-web${SAFE_INSTANCE ? `-${SAFE_INSTANCE}` : ''}.service`; + +/** launchd job label: `com.codeman.web`, or `com.codeman.beta.web` for a beta. */ +export const LAUNCHD_LABEL = SAFE_INSTANCE ? `com.codeman.${SAFE_INSTANCE}.web` : 'com.codeman.web'; diff --git a/src/daemon-control.ts b/src/daemon-control.ts new file mode 100644 index 00000000..52a6ac00 --- /dev/null +++ b/src/daemon-control.ts @@ -0,0 +1,496 @@ +/** + * @fileoverview Detached `codeman web` control: start (-d), stop, status. + * + * Backs `codeman web -d`, `codeman web --stop` and `codeman web --status`. The + * server itself is unchanged; this module re-launches the SAME entry script in a + * new session (`detached: true` calls setsid), so the child has no controlling + * terminal and no shell job entry. That is what actually makes it outlive the + * shell: `nohup` does not, because Node re-arms SIGHUP to its default disposition + * even when it inherits "ignore", and `cli.ts` installs a SIGHUP handler that + * shuts the server down gracefully (issue #231). + * + * Two rules shape the rest of the module: + * + * 1. **Never start a second server on one data dir.** `~/.codeman` and the + * `tmux -L codeman` socket are process-wide (config/instance.ts), so a second + * instance discovers and attaches PTYs to the first one's live sessions and + * starts resizing them. A double `-d` therefore has to be a hard error, which + * means checking both the pidfile AND the port before spawning. + * 2. **Never report success we have not seen.** The parent polls `/api/status` + * until the child answers (or dies) before printing a URL. A port clash or a + * missing dependency otherwise looks exactly like a clean start. + * + * Pure helpers (arg building, URL building, pidfile parsing, the process-identity + * check) are exported separately so they can be unit-tested without spawning. + * + * @module daemon-control + */ + +import { spawn, execFileSync } from 'node:child_process'; +import { appendFileSync, closeSync, existsSync, openSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'; +import http from 'node:http'; +import https from 'node:https'; +import { dataPath } from './config/instance.js'; +import { EXEC_TIMEOUT_MS } from './config/exec-timeout.js'; + +/** How long to wait for a freshly spawned server to answer `/api/status`. */ +const START_TIMEOUT_MS = 30_000; +/** How long to wait for a SIGTERM'd server to actually exit before giving up. */ +const STOP_TIMEOUT_MS = 15_000; +/** Poll interval while waiting for either of the above. */ +const POLL_INTERVAL_MS = 250; + +/** The `web` command's options, as far as a detached relaunch cares about them. */ +export interface WebLaunchOptions { + host: string; + port: number; + https: boolean; + titleHostname?: string; + allowUnauthenticatedNetwork?: boolean; + multiuser?: boolean; +} + +export interface StartResult { + ok: boolean; + pid?: number; + url?: string; + /** Machine-readable failure cause; `undefined` on success. */ + reason?: 'already-running' | 'exited' | 'timeout'; + message?: string; + logPath: string; +} + +export interface StopResult { + ok: boolean; + pid?: number; + reason?: 'not-running' | 'foreign-pid' | 'timeout' | 'no-pidfile-but-responding'; + message?: string; +} + +export interface DaemonStatus { + pid: number | null; + /** The pid in the pidfile is alive AND still looks like a Codeman web process. */ + running: boolean; + /** Something answered `/api/status` at the expected address. */ + responding: boolean; + version?: string; + url: string; + pidFile: string; + logPath: string; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Pure helpers +// ───────────────────────────────────────────────────────────────────────────── + +/** Rebuild the `web` argv for the child, dropping the daemon flags themselves. */ +export function buildWebArgs(options: WebLaunchOptions): string[] { + const args = ['web', '--host', options.host, '--port', String(options.port)]; + if (options.https) args.push('--https'); + if (options.titleHostname) args.push('--title-hostname', options.titleHostname); + if (options.allowUnauthenticatedNetwork) args.push('--allow-unauthenticated-network'); + if (options.multiuser) args.push('--multiuser'); + return args; +} + +/** + * Connectable address for this bind. A wildcard bind is not itself connectable, + * so `0.0.0.0` / `::` become loopback; a bare IPv6 literal gets bracketed. + */ +export function buildBaseUrl(options: WebLaunchOptions): string { + const protocol = options.https ? 'https' : 'http'; + let host = options.host.trim(); + if (host === '0.0.0.0' || host === '::' || host === '') host = '127.0.0.1'; + if (host.includes(':') && !host.startsWith('[')) host = `[${host}]`; + return `${protocol}://${host}:${options.port}`; +} + +/** The endpoint polled for readiness. */ +export function buildStatusUrl(options: WebLaunchOptions): string { + return `${buildBaseUrl(options)}/api/status`; +} + +/** Parse a pidfile body. Rejects garbage, and pid 1 (init is never ours). */ +export function parsePidFileContents(text: string): number | null { + const trimmed = text.trim(); + if (!/^\d+$/.test(trimmed)) return null; + const pid = Number.parseInt(trimmed, 10); + if (!Number.isSafeInteger(pid) || pid <= 1) return null; + return pid; +} + +/** + * Does this command line look like a Codeman web server? + * + * Pids are recycled, and a stale pidfile pointing at whatever inherited the + * number is a live footgun: `codeman web --stop` must not SIGTERM an unrelated + * process. Both the npm bin (`codeman`/`aicodeman`) and the direct entry + * (`node dist/index.js web`, `tsx src/index.ts web`) have to match. + */ +export function looksLikeCodemanWeb(command: string | null | undefined): boolean { + if (!command) return false; + if (!/(^|\s)web(\s|$)/.test(command)) return false; + return /(^|[/\s])(ai)?codeman(\s|$)/.test(command) || /index\.(js|ts)(\s|$)/.test(command); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Paths +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Resolved at call time, not module load: tests swap `HOME` per file, and the + * data dir is derived from it (see test/setup.ts). + */ +export function pidFilePath(): string { + return dataPath('web.pid'); +} + +/** Where a detached server's stdout/stderr is appended. */ +export function logFilePath(): string { + return dataPath('web.log'); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Process probing +// ───────────────────────────────────────────────────────────────────────────── + +/** Signal 0 liveness check. EPERM means the pid exists but is not ours. */ +export function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (err) { + return (err as NodeJS.ErrnoException).code === 'EPERM'; + } +} + +/** Full command line of a pid, or null. `-o command=` is portable to macOS. */ +export function readProcessCommand(pid: number): string | null { + try { + const out = execFileSync('ps', ['-o', 'command=', '-p', String(pid)], { + encoding: 'utf-8', + timeout: EXEC_TIMEOUT_MS, + stdio: ['ignore', 'pipe', 'ignore'], + }); + return out.trim() || null; + } catch { + return null; + } +} + +/** Read the pidfile, returning null when it is missing, empty or malformed. */ +export function readPidFile(): number | null { + const file = pidFilePath(); + if (!existsSync(file)) return null; + try { + return parsePidFileContents(readFileSync(file, 'utf-8')); + } catch { + return null; + } +} + +function removePidFile(): void { + try { + unlinkSync(pidFilePath()); + } catch { + /* already gone */ + } +} + +/** Pid of a live Codeman web server recorded in the pidfile, or null. */ +export function readLivePid(): number | null { + const pid = readPidFile(); + if (pid === null) return null; + if (!isProcessAlive(pid)) return null; + // A recycled pid is not ours. `ps` can also legitimately fail (containers with + // no procps); treat "cannot tell" as ours rather than orphaning the pidfile. + const command = readProcessCommand(pid); + if (command !== null && !looksLikeCodemanWeb(command)) return null; + return pid; +} + +// ───────────────────────────────────────────────────────────────────────────── +// HTTP readiness probe +// ───────────────────────────────────────────────────────────────────────────── + +export interface ProbeResult { + /** A Codeman server answered. A 401 counts: auth is active, the server is up. */ + up: boolean; + version?: string; +} + +/** + * Probe `/api/status`. Self-signed certs are accepted (`--https` generates one), + * and 401 counts as up because `CODEMAN_PASSWORD` gates that route. The body is + * checked so an unrelated service squatting on the port is not read as success. + */ +export function probeServer(url: string, timeoutMs = 2000): Promise { + return new Promise((resolve) => { + let settled = false; + const done = (result: ProbeResult) => { + if (settled) return; + settled = true; + resolve(result); + }; + + let target: URL; + try { + target = new URL(url); + } catch { + done({ up: false }); + return; + } + + const transport = target.protocol === 'https:' ? https : http; + const req = transport.request( + { + protocol: target.protocol, + hostname: target.hostname, + port: target.port, + path: target.pathname, + method: 'GET', + rejectUnauthorized: false, + timeout: timeoutMs, + headers: { Accept: 'application/json' }, + }, + (res) => { + if (res.statusCode === 401) { + res.resume(); + done({ up: true }); + return; + } + let body = ''; + res.setEncoding('utf-8'); + res.on('data', (chunk: string) => { + if (body.length < 4096) body += chunk; + }); + res.on('end', () => { + if (!body.includes('"success"')) { + done({ up: false }); + return; + } + let version: string | undefined; + try { + version = (JSON.parse(body) as { data?: { version?: string } }).data?.version; + } catch { + /* body was truncated at 4KB; up is still true */ + } + done({ up: true, version }); + }); + res.on('error', () => done({ up: false })); + } + ); + req.on('timeout', () => { + req.destroy(); + done({ up: false }); + }); + req.on('error', () => done({ up: false })); + req.end(); + }); +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Start / stop / status +// ───────────────────────────────────────────────────────────────────────────── + +/** + * The script to relaunch. `process.execArgv` is carried over with it so a dev + * run under tsx (whose execArgv holds the tsx loader flags) re-launches through + * tsx instead of handing a `.ts` file to bare node. + */ +function entryScript(): string { + const script = process.argv[1]; + if (!script) throw new Error('cannot determine the codeman entry script to relaunch'); + return script; +} + +/** Marks one launch in the append-only log so a tail cannot mix two runs. */ +const LOG_SEPARATOR = '=== codeman web start'; + +/** + * Last few lines of the daemon log, for reporting a failed start. The log is + * append-only across launches, so the tail starts at the last separator when + * there is one: otherwise a crash report is padded with the previous run's + * cheerful startup banner. + */ +export function tailLog(maxLines = 15): string { + try { + const lines = readFileSync(logFilePath(), 'utf-8').trimEnd().split('\n'); + const start = lines.map((line) => line.startsWith(LOG_SEPARATOR)).lastIndexOf(true); + const current = start === -1 ? lines : lines.slice(start + 1); + return current.slice(-maxLines).join('\n'); + } catch { + return ''; + } +} + +/** + * Spawn a detached `codeman web` and wait until it answers before returning. + * Refuses when a server is already up on this data dir (see rule 1 in the module + * docblock). + */ +export async function startDaemon(options: WebLaunchOptions): Promise { + const logPath = logFilePath(); + const url = buildBaseUrl(options); + const statusUrl = buildStatusUrl(options); + + const existingPid = readLivePid(); + if (existingPid !== null) { + return { + ok: false, + reason: 'already-running', + pid: existingPid, + logPath, + message: `a Codeman server is already running (pid ${existingPid}). Stop it with \`codeman web --stop\` first.`, + }; + } + const alreadyServing = await probeServer(statusUrl, 1500); + if (alreadyServing.up) { + return { + ok: false, + reason: 'already-running', + logPath, + url, + message: `something is already serving ${url}. Two servers on one data dir attach to each other's tmux sessions, so refusing to start.`, + }; + } + // A pidfile that survived a crash: the process is gone, so it is just litter. + if (readPidFile() !== null) removePidFile(); + + const args = buildWebArgs(options); + try { + appendFileSync(logPath, `\n${LOG_SEPARATOR} ${new Date().toISOString()} ===\n`, 'utf-8'); + } catch { + /* the spawn below reports a genuinely unwritable log */ + } + const logFd = openSync(logPath, 'a'); + let child; + try { + child = spawn(process.execPath, [...process.execArgv, entryScript(), ...args], { + detached: true, + stdio: ['ignore', logFd, logFd], + env: process.env, + }); + } finally { + closeSync(logFd); + } + + let exited = false; + child.on('exit', () => { + exited = true; + }); + child.on('error', () => { + exited = true; + }); + + const pid = child.pid; + if (pid === undefined) { + return { ok: false, reason: 'exited', logPath, message: 'failed to spawn the server process' }; + } + writeFileSync(pidFilePath(), `${pid}\n`, 'utf-8'); + + const deadline = Date.now() + START_TIMEOUT_MS; + while (Date.now() < deadline) { + if (exited) { + removePidFile(); + child.unref(); + return { + ok: false, + reason: 'exited', + logPath, + message: `the server exited during startup. Last lines of ${logPath}:\n${tailLog()}`, + }; + } + const probe = await probeServer(statusUrl, 1000); + if (probe.up) { + child.unref(); + return { ok: true, pid, url, logPath }; + } + await sleep(POLL_INTERVAL_MS); + } + + child.unref(); + return { + ok: false, + reason: 'timeout', + pid, + url, + logPath, + message: `the server did not answer ${url} within ${START_TIMEOUT_MS / 1000}s. It may still be starting; check ${logPath}.`, + }; +} + +/** SIGTERM the recorded server and wait for it to actually exit. */ +export async function stopDaemon(options: WebLaunchOptions): Promise { + const pid = readPidFile(); + if (pid === null) { + const probe = await probeServer(buildStatusUrl(options), 1500); + if (probe.up) { + return { + ok: false, + reason: 'no-pidfile-but-responding', + message: + 'a server is responding but there is no pidfile, so it was not started with `-d`. If it is a service use `codeman service uninstall` (or stop the unit); otherwise `pkill -f "index.js web"`.', + }; + } + return { ok: true, reason: 'not-running', message: 'no daemon is running; nothing to stop' }; + } + + if (!isProcessAlive(pid)) { + removePidFile(); + return { ok: true, pid, message: `stale pidfile removed (pid ${pid} was not running)` }; + } + + const command = readProcessCommand(pid); + if (command !== null && !looksLikeCodemanWeb(command)) { + return { + ok: false, + reason: 'foreign-pid', + pid, + message: `pid ${pid} is not a Codeman server (${command}). Refusing to signal it; delete ${pidFilePath()} if it is stale.`, + }; + } + + // SIGTERM, never SIGKILL: cli.ts flushes state on the way out. + try { + process.kill(pid, 'SIGTERM'); + } catch (err) { + return { ok: false, reason: 'foreign-pid', pid, message: `could not signal pid ${pid}: ${String(err)}` }; + } + + const deadline = Date.now() + STOP_TIMEOUT_MS; + while (Date.now() < deadline) { + if (!isProcessAlive(pid)) { + removePidFile(); + return { ok: true, pid }; + } + await sleep(POLL_INTERVAL_MS); + } + + return { + ok: false, + reason: 'timeout', + pid, + message: `pid ${pid} did not exit within ${STOP_TIMEOUT_MS / 1000}s. Force it with \`kill -9 ${pid}\` if you are sure.`, + }; +} + +/** Report on both halves: the recorded process, and whether the port answers. */ +export async function daemonStatus(options: WebLaunchOptions): Promise { + const url = buildBaseUrl(options); + const pid = readPidFile(); + const probe = await probeServer(buildStatusUrl(options), 2000); + return { + pid, + running: readLivePid() !== null, + responding: probe.up, + version: probe.version, + url, + pidFile: pidFilePath(), + logPath: logFilePath(), + }; +} diff --git a/src/service-installer.ts b/src/service-installer.ts new file mode 100644 index 00000000..2ac364db --- /dev/null +++ b/src/service-installer.ts @@ -0,0 +1,401 @@ +/** + * @fileoverview `codeman service install|uninstall|status`: write and load the + * systemd user unit (Linux) or LaunchAgent (macOS) that supervises `codeman web`. + * + * This is the "always running" half of issue #231, next to the "detached right + * now" half in daemon-control.ts. `install.sh` already does this for people who + * install with the one-liner; this exists for `npm i -g aicodeman` users, who + * otherwise have to hand-write a plist. + * + * Two details are load-bearing and easy to get wrong by hand: + * + * - **PATH.** launchd hands a job `/usr/bin:/bin:/usr/sbin:/sbin` and systemd's + * user manager is nearly as bare, so a Homebrew or nvm `node`, `tmux` or + * `claude` is simply not found and sessions fail in a way that reads as a + * Codeman bug. The unit therefore carries the PATH of the shell that ran the + * install, with the running node's own directory in front. + * - **The job name.** It is the one `install.sh` and the self-updater already use + * (config/service-names.ts), so re-running install.sh later updates this unit + * instead of supervising a second copy of the server. + * + * Secrets are deliberately NOT written here. `CODEMAN_PASSWORD` in the installing + * shell is not copied into the unit; the caller is told where to add it instead, + * because a unit file is long-lived, world-readable by default, and gets copied + * into bug reports. + * + * The file writers are pure string builders so they can be unit-tested without + * touching launchctl/systemctl. + * + * @module service-installer + */ + +import { execFileSync } from 'node:child_process'; +import { existsSync, mkdirSync, unlinkSync, writeFileSync } from 'node:fs'; +import { homedir, userInfo } from 'node:os'; +import { dirname, join } from 'node:path'; +import { LAUNCHD_LABEL, SYSTEMD_UNIT } from './config/service-names.js'; +import { CODEMAN_INSTANCE } from './config/instance.js'; +import { EXEC_TIMEOUT_MS } from './config/exec-timeout.js'; +import { + buildBaseUrl, + buildStatusUrl, + buildWebArgs, + logFilePath, + probeServer, + type WebLaunchOptions, +} from './daemon-control.js'; + +export type ServiceKind = 'launchd' | 'systemd'; + +/** Everything a unit file needs, resolved from the environment by the caller. */ +export interface ServicePlan { + kind: ServiceKind; + /** systemd unit filename or launchd label. */ + name: string; + nodePath: string; + /** Runner flags carried over from the current process (tsx loader in dev). */ + execArgv: string[]; + scriptPath: string; + args: string[]; + env: Record; + logPath: string; + workingDir: string; +} + +export interface ServiceActionResult { + ok: boolean; + message: string; + /** Path of the unit/plist that was written or removed. */ + unitPath?: string; + warnings?: string[]; +} + +export interface ServiceStatusResult { + kind: ServiceKind | null; + name: string; + unitPath: string; + installed: boolean; + loaded: boolean; + responding: boolean; + version?: string; + url: string; +} + +/** Directories worth having on PATH even when the installing shell lacked them. */ +const FALLBACK_PATH_DIRS = ['/opt/homebrew/bin', '/usr/local/bin', '/usr/bin', '/bin', '/usr/sbin', '/sbin']; + +// ───────────────────────────────────────────────────────────────────────────── +// Pure builders +// ───────────────────────────────────────────────────────────────────────────── + +/** XML text escaping for plist `` values. */ +export function xmlEscape(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +/** + * PATH for the supervised process: the running node's directory first (so an nvm + * or Homebrew node is used rather than whatever the supervisor finds), then the + * installing shell's PATH, then the fallbacks that are still missing. + * + * `node_modules/.bin` entries are dropped. npm and npx inject those for the + * lifetime of one command, and baking a project's local bin dir into a unit file + * that outlives the checkout is how a service ends up running a binary the + * operator deleted months ago. + */ +export function buildServicePath(nodeDir: string, currentPath: string, home: string): string { + const seen = new Set(); + const ordered: string[] = []; + const push = (dir: string) => { + const trimmed = dir.trim(); + if (!trimmed || seen.has(trimmed)) return; + if (/(^|\/)node_modules\/\.bin\/?$/.test(trimmed)) return; + seen.add(trimmed); + ordered.push(trimmed); + }; + + push(nodeDir); + for (const dir of currentPath.split(':')) push(dir); + push(join(home, '.local', 'bin')); + for (const dir of FALLBACK_PATH_DIRS) push(dir); + return ordered.join(':'); +} + +/** Environment written into the unit. Never includes secrets (see module docs). */ +export function buildServiceEnv( + nodeDir: string, + currentPath: string, + home: string, + lang?: string +): Record { + const env: Record = { + PATH: buildServicePath(nodeDir, currentPath, home), + HOME: home, + LANG: lang || 'en_US.UTF-8', + }; + if (CODEMAN_INSTANCE) env.CODEMAN_INSTANCE = CODEMAN_INSTANCE; + return env; +} + +/** systemd accepts double-quoted values; escape the two characters that matter. */ +export function systemdQuote(value: string): string { + return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`; +} + +export function buildLaunchAgentPlist(plan: ServicePlan): string { + const programArguments = [plan.nodePath, ...plan.execArgv, plan.scriptPath, ...plan.args] + .map((arg) => ` ${xmlEscape(arg)}`) + .join('\n'); + const environment = Object.entries(plan.env) + .map(([key, value]) => ` ${xmlEscape(key)}\n ${xmlEscape(value)}`) + .join('\n'); + + return ` + + + + Label + ${xmlEscape(plan.name)} + ProgramArguments + +${programArguments} + + EnvironmentVariables + +${environment} + + WorkingDirectory + ${xmlEscape(plan.workingDir)} + RunAtLoad + + KeepAlive + + ThrottleInterval + 10 + StandardOutPath + ${xmlEscape(plan.logPath)} + StandardErrorPath + ${xmlEscape(plan.logPath)} + + +`; +} + +export function buildSystemdUnit(plan: ServicePlan): string { + const execStart = [plan.nodePath, ...plan.execArgv, plan.scriptPath, ...plan.args] + .map((arg) => (/[\s"'\\]/.test(arg) ? systemdQuote(arg) : arg)) + .join(' '); + const environment = Object.entries(plan.env) + .map(([key, value]) => `Environment=${systemdQuote(`${key}=${value}`)}`) + .join('\n'); + + return `[Unit] +Description=Codeman Web Server +After=network.target + +[Service] +Type=simple +WorkingDirectory=${plan.workingDir} +ExecStart=${execStart} +Restart=always +RestartSec=10 +# Agents keep running in tmux when the server restarts, so only signal the +# server itself. +KillMode=process +${environment} +StandardOutput=journal +StandardError=journal +SyslogIdentifier=codeman +LimitNOFILE=65536 + +[Install] +WantedBy=default.target +`; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Environment resolution +// ───────────────────────────────────────────────────────────────────────────── + +export function detectServiceKind(): ServiceKind | null { + if (process.platform === 'darwin') return 'launchd'; + if (process.platform === 'linux') return 'systemd'; + return null; +} + +export function unitPathFor(kind: ServiceKind): string { + return kind === 'launchd' + ? join(homedir(), 'Library', 'LaunchAgents', `${LAUNCHD_LABEL}.plist`) + : join(homedir(), '.config', 'systemd', 'user', SYSTEMD_UNIT); +} + +function entryScript(): string { + const script = process.argv[1]; + if (!script) throw new Error('cannot determine the codeman entry script to supervise'); + return script; +} + +/** Resolve a full plan from the current process and the requested web options. */ +export function resolveServicePlan(kind: ServiceKind, options: WebLaunchOptions): ServicePlan { + const home = homedir(); + return { + kind, + name: kind === 'launchd' ? LAUNCHD_LABEL : SYSTEMD_UNIT, + nodePath: process.execPath, + execArgv: [...process.execArgv], + scriptPath: entryScript(), + args: buildWebArgs(options), + env: buildServiceEnv(dirname(process.execPath), process.env.PATH || '', home, process.env.LANG), + logPath: logFilePath(), + workingDir: home, + }; +} + +function run(command: string, args: string[]): { ok: boolean; output: string } { + try { + const output = execFileSync(command, args, { + encoding: 'utf-8', + timeout: EXEC_TIMEOUT_MS, + stdio: ['ignore', 'pipe', 'pipe'], + }); + return { ok: true, output: output.trim() }; + } catch (err) { + const e = err as { stderr?: Buffer | string; message?: string }; + const stderr = typeof e.stderr === 'string' ? e.stderr : e.stderr?.toString('utf-8'); + return { ok: false, output: (stderr || e.message || '').trim() }; + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Install / uninstall / status +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Write the unit, load it, and confirm the server actually answers before + * reporting success. `launchctl load` and `systemctl enable` are both quiet about + * a job that starts and immediately dies, which is the whole reason install.sh + * verifies too. + */ +export async function installService(options: WebLaunchOptions): Promise { + const kind = detectServiceKind(); + if (!kind) { + return { ok: false, message: `no supported supervisor on ${process.platform}; use \`codeman web -d\` instead` }; + } + + const plan = resolveServicePlan(kind, options); + const unitPath = unitPathFor(kind); + const warnings: string[] = []; + mkdirSync(dirname(unitPath), { recursive: true }); + + if (kind === 'launchd') { + const uid = process.getuid?.() ?? 0; + // Unload any previous copy first, otherwise bootstrap fails with "service + // already loaded" and leaves the OLD job running against the NEW file. + run('launchctl', ['bootout', `gui/${uid}/${LAUNCHD_LABEL}`]); + writeFileSync(unitPath, buildLaunchAgentPlist(plan), { encoding: 'utf-8', mode: 0o600 }); + const bootstrap = run('launchctl', ['bootstrap', `gui/${uid}`, unitPath]); + if (!bootstrap.ok) { + const legacy = run('launchctl', ['load', unitPath]); + if (!legacy.ok) { + return { + ok: false, + unitPath, + message: `wrote ${unitPath} but launchctl refused to load it: ${bootstrap.output}`, + }; + } + } + } else { + writeFileSync(unitPath, buildSystemdUnit(plan), { encoding: 'utf-8', mode: 0o600 }); + const reload = run('systemctl', ['--user', 'daemon-reload']); + if (!reload.ok) { + return { + ok: false, + unitPath, + message: `wrote ${unitPath} but \`systemctl --user daemon-reload\` failed: ${reload.output}`, + }; + } + const enable = run('systemctl', ['--user', 'enable', '--now', SYSTEMD_UNIT]); + if (!enable.ok) { + return { ok: false, unitPath, message: `wrote ${unitPath} but enabling it failed: ${enable.output}` }; + } + // Without lingering the unit stops at logout, which is exactly what someone + // installing a service does not want. Best effort: it needs polkit rights. + const linger = run('loginctl', ['enable-linger', userInfo().username]); + if (!linger.ok) { + warnings.push( + `could not enable lingering, so the service will stop when you log out. Run: sudo loginctl enable-linger ${userInfo().username}` + ); + } + } + + const url = buildBaseUrl(options); + const statusUrl = buildStatusUrl(options); + const deadline = Date.now() + 30_000; + while (Date.now() < deadline) { + const probe = await probeServer(statusUrl, 1000); + if (probe.up) { + return { ok: true, unitPath, warnings, message: `service installed and responding at ${url}` }; + } + await new Promise((resolve) => setTimeout(resolve, 500)); + } + + const hint = + kind === 'launchd' ? `tail -20 ${plan.logPath}` : `journalctl --user -u ${SYSTEMD_UNIT} -n 20 --no-pager`; + return { + ok: false, + unitPath, + warnings, + message: `wrote and loaded ${unitPath}, but nothing answered ${url} within 30s. Check: ${hint}`, + }; +} + +export function uninstallService(): ServiceActionResult { + const kind = detectServiceKind(); + if (!kind) return { ok: false, message: `no supported supervisor on ${process.platform}` }; + + const unitPath = unitPathFor(kind); + if (!existsSync(unitPath)) { + return { ok: false, unitPath, message: `no service installed at ${unitPath}` }; + } + + if (kind === 'launchd') { + const uid = process.getuid?.() ?? 0; + const bootout = run('launchctl', ['bootout', `gui/${uid}/${LAUNCHD_LABEL}`]); + if (!bootout.ok) run('launchctl', ['unload', unitPath]); + } else { + run('systemctl', ['--user', 'disable', '--now', SYSTEMD_UNIT]); + } + + try { + unlinkSync(unitPath); + } catch (err) { + return { ok: false, unitPath, message: `stopped the service but could not remove ${unitPath}: ${String(err)}` }; + } + if (kind === 'systemd') run('systemctl', ['--user', 'daemon-reload']); + + return { ok: true, unitPath, message: `service stopped and ${unitPath} removed. Your tmux sessions are untouched.` }; +} + +export async function serviceStatus(options: WebLaunchOptions): Promise { + const kind = detectServiceKind(); + const url = buildBaseUrl(options); + if (!kind) { + return { kind: null, name: '', unitPath: '', installed: false, loaded: false, responding: false, url }; + } + + const unitPath = unitPathFor(kind); + const name = kind === 'launchd' ? LAUNCHD_LABEL : SYSTEMD_UNIT; + const installed = existsSync(unitPath); + const loaded = + kind === 'launchd' + ? run('launchctl', ['list', LAUNCHD_LABEL]).ok + : run('systemctl', ['--user', 'is-active', SYSTEMD_UNIT]).output === 'active'; + const probe = await probeServer(buildStatusUrl(options), 2000); + + return { kind, name, unitPath, installed, loaded, responding: probe.up, version: probe.version, url }; +} diff --git a/src/web/self-update.ts b/src/web/self-update.ts index 2b85488a..40335cf5 100644 --- a/src/web/self-update.ts +++ b/src/web/self-update.ts @@ -30,6 +30,7 @@ import { homedir, tmpdir } from 'node:os'; import { randomUUID } from 'node:crypto'; import { createRequire } from 'node:module'; import { dataPath } from '../config/instance.js'; +import { LAUNCHD_LABEL, SYSTEMD_UNIT } from '../config/service-names.js'; import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js'; import type { InstallInfo, @@ -43,10 +44,9 @@ import type { const require = createRequire(import.meta.url); const { version: APP_VERSION } = require('../../package.json') as { version: string }; -/** systemd unit name (matches install.sh + scripts/codeman-web.service). */ -const SYSTEMD_UNIT = 'codeman-web.service'; -/** launchd agent label (matches install.sh setup_launchd_service). */ -const LAUNCHD_LABEL = 'com.codeman.web'; +// Unit name / job label live in config/service-names.ts so install.sh, this +// detector and `codeman service install` cannot drift apart. Unchanged for the +// default instance. /** Path to the persisted update status file. */ const STATUS_FILE = dataPath('update-status.json'); /** Network/git timeout for the "check" path (longer than EXEC_TIMEOUT_MS — ls-remote hits the network). */ diff --git a/test/daemon-control.test.ts b/test/daemon-control.test.ts new file mode 100644 index 00000000..49d777e6 --- /dev/null +++ b/test/daemon-control.test.ts @@ -0,0 +1,182 @@ +/** + * Unit tests for the pure halves of daemon-control (issue #231): argv rebuilding, + * the readiness URL, pidfile parsing, the stale-pid identity check, and the + * `/api/status` probe against a real socket. + */ + +import { describe, it, expect, afterAll, beforeAll } from 'vitest'; +import http from 'node:http'; +import { + buildBaseUrl, + buildStatusUrl, + buildWebArgs, + isProcessAlive, + looksLikeCodemanWeb, + parsePidFileContents, + probeServer, +} from '../src/daemon-control.js'; + +const PORT = 3212; + +describe('buildWebArgs', () => { + it('always passes host and port through explicitly', () => { + expect(buildWebArgs({ host: '127.0.0.1', port: 3000, https: false })).toEqual([ + 'web', + '--host', + '127.0.0.1', + '--port', + '3000', + ]); + }); + + it('forwards every optional flag it was given', () => { + const args = buildWebArgs({ + host: '0.0.0.0', + port: 8080, + https: true, + titleHostname: 'tower', + allowUnauthenticatedNetwork: true, + multiuser: true, + }); + expect(args).toEqual([ + 'web', + '--host', + '0.0.0.0', + '--port', + '8080', + '--https', + '--title-hostname', + 'tower', + '--allow-unauthenticated-network', + '--multiuser', + ]); + }); + + it('never re-emits the daemon flags themselves (the child must not re-fork)', () => { + const args = buildWebArgs({ host: '127.0.0.1', port: 3000, https: false }); + expect(args).not.toContain('--daemon'); + expect(args).not.toContain('-d'); + }); +}); + +describe('buildBaseUrl', () => { + it('is the address a browser can open, with no path on it', () => { + expect(buildBaseUrl({ host: '127.0.0.1', port: 3000, https: false })).toBe('http://127.0.0.1:3000'); + expect(buildBaseUrl({ host: '0.0.0.0', port: 8443, https: true })).toBe('https://127.0.0.1:8443'); + }); +}); + +describe('buildStatusUrl', () => { + it('uses http by default and https when asked', () => { + expect(buildStatusUrl({ host: '127.0.0.1', port: 3000, https: false })).toBe('http://127.0.0.1:3000/api/status'); + expect(buildStatusUrl({ host: '127.0.0.1', port: 3000, https: true })).toBe('https://127.0.0.1:3000/api/status'); + }); + + it('rewrites wildcard binds to loopback, since they are not connectable', () => { + expect(buildStatusUrl({ host: '0.0.0.0', port: 3000, https: false })).toBe('http://127.0.0.1:3000/api/status'); + expect(buildStatusUrl({ host: '::', port: 3000, https: false })).toBe('http://127.0.0.1:3000/api/status'); + }); + + it('brackets a bare IPv6 literal', () => { + expect(buildStatusUrl({ host: '::1', port: 3000, https: false })).toBe('http://[::1]:3000/api/status'); + expect(buildStatusUrl({ host: '[::1]', port: 3000, https: false })).toBe('http://[::1]:3000/api/status'); + }); +}); + +describe('parsePidFileContents', () => { + it('accepts a plain pid with surrounding whitespace', () => { + expect(parsePidFileContents('4242\n')).toBe(4242); + expect(parsePidFileContents(' 4242 ')).toBe(4242); + }); + + it('rejects garbage, empties and floats', () => { + expect(parsePidFileContents('')).toBeNull(); + expect(parsePidFileContents('not a pid')).toBeNull(); + expect(parsePidFileContents('42.5')).toBeNull(); + expect(parsePidFileContents('-42')).toBeNull(); + }); + + it('rejects pid 0 and pid 1: neither is ever our server', () => { + expect(parsePidFileContents('0')).toBeNull(); + expect(parsePidFileContents('1')).toBeNull(); + }); +}); + +describe('looksLikeCodemanWeb', () => { + it('matches the ways the server is actually launched', () => { + expect(looksLikeCodemanWeb('/usr/bin/node /home/u/.codeman/app/dist/index.js web')).toBe(true); + expect(looksLikeCodemanWeb('/usr/bin/node dist/index.js web --https')).toBe(true); + expect(looksLikeCodemanWeb('node /repo/src/index.ts web --port 3000')).toBe(true); + expect(looksLikeCodemanWeb('/opt/homebrew/bin/codeman web')).toBe(true); + expect(looksLikeCodemanWeb('aicodeman web --host 0.0.0.0')).toBe(true); + }); + + it('rejects anything that inherited a recycled pid', () => { + expect(looksLikeCodemanWeb(null)).toBe(false); + expect(looksLikeCodemanWeb('')).toBe(false); + expect(looksLikeCodemanWeb('/usr/bin/node dist/index.js session list')).toBe(false); + expect(looksLikeCodemanWeb('vim web')).toBe(false); + expect(looksLikeCodemanWeb('/usr/lib/systemd/systemd --user')).toBe(false); + }); +}); + +describe('isProcessAlive', () => { + it('sees this very process', () => { + expect(isProcessAlive(process.pid)).toBe(true); + }); + + it('does not see an unused high pid', () => { + // 2^22 is above the default pid_max on Linux and macOS. + expect(isProcessAlive(4_194_303)).toBe(false); + }); +}); + +describe('probeServer', () => { + let server: http.Server; + + beforeAll(async () => { + server = http.createServer((req, res) => { + if (req.url === '/unauthorized') { + res.writeHead(401).end('Unauthorized'); + return; + } + if (req.url === '/foreign') { + res.writeHead(200, { 'Content-Type': 'text/html' }).end('some other app'); + return; + } + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: true, data: { version: '9.9.9' } })); + }); + await new Promise((resolve) => server.listen(PORT, '127.0.0.1', resolve)); + }); + + afterAll(async () => { + await new Promise((resolve) => server.close(() => resolve())); + }); + + it('reports up and reads the version back', async () => { + const result = await probeServer(`http://127.0.0.1:${PORT}/api/status`); + expect(result.up).toBe(true); + expect(result.version).toBe('9.9.9'); + }); + + it('counts a 401 as up, because auth being active proves a server is there', async () => { + const result = await probeServer(`http://127.0.0.1:${PORT}/unauthorized`); + expect(result.up).toBe(true); + }); + + it('does not mistake an unrelated service squatting on the port for Codeman', async () => { + const result = await probeServer(`http://127.0.0.1:${PORT}/foreign`); + expect(result.up).toBe(false); + }); + + it('reports down when nothing is listening', async () => { + const result = await probeServer(`http://127.0.0.1:${PORT + 1}/api/status`, 1000); + expect(result.up).toBe(false); + }); + + it('reports down for a malformed url instead of throwing', async () => { + const result = await probeServer('not-a-url'); + expect(result.up).toBe(false); + }); +}); diff --git a/test/service-installer.test.ts b/test/service-installer.test.ts new file mode 100644 index 00000000..0115a59f --- /dev/null +++ b/test/service-installer.test.ts @@ -0,0 +1,179 @@ +/** + * Unit tests for the unit-file builders behind `codeman service install` + * (issue #231). These are the parts that must be right without launchctl or + * systemctl in the loop: PATH construction, escaping, and the file contents. + */ + +import { describe, it, expect } from 'vitest'; +import { + buildLaunchAgentPlist, + buildServiceEnv, + buildServicePath, + buildSystemdUnit, + detectServiceKind, + systemdQuote, + xmlEscape, + type ServicePlan, +} from '../src/service-installer.js'; + +function plan(overrides: Partial = {}): ServicePlan { + return { + kind: 'systemd', + name: 'codeman-web.service', + nodePath: '/usr/bin/node', + execArgv: [], + scriptPath: '/home/u/.codeman/app/dist/index.js', + args: ['web', '--host', '127.0.0.1', '--port', '3000'], + env: { PATH: '/usr/bin:/bin', HOME: '/home/u', LANG: 'en_US.UTF-8' }, + logPath: '/home/u/.codeman/web.log', + workingDir: '/home/u', + ...overrides, + }; +} + +describe('buildServicePath', () => { + it("puts the running node's directory first so nvm/homebrew node wins", () => { + const result = buildServicePath('/home/u/.nvm/versions/node/v22.0.0/bin', '/usr/bin:/bin', '/home/u'); + expect(result.split(':')[0]).toBe('/home/u/.nvm/versions/node/v22.0.0/bin'); + }); + + it('keeps the installing shell PATH, which is the whole point of the fix', () => { + const result = buildServicePath('/usr/bin', '/opt/homebrew/bin:/home/u/.bun/bin', '/home/u'); + expect(result.split(':')).toContain('/home/u/.bun/bin'); + expect(result.split(':')).toContain('/opt/homebrew/bin'); + }); + + it('appends the fallbacks a bare launchd PATH would otherwise be missing', () => { + const entries = buildServicePath('/usr/bin', '/usr/bin', '/home/u').split(':'); + expect(entries).toContain('/opt/homebrew/bin'); + expect(entries).toContain('/home/u/.local/bin'); + expect(entries).toContain('/usr/local/bin'); + }); + + it('never repeats a directory', () => { + const entries = buildServicePath('/usr/bin', '/usr/bin:/bin:/usr/bin', '/home/u').split(':'); + expect(new Set(entries).size).toBe(entries.length); + }); + + it('drops empty segments from a trailing-colon PATH', () => { + expect(buildServicePath('/usr/bin', '/usr/bin::/bin:', '/home/u').split(':')).not.toContain(''); + }); + + it('drops node_modules/.bin, which npx injects for one command only', () => { + const entries = buildServicePath( + '/usr/bin', + '/repo/node_modules/.bin:/repo/node_modules/.bin/:/home/u/bin', + '/home/u' + ).split(':'); + expect(entries.filter((e) => e.includes('node_modules'))).toEqual([]); + expect(entries).toContain('/home/u/bin'); + }); +}); + +describe('buildServiceEnv', () => { + it('carries PATH, HOME and a LANG default', () => { + const env = buildServiceEnv('/usr/bin', '/usr/bin:/bin', '/home/u'); + expect(env.HOME).toBe('/home/u'); + expect(env.LANG).toBe('en_US.UTF-8'); + expect(env.PATH).toContain('/usr/bin'); + }); + + it('prefers the caller LANG when there is one', () => { + expect(buildServiceEnv('/usr/bin', '/usr/bin', '/home/u', 'de_DE.UTF-8').LANG).toBe('de_DE.UTF-8'); + }); + + it('does not carry a password into the unit file', () => { + const env = buildServiceEnv('/usr/bin', '/usr/bin', '/home/u'); + expect(Object.keys(env)).not.toContain('CODEMAN_PASSWORD'); + }); +}); + +describe('escaping', () => { + it('escapes the five XML entities', () => { + expect(xmlEscape(`a&bd"e'f`)).toBe('a&b<c>d"e'f'); + }); + + it('quotes systemd values and escapes quotes and backslashes', () => { + expect(systemdQuote('plain')).toBe('"plain"'); + expect(systemdQuote('with "quotes"')).toBe('"with \\"quotes\\""'); + expect(systemdQuote('back\\slash')).toBe('"back\\\\slash"'); + }); +}); + +describe('buildLaunchAgentPlist', () => { + it('writes the label, the full command and the log paths', () => { + const xml = buildLaunchAgentPlist(plan({ kind: 'launchd', name: 'com.codeman.web' })); + expect(xml).toContain('com.codeman.web'); + expect(xml).toContain('/usr/bin/node'); + expect(xml).toContain('/home/u/.codeman/app/dist/index.js'); + expect(xml).toContain('web'); + expect(xml).toContain('/home/u/.codeman/web.log'); + }); + + it('keeps the argument order: node, script, then the web args', () => { + const xml = buildLaunchAgentPlist(plan({ kind: 'launchd', name: 'com.codeman.web' })); + // Match whole elements: the label itself contains the word "web". + const order = [ + '/usr/bin/node', + '/home/u/.codeman/app/dist/index.js', + 'web', + '--port', + ].map((s) => xml.indexOf(s)); + expect(order).toEqual([...order].sort((a, b) => a - b)); + expect(order.every((i) => i > -1)).toBe(true); + }); + + it('carries the runner flags so a tsx dev install still boots', () => { + const xml = buildLaunchAgentPlist(plan({ kind: 'launchd', execArgv: ['--import', 'tsx'] })); + expect(xml).toContain('--import'); + expect(xml).toContain('tsx'); + }); + + it('restarts on crash and at login', () => { + const xml = buildLaunchAgentPlist(plan({ kind: 'launchd' })); + expect(xml).toContain('KeepAlive'); + expect(xml).toContain('RunAtLoad'); + }); + + it('escapes a path with an ampersand instead of emitting broken XML', () => { + const xml = buildLaunchAgentPlist(plan({ kind: 'launchd', workingDir: '/Users/a&b' })); + expect(xml).toContain('/Users/a&b'); + expect(xml).not.toContain('/Users/a&b'); + }); +}); + +describe('buildSystemdUnit', () => { + it('builds ExecStart from node, script and args', () => { + expect(buildSystemdUnit(plan())).toContain( + 'ExecStart=/usr/bin/node /home/u/.codeman/app/dist/index.js web --host 127.0.0.1 --port 3000' + ); + }); + + it('quotes an argument containing spaces', () => { + const unit = buildSystemdUnit(plan({ scriptPath: '/home/my user/app/dist/index.js' })); + expect(unit).toContain('"/home/my user/app/dist/index.js"'); + }); + + it('writes each env var as a quoted Environment line', () => { + const unit = buildSystemdUnit(plan()); + expect(unit).toContain('Environment="PATH=/usr/bin:/bin"'); + expect(unit).toContain('Environment="HOME=/home/u"'); + }); + + it('keeps KillMode=process so agents survive a server restart', () => { + expect(buildSystemdUnit(plan())).toContain('KillMode=process'); + }); + + it('is installable and restarts on failure', () => { + const unit = buildSystemdUnit(plan()); + expect(unit).toContain('Restart=always'); + expect(unit).toContain('WantedBy=default.target'); + }); +}); + +describe('detectServiceKind', () => { + it('maps the platform to its supervisor', () => { + const expected = process.platform === 'darwin' ? 'launchd' : process.platform === 'linux' ? 'systemd' : null; + expect(detectServiceKind()).toBe(expected); + }); +});