@gudroniy/env-doctor 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/README.md +75 -0
- package/bin/env-doctor.js +113 -0
- package/package.json +26 -0
- package/src/inferType.js +14 -0
- package/src/parser.js +42 -0
- package/src/validators.js +64 -0
package/README.md
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# env-doctor
|
|
2
|
+
|
|
3
|
+
CLI, который проверяет `.env` файлы на пропущенные переменные, несовпадение типов и расхождения между окружениями (dev / staging / production) — **до** того, как это сломает деплой.
|
|
4
|
+
|
|
5
|
+
## Проблема
|
|
6
|
+
|
|
7
|
+
Классическая ситуация: в `.env` локально всё работает, а на проде забыли добавить одну переменную — и приложение падает после деплоя. Или переменная есть, но записана не в том формате (например, `PORT=abc` вместо числа). Обычно это ловят вручную, глазами, сравнивая файлы.
|
|
8
|
+
|
|
9
|
+
`env-doctor` автоматизирует эту проверку.
|
|
10
|
+
|
|
11
|
+
## Установка
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npm install -g env-doctor
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Или без глобальной установки:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
npx env-doctor check
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Быстрый старт
|
|
24
|
+
|
|
25
|
+
1. Сгенерируй схему на основе рабочего `.env`:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
env-doctor init --file .env
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Это создаст `env.schema.json` — файл с описанием ожидаемых переменных и их типов (определяются автоматически).
|
|
32
|
+
|
|
33
|
+
2. Отредактируй схему при необходимости — пометь необязательные переменные (`"required": false`), поправь типы.
|
|
34
|
+
|
|
35
|
+
3. Проверь любой другой `.env` файл на соответствие схеме:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
env-doctor check .env.production
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Без аргументов проверяются все файлы вида `.env*` в текущей папке:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
env-doctor check
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Пример вывода
|
|
48
|
+
|
|
49
|
+
```
|
|
50
|
+
.env.production
|
|
51
|
+
✘ Переменная "API_KEY" обязательна, но пустая
|
|
52
|
+
✘ Переменная "PORT" должна быть типа number, получено "not-a-number"
|
|
53
|
+
✘ Отсутствует обязательная переменная "STRIPE_WEBHOOK_SECRET"
|
|
54
|
+
⚠ Переменная "LEGACY_FLAG" не описана в схеме
|
|
55
|
+
|
|
56
|
+
Итого: 3 ошибок, 1 предупреждений
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Использование в CI
|
|
60
|
+
|
|
61
|
+
Команда завершается с кодом `1`, если найдены ошибки — удобно добавить проверку в pipeline перед деплоем:
|
|
62
|
+
|
|
63
|
+
```yaml
|
|
64
|
+
- run: npx env-doctor check .env.production --strict
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Флаг `--strict` также считает ошибкой любые переменные, не описанные в схеме (например, забытые старые ключи).
|
|
68
|
+
|
|
69
|
+
## Поддерживаемые типы
|
|
70
|
+
|
|
71
|
+
`string`, `number`, `boolean`, `url`, `email`
|
|
72
|
+
|
|
73
|
+
## Лицензия
|
|
74
|
+
|
|
75
|
+
MIT
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
const { program } = require('commander');
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const chalk = require('chalk');
|
|
6
|
+
const { parseEnvFile } = require('../src/parser');
|
|
7
|
+
const { inferType } = require('../src/inferType');
|
|
8
|
+
const { checkEnv } = require('../src/validators');
|
|
9
|
+
|
|
10
|
+
const DEFAULT_SCHEMA_FILE = 'env.schema.json';
|
|
11
|
+
|
|
12
|
+
program
|
|
13
|
+
.name('env-doctor')
|
|
14
|
+
.description(
|
|
15
|
+
'Проверяет .env файлы на пропущенные переменные, несоответствие типов и расхождения между окружениями'
|
|
16
|
+
)
|
|
17
|
+
.version('1.0.0');
|
|
18
|
+
|
|
19
|
+
program
|
|
20
|
+
.command('init')
|
|
21
|
+
.description('Сгенерировать env.schema.json на основе существующего .env файла')
|
|
22
|
+
.option('-f, --file <path>', 'Путь к исходному .env файлу', '.env')
|
|
23
|
+
.option('-o, --output <path>', 'Путь для сохранения схемы', DEFAULT_SCHEMA_FILE)
|
|
24
|
+
.action((opts) => {
|
|
25
|
+
const filePath = path.resolve(process.cwd(), opts.file);
|
|
26
|
+
if (!fs.existsSync(filePath)) {
|
|
27
|
+
console.error(chalk.red(`Файл не найден: ${filePath}`));
|
|
28
|
+
process.exit(1);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const { values } = parseEnvFile(filePath);
|
|
32
|
+
const schema = {};
|
|
33
|
+
for (const [key, value] of Object.entries(values)) {
|
|
34
|
+
schema[key] = { required: true, type: inferType(value) };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const schemaPath = path.resolve(process.cwd(), opts.output);
|
|
38
|
+
fs.writeFileSync(schemaPath, JSON.stringify(schema, null, 2) + '\n');
|
|
39
|
+
|
|
40
|
+
console.log(chalk.green(`✔ Схема создана: ${opts.output} (${Object.keys(schema).length} переменных)`));
|
|
41
|
+
console.log(chalk.gray(' Отредактируй файл вручную: пометь опциональные переменные, поправь типы при необходимости.'));
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
program
|
|
45
|
+
.command('check [files...]')
|
|
46
|
+
.description('Проверить .env файлы на соответствие схеме')
|
|
47
|
+
.option('-s, --strict', 'Считать лишние переменные ошибкой, а не предупреждением')
|
|
48
|
+
.option('--schema <path>', 'Путь к файлу схемы', DEFAULT_SCHEMA_FILE)
|
|
49
|
+
.action((files, opts) => {
|
|
50
|
+
const schemaPath = path.resolve(process.cwd(), opts.schema);
|
|
51
|
+
if (!fs.existsSync(schemaPath)) {
|
|
52
|
+
console.error(chalk.red(`Схема не найдена: ${schemaPath}`));
|
|
53
|
+
console.error(chalk.gray(' Запусти "env-doctor init" сначала, чтобы её создать.'));
|
|
54
|
+
process.exit(1);
|
|
55
|
+
}
|
|
56
|
+
const schema = JSON.parse(fs.readFileSync(schemaPath, 'utf-8'));
|
|
57
|
+
|
|
58
|
+
let targetFiles = files && files.length ? files : null;
|
|
59
|
+
if (!targetFiles) {
|
|
60
|
+
targetFiles = fs
|
|
61
|
+
.readdirSync(process.cwd())
|
|
62
|
+
.filter((f) => /^\.env(\..+)?$/.test(f));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (targetFiles.length === 0) {
|
|
66
|
+
console.error(chalk.red('Не найдено ни одного .env файла для проверки.'));
|
|
67
|
+
process.exit(1);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
let totalErrors = 0;
|
|
71
|
+
let totalWarnings = 0;
|
|
72
|
+
|
|
73
|
+
for (const file of targetFiles) {
|
|
74
|
+
const filePath = path.resolve(process.cwd(), file);
|
|
75
|
+
console.log(chalk.bold(`\n${file}`));
|
|
76
|
+
|
|
77
|
+
if (!fs.existsSync(filePath)) {
|
|
78
|
+
console.log(chalk.red(' ✘ файл не найден'));
|
|
79
|
+
totalErrors++;
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const { values, duplicates } = parseEnvFile(filePath);
|
|
84
|
+
const issues = checkEnv(schema, values, { strict: Boolean(opts.strict) });
|
|
85
|
+
|
|
86
|
+
duplicates.forEach((d) => {
|
|
87
|
+
issues.push({
|
|
88
|
+
level: 'warning',
|
|
89
|
+
key: d.key,
|
|
90
|
+
message: `Дублирующийся ключ "${d.key}" (строка ${d.line})`,
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
if (issues.length === 0) {
|
|
95
|
+
console.log(chalk.green(' ✔ Всё в порядке'));
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
issues.forEach((issue) => {
|
|
100
|
+
const icon = issue.level === 'error' ? chalk.red('✘') : chalk.yellow('⚠');
|
|
101
|
+
console.log(` ${icon} ${issue.message}`);
|
|
102
|
+
if (issue.level === 'error') totalErrors++;
|
|
103
|
+
else totalWarnings++;
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
console.log('');
|
|
108
|
+
console.log(chalk.bold(`Итого: ${totalErrors} ошибок, ${totalWarnings} предупреждений`));
|
|
109
|
+
|
|
110
|
+
if (totalErrors > 0) process.exit(1);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
program.parse();
|
package/package.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@gudroniy/env-doctor",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "CLI, который проверяет .env файлы на пропущенные переменные, несовпадение типов и расхождения между окружениями",
|
|
5
|
+
"bin": {
|
|
6
|
+
"env-doctor": "./bin/env-doctor.js"
|
|
7
|
+
},
|
|
8
|
+
"main": "src/index.js",
|
|
9
|
+
"license": "MIT",
|
|
10
|
+
"files": [
|
|
11
|
+
"bin",
|
|
12
|
+
"src",
|
|
13
|
+
"README.md"
|
|
14
|
+
],
|
|
15
|
+
"scripts": {
|
|
16
|
+
"test": "node --test"
|
|
17
|
+
},
|
|
18
|
+
"keywords": ["env", "dotenv", "cli", "devtools", "validation"],
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"commander": "^12.0.0",
|
|
21
|
+
"chalk": "^4.1.2"
|
|
22
|
+
},
|
|
23
|
+
"engines": {
|
|
24
|
+
"node": ">=14"
|
|
25
|
+
}
|
|
26
|
+
}
|
package/src/inferType.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Пытается угадать тип значения переменной окружения.
|
|
3
|
+
* Используется командой `init` для генерации схемы.
|
|
4
|
+
*/
|
|
5
|
+
function inferType(value) {
|
|
6
|
+
if (value === '') return 'string';
|
|
7
|
+
if (/^(true|false)$/i.test(value)) return 'boolean';
|
|
8
|
+
if (/^-?\d+(\.\d+)?$/.test(value)) return 'number';
|
|
9
|
+
if (/^https?:\/\/.+/i.test(value)) return 'url';
|
|
10
|
+
if (/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) return 'email';
|
|
11
|
+
return 'string';
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
module.exports = { inferType };
|
package/src/parser.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Парсит .env файл в объект ключ-значение.
|
|
5
|
+
* Возвращает также список дублирующихся ключей.
|
|
6
|
+
*/
|
|
7
|
+
function parseEnvFile(filePath) {
|
|
8
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
9
|
+
const lines = content.split(/\r?\n/);
|
|
10
|
+
const values = {};
|
|
11
|
+
const duplicates = [];
|
|
12
|
+
const seen = new Set();
|
|
13
|
+
|
|
14
|
+
lines.forEach((rawLine, idx) => {
|
|
15
|
+
const line = rawLine.trim();
|
|
16
|
+
if (!line || line.startsWith('#')) return;
|
|
17
|
+
|
|
18
|
+
const eqIndex = line.indexOf('=');
|
|
19
|
+
if (eqIndex === -1) return;
|
|
20
|
+
|
|
21
|
+
const key = line.slice(0, eqIndex).trim();
|
|
22
|
+
let value = line.slice(eqIndex + 1).trim();
|
|
23
|
+
|
|
24
|
+
// убираем окружающие кавычки
|
|
25
|
+
if (
|
|
26
|
+
(value.startsWith('"') && value.endsWith('"')) ||
|
|
27
|
+
(value.startsWith("'") && value.endsWith("'"))
|
|
28
|
+
) {
|
|
29
|
+
value = value.slice(1, -1);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (seen.has(key)) {
|
|
33
|
+
duplicates.push({ key, line: idx + 1 });
|
|
34
|
+
}
|
|
35
|
+
seen.add(key);
|
|
36
|
+
values[key] = value;
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
return { values, duplicates };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
module.exports = { parseEnvFile };
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
function validateType(value, expectedType) {
|
|
2
|
+
if (!expectedType || expectedType === 'string') return true;
|
|
3
|
+
if (expectedType === 'number') return /^-?\d+(\.\d+)?$/.test(value);
|
|
4
|
+
if (expectedType === 'boolean') return /^(true|false)$/i.test(value);
|
|
5
|
+
if (expectedType === 'url') return /^https?:\/\/.+/i.test(value);
|
|
6
|
+
if (expectedType === 'email') return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
|
|
7
|
+
return true;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Сравнивает значения из .env файла со схемой.
|
|
12
|
+
* Возвращает список проблем: { level: 'error' | 'warning', key, message }
|
|
13
|
+
*/
|
|
14
|
+
function checkEnv(schema, envValues, { strict = false } = {}) {
|
|
15
|
+
const issues = [];
|
|
16
|
+
|
|
17
|
+
for (const [key, rule] of Object.entries(schema)) {
|
|
18
|
+
const hasKey = Object.prototype.hasOwnProperty.call(envValues, key);
|
|
19
|
+
|
|
20
|
+
if (rule.required && !hasKey) {
|
|
21
|
+
issues.push({
|
|
22
|
+
level: 'error',
|
|
23
|
+
key,
|
|
24
|
+
message: `Отсутствует обязательная переменная "${key}"`,
|
|
25
|
+
});
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (hasKey) {
|
|
30
|
+
const value = envValues[key];
|
|
31
|
+
|
|
32
|
+
if (rule.required && value === '') {
|
|
33
|
+
issues.push({
|
|
34
|
+
level: 'error',
|
|
35
|
+
key,
|
|
36
|
+
message: `Переменная "${key}" обязательна, но пустая`,
|
|
37
|
+
});
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (value !== '' && rule.type && !validateType(value, rule.type)) {
|
|
42
|
+
issues.push({
|
|
43
|
+
level: 'error',
|
|
44
|
+
key,
|
|
45
|
+
message: `Переменная "${key}" должна быть типа ${rule.type}, получено "${value}"`,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
for (const key of Object.keys(envValues)) {
|
|
52
|
+
if (!schema[key]) {
|
|
53
|
+
issues.push({
|
|
54
|
+
level: strict ? 'error' : 'warning',
|
|
55
|
+
key,
|
|
56
|
+
message: `Переменная "${key}" не описана в схеме`,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return issues;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
module.exports = { checkEnv, validateType };
|