cartethyia 2.5.10 → 2.6.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 (367) hide show
  1. package/.env.example +8 -0
  2. package/dist/dashboard/assets/{BannedPage-Cd30Z0b9.js → BannedPage-l6XMnfr9.js} +1 -1
  3. package/dist/dashboard/assets/BannedPage-l6XMnfr9.js.gz +0 -0
  4. package/dist/dashboard/assets/{CliToolDetailPage-wf5u-ww6.js → CliToolDetailPage-DwOzAW45.js} +1 -1
  5. package/dist/dashboard/assets/CliToolDetailPage-DwOzAW45.js.gz +0 -0
  6. package/dist/dashboard/assets/{CliToolsPage-DykPvuck.js → CliToolsPage-BT3Up3QA.js} +1 -1
  7. package/dist/dashboard/assets/CliToolsPage-BT3Up3QA.js.gz +0 -0
  8. package/dist/dashboard/assets/{CombosPage-DwvSD3vs.js → CombosPage-6kYIUpzR.js} +1 -1
  9. package/dist/dashboard/assets/CombosPage-6kYIUpzR.js.gz +0 -0
  10. package/dist/dashboard/assets/{ConsoleLogPage-CIr7KDex.js → ConsoleLogPage-zRnj1mlR.js} +1 -1
  11. package/dist/dashboard/assets/ConsoleLogPage-zRnj1mlR.js.gz +0 -0
  12. package/dist/dashboard/assets/{CustomizationPage-82X0DDdH.js → CustomizationPage-C7m-H1Rz.js} +1 -1
  13. package/dist/dashboard/assets/CustomizationPage-C7m-H1Rz.js.gz +0 -0
  14. package/dist/dashboard/assets/HealthEventsModal-C8MR_BdB.js +1 -0
  15. package/dist/dashboard/assets/HealthEventsModal-C8MR_BdB.js.gz +0 -0
  16. package/dist/dashboard/assets/{LoginPage-CS5mPuVF.js → LoginPage-CbphLvjg.js} +1 -1
  17. package/dist/dashboard/assets/LoginPage-CbphLvjg.js.gz +0 -0
  18. package/dist/dashboard/assets/{ModelPicker-C0UCUsVJ.js → ModelPicker-DVny4S5_.js} +1 -1
  19. package/dist/dashboard/assets/ModelPicker-DVny4S5_.js.gz +0 -0
  20. package/dist/dashboard/assets/OverviewPage-DABu2Wgt.js +1 -0
  21. package/dist/dashboard/assets/OverviewPage-DABu2Wgt.js.gz +0 -0
  22. package/dist/dashboard/assets/ProviderDetailPage-C15x9q13.js +3 -0
  23. package/dist/dashboard/assets/ProviderDetailPage-C15x9q13.js.gz +0 -0
  24. package/dist/dashboard/assets/{ProvidersPage-vdja5ZPK.js → ProvidersPage-Dfb2896L.js} +1 -1
  25. package/dist/dashboard/assets/ProvidersPage-Dfb2896L.js.gz +0 -0
  26. package/dist/dashboard/assets/{ProxyPage-BB4CNm4-.js → ProxyPage-BaCIdAUt.js} +1 -1
  27. package/dist/dashboard/assets/ProxyPage-BaCIdAUt.js.gz +0 -0
  28. package/dist/dashboard/assets/QuotaPage-BsdoCIw1.js +1 -0
  29. package/dist/dashboard/assets/QuotaPage-BsdoCIw1.js.gz +0 -0
  30. package/dist/dashboard/assets/SettingsPage-KVJFh3Ip.js +1 -0
  31. package/dist/dashboard/assets/SettingsPage-KVJFh3Ip.js.gz +0 -0
  32. package/dist/dashboard/assets/{SetupPage-FadUOl9S.js → SetupPage-D4g6vOGc.js} +1 -1
  33. package/dist/dashboard/assets/SetupPage-D4g6vOGc.js.gz +0 -0
  34. package/dist/dashboard/assets/{SortableList-Cfy6_Khv.js → SortableList-C93i14BD.js} +1 -1
  35. package/dist/dashboard/assets/SortableList-C93i14BD.js.gz +0 -0
  36. package/dist/dashboard/assets/{StudioPage-CM92fgJf.js → StudioPage-SgQpymjm.js} +1 -1
  37. package/dist/dashboard/assets/{StudioPage-CM92fgJf.js.gz → StudioPage-SgQpymjm.js.gz} +0 -0
  38. package/dist/dashboard/assets/{UsagePage-BT4MyQeT.js → UsagePage-BFME-eba.js} +2 -2
  39. package/dist/dashboard/assets/UsagePage-BFME-eba.js.gz +0 -0
  40. package/dist/dashboard/assets/{api-keys-DQYLQxpW.js → api-keys-C23S9sgp.js} +1 -1
  41. package/dist/dashboard/assets/api-keys-C23S9sgp.js.gz +0 -0
  42. package/dist/dashboard/assets/{app-B7pVRhJ1.js → app-2KhtHCv9.js} +1 -1
  43. package/dist/dashboard/assets/{app-B7pVRhJ1.js.gz → app-2KhtHCv9.js.gz} +0 -0
  44. package/dist/dashboard/assets/app-CEeaMHOy.css +2 -0
  45. package/dist/dashboard/assets/app-CEeaMHOy.css.gz +0 -0
  46. package/dist/dashboard/assets/{assert-C9FD_JwJ.js → assert-DU6qrMA_.js} +1 -1
  47. package/dist/dashboard/assets/assert-DU6qrMA_.js.gz +0 -0
  48. package/dist/dashboard/assets/{badge-Bvyr7D0u.js → badge-CKsF123d.js} +1 -1
  49. package/dist/dashboard/assets/badge-CKsF123d.js.gz +0 -0
  50. package/dist/dashboard/assets/{banner-DIvKAZih.js → banner-8j7uChA9.js} +1 -1
  51. package/dist/dashboard/assets/banner-8j7uChA9.js.gz +0 -0
  52. package/dist/dashboard/assets/{card-D5_gYIS_.js → card-CdsSlWA3.js} +1 -1
  53. package/dist/dashboard/assets/card-CdsSlWA3.js.gz +0 -0
  54. package/dist/dashboard/assets/{cli-tools-CNqzrfdb.js → cli-tools-BaFUuFFF.js} +1 -1
  55. package/dist/dashboard/assets/cli-tools-BaFUuFFF.js.gz +0 -0
  56. package/dist/dashboard/assets/{clipboard-button-B6vDBtbm.js → clipboard-button-uL5aYLfW.js} +1 -1
  57. package/dist/dashboard/assets/clipboard-button-uL5aYLfW.js.gz +0 -0
  58. package/dist/dashboard/assets/{dialog-CpjrgdwJ.js → dialog-C07hr5_G.js} +1 -1
  59. package/dist/dashboard/assets/dialog-C07hr5_G.js.gz +0 -0
  60. package/dist/dashboard/assets/{drawer-BWmt3b5y.js → drawer-BPO1TcKH.js} +1 -1
  61. package/dist/dashboard/assets/drawer-BPO1TcKH.js.gz +0 -0
  62. package/dist/dashboard/assets/{entry-C_oakV3z.js → entry-Bz_CtDfC.js} +1 -1
  63. package/dist/dashboard/assets/entry-Bz_CtDfC.js.gz +0 -0
  64. package/dist/dashboard/assets/entry-CwPRKk0d.js +4 -0
  65. package/dist/dashboard/assets/entry-CwPRKk0d.js.gz +0 -0
  66. package/dist/dashboard/assets/entry-GJRbmrpA.js +1 -0
  67. package/dist/dashboard/assets/entry-GJRbmrpA.js.gz +0 -0
  68. package/dist/dashboard/assets/{github-badge-RRHHWGxh.js → github-badge-DmQreSDd.js} +1 -1
  69. package/dist/dashboard/assets/github-badge-DmQreSDd.js.gz +0 -0
  70. package/dist/dashboard/assets/{index-Bhey_rg_.js → index-Bblu6erR.js} +2 -2
  71. package/dist/dashboard/assets/index-Bblu6erR.js.gz +0 -0
  72. package/dist/dashboard/assets/{inline-C2_EQ5Uf.js → inline-C9dCYaBZ.js} +1 -1
  73. package/dist/dashboard/assets/inline-C9dCYaBZ.js.gz +0 -0
  74. package/dist/dashboard/assets/{input-BMRlQ450.js → input-CjCnXweJ.js} +1 -1
  75. package/dist/dashboard/assets/input-CjCnXweJ.js.gz +0 -0
  76. package/dist/dashboard/assets/{layout-XR5xP8ew.js → layout-V6y8DAkl.js} +1 -1
  77. package/dist/dashboard/assets/layout-V6y8DAkl.js.gz +0 -0
  78. package/dist/dashboard/assets/{list-row-Cw07dioc.js → list-row-CM1AaG_y.js} +1 -1
  79. package/dist/dashboard/assets/list-row-CM1AaG_y.js.gz +0 -0
  80. package/dist/dashboard/assets/{live-BivGfuUs.js → live-ta6GMb-8.js} +1 -1
  81. package/dist/dashboard/assets/live-ta6GMb-8.js.gz +0 -0
  82. package/dist/dashboard/assets/{model-bans-CmffjLuG.js → model-bans-BmYTmbAk.js} +1 -1
  83. package/dist/dashboard/assets/model-bans-BmYTmbAk.js.gz +0 -0
  84. package/dist/dashboard/assets/{mutation-46P0oWMe.js → mutation-g_fduiNw.js} +1 -1
  85. package/dist/dashboard/assets/mutation-g_fduiNw.js.gz +0 -0
  86. package/dist/dashboard/assets/{numbers-DrdA9oSM.js → numbers-BOevJzdz.js} +1 -1
  87. package/dist/dashboard/assets/numbers-BOevJzdz.js.gz +0 -0
  88. package/dist/dashboard/assets/{query-keys-BFhGpwR1.js → query-keys-UETqfytp.js} +1 -1
  89. package/dist/dashboard/assets/query-keys-UETqfytp.js.gz +0 -0
  90. package/dist/dashboard/assets/{routing-CAb4F37R.js → routing-BPUo-W8d.js} +1 -1
  91. package/dist/dashboard/assets/routing-BPUo-W8d.js.gz +0 -0
  92. package/dist/dashboard/assets/{select-CxVVB__N.js → select-C980c4cz.js} +1 -1
  93. package/dist/dashboard/assets/select-C980c4cz.js.gz +0 -0
  94. package/dist/dashboard/assets/{settings-DNX9sydi.js → settings--3hxSyry.js} +1 -1
  95. package/dist/dashboard/assets/settings--3hxSyry.js.gz +0 -0
  96. package/dist/dashboard/assets/{stack-uPOwM9Hs.js → stack-_gfTdLVB.js} +1 -1
  97. package/dist/dashboard/assets/stack-_gfTdLVB.js.gz +0 -0
  98. package/dist/dashboard/assets/{state-D1Yz79iL.js → state-2c5V4zo7.js} +1 -1
  99. package/dist/dashboard/assets/state-2c5V4zo7.js.gz +0 -0
  100. package/dist/dashboard/assets/{switch-sevsRrRo.js → switch-Dkke7Naf.js} +1 -1
  101. package/dist/dashboard/assets/switch-Dkke7Naf.js.gz +0 -0
  102. package/dist/dashboard/assets/{useMutation-Dem0lXQj.js → useMutation-EDFRkn1i.js} +1 -1
  103. package/dist/dashboard/assets/useMutation-EDFRkn1i.js.gz +0 -0
  104. package/dist/dashboard/index.html +1 -1
  105. package/dist/dashboard/index.html.gz +0 -0
  106. package/dist/dashboard/precompressed-assets.json +51 -51
  107. package/migrations/README.md +52 -52
  108. package/migrations/full-postgres/0020_add_telemetry_event_analytics.sql +77 -0
  109. package/migrations/full-postgres/0021_add_api_key_concurrency_tolerance.sql +13 -0
  110. package/migrations/full-postgres/0022_add_inferhub_config_key.sql +17 -0
  111. package/migrations/full-postgres/0023_drop_api_key_concurrency_tolerance.sql +13 -0
  112. package/migrations/lite-sqlite3/0020_add_telemetry_event_analytics.sql +69 -0
  113. package/migrations/lite-sqlite3/0021_add_api_key_concurrency_tolerance.sql +13 -0
  114. package/migrations/lite-sqlite3/0022_add_inferhub_config_key.sql +17 -0
  115. package/migrations/lite-sqlite3/0023_drop_api_key_concurrency_tolerance.sql +13 -0
  116. package/package.json +96 -95
  117. package/scripts/cli/admin.ts +126 -128
  118. package/scripts/cli/autostart.ts +237 -237
  119. package/scripts/cli/background.ts +120 -110
  120. package/scripts/cli/bin/carte.cjs +8 -8
  121. package/scripts/cli/bin/cartethyia.cjs +167 -167
  122. package/scripts/cli/console-menu.ts +39 -30
  123. package/scripts/cli/controller.ts +145 -0
  124. package/scripts/cli/env.ts +99 -99
  125. package/scripts/cli/gateway.ts +264 -260
  126. package/scripts/cli/instance.ts +234 -224
  127. package/scripts/cli/main.ts +188 -167
  128. package/scripts/cli/platform.ts +64 -64
  129. package/scripts/cli/prompt.ts +217 -79
  130. package/scripts/cli/services.ts +88 -88
  131. package/scripts/cli/setup.ts +445 -445
  132. package/scripts/cli/start.ts +85 -85
  133. package/scripts/cli/startup-signal.ts +23 -23
  134. package/scripts/cli/telemetry.ts +70 -0
  135. package/scripts/cli/tray/index.ts +263 -263
  136. package/scripts/cli/tray/windows.ps1 +107 -107
  137. package/scripts/cli/tray.ts +29 -4
  138. package/scripts/cli/update.ts +39 -39
  139. package/scripts/cli/version.ts +67 -67
  140. package/scripts/cli/wordmark.ts +58 -58
  141. package/src/boot/app.ts +16 -1
  142. package/src/boot/dependencies.ts +128 -25
  143. package/src/config/resources.ts +137 -0
  144. package/src/config/runtime.ts +133 -76
  145. package/src/config/spec.ts +30 -29
  146. package/src/config/telemetry.ts +0 -25
  147. package/src/console/api-keys/bucket-spend.ts +14 -17
  148. package/src/console/api-keys/contracts.ts +410 -410
  149. package/src/console/api-keys/routes.ts +12 -0
  150. package/src/console/audit/store.ts +2 -3
  151. package/src/console/auth/setup-service.ts +8 -1
  152. package/src/console/backup/routes.ts +7 -0
  153. package/src/console/backup/service.ts +463 -463
  154. package/src/console/backup/store.ts +16 -11
  155. package/src/console/backup/validate.ts +1 -4
  156. package/src/console/cli-tools/fs-ops.ts +18 -2
  157. package/src/console/data-management/contracts.ts +1 -8
  158. package/src/console/data-management/operations.ts +61 -48
  159. package/src/console/data-management/routes.ts +4 -2
  160. package/src/console/domain-registration.ts +25 -3
  161. package/src/console/inferhub/client.ts +139 -0
  162. package/src/console/inferhub/key-store.ts +129 -0
  163. package/src/console/inferhub/normalize.ts +347 -0
  164. package/src/console/inferhub/routes.ts +411 -0
  165. package/src/console/inferhub/store.ts +109 -0
  166. package/src/console/inferhub/types.ts +183 -0
  167. package/src/console/observability/contracts.ts +2 -41
  168. package/src/console/observability/store.ts +176 -371
  169. package/src/console/providers/catalog/public-model-store.ts +0 -0
  170. package/src/console/providers/catalog/store.ts +23 -36
  171. package/src/console/quota/account-quota-shared.ts +52 -52
  172. package/src/console/quota/account-quota-tenant.ts +16 -24
  173. package/src/console/quota/refresh.ts +441 -441
  174. package/src/console/routing/pools/import-parse.ts +206 -206
  175. package/src/console/routing/pools/operations.ts +543 -543
  176. package/src/console/routing/pools/probe-verdict.ts +132 -132
  177. package/src/console/routing/pools/routes.ts +181 -181
  178. package/src/console/settings/contracts.ts +42 -40
  179. package/src/console/settings/store.ts +25 -25
  180. package/src/console/share/share-stats.ts +22 -4
  181. package/src/console/share/share-usage.ts +30 -4
  182. package/src/console/shared/cursor.ts +15 -0
  183. package/src/console/updates/routes.ts +12 -0
  184. package/src/main.ts +13 -5
  185. package/src/network/pool/resolver.ts +1104 -1104
  186. package/src/observability/client-name.ts +22 -0
  187. package/src/observability/event-log.ts +5 -29
  188. package/src/observability/event-query.ts +690 -0
  189. package/src/observability/log-ring.ts +26 -2
  190. package/src/observability/log-sanitize.ts +118 -0
  191. package/src/observability/logger.ts +17 -13
  192. package/src/observability/metrics.ts +35 -0
  193. package/src/observability/payload-capture.ts +114 -41
  194. package/src/observability/payload-store.ts +249 -24
  195. package/src/observability/raw-event-writer.ts +118 -0
  196. package/src/observability/runtime-metrics.ts +71 -1
  197. package/src/observability/telemetry-buffer.ts +100 -17
  198. package/src/observability/telemetry-reconciliation.ts +417 -0
  199. package/src/persistence/boot.ts +20 -9
  200. package/src/persistence/db-pg.ts +13 -0
  201. package/src/persistence/db-sqlite.ts +569 -497
  202. package/src/persistence/dialect.ts +3 -2
  203. package/src/persistence/migrate.ts +181 -181
  204. package/src/persistence/schema.ts +1496 -1406
  205. package/src/persistence/shared-cache.ts +5 -1
  206. package/src/persistence/stores/api-key-store.ts +581 -581
  207. package/src/persistence/stores/telemetry-store.ts +124 -63
  208. package/src/protocol/reasoning-text.ts +30 -0
  209. package/src/protocol/response/chat.ts +33 -7
  210. package/src/protocol/response/codex.ts +14 -2
  211. package/src/protocol/response/messages.ts +5 -2
  212. package/src/providers/authentication/oauth-flow-store.ts +3 -10
  213. package/src/providers/bundled-discovery-providers.ts +122 -122
  214. package/src/providers/default-registry.ts +10 -0
  215. package/src/providers/discovery/catalog/catalog-blob.ts +18 -18
  216. package/src/providers/discovery/models-dev-catalog.ts +354 -354
  217. package/src/providers/discovery/probe-phases.ts +43 -7
  218. package/src/providers/discovery/probing-service.ts +70 -32
  219. package/src/providers/integrations/antigravity/antigravity.ts +9 -2
  220. package/src/providers/integrations/buddy/buddy-catalog-shared.ts +85 -17
  221. package/src/providers/integrations/buddy/buddy-chat-shared.ts +934 -934
  222. package/src/providers/integrations/buddy/buddy-checkin.ts +5 -9
  223. package/src/providers/integrations/buddy/buddy-discovery-shared.ts +3 -7
  224. package/src/providers/integrations/buddy/buddy-quota-shared.ts +3 -1
  225. package/src/providers/integrations/buddy/codebuddy/cn-provider.ts +57 -49
  226. package/src/providers/integrations/buddy/codebuddy/provider.ts +180 -180
  227. package/src/providers/integrations/buddy/workbuddy/provider.ts +206 -206
  228. package/src/providers/integrations/buddy/workbuddy/quota.ts +161 -161
  229. package/src/providers/integrations/claude/claude-quota.ts +2 -2
  230. package/src/providers/integrations/claude/claude-user-profiles.ts +3 -2
  231. package/src/providers/integrations/claude/claude.ts +3 -4
  232. package/src/providers/integrations/cline/cline.ts +94 -8
  233. package/src/providers/integrations/codex/codex.ts +61 -37
  234. package/src/providers/integrations/cursor/cursor-catalog.ts +368 -368
  235. package/src/providers/integrations/cursor/cursor-http2.ts +300 -300
  236. package/src/providers/integrations/cursor/cursor-identity.ts +2 -4
  237. package/src/providers/integrations/cursor/cursor-request.ts +11 -0
  238. package/src/providers/integrations/cursor/cursor.ts +29 -17
  239. package/src/providers/integrations/cursor/protocol/framing.ts +129 -129
  240. package/src/providers/integrations/cursor/protocol/messages.ts +2380 -2380
  241. package/src/providers/integrations/cursor/protocol/struct.ts +305 -305
  242. package/src/providers/integrations/cursor/protocol/tool-canonical.ts +275 -0
  243. package/src/providers/integrations/grok/grok-quota.ts +279 -279
  244. package/src/providers/integrations/grok/grok-ratelimit-store.ts +73 -73
  245. package/src/providers/integrations/grok/grok-ratelimit.ts +218 -218
  246. package/src/providers/integrations/hermes.ts +7 -1
  247. package/src/providers/integrations/inferhub.ts +201 -73
  248. package/src/providers/integrations/kilo/kilo.ts +152 -152
  249. package/src/providers/integrations/kiro/kiro-machine-id.ts +2 -4
  250. package/src/providers/integrations/openrouter/provider.ts +129 -129
  251. package/src/providers/integrations/qoder/qoder-quota.ts +193 -192
  252. package/src/providers/integrations/qoder/qoder-realm.ts +106 -106
  253. package/src/providers/integrations/trae/trae-oauth.ts +4 -26
  254. package/src/providers/integrations/trae/trae-quota.ts +389 -384
  255. package/src/providers/integrations/trae/trae-realm.ts +15 -2
  256. package/src/providers/integrations/xiaomi-mimo/think-stream.ts +100 -24
  257. package/src/providers/integrations/zcode/oauth.ts +1 -1
  258. package/src/providers/operations/account-health-service.ts +605 -279
  259. package/src/providers/provider-metadata.ts +12 -1
  260. package/src/providers/provider-model-definition.ts +373 -373
  261. package/src/providers/quota/quota-contracts.ts +3 -7
  262. package/src/providers/search/search-provider.ts +2 -6
  263. package/src/security/admission/contracts.ts +10 -0
  264. package/src/security/admission/in-memory-store.ts +89 -14
  265. package/src/security/admission/lua.ts +191 -182
  266. package/src/security/admission/redis-store.ts +30 -0
  267. package/src/security/admission/service.ts +18 -0
  268. package/src/security/api-key-auth-cache.ts +96 -0
  269. package/src/security/api-key-auth.ts +11 -100
  270. package/src/security/client-router-fingerprint.ts +3 -7
  271. package/src/security/ip-boundary.ts +7 -3
  272. package/src/shared/coerce.ts +67 -0
  273. package/src/shared/hash.ts +6 -0
  274. package/src/shared/time.ts +7 -0
  275. package/src/transport/dispatch/attempt-finalize.ts +26 -138
  276. package/src/transport/dispatch/attempt-loop.ts +103 -81
  277. package/src/transport/dispatch/capture-policy.ts +78 -0
  278. package/src/transport/dispatch/proxy-request.ts +4 -2
  279. package/src/transport/dispatch/streaming/encoder.ts +111 -5
  280. package/src/transport/dispatch/streaming/stream.ts +19 -2
  281. package/src/transport/dispatch/streaming/thinking-loop.ts +300 -0
  282. package/src/transport/failure-policy.ts +27 -0
  283. package/src/transport/guards/abuse-guard.ts +2 -2
  284. package/src/transport/guards/auth-guard.ts +17 -0
  285. package/src/transport/guards/body-policy.ts +3 -2
  286. package/src/transport/guards/concurrency-guard.ts +89 -13
  287. package/src/transport/guards/early-rejection-capture.ts +43 -3
  288. package/src/transport/guards/error-lifecycle.ts +10 -2
  289. package/src/transport/guards/guard-errors.ts +23 -0
  290. package/src/transport/guards/headroom-guard.ts +41 -7
  291. package/src/transport/guards/pipeline.ts +24 -0
  292. package/src/transport/guards/request-context.ts +2 -1
  293. package/src/transport/routing/admission.ts +51 -9
  294. package/src/transport/routing/bounded-lru.ts +49 -0
  295. package/src/transport/routing/candidate-index.ts +88 -0
  296. package/src/transport/routing/reservations.ts +121 -7
  297. package/src/transport/routing/round-robin.ts +10 -22
  298. package/src/transport/routing/route-catalog.ts +26 -13
  299. package/src/transport/routing/route-model.ts +22 -2
  300. package/src/transport/routing/router.ts +53 -31
  301. package/src/transport/surface/chat/encode.ts +2 -3
  302. package/src/transport/surface/chat/parse.ts +2 -1
  303. package/src/transport/surface/dialects.ts +11 -3
  304. package/src/transport/surface/messages/encode.ts +2 -1
  305. package/src/transport/surface/messages/parse.ts +3 -1
  306. package/src/transport/surface/messages/stream.ts +2 -6
  307. package/src/workers/maintenance-lease.ts +200 -0
  308. package/src/workers/scheduler.ts +101 -1
  309. package/dist/dashboard/assets/BannedPage-Cd30Z0b9.js.gz +0 -0
  310. package/dist/dashboard/assets/CliToolDetailPage-wf5u-ww6.js.gz +0 -0
  311. package/dist/dashboard/assets/CliToolsPage-DykPvuck.js.gz +0 -0
  312. package/dist/dashboard/assets/CombosPage-DwvSD3vs.js.gz +0 -0
  313. package/dist/dashboard/assets/ConsoleLogPage-CIr7KDex.js.gz +0 -0
  314. package/dist/dashboard/assets/CustomizationPage-82X0DDdH.js.gz +0 -0
  315. package/dist/dashboard/assets/HealthEventsModal-BYPucTTn.js +0 -1
  316. package/dist/dashboard/assets/HealthEventsModal-BYPucTTn.js.gz +0 -0
  317. package/dist/dashboard/assets/LoginPage-CS5mPuVF.js.gz +0 -0
  318. package/dist/dashboard/assets/ModelPicker-C0UCUsVJ.js.gz +0 -0
  319. package/dist/dashboard/assets/OverviewPage-Dk_ew3YU.js +0 -1
  320. package/dist/dashboard/assets/OverviewPage-Dk_ew3YU.js.gz +0 -0
  321. package/dist/dashboard/assets/ProviderDetailPage-CAiRn2Q0.js +0 -3
  322. package/dist/dashboard/assets/ProviderDetailPage-CAiRn2Q0.js.gz +0 -0
  323. package/dist/dashboard/assets/ProvidersPage-vdja5ZPK.js.gz +0 -0
  324. package/dist/dashboard/assets/ProxyPage-BB4CNm4-.js.gz +0 -0
  325. package/dist/dashboard/assets/QuotaPage-FEc4iNyq.js +0 -1
  326. package/dist/dashboard/assets/QuotaPage-FEc4iNyq.js.gz +0 -0
  327. package/dist/dashboard/assets/SettingsPage-Dlfi5L4N.js +0 -1
  328. package/dist/dashboard/assets/SettingsPage-Dlfi5L4N.js.gz +0 -0
  329. package/dist/dashboard/assets/SetupPage-FadUOl9S.js.gz +0 -0
  330. package/dist/dashboard/assets/SortableList-Cfy6_Khv.js.gz +0 -0
  331. package/dist/dashboard/assets/UsagePage-BT4MyQeT.js.gz +0 -0
  332. package/dist/dashboard/assets/api-keys-DQYLQxpW.js.gz +0 -0
  333. package/dist/dashboard/assets/app-CX_Z8cMs.css +0 -2
  334. package/dist/dashboard/assets/app-CX_Z8cMs.css.gz +0 -0
  335. package/dist/dashboard/assets/assert-C9FD_JwJ.js.gz +0 -0
  336. package/dist/dashboard/assets/badge-Bvyr7D0u.js.gz +0 -0
  337. package/dist/dashboard/assets/banner-DIvKAZih.js.gz +0 -0
  338. package/dist/dashboard/assets/card-D5_gYIS_.js.gz +0 -0
  339. package/dist/dashboard/assets/cli-tools-CNqzrfdb.js.gz +0 -0
  340. package/dist/dashboard/assets/clipboard-button-B6vDBtbm.js.gz +0 -0
  341. package/dist/dashboard/assets/dialog-CpjrgdwJ.js.gz +0 -0
  342. package/dist/dashboard/assets/drawer-BWmt3b5y.js.gz +0 -0
  343. package/dist/dashboard/assets/entry-B-gvimrg.js +0 -1
  344. package/dist/dashboard/assets/entry-B-gvimrg.js.gz +0 -0
  345. package/dist/dashboard/assets/entry-C_oakV3z.js.gz +0 -0
  346. package/dist/dashboard/assets/entry-Dj0cHfEc.js +0 -4
  347. package/dist/dashboard/assets/entry-Dj0cHfEc.js.gz +0 -0
  348. package/dist/dashboard/assets/github-badge-RRHHWGxh.js.gz +0 -0
  349. package/dist/dashboard/assets/index-Bhey_rg_.js.gz +0 -0
  350. package/dist/dashboard/assets/inline-C2_EQ5Uf.js.gz +0 -0
  351. package/dist/dashboard/assets/input-BMRlQ450.js.gz +0 -0
  352. package/dist/dashboard/assets/layout-XR5xP8ew.js.gz +0 -0
  353. package/dist/dashboard/assets/list-row-Cw07dioc.js.gz +0 -0
  354. package/dist/dashboard/assets/live-BivGfuUs.js.gz +0 -0
  355. package/dist/dashboard/assets/model-bans-CmffjLuG.js.gz +0 -0
  356. package/dist/dashboard/assets/mutation-46P0oWMe.js.gz +0 -0
  357. package/dist/dashboard/assets/numbers-DrdA9oSM.js.gz +0 -0
  358. package/dist/dashboard/assets/query-keys-BFhGpwR1.js.gz +0 -0
  359. package/dist/dashboard/assets/routing-CAb4F37R.js.gz +0 -0
  360. package/dist/dashboard/assets/select-CxVVB__N.js.gz +0 -0
  361. package/dist/dashboard/assets/settings-DNX9sydi.js.gz +0 -0
  362. package/dist/dashboard/assets/stack-uPOwM9Hs.js.gz +0 -0
  363. package/dist/dashboard/assets/state-D1Yz79iL.js.gz +0 -0
  364. package/dist/dashboard/assets/switch-sevsRrRo.js.gz +0 -0
  365. package/dist/dashboard/assets/useMutation-Dem0lXQj.js.gz +0 -0
  366. package/src/observability/event-compaction.ts +0 -317
  367. package/src/observability/event-day-index.ts +0 -439
@@ -16,6 +16,9 @@ import { chooseFromMenu, confirmByTyping, confirmStep, type MenuRow } from "./pr
16
16
  import { renderWordmark } from "./wordmark";
17
17
  import { exportBackup, resetConsoleAccount } from "./admin";
18
18
  import { runDetachedUpdate } from "./update";
19
+ import { acquireController } from "./controller";
20
+ import { restoreControllerAfterFailedHandoff } from "./controller";
21
+ import { attachGateway as attachRunningGateway } from "./instance";
19
22
 
20
23
 
21
24
  /**
@@ -25,6 +28,18 @@ import { runDetachedUpdate } from "./update";
25
28
  * and is never seen. `carte --tray` opts into the icon instead.
26
29
  */
27
30
  export async function startMenu(): Promise<void> {
31
+ const controller = acquireController("menu");
32
+ const releaseController = (): void => controller.release();
33
+ process.once("exit", releaseController);
34
+ try {
35
+ await runMenu(controller.handoff);
36
+ } finally {
37
+ controller.release();
38
+ process.removeListener("exit", releaseController);
39
+ }
40
+ }
41
+
42
+ async function runMenu(handoff: { readonly token: string; readonly parentPid: number }): Promise<void> {
28
43
  // A fresh install has no config file yet, and the gateway refuses to boot
29
44
  // without CARTETHYIA_DB_MODE — which surfaced as a stack trace about a
30
45
  // missing database mode rather than "you have not set this up". Run setup
@@ -136,6 +151,7 @@ export async function startMenu(): Promise<void> {
136
151
 
137
152
  let latestVersion = await readLatestPublishedVersion();
138
153
  let running = true;
154
+ let backupStatus: { readonly tone: "success" | "error"; readonly text: string } | undefined;
139
155
 
140
156
  while (running) {
141
157
  const upToDate = latestVersion === null || !isNewerRelease(currentVersion, latestVersion);
@@ -146,16 +162,15 @@ export async function startMenu(): Promise<void> {
146
162
  // action that matters.
147
163
  const versionCheckerLine =
148
164
  latestVersion === null
149
- ? `Version Checker: ${currentVersion} \u00b7 could not reach the registry`
165
+ ? `${currentVersion} \u00b7 registry unavailable`
150
166
  : upToDate
151
- ? `Version Checker: ${currentVersion} \u00b7 up to date`
152
- : `Version Checker: ${currentVersion} \u2192 ${latestVersion} available`;
167
+ ? `${currentVersion} \u00b7 up to date`
168
+ : `${currentVersion} \u2192 ${latestVersion} available`;
153
169
  const header = [
154
170
  ...renderWordmark(),
155
- `Cartethyia ${currentVersion} \u00b7 ${dashboardUrl}`,
156
- versionCheckerLine,
171
+ `Console ${dashboardUrl}`,
172
+ `Version ${versionCheckerLine}`,
157
173
  "",
158
- "Section menu",
159
174
  ];
160
175
 
161
176
  // Anything that talks to the gateway waits for it to be listening; the menu
@@ -167,30 +182,23 @@ export async function startMenu(): Promise<void> {
167
182
  const actions: readonly (() => Promise<void>)[] = [
168
183
  async () => openUrl(dashboardUrl),
169
184
  async () => {
170
- // Drain first, then start the tray.
171
- //
172
- // The tray's own startup probe refuses to boot when the port is busy,
173
- // so starting it before this process has let go made it fail with
174
- // "already running on port" every time — while the menu still printed
175
- // that everything was running, because a detached child with no stdio
176
- // cannot report anything back. Releasing the port first is the fix, and
177
- // waiting on the child's signal is what stops the menu from claiming
178
- // success for a child that died.
179
- console.log("Stopping the gateway to free the port…");
185
+ // Transfer the live gateway rather than draining it first. The tray
186
+ // adopts this instance through the ownership marker and reports ready
187
+ // before the menu returns to the shell.
180
188
  running = false;
181
189
  stopping = true;
182
190
  handedOff = true;
183
- await shutDown();
184
191
  console.log("Starting the tray in the background…");
185
192
  const signalPath = join(tmpdir(), `cartethyia-tray-menu-${process.pid}-${randomUUID()}.json`);
186
193
  try {
187
194
  const url = await launchBackgroundWithStatus(
188
195
  ["run", resolvePackagePath("scripts", "cli", "main.ts"), "tray"],
189
- { signalPath },
196
+ { signalPath, controllerHandoff: handoff },
190
197
  );
191
198
  console.log(`Cartethyia is running in the background at ${url || dashboardUrl}.`);
192
199
  console.log("The tray icon owns it; use Exit there to stop it.");
193
200
  } catch (error) {
201
+ restoreControllerAfterFailedHandoff(handoff);
194
202
  handedOff = false;
195
203
  console.error(
196
204
  `Could not hand off to the tray: ${error instanceof Error ? error.message : "unknown error"}`,
@@ -205,11 +213,12 @@ export async function startMenu(): Promise<void> {
205
213
  // the database, so the loopback token is the whole gate.
206
214
  await whenReady();
207
215
  const result = await exportBackup(port, drainToken);
208
- if (result === null) return;
209
- console.log(
210
- `Backup written to ${result.path} (${(result.bytes / 1024).toFixed(1)} KiB, ${Object.keys(result.counts).length} tables).`,
211
- );
212
- console.log("Telemetry is not included: it is per-request history, not configuration.");
216
+ backupStatus = "error" in result
217
+ ? { tone: "error", text: `Backup failed: ${result.error}` }
218
+ : {
219
+ tone: "success",
220
+ text: `Backup saved at: ${result.path} (${(result.bytes / 1024).toFixed(1)} KiB)`,
221
+ };
213
222
  },
214
223
  async () => {
215
224
  // Deleting the console account is the only real password recovery:
@@ -266,23 +275,23 @@ export async function startMenu(): Promise<void> {
266
275
  : [...actions.slice(0, actions.length - 1), updateAction, actions[actions.length - 1] ?? (async () => undefined)];
267
276
 
268
277
  const rows: MenuRow[] = [
269
- { kind: "action", label: "Open Console", hint: `browser \u00b7 ${dashboardUrl}` },
270
- { kind: "action", label: "Hide to Tray", hint: "keep the gateway running" },
271
- { kind: "action", label: "Backup", hint: "export config to Downloads" },
272
- { kind: "action", label: "Reset", hint: "deletes the console account" },
278
+ { kind: "action", label: "Open Console", hint: "Open dashboard in browser" },
279
+ { kind: "action", label: "Hide to Tray", hint: "Keep the gateway running" },
280
+ { kind: "action", label: "Backup", hint: "Export config to Downloads" },
281
+ { kind: "action", label: "Reset", hint: "Delete console account" },
273
282
  ];
274
283
  if (!upToDate && latestVersion !== null) {
275
284
  rows.push({ kind: "action", label: `Update to ${latestVersion}`, hint: "installs now and exits" });
276
285
  }
277
286
  rows.push({ kind: "rule" });
278
- rows.push({ kind: "action", label: "Exit", hint: "stop the gateway" });
287
+ rows.push({ kind: "action", label: "Exit", hint: "Stop the gateway" });
279
288
 
280
289
  // Focus the Update row by default when it exists, so the operator who
281
290
  // launched `carte` *because* they saw the version checker gets a one-key
282
291
  // path to the fix.
283
292
  const updateIndex = upToDate || latestVersion === null ? -1 : 4;
284
293
  const initialActionIndex = updateIndex >= 0 ? updateIndex : 0;
285
- const choice = await chooseFromMenu(header, rows, { initialIndex: initialActionIndex });
294
+ const choice = await chooseFromMenu(header, rows, { initialIndex: initialActionIndex, ...(backupStatus ? { status: backupStatus } : {}) });
286
295
  if (choice === -1) break;
287
296
  const action = extendedActions[choice];
288
297
  if (action) await action();
@@ -291,7 +300,7 @@ export async function startMenu(): Promise<void> {
291
300
  // would stop the very process the tray just adopted, which is why the menu
292
301
  // appeared to hand over a gateway that was not listening.
293
302
  if (handedOff) return;
294
- if (runningAttached !== null) {
303
+ if (runningAttached !== null || (await attachRunningGateway(port, drainToken)) !== null) {
295
304
  detachAttached();
296
305
  return;
297
306
  }
@@ -0,0 +1,145 @@
1
+ import { mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { pidAlive } from "./instance";
4
+
5
+ /** A menu or tray that exclusively owns this installation's interactive UI. */
6
+ export type ControllerKind = "menu" | "tray";
7
+
8
+ /** Proof passed to the tray during Hide to Tray. Never printed or persisted in logs. */
9
+ export interface ControllerHandoff {
10
+ readonly token: string;
11
+ readonly parentPid: number;
12
+ }
13
+
14
+ export interface ControllerLease {
15
+ readonly handoff: ControllerHandoff;
16
+ readonly release: () => void;
17
+ }
18
+
19
+ interface ControllerRecord extends ControllerHandoff {
20
+ readonly pid: number;
21
+ readonly kind: ControllerKind;
22
+ readonly startedAt: string;
23
+ }
24
+
25
+ const CONTROLLER_FILE = "controller.json";
26
+
27
+ function controllerPath(): string {
28
+ const configDir = process.env.CARTETHYIA_CONFIG_DIR;
29
+ if (!configDir) throw new Error("Cartethyia config directory is unavailable");
30
+ mkdirSync(configDir, { recursive: true });
31
+ return join(configDir, CONTROLLER_FILE);
32
+ }
33
+
34
+ function readController(path: string): ControllerRecord | null {
35
+ try {
36
+ const value = JSON.parse(readFileSync(path, "utf8")) as Partial<ControllerRecord>;
37
+ if (typeof value.pid !== "number" || typeof value.parentPid !== "number" ||
38
+ typeof value.token !== "string" || value.token.length === 0 ||
39
+ (value.kind !== "menu" && value.kind !== "tray") || typeof value.startedAt !== "string") return null;
40
+ return value as ControllerRecord;
41
+ } catch (error) {
42
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
43
+ // A partially rewritten or corrupt file must not be treated as permission
44
+ // to start a second gateway. It is only safe to reclaim a record whose old
45
+ // owner can be identified and is definitely dead.
46
+ throw new Error("The Cartethyia controller lock is unreadable; close carte/tray before retrying");
47
+ }
48
+ }
49
+
50
+ function writeController(path: string, record: ControllerRecord, exclusive: boolean): void {
51
+ writeFileSync(path, JSON.stringify(record), {
52
+ encoding: "utf8",
53
+ mode: 0o600,
54
+ ...(exclusive ? { flag: "wx" } : {}),
55
+ });
56
+ }
57
+
58
+ /**
59
+ * Acquire the one interactive controller for an installation.
60
+ *
61
+ * Hide to Tray passes the existing token to its child. The child replaces the
62
+ * pid in the same file without unlinking it, so a second menu cannot sneak into
63
+ * the handoff gap and drain the gateway while the first tray is taking over.
64
+ */
65
+ export function acquireController(kind: ControllerKind, handoff?: ControllerHandoff): ControllerLease {
66
+ const path = controllerPath();
67
+ if (handoff !== undefined) {
68
+ const current = readController(path);
69
+ if (current === null || current.token !== handoff.token || current.parentPid !== handoff.parentPid ||
70
+ current.kind !== "menu") {
71
+ throw new Error("The menu-to-tray handoff is no longer owned by its original menu");
72
+ }
73
+ writeController(path, { ...current, pid: process.pid, kind: "tray" }, false);
74
+ } else {
75
+ const current = readController(path);
76
+ if (current !== null && pidAlive(current.pid)) {
77
+ throw new Error(`Cartethyia is already controlled by a ${current.kind} (PID ${current.pid}); not starting another instance`);
78
+ }
79
+ const token = crypto.randomUUID();
80
+ // Writing the replacement atomically avoids a corruption window in which
81
+ // another carte cannot tell whether the old owner was alive. A concurrent
82
+ // claimant reads either the complete old record or this complete new one.
83
+ const next: ControllerRecord = {
84
+ pid: process.pid,
85
+ parentPid: process.pid,
86
+ token,
87
+ kind,
88
+ startedAt: new Date().toISOString(),
89
+ };
90
+ try {
91
+ if (current !== null) {
92
+ const temporary = `${path}.${process.pid}.tmp`;
93
+ writeFileSync(temporary, JSON.stringify(next), { encoding: "utf8", mode: 0o600 });
94
+ try { renameSync(temporary, path); } catch (error) {
95
+ try { unlinkSync(temporary); } catch { /* Preserve the rename error. */ }
96
+ throw error;
97
+ }
98
+ } else writeController(path, next, true);
99
+ } catch (error) {
100
+ if ((error as NodeJS.ErrnoException).code === "EEXIST") {
101
+ throw new Error("Another Cartethyia menu or tray acquired the controller; not starting another instance");
102
+ }
103
+ throw error;
104
+ }
105
+ }
106
+
107
+ const token = handoff?.token ?? readController(path)?.token;
108
+ if (!token) throw new Error("Could not establish the Cartethyia controller lock");
109
+ const pid = process.pid;
110
+ let released = false;
111
+ return {
112
+ handoff: { token, parentPid: pid },
113
+ release: () => {
114
+ if (released) return;
115
+ released = true;
116
+ const current = readController(path);
117
+ // During a successful handoff the file already belongs to the tray. The
118
+ // parent must not delete its child's lease while returning to the shell.
119
+ if (current?.token === token && current.pid === pid) {
120
+ try { unlinkSync(path); } catch { /* A dead owner is reclaimed next start. */ }
121
+ }
122
+ },
123
+ };
124
+ }
125
+
126
+ /** Hand a failed tray startup back to the still-running menu. */
127
+ export function restoreControllerAfterFailedHandoff(handoff: ControllerHandoff): void {
128
+ const path = controllerPath();
129
+ const current = readController(path);
130
+ if (current?.token === handoff.token && current.parentPid === handoff.parentPid && current.kind === "tray") {
131
+ writeController(path, { ...current, pid: handoff.parentPid, kind: "menu" }, false);
132
+ }
133
+ }
134
+
135
+ /** Read the one-shot handoff proof without exposing it to normal process logs. */
136
+ export function inheritedControllerHandoff(env: NodeJS.ProcessEnv = process.env): ControllerHandoff | undefined {
137
+ const token = env.CARTETHYIA_CONTROLLER_TOKEN;
138
+ const parentPid = Number(env.CARTETHYIA_CONTROLLER_PARENT_PID);
139
+ if (!token || !Number.isInteger(parentPid) || parentPid <= 0) {
140
+ delete env.CARTETHYIA_CONTROLLER_TOKEN;
141
+ delete env.CARTETHYIA_CONTROLLER_PARENT_PID;
142
+ return undefined;
143
+ }
144
+ return { token, parentPid };
145
+ }
@@ -1,99 +1,99 @@
1
- import { existsSync } from "node:fs";
2
- import { readFile } from "node:fs/promises";
3
- import { resolve } from "node:path";
4
- import { PACKAGE_ROOT } from "../../src/config/package-paths";
5
-
6
- /**
7
- * Absolute path of the repository root.
8
- *
9
- * Operational scripts and the npm runtime share one package-root resolver so
10
- * assets and repository config do not depend on the caller's working directory.
11
- */
12
- export const PROJECT_ROOT = PACKAGE_ROOT;
13
-
14
- /** Resolve the active `.env`; npm CLI sets a user-owned config directory. */
15
- export function resolveEnvPath(env: NodeJS.ProcessEnv = process.env): string {
16
- const configDir = env.CARTETHYIA_CONFIG_DIR?.trim();
17
- return configDir ? resolve(configDir, ".env") : resolve(PROJECT_ROOT, ".env");
18
- }
19
-
20
- /** Absolute path of the active `.env`. */
21
- export const ENV_PATH = resolveEnvPath();
22
-
23
- /**
24
- * Reads a dotenv-style file without overriding already-exported variables.
25
- */
26
- export async function readEnvFile(path: string): Promise<Record<string, string>> {
27
- if (!existsSync(path)) return {};
28
- const content = await readFile(path, "utf8");
29
- const values: Record<string, string> = {};
30
- for (const line of content.split(/\r?\n/)) {
31
- if (line.startsWith("#")) continue;
32
- const separator = line.indexOf("=");
33
- if (separator <= 0) continue;
34
- const key = line.slice(0, separator).trim();
35
- const value = line.slice(separator + 1).trim();
36
- if (key) values[key] = value;
37
- }
38
- return values;
39
- }
40
-
41
- /**
42
- * Checks if a string contains a configured placeholder value.
43
- */
44
- export function hasPlaceholderSecrets(value: string): boolean {
45
- const normalized = value.toLowerCase();
46
- return (
47
- normalized.includes("replace-with") ||
48
- normalized.includes("replace_me") ||
49
- normalized.includes("your_")
50
- );
51
- }
52
-
53
- /**
54
- * Generates a 256-bit hex cartethyia encryption key.
55
- *
56
- * Hex is preferred over base64 because it never carries padding or `+`/`/`
57
- * characters that confuse some dotenv shells, and it round-trips through
58
- * every adapter that accepts either encoding. The install/setup helpers call
59
- * this when the key is absent or still a placeholder.
60
- */
61
- export function generateCartethyiaEncryptionKey(): string {
62
- const bytes = new Uint8Array(32);
63
- crypto.getRandomValues(bytes);
64
- let hex = "";
65
- for (let i = 0; i < bytes.length; i += 1) hex += bytes[i]!.toString(16).padStart(2, "0");
66
- return hex;
67
- }
68
-
69
- /**
70
- * Returns the mandatory-only `.env` body derived from `.env.example`.
71
- *
72
- * Mandatory means the key is assigned without a leading `#`. Optional entries
73
- * (leading `#`) carry built-in defaults and must not be copied into a fresh
74
- * `.env` — they would override the default with a literal `example` value.
75
- * The generated body therefore contains only mandatory rows, plus a single
76
- * `CARTETHYIA_ENCRYPTION_KEY` replacement when the caller supplies one.
77
- */
78
- export async function mandatoryEnvBody(
79
- examplePath: string,
80
- overrides: Readonly<Record<string, string>> = {},
81
- ): Promise<string> {
82
- const raw = await readFile(examplePath, "utf8");
83
- const lines: string[] = [];
84
- for (const line of raw.split(/\r?\n/)) {
85
- const trimmed = line.trim();
86
- if (trimmed === "" || trimmed.startsWith("#")) continue;
87
- const sep = line.indexOf("=");
88
- if (sep <= 0) continue;
89
- const key = line.slice(0, sep).trim();
90
- if (!key) continue;
91
- const value = (overrides as Record<string, string>)[key] ?? line.slice(sep + 1).trim();
92
- lines.push(`${key}=${value}`);
93
- }
94
- // Preserve overrides even when the example row was commented out (legacy case).
95
- for (const [key, value] of Object.entries(overrides)) {
96
- if (!lines.some((l) => l.startsWith(`${key}=`))) lines.push(`${key}=${value}`);
97
- }
98
- return `${lines.join("\n")}\n`;
99
- }
1
+ import { existsSync } from "node:fs";
2
+ import { readFile } from "node:fs/promises";
3
+ import { resolve } from "node:path";
4
+ import { PACKAGE_ROOT } from "../../src/config/package-paths";
5
+
6
+ /**
7
+ * Absolute path of the repository root.
8
+ *
9
+ * Operational scripts and the npm runtime share one package-root resolver so
10
+ * assets and repository config do not depend on the caller's working directory.
11
+ */
12
+ export const PROJECT_ROOT = PACKAGE_ROOT;
13
+
14
+ /** Resolve the active `.env`; npm CLI sets a user-owned config directory. */
15
+ export function resolveEnvPath(env: NodeJS.ProcessEnv = process.env): string {
16
+ const configDir = env.CARTETHYIA_CONFIG_DIR?.trim();
17
+ return configDir ? resolve(configDir, ".env") : resolve(PROJECT_ROOT, ".env");
18
+ }
19
+
20
+ /** Absolute path of the active `.env`. */
21
+ export const ENV_PATH = resolveEnvPath();
22
+
23
+ /**
24
+ * Reads a dotenv-style file without overriding already-exported variables.
25
+ */
26
+ export async function readEnvFile(path: string): Promise<Record<string, string>> {
27
+ if (!existsSync(path)) return {};
28
+ const content = await readFile(path, "utf8");
29
+ const values: Record<string, string> = {};
30
+ for (const line of content.split(/\r?\n/)) {
31
+ if (line.startsWith("#")) continue;
32
+ const separator = line.indexOf("=");
33
+ if (separator <= 0) continue;
34
+ const key = line.slice(0, separator).trim();
35
+ const value = line.slice(separator + 1).trim();
36
+ if (key) values[key] = value;
37
+ }
38
+ return values;
39
+ }
40
+
41
+ /**
42
+ * Checks if a string contains a configured placeholder value.
43
+ */
44
+ export function hasPlaceholderSecrets(value: string): boolean {
45
+ const normalized = value.toLowerCase();
46
+ return (
47
+ normalized.includes("replace-with") ||
48
+ normalized.includes("replace_me") ||
49
+ normalized.includes("your_")
50
+ );
51
+ }
52
+
53
+ /**
54
+ * Generates a 256-bit hex cartethyia encryption key.
55
+ *
56
+ * Hex is preferred over base64 because it never carries padding or `+`/`/`
57
+ * characters that confuse some dotenv shells, and it round-trips through
58
+ * every adapter that accepts either encoding. The install/setup helpers call
59
+ * this when the key is absent or still a placeholder.
60
+ */
61
+ export function generateCartethyiaEncryptionKey(): string {
62
+ const bytes = new Uint8Array(32);
63
+ crypto.getRandomValues(bytes);
64
+ let hex = "";
65
+ for (let i = 0; i < bytes.length; i += 1) hex += bytes[i]!.toString(16).padStart(2, "0");
66
+ return hex;
67
+ }
68
+
69
+ /**
70
+ * Returns the mandatory-only `.env` body derived from `.env.example`.
71
+ *
72
+ * Mandatory means the key is assigned without a leading `#`. Optional entries
73
+ * (leading `#`) carry built-in defaults and must not be copied into a fresh
74
+ * `.env` — they would override the default with a literal `example` value.
75
+ * The generated body therefore contains only mandatory rows, plus a single
76
+ * `CARTETHYIA_ENCRYPTION_KEY` replacement when the caller supplies one.
77
+ */
78
+ export async function mandatoryEnvBody(
79
+ examplePath: string,
80
+ overrides: Readonly<Record<string, string>> = {},
81
+ ): Promise<string> {
82
+ const raw = await readFile(examplePath, "utf8");
83
+ const lines: string[] = [];
84
+ for (const line of raw.split(/\r?\n/)) {
85
+ const trimmed = line.trim();
86
+ if (trimmed === "" || trimmed.startsWith("#")) continue;
87
+ const sep = line.indexOf("=");
88
+ if (sep <= 0) continue;
89
+ const key = line.slice(0, sep).trim();
90
+ if (!key) continue;
91
+ const value = (overrides as Record<string, string>)[key] ?? line.slice(sep + 1).trim();
92
+ lines.push(`${key}=${value}`);
93
+ }
94
+ // Preserve overrides even when the example row was commented out (legacy case).
95
+ for (const [key, value] of Object.entries(overrides)) {
96
+ if (!lines.some((l) => l.startsWith(`${key}=`))) lines.push(`${key}=${value}`);
97
+ }
98
+ return `${lines.join("\n")}\n`;
99
+ }