/** * Generate and install / uninstall a supervisor unit for the SQLite * event-bus dispatcher. Linux gets a systemd unit; macOS gets a * launchd plist. The command WRITES the unit file but never touches * the supervisor state — the operator (or the celilo-mgmt Ansible * role) runs the enable/bootstrap steps themselves so the effect is * visible. * * Two scopes (designs/DISPATCHER_DAEMON_AND_TIMER_EVENTS.md A1): * - `user` (default): per-user unit under $HOME — dev-laptop shape. * Dies with the login session unless lingering is enabled. * - `system`: /etc/systemd/system unit (Linux) or a root-owned * /Library/LaunchDaemons plist (macOS) — the management-plane * shape; survives logout and reboot. The unit pins an explicit * run-as user (the celilo state-dir owner) so every handler * subprocess the dispatcher spawns stays signalable by that user * (ISS-0068 / A1b). * * Why split write-vs-enable: a celilo command silently flipping * systemd state would be hard to reason about during incidents. Pure * file-on-disk + printed next-steps keeps the operational surface * predictable. */ import { execFileSync } from 'node:child_process'; import { existsSync, mkdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from 'node:fs'; import { homedir, platform as nodePlatform, userInfo } from 'node:os'; import { dirname, join } from 'node:path'; import { getEventBusPath } from '../config/paths'; export type SupervisorPlatform = 'linux' | 'darwin'; export type SupervisorScope = 'user' | 'system'; export interface InstallDaemonOptions { celiloPath?: string; pollMs?: number; concurrency?: number; /** Unit scope. Defaults to `user`. */ scope?: SupervisorScope; /** * Run-as user for system-scope units. Defaults to the owner of the * bus DB's directory (the celilo state dir) — `celilo` on a * deb-bootstrapped box, root on a local install. Ignored for user * scope (user units already run as their owner). */ runAsUser?: string; /** Override platform detection. Mainly for tests. */ platform?: SupervisorPlatform; /** Override the bus DB path. Defaults to celilo's getEventBusPath(). */ busDbPath?: string; /** * Override the PATH written into the unit's environment. Defaults to * resolveDaemonPathEnv() captured at render time. Test seam — pin it so * assertions don't depend on the machine's bun install. */ pathEnv?: string; /** Override the home directory used to compute install paths. */ home?: string; /** Prefix for system-scope paths. Test seam — see getDaemonUnitPath. */ systemRoot?: string; } export interface InstallDaemonResult { platform: SupervisorPlatform; scope: SupervisorScope; unitPath: string; unitContent: string; celiloPath: string; busDbPath: string; /** Explicit run-as user for system scope; undefined for user scope. */ runAsUser?: string; /** * Set when the OTHER scope already has a unit installed. Both scopes use the * same unit name, so installing over that produces two same-named daemons on * one bus — see conflictingScopeError. */ conflict?: { scope: SupervisorScope; unitPath: string }; nextSteps: string[]; } export const SUPERVISOR_SCOPES: readonly SupervisorScope[] = ['user', 'system']; /** * Both scopes name the unit `celilo-events.service`, so `systemctl status * celilo-events.service` and `systemctl --user status celilo-events.service` * are different services that look identical in every operator-facing string. * * That is how celilo-mgr came to run two dispatchers for 40 days (#580, #610): * Ansible installed the system unit, someone later ran `celilo events * install-daemon` — which defaults to USER scope — and got a second daemon with * no warning. `systemctl status celilo-events.service` then reported the dead * system unit while the user-scope one served production. */ function conflictingScopeError(conflict: { scope: SupervisorScope; unitPath: string }): Error { const flag = conflict.scope === 'system' ? ' --system' : ''; return new Error( [ `celilo events daemon: a ${conflict.scope}-scope unit is already installed at ${conflict.unitPath}.`, 'Both scopes use the same unit name, so installing this one would create a SECOND dispatcher', 'on the same bus that `systemctl status` cannot distinguish.', `Remove the other first: \`celilo events uninstall-daemon${flag}\` (and disable it in systemd),`, 'or keep the existing one.', ].join(' '), ); } /** The other scope's unit, if it exists. */ function findConflictingScope( platform: SupervisorPlatform, home: string, scope: SupervisorScope, systemRoot?: string, ): { scope: SupervisorScope; unitPath: string } | undefined { const other = scope === 'user' ? 'system' : 'user'; const found = readInstalledUnit({ platform, scope: other, home, systemRoot }); return found.exists ? { scope: other, unitPath: found.path } : undefined; } export interface UninstallDaemonResult { platform: SupervisorPlatform; scope: SupervisorScope; unitPath: string; removed: boolean; nextSteps: string[]; } const SYSTEMD_UNIT_NAME = 'celilo-events.service'; const LAUNCHD_LABEL = 'com.celilo.events'; /** * Map a node `process.platform` to one of the supported supervisor * platforms. We don't ship Windows or BSD support for v1. */ export function detectPlatform(): SupervisorPlatform { const p = nodePlatform(); if (p === 'linux') return 'linux'; if (p === 'darwin') return 'darwin'; throw new Error( `celilo events daemon: unsupported platform "${p}". Only linux (systemd) and darwin (launchd) are supported in v1.`, ); } /** * Resolve where to write the unit file for a given platform + scope. * `home` only matters for user scope. * * `systemRoot` prefixes the system-scope path. It exists so a test can have * BOTH scopes installed at once without writing to the real /etc — the * two-units-one-name state that produced #610 is otherwise untestable, since * `home` alone can only ever relocate the user unit. */ export function getDaemonUnitPath( platform: SupervisorPlatform, home: string, scope: SupervisorScope = 'user', systemRoot = '/', ): string { if (platform === 'linux') { return scope === 'system' ? join(systemRoot, 'etc/systemd/system', SYSTEMD_UNIT_NAME) : join(home, '.config', 'systemd', 'user', SYSTEMD_UNIT_NAME); } return scope === 'system' ? join(systemRoot, 'Library/LaunchDaemons', `${LAUNCHD_LABEL}.plist`) : join(home, 'Library', 'LaunchAgents', `${LAUNCHD_LABEL}.plist`); } export interface LaunchdUnitStatus { /** The pid launchd currently runs the unit under, or null when it isn't running. */ pid: number | null; /** * The unit's last exit status in wait4 form (256 = exited 1, 15 = killed by * SIGTERM), or null when launchd has no record of one. */ lastExitStatus: number | null; } /** * Probe launchd for the unit's health. Uses `launchctl list