/**
* macOS launchd service management.
*
* Installs/uninstalls a per-user LaunchAgent that keeps the Oppi server
* running in the background. The server survives app quits, terminal closes,
* and reboots.
*
* Label: dev.chaosdonkey.oppi
* Plist: ~/Library/LaunchAgents/dev.chaosdonkey.oppi.plist
*
* Key design decisions (learned from OpenClaw #40659):
* - All paths in ProgramArguments are resolved to absolute paths at install time
* - PATH env includes /opt/homebrew/bin so git, pi, tailscale are available
* - KeepAlive restarts on crash; RunAtLoad starts on boot/login
*/
import {
existsSync,
mkdirSync,
readFileSync,
realpathSync,
unlinkSync,
writeFileSync,
} from "node:fs";
import { execSync } from "node:child_process";
import { homedir } from "node:os";
import { basename, join, resolve } from "node:path";
const LABEL = "dev.chaosdonkey.oppi";
const LEGACY_LABELS = ["dev.chenda.oppi"] as const;
function uid(): number {
const id = process.getuid?.();
if (id === undefined) throw new Error("uid() not available (not macOS?)");
return id;
}
function plistPathForLabel(label: string): string {
return join(homedir(), "Library", "LaunchAgents", `${label}.plist`);
}
function plistPath(): string {
return plistPathForLabel(LABEL);
}
function bootoutLabel(label: string): void {
try {
execSync(`launchctl bootout gui/${uid()}/${label} 2>/dev/null`, { stdio: "pipe" });
} catch {
// Not loaded — fine.
}
}
function bootoutPlist(path: string): void {
try {
execSync(`launchctl bootout gui/${uid()} ${path} 2>/dev/null`, { stdio: "pipe" });
} catch {
// Not loaded — fine.
}
}
function disableLegacyLaunchAgents(): string[] {
const removed: string[] = [];
for (const legacyLabel of LEGACY_LABELS) {
const legacyPlist = plistPathForLabel(legacyLabel);
bootoutLabel(legacyLabel);
if (!existsSync(legacyPlist)) {
continue;
}
bootoutPlist(legacyPlist);
unlinkSync(legacyPlist);
removed.push(legacyPlist);
}
return removed;
}
export interface LaunchdStatus {
installed: boolean;
running: boolean;
pid: number | null;
plistPath: string;
label: string;
}
type SemanticVersion = {
major: number;
minor: number;
patch: number;
};
function parseSemanticVersion(raw: string): SemanticVersion | null {
const normalized = raw.trim().replace(/^[vV]/, "");
const match = normalized.match(/^(\d+)(?:\.(\d+))?(?:\.(\d+))?/);
if (!match) {
return null;
}
return {
major: Number(match[1]),
minor: Number(match[2] || 0),
patch: Number(match[3] || 0),
};
}
function compareSemanticVersion(left: SemanticVersion, right: SemanticVersion): number {
if (left.major !== right.major) return left.major - right.major;
if (left.minor !== right.minor) return left.minor - right.minor;
return left.patch - right.patch;
}
function formatSemanticVersion(version: SemanticVersion): string {
return `${version.major}.${version.minor}.${version.patch}`;
}
function packageJsonPathForCli(cliPath: string): string {
let packageCliPath = cliPath;
try {
packageCliPath = realpathSync(cliPath);
} catch {
// Keep the original path when it is not a symlink or cannot be resolved.
}
return resolve(packageCliPath, "..", "..", "..", "package.json");
}
function minimumRequiredNodeVersion(cliPath?: string | null): SemanticVersion | null {
if (!cliPath) {
return null;
}
try {
const content = readFileSync(packageJsonPathForCli(cliPath), "utf-8");
const parsed = JSON.parse(content) as { engines?: { node?: string } };
const range = parsed.engines?.node?.trim();
if (!range) {
return null;
}
const match = range.match(/>=\s*([0-9]+(?:\.[0-9]+){0,2})/);
return match ? parseSemanticVersion(match[1]) : null;
} catch {
return null;
}
}
function installedNodeVersion(nodePath: string): SemanticVersion | null {
try {
const output = execSync(`"${nodePath}" --version`, {
encoding: "utf-8",
stdio: ["ignore", "pipe", "pipe"],
}).trim();
return parseSemanticVersion(output);
} catch {
return null;
}
}
function runtimeCompatibilityIssue(nodePath: string, cliPath?: string | null): string | null {
const minimum = minimumRequiredNodeVersion(cliPath);
if (!minimum) {
return null;
}
const installed = installedNodeVersion(nodePath);
if (!installed) {
return `Could not read Node.js version at ${nodePath}`;
}
if (compareSemanticVersion(installed, minimum) < 0) {
return `Node.js ${formatSemanticVersion(installed)} found, but Oppi requires Node.js ${formatSemanticVersion(minimum)} or newer`;
}
return null;
}
/**
* Resolve the absolute path to the Node.js runtime.
*
* Search order:
* 1. Homebrew Node.js
* 2. /usr/local Node.js
* 3. System Node.js
*/
function resolveRuntimeAbsolute(cliPath?: string | null): string | null {
const nodeCandidates = ["/opt/homebrew/bin/node", "/usr/local/bin/node", "/usr/bin/node"];
for (const p of nodeCandidates) {
if (existsSync(p) && !runtimeCompatibilityIssue(p, cliPath)) return p;
}
return null;
}
function runtimeResolutionFailureMessage(cliPath?: string | null): string {
const minimum = minimumRequiredNodeVersion(cliPath);
const nodeCandidates = ["/opt/homebrew/bin/node", "/usr/local/bin/node", "/usr/bin/node"];
for (const path of nodeCandidates) {
if (!existsSync(path)) continue;
return runtimeCompatibilityIssue(path, cliPath) || "Node.js runtime unavailable";
}
if (minimum) {
return `Node.js ${formatSemanticVersion(minimum)} or newer not found. Install Node.js and try again.`;
}
return "Node.js not found. Install Node.js and try again.";
}
/**
* Resolve the absolute path to the server CLI entry point.
*
* Search order:
* 1. The CLI that's currently running, including npm bin symlinks
* 2. npm global fallback locations
*/
function resolveCurrentCLIAbsolute(): string | null {
const selfCLI = process.argv[1];
if (!selfCLI || !existsSync(selfCLI)) return null;
// Preserve npm's public bin path instead of collapsing it to node_modules.
// The server then knows the exact directory that supplies `oppi` to humans
// and can keep that directory first for managed host-session tools.
if (basename(selfCLI) === "oppi" || selfCLI.endsWith("cli.js")) {
return resolve(selfCLI);
}
try {
const resolvedCLI = realpathSync(selfCLI);
return resolvedCLI.endsWith("cli.js") ? resolve(resolvedCLI) : null;
} catch {
return null;
}
}
function resolveCLIAbsolute(): string | null {
const currentCLI = resolveCurrentCLIAbsolute();
if (currentCLI) return currentCLI;
const globalCandidates = [
"/opt/homebrew/bin/oppi",
"/usr/local/bin/oppi",
join(homedir(), ".npm", "bin", "oppi"),
"/opt/homebrew/lib/node_modules/oppi-server/dist/src/cli.js",
"/usr/local/lib/node_modules/oppi-server/dist/src/cli.js",
join(homedir(), ".npm", "lib", "node_modules", "oppi-server", "dist", "src", "cli.js"),
];
for (const p of globalCandidates) {
if (existsSync(p)) return p;
}
return null;
}
function buildPlistXML(runtimePath: string, cliPath: string, dataDir: string): string {
const logPath = join(dataDir, "server.log");
return `
Label
${LABEL}
ProgramArguments
${runtimePath}
${cliPath}
serve
--data-dir
${dataDir}
WorkingDirectory
${homedir()}
EnvironmentVariables
PATH
/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin
OPPI_DATA_DIR
${dataDir}
RunAtLoad
KeepAlive
SuccessfulExit
ThrottleInterval
5
StandardOutPath
${logPath}
StandardErrorPath
${logPath}
ProcessType
Standard
`;
}
/**
* Install the LaunchAgent plist and load it.
*
* Returns a human-readable status message.
*/
export function installService(dataDir?: string): {
ok: boolean;
message: string;
runtimePath?: string;
cliPath?: string;
legacyRemoved?: string[];
} {
const cliPath = resolveCLIAbsolute();
const runtimePath = resolveRuntimeAbsolute(cliPath);
if (!runtimePath) {
return {
ok: false,
message: runtimeResolutionFailureMessage(cliPath),
};
}
if (!cliPath) {
return {
ok: false,
message: "Oppi CLI not found. Run 'npm install -g oppi-server' and try again.",
};
}
const resolvedDataDir = dataDir || join(homedir(), ".config", "oppi");
const plist = plistPath();
const launchAgentsDir = join(homedir(), "Library", "LaunchAgents");
let legacyRemoved: string[];
try {
legacyRemoved = disableLegacyLaunchAgents();
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
return {
ok: false,
message: `Failed to disable legacy LaunchAgent: ${msg}`,
runtimePath,
cliPath,
};
}
// Unload existing if present
if (existsSync(plist)) {
bootoutPlist(plist);
}
// Ensure LaunchAgents directory exists
mkdirSync(launchAgentsDir, { recursive: true });
// Write plist
const xml = buildPlistXML(runtimePath, cliPath, resolvedDataDir);
writeFileSync(plist, xml, { mode: 0o644 });
// Load the service
try {
execSync(`launchctl bootstrap gui/${uid()} ${plist}`, {
stdio: "pipe",
});
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
// Error 37 = already loaded — that's fine, just restart
if (msg.includes("37")) {
try {
execSync(`launchctl kickstart -k gui/${uid()}/${LABEL}`, { stdio: "pipe" });
} catch {
// Ignore restart failure
}
} else {
return { ok: false, message: `Failed to load LaunchAgent: ${msg}`, runtimePath, cliPath };
}
}
return {
ok: true,
message: `LaunchAgent installed and started`,
runtimePath,
cliPath,
legacyRemoved,
};
}
/**
* Uninstall the LaunchAgent — stop the service and remove the plist.
*/
export function uninstallService(): { ok: boolean; message: string } {
const plist = plistPath();
if (!existsSync(plist)) {
return { ok: true, message: "LaunchAgent not installed (nothing to remove)" };
}
// Unload
try {
execSync(`launchctl bootout gui/${uid()} ${plist}`, { stdio: "pipe" });
} catch {
// May already be unloaded
}
// Remove plist
try {
unlinkSync(plist);
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
return { ok: false, message: `Failed to remove plist: ${msg}` };
}
return { ok: true, message: "LaunchAgent uninstalled" };
}
/**
* Restart the service via launchctl kickstart.
*/
export function restartService(): { ok: boolean; message: string } {
const plist = plistPath();
if (!existsSync(plist)) {
return { ok: false, message: "LaunchAgent not installed. Run 'oppi server install' first." };
}
try {
execSync(`launchctl kickstart -k gui/${uid()}/${LABEL}`, { stdio: "pipe" });
return { ok: true, message: "Service restarted" };
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
return { ok: false, message: `Restart failed: ${msg}` };
}
}
/**
* Stop the service (it will NOT auto-restart due to KeepAlive config).
*
* To fully stop, we bootout which unloads the job. The plist remains
* on disk so a subsequent `server install` or reboot re-loads it.
*/
export function stopService(): { ok: boolean; message: string } {
const plist = plistPath();
if (!existsSync(plist)) {
return { ok: false, message: "LaunchAgent not installed." };
}
try {
execSync(`launchctl bootout gui/${uid()}/${LABEL}`, { stdio: "pipe" });
return {
ok: true,
message: "Service stopped (will start again on next login or 'oppi server install')",
};
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
if (msg.includes("No such process") || msg.includes("Could not find")) {
return { ok: true, message: "Service was not running" };
}
return { ok: false, message: `Stop failed: ${msg}` };
}
}
/**
* Check whether the LaunchAgent is installed and running.
*/
export function getServiceStatus(): LaunchdStatus {
const plist = plistPath();
const installed = existsSync(plist);
let running = false;
let pid: number | null = null;
if (installed) {
try {
const output = execSync(`launchctl print gui/${uid()}/${LABEL} 2>/dev/null`, {
encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"],
});
// Parse PID from launchctl print output
const pidMatch = output.match(/pid\s*=\s*(\d+)/);
if (pidMatch) {
pid = parseInt(pidMatch[1]);
running = pid > 0;
}
// Also check "state = running"
if (output.includes("state = running")) {
running = true;
}
} catch {
// Service not loaded
}
}
return { installed, running, pid, plistPath: plist, label: LABEL };
}
/**
* Read key paths from an installed plist (for display/diagnostics).
*/
export function readInstalledPlist(): {
runtimePath: string;
cliPath: string;
dataDir: string;
} | null {
const plist = plistPath();
if (!existsSync(plist)) return null;
try {
const content = readFileSync(plist, "utf-8");
const args =
content.match(/ProgramArguments<\/key>\s*([\s\S]*?)<\/array>/)?.[1] || "";
const strings = [...args.matchAll(/(.*?)<\/string>/g)].map((m) => m[1]);
// ProgramArguments: [runtimePath, cliPath, "serve", "--data-dir", dataDir]
if (strings.length >= 5) {
return {
runtimePath: strings[0],
cliPath: strings[1],
dataDir: strings[4],
};
}
} catch {
// Ignore parse errors
}
return null;
}