cartethyia 2.5.4 → 2.5.6

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 (395) hide show
  1. package/.env.example +13 -4
  2. package/README.md +2 -2
  3. package/dist/dashboard/assets/{BannedPage-C2orBsI2.js → BannedPage-CADDq4IU.js} +1 -1
  4. package/dist/dashboard/assets/BannedPage-CADDq4IU.js.gz +0 -0
  5. package/dist/dashboard/assets/CliToolDetailPage-_z1hID7E.js +1 -0
  6. package/dist/dashboard/assets/CliToolDetailPage-_z1hID7E.js.gz +0 -0
  7. package/dist/dashboard/assets/{CliToolsPage-BV6pxVQo.js → CliToolsPage-DuO1Z7dq.js} +1 -1
  8. package/dist/dashboard/assets/CliToolsPage-DuO1Z7dq.js.gz +0 -0
  9. package/dist/dashboard/assets/CombosPage-DNEPi3xU.js +1 -0
  10. package/dist/dashboard/assets/CombosPage-DNEPi3xU.js.gz +0 -0
  11. package/dist/dashboard/assets/{ConsoleLogPage-CW-wPmJ6.js → ConsoleLogPage-BVdpfiz1.js} +1 -1
  12. package/dist/dashboard/assets/ConsoleLogPage-BVdpfiz1.js.gz +0 -0
  13. package/dist/dashboard/assets/{CustomizationPage-0p8uX7vB.js → CustomizationPage-BR5HHvbi.js} +1 -1
  14. package/dist/dashboard/assets/CustomizationPage-BR5HHvbi.js.gz +0 -0
  15. package/dist/dashboard/assets/{HealthEventsModal-BjqSMfko.js → HealthEventsModal-mUfcknqm.js} +1 -1
  16. package/dist/dashboard/assets/HealthEventsModal-mUfcknqm.js.gz +0 -0
  17. package/dist/dashboard/assets/{LoginPage-BvzCx8Kr.js → LoginPage-V5faV9V2.js} +1 -1
  18. package/dist/dashboard/assets/LoginPage-V5faV9V2.js.gz +0 -0
  19. package/dist/dashboard/assets/ModelPicker-C7pDb41R.js +1 -0
  20. package/dist/dashboard/assets/ModelPicker-C7pDb41R.js.gz +0 -0
  21. package/dist/dashboard/assets/OverviewPage-BDu20Jdx.js +1 -0
  22. package/dist/dashboard/assets/OverviewPage-BDu20Jdx.js.gz +0 -0
  23. package/dist/dashboard/assets/ProviderDetailPage-Dllr_aFO.js +3 -0
  24. package/dist/dashboard/assets/ProviderDetailPage-Dllr_aFO.js.gz +0 -0
  25. package/dist/dashboard/assets/{ProvidersPage-Js9jUPSb.js → ProvidersPage-uPiGVTTK.js} +1 -1
  26. package/dist/dashboard/assets/ProvidersPage-uPiGVTTK.js.gz +0 -0
  27. package/dist/dashboard/assets/ProxyPage-BaUkO-4O.js +7 -0
  28. package/dist/dashboard/assets/ProxyPage-BaUkO-4O.js.gz +0 -0
  29. package/dist/dashboard/assets/QuotaPage-o0IFAsFb.js +1 -0
  30. package/dist/dashboard/assets/QuotaPage-o0IFAsFb.js.gz +0 -0
  31. package/dist/dashboard/assets/SettingsPage-CYWqbXjp.js +1 -0
  32. package/dist/dashboard/assets/SettingsPage-CYWqbXjp.js.gz +0 -0
  33. package/dist/dashboard/assets/{SetupPage-DDbMQsoo.js → SetupPage-UB690SE2.js} +1 -1
  34. package/dist/dashboard/assets/SetupPage-UB690SE2.js.gz +0 -0
  35. package/dist/dashboard/assets/{SortableList-CB3Z7VTf.js → SortableList-BLujdX7N.js} +1 -1
  36. package/dist/dashboard/assets/SortableList-BLujdX7N.js.gz +0 -0
  37. package/dist/dashboard/assets/{StudioPage-D95UCPHI.js → StudioPage-D5C2raAc.js} +3 -3
  38. package/dist/dashboard/assets/StudioPage-D5C2raAc.js.gz +0 -0
  39. package/dist/dashboard/assets/UsagePage-8uIcxa1W.js +56 -0
  40. package/dist/dashboard/assets/UsagePage-8uIcxa1W.js.gz +0 -0
  41. package/dist/dashboard/assets/api-keys-DQYLQxpW.js +1 -0
  42. package/dist/dashboard/assets/api-keys-DQYLQxpW.js.gz +0 -0
  43. package/dist/dashboard/assets/app-CX_Z8cMs.css +2 -0
  44. package/dist/dashboard/assets/app-CX_Z8cMs.css.gz +0 -0
  45. package/dist/dashboard/assets/{assert-Chi7pCnt.js → assert-C9FD_JwJ.js} +1 -1
  46. package/dist/dashboard/assets/assert-C9FD_JwJ.js.gz +0 -0
  47. package/dist/dashboard/assets/{badge-D4pQapKj.js → badge-CR72dasT.js} +1 -1
  48. package/dist/dashboard/assets/badge-CR72dasT.js.gz +0 -0
  49. package/dist/dashboard/assets/{banner-DH6cg-_B.js → banner-CI9_eSfO.js} +1 -1
  50. package/dist/dashboard/assets/banner-CI9_eSfO.js.gz +0 -0
  51. package/dist/dashboard/assets/{card-BKqlUOe4.js → card-DS7CQEsO.js} +1 -1
  52. package/dist/dashboard/assets/card-DS7CQEsO.js.gz +0 -0
  53. package/dist/dashboard/assets/{cli-tools-fTgljTGA.js → cli-tools-CNqzrfdb.js} +1 -1
  54. package/dist/dashboard/assets/cli-tools-CNqzrfdb.js.gz +0 -0
  55. package/dist/dashboard/assets/{clipboard-button-D5Ys_sM0.js → clipboard-button-611pSsaX.js} +1 -1
  56. package/dist/dashboard/assets/clipboard-button-611pSsaX.js.gz +0 -0
  57. package/dist/dashboard/assets/{fingerprint-pattern-BWuHtbW4.js → concurrency-BDF0ok7y.js} +1 -1
  58. package/dist/dashboard/assets/concurrency-BDF0ok7y.js.gz +0 -0
  59. package/dist/dashboard/assets/dialog-k37exjNt.js +1 -0
  60. package/dist/dashboard/assets/dialog-k37exjNt.js.gz +0 -0
  61. package/dist/dashboard/assets/drawer-CX7N8ucR.js +1 -0
  62. package/dist/dashboard/assets/drawer-CX7N8ucR.js.gz +0 -0
  63. package/dist/dashboard/assets/{entry-CQBr6U9c.js → entry-B9Xvtnfj.js} +1 -1
  64. package/dist/dashboard/assets/entry-B9Xvtnfj.js.gz +0 -0
  65. package/dist/dashboard/assets/entry-CTP8AyiS.js +1 -0
  66. package/dist/dashboard/assets/entry-CTP8AyiS.js.gz +0 -0
  67. package/dist/dashboard/assets/entry-DxBHnauy.js +4 -0
  68. package/dist/dashboard/assets/entry-DxBHnauy.js.gz +0 -0
  69. package/dist/dashboard/assets/{github-badge-BlptL8Uf.js → github-badge-sltk1qUi.js} +1 -1
  70. package/dist/dashboard/assets/github-badge-sltk1qUi.js.gz +0 -0
  71. package/dist/dashboard/assets/{index-js6TJvdr.js → index-CmxZr14F.js} +2 -2
  72. package/dist/dashboard/assets/index-CmxZr14F.js.gz +0 -0
  73. package/dist/dashboard/assets/{inline-DUWDSmDV.js → inline-BhY2sSnO.js} +1 -1
  74. package/dist/dashboard/assets/inline-BhY2sSnO.js.gz +0 -0
  75. package/dist/dashboard/assets/{input-LVjgZq4f.js → input-V1umFiBk.js} +1 -1
  76. package/dist/dashboard/assets/input-V1umFiBk.js.gz +0 -0
  77. package/dist/dashboard/assets/layout-BBOYycyS.js +1 -0
  78. package/dist/dashboard/assets/layout-BBOYycyS.js.gz +0 -0
  79. package/dist/dashboard/assets/{list-row-DupvLfDx.js → list-row-1zzZYM0B.js} +1 -1
  80. package/dist/dashboard/assets/list-row-1zzZYM0B.js.gz +0 -0
  81. package/dist/dashboard/assets/{live-C4pEJUqI.js → live-BivGfuUs.js} +1 -1
  82. package/dist/dashboard/assets/live-BivGfuUs.js.gz +0 -0
  83. package/dist/dashboard/assets/{model-bans-BRiuufkB.js → model-bans-CmffjLuG.js} +1 -1
  84. package/dist/dashboard/assets/model-bans-CmffjLuG.js.gz +0 -0
  85. package/dist/dashboard/assets/{mutation-B4ROwk8L.js → mutation-46P0oWMe.js} +1 -1
  86. package/dist/dashboard/assets/mutation-46P0oWMe.js.gz +0 -0
  87. package/dist/dashboard/assets/numbers-DkfMR5Qs.js +2 -0
  88. package/dist/dashboard/assets/numbers-DkfMR5Qs.js.gz +0 -0
  89. package/dist/dashboard/assets/{query-keys-gmjzC3RS.js → query-keys-BFhGpwR1.js} +1 -1
  90. package/dist/dashboard/assets/query-keys-BFhGpwR1.js.gz +0 -0
  91. package/dist/dashboard/assets/{routing-C7tBT5o-.js → routing-CAb4F37R.js} +1 -1
  92. package/dist/dashboard/assets/routing-CAb4F37R.js.gz +0 -0
  93. package/dist/dashboard/assets/{select-WUCXorXw.js → select-BYR_5K2c.js} +1 -1
  94. package/dist/dashboard/assets/select-BYR_5K2c.js.gz +0 -0
  95. package/dist/dashboard/assets/{settings-C_zKl1GS.js → settings-DNX9sydi.js} +1 -1
  96. package/dist/dashboard/assets/settings-DNX9sydi.js.gz +0 -0
  97. package/dist/dashboard/assets/{stack-_3O0F8Xn.js → stack-BHTfXdz_.js} +1 -1
  98. package/dist/dashboard/assets/stack-BHTfXdz_.js.gz +0 -0
  99. package/dist/dashboard/assets/{state-BgTxHcJS.js → state-mPDRlQkl.js} +1 -1
  100. package/dist/dashboard/assets/state-mPDRlQkl.js.gz +0 -0
  101. package/dist/dashboard/assets/{switch-DEjTtOWf.js → switch-DOmJxg7H.js} +1 -1
  102. package/dist/dashboard/assets/switch-DOmJxg7H.js.gz +0 -0
  103. package/dist/dashboard/assets/tokens-COzario9.js +1 -0
  104. package/dist/dashboard/assets/tokens-COzario9.js.gz +0 -0
  105. package/dist/dashboard/assets/use-modal-focus-By7oJoGd.js +1 -0
  106. package/dist/dashboard/assets/use-modal-focus-By7oJoGd.js.gz +0 -0
  107. package/dist/dashboard/assets/{useMutation-BWSC0UAS.js → useMutation-Dem0lXQj.js} +1 -1
  108. package/dist/dashboard/assets/useMutation-Dem0lXQj.js.gz +0 -0
  109. package/dist/dashboard/index.html +1 -1
  110. package/dist/dashboard/index.html.gz +0 -0
  111. package/dist/dashboard/precompressed-assets.json +54 -54
  112. package/migrations/README.md +52 -52
  113. package/migrations/lite-sqlite3/0000_baseline.sql +2 -2
  114. package/package.json +96 -95
  115. package/scripts/cli/admin.ts +125 -0
  116. package/scripts/cli/{ui/background.ts → background.ts} +1 -1
  117. package/scripts/cli/{command/console-menu.ts → console-menu.ts} +275 -244
  118. package/scripts/cli/doctor.ts +37 -0
  119. package/scripts/cli/{support/env.ts → env.ts} +99 -99
  120. package/scripts/cli/{gateway/spawn.ts → gateway.ts} +112 -24
  121. package/scripts/cli/instance.ts +224 -0
  122. package/scripts/cli/main.ts +61 -18
  123. package/scripts/cli/{support/platform.ts → platform.ts} +64 -64
  124. package/scripts/cli/port.ts +100 -0
  125. package/scripts/cli/{support/services.ts → services.ts} +88 -88
  126. package/scripts/cli/{command/setup.ts → setup.ts} +35 -34
  127. package/scripts/cli/{command/start.ts → start.ts} +10 -5
  128. package/scripts/cli/{support/startup-signal.ts → startup-signal.ts} +23 -23
  129. package/scripts/cli/tray/index.ts +62 -12
  130. package/scripts/cli/{command/tray.ts → tray.ts} +208 -208
  131. package/scripts/cli/{command/update.ts → update.ts} +39 -39
  132. package/scripts/cli/version.ts +67 -0
  133. package/scripts/cli/{ui/wordmark.ts → wordmark.ts} +58 -58
  134. package/src/boot/app.ts +697 -693
  135. package/src/boot/dependencies.ts +647 -646
  136. package/src/boot/timeout.ts +2 -13
  137. package/src/config/cooldowns.ts +186 -138
  138. package/src/config/network.ts +8 -14
  139. package/src/config/providers.ts +7 -10
  140. package/src/config/runtime.ts +76 -76
  141. package/src/config/security.ts +15 -0
  142. package/src/config/spec.ts +20 -30
  143. package/src/config/telemetry.ts +0 -20
  144. package/src/config/timeouts.ts +0 -6
  145. package/src/console/admin/model-abuse/contracts.ts +28 -6
  146. package/src/console/admin/model-abuse/routes.ts +56 -3
  147. package/src/console/api-keys/contracts.ts +410 -414
  148. package/src/console/api-keys/routes.ts +881 -880
  149. package/src/console/auth/credentials.ts +3 -3
  150. package/src/console/auth/setup-service.ts +2 -2
  151. package/src/console/backup/export-endpoint.ts +6 -2
  152. package/src/console/backup/nine-router.ts +2 -5
  153. package/src/console/backup/service.ts +4 -0
  154. package/src/console/backup/store.ts +192 -29
  155. package/src/console/backup/validate.ts +3 -45
  156. package/src/console/cli-tools/fs-ops.ts +487 -487
  157. package/src/console/console-router-guards.ts +1 -1
  158. package/src/console/data-management/contracts.ts +13 -6
  159. package/src/console/data-management/operations.ts +23 -18
  160. package/src/console/data-management/routes.ts +0 -1
  161. package/src/console/domain-registration.ts +10 -5
  162. package/src/console/observability/live.ts +22 -0
  163. package/src/console/observability/store.ts +39 -16
  164. package/src/console/observability/usage-dimensions.ts +4 -4
  165. package/src/console/providers/catalog/catalog-projections.ts +6 -7
  166. package/src/console/providers/catalog/model-operations.ts +5 -2
  167. package/src/console/providers/catalog/provider-operations.ts +4 -4
  168. package/src/console/providers/catalog/public-model-store.ts +8 -11
  169. package/src/console/providers/catalog/store.ts +1553 -1553
  170. package/src/console/providers/oauth/routes.ts +8 -12
  171. package/src/console/quota/account-quota-tenant.ts +6 -6
  172. package/src/console/quota/quota-view.ts +1 -1
  173. package/src/console/remote-support/contracts.ts +61 -0
  174. package/src/console/remote-support/routes.ts +65 -0
  175. package/src/console/remote-support/service.ts +207 -0
  176. package/src/console/response-compression.ts +0 -3
  177. package/src/console/routing/pools/probe-result.ts +3 -2
  178. package/src/console/routing/pools/probe-verdict.ts +132 -132
  179. package/src/console/routing/pools/store.ts +8 -8
  180. package/src/console/settings/contracts.ts +58 -2
  181. package/src/console/settings/store.ts +30 -3
  182. package/src/console/shared/cursor.ts +8 -8
  183. package/src/console/shared/errors.ts +1 -3
  184. package/src/network/http2-bidi.ts +2 -4
  185. package/src/network/pool/agent.ts +478 -478
  186. package/src/network/pool/selector.ts +5 -4
  187. package/src/network/retry.ts +1 -2
  188. package/src/network/ssrf.ts +5 -4
  189. package/src/observability/event-compaction.ts +80 -78
  190. package/src/observability/event-day-index.ts +13 -15
  191. package/src/observability/event-log.ts +4 -4
  192. package/src/observability/logger.ts +6 -6
  193. package/src/observability/payload-capture.ts +151 -33
  194. package/src/observability/payload-store.ts +397 -440
  195. package/src/observability/redaction.ts +5 -6
  196. package/src/observability/request-notify.ts +43 -0
  197. package/src/observability/telemetry-status.ts +8 -9
  198. package/src/observability/token-speed.ts +5 -4
  199. package/src/persistence/db-mode.ts +7 -7
  200. package/src/persistence/readiness.ts +1 -1
  201. package/src/persistence/schema.ts +1406 -1402
  202. package/src/persistence/shared-cache.ts +1 -2
  203. package/src/persistence/stores/api-key-store.ts +18 -6
  204. package/src/persistence/stores/share-store.ts +4 -3
  205. package/src/persistence/stores/telemetry-store.ts +236 -237
  206. package/src/protocol/canonical-model.ts +11 -3
  207. package/src/protocol/codecs.ts +138 -4
  208. package/src/protocol/gateway-error.ts +6 -4
  209. package/src/protocol/request/chat.ts +21 -1
  210. package/src/protocol/request/codex.ts +10 -1
  211. package/src/protocol/request/messages.ts +4 -5
  212. package/src/protocol/request/responses.ts +9 -1
  213. package/src/protocol/response/chat.ts +34 -4
  214. package/src/protocol/response/codex.ts +7 -4
  215. package/src/protocol/response/dsml.ts +140 -28
  216. package/src/protocol/response/responses.ts +11 -5
  217. package/src/protocol/stream-error-frames.ts +4 -5
  218. package/src/providers/authentication/jwt-validator.ts +9 -10
  219. package/src/providers/authentication/oauth-refresh-service.ts +9 -11
  220. package/src/providers/configured-openai-providers.ts +134 -136
  221. package/src/providers/discovery/catalog/catalog-blob.ts +18 -18
  222. package/src/providers/discovery/catalog/catalog-format.ts +3 -6
  223. package/src/providers/discovery/models-dev-catalog.ts +354 -354
  224. package/src/providers/discovery/probe-phases.ts +4 -4
  225. package/src/providers/discovery/probe-wire.ts +6 -13
  226. package/src/providers/discovery/probing-service.ts +1117 -1045
  227. package/src/providers/integrations/alibabacp/alibabacp.ts +1 -1
  228. package/src/providers/integrations/antigravity/antigravity-quota.ts +5 -7
  229. package/src/providers/integrations/buddy/buddy-chat-shared.ts +23 -2
  230. package/src/providers/integrations/buddy/buddy-checkin.ts +1 -4
  231. package/src/providers/integrations/buddy/buddy-cli-headers.ts +23 -6
  232. package/src/providers/integrations/buddy/buddy-conversation-headers.ts +0 -1
  233. package/src/providers/integrations/buddy/buddy-identity.ts +52 -0
  234. package/src/providers/integrations/buddy/buddy-media.ts +2 -2
  235. package/src/providers/integrations/buddy/buddy-usage-store.ts +3 -4
  236. package/src/providers/integrations/buddy/codebuddy/quota.ts +2 -13
  237. package/src/providers/integrations/buddy/codebuddy/shared.ts +118 -115
  238. package/src/providers/integrations/buddy/workbuddy/quota.ts +0 -11
  239. package/src/providers/integrations/buddy/workbuddy/shared.ts +5 -2
  240. package/src/providers/integrations/claude/claude.ts +608 -608
  241. package/src/providers/integrations/cline/cline-quota.ts +132 -132
  242. package/src/providers/integrations/cline/cline.ts +537 -539
  243. package/src/providers/integrations/codex/codex-oauth.ts +1 -1
  244. package/src/providers/integrations/commandcode/commandcode.ts +674 -675
  245. package/src/providers/integrations/commandcode/discovery.ts +117 -116
  246. package/src/providers/integrations/cursor/cursor-auth.ts +418 -418
  247. package/src/providers/integrations/cursor/cursor-quota.ts +309 -310
  248. package/src/providers/integrations/cursor/cursor.ts +11 -163
  249. package/src/providers/integrations/factory/factory-models.ts +3 -5
  250. package/src/providers/integrations/grok/grok-install-id.ts +2 -3
  251. package/src/providers/integrations/grok/grok.ts +6 -7
  252. package/src/providers/integrations/hermes.ts +14 -18
  253. package/src/providers/integrations/kiro/kiro-discovery.ts +1 -1
  254. package/src/providers/integrations/kiro/kiro.ts +1 -1
  255. package/src/providers/integrations/mistral.ts +6 -6
  256. package/src/providers/integrations/opencode/fingerprint.ts +3 -3
  257. package/src/providers/integrations/opencode/provider.ts +8 -8
  258. package/src/providers/integrations/qoder/qoder-catalog.ts +450 -450
  259. package/src/providers/integrations/qoder/qoder-oauth.ts +388 -310
  260. package/src/providers/integrations/qoder/qoder.ts +884 -865
  261. package/src/providers/integrations/shared/connect.ts +6 -6
  262. package/src/providers/integrations/trae/trae-catalog.ts +1 -2
  263. package/src/providers/integrations/trae/trae.ts +2 -2
  264. package/src/providers/integrations/zcode/oauth.ts +6 -1
  265. package/src/providers/operations/account-health-service.ts +1528 -1523
  266. package/src/providers/operations/account-reset-service.ts +1 -1
  267. package/src/providers/operations/client-versions.ts +6 -6
  268. package/src/providers/operations/custom-cli-headers.ts +3 -4
  269. package/src/providers/operations/provider-catalog-seeder.ts +95 -95
  270. package/src/providers/operations/provider-catalog-service.ts +3 -4
  271. package/src/providers/provider-metadata.ts +4 -8
  272. package/src/providers/provider-registry.ts +6 -7
  273. package/src/providers/provider-usage.ts +1 -1
  274. package/src/security/abuse.ts +15 -10
  275. package/src/security/access-control.ts +144 -146
  276. package/src/security/admission/contracts.ts +11 -0
  277. package/src/security/admission/in-memory-store.ts +4 -3
  278. package/src/security/admission/lease-sweep.ts +6 -6
  279. package/src/security/admission/reasons.ts +5 -1
  280. package/src/security/admission/redis-store.ts +211 -210
  281. package/src/security/admission/service.ts +4 -0
  282. package/src/security/api-key-auth.ts +504 -504
  283. package/src/security/ip-boundary.ts +155 -15
  284. package/src/security/model-abuse.ts +366 -94
  285. package/src/security/outbound-headers.ts +3 -4
  286. package/src/shared/sleep.ts +4 -0
  287. package/src/transport/dispatch/attempt-finalize.ts +47 -5
  288. package/src/transport/dispatch/attempt-loop.ts +531 -530
  289. package/src/transport/dispatch/stream-bridge.ts +1 -3
  290. package/src/transport/dispatch/streaming/finalize.ts +1 -1
  291. package/src/transport/dispatch/streaming/stream.ts +5 -6
  292. package/src/transport/failure-policy.ts +162 -8
  293. package/src/transport/guards/auth-guard.ts +7 -4
  294. package/src/transport/guards/concurrency-guard.ts +0 -5
  295. package/src/transport/guards/error-lifecycle.ts +13 -6
  296. package/src/transport/guards/pipeline.ts +15 -3
  297. package/src/transport/guards/request-context.ts +8 -2
  298. package/src/transport/request/inflight.ts +5 -6
  299. package/src/transport/request/preparer.ts +2 -2
  300. package/src/transport/request/state.ts +12 -8
  301. package/src/transport/routing/admission.ts +4 -0
  302. package/src/transport/routing/route-catalog.ts +8 -1
  303. package/src/transport/routing/route-model.ts +46 -14
  304. package/src/transport/routing/router.ts +21 -14
  305. package/src/transport/surface/completion.ts +2 -3
  306. package/src/transport/surface/dialects.ts +5 -6
  307. package/src/transport/surface/responses/encode.ts +38 -0
  308. package/src/transport/surface/responses/parse.ts +4 -0
  309. package/src/transport/translation/capabilities.ts +2 -3
  310. package/src/transport/translation/quirks.ts +3 -3
  311. package/src/workers/buddy-growth-tasks.ts +5 -10
  312. package/src/workers/daily-checkin.ts +17 -23
  313. package/src/workers/quota-refresh-sweep.ts +26 -26
  314. package/dist/dashboard/assets/BannedPage-C2orBsI2.js.gz +0 -0
  315. package/dist/dashboard/assets/CliToolDetailPage-DUcZ6pMB.js +0 -1
  316. package/dist/dashboard/assets/CliToolDetailPage-DUcZ6pMB.js.gz +0 -0
  317. package/dist/dashboard/assets/CliToolsPage-BV6pxVQo.js.gz +0 -0
  318. package/dist/dashboard/assets/CombosPage-Bsan4679.js +0 -1
  319. package/dist/dashboard/assets/CombosPage-Bsan4679.js.gz +0 -0
  320. package/dist/dashboard/assets/ConsoleLogPage-CW-wPmJ6.js.gz +0 -0
  321. package/dist/dashboard/assets/CustomizationPage-0p8uX7vB.js.gz +0 -0
  322. package/dist/dashboard/assets/HealthEventsModal-BjqSMfko.js.gz +0 -0
  323. package/dist/dashboard/assets/LoginPage-BvzCx8Kr.js.gz +0 -0
  324. package/dist/dashboard/assets/ModelPicker-BY1aKEkP.js +0 -1
  325. package/dist/dashboard/assets/ModelPicker-BY1aKEkP.js.gz +0 -0
  326. package/dist/dashboard/assets/OverviewPage-DOQpy1y0.js +0 -1
  327. package/dist/dashboard/assets/OverviewPage-DOQpy1y0.js.gz +0 -0
  328. package/dist/dashboard/assets/ProviderDetailPage-ecxyUMzt.js +0 -3
  329. package/dist/dashboard/assets/ProviderDetailPage-ecxyUMzt.js.gz +0 -0
  330. package/dist/dashboard/assets/ProvidersPage-Js9jUPSb.js.gz +0 -0
  331. package/dist/dashboard/assets/ProxyPage-J3TBi3Kx.js +0 -7
  332. package/dist/dashboard/assets/ProxyPage-J3TBi3Kx.js.gz +0 -0
  333. package/dist/dashboard/assets/QuotaPage-C1gZS6xr.js +0 -1
  334. package/dist/dashboard/assets/QuotaPage-C1gZS6xr.js.gz +0 -0
  335. package/dist/dashboard/assets/SettingsPage-CS86ho5X.js +0 -1
  336. package/dist/dashboard/assets/SettingsPage-CS86ho5X.js.gz +0 -0
  337. package/dist/dashboard/assets/SetupPage-DDbMQsoo.js.gz +0 -0
  338. package/dist/dashboard/assets/SortableList-CB3Z7VTf.js.gz +0 -0
  339. package/dist/dashboard/assets/StudioPage-D95UCPHI.js.gz +0 -0
  340. package/dist/dashboard/assets/UsagePage-Cnae9SWA.js +0 -56
  341. package/dist/dashboard/assets/UsagePage-Cnae9SWA.js.gz +0 -0
  342. package/dist/dashboard/assets/api-keys-VY68Mhd1.js +0 -1
  343. package/dist/dashboard/assets/api-keys-VY68Mhd1.js.gz +0 -0
  344. package/dist/dashboard/assets/app-COlK95oi.css +0 -2
  345. package/dist/dashboard/assets/app-COlK95oi.css.gz +0 -0
  346. package/dist/dashboard/assets/assert-Chi7pCnt.js.gz +0 -0
  347. package/dist/dashboard/assets/badge-D4pQapKj.js.gz +0 -0
  348. package/dist/dashboard/assets/banner-DH6cg-_B.js.gz +0 -0
  349. package/dist/dashboard/assets/card-BKqlUOe4.js.gz +0 -0
  350. package/dist/dashboard/assets/cli-tools-fTgljTGA.js.gz +0 -0
  351. package/dist/dashboard/assets/clipboard-button-D5Ys_sM0.js.gz +0 -0
  352. package/dist/dashboard/assets/dialog-BYj-8WuK.js +0 -1
  353. package/dist/dashboard/assets/dialog-BYj-8WuK.js.gz +0 -0
  354. package/dist/dashboard/assets/drawer-CmXIZ6H1.js +0 -1
  355. package/dist/dashboard/assets/drawer-CmXIZ6H1.js.gz +0 -0
  356. package/dist/dashboard/assets/entry-CBg5zeu8.js +0 -1
  357. package/dist/dashboard/assets/entry-CBg5zeu8.js.gz +0 -0
  358. package/dist/dashboard/assets/entry-CQBr6U9c.js.gz +0 -0
  359. package/dist/dashboard/assets/entry-CgdSAzEV.js +0 -4
  360. package/dist/dashboard/assets/entry-CgdSAzEV.js.gz +0 -0
  361. package/dist/dashboard/assets/fingerprint-pattern-BWuHtbW4.js.gz +0 -0
  362. package/dist/dashboard/assets/github-badge-BlptL8Uf.js.gz +0 -0
  363. package/dist/dashboard/assets/index-js6TJvdr.js.gz +0 -0
  364. package/dist/dashboard/assets/inline-DUWDSmDV.js.gz +0 -0
  365. package/dist/dashboard/assets/input-LVjgZq4f.js.gz +0 -0
  366. package/dist/dashboard/assets/layout-CPfxpsfy.js +0 -1
  367. package/dist/dashboard/assets/layout-CPfxpsfy.js.gz +0 -0
  368. package/dist/dashboard/assets/list-row-DupvLfDx.js.gz +0 -0
  369. package/dist/dashboard/assets/live-C4pEJUqI.js.gz +0 -0
  370. package/dist/dashboard/assets/model-bans-BRiuufkB.js.gz +0 -0
  371. package/dist/dashboard/assets/mutation-B4ROwk8L.js.gz +0 -0
  372. package/dist/dashboard/assets/numbers-YNcHvRY7.js +0 -2
  373. package/dist/dashboard/assets/numbers-YNcHvRY7.js.gz +0 -0
  374. package/dist/dashboard/assets/query-keys-gmjzC3RS.js.gz +0 -0
  375. package/dist/dashboard/assets/routing-C7tBT5o-.js.gz +0 -0
  376. package/dist/dashboard/assets/select-WUCXorXw.js.gz +0 -0
  377. package/dist/dashboard/assets/settings-C_zKl1GS.js.gz +0 -0
  378. package/dist/dashboard/assets/stack-_3O0F8Xn.js.gz +0 -0
  379. package/dist/dashboard/assets/state-BgTxHcJS.js.gz +0 -0
  380. package/dist/dashboard/assets/switch-DEjTtOWf.js.gz +0 -0
  381. package/dist/dashboard/assets/tokens-DDxMbSRL.js +0 -1
  382. package/dist/dashboard/assets/tokens-DDxMbSRL.js.gz +0 -0
  383. package/dist/dashboard/assets/use-presence-BSZEBx7U.js +0 -1
  384. package/dist/dashboard/assets/use-presence-BSZEBx7U.js.gz +0 -0
  385. package/dist/dashboard/assets/useMutation-BWSC0UAS.js.gz +0 -0
  386. package/scripts/cli/command/admin.ts +0 -65
  387. package/scripts/cli/command/doctor.ts +0 -257
  388. package/scripts/cli/gateway/drain-token.ts +0 -78
  389. package/scripts/cli/gateway/instance-lock.ts +0 -105
  390. package/scripts/cli/gateway/stop.ts +0 -51
  391. package/scripts/cli/support/bun.ts +0 -27
  392. package/scripts/cli/support/version.ts +0 -37
  393. /package/dist/dashboard/assets/{app-DCQZRmHY.js → app-BIt446yE.js} +0 -0
  394. /package/dist/dashboard/assets/{app-DCQZRmHY.js.gz → app-BIt446yE.js.gz} +0 -0
  395. /package/scripts/cli/{ui/prompt.ts → prompt.ts} +0 -0
@@ -1,487 +1,487 @@
1
- /**
2
- * Shared filesystem operations for CLI tool injectors.
3
- *
4
- * Every injector uses the helpers in this module instead of importing
5
- * `node:fs` or `Bun.file` directly. Two reasons: (1) it keeps the FS surface
6
- * tiny and auditable, and (2) tests can point injectors at temp dirs by
7
- * stubbing `HOME` rather than mocking `fs`.
8
- */
9
-
10
- import { homedir, platform } from "node:os";
11
- import { join } from "node:path";
12
- import { mkdir, rm } from "node:fs/promises";
13
-
14
- /** Home directory — shared contract used by all injectors. */
15
- export { homedir as homeDir };
16
- export { join };
17
-
18
- const IS_WIN: boolean = platform() === "win32";
19
-
20
- /** Check if a file exists. */
21
- export async function fileExists(path: string): Promise<boolean> {
22
- return Bun.file(path).exists();
23
- }
24
-
25
- /** Read and parse a JSON file, tolerating trailing commas. Returns null if missing or unparseable. */
26
- export async function readJsonFile(path: string): Promise<unknown | null> {
27
- try {
28
- const text = await Bun.file(path).text();
29
- const stripped = text.replace(/,(\s*[}\]])/g, "$1");
30
- return JSON.parse(stripped);
31
- } catch {
32
- return null;
33
- }
34
- }
35
-
36
- /** Write JSON to a file with 2-space indentation. */
37
- export async function writeJsonFile(path: string, data: unknown): Promise<void> {
38
- await Bun.write(path, JSON.stringify(data, null, 2));
39
- }
40
-
41
- /** Read a text file, returning null if it doesn't exist. */
42
- export async function readTextFile(path: string): Promise<string | null> {
43
- try {
44
- return await Bun.file(path).text();
45
- } catch {
46
- return null;
47
- }
48
- }
49
-
50
- /** Write text to a file. */
51
- export async function writeTextFile(path: string, content: string): Promise<void> {
52
- await Bun.write(path, content);
53
- }
54
-
55
- /** Remove a file if it exists. */
56
- export async function removeFile(path: string): Promise<void> {
57
- await rm(path, { force: true });
58
- }
59
-
60
- /** Create a directory recursively (like mkdir -p). */
61
- export async function ensureDir(path: string): Promise<void> {
62
- await mkdir(path, { recursive: true });
63
- }
64
-
65
- /**
66
- * Check if a CLI binary is installed on the current PATH.
67
- */
68
- export async function checkBinaryInstalled(binaryName: string): Promise<boolean> {
69
- const cmd = IS_WIN ? "where" : "which";
70
- const env: Record<string, string | undefined> = { ...process.env };
71
- if (IS_WIN && env.APPDATA) env.PATH = `${env.APPDATA}\\npm;${env.PATH ?? ""}`;
72
- try {
73
- const proc = Bun.spawn([cmd, binaryName], {
74
- stdout: "ignore",
75
- stderr: "ignore",
76
- env: env as Record<string, string>,
77
- });
78
- return (await proc.exited) === 0;
79
- } catch {
80
- return false;
81
- }
82
- }
83
-
84
- // ─── Endpoint normalization helpers ──────────────────────────────────────────
85
-
86
- /**
87
- * Ensure a URL ends with `/v1`.
88
- *
89
- * Trailing slashes are normalized first. Without that, an endpoint the operator
90
- * typed as `http://localhost:12800/` — the form a browser address bar produces,
91
- * and the one the dashboard's own links use — became
92
- * `http://localhost:12800//v1`, which every downstream client treats as a
93
- * different path and 404s. `http://host/v1/` became `http://host/v1//v1`.
94
- */
95
- export function ensureV1Suffix(url: string): string {
96
- const trimmed = url.replace(/\/+$/, "");
97
- return trimmed.endsWith("/v1") ? trimmed : `${trimmed}/v1`;
98
- }
99
-
100
- /** Strip a trailing `/v1` from a URL (some tools like Cline expect no /v1). */
101
- export function stripV1Suffix(url: string): string {
102
- const trimmed = url.replace(/\/+$/, "");
103
- return trimmed.endsWith("/v1") ? trimmed.slice(0, -3) : trimmed;
104
- }
105
-
106
- /**
107
- * Whether an endpoint URL points at a local Cartethyia instance.
108
- *
109
- * The match is anchored to the **hostname**, parsed rather than substring-
110
- * scanned. The previous unanchored `/localhost|127\.0\.0\.1|0\.0\.0\.0|
111
- * cartethyia/i` test matched anywhere in the string, so `https://notlocalhost.com`,
112
- * `https://mylocalhostproxy.net` and even `https://evil.com/localhost` were all
113
- * reported as local — and this value is what the dashboard shows the operator
114
- * as "configured", so a tool pointed at a remote host read as pointed at this
115
- * gateway. A hostname that merely *contains* one of these words is not local.
116
- *
117
- * A URL without a scheme is retried with an `http://` prefix, because the
118
- * operator may paste a bare `localhost:12800`.
119
- */
120
- export function isLocalEndpoint(url: string | null | undefined): boolean {
121
- if (!url) return false;
122
- const hostname = hostnameOf(url);
123
- if (hostname === null) return false;
124
- const normalized = hostname.toLowerCase().replace(/^\[|\]$/g, "");
125
- if (normalized === "localhost" || normalized === "::1") return true;
126
- if (normalized === "0.0.0.0") return true;
127
- if (/^127\./.test(normalized)) return true;
128
- // A Cartethyia *hostname*, not a path or query that happens to spell it.
129
- return normalized === "cartethyia" || normalized.startsWith("cartethyia.");
130
- }
131
-
132
- /**
133
- * Parses the hostname out of an endpoint, tolerating a missing scheme.
134
- *
135
- * A bare `localhost:12800` is the trap here: `new URL` *accepts* it, reading
136
- * `localhost:` as the scheme, and reports an **empty** hostname — so a parse
137
- * that only checks for a throw treats it as a host it cannot judge. Both forms
138
- * are therefore tried, and an empty hostname counts as a failure.
139
- */
140
- function hostnameOf(url: string): string | null {
141
- for (const candidate of [url, `http://${url}`]) {
142
- try {
143
- const hostname = new URL(candidate).hostname;
144
- if (hostname.length > 0) return hostname;
145
- } catch {
146
- // Not parseable in this form; try the next.
147
- }
148
- }
149
- return null;
150
- }
151
-
152
- /** Sanitize an API key to a prefix for display (first 8 chars + ...). */
153
- export function keyPrefix(key: string | null | undefined): string | null {
154
- if (!key || key.length < 8) return key ?? null;
155
- return `${key.slice(0, 8)}...`;
156
- }
157
-
158
- // ─── Structured text editing (TOML flat/section keys, .env KEY=VALUE) ───────
159
- //
160
- // These handle the simple structures the CLI tools use: flat key=value
161
- // pairs and one-level-nested [section] tables. They are NOT a general TOML
162
- // parser — they preserve existing file content via regex upsert/remove so
163
- // user comments and formatting are kept intact.
164
-
165
- function escapeRegex(s: string): string {
166
- return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
167
- }
168
-
169
- /**
170
- * Horizontal whitespace, never a line break.
171
- *
172
- * Every pattern below is **line-oriented**: a TOML or `.env` key/value line
173
- * cannot contain a newline, so each `\s` must be `[ \t]`. With `\s` a pattern
174
- * matches *across* a line break, and because several of these patterns drive a
175
- * `replace` whose replacement carries no trailing newline, the match swallows
176
- * the break and glues two lines together. Measured consequences of that,
177
- * before the fix — all of them syntactically invalid TOML, which the target
178
- * CLI then refuses to start on:
179
- *
180
- * - `[agents]\n\n k = "1"` → upserting `k` produced `[agents] k = "9"`,
181
- * putting a key on the section header's own line. `textGet` for that key
182
- * then read back `null`, so the status shown to the operator was wrong too.
183
- * - `[agents]\n k = "1"\n other = "2"\n[z]` → upserting `other` produced
184
- * `other = "9"[z]`, appending the next header to the last key's line.
185
- * - `model = "gpt-5"\n\n[section]` → an `insertAtTop` upsert of `model`
186
- * produced `model = "new"[section]`.
187
- *
188
- * The read-only patterns (`textGet`, `textHas`) get the same treatment for
189
- * consistency: a key written across two lines is not a key this module should
190
- * claim to have found.
191
- */
192
-
193
- /**
194
- * Addresses one field in structured text:
195
- * - `flat`: a root-level `key = "value"` (toml, default) or `KEY=value`
196
- * (`format: "env"`) line. `insertAtTop` (toml only) inserts new keys
197
- * before the first `[section]` header instead of appending at EOF —
198
- * required for keys that must precede any table per TOML syntax.
199
- * - `sectionKey`: a quoted key nested one level inside `[section]`,
200
- * upserted/removed without disturbing sibling keys.
201
- * - `section`: the whole `[section]` block as an opaque body string.
202
- */
203
- export type TextSelector =
204
- | {
205
- readonly kind: "flat";
206
- readonly key: string;
207
- readonly format?: "toml" | "env";
208
- readonly insertAtTop?: boolean;
209
- }
210
- | { readonly kind: "sectionKey"; readonly section: string; readonly key: string }
211
- | { readonly kind: "section"; readonly section: string };
212
-
213
- /** Body boundaries of an existing `[section]`, or null if it doesn't exist. */
214
- function sectionBounds(text: string, section: string): { start: number; end: number } | null {
215
- const header = new RegExp(`^\\[${escapeRegex(section)}\\][ \\t]*$`, "im");
216
- const match = header.exec(text);
217
- if (match === null || match.index === undefined) return null;
218
- const bodyStart = match.index + match[0].length;
219
- const remainder = text.slice(bodyStart);
220
- const nextSectionOffset = remainder.search(/^\[/m);
221
- const bodyEnd = nextSectionOffset < 0 ? text.length : bodyStart + nextSectionOffset;
222
- return { start: bodyStart, end: bodyEnd };
223
- }
224
-
225
- /**
226
- * Removes one whole line matching `pattern`, taking the line's own trailing
227
- * newline and nothing else.
228
- *
229
- * Removing a line must not disturb the blank lines around it: they are the
230
- * author's paragraphing, and in a config file the operator reads, collapsing
231
- * `key\n\n[next]` into `[next]` silently rewrites unrelated formatting. So the
232
- * match stops at the first line break — never at a run of whitespace — which is
233
- * the same discipline `horizontal` above enforces for the other patterns.
234
- */
235
- function removeLine(text: string, pattern: RegExp): string {
236
- const linePattern = new RegExp(`${pattern.source}[ \\t]*\\r?\\n?`, pattern.flags);
237
- return text.replace(linePattern, "");
238
- }
239
-
240
- /**
241
- * Index just past the last root key, i.e. after the blank lines that precede
242
- * the first `[section]` header are excluded.
243
- *
244
- * Those blank lines are the header's leading separator. Keeping them out of the
245
- * "root keys" slice lets an in-place replacement of a root key leave them
246
- * untouched; the caller re-attaches them unchanged.
247
- */
248
- function rootEndOfRootKeys(text: string, headerStart: number): number {
249
- const rootRegion = text.slice(0, headerStart);
250
- const trailingBreaks = /(?:\r?\n[ \t]*)+$/.exec(rootRegion);
251
- if (trailingBreaks === null) return headerStart;
252
- // The final line break is the root key's own terminator, so it stays.
253
- const withoutBlankTail = rootRegion.slice(0, trailingBreaks.index);
254
- const ownBreak = /^[ \t]*\r?\n/.exec(trailingBreaks[0]);
255
- return ownBreak === null
256
- ? withoutBlankTail.length
257
- : withoutBlankTail.length + ownBreak[0].length;
258
- }
259
-
260
- /**
261
- * Appends `line` to the end of a section body, preserving any blank lines the
262
- * author put inside it.
263
- *
264
- * The body is the text between a header and the next header, so its own
265
- * trailing newline belongs to the last key, not to the gap. Only the final
266
- * run of line breaks is trimmed, and only enough to make room for one line —
267
- * measured before the fix: a body `\n k = "1"\n\n other = "2"\n` collapsed to
268
- * `\n k = "1"\n other = "9"` with the blank line and the closing break gone.
269
- */
270
- function appendSectionLine(body: string, line: string): string {
271
- const withoutTrailingBreaks = body.replace(/(?:\r?\n[ \t]*)+$/, "");
272
- return `${withoutTrailingBreaks}\n${line}\n`;
273
- }
274
-
275
- /** Read a field's value; null if the field (or its section) doesn't exist. */
276
- export function textGet(text: string, selector: TextSelector): string | null {
277
- switch (selector.kind) {
278
- case "flat": {
279
- if (selector.format === "env") {
280
- const re = new RegExp(`^${escapeRegex(selector.key)}=(.*)$`, "im");
281
- return re.exec(text)?.[1]?.trim() ?? null;
282
- }
283
- const re = new RegExp(`^${escapeRegex(selector.key)}[ \\t]*=[ \\t]*"([^"]*)"`, "im");
284
- return re.exec(text)?.[1] ?? null;
285
- }
286
- case "sectionKey": {
287
- const bounds = sectionBounds(text, selector.section);
288
- if (!bounds) return null;
289
- const body = text.slice(bounds.start, bounds.end);
290
- const re = new RegExp(`^[ \\t]*${escapeRegex(selector.key)}[ \\t]*=[ \\t]*"([^"]*)"`, "im");
291
- return re.exec(body)?.[1] ?? null;
292
- }
293
- case "section": {
294
- const bounds = sectionBounds(text, selector.section);
295
- return bounds ? text.slice(bounds.start, bounds.end) : null;
296
- }
297
- default:
298
- selector satisfies never;
299
- return null;
300
- }
301
- }
302
-
303
- /** Check whether a field exists (or, for `section`, whether the header is present). */
304
- export function textHas(text: string, selector: TextSelector): boolean {
305
- switch (selector.kind) {
306
- case "flat": {
307
- const re =
308
- selector.format === "env"
309
- ? new RegExp(`^${escapeRegex(selector.key)}=.*$`, "im")
310
- : new RegExp(`^${escapeRegex(selector.key)}[ \\t]*=[ \\t]*"[^"]*"`, "im");
311
- return re.test(text);
312
- }
313
- case "sectionKey": {
314
- const bounds = sectionBounds(text, selector.section);
315
- if (!bounds) return false;
316
- const body = text.slice(bounds.start, bounds.end);
317
- return new RegExp(`^[ \\t]*${escapeRegex(selector.key)}[ \\t]*=[ \\t]*"[^"]*"`, "im").test(body);
318
- }
319
- case "section":
320
- return new RegExp(`^\\[${escapeRegex(selector.section)}\\]`, "im").test(text);
321
- default:
322
- selector satisfies never;
323
- return false;
324
- }
325
- }
326
-
327
- /** Upsert a field's value (or, for `section`, replace/insert the whole body). */
328
- export function textUpsert(text: string, selector: TextSelector, value: string): string {
329
- switch (selector.kind) {
330
- case "flat": {
331
- const env = selector.format === "env";
332
- const line = env ? `${selector.key}=${value}` : `${selector.key} = "${value}"`;
333
- if (selector.insertAtTop) {
334
- // Root-level insert before the first `[section]` header (TOML root
335
- // keys must precede all tables) rather than appending at EOF.
336
- //
337
- // The boundary is walked back over the blank lines that separate the
338
- // root keys from the first header, because those blank lines belong to
339
- // the header, not to the last root key. Replacing the key in place with
340
- // `keyPattern` anchored at `\\s*$` would otherwise swallow the break and
341
- // the indentation — measured: `model = "gpt-5"\n\n[section]` came out as
342
- // `model = "new"[section]`.
343
- const headerStart = text.search(/^\[/m);
344
- const boundary = headerStart < 0 ? text.length : headerStart;
345
- const rootEnd = headerStart < 0 ? boundary : rootEndOfRootKeys(text, headerStart);
346
- const root = text.slice(0, rootEnd);
347
- const keyPattern = new RegExp(
348
- `^${escapeRegex(selector.key)}[ \\t]*=[ \\t]*"[^"]*"[ \\t]*$`,
349
- "im",
350
- );
351
- // The blank lines between the root keys and the first header are the
352
- // header's leading separator, and `rootEndOfRootKeys` holds them out of
353
- // `root` so a replacement cannot eat them. Re-attaching them verbatim
354
- // is what keeps the file's paragraphing intact.
355
- const separator = text.slice(rootEnd, boundary);
356
- if (keyPattern.test(root)) {
357
- return `${root.replace(keyPattern, line)}${separator}${text.slice(boundary)}`;
358
- }
359
- const prefix = root.endsWith("\n") || root.length === 0 ? root : `${root}\n`;
360
- return `${prefix}${line}\n${separator}${text.slice(boundary)}`;
361
- }
362
- const re = env
363
- ? new RegExp(`^${escapeRegex(selector.key)}=.*$`, "im")
364
- : new RegExp(`^${escapeRegex(selector.key)}[ \\t]*=[ \\t]*"[^"]*"`, "im");
365
- if (re.test(text)) return text.replace(re, line);
366
- // Append at end-of-file. `text.length > 0` matters for the toml form too:
367
- // an empty file must not gain a leading blank line before its first key.
368
- if (text.length === 0) return `${line}\n`;
369
- const withNewline = text.endsWith("\n") ? text : `${text}\n`;
370
- return `${withNewline}${line}\n`;
371
- }
372
- case "sectionKey": {
373
- const line = ` ${selector.key} = "${value}"`;
374
- // Recovers a malformed file where the key ended up on the same line
375
- // as the section header (e.g. from an earlier buggy write).
376
- const malformed = new RegExp(
377
- `\\[${escapeRegex(selector.section)}\\][ \\t]+${escapeRegex(selector.key)}[ \\t]*=[ \\t]*"[^"]*"`,
378
- "im",
379
- );
380
- if (malformed.test(text)) return text.replace(malformed, `[${selector.section}]\n${line}\n`);
381
- const header = new RegExp(`^\\[${escapeRegex(selector.section)}\\][ \\t]*$`, "im");
382
- const match = header.exec(text);
383
- if (match === null || match.index === undefined) {
384
- // Create the section. An empty file must not gain a leading blank line.
385
- if (text.length === 0) return `[${selector.section}]\n${line}\n`;
386
- const prefix = text.endsWith("\n") ? text : `${text}\n`;
387
- return `${prefix}[${selector.section}]\n${line}\n`;
388
- }
389
- const bodyStart = match.index + match[0].length;
390
- const remainder = text.slice(bodyStart);
391
- const nextSectionOffset = remainder.search(/^\[/m);
392
- const bodyEnd = nextSectionOffset < 0 ? text.length : bodyStart + nextSectionOffset;
393
- const body = text.slice(bodyStart, bodyEnd);
394
- const keyPattern = new RegExp(
395
- `^[ \\t]*${escapeRegex(selector.key)}[ \\t]*=[ \\t]*"[^"]*"[ \\t]*$`,
396
- "im",
397
- );
398
- const nextBody = keyPattern.test(body)
399
- ? body.replace(keyPattern, line)
400
- : appendSectionLine(body, line);
401
- return `${text.slice(0, bodyStart)}${nextBody}${text.slice(bodyEnd)}`;
402
- }
403
- case "section": {
404
- // Matches the header line and every following line up to (not including)
405
- // the next header. `[ \t]*\r?\n` for the header's own break, and the body
406
- // alternation requires a line break per line, so the match can never run
407
- // past the section it names.
408
- const sectionRe = new RegExp(
409
- `^\\[${escapeRegex(selector.section)}\\][ \\t]*\\r?\\n(?:(?!^\\[)[^\\n]*\\n?)*`,
410
- "im",
411
- );
412
- const block = `[${selector.section}]\n${value}\n`;
413
- const existing = sectionRe.exec(text);
414
- if (existing === null) {
415
- // Append at end-of-file, adding only the break the previous line needs.
416
- // An empty file gets no leading blank line.
417
- if (text.length === 0) return block;
418
- return `${text.endsWith("\n") ? text : `${text}\n`}${block}`;
419
- }
420
- // The blank lines trailing the matched block are the *gap* before the next
421
- // header, not part of the body being replaced, so they are carried over.
422
- // Dropping them collapsed `[a]\n x = "1"\n\n[b]` into
423
- // `[a]\n x = "9"\n[b]` — silently rewriting the author's paragraphing on
424
- // every apply.
425
- const matched = existing[0];
426
- const trailingGap = /(?:\r?\n[ \t]*)+$/.exec(matched)?.[0] ?? "";
427
- const ownBreak = /^[ \t]*\r?\n/.exec(trailingGap)?.[0] ?? "";
428
- const carriedGap = trailingGap.slice(ownBreak.length);
429
- const replacement = `${block}${carriedGap}`;
430
- return `${text.slice(0, existing.index)}${replacement}${text.slice(existing.index + matched.length)}`;
431
- }
432
- default:
433
- selector satisfies never;
434
- return text;
435
- }
436
- }
437
-
438
- /**
439
- * Remove a field. `flat` removal is global for toml (all root occurrences,
440
- * matching the only variant this module ever needed) and single-match for
441
- * env; `sectionKey`/`section` removal is scoped to the named section.
442
- */
443
- export function textRemove(text: string, selector: TextSelector): string {
444
- switch (selector.kind) {
445
- case "flat": {
446
- if (selector.format === "env") {
447
- return removeLine(text, new RegExp(`^${escapeRegex(selector.key)}=.*$`, "im"));
448
- }
449
- return removeLine(
450
- text,
451
- new RegExp(`^${escapeRegex(selector.key)}[ \\t]*=[ \\t]*"[^"]*"[ \\t]*$`, "gim"),
452
- );
453
- }
454
- case "sectionKey": {
455
- const bounds = sectionBounds(text, selector.section);
456
- if (!bounds) return text;
457
- const body = text.slice(bounds.start, bounds.end);
458
- const nextBody = removeLine(
459
- body,
460
- new RegExp(`^[ \\t]*${escapeRegex(selector.key)}[ \\t]*=[ \\t]*"[^"]*"[ \\t]*$`, "im"),
461
- );
462
- return `${text.slice(0, bounds.start)}${nextBody}${text.slice(bounds.end)}`;
463
- }
464
- case "section": {
465
- // Matches the header line and every following line up to (not including)
466
- // the next header. `[ \t]*\r?\n` for the header's own break, and the body
467
- // alternation requires a line break per line, so the match can never run
468
- // past the section it names.
469
- const re = new RegExp(
470
- `^\\[${escapeRegex(selector.section)}\\][ \\t]*\\r?\\n(?:(?!^\\[)[^\\n]*\\n?)*`,
471
- "gim",
472
- );
473
- const removed = text.replace(re, "");
474
- // Collapse the gap the block left behind. The blank lines that separated
475
- // it from its neighbours are not part of it, so `# top\n\n\n[a]\n…` used to
476
- // come back as `# top\n\n\n` — trailing blanks where a section used to be.
477
- // Only a gap at the very start or the very end is collapsed; anywhere else
478
- // it still separates two real lines.
479
- const leadingGap = /^(?:[ \t]*\r?\n)+/.exec(removed)?.[0] ?? "";
480
- const body = removed.slice(leadingGap.length).replace(/(?:\r?\n[ \t]*)+$/, "");
481
- return body.length === 0 ? "" : `${body}\n`;
482
- }
483
- default:
484
- selector satisfies never;
485
- return text;
486
- }
487
- }
1
+ /**
2
+ * Shared filesystem operations for CLI tool injectors.
3
+ *
4
+ * Every injector uses the helpers in this module instead of importing
5
+ * `node:fs` or `Bun.file` directly. Two reasons: (1) it keeps the FS surface
6
+ * tiny and auditable, and (2) tests can point injectors at temp dirs by
7
+ * stubbing `HOME` rather than mocking `fs`.
8
+ */
9
+
10
+ import { homedir, platform } from "node:os";
11
+ import { join } from "node:path";
12
+ import { mkdir, rm } from "node:fs/promises";
13
+
14
+ /** Home directory — shared contract used by all injectors. */
15
+ export { homedir as homeDir };
16
+ export { join };
17
+
18
+ const IS_WIN: boolean = platform() === "win32";
19
+
20
+ /** Check if a file exists. */
21
+ export async function fileExists(path: string): Promise<boolean> {
22
+ return Bun.file(path).exists();
23
+ }
24
+
25
+ /** Read and parse a JSON file, tolerating trailing commas. Returns null if missing or unparseable. */
26
+ export async function readJsonFile(path: string): Promise<unknown | null> {
27
+ try {
28
+ const text = await Bun.file(path).text();
29
+ const stripped = text.replace(/,(\s*[}\]])/g, "$1");
30
+ return JSON.parse(stripped);
31
+ } catch {
32
+ return null;
33
+ }
34
+ }
35
+
36
+ /** Write JSON to a file with 2-space indentation. */
37
+ export async function writeJsonFile(path: string, data: unknown): Promise<void> {
38
+ await Bun.write(path, JSON.stringify(data, null, 2));
39
+ }
40
+
41
+ /** Read a text file, returning null if it doesn't exist. */
42
+ export async function readTextFile(path: string): Promise<string | null> {
43
+ try {
44
+ return await Bun.file(path).text();
45
+ } catch {
46
+ return null;
47
+ }
48
+ }
49
+
50
+ /** Write text to a file. */
51
+ export async function writeTextFile(path: string, content: string): Promise<void> {
52
+ await Bun.write(path, content);
53
+ }
54
+
55
+ /** Remove a file if it exists. */
56
+ export async function removeFile(path: string): Promise<void> {
57
+ await rm(path, { force: true });
58
+ }
59
+
60
+ /** Create a directory recursively (like mkdir -p). */
61
+ export async function ensureDir(path: string): Promise<void> {
62
+ await mkdir(path, { recursive: true });
63
+ }
64
+
65
+ /**
66
+ * Check if a CLI binary is installed on the current PATH.
67
+ */
68
+ export async function checkBinaryInstalled(binaryName: string): Promise<boolean> {
69
+ const cmd = IS_WIN ? "where" : "which";
70
+ const env: Record<string, string | undefined> = { ...process.env };
71
+ if (IS_WIN && env.APPDATA) env.PATH = `${env.APPDATA}\\npm;${env.PATH ?? ""}`;
72
+ try {
73
+ const proc = Bun.spawn([cmd, binaryName], {
74
+ stdout: "ignore",
75
+ stderr: "ignore",
76
+ env: env as Record<string, string>,
77
+ });
78
+ return (await proc.exited) === 0;
79
+ } catch {
80
+ return false;
81
+ }
82
+ }
83
+
84
+ // ─── Endpoint normalization helpers ──────────────────────────────────────────
85
+
86
+ /**
87
+ * Ensure a URL ends with `/v1`.
88
+ *
89
+ * Trailing slashes are normalized first. Without that, an endpoint the operator
90
+ * typed as `http://localhost:12800/` — the form a browser address bar produces,
91
+ * and the one the dashboard's own links use — became
92
+ * `http://localhost:12800//v1`, which every downstream client treats as a
93
+ * different path and 404s. `http://host/v1/` became `http://host/v1//v1`.
94
+ */
95
+ export function ensureV1Suffix(url: string): string {
96
+ const trimmed = url.replace(/\/+$/, "");
97
+ return trimmed.endsWith("/v1") ? trimmed : `${trimmed}/v1`;
98
+ }
99
+
100
+ /** Strip a trailing `/v1` from a URL (some tools like Cline expect no /v1). */
101
+ export function stripV1Suffix(url: string): string {
102
+ const trimmed = url.replace(/\/+$/, "");
103
+ return trimmed.endsWith("/v1") ? trimmed.slice(0, -3) : trimmed;
104
+ }
105
+
106
+ /**
107
+ * Whether an endpoint URL points at a local Cartethyia instance.
108
+ *
109
+ * The match is anchored to the **hostname**, parsed rather than substring-
110
+ * scanned. The previous unanchored `/localhost|127\.0\.0\.1|0\.0\.0\.0|
111
+ * cartethyia/i` test matched anywhere in the string, so `https://notlocalhost.com`,
112
+ * `https://mylocalhostproxy.net` and even `https://evil.com/localhost` were all
113
+ * reported as local — and this value is what the dashboard shows the operator
114
+ * as "configured", so a tool pointed at a remote host read as pointed at this
115
+ * gateway. A hostname that merely *contains* one of these words is not local.
116
+ *
117
+ * A URL without a scheme is retried with an `http://` prefix, because the
118
+ * operator may paste a bare `localhost:12800`.
119
+ */
120
+ export function isLocalEndpoint(url: string | null | undefined): boolean {
121
+ if (!url) return false;
122
+ const hostname = hostnameOf(url);
123
+ if (hostname === null) return false;
124
+ const normalized = hostname.toLowerCase().replace(/^\[|\]$/g, "");
125
+ if (normalized === "localhost" || normalized === "::1") return true;
126
+ if (normalized === "0.0.0.0") return true;
127
+ if (/^127\./.test(normalized)) return true;
128
+ // A Cartethyia *hostname*, not a path or query that happens to spell it.
129
+ return normalized === "cartethyia" || normalized.startsWith("cartethyia.");
130
+ }
131
+
132
+ /**
133
+ * Parses the hostname out of an endpoint, tolerating a missing scheme.
134
+ *
135
+ * A bare `localhost:12800` is the trap here: `new URL` *accepts* it, reading
136
+ * `localhost:` as the scheme, and reports an **empty** hostname — so a parse
137
+ * that only checks for a throw treats it as a host it cannot judge. Both forms
138
+ * are therefore tried, and an empty hostname counts as a failure.
139
+ */
140
+ function hostnameOf(url: string): string | null {
141
+ for (const candidate of [url, `http://${url}`]) {
142
+ try {
143
+ const hostname = new URL(candidate).hostname;
144
+ if (hostname.length > 0) return hostname;
145
+ } catch {
146
+ // Not parseable in this form; try the next.
147
+ }
148
+ }
149
+ return null;
150
+ }
151
+
152
+ /** Sanitize an API key to a prefix for display (first 8 chars + ...). */
153
+ export function keyPrefix(key: string | null | undefined): string | null {
154
+ if (!key || key.length < 8) return key ?? null;
155
+ return `${key.slice(0, 8)}...`;
156
+ }
157
+
158
+ // ─── Structured text editing (TOML flat/section keys, .env KEY=VALUE) ───────
159
+ //
160
+ // These handle the simple structures the CLI tools use: flat key=value
161
+ // pairs and one-level-nested [section] tables. They are NOT a general TOML
162
+ // parser — they preserve existing file content via regex upsert/remove so
163
+ // user comments and formatting are kept intact.
164
+
165
+ function escapeRegex(s: string): string {
166
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
167
+ }
168
+
169
+ /**
170
+ * Horizontal whitespace, never a line break.
171
+ *
172
+ * Every pattern below is **line-oriented**: a TOML or `.env` key/value line
173
+ * cannot contain a newline, so each `\s` must be `[ \t]`. With `\s` a pattern
174
+ * matches *across* a line break, and because several of these patterns drive a
175
+ * `replace` whose replacement carries no trailing newline, the match swallows
176
+ * the break and glues two lines together. Measured consequences of that,
177
+ * before the fix — all of them syntactically invalid TOML, which the target
178
+ * CLI then refuses to start on:
179
+ *
180
+ * - `[agents]\n\n k = "1"` → upserting `k` produced `[agents] k = "9"`,
181
+ * putting a key on the section header's own line. `textGet` for that key
182
+ * then read back `null`, so the status shown to the operator was wrong too.
183
+ * - `[agents]\n k = "1"\n other = "2"\n[z]` → upserting `other` produced
184
+ * `other = "9"[z]`, appending the next header to the last key's line.
185
+ * - `model = "gpt-5"\n\n[section]` → an `insertAtTop` upsert of `model`
186
+ * produced `model = "new"[section]`.
187
+ *
188
+ * The read-only patterns (`textGet`, `textHas`) get the same treatment for
189
+ * consistency: a key written across two lines is not a key this module should
190
+ * claim to have found.
191
+ */
192
+
193
+ /**
194
+ * Addresses one field in structured text:
195
+ * - `flat`: a root-level `key = "value"` (toml, default) or `KEY=value`
196
+ * (`format: "env"`) line. `insertAtTop` (toml only) inserts new keys
197
+ * before the first `[section]` header instead of appending at EOF —
198
+ * required for keys that must precede any table per TOML syntax.
199
+ * - `sectionKey`: a quoted key nested one level inside `[section]`,
200
+ * upserted/removed without disturbing sibling keys.
201
+ * - `section`: the whole `[section]` block as an opaque body string.
202
+ */
203
+ export type TextSelector =
204
+ | {
205
+ readonly kind: "flat";
206
+ readonly key: string;
207
+ readonly format?: "toml" | "env";
208
+ readonly insertAtTop?: boolean;
209
+ }
210
+ | { readonly kind: "sectionKey"; readonly section: string; readonly key: string }
211
+ | { readonly kind: "section"; readonly section: string };
212
+
213
+ /** Body boundaries of an existing `[section]`, or null if it doesn't exist. */
214
+ function sectionBounds(text: string, section: string): { start: number; end: number } | null {
215
+ const header = new RegExp(`^\\[${escapeRegex(section)}\\][ \\t]*$`, "im");
216
+ const match = header.exec(text);
217
+ if (match === null || match.index === undefined) return null;
218
+ const bodyStart = match.index + match[0].length;
219
+ const remainder = text.slice(bodyStart);
220
+ const nextSectionOffset = remainder.search(/^\[/m);
221
+ const bodyEnd = nextSectionOffset < 0 ? text.length : bodyStart + nextSectionOffset;
222
+ return { start: bodyStart, end: bodyEnd };
223
+ }
224
+
225
+ /**
226
+ * Removes one whole line matching `pattern`, taking the line's own trailing
227
+ * newline and nothing else.
228
+ *
229
+ * Removing a line must not disturb the blank lines around it: they are the
230
+ * author's paragraphing, and in a config file the operator reads, collapsing
231
+ * `key\n\n[next]` into `[next]` silently rewrites unrelated formatting. So the
232
+ * match stops at the first line break — never at a run of whitespace — which is
233
+ * the same discipline `horizontal` above enforces for the other patterns.
234
+ */
235
+ function removeLine(text: string, pattern: RegExp): string {
236
+ const linePattern = new RegExp(`${pattern.source}[ \\t]*\\r?\\n?`, pattern.flags);
237
+ return text.replace(linePattern, "");
238
+ }
239
+
240
+ /**
241
+ * Index just past the last root key, i.e. after the blank lines that precede
242
+ * the first `[section]` header are excluded.
243
+ *
244
+ * Those blank lines are the header's leading separator. Keeping them out of the
245
+ * "root keys" slice lets an in-place replacement of a root key leave them
246
+ * untouched; the caller re-attaches them unchanged.
247
+ */
248
+ function rootEndOfRootKeys(text: string, headerStart: number): number {
249
+ const rootRegion = text.slice(0, headerStart);
250
+ const trailingBreaks = /(?:\r?\n[ \t]*)+$/.exec(rootRegion);
251
+ if (trailingBreaks === null) return headerStart;
252
+ // The final line break is the root key's own terminator, so it stays.
253
+ const withoutBlankTail = rootRegion.slice(0, trailingBreaks.index);
254
+ const ownBreak = /^[ \t]*\r?\n/.exec(trailingBreaks[0]);
255
+ return ownBreak === null
256
+ ? withoutBlankTail.length
257
+ : withoutBlankTail.length + ownBreak[0].length;
258
+ }
259
+
260
+ /**
261
+ * Appends `line` to the end of a section body, preserving any blank lines the
262
+ * author put inside it.
263
+ *
264
+ * The body is the text between a header and the next header, so its own
265
+ * trailing newline belongs to the last key, not to the gap. Only the final
266
+ * run of line breaks is trimmed, and only enough to make room for one line —
267
+ * measured before the fix: a body `\n k = "1"\n\n other = "2"\n` collapsed to
268
+ * `\n k = "1"\n other = "9"` with the blank line and the closing break gone.
269
+ */
270
+ function appendSectionLine(body: string, line: string): string {
271
+ const withoutTrailingBreaks = body.replace(/(?:\r?\n[ \t]*)+$/, "");
272
+ return `${withoutTrailingBreaks}\n${line}\n`;
273
+ }
274
+
275
+ /** Read a field's value; null if the field (or its section) doesn't exist. */
276
+ export function textGet(text: string, selector: TextSelector): string | null {
277
+ switch (selector.kind) {
278
+ case "flat": {
279
+ if (selector.format === "env") {
280
+ const re = new RegExp(`^${escapeRegex(selector.key)}=(.*)$`, "im");
281
+ return re.exec(text)?.[1]?.trim() ?? null;
282
+ }
283
+ const re = new RegExp(`^${escapeRegex(selector.key)}[ \\t]*=[ \\t]*"([^"]*)"`, "im");
284
+ return re.exec(text)?.[1] ?? null;
285
+ }
286
+ case "sectionKey": {
287
+ const bounds = sectionBounds(text, selector.section);
288
+ if (!bounds) return null;
289
+ const body = text.slice(bounds.start, bounds.end);
290
+ const re = new RegExp(`^[ \\t]*${escapeRegex(selector.key)}[ \\t]*=[ \\t]*"([^"]*)"`, "im");
291
+ return re.exec(body)?.[1] ?? null;
292
+ }
293
+ case "section": {
294
+ const bounds = sectionBounds(text, selector.section);
295
+ return bounds ? text.slice(bounds.start, bounds.end) : null;
296
+ }
297
+ default:
298
+ selector satisfies never;
299
+ return null;
300
+ }
301
+ }
302
+
303
+ /** Check whether a field exists (or, for `section`, whether the header is present). */
304
+ export function textHas(text: string, selector: TextSelector): boolean {
305
+ switch (selector.kind) {
306
+ case "flat": {
307
+ const re =
308
+ selector.format === "env"
309
+ ? new RegExp(`^${escapeRegex(selector.key)}=.*$`, "im")
310
+ : new RegExp(`^${escapeRegex(selector.key)}[ \\t]*=[ \\t]*"[^"]*"`, "im");
311
+ return re.test(text);
312
+ }
313
+ case "sectionKey": {
314
+ const bounds = sectionBounds(text, selector.section);
315
+ if (!bounds) return false;
316
+ const body = text.slice(bounds.start, bounds.end);
317
+ return new RegExp(`^[ \\t]*${escapeRegex(selector.key)}[ \\t]*=[ \\t]*"[^"]*"`, "im").test(body);
318
+ }
319
+ case "section":
320
+ return new RegExp(`^\\[${escapeRegex(selector.section)}\\]`, "im").test(text);
321
+ default:
322
+ selector satisfies never;
323
+ return false;
324
+ }
325
+ }
326
+
327
+ /** Upsert a field's value (or, for `section`, replace/insert the whole body). */
328
+ export function textUpsert(text: string, selector: TextSelector, value: string): string {
329
+ switch (selector.kind) {
330
+ case "flat": {
331
+ const env = selector.format === "env";
332
+ const line = env ? `${selector.key}=${value}` : `${selector.key} = "${value}"`;
333
+ if (selector.insertAtTop) {
334
+ // Root-level insert before the first `[section]` header (TOML root
335
+ // keys must precede all tables) rather than appending at EOF.
336
+ //
337
+ // The boundary is walked back over the blank lines that separate the
338
+ // root keys from the first header, because those blank lines belong to
339
+ // the header, not to the last root key. Replacing the key in place with
340
+ // `keyPattern` anchored at `\\s*$` would otherwise swallow the break and
341
+ // the indentation — measured: `model = "gpt-5"\n\n[section]` came out as
342
+ // `model = "new"[section]`.
343
+ const headerStart = text.search(/^\[/m);
344
+ const boundary = headerStart < 0 ? text.length : headerStart;
345
+ const rootEnd = headerStart < 0 ? boundary : rootEndOfRootKeys(text, headerStart);
346
+ const root = text.slice(0, rootEnd);
347
+ const keyPattern = new RegExp(
348
+ `^${escapeRegex(selector.key)}[ \\t]*=[ \\t]*"[^"]*"[ \\t]*$`,
349
+ "im",
350
+ );
351
+ // The blank lines between the root keys and the first header are the
352
+ // header's leading separator, and `rootEndOfRootKeys` holds them out of
353
+ // `root` so a replacement cannot eat them. Re-attaching them verbatim
354
+ // is what keeps the file's paragraphing intact.
355
+ const separator = text.slice(rootEnd, boundary);
356
+ if (keyPattern.test(root)) {
357
+ return `${root.replace(keyPattern, line)}${separator}${text.slice(boundary)}`;
358
+ }
359
+ const prefix = root.endsWith("\n") || root.length === 0 ? root : `${root}\n`;
360
+ return `${prefix}${line}\n${separator}${text.slice(boundary)}`;
361
+ }
362
+ const re = env
363
+ ? new RegExp(`^${escapeRegex(selector.key)}=.*$`, "im")
364
+ : new RegExp(`^${escapeRegex(selector.key)}[ \\t]*=[ \\t]*"[^"]*"`, "im");
365
+ if (re.test(text)) return text.replace(re, line);
366
+ // Append at end-of-file. `text.length > 0` matters for the toml form too:
367
+ // an empty file must not gain a leading blank line before its first key.
368
+ if (text.length === 0) return `${line}\n`;
369
+ const withNewline = text.endsWith("\n") ? text : `${text}\n`;
370
+ return `${withNewline}${line}\n`;
371
+ }
372
+ case "sectionKey": {
373
+ const line = ` ${selector.key} = "${value}"`;
374
+ // Recovers a malformed file where the key ended up on the same line
375
+ // as the section header (e.g. from an earlier buggy write).
376
+ const malformed = new RegExp(
377
+ `\\[${escapeRegex(selector.section)}\\][ \\t]+${escapeRegex(selector.key)}[ \\t]*=[ \\t]*"[^"]*"`,
378
+ "im",
379
+ );
380
+ if (malformed.test(text)) return text.replace(malformed, `[${selector.section}]\n${line}\n`);
381
+ const header = new RegExp(`^\\[${escapeRegex(selector.section)}\\][ \\t]*$`, "im");
382
+ const match = header.exec(text);
383
+ if (match === null || match.index === undefined) {
384
+ // Create the section. An empty file must not gain a leading blank line.
385
+ if (text.length === 0) return `[${selector.section}]\n${line}\n`;
386
+ const prefix = text.endsWith("\n") ? text : `${text}\n`;
387
+ return `${prefix}[${selector.section}]\n${line}\n`;
388
+ }
389
+ const bodyStart = match.index + match[0].length;
390
+ const remainder = text.slice(bodyStart);
391
+ const nextSectionOffset = remainder.search(/^\[/m);
392
+ const bodyEnd = nextSectionOffset < 0 ? text.length : bodyStart + nextSectionOffset;
393
+ const body = text.slice(bodyStart, bodyEnd);
394
+ const keyPattern = new RegExp(
395
+ `^[ \\t]*${escapeRegex(selector.key)}[ \\t]*=[ \\t]*"[^"]*"[ \\t]*$`,
396
+ "im",
397
+ );
398
+ const nextBody = keyPattern.test(body)
399
+ ? body.replace(keyPattern, line)
400
+ : appendSectionLine(body, line);
401
+ return `${text.slice(0, bodyStart)}${nextBody}${text.slice(bodyEnd)}`;
402
+ }
403
+ case "section": {
404
+ // Matches the header line and every following line up to (not including)
405
+ // the next header. `[ \t]*\r?\n` for the header's own break, and the body
406
+ // alternation requires a line break per line, so the match can never run
407
+ // past the section it names.
408
+ const sectionRe = new RegExp(
409
+ `^\\[${escapeRegex(selector.section)}\\][ \\t]*\\r?\\n(?:(?!^\\[)[^\\n]*\\n?)*`,
410
+ "im",
411
+ );
412
+ const block = `[${selector.section}]\n${value}\n`;
413
+ const existing = sectionRe.exec(text);
414
+ if (existing === null) {
415
+ // Append at end-of-file, adding only the break the previous line needs.
416
+ // An empty file gets no leading blank line.
417
+ if (text.length === 0) return block;
418
+ return `${text.endsWith("\n") ? text : `${text}\n`}${block}`;
419
+ }
420
+ // The blank lines trailing the matched block are the *gap* before the next
421
+ // header, not part of the body being replaced, so they are carried over.
422
+ // Dropping them collapsed `[a]\n x = "1"\n\n[b]` into
423
+ // `[a]\n x = "9"\n[b]` — silently rewriting the author's paragraphing on
424
+ // every apply.
425
+ const matched = existing[0];
426
+ const trailingGap = /(?:\r?\n[ \t]*)+$/.exec(matched)?.[0] ?? "";
427
+ const ownBreak = /^[ \t]*\r?\n/.exec(trailingGap)?.[0] ?? "";
428
+ const carriedGap = trailingGap.slice(ownBreak.length);
429
+ const replacement = `${block}${carriedGap}`;
430
+ return `${text.slice(0, existing.index)}${replacement}${text.slice(existing.index + matched.length)}`;
431
+ }
432
+ default:
433
+ selector satisfies never;
434
+ return text;
435
+ }
436
+ }
437
+
438
+ /**
439
+ * Remove a field. `flat` removal is global for toml (all root occurrences,
440
+ * matching the only variant this module ever needed) and single-match for
441
+ * env; `sectionKey`/`section` removal is scoped to the named section.
442
+ */
443
+ export function textRemove(text: string, selector: TextSelector): string {
444
+ switch (selector.kind) {
445
+ case "flat": {
446
+ if (selector.format === "env") {
447
+ return removeLine(text, new RegExp(`^${escapeRegex(selector.key)}=.*$`, "im"));
448
+ }
449
+ return removeLine(
450
+ text,
451
+ new RegExp(`^${escapeRegex(selector.key)}[ \\t]*=[ \\t]*"[^"]*"[ \\t]*$`, "gim"),
452
+ );
453
+ }
454
+ case "sectionKey": {
455
+ const bounds = sectionBounds(text, selector.section);
456
+ if (!bounds) return text;
457
+ const body = text.slice(bounds.start, bounds.end);
458
+ const nextBody = removeLine(
459
+ body,
460
+ new RegExp(`^[ \\t]*${escapeRegex(selector.key)}[ \\t]*=[ \\t]*"[^"]*"[ \\t]*$`, "im"),
461
+ );
462
+ return `${text.slice(0, bounds.start)}${nextBody}${text.slice(bounds.end)}`;
463
+ }
464
+ case "section": {
465
+ // Matches the header line and every following line up to (not including)
466
+ // the next header. `[ \t]*\r?\n` for the header's own break, and the body
467
+ // alternation requires a line break per line, so the match can never run
468
+ // past the section it names.
469
+ const re = new RegExp(
470
+ `^\\[${escapeRegex(selector.section)}\\][ \\t]*\\r?\\n(?:(?!^\\[)[^\\n]*\\n?)*`,
471
+ "gim",
472
+ );
473
+ const removed = text.replace(re, "");
474
+ // Collapse the gap the block left behind. The blank lines that separated
475
+ // it from its neighbours are not part of it, so removing `[a]` from
476
+ // `# top\n\n\n[a]\n…` would otherwise leave `# top\n\n\n` — trailing blanks
477
+ // where the section was. Only a gap at the very start or the very end is
478
+ // collapsed; anywhere else it still separates two real lines.
479
+ const leadingGap = /^(?:[ \t]*\r?\n)+/.exec(removed)?.[0] ?? "";
480
+ const body = removed.slice(leadingGap.length).replace(/(?:\r?\n[ \t]*)+$/, "");
481
+ return body.length === 0 ? "" : `${body}\n`;
482
+ }
483
+ default:
484
+ selector satisfies never;
485
+ return text;
486
+ }
487
+ }