@karmaniverous/get-dotenv 5.1.0 → 5.2.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.
package/dist/cliHost.mjs CHANGED
@@ -35,6 +35,11 @@ const baseRootOptionDefaults = {
35
35
  dotenvToken: '.env',
36
36
  loadProcess: true,
37
37
  logger: console,
38
+ // Diagnostics defaults
39
+ warnEntropy: true,
40
+ entropyThreshold: 3.8,
41
+ entropyMinLength: 16,
42
+ entropyWhitelist: ['^GIT_', '^npm_', '^CI$', 'SHLVL'],
38
43
  paths: './',
39
44
  pathsDelimiter: ' ',
40
45
  privateToken: 'local',
@@ -55,7 +60,7 @@ const baseRootOptionDefaults = {
55
60
  const baseGetDotenvCliOptions = baseRootOptionDefaults;
56
61
 
57
62
  /** @internal */
58
- const isPlainObject = (value) => value !== null &&
63
+ const isPlainObject$1 = (value) => value !== null &&
59
64
  typeof value === 'object' &&
60
65
  Object.getPrototypeOf(value) === Object.prototype;
61
66
  const mergeInto = (target, source) => {
@@ -63,10 +68,10 @@ const mergeInto = (target, source) => {
63
68
  if (sVal === undefined)
64
69
  continue; // do not overwrite with undefined
65
70
  const tVal = target[key];
66
- if (isPlainObject(tVal) && isPlainObject(sVal)) {
71
+ if (isPlainObject$1(tVal) && isPlainObject$1(sVal)) {
67
72
  target[key] = mergeInto({ ...tVal }, sVal);
68
73
  }
69
- else if (isPlainObject(sVal)) {
74
+ else if (isPlainObject$1(sVal)) {
70
75
  target[key] = mergeInto({}, sVal);
71
76
  }
72
77
  else {
@@ -226,11 +231,12 @@ const getDotenvOptionsSchemaRaw = z.object({
226
231
  const getDotenvOptionsSchemaResolved = getDotenvOptionsSchemaRaw;
227
232
 
228
233
  /**
229
- * Zod schemas for configuration files discovered by the new loader. *
234
+ * Zod schemas for configuration files discovered by the new loader.
235
+ *
230
236
  * Notes:
231
237
  * - RAW: all fields optional; shapes are stringly-friendly (paths may be string[] or string).
232
238
  * - RESOLVED: normalized shapes (paths always string[]).
233
- * - For this step (JSON/YAML only), any defined `dynamic` will be rejected by the loader.
239
+ * - For JSON/YAML configs, the loader rejects "dynamic" and "schema" (JS/TS-only).
234
240
  */
235
241
  // String-only env value map
236
242
  const stringMap = z.record(z.string(), z.string());
@@ -245,6 +251,8 @@ const getDotenvConfigSchemaRaw = z.object({
245
251
  log: z.boolean().optional(),
246
252
  shell: z.union([z.string(), z.boolean()]).optional(),
247
253
  scripts: z.record(z.string(), z.unknown()).optional(), // Scripts validation left wide; generator validates elsewhere
254
+ requiredKeys: z.array(z.string()).optional(),
255
+ schema: z.unknown().optional(), // JS/TS-only; loader rejects in JSON/YAML
248
256
  vars: stringMap.optional(), // public, global
249
257
  envVars: envStringMap.optional(), // public, per-env
250
258
  // Dynamic in config (JS/TS only). JSON/YAML loader will reject if set.
@@ -427,9 +435,11 @@ const loadConfigFile = async (filePath) => {
427
435
  .join('\n');
428
436
  throw new Error(`Invalid config ${filePath}:\n${msgs}`);
429
437
  }
430
- // Disallow dynamic in JSON/YAML; allow in JS/TS
431
- if (!isJsOrTs(filePath) && parsed.data.dynamic !== undefined) {
432
- throw new Error(`Config ${filePath} specifies "dynamic"; JSON/YAML configs cannot include dynamic in this step. Use JS/TS config.`);
438
+ // Disallow dynamic and schema in JSON/YAML; allow both in JS/TS.
439
+ if (!isJsOrTs(filePath) &&
440
+ (parsed.data.dynamic !== undefined || parsed.data.schema !== undefined)) {
441
+ throw new Error(`Config ${filePath} specifies unsupported keys for JSON/YAML. ` +
442
+ `Use JS/TS config for "dynamic" or "schema".`);
433
443
  }
434
444
  return getDotenvConfigSchemaResolved.parse(parsed.data);
435
445
  };
@@ -617,6 +627,78 @@ const overlayEnv = ({ base, env, configs, programmaticVars, }) => {
617
627
  return current;
618
628
  };
619
629
 
630
+ /** src/diagnostics/entropy.ts
631
+ * Entropy diagnostics (presentation-only).
632
+ * - Gated by min length and printable ASCII.
633
+ * - Warn once per key per run when bits/char \>= threshold.
634
+ * - Supports whitelist patterns to suppress known-noise keys.
635
+ */
636
+ const warned = new Set();
637
+ const isPrintableAscii = (s) => /^[\x20-\x7E]+$/.test(s);
638
+ const compile$1 = (patterns) => (patterns ?? []).map((p) => new RegExp(p, 'i'));
639
+ const whitelisted = (key, regs) => regs.some((re) => re.test(key));
640
+ const shannonBitsPerChar = (s) => {
641
+ const freq = new Map();
642
+ for (const ch of s)
643
+ freq.set(ch, (freq.get(ch) ?? 0) + 1);
644
+ const n = s.length;
645
+ let h = 0;
646
+ for (const c of freq.values()) {
647
+ const p = c / n;
648
+ h -= p * Math.log2(p);
649
+ }
650
+ return h;
651
+ };
652
+ /**
653
+ * Maybe emit a one-line entropy warning for a key.
654
+ * Caller supplies an `emit(line)` function; the helper ensures once-per-key.
655
+ */
656
+ const maybeWarnEntropy = (key, value, origin, opts, emit) => {
657
+ if (!opts || opts.warnEntropy === false)
658
+ return;
659
+ if (warned.has(key))
660
+ return;
661
+ const v = value ?? '';
662
+ const minLen = Math.max(0, opts.entropyMinLength ?? 16);
663
+ const threshold = opts.entropyThreshold ?? 3.8;
664
+ if (v.length < minLen)
665
+ return;
666
+ if (!isPrintableAscii(v))
667
+ return;
668
+ const wl = compile$1(opts.entropyWhitelist);
669
+ if (whitelisted(key, wl))
670
+ return;
671
+ const bpc = shannonBitsPerChar(v);
672
+ if (bpc >= threshold) {
673
+ warned.add(key);
674
+ emit(`[entropy] key=${key} score=${bpc.toFixed(2)} len=${String(v.length)} origin=${origin}`);
675
+ }
676
+ };
677
+
678
+ const DEFAULT_PATTERNS = [
679
+ '\\bsecret\\b',
680
+ '\\btoken\\b',
681
+ '\\bpass(word)?\\b',
682
+ '\\bapi[_-]?key\\b',
683
+ '\\bkey\\b',
684
+ ];
685
+ const compile = (patterns) => (patterns && patterns.length > 0 ? patterns : DEFAULT_PATTERNS).map((p) => new RegExp(p, 'i'));
686
+ const shouldRedactKey = (key, regs) => regs.some((re) => re.test(key));
687
+ const MASK = '[redacted]';
688
+ /**
689
+ * Produce a shallow redacted copy of an env-like object for display.
690
+ */
691
+ const redactObject = (obj, opts) => {
692
+ if (!opts?.redact)
693
+ return { ...obj };
694
+ const regs = compile(opts.redactPatterns);
695
+ const out = {};
696
+ for (const [k, v] of Object.entries(obj)) {
697
+ out[k] = v && shouldRedactKey(k, regs) ? MASK : v;
698
+ }
699
+ return out;
700
+ };
701
+
620
702
  /**
621
703
  * Asynchronously read a dotenv file & parse it into an object.
622
704
  *
@@ -866,14 +948,103 @@ const getDotenv = async (options = {}) => {
866
948
  resultDotenv = dotenvForOutput;
867
949
  }
868
950
  // Log result.
869
- if (log)
870
- logger.log(resultDotenv);
951
+ if (log) {
952
+ const redactFlag = options.redact ?? false;
953
+ const redactPatterns = options.redactPatterns ?? undefined;
954
+ const redOpts = {};
955
+ if (redactFlag)
956
+ redOpts.redact = true;
957
+ if (redactFlag && Array.isArray(redactPatterns))
958
+ redOpts.redactPatterns = redactPatterns;
959
+ const bag = redactFlag
960
+ ? redactObject(resultDotenv, redOpts)
961
+ : { ...resultDotenv };
962
+ logger.log(bag);
963
+ // Entropy warnings: once-per-key-per-run (presentation only)
964
+ const warnEntropyVal = options.warnEntropy ?? true;
965
+ const entropyThresholdVal = options
966
+ .entropyThreshold;
967
+ const entropyMinLengthVal = options
968
+ .entropyMinLength;
969
+ const entropyWhitelistVal = options
970
+ .entropyWhitelist;
971
+ const entOpts = {};
972
+ if (typeof warnEntropyVal === 'boolean')
973
+ entOpts.warnEntropy = warnEntropyVal;
974
+ if (typeof entropyThresholdVal === 'number')
975
+ entOpts.entropyThreshold = entropyThresholdVal;
976
+ if (typeof entropyMinLengthVal === 'number')
977
+ entOpts.entropyMinLength = entropyMinLengthVal;
978
+ if (Array.isArray(entropyWhitelistVal))
979
+ entOpts.entropyWhitelist = entropyWhitelistVal;
980
+ for (const [k, v] of Object.entries(resultDotenv)) {
981
+ maybeWarnEntropy(k, v, v !== undefined ? 'dotenv' : 'unset', entOpts, (line) => {
982
+ logger.log(line);
983
+ });
984
+ }
985
+ }
871
986
  // Load process.env.
872
987
  if (loadProcess)
873
988
  Object.assign(process.env, resultDotenv);
874
989
  return resultDotenv;
875
990
  };
876
991
 
992
+ /**
993
+ * Deep interpolation utility for string leaves.
994
+ * - Expands string values using dotenv-style expansion against the provided envRef.
995
+ * - Preserves non-strings as-is.
996
+ * - Does not recurse into arrays (arrays are returned unchanged).
997
+ *
998
+ * Intended for:
999
+ * - Phase C option/config interpolation after composing ctx.dotenv.
1000
+ * - Per-plugin config slice interpolation before afterResolve.
1001
+ */
1002
+ /** @internal */
1003
+ const isPlainObject = (v) => v !== null &&
1004
+ typeof v === 'object' &&
1005
+ !Array.isArray(v) &&
1006
+ Object.getPrototypeOf(v) === Object.prototype;
1007
+ /**
1008
+ * Deeply interpolate string leaves against envRef.
1009
+ * Arrays are not recursed into; they are returned unchanged.
1010
+ *
1011
+ * @typeParam T - Shape of the input value.
1012
+ * @param value - Input value (object/array/primitive).
1013
+ * @param envRef - Reference environment for interpolation.
1014
+ * @returns A new value with string leaves interpolated.
1015
+ */
1016
+ const interpolateDeep = (value, envRef) => {
1017
+ // Strings: expand and return
1018
+ if (typeof value === 'string') {
1019
+ const out = dotenvExpand(value, envRef);
1020
+ // dotenvExpand returns string | undefined; preserve original on undefined
1021
+ return (out ?? value);
1022
+ }
1023
+ // Arrays: return as-is (no recursion)
1024
+ if (Array.isArray(value)) {
1025
+ return value;
1026
+ }
1027
+ // Plain objects: shallow clone and recurse into values
1028
+ if (isPlainObject(value)) {
1029
+ const src = value;
1030
+ const out = {};
1031
+ for (const [k, v] of Object.entries(src)) {
1032
+ // Recurse for strings/objects; keep arrays as-is; preserve other scalars
1033
+ if (typeof v === 'string')
1034
+ out[k] = dotenvExpand(v, envRef) ?? v;
1035
+ else if (Array.isArray(v))
1036
+ out[k] = v;
1037
+ else if (isPlainObject(v))
1038
+ out[k] = interpolateDeep(v, envRef);
1039
+ else
1040
+ out[k] = v;
1041
+ }
1042
+ return out;
1043
+ }
1044
+ // Other primitives/types: return as-is
1045
+ return value;
1046
+ };
1047
+
877
1048
  /**
878
1049
  * Compute the dotenv context for the host (uses the config loader/overlay path).
879
1050
  * - Resolves and validates options strictly (host-only).
@@ -957,19 +1128,32 @@ const computeContext = async (customOptions, plugins, hostMetaUrl) => {
957
1128
  {};
958
1129
  const mergedPluginConfigs = defaultsDeep({}, packagedPlugins, publicPlugins, localPlugins);
959
1130
  for (const p of plugins) {
960
- if (!p.id || !p.configSchema)
1131
+ if (!p.id)
961
1132
  continue;
962
1133
  const slice = mergedPluginConfigs[p.id];
963
1134
  if (slice === undefined)
964
1135
  continue;
965
- const parsed = p.configSchema.safeParse(slice);
966
- if (!parsed.success) {
967
- const msgs = parsed.error.issues
968
- .map((i) => `${i.path.join('.')}: ${i.message}`)
969
- .join('\n');
970
- throw new Error(`Invalid config for plugin '${p.id}':\n${msgs}`);
1136
+ // Per-plugin interpolation just before validation/afterResolve:
1137
+ // precedence: process.env wins over ctx.dotenv for slice defaults.
1138
+ const envRef = {
1139
+ ...dotenv,
1140
+ ...process.env,
1141
+ };
1142
+ const interpolated = interpolateDeep(slice, envRef);
1143
+ // Validate if a schema is provided; otherwise accept interpolated slice as-is.
1144
+ if (p.configSchema) {
1145
+ const parsed = p.configSchema.safeParse(interpolated);
1146
+ if (!parsed.success) {
1147
+ const msgs = parsed.error.issues
1148
+ .map((i) => `${i.path.join('.')}: ${i.message}`)
1149
+ .join('\n');
1150
+ throw new Error(`Invalid config for plugin '${p.id}':\n${msgs}`);
1151
+ }
1152
+ mergedPluginConfigs[p.id] = parsed.data;
1153
+ }
1154
+ else {
1155
+ mergedPluginConfigs[p.id] = interpolated;
971
1156
  }
972
- mergedPluginConfigs[p.id] = parsed.data;
973
1157
  }
974
1158
  return {
975
1159
  optionsResolved: validated,
@@ -1013,10 +1197,23 @@ class GetDotenvCli extends Command {
1013
1197
  visibleOptions: (cmd) => {
1014
1198
  const all = cmd.options ??
1015
1199
  [];
1016
- return all.filter((opt) => {
1200
+ const base = all.filter((opt) => {
1017
1201
  const group = opt.__group;
1018
1202
  return group === 'base';
1019
1203
  });
1204
+ // Sort: short-aliased options first, then long-only; stable by flags.
1205
+ const hasShort = (opt) => {
1206
+ const flags = opt.flags ?? '';
1207
+ // Matches "-x," or starting "-x " before any long
1208
+ return /(^|\s|,)-[A-Za-z]/.test(flags);
1209
+ };
1210
+ const byFlags = (opt) => opt.flags ?? '';
1211
+ base.sort((a, b) => {
1212
+ const aS = hasShort(a) ? 1 : 0;
1213
+ const bS = hasShort(b) ? 1 : 0;
1214
+ return bS - aS || byFlags(a).localeCompare(byFlags(b));
1215
+ });
1216
+ return base;
1020
1217
  },
1021
1218
  });
1022
1219
  this.addHelpText('beforeAll', () => {
@@ -1205,6 +1402,12 @@ class GetDotenvCli extends Command {
1205
1402
  return '';
1206
1403
  const renderRows = (title, rows) => {
1207
1404
  const width = Math.min(40, rows.reduce((m, r) => Math.max(m, r.flags.length), 0));
1405
+ // Sort within group: short-aliased flags first
1406
+ rows.sort((a, b) => {
1407
+ const aS = /(^|\s|,)-[A-Za-z]/.test(a.flags) ? 1 : 0;
1408
+ const bS = /(^|\s|,)-[A-Za-z]/.test(b.flags) ? 1 : 0;
1409
+ return bS - aS || a.flags.localeCompare(b.flags);
1410
+ });
1208
1411
  const lines = rows
1209
1412
  .map((r) => {
1210
1413
  const pad = ' '.repeat(Math.max(2, width - r.flags.length + 2));
package/dist/config.cjs CHANGED
@@ -8,11 +8,12 @@ var YAML = require('yaml');
8
8
  var zod = require('zod');
9
9
 
10
10
  /**
11
- * Zod schemas for configuration files discovered by the new loader. *
11
+ * Zod schemas for configuration files discovered by the new loader.
12
+ *
12
13
  * Notes:
13
14
  * - RAW: all fields optional; shapes are stringly-friendly (paths may be string[] or string).
14
15
  * - RESOLVED: normalized shapes (paths always string[]).
15
- * - For this step (JSON/YAML only), any defined `dynamic` will be rejected by the loader.
16
+ * - For JSON/YAML configs, the loader rejects "dynamic" and "schema" (JS/TS-only).
16
17
  */
17
18
  // String-only env value map
18
19
  const stringMap = zod.z.record(zod.z.string(), zod.z.string());
@@ -27,6 +28,8 @@ const getDotenvConfigSchemaRaw = zod.z.object({
27
28
  log: zod.z.boolean().optional(),
28
29
  shell: zod.z.union([zod.z.string(), zod.z.boolean()]).optional(),
29
30
  scripts: zod.z.record(zod.z.string(), zod.z.unknown()).optional(), // Scripts validation left wide; generator validates elsewhere
31
+ requiredKeys: zod.z.array(zod.z.string()).optional(),
32
+ schema: zod.z.unknown().optional(), // JS/TS-only; loader rejects in JSON/YAML
30
33
  vars: stringMap.optional(), // public, global
31
34
  envVars: envStringMap.optional(), // public, per-env
32
35
  // Dynamic in config (JS/TS only). JSON/YAML loader will reject if set.
@@ -209,9 +212,11 @@ const loadConfigFile = async (filePath) => {
209
212
  .join('\n');
210
213
  throw new Error(`Invalid config ${filePath}:\n${msgs}`);
211
214
  }
212
- // Disallow dynamic in JSON/YAML; allow in JS/TS
213
- if (!isJsOrTs(filePath) && parsed.data.dynamic !== undefined) {
214
- throw new Error(`Config ${filePath} specifies "dynamic"; JSON/YAML configs cannot include dynamic in this step. Use JS/TS config.`);
215
+ // Disallow dynamic and schema in JSON/YAML; allow both in JS/TS.
216
+ if (!isJsOrTs(filePath) &&
217
+ (parsed.data.dynamic !== undefined || parsed.data.schema !== undefined)) {
218
+ throw new Error(`Config ${filePath} specifies unsupported keys for JSON/YAML. ` +
219
+ `Use JS/TS config for "dynamic" or "schema".`);
215
220
  }
216
221
  return getDotenvConfigSchemaResolved.parse(parsed.data);
217
222
  };
package/dist/config.d.cts CHANGED
@@ -11,6 +11,8 @@ type GetDotenvConfigResolved = {
11
11
  log?: boolean;
12
12
  shell?: string | boolean;
13
13
  scripts?: Scripts;
14
+ requiredKeys?: string[];
15
+ schema?: unknown;
14
16
  vars?: Record<string, string>;
15
17
  envVars?: Record<string, Record<string, string>>;
16
18
  dynamic?: unknown;
package/dist/config.d.mts CHANGED
@@ -11,6 +11,8 @@ type GetDotenvConfigResolved = {
11
11
  log?: boolean;
12
12
  shell?: string | boolean;
13
13
  scripts?: Scripts;
14
+ requiredKeys?: string[];
15
+ schema?: unknown;
14
16
  vars?: Record<string, string>;
15
17
  envVars?: Record<string, Record<string, string>>;
16
18
  dynamic?: unknown;
package/dist/config.d.ts CHANGED
@@ -11,6 +11,8 @@ type GetDotenvConfigResolved = {
11
11
  log?: boolean;
12
12
  shell?: string | boolean;
13
13
  scripts?: Scripts;
14
+ requiredKeys?: string[];
15
+ schema?: unknown;
14
16
  vars?: Record<string, string>;
15
17
  envVars?: Record<string, Record<string, string>>;
16
18
  dynamic?: unknown;
package/dist/config.mjs CHANGED
@@ -6,11 +6,12 @@ import YAML from 'yaml';
6
6
  import { z } from 'zod';
7
7
 
8
8
  /**
9
- * Zod schemas for configuration files discovered by the new loader. *
9
+ * Zod schemas for configuration files discovered by the new loader.
10
+ *
10
11
  * Notes:
11
12
  * - RAW: all fields optional; shapes are stringly-friendly (paths may be string[] or string).
12
13
  * - RESOLVED: normalized shapes (paths always string[]).
13
- * - For this step (JSON/YAML only), any defined `dynamic` will be rejected by the loader.
14
+ * - For JSON/YAML configs, the loader rejects "dynamic" and "schema" (JS/TS-only).
14
15
  */
15
16
  // String-only env value map
16
17
  const stringMap = z.record(z.string(), z.string());
@@ -25,6 +26,8 @@ const getDotenvConfigSchemaRaw = z.object({
25
26
  log: z.boolean().optional(),
26
27
  shell: z.union([z.string(), z.boolean()]).optional(),
27
28
  scripts: z.record(z.string(), z.unknown()).optional(), // Scripts validation left wide; generator validates elsewhere
29
+ requiredKeys: z.array(z.string()).optional(),
30
+ schema: z.unknown().optional(), // JS/TS-only; loader rejects in JSON/YAML
28
31
  vars: stringMap.optional(), // public, global
29
32
  envVars: envStringMap.optional(), // public, per-env
30
33
  // Dynamic in config (JS/TS only). JSON/YAML loader will reject if set.
@@ -207,9 +210,11 @@ const loadConfigFile = async (filePath) => {
207
210
  .join('\n');
208
211
  throw new Error(`Invalid config ${filePath}:\n${msgs}`);
209
212
  }
210
- // Disallow dynamic in JSON/YAML; allow in JS/TS
211
- if (!isJsOrTs(filePath) && parsed.data.dynamic !== undefined) {
212
- throw new Error(`Config ${filePath} specifies "dynamic"; JSON/YAML configs cannot include dynamic in this step. Use JS/TS config.`);
213
+ // Disallow dynamic and schema in JSON/YAML; allow both in JS/TS.
214
+ if (!isJsOrTs(filePath) &&
215
+ (parsed.data.dynamic !== undefined || parsed.data.schema !== undefined)) {
216
+ throw new Error(`Config ${filePath} specifies unsupported keys for JSON/YAML. ` +
217
+ `Use JS/TS config for "dynamic" or "schema".`);
213
218
  }
214
219
  return getDotenvConfigSchemaResolved.parse(parsed.data);
215
220
  };
@@ -15,6 +15,8 @@ type GetDotenvConfigResolved = {
15
15
  log?: boolean;
16
16
  shell?: string | boolean;
17
17
  scripts?: Scripts;
18
+ requiredKeys?: string[];
19
+ schema?: unknown;
18
20
  vars?: Record<string, string>;
19
21
  envVars?: Record<string, Record<string, string>>;
20
22
  dynamic?: unknown;
@@ -15,6 +15,8 @@ type GetDotenvConfigResolved = {
15
15
  log?: boolean;
16
16
  shell?: string | boolean;
17
17
  scripts?: Scripts;
18
+ requiredKeys?: string[];
19
+ schema?: unknown;
18
20
  vars?: Record<string, string>;
19
21
  envVars?: Record<string, Record<string, string>>;
20
22
  dynamic?: unknown;
@@ -15,6 +15,8 @@ type GetDotenvConfigResolved = {
15
15
  log?: boolean;
16
16
  shell?: string | boolean;
17
17
  scripts?: Scripts;
18
+ requiredKeys?: string[];
19
+ schema?: unknown;
18
20
  vars?: Record<string, string>;
19
21
  envVars?: Record<string, Record<string, string>>;
20
22
  dynamic?: unknown;