@karmaniverous/get-dotenv 5.2.6 → 6.0.0-0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -1,10 +1,10 @@
1
- import { Option, Command } from 'commander';
2
1
  import fs from 'fs-extra';
3
2
  import { packageDirectory } from 'package-directory';
4
3
  import path, { join, extname } from 'path';
5
4
  import url, { fileURLToPath, pathToFileURL } from 'url';
6
5
  import YAML from 'yaml';
7
6
  import { z } from 'zod';
7
+ import { Option, Command } from 'commander';
8
8
  import { nanoid } from 'nanoid';
9
9
  import { parse } from 'dotenv';
10
10
  import { createHash } from 'crypto';
@@ -13,258 +13,6 @@ import { globby } from 'globby';
13
13
  import { stdin, stdout } from 'node:process';
14
14
  import { createInterface } from 'readline/promises';
15
15
 
16
- /**
17
- * Dotenv expansion utilities.
18
- *
19
- * This module implements recursive expansion of environment-variable
20
- * references in strings and records. It supports both whitespace and
21
- * bracket syntaxes with optional defaults:
22
- *
23
- * - Whitespace: `$VAR[:default]`
24
- * - Bracketed: `${VAR[:default]}`
25
- *
26
- * Escaped dollar signs (`\$`) are preserved.
27
- * Unknown variables resolve to empty string unless a default is provided.
28
- */
29
- /**
30
- * Like String.prototype.search but returns the last index.
31
- * @internal
32
- */
33
- const searchLast = (str, rgx) => {
34
- const matches = Array.from(str.matchAll(rgx));
35
- return matches.length > 0 ? (matches.slice(-1)[0]?.index ?? -1) : -1;
36
- };
37
- const replaceMatch = (value, match, ref) => {
38
- /**
39
- * @internal
40
- */
41
- const group = match[0];
42
- const key = match[1];
43
- const defaultValue = match[2];
44
- if (!key)
45
- return value;
46
- const replacement = value.replace(group, ref[key] ?? defaultValue ?? '');
47
- return interpolate(replacement, ref);
48
- };
49
- const interpolate = (value = '', ref = {}) => {
50
- /**
51
- * @internal
52
- */
53
- // if value is falsy, return it as is
54
- if (!value)
55
- return value;
56
- // get position of last unescaped dollar sign
57
- const lastUnescapedDollarSignIndex = searchLast(value, /(?!(?<=\\))\$/g);
58
- // return value if none found
59
- if (lastUnescapedDollarSignIndex === -1)
60
- return value;
61
- // evaluate the value tail
62
- const tail = value.slice(lastUnescapedDollarSignIndex);
63
- // find whitespace pattern: $KEY:DEFAULT
64
- const whitespacePattern = /^\$([\w]+)(?::([^\s]*))?/;
65
- const whitespaceMatch = whitespacePattern.exec(tail);
66
- if (whitespaceMatch != null)
67
- return replaceMatch(value, whitespaceMatch, ref);
68
- else {
69
- // find bracket pattern: ${KEY:DEFAULT}
70
- const bracketPattern = /^\${([\w]+)(?::([^}]*))?}/;
71
- const bracketMatch = bracketPattern.exec(tail);
72
- if (bracketMatch != null)
73
- return replaceMatch(value, bracketMatch, ref);
74
- }
75
- return value;
76
- };
77
- /**
78
- * Recursively expands environment variables in a string. Variables may be
79
- * presented with optional default as `$VAR[:default]` or `${VAR[:default]}`.
80
- * Unknown variables will expand to an empty string.
81
- *
82
- * @param value - The string to expand.
83
- * @param ref - The reference object to use for variable expansion.
84
- * @returns The expanded string.
85
- *
86
- * @example
87
- * ```ts
88
- * process.env.FOO = 'bar';
89
- * dotenvExpand('Hello $FOO'); // "Hello bar"
90
- * dotenvExpand('Hello $BAZ:world'); // "Hello world"
91
- * ```
92
- *
93
- * @remarks
94
- * The expansion is recursive. If a referenced variable itself contains
95
- * references, those will also be expanded until a stable value is reached.
96
- * Escaped references (e.g. `\$FOO`) are preserved as literals.
97
- */
98
- const dotenvExpand = (value, ref = process.env) => {
99
- const result = interpolate(value, ref);
100
- return result ? result.replace(/\\\$/g, '$') : undefined;
101
- };
102
- /**
103
- * Recursively expands environment variables in the values of a JSON object.
104
- * Variables may be presented with optional default as `$VAR[:default]` or
105
- * `${VAR[:default]}`. Unknown variables will expand to an empty string.
106
- *
107
- * @param values - The values object to expand.
108
- * @param options - Expansion options.
109
- * @returns The value object with expanded string values.
110
- *
111
- * @example
112
- * ```ts
113
- * process.env.FOO = 'bar';
114
- * dotenvExpandAll({ A: '$FOO', B: 'x${FOO}y' });
115
- * // => { A: "bar", B: "xbary" }
116
- * ```
117
- *
118
- * @remarks
119
- * Options:
120
- * - ref: The reference object to use for expansion (defaults to process.env).
121
- * - progressive: Whether to progressively add expanded values to the set of
122
- * reference keys.
123
- *
124
- * When `progressive` is true, each expanded key becomes available for
125
- * subsequent expansions in the same object (left-to-right by object key order).
126
- */
127
- const dotenvExpandAll = (values = {}, options = {}) => Object.keys(values).reduce((acc, key) => {
128
- const { ref = process.env, progressive = false } = options;
129
- acc[key] = dotenvExpand(values[key], {
130
- ...ref,
131
- ...(progressive ? acc : {}),
132
- });
133
- return acc;
134
- }, {});
135
- /**
136
- * Recursively expands environment variables in a string using `process.env` as
137
- * the expansion reference. Variables may be presented with optional default as
138
- * `$VAR[:default]` or `${VAR[:default]}`. Unknown variables will expand to an
139
- * empty string.
140
- *
141
- * @param value - The string to expand.
142
- * @returns The expanded string.
143
- *
144
- * @example
145
- * ```ts
146
- * process.env.FOO = 'bar';
147
- * dotenvExpandFromProcessEnv('Hello $FOO'); // "Hello bar"
148
- * ```
149
- */
150
- const dotenvExpandFromProcessEnv = (value) => dotenvExpand(value, process.env);
151
-
152
- /**
153
- * Attach legacy root flags to a Commander program.
154
- * Uses provided defaults to render help labels without coupling to generators.
155
- */
156
- const attachRootOptions = (program, defaults, opts) => {
157
- // Install temporary wrappers to tag all options added here as "base".
158
- const GROUP = 'base';
159
- const tagLatest = (cmd, group) => {
160
- const optsArr = cmd.options;
161
- if (Array.isArray(optsArr) && optsArr.length > 0) {
162
- const last = optsArr[optsArr.length - 1];
163
- last.__group = group;
164
- }
165
- };
166
- const originalAddOption = program.addOption.bind(program);
167
- const originalOption = program.option.bind(program);
168
- program.addOption = function patchedAdd(opt) {
169
- // Tag before adding, in case consumers inspect the Option directly.
170
- opt.__group = GROUP;
171
- const ret = originalAddOption(opt);
172
- return ret;
173
- };
174
- program.option = function patchedOption(...args) {
175
- const ret = originalOption(...args);
176
- tagLatest(this, GROUP);
177
- return ret;
178
- };
179
- const { defaultEnv, dotenvToken, dynamicPath, env, excludeDynamic, excludeEnv, excludeGlobal, excludePrivate, excludePublic, loadProcess, log, outputPath, paths, pathsDelimiter, pathsDelimiterPattern, privateToken, scripts, shell, varsAssignor, varsAssignorPattern, varsDelimiter, varsDelimiterPattern, } = defaults ?? {};
180
- const va = typeof defaults?.varsAssignor === 'string' ? defaults.varsAssignor : '=';
181
- const vd = typeof defaults?.varsDelimiter === 'string' ? defaults.varsDelimiter : ' ';
182
- // Build initial chain.
183
- let p = program
184
- .enablePositionalOptions()
185
- .passThroughOptions()
186
- .option('-e, --env <string>', `target environment (dotenv-expanded)`, dotenvExpandFromProcessEnv, env);
187
- p = p.option('-v, --vars <string>', `extra variables expressed as delimited key-value pairs (dotenv-expanded): ${[
188
- ['KEY1', 'VAL1'],
189
- ['KEY2', 'VAL2'],
190
- ]
191
- .map((v) => v.join(va))
192
- .join(vd)}`, dotenvExpandFromProcessEnv);
193
- // Optional legacy root command flag (kept for generated CLI compatibility).
194
- // Default is OFF; the generator opts in explicitly.
195
- if (opts?.includeCommandOption === true) {
196
- p = p.option('-c, --command <string>', 'command executed according to the --shell option, conflicts with cmd subcommand (dotenv-expanded)', dotenvExpandFromProcessEnv);
197
- }
198
- p = p
199
- .option('-o, --output-path <string>', 'consolidated output file (dotenv-expanded)', dotenvExpandFromProcessEnv, outputPath)
200
- .addOption(new Option('-s, --shell [string]', (() => {
201
- let defaultLabel = '';
202
- if (shell !== undefined) {
203
- if (typeof shell === 'boolean') {
204
- defaultLabel = ' (default OS shell)';
205
- }
206
- else if (typeof shell === 'string') {
207
- // Safe string interpolation
208
- defaultLabel = ` (default ${shell})`;
209
- }
210
- }
211
- return `command execution shell, no argument for default OS shell or provide shell string${defaultLabel}`;
212
- })()).conflicts('shellOff'))
213
- .addOption(new Option('-S, --shell-off', `command execution shell OFF${!shell ? ' (default)' : ''}`).conflicts('shell'))
214
- .addOption(new Option('-p, --load-process', `load variables to process.env ON${loadProcess ? ' (default)' : ''}`).conflicts('loadProcessOff'))
215
- .addOption(new Option('-P, --load-process-off', `load variables to process.env OFF${!loadProcess ? ' (default)' : ''}`).conflicts('loadProcess'))
216
- .addOption(new Option('-a, --exclude-all', `exclude all dotenv variables from loading ON${excludeDynamic &&
217
- ((excludeEnv && excludeGlobal) || (excludePrivate && excludePublic))
218
- ? ' (default)'
219
- : ''}`).conflicts('excludeAllOff'))
220
- .addOption(new Option('-A, --exclude-all-off', `exclude all dotenv variables from loading OFF (default)`).conflicts('excludeAll'))
221
- .addOption(new Option('-z, --exclude-dynamic', `exclude dynamic dotenv variables from loading ON${excludeDynamic ? ' (default)' : ''}`).conflicts('excludeDynamicOff'))
222
- .addOption(new Option('-Z, --exclude-dynamic-off', `exclude dynamic dotenv variables from loading OFF${!excludeDynamic ? ' (default)' : ''}`).conflicts('excludeDynamic'))
223
- .addOption(new Option('-n, --exclude-env', `exclude environment-specific dotenv variables from loading${excludeEnv ? ' (default)' : ''}`).conflicts('excludeEnvOff'))
224
- .addOption(new Option('-N, --exclude-env-off', `exclude environment-specific dotenv variables from loading OFF${!excludeEnv ? ' (default)' : ''}`).conflicts('excludeEnv'))
225
- .addOption(new Option('-g, --exclude-global', `exclude global dotenv variables from loading ON${excludeGlobal ? ' (default)' : ''}`).conflicts('excludeGlobalOff'))
226
- .addOption(new Option('-G, --exclude-global-off', `exclude global dotenv variables from loading OFF${!excludeGlobal ? ' (default)' : ''}`).conflicts('excludeGlobal'))
227
- .addOption(new Option('-r, --exclude-private', `exclude private dotenv variables from loading ON${excludePrivate ? ' (default)' : ''}`).conflicts('excludePrivateOff'))
228
- .addOption(new Option('-R, --exclude-private-off', `exclude private dotenv variables from loading OFF${!excludePrivate ? ' (default)' : ''}`).conflicts('excludePrivate'))
229
- .addOption(new Option('-u, --exclude-public', `exclude public dotenv variables from loading ON${excludePublic ? ' (default)' : ''}`).conflicts('excludePublicOff'))
230
- .addOption(new Option('-U, --exclude-public-off', `exclude public dotenv variables from loading OFF${!excludePublic ? ' (default)' : ''}`).conflicts('excludePublic'))
231
- .addOption(new Option('-l, --log', `console log loaded variables ON${log ? ' (default)' : ''}`).conflicts('logOff'))
232
- .addOption(new Option('-L, --log-off', `console log loaded variables OFF${!log ? ' (default)' : ''}`).conflicts('log'))
233
- .option('--capture', 'capture child process stdio for commands (tests/CI)')
234
- .option('--redact', 'mask secret-like values in logs/trace (presentation-only)')
235
- .option('--default-env <string>', 'default target environment', dotenvExpandFromProcessEnv, defaultEnv)
236
- .option('--dotenv-token <string>', 'dotenv-expanded token indicating a dotenv file', dotenvExpandFromProcessEnv, dotenvToken)
237
- .option('--dynamic-path <string>', 'dynamic variables path (.js or .ts; .ts is auto-compiled when esbuild is available, otherwise precompile)', dotenvExpandFromProcessEnv, dynamicPath)
238
- .option('--paths <string>', 'dotenv-expanded delimited list of paths to dotenv directory', dotenvExpandFromProcessEnv, paths)
239
- .option('--paths-delimiter <string>', 'paths delimiter string', pathsDelimiter)
240
- .option('--paths-delimiter-pattern <string>', 'paths delimiter regex pattern', pathsDelimiterPattern)
241
- .option('--private-token <string>', 'dotenv-expanded token indicating private variables', dotenvExpandFromProcessEnv, privateToken)
242
- .option('--vars-delimiter <string>', 'vars delimiter string', varsDelimiter)
243
- .option('--vars-delimiter-pattern <string>', 'vars delimiter regex pattern', varsDelimiterPattern)
244
- .option('--vars-assignor <string>', 'vars assignment operator string', varsAssignor)
245
- .option('--vars-assignor-pattern <string>', 'vars assignment operator regex pattern', varsAssignorPattern)
246
- // Hidden scripts pipe-through (stringified)
247
- .addOption(new Option('--scripts <string>')
248
- .default(JSON.stringify(scripts))
249
- .hideHelp());
250
- // Diagnostics: opt-in tracing; optional variadic keys after the flag.
251
- p = p.option('--trace [keys...]', 'emit diagnostics for child env composition (optional keys)');
252
- // Validation: strict mode fails on env validation issues (warn by default).
253
- p = p.option('--strict', 'fail on env validation errors (schema/requiredKeys)');
254
- // Entropy diagnostics (presentation-only)
255
- p = p
256
- .addOption(new Option('--entropy-warn', 'enable entropy warnings (default on)').conflicts('entropyWarnOff'))
257
- .addOption(new Option('--entropy-warn-off', 'disable entropy warnings').conflicts('entropyWarn'))
258
- .option('--entropy-threshold <number>', 'entropy bits/char threshold (default 3.8)')
259
- .option('--entropy-min-length <number>', 'min length to examine for entropy (default 16)')
260
- .option('--entropy-whitelist <pattern...>', 'suppress entropy warnings when key matches any regex pattern')
261
- .option('--redact-pattern <pattern...>', 'additional key-match regex patterns to trigger redaction');
262
- // Restore original methods to avoid tagging future additions outside base.
263
- program.addOption = originalAddOption;
264
- program.option = originalOption;
265
- return p;
266
- };
267
-
268
16
  // Base root CLI defaults (shared; kept untyped here to avoid cross-layer deps).
269
17
  const baseRootOptionDefaults = {
270
18
  dotenvToken: '.env',
@@ -841,9 +589,21 @@ const resolveGetDotenvOptions = async (customOptions) => {
841
589
  const localOptionsPath = localPkgDir
842
590
  ? join(localPkgDir, getDotenvOptionsFilename)
843
591
  : undefined;
844
- const localOptions = (localOptionsPath && (await fs.exists(localOptionsPath))
845
- ? JSON.parse((await fs.readFile(localOptionsPath)).toString())
846
- : {});
592
+ // Safely read local CLI-facing defaults (defensive typing to satisfy strict linting).
593
+ let localOptions = {};
594
+ if (localOptionsPath && (await fs.exists(localOptionsPath))) {
595
+ try {
596
+ const txt = await fs.readFile(localOptionsPath, 'utf-8');
597
+ const parsed = JSON.parse(txt);
598
+ if (parsed && typeof parsed === 'object') {
599
+ localOptions = parsed;
600
+ }
601
+ }
602
+ catch {
603
+ // Malformed or unreadable local options are treated as absent.
604
+ localOptions = {};
605
+ }
606
+ }
847
607
  // Merge order: base < local < custom (custom has highest precedence)
848
608
  const mergedCli = defaultsDeep(baseGetDotenvCliOptions, localOptions);
849
609
  const defaultsFromCli = getDotenvCliOptions2Options(mergedCli);
@@ -854,6 +614,316 @@ const resolveGetDotenvOptions = async (customOptions) => {
854
614
  };
855
615
  };
856
616
 
617
+ /**
618
+ * Dotenv expansion utilities.
619
+ *
620
+ * This module implements recursive expansion of environment-variable
621
+ * references in strings and records. It supports both whitespace and
622
+ * bracket syntaxes with optional defaults:
623
+ *
624
+ * - Whitespace: `$VAR[:default]`
625
+ * - Bracketed: `${VAR[:default]}`
626
+ *
627
+ * Escaped dollar signs (`\$`) are preserved.
628
+ * Unknown variables resolve to empty string unless a default is provided.
629
+ */
630
+ /**
631
+ * Like String.prototype.search but returns the last index.
632
+ * @internal
633
+ */
634
+ const searchLast = (str, rgx) => {
635
+ const matches = Array.from(str.matchAll(rgx));
636
+ return matches.length > 0 ? (matches.slice(-1)[0]?.index ?? -1) : -1;
637
+ };
638
+ const replaceMatch = (value, match, ref) => {
639
+ /**
640
+ * @internal
641
+ */
642
+ const group = match[0];
643
+ const key = match[1];
644
+ const defaultValue = match[2];
645
+ if (!key)
646
+ return value;
647
+ const replacement = value.replace(group, ref[key] ?? defaultValue ?? '');
648
+ return interpolate(replacement, ref);
649
+ };
650
+ const interpolate = (value = '', ref = {}) => {
651
+ /**
652
+ * @internal
653
+ */
654
+ // if value is falsy, return it as is
655
+ if (!value)
656
+ return value;
657
+ // get position of last unescaped dollar sign
658
+ const lastUnescapedDollarSignIndex = searchLast(value, /(?!(?<=\\))\$/g);
659
+ // return value if none found
660
+ if (lastUnescapedDollarSignIndex === -1)
661
+ return value;
662
+ // evaluate the value tail
663
+ const tail = value.slice(lastUnescapedDollarSignIndex);
664
+ // find whitespace pattern: $KEY:DEFAULT
665
+ const whitespacePattern = /^\$([\w]+)(?::([^\s]*))?/;
666
+ const whitespaceMatch = whitespacePattern.exec(tail);
667
+ if (whitespaceMatch != null)
668
+ return replaceMatch(value, whitespaceMatch, ref);
669
+ else {
670
+ // find bracket pattern: ${KEY:DEFAULT}
671
+ const bracketPattern = /^\${([\w]+)(?::([^}]*))?}/;
672
+ const bracketMatch = bracketPattern.exec(tail);
673
+ if (bracketMatch != null)
674
+ return replaceMatch(value, bracketMatch, ref);
675
+ }
676
+ return value;
677
+ };
678
+ /**
679
+ * Recursively expands environment variables in a string. Variables may be
680
+ * presented with optional default as `$VAR[:default]` or `${VAR[:default]}`.
681
+ * Unknown variables will expand to an empty string.
682
+ *
683
+ * @param value - The string to expand.
684
+ * @param ref - The reference object to use for variable expansion.
685
+ * @returns The expanded string.
686
+ *
687
+ * @example
688
+ * ```ts
689
+ * process.env.FOO = 'bar';
690
+ * dotenvExpand('Hello $FOO'); // "Hello bar"
691
+ * dotenvExpand('Hello $BAZ:world'); // "Hello world"
692
+ * ```
693
+ *
694
+ * @remarks
695
+ * The expansion is recursive. If a referenced variable itself contains
696
+ * references, those will also be expanded until a stable value is reached.
697
+ * Escaped references (e.g. `\$FOO`) are preserved as literals.
698
+ */
699
+ const dotenvExpand = (value, ref = process.env) => {
700
+ const result = interpolate(value, ref);
701
+ return result ? result.replace(/\\\$/g, '$') : undefined;
702
+ };
703
+ /**
704
+ * Recursively expands environment variables in the values of a JSON object.
705
+ * Variables may be presented with optional default as `$VAR[:default]` or
706
+ * `${VAR[:default]}`. Unknown variables will expand to an empty string.
707
+ *
708
+ * @param values - The values object to expand.
709
+ * @param options - Expansion options.
710
+ * @returns The value object with expanded string values.
711
+ *
712
+ * @example
713
+ * ```ts
714
+ * process.env.FOO = 'bar';
715
+ * dotenvExpandAll({ A: '$FOO', B: 'x${FOO}y' });
716
+ * // => { A: "bar", B: "xbary" }
717
+ * ```
718
+ *
719
+ * @remarks
720
+ * Options:
721
+ * - ref: The reference object to use for expansion (defaults to process.env).
722
+ * - progressive: Whether to progressively add expanded values to the set of
723
+ * reference keys.
724
+ *
725
+ * When `progressive` is true, each expanded key becomes available for
726
+ * subsequent expansions in the same object (left-to-right by object key order).
727
+ */
728
+ const dotenvExpandAll = (values = {}, options = {}) => Object.keys(values).reduce((acc, key) => {
729
+ const { ref = process.env, progressive = false } = options;
730
+ acc[key] = dotenvExpand(values[key], {
731
+ ...ref,
732
+ ...(progressive ? acc : {}),
733
+ });
734
+ return acc;
735
+ }, {});
736
+ /**
737
+ * Recursively expands environment variables in a string using `process.env` as
738
+ * the expansion reference. Variables may be presented with optional default as
739
+ * `$VAR[:default]` or `${VAR[:default]}`. Unknown variables will expand to an
740
+ * empty string.
741
+ *
742
+ * @param value - The string to expand.
743
+ * @returns The expanded string.
744
+ *
745
+ * @example
746
+ * ```ts
747
+ * process.env.FOO = 'bar';
748
+ * dotenvExpandFromProcessEnv('Hello $FOO'); // "Hello bar"
749
+ * ```
750
+ */
751
+ const dotenvExpandFromProcessEnv = (value) => dotenvExpand(value, process.env);
752
+
753
+ /* eslint-disable @typescript-eslint/no-deprecated */
754
+ /**
755
+ * Attach root flags to a GetDotenvCli instance.
756
+ * - Host-only: program is typed as GetDotenvCli and supports dynamicOption/createDynamicOption.
757
+ * - Any flag that displays an effective default in help uses dynamic descriptions.
758
+ */
759
+ const attachRootOptions = (program, defaults, opts) => {
760
+ // Install temporary wrappers to tag all options added here as "base" for grouped help.
761
+ const GROUP = 'base';
762
+ const tagLatest = (cmd, group) => {
763
+ const optsArr = cmd.options;
764
+ if (Array.isArray(optsArr) && optsArr.length > 0) {
765
+ const last = optsArr[optsArr.length - 1];
766
+ last.__group = group;
767
+ }
768
+ };
769
+ const originalAddOption = program.addOption.bind(program);
770
+ const originalOption = program.option.bind(program);
771
+ program.addOption = function patchedAdd(opt) {
772
+ opt.__group = GROUP;
773
+ return originalAddOption(opt);
774
+ };
775
+ program.option = function patchedOption(...args) {
776
+ const ret = originalOption(...args);
777
+ tagLatest(this, GROUP);
778
+ return ret;
779
+ };
780
+ const { defaultEnv, dotenvToken, dynamicPath, env, outputPath, paths, pathsDelimiter, pathsDelimiterPattern, privateToken, scripts, varsAssignor, varsAssignorPattern, varsDelimiter, varsDelimiterPattern, } = defaults ?? {};
781
+ const va = typeof defaults?.varsAssignor === 'string' ? defaults.varsAssignor : '=';
782
+ const vd = typeof defaults?.varsDelimiter === 'string' ? defaults.varsDelimiter : ' ';
783
+ // Helper: append (default) tags for ON/OFF toggles
784
+ const onOff = (on, isDefault) => on
785
+ ? `ON${isDefault ? ' (default)' : ''}`
786
+ : `OFF${isDefault ? ' (default)' : ''}`;
787
+ let p = program
788
+ .enablePositionalOptions()
789
+ .passThroughOptions()
790
+ .option('-e, --env <string>', `target environment (dotenv-expanded)`, dotenvExpandFromProcessEnv, env);
791
+ p = p.option('-v, --vars <string>', `extra variables expressed as delimited key-value pairs (dotenv-expanded): ${[
792
+ ['KEY1', 'VAL1'],
793
+ ['KEY2', 'VAL2'],
794
+ ]
795
+ .map((v) => v.join(va))
796
+ .join(vd)}`, dotenvExpandFromProcessEnv);
797
+ if (opts?.includeCommandOption === true) {
798
+ p = p.option('-c, --command <string>', 'command executed according to the --shell option, conflicts with cmd subcommand (dotenv-expanded)', dotenvExpandFromProcessEnv);
799
+ }
800
+ // Output path (interpolated later; help can remain static)
801
+ p = p.option('-o, --output-path <string>', 'consolidated output file (dotenv-expanded)', dotenvExpandFromProcessEnv, outputPath);
802
+ // Shell ON (string or boolean true => default shell)
803
+ p = p
804
+ .addOption(program
805
+ .createDynamicOption('-s, --shell [string]', (cfg) => {
806
+ const s = cfg.shell;
807
+ let tag = '';
808
+ if (typeof s === 'boolean' && s)
809
+ tag = ' (default OS shell)';
810
+ else if (typeof s === 'string' && s.length > 0)
811
+ tag = ` (default ${s})`;
812
+ return `command execution shell, no argument for default OS shell or provide shell string${tag}`;
813
+ })
814
+ .conflicts('shellOff'))
815
+ // Shell OFF
816
+ .addOption(program
817
+ .createDynamicOption('-S, --shell-off', (cfg) => {
818
+ const s = cfg.shell;
819
+ return `command execution shell OFF${s === false ? ' (default)' : ''}`;
820
+ })
821
+ .conflicts('shell'));
822
+ // Load process ON/OFF (dynamic defaults)
823
+ p = p
824
+ .addOption(program
825
+ .createDynamicOption('-p, --load-process', (cfg) => `load variables to process.env ${onOff(true, Boolean(cfg.loadProcess))}`)
826
+ .conflicts('loadProcessOff'))
827
+ .addOption(program
828
+ .createDynamicOption('-P, --load-process-off', (cfg) => `load variables to process.env ${onOff(false, !cfg.loadProcess)}`)
829
+ .conflicts('loadProcess'));
830
+ // Exclusion master toggle (dynamic)
831
+ p = p
832
+ .addOption(program
833
+ .createDynamicOption('-a, --exclude-all', (cfg) => {
834
+ const c = cfg;
835
+ const allOn = !!c.excludeDynamic &&
836
+ ((!!c.excludeEnv && !!c.excludeGlobal) ||
837
+ (!!c.excludePrivate && !!c.excludePublic));
838
+ const suffix = allOn ? ' (default)' : '';
839
+ return `exclude all dotenv variables from loading ON${suffix}`;
840
+ })
841
+ .conflicts('excludeAllOff'))
842
+ .addOption(new Option('-A, --exclude-all-off', `exclude all dotenv variables from loading OFF (default)`).conflicts('excludeAll'));
843
+ // Per-family exclusions (dynamic defaults)
844
+ p = p
845
+ .addOption(program
846
+ .createDynamicOption('-z, --exclude-dynamic', (cfg) => `exclude dynamic dotenv variables from loading ${onOff(true, Boolean(cfg.excludeDynamic))}`)
847
+ .conflicts('excludeDynamicOff'))
848
+ .addOption(program
849
+ .createDynamicOption('-Z, --exclude-dynamic-off', (cfg) => `exclude dynamic dotenv variables from loading ${onOff(false, !cfg.excludeDynamic)}`)
850
+ .conflicts('excludeDynamic'))
851
+ .addOption(program
852
+ .createDynamicOption('-n, --exclude-env', (cfg) => `exclude environment-specific dotenv variables from loading ${onOff(true, Boolean(cfg.excludeEnv))}`)
853
+ .conflicts('excludeEnvOff'))
854
+ .addOption(program
855
+ .createDynamicOption('-N, --exclude-env-off', (cfg) => `exclude environment-specific dotenv variables from loading ${onOff(false, !cfg.excludeEnv)}`)
856
+ .conflicts('excludeEnv'))
857
+ .addOption(program
858
+ .createDynamicOption('-g, --exclude-global', (cfg) => `exclude global dotenv variables from loading ${onOff(true, Boolean(cfg.excludeGlobal))}`)
859
+ .conflicts('excludeGlobalOff'))
860
+ .addOption(program
861
+ .createDynamicOption('-G, --exclude-global-off', (cfg) => `exclude global dotenv variables from loading ${onOff(false, !cfg.excludeGlobal)}`)
862
+ .conflicts('excludeGlobal'))
863
+ .addOption(program
864
+ .createDynamicOption('-r, --exclude-private', (cfg) => `exclude private dotenv variables from loading ${onOff(true, Boolean(cfg.excludePrivate))}`)
865
+ .conflicts('excludePrivateOff'))
866
+ .addOption(program
867
+ .createDynamicOption('-R, --exclude-private-off', (cfg) => `exclude private dotenv variables from loading ${onOff(false, !cfg.excludePrivate)}`)
868
+ .conflicts('excludePrivate'))
869
+ .addOption(program
870
+ .createDynamicOption('-u, --exclude-public', (cfg) => `exclude public dotenv variables from loading ${onOff(true, Boolean(cfg.excludePublic))}`)
871
+ .conflicts('excludePublicOff'))
872
+ .addOption(program
873
+ .createDynamicOption('-U, --exclude-public-off', (cfg) => `exclude public dotenv variables from loading ${onOff(false, !cfg.excludePublic)}`)
874
+ .conflicts('excludePublic'));
875
+ // Log ON/OFF (dynamic)
876
+ p = p
877
+ .addOption(program
878
+ .createDynamicOption('-l, --log', (cfg) => `console log loaded variables ${onOff(true, Boolean(cfg.log))}`)
879
+ .conflicts('logOff'))
880
+ .addOption(program
881
+ .createDynamicOption('-L, --log-off', (cfg) => `console log loaded variables ${onOff(false, !cfg.log)}`)
882
+ .conflicts('log'));
883
+ // Capture flag (no default display; static)
884
+ p = p.option('--capture', 'capture child process stdio for commands (tests/CI)');
885
+ // Core bootstrap/static flags (kept static in help)
886
+ p = p
887
+ .option('--default-env <string>', 'default target environment', dotenvExpandFromProcessEnv, defaultEnv)
888
+ .option('--dotenv-token <string>', 'dotenv-expanded token indicating a dotenv file', dotenvExpandFromProcessEnv, dotenvToken)
889
+ .option('--dynamic-path <string>', 'dynamic variables path (.js or .ts; .ts is auto-compiled when esbuild is available, otherwise precompile)', dotenvExpandFromProcessEnv, dynamicPath)
890
+ .option('--paths <string>', 'dotenv-expanded delimited list of paths to dotenv directory', dotenvExpandFromProcessEnv, paths)
891
+ .option('--paths-delimiter <string>', 'paths delimiter string', pathsDelimiter)
892
+ .option('--paths-delimiter-pattern <string>', 'paths delimiter regex pattern', pathsDelimiterPattern)
893
+ .option('--private-token <string>', 'dotenv-expanded token indicating private variables', dotenvExpandFromProcessEnv, privateToken)
894
+ .option('--vars-delimiter <string>', 'vars delimiter string', varsDelimiter)
895
+ .option('--vars-delimiter-pattern <string>', 'vars delimiter regex pattern', varsDelimiterPattern)
896
+ .option('--vars-assignor <string>', 'vars assignment operator string', varsAssignor)
897
+ .option('--vars-assignor-pattern <string>', 'vars assignment operator regex pattern', varsAssignorPattern)
898
+ // Hidden scripts pipe-through (stringified)
899
+ .addOption(new Option('--scripts <string>')
900
+ .default(JSON.stringify(scripts))
901
+ .hideHelp());
902
+ // Diagnostics / validation / entropy
903
+ p = p
904
+ .option('--trace [keys...]', 'emit diagnostics for child env composition (optional keys)')
905
+ .option('--strict', 'fail on env validation errors (schema/requiredKeys)');
906
+ p = p
907
+ .addOption(program
908
+ .createDynamicOption('--entropy-warn', (cfg) => {
909
+ const warn = cfg.warnEntropy;
910
+ // Default is effectively ON when warnEntropy is true or undefined.
911
+ return `enable entropy warnings${warn === false ? '' : ' (default on)'}`;
912
+ })
913
+ .conflicts('entropyWarnOff'))
914
+ .addOption(program
915
+ .createDynamicOption('--entropy-warn-off', (cfg) => `disable entropy warnings${cfg.warnEntropy === false ? ' (default)' : ''}`)
916
+ .conflicts('entropyWarn'))
917
+ .option('--entropy-threshold <number>', 'entropy bits/char threshold (default 3.8)')
918
+ .option('--entropy-min-length <number>', 'min length to examine for entropy (default 16)')
919
+ .option('--entropy-whitelist <pattern...>', 'suppress entropy warnings when key matches any regex pattern')
920
+ .option('--redact-pattern <pattern...>', 'additional key-match regex patterns to trigger redaction');
921
+ // Restore original methods
922
+ program.addOption = originalAddOption;
923
+ program.option = originalOption;
924
+ return p;
925
+ };
926
+
857
927
  /**
858
928
  * Zod schemas for programmatic GetDotenv options.
859
929
  *
@@ -1487,6 +1557,8 @@ const computeContext = async (customOptions, plugins, hostMetaUrl) => {
1487
1557
  };
1488
1558
  };
1489
1559
 
1560
+ // Dynamic help support: attach a private symbol to Option for description fns.
1561
+ const DYN_DESC_SYM = Symbol('getdotenv.dynamic.description');
1490
1562
  const HOST_META_URL = import.meta.url;
1491
1563
  const CTX_SYMBOL = Symbol('GetDotenvCli.ctx');
1492
1564
  const OPTS_SYMBOL = Symbol('GetDotenvCli.options');
@@ -1509,6 +1581,13 @@ let GetDotenvCli$1 = class GetDotenvCli extends Command {
1509
1581
  _installed = false;
1510
1582
  /** Optional header line to prepend in help output */
1511
1583
  [HELP_HEADER_SYMBOL];
1584
+ /**
1585
+ * Create a subcommand using the same subclass, preserving helpers like
1586
+ * dynamicOption on children.
1587
+ */
1588
+ createCommand(name) {
1589
+ return new this.constructor(name);
1590
+ }
1512
1591
  constructor(alias = 'getdotenv') {
1513
1592
  super(alias);
1514
1593
  // Ensure subcommands that use passThroughOptions can be attached safely.
@@ -1516,15 +1595,18 @@ let GetDotenvCli$1 = class GetDotenvCli extends Command {
1516
1595
  // child uses passThroughOptions.
1517
1596
  this.enablePositionalOptions();
1518
1597
  // Configure grouped help: show only base options in default "Options";
1519
- // append App/Plugin sections after default help.
1598
+ // we will insert App/Plugin sections before Commands in helpInformation().
1520
1599
  this.configureHelp({
1521
1600
  visibleOptions: (cmd) => {
1522
- const all = cmd.options ??
1523
- [];
1524
- const base = all.filter((opt) => {
1525
- const group = opt.__group;
1526
- return group === 'base';
1527
- });
1601
+ const all = cmd.options ?? [];
1602
+ const parent = cmd.parent ?? null;
1603
+ const isRoot = parent === null;
1604
+ const list = isRoot
1605
+ ? all.filter((opt) => {
1606
+ const group = opt.__group;
1607
+ return group === 'base';
1608
+ })
1609
+ : all.slice(); // subcommands: show all options (their own "Options:" block)
1528
1610
  // Sort: short-aliased options first, then long-only; stable by flags.
1529
1611
  const hasShort = (opt) => {
1530
1612
  const flags = opt.flags ?? '';
@@ -1532,19 +1614,18 @@ let GetDotenvCli$1 = class GetDotenvCli extends Command {
1532
1614
  return /(^|\s|,)-[A-Za-z]/.test(flags);
1533
1615
  };
1534
1616
  const byFlags = (opt) => opt.flags ?? '';
1535
- base.sort((a, b) => {
1617
+ list.sort((a, b) => {
1536
1618
  const aS = hasShort(a) ? 1 : 0;
1537
1619
  const bS = hasShort(b) ? 1 : 0;
1538
1620
  return bS - aS || byFlags(a).localeCompare(byFlags(b));
1539
1621
  });
1540
- return base;
1622
+ return list;
1541
1623
  },
1542
1624
  });
1543
1625
  this.addHelpText('beforeAll', () => {
1544
1626
  const header = this[HELP_HEADER_SYMBOL];
1545
1627
  return header && header.length > 0 ? `${header}\n\n` : '';
1546
1628
  });
1547
- this.addHelpText('afterAll', (ctx) => this.#renderOptionGroups(ctx.command));
1548
1629
  // Skeleton preSubcommand hook: produce a context if absent, without
1549
1630
  // mutating process.env. The passOptions hook (when installed) will // compute the final context using merged CLI options; keeping
1550
1631
  // loadProcess=false here avoids leaking dotenv values into the parent
@@ -1556,9 +1637,15 @@ let GetDotenvCli$1 = class GetDotenvCli extends Command {
1556
1637
  });
1557
1638
  }
1558
1639
  /**
1559
- * Resolve options (strict) and compute dotenv context. * Stores the context on the instance under a symbol.
1640
+ * Resolve options (strict) and compute dotenv context.
1641
+ * Stores the context on the instance under a symbol.
1642
+ *
1643
+ * Options:
1644
+ * - opts.runAfterResolve (default true): when false, skips running plugin
1645
+ * afterResolve hooks. Useful for top-level help rendering to avoid
1646
+ * long-running side-effects while still evaluating dynamic help text.
1560
1647
  */
1561
- async resolveAndLoad(customOptions = {}) {
1648
+ async resolveAndLoad(customOptions = {}, opts) {
1562
1649
  // Resolve defaults, then validate strictly under the new host.
1563
1650
  const optionsResolved = await resolveGetDotenvOptions(customOptions);
1564
1651
  getDotenvOptionsSchemaResolved.parse(optionsResolved);
@@ -1569,9 +1656,64 @@ let GetDotenvCli$1 = class GetDotenvCli extends Command {
1569
1656
  ctx;
1570
1657
  // Ensure plugins are installed exactly once, then run afterResolve.
1571
1658
  await this.install();
1572
- await this._runAfterResolve(ctx);
1659
+ if (opts?.runAfterResolve ?? true) {
1660
+ await this._runAfterResolve(ctx);
1661
+ }
1573
1662
  return ctx;
1574
1663
  }
1664
+ /**
1665
+ * Create a Commander Option that computes its description at help time.
1666
+ * The returned Option may be configured (conflicts, default, parser) and
1667
+ * added via addOption().
1668
+ */
1669
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters
1670
+ createDynamicOption(flags, desc, parser, defaultValue) {
1671
+ const opt = new Option(flags, '');
1672
+ // Keep the function on a private symbol so it survives through Commander.
1673
+ opt[DYN_DESC_SYM] = desc;
1674
+ if (parser)
1675
+ opt.argParser(parser);
1676
+ if (defaultValue !== undefined)
1677
+ opt.default(defaultValue);
1678
+ return opt;
1679
+ }
1680
+ /**
1681
+ * Chainable helper mirroring .option(), but with a dynamic description.
1682
+ * Equivalent to addOption(createDynamicOption(...)).
1683
+ */
1684
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters
1685
+ dynamicOption(flags, desc, parser, defaultValue) {
1686
+ const opt = this.createDynamicOption(flags, desc, parser, defaultValue);
1687
+ this.addOption(opt);
1688
+ return this;
1689
+ }
1690
+ /**
1691
+ * Evaluate dynamic descriptions for this command and all descendants using
1692
+ * the provided resolved configuration. Mutates the Option.description in
1693
+ * place so Commander help renders updated text.
1694
+ */
1695
+ evaluateDynamicOptions(resolved) {
1696
+ const visit = (cmd) => {
1697
+ const arr = cmd.options ?? [];
1698
+ for (const o of arr) {
1699
+ const dyn = o[DYN_DESC_SYM];
1700
+ if (typeof dyn === 'function') {
1701
+ try {
1702
+ const txt = dyn(resolved);
1703
+ // Commander Option has a public "description" field used by help.
1704
+ o.description = txt;
1705
+ }
1706
+ catch {
1707
+ // Best-effort: leave description as-is on evaluation failure.
1708
+ }
1709
+ }
1710
+ }
1711
+ const children = cmd.commands ?? [];
1712
+ for (const c of children)
1713
+ visit(c);
1714
+ };
1715
+ visit(this);
1716
+ }
1575
1717
  /**
1576
1718
  * Retrieve the current invocation context (if any).
1577
1719
  */
@@ -1601,6 +1743,7 @@ let GetDotenvCli$1 = class GetDotenvCli extends Command {
1601
1743
  tagAppOptions(fn) {
1602
1744
  const root = this;
1603
1745
  const originalAddOption = root.addOption.bind(root);
1746
+ // eslint-disable-next-line @typescript-eslint/no-deprecated
1604
1747
  const originalOption = root.option.bind(root);
1605
1748
  const tagLatest = (cmd, group) => {
1606
1749
  const optsArr = cmd.options;
@@ -1613,6 +1756,7 @@ let GetDotenvCli$1 = class GetDotenvCli extends Command {
1613
1756
  opt.__group = 'app';
1614
1757
  return originalAddOption(opt);
1615
1758
  };
1759
+ // eslint-disable-next-line @typescript-eslint/no-deprecated
1616
1760
  root.option = function patchedOption(...args) {
1617
1761
  const ret = originalOption(...args);
1618
1762
  tagLatest(this, 'app');
@@ -1623,6 +1767,7 @@ let GetDotenvCli$1 = class GetDotenvCli extends Command {
1623
1767
  }
1624
1768
  finally {
1625
1769
  root.addOption = originalAddOption;
1770
+ // eslint-disable-next-line @typescript-eslint/no-deprecated
1626
1771
  root.option = originalOption;
1627
1772
  }
1628
1773
  }
@@ -1668,6 +1813,40 @@ let GetDotenvCli$1 = class GetDotenvCli extends Command {
1668
1813
  }
1669
1814
  return this;
1670
1815
  }
1816
+ /**
1817
+ * Insert grouped plugin/app options between "Options" and "Commands" for
1818
+ * hybrid ordering. Applies to root and any parent command.
1819
+ */
1820
+ helpInformation() {
1821
+ // Base help text first (includes beforeAll/after hooks).
1822
+ const base = super.helpInformation();
1823
+ const groups = this.#renderOptionGroups(this);
1824
+ const block = typeof groups === 'string' ? groups.trim() : '';
1825
+ let out = base;
1826
+ if (!block) {
1827
+ // Ensure a trailing blank line even when no extra groups render.
1828
+ if (!out.endsWith('\n\n'))
1829
+ out = out.endsWith('\n') ? `${out}\n` : `${out}\n\n`;
1830
+ return out;
1831
+ }
1832
+ // Insert just before "Commands:" when present.
1833
+ const marker = '\nCommands:';
1834
+ const idx = base.indexOf(marker);
1835
+ if (idx >= 0) {
1836
+ const toInsert = groups.startsWith('\n') ? groups : `\n${groups}`;
1837
+ out = `${base.slice(0, idx)}${toInsert}${base.slice(idx)}`;
1838
+ }
1839
+ else {
1840
+ // Otherwise append.
1841
+ const sep = base.endsWith('\n') || groups.startsWith('\n') ? '' : '\n';
1842
+ out = `${base}${sep}${groups}`;
1843
+ }
1844
+ // Ensure a trailing blank line for prompt separation.
1845
+ if (!out.endsWith('\n\n')) {
1846
+ out = out.endsWith('\n') ? `${out}\n` : `${out}\n\n`;
1847
+ }
1848
+ return out;
1849
+ }
1671
1850
  /**
1672
1851
  * Register a plugin for installation (parent level).
1673
1852
  * Installation occurs on first resolveAndLoad() (or explicit install()).
@@ -1706,7 +1885,7 @@ let GetDotenvCli$1 = class GetDotenvCli extends Command {
1706
1885
  for (const p of this._plugins)
1707
1886
  await run(p);
1708
1887
  }
1709
- // Render App/Plugin grouped options appended after default help.
1888
+ // Render App/Plugin grouped options (used by helpInformation override).
1710
1889
  #renderOptionGroups(cmd) {
1711
1890
  const all = cmd.options ?? [];
1712
1891
  const byGroup = new Map();
@@ -1748,11 +1927,14 @@ let GetDotenvCli$1 = class GetDotenvCli extends Command {
1748
1927
  }
1749
1928
  // Plugin groups sorted by id
1750
1929
  const pluginKeys = Array.from(byGroup.keys()).filter((k) => k.startsWith('plugin:'));
1930
+ const currentName = cmd.name?.() ?? '';
1751
1931
  pluginKeys.sort((a, b) => a.localeCompare(b));
1752
1932
  for (const k of pluginKeys) {
1753
1933
  const id = k.slice('plugin:'.length) || '(unknown)';
1754
1934
  const rows = byGroup.get(k) ?? [];
1755
- if (rows.length > 0) {
1935
+ // Do not show a "Plugin options — <self>" section on the command that owns those options.
1936
+ // Only child-injected plugin groups should render at this level.
1937
+ if (rows.length > 0 && id !== currentName) {
1756
1938
  out += renderRows(`Plugin options — ${id}`, rows);
1757
1939
  }
1758
1940
  }
@@ -1823,6 +2005,17 @@ class GetDotenvCli extends GetDotenvCli$1 {
1823
2005
  // Build service options and compute context (always-on loader path).
1824
2006
  const serviceOptions = getDotenvCliOptions2Options(merged);
1825
2007
  await this.resolveAndLoad(serviceOptions);
2008
+ // Refresh dynamic option descriptions using resolved config + plugin slices
2009
+ try {
2010
+ const ctx = this.getCtx();
2011
+ this.evaluateDynamicOptions({
2012
+ ...ctx?.optionsResolved,
2013
+ plugins: ctx?.pluginConfigs ?? {},
2014
+ });
2015
+ }
2016
+ catch {
2017
+ /* best-effort */
2018
+ }
1826
2019
  // Global validation: once after Phase C using config sources.
1827
2020
  try {
1828
2021
  const ctx = this.getCtx();
@@ -1857,6 +2050,16 @@ class GetDotenvCli extends GetDotenvCli$1 {
1857
2050
  if (!this.getCtx()) {
1858
2051
  const serviceOptions = getDotenvCliOptions2Options(merged);
1859
2052
  await this.resolveAndLoad(serviceOptions);
2053
+ try {
2054
+ const ctx = this.getCtx();
2055
+ this.evaluateDynamicOptions({
2056
+ ...ctx?.optionsResolved,
2057
+ plugins: ctx?.pluginConfigs ?? {},
2058
+ });
2059
+ }
2060
+ catch {
2061
+ /* tolerate */
2062
+ }
1860
2063
  try {
1861
2064
  const ctx = this.getCtx();
1862
2065
  const dotenv = (ctx?.dotenv ?? {});
@@ -2351,7 +2554,6 @@ const awsPlugin = () => definePlugin({
2351
2554
  cli
2352
2555
  .ns('aws')
2353
2556
  .description('Establish an AWS session and optionally forward to the AWS CLI')
2354
- .configureHelp({ showGlobalOptions: true })
2355
2557
  .enablePositionalOptions()
2356
2558
  .passThroughOptions()
2357
2559
  .allowUnknownOption(true)
@@ -2542,9 +2744,10 @@ const globPaths = async ({ globs, logger, pkgCwd, rootPath, }) => {
2542
2744
  }
2543
2745
  return { absRootPath, paths };
2544
2746
  };
2545
- const execShellCommandBatch = async ({ command, getDotenvCliOptions, globs, ignoreErrors, list, logger, pkgCwd, rootPath, shell, }) => {
2747
+ const execShellCommandBatch = async ({ command, getDotenvCliOptions, dotenvEnv, globs, ignoreErrors, list, logger, pkgCwd, rootPath, shell, }) => {
2546
2748
  const capture = process.env.GETDOTENV_STDIO === 'pipe' ||
2547
- Boolean(getDotenvCliOptions?.capture); // Require a command only when not listing. In list mode, a command is optional.
2749
+ Boolean(getDotenvCliOptions?.capture);
2750
+ // Require a command only when not listing. In list mode, a command is optional.
2548
2751
  if (!command && !list) {
2549
2752
  logger.error(`No command provided. Use --command or --list.`);
2550
2753
  process.exit(0);
@@ -2591,12 +2794,25 @@ const execShellCommandBatch = async ({ command, getDotenvCliOptions, globs, igno
2591
2794
  const hasCmd = (typeof command === 'string' && command.length > 0) ||
2592
2795
  (Array.isArray(command) && command.length > 0);
2593
2796
  if (hasCmd) {
2594
- const envBag = getDotenvCliOptions !== undefined
2595
- ? { getDotenvCliOptions: JSON.stringify(getDotenvCliOptions) }
2596
- : undefined;
2797
+ // Compose child env overlay from dotenv (drop undefined) and merged options
2798
+ const overlay = {};
2799
+ if (dotenvEnv) {
2800
+ for (const [k, v] of Object.entries(dotenvEnv)) {
2801
+ if (typeof v === 'string')
2802
+ overlay[k] = v;
2803
+ }
2804
+ }
2805
+ if (getDotenvCliOptions !== undefined) {
2806
+ try {
2807
+ overlay.getDotenvCliOptions = JSON.stringify(getDotenvCliOptions);
2808
+ }
2809
+ catch {
2810
+ // best-effort: omit if serialization fails
2811
+ }
2812
+ }
2597
2813
  await runCommand(command, shell, {
2598
2814
  cwd: path,
2599
- env: buildSpawnEnv(process.env, envBag),
2815
+ env: buildSpawnEnv(process.env, overlay),
2600
2816
  stdio: capture ? 'pipe' : 'inherit',
2601
2817
  });
2602
2818
  }
@@ -2634,6 +2850,7 @@ const buildDefaultCmdAction = (cli, batchCmd, opts) => async (commandParts, _sub
2634
2850
  const ctx = cli.getCtx();
2635
2851
  const cfgRaw = (ctx?.pluginConfigs?.['batch'] ?? {});
2636
2852
  const cfg = (cfgRaw || {});
2853
+ const dotenvEnv = (ctx?.dotenv ?? {});
2637
2854
  // Resolve batch flags from the captured parent (batch) command.
2638
2855
  const raw = batchCmd.opts();
2639
2856
  const listFromParent = !!raw.list;
@@ -2652,6 +2869,7 @@ const buildDefaultCmdAction = (cli, batchCmd, opts) => async (commandParts, _sub
2652
2869
  if (typeof commandOpt === 'string') {
2653
2870
  await execShellCommandBatch({
2654
2871
  command: resolveCommand(scripts, commandOpt),
2872
+ dotenvEnv,
2655
2873
  globs,
2656
2874
  ignoreErrors,
2657
2875
  list: false,
@@ -2663,6 +2881,7 @@ const buildDefaultCmdAction = (cli, batchCmd, opts) => async (commandParts, _sub
2663
2881
  return;
2664
2882
  }
2665
2883
  if (raw.list || localList) {
2884
+ const shellBag = ((batchCmd.parent ?? undefined)?.getDotenvCliOptions ?? {});
2666
2885
  await execShellCommandBatch({
2667
2886
  globs,
2668
2887
  ignoreErrors,
@@ -2670,7 +2889,7 @@ const buildDefaultCmdAction = (cli, batchCmd, opts) => async (commandParts, _sub
2670
2889
  logger: loggerLocal,
2671
2890
  ...(pkgCwd ? { pkgCwd } : {}),
2672
2891
  rootPath,
2673
- shell: (shell ?? false),
2892
+ shell: (shell ?? shellBag.shell ?? false),
2674
2893
  });
2675
2894
  return;
2676
2895
  }
@@ -2737,6 +2956,7 @@ const buildDefaultCmdAction = (cli, batchCmd, opts) => async (commandParts, _sub
2737
2956
  }
2738
2957
  await execShellCommandBatch({
2739
2958
  command: commandArg,
2959
+ dotenvEnv,
2740
2960
  ...(envBag ? { getDotenvCliOptions: envBag } : {}),
2741
2961
  globs,
2742
2962
  ignoreErrors,
@@ -2755,6 +2975,7 @@ const buildParentAction = (cli, opts) => async (commandParts, thisCommand) => {
2755
2975
  const logger = opts.logger ?? console;
2756
2976
  // Ensure context exists (host preSubcommand on root creates if missing).
2757
2977
  const ctx = cli.getCtx();
2978
+ const dotenvEnv = (ctx?.dotenv ?? {});
2758
2979
  const cfgRaw = (ctx?.pluginConfigs?.['batch'] ?? {});
2759
2980
  const cfg = (cfgRaw || {});
2760
2981
  const raw = thisCommand.opts();
@@ -2777,6 +2998,7 @@ const buildParentAction = (cli, opts) => async (commandParts, thisCommand) => {
2777
2998
  const commandArg = resolved;
2778
2999
  await execShellCommandBatch({
2779
3000
  command: commandArg,
3001
+ dotenvEnv,
2780
3002
  globs,
2781
3003
  ignoreErrors,
2782
3004
  list: false,
@@ -2814,6 +3036,7 @@ const buildParentAction = (cli, opts) => async (commandParts, thisCommand) => {
2814
3036
  const shellOpt = opts.shell ?? cfg.shell ?? mergedBag.shell;
2815
3037
  await execShellCommandBatch({
2816
3038
  command: resolveCommand(scriptsOpt, commandOpt),
3039
+ dotenvEnv,
2817
3040
  globs,
2818
3041
  ignoreErrors,
2819
3042
  list,
@@ -2857,7 +3080,8 @@ const BatchConfigSchema = z.object({
2857
3080
  /**
2858
3081
  * Batch plugin for the GetDotenv CLI host.
2859
3082
  *
2860
- * Mirrors the legacy batch subcommand behavior without altering the shipped CLI. * Options:
3083
+ * Mirrors the legacy batch subcommand behavior without altering the shipped CLI.
3084
+ * Options:
2861
3085
  * - scripts/shell: used to resolve command and shell behavior per script or global default.
2862
3086
  * - logger: defaults to console.
2863
3087
  */
@@ -2869,12 +3093,32 @@ const batchPlugin = (opts = {}) => definePlugin({
2869
3093
  setup(cli) {
2870
3094
  const ns = cli.ns('batch');
2871
3095
  const batchCmd = ns; // capture the parent "batch" command for default-subcommand context
3096
+ const host = cli;
3097
+ const pluginId = 'batch';
3098
+ const GROUP = `plugin:${pluginId}`;
2872
3099
  ns.description('Batch command execution across multiple working directories.')
2873
3100
  .enablePositionalOptions()
2874
3101
  .passThroughOptions()
2875
- .option('-p, --pkg-cwd', 'use nearest package directory as current working directory')
2876
- .option('-r, --root-path <string>', 'path to batch root directory from current working directory', './')
2877
- .option('-g, --globs <string>', 'space-delimited globs from root path', '*')
3102
+ // Dynamic help: show effective defaults from the merged/interpolated plugin config slice.
3103
+ .addOption((() => {
3104
+ const opt = host.createDynamicOption('-p, --pkg-cwd', (cfg) => {
3105
+ const slice = cfg.plugins.batch ?? {};
3106
+ const on = !!slice.pkgCwd;
3107
+ return `use nearest package directory as current working directory${on ? ' (default)' : ''}`;
3108
+ });
3109
+ opt.__group = GROUP;
3110
+ return opt;
3111
+ })())
3112
+ .addOption((() => {
3113
+ const opt = host.createDynamicOption('-r, --root-path <string>', (cfg) => `path to batch root directory from current working directory (default: ${JSON.stringify(cfg.plugins.batch?.rootPath || './')})`);
3114
+ opt.__group = GROUP;
3115
+ return opt;
3116
+ })())
3117
+ .addOption((() => {
3118
+ const opt = host.createDynamicOption('-g, --globs <string>', (cfg) => `space-delimited globs from root path (default: ${JSON.stringify(cfg.plugins.batch?.globs || '*')})`);
3119
+ opt.__group = GROUP;
3120
+ return opt;
3121
+ })())
2878
3122
  .option('-c, --command <string>', 'command executed according to the base shell resolution')
2879
3123
  .option('-l, --list', 'list working directories without executing command')
2880
3124
  .option('-e, --ignore-errors', 'ignore errors and continue with next path')
@@ -3184,10 +3428,10 @@ const cmdPlugin = (options = {}) => definePlugin({
3184
3428
  return name.replace(/-([a-z])/g, (_m, c) => c.toUpperCase());
3185
3429
  };
3186
3430
  const aliasKey = aliasSpec ? deriveKey(aliasSpec.flags) : undefined;
3187
- const cmd = new Command()
3188
- .name('cmd')
3431
+ // Create as a GetDotenvCli child so helpInformation includes a trailing blank line.
3432
+ const cmd = cli
3433
+ .createCommand('cmd')
3189
3434
  .description('Execute command according to the --shell option, conflicts with --command option (default subcommand)')
3190
- .configureHelp({ showGlobalOptions: true })
3191
3435
  .enablePositionalOptions()
3192
3436
  .passThroughOptions()
3193
3437
  .argument('[command...]')
@@ -3379,7 +3623,7 @@ const demoPlugin = () => definePlugin({
3379
3623
  const dotenv = (ctx?.dotenv ?? {});
3380
3624
  // Inherit stdio for an interactive demo. Use --capture for CI.
3381
3625
  await runCommand(['node', '-e', code], false, {
3382
- env: { ...process.env, ...dotenv },
3626
+ env: buildSpawnEnv(process.env, dotenv),
3383
3627
  stdio: 'inherit',
3384
3628
  });
3385
3629
  });
@@ -3416,20 +3660,23 @@ const demoPlugin = () => definePlugin({
3416
3660
  const ctx = cli.getCtx();
3417
3661
  const dotenv = (ctx?.dotenv ?? {});
3418
3662
  await runCommand(resolved, shell, {
3419
- env: { ...process.env, ...dotenv },
3663
+ env: buildSpawnEnv(process.env, dotenv),
3420
3664
  stdio: 'inherit',
3421
3665
  });
3422
3666
  });
3423
3667
  },
3424
3668
  /**
3425
3669
  * Optional: afterResolve can initialize per-plugin state using ctx.dotenv.
3426
- * For the demo we just log once to hint where such logic would live.
3670
+ * For the demo we emit a single breadcrumb only when GETDOTENV_DEBUG is set,
3671
+ * keeping default runs (tests/CI/smoke) quiet.
3427
3672
  */
3428
3673
  afterResolve(_cli, ctx) {
3429
- const keys = Object.keys(ctx.dotenv);
3430
- if (keys.length > 0) {
3431
- // Keep noise low; a single-line breadcrumb is sufficient for the demo.
3432
- console.error('[demo] afterResolve: dotenv keys loaded:', keys.length);
3674
+ if (process.env.GETDOTENV_DEBUG) {
3675
+ const keys = Object.keys(ctx.dotenv);
3676
+ if (keys.length > 0) {
3677
+ // Keep noise low; a single-line breadcrumb is sufficient for the demo.
3678
+ console.error('[demo] afterResolve: dotenv keys loaded:', keys.length);
3679
+ }
3433
3680
  }
3434
3681
  },
3435
3682
  });
@@ -3687,402 +3934,40 @@ const initPlugin = (opts = {}) => definePlugin({
3687
3934
  },
3688
3935
  });
3689
3936
 
3690
- const cmdCommand$1 = new Command()
3691
- .name('cmd')
3692
- .description('execute command, conflicts with --command option (default subcommand)')
3693
- .enablePositionalOptions()
3694
- .passThroughOptions()
3695
- .argument('[command...]')
3696
- .action(async (commandParts, _options, thisCommand) => {
3697
- if (!thisCommand.parent)
3698
- throw new Error(`unable to resolve parent command`);
3699
- if (!thisCommand.parent.parent)
3700
- throw new Error(`unable to resolve root command`);
3701
- const { getDotenvCliOptions: { logger = console, ...getDotenvCliOptions }, } = thisCommand.parent.parent;
3702
- const raw = thisCommand.parent.opts();
3703
- const ignoreErrors = !!raw.ignoreErrors;
3704
- const globs = typeof raw.globs === 'string' ? raw.globs : '*';
3705
- const list = !!raw.list;
3706
- const pkgCwd = !!raw.pkgCwd;
3707
- const rootPath = typeof raw.rootPath === 'string' ? raw.rootPath : './';
3708
- // Execute command.
3709
- const args = Array.isArray(commandParts) ? commandParts : [];
3710
- // When no positional tokens are provided (e.g., option form `-c/--command`),
3711
- // the preSubcommand hook handles execution. Avoid a duplicate call here.
3712
- if (args.length === 0)
3713
- return;
3714
- const command = args.map(String).join(' ');
3715
- await execShellCommandBatch({
3716
- command: resolveCommand(getDotenvCliOptions.scripts, command),
3717
- getDotenvCliOptions,
3718
- globs,
3719
- ignoreErrors,
3720
- list,
3721
- logger,
3722
- pkgCwd,
3723
- rootPath,
3724
- // execa expects string | boolean | URL for `shell`. We normalize earlier;
3725
- // scripts[name].shell overrides take precedence and may be boolean or string.
3726
- shell: resolveShell(getDotenvCliOptions.scripts, command, getDotenvCliOptions.shell),
3727
- });
3728
- });
3729
-
3730
- const batchCommand = new Command()
3731
- .name('batch')
3732
- .description('Batch command execution across multiple working directories.')
3733
- .enablePositionalOptions()
3734
- .passThroughOptions()
3735
- .option('-p, --pkg-cwd', 'use nearest package directory as current working directory')
3736
- .option('-r, --root-path <string>', 'path to batch root directory from current working directory', './')
3737
- .option('-g, --globs <string>', 'space-delimited globs from root path', '*')
3738
- .option('-c, --command <string>', 'command executed according to the base --shell option, conflicts with cmd subcommand (dotenv-expanded)', dotenvExpandFromProcessEnv)
3739
- .option('-l, --list', 'list working directories without executing command')
3740
- .option('-e, --ignore-errors', 'ignore errors and continue with next path')
3741
- .hook('preSubcommand', async (thisCommand) => {
3742
- if (!thisCommand.parent)
3743
- throw new Error(`unable to resolve root command`);
3744
- const { getDotenvCliOptions: { logger = console, ...getDotenvCliOptions }, } = thisCommand.parent;
3745
- const raw = thisCommand.opts();
3746
- const commandOpt = typeof raw.command === 'string' ? raw.command : undefined;
3747
- const ignoreErrors = !!raw.ignoreErrors;
3748
- const globs = typeof raw.globs === 'string' ? raw.globs : '*';
3749
- const list = !!raw.list;
3750
- const pkgCwd = !!raw.pkgCwd;
3751
- const rootPath = typeof raw.rootPath === 'string' ? raw.rootPath : './';
3752
- const argCount = thisCommand.args.length;
3753
- if (typeof commandOpt === 'string' && argCount > 0) {
3754
- logger.error(`--command option conflicts with cmd subcommand.`);
3755
- process.exit(0);
3756
- }
3757
- // Execute command.
3758
- if (typeof commandOpt === 'string')
3759
- await execShellCommandBatch({
3760
- command: resolveCommand(getDotenvCliOptions.scripts, commandOpt),
3761
- getDotenvCliOptions,
3762
- globs,
3763
- ignoreErrors,
3764
- list,
3765
- logger,
3766
- pkgCwd,
3767
- rootPath,
3768
- // execa expects string | boolean | URL for `shell`. We normalize earlier;
3769
- // scripts[name].shell overrides take precedence and may be boolean or string.
3770
- shell: resolveShell(getDotenvCliOptions.scripts, commandOpt, getDotenvCliOptions.shell),
3771
- });
3772
- })
3773
- .addCommand(cmdCommand$1, { isDefault: true });
3774
-
3775
- const cmdCommand = new Command()
3776
- .name('cmd')
3777
- .description('Execute command according to the --shell option, conflicts with --command option (default subcommand)')
3778
- .configureHelp({ showGlobalOptions: true })
3779
- .enablePositionalOptions()
3780
- .passThroughOptions()
3781
- .argument('[command...]')
3782
- .action(async (commandParts, _options, thisCommand) => {
3783
- const args = Array.isArray(commandParts) ? commandParts : [];
3784
- if (args.length === 0)
3785
- return;
3786
- if (!thisCommand.parent)
3787
- throw new Error('parent command not found');
3788
- const { getDotenvCliOptions: { logger = console, ...getDotenvCliOptions }, } = thisCommand.parent;
3789
- const command = args.map(String).join(' ');
3790
- const cmd = resolveCommand(getDotenvCliOptions.scripts, command);
3791
- if (getDotenvCliOptions.debug)
3792
- logger.log('\n*** command ***\n', `'${cmd}'`);
3793
- await execaCommand(cmd, {
3794
- env: {
3795
- ...process.env,
3796
- getDotenvCliOptions: JSON.stringify(getDotenvCliOptions),
3797
- },
3798
- // execa expects string | boolean | URL; we normalize in generator
3799
- // and allow script-level overrides.
3800
- shell: resolveShell(getDotenvCliOptions.scripts, command, getDotenvCliOptions.shell),
3801
- stdio: 'inherit',
3802
- });
3803
- });
3804
-
3805
- /**
3806
- * Create the root Commander command with legacy root options (via cliCore)
3807
- * and built-in subcommands. Pure builder: no side-effects; the caller attaches
3808
- * lifecycle hooks separately.
3809
- */
3810
- const createRootCommand = (opts) => {
3811
- const program = new Command().name(opts.alias).description(opts.description);
3812
- // Attach legacy root flags using shared cliCore builder to keep parity.
3813
- attachRootOptions(program, opts, {
3814
- includeCommandOption: true,
3815
- });
3816
- // Subcommands
3817
- program.addCommand(batchCommand).addCommand(cmdCommand, { isDefault: true });
3818
- return program;
3819
- };
3820
-
3821
- /**
3822
- * Resolve `GetDotenvCliGenerateOptions` from `import.meta.url` and custom options.
3823
- */
3824
- const resolveGetDotenvCliGenerateOptions = async ({ importMetaUrl, ...customOptions }) => {
3825
- const baseOptions = {
3826
- ...baseGetDotenvCliOptions,
3827
- alias: 'getdotenv',
3828
- description: 'Base CLI.',
3829
- };
3830
- const globalPkgDir = importMetaUrl
3831
- ? await packageDirectory({
3832
- cwd: fileURLToPath(importMetaUrl),
3833
- })
3834
- : undefined;
3835
- const globalOptionsPath = globalPkgDir
3836
- ? join(globalPkgDir, getDotenvOptionsFilename)
3837
- : undefined;
3838
- const globalOptions = (globalOptionsPath && (await fs.exists(globalOptionsPath))
3839
- ? JSON.parse((await fs.readFile(globalOptionsPath)).toString())
3840
- : {});
3841
- const localPkgDir = await packageDirectory();
3842
- const localOptionsPath = localPkgDir
3843
- ? join(localPkgDir, getDotenvOptionsFilename)
3844
- : undefined;
3845
- const localOptions = (localOptionsPath &&
3846
- localOptionsPath !== globalOptionsPath &&
3847
- (await fs.exists(localOptionsPath))
3848
- ? JSON.parse((await fs.readFile(localOptionsPath)).toString())
3849
- : {});
3850
- // Merge order: base < global < local < custom
3851
- const merged = defaultsDeep(baseOptions, globalOptions, localOptions, customOptions);
3852
- return merged;
3853
- };
3854
-
3855
- /**
3856
- * Resolve dotenv values using the config-loader/overlay path (always-on in
3857
- * host/generator flows; no-op when no config files are present).
3858
- *
3859
- * Order:
3860
- * 1) Compute base from files only (exclude dynamic; ignore programmatic vars).
3861
- * 2) Discover packaged + project config sources and overlay onto base.
3862
- * 3) Apply dynamics in order:
3863
- * programmatic dynamic \> config dynamic (packaged → project public → project local)
3864
- * \> file dynamicPath.
3865
- * 4) Phase C interpolation of remaining string options (e.g., outputPath).
3866
- * 5) Optionally write outputPath, log, and merge into process.env.
3867
- */
3868
- const resolveDotenvWithConfigLoader = async (validated) => {
3869
- // 1) Base from files, no dynamic, no programmatic vars
3870
- const base = await getDotenv({
3871
- ...validated,
3872
- // Build a pure base without side effects or logging.
3873
- excludeDynamic: true,
3874
- vars: {},
3875
- log: false,
3876
- loadProcess: false,
3877
- outputPath: undefined,
3878
- });
3879
- // 2) Discover config sources (packaged via this module's import.meta.url)
3880
- const sources = await resolveGetDotenvConfigSources(import.meta.url);
3881
- const dotenv = overlayEnv({
3882
- base,
3883
- env: validated.env ?? validated.defaultEnv,
3884
- configs: sources,
3885
- ...(validated.vars ? { programmaticVars: validated.vars } : {}),
3886
- });
3887
- // Helper to apply a dynamic map progressively.
3888
- const applyDynamic = (target, dynamic, env) => {
3889
- if (!dynamic)
3890
- return;
3891
- for (const key of Object.keys(dynamic)) {
3892
- const value = typeof dynamic[key] === 'function'
3893
- ? dynamic[key](target, env)
3894
- : dynamic[key];
3895
- Object.assign(target, { [key]: value });
3896
- }
3897
- };
3898
- // 3) Apply dynamics in order
3899
- applyDynamic(dotenv, validated.dynamic, validated.env ?? validated.defaultEnv);
3900
- applyDynamic(dotenv, (sources.packaged?.dynamic ?? undefined), validated.env ?? validated.defaultEnv);
3901
- applyDynamic(dotenv, (sources.project?.public?.dynamic ?? undefined), validated.env ?? validated.defaultEnv);
3902
- applyDynamic(dotenv, (sources.project?.local?.dynamic ?? undefined), validated.env ?? validated.defaultEnv);
3903
- // file dynamicPath (lowest)
3904
- if (validated.dynamicPath) {
3905
- const absDynamicPath = path.resolve(validated.dynamicPath);
3906
- try {
3907
- const dyn = await loadModuleDefault(absDynamicPath, 'getdotenv-dynamic-host');
3908
- applyDynamic(dotenv, dyn, validated.env ?? validated.defaultEnv);
3909
- }
3910
- catch {
3911
- throw new Error(`Unable to load dynamic from ${validated.dynamicPath}`);
3912
- }
3913
- }
3914
- // 4) Phase C: interpolate remaining string options (exclude bootstrap set).
3915
- // For now, interpolate outputPath only; bootstrap keys are excluded by design.
3916
- const envRef = { ...process.env, ...dotenv };
3917
- const outputPathInterpolated = typeof validated.outputPath === 'string'
3918
- ? interpolateDeep(validated.outputPath, envRef)
3919
- : undefined;
3920
- // 5) Output/log/process merge (use interpolated outputPath if present)
3921
- if (outputPathInterpolated) {
3922
- await fs.writeFile(outputPathInterpolated, Object.keys(dotenv).reduce((contents, key) => {
3923
- const value = dotenv[key] ?? '';
3924
- return `${contents}${key}=${value.includes('\n') ? `"${value}"` : value}\n`;
3925
- }, ''), { encoding: 'utf-8' });
3926
- }
3927
- const logger = validated.logger ?? console;
3928
- if (validated.log) {
3929
- const redactFlag = validated.redact ?? false;
3930
- const redactPatterns = validated.redactPatterns;
3931
- const redOpts = {};
3932
- if (redactFlag)
3933
- redOpts.redact = true;
3934
- if (redactFlag && Array.isArray(redactPatterns))
3935
- redOpts.redactPatterns = redactPatterns;
3936
- const bag = redactFlag ? redactObject(dotenv, redOpts) : { ...dotenv };
3937
- logger.log(bag);
3938
- // Entropy warnings: once per key per run (presentation only)
3939
- const warnEntropyVal = validated.warnEntropy ?? true;
3940
- const entropyThresholdVal = validated.entropyThreshold;
3941
- const entropyMinLengthVal = validated.entropyMinLength;
3942
- const entropyWhitelistVal = validated.entropyWhitelist;
3943
- const entOpts = {};
3944
- // include keys only when defined to satisfy exactOptionalPropertyTypes
3945
- if (typeof warnEntropyVal === 'boolean')
3946
- entOpts.warnEntropy = warnEntropyVal;
3947
- if (typeof entropyThresholdVal === 'number')
3948
- entOpts.entropyThreshold = entropyThresholdVal;
3949
- if (typeof entropyMinLengthVal === 'number')
3950
- entOpts.entropyMinLength = entropyMinLengthVal;
3951
- if (Array.isArray(entropyWhitelistVal))
3952
- entOpts.entropyWhitelist = entropyWhitelistVal;
3953
- for (const [k, v] of Object.entries(dotenv)) {
3954
- maybeWarnEntropy(k, v, v !== undefined ? 'dotenv' : 'unset', entOpts, (line) => {
3955
- logger.log(line);
3956
- });
3957
- }
3958
- }
3959
- if (validated.loadProcess)
3960
- Object.assign(process.env, dotenv);
3961
- return dotenv;
3962
- };
3963
-
3964
- /**
3965
- * Omit a "logger" key from an options object in a typed manner.
3966
- */
3967
- const omitLogger = (obj) => {
3968
- const { logger: _omitted, ...rest } = obj;
3969
- return rest;
3970
- };
3971
- /**
3972
- * Build the Commander preSubcommand hook using the provided context.
3973
- * * Responsibilities:
3974
- * - Merge parent CLI options with current invocation (parent \< current). * - Resolve tri-state flags, including `--exclude-all` overrides.
3975
- * - Normalize the shell setting to a concrete value (string | boolean).
3976
- * - Persist merged options on the command instance and pass to subcommands.
3977
- * - Execute {@link getDotenv} and optional post-hook.
3978
- * - Either forward to the default `cmd` subcommand or execute `--command`.
3979
- *
3980
- * @param context - See {@link PreSubHookContext}.
3981
- * @returns An async hook suitable for Commander’s `preSubcommand`.
3982
- *
3983
- * @example `program.hook('preSubcommand', makePreSubcommandHook(ctx));`
3984
- */
3985
- const makePreSubcommandHook = ({ logger, preHook, postHook, defaults, }) => {
3986
- return async (thisCommand) => {
3987
- // Get raw CLI options from commander.
3988
- const rawCliOptions = thisCommand.opts();
3989
- const { merged: mergedGetDotenvCliOptions, command: commandOpt } = resolveCliOptions(rawCliOptions, defaults, process.env.getDotenvCliOptions);
3990
- // Optional debug logging retained via mergedGetDotenvCliOptions.debug if desired. // Execute pre-hook.
3991
- if (preHook) {
3992
- await preHook(mergedGetDotenvCliOptions);
3993
- if (mergedGetDotenvCliOptions.debug)
3994
- logger.debug('\n*** GetDotenvCliOptions after pre-hook ***\n', mergedGetDotenvCliOptions);
3995
- }
3996
- // Persist GetDotenvCliOptions in command for subcommand access.
3997
- thisCommand.getDotenvCliOptions =
3998
- mergedGetDotenvCliOptions;
3999
- // Execute getdotenv via always-on config loader/overlay path.
4000
- const serviceOptions = getDotenvCliOptions2Options(mergedGetDotenvCliOptions);
4001
- const dotenv = await resolveDotenvWithConfigLoader(serviceOptions);
4002
- // Global validation against config (warn by default; --strict fails).
4003
- try {
4004
- const sources = await resolveGetDotenvConfigSources(import.meta.url);
4005
- const issues = validateEnvAgainstSources(dotenv, sources);
4006
- if (Array.isArray(issues) && issues.length > 0) {
4007
- issues.forEach((m) => {
4008
- logger.error(m);
4009
- });
4010
- if (mergedGetDotenvCliOptions.strict) {
4011
- process.exit(1);
4012
- }
4013
- }
4014
- }
4015
- catch {
4016
- // Tolerate validator failures in non-strict mode
4017
- }
4018
- // Execute post-hook.
4019
- if (postHook)
4020
- await postHook(dotenv); // Execute command.
4021
- const args = thisCommand.args ?? [];
4022
- const isCommand = typeof commandOpt === 'string' && commandOpt.length > 0;
4023
- if (isCommand && args.length > 0) {
4024
- const lr = logger;
4025
- (lr.error ?? lr.log)(`--command option conflicts with cmd subcommand.`);
4026
- process.exit(0);
4027
- }
4028
- if (typeof commandOpt === 'string' && commandOpt.length > 0) {
4029
- const cmd = resolveCommand(mergedGetDotenvCliOptions.scripts, commandOpt);
4030
- if (mergedGetDotenvCliOptions.debug)
4031
- logger.debug('\n*** command ***\n', cmd);
4032
- // Build a logger-free bag for env round-trip.
4033
- const envSafe = omitLogger(mergedGetDotenvCliOptions);
4034
- await execaCommand(cmd, {
4035
- env: { ...process.env, getDotenvCliOptions: JSON.stringify(envSafe) },
4036
- shell: resolveShell(mergedGetDotenvCliOptions.scripts, commandOpt, mergedGetDotenvCliOptions.shell),
4037
- stdio: 'inherit',
4038
- });
4039
- }
4040
- };
4041
- };
4042
-
4043
- /**
4044
- * Generate a Commander CLI Command for get-dotenv.
4045
- * Orchestration only: delegates building and lifecycle hooks.
4046
- */
4047
- const generateGetDotenvCli = async (customOptions) => {
4048
- const options = await resolveGetDotenvCliGenerateOptions(customOptions);
4049
- const program = createRootCommand(options);
4050
- const defaults = {};
4051
- if (options.debug !== undefined)
4052
- defaults.debug = options.debug;
4053
- if (options.excludeDynamic !== undefined)
4054
- defaults.excludeDynamic = options.excludeDynamic;
4055
- if (options.excludeEnv !== undefined)
4056
- defaults.excludeEnv = options.excludeEnv;
4057
- if (options.excludeGlobal !== undefined)
4058
- defaults.excludeGlobal = options.excludeGlobal;
4059
- if (options.excludePrivate !== undefined)
4060
- defaults.excludePrivate = options.excludePrivate;
4061
- if (options.excludePublic !== undefined)
4062
- defaults.excludePublic = options.excludePublic;
4063
- if (options.loadProcess !== undefined)
4064
- defaults.loadProcess = options.loadProcess;
4065
- if (options.log !== undefined)
4066
- defaults.log = options.log;
4067
- if (options.scripts !== undefined)
4068
- defaults.scripts = options.scripts;
4069
- if (options.shell !== undefined)
4070
- defaults.shell = options.shell;
4071
- const ctx = {
4072
- logger: options.logger,
4073
- defaults,
4074
- ...(options.preHook ? { preHook: options.preHook } : {}),
4075
- ...(options.postHook ? { postHook: options.postHook } : {}),
4076
- };
4077
- program.hook('preSubcommand', makePreSubcommandHook(ctx));
4078
- return program;
4079
- };
4080
-
4081
3937
  function createCli(opts = {}) {
4082
3938
  const alias = typeof opts.alias === 'string' && opts.alias.length > 0
4083
3939
  ? opts.alias
4084
3940
  : 'getdotenv';
4085
3941
  const program = new GetDotenvCli(alias);
3942
+ // Normalize Commander output so help prints always end with a blank line.
3943
+ // This keeps E2E assertions (CRLF and >=2 trailing newlines) portable across
3944
+ // runtimes and capture modes without altering Commander internals.
3945
+ const outputCfg = {
3946
+ writeOut(str) {
3947
+ const txt = typeof str === 'string' ? str : '';
3948
+ const hasTwo = /(?:\r?\n){2,}$/.test(txt);
3949
+ const hasOne = /\r?\n$/.test(txt);
3950
+ const out = hasTwo ? txt : hasOne ? txt + '\n' : txt + '\n\n';
3951
+ try {
3952
+ process.stdout.write(out);
3953
+ }
3954
+ catch {
3955
+ /* ignore */
3956
+ }
3957
+ },
3958
+ writeErr(str) {
3959
+ process.stderr.write(str);
3960
+ },
3961
+ };
3962
+ // Apply to root and recursively to subcommands so all help paths are normalized.
3963
+ program.configureOutput(outputCfg);
3964
+ const applyOutputRecursively = (cmd) => {
3965
+ cmd.configureOutput(outputCfg);
3966
+ const kids = cmd.commands ?? [];
3967
+ for (const child of kids)
3968
+ applyOutputRecursively(child);
3969
+ };
3970
+ applyOutputRecursively(program);
4086
3971
  // Install base root flags and included plugins; resolve context once per run.
4087
3972
  program
4088
3973
  .attachRootOptions({ loadProcess: false })
@@ -4098,19 +3983,72 @@ function createCli(opts = {}) {
4098
3983
  if (underTests) {
4099
3984
  program.exitOverride((err) => {
4100
3985
  const code = err?.code;
4101
- if (code === 'commander.helpDisplayed' || code === 'commander.version')
3986
+ // Commander printed help already; ensure a trailing blank line for tests/CI capture.
3987
+ if (code === 'commander.helpDisplayed') {
3988
+ try {
3989
+ process.stdout.write('\n');
3990
+ }
3991
+ catch {
3992
+ /* ignore */
3993
+ }
3994
+ return;
3995
+ }
3996
+ if (code === 'commander.version') {
4102
3997
  return;
3998
+ }
4103
3999
  throw err;
4104
4000
  });
4105
4001
  }
4106
4002
  return {
4107
4003
  async run(argv) {
4108
- // Always short-circuit help to avoid Commander-triggered process.exit
4109
- // across environments (CJS/ESM) and to return immediately under dynamic
4110
- // ESM without performing extra IO. Prints help and returns.
4111
- if (argv.some((a) => a === '-h' || a === '--help')) {
4112
- program.outputHelp();
4113
- return;
4004
+ // Help handling:
4005
+ // - Short-circuit ONLY for true top-level -h/--help (no subcommand before flag).
4006
+ // - If a subcommand token appears before -h/--help, defer to Commander
4007
+ // to render that subcommand's help.
4008
+ const helpIdx = argv.findIndex((a) => a === '-h' || a === '--help');
4009
+ if (helpIdx >= 0) {
4010
+ // Build a set of known subcommand names/aliases on the root.
4011
+ const subs = new Set();
4012
+ const cmds = program.commands ?? [];
4013
+ for (const c of cmds) {
4014
+ subs.add(c.name());
4015
+ for (const a of c.aliases())
4016
+ subs.add(a);
4017
+ }
4018
+ const hasSubBeforeHelp = argv
4019
+ .slice(0, helpIdx)
4020
+ .some((tok) => subs.has(tok));
4021
+ if (!hasSubBeforeHelp) {
4022
+ await program.brand({
4023
+ name: alias,
4024
+ importMetaUrl: import.meta.url,
4025
+ description: 'Base CLI.',
4026
+ ...(typeof opts.branding === 'string' && opts.branding.length > 0
4027
+ ? { helpHeader: opts.branding }
4028
+ : {}),
4029
+ });
4030
+ // Resolve context once without side effects for help rendering.
4031
+ const ctx = await program.resolveAndLoad({
4032
+ loadProcess: false,
4033
+ log: false,
4034
+ }, { runAfterResolve: false });
4035
+ program.evaluateDynamicOptions({
4036
+ ...ctx.optionsResolved,
4037
+ plugins: ctx.pluginConfigs ?? {},
4038
+ });
4039
+ // Suppress output only during unit tests; allow E2E to capture.
4040
+ const piping = process.env.GETDOTENV_STDIO === 'pipe' ||
4041
+ process.env.GETDOTENV_STDOUT === 'pipe';
4042
+ if (underTests && !piping) {
4043
+ void program.helpInformation();
4044
+ }
4045
+ else {
4046
+ program.outputHelp();
4047
+ }
4048
+ return;
4049
+ }
4050
+ // Subcommand token exists before -h: fall through to normal parsing,
4051
+ // letting Commander print that subcommand's help.
4114
4052
  }
4115
4053
  await program.brand({
4116
4054
  name: alias,
@@ -4125,4 +4063,4 @@ function createCli(opts = {}) {
4125
4063
  };
4126
4064
  }
4127
4065
 
4128
- export { buildSpawnEnv, createCli, defineDynamic, dotenvExpand, dotenvExpandAll, dotenvExpandFromProcessEnv, generateGetDotenvCli, getDotenv, getDotenvCliOptions2Options, interpolateDeep };
4066
+ export { buildSpawnEnv, createCli, defineDynamic, dotenvExpand, dotenvExpandAll, dotenvExpandFromProcessEnv, getDotenv, getDotenvCliOptions2Options, interpolateDeep };