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.
- package/.env.example +13 -4
- package/README.md +2 -2
- package/dist/dashboard/assets/{BannedPage-C2orBsI2.js → BannedPage-CADDq4IU.js} +1 -1
- package/dist/dashboard/assets/BannedPage-CADDq4IU.js.gz +0 -0
- package/dist/dashboard/assets/CliToolDetailPage-_z1hID7E.js +1 -0
- package/dist/dashboard/assets/CliToolDetailPage-_z1hID7E.js.gz +0 -0
- package/dist/dashboard/assets/{CliToolsPage-BV6pxVQo.js → CliToolsPage-DuO1Z7dq.js} +1 -1
- package/dist/dashboard/assets/CliToolsPage-DuO1Z7dq.js.gz +0 -0
- package/dist/dashboard/assets/CombosPage-DNEPi3xU.js +1 -0
- package/dist/dashboard/assets/CombosPage-DNEPi3xU.js.gz +0 -0
- package/dist/dashboard/assets/{ConsoleLogPage-CW-wPmJ6.js → ConsoleLogPage-BVdpfiz1.js} +1 -1
- package/dist/dashboard/assets/ConsoleLogPage-BVdpfiz1.js.gz +0 -0
- package/dist/dashboard/assets/{CustomizationPage-0p8uX7vB.js → CustomizationPage-BR5HHvbi.js} +1 -1
- package/dist/dashboard/assets/CustomizationPage-BR5HHvbi.js.gz +0 -0
- package/dist/dashboard/assets/{HealthEventsModal-BjqSMfko.js → HealthEventsModal-mUfcknqm.js} +1 -1
- package/dist/dashboard/assets/HealthEventsModal-mUfcknqm.js.gz +0 -0
- package/dist/dashboard/assets/{LoginPage-BvzCx8Kr.js → LoginPage-V5faV9V2.js} +1 -1
- package/dist/dashboard/assets/LoginPage-V5faV9V2.js.gz +0 -0
- package/dist/dashboard/assets/ModelPicker-C7pDb41R.js +1 -0
- package/dist/dashboard/assets/ModelPicker-C7pDb41R.js.gz +0 -0
- package/dist/dashboard/assets/OverviewPage-BDu20Jdx.js +1 -0
- package/dist/dashboard/assets/OverviewPage-BDu20Jdx.js.gz +0 -0
- package/dist/dashboard/assets/ProviderDetailPage-Dllr_aFO.js +3 -0
- package/dist/dashboard/assets/ProviderDetailPage-Dllr_aFO.js.gz +0 -0
- package/dist/dashboard/assets/{ProvidersPage-Js9jUPSb.js → ProvidersPage-uPiGVTTK.js} +1 -1
- package/dist/dashboard/assets/ProvidersPage-uPiGVTTK.js.gz +0 -0
- package/dist/dashboard/assets/ProxyPage-BaUkO-4O.js +7 -0
- package/dist/dashboard/assets/ProxyPage-BaUkO-4O.js.gz +0 -0
- package/dist/dashboard/assets/QuotaPage-o0IFAsFb.js +1 -0
- package/dist/dashboard/assets/QuotaPage-o0IFAsFb.js.gz +0 -0
- package/dist/dashboard/assets/SettingsPage-CYWqbXjp.js +1 -0
- package/dist/dashboard/assets/SettingsPage-CYWqbXjp.js.gz +0 -0
- package/dist/dashboard/assets/{SetupPage-DDbMQsoo.js → SetupPage-UB690SE2.js} +1 -1
- package/dist/dashboard/assets/SetupPage-UB690SE2.js.gz +0 -0
- package/dist/dashboard/assets/{SortableList-CB3Z7VTf.js → SortableList-BLujdX7N.js} +1 -1
- package/dist/dashboard/assets/SortableList-BLujdX7N.js.gz +0 -0
- package/dist/dashboard/assets/{StudioPage-D95UCPHI.js → StudioPage-D5C2raAc.js} +3 -3
- package/dist/dashboard/assets/StudioPage-D5C2raAc.js.gz +0 -0
- package/dist/dashboard/assets/UsagePage-8uIcxa1W.js +56 -0
- package/dist/dashboard/assets/UsagePage-8uIcxa1W.js.gz +0 -0
- package/dist/dashboard/assets/api-keys-DQYLQxpW.js +1 -0
- package/dist/dashboard/assets/api-keys-DQYLQxpW.js.gz +0 -0
- package/dist/dashboard/assets/app-CX_Z8cMs.css +2 -0
- package/dist/dashboard/assets/app-CX_Z8cMs.css.gz +0 -0
- package/dist/dashboard/assets/{assert-Chi7pCnt.js → assert-C9FD_JwJ.js} +1 -1
- package/dist/dashboard/assets/assert-C9FD_JwJ.js.gz +0 -0
- package/dist/dashboard/assets/{badge-D4pQapKj.js → badge-CR72dasT.js} +1 -1
- package/dist/dashboard/assets/badge-CR72dasT.js.gz +0 -0
- package/dist/dashboard/assets/{banner-DH6cg-_B.js → banner-CI9_eSfO.js} +1 -1
- package/dist/dashboard/assets/banner-CI9_eSfO.js.gz +0 -0
- package/dist/dashboard/assets/{card-BKqlUOe4.js → card-DS7CQEsO.js} +1 -1
- package/dist/dashboard/assets/card-DS7CQEsO.js.gz +0 -0
- package/dist/dashboard/assets/{cli-tools-fTgljTGA.js → cli-tools-CNqzrfdb.js} +1 -1
- package/dist/dashboard/assets/cli-tools-CNqzrfdb.js.gz +0 -0
- package/dist/dashboard/assets/{clipboard-button-D5Ys_sM0.js → clipboard-button-611pSsaX.js} +1 -1
- package/dist/dashboard/assets/clipboard-button-611pSsaX.js.gz +0 -0
- package/dist/dashboard/assets/{fingerprint-pattern-BWuHtbW4.js → concurrency-BDF0ok7y.js} +1 -1
- package/dist/dashboard/assets/concurrency-BDF0ok7y.js.gz +0 -0
- package/dist/dashboard/assets/dialog-k37exjNt.js +1 -0
- package/dist/dashboard/assets/dialog-k37exjNt.js.gz +0 -0
- package/dist/dashboard/assets/drawer-CX7N8ucR.js +1 -0
- package/dist/dashboard/assets/drawer-CX7N8ucR.js.gz +0 -0
- package/dist/dashboard/assets/{entry-CQBr6U9c.js → entry-B9Xvtnfj.js} +1 -1
- package/dist/dashboard/assets/entry-B9Xvtnfj.js.gz +0 -0
- package/dist/dashboard/assets/entry-CTP8AyiS.js +1 -0
- package/dist/dashboard/assets/entry-CTP8AyiS.js.gz +0 -0
- package/dist/dashboard/assets/entry-DxBHnauy.js +4 -0
- package/dist/dashboard/assets/entry-DxBHnauy.js.gz +0 -0
- package/dist/dashboard/assets/{github-badge-BlptL8Uf.js → github-badge-sltk1qUi.js} +1 -1
- package/dist/dashboard/assets/github-badge-sltk1qUi.js.gz +0 -0
- package/dist/dashboard/assets/{index-js6TJvdr.js → index-CmxZr14F.js} +2 -2
- package/dist/dashboard/assets/index-CmxZr14F.js.gz +0 -0
- package/dist/dashboard/assets/{inline-DUWDSmDV.js → inline-BhY2sSnO.js} +1 -1
- package/dist/dashboard/assets/inline-BhY2sSnO.js.gz +0 -0
- package/dist/dashboard/assets/{input-LVjgZq4f.js → input-V1umFiBk.js} +1 -1
- package/dist/dashboard/assets/input-V1umFiBk.js.gz +0 -0
- package/dist/dashboard/assets/layout-BBOYycyS.js +1 -0
- package/dist/dashboard/assets/layout-BBOYycyS.js.gz +0 -0
- package/dist/dashboard/assets/{list-row-DupvLfDx.js → list-row-1zzZYM0B.js} +1 -1
- package/dist/dashboard/assets/list-row-1zzZYM0B.js.gz +0 -0
- package/dist/dashboard/assets/{live-C4pEJUqI.js → live-BivGfuUs.js} +1 -1
- package/dist/dashboard/assets/live-BivGfuUs.js.gz +0 -0
- package/dist/dashboard/assets/{model-bans-BRiuufkB.js → model-bans-CmffjLuG.js} +1 -1
- package/dist/dashboard/assets/model-bans-CmffjLuG.js.gz +0 -0
- package/dist/dashboard/assets/{mutation-B4ROwk8L.js → mutation-46P0oWMe.js} +1 -1
- package/dist/dashboard/assets/mutation-46P0oWMe.js.gz +0 -0
- package/dist/dashboard/assets/numbers-DkfMR5Qs.js +2 -0
- package/dist/dashboard/assets/numbers-DkfMR5Qs.js.gz +0 -0
- package/dist/dashboard/assets/{query-keys-gmjzC3RS.js → query-keys-BFhGpwR1.js} +1 -1
- package/dist/dashboard/assets/query-keys-BFhGpwR1.js.gz +0 -0
- package/dist/dashboard/assets/{routing-C7tBT5o-.js → routing-CAb4F37R.js} +1 -1
- package/dist/dashboard/assets/routing-CAb4F37R.js.gz +0 -0
- package/dist/dashboard/assets/{select-WUCXorXw.js → select-BYR_5K2c.js} +1 -1
- package/dist/dashboard/assets/select-BYR_5K2c.js.gz +0 -0
- package/dist/dashboard/assets/{settings-C_zKl1GS.js → settings-DNX9sydi.js} +1 -1
- package/dist/dashboard/assets/settings-DNX9sydi.js.gz +0 -0
- package/dist/dashboard/assets/{stack-_3O0F8Xn.js → stack-BHTfXdz_.js} +1 -1
- package/dist/dashboard/assets/stack-BHTfXdz_.js.gz +0 -0
- package/dist/dashboard/assets/{state-BgTxHcJS.js → state-mPDRlQkl.js} +1 -1
- package/dist/dashboard/assets/state-mPDRlQkl.js.gz +0 -0
- package/dist/dashboard/assets/{switch-DEjTtOWf.js → switch-DOmJxg7H.js} +1 -1
- package/dist/dashboard/assets/switch-DOmJxg7H.js.gz +0 -0
- package/dist/dashboard/assets/tokens-COzario9.js +1 -0
- package/dist/dashboard/assets/tokens-COzario9.js.gz +0 -0
- package/dist/dashboard/assets/use-modal-focus-By7oJoGd.js +1 -0
- package/dist/dashboard/assets/use-modal-focus-By7oJoGd.js.gz +0 -0
- package/dist/dashboard/assets/{useMutation-BWSC0UAS.js → useMutation-Dem0lXQj.js} +1 -1
- package/dist/dashboard/assets/useMutation-Dem0lXQj.js.gz +0 -0
- package/dist/dashboard/index.html +1 -1
- package/dist/dashboard/index.html.gz +0 -0
- package/dist/dashboard/precompressed-assets.json +54 -54
- package/migrations/README.md +52 -52
- package/migrations/lite-sqlite3/0000_baseline.sql +2 -2
- package/package.json +96 -95
- package/scripts/cli/admin.ts +125 -0
- package/scripts/cli/{ui/background.ts → background.ts} +1 -1
- package/scripts/cli/{command/console-menu.ts → console-menu.ts} +275 -244
- package/scripts/cli/doctor.ts +37 -0
- package/scripts/cli/{support/env.ts → env.ts} +99 -99
- package/scripts/cli/{gateway/spawn.ts → gateway.ts} +112 -24
- package/scripts/cli/instance.ts +224 -0
- package/scripts/cli/main.ts +61 -18
- package/scripts/cli/{support/platform.ts → platform.ts} +64 -64
- package/scripts/cli/port.ts +100 -0
- package/scripts/cli/{support/services.ts → services.ts} +88 -88
- package/scripts/cli/{command/setup.ts → setup.ts} +35 -34
- package/scripts/cli/{command/start.ts → start.ts} +10 -5
- package/scripts/cli/{support/startup-signal.ts → startup-signal.ts} +23 -23
- package/scripts/cli/tray/index.ts +62 -12
- package/scripts/cli/{command/tray.ts → tray.ts} +208 -208
- package/scripts/cli/{command/update.ts → update.ts} +39 -39
- package/scripts/cli/version.ts +67 -0
- package/scripts/cli/{ui/wordmark.ts → wordmark.ts} +58 -58
- package/src/boot/app.ts +697 -693
- package/src/boot/dependencies.ts +647 -646
- package/src/boot/timeout.ts +2 -13
- package/src/config/cooldowns.ts +186 -138
- package/src/config/network.ts +8 -14
- package/src/config/providers.ts +7 -10
- package/src/config/runtime.ts +76 -76
- package/src/config/security.ts +15 -0
- package/src/config/spec.ts +20 -30
- package/src/config/telemetry.ts +0 -20
- package/src/config/timeouts.ts +0 -6
- package/src/console/admin/model-abuse/contracts.ts +28 -6
- package/src/console/admin/model-abuse/routes.ts +56 -3
- package/src/console/api-keys/contracts.ts +410 -414
- package/src/console/api-keys/routes.ts +881 -880
- package/src/console/auth/credentials.ts +3 -3
- package/src/console/auth/setup-service.ts +2 -2
- package/src/console/backup/export-endpoint.ts +6 -2
- package/src/console/backup/nine-router.ts +2 -5
- package/src/console/backup/service.ts +4 -0
- package/src/console/backup/store.ts +192 -29
- package/src/console/backup/validate.ts +3 -45
- package/src/console/cli-tools/fs-ops.ts +487 -487
- package/src/console/console-router-guards.ts +1 -1
- package/src/console/data-management/contracts.ts +13 -6
- package/src/console/data-management/operations.ts +23 -18
- package/src/console/data-management/routes.ts +0 -1
- package/src/console/domain-registration.ts +10 -5
- package/src/console/observability/live.ts +22 -0
- package/src/console/observability/store.ts +39 -16
- package/src/console/observability/usage-dimensions.ts +4 -4
- package/src/console/providers/catalog/catalog-projections.ts +6 -7
- package/src/console/providers/catalog/model-operations.ts +5 -2
- package/src/console/providers/catalog/provider-operations.ts +4 -4
- package/src/console/providers/catalog/public-model-store.ts +8 -11
- package/src/console/providers/catalog/store.ts +1553 -1553
- package/src/console/providers/oauth/routes.ts +8 -12
- package/src/console/quota/account-quota-tenant.ts +6 -6
- package/src/console/quota/quota-view.ts +1 -1
- package/src/console/remote-support/contracts.ts +61 -0
- package/src/console/remote-support/routes.ts +65 -0
- package/src/console/remote-support/service.ts +207 -0
- package/src/console/response-compression.ts +0 -3
- package/src/console/routing/pools/probe-result.ts +3 -2
- package/src/console/routing/pools/probe-verdict.ts +132 -132
- package/src/console/routing/pools/store.ts +8 -8
- package/src/console/settings/contracts.ts +58 -2
- package/src/console/settings/store.ts +30 -3
- package/src/console/shared/cursor.ts +8 -8
- package/src/console/shared/errors.ts +1 -3
- package/src/network/http2-bidi.ts +2 -4
- package/src/network/pool/agent.ts +478 -478
- package/src/network/pool/selector.ts +5 -4
- package/src/network/retry.ts +1 -2
- package/src/network/ssrf.ts +5 -4
- package/src/observability/event-compaction.ts +80 -78
- package/src/observability/event-day-index.ts +13 -15
- package/src/observability/event-log.ts +4 -4
- package/src/observability/logger.ts +6 -6
- package/src/observability/payload-capture.ts +151 -33
- package/src/observability/payload-store.ts +397 -440
- package/src/observability/redaction.ts +5 -6
- package/src/observability/request-notify.ts +43 -0
- package/src/observability/telemetry-status.ts +8 -9
- package/src/observability/token-speed.ts +5 -4
- package/src/persistence/db-mode.ts +7 -7
- package/src/persistence/readiness.ts +1 -1
- package/src/persistence/schema.ts +1406 -1402
- package/src/persistence/shared-cache.ts +1 -2
- package/src/persistence/stores/api-key-store.ts +18 -6
- package/src/persistence/stores/share-store.ts +4 -3
- package/src/persistence/stores/telemetry-store.ts +236 -237
- package/src/protocol/canonical-model.ts +11 -3
- package/src/protocol/codecs.ts +138 -4
- package/src/protocol/gateway-error.ts +6 -4
- package/src/protocol/request/chat.ts +21 -1
- package/src/protocol/request/codex.ts +10 -1
- package/src/protocol/request/messages.ts +4 -5
- package/src/protocol/request/responses.ts +9 -1
- package/src/protocol/response/chat.ts +34 -4
- package/src/protocol/response/codex.ts +7 -4
- package/src/protocol/response/dsml.ts +140 -28
- package/src/protocol/response/responses.ts +11 -5
- package/src/protocol/stream-error-frames.ts +4 -5
- package/src/providers/authentication/jwt-validator.ts +9 -10
- package/src/providers/authentication/oauth-refresh-service.ts +9 -11
- package/src/providers/configured-openai-providers.ts +134 -136
- package/src/providers/discovery/catalog/catalog-blob.ts +18 -18
- package/src/providers/discovery/catalog/catalog-format.ts +3 -6
- package/src/providers/discovery/models-dev-catalog.ts +354 -354
- package/src/providers/discovery/probe-phases.ts +4 -4
- package/src/providers/discovery/probe-wire.ts +6 -13
- package/src/providers/discovery/probing-service.ts +1117 -1045
- package/src/providers/integrations/alibabacp/alibabacp.ts +1 -1
- package/src/providers/integrations/antigravity/antigravity-quota.ts +5 -7
- package/src/providers/integrations/buddy/buddy-chat-shared.ts +23 -2
- package/src/providers/integrations/buddy/buddy-checkin.ts +1 -4
- package/src/providers/integrations/buddy/buddy-cli-headers.ts +23 -6
- package/src/providers/integrations/buddy/buddy-conversation-headers.ts +0 -1
- package/src/providers/integrations/buddy/buddy-identity.ts +52 -0
- package/src/providers/integrations/buddy/buddy-media.ts +2 -2
- package/src/providers/integrations/buddy/buddy-usage-store.ts +3 -4
- package/src/providers/integrations/buddy/codebuddy/quota.ts +2 -13
- package/src/providers/integrations/buddy/codebuddy/shared.ts +118 -115
- package/src/providers/integrations/buddy/workbuddy/quota.ts +0 -11
- package/src/providers/integrations/buddy/workbuddy/shared.ts +5 -2
- package/src/providers/integrations/claude/claude.ts +608 -608
- package/src/providers/integrations/cline/cline-quota.ts +132 -132
- package/src/providers/integrations/cline/cline.ts +537 -539
- package/src/providers/integrations/codex/codex-oauth.ts +1 -1
- package/src/providers/integrations/commandcode/commandcode.ts +674 -675
- package/src/providers/integrations/commandcode/discovery.ts +117 -116
- package/src/providers/integrations/cursor/cursor-auth.ts +418 -418
- package/src/providers/integrations/cursor/cursor-quota.ts +309 -310
- package/src/providers/integrations/cursor/cursor.ts +11 -163
- package/src/providers/integrations/factory/factory-models.ts +3 -5
- package/src/providers/integrations/grok/grok-install-id.ts +2 -3
- package/src/providers/integrations/grok/grok.ts +6 -7
- package/src/providers/integrations/hermes.ts +14 -18
- package/src/providers/integrations/kiro/kiro-discovery.ts +1 -1
- package/src/providers/integrations/kiro/kiro.ts +1 -1
- package/src/providers/integrations/mistral.ts +6 -6
- package/src/providers/integrations/opencode/fingerprint.ts +3 -3
- package/src/providers/integrations/opencode/provider.ts +8 -8
- package/src/providers/integrations/qoder/qoder-catalog.ts +450 -450
- package/src/providers/integrations/qoder/qoder-oauth.ts +388 -310
- package/src/providers/integrations/qoder/qoder.ts +884 -865
- package/src/providers/integrations/shared/connect.ts +6 -6
- package/src/providers/integrations/trae/trae-catalog.ts +1 -2
- package/src/providers/integrations/trae/trae.ts +2 -2
- package/src/providers/integrations/zcode/oauth.ts +6 -1
- package/src/providers/operations/account-health-service.ts +1528 -1523
- package/src/providers/operations/account-reset-service.ts +1 -1
- package/src/providers/operations/client-versions.ts +6 -6
- package/src/providers/operations/custom-cli-headers.ts +3 -4
- package/src/providers/operations/provider-catalog-seeder.ts +95 -95
- package/src/providers/operations/provider-catalog-service.ts +3 -4
- package/src/providers/provider-metadata.ts +4 -8
- package/src/providers/provider-registry.ts +6 -7
- package/src/providers/provider-usage.ts +1 -1
- package/src/security/abuse.ts +15 -10
- package/src/security/access-control.ts +144 -146
- package/src/security/admission/contracts.ts +11 -0
- package/src/security/admission/in-memory-store.ts +4 -3
- package/src/security/admission/lease-sweep.ts +6 -6
- package/src/security/admission/reasons.ts +5 -1
- package/src/security/admission/redis-store.ts +211 -210
- package/src/security/admission/service.ts +4 -0
- package/src/security/api-key-auth.ts +504 -504
- package/src/security/ip-boundary.ts +155 -15
- package/src/security/model-abuse.ts +366 -94
- package/src/security/outbound-headers.ts +3 -4
- package/src/shared/sleep.ts +4 -0
- package/src/transport/dispatch/attempt-finalize.ts +47 -5
- package/src/transport/dispatch/attempt-loop.ts +531 -530
- package/src/transport/dispatch/stream-bridge.ts +1 -3
- package/src/transport/dispatch/streaming/finalize.ts +1 -1
- package/src/transport/dispatch/streaming/stream.ts +5 -6
- package/src/transport/failure-policy.ts +162 -8
- package/src/transport/guards/auth-guard.ts +7 -4
- package/src/transport/guards/concurrency-guard.ts +0 -5
- package/src/transport/guards/error-lifecycle.ts +13 -6
- package/src/transport/guards/pipeline.ts +15 -3
- package/src/transport/guards/request-context.ts +8 -2
- package/src/transport/request/inflight.ts +5 -6
- package/src/transport/request/preparer.ts +2 -2
- package/src/transport/request/state.ts +12 -8
- package/src/transport/routing/admission.ts +4 -0
- package/src/transport/routing/route-catalog.ts +8 -1
- package/src/transport/routing/route-model.ts +46 -14
- package/src/transport/routing/router.ts +21 -14
- package/src/transport/surface/completion.ts +2 -3
- package/src/transport/surface/dialects.ts +5 -6
- package/src/transport/surface/responses/encode.ts +38 -0
- package/src/transport/surface/responses/parse.ts +4 -0
- package/src/transport/translation/capabilities.ts +2 -3
- package/src/transport/translation/quirks.ts +3 -3
- package/src/workers/buddy-growth-tasks.ts +5 -10
- package/src/workers/daily-checkin.ts +17 -23
- package/src/workers/quota-refresh-sweep.ts +26 -26
- package/dist/dashboard/assets/BannedPage-C2orBsI2.js.gz +0 -0
- package/dist/dashboard/assets/CliToolDetailPage-DUcZ6pMB.js +0 -1
- package/dist/dashboard/assets/CliToolDetailPage-DUcZ6pMB.js.gz +0 -0
- package/dist/dashboard/assets/CliToolsPage-BV6pxVQo.js.gz +0 -0
- package/dist/dashboard/assets/CombosPage-Bsan4679.js +0 -1
- package/dist/dashboard/assets/CombosPage-Bsan4679.js.gz +0 -0
- package/dist/dashboard/assets/ConsoleLogPage-CW-wPmJ6.js.gz +0 -0
- package/dist/dashboard/assets/CustomizationPage-0p8uX7vB.js.gz +0 -0
- package/dist/dashboard/assets/HealthEventsModal-BjqSMfko.js.gz +0 -0
- package/dist/dashboard/assets/LoginPage-BvzCx8Kr.js.gz +0 -0
- package/dist/dashboard/assets/ModelPicker-BY1aKEkP.js +0 -1
- package/dist/dashboard/assets/ModelPicker-BY1aKEkP.js.gz +0 -0
- package/dist/dashboard/assets/OverviewPage-DOQpy1y0.js +0 -1
- package/dist/dashboard/assets/OverviewPage-DOQpy1y0.js.gz +0 -0
- package/dist/dashboard/assets/ProviderDetailPage-ecxyUMzt.js +0 -3
- package/dist/dashboard/assets/ProviderDetailPage-ecxyUMzt.js.gz +0 -0
- package/dist/dashboard/assets/ProvidersPage-Js9jUPSb.js.gz +0 -0
- package/dist/dashboard/assets/ProxyPage-J3TBi3Kx.js +0 -7
- package/dist/dashboard/assets/ProxyPage-J3TBi3Kx.js.gz +0 -0
- package/dist/dashboard/assets/QuotaPage-C1gZS6xr.js +0 -1
- package/dist/dashboard/assets/QuotaPage-C1gZS6xr.js.gz +0 -0
- package/dist/dashboard/assets/SettingsPage-CS86ho5X.js +0 -1
- package/dist/dashboard/assets/SettingsPage-CS86ho5X.js.gz +0 -0
- package/dist/dashboard/assets/SetupPage-DDbMQsoo.js.gz +0 -0
- package/dist/dashboard/assets/SortableList-CB3Z7VTf.js.gz +0 -0
- package/dist/dashboard/assets/StudioPage-D95UCPHI.js.gz +0 -0
- package/dist/dashboard/assets/UsagePage-Cnae9SWA.js +0 -56
- package/dist/dashboard/assets/UsagePage-Cnae9SWA.js.gz +0 -0
- package/dist/dashboard/assets/api-keys-VY68Mhd1.js +0 -1
- package/dist/dashboard/assets/api-keys-VY68Mhd1.js.gz +0 -0
- package/dist/dashboard/assets/app-COlK95oi.css +0 -2
- package/dist/dashboard/assets/app-COlK95oi.css.gz +0 -0
- package/dist/dashboard/assets/assert-Chi7pCnt.js.gz +0 -0
- package/dist/dashboard/assets/badge-D4pQapKj.js.gz +0 -0
- package/dist/dashboard/assets/banner-DH6cg-_B.js.gz +0 -0
- package/dist/dashboard/assets/card-BKqlUOe4.js.gz +0 -0
- package/dist/dashboard/assets/cli-tools-fTgljTGA.js.gz +0 -0
- package/dist/dashboard/assets/clipboard-button-D5Ys_sM0.js.gz +0 -0
- package/dist/dashboard/assets/dialog-BYj-8WuK.js +0 -1
- package/dist/dashboard/assets/dialog-BYj-8WuK.js.gz +0 -0
- package/dist/dashboard/assets/drawer-CmXIZ6H1.js +0 -1
- package/dist/dashboard/assets/drawer-CmXIZ6H1.js.gz +0 -0
- package/dist/dashboard/assets/entry-CBg5zeu8.js +0 -1
- package/dist/dashboard/assets/entry-CBg5zeu8.js.gz +0 -0
- package/dist/dashboard/assets/entry-CQBr6U9c.js.gz +0 -0
- package/dist/dashboard/assets/entry-CgdSAzEV.js +0 -4
- package/dist/dashboard/assets/entry-CgdSAzEV.js.gz +0 -0
- package/dist/dashboard/assets/fingerprint-pattern-BWuHtbW4.js.gz +0 -0
- package/dist/dashboard/assets/github-badge-BlptL8Uf.js.gz +0 -0
- package/dist/dashboard/assets/index-js6TJvdr.js.gz +0 -0
- package/dist/dashboard/assets/inline-DUWDSmDV.js.gz +0 -0
- package/dist/dashboard/assets/input-LVjgZq4f.js.gz +0 -0
- package/dist/dashboard/assets/layout-CPfxpsfy.js +0 -1
- package/dist/dashboard/assets/layout-CPfxpsfy.js.gz +0 -0
- package/dist/dashboard/assets/list-row-DupvLfDx.js.gz +0 -0
- package/dist/dashboard/assets/live-C4pEJUqI.js.gz +0 -0
- package/dist/dashboard/assets/model-bans-BRiuufkB.js.gz +0 -0
- package/dist/dashboard/assets/mutation-B4ROwk8L.js.gz +0 -0
- package/dist/dashboard/assets/numbers-YNcHvRY7.js +0 -2
- package/dist/dashboard/assets/numbers-YNcHvRY7.js.gz +0 -0
- package/dist/dashboard/assets/query-keys-gmjzC3RS.js.gz +0 -0
- package/dist/dashboard/assets/routing-C7tBT5o-.js.gz +0 -0
- package/dist/dashboard/assets/select-WUCXorXw.js.gz +0 -0
- package/dist/dashboard/assets/settings-C_zKl1GS.js.gz +0 -0
- package/dist/dashboard/assets/stack-_3O0F8Xn.js.gz +0 -0
- package/dist/dashboard/assets/state-BgTxHcJS.js.gz +0 -0
- package/dist/dashboard/assets/switch-DEjTtOWf.js.gz +0 -0
- package/dist/dashboard/assets/tokens-DDxMbSRL.js +0 -1
- package/dist/dashboard/assets/tokens-DDxMbSRL.js.gz +0 -0
- package/dist/dashboard/assets/use-presence-BSZEBx7U.js +0 -1
- package/dist/dashboard/assets/use-presence-BSZEBx7U.js.gz +0 -0
- package/dist/dashboard/assets/useMutation-BWSC0UAS.js.gz +0 -0
- package/scripts/cli/command/admin.ts +0 -65
- package/scripts/cli/command/doctor.ts +0 -257
- package/scripts/cli/gateway/drain-token.ts +0 -78
- package/scripts/cli/gateway/instance-lock.ts +0 -105
- package/scripts/cli/gateway/stop.ts +0 -51
- package/scripts/cli/support/bun.ts +0 -27
- package/scripts/cli/support/version.ts +0 -37
- /package/dist/dashboard/assets/{app-DCQZRmHY.js → app-BIt446yE.js} +0 -0
- /package/dist/dashboard/assets/{app-DCQZRmHY.js.gz → app-BIt446yE.js.gz} +0 -0
- /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
|
|
476
|
-
//
|
|
477
|
-
// Only a gap at the very start or the very end is
|
|
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
|
+
}
|