rnxsim 0.0.0 → 0.1.313

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 (228) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +512 -0
  3. package/detox/colors.ts +54 -0
  4. package/detox/config-loader.ts +135 -0
  5. package/detox/element-types.ts +39 -0
  6. package/detox/expectations.ts +537 -0
  7. package/detox/gestures.ts +480 -0
  8. package/detox/index.ts +2118 -0
  9. package/detox/jest-preset.cjs +51 -0
  10. package/detox/jest-setup-after-env.cjs +18 -0
  11. package/detox/matchers.ts +30 -0
  12. package/detox/motion-change.cjs +109 -0
  13. package/detox/motion-change.d.cts +35 -0
  14. package/detox/navigation.ts +43 -0
  15. package/detox/screenshots/animated-color-test-rest-norngh.png +0 -0
  16. package/detox/screenshots/color-test-after-drag-norngh.png +0 -0
  17. package/detox/screenshots/color-test-rest-norngh.png +0 -0
  18. package/detox/screenshots/theme-blue-toggle.png +0 -0
  19. package/detox/screenshots/theme-blue.png +0 -0
  20. package/detox/screenshots/theme-red-toggle.png +0 -0
  21. package/detox/screenshots/theme-red.png +0 -0
  22. package/dist-cli/bin.js +15 -0
  23. package/dist-cli/chunks/agent-XZ2KTPCU.js +48 -0
  24. package/dist-cli/chunks/agent-wrapper-JJYYW2WH.js +17 -0
  25. package/dist-cli/chunks/app-fonts-IXRNQG6B.js +4 -0
  26. package/dist-cli/chunks/assert-54T5SK5F.js +49 -0
  27. package/dist-cli/chunks/auth-FI5UDI45.js +12 -0
  28. package/dist-cli/chunks/beta-JV6UKADW.js +4 -0
  29. package/dist-cli/chunks/camera-VL73YIKP.js +22 -0
  30. package/dist-cli/chunks/chunk-2D2UPBBR.js +4 -0
  31. package/dist-cli/chunks/chunk-2YR5BGA5.js +5 -0
  32. package/dist-cli/chunks/chunk-3NV2NCNX.js +4 -0
  33. package/dist-cli/chunks/chunk-46EUUFJ5.js +4 -0
  34. package/dist-cli/chunks/chunk-4NPPOV2N.js +5 -0
  35. package/dist-cli/chunks/chunk-5DHC6KHQ.js +7 -0
  36. package/dist-cli/chunks/chunk-5TEF3ET3.js +74 -0
  37. package/dist-cli/chunks/chunk-5TPRP5QT.js +4 -0
  38. package/dist-cli/chunks/chunk-5YJCOWCH.js +4 -0
  39. package/dist-cli/chunks/chunk-7GN3LVWB.js +4 -0
  40. package/dist-cli/chunks/chunk-BBULZ7CG.js +568 -0
  41. package/dist-cli/chunks/chunk-BTWORNNG.js +71 -0
  42. package/dist-cli/chunks/chunk-D4FFVGI5.js +5 -0
  43. package/dist-cli/chunks/chunk-DCEMHR2Y.js +4 -0
  44. package/dist-cli/chunks/chunk-DZS6WPUI.js +4 -0
  45. package/dist-cli/chunks/chunk-F5ZRSS3C.js +3 -0
  46. package/dist-cli/chunks/chunk-FSUYIVJ6.js +9 -0
  47. package/dist-cli/chunks/chunk-G2WW6L2C.js +23 -0
  48. package/dist-cli/chunks/chunk-GASE6UBA.js +4 -0
  49. package/dist-cli/chunks/chunk-GGRX24GF.js +142 -0
  50. package/dist-cli/chunks/chunk-HI5TFJWN.js +5 -0
  51. package/dist-cli/chunks/chunk-IJ5CAZZC.js +5 -0
  52. package/dist-cli/chunks/chunk-IJO63TDP.js +4 -0
  53. package/dist-cli/chunks/chunk-KTHV3RUS.js +26 -0
  54. package/dist-cli/chunks/chunk-LF2ZVT7O.js +6 -0
  55. package/dist-cli/chunks/chunk-MJRLLB4R.js +38 -0
  56. package/dist-cli/chunks/chunk-NFK7T35W.js +4 -0
  57. package/dist-cli/chunks/chunk-NMF2ZMZQ.js +15 -0
  58. package/dist-cli/chunks/chunk-OHAZNXLK.js +4 -0
  59. package/dist-cli/chunks/chunk-OVFJFXUD.js +101 -0
  60. package/dist-cli/chunks/chunk-OZSSI4WN.js +28 -0
  61. package/dist-cli/chunks/chunk-QKDWYITG.js +25 -0
  62. package/dist-cli/chunks/chunk-RSZWCKNT.js +30 -0
  63. package/dist-cli/chunks/chunk-RTN5C5RL.js +4 -0
  64. package/dist-cli/chunks/chunk-TIVZIMMW.js +4 -0
  65. package/dist-cli/chunks/chunk-TZFFR3SD.js +7 -0
  66. package/dist-cli/chunks/chunk-UC6U3MML.js +5 -0
  67. package/dist-cli/chunks/chunk-VFCMSYZK.js +6 -0
  68. package/dist-cli/chunks/chunk-VNQEB4L7.js +5 -0
  69. package/dist-cli/chunks/chunk-VZXWHRUZ.js +983 -0
  70. package/dist-cli/chunks/chunk-W6K4EFPH.js +107 -0
  71. package/dist-cli/chunks/chunk-WEXDAC74.js +4 -0
  72. package/dist-cli/chunks/chunk-WF3T4SVI.js +14 -0
  73. package/dist-cli/chunks/chunk-WINYQ44O.js +4 -0
  74. package/dist-cli/chunks/chunk-WMIIKMGK.js +10 -0
  75. package/dist-cli/chunks/chunk-WUSWBCWA.js +507 -0
  76. package/dist-cli/chunks/chunk-WWZIXIRD.js +4 -0
  77. package/dist-cli/chunks/chunk-XEVZYVIW.js +766 -0
  78. package/dist-cli/chunks/chunk-YDGQTMQL.js +4 -0
  79. package/dist-cli/chunks/chunk-YIFT42WN.js +4 -0
  80. package/dist-cli/chunks/chunk-ZMJD5GEC.js +125 -0
  81. package/dist-cli/chunks/cleanup-P27PA6JI.js +25 -0
  82. package/dist-cli/chunks/cli-version-WWLPBDQ7.js +4 -0
  83. package/dist-cli/chunks/compat-ZD65FED3.js +20 -0
  84. package/dist-cli/chunks/config-XMJRNM2A.js +13 -0
  85. package/dist-cli/chunks/control-KMIQT3QP.js +4 -0
  86. package/dist-cli/chunks/daemon-G2ME7NLB.js +4 -0
  87. package/dist-cli/chunks/debug-PT4HOP7N.js +200 -0
  88. package/dist-cli/chunks/desktop-S3FG72AK.js +31 -0
  89. package/dist-cli/chunks/detox-B3D4IFCN.js +52 -0
  90. package/dist-cli/chunks/device-XBNDSB2R.js +18 -0
  91. package/dist-cli/chunks/diagnose-HMQXJE5N.js +43 -0
  92. package/dist-cli/chunks/disk-cleanup-BLCZ5BSZ.js +4 -0
  93. package/dist-cli/chunks/drivers-LDECZGP2.js +4 -0
  94. package/dist-cli/chunks/film-BJGTBYZB.js +74 -0
  95. package/dist-cli/chunks/flow-UEQNVTU7.js +4 -0
  96. package/dist-cli/chunks/help-T5FYSVGB.js +4 -0
  97. package/dist-cli/chunks/hidden-runtime-alias-ANOYADHM.js +4 -0
  98. package/dist-cli/chunks/home-paths-GT3LFNOR.js +4 -0
  99. package/dist-cli/chunks/inspect-ZA6XF5LD.js +4 -0
  100. package/dist-cli/chunks/install-desktop-TIMUDHPL.js +4 -0
  101. package/dist-cli/chunks/login-FJ737MWG.js +28 -0
  102. package/dist-cli/chunks/logout-ZCNMMHMY.js +4 -0
  103. package/dist-cli/chunks/maestro-SZTNKLDF.js +104 -0
  104. package/dist-cli/chunks/maestro-generate-DCFAIZ4H.js +36 -0
  105. package/dist-cli/chunks/mode-GRMQCRXR.js +26 -0
  106. package/dist-cli/chunks/optional-demo-registry-W36EWFFB.js +4 -0
  107. package/dist-cli/chunks/perf-QYBAAUZG.js +48 -0
  108. package/dist-cli/chunks/permissions-3QCQ6VF4.js +13 -0
  109. package/dist-cli/chunks/record-QPPC2S4E.js +72 -0
  110. package/dist-cli/chunks/report-issue-7NMFP4HK.js +29 -0
  111. package/dist-cli/chunks/runtime-XOAXMSTU.js +4 -0
  112. package/dist-cli/chunks/screenshot-command-67AECJFB.js +159 -0
  113. package/dist-cli/chunks/screenshot-layers-ASWBYPJL.js +20 -0
  114. package/dist-cli/chunks/screenshots-capture-PXA3HFQK.js +42 -0
  115. package/dist-cli/chunks/serve-BI2NBAXG.js +44 -0
  116. package/dist-cli/chunks/setup-7DWPMRSB.js +32 -0
  117. package/dist-cli/chunks/skills-S3Y22TUA.js +50 -0
  118. package/dist-cli/chunks/storage-XUIMJWAJ.js +37 -0
  119. package/dist-cli/chunks/store-JTHEJLAZ.js +4 -0
  120. package/dist-cli/chunks/telemetry-ZYJGD2DB.js +4 -0
  121. package/dist-cli/chunks/timeline-TMPLQPSP.js +24 -0
  122. package/dist-cli/chunks/upgrade-7HDSIM7K.js +7 -0
  123. package/dist-cli/chunks/upload-GMSZPWM6.js +4 -0
  124. package/dist-cli/chunks/version-HOCHZ37L.js +6 -0
  125. package/dist-cli/chunks/web-DG3WBYD3.js +4 -0
  126. package/dist-cli/chunks/what-happened-XFVUTZR7.js +25 -0
  127. package/dist-lib/agent-daemon-client.cjs +468 -0
  128. package/dist-lib/agent-events.cjs +48 -0
  129. package/dist-lib/agent-identity.cjs +53 -0
  130. package/dist-lib/agent-sessions.cjs +725 -0
  131. package/dist-lib/attached-projects.cjs +526 -0
  132. package/dist-lib/auth/shared-session.cjs +195 -0
  133. package/dist-lib/backend-origin.cjs +70 -0
  134. package/dist-lib/beta.cjs +41 -0
  135. package/dist-lib/beta.mjs +13 -0
  136. package/dist-lib/bridge-constants.cjs +161 -0
  137. package/dist-lib/cli-constants.cjs +32 -0
  138. package/dist-lib/config.cjs +96 -0
  139. package/dist-lib/detox/index.cjs +2863 -0
  140. package/dist-lib/detox/jest-preset.cjs +51 -0
  141. package/dist-lib/dev-bundle-resolution.cjs +223 -0
  142. package/dist-lib/home-paths.cjs +421 -0
  143. package/dist-lib/host/bridge-host.cjs +5600 -0
  144. package/dist-lib/host/fetch-proxy-handler.cjs +373 -0
  145. package/dist-lib/host/fetch-proxy-overrides.cjs +63 -0
  146. package/dist-lib/host/fetch-proxy-overrides.mjs +37 -0
  147. package/dist-lib/host/websocket-proxy.cjs +214 -0
  148. package/dist-lib/index.cjs +513 -0
  149. package/dist-lib/jump-to-source-babel.cjs +93 -0
  150. package/dist-lib/menu.cjs +620 -0
  151. package/dist-lib/menu.mjs +581 -0
  152. package/dist-lib/metro.cjs +297 -0
  153. package/dist-lib/profiles.cjs +246 -0
  154. package/dist-lib/public-brand.cjs +39 -0
  155. package/dist-lib/render-mode.cjs +54 -0
  156. package/dist-lib/scripts/dev-server-scanner.cjs +1034 -0
  157. package/dist-lib/sdk.cjs +2333 -0
  158. package/dist-lib/sdk.mjs +2240 -0
  159. package/dist-lib/skills.cjs +23922 -0
  160. package/dist-lib/vite.cjs +358 -0
  161. package/package.json +189 -18
  162. package/scripts/dev-server-scanner.ts +941 -0
  163. package/scripts/optional-demo-registry.ts +113 -0
  164. package/scripts/postinstall.cjs +3 -0
  165. package/scripts/preuninstall.cjs +118 -0
  166. package/skills/contrast/SKILL.md +171 -0
  167. package/skills/rnx-debug/SKILL.md +425 -0
  168. package/skills/rnx-perf/SKILL.md +295 -0
  169. package/skills/rnx-setup/SKILL.md +160 -0
  170. package/skills/rnx-test/SKILL.md +292 -0
  171. package/skills/rnx-visual/SKILL.md +122 -0
  172. package/src/agent-daemon-client.ts +410 -0
  173. package/src/agent-events.ts +71 -0
  174. package/src/agent-identity.ts +23 -0
  175. package/src/agent-prompt.ts +71 -0
  176. package/src/agent-sessions.ts +573 -0
  177. package/src/attached-projects.ts +539 -0
  178. package/src/auth/shared-session.ts +204 -0
  179. package/src/backend-origin.ts +49 -0
  180. package/src/beta.ts +19 -0
  181. package/src/bridge-constants.ts +171 -0
  182. package/src/browser-cache.ts +16 -0
  183. package/src/cli-constants.ts +1 -0
  184. package/src/cli-version.ts +32 -0
  185. package/src/codex-client.ts +215 -0
  186. package/src/config.ts +134 -0
  187. package/src/dev-bundle-resolution.ts +236 -0
  188. package/src/disk-cleanup.ts +466 -0
  189. package/src/home-paths.ts +552 -0
  190. package/src/host/agent-host.ts +666 -0
  191. package/src/host/bridge-host.ts +2626 -0
  192. package/src/host/fetch-proxy-handler.ts +397 -0
  193. package/src/host/fetch-proxy-overrides.ts +51 -0
  194. package/src/host/open-url.ts +89 -0
  195. package/src/host/websocket-proxy.ts +210 -0
  196. package/src/index.ts +12 -705
  197. package/src/jump-to-source-babel.ts +135 -0
  198. package/src/menu.ts +734 -0
  199. package/src/metro-plugin.ts +139 -0
  200. package/src/native-dev-bundle-url.ts +96 -0
  201. package/src/native-seam-manifest.ts +341 -0
  202. package/src/plist.ts +27 -0
  203. package/src/poll-until.ts +92 -0
  204. package/src/profiles.ts +179 -0
  205. package/src/public-brand.ts +9 -0
  206. package/src/render-mode.ts +27 -0
  207. package/src/runtime-assets.ts +228 -0
  208. package/src/runtime-delivery.ts +54 -0
  209. package/src/screenshots/compose.ts +422 -0
  210. package/src/screenshots/frame-compose.ts +454 -0
  211. package/src/screenshots/orchestrate.ts +256 -0
  212. package/src/screenshots/org-deck.ts +91 -0
  213. package/src/screenshots/registry.ts +58 -0
  214. package/src/screenshots/schema.ts +364 -0
  215. package/src/sdk.ts +7 -0
  216. package/src/settings-bundle.ts +418 -0
  217. package/src/skills/builtin/a11y-review.ts +132 -0
  218. package/src/skills/builtin/compat-check.ts +71 -0
  219. package/src/skills/builtin/maestro-test.ts +121 -0
  220. package/src/skills/builtin/perf-profile.ts +90 -0
  221. package/src/skills/builtin/screenshot-all.ts +52 -0
  222. package/src/skills/builtin/visual-diff.ts +100 -0
  223. package/src/skills/registry.ts +107 -0
  224. package/src/skills/types.ts +41 -0
  225. package/src/team-machine-prompt.ts +73 -0
  226. package/src/vite-plugin-one.ts +190 -0
  227. package/src/vite-plugin.ts +1750 -0
  228. package/src/worklets-babel.ts +134 -0
@@ -0,0 +1,552 @@
1
+ // canonical filesystem layout under ~/.sootsim/. 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
+ // ~/.sootsim/
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
+ // │ └── sootsim-runtime-<version>.tar.gz
16
+ // ├── daemon.json lockfile: pid, ports, active runtime, heartbeat
17
+ // ├── dev-bridge.json live Vite dev bridge pointer for local CLI commands
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
+ // ├── automatic-cleanup-v1.json completion marker for the one-time safe
22
+ // │ cleanup introduced for legacy unbounded data
23
+ // └── config.json user prefs: update channel, cdn origin override,
24
+ // telemetry, engine settings
25
+
26
+ import { randomUUID } from 'node:crypto'
27
+ import fs from 'node:fs'
28
+ import { homedir } from 'node:os'
29
+ import path from 'node:path'
30
+
31
+ export const SOOTSIM_HOME_ENV = 'SOOTSIM_HOME'
32
+ export const ACTIVE_RUNTIME_FILE = 'active'
33
+ export const DAEMON_LOCKFILE = 'daemon.json'
34
+ export const DEV_BRIDGE_LOCKFILE = 'dev-bridge.json'
35
+ export const CONFIG_FILE = 'config.json'
36
+ export const DAEMON_HEARTBEAT_STALE_MS = 30_000
37
+
38
+ export function sootsimHomeDir(): string {
39
+ const override = process.env[SOOTSIM_HOME_ENV]
40
+ if (override && override.length > 0) return path.resolve(override)
41
+ return path.join(homedir(), '.sootsim')
42
+ }
43
+
44
+ // detect when sootsim is running from a source checkout (the Contrast monorepo)
45
+ // rather than a published npm install. used to skip auto-install of the
46
+ // persistent launchd / systemd agent: dev shells shouldn't register an agent
47
+ // whose Program path points at workspace artifacts that change between
48
+ // sessions, and whose served engine assets are the stale prod build instead
49
+ // of the live `bun dev:sootsim` output.
50
+ //
51
+ // overrides:
52
+ // SOOTSIM_DEV=1 / SOOTSIM_DEV=0 force the answer
53
+ // SOOTSIM_FORCE_DAEMON_INSTALL=1 pretend prod even from a dev
54
+ // checkout, for exercising the
55
+ // install path from this repo
56
+ //
57
+ // signal: realpath of process.argv[1] lands inside a `packages/sootsim/`
58
+ // directory. workspace bin symlinks (`node_modules/.bin/sootsim` →
59
+ // `packages/sootsim/dist-cli/bin.js`) and bun-direct invocations
60
+ // (`bun packages/sootsim/cli/bin.ts ...`) both match; published installs
61
+ // resolve under `node_modules/sootsim/` instead.
62
+ export function isSootsimDevCheckout(): boolean {
63
+ if (process.env.SOOTSIM_FORCE_DAEMON_INSTALL === '1') return false
64
+ const env = process.env.SOOTSIM_DEV
65
+ if (env === '1' || env === 'true') return true
66
+ if (env === '0' || env === 'false') return false
67
+ const argv1 = process.argv[1]
68
+ if (!argv1) return false
69
+ try {
70
+ const real = fs.realpathSync(argv1)
71
+ return real.includes(`${path.sep}packages${path.sep}sootsim${path.sep}`)
72
+ } catch {
73
+ return false
74
+ }
75
+ }
76
+
77
+ // detect the human's dev workstation by the env markers the developer sets in
78
+ // their shell profile (`IS_TAMAGUI_DEV=1` in ~/.zshrc). distinct from
79
+ // isSootsimDevCheckout, which keys on the *binary path*: a globally-installed
80
+ // `sootsim` binary on the dev machine would NOT match the checkout path, yet a
81
+ // persistent daemon registered there is just as dangerous. it runs in the
82
+ // background carrying the workstation's dev env (dev auth tokens, prod-pointing
83
+ // vars), and when the live `bun dev` stack already owns the bridge port it
84
+ // silently lands on a fallback port and shadows the real one with stale assets
85
+ // — the "I was on the daemon on port 7-something" footgun.
86
+ //
87
+ // override: SOOTSIM_FORCE_DAEMON_INSTALL=1 to run the daemon here anyway.
88
+ export function isDevWorkstation(): boolean {
89
+ if (process.env.SOOTSIM_FORCE_DAEMON_INSTALL === '1') return false
90
+ return process.env.IS_TAMAGUI_DEV === '1'
91
+ }
92
+
93
+ // the persistent launchd / systemd daemon must never auto-install or run on a
94
+ // dev checkout or a dev workstation. either makes the background agent point at
95
+ // moving workspace artifacts / dev env and serve stale assets that shadow the
96
+ // live dev stack. setup and the launchd-spawned `sootsim serve` boot consult
97
+ // this; a foreground `sootsim serve` the dev starts
98
+ // by hand is intentional and stays allowed.
99
+ export function shouldSkipPersistentDaemon(): boolean {
100
+ return isSootsimDevCheckout() || isDevWorkstation()
101
+ }
102
+
103
+ export function runtimesDir(): string {
104
+ return path.join(sootsimHomeDir(), 'runtimes')
105
+ }
106
+
107
+ export function runtimeDir(version: string): string {
108
+ return path.join(runtimesDir(), version)
109
+ }
110
+
111
+ export function activeRuntimeFile(): string {
112
+ return path.join(runtimesDir(), ACTIVE_RUNTIME_FILE)
113
+ }
114
+
115
+ export function electronDir(): string {
116
+ return path.join(sootsimHomeDir(), 'electron')
117
+ }
118
+
119
+ export function electronUserDataDir(): string {
120
+ return path.join(electronDir(), 'userData')
121
+ }
122
+
123
+ export function electronVersionDir(version: string): string {
124
+ return path.join(electronDir(), version)
125
+ }
126
+
127
+ export function profilesDir(): string {
128
+ return path.join(sootsimHomeDir(), 'profiles')
129
+ }
130
+
131
+ // the launchd-managed daemon spawns ProgramArguments[0] directly, and macOS
132
+ // Background Task Management attributes the entry to whoever code-signed
133
+ // that binary. pointing launchd at bun directly makes Login Items say
134
+ // "software from Jarred Sumner" (bun's signer); wrapping the invocation in
135
+ // an ad-hoc-signed .app bundle here gives BTM a CFBundleDisplayName to
136
+ // read instead.
137
+ export function daemonAppDir(): string {
138
+ return path.join(sootsimHomeDir(), 'daemon-app')
139
+ }
140
+
141
+ export function daemonAppBundlePath(): string {
142
+ return path.join(daemonAppDir(), 'SootSim Daemon.app')
143
+ }
144
+
145
+ export function daemonAppLauncherPath(): string {
146
+ return path.join(daemonAppBundlePath(), 'Contents', 'MacOS', 'sootsim-daemon')
147
+ }
148
+
149
+ export function cacheDir(): string {
150
+ return path.join(sootsimHomeDir(), 'cache')
151
+ }
152
+
153
+ export function daemonLockfilePath(): string {
154
+ return path.join(sootsimHomeDir(), DAEMON_LOCKFILE)
155
+ }
156
+
157
+ export function devBridgeLockfilePath(): string {
158
+ return path.join(sootsimHomeDir(), DEV_BRIDGE_LOCKFILE)
159
+ }
160
+
161
+ export function configFilePath(): string {
162
+ return path.join(sootsimHomeDir(), CONFIG_FILE)
163
+ }
164
+
165
+ // --- shared sootsim config -----------------------------------------------
166
+ //
167
+ // ~/.sootsim/config.json is the single user-level config file every sootsim
168
+ // surface (cli, electron renderer, dev browser via electron ipc) agrees on.
169
+ //
170
+ // privacy preferences live in the nested settings object so the cli, desktop
171
+ // main process, renderer, daemon-served browser, and settings UI all read and
172
+ // write one persisted value per choice.
173
+
174
+ export interface SharedConfig {
175
+ telemetry?: boolean
176
+ settings?: Record<string, unknown>
177
+ [key: string]: unknown
178
+ }
179
+
180
+ export function readSharedConfig(): SharedConfig {
181
+ try {
182
+ const raw = fs.readFileSync(configFilePath(), 'utf8')
183
+ const parsed = JSON.parse(raw) as SharedConfig
184
+ return parsed && typeof parsed === 'object' ? parsed : {}
185
+ } catch {
186
+ return {}
187
+ }
188
+ }
189
+
190
+ /** merge `patch` into the shared config and atomically write to disk. nested
191
+ * objects (currently just `settings`) are shallow-merged so partial writes
192
+ * don't clobber unrelated fields. returns the new full snapshot. */
193
+ export function writeSharedConfig(patch: Partial<SharedConfig>): SharedConfig {
194
+ ensureSootsimHome()
195
+ const current = readSharedConfig()
196
+ const next: SharedConfig = { ...current, ...patch }
197
+ if (patch.settings && typeof patch.settings === 'object') {
198
+ next.settings = {
199
+ ...(current.settings && typeof current.settings === 'object'
200
+ ? current.settings
201
+ : {}),
202
+ ...patch.settings,
203
+ }
204
+ }
205
+ const tmp = `${configFilePath()}.tmp`
206
+ fs.writeFileSync(tmp, `${JSON.stringify(next, null, 2)}\n`, 'utf8')
207
+ fs.renameSync(tmp, configFilePath())
208
+ return next
209
+ }
210
+
211
+ export interface PrivacyPreferences {
212
+ productAnalytics: boolean
213
+ crashReports: boolean
214
+ configured: boolean
215
+ }
216
+
217
+ export function readPrivacyPreferences(): PrivacyPreferences {
218
+ const settings = readSharedConfig().settings
219
+ const productAnalytics = settings?.productAnalytics
220
+ const crashReports = settings?.crashReports
221
+ return {
222
+ productAnalytics: typeof productAnalytics === 'boolean' ? productAnalytics : true,
223
+ crashReports: typeof crashReports === 'boolean' ? crashReports : true,
224
+ configured:
225
+ typeof productAnalytics === 'boolean' && typeof crashReports === 'boolean',
226
+ }
227
+ }
228
+
229
+ export function writePrivacyPreferences(
230
+ preferences: Pick<PrivacyPreferences, 'productAnalytics' | 'crashReports'>,
231
+ ): void {
232
+ writeSharedConfig({
233
+ settings: {
234
+ productAnalytics: preferences.productAnalytics,
235
+ crashReports: preferences.crashReports,
236
+ },
237
+ })
238
+ }
239
+
240
+ // stable per-machine anonymous telemetry id. every signed-out cli used to
241
+ // report as the single literal distinct_id 'anonymous-cli', which merged all
242
+ // first-run users into one posthog person and made "a new user's login hung"
243
+ // impossible to see. created lazily on first use, persisted in config.json.
244
+ export function readOrCreateAnonymousId(): string {
245
+ const existing = readSharedConfig().anonymousId
246
+ if (typeof existing === 'string' && existing.trim()) return existing
247
+ const created = `anon-cli-${randomUUID()}`
248
+ writeSharedConfig({ anonymousId: created })
249
+ return created
250
+ }
251
+
252
+ // --- background-upgrade notice --------------------------------------------
253
+ //
254
+ // the daemon auto-updates the engine runtime in the background (hourly), so
255
+ // the only place its "updated to vX" line lands is the daemon's own stderr,
256
+ // which nobody reads. it also drops a one-shot notice file here; the next
257
+ // interactive CLI invocation prints it once and deletes it, so users learn
258
+ // their engine moved without having to run `sootsim version`.
259
+
260
+ export interface RuntimeUpgradeNotice {
261
+ from: string | null
262
+ to: string
263
+ at: number
264
+ }
265
+
266
+ export function runtimeUpgradeNoticePath(): string {
267
+ return path.join(sootsimHomeDir(), 'runtime-upgraded.json')
268
+ }
269
+
270
+ export function writeRuntimeUpgradeNotice(notice: RuntimeUpgradeNotice): void {
271
+ ensureSootsimHome()
272
+ const tmp = `${runtimeUpgradeNoticePath()}.tmp`
273
+ fs.writeFileSync(tmp, `${JSON.stringify(notice)}\n`, 'utf8')
274
+ fs.renameSync(tmp, runtimeUpgradeNoticePath())
275
+ }
276
+
277
+ /** read + delete the pending upgrade notice, or null when none is pending.
278
+ * deleting first-read keeps the banner one-shot even across racing CLIs. */
279
+ export function consumeRuntimeUpgradeNotice(): RuntimeUpgradeNotice | null {
280
+ try {
281
+ const raw = fs.readFileSync(runtimeUpgradeNoticePath(), 'utf8')
282
+ fs.unlinkSync(runtimeUpgradeNoticePath())
283
+ const parsed = JSON.parse(raw) as Partial<RuntimeUpgradeNotice>
284
+ if (typeof parsed?.to !== 'string' || !parsed.to) return null
285
+ return {
286
+ from: typeof parsed.from === 'string' ? parsed.from : null,
287
+ to: parsed.to,
288
+ at: typeof parsed.at === 'number' ? parsed.at : 0,
289
+ }
290
+ } catch {
291
+ return null
292
+ }
293
+ }
294
+
295
+ export function ensureSootsimHome(): void {
296
+ fs.mkdirSync(sootsimHomeDir(), { recursive: true })
297
+ fs.mkdirSync(runtimesDir(), { recursive: true })
298
+ fs.mkdirSync(electronDir(), { recursive: true })
299
+ fs.mkdirSync(profilesDir(), { recursive: true })
300
+ fs.mkdirSync(cacheDir(), { recursive: true })
301
+ }
302
+
303
+ /** read the active runtime version string, or null if none is selected. */
304
+ export function readActiveRuntime(): string | null {
305
+ try {
306
+ const value = fs.readFileSync(activeRuntimeFile(), 'utf8').trim()
307
+ return value.length > 0 ? value : null
308
+ } catch {
309
+ return null
310
+ }
311
+ }
312
+
313
+ /** set the active runtime version. caller is responsible for verifying the
314
+ * version actually exists on disk before calling. */
315
+ export function writeActiveRuntime(version: string): void {
316
+ fs.mkdirSync(runtimesDir(), { recursive: true })
317
+ fs.writeFileSync(activeRuntimeFile(), `${version}\n`, 'utf8')
318
+ }
319
+
320
+ // listInstalledRuntimes + compareSemver live in @contrast/runtime-delivery:
321
+ // they are delivery machinery (sorting the hosted catalog, deciding whether
322
+ // channel latest is newer than active), shared with contrast, and this module
323
+ // stays free of that dependency so every published `sootsim/*` export that
324
+ // resolves through it keeps working with no bundling requirement.
325
+
326
+ /** absolute path to the active runtime's directory, or null if none active
327
+ * or the active version is no longer installed. */
328
+ export function activeRuntimeDir(): string | null {
329
+ const version = readActiveRuntime()
330
+ if (!version) return null
331
+ const dir = runtimeDir(version)
332
+ try {
333
+ if (fs.statSync(dir).isDirectory()) return dir
334
+ } catch {}
335
+ return null
336
+ }
337
+
338
+ // --- daemon lockfile ----------------------------------------------------
339
+
340
+ export interface DaemonLockfile {
341
+ /** sootsim cli/daemon version that wrote the lockfile. bumped whenever
342
+ * the lockfile shape changes so readers can gate on it. */
343
+ schema: 1
344
+ pid: number
345
+ /** platform — useful when one home dir is shared across platforms via NFS. */
346
+ platform: NodeJS.Platform
347
+ /** ws bridge port (where cli + electron open control connections). */
348
+ bridgePort: number
349
+ /** http runtime server port (where electron loads the renderer from). */
350
+ runtimePort: number
351
+ /** active runtime version at boot, or null if the daemon booted with no
352
+ * runtime installed. updated live when the user runs `sootsim runtime use`. */
353
+ activeRuntime: string | null
354
+ /** absolute path to the active runtime's dist directory, or null. */
355
+ activeRuntimeDir: string | null
356
+ /** repo-selected runtime versions currently served to connected sims. */
357
+ servedRuntimes?: string[]
358
+ /** epoch-ms of daemon start. */
359
+ startedAt: number
360
+ /** epoch-ms of last heartbeat. daemons update this every ~5s; readers
361
+ * treat the lockfile as stale if now - heartbeatAt > DAEMON_HEARTBEAT_STALE_MS. */
362
+ heartbeatAt: number
363
+ /** true while the daemon is still fetching/activating its runtime on first
364
+ * boot. clients (electron splash, cli) should wait for this to become
365
+ * false before treating the daemon as ready to serve. */
366
+ bootstrapping?: boolean
367
+ }
368
+
369
+ export interface DevBridgeLockfile {
370
+ schema: 1
371
+ pid: number
372
+ platform: NodeJS.Platform
373
+ bridgePort: number
374
+ runtimePort: number
375
+ /** http port of the vite shell dev server that owns this bridge (e.g. 5173).
376
+ * optional: lockfiles written before this field existed still parse, and
377
+ * readers fall back to the default shell URL. this is what lets the CLI
378
+ * resolve the shell base URL and the bridge port from the SAME world
379
+ * instead of mixing dev bridge + daemon runtime (the split-brain bug). */
380
+ shellPort?: number
381
+ cwd: string
382
+ startedAt: number
383
+ heartbeatAt: number
384
+ source: 'vite-dev'
385
+ /** repo-selected runtime versions currently served to connected sims. */
386
+ servedRuntimes?: string[]
387
+ }
388
+
389
+ const DAEMON_LOCKFILE_MAX_BYTES = 16 * 1024
390
+
391
+ export function readDaemonLockfile(): DaemonLockfile | null {
392
+ try {
393
+ // cap the read so a junk/large file on the lockfile path can't OOM
394
+ // the CLI when someone has been messing with ~/.sootsim/.
395
+ const fd = fs.openSync(daemonLockfilePath(), 'r')
396
+ try {
397
+ const buf = Buffer.alloc(DAEMON_LOCKFILE_MAX_BYTES)
398
+ const bytesRead = fs.readSync(fd, buf, 0, DAEMON_LOCKFILE_MAX_BYTES, 0)
399
+ const raw = buf.subarray(0, bytesRead).toString('utf8')
400
+ const parsed = JSON.parse(raw) as Partial<DaemonLockfile>
401
+ if (
402
+ parsed &&
403
+ parsed.schema === 1 &&
404
+ typeof parsed.pid === 'number' &&
405
+ typeof parsed.bridgePort === 'number' &&
406
+ typeof parsed.runtimePort === 'number' &&
407
+ typeof parsed.startedAt === 'number' &&
408
+ typeof parsed.heartbeatAt === 'number' &&
409
+ (parsed.servedRuntimes === undefined ||
410
+ (Array.isArray(parsed.servedRuntimes) &&
411
+ parsed.servedRuntimes.every((version) => typeof version === 'string')))
412
+ ) {
413
+ return parsed as DaemonLockfile
414
+ }
415
+ return null
416
+ } finally {
417
+ fs.closeSync(fd)
418
+ }
419
+ } catch {
420
+ return null
421
+ }
422
+ }
423
+
424
+ export function readDevBridgeLockfile(): DevBridgeLockfile | null {
425
+ try {
426
+ const fd = fs.openSync(devBridgeLockfilePath(), 'r')
427
+ try {
428
+ const buf = Buffer.alloc(DAEMON_LOCKFILE_MAX_BYTES)
429
+ const bytesRead = fs.readSync(fd, buf, 0, DAEMON_LOCKFILE_MAX_BYTES, 0)
430
+ const raw = buf.subarray(0, bytesRead).toString('utf8')
431
+ const parsed = JSON.parse(raw) as Partial<DevBridgeLockfile>
432
+ if (
433
+ parsed &&
434
+ parsed.schema === 1 &&
435
+ parsed.source === 'vite-dev' &&
436
+ typeof parsed.pid === 'number' &&
437
+ typeof parsed.bridgePort === 'number' &&
438
+ typeof parsed.runtimePort === 'number' &&
439
+ typeof parsed.cwd === 'string' &&
440
+ typeof parsed.startedAt === 'number' &&
441
+ typeof parsed.heartbeatAt === 'number' &&
442
+ (parsed.shellPort === undefined || typeof parsed.shellPort === 'number') &&
443
+ (parsed.servedRuntimes === undefined ||
444
+ (Array.isArray(parsed.servedRuntimes) &&
445
+ parsed.servedRuntimes.every((version) => typeof version === 'string')))
446
+ ) {
447
+ return parsed as DevBridgeLockfile
448
+ }
449
+ return null
450
+ } finally {
451
+ fs.closeSync(fd)
452
+ }
453
+ } catch {
454
+ return null
455
+ }
456
+ }
457
+
458
+ /** true when the lockfile exists, the named pid is alive, and the heartbeat
459
+ * is recent. callers should reach for this before trusting any of the
460
+ * ports inside.
461
+ *
462
+ * pid-reuse note: `process.kill(pid, 0)` only checks that *some* process
463
+ * with that pid exists. after a reboot or heavy fork churn the OS can
464
+ * recycle the pid onto an unrelated process owned by the same user. the
465
+ * heartbeat freshness check catches most of that (30s stale ⇒ reject),
466
+ * but a stale lockfile where pid happens to be reused < 30s ago could
467
+ * still slip through. the consumer paths that actually connect (electron
468
+ * + ws-bridge client) time out quickly, so a stale lockfile degrades to
469
+ * "connect attempt fails" rather than corrupt state. */
470
+ export function isDaemonLockfileFresh(
471
+ lock: DaemonLockfile | null,
472
+ now = Date.now(),
473
+ ): lock is DaemonLockfile {
474
+ if (!lock) return false
475
+ if (now - lock.heartbeatAt > DAEMON_HEARTBEAT_STALE_MS) return false
476
+ try {
477
+ // signal 0 just tests whether the pid exists + we have perm to signal.
478
+ process.kill(lock.pid, 0)
479
+ return true
480
+ } catch {
481
+ return false
482
+ }
483
+ }
484
+
485
+ export function isDevBridgeLockfileFresh(
486
+ lock: DevBridgeLockfile | null,
487
+ now = Date.now(),
488
+ ): lock is DevBridgeLockfile {
489
+ if (!lock) return false
490
+ if (now - lock.heartbeatAt > DAEMON_HEARTBEAT_STALE_MS) return false
491
+ try {
492
+ process.kill(lock.pid, 0)
493
+ return true
494
+ } catch {
495
+ return false
496
+ }
497
+ }
498
+
499
+ export function readLiveRuntimeVersions(): string[] {
500
+ const versions = new Set<string>()
501
+ const daemon = readDaemonLockfile()
502
+ if (isDaemonLockfileFresh(daemon)) {
503
+ if (daemon.activeRuntime) versions.add(daemon.activeRuntime)
504
+ for (const version of daemon.servedRuntimes ?? []) versions.add(version)
505
+ }
506
+ const devBridge = readDevBridgeLockfile()
507
+ if (isDevBridgeLockfileFresh(devBridge)) {
508
+ for (const version of devBridge.servedRuntimes ?? []) versions.add(version)
509
+ }
510
+ return [...versions].sort()
511
+ }
512
+
513
+ export function writeDaemonLockfile(data: DaemonLockfile): void {
514
+ ensureSootsimHome()
515
+ const tmp = `${daemonLockfilePath()}.tmp`
516
+ fs.writeFileSync(tmp, `${JSON.stringify(data, null, 2)}\n`, 'utf8')
517
+ fs.renameSync(tmp, daemonLockfilePath())
518
+ }
519
+
520
+ export function writeDevBridgeLockfile(data: DevBridgeLockfile): void {
521
+ ensureSootsimHome()
522
+ const tmp = `${devBridgeLockfilePath()}.tmp`
523
+ fs.writeFileSync(tmp, `${JSON.stringify(data, null, 2)}\n`, 'utf8')
524
+ fs.renameSync(tmp, devBridgeLockfilePath())
525
+ }
526
+
527
+ /** try to claim the lockfile atomically on daemon boot. returns true if
528
+ * we now own it. returns false when another fresh daemon beat us to the
529
+ * punch — callers should refuse to start. a stale lockfile (dead pid or
530
+ * old heartbeat) is overwritten. race-safe: the final fs.renameSync is
531
+ * atomic on POSIX and on NTFS. */
532
+ export function claimDaemonLockfile(data: DaemonLockfile): boolean {
533
+ ensureSootsimHome()
534
+ const existing = readDaemonLockfile()
535
+ if (existing && isDaemonLockfileFresh(existing) && existing.pid !== data.pid) {
536
+ return false
537
+ }
538
+ writeDaemonLockfile(data)
539
+ return true
540
+ }
541
+
542
+ export function removeDaemonLockfile(): void {
543
+ try {
544
+ fs.unlinkSync(daemonLockfilePath())
545
+ } catch {}
546
+ }
547
+
548
+ export function removeDevBridgeLockfile(): void {
549
+ try {
550
+ fs.unlinkSync(devBridgeLockfilePath())
551
+ } catch {}
552
+ }