diff --git a/package.json b/package.json index 90291aa27..ae48a7b27 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/cli/src/commands/daemon/local-daemon.ts b/packages/cli/src/commands/daemon/local-daemon.ts index dda532c6a..73c1888a3 100644 --- a/packages/cli/src/commands/daemon/local-daemon.ts +++ b/packages/cli/src/commands/daemon/local-daemon.ts @@ -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 { diff --git a/packages/cli/src/commands/daemon/sherpa-env.ts b/packages/cli/src/commands/daemon/sherpa-env.ts deleted file mode 100644 index 1e91c7d71..000000000 --- a/packages/cli/src/commands/daemon/sherpa-env.ts +++ /dev/null @@ -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) -} diff --git a/packages/cli/src/commands/daemon/start.ts b/packages/cli/src/commands/daemon/start.ts index b2209aa5f..ffae16ffa 100644 --- a/packages/cli/src/commands/daemon/start.ts +++ b/packages/cli/src/commands/daemon/start.ts @@ -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 { 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 { } return } - - reexecWithSherpaLoaderEnvIfNeeded() - - if (options.home) { - process.env.PASEO_HOME = options.home - } - - let paseoHome: string - let logger: ReturnType - let config: ReturnType - try { - paseoHome = resolvePaseoHome() - const persistedConfig = loadPersistedConfig(paseoHome) - logger = createRootLogger(persistedConfig) - config = loadConfig(paseoHome, { cli: toCliOverrides(options) }) - } catch (err) { - exitWithError(getErrorMessage(err)) - } - - let daemon: Awaited> - 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}`) diff --git a/packages/server/scripts/daemon-runner.ts b/packages/server/scripts/daemon-runner.ts index d6c6f186c..1b96be948 100644 --- a/packages/server/scripts/daemon-runner.ts +++ b/packages/server/scripts/daemon-runner.ts @@ -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"], });