mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
fix(orcad): show help before server startup
Make orcad --help print usage and exit before native preflight or server startup, preserving existing flag parsing and managed-runtime startup. Verified against the built server and launcher. Adapted from #26621 by @espetro. Co-authored-by: Joaquin Terrasa <josocjoq+dev@pm.me>
This commit is contained in:
@@ -13,11 +13,14 @@ import {
|
||||
* catchable, so a preflight that lands after `main()` has already reached
|
||||
* `await import('../ipc/pty')` prevents nothing.
|
||||
*/
|
||||
const { order, profileProbe } = vi.hoisted(() => {
|
||||
const { order, profileProbe, reserveStdout } = vi.hoisted(() => {
|
||||
const order: string[] = []
|
||||
return { order, profileProbe: vi.fn(async () => {}) }
|
||||
return { order, profileProbe: vi.fn(async () => {}), reserveStdout: vi.fn() }
|
||||
})
|
||||
|
||||
vi.mock('../server/serve-stdout-boundary', () => ({
|
||||
reserveServeStdoutForReadiness: reserveStdout
|
||||
}))
|
||||
vi.mock('./orcad-bundled-runtime', () => ({ assertOrcadServerRuntime: () => {} }))
|
||||
vi.mock('./orcad-profile-preflight', () => ({
|
||||
preflightBundledOrcadStartup: async () => {
|
||||
@@ -49,12 +52,39 @@ vi.mock('./orcad-managed-stop-command', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('./orcad-entry', () => ({
|
||||
main: async () => {
|
||||
main: vi.fn(async () => {
|
||||
order.push('main')
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
describe('orcad entry', () => {
|
||||
it.each([['--help'], ['-h'], ['--json', '--help'], ['--bind', '--help', '-h']])(
|
||||
'handles help before startup probes or stdout redirection: %j',
|
||||
async (...argv) => {
|
||||
vi.spyOn(process, 'argv', 'get').mockReturnValue(['runtime', 'orcad.js', ...argv])
|
||||
await import('./main')
|
||||
const { main } = await import('./orcad-entry')
|
||||
|
||||
expect(main).toHaveBeenCalledWith(argv)
|
||||
expect(order).toEqual(['main'])
|
||||
expect(profileProbe).not.toHaveBeenCalled()
|
||||
expect(reserveStdout).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
it.each([
|
||||
['--bind', '--help'],
|
||||
['--pairing-address', '-h'],
|
||||
['--project-root', '--help']
|
||||
])('keeps startup probes when help is consumed as a value: %j', async (...argv) => {
|
||||
vi.spyOn(process, 'argv', 'get').mockReturnValue(['runtime', 'orcad.js', ...argv])
|
||||
await import('./main')
|
||||
await vi.waitFor(() => expect(order).toContain('main'))
|
||||
|
||||
expect(order).toEqual(['profile-admission', 'preflight', 'main'])
|
||||
expect(reserveStdout).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ flag: ORCAD_PROFILE_PREFLIGHT_FLAG, nativeFeatures: true },
|
||||
{ flag: ORCAD_STARTUP_PREFLIGHT_FLAG, nativeFeatures: false }
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
WINDOWS_BREAKAWAY_LAUNCH_FLAG
|
||||
} from '../../shared/windows-breakaway-launch'
|
||||
import { runWindowsBreakawayLaunchIfRequested } from '../../shared/windows-breakaway-launcher'
|
||||
import { parseArgs } from './orcad-command-arguments'
|
||||
|
||||
// Why exit before the preflight: reaching this line means the whole module graph resolved
|
||||
// under plain Node, which is all the build guard needs to prove. Probing natives or
|
||||
@@ -56,6 +57,11 @@ if (process.argv[2] === WINDOWS_BREAKAWAY_LAUNCH_FLAG) {
|
||||
|
||||
function startOrcadProcess(): void {
|
||||
try {
|
||||
const argv = process.argv.slice(2)
|
||||
if ((argv.includes('--help') || argv.includes('-h')) && parseArgs(argv).help) {
|
||||
void main(argv).catch(failStartup)
|
||||
return
|
||||
}
|
||||
assertOrcadServerRuntime()
|
||||
const flag = process.argv[2]
|
||||
if (flag === ORCAD_NATIVE_PREFLIGHT_FLAG && process.argv.length === 3) {
|
||||
|
||||
@@ -1,15 +1,45 @@
|
||||
import type { OrcadOptions } from './orcad-entry'
|
||||
import { DEFAULT_WS_PORT } from '../runtime/runtime-rpc/runtime-rpc-pairing-types'
|
||||
import { ORCAD_LOOPBACK_BIND_HOST } from './orcad-bind-address'
|
||||
|
||||
export const ORCAD_USAGE = `orcad — the Orca runtime served without Electron.
|
||||
|
||||
Usage: orcad [options]
|
||||
|
||||
Options:
|
||||
--port <n> WebSocket port, 0-65535; 0 picks a free port.
|
||||
Defaults to ${DEFAULT_WS_PORT}, with a fallback if unavailable.
|
||||
--bind <ip> Literal IP to bind (default ${ORCAD_LOOPBACK_BIND_HOST}, local only).
|
||||
Use an SSH port-forward or a network IP for remote access.
|
||||
--pairing-address <addr> Address advertised in pairing offers when different
|
||||
from the bind address (for example, a public hostname).
|
||||
--json Print the one-line server readiness JSON to stdout.
|
||||
--no-pairing Do not create a client pairing offer at startup.
|
||||
--mobile-pairing Create a mobile-scoped pairing offer with a QR code.
|
||||
--grant-desktop-control Allow desktop control in the default runtime offer;
|
||||
cannot combine with --no-pairing or --mobile-pairing.
|
||||
--recipe-json Print only the VM recipe line; requires --project-root.
|
||||
--project-root <dir> Existing absolute project directory for --recipe-json.
|
||||
--help, -h Print this text and exit without starting the server.
|
||||
|
||||
Environment:
|
||||
ORCA_USER_DATA Data directory for this instance's profile and state.
|
||||
`
|
||||
|
||||
type OrcadCommandArguments = OrcadOptions & { help?: boolean }
|
||||
|
||||
/**
|
||||
* orcad's flags. A value-taking flag consumes the next token whatever it looks
|
||||
* like, so `--bind --json` binds to the literal `--json`; only a missing token
|
||||
* is an error. Pinned by orcad-launch-contract.test.ts.
|
||||
*/
|
||||
export function parseArgs(argv: string[]): OrcadOptions {
|
||||
const options: OrcadOptions = {}
|
||||
export function parseArgs(argv: string[]): OrcadCommandArguments {
|
||||
const options: OrcadCommandArguments = {}
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const arg = argv[i]
|
||||
if (arg === '--port') {
|
||||
if (arg === '--help' || arg === '-h') {
|
||||
options.help = true
|
||||
} else if (arg === '--port') {
|
||||
const raw = argv[i + 1]
|
||||
const port = Number(raw)
|
||||
if (!Number.isInteger(port) || port < 0 || port > 65535) {
|
||||
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
installOrcadShutdownSignals,
|
||||
startOrcadWithHost
|
||||
} from './orcad-lifecycle'
|
||||
import { parseArgs } from './orcad-command-arguments'
|
||||
import { ORCAD_USAGE, parseArgs } from './orcad-command-arguments'
|
||||
import type { OrcadRuntimeCleanup } from './orcad-runtime-lifetime'
|
||||
import { installOrcadStopRequestListeners } from './orcad-stop-request-listener'
|
||||
import { prepareOrcadManagedStop } from './orcad-managed-stop-admission'
|
||||
@@ -399,7 +399,12 @@ async function startOrcadRuntime(
|
||||
}
|
||||
|
||||
export async function main(argv: string[] = process.argv.slice(2)): Promise<void> {
|
||||
const startup = startOrcad(parseArgs(argv))
|
||||
const options = parseArgs(argv)
|
||||
if (options.help) {
|
||||
console.log(ORCAD_USAGE)
|
||||
return
|
||||
}
|
||||
const startup = startOrcad(options)
|
||||
const requestShutdown = installOrcadShutdownSignals(async () => (await startup).stop())
|
||||
const handle = await startup
|
||||
// Why after startup: a managed request must name the runtime and instance this run became.
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { mkdtemp, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import { runProcess } from '../../shared/child-process/run-process'
|
||||
import { ORCAD_LAUNCHER_FILENAME, ORCAD_SERVER_ENTRY_FILENAME } from '../../shared/orcad-artifacts'
|
||||
|
||||
let directory = ''
|
||||
let launcher = ''
|
||||
|
||||
beforeAll(async () => {
|
||||
directory = await mkdtemp(join(tmpdir(), 'orcad-help-'))
|
||||
launcher = join(directory, ORCAD_LAUNCHER_FILENAME)
|
||||
const builder = pathToFileURL(join(process.cwd(), 'config/scripts/orcad-entry-build.mjs')).href
|
||||
const built = await runProcess({
|
||||
program: process.execPath,
|
||||
args: [
|
||||
'--input-type=module',
|
||||
'-e',
|
||||
`import {buildOrcadEntry,buildOrcadLauncher} from ${JSON.stringify(builder)};` +
|
||||
`await buildOrcadEntry(${JSON.stringify(join(directory, ORCAD_SERVER_ENTRY_FILENAME))});` +
|
||||
`await buildOrcadLauncher(${JSON.stringify(launcher)});`
|
||||
],
|
||||
env: { ...process.env, ORCA_BACKGROUND_LAUNCH: '1' },
|
||||
timeoutMs: 60_000
|
||||
})
|
||||
expect(built.code, built.stderr).toBe(0)
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('real orcad executable help', () => {
|
||||
it.each([['--help'], ['-h'], ['--json', '--help'], ['--help', '--json']])(
|
||||
'prints usage to stdout and exits without creating a profile: %j',
|
||||
async (...argv) => {
|
||||
const dataRoot = join(directory, `absent-${argv.join('-')}`)
|
||||
const result = await runProcess({
|
||||
program: process.execPath,
|
||||
args: [launcher, ...argv],
|
||||
env: { ...process.env, ORCA_BACKGROUND_LAUNCH: '1', ORCA_USER_DATA: dataRoot },
|
||||
timeoutMs: 10_000
|
||||
})
|
||||
expect(result.code, result.stderr).toBe(0)
|
||||
expect(result.timedOut).toBe(false)
|
||||
expect(result.stderr).toBe('')
|
||||
expect(result.stdout).toContain('Usage: orcad [options]')
|
||||
expect(result.stdout).toContain('--bind <ip>')
|
||||
expect(result.stdout).not.toContain('"type":"orca_server_ready"')
|
||||
await expect(readdir(dataRoot)).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
}
|
||||
)
|
||||
|
||||
it('leaves an existing instance lock untouched', async () => {
|
||||
const dataRoot = join(directory, 'locked-profile')
|
||||
await mkdir(dataRoot)
|
||||
const lock = join(dataRoot, 'orcad.lock')
|
||||
const content = 'an existing instance owns this profile\n'
|
||||
await writeFile(lock, content)
|
||||
const result = await runProcess({
|
||||
program: process.execPath,
|
||||
args: [launcher, '--help'],
|
||||
env: { ...process.env, ORCA_BACKGROUND_LAUNCH: '1', ORCA_USER_DATA: dataRoot },
|
||||
timeoutMs: 10_000
|
||||
})
|
||||
expect(result.code, result.stderr).toBe(0)
|
||||
expect(result.stderr).toBe('')
|
||||
expect(await readFile(lock, 'utf8')).toBe(content)
|
||||
expect(await readdir(dataRoot)).toEqual(['orcad.lock'])
|
||||
})
|
||||
})
|
||||
@@ -23,6 +23,37 @@ import { ProfileStateAccessError } from '../persistence/profile-state/profile-st
|
||||
import { OrcadBundledRuntimeError } from './orcad-bundled-runtime'
|
||||
|
||||
describe('parseArgs', () => {
|
||||
it('accepts help alongside valid server flags', () => {
|
||||
expect(parseArgs(['--help'])).toEqual({ help: true })
|
||||
expect(parseArgs(['-h'])).toEqual({ help: true })
|
||||
expect(parseArgs(['--json', '--help', '--port', '0'])).toEqual({
|
||||
json: true,
|
||||
help: true,
|
||||
port: 0
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps help-looking values as values', () => {
|
||||
expect(parseArgs(['--bind', '--help'])).toEqual({ bind: '--help' })
|
||||
expect(parseArgs(['--pairing-address', '-h'])).toEqual({ pairingAddress: '-h' })
|
||||
expect(parseArgs(['--project-root', '--help'])).toEqual({ projectRoot: '--help' })
|
||||
expect(parseArgs(['--bind', '--help', '-h'])).toEqual({ bind: '--help', help: true })
|
||||
})
|
||||
|
||||
it('still rejects invalid arguments and incompatible flags with help', () => {
|
||||
expect(() => parseArgs(['--help', '--unknown'])).toThrow('Unknown argument: --unknown')
|
||||
expect(() => parseArgs(['--help', '--port', 'bad'])).toThrow('--port expects an integer')
|
||||
expect(() => parseArgs(['--help', '--bind'])).toThrow('--bind expects a value')
|
||||
expect(() => parseArgs(['--help', '--recipe-json'])).toThrow(
|
||||
'--recipe-json requires --project-root'
|
||||
)
|
||||
for (const flag of ['--no-pairing', '--mobile-pairing']) {
|
||||
expect(() => parseArgs(['--help', '--grant-desktop-control', flag])).toThrow(
|
||||
'--grant-desktop-control applies only to the default runtime pairing offer'
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it('accepts --bind and leaves it unset when absent', () => {
|
||||
expect(parseArgs(['--bind', '0.0.0.0'])).toEqual({ bind: '0.0.0.0' })
|
||||
expect(parseArgs([])).toEqual({})
|
||||
|
||||
Reference in New Issue
Block a user