// launchd-plist.mjs — Spor C / C1 Tier 2: pure renderer for the detection // LaunchAgent. SKRIVER ALDRI / never writes — it only builds strings and // resolves paths. The thin imperative side (write the file, run launchctl) // lives in scheduler.mjs, mirroring the detection-schedule.mjs (pure core) + // run-detection.mjs (thin executor) split. // // Why these specific plist choices (search-first verified, 2026-06): // - StartCalendarInterval (NOT StartInterval): a calendar trigger runs the // job on wake if the fire time was missed during sleep; StartInterval just // skips it. Decisive for a laptop that sleeps. // - EnvironmentVariables.PATH: launchd starts jobs with a MINIMAL PATH, so a // bare `node` lookup (run-detection.mjs uses execFileSync('node', ...)) would // fail. We inject dirname(nodePath) PLUS the upgrade-stable /usr/local/bin // symlink dir, so a `brew upgrade node` (which moves the version-pinned // Cellar path) can't orphan the agent. // - Absolute paths only: launchd does not expand `~`. // // Pure: imports only node:path/node:url. No fs, no child_process. import { dirname } from 'node:path'; const LABEL = 'com.ktg.ms-ai-architect.detection'; /** The system PATH floor launchd should always have, plus Homebrew's stable symlink dir. */ const STABLE_PATH = ['/usr/local/bin', '/usr/bin', '/bin', '/usr/sbin', '/sbin']; /** Escape a value for inclusion in a plist (a plist is XML). */ export function xmlEscape(value) { return String(value) .replace(/&/g, '&') .replace(//g, '>'); } /** The stable reverse-DNS label for the detection LaunchAgent. */ export function defaultLabel() { return LABEL; } /** * Render a LaunchAgent plist that runs ` <...args>` * daily at hour:minute. Every interpolated value is XML-escaped; the schedule * is StartCalendarInterval (sleep-safe); RunAtLoad is false (first run at the * next scheduled fire, not at install). * @param {{label:string,nodePath:string,scriptPath:string,args:string[],hour:number,minute:number,logPath:string,workingDir:string}} o * @returns {string} */ export function renderPlist({ label, nodePath, scriptPath, args = [], hour, minute, logPath, workingDir }) { // PATH: dirname(node) first (so child `node` spawns resolve), then the stable floor. const pathValue = [dirname(nodePath), ...STABLE_PATH].join(':'); const programArgs = [nodePath, scriptPath, ...args] .map((a) => ` ${xmlEscape(a)}`) .join('\n'); return ` Label ${xmlEscape(label)} ProgramArguments ${programArgs} StartCalendarInterval Hour ${Number(hour)} Minute ${Number(minute)} RunAtLoad EnvironmentVariables PATH ${xmlEscape(pathValue)} WorkingDirectory ${xmlEscape(workingDir)} StandardOutPath ${xmlEscape(logPath)} StandardErrorPath ${xmlEscape(logPath)} `; } /** * Resolve the absolute paths the installer needs. All absolute (no `~`). * The LaunchAgent lives in ~/Library/LaunchAgents; the log lives in the * gitignored scripts/kb-update/data/ dir; launchd runs scheduler.mjs (which * gates on cadence, then delegates to the Claude-free run-detection.mjs). * @param {{pluginRoot:string,homeDir:string,nodePath:string}} o */ export function resolvePaths({ pluginRoot, homeDir }) { const label = LABEL; return { label, agentPlistPath: `${homeDir}/Library/LaunchAgents/${label}.plist`, scriptPath: `${pluginRoot}/scripts/kb-update/scheduler.mjs`, logPath: `${pluginRoot}/scripts/kb-update/data/detection-scheduler.log`, workingDir: pluginRoot, }; }