missing-env
A CLI that finds every environment variable your code reads and nobody set. On npm, without a single runtime dependency.
missing-env5 files in 15ms
2 missing — read by your code, set nowhere
1 empty — defined without a value
4 undocumented — a fresh clone would not know about these
2 unset — the code falls back to a default
1 unused — defined but never read
2 errors, 3 warnings, 5 notes
- Runtime dependencies
- 0
- Tests
- 69
- Language families
- 11
About the project
Somebody adds process.env.STRIPE_SECRET_KEY, exports it on their own machine and forgets to write it down. Two weeks later a new colleague clones the repository, the app starts without complaint, and the value turns up as undefined in the middle of a payment call. This is not an exotic failure, it happens on every team.
There are tools that lint .env files. They all share one blind spot: they know the file, not the program. missing-env works the other way round. It reads the source, collects every read of an environment variable, and compares that list against .env and .env.example. If a required variable is missing anywhere, the exit code is 1 — which makes the call a gate in CI rather than a report for humans to skim.
The reason this is not a grep lives in a single file: the lexer. A commented-out read is not a read, an occurrence inside a string literal is not a read, but the code inside a template hole is. Comments are overwritten with spaces rather than deleted, so byte offsets and line numbers stay exact — a finding pointing at the wrong line is worse than no finding at all. The same layer understands Python docstrings, nested Rust block comments, and the difference between '…' and "…" in shell scripts, where ${VAR} inside double quotes is still code.
The central decision is what counts as required in the first place, and there is exactly one rule for it: a read is optional when the call site supplies a fallback. process.env.DATABASE_URL is required, process.env.LOG_LEVEL ?? 'info' is not. Two APIs count as optional even without a default, because they force the caller to handle absence: Go's os.LookupEnv and Rust's option_env!. That uniformity was the point — a rule you can explain in one line of README is a rule people trust.
It recognises reads across eleven language families: JavaScript including TypeScript, Python, Go, Ruby, PHP, Java including Kotlin and Scala, C#, Rust, shell scripts, YAML including docker-compose, and Dockerfiles. All of it is 1,774 lines of source against 748 lines of tests, zero runtime dependencies, and TypeScript, Prettier and the Node types as the only dev dependencies. The package is on npm under MIT, a GitHub Action wraps the same call for pipelines, and CI covers Node 20, 22 and 24.
The most embarrassing bug was one the tool found in itself: a project's own ignore entries replaced the built-in exclusions instead of extending them, so node_modules quietly got scanned. Pointing a linter at its own repository is the most honest test method this kind of tool has.
Examples
Why not grep
// process.env.OLD_KEY ← a commentconst hint = 'set process.env.API_KEY' // ← inside a stringconst url = `${process.env.API_URL}/v1` // ← the only real readA grep finds all three lines, and only the last one is a real read. So every file goes through a lexer first: comments drop out, matches inside strings do not count, and the code inside a template hole still does.
Required or optional
process.env.DATABASE_URL // requiredprocess.env.LOG_LEVEL ?? 'info' // optionalconst { PORT = 3000 } = process.env // optionalOne rule across every language: a read is optional when the call site supplies a fallback. Anything without one is required — and that is what fails the build.
Same rule, other ecosystem
os.environ["SMTP_HOST"] # requiredos.getenv("SMTP_PORT", "587") # optionalTwo APIs count as optional even without a default, because they force the caller to handle absence: Go’s os.LookupEnv and Rust’s option_env!.
As a gate in CI
- uses: actions/checkout@v5- uses: Jxxck/missing-env-action@v1Two lines. The step fails as soon as a required variable is set nowhere, and writes the findings as a table into the job summary. Outside GitHub, `npx missing-env --quiet` does the same job.
Keeping the example file honest
$ npx missing-env --sync-example wrote 4 variables to .env.example # src/billing.ts:3STRIPE_SECRET_KEY=# worker/reports.py:4BATCH_SIZE=Appends every undocumented variable with the place it is used. Values are never copied over from .env — an example file is committed, and that is exactly how credentials end up in git history.
As a library
import { check } from 'missing-env' const report = await check({ root: process.cwd() })const blocking = report.findings.filter((f) => f.severity === 'error')The same check as a function, for when the output belongs somewhere else. If you need the pieces on their own, scanProject, parseEnv, diagnose and render are exported separately. `--json` prints the same report from the command line.