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