dotenv-diff 1.5.0 → 1.6.2

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
@@ -31,7 +31,7 @@ dotenv-diff will automatically compare all matching .env* files in your project
31
31
  - `.env.production`
32
32
  - Any other .env.* file
33
33
 
34
- ## Optional: Check values too
34
+ ## Optional: Check values too
35
35
 
36
36
  ```bash
37
37
  dotenv-diff --check-values
@@ -39,6 +39,30 @@ dotenv-diff --check-values
39
39
 
40
40
  When using the `--check-values` option, the tool will also compare the actual values of the variables in `.env` and `.env.example`. It will report any mismatches found and it also compares values if .env.example defines a non-empty expected value.
41
41
 
42
+ ## Duplicate key warnings
43
+
44
+ `dotenv-diff` warns when a `.env*` file contains the same key multiple times. The last occurrence wins. Suppress these warnings with `--allow-duplicates`.
45
+
46
+ ## Compare specific files
47
+
48
+ Override the autoscan and compare exactly two files:
49
+
50
+ ```bash
51
+ dotenv-diff --env .env.staging --example .env.example.staging
52
+ ```
53
+
54
+ You can also fix only one side. For example, force a particular `.env` file and let the tool find the matching `.env.example`:
55
+
56
+ ```bash
57
+ dotenv-diff --env .env.production
58
+ ```
59
+
60
+ Or provide just an example file and let the tool locate the appropriate `.env`:
61
+
62
+ ```bash
63
+ dotenv-diff --example .env.example.production
64
+ ```
65
+
42
66
  ## CI usage
43
67
 
44
68
  Run non-interactively in CI environments with:
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env node
2
+ import { createProgram } from '../src/cli/program.js';
3
+ import { run } from '../src/cli/run.js';
4
+ const program = createProgram();
5
+ run(program).catch((err) => {
6
+ console.error(err?.message ?? err);
7
+ process.exit(1);
8
+ });
package/dist/cli.js CHANGED
@@ -21,6 +21,8 @@ program
21
21
  .option('--check-values', 'Compare actual values if example has values')
22
22
  .option('--ci', 'Run non-interactively and never create files')
23
23
  .option('-y, --yes', 'Run non-interactively and answer Yes to prompts')
24
+ .option('--env <file>', 'Path to a specific .env file')
25
+ .option('--example <file>', 'Path to a specific .env.example file')
24
26
  .parse(process.argv);
25
27
  const options = program.opts();
26
28
  const checkValues = options.checkValues ?? false;
@@ -30,14 +32,108 @@ if (isCiMode && isYesMode) {
30
32
  console.log(chalk.yellow('⚠️ Both --ci and --yes provided; proceeding with --yes.'));
31
33
  }
32
34
  const cwd = process.cwd();
35
+ const envFlag = options.env ? path.resolve(cwd, options.env) : null;
36
+ const exampleFlag = options.example ? path.resolve(cwd, options.example) : null;
37
+ const bothFlags = Boolean(envFlag && exampleFlag);
38
+ let alreadyWarnedMissingEnv = false;
39
+ if (bothFlags) {
40
+ const envExistsFlag = fs.existsSync(envFlag);
41
+ const exampleExistsFlag = fs.existsSync(exampleFlag);
42
+ if (!envExistsFlag || !exampleExistsFlag) {
43
+ if (!envExistsFlag) {
44
+ console.error(chalk.red(`❌ Error: --env file not found: ${path.basename(envFlag)}`));
45
+ }
46
+ if (!exampleExistsFlag) {
47
+ console.error(chalk.red(`❌ Error: --example file not found: ${path.basename(exampleFlag)}`));
48
+ }
49
+ process.exit(1);
50
+ }
51
+ console.log(chalk.bold(`🔍 Comparing ${path.basename(envFlag)} ↔ ${path.basename(exampleFlag)}...`));
52
+ const current = parseEnvFile(envFlag);
53
+ const example = parseEnvFile(exampleFlag);
54
+ const diff = diffEnv(current, example, checkValues);
55
+ const emptyKeys = Object.entries(current)
56
+ .filter(([, value]) => (value ?? '').trim() === '')
57
+ .map(([key]) => key);
58
+ let exitWithError = false;
59
+ if (diff.missing.length === 0 &&
60
+ diff.extra.length === 0 &&
61
+ emptyKeys.length === 0 &&
62
+ diff.valueMismatches.length === 0) {
63
+ console.log(chalk.green(' ✅ All keys match.'));
64
+ console.log();
65
+ process.exit(0);
66
+ }
67
+ if (diff.missing.length > 0) {
68
+ exitWithError = true;
69
+ console.log(chalk.red(' ❌ Missing keys:'));
70
+ diff.missing.forEach((key) => console.log(chalk.red(` - ${key}`)));
71
+ }
72
+ if (diff.extra.length > 0) {
73
+ console.log(chalk.yellow(' ⚠️ Extra keys (not in example):'));
74
+ diff.extra.forEach((key) => console.log(chalk.yellow(` - ${key}`)));
75
+ }
76
+ if (emptyKeys.length > 0) {
77
+ console.log(chalk.yellow(' ⚠️ Empty values:'));
78
+ emptyKeys.forEach((key) => console.log(chalk.yellow(` - ${key}`)));
79
+ }
80
+ if (checkValues && diff.valueMismatches.length > 0) {
81
+ console.log(chalk.yellow(' ⚠️ Value mismatches:'));
82
+ diff.valueMismatches.forEach(({ key, expected, actual }) => {
83
+ console.log(chalk.yellow(` - ${key}: expected '${expected}', but got '${actual}'`));
84
+ });
85
+ }
86
+ console.log();
87
+ process.exit(exitWithError ? 1 : 0);
88
+ }
33
89
  const envFiles = fs
34
90
  .readdirSync(cwd)
35
91
  .filter((f) => f.startsWith('.env') && !f.startsWith('.env.example'))
36
92
  .sort((a, b) => (a === '.env' ? -1 : b === '.env' ? 1 : a.localeCompare(b)));
37
93
  // Brug første .env* fil som "main" hvis flere findes
38
94
  // (resten håndteres senere i et loop)
39
- const primaryEnv = envFiles.includes('.env') ? '.env' : envFiles[0] || '.env';
40
- const primaryExample = '.env.example';
95
+ let primaryEnv = envFiles.includes('.env') ? '.env' : envFiles[0] || '.env';
96
+ let primaryExample = '.env.example';
97
+ // Override env-siden hvis --env er sat
98
+ if (envFlag && !exampleFlag) {
99
+ const envNameFromFlag = path.basename(envFlag);
100
+ primaryEnv = envNameFromFlag;
101
+ const exists = fs.existsSync(envFlag);
102
+ if (exists) {
103
+ const set = new Set([envNameFromFlag, ...envFiles]);
104
+ envFiles.length = 0;
105
+ envFiles.push(...[...set]);
106
+ }
107
+ const suffix = envNameFromFlag === '.env' ? '' : envNameFromFlag.replace('.env', '');
108
+ const potentialExample = suffix ? `.env.example${suffix}` : '.env.example';
109
+ if (fs.existsSync(path.resolve(cwd, potentialExample))) {
110
+ primaryExample = potentialExample;
111
+ }
112
+ }
113
+ // Override example-siden hvis --example er sat
114
+ if (exampleFlag && !envFlag) {
115
+ const exampleNameFromFlag = path.basename(exampleFlag);
116
+ primaryExample = exampleNameFromFlag;
117
+ if (exampleNameFromFlag.startsWith('.env.example')) {
118
+ const suffix = exampleNameFromFlag.slice('.env.example'.length); // '' eller '.staging'
119
+ const matchedEnv = suffix ? `.env${suffix}` : '.env';
120
+ if (fs.existsSync(path.resolve(cwd, matchedEnv))) {
121
+ primaryEnv = matchedEnv;
122
+ envFiles.length = 0;
123
+ envFiles.push(matchedEnv);
124
+ }
125
+ else {
126
+ // Ingen tidlig log her; Case 2 håndterer “file not found”-logikken
127
+ alreadyWarnedMissingEnv = true;
128
+ }
129
+ }
130
+ else {
131
+ // Ikke et .env.example* navn → betragt det som en arbitrær example-fil.
132
+ // Rør ikke env’ens valg; behold primaryEnv som tidligere (typisk '.env').
133
+ if (envFiles.length === 0)
134
+ envFiles.push(primaryEnv);
135
+ }
136
+ }
41
137
  const envPath = path.resolve(cwd, primaryEnv);
42
138
  const examplePath = path.resolve(cwd, primaryExample);
43
139
  const envExists = fs.existsSync(envPath);
@@ -49,7 +145,9 @@ if (envFiles.length === 0 && !exampleExists) {
49
145
  }
50
146
  // Case 2: .env is missing but .env.example exists
51
147
  if (!envExists && exampleExists) {
52
- console.log(chalk.yellow('📄 .env file not found.'));
148
+ if (!alreadyWarnedMissingEnv) {
149
+ console.log(chalk.yellow(`📄 ${path.basename(envPath)} file not found.`));
150
+ }
53
151
  let createEnv = false;
54
152
  if (isYesMode) {
55
153
  createEnv = true;
@@ -62,7 +160,7 @@ if (!envExists && exampleExists) {
62
160
  const response = await prompts({
63
161
  type: 'select',
64
162
  name: 'createEnv',
65
- message: '❓ Do you want to create a new .env file from .env.example?',
163
+ message: `❓ Do you want to create a new ${path.basename(envPath)} file from ${path.basename(examplePath)}?`,
66
164
  choices: [
67
165
  { title: 'Yes', value: true },
68
166
  { title: 'No', value: false },
@@ -77,12 +175,12 @@ if (!envExists && exampleExists) {
77
175
  }
78
176
  const exampleContent = fs.readFileSync(examplePath, 'utf-8');
79
177
  fs.writeFileSync(envPath, exampleContent);
80
- console.log(chalk.green('✅ .env file created successfully from .env.example.\n'));
81
- warnIfEnvNotIgnored();
178
+ console.log(chalk.green(`✅ ${path.basename(envPath)} file created successfully from ${path.basename(examplePath)}.\n`));
179
+ warnIfEnvNotIgnored({ envFile: path.basename(envPath) });
82
180
  }
83
181
  // Case 3: .env exists, but .env.example is missing
84
182
  if (envExists && !exampleExists) {
85
- console.log(chalk.yellow('📄 .env.example file not found.'));
183
+ console.log(chalk.yellow(`📄 ${path.basename(examplePath)} file not found.`));
86
184
  let createExample = false;
87
185
  if (isYesMode) {
88
186
  createExample = true;
@@ -95,7 +193,7 @@ if (envExists && !exampleExists) {
95
193
  const response = await prompts({
96
194
  type: 'select',
97
195
  name: 'createExample',
98
- message: '❓ Do you want to create a new .env.example file from .env?',
196
+ message: `❓ Do you want to create a new ${path.basename(examplePath)} file from ${path.basename(envPath)}?`,
99
197
  choices: [
100
198
  { title: 'Yes', value: true },
101
199
  { title: 'No', value: false },
@@ -120,7 +218,7 @@ if (envExists && !exampleExists) {
120
218
  })
121
219
  .join('\n');
122
220
  fs.writeFileSync(examplePath, envContent);
123
- console.log(chalk.green('✅ .env.example file created successfully from .env.\n'));
221
+ console.log(chalk.green(`✅ ${path.basename(examplePath)} file created successfully from ${path.basename(envPath)}.\n`));
124
222
  }
125
223
  // Case 4: Run comparison
126
224
  if (!fs.existsSync(envPath) || !fs.existsSync(examplePath)) {
@@ -130,12 +228,22 @@ if (!fs.existsSync(envPath) || !fs.existsSync(examplePath)) {
130
228
  // Case 5: Compare all found .env* files
131
229
  let exitWithError = false;
132
230
  for (const envName of envFiles.length > 0 ? envFiles : [primaryEnv]) {
231
+ // Skip self-compare når --example er sat (uden --env)
232
+ if (exampleFlag && !envFlag) {
233
+ const envAbs = path.resolve(cwd, envName);
234
+ if (envAbs === examplePath) {
235
+ // (valgfri) console.log(chalk.gray(`Skipping self-compare for ${envName}`));
236
+ continue;
237
+ }
238
+ }
133
239
  const suffix = envName === '.env' ? '' : envName.replace('.env', '');
134
240
  const exampleName = suffix ? `.env.example${suffix}` : primaryExample;
135
241
  const envPathCurrent = path.resolve(cwd, envName);
136
- const examplePathCurrent = fs.existsSync(path.resolve(cwd, exampleName))
137
- ? path.resolve(cwd, exampleName)
138
- : examplePath;
242
+ const examplePathCurrent = (exampleFlag && !envFlag)
243
+ ? examplePath
244
+ : (fs.existsSync(path.resolve(cwd, exampleName))
245
+ ? path.resolve(cwd, exampleName)
246
+ : examplePath);
139
247
  if (!fs.existsSync(envPathCurrent) || !fs.existsSync(examplePathCurrent)) {
140
248
  console.log(chalk.bold(`🔍 Comparing ${envName} ↔ ${path.basename(examplePathCurrent)}...`));
141
249
  console.log(chalk.yellow(' ⚠️ Skipping: missing matching example file.'));
@@ -0,0 +1,2 @@
1
+ import { Command } from 'commander';
2
+ export declare function createProgram(): Command;
@@ -0,0 +1,12 @@
1
+ import { Command } from 'commander';
2
+ export function createProgram() {
3
+ return new Command()
4
+ .name('dotenv-diff')
5
+ .description('Compare .env and .env.example files')
6
+ .option('--check-values', 'Compare actual values if example has values')
7
+ .option('--ci', 'Run non-interactively and never create files')
8
+ .option('-y, --yes', 'Run non-interactively and answer Yes to prompts')
9
+ .option('--env <file>', 'Path to a specific .env file')
10
+ .option('--example <file>', 'Path to a specific .env.example file')
11
+ .option('--allow-duplicates', 'Do not warn about duplicate keys in .env* files');
12
+ }
@@ -0,0 +1,2 @@
1
+ import type { Command } from 'commander';
2
+ export declare function run(program: Command): Promise<void>;
@@ -0,0 +1,62 @@
1
+ import { normalizeOptions } from '../config/options.js';
2
+ import { discoverEnvFiles } from '../services/envDiscovery.js';
3
+ import { pairWithExample } from '../services/envPairing.js';
4
+ import { ensureFilesOrPrompt } from '../commands/init.js';
5
+ import { compareMany } from '../commands/compare.js';
6
+ import fs from 'fs';
7
+ import path from 'path';
8
+ import chalk from 'chalk';
9
+ export async function run(program) {
10
+ program.parse(process.argv);
11
+ const raw = program.opts();
12
+ const opts = normalizeOptions(raw);
13
+ // Special-case: both flags → direct comparison
14
+ if (opts.envFlag && opts.exampleFlag) {
15
+ const envExists = fs.existsSync(opts.envFlag);
16
+ const exExists = fs.existsSync(opts.exampleFlag);
17
+ if (!envExists || !exExists) {
18
+ if (!envExists) {
19
+ console.error(chalk.red(`❌ Error: --env file not found: ${path.basename(opts.envFlag)}`));
20
+ }
21
+ if (!exExists) {
22
+ console.error(chalk.red(`❌ Error: --example file not found: ${path.basename(opts.exampleFlag)}`));
23
+ }
24
+ process.exit(1);
25
+ }
26
+ const { exitWithError } = await compareMany([
27
+ {
28
+ envName: path.basename(opts.envFlag),
29
+ envPath: opts.envFlag,
30
+ examplePath: opts.exampleFlag,
31
+ },
32
+ ], {
33
+ checkValues: opts.checkValues,
34
+ cwd: opts.cwd,
35
+ allowDuplicates: opts.allowDuplicates,
36
+ });
37
+ process.exit(exitWithError ? 1 : 0);
38
+ }
39
+ const d = discoverEnvFiles({
40
+ cwd: opts.cwd,
41
+ envFlag: opts.envFlag,
42
+ exampleFlag: opts.exampleFlag,
43
+ });
44
+ const res = await ensureFilesOrPrompt({
45
+ cwd: d.cwd,
46
+ primaryEnv: d.primaryEnv,
47
+ primaryExample: d.primaryExample,
48
+ alreadyWarnedMissingEnv: d.alreadyWarnedMissingEnv,
49
+ isYesMode: opts.isYesMode,
50
+ isCiMode: opts.isCiMode,
51
+ });
52
+ if (res.shouldExit)
53
+ process.exit(res.exitCode);
54
+ // compare all pairs
55
+ const pairs = pairWithExample(d);
56
+ const { exitWithError } = await compareMany(pairs, {
57
+ checkValues: opts.checkValues,
58
+ cwd: opts.cwd,
59
+ allowDuplicates: opts.allowDuplicates,
60
+ });
61
+ process.exit(exitWithError ? 1 : 0);
62
+ }
@@ -0,0 +1,11 @@
1
+ export declare function compareMany(pairs: Array<{
2
+ envName: string;
3
+ envPath: string;
4
+ examplePath: string;
5
+ }>, opts: {
6
+ checkValues: boolean;
7
+ cwd: string;
8
+ allowDuplicates?: boolean;
9
+ }): Promise<{
10
+ exitWithError: boolean;
11
+ }>;
@@ -0,0 +1,72 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import chalk from 'chalk';
4
+ import { parseEnvFile } from '../lib/parseEnv.js';
5
+ import { diffEnv } from '../lib/diffEnv.js';
6
+ import { warnIfEnvNotIgnored } from '../services/git.js';
7
+ import { findDuplicateKeys } from '../services/duplicates.js';
8
+ export async function compareMany(pairs, opts) {
9
+ let exitWithError = false;
10
+ for (const { envName, envPath, examplePath } of pairs) {
11
+ if (!fs.existsSync(envPath) || !fs.existsSync(examplePath)) {
12
+ console.log(chalk.bold(`🔍 Comparing ${envName} ↔ ${path.basename(examplePath)}...`));
13
+ console.log(chalk.yellow(' ⚠️ Skipping: missing matching example file.'));
14
+ console.log();
15
+ continue;
16
+ }
17
+ console.log(chalk.bold(`🔍 Comparing ${envName} ↔ ${path.basename(examplePath)}...`));
18
+ warnIfEnvNotIgnored({
19
+ cwd: opts.cwd,
20
+ envFile: envName,
21
+ log: (msg) => console.log(msg.replace(/^/gm, ' ')),
22
+ });
23
+ if (!opts.allowDuplicates) {
24
+ const dupsEnv = findDuplicateKeys(envPath);
25
+ if (dupsEnv.length > 0) {
26
+ console.log(chalk.yellow(` ⚠️ Duplicate keys in ${envName} (last occurrence wins):`));
27
+ dupsEnv.forEach(({ key, count }) => console.log(chalk.yellow(` - ${key} (${count} occurrences)`)));
28
+ }
29
+ const exName = path.basename(examplePath);
30
+ const dupsEx = findDuplicateKeys(examplePath);
31
+ if (dupsEx.length > 0) {
32
+ console.log(chalk.yellow(` ⚠️ Duplicate keys in ${exName} (last occurrence wins):`));
33
+ dupsEx.forEach(({ key, count }) => console.log(chalk.yellow(` - ${key} (${count} occurrences)`)));
34
+ }
35
+ }
36
+ const current = parseEnvFile(envPath);
37
+ const example = parseEnvFile(examplePath);
38
+ const diff = diffEnv(current, example, opts.checkValues);
39
+ const emptyKeys = Object.entries(current)
40
+ .filter(([, v]) => (v ?? '').trim() === '')
41
+ .map(([k]) => k);
42
+ if (diff.missing.length === 0 &&
43
+ diff.extra.length === 0 &&
44
+ emptyKeys.length === 0 &&
45
+ diff.valueMismatches.length === 0) {
46
+ console.log(chalk.green(' ✅ All keys match.'));
47
+ console.log();
48
+ continue;
49
+ }
50
+ if (diff.missing.length > 0) {
51
+ exitWithError = true;
52
+ console.log(chalk.red(' ❌ Missing keys:'));
53
+ diff.missing.forEach((key) => console.log(chalk.red(` - ${key}`)));
54
+ }
55
+ if (diff.extra.length > 0) {
56
+ console.log(chalk.yellow(' ⚠️ Extra keys (not in example):'));
57
+ diff.extra.forEach((key) => console.log(chalk.yellow(` - ${key}`)));
58
+ }
59
+ if (emptyKeys.length > 0) {
60
+ console.log(chalk.yellow(' ⚠️ Empty values:'));
61
+ emptyKeys.forEach((key) => console.log(chalk.yellow(` - ${key}`)));
62
+ }
63
+ if (opts.checkValues && diff.valueMismatches.length > 0) {
64
+ console.log(chalk.yellow(' ⚠️ Value mismatches:'));
65
+ diff.valueMismatches.forEach(({ key, expected, actual }) => {
66
+ console.log(chalk.yellow(` - ${key}: expected '${expected}', but got '${actual}'`));
67
+ });
68
+ }
69
+ console.log();
70
+ }
71
+ return { exitWithError };
72
+ }
@@ -0,0 +1,12 @@
1
+ export declare function ensureFilesOrPrompt(args: {
2
+ cwd: string;
3
+ primaryEnv: string;
4
+ primaryExample: string;
5
+ alreadyWarnedMissingEnv: boolean;
6
+ isYesMode: boolean;
7
+ isCiMode: boolean;
8
+ }): Promise<{
9
+ didCreate: boolean;
10
+ shouldExit: boolean;
11
+ exitCode: number;
12
+ }>;
@@ -0,0 +1,66 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import chalk from 'chalk';
4
+ import { confirmYesNo } from '../ui/prompts.js';
5
+ import { warnIfEnvNotIgnored } from '../services/git.js';
6
+ export async function ensureFilesOrPrompt(args) {
7
+ const { cwd, primaryEnv, primaryExample, alreadyWarnedMissingEnv, isYesMode, isCiMode, } = args;
8
+ const envPath = path.resolve(cwd, primaryEnv);
9
+ const examplePath = path.resolve(cwd, primaryExample);
10
+ const envExists = fs.existsSync(envPath);
11
+ const exampleExists = fs.existsSync(examplePath);
12
+ // Case 1: no .env and no .env.example
13
+ if (!envExists && !exampleExists) {
14
+ const hasAnyEnv = fs.readdirSync(cwd).some((f) => f.startsWith('.env'));
15
+ if (!hasAnyEnv) {
16
+ console.log(chalk.yellow('⚠️ No .env* or .env.example file found. Skipping comparison.'));
17
+ return { didCreate: false, shouldExit: true, exitCode: 0 };
18
+ }
19
+ }
20
+ // Case 2: missing .env but has .env.example
21
+ if (!envExists && exampleExists) {
22
+ if (!alreadyWarnedMissingEnv) {
23
+ console.log(chalk.yellow(`📄 ${path.basename(envPath)} file not found.`));
24
+ }
25
+ let createEnv = isYesMode
26
+ ? true
27
+ : isCiMode
28
+ ? false
29
+ : await confirmYesNo(`❓ Do you want to create a new ${path.basename(envPath)} file from ${path.basename(examplePath)}?`, { isCiMode, isYesMode });
30
+ if (!createEnv) {
31
+ console.log(chalk.gray('🚫 Skipping .env creation.'));
32
+ return { didCreate: false, shouldExit: true, exitCode: 0 };
33
+ }
34
+ const exampleContent = fs.readFileSync(examplePath, 'utf-8');
35
+ fs.writeFileSync(envPath, exampleContent);
36
+ console.log(chalk.green(`✅ ${path.basename(envPath)} file created successfully from ${path.basename(examplePath)}.\n`));
37
+ warnIfEnvNotIgnored({ envFile: path.basename(envPath) });
38
+ }
39
+ // Case 3: has .env but is missing .env.example
40
+ if (envExists && !exampleExists) {
41
+ console.log(chalk.yellow(`📄 ${path.basename(examplePath)} file not found.`));
42
+ let createExample = isYesMode
43
+ ? true
44
+ : isCiMode
45
+ ? false
46
+ : await confirmYesNo(`❓ Do you want to create a new ${path.basename(examplePath)} file from ${path.basename(envPath)}?`, { isCiMode, isYesMode });
47
+ if (!createExample) {
48
+ console.log(chalk.gray('🚫 Skipping .env.example creation.'));
49
+ return { didCreate: false, shouldExit: true, exitCode: 0 };
50
+ }
51
+ const envContent = fs
52
+ .readFileSync(envPath, 'utf-8')
53
+ .split('\n')
54
+ .map((line) => {
55
+ const trimmed = line.trim();
56
+ if (!trimmed || trimmed.startsWith('#'))
57
+ return trimmed;
58
+ const [key] = trimmed.split('=');
59
+ return `${key}=`;
60
+ })
61
+ .join('\n');
62
+ fs.writeFileSync(examplePath, envContent);
63
+ console.log(chalk.green(`✅ ${path.basename(examplePath)} file created successfully from ${path.basename(envPath)}.\n`));
64
+ }
65
+ return { didCreate: true, shouldExit: false, exitCode: 0 };
66
+ }
@@ -0,0 +1,19 @@
1
+ export type Options = {
2
+ checkValues: boolean;
3
+ isCiMode: boolean;
4
+ isYesMode: boolean;
5
+ allowDuplicates: boolean;
6
+ envFlag: string | null;
7
+ exampleFlag: string | null;
8
+ cwd: string;
9
+ };
10
+ type RawOptions = {
11
+ checkValues?: boolean;
12
+ ci?: boolean;
13
+ yes?: boolean;
14
+ allowDuplicates?: boolean;
15
+ env?: string;
16
+ example?: string;
17
+ };
18
+ export declare function normalizeOptions(raw: RawOptions): Options;
19
+ export {};
@@ -0,0 +1,23 @@
1
+ import chalk from 'chalk';
2
+ import path from 'path';
3
+ export function normalizeOptions(raw) {
4
+ const checkValues = raw.checkValues ?? false;
5
+ const isCiMode = Boolean(raw.ci);
6
+ const isYesMode = Boolean(raw.yes);
7
+ const allowDuplicates = Boolean(raw.allowDuplicates);
8
+ if (isCiMode && isYesMode) {
9
+ console.log(chalk.yellow('⚠️ Both --ci and --yes provided; proceeding with --yes.'));
10
+ }
11
+ const cwd = process.cwd();
12
+ const envFlag = typeof raw.env === 'string' ? path.resolve(cwd, raw.env) : null;
13
+ const exampleFlag = typeof raw.example === 'string' ? path.resolve(cwd, raw.example) : null;
14
+ return {
15
+ checkValues,
16
+ isCiMode,
17
+ isYesMode,
18
+ allowDuplicates,
19
+ envFlag,
20
+ exampleFlag,
21
+ cwd,
22
+ };
23
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Scan a .env-like file for duplicate keys.
3
+ * - Ignores empty lines and comments (#...)
4
+ * - Splits on first '='
5
+ * - Trims the key
6
+ * - Returns keys that appear more than once, with counts
7
+ */
8
+ export declare function findDuplicateKeys(filePath: string): Array<{
9
+ key: string;
10
+ count: number;
11
+ }>;
@@ -0,0 +1,33 @@
1
+ import fs from 'fs';
2
+ /**
3
+ * Scan a .env-like file for duplicate keys.
4
+ * - Ignores empty lines and comments (#...)
5
+ * - Splits on first '='
6
+ * - Trims the key
7
+ * - Returns keys that appear more than once, with counts
8
+ */
9
+ export function findDuplicateKeys(filePath) {
10
+ if (!fs.existsSync(filePath))
11
+ return [];
12
+ const raw = fs.readFileSync(filePath, 'utf8');
13
+ const lines = raw.split('\n');
14
+ const counts = new Map();
15
+ for (const line of lines) {
16
+ const trimmed = line.trim();
17
+ if (!trimmed || trimmed.startsWith('#'))
18
+ continue;
19
+ const eq = trimmed.indexOf('=');
20
+ if (eq <= 0)
21
+ continue; // no '=' or empty key
22
+ const key = trimmed.slice(0, eq).trim();
23
+ if (!key)
24
+ continue;
25
+ counts.set(key, (counts.get(key) ?? 0) + 1);
26
+ }
27
+ const duplicates = [];
28
+ for (const [key, count] of counts) {
29
+ if (count > 1)
30
+ duplicates.push({ key, count });
31
+ }
32
+ return duplicates;
33
+ }
@@ -0,0 +1,2 @@
1
+ export { parseEnvFile } from './lib/parseEnv.js';
2
+ export { diffEnv, DiffResult } from './lib/diffEnv.js';
@@ -0,0 +1,2 @@
1
+ export { parseEnvFile } from './lib/parseEnv.js';
2
+ export { diffEnv } from './lib/diffEnv.js';
@@ -0,0 +1,18 @@
1
+ export type DiffResult = {
2
+ missing: string[];
3
+ extra: string[];
4
+ valueMismatches: {
5
+ key: string;
6
+ expected: string;
7
+ actual: string;
8
+ }[];
9
+ };
10
+ /**
11
+ * Compares two .env files and returns their differences.
12
+ *
13
+ * @param current - An object representing the current `.env` file (key-value pairs).
14
+ * @param example - An object representing the `.env.example` file (key-value pairs).
15
+ * @param checkValues - If true, compare values when the example has a non-empty value.
16
+ * @returns A `DiffResult` object containing missing, extra, and mismatched keys.
17
+ */
18
+ export declare function diffEnv(current: Record<string, string>, example: Record<string, string>, checkValues?: boolean): DiffResult;
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Compares two .env files and returns their differences.
3
+ *
4
+ * @param current - An object representing the current `.env` file (key-value pairs).
5
+ * @param example - An object representing the `.env.example` file (key-value pairs).
6
+ * @param checkValues - If true, compare values when the example has a non-empty value.
7
+ * @returns A `DiffResult` object containing missing, extra, and mismatched keys.
8
+ */
9
+ export function diffEnv(current, example, checkValues = false) {
10
+ const currentKeys = Object.keys(current);
11
+ const exampleKeys = Object.keys(example);
12
+ const missing = exampleKeys.filter((key) => !currentKeys.includes(key));
13
+ const extra = currentKeys.filter((key) => !exampleKeys.includes(key));
14
+ let valueMismatches = [];
15
+ if (checkValues) {
16
+ valueMismatches = exampleKeys
17
+ .filter((key) => {
18
+ return (currentKeys.includes(key) &&
19
+ example[key].trim() !== '' &&
20
+ current[key] !== example[key]);
21
+ })
22
+ .map((key) => ({
23
+ key,
24
+ expected: example[key],
25
+ actual: current[key],
26
+ }));
27
+ }
28
+ return { missing, extra, valueMismatches };
29
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Parses a `.env` file and returns an object with key-value pairs.
3
+ *
4
+ * @param path - The file path to the `.env` file.
5
+ * @returns A record object representing parsed environment variables.
6
+ *
7
+ * Lines that are empty or start with `#` (comments) are ignored.
8
+ * Multi-line or quoted values are not supported.
9
+ */
10
+ export declare function parseEnvFile(path: string): Record<string, string>;
@@ -0,0 +1,25 @@
1
+ import fs from 'fs';
2
+ /**
3
+ * Parses a `.env` file and returns an object with key-value pairs.
4
+ *
5
+ * @param path - The file path to the `.env` file.
6
+ * @returns A record object representing parsed environment variables.
7
+ *
8
+ * Lines that are empty or start with `#` (comments) are ignored.
9
+ * Multi-line or quoted values are not supported.
10
+ */
11
+ export function parseEnvFile(path) {
12
+ const content = fs.readFileSync(path, 'utf-8');
13
+ const lines = content.split('\n');
14
+ const result = {};
15
+ for (const line of lines) {
16
+ const trimmed = line.trim();
17
+ if (!trimmed || trimmed.startsWith('#'))
18
+ continue;
19
+ const [key, ...rest] = trimmed.split('=');
20
+ if (!key)
21
+ continue;
22
+ result[key.trim()] = rest.join('=').trim();
23
+ }
24
+ return result;
25
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Scan a .env-like file for duplicate keys.
3
+ * - Ignores empty lines and comments (#...)
4
+ * - Splits on first '='
5
+ * - Trims the key
6
+ * - Returns keys that appear more than once, with counts
7
+ */
8
+ export declare function findDuplicateKeys(filePath: string): Array<{
9
+ key: string;
10
+ count: number;
11
+ }>;
@@ -0,0 +1,33 @@
1
+ import fs from 'fs';
2
+ /**
3
+ * Scan a .env-like file for duplicate keys.
4
+ * - Ignores empty lines and comments (#...)
5
+ * - Splits on first '='
6
+ * - Trims the key
7
+ * - Returns keys that appear more than once, with counts
8
+ */
9
+ export function findDuplicateKeys(filePath) {
10
+ if (!fs.existsSync(filePath))
11
+ return [];
12
+ const raw = fs.readFileSync(filePath, 'utf8');
13
+ const lines = raw.split('\n');
14
+ const counts = new Map();
15
+ for (const line of lines) {
16
+ const trimmed = line.trim();
17
+ if (!trimmed || trimmed.startsWith('#'))
18
+ continue;
19
+ const eq = trimmed.indexOf('=');
20
+ if (eq <= 0)
21
+ continue; // no '=' or empty key
22
+ const key = trimmed.slice(0, eq).trim();
23
+ if (!key)
24
+ continue;
25
+ counts.set(key, (counts.get(key) ?? 0) + 1);
26
+ }
27
+ const duplicates = [];
28
+ for (const [key, count] of counts) {
29
+ if (count > 1)
30
+ duplicates.push({ key, count });
31
+ }
32
+ return duplicates;
33
+ }
@@ -0,0 +1,14 @@
1
+ export type Discovery = {
2
+ cwd: string;
3
+ envFiles: string[];
4
+ primaryEnv: string;
5
+ primaryExample: string;
6
+ envFlag: string | null;
7
+ exampleFlag: string | null;
8
+ alreadyWarnedMissingEnv: boolean;
9
+ };
10
+ export declare function discoverEnvFiles({ cwd, envFlag, exampleFlag, }: {
11
+ cwd: string;
12
+ envFlag: string | null;
13
+ exampleFlag: string | null;
14
+ }): Discovery;
@@ -0,0 +1,60 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ export function discoverEnvFiles({ cwd, envFlag, exampleFlag, }) {
4
+ // Find all .env* files in the current directory except .env.example*
5
+ const envFiles = fs
6
+ .readdirSync(cwd)
7
+ .filter((f) => f.startsWith('.env') && !f.startsWith('.env.example'))
8
+ .sort((a, b) => a === '.env' ? -1 : b === '.env' ? 1 : a.localeCompare(b));
9
+ let primaryEnv = envFiles.includes('.env') ? '.env' : envFiles[0] || '.env';
10
+ let primaryExample = '.env.example';
11
+ let alreadyWarnedMissingEnv = false;
12
+ // --env (without --example): force primaryEnv and try to find a matching example name via suffix
13
+ if (envFlag && !exampleFlag) {
14
+ const envNameFromFlag = path.basename(envFlag);
15
+ primaryEnv = envNameFromFlag;
16
+ // If the specified --env actually exists, make sure it's in the list (first) without duplicates
17
+ if (fs.existsSync(envFlag)) {
18
+ const set = new Set([envNameFromFlag, ...envFiles]);
19
+ envFiles.length = 0;
20
+ envFiles.push(...Array.from(set));
21
+ }
22
+ // try to find a matching example name based on the suffix
23
+ const suffix = envNameFromFlag === '.env' ? '' : envNameFromFlag.replace('.env', '');
24
+ const potentialExample = suffix ? `.env.example${suffix}` : '.env.example';
25
+ if (fs.existsSync(path.resolve(cwd, potentialExample))) {
26
+ primaryExample = potentialExample;
27
+ }
28
+ }
29
+ // --example (without --env): force primaryExample and try to find a matching env name via suffix
30
+ if (exampleFlag && !envFlag) {
31
+ const exampleNameFromFlag = path.basename(exampleFlag);
32
+ primaryExample = exampleNameFromFlag;
33
+ if (exampleNameFromFlag.startsWith('.env.example')) {
34
+ const suffix = exampleNameFromFlag.slice('.env.example'.length);
35
+ const matchedEnv = suffix ? `.env${suffix}` : '.env';
36
+ if (fs.existsSync(path.resolve(cwd, matchedEnv))) {
37
+ primaryEnv = matchedEnv;
38
+ envFiles.length = 0;
39
+ envFiles.push(matchedEnv);
40
+ }
41
+ else {
42
+ alreadyWarnedMissingEnv = true;
43
+ }
44
+ }
45
+ else {
46
+ // If the example file is not a standard .env.example, we just use it as is
47
+ if (envFiles.length === 0)
48
+ envFiles.push(primaryEnv);
49
+ }
50
+ }
51
+ return {
52
+ cwd,
53
+ envFiles,
54
+ primaryEnv,
55
+ primaryExample,
56
+ envFlag,
57
+ exampleFlag,
58
+ alreadyWarnedMissingEnv,
59
+ };
60
+ }
@@ -0,0 +1,6 @@
1
+ import type { Discovery } from './envDiscovery.js';
2
+ export declare function pairWithExample(d: Discovery): Array<{
3
+ envName: string;
4
+ envPath: string;
5
+ examplePath: string;
6
+ }>;
@@ -0,0 +1,27 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ export function pairWithExample(d) {
4
+ const pairs = [];
5
+ const list = d.envFiles.length > 0 ? d.envFiles : [d.primaryEnv];
6
+ for (const envName of list) {
7
+ if (d.exampleFlag && !d.envFlag) {
8
+ const envAbs = path.resolve(d.cwd, envName);
9
+ if (envAbs === path.resolve(d.cwd, d.primaryExample))
10
+ continue;
11
+ }
12
+ const suffix = envName === '.env' ? '' : envName.replace('.env', '');
13
+ const exampleName = suffix ? `.env.example${suffix}` : d.primaryExample;
14
+ const envPathCurrent = path.resolve(d.cwd, envName);
15
+ const examplePathCurrent = d.exampleFlag && !d.envFlag
16
+ ? path.resolve(d.cwd, d.primaryExample)
17
+ : fs.existsSync(path.resolve(d.cwd, exampleName))
18
+ ? path.resolve(d.cwd, exampleName)
19
+ : path.resolve(d.cwd, d.primaryExample);
20
+ pairs.push({
21
+ envName,
22
+ envPath: envPathCurrent,
23
+ examplePath: examplePathCurrent,
24
+ });
25
+ }
26
+ return pairs;
27
+ }
@@ -0,0 +1,23 @@
1
+ export type GitignoreCheckOptions = {
2
+ /** Project root directory (default: process.cwd()) */
3
+ cwd?: string;
4
+ /** Name of the env file (default: ".env") */
5
+ envFile?: string;
6
+ /** Custom logger (default: console.log) */
7
+ log?: (msg: string) => void;
8
+ };
9
+ /** Are we in a git repo? (checks for .git directory in cwd) */
10
+ export declare function isGitRepo(cwd?: string): boolean;
11
+ /**
12
+ * Returns:
13
+ * - true → .env-matching patterns are found in .gitignore
14
+ * - false → .env-matching patterns are NOT found (or a negation exists)
15
+ * - null → no .gitignore exists
16
+ */
17
+ export declare function isEnvIgnoredByGit(options?: GitignoreCheckOptions): boolean | null;
18
+ /**
19
+ * Logs a friendly warning if .env is not ignored by Git.
20
+ * - Does not hard fail: non-blocking DX.
21
+ * - Skips if not in a git repo or if .env does not exist.
22
+ */
23
+ export declare function warnIfEnvNotIgnored(options?: GitignoreCheckOptions): void;
@@ -0,0 +1,73 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import chalk from 'chalk';
4
+ /** Are we in a git repo? (checks for .git directory in cwd) */
5
+ export function isGitRepo(cwd = process.cwd()) {
6
+ return fs.existsSync(path.resolve(cwd, '.git'));
7
+ }
8
+ /**
9
+ * Returns:
10
+ * - true → .env-matching patterns are found in .gitignore
11
+ * - false → .env-matching patterns are NOT found (or a negation exists)
12
+ * - null → no .gitignore exists
13
+ */
14
+ export function isEnvIgnoredByGit(options = {}) {
15
+ const { cwd = process.cwd(), envFile = '.env' } = options;
16
+ const gitignorePath = path.resolve(cwd, '.gitignore');
17
+ if (!fs.existsSync(gitignorePath))
18
+ return null;
19
+ const raw = fs.readFileSync(gitignorePath, 'utf8');
20
+ const lines = raw
21
+ .split(/\r?\n/)
22
+ .map((l) => l.trim())
23
+ .filter((l) => l && !l.startsWith('#'));
24
+ // If there is a negation (!pattern) that matches our candidates, we consider it as "not ignored"
25
+ if (lines.some((l) => l.startsWith('!') && matchesCandidate(l.slice(1), envFile))) {
26
+ return false;
27
+ }
28
+ const candidates = getCandidatePatterns(envFile);
29
+ return lines.some((line) => candidates.has(line));
30
+ }
31
+ /** Our simple candidate patterns that typically cover env files in the root and subfolders */
32
+ function getCandidatePatterns(envFile = '.env') {
33
+ const base = envFile; // ".env"
34
+ const star = `${base}*`; // ".env*"
35
+ const dotStar = `${base}.*`; // ".env.*"
36
+ return new Set([
37
+ base,
38
+ `/${base}`,
39
+ `**/${base}`,
40
+ star,
41
+ `/${star}`,
42
+ `**/${star}`,
43
+ dotStar,
44
+ `/${dotStar}`,
45
+ `**/${dotStar}`,
46
+ ]);
47
+ }
48
+ // Matches only against our own candidate patterns (intentionally simple)
49
+ function matchesCandidate(pattern, envFile) {
50
+ return getCandidatePatterns(envFile).has(pattern);
51
+ }
52
+ /**
53
+ * Logs a friendly warning if .env is not ignored by Git.
54
+ * - Does not hard fail: non-blocking DX.
55
+ * - Skips if not in a git repo or if .env does not exist.
56
+ */
57
+ export function warnIfEnvNotIgnored(options = {}) {
58
+ const { cwd = process.cwd(), envFile = '.env', log = console.log } = options;
59
+ const envPath = path.resolve(cwd, envFile);
60
+ if (!fs.existsSync(envPath))
61
+ return; // No .env file → nothing to warn about
62
+ if (!isGitRepo(cwd))
63
+ return; // Not a git repo → skip
64
+ const gitignorePath = path.resolve(cwd, '.gitignore');
65
+ if (!fs.existsSync(gitignorePath)) {
66
+ log(chalk.yellow(`⚠️ No .gitignore found – your ${envFile} may be committed.\n Add:\n ${envFile}\n ${envFile}.*\n`));
67
+ return;
68
+ }
69
+ const ignored = isEnvIgnoredByGit({ cwd, envFile });
70
+ if (ignored === false || ignored === null) {
71
+ log(chalk.yellow(`⚠️ ${envFile} is not ignored by Git (.gitignore).\n Consider adding:\n ${envFile}\n ${envFile}.*\n`));
72
+ }
73
+ }
@@ -0,0 +1,4 @@
1
+ export declare function confirmYesNo(message: string, { isCiMode, isYesMode }: {
2
+ isCiMode: boolean;
3
+ isYesMode: boolean;
4
+ }): Promise<boolean>;
@@ -0,0 +1,18 @@
1
+ import prompts from 'prompts';
2
+ export async function confirmYesNo(message, { isCiMode, isYesMode }) {
3
+ if (isYesMode)
4
+ return true;
5
+ if (isCiMode)
6
+ return false;
7
+ const res = await prompts({
8
+ type: 'select',
9
+ name: 'ok',
10
+ message,
11
+ choices: [
12
+ { title: 'Yes', value: true },
13
+ { title: 'No', value: false },
14
+ ],
15
+ initial: 0,
16
+ });
17
+ return Boolean(res.ok);
18
+ }
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "dotenv-diff",
3
- "version": "1.5.0",
3
+ "version": "1.6.2",
4
4
  "type": "module",
5
5
  "description": "A small CLI and library to find differences between .env and .env.example files.",
6
6
  "bin": {
7
- "dotenv-diff": "dist/cli.js"
7
+ "dotenv-diff": "dist/bin/dotenv-diff.js"
8
8
  },
9
9
  "main": "dist/index.js",
10
10
  "types": "dist/index.d.ts",
@@ -19,7 +19,7 @@
19
19
  "test": "vitest",
20
20
  "lint": "eslint ./src --ext .ts",
21
21
  "format": "prettier --write \"src/**/*.ts\" \"src/*.ts\"",
22
- "start": "node dist/cli.js"
22
+ "start": "node dist/bin/dotenv-diff.js"
23
23
  },
24
24
  "author": "Chrilleweb",
25
25
  "license": "MIT",