rnxsim 0.1.419 → 0.1.420

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/cli/cloud-client.ts +164 -12
  2. package/cli/cloud-dispatch.ts +7 -2
  3. package/cli/commands/box/checkout-plane.ts +78 -40
  4. package/cli/commands/platform.ts +30 -9
  5. package/cli/outbound-endpoints.ts +6 -0
  6. package/cli/shell-init.ts +1 -1
  7. package/cli/ws-bridge.ts +7 -5
  8. package/dist-lib/agent-daemon-client.cjs +1 -1
  9. package/dist-lib/agent-events.cjs +1 -1
  10. package/dist-lib/agent-identity.cjs +1 -1
  11. package/dist-lib/agent-sessions.cjs +1 -1
  12. package/dist-lib/attached-projects.cjs +1 -1
  13. package/dist-lib/auth/shared-session.cjs +1 -1
  14. package/dist-lib/backend-origin.cjs +1 -1
  15. package/dist-lib/beta.cjs +1 -1
  16. package/dist-lib/beta.mjs +1 -1
  17. package/dist-lib/bridge-constants.cjs +1 -1
  18. package/dist-lib/bridge-contract-input.cjs +1 -1
  19. package/dist-lib/bridge-contract-input.mjs +1 -1
  20. package/dist-lib/bridge-contract.cjs +1 -1
  21. package/dist-lib/bridge-contract.mjs +1 -1
  22. package/dist-lib/capture-contract.cjs +1 -1
  23. package/dist-lib/capture-contract.mjs +1 -1
  24. package/dist-lib/cli-constants.cjs +1 -1
  25. package/dist-lib/cloud-contract.cjs +1 -1
  26. package/dist-lib/cloud-contract.mjs +1 -1
  27. package/dist-lib/config.cjs +1 -1
  28. package/dist-lib/detox/index.cjs +1 -1
  29. package/dist-lib/dev-bundle-resolution.cjs +6 -4
  30. package/dist-lib/home-paths.cjs +1 -1
  31. package/dist-lib/host/bridge-host.cjs +55 -28
  32. package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
  33. package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
  34. package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
  35. package/dist-lib/host/replacement-module-handler.cjs +1 -1
  36. package/dist-lib/host/websocket-proxy.cjs +1 -1
  37. package/dist-lib/index.cjs +7 -5
  38. package/dist-lib/jump-to-source-babel.cjs +1 -1
  39. package/dist-lib/menu.cjs +1 -1
  40. package/dist-lib/menu.mjs +1 -1
  41. package/dist-lib/metro-fingerprint-registry.cjs +1 -1
  42. package/dist-lib/metro-fingerprint-registry.mjs +1 -1
  43. package/dist-lib/metro-production-bundle.cjs +1 -1
  44. package/dist-lib/metro-production-bundle.mjs +1 -1
  45. package/dist-lib/metro.cjs +4 -3
  46. package/dist-lib/profiles.cjs +1 -1
  47. package/dist-lib/public-brand.cjs +1 -1
  48. package/dist-lib/react-native-host-modules.cjs +1 -1
  49. package/dist-lib/react-native-host-modules.mjs +1 -1
  50. package/dist-lib/render-mode.cjs +1 -1
  51. package/dist-lib/scripts/dev-server-scanner.cjs +9 -6
  52. package/dist-lib/sdk.cjs +1 -1
  53. package/dist-lib/sdk.mjs +1 -1
  54. package/dist-lib/skills.cjs +64 -34
  55. package/dist-lib/vite.cjs +1 -1
  56. package/package.json +1 -1
  57. package/scripts/dev-server-scanner.ts +9 -5
  58. package/src/dev-bundle-resolution.ts +6 -3
  59. package/src/dev-server-open.ts +3 -2
@@ -1,5 +1,12 @@
1
1
  import { createHash, randomUUID } from 'node:crypto'
2
- import { closeSync, fstatSync, openSync, readdirSync, readFileSync } from 'node:fs'
2
+ import {
3
+ closeSync,
4
+ existsSync,
5
+ fstatSync,
6
+ openSync,
7
+ readdirSync,
8
+ readFileSync,
9
+ } from 'node:fs'
3
10
  import { dirname, extname, join, resolve } from 'node:path'
4
11
  import {
5
12
  embedMetroAssetFiles,
@@ -17,6 +24,7 @@ import {
17
24
  isRnxCloudCommandType,
18
25
  RNX_CLOUD_MAX_ARTIFACT_BYTES,
19
26
  type RnxCloudBoxCreateReceipt,
27
+ type RnxCloudCreateReceipt,
20
28
  } from '../src/cloud-contract'
21
29
  import {
22
30
  RNX_METRO_MODULE_IDENTITY_VERSION,
@@ -119,11 +127,50 @@ function readImmutableBundleSource(bundlePath: string): string {
119
127
  }
120
128
  }
121
129
 
130
+ async function fetchBundleSource(bundleUrl: string): Promise<string> {
131
+ let response: Response
132
+ try {
133
+ response = await fetch(bundleUrl)
134
+ } catch (error) {
135
+ throw new Error(
136
+ `rnx ios --nano could not fetch bundle from ${bundleUrl}: ${
137
+ error instanceof Error ? error.message : String(error)
138
+ }`,
139
+ )
140
+ }
141
+ if (!response.ok) {
142
+ throw new Error(
143
+ `rnx ios --nano could not fetch bundle from ${bundleUrl}: HTTP ${response.status}${
144
+ response.statusText ? ` ${response.statusText}` : ''
145
+ }`,
146
+ )
147
+ }
148
+ const text = await response.text()
149
+ if (text.length === 0) {
150
+ throw new Error('rnx ios --nano bundle is empty')
151
+ }
152
+ if (text.includes('\0') || !text.includes('__d(') || !text.includes('__r(')) {
153
+ throw new Error('rnx ios --nano requires a Metro JavaScript bundle')
154
+ }
155
+ return text
156
+ }
157
+
158
+ async function loadBundleSource(bundleInput: string): Promise<string> {
159
+ if (bundleInput.startsWith('http://')) {
160
+ throw new Error('rnx ios --nano bundle URL must use HTTPS')
161
+ }
162
+ if (bundleInput.startsWith('https://')) {
163
+ return fetchBundleSource(bundleInput)
164
+ }
165
+ return readImmutableBundleSource(bundleInput)
166
+ }
167
+
122
168
  // `react-native bundle --assets-dest` dest paths are not always the JS
123
169
  // descriptor's httpServerLocation with `../` replaced by `_`. Metro 0.83 does
124
170
  // that (`__node_modules`); Metro 0.84 often dests `node_modules` and keeps the
125
171
  // project-root-relative app path. resolveMetroIosAssetDestFile matches both.
126
- function listMetroAssetDestFiles(assetsPath: string): string[] {
172
+ function listMetroAssetDestFiles(assetsPath?: string): string[] {
173
+ if (!assetsPath || !existsSync(assetsPath)) return []
127
174
  const files: string[] = []
128
175
  const walk = (dir: string, prefix: string) => {
129
176
  let entries
@@ -183,19 +230,19 @@ async function inferCloudModuleIdentity(
183
230
  * an ordinary unannotated Metro production bundle is identified through the
184
231
  * published fingerprint registry; a withRNX footer remains the fast path.
185
232
  */
186
- async function produceCloudArtifact(
233
+ export async function produceCloudArtifact(
187
234
  bundlePath: string,
188
- assetsPath: string,
235
+ assetsPath?: string,
189
236
  ): Promise<{ bytes: Buffer; sha256: string; receipt: RnxCloudArtifactReceipt }> {
190
- const source = readImmutableBundleSource(bundlePath)
191
- const destFiles = listMetroAssetDestFiles(assetsPath)
237
+ const source = await loadBundleSource(bundlePath)
238
+ const destFiles = assetsPath ? listMetroAssetDestFiles(assetsPath) : []
192
239
  const embedded = await embedMetroAssetFiles(source, async (variant) => {
193
240
  const relative = resolveMetroIosAssetDestFile(
194
241
  destFiles,
195
242
  variant.descriptor,
196
243
  variant.scale,
197
244
  )
198
- if (relative) return readFileSync(join(assetsPath, relative))
245
+ if (relative && assetsPath) return readFileSync(join(assetsPath, relative))
199
246
  // iOS copies only @1x..@3x, so a declared scale with no file is ordinary
200
247
  // and the producer narrows the descriptor. an asset with no file at any
201
248
  // scale is the real failure, and this is where the dest path a customer
@@ -212,8 +259,9 @@ async function produceCloudArtifact(
212
259
  const classic =
213
260
  metroIosAssetDestRelatives(variant.descriptor.httpServerLocation, filename)[0] ??
214
261
  filename
262
+ const lookupPath = assetsPath ?? '.'
215
263
  throw new Error(
216
- `rnx ios --nano could not read ${join(assetsPath, classic)} for ${variant.descriptor.name}.${variant.descriptor.type} at scale ${variant.scale}. Build the bundle with --assets-dest, or pass --assets <dir>.`,
264
+ `rnx ios --nano could not read ${join(lookupPath, classic)} for ${variant.descriptor.name}.${variant.descriptor.type} at scale ${variant.scale}. Build the bundle with --assets-dest, or pass --assets <dir>.`,
217
265
  )
218
266
  })
219
267
  const artifact = await buildRnxCloudArtifact(embedded.code, {
@@ -299,10 +347,13 @@ export async function createCloudBox(options: CreateCloudBoxOptions): Promise<{
299
347
  artifact: RnxCloudArtifactReceipt
300
348
  }> {
301
349
  const apiOrigin = resolveCloudOrigin(options.apiOrigin)
302
- const artifact = await produceCloudArtifact(
303
- options.bundlePath,
304
- resolve(options.assetsPath ?? dirname(options.bundlePath)),
305
- )
350
+ const isUrl = options.bundlePath.startsWith('https://')
351
+ const assetsPath = options.assetsPath
352
+ ? resolve(options.assetsPath)
353
+ : isUrl
354
+ ? undefined
355
+ : resolve(dirname(options.bundlePath))
356
+ const artifact = await produceCloudArtifact(options.bundlePath, assetsPath)
306
357
  let response: Response
307
358
  try {
308
359
  response = await fetch(`${apiOrigin}/v1/boxes`, {
@@ -521,3 +572,104 @@ export async function closeCloudBox(session: CloudSession): Promise<void> {
521
572
  throw responseError(response.status, text)
522
573
  }
523
574
  }
575
+
576
+ export interface AddCloudSimulatorOptions {
577
+ device: string
578
+ authorization: string
579
+ }
580
+
581
+ export async function addCloudSimulator(
582
+ session: CloudSession,
583
+ options: AddCloudSimulatorOptions,
584
+ ): Promise<{
585
+ receipt: RnxCloudCreateReceipt
586
+ session: CloudSession
587
+ }> {
588
+ let response: Response
589
+ try {
590
+ response = await fetch(
591
+ `${session.apiOrigin}/v1/boxes/${encodeURIComponent(session.boxId)}/simulators`,
592
+ {
593
+ method: 'POST',
594
+ headers: {
595
+ authorization: `Bearer ${session.boxToken}`,
596
+ 'x-rnx-account-authorization': options.authorization,
597
+ 'content-type': 'application/json',
598
+ },
599
+ body: JSON.stringify({ device: options.device }),
600
+ },
601
+ )
602
+ } catch (error) {
603
+ throw new Error(
604
+ `could not reach RNX Cloud at ${session.apiOrigin}: ${
605
+ error instanceof Error ? error.message : String(error)
606
+ }`,
607
+ )
608
+ }
609
+ const responseText = await response.text()
610
+ if (!response.ok) {
611
+ if (response.status === 404) {
612
+ try {
613
+ const parsed: unknown = JSON.parse(responseText)
614
+ if (
615
+ isRecord(parsed) &&
616
+ (parsed.error === 'unknown_artifact' ||
617
+ (isRecord(parsed.error) && parsed.error.code === 'unknown_artifact'))
618
+ ) {
619
+ throw new Error(
620
+ 'this box does not hold a new bundle; run `rnx close` and create again',
621
+ )
622
+ }
623
+ } catch (err) {
624
+ if (err instanceof Error && err.message.startsWith('this box does not hold')) {
625
+ throw err
626
+ }
627
+ }
628
+ throw new Error(
629
+ 'this box does not hold a new bundle; run `rnx close` and create again',
630
+ )
631
+ }
632
+ throw responseError(response.status, responseText)
633
+ }
634
+ let parsed: unknown
635
+ try {
636
+ parsed = JSON.parse(responseText)
637
+ } catch {
638
+ throw new Error('RNX Cloud simulator create returned unreadable JSON')
639
+ }
640
+ if (!isRecord(parsed) || !isRecord(parsed.simulator)) {
641
+ throw new Error('RNX Cloud simulator create returned an invalid receipt')
642
+ }
643
+ const simulator = parsed.simulator
644
+ const simId = simulator.simId
645
+ const claim = isRecord(simulator.claim) ? simulator.claim : null
646
+ const token = simulator.token
647
+ const artifact = isRecord(simulator.artifact) ? simulator.artifact : null
648
+ if (
649
+ typeof simId !== 'string' ||
650
+ !simId ||
651
+ !claim ||
652
+ typeof claim.id !== 'string' ||
653
+ !claim.id ||
654
+ typeof claim.expiresAt !== 'number' ||
655
+ typeof token !== 'string' ||
656
+ !token ||
657
+ !artifact ||
658
+ typeof artifact.id !== 'string' ||
659
+ typeof artifact.bytes !== 'number'
660
+ ) {
661
+ throw new Error('RNX Cloud simulator create returned an invalid receipt')
662
+ }
663
+ const receipt: RnxCloudCreateReceipt = {
664
+ simId,
665
+ artifact: { id: artifact.id as `sha256:${string}`, bytes: artifact.bytes },
666
+ claim: { id: claim.id, expiresAt: claim.expiresAt },
667
+ }
668
+ const updatedSession = createCloudSession({
669
+ ...session,
670
+ simId,
671
+ claimId: claim.id,
672
+ token,
673
+ })
674
+ return { receipt, session: updatedSession }
675
+ }
@@ -129,13 +129,18 @@ export async function dispatchCloudBoundary({
129
129
  command,
130
130
  args,
131
131
  }: CloudBoundaryInvocation): Promise<CloudBoundaryResult> {
132
- if (command === 'ios' && args.includes('--nano')) return { handled: false }
133
- if (command === 'android' && args.includes('--nano')) {
132
+ if (command === 'ios' && (args.includes('--nano') || args.includes('--cloud')))
133
+ return { handled: false }
134
+ if (command === 'android' && (args.includes('--nano') || args.includes('--cloud'))) {
134
135
  console.error(
135
136
  ' rnx android --nano is not available; RNX Cloud currently supports iOS',
136
137
  )
137
138
  return { handled: true, code: 1 }
138
139
  }
140
+ if ((command === 'ios' || command === 'android') && args.includes('--micro')) {
141
+ console.error(` rnx ${command} --micro is not implemented`)
142
+ return { handled: true, code: 1 }
143
+ }
139
144
  if (!CLOUD_TARGET_COMMANDS.has(command)) return { handled: false }
140
145
 
141
146
  const [{ parseBridgeCliArgs }, cloudClient, cloudSession] = await Promise.all([
@@ -51,6 +51,23 @@ const BINARY_SNIFF_BYTES = 8192
51
51
  // merely busy.
52
52
  const LIST_FILES_TIMEOUT_MS = 10_000
53
53
 
54
+ // the same busy machine fails this call in two ways that are both a failed
55
+ // READ rather than an answer: the spawn raises, and a child exits 0 having
56
+ // written nothing. docs/testing.md measured the second one directly on
57
+ // 2026-08-27 under CPU oversubscription, where a git child exited 0 with empty
58
+ // stdout while the very next `/bin/echo` read back fine.
59
+ //
60
+ // the answer to a failed read is to read again. every box command re-indexes
61
+ // before it runs, so a single lost answer is otherwise the difference between
62
+ // `touch created.txt` working and the box telling the user it has no file set
63
+ // to serve: measured here as exit 1 in 103ms, which is what took the box
64
+ // conformance suite red under a load average of 47. the conditions the two
65
+ // tests in test/rnxBoxCheckoutPlane.test.ts hold (a git that never answers, a
66
+ // git that always answers with nothing) are permanent, so they still fail, and
67
+ // they fail naming the same thing.
68
+ const LIST_FILES_ATTEMPTS = 3
69
+ const LIST_FILES_RETRY_MS = 25
70
+
54
71
  function looksBinary(filePath: string, size: number): boolean {
55
72
  if (size === 0) return false
56
73
  let fd: number | undefined
@@ -103,51 +120,72 @@ export class CheckoutFilePlane implements ProjectFilePlane {
103
120
  return this.entries
104
121
  }
105
122
 
106
- async refresh(): Promise<void> {
107
- let stdout: string
108
- try {
109
- // -z keeps paths containing spaces or newlines intact. a local Box sees
110
- // tracked plus visible untracked files, while a durable cloud import is
111
- // deliberately restricted to the checkout's tracked source.
112
- const args = this.trackedOnly
113
- ? ['ls-files', '-z']
114
- : ['ls-files', '-co', '--exclude-standard', '-z']
115
- stdout = execFileSync('git', args, {
116
- cwd: this.root,
117
- encoding: 'utf8',
118
- maxBuffer: 64 * 1024 * 1024,
119
- timeout: this.listTimeoutMs,
120
- // stating the environment is not redundant here. bun 1.3.14 resolves
121
- // the binary for the synchronous form against the PATH it captured at
122
- // startup and ignores a later change to `process.env.PATH`, so without
123
- // this it runs a different git than every other call in the process.
124
- env: process.env,
125
- })
126
- } catch (error) {
127
- const message = error instanceof Error ? error.message : String(error)
128
- // the deadline kills with SIGTERM, so that signal is what tells a bound
129
- // run apart from git failing on its own terms, and it is worth naming
130
- // separately: "git was killed by SIGTERM" reads as git's problem, when
131
- // what happened is that it never answered.
132
- const killed =
133
- error instanceof Error && 'signal' in error && error.signal === 'SIGTERM'
134
- throw new CheckoutRequiresGitError(
135
- killed
136
- ? `git did not list the project's files in ${this.root} within ${this.listTimeoutMs / 1000}s, so the box has no file set to serve. the checkout may be on a stalled filesystem, or this machine may be too loaded to start a subprocess.`
137
- : `could not list the project's files with git in ${this.root}: ${message}`,
138
- )
139
- }
140
-
141
- // a busy machine also hands back a child that exits 0 having written
142
- // nothing, which docs/testing.md owns: empty child output never means an
143
- // empty answer. without this the box would build an empty index and every
144
- // read would report the file as missing, which is a broken project rather
145
- // than a broken box, and nothing would say so.
123
+ private listFiles(): string {
124
+ // -z keeps paths containing spaces or newlines intact. a local Box sees
125
+ // tracked plus visible untracked files, while a durable cloud import is
126
+ // deliberately restricted to the checkout's tracked source.
127
+ const args = this.trackedOnly
128
+ ? ['ls-files', '-z']
129
+ : ['ls-files', '-co', '--exclude-standard', '-z']
130
+ const stdout = execFileSync('git', args, {
131
+ cwd: this.root,
132
+ encoding: 'utf8',
133
+ maxBuffer: 64 * 1024 * 1024,
134
+ timeout: this.listTimeoutMs,
135
+ // stating the environment is not redundant here. bun 1.3.14 resolves
136
+ // the binary for the synchronous form against the PATH it captured at
137
+ // startup and ignores a later change to `process.env.PATH`, so without
138
+ // this it runs a different git than every other call in the process.
139
+ env: process.env,
140
+ })
141
+ // empty child output never means an empty answer. without this the box
142
+ // would build an empty index and every read would report the file as
143
+ // missing, which is a broken project rather than a broken box, and nothing
144
+ // would say so.
146
145
  if (stdout.length === 0) {
147
146
  throw new CheckoutRequiresGitError(
148
147
  `git listed no ${this.trackedOnly ? 'tracked ' : ''}files in ${this.root}. either the checkout contains no eligible files, or this machine could not read the child process's output.`,
149
148
  )
150
149
  }
150
+ return stdout
151
+ }
152
+
153
+ async refresh(): Promise<void> {
154
+ let stdout = ''
155
+ let lastFailure: CheckoutRequiresGitError | undefined
156
+ for (let attempt = 1; attempt <= LIST_FILES_ATTEMPTS; attempt++) {
157
+ try {
158
+ stdout = this.listFiles()
159
+ lastFailure = undefined
160
+ break
161
+ } catch (error) {
162
+ if (error instanceof CheckoutRequiresGitError) {
163
+ lastFailure = error
164
+ } else {
165
+ const message = error instanceof Error ? error.message : String(error)
166
+ // the deadline kills with SIGTERM, so that signal is what tells a
167
+ // bound run apart from git failing on its own terms, and it is worth
168
+ // naming separately: "git was killed by SIGTERM" reads as git's
169
+ // problem, when what happened is that it never answered.
170
+ const killed =
171
+ error instanceof Error && 'signal' in error && error.signal === 'SIGTERM'
172
+ lastFailure = new CheckoutRequiresGitError(
173
+ killed
174
+ ? `git did not list the project's files in ${this.root} within ${this.listTimeoutMs / 1000}s, so the box has no file set to serve. the checkout may be on a stalled filesystem, or this machine may be too loaded to start a subprocess.`
175
+ : `could not list the project's files with git in ${this.root}: ${message}`,
176
+ )
177
+ }
178
+ if (attempt < LIST_FILES_ATTEMPTS) {
179
+ await new Promise((resolve) =>
180
+ setTimeout(resolve, LIST_FILES_RETRY_MS * attempt),
181
+ )
182
+ }
183
+ }
184
+ }
185
+ if (lastFailure) {
186
+ lastFailure.message = `${lastFailure.message} (${LIST_FILES_ATTEMPTS} attempts)`
187
+ throw lastFailure
188
+ }
151
189
 
152
190
  const next = new Map<string, ProjectFileMeta>()
153
191
  // rebuilt alongside the index rather than pruned, so a deleted file's
@@ -27,7 +27,7 @@ function isDeviceModel(value: string): value is DeviceModel {
27
27
  }
28
28
 
29
29
  const CLOUD_USAGE =
30
- 'usage: rnx ios --nano <local bundle.js> [--assets <dir>] [--device <model>]'
30
+ 'usage: rnx ios --nano <local bundle.js|https://bundle-url> [--assets <dir>] [--device <model>]'
31
31
 
32
32
  function cloudInputFromArgs(args: string[]): {
33
33
  bundlePath: string
@@ -37,7 +37,7 @@ function cloudInputFromArgs(args: string[]): {
37
37
  let assetsPath: string | null = null
38
38
  for (let index = 0; index < args.length; index++) {
39
39
  const arg = args[index]
40
- if (arg === '--nano') continue
40
+ if (arg === '--nano' || arg === '--cloud') continue
41
41
  if (arg === '--device') {
42
42
  index++
43
43
  continue
@@ -76,6 +76,13 @@ export async function runPlatformCommand(
76
76
  args: string[],
77
77
  opts: { port?: number } = {},
78
78
  ): Promise<number | undefined> {
79
+ if (args.includes('--micro')) {
80
+ console.error(
81
+ ` ${rnxPublicBrand.commandName} ${platform} --micro is not implemented`,
82
+ )
83
+ return 1
84
+ }
85
+
79
86
  // `--device` is applied to the settings store before dispatch, so a
80
87
  // contradiction shows up here as a platform mismatch. refuse it by name
81
88
  // rather than silently overriding whichever one the user meant.
@@ -114,7 +121,7 @@ export async function runPlatformCommand(
114
121
  }
115
122
  }
116
123
 
117
- if (args.includes('--nano')) {
124
+ if (args.includes('--nano') || args.includes('--cloud')) {
118
125
  if (platform !== 'ios') {
119
126
  console.error(
120
127
  ` ${rnxPublicBrand.commandName} android --nano is not available; RNX Cloud currently supports iOS`,
@@ -128,15 +135,26 @@ export async function runPlatformCommand(
128
135
  readCloudSession,
129
136
  requireCloudSessionShellUpdate,
130
137
  } = await import('../cloud-session')
131
- if (readCloudSession()) {
132
- throw new Error(
133
- 'this shell already targets an RNX Cloud box; run `rnx close` or create the next box in another shell',
134
- )
135
- }
136
138
  const descriptor = requireCloudSessionShellUpdate()
137
139
  const { authHeaderOrExit } = await import('../auth')
138
- const { closeCloudBox, createCloudBox } = await import('../cloud-client')
139
140
  const { header } = authHeaderOrExit('ios --nano')
141
+ const existingSession = readCloudSession()
142
+ if (existingSession) {
143
+ const { addCloudSimulator } = await import('../cloud-client')
144
+ const added = await addCloudSimulator(existingSession, {
145
+ device: selectedDevice,
146
+ authorization: header,
147
+ })
148
+ publishCloudSessionToShell(added.session, descriptor)
149
+ console.log(` cloud box: ${existingSession.boxId} (nano)`)
150
+ console.log(` simulator: ${added.receipt.simId}`)
151
+ console.log(` claim expires: ${added.receipt.claim.expiresAt}`)
152
+ console.log(
153
+ ' commands: describe, find, get tree|node|count|memory, wait ready|selector, do, reset, logs, state, screenshot',
154
+ )
155
+ return 0
156
+ }
157
+ const { closeCloudBox, createCloudBox } = await import('../cloud-client')
140
158
  const created = await createCloudBox({
141
159
  bundlePath: input.bundlePath,
142
160
  assetsPath: input.assetsPath ?? undefined,
@@ -193,6 +211,9 @@ export async function runPlatformCommand(
193
211
  '--no-hmr',
194
212
  '--no-describe',
195
213
  '--quiet',
214
+ '--nano',
215
+ '--cloud',
216
+ '--micro',
196
217
  ],
197
218
  stripValueFlags: [
198
219
  '--base-url',
@@ -125,12 +125,18 @@ export const DECLARED_OUTBOUND_CALLS: Record<string, OutboundCallDeclaration> =
125
125
  category: 'rnx_cloud_sim',
126
126
  count: 1,
127
127
  },
128
+ 'packages/sootsim/cli/cloud-client.ts :: fetch :: `${session.apiOrigin}/v1/boxes/${encodeURIComponent(session.boxId)}/simulators`':
129
+ { category: 'rnx_cloud_sim', count: 1 },
128
130
  'packages/sootsim/cli/cloud-client.ts :: fetch :: `${session.apiOrigin}/v1/sims/${encodeURIComponent(session.simId)}/claim`':
129
131
  { category: 'rnx_cloud_sim', count: 1 },
130
132
  'packages/sootsim/cli/cloud-client.ts :: fetch :: `${session.apiOrigin}/v1/boxes/${encodeURIComponent(session.boxId)}`':
131
133
  { category: 'rnx_cloud_sim', count: 1 },
132
134
  'packages/sootsim/cli/cloud-client.ts :: fetch :: `${this.session.apiOrigin}/v1/sims/${encodeURIComponent(this.session.simId)}/commands`':
133
135
  { category: 'rnx_cloud_sim', count: 1 },
136
+ 'packages/sootsim/cli/cloud-client.ts :: fetch :: bundleUrl': {
137
+ category: 'guest_app_network',
138
+ count: 1,
139
+ },
134
140
  // maestro's flow `http` API. the url is whatever the flow author wrote, and
135
141
  // it is fetched from a child process because that API is synchronous.
136
142
  'packages/sootsim/cli/internal-child.ts :: fetch :: request.url': {
package/cli/shell-init.ts CHANGED
@@ -30,7 +30,7 @@ export function renderRnxShellInit(shell: string): string {
30
30
  claim|close) ;;
31
31
  ios)
32
32
  for _rnx_shell_session in "$@"; do
33
- if [ "$_rnx_shell_session" = '--nano' ]; then
33
+ if [ "$_rnx_shell_session" = '--nano' ] || [ "$_rnx_shell_session" = '--cloud' ]; then
34
34
  _rnx_shell_create=1
35
35
  break
36
36
  fi
package/cli/ws-bridge.ts CHANGED
@@ -706,14 +706,16 @@ export function createBridge(wsPort: number, opts: WsBridgeOptions = {}): WsBrid
706
706
  }
707
707
  } catch {}
708
708
  ws.close()
709
- // unref, or a one-shot `rnx` command is held on the event loop for the
710
- // full 250ms after its work is done. that delay lands directly in in-box
711
- // prompt latency, since the shell spawns rnx and waits on child close.
712
- setTimeout(() => {
709
+ // clear the grace timer on a real close, or a one-shot `rnx` command is
710
+ // held on the event loop for the full 250ms after its work is done. that
711
+ // delay lands directly in in-box prompt latency, since the shell spawns
712
+ // rnx and waits on child close.
713
+ const terminateGrace = setTimeout(() => {
713
714
  if (ws.readyState !== WebSocket.CLOSED) {
714
715
  ws.terminate()
715
716
  }
716
- }, 250).unref()
717
+ }, 250)
718
+ ws.once('close', () => clearTimeout(terminateGrace))
717
719
  },
718
720
  }
719
721
  }
@@ -1,4 +1,4 @@
1
- /*! rnx v0.1.419 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
1
+ /*! rnx v0.1.420 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
2
2
  let __sootsim_import_meta_url = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; } catch {}
3
3
  "use strict";
4
4
  var __create = Object.create;
@@ -1,4 +1,4 @@
1
- /*! rnx v0.1.419 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
1
+ /*! rnx v0.1.420 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
2
2
  let __sootsim_import_meta_url = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; } catch {}
3
3
  "use strict";
4
4
  var __defProp = Object.defineProperty;
@@ -1,4 +1,4 @@
1
- /*! rnx v0.1.419 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
1
+ /*! rnx v0.1.420 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
2
2
  let __sootsim_import_meta_url = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; } catch {}
3
3
  "use strict";
4
4
  var __defProp = Object.defineProperty;
@@ -1,4 +1,4 @@
1
- /*! rnx v0.1.419 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
1
+ /*! rnx v0.1.420 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
2
2
  let __sootsim_import_meta_url = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; } catch {}
3
3
  "use strict";
4
4
  var __create = Object.create;
@@ -1,4 +1,4 @@
1
- /*! rnx v0.1.419 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
1
+ /*! rnx v0.1.420 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
2
2
  let __sootsim_import_meta_url = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; } catch {}
3
3
  "use strict";
4
4
  var __create = Object.create;
@@ -1,4 +1,4 @@
1
- /*! rnx v0.1.419 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
1
+ /*! rnx v0.1.420 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
2
2
  let __sootsim_import_meta_url = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; } catch {}
3
3
  "use strict";
4
4
  var __create = Object.create;
@@ -1,4 +1,4 @@
1
- /*! rnx v0.1.419 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
1
+ /*! rnx v0.1.420 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
2
2
  let __sootsim_import_meta_url = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; } catch {}
3
3
  "use strict";
4
4
  var __defProp = Object.defineProperty;
package/dist-lib/beta.cjs CHANGED
@@ -1,4 +1,4 @@
1
- /*! rnx v0.1.419 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
1
+ /*! rnx v0.1.420 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
2
2
  let __sootsim_import_meta_url = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; } catch {}
3
3
  "use strict";
4
4
  var __defProp = Object.defineProperty;
package/dist-lib/beta.mjs CHANGED
@@ -1,4 +1,4 @@
1
- /*! rnx v0.1.419 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
1
+ /*! rnx v0.1.420 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
2
2
 
3
3
  // src/beta.ts
4
4
  var IS_BETA = true;
@@ -1,4 +1,4 @@
1
- /*! rnx v0.1.419 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
1
+ /*! rnx v0.1.420 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
2
2
  let __sootsim_import_meta_url = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; } catch {}
3
3
  "use strict";
4
4
  var __defProp = Object.defineProperty;
@@ -1,4 +1,4 @@
1
- /*! rnx v0.1.419 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
1
+ /*! rnx v0.1.420 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
2
2
  let __sootsim_import_meta_url = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; } catch {}
3
3
  "use strict";
4
4
  var __defProp = Object.defineProperty;
@@ -1,4 +1,4 @@
1
- /*! rnx v0.1.419 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
1
+ /*! rnx v0.1.420 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
2
2
 
3
3
  // src/bridge-contract.ts
4
4
  var SIM_LONG_PRESS_MAX_MS = 5e3;
@@ -1,4 +1,4 @@
1
- /*! rnx v0.1.419 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
1
+ /*! rnx v0.1.420 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
2
2
  let __sootsim_import_meta_url = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; } catch {}
3
3
  "use strict";
4
4
  var __defProp = Object.defineProperty;
@@ -1,4 +1,4 @@
1
- /*! rnx v0.1.419 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
1
+ /*! rnx v0.1.420 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
2
2
 
3
3
  // src/bridge-contract.ts
4
4
  var SIM_LONG_PRESS_DEFAULT_MS = 500;
@@ -1,4 +1,4 @@
1
- /*! rnx v0.1.419 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
1
+ /*! rnx v0.1.420 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
2
2
  let __sootsim_import_meta_url = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; } catch {}
3
3
  "use strict";
4
4
  var __defProp = Object.defineProperty;
@@ -1,4 +1,4 @@
1
- /*! rnx v0.1.419 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
1
+ /*! rnx v0.1.420 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
2
2
 
3
3
  // src/capture-contract.ts
4
4
  var RNX_SCREEN_CAPTURE_VERSION = 1;
@@ -1,4 +1,4 @@
1
- /*! rnx v0.1.419 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
1
+ /*! rnx v0.1.420 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
2
2
  let __sootsim_import_meta_url = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; } catch {}
3
3
  "use strict";
4
4
  var __defProp = Object.defineProperty;