@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
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { writeFileSync, readFileSync, mkdirSync, rmSync, existsSync } from 'fs';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
import { generateExample, ensureGitignore } from '../src/core/generator.js';
|
|
5
|
+
|
|
6
|
+
const TEST_DIR = join(process.cwd(), '.test-gen-tmp');
|
|
7
|
+
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
mkdirSync(TEST_DIR, { recursive: true });
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true, force: true });
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
describe('generateExample', () => {
|
|
17
|
+
it('should create .env.example from .env', () => {
|
|
18
|
+
const envPath = join(TEST_DIR, '.env');
|
|
19
|
+
const outPath = join(TEST_DIR, '.env.example');
|
|
20
|
+
writeFileSync(envPath, 'DB_HOST=localhost\nDB_PORT=5432\nDEBUG=true');
|
|
21
|
+
|
|
22
|
+
generateExample(envPath, outPath);
|
|
23
|
+
const content = readFileSync(outPath, 'utf-8');
|
|
24
|
+
|
|
25
|
+
expect(content).toContain('DB_HOST=');
|
|
26
|
+
expect(content).toContain('DB_PORT=');
|
|
27
|
+
expect(content).toContain('DEBUG=');
|
|
28
|
+
expect(content).not.toContain('localhost');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('should mask URL values with placeholder', () => {
|
|
32
|
+
const envPath = join(TEST_DIR, '.env');
|
|
33
|
+
const outPath = join(TEST_DIR, '.env.example');
|
|
34
|
+
writeFileSync(envPath, 'API_URL=https://api.example.com/v1');
|
|
35
|
+
|
|
36
|
+
generateExample(envPath, outPath);
|
|
37
|
+
const content = readFileSync(outPath, 'utf-8');
|
|
38
|
+
|
|
39
|
+
expect(content).toContain('https://...');
|
|
40
|
+
expect(content).toContain('type:url');
|
|
41
|
+
expect(content).not.toContain('api.example.com');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('should add type:boolean hint for boolean values', () => {
|
|
45
|
+
const envPath = join(TEST_DIR, '.env');
|
|
46
|
+
const outPath = join(TEST_DIR, '.env.example');
|
|
47
|
+
writeFileSync(envPath, 'DEBUG=true');
|
|
48
|
+
|
|
49
|
+
generateExample(envPath, outPath);
|
|
50
|
+
const content = readFileSync(outPath, 'utf-8');
|
|
51
|
+
|
|
52
|
+
expect(content).toContain('type:boolean');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('should add type:number hint for numeric values', () => {
|
|
56
|
+
const envPath = join(TEST_DIR, '.env');
|
|
57
|
+
const outPath = join(TEST_DIR, '.env.example');
|
|
58
|
+
writeFileSync(envPath, 'MAX_RETRIES=5');
|
|
59
|
+
|
|
60
|
+
generateExample(envPath, outPath);
|
|
61
|
+
const content = readFileSync(outPath, 'utf-8');
|
|
62
|
+
|
|
63
|
+
expect(content).toContain('type:number');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('should throw when .env does not exist', () => {
|
|
67
|
+
expect(() => generateExample(join(TEST_DIR, '.env.missing'), join(TEST_DIR, '.env.example')))
|
|
68
|
+
.toThrow('not found');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('should include header comments', () => {
|
|
72
|
+
const envPath = join(TEST_DIR, '.env');
|
|
73
|
+
const outPath = join(TEST_DIR, '.env.example');
|
|
74
|
+
writeFileSync(envPath, 'KEY=value');
|
|
75
|
+
|
|
76
|
+
const output = generateExample(envPath, outPath);
|
|
77
|
+
expect(output).toContain('# Environment Variables');
|
|
78
|
+
expect(output).toContain('# Copy this file');
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe('ensureGitignore', () => {
|
|
83
|
+
it('should add .env entries to .gitignore', () => {
|
|
84
|
+
writeFileSync(join(TEST_DIR, '.gitignore'), 'node_modules/\n');
|
|
85
|
+
const updated = ensureGitignore(TEST_DIR);
|
|
86
|
+
|
|
87
|
+
expect(updated).toBe(true);
|
|
88
|
+
const content = readFileSync(join(TEST_DIR, '.gitignore'), 'utf-8');
|
|
89
|
+
expect(content).toContain('.env');
|
|
90
|
+
expect(content).toContain('.env.local');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('should not duplicate entries', () => {
|
|
94
|
+
writeFileSync(join(TEST_DIR, '.gitignore'), '.env\n.env.local\n.env.*.local\n');
|
|
95
|
+
const updated = ensureGitignore(TEST_DIR);
|
|
96
|
+
expect(updated).toBe(false);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('should create .gitignore if it does not exist', () => {
|
|
100
|
+
const updated = ensureGitignore(TEST_DIR);
|
|
101
|
+
expect(updated).toBe(true);
|
|
102
|
+
expect(existsSync(join(TEST_DIR, '.gitignore'))).toBe(true);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { writeFileSync, mkdirSync, rmSync, existsSync } from 'fs';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
import { checkLeaks } from '../src/core/leak.js';
|
|
5
|
+
|
|
6
|
+
const TEST_DIR = join(process.cwd(), '.test-leak-tmp');
|
|
7
|
+
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
mkdirSync(TEST_DIR, { recursive: true });
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true, force: true });
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
describe('checkLeaks', () => {
|
|
17
|
+
it('should detect .gitignore missing .env', () => {
|
|
18
|
+
writeFileSync(join(TEST_DIR, '.gitignore'), 'node_modules/\ndist/\n');
|
|
19
|
+
const result = checkLeaks(TEST_DIR);
|
|
20
|
+
expect(result.gitignoreHasEnv).toBe(false);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('should detect .gitignore including .env', () => {
|
|
24
|
+
writeFileSync(join(TEST_DIR, '.gitignore'), 'node_modules/\n.env\ndist/\n');
|
|
25
|
+
const result = checkLeaks(TEST_DIR);
|
|
26
|
+
expect(result.gitignoreHasEnv).toBe(true);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('should detect .gitignore with .env* wildcard', () => {
|
|
30
|
+
writeFileSync(join(TEST_DIR, '.gitignore'), '.env*\n');
|
|
31
|
+
const result = checkLeaks(TEST_DIR);
|
|
32
|
+
expect(result.gitignoreHasEnv).toBe(true);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('should report no pre-commit hook when .git/hooks missing', () => {
|
|
36
|
+
const result = checkLeaks(TEST_DIR);
|
|
37
|
+
expect(result.preCommitHookInstalled).toBe(false);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('should detect pre-commit hook with dotenv-guard', () => {
|
|
41
|
+
const hooksDir = join(TEST_DIR, '.git', 'hooks');
|
|
42
|
+
mkdirSync(hooksDir, { recursive: true });
|
|
43
|
+
writeFileSync(join(hooksDir, 'pre-commit'), '#!/bin/sh\n# dotenv-guard hook\nexit 0');
|
|
44
|
+
const result = checkLeaks(TEST_DIR);
|
|
45
|
+
expect(result.preCommitHookInstalled).toBe(true);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('should handle non-git directory gracefully', () => {
|
|
49
|
+
const result = checkLeaks(TEST_DIR);
|
|
50
|
+
expect(result.leaks).toEqual([]);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { parseEnvFile, parseEnvFileToMap } from '../src/core/parser.js';
|
|
3
|
+
|
|
4
|
+
describe('parseEnvFile', () => {
|
|
5
|
+
it('should parse simple KEY=VALUE pairs', () => {
|
|
6
|
+
const content = 'DB_HOST=localhost\nDB_PORT=5432';
|
|
7
|
+
const entries = parseEnvFile(content);
|
|
8
|
+
expect(entries).toHaveLength(2);
|
|
9
|
+
expect(entries[0]).toMatchObject({ key: 'DB_HOST', value: 'localhost', line: 1 });
|
|
10
|
+
expect(entries[1]).toMatchObject({ key: 'DB_PORT', value: '5432', line: 2 });
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('should handle double-quoted values', () => {
|
|
14
|
+
const content = 'NAME="hello world"';
|
|
15
|
+
const entries = parseEnvFile(content);
|
|
16
|
+
expect(entries[0].value).toBe('hello world');
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('should handle single-quoted values', () => {
|
|
20
|
+
const content = "NAME='hello world'";
|
|
21
|
+
const entries = parseEnvFile(content);
|
|
22
|
+
expect(entries[0].value).toBe('hello world');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('should skip empty lines and comments', () => {
|
|
26
|
+
const content = '# This is a comment\n\nKEY=value\n\n# Another comment';
|
|
27
|
+
const entries = parseEnvFile(content);
|
|
28
|
+
expect(entries).toHaveLength(1);
|
|
29
|
+
expect(entries[0].key).toBe('KEY');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('should handle inline comments on unquoted values', () => {
|
|
33
|
+
const content = 'PORT=3000 # server port';
|
|
34
|
+
const entries = parseEnvFile(content);
|
|
35
|
+
expect(entries[0].value).toBe('3000');
|
|
36
|
+
expect(entries[0].comment).toBe('server port');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('should handle values with equals signs', () => {
|
|
40
|
+
const content = 'DATABASE_URL=postgresql://user:pass@host:5432/db?schema=public';
|
|
41
|
+
const entries = parseEnvFile(content);
|
|
42
|
+
expect(entries[0].value).toBe('postgresql://user:pass@host:5432/db?schema=public');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('should handle empty values', () => {
|
|
46
|
+
const content = 'EMPTY=';
|
|
47
|
+
const entries = parseEnvFile(content);
|
|
48
|
+
expect(entries[0].value).toBe('');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('should handle values with spaces around equals', () => {
|
|
52
|
+
const content = 'KEY = value';
|
|
53
|
+
const entries = parseEnvFile(content);
|
|
54
|
+
expect(entries[0]).toMatchObject({ key: 'KEY', value: 'value' });
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('should track correct line numbers', () => {
|
|
58
|
+
const content = '# comment\n\nFIRST=1\n\nSECOND=2';
|
|
59
|
+
const entries = parseEnvFile(content);
|
|
60
|
+
expect(entries[0].line).toBe(3);
|
|
61
|
+
expect(entries[1].line).toBe(5);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('should skip invalid lines gracefully', () => {
|
|
65
|
+
const content = 'VALID=yes\n123INVALID=no\n-ALSO_INVALID=no\nALSO_VALID=yes';
|
|
66
|
+
const entries = parseEnvFile(content);
|
|
67
|
+
expect(entries).toHaveLength(2);
|
|
68
|
+
expect(entries[0].key).toBe('VALID');
|
|
69
|
+
expect(entries[1].key).toBe('ALSO_VALID');
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe('parseEnvFileToMap', () => {
|
|
74
|
+
it('should return a Map of key-value pairs', () => {
|
|
75
|
+
const content = 'A=1\nB=2\nC=3';
|
|
76
|
+
const map = parseEnvFileToMap(content);
|
|
77
|
+
expect(map.size).toBe(3);
|
|
78
|
+
expect(map.get('A')).toBe('1');
|
|
79
|
+
expect(map.get('B')).toBe('2');
|
|
80
|
+
expect(map.get('C')).toBe('3');
|
|
81
|
+
});
|
|
82
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { writeFileSync, mkdirSync, rmSync, existsSync } from 'fs';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
import { syncCheck } from '../src/core/sync.js';
|
|
5
|
+
|
|
6
|
+
const TEST_DIR = join(process.cwd(), '.test-sync-tmp');
|
|
7
|
+
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
mkdirSync(TEST_DIR, { recursive: true });
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true, force: true });
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
describe('syncCheck', () => {
|
|
17
|
+
it('should detect missing variables', () => {
|
|
18
|
+
const envPath = join(TEST_DIR, '.env');
|
|
19
|
+
const examplePath = join(TEST_DIR, '.env.example');
|
|
20
|
+
writeFileSync(examplePath, 'A=1\nB=2\nC=3');
|
|
21
|
+
writeFileSync(envPath, 'A=1');
|
|
22
|
+
|
|
23
|
+
const result = syncCheck(envPath, examplePath);
|
|
24
|
+
expect(result.missing).toEqual(['B', 'C']);
|
|
25
|
+
expect(result.synced).toEqual(['A']);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('should detect extra variables', () => {
|
|
29
|
+
const envPath = join(TEST_DIR, '.env');
|
|
30
|
+
const examplePath = join(TEST_DIR, '.env.example');
|
|
31
|
+
writeFileSync(examplePath, 'A=1');
|
|
32
|
+
writeFileSync(envPath, 'A=1\nEXTRA=yes');
|
|
33
|
+
|
|
34
|
+
const result = syncCheck(envPath, examplePath);
|
|
35
|
+
expect(result.extra).toEqual(['EXTRA']);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('should report full sync when all vars match', () => {
|
|
39
|
+
const envPath = join(TEST_DIR, '.env');
|
|
40
|
+
const examplePath = join(TEST_DIR, '.env.example');
|
|
41
|
+
writeFileSync(examplePath, 'A=1\nB=2');
|
|
42
|
+
writeFileSync(envPath, 'A=hello\nB=world');
|
|
43
|
+
|
|
44
|
+
const result = syncCheck(envPath, examplePath);
|
|
45
|
+
expect(result.missing).toEqual([]);
|
|
46
|
+
expect(result.extra).toEqual([]);
|
|
47
|
+
expect(result.synced).toEqual(['A', 'B']);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('should throw error when .env.example is missing', () => {
|
|
51
|
+
const envPath = join(TEST_DIR, '.env');
|
|
52
|
+
const examplePath = join(TEST_DIR, '.env.example.nonexistent');
|
|
53
|
+
writeFileSync(envPath, 'A=1');
|
|
54
|
+
|
|
55
|
+
expect(() => syncCheck(envPath, examplePath)).toThrow('not found');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('should treat all keys as missing when .env does not exist', () => {
|
|
59
|
+
const envPath = join(TEST_DIR, '.env.nonexistent');
|
|
60
|
+
const examplePath = join(TEST_DIR, '.env.example');
|
|
61
|
+
writeFileSync(examplePath, 'A=1\nB=2\nC=3');
|
|
62
|
+
|
|
63
|
+
const result = syncCheck(envPath, examplePath);
|
|
64
|
+
expect(result.missing).toEqual(['A', 'B', 'C']);
|
|
65
|
+
expect(result.synced).toEqual([]);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('should handle empty .env.example', () => {
|
|
69
|
+
const envPath = join(TEST_DIR, '.env');
|
|
70
|
+
const examplePath = join(TEST_DIR, '.env.example');
|
|
71
|
+
writeFileSync(examplePath, '# just comments\n');
|
|
72
|
+
writeFileSync(envPath, 'A=1');
|
|
73
|
+
|
|
74
|
+
const result = syncCheck(envPath, examplePath);
|
|
75
|
+
expect(result.missing).toEqual([]);
|
|
76
|
+
expect(result.extra).toEqual(['A']);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { validate, inferRules, ValidationRule } from '../src/core/validator.js';
|
|
3
|
+
|
|
4
|
+
describe('validate', () => {
|
|
5
|
+
it('should pass valid string values', () => {
|
|
6
|
+
const errors = validate(
|
|
7
|
+
{ NAME: 'hello' },
|
|
8
|
+
[{ key: 'NAME', type: 'string', required: true }]
|
|
9
|
+
);
|
|
10
|
+
expect(errors).toHaveLength(0);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('should fail when required variable is missing', () => {
|
|
14
|
+
const errors = validate(
|
|
15
|
+
{},
|
|
16
|
+
[{ key: 'API_KEY', required: true }]
|
|
17
|
+
);
|
|
18
|
+
expect(errors).toHaveLength(1);
|
|
19
|
+
expect(errors[0].message).toContain('required');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('should pass when optional variable is missing', () => {
|
|
23
|
+
const errors = validate(
|
|
24
|
+
{},
|
|
25
|
+
[{ key: 'OPTIONAL_VAR', required: false }]
|
|
26
|
+
);
|
|
27
|
+
expect(errors).toHaveLength(0);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('should validate number type', () => {
|
|
31
|
+
const rules: ValidationRule[] = [{ key: 'COUNT', type: 'number' }];
|
|
32
|
+
expect(validate({ COUNT: '42' }, rules)).toHaveLength(0);
|
|
33
|
+
expect(validate({ COUNT: 'abc' }, rules)).toHaveLength(1);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('should validate number min/max', () => {
|
|
37
|
+
const rules: ValidationRule[] = [{ key: 'N', type: 'number', min: 1, max: 100 }];
|
|
38
|
+
expect(validate({ N: '50' }, rules)).toHaveLength(0);
|
|
39
|
+
expect(validate({ N: '0' }, rules)).toHaveLength(1);
|
|
40
|
+
expect(validate({ N: '101' }, rules)).toHaveLength(1);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('should validate boolean type', () => {
|
|
44
|
+
const rules: ValidationRule[] = [{ key: 'DEBUG', type: 'boolean' }];
|
|
45
|
+
expect(validate({ DEBUG: 'true' }, rules)).toHaveLength(0);
|
|
46
|
+
expect(validate({ DEBUG: 'false' }, rules)).toHaveLength(0);
|
|
47
|
+
expect(validate({ DEBUG: '1' }, rules)).toHaveLength(0);
|
|
48
|
+
expect(validate({ DEBUG: '0' }, rules)).toHaveLength(0);
|
|
49
|
+
expect(validate({ DEBUG: 'yes' }, rules)).toHaveLength(0);
|
|
50
|
+
expect(validate({ DEBUG: 'no' }, rules)).toHaveLength(0);
|
|
51
|
+
expect(validate({ DEBUG: 'maybe' }, rules)).toHaveLength(1);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('should validate URL type', () => {
|
|
55
|
+
const rules: ValidationRule[] = [{ key: 'URL', type: 'url' }];
|
|
56
|
+
expect(validate({ URL: 'https://example.com' }, rules)).toHaveLength(0);
|
|
57
|
+
expect(validate({ URL: 'not-a-url' }, rules)).toHaveLength(1);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('should validate email type', () => {
|
|
61
|
+
const rules: ValidationRule[] = [{ key: 'EMAIL', type: 'email' }];
|
|
62
|
+
expect(validate({ EMAIL: 'user@example.com' }, rules)).toHaveLength(0);
|
|
63
|
+
expect(validate({ EMAIL: 'not-an-email' }, rules)).toHaveLength(1);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('should validate port type', () => {
|
|
67
|
+
const rules: ValidationRule[] = [{ key: 'PORT', type: 'port' }];
|
|
68
|
+
expect(validate({ PORT: '3000' }, rules)).toHaveLength(0);
|
|
69
|
+
expect(validate({ PORT: '0' }, rules)).toHaveLength(1);
|
|
70
|
+
expect(validate({ PORT: '65536' }, rules)).toHaveLength(1);
|
|
71
|
+
expect(validate({ PORT: 'abc' }, rules)).toHaveLength(1);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('should validate enum type', () => {
|
|
75
|
+
const rules: ValidationRule[] = [
|
|
76
|
+
{ key: 'NODE_ENV', type: 'enum', enum: ['development', 'staging', 'production'] }
|
|
77
|
+
];
|
|
78
|
+
expect(validate({ NODE_ENV: 'development' }, rules)).toHaveLength(0);
|
|
79
|
+
expect(validate({ NODE_ENV: 'invalid' }, rules)).toHaveLength(1);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('should validate custom pattern', () => {
|
|
83
|
+
const rules: ValidationRule[] = [
|
|
84
|
+
{ key: 'CODE', pattern: /^[A-Z]{3}-\d{3}$/ }
|
|
85
|
+
];
|
|
86
|
+
expect(validate({ CODE: 'ABC-123' }, rules)).toHaveLength(0);
|
|
87
|
+
expect(validate({ CODE: 'abc-123' }, rules)).toHaveLength(1);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe('inferRules', () => {
|
|
92
|
+
it('should infer type from comment hint', () => {
|
|
93
|
+
const rules = inferRules('DATABASE_URL=postgresql://... # type:url');
|
|
94
|
+
expect(rules[0].type).toBe('url');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('should infer optional from comment', () => {
|
|
98
|
+
const rules = inferRules('REDIS_URL= # type:url optional');
|
|
99
|
+
expect(rules[0].required).toBe(false);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('should infer enum from comment', () => {
|
|
103
|
+
const rules = inferRules('NODE_ENV=development # enum:development,staging,production');
|
|
104
|
+
expect(rules[0].type).toBe('enum');
|
|
105
|
+
expect(rules[0].enum).toEqual(['development', 'staging', 'production']);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('should infer URL type from value pattern', () => {
|
|
109
|
+
const rules = inferRules('API_URL=https://api.example.com');
|
|
110
|
+
expect(rules[0].type).toBe('url');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('should infer port type from key name and numeric value', () => {
|
|
114
|
+
const rules = inferRules('SERVER_PORT=3000');
|
|
115
|
+
expect(rules[0].type).toBe('port');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('should infer boolean type from value', () => {
|
|
119
|
+
const rules = inferRules('DEBUG=true');
|
|
120
|
+
expect(rules[0].type).toBe('boolean');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('should infer number type from numeric value', () => {
|
|
124
|
+
const rules = inferRules('MAX_RETRIES=5');
|
|
125
|
+
expect(rules[0].type).toBe('number');
|
|
126
|
+
});
|
|
127
|
+
});
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2020",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "bundler",
|
|
6
|
+
"declaration": true,
|
|
7
|
+
"declarationMap": true,
|
|
8
|
+
"sourceMap": true,
|
|
9
|
+
"outDir": "dist",
|
|
10
|
+
"rootDir": "src",
|
|
11
|
+
"strict": true,
|
|
12
|
+
"esModuleInterop": true,
|
|
13
|
+
"skipLibCheck": true,
|
|
14
|
+
"forceConsistentCasingInFileNames": true,
|
|
15
|
+
"resolveJsonModule": true,
|
|
16
|
+
"isolatedModules": true,
|
|
17
|
+
"lib": ["ES2020"]
|
|
18
|
+
},
|
|
19
|
+
"include": ["src"],
|
|
20
|
+
"exclude": ["node_modules", "dist", "tests"]
|
|
21
|
+
}
|