@rinorhatashi/envguard 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +350 -0
  3. package/dist/index.js +659 -0
  4. package/package.json +65 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Rinor Hatashi
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,350 @@
1
+ <p align="center">
2
+ <picture>
3
+ <source media="(prefers-color-scheme: dark)" srcset="assets/envguard-logo-dark.svg" />
4
+ <img src="assets/envguard-logo-light.svg" alt="EnvGuard" width="440" />
5
+ </picture>
6
+ </p>
7
+
8
+ <p align="center">
9
+ <strong>Know that every environment variable your code needs is configured in every environment — before you deploy.</strong>
10
+ </p>
11
+
12
+ <p align="center">
13
+ EnvGuard statically analyzes your codebase to inventory every environment variable it reads, then cross-references that list against what each of your environments actually provides — surfacing <em>missing</em> variables, staging/production <em>parity drift</em>, and <em>dead</em> configuration no code uses anymore.
14
+ </p>
15
+
16
+ <p align="center">
17
+ <a href="https://www.npmjs.com/package/@rinorhatashi/envguard"><img src="https://img.shields.io/npm/v/@rinorhatashi/envguard" alt="npm version" /></a>
18
+ <a href="https://github.com/rinorhatashi/EnvGuard/actions/workflows/ci.yml"><img src="https://github.com/rinorhatashi/EnvGuard/actions/workflows/ci.yml/badge.svg" alt="CI status" /></a>
19
+ <a href="./LICENSE"><img src="https://img.shields.io/npm/l/@rinorhatashi/envguard" alt="license" /></a>
20
+ <img src="https://img.shields.io/node/v/@rinorhatashi/envguard" alt="node version" />
21
+ </p>
22
+
23
+ <p align="center">
24
+ <a href="#what-it-does">What it does</a> •
25
+ <a href="#how-it-works">How it works</a> •
26
+ <a href="#runs-entirely-on-your-machine">Local &amp; private</a> •
27
+ <a href="#install">Install</a> •
28
+ <a href="#configuration">Configuration</a> •
29
+ <a href="#github-action">GitHub Action</a>
30
+ </p>
31
+
32
+ ---
33
+
34
+ ## The problem
35
+
36
+ "It works on staging but breaks in production" is one of the most common — and most avoidable — deployment failures, and environment misconfiguration is a leading cause. The problem is structural:
37
+
38
+ - Your **code** declares what it needs: `process.env.STRIPE_WEBHOOK_SECRET`, `os.environ["DATABASE_URL"]`.
39
+ - Your **infrastructure** declares what it has: secrets in AWS, Vercel, Doppler, a `.env` file.
40
+ - **Nothing checks that the two lists agree.**
41
+
42
+ So variables drift out of sync. One gets added to production at 3am during an incident and never backfilled to staging. One gets deleted from the code but lingers in every secrets manager for years. A new service ships to production missing a variable nobody knew to add.
43
+
44
+ Existing tools don't close this gap. Dotenv linters (`dotenv-linter`, `dotenv-safe`) only compare a single local `.env` against `.env.example`. Infrastructure drift tools (`driftctl`, Terragrunt) only compare cloud resources against Terraform state — not application-level variable usage. **EnvGuard is the missing link between what the code reads and what every environment provides.**
45
+
46
+ ## What it does
47
+
48
+ EnvGuard reduces the entire question — *"is every environment configured correctly for this code?"* — to one command, `envguard scan`, and one report with four verdicts:
49
+
50
+ | Status | Meaning | The failure it prevents |
51
+ | :--- | :--- | :--- |
52
+ | 🔴 **`MISSING`** | The code reads it, but **no** environment provides it. | A guaranteed crash the moment that code path runs. |
53
+ | 🟡 **`PARITY`** | Present in **some** environments but not others. | The classic "works on staging, breaks in production" drift. |
54
+ | ⚪ **`DEAD`** | Configured in an environment but **no** code reads it. | Secret sprawl and config rot — stale values that mislead. |
55
+ | 🟢 `OK` | Read by the code and present everywhere. | Nothing to do. |
56
+
57
+ Run it locally before a deploy, or wire up the [GitHub Action](#github-action) to post the report on every pull request and fail the build when something is missing or drifting.
58
+
59
+ ## How it works
60
+
61
+ EnvGuard is a three-stage pipeline. It performs **pure static analysis** — your code is never executed, so the scan is fast and safe to run anywhere, including untrusted CI.
62
+
63
+ ```mermaid
64
+ flowchart LR
65
+ src["Source code"] --> scan["Scanner<br/>language-aware patterns"]
66
+ scan --> expected["Expected vars<br/>what the code reads"]
67
+ envs["Environments<br/>.env files · secrets managers"] --> resolve["Resolver<br/>source adapters"]
68
+ resolve --> provided["Provided vars<br/>what each env has"]
69
+ expected --> compare{"Compare"}
70
+ provided --> compare
71
+ compare --> report["Report<br/>MISSING · PARITY · DEAD · OK"]
72
+ ```
73
+
74
+ **1. Scan — build the manifest of what the code expects.**
75
+ EnvGuard walks your source tree and applies a set of language-aware patterns to every supported file, extracting each environment-variable reference together with its file and line number. There's no build step and nothing is run; it simply reads source. The output is the *expected* manifest — the complete set of variables your code depends on.
76
+
77
+ **2. Resolve — find out what each environment provides.**
78
+ For every environment you configure, a **source adapter** reports the set of variable *names* that environment supplies. The built-in `dotenv` adapter reads the keys from a `.env` file; planned adapters query secrets managers like AWS SSM, Vercel, and Doppler. EnvGuard only ever reads variable **names — never secret values**. With no configuration at all, it auto-discovers local `.env.*` files and treats each as an environment.
79
+
80
+ **3. Compare — cross-reference and classify.**
81
+ EnvGuard builds a matrix of *what the code needs* against *what each environment has* and assigns every variable a status. `ignore` rules in your config demote intentional cases (an environment-specific flag, an allowed legacy var) to `OK`, and the `failOn` setting decides which statuses cause a non-zero exit so CI can gate on them.
82
+
83
+ Each variable lands in exactly one bucket:
84
+
85
+ ```mermaid
86
+ flowchart TD
87
+ start["Each variable"] --> incode{"Read by<br/>any code?"}
88
+ incode -- no --> dead["DEAD<br/>configured but unused"]
89
+ incode -- yes --> provided{"Provided by<br/>environments?"}
90
+ provided -- "none of them" --> missing["MISSING<br/>code needs it, nothing has it"]
91
+ provided -- "some, not all" --> parity["PARITY<br/>drift across environments"]
92
+ provided -- "all of them" --> ok["OK"]
93
+ ```
94
+
95
+ The report is then rendered three ways — a colored terminal table for humans, JSON for tooling, and Markdown for pull-request comments.
96
+
97
+ ## Runs entirely on your machine
98
+
99
+ EnvGuard is local-first: **no hosted service, no account, no telemetry.** The `envguard` CLI makes **no network calls** — it reads your source and `.env` files from disk, analyzes them in-process, and writes a report. Nothing about your code or configuration is uploaded anywhere, because there's no reason for it to be.
100
+
101
+ ```mermaid
102
+ flowchart LR
103
+ subgraph local["Your machine / CI runner — everything happens here"]
104
+ direction LR
105
+ code["Source files"] --> eg["envguard CLI"]
106
+ cfg[".env files and envguard.yml"] --> eg
107
+ eg --> report["Report:<br/>terminal · JSON · Markdown"]
108
+ end
109
+ eg x--x net(["External servers · telemetry"])
110
+ ```
111
+
112
+ - **Only names, never values.** EnvGuard compares variable *names*. It never reads, stores, or prints the secret values inside your `.env` files.
113
+ - **Future cloud sources stay direct.** Planned adapters (AWS SSM, Vercel, Doppler, …) will talk **directly** to your own provider with your own credentials to list variable names — still no third-party server in the middle, and still names only.
114
+
115
+ ## Install
116
+
117
+ ```bash
118
+ npm install -g @rinorhatashi/envguard
119
+ ```
120
+
121
+ …or run it without installing:
122
+
123
+ ```bash
124
+ npx @rinorhatashi/envguard scan
125
+ ```
126
+
127
+ Requires **Node.js 18 or newer**.
128
+
129
+ ## Quick start
130
+
131
+ From a project that has `.env.staging` and `.env.production` files, EnvGuard works with **zero configuration** — it discovers them automatically:
132
+
133
+ ```bash
134
+ envguard scan
135
+ ```
136
+
137
+ Point it at a specific directory and/or config file:
138
+
139
+ ```bash
140
+ envguard scan ./services/api --config envguard.yml
141
+ ```
142
+
143
+ ### Try the bundled demo
144
+
145
+ The repository ships a demo with source files in all five supported languages and two diverging environments:
146
+
147
+ ```bash
148
+ git clone https://github.com/rinorhatashi/EnvGuard
149
+ cd EnvGuard
150
+ npm install && npm run build
151
+ node dist/index.js scan examples/demo
152
+ ```
153
+
154
+ ## Example output
155
+
156
+ ```text
157
+ EnvGuard · scanned 5 files · 2 environments (staging, production)
158
+
159
+ VARIABLE CODE staging production STATUS
160
+ STRIPE_WEBHOOK_SECRET ✓ ✗ ✗ MISSING
161
+ API_KEY ✓ ✓ ✗ PARITY
162
+ DEBUG – ✓ ✗ DEAD
163
+ LEGACY_TOKEN – ✓ ✓ DEAD
164
+ DATABASE_URL ✓ ✓ ✓ OK
165
+ REDIS_URL ✓ ✓ ✓ OK
166
+ SENDGRID_API_KEY ✓ ✓ ✓ OK
167
+
168
+ 1 missing · 1 parity · 2 dead · 3 ok
169
+
170
+ MISSING STRIPE_WEBHOOK_SECRET — read at src/billing.ts:3, not set in any environment
171
+ PARITY API_KEY — set in staging; missing in production (read at app/server.py:5)
172
+ DEAD DEBUG — set in staging but not read by any scanned file
173
+ DEAD LEGACY_TOKEN — set in staging, production but not read by any scanned file
174
+ ```
175
+
176
+ The same report, as machine-readable JSON or as a Markdown PR comment:
177
+
178
+ ```bash
179
+ envguard scan --format json
180
+ envguard scan --format markdown
181
+ ```
182
+
183
+ ## Supported languages
184
+
185
+ Each language is matched with its own idiomatic access patterns. Adding a language is a small, self-contained change to [`src/scan/patterns.ts`](src/scan/patterns.ts).
186
+
187
+ | Language | Extensions | Detected forms |
188
+ | :--- | :--- | :--- |
189
+ | Node.js / TypeScript | `.js` `.jsx` `.mjs` `.cjs` `.ts` `.tsx` `.mts` `.cts` | `process.env.X`, `process.env['X']`, `import.meta.env.X` |
190
+ | Python | `.py` | `os.environ['X']`, `os.environ.get('X')`, `os.getenv('X')` |
191
+ | Ruby | `.rb` `.erb` `.rake` | `ENV['X']`, `ENV.fetch('X')` |
192
+ | Go | `.go` | `os.Getenv("X")`, `os.LookupEnv("X")` |
193
+ | Rust | `.rs` | `std::env::var("X")`, `env::var("X")`, `std::env::var_os("X")` |
194
+
195
+ ## Configuration
196
+
197
+ EnvGuard runs with zero config, but an `envguard.yml` at your project root unlocks declared environments, ignore rules, and CI behavior. **Every field is optional.**
198
+
199
+ ```yaml
200
+ # Which environments to compare, and where to read each one's variable names.
201
+ environments:
202
+ staging:
203
+ source: dotenv
204
+ path: .env.staging
205
+ production:
206
+ source: dotenv
207
+ path: .env.production
208
+
209
+ # Statuses that make `envguard scan` exit non-zero. Defaults to MISSING + PARITY.
210
+ failOn:
211
+ - MISSING
212
+ - PARITY
213
+
214
+ # Silence intentional findings by listing variable names.
215
+ ignore:
216
+ parity:
217
+ - DEBUG # intentionally environment-specific — don't flag drift
218
+ dead:
219
+ - LEGACY_TOKEN # allowed legacy var during a cleanup sprint
220
+ missing:
221
+ - OPTIONAL_VAR # read by code but not required in any environment
222
+
223
+ # Narrow what gets scanned. Defaults to every supported file under the root.
224
+ scan:
225
+ include:
226
+ - "src/**"
227
+ - "app/**"
228
+ exclude:
229
+ - "**/*.test.ts"
230
+ ```
231
+
232
+ If `environments` is omitted, EnvGuard auto-discovers them from local `.env.*` files (`.env` → `local`, `.env.staging` → `staging`, …), skipping `.example`, `.sample`, and `.local` files.
233
+
234
+ ## Sources
235
+
236
+ A **source** answers a single question: *"which variables does this environment provide?"* Adapters implement one small interface ([`src/sources/types.ts`](src/sources/types.ts)) and only ever read variable **names**, never secret values — so coverage grows without touching the core.
237
+
238
+ | Source | Status | Configuration |
239
+ | :--- | :--- | :--- |
240
+ | `dotenv` — local `.env` files | ✅ Available | `path: .env.<environment>` |
241
+ | AWS SSM Parameter Store | 🔜 Planned | — |
242
+ | GitHub Actions secrets | 🔜 Planned | — |
243
+ | Vercel | 🔜 Planned | — |
244
+ | Doppler | 🔜 Planned | — |
245
+ | Railway / Render | 🔜 Planned | — |
246
+
247
+ ## CLI reference
248
+
249
+ ```
250
+ envguard scan [path]
251
+
252
+ Arguments:
253
+ path directory to scan (default: ".")
254
+
255
+ Options:
256
+ -c, --config <file> path to an envguard.yml config file
257
+ -f, --format <format> output format: table | json | markdown (default: table)
258
+ -o, --output <file> write the report to a file instead of stdout
259
+ --fail-on <list> statuses that cause a non-zero exit:
260
+ missing, parity, dead, none (default: missing,parity)
261
+ --no-color disable colored output
262
+ -v, --version print the EnvGuard version
263
+ -h, --help show help
264
+ ```
265
+
266
+ ### Exit codes
267
+
268
+ EnvGuard is built to gate a pipeline:
269
+
270
+ | Code | Meaning |
271
+ | :---: | :--- |
272
+ | `0` | No findings in the `fail-on` set. |
273
+ | `1` | At least one finding in the `fail-on` set (e.g. a `MISSING` or `PARITY` variable). |
274
+ | `2` | EnvGuard errored (bad config, unreadable path, …). |
275
+
276
+ ## GitHub Action
277
+
278
+ Run EnvGuard on every pull request. It posts the report as a comment that updates in place on each push:
279
+
280
+ ```mermaid
281
+ sequenceDiagram
282
+ participant PR as Pull request
283
+ participant CI as GitHub Actions
284
+ participant EG as envguard CLI
285
+ PR->>CI: push / open PR
286
+ CI->>EG: envguard scan
287
+ EG-->>CI: report + exit code
288
+ CI->>PR: create or update the report comment
289
+ CI-->>CI: fail the check if MISSING / PARITY
290
+ ```
291
+
292
+ Copy [`examples/github-workflow.yml`](examples/github-workflow.yml) to `.github/workflows/envguard.yml`:
293
+
294
+ ```yaml
295
+ name: EnvGuard
296
+ on:
297
+ pull_request:
298
+ push:
299
+ branches: [main]
300
+
301
+ jobs:
302
+ envguard:
303
+ runs-on: ubuntu-latest
304
+ permissions:
305
+ contents: read
306
+ pull-requests: write # required to post the report comment
307
+ steps:
308
+ - uses: actions/checkout@v4
309
+ - uses: rinorhatashi/EnvGuard@v1
310
+ with:
311
+ working-directory: .
312
+ # config: envguard.yml
313
+ # fail-on: missing,parity
314
+ ```
315
+
316
+ When a `fail-on` status is present, the job fails — turning environment drift into a red check instead of a 3am page.
317
+
318
+ | Input | Description | Default |
319
+ | :--- | :--- | :--- |
320
+ | `working-directory` | Directory to scan. | `.` |
321
+ | `config` | Path to an `envguard.yml`. | _(auto)_ |
322
+ | `fail-on` | Statuses that fail the job. | _(from config / defaults)_ |
323
+ | `comment` | Post/update the PR comment. | `true` |
324
+ | `version` | `envguard` npm version to run. | `latest` |
325
+ | `github-token` | Token used to post the comment. | `${{ github.token }}` |
326
+
327
+ ## Roadmap
328
+
329
+ - Source adapters for AWS SSM, GitHub Actions secrets, Vercel, Doppler, Railway, and Render.
330
+ - Destructuring (`const { FOO } = process.env`) and comment-aware scanning.
331
+ - SARIF output for GitHub code scanning, and a `--baseline` file to accept existing findings.
332
+ - More languages (Java, PHP, .NET, Elixir).
333
+
334
+ ## Contributing
335
+
336
+ EnvGuard is designed to be extended in small, isolated pieces:
337
+
338
+ - **Add a language** — append a pattern set to [`src/scan/patterns.ts`](src/scan/patterns.ts) and a test to [`test/extract.test.ts`](test/extract.test.ts).
339
+ - **Add a source** — implement the `SourceAdapter` interface and register it in [`src/sources/index.ts`](src/sources/index.ts).
340
+
341
+ ```bash
342
+ npm install
343
+ npm test # run the test suite
344
+ npm run build # compile to dist/
345
+ npm run dev -- scan examples/demo # run straight from source
346
+ ```
347
+
348
+ ## License
349
+
350
+ [MIT](./LICENSE) © Rinor Hatashi
package/dist/index.js ADDED
@@ -0,0 +1,659 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import { readFileSync } from "fs";
5
+ import { Command } from "commander";
6
+
7
+ // src/commands/scan.ts
8
+ import path4 from "path";
9
+ import { writeFile } from "fs/promises";
10
+ import { createColors } from "picocolors";
11
+
12
+ // src/scan/index.ts
13
+ import { readFile } from "fs/promises";
14
+ import path from "path";
15
+
16
+ // src/scan/walk.ts
17
+ import fg from "fast-glob";
18
+
19
+ // src/scan/patterns.ts
20
+ var IDENT = "[A-Za-z_][A-Za-z0-9_]*";
21
+ var KEY = `[^'"]+`;
22
+ var LANGUAGE_PATTERNS = [
23
+ {
24
+ language: "javascript",
25
+ extensions: [".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx", ".mts", ".cts"],
26
+ patterns: [
27
+ // Dot access: process.env.FOO
28
+ new RegExp(`process\\.env\\.(${IDENT})`, "g"),
29
+ // Bracket access: process.env['FOO'] / process.env["FOO"]
30
+ new RegExp(`process\\.env\\[\\s*['"](${KEY})['"]\\s*\\]`, "g"),
31
+ // Vite-style: import.meta.env.FOO
32
+ new RegExp(`import\\.meta\\.env\\.(${IDENT})`, "g")
33
+ ]
34
+ },
35
+ {
36
+ language: "python",
37
+ extensions: [".py"],
38
+ patterns: [
39
+ // os.environ['FOO'] / environ["FOO"]
40
+ new RegExp(`(?:os\\.)?environ\\[\\s*['"](${KEY})['"]\\s*\\]`, "g"),
41
+ // os.environ.get('FOO') / environ.get("FOO")
42
+ new RegExp(`(?:os\\.)?environ\\.get\\(\\s*['"](${KEY})['"]`, "g"),
43
+ // os.getenv('FOO') / getenv("FOO")
44
+ new RegExp(`(?:os\\.)?getenv\\(\\s*['"](${KEY})['"]`, "g")
45
+ ]
46
+ },
47
+ {
48
+ language: "ruby",
49
+ extensions: [".rb", ".erb", ".rake"],
50
+ patterns: [
51
+ // ENV['FOO'] / ENV["FOO"]
52
+ new RegExp(`ENV\\[\\s*['"](${KEY})['"]\\s*\\]`, "g"),
53
+ // ENV.fetch('FOO') / ENV.fetch("FOO")
54
+ new RegExp(`ENV\\.fetch\\(\\s*['"](${KEY})['"]`, "g")
55
+ ]
56
+ },
57
+ {
58
+ language: "go",
59
+ extensions: [".go"],
60
+ patterns: [
61
+ // os.Getenv("FOO")
62
+ new RegExp(`os\\.Getenv\\(\\s*"(${KEY})"\\s*\\)`, "g"),
63
+ // os.LookupEnv("FOO")
64
+ new RegExp(`os\\.LookupEnv\\(\\s*"(${KEY})"\\s*\\)`, "g")
65
+ ]
66
+ },
67
+ {
68
+ language: "rust",
69
+ extensions: [".rs"],
70
+ patterns: [
71
+ // std::env::var("FOO") / env::var("FOO") / std::env::var_os("FOO")
72
+ new RegExp(`(?:std::)?env::var(?:_os)?\\(\\s*"(${KEY})"\\s*\\)`, "g")
73
+ ]
74
+ }
75
+ ];
76
+ var ALL_EXTENSIONS = [
77
+ ...new Set(LANGUAGE_PATTERNS.flatMap((p) => p.extensions))
78
+ ];
79
+ function patternsForExtension(ext) {
80
+ const out = [];
81
+ for (const lp of LANGUAGE_PATTERNS) {
82
+ if (lp.extensions.includes(ext)) out.push(...lp.patterns);
83
+ }
84
+ return out;
85
+ }
86
+
87
+ // src/scan/walk.ts
88
+ var DEFAULT_EXCLUDE = [
89
+ "**/node_modules/**",
90
+ "**/.git/**",
91
+ "**/dist/**",
92
+ "**/build/**",
93
+ "**/.next/**",
94
+ "**/coverage/**",
95
+ "**/vendor/**",
96
+ "**/target/**",
97
+ // Rust build output
98
+ "**/.venv/**",
99
+ "**/venv/**",
100
+ "**/__pycache__/**"
101
+ ];
102
+ async function walkSourceFiles(root, opts = {}) {
103
+ const include = opts.include && opts.include.length > 0 ? opts.include : ALL_EXTENSIONS.map((ext) => `**/*${ext}`);
104
+ const ignore = [...DEFAULT_EXCLUDE, ...opts.exclude ?? []];
105
+ return fg(include, {
106
+ cwd: root,
107
+ ignore,
108
+ onlyFiles: true,
109
+ dot: false,
110
+ absolute: false,
111
+ followSymbolicLinks: false,
112
+ suppressErrors: true
113
+ });
114
+ }
115
+
116
+ // src/scan/extract.ts
117
+ function extractFromContent(content, ext, relPath) {
118
+ const found = /* @__PURE__ */ new Map();
119
+ const patterns = patternsForExtension(ext);
120
+ if (patterns.length === 0) return found;
121
+ const lines = content.split(/\r?\n/);
122
+ for (let i = 0; i < lines.length; i++) {
123
+ const line = lines[i] ?? "";
124
+ for (const re of patterns) {
125
+ for (const match of line.matchAll(re)) {
126
+ const name = match[1];
127
+ if (!name) continue;
128
+ const refs = found.get(name) ?? [];
129
+ refs.push({ file: relPath, line: i + 1 });
130
+ found.set(name, refs);
131
+ }
132
+ }
133
+ }
134
+ return found;
135
+ }
136
+
137
+ // src/scan/index.ts
138
+ async function scanCode(root, opts = {}) {
139
+ const files = (await walkSourceFiles(root, opts)).filter(
140
+ (f) => ALL_EXTENSIONS.includes(path.extname(f))
141
+ );
142
+ const vars = /* @__PURE__ */ new Map();
143
+ let filesScanned = 0;
144
+ for (const rel of files) {
145
+ let content;
146
+ try {
147
+ content = await readFile(path.join(root, rel), "utf8");
148
+ } catch {
149
+ continue;
150
+ }
151
+ filesScanned++;
152
+ const fileVars = extractFromContent(content, path.extname(rel), rel);
153
+ for (const [name, refs] of fileVars) {
154
+ const existing = vars.get(name) ?? [];
155
+ existing.push(...refs);
156
+ vars.set(name, existing);
157
+ }
158
+ }
159
+ return { vars, filesScanned };
160
+ }
161
+
162
+ // src/config/load.ts
163
+ import { readFile as readFile2, readdir } from "fs/promises";
164
+ import { existsSync } from "fs";
165
+ import path2 from "path";
166
+ import { parse as parseYaml } from "yaml";
167
+ var CONFIG_NAMES = ["envguard.yml", "envguard.yaml"];
168
+ async function loadConfig(root, explicitPath) {
169
+ let configPath;
170
+ if (explicitPath) {
171
+ configPath = path2.isAbsolute(explicitPath) ? explicitPath : path2.join(root, explicitPath);
172
+ if (!existsSync(configPath)) {
173
+ throw new Error(`config file not found: ${explicitPath}`);
174
+ }
175
+ } else {
176
+ for (const name of CONFIG_NAMES) {
177
+ const candidate = path2.join(root, name);
178
+ if (existsSync(candidate)) {
179
+ configPath = candidate;
180
+ break;
181
+ }
182
+ }
183
+ }
184
+ if (configPath) {
185
+ const raw = await readFile2(configPath, "utf8");
186
+ const config = parseYaml(raw) ?? {};
187
+ if (!config.environments || Object.keys(config.environments).length === 0) {
188
+ config.environments = await discoverDotenvEnvironments(root);
189
+ return { config, configPath, autoDiscovered: true };
190
+ }
191
+ return { config, configPath, autoDiscovered: false };
192
+ }
193
+ const environments = await discoverDotenvEnvironments(root);
194
+ return { config: { environments }, autoDiscovered: true };
195
+ }
196
+ async function discoverDotenvEnvironments(root) {
197
+ let entries;
198
+ try {
199
+ entries = await readdir(root);
200
+ } catch {
201
+ return {};
202
+ }
203
+ const environments = {};
204
+ for (const file of entries.sort()) {
205
+ if (!file.startsWith(".env")) continue;
206
+ if (/\.(example|sample|local)$/.test(file)) continue;
207
+ let name;
208
+ if (file === ".env") name = "local";
209
+ else if (file.startsWith(".env.")) name = file.slice(".env.".length);
210
+ else continue;
211
+ if (!name) continue;
212
+ environments[name] = { source: "dotenv", path: file };
213
+ }
214
+ return environments;
215
+ }
216
+
217
+ // src/sources/dotenv.ts
218
+ import { readFile as readFile3 } from "fs/promises";
219
+ import path3 from "path";
220
+ import dotenv from "dotenv";
221
+ var dotenvAdapter = {
222
+ id: "dotenv",
223
+ async load(envName, config, root) {
224
+ const rel = config.path ?? `.env.${envName}`;
225
+ const abs = path3.isAbsolute(rel) ? rel : path3.join(root, rel);
226
+ try {
227
+ const content = await readFile3(abs, "utf8");
228
+ const parsed = dotenv.parse(content);
229
+ return {
230
+ name: envName,
231
+ source: "dotenv",
232
+ origin: rel,
233
+ vars: new Set(Object.keys(parsed)),
234
+ available: true
235
+ };
236
+ } catch {
237
+ return {
238
+ name: envName,
239
+ source: "dotenv",
240
+ origin: rel,
241
+ vars: /* @__PURE__ */ new Set(),
242
+ available: false
243
+ };
244
+ }
245
+ }
246
+ };
247
+
248
+ // src/sources/index.ts
249
+ var ADAPTERS = {
250
+ [dotenvAdapter.id]: dotenvAdapter
251
+ };
252
+ async function resolveEnvironments(config, root) {
253
+ const warnings = [];
254
+ const environments = [];
255
+ for (const [name, envCfg] of Object.entries(config.environments ?? {})) {
256
+ const sourceId = envCfg.source ?? "dotenv";
257
+ const adapter = ADAPTERS[sourceId];
258
+ if (!adapter) {
259
+ warnings.push(
260
+ `environment "${name}": unknown source "${sourceId}" \u2014 skipping`
261
+ );
262
+ continue;
263
+ }
264
+ const env = await adapter.load(name, envCfg, root);
265
+ if (!env.available) {
266
+ warnings.push(
267
+ `environment "${name}": could not read ${env.origin} \u2014 skipping`
268
+ );
269
+ continue;
270
+ }
271
+ environments.push(env);
272
+ }
273
+ return { environments, warnings };
274
+ }
275
+
276
+ // src/compare/index.ts
277
+ var STATUS_ORDER = {
278
+ MISSING: 0,
279
+ PARITY: 1,
280
+ DEAD: 2,
281
+ OK: 3
282
+ };
283
+ function buildReport(code, environments, config, root, generatedAt, warnings = []) {
284
+ const ignoreParity = new Set(config.ignore?.parity ?? []);
285
+ const ignoreDead = new Set(config.ignore?.dead ?? []);
286
+ const ignoreMissing = new Set(config.ignore?.missing ?? []);
287
+ const totalEnvs = environments.length;
288
+ const allVars = /* @__PURE__ */ new Set();
289
+ for (const name of code.vars.keys()) allVars.add(name);
290
+ for (const env of environments) for (const name of env.vars) allVars.add(name);
291
+ const findings = [];
292
+ for (const name of allVars) {
293
+ const inCode = code.vars.has(name);
294
+ const presence = {};
295
+ let presentCount = 0;
296
+ for (const env of environments) {
297
+ const has = env.vars.has(name);
298
+ presence[env.name] = has;
299
+ if (has) presentCount++;
300
+ }
301
+ let status;
302
+ if (!inCode) {
303
+ status = ignoreDead.has(name) ? "OK" : "DEAD";
304
+ } else if (totalEnvs > 0 && presentCount === 0) {
305
+ status = ignoreMissing.has(name) ? "OK" : "MISSING";
306
+ } else if (totalEnvs > 0 && presentCount < totalEnvs) {
307
+ status = ignoreParity.has(name) ? "OK" : "PARITY";
308
+ } else {
309
+ status = "OK";
310
+ }
311
+ findings.push({
312
+ name,
313
+ status,
314
+ inCode,
315
+ references: code.vars.get(name) ?? [],
316
+ presence
317
+ });
318
+ }
319
+ findings.sort(
320
+ (a, b) => STATUS_ORDER[a.status] - STATUS_ORDER[b.status] || a.name.localeCompare(b.name)
321
+ );
322
+ const summary = {
323
+ MISSING: 0,
324
+ PARITY: 0,
325
+ DEAD: 0,
326
+ OK: 0
327
+ };
328
+ for (const f of findings) summary[f.status]++;
329
+ return {
330
+ generatedAt,
331
+ root,
332
+ environments: environments.map((e) => e.name),
333
+ summary,
334
+ findings,
335
+ warnings
336
+ };
337
+ }
338
+
339
+ // src/report/table.ts
340
+ var cell = (text, render) => ({
341
+ text,
342
+ render: render ?? text
343
+ });
344
+ function renderTable(report, opts) {
345
+ const c = opts.colors;
346
+ const lines = [];
347
+ const envCount = report.environments.length;
348
+ const envList = envCount > 0 ? report.environments.join(", ") : "none";
349
+ const fileWord = opts.filesScanned === 1 ? "file" : "files";
350
+ const envWord = envCount === 1 ? "environment" : "environments";
351
+ lines.push(
352
+ c.bold("EnvGuard") + c.dim(
353
+ ` \xB7 scanned ${opts.filesScanned} ${fileWord} \xB7 ${envCount} ${envWord} (${envList})`
354
+ )
355
+ );
356
+ lines.push("");
357
+ if (report.findings.length === 0) {
358
+ lines.push(
359
+ c.dim("No environment variables found in code or environments.")
360
+ );
361
+ return lines.join("\n");
362
+ }
363
+ if (envCount === 0) {
364
+ lines.push(
365
+ c.yellow("No environments configured.") + c.dim(
366
+ " Add .env.<environment> files or an envguard.yml to compare against."
367
+ )
368
+ );
369
+ lines.push("");
370
+ lines.push(c.dim(`Variables referenced in code (${report.findings.length}):`));
371
+ for (const f of report.findings) {
372
+ const ref = f.references[0];
373
+ const loc = ref ? c.dim(` ${ref.file}:${ref.line}`) : "";
374
+ lines.push(` ${f.name}${loc}`);
375
+ }
376
+ return lines.join("\n");
377
+ }
378
+ const header = [
379
+ cell("VARIABLE"),
380
+ cell("CODE"),
381
+ ...report.environments.map((e) => cell(e)),
382
+ cell("STATUS")
383
+ ];
384
+ const aligns = [
385
+ "left",
386
+ "center",
387
+ ...report.environments.map(() => "center"),
388
+ "left"
389
+ ];
390
+ const rows = [header];
391
+ for (const f of report.findings) {
392
+ const nameCell = f.status === "OK" ? cell(f.name, c.dim(f.name)) : cell(f.name);
393
+ const codeCell = f.inCode ? cell("\u2713", c.cyan("\u2713")) : cell("\u2013", c.dim("\u2013"));
394
+ const envCells = report.environments.map(
395
+ (e) => f.presence[e] ? cell("\u2713", c.green("\u2713")) : cell("\u2717", c.red("\u2717"))
396
+ );
397
+ rows.push([nameCell, codeCell, ...envCells, statusCell(f.status, c)]);
398
+ }
399
+ const widths = header.map(
400
+ (_, col) => Math.max(...rows.map((r) => r[col].text.length))
401
+ );
402
+ const headerRow = rows[0].map((cl) => cell(cl.text, c.dim(cl.text)));
403
+ lines.push(renderRow(headerRow, widths, aligns));
404
+ for (let i = 1; i < rows.length; i++) {
405
+ lines.push(renderRow(rows[i], widths, aligns));
406
+ }
407
+ lines.push("");
408
+ lines.push(summaryLine(report, c));
409
+ const details = renderDetails(report, c);
410
+ if (details.length > 0) {
411
+ lines.push("");
412
+ lines.push(...details);
413
+ }
414
+ if (report.warnings.length > 0) {
415
+ lines.push("");
416
+ for (const w of report.warnings) {
417
+ lines.push(c.yellow("warning: ") + w);
418
+ }
419
+ }
420
+ return lines.join("\n");
421
+ }
422
+ function renderRow(cells, widths, aligns) {
423
+ return cells.map((cl, i) => pad(cl, widths[i] ?? cl.text.length, aligns[i] ?? "left")).join(" ").replace(/\s+$/, "");
424
+ }
425
+ function pad(cl, width, align) {
426
+ const space = Math.max(0, width - cl.text.length);
427
+ if (align === "center") {
428
+ const left = Math.floor(space / 2);
429
+ return " ".repeat(left) + cl.render + " ".repeat(space - left);
430
+ }
431
+ return cl.render + " ".repeat(space);
432
+ }
433
+ function statusCell(status, c) {
434
+ switch (status) {
435
+ case "MISSING":
436
+ return cell("MISSING", c.bold(c.red("MISSING")));
437
+ case "PARITY":
438
+ return cell("PARITY", c.yellow("PARITY"));
439
+ case "DEAD":
440
+ return cell("DEAD", c.dim("DEAD"));
441
+ case "OK":
442
+ return cell("OK", c.green("OK"));
443
+ }
444
+ }
445
+ function summaryLine(report, c) {
446
+ const s = report.summary;
447
+ const parts = [
448
+ s.MISSING > 0 ? c.red(`${s.MISSING} missing`) : c.dim(`${s.MISSING} missing`),
449
+ s.PARITY > 0 ? c.yellow(`${s.PARITY} parity`) : c.dim(`${s.PARITY} parity`),
450
+ c.dim(`${s.DEAD} dead`),
451
+ c.green(`${s.OK} ok`)
452
+ ];
453
+ return " " + parts.join(c.dim(" \xB7 "));
454
+ }
455
+ function renderDetails(report, c) {
456
+ const out = [];
457
+ for (const f of report.findings) {
458
+ if (f.status === "OK") continue;
459
+ const first = f.references[0];
460
+ const more = f.references.length > 1 ? c.dim(` (+${f.references.length - 1} more)`) : "";
461
+ const where = first ? c.dim(`${first.file}:${first.line}`) + more : c.dim("\u2014");
462
+ if (f.status === "MISSING") {
463
+ out.push(
464
+ ` ${c.bold(c.red("MISSING"))} ${c.bold(f.name)} \u2014 read at ${where}, not set in any environment`
465
+ );
466
+ } else if (f.status === "PARITY") {
467
+ const has = report.environments.filter((e) => f.presence[e]);
468
+ const missing = report.environments.filter((e) => !f.presence[e]);
469
+ out.push(
470
+ ` ${c.yellow("PARITY")} ${c.bold(f.name)} \u2014 set in ${has.join(", ")}; missing in ${c.red(missing.join(", "))} (read at ${where})`
471
+ );
472
+ } else if (f.status === "DEAD") {
473
+ const has = report.environments.filter((e) => f.presence[e]);
474
+ out.push(
475
+ ` ${c.dim("DEAD")} ${c.bold(f.name)} \u2014 set in ${has.join(", ")} but not read by any scanned file`
476
+ );
477
+ }
478
+ }
479
+ return out;
480
+ }
481
+
482
+ // src/report/markdown.ts
483
+ var MARKDOWN_MARKER = "<!-- envguard-report -->";
484
+ function renderMarkdown(report) {
485
+ const s = report.summary;
486
+ const out = [];
487
+ out.push(MARKDOWN_MARKER);
488
+ out.push("## EnvGuard \u2014 environment variable report");
489
+ out.push("");
490
+ const envText = report.environments.length ? "`" + report.environments.join("`, `") + "`" : "_no environments configured_";
491
+ out.push(
492
+ `**${s.MISSING} missing \xB7 ${s.PARITY} parity drift \xB7 ${s.DEAD} dead \xB7 ${s.OK} ok** across ${envText}.`
493
+ );
494
+ out.push("");
495
+ const missing = report.findings.filter((f) => f.status === "MISSING");
496
+ const parity = report.findings.filter((f) => f.status === "PARITY");
497
+ const dead = report.findings.filter((f) => f.status === "DEAD");
498
+ if (missing.length > 0) {
499
+ out.push("### \u274C Missing \u2014 code reads it, no environment provides it");
500
+ out.push("");
501
+ out.push("| Variable | Read at |");
502
+ out.push("| --- | --- |");
503
+ for (const f of missing) out.push(`| \`${f.name}\` | ${refList(f)} |`);
504
+ out.push("");
505
+ }
506
+ if (parity.length > 0) {
507
+ out.push(
508
+ "### \u26A0\uFE0F Parity drift \u2014 present in some environments, missing in others"
509
+ );
510
+ out.push("");
511
+ out.push(`| Variable | ${report.environments.join(" | ")} | Read at |`);
512
+ out.push(
513
+ `| --- |${report.environments.map(() => " --- |").join("")} --- |`
514
+ );
515
+ for (const f of parity) {
516
+ const cells = report.environments.map((e) => f.presence[e] ? "\u2705" : "\u274C").join(" | ");
517
+ out.push(`| \`${f.name}\` | ${cells} | ${refList(f)} |`);
518
+ }
519
+ out.push("");
520
+ }
521
+ if (dead.length > 0) {
522
+ out.push("### \u{1F9F9} Dead \u2014 configured but no code reads it");
523
+ out.push("");
524
+ out.push("| Variable | Configured in |");
525
+ out.push("| --- | --- |");
526
+ for (const f of dead) {
527
+ const has = report.environments.filter((e) => f.presence[e]).join(", ");
528
+ out.push(`| \`${f.name}\` | ${has} |`);
529
+ }
530
+ out.push("");
531
+ }
532
+ if (missing.length === 0 && parity.length === 0 && dead.length === 0) {
533
+ out.push(
534
+ "\u2705 Every variable the code reads is present in all configured environments, and nothing is configured that the code doesn't read."
535
+ );
536
+ out.push("");
537
+ }
538
+ if (report.warnings.length > 0) {
539
+ out.push("> **Warnings**");
540
+ for (const w of report.warnings) out.push(`> - ${w}`);
541
+ out.push("");
542
+ }
543
+ out.push(
544
+ "<sub>EnvGuard \xB7 run <code>envguard scan</code> locally to reproduce.</sub>"
545
+ );
546
+ return out.join("\n");
547
+ }
548
+ function refList(f) {
549
+ if (f.references.length === 0) return "\u2014";
550
+ const shown = f.references.slice(0, 3).map((r) => `\`${r.file}:${r.line}\``).join(", ");
551
+ const extra = f.references.length > 3 ? ` +${f.references.length - 3} more` : "";
552
+ return shown + extra;
553
+ }
554
+
555
+ // src/commands/scan.ts
556
+ var FORMATS = ["table", "json", "markdown"];
557
+ var FAILABLE = ["MISSING", "PARITY", "DEAD"];
558
+ async function runScan(targetPath, options) {
559
+ const root = path4.resolve(process.cwd(), targetPath || ".");
560
+ const format = (options.format ?? "table").toLowerCase();
561
+ if (!FORMATS.includes(format)) {
562
+ throw new Error(
563
+ `unknown format "${options.format}" (expected: ${FORMATS.join(", ")})`
564
+ );
565
+ }
566
+ const { config } = await loadConfig(root, options.config);
567
+ const code = await scanCode(root, {
568
+ include: config.scan?.include,
569
+ exclude: config.scan?.exclude
570
+ });
571
+ const { environments, warnings } = await resolveEnvironments(config, root);
572
+ const generatedAt = (/* @__PURE__ */ new Date()).toISOString();
573
+ const report = buildReport(
574
+ code,
575
+ environments,
576
+ config,
577
+ root,
578
+ generatedAt,
579
+ warnings
580
+ );
581
+ if (format === "json") {
582
+ const payload = { version: 1, ...report, filesScanned: code.filesScanned };
583
+ await emit(JSON.stringify(payload, null, 2) + "\n", options.output);
584
+ } else if (format === "markdown") {
585
+ await emit(renderMarkdown(report) + "\n", options.output);
586
+ } else {
587
+ const enabled = options.color !== false && Boolean(process.stdout.isTTY) && !process.env.NO_COLOR;
588
+ const colors = createColors(enabled);
589
+ const text = renderTable(report, {
590
+ colors,
591
+ filesScanned: code.filesScanned
592
+ });
593
+ await emit(text + "\n", options.output);
594
+ for (const w of warnings) {
595
+ process.stderr.write(colors.yellow("warning: ") + w + "\n");
596
+ }
597
+ }
598
+ const failOn = resolveFailOn(options.failOn, config.failOn);
599
+ const failed = failOn.some((s) => report.summary[s] > 0);
600
+ return failed ? 1 : 0;
601
+ }
602
+ async function emit(content, output) {
603
+ if (output) {
604
+ const dest = path4.isAbsolute(output) ? output : path4.resolve(process.cwd(), output);
605
+ await writeFile(dest, content);
606
+ } else {
607
+ process.stdout.write(content);
608
+ }
609
+ }
610
+ function resolveFailOn(flag, configFailOn) {
611
+ if (flag !== void 0) return parseStatusList(flag);
612
+ if (configFailOn && configFailOn.length > 0) {
613
+ return configFailOn.map((s) => String(s).toUpperCase()).filter((s) => FAILABLE.includes(s));
614
+ }
615
+ return ["MISSING", "PARITY"];
616
+ }
617
+ function parseStatusList(raw) {
618
+ const tokens = raw.split(",").map((t) => t.trim().toUpperCase()).filter(Boolean);
619
+ if (tokens.includes("NONE")) return [];
620
+ const out = [];
621
+ for (const t of tokens) {
622
+ if (FAILABLE.includes(t)) out.push(t);
623
+ else
624
+ throw new Error(
625
+ `invalid --fail-on value "${t}" (expected any of: missing, parity, dead, none)`
626
+ );
627
+ }
628
+ return out;
629
+ }
630
+
631
+ // src/index.ts
632
+ var pkg = JSON.parse(
633
+ readFileSync(new URL("../package.json", import.meta.url), "utf8")
634
+ );
635
+ var program = new Command();
636
+ program.name("envguard").description(
637
+ "Inventory the environment variables your code expects and check them against every environment."
638
+ ).version(pkg.version, "-v, --version", "print the EnvGuard version");
639
+ program.command("scan", { isDefault: true }).description(
640
+ "Scan source for env var usage and compare it against every configured environment."
641
+ ).argument("[path]", "directory to scan", ".").option("-c, --config <file>", "path to an envguard.yml config file").option("-f, --format <format>", "output format: table | json | markdown", "table").option("-o, --output <file>", "write the report to a file instead of stdout").option(
642
+ "--fail-on <list>",
643
+ "comma-separated statuses that cause a non-zero exit: missing, parity, dead, none"
644
+ ).option("--no-color", "disable colored output").action(async (pathArg, opts) => {
645
+ try {
646
+ process.exitCode = await runScan(pathArg, {
647
+ config: opts.config,
648
+ format: opts.format,
649
+ output: opts.output,
650
+ failOn: opts.failOn,
651
+ color: opts.color
652
+ });
653
+ } catch (err) {
654
+ process.stderr.write(`envguard: ${err.message}
655
+ `);
656
+ process.exitCode = 2;
657
+ }
658
+ });
659
+ program.parseAsync(process.argv);
package/package.json ADDED
@@ -0,0 +1,65 @@
1
+ {
2
+ "name": "@rinorhatashi/envguard",
3
+ "version": "0.1.0",
4
+ "publishConfig": {
5
+ "access": "public"
6
+ },
7
+ "description": "Inventory the environment variables your code expects and check them against every environment — surfacing missing vars, staging/production parity drift, and dead configuration.",
8
+ "keywords": [
9
+ "env",
10
+ "environment-variables",
11
+ "dotenv",
12
+ "configuration",
13
+ "parity",
14
+ "drift",
15
+ "secrets",
16
+ "cli",
17
+ "github-action",
18
+ "devops"
19
+ ],
20
+ "license": "MIT",
21
+ "author": "Rinor Hatashi",
22
+ "homepage": "https://github.com/rinorhatashi/EnvGuard#readme",
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "git+https://github.com/rinorhatashi/EnvGuard.git"
26
+ },
27
+ "bugs": {
28
+ "url": "https://github.com/rinorhatashi/EnvGuard/issues"
29
+ },
30
+ "type": "module",
31
+ "bin": {
32
+ "envguard": "dist/index.js"
33
+ },
34
+ "files": [
35
+ "dist",
36
+ "README.md",
37
+ "LICENSE"
38
+ ],
39
+ "engines": {
40
+ "node": ">=18"
41
+ },
42
+ "scripts": {
43
+ "build": "tsup",
44
+ "dev": "tsx src/index.ts",
45
+ "envguard": "tsx src/index.ts",
46
+ "typecheck": "tsc --noEmit",
47
+ "test": "vitest run",
48
+ "test:watch": "vitest",
49
+ "prepublishOnly": "npm run build"
50
+ },
51
+ "dependencies": {
52
+ "commander": "^12.1.0",
53
+ "dotenv": "^16.4.5",
54
+ "fast-glob": "^3.3.2",
55
+ "picocolors": "^1.1.0",
56
+ "yaml": "^2.5.1"
57
+ },
58
+ "devDependencies": {
59
+ "@types/node": "^22.7.0",
60
+ "tsup": "^8.3.0",
61
+ "tsx": "^4.19.1",
62
+ "typescript": "^5.6.2",
63
+ "vitest": "^2.1.1"
64
+ }
65
+ }