dotenv-gad 1.2.0 → 1.2.2
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 +76 -0
- package/dist/cli/commands/sync.js +19 -0
- package/dist/cli/commands/utils.js +19 -12
- package/dist/types/schema.d.ts +1 -0
- package/dist/types/utils.d.ts +2 -0
- package/dist/types/validator.d.ts +4 -0
- package/dist/validator.js +124 -17
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://badge.fury.io/js/dotenv-gad)
|
|
4
4
|
[](https://opensource.org/licenses/MIT)
|
|
5
|
+
[](https://kasimlyee.github.io/dotenv-gad/latest/)
|
|
5
6
|
|
|
6
7
|
**dotenv-gad** is an environment variable validation tool that brings type safety and schema validation to your Node.js and JavaScript applications. It extends `dotenv` with features like:
|
|
7
8
|
|
|
@@ -54,6 +55,23 @@ const env = loadEnv(schema);
|
|
|
54
55
|
console.log(`Server running on port ${env.PORT}`);
|
|
55
56
|
```
|
|
56
57
|
|
|
58
|
+
Documentation
|
|
59
|
+
|
|
60
|
+
[](https://kasimlyee.github.io/dotenv-gad/latest/)
|
|
61
|
+
|
|
62
|
+
Full documentation is available via GitHub Pages (published from `docs/`).
|
|
63
|
+
|
|
64
|
+
To preview locally:
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
npm ci
|
|
68
|
+
npm run docs:serve
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Docs preview on PRs
|
|
72
|
+
|
|
73
|
+
When you open or update a pull request that changes docs, an automated preview will be published to GitHub Pages under `previews/pr-<number>/` and a comment with the preview link will be posted on the PR. This makes it easy to review documentation changes without merging.
|
|
74
|
+
|
|
57
75
|
## CLI Commands
|
|
58
76
|
|
|
59
77
|
| Command | Description |
|
|
@@ -159,6 +177,23 @@ Environment validation failed:
|
|
|
159
177
|
- API_KEY: Must start with 'sk_' (received: "invalid")
|
|
160
178
|
```
|
|
161
179
|
|
|
180
|
+
By default values in the report are redacted (sensitive values are always masked). You can opt-in to include raw values in error reports when instantiating the validator (useful for local debugging) by using the `includeRaw` option. If you also want to reveal values marked as `sensitive: true` set `includeSensitive` to `true` (use with caution).
|
|
181
|
+
|
|
182
|
+
```ts
|
|
183
|
+
// include raw values in errors (non-sensitive values only)
|
|
184
|
+
import { loadEnv } from "dotenv-gad";
|
|
185
|
+
const env = loadEnv(schema, { includeRaw: true });
|
|
186
|
+
|
|
187
|
+
// or with finer control
|
|
188
|
+
import { EnvValidator } from "dotenv-gad";
|
|
189
|
+
const validator = new EnvValidator(schema, { includeRaw: true, includeSensitive: true });
|
|
190
|
+
try {
|
|
191
|
+
validator.validate(process.env);
|
|
192
|
+
} catch (err) {
|
|
193
|
+
console.error(String(err));
|
|
194
|
+
}
|
|
195
|
+
```
|
|
196
|
+
|
|
162
197
|
## more usages
|
|
163
198
|
|
|
164
199
|
### Environment-Specific Rules
|
|
@@ -198,6 +233,47 @@ Environment validation failed:
|
|
|
198
233
|
}
|
|
199
234
|
```
|
|
200
235
|
|
|
236
|
+
### Grouping / Namespaced envs
|
|
237
|
+
|
|
238
|
+
You can group related environment variables into a single object using `object` with `properties` and an optional `envPrefix` (defaults to `KEY_`):
|
|
239
|
+
|
|
240
|
+
```ts
|
|
241
|
+
const schema = defineSchema({
|
|
242
|
+
DATABASE: {
|
|
243
|
+
type: 'object',
|
|
244
|
+
envPrefix: 'DATABASE_', // optional; defaults to 'DATABASE_'
|
|
245
|
+
properties: {
|
|
246
|
+
DB_NAME: { type: 'string', required: true },
|
|
247
|
+
PORT: { type: 'port', default: 5432 },
|
|
248
|
+
PWD: { type: 'string', sensitive: true }
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
Given the following environment:
|
|
255
|
+
|
|
256
|
+
```
|
|
257
|
+
DATABASE_DB_NAME=mydb
|
|
258
|
+
DATABASE_PORT=5432
|
|
259
|
+
DATABASE_PWD=supersecret
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
`loadEnv(schema)` will return:
|
|
263
|
+
|
|
264
|
+
```ts
|
|
265
|
+
{ DATABASE: { DB_NAME: 'mydb', PORT: 5432, PWD: 'supersecret' } }
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
Notes and behavior:
|
|
269
|
+
|
|
270
|
+
- The default `envPrefix` is `${KEY}_` (for `DATABASE` it's `DATABASE_`) if you don't specify `envPrefix`.
|
|
271
|
+
- Prefixed variables take precedence over a JSON top-level env var (e.g., `DATABASE` = '{...}'). If both are present, prefixed variables win and a warning is printed.
|
|
272
|
+
- In strict mode (`{ strict: true }`), unexpected subkeys inside a group (e.g., `DATABASE_EXTRA`) will cause validation to fail.
|
|
273
|
+
- `sensitive` and `includeRaw` behavior still applies for grouped properties: sensitive properties are still masked in errors unless `includeSensitive` is explicitly set.
|
|
274
|
+
|
|
275
|
+
The CLI `sync` command will now generate grouped entries in `.env.example` for object properties so it's easier to scaffold grouped configuration.
|
|
276
|
+
|
|
201
277
|
## License
|
|
202
278
|
|
|
203
279
|
MIT © [Kasim Lyee]
|
|
@@ -16,6 +16,25 @@ export default function (program) {
|
|
|
16
16
|
Object.entries(schema).forEach(([key, rule]) => {
|
|
17
17
|
if (rule.sensitive)
|
|
18
18
|
return;
|
|
19
|
+
// If this is a grouped object with properties, emit grouped entries
|
|
20
|
+
const eff = rule;
|
|
21
|
+
if (eff.type === "object" && eff.properties) {
|
|
22
|
+
const prefix = eff.envPrefix || `${key}_`;
|
|
23
|
+
exampleContent += `# ${eff.docs || "No description available"}\n`;
|
|
24
|
+
exampleContent += `# Group: ${key} (prefix=${prefix})\n`;
|
|
25
|
+
Object.entries(eff.properties).forEach(([prop, pRule]) => {
|
|
26
|
+
const pr = pRule;
|
|
27
|
+
if (pr.sensitive)
|
|
28
|
+
return;
|
|
29
|
+
exampleContent += `# ${pr.docs || "No description available"}\n`;
|
|
30
|
+
exampleContent += `# Type: ${pr.type}\n`;
|
|
31
|
+
if (pr.default !== undefined) {
|
|
32
|
+
exampleContent += `# Default: ${JSON.stringify(pr.default)}\n`;
|
|
33
|
+
}
|
|
34
|
+
exampleContent += `${prefix}${prop}=${pr.default ? JSON.stringify(pr.default) : ""}\n\n`;
|
|
35
|
+
});
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
19
38
|
exampleContent += `# ${rule.docs || "No description available"}\n`;
|
|
20
39
|
exampleContent += `# Type: ${rule.type}\n`;
|
|
21
40
|
if (rule.default !== undefined) {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { readFileSync, writeFileSync, unlinkSync } from "fs";
|
|
2
|
-
import { dirname, join } from "path";
|
|
3
|
-
import { fileURLToPath } from "url";
|
|
1
|
+
import { readFileSync, writeFileSync, unlinkSync, existsSync } from "fs";
|
|
2
|
+
import { dirname, join, resolve } from "path";
|
|
3
|
+
import { fileURLToPath, pathToFileURL } from "url";
|
|
4
4
|
import { transformSync } from "esbuild";
|
|
5
5
|
import Chalk from "chalk";
|
|
6
6
|
import inquirer from "inquirer";
|
|
@@ -12,8 +12,13 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
|
12
12
|
* @throws If the schema file is malformed or cannot be loaded.
|
|
13
13
|
*/
|
|
14
14
|
export async function loadSchema(schemaPath) {
|
|
15
|
+
const absPath = resolve(schemaPath);
|
|
16
|
+
const importModule = async (filePath) => {
|
|
17
|
+
const fileUrl = pathToFileURL(filePath).href;
|
|
18
|
+
return (await import(`${fileUrl}?t=${Date.now()}`)).default;
|
|
19
|
+
};
|
|
15
20
|
const loadTsModule = async (tsFilePath) => {
|
|
16
|
-
const tempFile = join(__dirname,
|
|
21
|
+
const tempFile = join(__dirname, `../../temp-schema-${Date.now()}.mjs`);
|
|
17
22
|
try {
|
|
18
23
|
const tsCode = readFileSync(tsFilePath, "utf-8");
|
|
19
24
|
const { code } = transformSync(tsCode, {
|
|
@@ -22,21 +27,23 @@ export async function loadSchema(schemaPath) {
|
|
|
22
27
|
target: "esnext",
|
|
23
28
|
});
|
|
24
29
|
writeFileSync(tempFile, code);
|
|
25
|
-
return
|
|
30
|
+
return await importModule(tempFile);
|
|
26
31
|
}
|
|
27
32
|
finally {
|
|
28
|
-
|
|
33
|
+
if (existsSync(tempFile)) {
|
|
34
|
+
unlinkSync(tempFile);
|
|
35
|
+
}
|
|
29
36
|
}
|
|
30
37
|
};
|
|
31
38
|
try {
|
|
32
|
-
if (
|
|
33
|
-
return await loadTsModule(
|
|
39
|
+
if (absPath.endsWith(".ts")) {
|
|
40
|
+
return await loadTsModule(absPath);
|
|
34
41
|
}
|
|
35
|
-
else if (
|
|
36
|
-
return
|
|
42
|
+
else if (absPath.endsWith(".js")) {
|
|
43
|
+
return await importModule(absPath);
|
|
37
44
|
}
|
|
38
|
-
else if (
|
|
39
|
-
return JSON.parse(readFileSync(
|
|
45
|
+
else if (absPath.endsWith(".json")) {
|
|
46
|
+
return JSON.parse(readFileSync(absPath, "utf-8"));
|
|
40
47
|
}
|
|
41
48
|
throw new Error(`Unsupported schema format. Use .ts, .js or .json`);
|
|
42
49
|
}
|
package/dist/types/schema.d.ts
CHANGED
package/dist/types/utils.d.ts
CHANGED
|
@@ -10,6 +10,8 @@ import { SchemaDefinition } from "./schema.js";
|
|
|
10
10
|
*/
|
|
11
11
|
export declare function loadEnv(schema: SchemaDefinition, options?: {
|
|
12
12
|
strict?: boolean;
|
|
13
|
+
includeRaw?: boolean;
|
|
14
|
+
includeSensitive?: boolean;
|
|
13
15
|
}): Record<string, any>;
|
|
14
16
|
/**
|
|
15
17
|
* Create a proxy around the validated environment variables. The proxy will
|
|
@@ -11,8 +11,12 @@ export declare class EnvValidator {
|
|
|
11
11
|
*/
|
|
12
12
|
constructor(schema: SchemaDefinition, options?: {
|
|
13
13
|
strict?: boolean;
|
|
14
|
+
includeRaw?: boolean;
|
|
15
|
+
includeSensitive?: boolean;
|
|
14
16
|
} | undefined);
|
|
15
17
|
validate(env: Record<string, string | undefined>): Record<string, any>;
|
|
18
|
+
private redactValue;
|
|
19
|
+
private tryParseJSON;
|
|
16
20
|
private validateKey;
|
|
17
21
|
private getEffectiveRule;
|
|
18
22
|
}
|
package/dist/validator.js
CHANGED
|
@@ -16,16 +16,85 @@ export class EnvValidator {
|
|
|
16
16
|
validate(env) {
|
|
17
17
|
this.errors = [];
|
|
18
18
|
const result = {};
|
|
19
|
-
for
|
|
19
|
+
// Build grouping map for object types that support envPrefix.
|
|
20
|
+
// We'll collect all prefixes first and then make a single pass over env keys
|
|
21
|
+
// to assemble grouped objects for each schema key.
|
|
22
|
+
const groupedEnv = {};
|
|
23
|
+
const prefixes = [];
|
|
24
|
+
for (const [k, r] of Object.entries(this.schema)) {
|
|
25
|
+
const eff = this.getEffectiveRule(k, r);
|
|
26
|
+
if (eff.type === "object" && eff.properties) {
|
|
27
|
+
const prefix = eff.envPrefix ?? `${k}_`;
|
|
28
|
+
prefixes.push({ key: k, prefix });
|
|
29
|
+
groupedEnv[k] = {};
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
const envKeys = Object.keys(env);
|
|
33
|
+
for (let i = 0; i < envKeys.length; i++) {
|
|
34
|
+
const eKey = envKeys[i];
|
|
35
|
+
for (let j = 0; j < prefixes.length; j++) {
|
|
36
|
+
const { key, prefix } = prefixes[j];
|
|
37
|
+
if (eKey.startsWith(prefix)) {
|
|
38
|
+
const subKey = eKey.slice(prefix.length);
|
|
39
|
+
groupedEnv[key][subKey] = env[eKey];
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
// Micro-optimization: avoid creating intermediate arrays from Object.entries
|
|
44
|
+
const schemaKeys = Object.keys(this.schema);
|
|
45
|
+
for (let i = 0; i < schemaKeys.length; i++) {
|
|
46
|
+
const key = schemaKeys[i];
|
|
47
|
+
const rule = this.schema[key];
|
|
20
48
|
try {
|
|
21
|
-
|
|
49
|
+
// If we have grouped values for this key use them (preferred over JSON string)
|
|
50
|
+
const valToValidate = groupedEnv[key] && Object.keys(groupedEnv[key]).length > 0
|
|
51
|
+
? groupedEnv[key]
|
|
52
|
+
: env[key];
|
|
53
|
+
// If both grouped and a top-level JSON value exist, prefer grouped and warn
|
|
54
|
+
if (groupedEnv[key] && Object.keys(groupedEnv[key]).length > 0 && env[key] !== undefined) {
|
|
55
|
+
console.warn(`Both prefixed variables and top-level ${key} exist; prefixed vars are used`);
|
|
56
|
+
}
|
|
57
|
+
// If strict mode is enabled, and this key has grouped env vars, ensure there are no unexpected subkeys
|
|
58
|
+
if (this.options?.strict && groupedEnv[key] && Object.keys(groupedEnv[key]).length > 0) {
|
|
59
|
+
const propNames = rule.properties ? Object.keys(rule.properties) : [];
|
|
60
|
+
const extras = Object.keys(groupedEnv[key]).filter((s) => !propNames.includes(s));
|
|
61
|
+
if (extras.length > 0) {
|
|
62
|
+
this.errors.push({
|
|
63
|
+
key,
|
|
64
|
+
message: `Unexpected grouped environment variables: ${extras.join(", ")}`,
|
|
65
|
+
value: Object.keys(groupedEnv[key]),
|
|
66
|
+
rule,
|
|
67
|
+
});
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
result[key] = this.validateKey(key, rule, valToValidate);
|
|
22
72
|
}
|
|
23
73
|
catch (error) {
|
|
24
74
|
if (error instanceof Error) {
|
|
75
|
+
// Decide what to include in the error report depending on options:
|
|
76
|
+
// - default: redact sensitive values and shorten long strings
|
|
77
|
+
// - includeRaw: include raw values for non-sensitive fields
|
|
78
|
+
// - includeSensitive: when used with includeRaw, include raw sensitive values too (use with caution)
|
|
79
|
+
let displayedValue;
|
|
80
|
+
if (env[key] === undefined) {
|
|
81
|
+
displayedValue = undefined;
|
|
82
|
+
}
|
|
83
|
+
else if (this.options?.includeRaw) {
|
|
84
|
+
if (rule.sensitive && !this.options?.includeSensitive) {
|
|
85
|
+
displayedValue = "****";
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
displayedValue = env[key];
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
displayedValue = this.redactValue(env[key], rule.sensitive);
|
|
93
|
+
}
|
|
25
94
|
this.errors.push({
|
|
26
95
|
key,
|
|
27
96
|
message: error.message,
|
|
28
|
-
value:
|
|
97
|
+
value: displayedValue,
|
|
29
98
|
rule,
|
|
30
99
|
});
|
|
31
100
|
}
|
|
@@ -36,13 +105,49 @@ export class EnvValidator {
|
|
|
36
105
|
throw new AggregateError(this.errors, `Environment validation failed: ${keys}`);
|
|
37
106
|
}
|
|
38
107
|
if (this.options?.strict) {
|
|
39
|
-
const extraVars =
|
|
108
|
+
const extraVars = [];
|
|
109
|
+
for (const k in env) {
|
|
110
|
+
if (!(k in this.schema))
|
|
111
|
+
extraVars.push(k);
|
|
112
|
+
}
|
|
40
113
|
if (extraVars.length > 0) {
|
|
41
114
|
throw new Error(`Unexpected environment variables: ${extraVars.join(", ")}`);
|
|
42
115
|
}
|
|
43
116
|
}
|
|
44
117
|
return result;
|
|
45
118
|
}
|
|
119
|
+
// Redact or trim sensitive values for error reporting
|
|
120
|
+
redactValue(value, sensitive) {
|
|
121
|
+
if (value === undefined)
|
|
122
|
+
return undefined;
|
|
123
|
+
if (sensitive)
|
|
124
|
+
return "****";
|
|
125
|
+
if (typeof value !== "string")
|
|
126
|
+
return value;
|
|
127
|
+
if (value.length > 64) {
|
|
128
|
+
return `${value.slice(0, 4)}...${value.slice(-4)}`;
|
|
129
|
+
}
|
|
130
|
+
return value;
|
|
131
|
+
}
|
|
132
|
+
// Try to quickly determine if a string *might* be JSON before parsing to avoid
|
|
133
|
+
// costly exceptions in the hot path for clearly non-JSON values.
|
|
134
|
+
tryParseJSON(value) {
|
|
135
|
+
if (typeof value !== "string")
|
|
136
|
+
return { ok: false };
|
|
137
|
+
const s = value.trim();
|
|
138
|
+
if (!s)
|
|
139
|
+
return { ok: false };
|
|
140
|
+
const c = s[0];
|
|
141
|
+
if (c !== "{" && c !== "[" && c !== '"' && c !== "t" && c !== "f" && c !== "n" && (c < "0" || c > "9") && c !== "-") {
|
|
142
|
+
return { ok: false };
|
|
143
|
+
}
|
|
144
|
+
try {
|
|
145
|
+
return { ok: true, value: JSON.parse(s) };
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
return { ok: false };
|
|
149
|
+
}
|
|
150
|
+
}
|
|
46
151
|
validateKey(key, rule, value) {
|
|
47
152
|
const effectiveRule = this.getEffectiveRule(key, rule);
|
|
48
153
|
if (value === undefined || value === "") {
|
|
@@ -122,23 +227,23 @@ export class EnvValidator {
|
|
|
122
227
|
if (port < 1 || port > 65535) {
|
|
123
228
|
throw new Error("Must be between 1 and 65535");
|
|
124
229
|
}
|
|
230
|
+
value = port;
|
|
125
231
|
break;
|
|
126
232
|
case "json":
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
catch {
|
|
233
|
+
// fast-path non-json strings
|
|
234
|
+
const maybeJson = this.tryParseJSON(value);
|
|
235
|
+
if (!maybeJson.ok) {
|
|
131
236
|
throw new Error("Must be valid JSON");
|
|
132
237
|
}
|
|
238
|
+
value = maybeJson.value;
|
|
133
239
|
break;
|
|
134
240
|
case "array":
|
|
135
241
|
if (!Array.isArray(value)) {
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
}
|
|
139
|
-
catch {
|
|
242
|
+
const parsed = this.tryParseJSON(value);
|
|
243
|
+
if (!parsed.ok || !Array.isArray(parsed.value)) {
|
|
140
244
|
throw new Error("Must be a valid array or JSON array string");
|
|
141
245
|
}
|
|
246
|
+
value = parsed.value;
|
|
142
247
|
}
|
|
143
248
|
if (effectiveRule.items) {
|
|
144
249
|
value = value.map((item, i) => {
|
|
@@ -153,16 +258,18 @@ export class EnvValidator {
|
|
|
153
258
|
break;
|
|
154
259
|
case "object":
|
|
155
260
|
if (typeof value === "string") {
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
}
|
|
159
|
-
catch {
|
|
261
|
+
const parsed = this.tryParseJSON(value);
|
|
262
|
+
if (!parsed.ok || typeof parsed.value !== "object" || Array.isArray(parsed.value)) {
|
|
160
263
|
throw new Error("Must be a valid object or JSON string");
|
|
161
264
|
}
|
|
265
|
+
value = parsed.value;
|
|
162
266
|
}
|
|
163
267
|
if (effectiveRule.properties) {
|
|
164
268
|
const obj = {};
|
|
165
|
-
for (const
|
|
269
|
+
for (const prop in effectiveRule.properties) {
|
|
270
|
+
if (!Object.prototype.hasOwnProperty.call(effectiveRule.properties, prop))
|
|
271
|
+
continue;
|
|
272
|
+
const propRule = effectiveRule.properties[prop];
|
|
166
273
|
try {
|
|
167
274
|
obj[prop] = this.validateKey(`${key}.${prop}`, propRule, value[prop]);
|
|
168
275
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dotenv-gad",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.2",
|
|
4
4
|
"main": "dist/index.js",
|
|
5
5
|
"types": "dist/types/index.d.ts",
|
|
6
6
|
"type": "module",
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
"lint:fix": "eslint src --ext .ts --fix",
|
|
18
18
|
"format": "prettier --write \"src/**/*.ts\" \"__tests__/**/*.ts\"",
|
|
19
19
|
"format:check": "prettier --check \"src/**/*.ts\" \"__tests__/**/*.ts\"",
|
|
20
|
+
"bench": "node benchmarks/validate-bench.js",
|
|
20
21
|
"prepublishOnly": "npm run build && npm test",
|
|
21
22
|
"prepare": "husky"
|
|
22
23
|
},
|