// the rnx CLI proper: argv parsing, the one-time privacy choice, and the // single lazy-imported command a run dispatches to. `./bin` imports this only // when none of its hidden entry points claimed the process. import { rnxPublicBrand } from '../src/public-brand' import { parseArgs, TOP_LEVEL_RUNTIME_COMMANDS } from './parse-args' import { RnxExit } from './run-rnx' import { rnxSelfInvocation } from './self-invocation' import { IS_STANDALONE } from './standalone' // `./help` is loaded lazily (only on help paths) — it statically pulls the full // CLI registry renderers and engine settings, which would otherwise add ~400ms // of eager chunk load to every command, including hot-path reads like // `describe` / `do tap` that never render help. parse-args has no imports, so // the startup floor stays at the runtime's own cost (~15ms). // keep top-level dispatch crashes readable. without this, a 10s WS bridge // timeout (the most common failure when a sim is dead) prints a 1KB minified // chunk of the bundled CLI source straight to stderr, which buries the actual // "command timed out after 10s" message and is uncopyable for a bug report. // rejections that don't match this shape still get the verbose stack so we // don't paper over real bugs. function handleTopLevelError(err: unknown): never { const msg = err instanceof Error ? err.message : String(err) // rnx's known top-level errors are all single-line, narrow strings. // everything else gets the full stack so unexpected crashes still surface. const isKnownCliFailure = /^command timed out after \d+s$/.test(msg) || msg.startsWith('sim disconnected:') || msg.startsWith('bridge never reconnected') || msg.startsWith('multiple sims are connected:') || msg.startsWith('saved sim ') || msg.startsWith('no sim connected with id ') || msg.startsWith('could not connect to ws://') || msg.startsWith('rnx bridge daemon is not running') || msg.startsWith('rnx bridge lockfile is fresh') || msg.startsWith('rnx bridge exited before becoming ready') || msg.startsWith(`${rnxPublicBrand.commandName} open: requested bridge port`) || msg.startsWith('background daemon setup is unavailable') || msg.startsWith('rnx bridge did not start within') if (isKnownCliFailure && !process.env.SOOTSIM_VERBOSE) { process.stderr.write(` ${msg}\n`) process.exit(1) } if (err instanceof Error && err.stack) { process.stderr.write(`${err.stack}\n`) } else { process.stderr.write(`${msg}\n`) } process.exit(1) } process.on('unhandledRejection', handleTopLevelError) process.on('uncaughtException', handleTopLevelError) let parsed = parseArgs(process.argv) for (const warning of parsed.warnings) process.stderr.write(`${warning}\n`) // runtime actions and reads also work as hidden root aliases. resolve only // after the normal parser declined the token, so a real top-level command // always wins. keep this lazy so ordinary commands don't load the docs registry. if (!parsed.command && parsed.commandArgs.length > 0) { const { resolveHiddenRuntimeAlias } = await import('./hidden-runtime-alias') parsed = resolveHiddenRuntimeAlias(parsed) } const isAutomaticCleanupWorker = parsed.command === 'cleanup' && parsed.commandArgs.length === 1 && parsed.commandArgs[0] === '--automatic-worker' const isCliUpdateWorker = parsed.command === 'upgrade' && parsed.commandArgs.length === 1 && parsed.commandArgs[0] === '--check-worker' const isBareWelcome = !parsed.command && parsed.commandArgs.length === 0 const isReadOnlyInvocation = parsed.version || parsed.help || parsed.commandArgs.includes('--dry-run') if (isCliUpdateWorker) { if (IS_STANDALONE) { try { const { refreshCliUpdateCache } = await import('./cli-update') await refreshCliUpdateCache() } catch {} } process.exit(0) } if ( !isAutomaticCleanupWorker && !isBareWelcome && !parsed.help && !parsed.version && parsed.command !== 'config' && parsed.command !== 'setup' && parsed.command !== 'telemetry' ) { const { ensurePrivacyChoice } = await import('./privacy') await ensurePrivacyChoice({ prompt: true }) } // one-shot "engine upgraded in the background" banner. the daemon's hourly // auto-updater writes a notice file when it swaps the runtime; whichever // interactive command runs next prints it once (stderr, so --json stdout // stays parseable) and deletes it. the daemon/serve processes skip it so a // background process can't eat the notice before a human sees it. if (!isReadOnlyInvocation && parsed.command !== 'serve' && parsed.command !== 'daemon') { const { consumeRuntimeUpgradeNotice } = await import('../src/home-paths') const notice = consumeRuntimeUpgradeNotice() if (notice) { process.stderr.write( ` ${rnxPublicBrand.name} engine upgraded to v${notice.to}` + (notice.from ? ` (from v${notice.from})` : '') + ` · what's new: ${rnxPublicBrand.origin}/changelog\n`, ) } } let automaticCleanupFinished = false async function spawnDetachedSelf( args: string[], stderr: 'ignore' | 'inherit', ): Promise { const { spawn } = await import('node:child_process') const options: import('node:child_process').SpawnOptions = { detached: true, stdio: ['ignore', 'ignore', stderr], env: process.env, } const { executable, prefixArgs } = rnxSelfInvocation() return spawn(executable, [...prefixArgs, ...args], options) } async function scheduleAutomaticCleanup(): Promise { if (automaticCleanupFinished) return automaticCleanupFinished = true if ( isReadOnlyInvocation || parsed.command === 'cleanup' || parsed.command === 'serve' || parsed.command === 'agent-wrapper' || (parsed.command === 'daemon' && parsed.commandArgs[0] === 'uninstall') ) { return } try { const { shouldSkipAutomaticSootsimCleanup } = await import('../src/disk-cleanup') if (shouldSkipAutomaticSootsimCleanup()) return const workerArgs = ['cleanup', '--automatic-worker'] const child = await spawnDetachedSelf(workerArgs, 'inherit') child.once('error', (error) => { process.stderr.write( ` ${rnxPublicBrand.name} automatic cleanup will retry on the next run: ${error.message}\n`, ) }) child.unref() } catch (error) { process.stderr.write( ` ${rnxPublicBrand.name} automatic cleanup will retry on the next run: ${ error instanceof Error ? error.message : String(error) }\n`, ) } } let automaticCliUpdateFinished = false async function scheduleAutomaticCliUpdate(exitCode: number): Promise { if (automaticCliUpdateFinished) return automaticCliUpdateFinished = true if (!IS_STANDALONE) return const { claimAutomaticCliUpdateCheck, shouldUseAutomaticCliUpdateCheck, takeCliUpdateNotification, } = await import('./cli-update') if ( !shouldUseAutomaticCliUpdateCheck({ command: parsed.command, commandArgs: parsed.commandArgs, exitCode, standalone: IS_STANDALONE, stderrIsTTY: process.stderr.isTTY === true, stdoutIsTTY: process.stdout.isTTY === true, version: parsed.version, }) ) { return } const update = takeCliUpdateNotification() if (update) { process.stderr.write( ` update: rnx v${update.currentVersion} → v${update.latestVersion} · run \`rnx upgrade\`\n`, ) } if (!claimAutomaticCliUpdateCheck()) return try { const child = await spawnDetachedSelf(['upgrade', '--check-worker'], 'ignore') child.once('error', () => {}) child.unref() } catch {} } async function exitWithFlush(code: number): Promise { const { flushCliTelemetry } = await import('./telemetry') await flushCliTelemetry() await scheduleAutomaticCliUpdate(code) await scheduleAutomaticCleanup() process.exit(code) } // --version if (parsed.version) { // use the single canonical, cached version source — the same one // `--help`, `upgrade`, the startup flow, and the bridge-host use. it // previously re-read package.json independently here, which could // resolve a different copy (stale global install vs repo) and drift // from `--help` within one session (QA F20-4). const { getCliVersion } = await import('../src/cli-version') const { IS_BETA, BETA_LABEL } = await import('../src/beta') const suffix = IS_BETA ? ` · ${BETA_LABEL}` : '' console.log(`${rnxPublicBrand.commandName} v${getCliVersion()}${suffix}`) // runtime version on its own line — keeps the first line parseable while // surfacing the (independently-versioned) engine runtime users actually // render with. see `rnx upgrade`. const { readActiveRuntime } = await import('../src/home-paths') const runtime = readActiveRuntime() console.log(runtime ? `runtime v${runtime}` : 'runtime not installed') await exitWithFlush(0) } // --help with no command if (parsed.help && !parsed.command) { const { printHelp } = await import('./help') printHelp() await exitWithFlush(0) } // apply settings from global flags const port = parsed.globalFlags['port'] as number | undefined const verbose = parsed.verbose const device = parsed.globalFlags['device'] as string | undefined const theme = parsed.globalFlags['theme'] as string | undefined const homeScreen = parsed.globalFlags['home-screen'] as string | undefined const driver = parsed.globalFlags['driver'] as string | undefined const headless = (parsed.globalFlags['headless'] as boolean | undefined) === true const globalSimTarget = (parsed.globalFlags['sim'] ?? parsed.globalFlags['session'] ?? parsed.globalFlags['tab']) as string | undefined const commandArgsWithSim = globalSimTarget ? ['--sim', globalSimTarget, ...parsed.commandArgs] : parsed.commandArgs // apply device/theme/home-screen to settings store if provided if (device || theme || homeScreen) { const { settingsStore } = await import('sootsim-engine/settings/store') const overrides: Record = {} if (device) overrides.deviceModel = device if (theme) overrides.colorScheme = theme if (homeScreen) overrides.homeScreenVersion = homeScreen settingsStore.apply(overrides) } // no command, no args: guide first-time machine setup, then open ConnectRN. // returning runs open ConnectRN directly; app discovery belongs inside it. if (!parsed.command && parsed.commandArgs.length === 0) { const { runWelcome } = await import('./commands/welcome') await exitWithFlush(await runWelcome()) } // route to command. an empty command at this point only happens if the // caller passed bare positional args without a verb (e.g. `rnx -- foo`), // which used to fall through to the bundler-wrap behavior in `dev`. now that // `dev` is gone, treat it as "show help". const command = parsed.command ?? '' if (!command) { // a bare positional that isn't a known command lands here (parse-args // leaves `command` null and pushes the token into commandArgs). don't // silently dump the full help banner — name the unrecognized token so a // typo'd subcommand is obvious. matches the switch `default:` wording. if (parsed.commandArgs.length > 0) { const attempted = parsed.commandArgs.find((a) => !a.startsWith('-')) ?? parsed.commandArgs[0] console.error(` unknown command: ${attempted}`) console.error( ` run \`${rnxPublicBrand.commandName} --help\` to see the full surface.`, ) await exitWithFlush(1) } const { printHelp } = await import('./help') printHelp() await exitWithFlush(0) } // --help with a command (either global flag or in commandArgs) if ( parsed.help || parsed.commandArgs.includes('--help') || parsed.commandArgs.includes('-h') ) { if (command === 'skill') { const { runSkill } = await import('./commands/skills') await runSkill(parsed.commandArgs) await exitWithFlush(0) } if (command === 'state') { const { runState } = await import('./commands/state') await exitWithFlush(await runState(commandArgsWithSim, { port })) } if (command === 'reset') { const { runReset } = await import('./commands/reset') await exitWithFlush(await runReset(commandArgsWithSim, { port })) } // for grouping verbs, try to resolve per-verb help from the first // positional arg. e.g. `rnx do tap --help` shows tap's help page, // not the generic `do` grouping page. bare `rnx do --help` falls // through to runInspect which has the full verb group listing. const isGroupingVerb = command === 'do' || command === 'get' || command === 'debug' || command === 'shell' || command === 'perf' || command === 'wait' const subVerb = parsed.commandArgs.find((a) => !a.startsWith('-')) if (command === 'shell' || command === 'perf') { // shell and perf subcommands are documented by their runtime dispatchers. // don't try to route through generated per-verb docs. } else if (isGroupingVerb && !subVerb) { // bare `rnx --help` — render from the registry. const { printGroupHelp } = await import('./help') if (printGroupHelp(command)) await exitWithFlush(0) // fall through — runInspect shows the full help for anything the // registry doesn't yet cover (e.g. shell). } else { const { printCommandHelp } = await import('./help') const helpName = isGroupingVerb && subVerb ? subVerb : command printCommandHelp(helpName, { prefer: isGroupingVerb && subVerb ? 'verb' : 'command', group: isGroupingVerb && subVerb ? command : undefined, }) await exitWithFlush(0) } } const { dispatchCloudBoundary } = await import('./cloud-dispatch') const cloudBoundary = await dispatchCloudBoundary({ command, args: commandArgsWithSim, }) if (cloudBoundary.handled) await exitWithFlush(cloudBoundary.code) // the command modules stop by throwing RnxExit rather than ending the // process, because the same command has to run in a box shell and in // workerd, where process.exit() cancels the whole request. this is the // host boundary that turns it back into a real exit code. try { if (TOP_LEVEL_RUNTIME_COMMANDS.has(command)) { if (command === 'do') { const { hasDoChain, runDoChain } = await import('./commands/do-chain') if (hasDoChain(commandArgsWithSim)) { await exitWithFlush(await runDoChain(commandArgsWithSim, { port })) } } const { runInspect } = await import('./commands/inspect') await runInspect([command, ...commandArgsWithSim], { port, verbose }) } else { switch (command) { case 'test': { const { runTest } = await import('./commands/test') await exitWithFlush(await runTest(commandArgsWithSim, { port, verbose })) } case 'assert': { // stays outside the usual dispatcher — it spawns self to get the // inner verb's --json payload, so it never needs the shared bridge // machinery that lives inside runInspect. const { runAssert } = await import('./commands/assert') await runAssert(parsed.commandArgs) break } case 'detox': { const { runDetox } = await import('./commands/detox') await runDetox(parsed.commandArgs, { port, verbose }) break } case 'maestro': { const { runMaestro } = await import('./commands/maestro') // Maestro forwards playback argv to the shared runner, so it must // receive the global `--sim`. passing the bare // parsed.commandArgs dropped an explicit `rnx --sim maestro // …`, silently falling back to the current/saved sim (QA F21-4). const code = await runMaestro(commandArgsWithSim, { port, verbose }) // bridge + dynamic imports leave handles open that keep node alive. await exitWithFlush(typeof code === 'number' ? code : 0) } case 'record': { const { runRecord } = await import('./commands/record') await runRecord(commandArgsWithSim, { port, verbose }) break } case 'film': { const { runFilm } = await import('./commands/film') // film chains the flow runner, which opens ws bridges + dynamic // imports that keep node alive — exit explicitly like `flow` does. const code = await runFilm(commandArgsWithSim, { port, verbose }) await exitWithFlush(typeof code === 'number' ? code : 0) } case 'storage': { const { runStorage } = await import('./commands/storage') const code = await runStorage(commandArgsWithSim, { port, verbose }) await exitWithFlush(typeof code === 'number' ? code : 0) } case 'state': { const { runState } = await import('./commands/state') const code = await runState(commandArgsWithSim, { port }) await exitWithFlush(code) } case 'reset': { const { runReset } = await import('./commands/reset') const code = await runReset(commandArgsWithSim, { port }) await exitWithFlush(code) } case 'perf': { const { runPerf } = await import('./commands/perf') const code = await runPerf(commandArgsWithSim, { port, verbose }) await exitWithFlush(typeof code === 'number' ? code : 0) } case 'screenshot': { const { runScreenshotCommand } = await import('./commands/screenshot-command') const code = await runScreenshotCommand(commandArgsWithSim, { port, verbose }) await exitWithFlush(typeof code === 'number' ? code : 0) } case 'camera': { const { runCamera } = await import('./commands/camera') const code = await runCamera(commandArgsWithSim, { port }) await exitWithFlush(typeof code === 'number' ? code : 0) } case 'mode': { const { runMode } = await import('./commands/mode') await runMode(commandArgsWithSim, { port, verbose }) break } case 'permissions': { const { runPermissions } = await import('./commands/permissions') const code = await runPermissions(commandArgsWithSim, { port, verbose }) await exitWithFlush(code) } case 'debug': { const { runDebug } = await import('./commands/debug') await runDebug(commandArgsWithSim, { port, verbose }) break } case 'timeline': { const { runTimeline } = await import('./commands/timeline') await runTimeline(commandArgsWithSim, { port, verbose }) break } case 'what-happened': { const { runWhatHappened } = await import('./commands/what-happened') await runWhatHappened(commandArgsWithSim, { port, verbose }) break } case 'open': { const { runOpenCommand } = await import('./commands/control') await runOpenCommand(commandArgsWithSim, { port }) break } case 'ios': case 'android': { const { runPlatformCommand } = await import('./commands/platform') const code = await runPlatformCommand(command, commandArgsWithSim, { port }) await exitWithFlush(typeof code === 'number' ? code : 0) } case 'remote': { if (globalSimTarget) { console.error( ` ${rnxPublicBrand.commandName} remote creates a new simulator and cannot target --sim`, ) await exitWithFlush(1) } const platform = parsed.commandArgs[0] if (platform === 'close') { const { dispatchCloudBoundary } = await import('./cloud-dispatch') const result = await dispatchCloudBoundary({ command: 'close', args: parsed.commandArgs.slice(1), }) if (result.handled) { await exitWithFlush(result.code) } await exitWithFlush(0) } if (platform === 'ios' || platform === 'android') { const { runRemotePlatformCommand } = await import('./commands/platform') const code = await runRemotePlatformCommand( platform, parsed.commandArgs.slice(1), { port }, ) await exitWithFlush(typeof code === 'number' ? code : 0) } else if (platform === 'list') { const { runRemoteList } = await import('./commands/platform') await exitWithFlush(await runRemoteList(parsed.commandArgs.slice(1))) } else if (platform === 'stop') { const { runRemoteStop } = await import('./commands/platform') await exitWithFlush(await runRemoteStop(parsed.commandArgs.slice(1))) } else { console.error( ` usage: ${rnxPublicBrand.commandName} remote ios|android `, ) await exitWithFlush(1) } } case 'preview': { const { runPreview } = await import('./commands/preview') await exitWithFlush(await runPreview(parsed.commandArgs)) } case 'web': { const { getRnxCommandAvailability } = await import('./command-registry') const availability = getRnxCommandAvailability('web', parsed.commandArgs, 'node') console.error(` ${availability.reason ?? 'rnx web is unavailable'}`) await exitWithFlush(1) } case 'box': { const { runBox } = await import('./commands/box') await exitWithFlush(await runBox(commandArgsWithSim, { port, verbose })) } case 'use': { const { runUseCommand } = await import('./commands/control') await runUseCommand(commandArgsWithSim, { port }) break } case 'claim': { const { runClaimCommand } = await import('./commands/control') await runClaimCommand(commandArgsWithSim, { port }) break } case 'close': { const { runCloseCommand } = await import('./commands/control') await runCloseCommand(commandArgsWithSim, { port }) break } case 'device': { const { runDeviceCommand } = await import('./commands/device') await runDeviceCommand(commandArgsWithSim, { port }) break } case 'compat': { const { runCompat } = await import('./commands/compat') await runCompat(parsed.commandArgs) break } case 'report-issue': { const { runReportIssue } = await import('./commands/report-issue') const code = await runReportIssue(parsed.commandArgs) await exitWithFlush(code) } case 'desktop': { const { runDesktop } = await import('./commands/desktop') await runDesktop(parsed.commandArgs, { port, device }) break } case 'login': { const { runLogin } = await import('./commands/login') await runLogin(parsed.commandArgs) break } case 'logout': { const { runLogout } = await import('./commands/logout') await runLogout() break } case 'auth': { const { runAuth } = await import('./commands/auth') await runAuth(parsed.commandArgs) break } case 'setup': { const { runSetup } = await import('./commands/setup') await runSetup(parsed.commandArgs) break } case 'cli': { const { runCliInstall } = await import('./commands/install-cli') await runCliInstall(parsed.commandArgs) break } case 'serve': { const { runServe } = await import('./commands/serve') await runServe(parsed.commandArgs, { port }) break } case 'daemon': { const { runDaemon } = await import('./commands/daemon') await runDaemon(parsed.commandArgs, { port }) break } case 'runtime': { const { runRuntime } = await import('./commands/runtime') await runRuntime(parsed.commandArgs) break } case 'upgrade': { const { runUpgrade } = await import('./commands/upgrade') await runUpgrade(parsed.commandArgs) break } case 'version': { const { runVersion } = await import('./commands/version') await runVersion(parsed.commandArgs) break } case 'agent': { const { runAgentCommand } = await import('./commands/agent') const code = await runAgentCommand(parsed.commandArgs, { port }) await exitWithFlush(code) } case 'agent-wrapper': { const { runAgentWrapper } = await import('./commands/agent-wrapper') const code = await runAgentWrapper(parsed.commandArgs) await exitWithFlush(code) } case 'skill': { const { runSkill } = await import('./commands/skills') await runSkill(parsed.commandArgs) break } case 'app-fonts': { const { runAppFonts } = await import('./commands/app-fonts') await runAppFonts(parsed.commandArgs) break } case 'config': { const { runConfig } = await import('./commands/config') await runConfig(parsed.commandArgs) break } case 'cleanup': { const { runCleanup } = await import('./commands/cleanup') const code = await runCleanup(parsed.commandArgs) await exitWithFlush(code) } default: { console.error(` unknown command: ${command}`) console.error( ` run \`${rnxPublicBrand.commandName} --help\` to see the full surface.`, ) await exitWithFlush(1) } } } } catch (error) { if (error instanceof RnxExit) await exitWithFlush(error.code) throw error } await scheduleAutomaticCliUpdate(0) await scheduleAutomaticCleanup()