Skip to content

Commit 65fb751

Browse files
author
Spell Bot
committed
feat(spell-server): add dotenv loading and env validation to server config
- Extract shared env resolution module from manifest/parser.ts to config/env-resolver.ts (DRY across 3 parsers) - Add 'dotenv true' toggle in server.kdl to load .env from config dir - Add env() syntax support to server.kdl (port, auth, webhook-secret, goal-token) and channels.kdl (bot-token, upload-dir, default-model, default-project, voice API keys) - Pre-validate all env() references at startup: scan all KDL files including autonomy.kdl imports, report all missing vars at once with actionable error showing which file needs each var and .env path - Export parseEnvFile from @oh-my-pi/pi-utils for reuse - Replace channels-parser local resolveEnvValue with shared resolver - All parsers accept optional injected env map (testable, no process.env mutation in tests)
1 parent 3c36c8f commit 65fb751

17 files changed

Lines changed: 1730 additions & 252 deletions
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
#+TITLE: Shared env resolution module and env utilities
2+
#+STATE: DONE
3+
#+SESSION_ID: 14ac1a701fd08506
4+
#+TRANSCRIPT_PATH: [[file:/home/user/.spell/agent/sessions/-code-ora-spell/2026-04-03T11-57-18-847Z_14ac1a701fd08506.jsonl]]
5+
#+CUSTOM_ID: FEAT-260-shared-env-resolution-module-and-env-uti
6+
#+EFFORT: 2h
7+
#+PRIORITY: #B
8+
#+LAYER: lib
9+
10+
* Initial Message
11+
12+
enable reading from the local .env with a setting toggle on the server kdl when i run spell server and the right config is on in the kdl. show at the start which env variables have been got and which ones are missing. fail if any env required by the kdl is missing, explain with a message which config I need to add
13+
14+
* Scope
15+
Extract env resolution logic from =manifest/parser.ts= into a reusable module at =config/env-resolver.ts=. Export =parseEnvFile= from =pi-utils= for .env file loading. Add env reference scanning and validation utilities used by the startup reporter.
16+
17+
* Design
18+
19+
** Shared resolver (=config/env-resolver.ts=)
20+
Moved from =manifest/parser.ts=:
21+
- =EnvReference= type (name, optional, defaultValue, type)
22+
- =parseEnvReference(value: string): EnvReference | null=
23+
- =splitEnvTokens(content: string): string[]=
24+
- =parseDefaultValue(raw: string): string | number | boolean=
25+
- =coerceEnvValue(ref, rawValue, expectedType, pathLabel)=
26+
- =resolveEnvIfNeeded<T>(value, expectedType, pathLabel, env)=
27+
28+
New additions:
29+
- =EnvReferenceInfo= — =EnvReference= + =source: string= (which KDL file)
30+
- =scanEnvReferences(text: string, source: string): EnvReferenceInfo[]= — regex-based =env(...)= extraction from raw KDL text
31+
- =EnvValidationResult= — ={ loaded: EnvReferenceInfo[]; missing: EnvReferenceInfo[]; defaulted: EnvReferenceInfo[] }=
32+
- =validateEnvReferences(refs: EnvReferenceInfo[], env: Record<string, string | undefined>): EnvValidationResult=
33+
- =formatEnvReport(result: EnvValidationResult, configDir: string): string= — human-readable startup table
34+
35+
** Export =parseEnvFile= from pi-utils
36+
=packages/utils/src/env.ts= — change =parseEnvFile= from module-private function to named export. No behavior change.
37+
38+
* Sub-outline (test-first for pure functions)
39+
40+
** define-types
41+
Types: =EnvReferenceInfo=, =EnvValidationResult=. File: =config/env-resolver.ts=.
42+
43+
** write-tests
44+
Test the scanner, validator, and formatter in =test/config/env-resolver.test.ts=:
45+
- Scanner extracts =env(NAME)=, =env(NAME, default=x)=, =env(NAME, optional)=, =env(NAME, type=number)= from KDL text
46+
- Scanner ignores =env(= inside KDL comments (lines starting with =//-=)
47+
- Scanner attaches source label
48+
- Validator classifies refs as loaded / missing / defaulted
49+
- Formatter produces expected table output
50+
- =parseEnvFile= export works (import test)
51+
52+
** extract-resolver
53+
Move functions from =manifest/parser.ts= to =config/env-resolver.ts=. Update =manifest/parser.ts= to import from the new module. Existing manifest tests must still pass.
54+
55+
** implement-scanner-validator
56+
Implement =scanEnvReferences=, =validateEnvReferences=, =formatEnvReport=.
57+
58+
** export-parseEnvFile
59+
Export =parseEnvFile= from =packages/utils/src/env.ts=.
60+
61+
* Acceptance
62+
- =manifest/parser.ts= imports resolution functions from =config/env-resolver.ts= — no duplicated logic
63+
- All existing =test/manifest/env-resolution.test.ts= tests pass unchanged
64+
- New =test/config/env-resolver.test.ts= tests pass
65+
- =parseEnvFile= importable from =@oh-my-pi/pi-utils=
66+
67+
* Files
68+
| File | Change |
69+
|---+---|
70+
| =packages/spell-server/src/config/env-resolver.ts= | new |
71+
| =packages/spell-server/src/manifest/parser.ts= | modify (import from env-resolver) |
72+
| =packages/utils/src/env.ts= | modify (export parseEnvFile) |
73+
| =packages/spell-server/test/config/env-resolver.test.ts= | new |
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
#+TITLE: dotenv toggle and env support in serverkdl
2+
#+STATE: DONE
3+
#+SESSION_ID: 14ac1a701fd08506
4+
#+TRANSCRIPT_PATH: [[file:/home/user/.spell/agent/sessions/-code-ora-spell/2026-04-03T11-57-18-847Z_14ac1a701fd08506.jsonl]]
5+
#+CUSTOM_ID: FEAT-261-dotenv-toggle-and-env-support-in-server
6+
#+EFFORT: 2h
7+
#+PRIORITY: #A
8+
#+LAYER: config
9+
#+DEPENDS: FEAT-260-shared-env-resolution-module-and-env-uti
10+
11+
12+
13+
14+
NOTE [2026-04-03]: Started Wave 2 server parser env() work: adding dotenv parsing, threading env maps through server parser, and adding focused bun tests.
15+
16+
* Initial Message
17+
18+
enable reading from the local .env with a setting toggle on the server kdl when i run spell server and the right config is on in the kdl. show at the start which env variables have been got and which ones are missing. fail if any env required by the kdl is missing, explain with a message which config I need to add
19+
20+
* Scope
21+
Add a =dotenv= top-level node to =server.kdl= that enables loading a =.env= file from the config directory. Extend =server-parser.ts= to support =env()= references in all fields using the shared resolver.
22+
23+
* KDL Syntax
24+
#+begin_example
25+
dotenv true
26+
27+
http {
28+
port 8787
29+
auth {
30+
username "admin"
31+
password "env(HTTP_PASSWORD)"
32+
}
33+
webhook-secret "env(WEBHOOK_SECRET)"
34+
goal-token "incoming" "env(GOAL_TOKEN_INCOMING)"
35+
}
36+
#+end_example
37+
38+
=dotenv true= loads =<configDir>/.env=. =dotenv "./secrets/.env"= loads from a path relative to the config directory.
39+
40+
When =dotenv= is absent, no .env loading occurs (backward compatible). The =env()= references still resolve against =process.env= regardless of dotenv toggle — the toggle only controls whether a .env file is loaded before resolution.
41+
42+
* Design
43+
44+
** =parseDotenvConfig= (separate from server config)
45+
The dotenv setting is loader-level config, not server runtime config. It does NOT belong in =SpellServerConfig=.
46+
47+
#+begin_src typescript
48+
interface DotenvConfig {
49+
enabled: boolean;
50+
/** Relative path to .env file, defaults to ".env" */
51+
path: string;
52+
}
53+
54+
function parseDotenvConfig(kdlText: string): DotenvConfig | null;
55+
#+end_src
56+
57+
** server-parser env support
58+
=parseServerConfig= gains an =env= parameter:
59+
#+begin_src typescript
60+
function parseServerConfig(
61+
kdlText: string,
62+
env?: Record<string, string | undefined>
63+
): SpellServerConfig;
64+
#+end_src
65+
66+
All =expectStringArgument= / =expectNumberArgument= calls delegate to =resolveEnvIfNeeded= from the shared module.
67+
68+
** loader two-pass flow
69+
In =loadConfig()=:
70+
1. Read =server.kdl= text
71+
2. Call =parseDotenvConfig(text)= to extract dotenv setting
72+
3. If enabled, load =.env= from config dir using =parseEnvFile= into =process.env=
73+
4. Build env map from =process.env=
74+
5. Parse =server.kdl= / =channels.kdl= / =autonomy.kdl= with the env map
75+
76+
* Sub-outline
77+
78+
** define-parseDotenvConfig
79+
Add =parseDotenvConfig()= to =server-parser.ts=. Parses the =dotenv= node from KDL text.
80+
81+
** write-tests
82+
In =test/config/server-env.test.ts=:
83+
- =dotenv true= detected by =parseDotenvConfig=
84+
- =dotenv "./secrets/.env"= returns custom path
85+
- No =dotenv= node returns null
86+
- =env(NAME)= resolves in port (type=number), auth.username, auth.password, webhook-secret, goal-token
87+
- Missing required env var throws with variable name in error
88+
- =env(NAME, default=fallback)= works for optional fields
89+
- Literal values still work (backward compat)
90+
91+
** update-server-parser
92+
Refactor =parseServerConfig= to accept env map. Replace local =expectStringArgument= / =expectNumberArgument= with shared =resolveEnvIfNeeded=.
93+
94+
** update-loader
95+
Two-pass server.kdl loading. .env file loading when dotenv is enabled.
96+
97+
* Acceptance
98+
- =parseDotenvConfig= correctly extracts dotenv settings
99+
- =parseServerConfig= resolves =env()= references against provided env map
100+
- Existing server config tests pass with no env parameter (backward compat)
101+
- New env() tests pass
102+
- =loadConfig()= loads .env before parsing when dotenv is enabled
103+
104+
* Files
105+
| File | Change |
106+
|---+---|
107+
| =packages/spell-server/src/config/server-parser.ts= | modify (add env support, parseDotenvConfig) |
108+
| =packages/spell-server/src/config/loader.ts= | modify (two-pass loading, .env loading) |
109+
| =packages/spell-server/test/config/server-env.test.ts= | new |
110+
| =packages/spell-server/test/cli/server-start.test.ts= | modify (add dotenv integration test) |
111+
112+
113+
- Added `DotenvConfig` + `parseDotenvConfig()` to `server-parser.ts`.
114+
- Threaded optional env map through `parseServerConfig()` helpers so port/auth/webhook-secret/goal-token fields resolve shared `env()` references.
115+
- Added focused `server-env.test.ts` coverage for dotenv parsing, env map resolution, missing env failures, defaults, and literal backward compatibility.
116+
117+
118+
Completion report (server parser wave):
119+
- Exported `DotenvConfig` and `parseDotenvConfig()` from `packages/spell-server/src/config/server-parser.ts`.
120+
- `parseServerConfig(kdlText, env?)` now resolves env-backed port/auth/webhook-secret/goal-token values through the shared env resolver.
121+
- Added `packages/spell-server/test/config/server-env.test.ts` covering dotenv parsing, env resolution, missing vars, defaults, and literal backward compatibility.
122+
- Verified with `bun test packages/spell-server/test/config/server-env.test.ts` (pass).
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
#+TITLE: Unify env resolution in channelskdl parser
2+
#+STATE: DONE
3+
#+SESSION_ID: 14ac1a701fd08506
4+
#+TRANSCRIPT_PATH: [[file:/home/user/.spell/agent/sessions/-code-ora-spell/2026-04-03T11-57-18-847Z_14ac1a701fd08506.jsonl]]
5+
#+CUSTOM_ID: FEAT-262-unify-env-resolution-in-channels-kdl-par
6+
#+EFFORT: 1h
7+
#+PRIORITY: #B
8+
#+LAYER: config
9+
#+DEPENDS: FEAT-260-shared-env-resolution-module-and-env-uti
10+
11+
12+
13+
14+
NOTE [2026-04-03]: Started delegated implementation for Wave 2: replace channels parser local env resolution with shared env-resolver, thread injected env through parser/tests, and verify targeted Bun tests.
15+
16+
* Initial Message
17+
18+
enable reading from the local .env with a setting toggle on the server kdl when i run spell server and the right config is on in the kdl. show at the start which env variables have been got and which ones are missing. fail if any env required by the kdl is missing, explain with a message which config I need to add
19+
20+
* Scope
21+
Replace the local =resolveEnvValue()= in =channels-parser.ts= with the shared resolver from =config/env-resolver.ts=. Extend =env()= support to all string-valued fields in channels.kdl, not just voice API keys.
22+
23+
Currently =channels-parser.ts= has its own simpler =resolveEnvValue()= that:
24+
- Only handles =env(NAME)= and =env(NAME, default=fallback)=
25+
- Reads directly from =process.env= (no injected env map)
26+
- Only applied to voice config API keys
27+
28+
After this change:
29+
- Uses shared =resolveEnvIfNeeded= from =env-resolver.ts=
30+
- Accepts an injected =env= map parameter (like manifest parser)
31+
- =env()= works in all string fields: =bot-token=, =upload-dir=, =default-model=, voice API keys, etc.
32+
- Full =env()= syntax including =type== and =optional= flags
33+
34+
* Sub-outline
35+
36+
** write-tests
37+
In =test/config/channels-parser.test.ts= (extend existing):
38+
- =env(NAME)= resolves in =bot-token=
39+
- =env(NAME)= resolves in =upload-dir=
40+
- =env(NAME)= resolves in =default-model=
41+
- =env(NAME)= resolves in voice API keys (migration of existing voice-config tests)
42+
- Missing required env var throws
43+
- Literal values still work (backward compat)
44+
45+
** update-parser
46+
- Add =env= parameter to =parseChannelsConfig= signature
47+
- Delete local =resolveEnvValue()= function
48+
- Replace all calls with shared =resolveEnvIfNeeded= or a thin string-specific wrapper
49+
- Thread env through to voice config parsing
50+
51+
** update-loader
52+
Pass env map to =parseChannelsConfig()= from =loader.ts=.
53+
54+
** update-existing-tests
55+
=test/config/voice-config-parser.test.ts= — update to use injected env instead of =process.env= manipulation.
56+
57+
* Acceptance
58+
- Local =resolveEnvValue= removed from =channels-parser.ts=
59+
- =parseChannelsConfig= accepts and uses injected env map
60+
- All existing =test/config/channels-parser.test.ts= tests pass
61+
- All existing =test/config/voice-config-parser.test.ts= tests pass
62+
- New env() tests for bot-token, upload-dir, default-model pass
63+
64+
* Files
65+
| File | Change |
66+
|---+---|
67+
| =packages/spell-server/src/config/channels-parser.ts= | modify (use shared resolver, add env param) |
68+
| =packages/spell-server/src/config/loader.ts= | modify (pass env to channels parser) |
69+
| =packages/spell-server/test/config/channels-parser.test.ts= | modify |
70+
| =packages/spell-server/test/config/voice-config-parser.test.ts= | modify |
71+
72+
NOTE [2026-04-03]: Implemented channels-parser env injection: parseChannelsConfig now accepts optional env map, string fields bot-token/upload-dir/default-model/default-project resolve via shared resolveEnvString, voice API keys use shared resolver, and channels-specific local resolveEnvValue has been removed. Updated channels/voice parser tests to use injected env maps and added parser coverage for env-backed telegram string fields.
73+
74+
75+
- Completion report (2026-04-03): Replaced channels-parser local env() resolver with shared env-resolver usage, threaded optional env maps through parseChannelsConfig/voice parsing, added telegram string-field env coverage in channels-parser tests, and migrated voice parser tests away from process.env mutation to explicit injected env maps.
76+
- Verification: `bun test packages/spell-server/test/config/channels-parser.test.ts && bun test packages/spell-server/test/config/voice-config-parser.test.ts`
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
#+TITLE: Startup env validation and reporting
2+
#+STATE: DONE
3+
#+SESSION_ID: 14ac1a701fd08506
4+
#+TRANSCRIPT_PATH: [[file:/home/user/.spell/agent/sessions/-code-ora-spell/2026-04-03T11-57-18-847Z_14ac1a701fd08506.jsonl]]
5+
#+CUSTOM_ID: FEAT-263-startup-env-validation-and-reporting
6+
#+EFFORT: 2h
7+
#+PRIORITY: #A
8+
#+LAYER: config
9+
#+DEPENDS: FEAT-260-shared-env-resolution-module-and-env-uti FEAT-261-dotenv-toggle-and-env-support-in-server FEAT-262-unify-env-resolution-in-channels-kdl-par
10+
11+
* Initial Message
12+
13+
enable reading from the local .env with a setting toggle on the server kdl when i run spell server and the right config is on in the kdl. show at the start which env variables have been got and which ones are missing. fail if any env required by the kdl is missing, explain with a message which config I need to add
14+
15+
* Scope
16+
After loading =.env= (if dotenv enabled) and before fully parsing configs, scan all KDL files for =env()= references, validate against =process.env=, print a formatted status report, and fail with an actionable error listing missing variables and the =.env= path to add them to.
17+
18+
* Output format
19+
20+
When dotenv is enabled and some vars are missing:
21+
#+begin_example
22+
spell-server: loaded .env from /home/user/project/.spell/.env (3 variables)
23+
spell-server: environment check:
24+
HTTP_PASSWORD loaded
25+
WEBHOOK_SECRET loaded
26+
DEEPGRAM_API_KEY MISSING (required by channels.kdl)
27+
MAX_COST loaded (default: 10)
28+
29+
Error: missing required environment variables:
30+
31+
DEEPGRAM_API_KEY required by channels.kdl
32+
33+
Add to /home/user/project/.spell/.env:
34+
35+
DEEPGRAM_API_KEY=
36+
#+end_example
37+
38+
When all vars are present:
39+
#+begin_example
40+
spell-server: loaded .env from /home/user/project/.spell/.env (3 variables)
41+
spell-server: all 4 env references resolved
42+
#+end_example
43+
44+
When dotenv is disabled but env() references exist and all resolve from process.env:
45+
No output (silent success, backward compatible).
46+
47+
When dotenv is disabled and env() references are missing:
48+
Normal parser errors (unchanged behavior — individual parser throws on first missing var).
49+
50+
The pre-validation scan runs only when dotenv is enabled. When disabled, the existing parser-level validation handles missing vars as before.
51+
52+
* Design
53+
54+
** Scan flow (in =loader.ts=)
55+
After dotenv loading, before full config parsing:
56+
1. Read all three KDL file texts (server.kdl, channels.kdl, autonomy.kdl)
57+
2. For =autonomy.kdl=, regex-extract =import "<path>"= directives and read those files too
58+
3. Call =scanEnvReferences(text, sourceLabel)= on each
59+
4. Deduplicate by env var name (keep first source)
60+
5. Call =validateEnvReferences(refs, process.env)=
61+
6. If =result.missing.length > 0=: print report via =console.error=, throw aggregated error
62+
7. If all present: =logger.debug(...)=
63+
64+
** Import following
65+
Simple regex: =/^import\s+"([^"]+)"/gm= on autonomy.kdl text. Resolve relative to config dir. No recursive following of nested imports — the manifest parser catches those at parse time.
66+
67+
** Error message
68+
The thrown error includes:
69+
- Count of missing vars
70+
- Each missing var name and which KDL file references it
71+
- The .env file path where the user should add them
72+
- Skeleton entries (=VAR_NAME==) ready to copy-paste
73+
74+
* Sub-outline
75+
76+
** write-tests
77+
In =test/config/env-startup-validation.test.ts=:
78+
- Full config dir with dotenv enabled + all vars present: loads successfully, no error
79+
- Full config dir with dotenv enabled + missing required var: throws with var name and source file
80+
- Full config dir with dotenv enabled + var with default: loads successfully (default used)
81+
- Multiple missing vars: error message lists all of them, not just the first
82+
- Manifest with =import= directive: imported file's env() refs are also scanned
83+
- No dotenv node: no .env loading, env() resolves from process.env directly
84+
- .env file missing when dotenv enabled: warns but continues (the .env file itself is optional — it's the env vars that are required)
85+
86+
** implement-scan-step
87+
In =loader.ts=, add the scan flow between dotenv loading and config parsing.
88+
89+
** implement-report
90+
Wire =formatEnvReport= and error throwing in =loader.ts=.
91+
92+
* Acceptance
93+
- =spell server= with dotenv enabled and all vars present: server starts, log shows env loaded count
94+
- =spell server= with dotenv enabled and missing required var: server fails with clear error listing all missing vars, their source files, and .env path
95+
- =spell server= without dotenv: no behavioral change (backward compat)
96+
- All missing vars shown in one error (not one-at-a-time failures)
97+
98+
* Files
99+
| File | Change |
100+
|---+---|
101+
| =packages/spell-server/src/config/loader.ts= | modify (scan + validate + report) |
102+
| =packages/spell-server/test/config/env-startup-validation.test.ts= | new |

0 commit comments

Comments
 (0)