envdoc-scan 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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 takeaseatventure
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,118 @@
1
+ # envdoc-scan
2
+
3
+ **Scan your codebase for environment-variable references and generate a `.env.example` template or a documentation table. Never let your `.env.example` drift again.**
4
+
5
+ [![tests](https://img.shields.io/badge/tests-19%20pass-brightgreen)](#)
6
+ [![npm](https://img.shields.io/npm/v/envdoc-scan)](https://npmjs.com/package/envdoc-scan)
7
+
8
+ ---
9
+
10
+ ## Why
11
+
12
+ Every project has a `.env.example` that's supposed to list all the environment variables the app needs. In practice, it's always out of date — someone adds `process.env.NEW_THING` to the code and forgets to update the template. New contributors hit cryptic errors because they're missing a var.
13
+
14
+ `envdoc` scans your source files for every environment-variable reference and tells you exactly:
15
+
16
+ - **Which vars your code actually uses** (and where)
17
+ - **Which vars are in your `.env.example` but no longer referenced** (dead config)
18
+ - **Which vars are used in code but missing from `.env.example`** (undocumented)
19
+
20
+ ## Install
21
+
22
+ ```bash
23
+ npm install -g envdoc-scan
24
+ ```
25
+
26
+ Or use it directly with `npx`:
27
+
28
+ ```bash
29
+ npx envdoc-scan
30
+ ```
31
+
32
+ ## Usage
33
+
34
+ ```bash
35
+ # Print a .env.example template (default)
36
+ envdoc-scan
37
+
38
+ # Print a markdown documentation table
39
+ envdoc-scan --markdown
40
+
41
+ # Merge with your existing .env.example (preserves comments + defaults)
42
+ envdoc-scan --existing .env.example
43
+
44
+ # Flag vars in .env.example that are no longer in source
45
+ envdoc-scan --existing .env.example --mark-unused
46
+
47
+ # Output JSON (for CI checks or tooling)
48
+ envdoc-scan src/ --json
49
+
50
+ # Scan a specific directory
51
+ envdoc-scan src/
52
+ ```
53
+
54
+ ### Output formats
55
+
56
+ **`.env.example` (default):**
57
+ ```bash
58
+ # Database connection string
59
+ DATABASE_URL=postgres://localhost/mydb
60
+
61
+ # Auth
62
+ # (unused — not found in source)
63
+ SECRET_KEY=changeme
64
+
65
+ API_KEY=
66
+
67
+ REDIS_URL=
68
+ ```
69
+
70
+ **Markdown table:**
71
+
72
+ | Variable | Default | Used | Location |
73
+ |----------|---------|------|----------|
74
+ | `DATABASE_URL` | postgres://localhost/mydb | yes | `sample.js:3` (+1) |
75
+ | `SECRET_KEY` | changeme | no | — |
76
+ | `API_KEY` | — | yes | `sample.js:2` |
77
+
78
+ **JSON:**
79
+ ```json
80
+ {
81
+ "DATABASE_URL": [{ "file": "config.js", "line": 12, "col": 25 }]
82
+ }
83
+ ```
84
+
85
+ ## Supported patterns
86
+
87
+ `envdoc` detects environment-variable access across multiple languages:
88
+
89
+ | Language | Pattern |
90
+ |----------|---------|
91
+ | **Node / Bun** | `process.env.NAME`, `process.env['NAME']` |
92
+ | **Python** | `os.environ.get('NAME')`, `os.getenv('NAME')`, `os.environ['NAME']` |
93
+ | **Java** | `System.getenv("NAME")` |
94
+ | **Ruby** | `ENV['NAME']` |
95
+ | **Rust** | `env::var("NAME")` |
96
+ | **Go** | `os.LookupEnv("NAME")` |
97
+
98
+ ## What it scans
99
+
100
+ By default, `envdoc` scans these file types:
101
+
102
+ `.js`, `.mjs`, `.cjs`, `.ts`, `.jsx`, `.tsx`, `.py`, `.java`, `.rb`, `.rs`, `.go`, `.sh`, `.bash`, `.yml`, `.yaml`
103
+
104
+ It automatically skips: `node_modules`, `.git`, `vendor`, `dist`, `build`, `.next`, `target`, `__pycache__`, `.venv`, `venv`, `coverage`, `.cache`, and hidden directories.
105
+
106
+ ## CI integration
107
+
108
+ Use `--json` to fail CI if your `.env.example` is missing vars:
109
+
110
+ ```bash
111
+ # In your CI pipeline:
112
+ envdoc --json > /tmp/scanned.json
113
+ # ... diff against your committed .env.example
114
+ ```
115
+
116
+ ## License
117
+
118
+ MIT © takeaseatventure
package/bin/envdoc.js ADDED
@@ -0,0 +1,80 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const { scanDirectory } = require('../lib/scan');
5
+ const { readFile } = require('../lib/dotenv');
6
+ const { merge, formatEnvExample, formatMarkdown } = require('../lib/format');
7
+
8
+ function parseArgs(argv) {
9
+ const args = {
10
+ _: [],
11
+ format: 'env', // 'env' | 'markdown' | 'json'
12
+ root: '.',
13
+ existing: null, // path to existing .env.example to merge
14
+ markUnused: false,
15
+ help: false,
16
+ };
17
+ for (let i = 0; i < argv.length; i++) {
18
+ const a = argv[i];
19
+ if (a === '-h' || a === '--help') args.help = true;
20
+ else if (a === '--markdown' || a === '-m') args.format = 'markdown';
21
+ else if (a === '--json') args.format = 'json';
22
+ else if (a === '--env' || a === '-e') args.format = 'env';
23
+ else if (a === '--mark-unused') args.markUnused = true;
24
+ else if (a === '--existing') args.existing = argv[++i];
25
+ else if (a.startsWith('--root')) args.root = argv[++i];
26
+ else if (a.startsWith('-')) { /* ignore unknown */ }
27
+ else args._.push(a);
28
+ }
29
+ if (args._.length) args.root = args._[0];
30
+ return args;
31
+ }
32
+
33
+ const HELP = `envdoc — scan your codebase for environment-variable references
34
+ and generate a .env.example template or a documentation table.
35
+
36
+ USAGE
37
+ envdoc [options] [<path>]
38
+
39
+ OPTIONS
40
+ -e, --env Output a .env.example template (default)
41
+ -m, --markdown Output a markdown documentation table
42
+ --json Output JSON (name -> locations[])
43
+ --existing <file> Merge with an existing .env / .env.example
44
+ (preserves comments and known default values)
45
+ --mark-unused Flag vars in the existing file that are no longer
46
+ referenced in source
47
+ <path> Root directory to scan (default: current dir)
48
+ -h, --help Show this help
49
+
50
+ EXAMPLES
51
+ envdoc # scan . and print a .env.example
52
+ envdoc --markdown # print a markdown doc table
53
+ envdoc --existing .env.example # merge with your current template
54
+ envdoc src/ --json # scan only src/, emit JSON
55
+
56
+ Never let your .env.example drift again.`;
57
+
58
+ function main() {
59
+ const args = parseArgs(process.argv.slice(2));
60
+ if (args.help) { process.stdout.write(HELP + '\n'); return 0; }
61
+
62
+ const scanned = scanDirectory(args.root);
63
+ const existing = args.existing ? readFile(args.existing) : [];
64
+ const entries = merge(scanned, existing);
65
+
66
+ if (args.format === 'json') {
67
+ const obj = {};
68
+ for (const e of entries) obj[e.key] = e.locations || [];
69
+ process.stdout.write(JSON.stringify(obj, null, 2) + '\n');
70
+ return 0;
71
+ }
72
+ if (args.format === 'markdown') {
73
+ process.stdout.write(formatMarkdown(entries) + '\n');
74
+ return 0;
75
+ }
76
+ process.stdout.write(formatEnvExample(entries, { markUnused: args.markUnused }));
77
+ return 0;
78
+ }
79
+
80
+ process.exit(main());
package/lib/dotenv.js ADDED
@@ -0,0 +1,53 @@
1
+ 'use strict';
2
+ // dotenv.js — minimal .env / .env.example parser.
3
+ // Reads KEY=VALUE lines and any leading comments so we can preserve them
4
+ // when regenerating a template.
5
+
6
+ const fs = require('fs');
7
+
8
+ /**
9
+ * Parse a .env-format file into an ordered list of entries.
10
+ * @param {string} content
11
+ * @returns {Array<{key:string, value:string, comment:string, present:boolean}>}
12
+ */
13
+ function parse(content) {
14
+ const lines = content.split('\n');
15
+ const out = [];
16
+ let pendingComment = '';
17
+ for (const raw of lines) {
18
+ const line = raw.trim();
19
+ if (!line) { pendingComment = ''; continue; }
20
+ if (line.startsWith('#')) {
21
+ // Accumulate comment lines; attach to the next key.
22
+ const text = line.replace(/^#\s?/, '');
23
+ pendingComment = pendingComment ? pendingComment + ' ' + text : text;
24
+ continue;
25
+ }
26
+ const eq = line.indexOf('=');
27
+ if (eq === -1) { pendingComment = ''; continue; }
28
+ const key = line.slice(0, eq).trim();
29
+ let value = line.slice(eq + 1).trim();
30
+ // Strip surrounding quotes.
31
+ if ((value.startsWith('"') && value.endsWith('"')) ||
32
+ (value.startsWith("'") && value.endsWith("'"))) {
33
+ value = value.slice(1, -1);
34
+ }
35
+ if (!/^[A-Z0-9_]+$/i.test(key)) { pendingComment = ''; continue; }
36
+ out.push({ key, value, comment: pendingComment, present: true });
37
+ pendingComment = '';
38
+ }
39
+ return out;
40
+ }
41
+
42
+ /**
43
+ * Read a .env file from disk, returning entries (or [] if missing).
44
+ */
45
+ function readFile(filePath) {
46
+ try {
47
+ return parse(fs.readFileSync(filePath, 'utf8'));
48
+ } catch {
49
+ return [];
50
+ }
51
+ }
52
+
53
+ module.exports = { parse, readFile };
package/lib/format.js ADDED
@@ -0,0 +1,90 @@
1
+ 'use strict';
2
+ // format.js — turn a scan result into a .env.example or a markdown table.
3
+
4
+ /**
5
+ * Merge scanned vars with an existing .env.example's entries so comments
6
+ * and known defaults are preserved.
7
+ *
8
+ * @param {Map<string, Array>} scanned - name -> locations[]
9
+ * @param {Array} existing - [{key, value, comment, present}]
10
+ * @returns {Array} merged entries (ordered: existing first, then new scanned)
11
+ */
12
+ function merge(scanned, existing) {
13
+ const seen = new Set();
14
+ const merged = [];
15
+ // Existing entries first (preserve order + comments).
16
+ for (const e of existing) {
17
+ const locs = scanned.get(e.key) || [];
18
+ merged.push({
19
+ key: e.key,
20
+ value: e.value,
21
+ comment: e.comment,
22
+ used: locs.length > 0,
23
+ locations: locs,
24
+ });
25
+ seen.add(e.key);
26
+ }
27
+ // Then newly-discovered vars not in the existing file.
28
+ const sorted = Array.from(scanned.keys()).sort();
29
+ for (const key of sorted) {
30
+ if (seen.has(key)) continue;
31
+ merged.push({
32
+ key,
33
+ value: '',
34
+ comment: '',
35
+ used: true,
36
+ locations: scanned.get(key),
37
+ });
38
+ }
39
+ return merged;
40
+ }
41
+
42
+ /**
43
+ * Render merged entries as a .env.example file.
44
+ * @param {Array} entries
45
+ * @param {object} opts - { markUnused:true }
46
+ * @returns {string}
47
+ */
48
+ function formatEnvExample(entries, opts) {
49
+ opts = opts || {};
50
+ const lines = [];
51
+ for (const e of entries) {
52
+ if (e.comment) {
53
+ lines.push('# ' + e.comment);
54
+ }
55
+ if (opts.markUnused && e.used === false) {
56
+ lines.push('# (unused — not found in source)');
57
+ }
58
+ const value = e.value !== '' ? e.value : '';
59
+ lines.push(`${e.key}=${value}`);
60
+ lines.push('');
61
+ }
62
+ return lines.join('\n').replace(/\n+$/, '\n');
63
+ }
64
+
65
+ /**
66
+ * Render merged entries as a markdown documentation table.
67
+ * @param {Array} entries
68
+ * @returns {string}
69
+ */
70
+ function formatMarkdown(entries) {
71
+ const lines = [];
72
+ lines.push('# Environment Variables');
73
+ lines.push('');
74
+ lines.push('| Variable | Default | Used | Location |');
75
+ lines.push('|----------|---------|------|----------|');
76
+ for (const e of entries) {
77
+ const def = e.value || '—';
78
+ const used = e.used === false ? 'no' : 'yes';
79
+ const loc = e.locations && e.locations.length
80
+ ? '`' + e.locations[0].file + ':' + e.locations[0].line + '`' +
81
+ (e.locations.length > 1 ? ` (+${e.locations.length - 1})` : '')
82
+ : '—';
83
+ const row = `| \`${e.key}\` | ${def} | ${used} | ${loc} |`;
84
+ lines.push(row);
85
+ }
86
+ lines.push('');
87
+ return lines.join('\n');
88
+ }
89
+
90
+ module.exports = { merge, formatEnvExample, formatMarkdown };
package/lib/scan.js ADDED
@@ -0,0 +1,147 @@
1
+ 'use strict';
2
+ // scan.js — find environment-variable references in source files.
3
+ //
4
+ // Supports common patterns across languages:
5
+ // process.env.NAME (Node/Bun)
6
+ // process.env['NAME'] (Node, bracket form)
7
+ // os.environ['NAME'] (Python)
8
+ // os.environ.get('NAME') (Python)
9
+ // os.getenv('NAME') (Python)
10
+ // System.getenv("NAME") (Java)
11
+ // ENV['NAME'] (Ruby)
12
+ // env::var("NAME") (Rust)
13
+ // os.LookupEnv("NAME") (Go)
14
+ //
15
+ // Also reads an existing .env / .env.example to carry over comments and
16
+ // mark which vars are already documented.
17
+
18
+ const fs = require('fs');
19
+ const path = require('path');
20
+
21
+ // (accessor prefix) (optional quote) NAME (closing bracket or word boundary)
22
+ const PATTERNS = [
23
+ // process.env.NAME and process.env['NAME']
24
+ /process\.env(?:\[['"]([A-Z0-9_]{2,})['"]\]|\s*\.\s*([A-Z0-9_]{2,}))/g,
25
+ // os.environ['NAME'] / os.environ.get('NAME') / os.getenv('NAME')
26
+ /os\.environ(?:\.get)?\(\s*['"]([A-Z0-9_]{2,})['"]\s*\)|os\.environ\[['"]([A-Z0-9_]{2,})['"]\]/g,
27
+ /os\.getenv\(\s*['"]([A-Z0-9_]{2,})['"]\s*\)/g,
28
+ // System.getenv("NAME")
29
+ /System\.getenv\(\s*"([A-Z0-9_]{2,})"\s*\)/g,
30
+ // ENV['NAME'] (Ruby)
31
+ /\bENV\[['"]([A-Z0-9_]{2,})['"]\]/g,
32
+ // env::var("NAME") (Rust)
33
+ /env::var\(\s*"([A-Z0-9_]{2,})"\s*\)/g,
34
+ // os.LookupEnv("NAME") (Go)
35
+ /os\.LookupEnv\(\s*"([A-Z0-9_]{2,})"\s*\)/g,
36
+ ];
37
+
38
+ // File extensions we scan, by default.
39
+ const DEFAULT_EXTENSIONS = new Set([
40
+ '.js', '.mjs', '.cjs', '.ts', '.mts', '.cts', '.jsx', '.tsx',
41
+ '.py',
42
+ '.java',
43
+ '.rb',
44
+ '.rs',
45
+ '.go',
46
+ '.sh', '.bash',
47
+ '.yml', '.yaml',
48
+ ]);
49
+
50
+ // Directories we never descend into.
51
+ const IGNORE_DIRS = new Set([
52
+ 'node_modules', '.git', 'vendor', 'dist', 'build', '.next', '.nuxt',
53
+ 'target', '__pycache__', '.venv', 'venv', 'env', '.env',
54
+ 'coverage', '.cache', '.turbo', '.svelte-kit',
55
+ ]);
56
+
57
+ /**
58
+ * Walk a directory tree, returning source file paths to scan.
59
+ * @param {string} root
60
+ * @param {object} opts - { extensions?: Set<string> }
61
+ * @returns {string[]}
62
+ */
63
+ function listSourceFiles(root, opts) {
64
+ opts = opts || {};
65
+ const exts = opts.extensions || DEFAULT_EXTENSIONS;
66
+ const out = [];
67
+ if (!fs.existsSync(root)) return out;
68
+ const stat = fs.statSync(root);
69
+ if (stat.isFile()) return [root];
70
+ _walk(root, exts, out);
71
+ return out;
72
+ }
73
+
74
+ function _walk(dir, exts, out) {
75
+ let entries;
76
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
77
+ catch { return; }
78
+ for (const e of entries) {
79
+ if (e.isDirectory()) {
80
+ if (IGNORE_DIRS.has(e.name)) continue;
81
+ // Skip hidden directories.
82
+ if (e.name.startsWith('.')) continue;
83
+ _walk(path.join(dir, e.name), exts, out);
84
+ } else if (e.isFile()) {
85
+ if (exts.has(path.extname(e.name).toLowerCase())) {
86
+ out.push(path.join(dir, e.name));
87
+ }
88
+ }
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Scan a single file's content for env-var references.
94
+ * @param {string} content
95
+ * @returns {Array<{name:string, line:number, col:number}>}
96
+ */
97
+ function scanContent(content) {
98
+ const hits = [];
99
+ const lines = content.split('\n');
100
+ for (let i = 0; i < lines.length; i++) {
101
+ const line = lines[i];
102
+ for (const re of PATTERNS) {
103
+ re.lastIndex = 0;
104
+ let m;
105
+ while ((m = re.exec(line)) !== null) {
106
+ // Pick the first defined capture group (patterns have alternations).
107
+ const name = m[1] || m[2] || m[3] || null;
108
+ if (name) {
109
+ hits.push({ name, line: i + 1, col: m.index + 1 });
110
+ }
111
+ }
112
+ }
113
+ }
114
+ return hits;
115
+ }
116
+
117
+ /**
118
+ * Scan an entire directory, returning a map of varName -> locations[].
119
+ * @param {string} root
120
+ * @param {object} opts
121
+ * @returns {Map<string, Array<{file:string, line:number, col:number}>>}
122
+ */
123
+ function scanDirectory(root, opts) {
124
+ opts = opts || {};
125
+ const files = listSourceFiles(root, opts);
126
+ const result = new Map();
127
+ for (const file of files) {
128
+ let content;
129
+ try { content = fs.readFileSync(file, 'utf8'); }
130
+ catch { continue; }
131
+ const hits = scanContent(content);
132
+ for (const h of hits) {
133
+ if (!result.has(h.name)) result.set(h.name, []);
134
+ result.get(h.name).push({ file: path.relative(root, file), line: h.line, col: h.col });
135
+ }
136
+ }
137
+ return result;
138
+ }
139
+
140
+ module.exports = {
141
+ scanContent,
142
+ scanDirectory,
143
+ listSourceFiles,
144
+ PATTERNS,
145
+ DEFAULT_EXTENSIONS,
146
+ IGNORE_DIRS,
147
+ };
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "envdoc-scan",
3
+ "version": "0.1.0",
4
+ "description": "Scan your codebase for environment-variable references and generate a .env.example template and/or a documentation table. Never let your .env.example drift again.",
5
+ "license": "MIT",
6
+ "author": "takeaseatventure",
7
+ "keywords": [
8
+ "env",
9
+ "environment",
10
+ "variables",
11
+ "dotenv",
12
+ "example",
13
+ "documentation",
14
+ "cli",
15
+ "scanner",
16
+ "audit",
17
+ "envdoc"
18
+ ],
19
+ "homepage": "https://github.com/takeaseatventure/envdoc-scan#readme",
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/takeaseatventure/envdoc-scan.git"
23
+ },
24
+ "bin": {
25
+ "envdoc-scan": "./bin/envdoc.js"
26
+ },
27
+ "files": [
28
+ "bin/",
29
+ "lib/",
30
+ "README.md",
31
+ "LICENSE"
32
+ ],
33
+ "engines": {
34
+ "node": ">=18"
35
+ },
36
+ "scripts": {
37
+ "test": "node --test test/*.test.js"
38
+ },
39
+ "bugs": {
40
+ "url": "https://github.com/takeaseatventure/envdoc-scan/issues"
41
+ }
42
+ }