@verifyhash/env-diff
v0.1.0
Published
KEY-aware .env differ and .env.example generator: a zero-dependency, pure dotenv-subset parser + semantic key-level diff that reports unparsed lines and duplicate keys honestly and never leaks a value.
Maintainers
Readme
env-diff
A KEY-aware .env differ and .env.example generator — the semantic
counterpart to a plain line-based text diff. It compares two dotenv files by
variable, not by line: it tells you which keys are missing, which are extra,
and which changed — regardless of ordering, comments, or quoting style — and it
turns a real .env (which may hold live secrets) into a safe-to-commit
.env.example template.
Zero dependencies, zero network, zero filesystem. Three pure functions over strings that never leak an env value.
This is not a duplicate of polyatic's line-based
/diff-checker/. That tool compares raw text lines; env-diff understandsKEY=valuestructure, so re-ordering keys or adding a comment produces no false diff.
Who it's for
Developers eyeballing two .env files — for example checking a local .env
against the committed .env.example, or reconciling .env.staging with
.env.production — who want to know which variables differ without hand-
scanning line by line, and who want a committable .env.example so teammates
stop hitting "works on my machine" boot failures.
Install
# The final scoped name is the owner's call (see "Publishing" below).
npm install @verifyhash/env-diff # placeholder name — subject to changeconst { parseEnv, diffKeys, toExample } = require('@verifyhash/env-diff');Zero runtime dependencies, zero devDependencies — nothing else to install.
API
| Function | Signature | Returns |
| --- | --- | --- |
| parseEnv | parseEnv(text) | { entries, unparsed, duplicates } — parse the common dotenv subset |
| diffKeys | diffKeys(leftText, rightText) | { onlyLeft, onlyRight, valueDiffers, same, duplicates, unparsed } — a KEY-level semantic diff |
| toExample | toExample(text, opts?) | a ready-to-commit .env.example string, every value blanked |
opts for toExample is { stripComments?: boolean } — see below. Full,
precise TypeScript types ship in index.d.ts.
Runnable example
const { parseEnv, diffKeys, toExample } = require('@verifyhash/env-diff');
// 1) parseEnv — structured view of a .env
parseEnv('export API_KEY="abc 123"\n# comment\nPORT=8080\n');
// {
// entries: [
// { key: 'API_KEY', value: 'abc 123', line: 1, quoted: 'double', exported: true },
// { key: 'PORT', value: '8080', line: 3, quoted: false, exported: false }
// ],
// unparsed: [],
// duplicates: []
// }
// 2) diffKeys — which VARIABLES differ (never the values themselves)
diffKeys('A=1\nB=2\nSHARED=x\n', 'B=9\nC=3\nSHARED=x\n');
// {
// onlyLeft: ['A'],
// onlyRight: ['C'],
// valueDiffers: [{ key: 'B' }], // <-- key only; the values 2 vs 9 are NOT emitted
// same: ['SHARED'],
// duplicates: { left: [], right: [] },
// unparsed: { left: [], right: [] }
// }
// 3) toExample — a committable .env.example (values blanked)
toExample('export API_KEY="abc"\n# note\nPORT=8080\n');
// export API_KEY=
// # note
// PORT=parseEnv(text)
Returns { entries, unparsed, duplicates }:
- entries —
[{ key, value, line, quoted, exported }], one per assignment in source order.valueis the unquoted value (surrounding matching quotes stripped);''forKEY=.quotedis'single','double', orfalse. A value quoted single vs double but otherwise identical yields the samevalue.exportedistruewhen the line used theexportprefix.
- unparsed —
[{ line, text, reason }]for lines it could not parse: lines with no=, invalid keys,${...}/$VARinterpolation (kept literal, not resolved), and multiline continuations (trailing\). - duplicates —
[{ key, lines }], one row per key that appears more than once, listing all of its line numbers. Every occurrence still appears inentries; the last occurrence is the effective (last-wins) value.
diffKeys(leftText, rightText)
Parses both sides with parseEnv and returns a six-field, KEY-level diff:
- onlyLeft / onlyRight —
[key, ...]bare key strings present on only one side (in that side's first-seen order). - valueDiffers —
[{ key }, ...]for keys present on both sides whose effective (last-wins) values differ. Keys only, by design — the differing values are never emitted (they are secrets; the caller decides how, if at all, to display them). - same —
[key, ...]for keys on both sides with equal effective values. - duplicates / unparsed —
{ left, right }, each side'sparseEnvreport passed through unchanged.
Because the diff is computed on the parsed key sets, it is insensitive to
line order, to comments, and to quote style: KEY="1" and KEY=1
compare as same.
toExample(text, opts) — .env.example generator
Returns a ready-to-commit .env.example string with every recognized value
blanked to KEY=. It walks the raw input line by line (so it does not depend on
the parser to preserve comments), and:
- blanks each recognized value while preserving the
exportprefix, the key, and original line/key order; - keeps comments and blank lines by default; pass
{ stripComments: true }to drop full-line comments (blank lines are always kept); - collapses a duplicate key to a single blanked line at its first occurrence (later duplicate lines are dropped);
- carries any unparsed line (e.g.
${VAR}interpolation, trailing-\multiline, invalid keys) through verbatim, each preceded by a# env-diff: unparsedmarker so nothing is silently lost; - is deterministic: the same input yields byte-identical output. Empty
input yields
''. - is idempotent:
toExample(toExample(x)) === toExample(x). Re-processing an already-generated template is a fixed point — values are already blank, duplicates already collapsed, and an unparsed line whose marker survived from the previous pass is not re-marked (two consecutive unparsed lines in the original input still each get their own marker).
What is parsed vs reported
| Input | Result |
| ---------------------------- | -------------------------------------------------- |
| KEY=value | entry |
| export KEY=value | entry, exported: true |
| KEY="a b" / KEY='a b' | entry, quotes stripped, quoted records the style |
| KEY= | entry, value: '' |
| blank line / # comment | skipped (not an entry, not unparsed) |
| NO_EQUALS | unparsed — no = |
| KEY=${OTHER} | unparsed — interpolation kept literal, not resolved |
| KEY=value\ (trailing \) | unparsed — multiline continuation unsupported |
Honest limits
The .env "format" is loose and has no single standard. This library parses
the common dotenv subset and is deliberately conservative — it never guesses:
- It is not a shell parser. It understands
KEY=value,export KEY=value, and single/double surrounding quotes. That's it. Anything else is reported inunparsed, never silently reinterpreted. - It does NOT resolve
${FOO}/$FOOinterpolation. Such values are kept as literal text and flagged asunparsedso you are never misled into thinking a variable was expanded. - No multiline / heredoc values. A line ending in a trailing backslash is reported as an unsupported multiline continuation rather than joined.
- No escape-sequence decoding inside quotes. A double-quoted
\nstays the two characters\andn; the value text between the quotes is taken literally. - No inline
#comment stripping. A#is a comment only when it is the first non-blank character of a line (a full-line comment). A#after a value —KEY=val # noteorKEY="a # b"— is part of the value, quoted or not. The parser does not guess where an unquoted value ends, so it never truncates one at a#. - Diffing is by effective (last-wins) value, matching dotenv runtime semantics for duplicated keys. Duplicates are still surfaced separately so you can see the drift.
When in doubt, a line lands in unparsed with an honest reason string — the
library would rather tell you it didn't understand a line than pretend it did.
Redaction & trust
Env files routinely contain live secrets — API keys, DB passwords, tokens. This library is built so a value can never escape:
diffKeysnever emits a value.valueDifferscarries{ key }objects only;same/onlyLeft/onlyRightare bare key strings. Whether to show any value at all is the caller's decision, not the library's.toExampleblanks every recognized value toKEY=— that is its whole job: produce a template that is safe to commit.- Pure by construction. These modules have no network and no filesystem access — they are functions from string to data. They cannot exfiltrate anything, and the accompanying browser UI runs entirely client-side, so nothing you paste ever leaves your device.
This redaction discipline is a standing rule for the project, not a convenience
default — see GO-LIVE.md.
Package layout & publishing
The npm entry point is a small index.js that re-exports the
three core modules, with main and types pointing at index.js /
index.d.ts. We chose the re-export approach (over keeping parse.js as main
with an ambient multi-module .d.ts) because it gives consumers one obvious
import — require('@verifyhash/env-diff') yields parseEnv, diffKeys, and
toExample together — matches the sibling incubator libraries' layout, and
still leaves each module independently require-able (e.g.
require('@verifyhash/env-diff/parse.js')) and browser-loadable via the
globalThis.EnvDiff namespace the staged UI uses.
npm pack ships only the runtime surface — the core modules, the entry, the
types, this README, the LICENSE, and package.json. The UI files (app.js,
index.html, style.css) and test/ are intentionally excluded via the
files whitelist.
The scoped package name @verifyhash/env-diff above is a placeholder — the
final published name is the owner's call (see GO-LIVE.md).
Running the tests
To run the tests, from the package directory:
cd env-diff
npm testnpm test runs the hand-verified golden vectors in test/parse.test.js,
test/diff.test.js, and test/example.test.js (14 parse + 6 diff + 6 example
blocks). Each assertion fails the process (non-zero exit) on mismatch.
License
MIT — see LICENSE. Copyright (c) 2026 verifyhash.
