diff --git a/.gitignore b/.gitignore index cc9b2ff..9193168 100644 --- a/.gitignore +++ b/.gitignore @@ -54,3 +54,4 @@ skills-lock.json # Local experiment scratch (cloned tools / benchmark output, not part of the project) /aider/ /tmp.benchmarks/ +modlane.yaml diff --git a/openspec/changes/P9-env-support/proposal.md b/openspec/changes/P9-env-support/proposal.md new file mode 100644 index 0000000..d21eb6d --- /dev/null +++ b/openspec/changes/P9-env-support/proposal.md @@ -0,0 +1,14 @@ +# P9 — Local .env File Support + +## Why +Instead of forcing developers to manually `export` their API keys in their console session before starting Modlane, we want to allow storing them in a local, gitignored `.env` file that loads automatically at startup. + +## What changes +- `src/cli.ts`: Injected environment variable loading on startup. Uses `process.loadEnvFile()` natively (Node 20.12+) or a custom line-by-line fallback parser for older Node versions. +- `src/env.test.ts` [NEW]: Unit tests covering the fallback parser logic (quotes, spacing, comments). + +## Impact +Improves developer UX significantly, removing the need for console environment setup while keeping keys safely gitignored. + +## Status +Done — implemented, fully tested, and compiling. diff --git a/openspec/changes/P9-env-support/tasks.md b/openspec/changes/P9-env-support/tasks.md new file mode 100644 index 0000000..6fa54bc --- /dev/null +++ b/openspec/changes/P9-env-support/tasks.md @@ -0,0 +1,7 @@ +# P9 — Tasks + +- [x] Verify `.env` is ignored in `.gitignore`. +- [x] Add `.env` file loading logic at the startup of `src/cli.ts`. +- [x] Implement fallback parser to support Node.js versions < 20.12. +- [x] Add unit tests in `src/env.test.ts` to test parser accuracy. +- [x] Verify project builds and passes Vitest checks. diff --git a/src/cli.ts b/src/cli.ts index 9483257..d2093fa 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,7 +1,11 @@ #!/usr/bin/env node import { ConfigError, loadConfig } from "./config.js"; +import { loadDotEnv } from "./env.js"; import { startGateway } from "./server.js"; +// Load local environment variables from .env before anything reads process.env. +loadDotEnv(); + const VERSION = "0.0.1"; function usage(): void { diff --git a/src/env.test.ts b/src/env.test.ts new file mode 100644 index 0000000..a5b1ac1 --- /dev/null +++ b/src/env.test.ts @@ -0,0 +1,33 @@ +import { expect, test } from "vitest"; +import { parseEnv } from "./env.js"; + +test("parses standard key-value pairs", () => { + const res = parseEnv("TEST_KEY=my-value"); + expect(res.TEST_KEY).toBe("my-value"); +}); + +test("ignores comments and empty lines", () => { + const res = parseEnv(` + # This is a comment + TEST_KEY=my-value + + # Another comment + ANOTHER_KEY=value2 + `); + expect(res.TEST_KEY).toBe("my-value"); + expect(res.ANOTHER_KEY).toBe("value2"); +}); + +test("handles single and double quotes", () => { + const res = parseEnv(` + KEY1="double-quoted-value" + KEY2='single-quoted-value' + `); + expect(res.KEY1).toBe("double-quoted-value"); + expect(res.KEY2).toBe("single-quoted-value"); +}); + +test("keeps '=' inside values (e.g. base64 keys)", () => { + const res = parseEnv("TOKEN=abc==def"); + expect(res.TOKEN).toBe("abc==def"); +}); diff --git a/src/env.ts b/src/env.ts new file mode 100644 index 0000000..8182e27 --- /dev/null +++ b/src/env.ts @@ -0,0 +1,41 @@ +import { existsSync, readFileSync } from "node:fs"; + +/** + * Parse .env content into key/value pairs. Zero-dependency fallback for the + * native `process.loadEnvFile` (Node < 20.12). Supports comments, blank lines, + * and single/double-quoted values. Kept intentionally small — not a full + * dotenv (no `export` prefix, no inline comments, no variable expansion). + */ +export function parseEnv(content: string): Record { + const env: Record = {}; + for (const line of content.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + const idx = trimmed.indexOf("="); + if (idx === -1) continue; + const key = trimmed.slice(0, idx).trim(); + let val = trimmed.slice(idx + 1).trim(); + if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) { + val = val.slice(1, -1); + } + env[key] = val; + } + return env; +} + +/** Load a local .env into process.env if present. Native loader first, parser fallback. */ +export function loadDotEnv(path = ".env"): void { + if (!existsSync(path)) return; + try { + if (typeof process.loadEnvFile === "function") { + process.loadEnvFile(path); + } else { + const parsed = parseEnv(readFileSync(path, "utf8")); + for (const [key, val] of Object.entries(parsed)) { + process.env[key] = val; + } + } + } catch (err) { + console.warn("Warning: Failed to parse .env file:", err); + } +}