Skip to content
Open
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
29 changes: 29 additions & 0 deletions docs/setup/env-file.md
Original file line number Diff line number Diff line change
@@ -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: <message>` 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.
2 changes: 1 addition & 1 deletion server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
87 changes: 87 additions & 0 deletions shared/__tests__/env-file.test.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
});
});
85 changes: 85 additions & 0 deletions shared/env-file.ts
Original file line number Diff line number Diff line change
@@ -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;
}
3 changes: 3 additions & 0 deletions shared/load-env.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { loadEnvFileOrExit } from "./env-file.ts";

loadEnvFileOrExit();