safe-dotenv-check 0.2.1
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/LICENSE +21 -0
- package/README.md +161 -0
- package/action.yml +93 -0
- package/bin/safe-dotenv-check.js +6 -0
- package/package.json +42 -0
- package/src/check.js +128 -0
- package/src/cli.js +168 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# safe-dotenv-check
|
|
2
|
+
|
|
3
|
+
[](https://github.com/eunsujihoon-hub/safe-dotenv-check/actions/workflows/ci.yml)
|
|
4
|
+
[](./LICENSE)
|
|
5
|
+
|
|
6
|
+
Small CLI to compare required environment keys from a manifest file such as `.env.example` against one or more target `.env` files.
|
|
7
|
+
|
|
8
|
+
It focuses on the checks that usually break deploys:
|
|
9
|
+
|
|
10
|
+
- missing required keys
|
|
11
|
+
- empty values for required keys
|
|
12
|
+
- unexpected extra keys
|
|
13
|
+
- optional keys documented directly inside the example manifest
|
|
14
|
+
- machine-readable JSON output for CI or deployment checks
|
|
15
|
+
- a reusable GitHub Action wrapper for repository-level checks
|
|
16
|
+
|
|
17
|
+
## Install
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
npm install --global safe-dotenv-check
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Or run it without installing:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
npx safe-dotenv-check --example .env.example --env .env
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## GitHub Action
|
|
30
|
+
|
|
31
|
+
Use the repository directly in GitHub Actions:
|
|
32
|
+
|
|
33
|
+
```yaml
|
|
34
|
+
jobs:
|
|
35
|
+
env-check:
|
|
36
|
+
runs-on: ubuntu-latest
|
|
37
|
+
steps:
|
|
38
|
+
- uses: actions/checkout@v4
|
|
39
|
+
- uses: eunsujihoon-hub/safe-dotenv-check@v0.2.0
|
|
40
|
+
with:
|
|
41
|
+
example: .env.example
|
|
42
|
+
env_files: |
|
|
43
|
+
.env.ci
|
|
44
|
+
.env.production
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Available inputs:
|
|
48
|
+
|
|
49
|
+
- `example`: manifest path such as `.env.example`
|
|
50
|
+
- `env_files`: newline-separated target env file paths
|
|
51
|
+
- `allow_extra`: set to `true` to ignore keys that exist only in target files
|
|
52
|
+
- `summary`: set to `false` to skip step summary output
|
|
53
|
+
- `json_output_path`: optional path where the JSON report should be copied
|
|
54
|
+
|
|
55
|
+
## Usage
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
safe-dotenv-check --example .env.example --env .env
|
|
59
|
+
safe-dotenv-check --example .env.example --env .env --env .env.production
|
|
60
|
+
safe-dotenv-check --example .env.example --env .env --allow-extra
|
|
61
|
+
safe-dotenv-check --example .env.example --env .env --format json
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Exit codes
|
|
65
|
+
|
|
66
|
+
- `0`: all files passed
|
|
67
|
+
- `1`: at least one file has missing or empty required keys
|
|
68
|
+
- `2`: invalid CLI usage or unreadable files
|
|
69
|
+
|
|
70
|
+
## What counts as required
|
|
71
|
+
|
|
72
|
+
Every non-comment key in the example file is treated as required.
|
|
73
|
+
|
|
74
|
+
Example:
|
|
75
|
+
|
|
76
|
+
```dotenv
|
|
77
|
+
# .env.example
|
|
78
|
+
DATABASE_URL=
|
|
79
|
+
OPENAI_API_KEY=
|
|
80
|
+
LOG_LEVEL=info
|
|
81
|
+
?SENTRY_DSN=
|
|
82
|
+
REDIS_URL= # optional
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Optional keys can be marked in either of these forms:
|
|
86
|
+
|
|
87
|
+
```dotenv
|
|
88
|
+
?SENTRY_DSN=
|
|
89
|
+
REDIS_URL= # optional
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## Output example
|
|
93
|
+
|
|
94
|
+
```text
|
|
95
|
+
PASS .env
|
|
96
|
+
FAIL .env.production
|
|
97
|
+
missing: OPENAI_API_KEY
|
|
98
|
+
empty: DATABASE_URL
|
|
99
|
+
extra: DEBUG
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## JSON output
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
safe-dotenv-check --example .env.example --env .env --format json
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
```json
|
|
109
|
+
{
|
|
110
|
+
"ok": false,
|
|
111
|
+
"example": ".env.example",
|
|
112
|
+
"files": [
|
|
113
|
+
{
|
|
114
|
+
"file": ".env",
|
|
115
|
+
"missing": [
|
|
116
|
+
"OPENAI_API_KEY"
|
|
117
|
+
],
|
|
118
|
+
"empty": [
|
|
119
|
+
"DATABASE_URL"
|
|
120
|
+
],
|
|
121
|
+
"extra": [],
|
|
122
|
+
"optional": [],
|
|
123
|
+
"ok": false
|
|
124
|
+
}
|
|
125
|
+
]
|
|
126
|
+
}
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
## Why this exists
|
|
130
|
+
|
|
131
|
+
Many teams keep `.env.example` around but do not actually verify deploy-time env files against it. This tool is intentionally small enough to drop into CI, pre-deploy scripts, or local sanity checks.
|
|
132
|
+
|
|
133
|
+
## Secret safety
|
|
134
|
+
|
|
135
|
+
This repository ignores common secret-bearing files by default:
|
|
136
|
+
|
|
137
|
+
- `.env`
|
|
138
|
+
- `.env.local`
|
|
139
|
+
- `.env.production.local`
|
|
140
|
+
- `.envrc`
|
|
141
|
+
- `secrets/`
|
|
142
|
+
- certificate and private key files such as `*.pem` and `*.key`
|
|
143
|
+
|
|
144
|
+
Commit only redacted examples such as `.env.example`. Do not commit real credentials just because the tool checks them.
|
|
145
|
+
|
|
146
|
+
## Roadmap
|
|
147
|
+
|
|
148
|
+
- optional support for warning-only keys
|
|
149
|
+
- shell-friendly summary mode
|
|
150
|
+
- GitHub Action wrapper
|
|
151
|
+
|
|
152
|
+
## Contributing
|
|
153
|
+
|
|
154
|
+
Bug reports and pull requests are welcome. See [CONTRIBUTING.md](./CONTRIBUTING.md).
|
|
155
|
+
|
|
156
|
+
## Development
|
|
157
|
+
|
|
158
|
+
```bash
|
|
159
|
+
npm test
|
|
160
|
+
npm run pack:check
|
|
161
|
+
```
|
package/action.yml
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
name: safe-dotenv-check
|
|
2
|
+
description: Validate one or more .env files against a .env.example-style manifest.
|
|
3
|
+
|
|
4
|
+
inputs:
|
|
5
|
+
example:
|
|
6
|
+
description: Path to the manifest file, usually .env.example.
|
|
7
|
+
required: true
|
|
8
|
+
env_files:
|
|
9
|
+
description: Newline-separated target .env file paths to validate.
|
|
10
|
+
required: true
|
|
11
|
+
allow_extra:
|
|
12
|
+
description: Set to true to ignore keys that exist only in target env files.
|
|
13
|
+
required: false
|
|
14
|
+
default: "false"
|
|
15
|
+
summary:
|
|
16
|
+
description: Set to true to write a markdown summary to the GitHub Actions step summary.
|
|
17
|
+
required: false
|
|
18
|
+
default: "true"
|
|
19
|
+
json_output_path:
|
|
20
|
+
description: Optional path where the JSON report should be copied.
|
|
21
|
+
required: false
|
|
22
|
+
default: ""
|
|
23
|
+
|
|
24
|
+
outputs:
|
|
25
|
+
ok:
|
|
26
|
+
description: Whether all target files passed validation.
|
|
27
|
+
value: ${{ steps.validate.outputs.ok }}
|
|
28
|
+
report_path:
|
|
29
|
+
description: Path to the generated JSON report inside the runner workspace.
|
|
30
|
+
value: ${{ steps.validate.outputs.report_path }}
|
|
31
|
+
|
|
32
|
+
runs:
|
|
33
|
+
using: composite
|
|
34
|
+
steps:
|
|
35
|
+
- id: validate
|
|
36
|
+
shell: bash
|
|
37
|
+
run: |
|
|
38
|
+
set -euo pipefail
|
|
39
|
+
|
|
40
|
+
report_path="${RUNNER_TEMP}/safe-dotenv-check-report.json"
|
|
41
|
+
args=(--example "${{ inputs.example }}")
|
|
42
|
+
|
|
43
|
+
while IFS= read -r line; do
|
|
44
|
+
if [ -n "$line" ]; then
|
|
45
|
+
args+=(--env "$line")
|
|
46
|
+
fi
|
|
47
|
+
done <<'EOF'
|
|
48
|
+
${{ inputs.env_files }}
|
|
49
|
+
EOF
|
|
50
|
+
|
|
51
|
+
if [ "${{ inputs.allow_extra }}" = "true" ]; then
|
|
52
|
+
args+=(--allow-extra)
|
|
53
|
+
fi
|
|
54
|
+
|
|
55
|
+
set +e
|
|
56
|
+
node "${GITHUB_ACTION_PATH}/bin/safe-dotenv-check.js" "${args[@]}" --format json >"${report_path}"
|
|
57
|
+
status=$?
|
|
58
|
+
set -e
|
|
59
|
+
|
|
60
|
+
ok=false
|
|
61
|
+
if [ "${status}" -eq 0 ]; then
|
|
62
|
+
ok=true
|
|
63
|
+
fi
|
|
64
|
+
|
|
65
|
+
echo "ok=${ok}" >>"${GITHUB_OUTPUT}"
|
|
66
|
+
echo "report_path=${report_path}" >>"${GITHUB_OUTPUT}"
|
|
67
|
+
|
|
68
|
+
if [ -n "${{ inputs.json_output_path }}" ]; then
|
|
69
|
+
mkdir -p "$(dirname "${{ inputs.json_output_path }}")"
|
|
70
|
+
cp "${report_path}" "${{ inputs.json_output_path }}"
|
|
71
|
+
fi
|
|
72
|
+
|
|
73
|
+
if [ "${{ inputs.summary }}" = "true" ]; then
|
|
74
|
+
REPORT_PATH="${report_path}" node <<'EOF' >>"${GITHUB_STEP_SUMMARY}"
|
|
75
|
+
const fs = require("node:fs");
|
|
76
|
+
const report = JSON.parse(fs.readFileSync(process.env.REPORT_PATH, "utf8"));
|
|
77
|
+
|
|
78
|
+
process.stdout.write("## safe-dotenv-check\n\n");
|
|
79
|
+
process.stdout.write(`Manifest: \`${report.example}\`\n\n`);
|
|
80
|
+
process.stdout.write("| File | Status | Missing | Empty | Extra |\n");
|
|
81
|
+
process.stdout.write("| --- | --- | --- | --- | --- |\n");
|
|
82
|
+
|
|
83
|
+
for (const item of report.files) {
|
|
84
|
+
const status = item.ok ? "PASS" : "FAIL";
|
|
85
|
+
const missing = item.missing.length ? item.missing.join(", ") : "-";
|
|
86
|
+
const empty = item.empty.length ? item.empty.join(", ") : "-";
|
|
87
|
+
const extra = item.extra.length ? item.extra.join(", ") : "-";
|
|
88
|
+
process.stdout.write(`| \`${item.file}\` | ${status} | ${missing} | ${empty} | ${extra} |\n`);
|
|
89
|
+
}
|
|
90
|
+
EOF
|
|
91
|
+
fi
|
|
92
|
+
|
|
93
|
+
exit "${status}"
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "safe-dotenv-check",
|
|
3
|
+
"version": "0.2.1",
|
|
4
|
+
"description": "Small CLI to verify .env files against a required key manifest.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"homepage": "https://github.com/eunsujihoon-hub/safe-dotenv-check#readme",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/eunsujihoon-hub/safe-dotenv-check.git"
|
|
10
|
+
},
|
|
11
|
+
"bugs": {
|
|
12
|
+
"url": "https://github.com/eunsujihoon-hub/safe-dotenv-check/issues"
|
|
13
|
+
},
|
|
14
|
+
"bin": {
|
|
15
|
+
"safe-dotenv-check": "bin/safe-dotenv-check.js"
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"bin",
|
|
19
|
+
"src",
|
|
20
|
+
"action.yml"
|
|
21
|
+
],
|
|
22
|
+
"scripts": {
|
|
23
|
+
"test": "node --test",
|
|
24
|
+
"pack:check": "npm pack --dry-run",
|
|
25
|
+
"prepublishOnly": "npm test && npm run pack:check"
|
|
26
|
+
},
|
|
27
|
+
"keywords": [
|
|
28
|
+
"dotenv",
|
|
29
|
+
"env",
|
|
30
|
+
"cli",
|
|
31
|
+
"validation",
|
|
32
|
+
"developer-tools"
|
|
33
|
+
],
|
|
34
|
+
"author": "eunsujihoon-hub",
|
|
35
|
+
"license": "MIT",
|
|
36
|
+
"publishConfig": {
|
|
37
|
+
"access": "public"
|
|
38
|
+
},
|
|
39
|
+
"engines": {
|
|
40
|
+
"node": ">=18"
|
|
41
|
+
}
|
|
42
|
+
}
|
package/src/check.js
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
|
|
3
|
+
export function parseEnvFile(content) {
|
|
4
|
+
const entries = new Map();
|
|
5
|
+
|
|
6
|
+
for (const rawLine of content.split(/\r?\n/)) {
|
|
7
|
+
const line = rawLine.trim();
|
|
8
|
+
if (!line || line.startsWith("#")) {
|
|
9
|
+
continue;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const withoutExport = line.startsWith("export ") ? line.slice(7) : line;
|
|
13
|
+
const separatorIndex = withoutExport.indexOf("=");
|
|
14
|
+
const key = separatorIndex === -1
|
|
15
|
+
? withoutExport.trim()
|
|
16
|
+
: withoutExport.slice(0, separatorIndex).trim();
|
|
17
|
+
|
|
18
|
+
if (!key) {
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const value = separatorIndex === -1 ? "" : withoutExport.slice(separatorIndex + 1).trim();
|
|
23
|
+
entries.set(key, stripWrappingQuotes(value));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return entries;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function parseExampleFile(content) {
|
|
30
|
+
const requiredEntries = new Map();
|
|
31
|
+
const optionalEntries = new Map();
|
|
32
|
+
|
|
33
|
+
for (const rawLine of content.split(/\r?\n/)) {
|
|
34
|
+
const line = rawLine.trim();
|
|
35
|
+
if (!line || line.startsWith("#")) {
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const withoutExport = line.startsWith("export ") ? line.slice(7) : line;
|
|
40
|
+
const optional = /(^\?[\w.-]+\s*=)|(\s+#\s*optional\s*$)/i.test(withoutExport);
|
|
41
|
+
const normalizedLine = withoutExport
|
|
42
|
+
.replace(/^\?/, "")
|
|
43
|
+
.replace(/\s+#\s*optional\s*$/i, "");
|
|
44
|
+
|
|
45
|
+
const separatorIndex = normalizedLine.indexOf("=");
|
|
46
|
+
const key = separatorIndex === -1
|
|
47
|
+
? normalizedLine.trim()
|
|
48
|
+
: normalizedLine.slice(0, separatorIndex).trim();
|
|
49
|
+
|
|
50
|
+
if (!key) {
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const value = separatorIndex === -1 ? "" : normalizedLine.slice(separatorIndex + 1).trim();
|
|
55
|
+
const targetMap = optional ? optionalEntries : requiredEntries;
|
|
56
|
+
targetMap.set(key, stripWrappingQuotes(value));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
requiredEntries,
|
|
61
|
+
optionalEntries,
|
|
62
|
+
allEntries: new Map([...requiredEntries, ...optionalEntries])
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function stripWrappingQuotes(value) {
|
|
67
|
+
if (value.length >= 2) {
|
|
68
|
+
const first = value[0];
|
|
69
|
+
const last = value[value.length - 1];
|
|
70
|
+
if ((first === "\"" && last === "\"") || (first === "'" && last === "'")) {
|
|
71
|
+
return value.slice(1, -1);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return value;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function loadEnvFile(filePath) {
|
|
79
|
+
return parseEnvFile(fs.readFileSync(filePath, "utf8"));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function loadExampleFile(filePath) {
|
|
83
|
+
return parseExampleFile(fs.readFileSync(filePath, "utf8"));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function compareEnv(exampleEntries, targetEntries, options = {}) {
|
|
87
|
+
const exampleSpec = normalizeExampleSpec(exampleEntries);
|
|
88
|
+
const requiredKeys = [...exampleSpec.requiredEntries.keys()];
|
|
89
|
+
const targetKeys = new Set(targetEntries.keys());
|
|
90
|
+
const missing = [];
|
|
91
|
+
const empty = [];
|
|
92
|
+
const optional = [...exampleSpec.optionalEntries.keys()].sort();
|
|
93
|
+
|
|
94
|
+
for (const key of requiredKeys) {
|
|
95
|
+
if (!targetKeys.has(key)) {
|
|
96
|
+
missing.push(key);
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (targetEntries.get(key) === "") {
|
|
101
|
+
empty.push(key);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const extra = options.allowExtra
|
|
106
|
+
? []
|
|
107
|
+
: [...targetKeys].filter((key) => !exampleSpec.allEntries.has(key)).sort();
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
missing,
|
|
111
|
+
empty,
|
|
112
|
+
extra,
|
|
113
|
+
optional,
|
|
114
|
+
ok: missing.length === 0 && empty.length === 0 && extra.length === 0
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function normalizeExampleSpec(exampleEntries) {
|
|
119
|
+
if (exampleEntries?.requiredEntries && exampleEntries?.optionalEntries && exampleEntries?.allEntries) {
|
|
120
|
+
return exampleEntries;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
requiredEntries: exampleEntries,
|
|
125
|
+
optionalEntries: new Map(),
|
|
126
|
+
allEntries: exampleEntries
|
|
127
|
+
};
|
|
128
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { compareEnv, loadEnvFile, loadExampleFile } from "./check.js";
|
|
4
|
+
|
|
5
|
+
const HELP_TEXT = `safe-dotenv-check
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
safe-dotenv-check --example .env.example --env .env
|
|
9
|
+
safe-dotenv-check --example .env.example --env .env --env .env.production
|
|
10
|
+
safe-dotenv-check --example .env.example --env .env --allow-extra
|
|
11
|
+
safe-dotenv-check --example .env.example --env .env --format json
|
|
12
|
+
|
|
13
|
+
Options:
|
|
14
|
+
--example <path> Required manifest file, usually .env.example
|
|
15
|
+
--env <path> Target .env file to verify, repeatable
|
|
16
|
+
--allow-extra Ignore keys that exist only in target files
|
|
17
|
+
--format <type> Output format: text or json
|
|
18
|
+
--help Show this message
|
|
19
|
+
|
|
20
|
+
Example optional keys:
|
|
21
|
+
?SENTRY_DSN=
|
|
22
|
+
REDIS_URL= # optional
|
|
23
|
+
`;
|
|
24
|
+
|
|
25
|
+
export function runCli(argv, stdout, stderr) {
|
|
26
|
+
const parsed = parseArgs(argv);
|
|
27
|
+
|
|
28
|
+
if (parsed.help) {
|
|
29
|
+
stdout.write(`${HELP_TEXT}\n`);
|
|
30
|
+
return 0;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (parsed.error) {
|
|
34
|
+
stderr.write(`error: ${parsed.error}\n`);
|
|
35
|
+
stderr.write("run with --help for usage\n");
|
|
36
|
+
return 2;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
const exampleEntries = loadExampleFile(parsed.examplePath);
|
|
41
|
+
let allOk = true;
|
|
42
|
+
const reports = [];
|
|
43
|
+
|
|
44
|
+
for (const envPath of parsed.envPaths) {
|
|
45
|
+
const targetEntries = loadEnvFile(envPath);
|
|
46
|
+
const result = compareEnv(exampleEntries, targetEntries, {
|
|
47
|
+
allowExtra: parsed.allowExtra
|
|
48
|
+
});
|
|
49
|
+
reports.push({
|
|
50
|
+
file: envPath,
|
|
51
|
+
...result
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
if (!result.ok) {
|
|
55
|
+
allOk = false;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (parsed.format === "json") {
|
|
60
|
+
stdout.write(`${JSON.stringify({
|
|
61
|
+
ok: allOk,
|
|
62
|
+
example: parsed.examplePath,
|
|
63
|
+
files: reports
|
|
64
|
+
}, null, 2)}\n`);
|
|
65
|
+
} else {
|
|
66
|
+
for (const report of reports) {
|
|
67
|
+
if (report.ok) {
|
|
68
|
+
stdout.write(`PASS ${report.file}\n`);
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
stdout.write(`FAIL ${report.file}\n`);
|
|
73
|
+
writeList(stdout, "missing", report.missing);
|
|
74
|
+
writeList(stdout, "empty", report.empty);
|
|
75
|
+
writeList(stdout, "extra", report.extra);
|
|
76
|
+
writeList(stdout, "optional", report.optional);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return allOk ? 0 : 1;
|
|
81
|
+
} catch (error) {
|
|
82
|
+
stderr.write(`error: ${error.message}\n`);
|
|
83
|
+
return 2;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function parseArgs(argv) {
|
|
88
|
+
const envPaths = [];
|
|
89
|
+
let examplePath = "";
|
|
90
|
+
let allowExtra = false;
|
|
91
|
+
let format = "text";
|
|
92
|
+
let help = false;
|
|
93
|
+
|
|
94
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
95
|
+
const arg = argv[index];
|
|
96
|
+
|
|
97
|
+
if (arg === "--help" || arg === "-h") {
|
|
98
|
+
help = true;
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (arg === "--allow-extra") {
|
|
103
|
+
allowExtra = true;
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (arg === "--format") {
|
|
108
|
+
format = argv[index + 1] ?? "";
|
|
109
|
+
index += 1;
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (arg === "--example") {
|
|
114
|
+
examplePath = argv[index + 1] ?? "";
|
|
115
|
+
index += 1;
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (arg === "--env") {
|
|
120
|
+
const envPath = argv[index + 1] ?? "";
|
|
121
|
+
envPaths.push(envPath);
|
|
122
|
+
index += 1;
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return { error: `unknown argument: ${arg}` };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (!help) {
|
|
130
|
+
if (!examplePath) {
|
|
131
|
+
return { error: "--example is required" };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (envPaths.length === 0) {
|
|
135
|
+
return { error: "at least one --env is required" };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (!["text", "json"].includes(format)) {
|
|
139
|
+
return { error: "--format must be either text or json" };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const filePaths = [examplePath, ...envPaths];
|
|
143
|
+
for (const filePath of filePaths) {
|
|
144
|
+
if (!filePath) {
|
|
145
|
+
return { error: "missing value for --example or --env" };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const resolvedPath = path.resolve(filePath);
|
|
149
|
+
if (!fs.existsSync(resolvedPath)) {
|
|
150
|
+
return { error: `file not found: ${filePath}` };
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
allowExtra,
|
|
157
|
+
envPaths,
|
|
158
|
+
examplePath,
|
|
159
|
+
format,
|
|
160
|
+
help
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function writeList(stdout, label, values) {
|
|
165
|
+
if (values.length > 0) {
|
|
166
|
+
stdout.write(` ${label}: ${values.join(", ")}\n`);
|
|
167
|
+
}
|
|
168
|
+
}
|