import { existsSync, mkdirSync, writeFileSync, unlinkSync } from "fs";
import { join, resolve } from "path";
import { homedir } from "os";
import { getPaths } from "../utils/paths";
import { findDaemonPids } from "../core/daemon";
import { clearForceShutdownRequest, requestForceShutdown } from "../core/force-shutdown";
const PLIST_NAME = "com.niahere.agent";
const SYSTEMD_UNIT = "niahere.service";
function getExecCommand(): [string, string] {
const execPath = process.execPath;
// process.argv[1] may be undefined when called outside the normal CLI flow
// (e.g. from bun -e or programmatic import). Fall back to the known entry point.
let cliPath = process.argv[1];
if (!cliPath || cliPath === "undefined") {
const { resolve } = require("path");
cliPath = resolve(import.meta.dir, "../cli/index.ts");
}
return [execPath, cliPath];
}
// --- macOS launchd ---
function plistPath(): string {
return join(homedir(), "Library", "LaunchAgents", `${PLIST_NAME}.plist`);
}
function buildPlist(): string {
const paths = getPaths();
const [execPath, cliPath] = getExecCommand();
// Bun auto-loads .env from cwd. Without WorkingDirectory, launchd
// spawns the daemon with cwd=/ and any credentials in .env never load.
const workingDir = resolve(cliPath, "../../..");
return `
Label
${PLIST_NAME}
ProgramArguments
${execPath}
${cliPath}
run
WorkingDirectory
${workingDir}
RunAtLoad
KeepAlive
SuccessfulExit
ThrottleInterval
10
StandardOutPath
${paths.daemonLog}
StandardErrorPath
${paths.daemonLog}
EnvironmentVariables
PATH
${process.env.PATH || "/usr/local/bin:/usr/bin:/bin"}
`;
}
async function installLaunchd(): Promise {
const paths = getPaths();
const path = plistPath();
mkdirSync(join(homedir(), "Library", "LaunchAgents"), { recursive: true });
mkdirSync(`${paths.home}/tmp`, { recursive: true });
writeFileSync(path, buildPlist());
// Unload first (ignore errors if not loaded)
const unload = Bun.spawn(["launchctl", "unload", path], { stdout: "pipe", stderr: "pipe" });
await unload.exited;
const load = Bun.spawn(["launchctl", "load", path], { stdout: "pipe", stderr: "pipe" });
const exitCode = await load.exited;
if (exitCode !== 0) {
const stderr = await new Response(load.stderr).text();
throw new Error(`launchctl load failed: ${stderr.trim()}`);
}
}
async function uninstallLaunchd(): Promise {
const path = plistPath();
if (!existsSync(path)) return;
// Unload to stop the process and disable KeepAlive respawn.
// Keep the plist file so RunAtLoad starts the daemon on next login.
const unload = Bun.spawn(["launchctl", "unload", path], { stdout: "pipe", stderr: "pipe" });
await unload.exited;
}
function isLaunchdInstalled(): boolean {
return existsSync(plistPath());
}
// --- Linux systemd ---
function unitPath(): string {
return join(homedir(), ".config", "systemd", "user", SYSTEMD_UNIT);
}
function buildUnit(): string {
const paths = getPaths();
const [execPath, cliPath] = getExecCommand();
const workingDir = resolve(cliPath, "../../..");
return `[Unit]
Description=nia personal AI assistant
After=network.target
[Service]
ExecStart=${execPath} ${cliPath} run
WorkingDirectory=${workingDir}
Restart=always
RestartSec=5
StandardOutput=append:${paths.daemonLog}
StandardError=append:${paths.daemonLog}
Environment=PATH=${process.env.PATH || "/usr/local/bin:/usr/bin:/bin"}
[Install]
WantedBy=default.target
`;
}
async function installSystemd(): Promise {
const paths = getPaths();
const path = unitPath();
mkdirSync(join(homedir(), ".config", "systemd", "user"), { recursive: true });
mkdirSync(`${paths.home}/tmp`, { recursive: true });
writeFileSync(path, buildUnit());
const reload = Bun.spawn(["systemctl", "--user", "daemon-reload"], { stdout: "pipe", stderr: "pipe" });
await reload.exited;
const enable = Bun.spawn(["systemctl", "--user", "enable", "--now", SYSTEMD_UNIT], {
stdout: "pipe",
stderr: "pipe",
});
const exitCode = await enable.exited;
if (exitCode !== 0) {
const stderr = await new Response(enable.stderr).text();
throw new Error(`systemctl enable failed: ${stderr.trim()}`);
}
}
async function uninstallSystemd(): Promise {
const path = unitPath();
if (!existsSync(path)) return;
const disable = Bun.spawn(["systemctl", "--user", "disable", "--now", SYSTEMD_UNIT], {
stdout: "pipe",
stderr: "pipe",
});
await disable.exited;
try {
unlinkSync(path);
} catch {
/* already gone */
}
const reload = Bun.spawn(["systemctl", "--user", "daemon-reload"], { stdout: "pipe", stderr: "pipe" });
await reload.exited;
}
function isSystemdInstalled(): boolean {
return existsSync(unitPath());
}
// --- Public API (platform-aware) ---
export async function registerService(): Promise {
if (process.platform === "darwin") {
await installLaunchd();
} else if (process.platform === "linux") {
await installSystemd();
}
// Windows/other: no-op, daemon still works via startDaemon()
}
export async function unregisterService(opts: { force?: boolean } = {}): Promise {
if (opts.force) requestForceShutdown(findDaemonPids());
if (process.platform === "darwin") {
await uninstallLaunchd();
} else if (process.platform === "linux") {
await uninstallSystemd();
}
}
export function isServiceInstalled(): boolean {
if (process.platform === "darwin") return isLaunchdInstalled();
if (process.platform === "linux") return isSystemdInstalled();
return false;
}
/** Restart via service manager. Waits for old process to fully exit before starting new one. */
export async function restartService(opts: { force?: boolean } = {}): Promise {
if (opts.force) requestForceShutdown(findDaemonPids());
const waitMs = opts.force ? 30_000 : 310_000;
if (process.platform === "darwin") {
const path = plistPath();
// Unload — sends SIGTERM and disables KeepAlive respawn
const unload = Bun.spawn(["launchctl", "unload", path], { stdout: "pipe", stderr: "pipe" });
if ((await unload.exited) !== 0) {
const stderr = await new Response(unload.stderr).text();
console.error(` warning: launchctl unload failed: ${stderr.trim()}`);
}
// Wait for old daemon to fully exit (graceful shutdown may take time for active engines)
await waitForDaemonExit(waitMs);
if (opts.force) clearForceShutdownRequest();
// Load — starts a fresh single instance
const load = Bun.spawn(["launchctl", "load", path], { stdout: "pipe", stderr: "pipe" });
if ((await load.exited) !== 0) {
const stderr = await new Response(load.stderr).text();
console.error(` warning: launchctl load failed: ${stderr.trim()}`);
}
} else if (process.platform === "linux") {
// systemd restart handles stop-wait-start internally
const restart = Bun.spawn(["systemctl", "--user", "restart", SYSTEMD_UNIT], { stdout: "pipe", stderr: "pipe" });
await restart.exited;
if (opts.force) await waitForDaemonExit(waitMs);
if (opts.force) clearForceShutdownRequest();
}
}
async function waitForDaemonExit(timeoutMs: number): Promise {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (findDaemonPids().length === 0) return;
await new Promise((r) => setTimeout(r, 200));
}
// Force kill any stragglers
for (const pid of findDaemonPids()) {
try {
process.kill(pid, "SIGKILL");
} catch {}
}
}