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,256 @@
1
+ // ProjectFilePlane over a real local checkout: the second host behind the
2
+ // seam Contrast's browser already implements. writes land on the user's actual
3
+ // disk, so nothing here converts content: the plane reads and writes bytes.
4
+ //
5
+ // the file set comes from `git ls-files -co --exclude-standard`, which is the
6
+ // project's own definition of what belongs to it. a hardcoded skip list was
7
+ // measured against this repo and is not viable: it walked 230,229 files
8
+ // (16.5 GB) in 4.9 s, where git returns the same project as 14,984 paths in
9
+ // 67 ms. `index()` is a synchronous whole-project map, so the size of that set
10
+ // is the whole cost model.
11
+
12
+ import { execFileSync } from 'node:child_process'
13
+ import fs from 'node:fs'
14
+ import path from 'node:path'
15
+ import type { ProjectFileMeta, ProjectFilePlane } from '@rnx/box/plane'
16
+
17
+ export class CheckoutRequiresGitError extends Error {}
18
+
19
+ // a NUL byte is the usual "this is not text" test. it is published as metadata
20
+ // rather than acted on here: moving a binary file is byte-exact and perfectly
21
+ // fine, and so is deleting one, but a shell command that produced TEXT cannot
22
+ // be allowed to overwrite it, because that text is a lossy decode of the
23
+ // file's own bytes. BoxProjectFs owns that refusal. detecting it at write
24
+ // time instead is not an option: just-bash's `cat` discards every readFile
25
+ // error and reports "No such file or directory" whatever the error says, so
26
+ // the fact has to be in the index where the filesystem layer can read it.
27
+ const BINARY_SNIFF_BYTES = 8192
28
+
29
+ // every command a box runs re-indexes first, and the index read waits on a git
30
+ // subprocess. it used to await one, and that await could never finish: measured
31
+ // here over 961 refreshes, the call either answered within 314ms (p50 25ms) or
32
+ // never answered at all, once for 29 seconds while the process's own timer kept
33
+ // firing every second. that is not a slow git, it is a lost answer, and no
34
+ // budget reaches it.
35
+ //
36
+ // a deadline on the async form does not help either, which is why this is
37
+ // synchronous. node's `execFile` timeout kills the child and then still waits
38
+ // for a 'close' event to settle the promise, so the lost event takes the
39
+ // deadline down with it: the bound below was armed and silent through all 29
40
+ // of those seconds. `execFileSync` waits in the runtime instead of on a
41
+ // javascript event, so there is nothing to miss, and its timeout is enforced
42
+ // the same way.
43
+ //
44
+ // it costs nothing. the entry loop below is already synchronous stat and read,
45
+ // and under 24-way load the two forms were indistinguishable over 960 calls
46
+ // each, interleaved: p50 1529ms synchronous against 1520ms asynchronous.
47
+ //
48
+ // 10s is roughly fifteen times the slowest full refresh measured on a
49
+ // 14,889-file checkout (430-840 ms, of which `git ls-files` itself is 67 ms),
50
+ // so it cannot fire for a project that is merely large or a machine that is
51
+ // merely busy.
52
+ const LIST_FILES_TIMEOUT_MS = 10_000
53
+
54
+ // the same busy machine fails this call in two ways that are both a failed
55
+ // READ rather than an answer: the spawn raises, and a child exits 0 having
56
+ // written nothing. docs/testing.md measured the second one directly on
57
+ // 2026-08-27 under CPU oversubscription, where a git child exited 0 with empty
58
+ // stdout while the very next `/bin/echo` read back fine.
59
+ //
60
+ // the answer to a failed read is to read again. every box command re-indexes
61
+ // before it runs, so a single lost answer is otherwise the difference between
62
+ // `touch created.txt` working and the box telling the user it has no file set
63
+ // to serve: measured here as exit 1 in 103ms, which is what took the box
64
+ // conformance suite red under a load average of 47. the conditions the two
65
+ // tests in test/rnxBoxCheckoutPlane.test.ts hold (a git that never answers, a
66
+ // git that always answers with nothing) are permanent, so they still fail, and
67
+ // they fail naming the same thing.
68
+ const LIST_FILES_ATTEMPTS = 3
69
+ const LIST_FILES_RETRY_MS = 25
70
+
71
+ function looksBinary(filePath: string, size: number): boolean {
72
+ if (size === 0) return false
73
+ let fd: number | undefined
74
+ try {
75
+ fd = fs.openSync(filePath, 'r')
76
+ const buffer = Buffer.alloc(Math.min(BINARY_SNIFF_BYTES, size))
77
+ const read = fs.readSync(fd, buffer, 0, buffer.length, 0)
78
+ return buffer.subarray(0, read).includes(0)
79
+ } catch {
80
+ return false
81
+ } finally {
82
+ if (fd !== undefined) fs.closeSync(fd)
83
+ }
84
+ }
85
+
86
+ // the box re-indexes before every command, so the sniff above runs against the
87
+ // whole project every time a user presses return. on a 14,889-file checkout
88
+ // that is 277-644 ms of a 430-840 ms refresh, and nearly all of it re-reads
89
+ // files that did not change. a file whose size and mtime both match the last
90
+ // look has the same first 8 KB, so remember the answer against those two.
91
+ // `statSync` already returns mtimeMs, and it carries sub-millisecond
92
+ // resolution here, so the window in which a same-size rewrite could hide is
93
+ // narrower than a millisecond rather than git's one-second racy-clean case.
94
+ interface SniffedFile {
95
+ size: number
96
+ mtimeMs: number
97
+ binary: boolean
98
+ }
99
+
100
+ export class CheckoutFilePlane implements ProjectFilePlane {
101
+ readonly root: string
102
+ private entries = new Map<string, ProjectFileMeta>()
103
+ private sniffed = new Map<string, SniffedFile>()
104
+ // the deadline above, as a number a caller can set. what it changes is how
105
+ // long a wedged git is waited on, never whether it is waited on, so proving
106
+ // the kill does not have to cost the production deadline.
107
+ private readonly listTimeoutMs: number
108
+ private readonly trackedOnly: boolean
109
+
110
+ constructor(
111
+ root: string,
112
+ options: { listTimeoutMs?: number; trackedOnly?: boolean } = {},
113
+ ) {
114
+ this.root = path.resolve(root)
115
+ this.listTimeoutMs = options.listTimeoutMs ?? LIST_FILES_TIMEOUT_MS
116
+ this.trackedOnly = options.trackedOnly ?? false
117
+ }
118
+
119
+ index(): ReadonlyMap<string, ProjectFileMeta> {
120
+ return this.entries
121
+ }
122
+
123
+ private listFiles(): string {
124
+ // -z keeps paths containing spaces or newlines intact. a local Box sees
125
+ // tracked plus visible untracked files, while a durable cloud import is
126
+ // deliberately restricted to the checkout's tracked source.
127
+ const args = this.trackedOnly
128
+ ? ['ls-files', '-z']
129
+ : ['ls-files', '-co', '--exclude-standard', '-z']
130
+ const stdout = execFileSync('git', args, {
131
+ cwd: this.root,
132
+ encoding: 'utf8',
133
+ maxBuffer: 64 * 1024 * 1024,
134
+ timeout: this.listTimeoutMs,
135
+ // stating the environment is not redundant here. bun 1.3.14 resolves
136
+ // the binary for the synchronous form against the PATH it captured at
137
+ // startup and ignores a later change to `process.env.PATH`, so without
138
+ // this it runs a different git than every other call in the process.
139
+ env: process.env,
140
+ })
141
+ // empty child output never means an empty answer. without this the box
142
+ // would build an empty index and every read would report the file as
143
+ // missing, which is a broken project rather than a broken box, and nothing
144
+ // would say so.
145
+ if (stdout.length === 0) {
146
+ throw new CheckoutRequiresGitError(
147
+ `git listed no ${this.trackedOnly ? 'tracked ' : ''}files in ${this.root}. either the checkout contains no eligible files, or this machine could not read the child process's output.`,
148
+ )
149
+ }
150
+ return stdout
151
+ }
152
+
153
+ async refresh(): Promise<void> {
154
+ let stdout = ''
155
+ let lastFailure: CheckoutRequiresGitError | undefined
156
+ for (let attempt = 1; attempt <= LIST_FILES_ATTEMPTS; attempt++) {
157
+ try {
158
+ stdout = this.listFiles()
159
+ lastFailure = undefined
160
+ break
161
+ } catch (error) {
162
+ if (error instanceof CheckoutRequiresGitError) {
163
+ lastFailure = error
164
+ } else {
165
+ const message = error instanceof Error ? error.message : String(error)
166
+ // the deadline kills with SIGTERM, so that signal is what tells a
167
+ // bound run apart from git failing on its own terms, and it is worth
168
+ // naming separately: "git was killed by SIGTERM" reads as git's
169
+ // problem, when what happened is that it never answered.
170
+ const killed =
171
+ error instanceof Error && 'signal' in error && error.signal === 'SIGTERM'
172
+ lastFailure = new CheckoutRequiresGitError(
173
+ killed
174
+ ? `git did not list the project's files in ${this.root} within ${this.listTimeoutMs / 1000}s, so the box has no file set to serve. the checkout may be on a stalled filesystem, or this machine may be too loaded to start a subprocess.`
175
+ : `could not list the project's files with git in ${this.root}: ${message}`,
176
+ )
177
+ }
178
+ if (attempt < LIST_FILES_ATTEMPTS) {
179
+ await new Promise((resolve) =>
180
+ setTimeout(resolve, LIST_FILES_RETRY_MS * attempt),
181
+ )
182
+ }
183
+ }
184
+ }
185
+ if (lastFailure) {
186
+ lastFailure.message = `${lastFailure.message} (${LIST_FILES_ATTEMPTS} attempts)`
187
+ throw lastFailure
188
+ }
189
+
190
+ const next = new Map<string, ProjectFileMeta>()
191
+ // rebuilt alongside the index rather than pruned, so a deleted file's
192
+ // record cannot outlive it in a box that stays open all day
193
+ const nextSniffed = new Map<string, SniffedFile>()
194
+ for (const relative of stdout.split('\0')) {
195
+ if (!relative) continue
196
+ let stat: fs.Stats
197
+ try {
198
+ stat = fs.statSync(path.join(this.root, relative))
199
+ } catch {
200
+ // listed but gone (a deleted-but-tracked file); it is simply not there
201
+ continue
202
+ }
203
+ if (!stat.isFile()) continue
204
+ const seen = this.sniffed.get(relative)
205
+ const binary =
206
+ seen && seen.size === stat.size && seen.mtimeMs === stat.mtimeMs
207
+ ? seen.binary
208
+ : looksBinary(path.join(this.root, relative), stat.size)
209
+ nextSniffed.set(relative, { size: stat.size, mtimeMs: stat.mtimeMs, binary })
210
+ next.set(relative, {
211
+ size: stat.size,
212
+ // a checkout carries no write policy of its own; if the user can write
213
+ // the file, so can the shell
214
+ readOnly: false,
215
+ binary,
216
+ })
217
+ }
218
+ this.entries = next
219
+ this.sniffed = nextSniffed
220
+ }
221
+
222
+ private absolute(planePath: string): string {
223
+ const resolved = path.resolve(this.root, planePath)
224
+ // the shell resolves "." and ".." before calling us, but the plane owns the
225
+ // checkout boundary and must not depend on that.
226
+ if (resolved !== this.root && !resolved.startsWith(`${this.root}${path.sep}`)) {
227
+ throw new Error(`path escapes the project: ${planePath}`)
228
+ }
229
+ return resolved
230
+ }
231
+
232
+ async read(planePath: string): Promise<string> {
233
+ return fs.promises.readFile(this.absolute(planePath), 'utf8')
234
+ }
235
+
236
+ async readBytes(planePath: string): Promise<Uint8Array> {
237
+ return fs.promises.readFile(this.absolute(planePath))
238
+ }
239
+
240
+ async write(planePath: string, content: Uint8Array): Promise<void> {
241
+ const absolute = this.absolute(planePath)
242
+ await fs.promises.mkdir(path.dirname(absolute), { recursive: true })
243
+ await fs.promises.writeFile(absolute, content)
244
+ this.entries.set(planePath, {
245
+ size: content.byteLength,
246
+ readOnly: false,
247
+ // the bytes are in hand, so the same NUL test costs nothing here
248
+ binary: content.subarray(0, BINARY_SNIFF_BYTES).includes(0),
249
+ })
250
+ }
251
+
252
+ async delete(planePath: string): Promise<void> {
253
+ await fs.promises.rm(this.absolute(planePath), { force: true })
254
+ this.entries.delete(planePath)
255
+ }
256
+ }
@@ -0,0 +1,208 @@
1
+ // a local box authorizing its own checkout for browser access.
2
+ //
3
+ // the bridge daemon serves the `plane:*` envelope, but it will not open a root
4
+ // nobody has authorized, because a WebSocket to 127.0.0.1 is not subject to
5
+ // CORS and any page the user visits can open one. so the box registers its
6
+ // root and holds the socket for as long as it runs; when the box exits, the
7
+ // registration goes with it and the tab loses reach.
8
+ //
9
+ // THIS NEVER BLOCKS THE BOX. an earlier version awaited the socket before
10
+ // printing the prompt and cost 1450ms of start time on every box, because a
11
+ // daemon built before `plane:*` existed accepts the socket, ignores the
12
+ // message, and never replies, so the client sat on its timeout. a box that
13
+ // cannot share its plane is a box with one capability missing, not a box that
14
+ // starts slowly.
15
+ //
16
+ // IT ALSO REFUSES TO GUESS A PORT. when no lockfile claims a bridge,
17
+ // resolveBridgeWorld reports source 'default' and hands back
18
+ // DEFAULT_SOOTSIM_BRIDGE_PORT, which on a machine running a real daemon is
19
+ // that daemon. registering there would attach a test's or a benchmark's box to
20
+ // another session's daemon, which is exactly what a private RNX_HOME
21
+ // exists to prevent. so a guessed port is refused by name.
22
+
23
+ import { WebSocket } from 'ws'
24
+ import { bridgeWsUrl, resolveBridgeWorld } from '../../ws-bridge'
25
+
26
+ const REGISTER_TIMEOUT_MS = 1500
27
+
28
+ export interface PlaneShare {
29
+ /**
30
+ * resolves when the daemon has accepted the root, or with a reason it did
31
+ * not. `holders` is how many boxes now hold this checkout, including this
32
+ * one, so more than one means another box is writing the same tree.
33
+ */
34
+ readonly settled: Promise<{ port: number; holders: number } | { declined: string }>
35
+ close(): void
36
+ }
37
+
38
+ function target(
39
+ explicitPort: number | undefined,
40
+ ): { port: number } | { declined: string } {
41
+ if (explicitPort) return { port: explicitPort }
42
+ const world = resolveBridgeWorld()
43
+ if (world.source === 'default') {
44
+ return {
45
+ declined:
46
+ 'no daemon or dev bridge claims a port, and the default is another session’s daemon on a shared machine',
47
+ }
48
+ }
49
+ return { port: world.bridgePort }
50
+ }
51
+
52
+ /**
53
+ * register `root` with the bridge daemon in the background. returns
54
+ * immediately; the box never waits on it.
55
+ */
56
+ export function sharePlane(root: string, opts: { port?: number } = {}): PlaneShare {
57
+ const chosen = target(opts.port)
58
+ if ('declined' in chosen) {
59
+ return { settled: Promise.resolve(chosen), close: () => {} }
60
+ }
61
+
62
+ const port = chosen.port
63
+ let socket: WebSocket | null = null
64
+ let closed = false
65
+
66
+ const settled = new Promise<{ port: number; holders: number } | { declined: string }>(
67
+ (resolve) => {
68
+ let done = false
69
+ const finish = (
70
+ value: { port: number; holders: number } | { declined: string },
71
+ ) => {
72
+ if (done) return
73
+ done = true
74
+ clearTimeout(timer)
75
+ resolve(value)
76
+ }
77
+
78
+ const timer = setTimeout(() => {
79
+ // an older daemon accepts the socket and ignores plane:* entirely, so
80
+ // silence is the expected shape of "not supported" rather than a fault.
81
+ socket?.close()
82
+ finish({ declined: `bridge on port ${port} did not answer plane:register` })
83
+ }, REGISTER_TIMEOUT_MS)
84
+
85
+ try {
86
+ socket = new WebSocket(bridgeWsUrl(port))
87
+ } catch (error) {
88
+ finish({ declined: error instanceof Error ? error.message : String(error) })
89
+ return
90
+ }
91
+ if (closed) {
92
+ socket.close()
93
+ finish({ declined: 'box exited before the plane registered' })
94
+ return
95
+ }
96
+
97
+ socket.on('error', (error) => finish({ declined: error.message }))
98
+ socket.on('close', () => finish({ declined: `bridge on port ${port} closed` }))
99
+ socket.on('open', () => {
100
+ // node sends no Origin, so this socket is already cli-role. a browser
101
+ // client has to send bridge:hello to be corrected off the provisional
102
+ // sim role; see plans/rnx-cloud/transport-decision.md.
103
+ socket?.send(JSON.stringify({ type: 'plane:register', id: 'register', root }))
104
+ })
105
+ socket.on('message', (data) => {
106
+ let parsed: unknown
107
+ try {
108
+ parsed = JSON.parse(String(data))
109
+ } catch {
110
+ return
111
+ }
112
+ if (typeof parsed !== 'object' || parsed === null) return
113
+ const reply: Record<string, unknown> = { ...parsed }
114
+ if (reply.id !== 'register') return
115
+ if (reply.error) {
116
+ socket?.close()
117
+ finish({ declined: String(reply.error) })
118
+ return
119
+ }
120
+ // the daemon nests its payload under `result`; reading holders off the
121
+ // envelope silently reported 1 forever, which is the exact silence this
122
+ // field exists to remove.
123
+ const result = (reply.result ?? {}) as Record<string, unknown>
124
+ finish({ port, holders: Number(result.holders ?? 1) })
125
+ })
126
+ },
127
+ )
128
+
129
+ return {
130
+ settled,
131
+ close: () => {
132
+ closed = true
133
+ try {
134
+ socket?.close()
135
+ } catch {}
136
+ },
137
+ }
138
+ }
139
+
140
+ /**
141
+ * How many boxes hold `root` right now, without registering anything.
142
+ *
143
+ * `plane:register` cannot answer this: registering is what makes a root
144
+ * reachable over a loopback socket, so a readiness probe that asked with it
145
+ * would both authorize the root and count itself in the answer.
146
+ */
147
+ export async function planeStatus(
148
+ root: string,
149
+ opts: { port?: number } = {},
150
+ ): Promise<{ holders: number; registered: boolean } | { declined: string }> {
151
+ const chosen = target(opts.port)
152
+ if ('declined' in chosen) return chosen
153
+ const port = chosen.port
154
+
155
+ return new Promise((resolve) => {
156
+ let done = false
157
+ let socket: WebSocket | null = null
158
+ const finish = (
159
+ value: { holders: number; registered: boolean } | { declined: string },
160
+ ) => {
161
+ if (done) return
162
+ done = true
163
+ clearTimeout(timer)
164
+ try {
165
+ socket?.close()
166
+ } catch {}
167
+ resolve(value)
168
+ }
169
+ const timer = setTimeout(
170
+ () => finish({ declined: `bridge on port ${port} did not answer plane:status` }),
171
+ REGISTER_TIMEOUT_MS,
172
+ )
173
+
174
+ try {
175
+ socket = new WebSocket(bridgeWsUrl(port))
176
+ } catch (error) {
177
+ finish({ declined: error instanceof Error ? error.message : String(error) })
178
+ return
179
+ }
180
+ socket.on('error', (error) => finish({ declined: error.message }))
181
+ socket.on('close', () => finish({ declined: `bridge on port ${port} closed` }))
182
+ socket.on('open', () => {
183
+ socket?.send(JSON.stringify({ type: 'plane:status', id: 'status', root }))
184
+ })
185
+ socket.on('message', (data) => {
186
+ let parsed: unknown
187
+ try {
188
+ parsed = JSON.parse(String(data))
189
+ } catch {
190
+ return
191
+ }
192
+ if (typeof parsed !== 'object' || parsed === null) return
193
+ const reply: Record<string, unknown> = { ...parsed }
194
+ if (reply.id !== 'status') return
195
+ if (reply.error) {
196
+ finish({ declined: String(reply.error) })
197
+ return
198
+ }
199
+ // the daemon nests its payload under `result`, the same shape
200
+ // plane:register answers in.
201
+ const result = (reply.result ?? {}) as Record<string, unknown>
202
+ finish({
203
+ holders: Number(result.holders ?? 0),
204
+ registered: Boolean(result.registered),
205
+ })
206
+ })
207
+ })
208
+ }
@@ -0,0 +1,60 @@
1
+ // step 4: RNX operations registered as just-bash commands.
2
+ //
3
+ // `rnx` inside a box shell is the same CLI, re-invoked as a child process with
4
+ // the shell's own cwd. that is deliberate — spelling the verbs a second time
5
+ // in shell-command form would be a second implementation of every command, and
6
+ // the spec is explicit that the CLI and the in-shell commands are clients of
7
+ // one implementation, not two.
8
+
9
+ import { spawn } from 'node:child_process'
10
+ import path from 'node:path'
11
+ import { defineCommand, type CustomCommand } from '@rnx/box/shell'
12
+ import { PROJECT_MOUNT } from '@rnx/box/shell'
13
+ import { rnxPublicBrand } from '../../../src/public-brand'
14
+ import { rnxSelfInvocation } from '../../self-invocation'
15
+ import { RNX_PLACEMENT_ENV } from '../../ws-bridge'
16
+
17
+ // map a shell path under /project back onto the real checkout, so a child
18
+ // process started from `cd src && rnx describe` sees the directory the user
19
+ // is actually standing in.
20
+ function hostCwd(root: string, shellCwd: string): string {
21
+ if (shellCwd === PROJECT_MOUNT) return root
22
+ if (!shellCwd.startsWith(`${PROJECT_MOUNT}/`)) return root
23
+ return path.join(root, shellCwd.slice(PROJECT_MOUNT.length + 1))
24
+ }
25
+
26
+ export function createRnxCommand(options: {
27
+ root: string
28
+ shellCwd: () => string
29
+ }): CustomCommand {
30
+ return defineCommand(rnxPublicBrand.commandName, async (args) => {
31
+ const { executable, prefixArgs } = rnxSelfInvocation()
32
+ const child = spawn(executable, [...prefixArgs, ...args], {
33
+ cwd: hostCwd(options.root, options.shellCwd()),
34
+ // mark the child as running inside a local box. this is the only thing
35
+ // that sets a placement — nothing infers one from cwd — and it is what
36
+ // lets `rnx box` refuse to nest inside itself.
37
+ env: { ...process.env, [RNX_PLACEMENT_ENV]: 'local-box' },
38
+ stdio: ['ignore', 'pipe', 'pipe'],
39
+ })
40
+
41
+ let stdout = ''
42
+ let stderr = ''
43
+ child.stdout.on('data', (chunk) => {
44
+ stdout += String(chunk)
45
+ })
46
+ child.stderr.on('data', (chunk) => {
47
+ stderr += String(chunk)
48
+ })
49
+
50
+ const exitCode = await new Promise<number>((resolve) => {
51
+ child.once('error', (error) => {
52
+ stderr += `${rnxPublicBrand.commandName}: ${error.message}\n`
53
+ resolve(1)
54
+ })
55
+ child.once('close', (code) => resolve(code ?? 0))
56
+ })
57
+
58
+ return { stdout, stderr, exitCode }
59
+ })
60
+ }
@@ -0,0 +1,157 @@
1
+ // `rg` inside a local box, run by git rather than by a JavaScript matcher.
2
+ //
3
+ // just-bash implements rg in JavaScript and reads every file body through the
4
+ // plane, so one search of this repo is ~15,000 plane reads. MEASURED in the box
5
+ // shell: 14,093ms and two lines of output, which are its abort message. It does
6
+ // not return results at all, and searching is the most common thing an agent
7
+ // does, so that is the box being unusable on a real project.
8
+ //
9
+ // WHY GIT GREP AND NOT RIPGREP. The first version of this shelled out to `rg`,
10
+ // which turned CI red: the Linux runners have no ripgrep binary, and a box that
11
+ // depends on whatever happens to be installed on the host is not a box. Git is
12
+ // different in kind, not degree: `CheckoutFilePlane` already REFUSES to open a
13
+ // project that is not a git repository and runs `git ls-files` before every
14
+ // single command, so git is a hard, already-enforced requirement. Searching
15
+ // with it adds no dependency and cannot be absent where the box already runs.
16
+ //
17
+ // It also makes the file set correct BY CONSTRUCTION rather than by agreement.
18
+ // The box indexes `git ls-files -co --exclude-standard`, tracked AND
19
+ // untracked-not-ignored, and `git grep --untracked` applies those same rules
20
+ // from the same tool. RAN against a checkout built to expose the difference:
21
+ // tracked, untracked-not-ignored, a dotfile and a dotdir file all match, and
22
+ // gitignored trees do not. Ripgrep needed the plane's path list piped into it
23
+ // to get there, because plain `rg .` silently misses dotfiles and `rg --hidden`
24
+ // searches .git/**.
25
+ //
26
+ // MEASURED on this repo, 14,949 indexed files: 391ms p50 against ripgrep's
27
+ // 260ms and just-bash's 14,093ms. Giving up 130ms to remove a host dependency
28
+ // and an entire class of "works on my machine" is the right trade.
29
+ //
30
+ // It also removes the ARG_MAX chunking the ripgrep version needed. Git walks
31
+ // its own index, so there is one process and one exit code rather than several
32
+ // whose "no match here" had to be merged.
33
+
34
+ import { spawn } from 'node:child_process'
35
+ import { defineCommand, type CustomCommand } from '@rnx/box/shell'
36
+ import { PROJECT_MOUNT } from '@rnx/box/shell'
37
+
38
+ // generous next to a 414ms p95: this bounds a wedged child, it does not cut off
39
+ // a large search.
40
+ const SEARCH_DEADLINE_MS = 20_000
41
+
42
+ /**
43
+ * THE DEADLINE SETTLES THIS PROMISE ITSELF rather than only killing the child.
44
+ * A spawned child's answer has been observed going missing while the process
45
+ * stayed alive, with no one able to say which event never arrived, so nothing
46
+ * that waits on a child event can be trusted to settle. A timer in this process
47
+ * cannot be missed, so it is the authority and the kill is a side effect.
48
+ * See docs/testing.md.
49
+ */
50
+ function runGitGrep(args: string[], cwd: string) {
51
+ return new Promise<{ stdout: string; stderr: string; exitCode: number }>((resolve) => {
52
+ const child = spawn('git', args, {
53
+ cwd,
54
+ env: process.env,
55
+ stdio: ['ignore', 'pipe', 'pipe'],
56
+ })
57
+ let stdout = ''
58
+ let stderr = ''
59
+ let settled = false
60
+ const finish = (exitCode: number) => {
61
+ if (settled) return
62
+ settled = true
63
+ clearTimeout(timer)
64
+ resolve({ stdout, stderr, exitCode })
65
+ }
66
+ const timer = setTimeout(() => {
67
+ try {
68
+ child.kill('SIGKILL')
69
+ } catch {}
70
+ stderr += `rg: search timed out after ${SEARCH_DEADLINE_MS}ms\n`
71
+ finish(2)
72
+ }, SEARCH_DEADLINE_MS)
73
+
74
+ child.stdout.on('data', (chunk) => {
75
+ stdout += String(chunk)
76
+ })
77
+ child.stderr.on('data', (chunk) => {
78
+ stderr += String(chunk)
79
+ })
80
+ child.once('error', (error) => {
81
+ stderr += `rg: ${error.message}\n`
82
+ finish(2)
83
+ })
84
+ child.once('close', (code) => finish(code ?? 0))
85
+ })
86
+ }
87
+
88
+ // the rg flags worth honouring, mapped onto git grep's spelling. anything else
89
+ // is REFUSED BY NAME rather than dropped, because a search that silently
90
+ // ignores `-i` returns a confidently wrong answer.
91
+ const FLAG_MAP: Record<string, string> = {
92
+ '-i': '-i',
93
+ '--ignore-case': '-i',
94
+ '-l': '-l',
95
+ '--files-with-matches': '-l',
96
+ '-w': '-w',
97
+ '--word-regexp': '-w',
98
+ '-c': '-c',
99
+ '--count': '-c',
100
+ '-v': '-v',
101
+ '--invert-match': '-v',
102
+ '-F': '-F',
103
+ '--fixed-strings': '-F',
104
+ }
105
+
106
+ export function createSearchCommand(options: { root: string }): CustomCommand {
107
+ return defineCommand('rg', async (args) => {
108
+ const flags: string[] = []
109
+ const bare: string[] = []
110
+ for (const arg of args) {
111
+ if (!arg.startsWith('-')) {
112
+ bare.push(arg)
113
+ continue
114
+ }
115
+ const mapped = FLAG_MAP[arg]
116
+ if (!mapped) {
117
+ return {
118
+ stdout: '',
119
+ stderr: `rg: this box does not support ${arg}\n`,
120
+ exitCode: 2,
121
+ }
122
+ }
123
+ flags.push(mapped)
124
+ }
125
+
126
+ const pattern = bare.shift()
127
+ if (pattern === undefined) {
128
+ return { stdout: '', stderr: 'rg: no pattern given\n', exitCode: 2 }
129
+ }
130
+
131
+ // paths are project-relative to git; strip the shell's mount prefix so
132
+ // `rg x /project/src` and `rg x src` mean the same thing.
133
+ const scopes = bare.map((p) =>
134
+ p.startsWith(`${PROJECT_MOUNT}/`) ? p.slice(PROJECT_MOUNT.length + 1) : p,
135
+ )
136
+
137
+ // --untracked is what makes this the plane's set rather than git's tracked
138
+ // set. -E for extended regex, the closest of git's grammars to ripgrep's.
139
+ // -e so a pattern beginning with a dash is a pattern, not a flag.
140
+ const result = await runGitGrep(
141
+ [
142
+ 'grep',
143
+ '--no-color',
144
+ '-n',
145
+ '-I',
146
+ '--untracked',
147
+ '-E',
148
+ ...flags,
149
+ '-e',
150
+ pattern,
151
+ ...(scopes.length > 0 ? ['--', ...scopes] : []),
152
+ ],
153
+ options.root,
154
+ )
155
+ return result
156
+ })
157
+ }