// installs the standalone rnx executable into the user's command path. // // the public shell installer owns download and checksum verification. this // command mirrors its local mutation after those bytes are trusted, while the // desktop app copies its bundled executable without a network download. import { spawnSync } from 'node:child_process' import { appendFileSync, chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, } from 'node:fs' import { createServer } from 'node:net' import { homedir } from 'node:os' import { dirname, join } from 'node:path' import { isDaemonLockfileFresh, readDaemonLockfile } from '../../src/home-paths' import { IS_STANDALONE } from '../standalone' const INSTALL_WAIT_MS = 10_000 type CliInstallOptions = { alreadyInstalled: boolean handoffPid: number | null port: number | null } export async function runCliInstall(args: string[]): Promise { const options = parseCliInstallOptions(args) if (!IS_STANDALONE) { throw new Error('rnx cli install requires the standalone rnx executable') } const target = installedCliPath() if (!options.alreadyInstalled) installCurrentExecutable(target) else if (!existsSync(target)) { throw new Error(`rnx cli install expected ${target} to exist`) } const profile = installCommandPath(target) console.log(` command: ${target}`) if (profile) console.log(` shell: ${profile}`) if (options.handoffPid === null) return if (process.platform === 'win32') { console.log(' desktop daemon: remains app-owned on Windows') return } await handOffDaemon({ target, pid: options.handoffPid, port: options.port }) } function parseCliInstallOptions(args: string[]): CliInstallOptions { const [subcommand] = args if (subcommand === '--help' || subcommand === '-h') { console.log(`\nrnx cli install - install the rnx command in your PATH usage: rnx cli install internal options: --installed update shell startup after an installer copied rnx --handoff-pid replace this desktop-owned daemon with the service --port daemon bridge port to preserve during handoff `) process.exit(0) } if (subcommand !== 'install') { throw new Error('usage: rnx cli install') } const handoffPid = readHandoffPid(args) const port = readPort(args) if (handoffPid !== null && port === null) { throw new Error('--handoff-pid requires --port') } return { alreadyInstalled: args.includes('--installed'), handoffPid, port, } } function readHandoffPid(args: string[]): number | null { const flag = '--handoff-pid' const index = args.indexOf(flag) if (index < 0) return null const value = Number(args[index + 1]) if (!Number.isSafeInteger(value) || value < 2) { throw new Error(`${flag} requires a process id`) } return value } function readPort(args: string[]): number | null { const flag = '--port' const index = args.indexOf(flag) if (index < 0) return null const value = Number(args[index + 1]) if (!Number.isInteger(value) || value < 1 || value > 65_535) { throw new Error(`${flag} requires a port number`) } return value } function installedCliPath(): string { const home = process.env.HOME || homedir() const prefix = process.env.RNX_CLI_PREFIX || join(home, '.local') return join(prefix, 'bin', process.platform === 'win32' ? 'rnx.exe' : 'rnx') } function installCurrentExecutable(target: string): void { if (process.execPath === target) return mkdirSync(dirname(target), { recursive: true }) const temporary = join(dirname(target), `.rnx-install-${process.pid}`) try { copyFileSync(process.execPath, temporary) chmodSync(temporary, 0o755) renameSync(temporary, target) } finally { try { unlinkSync(temporary) } catch {} } } function installCommandPath(target: string): string | null { if (process.platform === 'win32') { const bin = dirname(target) const pathValue = process.env.Path || process.env.PATH || '' if ( !pathValue.split(';').some((entry) => entry.toLowerCase() === bin.toLowerCase()) ) { const next = pathValue ? `${bin};${pathValue}` : bin const result = spawnSync( 'reg.exe', [ 'add', 'HKCU\\Environment', '/v', 'Path', '/t', 'REG_EXPAND_SZ', '/d', next, '/f', ], { encoding: 'utf8' }, ) if (result.status !== 0) { throw new Error(`could not add rnx to the user PATH: ${result.stderr.trim()}`) } } return 'the user PATH' } const home = process.env.HOME || homedir() const shell = process.env.SHELL || '' const shellName = shell.endsWith('/zsh') ? 'zsh' : shell.endsWith('/bash') ? 'bash' : '' const profile = join( home, shellName === 'zsh' ? '.zshrc' : shellName === 'bash' ? '.bashrc' : '.profile', ) const bin = dirname(target) const pathLine = `export PATH="${bin}:$PATH"` const integration = shellName ? `eval "$(command rnx __shell-init ${shellName})"` : '' const current = existsSync(profile) ? readFileSync(profile, 'utf8') : '' if (current.includes(pathLine) && (!integration || current.includes(integration))) return null const additions = [ '', '# added by the rnx installer; delete this block to remove rnx from your shell', ...(current.includes(pathLine) ? [] : [pathLine]), ...(integration && !current.includes(integration) ? [integration] : []), ] appendFileSync(profile, `${additions.join('\n')}\n`) return profile } async function handOffDaemon({ target, pid, port, }: { target: string pid: number port: number | null }): Promise { if (port === null) throw new Error('the desktop daemon did not report its port') const deadline = Date.now() + INSTALL_WAIT_MS try { process.kill(pid, 'SIGTERM') } catch (error) { const code = error && typeof error === 'object' ? Reflect.get(error, 'code') : null if (code !== 'ESRCH') throw error } await waitFor( () => { const lock = readDaemonLockfile() return !isDaemonLockfileFresh(lock) || lock.pid !== pid }, 'the desktop daemon to stop', deadline, ) await waitForPortToClose(port, deadline) const result = spawnSync( target, ['--port', String(port), 'daemon', 'install', '--force'], { encoding: 'utf8', timeout: Math.max(1, deadline - Date.now()), env: { ...process.env, SOOTSIM_FORCE_DAEMON_INSTALL: '1', }, }, ) if (result.status !== 0) { throw new Error( `could not register the rnx daemon: ${(result.stderr || result.stdout || 'unknown error').trim()}`, ) } await waitFor( () => { const lock = readDaemonLockfile() return isDaemonLockfileFresh(lock) && lock.pid !== pid && lock.bridgePort === port }, 'the installed rnx daemon to start', deadline, ) console.log(' desktop daemon: handed off to the installed rnx command') } async function waitFor( condition: () => boolean, description: string, deadline: number, ): Promise { while (Date.now() < deadline) { if (condition()) return await new Promise((resolve) => setTimeout(resolve, 100)) } throw new Error(`timed out waiting for ${description}`) } async function waitForPortToClose(port: number, deadline: number): Promise { while (Date.now() < deadline) { const available = await new Promise((resolve, reject) => { const server = createServer() server.once('error', (error) => { if ('code' in error && error.code === 'EADDRINUSE') { resolve(false) return } reject(error) }) server.listen(port, '127.0.0.1', () => { server.close((error) => { if (error) reject(error) else resolve(true) }) }) }) if (available) return await new Promise((resolve) => setTimeout(resolve, 100)) } throw new Error(`timed out waiting for port ${port} to close`) }