env-example-doctor 0.0.0-stage → 1.0.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/dist/cli.js ADDED
@@ -0,0 +1,258 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli.ts
4
+ import path3 from "path";
5
+ import { parseArgs } from "util";
6
+
7
+ // src/env-file.ts
8
+ function parseEnvExample(content) {
9
+ const keys = /* @__PURE__ */ new Set();
10
+ for (const line of content.split(/\r?\n/)) {
11
+ const trimmed = line.trim();
12
+ if (!trimmed || trimmed.startsWith("#")) continue;
13
+ const eq = trimmed.indexOf("=");
14
+ const key = (eq === -1 ? trimmed : trimmed.slice(0, eq)).trim();
15
+ if (/^[A-Z_][A-Z0-9_]*$/.test(key)) keys.add(key);
16
+ }
17
+ return keys;
18
+ }
19
+ function formatEnvExampleAppend(keys) {
20
+ if (keys.length === 0) return "";
21
+ const body = keys.map((k) => `${k}=
22
+ `).join("");
23
+ return `
24
+ # suggested by env-doctor
25
+ ${body}`;
26
+ }
27
+
28
+ // src/analyze.ts
29
+ import { access, readFile as readFile2 } from "fs/promises";
30
+ import path2 from "path";
31
+
32
+ // src/scan.ts
33
+ var ENV_DOT_RX = /process\.env\.([A-Z_][A-Z0-9_]*)/g;
34
+ var META_DOT_RX = /import\.meta\.env\.([A-Z_][A-Z0-9_]*)/g;
35
+ var ENV_BRACKET_RX = /process\.env\[['"]([A-Z_][A-Z0-9_]*)['"]\]/g;
36
+ var META_BRACKET_RX = /import\.meta\.env\[['"]([A-Z_][A-Z0-9_]*)['"]\]/g;
37
+ var ENV_DESTRUCT_RX = /\{\s*([^}]+)\s*\}\s*=\s*process\.env\b/g;
38
+ var KEY_RX = /^[A-Z_][A-Z0-9_]*$/;
39
+ function collectMatches(text, rx, found) {
40
+ rx.lastIndex = 0;
41
+ let m;
42
+ while ((m = rx.exec(text)) !== null) {
43
+ if (m[1]) found.add(m[1]);
44
+ }
45
+ }
46
+ function collectDestructured(text, found) {
47
+ ENV_DESTRUCT_RX.lastIndex = 0;
48
+ let m;
49
+ while ((m = ENV_DESTRUCT_RX.exec(text)) !== null) {
50
+ const inner = m[1];
51
+ if (!inner) continue;
52
+ for (const part of inner.split(",")) {
53
+ const trimmed = part.trim();
54
+ if (!trimmed) continue;
55
+ const head = trimmed.split(/\s*[=:]/)[0]?.trim() ?? "";
56
+ const name = head.replace(/^\.\.\./, "").trim();
57
+ if (KEY_RX.test(name)) found.add(name);
58
+ }
59
+ }
60
+ }
61
+ function scanSourceForEnv(text) {
62
+ const found = /* @__PURE__ */ new Set();
63
+ for (const rx of [ENV_DOT_RX, META_DOT_RX, ENV_BRACKET_RX, META_BRACKET_RX]) {
64
+ collectMatches(text, rx, found);
65
+ }
66
+ collectDestructured(text, found);
67
+ return found;
68
+ }
69
+
70
+ // src/walk.ts
71
+ import { readdir, readFile, stat } from "fs/promises";
72
+ import path from "path";
73
+ var SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", ".git", "dist", "coverage", ".turbo"]);
74
+ var SOURCE_EXT = /* @__PURE__ */ new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".vue", ".svelte"]);
75
+ function pathIgnored(rel, patterns) {
76
+ const norm = rel.replace(/\\/g, "/");
77
+ return patterns.some((pat) => {
78
+ if (pat.includes("*")) {
79
+ const re = new RegExp(
80
+ "^" + pat.replace(/\*\*/g, ".*").replace(/\*/g, "[^/]*").replace(/\?/g, ".") + "$"
81
+ );
82
+ return re.test(norm);
83
+ }
84
+ return norm.includes(pat.replace(/\\/g, "/"));
85
+ });
86
+ }
87
+ async function listSourceFiles(root, ignore = []) {
88
+ const out = [];
89
+ async function walk(dir) {
90
+ const entries = await readdir(dir, { withFileTypes: true });
91
+ for (const ent of entries) {
92
+ const full = path.join(dir, ent.name);
93
+ const rel = path.relative(root, full);
94
+ if (pathIgnored(rel, ignore)) continue;
95
+ if (ent.isDirectory()) {
96
+ if (SKIP_DIRS.has(ent.name)) continue;
97
+ await walk(full);
98
+ } else if (ent.isFile()) {
99
+ const ext = path.extname(ent.name);
100
+ if (SOURCE_EXT.has(ext)) out.push(full);
101
+ }
102
+ }
103
+ }
104
+ const st = await stat(root);
105
+ if (!st.isDirectory()) return out;
106
+ await walk(root);
107
+ return out;
108
+ }
109
+ async function readSources(paths) {
110
+ const map = /* @__PURE__ */ new Map();
111
+ await Promise.all(
112
+ paths.map(async (p) => {
113
+ const text = await readFile(p, "utf8");
114
+ map.set(p, text);
115
+ })
116
+ );
117
+ return map;
118
+ }
119
+
120
+ // src/analyze.ts
121
+ async function analyzeEnv(options = {}) {
122
+ const root = path2.resolve(options.root ?? process.cwd());
123
+ const envPath = path2.resolve(options.envExamplePath ?? path2.join(root, ".env.example"));
124
+ const ignore = options.ignore ?? [];
125
+ let documented = /* @__PURE__ */ new Set();
126
+ let envExampleFound = false;
127
+ try {
128
+ await access(envPath);
129
+ envExampleFound = true;
130
+ const example = await readFile2(envPath, "utf8");
131
+ documented = parseEnvExample(example);
132
+ } catch {
133
+ documented = /* @__PURE__ */ new Set();
134
+ }
135
+ const files = await listSourceFiles(root, ignore);
136
+ const sources = await readSources(files);
137
+ const used = /* @__PURE__ */ new Set();
138
+ for (const text of sources.values()) {
139
+ for (const k of scanSourceForEnv(text)) used.add(k);
140
+ }
141
+ const missingInExample = [...used].filter((k) => !documented.has(k)).sort();
142
+ const unusedInCode = [...documented].filter((k) => !used.has(k)).sort();
143
+ return {
144
+ usedInCode: [...used].sort(),
145
+ documented: [...documented].sort(),
146
+ missingInExample,
147
+ unusedInCode,
148
+ ok: missingInExample.length === 0 && unusedInCode.length === 0,
149
+ envExamplePath: envPath,
150
+ envExampleFound
151
+ };
152
+ }
153
+ function shouldFail(report, failOn) {
154
+ if (failOn === "missing") return report.missingInExample.length > 0;
155
+ if (failOn === "unused") return report.unusedInCode.length > 0;
156
+ return !report.ok;
157
+ }
158
+
159
+ // src/version.ts
160
+ import { readFileSync } from "fs";
161
+ import { dirname, join } from "path";
162
+ function readPackageVersion() {
163
+ try {
164
+ const entry = process.argv[1];
165
+ if (!entry) return "0.0.0";
166
+ const pkgPath = join(dirname(entry), "..", "package.json");
167
+ const parsed = JSON.parse(readFileSync(pkgPath, "utf8"));
168
+ if (parsed && typeof parsed === "object" && "version" in parsed) {
169
+ return parsed.version;
170
+ }
171
+ } catch {
172
+ }
173
+ return "0.0.0";
174
+ }
175
+
176
+ // src/cli.ts
177
+ function printHelp() {
178
+ process.stdout.write(`env-doctor \u2014 sync code env usage with .env.example
179
+
180
+ Usage:
181
+ env-doctor [project-dir] [--env-example <file>] [--json] [--ignore <glob>]
182
+ [--fail-on missing|unused|any] [--suggest]
183
+
184
+ Options:
185
+ --env-example Path to .env.example (default: <dir>/.env.example)
186
+ --json JSON report on stdout
187
+ --suggest Print lines to append for missing keys (stdout only)
188
+ --ignore Glob to skip (repeatable)
189
+ --fail-on CI failure mode (default: any)
190
+ -h, --help
191
+ --version
192
+ `);
193
+ }
194
+ function main() {
195
+ const { values, positionals } = parseArgs({
196
+ options: {
197
+ help: { type: "boolean", short: "h", default: false },
198
+ version: { type: "boolean", default: false },
199
+ json: { type: "boolean", default: false },
200
+ suggest: { type: "boolean", default: false },
201
+ ignore: { type: "string", multiple: true, default: [] },
202
+ "env-example": { type: "string" },
203
+ "fail-on": { type: "string", default: "any" }
204
+ },
205
+ allowPositionals: true
206
+ });
207
+ if (values.version) {
208
+ process.stdout.write(`${readPackageVersion()}
209
+ `);
210
+ return Promise.resolve(0);
211
+ }
212
+ if (values.help) {
213
+ printHelp();
214
+ return Promise.resolve(0);
215
+ }
216
+ const root = path3.resolve(positionals[0] ?? process.cwd());
217
+ const failOn = values["fail-on"];
218
+ const envExamplePath = values["env-example"] ? path3.resolve(String(values["env-example"])) : void 0;
219
+ return analyzeEnv({ root, ignore: values.ignore, envExamplePath }).then((report) => {
220
+ if (values.json) {
221
+ process.stdout.write(`${JSON.stringify(report, null, 2)}
222
+ `);
223
+ } else {
224
+ process.stdout.write(`Env file: ${report.envExamplePath}${report.envExampleFound ? "" : " (not found)"}
225
+ `);
226
+ process.stdout.write(`Used in code: ${String(report.usedInCode.length)}
227
+ `);
228
+ process.stdout.write(`In .env.example: ${String(report.documented.length)}
229
+ `);
230
+ if (report.missingInExample.length) {
231
+ process.stdout.write(`
232
+ Missing in .env.example:
233
+ `);
234
+ for (const k of report.missingInExample) process.stdout.write(` - ${k}
235
+ `);
236
+ }
237
+ if (report.unusedInCode.length) {
238
+ process.stdout.write(`
239
+ Unused in code:
240
+ `);
241
+ for (const k of report.unusedInCode) process.stdout.write(` - ${k}
242
+ `);
243
+ }
244
+ if (report.ok) process.stdout.write("\nAll env keys are documented and used.\n");
245
+ if (values.suggest && report.missingInExample.length) {
246
+ process.stdout.write("\nSuggested append:\n");
247
+ process.stdout.write(formatEnvExampleAppend(report.missingInExample));
248
+ }
249
+ }
250
+ return shouldFail(report, failOn) ? 1 : 0;
251
+ });
252
+ }
253
+ main().then((code) => process.exit(code)).catch((err) => {
254
+ process.stderr.write(`${err instanceof Error ? err.message : String(err)}
255
+ `);
256
+ process.exit(2);
257
+ });
258
+ //# sourceMappingURL=cli.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/cli.ts","../src/env-file.ts","../src/analyze.ts","../src/scan.ts","../src/walk.ts","../src/version.ts"],"sourcesContent":["#!/usr/bin/env node\nimport path from 'node:path';\nimport { parseArgs } from 'node:util';\nimport { formatEnvExampleAppend } from './env-file.js';\nimport { analyzeEnv, shouldFail, type FailOn } from './index.js';\nimport { readPackageVersion } from './version.js';\n\nfunction printHelp(): void {\n process.stdout.write(`env-doctor — sync code env usage with .env.example\n\nUsage:\n env-doctor [project-dir] [--env-example <file>] [--json] [--ignore <glob>]\n [--fail-on missing|unused|any] [--suggest]\n\nOptions:\n --env-example Path to .env.example (default: <dir>/.env.example)\n --json JSON report on stdout\n --suggest Print lines to append for missing keys (stdout only)\n --ignore Glob to skip (repeatable)\n --fail-on CI failure mode (default: any)\n -h, --help\n --version\n`);\n}\n\nfunction main(): Promise<number> {\n const { values, positionals } = parseArgs({\n options: {\n help: { type: 'boolean', short: 'h', default: false },\n version: { type: 'boolean', default: false },\n json: { type: 'boolean', default: false },\n suggest: { type: 'boolean', default: false },\n ignore: { type: 'string', multiple: true, default: [] },\n 'env-example': { type: 'string' },\n 'fail-on': { type: 'string', default: 'any' },\n },\n allowPositionals: true,\n });\n\n if (values.version) {\n process.stdout.write(`${readPackageVersion()}\\n`);\n return Promise.resolve(0);\n }\n if (values.help) {\n printHelp();\n return Promise.resolve(0);\n }\n\n const root = path.resolve(positionals[0] ?? process.cwd());\n const failOn = values['fail-on'] as FailOn;\n\n const envExamplePath = values['env-example']\n ? path.resolve(String(values['env-example']))\n : undefined;\n\n return analyzeEnv({ root, ignore: values.ignore, envExamplePath }).then((report) => {\n if (values.json) {\n process.stdout.write(`${JSON.stringify(report, null, 2)}\\n`);\n } else {\n process.stdout.write(`Env file: ${report.envExamplePath}${report.envExampleFound ? '' : ' (not found)'}\\n`);\n process.stdout.write(`Used in code: ${String(report.usedInCode.length)}\\n`);\n process.stdout.write(`In .env.example: ${String(report.documented.length)}\\n`);\n if (report.missingInExample.length) {\n process.stdout.write(`\\nMissing in .env.example:\\n`);\n for (const k of report.missingInExample) process.stdout.write(` - ${k}\\n`);\n }\n if (report.unusedInCode.length) {\n process.stdout.write(`\\nUnused in code:\\n`);\n for (const k of report.unusedInCode) process.stdout.write(` - ${k}\\n`);\n }\n if (report.ok) process.stdout.write('\\nAll env keys are documented and used.\\n');\n if (values.suggest && report.missingInExample.length) {\n process.stdout.write('\\nSuggested append:\\n');\n process.stdout.write(formatEnvExampleAppend(report.missingInExample));\n }\n }\n return shouldFail(report, failOn) ? 1 : 0;\n });\n}\n\nmain()\n .then((code) => process.exit(code))\n .catch((err: unknown) => {\n process.stderr.write(`${err instanceof Error ? err.message : String(err)}\\n`);\n process.exit(2);\n });\n","/**\n * Parse `.env.example` style files (KEY=value, comments and blanks ignored).\n */\nexport function parseEnvExample(content: string): Set<string> {\n const keys = new Set<string>();\n for (const line of content.split(/\\r?\\n/)) {\n const trimmed = line.trim();\n if (!trimmed || trimmed.startsWith('#')) continue;\n const eq = trimmed.indexOf('=');\n const key = (eq === -1 ? trimmed : trimmed.slice(0, eq)).trim();\n if (/^[A-Z_][A-Z0-9_]*$/.test(key)) keys.add(key);\n }\n return keys;\n}\n\n/** Lines to append when keys are missing from `.env.example`. */\nexport function suggestEnvExampleLines(keys: string[]): string[] {\n return keys.map((k) => `${k}=`);\n}\n\nexport function formatEnvExampleAppend(keys: string[]): string {\n if (keys.length === 0) return '';\n const body = keys.map((k) => `${k}=\\n`).join('');\n return `\\n# suggested by env-doctor\\n${body}`;\n}\n","import { access, readFile } from 'node:fs/promises';\nimport path from 'node:path';\nimport { parseEnvExample } from './env-file.js';\nimport { scanSourceForEnv } from './scan.js';\nimport { listSourceFiles, readSources } from './walk.js';\n\nexport type FailOn = 'missing' | 'unused' | 'any';\n\nexport interface EnvDoctorOptions {\n root?: string;\n envExamplePath?: string;\n ignore?: string[];\n}\n\nexport interface EnvDoctorReport {\n usedInCode: string[];\n documented: string[];\n missingInExample: string[];\n unusedInCode: string[];\n ok: boolean;\n /** Resolved path to the env example file that was read (or attempted). */\n envExamplePath: string;\n /** False when the env example file does not exist (all keys treated as undocumented). */\n envExampleFound: boolean;\n}\n\n/**\n * Compare env usage in source against `.env.example`.\n */\nexport async function analyzeEnv(options: EnvDoctorOptions = {}): Promise<EnvDoctorReport> {\n const root = path.resolve(options.root ?? process.cwd());\n const envPath = path.resolve(options.envExamplePath ?? path.join(root, '.env.example'));\n const ignore = options.ignore ?? [];\n\n let documented = new Set<string>();\n let envExampleFound = false;\n try {\n await access(envPath);\n envExampleFound = true;\n const example = await readFile(envPath, 'utf8');\n documented = parseEnvExample(example);\n } catch {\n documented = new Set();\n }\n\n const files = await listSourceFiles(root, ignore);\n const sources = await readSources(files);\n const used = new Set<string>();\n for (const text of sources.values()) {\n for (const k of scanSourceForEnv(text)) used.add(k);\n }\n\n const missingInExample = [...used].filter((k) => !documented.has(k)).sort();\n const unusedInCode = [...documented].filter((k) => !used.has(k)).sort();\n\n return {\n usedInCode: [...used].sort(),\n documented: [...documented].sort(),\n missingInExample,\n unusedInCode,\n ok: missingInExample.length === 0 && unusedInCode.length === 0,\n envExamplePath: envPath,\n envExampleFound,\n };\n}\n\nexport function shouldFail(report: EnvDoctorReport, failOn: FailOn): boolean {\n if (failOn === 'missing') return report.missingInExample.length > 0;\n if (failOn === 'unused') return report.unusedInCode.length > 0;\n return !report.ok;\n}\n","const ENV_DOT_RX = /process\\.env\\.([A-Z_][A-Z0-9_]*)/g;\nconst META_DOT_RX = /import\\.meta\\.env\\.([A-Z_][A-Z0-9_]*)/g;\nconst ENV_BRACKET_RX = /process\\.env\\[['\"]([A-Z_][A-Z0-9_]*)['\"]\\]/g;\nconst META_BRACKET_RX = /import\\.meta\\.env\\[['\"]([A-Z_][A-Z0-9_]*)['\"]\\]/g;\nconst ENV_DESTRUCT_RX = /\\{\\s*([^}]+)\\s*\\}\\s*=\\s*process\\.env\\b/g;\n\nconst KEY_RX = /^[A-Z_][A-Z0-9_]*$/;\n\nfunction collectMatches(text: string, rx: RegExp, found: Set<string>): void {\n rx.lastIndex = 0;\n let m: RegExpExecArray | null;\n while ((m = rx.exec(text)) !== null) {\n if (m[1]) found.add(m[1]);\n }\n}\n\nfunction collectDestructured(text: string, found: Set<string>): void {\n ENV_DESTRUCT_RX.lastIndex = 0;\n let m: RegExpExecArray | null;\n while ((m = ENV_DESTRUCT_RX.exec(text)) !== null) {\n const inner = m[1];\n if (!inner) continue;\n for (const part of inner.split(',')) {\n const trimmed = part.trim();\n if (!trimmed) continue;\n const head = trimmed.split(/\\s*[=:]/)[0]?.trim() ?? '';\n const name = head.replace(/^\\.\\.\\./, '').trim();\n if (KEY_RX.test(name)) found.add(name);\n }\n }\n}\n\n/**\n * Extract environment variable names referenced in source text.\n * Supports dot access, bracket access, and simple `const { A, B } = process.env`.\n * Does not resolve dynamic keys (`process.env[name]`).\n */\nexport function scanSourceForEnv(text: string): Set<string> {\n const found = new Set<string>();\n for (const rx of [ENV_DOT_RX, META_DOT_RX, ENV_BRACKET_RX, META_BRACKET_RX]) {\n collectMatches(text, rx, found);\n }\n collectDestructured(text, found);\n return found;\n}\n","import { readdir, readFile, stat } from 'node:fs/promises';\nimport path from 'node:path';\n\nconst SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'coverage', '.turbo']);\n\nconst SOURCE_EXT = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.vue', '.svelte']);\n\nfunction pathIgnored(rel: string, patterns: string[]): boolean {\n const norm = rel.replace(/\\\\/g, '/');\n return patterns.some((pat) => {\n if (pat.includes('*')) {\n const re = new RegExp(\n '^' + pat.replace(/\\*\\*/g, '.*').replace(/\\*/g, '[^/]*').replace(/\\?/g, '.') + '$',\n );\n return re.test(norm);\n }\n return norm.includes(pat.replace(/\\\\/g, '/'));\n });\n}\n\n/**\n * Recursively list source files under `root`, honouring ignore globs (simple `*` / `**`).\n */\nexport async function listSourceFiles(root: string, ignore: string[] = []): Promise<string[]> {\n const out: string[] = [];\n\n async function walk(dir: string): Promise<void> {\n const entries = await readdir(dir, { withFileTypes: true });\n for (const ent of entries) {\n const full = path.join(dir, ent.name);\n const rel = path.relative(root, full);\n if (pathIgnored(rel, ignore)) continue;\n if (ent.isDirectory()) {\n if (SKIP_DIRS.has(ent.name)) continue;\n await walk(full);\n } else if (ent.isFile()) {\n const ext = path.extname(ent.name);\n if (SOURCE_EXT.has(ext)) out.push(full);\n }\n }\n }\n\n const st = await stat(root);\n if (!st.isDirectory()) return out;\n await walk(root);\n return out;\n}\n\nexport async function readSources(paths: string[]): Promise<Map<string, string>> {\n const map = new Map<string, string>();\n await Promise.all(\n paths.map(async (p) => {\n const text = await readFile(p, 'utf8');\n map.set(p, text);\n }),\n );\n return map;\n}\n","import { readFileSync } from 'node:fs';\nimport { dirname, join } from 'node:path';\n\n/** Read version from package.json next to compiled CLI (works for ESM + CJS bins). */\nexport function readPackageVersion(): string {\n try {\n const entry = process.argv[1];\n if (!entry) return '0.0.0';\n const pkgPath = join(dirname(entry), '..', 'package.json');\n const parsed: unknown = JSON.parse(readFileSync(pkgPath, 'utf8'));\n if (parsed && typeof parsed === 'object' && 'version' in parsed) {\n return (parsed as { version: string }).version;\n }\n } catch {\n // ignore\n }\n return '0.0.0';\n}\n"],"mappings":"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","names":["path","readFile","path","path","readFile","path"]}
package/dist/index.cjs ADDED
@@ -0,0 +1,205 @@
1
+ "use strict";
2
+ var __create = Object.create;
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __export = (target, all) => {
9
+ for (var name in all)
10
+ __defProp(target, name, { get: all[name], enumerable: true });
11
+ };
12
+ var __copyProps = (to, from, except, desc) => {
13
+ if (from && typeof from === "object" || typeof from === "function") {
14
+ for (let key of __getOwnPropNames(from))
15
+ if (!__hasOwnProp.call(to, key) && key !== except)
16
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
17
+ }
18
+ return to;
19
+ };
20
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
21
+ // If the importer is in node compatibility mode or this is not an ESM
22
+ // file that has been converted to a CommonJS file using a Babel-
23
+ // compatible transform (i.e. "__esModule" has not been set), then set
24
+ // "default" to the CommonJS "module.exports" for node compatibility.
25
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
26
+ mod
27
+ ));
28
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
29
+
30
+ // src/index.ts
31
+ var index_exports = {};
32
+ __export(index_exports, {
33
+ analyzeEnv: () => analyzeEnv,
34
+ formatEnvExampleAppend: () => formatEnvExampleAppend,
35
+ parseEnvExample: () => parseEnvExample,
36
+ scanSourceForEnv: () => scanSourceForEnv,
37
+ shouldFail: () => shouldFail,
38
+ suggestEnvExampleLines: () => suggestEnvExampleLines
39
+ });
40
+ module.exports = __toCommonJS(index_exports);
41
+
42
+ // src/analyze.ts
43
+ var import_promises2 = require("fs/promises");
44
+ var import_node_path2 = __toESM(require("path"), 1);
45
+
46
+ // src/env-file.ts
47
+ function parseEnvExample(content) {
48
+ const keys = /* @__PURE__ */ new Set();
49
+ for (const line of content.split(/\r?\n/)) {
50
+ const trimmed = line.trim();
51
+ if (!trimmed || trimmed.startsWith("#")) continue;
52
+ const eq = trimmed.indexOf("=");
53
+ const key = (eq === -1 ? trimmed : trimmed.slice(0, eq)).trim();
54
+ if (/^[A-Z_][A-Z0-9_]*$/.test(key)) keys.add(key);
55
+ }
56
+ return keys;
57
+ }
58
+ function suggestEnvExampleLines(keys) {
59
+ return keys.map((k) => `${k}=`);
60
+ }
61
+ function formatEnvExampleAppend(keys) {
62
+ if (keys.length === 0) return "";
63
+ const body = keys.map((k) => `${k}=
64
+ `).join("");
65
+ return `
66
+ # suggested by env-doctor
67
+ ${body}`;
68
+ }
69
+
70
+ // src/scan.ts
71
+ var ENV_DOT_RX = /process\.env\.([A-Z_][A-Z0-9_]*)/g;
72
+ var META_DOT_RX = /import\.meta\.env\.([A-Z_][A-Z0-9_]*)/g;
73
+ var ENV_BRACKET_RX = /process\.env\[['"]([A-Z_][A-Z0-9_]*)['"]\]/g;
74
+ var META_BRACKET_RX = /import\.meta\.env\[['"]([A-Z_][A-Z0-9_]*)['"]\]/g;
75
+ var ENV_DESTRUCT_RX = /\{\s*([^}]+)\s*\}\s*=\s*process\.env\b/g;
76
+ var KEY_RX = /^[A-Z_][A-Z0-9_]*$/;
77
+ function collectMatches(text, rx, found) {
78
+ rx.lastIndex = 0;
79
+ let m;
80
+ while ((m = rx.exec(text)) !== null) {
81
+ if (m[1]) found.add(m[1]);
82
+ }
83
+ }
84
+ function collectDestructured(text, found) {
85
+ ENV_DESTRUCT_RX.lastIndex = 0;
86
+ let m;
87
+ while ((m = ENV_DESTRUCT_RX.exec(text)) !== null) {
88
+ const inner = m[1];
89
+ if (!inner) continue;
90
+ for (const part of inner.split(",")) {
91
+ const trimmed = part.trim();
92
+ if (!trimmed) continue;
93
+ const head = trimmed.split(/\s*[=:]/)[0]?.trim() ?? "";
94
+ const name = head.replace(/^\.\.\./, "").trim();
95
+ if (KEY_RX.test(name)) found.add(name);
96
+ }
97
+ }
98
+ }
99
+ function scanSourceForEnv(text) {
100
+ const found = /* @__PURE__ */ new Set();
101
+ for (const rx of [ENV_DOT_RX, META_DOT_RX, ENV_BRACKET_RX, META_BRACKET_RX]) {
102
+ collectMatches(text, rx, found);
103
+ }
104
+ collectDestructured(text, found);
105
+ return found;
106
+ }
107
+
108
+ // src/walk.ts
109
+ var import_promises = require("fs/promises");
110
+ var import_node_path = __toESM(require("path"), 1);
111
+ var SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", ".git", "dist", "coverage", ".turbo"]);
112
+ var SOURCE_EXT = /* @__PURE__ */ new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".vue", ".svelte"]);
113
+ function pathIgnored(rel, patterns) {
114
+ const norm = rel.replace(/\\/g, "/");
115
+ return patterns.some((pat) => {
116
+ if (pat.includes("*")) {
117
+ const re = new RegExp(
118
+ "^" + pat.replace(/\*\*/g, ".*").replace(/\*/g, "[^/]*").replace(/\?/g, ".") + "$"
119
+ );
120
+ return re.test(norm);
121
+ }
122
+ return norm.includes(pat.replace(/\\/g, "/"));
123
+ });
124
+ }
125
+ async function listSourceFiles(root, ignore = []) {
126
+ const out = [];
127
+ async function walk(dir) {
128
+ const entries = await (0, import_promises.readdir)(dir, { withFileTypes: true });
129
+ for (const ent of entries) {
130
+ const full = import_node_path.default.join(dir, ent.name);
131
+ const rel = import_node_path.default.relative(root, full);
132
+ if (pathIgnored(rel, ignore)) continue;
133
+ if (ent.isDirectory()) {
134
+ if (SKIP_DIRS.has(ent.name)) continue;
135
+ await walk(full);
136
+ } else if (ent.isFile()) {
137
+ const ext = import_node_path.default.extname(ent.name);
138
+ if (SOURCE_EXT.has(ext)) out.push(full);
139
+ }
140
+ }
141
+ }
142
+ const st = await (0, import_promises.stat)(root);
143
+ if (!st.isDirectory()) return out;
144
+ await walk(root);
145
+ return out;
146
+ }
147
+ async function readSources(paths) {
148
+ const map = /* @__PURE__ */ new Map();
149
+ await Promise.all(
150
+ paths.map(async (p) => {
151
+ const text = await (0, import_promises.readFile)(p, "utf8");
152
+ map.set(p, text);
153
+ })
154
+ );
155
+ return map;
156
+ }
157
+
158
+ // src/analyze.ts
159
+ async function analyzeEnv(options = {}) {
160
+ const root = import_node_path2.default.resolve(options.root ?? process.cwd());
161
+ const envPath = import_node_path2.default.resolve(options.envExamplePath ?? import_node_path2.default.join(root, ".env.example"));
162
+ const ignore = options.ignore ?? [];
163
+ let documented = /* @__PURE__ */ new Set();
164
+ let envExampleFound = false;
165
+ try {
166
+ await (0, import_promises2.access)(envPath);
167
+ envExampleFound = true;
168
+ const example = await (0, import_promises2.readFile)(envPath, "utf8");
169
+ documented = parseEnvExample(example);
170
+ } catch {
171
+ documented = /* @__PURE__ */ new Set();
172
+ }
173
+ const files = await listSourceFiles(root, ignore);
174
+ const sources = await readSources(files);
175
+ const used = /* @__PURE__ */ new Set();
176
+ for (const text of sources.values()) {
177
+ for (const k of scanSourceForEnv(text)) used.add(k);
178
+ }
179
+ const missingInExample = [...used].filter((k) => !documented.has(k)).sort();
180
+ const unusedInCode = [...documented].filter((k) => !used.has(k)).sort();
181
+ return {
182
+ usedInCode: [...used].sort(),
183
+ documented: [...documented].sort(),
184
+ missingInExample,
185
+ unusedInCode,
186
+ ok: missingInExample.length === 0 && unusedInCode.length === 0,
187
+ envExamplePath: envPath,
188
+ envExampleFound
189
+ };
190
+ }
191
+ function shouldFail(report, failOn) {
192
+ if (failOn === "missing") return report.missingInExample.length > 0;
193
+ if (failOn === "unused") return report.unusedInCode.length > 0;
194
+ return !report.ok;
195
+ }
196
+ // Annotate the CommonJS export names for ESM import in node:
197
+ 0 && (module.exports = {
198
+ analyzeEnv,
199
+ formatEnvExampleAppend,
200
+ parseEnvExample,
201
+ scanSourceForEnv,
202
+ shouldFail,
203
+ suggestEnvExampleLines
204
+ });
205
+ //# sourceMappingURL=index.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/index.ts","../src/analyze.ts","../src/env-file.ts","../src/scan.ts","../src/walk.ts"],"sourcesContent":["export { analyzeEnv, shouldFail } from './analyze.js';\nexport type { EnvDoctorOptions, EnvDoctorReport, FailOn } from './analyze.js';\nexport { scanSourceForEnv } from './scan.js';\nexport {\n formatEnvExampleAppend,\n parseEnvExample,\n suggestEnvExampleLines,\n} from './env-file.js';\n","import { access, readFile } from 'node:fs/promises';\nimport path from 'node:path';\nimport { parseEnvExample } from './env-file.js';\nimport { scanSourceForEnv } from './scan.js';\nimport { listSourceFiles, readSources } from './walk.js';\n\nexport type FailOn = 'missing' | 'unused' | 'any';\n\nexport interface EnvDoctorOptions {\n root?: string;\n envExamplePath?: string;\n ignore?: string[];\n}\n\nexport interface EnvDoctorReport {\n usedInCode: string[];\n documented: string[];\n missingInExample: string[];\n unusedInCode: string[];\n ok: boolean;\n /** Resolved path to the env example file that was read (or attempted). */\n envExamplePath: string;\n /** False when the env example file does not exist (all keys treated as undocumented). */\n envExampleFound: boolean;\n}\n\n/**\n * Compare env usage in source against `.env.example`.\n */\nexport async function analyzeEnv(options: EnvDoctorOptions = {}): Promise<EnvDoctorReport> {\n const root = path.resolve(options.root ?? process.cwd());\n const envPath = path.resolve(options.envExamplePath ?? path.join(root, '.env.example'));\n const ignore = options.ignore ?? [];\n\n let documented = new Set<string>();\n let envExampleFound = false;\n try {\n await access(envPath);\n envExampleFound = true;\n const example = await readFile(envPath, 'utf8');\n documented = parseEnvExample(example);\n } catch {\n documented = new Set();\n }\n\n const files = await listSourceFiles(root, ignore);\n const sources = await readSources(files);\n const used = new Set<string>();\n for (const text of sources.values()) {\n for (const k of scanSourceForEnv(text)) used.add(k);\n }\n\n const missingInExample = [...used].filter((k) => !documented.has(k)).sort();\n const unusedInCode = [...documented].filter((k) => !used.has(k)).sort();\n\n return {\n usedInCode: [...used].sort(),\n documented: [...documented].sort(),\n missingInExample,\n unusedInCode,\n ok: missingInExample.length === 0 && unusedInCode.length === 0,\n envExamplePath: envPath,\n envExampleFound,\n };\n}\n\nexport function shouldFail(report: EnvDoctorReport, failOn: FailOn): boolean {\n if (failOn === 'missing') return report.missingInExample.length > 0;\n if (failOn === 'unused') return report.unusedInCode.length > 0;\n return !report.ok;\n}\n","/**\n * Parse `.env.example` style files (KEY=value, comments and blanks ignored).\n */\nexport function parseEnvExample(content: string): Set<string> {\n const keys = new Set<string>();\n for (const line of content.split(/\\r?\\n/)) {\n const trimmed = line.trim();\n if (!trimmed || trimmed.startsWith('#')) continue;\n const eq = trimmed.indexOf('=');\n const key = (eq === -1 ? trimmed : trimmed.slice(0, eq)).trim();\n if (/^[A-Z_][A-Z0-9_]*$/.test(key)) keys.add(key);\n }\n return keys;\n}\n\n/** Lines to append when keys are missing from `.env.example`. */\nexport function suggestEnvExampleLines(keys: string[]): string[] {\n return keys.map((k) => `${k}=`);\n}\n\nexport function formatEnvExampleAppend(keys: string[]): string {\n if (keys.length === 0) return '';\n const body = keys.map((k) => `${k}=\\n`).join('');\n return `\\n# suggested by env-doctor\\n${body}`;\n}\n","const ENV_DOT_RX = /process\\.env\\.([A-Z_][A-Z0-9_]*)/g;\nconst META_DOT_RX = /import\\.meta\\.env\\.([A-Z_][A-Z0-9_]*)/g;\nconst ENV_BRACKET_RX = /process\\.env\\[['\"]([A-Z_][A-Z0-9_]*)['\"]\\]/g;\nconst META_BRACKET_RX = /import\\.meta\\.env\\[['\"]([A-Z_][A-Z0-9_]*)['\"]\\]/g;\nconst ENV_DESTRUCT_RX = /\\{\\s*([^}]+)\\s*\\}\\s*=\\s*process\\.env\\b/g;\n\nconst KEY_RX = /^[A-Z_][A-Z0-9_]*$/;\n\nfunction collectMatches(text: string, rx: RegExp, found: Set<string>): void {\n rx.lastIndex = 0;\n let m: RegExpExecArray | null;\n while ((m = rx.exec(text)) !== null) {\n if (m[1]) found.add(m[1]);\n }\n}\n\nfunction collectDestructured(text: string, found: Set<string>): void {\n ENV_DESTRUCT_RX.lastIndex = 0;\n let m: RegExpExecArray | null;\n while ((m = ENV_DESTRUCT_RX.exec(text)) !== null) {\n const inner = m[1];\n if (!inner) continue;\n for (const part of inner.split(',')) {\n const trimmed = part.trim();\n if (!trimmed) continue;\n const head = trimmed.split(/\\s*[=:]/)[0]?.trim() ?? '';\n const name = head.replace(/^\\.\\.\\./, '').trim();\n if (KEY_RX.test(name)) found.add(name);\n }\n }\n}\n\n/**\n * Extract environment variable names referenced in source text.\n * Supports dot access, bracket access, and simple `const { A, B } = process.env`.\n * Does not resolve dynamic keys (`process.env[name]`).\n */\nexport function scanSourceForEnv(text: string): Set<string> {\n const found = new Set<string>();\n for (const rx of [ENV_DOT_RX, META_DOT_RX, ENV_BRACKET_RX, META_BRACKET_RX]) {\n collectMatches(text, rx, found);\n }\n collectDestructured(text, found);\n return found;\n}\n","import { readdir, readFile, stat } from 'node:fs/promises';\nimport path from 'node:path';\n\nconst SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'coverage', '.turbo']);\n\nconst SOURCE_EXT = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.vue', '.svelte']);\n\nfunction pathIgnored(rel: string, patterns: string[]): boolean {\n const norm = rel.replace(/\\\\/g, '/');\n return patterns.some((pat) => {\n if (pat.includes('*')) {\n const re = new RegExp(\n '^' + pat.replace(/\\*\\*/g, '.*').replace(/\\*/g, '[^/]*').replace(/\\?/g, '.') + '$',\n );\n return re.test(norm);\n }\n return norm.includes(pat.replace(/\\\\/g, '/'));\n });\n}\n\n/**\n * Recursively list source files under `root`, honouring ignore globs (simple `*` / `**`).\n */\nexport async function listSourceFiles(root: string, ignore: string[] = []): Promise<string[]> {\n const out: string[] = [];\n\n async function walk(dir: string): Promise<void> {\n const entries = await readdir(dir, { withFileTypes: true });\n for (const ent of entries) {\n const full = path.join(dir, ent.name);\n const rel = path.relative(root, full);\n if (pathIgnored(rel, ignore)) continue;\n if (ent.isDirectory()) {\n if (SKIP_DIRS.has(ent.name)) continue;\n await walk(full);\n } else if (ent.isFile()) {\n const ext = path.extname(ent.name);\n if (SOURCE_EXT.has(ext)) out.push(full);\n }\n }\n }\n\n const st = await stat(root);\n if (!st.isDirectory()) return out;\n await walk(root);\n return out;\n}\n\nexport async function readSources(paths: string[]): Promise<Map<string, string>> {\n const map = new Map<string, string>();\n await Promise.all(\n paths.map(async (p) => {\n const text = await readFile(p, 'utf8');\n map.set(p, text);\n }),\n );\n return map;\n}\n"],"mappings":"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","names":["import_promises","import_node_path","path","path"]}
@@ -0,0 +1,39 @@
1
+ type FailOn = 'missing' | 'unused' | 'any';
2
+ interface EnvDoctorOptions {
3
+ root?: string;
4
+ envExamplePath?: string;
5
+ ignore?: string[];
6
+ }
7
+ interface EnvDoctorReport {
8
+ usedInCode: string[];
9
+ documented: string[];
10
+ missingInExample: string[];
11
+ unusedInCode: string[];
12
+ ok: boolean;
13
+ /** Resolved path to the env example file that was read (or attempted). */
14
+ envExamplePath: string;
15
+ /** False when the env example file does not exist (all keys treated as undocumented). */
16
+ envExampleFound: boolean;
17
+ }
18
+ /**
19
+ * Compare env usage in source against `.env.example`.
20
+ */
21
+ declare function analyzeEnv(options?: EnvDoctorOptions): Promise<EnvDoctorReport>;
22
+ declare function shouldFail(report: EnvDoctorReport, failOn: FailOn): boolean;
23
+
24
+ /**
25
+ * Extract environment variable names referenced in source text.
26
+ * Supports dot access, bracket access, and simple `const { A, B } = process.env`.
27
+ * Does not resolve dynamic keys (`process.env[name]`).
28
+ */
29
+ declare function scanSourceForEnv(text: string): Set<string>;
30
+
31
+ /**
32
+ * Parse `.env.example` style files (KEY=value, comments and blanks ignored).
33
+ */
34
+ declare function parseEnvExample(content: string): Set<string>;
35
+ /** Lines to append when keys are missing from `.env.example`. */
36
+ declare function suggestEnvExampleLines(keys: string[]): string[];
37
+ declare function formatEnvExampleAppend(keys: string[]): string;
38
+
39
+ export { type EnvDoctorOptions, type EnvDoctorReport, type FailOn, analyzeEnv, formatEnvExampleAppend, parseEnvExample, scanSourceForEnv, shouldFail, suggestEnvExampleLines };
@@ -0,0 +1,39 @@
1
+ type FailOn = 'missing' | 'unused' | 'any';
2
+ interface EnvDoctorOptions {
3
+ root?: string;
4
+ envExamplePath?: string;
5
+ ignore?: string[];
6
+ }
7
+ interface EnvDoctorReport {
8
+ usedInCode: string[];
9
+ documented: string[];
10
+ missingInExample: string[];
11
+ unusedInCode: string[];
12
+ ok: boolean;
13
+ /** Resolved path to the env example file that was read (or attempted). */
14
+ envExamplePath: string;
15
+ /** False when the env example file does not exist (all keys treated as undocumented). */
16
+ envExampleFound: boolean;
17
+ }
18
+ /**
19
+ * Compare env usage in source against `.env.example`.
20
+ */
21
+ declare function analyzeEnv(options?: EnvDoctorOptions): Promise<EnvDoctorReport>;
22
+ declare function shouldFail(report: EnvDoctorReport, failOn: FailOn): boolean;
23
+
24
+ /**
25
+ * Extract environment variable names referenced in source text.
26
+ * Supports dot access, bracket access, and simple `const { A, B } = process.env`.
27
+ * Does not resolve dynamic keys (`process.env[name]`).
28
+ */
29
+ declare function scanSourceForEnv(text: string): Set<string>;
30
+
31
+ /**
32
+ * Parse `.env.example` style files (KEY=value, comments and blanks ignored).
33
+ */
34
+ declare function parseEnvExample(content: string): Set<string>;
35
+ /** Lines to append when keys are missing from `.env.example`. */
36
+ declare function suggestEnvExampleLines(keys: string[]): string[];
37
+ declare function formatEnvExampleAppend(keys: string[]): string;
38
+
39
+ export { type EnvDoctorOptions, type EnvDoctorReport, type FailOn, analyzeEnv, formatEnvExampleAppend, parseEnvExample, scanSourceForEnv, shouldFail, suggestEnvExampleLines };