@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/index.cjs CHANGED
@@ -7,11 +7,11 @@ var path = require('path');
7
7
  var execa = require('execa');
8
8
  var fs = require('fs-extra');
9
9
  var url = require('url');
10
+ var YAML = require('yaml');
11
+ var zod = require('zod');
10
12
  var nanoid = require('nanoid');
11
13
  var dotenv = require('dotenv');
12
14
  var crypto = require('crypto');
13
- var YAML = require('yaml');
14
- var zod = require('zod');
15
15
 
16
16
  var _documentCurrentScript = typeof document !== 'undefined' ? document.currentScript : null;
17
17
  /**
@@ -232,6 +232,7 @@ const attachRootOptions = (program, defaults, opts) => {
232
232
  .addOption(new commander.Option('-l, --log', `console log loaded variables ON${log ? ' (default)' : ''}`).conflicts('logOff'))
233
233
  .addOption(new commander.Option('-L, --log-off', `console log loaded variables OFF${!log ? ' (default)' : ''}`).conflicts('log'))
234
234
  .option('--capture', 'capture child process stdio for commands (tests/CI)')
235
+ .option('--redact', 'mask secret-like values in logs/trace (presentation-only)')
235
236
  .option('--default-env <string>', 'default target environment', dotenvExpandFromProcessEnv, defaultEnv)
236
237
  .option('--dotenv-token <string>', 'dotenv-expanded token indicating a dotenv file', dotenvExpandFromProcessEnv, dotenvToken)
237
238
  .option('--dynamic-path <string>', 'dynamic variables path (.js or .ts; .ts is auto-compiled when esbuild is available, otherwise precompile)', dotenvExpandFromProcessEnv, dynamicPath)
@@ -249,6 +250,16 @@ const attachRootOptions = (program, defaults, opts) => {
249
250
  .hideHelp());
250
251
  // Diagnostics: opt-in tracing; optional variadic keys after the flag.
251
252
  p = p.option('--trace [keys...]', 'emit diagnostics for child env composition (optional keys)');
253
+ // Validation: strict mode fails on env validation issues (warn by default).
254
+ p = p.option('--strict', 'fail on env validation errors (schema/requiredKeys)');
255
+ // Entropy diagnostics (presentation-only)
256
+ p = p
257
+ .addOption(new commander.Option('--entropy-warn', 'enable entropy warnings (default on)').conflicts('entropyWarnOff'))
258
+ .addOption(new commander.Option('--entropy-warn-off', 'disable entropy warnings').conflicts('entropyWarn'))
259
+ .option('--entropy-threshold <number>', 'entropy bits/char threshold (default 3.8)')
260
+ .option('--entropy-min-length <number>', 'min length to examine for entropy (default 16)')
261
+ .option('--entropy-whitelist <pattern...>', 'suppress entropy warnings when key matches any regex pattern')
262
+ .option('--redact-pattern <pattern...>', 'additional key-match regex patterns to trigger redaction');
252
263
  // Restore original methods to avoid tagging future additions outside base.
253
264
  program.addOption = originalAddOption;
254
265
  program.option = originalOption;
@@ -420,6 +431,48 @@ const runCommand = async (command, shell, opts) => {
420
431
  }
421
432
  };
422
433
 
434
+ const dropUndefined = (bag) => Object.fromEntries(Object.entries(bag).filter((e) => typeof e[1] === 'string'));
435
+ /** Build a sanitized env for child processes from base + overlay. */
436
+ const buildSpawnEnv = (base, overlay) => {
437
+ const raw = {
438
+ ...(base ?? {}),
439
+ ...(overlay ?? {}),
440
+ };
441
+ // Drop undefined first
442
+ const entries = Object.entries(dropUndefined(raw));
443
+ if (process.platform === 'win32') {
444
+ // Windows: keys are case-insensitive; collapse duplicates
445
+ const byLower = new Map();
446
+ for (const [k, v] of entries) {
447
+ byLower.set(k.toLowerCase(), [k, v]); // last wins; preserve latest casing
448
+ }
449
+ const out = {};
450
+ for (const [, [k, v]] of byLower)
451
+ out[k] = v;
452
+ // HOME fallback from USERPROFILE (common expectation)
453
+ if (!Object.prototype.hasOwnProperty.call(out, 'HOME')) {
454
+ const up = out['USERPROFILE'];
455
+ if (typeof up === 'string' && up.length > 0)
456
+ out['HOME'] = up;
457
+ }
458
+ // Normalize TMP/TEMP coherence (pick any present; reflect to both)
459
+ const tmp = out['TMP'] ?? out['TEMP'];
460
+ if (typeof tmp === 'string' && tmp.length > 0) {
461
+ out['TMP'] = tmp;
462
+ out['TEMP'] = tmp;
463
+ }
464
+ return out;
465
+ }
466
+ // POSIX: keep keys as-is
467
+ const out = Object.fromEntries(entries);
468
+ // Ensure TMPDIR exists when any temp key is present (best-effort)
469
+ const tmpdir = out['TMPDIR'] ?? out['TMP'] ?? out['TEMP'];
470
+ if (typeof tmpdir === 'string' && tmpdir.length > 0) {
471
+ out['TMPDIR'] = tmpdir;
472
+ }
473
+ return out;
474
+ };
475
+
423
476
  const globPaths = async ({ globs, logger, pkgCwd, rootPath, }) => {
424
477
  let cwd = process.cwd();
425
478
  if (pkgCwd) {
@@ -492,14 +545,12 @@ const execShellCommandBatch = async ({ command, getDotenvCliOptions, globs, igno
492
545
  const hasCmd = (typeof command === 'string' && command.length > 0) ||
493
546
  (Array.isArray(command) && command.length > 0);
494
547
  if (hasCmd) {
548
+ const envBag = getDotenvCliOptions !== undefined
549
+ ? { getDotenvCliOptions: JSON.stringify(getDotenvCliOptions) }
550
+ : undefined;
495
551
  await runCommand(command, shell, {
496
552
  cwd: path,
497
- env: {
498
- ...process.env,
499
- getDotenvCliOptions: getDotenvCliOptions
500
- ? JSON.stringify(getDotenvCliOptions)
501
- : undefined,
502
- },
553
+ env: buildSpawnEnv(process.env, envBag),
503
554
  stdio: capture ? 'pipe' : 'inherit',
504
555
  });
505
556
  }
@@ -652,6 +703,11 @@ const baseRootOptionDefaults = {
652
703
  dotenvToken: '.env',
653
704
  loadProcess: true,
654
705
  logger: console,
706
+ // Diagnostics defaults
707
+ warnEntropy: true,
708
+ entropyThreshold: 3.8,
709
+ entropyMinLength: 16,
710
+ entropyWhitelist: ['^GIT_', '^npm_', '^CI$', 'SHLVL'],
655
711
  paths: './',
656
712
  pathsDelimiter: ' ',
657
713
  privateToken: 'local',
@@ -672,7 +728,7 @@ const baseRootOptionDefaults = {
672
728
  const baseGetDotenvCliOptions = baseRootOptionDefaults;
673
729
 
674
730
  /** @internal */
675
- const isPlainObject = (value) => value !== null &&
731
+ const isPlainObject$1 = (value) => value !== null &&
676
732
  typeof value === 'object' &&
677
733
  Object.getPrototypeOf(value) === Object.prototype;
678
734
  const mergeInto = (target, source) => {
@@ -680,10 +736,10 @@ const mergeInto = (target, source) => {
680
736
  if (sVal === undefined)
681
737
  continue; // do not overwrite with undefined
682
738
  const tVal = target[key];
683
- if (isPlainObject(tVal) && isPlainObject(sVal)) {
739
+ if (isPlainObject$1(tVal) && isPlainObject$1(sVal)) {
684
740
  target[key] = mergeInto({ ...tVal }, sVal);
685
741
  }
686
- else if (isPlainObject(sVal)) {
742
+ else if (isPlainObject$1(sVal)) {
687
743
  target[key] = mergeInto({}, sVal);
688
744
  }
689
745
  else {
@@ -933,7 +989,7 @@ const resolveCliOptions = (rawCliOptions, defaults, parentJson) => {
933
989
  const parent = typeof parentJson === 'string' && parentJson.length > 0
934
990
  ? JSON.parse(parentJson)
935
991
  : undefined;
936
- const { command, debugOff, excludeAll, excludeAllOff, excludeDynamicOff, excludeEnvOff, excludeGlobalOff, excludePrivateOff, excludePublicOff, loadProcessOff, logOff, scripts, shellOff, ...rest } = rawCliOptions;
992
+ const { command, debugOff, excludeAll, excludeAllOff, excludeDynamicOff, excludeEnvOff, excludeGlobalOff, excludePrivateOff, excludePublicOff, loadProcessOff, logOff, entropyWarn, entropyWarnOff, scripts, shellOff, ...rest } = rawCliOptions;
937
993
  const current = { ...rest };
938
994
  if (typeof scripts === 'string') {
939
995
  try {
@@ -953,6 +1009,8 @@ const resolveCliOptions = (rawCliOptions, defaults, parentJson) => {
953
1009
  setOptionalFlag(merged, 'excludePublic', resolveExclusionAll(merged.excludePublic, excludePublicOff, d.excludePublic, excludeAll, excludeAllOff));
954
1010
  setOptionalFlag(merged, 'log', resolveExclusion(merged.log, logOff, d.log));
955
1011
  setOptionalFlag(merged, 'loadProcess', resolveExclusion(merged.loadProcess, loadProcessOff, d.loadProcess));
1012
+ // warnEntropy (tri-state)
1013
+ setOptionalFlag(merged, 'warnEntropy', resolveExclusion(merged.warnEntropy, entropyWarnOff, d.warnEntropy));
956
1014
  // Normalize shell for predictability: explicit default shell per OS.
957
1015
  const defaultShell = process.platform === 'win32' ? 'powershell.exe' : '/bin/bash';
958
1016
  let resolvedShell = merged.shell;
@@ -970,6 +1028,315 @@ const resolveCliOptions = (rawCliOptions, defaults, parentJson) => {
970
1028
  return cmd !== undefined ? { merged, command: cmd } : { merged };
971
1029
  };
972
1030
 
1031
+ /**
1032
+ * Zod schemas for configuration files discovered by the new loader.
1033
+ *
1034
+ * Notes:
1035
+ * - RAW: all fields optional; shapes are stringly-friendly (paths may be string[] or string).
1036
+ * - RESOLVED: normalized shapes (paths always string[]).
1037
+ * - For JSON/YAML configs, the loader rejects "dynamic" and "schema" (JS/TS-only).
1038
+ */
1039
+ // String-only env value map
1040
+ const stringMap = zod.z.record(zod.z.string(), zod.z.string());
1041
+ const envStringMap = zod.z.record(zod.z.string(), stringMap);
1042
+ // Allow string[] or single string for "paths" in RAW; normalize later.
1043
+ const rawPathsSchema = zod.z.union([zod.z.array(zod.z.string()), zod.z.string()]).optional();
1044
+ const getDotenvConfigSchemaRaw = zod.z.object({
1045
+ dotenvToken: zod.z.string().optional(),
1046
+ privateToken: zod.z.string().optional(),
1047
+ paths: rawPathsSchema,
1048
+ loadProcess: zod.z.boolean().optional(),
1049
+ log: zod.z.boolean().optional(),
1050
+ shell: zod.z.union([zod.z.string(), zod.z.boolean()]).optional(),
1051
+ scripts: zod.z.record(zod.z.string(), zod.z.unknown()).optional(), // Scripts validation left wide; generator validates elsewhere
1052
+ requiredKeys: zod.z.array(zod.z.string()).optional(),
1053
+ schema: zod.z.unknown().optional(), // JS/TS-only; loader rejects in JSON/YAML
1054
+ vars: stringMap.optional(), // public, global
1055
+ envVars: envStringMap.optional(), // public, per-env
1056
+ // Dynamic in config (JS/TS only). JSON/YAML loader will reject if set.
1057
+ dynamic: zod.z.unknown().optional(),
1058
+ // Per-plugin config bag; validated by plugins/host when used.
1059
+ plugins: zod.z.record(zod.z.string(), zod.z.unknown()).optional(),
1060
+ });
1061
+ // Normalize paths to string[]
1062
+ const normalizePaths = (p) => p === undefined ? undefined : Array.isArray(p) ? p : [p];
1063
+ const getDotenvConfigSchemaResolved = getDotenvConfigSchemaRaw.transform((raw) => ({
1064
+ ...raw,
1065
+ paths: normalizePaths(raw.paths),
1066
+ }));
1067
+
1068
+ // Discovery candidates (first match wins per scope/privacy).
1069
+ // Order preserves historical JSON/YAML precedence; JS/TS added afterwards.
1070
+ const PUBLIC_FILENAMES = [
1071
+ 'getdotenv.config.json',
1072
+ 'getdotenv.config.yaml',
1073
+ 'getdotenv.config.yml',
1074
+ 'getdotenv.config.js',
1075
+ 'getdotenv.config.mjs',
1076
+ 'getdotenv.config.cjs',
1077
+ 'getdotenv.config.ts',
1078
+ 'getdotenv.config.mts',
1079
+ 'getdotenv.config.cts',
1080
+ ];
1081
+ const LOCAL_FILENAMES = [
1082
+ 'getdotenv.config.local.json',
1083
+ 'getdotenv.config.local.yaml',
1084
+ 'getdotenv.config.local.yml',
1085
+ 'getdotenv.config.local.js',
1086
+ 'getdotenv.config.local.mjs',
1087
+ 'getdotenv.config.local.cjs',
1088
+ 'getdotenv.config.local.ts',
1089
+ 'getdotenv.config.local.mts',
1090
+ 'getdotenv.config.local.cts',
1091
+ ];
1092
+ const isYaml = (p) => ['.yaml', '.yml'].includes(path.extname(p).toLowerCase());
1093
+ const isJson = (p) => path.extname(p).toLowerCase() === '.json';
1094
+ const isJsOrTs = (p) => ['.js', '.mjs', '.cjs', '.ts', '.mts', '.cts'].includes(path.extname(p).toLowerCase());
1095
+ // --- Internal JS/TS module loader helpers (default export) ---
1096
+ const importDefault$1 = async (fileUrl) => {
1097
+ const mod = (await import(fileUrl));
1098
+ return mod.default;
1099
+ };
1100
+ const cacheName = (absPath, suffix) => {
1101
+ // sanitized filename with suffix; recompile on mtime changes not tracked here (simplified)
1102
+ const base = path.basename(absPath).replace(/[^a-zA-Z0-9._-]/g, '_');
1103
+ return `${base}.${suffix}.mjs`;
1104
+ };
1105
+ const ensureDir = async (dir) => {
1106
+ await fs.ensureDir(dir);
1107
+ return dir;
1108
+ };
1109
+ const loadJsTsDefault = async (absPath) => {
1110
+ const fileUrl = url.pathToFileURL(absPath).toString();
1111
+ const ext = path.extname(absPath).toLowerCase();
1112
+ if (ext === '.js' || ext === '.mjs' || ext === '.cjs') {
1113
+ return importDefault$1(fileUrl);
1114
+ }
1115
+ // Try direct import first in case a TS loader is active.
1116
+ try {
1117
+ const val = await importDefault$1(fileUrl);
1118
+ if (val)
1119
+ return val;
1120
+ }
1121
+ catch {
1122
+ /* fallthrough */
1123
+ }
1124
+ // esbuild bundle to a temp ESM file
1125
+ try {
1126
+ const esbuild = (await import('esbuild'));
1127
+ const outDir = await ensureDir(path.resolve('.tsbuild', 'getdotenv-config'));
1128
+ const outfile = path.join(outDir, cacheName(absPath, 'bundle'));
1129
+ await esbuild.build({
1130
+ entryPoints: [absPath],
1131
+ bundle: true,
1132
+ platform: 'node',
1133
+ format: 'esm',
1134
+ target: 'node20',
1135
+ outfile,
1136
+ sourcemap: false,
1137
+ logLevel: 'silent',
1138
+ });
1139
+ return await importDefault$1(url.pathToFileURL(outfile).toString());
1140
+ }
1141
+ catch {
1142
+ /* fallthrough to TS transpile */
1143
+ }
1144
+ // typescript.transpileModule simple transpile (single-file)
1145
+ try {
1146
+ const ts = (await import('typescript'));
1147
+ const src = await fs.readFile(absPath, 'utf-8');
1148
+ const out = ts.transpileModule(src, {
1149
+ compilerOptions: {
1150
+ module: 'ESNext',
1151
+ target: 'ES2022',
1152
+ moduleResolution: 'NodeNext',
1153
+ },
1154
+ }).outputText;
1155
+ const outDir = await ensureDir(path.resolve('.tsbuild', 'getdotenv-config'));
1156
+ const outfile = path.join(outDir, cacheName(absPath, 'ts'));
1157
+ await fs.writeFile(outfile, out, 'utf-8');
1158
+ return await importDefault$1(url.pathToFileURL(outfile).toString());
1159
+ }
1160
+ catch {
1161
+ throw new Error(`Unable to load JS/TS config: ${absPath}. Install 'esbuild' for robust bundling or ensure a TS loader.`);
1162
+ }
1163
+ };
1164
+ /**
1165
+ * Discover JSON/YAML config files in the packaged root and project root.
1166
+ * Order: packaged public → project public → project local. */
1167
+ const discoverConfigFiles = async (importMetaUrl) => {
1168
+ const files = [];
1169
+ // Packaged root via importMetaUrl (optional)
1170
+ if (importMetaUrl) {
1171
+ const fromUrl = url.fileURLToPath(importMetaUrl);
1172
+ const packagedRoot = await packageDirectory.packageDirectory({ cwd: fromUrl });
1173
+ if (packagedRoot) {
1174
+ for (const name of PUBLIC_FILENAMES) {
1175
+ const p = path.join(packagedRoot, name);
1176
+ if (await fs.pathExists(p)) {
1177
+ files.push({ path: p, privacy: 'public', scope: 'packaged' });
1178
+ break; // only one public file expected per scope
1179
+ }
1180
+ }
1181
+ // By policy, packaged .local is not expected; skip even if present.
1182
+ }
1183
+ }
1184
+ // Project root (from current working directory)
1185
+ const projectRoot = await packageDirectory.packageDirectory();
1186
+ if (projectRoot) {
1187
+ for (const name of PUBLIC_FILENAMES) {
1188
+ const p = path.join(projectRoot, name);
1189
+ if (await fs.pathExists(p)) {
1190
+ files.push({ path: p, privacy: 'public', scope: 'project' });
1191
+ break;
1192
+ }
1193
+ }
1194
+ for (const name of LOCAL_FILENAMES) {
1195
+ const p = path.join(projectRoot, name);
1196
+ if (await fs.pathExists(p)) {
1197
+ files.push({ path: p, privacy: 'local', scope: 'project' });
1198
+ break;
1199
+ }
1200
+ }
1201
+ }
1202
+ return files;
1203
+ };
1204
+ /**
1205
+ * Load a single config file (JSON/YAML). JS/TS is not supported in this step.
1206
+ * Validates with Zod RAW schema, then normalizes to RESOLVED.
1207
+ *
1208
+ * For JSON/YAML: if a "dynamic" property is present, throws with guidance.
1209
+ * For JS/TS: default export is loaded; "dynamic" is allowed.
1210
+ */
1211
+ const loadConfigFile = async (filePath) => {
1212
+ let raw = {};
1213
+ try {
1214
+ const abs = path.resolve(filePath);
1215
+ if (isJsOrTs(abs)) {
1216
+ // JS/TS support: load default export via robust pipeline.
1217
+ const mod = await loadJsTsDefault(abs);
1218
+ raw = mod ?? {};
1219
+ }
1220
+ else {
1221
+ const txt = await fs.readFile(abs, 'utf-8');
1222
+ raw = isJson(abs) ? JSON.parse(txt) : isYaml(abs) ? YAML.parse(txt) : {};
1223
+ }
1224
+ }
1225
+ catch (err) {
1226
+ throw new Error(`Failed to read/parse config: ${filePath}. ${String(err)}`);
1227
+ }
1228
+ // Validate RAW
1229
+ const parsed = getDotenvConfigSchemaRaw.safeParse(raw);
1230
+ if (!parsed.success) {
1231
+ const msgs = parsed.error.issues
1232
+ .map((i) => `${i.path.join('.')}: ${i.message}`)
1233
+ .join('\n');
1234
+ throw new Error(`Invalid config ${filePath}:\n${msgs}`);
1235
+ }
1236
+ // Disallow dynamic and schema in JSON/YAML; allow both in JS/TS.
1237
+ if (!isJsOrTs(filePath) &&
1238
+ (parsed.data.dynamic !== undefined || parsed.data.schema !== undefined)) {
1239
+ throw new Error(`Config ${filePath} specifies unsupported keys for JSON/YAML. ` +
1240
+ `Use JS/TS config for "dynamic" or "schema".`);
1241
+ }
1242
+ return getDotenvConfigSchemaResolved.parse(parsed.data);
1243
+ };
1244
+ /**
1245
+ * Discover and load configs into resolved shapes, ordered by scope/privacy.
1246
+ * JSON/YAML/JS/TS supported; first match per scope/privacy applies.
1247
+ */
1248
+ const resolveGetDotenvConfigSources = async (importMetaUrl) => {
1249
+ const discovered = await discoverConfigFiles(importMetaUrl);
1250
+ const result = {};
1251
+ for (const f of discovered) {
1252
+ const cfg = await loadConfigFile(f.path);
1253
+ if (f.scope === 'packaged') {
1254
+ // packaged public only
1255
+ result.packaged = cfg;
1256
+ }
1257
+ else {
1258
+ result.project ??= {};
1259
+ if (f.privacy === 'public')
1260
+ result.project.public = cfg;
1261
+ else
1262
+ result.project.local = cfg;
1263
+ }
1264
+ }
1265
+ return result;
1266
+ };
1267
+
1268
+ /** src/diagnostics/entropy.ts
1269
+ * Entropy diagnostics (presentation-only).
1270
+ * - Gated by min length and printable ASCII.
1271
+ * - Warn once per key per run when bits/char \>= threshold.
1272
+ * - Supports whitelist patterns to suppress known-noise keys.
1273
+ */
1274
+ const warned = new Set();
1275
+ const isPrintableAscii = (s) => /^[\x20-\x7E]+$/.test(s);
1276
+ const compile$1 = (patterns) => (patterns ?? []).map((p) => new RegExp(p, 'i'));
1277
+ const whitelisted = (key, regs) => regs.some((re) => re.test(key));
1278
+ const shannonBitsPerChar = (s) => {
1279
+ const freq = new Map();
1280
+ for (const ch of s)
1281
+ freq.set(ch, (freq.get(ch) ?? 0) + 1);
1282
+ const n = s.length;
1283
+ let h = 0;
1284
+ for (const c of freq.values()) {
1285
+ const p = c / n;
1286
+ h -= p * Math.log2(p);
1287
+ }
1288
+ return h;
1289
+ };
1290
+ /**
1291
+ * Maybe emit a one-line entropy warning for a key.
1292
+ * Caller supplies an `emit(line)` function; the helper ensures once-per-key.
1293
+ */
1294
+ const maybeWarnEntropy = (key, value, origin, opts, emit) => {
1295
+ if (!opts || opts.warnEntropy === false)
1296
+ return;
1297
+ if (warned.has(key))
1298
+ return;
1299
+ const v = value ?? '';
1300
+ const minLen = Math.max(0, opts.entropyMinLength ?? 16);
1301
+ const threshold = opts.entropyThreshold ?? 3.8;
1302
+ if (v.length < minLen)
1303
+ return;
1304
+ if (!isPrintableAscii(v))
1305
+ return;
1306
+ const wl = compile$1(opts.entropyWhitelist);
1307
+ if (whitelisted(key, wl))
1308
+ return;
1309
+ const bpc = shannonBitsPerChar(v);
1310
+ if (bpc >= threshold) {
1311
+ warned.add(key);
1312
+ emit(`[entropy] key=${key} score=${bpc.toFixed(2)} len=${String(v.length)} origin=${origin}`);
1313
+ }
1314
+ };
1315
+
1316
+ const DEFAULT_PATTERNS = [
1317
+ '\\bsecret\\b',
1318
+ '\\btoken\\b',
1319
+ '\\bpass(word)?\\b',
1320
+ '\\bapi[_-]?key\\b',
1321
+ '\\bkey\\b',
1322
+ ];
1323
+ const compile = (patterns) => (patterns && patterns.length > 0 ? patterns : DEFAULT_PATTERNS).map((p) => new RegExp(p, 'i'));
1324
+ const shouldRedactKey = (key, regs) => regs.some((re) => re.test(key));
1325
+ const MASK = '[redacted]';
1326
+ /**
1327
+ * Produce a shallow redacted copy of an env-like object for display.
1328
+ */
1329
+ const redactObject = (obj, opts) => {
1330
+ if (!opts?.redact)
1331
+ return { ...obj };
1332
+ const regs = compile(opts.redactPatterns);
1333
+ const out = {};
1334
+ for (const [k, v] of Object.entries(obj)) {
1335
+ out[k] = v && shouldRedactKey(k, regs) ? MASK : v;
1336
+ }
1337
+ return out;
1338
+ };
1339
+
973
1340
  const applyKv = (current, kv) => {
974
1341
  if (!kv || Object.keys(kv).length === 0)
975
1342
  return current;
@@ -1025,7 +1392,7 @@ const readDotenv = async (path) => {
1025
1392
  }
1026
1393
  };
1027
1394
 
1028
- const importDefault$1 = async (fileUrl) => {
1395
+ const importDefault = async (fileUrl) => {
1029
1396
  const mod = (await import(fileUrl));
1030
1397
  return mod.default;
1031
1398
  };
@@ -1076,11 +1443,11 @@ const loadModuleDefault = async (absPath, cacheDirName) => {
1076
1443
  const ext = path.extname(absPath).toLowerCase();
1077
1444
  const fileUrl = url.pathToFileURL(absPath).toString();
1078
1445
  if (!['.ts', '.mts', '.cts', '.tsx'].includes(ext)) {
1079
- return importDefault$1(fileUrl);
1446
+ return importDefault(fileUrl);
1080
1447
  }
1081
1448
  // Try direct import first (TS loader active)
1082
1449
  try {
1083
- const dyn = await importDefault$1(fileUrl);
1450
+ const dyn = await importDefault(fileUrl);
1084
1451
  if (dyn)
1085
1452
  return dyn;
1086
1453
  }
@@ -1105,7 +1472,7 @@ const loadModuleDefault = async (absPath, cacheDirName) => {
1105
1472
  sourcemap: false,
1106
1473
  logLevel: 'silent',
1107
1474
  });
1108
- const result = await importDefault$1(url.pathToFileURL(cacheFile).toString());
1475
+ const result = await importDefault(url.pathToFileURL(cacheFile).toString());
1109
1476
  // Best-effort: trim older cache files for this source.
1110
1477
  await cleanupOldCacheFiles(cacheDir, path.basename(absPath));
1111
1478
  return result;
@@ -1125,7 +1492,7 @@ const loadModuleDefault = async (absPath, cacheDirName) => {
1125
1492
  },
1126
1493
  }).outputText;
1127
1494
  await fs.writeFile(cacheFile, out, 'utf-8');
1128
- const result = await importDefault$1(url.pathToFileURL(cacheFile).toString());
1495
+ const result = await importDefault(url.pathToFileURL(cacheFile).toString());
1129
1496
  // Best-effort: trim older cache files for this source.
1130
1497
  await cleanupOldCacheFiles(cacheDir, path.basename(absPath));
1131
1498
  return result;
@@ -1259,8 +1626,41 @@ const getDotenv = async (options = {}) => {
1259
1626
  resultDotenv = dotenvForOutput;
1260
1627
  }
1261
1628
  // Log result.
1262
- if (log)
1263
- logger.log(resultDotenv);
1629
+ if (log) {
1630
+ const redactFlag = options.redact ?? false;
1631
+ const redactPatterns = options.redactPatterns ?? undefined;
1632
+ const redOpts = {};
1633
+ if (redactFlag)
1634
+ redOpts.redact = true;
1635
+ if (redactFlag && Array.isArray(redactPatterns))
1636
+ redOpts.redactPatterns = redactPatterns;
1637
+ const bag = redactFlag
1638
+ ? redactObject(resultDotenv, redOpts)
1639
+ : { ...resultDotenv };
1640
+ logger.log(bag);
1641
+ // Entropy warnings: once-per-key-per-run (presentation only)
1642
+ const warnEntropyVal = options.warnEntropy ?? true;
1643
+ const entropyThresholdVal = options
1644
+ .entropyThreshold;
1645
+ const entropyMinLengthVal = options
1646
+ .entropyMinLength;
1647
+ const entropyWhitelistVal = options
1648
+ .entropyWhitelist;
1649
+ const entOpts = {};
1650
+ if (typeof warnEntropyVal === 'boolean')
1651
+ entOpts.warnEntropy = warnEntropyVal;
1652
+ if (typeof entropyThresholdVal === 'number')
1653
+ entOpts.entropyThreshold = entropyThresholdVal;
1654
+ if (typeof entropyMinLengthVal === 'number')
1655
+ entOpts.entropyMinLength = entropyMinLengthVal;
1656
+ if (Array.isArray(entropyWhitelistVal))
1657
+ entOpts.entropyWhitelist = entropyWhitelistVal;
1658
+ for (const [k, v] of Object.entries(resultDotenv)) {
1659
+ maybeWarnEntropy(k, v, v !== undefined ? 'dotenv' : 'unset', entOpts, (line) => {
1660
+ logger.log(line);
1661
+ });
1662
+ }
1663
+ }
1264
1664
  // Load process.env.
1265
1665
  if (loadProcess)
1266
1666
  Object.assign(process.env, resultDotenv);
@@ -1268,235 +1668,59 @@ const getDotenv = async (options = {}) => {
1268
1668
  };
1269
1669
 
1270
1670
  /**
1271
- * Zod schemas for configuration files discovered by the new loader. *
1272
- * Notes:
1273
- * - RAW: all fields optional; shapes are stringly-friendly (paths may be string[] or string).
1274
- * - RESOLVED: normalized shapes (paths always string[]).
1275
- * - For this step (JSON/YAML only), any defined `dynamic` will be rejected by the loader.
1671
+ * Deep interpolation utility for string leaves.
1672
+ * - Expands string values using dotenv-style expansion against the provided envRef.
1673
+ * - Preserves non-strings as-is.
1674
+ * - Does not recurse into arrays (arrays are returned unchanged).
1675
+ *
1676
+ * Intended for:
1677
+ * - Phase C option/config interpolation after composing ctx.dotenv.
1678
+ * - Per-plugin config slice interpolation before afterResolve.
1276
1679
  */
1277
- // String-only env value map
1278
- const stringMap = zod.z.record(zod.z.string(), zod.z.string());
1279
- const envStringMap = zod.z.record(zod.z.string(), stringMap);
1280
- // Allow string[] or single string for "paths" in RAW; normalize later.
1281
- const rawPathsSchema = zod.z.union([zod.z.array(zod.z.string()), zod.z.string()]).optional();
1282
- const getDotenvConfigSchemaRaw = zod.z.object({
1283
- dotenvToken: zod.z.string().optional(),
1284
- privateToken: zod.z.string().optional(),
1285
- paths: rawPathsSchema,
1286
- loadProcess: zod.z.boolean().optional(),
1287
- log: zod.z.boolean().optional(),
1288
- shell: zod.z.union([zod.z.string(), zod.z.boolean()]).optional(),
1289
- scripts: zod.z.record(zod.z.string(), zod.z.unknown()).optional(), // Scripts validation left wide; generator validates elsewhere
1290
- vars: stringMap.optional(), // public, global
1291
- envVars: envStringMap.optional(), // public, per-env
1292
- // Dynamic in config (JS/TS only). JSON/YAML loader will reject if set.
1293
- dynamic: zod.z.unknown().optional(),
1294
- // Per-plugin config bag; validated by plugins/host when used.
1295
- plugins: zod.z.record(zod.z.string(), zod.z.unknown()).optional(),
1296
- });
1297
- // Normalize paths to string[]
1298
- const normalizePaths = (p) => p === undefined ? undefined : Array.isArray(p) ? p : [p];
1299
- const getDotenvConfigSchemaResolved = getDotenvConfigSchemaRaw.transform((raw) => ({
1300
- ...raw,
1301
- paths: normalizePaths(raw.paths),
1302
- }));
1303
-
1304
- // Discovery candidates (first match wins per scope/privacy).
1305
- // Order preserves historical JSON/YAML precedence; JS/TS added afterwards.
1306
- const PUBLIC_FILENAMES = [
1307
- 'getdotenv.config.json',
1308
- 'getdotenv.config.yaml',
1309
- 'getdotenv.config.yml',
1310
- 'getdotenv.config.js',
1311
- 'getdotenv.config.mjs',
1312
- 'getdotenv.config.cjs',
1313
- 'getdotenv.config.ts',
1314
- 'getdotenv.config.mts',
1315
- 'getdotenv.config.cts',
1316
- ];
1317
- const LOCAL_FILENAMES = [
1318
- 'getdotenv.config.local.json',
1319
- 'getdotenv.config.local.yaml',
1320
- 'getdotenv.config.local.yml',
1321
- 'getdotenv.config.local.js',
1322
- 'getdotenv.config.local.mjs',
1323
- 'getdotenv.config.local.cjs',
1324
- 'getdotenv.config.local.ts',
1325
- 'getdotenv.config.local.mts',
1326
- 'getdotenv.config.local.cts',
1327
- ];
1328
- const isYaml = (p) => ['.yaml', '.yml'].includes(path.extname(p).toLowerCase());
1329
- const isJson = (p) => path.extname(p).toLowerCase() === '.json';
1330
- const isJsOrTs = (p) => ['.js', '.mjs', '.cjs', '.ts', '.mts', '.cts'].includes(path.extname(p).toLowerCase());
1331
- // --- Internal JS/TS module loader helpers (default export) ---
1332
- const importDefault = async (fileUrl) => {
1333
- const mod = (await import(fileUrl));
1334
- return mod.default;
1335
- };
1336
- const cacheName = (absPath, suffix) => {
1337
- // sanitized filename with suffix; recompile on mtime changes not tracked here (simplified)
1338
- const base = path.basename(absPath).replace(/[^a-zA-Z0-9._-]/g, '_');
1339
- return `${base}.${suffix}.mjs`;
1340
- };
1341
- const ensureDir = async (dir) => {
1342
- await fs.ensureDir(dir);
1343
- return dir;
1344
- };
1345
- const loadJsTsDefault = async (absPath) => {
1346
- const fileUrl = url.pathToFileURL(absPath).toString();
1347
- const ext = path.extname(absPath).toLowerCase();
1348
- if (ext === '.js' || ext === '.mjs' || ext === '.cjs') {
1349
- return importDefault(fileUrl);
1350
- }
1351
- // Try direct import first in case a TS loader is active.
1352
- try {
1353
- const val = await importDefault(fileUrl);
1354
- if (val)
1355
- return val;
1356
- }
1357
- catch {
1358
- /* fallthrough */
1359
- }
1360
- // esbuild bundle to a temp ESM file
1361
- try {
1362
- const esbuild = (await import('esbuild'));
1363
- const outDir = await ensureDir(path.resolve('.tsbuild', 'getdotenv-config'));
1364
- const outfile = path.join(outDir, cacheName(absPath, 'bundle'));
1365
- await esbuild.build({
1366
- entryPoints: [absPath],
1367
- bundle: true,
1368
- platform: 'node',
1369
- format: 'esm',
1370
- target: 'node20',
1371
- outfile,
1372
- sourcemap: false,
1373
- logLevel: 'silent',
1374
- });
1375
- return await importDefault(url.pathToFileURL(outfile).toString());
1376
- }
1377
- catch {
1378
- /* fallthrough to TS transpile */
1379
- }
1380
- // typescript.transpileModule simple transpile (single-file)
1381
- try {
1382
- const ts = (await import('typescript'));
1383
- const src = await fs.readFile(absPath, 'utf-8');
1384
- const out = ts.transpileModule(src, {
1385
- compilerOptions: {
1386
- module: 'ESNext',
1387
- target: 'ES2022',
1388
- moduleResolution: 'NodeNext',
1389
- },
1390
- }).outputText;
1391
- const outDir = await ensureDir(path.resolve('.tsbuild', 'getdotenv-config'));
1392
- const outfile = path.join(outDir, cacheName(absPath, 'ts'));
1393
- await fs.writeFile(outfile, out, 'utf-8');
1394
- return await importDefault(url.pathToFileURL(outfile).toString());
1395
- }
1396
- catch {
1397
- throw new Error(`Unable to load JS/TS config: ${absPath}. Install 'esbuild' for robust bundling or ensure a TS loader.`);
1398
- }
1399
- };
1400
- /**
1401
- * Discover JSON/YAML config files in the packaged root and project root.
1402
- * Order: packaged public → project public → project local. */
1403
- const discoverConfigFiles = async (importMetaUrl) => {
1404
- const files = [];
1405
- // Packaged root via importMetaUrl (optional)
1406
- if (importMetaUrl) {
1407
- const fromUrl = url.fileURLToPath(importMetaUrl);
1408
- const packagedRoot = await packageDirectory.packageDirectory({ cwd: fromUrl });
1409
- if (packagedRoot) {
1410
- for (const name of PUBLIC_FILENAMES) {
1411
- const p = path.join(packagedRoot, name);
1412
- if (await fs.pathExists(p)) {
1413
- files.push({ path: p, privacy: 'public', scope: 'packaged' });
1414
- break; // only one public file expected per scope
1415
- }
1416
- }
1417
- // By policy, packaged .local is not expected; skip even if present.
1418
- }
1419
- }
1420
- // Project root (from current working directory)
1421
- const projectRoot = await packageDirectory.packageDirectory();
1422
- if (projectRoot) {
1423
- for (const name of PUBLIC_FILENAMES) {
1424
- const p = path.join(projectRoot, name);
1425
- if (await fs.pathExists(p)) {
1426
- files.push({ path: p, privacy: 'public', scope: 'project' });
1427
- break;
1428
- }
1429
- }
1430
- for (const name of LOCAL_FILENAMES) {
1431
- const p = path.join(projectRoot, name);
1432
- if (await fs.pathExists(p)) {
1433
- files.push({ path: p, privacy: 'local', scope: 'project' });
1434
- break;
1435
- }
1436
- }
1437
- }
1438
- return files;
1439
- };
1680
+ /** @internal */
1681
+ const isPlainObject = (v) => v !== null &&
1682
+ typeof v === 'object' &&
1683
+ !Array.isArray(v) &&
1684
+ Object.getPrototypeOf(v) === Object.prototype;
1440
1685
  /**
1441
- * Load a single config file (JSON/YAML). JS/TS is not supported in this step.
1442
- * Validates with Zod RAW schema, then normalizes to RESOLVED.
1686
+ * Deeply interpolate string leaves against envRef.
1687
+ * Arrays are not recursed into; they are returned unchanged.
1443
1688
  *
1444
- * For JSON/YAML: if a "dynamic" property is present, throws with guidance.
1445
- * For JS/TS: default export is loaded; "dynamic" is allowed.
1689
+ * @typeParam T - Shape of the input value.
1690
+ * @param value - Input value (object/array/primitive).
1691
+ * @param envRef - Reference environment for interpolation.
1692
+ * @returns A new value with string leaves interpolated.
1446
1693
  */
1447
- const loadConfigFile = async (filePath) => {
1448
- let raw = {};
1449
- try {
1450
- const abs = path.resolve(filePath);
1451
- if (isJsOrTs(abs)) {
1452
- // JS/TS support: load default export via robust pipeline.
1453
- const mod = await loadJsTsDefault(abs);
1454
- raw = mod ?? {};
1455
- }
1456
- else {
1457
- const txt = await fs.readFile(abs, 'utf-8');
1458
- raw = isJson(abs) ? JSON.parse(txt) : isYaml(abs) ? YAML.parse(txt) : {};
1459
- }
1460
- }
1461
- catch (err) {
1462
- throw new Error(`Failed to read/parse config: ${filePath}. ${String(err)}`);
1463
- }
1464
- // Validate RAW
1465
- const parsed = getDotenvConfigSchemaRaw.safeParse(raw);
1466
- if (!parsed.success) {
1467
- const msgs = parsed.error.issues
1468
- .map((i) => `${i.path.join('.')}: ${i.message}`)
1469
- .join('\n');
1470
- throw new Error(`Invalid config ${filePath}:\n${msgs}`);
1694
+ const interpolateDeep = (value, envRef) => {
1695
+ // Strings: expand and return
1696
+ if (typeof value === 'string') {
1697
+ const out = dotenvExpand(value, envRef);
1698
+ // dotenvExpand returns string | undefined; preserve original on undefined
1699
+ return (out ?? value);
1471
1700
  }
1472
- // Disallow dynamic in JSON/YAML; allow in JS/TS
1473
- if (!isJsOrTs(filePath) && parsed.data.dynamic !== undefined) {
1474
- throw new Error(`Config ${filePath} specifies "dynamic"; JSON/YAML configs cannot include dynamic in this step. Use JS/TS config.`);
1701
+ // Arrays: return as-is (no recursion)
1702
+ if (Array.isArray(value)) {
1703
+ return value;
1475
1704
  }
1476
- return getDotenvConfigSchemaResolved.parse(parsed.data);
1477
- };
1478
- /**
1479
- * Discover and load configs into resolved shapes, ordered by scope/privacy.
1480
- * JSON/YAML/JS/TS supported; first match per scope/privacy applies.
1481
- */
1482
- const resolveGetDotenvConfigSources = async (importMetaUrl) => {
1483
- const discovered = await discoverConfigFiles(importMetaUrl);
1484
- const result = {};
1485
- for (const f of discovered) {
1486
- const cfg = await loadConfigFile(f.path);
1487
- if (f.scope === 'packaged') {
1488
- // packaged public only
1489
- result.packaged = cfg;
1490
- }
1491
- else {
1492
- result.project ??= {};
1493
- if (f.privacy === 'public')
1494
- result.project.public = cfg;
1705
+ // Plain objects: shallow clone and recurse into values
1706
+ if (isPlainObject(value)) {
1707
+ const src = value;
1708
+ const out = {};
1709
+ for (const [k, v] of Object.entries(src)) {
1710
+ // Recurse for strings/objects; keep arrays as-is; preserve other scalars
1711
+ if (typeof v === 'string')
1712
+ out[k] = dotenvExpand(v, envRef) ?? v;
1713
+ else if (Array.isArray(v))
1714
+ out[k] = v;
1715
+ else if (isPlainObject(v))
1716
+ out[k] = interpolateDeep(v, envRef);
1495
1717
  else
1496
- result.project.local = cfg;
1718
+ out[k] = v;
1497
1719
  }
1720
+ return out;
1498
1721
  }
1499
- return result;
1722
+ // Other primitives/types: return as-is
1723
+ return value;
1500
1724
  };
1501
1725
 
1502
1726
  /**
@@ -1508,7 +1732,9 @@ const resolveGetDotenvConfigSources = async (importMetaUrl) => {
1508
1732
  * 2) Discover packaged + project config sources and overlay onto base.
1509
1733
  * 3) Apply dynamics in order:
1510
1734
  * programmatic dynamic \> config dynamic (packaged → project public → project local)
1511
- * \> file dynamicPath. * 4) Optionally write outputPath, log, and merge into process.env.
1735
+ * \> file dynamicPath.
1736
+ * 4) Phase C interpolation of remaining string options (e.g., outputPath).
1737
+ * 5) Optionally write outputPath, log, and merge into process.env.
1512
1738
  */
1513
1739
  const resolveDotenvWithConfigLoader = async (validated) => {
1514
1740
  // 1) Base from files, no dynamic, no programmatic vars
@@ -1556,21 +1782,112 @@ const resolveDotenvWithConfigLoader = async (validated) => {
1556
1782
  throw new Error(`Unable to load dynamic from ${validated.dynamicPath}`);
1557
1783
  }
1558
1784
  }
1559
- // 4) Output/log/process merge
1560
- if (validated.outputPath) {
1561
- await fs.writeFile(validated.outputPath, Object.keys(dotenv).reduce((contents, key) => {
1785
+ // 4) Phase C: interpolate remaining string options (exclude bootstrap set).
1786
+ // For now, interpolate outputPath only; bootstrap keys are excluded by design.
1787
+ const envRef = { ...process.env, ...dotenv };
1788
+ const outputPathInterpolated = typeof validated.outputPath === 'string'
1789
+ ? interpolateDeep(validated.outputPath, envRef)
1790
+ : undefined;
1791
+ // 5) Output/log/process merge (use interpolated outputPath if present)
1792
+ if (outputPathInterpolated) {
1793
+ await fs.writeFile(outputPathInterpolated, Object.keys(dotenv).reduce((contents, key) => {
1562
1794
  const value = dotenv[key] ?? '';
1563
1795
  return `${contents}${key}=${value.includes('\n') ? `"${value}"` : value}\n`;
1564
1796
  }, ''), { encoding: 'utf-8' });
1565
1797
  }
1566
1798
  const logger = validated.logger ?? console;
1567
- if (validated.log)
1568
- logger.log(dotenv);
1799
+ if (validated.log) {
1800
+ const redactFlag = validated.redact ?? false;
1801
+ const redactPatterns = validated.redactPatterns;
1802
+ const redOpts = {};
1803
+ if (redactFlag)
1804
+ redOpts.redact = true;
1805
+ if (redactFlag && Array.isArray(redactPatterns))
1806
+ redOpts.redactPatterns = redactPatterns;
1807
+ const bag = redactFlag ? redactObject(dotenv, redOpts) : { ...dotenv };
1808
+ logger.log(bag);
1809
+ // Entropy warnings: once per key per run (presentation only)
1810
+ const warnEntropyVal = validated.warnEntropy ?? true;
1811
+ const entropyThresholdVal = validated.entropyThreshold;
1812
+ const entropyMinLengthVal = validated.entropyMinLength;
1813
+ const entropyWhitelistVal = validated.entropyWhitelist;
1814
+ const entOpts = {};
1815
+ // include keys only when defined to satisfy exactOptionalPropertyTypes
1816
+ if (typeof warnEntropyVal === 'boolean')
1817
+ entOpts.warnEntropy = warnEntropyVal;
1818
+ if (typeof entropyThresholdVal === 'number')
1819
+ entOpts.entropyThreshold = entropyThresholdVal;
1820
+ if (typeof entropyMinLengthVal === 'number')
1821
+ entOpts.entropyMinLength = entropyMinLengthVal;
1822
+ if (Array.isArray(entropyWhitelistVal))
1823
+ entOpts.entropyWhitelist = entropyWhitelistVal;
1824
+ for (const [k, v] of Object.entries(dotenv)) {
1825
+ maybeWarnEntropy(k, v, v !== undefined ? 'dotenv' : 'unset', entOpts, (line) => {
1826
+ logger.log(line);
1827
+ });
1828
+ }
1829
+ }
1569
1830
  if (validated.loadProcess)
1570
1831
  Object.assign(process.env, dotenv);
1571
1832
  return dotenv;
1572
1833
  };
1573
1834
 
1835
+ /**
1836
+ * Validate a composed env against config-provided validation surfaces.
1837
+ * Precedence for validation definitions:
1838
+ * project.local -\> project.public -\> packaged
1839
+ *
1840
+ * Behavior:
1841
+ * - If a JS/TS `schema` is present, use schema.safeParse(finalEnv).
1842
+ * - Else if `requiredKeys` is present, check presence (value !== undefined).
1843
+ * - Returns a flat list of issue strings; caller decides warn vs fail.
1844
+ */
1845
+ const validateEnvAgainstSources = (finalEnv, sources) => {
1846
+ const pick = (getter) => {
1847
+ const pl = sources.project?.local;
1848
+ const pp = sources.project?.public;
1849
+ const pk = sources.packaged;
1850
+ return ((pl && getter(pl)) ||
1851
+ (pp && getter(pp)) ||
1852
+ (pk && getter(pk)) ||
1853
+ undefined);
1854
+ };
1855
+ const schema = pick((cfg) => cfg['schema']);
1856
+ if (schema &&
1857
+ typeof schema.safeParse === 'function') {
1858
+ try {
1859
+ const parsed = schema.safeParse(finalEnv);
1860
+ if (!parsed.success) {
1861
+ // Try to render zod-style issues when available.
1862
+ const err = parsed.error;
1863
+ const issues = Array.isArray(err.issues) && err.issues.length > 0
1864
+ ? err.issues.map((i) => {
1865
+ const path = Array.isArray(i.path) ? i.path.join('.') : '';
1866
+ const msg = i.message ?? 'Invalid value';
1867
+ return path ? `[schema] ${path}: ${msg}` : `[schema] ${msg}`;
1868
+ })
1869
+ : ['[schema] validation failed'];
1870
+ return issues;
1871
+ }
1872
+ return [];
1873
+ }
1874
+ catch {
1875
+ // If schema invocation fails, surface a single diagnostic.
1876
+ return [
1877
+ '[schema] validation failed (unable to execute schema.safeParse)',
1878
+ ];
1879
+ }
1880
+ }
1881
+ const requiredKeys = pick((cfg) => cfg['requiredKeys']);
1882
+ if (Array.isArray(requiredKeys) && requiredKeys.length > 0) {
1883
+ const missing = requiredKeys.filter((k) => finalEnv[k] === undefined);
1884
+ if (missing.length > 0) {
1885
+ return missing.map((k) => `[requiredKeys] missing: ${k}`);
1886
+ }
1887
+ }
1888
+ return [];
1889
+ };
1890
+
1574
1891
  /**
1575
1892
  * Omit a "logger" key from an options object in a typed manner.
1576
1893
  */
@@ -1609,6 +1926,22 @@ const makePreSubcommandHook = ({ logger, preHook, postHook, defaults, }) => {
1609
1926
  // Execute getdotenv via always-on config loader/overlay path.
1610
1927
  const serviceOptions = getDotenvCliOptions2Options(mergedGetDotenvCliOptions);
1611
1928
  const dotenv = await resolveDotenvWithConfigLoader(serviceOptions);
1929
+ // Global validation against config (warn by default; --strict fails).
1930
+ try {
1931
+ const sources = await resolveGetDotenvConfigSources((typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === 'SCRIPT' && _documentCurrentScript.src || new URL('index.cjs', document.baseURI).href)));
1932
+ const issues = validateEnvAgainstSources(dotenv, sources);
1933
+ if (Array.isArray(issues) && issues.length > 0) {
1934
+ issues.forEach((m) => {
1935
+ logger.error(m);
1936
+ });
1937
+ if (mergedGetDotenvCliOptions.strict) {
1938
+ process.exit(1);
1939
+ }
1940
+ }
1941
+ }
1942
+ catch {
1943
+ // Tolerate validator failures in non-strict mode
1944
+ }
1612
1945
  // Execute post-hook.
1613
1946
  if (postHook)
1614
1947
  await postHook(dotenv); // Execute command.
@@ -1679,3 +2012,4 @@ exports.dotenvExpandFromProcessEnv = dotenvExpandFromProcessEnv;
1679
2012
  exports.generateGetDotenvCli = generateGetDotenvCli;
1680
2013
  exports.getDotenv = getDotenv;
1681
2014
  exports.getDotenvCliOptions2Options = getDotenvCliOptions2Options;
2015
+ exports.interpolateDeep = interpolateDeep;