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 CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  [![npm version](https://badge.fury.io/js/dotenv-gad.svg)](https://badge.fury.io/js/dotenv-gad)
4
4
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
5
+ [![Docs](https://img.shields.io/badge/docs-latest-blue?style=flat-square)](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
+ [![Docs](https://img.shields.io/badge/docs-latest-blue?style=flat-square)](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, "../../temp-schema.mjs");
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 (await import(`${tempFile}?t=${Date.now()}`)).default;
30
+ return await importModule(tempFile);
26
31
  }
27
32
  finally {
28
- unlinkSync(tempFile);
33
+ if (existsSync(tempFile)) {
34
+ unlinkSync(tempFile);
35
+ }
29
36
  }
30
37
  };
31
38
  try {
32
- if (schemaPath.endsWith(".ts")) {
33
- return await loadTsModule(schemaPath);
39
+ if (absPath.endsWith(".ts")) {
40
+ return await loadTsModule(absPath);
34
41
  }
35
- else if (schemaPath.endsWith(".js")) {
36
- return (await import(`${schemaPath}?t=${Date.now()}`)).default;
42
+ else if (absPath.endsWith(".js")) {
43
+ return await importModule(absPath);
37
44
  }
38
- else if (schemaPath.endsWith(".json")) {
39
- return JSON.parse(readFileSync(schemaPath, "utf-8"));
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
  }
@@ -19,6 +19,7 @@ export interface SchemaRule {
19
19
  error?: string;
20
20
  items?: SchemaRule;
21
21
  properties?: Record<string, SchemaRule>;
22
+ envPrefix?: string;
22
23
  env?: {
23
24
  [envName: string]: Partial<SchemaRule>;
24
25
  };
@@ -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 (const [key, rule] of Object.entries(this.schema)) {
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
- result[key] = this.validateKey(key, rule, env[key]);
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: env[key],
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 = Object.keys(env).filter((k) => !(k in this.schema));
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
- try {
128
- value = JSON.parse(value);
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
- try {
137
- value = JSON.parse(value);
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
- try {
157
- value = JSON.parse(value);
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 [prop, propRule] of Object.entries(effectiveRule.properties)) {
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.0",
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
  },