rnxsim 0.1.381 → 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 +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 +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 +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 +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
package/src/sim-client.ts CHANGED
@@ -334,6 +334,10 @@ export class SimClient implements WsBridge, InspectBridge {
334
334
  return openAppUrl(this, target)
335
335
  }
336
336
 
337
+ async setAppearance(appearance: 'light' | 'dark'): Promise<void> {
338
+ await this.send({ type: 'setAppearance', appearance })
339
+ }
340
+
337
341
  /**
338
342
  * block until the guest app has mounted and painted real content.
339
343
  * throws SimReadyTimeoutError (with the last probe status) on timeout.
@@ -84,7 +84,7 @@ export function rnxPlugin(options: RnxPluginOptions = {}): Plugin {
84
84
  : DEFAULT_SOOTSIM_BRIDGE_PORT
85
85
  // inject the bridge port + a default bundle (only when the URL has none,
86
86
  // so the CLI's resolved bundle wins), and PRESERVE the existing query.
87
- // preserving the query is essential: `sootsim open` stamps the open URL
87
+ // preserving the query is essential: `rnx open` stamps the open URL
88
88
  // with `inspectOpen=<token>` and matches the registered sim by that
89
89
  // token (waitForSimMatch). the old code forced `?bundle=` only and
90
90
  // stripped inspectOpen, so the sim registered but never matched and
@@ -122,7 +122,7 @@ export function rnxPlugin(options: RnxPluginOptions = {}): Plugin {
122
122
  // bundle. dev-bundle-resolution's resolvePortViaServerScan fetches
123
123
  // `/__server-scan` RELATIVE to the shell origin; when the shell is loaded
124
124
  // from this target origin, that hits here. without it the request SPA-
125
- // falls-back to 404 and the bundle never resolves (sootsim open hangs).
125
+ // falls-back to 404 and the bundle never resolves (rnx open hangs).
126
126
  // we describe only this server (the shell drives THIS app) — no localhost-
127
127
  // wide scan, so the published plugin pulls in no shell-only scanner
128
128
  // internals. shape matches DiscoveredServer enough for the resolver.
@@ -1,4 +1,4 @@
1
- // sootsim vite plugin — use in any vite project to get react-native resolution,
1
+ // rnx vite plugin — use in any vite project to get react-native resolution,
2
2
  // native dep stubbing, and external app support.
3
3
  //
4
4
  // usage:
@@ -1,294 +0,0 @@
1
- // the client half of a cloud box: create it, seed this checkout into it, and
2
- // run its shell over the box's own WebSocket.
3
- //
4
- // the box holds the files and the shell; this process holds neither. so the
5
- // REPL here sends `shell:exec` and prints what comes back, and `cwd` is
6
- // whatever the box last reported rather than anything tracked locally. that is
7
- // the whole difference between the two placements, and it is why the same
8
- // readline loop drives both.
9
- //
10
- // there is no default endpoint. a cloud box has to be deployed before one can
11
- // exist, and pointing at a URL that answers nothing would advertise a
12
- // capability that always fails.
13
-
14
- import { WebSocket } from 'ws'
15
- import type { CheckoutFilePlane } from './checkout-plane'
16
- import type { HarnessShellResult } from '@contrast/harness/shell'
17
-
18
- // a checkout is 15k files on this repo, so seeding is batched by BYTES rather
19
- // than by count: one request carrying every file would be a single multi-
20
- // hundred-megabyte body, and one request per file would be 15k round trips.
21
- const SEED_BATCH_BYTES = 4 * 1024 * 1024
22
- // base64 costs 4 bytes for every 3, and a request carries the encoded size.
23
- const BASE64_OVERHEAD = 4 / 3
24
-
25
- export interface CloudBoxSession {
26
- readonly cwd: string
27
- exec(command: string): Promise<HarnessShellResult>
28
- close(): void
29
- }
30
-
31
- export class CloudBoxError extends Error {}
32
-
33
- function endpointOf(base: string, name: string, verb: string): string {
34
- return `${base.replace(/\/+$/, '')}/v1/boxes/${encodeURIComponent(name)}/${verb}`
35
- }
36
-
37
- async function boxFetch(
38
- base: string,
39
- name: string,
40
- verb: string,
41
- token: string | null,
42
- body?: unknown,
43
- ): Promise<Record<string, unknown>> {
44
- const headers: Record<string, string> = { 'content-type': 'application/json' }
45
- if (token) headers.authorization = `Bearer ${token}`
46
- let response: Response
47
- try {
48
- response = await fetch(endpointOf(base, name, verb), {
49
- method: body === undefined ? 'GET' : 'POST',
50
- headers,
51
- body: body === undefined ? undefined : JSON.stringify(body),
52
- })
53
- } catch (error) {
54
- throw new CloudBoxError(
55
- `could not reach the box service at ${base}: ${
56
- error instanceof Error ? error.message : String(error)
57
- }`,
58
- )
59
- }
60
- const text = await response.text()
61
- const parsed = text ? JSON.parse(text) : {}
62
- if (!response.ok) {
63
- throw new CloudBoxError(
64
- String(parsed.error ?? `box ${verb} failed (${response.status})`),
65
- )
66
- }
67
- return parsed
68
- }
69
-
70
- /**
71
- * create the box and copy this checkout into it.
72
- *
73
- * `apiKey` is the ACCOUNT's key, which the box service forwards to
74
- * /v1/rnx/sessions to authenticate the caller and start the session that pays
75
- * for the box. the box's own token comes back minted by the box; a caller does
76
- * not choose it.
77
- */
78
- export async function createCloudBox(options: {
79
- endpoint: string
80
- name: string
81
- apiKey: string
82
- plane: CheckoutFilePlane
83
- onProgress?: (seeded: number, total: number) => void
84
- }): Promise<{ files: number; token: string; sessionId: string }> {
85
- const { endpoint, name, apiKey, plane } = options
86
- const created = await boxFetch(endpoint, name, 'create', apiKey, { name })
87
- const token = String(created.token ?? '')
88
- if (!token) throw new CloudBoxError('the box service returned no access token')
89
-
90
- const paths = [...plane.index().keys()].sort()
91
- let batch: [string, string][] = []
92
- let batchBytes = 0
93
- let seeded = 0
94
-
95
- const flush = async () => {
96
- if (batch.length === 0) return
97
- await boxFetch(endpoint, name, 'seed', token, { files: batch })
98
- seeded += batch.length
99
- options.onProgress?.(seeded, paths.length)
100
- batch = []
101
- batchBytes = 0
102
- }
103
-
104
- for (const path of paths) {
105
- const bytes = await plane.readBytes(path)
106
- batch.push([path, Buffer.from(bytes).toString('base64')])
107
- batchBytes += bytes.byteLength * BASE64_OVERHEAD
108
- if (batchBytes >= SEED_BATCH_BYTES) await flush()
109
- }
110
- await flush()
111
-
112
- const status = await boxFetch(endpoint, name, 'status', token)
113
- return {
114
- files: Number(status.files ?? 0),
115
- token,
116
- sessionId: String(status.sessionId ?? ''),
117
- }
118
- }
119
-
120
- /**
121
- * stop a box, or stop and wipe it.
122
- *
123
- * takes the ACCOUNT key, not the box token: it ends the session the account is
124
- * billed for, which the box deliberately cannot do on its own.
125
- */
126
- export async function stopCloudBox(options: {
127
- endpoint: string
128
- name: string
129
- apiKey: string
130
- purge?: boolean
131
- }): Promise<Record<string, unknown>> {
132
- return boxFetch(
133
- options.endpoint,
134
- options.name,
135
- options.purge ? 'delete' : 'stop',
136
- options.apiKey,
137
- {},
138
- )
139
- }
140
-
141
- /**
142
- * the account's running boxes, from the session meter rather than from the box
143
- * service. the meter already knows what this account is running and what it
144
- * cost, so a second per-account index of boxes would be a second answer to the
145
- * same question, free to drift.
146
- */
147
- export async function listCloudBoxes(options: {
148
- apiOrigin: string
149
- apiKey: string
150
- }): Promise<{ id: string; name: string | null; startedAt: string; meteredMs: number }[]> {
151
- const response = await fetch(
152
- `${options.apiOrigin.replace(/\/+$/, '')}/v1/rnx/sessions`,
153
- {
154
- headers: { authorization: `Bearer ${options.apiKey}` },
155
- },
156
- )
157
- const text = await response.text()
158
- const parsed = text ? JSON.parse(text) : {}
159
- if (!response.ok) {
160
- throw new CloudBoxError(
161
- String(parsed.message ?? parsed.error ?? 'could not list boxes'),
162
- )
163
- }
164
- const sessions = Array.isArray(parsed.sessions) ? parsed.sessions : []
165
- return sessions
166
- .filter((session: { kind?: string }) => session.kind === 'box')
167
- .map((session: Record<string, unknown>) => ({
168
- id: String(session.id),
169
- name: typeof session.name === 'string' ? session.name : null,
170
- startedAt: String(session.startedAt ?? ''),
171
- meteredMs: Number(session.meteredMs ?? 0),
172
- }))
173
- }
174
-
175
- /**
176
- * bank the wall clock while a box is open.
177
- *
178
- * the box holds no account credential and so cannot heartbeat for itself; the
179
- * client that is actually using it does. when this process dies the heartbeats
180
- * stop and the site's stale sweep closes the session at the last one, which is
181
- * why there is no second idle timer in the box.
182
- */
183
- export function heartbeatSession(options: {
184
- apiOrigin: string
185
- apiKey: string
186
- sessionId: string
187
- everyMs?: number
188
- }): { stop: () => void } {
189
- const url = `${options.apiOrigin.replace(/\/+$/, '')}/v1/rnx/sessions/${encodeURIComponent(
190
- options.sessionId,
191
- )}`
192
- const timer = setInterval(() => {
193
- void fetch(url, {
194
- method: 'POST',
195
- headers: { authorization: `Bearer ${options.apiKey}` },
196
- }).catch(() => {
197
- // a missed heartbeat is not an error worth interrupting a shell for.
198
- // the sweep closes the session at the last one that landed.
199
- })
200
- }, options.everyMs ?? 60_000)
201
- // do not hold the process open just to keep billing alive
202
- timer.unref?.()
203
- return { stop: () => clearInterval(timer) }
204
- }
205
-
206
- /** open the box's socket and return a session the REPL can drive. */
207
- export async function connectCloudBox(options: {
208
- endpoint: string
209
- name: string
210
- token: string
211
- }): Promise<CloudBoxSession> {
212
- const { endpoint, name, token } = options
213
- const url = endpointOf(endpoint, name, 'connect').replace(/^http/, 'ws')
214
- // as a subprotocol, not a header. a browser cannot set a header on a
215
- // WebSocket, and the box takes the token one way rather than one way per
216
- // kind of caller.
217
- const socket = new WebSocket(url, [`rnx-box-token.${token}`])
218
-
219
- // a persistent handler, installed before the handshake can fail. ws emits
220
- // 'error' again after a refused upgrade, and a `once` listener consumed by
221
- // the rejection leaves that second emit unhandled, which crashes the
222
- // process rather than failing the command.
223
- let socketError: string | null = null
224
- socket.on('error', (error) => {
225
- socketError = error.message
226
- })
227
- await new Promise<void>((resolve, reject) => {
228
- socket.once('open', resolve)
229
- socket.once('error', (error) => {
230
- socket.close()
231
- reject(error)
232
- })
233
- }).catch(async (error: unknown) => {
234
- // ws's handshake error text is runtime-specific: node says "Unexpected
235
- // server response: 401" and bun says "Expected 101 status code" with no
236
- // status at all. so ask the service itself why, over plain HTTP, and
237
- // report THAT. this runs only on the failure path.
238
- const reason = await boxFetch(endpoint, name, 'status', token).then(
239
- () => null,
240
- (failure: unknown) =>
241
- failure instanceof Error ? failure.message : String(failure),
242
- )
243
- const fallback = error instanceof Error ? error.message : String(error)
244
- throw new CloudBoxError(`could not open the box shell: ${reason ?? fallback}`)
245
- })
246
-
247
- let nextId = 0
248
- const waiting = new Map<number, (reply: Record<string, unknown>) => void>()
249
- socket.on('message', (data) => {
250
- let parsed: unknown
251
- try {
252
- parsed = JSON.parse(String(data))
253
- } catch {
254
- return
255
- }
256
- if (typeof parsed !== 'object' || parsed === null) return
257
- const reply = parsed as Record<string, unknown>
258
- const resolve = waiting.get(Number(reply.id))
259
- if (!resolve) return
260
- waiting.delete(Number(reply.id))
261
- resolve(reply)
262
- })
263
-
264
- // the socket dying mid-command must fail the command rather than hang the
265
- // prompt forever, so every waiter is settled when it closes.
266
- let closedReason: string | null = null
267
- const settleAll = (reason: string) => {
268
- closedReason = reason
269
- for (const [id, resolve] of waiting) resolve({ id, error: reason })
270
- waiting.clear()
271
- }
272
- socket.on('close', () => settleAll(socketError ?? 'the box closed the connection'))
273
- socket.on('error', (error) => settleAll(error.message))
274
-
275
- let cwd = '/project'
276
- return {
277
- get cwd() {
278
- return cwd
279
- },
280
- async exec(command: string): Promise<HarnessShellResult> {
281
- if (closedReason) throw new CloudBoxError(closedReason)
282
- const id = nextId++
283
- const reply = await new Promise<Record<string, unknown>>((resolve) => {
284
- waiting.set(id, resolve)
285
- socket.send(JSON.stringify({ type: 'shell:exec', id, command }))
286
- })
287
- if (reply.error) throw new CloudBoxError(String(reply.error))
288
- const result = reply.result as HarnessShellResult
289
- if (result.cwd) cwd = result.cwd
290
- return result
291
- },
292
- close: () => socket.close(),
293
- }
294
- }