refactor(startup): unify dev and cli daemon startup through runner

This commit is contained in:
Mohamed Boudra
2026-02-16 03:22:03 +00:00
parent 5bd6cc11de
commit 04a06197de
5 changed files with 87 additions and 171 deletions
+1 -1
View File
@@ -12,7 +12,7 @@
],
"scripts": {
"dev": "./scripts/dev.sh",
"dev:server": "NODE_ENV=development tsx packages/server/scripts/dev-runner.ts",
"dev:server": "NODE_ENV=development tsx packages/server/scripts/daemon-runner.ts --dev",
"dev:app": "npm run start --workspace=@getpaseo/app",
"dev:website": "npm run dev --workspace=@getpaseo/website",
"postinstall": "node scripts/postinstall-patches.mjs",
@@ -1,9 +1,8 @@
import { spawn } from 'node:child_process'
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'
import { applySherpaLoaderEnv } from './sherpa-env.js'
export interface DaemonStartOptions {
port?: string
@@ -100,6 +99,22 @@ function buildRunnerArgs(options: DaemonStartOptions): string[] {
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)
@@ -280,19 +295,7 @@ export async function startLocalDaemonDetached(
throw new Error('Cannot use --listen and --port together')
}
const childEnv: NodeJS.ProcessEnv = { ...process.env }
applySherpaLoaderEnv(childEnv)
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
}
const childEnv = buildChildEnv(options)
const paseoHome = resolvePaseoHome(childEnv)
const logPath = path.join(paseoHome, DAEMON_LOG_FILENAME)
@@ -358,6 +361,29 @@ export async function startLocalDaemonDetached(
}
}
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<StopLocalDaemonResult> {
@@ -1,53 +0,0 @@
import { spawnSync } from 'node:child_process'
import {
applySherpaLoaderEnv as applySherpaLoaderEnvFromServer,
resolveSherpaLoaderEnv,
} from '@getpaseo/server'
const SHERPA_ENV_BOOTSTRAPPED = 'PASEO_SHERPA_ENV_BOOTSTRAPPED'
export function applySherpaLoaderEnv(env: NodeJS.ProcessEnv): {
changed: boolean
key: 'LD_LIBRARY_PATH' | 'DYLD_LIBRARY_PATH' | 'PATH' | null
libDir: string | null
} {
const result = applySherpaLoaderEnvFromServer(env)
return {
changed: result.changed,
key: result.key,
libDir: result.libDir,
}
}
export function reexecWithSherpaLoaderEnvIfNeeded(): never | void {
const resolved = resolveSherpaLoaderEnv()
if (!resolved) {
return
}
if (process.env[SHERPA_ENV_BOOTSTRAPPED] === '1') {
return
}
const envCopy: NodeJS.ProcessEnv = { ...process.env }
const applied = applySherpaLoaderEnvFromServer(envCopy)
const updatedValue = applied.key ? envCopy[applied.key] ?? '' : ''
if (applied.key && updatedValue === (process.env[applied.key] ?? '')) {
return
}
const env: NodeJS.ProcessEnv = {
...envCopy,
[SHERPA_ENV_BOOTSTRAPPED]: '1',
}
const result = spawnSync(process.execPath, process.argv.slice(1), {
env,
stdio: 'inherit',
})
if (result.error) {
throw result.error
}
process.exit(result.status ?? 1)
}
+3 -95
View File
@@ -1,18 +1,10 @@
import { Command } from 'commander'
import chalk from 'chalk'
import {
createPaseoDaemon,
loadConfig,
resolvePaseoHome,
createRootLogger,
loadPersistedConfig,
} from '@getpaseo/server'
import type { CliConfigOverrides } from '@getpaseo/server'
import {
startLocalDaemonForeground,
startLocalDaemonDetached,
type DaemonStartOptions as StartOptions,
} from './local-daemon.js'
import { reexecWithSherpaLoaderEnvIfNeeded } from './sherpa-env.js'
import { getErrorMessage } from '../../utils/errors.js'
export type { DaemonStartOptions as StartOptions } from './local-daemon.js'
@@ -35,34 +27,6 @@ export function startCommand(): Command {
})
}
function toCliOverrides(options: StartOptions): CliConfigOverrides {
const cliOverrides: CliConfigOverrides = {}
if (options.listen) {
cliOverrides.listen = options.listen
} else if (options.port) {
cliOverrides.listen = `127.0.0.1:${options.port}`
}
if (options.relay === false) {
cliOverrides.relayEnabled = false
}
if (options.allowedHosts) {
const raw = options.allowedHosts.trim()
cliOverrides.allowedHosts =
raw.toLowerCase() === 'true'
? true
: raw.split(',').map(h => h.trim()).filter(Boolean)
}
if (options.mcp === false) {
cliOverrides.mcpEnabled = false
}
return cliOverrides
}
export async function runStart(options: StartOptions): Promise<void> {
if (options.listen && options.port) {
console.error(chalk.red('Cannot use --listen and --port together'))
@@ -79,65 +43,9 @@ export async function runStart(options: StartOptions): Promise<void> {
}
return
}
reexecWithSherpaLoaderEnvIfNeeded()
if (options.home) {
process.env.PASEO_HOME = options.home
}
let paseoHome: string
let logger: ReturnType<typeof createRootLogger>
let config: ReturnType<typeof loadConfig>
try {
paseoHome = resolvePaseoHome()
const persistedConfig = loadPersistedConfig(paseoHome)
logger = createRootLogger(persistedConfig)
config = loadConfig(paseoHome, { cli: toCliOverrides(options) })
} catch (err) {
exitWithError(getErrorMessage(err))
}
let daemon: Awaited<ReturnType<typeof createPaseoDaemon>>
try {
daemon = await createPaseoDaemon(config, logger)
} catch (err) {
const message = getErrorMessage(err)
exitWithError(`Failed to initialize daemon: ${message}`)
}
let shuttingDown = false
const handleShutdown = async (signal: string) => {
if (shuttingDown) {
logger.info('Forcing exit...')
process.exit(1)
}
shuttingDown = true
logger.info(`${signal} received, shutting down gracefully... (press Ctrl+C again to force exit)`)
const forceExit = setTimeout(() => {
logger.warn('Forcing shutdown - HTTP server didn\'t close in time')
process.exit(1)
}, 10000)
try {
await daemon.stop()
clearTimeout(forceExit)
logger.info('Server closed')
process.exit(0)
} catch (err) {
clearTimeout(forceExit)
logger.error({ err }, 'Shutdown failed')
process.exit(1)
}
}
process.on('SIGTERM', () => handleShutdown('SIGTERM'))
process.on('SIGINT', () => handleShutdown('SIGINT'))
try {
await daemon.start()
const status = startLocalDaemonForeground(options)
process.exit(status)
} catch (err) {
const message = getErrorMessage(err)
exitWithError(`Failed to start daemon: ${message}`)
+42 -7
View File
@@ -1,6 +1,27 @@
import { fileURLToPath } from "url";
import { existsSync } from "node:fs";
import { runSupervisor } from "./supervisor.js";
import { applySherpaLoaderEnv } from "../src/server/speech/providers/local/sherpa/sherpa-runtime-env.js";
type DaemonRunnerConfig = {
devMode: boolean;
workerArgs: string[];
};
function parseConfig(argv: string[]): DaemonRunnerConfig {
let devMode = false;
const workerArgs: string[] = [];
for (const arg of argv) {
if (arg === "--dev") {
devMode = true;
continue;
}
workerArgs.push(arg);
}
return { devMode, workerArgs };
}
function resolveWorkerEntry(): string {
const candidates = [
@@ -19,18 +40,32 @@ function resolveWorkerEntry(): string {
return candidates[0];
}
function resolveWorkerExecArgv(): string[] {
const workerEntry = resolveWorkerEntry();
function resolveDevWorkerEntry(): string {
const candidate = fileURLToPath(new URL("../src/server/index.ts", import.meta.url));
if (!existsSync(candidate)) {
throw new Error(`Dev worker entry not found: ${candidate}`);
}
return candidate;
}
function resolveWorkerExecArgv(workerEntry: string): string[] {
return workerEntry.endsWith(".ts") ? ["--import", "tsx"] : [];
}
const config = parseConfig(process.argv.slice(2));
const workerEntry = config.devMode ? resolveDevWorkerEntry() : resolveWorkerEntry();
applySherpaLoaderEnv(process.env);
runSupervisor({
name: "DaemonRunner",
startupMessage: "Starting daemon worker (IPC restart enabled)",
resolveWorkerEntry,
workerArgs: process.argv.slice(2),
startupMessage: config.devMode
? "Starting daemon worker (dev mode, crash restarts enabled)"
: "Starting daemon worker (IPC restart enabled)",
resolveWorkerEntry: () => workerEntry,
workerArgs: config.workerArgs,
workerEnv: process.env,
workerExecArgv: resolveWorkerExecArgv(),
restartOnCrash: false,
workerExecArgv: resolveWorkerExecArgv(workerEntry),
restartOnCrash: config.devMode,
shutdownReasons: ["cli_shutdown"],
});