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.
- package/README.md +43 -26
- package/cli/app-fonts.ts +1 -1
- package/cli/bin.ts +118 -26
- package/cli/bridge-diagnostics.ts +1 -1
- package/cli/bridge-flow-runner.ts +9 -9
- package/cli/browser-evals.ts +3 -3
- package/cli/cli-update.ts +261 -0
- package/cli/cloud-client.ts +407 -0
- package/cli/cloud-dispatch.ts +165 -0
- package/cli/cloud-session.ts +156 -0
- package/cli/command-contract.ts +2 -2
- package/cli/command-registry.ts +9 -0
- package/cli/commands/agent-wrapper.ts +4 -4
- package/cli/commands/agent.ts +3 -3
- package/cli/commands/box/checkout-plane.ts +16 -8
- package/cli/commands/box/rnx-command.ts +2 -2
- package/cli/commands/box/search-command.ts +2 -2
- package/cli/commands/box.ts +123 -70
- package/cli/commands/config.ts +3 -2
- package/cli/commands/control.ts +6 -6
- package/cli/commands/cpu-profile.ts +2 -2
- package/cli/commands/daemon.ts +7 -7
- package/cli/commands/debug.ts +2 -2
- package/cli/commands/detox.ts +7 -7
- package/cli/commands/diagnose.ts +1 -1
- package/cli/commands/electron.ts +1 -1
- package/cli/commands/film.ts +1 -1
- package/cli/commands/flow.ts +4 -4
- package/cli/commands/inspect/core.ts +58 -7
- package/cli/commands/inspect/env.ts +5 -3
- package/cli/commands/inspect/shared.ts +2 -1
- package/cli/commands/inspect/wait-event.ts +1 -1
- package/cli/commands/inspect/wait-ready.ts +15 -5
- package/cli/commands/inspect.ts +31 -9
- package/cli/commands/install-desktop.ts +2 -2
- package/cli/commands/maestro.ts +3 -3
- package/cli/commands/permissions.ts +1 -1
- package/cli/commands/platform.ts +91 -0
- package/cli/commands/record.ts +3 -3
- package/cli/commands/report-issue.ts +1 -1
- package/cli/commands/runtime.ts +1 -1
- package/cli/commands/screenshot-mode.ts +1 -1
- package/cli/commands/screenshot.ts +25 -0
- package/cli/commands/serve.ts +1 -1
- package/cli/commands/skills.ts +1 -1
- package/cli/commands/timeline.ts +1 -1
- package/cli/commands/upgrade.ts +8 -34
- package/cli/commands/upload.ts +3 -3
- package/cli/commands/version.ts +14 -3
- package/cli/commands/what-happened.ts +2 -2
- package/cli/current-sim.ts +43 -30
- package/cli/desktop-companion.ts +1 -1
- package/cli/drivers/electron.ts +1 -1
- package/cli/drivers/playwright.ts +3 -3
- package/cli/drivers/types.ts +4 -4
- package/cli/flow-file.ts +3 -3
- package/cli/help-core.ts +18 -11
- package/cli/hidden-runtime-alias.ts +1 -1
- package/cli/hints.ts +2 -2
- package/cli/maestro-js.ts +2 -2
- package/cli/outbound-endpoints.ts +40 -6
- package/cli/parse-args.ts +1 -1
- package/cli/registry.ts +1 -1
- package/cli/resolve-assets.ts +2 -2
- package/cli/secure-file.ts +31 -0
- package/cli/shell-init.ts +98 -0
- package/cli/ws-bridge.ts +18 -8
- package/detox/expectations.ts +1 -1
- package/detox/gestures.ts +3 -3
- package/detox/index.ts +3 -3
- package/dist-lib/agent-daemon-client.cjs +1 -1
- package/dist-lib/agent-events.cjs +1 -1
- package/dist-lib/agent-identity.cjs +1 -1
- package/dist-lib/agent-sessions.cjs +1 -1
- package/dist-lib/attached-projects.cjs +1 -1
- package/dist-lib/auth/shared-session.cjs +1 -1
- package/dist-lib/backend-origin.cjs +1 -1
- package/dist-lib/beta.cjs +1 -1
- package/dist-lib/beta.mjs +1 -1
- package/dist-lib/bridge-constants.cjs +1 -1
- package/dist-lib/bridge-contract-input.cjs +15 -8
- package/dist-lib/bridge-contract-input.mjs +15 -8
- package/dist-lib/bridge-contract.cjs +1 -1
- package/dist-lib/bridge-contract.mjs +1 -1
- package/dist-lib/capture-contract.cjs +107 -0
- package/dist-lib/capture-contract.mjs +80 -0
- package/dist-lib/cli-constants.cjs +1 -1
- package/dist-lib/cloud-contract.cjs +32 -0
- package/dist-lib/cloud-contract.mjs +7 -0
- package/dist-lib/config.cjs +1 -1
- package/dist-lib/detox/index.cjs +4 -4
- package/dist-lib/dev-bundle-resolution.cjs +1 -1
- package/dist-lib/home-paths.cjs +9 -1
- package/dist-lib/host/bridge-host.cjs +15 -10
- package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
- package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
- package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
- package/dist-lib/host/replacement-module-handler.cjs +1 -1
- package/dist-lib/host/websocket-proxy.cjs +1 -1
- package/dist-lib/index.cjs +248 -73
- package/dist-lib/jump-to-source-babel.cjs +1 -1
- package/dist-lib/menu.cjs +1 -1
- package/dist-lib/menu.mjs +1 -1
- package/dist-lib/metro-production-bundle.cjs +1 -1
- package/dist-lib/metro-production-bundle.mjs +1 -1
- package/dist-lib/metro.cjs +1 -1
- package/dist-lib/profiles.cjs +1 -1
- package/dist-lib/public-brand.cjs +1 -1
- package/dist-lib/react-native-host-modules.cjs +1 -1
- package/dist-lib/react-native-host-modules.mjs +1 -1
- package/dist-lib/render-mode.cjs +1 -1
- package/dist-lib/scripts/dev-server-scanner.cjs +1 -1
- package/dist-lib/sdk.cjs +44 -6
- package/dist-lib/sdk.mjs +44 -6
- package/dist-lib/skills.cjs +435 -155
- package/dist-lib/vite.cjs +1 -1
- package/package.json +21 -5
- package/scripts/dev-server-scanner.ts +3 -3
- package/skills/contrast/SKILL.md +1 -1
- package/skills/rnx-perf/SKILL.md +68 -52
- package/src/app-url.ts +2 -3
- package/src/bridge-contract-input.ts +17 -6
- package/src/bridge-contract.ts +25 -4
- package/src/capture-contract.ts +423 -0
- package/src/cloud-contract.ts +15 -0
- package/src/home-paths.ts +9 -3
- package/src/host/agent-host.ts +1 -1
- package/src/host/bridge-host.ts +13 -11
- package/src/host/plane-host.ts +2 -2
- package/src/index.ts +21 -0
- package/src/native-dev-bundle-url.ts +2 -2
- package/src/screenshots/orchestrate.ts +1 -1
- package/src/screenshots/org-deck.ts +1 -1
- package/src/settings-bundle.ts +1 -1
- package/src/sim-client.ts +4 -0
- package/src/vite-plugin-one.ts +2 -2
- package/src/vite-plugin.ts +1 -1
- 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
|
+
}
|