peachsim 0.1.527

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 (324) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +640 -0
  3. package/cli/app-config.ts +82 -0
  4. package/cli/app-fonts.ts +388 -0
  5. package/cli/app-project.ts +59 -0
  6. package/cli/app-splash.ts +171 -0
  7. package/cli/app-state-reset.ts +26 -0
  8. package/cli/auth.ts +184 -0
  9. package/cli/bin.ts +59 -0
  10. package/cli/bridge-diagnostics.ts +226 -0
  11. package/cli/bridge-flow-runner.ts +2806 -0
  12. package/cli/browser-backend.ts +44 -0
  13. package/cli/browser-evals.ts +96 -0
  14. package/cli/cli-update.ts +261 -0
  15. package/cli/cloud-client.ts +1530 -0
  16. package/cli/cloud-dispatch.ts +206 -0
  17. package/cli/cloud-session.ts +227 -0
  18. package/cli/command-contract.ts +221 -0
  19. package/cli/command-registry.ts +304 -0
  20. package/cli/commands/agent-wrapper.ts +1048 -0
  21. package/cli/commands/agent.ts +439 -0
  22. package/cli/commands/app-fonts.ts +99 -0
  23. package/cli/commands/assert.ts +542 -0
  24. package/cli/commands/auth.ts +71 -0
  25. package/cli/commands/box/bundle-plane.ts +71 -0
  26. package/cli/commands/box/checkout-plane.ts +256 -0
  27. package/cli/commands/box/plane-share.ts +208 -0
  28. package/cli/commands/box/rnx-command.ts +60 -0
  29. package/cli/commands/box/search-command.ts +157 -0
  30. package/cli/commands/box.ts +925 -0
  31. package/cli/commands/camera.ts +266 -0
  32. package/cli/commands/cleanup.ts +169 -0
  33. package/cli/commands/compat.ts +176 -0
  34. package/cli/commands/config.ts +34 -0
  35. package/cli/commands/control.ts +2154 -0
  36. package/cli/commands/cpu-profile.ts +941 -0
  37. package/cli/commands/daemon-mac-app.ts +165 -0
  38. package/cli/commands/daemon.ts +800 -0
  39. package/cli/commands/debug.ts +751 -0
  40. package/cli/commands/desktop.ts +39 -0
  41. package/cli/commands/detect.ts +201 -0
  42. package/cli/commands/detox.ts +481 -0
  43. package/cli/commands/device.ts +134 -0
  44. package/cli/commands/diagnose.ts +589 -0
  45. package/cli/commands/do-chain.ts +408 -0
  46. package/cli/commands/electron.ts +94 -0
  47. package/cli/commands/film.ts +379 -0
  48. package/cli/commands/flow.ts +1240 -0
  49. package/cli/commands/inspect/actions.ts +516 -0
  50. package/cli/commands/inspect/core.ts +2086 -0
  51. package/cli/commands/inspect/count.ts +17 -0
  52. package/cli/commands/inspect/describe.ts +119 -0
  53. package/cli/commands/inspect/env.ts +25 -0
  54. package/cli/commands/inspect/find.ts +169 -0
  55. package/cli/commands/inspect/get-layout.ts +53 -0
  56. package/cli/commands/inspect/keyboard.ts +54 -0
  57. package/cli/commands/inspect/list.ts +175 -0
  58. package/cli/commands/inspect/memory.ts +215 -0
  59. package/cli/commands/inspect/redaction.ts +39 -0
  60. package/cli/commands/inspect/resolve-target.ts +77 -0
  61. package/cli/commands/inspect/screens.ts +78 -0
  62. package/cli/commands/inspect/settle.ts +32 -0
  63. package/cli/commands/inspect/settling.ts +197 -0
  64. package/cli/commands/inspect/shared.ts +259 -0
  65. package/cli/commands/inspect/sleep.ts +15 -0
  66. package/cli/commands/inspect/tree.ts +23 -0
  67. package/cli/commands/inspect/url.ts +17 -0
  68. package/cli/commands/inspect/wait-event.ts +211 -0
  69. package/cli/commands/inspect/wait-idle.ts +25 -0
  70. package/cli/commands/inspect/wait-ready.ts +87 -0
  71. package/cli/commands/inspect/wait-selector.ts +59 -0
  72. package/cli/commands/inspect/wait.ts +32 -0
  73. package/cli/commands/inspect.ts +4738 -0
  74. package/cli/commands/install-cli.ts +275 -0
  75. package/cli/commands/install-desktop.ts +385 -0
  76. package/cli/commands/login.ts +357 -0
  77. package/cli/commands/logout.ts +24 -0
  78. package/cli/commands/maestro-generate.ts +375 -0
  79. package/cli/commands/maestro.ts +536 -0
  80. package/cli/commands/mode.ts +58 -0
  81. package/cli/commands/no-bridge-hint.ts +80 -0
  82. package/cli/commands/perf.ts +67 -0
  83. package/cli/commands/permissions.ts +203 -0
  84. package/cli/commands/platform.ts +410 -0
  85. package/cli/commands/preview.ts +103 -0
  86. package/cli/commands/profile.ts +106 -0
  87. package/cli/commands/react.ts +353 -0
  88. package/cli/commands/record.ts +1435 -0
  89. package/cli/commands/report-issue.ts +305 -0
  90. package/cli/commands/reset.ts +89 -0
  91. package/cli/commands/runtime.ts +348 -0
  92. package/cli/commands/screenshot-command.ts +106 -0
  93. package/cli/commands/screenshot-layers.ts +144 -0
  94. package/cli/commands/screenshot-mode.ts +38 -0
  95. package/cli/commands/screenshot.ts +492 -0
  96. package/cli/commands/screenshots-capture.ts +608 -0
  97. package/cli/commands/screenshots.ts +127 -0
  98. package/cli/commands/serve.ts +139 -0
  99. package/cli/commands/setup.ts +416 -0
  100. package/cli/commands/shell-boolean-mode.ts +82 -0
  101. package/cli/commands/skills.ts +468 -0
  102. package/cli/commands/slides.ts +357 -0
  103. package/cli/commands/state.ts +94 -0
  104. package/cli/commands/storage.ts +58 -0
  105. package/cli/commands/telemetry.ts +55 -0
  106. package/cli/commands/test.ts +535 -0
  107. package/cli/commands/three-mode.ts +771 -0
  108. package/cli/commands/timeline.ts +121 -0
  109. package/cli/commands/upgrade.ts +167 -0
  110. package/cli/commands/upload.ts +1203 -0
  111. package/cli/commands/version.ts +63 -0
  112. package/cli/commands/welcome.ts +17 -0
  113. package/cli/commands/what-happened.ts +328 -0
  114. package/cli/current-sim.ts +217 -0
  115. package/cli/desktop-companion.ts +315 -0
  116. package/cli/drivers/electron.ts +70 -0
  117. package/cli/drivers/index.ts +20 -0
  118. package/cli/drivers/playwright-provisioning.ts +184 -0
  119. package/cli/drivers/playwright-sim-host.ts +537 -0
  120. package/cli/drivers/playwright.ts +209 -0
  121. package/cli/drivers/registry.ts +65 -0
  122. package/cli/drivers/types.ts +102 -0
  123. package/cli/flow-export.ts +55 -0
  124. package/cli/flow-file.ts +142 -0
  125. package/cli/flow-live-status.ts +126 -0
  126. package/cli/flow-session.ts +140 -0
  127. package/cli/help-core.ts +143 -0
  128. package/cli/help.ts +44 -0
  129. package/cli/hidden-runtime-alias.ts +19 -0
  130. package/cli/hints.ts +214 -0
  131. package/cli/inspect-notice-state.ts +114 -0
  132. package/cli/internal-child.ts +176 -0
  133. package/cli/maestro-js.ts +323 -0
  134. package/cli/main.ts +704 -0
  135. package/cli/open-url.ts +8 -0
  136. package/cli/outbound-endpoints.ts +830 -0
  137. package/cli/parent-pid.ts +204 -0
  138. package/cli/parse-args.ts +189 -0
  139. package/cli/privacy.ts +53 -0
  140. package/cli/prompt.ts +56 -0
  141. package/cli/recording-access.ts +108 -0
  142. package/cli/registry.ts +2 -0
  143. package/cli/resolve-assets.ts +63 -0
  144. package/cli/rnx-in-process.ts +98 -0
  145. package/cli/run-registry.ts +226 -0
  146. package/cli/run-rnx-core.ts +148 -0
  147. package/cli/run-rnx.ts +137 -0
  148. package/cli/runtime-notes.ts +66 -0
  149. package/cli/runtime-summary.ts +25 -0
  150. package/cli/secure-file.ts +31 -0
  151. package/cli/self-invocation.ts +58 -0
  152. package/cli/send-to-box.ts +109 -0
  153. package/cli/setup-repository.ts +191 -0
  154. package/cli/shell-init.ts +98 -0
  155. package/cli/standalone.ts +4 -0
  156. package/cli/telemetry.ts +84 -0
  157. package/cli/ui.ts +68 -0
  158. package/cli/ws-bridge.ts +1020 -0
  159. package/detox/attempt-events.cjs +23 -0
  160. package/detox/colors.ts +54 -0
  161. package/detox/config-loader.ts +135 -0
  162. package/detox/element-types.ts +48 -0
  163. package/detox/expectations.ts +534 -0
  164. package/detox/gestures.ts +501 -0
  165. package/detox/index.ts +2694 -0
  166. package/detox/jest-preset.cjs +29 -0
  167. package/detox/jest-setup-after-env.cjs +22 -0
  168. package/detox/matchers.ts +30 -0
  169. package/detox/motion-change.cjs +109 -0
  170. package/detox/motion-change.d.cts +35 -0
  171. package/detox/navigation-lifecycle.ts +290 -0
  172. package/detox/navigation.ts +43 -0
  173. package/detox/proof-frame.cjs +114 -0
  174. package/detox/record-block-execution.cjs +78 -0
  175. package/detox/screenshots/animated-color-test-rest-norngh.png +0 -0
  176. package/detox/screenshots/color-test-after-drag-norngh.png +0 -0
  177. package/detox/screenshots/color-test-rest-norngh.png +0 -0
  178. package/detox/screenshots/theme-blue-toggle.png +0 -0
  179. package/detox/screenshots/theme-blue.png +0 -0
  180. package/detox/screenshots/theme-red-toggle.png +0 -0
  181. package/detox/screenshots/theme-red.png +0 -0
  182. package/dist-lib/agent-daemon-client.cjs +502 -0
  183. package/dist-lib/agent-events.cjs +48 -0
  184. package/dist-lib/agent-identity.cjs +53 -0
  185. package/dist-lib/agent-sessions.cjs +759 -0
  186. package/dist-lib/attached-projects.cjs +526 -0
  187. package/dist-lib/auth/shared-session.cjs +208 -0
  188. package/dist-lib/backend-origin.cjs +88 -0
  189. package/dist-lib/beta.cjs +41 -0
  190. package/dist-lib/beta.mjs +13 -0
  191. package/dist-lib/bridge-constants.cjs +161 -0
  192. package/dist-lib/bridge-contract-input.cjs +451 -0
  193. package/dist-lib/bridge-contract-input.mjs +414 -0
  194. package/dist-lib/bridge-contract.cjs +130 -0
  195. package/dist-lib/bridge-contract.mjs +94 -0
  196. package/dist-lib/capture-contract.cjs +220 -0
  197. package/dist-lib/capture-contract.mjs +190 -0
  198. package/dist-lib/cli-constants.cjs +32 -0
  199. package/dist-lib/cloud-contract.cjs +194 -0
  200. package/dist-lib/cloud-contract.mjs +145 -0
  201. package/dist-lib/cloud.cjs +670 -0
  202. package/dist-lib/cloud.mjs +639 -0
  203. package/dist-lib/config.cjs +96 -0
  204. package/dist-lib/detox/index.cjs +3789 -0
  205. package/dist-lib/detox/jest-preset.cjs +29 -0
  206. package/dist-lib/dev-bundle-resolution.cjs +290 -0
  207. package/dist-lib/home-paths.cjs +638 -0
  208. package/dist-lib/host/bridge-host.cjs +10903 -0
  209. package/dist-lib/host/fetch-proxy-handler.cjs +421 -0
  210. package/dist-lib/host/fetch-proxy-overrides.cjs +63 -0
  211. package/dist-lib/host/fetch-proxy-overrides.mjs +37 -0
  212. package/dist-lib/host/replacement-module-handler.cjs +469 -0
  213. package/dist-lib/host/websocket-proxy.cjs +298 -0
  214. package/dist-lib/index.cjs +4268 -0
  215. package/dist-lib/jump-to-source-babel.cjs +93 -0
  216. package/dist-lib/jump-to-source-native.cjs +112 -0
  217. package/dist-lib/menu.cjs +646 -0
  218. package/dist-lib/menu.mjs +604 -0
  219. package/dist-lib/metro-fingerprint-registry.cjs +186 -0
  220. package/dist-lib/metro-fingerprint-registry.mjs +156 -0
  221. package/dist-lib/metro-production-bundle.cjs +329 -0
  222. package/dist-lib/metro-production-bundle.mjs +295 -0
  223. package/dist-lib/metro.cjs +904 -0
  224. package/dist-lib/profiles.cjs +246 -0
  225. package/dist-lib/public-brand.cjs +39 -0
  226. package/dist-lib/react-native-host-modules.cjs +51 -0
  227. package/dist-lib/react-native-host-modules.mjs +25 -0
  228. package/dist-lib/render-mode.cjs +54 -0
  229. package/dist-lib/scripts/dev-server-scanner.cjs +1232 -0
  230. package/dist-lib/sdk.cjs +2433 -0
  231. package/dist-lib/sdk.mjs +2336 -0
  232. package/dist-lib/skills.cjs +39132 -0
  233. package/dist-lib/swift-submit.cjs +92 -0
  234. package/dist-lib/swift-submit.mjs +65 -0
  235. package/dist-lib/swift.cjs +1646 -0
  236. package/dist-lib/vite.cjs +1591 -0
  237. package/package.json +290 -0
  238. package/scripts/dev-server-scanner.ts +1291 -0
  239. package/scripts/optional-demo-registry.ts +113 -0
  240. package/skills/contrast/SKILL.md +165 -0
  241. package/skills/rnx-debug/SKILL.md +433 -0
  242. package/skills/rnx-perf/SKILL.md +399 -0
  243. package/skills/rnx-setup/SKILL.md +172 -0
  244. package/skills/rnx-test/SKILL.md +321 -0
  245. package/skills/rnx-visual/SKILL.md +126 -0
  246. package/src/agent-daemon-client.ts +410 -0
  247. package/src/agent-events.ts +71 -0
  248. package/src/agent-identity.ts +23 -0
  249. package/src/agent-prompt.ts +71 -0
  250. package/src/agent-sessions.ts +580 -0
  251. package/src/app-url.ts +39 -0
  252. package/src/attached-projects.ts +539 -0
  253. package/src/auth/shared-session.ts +235 -0
  254. package/src/backend-origin.ts +70 -0
  255. package/src/beta.ts +19 -0
  256. package/src/bridge-constants.ts +175 -0
  257. package/src/bridge-contract-input.ts +502 -0
  258. package/src/bridge-contract.ts +558 -0
  259. package/src/browser-cache.ts +16 -0
  260. package/src/capture-contract.ts +587 -0
  261. package/src/cli-constants.ts +1 -0
  262. package/src/cli-version.ts +40 -0
  263. package/src/cloud-contract.ts +274 -0
  264. package/src/cloud.ts +762 -0
  265. package/src/codex-client.ts +234 -0
  266. package/src/config.ts +132 -0
  267. package/src/connect.ts +101 -0
  268. package/src/dev-bundle-resolution.ts +346 -0
  269. package/src/dev-server-open.ts +198 -0
  270. package/src/disk-cleanup.ts +540 -0
  271. package/src/flow-timeout-scale.ts +15 -0
  272. package/src/home-paths.ts +806 -0
  273. package/src/host/agent-host.ts +666 -0
  274. package/src/host/bridge-host.ts +2788 -0
  275. package/src/host/fetch-proxy-handler.ts +449 -0
  276. package/src/host/fetch-proxy-overrides.ts +51 -0
  277. package/src/host/open-url.ts +89 -0
  278. package/src/host/plane-host.ts +203 -0
  279. package/src/host/replacement-module-handler.ts +158 -0
  280. package/src/host/websocket-proxy.ts +315 -0
  281. package/src/index.ts +71 -0
  282. package/src/jump-to-source-babel.ts +135 -0
  283. package/src/jump-to-source-native.ts +102 -0
  284. package/src/menu.ts +777 -0
  285. package/src/metro-fingerprint-registry.ts +302 -0
  286. package/src/metro-plugin.ts +341 -0
  287. package/src/metro-production-bundle.ts +471 -0
  288. package/src/native-dev-bundle-url.ts +118 -0
  289. package/src/native-seam-manifest.ts +341 -0
  290. package/src/playwright-host-log.ts +16 -0
  291. package/src/plist.ts +27 -0
  292. package/src/poll-until.ts +123 -0
  293. package/src/profiles.ts +179 -0
  294. package/src/public-brand.ts +9 -0
  295. package/src/react-native-host-modules.ts +26 -0
  296. package/src/render-mode.ts +27 -0
  297. package/src/rolldown-plugin-swift.ts +240 -0
  298. package/src/runtime-assets.ts +233 -0
  299. package/src/runtime-delivery.ts +54 -0
  300. package/src/screenshots/compose.ts +422 -0
  301. package/src/screenshots/frame-compose.ts +514 -0
  302. package/src/screenshots/orchestrate.ts +256 -0
  303. package/src/screenshots/org-deck.ts +91 -0
  304. package/src/screenshots/registry.ts +58 -0
  305. package/src/screenshots/schema.ts +364 -0
  306. package/src/sdk.ts +15 -0
  307. package/src/settings-bundle.ts +418 -0
  308. package/src/shell-bridge-identity.ts +15 -0
  309. package/src/sim-client.ts +697 -0
  310. package/src/skills/builtin/a11y-review.ts +132 -0
  311. package/src/skills/builtin/compat-check.ts +71 -0
  312. package/src/skills/builtin/maestro-test.ts +121 -0
  313. package/src/skills/builtin/perf-profile.ts +90 -0
  314. package/src/skills/builtin/screenshot-all.ts +52 -0
  315. package/src/skills/builtin/visual-diff.ts +100 -0
  316. package/src/skills/registry.ts +107 -0
  317. package/src/skills/types.ts +41 -0
  318. package/src/swift-submit.ts +185 -0
  319. package/src/swift.ts +7 -0
  320. package/src/team-machine-prompt.ts +73 -0
  321. package/src/vite-plugin-one.ts +264 -0
  322. package/src/vite-plugin-swift.ts +620 -0
  323. package/src/vite-plugin.ts +1946 -0
  324. package/src/worklets-babel.ts +250 -0
@@ -0,0 +1,1435 @@
1
+ // rnx record — drives the engine's built-in canvas recorder over
2
+ // the WS bridge. no playwright, no ffmpeg. encoding happens in the
3
+ // running rnx page (webm via MediaRecorder, mp4 via WebCodecs, gif
4
+ // via gifenc, raw png frames via a shared bitmap sampler).
5
+
6
+ import {
7
+ existsSync,
8
+ mkdirSync,
9
+ readdirSync,
10
+ readFileSync,
11
+ rmSync,
12
+ writeFileSync,
13
+ } from 'fs'
14
+ import { homedir, tmpdir } from 'os'
15
+ import { dirname, extname, join, resolve } from 'path'
16
+ import { githubUploadIdentity, resolveCliAuth } from '../auth'
17
+ import { getCliIdentityKey } from '../current-sim'
18
+ import { openUrl } from '../open-url'
19
+ import { ensureCliRecordingEntitlement } from '../recording-access'
20
+ import { resolveRunProvenance } from '../run-registry'
21
+ import { rnxExit } from '../run-rnx'
22
+ import {
23
+ checkSimHealth,
24
+ createBridgeFromParsed,
25
+ parseBridgeCliArgs,
26
+ type WsBridge,
27
+ } from '../ws-bridge'
28
+
29
+ interface RecordOptions {
30
+ port?: number
31
+ verbose?: boolean
32
+ }
33
+
34
+ type OutputFormat = 'webm' | 'mp4' | 'gif' | 'png'
35
+
36
+ // recording mode — 'video' is a local webm/mp4 download via the headless
37
+ // recorder bridge (current CLI default). 'live' and 'combined' route
38
+ // through the recording store so they capture pointer events (and, for
39
+ // combined, video) and upload to /preview/<id>. mirrors RecordingMode in
40
+ // packages/sootsim-engine/src/recording/recordingStore.ts.
41
+ type RecordMode = 'video' | 'live' | 'combined'
42
+
43
+ // how long to wait for the live/combined upload to settle after stop. dev
44
+ // bundles routinely include tens of MB of transformed JS plus captured
45
+ // request bodies; 60s was too tight and left valid PR-preview recordings
46
+ // uploading in the page after the CLI had already reported failure.
47
+ const UPLOAD_TIMEOUT_MS = 240_000
48
+
49
+ // how long `record start --mode live/combined` waits for each setup bridge
50
+ // eval (auth inject, store start) to round-trip. unlike the light `video`
51
+ // start (a single __sootsimRecorder.start), the store-backed start must reach
52
+ // an engine that — on a contended CI runner, right after the PR-preview agent
53
+ // drove a populated app — is saturated rendering at a few fps and cannot answer
54
+ // a bridge eval within the 15s the video path uses. that 15s cap made
55
+ // `record start` time out, the agent retry it many times, and the leading idle
56
+ // from the failed attempts blow the 15s first-interaction quality gate
57
+ // (preview-quality.ts) so the take was rejected even once it finally recorded.
58
+ // give the upload-bearing start the slack the atomic combined path
59
+ // (runRecord, commandTimeoutMs 60s) already uses, plus headroom for a starved
60
+ // engine. video start stays light at 15s.
61
+ const STORE_RECORD_START_TIMEOUT_MS = 90_000
62
+
63
+ // per-step bridge-eval budget for local-file capture (gif / video). the
64
+ // blanket 60s command timeout means a contended or wedged render host
65
+ // produces a 60s generic "command timed out" hang with no hint which step
66
+ // stalled (QA F19-5). scope each capture eval to its own recording duration
67
+ // plus a generous encode/teardown allowance so a stalled host fails fast
68
+ // and actionably. the bitmap-capture lock now self-releases on abandon
69
+ // (bitmap-capture.ts), so recovery is simply to retry.
70
+ const CAPTURE_STEP_ENCODE_BUDGET_MS = 30_000
71
+
72
+ async function evalCaptureStep<T>(
73
+ bridge: WsBridge,
74
+ code: string,
75
+ step: string,
76
+ durationMs: number,
77
+ ): Promise<T> {
78
+ const timeoutMs = Math.max(20_000, durationMs + CAPTURE_STEP_ENCODE_BUDGET_MS)
79
+ try {
80
+ return (await bridge.send({ type: 'evaluate', code }, { timeoutMs })) as T
81
+ } catch (err) {
82
+ const msg = err instanceof Error ? err.message : String(err)
83
+ if (/^command timed out after \d+s$/.test(msg)) {
84
+ console.error(
85
+ ` ${step} stalled — the render host did not finish within ` +
86
+ `${Math.round(timeoutMs / 1000)}s; it is likely contended or wedged.\n` +
87
+ ` the capture lock auto-releases, so simply retry the recording.\n` +
88
+ ` if it persists, recover the sim with \`rnx close --sim <id>\`.`,
89
+ )
90
+ rnxExit(1)
91
+ }
92
+ // a leaked/contended render-host streaming session surfaces as an
93
+ // engine-side error delivered over the WS bridge. on the gif/video
94
+ // start path this rejection previously had no upstream catch, so it
95
+ // escaped as a raw unhandled stack trace AND the process self-exited
96
+ // 0 (false success, no file written) — QA F20-1. handle it here:
97
+ // actionable message, non-zero exit, no stack.
98
+ if (
99
+ /render host not available|already streaming|already recording|lock already held/i.test(
100
+ msg,
101
+ )
102
+ ) {
103
+ console.error(
104
+ ` ${step} failed: ${msg}\n` +
105
+ ` a prior recording was abandoned and its capture is still\n` +
106
+ ` releasing. the lock self-heals in ~${Math.round(
107
+ (durationMs + 20_000) / 1000,
108
+ )}s — retry then, or recover now with\n` +
109
+ ` \`rnx close --sim <id>\` and reopen the sim.`,
110
+ )
111
+ rnxExit(1)
112
+ }
113
+ // never let any other capture-eval rejection escape as an unhandled
114
+ // promise (raw stack + false exit 0). surface it and exit non-zero.
115
+ console.error(` ${step} failed: ${msg}`)
116
+ rnxExit(1)
117
+ }
118
+ }
119
+
120
+ // value-taking flags that may legitimately appear before the subcommand —
121
+ // notably `--sim`, which bin.ts prepends to the command args. their values
122
+ // must not be mistaken for the subcommand.
123
+ const RECORD_VALUE_FLAGS = new Set([
124
+ '--sim',
125
+ '--port',
126
+ '-p',
127
+ '--mode',
128
+ '--duration',
129
+ '--fps',
130
+ '--format',
131
+ '--output',
132
+ '-o',
133
+ '--frames',
134
+ '--max-width',
135
+ '--origin',
136
+ '--owner',
137
+ '--repo',
138
+ ])
139
+ const RECORD_SUBCOMMANDS = new Set([
140
+ 'start',
141
+ 'stop',
142
+ 'cancel',
143
+ 'status',
144
+ 'prelude-start',
145
+ 'upload',
146
+ ])
147
+
148
+ // the record subcommand (start/stop/cancel/status) is the first non-flag
149
+ // positional — NOT necessarily args[0]. bin.ts prepends `--sim <id>` when a
150
+ // global --sim target is set, so a naive args[0] check silently fell through
151
+ // to the atomic-record path and started a spurious recording (F41).
152
+ function findRecordSubcommand(args: string[]): { name: string; index: number } | null {
153
+ for (let i = 0; i < args.length; i++) {
154
+ const a = args[i]
155
+ if (a.startsWith('-')) {
156
+ if (RECORD_VALUE_FLAGS.has(a)) i++ // skip the flag's value
157
+ continue
158
+ }
159
+ return RECORD_SUBCOMMANDS.has(a) ? { name: a, index: i } : null
160
+ }
161
+ return null
162
+ }
163
+
164
+ type RepoScope = { owner: string; repo: string } | null
165
+
166
+ // git provenance for PR-preview shares, read from the github-actions env
167
+ // (run.sh exports these). repo-agnostic: every PR-preview run carries the
168
+ // standard GITHUB_* vars, with CONTRAST_* overrides matching resolveCliAuth.
169
+ // merged into the upload identity so the org/recent feed shows the same
170
+ // branch / PR title + "sha · author" for previews as for branch-builds.
171
+ function readRepoScope(args: string[]): RepoScope {
172
+ const owner = valueOf(args, '--owner')?.trim()
173
+ const repo = valueOf(args, '--repo')?.trim()
174
+ if ((owner && !repo) || (!owner && repo)) {
175
+ console.error(' --owner and --repo must be provided together')
176
+ rnxExit(1)
177
+ }
178
+ return owner && repo ? { owner, repo } : null
179
+ }
180
+
181
+ export async function runRecord(args: string[], opts: RecordOptions) {
182
+ // note: `--help` / `-h` never reach here — bin.ts intercepts them and
183
+ // renders the registry-driven help page (packages/rnx-skills meta.ts).
184
+
185
+ const sub = findRecordSubcommand(args)
186
+ if (sub) {
187
+ // drop the subcommand token; the rest (incl. any --sim/--port) flows on
188
+ // to the subcommand handlers, which parse bridge flags themselves.
189
+ const rest = [...args.slice(0, sub.index), ...args.slice(sub.index + 1)]
190
+ if (sub.name === 'start') {
191
+ await recordStart(rest, opts)
192
+ return
193
+ }
194
+ if (sub.name === 'prelude-start') {
195
+ await recordPreludeStart(rest, opts)
196
+ return
197
+ }
198
+ if (sub.name === 'upload') {
199
+ const { runUpload } = await import('./upload')
200
+ await runUpload(rest, opts)
201
+ return
202
+ }
203
+ if (sub.name === 'stop') {
204
+ await recordStop(rest, opts)
205
+ return
206
+ }
207
+ if (sub.name === 'cancel') {
208
+ await recordCancel(rest, opts)
209
+ return
210
+ }
211
+ await recordStatus(rest, opts)
212
+ return
213
+ }
214
+
215
+ const parsed = parseBridgeCliArgs(args, {
216
+ port: opts.port,
217
+ stripBooleanFlags: ['--no-shell', '--shell-only', '--open', '--lockstep'],
218
+ stripValueFlags: [
219
+ '--mode',
220
+ '--duration',
221
+ '--fps',
222
+ '--format',
223
+ '--output',
224
+ '--frames',
225
+ '--max-width',
226
+ '--origin',
227
+ '--owner',
228
+ '--repo',
229
+ ],
230
+ })
231
+ const mode = parseMode(valueOf(args, '--mode'))
232
+ const layers: 'tenant' | 'shell' | undefined = args.includes('--shell-only')
233
+ ? 'shell'
234
+ : args.includes('--no-shell')
235
+ ? 'tenant'
236
+ : undefined
237
+ const formatArg = valueOf(args, '--format') as OutputFormat | undefined
238
+ if (formatArg && !['webm', 'mp4', 'gif', 'png'].includes(formatArg)) {
239
+ console.error(` invalid --format "${formatArg}" — expected webm | mp4 | gif | png`)
240
+ rnxExit(1)
241
+ }
242
+ const outputArg = valueOf(args, '--output')
243
+ const durationSec = Number(valueOf(args, '--duration') ?? '10')
244
+ if (!Number.isFinite(durationSec) || durationSec <= 0) {
245
+ console.error(
246
+ ` invalid --duration "${valueOf(args, '--duration')}" — expected a positive number of seconds`,
247
+ )
248
+ rnxExit(1)
249
+ }
250
+ // upper bound: a fat-fingered `--duration 99999` (≈27.7h) otherwise
251
+ // started a runaway recording with no warning (QA F23-3). 600s mirrors
252
+ // the server's MAX_FLOW_VIDEO_DURATION_MS — live/combined uploads past
253
+ // it are rejected anyway, and no legitimate atomic capture runs longer
254
+ // (the bitmap watchdog tears down well before that). float seconds stay
255
+ // allowed (rounded to ms below) — unlike --frames, a duration is a time,
256
+ // not a count, so sub-second precision is meaningful.
257
+ const MAX_DURATION_SEC = 600
258
+ if (durationSec > MAX_DURATION_SEC) {
259
+ console.error(
260
+ ` invalid --duration "${valueOf(args, '--duration')}" — exceeds the ${MAX_DURATION_SEC}s (10m) maximum`,
261
+ )
262
+ rnxExit(1)
263
+ }
264
+ const fps = Number(valueOf(args, '--fps') ?? '30')
265
+ const framesArg = valueOf(args, '--frames')
266
+ // --frames is a count, not a duration — reject 0 / negatives / fractions
267
+ // up front instead of silently "saving 0 frames" (F11-3), mirroring the
268
+ // --duration guard above.
269
+ if (framesArg !== undefined) {
270
+ const n = Number(framesArg)
271
+ if (!Number.isInteger(n) || n <= 0) {
272
+ console.error(
273
+ ` invalid --frames "${framesArg}" — expected a positive integer count`,
274
+ )
275
+ rnxExit(1)
276
+ }
277
+ }
278
+ const openAfter = args.includes('--open')
279
+ const maxWidth = valueOf(args, '--max-width')
280
+ ? Number(valueOf(args, '--max-width'))
281
+ : undefined
282
+ if (args.includes('--lockstep')) {
283
+ // atomic mode times the recording in wall seconds, but lockstep virtual
284
+ // time runs slower than the wall clock, so `--duration` would lie about
285
+ // the captured span. drive lockstep through start/stop instead.
286
+ console.error(
287
+ ' --lockstep works with `record start` / `record stop` (stateful mode) — atomic --duration is wall-clock and cannot bound a virtual-time capture',
288
+ )
289
+ rnxExit(1)
290
+ }
291
+
292
+ const durationMs = Math.max(100, Math.round(durationSec * 1000))
293
+ // --origin explicitly pins the billing/upload origin. it wins over the
294
+ // saved desktop-session origin so a key minted against a local :3000
295
+ // stack verifies there instead of prod (the e2e blocker — F12).
296
+ const originOverride = valueOf(args, '--origin')
297
+ const repoScope = readRepoScope(args)
298
+
299
+ // live / combined route through the recording store (events + optional
300
+ // video, upload to /preview/<id>) instead of the headless recorder.
301
+ if (mode === 'live' || mode === 'combined') {
302
+ // --output only applies to local-file capture (video/gif/png). live and
303
+ // combined upload to /preview/<id> and write no local file — say so
304
+ // rather than silently swallowing the flag.
305
+ if (outputArg) {
306
+ console.log(
307
+ ` note: --output is ignored for --mode ${mode} — the recording uploads to /preview/<id>`,
308
+ )
309
+ }
310
+ // --no-shell / --shell-only select which layers the captured video
311
+ // shows. live mode records events only (no video), so the flag has
312
+ // nothing to act on there — say so rather than dropping it silently
313
+ // (F12-1).
314
+ if (layers && mode === 'live') {
315
+ console.log(
316
+ ` note: --${layers === 'shell' ? 'shell-only' : 'no-shell'} is ignored for --mode live — live recordings capture events only, no video`,
317
+ )
318
+ }
319
+ // only the upload-bearing modes touch the cloud, so gate the recording
320
+ // entitlement here, not on local-file video/gif/png capture (F19).
321
+ // allowGitHubAuth mirrors preview flow recording: the github pr-preview
322
+ // runner authenticates with an installation token, not RNX_API_KEY
323
+ // (zero-secrets design). the bypass only fires when auth.kind ===
324
+ // 'github' (ci/preview); local paid users have session/
325
+ // api-key auth and still go through the billing entitlement check.
326
+ await ensureCliRecordingEntitlement('record', {
327
+ originOverride,
328
+ allowGitHubAuth: true,
329
+ })
330
+ const bridge = createBridgeFromParsed({ ...parsed, commandTimeoutMs: 60_000 })
331
+ try {
332
+ await assertStoreBridgesAvailable(bridge)
333
+ await assertGuestBundleLoaded(bridge, mode)
334
+ if (mode === 'combined') await assertTabVisibleForCapture(bridge)
335
+ await injectPreviewSessionAuth(bridge, originOverride, repoScope)
336
+ const started = await evalStoreStart(
337
+ bridge,
338
+ mode,
339
+ mode === 'combined' ? layers : undefined,
340
+ )
341
+ if (!started.ok) {
342
+ console.error(
343
+ ` start failed: recording store refused to start (${mode})` +
344
+ (started.error ? `: ${started.error}` : ''),
345
+ )
346
+ rnxExit(1)
347
+ }
348
+ console.log(` recording ${mode} for ${durationSec}s`)
349
+ await new Promise((r) => setTimeout(r, durationMs))
350
+ await evalStoreStop(bridge)
351
+ const result = await pollStoreUpload(bridge)
352
+ handleStoreUploadResult(result, openAfter, mode)
353
+ } finally {
354
+ bridge.close()
355
+ }
356
+ return
357
+ }
358
+
359
+ const framesCount = framesArg ? Number(framesArg) : null
360
+ const format: OutputFormat = resolveFormat(formatArg, outputArg, framesCount)
361
+
362
+ // --open launches the uploaded /preview/<id> page — local-file capture
363
+ // (video/gif/png) produces no URL, so say it's ignored rather than
364
+ // swallowing the flag silently (F12-4).
365
+ if (openAfter) {
366
+ console.log(
367
+ ` note: --open is ignored for local-file capture — it applies to --mode live/combined`,
368
+ )
369
+ }
370
+
371
+ const bridge = createBridgeFromParsed({ ...parsed, commandTimeoutMs: 60_000 })
372
+ try {
373
+ await assertRecorderAvailable(bridge)
374
+ await assertTabVisibleForCapture(bridge)
375
+
376
+ if (format === 'png') {
377
+ const count = framesCount ?? 10
378
+ const outDir = resolveRecordingOutput(outputArg, `rnx-frames-${stamp()}`)
379
+ // --frames writes one png PER frame, so --output names a directory, not
380
+ // a file. a caller passing `--output shot.png` gets a directory literally
381
+ // named `shot.png` — flag the likely mistake rather than silently doing it.
382
+ if (outputArg && extname(outputArg)) {
383
+ console.log(
384
+ ` note: --frames writes multiple pngs, so --output "${outputArg}" is a directory — frame-NNN.png files land inside it`,
385
+ )
386
+ }
387
+ mkdirSync(outDir, { recursive: true })
388
+ // clear stale frame-NNN.png from a prior run into the same dir — a
389
+ // shorter `--frames` count would otherwise leave higher-numbered frames
390
+ // behind, so the dir mixes two captures (F43). only frame-*.png is
391
+ // touched; any other files the caller put there are left alone.
392
+ for (const name of readdirSync(outDir)) {
393
+ if (/^frame-\d+\.png$/.test(name)) rmSync(join(outDir, name), { force: true })
394
+ }
395
+ console.log(` sampling ${count} frames over ${durationSec}s → ${outDir}`)
396
+ const start: { ok: boolean; requestId?: number; error?: string } =
397
+ await bridge.send({
398
+ type: 'evaluate',
399
+ code: `window.__sootsimRecorder.startFrameCapture({ count: ${count}, durationMs: ${durationMs}${
400
+ layers ? `, layers: ${JSON.stringify(layers)}` : ''
401
+ } })`,
402
+ })
403
+ if (!start.ok || !start.requestId) {
404
+ console.error(` frame capture start failed: ${start.error ?? 'unknown error'}`)
405
+ rnxExit(1)
406
+ }
407
+ await new Promise((r) => setTimeout(r, durationMs))
408
+ const deadline = Date.now() + Math.max(5_000, durationMs)
409
+ let frames: Array<{ data: string; size: number }> | null = null
410
+ for (;;) {
411
+ const result: {
412
+ ok: boolean
413
+ done: boolean
414
+ frames?: Array<{ data: string; size: number }>
415
+ error?: string
416
+ } | null = await bridge.send({
417
+ type: 'evaluate',
418
+ code: `window.__sootsimRecorder.getFrameCaptureResult(${start.requestId})`,
419
+ })
420
+ if (!result) {
421
+ console.error(' frame capture result missing')
422
+ rnxExit(1)
423
+ }
424
+ if (result.done) {
425
+ if (!result.ok) {
426
+ console.error(` frame capture failed: ${result.error ?? 'unknown error'}`)
427
+ rnxExit(1)
428
+ }
429
+ frames = result.frames ?? []
430
+ break
431
+ }
432
+ if (Date.now() >= deadline) {
433
+ console.error(' frame capture timed out')
434
+ rnxExit(1)
435
+ }
436
+ await new Promise((r) => setTimeout(r, 100))
437
+ }
438
+ frames.forEach((f, i) => {
439
+ const p = `${outDir}/frame-${String(i + 1).padStart(3, '0')}.png`
440
+ writeFileSync(p, Buffer.from(f.data, 'base64'))
441
+ })
442
+ console.log(` saved ${frames.length} frames`)
443
+ return
444
+ }
445
+
446
+ if (format === 'gif') {
447
+ const count = framesCount ?? Math.max(10, Math.round((durationSec * fps) / 3))
448
+ const out = resolveRecordingOutput(outputArg, `rnx-${stamp()}.gif`)
449
+ mkdirSync(dirname(out), { recursive: true })
450
+ console.log(` encoding gif: ${count} frames over ${durationSec}s → ${out}`)
451
+ const result: { data: string; size: number } | null = await evalCaptureStep(
452
+ bridge,
453
+ `window.__sootsimRecorder.captureGif({ frames: ${count}, durationMs: ${durationMs}${
454
+ maxWidth ? `, maxWidth: ${maxWidth}` : ''
455
+ }${layers ? `, layers: ${JSON.stringify(layers)}` : ''} })`,
456
+ 'gif encode',
457
+ durationMs,
458
+ )
459
+ if (!result) {
460
+ console.error(' gif capture returned no frames')
461
+ rnxExit(1)
462
+ }
463
+ writeFileSync(out, Buffer.from(result.data, 'base64'))
464
+ console.log(` saved: ${out} (${formatBytes(result.size)})`)
465
+ return
466
+ }
467
+
468
+ // video path: webm or mp4
469
+ const out = resolveRecordingOutput(outputArg, `rnx-${stamp()}.${format}`)
470
+ mkdirSync(dirname(out), { recursive: true })
471
+
472
+ const startOpts: {
473
+ format: string
474
+ fps: number
475
+ layers?: string
476
+ durationMs: number
477
+ } = {
478
+ format,
479
+ fps,
480
+ // thread the known duration so an abandoned recording's bitmap lock
481
+ // self-heals in ~duration+20s instead of the 8-min ceiling (F20-1).
482
+ durationMs,
483
+ }
484
+ if (layers) startOpts.layers = layers
485
+ const startResult: { ok: boolean; error?: string; format?: string } =
486
+ await evalCaptureStep(
487
+ bridge,
488
+ `window.__sootsimRecorder.start(${JSON.stringify(startOpts)})`,
489
+ `${format} start`,
490
+ durationMs,
491
+ )
492
+ if (!startResult.ok) {
493
+ console.error(` start failed: ${startResult.error ?? 'unknown error'}`)
494
+ // a stale render-host streaming session is the usual cause — say how
495
+ // to recover instead of leaving the user staring at a bare error.
496
+ if (
497
+ /render host not available|already streaming|already recording|lock already held/i.test(
498
+ startResult.error ?? '',
499
+ )
500
+ ) {
501
+ console.error(
502
+ ' a prior recording was abandoned and its capture is still\n' +
503
+ ' releasing. retry in ~20s, or recover now with\n' +
504
+ ' `rnx close --sim <id>` then reopen the sim.',
505
+ )
506
+ }
507
+ rnxExit(1)
508
+ }
509
+
510
+ console.log(` recording ${format} for ${durationSec}s → ${out}`)
511
+ await new Promise((r) => setTimeout(r, durationMs))
512
+
513
+ const stopResult: {
514
+ ok: boolean
515
+ error?: string
516
+ size?: number
517
+ mime?: string
518
+ durationMs?: number
519
+ frameCount?: number
520
+ } = await evalCaptureStep(
521
+ bridge,
522
+ `window.__sootsimRecorder.stop()`,
523
+ `${format} encode/flush`,
524
+ durationMs,
525
+ )
526
+ if (!stopResult.ok) {
527
+ console.error(` stop failed: ${stopResult.error ?? 'unknown error'}`)
528
+ rnxExit(1)
529
+ }
530
+
531
+ if (!stopResult.size) {
532
+ console.error(' recorder returned an empty blob — nothing written')
533
+ rnxExit(1)
534
+ }
535
+
536
+ await downloadBlob(bridge, out)
537
+ console.log(` saved: ${out} (${formatBytes(stopResult.size)})`)
538
+ if (stopResult.durationMs && stopResult.frameCount !== undefined) {
539
+ console.log(
540
+ ` source frames: ${stopResult.frameCount} over ${(stopResult.durationMs / 1000).toFixed(2)}s ` +
541
+ `(${(stopResult.frameCount / (stopResult.durationMs / 1000)).toFixed(1)}fps)`,
542
+ )
543
+ }
544
+ } finally {
545
+ bridge.close()
546
+ }
547
+ }
548
+
549
+ // readiness probe with a short timeout. a sim can be registered with the
550
+ // bridge yet never answer an `evaluate` — it loaded no app, or its page/worker
551
+ // died. without the short cap the probe stalls the full recording timeout
552
+ // (60s) before failing; cap it at 6s and fail fast with an actionable message.
553
+ const SIM_PROBE_TIMEOUT_MS = 6_000
554
+ async function probeSim(bridge: WsBridge, code: string): Promise<boolean> {
555
+ try {
556
+ return await bridge.send(
557
+ { type: 'evaluate', code },
558
+ { timeoutMs: SIM_PROBE_TIMEOUT_MS },
559
+ )
560
+ } catch (err) {
561
+ const msg = err instanceof Error ? err.message : String(err)
562
+ if (/^command timed out after \d+s$/.test(msg)) {
563
+ console.error(
564
+ ' sim did not respond — it is connected to the bridge but has not\n' +
565
+ ' loaded an app (or its page is unresponsive). run `rnx list`\n' +
566
+ ' and target a sim with a loaded app via --sim <id>.',
567
+ )
568
+ rnxExit(1)
569
+ }
570
+ throw err
571
+ }
572
+ }
573
+
574
+ async function assertRecorderAvailable(bridge: WsBridge) {
575
+ const ok = await probeSim(bridge, 'typeof window.__sootsimRecorder !== "undefined"')
576
+ if (!ok) {
577
+ console.error(
578
+ ' window.__sootsimRecorder missing — is rnx engine running in this sim?',
579
+ )
580
+ rnxExit(1)
581
+ }
582
+ }
583
+
584
+ // frame-capturing recordings (video / gif / png / combined) drive their
585
+ // encoder off requestAnimationFrame and throttled timers, which browsers
586
+ // clamp to ~1fps when the rnx tab is hidden (backgrounded, or its chrome
587
+ // window minimized). that silently produced a frozen-looking file and is
588
+ // exactly why agents started pre-checking `document.hidden` by hand. refuse
589
+ // up front with an actionable message instead. checkSimHealth already emits
590
+ // the shared hidden-tab warning over the existing bridge probe (DRY — one
591
+ // source for hidden detection); this only adds the hard stop. event-only
592
+ // `live` recordings don't capture frames, so callers skip this guard there.
593
+ async function assertTabVisibleForCapture(bridge: WsBridge) {
594
+ const { hidden } = await checkSimHealth(bridge)
595
+ if (hidden) {
596
+ console.error(
597
+ ' refusing to record: the rnx tab is hidden, so frame capture\n' +
598
+ ' would be throttled to ~1fps and produce a frozen-looking file.\n' +
599
+ ' bring the chrome window to the foreground (un-minimize / switch to\n' +
600
+ ' its tab) or run the recording in headless playwright, then retry.',
601
+ )
602
+ rnxExit(1)
603
+ }
604
+ }
605
+
606
+ async function downloadBlob(bridge: WsBridge, outPath: string) {
607
+ // stream the blob back in chunks to avoid a single huge base64 payload
608
+ const chunks: Buffer[] = []
609
+ let offset = 0
610
+ while (true) {
611
+ const result: {
612
+ data: string
613
+ size: number
614
+ offset: number
615
+ done: boolean
616
+ mime: string
617
+ } | null = await bridge.send({
618
+ type: 'evaluate',
619
+ code: `window.__sootsimRecorder.getBlobBase64({ offset: ${offset}, chunk: ${
620
+ 2 * 1024 * 1024
621
+ } })`,
622
+ })
623
+ if (!result) throw new Error('no blob available on recorder')
624
+ chunks.push(Buffer.from(result.data, 'base64'))
625
+ offset = result.offset
626
+ if (result.done) break
627
+ }
628
+ writeFileSync(outPath, Buffer.concat(chunks))
629
+ }
630
+
631
+ export function valueOf(args: string[], flag: string): string | undefined {
632
+ const idx = args.indexOf(flag)
633
+ if (idx < 0 || idx === args.length - 1) return undefined
634
+ return args[idx + 1]
635
+ }
636
+
637
+ export function extToFormat(path?: string): OutputFormat | undefined {
638
+ if (!path) return undefined
639
+ const ext = extname(path).toLowerCase().replace(/^\./, '')
640
+ if (ext === 'webm' || ext === 'mp4' || ext === 'gif') return ext as OutputFormat
641
+ if (ext === 'png') return 'png'
642
+ return undefined
643
+ }
644
+
645
+ export function resolveFormat(
646
+ formatArg: string | undefined,
647
+ outputArg: string | undefined,
648
+ framesCount: number | null,
649
+ ): OutputFormat {
650
+ if (formatArg) return formatArg as OutputFormat
651
+ if (framesCount != null) return 'png'
652
+ return extToFormat(outputArg) ?? 'webm'
653
+ }
654
+
655
+ function stamp(): string {
656
+ return new Date().toISOString().replace(/[:T]/g, '-').replace(/\..+/, '')
657
+ }
658
+
659
+ // default home for recordings when --output is omitted. writing into the
660
+ // process cwd litters whatever directory the CLI was invoked from (the repo
661
+ // root, most often) with rnx-*.mp4 / rnx-frames-*/ — F11-2. an
662
+ // explicit --output still resolves relative to cwd as the caller expects.
663
+ function recordingsDir(): string {
664
+ const dir = join(homedir(), '.rnx', 'recordings')
665
+ mkdirSync(dir, { recursive: true })
666
+ return dir
667
+ }
668
+
669
+ function resolveRecordingOutput(
670
+ outputArg: string | undefined,
671
+ defaultName: string,
672
+ ): string {
673
+ return outputArg
674
+ ? resolve(process.cwd(), outputArg)
675
+ : join(recordingsDir(), defaultName)
676
+ }
677
+
678
+ function formatBytes(n: number): string {
679
+ if (n < 1024) return `${n}B`
680
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)}KB`
681
+ return `${(n / (1024 * 1024)).toFixed(2)}MB`
682
+ }
683
+
684
+ interface RecordingState {
685
+ simId: string | null
686
+ mode: RecordMode
687
+ format: 'webm' | 'mp4'
688
+ fps: number
689
+ layers?: 'tenant' | 'shell'
690
+ startedAt: string
691
+ }
692
+
693
+ function parseMode(raw: string | undefined): RecordMode {
694
+ if (!raw) return 'video'
695
+ if (raw === 'video' || raw === 'live' || raw === 'combined') return raw
696
+ console.error(` invalid --mode "${raw}" — expected video | live | combined`)
697
+ rnxExit(1)
698
+ }
699
+
700
+ function recordingStatePath(): string {
701
+ return join(tmpdir(), `rnx-recording-${getCliIdentityKey()}.json`)
702
+ }
703
+
704
+ function readRecordingState(): RecordingState | null {
705
+ const p = recordingStatePath()
706
+ if (!existsSync(p)) return null
707
+ try {
708
+ const parsed = JSON.parse(readFileSync(p, 'utf8')) as Partial<RecordingState>
709
+ // default mode for state files written before --mode existed
710
+ return { mode: 'video', ...parsed } as RecordingState
711
+ } catch {
712
+ rmSync(p, { force: true })
713
+ return null
714
+ }
715
+ }
716
+
717
+ function writeRecordingState(state: RecordingState) {
718
+ writeFileSync(recordingStatePath(), JSON.stringify(state, null, 2))
719
+ }
720
+
721
+ function clearRecordingState() {
722
+ rmSync(recordingStatePath(), { force: true })
723
+ }
724
+
725
+ // the record state file is global — keyed by CLI identity, not per-sim — so
726
+ // `status`/`stop`/`cancel` act on whichever `record start` session exists
727
+ // regardless of --sim. when the caller targets a different sim than the one
728
+ // the session was started on, say so instead of silently reporting/stopping
729
+ // another sim's recording (F11-1).
730
+ function noteSimMismatch(
731
+ requestedSimId: string | null | undefined,
732
+ state: RecordingState,
733
+ ) {
734
+ if (requestedSimId && state.simId && requestedSimId !== state.simId) {
735
+ console.log(
736
+ ` note: the tracked recording session is on sim ${state.simId}, not the\n` +
737
+ ` requested sim ${requestedSimId}. record state is global (not per-sim);\n` +
738
+ ` this command acts on that session regardless of --sim.`,
739
+ )
740
+ }
741
+ }
742
+
743
+ async function recordStart(args: string[], opts: RecordOptions) {
744
+ const existing = readRecordingState()
745
+ if (existing) {
746
+ console.error(
747
+ ` recording already in progress (started ${existing.startedAt}, sim ${existing.simId ?? '?'}). run \`rnx record stop\` first, or \`rnx record cancel\` to discard.`,
748
+ )
749
+ rnxExit(1)
750
+ }
751
+
752
+ const parsed = parseBridgeCliArgs(args, {
753
+ port: opts.port,
754
+ stripBooleanFlags: ['--no-shell', '--shell-only', '--lockstep'],
755
+ stripValueFlags: [
756
+ '--mode',
757
+ '--fps',
758
+ '--format',
759
+ '--max-width',
760
+ '--origin',
761
+ '--owner',
762
+ '--repo',
763
+ ],
764
+ })
765
+ const mode = parseMode(valueOf(args, '--mode'))
766
+ const layers: 'tenant' | 'shell' | undefined = args.includes('--shell-only')
767
+ ? 'shell'
768
+ : args.includes('--no-shell')
769
+ ? 'tenant'
770
+ : undefined
771
+ const formatArg = valueOf(args, '--format') as OutputFormat | undefined
772
+ const format = formatArg === 'mp4' ? 'mp4' : 'webm'
773
+ if (formatArg && format !== formatArg) {
774
+ console.error(
775
+ ` record start only supports webm or mp4 (got: ${formatArg}). for gif/png use atomic mode: rnx record --format ${formatArg} --duration <s>`,
776
+ )
777
+ rnxExit(1)
778
+ }
779
+ const fps = Number(valueOf(args, '--fps') ?? '30')
780
+ const repoScope = readRepoScope(args)
781
+
782
+ const bridge = createBridgeFromParsed({
783
+ ...parsed,
784
+ commandTimeoutMs:
785
+ mode === 'live' || mode === 'combined' ? STORE_RECORD_START_TIMEOUT_MS : 15_000,
786
+ })
787
+ try {
788
+ if (mode === 'live' || mode === 'combined') {
789
+ // live mode captures events only — --no-shell / --shell-only have no
790
+ // video to act on there (F12-1).
791
+ if (layers && mode === 'live') {
792
+ console.log(
793
+ ` note: --${layers === 'shell' ? 'shell-only' : 'no-shell'} is ignored for --mode live — live recordings capture events only, no video`,
794
+ )
795
+ }
796
+ // only upload-bearing modes hit the cloud — gate the recording
797
+ // entitlement here, not on local-file webm/mp4 capture (F19).
798
+ // allowGitHubAuth: the PR-preview runner (`record start --mode
799
+ // combined`) authenticates with a GitHub installation token, not
800
+ // RNX_API_KEY — the whole pipeline is zero-secrets by design.
801
+ // mirrors `flow --record`. the bypass only triggers for github-kind
802
+ // auth (CI); local paid users still hit the billing check.
803
+ await ensureCliRecordingEntitlement('record', {
804
+ originOverride: valueOf(args, '--origin'),
805
+ allowGitHubAuth: true,
806
+ })
807
+ await assertStoreBridgesAvailable(bridge)
808
+ await assertGuestBundleLoaded(bridge, mode)
809
+ if (mode === 'combined') await assertTabVisibleForCapture(bridge)
810
+ await injectPreviewSessionAuth(bridge, valueOf(args, '--origin'), repoScope)
811
+ const started = await evalStoreStart(
812
+ bridge,
813
+ mode,
814
+ mode === 'combined' ? layers : undefined,
815
+ )
816
+ if (!started.ok) {
817
+ console.error(
818
+ ` start failed: recording store refused to start (${mode})` +
819
+ (started.error ? `: ${started.error}` : ''),
820
+ )
821
+ rnxExit(1)
822
+ }
823
+ } else {
824
+ await assertRecorderAvailable(bridge)
825
+ await assertTabVisibleForCapture(bridge)
826
+ const lockstep = args.includes('--lockstep')
827
+ if (lockstep && format !== 'mp4') {
828
+ console.error(' --lockstep requires --format mp4 (webm is realtime-clocked)')
829
+ rnxExit(1)
830
+ }
831
+ const startOpts: {
832
+ format: string
833
+ fps: number
834
+ layers?: string
835
+ lockstep?: boolean
836
+ } = { format, fps }
837
+ if (layers) startOpts.layers = layers
838
+ if (lockstep) startOpts.lockstep = true
839
+ const result: { ok: boolean; error?: string } = await bridge.send({
840
+ type: 'evaluate',
841
+ code: `window.__sootsimRecorder.start(${JSON.stringify(startOpts)})`,
842
+ })
843
+ if (!result.ok) {
844
+ console.error(` start failed: ${result.error ?? 'unknown error'}`)
845
+ rnxExit(1)
846
+ }
847
+ }
848
+ writeRecordingState({
849
+ simId: parsed.simId ?? null,
850
+ mode,
851
+ format,
852
+ fps,
853
+ layers,
854
+ startedAt: new Date().toISOString(),
855
+ })
856
+ if (mode === 'video') {
857
+ console.log(
858
+ ` recording ${format} @ ${fps}fps${layers ? ` (${layers})` : ''} — run \`rnx record stop --output <path>\` when done`,
859
+ )
860
+ } else {
861
+ console.log(
862
+ ` recording ${mode} — run \`rnx record stop\` when done (add --open to launch the preview URL)`,
863
+ )
864
+ }
865
+ } finally {
866
+ bridge.close()
867
+ }
868
+ }
869
+
870
+ async function recordPreludeStart(args: string[], opts: RecordOptions) {
871
+ const existing = readRecordingState()
872
+ if (existing) {
873
+ console.error(
874
+ ` recording already in progress (started ${existing.startedAt}, sim ${existing.simId ?? '?'}). run \`rnx record stop\` first, or \`rnx record cancel\` to discard.`,
875
+ )
876
+ rnxExit(1)
877
+ }
878
+
879
+ const parsed = parseBridgeCliArgs(args, {
880
+ port: opts.port,
881
+ stripBooleanFlags: [],
882
+ stripValueFlags: ['--origin'],
883
+ })
884
+ const bridge = createBridgeFromParsed({ ...parsed, commandTimeoutMs: 15_000 })
885
+ try {
886
+ await assertStoreBridgesAvailable(bridge)
887
+ await assertTabVisibleForCapture(bridge)
888
+ const ok: boolean = await bridge.send({
889
+ type: 'evaluate',
890
+ code: `(() => {
891
+ const start = window.SootSim?.bridges?.startRecordingPrelude
892
+ if (typeof start !== 'function') return false
893
+ return Promise.resolve(start()).then((value) => value === true)
894
+ })()`,
895
+ })
896
+ if (!ok) {
897
+ const reason = await bridge
898
+ .send({
899
+ type: 'evaluate',
900
+ code: `(() => {
901
+ const getError = window.SootSim?.bridges?.getRecordingStartError
902
+ return typeof getError === 'function' ? getError() : null
903
+ })()`,
904
+ })
905
+ .catch(() => null)
906
+ console.error(
907
+ ` prelude start failed: recording store refused to start${
908
+ typeof reason === 'string' && reason ? ` (${reason})` : ''
909
+ }`,
910
+ )
911
+ rnxExit(1)
912
+ }
913
+ console.log(
914
+ ' recording preview prelude — run `rnx record start --mode combined` when the visible proof segment is ready',
915
+ )
916
+ } finally {
917
+ bridge.close()
918
+ }
919
+ }
920
+
921
+ // query the engine for the *actual* recording state. the local state file
922
+ // only tracks `record start` sessions — an atomic `record --duration` run
923
+ // or the in-browser record button records without writing it. probing the
924
+ // engine lets `status`/`cancel` report the truth instead of lying "no
925
+ // recording in progress" while one is plainly running (F52).
926
+ //
927
+ // two independent recorder surfaces exist and must both be checked:
928
+ // - the recording store (`SootSim.bridges.getRecordingState`) drives
929
+ // live/combined preview-share recordings
930
+ // - the headless recorder (`__sootsimRecorder`) drives atomic video/gif/
931
+ // png capture — its state lives on a separate `active` flag
932
+ // - the event recorder alone drives hidden PR-preview preludes before a
933
+ // visible live/combined segment exists
934
+ // returns null when the engine can't be reached.
935
+ type EngineRecordingState = 'idle' | 'recording' | 'processing' | 'prelude'
936
+
937
+ async function probeEngineRecordingState(
938
+ bridge: WsBridge,
939
+ ): Promise<EngineRecordingState | null> {
940
+ try {
941
+ const snap: { store?: string; headless?: string; eventRecording?: boolean } | null =
942
+ await bridge.send({
943
+ type: 'evaluate',
944
+ code: `(() => {
945
+ const store = window.SootSim?.bridges?.getRecordingState?.();
946
+ const headless = window.__sootsimRecorder?.state?.();
947
+ const eventRecorder = window.SootSim?.bridges?.eventRecorder ?? window.__sootsimEventRecorder;
948
+ const eventRecording =
949
+ typeof eventRecorder?.isRecording === 'function'
950
+ ? eventRecorder.isRecording() === true
951
+ : false;
952
+ return { store: store ? store.state : undefined, headless, eventRecording };
953
+ })()`,
954
+ })
955
+ if (!snap) return null
956
+ if (snap.store === 'recording' || snap.headless === 'recording') return 'recording'
957
+ if (snap.store === 'processing') return 'processing'
958
+ if (snap.eventRecording) return 'prelude'
959
+ if (snap.store === 'idle' || snap.headless === 'idle') return 'idle'
960
+ return null
961
+ } catch {
962
+ return null
963
+ }
964
+ }
965
+
966
+ async function cancelEngineRecording(bridge: WsBridge): Promise<{
967
+ store: boolean
968
+ headless: boolean
969
+ }> {
970
+ const storeCanceled: boolean = await bridge
971
+ .send({
972
+ type: 'evaluate',
973
+ code: `(() => {
974
+ const store = window.SootSim?.bridges?.getRecordingState?.();
975
+ const eventRecorder = window.SootSim?.bridges?.eventRecorder ?? window.__sootsimEventRecorder;
976
+ const hadStoreRecording = store && store.state !== 'idle';
977
+ const hadEventRecording =
978
+ typeof eventRecorder?.isRecording === 'function'
979
+ ? eventRecorder.isRecording() === true
980
+ : false;
981
+ const cancel = window.SootSim?.bridges?.cancelRecording;
982
+ if (typeof cancel === 'function') cancel();
983
+ return Boolean(hadStoreRecording || hadEventRecording);
984
+ })()`,
985
+ })
986
+ .catch(() => false)
987
+ const headlessCanceled: boolean = await bridge
988
+ .send({
989
+ type: 'evaluate',
990
+ code: `(async () => {
991
+ const r = window.__sootsimRecorder;
992
+ if (r && typeof r.forceRelease === 'function') return await r.forceRelease();
993
+ return false;
994
+ })()`,
995
+ })
996
+ .catch(() => false)
997
+ return { store: storeCanceled, headless: headlessCanceled }
998
+ }
999
+
1000
+ async function recordStatus(args: string[], opts: RecordOptions) {
1001
+ const state = readRecordingState()
1002
+ if (state) {
1003
+ noteSimMismatch(parseBridgeCliArgs(args, { port: opts.port }).simId, state)
1004
+ if (state.mode === 'video') {
1005
+ console.log(
1006
+ ` recording ${state.mode} (${state.format} @ ${state.fps}fps) on sim ${state.simId ?? '?'} since ${state.startedAt}`,
1007
+ )
1008
+ } else {
1009
+ console.log(
1010
+ ` recording ${state.mode} on sim ${state.simId ?? '?'} since ${state.startedAt}`,
1011
+ )
1012
+ }
1013
+ return
1014
+ }
1015
+ // no `record start` session — but an atomic run or the rail button may
1016
+ // still be recording. ask the engine before claiming nothing is happening.
1017
+ const parsed = parseBridgeCliArgs(args, { port: opts.port })
1018
+ const bridge = createBridgeFromParsed({ ...parsed, commandTimeoutMs: 8_000 })
1019
+ try {
1020
+ const engineState = await probeEngineRecordingState(bridge)
1021
+ if (
1022
+ engineState === 'recording' ||
1023
+ engineState === 'processing' ||
1024
+ engineState === 'prelude'
1025
+ ) {
1026
+ if (engineState === 'prelude') {
1027
+ console.log(
1028
+ ' a recording preview prelude is active. hidden setup is being\n' +
1029
+ ' captured for the next live/combined preview recording. finish\n' +
1030
+ ' it with `rnx record start --mode combined`, or discard it\n' +
1031
+ ' with `rnx record cancel`.',
1032
+ )
1033
+ return
1034
+ }
1035
+ console.log(
1036
+ ` a recording is in progress (engine state: ${engineState}), but not via\n` +
1037
+ ' `record start` — it was started by an atomic `record --duration`\n' +
1038
+ ' run or the in-browser record button, which own their own lifecycle.\n' +
1039
+ ' if that controlling run is still alive it will finish on its own\n' +
1040
+ ' (atomic `--mode video/gif/png` writes a local file from that\n' +
1041
+ ' process; live/combined upload to /preview/<id>). if the run was\n' +
1042
+ ' killed, the recording is abandoned and will NOT finish — reclaim\n' +
1043
+ ' it now with `rnx record cancel`, or it auto-reclaims when the\n' +
1044
+ " abandoned capture's deadline lapses.",
1045
+ )
1046
+ } else {
1047
+ console.log(' no recording in progress')
1048
+ }
1049
+ } finally {
1050
+ bridge.close()
1051
+ }
1052
+ }
1053
+
1054
+ async function recordCancel(args: string[], opts: RecordOptions) {
1055
+ const state = readRecordingState()
1056
+ if (!state) {
1057
+ // no `record start` session to cancel — but check the engine so we
1058
+ // don't falsely claim nothing is happening while an atomic run records.
1059
+ const probeParsed = parseBridgeCliArgs(args, { port: opts.port })
1060
+ const probeBridge = createBridgeFromParsed({
1061
+ ...probeParsed,
1062
+ commandTimeoutMs: 8_000,
1063
+ })
1064
+ try {
1065
+ const engineState = await probeEngineRecordingState(probeBridge)
1066
+ if (
1067
+ engineState === 'recording' ||
1068
+ engineState === 'processing' ||
1069
+ engineState === 'prelude'
1070
+ ) {
1071
+ // `record cancel` is explicit intent: the caller wants whatever is
1072
+ // recording gone. force-reclaim the headless/store recorder rather
1073
+ // than refusing and telling them to wait out the deadline (QA
1074
+ // F22-1). this also un-wedges live/combined, which the killed
1075
+ // atomic `--mode video` bitmap lock was blocking.
1076
+ const reclaimed = await cancelEngineRecording(probeBridge)
1077
+ if (reclaimed.store && engineState === 'prelude') {
1078
+ console.log(' recording preview prelude cancelled')
1079
+ } else if (reclaimed.store || reclaimed.headless) {
1080
+ console.log(
1081
+ ' reclaimed an abandoned recording (started by an atomic\n' +
1082
+ ' `record --duration` run or the record button whose owner is\n' +
1083
+ ' gone). the recorder is idle again — live/combined can start.',
1084
+ )
1085
+ } else {
1086
+ console.log(
1087
+ ' a recording is in progress and still owned by a live atomic\n' +
1088
+ ' `record --duration` run or the in-browser record button — it\n' +
1089
+ ' will finish on its own and nothing was reclaimed.',
1090
+ )
1091
+ }
1092
+ } else {
1093
+ console.log(' no recording in progress')
1094
+ }
1095
+ } finally {
1096
+ probeBridge.close()
1097
+ }
1098
+ return
1099
+ }
1100
+ const parsed = parseBridgeCliArgs(args, { port: opts.port })
1101
+ noteSimMismatch(parsed.simId, state)
1102
+ // the recorder (and its buffered blob) lives in the page of the sim the
1103
+ // recording was *started* on — `state.simId` wins over the requested
1104
+ // --sim, otherwise cancel would stop an idle recorder on the wrong sim
1105
+ // and leave the real recording running (F11-1).
1106
+ const simId = state.simId ?? parsed.simId ?? undefined
1107
+ const bridge = createBridgeFromParsed({
1108
+ ...parsed,
1109
+ simId,
1110
+ commandTimeoutMs: 15_000,
1111
+ })
1112
+ try {
1113
+ if (state.mode === 'live' || state.mode === 'combined') {
1114
+ await bridge.send({
1115
+ type: 'evaluate',
1116
+ code: `void window.SootSim?.bridges?.cancelRecording?.()`,
1117
+ })
1118
+ } else {
1119
+ await bridge.send({
1120
+ type: 'evaluate',
1121
+ code: `window.__sootsimRecorder.stop()`,
1122
+ })
1123
+ }
1124
+ } catch {
1125
+ // best-effort — always clear local state
1126
+ } finally {
1127
+ clearRecordingState()
1128
+ bridge.close()
1129
+ }
1130
+ console.log(' recording cancelled')
1131
+ }
1132
+
1133
+ async function recordStop(args: string[], opts: RecordOptions) {
1134
+ const state = readRecordingState()
1135
+ if (!state) {
1136
+ console.error(' no recording in progress. start one with `rnx record start`.')
1137
+ rnxExit(1)
1138
+ }
1139
+
1140
+ const parsed = parseBridgeCliArgs(args, {
1141
+ port: opts.port,
1142
+ stripBooleanFlags: ['--open'],
1143
+ stripValueFlags: ['--output'],
1144
+ })
1145
+ noteSimMismatch(parsed.simId, state)
1146
+ // the recorder + buffered blob live in the sim the recording was started
1147
+ // on — route there, not to the requested --sim, or stop would drain an
1148
+ // idle recorder on the wrong sim and lose the capture (F11-1).
1149
+ const simId = state.simId ?? parsed.simId ?? undefined
1150
+ const openAfter = args.includes('--open')
1151
+
1152
+ const bridge = createBridgeFromParsed({
1153
+ ...parsed,
1154
+ simId,
1155
+ commandTimeoutMs:
1156
+ state.mode === 'live' || state.mode === 'combined' ? UPLOAD_TIMEOUT_MS : 60_000,
1157
+ })
1158
+ try {
1159
+ if (state.mode === 'live' || state.mode === 'combined') {
1160
+ await evalStoreStop(bridge)
1161
+ const result = await pollStoreUpload(bridge)
1162
+ clearRecordingState()
1163
+ handleStoreUploadResult(result, openAfter, state.mode)
1164
+ return
1165
+ }
1166
+
1167
+ const outputArg = valueOf(args, '--output')
1168
+ const out = resolveRecordingOutput(outputArg, `rnx-${stamp()}.${state.format}`)
1169
+ mkdirSync(dirname(out), { recursive: true })
1170
+ const stopResult: {
1171
+ ok: boolean
1172
+ error?: string
1173
+ size?: number
1174
+ mime?: string
1175
+ durationMs?: number
1176
+ frameCount?: number
1177
+ } = await bridge.send({
1178
+ type: 'evaluate',
1179
+ code: `window.__sootsimRecorder.stop()`,
1180
+ })
1181
+ if (!stopResult.ok) {
1182
+ console.error(` stop failed: ${stopResult.error ?? 'unknown error'}`)
1183
+ clearRecordingState()
1184
+ rnxExit(1)
1185
+ }
1186
+ if (!stopResult.size) {
1187
+ console.error(' recorder returned an empty blob — nothing written')
1188
+ clearRecordingState()
1189
+ rnxExit(1)
1190
+ }
1191
+ await downloadBlob(bridge, out)
1192
+ clearRecordingState()
1193
+ console.log(` saved: ${out} (${formatBytes(stopResult.size)})`)
1194
+ if (stopResult.durationMs && stopResult.frameCount !== undefined) {
1195
+ console.log(
1196
+ ` source frames: ${stopResult.frameCount} over ${(stopResult.durationMs / 1000).toFixed(2)}s ` +
1197
+ `(${(stopResult.frameCount / (stopResult.durationMs / 1000)).toFixed(1)}fps)`,
1198
+ )
1199
+ }
1200
+ } finally {
1201
+ bridge.close()
1202
+ }
1203
+ }
1204
+
1205
+ // ─── store-backed recording helpers (live / combined) ─────────────────────
1206
+
1207
+ async function assertStoreBridgesAvailable(bridge: WsBridge) {
1208
+ const ok = await probeSim(
1209
+ bridge,
1210
+ 'typeof window.SootSim?.bridges?.startRecording === "function" && typeof window.SootSim?.bridges?.stopRecording === "function"',
1211
+ )
1212
+ if (!ok) {
1213
+ console.error(
1214
+ ' SootSim.bridges.startRecording missing — is rnx engine running in this sim?',
1215
+ )
1216
+ rnxExit(1)
1217
+ }
1218
+ }
1219
+
1220
+ // CLI-driven live/combined recording runs in a headless browser that was
1221
+ // never logged in, so browser-side upload has no bearer. push the CLI's own
1222
+ // auth — the same RNX_API_KEY / `rnx login` / GitHub token
1223
+ // ensureCliRecordingEntitlement just verified — into the page's shared-session
1224
+ // store before starting. GitHub PR-preview auth also needs its repo identity
1225
+ // in the upload init body so the server treats the bearer as a GitHub upload
1226
+ // rather than a contrast user session.
1227
+ // combined/live recordings upload the guest bundle to /preview/<id> so the
1228
+ // page can replay the app. a built-in shell app (photos, settings, the home
1229
+ // grid) has no guest bundle — `uploadLivePreview` only discovers this at the
1230
+ // END and throws "no bundle loaded" *after* the full timed recording already
1231
+ // ran (QA F19-3). probe the same `__sootsimCaptureBundle` snapshot the upload
1232
+ // uses, up front, and fail fast with an actionable message instead.
1233
+ async function assertGuestBundleLoaded(bridge: WsBridge, mode: RecordMode) {
1234
+ const hasBundle = await probeSim(
1235
+ bridge,
1236
+ `(() => {
1237
+ const fn = window.__sootsimCaptureBundle
1238
+ if (typeof fn !== 'function') return false
1239
+ const snap = fn()
1240
+ return !!(snap && snap.bundleUrl)
1241
+ })()`,
1242
+ )
1243
+ if (!hasBundle) {
1244
+ console.error(
1245
+ ` --mode ${mode} needs a guest app bundle to upload to /preview/<id>,\n` +
1246
+ ` but this sim has no bundle loaded (a built-in shell screen like\n` +
1247
+ ` photos/settings/home has none). open a metro/guest app first, or\n` +
1248
+ ` use \`--mode video\` for a local screen recording with no upload.`,
1249
+ )
1250
+ rnxExit(1)
1251
+ }
1252
+ }
1253
+
1254
+ async function injectPreviewSessionAuth(
1255
+ bridge: WsBridge,
1256
+ originOverride?: string,
1257
+ repoScope?: RepoScope,
1258
+ ): Promise<void> {
1259
+ const auth = resolveCliAuth()
1260
+ // ensureCliRecordingEntitlement already exits the process when auth is
1261
+ // missing — this guard is purely defensive.
1262
+ if (!auth) return
1263
+ const token = auth.kind === 'api-key' ? auth.secret : auth.token
1264
+ const provenance = resolveRunProvenance()
1265
+ const gitContext = {
1266
+ branch: provenance.branch ?? undefined,
1267
+ commitSha: provenance.commitSha ?? undefined,
1268
+ githubUsername: provenance.githubUsername ?? undefined,
1269
+ pullRequestNumber: provenance.pullRequestNumber ?? undefined,
1270
+ pullRequestTitle: provenance.pullRequestTitle ?? undefined,
1271
+ }
1272
+ const uploadIdentity =
1273
+ auth.kind === 'github'
1274
+ ? {
1275
+ ...githubUploadIdentity(auth),
1276
+ owner: repoScope?.owner,
1277
+ repo: repoScope?.repo,
1278
+ ...gitContext,
1279
+ }
1280
+ : repoScope
1281
+ ? {
1282
+ owner: repoScope.owner,
1283
+ repo: repoScope.repo,
1284
+ ...gitContext,
1285
+ }
1286
+ : null
1287
+ const uploadOrigin = originOverride ? originOverride.replace(/\/$/, '') : null
1288
+ const ok: boolean = await bridge.send({
1289
+ type: 'evaluate',
1290
+ code: `(() => {
1291
+ const set = window.SootSim && window.SootSim.bridges && window.SootSim.bridges.setSession
1292
+ if (typeof set !== 'function') return false
1293
+ set({ token: ${JSON.stringify(token)}, user: null })
1294
+ window.__sootsimPreviewUploadIdentity = ${JSON.stringify(uploadIdentity)}
1295
+ window.__sootsimPreviewUseInjectedBearer = true
1296
+ ${
1297
+ uploadOrigin
1298
+ ? `window.__sootsimUploadOrigin = ${JSON.stringify(uploadOrigin)}`
1299
+ : 'delete window.__sootsimUploadOrigin'
1300
+ }
1301
+ return true
1302
+ })()`,
1303
+ })
1304
+ if (!ok) {
1305
+ // the usual cause is a stale tab — a sim that connected before the
1306
+ // engine was rebuilt keeps the old bundle until it reloads. an actually
1307
+ // outdated install is the rarer case, so lead with the cheap fix.
1308
+ console.error(
1309
+ ' SootSim.bridges.setSession missing — this sim is running an engine\n' +
1310
+ ' build without CLI-injected preview auth. reload the sim or open a\n' +
1311
+ ' fresh one (`rnx open --new`); if it persists, update rnx.',
1312
+ )
1313
+ rnxExit(1)
1314
+ }
1315
+ }
1316
+
1317
+ async function evalStoreStart(
1318
+ bridge: WsBridge,
1319
+ mode: RecordMode,
1320
+ layers?: 'tenant' | 'shell',
1321
+ ): Promise<{ ok: boolean; error: string | null }> {
1322
+ // the CLI already made the entitlement/auth decision before entering the
1323
+ // engine. pass that explicit fact through so GitHub PR-preview auth is not
1324
+ // rejected by the browser-session billing gate.
1325
+ const layersArg = layers ? JSON.stringify(layers) : 'undefined'
1326
+ const ok: boolean = await bridge.send({
1327
+ type: 'evaluate',
1328
+ code: `window.SootSim.bridges.startRecording(${JSON.stringify(mode)}, ${layersArg}, { skipEntitlement: true })`,
1329
+ })
1330
+ if (ok === true) return { ok: true, error: null }
1331
+ // the engine knows exactly why it refused — read the structured reason
1332
+ // back so the operator (and the PR-preview agent) sees it instead of an
1333
+ // opaque "recording store refused to start".
1334
+ const error: string | null = await bridge
1335
+ .send({
1336
+ type: 'evaluate',
1337
+ code: `(window.SootSim?.bridges?.getRecordingStartError?.() ?? null)`,
1338
+ })
1339
+ .catch(() => null)
1340
+ return { ok: false, error: error ?? null }
1341
+ }
1342
+
1343
+ async function evalStoreStop(bridge: WsBridge): Promise<void> {
1344
+ await bridge.send({
1345
+ type: 'evaluate',
1346
+ code: `void window.SootSim.bridges.stopRecording()`,
1347
+ })
1348
+ }
1349
+
1350
+ interface StoreUploadResult {
1351
+ previewUrl?: string
1352
+ uploadError?: string
1353
+ eventCount?: number
1354
+ }
1355
+
1356
+ async function pollStoreUpload(bridge: WsBridge): Promise<StoreUploadResult> {
1357
+ const deadline = Date.now() + UPLOAD_TIMEOUT_MS
1358
+ while (Date.now() < deadline) {
1359
+ const snap: {
1360
+ state: 'idle' | 'recording' | 'processing'
1361
+ lastUpload: { previewUrl?: string; eventCount?: number } | null
1362
+ uploadError: string | null
1363
+ } | null = await bridge.send({
1364
+ type: 'evaluate',
1365
+ code: `(() => { const s = window.SootSim?.bridges?.getRecordingState?.(); return s ? { state: s.state, lastUpload: s.lastUpload, uploadError: s.uploadError } : null })()`,
1366
+ })
1367
+ if (snap && snap.state === 'idle') {
1368
+ if (snap.uploadError) return { uploadError: snap.uploadError }
1369
+ if (snap.lastUpload?.previewUrl) {
1370
+ return {
1371
+ previewUrl: snap.lastUpload.previewUrl,
1372
+ eventCount: snap.lastUpload.eventCount,
1373
+ }
1374
+ }
1375
+ }
1376
+ await new Promise((r) => setTimeout(r, 300))
1377
+ }
1378
+ return { uploadError: `upload did not settle within ${UPLOAD_TIMEOUT_MS / 1000}s` }
1379
+ }
1380
+
1381
+ function handleStoreUploadResult(
1382
+ result: StoreUploadResult,
1383
+ openAfter: boolean,
1384
+ mode: RecordMode,
1385
+ ) {
1386
+ if (result.uploadError) {
1387
+ console.error(` upload failed: ${result.uploadError}`)
1388
+ rnxExit(1)
1389
+ }
1390
+ if (!result.previewUrl) {
1391
+ console.error(' upload returned no preview URL')
1392
+ rnxExit(1)
1393
+ }
1394
+ console.log(` preview: ${result.previewUrl}`)
1395
+ // the preview id is content-addressed (shareId = hash of the bundle), so
1396
+ // re-recording the *same* app bundle deliberately reuses this URL and
1397
+ // replaces the prior capture there — this is how stable demo shares work.
1398
+ // it's intentional, but it was silent: a quick throwaway re-record could
1399
+ // clobber a good earlier capture at a URL someone already shared, with no
1400
+ // warning (QA F19-4). say so once so the reuse is a known choice.
1401
+ console.log(
1402
+ ' note: this URL is content-addressed — re-recording the same bundle\n' +
1403
+ ' replaces this capture at the same /preview/<id>.',
1404
+ )
1405
+ // surface how many interaction events were captured (F14-7). without this
1406
+ // the CLI gave no signal that an event-capture regression shipped a stale
1407
+ // or empty stream — the only tell was opening the preview and watching
1408
+ // replay come up short.
1409
+ if ((mode === 'live' || mode === 'combined') && typeof result.eventCount === 'number') {
1410
+ console.log(
1411
+ ` captured ${result.eventCount} event${result.eventCount === 1 ? '' : 's'}`,
1412
+ )
1413
+ }
1414
+ // a recording with no captured events uploads a valid share, but the
1415
+ // preview page's Live tab has nothing to replay — it just boots a fresh
1416
+ // interactive session, which reads as a broken link. say so rather than
1417
+ // letting the user discover it after sharing (F50). combined recordings
1418
+ // still have a playable Video tab, but Live has no action stream.
1419
+ if ((mode === 'live' || mode === 'combined') && result.eventCount === 0) {
1420
+ if (mode === 'combined') {
1421
+ console.log(
1422
+ ' note: this combined recording captured no events — the Live tab has\n' +
1423
+ ' nothing to replay. Video will play the captured screen recording.',
1424
+ )
1425
+ } else {
1426
+ console.log(
1427
+ ' note: this live recording captured no events — the preview has nothing\n' +
1428
+ ' to replay and will boot a fresh interactive session instead.',
1429
+ )
1430
+ }
1431
+ }
1432
+ if (openAfter) {
1433
+ void openUrl(result.previewUrl)
1434
+ }
1435
+ }