@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/README.md +14 -0
- package/dist/cliHost.cjs +222 -19
- package/dist/cliHost.d.cts +17 -0
- package/dist/cliHost.d.mts +17 -0
- package/dist/cliHost.d.ts +17 -0
- package/dist/cliHost.mjs +222 -19
- package/dist/config.cjs +10 -5
- package/dist/config.d.cts +2 -0
- package/dist/config.d.mts +2 -0
- package/dist/config.d.ts +2 -0
- package/dist/config.mjs +10 -5
- package/dist/env-overlay.d.cts +2 -0
- package/dist/env-overlay.d.mts +2 -0
- package/dist/env-overlay.d.ts +2 -0
- package/dist/getdotenv.cli.mjs +479 -36
- package/dist/index.cjs +579 -245
- package/dist/index.d.cts +36 -1
- package/dist/index.d.mts +36 -1
- package/dist/index.d.ts +36 -1
- package/dist/index.mjs +579 -246
- package/dist/plugins-aws.cjs +43 -1
- package/dist/plugins-aws.d.cts +17 -0
- package/dist/plugins-aws.d.mts +17 -0
- package/dist/plugins-aws.d.ts +17 -0
- package/dist/plugins-aws.mjs +43 -1
- package/dist/plugins-batch.cjs +46 -6
- package/dist/plugins-batch.d.cts +17 -0
- package/dist/plugins-batch.d.mts +17 -0
- package/dist/plugins-batch.d.ts +17 -0
- package/dist/plugins-batch.mjs +46 -6
- package/dist/plugins-init.d.cts +17 -0
- package/dist/plugins-init.d.mts +17 -0
- package/dist/plugins-init.d.ts +17 -0
- package/package.json +25 -24
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.
|
|
@@ -229,6 +229,7 @@ const attachRootOptions = (program, defaults, opts) => {
|
|
|
229
229
|
.addOption(new Option('-l, --log', `console log loaded variables ON${log ? ' (default)' : ''}`).conflicts('logOff'))
|
|
230
230
|
.addOption(new Option('-L, --log-off', `console log loaded variables OFF${!log ? ' (default)' : ''}`).conflicts('log'))
|
|
231
231
|
.option('--capture', 'capture child process stdio for commands (tests/CI)')
|
|
232
|
+
.option('--redact', 'mask secret-like values in logs/trace (presentation-only)')
|
|
232
233
|
.option('--default-env <string>', 'default target environment', dotenvExpandFromProcessEnv, defaultEnv)
|
|
233
234
|
.option('--dotenv-token <string>', 'dotenv-expanded token indicating a dotenv file', dotenvExpandFromProcessEnv, dotenvToken)
|
|
234
235
|
.option('--dynamic-path <string>', 'dynamic variables path (.js or .ts; .ts is auto-compiled when esbuild is available, otherwise precompile)', dotenvExpandFromProcessEnv, dynamicPath)
|
|
@@ -246,6 +247,16 @@ const attachRootOptions = (program, defaults, opts) => {
|
|
|
246
247
|
.hideHelp());
|
|
247
248
|
// Diagnostics: opt-in tracing; optional variadic keys after the flag.
|
|
248
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');
|
|
249
260
|
// Restore original methods to avoid tagging future additions outside base.
|
|
250
261
|
program.addOption = originalAddOption;
|
|
251
262
|
program.option = originalOption;
|
|
@@ -417,6 +428,48 @@ const runCommand = async (command, shell, opts) => {
|
|
|
417
428
|
}
|
|
418
429
|
};
|
|
419
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
|
+
|
|
420
473
|
const globPaths = async ({ globs, logger, pkgCwd, rootPath, }) => {
|
|
421
474
|
let cwd = process.cwd();
|
|
422
475
|
if (pkgCwd) {
|
|
@@ -489,14 +542,12 @@ const execShellCommandBatch = async ({ command, getDotenvCliOptions, globs, igno
|
|
|
489
542
|
const hasCmd = (typeof command === 'string' && command.length > 0) ||
|
|
490
543
|
(Array.isArray(command) && command.length > 0);
|
|
491
544
|
if (hasCmd) {
|
|
545
|
+
const envBag = getDotenvCliOptions !== undefined
|
|
546
|
+
? { getDotenvCliOptions: JSON.stringify(getDotenvCliOptions) }
|
|
547
|
+
: undefined;
|
|
492
548
|
await runCommand(command, shell, {
|
|
493
549
|
cwd: path,
|
|
494
|
-
env:
|
|
495
|
-
...process.env,
|
|
496
|
-
getDotenvCliOptions: getDotenvCliOptions
|
|
497
|
-
? JSON.stringify(getDotenvCliOptions)
|
|
498
|
-
: undefined,
|
|
499
|
-
},
|
|
550
|
+
env: buildSpawnEnv(process.env, envBag),
|
|
500
551
|
stdio: capture ? 'pipe' : 'inherit',
|
|
501
552
|
});
|
|
502
553
|
}
|
|
@@ -649,6 +700,11 @@ const baseRootOptionDefaults = {
|
|
|
649
700
|
dotenvToken: '.env',
|
|
650
701
|
loadProcess: true,
|
|
651
702
|
logger: console,
|
|
703
|
+
// Diagnostics defaults
|
|
704
|
+
warnEntropy: true,
|
|
705
|
+
entropyThreshold: 3.8,
|
|
706
|
+
entropyMinLength: 16,
|
|
707
|
+
entropyWhitelist: ['^GIT_', '^npm_', '^CI$', 'SHLVL'],
|
|
652
708
|
paths: './',
|
|
653
709
|
pathsDelimiter: ' ',
|
|
654
710
|
privateToken: 'local',
|
|
@@ -669,7 +725,7 @@ const baseRootOptionDefaults = {
|
|
|
669
725
|
const baseGetDotenvCliOptions = baseRootOptionDefaults;
|
|
670
726
|
|
|
671
727
|
/** @internal */
|
|
672
|
-
const isPlainObject = (value) => value !== null &&
|
|
728
|
+
const isPlainObject$1 = (value) => value !== null &&
|
|
673
729
|
typeof value === 'object' &&
|
|
674
730
|
Object.getPrototypeOf(value) === Object.prototype;
|
|
675
731
|
const mergeInto = (target, source) => {
|
|
@@ -677,10 +733,10 @@ const mergeInto = (target, source) => {
|
|
|
677
733
|
if (sVal === undefined)
|
|
678
734
|
continue; // do not overwrite with undefined
|
|
679
735
|
const tVal = target[key];
|
|
680
|
-
if (isPlainObject(tVal) && isPlainObject(sVal)) {
|
|
736
|
+
if (isPlainObject$1(tVal) && isPlainObject$1(sVal)) {
|
|
681
737
|
target[key] = mergeInto({ ...tVal }, sVal);
|
|
682
738
|
}
|
|
683
|
-
else if (isPlainObject(sVal)) {
|
|
739
|
+
else if (isPlainObject$1(sVal)) {
|
|
684
740
|
target[key] = mergeInto({}, sVal);
|
|
685
741
|
}
|
|
686
742
|
else {
|
|
@@ -930,7 +986,7 @@ const resolveCliOptions = (rawCliOptions, defaults, parentJson) => {
|
|
|
930
986
|
const parent = typeof parentJson === 'string' && parentJson.length > 0
|
|
931
987
|
? JSON.parse(parentJson)
|
|
932
988
|
: undefined;
|
|
933
|
-
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;
|
|
934
990
|
const current = { ...rest };
|
|
935
991
|
if (typeof scripts === 'string') {
|
|
936
992
|
try {
|
|
@@ -950,6 +1006,8 @@ const resolveCliOptions = (rawCliOptions, defaults, parentJson) => {
|
|
|
950
1006
|
setOptionalFlag(merged, 'excludePublic', resolveExclusionAll(merged.excludePublic, excludePublicOff, d.excludePublic, excludeAll, excludeAllOff));
|
|
951
1007
|
setOptionalFlag(merged, 'log', resolveExclusion(merged.log, logOff, d.log));
|
|
952
1008
|
setOptionalFlag(merged, 'loadProcess', resolveExclusion(merged.loadProcess, loadProcessOff, d.loadProcess));
|
|
1009
|
+
// warnEntropy (tri-state)
|
|
1010
|
+
setOptionalFlag(merged, 'warnEntropy', resolveExclusion(merged.warnEntropy, entropyWarnOff, d.warnEntropy));
|
|
953
1011
|
// Normalize shell for predictability: explicit default shell per OS.
|
|
954
1012
|
const defaultShell = process.platform === 'win32' ? 'powershell.exe' : '/bin/bash';
|
|
955
1013
|
let resolvedShell = merged.shell;
|
|
@@ -967,6 +1025,315 @@ const resolveCliOptions = (rawCliOptions, defaults, parentJson) => {
|
|
|
967
1025
|
return cmd !== undefined ? { merged, command: cmd } : { merged };
|
|
968
1026
|
};
|
|
969
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
|
+
|
|
970
1337
|
const applyKv = (current, kv) => {
|
|
971
1338
|
if (!kv || Object.keys(kv).length === 0)
|
|
972
1339
|
return current;
|
|
@@ -1022,7 +1389,7 @@ const readDotenv = async (path) => {
|
|
|
1022
1389
|
}
|
|
1023
1390
|
};
|
|
1024
1391
|
|
|
1025
|
-
const importDefault
|
|
1392
|
+
const importDefault = async (fileUrl) => {
|
|
1026
1393
|
const mod = (await import(fileUrl));
|
|
1027
1394
|
return mod.default;
|
|
1028
1395
|
};
|
|
@@ -1073,11 +1440,11 @@ const loadModuleDefault = async (absPath, cacheDirName) => {
|
|
|
1073
1440
|
const ext = path.extname(absPath).toLowerCase();
|
|
1074
1441
|
const fileUrl = url.pathToFileURL(absPath).toString();
|
|
1075
1442
|
if (!['.ts', '.mts', '.cts', '.tsx'].includes(ext)) {
|
|
1076
|
-
return importDefault
|
|
1443
|
+
return importDefault(fileUrl);
|
|
1077
1444
|
}
|
|
1078
1445
|
// Try direct import first (TS loader active)
|
|
1079
1446
|
try {
|
|
1080
|
-
const dyn = await importDefault
|
|
1447
|
+
const dyn = await importDefault(fileUrl);
|
|
1081
1448
|
if (dyn)
|
|
1082
1449
|
return dyn;
|
|
1083
1450
|
}
|
|
@@ -1102,7 +1469,7 @@ const loadModuleDefault = async (absPath, cacheDirName) => {
|
|
|
1102
1469
|
sourcemap: false,
|
|
1103
1470
|
logLevel: 'silent',
|
|
1104
1471
|
});
|
|
1105
|
-
const result = await importDefault
|
|
1472
|
+
const result = await importDefault(url.pathToFileURL(cacheFile).toString());
|
|
1106
1473
|
// Best-effort: trim older cache files for this source.
|
|
1107
1474
|
await cleanupOldCacheFiles(cacheDir, path.basename(absPath));
|
|
1108
1475
|
return result;
|
|
@@ -1122,7 +1489,7 @@ const loadModuleDefault = async (absPath, cacheDirName) => {
|
|
|
1122
1489
|
},
|
|
1123
1490
|
}).outputText;
|
|
1124
1491
|
await fs.writeFile(cacheFile, out, 'utf-8');
|
|
1125
|
-
const result = await importDefault
|
|
1492
|
+
const result = await importDefault(url.pathToFileURL(cacheFile).toString());
|
|
1126
1493
|
// Best-effort: trim older cache files for this source.
|
|
1127
1494
|
await cleanupOldCacheFiles(cacheDir, path.basename(absPath));
|
|
1128
1495
|
return result;
|
|
@@ -1256,8 +1623,41 @@ const getDotenv = async (options = {}) => {
|
|
|
1256
1623
|
resultDotenv = dotenvForOutput;
|
|
1257
1624
|
}
|
|
1258
1625
|
// Log result.
|
|
1259
|
-
if (log)
|
|
1260
|
-
|
|
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
|
+
}
|
|
1261
1661
|
// Load process.env.
|
|
1262
1662
|
if (loadProcess)
|
|
1263
1663
|
Object.assign(process.env, resultDotenv);
|
|
@@ -1265,235 +1665,59 @@ const getDotenv = async (options = {}) => {
|
|
|
1265
1665
|
};
|
|
1266
1666
|
|
|
1267
1667
|
/**
|
|
1268
|
-
*
|
|
1269
|
-
*
|
|
1270
|
-
* -
|
|
1271
|
-
* -
|
|
1272
|
-
*
|
|
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.
|
|
1273
1676
|
*/
|
|
1274
|
-
|
|
1275
|
-
const
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
const getDotenvConfigSchemaRaw = z.object({
|
|
1280
|
-
dotenvToken: z.string().optional(),
|
|
1281
|
-
privateToken: z.string().optional(),
|
|
1282
|
-
paths: rawPathsSchema,
|
|
1283
|
-
loadProcess: z.boolean().optional(),
|
|
1284
|
-
log: z.boolean().optional(),
|
|
1285
|
-
shell: z.union([z.string(), z.boolean()]).optional(),
|
|
1286
|
-
scripts: z.record(z.string(), z.unknown()).optional(), // Scripts validation left wide; generator validates elsewhere
|
|
1287
|
-
vars: stringMap.optional(), // public, global
|
|
1288
|
-
envVars: envStringMap.optional(), // public, per-env
|
|
1289
|
-
// Dynamic in config (JS/TS only). JSON/YAML loader will reject if set.
|
|
1290
|
-
dynamic: z.unknown().optional(),
|
|
1291
|
-
// Per-plugin config bag; validated by plugins/host when used.
|
|
1292
|
-
plugins: z.record(z.string(), z.unknown()).optional(),
|
|
1293
|
-
});
|
|
1294
|
-
// Normalize paths to string[]
|
|
1295
|
-
const normalizePaths = (p) => p === undefined ? undefined : Array.isArray(p) ? p : [p];
|
|
1296
|
-
const getDotenvConfigSchemaResolved = getDotenvConfigSchemaRaw.transform((raw) => ({
|
|
1297
|
-
...raw,
|
|
1298
|
-
paths: normalizePaths(raw.paths),
|
|
1299
|
-
}));
|
|
1300
|
-
|
|
1301
|
-
// Discovery candidates (first match wins per scope/privacy).
|
|
1302
|
-
// Order preserves historical JSON/YAML precedence; JS/TS added afterwards.
|
|
1303
|
-
const PUBLIC_FILENAMES = [
|
|
1304
|
-
'getdotenv.config.json',
|
|
1305
|
-
'getdotenv.config.yaml',
|
|
1306
|
-
'getdotenv.config.yml',
|
|
1307
|
-
'getdotenv.config.js',
|
|
1308
|
-
'getdotenv.config.mjs',
|
|
1309
|
-
'getdotenv.config.cjs',
|
|
1310
|
-
'getdotenv.config.ts',
|
|
1311
|
-
'getdotenv.config.mts',
|
|
1312
|
-
'getdotenv.config.cts',
|
|
1313
|
-
];
|
|
1314
|
-
const LOCAL_FILENAMES = [
|
|
1315
|
-
'getdotenv.config.local.json',
|
|
1316
|
-
'getdotenv.config.local.yaml',
|
|
1317
|
-
'getdotenv.config.local.yml',
|
|
1318
|
-
'getdotenv.config.local.js',
|
|
1319
|
-
'getdotenv.config.local.mjs',
|
|
1320
|
-
'getdotenv.config.local.cjs',
|
|
1321
|
-
'getdotenv.config.local.ts',
|
|
1322
|
-
'getdotenv.config.local.mts',
|
|
1323
|
-
'getdotenv.config.local.cts',
|
|
1324
|
-
];
|
|
1325
|
-
const isYaml = (p) => ['.yaml', '.yml'].includes(extname(p).toLowerCase());
|
|
1326
|
-
const isJson = (p) => extname(p).toLowerCase() === '.json';
|
|
1327
|
-
const isJsOrTs = (p) => ['.js', '.mjs', '.cjs', '.ts', '.mts', '.cts'].includes(extname(p).toLowerCase());
|
|
1328
|
-
// --- Internal JS/TS module loader helpers (default export) ---
|
|
1329
|
-
const importDefault = async (fileUrl) => {
|
|
1330
|
-
const mod = (await import(fileUrl));
|
|
1331
|
-
return mod.default;
|
|
1332
|
-
};
|
|
1333
|
-
const cacheName = (absPath, suffix) => {
|
|
1334
|
-
// sanitized filename with suffix; recompile on mtime changes not tracked here (simplified)
|
|
1335
|
-
const base = path.basename(absPath).replace(/[^a-zA-Z0-9._-]/g, '_');
|
|
1336
|
-
return `${base}.${suffix}.mjs`;
|
|
1337
|
-
};
|
|
1338
|
-
const ensureDir = async (dir) => {
|
|
1339
|
-
await fs.ensureDir(dir);
|
|
1340
|
-
return dir;
|
|
1341
|
-
};
|
|
1342
|
-
const loadJsTsDefault = async (absPath) => {
|
|
1343
|
-
const fileUrl = pathToFileURL(absPath).toString();
|
|
1344
|
-
const ext = extname(absPath).toLowerCase();
|
|
1345
|
-
if (ext === '.js' || ext === '.mjs' || ext === '.cjs') {
|
|
1346
|
-
return importDefault(fileUrl);
|
|
1347
|
-
}
|
|
1348
|
-
// Try direct import first in case a TS loader is active.
|
|
1349
|
-
try {
|
|
1350
|
-
const val = await importDefault(fileUrl);
|
|
1351
|
-
if (val)
|
|
1352
|
-
return val;
|
|
1353
|
-
}
|
|
1354
|
-
catch {
|
|
1355
|
-
/* fallthrough */
|
|
1356
|
-
}
|
|
1357
|
-
// esbuild bundle to a temp ESM file
|
|
1358
|
-
try {
|
|
1359
|
-
const esbuild = (await import('esbuild'));
|
|
1360
|
-
const outDir = await ensureDir(path.resolve('.tsbuild', 'getdotenv-config'));
|
|
1361
|
-
const outfile = path.join(outDir, cacheName(absPath, 'bundle'));
|
|
1362
|
-
await esbuild.build({
|
|
1363
|
-
entryPoints: [absPath],
|
|
1364
|
-
bundle: true,
|
|
1365
|
-
platform: 'node',
|
|
1366
|
-
format: 'esm',
|
|
1367
|
-
target: 'node20',
|
|
1368
|
-
outfile,
|
|
1369
|
-
sourcemap: false,
|
|
1370
|
-
logLevel: 'silent',
|
|
1371
|
-
});
|
|
1372
|
-
return await importDefault(pathToFileURL(outfile).toString());
|
|
1373
|
-
}
|
|
1374
|
-
catch {
|
|
1375
|
-
/* fallthrough to TS transpile */
|
|
1376
|
-
}
|
|
1377
|
-
// typescript.transpileModule simple transpile (single-file)
|
|
1378
|
-
try {
|
|
1379
|
-
const ts = (await import('typescript'));
|
|
1380
|
-
const src = await fs.readFile(absPath, 'utf-8');
|
|
1381
|
-
const out = ts.transpileModule(src, {
|
|
1382
|
-
compilerOptions: {
|
|
1383
|
-
module: 'ESNext',
|
|
1384
|
-
target: 'ES2022',
|
|
1385
|
-
moduleResolution: 'NodeNext',
|
|
1386
|
-
},
|
|
1387
|
-
}).outputText;
|
|
1388
|
-
const outDir = await ensureDir(path.resolve('.tsbuild', 'getdotenv-config'));
|
|
1389
|
-
const outfile = path.join(outDir, cacheName(absPath, 'ts'));
|
|
1390
|
-
await fs.writeFile(outfile, out, 'utf-8');
|
|
1391
|
-
return await importDefault(pathToFileURL(outfile).toString());
|
|
1392
|
-
}
|
|
1393
|
-
catch {
|
|
1394
|
-
throw new Error(`Unable to load JS/TS config: ${absPath}. Install 'esbuild' for robust bundling or ensure a TS loader.`);
|
|
1395
|
-
}
|
|
1396
|
-
};
|
|
1397
|
-
/**
|
|
1398
|
-
* Discover JSON/YAML config files in the packaged root and project root.
|
|
1399
|
-
* Order: packaged public → project public → project local. */
|
|
1400
|
-
const discoverConfigFiles = async (importMetaUrl) => {
|
|
1401
|
-
const files = [];
|
|
1402
|
-
// Packaged root via importMetaUrl (optional)
|
|
1403
|
-
if (importMetaUrl) {
|
|
1404
|
-
const fromUrl = fileURLToPath(importMetaUrl);
|
|
1405
|
-
const packagedRoot = await packageDirectory({ cwd: fromUrl });
|
|
1406
|
-
if (packagedRoot) {
|
|
1407
|
-
for (const name of PUBLIC_FILENAMES) {
|
|
1408
|
-
const p = join(packagedRoot, name);
|
|
1409
|
-
if (await fs.pathExists(p)) {
|
|
1410
|
-
files.push({ path: p, privacy: 'public', scope: 'packaged' });
|
|
1411
|
-
break; // only one public file expected per scope
|
|
1412
|
-
}
|
|
1413
|
-
}
|
|
1414
|
-
// By policy, packaged .local is not expected; skip even if present.
|
|
1415
|
-
}
|
|
1416
|
-
}
|
|
1417
|
-
// Project root (from current working directory)
|
|
1418
|
-
const projectRoot = await packageDirectory();
|
|
1419
|
-
if (projectRoot) {
|
|
1420
|
-
for (const name of PUBLIC_FILENAMES) {
|
|
1421
|
-
const p = join(projectRoot, name);
|
|
1422
|
-
if (await fs.pathExists(p)) {
|
|
1423
|
-
files.push({ path: p, privacy: 'public', scope: 'project' });
|
|
1424
|
-
break;
|
|
1425
|
-
}
|
|
1426
|
-
}
|
|
1427
|
-
for (const name of LOCAL_FILENAMES) {
|
|
1428
|
-
const p = join(projectRoot, name);
|
|
1429
|
-
if (await fs.pathExists(p)) {
|
|
1430
|
-
files.push({ path: p, privacy: 'local', scope: 'project' });
|
|
1431
|
-
break;
|
|
1432
|
-
}
|
|
1433
|
-
}
|
|
1434
|
-
}
|
|
1435
|
-
return files;
|
|
1436
|
-
};
|
|
1677
|
+
/** @internal */
|
|
1678
|
+
const isPlainObject = (v) => v !== null &&
|
|
1679
|
+
typeof v === 'object' &&
|
|
1680
|
+
!Array.isArray(v) &&
|
|
1681
|
+
Object.getPrototypeOf(v) === Object.prototype;
|
|
1437
1682
|
/**
|
|
1438
|
-
*
|
|
1439
|
-
*
|
|
1683
|
+
* Deeply interpolate string leaves against envRef.
|
|
1684
|
+
* Arrays are not recursed into; they are returned unchanged.
|
|
1440
1685
|
*
|
|
1441
|
-
*
|
|
1442
|
-
*
|
|
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.
|
|
1443
1690
|
*/
|
|
1444
|
-
const
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
const
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
const mod = await loadJsTsDefault(abs);
|
|
1451
|
-
raw = mod ?? {};
|
|
1452
|
-
}
|
|
1453
|
-
else {
|
|
1454
|
-
const txt = await fs.readFile(abs, 'utf-8');
|
|
1455
|
-
raw = isJson(abs) ? JSON.parse(txt) : isYaml(abs) ? YAML.parse(txt) : {};
|
|
1456
|
-
}
|
|
1457
|
-
}
|
|
1458
|
-
catch (err) {
|
|
1459
|
-
throw new Error(`Failed to read/parse config: ${filePath}. ${String(err)}`);
|
|
1460
|
-
}
|
|
1461
|
-
// Validate RAW
|
|
1462
|
-
const parsed = getDotenvConfigSchemaRaw.safeParse(raw);
|
|
1463
|
-
if (!parsed.success) {
|
|
1464
|
-
const msgs = parsed.error.issues
|
|
1465
|
-
.map((i) => `${i.path.join('.')}: ${i.message}`)
|
|
1466
|
-
.join('\n');
|
|
1467
|
-
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);
|
|
1468
1697
|
}
|
|
1469
|
-
//
|
|
1470
|
-
if (
|
|
1471
|
-
|
|
1698
|
+
// Arrays: return as-is (no recursion)
|
|
1699
|
+
if (Array.isArray(value)) {
|
|
1700
|
+
return value;
|
|
1472
1701
|
}
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
// packaged public only
|
|
1486
|
-
result.packaged = cfg;
|
|
1487
|
-
}
|
|
1488
|
-
else {
|
|
1489
|
-
result.project ??= {};
|
|
1490
|
-
if (f.privacy === 'public')
|
|
1491
|
-
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);
|
|
1492
1714
|
else
|
|
1493
|
-
|
|
1715
|
+
out[k] = v;
|
|
1494
1716
|
}
|
|
1717
|
+
return out;
|
|
1495
1718
|
}
|
|
1496
|
-
return
|
|
1719
|
+
// Other primitives/types: return as-is
|
|
1720
|
+
return value;
|
|
1497
1721
|
};
|
|
1498
1722
|
|
|
1499
1723
|
/**
|
|
@@ -1505,7 +1729,9 @@ const resolveGetDotenvConfigSources = async (importMetaUrl) => {
|
|
|
1505
1729
|
* 2) Discover packaged + project config sources and overlay onto base.
|
|
1506
1730
|
* 3) Apply dynamics in order:
|
|
1507
1731
|
* programmatic dynamic \> config dynamic (packaged → project public → project local)
|
|
1508
|
-
* \> file dynamicPath.
|
|
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.
|
|
1509
1735
|
*/
|
|
1510
1736
|
const resolveDotenvWithConfigLoader = async (validated) => {
|
|
1511
1737
|
// 1) Base from files, no dynamic, no programmatic vars
|
|
@@ -1553,21 +1779,112 @@ const resolveDotenvWithConfigLoader = async (validated) => {
|
|
|
1553
1779
|
throw new Error(`Unable to load dynamic from ${validated.dynamicPath}`);
|
|
1554
1780
|
}
|
|
1555
1781
|
}
|
|
1556
|
-
// 4)
|
|
1557
|
-
|
|
1558
|
-
|
|
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) => {
|
|
1559
1791
|
const value = dotenv[key] ?? '';
|
|
1560
1792
|
return `${contents}${key}=${value.includes('\n') ? `"${value}"` : value}\n`;
|
|
1561
1793
|
}, ''), { encoding: 'utf-8' });
|
|
1562
1794
|
}
|
|
1563
1795
|
const logger = validated.logger ?? console;
|
|
1564
|
-
if (validated.log)
|
|
1565
|
-
|
|
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
|
+
}
|
|
1566
1827
|
if (validated.loadProcess)
|
|
1567
1828
|
Object.assign(process.env, dotenv);
|
|
1568
1829
|
return dotenv;
|
|
1569
1830
|
};
|
|
1570
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
|
+
|
|
1571
1888
|
/**
|
|
1572
1889
|
* Omit a "logger" key from an options object in a typed manner.
|
|
1573
1890
|
*/
|
|
@@ -1606,6 +1923,22 @@ const makePreSubcommandHook = ({ logger, preHook, postHook, defaults, }) => {
|
|
|
1606
1923
|
// Execute getdotenv via always-on config loader/overlay path.
|
|
1607
1924
|
const serviceOptions = getDotenvCliOptions2Options(mergedGetDotenvCliOptions);
|
|
1608
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
|
+
}
|
|
1609
1942
|
// Execute post-hook.
|
|
1610
1943
|
if (postHook)
|
|
1611
1944
|
await postHook(dotenv); // Execute command.
|
|
@@ -1669,4 +2002,4 @@ const generateGetDotenvCli = async (customOptions) => {
|
|
|
1669
2002
|
return program;
|
|
1670
2003
|
};
|
|
1671
2004
|
|
|
1672
|
-
export { defineDynamic, dotenvExpand, dotenvExpandAll, dotenvExpandFromProcessEnv, generateGetDotenvCli, getDotenv, getDotenvCliOptions2Options };
|
|
2005
|
+
export { defineDynamic, dotenvExpand, dotenvExpandAll, dotenvExpandFromProcessEnv, generateGetDotenvCli, getDotenv, getDotenvCliOptions2Options, interpolateDeep };
|