dotenv-diff 1.6.4 → 2.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/README.md CHANGED
@@ -2,6 +2,10 @@
2
2
 
3
3
  Easily compare your .env, .env.example, and other environment files (like .env.local, .env.production) to detect missing, extra, empty, or mismatched variables — and ensure they’re properly ignored by Git.
4
4
 
5
+ Or scan your codebase to find out which environment variables are actually used in your code, and which ones are not.
6
+
7
+ Optimized for JavaScript/TypeScript projects and frontend frameworks including Node.js, Next.js, Vite, SvelteKit, Nuxt, Vue, and Deno. Can also be used with other project types for basic .env file comparison.
8
+
5
9
  [![npm version](https://img.shields.io/npm/v/dotenv-diff.svg)](https://www.npmjs.com/package/dotenv-diff)
6
10
  [![npm downloads](https://img.shields.io/npm/dt/dotenv-diff.svg)](https://www.npmjs.com/package/dotenv-diff)
7
11
 
@@ -31,6 +35,38 @@ dotenv-diff will automatically compare all matching .env* files in your project
31
35
  - `.env.production`
32
36
  - Any other .env.* file
33
37
 
38
+ ## Scan your codebase for environment variable usage
39
+
40
+ ```bash
41
+ dotenv-diff --scan-usage
42
+ ```
43
+ This scans your entire codebase to detect which environment variables are actually used in the code — and compare them against your `.env` file(s).
44
+
45
+ ## Show unused variables
46
+
47
+ Use `--show-unused` together with `--scan-usage` to list variables that are defined in `.env` but never used in your codebase:
48
+ ```bash
49
+ dotenv-diff --scan-usage --show-unused
50
+ ```
51
+ This will show you which variables are defined in your `.env` file but not used in your codebase. This helps you clean up unnecessary environment variables.
52
+
53
+ ## Show scan statistics
54
+
55
+ ```bash
56
+ dotenv-diff --show-stats
57
+ ```
58
+ This will display statistics about the scan, such as the number of files scanned, variables found, and any unused variables. It provides a quick overview of your environment variable usage.
59
+
60
+ ## include or exclude specific files for scanning
61
+
62
+ You can specify which files to include or exclude from the scan using the `--include-files` and `--exclude-files` options:
63
+
64
+ ```bash
65
+ dotenv-diff --scan-usage --include-files '**/*.js,**/*.ts' --exclude-files '**/*.spec.ts'
66
+ ```
67
+
68
+ This allows you to focus the scan on specific file types or directories, making it more efficient and tailored to your project structure.
69
+
34
70
  ## Optional: Check values too
35
71
 
36
72
  ```bash
@@ -43,6 +79,15 @@ When using the `--check-values` option, the tool will also compare the actual va
43
79
 
44
80
  `dotenv-diff` warns when a `.env*` file contains the same key multiple times. The last occurrence wins. Suppress these warnings with `--allow-duplicates`.
45
81
 
82
+ ## Only show specific categories
83
+
84
+ Use the `--only` flag to restrict the comparison to specific categories. For example:
85
+
86
+ ```bash
87
+ dotenv-diff --only missing,extra
88
+ ```
89
+ This will only show missing and extra keys, ignoring empty, mismatched, duplicate keys and so on.
90
+
46
91
  ## Ignore specific keys
47
92
 
48
93
  Exclude certain keys from the comparison using `--ignore` for exact names or `--ignore-regex` for patterns:
@@ -11,5 +11,11 @@ export function createProgram() {
11
11
  .option('--allow-duplicates', 'Do not warn about duplicate keys in .env* files')
12
12
  .option('--ignore <keys>', 'Comma-separated list of keys to ignore')
13
13
  .option('--ignore-regex <pattern>', 'Regex pattern to ignore matching keys')
14
- .option('--json', 'Output results in JSON format');
14
+ .option('--json', 'Output results in JSON format')
15
+ .option('--only <list>', 'Comma-separated categories to only run (missing,extra,empty,mismatch,duplicate,gitignore)')
16
+ .option('--scan-usage', 'Scan codebase for environment variable usage')
17
+ .option('--include-files <patterns>', '[requires --scan-usage] Comma-separated file patterns to include in scan (default: **/*.{js,ts,jsx,tsx,vue,svelte})')
18
+ .option('--exclude-files <patterns>', '[requires --scan-usage] Comma-separated file patterns to exclude from scan')
19
+ .option('--show-unused', '[requires --scan-usage] Show variables defined in .env but not used in code')
20
+ .option('--show-stats', 'Show statistics (in scan-usage mode: scan stats, in compare mode: env compare stats)');
15
21
  }
@@ -6,10 +6,26 @@ import { discoverEnvFiles } from '../services/envDiscovery.js';
6
6
  import { pairWithExample } from '../services/envPairing.js';
7
7
  import { ensureFilesOrPrompt } from '../commands/init.js';
8
8
  import { compareMany } from '../commands/compare.js';
9
+ import { scanUsage } from '../commands/scanUsage.js';
9
10
  export async function run(program) {
10
11
  program.parse(process.argv);
11
12
  const raw = program.opts();
12
13
  const opts = normalizeOptions(raw);
14
+ if (opts.scanUsage) {
15
+ const envPath = opts.envFlag || (fs.existsSync('.env') ? '.env' : undefined);
16
+ const { exitWithError } = await scanUsage({
17
+ cwd: opts.cwd,
18
+ include: opts.includeFiles,
19
+ exclude: opts.excludeFiles,
20
+ ignore: opts.ignore,
21
+ ignoreRegex: opts.ignoreRegex,
22
+ envPath,
23
+ json: opts.json,
24
+ showUnused: opts.showUnused,
25
+ showStats: opts.showStats,
26
+ });
27
+ process.exit(exitWithError ? 1 : 0);
28
+ }
13
29
  // Special-case: both flags → direct comparison of exactly those two files
14
30
  if (opts.envFlag && opts.exampleFlag) {
15
31
  const envExists = fs.existsSync(opts.envFlag);
@@ -37,6 +53,8 @@ export async function run(program) {
37
53
  json: opts.json,
38
54
  ignore: opts.ignore,
39
55
  ignoreRegex: opts.ignoreRegex,
56
+ only: opts.only,
57
+ showStats: opts.showStats,
40
58
  collect: (e) => report.push(e),
41
59
  });
42
60
  if (opts.json) {
@@ -75,6 +93,8 @@ export async function run(program) {
75
93
  json: opts.json,
76
94
  ignore: opts.ignore,
77
95
  ignoreRegex: opts.ignoreRegex,
96
+ only: opts.only,
97
+ showStats: opts.showStats,
78
98
  collect: (e) => report.push(e),
79
99
  });
80
100
  if (opts.json) {
@@ -1,29 +1,4 @@
1
- export type CompareJsonEntry = {
2
- env: string;
3
- example: string;
4
- skipped?: {
5
- reason: string;
6
- };
7
- duplicates?: {
8
- env?: Array<{
9
- key: string;
10
- count: number;
11
- }>;
12
- example?: Array<{
13
- key: string;
14
- count: number;
15
- }>;
16
- };
17
- missing?: string[];
18
- extra?: string[];
19
- empty?: string[];
20
- valueMismatches?: Array<{
21
- key: string;
22
- expected: string;
23
- actual: string;
24
- }>;
25
- ok?: boolean;
26
- };
1
+ import type { Category, CompareJsonEntry } from '../config/types.js';
27
2
  export declare function compareMany(pairs: Array<{
28
3
  envName: string;
29
4
  envPath: string;
@@ -36,6 +11,8 @@ export declare function compareMany(pairs: Array<{
36
11
  ignore: string[];
37
12
  ignoreRegex: RegExp[];
38
13
  collect?: (entry: CompareJsonEntry) => void;
14
+ only?: Category[];
15
+ showStats?: boolean;
39
16
  }): Promise<{
40
17
  exitWithError: boolean;
41
18
  }>;
@@ -8,6 +8,18 @@ import { findDuplicateKeys } from '../services/duplicates.js';
8
8
  import { filterIgnoredKeys } from '../core/filterIgnoredKeys.js';
9
9
  export async function compareMany(pairs, opts) {
10
10
  let exitWithError = false;
11
+ const onlySet = opts.only?.length
12
+ ? new Set(opts.only)
13
+ : undefined;
14
+ const run = (cat) => !onlySet || onlySet.has(cat);
15
+ const totals = {
16
+ missing: 0,
17
+ extra: 0,
18
+ empty: 0,
19
+ mismatch: 0,
20
+ duplicate: 0,
21
+ gitignore: 0,
22
+ };
11
23
  for (const { envName, envPath, examplePath } of pairs) {
12
24
  const exampleName = path.basename(examplePath);
13
25
  const entry = { env: envName, example: exampleName };
@@ -25,36 +37,45 @@ export async function compareMany(pairs, opts) {
25
37
  console.log(chalk.bold(`🔍 Comparing ${envName} ↔ ${exampleName}...`));
26
38
  }
27
39
  // Git ignore hint (only when not JSON)
28
- warnIfEnvNotIgnored({
29
- cwd: opts.cwd,
30
- envFile: envName,
31
- log: (msg) => {
32
- if (!opts.json)
33
- console.log(msg.replace(/^/gm, ' '));
34
- },
35
- });
36
- // Duplicate detection
37
- if (!opts.allowDuplicates) {
38
- const dupsEnv = findDuplicateKeys(envPath).filter(({ key }) => !opts.ignore.includes(key) &&
40
+ let gitignoreUnsafe = false;
41
+ if (run('gitignore')) {
42
+ warnIfEnvNotIgnored({
43
+ cwd: opts.cwd,
44
+ envFile: envName,
45
+ log: (msg) => {
46
+ gitignoreUnsafe = true;
47
+ if (!opts.json)
48
+ console.log(msg.replace(/^/gm, ' '));
49
+ },
50
+ });
51
+ }
52
+ else {
53
+ // still call to keep previous hints? No—masked by --only.
54
+ }
55
+ // Duplicate detection (skip entirely if --only excludes it)
56
+ let dupsEnv = [];
57
+ let dupsEx = [];
58
+ if (!opts.allowDuplicates && run('duplicate')) {
59
+ dupsEnv = findDuplicateKeys(envPath).filter(({ key }) => !opts.ignore.includes(key) &&
39
60
  !opts.ignoreRegex.some((rx) => rx.test(key)));
40
- const dupsEx = findDuplicateKeys(examplePath).filter(({ key }) => !opts.ignore.includes(key) &&
61
+ dupsEx = findDuplicateKeys(examplePath).filter(({ key }) => !opts.ignore.includes(key) &&
41
62
  !opts.ignoreRegex.some((rx) => rx.test(key)));
42
- if (dupsEnv.length || dupsEx.length) {
43
- entry.duplicates = {};
44
- }
45
- if (dupsEnv.length) {
46
- entry.duplicates.env = dupsEnv;
47
- if (!opts.json) {
48
- console.log(chalk.yellow(` ⚠️ Duplicate keys in ${envName} (last occurrence wins):`));
49
- dupsEnv.forEach(({ key, count }) => console.log(chalk.yellow(` - ${key} (${count} occurrences)`)));
50
- }
63
+ }
64
+ if (dupsEnv.length || dupsEx.length) {
65
+ entry.duplicates = {};
66
+ }
67
+ if (dupsEnv.length) {
68
+ entry.duplicates.env = dupsEnv;
69
+ if (!opts.json) {
70
+ console.log(chalk.yellow(` ⚠️ Duplicate keys in ${envName} (last occurrence wins):`));
71
+ dupsEnv.forEach(({ key, count }) => console.log(chalk.yellow(` - ${key} (${count} occurrences)`)));
51
72
  }
52
- if (dupsEx.length) {
53
- entry.duplicates.example = dupsEx;
54
- if (!opts.json) {
55
- console.log(chalk.yellow(` ⚠️ Duplicate keys in ${exampleName} (last occurrence wins):`));
56
- dupsEx.forEach(({ key, count }) => console.log(chalk.yellow(` - ${key} (${count} occurrences)`)));
57
- }
73
+ }
74
+ if (dupsEx.length) {
75
+ entry.duplicates.example = dupsEx;
76
+ if (!opts.json) {
77
+ console.log(chalk.yellow(` ⚠️ Duplicate keys in ${exampleName} (last occurrence wins):`));
78
+ dupsEx.forEach(({ key, count }) => console.log(chalk.yellow(` - ${key} (${count} occurrences)`)));
58
79
  }
59
80
  }
60
81
  // Diff + empty
@@ -68,10 +89,19 @@ export async function compareMany(pairs, opts) {
68
89
  const emptyKeys = Object.entries(current)
69
90
  .filter(([, v]) => (v ?? '').trim() === '')
70
91
  .map(([k]) => k);
71
- const allOk = diff.missing.length === 0 &&
72
- diff.extra.length === 0 &&
73
- emptyKeys.length === 0 &&
74
- diff.valueMismatches.length === 0;
92
+ const filtered = {
93
+ missing: run('missing') ? diff.missing : [],
94
+ extra: run('extra') ? diff.extra : [],
95
+ empty: run('empty') ? emptyKeys : [],
96
+ mismatches: run('mismatch') && opts.checkValues ? diff.valueMismatches : [],
97
+ duplicatesEnv: run('duplicate') ? dupsEnv : [],
98
+ duplicatesEx: run('duplicate') ? dupsEx : [],
99
+ gitignoreUnsafe: run('gitignore') ? gitignoreUnsafe : false,
100
+ };
101
+ const allOk = filtered.missing.length === 0 &&
102
+ filtered.extra.length === 0 &&
103
+ filtered.empty.length === 0 &&
104
+ filtered.mismatches.length === 0;
75
105
  if (allOk) {
76
106
  entry.ok = true;
77
107
  if (!opts.json) {
@@ -81,33 +111,72 @@ export async function compareMany(pairs, opts) {
81
111
  opts.collect?.(entry);
82
112
  continue;
83
113
  }
84
- if (diff.missing.length) {
85
- entry.missing = diff.missing;
114
+ // --- Stats block for compare mode when --show-stats is active ---
115
+ if (opts.showStats && !opts.json) {
116
+ const envCount = currentKeys.length;
117
+ const exampleCount = exampleKeys.length;
118
+ const sharedCount = new Set(currentKeys.filter((k) => exampleKeys.includes(k))).size;
119
+ // Duplicate "occurrences beyond the first", summed across both files
120
+ const duplicateCount = [...dupsEnv, ...dupsEx].reduce((acc, { count }) => acc + Math.max(0, count - 1), 0);
121
+ const valueMismatchCount = opts.checkValues
122
+ ? filtered.mismatches.length
123
+ : 0;
124
+ console.log(chalk.bold(' 📊 Compare Statistics:'));
125
+ console.log(chalk.gray(` Keys in ${envName}: ${envCount}`));
126
+ console.log(chalk.gray(` Keys in ${exampleName}: ${exampleCount}`));
127
+ console.log(chalk.gray(` Shared keys: ${sharedCount}`));
128
+ console.log(chalk.gray(` Missing (in ${envName}): ${filtered.missing.length}`));
129
+ console.log(chalk.gray(` Extra (not in ${exampleName}): ${filtered.extra.length}`));
130
+ console.log(chalk.gray(` Empty values: ${filtered.empty.length}`));
131
+ console.log(chalk.gray(` Duplicate keys: ${duplicateCount}`));
132
+ console.log(chalk.gray(` Value mismatches: ${valueMismatchCount}`));
133
+ console.log();
134
+ }
135
+ if (filtered.missing.length) {
136
+ entry.missing = filtered.missing;
86
137
  exitWithError = true;
138
+ totals.missing += filtered.missing.length;
87
139
  }
88
- if (diff.extra.length)
89
- entry.extra = diff.extra;
90
- if (emptyKeys.length)
91
- entry.empty = emptyKeys;
92
- if (opts.checkValues && diff.valueMismatches.length) {
93
- entry.valueMismatches = diff.valueMismatches;
140
+ if (filtered.extra.length) {
141
+ entry.extra = filtered.extra;
142
+ exitWithError = true;
143
+ totals.extra += filtered.extra.length;
144
+ }
145
+ if (filtered.empty.length) {
146
+ entry.empty = filtered.empty;
147
+ exitWithError = true;
148
+ totals.empty += filtered.empty.length;
149
+ }
150
+ if (filtered.mismatches.length) {
151
+ entry.valueMismatches = filtered.mismatches;
152
+ totals.mismatch += filtered.mismatches.length;
153
+ exitWithError = true;
154
+ }
155
+ if (filtered.duplicatesEnv.length || filtered.duplicatesEx.length) {
156
+ totals.duplicate +=
157
+ filtered.duplicatesEnv.length + filtered.duplicatesEx.length;
158
+ exitWithError = true;
159
+ }
160
+ if (filtered.gitignoreUnsafe) {
161
+ totals.gitignore += 1;
162
+ exitWithError = true;
94
163
  }
95
164
  if (!opts.json) {
96
- if (diff.missing.length) {
165
+ if (filtered.missing.length) {
97
166
  console.log(chalk.red(' ❌ Missing keys:'));
98
- diff.missing.forEach((key) => console.log(chalk.red(` - ${key}`)));
167
+ filtered.missing.forEach((key) => console.log(chalk.red(` - ${key}`)));
99
168
  }
100
- if (diff.extra.length) {
169
+ if (filtered.extra.length) {
101
170
  console.log(chalk.yellow(' ⚠️ Extra keys (not in example):'));
102
- diff.extra.forEach((key) => console.log(chalk.yellow(` - ${key}`)));
171
+ filtered.extra.forEach((key) => console.log(chalk.yellow(` - ${key}`)));
103
172
  }
104
- if (emptyKeys.length) {
173
+ if (filtered.empty.length) {
105
174
  console.log(chalk.yellow(' ⚠️ Empty values:'));
106
- emptyKeys.forEach((key) => console.log(chalk.yellow(` - ${key}`)));
175
+ filtered.empty.forEach((key) => console.log(chalk.yellow(` - ${key}`)));
107
176
  }
108
- if (opts.checkValues && diff.valueMismatches.length) {
177
+ if (filtered.mismatches.length) {
109
178
  console.log(chalk.yellow(' ⚠️ Value mismatches:'));
110
- diff.valueMismatches.forEach(({ key, expected, actual }) => console.log(chalk.yellow(` - ${key}: expected '${expected}', but got '${actual}'`)));
179
+ filtered.mismatches.forEach(({ key, expected, actual }) => console.log(chalk.yellow(` - ${key}: expected '${expected}', but got '${actual}'`)));
111
180
  }
112
181
  console.log();
113
182
  }
@@ -0,0 +1,43 @@
1
+ import { type ScanOptions } from '../services/codeBaseScanner.js';
2
+ export interface ScanUsageOptions extends ScanOptions {
3
+ envPath?: string;
4
+ json: boolean;
5
+ showUnused: boolean;
6
+ showStats: boolean;
7
+ }
8
+ export interface ScanJsonEntry {
9
+ stats: {
10
+ filesScanned: number;
11
+ totalUsages: number;
12
+ uniqueVariables: number;
13
+ };
14
+ missing: Array<{
15
+ variable: string;
16
+ usages: Array<{
17
+ file: string;
18
+ line: number;
19
+ pattern: string;
20
+ context: string;
21
+ }>;
22
+ }>;
23
+ unused: string[];
24
+ allUsages?: Array<{
25
+ variable: string;
26
+ file: string;
27
+ line: number;
28
+ pattern: string;
29
+ context: string;
30
+ }>;
31
+ }
32
+ /**
33
+ * Scans codebase for environment variable usage and compares with .env file
34
+ * @param {ScanUsageOptions} opts - Scan configuration options
35
+ * @param {string} [opts.envPath] - Path to .env file for comparison
36
+ * @param {boolean} opts.json - Output as JSON instead of console
37
+ * @param {boolean} opts.showUnused - Show unused variables from .env
38
+ * @param {boolean} opts.showStats - Show detailed statistics
39
+ * @returns {Promise<{exitWithError: boolean}>} Returns true if missing variables found
40
+ */
41
+ export declare function scanUsage(opts: ScanUsageOptions): Promise<{
42
+ exitWithError: boolean;
43
+ }>;
@@ -0,0 +1,154 @@
1
+ import chalk from 'chalk';
2
+ import { parseEnvFile } from '../lib/parseEnv.js';
3
+ import { scanCodebase, compareWithEnvFiles, } from '../services/codeBaseScanner.js';
4
+ import { filterIgnoredKeys } from '../core/filterIgnoredKeys.js';
5
+ /**
6
+ * Scans codebase for environment variable usage and compares with .env file
7
+ * @param {ScanUsageOptions} opts - Scan configuration options
8
+ * @param {string} [opts.envPath] - Path to .env file for comparison
9
+ * @param {boolean} opts.json - Output as JSON instead of console
10
+ * @param {boolean} opts.showUnused - Show unused variables from .env
11
+ * @param {boolean} opts.showStats - Show detailed statistics
12
+ * @returns {Promise<{exitWithError: boolean}>} Returns true if missing variables found
13
+ */
14
+ export async function scanUsage(opts) {
15
+ if (!opts.json) {
16
+ console.log(chalk.bold('🔍 Scanning codebase for environment variable usage...'));
17
+ console.log();
18
+ }
19
+ // Scan the codebase
20
+ let scanResult = await scanCodebase(opts);
21
+ // If we have an env file, compare with it
22
+ let envVariables = {};
23
+ if (opts.envPath) {
24
+ try {
25
+ const envFull = parseEnvFile(opts.envPath);
26
+ const envKeys = filterIgnoredKeys(Object.keys(envFull), opts.ignore, opts.ignoreRegex);
27
+ envVariables = Object.fromEntries(envKeys.map((k) => [k, envFull[k]]));
28
+ scanResult = compareWithEnvFiles(scanResult, envVariables);
29
+ }
30
+ catch (error) {
31
+ if (!opts.json) {
32
+ console.log(chalk.yellow(`⚠️ Could not read env file: ${opts.envPath} - ${error}`));
33
+ }
34
+ }
35
+ }
36
+ // Prepare JSON output
37
+ if (opts.json) {
38
+ const jsonOutput = createJsonOutput(scanResult, opts);
39
+ console.log(JSON.stringify(jsonOutput, null, 2));
40
+ return { exitWithError: scanResult.missing.length > 0 };
41
+ }
42
+ // Console output
43
+ return outputToConsole(scanResult, opts);
44
+ }
45
+ function createJsonOutput(scanResult, opts) {
46
+ // Group usages by variable for missing variables
47
+ const missingGrouped = scanResult.missing.map((variable) => ({
48
+ variable,
49
+ usages: scanResult.used
50
+ .filter((u) => u.variable === variable)
51
+ .map((u) => ({
52
+ file: u.file,
53
+ line: u.line,
54
+ pattern: u.pattern,
55
+ context: u.context,
56
+ })),
57
+ }));
58
+ const output = {
59
+ stats: scanResult.stats,
60
+ missing: missingGrouped,
61
+ unused: scanResult.unused,
62
+ };
63
+ // Optionally include all usages
64
+ if (opts.showStats) {
65
+ output.allUsages = scanResult.used.map((u) => ({
66
+ variable: u.variable,
67
+ file: u.file,
68
+ line: u.line,
69
+ pattern: u.pattern,
70
+ context: u.context,
71
+ }));
72
+ }
73
+ return output;
74
+ }
75
+ function outputToConsole(scanResult, opts) {
76
+ let exitWithError = false;
77
+ // Show stats if requested
78
+ if (opts.showStats) {
79
+ console.log(chalk.bold('📊 Scan Statistics:'));
80
+ console.log(chalk.gray(` Files scanned: ${scanResult.stats.filesScanned}`));
81
+ console.log(chalk.gray(` Total usages found: ${scanResult.stats.totalUsages}`));
82
+ console.log(chalk.gray(` Unique variables: ${scanResult.stats.uniqueVariables}`));
83
+ console.log();
84
+ }
85
+ // Always show found variables when not in .env comparison mode
86
+ if (!opts.envPath || scanResult.missing.length === 0) {
87
+ console.log(chalk.green(`✅ Found ${scanResult.stats.uniqueVariables} unique environment variables in use`));
88
+ console.log();
89
+ // List all variables found (if any)
90
+ if (scanResult.stats.uniqueVariables > 0) {
91
+ // Group by variable to get unique list
92
+ const variableUsages = scanResult.used.reduce((acc, usage) => {
93
+ if (!acc[usage.variable]) {
94
+ acc[usage.variable] = [];
95
+ }
96
+ acc[usage.variable].push(usage);
97
+ return acc;
98
+ }, {});
99
+ // Display each unique variable
100
+ for (const [variable, usages] of Object.entries(variableUsages)) {
101
+ console.log(chalk.blue(` ${variable}`));
102
+ // Show usage details if stats are enabled
103
+ if (opts.showStats) {
104
+ const displayUsages = usages.slice(0, 2);
105
+ displayUsages.forEach((usage) => {
106
+ console.log(chalk.gray(` Used in: ${usage.file}:${usage.line} (${usage.pattern})`));
107
+ });
108
+ if (usages.length > 2) {
109
+ console.log(chalk.gray(` ... and ${usages.length - 2} more locations`));
110
+ }
111
+ }
112
+ }
113
+ console.log();
114
+ }
115
+ }
116
+ // Missing variables (used in code but not in .env)
117
+ if (scanResult.missing.length > 0) {
118
+ exitWithError = true;
119
+ console.log(chalk.red('❌ Missing in .env:'));
120
+ const grouped = scanResult.missing.reduce((acc, variable) => {
121
+ const usages = scanResult.used.filter((u) => u.variable === variable);
122
+ acc[variable] = usages;
123
+ return acc;
124
+ }, {});
125
+ for (const [variable, usages] of Object.entries(grouped)) {
126
+ console.log(chalk.red(` - ${variable}`));
127
+ // Show first few usages
128
+ const maxShow = 3;
129
+ usages.slice(0, maxShow).forEach((usage) => {
130
+ console.log(chalk.gray(` Used in: ${usage.file}:${usage.line} (${usage.pattern})`));
131
+ });
132
+ if (usages.length > maxShow) {
133
+ console.log(chalk.gray(` ... and ${usages.length - maxShow} more locations`));
134
+ }
135
+ }
136
+ console.log();
137
+ }
138
+ // Unused variables (in .env but not used in code)
139
+ if (opts.showUnused && scanResult.unused.length > 0) {
140
+ console.log(chalk.yellow('⚠️ Unused in codebase:'));
141
+ scanResult.unused.forEach((variable) => {
142
+ console.log(chalk.yellow(` - ${variable}`));
143
+ });
144
+ console.log();
145
+ }
146
+ // Success message for .env comparison
147
+ if (opts.envPath && scanResult.missing.length === 0) {
148
+ console.log(chalk.green('✅ All used environment variables are defined in .env'));
149
+ if (opts.showUnused && scanResult.unused.length === 0) {
150
+ console.log(chalk.green('✅ No unused environment variables found'));
151
+ }
152
+ }
153
+ return { exitWithError };
154
+ }
@@ -1,25 +1,2 @@
1
- export type Options = {
2
- checkValues: boolean;
3
- isCiMode: boolean;
4
- isYesMode: boolean;
5
- allowDuplicates: boolean;
6
- json: boolean;
7
- envFlag: string | null;
8
- exampleFlag: string | null;
9
- ignore: string[];
10
- ignoreRegex: RegExp[];
11
- cwd: string;
12
- };
13
- type RawOptions = {
14
- checkValues?: boolean;
15
- ci?: boolean;
16
- yes?: boolean;
17
- allowDuplicates?: boolean;
18
- json?: boolean;
19
- env?: string;
20
- example?: string;
21
- ignore?: string | string[];
22
- ignoreRegex?: string | string[];
23
- };
1
+ import { Options, RawOptions } from './types.js';
24
2
  export declare function normalizeOptions(raw: RawOptions): Options;
25
- export {};
@@ -1,18 +1,35 @@
1
1
  import chalk from 'chalk';
2
2
  import path from 'path';
3
+ import { ALLOWED_CATEGORIES } from './types.js';
4
+ function parseList(val) {
5
+ const arr = Array.isArray(val) ? val : val ? [val] : [];
6
+ return arr
7
+ .flatMap((s) => String(s).split(','))
8
+ .map((s) => s.trim())
9
+ .filter(Boolean);
10
+ }
11
+ function parseCategories(val, flagName = '') {
12
+ const raw = parseList(val);
13
+ const bad = raw.filter((c) => !ALLOWED_CATEGORIES.includes(c));
14
+ if (bad.length) {
15
+ console.error(chalk.red(`❌ Error: invalid ${flagName} value(s): ${bad.join(', ')}. Allowed: ${ALLOWED_CATEGORIES.join(', ')}`));
16
+ process.exit(1);
17
+ }
18
+ return raw;
19
+ }
3
20
  export function normalizeOptions(raw) {
4
21
  const checkValues = raw.checkValues ?? false;
5
22
  const isCiMode = Boolean(raw.ci);
6
23
  const isYesMode = Boolean(raw.yes);
7
24
  const allowDuplicates = Boolean(raw.allowDuplicates);
8
25
  const json = Boolean(raw.json);
9
- const parseList = (val) => {
10
- const arr = Array.isArray(val) ? val : val ? [val] : [];
11
- return arr
12
- .flatMap((s) => s.split(','))
13
- .map((s) => s.trim())
14
- .filter(Boolean);
15
- };
26
+ const onlyParsed = parseCategories(raw.only, '--only');
27
+ const only = onlyParsed.length ? onlyParsed : undefined;
28
+ const scanUsage = Boolean(raw.scanUsage);
29
+ const includeFiles = parseList(raw.includeFiles);
30
+ const excludeFiles = parseList(raw.excludeFiles);
31
+ const showUnused = Boolean(raw.showUnused);
32
+ const showStats = Boolean(raw.showStats);
16
33
  const ignore = parseList(raw.ignore);
17
34
  const ignoreRegex = [];
18
35
  for (const pattern of parseList(raw.ignoreRegex)) {
@@ -41,5 +58,11 @@ export function normalizeOptions(raw) {
41
58
  ignore,
42
59
  ignoreRegex,
43
60
  cwd,
61
+ only,
62
+ scanUsage,
63
+ includeFiles,
64
+ excludeFiles,
65
+ showUnused,
66
+ showStats,
44
67
  };
45
68
  }
@@ -0,0 +1,63 @@
1
+ export declare const ALLOWED_CATEGORIES: readonly ["missing", "extra", "empty", "mismatch", "duplicate", "gitignore"];
2
+ export type Category = (typeof ALLOWED_CATEGORIES)[number];
3
+ export type Options = {
4
+ checkValues: boolean;
5
+ isCiMode: boolean;
6
+ isYesMode: boolean;
7
+ allowDuplicates: boolean;
8
+ json: boolean;
9
+ envFlag: string | null;
10
+ exampleFlag: string | null;
11
+ ignore: string[];
12
+ ignoreRegex: RegExp[];
13
+ cwd: string;
14
+ only?: Category[];
15
+ scanUsage: boolean;
16
+ includeFiles: string[];
17
+ excludeFiles: string[];
18
+ showUnused: boolean;
19
+ showStats: boolean;
20
+ };
21
+ export type RawOptions = {
22
+ checkValues?: boolean;
23
+ ci?: boolean;
24
+ yes?: boolean;
25
+ allowDuplicates?: boolean;
26
+ json?: boolean;
27
+ env?: string;
28
+ example?: string;
29
+ ignore?: string | string[];
30
+ ignoreRegex?: string | string[];
31
+ only?: string | string[];
32
+ scanUsage?: boolean;
33
+ includeFiles?: string | string[];
34
+ excludeFiles?: string | string[];
35
+ showUnused?: boolean;
36
+ showStats?: boolean;
37
+ };
38
+ export type CompareJsonEntry = {
39
+ env: string;
40
+ example: string;
41
+ skipped?: {
42
+ reason: string;
43
+ };
44
+ duplicates?: {
45
+ env?: Array<{
46
+ key: string;
47
+ count: number;
48
+ }>;
49
+ example?: Array<{
50
+ key: string;
51
+ count: number;
52
+ }>;
53
+ };
54
+ missing?: string[];
55
+ extra?: string[];
56
+ empty?: string[];
57
+ valueMismatches?: Array<{
58
+ key: string;
59
+ expected: string;
60
+ actual: string;
61
+ }>;
62
+ ok?: boolean;
63
+ };
@@ -0,0 +1,8 @@
1
+ export const ALLOWED_CATEGORIES = [
2
+ 'missing',
3
+ 'extra',
4
+ 'empty',
5
+ 'mismatch',
6
+ 'duplicate',
7
+ 'gitignore',
8
+ ];
@@ -0,0 +1,32 @@
1
+ export interface EnvUsage {
2
+ variable: string;
3
+ file: string;
4
+ line: number;
5
+ column: number;
6
+ pattern: 'process.env' | 'import.meta.env' | 'sveltekit' | 'deno' | 'next' | 'nuxt';
7
+ context: string;
8
+ }
9
+ export interface ScanOptions {
10
+ cwd: string;
11
+ include: string[];
12
+ exclude: string[];
13
+ ignore: string[];
14
+ ignoreRegex: RegExp[];
15
+ }
16
+ export interface ScanResult {
17
+ used: EnvUsage[];
18
+ missing: string[];
19
+ unused: string[];
20
+ stats: {
21
+ filesScanned: number;
22
+ totalUsages: number;
23
+ uniqueVariables: number;
24
+ };
25
+ }
26
+ /**
27
+ * Scans the codebase for environment variable usage based on the provided options.
28
+ * @param opts - Options for scanning the codebase.
29
+ * @returns A promise that resolves to the scan result containing used, missing, and unused variables.
30
+ */
31
+ export declare function scanCodebase(opts: ScanOptions): Promise<ScanResult>;
32
+ export declare function compareWithEnvFiles(scanResult: ScanResult, envVariables: Record<string, string | undefined>): ScanResult;
@@ -0,0 +1,236 @@
1
+ import fs from 'fs/promises';
2
+ import path from 'path';
3
+ // Framework-specific patterns for finding environment variable usage
4
+ const ENV_PATTERNS = [
5
+ {
6
+ name: 'process.env',
7
+ regex: /process\.env\.([A-Z_][A-Z0-9_]*)/g,
8
+ frameworks: ['node', 'next', 'general'],
9
+ },
10
+ {
11
+ name: 'import.meta.env',
12
+ regex: /import\.meta\.env\.([A-Z_][A-Z0-9_]*)/g,
13
+ frameworks: ['vite', 'svelte', 'vue'],
14
+ },
15
+ {
16
+ name: 'sveltekit',
17
+ regex: /\$env\/(?:static|dynamic)\/(?:private|public)\/([A-Z_][A-Z0-9_]*)/g,
18
+ frameworks: ['sveltekit'],
19
+ },
20
+ {
21
+ name: 'deno',
22
+ regex: /Deno\.env\.get\(['"`]([A-Z_][A-Z0-9_]*)['"`]\)/g,
23
+ frameworks: ['deno'],
24
+ },
25
+ {
26
+ name: 'next',
27
+ regex: /process\.env\.(NEXT_PUBLIC_[A-Z_][A-Z0-9_]*)/g,
28
+ frameworks: ['next'],
29
+ },
30
+ {
31
+ name: 'nuxt',
32
+ regex: /(?:\$config|useRuntimeConfig\(\))\.([A-Z_][A-Z0-9_]*)/g,
33
+ frameworks: ['nuxt'],
34
+ },
35
+ ];
36
+ const DEFAULT_INCLUDE_EXTENSIONS = [
37
+ '.js',
38
+ '.ts',
39
+ '.jsx',
40
+ '.tsx',
41
+ '.vue',
42
+ '.svelte',
43
+ '.mjs',
44
+ '.cjs',
45
+ ];
46
+ const DEFAULT_EXCLUDE_PATTERNS = [
47
+ 'node_modules',
48
+ 'dist',
49
+ 'build',
50
+ '.next',
51
+ '.nuxt',
52
+ 'coverage',
53
+ '.git',
54
+ '.vscode',
55
+ '.idea',
56
+ '.test.',
57
+ '.spec.',
58
+ '__tests__',
59
+ '__mocks__',
60
+ ];
61
+ /**
62
+ * Scans the codebase for environment variable usage based on the provided options.
63
+ * @param opts - Options for scanning the codebase.
64
+ * @returns A promise that resolves to the scan result containing used, missing, and unused variables.
65
+ */
66
+ export async function scanCodebase(opts) {
67
+ const files = await findFiles(opts.cwd, {
68
+ include: opts.include,
69
+ exclude: [...DEFAULT_EXCLUDE_PATTERNS, ...opts.exclude],
70
+ });
71
+ const allUsages = [];
72
+ let filesScanned = 0;
73
+ for (const filePath of files) {
74
+ try {
75
+ const content = await fs.readFile(filePath, 'utf-8');
76
+ const fileUsages = await scanFile(filePath, content, opts);
77
+ allUsages.push(...fileUsages);
78
+ filesScanned++;
79
+ }
80
+ catch {
81
+ // Skip files we can't read (binary, permissions, etc.)
82
+ continue;
83
+ }
84
+ }
85
+ // Filter out ignored variables
86
+ const filteredUsages = allUsages.filter((usage) => !opts.ignore.includes(usage.variable) &&
87
+ !opts.ignoreRegex.some((regex) => regex.test(usage.variable)));
88
+ const uniqueVariables = [...new Set(filteredUsages.map((u) => u.variable))];
89
+ return {
90
+ used: filteredUsages,
91
+ missing: [],
92
+ unused: [],
93
+ stats: {
94
+ filesScanned,
95
+ totalUsages: filteredUsages.length,
96
+ uniqueVariables: uniqueVariables.length,
97
+ },
98
+ };
99
+ }
100
+ /**
101
+ * Recursively finds all files in the given directory matching the include patterns,
102
+ * while excluding files and directories that match the exclude patterns.
103
+ * @param rootDir The root directory to start searching from.
104
+ * @param opts Options for include and exclude patterns.
105
+ * @returns A promise that resolves to an array of file paths.
106
+ */
107
+ async function findFiles(rootDir, opts) {
108
+ const files = [];
109
+ async function walk(currentDir) {
110
+ let entries;
111
+ try {
112
+ entries = await fs.readdir(currentDir, { withFileTypes: true });
113
+ }
114
+ catch {
115
+ // Skip directories we can't read (permissions, etc.)
116
+ return;
117
+ }
118
+ for (const entry of entries) {
119
+ const fullPath = path.join(currentDir, entry.name);
120
+ const relativePath = path.relative(rootDir, fullPath);
121
+ // Check if should be excluded (directory or file)
122
+ if (shouldExclude(entry.name, relativePath, opts.exclude)) {
123
+ continue;
124
+ }
125
+ if (entry.isDirectory()) {
126
+ await walk(fullPath);
127
+ }
128
+ else if (entry.isFile() &&
129
+ shouldInclude(entry.name, relativePath, opts.include)) {
130
+ files.push(fullPath);
131
+ }
132
+ }
133
+ }
134
+ await walk(rootDir);
135
+ return files;
136
+ }
137
+ /**
138
+ * Check if a file should be included based on its name, path, and include patterns.
139
+ * @param fileName The name of the file.
140
+ * @param relativePath The relative path of the file.
141
+ * @param patterns The include patterns to match against.
142
+ * @returns True if the file should be included, false otherwise.
143
+ */
144
+ function shouldInclude(fileName, relativePath, patterns) {
145
+ // If no include patterns specified, use default extensions
146
+ if (!patterns.length) {
147
+ return DEFAULT_INCLUDE_EXTENSIONS.some((ext) => fileName.endsWith(ext));
148
+ }
149
+ return patterns.some((pattern) => {
150
+ if (pattern.startsWith('**')) {
151
+ // Handle **/*.ext patterns
152
+ const ext = pattern.substring(pattern.lastIndexOf('.'));
153
+ return fileName.endsWith(ext);
154
+ }
155
+ else if (pattern.includes('*')) {
156
+ // Simple glob pattern matching
157
+ return matchesGlobPattern(relativePath, pattern);
158
+ }
159
+ else {
160
+ // Exact match or extension
161
+ return relativePath.includes(pattern) || fileName.endsWith(pattern);
162
+ }
163
+ });
164
+ }
165
+ function shouldExclude(fileName, relativePath, patterns) {
166
+ // Check if filename or any part of the path should be excluded
167
+ return patterns.some((pattern) => {
168
+ // Direct name match (like 'node_modules')
169
+ if (fileName === pattern)
170
+ return true;
171
+ // Path contains pattern
172
+ if (relativePath.includes(pattern))
173
+ return true;
174
+ // Pattern matching for extensions and wildcards
175
+ if (pattern.includes('*')) {
176
+ return matchesGlobPattern(relativePath, pattern);
177
+ }
178
+ // Special case for test files
179
+ if (pattern.includes('.test.') && fileName.includes('.test.'))
180
+ return true;
181
+ if (pattern.includes('.spec.') && fileName.includes('.spec.'))
182
+ return true;
183
+ return false;
184
+ });
185
+ }
186
+ function matchesGlobPattern(filePath, pattern) {
187
+ // Convert simple glob patterns to regex
188
+ // This handles basic cases like *.js, **/*.ts, etc.
189
+ const regexPattern = pattern
190
+ .replace(/\*\*/g, '.*') // ** matches any path
191
+ .replace(/\*/g, '[^/]*') // * matches any filename chars (not path separators)
192
+ .replace(/\./g, '\\.') // Escape dots
193
+ .replace(/\//g, '[/\\\\]'); // Handle both forward and back slashes
194
+ const regex = new RegExp(`^${regexPattern}$`, 'i'); // Case insensitive
195
+ return regex.test(filePath.replace(/\\/g, '/')); // Normalize path separators
196
+ }
197
+ async function scanFile(filePath, content, opts) {
198
+ const usages = [];
199
+ const lines = content.split('\n');
200
+ const relativePath = path.relative(opts.cwd, filePath);
201
+ for (const pattern of ENV_PATTERNS) {
202
+ let match;
203
+ const regex = new RegExp(pattern.regex.source, pattern.regex.flags);
204
+ while ((match = regex.exec(content)) !== null) {
205
+ const variable = match[1];
206
+ const matchIndex = match.index;
207
+ // Find line and column
208
+ const beforeMatch = content.substring(0, matchIndex);
209
+ const lineNumber = beforeMatch.split('\n').length;
210
+ const lastNewlineIndex = beforeMatch.lastIndexOf('\n');
211
+ const column = matchIndex - lastNewlineIndex;
212
+ // Get the context (the actual line)
213
+ const contextLine = lines[lineNumber - 1]?.trim() || '';
214
+ usages.push({
215
+ variable,
216
+ file: relativePath,
217
+ line: lineNumber,
218
+ column,
219
+ pattern: pattern.name,
220
+ context: contextLine,
221
+ });
222
+ }
223
+ }
224
+ return usages;
225
+ }
226
+ export function compareWithEnvFiles(scanResult, envVariables) {
227
+ const usedVariables = new Set(scanResult.used.map((u) => u.variable));
228
+ const envKeys = new Set(Object.keys(envVariables));
229
+ const missing = [...usedVariables].filter((v) => !envKeys.has(v));
230
+ const unused = [...envKeys].filter((v) => !usedVariables.has(v));
231
+ return {
232
+ ...scanResult,
233
+ missing,
234
+ unused,
235
+ };
236
+ }
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "dotenv-diff",
3
- "version": "1.6.4",
3
+ "version": "2.0.0",
4
4
  "type": "module",
5
- "description": "A CLI tool to find differences between .env and .env.example / .env.* files.",
5
+ "description": "A CLI tool to find differences between .env and .env.example / .env.* files. And optionally scan your codebase to find out which environment variables are actually used in your code.",
6
6
  "bin": {
7
7
  "dotenv-diff": "dist/bin/dotenv-diff.js"
8
8
  },