@neerav34/env-doctor 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/index.js ADDED
@@ -0,0 +1,1017 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import { Command, Option } from "commander";
5
+
6
+ // src/commands/check.ts
7
+ import path5 from "path";
8
+ import { writeFile, readFile as readFile4 } from "fs/promises";
9
+
10
+ // src/core/parser.ts
11
+ import { readFile } from "fs/promises";
12
+ async function parseEnvFile(filePath, isExample) {
13
+ let content;
14
+ try {
15
+ content = await readFile(filePath, "utf-8");
16
+ } catch {
17
+ return { vars: /* @__PURE__ */ new Map(), exists: false };
18
+ }
19
+ return { vars: parseEnvContent(content, filePath, isExample), exists: true };
20
+ }
21
+ function parseEnvContent(content, filePath, isExample) {
22
+ const vars = /* @__PURE__ */ new Map();
23
+ const lines = content.split("\n");
24
+ for (let i = 0; i < lines.length; i++) {
25
+ const rawLine = lines[i] ?? "";
26
+ let line = rawLine.trim();
27
+ if (!line || line.startsWith("#") || line.startsWith("//")) continue;
28
+ if (line.startsWith("export ")) {
29
+ line = line.slice(7).trim();
30
+ }
31
+ const eqIndex = line.indexOf("=");
32
+ if (eqIndex === -1) continue;
33
+ const key = line.slice(0, eqIndex).trim();
34
+ if (!key || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
35
+ let rawValue = line.slice(eqIndex + 1);
36
+ if (rawValue.startsWith('"') && !rawValue.slice(1).includes('"') || rawValue.startsWith("'") && !rawValue.slice(1).includes("'")) {
37
+ const quote = rawValue[0];
38
+ let combined = rawValue;
39
+ while (i + 1 < lines.length) {
40
+ i++;
41
+ const nextLine = lines[i] ?? "";
42
+ combined += "\n" + nextLine;
43
+ if (nextLine.includes(quote)) break;
44
+ }
45
+ rawValue = combined;
46
+ }
47
+ const value = extractValue(rawValue);
48
+ const displayValue = truncateValue(value);
49
+ vars.set(key, {
50
+ name: key,
51
+ file: filePath,
52
+ line: i + 1,
53
+ ...displayValue !== void 0 ? { value: displayValue } : {},
54
+ isExample
55
+ });
56
+ }
57
+ return vars;
58
+ }
59
+ function extractValue(raw) {
60
+ let value = raw.trim();
61
+ const firstChar = value[0];
62
+ if (firstChar !== '"' && firstChar !== "'") {
63
+ const commentIdx = value.search(/\s+#/);
64
+ if (commentIdx !== -1) {
65
+ value = value.slice(0, commentIdx).trim();
66
+ }
67
+ }
68
+ if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
69
+ value = value.slice(1, -1);
70
+ }
71
+ return value;
72
+ }
73
+ function truncateValue(value) {
74
+ if (!value) return void 0;
75
+ if (value.length <= 3) return "***";
76
+ return value.slice(0, 3) + "***";
77
+ }
78
+
79
+ // src/core/scanner.ts
80
+ import { open } from "fs/promises";
81
+ import path3 from "path";
82
+
83
+ // src/utils/glob.ts
84
+ import fg from "fast-glob";
85
+ import path2 from "path";
86
+
87
+ // src/utils/gitignore.ts
88
+ import { readFile as readFile2 } from "fs/promises";
89
+ import path from "path";
90
+ async function loadGitignorePatterns(root) {
91
+ const gitignorePath = path.join(root, ".gitignore");
92
+ try {
93
+ const content = await readFile2(gitignorePath, "utf-8");
94
+ return parseGitignorePatterns(content);
95
+ } catch {
96
+ return [];
97
+ }
98
+ }
99
+ function parseGitignorePatterns(content) {
100
+ return content.split("\n").map((line) => line.trim()).filter((line) => line.length > 0 && !line.startsWith("#")).flatMap((pattern) => {
101
+ if (pattern.startsWith("!")) return [];
102
+ if (pattern.endsWith("/")) {
103
+ const dir = pattern.slice(0, -1);
104
+ return [`**/${dir}/**`, `${dir}/**`];
105
+ }
106
+ if (pattern.startsWith("/")) {
107
+ return [pattern.slice(1), `${pattern.slice(1)}/**`];
108
+ }
109
+ if (pattern.includes("/")) {
110
+ return [pattern];
111
+ }
112
+ return [`**/${pattern}`, `**/${pattern}/**`];
113
+ });
114
+ }
115
+
116
+ // src/utils/glob.ts
117
+ var ALWAYS_IGNORE = [
118
+ "**/node_modules/**",
119
+ "**/.git/**",
120
+ "**/dist/**",
121
+ "**/build/**",
122
+ "**/coverage/**",
123
+ "**/.next/**",
124
+ "**/.nuxt/**",
125
+ "**/vendor/**",
126
+ "**/target/**",
127
+ "**/bin/**",
128
+ "**/obj/**",
129
+ "**/.cache/**",
130
+ "**/__pycache__/**",
131
+ "**/*.pyc",
132
+ "**/*.min.js",
133
+ "**/*.min.css",
134
+ "**/*.map",
135
+ "**/*.lock",
136
+ "**/package-lock.json",
137
+ "**/yarn.lock",
138
+ "**/pnpm-lock.yaml",
139
+ // Exclude .env files from source scanning — they're parsed separately by parser.ts
140
+ ".env",
141
+ "**/.env",
142
+ ".env.*",
143
+ "**/.env.*"
144
+ ];
145
+ async function findSourceFiles(root, options = {}) {
146
+ const { ignore = [], respectGitignore = true } = options;
147
+ const gitignorePatterns = respectGitignore ? await loadGitignorePatterns(root) : [];
148
+ const allIgnore = [...ALWAYS_IGNORE, ...gitignorePatterns, ...ignore];
149
+ const files = await fg("**/*", {
150
+ cwd: root,
151
+ ignore: allIgnore,
152
+ absolute: false,
153
+ dot: true,
154
+ onlyFiles: true,
155
+ followSymbolicLinks: false
156
+ });
157
+ return files.sort();
158
+ }
159
+ function resolveRoot(providedRoot) {
160
+ if (providedRoot) {
161
+ return path2.resolve(providedRoot);
162
+ }
163
+ return process.cwd();
164
+ }
165
+
166
+ // src/core/scanner.ts
167
+ async function scanProjectFiles(root, options = {}) {
168
+ const allFiles = await findSourceFiles(root, options);
169
+ const results = await Promise.allSettled(
170
+ allFiles.map((file) => isTextFile(path3.join(root, file)).then((isText) => isText ? file : null))
171
+ );
172
+ const textFiles = [];
173
+ let skippedFiles = 0;
174
+ for (const result of results) {
175
+ if (result.status === "fulfilled" && result.value !== null) {
176
+ textFiles.push(result.value);
177
+ } else {
178
+ skippedFiles++;
179
+ }
180
+ }
181
+ return { files: textFiles, skippedFiles };
182
+ }
183
+ async function isTextFile(fullPath) {
184
+ let fd = null;
185
+ try {
186
+ fd = await open(fullPath, "r");
187
+ const buffer = Buffer.alloc(1024);
188
+ const { bytesRead } = await fd.read(buffer, 0, 1024, 0);
189
+ return !buffer.subarray(0, bytesRead).includes(0);
190
+ } catch {
191
+ return false;
192
+ } finally {
193
+ await fd?.close().catch(() => void 0);
194
+ }
195
+ }
196
+
197
+ // src/core/detector.ts
198
+ import { readFile as readFile3 } from "fs/promises";
199
+ import path4 from "path";
200
+ var PATTERN_DEFS = [
201
+ // JavaScript / TypeScript — process.env.VAR (dot notation)
202
+ {
203
+ lang: "js",
204
+ source: String.raw`process\.env\.([A-Z_][A-Z0-9_]*)`
205
+ },
206
+ // JavaScript / TypeScript — process.env['VAR'] or process.env["VAR"] (bracket notation)
207
+ {
208
+ lang: "js",
209
+ source: String.raw`process\.env\[['"]([A-Z_][A-Z0-9_]*)['"]\]`
210
+ },
211
+ // Vite / import.meta.env.VAR
212
+ {
213
+ lang: "js",
214
+ source: String.raw`import\.meta\.env\.([A-Z_][A-Z0-9_]*)`
215
+ },
216
+ // Python — os.environ.get('VAR') and os.getenv('VAR') (function call)
217
+ {
218
+ lang: "py",
219
+ source: String.raw`os\.(?:environ\.get|getenv)\(\s*['"]([A-Z_][A-Z0-9_]*)['"]\s*`,
220
+ extensions: [".py"]
221
+ },
222
+ // Python — os.environ['VAR'] (direct subscript)
223
+ {
224
+ lang: "py",
225
+ source: String.raw`os\.environ\[['"]([A-Z_][A-Z0-9_]*)['"]\]`,
226
+ extensions: [".py"]
227
+ },
228
+ // Go — os.Getenv("VAR")
229
+ {
230
+ lang: "go",
231
+ source: String.raw`os\.Getenv\(\s*"([A-Z_][A-Z0-9_]*)"\s*\)`,
232
+ extensions: [".go"]
233
+ },
234
+ // Rust — env::var("VAR") or std::env::var("VAR")
235
+ {
236
+ lang: "rs",
237
+ source: String.raw`env::var\(\s*"([A-Z_][A-Z0-9_]*)"\s*\)`,
238
+ extensions: [".rs"]
239
+ },
240
+ // Ruby — ENV['VAR'] or ENV["VAR"]
241
+ {
242
+ lang: "rb",
243
+ source: String.raw`ENV\[['"]([A-Z_][A-Z0-9_]*)['"]\]`,
244
+ extensions: [".rb"]
245
+ },
246
+ // PHP — $_ENV['VAR'] or $_ENV["VAR"] (array subscript)
247
+ {
248
+ lang: "php",
249
+ source: String.raw`\$_ENV\[['"]([A-Z_][A-Z0-9_]*)['"]\]`,
250
+ extensions: [".php"]
251
+ },
252
+ // PHP — getenv('VAR') (function call)
253
+ {
254
+ lang: "php",
255
+ source: String.raw`getenv\(\s*['"]([A-Z_][A-Z0-9_]*)['"]\s*\)`,
256
+ extensions: [".php"]
257
+ },
258
+ // Docker Compose / Shell — ${VAR} syntax (restrict to avoid false positives)
259
+ {
260
+ lang: "sh",
261
+ source: String.raw`\$\{([A-Z_][A-Z0-9_]*)\}`,
262
+ extensions: [
263
+ ".sh",
264
+ ".bash",
265
+ ".zsh",
266
+ ".fish",
267
+ "Dockerfile",
268
+ ".dockerfile",
269
+ "docker-compose.yml",
270
+ "docker-compose.yaml",
271
+ ".env.example"
272
+ ]
273
+ },
274
+ // Shell — $VAR syntax (only in shell scripts)
275
+ {
276
+ lang: "sh",
277
+ source: String.raw`(?<![A-Za-z0-9_])\$([A-Z_][A-Z0-9_]*)(?![A-Za-z0-9_({])`,
278
+ extensions: [".sh", ".bash", ".zsh"]
279
+ }
280
+ ];
281
+ function getLineColumn(content, index) {
282
+ const before = content.slice(0, index);
283
+ const lines = before.split("\n");
284
+ const lastLine = lines[lines.length - 1] ?? "";
285
+ return {
286
+ line: lines.length,
287
+ column: lastLine.length + 1
288
+ };
289
+ }
290
+ function isCommentedLine(line) {
291
+ const trimmed = line.trimStart();
292
+ return trimmed.startsWith("//") || trimmed.startsWith("#") || trimmed.startsWith("*") || trimmed.startsWith("/*") || trimmed.startsWith("--") || trimmed.startsWith("<!--");
293
+ }
294
+ function fileMatchesPattern(filePath, extensions) {
295
+ const ext = path4.extname(filePath).toLowerCase();
296
+ const basename = path4.basename(filePath);
297
+ return extensions.some((e) => ext === e || basename === e || basename.endsWith(e));
298
+ }
299
+ function detectVarsInContent(content, filePath) {
300
+ const lines = content.split("\n");
301
+ const seen = /* @__PURE__ */ new Set();
302
+ const refs = [];
303
+ for (const def of PATTERN_DEFS) {
304
+ if (def.extensions && !fileMatchesPattern(filePath, def.extensions)) continue;
305
+ const regex = new RegExp(def.source, "g");
306
+ let match;
307
+ while ((match = regex.exec(content)) !== null) {
308
+ const varName = match[1];
309
+ if (!varName) continue;
310
+ const pos = getLineColumn(content, match.index);
311
+ const lineContent = lines[pos.line - 1] ?? "";
312
+ if (isCommentedLine(lineContent)) continue;
313
+ const key = `${varName}:${pos.line}:${pos.column}`;
314
+ if (seen.has(key)) continue;
315
+ seen.add(key);
316
+ refs.push({
317
+ name: varName,
318
+ file: filePath,
319
+ line: pos.line,
320
+ column: pos.column,
321
+ pattern: def.lang,
322
+ context: lineContent.trim()
323
+ });
324
+ }
325
+ }
326
+ return refs;
327
+ }
328
+ async function detectVarsInFile(root, relativePath) {
329
+ const fullPath = path4.join(root, relativePath);
330
+ const content = await readFile3(fullPath, "utf-8");
331
+ return detectVarsInContent(content, relativePath);
332
+ }
333
+
334
+ // src/core/analyzer.ts
335
+ function analyze(input) {
336
+ const { foundVars, envVars, exampleVars } = input;
337
+ const issues = [];
338
+ for (const [name, refs] of foundVars) {
339
+ const inEnv = envVars.has(name);
340
+ const inExample = exampleVars.has(name);
341
+ if (!inEnv && !inExample) {
342
+ issues.push({
343
+ severity: "error",
344
+ type: "missing",
345
+ variable: name,
346
+ message: `${name} is referenced in ${refs.length} location(s) but not defined in .env or .env.example`,
347
+ references: refs,
348
+ suggestion: `Add ${name} to .env.example`
349
+ });
350
+ } else if (!inEnv && inExample) {
351
+ const def = exampleVars.get(name);
352
+ issues.push({
353
+ severity: "warn",
354
+ type: "missing",
355
+ variable: name,
356
+ message: `${name} is referenced but missing from .env (documented in .env.example)`,
357
+ references: refs,
358
+ ...def !== void 0 ? { definition: def } : {},
359
+ suggestion: `Copy ${name} from .env.example to .env and set a real value`
360
+ });
361
+ }
362
+ }
363
+ for (const [name, def] of envVars) {
364
+ if (!foundVars.has(name)) {
365
+ issues.push({
366
+ severity: "warn",
367
+ type: "unused",
368
+ variable: name,
369
+ message: `${name} is defined in .env but never referenced in source code`,
370
+ definition: def,
371
+ suggestion: `Remove ${name} from .env, or check for typos in variable name`
372
+ });
373
+ }
374
+ if (!exampleVars.has(name)) {
375
+ issues.push({
376
+ severity: "warn",
377
+ type: "example-drift",
378
+ variable: name,
379
+ message: `${name} exists in .env but is missing from .env.example`,
380
+ definition: def,
381
+ suggestion: `Run \`env-doctor check --fix\` to sync .env.example`
382
+ });
383
+ }
384
+ }
385
+ for (const [name, def] of exampleVars) {
386
+ if (!foundVars.has(name) && !envVars.has(name)) {
387
+ issues.push({
388
+ severity: "warn",
389
+ type: "example-drift",
390
+ variable: name,
391
+ message: `${name} is in .env.example but not referenced in code or .env`,
392
+ definition: def,
393
+ suggestion: `Remove ${name} from .env.example if it is no longer needed`
394
+ });
395
+ }
396
+ }
397
+ return issues.sort((a, b) => {
398
+ if (a.severity !== b.severity) {
399
+ return a.severity === "error" ? -1 : 1;
400
+ }
401
+ return a.variable.localeCompare(b.variable);
402
+ });
403
+ }
404
+
405
+ // src/utils/logger.ts
406
+ import chalk from "chalk";
407
+ var colorEnabled = true;
408
+ function setColorEnabled(enabled) {
409
+ colorEnabled = enabled;
410
+ }
411
+ function c(colorFn, msg) {
412
+ return colorEnabled ? colorFn(msg) : msg;
413
+ }
414
+ var logger = {
415
+ error: (msg) => {
416
+ process.stderr.write(c(chalk.red, msg) + "\n");
417
+ },
418
+ warn: (msg) => {
419
+ process.stderr.write(c(chalk.yellow, msg) + "\n");
420
+ },
421
+ info: (msg) => {
422
+ console.log(c(chalk.blue, msg));
423
+ },
424
+ success: (msg) => {
425
+ console.log(c(chalk.green, msg));
426
+ },
427
+ header: (msg) => {
428
+ console.log(c(chalk.bold.white, msg));
429
+ },
430
+ dim: (msg) => {
431
+ console.log(c(chalk.dim, msg));
432
+ },
433
+ log: (msg) => {
434
+ console.log(msg);
435
+ },
436
+ red: (msg) => c(chalk.red, msg),
437
+ yellow: (msg) => c(chalk.yellow, msg),
438
+ green: (msg) => c(chalk.green, msg),
439
+ blue: (msg) => c(chalk.blue, msg),
440
+ cyan: (msg) => c(chalk.cyan, msg),
441
+ bold: (msg) => c(chalk.bold, msg),
442
+ dim_: (msg) => c(chalk.dim, msg),
443
+ gray: (msg) => c(chalk.gray, msg)
444
+ };
445
+
446
+ // src/core/reporter.ts
447
+ function reportPretty(result) {
448
+ const { issues, scannedFiles, skippedFiles, duration } = result;
449
+ const errors = issues.filter((i) => i.severity === "error");
450
+ const warns = issues.filter((i) => i.severity === "warn");
451
+ console.log("");
452
+ if (errors.length === 0 && warns.length === 0) {
453
+ logger.success(` \u2713 All environment variables are properly configured`);
454
+ printStats(scannedFiles, skippedFiles, duration);
455
+ return;
456
+ }
457
+ if (errors.length > 0) {
458
+ console.log(logger.bold(logger.red(` ERRORS (${errors.length})`)));
459
+ console.log("");
460
+ for (const issue of errors) {
461
+ printIssuePretty(issue, "error");
462
+ }
463
+ }
464
+ if (warns.length > 0) {
465
+ console.log(logger.bold(logger.yellow(` WARNINGS (${warns.length})`)));
466
+ console.log("");
467
+ for (const issue of warns) {
468
+ printIssuePretty(issue, "warn");
469
+ }
470
+ }
471
+ printStats(scannedFiles, skippedFiles, duration);
472
+ }
473
+ function printIssuePretty(issue, level) {
474
+ const icon = level === "error" ? logger.red(" \u2717") : logger.yellow(" \u26A0");
475
+ const typeLbl = formatIssueType(issue.type);
476
+ const varName = level === "error" ? logger.bold(logger.red(issue.variable)) : logger.bold(logger.yellow(issue.variable));
477
+ console.log(`${icon} ${varName} ${logger.gray(`[${typeLbl}]`)}`);
478
+ console.log(` ${logger.gray(issue.message)}`);
479
+ if (issue.references) {
480
+ for (const ref of issue.references.slice(0, 3)) {
481
+ console.log(` ${logger.dim_("\u2514\u2500")} ${logger.cyan(`${ref.file}:${ref.line}`)} ${logger.dim_(ref.context)}`);
482
+ }
483
+ if (issue.references.length > 3) {
484
+ console.log(` ${logger.dim_(` ... and ${issue.references.length - 3} more`)}`);
485
+ }
486
+ }
487
+ if (issue.definition) {
488
+ const loc = `${issue.definition.file}:${issue.definition.line}`;
489
+ console.log(` ${logger.dim_("\u2514\u2500")} ${logger.cyan(loc)}`);
490
+ }
491
+ if (issue.suggestion) {
492
+ console.log(` ${logger.green("\u2192")} ${issue.suggestion}`);
493
+ }
494
+ console.log("");
495
+ }
496
+ function printStats(scannedFiles, skippedFiles, duration) {
497
+ console.log("");
498
+ console.log(
499
+ logger.dim_(
500
+ ` Scanned ${scannedFiles} files` + (skippedFiles > 0 ? ` (${skippedFiles} skipped)` : "") + ` in ${duration}ms`
501
+ )
502
+ );
503
+ console.log("");
504
+ }
505
+ function formatIssueType(type) {
506
+ const labels = {
507
+ missing: "Missing Required",
508
+ unused: "Unused Variable",
509
+ "example-drift": "Example Drift",
510
+ "type-mismatch": "Type Mismatch"
511
+ };
512
+ return labels[type] ?? type;
513
+ }
514
+ function buildJsonOutput(result) {
515
+ return {
516
+ success: result.issues.filter((i) => i.severity === "error").length === 0,
517
+ summary: {
518
+ errors: result.issues.filter((i) => i.severity === "error").length,
519
+ warnings: result.issues.filter((i) => i.severity === "warn").length,
520
+ scannedFiles: result.scannedFiles,
521
+ skippedFiles: result.skippedFiles,
522
+ duration: result.duration
523
+ },
524
+ issues: result.issues.map((issue) => ({
525
+ severity: issue.severity,
526
+ type: issue.type,
527
+ variable: issue.variable,
528
+ message: issue.message,
529
+ ...issue.references !== void 0 ? { references: issue.references.map((r) => ({ file: r.file, line: r.line, column: r.column, context: r.context })) } : {},
530
+ ...issue.definition !== void 0 ? { definition: { file: issue.definition.file, line: issue.definition.line } } : {},
531
+ ...issue.suggestion !== void 0 ? { suggestion: issue.suggestion } : {}
532
+ }))
533
+ };
534
+ }
535
+ function reportJson(result) {
536
+ console.log(JSON.stringify(buildJsonOutput(result), null, 2));
537
+ }
538
+ function reportMarkdown(result) {
539
+ const { issues, scannedFiles, duration } = result;
540
+ const errors = issues.filter((i) => i.severity === "error");
541
+ const warns = issues.filter((i) => i.severity === "warn");
542
+ const lines = [];
543
+ lines.push("## Environment Variable Report");
544
+ lines.push("");
545
+ if (errors.length === 0 && warns.length === 0) {
546
+ lines.push("\u2705 All environment variables are properly configured.");
547
+ } else {
548
+ const badge = errors.length > 0 ? "\u{1F534}" : "\u{1F7E1}";
549
+ lines.push(`${badge} **${errors.length} error(s), ${warns.length} warning(s)**`);
550
+ lines.push("");
551
+ if (errors.length > 0) {
552
+ lines.push("### Errors");
553
+ lines.push("");
554
+ for (const issue of errors) {
555
+ lines.push(issueToMarkdown(issue));
556
+ }
557
+ }
558
+ if (warns.length > 0) {
559
+ lines.push("### Warnings");
560
+ lines.push("");
561
+ for (const issue of warns) {
562
+ lines.push(issueToMarkdown(issue));
563
+ }
564
+ }
565
+ }
566
+ lines.push("---");
567
+ lines.push(`_Scanned ${scannedFiles} files in ${duration}ms_`);
568
+ console.log(lines.join("\n"));
569
+ }
570
+ function issueToMarkdown(issue) {
571
+ const parts = [];
572
+ const icon = issue.severity === "error" ? "\u274C" : "\u26A0\uFE0F";
573
+ parts.push(`#### ${icon} \`${issue.variable}\``);
574
+ parts.push("");
575
+ parts.push(issue.message);
576
+ if (issue.references && issue.references.length > 0) {
577
+ parts.push("");
578
+ parts.push("**References:**");
579
+ for (const ref of issue.references) {
580
+ parts.push(`- \`${ref.file}:${ref.line}\` \u2014 \`${ref.context}\``);
581
+ }
582
+ }
583
+ if (issue.suggestion) {
584
+ parts.push("");
585
+ parts.push(`> \u{1F4A1} ${issue.suggestion}`);
586
+ }
587
+ parts.push("");
588
+ return parts.join("\n");
589
+ }
590
+ function reportDoctor(result, health) {
591
+ const scoreColor = health.score >= 80 ? logger.green : health.score >= 50 ? logger.yellow : logger.red;
592
+ const scoreIcon = health.score >= 80 ? "\u2713" : health.score >= 50 ? "\u26A0" : "\u2717";
593
+ console.log("");
594
+ console.log(logger.bold(" ENVIRONMENT HEALTH REPORT"));
595
+ console.log("");
596
+ console.log(` Health Score: ${scoreColor(`${health.score}/100`)} ${scoreIcon}`);
597
+ console.log("");
598
+ if (health.breakdown.length > 0) {
599
+ console.log(logger.bold(" Breakdown:"));
600
+ for (const item of health.breakdown) {
601
+ const pts = item.points > 0 ? logger.red(`-${item.points} pts`) : logger.green("0 pts");
602
+ console.log(` ${item.label.padEnd(25)} ${String(item.count).padStart(3)} (${pts})`);
603
+ }
604
+ console.log("");
605
+ }
606
+ if (health.mostProblematicFiles.length > 0) {
607
+ console.log(logger.bold(" Most Problematic Files:"));
608
+ health.mostProblematicFiles.forEach((f, idx) => {
609
+ console.log(` ${idx + 1}. ${logger.cyan(f.file)} ${logger.gray(`(${f.issueCount} issue${f.issueCount === 1 ? "" : "s"})`)}`);
610
+ });
611
+ console.log("");
612
+ }
613
+ if (health.recommendations.length > 0) {
614
+ console.log(logger.bold(" Recommendations:"));
615
+ for (const rec of health.recommendations) {
616
+ console.log(` ${logger.green("\u2192")} ${rec}`);
617
+ }
618
+ console.log("");
619
+ }
620
+ reportPretty(result);
621
+ }
622
+
623
+ // src/commands/check.ts
624
+ async function runCheck(options) {
625
+ const { format, noColor, strict, fix } = options;
626
+ setColorEnabled(!noColor);
627
+ const root = resolveRoot(options.root);
628
+ const envFilePath = path5.resolve(root, options.envFile);
629
+ const exampleFilePath = path5.resolve(root, options.exampleFile);
630
+ const start = Date.now();
631
+ const [envResult, exampleResult] = await Promise.all([
632
+ parseEnvFile(envFilePath, false),
633
+ parseEnvFile(exampleFilePath, true)
634
+ ]);
635
+ const { files, skippedFiles } = await scanProjectFiles(root, {
636
+ ignore: options.ignore,
637
+ respectGitignore: true
638
+ });
639
+ const refResults = await Promise.allSettled(
640
+ files.map((file) => detectVarsInFile(root, file))
641
+ );
642
+ const foundVars = /* @__PURE__ */ new Map();
643
+ for (const result of refResults) {
644
+ if (result.status !== "fulfilled") continue;
645
+ for (const ref of result.value) {
646
+ const existing = foundVars.get(ref.name) ?? [];
647
+ existing.push(ref);
648
+ foundVars.set(ref.name, existing);
649
+ }
650
+ }
651
+ const issues = analyze({
652
+ foundVars,
653
+ envVars: envResult.vars,
654
+ exampleVars: exampleResult.vars
655
+ });
656
+ const duration = Date.now() - start;
657
+ const scanResult = {
658
+ projectRoot: root,
659
+ scannedFiles: files.length,
660
+ skippedFiles,
661
+ foundVars,
662
+ envVars: envResult.vars,
663
+ exampleVars: exampleResult.vars,
664
+ issues,
665
+ duration
666
+ };
667
+ if (fix) {
668
+ await applyFix(scanResult, exampleFilePath);
669
+ }
670
+ switch (format) {
671
+ case "json":
672
+ reportJson(scanResult);
673
+ break;
674
+ case "markdown":
675
+ reportMarkdown(scanResult);
676
+ break;
677
+ default:
678
+ reportPretty(scanResult);
679
+ }
680
+ const hasErrors = issues.some((i) => i.severity === "error");
681
+ const hasWarns = issues.some((i) => i.severity === "warn");
682
+ if (hasErrors || strict && hasWarns) {
683
+ process.exit(1);
684
+ } else if (hasWarns) {
685
+ process.exit(2);
686
+ } else {
687
+ process.exit(0);
688
+ }
689
+ }
690
+ async function applyFix(result, exampleFilePath) {
691
+ const { foundVars, exampleVars } = result;
692
+ const newVars = [];
693
+ for (const [name] of foundVars) {
694
+ if (!exampleVars.has(name)) {
695
+ newVars.push(name);
696
+ }
697
+ }
698
+ if (newVars.length === 0) return;
699
+ let existing = "";
700
+ try {
701
+ existing = await readFile4(exampleFilePath, "utf-8");
702
+ } catch {
703
+ }
704
+ const additions = newVars.sort().map((name) => {
705
+ const refs = result.foundVars.get(name) ?? [];
706
+ const firstRef = refs[0];
707
+ const comment = firstRef ? ` # referenced in ${firstRef.file}:${firstRef.line}` : "";
708
+ return `${name}=${comment}`;
709
+ }).join("\n");
710
+ const newContent = existing ? existing.trimEnd() + "\n\n# Added by env-doctor check --fix\n" + additions + "\n" : additions + "\n";
711
+ await writeFile(exampleFilePath, newContent, "utf-8");
712
+ console.log(`
713
+ Fixed: added ${newVars.length} variable(s) to ${result.exampleVars.size > 0 ? exampleFilePath : ".env.example"}`);
714
+ }
715
+
716
+ // src/commands/init.ts
717
+ import path6 from "path";
718
+ import { writeFile as writeFile2, readFile as readFile5 } from "fs/promises";
719
+ async function runInit(options) {
720
+ setColorEnabled(!options.noColor);
721
+ const root = resolveRoot(options.root);
722
+ const envFilePath = path6.resolve(root, options.envFile);
723
+ const exampleFilePath = path6.resolve(root, options.exampleFile);
724
+ logger.header("\n Generating .env.example...\n");
725
+ const start = Date.now();
726
+ const [envResult, existingExampleResult] = await Promise.all([
727
+ parseEnvFile(envFilePath, false),
728
+ parseEnvFile(exampleFilePath, true)
729
+ ]);
730
+ const { files } = await scanProjectFiles(root, {
731
+ ignore: options.ignore,
732
+ respectGitignore: true
733
+ });
734
+ const refResults = await Promise.allSettled(
735
+ files.map((file) => detectVarsInFile(root, file))
736
+ );
737
+ const foundVarsMap = /* @__PURE__ */ new Map();
738
+ for (const result of refResults) {
739
+ if (result.status !== "fulfilled") continue;
740
+ for (const ref of result.value) {
741
+ if (!foundVarsMap.has(ref.name)) {
742
+ foundVarsMap.set(ref.name, ref);
743
+ }
744
+ }
745
+ }
746
+ const allVarNames = /* @__PURE__ */ new Set([
747
+ ...foundVarsMap.keys(),
748
+ ...envResult.vars.keys()
749
+ ]);
750
+ const existingNames = new Set(existingExampleResult.vars.keys());
751
+ const newVars = [];
752
+ const presentVars = [];
753
+ for (const name of allVarNames) {
754
+ if (existingNames.has(name)) {
755
+ presentVars.push(name);
756
+ } else {
757
+ newVars.push(name);
758
+ }
759
+ }
760
+ if (newVars.length === 0 && existingExampleResult.exists) {
761
+ logger.success(` \u2713 .env.example is already up to date (${presentVars.length} variable${presentVars.length === 1 ? "" : "s"})
762
+ `);
763
+ return;
764
+ }
765
+ const content = buildExampleContent({
766
+ existingContent: existingExampleResult.exists ? await readFile5(exampleFilePath, "utf-8").catch(() => "") : null,
767
+ newVars,
768
+ presentVars,
769
+ foundVarsMap,
770
+ envVars: envResult.vars,
771
+ withComments: options.withComments
772
+ });
773
+ await writeFile2(exampleFilePath, content, "utf-8");
774
+ const duration = Date.now() - start;
775
+ logger.success(
776
+ ` \u2713 ${existingExampleResult.exists ? "Updated" : "Created"} ${options.exampleFile} with ${allVarNames.size} variable${allVarNames.size === 1 ? "" : "s"} (${newVars.length} new, ${presentVars.length} already present)
777
+ `
778
+ );
779
+ if (newVars.length > 0) {
780
+ logger.bold(" New variables added:");
781
+ for (const name of newVars.sort()) {
782
+ const ref = foundVarsMap.get(name);
783
+ const loc = ref ? logger.dim_(` # from ${ref.file}:${ref.line}`) : "";
784
+ logger.log(` ${logger.cyan(name)}${loc}`);
785
+ }
786
+ }
787
+ logger.dim_(`
788
+ Completed in ${duration}ms
789
+ `);
790
+ }
791
+ function buildExampleContent(opts) {
792
+ const { existingContent, newVars, foundVarsMap, envVars, withComments } = opts;
793
+ if (existingContent !== null && newVars.length > 0) {
794
+ const additions = newVars.sort().map((name) => formatVar(name, foundVarsMap.get(name), envVars, withComments)).join("\n");
795
+ return existingContent.trimEnd() + "\n\n# Added by env-doctor init\n" + additions + "\n";
796
+ }
797
+ const allNames = [.../* @__PURE__ */ new Set([...foundVarsMap.keys(), ...envVars.keys()])].sort();
798
+ const lines = [
799
+ "# Environment Variables",
800
+ "# Generated by env-doctor init \u2014 do not commit real values",
801
+ ""
802
+ ];
803
+ for (const name of allNames) {
804
+ lines.push(formatVar(name, foundVarsMap.get(name), envVars, withComments));
805
+ }
806
+ return lines.join("\n") + "\n";
807
+ }
808
+ function formatVar(name, ref, envVars, withComments) {
809
+ const def = envVars.get(name);
810
+ const placeholder = def?.value ? `# ${def.value}` : "";
811
+ const comment = withComments && ref ? ` # referenced in ${ref.file}:${ref.line}` : "";
812
+ return `${name}=${placeholder}${comment}`;
813
+ }
814
+
815
+ // src/commands/doctor.ts
816
+ import path7 from "path";
817
+ import { readFile as readFile6, writeFile as writeFile3, mkdir } from "fs/promises";
818
+ var SEVERITY_POINTS = { error: 10, warn: 3, info: 0 };
819
+ var CACHE_DIR = ".env-doctor";
820
+ var CACHE_FILE = "cache.json";
821
+ async function runDoctor(options) {
822
+ setColorEnabled(!options.noColor);
823
+ const root = resolveRoot(options.root);
824
+ const envFilePath = path7.resolve(root, options.envFile);
825
+ const exampleFilePath = path7.resolve(root, options.exampleFile);
826
+ const start = Date.now();
827
+ const [envResult, exampleResult] = await Promise.all([
828
+ parseEnvFile(envFilePath, false),
829
+ parseEnvFile(exampleFilePath, true)
830
+ ]);
831
+ const { files, skippedFiles } = await scanProjectFiles(root, {
832
+ ignore: options.ignore,
833
+ respectGitignore: true
834
+ });
835
+ const refResults = await Promise.allSettled(
836
+ files.map((file) => detectVarsInFile(root, file))
837
+ );
838
+ const foundVars = /* @__PURE__ */ new Map();
839
+ for (const result of refResults) {
840
+ if (result.status !== "fulfilled") continue;
841
+ for (const ref of result.value) {
842
+ const existing = foundVars.get(ref.name) ?? [];
843
+ existing.push(ref);
844
+ foundVars.set(ref.name, existing);
845
+ }
846
+ }
847
+ const issues = analyze({
848
+ foundVars,
849
+ envVars: envResult.vars,
850
+ exampleVars: exampleResult.vars
851
+ });
852
+ const duration = Date.now() - start;
853
+ const scanResult = {
854
+ projectRoot: root,
855
+ scannedFiles: files.length,
856
+ skippedFiles,
857
+ foundVars,
858
+ envVars: envResult.vars,
859
+ exampleVars: exampleResult.vars,
860
+ issues,
861
+ duration
862
+ };
863
+ const health = buildHealthReport(scanResult);
864
+ const previous = await loadCache(root);
865
+ if (previous) {
866
+ const delta = health.score - previous.healthScore;
867
+ const trend = delta > 0 ? `+${delta}` : String(delta);
868
+ const arrow = delta > 0 ? "\u2191" : delta < 0 ? "\u2193" : "\u2192";
869
+ console.log(`
870
+ Trend: ${arrow} ${trend} pts from last scan (${previous.timestamp})`);
871
+ }
872
+ await saveCache(root, health);
873
+ reportDoctor(scanResult, health);
874
+ const hasErrors = issues.some((i) => i.severity === "error");
875
+ const hasWarns = issues.some((i) => i.severity === "warn");
876
+ if (hasErrors || options.strict && hasWarns) {
877
+ process.exit(1);
878
+ } else if (hasWarns) {
879
+ process.exit(2);
880
+ }
881
+ }
882
+ function buildHealthReport(result) {
883
+ const { issues } = result;
884
+ const errorCount = issues.filter((i) => i.severity === "error").length;
885
+ const warnCount = issues.filter((i) => i.severity === "warn").length;
886
+ const totalPoints = errorCount * SEVERITY_POINTS.error + warnCount * SEVERITY_POINTS.warn;
887
+ const score = Math.max(0, 100 - totalPoints);
888
+ const typeCounts = {};
889
+ for (const issue of issues) {
890
+ typeCounts[issue.type] = (typeCounts[issue.type] ?? 0) + 1;
891
+ }
892
+ const breakdown = Object.entries(typeCounts).map(([type, count]) => {
893
+ const pts = issues.filter((i) => i.type === type).reduce((sum, i) => sum + SEVERITY_POINTS[i.severity], 0);
894
+ return {
895
+ label: formatTypeLabel(type),
896
+ count,
897
+ points: pts
898
+ };
899
+ });
900
+ const fileCounts = /* @__PURE__ */ new Map();
901
+ for (const issue of issues) {
902
+ for (const ref of issue.references ?? []) {
903
+ fileCounts.set(ref.file, (fileCounts.get(ref.file) ?? 0) + 1);
904
+ }
905
+ }
906
+ const mostProblematicFiles = [...fileCounts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5).map(([file, issueCount]) => ({ file, issueCount }));
907
+ const recommendations = [];
908
+ if (issues.some((i) => i.type === "missing" && i.severity === "error")) {
909
+ recommendations.push("Run `env-doctor check --fix` to update .env.example");
910
+ }
911
+ const unusedVars = issues.filter((i) => i.type === "unused").map((i) => i.variable);
912
+ if (unusedVars.length > 0) {
913
+ const list = unusedVars.slice(0, 3).join(", ");
914
+ const more = unusedVars.length > 3 ? ` (+${unusedVars.length - 3} more)` : "";
915
+ recommendations.push(`Review unused variables: ${list}${more}`);
916
+ }
917
+ if (issues.some((i) => i.type === "example-drift")) {
918
+ recommendations.push("Run `env-doctor init` to regenerate .env.example from current code");
919
+ }
920
+ return {
921
+ score,
922
+ errorCount,
923
+ warnCount,
924
+ breakdown,
925
+ mostProblematicFiles,
926
+ recommendations
927
+ };
928
+ }
929
+ function formatTypeLabel(type) {
930
+ const labels = {
931
+ missing: "Missing Required",
932
+ unused: "Unused Variables",
933
+ "example-drift": "Example Drift",
934
+ "type-mismatch": "Type Mismatches"
935
+ };
936
+ return labels[type] ?? type;
937
+ }
938
+ async function loadCache(root) {
939
+ const cachePath = path7.join(root, CACHE_DIR, CACHE_FILE);
940
+ try {
941
+ const raw = await readFile6(cachePath, "utf-8");
942
+ return JSON.parse(raw);
943
+ } catch {
944
+ return null;
945
+ }
946
+ }
947
+ async function saveCache(root, health) {
948
+ const cacheDir = path7.join(root, CACHE_DIR);
949
+ const cachePath = path7.join(cacheDir, CACHE_FILE);
950
+ try {
951
+ await mkdir(cacheDir, { recursive: true });
952
+ const entry = {
953
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
954
+ healthScore: health.score,
955
+ errorCount: health.errorCount,
956
+ warnCount: health.warnCount
957
+ };
958
+ await writeFile3(cachePath, JSON.stringify(entry, null, 2), "utf-8");
959
+ } catch {
960
+ }
961
+ }
962
+
963
+ // src/index.ts
964
+ var DEFAULT_IGNORE = ["node_modules", "dist", ".git"];
965
+ var program = new Command();
966
+ program.name("env-doctor").description("The eslint of environment variables \u2014 catch missing env vars before they hit production").version("1.0.0");
967
+ program.command("check").description("Scan codebase and report environment variable discrepancies").option("--fix", "Auto-update .env.example to match code references", false).option("--strict", "Treat warnings as errors (exit 1)", false).option("--env-file <path>", "Path to .env file", ".env").option("--example-file <path>", "Path to .env.example file", ".env.example").option("--ignore <patterns...>", "Additional glob patterns to skip", DEFAULT_IGNORE).option("--no-color", "Disable ANSI color output").option("--root <path>", "Project root directory (default: cwd)").addOption(
968
+ new Option("--format <format>", "Output format").choices(["pretty", "json", "markdown"]).default("pretty")
969
+ ).action(async (opts) => {
970
+ const options = {
971
+ fix: Boolean(opts["fix"]),
972
+ strict: Boolean(opts["strict"]),
973
+ envFile: String(opts["envFile"] ?? ".env"),
974
+ exampleFile: String(opts["exampleFile"] ?? ".env.example"),
975
+ ignore: Array.isArray(opts["ignore"]) ? opts["ignore"] : DEFAULT_IGNORE,
976
+ format: opts["format"] ?? "pretty",
977
+ noColor: !opts["color"],
978
+ root: String(opts["root"] ?? "")
979
+ };
980
+ await runCheck(options).catch(fatalError);
981
+ });
982
+ program.command("init").description("Generate or update .env.example from code references and .env").option("--env-file <path>", "Path to .env file", ".env").option("--example-file <path>", "Path to .env.example file", ".env.example").option("--ignore <patterns...>", "Additional glob patterns to skip", DEFAULT_IGNORE).option("--with-comments", "Add source file comments to each variable", false).option("--no-color", "Disable ANSI color output").option("--root <path>", "Project root directory (default: cwd)").action(async (opts) => {
983
+ const options = {
984
+ envFile: String(opts["envFile"] ?? ".env"),
985
+ exampleFile: String(opts["exampleFile"] ?? ".env.example"),
986
+ ignore: Array.isArray(opts["ignore"]) ? opts["ignore"] : DEFAULT_IGNORE,
987
+ withComments: Boolean(opts["withComments"]),
988
+ noColor: !opts["color"],
989
+ root: String(opts["root"] ?? "")
990
+ };
991
+ await runInit(options).catch(fatalError);
992
+ });
993
+ program.command("doctor").description("Full diagnostic with health score and trend analysis").option("--strict", "Treat warnings as errors (exit 1)", false).option("--env-file <path>", "Path to .env file", ".env").option("--example-file <path>", "Path to .env.example file", ".env.example").option("--ignore <patterns...>", "Additional glob patterns to skip", DEFAULT_IGNORE).option("--no-color", "Disable ANSI color output").option("--root <path>", "Project root directory (default: cwd)").addOption(
994
+ new Option("--format <format>", "Output format").choices(["pretty", "json", "markdown"]).default("pretty")
995
+ ).action(async (opts) => {
996
+ const options = {
997
+ fix: false,
998
+ strict: Boolean(opts["strict"]),
999
+ envFile: String(opts["envFile"] ?? ".env"),
1000
+ exampleFile: String(opts["exampleFile"] ?? ".env.example"),
1001
+ ignore: Array.isArray(opts["ignore"]) ? opts["ignore"] : DEFAULT_IGNORE,
1002
+ format: opts["format"] ?? "pretty",
1003
+ noColor: !opts["color"],
1004
+ root: String(opts["root"] ?? "")
1005
+ };
1006
+ await runDoctor(options).catch(fatalError);
1007
+ });
1008
+ function fatalError(err) {
1009
+ const message = err instanceof Error ? err.message : String(err);
1010
+ process.stderr.write(`
1011
+ Fatal error: ${message}
1012
+
1013
+ `);
1014
+ process.exit(3);
1015
+ }
1016
+ program.parseAsync(process.argv).catch(fatalError);
1017
+ //# sourceMappingURL=index.js.map