rnxsim 0.1.380 → 0.1.382

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 +43 -26
  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 +15 -8
  82. package/dist-lib/bridge-contract-input.mjs +15 -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 +107 -0
  86. package/dist-lib/capture-contract.mjs +80 -0
  87. package/dist-lib/cli-constants.cjs +1 -1
  88. package/dist-lib/cloud-contract.cjs +32 -0
  89. package/dist-lib/cloud-contract.mjs +7 -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 +248 -73
  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 +435 -155
  116. package/dist-lib/vite.cjs +1 -1
  117. package/package.json +21 -5
  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 +17 -6
  123. package/src/bridge-contract.ts +25 -4
  124. package/src/capture-contract.ts +423 -0
  125. package/src/cloud-contract.ts +15 -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 +21 -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,261 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import { compareSemver } from '@contrast/runtime-delivery'
4
+ import { getCliVersion } from '../src/cli-version'
5
+ import { cliUpdateFilePath } from '../src/home-paths'
6
+ import { isAgentEnv } from './commands/inspect/env'
7
+
8
+ export const DEFAULT_CLI_RELEASE_ORIGIN = 'https://r2.rnxsim.com/rnx-cli'
9
+ export const CLI_UPDATE_CHECK_INTERVAL_MS = 20 * 60 * 60 * 1000
10
+ export const CLI_UPDATE_NOTIFICATION_COOLDOWN_MS = 7 * 24 * 60 * 60 * 1000
11
+
12
+ const CLI_UPDATE_STATE_SCHEMA = 1 as const
13
+ const CLI_VERSION_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/
14
+ let stateWriteSequence = 0
15
+
16
+ interface CliUpdateState {
17
+ schema: typeof CLI_UPDATE_STATE_SCHEMA
18
+ latestVersion: string | null
19
+ checkedAt: number
20
+ lastAttemptAt: number
21
+ lastNotifiedVersion: string | null
22
+ lastNotifiedAt: number
23
+ }
24
+
25
+ export interface CliUpdateAvailability {
26
+ currentVersion: string
27
+ latestVersion: string
28
+ outdated: boolean
29
+ }
30
+
31
+ export interface CliUpdateInvocation {
32
+ command: string | null
33
+ commandArgs: readonly string[]
34
+ exitCode: number
35
+ standalone: boolean
36
+ stderrIsTTY: boolean
37
+ stdoutIsTTY: boolean
38
+ version: boolean
39
+ env?: Readonly<Record<string, string | undefined>>
40
+ }
41
+
42
+ function emptyState(): CliUpdateState {
43
+ return {
44
+ schema: CLI_UPDATE_STATE_SCHEMA,
45
+ latestVersion: null,
46
+ checkedAt: 0,
47
+ lastAttemptAt: 0,
48
+ lastNotifiedVersion: null,
49
+ lastNotifiedAt: 0,
50
+ }
51
+ }
52
+
53
+ function isRecord(value: unknown): value is Record<string, unknown> {
54
+ return value !== null && typeof value === 'object'
55
+ }
56
+
57
+ function isTimestamp(value: unknown): value is number {
58
+ return typeof value === 'number' && Number.isFinite(value) && value >= 0
59
+ }
60
+
61
+ function readState(): CliUpdateState {
62
+ try {
63
+ const parsed: unknown = JSON.parse(fs.readFileSync(cliUpdateFilePath(), 'utf8'))
64
+ if (
65
+ !isRecord(parsed) ||
66
+ parsed.schema !== CLI_UPDATE_STATE_SCHEMA ||
67
+ (parsed.latestVersion !== null &&
68
+ (typeof parsed.latestVersion !== 'string' ||
69
+ !CLI_VERSION_PATTERN.test(parsed.latestVersion))) ||
70
+ !isTimestamp(parsed.checkedAt) ||
71
+ !isTimestamp(parsed.lastAttemptAt) ||
72
+ (parsed.lastNotifiedVersion !== null &&
73
+ (typeof parsed.lastNotifiedVersion !== 'string' ||
74
+ !CLI_VERSION_PATTERN.test(parsed.lastNotifiedVersion))) ||
75
+ !isTimestamp(parsed.lastNotifiedAt)
76
+ ) {
77
+ return emptyState()
78
+ }
79
+ return {
80
+ schema: CLI_UPDATE_STATE_SCHEMA,
81
+ latestVersion: parsed.latestVersion,
82
+ checkedAt: parsed.checkedAt,
83
+ lastAttemptAt: parsed.lastAttemptAt,
84
+ lastNotifiedVersion: parsed.lastNotifiedVersion,
85
+ lastNotifiedAt: parsed.lastNotifiedAt,
86
+ }
87
+ } catch {
88
+ return emptyState()
89
+ }
90
+ }
91
+
92
+ function writeState(state: CliUpdateState): boolean {
93
+ const file = cliUpdateFilePath()
94
+ const temporary = `${file}.tmp-${process.pid}-${stateWriteSequence++}`
95
+ try {
96
+ fs.mkdirSync(path.dirname(file), { recursive: true })
97
+ fs.writeFileSync(temporary, `${JSON.stringify(state)}\n`, 'utf8')
98
+ fs.renameSync(temporary, file)
99
+ return true
100
+ } catch {
101
+ try {
102
+ fs.rmSync(temporary, { force: true })
103
+ } catch {}
104
+ return false
105
+ }
106
+ }
107
+
108
+ function availability(
109
+ state: CliUpdateState,
110
+ currentVersion = getCliVersion(),
111
+ ): CliUpdateAvailability | null {
112
+ if (!state.latestVersion) return null
113
+ return {
114
+ currentVersion,
115
+ latestVersion: state.latestVersion,
116
+ outdated: compareSemver(state.latestVersion, currentVersion) > 0,
117
+ }
118
+ }
119
+
120
+ function envFlagIsOff(value: string | undefined): boolean {
121
+ return (
122
+ value === '0' || value?.toLowerCase() === 'false' || value?.toLowerCase() === 'off'
123
+ )
124
+ }
125
+
126
+ function envFlagIsOn(value: string | undefined): boolean {
127
+ return Boolean(value) && !envFlagIsOff(value)
128
+ }
129
+
130
+ export function automaticCliUpdateChecksEnabled(
131
+ env: Readonly<Record<string, string | undefined>> = process.env,
132
+ ): boolean {
133
+ return !envFlagIsOff(env.RNX_UPDATE_CHECK)
134
+ }
135
+
136
+ export function shouldUseAutomaticCliUpdateCheck(
137
+ invocation: CliUpdateInvocation,
138
+ ): boolean {
139
+ const env = invocation.env ?? process.env
140
+ if (
141
+ !invocation.standalone ||
142
+ invocation.exitCode !== 0 ||
143
+ invocation.version ||
144
+ !invocation.stdoutIsTTY ||
145
+ !invocation.stderrIsTTY ||
146
+ !automaticCliUpdateChecksEnabled(env) ||
147
+ envFlagIsOn(env.CI) ||
148
+ envFlagIsOn(env.GITHUB_ACTIONS) ||
149
+ envFlagIsOn(env.RNX_NO_PROMPT) ||
150
+ isAgentEnv(env) ||
151
+ invocation.commandArgs.includes('--json') ||
152
+ invocation.commandArgs.includes('--ci') ||
153
+ invocation.commandArgs.includes('--dry-run')
154
+ ) {
155
+ return false
156
+ }
157
+
158
+ return !['agent-wrapper', 'cleanup', 'daemon', 'serve', 'upgrade', 'version'].includes(
159
+ invocation.command ?? '',
160
+ )
161
+ }
162
+
163
+ export function claimAutomaticCliUpdateCheck(now = Date.now()): boolean {
164
+ const state = readState()
165
+ const lastCheck = Math.max(state.checkedAt, state.lastAttemptAt)
166
+ if (now >= lastCheck && now - lastCheck < CLI_UPDATE_CHECK_INTERVAL_MS) {
167
+ return false
168
+ }
169
+ return writeState({ ...state, lastAttemptAt: now })
170
+ }
171
+
172
+ export function readCachedCliUpdate(
173
+ currentVersion = getCliVersion(),
174
+ ): CliUpdateAvailability | null {
175
+ return availability(readState(), currentVersion)
176
+ }
177
+
178
+ export function takeCliUpdateNotification(
179
+ currentVersion = getCliVersion(),
180
+ now = Date.now(),
181
+ ): CliUpdateAvailability | null {
182
+ const state = readState()
183
+ const update = availability(state, currentVersion)
184
+ if (!update?.outdated || state.lastNotifiedVersion === update.latestVersion) return null
185
+ if (
186
+ now >= state.lastNotifiedAt &&
187
+ now - state.lastNotifiedAt < CLI_UPDATE_NOTIFICATION_COOLDOWN_MS
188
+ ) {
189
+ return null
190
+ }
191
+ if (
192
+ !writeState({
193
+ ...state,
194
+ lastNotifiedVersion: update.latestVersion,
195
+ lastNotifiedAt: now,
196
+ })
197
+ ) {
198
+ return null
199
+ }
200
+ return update
201
+ }
202
+
203
+ export async function fetchCliReleaseFile(
204
+ url: string,
205
+ timeoutMs: number,
206
+ ): Promise<Buffer> {
207
+ let response: Response
208
+ try {
209
+ response = await fetch(url, {
210
+ headers: { accept: 'application/octet-stream' },
211
+ signal: AbortSignal.timeout(timeoutMs),
212
+ })
213
+ } catch (error) {
214
+ throw new Error(
215
+ `could not reach the rnx update service: ${
216
+ error instanceof Error ? error.message : String(error)
217
+ }`,
218
+ )
219
+ }
220
+ if (!response.ok) {
221
+ throw new Error(
222
+ `rnx update download failed: ${response.status} ${response.statusText}`,
223
+ )
224
+ }
225
+ return Buffer.from(await response.arrayBuffer())
226
+ }
227
+
228
+ export async function refreshCliUpdateCache(
229
+ options: { releaseOrigin?: string; now?: number; timeoutMs?: number } = {},
230
+ ): Promise<CliUpdateAvailability> {
231
+ const releaseOrigin = (options.releaseOrigin ?? DEFAULT_CLI_RELEASE_ORIGIN).replace(
232
+ /\/+$/,
233
+ '',
234
+ )
235
+ const latestVersion = (
236
+ await fetchCliReleaseFile(
237
+ `${releaseOrigin}/channels/stable`,
238
+ options.timeoutMs ?? 8_000,
239
+ )
240
+ )
241
+ .toString('utf8')
242
+ .trim()
243
+ if (!CLI_VERSION_PATTERN.test(latestVersion)) {
244
+ throw new Error(
245
+ `rnx update service returned an invalid version: ${latestVersion || '(empty)'}`,
246
+ )
247
+ }
248
+
249
+ const now = options.now ?? Date.now()
250
+ const state = readState()
251
+ const next: CliUpdateState = {
252
+ ...state,
253
+ latestVersion,
254
+ checkedAt: now,
255
+ lastAttemptAt: now,
256
+ }
257
+ writeState(next)
258
+ const update = availability(next)
259
+ if (!update) throw new Error('rnx update cache did not retain the latest version')
260
+ return update
261
+ }
@@ -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
+ }