@scuton/dotenv-guard 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/ISSUE_TEMPLATE/bug_report.md +22 -0
- package/.github/ISSUE_TEMPLATE/feature_request.md +15 -0
- package/.github/workflows/ci.yml +31 -0
- package/CONTRIBUTING.md +40 -0
- package/LICENSE +21 -0
- package/README.md +85 -0
- package/dist/auto.d.mts +2 -0
- package/dist/auto.d.ts +2 -0
- package/dist/auto.js +81 -0
- package/dist/auto.mjs +30 -0
- package/dist/chunk-QXZQR35R.mjs +222 -0
- package/dist/chunk-RLNPDDSP.mjs +19 -0
- package/dist/chunk-YYFNUR7Y.mjs +54 -0
- package/dist/cli.d.mts +1 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +466 -0
- package/dist/cli.mjs +211 -0
- package/dist/index.d.mts +64 -0
- package/dist/index.d.ts +64 -0
- package/dist/index.js +331 -0
- package/dist/index.mjs +50 -0
- package/package.json +49 -0
- package/src/auto.ts +28 -0
- package/src/cli.ts +225 -0
- package/src/core/generator.ts +49 -0
- package/src/core/leak.ts +115 -0
- package/src/core/parser.ts +50 -0
- package/src/core/sync.ts +34 -0
- package/src/core/validator.ts +126 -0
- package/src/index.ts +42 -0
- package/src/utils/colors.ts +13 -0
- package/src/utils/helpers.ts +20 -0
- package/tests/generator.test.ts +104 -0
- package/tests/leak.test.ts +52 -0
- package/tests/parser.test.ts +82 -0
- package/tests/sync.test.ts +78 -0
- package/tests/validator.test.ts +127 -0
- package/tsconfig.json +21 -0
package/src/cli.ts
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { readFileSync, existsSync } from 'fs';
|
|
4
|
+
import { syncCheck } from './core/sync.js';
|
|
5
|
+
import { validate, inferRules } from './core/validator.js';
|
|
6
|
+
import { checkLeaks, installPreCommitHook } from './core/leak.js';
|
|
7
|
+
import { generateExample, ensureGitignore } from './core/generator.js';
|
|
8
|
+
import { parseEnvFileToMap } from './core/parser.js';
|
|
9
|
+
import { red, green, yellow, gray, bold, dim } from './utils/colors.js';
|
|
10
|
+
|
|
11
|
+
const args = process.argv.slice(2);
|
|
12
|
+
const command = args[0];
|
|
13
|
+
|
|
14
|
+
function showHelp() {
|
|
15
|
+
console.log(`
|
|
16
|
+
${bold('dotenv-guard')} — Validate env vars, sync .env files, prevent leaks
|
|
17
|
+
|
|
18
|
+
${bold('Usage:')}
|
|
19
|
+
dotenv-guard <command> [options]
|
|
20
|
+
|
|
21
|
+
${bold('Commands:')}
|
|
22
|
+
sync Compare .env with .env.example
|
|
23
|
+
validate Validate env var types (from .env.example hints)
|
|
24
|
+
leak-check Scan for leaked secrets in git
|
|
25
|
+
init Create .env.example + update .gitignore
|
|
26
|
+
install-hook Install git pre-commit hook to block .env commits
|
|
27
|
+
|
|
28
|
+
${bold('Options:')}
|
|
29
|
+
--env <path> Path to .env file (default: .env)
|
|
30
|
+
--example <path> Path to .env.example (default: .env.example)
|
|
31
|
+
-h, --help Show help
|
|
32
|
+
-v, --version Show version
|
|
33
|
+
|
|
34
|
+
${bold('Programmatic:')}
|
|
35
|
+
import 'dotenv-guard/auto';
|
|
36
|
+
// Auto-check on import
|
|
37
|
+
import { guard } from 'dotenv-guard'; // Manual check
|
|
38
|
+
`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Arg parse
|
|
42
|
+
const envPath = args.includes('--env') ? args[args.indexOf('--env') + 1] : '.env';
|
|
43
|
+
const examplePath = args.includes('--example') ? args[args.indexOf('--example') + 1] : '.env.example';
|
|
44
|
+
|
|
45
|
+
switch (command) {
|
|
46
|
+
case 'sync': {
|
|
47
|
+
try {
|
|
48
|
+
const result = syncCheck(envPath, examplePath);
|
|
49
|
+
console.log('');
|
|
50
|
+
console.log(bold(' dotenv-guard sync'));
|
|
51
|
+
console.log(gray(` ${result.envPath} ↔ ${result.examplePath}`));
|
|
52
|
+
console.log('');
|
|
53
|
+
|
|
54
|
+
if (result.missing.length > 0) {
|
|
55
|
+
console.log(red(` ✖ Missing (${result.missing.length}):`));
|
|
56
|
+
result.missing.forEach(k => console.log(yellow(` → ${k}`)));
|
|
57
|
+
console.log('');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (result.extra.length > 0) {
|
|
61
|
+
console.log(yellow(` ⚠ Extra (${result.extra.length}):`));
|
|
62
|
+
result.extra.forEach(k => console.log(gray(` → ${k}`)));
|
|
63
|
+
console.log('');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
console.log(green(` ✓ Synced: ${result.synced.length} variables`));
|
|
67
|
+
|
|
68
|
+
if (result.missing.length > 0) {
|
|
69
|
+
console.log('');
|
|
70
|
+
console.log(dim(` Add missing variables to ${result.envPath} to fix.`));
|
|
71
|
+
}
|
|
72
|
+
console.log('');
|
|
73
|
+
|
|
74
|
+
process.exit(result.missing.length > 0 ? 1 : 0);
|
|
75
|
+
} catch (err: any) {
|
|
76
|
+
console.error(red(`\n ✖ ${err.message}\n`));
|
|
77
|
+
process.exit(1);
|
|
78
|
+
}
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
case 'validate': {
|
|
83
|
+
try {
|
|
84
|
+
if (!existsSync(examplePath)) {
|
|
85
|
+
console.error(red(`\n ✖ ${examplePath} not found. Run "dotenv-guard init" first.\n`));
|
|
86
|
+
process.exit(1);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const exampleContent = readFileSync(examplePath, 'utf-8');
|
|
90
|
+
const rules = inferRules(exampleContent);
|
|
91
|
+
|
|
92
|
+
// Mevcut env değerlerini al
|
|
93
|
+
const envMap = existsSync(envPath) ? parseEnvFileToMap(readFileSync(envPath, 'utf-8')) : new Map();
|
|
94
|
+
const env: Record<string, string | undefined> = {};
|
|
95
|
+
|
|
96
|
+
// process.env + .env dosyası birleştir
|
|
97
|
+
for (const rule of rules) {
|
|
98
|
+
env[rule.key] = process.env[rule.key] || envMap.get(rule.key);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const errors = validate(env, rules);
|
|
102
|
+
|
|
103
|
+
console.log('');
|
|
104
|
+
console.log(bold(' dotenv-guard validate'));
|
|
105
|
+
console.log('');
|
|
106
|
+
|
|
107
|
+
if (errors.length === 0) {
|
|
108
|
+
console.log(green(` ✓ All ${rules.length} variables are valid`));
|
|
109
|
+
} else {
|
|
110
|
+
console.log(red(` ✖ ${errors.length} validation error(s):`));
|
|
111
|
+
console.log('');
|
|
112
|
+
errors.forEach(e => {
|
|
113
|
+
console.log(` ${red('✖')} ${bold(e.key)} ${e.message}`);
|
|
114
|
+
if (e.value) console.log(gray(` current value: "${e.value}"`));
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
console.log('');
|
|
118
|
+
|
|
119
|
+
process.exit(errors.length > 0 ? 1 : 0);
|
|
120
|
+
} catch (err: any) {
|
|
121
|
+
console.error(red(`\n ✖ ${err.message}\n`));
|
|
122
|
+
process.exit(1);
|
|
123
|
+
}
|
|
124
|
+
break;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
case 'leak-check': {
|
|
128
|
+
const result = checkLeaks('.');
|
|
129
|
+
console.log('');
|
|
130
|
+
console.log(bold(' dotenv-guard leak-check'));
|
|
131
|
+
console.log('');
|
|
132
|
+
|
|
133
|
+
// .gitignore check
|
|
134
|
+
if (result.gitignoreHasEnv) {
|
|
135
|
+
console.log(green(' ✓ .gitignore includes .env'));
|
|
136
|
+
} else {
|
|
137
|
+
console.log(red(' ✖ .gitignore does NOT include .env'));
|
|
138
|
+
console.log(gray(' Run: dotenv-guard init'));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Pre-commit hook
|
|
142
|
+
if (result.preCommitHookInstalled) {
|
|
143
|
+
console.log(green(' ✓ Pre-commit hook installed'));
|
|
144
|
+
} else {
|
|
145
|
+
console.log(yellow(' ⚠ No pre-commit hook'));
|
|
146
|
+
console.log(gray(' Run: dotenv-guard install-hook'));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Leaks
|
|
150
|
+
if (result.leaks.length === 0) {
|
|
151
|
+
console.log(green(' ✓ No .env files tracked in git'));
|
|
152
|
+
} else {
|
|
153
|
+
console.log(red(` ✖ ${result.leaks.length} leaked variable(s) in git:`));
|
|
154
|
+
console.log('');
|
|
155
|
+
result.leaks.forEach(l => {
|
|
156
|
+
const icon = l.severity === 'high' ? red('HIGH') : yellow('MED');
|
|
157
|
+
console.log(` [${icon}] ${l.file}:${l.line} — ${bold(l.key)}`);
|
|
158
|
+
});
|
|
159
|
+
console.log('');
|
|
160
|
+
console.log(dim(' Remove tracked files: git rm --cached .env'));
|
|
161
|
+
}
|
|
162
|
+
console.log('');
|
|
163
|
+
|
|
164
|
+
process.exit(result.leaks.length > 0 ? 1 : 0);
|
|
165
|
+
break;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
case 'init': {
|
|
169
|
+
console.log('');
|
|
170
|
+
console.log(bold(' dotenv-guard init'));
|
|
171
|
+
console.log('');
|
|
172
|
+
|
|
173
|
+
// .env.example oluştur
|
|
174
|
+
if (existsSync('.env') && !existsSync('.env.example')) {
|
|
175
|
+
generateExample('.env', '.env.example');
|
|
176
|
+
console.log(green(' ✓ Created .env.example from .env'));
|
|
177
|
+
} else if (existsSync('.env.example')) {
|
|
178
|
+
console.log(gray(' • .env.example already exists'));
|
|
179
|
+
} else {
|
|
180
|
+
console.log(yellow(' ⚠ No .env file found — create one first'));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// .gitignore güncelle
|
|
184
|
+
const updated = ensureGitignore('.');
|
|
185
|
+
if (updated) {
|
|
186
|
+
console.log(green(' ✓ Updated .gitignore with .env entries'));
|
|
187
|
+
} else {
|
|
188
|
+
console.log(gray(' • .gitignore already includes .env'));
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
console.log('');
|
|
192
|
+
break;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
case 'install-hook': {
|
|
196
|
+
try {
|
|
197
|
+
installPreCommitHook('.');
|
|
198
|
+
console.log('');
|
|
199
|
+
console.log(green(bold(' ✓ Pre-commit hook installed')));
|
|
200
|
+
console.log(gray(' .env files will be blocked from commits'));
|
|
201
|
+
console.log(gray(' Bypass with: git commit --no-verify'));
|
|
202
|
+
console.log('');
|
|
203
|
+
} catch (err: any) {
|
|
204
|
+
console.error(red(`\n ✖ ${err.message}\n`));
|
|
205
|
+
process.exit(1);
|
|
206
|
+
}
|
|
207
|
+
break;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
case '-v':
|
|
211
|
+
case '--version':
|
|
212
|
+
console.log('1.0.0');
|
|
213
|
+
break;
|
|
214
|
+
|
|
215
|
+
case '-h':
|
|
216
|
+
case '--help':
|
|
217
|
+
case undefined:
|
|
218
|
+
showHelp();
|
|
219
|
+
break;
|
|
220
|
+
|
|
221
|
+
default:
|
|
222
|
+
console.error(red(`\n Unknown command: ${command}`));
|
|
223
|
+
showHelp();
|
|
224
|
+
process.exit(1);
|
|
225
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync, appendFileSync } from 'fs';
|
|
2
|
+
import { parseEnvFile } from './parser.js';
|
|
3
|
+
|
|
4
|
+
// .env'den .env.example oluştur (değerleri maskele)
|
|
5
|
+
export function generateExample(envPath: string = '.env', outputPath: string = '.env.example'): string {
|
|
6
|
+
if (!existsSync(envPath)) throw new Error(`${envPath} not found`);
|
|
7
|
+
|
|
8
|
+
const content = readFileSync(envPath, 'utf-8');
|
|
9
|
+
const entries = parseEnvFile(content);
|
|
10
|
+
|
|
11
|
+
const lines: string[] = ['# Environment Variables', '# Copy this file to .env and fill in the values', ''];
|
|
12
|
+
|
|
13
|
+
for (const entry of entries) {
|
|
14
|
+
// Değeri maskele ama tip hint'i bırak
|
|
15
|
+
let placeholder = '';
|
|
16
|
+
if (entry.value.match(/^https?:\/\//)) placeholder = 'https://... # type:url';
|
|
17
|
+
else if (entry.value.match(/^\d+$/)) placeholder = '0 # type:number';
|
|
18
|
+
else if (entry.value.match(/^(true|false)$/i)) placeholder = 'false # type:boolean';
|
|
19
|
+
else placeholder = '# type:string';
|
|
20
|
+
|
|
21
|
+
lines.push(`${entry.key}=${placeholder}`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const output = lines.join('\n') + '\n';
|
|
25
|
+
writeFileSync(outputPath, output, 'utf-8');
|
|
26
|
+
return output;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// .gitignore'a .env ekle
|
|
30
|
+
export function ensureGitignore(dir: string = '.'): boolean {
|
|
31
|
+
const gitignorePath = `${dir}/.gitignore`;
|
|
32
|
+
const envEntries = ['.env', '.env.local', '.env.*.local'];
|
|
33
|
+
|
|
34
|
+
let content = '';
|
|
35
|
+
if (existsSync(gitignorePath)) {
|
|
36
|
+
content = readFileSync(gitignorePath, 'utf-8');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const lines = content.split('\n');
|
|
40
|
+
const toAdd = envEntries.filter(e => !lines.some(l => l.trim() === e));
|
|
41
|
+
|
|
42
|
+
if (toAdd.length > 0) {
|
|
43
|
+
const addition = '\n# Environment variables\n' + toAdd.join('\n') + '\n';
|
|
44
|
+
appendFileSync(gitignorePath, addition, 'utf-8');
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return false;
|
|
49
|
+
}
|
package/src/core/leak.ts
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { execSync } from 'child_process';
|
|
2
|
+
import { readFileSync, existsSync, mkdirSync, writeFileSync, chmodSync } from 'fs';
|
|
3
|
+
|
|
4
|
+
export interface LeakResult {
|
|
5
|
+
leaks: LeakEntry[];
|
|
6
|
+
gitignoreHasEnv: boolean;
|
|
7
|
+
preCommitHookInstalled: boolean;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface LeakEntry {
|
|
11
|
+
file: string;
|
|
12
|
+
line: number;
|
|
13
|
+
key: string;
|
|
14
|
+
severity: 'high' | 'medium' | 'low';
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Hassas key pattern'leri
|
|
18
|
+
const SENSITIVE_PATTERNS = [
|
|
19
|
+
/API[_-]?KEY/i,
|
|
20
|
+
/SECRET/i,
|
|
21
|
+
/PASSWORD/i,
|
|
22
|
+
/TOKEN/i,
|
|
23
|
+
/PRIVATE[_-]?KEY/i,
|
|
24
|
+
/DATABASE[_-]?URL/i,
|
|
25
|
+
/MONGO[_-]?URI/i,
|
|
26
|
+
/REDIS[_-]?URL/i,
|
|
27
|
+
/AWS[_-]?ACCESS/i,
|
|
28
|
+
/STRIPE/i,
|
|
29
|
+
/SENDGRID/i,
|
|
30
|
+
/TWILIO/i,
|
|
31
|
+
/ANTHROPIC/i,
|
|
32
|
+
/OPENAI/i,
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
export function checkLeaks(dir: string = '.'): LeakResult {
|
|
36
|
+
const leaks: LeakEntry[] = [];
|
|
37
|
+
|
|
38
|
+
// .gitignore kontrolü
|
|
39
|
+
let gitignoreHasEnv = false;
|
|
40
|
+
const gitignorePath = `${dir}/.gitignore`;
|
|
41
|
+
if (existsSync(gitignorePath)) {
|
|
42
|
+
const content = readFileSync(gitignorePath, 'utf-8');
|
|
43
|
+
gitignoreHasEnv = content.split('\n').some(line =>
|
|
44
|
+
line.trim() === '.env' || line.trim() === '.env*' || line.trim() === '.env.local'
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Git tracked files'da .env var mı?
|
|
49
|
+
try {
|
|
50
|
+
const tracked = execSync('git ls-files', { cwd: dir, encoding: 'utf-8' });
|
|
51
|
+
const envFiles = tracked.split('\n').filter(f =>
|
|
52
|
+
f.match(/^\.env($|\.)/) && !f.endsWith('.example') && !f.endsWith('.template')
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
for (const file of envFiles) {
|
|
56
|
+
const content = readFileSync(`${dir}/${file}`, 'utf-8');
|
|
57
|
+
const lines = content.split('\n');
|
|
58
|
+
for (let i = 0; i < lines.length; i++) {
|
|
59
|
+
const match = lines[i].match(/^([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.+)/);
|
|
60
|
+
if (match && match[2].trim().length > 0) {
|
|
61
|
+
const key = match[1];
|
|
62
|
+
const severity = SENSITIVE_PATTERNS.some(p => p.test(key)) ? 'high' : 'medium';
|
|
63
|
+
leaks.push({ file, line: i + 1, key, severity });
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
} catch {
|
|
68
|
+
// git yoksa veya git repo değilse skip
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Pre-commit hook var mı?
|
|
72
|
+
let preCommitHookInstalled = false;
|
|
73
|
+
const hookPath = `${dir}/.git/hooks/pre-commit`;
|
|
74
|
+
if (existsSync(hookPath)) {
|
|
75
|
+
const hookContent = readFileSync(hookPath, 'utf-8');
|
|
76
|
+
preCommitHookInstalled = hookContent.includes('dotenv-guard');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return { leaks, gitignoreHasEnv, preCommitHookInstalled };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Pre-commit hook oluştur
|
|
83
|
+
export function installPreCommitHook(dir: string = '.'): void {
|
|
84
|
+
const hookDir = `${dir}/.git/hooks`;
|
|
85
|
+
const hookPath = `${hookDir}/pre-commit`;
|
|
86
|
+
|
|
87
|
+
const hookScript = `#!/bin/sh
|
|
88
|
+
# dotenv-guard pre-commit hook — prevent .env leaks
|
|
89
|
+
# Installed by: npx dotenv-guard install-hook
|
|
90
|
+
|
|
91
|
+
ENV_FILES=$(git diff --cached --name-only | grep -E '^\\.env' | grep -v '\\.example$' | grep -v '\\.template$')
|
|
92
|
+
|
|
93
|
+
if [ -n "$ENV_FILES" ]; then
|
|
94
|
+
echo ""
|
|
95
|
+
echo "\\033[31m✖ dotenv-guard: Blocked commit — .env file(s) detected:\\033[0m"
|
|
96
|
+
echo ""
|
|
97
|
+
for f in $ENV_FILES; do
|
|
98
|
+
echo " \\033[33m→ $f\\033[0m"
|
|
99
|
+
done
|
|
100
|
+
echo ""
|
|
101
|
+
echo " Remove with: git reset HEAD <file>"
|
|
102
|
+
echo " Or force commit: git commit --no-verify"
|
|
103
|
+
echo ""
|
|
104
|
+
exit 1
|
|
105
|
+
fi
|
|
106
|
+
`;
|
|
107
|
+
|
|
108
|
+
mkdirSync(hookDir, { recursive: true });
|
|
109
|
+
writeFileSync(hookPath, hookScript, 'utf-8');
|
|
110
|
+
try {
|
|
111
|
+
chmodSync(hookPath, '755');
|
|
112
|
+
} catch {
|
|
113
|
+
// Windows'ta chmod çalışmayabilir
|
|
114
|
+
}
|
|
115
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
export interface EnvEntry {
|
|
2
|
+
key: string;
|
|
3
|
+
value: string;
|
|
4
|
+
line: number;
|
|
5
|
+
comment?: string;
|
|
6
|
+
raw: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function parseEnvFile(content: string): EnvEntry[] {
|
|
10
|
+
const entries: EnvEntry[] = [];
|
|
11
|
+
const lines = content.split('\n');
|
|
12
|
+
|
|
13
|
+
for (let i = 0; i < lines.length; i++) {
|
|
14
|
+
const raw = lines[i];
|
|
15
|
+
const trimmed = raw.trim();
|
|
16
|
+
|
|
17
|
+
// Boş satır veya comment
|
|
18
|
+
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
19
|
+
|
|
20
|
+
// KEY=VALUE parse
|
|
21
|
+
const match = trimmed.match(/^([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)/);
|
|
22
|
+
if (!match) continue;
|
|
23
|
+
|
|
24
|
+
const key = match[1];
|
|
25
|
+
let value = match[2];
|
|
26
|
+
|
|
27
|
+
// Tırnak temizle
|
|
28
|
+
if ((value.startsWith('"') && value.endsWith('"')) ||
|
|
29
|
+
(value.startsWith("'") && value.endsWith("'"))) {
|
|
30
|
+
value = value.slice(1, -1);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Inline comment temizle (tırnaksız değerlerde)
|
|
34
|
+
const commentMatch = value.match(/\s+#\s/);
|
|
35
|
+
let comment: string | undefined;
|
|
36
|
+
if (commentMatch && !match[2].startsWith('"') && !match[2].startsWith("'")) {
|
|
37
|
+
comment = value.slice(commentMatch.index! + commentMatch[0].length);
|
|
38
|
+
value = value.slice(0, commentMatch.index);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
entries.push({ key, value, line: i + 1, comment, raw });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return entries;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function parseEnvFileToMap(content: string): Map<string, string> {
|
|
48
|
+
const entries = parseEnvFile(content);
|
|
49
|
+
return new Map(entries.map(e => [e.key, e.value]));
|
|
50
|
+
}
|
package/src/core/sync.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from 'fs';
|
|
2
|
+
import { parseEnvFile } from './parser.js';
|
|
3
|
+
|
|
4
|
+
export interface SyncResult {
|
|
5
|
+
missing: string[]; // .env.example'da var ama .env'de yok
|
|
6
|
+
extra: string[]; // .env'de var ama .env.example'da yok
|
|
7
|
+
synced: string[]; // İkisinde de var
|
|
8
|
+
examplePath: string;
|
|
9
|
+
envPath: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function syncCheck(
|
|
13
|
+
envPath: string = '.env',
|
|
14
|
+
examplePath: string = '.env.example'
|
|
15
|
+
): SyncResult {
|
|
16
|
+
if (!existsSync(examplePath)) {
|
|
17
|
+
throw new Error(`${examplePath} not found. Run "dotenv-guard init" to create one.`);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const exampleContent = readFileSync(examplePath, 'utf-8');
|
|
21
|
+
const exampleKeys = new Set(parseEnvFile(exampleContent).map(e => e.key));
|
|
22
|
+
|
|
23
|
+
let envKeys = new Set<string>();
|
|
24
|
+
if (existsSync(envPath)) {
|
|
25
|
+
const envContent = readFileSync(envPath, 'utf-8');
|
|
26
|
+
envKeys = new Set(parseEnvFile(envContent).map(e => e.key));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const missing = [...exampleKeys].filter(k => !envKeys.has(k));
|
|
30
|
+
const extra = [...envKeys].filter(k => !exampleKeys.has(k));
|
|
31
|
+
const synced = [...exampleKeys].filter(k => envKeys.has(k));
|
|
32
|
+
|
|
33
|
+
return { missing, extra, synced, examplePath, envPath };
|
|
34
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
export type EnvType = 'string' | 'number' | 'boolean' | 'url' | 'email' | 'port' | 'enum';
|
|
2
|
+
|
|
3
|
+
export interface ValidationRule {
|
|
4
|
+
key: string;
|
|
5
|
+
type?: EnvType;
|
|
6
|
+
required?: boolean;
|
|
7
|
+
enum?: string[];
|
|
8
|
+
pattern?: RegExp;
|
|
9
|
+
min?: number;
|
|
10
|
+
max?: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface ValidationError {
|
|
14
|
+
key: string;
|
|
15
|
+
message: string;
|
|
16
|
+
value?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function validate(
|
|
20
|
+
env: Record<string, string | undefined>,
|
|
21
|
+
rules: ValidationRule[]
|
|
22
|
+
): ValidationError[] {
|
|
23
|
+
const errors: ValidationError[] = [];
|
|
24
|
+
|
|
25
|
+
for (const rule of rules) {
|
|
26
|
+
const value = env[rule.key];
|
|
27
|
+
|
|
28
|
+
// Required check
|
|
29
|
+
if (rule.required !== false && (value === undefined || value === '')) {
|
|
30
|
+
errors.push({ key: rule.key, message: 'is required but missing or empty' });
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (value === undefined || value === '') continue;
|
|
35
|
+
|
|
36
|
+
// Type checks
|
|
37
|
+
switch (rule.type) {
|
|
38
|
+
case 'number': {
|
|
39
|
+
const num = Number(value);
|
|
40
|
+
if (isNaN(num)) {
|
|
41
|
+
errors.push({ key: rule.key, value, message: `must be a number, got "${value}"` });
|
|
42
|
+
} else {
|
|
43
|
+
if (rule.min !== undefined && num < rule.min)
|
|
44
|
+
errors.push({ key: rule.key, value, message: `must be >= ${rule.min}` });
|
|
45
|
+
if (rule.max !== undefined && num > rule.max)
|
|
46
|
+
errors.push({ key: rule.key, value, message: `must be <= ${rule.max}` });
|
|
47
|
+
}
|
|
48
|
+
break;
|
|
49
|
+
}
|
|
50
|
+
case 'boolean':
|
|
51
|
+
if (!['true', 'false', '1', '0', 'yes', 'no'].includes(value.toLowerCase()))
|
|
52
|
+
errors.push({ key: rule.key, value, message: 'must be a boolean (true/false/1/0/yes/no)' });
|
|
53
|
+
break;
|
|
54
|
+
case 'url':
|
|
55
|
+
try { new URL(value); }
|
|
56
|
+
catch { errors.push({ key: rule.key, value, message: 'must be a valid URL' }); }
|
|
57
|
+
break;
|
|
58
|
+
case 'email':
|
|
59
|
+
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value))
|
|
60
|
+
errors.push({ key: rule.key, value, message: 'must be a valid email' });
|
|
61
|
+
break;
|
|
62
|
+
case 'port': {
|
|
63
|
+
const port = Number(value);
|
|
64
|
+
if (isNaN(port) || port < 1 || port > 65535)
|
|
65
|
+
errors.push({ key: rule.key, value, message: 'must be a valid port (1-65535)' });
|
|
66
|
+
break;
|
|
67
|
+
}
|
|
68
|
+
case 'enum':
|
|
69
|
+
if (rule.enum && !rule.enum.includes(value))
|
|
70
|
+
errors.push({ key: rule.key, value, message: `must be one of: ${rule.enum.join(', ')}` });
|
|
71
|
+
break;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Custom pattern
|
|
75
|
+
if (rule.pattern && !rule.pattern.test(value))
|
|
76
|
+
errors.push({ key: rule.key, value, message: `does not match required pattern` });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return errors;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// .env.example'dan otomatik rule çıkar (comment'lerdeki type hint'lerden)
|
|
83
|
+
export function inferRules(exampleContent: string): ValidationRule[] {
|
|
84
|
+
const lines = exampleContent.split('\n');
|
|
85
|
+
const rules: ValidationRule[] = [];
|
|
86
|
+
|
|
87
|
+
for (const line of lines) {
|
|
88
|
+
const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)/);
|
|
89
|
+
if (!match) continue;
|
|
90
|
+
|
|
91
|
+
const key = match[1];
|
|
92
|
+
const rest = match[2];
|
|
93
|
+
const rule: ValidationRule = { key, required: true };
|
|
94
|
+
|
|
95
|
+
// Comment'ten type hint çıkar
|
|
96
|
+
const commentMatch = rest.match(/#\s*type:\s*(\w+)/i);
|
|
97
|
+
if (commentMatch) {
|
|
98
|
+
rule.type = commentMatch[1].toLowerCase() as EnvType;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Comment'ten required hint
|
|
102
|
+
if (rest.match(/#.*optional/i)) {
|
|
103
|
+
rule.required = false;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Comment'ten enum hint: # enum:development,staging,production
|
|
107
|
+
const enumMatch = rest.match(/#\s*enum:\s*([^\s]+)/i);
|
|
108
|
+
if (enumMatch) {
|
|
109
|
+
rule.type = 'enum';
|
|
110
|
+
rule.enum = enumMatch[1].split(',');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Value'dan type tahmin et
|
|
114
|
+
if (!rule.type) {
|
|
115
|
+
const value = rest.split('#')[0].trim().replace(/^["']|["']$/g, '');
|
|
116
|
+
if (value.match(/^https?:\/\//)) rule.type = 'url';
|
|
117
|
+
else if (value.match(/^\d+$/) && key.match(/PORT/i)) rule.type = 'port';
|
|
118
|
+
else if (value.match(/^(true|false)$/i)) rule.type = 'boolean';
|
|
119
|
+
else if (value.match(/^\d+$/)) rule.type = 'number';
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
rules.push(rule);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return rules;
|
|
126
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export { parseEnvFile, parseEnvFileToMap } from './core/parser.js';
|
|
2
|
+
export type { EnvEntry } from './core/parser.js';
|
|
3
|
+
import { syncCheck as _syncCheck } from './core/sync.js';
|
|
4
|
+
export { _syncCheck as syncCheck };
|
|
5
|
+
export type { SyncResult } from './core/sync.js';
|
|
6
|
+
export { validate, inferRules } from './core/validator.js';
|
|
7
|
+
export type { ValidationRule, ValidationError, EnvType } from './core/validator.js';
|
|
8
|
+
export { checkLeaks, installPreCommitHook } from './core/leak.js';
|
|
9
|
+
export type { LeakResult, LeakEntry } from './core/leak.js';
|
|
10
|
+
export { generateExample, ensureGitignore } from './core/generator.js';
|
|
11
|
+
|
|
12
|
+
// Convenience: guard fonksiyonu — tek çağrıda her şeyi kontrol et
|
|
13
|
+
export function guard(options?: {
|
|
14
|
+
envPath?: string;
|
|
15
|
+
examplePath?: string;
|
|
16
|
+
exitOnError?: boolean;
|
|
17
|
+
}): { ok: boolean; errors: string[] } {
|
|
18
|
+
const envPath = options?.envPath ?? '.env';
|
|
19
|
+
const examplePath = options?.examplePath ?? '.env.example';
|
|
20
|
+
const exitOnError = options?.exitOnError ?? true;
|
|
21
|
+
const errors: string[] = [];
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
const sync = _syncCheck(envPath, examplePath);
|
|
25
|
+
if (sync.missing.length > 0) {
|
|
26
|
+
errors.push(`Missing variables: ${sync.missing.join(', ')}`);
|
|
27
|
+
}
|
|
28
|
+
} catch (err: any) {
|
|
29
|
+
if (!err.message.includes('not found')) {
|
|
30
|
+
errors.push(err.message);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (errors.length > 0 && exitOnError) {
|
|
35
|
+
console.error('\n dotenv-guard errors:\n');
|
|
36
|
+
errors.forEach(e => console.error(` ✖ ${e}`));
|
|
37
|
+
console.error('');
|
|
38
|
+
process.exit(1);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return { ok: errors.length === 0, errors };
|
|
42
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// chalk kullanmıyoruz — sıfır dependency
|
|
2
|
+
const isColorSupported = process.env.NO_COLOR === undefined && process.stdout.isTTY;
|
|
3
|
+
|
|
4
|
+
const wrap = (code: number, resetCode: number) => (str: string) =>
|
|
5
|
+
isColorSupported ? `\x1b[${code}m${str}\x1b[${resetCode}m` : str;
|
|
6
|
+
|
|
7
|
+
export const red = wrap(31, 39);
|
|
8
|
+
export const green = wrap(32, 39);
|
|
9
|
+
export const yellow = wrap(33, 39);
|
|
10
|
+
export const blue = wrap(34, 39);
|
|
11
|
+
export const gray = wrap(90, 39);
|
|
12
|
+
export const bold = wrap(1, 22);
|
|
13
|
+
export const dim = wrap(2, 22);
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'fs';
|
|
2
|
+
import { resolve } from 'path';
|
|
3
|
+
|
|
4
|
+
export function resolveFilePath(filePath: string, cwd?: string): string {
|
|
5
|
+
if (filePath.startsWith('/') || filePath.startsWith('\\')) return filePath;
|
|
6
|
+
return resolve(cwd || process.cwd(), filePath);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function readFileSafe(filePath: string): string | null {
|
|
10
|
+
try {
|
|
11
|
+
if (!existsSync(filePath)) return null;
|
|
12
|
+
return readFileSync(filePath, 'utf-8');
|
|
13
|
+
} catch {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function pluralize(count: number, singular: string, plural?: string): string {
|
|
19
|
+
return count === 1 ? singular : (plural || singular + 's');
|
|
20
|
+
}
|