rnxsim 0.1.432 → 0.1.433

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