rnxsim 0.1.432 → 0.1.434

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 (83) hide show
  1. package/README.md +20 -50
  2. package/cli/auth.ts +33 -3
  3. package/cli/cloud-client.ts +577 -99
  4. package/cli/cloud-dispatch.ts +5 -10
  5. package/cli/cloud-session.ts +5 -5
  6. package/cli/commands/auth.ts +13 -2
  7. package/cli/commands/box.ts +270 -26
  8. package/cli/commands/control.ts +40 -13
  9. package/cli/commands/device.ts +1 -1
  10. package/cli/commands/flow.ts +189 -43
  11. package/cli/commands/inspect/get-layout.ts +1 -1
  12. package/cli/commands/inspect.ts +8 -1
  13. package/cli/commands/login.ts +79 -14
  14. package/cli/commands/maestro-generate.ts +70 -54
  15. package/cli/commands/maestro.ts +69 -20
  16. package/cli/commands/platform.ts +30 -32
  17. package/cli/commands/screenshot.ts +3 -2
  18. package/cli/commands/upload.ts +28 -10
  19. package/cli/help-core.ts +11 -1
  20. package/cli/outbound-endpoints.ts +32 -5
  21. package/cli/parse-args.ts +17 -0
  22. package/cli/send-to-box.ts +112 -0
  23. package/cli/shell-init.ts +4 -4
  24. package/cli/ws-bridge.ts +109 -62
  25. package/dist-lib/agent-daemon-client.cjs +1 -1
  26. package/dist-lib/agent-events.cjs +1 -1
  27. package/dist-lib/agent-identity.cjs +1 -1
  28. package/dist-lib/agent-sessions.cjs +1 -1
  29. package/dist-lib/attached-projects.cjs +1 -1
  30. package/dist-lib/auth/shared-session.cjs +17 -4
  31. package/dist-lib/backend-origin.cjs +1 -1
  32. package/dist-lib/beta.cjs +1 -1
  33. package/dist-lib/beta.mjs +1 -1
  34. package/dist-lib/bridge-constants.cjs +1 -1
  35. package/dist-lib/bridge-contract-input.cjs +1 -1
  36. package/dist-lib/bridge-contract-input.mjs +1 -1
  37. package/dist-lib/bridge-contract.cjs +1 -1
  38. package/dist-lib/bridge-contract.mjs +1 -1
  39. package/dist-lib/capture-contract.cjs +1 -1
  40. package/dist-lib/capture-contract.mjs +1 -1
  41. package/dist-lib/cli-constants.cjs +1 -1
  42. package/dist-lib/cloud-contract.cjs +1 -1
  43. package/dist-lib/cloud-contract.mjs +1 -1
  44. package/dist-lib/cloud.cjs +108 -93
  45. package/dist-lib/cloud.mjs +106 -91
  46. package/dist-lib/config.cjs +1 -1
  47. package/dist-lib/detox/index.cjs +1 -1
  48. package/dist-lib/dev-bundle-resolution.cjs +1 -1
  49. package/dist-lib/home-paths.cjs +1 -1
  50. package/dist-lib/host/bridge-host.cjs +5324 -1371
  51. package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
  52. package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
  53. package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
  54. package/dist-lib/host/replacement-module-handler.cjs +298 -91
  55. package/dist-lib/host/websocket-proxy.cjs +1 -1
  56. package/dist-lib/index.cjs +369 -241
  57. package/dist-lib/jump-to-source-babel.cjs +1 -1
  58. package/dist-lib/menu.cjs +7 -1
  59. package/dist-lib/menu.mjs +7 -1
  60. package/dist-lib/metro-fingerprint-registry.cjs +1 -1
  61. package/dist-lib/metro-fingerprint-registry.mjs +1 -1
  62. package/dist-lib/metro-production-bundle.cjs +1 -1
  63. package/dist-lib/metro-production-bundle.mjs +1 -1
  64. package/dist-lib/metro.cjs +327 -299
  65. package/dist-lib/profiles.cjs +1 -1
  66. package/dist-lib/public-brand.cjs +1 -1
  67. package/dist-lib/react-native-host-modules.cjs +1 -1
  68. package/dist-lib/react-native-host-modules.mjs +1 -1
  69. package/dist-lib/render-mode.cjs +1 -1
  70. package/dist-lib/scripts/dev-server-scanner.cjs +1 -1
  71. package/dist-lib/sdk.cjs +1 -1
  72. package/dist-lib/sdk.mjs +1 -1
  73. package/dist-lib/skills.cjs +4849 -1199
  74. package/dist-lib/vite.cjs +298 -91
  75. package/package.json +3 -2
  76. package/src/auth/shared-session.ts +31 -0
  77. package/src/bridge-contract.ts +6 -0
  78. package/src/cloud.ts +83 -113
  79. package/src/host/bridge-host.ts +79 -0
  80. package/src/host/replacement-module-handler.ts +20 -96
  81. package/src/menu.ts +8 -0
  82. package/src/metro-plugin.ts +30 -113
  83. package/src/vite-plugin.ts +11 -2
@@ -121,7 +121,7 @@ function refuseCloudInvocation(
121
121
  ): CloudBoundaryResult {
122
122
  const verb = firstVerb(positional)
123
123
  const name = verb ? `${command} ${verb}` : command
124
- console.error(` rnx ${name} is not available for an RNX Cloud simulator`)
124
+ console.error(` rnx ${name} is not available for a remote simulator`)
125
125
  return { handled: true, code: 1 }
126
126
  }
127
127
 
@@ -129,18 +129,13 @@ export async function dispatchCloudBoundary({
129
129
  command,
130
130
  args,
131
131
  }: CloudBoundaryInvocation): Promise<CloudBoundaryResult> {
132
- if (command === 'ios' && (args.includes('--nano') || args.includes('--cloud')))
133
- return { handled: false }
134
- if (command === 'android' && (args.includes('--nano') || args.includes('--cloud'))) {
132
+ if (command === 'ios' && args.includes('--remote')) return { handled: false }
133
+ if (command === 'android' && args.includes('--remote')) {
135
134
  console.error(
136
- ' rnx android --nano is not available; RNX Cloud currently supports iOS',
135
+ ' rnx android --remote is not available; remote simulators are iOS only',
137
136
  )
138
137
  return { handled: true, code: 1 }
139
138
  }
140
- if ((command === 'ios' || command === 'android') && args.includes('--micro')) {
141
- console.error(` rnx ${command} --micro is not implemented`)
142
- return { handled: true, code: 1 }
143
- }
144
139
  if (!CLOUD_TARGET_COMMANDS.has(command)) return { handled: false }
145
140
 
146
141
  const [{ parseBridgeCliArgs }, cloudClient, cloudSession] = await Promise.all([
@@ -189,7 +184,7 @@ export async function dispatchCloudBoundary({
189
184
  const descriptor = cloudSession.requireCloudSessionShellUpdate()
190
185
  await cloudClient.closeCloudBox(session)
191
186
  cloudSession.publishCloudSessionToShell(null, descriptor)
192
- console.log(` closed: box ${session.boxId} and simulator ${session.simId}`)
187
+ console.log(` closed: remote simulator ${session.simId}`)
193
188
  return { handled: true, code: 0 }
194
189
  } catch (error) {
195
190
  console.error(
@@ -10,7 +10,7 @@ export const RNX_SHELL_UPDATE_PREFIX = '__RNX_SHELL_UPDATE_V1__'
10
10
  export const RNX_SHELL_UPDATE_SET_PREFIX = `${RNX_SHELL_UPDATE_PREFIX}set:`
11
11
  export const RNX_SHELL_UPDATE_UNSET = `${RNX_SHELL_UPDATE_PREFIX}unset`
12
12
 
13
- // the shell's target: one nano box, addressed by the box token, and the
13
+ // the shell's target: one remote host, addressed by the box token, and the
14
14
  // simulator inside it that ordinary commands reach with the simulator token
15
15
  export interface CloudSession {
16
16
  version: 2
@@ -31,7 +31,7 @@ export function normalizeCloudOrigin(source: string): string {
31
31
  try {
32
32
  parsed = new URL(source)
33
33
  } catch {
34
- throw new Error(`invalid RNX Cloud origin: ${source}`)
34
+ throw new Error(`invalid remote simulator origin: ${source}`)
35
35
  }
36
36
  if (
37
37
  (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') ||
@@ -41,10 +41,10 @@ export function normalizeCloudOrigin(source: string): string {
41
41
  parsed.search ||
42
42
  parsed.hash
43
43
  ) {
44
- throw new Error(`RNX Cloud origin must be an HTTP(S) origin: ${source}`)
44
+ throw new Error(`remote simulator origin must be an HTTP(S) origin: ${source}`)
45
45
  }
46
46
  if (parsed.protocol === 'http:' && !isLoopbackHost(parsed.hostname)) {
47
- throw new Error(`RNX Cloud origin requires HTTPS outside loopback: ${source}`)
47
+ throw new Error(`remote simulator origin requires HTTPS outside loopback: ${source}`)
48
48
  }
49
49
  return parsed.origin
50
50
  }
@@ -157,7 +157,7 @@ export function requireCloudSessionShellUpdate(): number {
157
157
  const descriptor = shellUpdateFd()
158
158
  if (descriptor === null) {
159
159
  throw new Error(
160
- 'RNX shell integration is required so this cloud session stays in the current shell; restart the shell after installing RNX',
160
+ 'RNX shell integration is required so this remote simulator stays connected to the current shell; restart the shell after installing RNX',
161
161
  )
162
162
  }
163
163
  return descriptor
@@ -1,5 +1,8 @@
1
- import { refreshSharedDesktopAuthSession } from '../../src/auth/shared-session'
2
- import { resolveCliAuth } from '../auth'
1
+ import {
2
+ refreshSharedDesktopAuthSession,
3
+ sharedDesktopAuthAccounts,
4
+ } from '../../src/auth/shared-session'
5
+ import { CHOOSE_ACCOUNT_HINT, resolveCliAuth } from '../auth'
3
6
  import { rnxExit } from '../run-rnx'
4
7
 
5
8
  function printHelp(): void {
@@ -36,7 +39,15 @@ async function printStatus(): Promise<number> {
36
39
 
37
40
  const current = await refreshSharedDesktopAuthSession(auth.origin)
38
41
  const user = current?.user
42
+ const accounts = sharedDesktopAuthAccounts(user)
43
+ const accountId = current?.accountId ?? auth.accountId
44
+ const account = accounts.find((candidate) => candidate.id === accountId)
39
45
  console.log(` ${user?.email || user?.name || user?.id || 'signed in'}`)
46
+ console.log(
47
+ ` account: ${
48
+ account ? `${account.name} (${account.id})` : (accountId ?? CHOOSE_ACCOUNT_HINT)
49
+ }`,
50
+ )
40
51
  console.log(` origin: ${current?.origin ?? auth.origin}`)
41
52
  if (current?.updatedAt) console.log(` updated: ${current.updatedAt}`)
42
53
  return 0
@@ -2,9 +2,11 @@
2
2
  //
3
3
  // bare `rnx box` is LOCAL and mounts the current checkout at /project. cloud
4
4
  // create provisions through the shared client and enters the same shell unless
5
- // --json asks for a noninteractive result. reconnect still needs an authorized
6
- // provider lookup from public box identity to the provider reference.
5
+ // --json asks for a noninteractive result. connect asks the account service to
6
+ // turn a Box name into an authorized reference and enters that same shell, so
7
+ // this process never stores a Box token.
7
8
 
9
+ import { spawn } from 'node:child_process'
8
10
  import path from 'node:path'
9
11
  import { createInterface } from 'node:readline'
10
12
  import { BoxClientError, createBoxClient } from '@rnx/box/client'
@@ -16,9 +18,16 @@ import {
16
18
  sourceControlBoxTemplate,
17
19
  terminalBoxTemplate,
18
20
  } from '@rnx/box/template'
19
- import { createCloudBoxProvider, heartbeatSession } from 'rnx-cloud-box/client'
21
+ import {
22
+ cloudBoxBrowserLiveView,
23
+ cloudBoxConnectUrl,
24
+ createCloudBoxProvider,
25
+ ensureCloudBoxBrowser,
26
+ heartbeatSession,
27
+ resolveCloudBoxReference,
28
+ } from 'rnx-cloud-box/client'
20
29
  import { rnxPublicBrand } from '../../src/public-brand'
21
- import { authHeaderOrExit } from '../auth'
30
+ import { authHeaderOrExit, cloudAccountIdOrExit, type CliAuth } from '../auth'
22
31
  import { findRepositoryPackageRoot } from '../setup-repository'
23
32
  import { resolvePlacement } from '../ws-bridge'
24
33
  import { CheckoutFilePlane, CheckoutRequiresGitError } from './box/checkout-plane'
@@ -58,6 +67,13 @@ function usage(): void {
58
67
  the box service to create it in, or set
59
68
  ${ENDPOINT_ENV}
60
69
  ${rnx} box create --json create without entering the shell
70
+ ${rnx} box connect <id> enter the shell of a cloud box you own
71
+ ${rnx} box connect <id> --json print its reference without a shell
72
+ ${rnx} box send send the running local app to a nano box
73
+ ${rnx} box send --name=<n> name the box it creates
74
+ ${rnx} box send --no-auth leave the signed-in session behind
75
+ ${rnx} box open <id> watch a cloud box in your browser
76
+ ${rnx} box open <id> --print print the viewing URL instead of opening it
61
77
  ${rnx} box ls cloud boxes running on this account
62
78
  ${rnx} box stop --name=<n> stop a cloud box and its billing
63
79
  ${rnx} box delete --name=<n> stop it and wipe its files
@@ -91,13 +107,9 @@ export async function runBox(
91
107
 
92
108
  const subcommand = args.find((arg) => !arg.startsWith('-'))
93
109
  if (subcommand === 'create') return runCloudBoxCreate(args, opts)
94
- if (subcommand === 'connect') {
95
- console.error(
96
- ` ${rnxPublicBrand.commandName} box connect: the cloud provider cannot yet resolve a Box ID or name to an authorized Box reference.\n` +
97
- ` the shared Box client requires that reference's access token, and the CLI does not persist Box tokens.`,
98
- )
99
- return 1
100
- }
110
+ if (subcommand === 'connect') return runCloudBoxConnect(args)
111
+ if (subcommand === 'send') return runBoxSend(args, opts)
112
+ if (subcommand === 'open') return runCloudBoxOpen(args)
101
113
  if (subcommand === 'stop' || subcommand === 'delete') {
102
114
  return runCloudBoxStop(args, subcommand === 'delete')
103
115
  }
@@ -130,12 +142,15 @@ function flag(args: string[], name: string): string | undefined {
130
142
  return undefined
131
143
  }
132
144
 
133
- function apiOrigin(args: string[]): string {
145
+ // the account service, which owns /api/v1/rnx/sessions and the box connect
146
+ // route. a login session names the origin it was issued by; an api key
147
+ // carries none, so it goes where every other key-authenticated command goes.
148
+ function apiOrigin(args: string[], auth: CliAuth): string {
134
149
  return (
135
150
  flag(args, 'api-origin') ??
136
151
  process.env[API_ORIGIN_ENV]?.trim() ??
137
- rnxPublicBrand.origin
138
- )
152
+ (auth.kind === 'session' ? auth.origin : 'https://contrast.dev')
153
+ ).replace(/\/+$/, '')
139
154
  }
140
155
 
141
156
  function requireEndpoint(args: string[], label: string): string | null {
@@ -149,6 +164,46 @@ function requireEndpoint(args: string[], label: string): string | null {
149
164
  return null
150
165
  }
151
166
 
167
+ // `rnx box send` — the CLI half of "send to box". the shell's rail button runs
168
+ // the same function inside the daemon; this is the same send for an agent or a
169
+ // terminal, and it prints the id and URL either way.
170
+ async function runBoxSend(args: string[], opts: BoxCommandOptions): Promise<number> {
171
+ const rnx = rnxPublicBrand.commandName
172
+ const { auth, header } = authHeaderOrExit('box send')
173
+ const accountId = cloudAccountIdOrExit(auth)
174
+ const { createBridgeFromParsed, parseBridgeCliArgs } = await import('../ws-bridge')
175
+ const parsed = parseBridgeCliArgs(
176
+ args.filter((arg) => arg !== 'send'),
177
+ {
178
+ port: opts.port,
179
+ stripBooleanFlags: ['--no-auth', '--json'],
180
+ stripValueFlags: ['--name', '--assets', '--api-origin'],
181
+ },
182
+ )
183
+ const bridge = createBridgeFromParsed(parsed)
184
+ try {
185
+ const { sendToBox } = await import('../send-to-box')
186
+ const result = await sendToBox({
187
+ send: (command) => bridge.send(command),
188
+ authorization: header,
189
+ accountId,
190
+ carryAuth: !args.includes('--no-auth'),
191
+ boxName: flag(args, 'name') ?? null,
192
+ assetsPath: flag(args, 'assets') ?? null,
193
+ apiOrigin: flag(args, 'api-origin'),
194
+ })
195
+ if (args.includes('--json')) console.log(JSON.stringify(result))
196
+ return 0
197
+ } catch (error) {
198
+ console.error(
199
+ ` ${rnx} box send: ${error instanceof Error ? error.message : String(error)}`,
200
+ )
201
+ return 1
202
+ } finally {
203
+ bridge.close()
204
+ }
205
+ }
206
+
152
207
  async function runCloudBoxStop(args: string[], purge: boolean): Promise<number> {
153
208
  const rnx = rnxPublicBrand.commandName
154
209
  const label = purge ? 'delete' : 'stop'
@@ -160,12 +215,13 @@ async function runCloudBoxStop(args: string[], purge: boolean): Promise<number>
160
215
  console.error(` ${rnx} box ${label}: which box? pass --name=<n>.`)
161
216
  return 1
162
217
  }
163
- const { header } = authHeaderOrExit(`box ${label}`)
218
+ const { auth, header } = authHeaderOrExit(`box ${label}`)
164
219
  const apiKey = header.replace(/^Bearer /, '')
220
+ const accountId = cloudAccountIdOrExit(auth)
165
221
  const client = createBoxClient(
166
222
  createCloudBoxProvider({
167
223
  endpoint,
168
- authority: { kind: 'account', apiOrigin: apiOrigin(args), apiKey },
224
+ authority: { kind: 'account', apiOrigin: apiOrigin(args, auth), apiKey, accountId },
169
225
  }),
170
226
  )
171
227
  try {
@@ -189,12 +245,13 @@ async function runCloudBoxStop(args: string[], purge: boolean): Promise<number>
189
245
 
190
246
  async function runCloudBoxList(args: string[]): Promise<number> {
191
247
  const rnx = rnxPublicBrand.commandName
192
- const { header } = authHeaderOrExit('box ls')
248
+ const { auth, header } = authHeaderOrExit('box ls')
193
249
  const apiKey = header.replace(/^Bearer /, '')
250
+ const accountId = cloudAccountIdOrExit(auth)
194
251
  const client = createBoxClient(
195
252
  createCloudBoxProvider({
196
253
  endpoint: flag(args, 'endpoint') ?? process.env[ENDPOINT_ENV]?.trim() ?? '',
197
- authority: { kind: 'account', apiOrigin: apiOrigin(args), apiKey },
254
+ authority: { kind: 'account', apiOrigin: apiOrigin(args, auth), apiKey, accountId },
198
255
  }),
199
256
  )
200
257
  try {
@@ -219,6 +276,161 @@ async function runCloudBoxList(args: string[]): Promise<number> {
219
276
  }
220
277
  }
221
278
 
279
+ async function runCloudBoxConnect(args: string[]): Promise<number> {
280
+ const rnx = rnxPublicBrand.commandName
281
+ const json = args.includes('--json')
282
+ // the first positional is `connect`; the second is the Box.
283
+ const boxId = args.filter((arg) => !arg.startsWith('-'))[1]
284
+ if (!boxId) {
285
+ console.error(
286
+ ` ${rnx} box connect: which box? pass its ID or name, as \`${rnx} box ls\` prints it.`,
287
+ )
288
+ return 1
289
+ }
290
+
291
+ // the ACCOUNT's credential. the account service is the only thing that knows
292
+ // which account a Box belongs to, so it resolves the name and hands back the
293
+ // Box's own token; nothing here writes that token down.
294
+ const { auth, header } = authHeaderOrExit('box connect')
295
+ const apiKey = header.replace(/^Bearer /, '')
296
+ const origin = apiOrigin(args, auth)
297
+
298
+ let session: BoxShellSession | null = null
299
+ let heartbeat: { stop: () => void } | null = null
300
+ try {
301
+ const reference = await resolveCloudBoxReference({ apiOrigin: origin, apiKey, boxId })
302
+ const box = await createBoxClient(
303
+ createCloudBoxProvider({
304
+ endpoint: reference.endpoint,
305
+ authority: { kind: 'attach' },
306
+ }),
307
+ ).attach({
308
+ // no stack here on purpose: the Box reports the one it was created
309
+ // with, and holding somebody else's Box to a stack this process guessed
310
+ // would offer primitives it does not actually have.
311
+ reference: { boxId: reference.boxId, accessToken: reference.token },
312
+ })
313
+ if (json) {
314
+ // the reference without its token. a caller scripting against this gets
315
+ // the Box's identity; the credential stays out of stdout and out of
316
+ // whatever collects it.
317
+ console.log(JSON.stringify({ endpoint: reference.endpoint, ...box.description }))
318
+ return 0
319
+ }
320
+ console.log(` ...connected to Box ${box.id}`)
321
+ if (box.description.pageUrl) {
322
+ console.log(` ...open ${box.description.pageUrl}`)
323
+ }
324
+ console.log(` ...accessing shell\n`)
325
+ session = await box.connect()
326
+ // the same reason `create` heartbeats: the box holds no account credential,
327
+ // so the client using it is what keeps its session alive.
328
+ const sessionId = box.description.sessionId
329
+ if (!sessionId) throw new BoxClientError('the box service returned no session id')
330
+ heartbeat = heartbeatSession({ apiOrigin: origin, apiKey, sessionId })
331
+ return await repl(session, '')
332
+ } catch (error) {
333
+ if (error instanceof BoxClientError) {
334
+ console.error(` ${rnx} box connect: ${error.message}`)
335
+ return 1
336
+ }
337
+ throw error
338
+ } finally {
339
+ heartbeat?.stop()
340
+ session?.close()
341
+ }
342
+ }
343
+
344
+ /**
345
+ * watch a cloud box in a browser.
346
+ *
347
+ * the box is already running its page in a headless Browser Run tab; this
348
+ * attaches a viewer to that same tab rather than starting a second one. the
349
+ * URL it mints is a short-lived bearer capability, so it is handed straight to
350
+ * the browser and this output is the only place it ever exists.
351
+ */
352
+ async function runCloudBoxOpen(args: string[]): Promise<number> {
353
+ const rnx = rnxPublicBrand.commandName
354
+ const json = args.includes('--json')
355
+ const print = args.includes('--print')
356
+ // the first positional is `open`; the second is the Box.
357
+ const boxId = args.filter((arg) => !arg.startsWith('-'))[1] ?? flag(args, 'name')
358
+ if (!boxId) {
359
+ console.error(
360
+ ` ${rnx} box open: which box? pass its ID or name, as \`${rnx} box ls\` prints it.`,
361
+ )
362
+ return 1
363
+ }
364
+
365
+ // the ACCOUNT's credential, for the same reason connect takes one: the
366
+ // account service is what knows which account owns this Box, and it hands
367
+ // back the Box's own token rather than this process storing one.
368
+ const { auth, header } = authHeaderOrExit('box open')
369
+ const apiKey = header.replace(/^Bearer /, '')
370
+
371
+ try {
372
+ const reference = await resolveCloudBoxReference({
373
+ apiOrigin: apiOrigin(args, auth),
374
+ apiKey,
375
+ boxId,
376
+ })
377
+ const connectUrl = cloudBoxConnectUrl(reference.endpoint, reference.boxId)
378
+ // ensure first: it is what navigates the generation's tab to the Box page,
379
+ // so a box whose browser lapsed gets a new generation with its page
380
+ // restored before anybody is looking at it.
381
+ const browser = await ensureCloudBoxBrowser({
382
+ connectUrl,
383
+ accessToken: reference.token,
384
+ })
385
+ if (!browser.reused && !json && !print) {
386
+ console.log(` ...started this box's browser`)
387
+ }
388
+ const view = await cloudBoxBrowserLiveView({
389
+ connectUrl,
390
+ accessToken: reference.token,
391
+ mode: 'tab',
392
+ })
393
+ if (json) {
394
+ console.log(
395
+ JSON.stringify({
396
+ url: view.url,
397
+ expiresAt: new Date(Date.now() + view.expiresInMs).toISOString(),
398
+ }),
399
+ )
400
+ return 0
401
+ }
402
+ if (print) {
403
+ console.log(view.url)
404
+ return 0
405
+ }
406
+ return await openInBrowser(view.url)
407
+ } catch (error) {
408
+ if (error instanceof BoxClientError) {
409
+ console.error(` ${rnx} box open: ${error.message}`)
410
+ return 1
411
+ }
412
+ throw error
413
+ }
414
+ }
415
+
416
+ /**
417
+ * hand a URL to the operating system's browser.
418
+ *
419
+ * argv rather than a shell string, because this URL is a bearer capability and
420
+ * a shell would take it through history, expansion and quoting on the way. when
421
+ * `open` cannot run it is printed instead: this process is the only place the
422
+ * URL exists, so dropping it would cost the caller another mint.
423
+ */
424
+ async function openInBrowser(url: string): Promise<number> {
425
+ const child = spawn('open', [url], { stdio: 'ignore' })
426
+ const opened = await new Promise<boolean>((resolve) => {
427
+ child.once('error', () => resolve(false))
428
+ child.once('close', (code) => resolve(code === 0))
429
+ })
430
+ if (!opened) console.log(url)
431
+ return 0
432
+ }
433
+
222
434
  async function runCloudBoxCreate(
223
435
  args: string[],
224
436
  opts: BoxCommandOptions,
@@ -270,14 +482,15 @@ async function runCloudBoxCreate(
270
482
  // the ACCOUNT's key. the box service forwards it to /v1/rnx/sessions, which
271
483
  // is what authenticates this caller, names the account, and starts the
272
484
  // session the box is billed against.
273
- const { header } = authHeaderOrExit('box create')
485
+ const { auth, header } = authHeaderOrExit('box create')
274
486
  const apiKey = header.replace(/^Bearer /, '')
487
+ const accountId = cloudAccountIdOrExit(auth)
275
488
 
276
489
  if (!json) console.log(` ...creating cloud box ${name}`)
277
490
  const client = createBoxClient(
278
491
  createCloudBoxProvider({
279
492
  endpoint,
280
- authority: { kind: 'account', apiOrigin: apiOrigin(args), apiKey },
493
+ authority: { kind: 'account', apiOrigin: apiOrigin(args, auth), apiKey, accountId },
281
494
  }),
282
495
  )
283
496
  let session: BoxShellSession | null = null
@@ -303,13 +516,16 @@ async function runCloudBoxCreate(
303
516
  return 0
304
517
  }
305
518
  console.log(` ...created Box ${box.id}`)
306
- if (box.description.stableUrl) {
307
- console.log(` ...live ${box.description.stableUrl}`)
308
- }
309
519
  console.log(` ...seeded ${box.description.files} project files`)
310
520
  // shown once and never stored: this process does not own a credential
311
521
  // store, and inventing one here would be a second place secrets live.
312
522
  console.log(` ...box token (shown once): ${box.accessToken}`)
523
+ // last, so it is the line still on screen when the shell takes over. it
524
+ // carries no credential: opening it signed in resolves the box through the
525
+ // account service.
526
+ if (box.description.pageUrl) {
527
+ console.log(` ...open ${box.description.pageUrl}`)
528
+ }
313
529
  console.log(` ...accessing shell\n`)
314
530
  session = await box.connect()
315
531
  // bank the wall clock while the shell is open. the box holds no account
@@ -318,7 +534,7 @@ async function runCloudBoxCreate(
318
534
  const sessionId = box.description.sessionId
319
535
  if (!sessionId) throw new BoxClientError('the box service returned no session id')
320
536
  heartbeat = heartbeatSession({
321
- apiOrigin: apiOrigin(args),
537
+ apiOrigin: apiOrigin(args, auth),
322
538
  apiKey,
323
539
  sessionId,
324
540
  })
@@ -473,15 +689,30 @@ async function repl(
473
689
  let inputEnded = false
474
690
  // the user typed `exit`: abandon anything still queued behind it.
475
691
  let stopped = false
692
+ // the command running right now, so Ctrl-C interrupts it rather than the CLI.
693
+ let interrupt: AbortController | null = null
476
694
 
477
695
  const run = async (command: string) => {
478
696
  if (stopped) return
697
+ const controller = new AbortController()
698
+ interrupt = controller
479
699
  try {
480
700
  await beforeCommand?.()
481
- const result = await session.exec(command)
482
- if (result.text && result.text !== '(no output)') console.log(result.text)
701
+ const result = await session.exec(command, {
702
+ signal: controller.signal,
703
+ // written as it arrives rather than collected: a terminal shows a
704
+ // command's output while it runs, and stderr keeps its own stream so
705
+ // piping the shell's stdout somewhere still separates the two.
706
+ onOutput: (stream, chunk) => {
707
+ if (stream === 'stdout') process.stdout.write(chunk)
708
+ else process.stderr.write(chunk)
709
+ },
710
+ })
711
+ if (result.exitCode !== 0) console.log(`exit code: ${result.exitCode}`)
483
712
  } catch (error) {
484
713
  console.error(error instanceof Error ? error.message : String(error))
714
+ } finally {
715
+ interrupt = null
485
716
  }
486
717
  if (!inputEnded && !stopped) prompt()
487
718
  }
@@ -489,6 +720,19 @@ async function repl(
489
720
  return new Promise<number>((resolve) => {
490
721
  prompt()
491
722
 
723
+ // Ctrl-C while a command is running interrupts the command. at an idle
724
+ // prompt there is nothing to interrupt, so it still leaves the shell,
725
+ // which is the only way out of a box that has stopped answering.
726
+ rl.on('SIGINT', () => {
727
+ if (interrupt) {
728
+ process.stdout.write('^C\n')
729
+ interrupt.abort()
730
+ return
731
+ }
732
+ stopped = true
733
+ rl.close()
734
+ })
735
+
492
736
  rl.on('line', (line) => {
493
737
  const command = line.trim()
494
738
  if (!command) {
@@ -62,11 +62,11 @@ import { rethrowIfExit, rnxExit } from '../run-rnx'
62
62
  import { rnxSelfInvocation } from '../self-invocation'
63
63
  import {
64
64
  BridgeSimLockedError,
65
- bridgePortForShellBaseUrl,
66
65
  createBridge,
67
66
  createBridgeFromParsed,
68
67
  parseBridgeCliArgs,
69
68
  printBridgeWorldNotice,
69
+ resolveBridgePortForPin,
70
70
  resolveBridgeWorld,
71
71
  worldForBridgePort,
72
72
  type BridgeSimInfo,
@@ -747,6 +747,16 @@ export interface BuildShellUrlOptions {
747
747
  device?: string
748
748
  resolvedBundle?: ResolvedDevBundle
749
749
  project?: ResolvedAppProject | null
750
+ // deterministic-capture mode, stamped onto the engine URL as ?proof=1. the
751
+ // shell worker, compositor worker and tenant worker each read it off the
752
+ // host page URL at boot (syncProofModeFromInitUrl) and pin everything that
753
+ // would otherwise differ frame to frame: the TextInput caret stops blinking
754
+ // and stays visible, and notifications stop auto-dismissing. capture-frame
755
+ // also reads the raw param and stops clipping the device corners, so a
756
+ // capture matches simctl's rectangular framebuffer. without it a
757
+ // screen holding a focused text field never reaches three identical idle
758
+ // frames, so the screenshot guard burns all 12 attempts and then fails.
759
+ proof?: boolean
750
760
  }
751
761
 
752
762
  function bundleUrlOf(urlStr: string): string | undefined {
@@ -771,9 +781,10 @@ export async function buildShellUrl(
771
781
  const project =
772
782
  opts.project ?? (await resolveAppProjectForBundleUrl(bundleUrlOf(resolved)))
773
783
  const stamped = await stampAppSplash(await stampAppFonts(resolved, project), project)
774
- if (!opts.device) return stamped
784
+ if (!opts.device && !opts.proof) return stamped
775
785
  const url = new URL(stamped)
776
- url.searchParams.set('device', opts.device)
786
+ if (opts.device) url.searchParams.set('device', opts.device)
787
+ if (opts.proof) url.searchParams.set('proof', '1')
777
788
  return url.toString()
778
789
  }
779
790
 
@@ -1417,6 +1428,7 @@ export async function runOpenCommand(
1417
1428
  '--no-hmr',
1418
1429
  '--no-describe',
1419
1430
  '--quiet',
1431
+ '--proof',
1420
1432
  ],
1421
1433
  stripValueFlags: [
1422
1434
  '--base-url',
@@ -1516,14 +1528,25 @@ export async function runOpenCommand(
1516
1528
  await ensureConfiguredRuntime(configuredRuntimeVersion)
1517
1529
  }
1518
1530
 
1519
- // keep the bridge port and the shell base URL in one world: an explicit
1520
- // --base-url without --port retargets polling at THAT world's bridge
1521
- // (otherwise open builds the page against one runtime while polling the
1522
- // other bridge for registration and times out after ~125s).
1531
+ // keep the bridge port and the shell base URL in one world: --base-url
1532
+ // retargets polling at THAT world's bridge (otherwise open builds the page
1533
+ // against one runtime while polling the other bridge for registration and
1534
+ // times out after ~125s), and a pin nothing serves is refused rather than
1535
+ // falling back to the discovered default, which used to send
1536
+ // ensureBridgeForOpen below off to start a second shell.
1523
1537
  const explicitBaseUrl = args.find((_, i) => args[i - 1] === '--base-url')
1524
- if (explicitBaseUrl && !parsed.explicitPort) {
1525
- const matched = bridgePortForShellBaseUrl(explicitBaseUrl)
1526
- if (matched) parsed.wsPort = matched
1538
+ if (explicitBaseUrl) {
1539
+ const pinned = resolveBridgePortForPin({
1540
+ baseUrl: explicitBaseUrl,
1541
+ wsPort: parsed.wsPort,
1542
+ explicitPort: parsed.explicitPort,
1543
+ })
1544
+ if (pinned.error !== undefined) {
1545
+ console.error(` error: ${pinned.error}`)
1546
+ rnxExit(1)
1547
+ } else {
1548
+ parsed.wsPort = pinned.port
1549
+ }
1527
1550
  }
1528
1551
 
1529
1552
  parsed.wsPort = await ensureBridgeForOpen(parsed.wsPort, parsed.explicitPort)
@@ -1597,6 +1620,7 @@ export async function runOpenCommand(
1597
1620
  device: deviceArg,
1598
1621
  resolvedBundle,
1599
1622
  project,
1623
+ proof: args.includes('--proof'),
1600
1624
  }
1601
1625
 
1602
1626
  // resolve the shell base URL from the bridge port we settled on, so the page
@@ -1762,9 +1786,12 @@ export async function runOpenCommand(
1762
1786
  const result = await driver.launch({
1763
1787
  url: openUrl,
1764
1788
  // the shell serves the sim page but its WS bridge may have drifted off
1765
- // the shell-port-implied default. inject the resolved bridge port so the
1766
- // page does not reconnect to a stale world.
1767
- bridgePort: bridgePortForShellBaseUrl(baseUrl) ?? parsed.wsPort,
1789
+ // the shell-port-implied default. inject the bridge this command settled
1790
+ // on so the page does not reconnect to a stale world. re-deriving it from
1791
+ // the shell URL could name a third port, since a bridge with no lockfile
1792
+ // resolves back to the default shell URL and that shell may belong to a
1793
+ // live world of its own.
1794
+ bridgePort: parsed.wsPort,
1768
1795
  headless: driverHeadless,
1769
1796
  profileId,
1770
1797
  ephemeralProfile,
@@ -36,7 +36,7 @@ usage:
36
36
 
37
37
  examples:
38
38
  rnx device
39
- rnx device set iphone-14
39
+ rnx device set iphone-16
40
40
  rnx device set iphone-16-pro-max --sim a7
41
41
  `)
42
42
  }