@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.
@@ -19,6 +19,11 @@ const baseRootOptionDefaults = {
19
19
  dotenvToken: '.env',
20
20
  loadProcess: true,
21
21
  logger: console,
22
+ // Diagnostics defaults
23
+ warnEntropy: true,
24
+ entropyThreshold: 3.8,
25
+ entropyMinLength: 16,
26
+ entropyWhitelist: ['^GIT_', '^npm_', '^CI$', 'SHLVL'],
22
27
  paths: './',
23
28
  pathsDelimiter: ' ',
24
29
  privateToken: 'local',
@@ -39,7 +44,7 @@ const baseRootOptionDefaults = {
39
44
  const baseGetDotenvCliOptions = baseRootOptionDefaults;
40
45
 
41
46
  /** @internal */
42
- const isPlainObject = (value) => value !== null &&
47
+ const isPlainObject$1 = (value) => value !== null &&
43
48
  typeof value === 'object' &&
44
49
  Object.getPrototypeOf(value) === Object.prototype;
45
50
  const mergeInto = (target, source) => {
@@ -47,10 +52,10 @@ const mergeInto = (target, source) => {
47
52
  if (sVal === undefined)
48
53
  continue; // do not overwrite with undefined
49
54
  const tVal = target[key];
50
- if (isPlainObject(tVal) && isPlainObject(sVal)) {
55
+ if (isPlainObject$1(tVal) && isPlainObject$1(sVal)) {
51
56
  target[key] = mergeInto({ ...tVal }, sVal);
52
57
  }
53
- else if (isPlainObject(sVal)) {
58
+ else if (isPlainObject$1(sVal)) {
54
59
  target[key] = mergeInto({}, sVal);
55
60
  }
56
61
  else {
@@ -210,11 +215,12 @@ const getDotenvOptionsSchemaRaw = z.object({
210
215
  const getDotenvOptionsSchemaResolved = getDotenvOptionsSchemaRaw;
211
216
 
212
217
  /**
213
- * Zod schemas for configuration files discovered by the new loader. *
218
+ * Zod schemas for configuration files discovered by the new loader.
219
+ *
214
220
  * Notes:
215
221
  * - RAW: all fields optional; shapes are stringly-friendly (paths may be string[] or string).
216
222
  * - RESOLVED: normalized shapes (paths always string[]).
217
- * - For this step (JSON/YAML only), any defined `dynamic` will be rejected by the loader.
223
+ * - For JSON/YAML configs, the loader rejects "dynamic" and "schema" (JS/TS-only).
218
224
  */
219
225
  // String-only env value map
220
226
  const stringMap = z.record(z.string(), z.string());
@@ -229,6 +235,8 @@ const getDotenvConfigSchemaRaw = z.object({
229
235
  log: z.boolean().optional(),
230
236
  shell: z.union([z.string(), z.boolean()]).optional(),
231
237
  scripts: z.record(z.string(), z.unknown()).optional(), // Scripts validation left wide; generator validates elsewhere
238
+ requiredKeys: z.array(z.string()).optional(),
239
+ schema: z.unknown().optional(), // JS/TS-only; loader rejects in JSON/YAML
232
240
  vars: stringMap.optional(), // public, global
233
241
  envVars: envStringMap.optional(), // public, per-env
234
242
  // Dynamic in config (JS/TS only). JSON/YAML loader will reject if set.
@@ -411,9 +419,11 @@ const loadConfigFile = async (filePath) => {
411
419
  .join('\n');
412
420
  throw new Error(`Invalid config ${filePath}:\n${msgs}`);
413
421
  }
414
- // Disallow dynamic in JSON/YAML; allow in JS/TS
415
- if (!isJsOrTs(filePath) && parsed.data.dynamic !== undefined) {
416
- throw new Error(`Config ${filePath} specifies "dynamic"; JSON/YAML configs cannot include dynamic in this step. Use JS/TS config.`);
422
+ // Disallow dynamic and schema in JSON/YAML; allow both in JS/TS.
423
+ if (!isJsOrTs(filePath) &&
424
+ (parsed.data.dynamic !== undefined || parsed.data.schema !== undefined)) {
425
+ throw new Error(`Config ${filePath} specifies unsupported keys for JSON/YAML. ` +
426
+ `Use JS/TS config for "dynamic" or "schema".`);
417
427
  }
418
428
  return getDotenvConfigSchemaResolved.parse(parsed.data);
419
429
  };
@@ -617,6 +627,99 @@ 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
+ /**
702
+ * Utility to redact three related displayed values (parent/dotenv/final)
703
+ * consistently for trace lines.
704
+ */
705
+ const redactTriple = (key, triple, opts) => {
706
+ if (!opts?.redact)
707
+ return triple;
708
+ const regs = compile(opts.redactPatterns);
709
+ const maskIf = (v) => (v && shouldRedactKey(key, regs) ? MASK : v);
710
+ const out = {};
711
+ const p = maskIf(triple.parent);
712
+ const d = maskIf(triple.dotenv);
713
+ const f = maskIf(triple.final);
714
+ if (p !== undefined)
715
+ out.parent = p;
716
+ if (d !== undefined)
717
+ out.dotenv = d;
718
+ if (f !== undefined)
719
+ out.final = f;
720
+ return out;
721
+ };
722
+
620
723
  /**
621
724
  * Asynchronously read a dotenv file & parse it into an object.
622
725
  *
@@ -866,14 +969,103 @@ const getDotenv = async (options = {}) => {
866
969
  resultDotenv = dotenvForOutput;
867
970
  }
868
971
  // Log result.
869
- if (log)
870
- logger.log(resultDotenv);
972
+ if (log) {
973
+ const redactFlag = options.redact ?? false;
974
+ const redactPatterns = options.redactPatterns ?? undefined;
975
+ const redOpts = {};
976
+ if (redactFlag)
977
+ redOpts.redact = true;
978
+ if (redactFlag && Array.isArray(redactPatterns))
979
+ redOpts.redactPatterns = redactPatterns;
980
+ const bag = redactFlag
981
+ ? redactObject(resultDotenv, redOpts)
982
+ : { ...resultDotenv };
983
+ logger.log(bag);
984
+ // Entropy warnings: once-per-key-per-run (presentation only)
985
+ const warnEntropyVal = options.warnEntropy ?? true;
986
+ const entropyThresholdVal = options
987
+ .entropyThreshold;
988
+ const entropyMinLengthVal = options
989
+ .entropyMinLength;
990
+ const entropyWhitelistVal = options
991
+ .entropyWhitelist;
992
+ const entOpts = {};
993
+ if (typeof warnEntropyVal === 'boolean')
994
+ entOpts.warnEntropy = warnEntropyVal;
995
+ if (typeof entropyThresholdVal === 'number')
996
+ entOpts.entropyThreshold = entropyThresholdVal;
997
+ if (typeof entropyMinLengthVal === 'number')
998
+ entOpts.entropyMinLength = entropyMinLengthVal;
999
+ if (Array.isArray(entropyWhitelistVal))
1000
+ entOpts.entropyWhitelist = entropyWhitelistVal;
1001
+ for (const [k, v] of Object.entries(resultDotenv)) {
1002
+ maybeWarnEntropy(k, v, v !== undefined ? 'dotenv' : 'unset', entOpts, (line) => {
1003
+ logger.log(line);
1004
+ });
1005
+ }
1006
+ }
871
1007
  // Load process.env.
872
1008
  if (loadProcess)
873
1009
  Object.assign(process.env, resultDotenv);
874
1010
  return resultDotenv;
875
1011
  };
876
1012
 
1013
+ /**
1014
+ * Deep interpolation utility for string leaves.
1015
+ * - Expands string values using dotenv-style expansion against the provided envRef.
1016
+ * - Preserves non-strings as-is.
1017
+ * - Does not recurse into arrays (arrays are returned unchanged).
1018
+ *
1019
+ * Intended for:
1020
+ * - Phase C option/config interpolation after composing ctx.dotenv.
1021
+ * - Per-plugin config slice interpolation before afterResolve.
1022
+ */
1023
+ /** @internal */
1024
+ const isPlainObject = (v) => v !== null &&
1025
+ typeof v === 'object' &&
1026
+ !Array.isArray(v) &&
1027
+ Object.getPrototypeOf(v) === Object.prototype;
1028
+ /**
1029
+ * Deeply interpolate string leaves against envRef.
1030
+ * Arrays are not recursed into; they are returned unchanged.
1031
+ *
1032
+ * @typeParam T - Shape of the input value.
1033
+ * @param value - Input value (object/array/primitive).
1034
+ * @param envRef - Reference environment for interpolation.
1035
+ * @returns A new value with string leaves interpolated.
1036
+ */
1037
+ const interpolateDeep = (value, envRef) => {
1038
+ // Strings: expand and return
1039
+ if (typeof value === 'string') {
1040
+ const out = dotenvExpand(value, envRef);
1041
+ // dotenvExpand returns string | undefined; preserve original on undefined
1042
+ return (out ?? value);
1043
+ }
1044
+ // Arrays: return as-is (no recursion)
1045
+ if (Array.isArray(value)) {
1046
+ return value;
1047
+ }
1048
+ // Plain objects: shallow clone and recurse into values
1049
+ if (isPlainObject(value)) {
1050
+ const src = value;
1051
+ const out = {};
1052
+ for (const [k, v] of Object.entries(src)) {
1053
+ // Recurse for strings/objects; keep arrays as-is; preserve other scalars
1054
+ if (typeof v === 'string')
1055
+ out[k] = dotenvExpand(v, envRef) ?? v;
1056
+ else if (Array.isArray(v))
1057
+ out[k] = v;
1058
+ else if (isPlainObject(v))
1059
+ out[k] = interpolateDeep(v, envRef);
1060
+ else
1061
+ out[k] = v;
1062
+ }
1063
+ return out;
1064
+ }
1065
+ // Other primitives/types: return as-is
1066
+ return value;
1067
+ };
1068
+
877
1069
  /**
878
1070
  * Compute the dotenv context for the host (uses the config loader/overlay path).
879
1071
  * - Resolves and validates options strictly (host-only).
@@ -957,19 +1149,32 @@ const computeContext = async (customOptions, plugins, hostMetaUrl) => {
957
1149
  {};
958
1150
  const mergedPluginConfigs = defaultsDeep({}, packagedPlugins, publicPlugins, localPlugins);
959
1151
  for (const p of plugins) {
960
- if (!p.id || !p.configSchema)
1152
+ if (!p.id)
961
1153
  continue;
962
1154
  const slice = mergedPluginConfigs[p.id];
963
1155
  if (slice === undefined)
964
1156
  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}`);
1157
+ // Per-plugin interpolation just before validation/afterResolve:
1158
+ // precedence: process.env wins over ctx.dotenv for slice defaults.
1159
+ const envRef = {
1160
+ ...dotenv,
1161
+ ...process.env,
1162
+ };
1163
+ const interpolated = interpolateDeep(slice, envRef);
1164
+ // Validate if a schema is provided; otherwise accept interpolated slice as-is.
1165
+ if (p.configSchema) {
1166
+ const parsed = p.configSchema.safeParse(interpolated);
1167
+ if (!parsed.success) {
1168
+ const msgs = parsed.error.issues
1169
+ .map((i) => `${i.path.join('.')}: ${i.message}`)
1170
+ .join('\n');
1171
+ throw new Error(`Invalid config for plugin '${p.id}':\n${msgs}`);
1172
+ }
1173
+ mergedPluginConfigs[p.id] = parsed.data;
1174
+ }
1175
+ else {
1176
+ mergedPluginConfigs[p.id] = interpolated;
971
1177
  }
972
- mergedPluginConfigs[p.id] = parsed.data;
973
1178
  }
974
1179
  return {
975
1180
  optionsResolved: validated,
@@ -1013,10 +1218,23 @@ class GetDotenvCli extends Command {
1013
1218
  visibleOptions: (cmd) => {
1014
1219
  const all = cmd.options ??
1015
1220
  [];
1016
- return all.filter((opt) => {
1221
+ const base = all.filter((opt) => {
1017
1222
  const group = opt.__group;
1018
1223
  return group === 'base';
1019
1224
  });
1225
+ // Sort: short-aliased options first, then long-only; stable by flags.
1226
+ const hasShort = (opt) => {
1227
+ const flags = opt.flags ?? '';
1228
+ // Matches "-x," or starting "-x " before any long
1229
+ return /(^|\s|,)-[A-Za-z]/.test(flags);
1230
+ };
1231
+ const byFlags = (opt) => opt.flags ?? '';
1232
+ base.sort((a, b) => {
1233
+ const aS = hasShort(a) ? 1 : 0;
1234
+ const bS = hasShort(b) ? 1 : 0;
1235
+ return bS - aS || byFlags(a).localeCompare(byFlags(b));
1236
+ });
1237
+ return base;
1020
1238
  },
1021
1239
  });
1022
1240
  this.addHelpText('beforeAll', () => {
@@ -1205,6 +1423,12 @@ class GetDotenvCli extends Command {
1205
1423
  return '';
1206
1424
  const renderRows = (title, rows) => {
1207
1425
  const width = Math.min(40, rows.reduce((m, r) => Math.max(m, r.flags.length), 0));
1426
+ // Sort within group: short-aliased flags first
1427
+ rows.sort((a, b) => {
1428
+ const aS = /(^|\s|,)-[A-Za-z]/.test(a.flags) ? 1 : 0;
1429
+ const bS = /(^|\s|,)-[A-Za-z]/.test(b.flags) ? 1 : 0;
1430
+ return bS - aS || a.flags.localeCompare(b.flags);
1431
+ });
1208
1432
  const lines = rows
1209
1433
  .map((r) => {
1210
1434
  const pad = ' '.repeat(Math.max(2, width - r.flags.length + 2));
@@ -1233,6 +1457,62 @@ class GetDotenvCli extends Command {
1233
1457
  }
1234
1458
  }
1235
1459
 
1460
+ /**
1461
+ * Validate a composed env against config-provided validation surfaces.
1462
+ * Precedence for validation definitions:
1463
+ * project.local -\> project.public -\> packaged
1464
+ *
1465
+ * Behavior:
1466
+ * - If a JS/TS `schema` is present, use schema.safeParse(finalEnv).
1467
+ * - Else if `requiredKeys` is present, check presence (value !== undefined).
1468
+ * - Returns a flat list of issue strings; caller decides warn vs fail.
1469
+ */
1470
+ const validateEnvAgainstSources = (finalEnv, sources) => {
1471
+ const pick = (getter) => {
1472
+ const pl = sources.project?.local;
1473
+ const pp = sources.project?.public;
1474
+ const pk = sources.packaged;
1475
+ return ((pl && getter(pl)) ||
1476
+ (pp && getter(pp)) ||
1477
+ (pk && getter(pk)) ||
1478
+ undefined);
1479
+ };
1480
+ const schema = pick((cfg) => cfg['schema']);
1481
+ if (schema &&
1482
+ typeof schema.safeParse === 'function') {
1483
+ try {
1484
+ const parsed = schema.safeParse(finalEnv);
1485
+ if (!parsed.success) {
1486
+ // Try to render zod-style issues when available.
1487
+ const err = parsed.error;
1488
+ const issues = Array.isArray(err.issues) && err.issues.length > 0
1489
+ ? err.issues.map((i) => {
1490
+ const path = Array.isArray(i.path) ? i.path.join('.') : '';
1491
+ const msg = i.message ?? 'Invalid value';
1492
+ return path ? `[schema] ${path}: ${msg}` : `[schema] ${msg}`;
1493
+ })
1494
+ : ['[schema] validation failed'];
1495
+ return issues;
1496
+ }
1497
+ return [];
1498
+ }
1499
+ catch {
1500
+ // If schema invocation fails, surface a single diagnostic.
1501
+ return [
1502
+ '[schema] validation failed (unable to execute schema.safeParse)',
1503
+ ];
1504
+ }
1505
+ }
1506
+ const requiredKeys = pick((cfg) => cfg['requiredKeys']);
1507
+ if (Array.isArray(requiredKeys) && requiredKeys.length > 0) {
1508
+ const missing = requiredKeys.filter((k) => finalEnv[k] === undefined);
1509
+ if (missing.length > 0) {
1510
+ return missing.map((k) => `[requiredKeys] missing: ${k}`);
1511
+ }
1512
+ }
1513
+ return [];
1514
+ };
1515
+
1236
1516
  /**
1237
1517
  * Attach legacy root flags to a Commander program.
1238
1518
  * Uses provided defaults to render help labels without coupling to generators.
@@ -1315,6 +1595,7 @@ const attachRootOptions = (program, defaults, opts) => {
1315
1595
  .addOption(new Option('-l, --log', `console log loaded variables ON${log ? ' (default)' : ''}`).conflicts('logOff'))
1316
1596
  .addOption(new Option('-L, --log-off', `console log loaded variables OFF${!log ? ' (default)' : ''}`).conflicts('log'))
1317
1597
  .option('--capture', 'capture child process stdio for commands (tests/CI)')
1598
+ .option('--redact', 'mask secret-like values in logs/trace (presentation-only)')
1318
1599
  .option('--default-env <string>', 'default target environment', dotenvExpandFromProcessEnv, defaultEnv)
1319
1600
  .option('--dotenv-token <string>', 'dotenv-expanded token indicating a dotenv file', dotenvExpandFromProcessEnv, dotenvToken)
1320
1601
  .option('--dynamic-path <string>', 'dynamic variables path (.js or .ts; .ts is auto-compiled when esbuild is available, otherwise precompile)', dotenvExpandFromProcessEnv, dynamicPath)
@@ -1332,6 +1613,16 @@ const attachRootOptions = (program, defaults, opts) => {
1332
1613
  .hideHelp());
1333
1614
  // Diagnostics: opt-in tracing; optional variadic keys after the flag.
1334
1615
  p = p.option('--trace [keys...]', 'emit diagnostics for child env composition (optional keys)');
1616
+ // Validation: strict mode fails on env validation issues (warn by default).
1617
+ p = p.option('--strict', 'fail on env validation errors (schema/requiredKeys)');
1618
+ // Entropy diagnostics (presentation-only)
1619
+ p = p
1620
+ .addOption(new Option('--entropy-warn', 'enable entropy warnings (default on)').conflicts('entropyWarnOff'))
1621
+ .addOption(new Option('--entropy-warn-off', 'disable entropy warnings').conflicts('entropyWarn'))
1622
+ .option('--entropy-threshold <number>', 'entropy bits/char threshold (default 3.8)')
1623
+ .option('--entropy-min-length <number>', 'min length to examine for entropy (default 16)')
1624
+ .option('--entropy-whitelist <pattern...>', 'suppress entropy warnings when key matches any regex pattern')
1625
+ .option('--redact-pattern <pattern...>', 'additional key-match regex patterns to trigger redaction');
1335
1626
  // Restore original methods to avoid tagging future additions outside base.
1336
1627
  program.addOption = originalAddOption;
1337
1628
  program.option = originalOption;
@@ -1424,7 +1715,7 @@ const resolveCliOptions = (rawCliOptions, defaults, parentJson) => {
1424
1715
  const parent = typeof parentJson === 'string' && parentJson.length > 0
1425
1716
  ? JSON.parse(parentJson)
1426
1717
  : undefined;
1427
- const { command, debugOff, excludeAll, excludeAllOff, excludeDynamicOff, excludeEnvOff, excludeGlobalOff, excludePrivateOff, excludePublicOff, loadProcessOff, logOff, scripts, shellOff, ...rest } = rawCliOptions;
1718
+ const { command, debugOff, excludeAll, excludeAllOff, excludeDynamicOff, excludeEnvOff, excludeGlobalOff, excludePrivateOff, excludePublicOff, loadProcessOff, logOff, entropyWarn, entropyWarnOff, scripts, shellOff, ...rest } = rawCliOptions;
1428
1719
  const current = { ...rest };
1429
1720
  if (typeof scripts === 'string') {
1430
1721
  try {
@@ -1444,6 +1735,8 @@ const resolveCliOptions = (rawCliOptions, defaults, parentJson) => {
1444
1735
  setOptionalFlag(merged, 'excludePublic', resolveExclusionAll(merged.excludePublic, excludePublicOff, d.excludePublic, excludeAll, excludeAllOff));
1445
1736
  setOptionalFlag(merged, 'log', resolveExclusion(merged.log, logOff, d.log));
1446
1737
  setOptionalFlag(merged, 'loadProcess', resolveExclusion(merged.loadProcess, loadProcessOff, d.loadProcess));
1738
+ // warnEntropy (tri-state)
1739
+ setOptionalFlag(merged, 'warnEntropy', resolveExclusion(merged.warnEntropy, entropyWarnOff, d.warnEntropy));
1447
1740
  // Normalize shell for predictability: explicit default shell per OS.
1448
1741
  const defaultShell = process.platform === 'win32' ? 'powershell.exe' : '/bin/bash';
1449
1742
  let resolvedShell = merged.shell;
@@ -1479,6 +1772,29 @@ GetDotenvCli.prototype.passOptions = function (defaults) {
1479
1772
  // Build service options and compute context (always-on config loader path).
1480
1773
  const serviceOptions = getDotenvCliOptions2Options(merged);
1481
1774
  await this.resolveAndLoad(serviceOptions);
1775
+ // Global validation: once after Phase C using config sources.
1776
+ try {
1777
+ const ctx = this.getCtx();
1778
+ const dotenv = (ctx?.dotenv ?? {});
1779
+ const sources = await resolveGetDotenvConfigSources(import.meta.url);
1780
+ const issues = validateEnvAgainstSources(dotenv, sources);
1781
+ if (Array.isArray(issues) && issues.length > 0) {
1782
+ const logger = (merged.logger ??
1783
+ console);
1784
+ const emit = logger.error ?? logger.log;
1785
+ issues.forEach((m) => {
1786
+ emit(m);
1787
+ });
1788
+ if (merged.strict) {
1789
+ // Deterministic failure under strict mode
1790
+ process.exit(1);
1791
+ }
1792
+ }
1793
+ }
1794
+ catch {
1795
+ // Be tolerant: validation errors reported above; unexpected failures here
1796
+ // should not crash non-strict flows.
1797
+ }
1482
1798
  });
1483
1799
  // Also handle root-level flows (no subcommand) so option-aliases can run
1484
1800
  // with the same merged options and context without duplicating logic.
@@ -1492,6 +1808,26 @@ GetDotenvCli.prototype.passOptions = function (defaults) {
1492
1808
  if (!this.getCtx()) {
1493
1809
  const serviceOptions = getDotenvCliOptions2Options(merged);
1494
1810
  await this.resolveAndLoad(serviceOptions);
1811
+ try {
1812
+ const ctx = this.getCtx();
1813
+ const dotenv = (ctx?.dotenv ?? {});
1814
+ const sources = await resolveGetDotenvConfigSources(import.meta.url);
1815
+ const issues = validateEnvAgainstSources(dotenv, sources);
1816
+ if (Array.isArray(issues) && issues.length > 0) {
1817
+ const logger = (merged
1818
+ .logger ?? console);
1819
+ const emit = logger.error ?? logger.log;
1820
+ issues.forEach((m) => {
1821
+ emit(m);
1822
+ });
1823
+ if (merged.strict) {
1824
+ process.exit(1);
1825
+ }
1826
+ }
1827
+ }
1828
+ catch {
1829
+ // Tolerate validation side-effects in non-strict mode
1830
+ }
1495
1831
  }
1496
1832
  });
1497
1833
  return this;
@@ -1688,6 +2024,48 @@ const runCommand = async (command, shell, opts) => {
1688
2024
  }
1689
2025
  };
1690
2026
 
2027
+ const dropUndefined = (bag) => Object.fromEntries(Object.entries(bag).filter((e) => typeof e[1] === 'string'));
2028
+ /** Build a sanitized env for child processes from base + overlay. */
2029
+ const buildSpawnEnv = (base, overlay) => {
2030
+ const raw = {
2031
+ ...(base ?? {}),
2032
+ ...(overlay ?? {}),
2033
+ };
2034
+ // Drop undefined first
2035
+ const entries = Object.entries(dropUndefined(raw));
2036
+ if (process.platform === 'win32') {
2037
+ // Windows: keys are case-insensitive; collapse duplicates
2038
+ const byLower = new Map();
2039
+ for (const [k, v] of entries) {
2040
+ byLower.set(k.toLowerCase(), [k, v]); // last wins; preserve latest casing
2041
+ }
2042
+ const out = {};
2043
+ for (const [, [k, v]] of byLower)
2044
+ out[k] = v;
2045
+ // HOME fallback from USERPROFILE (common expectation)
2046
+ if (!Object.prototype.hasOwnProperty.call(out, 'HOME')) {
2047
+ const up = out['USERPROFILE'];
2048
+ if (typeof up === 'string' && up.length > 0)
2049
+ out['HOME'] = up;
2050
+ }
2051
+ // Normalize TMP/TEMP coherence (pick any present; reflect to both)
2052
+ const tmp = out['TMP'] ?? out['TEMP'];
2053
+ if (typeof tmp === 'string' && tmp.length > 0) {
2054
+ out['TMP'] = tmp;
2055
+ out['TEMP'] = tmp;
2056
+ }
2057
+ return out;
2058
+ }
2059
+ // POSIX: keep keys as-is
2060
+ const out = Object.fromEntries(entries);
2061
+ // Ensure TMPDIR exists when any temp key is present (best-effort)
2062
+ const tmpdir = out['TMPDIR'] ?? out['TMP'] ?? out['TEMP'];
2063
+ if (typeof tmpdir === 'string' && tmpdir.length > 0) {
2064
+ out['TMPDIR'] = tmpdir;
2065
+ }
2066
+ return out;
2067
+ };
2068
+
1691
2069
  /**
1692
2070
  * Define a GetDotenv CLI plugin with compositional helpers.
1693
2071
  *
@@ -2045,7 +2423,7 @@ const awsPlugin = () => definePlugin({
2045
2423
  const shellSetting = resolveShell(rootOpts?.scripts, 'aws', rootOpts?.shell);
2046
2424
  const ctxDotenv = (ctx?.dotenv ?? {});
2047
2425
  const exit = await runCommand(argv, shellSetting, {
2048
- env: { ...process.env, ...ctxDotenv },
2426
+ env: buildSpawnEnv(process.env, ctxDotenv),
2049
2427
  stdio: capture ? 'pipe' : 'inherit',
2050
2428
  });
2051
2429
  // Deterministic termination (suppressed under tests)
@@ -2184,14 +2562,12 @@ const execShellCommandBatch = async ({ command, getDotenvCliOptions, globs, igno
2184
2562
  const hasCmd = (typeof command === 'string' && command.length > 0) ||
2185
2563
  (Array.isArray(command) && command.length > 0);
2186
2564
  if (hasCmd) {
2565
+ const envBag = getDotenvCliOptions !== undefined
2566
+ ? { getDotenvCliOptions: JSON.stringify(getDotenvCliOptions) }
2567
+ : undefined;
2187
2568
  await runCommand(command, shell, {
2188
2569
  cwd: path,
2189
- env: {
2190
- ...process.env,
2191
- getDotenvCliOptions: getDotenvCliOptions
2192
- ? JSON.stringify(getDotenvCliOptions)
2193
- : undefined,
2194
- },
2570
+ env: buildSpawnEnv(process.env, envBag),
2195
2571
  stdio: capture ? 'pipe' : 'inherit',
2196
2572
  });
2197
2573
  }
@@ -2549,7 +2925,9 @@ const attachParentAlias = (cli, options, _cmd) => {
2549
2925
  aliasHandled = true;
2550
2926
  dbg('alias-only invocation detected');
2551
2927
  // Merge CLI options and resolve dotenv context.
2552
- const { merged } = resolveCliOptions(o, baseRootOptionDefaults, process.env.getDotenvCliOptions);
2928
+ const { merged } = resolveCliOptions(o,
2929
+ // cast through unknown to avoid readonly -> mutable incompatibilities
2930
+ baseRootOptionDefaults, process.env.getDotenvCliOptions);
2553
2931
  const logger = merged.logger ?? console;
2554
2932
  const serviceOptions = getDotenvCliOptions2Options(merged);
2555
2933
  await cli.resolveAndLoad(serviceOptions);
@@ -2602,7 +2980,41 @@ const attachParentAlias = (cli, options, _cmd) => {
2602
2980
  : parent !== undefined
2603
2981
  ? 'parent'
2604
2982
  : 'unset';
2605
- process.stderr.write(`[trace] key=${k} origin=${origin} parent=${parent ?? ''} dotenv=${dot ?? ''} final=${final ?? ''}\n`);
2983
+ // Build redact options and triple bag without undefined-valued fields
2984
+ const redOpts = {};
2985
+ const redFlag = merged.redact;
2986
+ const redPatterns = merged
2987
+ .redactPatterns;
2988
+ if (redFlag)
2989
+ redOpts.redact = true;
2990
+ if (redFlag && Array.isArray(redPatterns))
2991
+ redOpts.redactPatterns = redPatterns;
2992
+ const tripleBag = {};
2993
+ if (parent !== undefined)
2994
+ tripleBag.parent = parent;
2995
+ if (dot !== undefined)
2996
+ tripleBag.dotenv = dot;
2997
+ if (final !== undefined)
2998
+ tripleBag.final = final;
2999
+ const triple = redactTriple(k, tripleBag, redOpts);
3000
+ process.stderr.write(`[trace] key=${k} origin=${origin} parent=${triple.parent ?? ''} dotenv=${triple.dotenv ?? ''} final=${triple.final ?? ''}\n`);
3001
+ const entOpts = {};
3002
+ const warnEntropy = merged.warnEntropy;
3003
+ const entropyThreshold = merged
3004
+ .entropyThreshold;
3005
+ const entropyMinLength = merged
3006
+ .entropyMinLength;
3007
+ const entropyWhitelist = merged
3008
+ .entropyWhitelist;
3009
+ if (typeof warnEntropy === 'boolean')
3010
+ entOpts.warnEntropy = warnEntropy;
3011
+ if (typeof entropyThreshold === 'number')
3012
+ entOpts.entropyThreshold = entropyThreshold;
3013
+ if (typeof entropyMinLength === 'number')
3014
+ entOpts.entropyMinLength = entropyMinLength;
3015
+ if (Array.isArray(entropyWhitelist))
3016
+ entOpts.entropyWhitelist = entropyWhitelist;
3017
+ maybeWarnEntropy(k, final, origin, entOpts, (line) => process.stderr.write(line + '\n'));
2606
3018
  }
2607
3019
  }
2608
3020
  let exitCode = Number.NaN;
@@ -2657,11 +3069,10 @@ const attachParentAlias = (cli, options, _cmd) => {
2657
3069
  }
2658
3070
  }
2659
3071
  exitCode = await runCommand(commandArg, shellSetting, {
2660
- env: {
2661
- ...process.env,
3072
+ env: buildSpawnEnv(process.env, {
2662
3073
  ...dotenv,
2663
3074
  getDotenvCliOptions: JSON.stringify(envBag),
2664
- },
3075
+ }),
2665
3076
  stdio: capture ? 'pipe' : 'inherit',
2666
3077
  });
2667
3078
  dbg('run:done', { exitCode });
@@ -2819,8 +3230,41 @@ const cmdPlugin = (options = {}) => definePlugin({
2819
3230
  : parent !== undefined
2820
3231
  ? 'parent'
2821
3232
  : 'unset';
3233
+ // Apply presentation-time redaction (if enabled)
3234
+ const redFlag = merged.redact;
3235
+ const redPatterns = merged
3236
+ .redactPatterns;
3237
+ const redOpts = {};
3238
+ if (redFlag)
3239
+ redOpts.redact = true;
3240
+ if (redFlag && Array.isArray(redPatterns))
3241
+ redOpts.redactPatterns = redPatterns;
3242
+ const tripleBag = {};
3243
+ if (parent !== undefined)
3244
+ tripleBag.parent = parent;
3245
+ if (dot !== undefined)
3246
+ tripleBag.dotenv = dot;
3247
+ if (final !== undefined)
3248
+ tripleBag.final = final;
3249
+ const triple = redactTriple(k, tripleBag, redOpts);
2822
3250
  // Emit concise diagnostic line to stderr.
2823
- process.stderr.write(`[trace] key=${k} origin=${origin} parent=${parent ?? ''} dotenv=${dot ?? ''} final=${final ?? ''}\n`);
3251
+ process.stderr.write(`[trace] key=${k} origin=${origin} parent=${triple.parent ?? ''} dotenv=${triple.dotenv ?? ''} final=${triple.final ?? ''}\n`);
3252
+ // Optional entropy warning (once-per-key)
3253
+ const entOpts = {};
3254
+ const warnEntropy = merged
3255
+ .warnEntropy;
3256
+ const entropyThreshold = merged.entropyThreshold;
3257
+ const entropyMinLength = merged.entropyMinLength;
3258
+ const entropyWhitelist = merged.entropyWhitelist;
3259
+ if (typeof warnEntropy === 'boolean')
3260
+ entOpts.warnEntropy = warnEntropy;
3261
+ if (typeof entropyThreshold === 'number')
3262
+ entOpts.entropyThreshold = entropyThreshold;
3263
+ if (typeof entropyMinLength === 'number')
3264
+ entOpts.entropyMinLength = entropyMinLength;
3265
+ if (Array.isArray(entropyWhitelist))
3266
+ entOpts.entropyWhitelist = entropyWhitelist;
3267
+ maybeWarnEntropy(k, final, origin, entOpts, (line) => process.stderr.write(line + '\n'));
2824
3268
  }
2825
3269
  }
2826
3270
  const shellSetting = resolveShell(scripts, input, shell);
@@ -2838,11 +3282,10 @@ const cmdPlugin = (options = {}) => definePlugin({
2838
3282
  ? args.map(String)
2839
3283
  : resolved;
2840
3284
  await runCommand(commandArg, shellSetting, {
2841
- env: {
2842
- ...process.env,
3285
+ env: buildSpawnEnv(process.env, {
2843
3286
  ...dotenv,
2844
3287
  getDotenvCliOptions: JSON.stringify(envBag),
2845
- },
3288
+ }),
2846
3289
  stdio: capture ? 'pipe' : 'inherit',
2847
3290
  });
2848
3291
  });