typeclaw 0.1.1 → 0.1.3

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 (74) hide show
  1. package/README.md +16 -12
  2. package/auth.schema.json +238 -7
  3. package/package.json +1 -1
  4. package/secrets.schema.json +238 -7
  5. package/src/agent/auth.ts +19 -38
  6. package/src/agent/doctor.ts +173 -0
  7. package/src/agent/subagents.ts +24 -2
  8. package/src/agent/tools/channel-fetch-attachment.ts +6 -0
  9. package/src/agent/tools/channel-history.ts +10 -1
  10. package/src/agent/tools/channel-log.ts +32 -0
  11. package/src/agent/tools/channel-reply.ts +18 -1
  12. package/src/agent/tools/channel-send.ts +13 -1
  13. package/src/bundled-plugins/backup/README.md +81 -0
  14. package/src/bundled-plugins/backup/index.ts +209 -0
  15. package/src/bundled-plugins/backup/runner.ts +231 -0
  16. package/src/bundled-plugins/backup/subagents.ts +200 -0
  17. package/src/bundled-plugins/memory/index.ts +42 -1
  18. package/src/bundled-plugins/tool-result-cap/README.md +67 -0
  19. package/src/bundled-plugins/tool-result-cap/cap-result.ts +56 -0
  20. package/src/bundled-plugins/tool-result-cap/index.ts +51 -0
  21. package/src/channels/adapters/kakaotalk.ts +25 -16
  22. package/src/channels/manager.ts +47 -38
  23. package/src/channels/router.ts +29 -0
  24. package/src/cli/channel.ts +3 -3
  25. package/src/cli/compose.ts +92 -1
  26. package/src/cli/doctor.ts +100 -0
  27. package/src/cli/index.ts +4 -0
  28. package/src/cli/init.ts +2 -1
  29. package/src/cli/ui.ts +11 -0
  30. package/src/compose/doctor.ts +141 -0
  31. package/src/compose/index.ts +8 -0
  32. package/src/compose/logs.ts +32 -19
  33. package/src/config/config.ts +31 -0
  34. package/src/container/log-colors.ts +75 -0
  35. package/src/container/log-timestamps.ts +84 -0
  36. package/src/container/logs.ts +71 -5
  37. package/src/container/start.ts +113 -9
  38. package/src/cron/consumer.ts +29 -7
  39. package/src/doctor/checks.ts +426 -0
  40. package/src/doctor/commit.ts +71 -0
  41. package/src/doctor/index.ts +287 -0
  42. package/src/doctor/plugin-bridge.ts +147 -0
  43. package/src/doctor/report.ts +142 -0
  44. package/src/doctor/types.ts +87 -0
  45. package/src/hostd/daemon.ts +28 -3
  46. package/src/hostd/protocol.ts +7 -0
  47. package/src/init/auto-upgrade.ts +368 -0
  48. package/src/init/cli-version.ts +81 -0
  49. package/src/init/dockerfile.ts +234 -25
  50. package/src/init/index.ts +141 -87
  51. package/src/init/kakaotalk-auth.ts +9 -3
  52. package/src/init/run-bun-install.ts +34 -0
  53. package/src/plugin/hooks.ts +32 -0
  54. package/src/plugin/index.ts +7 -0
  55. package/src/plugin/manager.ts +2 -0
  56. package/src/plugin/registry.ts +32 -3
  57. package/src/plugin/types.ts +65 -0
  58. package/src/run/bundled-plugins.ts +15 -0
  59. package/src/run/index.ts +19 -5
  60. package/src/secrets/defaults.ts +67 -0
  61. package/src/secrets/hydrate.ts +99 -0
  62. package/src/secrets/index.ts +6 -12
  63. package/src/secrets/kakao-store.ts +129 -0
  64. package/src/secrets/migrate-kakaotalk.ts +82 -0
  65. package/src/secrets/migrate.ts +5 -4
  66. package/src/secrets/resolve.ts +57 -0
  67. package/src/secrets/schema.ts +162 -42
  68. package/src/secrets/storage.ts +253 -47
  69. package/src/server/index.ts +103 -5
  70. package/src/shared/index.ts +3 -0
  71. package/src/shared/protocol.ts +22 -0
  72. package/src/skills/typeclaw-config/SKILL.md +48 -9
  73. package/typeclaw.schema.json +84 -0
  74. package/src/secrets/env.ts +0 -43
@@ -6,6 +6,7 @@ import {
6
6
  type CreateSessionOptions,
7
7
  type CreateSessionResult,
8
8
  } from '@/agent'
9
+ import { runPluginDoctorChecks, runPluginDoctorFix } from '@/agent/doctor'
9
10
  import type { SessionOrigin } from '@/agent/session-origin'
10
11
  import type { ChannelRouter } from '@/channels/router'
11
12
  import type { HookBus } from '@/plugin'
@@ -159,7 +160,7 @@ export function createServer({
159
160
 
160
161
  if (stream) {
161
162
  state.unsubPrompts = stream.subscribe({ target: { kind: 'session', sessionId: sessionFileId } }, (msg) =>
162
- enqueuePrompt(ws, state, msg),
163
+ enqueuePrompt(ws, state, msg, agentDir),
163
164
  )
164
165
 
165
166
  state.unsubBroadcast = stream.subscribe({ target: { kind: 'broadcast' } }, (msg) => {
@@ -190,6 +191,16 @@ export function createServer({
190
191
  return
191
192
  }
192
193
 
194
+ if (msg.type === 'doctor') {
195
+ await handleDoctor(ws, msg.requestId, pluginRuntime, agentDir)
196
+ return
197
+ }
198
+
199
+ if (msg.type === 'doctor_fix') {
200
+ await handleDoctorFix(ws, msg.requestId, msg.checkId, pluginRuntime, agentDir)
201
+ return
202
+ }
203
+
193
204
  if (msg.type === 'abort') {
194
205
  if (!state) return
195
206
  await state.session.abort()
@@ -215,13 +226,27 @@ export function createServer({
215
226
  return
216
227
  }
217
228
  send(ws, { type: 'prompt_started', messageId: `local-${crypto.randomUUID()}`, text: msg.text })
229
+ const fallbackHooks = state.runtimeSnapshot?.hooks
230
+ if (fallbackHooks !== undefined && agentDir !== undefined) {
231
+ await fallbackHooks.runSessionTurnStart({
232
+ sessionId: state.sessionFileId,
233
+ agentDir,
234
+ origin: state.origin,
235
+ })
236
+ }
218
237
  try {
219
238
  await state.session.prompt(msg.text)
220
239
  send(ws, { type: 'done' })
221
240
  } catch (err) {
222
241
  send(ws, { type: 'error', message: err instanceof Error ? err.message : String(err) })
223
242
  }
224
- const fallbackHooks = state.runtimeSnapshot?.hooks
243
+ if (fallbackHooks !== undefined && agentDir !== undefined) {
244
+ await fallbackHooks.runSessionTurnEnd({
245
+ sessionId: state.sessionFileId,
246
+ agentDir,
247
+ origin: state.origin,
248
+ })
249
+ }
225
250
  if (fallbackHooks !== undefined) {
226
251
  await fallbackHooks.runSessionIdle({
227
252
  sessionId: state.sessionFileId,
@@ -323,7 +348,7 @@ function forwardAssistantError(ws: Ws, message: unknown): void {
323
348
  send(ws, { type: 'error', message: text })
324
349
  }
325
350
 
326
- function enqueuePrompt(ws: Ws, state: SessionState, msg: StreamMessage): void {
351
+ function enqueuePrompt(ws: Ws, state: SessionState, msg: StreamMessage, agentDir: string | undefined): void {
327
352
  const payload = msg.payload as { kind?: string; text?: string; delivery?: PromptDelivery }
328
353
  if (payload?.kind !== 'prompt' || typeof payload.text !== 'string') return
329
354
  const delivery: PromptDelivery = payload.delivery ?? 'queue'
@@ -339,7 +364,7 @@ function enqueuePrompt(ws: Ws, state: SessionState, msg: StreamMessage): void {
339
364
  ts: msg.ts,
340
365
  })
341
366
  pushQueueState(ws, state)
342
- void drain(ws, state)
367
+ void drain(ws, state, agentDir)
343
368
  }
344
369
 
345
370
  // `session.idle` semantically means "the agent finished a prompt and is now
@@ -360,10 +385,26 @@ function makeIdleHookCaller(state: SessionState): () => Promise<void> {
360
385
  }
361
386
  }
362
387
 
363
- async function drain(ws: Ws, state: SessionState): Promise<void> {
388
+ function makeTurnHookCallers(
389
+ state: SessionState,
390
+ agentDir: string | undefined,
391
+ ): { fireTurnStart: () => Promise<void>; fireTurnEnd: () => Promise<void> } {
392
+ const hooks: HookBus | undefined = state.runtimeSnapshot?.hooks
393
+ if (hooks === undefined || agentDir === undefined) {
394
+ return { fireTurnStart: async () => {}, fireTurnEnd: async () => {} }
395
+ }
396
+ const event = { sessionId: state.sessionFileId, agentDir, origin: state.origin }
397
+ return {
398
+ fireTurnStart: () => hooks.runSessionTurnStart(event),
399
+ fireTurnEnd: () => hooks.runSessionTurnEnd(event),
400
+ }
401
+ }
402
+
403
+ async function drain(ws: Ws, state: SessionState, agentDir: string | undefined): Promise<void> {
364
404
  if (state.draining) return
365
405
  state.draining = true
366
406
  const fireIdle = makeIdleHookCaller(state)
407
+ const { fireTurnStart, fireTurnEnd } = makeTurnHookCallers(state, agentDir)
367
408
  try {
368
409
  while (state.drainQueue.length > 0) {
369
410
  const item = state.drainQueue.shift()
@@ -371,12 +412,14 @@ async function drain(ws: Ws, state: SessionState): Promise<void> {
371
412
  pushQueueState(ws, state)
372
413
  send(ws, { type: 'prompt_started', messageId: item.streamMessageId, text: item.text })
373
414
 
415
+ await fireTurnStart()
374
416
  try {
375
417
  await state.session.prompt(item.text)
376
418
  send(ws, { type: 'done' })
377
419
  } catch (err) {
378
420
  send(ws, { type: 'error', message: err instanceof Error ? err.message : String(err) })
379
421
  }
422
+ await fireTurnEnd()
380
423
  await fireIdle()
381
424
  }
382
425
  } finally {
@@ -393,6 +436,61 @@ function pushQueueState(ws: Ws, state: SessionState): void {
393
436
  send(ws, { type: 'queue_state', pending })
394
437
  }
395
438
 
439
+ async function handleDoctor(
440
+ ws: Ws,
441
+ requestId: string,
442
+ pluginRuntime: PluginRuntime | undefined,
443
+ agentDir: string | undefined,
444
+ ): Promise<void> {
445
+ if (pluginRuntime === undefined || agentDir === undefined) {
446
+ send(ws, { type: 'doctor_result', requestId, checks: [] })
447
+ return
448
+ }
449
+ const snapshot = pluginRuntime.get()
450
+ if (snapshot === undefined) {
451
+ send(ws, { type: 'doctor_result', requestId, checks: [] })
452
+ return
453
+ }
454
+ try {
455
+ const checks = await runPluginDoctorChecks({ registry: snapshot.registry, agentDir })
456
+ send(ws, { type: 'doctor_result', requestId, checks })
457
+ } catch (err) {
458
+ send(ws, { type: 'error', message: err instanceof Error ? err.message : String(err) })
459
+ }
460
+ }
461
+
462
+ async function handleDoctorFix(
463
+ ws: Ws,
464
+ requestId: string,
465
+ checkId: string,
466
+ pluginRuntime: PluginRuntime | undefined,
467
+ agentDir: string | undefined,
468
+ ): Promise<void> {
469
+ if (pluginRuntime === undefined || agentDir === undefined) {
470
+ send(ws, {
471
+ type: 'doctor_fix_result',
472
+ requestId,
473
+ result: { ok: false, checkId, error: 'plugin runtime not configured' },
474
+ })
475
+ return
476
+ }
477
+ const snapshot = pluginRuntime.get()
478
+ if (snapshot === undefined) {
479
+ send(ws, {
480
+ type: 'doctor_fix_result',
481
+ requestId,
482
+ result: { ok: false, checkId, error: 'plugin runtime not configured' },
483
+ })
484
+ return
485
+ }
486
+ const outcome = await runPluginDoctorFix({ registry: snapshot.registry, agentDir, checkId })
487
+ const result =
488
+ outcome.ok === true
489
+ ? { ok: true as const, checkId, summary: outcome.summary, changedPaths: outcome.changedPaths }
490
+ : { ok: false as const, checkId, error: outcome.error }
491
+ send(ws, { type: 'doctor_fix_result', requestId, result })
492
+ }
493
+
396
494
  async function handleReload(
397
495
  ws: Ws,
398
496
  reloadAll: ReloadAllFn | undefined,
@@ -1,5 +1,8 @@
1
1
  export {
2
2
  type ClientMessage,
3
+ type DoctorCheckPayload,
4
+ type DoctorFixPayload,
5
+ type DoctorRequestId,
3
6
  type PromptDelivery,
4
7
  type QueueStateItem,
5
8
  type ReloadResultPayload,
@@ -4,11 +4,31 @@ export type ReloadResultPayload =
4
4
 
5
5
  export type PromptDelivery = 'queue' | 'steer' | 'interrupt'
6
6
 
7
+ export type DoctorRequestId = string
8
+
9
+ export type DoctorCheckPayload = {
10
+ id: string
11
+ pluginName: string
12
+ checkName: string
13
+ description: string
14
+ category: string
15
+ status: 'ok' | 'warning' | 'error'
16
+ message: string
17
+ details?: string[]
18
+ fix?: { description: string; hasApply: boolean }
19
+ }
20
+
21
+ export type DoctorFixPayload =
22
+ | { ok: true; checkId: string; summary: string; changedPaths: string[] }
23
+ | { ok: false; checkId: string; error: string }
24
+
7
25
  export type ClientMessage =
8
26
  | { type: 'prompt'; text: string; delivery?: PromptDelivery }
9
27
  | { type: 'reload'; scope?: string }
10
28
  | { type: 'abort' }
11
29
  | { type: 'queue_cancel'; messageId: string }
30
+ | { type: 'doctor'; requestId: DoctorRequestId }
31
+ | { type: 'doctor_fix'; requestId: DoctorRequestId; checkId: string }
12
32
 
13
33
  export type QueueStateItem = { id: string; text: string; ts: number }
14
34
 
@@ -23,3 +43,5 @@ export type ServerMessage =
23
43
  | { type: 'notification'; payload: unknown; replyTo?: string; meta?: Record<string, string> }
24
44
  | { type: 'queue_state'; pending: QueueStateItem[] }
25
45
  | { type: 'prompt_started'; messageId: string; text: string }
46
+ | { type: 'doctor_result'; requestId: DoctorRequestId; checks: DoctorCheckPayload[] }
47
+ | { type: 'doctor_fix_result'; requestId: DoctorRequestId; result: DoctorFixPayload }
@@ -403,7 +403,7 @@ RUN apt-get install ... <baseline + enabled toggle packages> ← toggles fan o
403
403
  ENV NODE_ENV=production
404
404
  # Custom lines from typeclaw.json#dockerfile.append. ← only emitted when append is non-empty
405
405
  <your appended lines>
406
- ENTRYPOINT ["bun", "run", "typeclaw"]
406
+ ENTRYPOINT ["/usr/local/bin/typeclaw-entrypoint"]
407
407
  CMD ["run"]
408
408
  ```
409
409
 
@@ -539,17 +539,56 @@ Do **not** edit `typeclaw.json` to a model the registry doesn't know, even if th
539
539
 
540
540
  `typeclaw.json` does **not** hold API keys or OAuth tokens. Credentials live in two gitignored files:
541
541
 
542
- - **`./.env`** (API key providers): the env var depends on which provider's model you've selected.
543
- - `OPENAI_API_KEY` — required for any `openai/...` model.
544
- - `FIREWORKS_API_KEY` — required for any `fireworks/...` model.
545
- - **`./secrets.json`** (OAuth providers): structured JSON file managed by `pi-coding-agent`'s `AuthStorage`, wrapped by `SecretsBackend`. Contains refresh + access tokens. The container refreshes tokens on its own with file locking; the host writes once at `typeclaw init` time when the user picks "OAuth (browser login)". (Pre-rename agent folders may carry the file as `auth.json`; it is migrated to `secrets.json` on the next agent boot.)
546
- - `openai-codex/...` models — credentials persisted under the `llm` slice as `{ "llm": { "openai-codex": { "type": "oauth", ... } } }`.
542
+ - **`./.env`** (any environment variable, including API keys): plain `KEY=value` lines, loaded by Docker via `--env-file` at container start. The canonical env-var names per provider:
543
+ - `OPENAI_API_KEY` — for any `openai/...` model.
544
+ - `FIREWORKS_API_KEY` — for any `fireworks/...` model.
545
+ - **`./secrets.json`** (structured store): a `v2` envelope managed by `SecretsBackend` (wraps `pi-coding-agent`'s `AuthStorage`). Two top-level slices:
546
+ - `providers.*`per-provider credentials. API-key providers store `{ type: 'api_key', key: <Secret> }`. OAuth providers store the `pi-coding-agent` token blob `{ type: 'oauth', access_token, refresh_token, expires_at, ... }`. The container auto-refreshes OAuth tokens with file locking; api-key writes only happen on explicit user-driven rotation.
547
+ - `channels.*` — per-adapter credentials, with named fields per adapter:
548
+ - `discord-bot: { token: <Secret> }`
549
+ - `slack-bot: { botToken: <Secret>, appToken: <Secret> }`
550
+ - `telegram-bot: { token: <Secret> }`
547
551
 
548
- If a user wants to switch from API key to OAuth (or vice versa) for a provider that supports both, the easiest path is to delete the relevant entry from `.env` / `secrets.json` and re-run `typeclaw init` from inside the agent folder — it'll prompt for the auth method again.
552
+ (Pre-v2 agent folders carry the older `llm` slice and channel-env-var-keyed shape; they are upgraded transparently on first read. Pre-rename folders may even carry the file as `auth.json`; it is renamed to `secrets.json` on the next boot.)
549
553
 
550
- If the user wants to rotate or change the key, edit `.env`, not `typeclaw.json`. After editing `.env`, the same restart rule applies: `typeclaw restart` on the host stage.
554
+ ### The `Secret` shape and env-wins resolution
551
555
 
552
- Never echo, log, or commit values from `.env`. `.env` is gitignored by default keep it that way.
556
+ Every secret-bearing field in `secrets.json` is a **`Secret`**: either a plain string or an object `{ value?, env? }`.
557
+
558
+ ```json
559
+ {
560
+ "version": 2,
561
+ "providers": {
562
+ "fireworks": { "type": "api_key", "key": "fw_xxx" },
563
+ "openai-codex": { "type": "oauth", "access_token": "...", "refresh_token": "...", "expires_at": 99 }
564
+ },
565
+ "channels": {
566
+ "slack-bot": {
567
+ "botToken": "xoxb-...",
568
+ "appToken": { "value": "xapp-...", "env": "MY_CUSTOM_SLACK_APP_TOKEN" }
569
+ }
570
+ }
571
+ }
572
+ ```
573
+
574
+ **Resolution at boot, in order:**
575
+
576
+ 1. `process.env[secret.env]` — explicit binding wins (the `env` field on the object form).
577
+ 2. `process.env[<canonical env name>]` — canonical-env fallback (`SLACK_BOT_TOKEN`, `FIREWORKS_API_KEY`, etc.).
578
+ 3. `secret.value` — the on-disk value.
579
+ 4. Otherwise the field is treated as missing.
580
+
581
+ **Env wins, the file is never auto-mutated.** When the env var is set, that value is used in-memory via `setRuntimeApiKey` (api-keys) or `process.env` injection (channels) — `secrets.json` is **not** rewritten to capture the env value. The user's file stays user-owned.
582
+
583
+ **Custom env-var binding** — the optional `env` field on the object form lets the user route a credential through an env var of their choosing (e.g., a CI system that exposes `MY_PROD_SLACK_TOKEN` instead of `SLACK_BOT_TOKEN`).
584
+
585
+ ### Switching credentials
586
+
587
+ If a user wants to switch from API key to OAuth (or vice versa) for a provider that supports both, the easiest path is to delete the relevant entry from `.env` / `secrets.json#providers` and re-run `typeclaw init` from inside the agent folder — it'll prompt for the auth method again.
588
+
589
+ If the user wants to rotate an api-key, edit either `.env` (env-wins picks it up immediately) or `secrets.json#providers.<provider>.key` (rewrite the `value` field, or remove the entry if the env var should take over). After either, `typeclaw restart` on the host stage.
590
+
591
+ Never echo, log, or commit values from `.env` or `secrets.json`. Both are gitignored by default — keep them that way.
553
592
 
554
593
  ## Editing `typeclaw.json` safely
555
594
 
@@ -706,6 +706,18 @@
706
706
  "allow"
707
707
  ]
708
708
  },
709
+ "network": {
710
+ "default": {
711
+ "blockInternal": true
712
+ },
713
+ "type": "object",
714
+ "properties": {
715
+ "blockInternal": {
716
+ "default": true,
717
+ "type": "boolean"
718
+ }
719
+ }
720
+ },
709
721
  "dockerfile": {
710
722
  "default": {
711
723
  "ffmpeg": false,
@@ -780,6 +792,40 @@
780
792
  }
781
793
  }
782
794
  },
795
+ "tool-result-cap": {
796
+ "default": {
797
+ "enabled": true,
798
+ "imageMaxBytes": 262144,
799
+ "textMaxBytes": 65536,
800
+ "exemptTools": []
801
+ },
802
+ "type": "object",
803
+ "properties": {
804
+ "enabled": {
805
+ "default": true,
806
+ "type": "boolean"
807
+ },
808
+ "imageMaxBytes": {
809
+ "default": 262144,
810
+ "type": "integer",
811
+ "minimum": 1024,
812
+ "maximum": 9007199254740991
813
+ },
814
+ "textMaxBytes": {
815
+ "default": 65536,
816
+ "type": "integer",
817
+ "minimum": 1024,
818
+ "maximum": 9007199254740991
819
+ },
820
+ "exemptTools": {
821
+ "default": [],
822
+ "type": "array",
823
+ "items": {
824
+ "type": "string"
825
+ }
826
+ }
827
+ }
828
+ },
783
829
  "memory": {
784
830
  "default": {
785
831
  "idleMs": 10000,
@@ -816,6 +862,44 @@
816
862
  }
817
863
  }
818
864
  }
865
+ },
866
+ "backup": {
867
+ "default": {
868
+ "enabled": true,
869
+ "idleMs": 30000,
870
+ "pushToOrigin": true,
871
+ "commitTimeoutMs": 30000,
872
+ "networkTimeoutMs": 60000
873
+ },
874
+ "type": "object",
875
+ "properties": {
876
+ "enabled": {
877
+ "default": true,
878
+ "type": "boolean"
879
+ },
880
+ "idleMs": {
881
+ "default": 30000,
882
+ "type": "integer",
883
+ "minimum": 1000,
884
+ "maximum": 9007199254740991
885
+ },
886
+ "pushToOrigin": {
887
+ "default": true,
888
+ "type": "boolean"
889
+ },
890
+ "commitTimeoutMs": {
891
+ "default": 30000,
892
+ "type": "integer",
893
+ "minimum": 1,
894
+ "maximum": 9007199254740991
895
+ },
896
+ "networkTimeoutMs": {
897
+ "default": 60000,
898
+ "type": "integer",
899
+ "minimum": 1,
900
+ "maximum": 9007199254740991
901
+ }
902
+ }
819
903
  }
820
904
  },
821
905
  "additionalProperties": {}
@@ -1,43 +0,0 @@
1
- import { readFileSync, writeFileSync } from 'node:fs'
2
-
3
- // No-op when the file is missing or the key is absent: the caller has
4
- // already persisted to `secrets.json` and just wants `.env` to stop being a
5
- // second source of truth. Parsing matches `parseEnvKeys` in
6
- // `src/init/index.ts` — line-based, trim, skip blanks/comments, split on the
7
- // first `=`. Duplicate assignments to the same key are all removed because
8
- // dotenv resolves "last wins" so every duplicate carries the value we just
9
- // promoted.
10
- export function stripEnvKey(path: string, key: string): void {
11
- let original: string
12
- try {
13
- original = readFileSync(path, 'utf8')
14
- } catch (error) {
15
- if ((error as NodeJS.ErrnoException).code === 'ENOENT') return
16
- throw error
17
- }
18
-
19
- const next = removeKeyFromEnvText(original, key)
20
- if (next === original) return
21
- writeFileSync(path, next)
22
- }
23
-
24
- export function removeKeyFromEnvText(content: string, key: string): string {
25
- const lines = content.split('\n')
26
- const kept: string[] = []
27
- for (const line of lines) {
28
- const trimmed = line.trim()
29
- if (trimmed === '' || trimmed.startsWith('#')) {
30
- kept.push(line)
31
- continue
32
- }
33
- const eq = trimmed.indexOf('=')
34
- if (eq <= 0) {
35
- kept.push(line)
36
- continue
37
- }
38
- const lineKey = trimmed.slice(0, eq).trim()
39
- if (lineKey === key) continue
40
- kept.push(line)
41
- }
42
- return kept.join('\n')
43
- }