feat(cli): auto-load .env and .env.local from cwd

On every CLI invocation, parse .env (and .env.local if present) from
the working directory into process.env before dispatching to command
handlers. Existing shell-exported variables always win — the loader
never clobbers an explicitly-set value. .env.local overrides .env.

Motivation: FUSION_DAEMON_TOKEN (and soon other config knobs) is more
ergonomic as a gitignored local file than as a shell export each
session. Without this, `fn dashboard` falls back to auto-generating a
new token on every restart, which means the banner URL changes every
time and stale localStorage tokens silently return 401 on every API
call.

SSE and WebSocket clients already carry the token via appendTokenQuery
(fn_token= query-string fallback, since EventSource and WebSocket
constructors cannot set Authorization headers) — verified: every
`new EventSource` and `new WebSocket` call site is wrapped.

Hand-rolled minimal parser (no new dependency) to keep the bundled
single-binary CLI lean. Supports KEY=value, quoted values, comments,
blank lines.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-04-21 20:48:47 -07:00
parent d704e24244
commit f8173ac7f8

View File

@@ -59,6 +59,48 @@ function configurePiPackage(): void {
configurePiPackage();
/**
* Load `.env` (and `.env.local`) from the current working directory into
* process.env so that secrets like FUSION_DAEMON_TOKEN can live in a local
* gitignored file instead of being exported manually each session.
*
* Existing environment variables always win — this loader never clobbers
* values the user set explicitly in their shell. `.env.local` overrides
* `.env` when both are present.
*
* We deliberately hand-roll a minimal parser instead of pulling in dotenv:
* the CLI ships as a single bundled binary and we want to keep it lean.
*/
function loadEnvFile(path: string): void {
if (!existsSync(path)) return;
const contents = readFileSync(path, "utf-8");
for (const rawLine of contents.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line || line.startsWith("#")) continue;
const eq = line.indexOf("=");
if (eq === -1) continue;
const key = line.slice(0, eq).trim();
if (!key || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
if (process.env[key] !== undefined) continue; // Shell wins.
let value = line.slice(eq + 1).trim();
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
process.env[key] = value;
}
}
function loadLocalEnv(): void {
const cwd = process.cwd();
loadEnvFile(join(cwd, ".env"));
loadEnvFile(join(cwd, ".env.local"));
}
loadLocalEnv();
// Command handlers are loaded lazily so --help can return immediately
// without importing the full command graph.
async function loadCommandHandlers() {