@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/README.md +14 -0
- package/dist/cliHost.cjs +393 -21
- package/dist/cliHost.d.cts +132 -3
- package/dist/cliHost.d.mts +132 -3
- package/dist/cliHost.d.ts +132 -3
- package/dist/cliHost.mjs +393 -22
- 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 +678 -39
- package/dist/index.cjs +604 -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 +604 -246
- package/dist/plugins-aws.cjs +43 -1
- package/dist/plugins-aws.d.cts +115 -0
- package/dist/plugins-aws.d.mts +115 -0
- package/dist/plugins-aws.d.ts +115 -0
- package/dist/plugins-aws.mjs +43 -1
- package/dist/plugins-batch.cjs +46 -6
- package/dist/plugins-batch.d.cts +115 -0
- package/dist/plugins-batch.d.mts +115 -0
- package/dist/plugins-batch.d.ts +115 -0
- package/dist/plugins-batch.mjs +46 -6
- package/dist/plugins-init.d.cts +115 -0
- package/dist/plugins-init.d.mts +115 -0
- package/dist/plugins-init.d.ts +115 -0
- package/package.json +25 -24
package/dist/getdotenv.cli.mjs
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
import { Command, Option } from 'commander';
|
|
3
3
|
import fs from 'fs-extra';
|
|
4
4
|
import { packageDirectory } from 'package-directory';
|
|
5
|
+
import url, { fileURLToPath, pathToFileURL } from 'url';
|
|
5
6
|
import path, { join, extname } from 'path';
|
|
6
7
|
import { z } from 'zod';
|
|
7
|
-
import url, { fileURLToPath, pathToFileURL } from 'url';
|
|
8
8
|
import YAML from 'yaml';
|
|
9
9
|
import { nanoid } from 'nanoid';
|
|
10
10
|
import { parse } from 'dotenv';
|
|
@@ -19,6 +19,11 @@ const baseRootOptionDefaults = {
|
|
|
19
19
|
dotenvToken: '.env',
|
|
20
20
|
loadProcess: true,
|
|
21
21
|
logger: console,
|
|
22
|
+
// Diagnostics defaults
|
|
23
|
+
warnEntropy: true,
|
|
24
|
+
entropyThreshold: 3.8,
|
|
25
|
+
entropyMinLength: 16,
|
|
26
|
+
entropyWhitelist: ['^GIT_', '^npm_', '^CI$', 'SHLVL'],
|
|
22
27
|
paths: './',
|
|
23
28
|
pathsDelimiter: ' ',
|
|
24
29
|
privateToken: 'local',
|
|
@@ -39,7 +44,7 @@ const baseRootOptionDefaults = {
|
|
|
39
44
|
const baseGetDotenvCliOptions = baseRootOptionDefaults;
|
|
40
45
|
|
|
41
46
|
/** @internal */
|
|
42
|
-
const isPlainObject = (value) => value !== null &&
|
|
47
|
+
const isPlainObject$1 = (value) => value !== null &&
|
|
43
48
|
typeof value === 'object' &&
|
|
44
49
|
Object.getPrototypeOf(value) === Object.prototype;
|
|
45
50
|
const mergeInto = (target, source) => {
|
|
@@ -47,10 +52,10 @@ const mergeInto = (target, source) => {
|
|
|
47
52
|
if (sVal === undefined)
|
|
48
53
|
continue; // do not overwrite with undefined
|
|
49
54
|
const tVal = target[key];
|
|
50
|
-
if (isPlainObject(tVal) && isPlainObject(sVal)) {
|
|
55
|
+
if (isPlainObject$1(tVal) && isPlainObject$1(sVal)) {
|
|
51
56
|
target[key] = mergeInto({ ...tVal }, sVal);
|
|
52
57
|
}
|
|
53
|
-
else if (isPlainObject(sVal)) {
|
|
58
|
+
else if (isPlainObject$1(sVal)) {
|
|
54
59
|
target[key] = mergeInto({}, sVal);
|
|
55
60
|
}
|
|
56
61
|
else {
|
|
@@ -210,11 +215,12 @@ const getDotenvOptionsSchemaRaw = z.object({
|
|
|
210
215
|
const getDotenvOptionsSchemaResolved = getDotenvOptionsSchemaRaw;
|
|
211
216
|
|
|
212
217
|
/**
|
|
213
|
-
* Zod schemas for configuration files discovered by the new loader.
|
|
218
|
+
* Zod schemas for configuration files discovered by the new loader.
|
|
219
|
+
*
|
|
214
220
|
* Notes:
|
|
215
221
|
* - RAW: all fields optional; shapes are stringly-friendly (paths may be string[] or string).
|
|
216
222
|
* - RESOLVED: normalized shapes (paths always string[]).
|
|
217
|
-
* - For
|
|
223
|
+
* - For JSON/YAML configs, the loader rejects "dynamic" and "schema" (JS/TS-only).
|
|
218
224
|
*/
|
|
219
225
|
// String-only env value map
|
|
220
226
|
const stringMap = z.record(z.string(), z.string());
|
|
@@ -229,6 +235,8 @@ const getDotenvConfigSchemaRaw = z.object({
|
|
|
229
235
|
log: z.boolean().optional(),
|
|
230
236
|
shell: z.union([z.string(), z.boolean()]).optional(),
|
|
231
237
|
scripts: z.record(z.string(), z.unknown()).optional(), // Scripts validation left wide; generator validates elsewhere
|
|
238
|
+
requiredKeys: z.array(z.string()).optional(),
|
|
239
|
+
schema: z.unknown().optional(), // JS/TS-only; loader rejects in JSON/YAML
|
|
232
240
|
vars: stringMap.optional(), // public, global
|
|
233
241
|
envVars: envStringMap.optional(), // public, per-env
|
|
234
242
|
// Dynamic in config (JS/TS only). JSON/YAML loader will reject if set.
|
|
@@ -411,9 +419,11 @@ const loadConfigFile = async (filePath) => {
|
|
|
411
419
|
.join('\n');
|
|
412
420
|
throw new Error(`Invalid config ${filePath}:\n${msgs}`);
|
|
413
421
|
}
|
|
414
|
-
// Disallow dynamic in JSON/YAML; allow in JS/TS
|
|
415
|
-
if (!isJsOrTs(filePath) &&
|
|
416
|
-
|
|
422
|
+
// Disallow dynamic and schema in JSON/YAML; allow both in JS/TS.
|
|
423
|
+
if (!isJsOrTs(filePath) &&
|
|
424
|
+
(parsed.data.dynamic !== undefined || parsed.data.schema !== undefined)) {
|
|
425
|
+
throw new Error(`Config ${filePath} specifies unsupported keys for JSON/YAML. ` +
|
|
426
|
+
`Use JS/TS config for "dynamic" or "schema".`);
|
|
417
427
|
}
|
|
418
428
|
return getDotenvConfigSchemaResolved.parse(parsed.data);
|
|
419
429
|
};
|
|
@@ -617,6 +627,99 @@ const overlayEnv = ({ base, env, configs, programmaticVars, }) => {
|
|
|
617
627
|
return current;
|
|
618
628
|
};
|
|
619
629
|
|
|
630
|
+
/** src/diagnostics/entropy.ts
|
|
631
|
+
* Entropy diagnostics (presentation-only).
|
|
632
|
+
* - Gated by min length and printable ASCII.
|
|
633
|
+
* - Warn once per key per run when bits/char \>= threshold.
|
|
634
|
+
* - Supports whitelist patterns to suppress known-noise keys.
|
|
635
|
+
*/
|
|
636
|
+
const warned = new Set();
|
|
637
|
+
const isPrintableAscii = (s) => /^[\x20-\x7E]+$/.test(s);
|
|
638
|
+
const compile$1 = (patterns) => (patterns ?? []).map((p) => new RegExp(p, 'i'));
|
|
639
|
+
const whitelisted = (key, regs) => regs.some((re) => re.test(key));
|
|
640
|
+
const shannonBitsPerChar = (s) => {
|
|
641
|
+
const freq = new Map();
|
|
642
|
+
for (const ch of s)
|
|
643
|
+
freq.set(ch, (freq.get(ch) ?? 0) + 1);
|
|
644
|
+
const n = s.length;
|
|
645
|
+
let h = 0;
|
|
646
|
+
for (const c of freq.values()) {
|
|
647
|
+
const p = c / n;
|
|
648
|
+
h -= p * Math.log2(p);
|
|
649
|
+
}
|
|
650
|
+
return h;
|
|
651
|
+
};
|
|
652
|
+
/**
|
|
653
|
+
* Maybe emit a one-line entropy warning for a key.
|
|
654
|
+
* Caller supplies an `emit(line)` function; the helper ensures once-per-key.
|
|
655
|
+
*/
|
|
656
|
+
const maybeWarnEntropy = (key, value, origin, opts, emit) => {
|
|
657
|
+
if (!opts || opts.warnEntropy === false)
|
|
658
|
+
return;
|
|
659
|
+
if (warned.has(key))
|
|
660
|
+
return;
|
|
661
|
+
const v = value ?? '';
|
|
662
|
+
const minLen = Math.max(0, opts.entropyMinLength ?? 16);
|
|
663
|
+
const threshold = opts.entropyThreshold ?? 3.8;
|
|
664
|
+
if (v.length < minLen)
|
|
665
|
+
return;
|
|
666
|
+
if (!isPrintableAscii(v))
|
|
667
|
+
return;
|
|
668
|
+
const wl = compile$1(opts.entropyWhitelist);
|
|
669
|
+
if (whitelisted(key, wl))
|
|
670
|
+
return;
|
|
671
|
+
const bpc = shannonBitsPerChar(v);
|
|
672
|
+
if (bpc >= threshold) {
|
|
673
|
+
warned.add(key);
|
|
674
|
+
emit(`[entropy] key=${key} score=${bpc.toFixed(2)} len=${String(v.length)} origin=${origin}`);
|
|
675
|
+
}
|
|
676
|
+
};
|
|
677
|
+
|
|
678
|
+
const DEFAULT_PATTERNS = [
|
|
679
|
+
'\\bsecret\\b',
|
|
680
|
+
'\\btoken\\b',
|
|
681
|
+
'\\bpass(word)?\\b',
|
|
682
|
+
'\\bapi[_-]?key\\b',
|
|
683
|
+
'\\bkey\\b',
|
|
684
|
+
];
|
|
685
|
+
const compile = (patterns) => (patterns && patterns.length > 0 ? patterns : DEFAULT_PATTERNS).map((p) => new RegExp(p, 'i'));
|
|
686
|
+
const shouldRedactKey = (key, regs) => regs.some((re) => re.test(key));
|
|
687
|
+
const MASK = '[redacted]';
|
|
688
|
+
/**
|
|
689
|
+
* Produce a shallow redacted copy of an env-like object for display.
|
|
690
|
+
*/
|
|
691
|
+
const redactObject = (obj, opts) => {
|
|
692
|
+
if (!opts?.redact)
|
|
693
|
+
return { ...obj };
|
|
694
|
+
const regs = compile(opts.redactPatterns);
|
|
695
|
+
const out = {};
|
|
696
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
697
|
+
out[k] = v && shouldRedactKey(k, regs) ? MASK : v;
|
|
698
|
+
}
|
|
699
|
+
return out;
|
|
700
|
+
};
|
|
701
|
+
/**
|
|
702
|
+
* Utility to redact three related displayed values (parent/dotenv/final)
|
|
703
|
+
* consistently for trace lines.
|
|
704
|
+
*/
|
|
705
|
+
const redactTriple = (key, triple, opts) => {
|
|
706
|
+
if (!opts?.redact)
|
|
707
|
+
return triple;
|
|
708
|
+
const regs = compile(opts.redactPatterns);
|
|
709
|
+
const maskIf = (v) => (v && shouldRedactKey(key, regs) ? MASK : v);
|
|
710
|
+
const out = {};
|
|
711
|
+
const p = maskIf(triple.parent);
|
|
712
|
+
const d = maskIf(triple.dotenv);
|
|
713
|
+
const f = maskIf(triple.final);
|
|
714
|
+
if (p !== undefined)
|
|
715
|
+
out.parent = p;
|
|
716
|
+
if (d !== undefined)
|
|
717
|
+
out.dotenv = d;
|
|
718
|
+
if (f !== undefined)
|
|
719
|
+
out.final = f;
|
|
720
|
+
return out;
|
|
721
|
+
};
|
|
722
|
+
|
|
620
723
|
/**
|
|
621
724
|
* Asynchronously read a dotenv file & parse it into an object.
|
|
622
725
|
*
|
|
@@ -866,14 +969,103 @@ const getDotenv = async (options = {}) => {
|
|
|
866
969
|
resultDotenv = dotenvForOutput;
|
|
867
970
|
}
|
|
868
971
|
// Log result.
|
|
869
|
-
if (log)
|
|
870
|
-
|
|
972
|
+
if (log) {
|
|
973
|
+
const redactFlag = options.redact ?? false;
|
|
974
|
+
const redactPatterns = options.redactPatterns ?? undefined;
|
|
975
|
+
const redOpts = {};
|
|
976
|
+
if (redactFlag)
|
|
977
|
+
redOpts.redact = true;
|
|
978
|
+
if (redactFlag && Array.isArray(redactPatterns))
|
|
979
|
+
redOpts.redactPatterns = redactPatterns;
|
|
980
|
+
const bag = redactFlag
|
|
981
|
+
? redactObject(resultDotenv, redOpts)
|
|
982
|
+
: { ...resultDotenv };
|
|
983
|
+
logger.log(bag);
|
|
984
|
+
// Entropy warnings: once-per-key-per-run (presentation only)
|
|
985
|
+
const warnEntropyVal = options.warnEntropy ?? true;
|
|
986
|
+
const entropyThresholdVal = options
|
|
987
|
+
.entropyThreshold;
|
|
988
|
+
const entropyMinLengthVal = options
|
|
989
|
+
.entropyMinLength;
|
|
990
|
+
const entropyWhitelistVal = options
|
|
991
|
+
.entropyWhitelist;
|
|
992
|
+
const entOpts = {};
|
|
993
|
+
if (typeof warnEntropyVal === 'boolean')
|
|
994
|
+
entOpts.warnEntropy = warnEntropyVal;
|
|
995
|
+
if (typeof entropyThresholdVal === 'number')
|
|
996
|
+
entOpts.entropyThreshold = entropyThresholdVal;
|
|
997
|
+
if (typeof entropyMinLengthVal === 'number')
|
|
998
|
+
entOpts.entropyMinLength = entropyMinLengthVal;
|
|
999
|
+
if (Array.isArray(entropyWhitelistVal))
|
|
1000
|
+
entOpts.entropyWhitelist = entropyWhitelistVal;
|
|
1001
|
+
for (const [k, v] of Object.entries(resultDotenv)) {
|
|
1002
|
+
maybeWarnEntropy(k, v, v !== undefined ? 'dotenv' : 'unset', entOpts, (line) => {
|
|
1003
|
+
logger.log(line);
|
|
1004
|
+
});
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
871
1007
|
// Load process.env.
|
|
872
1008
|
if (loadProcess)
|
|
873
1009
|
Object.assign(process.env, resultDotenv);
|
|
874
1010
|
return resultDotenv;
|
|
875
1011
|
};
|
|
876
1012
|
|
|
1013
|
+
/**
|
|
1014
|
+
* Deep interpolation utility for string leaves.
|
|
1015
|
+
* - Expands string values using dotenv-style expansion against the provided envRef.
|
|
1016
|
+
* - Preserves non-strings as-is.
|
|
1017
|
+
* - Does not recurse into arrays (arrays are returned unchanged).
|
|
1018
|
+
*
|
|
1019
|
+
* Intended for:
|
|
1020
|
+
* - Phase C option/config interpolation after composing ctx.dotenv.
|
|
1021
|
+
* - Per-plugin config slice interpolation before afterResolve.
|
|
1022
|
+
*/
|
|
1023
|
+
/** @internal */
|
|
1024
|
+
const isPlainObject = (v) => v !== null &&
|
|
1025
|
+
typeof v === 'object' &&
|
|
1026
|
+
!Array.isArray(v) &&
|
|
1027
|
+
Object.getPrototypeOf(v) === Object.prototype;
|
|
1028
|
+
/**
|
|
1029
|
+
* Deeply interpolate string leaves against envRef.
|
|
1030
|
+
* Arrays are not recursed into; they are returned unchanged.
|
|
1031
|
+
*
|
|
1032
|
+
* @typeParam T - Shape of the input value.
|
|
1033
|
+
* @param value - Input value (object/array/primitive).
|
|
1034
|
+
* @param envRef - Reference environment for interpolation.
|
|
1035
|
+
* @returns A new value with string leaves interpolated.
|
|
1036
|
+
*/
|
|
1037
|
+
const interpolateDeep = (value, envRef) => {
|
|
1038
|
+
// Strings: expand and return
|
|
1039
|
+
if (typeof value === 'string') {
|
|
1040
|
+
const out = dotenvExpand(value, envRef);
|
|
1041
|
+
// dotenvExpand returns string | undefined; preserve original on undefined
|
|
1042
|
+
return (out ?? value);
|
|
1043
|
+
}
|
|
1044
|
+
// Arrays: return as-is (no recursion)
|
|
1045
|
+
if (Array.isArray(value)) {
|
|
1046
|
+
return value;
|
|
1047
|
+
}
|
|
1048
|
+
// Plain objects: shallow clone and recurse into values
|
|
1049
|
+
if (isPlainObject(value)) {
|
|
1050
|
+
const src = value;
|
|
1051
|
+
const out = {};
|
|
1052
|
+
for (const [k, v] of Object.entries(src)) {
|
|
1053
|
+
// Recurse for strings/objects; keep arrays as-is; preserve other scalars
|
|
1054
|
+
if (typeof v === 'string')
|
|
1055
|
+
out[k] = dotenvExpand(v, envRef) ?? v;
|
|
1056
|
+
else if (Array.isArray(v))
|
|
1057
|
+
out[k] = v;
|
|
1058
|
+
else if (isPlainObject(v))
|
|
1059
|
+
out[k] = interpolateDeep(v, envRef);
|
|
1060
|
+
else
|
|
1061
|
+
out[k] = v;
|
|
1062
|
+
}
|
|
1063
|
+
return out;
|
|
1064
|
+
}
|
|
1065
|
+
// Other primitives/types: return as-is
|
|
1066
|
+
return value;
|
|
1067
|
+
};
|
|
1068
|
+
|
|
877
1069
|
/**
|
|
878
1070
|
* Compute the dotenv context for the host (uses the config loader/overlay path).
|
|
879
1071
|
* - Resolves and validates options strictly (host-only).
|
|
@@ -957,19 +1149,32 @@ const computeContext = async (customOptions, plugins, hostMetaUrl) => {
|
|
|
957
1149
|
{};
|
|
958
1150
|
const mergedPluginConfigs = defaultsDeep({}, packagedPlugins, publicPlugins, localPlugins);
|
|
959
1151
|
for (const p of plugins) {
|
|
960
|
-
if (!p.id
|
|
1152
|
+
if (!p.id)
|
|
961
1153
|
continue;
|
|
962
1154
|
const slice = mergedPluginConfigs[p.id];
|
|
963
1155
|
if (slice === undefined)
|
|
964
1156
|
continue;
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
1157
|
+
// Per-plugin interpolation just before validation/afterResolve:
|
|
1158
|
+
// precedence: process.env wins over ctx.dotenv for slice defaults.
|
|
1159
|
+
const envRef = {
|
|
1160
|
+
...dotenv,
|
|
1161
|
+
...process.env,
|
|
1162
|
+
};
|
|
1163
|
+
const interpolated = interpolateDeep(slice, envRef);
|
|
1164
|
+
// Validate if a schema is provided; otherwise accept interpolated slice as-is.
|
|
1165
|
+
if (p.configSchema) {
|
|
1166
|
+
const parsed = p.configSchema.safeParse(interpolated);
|
|
1167
|
+
if (!parsed.success) {
|
|
1168
|
+
const msgs = parsed.error.issues
|
|
1169
|
+
.map((i) => `${i.path.join('.')}: ${i.message}`)
|
|
1170
|
+
.join('\n');
|
|
1171
|
+
throw new Error(`Invalid config for plugin '${p.id}':\n${msgs}`);
|
|
1172
|
+
}
|
|
1173
|
+
mergedPluginConfigs[p.id] = parsed.data;
|
|
1174
|
+
}
|
|
1175
|
+
else {
|
|
1176
|
+
mergedPluginConfigs[p.id] = interpolated;
|
|
971
1177
|
}
|
|
972
|
-
mergedPluginConfigs[p.id] = parsed.data;
|
|
973
1178
|
}
|
|
974
1179
|
return {
|
|
975
1180
|
optionsResolved: validated,
|
|
@@ -981,6 +1186,8 @@ const computeContext = async (customOptions, plugins, hostMetaUrl) => {
|
|
|
981
1186
|
|
|
982
1187
|
const HOST_META_URL = import.meta.url;
|
|
983
1188
|
const CTX_SYMBOL = Symbol('GetDotenvCli.ctx');
|
|
1189
|
+
const OPTS_SYMBOL = Symbol('GetDotenvCli.options');
|
|
1190
|
+
const HELP_HEADER_SYMBOL = Symbol('GetDotenvCli.helpHeader');
|
|
984
1191
|
/**
|
|
985
1192
|
* Plugin-first CLI host for get-dotenv. Extends Commander.Command.
|
|
986
1193
|
*
|
|
@@ -997,15 +1204,46 @@ class GetDotenvCli extends Command {
|
|
|
997
1204
|
_plugins = [];
|
|
998
1205
|
/** One-time installation guard */
|
|
999
1206
|
_installed = false;
|
|
1207
|
+
/** Optional header line to prepend in help output */
|
|
1208
|
+
[HELP_HEADER_SYMBOL];
|
|
1000
1209
|
constructor(alias = 'getdotenv') {
|
|
1001
1210
|
super(alias);
|
|
1002
1211
|
// Ensure subcommands that use passThroughOptions can be attached safely.
|
|
1003
1212
|
// Commander requires parent commands to enable positional options when a
|
|
1004
1213
|
// child uses passThroughOptions.
|
|
1005
1214
|
this.enablePositionalOptions();
|
|
1215
|
+
// Configure grouped help: show only base options in default "Options";
|
|
1216
|
+
// append App/Plugin sections after default help.
|
|
1217
|
+
this.configureHelp({
|
|
1218
|
+
visibleOptions: (cmd) => {
|
|
1219
|
+
const all = cmd.options ??
|
|
1220
|
+
[];
|
|
1221
|
+
const base = all.filter((opt) => {
|
|
1222
|
+
const group = opt.__group;
|
|
1223
|
+
return group === 'base';
|
|
1224
|
+
});
|
|
1225
|
+
// Sort: short-aliased options first, then long-only; stable by flags.
|
|
1226
|
+
const hasShort = (opt) => {
|
|
1227
|
+
const flags = opt.flags ?? '';
|
|
1228
|
+
// Matches "-x," or starting "-x " before any long
|
|
1229
|
+
return /(^|\s|,)-[A-Za-z]/.test(flags);
|
|
1230
|
+
};
|
|
1231
|
+
const byFlags = (opt) => opt.flags ?? '';
|
|
1232
|
+
base.sort((a, b) => {
|
|
1233
|
+
const aS = hasShort(a) ? 1 : 0;
|
|
1234
|
+
const bS = hasShort(b) ? 1 : 0;
|
|
1235
|
+
return bS - aS || byFlags(a).localeCompare(byFlags(b));
|
|
1236
|
+
});
|
|
1237
|
+
return base;
|
|
1238
|
+
},
|
|
1239
|
+
});
|
|
1240
|
+
this.addHelpText('beforeAll', () => {
|
|
1241
|
+
const header = this[HELP_HEADER_SYMBOL];
|
|
1242
|
+
return header && header.length > 0 ? `${header}\n\n` : '';
|
|
1243
|
+
});
|
|
1244
|
+
this.addHelpText('afterAll', (ctx) => this.#renderOptionGroups(ctx.command));
|
|
1006
1245
|
// Skeleton preSubcommand hook: produce a context if absent, without
|
|
1007
|
-
// mutating process.env. The passOptions hook (when installed) will
|
|
1008
|
-
// compute the final context using merged CLI options; keeping
|
|
1246
|
+
// mutating process.env. The passOptions hook (when installed) will // compute the final context using merged CLI options; keeping
|
|
1009
1247
|
// loadProcess=false here avoids leaking dotenv values into the parent
|
|
1010
1248
|
// process env before subcommands execute.
|
|
1011
1249
|
this.hook('preSubcommand', async () => {
|
|
@@ -1037,11 +1275,96 @@ class GetDotenvCli extends Command {
|
|
|
1037
1275
|
getCtx() {
|
|
1038
1276
|
return this[CTX_SYMBOL];
|
|
1039
1277
|
}
|
|
1278
|
+
/**
|
|
1279
|
+
* Retrieve the merged root CLI options bag (if set by passOptions()).
|
|
1280
|
+
* Downstream-safe: no generics required.
|
|
1281
|
+
*/
|
|
1282
|
+
getOptions() {
|
|
1283
|
+
return this[OPTS_SYMBOL];
|
|
1284
|
+
}
|
|
1285
|
+
/** Internal: set the merged root options bag for this run. */
|
|
1286
|
+
_setOptionsBag(bag) {
|
|
1287
|
+
this[OPTS_SYMBOL] = bag;
|
|
1288
|
+
}
|
|
1040
1289
|
/** * Convenience helper to create a namespaced subcommand.
|
|
1041
1290
|
*/
|
|
1042
1291
|
ns(name) {
|
|
1043
1292
|
return this.command(name);
|
|
1044
1293
|
}
|
|
1294
|
+
/**
|
|
1295
|
+
* Tag options added during the provided callback as 'app' for grouped help.
|
|
1296
|
+
* Allows downstream apps to demarcate their root-level options.
|
|
1297
|
+
*/
|
|
1298
|
+
tagAppOptions(fn) {
|
|
1299
|
+
const root = this;
|
|
1300
|
+
const originalAddOption = root.addOption.bind(root);
|
|
1301
|
+
const originalOption = root.option.bind(root);
|
|
1302
|
+
const tagLatest = (cmd, group) => {
|
|
1303
|
+
const optsArr = cmd.options;
|
|
1304
|
+
if (Array.isArray(optsArr) && optsArr.length > 0) {
|
|
1305
|
+
const last = optsArr[optsArr.length - 1];
|
|
1306
|
+
last.__group = group;
|
|
1307
|
+
}
|
|
1308
|
+
};
|
|
1309
|
+
root.addOption = function patchedAdd(opt) {
|
|
1310
|
+
opt.__group = 'app';
|
|
1311
|
+
return originalAddOption(opt);
|
|
1312
|
+
};
|
|
1313
|
+
root.option = function patchedOption(...args) {
|
|
1314
|
+
const ret = originalOption(...args);
|
|
1315
|
+
tagLatest(this, 'app');
|
|
1316
|
+
return ret;
|
|
1317
|
+
};
|
|
1318
|
+
try {
|
|
1319
|
+
return fn(root);
|
|
1320
|
+
}
|
|
1321
|
+
finally {
|
|
1322
|
+
root.addOption = originalAddOption;
|
|
1323
|
+
root.option = originalOption;
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
/**
|
|
1327
|
+
* Branding helper: set CLI name/description/version and optional help header.
|
|
1328
|
+
* If version is omitted and importMetaUrl is provided, attempts to read the
|
|
1329
|
+
* nearest package.json version (best-effort; non-fatal on failure).
|
|
1330
|
+
*/
|
|
1331
|
+
async brand(args) {
|
|
1332
|
+
const { name, description, version, importMetaUrl, helpHeader } = args;
|
|
1333
|
+
if (typeof name === 'string' && name.length > 0)
|
|
1334
|
+
this.name(name);
|
|
1335
|
+
if (typeof description === 'string')
|
|
1336
|
+
this.description(description);
|
|
1337
|
+
let v = version;
|
|
1338
|
+
if (!v && importMetaUrl) {
|
|
1339
|
+
try {
|
|
1340
|
+
const fromUrl = fileURLToPath(importMetaUrl);
|
|
1341
|
+
const pkgDir = await packageDirectory({ cwd: fromUrl });
|
|
1342
|
+
if (pkgDir) {
|
|
1343
|
+
const txt = await fs.readFile(`${pkgDir}/package.json`, 'utf-8');
|
|
1344
|
+
const pkg = JSON.parse(txt);
|
|
1345
|
+
if (pkg.version)
|
|
1346
|
+
v = pkg.version;
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
catch {
|
|
1350
|
+
// best-effort only
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
if (v)
|
|
1354
|
+
this.version(v);
|
|
1355
|
+
// Help header:
|
|
1356
|
+
// - If caller provides helpHeader, use it.
|
|
1357
|
+
// - Otherwise, when a version is known, default to "<name> v<version>".
|
|
1358
|
+
if (typeof helpHeader === 'string') {
|
|
1359
|
+
this[HELP_HEADER_SYMBOL] = helpHeader;
|
|
1360
|
+
}
|
|
1361
|
+
else if (v) {
|
|
1362
|
+
// Use the current command name (possibly overridden by 'name' above).
|
|
1363
|
+
const header = `${this.name()} v${v}`;
|
|
1364
|
+
this[HELP_HEADER_SYMBOL] = header;
|
|
1365
|
+
}
|
|
1366
|
+
return this;
|
|
1367
|
+
}
|
|
1045
1368
|
/**
|
|
1046
1369
|
* Register a plugin for installation (parent level).
|
|
1047
1370
|
* Installation occurs on first resolveAndLoad() (or explicit install()).
|
|
@@ -1080,13 +1403,143 @@ class GetDotenvCli extends Command {
|
|
|
1080
1403
|
for (const p of this._plugins)
|
|
1081
1404
|
await run(p);
|
|
1082
1405
|
}
|
|
1406
|
+
// Render App/Plugin grouped options appended after default help.
|
|
1407
|
+
#renderOptionGroups(cmd) {
|
|
1408
|
+
const all = cmd.options ?? [];
|
|
1409
|
+
const byGroup = new Map();
|
|
1410
|
+
for (const o of all) {
|
|
1411
|
+
const opt = o;
|
|
1412
|
+
const g = opt.__group;
|
|
1413
|
+
if (!g || g === 'base')
|
|
1414
|
+
continue; // base handled by default help
|
|
1415
|
+
const rows = byGroup.get(g) ?? [];
|
|
1416
|
+
rows.push({
|
|
1417
|
+
flags: opt.flags ?? '',
|
|
1418
|
+
description: opt.description ?? '',
|
|
1419
|
+
});
|
|
1420
|
+
byGroup.set(g, rows);
|
|
1421
|
+
}
|
|
1422
|
+
if (byGroup.size === 0)
|
|
1423
|
+
return '';
|
|
1424
|
+
const renderRows = (title, rows) => {
|
|
1425
|
+
const width = Math.min(40, rows.reduce((m, r) => Math.max(m, r.flags.length), 0));
|
|
1426
|
+
// Sort within group: short-aliased flags first
|
|
1427
|
+
rows.sort((a, b) => {
|
|
1428
|
+
const aS = /(^|\s|,)-[A-Za-z]/.test(a.flags) ? 1 : 0;
|
|
1429
|
+
const bS = /(^|\s|,)-[A-Za-z]/.test(b.flags) ? 1 : 0;
|
|
1430
|
+
return bS - aS || a.flags.localeCompare(b.flags);
|
|
1431
|
+
});
|
|
1432
|
+
const lines = rows
|
|
1433
|
+
.map((r) => {
|
|
1434
|
+
const pad = ' '.repeat(Math.max(2, width - r.flags.length + 2));
|
|
1435
|
+
return ` ${r.flags}${pad}${r.description}`.trimEnd();
|
|
1436
|
+
})
|
|
1437
|
+
.join('\n');
|
|
1438
|
+
return `\n${title}:\n${lines}\n`;
|
|
1439
|
+
};
|
|
1440
|
+
let out = '';
|
|
1441
|
+
// App options (if any)
|
|
1442
|
+
const app = byGroup.get('app');
|
|
1443
|
+
if (app && app.length > 0) {
|
|
1444
|
+
out += renderRows('App options', app);
|
|
1445
|
+
}
|
|
1446
|
+
// Plugin groups sorted by id
|
|
1447
|
+
const pluginKeys = Array.from(byGroup.keys()).filter((k) => k.startsWith('plugin:'));
|
|
1448
|
+
pluginKeys.sort((a, b) => a.localeCompare(b));
|
|
1449
|
+
for (const k of pluginKeys) {
|
|
1450
|
+
const id = k.slice('plugin:'.length) || '(unknown)';
|
|
1451
|
+
const rows = byGroup.get(k) ?? [];
|
|
1452
|
+
if (rows.length > 0) {
|
|
1453
|
+
out += renderRows(`Plugin options — ${id}`, rows);
|
|
1454
|
+
}
|
|
1455
|
+
}
|
|
1456
|
+
return out;
|
|
1457
|
+
}
|
|
1083
1458
|
}
|
|
1084
1459
|
|
|
1460
|
+
/**
|
|
1461
|
+
* Validate a composed env against config-provided validation surfaces.
|
|
1462
|
+
* Precedence for validation definitions:
|
|
1463
|
+
* project.local -\> project.public -\> packaged
|
|
1464
|
+
*
|
|
1465
|
+
* Behavior:
|
|
1466
|
+
* - If a JS/TS `schema` is present, use schema.safeParse(finalEnv).
|
|
1467
|
+
* - Else if `requiredKeys` is present, check presence (value !== undefined).
|
|
1468
|
+
* - Returns a flat list of issue strings; caller decides warn vs fail.
|
|
1469
|
+
*/
|
|
1470
|
+
const validateEnvAgainstSources = (finalEnv, sources) => {
|
|
1471
|
+
const pick = (getter) => {
|
|
1472
|
+
const pl = sources.project?.local;
|
|
1473
|
+
const pp = sources.project?.public;
|
|
1474
|
+
const pk = sources.packaged;
|
|
1475
|
+
return ((pl && getter(pl)) ||
|
|
1476
|
+
(pp && getter(pp)) ||
|
|
1477
|
+
(pk && getter(pk)) ||
|
|
1478
|
+
undefined);
|
|
1479
|
+
};
|
|
1480
|
+
const schema = pick((cfg) => cfg['schema']);
|
|
1481
|
+
if (schema &&
|
|
1482
|
+
typeof schema.safeParse === 'function') {
|
|
1483
|
+
try {
|
|
1484
|
+
const parsed = schema.safeParse(finalEnv);
|
|
1485
|
+
if (!parsed.success) {
|
|
1486
|
+
// Try to render zod-style issues when available.
|
|
1487
|
+
const err = parsed.error;
|
|
1488
|
+
const issues = Array.isArray(err.issues) && err.issues.length > 0
|
|
1489
|
+
? err.issues.map((i) => {
|
|
1490
|
+
const path = Array.isArray(i.path) ? i.path.join('.') : '';
|
|
1491
|
+
const msg = i.message ?? 'Invalid value';
|
|
1492
|
+
return path ? `[schema] ${path}: ${msg}` : `[schema] ${msg}`;
|
|
1493
|
+
})
|
|
1494
|
+
: ['[schema] validation failed'];
|
|
1495
|
+
return issues;
|
|
1496
|
+
}
|
|
1497
|
+
return [];
|
|
1498
|
+
}
|
|
1499
|
+
catch {
|
|
1500
|
+
// If schema invocation fails, surface a single diagnostic.
|
|
1501
|
+
return [
|
|
1502
|
+
'[schema] validation failed (unable to execute schema.safeParse)',
|
|
1503
|
+
];
|
|
1504
|
+
}
|
|
1505
|
+
}
|
|
1506
|
+
const requiredKeys = pick((cfg) => cfg['requiredKeys']);
|
|
1507
|
+
if (Array.isArray(requiredKeys) && requiredKeys.length > 0) {
|
|
1508
|
+
const missing = requiredKeys.filter((k) => finalEnv[k] === undefined);
|
|
1509
|
+
if (missing.length > 0) {
|
|
1510
|
+
return missing.map((k) => `[requiredKeys] missing: ${k}`);
|
|
1511
|
+
}
|
|
1512
|
+
}
|
|
1513
|
+
return [];
|
|
1514
|
+
};
|
|
1515
|
+
|
|
1085
1516
|
/**
|
|
1086
1517
|
* Attach legacy root flags to a Commander program.
|
|
1087
1518
|
* Uses provided defaults to render help labels without coupling to generators.
|
|
1088
1519
|
*/
|
|
1089
1520
|
const attachRootOptions = (program, defaults, opts) => {
|
|
1521
|
+
// Install temporary wrappers to tag all options added here as "base".
|
|
1522
|
+
const GROUP = 'base';
|
|
1523
|
+
const tagLatest = (cmd, group) => {
|
|
1524
|
+
const optsArr = cmd.options;
|
|
1525
|
+
if (Array.isArray(optsArr) && optsArr.length > 0) {
|
|
1526
|
+
const last = optsArr[optsArr.length - 1];
|
|
1527
|
+
last.__group = group;
|
|
1528
|
+
}
|
|
1529
|
+
};
|
|
1530
|
+
const originalAddOption = program.addOption.bind(program);
|
|
1531
|
+
const originalOption = program.option.bind(program);
|
|
1532
|
+
program.addOption = function patchedAdd(opt) {
|
|
1533
|
+
// Tag before adding, in case consumers inspect the Option directly.
|
|
1534
|
+
opt.__group = GROUP;
|
|
1535
|
+
const ret = originalAddOption(opt);
|
|
1536
|
+
return ret;
|
|
1537
|
+
};
|
|
1538
|
+
program.option = function patchedOption(...args) {
|
|
1539
|
+
const ret = originalOption(...args);
|
|
1540
|
+
tagLatest(this, GROUP);
|
|
1541
|
+
return ret;
|
|
1542
|
+
};
|
|
1090
1543
|
const { defaultEnv, dotenvToken, dynamicPath, env, excludeDynamic, excludeEnv, excludeGlobal, excludePrivate, excludePublic, loadProcess, log, outputPath, paths, pathsDelimiter, pathsDelimiterPattern, privateToken, scripts, shell, varsAssignor, varsAssignorPattern, varsDelimiter, varsDelimiterPattern, } = defaults ?? {};
|
|
1091
1544
|
const va = typeof defaults?.varsAssignor === 'string' ? defaults.varsAssignor : '=';
|
|
1092
1545
|
const vd = typeof defaults?.varsDelimiter === 'string' ? defaults.varsDelimiter : ' ';
|
|
@@ -1142,6 +1595,7 @@ const attachRootOptions = (program, defaults, opts) => {
|
|
|
1142
1595
|
.addOption(new Option('-l, --log', `console log loaded variables ON${log ? ' (default)' : ''}`).conflicts('logOff'))
|
|
1143
1596
|
.addOption(new Option('-L, --log-off', `console log loaded variables OFF${!log ? ' (default)' : ''}`).conflicts('log'))
|
|
1144
1597
|
.option('--capture', 'capture child process stdio for commands (tests/CI)')
|
|
1598
|
+
.option('--redact', 'mask secret-like values in logs/trace (presentation-only)')
|
|
1145
1599
|
.option('--default-env <string>', 'default target environment', dotenvExpandFromProcessEnv, defaultEnv)
|
|
1146
1600
|
.option('--dotenv-token <string>', 'dotenv-expanded token indicating a dotenv file', dotenvExpandFromProcessEnv, dotenvToken)
|
|
1147
1601
|
.option('--dynamic-path <string>', 'dynamic variables path (.js or .ts; .ts is auto-compiled when esbuild is available, otherwise precompile)', dotenvExpandFromProcessEnv, dynamicPath)
|
|
@@ -1159,6 +1613,19 @@ const attachRootOptions = (program, defaults, opts) => {
|
|
|
1159
1613
|
.hideHelp());
|
|
1160
1614
|
// Diagnostics: opt-in tracing; optional variadic keys after the flag.
|
|
1161
1615
|
p = p.option('--trace [keys...]', 'emit diagnostics for child env composition (optional keys)');
|
|
1616
|
+
// Validation: strict mode fails on env validation issues (warn by default).
|
|
1617
|
+
p = p.option('--strict', 'fail on env validation errors (schema/requiredKeys)');
|
|
1618
|
+
// Entropy diagnostics (presentation-only)
|
|
1619
|
+
p = p
|
|
1620
|
+
.addOption(new Option('--entropy-warn', 'enable entropy warnings (default on)').conflicts('entropyWarnOff'))
|
|
1621
|
+
.addOption(new Option('--entropy-warn-off', 'disable entropy warnings').conflicts('entropyWarn'))
|
|
1622
|
+
.option('--entropy-threshold <number>', 'entropy bits/char threshold (default 3.8)')
|
|
1623
|
+
.option('--entropy-min-length <number>', 'min length to examine for entropy (default 16)')
|
|
1624
|
+
.option('--entropy-whitelist <pattern...>', 'suppress entropy warnings when key matches any regex pattern')
|
|
1625
|
+
.option('--redact-pattern <pattern...>', 'additional key-match regex patterns to trigger redaction');
|
|
1626
|
+
// Restore original methods to avoid tagging future additions outside base.
|
|
1627
|
+
program.addOption = originalAddOption;
|
|
1628
|
+
program.option = originalOption;
|
|
1162
1629
|
return p;
|
|
1163
1630
|
};
|
|
1164
1631
|
|
|
@@ -1248,7 +1715,7 @@ const resolveCliOptions = (rawCliOptions, defaults, parentJson) => {
|
|
|
1248
1715
|
const parent = typeof parentJson === 'string' && parentJson.length > 0
|
|
1249
1716
|
? JSON.parse(parentJson)
|
|
1250
1717
|
: undefined;
|
|
1251
|
-
const { command, debugOff, excludeAll, excludeAllOff, excludeDynamicOff, excludeEnvOff, excludeGlobalOff, excludePrivateOff, excludePublicOff, loadProcessOff, logOff, scripts, shellOff, ...rest } = rawCliOptions;
|
|
1718
|
+
const { command, debugOff, excludeAll, excludeAllOff, excludeDynamicOff, excludeEnvOff, excludeGlobalOff, excludePrivateOff, excludePublicOff, loadProcessOff, logOff, entropyWarn, entropyWarnOff, scripts, shellOff, ...rest } = rawCliOptions;
|
|
1252
1719
|
const current = { ...rest };
|
|
1253
1720
|
if (typeof scripts === 'string') {
|
|
1254
1721
|
try {
|
|
@@ -1268,6 +1735,8 @@ const resolveCliOptions = (rawCliOptions, defaults, parentJson) => {
|
|
|
1268
1735
|
setOptionalFlag(merged, 'excludePublic', resolveExclusionAll(merged.excludePublic, excludePublicOff, d.excludePublic, excludeAll, excludeAllOff));
|
|
1269
1736
|
setOptionalFlag(merged, 'log', resolveExclusion(merged.log, logOff, d.log));
|
|
1270
1737
|
setOptionalFlag(merged, 'loadProcess', resolveExclusion(merged.loadProcess, loadProcessOff, d.loadProcess));
|
|
1738
|
+
// warnEntropy (tri-state)
|
|
1739
|
+
setOptionalFlag(merged, 'warnEntropy', resolveExclusion(merged.warnEntropy, entropyWarnOff, d.warnEntropy));
|
|
1271
1740
|
// Normalize shell for predictability: explicit default shell per OS.
|
|
1272
1741
|
const defaultShell = process.platform === 'win32' ? 'powershell.exe' : '/bin/bash';
|
|
1273
1742
|
let resolvedShell = merged.shell;
|
|
@@ -1298,9 +1767,34 @@ GetDotenvCli.prototype.passOptions = function (defaults) {
|
|
|
1298
1767
|
// Persist merged options for nested invocations (batch exec).
|
|
1299
1768
|
thisCommand.getDotenvCliOptions =
|
|
1300
1769
|
merged;
|
|
1770
|
+
// Also store on the host for downstream ergonomic accessors.
|
|
1771
|
+
this._setOptionsBag(merged);
|
|
1301
1772
|
// Build service options and compute context (always-on config loader path).
|
|
1302
1773
|
const serviceOptions = getDotenvCliOptions2Options(merged);
|
|
1303
1774
|
await this.resolveAndLoad(serviceOptions);
|
|
1775
|
+
// Global validation: once after Phase C using config sources.
|
|
1776
|
+
try {
|
|
1777
|
+
const ctx = this.getCtx();
|
|
1778
|
+
const dotenv = (ctx?.dotenv ?? {});
|
|
1779
|
+
const sources = await resolveGetDotenvConfigSources(import.meta.url);
|
|
1780
|
+
const issues = validateEnvAgainstSources(dotenv, sources);
|
|
1781
|
+
if (Array.isArray(issues) && issues.length > 0) {
|
|
1782
|
+
const logger = (merged.logger ??
|
|
1783
|
+
console);
|
|
1784
|
+
const emit = logger.error ?? logger.log;
|
|
1785
|
+
issues.forEach((m) => {
|
|
1786
|
+
emit(m);
|
|
1787
|
+
});
|
|
1788
|
+
if (merged.strict) {
|
|
1789
|
+
// Deterministic failure under strict mode
|
|
1790
|
+
process.exit(1);
|
|
1791
|
+
}
|
|
1792
|
+
}
|
|
1793
|
+
}
|
|
1794
|
+
catch {
|
|
1795
|
+
// Be tolerant: validation errors reported above; unexpected failures here
|
|
1796
|
+
// should not crash non-strict flows.
|
|
1797
|
+
}
|
|
1304
1798
|
});
|
|
1305
1799
|
// Also handle root-level flows (no subcommand) so option-aliases can run
|
|
1306
1800
|
// with the same merged options and context without duplicating logic.
|
|
@@ -1309,10 +1803,31 @@ GetDotenvCli.prototype.passOptions = function (defaults) {
|
|
|
1309
1803
|
const { merged } = resolveCliOptions(raw, d, process.env.getDotenvCliOptions);
|
|
1310
1804
|
thisCommand.getDotenvCliOptions =
|
|
1311
1805
|
merged;
|
|
1806
|
+
this._setOptionsBag(merged);
|
|
1312
1807
|
// Avoid duplicate heavy work if a context is already present.
|
|
1313
1808
|
if (!this.getCtx()) {
|
|
1314
1809
|
const serviceOptions = getDotenvCliOptions2Options(merged);
|
|
1315
1810
|
await this.resolveAndLoad(serviceOptions);
|
|
1811
|
+
try {
|
|
1812
|
+
const ctx = this.getCtx();
|
|
1813
|
+
const dotenv = (ctx?.dotenv ?? {});
|
|
1814
|
+
const sources = await resolveGetDotenvConfigSources(import.meta.url);
|
|
1815
|
+
const issues = validateEnvAgainstSources(dotenv, sources);
|
|
1816
|
+
if (Array.isArray(issues) && issues.length > 0) {
|
|
1817
|
+
const logger = (merged
|
|
1818
|
+
.logger ?? console);
|
|
1819
|
+
const emit = logger.error ?? logger.log;
|
|
1820
|
+
issues.forEach((m) => {
|
|
1821
|
+
emit(m);
|
|
1822
|
+
});
|
|
1823
|
+
if (merged.strict) {
|
|
1824
|
+
process.exit(1);
|
|
1825
|
+
}
|
|
1826
|
+
}
|
|
1827
|
+
}
|
|
1828
|
+
catch {
|
|
1829
|
+
// Tolerate validation side-effects in non-strict mode
|
|
1830
|
+
}
|
|
1316
1831
|
}
|
|
1317
1832
|
});
|
|
1318
1833
|
return this;
|
|
@@ -1509,6 +2024,48 @@ const runCommand = async (command, shell, opts) => {
|
|
|
1509
2024
|
}
|
|
1510
2025
|
};
|
|
1511
2026
|
|
|
2027
|
+
const dropUndefined = (bag) => Object.fromEntries(Object.entries(bag).filter((e) => typeof e[1] === 'string'));
|
|
2028
|
+
/** Build a sanitized env for child processes from base + overlay. */
|
|
2029
|
+
const buildSpawnEnv = (base, overlay) => {
|
|
2030
|
+
const raw = {
|
|
2031
|
+
...(base ?? {}),
|
|
2032
|
+
...(overlay ?? {}),
|
|
2033
|
+
};
|
|
2034
|
+
// Drop undefined first
|
|
2035
|
+
const entries = Object.entries(dropUndefined(raw));
|
|
2036
|
+
if (process.platform === 'win32') {
|
|
2037
|
+
// Windows: keys are case-insensitive; collapse duplicates
|
|
2038
|
+
const byLower = new Map();
|
|
2039
|
+
for (const [k, v] of entries) {
|
|
2040
|
+
byLower.set(k.toLowerCase(), [k, v]); // last wins; preserve latest casing
|
|
2041
|
+
}
|
|
2042
|
+
const out = {};
|
|
2043
|
+
for (const [, [k, v]] of byLower)
|
|
2044
|
+
out[k] = v;
|
|
2045
|
+
// HOME fallback from USERPROFILE (common expectation)
|
|
2046
|
+
if (!Object.prototype.hasOwnProperty.call(out, 'HOME')) {
|
|
2047
|
+
const up = out['USERPROFILE'];
|
|
2048
|
+
if (typeof up === 'string' && up.length > 0)
|
|
2049
|
+
out['HOME'] = up;
|
|
2050
|
+
}
|
|
2051
|
+
// Normalize TMP/TEMP coherence (pick any present; reflect to both)
|
|
2052
|
+
const tmp = out['TMP'] ?? out['TEMP'];
|
|
2053
|
+
if (typeof tmp === 'string' && tmp.length > 0) {
|
|
2054
|
+
out['TMP'] = tmp;
|
|
2055
|
+
out['TEMP'] = tmp;
|
|
2056
|
+
}
|
|
2057
|
+
return out;
|
|
2058
|
+
}
|
|
2059
|
+
// POSIX: keep keys as-is
|
|
2060
|
+
const out = Object.fromEntries(entries);
|
|
2061
|
+
// Ensure TMPDIR exists when any temp key is present (best-effort)
|
|
2062
|
+
const tmpdir = out['TMPDIR'] ?? out['TMP'] ?? out['TEMP'];
|
|
2063
|
+
if (typeof tmpdir === 'string' && tmpdir.length > 0) {
|
|
2064
|
+
out['TMPDIR'] = tmpdir;
|
|
2065
|
+
}
|
|
2066
|
+
return out;
|
|
2067
|
+
};
|
|
2068
|
+
|
|
1512
2069
|
/**
|
|
1513
2070
|
* Define a GetDotenv CLI plugin with compositional helpers.
|
|
1514
2071
|
*
|
|
@@ -1866,7 +2423,7 @@ const awsPlugin = () => definePlugin({
|
|
|
1866
2423
|
const shellSetting = resolveShell(rootOpts?.scripts, 'aws', rootOpts?.shell);
|
|
1867
2424
|
const ctxDotenv = (ctx?.dotenv ?? {});
|
|
1868
2425
|
const exit = await runCommand(argv, shellSetting, {
|
|
1869
|
-
env:
|
|
2426
|
+
env: buildSpawnEnv(process.env, ctxDotenv),
|
|
1870
2427
|
stdio: capture ? 'pipe' : 'inherit',
|
|
1871
2428
|
});
|
|
1872
2429
|
// Deterministic termination (suppressed under tests)
|
|
@@ -2005,14 +2562,12 @@ const execShellCommandBatch = async ({ command, getDotenvCliOptions, globs, igno
|
|
|
2005
2562
|
const hasCmd = (typeof command === 'string' && command.length > 0) ||
|
|
2006
2563
|
(Array.isArray(command) && command.length > 0);
|
|
2007
2564
|
if (hasCmd) {
|
|
2565
|
+
const envBag = getDotenvCliOptions !== undefined
|
|
2566
|
+
? { getDotenvCliOptions: JSON.stringify(getDotenvCliOptions) }
|
|
2567
|
+
: undefined;
|
|
2008
2568
|
await runCommand(command, shell, {
|
|
2009
2569
|
cwd: path,
|
|
2010
|
-
env:
|
|
2011
|
-
...process.env,
|
|
2012
|
-
getDotenvCliOptions: getDotenvCliOptions
|
|
2013
|
-
? JSON.stringify(getDotenvCliOptions)
|
|
2014
|
-
: undefined,
|
|
2015
|
-
},
|
|
2570
|
+
env: buildSpawnEnv(process.env, envBag),
|
|
2016
2571
|
stdio: capture ? 'pipe' : 'inherit',
|
|
2017
2572
|
});
|
|
2018
2573
|
}
|
|
@@ -2329,6 +2884,17 @@ const attachParentAlias = (cli, options, _cmd) => {
|
|
|
2329
2884
|
const desc = aliasSpec.description ??
|
|
2330
2885
|
'alias of cmd subcommand; provide command tokens (variadic)';
|
|
2331
2886
|
cli.option(aliasSpec.flags, desc);
|
|
2887
|
+
// Tag the just-added parent option for grouped help rendering.
|
|
2888
|
+
try {
|
|
2889
|
+
const optsArr = cli.options;
|
|
2890
|
+
if (Array.isArray(optsArr) && optsArr.length > 0) {
|
|
2891
|
+
const last = optsArr[optsArr.length - 1];
|
|
2892
|
+
last.__group = 'plugin:cmd';
|
|
2893
|
+
}
|
|
2894
|
+
}
|
|
2895
|
+
catch {
|
|
2896
|
+
/* noop */
|
|
2897
|
+
}
|
|
2332
2898
|
// Shared alias executor for either preAction or preSubcommand hooks.
|
|
2333
2899
|
// Ensure we only execute once even if both hooks fire in a single parse.
|
|
2334
2900
|
let aliasHandled = false;
|
|
@@ -2359,7 +2925,9 @@ const attachParentAlias = (cli, options, _cmd) => {
|
|
|
2359
2925
|
aliasHandled = true;
|
|
2360
2926
|
dbg('alias-only invocation detected');
|
|
2361
2927
|
// Merge CLI options and resolve dotenv context.
|
|
2362
|
-
const { merged } = resolveCliOptions(o,
|
|
2928
|
+
const { merged } = resolveCliOptions(o,
|
|
2929
|
+
// cast through unknown to avoid readonly -> mutable incompatibilities
|
|
2930
|
+
baseRootOptionDefaults, process.env.getDotenvCliOptions);
|
|
2363
2931
|
const logger = merged.logger ?? console;
|
|
2364
2932
|
const serviceOptions = getDotenvCliOptions2Options(merged);
|
|
2365
2933
|
await cli.resolveAndLoad(serviceOptions);
|
|
@@ -2412,7 +2980,41 @@ const attachParentAlias = (cli, options, _cmd) => {
|
|
|
2412
2980
|
: parent !== undefined
|
|
2413
2981
|
? 'parent'
|
|
2414
2982
|
: 'unset';
|
|
2415
|
-
|
|
2983
|
+
// Build redact options and triple bag without undefined-valued fields
|
|
2984
|
+
const redOpts = {};
|
|
2985
|
+
const redFlag = merged.redact;
|
|
2986
|
+
const redPatterns = merged
|
|
2987
|
+
.redactPatterns;
|
|
2988
|
+
if (redFlag)
|
|
2989
|
+
redOpts.redact = true;
|
|
2990
|
+
if (redFlag && Array.isArray(redPatterns))
|
|
2991
|
+
redOpts.redactPatterns = redPatterns;
|
|
2992
|
+
const tripleBag = {};
|
|
2993
|
+
if (parent !== undefined)
|
|
2994
|
+
tripleBag.parent = parent;
|
|
2995
|
+
if (dot !== undefined)
|
|
2996
|
+
tripleBag.dotenv = dot;
|
|
2997
|
+
if (final !== undefined)
|
|
2998
|
+
tripleBag.final = final;
|
|
2999
|
+
const triple = redactTriple(k, tripleBag, redOpts);
|
|
3000
|
+
process.stderr.write(`[trace] key=${k} origin=${origin} parent=${triple.parent ?? ''} dotenv=${triple.dotenv ?? ''} final=${triple.final ?? ''}\n`);
|
|
3001
|
+
const entOpts = {};
|
|
3002
|
+
const warnEntropy = merged.warnEntropy;
|
|
3003
|
+
const entropyThreshold = merged
|
|
3004
|
+
.entropyThreshold;
|
|
3005
|
+
const entropyMinLength = merged
|
|
3006
|
+
.entropyMinLength;
|
|
3007
|
+
const entropyWhitelist = merged
|
|
3008
|
+
.entropyWhitelist;
|
|
3009
|
+
if (typeof warnEntropy === 'boolean')
|
|
3010
|
+
entOpts.warnEntropy = warnEntropy;
|
|
3011
|
+
if (typeof entropyThreshold === 'number')
|
|
3012
|
+
entOpts.entropyThreshold = entropyThreshold;
|
|
3013
|
+
if (typeof entropyMinLength === 'number')
|
|
3014
|
+
entOpts.entropyMinLength = entropyMinLength;
|
|
3015
|
+
if (Array.isArray(entropyWhitelist))
|
|
3016
|
+
entOpts.entropyWhitelist = entropyWhitelist;
|
|
3017
|
+
maybeWarnEntropy(k, final, origin, entOpts, (line) => process.stderr.write(line + '\n'));
|
|
2416
3018
|
}
|
|
2417
3019
|
}
|
|
2418
3020
|
let exitCode = Number.NaN;
|
|
@@ -2467,11 +3069,10 @@ const attachParentAlias = (cli, options, _cmd) => {
|
|
|
2467
3069
|
}
|
|
2468
3070
|
}
|
|
2469
3071
|
exitCode = await runCommand(commandArg, shellSetting, {
|
|
2470
|
-
env: {
|
|
2471
|
-
...process.env,
|
|
3072
|
+
env: buildSpawnEnv(process.env, {
|
|
2472
3073
|
...dotenv,
|
|
2473
3074
|
getDotenvCliOptions: JSON.stringify(envBag),
|
|
2474
|
-
},
|
|
3075
|
+
}),
|
|
2475
3076
|
stdio: capture ? 'pipe' : 'inherit',
|
|
2476
3077
|
});
|
|
2477
3078
|
dbg('run:done', { exitCode });
|
|
@@ -2629,8 +3230,41 @@ const cmdPlugin = (options = {}) => definePlugin({
|
|
|
2629
3230
|
: parent !== undefined
|
|
2630
3231
|
? 'parent'
|
|
2631
3232
|
: 'unset';
|
|
3233
|
+
// Apply presentation-time redaction (if enabled)
|
|
3234
|
+
const redFlag = merged.redact;
|
|
3235
|
+
const redPatterns = merged
|
|
3236
|
+
.redactPatterns;
|
|
3237
|
+
const redOpts = {};
|
|
3238
|
+
if (redFlag)
|
|
3239
|
+
redOpts.redact = true;
|
|
3240
|
+
if (redFlag && Array.isArray(redPatterns))
|
|
3241
|
+
redOpts.redactPatterns = redPatterns;
|
|
3242
|
+
const tripleBag = {};
|
|
3243
|
+
if (parent !== undefined)
|
|
3244
|
+
tripleBag.parent = parent;
|
|
3245
|
+
if (dot !== undefined)
|
|
3246
|
+
tripleBag.dotenv = dot;
|
|
3247
|
+
if (final !== undefined)
|
|
3248
|
+
tripleBag.final = final;
|
|
3249
|
+
const triple = redactTriple(k, tripleBag, redOpts);
|
|
2632
3250
|
// Emit concise diagnostic line to stderr.
|
|
2633
|
-
process.stderr.write(`[trace] key=${k} origin=${origin} parent=${parent ?? ''} dotenv=${
|
|
3251
|
+
process.stderr.write(`[trace] key=${k} origin=${origin} parent=${triple.parent ?? ''} dotenv=${triple.dotenv ?? ''} final=${triple.final ?? ''}\n`);
|
|
3252
|
+
// Optional entropy warning (once-per-key)
|
|
3253
|
+
const entOpts = {};
|
|
3254
|
+
const warnEntropy = merged
|
|
3255
|
+
.warnEntropy;
|
|
3256
|
+
const entropyThreshold = merged.entropyThreshold;
|
|
3257
|
+
const entropyMinLength = merged.entropyMinLength;
|
|
3258
|
+
const entropyWhitelist = merged.entropyWhitelist;
|
|
3259
|
+
if (typeof warnEntropy === 'boolean')
|
|
3260
|
+
entOpts.warnEntropy = warnEntropy;
|
|
3261
|
+
if (typeof entropyThreshold === 'number')
|
|
3262
|
+
entOpts.entropyThreshold = entropyThreshold;
|
|
3263
|
+
if (typeof entropyMinLength === 'number')
|
|
3264
|
+
entOpts.entropyMinLength = entropyMinLength;
|
|
3265
|
+
if (Array.isArray(entropyWhitelist))
|
|
3266
|
+
entOpts.entropyWhitelist = entropyWhitelist;
|
|
3267
|
+
maybeWarnEntropy(k, final, origin, entOpts, (line) => process.stderr.write(line + '\n'));
|
|
2634
3268
|
}
|
|
2635
3269
|
}
|
|
2636
3270
|
const shellSetting = resolveShell(scripts, input, shell);
|
|
@@ -2648,11 +3282,10 @@ const cmdPlugin = (options = {}) => definePlugin({
|
|
|
2648
3282
|
? args.map(String)
|
|
2649
3283
|
: resolved;
|
|
2650
3284
|
await runCommand(commandArg, shellSetting, {
|
|
2651
|
-
env: {
|
|
2652
|
-
...process.env,
|
|
3285
|
+
env: buildSpawnEnv(process.env, {
|
|
2653
3286
|
...dotenv,
|
|
2654
3287
|
getDotenvCliOptions: JSON.stringify(envBag),
|
|
2655
|
-
},
|
|
3288
|
+
}),
|
|
2656
3289
|
stdio: capture ? 'pipe' : 'inherit',
|
|
2657
3290
|
});
|
|
2658
3291
|
});
|
|
@@ -3059,7 +3692,13 @@ const initPlugin = (opts = {}) => definePlugin({
|
|
|
3059
3692
|
});
|
|
3060
3693
|
|
|
3061
3694
|
// Shipped CLI rebased on plugin-first host.
|
|
3062
|
-
const program = new GetDotenvCli('getdotenv')
|
|
3695
|
+
const program = new GetDotenvCli('getdotenv');
|
|
3696
|
+
// Brand the shipped CLI so help shows the package version (e.g., "getdotenv v5.0.0").
|
|
3697
|
+
await program.brand({
|
|
3698
|
+
importMetaUrl: import.meta.url,
|
|
3699
|
+
description: 'Base CLI.',
|
|
3700
|
+
});
|
|
3701
|
+
program
|
|
3063
3702
|
.attachRootOptions({ loadProcess: false })
|
|
3064
3703
|
.use(cmdPlugin({ asDefault: true, optionAlias: '-c, --cmd <command...>' }))
|
|
3065
3704
|
.use(batchPlugin())
|