import { spawn, spawnSync } from "node:child_process"; import { existsSync, readFileSync } from "node:fs"; import { createRequire } from "node:module"; import path from "node:path"; import { loadConfig, resolvePaseoHome } from "@getpaseo/server"; import { tryConnectToDaemon } from "../../utils/client.js"; export interface DaemonStartOptions { port?: string; listen?: string; home?: string; foreground?: boolean; relay?: boolean; mcp?: boolean; injectMcp?: boolean; hostnames?: string; } export interface LocalDaemonPidInfo { pid: number; startedAt?: string; hostname?: string; uid?: number; listen?: string; desktopManaged?: boolean; } export interface LocalDaemonState { home: string; listen: string; logPath: string; pidPath: string; pidInfo: LocalDaemonPidInfo | null; running: boolean; stalePidFile: boolean; } export interface DetachedStartResult { pid: number | null; logPath: string; } export interface StopLocalDaemonOptions { home?: string; timeoutMs?: number; force?: boolean; } export interface StopLocalDaemonResult { action: "stopped" | "not_running"; home: string; pid: number | null; forced: boolean; message: string; } interface ProcessExitDetails { code: number | null; signal: NodeJS.Signals | null; error?: Error; } type DetachedStartupResult = { exitedEarly: false } | ({ exitedEarly: true } & ProcessExitDetails); const DETACHED_STARTUP_GRACE_MS = 1200; const PID_POLL_INTERVAL_MS = 100; const KILL_TIMEOUT_MS = 3000; const DAEMON_LOG_FILENAME = "daemon.log"; const DAEMON_PID_FILENAME = "paseo.pid"; export const DEFAULT_STOP_TIMEOUT_MS = 15_000; const require = createRequire(import.meta.url); const startupReady = (): DetachedStartupResult => ({ exitedEarly: false }); const startupExited = (details: ProcessExitDetails): DetachedStartupResult => ({ exitedEarly: true, ...details, }); function envWithHome(home?: string): NodeJS.ProcessEnv { if (!home) { return process.env; } return { ...process.env, PASEO_HOME: home }; } function buildRunnerArgs(options: DaemonStartOptions): string[] { const args: string[] = []; if (options.relay === false) { args.push("--no-relay"); } if (options.mcp === false) { args.push("--no-mcp"); } if (options.injectMcp === false) { args.push("--no-inject-mcp"); } return args; } function buildChildEnv(options: DaemonStartOptions): NodeJS.ProcessEnv { const childEnv: NodeJS.ProcessEnv = { ...process.env }; if (options.home) { childEnv.PASEO_HOME = options.home; } if (options.listen) { childEnv.PASEO_LISTEN = options.listen; } else if (options.port) { childEnv.PASEO_LISTEN = `127.0.0.1:${options.port}`; } if (options.hostnames) { childEnv.PASEO_HOSTNAMES = options.hostnames; } return childEnv; } function resolveServerRunnerFromDir(currentDir: string): string | null { const packageJsonPath = path.join(currentDir, "package.json"); if (!existsSync(packageJsonPath)) return null; try { const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8")) as { name?: string }; if (packageJson.name !== "@getpaseo/server") return null; const distRunner = path.join(currentDir, "dist", "scripts", "supervisor-entrypoint.js"); if (existsSync(distRunner)) { return distRunner; } return path.join(currentDir, "scripts", "supervisor-entrypoint.ts"); } catch { return null; } } function resolveDaemonRunnerEntry(): string { const serverExportPath = require.resolve("@getpaseo/server"); let currentDir = path.dirname(serverExportPath); while (true) { const entry = resolveServerRunnerFromDir(currentDir); if (entry) { return entry; } const parentDir = path.dirname(currentDir); if (parentDir === currentDir) { break; } currentDir = parentDir; } throw new Error("Unable to resolve @getpaseo/server package root for daemon runner"); } function pidFilePath(paseoHome: string): string { return path.join(paseoHome, DAEMON_PID_FILENAME); } function resolveListenField(listen: unknown, sockPath: unknown): string | undefined { if (typeof listen === "string") return listen; if (typeof sockPath === "string") return sockPath; return undefined; } function resolveStopMessage( forced: boolean, lifecycleRequested: boolean, fallbackMessage: string | null | undefined, ): string { if (forced) return "Daemon owner process was force-stopped"; if (lifecycleRequested) return "Daemon stopped gracefully"; return fallbackMessage ?? "Daemon stopped via owner PID signal"; } function readPidFile(pidPath: string): LocalDaemonPidInfo | null { try { const parsed = JSON.parse(readFileSync(pidPath, "utf-8")) as Record; const pidValue = parsed.pid; if (typeof pidValue !== "number" || !Number.isInteger(pidValue) || pidValue <= 0) { return null; } return { pid: pidValue, startedAt: typeof parsed.startedAt === "string" ? parsed.startedAt : undefined, hostname: typeof parsed.hostname === "string" ? parsed.hostname : undefined, uid: typeof parsed.uid === "number" ? parsed.uid : undefined, listen: resolveListenField(parsed.listen, parsed.sockPath), desktopManaged: parsed.desktopManaged === true ? true : undefined, }; } catch { return null; } } function tailFile(filePath: string, lines = 30): string | null { try { const content = readFileSync(filePath, "utf-8"); return content.split("\n").filter(Boolean).slice(-lines).join("\n"); } catch { return null; } } function sleep(ms: number): Promise { return new Promise((resolve) => { setTimeout(resolve, ms); }); } function readNodeErrnoCode(error: unknown): string | undefined { if (typeof error !== "object" || error === null || !("code" in error)) { return undefined; } return typeof error.code === "string" ? error.code : undefined; } function isProcessRunning(pid: number): boolean { try { process.kill(pid, 0); return true; } catch (err) { const code = readNodeErrnoCode(err); if (code === "EPERM") { return true; } return false; } } function signalProcess(pid: number, signal: NodeJS.Signals): boolean { try { process.kill(pid, signal); return true; } catch (err) { const code = readNodeErrnoCode(err); if (code === "ESRCH") { return false; } throw err; } } function signalProcessSafely(pid: number, signal: NodeJS.Signals): boolean { if (!Number.isInteger(pid) || pid <= 1 || pid === process.pid) { return false; } try { return signalProcess(pid, signal); } catch (err) { const code = readNodeErrnoCode(err); if (code === "EPERM") { return true; } throw err; } } function signalProcessGroupSafely(pid: number, signal: NodeJS.Signals): boolean { if (!Number.isInteger(pid) || pid <= 1 || pid === process.pid) { return false; } if (process.platform === "win32") { return signalProcessSafely(pid, signal); } try { process.kill(-pid, signal); return true; } catch (err) { const code = readNodeErrnoCode(err); if (code === "ESRCH") { return signalProcessSafely(pid, signal); } if (code === "EPERM") { return true; } throw err; } } async function waitForPidExit(pid: number, timeoutMs: number): Promise { const deadline = Date.now() + timeoutMs; async function poll(): Promise { if (!isProcessRunning(pid)) return true; if (Date.now() >= deadline) return !isProcessRunning(pid); await sleep(PID_POLL_INTERVAL_MS); return poll(); } return poll(); } type LifecycleShutdownAttempt = { requested: true } | { requested: false; reason: string }; function getErrorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } export function resolveLocalPaseoHome(home?: string): string { return resolvePaseoHome(envWithHome(home)); } export function resolveTcpHostFromListen(listen: string): string | null { const normalized = listen.trim(); if (!normalized) { return null; } if ( normalized.startsWith("/") || normalized.startsWith("unix://") || normalized.startsWith("pipe://") || normalized.startsWith("\\\\.\\pipe\\") || /^[A-Za-z]:[/\\]/.test(normalized) ) { return null; } if (/^\d+$/.test(normalized)) { return `127.0.0.1:${normalized}`; } if (normalized.includes(":")) { return normalized; } return null; } export function resolveLocalDaemonState(options: { home?: string } = {}): LocalDaemonState { const env: NodeJS.ProcessEnv = { ...envWithHome(options.home), // Status should reflect local persisted config + pid file, not inherited daemon env overrides. PASEO_LISTEN: undefined, PASEO_HOSTNAMES: undefined, PASEO_ALLOWED_HOSTS: undefined, }; const home = resolvePaseoHome(env); const config = loadConfig(home, { env }); const pidPath = pidFilePath(home); const logPath = path.join(home, DAEMON_LOG_FILENAME); const pidInfo = existsSync(pidPath) ? readPidFile(pidPath) : null; const running = pidInfo ? isProcessRunning(pidInfo.pid) : false; const listen = pidInfo?.listen ?? config.listen; return { home, listen, logPath, pidPath, pidInfo, running, stalePidFile: Boolean(pidInfo) && !running, }; } export function tailDaemonLog(home?: string, lines = 30): string | null { const logPath = path.join(resolveLocalPaseoHome(home), DAEMON_LOG_FILENAME); return tailFile(logPath, lines); } export async function startLocalDaemonDetached( options: DaemonStartOptions, ): Promise { if (options.listen && options.port) { throw new Error("Cannot use --listen and --port together"); } const childEnv = buildChildEnv(options); const paseoHome = resolvePaseoHome(childEnv); const logPath = path.join(paseoHome, DAEMON_LOG_FILENAME); const daemonRunnerEntry = resolveDaemonRunnerEntry(); const child = spawn( process.execPath, [...process.execArgv, daemonRunnerEntry, ...buildRunnerArgs(options)], { detached: true, env: childEnv, stdio: ["ignore", "ignore", "ignore"], }, ); child.unref(); const startup = await new Promise((resolve) => { let settled = false; const finish = (value: DetachedStartupResult) => { if (settled) return; settled = true; resolve(value); }; const timer = setTimeout(() => finish(startupReady()), DETACHED_STARTUP_GRACE_MS); child.once("error", (error) => { clearTimeout(timer); finish(startupExited({ code: null, signal: null, error })); }); child.once("exit", (code, signal) => { clearTimeout(timer); finish(startupExited({ code, signal })); }); }); if (startup.exitedEarly) { const reason = startup.error ? startup.error.message : `exit code ${startup.code ?? "unknown"}${startup.signal ? ` (${startup.signal})` : ""}`; const recentLogs = tailFile(logPath); throw new Error( [ `Daemon failed to start in background (${reason}).`, recentLogs ? `Recent daemon logs:\n${recentLogs}` : null, ] .filter(Boolean) .join("\n\n"), ); } return { pid: child.pid ?? null, logPath, }; } export function startLocalDaemonForeground(options: DaemonStartOptions): number { if (options.listen && options.port) { throw new Error("Cannot use --listen and --port together"); } const childEnv = buildChildEnv(options); const daemonRunnerEntry = resolveDaemonRunnerEntry(); const result = spawnSync( process.execPath, [...process.execArgv, daemonRunnerEntry, ...buildRunnerArgs(options)], { env: childEnv, stdio: "inherit", }, ); if (result.error) { throw result.error; } return result.status ?? 1; } async function requestLifecycleShutdown( state: LocalDaemonState, timeoutMs: number, ): Promise { const host = resolveTcpHostFromListen(state.listen); if (!host) { return { requested: false, reason: "daemon listen target is not TCP, falling back to owner PID signal", }; } const client = await tryConnectToDaemon({ host, timeout: Math.min(timeoutMs, 5000) }); if (!client) { return { requested: false, reason: `daemon websocket at ${host} is not reachable, falling back to owner PID signal`, }; } try { await client.shutdownServer(); return { requested: true }; } catch (error) { return { requested: false, reason: `daemon lifecycle shutdown request failed (${getErrorMessage( error, )}), falling back to owner PID signal`, }; } finally { await client.close().catch(() => undefined); } } export async function stopLocalDaemon( options: StopLocalDaemonOptions = {}, ): Promise { const timeoutMs = options.timeoutMs ?? DEFAULT_STOP_TIMEOUT_MS; const state = resolveLocalDaemonState({ home: options.home }); if (!state.pidInfo || !state.running) { const staleSuffix = state.stalePidFile && state.pidInfo ? ` (stale PID file for ${state.pidInfo.pid})` : ""; return { action: "not_running", home: state.home, pid: state.pidInfo?.pid ?? null, forced: false, message: `Daemon is not running${staleSuffix}`, }; } const pid = state.pidInfo.pid; const shutdownAttempt = await requestLifecycleShutdown(state, timeoutMs); const lifecycleRequested = shutdownAttempt.requested; const fallbackMessage = shutdownAttempt.requested ? null : shutdownAttempt.reason; let forced = false; if (!lifecycleRequested) { const signaled = signalProcessSafely(pid, "SIGTERM"); if (!signaled) { return { action: "not_running", home: state.home, pid, forced: false, message: "Daemon process was already stopped", }; } } let stopped = await waitForPidExit(pid, timeoutMs); if (!stopped && options.force) { forced = true; signalProcessGroupSafely(pid, "SIGKILL"); stopped = await waitForPidExit(pid, KILL_TIMEOUT_MS); } if (!stopped) { throw new Error( `Timed out waiting for daemon PID ${pid} to stop after ${Math.ceil(timeoutMs / 1000)}s`, ); } return { action: "stopped", home: state.home, pid, forced, message: resolveStopMessage(forced, lifecycleRequested, fallbackMessage), }; }