@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/README.md
CHANGED
|
@@ -34,6 +34,20 @@ Load environment variables with a cascade of environment-aware dotenv files. You
|
|
|
34
34
|
|
|
35
35
|
✅ Set defaults for all options in a `getdotenv.config.json` file in your project root directory.
|
|
36
36
|
|
|
37
|
+
✅ Validate your final composed environment via config: JSON/YAML `requiredKeys` or a JS/TS Zod `schema`. Validation runs once after Phase C (interpolation). Use `--strict` to fail on issues; otherwise warnings are printed. See the [Config files and overlays](./guides/config.md) guide.
|
|
38
|
+
|
|
39
|
+
✅ Diagnostics for safer visibility without altering runtime values:
|
|
40
|
+
|
|
41
|
+
- Redaction at presentation time for secret-like keys (`--redact`, `--redact-pattern`).
|
|
42
|
+
- Entropy warnings (on by default) for high-entropy strings; gated by length/printability and tunable via `--entropy-*` flags. See [Config files and overlays](./guides/config.md).
|
|
43
|
+
|
|
44
|
+
✅ Clear tracing and CI-friendly capture:
|
|
45
|
+
|
|
46
|
+
- `--trace [keys...]` shows per-key origin (parent | dotenv | unset) before spawning.
|
|
47
|
+
- Set `GETDOTENV_STDIO=pipe` or use `--capture` to buffer child stdout/stderr deterministically. See [Shell execution behavior](./guides/shell.md).
|
|
48
|
+
|
|
49
|
+
✅ Cross-platform shells and normalized child environments: defaults to `/bin/bash` on POSIX and `powershell.exe` on Windows; subprocess env is composed once via a unified helper that drops undefineds and normalizes temp/home variables. See [Shell execution behavior](./guides/shell.md).
|
|
50
|
+
|
|
37
51
|
✅ [Generate an extensible `getdotenv`-based CLI](https://github.com/karmaniverous/get-dotenv-child) for use in your own projects.
|
|
38
52
|
|
|
39
53
|
`getdotenv` relies on the excellent [`dotenv`](https://www.npmjs.com/package/dotenv) parser and somewhat improves on [`dotenv-expand`](https://www.npmjs.com/package/dotenv-expand) for recursive variable expansion.
|
package/dist/cliHost.cjs
CHANGED
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
var commander = require('commander');
|
|
4
4
|
var fs = require('fs-extra');
|
|
5
5
|
var packageDirectory = require('package-directory');
|
|
6
|
+
var url = require('url');
|
|
6
7
|
var path = require('path');
|
|
7
8
|
var zod = require('zod');
|
|
8
|
-
var url = require('url');
|
|
9
9
|
var YAML = require('yaml');
|
|
10
10
|
var nanoid = require('nanoid');
|
|
11
11
|
var dotenv = require('dotenv');
|
|
@@ -38,6 +38,11 @@ const baseRootOptionDefaults = {
|
|
|
38
38
|
dotenvToken: '.env',
|
|
39
39
|
loadProcess: true,
|
|
40
40
|
logger: console,
|
|
41
|
+
// Diagnostics defaults
|
|
42
|
+
warnEntropy: true,
|
|
43
|
+
entropyThreshold: 3.8,
|
|
44
|
+
entropyMinLength: 16,
|
|
45
|
+
entropyWhitelist: ['^GIT_', '^npm_', '^CI$', 'SHLVL'],
|
|
41
46
|
paths: './',
|
|
42
47
|
pathsDelimiter: ' ',
|
|
43
48
|
privateToken: 'local',
|
|
@@ -58,7 +63,7 @@ const baseRootOptionDefaults = {
|
|
|
58
63
|
const baseGetDotenvCliOptions = baseRootOptionDefaults;
|
|
59
64
|
|
|
60
65
|
/** @internal */
|
|
61
|
-
const isPlainObject = (value) => value !== null &&
|
|
66
|
+
const isPlainObject$1 = (value) => value !== null &&
|
|
62
67
|
typeof value === 'object' &&
|
|
63
68
|
Object.getPrototypeOf(value) === Object.prototype;
|
|
64
69
|
const mergeInto = (target, source) => {
|
|
@@ -66,10 +71,10 @@ const mergeInto = (target, source) => {
|
|
|
66
71
|
if (sVal === undefined)
|
|
67
72
|
continue; // do not overwrite with undefined
|
|
68
73
|
const tVal = target[key];
|
|
69
|
-
if (isPlainObject(tVal) && isPlainObject(sVal)) {
|
|
74
|
+
if (isPlainObject$1(tVal) && isPlainObject$1(sVal)) {
|
|
70
75
|
target[key] = mergeInto({ ...tVal }, sVal);
|
|
71
76
|
}
|
|
72
|
-
else if (isPlainObject(sVal)) {
|
|
77
|
+
else if (isPlainObject$1(sVal)) {
|
|
73
78
|
target[key] = mergeInto({}, sVal);
|
|
74
79
|
}
|
|
75
80
|
else {
|
|
@@ -229,11 +234,12 @@ const getDotenvOptionsSchemaRaw = zod.z.object({
|
|
|
229
234
|
const getDotenvOptionsSchemaResolved = getDotenvOptionsSchemaRaw;
|
|
230
235
|
|
|
231
236
|
/**
|
|
232
|
-
* Zod schemas for configuration files discovered by the new loader.
|
|
237
|
+
* Zod schemas for configuration files discovered by the new loader.
|
|
238
|
+
*
|
|
233
239
|
* Notes:
|
|
234
240
|
* - RAW: all fields optional; shapes are stringly-friendly (paths may be string[] or string).
|
|
235
241
|
* - RESOLVED: normalized shapes (paths always string[]).
|
|
236
|
-
* - For
|
|
242
|
+
* - For JSON/YAML configs, the loader rejects "dynamic" and "schema" (JS/TS-only).
|
|
237
243
|
*/
|
|
238
244
|
// String-only env value map
|
|
239
245
|
const stringMap = zod.z.record(zod.z.string(), zod.z.string());
|
|
@@ -248,6 +254,8 @@ const getDotenvConfigSchemaRaw = zod.z.object({
|
|
|
248
254
|
log: zod.z.boolean().optional(),
|
|
249
255
|
shell: zod.z.union([zod.z.string(), zod.z.boolean()]).optional(),
|
|
250
256
|
scripts: zod.z.record(zod.z.string(), zod.z.unknown()).optional(), // Scripts validation left wide; generator validates elsewhere
|
|
257
|
+
requiredKeys: zod.z.array(zod.z.string()).optional(),
|
|
258
|
+
schema: zod.z.unknown().optional(), // JS/TS-only; loader rejects in JSON/YAML
|
|
251
259
|
vars: stringMap.optional(), // public, global
|
|
252
260
|
envVars: envStringMap.optional(), // public, per-env
|
|
253
261
|
// Dynamic in config (JS/TS only). JSON/YAML loader will reject if set.
|
|
@@ -430,9 +438,11 @@ const loadConfigFile = async (filePath) => {
|
|
|
430
438
|
.join('\n');
|
|
431
439
|
throw new Error(`Invalid config ${filePath}:\n${msgs}`);
|
|
432
440
|
}
|
|
433
|
-
// Disallow dynamic in JSON/YAML; allow in JS/TS
|
|
434
|
-
if (!isJsOrTs(filePath) &&
|
|
435
|
-
|
|
441
|
+
// Disallow dynamic and schema in JSON/YAML; allow both in JS/TS.
|
|
442
|
+
if (!isJsOrTs(filePath) &&
|
|
443
|
+
(parsed.data.dynamic !== undefined || parsed.data.schema !== undefined)) {
|
|
444
|
+
throw new Error(`Config ${filePath} specifies unsupported keys for JSON/YAML. ` +
|
|
445
|
+
`Use JS/TS config for "dynamic" or "schema".`);
|
|
436
446
|
}
|
|
437
447
|
return getDotenvConfigSchemaResolved.parse(parsed.data);
|
|
438
448
|
};
|
|
@@ -620,6 +630,78 @@ const overlayEnv = ({ base, env, configs, programmaticVars, }) => {
|
|
|
620
630
|
return current;
|
|
621
631
|
};
|
|
622
632
|
|
|
633
|
+
/** src/diagnostics/entropy.ts
|
|
634
|
+
* Entropy diagnostics (presentation-only).
|
|
635
|
+
* - Gated by min length and printable ASCII.
|
|
636
|
+
* - Warn once per key per run when bits/char \>= threshold.
|
|
637
|
+
* - Supports whitelist patterns to suppress known-noise keys.
|
|
638
|
+
*/
|
|
639
|
+
const warned = new Set();
|
|
640
|
+
const isPrintableAscii = (s) => /^[\x20-\x7E]+$/.test(s);
|
|
641
|
+
const compile$1 = (patterns) => (patterns ?? []).map((p) => new RegExp(p, 'i'));
|
|
642
|
+
const whitelisted = (key, regs) => regs.some((re) => re.test(key));
|
|
643
|
+
const shannonBitsPerChar = (s) => {
|
|
644
|
+
const freq = new Map();
|
|
645
|
+
for (const ch of s)
|
|
646
|
+
freq.set(ch, (freq.get(ch) ?? 0) + 1);
|
|
647
|
+
const n = s.length;
|
|
648
|
+
let h = 0;
|
|
649
|
+
for (const c of freq.values()) {
|
|
650
|
+
const p = c / n;
|
|
651
|
+
h -= p * Math.log2(p);
|
|
652
|
+
}
|
|
653
|
+
return h;
|
|
654
|
+
};
|
|
655
|
+
/**
|
|
656
|
+
* Maybe emit a one-line entropy warning for a key.
|
|
657
|
+
* Caller supplies an `emit(line)` function; the helper ensures once-per-key.
|
|
658
|
+
*/
|
|
659
|
+
const maybeWarnEntropy = (key, value, origin, opts, emit) => {
|
|
660
|
+
if (!opts || opts.warnEntropy === false)
|
|
661
|
+
return;
|
|
662
|
+
if (warned.has(key))
|
|
663
|
+
return;
|
|
664
|
+
const v = value ?? '';
|
|
665
|
+
const minLen = Math.max(0, opts.entropyMinLength ?? 16);
|
|
666
|
+
const threshold = opts.entropyThreshold ?? 3.8;
|
|
667
|
+
if (v.length < minLen)
|
|
668
|
+
return;
|
|
669
|
+
if (!isPrintableAscii(v))
|
|
670
|
+
return;
|
|
671
|
+
const wl = compile$1(opts.entropyWhitelist);
|
|
672
|
+
if (whitelisted(key, wl))
|
|
673
|
+
return;
|
|
674
|
+
const bpc = shannonBitsPerChar(v);
|
|
675
|
+
if (bpc >= threshold) {
|
|
676
|
+
warned.add(key);
|
|
677
|
+
emit(`[entropy] key=${key} score=${bpc.toFixed(2)} len=${String(v.length)} origin=${origin}`);
|
|
678
|
+
}
|
|
679
|
+
};
|
|
680
|
+
|
|
681
|
+
const DEFAULT_PATTERNS = [
|
|
682
|
+
'\\bsecret\\b',
|
|
683
|
+
'\\btoken\\b',
|
|
684
|
+
'\\bpass(word)?\\b',
|
|
685
|
+
'\\bapi[_-]?key\\b',
|
|
686
|
+
'\\bkey\\b',
|
|
687
|
+
];
|
|
688
|
+
const compile = (patterns) => (patterns && patterns.length > 0 ? patterns : DEFAULT_PATTERNS).map((p) => new RegExp(p, 'i'));
|
|
689
|
+
const shouldRedactKey = (key, regs) => regs.some((re) => re.test(key));
|
|
690
|
+
const MASK = '[redacted]';
|
|
691
|
+
/**
|
|
692
|
+
* Produce a shallow redacted copy of an env-like object for display.
|
|
693
|
+
*/
|
|
694
|
+
const redactObject = (obj, opts) => {
|
|
695
|
+
if (!opts?.redact)
|
|
696
|
+
return { ...obj };
|
|
697
|
+
const regs = compile(opts.redactPatterns);
|
|
698
|
+
const out = {};
|
|
699
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
700
|
+
out[k] = v && shouldRedactKey(k, regs) ? MASK : v;
|
|
701
|
+
}
|
|
702
|
+
return out;
|
|
703
|
+
};
|
|
704
|
+
|
|
623
705
|
/**
|
|
624
706
|
* Asynchronously read a dotenv file & parse it into an object.
|
|
625
707
|
*
|
|
@@ -869,14 +951,103 @@ const getDotenv = async (options = {}) => {
|
|
|
869
951
|
resultDotenv = dotenvForOutput;
|
|
870
952
|
}
|
|
871
953
|
// Log result.
|
|
872
|
-
if (log)
|
|
873
|
-
|
|
954
|
+
if (log) {
|
|
955
|
+
const redactFlag = options.redact ?? false;
|
|
956
|
+
const redactPatterns = options.redactPatterns ?? undefined;
|
|
957
|
+
const redOpts = {};
|
|
958
|
+
if (redactFlag)
|
|
959
|
+
redOpts.redact = true;
|
|
960
|
+
if (redactFlag && Array.isArray(redactPatterns))
|
|
961
|
+
redOpts.redactPatterns = redactPatterns;
|
|
962
|
+
const bag = redactFlag
|
|
963
|
+
? redactObject(resultDotenv, redOpts)
|
|
964
|
+
: { ...resultDotenv };
|
|
965
|
+
logger.log(bag);
|
|
966
|
+
// Entropy warnings: once-per-key-per-run (presentation only)
|
|
967
|
+
const warnEntropyVal = options.warnEntropy ?? true;
|
|
968
|
+
const entropyThresholdVal = options
|
|
969
|
+
.entropyThreshold;
|
|
970
|
+
const entropyMinLengthVal = options
|
|
971
|
+
.entropyMinLength;
|
|
972
|
+
const entropyWhitelistVal = options
|
|
973
|
+
.entropyWhitelist;
|
|
974
|
+
const entOpts = {};
|
|
975
|
+
if (typeof warnEntropyVal === 'boolean')
|
|
976
|
+
entOpts.warnEntropy = warnEntropyVal;
|
|
977
|
+
if (typeof entropyThresholdVal === 'number')
|
|
978
|
+
entOpts.entropyThreshold = entropyThresholdVal;
|
|
979
|
+
if (typeof entropyMinLengthVal === 'number')
|
|
980
|
+
entOpts.entropyMinLength = entropyMinLengthVal;
|
|
981
|
+
if (Array.isArray(entropyWhitelistVal))
|
|
982
|
+
entOpts.entropyWhitelist = entropyWhitelistVal;
|
|
983
|
+
for (const [k, v] of Object.entries(resultDotenv)) {
|
|
984
|
+
maybeWarnEntropy(k, v, v !== undefined ? 'dotenv' : 'unset', entOpts, (line) => {
|
|
985
|
+
logger.log(line);
|
|
986
|
+
});
|
|
987
|
+
}
|
|
988
|
+
}
|
|
874
989
|
// Load process.env.
|
|
875
990
|
if (loadProcess)
|
|
876
991
|
Object.assign(process.env, resultDotenv);
|
|
877
992
|
return resultDotenv;
|
|
878
993
|
};
|
|
879
994
|
|
|
995
|
+
/**
|
|
996
|
+
* Deep interpolation utility for string leaves.
|
|
997
|
+
* - Expands string values using dotenv-style expansion against the provided envRef.
|
|
998
|
+
* - Preserves non-strings as-is.
|
|
999
|
+
* - Does not recurse into arrays (arrays are returned unchanged).
|
|
1000
|
+
*
|
|
1001
|
+
* Intended for:
|
|
1002
|
+
* - Phase C option/config interpolation after composing ctx.dotenv.
|
|
1003
|
+
* - Per-plugin config slice interpolation before afterResolve.
|
|
1004
|
+
*/
|
|
1005
|
+
/** @internal */
|
|
1006
|
+
const isPlainObject = (v) => v !== null &&
|
|
1007
|
+
typeof v === 'object' &&
|
|
1008
|
+
!Array.isArray(v) &&
|
|
1009
|
+
Object.getPrototypeOf(v) === Object.prototype;
|
|
1010
|
+
/**
|
|
1011
|
+
* Deeply interpolate string leaves against envRef.
|
|
1012
|
+
* Arrays are not recursed into; they are returned unchanged.
|
|
1013
|
+
*
|
|
1014
|
+
* @typeParam T - Shape of the input value.
|
|
1015
|
+
* @param value - Input value (object/array/primitive).
|
|
1016
|
+
* @param envRef - Reference environment for interpolation.
|
|
1017
|
+
* @returns A new value with string leaves interpolated.
|
|
1018
|
+
*/
|
|
1019
|
+
const interpolateDeep = (value, envRef) => {
|
|
1020
|
+
// Strings: expand and return
|
|
1021
|
+
if (typeof value === 'string') {
|
|
1022
|
+
const out = dotenvExpand(value, envRef);
|
|
1023
|
+
// dotenvExpand returns string | undefined; preserve original on undefined
|
|
1024
|
+
return (out ?? value);
|
|
1025
|
+
}
|
|
1026
|
+
// Arrays: return as-is (no recursion)
|
|
1027
|
+
if (Array.isArray(value)) {
|
|
1028
|
+
return value;
|
|
1029
|
+
}
|
|
1030
|
+
// Plain objects: shallow clone and recurse into values
|
|
1031
|
+
if (isPlainObject(value)) {
|
|
1032
|
+
const src = value;
|
|
1033
|
+
const out = {};
|
|
1034
|
+
for (const [k, v] of Object.entries(src)) {
|
|
1035
|
+
// Recurse for strings/objects; keep arrays as-is; preserve other scalars
|
|
1036
|
+
if (typeof v === 'string')
|
|
1037
|
+
out[k] = dotenvExpand(v, envRef) ?? v;
|
|
1038
|
+
else if (Array.isArray(v))
|
|
1039
|
+
out[k] = v;
|
|
1040
|
+
else if (isPlainObject(v))
|
|
1041
|
+
out[k] = interpolateDeep(v, envRef);
|
|
1042
|
+
else
|
|
1043
|
+
out[k] = v;
|
|
1044
|
+
}
|
|
1045
|
+
return out;
|
|
1046
|
+
}
|
|
1047
|
+
// Other primitives/types: return as-is
|
|
1048
|
+
return value;
|
|
1049
|
+
};
|
|
1050
|
+
|
|
880
1051
|
/**
|
|
881
1052
|
* Compute the dotenv context for the host (uses the config loader/overlay path).
|
|
882
1053
|
* - Resolves and validates options strictly (host-only).
|
|
@@ -960,19 +1131,32 @@ const computeContext = async (customOptions, plugins, hostMetaUrl) => {
|
|
|
960
1131
|
{};
|
|
961
1132
|
const mergedPluginConfigs = defaultsDeep({}, packagedPlugins, publicPlugins, localPlugins);
|
|
962
1133
|
for (const p of plugins) {
|
|
963
|
-
if (!p.id
|
|
1134
|
+
if (!p.id)
|
|
964
1135
|
continue;
|
|
965
1136
|
const slice = mergedPluginConfigs[p.id];
|
|
966
1137
|
if (slice === undefined)
|
|
967
1138
|
continue;
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
1139
|
+
// Per-plugin interpolation just before validation/afterResolve:
|
|
1140
|
+
// precedence: process.env wins over ctx.dotenv for slice defaults.
|
|
1141
|
+
const envRef = {
|
|
1142
|
+
...dotenv,
|
|
1143
|
+
...process.env,
|
|
1144
|
+
};
|
|
1145
|
+
const interpolated = interpolateDeep(slice, envRef);
|
|
1146
|
+
// Validate if a schema is provided; otherwise accept interpolated slice as-is.
|
|
1147
|
+
if (p.configSchema) {
|
|
1148
|
+
const parsed = p.configSchema.safeParse(interpolated);
|
|
1149
|
+
if (!parsed.success) {
|
|
1150
|
+
const msgs = parsed.error.issues
|
|
1151
|
+
.map((i) => `${i.path.join('.')}: ${i.message}`)
|
|
1152
|
+
.join('\n');
|
|
1153
|
+
throw new Error(`Invalid config for plugin '${p.id}':\n${msgs}`);
|
|
1154
|
+
}
|
|
1155
|
+
mergedPluginConfigs[p.id] = parsed.data;
|
|
1156
|
+
}
|
|
1157
|
+
else {
|
|
1158
|
+
mergedPluginConfigs[p.id] = interpolated;
|
|
974
1159
|
}
|
|
975
|
-
mergedPluginConfigs[p.id] = parsed.data;
|
|
976
1160
|
}
|
|
977
1161
|
return {
|
|
978
1162
|
optionsResolved: validated,
|
|
@@ -984,6 +1168,8 @@ const computeContext = async (customOptions, plugins, hostMetaUrl) => {
|
|
|
984
1168
|
|
|
985
1169
|
const HOST_META_URL = (typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === 'SCRIPT' && _documentCurrentScript.src || new URL('cliHost.cjs', document.baseURI).href));
|
|
986
1170
|
const CTX_SYMBOL = Symbol('GetDotenvCli.ctx');
|
|
1171
|
+
const OPTS_SYMBOL = Symbol('GetDotenvCli.options');
|
|
1172
|
+
const HELP_HEADER_SYMBOL = Symbol('GetDotenvCli.helpHeader');
|
|
987
1173
|
/**
|
|
988
1174
|
* Plugin-first CLI host for get-dotenv. Extends Commander.Command.
|
|
989
1175
|
*
|
|
@@ -1000,15 +1186,46 @@ class GetDotenvCli extends commander.Command {
|
|
|
1000
1186
|
_plugins = [];
|
|
1001
1187
|
/** One-time installation guard */
|
|
1002
1188
|
_installed = false;
|
|
1189
|
+
/** Optional header line to prepend in help output */
|
|
1190
|
+
[HELP_HEADER_SYMBOL];
|
|
1003
1191
|
constructor(alias = 'getdotenv') {
|
|
1004
1192
|
super(alias);
|
|
1005
1193
|
// Ensure subcommands that use passThroughOptions can be attached safely.
|
|
1006
1194
|
// Commander requires parent commands to enable positional options when a
|
|
1007
1195
|
// child uses passThroughOptions.
|
|
1008
1196
|
this.enablePositionalOptions();
|
|
1197
|
+
// Configure grouped help: show only base options in default "Options";
|
|
1198
|
+
// append App/Plugin sections after default help.
|
|
1199
|
+
this.configureHelp({
|
|
1200
|
+
visibleOptions: (cmd) => {
|
|
1201
|
+
const all = cmd.options ??
|
|
1202
|
+
[];
|
|
1203
|
+
const base = all.filter((opt) => {
|
|
1204
|
+
const group = opt.__group;
|
|
1205
|
+
return group === 'base';
|
|
1206
|
+
});
|
|
1207
|
+
// Sort: short-aliased options first, then long-only; stable by flags.
|
|
1208
|
+
const hasShort = (opt) => {
|
|
1209
|
+
const flags = opt.flags ?? '';
|
|
1210
|
+
// Matches "-x," or starting "-x " before any long
|
|
1211
|
+
return /(^|\s|,)-[A-Za-z]/.test(flags);
|
|
1212
|
+
};
|
|
1213
|
+
const byFlags = (opt) => opt.flags ?? '';
|
|
1214
|
+
base.sort((a, b) => {
|
|
1215
|
+
const aS = hasShort(a) ? 1 : 0;
|
|
1216
|
+
const bS = hasShort(b) ? 1 : 0;
|
|
1217
|
+
return bS - aS || byFlags(a).localeCompare(byFlags(b));
|
|
1218
|
+
});
|
|
1219
|
+
return base;
|
|
1220
|
+
},
|
|
1221
|
+
});
|
|
1222
|
+
this.addHelpText('beforeAll', () => {
|
|
1223
|
+
const header = this[HELP_HEADER_SYMBOL];
|
|
1224
|
+
return header && header.length > 0 ? `${header}\n\n` : '';
|
|
1225
|
+
});
|
|
1226
|
+
this.addHelpText('afterAll', (ctx) => this.#renderOptionGroups(ctx.command));
|
|
1009
1227
|
// Skeleton preSubcommand hook: produce a context if absent, without
|
|
1010
|
-
// mutating process.env. The passOptions hook (when installed) will
|
|
1011
|
-
// compute the final context using merged CLI options; keeping
|
|
1228
|
+
// mutating process.env. The passOptions hook (when installed) will // compute the final context using merged CLI options; keeping
|
|
1012
1229
|
// loadProcess=false here avoids leaking dotenv values into the parent
|
|
1013
1230
|
// process env before subcommands execute.
|
|
1014
1231
|
this.hook('preSubcommand', async () => {
|
|
@@ -1040,11 +1257,96 @@ class GetDotenvCli extends commander.Command {
|
|
|
1040
1257
|
getCtx() {
|
|
1041
1258
|
return this[CTX_SYMBOL];
|
|
1042
1259
|
}
|
|
1260
|
+
/**
|
|
1261
|
+
* Retrieve the merged root CLI options bag (if set by passOptions()).
|
|
1262
|
+
* Downstream-safe: no generics required.
|
|
1263
|
+
*/
|
|
1264
|
+
getOptions() {
|
|
1265
|
+
return this[OPTS_SYMBOL];
|
|
1266
|
+
}
|
|
1267
|
+
/** Internal: set the merged root options bag for this run. */
|
|
1268
|
+
_setOptionsBag(bag) {
|
|
1269
|
+
this[OPTS_SYMBOL] = bag;
|
|
1270
|
+
}
|
|
1043
1271
|
/** * Convenience helper to create a namespaced subcommand.
|
|
1044
1272
|
*/
|
|
1045
1273
|
ns(name) {
|
|
1046
1274
|
return this.command(name);
|
|
1047
1275
|
}
|
|
1276
|
+
/**
|
|
1277
|
+
* Tag options added during the provided callback as 'app' for grouped help.
|
|
1278
|
+
* Allows downstream apps to demarcate their root-level options.
|
|
1279
|
+
*/
|
|
1280
|
+
tagAppOptions(fn) {
|
|
1281
|
+
const root = this;
|
|
1282
|
+
const originalAddOption = root.addOption.bind(root);
|
|
1283
|
+
const originalOption = root.option.bind(root);
|
|
1284
|
+
const tagLatest = (cmd, group) => {
|
|
1285
|
+
const optsArr = cmd.options;
|
|
1286
|
+
if (Array.isArray(optsArr) && optsArr.length > 0) {
|
|
1287
|
+
const last = optsArr[optsArr.length - 1];
|
|
1288
|
+
last.__group = group;
|
|
1289
|
+
}
|
|
1290
|
+
};
|
|
1291
|
+
root.addOption = function patchedAdd(opt) {
|
|
1292
|
+
opt.__group = 'app';
|
|
1293
|
+
return originalAddOption(opt);
|
|
1294
|
+
};
|
|
1295
|
+
root.option = function patchedOption(...args) {
|
|
1296
|
+
const ret = originalOption(...args);
|
|
1297
|
+
tagLatest(this, 'app');
|
|
1298
|
+
return ret;
|
|
1299
|
+
};
|
|
1300
|
+
try {
|
|
1301
|
+
return fn(root);
|
|
1302
|
+
}
|
|
1303
|
+
finally {
|
|
1304
|
+
root.addOption = originalAddOption;
|
|
1305
|
+
root.option = originalOption;
|
|
1306
|
+
}
|
|
1307
|
+
}
|
|
1308
|
+
/**
|
|
1309
|
+
* Branding helper: set CLI name/description/version and optional help header.
|
|
1310
|
+
* If version is omitted and importMetaUrl is provided, attempts to read the
|
|
1311
|
+
* nearest package.json version (best-effort; non-fatal on failure).
|
|
1312
|
+
*/
|
|
1313
|
+
async brand(args) {
|
|
1314
|
+
const { name, description, version, importMetaUrl, helpHeader } = args;
|
|
1315
|
+
if (typeof name === 'string' && name.length > 0)
|
|
1316
|
+
this.name(name);
|
|
1317
|
+
if (typeof description === 'string')
|
|
1318
|
+
this.description(description);
|
|
1319
|
+
let v = version;
|
|
1320
|
+
if (!v && importMetaUrl) {
|
|
1321
|
+
try {
|
|
1322
|
+
const fromUrl = url.fileURLToPath(importMetaUrl);
|
|
1323
|
+
const pkgDir = await packageDirectory.packageDirectory({ cwd: fromUrl });
|
|
1324
|
+
if (pkgDir) {
|
|
1325
|
+
const txt = await fs.readFile(`${pkgDir}/package.json`, 'utf-8');
|
|
1326
|
+
const pkg = JSON.parse(txt);
|
|
1327
|
+
if (pkg.version)
|
|
1328
|
+
v = pkg.version;
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
1331
|
+
catch {
|
|
1332
|
+
// best-effort only
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
if (v)
|
|
1336
|
+
this.version(v);
|
|
1337
|
+
// Help header:
|
|
1338
|
+
// - If caller provides helpHeader, use it.
|
|
1339
|
+
// - Otherwise, when a version is known, default to "<name> v<version>".
|
|
1340
|
+
if (typeof helpHeader === 'string') {
|
|
1341
|
+
this[HELP_HEADER_SYMBOL] = helpHeader;
|
|
1342
|
+
}
|
|
1343
|
+
else if (v) {
|
|
1344
|
+
// Use the current command name (possibly overridden by 'name' above).
|
|
1345
|
+
const header = `${this.name()} v${v}`;
|
|
1346
|
+
this[HELP_HEADER_SYMBOL] = header;
|
|
1347
|
+
}
|
|
1348
|
+
return this;
|
|
1349
|
+
}
|
|
1048
1350
|
/**
|
|
1049
1351
|
* Register a plugin for installation (parent level).
|
|
1050
1352
|
* Installation occurs on first resolveAndLoad() (or explicit install()).
|
|
@@ -1083,7 +1385,77 @@ class GetDotenvCli extends commander.Command {
|
|
|
1083
1385
|
for (const p of this._plugins)
|
|
1084
1386
|
await run(p);
|
|
1085
1387
|
}
|
|
1388
|
+
// Render App/Plugin grouped options appended after default help.
|
|
1389
|
+
#renderOptionGroups(cmd) {
|
|
1390
|
+
const all = cmd.options ?? [];
|
|
1391
|
+
const byGroup = new Map();
|
|
1392
|
+
for (const o of all) {
|
|
1393
|
+
const opt = o;
|
|
1394
|
+
const g = opt.__group;
|
|
1395
|
+
if (!g || g === 'base')
|
|
1396
|
+
continue; // base handled by default help
|
|
1397
|
+
const rows = byGroup.get(g) ?? [];
|
|
1398
|
+
rows.push({
|
|
1399
|
+
flags: opt.flags ?? '',
|
|
1400
|
+
description: opt.description ?? '',
|
|
1401
|
+
});
|
|
1402
|
+
byGroup.set(g, rows);
|
|
1403
|
+
}
|
|
1404
|
+
if (byGroup.size === 0)
|
|
1405
|
+
return '';
|
|
1406
|
+
const renderRows = (title, rows) => {
|
|
1407
|
+
const width = Math.min(40, rows.reduce((m, r) => Math.max(m, r.flags.length), 0));
|
|
1408
|
+
// Sort within group: short-aliased flags first
|
|
1409
|
+
rows.sort((a, b) => {
|
|
1410
|
+
const aS = /(^|\s|,)-[A-Za-z]/.test(a.flags) ? 1 : 0;
|
|
1411
|
+
const bS = /(^|\s|,)-[A-Za-z]/.test(b.flags) ? 1 : 0;
|
|
1412
|
+
return bS - aS || a.flags.localeCompare(b.flags);
|
|
1413
|
+
});
|
|
1414
|
+
const lines = rows
|
|
1415
|
+
.map((r) => {
|
|
1416
|
+
const pad = ' '.repeat(Math.max(2, width - r.flags.length + 2));
|
|
1417
|
+
return ` ${r.flags}${pad}${r.description}`.trimEnd();
|
|
1418
|
+
})
|
|
1419
|
+
.join('\n');
|
|
1420
|
+
return `\n${title}:\n${lines}\n`;
|
|
1421
|
+
};
|
|
1422
|
+
let out = '';
|
|
1423
|
+
// App options (if any)
|
|
1424
|
+
const app = byGroup.get('app');
|
|
1425
|
+
if (app && app.length > 0) {
|
|
1426
|
+
out += renderRows('App options', app);
|
|
1427
|
+
}
|
|
1428
|
+
// Plugin groups sorted by id
|
|
1429
|
+
const pluginKeys = Array.from(byGroup.keys()).filter((k) => k.startsWith('plugin:'));
|
|
1430
|
+
pluginKeys.sort((a, b) => a.localeCompare(b));
|
|
1431
|
+
for (const k of pluginKeys) {
|
|
1432
|
+
const id = k.slice('plugin:'.length) || '(unknown)';
|
|
1433
|
+
const rows = byGroup.get(k) ?? [];
|
|
1434
|
+
if (rows.length > 0) {
|
|
1435
|
+
out += renderRows(`Plugin options — ${id}`, rows);
|
|
1436
|
+
}
|
|
1437
|
+
}
|
|
1438
|
+
return out;
|
|
1439
|
+
}
|
|
1086
1440
|
}
|
|
1087
1441
|
|
|
1442
|
+
/**
|
|
1443
|
+
* Helper to retrieve the merged root options bag from any action handler
|
|
1444
|
+
* that only has access to thisCommand. Avoids structural casts.
|
|
1445
|
+
*/
|
|
1446
|
+
const readMergedOptions = (cmd) => {
|
|
1447
|
+
// Ascend to the root command
|
|
1448
|
+
let root = cmd;
|
|
1449
|
+
while (root.parent) {
|
|
1450
|
+
root = root.parent;
|
|
1451
|
+
}
|
|
1452
|
+
const hostAny = root;
|
|
1453
|
+
return typeof hostAny.getOptions === 'function'
|
|
1454
|
+
? hostAny.getOptions()
|
|
1455
|
+
: root
|
|
1456
|
+
.getDotenvCliOptions;
|
|
1457
|
+
};
|
|
1458
|
+
|
|
1088
1459
|
exports.GetDotenvCli = GetDotenvCli;
|
|
1089
1460
|
exports.definePlugin = definePlugin;
|
|
1461
|
+
exports.readMergedOptions = readMergedOptions;
|