// rnx runtime — manage engine runtimes under ~/.rnx/runtimes/. // // runtimes are versioned tarballs hosted behind the configured CDN origin // (default https://contrast.dev/runtimes/). each install: // 1. fetches manifest.json to resolve a version (or confirm the literal) // 2. downloads sootsim-runtime-.tar.gz to an operation temp directory // 3. verifies sha256 // 4. extracts to ~/.rnx/runtimes// // 5. (optionally) sets it active + tells the daemon to hot-swap // // the daemon-side state (active runtime) is mirrored in ~/.rnx/runtimes/active // so the CLI can change it even when the daemon isn't running. import fs from 'fs' import { compareSemver } from '@contrast/runtime-delivery' import { WebSocket } from 'ws' import { ensureRnxHome, readActiveRuntime, readLiveRuntimeVersions, runtimeDir, writeActiveRuntime, } from '../../src/home-paths' import { DEFAULT_RUNTIME_CDN_ORIGIN, RNX_CHANGELOG_URL, rnxRuntime, } from '../../src/runtime-delivery' import { resolveRNXAppConfig } from '../app-config' import { rnxExit } from '../run-rnx' import { fetchRuntimeReleaseNotes, formatRuntimeReleaseNotes } from '../runtime-notes' import { resolveDefaultBridgePort } from '../ws-bridge' interface RuntimeOptions { channel?: string } export async function runRuntime(args: string[], opts: RuntimeOptions = {}) { const [sub, ...rest] = args if (!sub || sub === '--help' || sub === '-h') { printHelp() return } switch (sub) { case 'install': return runtimeInstall(rest, opts) case 'list': return runtimeList(rest) case 'use': return runtimeUse(rest) case 'remove': return runtimeRemove(rest) case 'which': return runtimeWhich() case 'notes': return runtimeNotes(rest) default: console.error(` unknown runtime subcommand: ${sub}`) printHelp() rnxExit(1) } } function printHelp() { console.log(` rnx runtime — manage engine runtimes under ~/.rnx/runtimes/ usage: rnx runtime install [version] install a version (default: channel latest) rnx runtime list show installed + available versions rnx runtime use switch active runtime rnx runtime remove delete an installed runtime rnx runtime which print active runtime version rnx runtime notes print repo or default engine release notes flags: --channel channel to resolve 'latest' from (default: stable) --force reinstall even if the version is already on disk --set-active=false do not switch active runtime after install environment: RNX_CDN_ORIGIN override the CDN base URL (default: ${DEFAULT_RUNTIME_CDN_ORIGIN}) examples: rnx runtime install rnx runtime install 1.2.3 rnx runtime install --channel beta rnx runtime use 1.2.3 rnx runtime notes `) } // --- install -------------------------------------------------------------- async function runtimeInstall(args: string[], opts: RuntimeOptions) { const { version: versionArg, flags } = parseVersionAndFlags(args) const channel = flags.channel ?? opts.channel ?? 'stable' const force = flags.force === true const setActive = flags.setActive !== false ensureRnxHome() console.log(`rnx runtime install`) console.log(` cdn: ${rnxRuntime.resolveCdnOrigin()}`) try { const result = await rnxRuntime.install({ version: versionArg, channel, force, setActive, protectVersions: readLiveRuntimeVersions(), }) console.log(` version: ${result.version} (channel: ${result.channel})`) if (result.installed) { console.log(` installed ${result.version}`) } else { console.log(` already installed at ${result.runtimeDir}`) } if (setActive) await activateVersion(result.version) return result } catch (err) { console.error(` ${describeError(err)}`) rnxExit(1) } } async function activateVersion(version: string) { writeActiveRuntime(version) console.log(` active: ${version}`) // if a daemon is running, tell it to hot-swap its http root. the // daemon re-reads the active runtime per-request anyway, so this is // mostly a nudge — but it also broadcasts runtime:changed so any // connected electron webContents can reload. silent failure is fine; // the next daemon start picks up the active file from disk. const notified = await tellDaemonRuntimeChanged(version) if (!notified) { console.log( ` (no daemon running — next rnx/electron launch will pick up ${version})`, ) } } /** notify a running daemon that the active runtime changed so it hot-swaps * its http root + pushes a runtime:changed message to every connected * browser (electron). uses a raw WebSocket to skip the bridge's * primary-browser resolution — this message is daemon-only, not routed * to a browser. returns true when the daemon acknowledged; false when * no fresh daemon was reachable (caller logs accordingly). * * skips the ws probe entirely when the lockfile isn't fresh — the * default-port fallback in resolveDefaultBridgePort() would otherwise * eat a full handshakeTimeout on every `runtime use` when no daemon * is running. */ async function tellDaemonRuntimeChanged(version: string): Promise { const { isDaemonLockfileFresh, readDaemonLockfile } = await import('../../src/home-paths') const lock = readDaemonLockfile() if (!isDaemonLockfileFresh(lock)) return false const port = resolveDefaultBridgePort() return new Promise((resolve) => { let settled = false let success = false const finish = (ok: boolean) => { if (settled) return settled = true resolve(ok) } const ws = new WebSocket(`ws://127.0.0.1:${port}`, { handshakeTimeout: 800, }) const timer = setTimeout(() => { try { ws.close() } catch {} finish(success) }, 1500) ws.on('open', () => { try { ws.send(JSON.stringify({ type: 'runtime:use', version, id: 0 })) success = true } catch {} // give the daemon a beat to process, then close setTimeout(() => { try { ws.close() } catch {} }, 100) }) ws.on('close', () => { clearTimeout(timer) finish(success) }) ws.on('error', () => { clearTimeout(timer) finish(false) }) }) } // --- list ----------------------------------------------------------------- async function runtimeList(_args: string[]) { ensureRnxHome() const installed = rnxRuntime.listInstalled() const active = readActiveRuntime() console.log(`installed:`) if (installed.length === 0) { console.log(` (none)`) } else { for (const v of installed) { const marker = v === active ? '*' : ' ' console.log(` ${marker} ${v}`) } } try { const manifest = await rnxRuntime.fetchManifest() console.log(`available (latest per channel):`) for (const [name, ch] of Object.entries(manifest.channels)) { console.log(` ${name.padEnd(8)} ${ch.latest}`) } const hosted = Object.keys(manifest.versions).sort(compareSemver).reverse() if (hosted.length > 1) { console.log(`hosted versions:`) for (const v of hosted) console.log(` ${v}`) } } catch (err) { console.log(`available: (could not fetch manifest: ${describeError(err)})`) } } // --- use ------------------------------------------------------------------ async function runtimeUse(args: string[]) { const version = args[0] if (!version) { console.error(` usage: rnx runtime use `) rnxExit(1) } const installed = rnxRuntime.listInstalled() if (!installed.includes(version)) { console.error(` version ${version} is not installed`) console.error(` installed: ${installed.join(', ') || '(none)'}`) console.error(` run \`rnx runtime install ${version}\` first`) rnxExit(1) } await activateVersion(version) } // --- remove --------------------------------------------------------------- async function runtimeRemove(args: string[]) { const version = args[0] if (!version) { console.error(` usage: rnx runtime remove `) rnxExit(1) } const active = readActiveRuntime() if (active === version) { console.error(` cannot remove active runtime ${version}`) console.error( ` switch with \`rnx runtime use \` first, or install another version`, ) rnxExit(1) } if (readLiveRuntimeVersions().includes(version)) { console.error(` cannot remove runtime ${version} while a project is using it`) rnxExit(1) } const dir = runtimeDir(version) if (!fs.existsSync(dir)) { console.error(` ${version} is not installed`) return } fs.rmSync(dir, { recursive: true, force: true }) console.log(` removed ${version}`) } // --- which ---------------------------------------------------------------- async function runtimeWhich() { const active = readActiveRuntime() if (!active) { console.log(` no active runtime`) return } console.log(active) } // --- notes ---------------------------------------------------------------- async function runtimeNotes(args: string[]) { if (args.length > 0) { console.error(` usage: rnx runtime notes`) rnxExit(1) } const { config } = await resolveRNXAppConfig() const version = config?.runtimeVersion ?? readActiveRuntime() if (!version) { console.log(` no active runtime`) console.log(` release notes: ${RNX_CHANGELOG_URL}`) return } console.log(formatRuntimeReleaseNotes(version, await fetchRuntimeReleaseNotes(version))) } // --- helpers -------------------------------------------------------------- interface InstallFlags { channel?: string force?: boolean setActive?: boolean } function parseVersionAndFlags(args: string[]): { version: string | null flags: InstallFlags } { const flags: InstallFlags = {} const positional: string[] = [] for (let i = 0; i < args.length; i++) { const arg = args[i] if (arg === '--channel' && i + 1 < args.length) { flags.channel = args[i + 1] i++ continue } if (arg.startsWith('--channel=')) { flags.channel = arg.slice('--channel='.length) continue } if (arg === '--force') { flags.force = true continue } if (arg === '--set-active=false' || arg === '--no-set-active') { flags.setActive = false continue } positional.push(arg) } return { version: positional[0] ?? null, flags } } function describeError(err: unknown): string { if (err instanceof Error) return err.message return String(err) }