Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -54,3 +54,4 @@ skills-lock.json
# Local experiment scratch (cloned tools / benchmark output, not part of the project)
/aider/
/tmp.benchmarks/
modlane.yaml
14 changes: 14 additions & 0 deletions openspec/changes/P9-env-support/proposal.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 7 additions & 0 deletions openspec/changes/P9-env-support/tasks.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 4 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down
33 changes: 33 additions & 0 deletions src/env.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
41 changes: 41 additions & 0 deletions src/env.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> {
const env: Record<string, string> = {};
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);
}
}
Loading