peachsim 0.1.1594 → 0.1.1620

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 (226) hide show
  1. package/README.md +34 -11
  2. package/cli/app-catalog.ts +7 -5
  3. package/cli/app-config.ts +6 -2
  4. package/cli/app-fonts.ts +74 -35
  5. package/cli/app-stubs.ts +173 -42
  6. package/cli/bin.ts +9 -0
  7. package/cli/bridge-flow-runner.ts +226 -145
  8. package/cli/cloud-client.ts +52 -29
  9. package/cli/commands/agent-indicator-mode.ts +39 -0
  10. package/cli/commands/box.ts +13 -3
  11. package/cli/commands/control.ts +132 -108
  12. package/cli/commands/daemon.ts +99 -24
  13. package/cli/commands/debug.ts +29 -1
  14. package/cli/commands/detect.ts +1 -1
  15. package/cli/commands/device.ts +6 -8
  16. package/cli/commands/diagnose.ts +2 -0
  17. package/cli/commands/doctor.ts +97 -34
  18. package/cli/commands/flow.ts +22 -12
  19. package/cli/commands/inspect/actions.ts +2 -0
  20. package/cli/commands/inspect/core.ts +106 -40
  21. package/cli/commands/inspect/list.ts +9 -3
  22. package/cli/commands/inspect/memory.ts +9 -0
  23. package/cli/commands/inspect/settling.ts +167 -49
  24. package/cli/commands/inspect/shared.ts +53 -12
  25. package/cli/commands/inspect/wait-event.ts +0 -1
  26. package/cli/commands/inspect/wait.ts +2 -2
  27. package/cli/commands/inspect.ts +19 -4
  28. package/cli/commands/kotlin.ts +5 -7
  29. package/cli/commands/maestro-compare.ts +25 -2
  30. package/cli/commands/mode.ts +13 -1
  31. package/cli/commands/perf-record.ts +296 -0
  32. package/cli/commands/perf.ts +6 -1
  33. package/cli/commands/platform.ts +34 -11
  34. package/cli/commands/preview.ts +1 -1
  35. package/cli/commands/record.ts +48 -20
  36. package/cli/commands/runtime.ts +9 -11
  37. package/cli/commands/screenshots.ts +4 -3
  38. package/cli/commands/setup.ts +17 -5
  39. package/cli/commands/skills.ts +16 -43
  40. package/cli/commands/swift.ts +18 -3
  41. package/cli/commands/upgrade.ts +7 -6
  42. package/cli/commands/upload.ts +27 -51
  43. package/cli/commands/window.ts +449 -0
  44. package/cli/desktop-companion.ts +84 -10
  45. package/cli/drivers/desktop.ts +2 -0
  46. package/cli/drivers/playwright-sim-host.ts +12 -1
  47. package/cli/flow-file.ts +1 -0
  48. package/cli/maestro-compare.ts +19 -3
  49. package/cli/main.ts +22 -1
  50. package/cli/material-symbol-fonts.ts +171 -0
  51. package/cli/outbound-endpoints.ts +32 -16
  52. package/cli/preview-capture.ts +2 -2
  53. package/cli/run-registry.ts +5 -2
  54. package/cli/telemetry.ts +6 -3
  55. package/cli/ws-bridge.ts +15 -19
  56. package/detox/attempt-events.cjs +63 -7
  57. package/detox/attempt-events.d.cts +7 -0
  58. package/detox/gestures.ts +115 -2
  59. package/detox/index.ts +313 -411
  60. package/detox/jest-environment.cjs +86 -0
  61. package/detox/jest-preset.cjs +3 -1
  62. package/detox/jest-setup-after-env.cjs +1 -0
  63. package/detox/matchers.ts +3 -6
  64. package/detox/motion-change.cjs +9 -1
  65. package/detox/proof-frame.cjs +83 -9
  66. package/dist-lib/agent-daemon-client.cjs +1 -1
  67. package/dist-lib/agent-events.cjs +1 -1
  68. package/dist-lib/attached-projects.cjs +1 -1
  69. package/dist-lib/auth/shared-session.cjs +15 -5
  70. package/dist-lib/backend-origin.cjs +1 -1
  71. package/dist-lib/beta.cjs +1 -1
  72. package/dist-lib/beta.mjs +1 -1
  73. package/dist-lib/bridge-constants.cjs +1 -1
  74. package/dist-lib/bridge-contract-input.cjs +1 -1
  75. package/dist-lib/bridge-contract-input.mjs +1 -1
  76. package/dist-lib/bridge-contract.cjs +1 -1
  77. package/dist-lib/bridge-contract.mjs +1 -1
  78. package/dist-lib/bundle-channel.cjs +4005 -1362
  79. package/dist-lib/capture-contract.cjs +1 -1
  80. package/dist-lib/capture-contract.mjs +1 -1
  81. package/dist-lib/cloud-contract.cjs +6 -1
  82. package/dist-lib/cloud-contract.mjs +5 -1
  83. package/dist-lib/cloud-test-args.cjs +1 -1
  84. package/dist-lib/cloud-test-args.mjs +1 -1
  85. package/dist-lib/cloud.cjs +2 -2
  86. package/dist-lib/cloud.mjs +2 -2
  87. package/dist-lib/config.cjs +26 -7
  88. package/dist-lib/config.mjs +26 -7
  89. package/dist-lib/detox/index.cjs +1908 -539
  90. package/dist-lib/detox/jest-environment.cjs +86 -0
  91. package/dist-lib/detox/jest-preset.cjs +3 -1
  92. package/dist-lib/dev-bundle-resolution.cjs +1 -1
  93. package/dist-lib/expo-swift-bundle.cjs +3617 -1016
  94. package/dist-lib/expo-swift-metadata.cjs +1 -1
  95. package/dist-lib/expo-swift-metadata.mjs +1 -1
  96. package/dist-lib/home-paths.cjs +1 -1
  97. package/dist-lib/host/bridge-host.cjs +4760 -1825
  98. package/dist-lib/host/fetch-proxy-handler.cjs +59 -9
  99. package/dist-lib/host/fetch-proxy-overrides.cjs +62 -2
  100. package/dist-lib/host/fetch-proxy-overrides.mjs +56 -2
  101. package/dist-lib/host/replacement-module-handler.cjs +8 -8
  102. package/dist-lib/host/websocket-proxy.cjs +22 -6
  103. package/dist-lib/index.cjs +610 -184
  104. package/dist-lib/jump-to-source-babel.cjs +1 -1
  105. package/dist-lib/jump-to-source-native.cjs +33 -4
  106. package/dist-lib/kotlin-host.cjs +262 -121
  107. package/dist-lib/menu.cjs +1 -1
  108. package/dist-lib/menu.mjs +1 -1
  109. package/dist-lib/metro-fingerprint-registry.cjs +1 -1
  110. package/dist-lib/metro-fingerprint-registry.mjs +1 -1
  111. package/dist-lib/metro-production-bundle.cjs +101 -7
  112. package/dist-lib/metro-production-bundle.mjs +106 -7
  113. package/dist-lib/metro.cjs +315 -87
  114. package/dist-lib/native-app-identity-project.cjs +101 -26
  115. package/dist-lib/native-app-identity-project.mjs +101 -26
  116. package/dist-lib/native-app-identity.cjs +33 -7
  117. package/dist-lib/native-app-identity.mjs +33 -7
  118. package/dist-lib/native-firebase-metadata.cjs +1 -1
  119. package/dist-lib/native-firebase-metadata.mjs +1 -1
  120. package/dist-lib/plist.cjs +1 -1
  121. package/dist-lib/plist.mjs +1 -1
  122. package/dist-lib/profiles.cjs +1 -1
  123. package/dist-lib/public-brand.cjs +1 -1
  124. package/dist-lib/react-native-host-modules.cjs +1 -1
  125. package/dist-lib/react-native-host-modules.mjs +1 -1
  126. package/dist-lib/scripts/dev-server-scanner.cjs +30 -9
  127. package/dist-lib/sdk.cjs +236 -78
  128. package/dist-lib/sdk.mjs +236 -78
  129. package/dist-lib/skills.cjs +6788 -2636
  130. package/dist-lib/swift-compile.cjs +1637 -299
  131. package/dist-lib/swift-compile.mjs +1558 -219
  132. package/dist-lib/swift-package.cjs +25 -3
  133. package/dist-lib/swift-package.mjs +22 -2
  134. package/dist-lib/swift-submit.cjs +1 -1
  135. package/dist-lib/swift-submit.mjs +1 -1
  136. package/dist-lib/swift-toolchain.cjs +1 -1
  137. package/dist-lib/swift.cjs +1 -1
  138. package/dist-lib/vite-swift.cjs +3675 -1051
  139. package/dist-lib/vite.cjs +142 -20
  140. package/dist-lib/worklet-executable.cjs +1 -1
  141. package/dist-lib/worklet-executable.mjs +1 -1
  142. package/install.sh +1 -1
  143. package/kotlin/AppSources/android/view/Window.kt +4 -1
  144. package/kotlin/AppSources/android/window/BackEvent.kt +24 -0
  145. package/kotlin/AppSources/android/window/OnBackAnimationCallback.kt +7 -0
  146. package/kotlin/AppSources/android/window/OnBackInvokedDispatcher.kt +37 -1
  147. package/kotlin/AppSources/androidx/activity/BackEventCompat.kt +5 -0
  148. package/kotlin/AppSources/androidx/activity/OnBackPressedDispatcher.kt +8 -1
  149. package/kotlin/AppSources/dev/peach/app/PeachApp.kt +14 -0
  150. package/kotlin/CompilerSources/META-INF/services/org.jetbrains.kotlin.compiler.plugin.CompilerPluginRegistrar +1 -0
  151. package/kotlin/CompilerSources/PeachJniIncoming.kt +243 -0
  152. package/kotlin/CompilerSources/PeachJniPlugin.kt +116 -0
  153. package/kotlin/ComposeSources/PeachComposeHost.kt +27 -20
  154. package/kotlin/JniPlatformSources/androidx/annotation/CallSuper.kt +5 -0
  155. package/kotlin/JniPlatformSources/com/facebook/proguard/annotations/DoNotStrip.kt +5 -0
  156. package/kotlin/JniPlatformSources/dalvik/annotation/optimization/FastNative.kt +5 -0
  157. package/kotlin/JniPlatformSources/kotlin/Synchronized.kt +6 -0
  158. package/kotlin/JniPlatformSources/kotlin/jvm/JvmField.kt +5 -0
  159. package/kotlin/JniPlatformSources/kotlin/jvm/JvmStatic.kt +5 -0
  160. package/kotlin/JniSources/com/facebook/jni/HybridData.kt +68 -0
  161. package/kotlin/JniSources/dev/peach/jni/IncomingBindings.kt +157 -0
  162. package/kotlin/JniSources/dev/peach/jni/NativeBindings.kt +79 -0
  163. package/kotlin/JniTransportSources/dev/peach/jni/integration/JniTransport.kt +91 -0
  164. package/kotlin/README.md +18 -0
  165. package/package.json +1 -1
  166. package/scripts/dev-server-scanner.ts +30 -40
  167. package/skills/contrast/SKILL.md +1 -1
  168. package/skills/peach-perf/SKILL.md +25 -0
  169. package/skills/peach-setup/SKILL.md +25 -1
  170. package/src/assetCatalogContract.ts +31 -3
  171. package/src/auth/shared-session.ts +3 -2
  172. package/src/bridge-contract.ts +5 -0
  173. package/src/bundle-channel.mjs +1 -1
  174. package/src/cloud-contract.ts +11 -0
  175. package/src/cloud.ts +2 -2
  176. package/src/config.ts +16 -8
  177. package/src/expo-kotlin-host.ts +7 -2
  178. package/src/guest-bundle.ts +15 -8
  179. package/src/host/agent-host.ts +2 -4
  180. package/src/host/bridge-host.ts +52 -54
  181. package/src/host/fetch-proxy-handler.ts +94 -31
  182. package/src/host/fetch-proxy-overrides.ts +91 -0
  183. package/src/host/replacement-module-handler.ts +11 -10
  184. package/src/host/server-scan-handler.ts +1 -1
  185. package/src/host/staged-host-files.ts +92 -0
  186. package/src/host/websocket-proxy.ts +27 -6
  187. package/src/installed-package-versions.ts +1 -0
  188. package/src/jump-to-source-native.ts +3 -3
  189. package/src/kotlin-host.ts +143 -26
  190. package/src/kotlin-module.ts +52 -0
  191. package/src/metro-layout-parser.ts +78 -0
  192. package/src/metro-production-bundle.ts +59 -10
  193. package/src/native-app-identity-project.ts +67 -3
  194. package/src/native-app-identity.ts +48 -0
  195. package/src/native-firebase-config.ts +3 -2
  196. package/src/native-seam-manifest.ts +0 -44
  197. package/src/nativeAppIconContract.ts +11 -6
  198. package/src/nativeViewSourceContract.ts +49 -0
  199. package/src/nativeViewWebContract.ts +41 -0
  200. package/src/nitro-source.ts +3 -7
  201. package/src/one-widget-metadata.ts +23 -3
  202. package/src/oneWidgetContract.ts +22 -0
  203. package/src/process-group.ts +306 -0
  204. package/src/process-liveness.ts +24 -0
  205. package/src/process-output.ts +21 -2
  206. package/src/serverDiscoveryContract.ts +41 -0
  207. package/src/swift-compile.ts +1063 -265
  208. package/src/swift-design-time.ts +552 -0
  209. package/src/swift-distribution.lock.json +12 -11
  210. package/src/swift-host.ts +133 -25
  211. package/src/swift-objc-sdk.ts +726 -0
  212. package/src/swift-objc-standin.ts +441 -0
  213. package/src/swift-package.ts +25 -0
  214. package/src/swift-packages.ts +25 -10
  215. package/src/swift-standin-config.ts +94 -0
  216. package/src/swift-standin-emit.ts +5 -3
  217. package/src/swift-standins.ts +225 -81
  218. package/src/swiftContract.ts +4 -0
  219. package/src/target-matcher.ts +67 -0
  220. package/src/visit-parsed-program.ts +21 -0
  221. package/src/vite-plugin-one.ts +58 -16
  222. package/src/vite-plugin-swift.ts +1 -0
  223. package/src/vite-plugin.ts +105 -41
  224. package/src/worker-system-chunks.ts +86 -9
  225. package/src/worklets-babel.ts +85 -13
  226. package/src/xcode-project.ts +40 -15
@@ -45,7 +45,9 @@ import {
45
45
  runtimesDir,
46
46
  } from '../../src/home-paths'
47
47
  import { projectLoopbackUrl } from '../../src/host/loopback-ports'
48
+ import { stageHostFile } from '../../src/host/staged-host-files'
48
49
  import { readInstalledNativePackageVersions } from '../../src/installed-package-versions'
50
+ import { detectBundleModeFromText } from '../../src/metro-production-bundle'
49
51
  import {
50
52
  canExtractNativeAppIdentity,
51
53
  createNativeAppIdentityHeader,
@@ -59,6 +61,7 @@ import {
59
61
  readExpoConfigAsync,
60
62
  readNativeFirebaseConfig,
61
63
  } from '../../src/native-firebase-config'
64
+ import { isProcessRunning } from '../../src/process-liveness'
62
65
  import { ensureProfile } from '../../src/profiles'
63
66
  import { rnxPublicBrand } from '../../src/public-brand'
64
67
  import {
@@ -115,6 +118,7 @@ import {
115
118
  import { readNativeProjectRenderer } from './detect'
116
119
  import { inspectDescribe, inspectReadySnapshot, waitReadyReason } from './inspect/core'
117
120
  import { isAgentEnv } from './inspect/env'
121
+ import { waitForSimReady } from './inspect/shared'
118
122
  import { hasInstalledPeachSkills } from './skills'
119
123
  import type { DriverId, DriverLaunchResult } from '../drivers/types'
120
124
 
@@ -144,6 +148,11 @@ const DRIVER_CONNECT_INTERVAL_MS = 100
144
148
  const DRIVER_CONNECT_HOST_GRACE_MS = 5_000
145
149
  const OPEN_BRIDGE_SPAWN_TIMEOUT_MS = 10_000
146
150
  const OPEN_BRIDGE_RUNTIME_TIMEOUT_MS = 120_000
151
+ // a closed port refuses at once, so this window only matters when something
152
+ // listens: a live bridge on a loaded machine can take longer than 250 ms to
153
+ // finish the handshake, and missing it spawned a second bridge or reported a
154
+ // live daemon as unreachable.
155
+ const LIVE_BRIDGE_HANDSHAKE_TIMEOUT_MS = 2_000
147
156
 
148
157
  // built-in shell apps use pathname-based routing, not ?bundle= params
149
158
  const SHELL_APPS: Record<string, string> = {
@@ -235,7 +244,10 @@ export async function ensureBridgeForOpen(
235
244
  )
236
245
  // a bridge accepts connections while its first runtime download is still
237
246
  // running, and every shell page answers 503 until that finishes.
238
- if (runtimeSource === 'bridge') await waitForBridgeRuntime(port)
247
+ if (runtimeSource === 'bridge') {
248
+ await waitForBridgeRuntime(port)
249
+ await ensureRuntimeForOpen()
250
+ }
239
251
  return port
240
252
  }
241
253
 
@@ -262,13 +274,8 @@ async function connectBridgeForOpen(
262
274
  ): Promise<number> {
263
275
  const lock = readDaemonLockfile()
264
276
  assertExplicitBridgeLock(preferredPort, explicitPort, lock)
265
- // an explicit shell origin owns its assets; the bridge only routes commands.
266
- if (runtimeSource === 'bridge') await ensureRuntimeForOpen()
267
277
  const preferredWorld = worldForBridgePort(preferredPort)
268
- // a closed port refuses at once, so this window only matters when something
269
- // listens: a live bridge on a loaded machine can take longer than 250 ms to
270
- // finish the handshake, and missing it spawned a second bridge.
271
- const handshake = await bridgeHandshake(preferredPort, 2_000)
278
+ const handshake = await bridgeHandshake(preferredPort, LIVE_BRIDGE_HANDSHAKE_TIMEOUT_MS)
272
279
  if ((explicitPort || preferredWorld !== null) && handshake === 'open') {
273
280
  return preferredPort
274
281
  }
@@ -279,7 +286,9 @@ async function connectBridgeForOpen(
279
286
  !explicitPort &&
280
287
  lock &&
281
288
  isDaemonLockfileFresh(lock) &&
282
- (await canConnectToBridge(lock.bridgePort, 500))
289
+ (lock.bridgePort === preferredPort
290
+ ? handshake === 'open'
291
+ : await canConnectToBridge(lock.bridgePort, LIVE_BRIDGE_HANDSHAKE_TIMEOUT_MS))
283
292
  ) {
284
293
  return lock.bridgePort
285
294
  }
@@ -289,6 +298,9 @@ async function connectBridgeForOpen(
289
298
  )
290
299
  }
291
300
 
301
+ // starting a bridge needs ready local assets; an existing bridge gets
302
+ // to finish its own install before the caller validates them.
303
+ if (runtimeSource === 'bridge') await ensureRuntimeForOpen()
292
304
  const { executable, prefixArgs } = rnxSelfInvocation()
293
305
  // the bridge outlives open, so it must not hold the command's captured stderr.
294
306
  const bridgeLogPath = path.join(
@@ -510,7 +522,12 @@ function buildShellPathOpenUrl(target: string, baseUrl: string): string {
510
522
  : targetUrl.pathname
511
523
 
512
524
  url.pathname = `${prefix}${routePath}`
525
+ const baseDemo = url.searchParams.getAll('demo')
513
526
  url.search = targetUrl.search
527
+ // demo also sets embed chrome around native routes; a target can override it.
528
+ if (!targetUrl.searchParams.has('demo')) {
529
+ for (const demo of baseDemo) url.searchParams.append('demo', demo)
530
+ }
514
531
  url.hash = targetUrl.hash
515
532
  return url.toString()
516
533
  }
@@ -639,7 +656,6 @@ function buildConnectOpenUrl(target: string, baseUrl: string): string {
639
656
 
640
657
  url.pathname = `${prefix}/rn`
641
658
  url.searchParams.delete('bundle')
642
- url.searchParams.delete('demo')
643
659
  url.searchParams.delete('app')
644
660
  url.searchParams.delete('open')
645
661
  url.searchParams.delete('port')
@@ -666,6 +682,39 @@ function shouldResolveBundleTargetInCli(target: string): boolean {
666
682
  )
667
683
  }
668
684
 
685
+ // a built bundle on disk (`peach open ./dist/ios.js`). the bridge serves a
686
+ // staged copy, so it loads with no dev server running.
687
+ function prebuiltBundleFile(target: string): string | null {
688
+ if (!/\.(js|bundle|jsbundle)$/i.test(target)) return null
689
+ const file = path.resolve(process.cwd(), target)
690
+ return fs.statSync(file, { throwIfNoEntry: false })?.isFile() ? file : null
691
+ }
692
+
693
+ // the nearest app above the bundle supplies its fonts, splash and identity
694
+ function appDirAbove(file: string): string | undefined {
695
+ for (let dir = path.dirname(file); dir !== path.dirname(dir); dir = path.dirname(dir)) {
696
+ if (resolveAppProject(dir)) return dir
697
+ }
698
+ return undefined
699
+ }
700
+
701
+ // the url names the bundle's own mode, so the engine boots the matching
702
+ // tenant first instead of restarting into it after the fetch.
703
+ async function stagePrebuiltBundle(
704
+ file: string,
705
+ bridgePort: number,
706
+ ): Promise<ResolvedDevBundle> {
707
+ const mode = await detectBundleModeFromText(fs.readFileSync(file, 'utf8'))
708
+ if (!mode) {
709
+ throw new Error(
710
+ `${file} carries no __DEV__ prelude or Metro module shape, so its development or production build cannot be told apart`,
711
+ )
712
+ }
713
+ const url = new URL(stageHostFile(file, `http://localhost:${bridgePort}`))
714
+ url.searchParams.set('dev', String(mode === 'development'))
715
+ return { bundleUrl: url.toString(), port: bridgePort, framework: 'unknown' }
716
+ }
717
+
669
718
  async function buildResolvedBundleOpenUrl(
670
719
  target: string,
671
720
  baseUrl: string,
@@ -677,7 +726,6 @@ async function buildResolvedBundleOpenUrl(
677
726
  url.pathname = `${prefix}/rn`
678
727
  url.searchParams.delete('open')
679
728
  url.searchParams.delete('port')
680
- url.searchParams.delete('demo')
681
729
  url.searchParams.delete('app')
682
730
  const bundleUrl =
683
731
  opts.hot === undefined
@@ -809,7 +857,12 @@ export function resolveShellBaseUrlForBridgePort(bridgePort: number): string {
809
857
  // fetches itself. auto-discovery only — scans the repo for expo-font config
810
858
  // plugin declarations, no user-facing flag. discovery keeps only font files
811
859
  // that exist on disk, so the stamp never waits on the dev server.
812
- function stampAppFonts(urlStr: string, project?: ResolvedAppProject | null): string {
860
+ function stampAppFonts(
861
+ urlStr: string,
862
+ platform: 'ios' | 'android',
863
+ nativeAssetOrigin: string,
864
+ project?: ResolvedAppProject | null,
865
+ ): string {
813
866
  try {
814
867
  const url = new URL(urlStr)
815
868
  const bundleUrl = url.searchParams.get('bundle') || ''
@@ -817,6 +870,8 @@ function stampAppFonts(urlStr: string, project?: ResolvedAppProject | null): str
817
870
  const specs = discoverNativeLinkedAppFonts({
818
871
  bundleUrl,
819
872
  projectDir: project.appDir,
873
+ platform,
874
+ nativeAssetOrigin,
820
875
  })
821
876
  if (specs.length === 0) return urlStr
822
877
  url.searchParams.set('appFonts', encodeAppFontsWire(specs))
@@ -1095,21 +1150,32 @@ export async function buildShellUrl(
1095
1150
  const project =
1096
1151
  opts.project ?? (await resolveAppProjectForBundleUrl(bundleUrlOf(resolved)))
1097
1152
  const expoRoot = project?.appDir ?? null
1153
+ const world = resolveBridgeWorld()
1154
+ const port = resolveBridgePortForPin({
1155
+ baseUrl,
1156
+ wsPort: opts.bridgePort ?? world.bridgePort,
1157
+ explicitPort: opts.bridgePort !== undefined,
1158
+ })
1159
+ if (project && port.error) throw new Error(port.error)
1160
+ const device = opts.device ?? new URL(resolved).searchParams.get('device')
1161
+ const bundle = bundleUrlOf(resolved)
1162
+ const platform =
1163
+ device && isDeviceModel(device)
1164
+ ? getRuntimePlatform(device)
1165
+ : bundle && new URL(bundle).searchParams.get('platform') === 'android'
1166
+ ? 'android'
1167
+ : 'ios'
1098
1168
  const stamped = stampAppCatalog(
1099
- await stampAppSplash(stampAppFonts(resolved, project), project, opts.oneAppSplash),
1169
+ await stampAppSplash(
1170
+ stampAppFonts(resolved, platform, `http://localhost:${port.port}`, project),
1171
+ project,
1172
+ opts.oneAppSplash,
1173
+ ),
1100
1174
  project,
1101
1175
  )
1102
1176
  const expoStamped = new URL(stamped)
1103
1177
  expoStamped.searchParams.delete('nativeAppIdentity')
1104
1178
  if (project) {
1105
- const device = opts.device ?? expoStamped.searchParams.get('device')
1106
- const bundle = bundleUrlOf(resolved)
1107
- const platform =
1108
- device && isDeviceModel(device)
1109
- ? getRuntimePlatform(device)
1110
- : bundle && new URL(bundle).searchParams.get('platform') === 'android'
1111
- ? 'android'
1112
- : 'ios'
1113
1179
  const nativeRenderer = readNativeProjectRenderer(project.appDir, platform)
1114
1180
  if (nativeRenderer !== undefined) {
1115
1181
  const config = readPeachConfigFromSearchParams(expoStamped.searchParams)
@@ -1148,13 +1214,6 @@ export async function buildShellUrl(
1148
1214
  expoStamped.searchParams.set('bundleOriginOverride', opts.bundleOriginOverride)
1149
1215
  if (opts.hostDriver) expoStamped.searchParams.set('peachHostDriver', opts.hostDriver)
1150
1216
  if (expoRoot) {
1151
- const world = resolveBridgeWorld()
1152
- const port = resolveBridgePortForPin({
1153
- baseUrl,
1154
- wsPort: opts.bridgePort ?? world.bridgePort,
1155
- explicitPort: opts.bridgePort !== undefined,
1156
- })
1157
- if (port.error) throw new Error(port.error)
1158
1217
  expoStamped.searchParams.set(
1159
1218
  'expoSwift',
1160
1219
  `http://localhost:${port.port}/__peach/expo-swift?root=${encodeURIComponent(expoRoot)}`,
@@ -1198,7 +1257,8 @@ function deriveCurrentSimBaseUrl(
1198
1257
  try {
1199
1258
  const url = new URL(source)
1200
1259
  url.searchParams.delete('bundle')
1201
- url.searchParams.delete('demo')
1260
+ // demo is embed chrome around the next target, like device/theme: the
1261
+ // selected-sim rebuild keeps it unless the target overrides it.
1202
1262
  url.searchParams.delete('app')
1203
1263
  url.searchParams.delete('open')
1204
1264
  url.searchParams.delete('port')
@@ -1232,65 +1292,6 @@ function withOpenToken(shellUrl: string, openToken: string): string {
1232
1292
  return url.toString()
1233
1293
  }
1234
1294
 
1235
- async function waitForSimReady(
1236
- wsPort: number,
1237
- commandTimeoutMs: number,
1238
- simId: string,
1239
- opts: {
1240
- attempts?: number
1241
- intervalMs?: number
1242
- minNodeCount?: number
1243
- } = {},
1244
- ) {
1245
- // the ceiling here is attempts * intervalMs. a hosted launchApp re-evals the
1246
- // whole guest bundle over a tunnel, which routinely outlasts the local
1247
- // default, so scale the poll ceiling by the same factor the flow runner
1248
- // scales its own waits. this still returns the moment the sim reports enough
1249
- // nodes; only the deadline moves, and locally the scale is 1.
1250
- const intervalMs = opts.intervalMs ?? 500
1251
- const attempts = opts.attempts ?? Math.round(30 * flowTimeoutScale())
1252
- const minNodeCount = opts.minNodeCount ?? 10
1253
-
1254
- // a poll that only ever reports "timed out" cannot say whether the guest was
1255
- // still booting, never installed the test hook, or was unreachable, so keep
1256
- // the last observation and hand it to the caller for the failure message.
1257
- let lastCount: unknown
1258
- let lastError = ''
1259
-
1260
- for (let i = 0; i < attempts; i++) {
1261
- const bridge = createBridge(wsPort, {
1262
- commandTimeoutMs,
1263
- simId,
1264
- simIdSource: 'flag',
1265
- })
1266
- try {
1267
- const count = await bridge.send({
1268
- type: 'evaluate',
1269
- code: '(async () => (await window.__sootsimTest?.getNodeCount()) ?? null)()',
1270
- })
1271
- lastCount = count
1272
- lastError = ''
1273
- if (typeof count === 'number' && count > minNodeCount) {
1274
- return { bridge, count }
1275
- }
1276
- } catch (err) {
1277
- lastError = err instanceof Error ? err.message : String(err)
1278
- }
1279
- bridge.close()
1280
- await sleep(intervalMs)
1281
- }
1282
-
1283
- const observed = lastError
1284
- ? `last bridge error: ${lastError}`
1285
- : lastCount === null
1286
- ? 'guest never installed window.__sootsimTest'
1287
- : `last node count: ${String(lastCount)} (needs > ${minNodeCount})`
1288
- return {
1289
- bridge: null,
1290
- failure: `after ${attempts} polls over ${Math.round((attempts * intervalMs) / 1000)}s; ${observed}`,
1291
- }
1292
- }
1293
-
1294
1295
  export async function waitForSimMatch(
1295
1296
  wsPort: number,
1296
1297
  commandTimeoutMs: number,
@@ -1335,7 +1336,8 @@ export async function waitForSimMatchOrHostExit(
1335
1336
  const attempts = opts.attempts ?? 30
1336
1337
  const intervalMs = opts.intervalMs ?? 500
1337
1338
  const watchHost = !!hostPid && Number.isInteger(hostPid) && hostPid > 1
1338
- const hostExited = () => watchHost && hostPid !== undefined && !isProcessAlive(hostPid)
1339
+ const hostExited = () =>
1340
+ watchHost && hostPid !== undefined && !isProcessRunning(hostPid)
1339
1341
  for (let i = 0; i < attempts; i++) {
1340
1342
  opts.signal?.throwIfAborted()
1341
1343
  if (hostExited()) {
@@ -1444,22 +1446,13 @@ export function printDriverDiagnosticLogTail(result: DriverLaunchResult) {
1444
1446
  }
1445
1447
  }
1446
1448
 
1447
- function isProcessAlive(pid: number): boolean {
1448
- try {
1449
- process.kill(pid, 0)
1450
- return true
1451
- } catch (err) {
1452
- return (err as NodeJS.ErrnoException)?.code === 'EPERM'
1453
- }
1454
- }
1455
-
1456
1449
  async function waitForProcessExit(pid: number, timeoutMs = 2_500): Promise<boolean> {
1457
1450
  const deadline = Date.now() + timeoutMs
1458
1451
  while (Date.now() < deadline) {
1459
- if (!isProcessAlive(pid)) return true
1452
+ if (!isProcessRunning(pid)) return true
1460
1453
  await sleep(100)
1461
1454
  }
1462
- return !isProcessAlive(pid)
1455
+ return !isProcessRunning(pid)
1463
1456
  }
1464
1457
 
1465
1458
  // a sim record's host pid can outlive its process; after pid reuse the number
@@ -1468,6 +1461,17 @@ async function waitForProcessExit(pid: number, timeoutMs = 2_500): Promise<boole
1468
1461
  // whole dev stack down). only signal a pid whose argv proves it is still a
1469
1462
  // peach playwright host launcher.
1470
1463
  function isPlaywrightHostProcess(pid: number): boolean {
1464
+ // linux reads the environment from /proc: container images often ship no ps.
1465
+ if (process.platform === 'linux') {
1466
+ try {
1467
+ return fs
1468
+ .readFileSync(`/proc/${pid}/environ`, 'utf8')
1469
+ .split('\0')
1470
+ .some((entry) => entry.startsWith('SOOTSIM_PW_URL='))
1471
+ } catch {
1472
+ return false
1473
+ }
1474
+ }
1471
1475
  // `ps eww` prints argv then the process ENVIRONMENT for same-user
1472
1476
  // processes. the marker must live in the env SUFFIX (everything past the
1473
1477
  // plain argv), because argv text can contain the string coincidentally —
@@ -1637,7 +1641,6 @@ async function maybeDescribeAfterOpen(wsPort: number, simId: string, args: strin
1637
1641
  filter: '',
1638
1642
  compact: true,
1639
1643
  hideXy: false,
1640
- maxDepth: 12,
1641
1644
  })
1642
1645
  if (!result?.tree) return
1643
1646
  console.log('')
@@ -1876,9 +1879,12 @@ export async function runOpenCommand(
1876
1879
  return
1877
1880
  }
1878
1881
 
1879
- const resolvedBundle = shouldResolveBundleTargetInCli(target)
1880
- ? await resolveConnectionInputForCli(target)
1881
- : undefined
1882
+ const prebuiltBundle = prebuiltBundleFile(target)
1883
+ // a bundle file gets its url once the bridge that serves it is settled
1884
+ let resolvedBundle =
1885
+ !prebuiltBundle && shouldResolveBundleTargetInCli(target)
1886
+ ? await resolveConnectionInputForCli(target)
1887
+ : undefined
1882
1888
  const projectRootIndex = args.indexOf('--project-root')
1883
1889
  const projectRoot = projectRootIndex < 0 ? undefined : args[projectRootIndex + 1]
1884
1890
  if (projectRootIndex >= 0 && (!projectRoot || projectRoot.startsWith('--'))) {
@@ -1887,7 +1893,7 @@ export async function runOpenCommand(
1887
1893
  }
1888
1894
  const project = await resolveAppProjectForBundleUrl(
1889
1895
  resolvedBundle?.bundleUrl,
1890
- projectRoot,
1896
+ projectRoot ?? (prebuiltBundle ? appDirAbove(prebuiltBundle) : undefined),
1891
1897
  )
1892
1898
  startNativeFirebaseConfigRead(project)
1893
1899
  // playwright is the browser-sim launcher. it owns an isolated profile and
@@ -2000,10 +2006,25 @@ export async function runOpenCommand(
2000
2006
  parsed.wsPort,
2001
2007
  parsed.explicitPort,
2002
2008
  opts.resources,
2003
- explicitBaseUrl && !configuredRuntimeVersion ? 'external' : 'bridge',
2009
+ !configuredRuntimeVersion && (explicitBaseUrl || looksLikeSootsimUrl(target))
2010
+ ? 'external'
2011
+ : 'bridge',
2004
2012
  )
2005
2013
  if (opts.resources) opts.resources.port = parsed.wsPort
2006
2014
  opts.signal?.throwIfAborted()
2015
+ // the shell opens a bundle file as the url the bridge serves it at
2016
+ let shellTarget = target
2017
+ if (prebuiltBundle) {
2018
+ try {
2019
+ resolvedBundle = await stagePrebuiltBundle(prebuiltBundle, parsed.wsPort)
2020
+ shellTarget = resolvedBundle.bundleUrl
2021
+ } catch (error) {
2022
+ console.error(
2023
+ ` peach open: ${error instanceof Error ? error.message : String(error)}`,
2024
+ )
2025
+ peachExit(1)
2026
+ }
2027
+ }
2007
2028
 
2008
2029
  // --posture <open|closed> seeds the fold posture for the opened sim,
2009
2030
  // stamped on the engine URL next to ?device= so it round-trips instead of
@@ -2179,7 +2200,7 @@ export async function runOpenCommand(
2179
2200
  }
2180
2201
  if (!targetSim && parsed.simIdSource !== 'flag') {
2181
2202
  targetShellUrl = newSimShellUrl = await buildShellUrl(
2182
- target,
2203
+ shellTarget,
2183
2204
  applyPeachConfigToUrl(baseUrl, runtimeConfig),
2184
2205
  buildShellUrlOpts,
2185
2206
  )
@@ -2218,7 +2239,7 @@ export async function runOpenCommand(
2218
2239
  navigatedShellUrl =
2219
2240
  targetShellUrl ??
2220
2241
  (await buildShellUrl(
2221
- target,
2242
+ shellTarget,
2222
2243
  applyPeachConfigToUrl(resolvedBaseUrl, runtimeConfig),
2223
2244
  buildShellUrlOpts,
2224
2245
  ))
@@ -2278,6 +2299,7 @@ export async function runOpenCommand(
2278
2299
  parsed.wsPort,
2279
2300
  parsed.commandTimeoutMs,
2280
2301
  targetSim.id,
2302
+ { attempts: Math.round(30 * flowTimeoutScale()), simIdSource: 'flag' },
2281
2303
  )
2282
2304
  if (!ready.bridge) {
2283
2305
  console.error(
@@ -2309,7 +2331,8 @@ export async function runOpenCommand(
2309
2331
 
2310
2332
  const configuredBaseUrl = applyPeachConfigToUrl(baseUrl, runtimeConfig)
2311
2333
  const openUrl = withOpenToken(
2312
- newSimShellUrl ?? (await buildShellUrl(target, configuredBaseUrl, buildShellUrlOpts)),
2334
+ newSimShellUrl ??
2335
+ (await buildShellUrl(shellTarget, configuredBaseUrl, buildShellUrlOpts)),
2313
2336
  token,
2314
2337
  )
2315
2338
  const ownerPid = opts.ownerPid ?? getStableOwnerPid()
@@ -2401,6 +2424,7 @@ export async function runOpenCommand(
2401
2424
  const ready = await waitForSimReady(parsed.wsPort, parsed.commandTimeoutMs, match.id, {
2402
2425
  intervalMs: connectWait.intervalMs,
2403
2426
  attempts: connectWait.attempts,
2427
+ simIdSource: 'flag',
2404
2428
  })
2405
2429
  if (!ready.bridge) {
2406
2430
  console.error(
@@ -24,6 +24,7 @@ import {
24
24
  readlinkSync,
25
25
  realpathSync,
26
26
  rmSync,
27
+ statSync,
27
28
  writeFileSync,
28
29
  } from 'fs'
29
30
  import { homedir } from 'os'
@@ -365,6 +366,91 @@ function macBridgeHasListener(pid: number, port: number): boolean {
365
366
  return result.stdout.trim() === String(pid)
366
367
  }
367
368
 
369
+ // a process runs this invocation's `serve` when its executable is the same
370
+ // file and its arguments start with the entry script and `serve`. argv[0] is
371
+ // spelled however the spawner named the file (a symlinked /tmp, a bin shim,
372
+ // a relative path) while process.execPath is resolved, so the executable is
373
+ // compared by device and inode, never by path text. inodes on the system
374
+ // volume exceed 2^53, so both compare as bigints.
375
+ function sameFile(path: string, device: bigint, inode: bigint): boolean {
376
+ try {
377
+ const stats = statSync(path, { bigint: true })
378
+ return stats.dev === device && stats.ino === inode
379
+ } catch {
380
+ return false
381
+ }
382
+ }
383
+
384
+ function serveArgs(invocation: RnxSelfInvocation): string[] {
385
+ return [...invocation.prefixArgs, 'serve']
386
+ }
387
+
388
+ function macProcessRunsServe(pid: number, invocation: RnxSelfInvocation): boolean {
389
+ // lsof names each mapped text file by device and inode; the executable is one.
390
+ const files = spawnSync('lsof', ['-nP', '-a', '-p', String(pid), '-d', 'txt', '-FDi'], {
391
+ encoding: 'utf8',
392
+ })
393
+ let device = -1n
394
+ let runsExecutable = false
395
+ for (const line of files.stdout.split('\n')) {
396
+ if (line.startsWith('D')) device = BigInt(line.slice(1))
397
+ else if (
398
+ line.startsWith('i') &&
399
+ sameFile(invocation.executable, device, BigInt(line.slice(1)))
400
+ )
401
+ runsExecutable = true
402
+ }
403
+ if (!runsExecutable) return false
404
+ const args = macProcessArgs(pid)
405
+ return (
406
+ args !== null && serveArgs(invocation).every((arg, index) => args[index + 1] === arg)
407
+ )
408
+ }
409
+
410
+ // the kernel's exact argv vector (KERN_PROCARGS2: argc, executable path, then
411
+ // argc NUL-terminated arguments), read through the system JXA Foundation
412
+ // bridge. `ps` flattens argv with spaces and cannot recover argument
413
+ // boundaries.
414
+ function macProcessArgs(pid: number): string[] | null {
415
+ const int32 = (value: number) => {
416
+ const bytes = Buffer.alloc(4)
417
+ bytes.writeUInt32LE(value)
418
+ return bytes
419
+ }
420
+ const capacity = 1 << 20 // ARG_MAX
421
+ const mib = Buffer.concat([int32(1), int32(49), int32(pid)]).toString('base64')
422
+ const size = Buffer.alloc(8)
423
+ size.writeBigUInt64LE(BigInt(capacity))
424
+ const script = `
425
+ ObjC.import('Foundation');
426
+ ObjC.bindFunction('sysctl', ['int', ['void *', 'unsigned int', 'void *', 'void *', 'void *', 'unsigned long']]);
427
+ const mib = $.NSData.alloc.initWithBase64EncodedStringOptions(${JSON.stringify(mib)}, 0).mutableCopy;
428
+ const size = $.NSData.alloc.initWithBase64EncodedStringOptions(${JSON.stringify(size.toString('base64'))}, 0).mutableCopy;
429
+ const out = $.NSMutableData.dataWithLength(${capacity});
430
+ if ($.sysctl(mib.mutableBytes, 3, out.mutableBytes, size.mutableBytes, null, 0) !== 0)
431
+ throw new Error('sysctl KERN_PROCARGS2 failed');
432
+ ObjC.unwrap(size.base64EncodedStringWithOptions(0)) + ' ' + ObjC.unwrap(out.base64EncodedStringWithOptions(0));
433
+ `
434
+ // runner-toolchain-platform: darwin
435
+ const result = spawnSync('/usr/bin/osascript', ['-l', 'JavaScript', '-e', script], {
436
+ encoding: 'utf8',
437
+ maxBuffer: 4 * capacity,
438
+ })
439
+ if (result.status !== 0) return null
440
+ const [length, data] = result.stdout.trim().split(' ')
441
+ const buffer = Buffer.from(data, 'base64').subarray(
442
+ 0,
443
+ Number(Buffer.from(length, 'base64').readBigUInt64LE()),
444
+ )
445
+ const argc = buffer.readInt32LE(0)
446
+ let at = buffer.indexOf(0, 4) // end of the executable path
447
+ while (buffer[at] === 0) at++ // padding before argv[0]
448
+ const args: string[] = []
449
+ for (; args.length < argc; at = buffer.indexOf(0, at) + 1)
450
+ args.push(buffer.toString('utf8', at, buffer.indexOf(0, at)))
451
+ return args
452
+ }
453
+
368
454
  export async function stopMacDaemonBridge(
369
455
  invocation: RnxSelfInvocation,
370
456
  target: string | null = macServiceTarget(),
@@ -373,16 +459,9 @@ export async function stopMacDaemonBridge(
373
459
  const lock = readDaemonLockfile()
374
460
  if (isDaemonLockfileFresh(lock) && lock.pid !== service?.pid) {
375
461
  // a lockfile alone cannot authorize a signal after pid reuse.
376
- const ownsListener = macBridgeHasListener(lock.pid, lock.bridgePort)
377
- const processInfo = spawnSync('ps', ['-o', 'command=', '-p', String(lock.pid)], {
378
- encoding: 'utf8',
379
- })
380
- const command = [invocation.executable, ...invocation.prefixArgs, 'serve'].join(' ')
381
- const runningCommand = processInfo.stdout.trim()
382
462
  if (
383
- !ownsListener ||
384
- processInfo.status !== 0 ||
385
- (runningCommand !== command && !runningCommand.startsWith(`${command} `))
463
+ !macBridgeHasListener(lock.pid, lock.bridgePort) ||
464
+ !macProcessRunsServe(lock.pid, invocation)
386
465
  ) {
387
466
  throw new Error(
388
467
  `cannot hand off bridge on port ${lock.bridgePort}: pid ${lock.pid} is not this Peach invocation`,
@@ -441,7 +520,7 @@ export async function startMacDaemon(
441
520
 
442
521
  // --- linux bridge ownership --------------------------------------------
443
522
 
444
- // the linux counterparts to the mac listener and command checks above. both
523
+ // the linux counterparts to the mac listener and executable checks above. both
445
524
  // read /proc directly instead of shelling out to lsof, ss or ps, so minimal
446
525
  // containers and fresh users get the same ownership proof with no extra
447
526
  // binaries installed.
@@ -483,16 +562,16 @@ function linuxBridgeHasListener(pid: number, port: number): boolean {
483
562
  return false
484
563
  }
485
564
 
486
- function readLinuxProcessCommand(pid: number): string | null {
487
- // /proc cmdline is the argv vector verbatim; joining with spaces matches
488
- // what `ps -o command=` prints without needing procps installed.
565
+ function linuxProcessRunsServe(pid: number, invocation: RnxSelfInvocation): boolean {
566
+ // /proc/<pid>/exe resolves to the running executable and cmdline is the argv
567
+ // vector verbatim, so both compare exactly without procps installed.
489
568
  try {
490
- const args = readFileSync(`/proc/${pid}/cmdline`, 'utf8')
491
- .split('\0')
492
- .filter((part) => part.length > 0)
493
- return args.length > 0 ? args.join(' ') : null
569
+ const exe = statSync(`/proc/${pid}/exe`, { bigint: true })
570
+ if (!sameFile(invocation.executable, exe.dev, exe.ino)) return false
571
+ const args = readFileSync(`/proc/${pid}/cmdline`, 'utf8').split('\0').slice(1)
572
+ return serveArgs(invocation).every((arg, index) => args[index] === arg)
494
573
  } catch {
495
- return null
574
+ return false
496
575
  }
497
576
  }
498
577
 
@@ -514,13 +593,9 @@ export async function stopLinuxDaemonBridge(
514
593
  const lock = readDaemonLockfile()
515
594
  if (!isDaemonLockfileFresh(lock)) return
516
595
  // a lockfile alone cannot authorize a signal after pid reuse.
517
- const ownsListener = linuxBridgeHasListener(lock.pid, lock.bridgePort)
518
- const runningCommand = readLinuxProcessCommand(lock.pid)
519
- const command = [invocation.executable, ...invocation.prefixArgs, 'serve'].join(' ')
520
596
  if (
521
- !ownsListener ||
522
- runningCommand === null ||
523
- (runningCommand !== command && !runningCommand.startsWith(`${command} `))
597
+ !linuxBridgeHasListener(lock.pid, lock.bridgePort) ||
598
+ !linuxProcessRunsServe(lock.pid, invocation)
524
599
  ) {
525
600
  throw new Error(
526
601
  `cannot hand off bridge on port ${lock.bridgePort}: pid ${lock.pid} is not this Peach invocation`,
@@ -40,6 +40,7 @@ import type {
40
40
  ImageAuditReport,
41
41
  ImageAuditRow,
42
42
  } from '../../../peach-engine/src/render-worker/compositor-protocol'
43
+ import type { PeachDebugSnapshotOperation } from '@peach/globals'
43
44
 
44
45
  interface DebugOptions {
45
46
  port?: number
@@ -70,6 +71,7 @@ usage:
70
71
 
71
72
  record, recent, and clear-events target the tenant; --host selects the page buffer.
72
73
  snapshot, snapshots, diff, and clear-snapshots also target the tenant by default.
74
+ --shell selects the mounted shell worker trees for snapshot operations.
73
75
  enable/disable/toggle also update the tenant unless --host is passed.
74
76
  shell event buffers are separate; shell console logs are forwarded to the host.
75
77
 
@@ -376,7 +378,7 @@ function printImageAudit(
376
378
  export async function runDebug(args: string[], opts: DebugOptions) {
377
379
  const parsed = parseBridgeCliArgs(args, {
378
380
  port: opts.port,
379
- stripBooleanFlags: ['--pretty', '--json', '--help', '-h', '--host'],
381
+ stripBooleanFlags: ['--pretty', '--json', '--help', '-h', '--host', '--shell'],
380
382
  })
381
383
  const positional = parsed.positional
382
384
  const subcommand = positional[0]
@@ -422,6 +424,32 @@ export async function runDebug(args: string[], opts: DebugOptions) {
422
424
  const bridge = createBridgeFromParsed(parsed)
423
425
 
424
426
  try {
427
+ if (args.includes('--host') && args.includes('--shell')) {
428
+ throw new Error('select either --host or --shell')
429
+ }
430
+ if (
431
+ !args.includes('--host') &&
432
+ (sub === 'snapshot' ||
433
+ sub === 'snapshots' ||
434
+ sub === 'diff' ||
435
+ sub === 'clear-snapshots')
436
+ ) {
437
+ const operations = {
438
+ snapshot: 'snapshot',
439
+ snapshots: 'snapshots',
440
+ diff: 'diff',
441
+ 'clear-snapshots': 'clearSnapshots',
442
+ } satisfies Record<string, PeachDebugSnapshotOperation>
443
+ const result = await callTestBridge(
444
+ bridge,
445
+ 'debugSnapshot',
446
+ operations[sub],
447
+ rest,
448
+ args.includes('--shell') ? 'shell' : 'tenant',
449
+ )
450
+ console.log(fmt(result, pretty))
451
+ return
452
+ }
425
453
  switch (sub) {
426
454
  case 'enable': {
427
455
  const channels = parseChannelList(rest[0])