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 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
+ [![CI](https://github.com/eunsujihoon-hub/safe-dotenv-check/actions/workflows/ci.yml/badge.svg)](https://github.com/eunsujihoon-hub/safe-dotenv-check/actions/workflows/ci.yml)
4
+ [![MIT License](https://img.shields.io/badge/license-MIT-green.svg)](./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}"
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { runCli } from "../src/cli.js";
4
+
5
+ const exitCode = runCli(process.argv.slice(2), process.stdout, process.stderr);
6
+ process.exitCode = exitCode;
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
+ }