import { writeFileSync, unlinkSync, existsSync, chmodSync } from 'node:fs'; import { resolve } from 'node:path'; import { homedir } from 'node:os'; import { execSync } from 'node:child_process'; import { dirname } from 'node:path'; // --------------- types --------------- export interface PlistOptions { /** Absolute path to the Node.js binary */ nodePath: string; /** Absolute path to the daemon script (cli.js) */ scriptPath: string; /** Absolute path to the config file */ configPath: string; /** Directory to use as WorkingDirectory in the plist (defaults to homedir) */ workingDirectory?: string; /** Override stdout log path */ stdoutPath?: string; /** Override stderr log path */ stderrPath?: string; } export interface LaunchdStatus { /** Whether the daemon is registered with launchd */ registered: boolean; /** PID if currently running, null otherwise */ pid: number | null; /** Last exit status code, null if never exited or not available */ lastExitStatus: number | null; } export type RunCommandFn = (cmd: string) => string; // --------------- constants --------------- const LABEL = 'com.sf.daemon'; const PLIST_FILENAME = `${LABEL}.plist`; // --------------- helpers --------------- /** Escape special XML characters in a string. */ export function escapeXml(str: string): string { return str .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } /** Return the canonical plist path under ~/Library/LaunchAgents/. */ export function getPlistPath(): string { return resolve(homedir(), 'Library', 'LaunchAgents', PLIST_FILENAME); } /** * Build the NVM-aware PATH string. * Includes the directory containing the Node binary so that launchd can find node * even when launched outside a shell session (where NVM isn't sourced). */ function buildEnvPath(nodePath: string): string { const nodeBinDir = dirname(nodePath); // Keep system essentials and prepend the node binary's directory return `${nodeBinDir}:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin`; } // --------------- plist generation --------------- /** Generate valid launchd plist XML for the SF daemon. */ export function generatePlist(opts: PlistOptions): string { const home = homedir(); const workDir = opts.workingDirectory ?? home; const stdoutPath = opts.stdoutPath ?? resolve(home, '.sf', 'daemon-stdout.log'); const stderrPath = opts.stderrPath ?? resolve(home, '.sf', 'daemon-stderr.log'); const envPath = buildEnvPath(opts.nodePath); // Forward ANTHROPIC_API_KEY so the orchestrator LLM can authenticate. // Captured at install time from the current process environment. const anthropicKey = process.env.ANTHROPIC_API_KEY; const anthropicKeyXml = anthropicKey ? `\n\t\tANTHROPIC_API_KEY\n\t\t${escapeXml(anthropicKey)}` : ''; return ` \tLabel \t${escapeXml(LABEL)} \tProgramArguments \t \t\t${escapeXml(opts.nodePath)} \t\t${escapeXml(opts.scriptPath)} \t\t--config \t\t${escapeXml(opts.configPath)} \t \tKeepAlive \t \t\tSuccessfulExit \t\t \t \tRunAtLoad \t \tEnvironmentVariables \t \t\tPATH \t\t${escapeXml(envPath)} \t\tHOME \t\t${escapeXml(home)}${anthropicKeyXml} \t \tWorkingDirectory \t${escapeXml(workDir)} \tStandardOutPath \t${escapeXml(stdoutPath)} \tStandardErrorPath \t${escapeXml(stderrPath)} `; } // --------------- install / uninstall / status --------------- /** Default runCommand using execSync. */ function defaultRunCommand(cmd: string): string { return execSync(cmd, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }); } /** * Install the launchd agent: write plist and load it. * Idempotent — unloads first if already loaded. */ export function install( opts: PlistOptions, runCommand: RunCommandFn = defaultRunCommand, ): void { const plistPath = getPlistPath(); const xml = generatePlist(opts); // Unload first if already present (ignore errors) if (existsSync(plistPath)) { try { runCommand(`launchctl unload ${plistPath}`); } catch { // already unloaded — fine } } writeFileSync(plistPath, xml, 'utf-8'); chmodSync(plistPath, 0o644); runCommand(`launchctl load ${plistPath}`); // Verify it loaded try { runCommand(`launchctl list ${LABEL}`); } catch { throw new Error( `Plist was written to ${plistPath} and launchctl load succeeded, but launchctl list ${LABEL} failed. The agent may not have started.`, ); } } /** * Uninstall the launchd agent: unload and remove plist. * Graceful — does not throw if already uninstalled. */ export function uninstall(runCommand: RunCommandFn = defaultRunCommand): void { const plistPath = getPlistPath(); if (existsSync(plistPath)) { try { runCommand(`launchctl unload ${plistPath}`); } catch { // already unloaded — that's fine } unlinkSync(plistPath); } // If plist doesn't exist, nothing to do — already uninstalled } /** * Query launchd for the daemon's status. * Returns structured information about registration, PID, and last exit code. * * Handles two launchctl output formats: * 1. Tabular: "PID\tStatus\tLabel" (older macOS) * 2. JSON-style dict: `"PID" = 1234;` / `"LastExitStatus" = 0;` (newer macOS) */ export function status(runCommand: RunCommandFn = defaultRunCommand): LaunchdStatus { try { const output = runCommand(`launchctl list ${LABEL}`); // --- Try tabular format first --- const lines = output.trim().split('\n'); for (const line of lines) { const parts = line.trim().split(/\t+/); if (parts.length >= 3 && parts[2] === LABEL) { const pidStr = parts[0]; const statusStr = parts[1]; const pid = pidStr === '-' ? null : parseInt(pidStr, 10); const lastExitStatus = statusStr != null ? parseInt(statusStr, 10) : null; return { registered: true, pid: Number.isNaN(pid!) ? null : pid, lastExitStatus: Number.isNaN(lastExitStatus!) ? null : lastExitStatus, }; } } // --- Try JSON-style dict format --- // Matches: "PID" = 1234; or "LastExitStatus" = 0; const pidMatch = output.match(/"PID"\s*=\s*(\d+)\s*;/); const exitMatch = output.match(/"LastExitStatus"\s*=\s*(\d+)\s*;/); if (pidMatch || exitMatch) { const pid = pidMatch ? parseInt(pidMatch[1], 10) : null; const lastExitStatus = exitMatch ? parseInt(exitMatch[1], 10) : null; return { registered: true, pid: Number.isNaN(pid!) ? null : pid, lastExitStatus: Number.isNaN(lastExitStatus!) ? null : lastExitStatus, }; } // Label resolved (no error) but no parseable output — still registered return { registered: true, pid: null, lastExitStatus: null }; } catch { // launchctl list exits non-zero when the label isn't found return { registered: false, pid: null, lastExitStatus: null }; } }