envconform
v1.2.0
Published
Detect and fix drift between your .env, .env.example, and the env vars your code actually uses.
Maintainers
Readme
envconform
Detect drift between your .env, .env.example, and the env vars your code
actually uses — before it breaks a teammate's setup or your CI pipeline.
The problem
Environment variables live in three places that constantly fall out of sync:
- Template files (
.env.example) — the documented contract of what a project needs. - Actual value files (
.env,.env.local, ...) — the real values on a given machine, usually gitignored. - Source code — where variables are actually read.
A developer adds a new process.env.STRIPE_KEY in code but forgets to add it
to .env.example. The next person who clones the repo gets a runtime crash
instead of a clear error. .env.example also tends to accumulate dead keys
nobody uses anymore, and nothing catches either problem until it breaks
something.
What envconform does
envconform doesn't diff files against each other — it builds three sets of keys and reports where they disagree:
| Set | Built from | Meaning |
|------------|--------------------------------------|-----------------------------------|
| DECLARED | all template files (.env.example) | what the project says it needs |
| PRESENT | all actual-value files (.env, ...) | what values actually exist |
| USED | env reads found in source code | what the code actually uses |
Every finding keeps its provenance — the exact file and line it came from — so the report always says where, not just what.
$ envconform
envconform — 3 issues found (comparing against: .env.example)
🔴 Used in code, missing from template
STRIPE_SECRET_KEY src/payments.js:1
🟡 Has a value but missing from template
SHARED_SECRET .env:2
🔵 Documented in template but missing locally
OLD_TOKEN .env.example:2Env vars used in code but never documented (🔴) is the headline check — the
class of bug that crashes a teammate's first npm run dev after a clone.
"Template" always means an actual file — the (comparing against: ...) line
names it, and lists every one if you have more than one (e.g. a monorepo with
.env.example at the root and another under apps/web/). If no template
file is found, every key gets flagged, but envconform says so explicitly
instead of leaving you to guess why — and tells you what to do about it,
whether that's generating one or pointing envconform at one you already have:
$ npx envconform
⚠️ No template file found (e.g. .env.example) — 'template' below refers to
that missing file. Every key is flagged because there is nothing to
compare against yet.
Run `npx envconform init` to generate one from what you already have, or —
if you already have one under a different name (not ending in .example/.sample/
.template) — run `npx envconform config init` to create envconform.config.json,
then add its exact filename to "templates" there.
envconform — 12 issues found
..."Not found" and "doesn't exist" aren't the same thing. envconform only
recognizes a template by filename pattern (below) — it never reads file
contents to guess. If your team names its template something that doesn't
match the pattern (env.rules, vars.example.txt, whatever), envconform
will report zero template files, even though you have one. There's
usually no envconform.config.json yet either at that point — npx
envconform config init creates one with every field already set to its
real default, so you're editing a working file, not writing JSON from
scratch — see Configuration.
Install
npx envconformNo install needed for a one-off run. To use it regularly:
npm install --save-dev envconformUsage
npx envconform # human-readable report
npx envconform --json # machine-readable report
npx envconform --ci # non-zero exit code if a var is used in code but undocumented(Drop the npx prefix only if you've installed it globally with npm install -g envconform — otherwise a bare envconform won't be on your PATH and the shell will tell you the command doesn't exist.)
Run it from the root of a project. It walks the whole tree (skipping
node_modules, .git, dist, build, .next, coverage) looking for
template files and actual-value files — env files are read even if
.gitignore excludes them, since that's normal for .env/.env.local. It
also scans JS/TS source files (respecting .gitignore this time) for env
var reads, and prints any drift between what's declared, what's present, and
what's used.
How it decides what's a template, by default: any filename ending in
.example, .sample, or .template (case-insensitive) — so .env.example,
.env.sample, .env.template all just work, and so would something like
config.sample. This is a filename pattern, not a content check — envconform
never opens a file to guess whether it "looks like" a template.
How it decides what's an actual-value file: .env itself, or anything
starting with .env. that isn't a template (.env.local, .env.development,
.env.production.local, etc.).
If your naming doesn't match either pattern, envconform won't find your
template on its own — it'll report zero template files, not an error, so the
failure mode is silent unless you know to look for it. Add the exact
filename to envconform.config.json's templates (or actual) array and
it's treated as one from then on, regardless of what it's called — see
Configuration.
More flags (--env, --example, --src, --ignore) are on the roadmap —
use a config file (below) to set these for now.
NODE_ENV is ignored by default — it's set by the runtime/framework itself,
never something you declare in .env.example. When a key is used in a lot
of places, the table view shows the first 4 locations and +N more to keep
the report scannable (--json always includes every location in full).
envconform init / envconform --fix
envconform can also write your template for you — it never copies a real value into it, only key names and provenance comments.
npx envconform init # create .env.example when none exists
npx envconform --fix # add only the keys used in code but missing from an existing template
npx envconform init --placeholders # same as init, but infer non-secret placeholders from value shapeinit builds the file from the union of every key used in code and every
key present in an actual-value file. It refuses to run (exit 2) if a
template already exists — use --fix instead. --fix only appends the 🔴
used-but-undocumented keys to the end of the existing file; it never
reorders, edits, or removes anything already there.
# Generated by envconform — fill in real values in your local .env
# Used in: src/payments.ts
STRIPE_SECRET_KEY=
# Used in: src/cache.ts (also found in .env.local)
REDIS_URL=
DATABASE_URL=--placeholders (off by default) fills in a placeholder inferred from a
present value's shape only — never the value itself: a URL-shaped value
becomes http://localhost:PORT, true/false becomes false, a number
becomes 0, anything else stays blank. This is enforced structurally: the
function that renders a template entry never receives the real value at
all, only one of those four fixed strings.
Configuration
Everything works with zero config. To override defaults, add
envconform.config.json at the project root (or an "envconform" key in
package.json — envconform.config.json wins if both exist). Don't want to
write the JSON by hand? Generate a starter file with every field already set
to its real default:
npx envconform config init{
"templates": [".env.staging"], // force-classify extra filenames as templates
"actual": [], // force-classify extra filenames as actual-value files
"src": ["src", "app"], // restrict JS/TS scanning to these dirs (default: whole tree)
"ignore": ["DEBUG_PORT", "CI"], // keys to never report (default: ["NODE_ENV"] — this replaces it, not adds to it)
"failOn": ["used-undocumented"], // finding categories that make --ci exit 1 (this is the default)
"placeholders": false // default for --placeholders on init/--fix
}(That's annotated for reference — real JSON can't have comments, so the file
config init actually writes has no // lines, just the six fields at
their real defaults, ready to edit.)
Every field is optional and only overrides its own default — templates
and actual add exceptions on top of the built-in name patterns (they
don't replace them), while ignore, failOn, src, and placeholders
replace their default outright when present. That means if you set your own
ignore list, it replaces the default ["NODE_ENV"] rather than adding to
it — include "NODE_ENV" yourself if you still want it filtered.
Exit codes
| Code | Meaning |
|------|------------------------------------------------------------------|
| 0 | No blocking findings (or --ci wasn't passed) |
| 1 | --ci was passed and a var is used in code but undocumented (🔴) |
| 2 | Usage error — unknown flag or unreadable file |
CI
# .github/workflows/envconform.yml
name: envconform
on: [pull_request]
jobs:
check-env:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npx envconform --ciHow it works
envconform is a four-stage pipeline, kept strictly separated:
- Parsers read
.env-format files into{ key, value, file, line }records — no evaluation, no expansion. - Classifier decides whether a file is a template or an actual-value file, by name pattern.
- Scanner walks the source tree (respecting
.gitignore) and delegates to a per-language detector behind a stableDetectorinterface — the JS/TS detector parses each file into a real AST (@babel/parser, with thetypescript/jsxplugins as appropriate) rather than pattern-matching raw text, soprocess.env.Xinside a comment or a string literal is correctly ignored instead of producing a false positive. A file that fails to parse is skipped rather than crashing the scan. - Reconciler is pure set logic — no I/O — that takes the DECLARED, PRESENT, and USED sets and returns a list of findings.
- Reporter formats a
Reportinto human-readable table output or--json; renderers never compute findings, only format them. - Generator (
init/--fix) is also pure — it builds template text from key names, file provenance, and (optionally) a shape-inferred placeholder, never from the realEnvRecord.value. The CLI is the only layer that touches the filesystem for it. - Config (
envconform.config.json) is loaded once at the top of the CLI and threaded through as plain data — classification, scanning, and reconciliation all stay pure and config-agnostic in isolation; only the CLI knows a config file exists.
Roadmap
- [x] v0.1 — env-to-env diff
- [x] v0.2 — scan JS/TS source for
process.env.*reads, flag undocumented usage - [x] v0.3 —
--jsonoutput,--cimode with exit codes - [x] v0.4 — multiple env files, full provenance,
.env.localseverity nuance - [x] v0.5 —
envconform init/envconform --fix(generate and patch templates, never leaking real values) - [x] v0.6 — config file, shape-based placeholders
- [x] v0.7 — AST-based JS/TS detector
- [x] v1.0 — polish, docs, publish to npm
- [x] v1.1 — real-world polish from dogfooding: explicit no-template notice, names the
actual template file(s) instead of the abstract word "template", default-ignores
NODE_ENV, truncates long location lists in the table view - [x] v1.1.1 — a declared key that's both unused and missing locally no longer shows up as two separate findings — it's one fact ("safe to remove"), not a duplicate
- [x] v1.1.2 — the no-template notice now proactively points to
envconform.config.jsonfor teams with a non-standard template filename, instead of only documenting it - [x] v1.1.3 — every self-referential command the tool prints (in error messages and
the no-template notice) now says
npx envconform ...explicitly, since a bareenvconformonly works if it's installed globally - [x] v1.2.0 —
envconform config initscaffoldsenvconform.config.jsonwith the real defaults already filled in, since telling someone to "add it to config" isn't useful advice when they don't have a config file yet and don't know its shape. The no-template notice now points to this command directly.
Development
npm install
npm test # vitest (builds first via pretest)
npm run coverage # vitest with coverage — reconcile.ts is held to a 90%+ threshold
npm run typecheck
npm run build # bundles src/cli.ts to dist/cli.js via tsupLicense
MIT — see LICENSE.
