// rnx setup owns the optional machine and repository choices. rnx itself // works without project setup; bare first-run onboarding launches ConnectRN // during this flow and leaves the repository choice until the end. import os from 'node:os' import path from 'node:path' import { DEFAULT_SOOTSIM_BRIDGE_PORT } from '../../src/bridge-constants' import { getCliVersion } from '../../src/cli-version' import { readActiveRuntime, readPrivacyPreferences, readSimulatorDriverPreference, runtimeDir, type SimulatorDriverPreference, writeOnboardingPreferences, writePrivacyPreferences, } from '../../src/home-paths' import { rnxRuntime } from '../../src/runtime-delivery' import { findDesktopCompanion } from '../desktop-companion' import { confirm, select } from '../prompt' import { applyRepositorySetup, findRepositoryPackageRoot, formatRepositoryOperations, planRepositorySetup, type RepositorySetupPlan, } from '../setup-repository' import { trackCliError } from '../telemetry' import { accent, bold, muted, printBrand, printPromptBlock, success, withSpinner, } from '../ui' import { runOpenCommand } from './control' import { daemonInstall, getDaemonServiceStatus } from './daemon' import { isAgentEnv } from './inspect/env' import { runInstallDesktop } from './install-desktop' interface SetupOptions { appDir: string ci: boolean dryRun: boolean json: boolean yes: boolean service: boolean | undefined repository: boolean | undefined productAnalytics: boolean | undefined simulatorDriver: SimulatorDriverPreference | undefined } interface SetupRunOptions { launchConnectRN?: boolean } export interface SetupResult { runtimeVersion: string serviceInstalled: boolean repositoryPrepared: boolean simulatorDriver: SimulatorDriverPreference } export async function runSetup( args: string[], runOptions: SetupRunOptions = {}, ): Promise { if (args.includes('--help') || args.includes('-h')) { printHelp() return } const options = parseSetupOptions(args) const launchConnectRN = runOptions.launchConnectRN ?? (!options.ci && !options.yes && !options.json && !options.dryRun && process.stdin.isTTY) try { printIntro(options) const packageRoot = findRepositoryPackageRoot(options.appDir) const serviceStatus = getDaemonServiceStatus() const privacy = readPrivacyPreferences() const service = await decideService(options, serviceStatus) if (options.dryRun && service && !serviceStatus.installed && !options.json) { console.log(` ${muted('Would enable')} ${accent('the background service')}`) } if (!service && !serviceStatus.installed && !options.json) { console.log(` ${muted('Enable it later with')} ${accent('rnx daemon install')}`) } const simulatorDriver = await decideSimulatorDriver(options) if (options.dryRun && !options.json) { console.log( ` ${muted('Would use')} ${accent(simulatorDriver === 'playwright' ? 'Browser' : 'Desktop')}`, ) } const manifest = options.json ? await rnxRuntime.fetchManifest() : await withSpinner('Checking the rnx runtime', () => rnxRuntime.fetchManifest()) const runtimeVersion = rnxRuntime.resolveVersion(manifest, { channel: 'stable', }).version const cliVersion = getCliVersion() if (cliVersion === '0.0.0') { throw new Error('could not resolve the installed rnx CLI version') } let runtimeInstalled = false if (!options.dryRun) { const activeRuntime = readActiveRuntime() const install = () => rnxRuntime.install({ version: runtimeVersion, channel: 'stable', setActive: true, protectVersions: activeRuntime ? [activeRuntime] : [], }) const result = options.json ? await install() : await withSpinner(`Preparing runtime ${runtimeVersion}`, install) runtimeInstalled = result.installed } let serviceInstalled = serviceStatus.installed if (service && !serviceStatus.installed && !options.dryRun) { if (options.json) { await daemonInstall({ port: DEFAULT_SOOTSIM_BRIDGE_PORT, force: false }) } else { console.log(`\n ${accent('◆')} ${bold('Enabling the background service')}`) await daemonInstall({ port: DEFAULT_SOOTSIM_BRIDGE_PORT, force: false }) } serviceInstalled = true } if (simulatorDriver === 'electron' && !options.dryRun && !findDesktopCompanion()) { await runInstallDesktop(['--yes']) } if (launchConnectRN) { console.log(`\n ${accent('◆')} Opening ConnectRN`) await runOpenCommand([ 'ConnectRN', '--driver', simulatorDriver, '--no-describe', '--quiet', ]) } const productAnalytics = await decidePrivacy(options, privacy) const crashReports = false const repositoryPlan = packageRoot ? planRepositorySetup({ appDir: packageRoot, cliVersion }) : null const repository = await decideRepository(options, repositoryPlan) if (repository && repositoryPlan) { if (!options.json) { for (const operation of formatRepositoryOperations(repositoryPlan)) { console.log(` ${muted(options.dryRun ? 'Would run' : 'Running')} ${operation}`) } } await applyRepositorySetup(repositoryPlan, { dryRun: options.dryRun, json: options.json, }) } if (!options.dryRun) { writePrivacyPreferences({ productAnalytics, crashReports }) writeOnboardingPreferences(simulatorDriver) } const result: SetupResult = { runtimeVersion, serviceInstalled, repositoryPrepared: repository, simulatorDriver, } if (options.json) { console.log( JSON.stringify( { ...result, runtimeInstalled, dryRun: options.dryRun, cache: runtimeCacheMetadata(runtimeVersion), repositoryOperations: repository && repositoryPlan ? formatRepositoryOperations(repositoryPlan) : [], }, null, 2, ), ) } else { console.log( options.dryRun ? `\n ${success('✓')} Dry run complete.` : `\n ${success('✓')} ${launchConnectRN ? 'ConnectRN is open. ' : ''}rnx is ready.`, ) } return result } catch (error) { trackCliError(error) throw error } } function parseSetupOptions(args: string[]): SetupOptions { const appIndex = args.indexOf('--app') const appArg = appIndex >= 0 ? args[appIndex + 1] : undefined if (appIndex >= 0 && (!appArg || appArg.startsWith('-'))) { throw new Error('--app requires a directory') } return { appDir: path.resolve(appArg ?? process.cwd()), ci: args.includes('--ci') || isAgentEnv(), dryRun: args.includes('--dry-run'), json: args.includes('--json'), yes: args.includes('--yes') || args.includes('-y'), service: readBooleanFlag(args, '--service', '--no-service'), repository: readBooleanFlag(args, '--repo', '--no-repo'), productAnalytics: readOnOffFlag(args, '--analytics'), simulatorDriver: readSimulatorDriverFlag(args), } } function readSimulatorDriverFlag(args: string[]): SimulatorDriverPreference | undefined { const desktop = args.includes('--desktop') const browser = args.includes('--browser') if (desktop && browser) { throw new Error('--desktop and --browser cannot be used together') } if (desktop) return 'electron' if (browser) return 'playwright' return undefined } function readBooleanFlag( args: string[], positive: string, negative: string, ): boolean | undefined { if (args.includes(positive) && args.includes(negative)) { throw new Error(`${positive} and ${negative} cannot be used together`) } if (args.includes(positive)) return true if (args.includes(negative)) return false return undefined } function readOnOffFlag(args: string[], name: string): boolean | undefined { const equals = args.find((arg) => arg.startsWith(`${name}=`)) const index = args.indexOf(name) const raw = equals?.slice(name.length + 1) ?? (index >= 0 ? args[index + 1] : undefined) if (raw === undefined) return undefined if (raw === 'on' || raw === 'yes' || raw === 'true') return true if (raw === 'off' || raw === 'no' || raw === 'false') return false throw new Error(`${name} must be on or off`) } async function decideService( options: SetupOptions, status: ReturnType, ): Promise { if (options.ci) { if (options.service === true) throw new Error('--ci cannot install a machine service') return false } if (!status.supported) { if (!options.json) { printPromptBlock('Background service', [ 'The persistent service is currently available on macOS and Linux.', 'rnx will start the bridge when needed on this platform.', ]) } return false } if (status.installed && options.service === undefined) { if (!options.json) console.log(` ${success('✓')} Background service is ready`) return true } if (options.service !== undefined) return options.service if (options.yes || options.dryRun) return true requireInteractive(options, '--service or --no-service') printPromptBlock('Background service', [ 'Keeps the local bridge and runtime ready between commands.', 'Agent inspection, interaction, and test commands start much faster.', ]) return confirm('Enable the background service?', true) } async function decideSimulatorDriver( options: SetupOptions, ): Promise { if (options.simulatorDriver) return options.simulatorDriver const current = readSimulatorDriverPreference() if (current) return current if (options.ci || options.yes || options.dryRun) return 'playwright' requireInteractive(options, '--desktop or --browser') const desktopDescription = process.platform === 'darwin' ? 'Desktop downloads a DMG and installs rnx.app.' : process.platform === 'win32' ? 'Desktop downloads and runs the rnx installer.' : 'Desktop downloads the matching rnx AppImage.' const desktopChoice = process.platform === 'darwin' ? 'Desktop app Installs rnx.app from a DMG' : process.platform === 'win32' ? 'Desktop app Runs the rnx installer' : 'Desktop app Installs the matching AppImage' printPromptBlock('Simulator window', [ 'Browser opens immediately in an isolated Chrome window.', desktopDescription, 'The Desktop app opens in a dedicated native window.', ]) const choice = await select('Where should rnx open?', [ 'Browser (recommended) No additional download', desktopChoice, ]) return choice === 0 ? 'playwright' : 'electron' } async function decidePrivacy( options: SetupOptions, current: ReturnType, ): Promise { if (options.productAnalytics !== undefined) return options.productAnalytics if (current.configured) return current.productAnalytics if (options.yes || options.ci || !process.stdin.isTTY) return false printPromptBlock('Private by default', [ 'Diagnostics stay off unless you opt in.', 'Reports can contain package compatibility or an error class.', 'They never contain your code or app data.', ]) return confirm('Share bounded diagnostics to improve rnx?', false) } async function decideRepository( options: SetupOptions, plan: RepositorySetupPlan | null, ): Promise { if (!plan) { if (options.repository === true) { throw new Error('--repo requires a package.json inside a Git repository') } return false } if (plan.operations.length === 0) return true if (options.repository !== undefined) return options.repository if (options.yes || options.ci || !process.stdin.isTTY) return false printPromptBlock('Share rnx with your team', [ 'rnx already works here with no project setup.', 'This only adds an exact rnxsim dev dependency and an rnx script for teammates.', ]) return confirm(`Add rnx to ${path.basename(plan.appDir)}?`, false) } function requireInteractive(options: SetupOptions, flags: string): void { if (process.stdin.isTTY && !options.ci) return throw new Error(`non-interactive setup requires ${flags}, or --yes to accept defaults`) } function runtimeCacheMetadata(runtimeVersion: string) { const platform = os.platform() const arch = os.arch() return { path: runtimeDir(runtimeVersion), key: `rnx-runtime-${platform}-${arch}-${runtimeVersion}`, inputs: { platform, arch, runtimeVersion }, } } function printIntro(options: SetupOptions): void { if (options.json) return printBrand('Run any React Native app in a local simulator. No project setup required.') } function printHelp(): void { console.log(` rnx setup - configure optional local conveniences rnx works without project setup. This command can enable the faster background service, choose Browser or Desktop, configure private diagnostics, and optionally add an exact rnxsim dependency plus an rnx script for teammates. usage: rnx setup rnx setup --yes rnx setup --ci --yes --json rnx setup --dry-run choices: --service | --no-service --desktop | --browser --analytics on|off --repo | --no-repo automation: --yes accept recommended machine defaults; diagnostics and repo changes stay off --ci never prompt or install a background service --json emit runtime and cache metadata as JSON --dry-run show choices and repository operations without changing anything --app DIR inspect DIR for an optional repository installation `) }