rnxsim 0.1.381 → 0.1.383

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 (138) hide show
  1. package/README.md +19 -6
  2. package/cli/app-fonts.ts +1 -1
  3. package/cli/bin.ts +118 -26
  4. package/cli/bridge-diagnostics.ts +1 -1
  5. package/cli/bridge-flow-runner.ts +9 -9
  6. package/cli/browser-evals.ts +3 -3
  7. package/cli/cli-update.ts +261 -0
  8. package/cli/cloud-client.ts +407 -0
  9. package/cli/cloud-dispatch.ts +165 -0
  10. package/cli/cloud-session.ts +156 -0
  11. package/cli/command-contract.ts +2 -2
  12. package/cli/command-registry.ts +9 -0
  13. package/cli/commands/agent-wrapper.ts +4 -4
  14. package/cli/commands/agent.ts +3 -3
  15. package/cli/commands/box/checkout-plane.ts +16 -8
  16. package/cli/commands/box/rnx-command.ts +2 -2
  17. package/cli/commands/box/search-command.ts +2 -2
  18. package/cli/commands/box.ts +123 -70
  19. package/cli/commands/config.ts +3 -2
  20. package/cli/commands/control.ts +6 -6
  21. package/cli/commands/cpu-profile.ts +2 -2
  22. package/cli/commands/daemon.ts +7 -7
  23. package/cli/commands/debug.ts +2 -2
  24. package/cli/commands/detox.ts +7 -7
  25. package/cli/commands/diagnose.ts +1 -1
  26. package/cli/commands/electron.ts +1 -1
  27. package/cli/commands/film.ts +1 -1
  28. package/cli/commands/flow.ts +4 -4
  29. package/cli/commands/inspect/core.ts +58 -7
  30. package/cli/commands/inspect/env.ts +5 -3
  31. package/cli/commands/inspect/shared.ts +2 -1
  32. package/cli/commands/inspect/wait-event.ts +1 -1
  33. package/cli/commands/inspect/wait-ready.ts +15 -5
  34. package/cli/commands/inspect.ts +31 -9
  35. package/cli/commands/install-desktop.ts +2 -2
  36. package/cli/commands/maestro.ts +3 -3
  37. package/cli/commands/permissions.ts +1 -1
  38. package/cli/commands/platform.ts +91 -0
  39. package/cli/commands/record.ts +3 -3
  40. package/cli/commands/report-issue.ts +1 -1
  41. package/cli/commands/runtime.ts +1 -1
  42. package/cli/commands/screenshot-mode.ts +1 -1
  43. package/cli/commands/screenshot.ts +25 -0
  44. package/cli/commands/serve.ts +1 -1
  45. package/cli/commands/skills.ts +1 -1
  46. package/cli/commands/timeline.ts +1 -1
  47. package/cli/commands/upgrade.ts +8 -34
  48. package/cli/commands/upload.ts +3 -3
  49. package/cli/commands/version.ts +14 -3
  50. package/cli/commands/what-happened.ts +2 -2
  51. package/cli/current-sim.ts +43 -30
  52. package/cli/desktop-companion.ts +1 -1
  53. package/cli/drivers/electron.ts +1 -1
  54. package/cli/drivers/playwright.ts +3 -3
  55. package/cli/drivers/types.ts +4 -4
  56. package/cli/flow-file.ts +3 -3
  57. package/cli/help-core.ts +18 -11
  58. package/cli/hidden-runtime-alias.ts +1 -1
  59. package/cli/hints.ts +2 -2
  60. package/cli/maestro-js.ts +2 -2
  61. package/cli/outbound-endpoints.ts +40 -6
  62. package/cli/parse-args.ts +1 -1
  63. package/cli/registry.ts +1 -1
  64. package/cli/resolve-assets.ts +2 -2
  65. package/cli/secure-file.ts +31 -0
  66. package/cli/shell-init.ts +98 -0
  67. package/cli/ws-bridge.ts +18 -8
  68. package/detox/expectations.ts +1 -1
  69. package/detox/gestures.ts +3 -3
  70. package/detox/index.ts +3 -3
  71. package/dist-lib/agent-daemon-client.cjs +1 -1
  72. package/dist-lib/agent-events.cjs +1 -1
  73. package/dist-lib/agent-identity.cjs +1 -1
  74. package/dist-lib/agent-sessions.cjs +1 -1
  75. package/dist-lib/attached-projects.cjs +1 -1
  76. package/dist-lib/auth/shared-session.cjs +1 -1
  77. package/dist-lib/backend-origin.cjs +1 -1
  78. package/dist-lib/beta.cjs +1 -1
  79. package/dist-lib/beta.mjs +1 -1
  80. package/dist-lib/bridge-constants.cjs +1 -1
  81. package/dist-lib/bridge-contract-input.cjs +20 -8
  82. package/dist-lib/bridge-contract-input.mjs +20 -8
  83. package/dist-lib/bridge-contract.cjs +1 -1
  84. package/dist-lib/bridge-contract.mjs +1 -1
  85. package/dist-lib/capture-contract.cjs +217 -0
  86. package/dist-lib/capture-contract.mjs +187 -0
  87. package/dist-lib/cli-constants.cjs +1 -1
  88. package/dist-lib/cloud-contract.cjs +41 -0
  89. package/dist-lib/cloud-contract.mjs +13 -0
  90. package/dist-lib/config.cjs +1 -1
  91. package/dist-lib/detox/index.cjs +4 -4
  92. package/dist-lib/dev-bundle-resolution.cjs +1 -1
  93. package/dist-lib/home-paths.cjs +9 -1
  94. package/dist-lib/host/bridge-host.cjs +15 -10
  95. package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
  96. package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
  97. package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
  98. package/dist-lib/host/replacement-module-handler.cjs +1 -1
  99. package/dist-lib/host/websocket-proxy.cjs +1 -1
  100. package/dist-lib/index.cjs +361 -75
  101. package/dist-lib/jump-to-source-babel.cjs +1 -1
  102. package/dist-lib/menu.cjs +1 -1
  103. package/dist-lib/menu.mjs +1 -1
  104. package/dist-lib/metro-production-bundle.cjs +1 -1
  105. package/dist-lib/metro-production-bundle.mjs +1 -1
  106. package/dist-lib/metro.cjs +1 -1
  107. package/dist-lib/profiles.cjs +1 -1
  108. package/dist-lib/public-brand.cjs +1 -1
  109. package/dist-lib/react-native-host-modules.cjs +1 -1
  110. package/dist-lib/react-native-host-modules.mjs +1 -1
  111. package/dist-lib/render-mode.cjs +1 -1
  112. package/dist-lib/scripts/dev-server-scanner.cjs +1 -1
  113. package/dist-lib/sdk.cjs +44 -6
  114. package/dist-lib/sdk.mjs +44 -6
  115. package/dist-lib/skills.cjs +436 -155
  116. package/dist-lib/vite.cjs +1 -1
  117. package/package.json +20 -4
  118. package/scripts/dev-server-scanner.ts +3 -3
  119. package/skills/contrast/SKILL.md +1 -1
  120. package/skills/rnx-perf/SKILL.md +68 -52
  121. package/src/app-url.ts +2 -3
  122. package/src/bridge-contract-input.ts +25 -6
  123. package/src/bridge-contract.ts +35 -5
  124. package/src/capture-contract.ts +576 -0
  125. package/src/cloud-contract.ts +18 -0
  126. package/src/home-paths.ts +9 -3
  127. package/src/host/agent-host.ts +1 -1
  128. package/src/host/bridge-host.ts +13 -11
  129. package/src/host/plane-host.ts +2 -2
  130. package/src/index.ts +25 -0
  131. package/src/native-dev-bundle-url.ts +2 -2
  132. package/src/screenshots/orchestrate.ts +1 -1
  133. package/src/screenshots/org-deck.ts +1 -1
  134. package/src/settings-bundle.ts +1 -1
  135. package/src/sim-client.ts +4 -0
  136. package/src/vite-plugin-one.ts +2 -2
  137. package/src/vite-plugin.ts +1 -1
  138. package/cli/commands/box/cloud-box.ts +0 -294
@@ -0,0 +1,407 @@
1
+ import { createHash, randomUUID } from 'node:crypto'
2
+ import { closeSync, fstatSync, openSync, readFileSync } from 'node:fs'
3
+ import { extname, resolve } from 'node:path'
4
+ import { assertSelfContainedMetroArtifactAssets } from '../../contrast-bundler/src/metroAssetArtifact'
5
+ import {
6
+ RNX_CLOUD_MAX_ARTIFACT_BYTES,
7
+ type RnxCloudCreateReceipt,
8
+ } from '../src/cloud-contract'
9
+ import { rnxPublicBrand } from '../src/public-brand'
10
+ import {
11
+ createCloudSession,
12
+ normalizeCloudOrigin,
13
+ readCloudSession,
14
+ type CloudSession,
15
+ } from './cloud-session'
16
+ import type {
17
+ BridgeClaimResult,
18
+ BridgeSimInfo,
19
+ ParsedBridgeCliArgs,
20
+ WsBridge,
21
+ } from './ws-bridge'
22
+
23
+ export const DEFAULT_RNX_CLOUD_ORIGIN = rnxPublicBrand.origin
24
+ export const MAX_RNX_CLOUD_BUNDLE_BYTES = RNX_CLOUD_MAX_ARTIFACT_BYTES
25
+
26
+ interface CreateCloudSimulatorOptions {
27
+ bundlePath: string
28
+ device: string
29
+ authorization: string
30
+ apiOrigin?: string
31
+ }
32
+
33
+ interface CloudCommandEnvelope {
34
+ id: string
35
+ claimId: string
36
+ command: Record<string, unknown>
37
+ }
38
+
39
+ function isRecord(value: unknown): value is Record<string, unknown> {
40
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
41
+ }
42
+
43
+ function responseError(status: number, text: string): Error {
44
+ if (text) {
45
+ try {
46
+ const parsed: unknown = JSON.parse(text)
47
+ const error = isRecord(parsed) ? parsed.error : null
48
+ if (isRecord(error) && typeof error.message === 'string') {
49
+ return new Error(`RNX Cloud request failed (${status}): ${error.message}`)
50
+ }
51
+ } catch {}
52
+ }
53
+ return new Error(
54
+ `RNX Cloud request failed (${status})${text.trim() ? `: ${text.trim()}` : ''}`,
55
+ )
56
+ }
57
+
58
+ function resolveCloudOrigin(value?: string): string {
59
+ const configured = value?.trim() || process.env.RNX_CLOUD_ORIGIN?.trim()
60
+ const source = configured || DEFAULT_RNX_CLOUD_ORIGIN
61
+ return normalizeCloudOrigin(source)
62
+ }
63
+
64
+ async function readImmutableBundle(
65
+ bundlePath: string,
66
+ ): Promise<{ bytes: Buffer; sha256: string }> {
67
+ const filepath = resolve(bundlePath)
68
+ if (extname(filepath) !== '.js') {
69
+ throw new Error('rnx ios --cloud requires one local Metro .js bundle')
70
+ }
71
+ const descriptor = openSync(filepath, 'r')
72
+ try {
73
+ const before = fstatSync(descriptor, { bigint: true })
74
+ if (!before.isFile()) {
75
+ throw new Error('rnx ios --cloud bundle must be a regular local file')
76
+ }
77
+ if (before.size <= 0n) {
78
+ throw new Error('rnx ios --cloud bundle is empty')
79
+ }
80
+ if (before.size > BigInt(MAX_RNX_CLOUD_BUNDLE_BYTES)) {
81
+ throw new Error(
82
+ `rnx ios --cloud bundle exceeds the ${MAX_RNX_CLOUD_BUNDLE_BYTES}-byte limit`,
83
+ )
84
+ }
85
+ const bytes = readFileSync(descriptor)
86
+ const after = fstatSync(descriptor, { bigint: true })
87
+ if (
88
+ before.dev !== after.dev ||
89
+ before.ino !== after.ino ||
90
+ before.size !== after.size ||
91
+ before.mtimeNs !== after.mtimeNs ||
92
+ bytes.length !== Number(before.size)
93
+ ) {
94
+ throw new Error('rnx ios --cloud bundle changed while it was being read')
95
+ }
96
+ let source: string
97
+ try {
98
+ source = new TextDecoder('utf-8', { fatal: true }).decode(bytes)
99
+ } catch {
100
+ throw new Error('rnx ios --cloud bundle must be valid UTF-8 JavaScript')
101
+ }
102
+ if (source.includes('\0') || !source.includes('__d(') || !source.includes('__r(')) {
103
+ throw new Error('rnx ios --cloud requires a Metro JavaScript bundle')
104
+ }
105
+ await assertSelfContainedMetroArtifactAssets(source)
106
+ return {
107
+ bytes,
108
+ sha256: createHash('sha256').update(bytes).digest('hex'),
109
+ }
110
+ } finally {
111
+ closeSync(descriptor)
112
+ }
113
+ }
114
+
115
+ function readCreateResponse(
116
+ value: unknown,
117
+ expectedBytes: number,
118
+ expectedSha256: string,
119
+ ): {
120
+ receipt: RnxCloudCreateReceipt
121
+ token: string
122
+ } {
123
+ if (!isRecord(value)) throw new Error('RNX Cloud create returned invalid JSON')
124
+ const simId = value.simId
125
+ const artifact = value.artifact
126
+ const claim = value.claim
127
+ const token = value.token
128
+ if (
129
+ typeof simId !== 'string' ||
130
+ !simId ||
131
+ !isRecord(artifact) ||
132
+ typeof artifact.id !== 'string' ||
133
+ artifact.id !== `sha256:${expectedSha256}` ||
134
+ typeof artifact.bytes !== 'number' ||
135
+ !Number.isInteger(artifact.bytes) ||
136
+ artifact.bytes !== expectedBytes ||
137
+ !isRecord(claim) ||
138
+ typeof claim.id !== 'string' ||
139
+ !claim.id ||
140
+ typeof claim.expiresAt !== 'number' ||
141
+ typeof token !== 'string' ||
142
+ !token
143
+ ) {
144
+ throw new Error('RNX Cloud create returned an invalid simulator receipt')
145
+ }
146
+ return {
147
+ receipt: {
148
+ simId,
149
+ artifact: { id: `sha256:${expectedSha256}`, bytes: artifact.bytes },
150
+ claim: { id: claim.id, expiresAt: claim.expiresAt },
151
+ },
152
+ token,
153
+ }
154
+ }
155
+
156
+ export async function createCloudSimulator(
157
+ options: CreateCloudSimulatorOptions,
158
+ ): Promise<{ receipt: RnxCloudCreateReceipt; session: CloudSession }> {
159
+ const apiOrigin = resolveCloudOrigin(options.apiOrigin)
160
+ const artifact = await readImmutableBundle(options.bundlePath)
161
+ let response: Response
162
+ try {
163
+ response = await fetch(`${apiOrigin}/v1/sims`, {
164
+ method: 'POST',
165
+ headers: {
166
+ authorization: options.authorization,
167
+ 'content-type': 'application/javascript',
168
+ 'x-rnx-artifact-sha256': artifact.sha256,
169
+ 'x-rnx-platform': 'ios',
170
+ 'x-rnx-device': options.device,
171
+ },
172
+ body: new Uint8Array(artifact.bytes),
173
+ })
174
+ } catch (error) {
175
+ throw new Error(
176
+ `could not reach RNX Cloud at ${apiOrigin}: ${
177
+ error instanceof Error ? error.message : String(error)
178
+ }`,
179
+ )
180
+ }
181
+ const responseText = await response.text()
182
+ if (!response.ok) throw responseError(response.status, responseText)
183
+ let parsed: unknown
184
+ try {
185
+ parsed = JSON.parse(responseText)
186
+ } catch {
187
+ throw new Error('RNX Cloud create returned unreadable JSON')
188
+ }
189
+ const created = readCreateResponse(parsed, artifact.bytes.length, artifact.sha256)
190
+ const session = createCloudSession({
191
+ simId: created.receipt.simId,
192
+ apiOrigin,
193
+ claimId: created.receipt.claim.id,
194
+ token: created.token,
195
+ })
196
+ return { receipt: created.receipt, session }
197
+ }
198
+
199
+ export function resolveCloudSessionForParsed(
200
+ parsed: Pick<ParsedBridgeCliArgs, 'simId' | 'simIdSource'>,
201
+ ): CloudSession | null {
202
+ const session = readCloudSession()
203
+ if (!session) return null
204
+ if (parsed.simIdSource === 'flag' && parsed.simId !== session.simId) {
205
+ throw new Error(
206
+ `this shell is connected to RNX Cloud simulator ${session.simId}; ${
207
+ parsed.simId ? `--sim ${parsed.simId}` : 'the requested simulator'
208
+ } is not available in this shell`,
209
+ )
210
+ }
211
+ return session
212
+ }
213
+
214
+ function cloudCommand(command: {
215
+ type: string
216
+ [key: string]: unknown
217
+ }): Record<string, unknown> {
218
+ const clean: Record<string, unknown> = {}
219
+ for (const [key, value] of Object.entries(command)) {
220
+ if (key !== 'id' && key !== 'simId') clean[key] = value
221
+ }
222
+ if (command.type === 'query') {
223
+ const query = command.query
224
+ if (
225
+ !isRecord(query) ||
226
+ (query.kind !== 'tree' && query.kind !== 'find' && query.kind !== 'count')
227
+ ) {
228
+ throw new Error('RNX Cloud supports only structured tree, node, and count queries')
229
+ }
230
+ return clean
231
+ }
232
+ if (command.type === 'waitFor') {
233
+ const options = command.waitForOptions
234
+ const condition = isRecord(options) ? options.condition : null
235
+ if (
236
+ !isRecord(condition) ||
237
+ (condition.type !== 'ready' &&
238
+ condition.type !== 'present' &&
239
+ condition.type !== 'absent')
240
+ ) {
241
+ throw new Error('RNX Cloud supports only ready and selector waits')
242
+ }
243
+ return clean
244
+ }
245
+ if (command.type === 'state') return clean
246
+ if (command.type === 'capture' || command.type === 'captureRegions') return clean
247
+ if (command.type === 'setAppearance' || command.type === 'openUrl') return clean
248
+ if (
249
+ command.type === 'screenshot' &&
250
+ command.crop === undefined &&
251
+ command.captureFilter === undefined &&
252
+ (command.layers === undefined || command.layers === 'tenant')
253
+ ) {
254
+ return clean
255
+ }
256
+ throw new Error(`RNX Cloud does not support bridge command type ${command.type}`)
257
+ }
258
+
259
+ class CloudBridge implements WsBridge {
260
+ readonly plane = 'cloud' as const
261
+
262
+ constructor(private readonly session: CloudSession) {}
263
+
264
+ async send(
265
+ command: { type: string; [key: string]: unknown },
266
+ options?: { timeoutMs?: number },
267
+ ): Promise<unknown> {
268
+ const body: CloudCommandEnvelope = {
269
+ id: randomUUID(),
270
+ claimId: this.session.claimId,
271
+ command: cloudCommand(command),
272
+ }
273
+ let response: Response
274
+ try {
275
+ response = await fetch(
276
+ `${this.session.apiOrigin}/v1/sims/${encodeURIComponent(this.session.simId)}/commands`,
277
+ {
278
+ method: 'POST',
279
+ headers: {
280
+ authorization: `Bearer ${this.session.token}`,
281
+ 'content-type': 'application/json',
282
+ },
283
+ body: JSON.stringify(body),
284
+ ...(options?.timeoutMs
285
+ ? { signal: AbortSignal.timeout(options.timeoutMs) }
286
+ : {}),
287
+ },
288
+ )
289
+ } catch (error) {
290
+ throw new Error(
291
+ `could not reach RNX Cloud at ${this.session.apiOrigin}: ${
292
+ error instanceof Error ? error.message : String(error)
293
+ }`,
294
+ )
295
+ }
296
+ const text = await response.text()
297
+ if (!response.ok) throw responseError(response.status, text)
298
+ let parsed: unknown
299
+ try {
300
+ parsed = JSON.parse(text)
301
+ } catch {
302
+ throw new Error('RNX Cloud command returned unreadable JSON')
303
+ }
304
+ if (!isRecord(parsed) || parsed.id !== body.id) {
305
+ throw new Error('RNX Cloud command returned an invalid command receipt')
306
+ }
307
+ if (typeof parsed.error === 'string' && parsed.error) throw new Error(parsed.error)
308
+ if (!Object.hasOwn(parsed, 'result')) {
309
+ throw new Error('RNX Cloud command receipt has no result')
310
+ }
311
+ return parsed.result
312
+ }
313
+
314
+ listSims(): Promise<BridgeSimInfo[]> {
315
+ return Promise.reject(new Error('RNX Cloud does not expose a local simulator list'))
316
+ }
317
+
318
+ focusSim(): Promise<never> {
319
+ return Promise.reject(
320
+ new Error('RNX Cloud simulators have no browser window to focus'),
321
+ )
322
+ }
323
+
324
+ closeSim(): Promise<never> {
325
+ return Promise.reject(new Error('RNX Cloud close uses the simulator DELETE route'))
326
+ }
327
+
328
+ claim(): Promise<BridgeClaimResult> {
329
+ return Promise.reject(new Error('RNX Cloud claim uses the simulator claim route'))
330
+ }
331
+
332
+ close(): void {}
333
+ }
334
+
335
+ export function createCloudBridgeForParsed(parsed: ParsedBridgeCliArgs): WsBridge | null {
336
+ const session = resolveCloudSessionForParsed(parsed)
337
+ return session ? new CloudBridge(session) : null
338
+ }
339
+
340
+ function readClaimResponse(value: unknown): { id: string; expiresAt: number } {
341
+ const claim = isRecord(value) ? value.claim : null
342
+ if (
343
+ !isRecord(claim) ||
344
+ typeof claim.id !== 'string' ||
345
+ !claim.id ||
346
+ typeof claim.expiresAt !== 'number'
347
+ ) {
348
+ throw new Error('RNX Cloud claim returned an invalid receipt')
349
+ }
350
+ return { id: claim.id, expiresAt: claim.expiresAt }
351
+ }
352
+
353
+ export async function claimCloudSimulator(
354
+ session: CloudSession,
355
+ options: { force?: boolean } = {},
356
+ ): Promise<{
357
+ claim: { id: string; expiresAt: number }
358
+ session: CloudSession
359
+ }> {
360
+ const response = await fetch(
361
+ `${session.apiOrigin}/v1/sims/${encodeURIComponent(session.simId)}/claim`,
362
+ {
363
+ method: 'POST',
364
+ headers: {
365
+ authorization: `Bearer ${session.token}`,
366
+ 'content-type': 'application/json',
367
+ },
368
+ body: JSON.stringify(
369
+ options.force ? { force: true } : { claimId: session.claimId },
370
+ ),
371
+ },
372
+ )
373
+ const text = await response.text()
374
+ if (!response.ok) throw responseError(response.status, text)
375
+ let parsed: unknown
376
+ try {
377
+ parsed = JSON.parse(text)
378
+ } catch {
379
+ throw new Error('RNX Cloud claim returned unreadable JSON')
380
+ }
381
+ const claim = readClaimResponse(parsed)
382
+ return {
383
+ claim,
384
+ session: createCloudSession({ ...session, claimId: claim.id }),
385
+ }
386
+ }
387
+
388
+ export async function confirmCloudSimulator(session: CloudSession): Promise<void> {
389
+ const confirmed = await claimCloudSimulator(session)
390
+ if (confirmed.session.claimId !== session.claimId) {
391
+ throw new Error('RNX Cloud confirmation replaced the initial claim')
392
+ }
393
+ }
394
+
395
+ export async function closeCloudSimulator(session: CloudSession): Promise<void> {
396
+ const response = await fetch(
397
+ `${session.apiOrigin}/v1/sims/${encodeURIComponent(session.simId)}`,
398
+ {
399
+ method: 'DELETE',
400
+ headers: { authorization: `Bearer ${session.token}` },
401
+ },
402
+ )
403
+ const text = await response.text()
404
+ if (!response.ok && response.status !== 404) {
405
+ throw responseError(response.status, text)
406
+ }
407
+ }
@@ -0,0 +1,165 @@
1
+ interface CloudBoundaryInvocation {
2
+ command: string
3
+ args: string[]
4
+ }
5
+
6
+ export type CloudBoundaryResult = { handled: false } | { handled: true; code: number }
7
+
8
+ const CLOUD_TARGET_COMMANDS = new Set([
9
+ 'assert',
10
+ 'camera',
11
+ 'claim',
12
+ 'close',
13
+ 'debug',
14
+ 'describe',
15
+ 'detox',
16
+ 'do',
17
+ 'film',
18
+ 'find',
19
+ 'get',
20
+ 'logs',
21
+ 'maestro',
22
+ 'mode',
23
+ 'network',
24
+ 'perf',
25
+ 'permissions',
26
+ 'record',
27
+ 'reset',
28
+ 'screenshot',
29
+ 'shell',
30
+ 'state',
31
+ 'storage',
32
+ 'timeline',
33
+ 'wait',
34
+ 'what-happened',
35
+ ])
36
+
37
+ function firstVerb(positional: string[]): string | null {
38
+ return positional.find((value) => !value.startsWith('-')) ?? null
39
+ }
40
+
41
+ function supportsCloudRead(
42
+ command: string,
43
+ args: string[],
44
+ positional: string[],
45
+ ): boolean {
46
+ if (command === 'describe' || command === 'find') return true
47
+ if (command === 'state') return firstVerb(positional) === null
48
+ if (command === 'get') {
49
+ const verb = firstVerb(positional)
50
+ return verb === 'tree' || verb === 'node' || verb === 'count'
51
+ }
52
+ if (command === 'wait') {
53
+ const verb = firstVerb(positional)
54
+ return verb === 'ready' || verb === 'selector'
55
+ }
56
+ if (command === 'logs') {
57
+ const verb = firstVerb(positional)
58
+ return (
59
+ !args.includes('--tail') &&
60
+ !args.includes('-f') &&
61
+ verb !== 'tail' &&
62
+ verb !== 'clear' &&
63
+ verb !== 'purge'
64
+ )
65
+ }
66
+ if (command === 'screenshot') {
67
+ return !args.some((arg) =>
68
+ [
69
+ '--area',
70
+ '--gallery',
71
+ '--id',
72
+ '--no-shell',
73
+ '--shell-only',
74
+ '--text',
75
+ '--themes',
76
+ '--with-frame',
77
+ ].includes(arg),
78
+ )
79
+ }
80
+ return false
81
+ }
82
+
83
+ function refuseCloudInvocation(
84
+ command: string,
85
+ positional: string[],
86
+ ): CloudBoundaryResult {
87
+ const verb = firstVerb(positional)
88
+ const name = verb ? `${command} ${verb}` : command
89
+ console.error(` rnx ${name} is not available for an RNX Cloud simulator`)
90
+ return { handled: true, code: 1 }
91
+ }
92
+
93
+ export async function dispatchCloudBoundary({
94
+ command,
95
+ args,
96
+ }: CloudBoundaryInvocation): Promise<CloudBoundaryResult> {
97
+ if (command === 'ios' && args.includes('--cloud')) return { handled: false }
98
+ if (command === 'android' && args.includes('--cloud')) {
99
+ console.error(
100
+ ' rnx android --cloud is not available; RNX Cloud currently supports iOS',
101
+ )
102
+ return { handled: true, code: 1 }
103
+ }
104
+ if (!CLOUD_TARGET_COMMANDS.has(command)) return { handled: false }
105
+
106
+ const [{ parseBridgeCliArgs }, cloudClient, cloudSession] = await Promise.all([
107
+ import('./ws-bridge'),
108
+ import('./cloud-client'),
109
+ import('./cloud-session'),
110
+ ])
111
+ const parsed = parseBridgeCliArgs(args)
112
+ const positionalTarget =
113
+ command === 'claim' || command === 'close' ? firstVerb(parsed.positional) : null
114
+ const target: Pick<typeof parsed, 'simId' | 'simIdSource'> = positionalTarget
115
+ ? { simId: positionalTarget, simIdSource: 'flag' }
116
+ : parsed
117
+ let session
118
+ try {
119
+ session = cloudClient.resolveCloudSessionForParsed(target)
120
+ } catch (error) {
121
+ console.error(` ${error instanceof Error ? error.message : String(error)}`)
122
+ return { handled: true, code: 1 }
123
+ }
124
+ if (!session) return { handled: false }
125
+
126
+ if (command === 'claim') {
127
+ try {
128
+ const descriptor = cloudSession.requireCloudSessionShellUpdate()
129
+ const claimed = await cloudClient.claimCloudSimulator(session, {
130
+ force: args.includes('--force'),
131
+ })
132
+ cloudSession.publishCloudSessionToShell(claimed.session, descriptor)
133
+ console.log(` claimed: ${session.simId}`)
134
+ console.log(` expires: ${claimed.claim.expiresAt}`)
135
+ return { handled: true, code: 0 }
136
+ } catch (error) {
137
+ console.error(
138
+ ` claim failed: ${error instanceof Error ? error.message : String(error)}`,
139
+ )
140
+ return { handled: true, code: 1 }
141
+ }
142
+ }
143
+
144
+ if (command === 'close') {
145
+ if (args.includes('--all') || args.includes('--others')) {
146
+ return refuseCloudInvocation(command, parsed.positional)
147
+ }
148
+ try {
149
+ const descriptor = cloudSession.requireCloudSessionShellUpdate()
150
+ await cloudClient.closeCloudSimulator(session)
151
+ cloudSession.publishCloudSessionToShell(null, descriptor)
152
+ console.log(` closed: ${session.simId}`)
153
+ return { handled: true, code: 0 }
154
+ } catch (error) {
155
+ console.error(
156
+ ` close failed: ${error instanceof Error ? error.message : String(error)}`,
157
+ )
158
+ return { handled: true, code: 1 }
159
+ }
160
+ }
161
+
162
+ return supportsCloudRead(command, args, parsed.positional)
163
+ ? { handled: false }
164
+ : refuseCloudInvocation(command, parsed.positional)
165
+ }