peachsim 0.1.669 → 0.1.670

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 (115) hide show
  1. package/cli/commands/control.ts +8 -5
  2. package/cli/commands/inspect/core.ts +7 -5
  3. package/cli/commands/inspect/wait-selector.ts +2 -11
  4. package/cli/commands/inspect.ts +6 -59
  5. package/cli/commands/kotlin.ts +6 -4
  6. package/cli/commands/skills.ts +28 -7
  7. package/cli/commands/upgrade.ts +18 -3
  8. package/cli/main.ts +21 -0
  9. package/cli/output-redaction.ts +182 -0
  10. package/cli/parse-args.ts +1 -0
  11. package/dist-lib/agent-daemon-client.cjs +1 -1
  12. package/dist-lib/agent-events.cjs +1 -1
  13. package/dist-lib/agent-identity.cjs +1 -1
  14. package/dist-lib/agent-sessions.cjs +1 -1
  15. package/dist-lib/attached-projects.cjs +1 -1
  16. package/dist-lib/auth/shared-session.cjs +1 -1
  17. package/dist-lib/backend-origin.cjs +1 -1
  18. package/dist-lib/beta.cjs +1 -1
  19. package/dist-lib/beta.mjs +1 -1
  20. package/dist-lib/bridge-constants.cjs +1 -1
  21. package/dist-lib/bridge-contract-input.cjs +1 -1
  22. package/dist-lib/bridge-contract-input.mjs +1 -1
  23. package/dist-lib/bridge-contract.cjs +1 -1
  24. package/dist-lib/bridge-contract.mjs +1 -1
  25. package/dist-lib/capture-contract.cjs +1 -1
  26. package/dist-lib/capture-contract.mjs +1 -1
  27. package/dist-lib/cli-constants.cjs +1 -1
  28. package/dist-lib/cloud-contract.cjs +1 -1
  29. package/dist-lib/cloud-contract.mjs +1 -1
  30. package/dist-lib/cloud-test-args.cjs +1 -1
  31. package/dist-lib/cloud-test-args.mjs +1 -1
  32. package/dist-lib/cloud.cjs +1 -1
  33. package/dist-lib/cloud.mjs +1 -1
  34. package/dist-lib/config.cjs +1 -1
  35. package/dist-lib/detox/index.cjs +1 -1
  36. package/dist-lib/dev-bundle-resolution.cjs +1 -1
  37. package/dist-lib/home-paths.cjs +1 -1
  38. package/dist-lib/host/bridge-host.cjs +29 -19
  39. package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
  40. package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
  41. package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
  42. package/dist-lib/host/replacement-module-handler.cjs +1 -1
  43. package/dist-lib/host/websocket-proxy.cjs +1 -1
  44. package/dist-lib/index.cjs +260 -17
  45. package/dist-lib/jump-to-source-babel.cjs +1 -1
  46. package/dist-lib/jump-to-source-native.cjs +1 -1
  47. package/dist-lib/kotlin-host.cjs +2 -1
  48. package/dist-lib/menu.cjs +1 -1
  49. package/dist-lib/menu.mjs +1 -1
  50. package/dist-lib/metro-fingerprint-registry.cjs +1 -1
  51. package/dist-lib/metro-fingerprint-registry.mjs +1 -1
  52. package/dist-lib/metro-production-bundle.cjs +1 -1
  53. package/dist-lib/metro-production-bundle.mjs +1 -1
  54. package/dist-lib/metro.cjs +259 -16
  55. package/dist-lib/native-app-identity.cjs +1 -1
  56. package/dist-lib/native-app-identity.mjs +1 -1
  57. package/dist-lib/native-firebase-metadata.cjs +115 -0
  58. package/dist-lib/native-firebase-metadata.mjs +86 -0
  59. package/dist-lib/plist.cjs +1 -1
  60. package/dist-lib/plist.mjs +1 -1
  61. package/dist-lib/profiles.cjs +1 -1
  62. package/dist-lib/public-brand.cjs +1 -1
  63. package/dist-lib/react-native-host-modules.cjs +1 -1
  64. package/dist-lib/react-native-host-modules.mjs +1 -1
  65. package/dist-lib/render-mode.cjs +1 -1
  66. package/dist-lib/scripts/dev-server-scanner.cjs +1 -1
  67. package/dist-lib/sdk.cjs +2 -2
  68. package/dist-lib/sdk.mjs +2 -2
  69. package/dist-lib/skills.cjs +479 -203
  70. package/dist-lib/swift-compile.cjs +1 -1
  71. package/dist-lib/swift-compile.mjs +1 -1
  72. package/dist-lib/swift-package.cjs +1 -1
  73. package/dist-lib/swift-package.mjs +1 -1
  74. package/dist-lib/swift-submit.cjs +1 -1
  75. package/dist-lib/swift-submit.mjs +1 -1
  76. package/dist-lib/swift.cjs +1 -1
  77. package/dist-lib/vite-swift.cjs +21 -13
  78. package/dist-lib/vite.cjs +1 -1
  79. package/kotlin/AppSources/androidx/activity/ActivityFlags.kt +9 -0
  80. package/kotlin/AppSources/androidx/activity/BackEventCompat.kt +25 -4
  81. package/kotlin/AppSources/androidx/activity/ComponentActivity.kt +15 -4
  82. package/kotlin/AppSources/androidx/activity/EdgeToEdge.kt +12 -8
  83. package/kotlin/AppSources/androidx/activity/ExperimentalActivityApi.kt +6 -0
  84. package/kotlin/AppSources/androidx/activity/OnBackPressedCallback.kt +50 -9
  85. package/kotlin/AppSources/androidx/activity/OnBackPressedDispatcher.kt +67 -89
  86. package/kotlin/AppSources/androidx/activity/compose/BackHandler.kt +55 -24
  87. package/kotlin/AppSources/androidx/activity/compose/internal/BackHandlerCompat.kt +38 -0
  88. package/kotlin/AppSources/androidx/activity/compose/internal/BackHandlerDispatcherCompat.kt +27 -0
  89. package/kotlin/AppSources/dev/peach/app/PeachApp.kt +5 -5
  90. package/kotlin/ComposeSources/PeachComposeHost.kt +31 -7
  91. package/kotlin/fixtures/kotlin-app/app/build.gradle.kts +1 -1
  92. package/kotlin/fixtures/kotlin-app-variants/Browse.kt +73 -0
  93. package/kotlin/fixtures/kotlin-app-variants/Focus.kt +62 -0
  94. package/kotlin/fixtures/kotlin-app-variants/Lists.kt +57 -0
  95. package/kotlin/fixtures/kotlin-app-variants/Nav.kt +47 -0
  96. package/kotlin/fixtures/kotlin-app-variants/Nested.kt +64 -0
  97. package/kotlin/fixtures/kotlin-app-variants/Pan.kt +38 -0
  98. package/kotlin/fixtures/kotlin-app-variants/Pickers.kt +44 -0
  99. package/kotlin/fixtures/kotlin-app-variants/Press.kt +43 -0
  100. package/kotlin/fixtures/kotlin-app-variants/Transition.kt +69 -0
  101. package/kotlin/fixtures/kotlin-app-variants/ViewConfig.kt +22 -0
  102. package/kotlin/toolchain.json +30 -2
  103. package/package.json +8 -1
  104. package/skills/contrast/SKILL.md +2 -1
  105. package/skills/peach-debug/SKILL.md +2 -2
  106. package/skills/peach-test/SKILL.md +3 -3
  107. package/src/expo-swift-host.ts +13 -4
  108. package/src/kotlin-app.ts +35 -16
  109. package/src/kotlin-host.ts +1 -0
  110. package/src/metro-plugin.ts +24 -0
  111. package/src/native-firebase-config.ts +202 -21
  112. package/src/native-firebase-metadata.ts +114 -0
  113. package/src/swift-host.ts +30 -15
  114. package/src/vite-plugin.ts +46 -2
  115. package/kotlin/AppSources/androidx/activity/Cancellable.kt +0 -5
@@ -92,6 +92,7 @@ import {
92
92
  } from '../ws-bridge'
93
93
  import { inspectDescribe, inspectReadySnapshot, waitReadyReason } from './inspect/core'
94
94
  import { isAgentEnv } from './inspect/env'
95
+ import { hasInstalledPeachSkills } from './skills'
95
96
  import type { DriverId, DriverLaunchResult } from '../drivers/types'
96
97
 
97
98
  interface ControlCommandOptions {
@@ -1329,9 +1330,11 @@ export function printOpenedSim(
1329
1330
  console.log(JSON.stringify({ simId: sim.id, url: printableSimUrl }, null, 2))
1330
1331
  }
1331
1332
 
1332
- function printAgentListenHint(quiet: boolean): void {
1333
- if (!quiet) {
1334
- console.log(' inspect messages: run `peach agent listen` when you finish your turn')
1333
+ function printAgentHints(quiet: boolean): void {
1334
+ if (quiet) return
1335
+ console.log(' inspect messages: run `peach agent listen` when you finish your turn')
1336
+ if (!hasInstalledPeachSkills()) {
1337
+ console.log(' driving peach from an agent? `peach skill install` adds its workflows')
1335
1338
  }
1336
1339
  }
1337
1340
 
@@ -1968,7 +1971,7 @@ export async function runOpenCommand(
1968
1971
  quiet,
1969
1972
  )
1970
1973
  await maybeDescribeAfterOpen(parsed.wsPort, targetSim.id, args)
1971
- printAgentListenHint(quiet)
1974
+ printAgentHints(quiet)
1972
1975
  return { simId: targetSim.id, launched: false }
1973
1976
  }
1974
1977
  } finally {
@@ -2055,7 +2058,7 @@ export async function runOpenCommand(
2055
2058
  signalDriverLaunchConnected(result)
2056
2059
  printOpenedSim(openUrl, match, `${driver.name} driver`, quiet)
2057
2060
  await maybeDescribeAfterOpen(parsed.wsPort, match.id, args)
2058
- printAgentListenHint(quiet)
2061
+ printAgentHints(quiet)
2059
2062
  return { simId: match.id, launched: true }
2060
2063
  }
2061
2064
 
@@ -40,12 +40,14 @@ import type {
40
40
  } from '@peach/globals'
41
41
  import type { KeyboardLayoutSnapshot } from 'peach-engine/render-worker/native-ui-protocol'
42
42
 
43
- // resolve a `--max-ms <n>` flag, tolerating the spellings agents and humans
44
- // actually type (`--maxMs`, `--maxms`, `--max_ms`). a silently-ignored
45
- // timeout flag is a sharp edge: the call doesn't error, it just uses the
46
- // default, so the operator thinks their bound applied when it didn't.
43
+ // resolve a wait's `--max-ms <n>` budget, tolerating the spellings agents and
44
+ // humans actually type (`--maxMs`, `--maxms`, `--max_ms`). `--timeout`, the
45
+ // global per-command budget, means the same thing for a command whose whole
46
+ // job is to wait. a silently-ignored timeout flag is a sharp edge: the call
47
+ // doesn't error, it just uses the default, so the operator thinks their bound
48
+ // applied when it didn't.
47
49
  export function resolveMaxMsFlag(args: string[], fallbackMs: number): number {
48
- const aliases = ['--max-ms', '--maxMs', '--maxms', '--max_ms']
50
+ const aliases = ['--max-ms', '--maxMs', '--maxms', '--max_ms', '--timeout']
49
51
  for (const flag of aliases) {
50
52
  const i = args.indexOf(flag)
51
53
  if (i >= 0 && args[i + 1]) {
@@ -1,5 +1,5 @@
1
1
  import { rnxExit } from '../../run-rnx'
2
- import { inspectWaitSelector } from './core'
2
+ import { inspectWaitSelector, resolveMaxMsFlag } from './core'
3
3
  import type { WsBridge } from '../../ws-bridge'
4
4
 
5
5
  // blocks until a node with the given testID is visible and laid out, or — with
@@ -27,16 +27,7 @@ export async function runWaitSelectorSubcommand(opts: {
27
27
  rnxExit(1)
28
28
  }
29
29
  const testId = rawTestId.replace(/^#/, '')
30
- // the wait budget. `--max-ms` names it, and `--timeout` (the global
31
- // per-command budget) means the same thing for a command whose whole job is
32
- // to wait — without this it parsed away and the wait silently stayed at 5s,
33
- // reporting a false timeout.
34
- const maxMsIdx = args.indexOf('--max-ms')
35
- const budgetIdx = maxMsIdx >= 0 ? maxMsIdx : args.indexOf('--timeout')
36
- const timeoutMs =
37
- budgetIdx >= 0 && args[budgetIdx + 1]
38
- ? Math.max(100, Number(args[budgetIdx + 1]))
39
- : 5000
30
+ const timeoutMs = resolveMaxMsFlag(args, 5000)
40
31
  const gone = args.includes('--gone')
41
32
  const structural = args.includes('--structural')
42
33
 
@@ -2003,10 +2003,15 @@ export async function runInspect(args: string[], opts: InspectOptions) {
2003
2003
  break
2004
2004
  }
2005
2005
 
2006
+ // `tap-id` is `tap` with an exact testID, so a numeric id stays an id.
2007
+ case 'tap-id':
2006
2008
  case 'tap': {
2007
2009
  let x = Number(positional[1])
2008
2010
  let y = Number(positional[2])
2009
- const target = readTargetFlag(args, positional)
2011
+ const target =
2012
+ subcommand === 'tap-id'
2013
+ ? readTargetFlag(['--testid', positional[1] ?? ''])
2014
+ : readTargetFlag(args, positional)
2010
2015
  if (target) {
2011
2016
  const outcome = await tapResolvedTarget(bridge, {
2012
2017
  agent: isAgentEnv(),
@@ -2460,64 +2465,6 @@ export async function runInspect(args: string[], opts: InspectOptions) {
2460
2465
  break
2461
2466
  }
2462
2467
 
2463
- case 'tap-id': {
2464
- const query = positional[1]
2465
- if (!query) {
2466
- console.error(inspectUsage('tap-id', '<id>'))
2467
- rnxExit(1)
2468
- }
2469
-
2470
- const outcome = await tapById(bridge, query, { agent: isAgentEnv() })
2471
- const payload = outcome.payload
2472
- if (!payload || typeof payload.cx !== 'number') {
2473
- console.error(` not found: ${query}`)
2474
- // when the testID didn't match, fall back to a fuzzy list of
2475
- // nearby testIDs in the live tree so the agent can pick the
2476
- // intended one without dumping the whole tree first.
2477
- await printSimilarTestIds(bridge, query)
2478
- rnxExit(1)
2479
- }
2480
- if (!isTapSuccess(outcome.result)) {
2481
- printTapFailure(`id "${query}"`, outcome)
2482
- rnxExit(1)
2483
- }
2484
- const candidate = buildTapCandidateFromNode(
2485
- query,
2486
- {
2487
- id: payload.target?.id ?? null,
2488
- testID: payload.target?.testID ?? null,
2489
- type: payload.target?.type ?? null,
2490
- cx: payload.cx,
2491
- cy: payload.cy,
2492
- },
2493
- 'id',
2494
- )
2495
- await recordInspectAction('inspect tap-id', candidate.step, candidate.summary)
2496
- console.log(
2497
- JSON.stringify(
2498
- {
2499
- matched: payload.match,
2500
- tapped: {
2501
- nodeId: payload.target?.nodeId ?? null,
2502
- id: payload.target?.id ?? null,
2503
- testID: payload.target?.testID ?? null,
2504
- type: payload.target?.type ?? null,
2505
- cx: payload.cx,
2506
- cy: payload.cy,
2507
- },
2508
- ...(payload.strategy && payload.strategy !== 'matched-node'
2509
- ? { strategy: payload.strategy }
2510
- : {}),
2511
- ...(outcome.attempts > 1 ? { attempts: outcome.attempts } : {}),
2512
- result: outcome.result,
2513
- },
2514
- null,
2515
- 2,
2516
- ),
2517
- )
2518
- break
2519
- }
2520
-
2521
2468
  case 'type-into': {
2522
2469
  const targetId = positional[1]
2523
2470
  const text = positional.slice(2).join(' ')
@@ -6,7 +6,7 @@ import { WebSocket, WebSocketServer } from 'ws'
6
6
  import { kotlinGuestBundle } from '../../src/guest-bundle'
7
7
  import {
8
8
  readKotlinApp,
9
- type ComposeAndroidPins,
9
+ type AndroidPins,
10
10
  type KotlinAppProject,
11
11
  } from '../../src/kotlin-app'
12
12
  import { PeachExit } from '../run-rnx'
@@ -19,7 +19,7 @@ const HMR_PATH = '/__peach/kotlin-hmr'
19
19
  // and in the built CLI (dist-cli/chunks)
20
20
  const MODULE_ROOT = path.resolve(import.meta.dirname, '../../kotlin')
21
21
 
22
- function composePins(): ComposeAndroidPins {
22
+ function androidPins(): AndroidPins {
23
23
  const toolchain: unknown = JSON.parse(
24
24
  fs.readFileSync(path.join(MODULE_ROOT, 'toolchain.json'), 'utf8'),
25
25
  )
@@ -30,7 +30,7 @@ function composePins(): ComposeAndroidPins {
30
30
  const pin = (name: string) => {
31
31
  const value: unknown = android ? Reflect.get(android, name) : undefined
32
32
  if (typeof value !== 'string')
33
- throw new Error(`Kotlin toolchain has no Compose ${name} pin`)
33
+ throw new Error(`Kotlin toolchain has no Android ${name} pin`)
34
34
  return value
35
35
  }
36
36
  return {
@@ -38,6 +38,8 @@ function composePins(): ComposeAndroidPins {
38
38
  foundation: pin('foundation'),
39
39
  runtime: pin('runtime'),
40
40
  material3: pin('material3'),
41
+ navigation: pin('navigation'),
42
+ activity: pin('activity'),
41
43
  }
42
44
  }
43
45
 
@@ -91,7 +93,7 @@ export async function runKotlin(
91
93
  throw new Error(
92
94
  `peach kotlin runs an Android app; ${device} is not an Android device`,
93
95
  )
94
- const pins = composePins()
96
+ const pins = androidPins()
95
97
  let project: KotlinAppProject = readKotlinApp(modulePath, { activity, pins })
96
98
  const host = await import('../../src/kotlin-host')
97
99
  const controller = new AbortController()
@@ -40,7 +40,13 @@ function getSkillsDir(): string {
40
40
  }
41
41
  }
42
42
 
43
- const SKILLS_DIR = getSkillsDir()
43
+ // resolved on first use: the standalone binary writes its skills when the
44
+ // directory is resolved, and importing this module must never touch disk.
45
+ let skillsDirPath: string | undefined
46
+ function skillsDir(): string {
47
+ skillsDirPath ??= getSkillsDir()
48
+ return skillsDirPath
49
+ }
44
50
  const AGENT_TARGET_IDS = new Set(['all', 'codex', 'claude'])
45
51
 
46
52
  interface SkillInfo {
@@ -109,6 +115,21 @@ function defaultAgentTargets(): InstallTarget[] {
109
115
  ]
110
116
  }
111
117
 
118
+ // whether any bundled skill is installed in a default agent skills directory.
119
+ // reads the names without materializing the binary's skills, so a command
120
+ // that only prints a hint writes nothing.
121
+ export function hasInstalledPeachSkills(): boolean {
122
+ const names =
123
+ typeof __PEACH_BUNDLED_SKILLS__ !== 'undefined'
124
+ ? Object.keys(__PEACH_BUNDLED_SKILLS__).flatMap(
125
+ (file) => /^([^/]+)\/SKILL\.md$/.exec(file)?.[1] ?? [],
126
+ )
127
+ : listSkills().map((skill) => skill.name)
128
+ return defaultAgentTargets().some(({ dir }) =>
129
+ names.some((name) => fs.existsSync(path.join(dir, name, 'SKILL.md'))),
130
+ )
131
+ }
132
+
112
133
  function agentTarget(target: string): InstallTarget[] {
113
134
  if (target === 'all') return defaultAgentTargets()
114
135
  if (target === 'codex') {
@@ -173,10 +194,10 @@ function readFrontmatterField(frontmatter: string, field: string): string | null
173
194
  // bundled skills live in the standard agent layout: skills/<name>/SKILL.md.
174
195
  // the directory name is the skill name; frontmatter is the display metadata.
175
196
  function listSkills(): SkillInfo[] {
176
- if (!fs.existsSync(SKILLS_DIR)) return []
197
+ if (!fs.existsSync(skillsDir())) return []
177
198
  const skills: SkillInfo[] = []
178
- for (const entry of fs.readdirSync(SKILLS_DIR)) {
179
- const skillMd = path.join(SKILLS_DIR, entry, 'SKILL.md')
199
+ for (const entry of fs.readdirSync(skillsDir())) {
200
+ const skillMd = path.join(skillsDir(), entry, 'SKILL.md')
180
201
  if (!fs.existsSync(skillMd)) continue
181
202
  const meta = parseSkillFrontmatter(fs.readFileSync(skillMd, 'utf8'))
182
203
  if (meta) {
@@ -266,7 +287,7 @@ function isOwnLegacyInstall(stat: fs.Stats, dest: string, srcDir: string): boole
266
287
  }
267
288
 
268
289
  function linkSkill(skill: SkillInfo, targetDir: string, force: boolean): LinkResult {
269
- const src = path.join(SKILLS_DIR, skill.name)
290
+ const src = path.join(skillsDir(), skill.name)
270
291
  const dest = path.join(targetDir, skill.name)
271
292
  fs.mkdirSync(targetDir, { recursive: true })
272
293
 
@@ -306,7 +327,7 @@ function removeLegacyBundledSkills(targetDir: string) {
306
327
  if (!stat) continue
307
328
  if (
308
329
  stat.isSymbolicLink() ||
309
- isOwnLegacyInstall(stat, dest, path.join(SKILLS_DIR, name))
330
+ isOwnLegacyInstall(stat, dest, path.join(skillsDir(), name))
310
331
  ) {
311
332
  fs.rmSync(dest, { recursive: true, force: true })
312
333
  }
@@ -435,7 +456,7 @@ function runShow(args: string[]) {
435
456
  return
436
457
  }
437
458
  const [skill] = matchSkills(skills, [name])
438
- const content = fs.readFileSync(path.join(SKILLS_DIR, skill.name, 'SKILL.md'), 'utf8')
459
+ const content = fs.readFileSync(path.join(skillsDir(), skill.name, 'SKILL.md'), 'utf8')
439
460
  console.log(content)
440
461
  }
441
462
 
@@ -15,17 +15,21 @@ import {
15
15
  fetchCliReleaseFile,
16
16
  refreshCliUpdateCache,
17
17
  } from '../cli-update'
18
+ import { rnxExit } from '../run-rnx'
18
19
  import { fetchRuntimeReleaseNotes, formatRuntimeReleaseNotes } from '../runtime-notes'
19
20
  import { IS_STANDALONE } from '../standalone'
20
21
  import { runRuntime } from './runtime'
21
22
 
22
23
  const SHA256_PATTERN = /^[a-f0-9]{64}$/
24
+ const VERSION_PATTERN = /^\d+\.\d+\.\d+(?:-[\w.]+)?$/
23
25
 
24
26
  export interface UpgradeOptions {
25
27
  cliExecutablePath?: string
26
28
  cliReleaseOrigin?: string
27
29
  standalone?: boolean
28
30
  includeReleaseNotes?: boolean
31
+ // install exactly this cli release, newer or older, instead of the latest.
32
+ cliVersion?: string
29
33
  }
30
34
 
31
35
  export async function runUpgrade(
@@ -35,6 +39,12 @@ export async function runUpgrade(
35
39
  const channelIdx = args.indexOf('--channel')
36
40
  const channel =
37
41
  channelIdx >= 0 && args[channelIdx + 1] ? args[channelIdx + 1] : undefined
42
+ const toIdx = args.indexOf('--to')
43
+ const cliVersion = toIdx >= 0 ? args[toIdx + 1]?.replace(/^v/, '') : undefined
44
+ if (toIdx >= 0 && !(cliVersion && VERSION_PATTERN.test(cliVersion))) {
45
+ console.error(' peach upgrade: --to takes a CLI version such as 0.1.669')
46
+ rnxExit(1)
47
+ }
38
48
 
39
49
  console.log('peach upgrade\n')
40
50
 
@@ -79,7 +89,7 @@ export async function runUpgrade(
79
89
  : null
80
90
 
81
91
  console.log('\ncli:')
82
- await upgradeCli(options)
92
+ await upgradeCli({ ...options, cliVersion })
83
93
 
84
94
  if (installedRuntime && options.includeReleaseNotes !== false) {
85
95
  console.log(`\n${formatRuntimeReleaseNotes(installedRuntime, releaseNotes)}`)
@@ -103,8 +113,13 @@ export async function upgradeCli(options: UpgradeOptions = {}): Promise<void> {
103
113
  '',
104
114
  )
105
115
  const current = getCliVersion()
106
- const { latestVersion: latest } = await refreshCliUpdateCache({ releaseOrigin })
107
- if (compareSemver(latest, current) <= 0) {
116
+ const latest =
117
+ options.cliVersion ?? (await refreshCliUpdateCache({ releaseOrigin })).latestVersion
118
+ if (options.cliVersion === current) {
119
+ console.log(` v${current} (requested)`)
120
+ return
121
+ }
122
+ if (!options.cliVersion && compareSemver(latest, current) <= 0) {
108
123
  console.log(` v${current} (latest)`)
109
124
  return
110
125
  }
package/cli/main.ts CHANGED
@@ -74,6 +74,18 @@ process.on('unhandledRejection', handleTopLevelError)
74
74
  process.on('uncaughtException', handleTopLevelError)
75
75
 
76
76
  let parsed = parseArgs(process.argv)
77
+ // `--redact` reads the same before or after the command name, and masks from
78
+ // the first line printed, parser warnings included.
79
+ const redact =
80
+ parsed.globalFlags['redact'] === true || parsed.commandArgs.includes('--redact')
81
+ if (redact) {
82
+ parsed = {
83
+ ...parsed,
84
+ commandArgs: parsed.commandArgs.filter((arg) => arg !== '--redact'),
85
+ }
86
+ const { redactProcessOutput } = await import('./output-redaction')
87
+ redactProcessOutput()
88
+ }
77
89
  for (const warning of parsed.warnings) process.stderr.write(`${warning}\n`)
78
90
 
79
91
  // runtime actions and reads also work as hidden root aliases. resolve only
@@ -83,6 +95,15 @@ if (!parsed.command && parsed.commandArgs.length > 0) {
83
95
  const { resolveHiddenRuntimeAlias } = await import('./hidden-runtime-alias')
84
96
  parsed = resolveHiddenRuntimeAlias(parsed)
85
97
  }
98
+ if (redact) {
99
+ const { REDACTABLE_COMMANDS } = await import('./output-redaction')
100
+ if (!REDACTABLE_COMMANDS.has(parsed.command ?? '')) {
101
+ process.stderr.write(
102
+ ` --redact applies to ${[...REDACTABLE_COMMANDS].join(', ')}; this command can print output it cannot mask\n`,
103
+ )
104
+ process.exit(1)
105
+ }
106
+ }
86
107
 
87
108
  const isAutomaticCleanupWorker =
88
109
  parsed.command === 'cleanup' &&
@@ -0,0 +1,182 @@
1
+ // `--redact` masks secrets and account identifiers in what a command prints,
2
+ // so logs, network dumps and timelines can be pasted where others read them.
3
+ // each value becomes `<redacted:xxxx>`, a short digest, so one value still
4
+ // matches itself across lines without revealing it.
5
+
6
+ import { createHash } from 'node:crypto'
7
+ import { format, inspect } from 'node:util'
8
+
9
+ // the commands that print only through this process's console and streams.
10
+ // anything else may hand its output to a child process, which no wrapper here
11
+ // can see, so it refuses the flag instead of printing secrets it claims to hide.
12
+ export const REDACTABLE_COMMANDS = new Set([
13
+ 'logs',
14
+ 'network',
15
+ 'what-happened',
16
+ 'history',
17
+ 'get',
18
+ ])
19
+
20
+ // names whose values grant access or identify an account, in json, js object
21
+ // output, query strings and headers. operational ids stay readable: a bare
22
+ // `id` addresses `network get <id>`, a sim's `sessionId` names the sim, and a
23
+ // keyboard `key` is the key pressed.
24
+ const SECRET_NAME =
25
+ /^(?:[\w-]*(?:api[-_]?key|token|secret|password|passwd|authorization|cookie|signature|credential)s?|auth|sig|email|uid|[\w-]*(?:user|account|local|device|installation|client|app|project|sender|measurement|tenant)[-_]?id)$/i
26
+
27
+ const JWT = /\beyJ[\w-]{6,}\.[\w-]{6,}\.[\w-]{6,}/g
28
+ const GOOGLE_API_KEY = /\bAIza[\w-]{30,}/g
29
+ const BEARER = /\b(Bearer|Basic)\s+[\w.~+/=-]{8,}/gi
30
+ const EMAIL = /\b[\w.+-]+@[\w-]+(?:\.[\w-]+)+\b/g
31
+ // a name and its value: a quoted json name or a bare js object key, then a
32
+ // json or js string, a whole number token, or an array of strings. a string is
33
+ // matched at its own escaping level only; a json document escaped inside it is
34
+ // reached by decoding the string, since escaped quotes cannot show where an
35
+ // escaped value ends.
36
+ const STRING = String.raw`(?:"(?:[^"\\\n]|\\.)*"|'(?:[^'\\\n]|\\.)*')`
37
+ const NUMBER = String.raw`-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?`
38
+ const PAIR = new RegExp(
39
+ String.raw`((?:"([\w-]+)"|\b([\w-]+))\s*:\s*)(${STRING}|${NUMBER}\b|\[\s*${STRING}(?:\s*,\s*${STRING})*\s*\])`,
40
+ 'g',
41
+ )
42
+ const STRING_TOKEN = new RegExp(STRING, 'g')
43
+ const QUERY_PAIR = /([?&;]([\w.%-]+)=)([^&#\s"']+)/g
44
+ const HEADER_LINE = /^(\s*([\w-]+)\s*:\s+)(\S.*)$/gm
45
+ const MASKED = /^<redacted:[0-9a-f]{4}>$/
46
+
47
+ // a value an earlier rule already masked keeps its digest.
48
+ function mask(value: string): string {
49
+ if (MASKED.test(value)) return value
50
+ return `<redacted:${createHash('sha256').update(value).digest('hex').slice(0, 4)}>`
51
+ }
52
+
53
+ // masks a quoted value inside its own quotes, so json stays json.
54
+ function maskQuoted(quoted: string): string {
55
+ return `${quoted[0]}${mask(quoted.slice(1, -1))}${quoted[0]}`
56
+ }
57
+
58
+ // a string can carry a document of its own: json escaped inside a json string,
59
+ // or json inside a js string. its decoded contents are redacted and encoded
60
+ // back the same way; each level is shorter, so this ends.
61
+ function redactString(quoted: string): string {
62
+ if (quoted.startsWith("'")) {
63
+ const inner = quoted.slice(1, -1)
64
+ const redacted = redactForSharing(inner)
65
+ return redacted === inner ? quoted : `'${redacted}'`
66
+ }
67
+ let decoded: string
68
+ try {
69
+ decoded = JSON.parse(quoted)
70
+ } catch {
71
+ decoded = quoted.slice(1, -1).replace(/\\(["\\/])/g, '$1')
72
+ }
73
+ const redacted = redactForSharing(decoded)
74
+ return redacted === decoded ? quoted : JSON.stringify(redacted)
75
+ }
76
+
77
+ function decodeOrSelf(value: string): string {
78
+ try {
79
+ return decodeURIComponent(value)
80
+ } catch {
81
+ return value
82
+ }
83
+ }
84
+
85
+ // a non-secret string, or each string of a non-secret array, is searched
86
+ // for pairs of its own.
87
+ function redactPair(
88
+ pair: string,
89
+ prefix: string,
90
+ quotedName: string | undefined,
91
+ bareName: string,
92
+ value: string,
93
+ ): string {
94
+ if (!SECRET_NAME.test(quotedName ?? bareName)) {
95
+ if (!/^["'[]/.test(value)) return pair
96
+ const inner = value.startsWith('[')
97
+ ? value.replace(STRING_TOKEN, redactString)
98
+ : redactString(value)
99
+ return inner === value ? pair : `${prefix}${inner}`
100
+ }
101
+ if (value.startsWith('[')) return `${prefix}${value.replace(STRING_TOKEN, maskQuoted)}`
102
+ return `${prefix}${/^["']/.test(value) ? maskQuoted(value) : `"${mask(value)}"`}`
103
+ }
104
+
105
+ export function redactForSharing(text: string): string {
106
+ return text
107
+ .replace(JWT, mask)
108
+ .replace(GOOGLE_API_KEY, mask)
109
+ .replace(BEARER, (match, scheme: string) => `${scheme} ${mask(match)}`)
110
+ .replace(PAIR, redactPair)
111
+ .replace(QUERY_PAIR, (pair, prefix: string, name: string, value: string) => {
112
+ if (SECRET_NAME.test(decodeOrSelf(name))) return `${prefix}${mask(value)}`
113
+ // a launch url can carry a whole config object, or another url with its
114
+ // own query, as one encoded value; each decode is shorter, so this ends.
115
+ if (!/%[0-9a-f]{2}/i.test(value)) return pair
116
+ const decoded = decodeOrSelf(value)
117
+ const redacted = redactForSharing(decoded)
118
+ return redacted === decoded ? pair : `${prefix}${encodeURIComponent(redacted)}`
119
+ })
120
+ .replace(HEADER_LINE, (line, prefix: string, name: string, value: string) =>
121
+ SECRET_NAME.test(name) ? `${prefix}${mask(value)}` : line,
122
+ )
123
+ .replace(EMAIL, mask)
124
+ }
125
+
126
+ const utf8 = new TextDecoder('utf-8', { fatal: true })
127
+
128
+ // applies to both streams and the console for the rest of the process; bun's
129
+ // console writes without passing through the streams. text is masked a whole
130
+ // line at a time, so a value split across writes is still seen whole; binary
131
+ // output passes through untouched.
132
+ export function redactProcessOutput(): void {
133
+ for (const stream of [process.stdout, process.stderr]) {
134
+ const write = stream.write.bind(stream)
135
+ let pending = ''
136
+ stream.write = (
137
+ chunk: string | Uint8Array,
138
+ encodingOrCallback?: BufferEncoding | ((error?: Error | null) => void),
139
+ callback?: (error?: Error | null) => void,
140
+ ): boolean => {
141
+ const done =
142
+ typeof encodingOrCallback === 'function' ? encodingOrCallback : callback
143
+ let text: string
144
+ if (typeof chunk === 'string') text = chunk
145
+ else {
146
+ try {
147
+ text = utf8.decode(chunk)
148
+ } catch {
149
+ return write(chunk, done)
150
+ }
151
+ }
152
+ const buffered = pending + text
153
+ const end = buffered.lastIndexOf('\n') + 1
154
+ pending = buffered.slice(end)
155
+ if (end === 0) {
156
+ done?.()
157
+ return true
158
+ }
159
+ return write(redactForSharing(buffered.slice(0, end)), done)
160
+ }
161
+ process.on('exit', () => {
162
+ if (pending) write(redactForSharing(pending))
163
+ })
164
+ }
165
+ const methods: ('log' | 'info' | 'debug' | 'warn' | 'error' | 'trace')[] = [
166
+ 'log',
167
+ 'info',
168
+ 'debug',
169
+ 'warn',
170
+ 'error',
171
+ 'trace',
172
+ ]
173
+ for (const method of methods) {
174
+ const print = console[method].bind(console)
175
+ console[method] = (...args: unknown[]) => print(redactForSharing(format(...args)))
176
+ }
177
+ console.dir = (value: unknown, options?: object) =>
178
+ console.log(redactForSharing(inspect(value, options)))
179
+ console.assert = (condition?: boolean, ...args: unknown[]) => {
180
+ if (!condition) console.error('Assertion failed:', ...args)
181
+ }
182
+ }
package/cli/parse-args.ts CHANGED
@@ -29,6 +29,7 @@ const GLOBAL_FLAGS: Record<
29
29
  // `--session <id>`). normalized to the sim target in bin.ts.
30
30
  '--session': { type: 'string' },
31
31
  '--tab': { type: 'string' },
32
+ '--redact': { type: 'boolean' },
32
33
  }
33
34
 
34
35
  // every spelling parseRnxArgs consumes before the command name, mapped to
@@ -1,4 +1,4 @@
1
- /*! rnx v0.1.669 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
1
+ /*! rnx v0.1.670 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
2
2
  let __sootsim_import_meta_url = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; } catch {}
3
3
  "use strict";
4
4
  var __create = Object.create;
@@ -1,4 +1,4 @@
1
- /*! rnx v0.1.669 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
1
+ /*! rnx v0.1.670 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
2
2
  let __sootsim_import_meta_url = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; } catch {}
3
3
  "use strict";
4
4
  var __defProp = Object.defineProperty;
@@ -1,4 +1,4 @@
1
- /*! rnx v0.1.669 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
1
+ /*! rnx v0.1.670 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
2
2
  let __sootsim_import_meta_url = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; } catch {}
3
3
  "use strict";
4
4
  var __defProp = Object.defineProperty;
@@ -1,4 +1,4 @@
1
- /*! rnx v0.1.669 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
1
+ /*! rnx v0.1.670 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
2
2
  let __sootsim_import_meta_url = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; } catch {}
3
3
  "use strict";
4
4
  var __create = Object.create;
@@ -1,4 +1,4 @@
1
- /*! rnx v0.1.669 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
1
+ /*! rnx v0.1.670 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
2
2
  let __sootsim_import_meta_url = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; } catch {}
3
3
  "use strict";
4
4
  var __create = Object.create;
@@ -1,4 +1,4 @@
1
- /*! rnx v0.1.669 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
1
+ /*! rnx v0.1.670 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
2
2
  let __sootsim_import_meta_url = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; } catch {}
3
3
  "use strict";
4
4
  var __create = Object.create;
@@ -1,4 +1,4 @@
1
- /*! rnx v0.1.669 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
1
+ /*! rnx v0.1.670 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
2
2
  let __sootsim_import_meta_url = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; } catch {}
3
3
  "use strict";
4
4
  var __defProp = Object.defineProperty;
package/dist-lib/beta.cjs CHANGED
@@ -1,4 +1,4 @@
1
- /*! rnx v0.1.669 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
1
+ /*! rnx v0.1.670 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
2
2
  let __sootsim_import_meta_url = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; } catch {}
3
3
  "use strict";
4
4
  var __defProp = Object.defineProperty;
package/dist-lib/beta.mjs CHANGED
@@ -1,4 +1,4 @@
1
- /*! rnx v0.1.669 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
1
+ /*! rnx v0.1.670 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
2
2
 
3
3
  // src/beta.ts
4
4
  var IS_BETA = true;
@@ -1,4 +1,4 @@
1
- /*! rnx v0.1.669 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
1
+ /*! rnx v0.1.670 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
2
2
  let __sootsim_import_meta_url = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; } catch {}
3
3
  "use strict";
4
4
  var __defProp = Object.defineProperty;
@@ -1,4 +1,4 @@
1
- /*! rnx v0.1.669 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
1
+ /*! rnx v0.1.670 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
2
2
  let __sootsim_import_meta_url = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; } catch {}
3
3
  "use strict";
4
4
  var __defProp = Object.defineProperty;
@@ -1,4 +1,4 @@
1
- /*! rnx v0.1.669 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
1
+ /*! rnx v0.1.670 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
2
2
 
3
3
  // src/bridge-contract.ts
4
4
  var SIM_LONG_PRESS_MAX_MS = 5e3;