@thevinci/web 1.0.0

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 (337) hide show
  1. package/README.md +197 -0
  2. package/bin/cli-entry.js +55 -0
  3. package/bin/cli-output.js +145 -0
  4. package/bin/cli.js +4887 -0
  5. package/bin/cli.test.js +64 -0
  6. package/dist/apple-touch-icon-120x120.png +0 -0
  7. package/dist/apple-touch-icon-152x152.png +0 -0
  8. package/dist/apple-touch-icon-167x167.png +0 -0
  9. package/dist/apple-touch-icon-180x180.png +0 -0
  10. package/dist/apple-touch-icon.png +0 -0
  11. package/dist/apple-touch-icon.svg +528 -0
  12. package/dist/assets/JsonTreeView-CSm9OzXG.js +1 -0
  13. package/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  14. package/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  15. package/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  16. package/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  17. package/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  18. package/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  19. package/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  20. package/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  21. package/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  22. package/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  23. package/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  24. package/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  25. package/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  26. package/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  27. package/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  28. package/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  29. package/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  30. package/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  31. package/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  32. package/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  33. package/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  34. package/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  35. package/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  36. package/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  37. package/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  38. package/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  39. package/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  40. package/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  41. package/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  42. package/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  43. package/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  44. package/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  45. package/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  46. package/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  47. package/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  48. package/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  49. package/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  50. package/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  51. package/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  52. package/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  53. package/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  54. package/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  55. package/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  56. package/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  57. package/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  58. package/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  59. package/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  60. package/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  61. package/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  62. package/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  63. package/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  64. package/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  65. package/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  66. package/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  67. package/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  68. package/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  69. package/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  70. package/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  71. package/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  72. package/dist/assets/MarkdownRendererImpl-DensKOLc.js +6 -0
  73. package/dist/assets/MultiRunWindow-Bo7THayo.js +1 -0
  74. package/dist/assets/OnboardingScreen-BDqmzTVR.js +2 -0
  75. package/dist/assets/SettingsWindow-coz__Ykw.js +1 -0
  76. package/dist/assets/TerminalView-DrZ-i3Dr.js +1 -0
  77. package/dist/assets/ToolOutputDialog-Eglzslt3.js +16 -0
  78. package/dist/assets/es-4o9ciP61.js +15 -0
  79. package/dist/assets/ibm-plex-mono-latin-400-normal-CvHOgSBP.woff +0 -0
  80. package/dist/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 +0 -0
  81. package/dist/assets/ibm-plex-mono-latin-500-normal-CB9ihrfo.woff +0 -0
  82. package/dist/assets/ibm-plex-mono-latin-500-normal-DSY6xOcd.woff2 +0 -0
  83. package/dist/assets/ibm-plex-mono-latin-600-normal-BgSNZQsw.woff2 +0 -0
  84. package/dist/assets/ibm-plex-mono-latin-600-normal-DWFSQ4vo.woff +0 -0
  85. package/dist/assets/ibm-plex-sans-latin-400-normal-CDDApCn2.woff2 +0 -0
  86. package/dist/assets/ibm-plex-sans-latin-400-normal-CYLoc0-x.woff +0 -0
  87. package/dist/assets/ibm-plex-sans-latin-500-normal-6ng42L7E.woff2 +0 -0
  88. package/dist/assets/ibm-plex-sans-latin-500-normal-BgVn5rGT.woff +0 -0
  89. package/dist/assets/ibm-plex-sans-latin-600-normal-Cu4Hd6ag.woff +0 -0
  90. package/dist/assets/ibm-plex-sans-latin-600-normal-CuJfVYMP.woff2 +0 -0
  91. package/dist/assets/index-DLTDToSP.css +1 -0
  92. package/dist/assets/index-DgiFEKGN.js +1 -0
  93. package/dist/assets/ko-B20imCHE.js +15 -0
  94. package/dist/assets/main-BV3KOtdA.css +1 -0
  95. package/dist/assets/main-CDKJj0sH.js +226 -0
  96. package/dist/assets/main-LC-PSNVM.js +2 -0
  97. package/dist/assets/miniChat-CQUiG_cr.js +2 -0
  98. package/dist/assets/modelPrefsAutoSave-Dm799vzR.js +6986 -0
  99. package/dist/assets/pl-DQJ7LSzj.js +15 -0
  100. package/dist/assets/pt-BR-OmjHUz9y.js +15 -0
  101. package/dist/assets/renderElectronMiniChatApp-CARbeW0G.js +2 -0
  102. package/dist/assets/uk-BNFxOlO4.js +15 -0
  103. package/dist/assets/vendor--DBfsbEis.css +1 -0
  104. package/dist/assets/vendor-.bun-B9l0ZNi2.js +4094 -0
  105. package/dist/assets/wasm-CG6Dc4jp.js +1 -0
  106. package/dist/assets/wasmSttWorker-Dtlxac_K.js +1 -0
  107. package/dist/assets/wasmSttWorker-oo7Dm_jy.js +1806 -0
  108. package/dist/assets/worker-CbT6TVo7.js +155 -0
  109. package/dist/assets/zh-CN-C6T-Ac7F.js +15 -0
  110. package/dist/favicon-16.png +0 -0
  111. package/dist/favicon-32.png +0 -0
  112. package/dist/favicon.png +0 -0
  113. package/dist/favicon.svg +528 -0
  114. package/dist/index.html +607 -0
  115. package/dist/logo-dark-192x192.png +0 -0
  116. package/dist/logo-dark-512x512.png +0 -0
  117. package/dist/logo-dark-512x512.svg +528 -0
  118. package/dist/logo-light-192x192.png +0 -0
  119. package/dist/logo-light-512x512.png +0 -0
  120. package/dist/logo-light-512x512.svg +528 -0
  121. package/dist/mini-chat.html +16 -0
  122. package/dist/pwa-192.png +0 -0
  123. package/dist/pwa-512.png +0 -0
  124. package/dist/pwa-maskable-192.png +0 -0
  125. package/dist/pwa-maskable-512.png +0 -0
  126. package/dist/site.webmanifest +21 -0
  127. package/dist/sw.js +1 -0
  128. package/package.json +118 -0
  129. package/public/apple-touch-icon-120x120.png +0 -0
  130. package/public/apple-touch-icon-152x152.png +0 -0
  131. package/public/apple-touch-icon-167x167.png +0 -0
  132. package/public/apple-touch-icon-180x180.png +0 -0
  133. package/public/apple-touch-icon.png +0 -0
  134. package/public/apple-touch-icon.svg +528 -0
  135. package/public/favicon-16.png +0 -0
  136. package/public/favicon-32.png +0 -0
  137. package/public/favicon.png +0 -0
  138. package/public/favicon.svg +528 -0
  139. package/public/logo-dark-192x192.png +0 -0
  140. package/public/logo-dark-512x512.png +0 -0
  141. package/public/logo-dark-512x512.svg +528 -0
  142. package/public/logo-light-192x192.png +0 -0
  143. package/public/logo-light-512x512.png +0 -0
  144. package/public/logo-light-512x512.svg +528 -0
  145. package/public/pwa-192.png +0 -0
  146. package/public/pwa-512.png +0 -0
  147. package/public/pwa-maskable-192.png +0 -0
  148. package/public/pwa-maskable-512.png +0 -0
  149. package/public/site.webmanifest +21 -0
  150. package/server/TERMINAL_WS_PROTOCOL.md +48 -0
  151. package/server/index.d.ts +39 -0
  152. package/server/index.js +1311 -0
  153. package/server/lib/cloudflare-tunnel.js +650 -0
  154. package/server/lib/event-stream/DOCUMENTATION.md +61 -0
  155. package/server/lib/event-stream/directory-ws-bridge.js +185 -0
  156. package/server/lib/event-stream/global-hub.js +158 -0
  157. package/server/lib/event-stream/global-hub.test.js +140 -0
  158. package/server/lib/event-stream/global-ws-bridge.js +206 -0
  159. package/server/lib/event-stream/index.js +25 -0
  160. package/server/lib/event-stream/protocol.js +131 -0
  161. package/server/lib/event-stream/protocol.test.js +182 -0
  162. package/server/lib/event-stream/runtime.js +180 -0
  163. package/server/lib/event-stream/runtime.test.js +512 -0
  164. package/server/lib/event-stream/upstream-reader.js +226 -0
  165. package/server/lib/event-stream/upstream-reader.test.js +276 -0
  166. package/server/lib/fs/DOCUMENTATION.md +36 -0
  167. package/server/lib/fs/routes.js +1040 -0
  168. package/server/lib/fs/search.js +238 -0
  169. package/server/lib/git/DOCUMENTATION.md +152 -0
  170. package/server/lib/git/credentials.js +74 -0
  171. package/server/lib/git/identity-storage.js +112 -0
  172. package/server/lib/git/index.js +6 -0
  173. package/server/lib/git/routes.js +972 -0
  174. package/server/lib/git/service.js +3432 -0
  175. package/server/lib/git/service.test.js +39 -0
  176. package/server/lib/github/DOCUMENTATION.md +171 -0
  177. package/server/lib/github/auth.js +307 -0
  178. package/server/lib/github/device-flow.js +50 -0
  179. package/server/lib/github/index.js +24 -0
  180. package/server/lib/github/octokit.js +10 -0
  181. package/server/lib/github/pr-status.js +519 -0
  182. package/server/lib/github/repo/fork-detection.js +102 -0
  183. package/server/lib/github/repo/index.js +55 -0
  184. package/server/lib/github/routes.js +1560 -0
  185. package/server/lib/magic-prompts/routes.js +63 -0
  186. package/server/lib/magic-prompts/runtime.js +119 -0
  187. package/server/lib/notifications/DOCUMENTATION.md +122 -0
  188. package/server/lib/notifications/emitter-runtime.js +102 -0
  189. package/server/lib/notifications/index.js +4 -0
  190. package/server/lib/notifications/message.js +52 -0
  191. package/server/lib/notifications/message.test.js +34 -0
  192. package/server/lib/notifications/push-runtime.js +304 -0
  193. package/server/lib/notifications/routes.js +315 -0
  194. package/server/lib/notifications/runtime.js +566 -0
  195. package/server/lib/notifications/template-runtime.js +349 -0
  196. package/server/lib/notifications/template-runtime.test.js +26 -0
  197. package/server/lib/opencode/DOCUMENTATION.md +362 -0
  198. package/server/lib/opencode/agents.js +634 -0
  199. package/server/lib/opencode/auth-state-runtime.js +88 -0
  200. package/server/lib/opencode/auth.js +83 -0
  201. package/server/lib/opencode/bootstrap-runtime.js +131 -0
  202. package/server/lib/opencode/cli-entry-runtime.js +43 -0
  203. package/server/lib/opencode/cli-options.js +128 -0
  204. package/server/lib/opencode/commands.js +339 -0
  205. package/server/lib/opencode/config-entity-routes.js +370 -0
  206. package/server/lib/opencode/core-routes.js +500 -0
  207. package/server/lib/opencode/core-routes.test.js +26 -0
  208. package/server/lib/opencode/env-config.js +74 -0
  209. package/server/lib/opencode/env-keys.js +68 -0
  210. package/server/lib/opencode/env-runtime.js +1162 -0
  211. package/server/lib/opencode/env-runtime.test.js +116 -0
  212. package/server/lib/opencode/feature-routes-runtime.js +244 -0
  213. package/server/lib/opencode/hmr-state-runtime.js +85 -0
  214. package/server/lib/opencode/index.js +66 -0
  215. package/server/lib/opencode/lifecycle.js +1019 -0
  216. package/server/lib/opencode/lifecycle.test.js +240 -0
  217. package/server/lib/opencode/mcp.js +278 -0
  218. package/server/lib/opencode/network-runtime.js +104 -0
  219. package/server/lib/opencode/network-runtime.test.js +37 -0
  220. package/server/lib/opencode/opencode-resolution-runtime.js +71 -0
  221. package/server/lib/opencode/path-utils.js +100 -0
  222. package/server/lib/opencode/path-utils.test.js +71 -0
  223. package/server/lib/opencode/project-directory-runtime.js +124 -0
  224. package/server/lib/opencode/project-icon-routes.js +399 -0
  225. package/server/lib/opencode/project-icon-routes.test.js +107 -0
  226. package/server/lib/opencode/providers.js +96 -0
  227. package/server/lib/opencode/proxy.js +445 -0
  228. package/server/lib/opencode/pwa-manifest-routes.js +257 -0
  229. package/server/lib/opencode/pwa-manifest-routes.test.js +133 -0
  230. package/server/lib/opencode/routes.js +541 -0
  231. package/server/lib/opencode/server-startup-runtime.js +156 -0
  232. package/server/lib/opencode/server-utils-runtime.js +168 -0
  233. package/server/lib/opencode/server-utils-runtime.test.js +135 -0
  234. package/server/lib/opencode/session-runtime.js +356 -0
  235. package/server/lib/opencode/session-runtime.test.js +151 -0
  236. package/server/lib/opencode/settings-helpers.js +770 -0
  237. package/server/lib/opencode/settings-helpers.test.js +109 -0
  238. package/server/lib/opencode/settings-normalization-runtime.js +428 -0
  239. package/server/lib/opencode/settings-runtime.js +826 -0
  240. package/server/lib/opencode/settings-runtime.test.js +85 -0
  241. package/server/lib/opencode/shared.js +615 -0
  242. package/server/lib/opencode/shutdown-runtime.js +139 -0
  243. package/server/lib/opencode/shutdown-runtime.test.js +58 -0
  244. package/server/lib/opencode/skill-routes.js +701 -0
  245. package/server/lib/opencode/skills.js +548 -0
  246. package/server/lib/opencode/startup-pipeline-runtime.js +130 -0
  247. package/server/lib/opencode/static-routes-runtime.js +65 -0
  248. package/server/lib/opencode/theme-runtime.js +167 -0
  249. package/server/lib/opencode/tunnel-auth.js +591 -0
  250. package/server/lib/opencode/tunnel-wiring-runtime.js +94 -0
  251. package/server/lib/opencode/vinci-routes.js +76 -0
  252. package/server/lib/opencode/watcher.js +115 -0
  253. package/server/lib/opencode/watcher.test.js +239 -0
  254. package/server/lib/preview/proxy-runtime.js +1333 -0
  255. package/server/lib/preview/proxy-runtime.test.js +144 -0
  256. package/server/lib/projects/project-config.js +567 -0
  257. package/server/lib/projects/project-config.test.js +175 -0
  258. package/server/lib/projects/project-id.js +13 -0
  259. package/server/lib/quota/DOCUMENTATION.md +58 -0
  260. package/server/lib/quota/index.js +25 -0
  261. package/server/lib/quota/providers/claude.js +107 -0
  262. package/server/lib/quota/providers/codex.js +113 -0
  263. package/server/lib/quota/providers/copilot.js +165 -0
  264. package/server/lib/quota/providers/google/api.js +92 -0
  265. package/server/lib/quota/providers/google/auth.js +108 -0
  266. package/server/lib/quota/providers/google/index.js +124 -0
  267. package/server/lib/quota/providers/google/transforms.js +109 -0
  268. package/server/lib/quota/providers/index.js +168 -0
  269. package/server/lib/quota/providers/interface.js +55 -0
  270. package/server/lib/quota/providers/kimi.js +108 -0
  271. package/server/lib/quota/providers/minimax-cn-coding-plan.js +140 -0
  272. package/server/lib/quota/providers/minimax-coding-plan.js +139 -0
  273. package/server/lib/quota/providers/nanogpt.js +124 -0
  274. package/server/lib/quota/providers/ollama-cloud.js +112 -0
  275. package/server/lib/quota/providers/openai.js +91 -0
  276. package/server/lib/quota/providers/openrouter.js +92 -0
  277. package/server/lib/quota/providers/zai.js +91 -0
  278. package/server/lib/quota/providers/zhipuai-coding-plan.js +133 -0
  279. package/server/lib/quota/providers/zhipuai.js +114 -0
  280. package/server/lib/quota/routes.js +27 -0
  281. package/server/lib/quota/utils/auth.js +50 -0
  282. package/server/lib/quota/utils/formatters.js +85 -0
  283. package/server/lib/quota/utils/formatters.test.js +54 -0
  284. package/server/lib/quota/utils/index.js +10 -0
  285. package/server/lib/quota/utils/transformers.js +55 -0
  286. package/server/lib/scheduled-tasks/DOCUMENTATION.md +44 -0
  287. package/server/lib/scheduled-tasks/routes.js +235 -0
  288. package/server/lib/scheduled-tasks/runtime.js +773 -0
  289. package/server/lib/scheduled-tasks/runtime.test.js +100 -0
  290. package/server/lib/security/request-security.js +115 -0
  291. package/server/lib/session-folders/routes.js +63 -0
  292. package/server/lib/session-folders/routes.test.js +102 -0
  293. package/server/lib/skills-catalog/DOCUMENTATION.md +178 -0
  294. package/server/lib/skills-catalog/cache.js +29 -0
  295. package/server/lib/skills-catalog/clawdhub/api.js +158 -0
  296. package/server/lib/skills-catalog/clawdhub/index.js +30 -0
  297. package/server/lib/skills-catalog/clawdhub/install.js +238 -0
  298. package/server/lib/skills-catalog/clawdhub/scan.js +113 -0
  299. package/server/lib/skills-catalog/curated-sources.js +21 -0
  300. package/server/lib/skills-catalog/git.js +77 -0
  301. package/server/lib/skills-catalog/index.js +42 -0
  302. package/server/lib/skills-catalog/install.js +294 -0
  303. package/server/lib/skills-catalog/scan.js +221 -0
  304. package/server/lib/skills-catalog/source.js +87 -0
  305. package/server/lib/terminal/DOCUMENTATION.md +76 -0
  306. package/server/lib/terminal/index.js +31 -0
  307. package/server/lib/terminal/output-replay-buffer.js +78 -0
  308. package/server/lib/terminal/output-replay-buffer.test.js +75 -0
  309. package/server/lib/terminal/runtime.js +850 -0
  310. package/server/lib/terminal/runtime.test.js +96 -0
  311. package/server/lib/terminal/terminal-ws-protocol.js +68 -0
  312. package/server/lib/terminal/terminal-ws-protocol.test.js +145 -0
  313. package/server/lib/text/DOCUMENTATION.md +35 -0
  314. package/server/lib/text/summarization.js +138 -0
  315. package/server/lib/text/summarization.test.js +34 -0
  316. package/server/lib/tts/DOCUMENTATION.md +146 -0
  317. package/server/lib/tts/base-url.js +62 -0
  318. package/server/lib/tts/capability-runtime.js +31 -0
  319. package/server/lib/tts/index.js +19 -0
  320. package/server/lib/tts/routes.js +261 -0
  321. package/server/lib/tts/routes.test.js +53 -0
  322. package/server/lib/tts/service.js +178 -0
  323. package/server/lib/tts/stt.js +75 -0
  324. package/server/lib/tunnels/DOCUMENTATION.md +18 -0
  325. package/server/lib/tunnels/index.js +166 -0
  326. package/server/lib/tunnels/managed-config.js +201 -0
  327. package/server/lib/tunnels/providers/cloudflare.js +260 -0
  328. package/server/lib/tunnels/registry.js +51 -0
  329. package/server/lib/tunnels/routes.js +605 -0
  330. package/server/lib/tunnels/types.js +219 -0
  331. package/server/lib/ui-auth/DOCUMENTATION.md +38 -0
  332. package/server/lib/ui-auth/ui-auth.js +673 -0
  333. package/server/lib/ui-auth/ui-passkeys.js +545 -0
  334. package/server/opencode-proxy.test.js +151 -0
  335. package/server/proxy-headers.js +61 -0
  336. package/server/proxy-headers.test.js +58 -0
  337. package/server/sse-routes.test.js +152 -0
@@ -0,0 +1,826 @@
1
+ import { createProjectIdFromPath } from '../projects/project-id.js';
2
+
3
+ const DEFAULT_NOTIFICATION_TEMPLATES = {
4
+ completion: { title: '{agent_name} is ready', message: '{model_name} completed the task' },
5
+ error: { title: 'Tool error', message: '{last_message}' },
6
+ question: { title: 'Input needed', message: '{last_message}' },
7
+ subtask: { title: '{agent_name} is ready', message: '{model_name} completed the task' },
8
+ };
9
+
10
+ const ensureNotificationTemplateShape = (templates) => {
11
+ const input = templates && typeof templates === 'object' ? templates : {};
12
+ let changed = false;
13
+ const next = {};
14
+
15
+ for (const event of Object.keys(DEFAULT_NOTIFICATION_TEMPLATES)) {
16
+ const currentEntry = input[event];
17
+ const base = DEFAULT_NOTIFICATION_TEMPLATES[event];
18
+ const currentTitle = typeof currentEntry?.title === 'string' ? currentEntry.title : base.title;
19
+ const currentMessage = typeof currentEntry?.message === 'string' ? currentEntry.message : base.message;
20
+ if (!currentEntry || typeof currentEntry.title !== 'string' || typeof currentEntry.message !== 'string') {
21
+ changed = true;
22
+ }
23
+ next[event] = { title: currentTitle, message: currentMessage };
24
+ }
25
+
26
+ return { templates: next, changed };
27
+ };
28
+
29
+ export const createSettingsRuntime = (deps) => {
30
+ const {
31
+ fsPromises,
32
+ path,
33
+ crypto,
34
+ SETTINGS_FILE_PATH,
35
+ sanitizeProjects,
36
+ sanitizeSettingsUpdate,
37
+ mergePersistedSettings,
38
+ normalizeSettingsPaths,
39
+ normalizeStringArray,
40
+ formatSettingsResponse,
41
+ resolveDirectoryCandidate,
42
+ normalizeManagedRemoteTunnelHostname,
43
+ normalizeManagedRemoteTunnelPresets,
44
+ normalizeManagedRemoteTunnelPresetTokens,
45
+ syncManagedRemoteTunnelConfigWithPresets,
46
+ upsertManagedRemoteTunnelToken,
47
+ } = deps;
48
+
49
+ let persistSettingsLock = Promise.resolve();
50
+
51
+ // Orphan recovery is a one-shot best-effort scan: when orphans can't be
52
+ // matched on first pass they stay on disk and every subsequent settings
53
+ // read would re-scan them. In-process (Electron) this runs in the main
54
+ // event loop, so hitting it 3+ times/second from fs/list, path, etc.
55
+ // turns into perceptible UI jank for ~10-15 seconds after launch.
56
+ // Cache the outcome for this process lifetime.
57
+ let orphanRecoveryDone = false;
58
+
59
+ const PROJECTS_ROOT_DIR = path.join(path.dirname(SETTINGS_FILE_PATH), 'projects');
60
+ const PROJECT_ICONS_DIR = path.join(path.dirname(SETTINGS_FILE_PATH), 'project-icons');
61
+
62
+ const sha1Hex = (value) => crypto.createHash('sha1').update(value).digest('hex');
63
+ const projectIconBaseName = (projectId) => `project-${sha1Hex(projectId)}`;
64
+ const PROJECT_ICON_EXTENSIONS = ['png', 'jpg', 'svg', 'webp', 'ico'];
65
+
66
+ const readJsonFile = async (filePath) => {
67
+ try {
68
+ const raw = await fsPromises.readFile(filePath, 'utf8');
69
+ const parsed = JSON.parse(raw);
70
+ return parsed && typeof parsed === 'object' ? parsed : null;
71
+ } catch (error) {
72
+ if (error && typeof error === 'object' && error.code === 'ENOENT') {
73
+ return null;
74
+ }
75
+ throw error;
76
+ }
77
+ };
78
+
79
+ const writeJsonFile = async (filePath, value) => {
80
+ await fsPromises.mkdir(path.dirname(filePath), { recursive: true });
81
+ await fsPromises.writeFile(filePath, JSON.stringify(value, null, 2), 'utf8');
82
+ };
83
+
84
+ const uniqueStrings = (values) => Array.from(new Set(values.filter((value) => typeof value === 'string' && value.trim().length > 0)));
85
+
86
+ const mergeByKey = (oldItems, newItems, getKey) => {
87
+ const result = [];
88
+ const seen = new Set();
89
+ for (const item of [...(Array.isArray(newItems) ? newItems : []), ...(Array.isArray(oldItems) ? oldItems : [])]) {
90
+ if (!item || typeof item !== 'object') continue;
91
+ const key = getKey(item);
92
+ if (!key || seen.has(key)) continue;
93
+ seen.add(key);
94
+ result.push(item);
95
+ }
96
+ return result;
97
+ };
98
+
99
+ const remapPlanPaths = (entries, fromDir, toDir) => {
100
+ if (!Array.isArray(entries) || !fromDir || !toDir || fromDir === toDir) {
101
+ return Array.isArray(entries) ? entries : [];
102
+ }
103
+ return entries.map((entry) => {
104
+ if (!entry || typeof entry !== 'object' || typeof entry.path !== 'string') {
105
+ return entry;
106
+ }
107
+ const trimmedPath = entry.path.trim();
108
+ const relativePath = path.relative(fromDir, trimmedPath);
109
+ if (relativePath && (relativePath.startsWith('..') || path.isAbsolute(relativePath))) {
110
+ return entry;
111
+ }
112
+ return {
113
+ ...entry,
114
+ path: relativePath ? path.join(toDir, relativePath) : toDir,
115
+ };
116
+ });
117
+ };
118
+
119
+ const mergeProjectConfigData = ({ oldConfig, newConfig, oldStorageDir, newStorageDir, projectPath }) => {
120
+ const oldValue = oldConfig && typeof oldConfig === 'object' ? oldConfig : {};
121
+ const newValue = newConfig && typeof newConfig === 'object' ? newConfig : {};
122
+ const oldPlanFiles = remapPlanPaths(oldValue.projectPlanFiles, oldStorageDir, newStorageDir);
123
+ const newPlanFiles = remapPlanPaths(newValue.projectPlanFiles, oldStorageDir, newStorageDir);
124
+ const oldNotes = typeof oldValue.projectNotes === 'string' ? oldValue.projectNotes : '';
125
+ const newNotes = typeof newValue.projectNotes === 'string' ? newValue.projectNotes : '';
126
+
127
+ return {
128
+ ...oldValue,
129
+ ...newValue,
130
+ ...(typeof projectPath === 'string' && projectPath.trim().length > 0 ? { projectPath } : {}),
131
+ ...(uniqueStrings([...(Array.isArray(oldValue['setup-worktree']) ? oldValue['setup-worktree'] : []), ...(Array.isArray(newValue['setup-worktree']) ? newValue['setup-worktree'] : [])]).length > 0
132
+ ? { 'setup-worktree': uniqueStrings([...(Array.isArray(oldValue['setup-worktree']) ? oldValue['setup-worktree'] : []), ...(Array.isArray(newValue['setup-worktree']) ? newValue['setup-worktree'] : [])]) }
133
+ : {}),
134
+ ...(oldNotes || newNotes ? { projectNotes: newNotes || oldNotes } : {}),
135
+ ...(mergeByKey(oldValue.projectTodos, newValue.projectTodos, (item) => item.id).length > 0
136
+ ? { projectTodos: mergeByKey(oldValue.projectTodos, newValue.projectTodos, (item) => item.id) }
137
+ : {}),
138
+ ...(mergeByKey(oldValue.projectActions, newValue.projectActions, (item) => item.id).length > 0
139
+ ? { projectActions: mergeByKey(oldValue.projectActions, newValue.projectActions, (item) => item.id) }
140
+ : {}),
141
+ ...(mergeByKey(oldValue.scheduledTasks, newValue.scheduledTasks, (item) => item.id).length > 0
142
+ ? { scheduledTasks: mergeByKey(oldValue.scheduledTasks, newValue.scheduledTasks, (item) => item.id) }
143
+ : {}),
144
+ ...(mergeByKey(oldPlanFiles, newPlanFiles, (item) => item.id || item.path).length > 0
145
+ ? { projectPlanFiles: mergeByKey(oldPlanFiles, newPlanFiles, (item) => item.id || item.path) }
146
+ : {}),
147
+ ...(typeof newValue.projectActionsPrimaryId === 'string' && newValue.projectActionsPrimaryId.trim().length > 0
148
+ ? { projectActionsPrimaryId: newValue.projectActionsPrimaryId }
149
+ : typeof oldValue.projectActionsPrimaryId === 'string' && oldValue.projectActionsPrimaryId.trim().length > 0
150
+ ? { projectActionsPrimaryId: oldValue.projectActionsPrimaryId }
151
+ : {}),
152
+ };
153
+ };
154
+
155
+ const moveDirectoryContents = async (fromDir, toDir) => {
156
+ try {
157
+ const entries = await fsPromises.readdir(fromDir, { withFileTypes: true });
158
+ await fsPromises.mkdir(toDir, { recursive: true });
159
+
160
+ for (const entry of entries) {
161
+ const fromPath = path.join(fromDir, entry.name);
162
+ const toPath = path.join(toDir, entry.name);
163
+ if (entry.isDirectory()) {
164
+ await moveDirectoryContents(fromPath, toPath);
165
+ continue;
166
+ }
167
+ try {
168
+ await fsPromises.access(toPath);
169
+ } catch {
170
+ await fsPromises.rename(fromPath, toPath);
171
+ }
172
+ }
173
+
174
+ await fsPromises.rm(fromDir, { recursive: true, force: true });
175
+ } catch (error) {
176
+ if (!(error && typeof error === 'object' && error.code === 'ENOENT')) {
177
+ throw error;
178
+ }
179
+ }
180
+ };
181
+
182
+ const migrateProjectIconFiles = async ({ oldId, newId }) => {
183
+ if (!oldId || !newId || oldId === newId) {
184
+ return;
185
+ }
186
+
187
+ const oldBase = projectIconBaseName(oldId);
188
+ const newBase = projectIconBaseName(newId);
189
+
190
+ await fsPromises.mkdir(PROJECT_ICONS_DIR, { recursive: true });
191
+
192
+ for (const ext of PROJECT_ICON_EXTENSIONS) {
193
+ const oldPath = path.join(PROJECT_ICONS_DIR, `${oldBase}.${ext}`);
194
+ const newPath = path.join(PROJECT_ICONS_DIR, `${newBase}.${ext}`);
195
+ try {
196
+ await fsPromises.access(oldPath);
197
+ } catch (error) {
198
+ if (error && typeof error === 'object' && error.code === 'ENOENT') {
199
+ continue;
200
+ }
201
+ throw error;
202
+ }
203
+
204
+ try {
205
+ await fsPromises.access(newPath);
206
+ } catch {
207
+ await fsPromises.rename(oldPath, newPath);
208
+ continue;
209
+ }
210
+
211
+ await fsPromises.rm(oldPath, { force: true });
212
+ }
213
+ };
214
+
215
+ const migrateProjectScopedStorage = async ({ oldId, newId, projectPath }) => {
216
+ if (!oldId || !newId || oldId === newId) {
217
+ return;
218
+ }
219
+
220
+ const oldConfigPath = path.join(PROJECTS_ROOT_DIR, `${oldId}.json`);
221
+ const newConfigPath = path.join(PROJECTS_ROOT_DIR, `${newId}.json`);
222
+ const oldStorageDir = path.join(PROJECTS_ROOT_DIR, oldId);
223
+ const newStorageDir = path.join(PROJECTS_ROOT_DIR, newId);
224
+
225
+ const [oldConfig, newConfig] = await Promise.all([
226
+ readJsonFile(oldConfigPath),
227
+ readJsonFile(newConfigPath),
228
+ ]);
229
+
230
+ if (oldConfig || newConfig) {
231
+ const merged = mergeProjectConfigData({ oldConfig, newConfig, oldStorageDir, newStorageDir, projectPath });
232
+ await writeJsonFile(newConfigPath, merged);
233
+ }
234
+
235
+ await moveDirectoryContents(oldStorageDir, newStorageDir);
236
+ await fsPromises.rm(oldConfigPath, { force: true });
237
+ };
238
+
239
+ const migrateSettingsToDeterministicProjectIds = async (current) => {
240
+ const settings = current && typeof current === 'object' ? current : {};
241
+ const projects = sanitizeProjects(settings.projects) || [];
242
+ if (projects.length === 0) {
243
+ return { settings, changed: false };
244
+ }
245
+
246
+ let changed = false;
247
+ const projectIdMap = new Map();
248
+ const nextProjects = [];
249
+
250
+ for (const project of projects) {
251
+ const canonicalId = createProjectIdFromPath(project.path);
252
+ const nextId = canonicalId || project.id;
253
+ projectIdMap.set(project.id, nextId);
254
+ if (nextId !== project.id) {
255
+ changed = true;
256
+ await migrateProjectScopedStorage({ oldId: project.id, newId: nextId, projectPath: project.path });
257
+ await migrateProjectIconFiles({ oldId: project.id, newId: nextId });
258
+ }
259
+ nextProjects.push({ ...project, id: nextId });
260
+ }
261
+
262
+ if (!orphanRecoveryDone) {
263
+ orphanRecoveryDone = true; // set before await to close races under concurrent settings reads
264
+ try {
265
+ await recoverOrphanProjectFiles(nextProjects);
266
+ } catch (error) {
267
+ console.warn('[projects] Orphan recovery failed, continuing startup:', error);
268
+ }
269
+ }
270
+
271
+ if (!changed) {
272
+ return { settings, changed: false };
273
+ }
274
+
275
+ const currentActiveId = typeof settings.activeProjectId === 'string' ? settings.activeProjectId : '';
276
+ const nextActiveProjectId = projectIdMap.get(currentActiveId) || currentActiveId || nextProjects[0]?.id;
277
+
278
+ return {
279
+ settings: {
280
+ ...settings,
281
+ projects: nextProjects,
282
+ ...(nextActiveProjectId ? { activeProjectId: nextActiveProjectId } : {}),
283
+ },
284
+ changed: true,
285
+ };
286
+ };
287
+
288
+ // Orphan files are project jsons left behind from earlier random-UUID project
289
+ // ids (they have no projectPath field and are not referenced by settings).
290
+ // For each canonical project whose current config is empty (lost during the
291
+ // earlier id churn), try to find a single orphan whose setup-worktree command
292
+ // patterns uniquely match the project's basename and merge it in.
293
+ const recoverOrphanProjectFiles = async (canonicalProjects) => {
294
+ let entries;
295
+ try {
296
+ entries = await fsPromises.readdir(PROJECTS_ROOT_DIR, { withFileTypes: true });
297
+ } catch (error) {
298
+ if (error && typeof error === 'object' && error.code === 'ENOENT') return;
299
+ throw error;
300
+ }
301
+
302
+ const canonicalIds = new Set(canonicalProjects.map((project) => project.id));
303
+ const orphanFiles = entries
304
+ .filter((entry) => entry.isFile() && entry.name.endsWith('.json'))
305
+ .map((entry) => entry.name.replace(/\.json$/, ''))
306
+ .filter((id) => id && !id.startsWith('path_') && !canonicalIds.has(id));
307
+
308
+ if (orphanFiles.length === 0) return;
309
+
310
+ console.warn(`[projects] Found ${orphanFiles.length} orphan project config(s) without projectPath.`);
311
+
312
+ const orphans = [];
313
+ for (const orphanId of orphanFiles) {
314
+ const filePath = path.join(PROJECTS_ROOT_DIR, `${orphanId}.json`);
315
+ const content = await readJsonFile(filePath);
316
+ if (!content) continue;
317
+ const hasContent = [
318
+ typeof content.projectNotes === 'string' && content.projectNotes.trim().length > 0,
319
+ Array.isArray(content.projectTodos) && content.projectTodos.length > 0,
320
+ Array.isArray(content.projectActions) && content.projectActions.length > 0,
321
+ Array.isArray(content['setup-worktree']) && content['setup-worktree'].length > 0,
322
+ Array.isArray(content.projectPlanFiles) && content.projectPlanFiles.length > 0,
323
+ ].some(Boolean);
324
+ if (!hasContent) continue;
325
+ orphans.push({ orphanId, filePath, content });
326
+ }
327
+
328
+ if (orphans.length === 0) return;
329
+
330
+ const basenameOf = (projectPath) => {
331
+ if (typeof projectPath !== 'string') return '';
332
+ const normalized = projectPath.replace(/\\/g, '/').replace(/\/+$/g, '');
333
+ const idx = normalized.lastIndexOf('/');
334
+ return (idx >= 0 ? normalized.slice(idx + 1) : normalized).toLowerCase();
335
+ };
336
+
337
+ const extractRootRelPaths = (orphan) => {
338
+ const commands = [
339
+ ...(Array.isArray(orphan.content['setup-worktree']) ? orphan.content['setup-worktree'] : []),
340
+ ...(Array.isArray(orphan.content.projectActions) ? orphan.content.projectActions.map((a) => typeof a?.command === 'string' ? a.command : '') : []),
341
+ ].filter((s) => typeof s === 'string');
342
+ const results = new Set();
343
+ const re = /\$(?:\{)?ROOT_(?:PROJECT|WORKTREE)_PATH\}?\/([A-Za-z0-9._/-]+)/g;
344
+ for (const cmd of commands) {
345
+ let match;
346
+ while ((match = re.exec(cmd)) !== null) {
347
+ results.add(match[1]);
348
+ }
349
+ }
350
+ return Array.from(results);
351
+ };
352
+
353
+ const fileExistsInProject = async (projectPath, relPath) => {
354
+ try {
355
+ await fsPromises.access(path.join(projectPath, relPath));
356
+ return true;
357
+ } catch {
358
+ return false;
359
+ }
360
+ };
361
+
362
+ const orphanMatchesProject = async (orphan, project) => {
363
+ if (typeof project.path !== 'string' || !project.path.trim()) return false;
364
+ const rels = extractRootRelPaths(orphan);
365
+ for (const rel of rels) {
366
+ if (await fileExistsInProject(project.path, rel)) {
367
+ return true;
368
+ }
369
+ }
370
+ const name = basenameOf(project.path);
371
+ if (!name) return false;
372
+ const haystacks = [
373
+ ...(Array.isArray(orphan.content['setup-worktree']) ? orphan.content['setup-worktree'] : []),
374
+ ...(Array.isArray(orphan.content.projectActions) ? orphan.content.projectActions.map((a) => `${a?.name || ''} ${a?.command || ''}`) : []),
375
+ ].join(' ').toLowerCase();
376
+ return haystacks.includes(name);
377
+ };
378
+
379
+ const matches = new Map();
380
+ for (const orphan of orphans) {
381
+ const matchedProjects = [];
382
+ for (const project of canonicalProjects) {
383
+ if (await orphanMatchesProject(orphan, project)) {
384
+ matchedProjects.push(project);
385
+ }
386
+ }
387
+ if (matchedProjects.length === 1) {
388
+ const project = matchedProjects[0];
389
+ const list = matches.get(project.id) || [];
390
+ list.push(orphan);
391
+ matches.set(project.id, list);
392
+ }
393
+ }
394
+
395
+ const orphansConsumed = new Set();
396
+ for (const [projectId, orphansForProject] of matches.entries()) {
397
+ const project = canonicalProjects.find((p) => p.id === projectId);
398
+ if (!project) continue;
399
+ const targetPath = path.join(PROJECTS_ROOT_DIR, `${project.id}.json`);
400
+
401
+ for (const orphan of orphansForProject) {
402
+ const targetExisting = (await readJsonFile(targetPath)) || {};
403
+ const merged = mergeProjectConfigData({
404
+ oldConfig: orphan.content,
405
+ newConfig: targetExisting,
406
+ oldStorageDir: path.join(PROJECTS_ROOT_DIR, orphan.orphanId),
407
+ newStorageDir: path.join(PROJECTS_ROOT_DIR, project.id),
408
+ projectPath: project.path,
409
+ });
410
+ await writeJsonFile(targetPath, merged);
411
+ await moveDirectoryContents(path.join(PROJECTS_ROOT_DIR, orphan.orphanId), path.join(PROJECTS_ROOT_DIR, project.id));
412
+ await fsPromises.rm(orphan.filePath, { force: true });
413
+ orphansConsumed.add(orphan.orphanId);
414
+ console.log(`[projects] Recovered orphan ${orphan.orphanId} -> ${project.id} (${project.path})`);
415
+ }
416
+ }
417
+
418
+ const remaining = orphans.filter((orphan) => !orphansConsumed.has(orphan.orphanId));
419
+ if (remaining.length > 0) {
420
+ console.warn(`[projects] ${remaining.length} orphan project file(s) could not be auto-matched: ${remaining.map((o) => o.orphanId).join(', ')}`);
421
+ }
422
+ };
423
+
424
+ const readSettingsFromDisk = async () => {
425
+ try {
426
+ const raw = await fsPromises.readFile(SETTINGS_FILE_PATH, 'utf8');
427
+ const parsed = JSON.parse(raw);
428
+ if (parsed && typeof parsed === 'object') {
429
+ return parsed;
430
+ }
431
+ return {};
432
+ } catch (error) {
433
+ if (error && typeof error === 'object' && error.code === 'ENOENT') {
434
+ return {};
435
+ }
436
+ console.warn('Failed to read settings file:', error);
437
+ return {};
438
+ }
439
+ };
440
+
441
+ const writeSettingsToDisk = async (settings) => {
442
+ try {
443
+ await fsPromises.mkdir(path.dirname(SETTINGS_FILE_PATH), { recursive: true });
444
+ // Atomic write: Electron main and ssh-manager read this file via plain
445
+ // readFile + JSON.parse and silently coerce parse errors to {}. A
446
+ // partial read during a non-atomic writeFile would make their next
447
+ // read-modify-write wipe the settings file.
448
+ const tmp = `${SETTINGS_FILE_PATH}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
449
+ await fsPromises.writeFile(tmp, JSON.stringify(settings, null, 2), 'utf8');
450
+ await fsPromises.rename(tmp, SETTINGS_FILE_PATH);
451
+ } catch (error) {
452
+ console.warn('Failed to write settings file:', error);
453
+ throw error;
454
+ }
455
+ };
456
+
457
+ const validateProjectEntries = async (projects) => {
458
+ console.log(`[validateProjectEntries] Starting validation for ${projects.length} projects`);
459
+
460
+ if (!Array.isArray(projects)) {
461
+ console.warn('[validateProjectEntries] Input is not an array, returning empty');
462
+ return [];
463
+ }
464
+
465
+ const validations = projects.map(async (project) => {
466
+ if (!project || typeof project.path !== 'string' || project.path.length === 0) {
467
+ console.error('[validateProjectEntries] Invalid project entry: missing or empty path', project);
468
+ return null;
469
+ }
470
+ try {
471
+ const stats = await fsPromises.stat(project.path);
472
+ if (!stats.isDirectory()) {
473
+ console.error(`[validateProjectEntries] Project path is not a directory: ${project.path}`);
474
+ return null;
475
+ }
476
+ return project;
477
+ } catch (error) {
478
+ const err = error;
479
+ console.error(`[validateProjectEntries] Failed to validate project "${project.path}": ${err.code || err.message || err}`);
480
+ if (err && typeof err === 'object' && err.code === 'ENOENT') {
481
+ console.log(`[validateProjectEntries] Removing project with ENOENT: ${project.path}`);
482
+ return null;
483
+ }
484
+ console.log(`[validateProjectEntries] Keeping project despite non-ENOENT error: ${project.path}`);
485
+ return project;
486
+ }
487
+ });
488
+
489
+ const results = (await Promise.all(validations)).filter((p) => p !== null);
490
+
491
+ console.log(`[validateProjectEntries] Validation complete: ${results.length}/${projects.length} projects valid`);
492
+ return results;
493
+ };
494
+
495
+ const migrateSettingsFromLegacyLastDirectory = async (current) => {
496
+ const settings = current && typeof current === 'object' ? current : {};
497
+ const now = Date.now();
498
+
499
+ const sanitizedProjects = sanitizeProjects(settings.projects) || [];
500
+ let nextProjects = sanitizedProjects;
501
+ let nextActiveProjectId =
502
+ typeof settings.activeProjectId === 'string' ? settings.activeProjectId : undefined;
503
+
504
+ let changed = false;
505
+
506
+ if (nextProjects.length === 0) {
507
+ const legacy = typeof settings.lastDirectory === 'string' ? settings.lastDirectory.trim() : '';
508
+ const candidate = legacy ? resolveDirectoryCandidate(legacy) : null;
509
+
510
+ if (candidate) {
511
+ try {
512
+ const stats = await fsPromises.stat(candidate);
513
+ if (stats.isDirectory()) {
514
+ const id = createProjectIdFromPath(candidate);
515
+ nextProjects = [
516
+ {
517
+ id,
518
+ path: candidate,
519
+ addedAt: now,
520
+ lastOpenedAt: now,
521
+ },
522
+ ];
523
+ nextActiveProjectId = id;
524
+ changed = true;
525
+ }
526
+ } catch {
527
+ // ignore invalid lastDirectory
528
+ }
529
+ }
530
+ }
531
+
532
+ if (nextProjects.length > 0) {
533
+ const active = nextProjects.find((project) => project.id === nextActiveProjectId) || null;
534
+ if (!active) {
535
+ nextActiveProjectId = nextProjects[0].id;
536
+ changed = true;
537
+ }
538
+ } else if (nextActiveProjectId) {
539
+ nextActiveProjectId = undefined;
540
+ changed = true;
541
+ }
542
+
543
+ if (!changed) {
544
+ return { settings, changed: false };
545
+ }
546
+
547
+ const merged = mergePersistedSettings(settings, {
548
+ ...settings,
549
+ projects: nextProjects,
550
+ ...(nextActiveProjectId ? { activeProjectId: nextActiveProjectId } : { activeProjectId: undefined }),
551
+ });
552
+
553
+ return { settings: merged, changed: true };
554
+ };
555
+
556
+ const migrateSettingsFromLegacyThemePreferences = async (current) => {
557
+ const settings = current && typeof current === 'object' ? current : {};
558
+
559
+ const themeId = typeof settings.themeId === 'string' ? settings.themeId.trim() : '';
560
+ const themeVariant = typeof settings.themeVariant === 'string' ? settings.themeVariant.trim() : '';
561
+
562
+ const hasLight = typeof settings.lightThemeId === 'string' && settings.lightThemeId.trim().length > 0;
563
+ const hasDark = typeof settings.darkThemeId === 'string' && settings.darkThemeId.trim().length > 0;
564
+
565
+ if (hasLight && hasDark) {
566
+ return { settings, changed: false };
567
+ }
568
+
569
+ const defaultLight = 'flexoki-light';
570
+ const defaultDark = 'flexoki-dark';
571
+
572
+ let nextLightThemeId = hasLight ? settings.lightThemeId : undefined;
573
+ let nextDarkThemeId = hasDark ? settings.darkThemeId : undefined;
574
+
575
+ if (!hasLight) {
576
+ if (themeId && themeVariant === 'light') {
577
+ nextLightThemeId = themeId;
578
+ } else {
579
+ nextLightThemeId = defaultLight;
580
+ }
581
+ }
582
+
583
+ if (!hasDark) {
584
+ if (themeId && themeVariant === 'dark') {
585
+ nextDarkThemeId = themeId;
586
+ } else {
587
+ nextDarkThemeId = defaultDark;
588
+ }
589
+ }
590
+
591
+ const merged = mergePersistedSettings(settings, {
592
+ ...settings,
593
+ ...(nextLightThemeId ? { lightThemeId: nextLightThemeId } : {}),
594
+ ...(nextDarkThemeId ? { darkThemeId: nextDarkThemeId } : {}),
595
+ });
596
+
597
+ return { settings: merged, changed: true };
598
+ };
599
+
600
+ const migrateSettingsFromLegacyCollapsedProjects = async (current) => {
601
+ const settings = current && typeof current === 'object' ? current : {};
602
+ const collapsed = Array.isArray(settings.collapsedProjects)
603
+ ? normalizeStringArray(settings.collapsedProjects)
604
+ : [];
605
+
606
+ if (collapsed.length === 0 || !Array.isArray(settings.projects)) {
607
+ if (collapsed.length === 0) {
608
+ return { settings, changed: false };
609
+ }
610
+ const next = { ...settings };
611
+ delete next.collapsedProjects;
612
+ return { settings: next, changed: true };
613
+ }
614
+
615
+ const set = new Set(collapsed);
616
+ const projects = sanitizeProjects(settings.projects) || [];
617
+ let changed = false;
618
+
619
+ const nextProjects = projects.map((project) => {
620
+ const shouldCollapse = set.has(project.id);
621
+ if (project.sidebarCollapsed !== shouldCollapse) {
622
+ changed = true;
623
+ return { ...project, sidebarCollapsed: shouldCollapse };
624
+ }
625
+ return project;
626
+ });
627
+
628
+ if (!changed) {
629
+ if (Object.prototype.hasOwnProperty.call(settings, 'collapsedProjects')) {
630
+ const next = { ...settings };
631
+ delete next.collapsedProjects;
632
+ return { settings: next, changed: true };
633
+ }
634
+ return { settings, changed: false };
635
+ }
636
+
637
+ const next = { ...settings, projects: nextProjects };
638
+ delete next.collapsedProjects;
639
+ return { settings: next, changed: true };
640
+ };
641
+
642
+ const migrateSettingsNotificationDefaults = async (current) => {
643
+ const settings = current && typeof current === 'object' ? current : {};
644
+ let changed = false;
645
+ const next = { ...settings };
646
+
647
+ if (typeof settings.notifyOnSubtasks !== 'boolean') {
648
+ next.notifyOnSubtasks = true;
649
+ changed = true;
650
+ }
651
+ if (typeof settings.notifyOnCompletion !== 'boolean') {
652
+ next.notifyOnCompletion = true;
653
+ changed = true;
654
+ }
655
+ if (typeof settings.notifyOnError !== 'boolean') {
656
+ next.notifyOnError = true;
657
+ changed = true;
658
+ }
659
+ if (typeof settings.notifyOnQuestion !== 'boolean') {
660
+ next.notifyOnQuestion = true;
661
+ changed = true;
662
+ }
663
+
664
+ const { templates, changed: templatesChanged } = ensureNotificationTemplateShape(settings.notificationTemplates);
665
+ if (templatesChanged || !settings.notificationTemplates || typeof settings.notificationTemplates !== 'object') {
666
+ next.notificationTemplates = templates;
667
+ changed = true;
668
+ }
669
+
670
+ return { settings: changed ? next : settings, changed };
671
+ };
672
+
673
+ const migrateSettingsFromLegacyNamedTunnelKeys = async (current) => {
674
+ const settings = current && typeof current === 'object' ? current : {};
675
+ const next = { ...settings };
676
+ let changed = false;
677
+
678
+ if (!Object.prototype.hasOwnProperty.call(next, 'managedRemoteTunnelHostname')
679
+ && Object.prototype.hasOwnProperty.call(next, 'namedTunnelHostname')) {
680
+ next.managedRemoteTunnelHostname = normalizeManagedRemoteTunnelHostname(next.namedTunnelHostname);
681
+ changed = true;
682
+ }
683
+
684
+ if (!Object.prototype.hasOwnProperty.call(next, 'managedRemoteTunnelToken')
685
+ && Object.prototype.hasOwnProperty.call(next, 'namedTunnelToken')) {
686
+ if (next.namedTunnelToken === null) {
687
+ next.managedRemoteTunnelToken = null;
688
+ } else if (typeof next.namedTunnelToken === 'string') {
689
+ next.managedRemoteTunnelToken = next.namedTunnelToken.trim();
690
+ }
691
+ changed = true;
692
+ }
693
+
694
+ if (!Object.prototype.hasOwnProperty.call(next, 'managedRemoteTunnelPresets')
695
+ && Object.prototype.hasOwnProperty.call(next, 'namedTunnelPresets')) {
696
+ next.managedRemoteTunnelPresets = normalizeManagedRemoteTunnelPresets(next.namedTunnelPresets);
697
+ changed = true;
698
+ }
699
+
700
+ if (!Object.prototype.hasOwnProperty.call(next, 'managedRemoteTunnelPresetTokens')
701
+ && Object.prototype.hasOwnProperty.call(next, 'namedTunnelPresetTokens')) {
702
+ next.managedRemoteTunnelPresetTokens = normalizeManagedRemoteTunnelPresetTokens(next.namedTunnelPresetTokens);
703
+ changed = true;
704
+ }
705
+
706
+ if (!Object.prototype.hasOwnProperty.call(next, 'managedRemoteTunnelSelectedPresetId')
707
+ && Object.prototype.hasOwnProperty.call(next, 'namedTunnelSelectedPresetId')) {
708
+ const selectedPresetId = typeof next.namedTunnelSelectedPresetId === 'string'
709
+ ? next.namedTunnelSelectedPresetId.trim()
710
+ : '';
711
+ if (selectedPresetId) {
712
+ next.managedRemoteTunnelSelectedPresetId = selectedPresetId;
713
+ }
714
+ changed = true;
715
+ }
716
+
717
+ const legacyKeys = [
718
+ 'namedTunnelHostname',
719
+ 'namedTunnelToken',
720
+ 'namedTunnelPresets',
721
+ 'namedTunnelPresetTokens',
722
+ 'namedTunnelSelectedPresetId',
723
+ ];
724
+ for (const key of legacyKeys) {
725
+ if (Object.prototype.hasOwnProperty.call(next, key)) {
726
+ delete next[key];
727
+ changed = true;
728
+ }
729
+ }
730
+
731
+ return { settings: changed ? next : settings, changed };
732
+ };
733
+
734
+ const readSettingsFromDiskMigrated = async () => {
735
+ const current = await readSettingsFromDisk();
736
+ const migration1 = await migrateSettingsFromLegacyLastDirectory(current);
737
+ const migration2 = await migrateSettingsFromLegacyThemePreferences(migration1.settings);
738
+ const migration3 = await migrateSettingsFromLegacyCollapsedProjects(migration2.settings);
739
+ const migration4 = await migrateSettingsNotificationDefaults(migration3.settings);
740
+ const migration5 = await migrateSettingsFromLegacyNamedTunnelKeys(migration4.settings);
741
+ const migration6 = normalizeSettingsPaths(migration5.settings);
742
+ const migration7 = await migrateSettingsToDeterministicProjectIds(migration6.settings);
743
+ if (migration1.changed || migration2.changed || migration3.changed || migration4.changed || migration5.changed || migration6.changed || migration7.changed) {
744
+ await writeSettingsToDisk(migration7.settings);
745
+ }
746
+ return migration7.settings;
747
+ };
748
+
749
+ const persistSettings = async (changes) => {
750
+ persistSettingsLock = persistSettingsLock.then(async () => {
751
+ console.log('[persistSettings] Called with changes:', JSON.stringify(changes, null, 2));
752
+ const current = await readSettingsFromDisk();
753
+ console.log('[persistSettings] Current projects count:', Array.isArray(current.projects) ? current.projects.length : 'N/A');
754
+ const sanitized = sanitizeSettingsUpdate(changes);
755
+ let next = mergePersistedSettings(current, sanitized);
756
+
757
+ const normalizedState = normalizeSettingsPaths(next);
758
+ if (normalizedState.changed) {
759
+ next = normalizedState.settings;
760
+ }
761
+
762
+ const deterministicProjectIdMigration = await migrateSettingsToDeterministicProjectIds(next);
763
+ if (deterministicProjectIdMigration.changed) {
764
+ next = deterministicProjectIdMigration.settings;
765
+ }
766
+
767
+ if (Array.isArray(next.projects)) {
768
+ console.log(`[persistSettings] Validating ${next.projects.length} projects...`);
769
+ const validated = await validateProjectEntries(next.projects);
770
+ console.log(`[persistSettings] After validation: ${validated.length} projects remain`);
771
+ next = { ...next, projects: validated };
772
+ }
773
+
774
+ if (Array.isArray(next.projects) && next.projects.length > 0) {
775
+ const activeId = typeof next.activeProjectId === 'string' ? next.activeProjectId : '';
776
+ const active = next.projects.find((project) => project.id === activeId) || null;
777
+ if (!active) {
778
+ console.log(`[persistSettings] Active project ID ${activeId} not found, switching to ${next.projects[0].id}`);
779
+ next = { ...next, activeProjectId: next.projects[0].id };
780
+ }
781
+ } else if (next.activeProjectId) {
782
+ console.log(`[persistSettings] No projects found, clearing activeProjectId ${next.activeProjectId}`);
783
+ next = { ...next, activeProjectId: undefined };
784
+ }
785
+
786
+ if (Object.prototype.hasOwnProperty.call(sanitized, 'managedRemoteTunnelPresets')) {
787
+ await syncManagedRemoteTunnelConfigWithPresets(next.managedRemoteTunnelPresets);
788
+ }
789
+
790
+ if (Object.prototype.hasOwnProperty.call(sanitized, 'managedRemoteTunnelPresetTokens') && sanitized.managedRemoteTunnelPresetTokens) {
791
+ const presetsById = new Map((next.managedRemoteTunnelPresets || []).map((entry) => [entry.id, entry]));
792
+ const updates = Object.entries(sanitized.managedRemoteTunnelPresetTokens)
793
+ .map(([presetId, token]) => {
794
+ const preset = presetsById.get(presetId);
795
+ if (!preset || typeof token !== 'string' || token.trim().length === 0) {
796
+ return null;
797
+ }
798
+ return {
799
+ id: preset.id,
800
+ name: preset.name,
801
+ hostname: preset.hostname,
802
+ token: token.trim(),
803
+ };
804
+ })
805
+ .filter(Boolean);
806
+
807
+ for (const update of updates) {
808
+ await upsertManagedRemoteTunnelToken(update);
809
+ }
810
+ }
811
+
812
+ await writeSettingsToDisk(next);
813
+ console.log(`[persistSettings] Successfully saved ${next.projects?.length || 0} projects to disk`);
814
+ return formatSettingsResponse(next);
815
+ });
816
+
817
+ return persistSettingsLock;
818
+ };
819
+
820
+ return {
821
+ readSettingsFromDisk,
822
+ readSettingsFromDiskMigrated,
823
+ writeSettingsToDisk,
824
+ persistSettings,
825
+ };
826
+ };