Env File Diff CLI
A zero-dependency CLI that parses two .env files and reports keys only on the left, only on the right, and keys whose values changed.
Project
node-env-diff-cli
Entry point: envdiff.mjs
Install
node --versionRun
node envdiff.mjs base.env feature.envTest
node --test envdiff.test.mjsExpected output
For a base and feature env file the CLI prints only-in-left, only-in-right, and changed sections with one key per line.Dependencies
- node >=20
Security note
Prints key names only, never values, so the output is safe to paste into issues and pull request reviews.
What it does
Compares two .env files and classifies every key as only-in-left, only-in-right, or changed. Values never print, only key names, so the output is safe to share.
import { readFileSync } from 'node:fs';
export function parseEnv(text) {
const out = new Map();
for (const rawLine of text.split('\n')) {
const line = rawLine.trim();
if (line === '' || line.startsWith('#')) continue;
const stripped = line.startsWith('export ') ? line.slice(7) : line;
const eq = stripped.indexOf('=');
if (eq <= 0) continue;
const key = stripped.slice(0, eq).trim();
let value = stripped.slice(eq + 1).trim();
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1);
}
out.set(key, value);
}
return out;
}
export function diffEnv(a, b) {
const onlyLeft = [];
const onlyRight = [];
const changed = [];
for (const [key, value] of a) {
if (!b.has(key)) onlyLeft.push(key);
else if (b.get(key) !== value) changed.push({ key, from: value, to: b.get(key) });
}
for (const key of b.keys()) {
if (!a.has(key)) onlyRight.push(key);
}
return { onlyLeft, onlyRight, changed };
}
const [, , leftPath, rightPath] = process.argv;
if (leftPath && rightPath) {
const result = diffEnv(parseEnv(readFileSync(leftPath, 'utf8')), parseEnv(readFileSync(rightPath, 'utf8')));
const lines = [];
for (const key of result.onlyLeft) lines.push('only-in-left: ' + key);
for (const key of result.onlyRight) lines.push('only-in-right: ' + key);
for (const c of result.changed) lines.push('changed: ' + c.key);
console.log(lines.length ? lines.join('\n') : 'no differences');
}
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { parseEnv, diffEnv } from './envdiff.mjs';
test('parses quotes, comments, and export prefixes', () => {
const env = parseEnv('export HOST=localhost\n# comment\nEMPTY=""\nPORT=8080');
assert.equal(env.get('HOST'), 'localhost');
assert.equal(env.get('PORT'), '8080');
assert.equal(env.get('EMPTY'), '');
assert.equal(env.size, 3);
});
test('diff classifies keys into three buckets', () => {
const a = parseEnv('SHARED=same\nCHANGED=old\nONLY_A=1');
const b = parseEnv('SHARED=same\nCHANGED=new\nONLY_B=2');
const d = diffEnv(a, b);
assert.deepEqual(d.onlyLeft, ['ONLY_A']);
assert.deepEqual(d.onlyRight, ['ONLY_B']);
assert.equal(d.changed.length, 1);
assert.equal(d.changed[0].key, 'CHANGED');
});
Run and verify
node --test envdiff.test.mjs
node envdiff.mjs base.env feature.env
Run the test first, then diff two env files. The demo output lists the three buckets with one key per line.
Expected Output
The test run should print two passing tests for parsing and bucketing. The CLI’s console.log output should print the classified keys grouped by bucket, one key per line, with values never shown. Diffing a base.env that defines DEBUG and PORT=8080 against a feature.env that defines VERBOSE and PORT=3000, the Output: is:
only-in-left: DEBUG
only-in-right: VERBOSE
changed: PORT
Two files with the same keys and values print the single line no differences, and the parser tolerates export prefixes, comments, and quoted values before comparing.
Limitations
The parser does not interpolate variables inside values, and duplicated keys resolve last-wins without a warning. It is a diff tool, not a validator.