diff --git a/docs/setup/env-file.md b/docs/setup/env-file.md new file mode 100644 index 0000000..2cdc2c0 --- /dev/null +++ b/docs/setup/env-file.md @@ -0,0 +1,29 @@ +# Environment file syntax + +CareGuard loads `.env` before the unified server imports application code. If the file cannot be parsed, startup prints `Failed to parse .env: ` and exits with status `1`. + +## Supported lines + +```bash +# Comments and blank lines are ignored. +KEY=value +KEY = value +export KEY=value +SERVICE_URL: http://localhost:3001 +QUOTED_VALUE="value with spaces" +``` + +- Keys may contain letters, numbers, underscores, periods, and dashes. +- Values may be unquoted or quoted with matching single quotes, double quotes, or backticks. +- A UTF-8 byte order mark at the start of the file is ignored. +- Existing process environment variables are kept unless the loader is called with override mode. + +## Unsupported lines + +Raw multiline values are not accepted in `.env`. Encode newlines as `\n` inside a quoted value instead. + +```bash +PRIVATE_KEY="line one\nline two" +``` + +Every non-comment line must be a complete assignment. For example, `LLM_API_KEY="unterminated` fails with a clear parse error instead of a low-level runtime crash. diff --git a/server.ts b/server.ts index 3a1d6a3..74266e5 100644 --- a/server.ts +++ b/server.ts @@ -8,7 +8,7 @@ * For production: use `npm start` (this file) */ -import "dotenv/config"; +import "./shared/load-env.ts"; import { createHash } from "crypto"; import express from "express"; import { Keypair, Horizon } from "@stellar/stellar-sdk"; diff --git a/shared/__tests__/env-file.test.ts b/shared/__tests__/env-file.test.ts new file mode 100644 index 0000000..21fbf6e --- /dev/null +++ b/shared/__tests__/env-file.test.ts @@ -0,0 +1,87 @@ +import { mkdtempSync, rmSync, writeFileSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { loadEnvFile, loadEnvFileOrExit } from "../env-file.ts"; + +function writeTempEnv(contents: string): { dir: string; path: string } { + const dir = mkdtempSync(join(tmpdir(), "careguard-env-")); + const path = join(dir, ".env"); + writeFileSync(path, contents); + return { dir, path }; +} + +describe("env-file", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("loads valid .env values without overwriting existing variables", () => { + const { dir, path } = writeTempEnv([ + "# local config", + "LLM_API_KEY=from-file", + "PHARMACY_API_URL: http://localhost:3001", + "export BILL_AUDIT_API_URL=http://localhost:3002", + ].join("\n")); + const env = { + LLM_API_KEY: "already-set", + } as NodeJS.ProcessEnv; + + try { + const result = loadEnvFile({ path, env }); + + expect(result).toMatchObject({ loaded: true, path }); + expect(result.error).toBeUndefined(); + expect(env.LLM_API_KEY).toBe("already-set"); + expect(env.PHARMACY_API_URL).toBe("http://localhost:3001"); + expect(env.BILL_AUDIT_API_URL).toBe("http://localhost:3002"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("accepts files with a UTF-8 BOM", () => { + const { dir, path } = writeTempEnv("\uFEFFLLM_API_KEY=from-file\n"); + const env = {} as NodeJS.ProcessEnv; + + try { + const result = loadEnvFile({ path, env }); + + expect(result.error).toBeUndefined(); + expect(env.LLM_API_KEY).toBe("from-file"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("returns a clear parse error for malformed .env syntax", () => { + const { dir, path } = writeTempEnv('LLM_API_KEY="unterminated\n'); + + try { + const result = loadEnvFile({ path, env: {} as NodeJS.ProcessEnv }); + + expect(result.loaded).toBe(false); + expect(result.error?.message).toBe('line 1: unbalanced " quote'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("prints a clean parse message and exits on malformed .env files", () => { + const { dir, path } = writeTempEnv("not valid dotenv syntax\n"); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); + const exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number | string | null) => { + throw new Error(`exit:${code}`); + }) as never); + + try { + expect(() => loadEnvFileOrExit({ path })).toThrow("exit:1"); + expect(errorSpy).toHaveBeenCalledWith( + "Failed to parse .env: line 1: expected KEY=value", + ); + expect(exitSpy).toHaveBeenCalledWith(1); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/shared/env-file.ts b/shared/env-file.ts new file mode 100644 index 0000000..828e667 --- /dev/null +++ b/shared/env-file.ts @@ -0,0 +1,85 @@ +import { existsSync, readFileSync } from "fs"; +import { resolve } from "path"; +import * as dotenv from "dotenv"; + +export interface EnvFileLoadOptions { + path?: string; + override?: boolean; + env?: NodeJS.ProcessEnv; +} + +export interface EnvFileLoadResult { + loaded: boolean; + path: string; + error?: Error; +} + +function normalizeEnvText(text: string): string { + return text.replace(/^\uFEFF/, ""); +} + +function validateEnvText(text: string): Error | null { + const lines = normalizeEnvText(text).split(/\n/); + + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index].replace(/\r$/, ""); + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + + const match = trimmed.match(/^(?:export\s+)?([\w.-]+)(?:\s*=\s*|:\s+)(.*)$/); + if (!match) { + return new Error(`line ${index + 1}: expected KEY=value`); + } + + const rawValue = match[2].trim(); + const quote = rawValue[0]; + if ((quote === '"' || quote === "'" || quote === "`") && !rawValue.endsWith(quote)) { + return new Error(`line ${index + 1}: unbalanced ${quote} quote`); + } + } + + return null; +} + +export function loadEnvFile(options: EnvFileLoadOptions = {}): EnvFileLoadResult { + const path = resolve(options.path ?? ".env"); + const env = options.env ?? process.env; + + if (!existsSync(path)) { + return { loaded: false, path }; + } + + try { + const raw = readFileSync(path, "utf8"); + const validationError = validateEnvText(raw); + if (validationError) { + return { loaded: false, path, error: validationError }; + } + + const parsed = dotenv.parse(normalizeEnvText(raw)); + for (const [key, value] of Object.entries(parsed)) { + if (options.override || env[key] === undefined) { + env[key] = value; + } + } + + return { loaded: true, path }; + } catch (error) { + return { + loaded: false, + path, + error: error instanceof Error ? error : new Error(String(error)), + }; + } +} + +export function loadEnvFileOrExit(options: EnvFileLoadOptions = {}): EnvFileLoadResult { + const result = loadEnvFile(options); + + if (result.error) { + console.error(`Failed to parse .env: ${result.error.message}`); + process.exit(1); + } + + return result; +} diff --git a/shared/load-env.ts b/shared/load-env.ts new file mode 100644 index 0000000..bf86e42 --- /dev/null +++ b/shared/load-env.ts @@ -0,0 +1,3 @@ +import { loadEnvFileOrExit } from "./env-file.ts"; + +loadEnvFileOrExit();