import { spawn, spawnSync } from 'node:child_process' import { closeSync, existsSync, openSync, readFileSync, rmSync } from 'node:fs' import { createRequire } from 'node:module' import path from 'node:path' import { loadConfig, resolvePaseoHome } from '@getpaseo/server' export interface DaemonStartOptions { port?: string listen?: string home?: string foreground?: boolean relay?: boolean mcp?: boolean allowedHosts?: string } export interface LocalDaemonPidInfo { pid: number startedAt?: string hostname?: string uid?: number sockPath?: string } 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 } type 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') } 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.allowedHosts) { childEnv.PASEO_ALLOWED_HOSTS = options.allowedHosts } return childEnv } function resolveDaemonRunnerEntry(): string { const serverExportPath = require.resolve('@getpaseo/server') let currentDir = path.dirname(serverExportPath) while (true) { const packageJsonPath = path.join(currentDir, 'package.json') if (existsSync(packageJsonPath)) { try { const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8')) as { name?: string } if (packageJson.name === '@getpaseo/server') { const distRunner = path.join(currentDir, 'dist', 'scripts', 'daemon-runner.js') if (existsSync(distRunner)) { return distRunner } return path.join(currentDir, 'scripts', 'daemon-runner.ts') } } catch { // Continue searching up if package.json exists but is invalid. } } 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 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, sockPath: typeof parsed.sockPath === 'string' ? parsed.sockPath : 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 } } async function waitForExit(pid: number, timeoutMs: number): Promise { const deadline = Date.now() + timeoutMs while (Date.now() < deadline) { if (!isProcessRunning(pid)) { return true } await sleep(PID_POLL_INTERVAL_MS) } return !isProcessRunning(pid) } 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://')) { 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_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?.sockPath ?? 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 logFd = openSync(logPath, 'a') try { const child = spawn( process.execPath, [...process.execArgv, daemonRunnerEntry, ...buildRunnerArgs(options)], { detached: true, env: childEnv, stdio: ['ignore', logFd, logFd], } ) 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, } } finally { closeSync(logFd) } } 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 } 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 signaled = signalProcess(pid, 'SIGTERM') if (!signaled) { return { action: 'not_running', home: state.home, pid, forced: false, message: 'Daemon process was already stopped', } } let forced = false let stopped = await waitForExit(pid, timeoutMs) if (!stopped && options.force) { forced = true const killSent = signalProcess(pid, 'SIGKILL') if (killSent) { stopped = await waitForExit(pid, KILL_TIMEOUT_MS) } else { stopped = true } } if (!stopped) { throw new Error( `Timed out waiting for daemon PID ${pid} to stop after ${Math.ceil(timeoutMs / 1000)}s` ) } rmSync(state.pidPath, { force: true }) return { action: 'stopped', home: state.home, pid, forced, message: forced ? 'Daemon was force-stopped' : 'Daemon stopped gracefully', } }