rnxsim 0.1.370 → 0.1.371

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 (203) hide show
  1. package/cli/commands/box/checkout-plane.ts +65 -14
  2. package/cli/commands/box/cloud-box.ts +294 -0
  3. package/cli/commands/box/plane-share.ts +208 -0
  4. package/cli/commands/box/rnx-command.ts +2 -2
  5. package/cli/commands/box/search-command.ts +157 -0
  6. package/cli/commands/box.ts +269 -13
  7. package/cli/commands/control.ts +6 -1
  8. package/cli/outbound-endpoints.ts +37 -1
  9. package/dist-cli/bin.js +5 -5
  10. package/dist-cli/chunks/{agent-3R4424LW.js → agent-W2D2V7WF.js} +2 -2
  11. package/dist-cli/chunks/{agent-wrapper-4PPWZWSY.js → agent-wrapper-GVNM2Z4S.js} +2 -2
  12. package/dist-cli/chunks/{app-fonts-KYXB6CXQ.js → app-fonts-J6R4TTXD.js} +2 -2
  13. package/dist-cli/chunks/{assert-7EZOWGYQ.js → assert-YN6U4ZTB.js} +2 -2
  14. package/dist-cli/chunks/{auth-L7M36H6C.js → auth-XGYZAK2P.js} +2 -2
  15. package/dist-cli/chunks/{beta-FKAF6YWJ.js → beta-WRDMHHWJ.js} +2 -2
  16. package/dist-cli/chunks/{box-5Y5VBMHV.js → box-ML5TFUQW.js} +617 -596
  17. package/dist-cli/chunks/{camera-XHF2RPFG.js → camera-OBEI4FZA.js} +2 -2
  18. package/dist-cli/chunks/{chunk-Z2VBSNFI.js → chunk-2FIT64GR.js} +1 -1
  19. package/dist-cli/chunks/{chunk-PAUEZO6F.js → chunk-2LH4DYPM.js} +2 -2
  20. package/dist-cli/chunks/{chunk-H5X5FJ63.js → chunk-2QW3JH6T.js} +2 -2
  21. package/dist-cli/chunks/{chunk-NR2V2ONN.js → chunk-2W4YBCPX.js} +2 -2
  22. package/dist-cli/chunks/{chunk-DMHSD6YJ.js → chunk-42YOIDLR.js} +2 -2
  23. package/dist-cli/chunks/{chunk-72S5FCBY.js → chunk-47MSXLWK.js} +2 -2
  24. package/dist-cli/chunks/{chunk-6GPS7U4X.js → chunk-4OLUFHEZ.js} +2 -2
  25. package/dist-cli/chunks/{chunk-ARMXC3C3.js → chunk-5PPVVGLH.js} +3 -3
  26. package/dist-cli/chunks/{chunk-6A6L3VJR.js → chunk-6AZKZ5AJ.js} +2 -2
  27. package/dist-cli/chunks/{chunk-UFVAAWGZ.js → chunk-6FPXLSBJ.js} +1 -1
  28. package/dist-cli/chunks/{chunk-PIZXOXBL.js → chunk-763GBI7G.js} +2 -2
  29. package/dist-cli/chunks/{chunk-VVQCZ42C.js → chunk-7ASCDKA3.js} +1 -1
  30. package/dist-cli/chunks/{chunk-LIMWG54N.js → chunk-7Q4WHJDH.js} +1 -1
  31. package/dist-cli/chunks/{chunk-DHYTRCIP.js → chunk-7TWD4P6J.js} +2 -2
  32. package/dist-cli/chunks/{chunk-NJMU5BN6.js → chunk-7YB46LGI.js} +1 -1
  33. package/dist-cli/chunks/{chunk-EWSWOWXK.js → chunk-ADJSGO55.js} +2 -2
  34. package/dist-cli/chunks/{chunk-CMVRTZ5G.js → chunk-AGZZWLEL.js} +2 -2
  35. package/dist-cli/chunks/{chunk-3BG6UCQY.js → chunk-AVIH3TAZ.js} +11 -11
  36. package/dist-cli/chunks/{chunk-WPV4H5S4.js → chunk-B26ZJUQA.js} +2 -2
  37. package/dist-cli/chunks/{chunk-AALKIAUZ.js → chunk-BNS5LNEU.js} +1 -1
  38. package/dist-cli/chunks/{chunk-FA75F34Z.js → chunk-CJQXP4XM.js} +1 -1
  39. package/dist-cli/chunks/{chunk-6S5LJV2O.js → chunk-DP6RGTNN.js} +2 -2
  40. package/dist-cli/chunks/{chunk-3SOPYG7S.js → chunk-DYFOLOYL.js} +2 -2
  41. package/dist-cli/chunks/{chunk-AVJBT5LF.js → chunk-ERBGQEJ5.js} +1 -1
  42. package/dist-cli/chunks/{chunk-EOIBLKZO.js → chunk-ESWSXF55.js} +2 -2
  43. package/dist-cli/chunks/{chunk-5RKRVS5A.js → chunk-FLNGSIBL.js} +2 -2
  44. package/dist-cli/chunks/{chunk-DXV37CJN.js → chunk-GQGS7TRK.js} +1 -1
  45. package/dist-cli/chunks/{chunk-GRO74J2X.js → chunk-H4DU457S.js} +1 -1
  46. package/dist-cli/chunks/{chunk-7C7TJYZ3.js → chunk-HFY35VAG.js} +2 -2
  47. package/dist-cli/chunks/{chunk-PNOENVMJ.js → chunk-HV3WOWQM.js} +1 -1
  48. package/dist-cli/chunks/{chunk-O6QLKSAM.js → chunk-HZ365EMS.js} +2 -2
  49. package/dist-cli/chunks/chunk-IPNDXGVA.js +11 -0
  50. package/dist-cli/chunks/{chunk-MDCY532E.js → chunk-IX7Z6ZVJ.js} +2 -2
  51. package/dist-cli/chunks/{chunk-EQ3OE2AV.js → chunk-IYU2EOIU.js} +1 -1
  52. package/dist-cli/chunks/{chunk-HQQTS2CB.js → chunk-J6A5W2DI.js} +3 -3
  53. package/dist-cli/chunks/{chunk-BMIJZNKD.js → chunk-JKRMBNPO.js} +2 -2
  54. package/dist-cli/chunks/{chunk-YIZGNT7A.js → chunk-JUTLQ3GX.js} +1 -1
  55. package/dist-cli/chunks/{chunk-RRX52O37.js → chunk-KHGXEU4L.js} +1 -1
  56. package/dist-cli/chunks/{chunk-D2GZASRV.js → chunk-KL34TRNW.js} +1 -1
  57. package/dist-cli/chunks/chunk-LFANRLWK.js +7 -0
  58. package/dist-cli/chunks/{chunk-2FOGXVOK.js → chunk-MOIQZVRU.js} +2 -2
  59. package/dist-cli/chunks/{chunk-HEKHLQ3B.js → chunk-MW4E5EED.js} +3 -3
  60. package/dist-cli/chunks/{chunk-GJRLPJQL.js → chunk-N3LAMTGS.js} +2 -2
  61. package/dist-cli/chunks/{chunk-3UXXYJVD.js → chunk-NQ4CI2VI.js} +1 -1
  62. package/dist-cli/chunks/{chunk-JZLDHPB7.js → chunk-OF3U4SZO.js} +2 -2
  63. package/dist-cli/chunks/{chunk-I52NT6VG.js → chunk-OF5OBB67.js} +1 -1
  64. package/dist-cli/chunks/{chunk-64JAEJ6N.js → chunk-OQPZOEEP.js} +1 -1
  65. package/dist-cli/chunks/{chunk-X6WQ4YWE.js → chunk-OWDJQWJ7.js} +1 -1
  66. package/dist-cli/chunks/{chunk-Q4MRJ6PO.js → chunk-PTWMX4AA.js} +2 -2
  67. package/dist-cli/chunks/{chunk-LUOHRBJG.js → chunk-RYJTPZCX.js} +1 -1
  68. package/dist-cli/chunks/{chunk-IDLFVRPN.js → chunk-S3AH2O67.js} +1 -1
  69. package/dist-cli/chunks/{chunk-6W4HIWC7.js → chunk-SH2HXHR2.js} +2 -2
  70. package/dist-cli/chunks/{chunk-5BR33NAZ.js → chunk-SHLJYXIN.js} +1 -1
  71. package/dist-cli/chunks/{chunk-Z3DENGXW.js → chunk-TL22EBF5.js} +2 -2
  72. package/dist-cli/chunks/{chunk-FK4JSTZ3.js → chunk-TWNLRDIH.js} +2 -2
  73. package/dist-cli/chunks/{chunk-LXPHL6FO.js → chunk-UHYAY3RU.js} +2 -2
  74. package/dist-cli/chunks/{chunk-C7BA5CCM.js → chunk-UPMR6Y5P.js} +2 -2
  75. package/dist-cli/chunks/{chunk-D24EOJ4C.js → chunk-VEFSHPSU.js} +2 -2
  76. package/dist-cli/chunks/{chunk-DUXHYPBP.js → chunk-WCB5XPCH.js} +2 -2
  77. package/dist-cli/chunks/{chunk-WFQHT4QD.js → chunk-WHZE63R2.js} +2 -2
  78. package/dist-cli/chunks/{chunk-OJZHECGH.js → chunk-WLRIPAGJ.js} +1 -1
  79. package/dist-cli/chunks/{chunk-U63T6LIQ.js → chunk-Y7SKGRAV.js} +1 -1
  80. package/dist-cli/chunks/chunk-Z6CQDU74.js +4 -0
  81. package/dist-cli/chunks/{cleanup-MXMMJ7JW.js → cleanup-PTJBMC34.js} +2 -2
  82. package/dist-cli/chunks/cli-version-6ZBD6GKP.js +4 -0
  83. package/dist-cli/chunks/{compat-E6KE5KDI.js → compat-FJ2HWRAG.js} +2 -2
  84. package/dist-cli/chunks/{config-QSLLVZDS.js → config-PYGH7JPN.js} +2 -2
  85. package/dist-cli/chunks/{connect-QE4VU6GB.js → connect-AJV4PZA4.js} +2 -2
  86. package/dist-cli/chunks/control-667PAYX4.js +4 -0
  87. package/dist-cli/chunks/daemon-EIBOHZYQ.js +4 -0
  88. package/dist-cli/chunks/{debug-5FG3MBJS.js → debug-XF2XUDHN.js} +3 -3
  89. package/dist-cli/chunks/{desktop-5EZX7NX5.js → desktop-HEZYNFYW.js} +3 -3
  90. package/dist-cli/chunks/{detox-WJGQP5TM.js → detox-JCFZJRNE.js} +2 -2
  91. package/dist-cli/chunks/{device-D7LKCAMS.js → device-KGUVZHFW.js} +2 -2
  92. package/dist-cli/chunks/{diagnose-A3W6ZTGA.js → diagnose-KVJQ3TOI.js} +2 -2
  93. package/dist-cli/chunks/{disk-cleanup-BDRHX5ZG.js → disk-cleanup-7HQXJFUX.js} +2 -2
  94. package/dist-cli/chunks/{do-chain-RUJNRPPF.js → do-chain-3FYJNA2N.js} +2 -2
  95. package/dist-cli/chunks/drivers-5S6M5TJB.js +4 -0
  96. package/dist-cli/chunks/{env-U67SQVTV.js → env-BX6S2SOM.js} +2 -2
  97. package/dist-cli/chunks/{film-XC6UAQ3V.js → film-2WZUVYFA.js} +3 -3
  98. package/dist-cli/chunks/flow-6BZSWNHG.js +4 -0
  99. package/dist-cli/chunks/help-OGJBD4NP.js +4 -0
  100. package/dist-cli/chunks/{hidden-runtime-alias-7S2TNZXG.js → hidden-runtime-alias-PGF55OFH.js} +2 -2
  101. package/dist-cli/chunks/{home-paths-LMO5LB4F.js → home-paths-EGRMTSGE.js} +2 -2
  102. package/dist-cli/chunks/inspect-MZVDGAN4.js +4 -0
  103. package/dist-cli/chunks/install-desktop-QB76DNZF.js +4 -0
  104. package/dist-cli/chunks/{login-JIU42S4C.js → login-HRFPYPZ3.js} +2 -2
  105. package/dist-cli/chunks/{logout-JTAWQXZO.js → logout-LRH5HZGP.js} +2 -2
  106. package/dist-cli/chunks/{maestro-TYBQYOGB.js → maestro-S53T4PPE.js} +3 -3
  107. package/dist-cli/chunks/{maestro-generate-YLAGWVKE.js → maestro-generate-YKIVKOB3.js} +3 -3
  108. package/dist-cli/chunks/{mode-DV24ZRZ6.js → mode-47JKNUNS.js} +2 -2
  109. package/dist-cli/chunks/{optional-demo-registry-JZH66VIM.js → optional-demo-registry-ARZSPS5Y.js} +2 -2
  110. package/dist-cli/chunks/{perf-LXXJ4EAX.js → perf-J73OKB4T.js} +2 -2
  111. package/dist-cli/chunks/{permissions-KAOVZUPW.js → permissions-L3FPI56H.js} +2 -2
  112. package/dist-cli/chunks/{platform-KW35446W.js → platform-YAROAHRM.js} +3 -3
  113. package/dist-cli/chunks/privacy-JZQCXHKN.js +4 -0
  114. package/dist-cli/chunks/{record-EK3ETFHZ.js → record-QAQMGYOO.js} +3 -3
  115. package/dist-cli/chunks/{report-issue-NW5PCAZF.js → report-issue-4FVZCAFQ.js} +2 -2
  116. package/dist-cli/chunks/reset-NYFUFZCG.js +4 -0
  117. package/dist-cli/chunks/resolve-target-SP22ZGFZ.js +4 -0
  118. package/dist-cli/chunks/runtime-WTS2QMXZ.js +4 -0
  119. package/dist-cli/chunks/{screenshot-command-TQSPTFT2.js → screenshot-command-Q5MGGEWY.js} +3 -3
  120. package/dist-cli/chunks/{screenshot-layers-IZP3L3AQ.js → screenshot-layers-4JW7DRL7.js} +2 -2
  121. package/dist-cli/chunks/{screenshots-capture-K7LJOVAE.js → screenshots-capture-X5PMO44E.js} +2 -2
  122. package/dist-cli/chunks/{serve-MMX4WXXG.js → serve-3JWJ45J3.js} +19 -19
  123. package/dist-cli/chunks/setup-USPDJESI.js +4 -0
  124. package/dist-cli/chunks/{sim-client-KLAYIFOC.js → sim-client-RL6DGOJE.js} +2 -2
  125. package/dist-cli/chunks/{skills-N4D3POIS.js → skills-QNO6GDJB.js} +2 -2
  126. package/dist-cli/chunks/{state-ES6TFQZF.js → state-OGUCSUC3.js} +2 -2
  127. package/dist-cli/chunks/{storage-52DR5V2C.js → storage-TG7KFQZ3.js} +2 -2
  128. package/dist-cli/chunks/store-ZYMQW2LN.js +4 -0
  129. package/dist-cli/chunks/telemetry-7CSLUUIW.js +4 -0
  130. package/dist-cli/chunks/{timeline-NRVNB5JR.js → timeline-R4G5P335.js} +2 -2
  131. package/dist-cli/chunks/{upgrade-LNED5YQ2.js → upgrade-RDTXJ6O3.js} +2 -2
  132. package/dist-cli/chunks/upload-PLF2DPMS.js +4 -0
  133. package/dist-cli/chunks/{version-VEF2DYGG.js → version-GU2NUZDS.js} +2 -2
  134. package/dist-cli/chunks/{web-CGDE6S73.js → web-7PUVG3GE.js} +2 -2
  135. package/dist-cli/chunks/welcome-4GGUV7FY.js +5 -0
  136. package/dist-cli/chunks/{what-happened-FSIDWL32.js → what-happened-OHAAX6F6.js} +2 -2
  137. package/dist-cli/chunks/{ws-bridge-QXFQLRE7.js → ws-bridge-Z3O35MNF.js} +2 -2
  138. package/dist-lib/agent-daemon-client.cjs +1 -1
  139. package/dist-lib/agent-events.cjs +1 -1
  140. package/dist-lib/agent-identity.cjs +1 -1
  141. package/dist-lib/agent-sessions.cjs +1 -1
  142. package/dist-lib/attached-projects.cjs +1 -1
  143. package/dist-lib/auth/shared-session.cjs +1 -1
  144. package/dist-lib/backend-origin.cjs +1 -1
  145. package/dist-lib/beta.cjs +1 -1
  146. package/dist-lib/beta.mjs +1 -1
  147. package/dist-lib/bridge-constants.cjs +1 -1
  148. package/dist-lib/bridge-contract-input.cjs +1 -1
  149. package/dist-lib/bridge-contract-input.mjs +1 -1
  150. package/dist-lib/bridge-contract.cjs +1 -1
  151. package/dist-lib/bridge-contract.mjs +1 -1
  152. package/dist-lib/cli-constants.cjs +1 -1
  153. package/dist-lib/config.cjs +1 -1
  154. package/dist-lib/detox/index.cjs +1 -1
  155. package/dist-lib/dev-bundle-resolution.cjs +69 -34
  156. package/dist-lib/home-paths.cjs +1 -1
  157. package/dist-lib/host/bridge-host.cjs +430 -106
  158. package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
  159. package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
  160. package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
  161. package/dist-lib/host/replacement-module-handler.cjs +1 -1
  162. package/dist-lib/host/websocket-proxy.cjs +1 -1
  163. package/dist-lib/index.cjs +1 -1
  164. package/dist-lib/jump-to-source-babel.cjs +1 -1
  165. package/dist-lib/menu.cjs +1 -1
  166. package/dist-lib/menu.mjs +1 -1
  167. package/dist-lib/metro-production-bundle.cjs +1 -1
  168. package/dist-lib/metro-production-bundle.mjs +1 -1
  169. package/dist-lib/metro.cjs +1 -1
  170. package/dist-lib/profiles.cjs +1 -1
  171. package/dist-lib/public-brand.cjs +1 -1
  172. package/dist-lib/react-native-host-modules.cjs +1 -1
  173. package/dist-lib/react-native-host-modules.mjs +1 -1
  174. package/dist-lib/render-mode.cjs +1 -1
  175. package/dist-lib/scripts/dev-server-scanner.cjs +149 -93
  176. package/dist-lib/sdk.cjs +1 -1
  177. package/dist-lib/sdk.mjs +1 -1
  178. package/dist-lib/skills.cjs +679 -307
  179. package/dist-lib/vite.cjs +1 -1
  180. package/package.json +1 -1
  181. package/scripts/dev-server-scanner.ts +275 -92
  182. package/src/dev-bundle-resolution.ts +123 -40
  183. package/src/host/bridge-host.ts +11 -0
  184. package/src/host/plane-host.ts +203 -0
  185. package/dist-cli/chunks/chunk-NTC46LSA.js +0 -4
  186. package/dist-cli/chunks/chunk-OTFTMGAL.js +0 -7
  187. package/dist-cli/chunks/cli-version-MLS55UTY.js +0 -4
  188. package/dist-cli/chunks/control-XD5F6K5T.js +0 -4
  189. package/dist-cli/chunks/daemon-UN5SE2JO.js +0 -4
  190. package/dist-cli/chunks/drivers-3NJ5RL6X.js +0 -4
  191. package/dist-cli/chunks/flow-FHZZD3P6.js +0 -4
  192. package/dist-cli/chunks/help-BYYDUSXF.js +0 -4
  193. package/dist-cli/chunks/inspect-RQCTWDCC.js +0 -4
  194. package/dist-cli/chunks/install-desktop-GOWXCQUE.js +0 -4
  195. package/dist-cli/chunks/privacy-MIFE72CG.js +0 -4
  196. package/dist-cli/chunks/reset-RZ4O5AFJ.js +0 -4
  197. package/dist-cli/chunks/resolve-target-7R5BNYMM.js +0 -4
  198. package/dist-cli/chunks/runtime-MZHLSRS6.js +0 -4
  199. package/dist-cli/chunks/setup-YFP3KEQZ.js +0 -4
  200. package/dist-cli/chunks/store-HOL45WCI.js +0 -4
  201. package/dist-cli/chunks/telemetry-BV6OTYPF.js +0 -4
  202. package/dist-cli/chunks/upload-RXKHFIBU.js +0 -4
  203. package/dist-cli/chunks/welcome-FT2AMUWW.js +0 -5
@@ -1,4 +1,4 @@
1
- // ProjectFilePlane over a real local checkout the second host behind the
1
+ // ProjectFilePlane over a real local checkout: the second host behind the
2
2
  // seam Contrast's browser already implements. writes land on the user's actual
3
3
  // disk, so nothing here converts content: the plane reads and writes bytes.
4
4
  //
@@ -9,13 +9,10 @@
9
9
  // 67 ms. `index()` is a synchronous whole-project map, so the size of that set
10
10
  // is the whole cost model.
11
11
 
12
- import { execFile } from 'node:child_process'
12
+ import { execFileSync } from 'node:child_process'
13
13
  import fs from 'node:fs'
14
14
  import path from 'node:path'
15
- import { promisify } from 'node:util'
16
- import type { ProjectFileMeta, ProjectFilePlane } from '@contrast/harness'
17
-
18
- const execFileAsync = promisify(execFile)
15
+ import type { ProjectFileMeta, ProjectFilePlane } from '@contrast/harness/plane'
19
16
 
20
17
  export class CheckoutRequiresGitError extends Error {}
21
18
 
@@ -29,6 +26,31 @@ export class CheckoutRequiresGitError extends Error {}
29
26
  // the fact has to be in the index where the filesystem layer can read it.
30
27
  const BINARY_SNIFF_BYTES = 8192
31
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
+
32
54
  function looksBinary(filePath: string, size: number): boolean {
33
55
  if (size === 0) return false
34
56
  let fd: number | undefined
@@ -62,9 +84,14 @@ export class CheckoutFilePlane implements ProjectFilePlane {
62
84
  readonly root: string
63
85
  private entries = new Map<string, ProjectFileMeta>()
64
86
  private sniffed = new Map<string, SniffedFile>()
87
+ // the deadline above, as a number a caller can set. what it changes is how
88
+ // long a wedged git is waited on, never whether it is waited on, so proving
89
+ // the kill does not have to cost the production deadline.
90
+ private readonly listTimeoutMs: number
65
91
 
66
- constructor(root: string) {
92
+ constructor(root: string, options: { listTimeoutMs?: number } = {}) {
67
93
  this.root = path.resolve(root)
94
+ this.listTimeoutMs = options.listTimeoutMs ?? LIST_FILES_TIMEOUT_MS
68
95
  }
69
96
 
70
97
  index(): ReadonlyMap<string, ProjectFileMeta> {
@@ -77,16 +104,40 @@ export class CheckoutFilePlane implements ProjectFilePlane {
77
104
  // -z so paths containing spaces or newlines survive; -co --exclude-standard
78
105
  // is tracked plus untracked-not-ignored, which is what a person means by
79
106
  // "the files in my project".
80
- const result = await execFileAsync(
81
- 'git',
82
- ['ls-files', '-co', '--exclude-standard', '-z'],
83
- { cwd: this.root, maxBuffer: 64 * 1024 * 1024 },
84
- )
85
- stdout = result.stdout
107
+ stdout = execFileSync('git', ['ls-files', '-co', '--exclude-standard', '-z'], {
108
+ cwd: this.root,
109
+ encoding: 'utf8',
110
+ maxBuffer: 64 * 1024 * 1024,
111
+ timeout: this.listTimeoutMs,
112
+ // stating the environment is not redundant here. bun 1.3.14 resolves
113
+ // the binary for the synchronous form against the PATH it captured at
114
+ // startup and ignores a later change to `process.env.PATH`, so without
115
+ // this it runs a different git than every other call in the process.
116
+ env: process.env,
117
+ })
86
118
  } catch (error) {
87
119
  const message = error instanceof Error ? error.message : String(error)
120
+ // the deadline kills with SIGTERM, so that signal is what tells a bound
121
+ // run apart from git failing on its own terms, and it is worth naming
122
+ // separately: "git was killed by SIGTERM" reads as git's problem, when
123
+ // what happened is that it never answered.
124
+ const killed =
125
+ error instanceof Error && 'signal' in error && error.signal === 'SIGTERM'
126
+ throw new CheckoutRequiresGitError(
127
+ killed
128
+ ? `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.`
129
+ : `could not list the project's files with git in ${this.root}: ${message}`,
130
+ )
131
+ }
132
+
133
+ // a busy machine also hands back a child that exits 0 having written
134
+ // nothing, which docs/testing.md owns: empty child output never means an
135
+ // empty answer. without this the box would build an empty index and every
136
+ // read would report the file as missing, which is a broken project rather
137
+ // than a broken box, and nothing would say so.
138
+ if (stdout.length === 0) {
88
139
  throw new CheckoutRequiresGitError(
89
- `could not list the project's files with git in ${this.root}: ${message}`,
140
+ `git listed no files in ${this.root}. either git tracks nothing there and nothing untracked is visible to it, or this machine could not read the child process's output.`,
90
141
  )
91
142
  }
92
143
 
@@ -0,0 +1,294 @@
1
+ // the client half of a cloud box: create it, seed this checkout into it, and
2
+ // run its shell over the box's own WebSocket.
3
+ //
4
+ // the box holds the files and the shell; this process holds neither. so the
5
+ // REPL here sends `shell:exec` and prints what comes back, and `cwd` is
6
+ // whatever the box last reported rather than anything tracked locally. that is
7
+ // the whole difference between the two placements, and it is why the same
8
+ // readline loop drives both.
9
+ //
10
+ // there is no default endpoint. a cloud box has to be deployed before one can
11
+ // exist, and pointing at a URL that answers nothing would advertise a
12
+ // capability that always fails.
13
+
14
+ import WebSocket from 'ws'
15
+ import type { CheckoutFilePlane } from './checkout-plane'
16
+ import type { HarnessShellResult } from '@contrast/harness/shell'
17
+
18
+ // a checkout is 15k files on this repo, so seeding is batched by BYTES rather
19
+ // than by count: one request carrying every file would be a single multi-
20
+ // hundred-megabyte body, and one request per file would be 15k round trips.
21
+ const SEED_BATCH_BYTES = 4 * 1024 * 1024
22
+ // base64 costs 4 bytes for every 3, and a request carries the encoded size.
23
+ const BASE64_OVERHEAD = 4 / 3
24
+
25
+ export interface CloudBoxSession {
26
+ readonly cwd: string
27
+ exec(command: string): Promise<HarnessShellResult>
28
+ close(): void
29
+ }
30
+
31
+ export class CloudBoxError extends Error {}
32
+
33
+ function endpointOf(base: string, name: string, verb: string): string {
34
+ return `${base.replace(/\/+$/, '')}/v1/boxes/${encodeURIComponent(name)}/${verb}`
35
+ }
36
+
37
+ async function boxFetch(
38
+ base: string,
39
+ name: string,
40
+ verb: string,
41
+ token: string | null,
42
+ body?: unknown,
43
+ ): Promise<Record<string, unknown>> {
44
+ const headers: Record<string, string> = { 'content-type': 'application/json' }
45
+ if (token) headers.authorization = `Bearer ${token}`
46
+ let response: Response
47
+ try {
48
+ response = await fetch(endpointOf(base, name, verb), {
49
+ method: body === undefined ? 'GET' : 'POST',
50
+ headers,
51
+ body: body === undefined ? undefined : JSON.stringify(body),
52
+ })
53
+ } catch (error) {
54
+ throw new CloudBoxError(
55
+ `could not reach the box service at ${base}: ${
56
+ error instanceof Error ? error.message : String(error)
57
+ }`,
58
+ )
59
+ }
60
+ const text = await response.text()
61
+ const parsed = text ? JSON.parse(text) : {}
62
+ if (!response.ok) {
63
+ throw new CloudBoxError(
64
+ String(parsed.error ?? `box ${verb} failed (${response.status})`),
65
+ )
66
+ }
67
+ return parsed
68
+ }
69
+
70
+ /**
71
+ * create the box and copy this checkout into it.
72
+ *
73
+ * `apiKey` is the ACCOUNT's key, which the box service forwards to
74
+ * /v1/rnx/sessions to authenticate the caller and start the session that pays
75
+ * for the box. the box's own token comes back minted by the box; a caller does
76
+ * not choose it.
77
+ */
78
+ export async function createCloudBox(options: {
79
+ endpoint: string
80
+ name: string
81
+ apiKey: string
82
+ plane: CheckoutFilePlane
83
+ onProgress?: (seeded: number, total: number) => void
84
+ }): Promise<{ files: number; token: string; sessionId: string }> {
85
+ const { endpoint, name, apiKey, plane } = options
86
+ const created = await boxFetch(endpoint, name, 'create', apiKey, { name })
87
+ const token = String(created.token ?? '')
88
+ if (!token) throw new CloudBoxError('the box service returned no access token')
89
+
90
+ const paths = [...plane.index().keys()].sort()
91
+ let batch: [string, string][] = []
92
+ let batchBytes = 0
93
+ let seeded = 0
94
+
95
+ const flush = async () => {
96
+ if (batch.length === 0) return
97
+ await boxFetch(endpoint, name, 'seed', token, { files: batch })
98
+ seeded += batch.length
99
+ options.onProgress?.(seeded, paths.length)
100
+ batch = []
101
+ batchBytes = 0
102
+ }
103
+
104
+ for (const path of paths) {
105
+ const bytes = await plane.readBytes(path)
106
+ batch.push([path, Buffer.from(bytes).toString('base64')])
107
+ batchBytes += bytes.byteLength * BASE64_OVERHEAD
108
+ if (batchBytes >= SEED_BATCH_BYTES) await flush()
109
+ }
110
+ await flush()
111
+
112
+ const status = await boxFetch(endpoint, name, 'status', token)
113
+ return {
114
+ files: Number(status.files ?? 0),
115
+ token,
116
+ sessionId: String(status.sessionId ?? ''),
117
+ }
118
+ }
119
+
120
+ /**
121
+ * stop a box, or stop and wipe it.
122
+ *
123
+ * takes the ACCOUNT key, not the box token: it ends the session the account is
124
+ * billed for, which the box deliberately cannot do on its own.
125
+ */
126
+ export async function stopCloudBox(options: {
127
+ endpoint: string
128
+ name: string
129
+ apiKey: string
130
+ purge?: boolean
131
+ }): Promise<Record<string, unknown>> {
132
+ return boxFetch(
133
+ options.endpoint,
134
+ options.name,
135
+ options.purge ? 'delete' : 'stop',
136
+ options.apiKey,
137
+ {},
138
+ )
139
+ }
140
+
141
+ /**
142
+ * the account's running boxes, from the session meter rather than from the box
143
+ * service. the meter already knows what this account is running and what it
144
+ * cost, so a second per-account index of boxes would be a second answer to the
145
+ * same question, free to drift.
146
+ */
147
+ export async function listCloudBoxes(options: {
148
+ apiOrigin: string
149
+ apiKey: string
150
+ }): Promise<{ id: string; name: string | null; startedAt: string; meteredMs: number }[]> {
151
+ const response = await fetch(
152
+ `${options.apiOrigin.replace(/\/+$/, '')}/v1/rnx/sessions`,
153
+ {
154
+ headers: { authorization: `Bearer ${options.apiKey}` },
155
+ },
156
+ )
157
+ const text = await response.text()
158
+ const parsed = text ? JSON.parse(text) : {}
159
+ if (!response.ok) {
160
+ throw new CloudBoxError(
161
+ String(parsed.message ?? parsed.error ?? 'could not list boxes'),
162
+ )
163
+ }
164
+ const sessions = Array.isArray(parsed.sessions) ? parsed.sessions : []
165
+ return sessions
166
+ .filter((session: { kind?: string }) => session.kind === 'box')
167
+ .map((session: Record<string, unknown>) => ({
168
+ id: String(session.id),
169
+ name: typeof session.name === 'string' ? session.name : null,
170
+ startedAt: String(session.startedAt ?? ''),
171
+ meteredMs: Number(session.meteredMs ?? 0),
172
+ }))
173
+ }
174
+
175
+ /**
176
+ * bank the wall clock while a box is open.
177
+ *
178
+ * the box holds no account credential and so cannot heartbeat for itself; the
179
+ * client that is actually using it does. when this process dies the heartbeats
180
+ * stop and the site's stale sweep closes the session at the last one, which is
181
+ * why there is no second idle timer in the box.
182
+ */
183
+ export function heartbeatSession(options: {
184
+ apiOrigin: string
185
+ apiKey: string
186
+ sessionId: string
187
+ everyMs?: number
188
+ }): { stop: () => void } {
189
+ const url = `${options.apiOrigin.replace(/\/+$/, '')}/v1/rnx/sessions/${encodeURIComponent(
190
+ options.sessionId,
191
+ )}`
192
+ const timer = setInterval(() => {
193
+ void fetch(url, {
194
+ method: 'POST',
195
+ headers: { authorization: `Bearer ${options.apiKey}` },
196
+ }).catch(() => {
197
+ // a missed heartbeat is not an error worth interrupting a shell for.
198
+ // the sweep closes the session at the last one that landed.
199
+ })
200
+ }, options.everyMs ?? 60_000)
201
+ // do not hold the process open just to keep billing alive
202
+ timer.unref?.()
203
+ return { stop: () => clearInterval(timer) }
204
+ }
205
+
206
+ /** open the box's socket and return a session the REPL can drive. */
207
+ export async function connectCloudBox(options: {
208
+ endpoint: string
209
+ name: string
210
+ token: string
211
+ }): Promise<CloudBoxSession> {
212
+ const { endpoint, name, token } = options
213
+ const url = endpointOf(endpoint, name, 'connect').replace(/^http/, 'ws')
214
+ // as a subprotocol, not a header. a browser cannot set a header on a
215
+ // WebSocket, and the box takes the token one way rather than one way per
216
+ // kind of caller.
217
+ const socket = new WebSocket(url, [`rnx-box-token.${token}`])
218
+
219
+ // a persistent handler, installed before the handshake can fail. ws emits
220
+ // 'error' again after a refused upgrade, and a `once` listener consumed by
221
+ // the rejection leaves that second emit unhandled, which crashes the
222
+ // process rather than failing the command.
223
+ let socketError: string | null = null
224
+ socket.on('error', (error) => {
225
+ socketError = error.message
226
+ })
227
+ await new Promise<void>((resolve, reject) => {
228
+ socket.once('open', resolve)
229
+ socket.once('error', (error) => {
230
+ socket.close()
231
+ reject(error)
232
+ })
233
+ }).catch(async (error: unknown) => {
234
+ // ws's handshake error text is runtime-specific: node says "Unexpected
235
+ // server response: 401" and bun says "Expected 101 status code" with no
236
+ // status at all. so ask the service itself why, over plain HTTP, and
237
+ // report THAT. this runs only on the failure path.
238
+ const reason = await boxFetch(endpoint, name, 'status', token).then(
239
+ () => null,
240
+ (failure: unknown) =>
241
+ failure instanceof Error ? failure.message : String(failure),
242
+ )
243
+ const fallback = error instanceof Error ? error.message : String(error)
244
+ throw new CloudBoxError(`could not open the box shell: ${reason ?? fallback}`)
245
+ })
246
+
247
+ let nextId = 0
248
+ const waiting = new Map<number, (reply: Record<string, unknown>) => void>()
249
+ socket.on('message', (data) => {
250
+ let parsed: unknown
251
+ try {
252
+ parsed = JSON.parse(String(data))
253
+ } catch {
254
+ return
255
+ }
256
+ if (typeof parsed !== 'object' || parsed === null) return
257
+ const reply = parsed as Record<string, unknown>
258
+ const resolve = waiting.get(Number(reply.id))
259
+ if (!resolve) return
260
+ waiting.delete(Number(reply.id))
261
+ resolve(reply)
262
+ })
263
+
264
+ // the socket dying mid-command must fail the command rather than hang the
265
+ // prompt forever, so every waiter is settled when it closes.
266
+ let closedReason: string | null = null
267
+ const settleAll = (reason: string) => {
268
+ closedReason = reason
269
+ for (const [id, resolve] of waiting) resolve({ id, error: reason })
270
+ waiting.clear()
271
+ }
272
+ socket.on('close', () => settleAll(socketError ?? 'the box closed the connection'))
273
+ socket.on('error', (error) => settleAll(error.message))
274
+
275
+ let cwd = '/project'
276
+ return {
277
+ get cwd() {
278
+ return cwd
279
+ },
280
+ async exec(command: string): Promise<HarnessShellResult> {
281
+ if (closedReason) throw new CloudBoxError(closedReason)
282
+ const id = nextId++
283
+ const reply = await new Promise<Record<string, unknown>>((resolve) => {
284
+ waiting.set(id, resolve)
285
+ socket.send(JSON.stringify({ type: 'shell:exec', id, command }))
286
+ })
287
+ if (reply.error) throw new CloudBoxError(String(reply.error))
288
+ const result = reply.result as HarnessShellResult
289
+ if (result.cwd) cwd = result.cwd
290
+ return result
291
+ },
292
+ close: () => socket.close(),
293
+ }
294
+ }
@@ -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 SOOTSIM_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
+ }
@@ -8,8 +8,8 @@
8
8
 
9
9
  import { spawn } from 'node:child_process'
10
10
  import path from 'node:path'
11
- import { defineCommand, type CustomCommand } from '@contrast/harness'
12
- import { PROJECT_MOUNT } from '@contrast/harness'
11
+ import { defineCommand, type CustomCommand } from '@contrast/harness/shell'
12
+ import { PROJECT_MOUNT } from '@contrast/harness/shell'
13
13
  import { rnxPublicBrand } from '../../../src/public-brand'
14
14
  import { RNX_PLACEMENT_ENV } from '../../ws-bridge'
15
15