rnxsim 0.1.432 → 0.1.434

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 (83) hide show
  1. package/README.md +20 -50
  2. package/cli/auth.ts +33 -3
  3. package/cli/cloud-client.ts +577 -99
  4. package/cli/cloud-dispatch.ts +5 -10
  5. package/cli/cloud-session.ts +5 -5
  6. package/cli/commands/auth.ts +13 -2
  7. package/cli/commands/box.ts +270 -26
  8. package/cli/commands/control.ts +40 -13
  9. package/cli/commands/device.ts +1 -1
  10. package/cli/commands/flow.ts +189 -43
  11. package/cli/commands/inspect/get-layout.ts +1 -1
  12. package/cli/commands/inspect.ts +8 -1
  13. package/cli/commands/login.ts +79 -14
  14. package/cli/commands/maestro-generate.ts +70 -54
  15. package/cli/commands/maestro.ts +69 -20
  16. package/cli/commands/platform.ts +30 -32
  17. package/cli/commands/screenshot.ts +3 -2
  18. package/cli/commands/upload.ts +28 -10
  19. package/cli/help-core.ts +11 -1
  20. package/cli/outbound-endpoints.ts +32 -5
  21. package/cli/parse-args.ts +17 -0
  22. package/cli/send-to-box.ts +112 -0
  23. package/cli/shell-init.ts +4 -4
  24. package/cli/ws-bridge.ts +109 -62
  25. package/dist-lib/agent-daemon-client.cjs +1 -1
  26. package/dist-lib/agent-events.cjs +1 -1
  27. package/dist-lib/agent-identity.cjs +1 -1
  28. package/dist-lib/agent-sessions.cjs +1 -1
  29. package/dist-lib/attached-projects.cjs +1 -1
  30. package/dist-lib/auth/shared-session.cjs +17 -4
  31. package/dist-lib/backend-origin.cjs +1 -1
  32. package/dist-lib/beta.cjs +1 -1
  33. package/dist-lib/beta.mjs +1 -1
  34. package/dist-lib/bridge-constants.cjs +1 -1
  35. package/dist-lib/bridge-contract-input.cjs +1 -1
  36. package/dist-lib/bridge-contract-input.mjs +1 -1
  37. package/dist-lib/bridge-contract.cjs +1 -1
  38. package/dist-lib/bridge-contract.mjs +1 -1
  39. package/dist-lib/capture-contract.cjs +1 -1
  40. package/dist-lib/capture-contract.mjs +1 -1
  41. package/dist-lib/cli-constants.cjs +1 -1
  42. package/dist-lib/cloud-contract.cjs +1 -1
  43. package/dist-lib/cloud-contract.mjs +1 -1
  44. package/dist-lib/cloud.cjs +108 -93
  45. package/dist-lib/cloud.mjs +106 -91
  46. package/dist-lib/config.cjs +1 -1
  47. package/dist-lib/detox/index.cjs +1 -1
  48. package/dist-lib/dev-bundle-resolution.cjs +1 -1
  49. package/dist-lib/home-paths.cjs +1 -1
  50. package/dist-lib/host/bridge-host.cjs +5324 -1371
  51. package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
  52. package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
  53. package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
  54. package/dist-lib/host/replacement-module-handler.cjs +298 -91
  55. package/dist-lib/host/websocket-proxy.cjs +1 -1
  56. package/dist-lib/index.cjs +369 -241
  57. package/dist-lib/jump-to-source-babel.cjs +1 -1
  58. package/dist-lib/menu.cjs +7 -1
  59. package/dist-lib/menu.mjs +7 -1
  60. package/dist-lib/metro-fingerprint-registry.cjs +1 -1
  61. package/dist-lib/metro-fingerprint-registry.mjs +1 -1
  62. package/dist-lib/metro-production-bundle.cjs +1 -1
  63. package/dist-lib/metro-production-bundle.mjs +1 -1
  64. package/dist-lib/metro.cjs +327 -299
  65. package/dist-lib/profiles.cjs +1 -1
  66. package/dist-lib/public-brand.cjs +1 -1
  67. package/dist-lib/react-native-host-modules.cjs +1 -1
  68. package/dist-lib/react-native-host-modules.mjs +1 -1
  69. package/dist-lib/render-mode.cjs +1 -1
  70. package/dist-lib/scripts/dev-server-scanner.cjs +1 -1
  71. package/dist-lib/sdk.cjs +1 -1
  72. package/dist-lib/sdk.mjs +1 -1
  73. package/dist-lib/skills.cjs +4849 -1199
  74. package/dist-lib/vite.cjs +298 -91
  75. package/package.json +3 -2
  76. package/src/auth/shared-session.ts +31 -0
  77. package/src/bridge-contract.ts +6 -0
  78. package/src/cloud.ts +83 -113
  79. package/src/host/bridge-host.ts +79 -0
  80. package/src/host/replacement-module-handler.ts +20 -96
  81. package/src/menu.ts +8 -0
  82. package/src/metro-plugin.ts +30 -113
  83. package/src/vite-plugin.ts +11 -2
@@ -10,9 +10,13 @@ import {
10
10
  import { dirname, extname, join, resolve } from 'node:path'
11
11
  import {
12
12
  embedMetroAssetFiles,
13
+ extractAssetDescriptors,
14
+ metroAssetPlaceholderBytes,
13
15
  metroAssetScaleFilename,
14
16
  metroIosAssetDestRelatives,
15
- resolveMetroIosAssetDestFile,
17
+ resolveMetroIosAssetDestFiles,
18
+ type MetroArtifactAssetSize,
19
+ type MetroAssetDescriptor,
16
20
  } from '../../contrast-bundler/src/metroAssetArtifact'
17
21
  import {
18
22
  buildRnxCloudArtifact,
@@ -20,6 +24,7 @@ import {
20
24
  } from '../../contrast-bundler/src/metroCloudArtifact'
21
25
  import { inferMetroModuleIdentity } from '../../sootsim-engine/src/metro-fingerprint'
22
26
  import { loadMetroFingerprintRegistry } from '../../sootsim-engine/src/metro-fingerprint-registry-client'
27
+ import { isLoopbackHost } from '../src/backend-origin'
23
28
  import {
24
29
  isRnxCloudCommandType,
25
30
  RNX_CLOUD_MAX_ARTIFACT_BYTES,
@@ -31,12 +36,15 @@ import {
31
36
  type RNXMetroModuleIdentityMetadata,
32
37
  } from '../src/metro-production-bundle'
33
38
  import { rnxPublicBrand } from '../src/public-brand'
39
+ import { CHOOSE_ACCOUNT_HINT } from './auth'
34
40
  import {
35
41
  createCloudSession,
36
42
  normalizeCloudOrigin,
37
43
  readCloudSession,
38
44
  type CloudSession,
39
45
  } from './cloud-session'
46
+ import { detectProject, findProjectRoot } from './commands/detect'
47
+ import type { StorageSnapshot } from '../../sootsim-engine/src/preview/storage-snapshot-contract'
40
48
  import type {
41
49
  BridgeClaimResult,
42
50
  BridgeSimInfo,
@@ -47,13 +55,35 @@ import type {
47
55
  export const DEFAULT_RNX_CLOUD_ORIGIN = rnxPublicBrand.origin
48
56
  export const MAX_RNX_CLOUD_BUNDLE_BYTES = RNX_CLOUD_MAX_ARTIFACT_BYTES
49
57
 
58
+ // every message this module writes names the command through this constant.
59
+ const REMOTE_COMMAND = 'rnx ios --remote'
60
+
61
+ // an artifact this close to the ceiling is one asset away from being refused,
62
+ // and the producer knows which assets it would be.
63
+ const WARN_ARTIFACT_BYTES = Math.floor(MAX_RNX_CLOUD_BUNDLE_BYTES * 0.8)
64
+
65
+ // a Metro asset dest is a shallow tree of images. a walk that runs past this
66
+ // is pointed at something else entirely, and its extra entries cannot resolve
67
+ // an asset anyway.
68
+ const MAX_ASSET_DEST_FILES = 25_000
69
+
50
70
  interface CreateCloudBoxOptions {
51
71
  bundlePath: string
52
72
  device: string
53
73
  authorization: string
74
+ /** the account to bill when the authorization is a session token; an api key names its own */
75
+ accountId: string | null
54
76
  /** the `--assets-dest` directory Metro wrote beside the bundle. */
55
77
  assetsPath?: string
56
78
  apiOrigin?: string
79
+ /** the name this box carries in the account's box list. */
80
+ boxName?: string | null
81
+ /**
82
+ * the storage and route the first simulator boots with, captured from a
83
+ * running app. a snapshot reaches a megabyte, so a create that carries one
84
+ * is multipart rather than a bare artifact body.
85
+ */
86
+ storage?: StorageSnapshot | null
57
87
  }
58
88
 
59
89
  interface CloudCommandEnvelope {
@@ -66,18 +96,43 @@ function isRecord(value: unknown): value is Record<string, unknown> {
66
96
  return value !== null && typeof value === 'object' && !Array.isArray(value)
67
97
  }
68
98
 
69
- function responseError(status: number, text: string): Error {
70
- if (text) {
71
- try {
72
- const parsed: unknown = JSON.parse(text)
73
- const error = isRecord(parsed) ? parsed.error : null
74
- if (isRecord(error) && typeof error.message === 'string') {
75
- return new Error(`RNX Cloud request failed (${status}): ${error.message}`)
76
- }
77
- } catch {}
99
+ // three services answer this client and each states a failure its own way:
100
+ // the box service says { error: '<sentence>' }, the site's public api says
101
+ // { error: '<code>', message: '<sentence>' }, and its service-to-service api
102
+ // says { error: { code, message } }. all three carry one sentence meant for
103
+ // the person at the terminal, so read whichever field holds it. without this
104
+ // the sentence never reaches them and they get the raw json body instead.
105
+ function failureSentence(text: string): string | null {
106
+ let parsed: unknown
107
+ try {
108
+ parsed = JSON.parse(text)
109
+ } catch {
110
+ return null
111
+ }
112
+ if (!isRecord(parsed)) return null
113
+ if (isRecord(parsed.error) && typeof parsed.error.message === 'string') {
114
+ return parsed.error.message
78
115
  }
116
+ if (typeof parsed.message === 'string') return parsed.message
117
+ if (typeof parsed.error === 'string') return parsed.error
118
+ return null
119
+ }
120
+
121
+ // the account service says what it needs in its own words; the CLI says what
122
+ // the person can do about it, which is choose the account at `rnx login`.
123
+ const ACCOUNT_CHOICE_REFUSALS = new Set([
124
+ 'accountId is required',
125
+ 'you do not have access to this account',
126
+ ])
127
+
128
+ function responseError(status: number, text: string): Error {
129
+ const parsed = text ? failureSentence(text) : null
130
+ const sentence =
131
+ parsed && ACCOUNT_CHOICE_REFUSALS.has(parsed) ? CHOOSE_ACCOUNT_HINT : parsed
79
132
  return new Error(
80
- `RNX Cloud request failed (${status})${text.trim() ? `: ${text.trim()}` : ''}`,
133
+ `remote simulator request failed (${status})${
134
+ sentence ? `: ${sentence}` : text.trim() ? `: ${text.trim()}` : ''
135
+ }`,
81
136
  )
82
137
  }
83
138
 
@@ -90,16 +145,16 @@ function resolveCloudOrigin(value?: string): string {
90
145
  function readImmutableBundleSource(bundlePath: string): string {
91
146
  const filepath = resolve(bundlePath)
92
147
  if (extname(filepath) !== '.js') {
93
- throw new Error('rnx ios --nano requires one local Metro .js bundle')
148
+ throw new Error(`${REMOTE_COMMAND} requires one local Metro .js bundle`)
94
149
  }
95
150
  const descriptor = openSync(filepath, 'r')
96
151
  try {
97
152
  const before = fstatSync(descriptor, { bigint: true })
98
153
  if (!before.isFile()) {
99
- throw new Error('rnx ios --nano bundle must be a regular local file')
154
+ throw new Error(`${REMOTE_COMMAND} bundle must be a regular local file`)
100
155
  }
101
156
  if (before.size <= 0n) {
102
- throw new Error('rnx ios --nano bundle is empty')
157
+ throw new Error(`${REMOTE_COMMAND} bundle is empty`)
103
158
  }
104
159
  const bytes = readFileSync(descriptor)
105
160
  const after = fstatSync(descriptor, { bigint: true })
@@ -110,16 +165,16 @@ function readImmutableBundleSource(bundlePath: string): string {
110
165
  before.mtimeNs !== after.mtimeNs ||
111
166
  bytes.length !== Number(before.size)
112
167
  ) {
113
- throw new Error('rnx ios --nano bundle changed while it was being read')
168
+ throw new Error(`${REMOTE_COMMAND} bundle changed while it was being read`)
114
169
  }
115
170
  let source: string
116
171
  try {
117
172
  source = new TextDecoder('utf-8', { fatal: true }).decode(bytes)
118
173
  } catch {
119
- throw new Error('rnx ios --nano bundle must be valid UTF-8 JavaScript')
174
+ throw new Error(`${REMOTE_COMMAND} bundle must be valid UTF-8 JavaScript`)
120
175
  }
121
176
  if (source.includes('\0') || !source.includes('__d(') || !source.includes('__r(')) {
122
- throw new Error('rnx ios --nano requires a Metro JavaScript bundle')
177
+ throw new Error(`${REMOTE_COMMAND} requires a Metro JavaScript bundle`)
123
178
  }
124
179
  return source
125
180
  } finally {
@@ -133,33 +188,49 @@ async function fetchBundleSource(bundleUrl: string): Promise<string> {
133
188
  response = await fetch(bundleUrl)
134
189
  } catch (error) {
135
190
  throw new Error(
136
- `rnx ios --nano could not fetch bundle from ${bundleUrl}: ${
191
+ `${REMOTE_COMMAND} could not fetch bundle from ${bundleUrl}: ${
137
192
  error instanceof Error ? error.message : String(error)
138
193
  }`,
139
194
  )
140
195
  }
141
196
  if (!response.ok) {
142
197
  throw new Error(
143
- `rnx ios --nano could not fetch bundle from ${bundleUrl}: HTTP ${response.status}${
198
+ `${REMOTE_COMMAND} could not fetch bundle from ${bundleUrl}: HTTP ${response.status}${
144
199
  response.statusText ? ` ${response.statusText}` : ''
145
200
  }`,
146
201
  )
147
202
  }
148
203
  const text = await response.text()
149
204
  if (text.length === 0) {
150
- throw new Error('rnx ios --nano bundle is empty')
205
+ throw new Error(`${REMOTE_COMMAND} bundle is empty`)
151
206
  }
152
207
  if (text.includes('\0') || !text.includes('__d(') || !text.includes('__r(')) {
153
- throw new Error('rnx ios --nano requires a Metro JavaScript bundle')
208
+ throw new Error(`${REMOTE_COMMAND} requires a Metro JavaScript bundle`)
154
209
  }
155
210
  return text
156
211
  }
157
212
 
213
+ // a bundle served over plain http is only trustworthy when it comes from this
214
+ // machine, which is what "send to box" reads: the Metro dev server the app is
215
+ // already running on.
216
+ function isLoopbackBundleUrl(bundleInput: string): boolean {
217
+ if (!bundleInput.startsWith('http://')) return false
218
+ try {
219
+ return isLoopbackHost(new URL(bundleInput).hostname)
220
+ } catch {
221
+ return false
222
+ }
223
+ }
224
+
225
+ function isRemoteBundleInput(bundleInput: string): boolean {
226
+ return bundleInput.startsWith('https://') || bundleInput.startsWith('http://')
227
+ }
228
+
158
229
  async function loadBundleSource(bundleInput: string): Promise<string> {
159
- if (bundleInput.startsWith('http://')) {
160
- throw new Error('rnx ios --nano bundle URL must use HTTPS')
230
+ if (bundleInput.startsWith('http://') && !isLoopbackBundleUrl(bundleInput)) {
231
+ throw new Error(`${REMOTE_COMMAND} bundle URL must use HTTPS`)
161
232
  }
162
- if (bundleInput.startsWith('https://')) {
233
+ if (isRemoteBundleInput(bundleInput)) {
163
234
  return fetchBundleSource(bundleInput)
164
235
  }
165
236
  return readImmutableBundleSource(bundleInput)
@@ -168,10 +239,36 @@ async function loadBundleSource(bundleInput: string): Promise<string> {
168
239
  // `react-native bundle --assets-dest` dest paths are not always the JS
169
240
  // descriptor's httpServerLocation with `../` replaced by `_`. Metro 0.83 does
170
241
  // that (`__node_modules`); Metro 0.84 often dests `node_modules` and keeps the
171
- // project-root-relative app path. resolveMetroIosAssetDestFile matches both.
172
- function listMetroAssetDestFiles(assetsPath?: string): string[] {
173
- if (!assetsPath || !existsSync(assetsPath)) return []
242
+ // project-root-relative app path. resolveMetroIosAssetDestFiles matches both.
243
+ interface MetroAssetDestListing {
244
+ directory: string
245
+ files: string[]
246
+ /** the walk stopped at the cap, so "not in this listing" proves nothing. */
247
+ truncated: boolean
248
+ }
249
+
250
+ // a Metro asset dest never holds these, but a project root does, and a
251
+ // candidate can be either. `node_modules` is not in the list on purpose: a real
252
+ // dest DOES carry one, because that is where `react-native bundle` copies the
253
+ // assets that live inside packages. it is skipped only under a project root,
254
+ // which is what a package.json at the candidate marks.
255
+ const PROJECT_ONLY_DIRECTORIES = new Set([
256
+ 'node_modules',
257
+ 'Pods',
258
+ 'DerivedData',
259
+ '.expo',
260
+ '.next',
261
+ '.turbo',
262
+ '.cache',
263
+ '.gradle',
264
+ ])
265
+
266
+ function listMetroAssetDest(assetsPath: string): MetroAssetDestListing {
267
+ const directory = resolve(assetsPath)
268
+ if (!existsSync(directory)) return { directory, files: [], truncated: false }
269
+ const skipNested = existsSync(join(directory, 'package.json'))
174
270
  const files: string[] = []
271
+ let truncated = false
175
272
  const walk = (dir: string, prefix: string) => {
176
273
  let entries
177
274
  try {
@@ -180,13 +277,298 @@ function listMetroAssetDestFiles(assetsPath?: string): string[] {
180
277
  return
181
278
  }
182
279
  for (const entry of entries) {
183
- const rel = prefix ? `${prefix}/${entry.name}` : entry.name
184
- if (entry.isDirectory()) walk(join(dir, entry.name), rel)
185
- else if (entry.isFile()) files.push(rel)
280
+ if (files.length >= MAX_ASSET_DEST_FILES) {
281
+ truncated = true
282
+ return
283
+ }
284
+ if (entry.isDirectory()) {
285
+ if (entry.name === '.git') continue
286
+ if (skipNested && PROJECT_ONLY_DIRECTORIES.has(entry.name)) continue
287
+ walk(join(dir, entry.name), prefix ? `${prefix}/${entry.name}` : entry.name)
288
+ } else if (entry.isFile()) {
289
+ files.push(prefix ? `${prefix}/${entry.name}` : entry.name)
290
+ }
291
+ }
292
+ }
293
+ walk(directory, '')
294
+ return { directory, files, truncated }
295
+ }
296
+
297
+ /**
298
+ * Metro's own identity for an asset. `getAbsoluteAssetInfo` in metro/src/Assets
299
+ * md5s every scale file's bytes into ONE digest, in the ascending scale order
300
+ * `buildAssetMap` keeps `scales` and `files` parallel in, and that digest is
301
+ * the descriptor's `hash`. so a directory can be asked whether it holds THIS
302
+ * asset instead of merely a file with the same name.
303
+ */
304
+ function metroDescriptorHash(scaleBytes: readonly Buffer[]): string {
305
+ const hasher = createHash('md5')
306
+ for (const bytes of scaleBytes) hasher.update(bytes)
307
+ return hasher.digest('hex')
308
+ }
309
+
310
+ function metroAssetKey(descriptor: MetroAssetDescriptor): string {
311
+ return `${descriptor.httpServerLocation}/${descriptor.name}.${descriptor.type}`
312
+ }
313
+
314
+ /** where one descriptor's scales sit inside one candidate directory. */
315
+ interface DescriptorPlacement {
316
+ /** the dest-relative file chosen for each declared scale that is present. */
317
+ files: Map<number, string>
318
+ /** files a scale matched that hold different images, so none was chosen. */
319
+ ambiguous: string[]
320
+ /** every declared scale is present and their bytes are Metro's `hash`. */
321
+ verified: boolean
322
+ /** every declared scale is present and their bytes are something else. */
323
+ mismatched: boolean
324
+ }
325
+
326
+ // an ambiguous scale is rare and its file count is small, so trying every
327
+ // combination against the descriptor hash is cheap. the cap only stops a
328
+ // pathological dest from turning identity into a search.
329
+ const MAX_PLACEMENT_COMBINATIONS = 64
330
+
331
+ function placementCombinations(perScale: readonly string[][]): string[][] {
332
+ let total = 1
333
+ for (const options of perScale) total *= options.length
334
+ if (total > MAX_PLACEMENT_COMBINATIONS) return []
335
+ let combinations: string[][] = [[]]
336
+ for (const options of perScale) {
337
+ combinations = combinations.flatMap((prefix) =>
338
+ options.map((option) => [...prefix, option]),
339
+ )
340
+ }
341
+ return combinations
342
+ }
343
+
344
+ /**
345
+ * one file per distinct image, out of the files a scale matched.
346
+ *
347
+ * two paths holding byte-identical images are not a choice to make, and the
348
+ * descriptor's hash could not separate them if they were.
349
+ */
350
+ function distinctByContent(directory: string, options: readonly string[]): string[] {
351
+ if (options.length < 2) return [...options]
352
+ const byContent = new Map<string, string>()
353
+ for (const relative of options) {
354
+ let bytes: Buffer
355
+ try {
356
+ bytes = readFileSync(join(directory, relative))
357
+ } catch {
358
+ continue
186
359
  }
360
+ const digest = createHash('md5').update(bytes).digest('hex')
361
+ if (!byContent.has(digest)) byContent.set(digest, relative)
187
362
  }
188
- walk(assetsPath, '')
189
- return files
363
+ return [...byContent.values()]
364
+ }
365
+
366
+ /**
367
+ * decides what a candidate directory holds for one descriptor, by bytes.
368
+ *
369
+ * Metro hashes the asset's SOURCE files, and `react-native bundle` copies only
370
+ * @1x..@3x to an iOS dest while leaving `scales` unfiltered. so a descriptor
371
+ * that declares an Android rung such as @1.5x records a digest no iOS dest can
372
+ * reproduce, however complete that dest is. those come back neither verified
373
+ * nor mismatched, and the caller refuses when a whole bundle is like that
374
+ * rather than picking a directory it cannot check.
375
+ */
376
+ function placeMetroDescriptor(
377
+ listing: MetroAssetDestListing,
378
+ descriptor: MetroAssetDescriptor,
379
+ ): DescriptorPlacement {
380
+ const perScale = descriptor.scales.map((scale) =>
381
+ distinctByContent(
382
+ listing.directory,
383
+ resolveMetroIosAssetDestFiles(listing.files, descriptor, scale),
384
+ ),
385
+ )
386
+ // a scale that resolved to one image names its file whatever the verdict
387
+ // below is. one that resolved to several is left for the hash to settle.
388
+ const files = new Map<number, string>()
389
+ const ambiguous: string[] = []
390
+ descriptor.scales.forEach((scale, index) => {
391
+ const options = perScale[index] ?? []
392
+ if (options.length === 1 && options[0]) files.set(scale, options[0])
393
+ else if (options.length > 1) ambiguous.push(...options)
394
+ })
395
+ if (perScale.length === 0 || perScale.some((options) => options.length === 0)) {
396
+ return { files, ambiguous, verified: false, mismatched: false }
397
+ }
398
+ for (const combination of placementCombinations(perScale)) {
399
+ let bytes: Buffer[]
400
+ try {
401
+ bytes = combination.map((relative) =>
402
+ readFileSync(join(listing.directory, relative)),
403
+ )
404
+ } catch {
405
+ continue
406
+ }
407
+ if (metroDescriptorHash(bytes) !== descriptor.hash) continue
408
+ const chosen = new Map<number, string>()
409
+ descriptor.scales.forEach((scale, index) => {
410
+ const relative = combination[index]
411
+ if (relative) chosen.set(scale, relative)
412
+ })
413
+ return { files: chosen, ambiguous: [], verified: true, mismatched: false }
414
+ }
415
+ // every declared scale is here and no arrangement of them is this asset, so
416
+ // the directory holds different images under these names.
417
+ return { files, ambiguous, verified: false, mismatched: true }
418
+ }
419
+
420
+ interface MetroAssetDest {
421
+ directory: string
422
+ /** the placement of each descriptor, in the order the bundle declares them. */
423
+ placements: DescriptorPlacement[]
424
+ }
425
+
426
+ interface MetroAssetSearch {
427
+ /** the directory the producer reads asset bytes from, or null if none held any. */
428
+ dest: MetroAssetDest | null
429
+ /** every directory the search considered, in order, for the warning text. */
430
+ searched: string[]
431
+ /** a walk stopped at the cap, so no directory can be called fully looked at. */
432
+ truncated: boolean
433
+ }
434
+
435
+ /**
436
+ * what two candidates could still disagree about.
437
+ *
438
+ * a verified placement's bytes reproduce Metro's own digest, so it is the right
439
+ * content wherever it was read from and two directories that both verified it
440
+ * are not in conflict. only the unverified files are a real fork, and they are
441
+ * keyed by absolute path because `<dir>/assets` and `<dir>` are both candidates
442
+ * and list the same files under different dest-relative names.
443
+ */
444
+ function unverifiedFileKey(dest: MetroAssetDest): string {
445
+ return dest.placements
446
+ .filter((placement) => !placement.verified)
447
+ .flatMap((placement) =>
448
+ [...placement.files.values()].map((relative) => join(dest.directory, relative)),
449
+ )
450
+ .sort()
451
+ .join('\n')
452
+ }
453
+
454
+ /**
455
+ * finds the `--assets-dest` directory by RESOLVING the bundle's own asset
456
+ * descriptors against a short ordered list of the places Metro writes one, and
457
+ * CONFIRMING the bytes it finds against the md5 Metro recorded in each
458
+ * descriptor. name and layout alone are a popularity contest an unrelated build
459
+ * output can win; the hash makes it an identity check.
460
+ */
461
+ function searchMetroAssetDest(
462
+ bundlePath: string,
463
+ descriptors: readonly MetroAssetDescriptor[],
464
+ ): MetroAssetSearch {
465
+ const bundleDirectory = dirname(resolve(bundlePath))
466
+ const candidates = [
467
+ join(bundleDirectory, 'assets'),
468
+ bundleDirectory,
469
+ resolve(bundleDirectory, '..', 'assets'),
470
+ ]
471
+ const cwd = process.cwd()
472
+ if (detectProject(cwd)?.hasMetroConfig) {
473
+ const root = findProjectRoot(cwd)
474
+ candidates.push(join(root, 'dist'), join(root, 'build'))
475
+ }
476
+ const searched: string[] = []
477
+ const holders: { dest: MetroAssetDest; verified: number }[] = []
478
+ const stale: string[] = []
479
+ let truncated = false
480
+ for (const candidate of candidates) {
481
+ const directory = resolve(candidate)
482
+ if (searched.includes(directory)) continue
483
+ searched.push(directory)
484
+ const listing = listMetroAssetDest(directory)
485
+ // a cut-short walk is remembered even when the candidate resolves nothing,
486
+ // because the reason it resolved nothing may be that it never finished.
487
+ if (listing.truncated) truncated = true
488
+ if (listing.files.length === 0) continue
489
+ const placements = descriptors.map((descriptor) =>
490
+ placeMetroDescriptor(listing, descriptor),
491
+ )
492
+ // one wrong asset is enough: a directory that provably holds different
493
+ // bytes under these names is a different build, not a partial dest.
494
+ if (placements.some((placement) => placement.mismatched)) {
495
+ stale.push(directory)
496
+ continue
497
+ }
498
+ const verified = placements.filter((placement) => placement.verified).length
499
+ if (verified === 0 && placements.every((placement) => placement.files.size === 0))
500
+ continue
501
+ holders.push({ dest: { directory, placements }, verified })
502
+ }
503
+ if (holders.length === 0) {
504
+ // every scale is on disk and none of it is this bundle's. saying the
505
+ // assets are missing would be false, and a stand-in over a stale image is
506
+ // the wrong repair for a dest that only needs rebuilding.
507
+ if (stale.length > 0) {
508
+ throw new Error(
509
+ `${REMOTE_COMMAND} found this bundle's assets in ${stale.join(', ')}, but their bytes are not the ones it was built against. Rebuild with \`react-native bundle --assets-dest ${stale[0]}\` so the bundle and the images match.`,
510
+ )
511
+ }
512
+ return { dest: null, searched, truncated }
513
+ }
514
+ const best = Math.max(...holders.map((holder) => holder.verified))
515
+ // a directory that confirmed nothing cannot outrank one that confirmed
516
+ // something, and when nothing anywhere confirmed, every claimant is a rival.
517
+ const rivals = holders.filter((holder) => holder.verified === best)
518
+ const distinct = new Set(rivals.map((rival) => unverifiedFileKey(rival.dest)))
519
+ const winner = rivals[0]
520
+ if (!winner) return { dest: null, searched, truncated }
521
+ if (distinct.size > 1) {
522
+ throw new Error(
523
+ `${REMOTE_COMMAND} found this bundle's assets in more than one directory and cannot tell which build wrote them: ${rivals
524
+ .map((rival) => rival.dest.directory)
525
+ .join(
526
+ ', ',
527
+ )}. Pass --assets <dir> to name the one \`react-native bundle --assets-dest\` wrote.`,
528
+ )
529
+ }
530
+ if (best === 0) {
531
+ // every descriptor declares a scale this directory does not hold, so not
532
+ // one of Metro's digests can be reproduced from it. that is ordinary for an
533
+ // asset carrying an Android rung such as @1.5x, and it is also exactly what
534
+ // a half-copied dest looks like, so there is nothing here to choose on.
535
+ throw new Error(
536
+ `${REMOTE_COMMAND} found images for this bundle in ${winner.dest.directory} but could not confirm a single one of them, so it will not guess that this is the build's own \`--assets-dest\`. Pass --assets <dir> to name the directory \`react-native bundle --assets-dest\` wrote.`,
537
+ )
538
+ }
539
+ return { dest: winner.dest, searched, truncated }
540
+ }
541
+
542
+ /**
543
+ * an explicitly named `--assets <dir>`. the caller promised this is the dest,
544
+ * so it is never ranked against anything, but its bytes are still checked
545
+ * against Metro's hashes so a stale dest is caught rather than shipped.
546
+ */
547
+ function explicitMetroAssetDest(
548
+ directory: string,
549
+ descriptors: readonly MetroAssetDescriptor[],
550
+ ): MetroAssetSearch {
551
+ const listing = listMetroAssetDest(directory)
552
+ const placements = descriptors.map((descriptor) =>
553
+ placeMetroDescriptor(listing, descriptor),
554
+ )
555
+ return {
556
+ dest: { directory: listing.directory, placements },
557
+ searched: [listing.directory],
558
+ truncated: listing.truncated,
559
+ }
560
+ }
561
+
562
+ function largestEmbeddedAssets(
563
+ sizes: readonly MetroArtifactAssetSize[],
564
+ count: number,
565
+ ): string {
566
+ if (sizes.length === 0) return ''
567
+ const largest = sizes
568
+ .slice(0, count)
569
+ .map((entry) => `${entry.asset} ${entry.bytes} bytes`)
570
+ .join(', ')
571
+ return ` Largest embedded assets: ${largest}.`
190
572
  }
191
573
 
192
574
  async function inferCloudModuleIdentity(
@@ -197,9 +579,9 @@ async function inferCloudModuleIdentity(
197
579
  receipt = await loadMetroFingerprintRegistry()
198
580
  } catch (error) {
199
581
  throw new Error(
200
- `rnx Cloud could not load the Metro fingerprint registry needed to identify this bundle: ${
582
+ `${REMOTE_COMMAND} could not load the Metro fingerprint registry needed to identify this bundle: ${
201
583
  error instanceof Error ? error.message : String(error)
202
- }. Build it through the published Metro plugin: withRNX(config, { productionBundles: true }) for the production graph, or withRNX(config, { developmentBundles: true }) for the development graph.`,
584
+ }. Build it through the published Metro plugin: withRNX(config).`,
203
585
  )
204
586
  }
205
587
  try {
@@ -216,9 +598,9 @@ async function inferCloudModuleIdentity(
216
598
  }
217
599
  } catch (error) {
218
600
  throw new Error(
219
- `rnx Cloud could not infer module identity for this Metro bundle: ${
601
+ `${REMOTE_COMMAND} could not infer module identity for this Metro bundle: ${
220
602
  error instanceof Error ? error.message : String(error)
221
- }. Build it through the published Metro plugin: withRNX(config, { productionBundles: true }) for the production graph, or withRNX(config, { developmentBundles: true }) for the development graph.`,
603
+ }. Build it through the published Metro plugin: withRNX(config).`,
222
604
  )
223
605
  }
224
606
  }
@@ -230,27 +612,80 @@ async function inferCloudModuleIdentity(
230
612
  * an ordinary unannotated Metro production bundle is identified through the
231
613
  * published fingerprint registry; a withRNX footer remains the fast path.
232
614
  */
615
+ export interface CloudArtifactProduction {
616
+ bytes: Buffer
617
+ sha256: string
618
+ receipt: RnxCloudArtifactReceipt
619
+ /** the asset dest directory the producer read, or null when it read none. */
620
+ assetsDirectory: string | null
621
+ /** what the CLI prints after the receipt. */
622
+ warnings: string[]
623
+ }
624
+
233
625
  export async function produceCloudArtifact(
234
626
  bundlePath: string,
235
627
  assetsPath?: string,
236
- ): Promise<{ bytes: Buffer; sha256: string; receipt: RnxCloudArtifactReceipt }> {
628
+ ): Promise<CloudArtifactProduction> {
237
629
  const source = await loadBundleSource(bundlePath)
238
- const destFiles = assetsPath ? listMetroAssetDestFiles(assetsPath) : []
630
+ const descriptors = extractAssetDescriptors(source)
631
+ // an https bundle has no local tree to search, and a bundle with no assets
632
+ // has nothing to search for.
633
+ const search: MetroAssetSearch = assetsPath
634
+ ? explicitMetroAssetDest(resolve(assetsPath), descriptors)
635
+ : descriptors.length > 0 && !isRemoteBundleInput(bundlePath)
636
+ ? searchMetroAssetDest(bundlePath, descriptors)
637
+ : { dest: null, searched: [], truncated: false }
638
+ const dest = search.dest
639
+ // `embedMetroAssetFiles` re-parses the bundle, so its descriptors are equal
640
+ // to the ones the search placed but not the same objects.
641
+ const placements = new Map<string, DescriptorPlacement>()
642
+ descriptors.forEach((descriptor, index) => {
643
+ const placement = dest?.placements[index]
644
+ if (placement) placements.set(metroAssetKey(descriptor), placement)
645
+ })
646
+ const substituted: string[] = []
239
647
  const embedded = await embedMetroAssetFiles(source, async (variant) => {
240
- const relative = resolveMetroIosAssetDestFile(
241
- destFiles,
242
- variant.descriptor,
243
- variant.scale,
244
- )
245
- if (relative && assetsPath) return readFileSync(join(assetsPath, relative))
648
+ const label = metroAssetKey(variant.descriptor)
649
+ const placement = placements.get(label)
650
+ if (placement?.mismatched && dest) {
651
+ throw new Error(
652
+ `${REMOTE_COMMAND} found ${variant.descriptor.name}.${variant.descriptor.type} in ${dest.directory}, but its bytes are not the ones this bundle was built against. Rebuild with \`react-native bundle --assets-dest ${dest.directory}\` so the bundle and the images match.`,
653
+ )
654
+ }
655
+ const relative = placement?.files.get(variant.scale)
656
+ if (relative && dest) return readFileSync(join(dest.directory, relative))
246
657
  // iOS copies only @1x..@3x, so a declared scale with no file is ordinary
247
- // and the producer narrows the descriptor. an asset with no file at any
248
- // scale is the real failure, and this is where the dest path a customer
249
- // should look for is still known.
250
- const written = variant.descriptor.scales.some((scale) =>
251
- resolveMetroIosAssetDestFile(destFiles, variant.descriptor, scale),
252
- )
253
- if (written) return null
658
+ // and the producer narrows the descriptor.
659
+ if (placement && placement.files.size > 0) return null
660
+ if (placement && placement.ambiguous.length > 0 && dest) {
661
+ throw new Error(
662
+ `${REMOTE_COMMAND} found more than one image for ${label} in ${dest.directory} and none of them is the one Metro hashed: ${placement.ambiguous.sort().join(', ')}. Pass --assets <dir> to name the directory \`react-native bundle --assets-dest\` wrote.`,
663
+ )
664
+ }
665
+ // a stand-in is only honest when the asset is genuinely absent from a
666
+ // directory we know is the right one. an explicitly named dest is a
667
+ // promise, not a guess, so a miss there is a typo, and a truncated walk
668
+ // never finished looking. both refuse instead of shipping bytes nobody
669
+ // chose.
670
+ if (assetsPath) {
671
+ throw new Error(
672
+ `${REMOTE_COMMAND} could not find ${label} under --assets ${dest?.directory ?? resolve(assetsPath)}. Point --assets at the directory \`react-native bundle --assets-dest\` wrote, or drop the flag to let ${REMOTE_COMMAND} find it.`,
673
+ )
674
+ }
675
+ if (search.truncated) {
676
+ throw new Error(
677
+ `${REMOTE_COMMAND} stopped listing ${search.searched.join(', ')} after ${MAX_ASSET_DEST_FILES} files and never reached ${label}, so it cannot tell a missing asset from an unread one. Pass --assets <dir> to name the directory \`react-native bundle --assets-dest\` wrote.`,
678
+ )
679
+ }
680
+ // no scale of this asset exists anywhere the search looked. an image can
681
+ // stand in visibly and let the app run; every declared scale gets the same
682
+ // stand-in, because narrowing to one would rewrite a `scales` list the
683
+ // descriptor may not even carry.
684
+ const placeholder = metroAssetPlaceholderBytes(variant.descriptor.type)
685
+ if (placeholder) {
686
+ if (!substituted.includes(label)) substituted.push(label)
687
+ return placeholder
688
+ }
254
689
  const filename = metroAssetScaleFilename(
255
690
  variant.descriptor.name,
256
691
  variant.descriptor.type,
@@ -259,24 +694,37 @@ export async function produceCloudArtifact(
259
694
  const classic =
260
695
  metroIosAssetDestRelatives(variant.descriptor.httpServerLocation, filename)[0] ??
261
696
  filename
262
- const lookupPath = assetsPath ?? '.'
263
697
  throw new Error(
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>.`,
698
+ `${REMOTE_COMMAND} could not read ${join(dest?.directory ?? '.', classic)} for ${variant.descriptor.name}.${variant.descriptor.type} at scale ${variant.scale}, and has no stand-in for a .${variant.descriptor.type} asset. Build the bundle with --assets-dest, or pass --assets <dir>.`,
265
699
  )
266
700
  })
267
701
  const artifact = await buildRnxCloudArtifact(embedded.code, {
268
702
  inferModuleIdentity: inferCloudModuleIdentity,
269
703
  })
270
704
  const bytes = Buffer.from(artifact.code, 'utf8')
705
+ const assets = artifact.receipt.assets
271
706
  if (bytes.length > MAX_RNX_CLOUD_BUNDLE_BYTES) {
272
707
  throw new Error(
273
- `rnx ios --nano artifact is ${bytes.length} bytes and exceeds the ${MAX_RNX_CLOUD_BUNDLE_BYTES}-byte limit`,
708
+ `${REMOTE_COMMAND} artifact is ${bytes.length} bytes and exceeds the ${MAX_RNX_CLOUD_BUNDLE_BYTES}-byte limit. Embedded assets weigh ${assets.bytes} bytes on disk, and base64 embedding costs about a third on top of that, so the practical asset budget is roughly 15 MB.${largestEmbeddedAssets(assets.sizes, 10)}`,
709
+ )
710
+ }
711
+ const warnings: string[] = []
712
+ if (substituted.length > 0) {
713
+ warnings.push(
714
+ `${REMOTE_COMMAND} embedded a placeholder image for ${substituted.length} asset(s) no scale of which is in ${search.searched.join(', ') || 'any asset dest directory'}: ${substituted.join(', ')}. Build the bundle with --assets-dest, or pass --assets <dir>, to ship the real images.`,
715
+ )
716
+ }
717
+ if (bytes.length >= WARN_ARTIFACT_BYTES) {
718
+ warnings.push(
719
+ `${REMOTE_COMMAND} artifact is ${bytes.length} bytes, ${Math.round((bytes.length / MAX_RNX_CLOUD_BUNDLE_BYTES) * 100)}% of the ${MAX_RNX_CLOUD_BUNDLE_BYTES}-byte limit.${largestEmbeddedAssets(assets.sizes, 5)}`,
274
720
  )
275
721
  }
276
722
  return {
277
723
  bytes,
278
724
  sha256: createHash('sha256').update(bytes).digest('hex'),
279
725
  receipt: artifact.receipt,
726
+ assetsDirectory: dest?.directory ?? null,
727
+ warnings,
280
728
  }
281
729
  }
282
730
 
@@ -289,7 +737,7 @@ function readCreateResponse(
289
737
  boxToken: string
290
738
  simulatorToken: string
291
739
  } {
292
- if (!isRecord(value)) throw new Error('RNX Cloud create returned invalid JSON')
740
+ if (!isRecord(value)) throw new Error('remote simulator create returned invalid JSON')
293
741
  const artifactId = `sha256:${expectedSha256}` as const
294
742
  const isExpectedArtifact = (artifact: unknown): artifact is { bytes: number } =>
295
743
  isRecord(artifact) &&
@@ -321,7 +769,7 @@ function readCreateResponse(
321
769
  typeof simulatorToken !== 'string' ||
322
770
  !simulatorToken
323
771
  ) {
324
- throw new Error('RNX Cloud create returned an invalid box receipt')
772
+ throw new Error('remote simulator create returned an invalid receipt')
325
773
  }
326
774
  return {
327
775
  receipt: {
@@ -339,38 +787,51 @@ function readCreateResponse(
339
787
  }
340
788
  }
341
789
 
342
- // `rnx ios --nano <bundle>` creates a nano box around the artifact with its
343
- // first simulator in one request, and the shell then targets that box
790
+ // `rnx ios --remote <bundle>` creates the remote host for the artifact and its
791
+ // first simulator in one request, and the shell then targets that simulator
344
792
  export async function createCloudBox(options: CreateCloudBoxOptions): Promise<{
345
793
  receipt: RnxCloudBoxCreateReceipt
346
794
  session: CloudSession
347
795
  artifact: RnxCloudArtifactReceipt
796
+ assetsDirectory: string | null
797
+ warnings: string[]
348
798
  }> {
349
799
  const apiOrigin = resolveCloudOrigin(options.apiOrigin)
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)
800
+ const artifact = await produceCloudArtifact(options.bundlePath, options.assetsPath)
801
+ const boxName = options.boxName?.trim()
802
+ const headers: Record<string, string> = {
803
+ authorization: options.authorization,
804
+ 'x-rnx-box-size': 'nano',
805
+ 'x-rnx-artifact-sha256': artifact.sha256,
806
+ 'x-rnx-platform': 'ios',
807
+ 'x-rnx-device': options.device,
808
+ ...(options.accountId ? { 'x-rnx-account-id': options.accountId } : null),
809
+ ...(boxName ? { 'x-rnx-box-name': boxName } : null),
810
+ }
811
+ let body: BodyInit
812
+ if (options.storage) {
813
+ const form = new FormData()
814
+ form.append(
815
+ 'artifact',
816
+ new Blob([new Uint8Array(artifact.bytes)], { type: 'application/javascript' }),
817
+ 'bundle.js',
818
+ )
819
+ form.append('storage', JSON.stringify(options.storage))
820
+ body = form
821
+ } else {
822
+ headers['content-type'] = 'application/javascript'
823
+ body = new Uint8Array(artifact.bytes)
824
+ }
357
825
  let response: Response
358
826
  try {
359
827
  response = await fetch(`${apiOrigin}/v1/boxes`, {
360
828
  method: 'POST',
361
- headers: {
362
- authorization: options.authorization,
363
- 'content-type': 'application/javascript',
364
- 'x-rnx-box-size': 'nano',
365
- 'x-rnx-artifact-sha256': artifact.sha256,
366
- 'x-rnx-platform': 'ios',
367
- 'x-rnx-device': options.device,
368
- },
369
- body: new Uint8Array(artifact.bytes),
829
+ headers,
830
+ body,
370
831
  })
371
832
  } catch (error) {
372
833
  throw new Error(
373
- `could not reach RNX Cloud at ${apiOrigin}: ${
834
+ `${REMOTE_COMMAND} could not reach ${apiOrigin}: ${
374
835
  error instanceof Error ? error.message : String(error)
375
836
  }`,
376
837
  )
@@ -381,7 +842,7 @@ export async function createCloudBox(options: CreateCloudBoxOptions): Promise<{
381
842
  try {
382
843
  parsed = JSON.parse(responseText)
383
844
  } catch {
384
- throw new Error('RNX Cloud create returned unreadable JSON')
845
+ throw new Error('remote simulator create returned unreadable JSON')
385
846
  }
386
847
  const created = readCreateResponse(parsed, artifact.bytes.length, artifact.sha256)
387
848
  const session = createCloudSession({
@@ -392,7 +853,13 @@ export async function createCloudBox(options: CreateCloudBoxOptions): Promise<{
392
853
  claimId: created.receipt.simulator.claim.id,
393
854
  token: created.simulatorToken,
394
855
  })
395
- return { receipt: created.receipt, session, artifact: artifact.receipt }
856
+ return {
857
+ receipt: created.receipt,
858
+ session,
859
+ artifact: artifact.receipt,
860
+ assetsDirectory: artifact.assetsDirectory,
861
+ warnings: artifact.warnings,
862
+ }
396
863
  }
397
864
 
398
865
  export function resolveCloudSessionForParsed(
@@ -402,7 +869,7 @@ export function resolveCloudSessionForParsed(
402
869
  if (!session) return null
403
870
  if (parsed.simIdSource === 'flag' && parsed.simId !== session.simId) {
404
871
  throw new Error(
405
- `this shell is connected to RNX Cloud simulator ${session.simId}; ${
872
+ `this shell is connected to remote simulator ${session.simId}; ${
406
873
  parsed.simId ? `--sim ${parsed.simId}` : 'the requested simulator'
407
874
  } is not available in this shell`,
408
875
  )
@@ -419,7 +886,9 @@ function cloudCommand(command: {
419
886
  if (key !== 'id' && key !== 'simId') clean[key] = value
420
887
  }
421
888
  if (isRnxCloudCommandType(command.type)) return clean
422
- throw new Error(`RNX Cloud does not support bridge command type ${command.type}`)
889
+ throw new Error(
890
+ `a remote simulator does not support bridge command type ${command.type}`,
891
+ )
423
892
  }
424
893
 
425
894
  class CloudBridge implements WsBridge {
@@ -454,7 +923,7 @@ class CloudBridge implements WsBridge {
454
923
  )
455
924
  } catch (error) {
456
925
  throw new Error(
457
- `could not reach RNX Cloud at ${this.session.apiOrigin}: ${
926
+ `could not reach the remote simulator at ${this.session.apiOrigin}: ${
458
927
  error instanceof Error ? error.message : String(error)
459
928
  }`,
460
929
  )
@@ -465,34 +934,38 @@ class CloudBridge implements WsBridge {
465
934
  try {
466
935
  parsed = JSON.parse(text)
467
936
  } catch {
468
- throw new Error('RNX Cloud command returned unreadable JSON')
937
+ throw new Error('remote simulator command returned unreadable JSON')
469
938
  }
470
939
  if (!isRecord(parsed) || parsed.id !== body.id) {
471
- throw new Error('RNX Cloud command returned an invalid command receipt')
940
+ throw new Error('remote simulator command returned an invalid command receipt')
472
941
  }
473
942
  if (typeof parsed.error === 'string' && parsed.error) throw new Error(parsed.error)
474
943
  if (!Object.hasOwn(parsed, 'result')) {
475
- throw new Error('RNX Cloud command receipt has no result')
944
+ throw new Error('remote simulator command receipt has no result')
476
945
  }
477
946
  return parsed.result
478
947
  }
479
948
 
480
949
  listSims(): Promise<BridgeSimInfo[]> {
481
- return Promise.reject(new Error('RNX Cloud does not expose a local simulator list'))
950
+ return Promise.reject(
951
+ new Error('a remote simulator does not expose a local simulator list'),
952
+ )
482
953
  }
483
954
 
484
955
  focusSim(): Promise<never> {
485
- return Promise.reject(
486
- new Error('RNX Cloud simulators have no browser window to focus'),
487
- )
956
+ return Promise.reject(new Error('remote simulators have no browser window to focus'))
488
957
  }
489
958
 
490
959
  closeSim(): Promise<never> {
491
- return Promise.reject(new Error('RNX Cloud close uses the simulator DELETE route'))
960
+ return Promise.reject(
961
+ new Error('remote simulator close uses the simulator DELETE route'),
962
+ )
492
963
  }
493
964
 
494
965
  claim(): Promise<BridgeClaimResult> {
495
- return Promise.reject(new Error('RNX Cloud claim uses the simulator claim route'))
966
+ return Promise.reject(
967
+ new Error('remote simulator claim uses the simulator claim route'),
968
+ )
496
969
  }
497
970
 
498
971
  close(): void {}
@@ -511,7 +984,7 @@ function readClaimResponse(value: unknown): { id: string; expiresAt: number } {
511
984
  !claim.id ||
512
985
  typeof claim.expiresAt !== 'number'
513
986
  ) {
514
- throw new Error('RNX Cloud claim returned an invalid receipt')
987
+ throw new Error('remote simulator claim returned an invalid receipt')
515
988
  }
516
989
  return { id: claim.id, expiresAt: claim.expiresAt }
517
990
  }
@@ -542,7 +1015,7 @@ export async function claimCloudSimulator(
542
1015
  try {
543
1016
  parsed = JSON.parse(text)
544
1017
  } catch {
545
- throw new Error('RNX Cloud claim returned unreadable JSON')
1018
+ throw new Error('remote simulator claim returned unreadable JSON')
546
1019
  }
547
1020
  const claim = readClaimResponse(parsed)
548
1021
  return {
@@ -554,7 +1027,7 @@ export async function claimCloudSimulator(
554
1027
  export async function confirmCloudSimulator(session: CloudSession): Promise<void> {
555
1028
  const confirmed = await claimCloudSimulator(session)
556
1029
  if (confirmed.session.claimId !== session.claimId) {
557
- throw new Error('RNX Cloud confirmation replaced the initial claim')
1030
+ throw new Error('remote simulator confirmation replaced the initial claim')
558
1031
  }
559
1032
  }
560
1033
 
@@ -576,6 +1049,7 @@ export async function closeCloudBox(session: CloudSession): Promise<void> {
576
1049
  export interface AddCloudSimulatorOptions {
577
1050
  device: string
578
1051
  authorization: string
1052
+ accountId: string | null
579
1053
  }
580
1054
 
581
1055
  export async function addCloudSimulator(
@@ -595,13 +1069,14 @@ export async function addCloudSimulator(
595
1069
  authorization: `Bearer ${session.boxToken}`,
596
1070
  'x-rnx-account-authorization': options.authorization,
597
1071
  'content-type': 'application/json',
1072
+ ...(options.accountId ? { 'x-rnx-account-id': options.accountId } : {}),
598
1073
  },
599
1074
  body: JSON.stringify({ device: options.device }),
600
1075
  },
601
1076
  )
602
1077
  } catch (error) {
603
1078
  throw new Error(
604
- `could not reach RNX Cloud at ${session.apiOrigin}: ${
1079
+ `could not reach the remote simulator at ${session.apiOrigin}: ${
605
1080
  error instanceof Error ? error.message : String(error)
606
1081
  }`,
607
1082
  )
@@ -617,16 +1092,19 @@ export async function addCloudSimulator(
617
1092
  (isRecord(parsed.error) && parsed.error.code === 'unknown_artifact'))
618
1093
  ) {
619
1094
  throw new Error(
620
- 'this box does not hold a new bundle; run `rnx close` and create again',
1095
+ 'this remote simulator does not hold a new bundle; run `rnx close` and create again',
621
1096
  )
622
1097
  }
623
1098
  } catch (err) {
624
- if (err instanceof Error && err.message.startsWith('this box does not hold')) {
1099
+ if (
1100
+ err instanceof Error &&
1101
+ err.message.startsWith('this remote simulator does not hold')
1102
+ ) {
625
1103
  throw err
626
1104
  }
627
1105
  }
628
1106
  throw new Error(
629
- 'this box does not hold a new bundle; run `rnx close` and create again',
1107
+ 'this remote simulator does not hold a new bundle; run `rnx close` and create again',
630
1108
  )
631
1109
  }
632
1110
  throw responseError(response.status, responseText)
@@ -635,10 +1113,10 @@ export async function addCloudSimulator(
635
1113
  try {
636
1114
  parsed = JSON.parse(responseText)
637
1115
  } catch {
638
- throw new Error('RNX Cloud simulator create returned unreadable JSON')
1116
+ throw new Error('remote simulator create returned unreadable JSON')
639
1117
  }
640
1118
  if (!isRecord(parsed) || !isRecord(parsed.simulator)) {
641
- throw new Error('RNX Cloud simulator create returned an invalid receipt')
1119
+ throw new Error('remote simulator create returned an invalid receipt')
642
1120
  }
643
1121
  const simulator = parsed.simulator
644
1122
  const simId = simulator.simId
@@ -658,7 +1136,7 @@ export async function addCloudSimulator(
658
1136
  typeof artifact.id !== 'string' ||
659
1137
  typeof artifact.bytes !== 'number'
660
1138
  ) {
661
- throw new Error('RNX Cloud simulator create returned an invalid receipt')
1139
+ throw new Error('remote simulator create returned an invalid receipt')
662
1140
  }
663
1141
  const receipt: RnxCloudCreateReceipt = {
664
1142
  simId,