Zum Inhalt springen
Zurück zu den Projekten
Live2026

missing-env

CLI, das jede Umgebungsvariable findet, die der Code liest und niemand gesetzt hat. Auf npm, ohne eine einzige Laufzeit-Abhängigkeit.

$ npx missing-env

missing-env5 Dateien in 15 ms

2 missing — vom Code gelesen, nirgends gesetzt

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

1 empty — definiert, aber ohne Wert

SMTP_PASSWORDsrc/mailer.ts:5

4 undocumented — ein frischer Klon wüsste nichts davon

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

2 unset — der Code fällt auf einen Standardwert zurück

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

1 unused — definiert, aber nie gelesen

OLD_MAILCHIMP_KEY.env:9

2 Fehler, 3 Warnungen, 5 Hinweise

Exit-Code 1bricht den Build ab
Laufzeit-Abhängigkeiten
0
Tests
69
Sprachfamilien
11

Über das Projekt

Jemand fügt process.env.STRIPE_SECRET_KEY hinzu, setzt den Wert auf seinem Rechner und vergisst, ihn aufzuschreiben. Zwei Wochen später klont ein neuer Kollege das Repository, die Anwendung startet ohne Fehler, und der Wert taucht als undefined mitten in einem Zahlungsvorgang wieder auf. Das ist kein exotischer Fall, das passiert in jedem Team.

Es gibt Werkzeuge, die .env-Dateien prüfen. Die haben alle dasselbe Problem: Sie kennen nur die Datei, nicht das Programm. missing-env geht den umgekehrten Weg. Es liest den Quellcode, sammelt jeden Lesezugriff auf eine Umgebungsvariable und vergleicht diese Liste gegen .env und .env.example. Fehlt eine Pflichtvariable, ist der Exit-Code 1 — damit taugt der Aufruf als Gate in der CI, nicht nur als Bericht für Menschen.

Der Grund, warum das kein grep ist, steht in einer einzigen Datei: dem Lexer. Ein auskommentierter Zugriff ist keiner, ein Vorkommen in einem String-Literal ist keiner, der Code in einem Template-Loch dagegen schon. Kommentare werden dabei mit Leerzeichen überschrieben statt gelöscht, damit Byte-Offsets und Zeilennummern exakt bleiben — eine Fundstelle, die auf die falsche Zeile zeigt, ist schlimmer als keine. Dieselbe Schicht kennt Python-Docstrings, verschachtelte Rust-Blockkommentare und den Unterschied zwischen '…' und "…" in Shell-Skripten, wo in doppelten Anführungszeichen ${VAR} weiterhin Code ist.

Die zentrale Entscheidung ist, was überhaupt Pflicht ist. Dafür gibt es genau eine Regel: Ein Lesezugriff ist optional, wenn die Aufrufstelle einen Ersatzwert liefert. process.env.DATABASE_URL ist Pflicht, process.env.LOG_LEVEL ?? 'info' nicht. Zwei Ausnahmen gelten auch ohne Default als optional, weil die API den Aufrufer zwingt, das Fehlen zu behandeln: Gos os.LookupEnv und Rusts option_env!. Diese Einheitlichkeit war Absicht — eine Regel, die sich in einer Zeile README erklären lässt, wird auch geglaubt.

Erkannt wird das in elf Sprachfamilien: JavaScript samt TypeScript, Python, Go, Ruby, PHP, Java samt Kotlin und Scala, C#, Rust, Shell-Skripte, YAML inklusive docker-compose und Dockerfiles. Das Ganze sind 1.774 Zeilen Quellcode gegen 748 Zeilen Tests, null Laufzeit-Abhängigkeiten und als Entwicklungsabhängigkeiten nur TypeScript, Prettier und die Node-Typen. Das Paket liegt unter MIT auf npm, eine GitHub Action verpackt denselben Aufruf für Pipelines, und die CI prüft Node 20, 22 und 24.

Den peinlichsten Fehler fand das Werkzeug an sich selbst: Eigene ignore-Angaben ersetzten die Standard-Ausschlüsse, statt sie zu ergänzen — womit plötzlich node_modules mitgescannt wurde. Ein Linter, den man auf sein eigenes Repository loslässt, ist die ehrlichste Testmethode, die es für so ein Werkzeug gibt.

Beispiele

Warum kein 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

Ein grep findet alle drei Zeilen, ein echter Lesezugriff ist nur die letzte. Deshalb läuft jede Datei zuerst durch einen Lexer: Kommentare fallen weg, Treffer in Strings zählen nicht, der Code in einem Template-Loch dagegen schon.

Pflicht oder optional

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

Eine Regel für alle Sprachen: Ein Lesezugriff ist optional, wenn die Aufrufstelle einen Ersatzwert liefert. Alles ohne Rückfallwert ist Pflicht — und genau das lässt den Build scheitern.

Dieselbe Regel, anderes Ökosystem

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

Zwei APIs gelten auch ohne Default als optional, weil sie den Aufrufer zwingen, das Fehlen zu behandeln: Gos os.LookupEnv und Rusts option_env!.

Als Gate in der CI

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

Zwei Zeilen. Der Schritt schlägt fehl, sobald eine Pflichtvariable nirgends gesetzt ist, und schreibt die Fundstellen als Tabelle in die Job-Zusammenfassung. Ohne GitHub tut `npx missing-env --quiet` dasselbe.

Die Beispieldatei ehrlich halten

.env
$ npx missing-env --sync-example  wrote 4 variables to .env.example # src/billing.ts:3STRIPE_SECRET_KEY=# worker/reports.py:4BATCH_SIZE=

Ergänzt jede undokumentierte Variable samt Fundstelle. Werte werden nie aus der .env übernommen — eine Beispieldatei landet im Git, und genau so kommen Zugangsdaten in die Versionsgeschichte.

Als Bibliothek

typescript
import { check } from 'missing-env' const report = await check({ root: process.cwd() })const blocking = report.findings.filter((f) => f.severity === 'error')

Dieselbe Prüfung als Funktion, wenn die Ausgabe woanders hinsoll. Wer die Bausteine einzeln braucht, bekommt scanProject, parseEnv, diagnose und render auch getrennt. `--json` gibt denselben Bericht auf der Kommandozeile aus.