Skip to content
Back to projects
Live2026

missing-env

A CLI that finds every environment variable your code reads and nobody set. On npm, without a single runtime dependency.

$ npx missing-env

missing-env5 files in 15ms

2 missing — read by your code, set nowhere

STRIPE_SECRET_KEYsrc/billing.ts:3
STRIPE_WEBHOOK_SECRETsrc/billing.ts:6

1 empty — defined without a value

SMTP_PASSWORDsrc/mailer.ts:5

4 undocumented — a fresh clone would not know about these

STRIPE_SECRET_KEYsrc/billing.ts:3
STRIPE_WEBHOOK_SECRETsrc/billing.ts:6
BATCH_SIZEworker/reports.py:4
IMAGE_TAGdocker-compose.yml:3

2 unset — the code falls back to a default

BATCH_SIZEworker/reports.py:4
IMAGE_TAGdocker-compose.yml:3

1 unused — defined but never read

OLD_MAILCHIMP_KEY.env:9

2 errors, 3 warnings, 5 notes

exit code 1fails the build
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

javascript
// process.env.OLD_KEY                    ← a commentconst hint = 'set process.env.API_KEY'    // ← inside a stringconst url = `${process.env.API_URL}/v1`   // ← the only real read

A 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

javascript
process.env.DATABASE_URL            // requiredprocess.env.LOG_LEVEL ?? 'info'     // optionalconst { PORT = 3000 } = process.env // optional

One 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

python
os.environ["SMTP_HOST"]        # requiredos.getenv("SMTP_PORT", "587")  # optional

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!.

As a gate in CI

yaml
- uses: actions/checkout@v5- uses: Jxxck/missing-env-action@v1

Two 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

.env
$ 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

typescript
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.