diff --git a/src/main/orcad/main-preflight-order.test.ts b/src/main/orcad/main-preflight-order.test.ts index 5a77d2c5cca..5da4ac08d44 100644 --- a/src/main/orcad/main-preflight-order.test.ts +++ b/src/main/orcad/main-preflight-order.test.ts @@ -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 } diff --git a/src/main/orcad/main.ts b/src/main/orcad/main.ts index 7bb98032d9b..30e21462a8f 100644 --- a/src/main/orcad/main.ts +++ b/src/main/orcad/main.ts @@ -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) { diff --git a/src/main/orcad/orcad-command-arguments.ts b/src/main/orcad/orcad-command-arguments.ts index 346379f72b7..03e4eb0b128 100644 --- a/src/main/orcad/orcad-command-arguments.ts +++ b/src/main/orcad/orcad-command-arguments.ts @@ -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 WebSocket port, 0-65535; 0 picks a free port. + Defaults to ${DEFAULT_WS_PORT}, with a fallback if unavailable. + --bind 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 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 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) { diff --git a/src/main/orcad/orcad-entry.ts b/src/main/orcad/orcad-entry.ts index 384642b9247..fb30cd3aba5 100644 --- a/src/main/orcad/orcad-entry.ts +++ b/src/main/orcad/orcad-entry.ts @@ -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 { - 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. diff --git a/src/main/orcad/orcad-help.integration.test.ts b/src/main/orcad/orcad-help.integration.test.ts new file mode 100644 index 00000000000..8c7330e7e07 --- /dev/null +++ b/src/main/orcad/orcad-help.integration.test.ts @@ -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 ') + 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']) + }) +}) diff --git a/src/main/orcad/orcad-launch-contract.test.ts b/src/main/orcad/orcad-launch-contract.test.ts index 3c204fcf0ae..956a511cccb 100644 --- a/src/main/orcad/orcad-launch-contract.test.ts +++ b/src/main/orcad/orcad-launch-contract.test.ts @@ -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({})