dotenv-diff 1.6.5 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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,59 @@ 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
+ ## CI/CD integration with `--ci` option
46
+ You can scan and compare against specific environment file, eg. `.env.example`
47
+ This is useful for CI/CD pipelines to ensure that the environment variables used in your code match those defined in your `.env.example` file.
48
+
49
+ Use the `--ci` flag for automated environments. This enables strict mode where the tool exits with code 1 on any issues, making it perfect for CI/CD pipelines.
50
+
51
+ And the `--example` option allows you to specify which `.env.example` file to compare against.
52
+
53
+ ### Use it in Github Actions:
54
+
55
+ ```yaml
56
+ - name: Check environment variables
57
+ run: dotenv-diff --scan-usage --example .env.example --ci
58
+ ```
59
+
60
+ You can also change the comparison file by using the `--example` flag to point to a different `.env.example` file.
61
+
62
+ ```bash
63
+ dotenv-diff --scan-usage --example .env.example.staging --ci
64
+ ```
65
+
66
+ ## Show unused variables
67
+
68
+ Use `--show-unused` together with `--scan-usage` to list variables that are defined in `.env` but never used in your codebase:
69
+ ```bash
70
+ dotenv-diff --scan-usage --show-unused
71
+ ```
72
+ 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.
73
+
74
+ ## Show scan statistics
75
+
76
+ ```bash
77
+ dotenv-diff --show-stats
78
+ ```
79
+ 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.
80
+
81
+ ## include or exclude specific files for scanning
82
+
83
+ You can specify which files to include or exclude from the scan using the `--include-files` and `--exclude-files` options:
84
+
85
+ ```bash
86
+ dotenv-diff --scan-usage --include-files '**/*.js,**/*.ts' --exclude-files '**/*.spec.ts'
87
+ ```
88
+
89
+ This allows you to focus the scan on specific file types or directories, making it more efficient and tailored to your project structure.
90
+
34
91
  ## Optional: Check values too
35
92
 
36
93
  ```bash
@@ -12,5 +12,10 @@ export function createProgram() {
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
14
  .option('--json', 'Output results in JSON format')
15
- .option('--only <list>', 'Comma-separated categories to only run (missing,extra,empty,mismatch,duplicate,gitignore)');
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)');
16
21
  }
@@ -6,10 +6,28 @@ 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
+ examplePath: opts.exampleFlag || undefined,
23
+ envPath,
24
+ json: opts.json,
25
+ showUnused: opts.showUnused,
26
+ showStats: opts.showStats,
27
+ isCiMode: opts.isCiMode,
28
+ });
29
+ process.exit(exitWithError ? 1 : 0);
30
+ }
13
31
  // Special-case: both flags → direct comparison of exactly those two files
14
32
  if (opts.envFlag && opts.exampleFlag) {
15
33
  const envExists = fs.existsSync(opts.envFlag);
@@ -38,6 +56,7 @@ export async function run(program) {
38
56
  ignore: opts.ignore,
39
57
  ignoreRegex: opts.ignoreRegex,
40
58
  only: opts.only,
59
+ showStats: opts.showStats,
41
60
  collect: (e) => report.push(e),
42
61
  });
43
62
  if (opts.json) {
@@ -77,6 +96,7 @@ export async function run(program) {
77
96
  ignore: opts.ignore,
78
97
  ignoreRegex: opts.ignoreRegex,
79
98
  only: opts.only,
99
+ showStats: opts.showStats,
80
100
  collect: (e) => report.push(e),
81
101
  });
82
102
  if (opts.json) {
@@ -12,6 +12,7 @@ export declare function compareMany(pairs: Array<{
12
12
  ignoreRegex: RegExp[];
13
13
  collect?: (entry: CompareJsonEntry) => void;
14
14
  only?: Category[];
15
+ showStats?: boolean;
15
16
  }): Promise<{
16
17
  exitWithError: boolean;
17
18
  }>;
@@ -60,22 +60,22 @@ export async function compareMany(pairs, opts) {
60
60
  !opts.ignoreRegex.some((rx) => rx.test(key)));
61
61
  dupsEx = findDuplicateKeys(examplePath).filter(({ key }) => !opts.ignore.includes(key) &&
62
62
  !opts.ignoreRegex.some((rx) => rx.test(key)));
63
- if (dupsEnv.length || dupsEx.length) {
64
- entry.duplicates = {};
65
- }
66
- if (dupsEnv.length) {
67
- entry.duplicates.env = dupsEnv;
68
- if (!opts.json) {
69
- console.log(chalk.yellow(` ⚠️ Duplicate keys in ${envName} (last occurrence wins):`));
70
- dupsEnv.forEach(({ key, count }) => console.log(chalk.yellow(` - ${key} (${count} occurrences)`)));
71
- }
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)`)));
72
72
  }
73
- if (dupsEx.length) {
74
- entry.duplicates.example = dupsEx;
75
- if (!opts.json) {
76
- console.log(chalk.yellow(` ⚠️ Duplicate keys in ${exampleName} (last occurrence wins):`));
77
- dupsEx.forEach(({ key, count }) => console.log(chalk.yellow(` - ${key} (${count} occurrences)`)));
78
- }
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)`)));
79
79
  }
80
80
  }
81
81
  // Diff + empty
@@ -111,6 +111,27 @@ export async function compareMany(pairs, opts) {
111
111
  opts.collect?.(entry);
112
112
  continue;
113
113
  }
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
+ }
114
135
  if (filtered.missing.length) {
115
136
  entry.missing = filtered.missing;
116
137
  exitWithError = true;
@@ -0,0 +1,49 @@
1
+ import { type ScanOptions } from '../services/codeBaseScanner.js';
2
+ export interface ScanUsageOptions extends ScanOptions {
3
+ envPath?: string;
4
+ examplePath?: string;
5
+ json: boolean;
6
+ showUnused: boolean;
7
+ showStats: boolean;
8
+ isCiMode?: boolean;
9
+ }
10
+ export interface ScanJsonEntry {
11
+ stats: {
12
+ filesScanned: number;
13
+ totalUsages: number;
14
+ uniqueVariables: number;
15
+ };
16
+ missing: Array<{
17
+ variable: string;
18
+ usages: Array<{
19
+ file: string;
20
+ line: number;
21
+ pattern: string;
22
+ context: string;
23
+ }>;
24
+ }>;
25
+ unused: string[];
26
+ allUsages?: Array<{
27
+ variable: string;
28
+ file: string;
29
+ line: number;
30
+ pattern: string;
31
+ context: string;
32
+ }>;
33
+ comparedAgainst?: string;
34
+ totalEnvVariables?: number;
35
+ }
36
+ /**
37
+ * Scans codebase for environment variable usage and compares with .env file
38
+ * @param {ScanUsageOptions} opts - Scan configuration options
39
+ * @param {string} [opts.envPath] - Path to .env file for comparison
40
+ * @param {string} [opts.examplePath] - Path to .env.example file for comparison
41
+ * @param {boolean} opts.json - Output as JSON instead of console
42
+ * @param {boolean} opts.showUnused - Show unused variables from .env
43
+ * @param {boolean} opts.showStats - Show detailed statistics
44
+ * @param {boolean} [opts.isCiMode] - Run in CI mode (exit with error code)
45
+ * @returns {Promise<{exitWithError: boolean}>} Returns true if missing variables found
46
+ */
47
+ export declare function scanUsage(opts: ScanUsageOptions): Promise<{
48
+ exitWithError: boolean;
49
+ }>;
@@ -0,0 +1,207 @@
1
+ import chalk from 'chalk';
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+ import { parseEnvFile } from '../lib/parseEnv.js';
5
+ import { scanCodebase, compareWithEnvFiles, } from '../services/codeBaseScanner.js';
6
+ import { filterIgnoredKeys } from '../core/filterIgnoredKeys.js';
7
+ /**
8
+ * Scans codebase for environment variable usage and compares with .env file
9
+ * @param {ScanUsageOptions} opts - Scan configuration options
10
+ * @param {string} [opts.envPath] - Path to .env file for comparison
11
+ * @param {string} [opts.examplePath] - Path to .env.example file for comparison
12
+ * @param {boolean} opts.json - Output as JSON instead of console
13
+ * @param {boolean} opts.showUnused - Show unused variables from .env
14
+ * @param {boolean} opts.showStats - Show detailed statistics
15
+ * @param {boolean} [opts.isCiMode] - Run in CI mode (exit with error code)
16
+ * @returns {Promise<{exitWithError: boolean}>} Returns true if missing variables found
17
+ */
18
+ export async function scanUsage(opts) {
19
+ if (!opts.json) {
20
+ console.log(chalk.bold('🔍 Scanning codebase for environment variable usage...'));
21
+ console.log();
22
+ }
23
+ // Scan the codebase
24
+ let scanResult = await scanCodebase(opts);
25
+ // Determine which file to compare against
26
+ const compareFile = determineComparisonFile(opts);
27
+ let envVariables = {};
28
+ let comparedAgainst = '';
29
+ if (compareFile) {
30
+ try {
31
+ const envFull = parseEnvFile(compareFile.path);
32
+ const envKeys = filterIgnoredKeys(Object.keys(envFull), opts.ignore, opts.ignoreRegex);
33
+ envVariables = Object.fromEntries(envKeys.map((k) => [k, envFull[k]]));
34
+ scanResult = compareWithEnvFiles(scanResult, envVariables);
35
+ comparedAgainst = compareFile.name;
36
+ }
37
+ catch (error) {
38
+ const errorMessage = `⚠️ Could not read ${compareFile.name}: ${compareFile.path} - ${error}`;
39
+ if (opts.isCiMode) {
40
+ // In CI mode, exit with error if file doesn't exist
41
+ console.error(chalk.red(`❌ ${errorMessage}`));
42
+ return { exitWithError: true };
43
+ }
44
+ if (!opts.json) {
45
+ console.log(chalk.yellow(errorMessage));
46
+ }
47
+ }
48
+ }
49
+ // Prepare JSON output
50
+ if (opts.json) {
51
+ const jsonOutput = createJsonOutput(scanResult, opts, comparedAgainst, Object.keys(envVariables).length);
52
+ console.log(JSON.stringify(jsonOutput, null, 2));
53
+ return { exitWithError: scanResult.missing.length > 0 };
54
+ }
55
+ // Console output
56
+ return outputToConsole(scanResult, opts, comparedAgainst);
57
+ }
58
+ /**
59
+ * Determines which file to use for comparison based on provided options
60
+ */
61
+ function determineComparisonFile(opts) {
62
+ // Priority: explicit flags first, then auto-discovery
63
+ if (opts.examplePath && fs.existsSync(opts.examplePath)) {
64
+ return { path: opts.examplePath, name: path.basename(opts.examplePath) };
65
+ }
66
+ if (opts.envPath && fs.existsSync(opts.envPath)) {
67
+ return { path: opts.envPath, name: path.basename(opts.envPath) };
68
+ }
69
+ // Auto-discovery: look for common env files
70
+ const candidates = ['.env', '.env.example', '.env.local', '.env.production'];
71
+ for (const candidate of candidates) {
72
+ const fullPath = path.resolve(opts.cwd, candidate);
73
+ if (fs.existsSync(fullPath)) {
74
+ return { path: fullPath, name: candidate };
75
+ }
76
+ }
77
+ return null;
78
+ }
79
+ function createJsonOutput(scanResult, opts, comparedAgainst, totalEnvVariables) {
80
+ // Group usages by variable for missing variables
81
+ const missingGrouped = scanResult.missing.map((variable) => ({
82
+ variable,
83
+ usages: scanResult.used
84
+ .filter((u) => u.variable === variable)
85
+ .map((u) => ({
86
+ file: u.file,
87
+ line: u.line,
88
+ pattern: u.pattern,
89
+ context: u.context,
90
+ })),
91
+ }));
92
+ const output = {
93
+ stats: scanResult.stats,
94
+ missing: missingGrouped,
95
+ unused: scanResult.unused,
96
+ };
97
+ // Add comparison info if we compared against a file
98
+ if (comparedAgainst) {
99
+ output.comparedAgainst = comparedAgainst;
100
+ output.totalEnvVariables = totalEnvVariables;
101
+ }
102
+ // Optionally include all usages
103
+ if (opts.showStats) {
104
+ output.allUsages = scanResult.used.map((u) => ({
105
+ variable: u.variable,
106
+ file: u.file,
107
+ line: u.line,
108
+ pattern: u.pattern,
109
+ context: u.context,
110
+ }));
111
+ }
112
+ return output;
113
+ }
114
+ function outputToConsole(scanResult, opts, comparedAgainst) {
115
+ let exitWithError = false;
116
+ // Show what we're comparing against
117
+ if (comparedAgainst) {
118
+ console.log(chalk.gray(`📋 Comparing codebase usage against: ${comparedAgainst}`));
119
+ console.log();
120
+ }
121
+ // Show stats if requested
122
+ if (opts.showStats) {
123
+ console.log(chalk.bold('📊 Scan Statistics:'));
124
+ console.log(chalk.gray(` Files scanned: ${scanResult.stats.filesScanned}`));
125
+ console.log(chalk.gray(` Total usages found: ${scanResult.stats.totalUsages}`));
126
+ console.log(chalk.gray(` Unique variables: ${scanResult.stats.uniqueVariables}`));
127
+ console.log();
128
+ }
129
+ // Always show found variables when not comparing or when no missing variables
130
+ if (!comparedAgainst || scanResult.missing.length === 0) {
131
+ console.log(chalk.green(`✅ Found ${scanResult.stats.uniqueVariables} unique environment variables in use`));
132
+ console.log();
133
+ // List all variables found (if any)
134
+ if (scanResult.stats.uniqueVariables > 0) {
135
+ // Group by variable to get unique list
136
+ const variableUsages = scanResult.used.reduce((acc, usage) => {
137
+ if (!acc[usage.variable]) {
138
+ acc[usage.variable] = [];
139
+ }
140
+ acc[usage.variable].push(usage);
141
+ return acc;
142
+ }, {});
143
+ // Display each unique variable
144
+ for (const [variable, usages] of Object.entries(variableUsages)) {
145
+ console.log(chalk.blue(` ${variable}`));
146
+ // Show usage details if stats are enabled
147
+ if (opts.showStats) {
148
+ const displayUsages = usages.slice(0, 2);
149
+ displayUsages.forEach((usage) => {
150
+ console.log(chalk.gray(` Used in: ${usage.file}:${usage.line} (${usage.pattern})`));
151
+ });
152
+ if (usages.length > 2) {
153
+ console.log(chalk.gray(` ... and ${usages.length - 2} more locations`));
154
+ }
155
+ }
156
+ }
157
+ console.log();
158
+ }
159
+ }
160
+ // Missing variables (used in code but not in env file)
161
+ if (scanResult.missing.length > 0) {
162
+ exitWithError = true;
163
+ const fileType = comparedAgainst || 'environment file';
164
+ console.log(chalk.red(`❌ Missing in ${fileType}:`));
165
+ const grouped = scanResult.missing.reduce((acc, variable) => {
166
+ const usages = scanResult.used.filter((u) => u.variable === variable);
167
+ acc[variable] = usages;
168
+ return acc;
169
+ }, {});
170
+ for (const [variable, usages] of Object.entries(grouped)) {
171
+ console.log(chalk.red(` - ${variable}`));
172
+ // Show first few usages
173
+ const maxShow = 3;
174
+ usages.slice(0, maxShow).forEach((usage) => {
175
+ console.log(chalk.gray(` Used in: ${usage.file}:${usage.line} (${usage.pattern})`));
176
+ });
177
+ if (usages.length > maxShow) {
178
+ console.log(chalk.gray(` ... and ${usages.length - maxShow} more locations`));
179
+ }
180
+ }
181
+ console.log();
182
+ // CI mode specific message
183
+ if (opts.isCiMode) {
184
+ console.log(chalk.red(`💥 Found ${scanResult.missing.length} missing environment variable(s).`));
185
+ console.log(chalk.red(` Add these variables to ${comparedAgainst || 'your environment file'} to fix this error.`));
186
+ console.log();
187
+ }
188
+ }
189
+ // Unused variables (in env file but not used in code)
190
+ if (opts.showUnused && scanResult.unused.length > 0) {
191
+ const fileType = comparedAgainst || 'environment file';
192
+ console.log(chalk.yellow(`⚠️ Unused in codebase (defined in ${fileType}):`));
193
+ scanResult.unused.forEach((variable) => {
194
+ console.log(chalk.yellow(` - ${variable}`));
195
+ });
196
+ console.log();
197
+ }
198
+ // Success message for env file comparison
199
+ if (comparedAgainst && scanResult.missing.length === 0) {
200
+ console.log(chalk.green(`✅ All used environment variables are defined in ${comparedAgainst}`));
201
+ if (opts.showUnused && scanResult.unused.length === 0) {
202
+ console.log(chalk.green('✅ No unused environment variables found'));
203
+ }
204
+ console.log();
205
+ }
206
+ return { exitWithError };
207
+ }
@@ -25,6 +25,11 @@ export function normalizeOptions(raw) {
25
25
  const json = Boolean(raw.json);
26
26
  const onlyParsed = parseCategories(raw.only, '--only');
27
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);
28
33
  const ignore = parseList(raw.ignore);
29
34
  const ignoreRegex = [];
30
35
  for (const pattern of parseList(raw.ignoreRegex)) {
@@ -54,5 +59,10 @@ export function normalizeOptions(raw) {
54
59
  ignoreRegex,
55
60
  cwd,
56
61
  only,
62
+ scanUsage,
63
+ includeFiles,
64
+ excludeFiles,
65
+ showUnused,
66
+ showStats,
57
67
  };
58
68
  }
@@ -12,6 +12,11 @@ export type Options = {
12
12
  ignoreRegex: RegExp[];
13
13
  cwd: string;
14
14
  only?: Category[];
15
+ scanUsage: boolean;
16
+ includeFiles: string[];
17
+ excludeFiles: string[];
18
+ showUnused: boolean;
19
+ showStats: boolean;
15
20
  };
16
21
  export type RawOptions = {
17
22
  checkValues?: boolean;
@@ -24,6 +29,11 @@ export type RawOptions = {
24
29
  ignore?: string | string[];
25
30
  ignoreRegex?: string | string[];
26
31
  only?: string | string[];
32
+ scanUsage?: boolean;
33
+ includeFiles?: string | string[];
34
+ excludeFiles?: string | string[];
35
+ showUnused?: boolean;
36
+ showStats?: boolean;
27
37
  };
28
38
  export type CompareJsonEntry = {
29
39
  env: string;
@@ -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.5",
3
+ "version": "2.1.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
  },