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,806 @@
1
+ // canonical filesystem layout under ~/.rnx/. shared by the CLI and the
2
+ // electron main process so every surface agrees where runtimes, the daemon
3
+ // lockfile, and caches live.
4
+ //
5
+ // ~/.rnx/
6
+ // ├── runtimes/
7
+ // │ ├── <version>/ unpacked dist/ of sootsim-engine
8
+ // │ └── active text file with the active version string (win-safe
9
+ // │ alternative to a symlink)
10
+ // ├── electron/
11
+ // │ └── <version>/ pinned electron binary (future: playwright-style)
12
+ // ├── profiles/
13
+ // │ └── profiles.json storage profile metadata
14
+ // ├── cache/
15
+ // │ └── rnx-runtime-<version>.tar.gz
16
+ // ├── daemon.json lockfile: pid, ports, active runtime, heartbeat
17
+ // ├── dev-bridges/ live Vite dev bridge records, keyed by bound port
18
+ // ├── runtime-upgraded.json one-shot notice written by the daemon's
19
+ // │ background updater, consumed (printed + deleted)
20
+ // │ by the next interactive CLI run
21
+ // ├── cli-update.json cached standalone CLI release check + notice state
22
+ // ├── automatic-cleanup-v1.json completion marker for the one-time safe
23
+ // │ cleanup introduced for legacy unbounded data
24
+ // └── config.json user prefs: update channel, cdn origin override,
25
+ // telemetry, engine settings, default box
26
+
27
+ import { randomUUID } from 'node:crypto'
28
+ import fs from 'node:fs'
29
+ import { homedir } from 'node:os'
30
+ import path from 'node:path'
31
+
32
+ export const RNX_HOME_ENV = 'RNX_HOME'
33
+ export const ACTIVE_RUNTIME_FILE = 'active'
34
+ export const DAEMON_LOCKFILE = 'daemon.json'
35
+ export const DEV_BRIDGES_DIR = 'dev-bridges'
36
+ export const CONFIG_FILE = 'config.json'
37
+ export const CLI_UPDATE_FILE = 'cli-update.json'
38
+ export const DAEMON_HEARTBEAT_STALE_MS = 30_000
39
+
40
+ export function rnxHomeDir(): string {
41
+ const override = process.env[RNX_HOME_ENV]
42
+ if (override && override.length > 0) return path.resolve(override)
43
+ return path.join(homedir(), '.rnx')
44
+ }
45
+
46
+ // detect when sootsim is running from a source checkout (the Contrast monorepo)
47
+ // rather than a published npm install. used to skip auto-install of the
48
+ // persistent launchd / systemd agent: dev shells shouldn't register an agent
49
+ // whose Program path points at workspace artifacts that change between
50
+ // sessions, and whose served engine assets are the stale prod build instead
51
+ // of the live `bun dev:sootsim` output.
52
+ //
53
+ // overrides:
54
+ // SOOTSIM_DEV=1 / SOOTSIM_DEV=0 force the answer
55
+ // SOOTSIM_FORCE_DAEMON_INSTALL=1 pretend prod even from a dev
56
+ // checkout, for exercising the
57
+ // install path from this repo
58
+ //
59
+ // signal: realpath of process.argv[1] lands inside a `packages/sootsim/`
60
+ // directory. workspace bin symlinks (`node_modules/.bin/sootsim` →
61
+ // `packages/sootsim/dist-cli/bin.js`) and bun-direct invocations
62
+ // (`bun packages/sootsim/cli/bin.ts ...`) both match; published installs
63
+ // resolve under `node_modules/sootsim/` instead.
64
+ export function isSootsimDevCheckout(): boolean {
65
+ if (process.env.SOOTSIM_FORCE_DAEMON_INSTALL === '1') return false
66
+ const env = process.env.SOOTSIM_DEV
67
+ if (env === '1' || env === 'true') return true
68
+ if (env === '0' || env === 'false') return false
69
+ const argv1 = process.argv[1]
70
+ if (!argv1) return false
71
+ try {
72
+ const real = fs.realpathSync(argv1)
73
+ return real.includes(`${path.sep}packages${path.sep}sootsim${path.sep}`)
74
+ } catch {
75
+ return false
76
+ }
77
+ }
78
+
79
+ // detect the human's dev workstation by the env markers the developer sets in
80
+ // their shell profile (`IS_TAMAGUI_DEV=1` in ~/.zshrc). distinct from
81
+ // isSootsimDevCheckout, which keys on the *binary path*: a globally-installed
82
+ // `sootsim` binary on the dev machine would NOT match the checkout path, yet a
83
+ // persistent daemon registered there is just as dangerous. it runs in the
84
+ // background carrying the workstation's dev env (dev auth tokens, prod-pointing
85
+ // vars), and when the live `bun dev` stack already owns the bridge port it
86
+ // silently lands on a fallback port and shadows the real one with stale assets
87
+ // — the "I was on the daemon on port 7-something" footgun.
88
+ //
89
+ // override: SOOTSIM_FORCE_DAEMON_INSTALL=1 to run the daemon here anyway.
90
+ export function isDevWorkstation(): boolean {
91
+ if (process.env.SOOTSIM_FORCE_DAEMON_INSTALL === '1') return false
92
+ return process.env.IS_TAMAGUI_DEV === '1'
93
+ }
94
+
95
+ // the persistent launchd / systemd daemon must never auto-install or run on a
96
+ // dev checkout or a dev workstation. either makes the background agent point at
97
+ // moving workspace artifacts / dev env and serve stale assets that shadow the
98
+ // live dev stack. setup and the launchd-spawned `rnx serve` boot consult
99
+ // this; a foreground `rnx serve` the dev starts
100
+ // by hand is intentional and stays allowed.
101
+ export function shouldSkipPersistentDaemon(): boolean {
102
+ return isSootsimDevCheckout() || isDevWorkstation()
103
+ }
104
+
105
+ export function runtimesDir(): string {
106
+ return path.join(rnxHomeDir(), 'runtimes')
107
+ }
108
+
109
+ export function runtimeDir(version: string): string {
110
+ return path.join(runtimesDir(), version)
111
+ }
112
+
113
+ export function activeRuntimeFile(): string {
114
+ return path.join(runtimesDir(), ACTIVE_RUNTIME_FILE)
115
+ }
116
+
117
+ export function electronDir(): string {
118
+ return path.join(rnxHomeDir(), 'electron')
119
+ }
120
+
121
+ export function electronUserDataDir(): string {
122
+ return path.join(electronDir(), 'userData')
123
+ }
124
+
125
+ export function electronVersionDir(version: string): string {
126
+ return path.join(electronDir(), version)
127
+ }
128
+
129
+ export function profilesDir(): string {
130
+ return path.join(rnxHomeDir(), 'profiles')
131
+ }
132
+
133
+ // video fixtures staged for `rnx camera play`. the CLI copies the user's
134
+ // file in here and the daemon serves it by basename from this one directory,
135
+ // so the route can never be talked into reading anywhere else on disk.
136
+ export function cameraFixturesDir(): string {
137
+ return path.join(rnxHomeDir(), 'camera-fixtures')
138
+ }
139
+
140
+ // the launchd-managed daemon spawns ProgramArguments[0] directly, and macOS
141
+ // Background Task Management attributes the entry to whoever code-signed
142
+ // that binary. pointing launchd at bun directly makes Login Items say
143
+ // "software from Jarred Sumner" (bun's signer); wrapping the invocation in
144
+ // an ad-hoc-signed .app bundle here gives BTM a CFBundleDisplayName to
145
+ // read instead.
146
+ export function daemonAppDir(): string {
147
+ return path.join(rnxHomeDir(), 'daemon-app')
148
+ }
149
+
150
+ export function daemonAppBundlePath(): string {
151
+ return path.join(daemonAppDir(), 'RNX Daemon.app')
152
+ }
153
+
154
+ export function daemonAppLauncherPath(): string {
155
+ return path.join(daemonAppBundlePath(), 'Contents', 'MacOS', 'rnx-daemon')
156
+ }
157
+
158
+ export function cacheDir(): string {
159
+ return path.join(rnxHomeDir(), 'cache')
160
+ }
161
+
162
+ export function daemonLockfilePath(): string {
163
+ return path.join(rnxHomeDir(), DAEMON_LOCKFILE)
164
+ }
165
+
166
+ export function devBridgesDir(): string {
167
+ return path.join(rnxHomeDir(), DEV_BRIDGES_DIR)
168
+ }
169
+
170
+ export function devBridgeLockfilePath(bridgePort: number): string {
171
+ if (!Number.isInteger(bridgePort) || bridgePort <= 0) {
172
+ throw new Error(`invalid development bridge port: ${bridgePort}`)
173
+ }
174
+ return path.join(devBridgesDir(), `${bridgePort}.json`)
175
+ }
176
+
177
+ export function configFilePath(): string {
178
+ return path.join(rnxHomeDir(), CONFIG_FILE)
179
+ }
180
+
181
+ export function cliUpdateFilePath(): string {
182
+ return path.join(rnxHomeDir(), CLI_UPDATE_FILE)
183
+ }
184
+
185
+ // --- shared rnx config ---------------------------------------------------
186
+ //
187
+ // ~/.rnx/config.json is the single user-level config file every rnx
188
+ // surface (cli, electron renderer, dev browser via electron ipc) agrees on.
189
+ //
190
+ // privacy preferences live in the nested settings object so the cli, desktop
191
+ // main process, renderer, daemon-served browser, and settings UI all read and
192
+ // write one persisted value per choice.
193
+
194
+ export interface SharedConfig {
195
+ telemetry?: boolean
196
+ onboardingVersion?: number
197
+ simulatorDriver?: 'electron' | 'playwright'
198
+ settings?: Record<string, unknown>
199
+ [key: string]: unknown
200
+ }
201
+
202
+ export function readSharedConfig(): SharedConfig {
203
+ try {
204
+ const raw = fs.readFileSync(configFilePath(), 'utf8')
205
+ const parsed = JSON.parse(raw) as SharedConfig
206
+ return parsed && typeof parsed === 'object' ? parsed : {}
207
+ } catch {
208
+ return {}
209
+ }
210
+ }
211
+
212
+ /** merge `patch` into the shared config and atomically write to disk. nested
213
+ * objects (currently just `settings`) are shallow-merged so partial writes
214
+ * don't clobber unrelated fields. returns the new full snapshot. */
215
+ export function writeSharedConfig(patch: Partial<SharedConfig>): SharedConfig {
216
+ ensureRnxHome()
217
+ const current = readSharedConfig()
218
+ const next: SharedConfig = { ...current, ...patch }
219
+ if (patch.settings && typeof patch.settings === 'object') {
220
+ next.settings = {
221
+ ...(current.settings && typeof current.settings === 'object'
222
+ ? current.settings
223
+ : {}),
224
+ ...patch.settings,
225
+ }
226
+ }
227
+ const tmp = `${configFilePath()}.tmp`
228
+ fs.writeFileSync(tmp, `${JSON.stringify(next, null, 2)}\n`, 'utf8')
229
+ fs.renameSync(tmp, configFilePath())
230
+ return next
231
+ }
232
+
233
+ const CURRENT_ONBOARDING_VERSION = 1
234
+
235
+ export type SimulatorDriverPreference = 'electron' | 'playwright'
236
+
237
+ export function isOnboardingComplete(): boolean {
238
+ return readSharedConfig().onboardingVersion === CURRENT_ONBOARDING_VERSION
239
+ }
240
+
241
+ export function readSimulatorDriverPreference(): SimulatorDriverPreference | null {
242
+ const driver = readSharedConfig().simulatorDriver
243
+ return driver === 'electron' || driver === 'playwright' ? driver : null
244
+ }
245
+
246
+ export function writeOnboardingPreferences(driver: SimulatorDriverPreference): void {
247
+ writeSharedConfig({
248
+ onboardingVersion: CURRENT_ONBOARDING_VERSION,
249
+ simulatorDriver: driver,
250
+ })
251
+ }
252
+
253
+ export interface PrivacyPreferences {
254
+ productAnalytics: boolean
255
+ crashReports: boolean
256
+ configured: boolean
257
+ }
258
+
259
+ export function readPrivacyPreferences(): PrivacyPreferences {
260
+ const settings = readSharedConfig().settings
261
+ const productAnalytics = settings?.productAnalytics
262
+ const crashReports = settings?.crashReports
263
+ return {
264
+ productAnalytics: typeof productAnalytics === 'boolean' ? productAnalytics : false,
265
+ crashReports: typeof crashReports === 'boolean' ? crashReports : false,
266
+ configured:
267
+ typeof productAnalytics === 'boolean' && typeof crashReports === 'boolean',
268
+ }
269
+ }
270
+
271
+ export function writePrivacyPreferences(
272
+ preferences: Pick<PrivacyPreferences, 'productAnalytics' | 'crashReports'>,
273
+ ): void {
274
+ writeSharedConfig({
275
+ settings: {
276
+ productAnalytics: preferences.productAnalytics,
277
+ crashReports: preferences.crashReports,
278
+ },
279
+ })
280
+ }
281
+
282
+ // stable per-machine anonymous telemetry id. every signed-out cli used to
283
+ // report as the single literal distinct_id 'anonymous-cli', which merged all
284
+ // first-run users into one posthog person and made "a new user's login hung"
285
+ // impossible to see. created lazily on first use, persisted in config.json.
286
+ export function readOrCreateAnonymousId(): string {
287
+ const existing = readSharedConfig().anonymousId
288
+ if (typeof existing === 'string' && existing.trim()) return existing
289
+ const created = `anon-cli-${randomUUID()}`
290
+ writeSharedConfig({ anonymousId: created })
291
+ return created
292
+ }
293
+
294
+ // --- default box ----------------------------------------------------------
295
+ //
296
+ // the box every box verb acts on when the command line names none. `rnx box
297
+ // use` writes it and `rnx box stop` clears it when it stops that box.
298
+ //
299
+ // the id and the display name, and nothing else. a Box is reached with a
300
+ // bearer token the account service hands out per attach, so writing one here
301
+ // would make this file a credential store; every verb resolves the token
302
+ // again from the id it reads back.
303
+
304
+ export interface DefaultBox {
305
+ boxId: string
306
+ name: string | null
307
+ }
308
+
309
+ export function readDefaultBox(): DefaultBox | null {
310
+ const stored = readSharedConfig().defaultBox
311
+ if (typeof stored !== 'object' || stored === null) return null
312
+ if (!('boxId' in stored) || typeof stored.boxId !== 'string' || !stored.boxId) {
313
+ return null
314
+ }
315
+ const name = 'name' in stored && typeof stored.name === 'string' ? stored.name : null
316
+ return { boxId: stored.boxId, name }
317
+ }
318
+
319
+ /** set the default box, or clear it with null. */
320
+ export function writeDefaultBox(box: DefaultBox | null): void {
321
+ writeSharedConfig({ defaultBox: box })
322
+ }
323
+
324
+ // --- background-upgrade notice --------------------------------------------
325
+ //
326
+ // the daemon auto-updates the engine runtime in the background (hourly), so
327
+ // the only place its "updated to vX" line lands is the daemon's own stderr,
328
+ // which nobody reads. it also drops a one-shot notice file here; the next
329
+ // interactive CLI invocation prints it once and deletes it, so users learn
330
+ // their engine moved without having to run `rnx version`.
331
+
332
+ export interface RuntimeUpgradeNotice {
333
+ from: string | null
334
+ to: string
335
+ at: number
336
+ }
337
+
338
+ export function runtimeUpgradeNoticePath(): string {
339
+ return path.join(rnxHomeDir(), 'runtime-upgraded.json')
340
+ }
341
+
342
+ export function writeRuntimeUpgradeNotice(notice: RuntimeUpgradeNotice): void {
343
+ ensureRnxHome()
344
+ const tmp = `${runtimeUpgradeNoticePath()}.tmp`
345
+ fs.writeFileSync(tmp, `${JSON.stringify(notice)}\n`, 'utf8')
346
+ fs.renameSync(tmp, runtimeUpgradeNoticePath())
347
+ }
348
+
349
+ /** read + delete the pending upgrade notice, or null when none is pending.
350
+ * deleting first-read keeps the banner one-shot even across racing CLIs. */
351
+ export function consumeRuntimeUpgradeNotice(): RuntimeUpgradeNotice | null {
352
+ try {
353
+ const raw = fs.readFileSync(runtimeUpgradeNoticePath(), 'utf8')
354
+ fs.unlinkSync(runtimeUpgradeNoticePath())
355
+ const parsed = JSON.parse(raw) as Partial<RuntimeUpgradeNotice>
356
+ if (typeof parsed?.to !== 'string' || !parsed.to) return null
357
+ return {
358
+ from: typeof parsed.from === 'string' ? parsed.from : null,
359
+ to: parsed.to,
360
+ at: typeof parsed.at === 'number' ? parsed.at : 0,
361
+ }
362
+ } catch {
363
+ return null
364
+ }
365
+ }
366
+
367
+ export function ensureRnxHome(): void {
368
+ fs.mkdirSync(rnxHomeDir(), { recursive: true })
369
+ fs.mkdirSync(runtimesDir(), { recursive: true })
370
+ fs.mkdirSync(electronDir(), { recursive: true })
371
+ fs.mkdirSync(profilesDir(), { recursive: true })
372
+ fs.mkdirSync(cacheDir(), { recursive: true })
373
+ }
374
+
375
+ /** read the active runtime version string, or null if none is selected. */
376
+ export function readActiveRuntime(): string | null {
377
+ try {
378
+ const value = fs.readFileSync(activeRuntimeFile(), 'utf8').trim()
379
+ return value.length > 0 ? value : null
380
+ } catch {
381
+ return null
382
+ }
383
+ }
384
+
385
+ /** set the active runtime version. caller is responsible for verifying the
386
+ * version actually exists on disk before calling. */
387
+ export function writeActiveRuntime(version: string): void {
388
+ fs.mkdirSync(runtimesDir(), { recursive: true })
389
+ fs.writeFileSync(activeRuntimeFile(), `${version}\n`, 'utf8')
390
+ }
391
+
392
+ // listInstalledRuntimes + compareSemver live in @contrast/runtime-delivery:
393
+ // they are delivery machinery (sorting the hosted catalog, deciding whether
394
+ // channel latest is newer than active), shared with contrast, and this module
395
+ // stays free of that dependency so every published `sootsim/*` export that
396
+ // resolves through it keeps working with no bundling requirement.
397
+
398
+ /** absolute path to the active runtime's directory, or null if none active
399
+ * or the active version is no longer installed. */
400
+ export function activeRuntimeDir(): string | null {
401
+ const version = readActiveRuntime()
402
+ if (!version) return null
403
+ const dir = runtimeDir(version)
404
+ try {
405
+ if (fs.statSync(dir).isDirectory()) return dir
406
+ } catch {}
407
+ return null
408
+ }
409
+
410
+ // --- daemon lockfile ----------------------------------------------------
411
+
412
+ export interface DaemonLockfile {
413
+ /** sootsim cli/daemon version that wrote the lockfile. bumped whenever
414
+ * the lockfile shape changes so readers can gate on it. */
415
+ schema: 1
416
+ pid: number
417
+ /** platform — useful when one home dir is shared across platforms via NFS. */
418
+ platform: NodeJS.Platform
419
+ /** ws bridge port (where cli + electron open control connections). */
420
+ bridgePort: number
421
+ /** http runtime server port (where electron loads the renderer from). */
422
+ runtimePort: number
423
+ /** active runtime version at boot, or null if the daemon booted with no
424
+ * runtime installed. updated live when the user runs `sootsim runtime use`. */
425
+ activeRuntime: string | null
426
+ /** absolute path to the active runtime's dist directory, or null. */
427
+ activeRuntimeDir: string | null
428
+ /** repo-selected runtime versions currently served to connected sims. */
429
+ servedRuntimes?: string[]
430
+ /** epoch-ms of daemon start. */
431
+ startedAt: number
432
+ /** epoch-ms of last heartbeat. daemons update this every ~5s; readers
433
+ * treat the lockfile as stale if now - heartbeatAt > DAEMON_HEARTBEAT_STALE_MS. */
434
+ heartbeatAt: number
435
+ /** true while the daemon is still fetching/activating its runtime on first
436
+ * boot. clients (electron splash, cli) should wait for this to become
437
+ * false before treating the daemon as ready to serve. */
438
+ bootstrapping?: boolean
439
+ }
440
+
441
+ export interface DevBridgeLockfile {
442
+ schema: 1
443
+ pid: number
444
+ platform: NodeJS.Platform
445
+ bridgePort: number
446
+ runtimePort: number
447
+ /** http port of the vite shell dev server that owns this bridge (e.g. 5173).
448
+ * optional: lockfiles written before this field existed still parse, and
449
+ * readers fall back to the default shell URL. this is what lets the CLI
450
+ * resolve the shell base URL and the bridge port from the SAME world
451
+ * instead of mixing dev bridge + daemon runtime (the split-brain bug). */
452
+ shellPort?: number
453
+ cwd: string
454
+ startedAt: number
455
+ heartbeatAt: number
456
+ source: 'vite-dev'
457
+ /** repo-selected runtime versions currently served to connected sims. */
458
+ servedRuntimes?: string[]
459
+ }
460
+
461
+ const DAEMON_LOCKFILE_MAX_BYTES = 16 * 1024
462
+ const DAEMON_CLAIM_STALE_MS = 30_000
463
+
464
+ interface DaemonClaim {
465
+ pid: number
466
+ startedAt: number
467
+ createdAt: number
468
+ }
469
+
470
+ export function readDaemonLockfile(): DaemonLockfile | null {
471
+ try {
472
+ // cap the read so a junk/large file on the lockfile path can't OOM
473
+ // the CLI when someone has been messing with ~/.rnx/.
474
+ const fd = fs.openSync(daemonLockfilePath(), 'r')
475
+ try {
476
+ const buf = Buffer.alloc(DAEMON_LOCKFILE_MAX_BYTES)
477
+ const bytesRead = fs.readSync(fd, buf, 0, DAEMON_LOCKFILE_MAX_BYTES, 0)
478
+ const raw = buf.subarray(0, bytesRead).toString('utf8')
479
+ const parsed = JSON.parse(raw) as Partial<DaemonLockfile>
480
+ if (
481
+ parsed &&
482
+ parsed.schema === 1 &&
483
+ typeof parsed.pid === 'number' &&
484
+ typeof parsed.bridgePort === 'number' &&
485
+ typeof parsed.runtimePort === 'number' &&
486
+ typeof parsed.startedAt === 'number' &&
487
+ typeof parsed.heartbeatAt === 'number' &&
488
+ (parsed.servedRuntimes === undefined ||
489
+ (Array.isArray(parsed.servedRuntimes) &&
490
+ parsed.servedRuntimes.every((version) => typeof version === 'string')))
491
+ ) {
492
+ return parsed as DaemonLockfile
493
+ }
494
+ return null
495
+ } finally {
496
+ fs.closeSync(fd)
497
+ }
498
+ } catch {
499
+ return null
500
+ }
501
+ }
502
+
503
+ function parseDevBridgeLockfile(raw: string): DevBridgeLockfile | null {
504
+ try {
505
+ const parsed = JSON.parse(raw)
506
+ if (
507
+ parsed &&
508
+ parsed.schema === 1 &&
509
+ parsed.source === 'vite-dev' &&
510
+ typeof parsed.pid === 'number' &&
511
+ typeof parsed.bridgePort === 'number' &&
512
+ typeof parsed.runtimePort === 'number' &&
513
+ typeof parsed.cwd === 'string' &&
514
+ typeof parsed.startedAt === 'number' &&
515
+ typeof parsed.heartbeatAt === 'number' &&
516
+ (parsed.shellPort === undefined || typeof parsed.shellPort === 'number') &&
517
+ (parsed.servedRuntimes === undefined ||
518
+ (Array.isArray(parsed.servedRuntimes) &&
519
+ parsed.servedRuntimes.every((version: unknown) => typeof version === 'string')))
520
+ ) {
521
+ return parsed
522
+ }
523
+ return null
524
+ } catch {
525
+ return null
526
+ }
527
+ }
528
+
529
+ export function readDevBridgeLockfiles(): DevBridgeLockfile[] {
530
+ let entries: fs.Dirent[]
531
+ try {
532
+ entries = fs.readdirSync(devBridgesDir(), { withFileTypes: true })
533
+ } catch {
534
+ return []
535
+ }
536
+
537
+ const lockfiles: DevBridgeLockfile[] = []
538
+ for (const entry of entries) {
539
+ if (!entry.isFile() || !/^\d+\.json$/.test(entry.name)) continue
540
+ try {
541
+ const filePath = path.join(devBridgesDir(), entry.name)
542
+ const fd = fs.openSync(filePath, 'r')
543
+ try {
544
+ const buf = Buffer.alloc(DAEMON_LOCKFILE_MAX_BYTES)
545
+ const bytesRead = fs.readSync(fd, buf, 0, DAEMON_LOCKFILE_MAX_BYTES, 0)
546
+ const lockfile = parseDevBridgeLockfile(
547
+ buf.subarray(0, bytesRead).toString('utf8'),
548
+ )
549
+ if (lockfile && entry.name === `${lockfile.bridgePort}.json`) {
550
+ lockfiles.push(lockfile)
551
+ }
552
+ } finally {
553
+ fs.closeSync(fd)
554
+ }
555
+ } catch {}
556
+ }
557
+ return lockfiles.sort((left, right) => left.bridgePort - right.bridgePort)
558
+ }
559
+
560
+ /** true when the lockfile exists, the named pid is alive, and the heartbeat
561
+ * is recent. callers should reach for this before trusting any of the
562
+ * ports inside.
563
+ *
564
+ * pid-reuse note: `process.kill(pid, 0)` only checks that *some* process
565
+ * with that pid exists. after a reboot or heavy fork churn the OS can
566
+ * recycle the pid onto an unrelated process owned by the same user. the
567
+ * heartbeat freshness check catches most of that (30s stale ⇒ reject),
568
+ * but a stale lockfile where pid happens to be reused < 30s ago could
569
+ * still slip through. the consumer paths that actually connect (electron
570
+ * + ws-bridge client) time out quickly, so a stale lockfile degrades to
571
+ * "connect attempt fails" rather than corrupt state. */
572
+ export function isDaemonLockfileFresh(
573
+ lock: DaemonLockfile | null,
574
+ now = Date.now(),
575
+ ): lock is DaemonLockfile {
576
+ if (!lock) return false
577
+ if (now - lock.heartbeatAt > DAEMON_HEARTBEAT_STALE_MS) return false
578
+ try {
579
+ // signal 0 just tests whether the pid exists + we have perm to signal.
580
+ process.kill(lock.pid, 0)
581
+ return true
582
+ } catch {
583
+ return false
584
+ }
585
+ }
586
+
587
+ export function isDevBridgeLockfileFresh(
588
+ lock: DevBridgeLockfile | null,
589
+ now = Date.now(),
590
+ ): lock is DevBridgeLockfile {
591
+ if (!lock) return false
592
+ if (now - lock.heartbeatAt > DAEMON_HEARTBEAT_STALE_MS) return false
593
+ try {
594
+ process.kill(lock.pid, 0)
595
+ return true
596
+ } catch {
597
+ return false
598
+ }
599
+ }
600
+
601
+ export function readLiveRuntimeVersions(): string[] {
602
+ const versions = new Set<string>()
603
+ const daemon = readDaemonLockfile()
604
+ if (isDaemonLockfileFresh(daemon)) {
605
+ if (daemon.activeRuntime) versions.add(daemon.activeRuntime)
606
+ for (const version of daemon.servedRuntimes ?? []) versions.add(version)
607
+ }
608
+ for (const devBridge of readDevBridgeLockfiles()) {
609
+ if (!isDevBridgeLockfileFresh(devBridge)) continue
610
+ for (const version of devBridge.servedRuntimes ?? []) versions.add(version)
611
+ }
612
+ return [...versions].sort()
613
+ }
614
+
615
+ export function writeDaemonLockfile(data: DaemonLockfile): void {
616
+ ensureRnxHome()
617
+ const lockfilePath = daemonLockfilePath()
618
+ const existing = readDaemonLockfile()
619
+ if (!existing) {
620
+ if (createDaemonLockfileExclusive(data)) return
621
+ throw new Error('another rnx daemon claimed the lockfile before this write')
622
+ }
623
+ if (!sameDaemonOwner(existing, data)) {
624
+ throw new Error('refusing to overwrite another rnx daemon lockfile')
625
+ }
626
+ const tmp = `${lockfilePath}.${data.pid}.${randomUUID()}.tmp`
627
+ fs.writeFileSync(tmp, `${JSON.stringify(data, null, 2)}\n`, 'utf8')
628
+ try {
629
+ const current = readDaemonLockfile()
630
+ if (!current || !sameDaemonOwner(current, data)) {
631
+ throw new Error('rnx daemon lock ownership changed during heartbeat')
632
+ }
633
+ fs.renameSync(tmp, lockfilePath)
634
+ } finally {
635
+ try {
636
+ fs.unlinkSync(tmp)
637
+ } catch {}
638
+ }
639
+ }
640
+
641
+ export function writeDevBridgeLockfile(data: DevBridgeLockfile): void {
642
+ fs.mkdirSync(devBridgesDir(), { recursive: true })
643
+ const lockfilePath = devBridgeLockfilePath(data.bridgePort)
644
+ const tmp = `${lockfilePath}.${data.pid}.tmp`
645
+ fs.writeFileSync(tmp, `${JSON.stringify(data, null, 2)}\n`, 'utf8')
646
+ fs.renameSync(tmp, lockfilePath)
647
+ }
648
+
649
+ function sameDaemonOwner(
650
+ left: Pick<DaemonLockfile, 'pid' | 'startedAt'>,
651
+ right: Pick<DaemonLockfile, 'pid' | 'startedAt'>,
652
+ ): boolean {
653
+ return left.pid === right.pid && left.startedAt === right.startedAt
654
+ }
655
+
656
+ function isAlreadyExists(error: unknown): boolean {
657
+ return (
658
+ error instanceof Error && 'code' in error && Reflect.get(error, 'code') === 'EEXIST'
659
+ )
660
+ }
661
+
662
+ function createDaemonLockfileExclusive(data: DaemonLockfile): boolean {
663
+ const lockfilePath = daemonLockfilePath()
664
+ const tmp = `${lockfilePath}.${data.pid}.${randomUUID()}.claim`
665
+ fs.writeFileSync(tmp, `${JSON.stringify(data, null, 2)}\n`, 'utf8')
666
+ try {
667
+ fs.linkSync(tmp, lockfilePath)
668
+ return true
669
+ } catch (error) {
670
+ if (isAlreadyExists(error)) return false
671
+ throw error
672
+ } finally {
673
+ try {
674
+ fs.unlinkSync(tmp)
675
+ } catch {}
676
+ }
677
+ }
678
+
679
+ function daemonClaimPath(): string {
680
+ return `${daemonLockfilePath()}.claim`
681
+ }
682
+
683
+ function parseDaemonClaim(raw: string): DaemonClaim | null {
684
+ try {
685
+ const value: unknown = JSON.parse(raw)
686
+ if (
687
+ value !== null &&
688
+ typeof value === 'object' &&
689
+ typeof Reflect.get(value, 'pid') === 'number' &&
690
+ typeof Reflect.get(value, 'startedAt') === 'number' &&
691
+ typeof Reflect.get(value, 'createdAt') === 'number'
692
+ ) {
693
+ return {
694
+ pid: Reflect.get(value, 'pid'),
695
+ startedAt: Reflect.get(value, 'startedAt'),
696
+ createdAt: Reflect.get(value, 'createdAt'),
697
+ }
698
+ }
699
+ } catch {}
700
+ return null
701
+ }
702
+
703
+ function readDaemonClaim(): DaemonClaim | null {
704
+ try {
705
+ return parseDaemonClaim(fs.readFileSync(daemonClaimPath(), 'utf8'))
706
+ } catch {
707
+ return null
708
+ }
709
+ }
710
+
711
+ function isDaemonClaimFresh(claim: DaemonClaim, now = Date.now()): boolean {
712
+ if (now - claim.createdAt > DAEMON_CLAIM_STALE_MS) return false
713
+ try {
714
+ process.kill(claim.pid, 0)
715
+ return true
716
+ } catch {
717
+ return false
718
+ }
719
+ }
720
+
721
+ function acquireDaemonClaim(data: DaemonLockfile): boolean {
722
+ const claim: DaemonClaim = {
723
+ pid: data.pid,
724
+ startedAt: data.startedAt,
725
+ createdAt: Date.now(),
726
+ }
727
+ const serialized = `${JSON.stringify(claim)}\n`
728
+ for (let attempt = 0; attempt < 8; attempt++) {
729
+ try {
730
+ fs.writeFileSync(daemonClaimPath(), serialized, { encoding: 'utf8', flag: 'wx' })
731
+ return true
732
+ } catch (error) {
733
+ if (!isAlreadyExists(error)) throw error
734
+ }
735
+ const existing = readDaemonClaim()
736
+ if (existing && sameDaemonOwner(existing, claim)) return true
737
+ if (existing && isDaemonClaimFresh(existing)) return false
738
+ let before: string
739
+ try {
740
+ before = fs.readFileSync(daemonClaimPath(), 'utf8')
741
+ const latest = parseDaemonClaim(before)
742
+ if (latest && isDaemonClaimFresh(latest)) return false
743
+ if (fs.readFileSync(daemonClaimPath(), 'utf8') !== before) continue
744
+ fs.unlinkSync(daemonClaimPath())
745
+ } catch {}
746
+ }
747
+ return false
748
+ }
749
+
750
+ function releaseDaemonClaim(owner: Pick<DaemonLockfile, 'pid' | 'startedAt'>): void {
751
+ try {
752
+ const claim = readDaemonClaim()
753
+ if (!claim || !sameDaemonOwner(claim, owner)) return
754
+ fs.unlinkSync(daemonClaimPath())
755
+ } catch {}
756
+ }
757
+
758
+ /** try to claim the lockfile atomically on daemon boot. a short-lived,
759
+ * process-owned election file serializes concurrent fallback-port binds;
760
+ * the complete lock snapshot is then installed with an exclusive hard link. */
761
+ export function claimDaemonLockfile(data: DaemonLockfile): boolean {
762
+ ensureRnxHome()
763
+ if (!acquireDaemonClaim(data)) return false
764
+ try {
765
+ const existing = readDaemonLockfile()
766
+ if (existing && sameDaemonOwner(existing, data)) {
767
+ writeDaemonLockfile(data)
768
+ return true
769
+ }
770
+ if (existing && isDaemonLockfileFresh(existing)) return false
771
+ if (existing) {
772
+ const latest = readDaemonLockfile()
773
+ if (!latest || !sameDaemonOwner(latest, existing)) return false
774
+ if (isDaemonLockfileFresh(latest)) return false
775
+ try {
776
+ fs.unlinkSync(daemonLockfilePath())
777
+ } catch {
778
+ return false
779
+ }
780
+ }
781
+ return createDaemonLockfileExclusive(data)
782
+ } finally {
783
+ releaseDaemonClaim(data)
784
+ }
785
+ }
786
+
787
+ export function removeDaemonLockfile(
788
+ owner: Pick<DaemonLockfile, 'pid' | 'startedAt'>,
789
+ ): void {
790
+ try {
791
+ const lockfile = readDaemonLockfile()
792
+ if (!lockfile || !sameDaemonOwner(lockfile, owner)) return
793
+ fs.unlinkSync(daemonLockfilePath())
794
+ } catch {}
795
+ }
796
+
797
+ export function removeDevBridgeLockfile(
798
+ owner: Pick<DevBridgeLockfile, 'bridgePort' | 'pid' | 'startedAt'>,
799
+ ): void {
800
+ try {
801
+ const lockfilePath = devBridgeLockfilePath(owner.bridgePort)
802
+ const lockfile = parseDevBridgeLockfile(fs.readFileSync(lockfilePath, 'utf8'))
803
+ if (lockfile?.pid !== owner.pid || lockfile.startedAt !== owner.startedAt) return
804
+ fs.unlinkSync(lockfilePath)
805
+ } catch {}
806
+ }