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 +25 -1
- package/dist/bin/dotenv-diff.d.ts +2 -0
- package/dist/bin/dotenv-diff.js +8 -0
- package/dist/cli.js +120 -12
- package/dist/src/cli/program.d.ts +2 -0
- package/dist/src/cli/program.js +12 -0
- package/dist/src/cli/run.d.ts +2 -0
- package/dist/src/cli/run.js +62 -0
- package/dist/src/commands/compare.d.ts +11 -0
- package/dist/src/commands/compare.js +72 -0
- package/dist/src/commands/init.d.ts +12 -0
- package/dist/src/commands/init.js +66 -0
- package/dist/src/config/options.d.ts +19 -0
- package/dist/src/config/options.js +23 -0
- package/dist/src/core/duplicates.d.ts +11 -0
- package/dist/src/core/duplicates.js +33 -0
- package/dist/src/index.d.ts +2 -0
- package/dist/src/index.js +2 -0
- package/dist/src/lib/diffEnv.d.ts +18 -0
- package/dist/src/lib/diffEnv.js +29 -0
- package/dist/src/lib/parseEnv.d.ts +10 -0
- package/dist/src/lib/parseEnv.js +25 -0
- package/dist/src/services/duplicates.d.ts +11 -0
- package/dist/src/services/duplicates.js +33 -0
- package/dist/src/services/envDiscovery.d.ts +14 -0
- package/dist/src/services/envDiscovery.js +60 -0
- package/dist/src/services/envPairing.d.ts +6 -0
- package/dist/src/services/envPairing.js +27 -0
- package/dist/src/services/git.d.ts +23 -0
- package/dist/src/services/git.js +73 -0
- package/dist/src/ui/prompts.d.ts +4 -0
- package/dist/src/ui/prompts.js +18 -0
- package/package.json +3 -3
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:
|
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
|
-
|
|
40
|
-
|
|
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
|
-
|
|
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:
|
|
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(
|
|
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(
|
|
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:
|
|
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(
|
|
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 =
|
|
137
|
-
?
|
|
138
|
-
:
|
|
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,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,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,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,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,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,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.
|
|
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/
|
|
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/
|
|
22
|
+
"start": "node dist/bin/dotenv-diff.js"
|
|
23
23
|
},
|
|
24
24
|
"author": "Chrilleweb",
|
|
25
25
|
"license": "MIT",
|