// `rnx list` — connected sims, with the active one marked. import { resolveDevCheckoutRuntimeRoot } from '../../../src/runtime-assets' import { type BridgeSimInfo, formatLockOwner, type WsBridge } from '../../ws-bridge' import { printJson, wantsJson } from './shared' export async function runListSubcommand(opts: { bridge: WsBridge simId: string | undefined args: string[] }): Promise { const sims = await opts.bridge.listSims() // filter sims: // --all show every sim including empty shells (default // hides sims that have no bundle URL — they're // usually idle host shells that overflow the // list when several demos are open at once) // --bundle only sims whose loaded url contains the substring // --app-port shorthand for `--bundle :/`. NOT `--port` // which is taken by the global bridge-port flag // (see ws-bridge.ts parseBridgeCliArgs) — using // --port here used to silently set the bridge // port too, sending the WS connect to the wrong // place and failing with "daemon is not running" // --primary only the primary sim const showAll = opts.args.includes('--all') const bundleArg = opts.args.find((_, i) => opts.args[i - 1] === '--bundle') const portArg = opts.args.find((_, i) => opts.args[i - 1] === '--app-port') const primaryOnly = opts.args.includes('--primary') const filtered = sims.filter((sim) => { if (primaryOnly && !sim.isPrimary) return false if (bundleArg && !(sim.url ?? '').includes(bundleArg)) return false // a sim url looks like `http://localhost:5173/rn/8085?bundle=...` where // `8085` is the metro/vite port the app is loaded from. the bundle url // inside the `?bundle=` param is itself percent-encoded (`:8085/` -> // `%3A8085%2F`), so the previous literal `:${port}/` substring check // never matched. match the canonical `/rn/` path segment instead. if (portArg && !(sim.url ?? '').includes(`/rn/${portArg}`)) return false if (!showAll && !bundleArg && !portArg && !primaryOnly) { // hide host shells (no bundle loaded — only origin) unless they're // the currently-selected sim const hasBundle = sim.url && (sim.url.includes('bundle=') || sim.url.includes('/index.bundle')) if (!hasBundle && sim.id !== opts.simId) return false } return true }) if (wantsJson(opts.args)) { printJson(filtered.map((b) => ({ ...b, active: b.id === opts.simId }))) return } printConnectedSims(filtered, opts.simId) if (filtered.length < sims.length && !wantsJson(opts.args)) { console.log(` (${sims.length - filtered.length} more hidden — pass --all to show)`) } } // returns a short, human-friendly description of what a sim is showing — // e.g. "bundle host/...path", "connect :8081", "demo bluesky". returns null // when there's no useful summary beyond the raw URL itself, so the caller // can skip the redundant `loaded:` line in that case. function summarizeSimUrl(url: string | undefined): string | null { if (!url) return null try { const parsed = new URL(url) if (parsed.searchParams.has('bundle')) { const bundleUrl = parsed.searchParams.get('bundle') || '' try { const bundle = new URL(bundleUrl) const shortPath = bundle.pathname.length > 36 ? `...${bundle.pathname.slice(-36)}` : bundle.pathname return `bundle ${bundle.host}${shortPath}` } catch { return 'bundle' } } if (parsed.searchParams.has('port')) { return `connect :${parsed.searchParams.get('port') || ''}` } if (parsed.searchParams.has('open')) { return `connect ${parsed.searchParams.get('open') || ''}` } if (parsed.searchParams.has('demo')) { return `demo ${parsed.searchParams.get('demo') || 'default'}` } if ( parsed.pathname.includes('/sootsim/index.html') || parsed.pathname === '/sootsim/' || parsed.pathname === '/sootsim' ) { return 'embedded rnx' } return null } catch { return null } } function printConnectedSims(sims: BridgeSimInfo[], selectedId?: string) { if (sims.length === 0) { console.log(' no sims connected') return } console.log(` connected sims (${sims.length}):\n`) for (const sim of sims) { const lockTag = sim.lockedBy && sim.lockExpiresAt ? `locked by ${formatLockOwner(sim.lockedBy)} (${Math.max(0, Math.round((sim.lockExpiresAt - Date.now()) / 1000))}s)` : '' const tags = [ sim.isPrimary ? 'primary' : '', sim.id === selectedId ? 'selected' : '', sim.readyState, sim.userVisible === false ? 'hidden' : sim.userVisible === true ? 'visible' : '', sim.attachedCliCount && sim.attachedCliCount > 0 ? 'in use' : '', sim.userFocused ? 'focused' : '', lockTag, ].filter(Boolean) console.log(` ${sim.id}${tags.length ? ` [${tags.join(', ')}]` : ''}`) const loaded = summarizeSimUrl(sim.url) if (loaded) { console.log(` loaded: ${loaded}`) } else if (sim.url) { // no friendly summary — show the raw url so the caller can still copy it. // truncate aggressively so a 400-char metro url doesn't dominate the list. const shown = sim.url.length > 96 ? `${sim.url.slice(0, 93)}…` : sim.url console.log(` url: ${shown}`) } else if (sim.origin) { console.log(` origin: ${sim.origin}`) } const engine = describeEngineSource(sim) if (engine) console.log(` engine: ${engine}`) if (sim.title) console.log(` title: ${sim.title}`) if (sim.visibilityState && sim.visibilityState !== 'visible') { console.log(` visibility: ${sim.visibilityState}`) } console.log(` connected: ${formatRelativeAge(Date.now() - sim.connectedAt)}`) if (sim.lastActiveAt && sim.lastActiveAt > 0) { console.log(` last active: ${formatRelativeAge(Date.now() - sim.lastActiveAt)}`) } } } // classify which engine a sim is running so dev-vs-published is never ambiguous // to agents or humans reading `rnx list`. dev shell serves local source on // :5173; the Contrast app embeds rnx on :3000; the bridge/daemon and the // /__soot plugin serve the active runtime dir — which, inside a dev checkout, is // the fresh dev-stack build (resolveDevCheckoutRuntimeRoot mode boundary), not a // published runtime. keep this label honest so it never says "published" while // actually serving local build output. function describeEngineSource(sim: { url?: string; origin?: string }): string | null { const ref = sim.url || sim.origin || '' if (!ref) return null try { const port = new URL(ref).port if (port === '5173') return 'dev — local source (:5173)' if (port === '3000') return 'Contrast app embedded (:3000)' if (resolveDevCheckoutRuntimeRoot()) return `dev — local build (:${port || '?'})` return `runtime — published engine (:${port || '?'})` } catch { return null } } function formatRelativeAge(ms: number): string { const sec = Math.max(0, Math.round(ms / 1000)) if (sec < 60) return `${sec}s ago` if (sec < 3600) return `${Math.round(sec / 60)}m ago` if (sec < 86400) return `${Math.round(sec / 3600)}h ago` return `${Math.round(sec / 86400)}d ago` }