rnxsim 0.1.431 → 0.1.433

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 (78) hide show
  1. package/README.md +20 -50
  2. package/cli/auth.ts +33 -3
  3. package/cli/cloud-client.ts +524 -88
  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 +189 -20
  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 +27 -5
  21. package/cli/parse-args.ts +17 -0
  22. package/cli/shell-init.ts +4 -4
  23. package/cli/ws-bridge.ts +109 -62
  24. package/dist-lib/agent-daemon-client.cjs +1 -1
  25. package/dist-lib/agent-events.cjs +1 -1
  26. package/dist-lib/agent-identity.cjs +1 -1
  27. package/dist-lib/agent-sessions.cjs +1 -1
  28. package/dist-lib/attached-projects.cjs +1 -1
  29. package/dist-lib/auth/shared-session.cjs +17 -4
  30. package/dist-lib/backend-origin.cjs +1 -1
  31. package/dist-lib/beta.cjs +1 -1
  32. package/dist-lib/beta.mjs +1 -1
  33. package/dist-lib/bridge-constants.cjs +1 -1
  34. package/dist-lib/bridge-contract-input.cjs +1 -1
  35. package/dist-lib/bridge-contract-input.mjs +1 -1
  36. package/dist-lib/bridge-contract.cjs +1 -1
  37. package/dist-lib/bridge-contract.mjs +1 -1
  38. package/dist-lib/capture-contract.cjs +1 -1
  39. package/dist-lib/capture-contract.mjs +1 -1
  40. package/dist-lib/cli-constants.cjs +1 -1
  41. package/dist-lib/cloud-contract.cjs +1 -1
  42. package/dist-lib/cloud-contract.mjs +1 -1
  43. package/dist-lib/cloud.cjs +108 -93
  44. package/dist-lib/cloud.mjs +106 -91
  45. package/dist-lib/config.cjs +1 -1
  46. package/dist-lib/detox/index.cjs +1 -1
  47. package/dist-lib/dev-bundle-resolution.cjs +1 -1
  48. package/dist-lib/home-paths.cjs +1 -1
  49. package/dist-lib/host/bridge-host.cjs +1 -1
  50. package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
  51. package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
  52. package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
  53. package/dist-lib/host/replacement-module-handler.cjs +1 -1
  54. package/dist-lib/host/websocket-proxy.cjs +1 -1
  55. package/dist-lib/index.cjs +58 -139
  56. package/dist-lib/jump-to-source-babel.cjs +1 -1
  57. package/dist-lib/menu.cjs +1 -1
  58. package/dist-lib/menu.mjs +1 -1
  59. package/dist-lib/metro-fingerprint-registry.cjs +1 -1
  60. package/dist-lib/metro-fingerprint-registry.mjs +1 -1
  61. package/dist-lib/metro-production-bundle.cjs +1 -1
  62. package/dist-lib/metro-production-bundle.mjs +1 -1
  63. package/dist-lib/metro.cjs +33 -212
  64. package/dist-lib/profiles.cjs +1 -1
  65. package/dist-lib/public-brand.cjs +1 -1
  66. package/dist-lib/react-native-host-modules.cjs +1 -1
  67. package/dist-lib/react-native-host-modules.mjs +1 -1
  68. package/dist-lib/render-mode.cjs +1 -1
  69. package/dist-lib/scripts/dev-server-scanner.cjs +1 -1
  70. package/dist-lib/sdk.cjs +1 -1
  71. package/dist-lib/sdk.mjs +1 -1
  72. package/dist-lib/skills.cjs +436 -351
  73. package/dist-lib/vite.cjs +1 -1
  74. package/package.json +2 -1
  75. package/src/auth/shared-session.ts +31 -0
  76. package/src/cloud.ts +83 -113
  77. package/src/metro-plugin.ts +30 -113
  78. package/src/vite-plugin.ts +11 -2
@@ -24,8 +24,15 @@ import {
24
24
  keepFlowCandidate,
25
25
  startFlowSession,
26
26
  } from '../flow-session'
27
+ import { GLOBAL_FLAG_TAKES_VALUE } from '../parse-args'
27
28
  import { rnxExit } from '../run-rnx'
28
- import { createBridge, createBridgeFromParsed, parseBridgeCliArgs } from '../ws-bridge'
29
+ import {
30
+ BRIDGE_VALUE_FLAGS,
31
+ createBridge,
32
+ createBridgeFromParsed,
33
+ parseBridgeCliArgs,
34
+ resolveBridgePortForPin,
35
+ } from '../ws-bridge'
29
36
  import {
30
37
  buildShellUrl,
31
38
  closeSimsBulk,
@@ -39,6 +46,110 @@ import type { UploadResult } from './upload'
39
46
 
40
47
  export { parseFlowFile, validateFlowFile } from '../flow-file'
41
48
 
49
+ // the complete flag vocabulary of `runFlowPlayback`. it is both the strip list
50
+ // handed to parseBridgeCliArgs and the vocabulary the unknown-argument check
51
+ // enforces, so a flag added to the runner and left out of here gets rejected
52
+ // rather than silently dropped.
53
+ const FLOW_BOOLEAN_FLAGS = [
54
+ '--record',
55
+ '--profile',
56
+ '--electron',
57
+ '--new',
58
+ '--preview',
59
+ '--preview-open',
60
+ '--headless',
61
+ '--headed',
62
+ '--proof',
63
+ '--no-shell',
64
+ '--shell-only',
65
+ ]
66
+
67
+ const FLOW_VALUE_FLAGS = [
68
+ '--out',
69
+ '--base-url',
70
+ '--device',
71
+ '--theme',
72
+ '--slow',
73
+ '--tail-wait',
74
+ '--url',
75
+ '--replace',
76
+ '--remap',
77
+ '--screenshots',
78
+ '--screenshot-paths',
79
+ '--preview-origin',
80
+ '--preview-public-origin',
81
+ '--driver',
82
+ '--billing-kind',
83
+ '--owner',
84
+ '--repo',
85
+ ]
86
+
87
+ // the strip lists parseBridgeCliArgs needs to see a flow argv the way the
88
+ // runner does. exported so a command that pre-parses argv before handing it to
89
+ // runFlowPlayback reads the same vocabulary instead of keeping its own copy.
90
+ export const FLOW_BRIDGE_ARG_OPTIONS = {
91
+ stripBooleanFlags: FLOW_BOOLEAN_FLAGS,
92
+ stripValueFlags: FLOW_VALUE_FLAGS,
93
+ }
94
+
95
+ // every flag that swallows the token after it on a flow argv. a caller picking
96
+ // the positional flow path out of raw argv has to skip those tokens, or
97
+ // `maestro test --sim a2 login.yaml` resolves `a2` as the flow file.
98
+ export const FLOW_ARG_VALUE_FLAGS = [...FLOW_VALUE_FLAGS, ...BRIDGE_VALUE_FLAGS]
99
+
100
+ export interface UnexpectedFlowArg {
101
+ arg: string
102
+ message: string
103
+ }
104
+
105
+ // runFlowPlayback is the end of the line for argv: everything below it is built
106
+ // from named values, never forwarded raw. so an argument it does not read is
107
+ // read by nobody, and dropping one is how a run reports success while doing
108
+ // something other than what was asked — a stale build took --proof, discarded
109
+ // it, and produced non-proof screenshots under a proof-mode label.
110
+ //
111
+ // parseBridgeCliArgs already removes every flag this command understands and
112
+ // every flag the bridge owns, each with its value, so whatever it leaves behind
113
+ // past the flow path at args[0] is an argument nothing here consumes.
114
+ export function findUnexpectedFlowArgs(args: string[]): UnexpectedFlowArg[] {
115
+ const known = new Set([...FLOW_BOOLEAN_FLAGS, ...FLOW_VALUE_FLAGS])
116
+ const problems: UnexpectedFlowArg[] = parseBridgeCliArgs(args, FLOW_BRIDGE_ARG_OPTIONS)
117
+ .positional.slice(1)
118
+ .map((arg) => {
119
+ const eq = arg.indexOf('=')
120
+ const head = eq > 0 ? arg.slice(0, eq) : ''
121
+ if (known.has(head)) {
122
+ return {
123
+ arg,
124
+ message: `${head} takes its value as a separate argument: ${head} ${arg.slice(eq + 1)}`,
125
+ }
126
+ }
127
+ const globalTakesValue = GLOBAL_FLAG_TAKES_VALUE.get(arg)
128
+ if (globalTakesValue !== undefined) {
129
+ return {
130
+ arg,
131
+ message: `${arg} is a global rnx flag and goes before the command: rnx ${arg}${
132
+ globalTakesValue ? ' <value>' : ''
133
+ } <command>`,
134
+ }
135
+ }
136
+ if (arg.startsWith('-')) return { arg, message: `unknown flag: ${arg}` }
137
+ return { arg, message: `unexpected argument: ${arg}` }
138
+ })
139
+
140
+ // a value flag left without a value keeps the runner's default instead of
141
+ // what the caller meant to set, which is the same silent substitution.
142
+ for (let i = 0; i < args.length; i++) {
143
+ if (!FLOW_VALUE_FLAGS.includes(args[i])) continue
144
+ const value = args[i + 1]
145
+ if (value === undefined || value.startsWith('-')) {
146
+ problems.push({ arg: args[i], message: `${args[i]} expects a value` })
147
+ }
148
+ i++
149
+ }
150
+ return problems
151
+ }
152
+
42
153
  let lastPreviewUploadResult: UploadResult | null = null
43
154
 
44
155
  export function getLastFlowPreviewUploadResult(): UploadResult | null {
@@ -112,11 +223,17 @@ options:
112
223
  --profile capture perf stats while the flow runs
113
224
  --out <dir> output directory (default: /tmp/rnx-recordings)
114
225
  --device <model> override the flow frontmatter device for this run
226
+ --theme <scheme> override the flow frontmatter theme (light or dark) for
227
+ this run
115
228
  --slow <ms> delay between steps for natural pacing
116
229
  --tail-wait <ms> hold the recording open after the last step so the
117
230
  final UI state is captured (default: smart idle up to
118
231
  6000ms for --preview, fixed 2000ms for --record, 0 otherwise)
119
232
  --url <url> load this target before running the flow
233
+ --base-url <url> run the flow against the shell at this URL instead of
234
+ the discovered one, and poll that shell's own bridge.
235
+ errors when no live rnx world serves it, so a pinned run
236
+ can never quietly measure the default shell instead
120
237
  --screenshot-paths <mode>
121
238
  screenshot path mode: dir (default) or flow
122
239
  --no-shell capture every screenshot tenant-only (no status bar,
@@ -134,6 +251,14 @@ options:
134
251
  --new; overrides --headless). use when you want to
135
252
  watch the flow run live even if the current sim is
136
253
  headless or stale
254
+ --proof deterministic-capture mode: pin the TextInput caret
255
+ visible instead of blinking, stop notifications
256
+ auto-dismissing, and leave the device corners unclipped
257
+ to match a native simctl framebuffer. a screen holding a
258
+ focused text field cannot reach three identical idle
259
+ frames without this, so its screenshot fails after 12
260
+ tries. use it for any run whose screenshots get
261
+ compared to anything
137
262
  --electron prefer opening the target in the desktop companion
138
263
  --sim <id> target a specific current sim
139
264
  --validate replay the drafted flow before clearing it
@@ -152,7 +277,7 @@ options:
152
277
 
153
278
  frontmatter (top of flow.yaml between --- markers):
154
279
  app: 8081 load this target before the flow starts
155
- device: iphone-14 set the live device model for this flow run
280
+ device: iphone-16 set the live device model for this flow run
156
281
  electron: true prefer the desktop companion sim for this flow
157
282
 
158
283
  default runtime:
@@ -180,6 +305,15 @@ flow extension:
180
305
  rnxExit(0)
181
306
  }
182
307
 
308
+ const unexpected = findUnexpectedFlowArgs(args)
309
+ if (unexpected.length > 0) {
310
+ for (const problem of unexpected) {
311
+ console.error(` error: ${problem.message}`)
312
+ }
313
+ console.error('\n run `rnx maestro --help` for the flags this command accepts')
314
+ rnxExit(1)
315
+ }
316
+
183
317
  const flowPath = args[0]
184
318
  if (!fs.existsSync(flowPath)) {
185
319
  console.error(` error: ${flowPath} not found`)
@@ -191,6 +325,10 @@ flow extension:
191
325
  // --headed forces a fresh visible window: implies --new and overrides
192
326
  // --headless (you asked to watch it run).
193
327
  const headed = args.includes('--headed')
328
+ // deterministic-capture mode. pins everything that would otherwise change
329
+ // between two frames of an idle screen, so `takeScreenshot` reaches its
330
+ // three identical frames on the first try instead of hunting for them.
331
+ const proofMode = args.includes('--proof')
194
332
  const openInNewSim = args.includes('--new') || headed
195
333
  const outDir = getFlag('--out') || '/tmp/rnx-recordings'
196
334
  // when --screenshots isn't explicitly set, default to the --out directory.
@@ -214,6 +352,12 @@ flow extension:
214
352
  ? 'shell'
215
353
  : undefined
216
354
  const deviceOverride = getFlag('--device')?.trim() || ''
355
+ // --device and --theme are also global flags, but a global sets the settings
356
+ // store inside THIS process and the sim renders in the shell's browser page,
357
+ // so `rnx --theme light maestro test` reaches nothing. both have to be read
358
+ // here and pushed over the bridge, and they are read the same way so a caller
359
+ // that gets one right cannot get the other silently ignored.
360
+ const themeOverride = getFlag('--theme')?.trim() || ''
217
361
  const slow = args.includes('--slow') ? +(getFlag('--slow') || '500') : 0
218
362
  const cliElectron = args.includes('--electron')
219
363
  const preview = args.includes('--preview')
@@ -275,7 +419,7 @@ flow extension:
275
419
  rnxExit(1)
276
420
  }
277
421
  const targetDevice = deviceOverride || frontmatter.device || ''
278
- const targetTheme = frontmatter.theme || ''
422
+ const targetTheme = themeOverride || frontmatter.theme || ''
279
423
 
280
424
  const wantElectron = cliElectron || frontmatter.electron === true
281
425
  const rawTargetApp =
@@ -289,37 +433,29 @@ flow extension:
289
433
  const targetAppResolution = await resolveAppFrontmatterTarget(rawTargetApp)
290
434
  const targetApp = targetAppResolution.target
291
435
 
292
- const bridgeArgOptions = {
293
- stripBooleanFlags: [
294
- '--record',
295
- '--profile',
296
- '--electron',
297
- '--new',
298
- '--preview',
299
- '--preview-open',
300
- '--headless',
301
- '--headed',
302
- ],
303
- stripValueFlags: [
304
- '--out',
305
- '--device',
306
- '--slow',
307
- '--tail-wait',
308
- '--url',
309
- '--replace',
310
- '--remap',
311
- '--screenshots',
312
- '--screenshot-paths',
313
- '--preview-origin',
314
- '--preview-public-origin',
315
- '--driver',
316
- '--billing-kind',
317
- '--owner',
318
- '--repo',
319
- ],
320
- }
321
- const parsedBridgeArgs = parseBridgeCliArgs(args, bridgeArgOptions)
436
+ const parsedBridgeArgs = parseBridgeCliArgs(args, FLOW_BRIDGE_ARG_OPTIONS)
322
437
  const url = getFlag('--url') || ''
438
+
439
+ // --base-url pins the run to one shell, so a caller can build an engine from
440
+ // a known tree, serve it, and measure that build instead of whatever the
441
+ // discovered shell happens to hold. the page and the bridge have to stay in
442
+ // one world: without this retarget the flow would open the pinned shell and
443
+ // then poll the default world's bridge, waiting out its timeout with no sign
444
+ // of which shell it actually ran against.
445
+ const pinnedShellBaseUrl = getFlag('--base-url') || ''
446
+ if (pinnedShellBaseUrl) {
447
+ const pinned = resolveBridgePortForPin({
448
+ baseUrl: pinnedShellBaseUrl,
449
+ wsPort: parsedBridgeArgs.wsPort,
450
+ explicitPort: parsedBridgeArgs.explicitPort,
451
+ })
452
+ if (pinned.error !== undefined) {
453
+ console.error(` error: ${pinned.error}`)
454
+ rnxExit(1)
455
+ } else {
456
+ parsedBridgeArgs.wsPort = pinned.port
457
+ }
458
+ }
323
459
  if (wantElectron && !findDesktopCompanion()) {
324
460
  console.error(
325
461
  ' error: desktop companion not found. install or build it first with `bun run build:electron`',
@@ -338,7 +474,7 @@ flow extension:
338
474
  const effectiveOpenInNewSim =
339
475
  openInNewSim || (wantElectron && parsedBridgeArgs.simIdSource !== 'flag')
340
476
  const driverFlag = getFlag('--driver') || ''
341
- let closeManagedDriverSimAfterRun = false
477
+ let closeFlowOwnedSimAfterRun = false
342
478
  const ownedSimIds = new Set<string>()
343
479
 
344
480
  // if we weren't told exactly where to go, and there's already a primary
@@ -379,16 +515,19 @@ flow extension:
379
515
 
380
516
  if (openTarget && targetDevice) {
381
517
  const shellBaseUrl = applyRNXConfigToUrl(
382
- resolveShellBaseUrlForBridgePort(parsedBridgeArgs.wsPort),
518
+ pinnedShellBaseUrl || resolveShellBaseUrlForBridgePort(parsedBridgeArgs.wsPort),
383
519
  targetAppResolution.runtimeConfig,
384
520
  )
385
- const url = new URL(await buildShellUrl(openTarget, shellBaseUrl))
521
+ const url = new URL(
522
+ await buildShellUrl(openTarget, shellBaseUrl, { proof: proofMode }),
523
+ )
386
524
  url.searchParams.set('device', targetDevice)
387
525
  openTarget = url.toString()
388
526
  }
389
527
 
390
528
  if (openTarget) {
391
529
  const openArgs = [openTarget]
530
+ if (pinnedShellBaseUrl) openArgs.push('--base-url', pinnedShellBaseUrl)
392
531
  openArgs.push(...replaceArgs)
393
532
  openArgs.push(...remapArgs)
394
533
  if (effectiveOpenInNewSim) {
@@ -400,23 +539,30 @@ flow extension:
400
539
  }
401
540
  if (driverFlag) {
402
541
  openArgs.push('--driver', driverFlag)
403
- closeManagedDriverSimAfterRun = true
404
542
  }
405
543
  if (args.includes('--headless') && !headed) {
406
544
  openArgs.push('--headless')
407
545
  }
546
+ if (proofMode) openArgs.push('--proof')
408
547
  await runOpenCommand(openArgs, {
409
548
  port: parsedBridgeArgs.wsPort,
410
549
  timeoutMs: parsedBridgeArgs.commandTimeoutMs,
411
550
  runtimeConfig: targetAppResolution.runtimeConfig,
412
551
  })
413
- if (closeManagedDriverSimAfterRun) {
552
+ // a flow that opened its own sim owns it, and owning it means closing it.
553
+ // this used to be claimed only for `--driver` runs, so a `--new` run
554
+ // stranded its browser: the detached host's watchdog polls the *top*
555
+ // ancestor (getStableOwnerPid -> findTopAncestor), which is the shell or
556
+ // agent session, not the run. that pid lives for hours, so the watchdog
557
+ // stayed happy and every flow left one more Chrome behind.
558
+ closeFlowOwnedSimAfterRun = effectiveOpenInNewSim || !!driverFlag
559
+ if (closeFlowOwnedSimAfterRun) {
414
560
  const openedSimId = readCurrentSimId()
415
561
  if (openedSimId) ownedSimIds.add(openedSimId)
416
562
  }
417
563
  }
418
564
 
419
- const resolvedBridgeArgs = parseBridgeCliArgs(args, bridgeArgOptions)
565
+ const resolvedBridgeArgs = parseBridgeCliArgs(args, FLOW_BRIDGE_ARG_OPTIONS)
420
566
  const pinnedSimId =
421
567
  resolvedBridgeArgs.simIdSource === 'flag'
422
568
  ? resolvedBridgeArgs.simId
@@ -432,7 +578,7 @@ flow extension:
432
578
  simIdSource: pinnedSimIdSource,
433
579
  })
434
580
  let driver: SootSimBridgeFlowRunner
435
- const applyFlowFrontmatter = async () => {
581
+ const applySimSettings = async () => {
436
582
  const statusBarTime = frontmatter.env?.RNX_STATUS_BAR_TIME
437
583
  if (statusBarTime) {
438
584
  if (!/^(?:[1-9]|1[0-2]):[0-5][0-9]$/.test(statusBarTime)) {
@@ -483,7 +629,7 @@ flow extension:
483
629
  // recorder lives in-page state and gets wiped by window.location.reload.
484
630
  onAfterLaunch: async (simId) => {
485
631
  if (simId) saveCurrentSimId(simId)
486
- const deviceChanged = await applyFlowFrontmatter()
632
+ const deviceChanged = await applySimSettings()
487
633
  if (deviceChanged) await driver.waitForTree(120000)
488
634
  if (!preview) return
489
635
  await bridge.send({
@@ -526,7 +672,7 @@ flow extension:
526
672
  if (!startsWithLaunchApp) {
527
673
  await driver.waitForTree(120000)
528
674
  }
529
- const deviceChanged = await applyFlowFrontmatter()
675
+ const deviceChanged = await applySimSettings()
530
676
  if (deviceChanged && !startsWithLaunchApp) await driver.waitForTree(120000)
531
677
  // preview replay must reproduce the SAME initial conditions the agent
532
678
  // explored from. the explore session runs on a fresh ephemeral profile
@@ -783,7 +929,7 @@ flow extension:
783
929
 
784
930
  bridge.close()
785
931
 
786
- if (closeManagedDriverSimAfterRun) {
932
+ if (closeFlowOwnedSimAfterRun) {
787
933
  if (driver.simId) ownedSimIds.add(driver.simId)
788
934
  await closeFlowOwnedSims(parsedBridgeArgs.wsPort, parsedBridgeArgs.commandTimeoutMs, [
789
935
  ...ownedSimIds,
@@ -33,7 +33,7 @@ export async function runGetLayoutSubcommand(opts: {
33
33
  error.message === 'unsupported RNX Cloud command type: evaluate'
34
34
  ) {
35
35
  throw new Error(
36
- 'get layout --styling is unavailable in RNX Cloud; rerun without --styling or use a local sim',
36
+ 'get layout --styling is unavailable on a remote simulator; rerun without --styling or use a local sim',
37
37
  )
38
38
  }
39
39
  throw error
@@ -436,7 +436,9 @@ function printRenderProfile(worker: string, rp: Record<string, any> | undefined)
436
436
  const recordCauses = [
437
437
  rp.boundaryRecordsInvalidated ? `invalidated ${rp.boundaryRecordsInvalidated}` : '',
438
438
  rp.boundaryRecordsOrigin ? `moved ${rp.boundaryRecordsOrigin}` : '',
439
- rp.boundaryRecordsScheme ? `first-record ${rp.boundaryRecordsScheme}` : '',
439
+ rp.boundaryRecordsFirst ? `first-record ${rp.boundaryRecordsFirst}` : '',
440
+ rp.boundaryRecordsScheme ? `scheme ${rp.boundaryRecordsScheme}` : '',
441
+ rp.boundaryRecordsFont ? `late-font ${rp.boundaryRecordsFont}` : '',
440
442
  ]
441
443
  .filter(Boolean)
442
444
  .join(' · ')
@@ -472,6 +474,11 @@ function printRenderProfile(worker: string, rp: Record<string, any> | undefined)
472
474
  )
473
475
  }
474
476
  }
477
+ if (rp.paragraphBuilds || rp.paragraphLayouts) {
478
+ console.log(
479
+ ` text shaping: ${rp.paragraphBuilds} shaped ${Number(rp.paragraphBuildMs ?? 0).toFixed(1)}ms · ${rp.paragraphLayouts} re-broken ${Number(rp.paragraphLayoutMs ?? 0).toFixed(1)}ms`,
480
+ )
481
+ }
475
482
  console.log(` blur: ${rp.avgBlurMs}ms`)
476
483
  if (rp.glassDownsampleActive || rp.glassBackdropCacheMisses) {
477
484
  console.log(
@@ -1,9 +1,12 @@
1
1
  import { exec } from 'node:child_process'
2
2
  import { randomBytes } from 'node:crypto'
3
3
  import http from 'node:http'
4
+ import { createInterface } from 'node:readline/promises'
4
5
  import {
5
6
  refreshSharedDesktopAuthSession,
7
+ sharedDesktopAuthAccounts,
6
8
  writeSharedDesktopAuthSession,
9
+ type SharedDesktopAuthSession,
7
10
  } from '../../src/auth/shared-session'
8
11
  import { rnxPublicBrand } from '../../src/public-brand'
9
12
  import { rnxExit } from '../run-rnx'
@@ -26,15 +29,80 @@ function printHelp() {
26
29
  rnx login — sign in so uploads can attach to your account
27
30
 
28
31
  usage:
29
- rnx login [--origin <url>]
32
+ rnx login [--origin <url>] [--account <name-or-id>]
30
33
 
31
34
  options:
32
- --origin <url> auth host (default: ${REMOTE_DEFAULT_ORIGIN})
33
- override with RNX_AUTH_ORIGIN env var
35
+ --origin <url> auth host (default: ${REMOTE_DEFAULT_ORIGIN})
36
+ override with RNX_AUTH_ORIGIN env var
37
+ --account <name-or-id> the account remote simulators and boxes bill to.
38
+ one account is chosen for you; several ask.
34
39
  -h, --help
35
40
  `)
36
41
  }
37
42
 
43
+ // a session token names the person, not an account. the account that pays for
44
+ // cloud work is chosen here, once, and stored on the session; a person with one
45
+ // account never sees the question.
46
+ async function chooseAccount(
47
+ session: SharedDesktopAuthSession,
48
+ requested: string | undefined,
49
+ ): Promise<{ id: string; name: string } | null> {
50
+ const accounts = sharedDesktopAuthAccounts(session.user)
51
+ if (requested !== undefined) {
52
+ const wanted = requested.trim().toLowerCase()
53
+ const match = accounts.find(
54
+ (account) =>
55
+ account.id === requested.trim() || account.name.toLowerCase() === wanted,
56
+ )
57
+ if (!match) {
58
+ console.error(
59
+ ` no account named ${JSON.stringify(requested)}. your accounts:\n` +
60
+ accounts.map((account) => ` ${account.name} (${account.id})`).join('\n'),
61
+ )
62
+ rnxExit(1)
63
+ }
64
+ return match
65
+ }
66
+ if (accounts.length <= 1) return accounts[0] ?? null
67
+ if (!process.stdin.isTTY) {
68
+ console.error(
69
+ ' this sign-in belongs to several accounts; rerun with --account <name> to choose the one to bill:\n' +
70
+ accounts.map((account) => ` ${account.name} (${account.id})`).join('\n'),
71
+ )
72
+ rnxExit(1)
73
+ }
74
+ console.log(' which account should remote simulators and boxes bill to?')
75
+ accounts.forEach((account, index) => console.log(` ${index + 1}. ${account.name}`))
76
+ const prompt = createInterface({ input: process.stdin, output: process.stdout })
77
+ try {
78
+ for (;;) {
79
+ const answer = (await prompt.question(` account [1-${accounts.length}]: `)).trim()
80
+ const chosen = accounts[Number(answer) - 1]
81
+ if (chosen && /^\d+$/.test(answer)) return chosen
82
+ console.log(` enter a number from 1 to ${accounts.length}`)
83
+ }
84
+ } finally {
85
+ prompt.close()
86
+ }
87
+ }
88
+
89
+ async function finishLogin(
90
+ origin: string,
91
+ requestedAccount: string | undefined,
92
+ fallbackLabel: string | undefined,
93
+ suffix: string,
94
+ ) {
95
+ const refreshed = await refreshSharedDesktopAuthSession(origin)
96
+ const account = refreshed ? await chooseAccount(refreshed, requestedAccount) : null
97
+ if (refreshed && account) {
98
+ writeSharedDesktopAuthSession({ ...refreshed, accountId: account.id })
99
+ }
100
+ const label = refreshed?.user?.email || fallbackLabel || refreshed?.user?.id
101
+ console.log(
102
+ ` signed in${label ? ` as ${label}` : ''}${account ? ` (${account.name})` : ''}${suffix}`,
103
+ )
104
+ }
105
+
38
106
  function extractStringFlag(args: string[], name: string): string | undefined {
39
107
  const idx = args.findIndex((a) => a === name)
40
108
  if (idx < 0) return undefined
@@ -147,6 +215,7 @@ export async function runLogin(args: string[]) {
147
215
 
148
216
  const mutableArgs = [...args]
149
217
  const origin = resolveDefaultAuthOrigin(extractStringFlag(mutableArgs, '--origin'))
218
+ const requestedAccount = extractStringFlag(mutableArgs, '--account')
150
219
 
151
220
  // in dev (localhost), skip the browser and sign in as the shared dev
152
221
  // user via POST /api/dev-login. the no-device contract is local-only, so this
@@ -161,13 +230,12 @@ export async function runLogin(args: string[]) {
161
230
  origin,
162
231
  source: 'cli',
163
232
  })
164
- const refreshed = await refreshSharedDesktopAuthSession(origin)
165
- const label =
166
- refreshed?.user?.email ||
167
- devResult.email ||
168
- refreshed?.user?.id ||
169
- devResult.userId
170
- console.log(` signed in${label ? ` as ${label}` : ''} (dev)`)
233
+ await finishLogin(
234
+ origin,
235
+ requestedAccount,
236
+ devResult.email || devResult.userId,
237
+ ' (dev)',
238
+ )
171
239
  return
172
240
  }
173
241
  console.log(` dev-login not available (${devResult.error}); falling back to browser`)
@@ -285,8 +353,5 @@ export async function runLogin(args: string[]) {
285
353
  source: 'cli',
286
354
  })
287
355
 
288
- const refreshed = await refreshSharedDesktopAuthSession(origin)
289
- const label =
290
- refreshed?.user?.email || result.email || refreshed?.user?.id || result.userId
291
- console.log(` signed in${label ? ` as ${label}` : ''}`)
356
+ await finishLogin(origin, requestedAccount, result.email || result.userId, '')
292
357
  }