orca/src/cli/runtime/launch.ts

284 lines
8.9 KiB
TypeScript

import { spawn as spawnProcess, type SpawnOptions } from 'node:child_process'
import { dirname, resolve } from 'node:path'
import { StringDecoder } from 'node:string_decoder'
import {
getEphemeralVmRecipeResultConnection,
parseEphemeralVmRecipeResult
} from '../../shared/ephemeral-vm-recipes'
import { RuntimeClientError } from './types'
const IGNORED_NON_RECIPE_STDOUT = '[serve] ignored non-recipe stdout'
export function launchOrcaApp(): void {
const overrideCommand = process.env.ORCA_OPEN_COMMAND
if (typeof overrideCommand === 'string' && overrideCommand.trim().length > 0) {
spawnDetached(overrideCommand, [], { shell: true })
return
}
const overrideExecutable = process.env.ORCA_APP_EXECUTABLE
if (typeof overrideExecutable === 'string' && overrideExecutable.trim().length > 0) {
spawnDetached(overrideExecutable, getExecutableAppArgs(), {
...getExecutableSpawnOptions(overrideExecutable),
env: stripElectronRunAsNode(process.env)
})
return
}
if (process.env.ELECTRON_RUN_AS_NODE === '1') {
if (process.platform === 'darwin') {
const appBundlePath = getMacAppBundlePath(process.execPath)
if (appBundlePath) {
// Why: launching the inner MacOS binary directly can trigger macOS app
// launch failures and bypass normal bundle lifecycle. The public
// packaged CLI should re-open the .app the same way Finder does.
spawnDetached('open', [appBundlePath], {
env: stripElectronRunAsNode(process.env)
})
return
}
}
spawnDetached(process.execPath, [], {
env: stripElectronRunAsNode(process.env)
})
return
}
throw new RuntimeClientError(
'runtime_open_failed',
'Could not determine how to launch Orca. Start Orca manually and try again.'
)
}
function spawnDetached(command: string, args: string[], options: SpawnOptions): void {
const child = spawnProcess(command, args, {
detached: true,
stdio: 'ignore',
...options
})
// Why: detached launch errors are reported asynchronously after this function
// returns; openOrca already reports the user-facing timeout if startup fails.
child.once('error', () => {})
child.unref()
}
export function serveOrcaApp(
args: {
json?: boolean
port?: string | null
pairingAddress?: string | null
noPairing?: boolean
mobilePairing?: boolean
recipeJson?: boolean
projectRoot?: string | null
} = {}
): Promise<number> {
const executable = resolveForegroundOrcaExecutable()
const childArgs = [...getExecutableAppArgs(), '--serve']
if (args.json) {
childArgs.push('--serve-json')
}
if (args.port) {
childArgs.push('--serve-port', args.port)
}
if (args.pairingAddress) {
childArgs.push('--serve-pairing-address', args.pairingAddress)
}
if (args.noPairing) {
childArgs.push('--serve-no-pairing')
}
if (args.mobilePairing) {
childArgs.push('--serve-mobile-pairing')
}
if (args.recipeJson) {
if (!args.projectRoot) {
throw new RuntimeClientError(
'invalid_argument',
'Recipe JSON output requires --project-root.'
)
}
childArgs.push('--serve-recipe-json', '--serve-project-root', args.projectRoot)
}
const child = spawnProcess(executable, childArgs, {
detached: args.recipeJson === true,
cwd: resolveAppRoot(),
stdio: args.recipeJson === true ? ['ignore', 'pipe', 'inherit'] : 'inherit',
...getExecutableSpawnOptions(executable),
env: stripElectronRunAsNode(process.env)
})
if (args.recipeJson) {
return waitForRecipeJson(child)
}
return new Promise((resolve, reject) => {
let forceKillTimer: ReturnType<typeof setTimeout> | null = null
const forwardSignal = (signal: NodeJS.Signals): void => {
child.kill(signal)
forceKillTimer ??= setTimeout(() => {
child.kill('SIGKILL')
}, 5000)
}
const cleanup = (): void => {
process.off('SIGINT', forwardSignal)
process.off('SIGTERM', forwardSignal)
if (forceKillTimer) {
clearTimeout(forceKillTimer)
forceKillTimer = null
}
}
process.on('SIGINT', forwardSignal)
process.on('SIGTERM', forwardSignal)
child.once('error', (error) => {
cleanup()
reject(error)
})
child.once('exit', (code, signal) => {
cleanup()
if (typeof code === 'number') {
resolve(code)
return
}
reject(new RuntimeClientError('runtime_serve_failed', `Orca serve exited via ${signal}`))
})
})
}
function waitForRecipeJson(child: ReturnType<typeof spawnProcess>): Promise<number> {
return new Promise((resolve, reject) => {
let output = ''
let settled = false
const timeout = setTimeout(() => {
finish(new RuntimeClientError('runtime_serve_failed', 'Timed out waiting for recipe JSON.'))
child.kill('SIGTERM')
}, 60000)
const finish = (error?: Error): void => {
if (settled) {
return
}
settled = true
clearTimeout(timeout)
child.stdout?.off('data', onData)
child.off('error', onError)
child.off('close', onClose)
if (error) {
reject(error)
return
}
child.stdout?.destroy?.()
child.unref()
resolve(0)
}
const writeIgnoredRecipeStdout = (): void => {
// Why: non-readiness child stdout is untrusted and cannot be safely
// redacted, including schema-valid results with arbitrary user data.
process.stderr.write(`${IGNORED_NON_RECIPE_STDOUT}\n`)
}
const processRecipeOutputLine = (line: string): void => {
const normalizedLine = line.endsWith('\r') ? line.slice(0, -1) : line
if (!normalizedLine.trim()) {
return
}
const parsed = parseEphemeralVmRecipeResult(normalizedLine)
if (!parsed.ok) {
writeIgnoredRecipeStdout()
return
}
if (getEphemeralVmRecipeResultConnection(parsed.result).type !== 'orca-server') {
writeIgnoredRecipeStdout()
return
}
process.stdout.write(`${normalizedLine.trim()}\n`)
finish()
}
const stdoutDecoder = new StringDecoder('utf8')
const onData = (chunk: Buffer | string): void => {
output += typeof chunk === 'string' ? chunk : stdoutDecoder.write(chunk)
while (!settled) {
const newlineIndex = output.indexOf('\n')
if (newlineIndex === -1) {
return
}
const line = output.slice(0, newlineIndex)
output = output.slice(newlineIndex + 1)
processRecipeOutputLine(line)
}
}
const onError = (error: Error): void => {
finish(error)
}
const onClose = (code: number | null, signal: NodeJS.Signals | null): void => {
if (settled) {
return
}
output += stdoutDecoder.end()
if (output.trim()) {
processRecipeOutputLine(output)
}
if (settled) {
return
}
finish(
new RuntimeClientError(
'runtime_serve_failed',
typeof code === 'number'
? `Orca serve exited before printing valid recipe JSON with code ${code}.`
: `Orca serve exited before printing valid recipe JSON via ${signal}.`
)
)
}
child.stdout?.on('data', onData)
child.once('error', onError)
// Why: `exit` can precede the final piped stdout data. `close` waits until
// stdio closes so a last recipe chunk is not mistaken for missing output.
child.once('close', onClose)
})
}
function getExecutableAppArgs(): string[] {
return process.env.ORCA_APP_EXECUTABLE_NEEDS_APP_ROOT === '1' ? [resolveAppRoot()] : []
}
function getExecutableSpawnOptions(executable: string): Pick<SpawnOptions, 'shell'> {
return process.platform === 'win32' && /\.(?:cmd|bat)$/i.test(executable) ? { shell: true } : {}
}
function resolveAppRoot(): string {
// Why: dev-mode resource resolution in the Electron child may consult
// process.cwd(). Pin it to the app root so `orca serve` behaves the same
// regardless of the shell directory it was launched from.
return resolve(__dirname, '../../..')
}
function resolveForegroundOrcaExecutable(): string {
const overrideExecutable = process.env.ORCA_APP_EXECUTABLE
if (typeof overrideExecutable === 'string' && overrideExecutable.trim().length > 0) {
return overrideExecutable
}
if (process.env.ELECTRON_RUN_AS_NODE === '1') {
return process.execPath
}
throw new RuntimeClientError(
'runtime_serve_failed',
'Could not determine how to start Orca server. Set ORCA_APP_EXECUTABLE to the Orca executable.'
)
}
function stripElectronRunAsNode(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
const next = { ...env }
delete next.ELECTRON_RUN_AS_NODE
return next
}
function getMacAppBundlePath(execPath: string): string | null {
if (process.platform !== 'darwin') {
return null
}
const macOsDir = dirname(execPath)
const contentsDir = dirname(macOsDir)
const appBundlePath = dirname(contentsDir)
return appBundlePath.endsWith('.app') ? appBundlePath : null
}