sootsim 0.1.83 → 0.1.84

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 (207) hide show
  1. package/README.md +0 -1
  2. package/detox/colors.ts +54 -0
  3. package/detox/config-loader.ts +135 -0
  4. package/detox/expectations.ts +477 -0
  5. package/detox/gestures.ts +442 -0
  6. package/detox/index.ts +1436 -0
  7. package/detox/jest-environment.ts +86 -0
  8. package/detox/jest-preset.cjs +50 -0
  9. package/detox/matchers.ts +29 -0
  10. package/detox/navigation.ts +43 -0
  11. package/detox/run-test.ts +113 -0
  12. package/detox/screenshots/animated-color-test-rest-norngh.png +0 -0
  13. package/detox/screenshots/color-test-after-drag-norngh.png +0 -0
  14. package/detox/screenshots/color-test-rest-norngh.png +0 -0
  15. package/detox/screenshots/theme-blue-toggle.png +0 -0
  16. package/detox/screenshots/theme-blue.png +0 -0
  17. package/detox/screenshots/theme-red-toggle.png +0 -0
  18. package/detox/screenshots/theme-red.png +0 -0
  19. package/dist-cli/bin.js +3 -3
  20. package/dist-cli/chunks/{agent-MQ7GLVIB.js → agent-2CWD6W6P.js} +2 -2
  21. package/dist-cli/chunks/{agent-wrapper-7KAFDQCN.js → agent-wrapper-5W3LOX6S.js} +2 -2
  22. package/dist-cli/chunks/{assert-TV46GUNU.js → assert-ZOMAMKRT.js} +2 -2
  23. package/dist-cli/chunks/auto-bootstrap-NYYSMTIM.js +2 -0
  24. package/dist-cli/chunks/beta-4K2SQACK.js +2 -0
  25. package/dist-cli/chunks/chunk-3HXQ7MJK.js +79 -0
  26. package/dist-cli/chunks/{chunk-4LS5MZAI.js → chunk-4K7BH2D4.js} +3 -3
  27. package/dist-cli/chunks/{chunk-FJYT7XL2.js → chunk-4OPRODFA.js} +2 -2
  28. package/dist-cli/chunks/{chunk-DP7O5MHK.js → chunk-4OWVPRZV.js} +2 -2
  29. package/dist-cli/chunks/{chunk-PM5NVKLP.js → chunk-5XCXOLG2.js} +2 -2
  30. package/dist-cli/chunks/chunk-67ZZ2CM5.js +1 -0
  31. package/dist-cli/chunks/{chunk-WN7M3QON.js → chunk-73UZXB4B.js} +2 -2
  32. package/dist-cli/chunks/{chunk-5DJXZIFZ.js → chunk-7NWNTUJF.js} +1 -1
  33. package/dist-cli/chunks/{chunk-Y2VJBRSP.js → chunk-7YHDJLO2.js} +1 -1
  34. package/dist-cli/chunks/{chunk-6NN2D4EJ.js → chunk-AJVTY6KY.js} +1 -1
  35. package/dist-cli/chunks/chunk-AWSQUOAS.js +67 -0
  36. package/dist-cli/chunks/{chunk-CJY3AVI7.js → chunk-BCBNVJVG.js} +1 -1
  37. package/dist-cli/chunks/{chunk-OYMFNU3M.js → chunk-BKBL6K2G.js} +1 -1
  38. package/dist-cli/chunks/{chunk-IBNRRAES.js → chunk-C3DPQZ4J.js} +2 -2
  39. package/dist-cli/chunks/chunk-D3ZSBIIY.js +2 -0
  40. package/dist-cli/chunks/{chunk-2AWQ7OB2.js → chunk-D4HUVLZR.js} +1 -1
  41. package/dist-cli/chunks/{chunk-F3HP444U.js → chunk-DUUSJDES.js} +1 -1
  42. package/dist-cli/chunks/{chunk-277XAALA.js → chunk-ELJLF4SG.js} +3 -3
  43. package/dist-cli/chunks/{chunk-RH4F2TF7.js → chunk-EQ7TFQ2F.js} +1 -1
  44. package/dist-cli/chunks/{chunk-HNWEELAE.js → chunk-EQCKGC4B.js} +1 -1
  45. package/dist-cli/chunks/chunk-FUCGLWNN.js +1 -0
  46. package/dist-cli/chunks/{chunk-FRM355UL.js → chunk-HYPJW65U.js} +2 -2
  47. package/dist-cli/chunks/chunk-IILJQCZA.js +2 -0
  48. package/dist-cli/chunks/{chunk-Y4BUVURT.js → chunk-KU6MSPAH.js} +2 -2
  49. package/dist-cli/chunks/{chunk-DM6WT7QM.js → chunk-OOOR7NT2.js} +1 -1
  50. package/dist-cli/chunks/{chunk-HAWOAQAG.js → chunk-P7WDNKOS.js} +3 -3
  51. package/dist-cli/chunks/{chunk-6TNANCQC.js → chunk-PPKKA5VW.js} +2 -2
  52. package/dist-cli/chunks/{chunk-JQ7ZXOXJ.js → chunk-PS2G44GT.js} +2 -2
  53. package/dist-cli/chunks/{chunk-ECJBV65H.js → chunk-QMSJR5R2.js} +2 -2
  54. package/dist-cli/chunks/{chunk-J2GYISVJ.js → chunk-RF4R2U46.js} +2 -2
  55. package/dist-cli/chunks/{chunk-VMXWC2JO.js → chunk-RIXUH3NK.js} +2 -2
  56. package/dist-cli/chunks/{chunk-2PY3UZVO.js → chunk-SFGUPL2X.js} +2 -2
  57. package/dist-cli/chunks/{chunk-572VSFNP.js → chunk-SQX5CAYG.js} +1 -1
  58. package/dist-cli/chunks/{chunk-NXATOWWF.js → chunk-SQZAC7C4.js} +1 -1
  59. package/dist-cli/chunks/{chunk-WTKTOL3C.js → chunk-SV7FOGJ3.js} +2 -2
  60. package/dist-cli/chunks/{chunk-JHJNODXN.js → chunk-TK3OJSEO.js} +2 -2
  61. package/dist-cli/chunks/{chunk-KASUZ5XV.js → chunk-TL7SIZ7S.js} +1 -1
  62. package/dist-cli/chunks/{chunk-6XZOEBTZ.js → chunk-V2GQ4WXJ.js} +2 -2
  63. package/dist-cli/chunks/{chunk-IP3QJLRH.js → chunk-VH7F45CN.js} +1 -1
  64. package/dist-cli/chunks/chunk-WNVNU2OW.js +4 -0
  65. package/dist-cli/chunks/{chunk-YUELRHGB.js → chunk-XQ2OBHBE.js} +2 -2
  66. package/dist-cli/chunks/{chunk-CYV6Y6YV.js → chunk-YCIA4BHJ.js} +2 -2
  67. package/dist-cli/chunks/chunk-ZSMMJMPA.js +1 -0
  68. package/dist-cli/chunks/cli-version-QB4VH24H.js +2 -0
  69. package/dist-cli/chunks/{compat-QLLWBTS3.js → compat-FWSEEGEH.js} +3 -3
  70. package/dist-cli/chunks/{config-2DSLDCXV.js → config-CYI2WAGP.js} +2 -2
  71. package/dist-cli/chunks/control-UXY7YQVX.js +2 -0
  72. package/dist-cli/chunks/{cpu-profile-GEIKHCPC.js → cpu-profile-IKAE3KTY.js} +2 -2
  73. package/dist-cli/chunks/{daemon-4EBUFN4D.js → daemon-ZUMF53YB.js} +2 -2
  74. package/dist-cli/chunks/{debug-WGD6XWOF.js → debug-P6KULKKS.js} +3 -3
  75. package/dist-cli/chunks/{detox-LNKGRZU6.js → detox-SPWAZCYG.js} +2 -2
  76. package/dist-cli/chunks/{device-AYKXKVIQ.js → device-JWEPK6I2.js} +2 -2
  77. package/dist-cli/chunks/{diagnose-TMXSDOOC.js → diagnose-IZODTXV2.js} +2 -2
  78. package/dist-cli/chunks/drivers-MK6WJKBC.js +2 -0
  79. package/dist-cli/chunks/{electron-QFPF7TBY.js → electron-R5GP6RVB.js} +3 -3
  80. package/dist-cli/chunks/flow-6O4GEOPJ.js +2 -0
  81. package/dist-cli/chunks/{hints-MXKRR4TG.js → hints-DYDNYX7N.js} +2 -2
  82. package/dist-cli/chunks/{home-paths-REMWQDAO.js → home-paths-GLMX5OKL.js} +2 -2
  83. package/dist-cli/chunks/{inspect-XGSQNFV7.js → inspect-FJOPCTY2.js} +3 -3
  84. package/dist-cli/chunks/install-A3TUGGHN.js +2 -0
  85. package/dist-cli/chunks/{install-desktop-NQG3RZSA.js → install-desktop-YPJZMZM5.js} +3 -3
  86. package/dist-cli/chunks/{keys-5QZWXL3F.js → keys-GSYPHWNY.js} +2 -2
  87. package/dist-cli/chunks/{launch-SBXOZWKO.js → launch-4G2PKW5X.js} +3 -3
  88. package/dist-cli/chunks/{login-EACQXE24.js → login-KJQGHA64.js} +4 -4
  89. package/dist-cli/chunks/{logout-IBQLMUML.js → logout-XM2SYH5C.js} +2 -2
  90. package/dist-cli/chunks/{maestro-LFYXUX7O.js → maestro-EOWGI7DG.js} +2 -2
  91. package/dist-cli/chunks/{preview-U4SBOEGQ.js → preview-F73TKK37.js} +2 -2
  92. package/dist-cli/chunks/{profile-GWS5ECMY.js → profile-22FDKBUO.js} +2 -2
  93. package/dist-cli/chunks/{react-QDHLMVYL.js → react-5L6VPFUP.js} +2 -2
  94. package/dist-cli/chunks/{record-BUEUWPDI.js → record-JZXCQ4IN.js} +2 -2
  95. package/dist-cli/chunks/runtime-EEBX7CFV.js +2 -0
  96. package/dist-cli/chunks/{runtime-delivery-G7L6RVZ7.js → runtime-delivery-LXUM3R4A.js} +2 -2
  97. package/dist-cli/chunks/{screenshot-T2HBA3VI.js → screenshot-HDRRG33Q.js} +2 -2
  98. package/dist-cli/chunks/{screenshot-mode-EG5HMIH3.js → screenshot-mode-WY63LZIX.js} +2 -2
  99. package/dist-cli/chunks/{screenshots-S52AFHTV.js → screenshots-MPV2ENL5.js} +2 -2
  100. package/dist-cli/chunks/{server-MFFVYUGG.js → server-5LBMCJ3G.js} +2 -2
  101. package/dist-cli/chunks/setup-repo-SZSYNKNI.js +2 -0
  102. package/dist-cli/chunks/{skills-HQGWBS2O.js → skills-BQ73YOBF.js} +2 -2
  103. package/dist-cli/chunks/{start-E3DRYY7W.js → start-2WU4W6ZU.js} +4 -4
  104. package/dist-cli/chunks/store-RE45SUBF.js +2 -0
  105. package/dist-cli/chunks/telemetry-DG6GJLCP.js +2 -0
  106. package/dist-cli/chunks/{test-ZY3EF62K.js → test-OVO4CQTG.js} +3 -3
  107. package/dist-cli/chunks/{three-mode-WSPKQCJ5.js → three-mode-BKM3KFM7.js} +2 -2
  108. package/dist-cli/chunks/{timeline-3XAB5EWZ.js → timeline-MDXGEDQL.js} +2 -2
  109. package/dist-cli/chunks/{upgrade-WNENPFM5.js → upgrade-JGQABWVF.js} +2 -2
  110. package/dist-cli/chunks/upload-UJNUA4ZV.js +2 -0
  111. package/dist-cli/chunks/{web-D2AOZY44.js → web-WYFAYQ72.js} +2 -2
  112. package/dist-cli/chunks/{what-happened-F43KNSG6.js → what-happened-PZW2KW6A.js} +2 -2
  113. package/dist-cli/chunks/{whoami-T22VBR7C.js → whoami-7ATWJQS6.js} +2 -2
  114. package/dist-lib/agent-daemon-client.cjs +1 -1
  115. package/dist-lib/agent-events.cjs +1 -1
  116. package/dist-lib/agent-sessions.cjs +1 -1
  117. package/dist-lib/attached-projects.cjs +1 -1
  118. package/dist-lib/auth/shared-session.cjs +1 -1
  119. package/dist-lib/backend-origin.cjs +1 -1
  120. package/dist-lib/beta.cjs +44 -0
  121. package/dist-lib/bridge-constants.cjs +1 -1
  122. package/dist-lib/cli-constants.cjs +1 -1
  123. package/dist-lib/config.cjs +1 -1
  124. package/dist-lib/detox/index.cjs +1770 -0
  125. package/dist-lib/detox/jest-preset.cjs +50 -0
  126. package/dist-lib/dev-bundle-resolution.cjs +1 -1
  127. package/dist-lib/home-paths.cjs +1 -1
  128. package/dist-lib/host/bridge-host.cjs +1 -1
  129. package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
  130. package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
  131. package/dist-lib/index.cjs +1 -1
  132. package/dist-lib/metro.cjs +1 -1
  133. package/dist-lib/profiles.cjs +1 -1
  134. package/dist-lib/render-mode.cjs +1 -1
  135. package/dist-lib/scripts/demo-app-registry.cjs +809 -0
  136. package/dist-lib/scripts/dev-server-scanner.cjs +1269 -0
  137. package/dist-lib/skills.cjs +8322 -0
  138. package/dist-lib/vite-base.cjs +3 -3
  139. package/dist-lib/vite.cjs +1 -1
  140. package/package.json +39 -10
  141. package/scripts/demo-app-registry.ts +989 -0
  142. package/scripts/dev-server-scanner.ts +674 -0
  143. package/src/agent-daemon-client.ts +390 -0
  144. package/src/agent-events.ts +71 -0
  145. package/src/agent-prompt.ts +71 -0
  146. package/src/agent-sessions.ts +572 -0
  147. package/src/attached-projects.ts +536 -0
  148. package/src/auth/shared-session.ts +199 -0
  149. package/src/backend-origin.ts +49 -0
  150. package/src/beta.ts +21 -0
  151. package/src/bridge-constants.ts +10 -0
  152. package/src/cli-constants.ts +1 -0
  153. package/src/cli-version.ts +30 -0
  154. package/src/codex-client.ts +215 -0
  155. package/src/config.ts +110 -0
  156. package/src/dev-bundle-resolution.ts +180 -0
  157. package/src/home-paths.ts +382 -0
  158. package/src/host/agent-host.ts +576 -0
  159. package/src/host/bridge-host.ts +2293 -0
  160. package/src/host/fetch-proxy-handler.ts +288 -0
  161. package/src/host/fetch-proxy-overrides.ts +39 -0
  162. package/src/host/open-url.ts +234 -0
  163. package/src/index.ts +9 -0
  164. package/src/metro-plugin.ts +207 -0
  165. package/src/native-dev-bundle-url.ts +62 -0
  166. package/src/native-seam-manifest.ts +313 -0
  167. package/src/profiles.ts +179 -0
  168. package/src/render-mode.ts +27 -0
  169. package/src/runtime-delivery.ts +334 -0
  170. package/src/screenshots/compose.ts +422 -0
  171. package/src/screenshots/frame-compose.ts +438 -0
  172. package/src/screenshots/orchestrate.ts +244 -0
  173. package/src/screenshots/registry.ts +58 -0
  174. package/src/screenshots/schema.ts +364 -0
  175. package/src/skills/builtin/a11y-review.ts +126 -0
  176. package/src/skills/builtin/compat-check.ts +104 -0
  177. package/src/skills/builtin/perf-profile.ts +84 -0
  178. package/src/skills/builtin/screenshot-all.ts +46 -0
  179. package/src/skills/builtin/test-flow.ts +118 -0
  180. package/src/skills/builtin/visual-diff.ts +94 -0
  181. package/src/skills/registry.ts +107 -0
  182. package/src/skills/types.ts +41 -0
  183. package/src/vite-plugin-one.ts +187 -0
  184. package/src/vite-plugin.ts +1381 -0
  185. package/src/worklets-babel.ts +132 -0
  186. package/dist-cli/chunks/auto-bootstrap-FQS4ZD2K.js +0 -2
  187. package/dist-cli/chunks/beta-VG7CDY2U.js +0 -2
  188. package/dist-cli/chunks/chunk-2OIBDYHW.js +0 -1
  189. package/dist-cli/chunks/chunk-6BNLVMXA.js +0 -1
  190. package/dist-cli/chunks/chunk-6XD6CBJM.js +0 -2
  191. package/dist-cli/chunks/chunk-CHQTO426.js +0 -1
  192. package/dist-cli/chunks/chunk-FAPYGVIU.js +0 -4
  193. package/dist-cli/chunks/chunk-PEHFE3LG.js +0 -64
  194. package/dist-cli/chunks/chunk-RXH2SLKF.js +0 -2
  195. package/dist-cli/chunks/chunk-UXQWC5ZR.js +0 -79
  196. package/dist-cli/chunks/chunk-XFQL74PF.js +0 -5
  197. package/dist-cli/chunks/cli-version-PWF6I6LY.js +0 -2
  198. package/dist-cli/chunks/control-UIOXGYXU.js +0 -2
  199. package/dist-cli/chunks/demo-app-registry-G3BDOFWC.js +0 -2
  200. package/dist-cli/chunks/drivers-IDQF34HP.js +0 -2
  201. package/dist-cli/chunks/flow-3JN3Y7RF.js +0 -2
  202. package/dist-cli/chunks/install-2N3YOOSN.js +0 -2
  203. package/dist-cli/chunks/runtime-PVB4VGUH.js +0 -2
  204. package/dist-cli/chunks/setup-repo-YOF7NV5D.js +0 -2
  205. package/dist-cli/chunks/store-MAI6D3UO.js +0 -2
  206. package/dist-cli/chunks/telemetry-RCQKCJTH.js +0 -2
  207. package/dist-cli/chunks/upload-YLJ4RA73.js +0 -2
@@ -0,0 +1,180 @@
1
+ import { isLoopbackHost } from './backend-origin'
2
+ import { METRO_BUNDLE_PATH, normalizeNativeDevBundleUrl } from './native-dev-bundle-url'
3
+
4
+ export const DEFAULT_TEST_DRIVE_PORT = 8081
5
+
6
+ export interface ResolvedDevBundle {
7
+ bundleUrl: string
8
+ port: number
9
+ framework: string
10
+ projectName?: string
11
+ }
12
+
13
+ function buildUnresolvedTargetError(targetLabel: string): Error {
14
+ return new Error(
15
+ `could not resolve a native bundle for ${targetLabel}. pass an explicit bundle URL or open Connect and choose the app there.`,
16
+ )
17
+ }
18
+
19
+ export function inferManifestFramework(
20
+ launchUrl: string | undefined,
21
+ sdkVersion: unknown,
22
+ ): string {
23
+ if (launchUrl?.includes('/one/metro-entry.bundle')) return 'one'
24
+ if (typeof sdkVersion === 'string' && sdkVersion) return 'expo'
25
+ return 'unknown'
26
+ }
27
+
28
+ function buildBaseUrl(protocol: string, host: string, port: number) {
29
+ return `${protocol}//${host}:${port}`
30
+ }
31
+
32
+ function shouldProxyLoopbackProbe(targetUrl: string) {
33
+ if (typeof window === 'undefined') return false
34
+ try {
35
+ const parsed = new URL(targetUrl)
36
+ return (
37
+ isLoopbackHost(parsed.hostname) &&
38
+ isLoopbackHost(window.location.hostname) &&
39
+ parsed.origin !== window.location.origin
40
+ )
41
+ } catch {
42
+ return false
43
+ }
44
+ }
45
+
46
+ async function fetchDevProbe(targetUrl: string, init?: RequestInit): Promise<Response> {
47
+ const requestInit: RequestInit = {
48
+ ...init,
49
+ cache: init?.cache ?? 'no-store',
50
+ }
51
+ if (!shouldProxyLoopbackProbe(targetUrl)) {
52
+ return fetch(targetUrl, requestInit)
53
+ }
54
+ return fetch(`/__fetch-proxy?url=${encodeURIComponent(targetUrl)}`, requestInit)
55
+ }
56
+
57
+ function getDefaultPortForProtocol(protocol: string) {
58
+ return protocol === 'https:' ? 443 : 80
59
+ }
60
+
61
+ function isBaseServerUrl(url: URL) {
62
+ const path = url.pathname || '/'
63
+ return (path === '/' || path === '') && !url.search && !url.hash
64
+ }
65
+
66
+ async function probeBaseUrlBundle(
67
+ baseUrl: string,
68
+ port: number,
69
+ ): Promise<ResolvedDevBundle | null> {
70
+ const normalizedBaseUrl = baseUrl.replace(/\/+$/, '')
71
+
72
+ try {
73
+ const manifestRes = await fetchDevProbe(`${normalizedBaseUrl}/`, {
74
+ headers: { 'expo-platform': 'ios' },
75
+ })
76
+ if (manifestRes.ok) {
77
+ const manifest = await manifestRes.json()
78
+ const client = manifest?.extra?.expoClient || manifest?.extra || {}
79
+ const launchUrl =
80
+ typeof manifest?.launchAsset?.url === 'string'
81
+ ? manifest.launchAsset.url
82
+ : undefined
83
+ if (launchUrl || client.name) {
84
+ return {
85
+ bundleUrl: normalizeNativeDevBundleUrl(
86
+ launchUrl || `${normalizedBaseUrl}${METRO_BUNDLE_PATH}`,
87
+ ),
88
+ port,
89
+ framework: inferManifestFramework(launchUrl, client.sdkVersion),
90
+ projectName: client.name,
91
+ }
92
+ }
93
+ }
94
+ } catch {}
95
+
96
+ // vanilla Metro (or any non-Expo dev server) — packager-status:running on
97
+ // /status is the canonical signature. we previously also did `HEAD
98
+ // /node_modules/one/metro-entry.bundle` as a framework discriminator, but
99
+ // Metro lazy-resolves modules at request time, so HEAD on *any* `.bundle`
100
+ // path can return 200 even when the underlying file doesn't exist. that
101
+ // caused false-positive One framework detection on non-One apps that had
102
+ // `node_modules/one` anywhere on disk (or that returned 200 HEAD + 404
103
+ // GET for missing bundles), surfacing as
104
+ // "failed to fetch bundle (404|502): .../node_modules/one/metro-entry.bundle…"
105
+ // the Expo manifest probe above already returns the correct launchAsset.url
106
+ // for One apps (which serve the Expo manifest format via vxrn), so we
107
+ // drop the bespoke One probe entirely.
108
+ try {
109
+ const statusRes = await fetchDevProbe(`${normalizedBaseUrl}/status`)
110
+ if (statusRes.ok) {
111
+ const text = await statusRes.text()
112
+ if (text.includes('packager-status:running')) {
113
+ return {
114
+ bundleUrl: `${normalizedBaseUrl}${METRO_BUNDLE_PATH}`,
115
+ port,
116
+ framework: 'metro',
117
+ }
118
+ }
119
+ }
120
+ } catch {}
121
+
122
+ return null
123
+ }
124
+
125
+ export async function probePortBundle(port: number): Promise<ResolvedDevBundle | null> {
126
+ return probeBaseUrlBundle(buildBaseUrl('http:', 'localhost', port), port)
127
+ }
128
+
129
+ export async function resolveConnectionInput(input: string): Promise<ResolvedDevBundle> {
130
+ const trimmed = input.trim()
131
+
132
+ if (/^\d+$/.test(trimmed)) {
133
+ const port = parseInt(trimmed, 10)
134
+ const resolved = await probePortBundle(port)
135
+ if (resolved) return resolved
136
+ // soot's demo launcher allocates ports via `preferredPort` + shift if the
137
+ // preferred port is in use, so a flow YAML that hardcodes `app: 8081`
138
+ // breaks the day someone else binds 8081 first. probe a small window of
139
+ // higher ports for a metro server before giving up. only the *first* hit
140
+ // is used — we never scan past 3 to keep the search bounded and avoid
141
+ // grabbing a completely unrelated dev server.
142
+ const SHIFT_WINDOW = 3
143
+ for (let offset = 1; offset <= SHIFT_WINDOW; offset++) {
144
+ const shifted = await probePortBundle(port + offset)
145
+ if (shifted) return shifted
146
+ }
147
+ throw buildUnresolvedTargetError(
148
+ `localhost:${port} (also scanned +1..+${SHIFT_WINDOW})`,
149
+ )
150
+ }
151
+
152
+ const bundleUrl = trimmed.startsWith('http') ? trimmed : `http://${trimmed}`
153
+ let parsed: URL
154
+
155
+ try {
156
+ parsed = new URL(bundleUrl)
157
+ } catch {
158
+ throw new Error(
159
+ `could not parse "${input}". pass a dev-server port, a dev-server base URL, or a full bundle URL.`,
160
+ )
161
+ }
162
+
163
+ const protocol = parsed.protocol || 'http:'
164
+ const port = parsed.port
165
+ ? parseInt(parsed.port, 10)
166
+ : getDefaultPortForProtocol(protocol)
167
+ const baseUrl = buildBaseUrl(protocol, parsed.hostname, port)
168
+
169
+ // a bare base URL (just an origin, no path) is unambiguously a dev server
170
+ // to probe — regardless of host. probe its live manifest rather than
171
+ // guessing that the origin itself is the bundle URL. loopback vs. remote
172
+ // only affects how the probe fetch is routed (see shouldProxyLoopbackProbe).
173
+ if (isBaseServerUrl(parsed)) {
174
+ const resolved = await probeBaseUrlBundle(baseUrl, port)
175
+ if (resolved) return resolved
176
+ throw buildUnresolvedTargetError(baseUrl)
177
+ }
178
+
179
+ return { bundleUrl: parsed.toString(), port, framework: 'unknown' }
180
+ }
@@ -0,0 +1,382 @@
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
+ // └── config.json user prefs: update channel, cdn origin override
18
+
19
+ import fs from 'node:fs'
20
+ import { homedir } from 'node:os'
21
+ import path from 'node:path'
22
+
23
+ export const SOOTSIM_HOME_ENV = 'SOOTSIM_HOME'
24
+ export const ACTIVE_RUNTIME_FILE = 'active'
25
+ export const DAEMON_LOCKFILE = 'daemon.json'
26
+ export const CONFIG_FILE = 'config.json'
27
+ export const DAEMON_HEARTBEAT_STALE_MS = 30_000
28
+
29
+ export function sootsimHomeDir(): string {
30
+ const override = process.env[SOOTSIM_HOME_ENV]
31
+ if (override && override.length > 0) return path.resolve(override)
32
+ return path.join(homedir(), '.sootsim')
33
+ }
34
+
35
+ // detect when sootsim is running from a source checkout (the soot monorepo)
36
+ // rather than a published npm install. used to skip auto-install of the
37
+ // persistent launchd / systemd agent: dev shells shouldn't register an agent
38
+ // whose Program path points at workspace artifacts that change between
39
+ // sessions, and whose served engine assets are the stale prod build instead
40
+ // of the live `bun dev:sootsim` output.
41
+ //
42
+ // overrides:
43
+ // SOOTSIM_DEV=1 / SOOTSIM_DEV=0 force the answer
44
+ // SOOTSIM_FORCE_DAEMON_INSTALL=1 pretend prod even from a dev
45
+ // checkout, for exercising the
46
+ // install path from this repo
47
+ //
48
+ // signal: realpath of process.argv[1] lands inside a `packages/sootsim/`
49
+ // directory. workspace bin symlinks (`node_modules/.bin/sootsim` →
50
+ // `packages/sootsim/dist-cli/bin.js`) and bun-direct invocations
51
+ // (`bun packages/sootsim/cli/bin.ts ...`) both match; published installs
52
+ // resolve under `node_modules/sootsim/` instead.
53
+ export function isSootsimDevCheckout(): boolean {
54
+ if (process.env.SOOTSIM_FORCE_DAEMON_INSTALL === '1') return false
55
+ const env = process.env.SOOTSIM_DEV
56
+ if (env === '1' || env === 'true') return true
57
+ if (env === '0' || env === 'false') return false
58
+ const argv1 = process.argv[1]
59
+ if (!argv1) return false
60
+ try {
61
+ const real = fs.realpathSync(argv1)
62
+ return real.includes(`${path.sep}packages${path.sep}sootsim${path.sep}`)
63
+ } catch {
64
+ return false
65
+ }
66
+ }
67
+
68
+ export function runtimesDir(): string {
69
+ return path.join(sootsimHomeDir(), 'runtimes')
70
+ }
71
+
72
+ export function runtimeDir(version: string): string {
73
+ return path.join(runtimesDir(), version)
74
+ }
75
+
76
+ export function activeRuntimeFile(): string {
77
+ return path.join(runtimesDir(), ACTIVE_RUNTIME_FILE)
78
+ }
79
+
80
+ export function electronDir(): string {
81
+ return path.join(sootsimHomeDir(), 'electron')
82
+ }
83
+
84
+ export function electronUserDataDir(): string {
85
+ return path.join(electronDir(), 'userData')
86
+ }
87
+
88
+ export function electronVersionDir(version: string): string {
89
+ return path.join(electronDir(), version)
90
+ }
91
+
92
+ export function profilesDir(): string {
93
+ return path.join(sootsimHomeDir(), 'profiles')
94
+ }
95
+
96
+ // the launchd-managed daemon spawns ProgramArguments[0] directly, and macOS
97
+ // Background Task Management attributes the entry to whoever code-signed
98
+ // that binary. pointing launchd at bun directly makes Login Items say
99
+ // "software from Jarred Sumner" (bun's signer); wrapping the invocation in
100
+ // an ad-hoc-signed .app bundle here gives BTM a CFBundleDisplayName to
101
+ // read instead.
102
+ export function daemonAppDir(): string {
103
+ return path.join(sootsimHomeDir(), 'daemon-app')
104
+ }
105
+
106
+ export function daemonAppBundlePath(): string {
107
+ return path.join(daemonAppDir(), 'SootSim Daemon.app')
108
+ }
109
+
110
+ export function daemonAppLauncherPath(): string {
111
+ return path.join(daemonAppBundlePath(), 'Contents', 'MacOS', 'sootsim-daemon')
112
+ }
113
+
114
+ export function cacheDir(): string {
115
+ return path.join(sootsimHomeDir(), 'cache')
116
+ }
117
+
118
+ export function daemonLockfilePath(): string {
119
+ return path.join(sootsimHomeDir(), DAEMON_LOCKFILE)
120
+ }
121
+
122
+ export function configFilePath(): string {
123
+ return path.join(sootsimHomeDir(), CONFIG_FILE)
124
+ }
125
+
126
+ // --- shared sootsim config -----------------------------------------------
127
+ //
128
+ // ~/.sootsim/config.json is the single user-level config file every sootsim
129
+ // surface (cli, electron renderer, dev browser via electron ipc) agrees on.
130
+ //
131
+ // {
132
+ // "telemetry": true, // splash checkbox; cli + electron
133
+ // "settings": { // engine settings persisted across
134
+ // "onboardingComplete": true, // sessions. shape mirrors the
135
+ // "betaConsentVersion": 1, // PERSISTED_KEYS slice of
136
+ // "detailedTelemetry": false, // SettingsValues — see
137
+ // ... // sootsim-engine/settings/schema.ts
138
+ // }
139
+ // }
140
+ //
141
+ // telemetry default is opt-in (missing/corrupt config means "enabled") so a
142
+ // fresh install never silently drops error reports. settings default is empty
143
+ // (the engine's getDefaults() fills the rest).
144
+
145
+ export interface SharedConfig {
146
+ telemetry?: boolean
147
+ settings?: Record<string, unknown>
148
+ [key: string]: unknown
149
+ }
150
+
151
+ export function readSharedConfig(): SharedConfig {
152
+ try {
153
+ const raw = fs.readFileSync(configFilePath(), 'utf8')
154
+ const parsed = JSON.parse(raw) as SharedConfig
155
+ return parsed && typeof parsed === 'object' ? parsed : {}
156
+ } catch {
157
+ return {}
158
+ }
159
+ }
160
+
161
+ /** merge `patch` into the shared config and atomically write to disk. nested
162
+ * objects (currently just `settings`) are shallow-merged so partial writes
163
+ * don't clobber unrelated fields. returns the new full snapshot. */
164
+ export function writeSharedConfig(patch: Partial<SharedConfig>): SharedConfig {
165
+ ensureSootsimHome()
166
+ const current = readSharedConfig()
167
+ const next: SharedConfig = { ...current, ...patch }
168
+ if (patch.settings && typeof patch.settings === 'object') {
169
+ next.settings = {
170
+ ...(current.settings && typeof current.settings === 'object'
171
+ ? current.settings
172
+ : {}),
173
+ ...patch.settings,
174
+ }
175
+ }
176
+ const tmp = `${configFilePath()}.tmp`
177
+ fs.writeFileSync(tmp, `${JSON.stringify(next, null, 2)}\n`, 'utf8')
178
+ fs.renameSync(tmp, configFilePath())
179
+ return next
180
+ }
181
+
182
+ export function readTelemetryEnabled(): boolean {
183
+ return readSharedConfig().telemetry !== false
184
+ }
185
+
186
+ export function writeTelemetryEnabled(enabled: boolean): void {
187
+ writeSharedConfig({ telemetry: enabled })
188
+ }
189
+
190
+ export function ensureSootsimHome(): void {
191
+ fs.mkdirSync(sootsimHomeDir(), { recursive: true })
192
+ fs.mkdirSync(runtimesDir(), { recursive: true })
193
+ fs.mkdirSync(electronDir(), { recursive: true })
194
+ fs.mkdirSync(profilesDir(), { recursive: true })
195
+ fs.mkdirSync(cacheDir(), { recursive: true })
196
+ }
197
+
198
+ /** read the active runtime version string, or null if none is selected. */
199
+ export function readActiveRuntime(): string | null {
200
+ try {
201
+ const value = fs.readFileSync(activeRuntimeFile(), 'utf8').trim()
202
+ return value.length > 0 ? value : null
203
+ } catch {
204
+ return null
205
+ }
206
+ }
207
+
208
+ /** set the active runtime version. caller is responsible for verifying the
209
+ * version actually exists on disk before calling. */
210
+ export function writeActiveRuntime(version: string): void {
211
+ fs.mkdirSync(runtimesDir(), { recursive: true })
212
+ fs.writeFileSync(activeRuntimeFile(), `${version}\n`, 'utf8')
213
+ }
214
+
215
+ /** list installed runtime versions (directories under runtimes/). sorted
216
+ * ascending by semver when parseable, falling back to lexicographic for
217
+ * odd names. */
218
+ export function listInstalledRuntimes(): string[] {
219
+ try {
220
+ return fs
221
+ .readdirSync(runtimesDir(), { withFileTypes: true })
222
+ .filter((d) => d.isDirectory())
223
+ .map((d) => d.name)
224
+ .sort(compareSemver)
225
+ } catch {
226
+ return []
227
+ }
228
+ }
229
+
230
+ /** compare two version strings as coarse semver. treats pre-release
231
+ * tags as lower than release of the same major.minor.patch. callers
232
+ * that need strict semver compliance should use a dedicated library. */
233
+ export function compareSemver(a: string, b: string): number {
234
+ const parse = (v: string): [number[], string] => {
235
+ const hyphen = v.indexOf('-')
236
+ const core = hyphen >= 0 ? v.slice(0, hyphen) : v
237
+ const pre = hyphen >= 0 ? v.slice(hyphen + 1) : ''
238
+ const parts = core.split('.').map((n) => Number.parseInt(n, 10))
239
+ if (parts.some((n) => !Number.isFinite(n))) return [[Number.POSITIVE_INFINITY], v]
240
+ return [parts, pre]
241
+ }
242
+ const [an, ap] = parse(a)
243
+ const [bn, bp] = parse(b)
244
+ for (let i = 0; i < Math.max(an.length, bn.length); i++) {
245
+ const av = an[i] ?? 0
246
+ const bv = bn[i] ?? 0
247
+ if (av !== bv) return av - bv
248
+ }
249
+ // pre-release of same core sorts lower than release (empty pre).
250
+ if (ap === bp) return 0
251
+ if (!ap) return 1
252
+ if (!bp) return -1
253
+ return ap < bp ? -1 : 1
254
+ }
255
+
256
+ /** absolute path to the active runtime's directory, or null if none active
257
+ * or the active version is no longer installed. */
258
+ export function activeRuntimeDir(): string | null {
259
+ const version = readActiveRuntime()
260
+ if (!version) return null
261
+ const dir = runtimeDir(version)
262
+ try {
263
+ if (fs.statSync(dir).isDirectory()) return dir
264
+ } catch {}
265
+ return null
266
+ }
267
+
268
+ // --- daemon lockfile ----------------------------------------------------
269
+
270
+ export interface DaemonLockfile {
271
+ /** sootsim cli/daemon version that wrote the lockfile. bumped whenever
272
+ * the lockfile shape changes so readers can gate on it. */
273
+ schema: 1
274
+ pid: number
275
+ /** platform — useful when one home dir is shared across platforms via NFS. */
276
+ platform: NodeJS.Platform
277
+ /** ws bridge port (where cli + electron open control connections). */
278
+ bridgePort: number
279
+ /** http runtime server port (where electron loads the renderer from). */
280
+ runtimePort: number
281
+ /** active runtime version at boot, or null if the daemon booted with no
282
+ * runtime installed. updated live when the user runs `sootsim runtime use`. */
283
+ activeRuntime: string | null
284
+ /** absolute path to the active runtime's dist directory, or null. */
285
+ activeRuntimeDir: string | null
286
+ /** epoch-ms of daemon start. */
287
+ startedAt: number
288
+ /** epoch-ms of last heartbeat. daemons update this every ~5s; readers
289
+ * treat the lockfile as stale if now - heartbeatAt > DAEMON_HEARTBEAT_STALE_MS. */
290
+ heartbeatAt: number
291
+ /** true while the daemon is still fetching/activating its runtime on first
292
+ * boot. clients (electron splash, cli) should wait for this to become
293
+ * false before treating the daemon as ready to serve. */
294
+ bootstrapping?: boolean
295
+ }
296
+
297
+ const DAEMON_LOCKFILE_MAX_BYTES = 16 * 1024
298
+
299
+ export function readDaemonLockfile(): DaemonLockfile | null {
300
+ try {
301
+ // cap the read so a junk/large file on the lockfile path can't OOM
302
+ // the CLI when someone has been messing with ~/.sootsim/.
303
+ const fd = fs.openSync(daemonLockfilePath(), 'r')
304
+ try {
305
+ const buf = Buffer.alloc(DAEMON_LOCKFILE_MAX_BYTES)
306
+ const bytesRead = fs.readSync(fd, buf, 0, DAEMON_LOCKFILE_MAX_BYTES, 0)
307
+ const raw = buf.subarray(0, bytesRead).toString('utf8')
308
+ const parsed = JSON.parse(raw) as Partial<DaemonLockfile>
309
+ if (
310
+ parsed &&
311
+ parsed.schema === 1 &&
312
+ typeof parsed.pid === 'number' &&
313
+ typeof parsed.bridgePort === 'number' &&
314
+ typeof parsed.runtimePort === 'number' &&
315
+ typeof parsed.startedAt === 'number' &&
316
+ typeof parsed.heartbeatAt === 'number'
317
+ ) {
318
+ return parsed as DaemonLockfile
319
+ }
320
+ return null
321
+ } finally {
322
+ fs.closeSync(fd)
323
+ }
324
+ } catch {
325
+ return null
326
+ }
327
+ }
328
+
329
+ /** true when the lockfile exists, the named pid is alive, and the heartbeat
330
+ * is recent. callers should reach for this before trusting any of the
331
+ * ports inside.
332
+ *
333
+ * pid-reuse note: `process.kill(pid, 0)` only checks that *some* process
334
+ * with that pid exists. after a reboot or heavy fork churn the OS can
335
+ * recycle the pid onto an unrelated process owned by the same user. the
336
+ * heartbeat freshness check catches most of that (30s stale ⇒ reject),
337
+ * but a stale lockfile where pid happens to be reused < 30s ago could
338
+ * still slip through. the consumer paths that actually connect (electron
339
+ * + ws-bridge client) time out quickly, so a stale lockfile degrades to
340
+ * "connect attempt fails" rather than corrupt state. */
341
+ export function isDaemonLockfileFresh(
342
+ lock: DaemonLockfile | null,
343
+ now = Date.now(),
344
+ ): lock is DaemonLockfile {
345
+ if (!lock) return false
346
+ if (now - lock.heartbeatAt > DAEMON_HEARTBEAT_STALE_MS) return false
347
+ try {
348
+ // signal 0 just tests whether the pid exists + we have perm to signal.
349
+ process.kill(lock.pid, 0)
350
+ return true
351
+ } catch {
352
+ return false
353
+ }
354
+ }
355
+
356
+ export function writeDaemonLockfile(data: DaemonLockfile): void {
357
+ ensureSootsimHome()
358
+ const tmp = `${daemonLockfilePath()}.tmp`
359
+ fs.writeFileSync(tmp, `${JSON.stringify(data, null, 2)}\n`, 'utf8')
360
+ fs.renameSync(tmp, daemonLockfilePath())
361
+ }
362
+
363
+ /** try to claim the lockfile atomically on daemon boot. returns true if
364
+ * we now own it. returns false when another fresh daemon beat us to the
365
+ * punch — callers should refuse to start. a stale lockfile (dead pid or
366
+ * old heartbeat) is overwritten. race-safe: the final fs.renameSync is
367
+ * atomic on POSIX and on NTFS. */
368
+ export function claimDaemonLockfile(data: DaemonLockfile): boolean {
369
+ ensureSootsimHome()
370
+ const existing = readDaemonLockfile()
371
+ if (existing && isDaemonLockfileFresh(existing) && existing.pid !== data.pid) {
372
+ return false
373
+ }
374
+ writeDaemonLockfile(data)
375
+ return true
376
+ }
377
+
378
+ export function removeDaemonLockfile(): void {
379
+ try {
380
+ fs.unlinkSync(daemonLockfilePath())
381
+ } catch {}
382
+ }