dotenv-gad 1.4.2 → 1.6.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.
Files changed (42) hide show
  1. package/README.md +81 -11
  2. package/dist/cli/commands/check.js +10 -2
  3. package/dist/cli/commands/decrypt.js +126 -0
  4. package/dist/cli/commands/docs.js +2 -2
  5. package/dist/cli/commands/encrypt.js +100 -0
  6. package/dist/cli/commands/fix.js +16 -12
  7. package/dist/cli/commands/init.js +17 -12
  8. package/dist/cli/commands/keygen.js +97 -0
  9. package/dist/cli/commands/rotate.js +160 -0
  10. package/dist/cli/commands/status.js +116 -0
  11. package/dist/cli/commands/sync.js +15 -17
  12. package/dist/cli/commands/utils.js +40 -28
  13. package/dist/cli/commands/verify.js +101 -0
  14. package/dist/cli/index.js +12 -0
  15. package/dist/crypto.js +165 -0
  16. package/dist/errors.js +92 -4
  17. package/dist/index.cjs +408 -26
  18. package/dist/index.js +6 -4
  19. package/dist/runtime.js +43 -0
  20. package/dist/schema-loader.js +12 -3
  21. package/dist/types/cli/commands/check.d.ts +1 -1
  22. package/dist/types/cli/commands/decrypt.d.ts +2 -0
  23. package/dist/types/cli/commands/docs.d.ts +1 -1
  24. package/dist/types/cli/commands/encrypt.d.ts +2 -0
  25. package/dist/types/cli/commands/fix.d.ts +1 -1
  26. package/dist/types/cli/commands/init.d.ts +1 -1
  27. package/dist/types/cli/commands/keygen.d.ts +2 -0
  28. package/dist/types/cli/commands/rotate.d.ts +2 -0
  29. package/dist/types/cli/commands/status.d.ts +2 -0
  30. package/dist/types/cli/commands/sync.d.ts +1 -1
  31. package/dist/types/cli/commands/verify.d.ts +2 -0
  32. package/dist/types/crypto.d.ts +58 -0
  33. package/dist/types/errors.d.ts +35 -12
  34. package/dist/types/index.d.ts +8 -3
  35. package/dist/types/runtime.d.ts +29 -0
  36. package/dist/types/schema-loader.d.ts +5 -2
  37. package/dist/types/schema.d.ts +7 -0
  38. package/dist/types/utils.d.ts +21 -10
  39. package/dist/types/validator.d.ts +40 -0
  40. package/dist/utils.js +50 -13
  41. package/dist/validator.js +164 -19
  42. package/package.json +6 -4
package/dist/validator.js CHANGED
@@ -1,20 +1,39 @@
1
- import { EnvAggregateError } from "./errors.js";
1
+ import { EnvAggregateError, EncryptionKeyMissingError } from "./errors.js";
2
+ import { decryptEnvValue, isEncryptedValue, loadPrivateKey } from "./crypto.js";
3
+ import { getEnv } from "./runtime.js";
4
+ import net from "net";
5
+ const kValidator = Symbol.for("dotenv-gad.EnvValidator");
2
6
  export class EnvValidator {
3
7
  schema;
4
8
  options;
9
+ static EMAIL_REGEX = /^[-!#$%&'*+\/0-9=?A-Z^_a-z`{|}~](\.?[-!#$%&'*+\/0-9=?A-Z^_a-z`{|}~])*@[a-zA-Z0-9](-?\.?[a-zA-Z0-9])*\.[a-zA-Z](-?[a-zA-Z0-9])+$/;
10
+ static LOCAL_MAX = 64;
11
+ static DOMAIN_MAX = 255;
12
+ static DOMAIN_PART_MAX = 63;
5
13
  errors = [];
6
14
  /**
7
15
  * Constructs a new EnvValidator instance.
8
16
  * @param {SchemaDefinition} schema The schema definition for the environment variables.
9
17
  * @param {Object} [options] Optional options for the validation process.
10
18
  * @param {boolean} [options.strict] When true, environment variables not present in the schema will be rejected.
19
+ * @param {boolean} [options.allowPlaintext] When true, fields with `encrypted: true` that have plaintext values emit a warning instead of an error. Useful for gradual migration.
20
+ * @param {string} [options.keysPath] Path to the `.env.keys` file (default: `.env.keys`).
11
21
  */
12
22
  constructor(schema, options) {
13
23
  this.schema = schema;
14
24
  this.options = options;
25
+ Object.defineProperty(this, kValidator, { value: true, enumerable: false });
26
+ }
27
+ /** Bundler-safe instanceof — checks the symbol marker, not the class name. */
28
+ static [Symbol.hasInstance](instance) {
29
+ return (typeof instance === "object" &&
30
+ instance !== null &&
31
+ Object.prototype.hasOwnProperty.call(instance, kValidator));
15
32
  }
16
33
  validate(env) {
17
34
  this.errors = [];
35
+ // First Decrypt encrypted fields and check for schema/value mismatches.
36
+ const { processedEnv, skipKeys } = this.preprocessEncryption(env);
18
37
  const result = {};
19
38
  // Build grouping map for object types that support envPrefix.
20
39
  // We'll collect all prefixes first and then make a single pass over env keys
@@ -29,29 +48,31 @@ export class EnvValidator {
29
48
  groupedEnv[k] = {};
30
49
  }
31
50
  }
32
- const envKeys = Object.keys(env);
51
+ const envKeys = Object.keys(processedEnv);
33
52
  for (let i = 0; i < envKeys.length; i++) {
34
53
  const eKey = envKeys[i];
35
54
  for (let j = 0; j < prefixes.length; j++) {
36
55
  const { key, prefix } = prefixes[j];
37
56
  if (eKey.startsWith(prefix)) {
38
57
  const subKey = eKey.slice(prefix.length);
39
- groupedEnv[key][subKey] = env[eKey];
58
+ groupedEnv[key][subKey] = processedEnv[eKey];
40
59
  }
41
60
  }
42
61
  }
43
- // Micro-optimization: avoid creating intermediate arrays from Object.entries
44
62
  const schemaKeys = Object.keys(this.schema);
45
63
  for (let i = 0; i < schemaKeys.length; i++) {
46
64
  const key = schemaKeys[i];
47
65
  const rule = this.schema[key];
66
+ // Keys that already have a preprocessing error (encryption mismatch, decryption failure, etc.) get skipped
67
+ if (skipKeys.has(key))
68
+ continue;
48
69
  try {
49
70
  // If we have grouped values for this key use them (preferred over JSON string)
50
71
  const valToValidate = groupedEnv[key] && Object.keys(groupedEnv[key]).length > 0
51
72
  ? groupedEnv[key]
52
- : env[key];
73
+ : processedEnv[key];
53
74
  // 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) {
75
+ if (groupedEnv[key] && Object.keys(groupedEnv[key]).length > 0 && processedEnv[key] !== undefined) {
55
76
  console.warn(`Both prefixed variables and top-level ${key} exist; prefixed vars are used`);
56
77
  }
57
78
  // If strict mode is enabled, and this key has grouped env vars, ensure there are no unexpected subkeys
@@ -77,7 +98,7 @@ export class EnvValidator {
77
98
  // - includeRaw: include raw values for non-sensitive fields
78
99
  // - includeSensitive: when used with includeRaw, include raw sensitive values too (use with caution)
79
100
  let displayedValue;
80
- if (env[key] === undefined) {
101
+ if (processedEnv[key] === undefined) {
81
102
  displayedValue = undefined;
82
103
  }
83
104
  else if (this.options?.includeRaw) {
@@ -85,11 +106,11 @@ export class EnvValidator {
85
106
  displayedValue = "****";
86
107
  }
87
108
  else {
88
- displayedValue = env[key];
109
+ displayedValue = processedEnv[key];
89
110
  }
90
111
  }
91
112
  else {
92
- displayedValue = this.redactValue(env[key], rule.sensitive);
113
+ displayedValue = this.redactValue(processedEnv[key], rule.sensitive);
93
114
  }
94
115
  this.errors.push({
95
116
  key,
@@ -123,7 +144,17 @@ export class EnvValidator {
123
144
  }
124
145
  return result;
125
146
  }
126
- // Redact or trim sensitive values for error reporting
147
+ /**
148
+ * Redacts a value to hide its contents, following these rules:
149
+ * - If `value` is undefined, returns undefined.
150
+ * - If `sensitive` is true, returns `"****"`.
151
+ * - If `value` is not a string, returns the original value.
152
+ * - If `value` is a string longer than 64 characters, truncates it to 4 characters
153
+ * at the start and end, and replaces the middle with `"..."`.
154
+ * - Otherwise, returns the original string.
155
+ * @param value The value to redact.
156
+ * @param sensitive If true, redacts the value to `"****"`.
157
+ */
127
158
  redactValue(value, sensitive) {
128
159
  if (value === undefined)
129
160
  return undefined;
@@ -136,8 +167,16 @@ export class EnvValidator {
136
167
  }
137
168
  return value;
138
169
  }
139
- // Try to quickly determine if a string *might* be JSON before parsing to avoid
140
- // costly exceptions in the hot path for clearly non-JSON values.
170
+ /**
171
+ * Tries to parse the given value as a JSON object.
172
+ * Returns `{ ok: true, value: JSON.parse(s) }` if successful,
173
+ * or `{ ok: false }` if not.
174
+ * The following conditions will cause the function to return `{ ok: false }`:
175
+ * - `value` is not a string
176
+ * - `value` is an empty string
177
+ * - `value` does not start with one of the following characters: `{`, `[`, `"`, `t`, `f`, `n`, or a digit
178
+ * - `value` cannot be parsed as a JSON object
179
+ */
141
180
  tryParseJSON(value) {
142
181
  if (typeof value !== "string")
143
182
  return { ok: false };
@@ -162,12 +201,22 @@ export class EnvValidator {
162
201
  throw new Error(`Missing required environment variable`);
163
202
  return effectiveRule.default;
164
203
  }
204
+ if (typeof value === "string") {
205
+ value = value.trim();
206
+ // Re-check emptiness after trim in case the value was only whitespace
207
+ if (value === "") {
208
+ if (effectiveRule.required)
209
+ throw new Error(`Missing required environment variable`);
210
+ return effectiveRule.default;
211
+ }
212
+ }
165
213
  if (effectiveRule.transform) {
166
214
  value = effectiveRule.transform(value);
167
215
  }
168
216
  switch (effectiveRule.type) {
169
217
  case "string":
170
- value = String(value).trim();
218
+ // value is already trimmed above; cast to string for minLength/maxLength checks
219
+ value = String(value);
171
220
  if (effectiveRule.minLength !== undefined && value.length < effectiveRule.minLength) {
172
221
  throw new Error(`Environment variable ${key} must be at least ${effectiveRule.minLength} characters`);
173
222
  }
@@ -190,10 +239,10 @@ export class EnvValidator {
190
239
  case "boolean":
191
240
  if (typeof value === "string") {
192
241
  value = value.toLowerCase();
193
- if (value === "true") {
242
+ if (value === "true" || value === "yes" || value === "1" || value === "on") {
194
243
  value = true;
195
244
  }
196
- else if (value === "false") {
245
+ else if (value === "false" || value === "no" || value === "0" || value === "off") {
197
246
  value = false;
198
247
  }
199
248
  }
@@ -218,12 +267,12 @@ export class EnvValidator {
218
267
  }
219
268
  break;
220
269
  case "email":
221
- if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(String(value))) {
270
+ if (!this.validateEmail(value)) {
222
271
  throw new Error("Must be a valid email");
223
272
  }
224
273
  break;
225
274
  case "ip":
226
- if (!/^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/.test(String(value))) {
275
+ if (!net.isIP(value)) {
227
276
  throw new Error("Must be a valid IP address");
228
277
  }
229
278
  break;
@@ -300,9 +349,105 @@ export class EnvValidator {
300
349
  }
301
350
  return value;
302
351
  }
303
- getEffectiveRule(key, rule) {
304
- const envName = process.env.NODE_ENV || "development";
352
+ getEffectiveRule(_key, rule) {
353
+ const runtimeEnv = getEnv();
354
+ const envName = runtimeEnv.NODE_ENV || "development";
305
355
  const envRule = rule.env?.[envName] || {};
306
356
  return { ...rule, ...envRule };
307
357
  }
358
+ /**
359
+ * Preprocesses environment variables to detect and handle encrypted values.
360
+ * 1. Decrypts any encrypted values using the private key.
361
+ * 2. Checks if any plaintext values are present for fields that should be encrypted.
362
+ * 3. Flags any env value that looks encrypted but the schema doesn't declare encrypted: true.
363
+ * @returns An object containing the processed environment variables and a set of keys that were skipped due to errors.
364
+ */
365
+ preprocessEncryption(env) {
366
+ const processedEnv = { ...env };
367
+ const skipKeys = new Set();
368
+ const encryptedSchemaKeys = Object.keys(this.schema).filter((k) => this.schema[k].encrypted);
369
+ if (encryptedSchemaKeys.length > 0) {
370
+ // Only load the private key when at least one value is already encrypted
371
+ const needsDecryption = encryptedSchemaKeys.some((k) => processedEnv[k] != null && processedEnv[k] !== "" && isEncryptedValue(processedEnv[k]));
372
+ let privateKeyHex = null;
373
+ if (needsDecryption) {
374
+ privateKeyHex = loadPrivateKey({ keysPath: this.options?.keysPath });
375
+ if (!privateKeyHex) {
376
+ throw new EncryptionKeyMissingError("decryption");
377
+ }
378
+ }
379
+ for (const key of encryptedSchemaKeys) {
380
+ const value = processedEnv[key];
381
+ if (value == null || value === "")
382
+ continue; // handled by required check in main loop
383
+ if (isEncryptedValue(value)) {
384
+ try {
385
+ processedEnv[key] = decryptEnvValue(value, privateKeyHex, key);
386
+ }
387
+ catch (err) {
388
+ this.errors.push({
389
+ key,
390
+ message: err instanceof Error ? err.message : "Decryption failed",
391
+ rule: this.schema[key],
392
+ });
393
+ skipKeys.add(key);
394
+ }
395
+ }
396
+ else {
397
+ // Plaintext value for a field that should be encrypted
398
+ if (this.options?.allowPlaintext) {
399
+ console.warn(`[dotenv-gad] "${key}" has a plaintext value but schema declares encrypted: true. ` +
400
+ "Run: npx dotenv-gad encrypt");
401
+ }
402
+ else {
403
+ this.errors.push({
404
+ key,
405
+ message: 'Must be encrypted. Run: npx dotenv-gad encrypt',
406
+ rule: this.schema[key],
407
+ });
408
+ skipKeys.add(key);
409
+ }
410
+ }
411
+ }
412
+ }
413
+ // Flag any env value that looks encrypted but the schema doesn't declare encrypted: true
414
+ for (const [eKey, value] of Object.entries(processedEnv)) {
415
+ if (skipKeys.has(eKey))
416
+ continue;
417
+ if (value && isEncryptedValue(value) && !this.schema[eKey]?.encrypted) {
418
+ this.errors.push({
419
+ key: eKey,
420
+ message: "Encrypted value found but schema does not declare encrypted: true",
421
+ });
422
+ skipKeys.add(eKey);
423
+ }
424
+ }
425
+ return { processedEnv, skipKeys };
426
+ }
427
+ /*
428
+ * Email validation logic adapted from:
429
+ * Project: email-validator
430
+ * * Repository: https://github.com/manishsaraan/email-validator
431
+ *
432
+ * * Adapted to enforce email validation rules more strictly
433
+ */
434
+ validateEmail(email) {
435
+ if (!email)
436
+ return false;
437
+ const emailParts = email.split("@");
438
+ if (emailParts.length !== 2)
439
+ return false;
440
+ const [local, domain] = emailParts;
441
+ if (local.length > EnvValidator.LOCAL_MAX || domain.length > EnvValidator.DOMAIN_MAX)
442
+ return false;
443
+ const domainParts = domain.split(".");
444
+ if (domainParts.some(part => part.length > EnvValidator.DOMAIN_PART_MAX))
445
+ return false;
446
+ return EnvValidator.EMAIL_REGEX.test(email);
447
+ }
308
448
  }
449
+ Object.defineProperty(EnvValidator, "name", {
450
+ value: "EnvValidator",
451
+ configurable: true,
452
+ writable: false,
453
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dotenv-gad",
3
- "version": "1.4.2",
3
+ "version": "1.6.0",
4
4
  "main": "dist/index.js",
5
5
  "types": "dist/types/index.d.ts",
6
6
  "type": "module",
@@ -32,10 +32,12 @@
32
32
  "config",
33
33
  "type-safe",
34
34
  "nodejs",
35
+ "bun",
35
36
  "cli"
36
37
  ],
37
38
  "engines": {
38
- "node": ">=18.0.0"
39
+ "node": ">=18.0.0",
40
+ "bun": ">=1.0.0"
39
41
  },
40
42
  "exports": {
41
43
  ".": {
@@ -63,7 +65,7 @@
63
65
  ],
64
66
  "author": "Kasim Lyee",
65
67
  "license": "MIT",
66
- "description": "Environment variable validation and type safety for Node.js and modern JavaScript applications",
68
+ "description": "Environment variable validation and type safety for Node.js, Bun, and modern JavaScript applications",
67
69
  "repository": {
68
70
  "type": "git",
69
71
  "url": "https://github.com/kasimlyee/dotenv-gad.git"
@@ -96,11 +98,11 @@
96
98
  "esbuild": "^0.27.2"
97
99
  },
98
100
  "dependencies": {
101
+ "@noble/ciphers": "2.2.0",
99
102
  "chalk": "^5.6.2",
100
103
  "commander": "^14.0.2",
101
104
  "dotenv": "^17.2.3",
102
105
  "figlet": "^1.10.0",
103
- "inquirer": "^13.2.1",
104
106
  "ora": "^9.1.0"
105
107
  }
106
108
  }