From fe0bd61f472f4dfa85e1c809048b0281c76c8f57 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Thu, 13 Aug 2026 20:46:03 +0000 Subject: [PATCH] feat(12factor-env): opt-in dotenv support via dotenv()/parseDotenv --- packages/12factor-env/README.md | 33 ++++++++++ packages/12factor-env/__tests__/env.test.ts | 71 +++++++++++++++++++++ packages/12factor-env/src/index.ts | 59 +++++++++++++++++ 3 files changed, 163 insertions(+) diff --git a/packages/12factor-env/README.md b/packages/12factor-env/README.md index 75504b8624..38b5b90d97 100644 --- a/packages/12factor-env/README.md +++ b/packages/12factor-env/README.md @@ -115,6 +115,39 @@ parseEnvBoolean('YES'); // true parseEnvNumber('42'); // 42 ``` +## Opt-in dotenv support + +The 12-factor rule is **environment first, `.env` as a local-dev convenience**. +`dotenv()` builds an environment record where a local `.env` fills gaps but real +environment variables always win. It never mutates `process.env`, a missing file +is not an error, and nothing changes unless you call it — existing `env()` usage +is unaffected. + +```ts +import { env, dotenv, str, port } from '12factor-env'; + +const config = env( + dotenv(), // process.env, backed by ./.env when present + { DATABASE_URL: str() }, + { PORT: port({ default: 3000 }) } +); +``` + +Options: + +```ts +dotenv(); // /.env merged under process.env +dotenv({ cwd: projectDir }); // look for .env in another directory +dotenv({ file: '.env.local' }); // different file name under cwd +dotenv({ path: '/etc/app/config.env' }); // explicit file path +dotenv({ environment: {} }); // file only, ignore process.env +dotenv({ override: true }); // file values win over the environment +``` + +Parsing uses Node's built-in dotenv parser (`util.parseEnv`), which handles +comments, quoting, and `export` prefixes. The raw parser is exported as +`parseDotenv(source)` if you need it directly. + ## Validators All validators from [envalid](https://github.com/af/envalid) are re-exported: diff --git a/packages/12factor-env/__tests__/env.test.ts b/packages/12factor-env/__tests__/env.test.ts index d649e2cac7..ef1c93d5e6 100644 --- a/packages/12factor-env/__tests__/env.test.ts +++ b/packages/12factor-env/__tests__/env.test.ts @@ -1,7 +1,12 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + import { bool, boolish, devDefault, + dotenv, env, getNodeEnv, getStrictEnvMode, @@ -9,6 +14,7 @@ import { isDevelopment, isProduction, isTest, + parseDotenv, parseEnvBoolean, parseEnvList, parseEnvNumber, @@ -297,4 +303,69 @@ describe('env', () => { expect(getStrictEnvMode({ STRICT_ENV: 'THROW' })).toBe('throw'); }); }); + + describe('dotenv', () => { + let dir: string; + + beforeEach(() => { + dir = mkdtempSync(path.join(os.tmpdir(), '12factor-env-')); + }); + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + it('parses dotenv source with parseDotenv', () => { + const parsed = parseDotenv('A=1\n# comment\nB="two words"\n'); + expect(parsed).toEqual({ A: '1', B: 'two words' }); + }); + + it('merges .env values under the environment (environment wins)', () => { + writeFileSync(path.join(dir, '.env'), 'FROM_FILE=file\nSHARED=file\n'); + const merged = dotenv({ cwd: dir, environment: { SHARED: 'env', FROM_ENV: 'env' } }); + expect(merged).toEqual({ FROM_FILE: 'file', SHARED: 'env', FROM_ENV: 'env' }); + }); + + it('lets file values win when override is true', () => { + writeFileSync(path.join(dir, '.env'), 'SHARED=file\n'); + const merged = dotenv({ cwd: dir, environment: { SHARED: 'env' }, override: true }); + expect(merged.SHARED).toBe('file'); + }); + + it('returns the environment unchanged when the file is missing', () => { + const merged = dotenv({ cwd: dir, environment: { ONLY: 'env' } }); + expect(merged).toEqual({ ONLY: 'env' }); + }); + + it('resolves an explicit path over cwd/file', () => { + const custom = path.join(dir, 'custom.env'); + writeFileSync(custom, 'CUSTOM=yes\n'); + const merged = dotenv({ path: custom, cwd: '/nonexistent', environment: {} }); + expect(merged).toEqual({ CUSTOM: 'yes' }); + }); + + it('resolves a custom file name under cwd', () => { + writeFileSync(path.join(dir, '.env.local'), 'LOCAL=yes\n'); + const merged = dotenv({ cwd: dir, file: '.env.local', environment: {} }); + expect(merged).toEqual({ LOCAL: 'yes' }); + }); + + it('never mutates process.env or the provided environment', () => { + writeFileSync(path.join(dir, '.env'), 'DOTENV_MUTATION_CHECK=file\n'); + const provided = { KEEP: 'env' }; + dotenv({ cwd: dir, environment: provided }); + dotenv({ cwd: dir }); + expect(provided).toEqual({ KEEP: 'env' }); + expect(process.env.DOTENV_MUTATION_CHECK).toBeUndefined(); + }); + + it('composes with env() for validation', () => { + writeFileSync(path.join(dir, '.env'), 'DATABASE_URL=postgres://localhost/dev\n'); + const config = env( + dotenv({ cwd: dir, environment: {} }), + { DATABASE_URL: str() } + ); + expect(config.DATABASE_URL).toBe('postgres://localhost/dev'); + }); + }); }); diff --git a/packages/12factor-env/src/index.ts b/packages/12factor-env/src/index.ts index dbfaeefe70..1180525666 100644 --- a/packages/12factor-env/src/index.ts +++ b/packages/12factor-env/src/index.ts @@ -1,3 +1,7 @@ +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { parseEnv as parseEnvSource } from 'node:util'; + import type { CleanedEnv, CleanOptions, Spec,ValidatorSpec } from 'envalid'; import { bool, @@ -191,6 +195,61 @@ const boolish = makeValidator((value: string) => { return parseEnvBoolean(String(raw)) ?? false; }); +// ── Opt-in dotenv support ──────────────────────────────────────────────────── +// +// The 12-factor rule is "environment first, `.env` as a local-dev convenience": +// a container gets its configuration from the process environment, while a +// developer's project folder may carry a `.env`. `dotenv()` produces an +// environment record that honors that rule — file values fill gaps, real +// environment variables always win (unless `override` is set). It is a pure +// input builder for `env()`/`cleanEnv`; it never mutates `process.env`. + +export type DotenvOptions = { + /** Explicit path to the env file. Takes precedence over `cwd`/`file`. */ + path?: string; + /** Directory to resolve `file` in. Default: `process.cwd()`. */ + cwd?: string; + /** File name resolved under `cwd`. Default: `.env`. */ + file?: string; + /** Base environment the file is merged into. Default: `process.env`. */ + environment?: Record; + /** When true, file values win over the base environment. Default: false. */ + override?: boolean; +}; + +/** Parse dotenv-format source text into a plain record (no interpolation). */ +export const parseDotenv = (source: string): Record => + parseEnvSource(source) as Record; + +/** + * Read an env file and merge it with an environment record. A missing file is + * not an error — the base environment is returned unchanged, so the same code + * path works in a container (no file) and in local dev (file present). + */ +export const dotenv = ( + options: DotenvOptions = {} +): Record => { + const { + environment = process.env, + override = false, + cwd = process.cwd(), + file = '.env' + } = options; + const filePath = options.path ?? path.join(cwd, file); + + let source: string; + try { + source = readFileSync(filePath, 'utf8'); + } catch { + return { ...environment }; + } + + const fileVars = parseDotenv(source); + return override + ? { ...environment, ...fileVars } + : { ...fileVars, ...environment }; +}; + // Type for specs object type Specs = Record>;