@karmaniverous/get-dotenv 5.2.6 → 6.0.0-1

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.
Files changed (55) hide show
  1. package/README.md +106 -70
  2. package/dist/cliHost.d.ts +232 -226
  3. package/dist/cliHost.mjs +777 -545
  4. package/dist/config.d.ts +7 -2
  5. package/dist/env-overlay.d.ts +21 -9
  6. package/dist/env-overlay.mjs +14 -19
  7. package/dist/getdotenv.cli.mjs +1366 -1163
  8. package/dist/index.d.ts +415 -242
  9. package/dist/index.mjs +1364 -1414
  10. package/dist/plugins-aws.d.ts +149 -94
  11. package/dist/plugins-aws.mjs +307 -195
  12. package/dist/plugins-batch.d.ts +153 -99
  13. package/dist/plugins-batch.mjs +277 -95
  14. package/dist/plugins-cmd.d.ts +140 -94
  15. package/dist/plugins-cmd.mjs +636 -502
  16. package/dist/plugins-demo.d.ts +140 -94
  17. package/dist/plugins-demo.mjs +237 -46
  18. package/dist/plugins-init.d.ts +140 -94
  19. package/dist/plugins-init.mjs +129 -12
  20. package/dist/plugins.d.ts +166 -103
  21. package/dist/plugins.mjs +977 -840
  22. package/package.json +15 -53
  23. package/templates/cli/ts/plugins/hello.ts +27 -6
  24. package/templates/config/js/getdotenv.config.js +1 -1
  25. package/templates/config/ts/getdotenv.config.ts +9 -2
  26. package/dist/cliHost.cjs +0 -1875
  27. package/dist/cliHost.d.cts +0 -409
  28. package/dist/cliHost.d.mts +0 -409
  29. package/dist/config.cjs +0 -252
  30. package/dist/config.d.cts +0 -55
  31. package/dist/config.d.mts +0 -55
  32. package/dist/env-overlay.cjs +0 -163
  33. package/dist/env-overlay.d.cts +0 -50
  34. package/dist/env-overlay.d.mts +0 -50
  35. package/dist/index.cjs +0 -4140
  36. package/dist/index.d.cts +0 -457
  37. package/dist/index.d.mts +0 -457
  38. package/dist/plugins-aws.cjs +0 -667
  39. package/dist/plugins-aws.d.cts +0 -158
  40. package/dist/plugins-aws.d.mts +0 -158
  41. package/dist/plugins-batch.cjs +0 -616
  42. package/dist/plugins-batch.d.cts +0 -180
  43. package/dist/plugins-batch.d.mts +0 -180
  44. package/dist/plugins-cmd.cjs +0 -1113
  45. package/dist/plugins-cmd.d.cts +0 -178
  46. package/dist/plugins-cmd.d.mts +0 -178
  47. package/dist/plugins-demo.cjs +0 -307
  48. package/dist/plugins-demo.d.cts +0 -158
  49. package/dist/plugins-demo.d.mts +0 -158
  50. package/dist/plugins-init.cjs +0 -289
  51. package/dist/plugins-init.d.cts +0 -162
  52. package/dist/plugins-init.d.mts +0 -162
  53. package/dist/plugins.cjs +0 -2283
  54. package/dist/plugins.d.cts +0 -210
  55. package/dist/plugins.d.mts +0 -210
package/dist/index.mjs CHANGED
@@ -1,10 +1,11 @@
1
- import { Option, Command } from 'commander';
1
+ import { z } from 'zod';
2
+ export { z } from 'zod';
2
3
  import fs from 'fs-extra';
3
4
  import { packageDirectory } from 'package-directory';
4
5
  import path, { join, extname } from 'path';
5
6
  import url, { fileURLToPath, pathToFileURL } from 'url';
6
7
  import YAML from 'yaml';
7
- import { z } from 'zod';
8
+ import { Option, Command } from 'commander';
8
9
  import { nanoid } from 'nanoid';
9
10
  import { parse } from 'dotenv';
10
11
  import { createHash } from 'crypto';
@@ -14,256 +15,10 @@ import { stdin, stdout } from 'node:process';
14
15
  import { createInterface } from 'readline/promises';
15
16
 
16
17
  /**
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.
18
+ * Identity helper to define a scripts table while preserving a concrete TShell
19
+ * type parameter in downstream inference.
155
20
  */
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
- };
21
+ const defineScripts = () => (t) => t;
267
22
 
268
23
  // Base root CLI defaults (shared; kept untyped here to avoid cross-layer deps).
269
24
  const baseRootOptionDefaults = {
@@ -313,26 +68,12 @@ const mergeInto = (target, source) => {
313
68
  }
314
69
  return target;
315
70
  };
316
- /**
317
- * Perform a deep defaults-style merge across plain objects. *
318
- * - Only merges plain objects (prototype === Object.prototype).
319
- * - Arrays and non-objects are replaced, not merged.
320
- * - `undefined` values are ignored and do not overwrite prior values.
321
- *
322
- * @typeParam T - The resulting shape after merging all layers.
323
- * @param layers - Zero or more partial layers in ascending precedence order.
324
- * @returns The merged object typed as {@link T}.
325
- *
326
- * @example
327
- * defaultsDeep(\{ a: 1, nested: \{ b: 2 \} \}, \{ nested: \{ b: 3, c: 4 \} \})
328
- * =\> \{ a: 1, nested: \{ b: 3, c: 4 \} \}
329
- */
330
- const defaultsDeep = (...layers) => {
71
+ function defaultsDeep(...layers) {
331
72
  const result = layers
332
73
  .filter(Boolean)
333
74
  .reduce((acc, layer) => mergeInto(acc, layer), {});
334
75
  return result;
335
- };
76
+ }
336
77
 
337
78
  /**
338
79
  * Resolve a tri-state optional boolean flag under exactOptionalPropertyTypes.
@@ -754,113 +495,458 @@ const validateEnvAgainstSources = (finalEnv, sources) => {
754
495
 
755
496
  const baseGetDotenvCliOptions = baseRootOptionDefaults;
756
497
 
498
+ /** src/util/omitUndefined.ts
499
+ * Helpers to drop undefined-valued properties in a typed-friendly way.
500
+ */
501
+ /**
502
+ * Omit keys whose runtime value is undefined from a shallow object.
503
+ * Returns a Partial with non-undefined value types preserved.
504
+ */
505
+ function omitUndefined(obj) {
506
+ const out = {};
507
+ for (const [k, v] of Object.entries(obj)) {
508
+ if (v !== undefined)
509
+ out[k] = v;
510
+ }
511
+ return out;
512
+ }
513
+ /**
514
+ * Specialized helper for env-like maps: drop undefined and return string-only.
515
+ */
516
+ function omitUndefinedRecord(obj) {
517
+ const out = {};
518
+ for (const [k, v] of Object.entries(obj)) {
519
+ if (v !== undefined)
520
+ out[k] = v;
521
+ }
522
+ return out;
523
+ }
524
+
757
525
  // src/GetDotenvOptions.ts
758
- const getDotenvOptionsFilename = 'getdotenv.config.json';
759
526
  /**
760
- * Helper to define a dynamic map with strong inference.
527
+ * Canonical programmatic options and helpers for get-dotenv.
761
528
  *
762
- * @example
763
- * const dynamic = defineDynamic(\{ KEY: (\{ FOO = '' \}) =\> FOO + '-x' \});
529
+ * Requirements addressed:
530
+ * - GetDotenvOptions derives from the Zod schema output (single source of truth).
531
+ * - Removed deprecated/compat flags from the public shape (e.g., useConfigLoader).
532
+ * - Provide Vars-aware defineDynamic and a typed config builder defineGetDotenvConfig\<Vars, Env\>().
533
+ * - Preserve existing behavior for defaults resolution and compat converters.
764
534
  */
765
- const defineDynamic = (d) => d;
535
+ const getDotenvOptionsFilename = 'getdotenv.config.json';
536
+ // Implementation
537
+ function defineDynamic(d) {
538
+ return d;
539
+ }
540
+ function defineGetDotenvConfig(cfg) {
541
+ return cfg;
542
+ }
766
543
  /**
767
- * Converts programmatic CLI options to `getDotenv` options. *
768
- * @param cliOptions - CLI options. Defaults to `{}`.
544
+ * Converts programmatic CLI options to `getDotenv` options.
769
545
  *
770
- * @returns `getDotenv` options.
546
+ * Accepts "stringly" CLI inputs for vars/paths and normalizes them into
547
+ * the programmatic shape. Preserves exactOptionalPropertyTypes semantics by
548
+ * omitting keys when undefined.
771
549
  */
772
- const getDotenvCliOptions2Options = ({ paths, pathsDelimiter, pathsDelimiterPattern, vars, varsAssignor, varsAssignorPattern, varsDelimiter, varsDelimiterPattern, ...rest }) => {
773
- /**
774
- * Convert CLI-facing string options into {@link GetDotenvOptions}.
775
- *
776
- * - Splits {@link GetDotenvCliOptions.paths} using either a delimiter * or a regular expression pattern into a string array. * - Parses {@link GetDotenvCliOptions.vars} as space-separated `KEY=VALUE`
777
- * pairs (configurable delimiters) into a {@link ProcessEnv}.
778
- * - Drops CLI-only keys that have no programmatic equivalent.
779
- *
780
- * @remarks
781
- * Follows exact-optional semantics by not emitting undefined-valued entries.
782
- */
783
- // Drop CLI-only keys (debug/scripts) without relying on Record casts.
784
- // Create a shallow copy then delete optional CLI-only keys if present.
785
- const restObj = { ...rest };
786
- delete restObj.debug;
787
- delete restObj.scripts;
788
- const splitBy = (value, delim, pattern) => (value ? value.split(pattern ? RegExp(pattern) : (delim ?? ' ')) : []);
550
+ const getDotenvCliOptions2Options = ({ paths, pathsDelimiter, pathsDelimiterPattern, vars, varsAssignor, varsAssignorPattern, varsDelimiter, varsDelimiterPattern,
551
+ // drop CLI-only keys from the pass-through bag
552
+ debug: _debug, scripts: _scripts, ...rest }) => {
553
+ // Split helper for delimited strings or regex patterns
554
+ const splitBy = (value, delim, pattern) => {
555
+ if (!value)
556
+ return [];
557
+ if (pattern)
558
+ return value.split(RegExp(pattern));
559
+ if (typeof delim === 'string')
560
+ return value.split(delim);
561
+ return value.split(' ');
562
+ };
789
563
  // Tolerate vars as either a CLI string ("A=1 B=2") or an object map.
790
564
  let parsedVars;
791
565
  if (typeof vars === 'string') {
792
- const kvPairs = splitBy(vars, varsDelimiter, varsDelimiterPattern).map((v) => v.split(varsAssignorPattern
566
+ const kvPairs = splitBy(vars, varsDelimiter, varsDelimiterPattern)
567
+ .map((v) => v.split(varsAssignorPattern
793
568
  ? RegExp(varsAssignorPattern)
794
- : (varsAssignor ?? '=')));
569
+ : (varsAssignor ?? '=')))
570
+ .filter(([k]) => typeof k === 'string' && k.length > 0);
795
571
  parsedVars = Object.fromEntries(kvPairs);
796
572
  }
797
573
  else if (vars && typeof vars === 'object' && !Array.isArray(vars)) {
798
- // Keep only string or undefined values to match ProcessEnv.
799
- const entries = Object.entries(vars).filter(([k, v]) => typeof k === 'string' && (typeof v === 'string' || v === undefined));
800
- parsedVars = Object.fromEntries(entries);
574
+ // Accept provided object map of string | undefined; drop undefined values
575
+ // in the normalization step below to produce a ProcessEnv-compatible bag.
576
+ parsedVars = Object.fromEntries(Object.entries(vars));
801
577
  }
802
578
  // Drop undefined-valued entries at the converter stage to match ProcessEnv
803
579
  // expectations and the compat test assertions.
804
580
  if (parsedVars) {
805
- parsedVars = Object.fromEntries(Object.entries(parsedVars).filter(([, v]) => v !== undefined));
581
+ parsedVars = omitUndefinedRecord(parsedVars);
806
582
  }
807
583
  // Tolerate paths as either a delimited string or string[]
808
- // Use a locally cast union type to avoid lint warnings about always-falsy conditions
809
- // under the RootOptionsShape (which declares paths as string | undefined).
810
- const pathsAny = paths;
811
- const pathsOut = Array.isArray(pathsAny)
812
- ? pathsAny.filter((p) => typeof p === 'string')
813
- : splitBy(pathsAny, pathsDelimiter, pathsDelimiterPattern);
584
+ const pathsOut = Array.isArray(paths)
585
+ ? paths.filter((p) => typeof p === 'string')
586
+ : splitBy(paths, pathsDelimiter, pathsDelimiterPattern);
814
587
  // Preserve exactOptionalPropertyTypes: only include keys when defined.
815
588
  return {
816
- ...restObj,
589
+ ...rest,
817
590
  ...(pathsOut.length > 0 ? { paths: pathsOut } : {}),
818
591
  ...(parsedVars !== undefined ? { vars: parsedVars } : {}),
819
592
  };
820
593
  };
594
+ /**
595
+ * Resolve {@link GetDotenvOptions} by layering defaults in ascending precedence:
596
+ *
597
+ * 1. Base defaults derived from the CLI generator defaults
598
+ * ({@link baseGetDotenvCliOptions}).
599
+ * 2. Local project overrides from a `getdotenv.config.json` in the nearest
600
+ * package root (if present).
601
+ * 3. The provided customOptions.
602
+ *
603
+ * The result preserves explicit empty values and drops only `undefined`.
604
+ */
821
605
  const resolveGetDotenvOptions = async (customOptions) => {
822
- /**
823
- * Resolve {@link GetDotenvOptions} by layering defaults in ascending precedence:
824
- *
825
- * 1. Base defaults derived from the CLI generator defaults
826
- * ({@link baseGetDotenvCliOptions}).
827
- * 2. Local project overrides from a `getdotenv.config.json` in the nearest
828
- * package root (if present).
829
- * 3. The provided {@link customOptions}.
830
- *
831
- * The result preserves explicit empty values and drops only `undefined`.
832
- *
833
- * @returns Fully-resolved {@link GetDotenvOptions}.
834
- *
835
- * @example
836
- * ```ts
837
- * const options = await resolveGetDotenvOptions({ env: 'dev' });
838
- * ```
839
- */
840
606
  const localPkgDir = await packageDirectory();
841
607
  const localOptionsPath = localPkgDir
842
608
  ? join(localPkgDir, getDotenvOptionsFilename)
843
609
  : undefined;
844
- const localOptions = (localOptionsPath && (await fs.exists(localOptionsPath))
845
- ? JSON.parse((await fs.readFile(localOptionsPath)).toString())
846
- : {});
610
+ // Safely read local CLI-facing defaults (defensive typing to satisfy strict linting).
611
+ let localOptions = {};
612
+ if (localOptionsPath && (await fs.exists(localOptionsPath))) {
613
+ try {
614
+ const txt = await fs.readFile(localOptionsPath, 'utf-8');
615
+ const parsed = JSON.parse(txt);
616
+ if (parsed && typeof parsed === 'object') {
617
+ localOptions = parsed;
618
+ }
619
+ }
620
+ catch {
621
+ // Malformed or unreadable local options are treated as absent.
622
+ localOptions = {};
623
+ }
624
+ }
847
625
  // Merge order: base < local < custom (custom has highest precedence)
848
626
  const mergedCli = defaultsDeep(baseGetDotenvCliOptions, localOptions);
849
627
  const defaultsFromCli = getDotenvCliOptions2Options(mergedCli);
850
628
  const result = defaultsDeep(defaultsFromCli, customOptions);
851
629
  return {
852
630
  ...result, // Keep explicit empty strings/zeros; drop only undefined
853
- vars: Object.fromEntries(Object.entries(result.vars ?? {}).filter(([, v]) => v !== undefined)),
631
+ vars: omitUndefinedRecord(result.vars ?? {}),
632
+ };
633
+ };
634
+
635
+ /**
636
+ * Dotenv expansion utilities.
637
+ *
638
+ * This module implements recursive expansion of environment-variable
639
+ * references in strings and records. It supports both whitespace and
640
+ * bracket syntaxes with optional defaults:
641
+ *
642
+ * - Whitespace: `$VAR[:default]`
643
+ * - Bracketed: `${VAR[:default]}`
644
+ *
645
+ * Escaped dollar signs (`\$`) are preserved.
646
+ * Unknown variables resolve to empty string unless a default is provided.
647
+ */
648
+ /**
649
+ * Like String.prototype.search but returns the last index.
650
+ * @internal
651
+ */
652
+ const searchLast = (str, rgx) => {
653
+ const matches = Array.from(str.matchAll(rgx));
654
+ return matches.length > 0 ? (matches.slice(-1)[0]?.index ?? -1) : -1;
655
+ };
656
+ const replaceMatch = (value, match, ref) => {
657
+ /**
658
+ * @internal
659
+ */
660
+ const group = match[0];
661
+ const key = match[1];
662
+ const defaultValue = match[2];
663
+ if (!key)
664
+ return value;
665
+ const replacement = value.replace(group, ref[key] ?? defaultValue ?? '');
666
+ return interpolate(replacement, ref);
667
+ };
668
+ const interpolate = (value = '', ref = {}) => {
669
+ /**
670
+ * @internal
671
+ */
672
+ // if value is falsy, return it as is
673
+ if (!value)
674
+ return value;
675
+ // get position of last unescaped dollar sign
676
+ const lastUnescapedDollarSignIndex = searchLast(value, /(?!(?<=\\))\$/g);
677
+ // return value if none found
678
+ if (lastUnescapedDollarSignIndex === -1)
679
+ return value;
680
+ // evaluate the value tail
681
+ const tail = value.slice(lastUnescapedDollarSignIndex);
682
+ // find whitespace pattern: $KEY:DEFAULT
683
+ const whitespacePattern = /^\$([\w]+)(?::([^\s]*))?/;
684
+ const whitespaceMatch = whitespacePattern.exec(tail);
685
+ if (whitespaceMatch != null)
686
+ return replaceMatch(value, whitespaceMatch, ref);
687
+ else {
688
+ // find bracket pattern: ${KEY:DEFAULT}
689
+ const bracketPattern = /^\${([\w]+)(?::([^}]*))?}/;
690
+ const bracketMatch = bracketPattern.exec(tail);
691
+ if (bracketMatch != null)
692
+ return replaceMatch(value, bracketMatch, ref);
693
+ }
694
+ return value;
695
+ };
696
+ /**
697
+ * Recursively expands environment variables in a string. Variables may be
698
+ * presented with optional default as `$VAR[:default]` or `${VAR[:default]}`.
699
+ * Unknown variables will expand to an empty string.
700
+ *
701
+ * @param value - The string to expand.
702
+ * @param ref - The reference object to use for variable expansion.
703
+ * @returns The expanded string.
704
+ *
705
+ * @example
706
+ * ```ts
707
+ * process.env.FOO = 'bar';
708
+ * dotenvExpand('Hello $FOO'); // "Hello bar"
709
+ * dotenvExpand('Hello $BAZ:world'); // "Hello world"
710
+ * ```
711
+ *
712
+ * @remarks
713
+ * The expansion is recursive. If a referenced variable itself contains
714
+ * references, those will also be expanded until a stable value is reached.
715
+ * Escaped references (e.g. `\$FOO`) are preserved as literals.
716
+ */
717
+ const dotenvExpand = (value, ref = process.env) => {
718
+ const result = interpolate(value, ref);
719
+ return result ? result.replace(/\\\$/g, '$') : undefined;
720
+ };
721
+ /**
722
+ * Recursively expands environment variables in the values of a JSON object.
723
+ * Variables may be presented with optional default as `$VAR[:default]` or
724
+ * `${VAR[:default]}`. Unknown variables will expand to an empty string.
725
+ *
726
+ * @param values - The values object to expand.
727
+ * @param options - Expansion options.
728
+ * @returns The value object with expanded string values.
729
+ *
730
+ * @example
731
+ * ```ts
732
+ * process.env.FOO = 'bar';
733
+ * dotenvExpandAll({ A: '$FOO', B: 'x${FOO}y' });
734
+ * // => { A: "bar", B: "xbary" }
735
+ * ```
736
+ *
737
+ * @remarks
738
+ * Options:
739
+ * - ref: The reference object to use for expansion (defaults to process.env).
740
+ * - progressive: Whether to progressively add expanded values to the set of
741
+ * reference keys.
742
+ *
743
+ * When `progressive` is true, each expanded key becomes available for
744
+ * subsequent expansions in the same object (left-to-right by object key order).
745
+ */
746
+ function dotenvExpandAll(values, options = {}) {
747
+ const { ref = process.env, progressive = false, } = options;
748
+ const out = Object.keys(values).reduce((acc, key) => {
749
+ acc[key] = dotenvExpand(values[key], {
750
+ ...ref,
751
+ ...(progressive ? acc : {}),
752
+ });
753
+ return acc;
754
+ }, {});
755
+ // Key-preserving return with a permissive index signature to allow later additions.
756
+ return out;
757
+ }
758
+ /**
759
+ * Recursively expands environment variables in a string using `process.env` as
760
+ * the expansion reference. Variables may be presented with optional default as
761
+ * `$VAR[:default]` or `${VAR[:default]}`. Unknown variables will expand to an
762
+ * empty string.
763
+ *
764
+ * @param value - The string to expand.
765
+ * @returns The expanded string.
766
+ *
767
+ * @example
768
+ * ```ts
769
+ * process.env.FOO = 'bar';
770
+ * dotenvExpandFromProcessEnv('Hello $FOO'); // "Hello bar"
771
+ * ```
772
+ */
773
+ const dotenvExpandFromProcessEnv = (value) => dotenvExpand(value, process.env);
774
+
775
+ /* eslint-disable @typescript-eslint/no-deprecated */
776
+ /**
777
+ * Attach root flags to a GetDotenvCli instance.
778
+ * - Host-only: program is typed as GetDotenvCli and supports dynamicOption/createDynamicOption.
779
+ * - Any flag that displays an effective default in help uses dynamic descriptions.
780
+ */
781
+ const attachRootOptions = (program, defaults) => {
782
+ // Install temporary wrappers to tag all options added here as "base" for grouped help.
783
+ const GROUP = 'base';
784
+ const tagLatest = (cmd, group) => {
785
+ const optsArr = cmd.options ?? [];
786
+ if (Array.isArray(optsArr) && optsArr.length > 0) {
787
+ const last = optsArr[optsArr.length - 1];
788
+ program.setOptionGroup(last, group);
789
+ }
790
+ };
791
+ const originalAddOption = program.addOption.bind(program);
792
+ const originalOption = program.option.bind(program);
793
+ program.addOption = function patchedAdd(opt) {
794
+ program.setOptionGroup(opt, GROUP);
795
+ return originalAddOption(opt);
854
796
  };
797
+ program.option = function patchedOption(...args) {
798
+ const ret = originalOption(...args);
799
+ tagLatest(this, GROUP);
800
+ return ret;
801
+ };
802
+ const { defaultEnv, dotenvToken, dynamicPath, env, outputPath, paths, pathsDelimiter, pathsDelimiterPattern, privateToken, scripts, varsAssignor, varsAssignorPattern, varsDelimiter, varsDelimiterPattern, } = defaults ?? {};
803
+ const va = typeof defaults?.varsAssignor === 'string' ? defaults.varsAssignor : '=';
804
+ const vd = typeof defaults?.varsDelimiter === 'string' ? defaults.varsDelimiter : ' ';
805
+ // Helper: append (default) tags for ON/OFF toggles
806
+ const onOff = (on, isDefault) => on
807
+ ? `ON${isDefault ? ' (default)' : ''}`
808
+ : `OFF${isDefault ? ' (default)' : ''}`;
809
+ let p = program
810
+ .enablePositionalOptions()
811
+ .passThroughOptions()
812
+ .option('-e, --env <string>', `target environment (dotenv-expanded)`, dotenvExpandFromProcessEnv, env);
813
+ p = p.option('-v, --vars <string>', `extra variables expressed as delimited key-value pairs (dotenv-expanded): ${[
814
+ ['KEY1', 'VAL1'],
815
+ ['KEY2', 'VAL2'],
816
+ ]
817
+ .map((v) => v.join(va))
818
+ .join(vd)}`, dotenvExpandFromProcessEnv);
819
+ // Output path (interpolated later; help can remain static)
820
+ p = p.option('-o, --output-path <string>', 'consolidated output file (dotenv-expanded)', dotenvExpandFromProcessEnv, outputPath);
821
+ // Shell ON (string or boolean true => default shell)
822
+ p = p
823
+ .addOption(program
824
+ .createDynamicOption('-s, --shell [string]', (cfg) => {
825
+ const s = cfg.shell;
826
+ let tag = '';
827
+ if (typeof s === 'boolean' && s)
828
+ tag = ' (default OS shell)';
829
+ else if (typeof s === 'string' && s.length > 0)
830
+ tag = ` (default ${s})`;
831
+ return `command execution shell, no argument for default OS shell or provide shell string${tag}`;
832
+ })
833
+ .conflicts('shellOff'))
834
+ // Shell OFF
835
+ .addOption(program
836
+ .createDynamicOption('-S, --shell-off', (cfg) => {
837
+ const s = cfg.shell;
838
+ return `command execution shell OFF${s === false ? ' (default)' : ''}`;
839
+ })
840
+ .conflicts('shell'));
841
+ // Load process ON/OFF (dynamic defaults)
842
+ p = p
843
+ .addOption(program
844
+ .createDynamicOption('-p, --load-process', (cfg) => `load variables to process.env ${onOff(true, Boolean(cfg.loadProcess))}`)
845
+ .conflicts('loadProcessOff'))
846
+ .addOption(program
847
+ .createDynamicOption('-P, --load-process-off', (cfg) => `load variables to process.env ${onOff(false, !cfg.loadProcess)}`)
848
+ .conflicts('loadProcess'));
849
+ // Exclusion master toggle (dynamic)
850
+ p = p
851
+ .addOption(program
852
+ .createDynamicOption('-a, --exclude-all', (cfg) => {
853
+ const allOn = !!cfg.excludeDynamic &&
854
+ ((!!cfg.excludeEnv && !!cfg.excludeGlobal) ||
855
+ (!!cfg.excludePrivate && !!cfg.excludePublic));
856
+ const suffix = allOn ? ' (default)' : '';
857
+ return `exclude all dotenv variables from loading ON${suffix}`;
858
+ })
859
+ .conflicts('excludeAllOff'))
860
+ .addOption(new Option('-A, --exclude-all-off', `exclude all dotenv variables from loading OFF (default)`).conflicts('excludeAll'));
861
+ // Per-family exclusions (dynamic defaults)
862
+ p = p
863
+ .addOption(program
864
+ .createDynamicOption('-z, --exclude-dynamic', (cfg) => `exclude dynamic dotenv variables from loading ${onOff(true, Boolean(cfg.excludeDynamic))}`)
865
+ .conflicts('excludeDynamicOff'))
866
+ .addOption(program
867
+ .createDynamicOption('-Z, --exclude-dynamic-off', (cfg) => `exclude dynamic dotenv variables from loading ${onOff(false, !cfg.excludeDynamic)}`)
868
+ .conflicts('excludeDynamic'))
869
+ .addOption(program
870
+ .createDynamicOption('-n, --exclude-env', (cfg) => `exclude environment-specific dotenv variables from loading ${onOff(true, Boolean(cfg.excludeEnv))}`)
871
+ .conflicts('excludeEnvOff'))
872
+ .addOption(program
873
+ .createDynamicOption('-N, --exclude-env-off', (cfg) => `exclude environment-specific dotenv variables from loading ${onOff(false, !cfg.excludeEnv)}`)
874
+ .conflicts('excludeEnv'))
875
+ .addOption(program
876
+ .createDynamicOption('-g, --exclude-global', (cfg) => `exclude global dotenv variables from loading ${onOff(true, Boolean(cfg.excludeGlobal))}`)
877
+ .conflicts('excludeGlobalOff'))
878
+ .addOption(program
879
+ .createDynamicOption('-G, --exclude-global-off', (cfg) => `exclude global dotenv variables from loading ${onOff(false, !cfg.excludeGlobal)}`)
880
+ .conflicts('excludeGlobal'))
881
+ .addOption(program
882
+ .createDynamicOption('-r, --exclude-private', (cfg) => `exclude private dotenv variables from loading ${onOff(true, Boolean(cfg.excludePrivate))}`)
883
+ .conflicts('excludePrivateOff'))
884
+ .addOption(program
885
+ .createDynamicOption('-R, --exclude-private-off', (cfg) => `exclude private dotenv variables from loading ${onOff(false, !cfg.excludePrivate)}`)
886
+ .conflicts('excludePrivate'))
887
+ .addOption(program
888
+ .createDynamicOption('-u, --exclude-public', (cfg) => `exclude public dotenv variables from loading ${onOff(true, Boolean(cfg.excludePublic))}`)
889
+ .conflicts('excludePublicOff'))
890
+ .addOption(program
891
+ .createDynamicOption('-U, --exclude-public-off', (cfg) => `exclude public dotenv variables from loading ${onOff(false, !cfg.excludePublic)}`)
892
+ .conflicts('excludePublic'));
893
+ // Log ON/OFF (dynamic)
894
+ p = p
895
+ .addOption(program
896
+ .createDynamicOption('-l, --log', (cfg) => `console log loaded variables ${onOff(true, Boolean(cfg.log))}`)
897
+ .conflicts('logOff'))
898
+ .addOption(program
899
+ .createDynamicOption('-L, --log-off', (cfg) => `console log loaded variables ${onOff(false, !cfg.log)}`)
900
+ .conflicts('log'));
901
+ // Capture flag (no default display; static)
902
+ p = p.option('--capture', 'capture child process stdio for commands (tests/CI)');
903
+ // Core bootstrap/static flags (kept static in help)
904
+ p = p
905
+ .option('--default-env <string>', 'default target environment', dotenvExpandFromProcessEnv, defaultEnv)
906
+ .option('--dotenv-token <string>', 'dotenv-expanded token indicating a dotenv file', dotenvExpandFromProcessEnv, dotenvToken)
907
+ .option('--dynamic-path <string>', 'dynamic variables path (.js or .ts; .ts is auto-compiled when esbuild is available, otherwise precompile)', dotenvExpandFromProcessEnv, dynamicPath)
908
+ .option('--paths <string>', 'dotenv-expanded delimited list of paths to dotenv directory', dotenvExpandFromProcessEnv, paths)
909
+ .option('--paths-delimiter <string>', 'paths delimiter string', pathsDelimiter)
910
+ .option('--paths-delimiter-pattern <string>', 'paths delimiter regex pattern', pathsDelimiterPattern)
911
+ .option('--private-token <string>', 'dotenv-expanded token indicating private variables', dotenvExpandFromProcessEnv, privateToken)
912
+ .option('--vars-delimiter <string>', 'vars delimiter string', varsDelimiter)
913
+ .option('--vars-delimiter-pattern <string>', 'vars delimiter regex pattern', varsDelimiterPattern)
914
+ .option('--vars-assignor <string>', 'vars assignment operator string', varsAssignor)
915
+ .option('--vars-assignor-pattern <string>', 'vars assignment operator regex pattern', varsAssignorPattern)
916
+ // Hidden scripts pipe-through (stringified)
917
+ .addOption(new Option('--scripts <string>')
918
+ .default(JSON.stringify(scripts))
919
+ .hideHelp());
920
+ // Diagnostics / validation / entropy
921
+ p = p
922
+ .option('--trace [keys...]', 'emit diagnostics for child env composition (optional keys)')
923
+ .option('--strict', 'fail on env validation errors (schema/requiredKeys)');
924
+ p = p
925
+ .addOption(program
926
+ .createDynamicOption('--entropy-warn', (cfg) => {
927
+ const warn = cfg.warnEntropy;
928
+ // Default is effectively ON when warnEntropy is true or undefined.
929
+ return `enable entropy warnings${warn === false ? '' : ' (default on)'}`;
930
+ })
931
+ .conflicts('entropyWarnOff'))
932
+ .addOption(program
933
+ .createDynamicOption('--entropy-warn-off', (cfg) => `disable entropy warnings${cfg.warnEntropy === false ? ' (default)' : ''}`)
934
+ .conflicts('entropyWarn'))
935
+ .option('--entropy-threshold <number>', 'entropy bits/char threshold (default 3.8)')
936
+ .option('--entropy-min-length <number>', 'min length to examine for entropy (default 16)')
937
+ .option('--entropy-whitelist <pattern...>', 'suppress entropy warnings when key matches any regex pattern')
938
+ .option('--redact-pattern <pattern...>', 'additional key-match regex patterns to trigger redaction');
939
+ // Restore original methods
940
+ program.addOption = originalAddOption;
941
+ program.option = originalOption;
942
+ return p;
855
943
  };
856
944
 
857
945
  /**
858
946
  * Zod schemas for programmatic GetDotenv options.
859
947
  *
860
- * NOTE: These schemas are introduced without wiring to avoid behavior changes.
861
- * Legacy paths continue to use existing types/logic. The new plugin host will
862
- * use these schemas in strict mode; legacy paths will adopt them in warn mode
863
- * later per the staged plan.
948
+ * Canonical source of truth for options shape. Public types are derived
949
+ * from these schemas (see consumers via z.output\<\>).
864
950
  */
865
951
  // Minimal process env representation: string values or undefined to indicate "unset".
866
952
  const processEnvSchema = z.record(z.string(), z.string().optional());
@@ -879,12 +965,11 @@ const getDotenvOptionsSchemaRaw = z.object({
879
965
  excludePublic: z.boolean().optional(),
880
966
  loadProcess: z.boolean().optional(),
881
967
  log: z.boolean().optional(),
968
+ logger: z.unknown().optional(),
882
969
  outputPath: z.string().optional(),
883
970
  paths: z.array(z.string()).optional(),
884
971
  privateToken: z.string().optional(),
885
972
  vars: processEnvSchema.optional(),
886
- // Host-only feature flag: guarded integration of config loader/overlay
887
- useConfigLoader: z.boolean().optional(),
888
973
  });
889
974
  // RESOLVED: service-boundary contract (post-inheritance).
890
975
  // For Step A, keep identical to RAW (no behavior change). Later stages will// materialize required defaults and narrow shapes as resolution is wired.
@@ -904,16 +989,7 @@ const applyConfigSlice = (current, cfg, env) => {
904
989
  const envKv = env && cfg.envVars ? cfg.envVars[env] : undefined;
905
990
  return applyKv(afterGlobal, envKv);
906
991
  };
907
- /**
908
- * Overlay config-provided values onto a base ProcessEnv using precedence axes:
909
- * - kind: env \> global
910
- * - privacy: local \> public
911
- * - source: project \> packaged \> base
912
- *
913
- * Programmatic explicit vars (if provided) override all config slices.
914
- * Progressive expansion is applied within each slice.
915
- */
916
- const overlayEnv = ({ base, env, configs, programmaticVars, }) => {
992
+ function overlayEnv({ base, env, configs, programmaticVars, }) {
917
993
  let current = { ...base };
918
994
  // Source: packaged (public -> local)
919
995
  current = applyConfigSlice(current, configs.packaged, env);
@@ -928,7 +1004,7 @@ const overlayEnv = ({ base, env, configs, programmaticVars, }) => {
928
1004
  current = applyKv(current, toApply);
929
1005
  }
930
1006
  return current;
931
- };
1007
+ }
932
1008
 
933
1009
  /** src/diagnostics/entropy.ts
934
1010
  * Entropy diagnostics (presentation-only).
@@ -938,7 +1014,7 @@ const overlayEnv = ({ base, env, configs, programmaticVars, }) => {
938
1014
  */
939
1015
  const warned = new Set();
940
1016
  const isPrintableAscii = (s) => /^[\x20-\x7E]+$/.test(s);
941
- const compile$1 = (patterns) => (patterns ?? []).map((p) => new RegExp(p, 'i'));
1017
+ const compile$1 = (patterns) => (patterns ?? []).map((p) => (typeof p === 'string' ? new RegExp(p, 'i') : p));
942
1018
  const whitelisted = (key, regs) => regs.some((re) => re.test(key));
943
1019
  const shannonBitsPerChar = (s) => {
944
1020
  const freq = new Map();
@@ -985,7 +1061,7 @@ const DEFAULT_PATTERNS = [
985
1061
  '\\bapi[_-]?key\\b',
986
1062
  '\\bkey\\b',
987
1063
  ];
988
- const compile = (patterns) => (patterns && patterns.length > 0 ? patterns : DEFAULT_PATTERNS).map((p) => new RegExp(p, 'i'));
1064
+ const compile = (patterns) => (patterns && patterns.length > 0 ? patterns : DEFAULT_PATTERNS).map((p) => typeof p === 'string' ? new RegExp(p, 'i') : p);
989
1065
  const shouldRedactKey = (key, regs) => regs.some((re) => re.test(key));
990
1066
  const MASK = '[redacted]';
991
1067
  /**
@@ -1149,43 +1225,7 @@ const loadModuleDefault = async (absPath, cacheDirName) => {
1149
1225
  }
1150
1226
  };
1151
1227
 
1152
- /**
1153
- * Asynchronously process dotenv files of the form `.env[.<ENV>][.<PRIVATE_TOKEN>]`
1154
- *
1155
- * @param options - `GetDotenvOptions` object
1156
- * @returns The combined parsed dotenv object.
1157
- * * @example Load from the project root with default tokens
1158
- * ```ts
1159
- * const vars = await getDotenv();
1160
- * console.log(vars.MY_SETTING);
1161
- * ```
1162
- *
1163
- * @example Load from multiple paths and a specific environment
1164
- * ```ts
1165
- * const vars = await getDotenv({
1166
- * env: 'dev',
1167
- * dotenvToken: '.testenv',
1168
- * privateToken: 'secret',
1169
- * paths: ['./', './packages/app'],
1170
- * });
1171
- * ```
1172
- *
1173
- * @example Use dynamic variables
1174
- * ```ts
1175
- * // .env.js default-exports: { DYNAMIC: ({ PREV }) => `${PREV}-suffix` }
1176
- * const vars = await getDotenv({ dynamicPath: '.env.js' });
1177
- * ```
1178
- *
1179
- * @remarks
1180
- * - When {@link GetDotenvOptions.loadProcess} is true, the resulting variables are merged
1181
- * into `process.env` as a side effect.
1182
- * - When {@link GetDotenvOptions.outputPath} is provided, a consolidated dotenv file is written.
1183
- * The path is resolved after expansion, so it may reference previously loaded vars.
1184
- *
1185
- * @throws Error when a dynamic module is present but cannot be imported.
1186
- * @throws Error when an output path was requested but could not be resolved.
1187
- */
1188
- const getDotenv = async (options = {}) => {
1228
+ async function getDotenv(options = {}) {
1189
1229
  // Apply defaults.
1190
1230
  const { defaultEnv, dotenvToken = '.env', dynamicPath, env, excludeDynamic = false, excludeEnv = false, excludeGlobal = false, excludePrivate = false, excludePublic = false, loadProcess = false, log = false, logger = console, outputPath, paths = [], privateToken = 'local', vars = {}, } = await resolveGetDotenvOptions(options);
1191
1231
  // Read .env files.
@@ -1290,8 +1330,7 @@ const getDotenv = async (options = {}) => {
1290
1330
  .entropyThreshold;
1291
1331
  const entropyMinLengthVal = options
1292
1332
  .entropyMinLength;
1293
- const entropyWhitelistVal = options
1294
- .entropyWhitelist;
1333
+ const entropyWhitelistVal = options.entropyWhitelist;
1295
1334
  const entOpts = {};
1296
1335
  if (typeof warnEntropyVal === 'boolean')
1297
1336
  entOpts.warnEntropy = warnEntropyVal;
@@ -1311,7 +1350,7 @@ const getDotenv = async (options = {}) => {
1311
1350
  if (loadProcess)
1312
1351
  Object.assign(process.env, resultDotenv);
1313
1352
  return resultDotenv;
1314
- };
1353
+ }
1315
1354
 
1316
1355
  /**
1317
1356
  * Deep interpolation utility for string leaves.
@@ -1369,6 +1408,18 @@ const interpolateDeep = (value, envRef) => {
1369
1408
  return value;
1370
1409
  };
1371
1410
 
1411
+ /**
1412
+ * Instance-bound plugin config store.
1413
+ * Host stores the validated/interpolated slice per plugin instance.
1414
+ * The store is intentionally private to this module; definePlugin()
1415
+ * provides a typed accessor that reads from this store for the calling
1416
+ * plugin instance.
1417
+ */
1418
+ const PLUGIN_CONFIG_STORE = new WeakMap();
1419
+ const _setPluginConfigForInstance = (plugin, cfg) => {
1420
+ PLUGIN_CONFIG_STORE.set(plugin, cfg);
1421
+ };
1422
+ const _getPluginConfigForInstance = (plugin) => PLUGIN_CONFIG_STORE.get(plugin);
1372
1423
  /**
1373
1424
  * Compute the dotenv context for the host (uses the config loader/overlay path).
1374
1425
  * - Resolves and validates options strictly (host-only).
@@ -1377,20 +1428,26 @@ const interpolateDeep = (value, envRef) => {
1377
1428
  *
1378
1429
  * @param customOptions - Partial options from the current invocation.
1379
1430
  * @param plugins - Installed plugins (for config validation).
1380
- * @param hostMetaUrl - import.meta.url of the host module (for packaged root discovery). */
1431
+ * @param hostMetaUrl - import.meta.url of the host module (for packaged root discovery).
1432
+ */
1381
1433
  const computeContext = async (customOptions, plugins, hostMetaUrl) => {
1382
1434
  const optionsResolved = await resolveGetDotenvOptions(customOptions);
1435
+ // Zod boundary: parse returns the schema-derived shape; we adopt our public
1436
+ // GetDotenvOptions overlay (logger/dynamic typing) for internal processing.
1383
1437
  const validated = getDotenvOptionsSchemaResolved.parse(optionsResolved);
1384
1438
  // Always-on loader path
1385
1439
  // 1) Base from files only (no dynamic, no programmatic vars)
1440
+ // Sanitize to avoid passing properties explicitly set to undefined.
1441
+ const cleanedValidated = omitUndefined(validated);
1386
1442
  const base = await getDotenv({
1387
- ...validated,
1443
+ ...cleanedValidated,
1388
1444
  // Build a pure base without side effects or logging.
1389
1445
  excludeDynamic: true,
1390
1446
  vars: {},
1391
1447
  log: false,
1392
1448
  loadProcess: false,
1393
- outputPath: undefined,
1449
+ // Intentionally omit outputPath for the base pass; including a key with
1450
+ // undefined would violate exactOptionalPropertyTypes on the Partial target.
1394
1451
  });
1395
1452
  // 2) Discover config sources and overlay
1396
1453
  const sources = await resolveGetDotenvConfigSources(hostMetaUrl);
@@ -1435,7 +1492,7 @@ const computeContext = async (customOptions, plugins, hostMetaUrl) => {
1435
1492
  return `${contents}${key}=${value.includes('\n') ? `"${value}"` : value}\n`;
1436
1493
  }, ''), { encoding: 'utf-8' });
1437
1494
  }
1438
- const logger = validated.logger ?? console;
1495
+ const logger = customOptions.logger ?? console;
1439
1496
  if (validated.log)
1440
1497
  logger.log(dotenv);
1441
1498
  if (validated.loadProcess)
@@ -1450,43 +1507,148 @@ const computeContext = async (customOptions, plugins, hostMetaUrl) => {
1450
1507
  const localPlugins = (sources.project?.local &&
1451
1508
  sources.project.local.plugins) ??
1452
1509
  {};
1453
- const mergedPluginConfigs = defaultsDeep({}, packagedPlugins, publicPlugins, localPlugins);
1510
+ // The by-id map is retained only for backwards-compat rendering paths
1511
+ // (root help dynamic evaluation). Instance-bound access is the source
1512
+ // of truth going forward and is populated below.
1513
+ const mergedPluginConfigsById = defaultsDeep({}, packagedPlugins, publicPlugins, localPlugins);
1454
1514
  for (const p of plugins) {
1455
1515
  if (!p.id)
1456
1516
  continue;
1457
- const slice = mergedPluginConfigs[p.id];
1458
- if (slice === undefined)
1459
- continue;
1460
- // Per-plugin interpolation just before validation/afterResolve:
1461
- // precedence: process.env wins over ctx.dotenv for slice defaults.
1517
+ const slice = mergedPluginConfigsById[p.id];
1518
+ // Build interpolation reference once per plugin:
1462
1519
  const envRef = {
1463
1520
  ...dotenv,
1464
1521
  ...process.env,
1465
1522
  };
1466
- const interpolated = interpolateDeep(slice, envRef);
1467
- // Validate if a schema is provided; otherwise accept interpolated slice as-is.
1468
- if (p.configSchema) {
1469
- const parsed = p.configSchema.safeParse(interpolated);
1470
- if (!parsed.success) {
1471
- const msgs = parsed.error.issues
1472
- .map((i) => `${i.path.join('.')}: ${i.message}`)
1473
- .join('\n');
1474
- throw new Error(`Invalid config for plugin '${p.id}':\n${msgs}`);
1475
- }
1476
- mergedPluginConfigs[p.id] = parsed.data;
1477
- }
1478
- else {
1479
- mergedPluginConfigs[p.id] = interpolated;
1523
+ const interpolated = slice && typeof slice === 'object'
1524
+ ? interpolateDeep(slice, envRef)
1525
+ : {};
1526
+ // Enforced: plugins always carry a schema (strict empty by default).
1527
+ // Zod v4: avoid legacy multi-generic usage; treat as generic ZodObject.
1528
+ const schema = p.configSchema;
1529
+ const toParse = interpolated;
1530
+ const parsed = schema.safeParse(toParse);
1531
+ if (!parsed.success) {
1532
+ const err = parsed.error;
1533
+ const msgs = err.issues
1534
+ .map((i) => {
1535
+ const path = Array.isArray(i.path) ? i.path.join('.') : '';
1536
+ const msg = typeof i.message === 'string' ? i.message : 'Invalid value';
1537
+ return path ? `${path}: ${msg}` : msg;
1538
+ })
1539
+ .join('\n');
1540
+ throw new Error(`Invalid config for plugin '${p.id}':\n${msgs}`);
1480
1541
  }
1542
+ // Store a readonly (shallow-frozen) value for runtime safety.
1543
+ const frozen = Object.freeze(parsed.data);
1544
+ _setPluginConfigForInstance(p, frozen);
1545
+ mergedPluginConfigsById[p.id] = frozen;
1481
1546
  }
1482
1547
  return {
1483
1548
  optionsResolved: validated,
1484
1549
  dotenv: dotenv,
1485
1550
  plugins: {},
1486
- pluginConfigs: mergedPluginConfigs,
1551
+ // Retained for legacy root help dynamic evaluation only. Instance-bound
1552
+ // access is used by plugins themselves and tests/docs moving forward.
1553
+ pluginConfigs: mergedPluginConfigsById,
1487
1554
  };
1488
1555
  };
1489
1556
 
1557
+ // Registry for dynamic descriptions keyed by Option (WeakMap so GC-friendly)
1558
+ const DYN_DESC = new WeakMap();
1559
+ /**
1560
+ * Create an Option with a dynamic description callback stored in DYN_DESC.
1561
+ */
1562
+ function makeDynamicOption(flags, desc, parser, defaultValue) {
1563
+ const opt = new Option(flags, '');
1564
+ DYN_DESC.set(opt, desc);
1565
+ if (parser) {
1566
+ opt.argParser((value, previous) => parser(value, previous));
1567
+ }
1568
+ if (defaultValue !== undefined)
1569
+ opt.default(defaultValue);
1570
+ return opt;
1571
+ }
1572
+ /**
1573
+ * Evaluate dynamic descriptions across a command tree using the resolved config.
1574
+ */
1575
+ function evaluateDynamicOptions(root, resolved) {
1576
+ const visit = (cmd) => {
1577
+ const arr = cmd.options;
1578
+ for (const o of arr) {
1579
+ const dyn = DYN_DESC.get(o);
1580
+ if (typeof dyn === 'function') {
1581
+ try {
1582
+ const txt = dyn(resolved);
1583
+ // Commander uses Option.description during help rendering.
1584
+ o.description = txt;
1585
+ }
1586
+ catch {
1587
+ /* best-effort; leave as-is */
1588
+ }
1589
+ }
1590
+ }
1591
+ for (const c of cmd.commands)
1592
+ visit(c);
1593
+ };
1594
+ visit(root);
1595
+ }
1596
+
1597
+ // Registry for grouping; root help renders groups between Options and Commands.
1598
+ const GROUP_TAG = new WeakMap();
1599
+ function renderOptionGroups(cmd) {
1600
+ const all = cmd.options;
1601
+ const byGroup = new Map();
1602
+ for (const o of all) {
1603
+ const opt = o;
1604
+ const g = GROUP_TAG.get(opt);
1605
+ if (!g || g === 'base')
1606
+ continue; // base handled by default help
1607
+ const rows = byGroup.get(g) ?? [];
1608
+ rows.push({
1609
+ flags: opt.flags,
1610
+ description: opt.description ?? '',
1611
+ });
1612
+ byGroup.set(g, rows);
1613
+ }
1614
+ if (byGroup.size === 0)
1615
+ return '';
1616
+ const renderRows = (title, rows) => {
1617
+ const width = Math.min(40, rows.reduce((m, r) => Math.max(m, r.flags.length), 0));
1618
+ // Sort within group: short-aliased flags first
1619
+ rows.sort((a, b) => {
1620
+ const aS = /(^|\s|,)-[A-Za-z]/.test(a.flags) ? 1 : 0;
1621
+ const bS = /(^|\s|,)-[A-Za-z]/.test(b.flags) ? 1 : 0;
1622
+ return bS - aS || a.flags.localeCompare(b.flags);
1623
+ });
1624
+ const lines = rows
1625
+ .map((r) => {
1626
+ const pad = ' '.repeat(Math.max(2, width - r.flags.length + 2));
1627
+ return ` ${r.flags}${pad}${r.description}`.trimEnd();
1628
+ })
1629
+ .join('\n');
1630
+ return `\n${title}:\n${lines}\n`;
1631
+ };
1632
+ let out = '';
1633
+ // App options (if any)
1634
+ const app = byGroup.get('app');
1635
+ if (app && app.length > 0) {
1636
+ out += renderRows('App options', app);
1637
+ }
1638
+ // Plugin groups sorted by id; suppress self group on the owning command name.
1639
+ const pluginKeys = Array.from(byGroup.keys()).filter((k) => k.startsWith('plugin:'));
1640
+ const currentName = cmd.name();
1641
+ pluginKeys.sort((a, b) => a.localeCompare(b));
1642
+ for (const k of pluginKeys) {
1643
+ const id = k.slice('plugin:'.length) || '(unknown)';
1644
+ const rows = byGroup.get(k) ?? [];
1645
+ if (rows.length > 0 && id !== currentName) {
1646
+ out += renderRows(`Plugin options — ${id}`, rows);
1647
+ }
1648
+ }
1649
+ return out;
1650
+ }
1651
+
1490
1652
  const HOST_META_URL = import.meta.url;
1491
1653
  const CTX_SYMBOL = Symbol('GetDotenvCli.ctx');
1492
1654
  const OPTS_SYMBOL = Symbol('GetDotenvCli.options');
@@ -1499,8 +1661,6 @@ const HELP_HEADER_SYMBOL = Symbol('GetDotenvCli.helpHeader');
1499
1661
  * - Expose a stable accessor for the current context (getCtx).
1500
1662
  * - Provide a namespacing helper (ns).
1501
1663
  * - Support composable plugins with parent → children install and afterResolve.
1502
- *
1503
- * NOTE: This host is additive and does not alter the legacy CLI.
1504
1664
  */
1505
1665
  let GetDotenvCli$1 = class GetDotenvCli extends Command {
1506
1666
  /** Registered top-level plugins (composition happens via .use()) */
@@ -1509,6 +1669,17 @@ let GetDotenvCli$1 = class GetDotenvCli extends Command {
1509
1669
  _installed = false;
1510
1670
  /** Optional header line to prepend in help output */
1511
1671
  [HELP_HEADER_SYMBOL];
1672
+ /** Context/options stored under symbols (typed) */
1673
+ [CTX_SYMBOL];
1674
+ [OPTS_SYMBOL];
1675
+ /**
1676
+ * Create a subcommand using the same subclass, preserving helpers like
1677
+ * dynamicOption on children.
1678
+ */
1679
+ createCommand(name) {
1680
+ // Explicitly construct a GetDotenvCli (drop subclass constructor semantics).
1681
+ return new GetDotenvCli(name);
1682
+ }
1512
1683
  constructor(alias = 'getdotenv') {
1513
1684
  super(alias);
1514
1685
  // Ensure subcommands that use passThroughOptions can be attached safely.
@@ -1516,37 +1687,39 @@ let GetDotenvCli$1 = class GetDotenvCli extends Command {
1516
1687
  // child uses passThroughOptions.
1517
1688
  this.enablePositionalOptions();
1518
1689
  // Configure grouped help: show only base options in default "Options";
1519
- // append App/Plugin sections after default help.
1690
+ // we will insert App/Plugin sections before Commands in helpInformation().
1520
1691
  this.configureHelp({
1521
1692
  visibleOptions: (cmd) => {
1522
- const all = cmd.options ??
1523
- [];
1524
- const base = all.filter((opt) => {
1525
- const group = opt.__group;
1526
- return group === 'base';
1527
- });
1693
+ const all = cmd.options;
1694
+ const isRoot = cmd.parent === null;
1695
+ const list = isRoot
1696
+ ? all.filter((opt) => {
1697
+ const group = GROUP_TAG.get(opt);
1698
+ return group === 'base';
1699
+ })
1700
+ : all.slice(); // subcommands: show all options (their own "Options:" block)
1528
1701
  // Sort: short-aliased options first, then long-only; stable by flags.
1529
1702
  const hasShort = (opt) => {
1530
- const flags = opt.flags ?? '';
1703
+ const flags = opt.flags;
1531
1704
  // Matches "-x," or starting "-x " before any long
1532
1705
  return /(^|\s|,)-[A-Za-z]/.test(flags);
1533
1706
  };
1534
- const byFlags = (opt) => opt.flags ?? '';
1535
- base.sort((a, b) => {
1707
+ const byFlags = (opt) => opt.flags;
1708
+ list.sort((a, b) => {
1536
1709
  const aS = hasShort(a) ? 1 : 0;
1537
1710
  const bS = hasShort(b) ? 1 : 0;
1538
1711
  return bS - aS || byFlags(a).localeCompare(byFlags(b));
1539
1712
  });
1540
- return base;
1713
+ return list;
1541
1714
  },
1542
1715
  });
1543
1716
  this.addHelpText('beforeAll', () => {
1544
1717
  const header = this[HELP_HEADER_SYMBOL];
1545
1718
  return header && header.length > 0 ? `${header}\n\n` : '';
1546
1719
  });
1547
- this.addHelpText('afterAll', (ctx) => this.#renderOptionGroups(ctx.command));
1548
1720
  // Skeleton preSubcommand hook: produce a context if absent, without
1549
- // mutating process.env. The passOptions hook (when installed) will // compute the final context using merged CLI options; keeping
1721
+ // mutating process.env. The passOptions hook (when installed) will
1722
+ // compute the final context using merged CLI options; keeping
1550
1723
  // loadProcess=false here avoids leaking dotenv values into the parent
1551
1724
  // process env before subcommands execute.
1552
1725
  this.hook('preSubcommand', async () => {
@@ -1556,22 +1729,49 @@ let GetDotenvCli$1 = class GetDotenvCli extends Command {
1556
1729
  });
1557
1730
  }
1558
1731
  /**
1559
- * Resolve options (strict) and compute dotenv context. * Stores the context on the instance under a symbol.
1732
+ * Resolve options (strict) and compute dotenv context.
1733
+ * Stores the context on the instance under a symbol.
1734
+ *
1735
+ * Options:
1736
+ * - opts.runAfterResolve (default true): when false, skips running plugin
1737
+ * afterResolve hooks. Useful for top-level help rendering to avoid
1738
+ * long-running side-effects while still evaluating dynamic help text.
1560
1739
  */
1561
- async resolveAndLoad(customOptions = {}) {
1740
+ async resolveAndLoad(customOptions = {}, opts) {
1562
1741
  // Resolve defaults, then validate strictly under the new host.
1563
1742
  const optionsResolved = await resolveGetDotenvOptions(customOptions);
1564
1743
  getDotenvOptionsSchemaResolved.parse(optionsResolved);
1565
1744
  // Delegate the heavy lifting to the shared helper (guarded path supported).
1566
1745
  const ctx = await computeContext(optionsResolved, this._plugins, HOST_META_URL);
1567
1746
  // Persist context on the instance for later access.
1568
- this[CTX_SYMBOL] =
1569
- ctx;
1747
+ this[CTX_SYMBOL] = ctx;
1570
1748
  // Ensure plugins are installed exactly once, then run afterResolve.
1571
1749
  await this.install();
1572
- await this._runAfterResolve(ctx);
1750
+ if (opts?.runAfterResolve ?? true) {
1751
+ await this._runAfterResolve(ctx);
1752
+ }
1573
1753
  return ctx;
1574
1754
  }
1755
+ // Implementation
1756
+ createDynamicOption(flags, desc, parser, defaultValue) {
1757
+ return makeDynamicOption(flags, (c) => desc(c), parser, defaultValue);
1758
+ }
1759
+ /**
1760
+ * Chainable helper mirroring .option(), but with a dynamic description.
1761
+ * Equivalent to addOption(createDynamicOption(...)).
1762
+ */
1763
+ dynamicOption(flags, desc, parser, defaultValue) {
1764
+ this.addOption(this.createDynamicOption(flags, desc, parser, defaultValue));
1765
+ return this;
1766
+ }
1767
+ /**
1768
+ * Evaluate dynamic descriptions for this command and all descendants using
1769
+ * the provided resolved configuration. Mutates the Option.description in
1770
+ * place so Commander help renders updated text.
1771
+ */
1772
+ evaluateDynamicOptions(resolved) {
1773
+ evaluateDynamicOptions(this, resolved);
1774
+ }
1575
1775
  /**
1576
1776
  * Retrieve the current invocation context (if any).
1577
1777
  */
@@ -1589,9 +1789,13 @@ let GetDotenvCli$1 = class GetDotenvCli extends Command {
1589
1789
  _setOptionsBag(bag) {
1590
1790
  this[OPTS_SYMBOL] = bag;
1591
1791
  }
1592
- /** * Convenience helper to create a namespaced subcommand.
1593
- */
1792
+ /** Convenience helper to create a namespaced subcommand. */
1594
1793
  ns(name) {
1794
+ // Guard against same-level duplicate command names for clearer diagnostics.
1795
+ const exists = this.commands.some((c) => c.name() === name);
1796
+ if (exists) {
1797
+ throw new Error(`Duplicate command name: ${name}`);
1798
+ }
1595
1799
  return this.command(name);
1596
1800
  }
1597
1801
  /**
@@ -1601,29 +1805,15 @@ let GetDotenvCli$1 = class GetDotenvCli extends Command {
1601
1805
  tagAppOptions(fn) {
1602
1806
  const root = this;
1603
1807
  const originalAddOption = root.addOption.bind(root);
1604
- const originalOption = root.option.bind(root);
1605
- const tagLatest = (cmd, group) => {
1606
- const optsArr = cmd.options;
1607
- if (Array.isArray(optsArr) && optsArr.length > 0) {
1608
- const last = optsArr[optsArr.length - 1];
1609
- last.__group = group;
1610
- }
1611
- };
1612
1808
  root.addOption = function patchedAdd(opt) {
1613
- opt.__group = 'app';
1809
+ root.setOptionGroup(opt, 'app');
1614
1810
  return originalAddOption(opt);
1615
1811
  };
1616
- root.option = function patchedOption(...args) {
1617
- const ret = originalOption(...args);
1618
- tagLatest(this, 'app');
1619
- return ret;
1620
- };
1621
1812
  try {
1622
1813
  return fn(root);
1623
1814
  }
1624
1815
  finally {
1625
1816
  root.addOption = originalAddOption;
1626
- root.option = originalOption;
1627
1817
  }
1628
1818
  }
1629
1819
  /**
@@ -1662,12 +1852,51 @@ let GetDotenvCli$1 = class GetDotenvCli extends Command {
1662
1852
  this[HELP_HEADER_SYMBOL] = helpHeader;
1663
1853
  }
1664
1854
  else if (v) {
1665
- // Use the current command name (possibly overridden by 'name' above).
1666
1855
  const header = `${this.name()} v${v}`;
1667
1856
  this[HELP_HEADER_SYMBOL] = header;
1668
1857
  }
1669
1858
  return this;
1670
1859
  }
1860
+ /**
1861
+ * Insert grouped plugin/app options between "Options" and "Commands" for
1862
+ * hybrid ordering. Applies to root and any parent command.
1863
+ */
1864
+ helpInformation() {
1865
+ // Base help text first (includes beforeAll/after hooks).
1866
+ const base = super.helpInformation();
1867
+ const groups = renderOptionGroups(this);
1868
+ const block = typeof groups === 'string' ? groups.trim() : '';
1869
+ let out = base;
1870
+ if (!block) {
1871
+ // Ensure a trailing blank line even when no extra groups render.
1872
+ if (!out.endsWith('\n\n'))
1873
+ out = out.endsWith('\n') ? `${out}\n` : `${out}\n\n`;
1874
+ return out;
1875
+ }
1876
+ // Insert just before "Commands:" when present.
1877
+ const marker = '\nCommands:';
1878
+ const idx = base.indexOf(marker);
1879
+ if (idx >= 0) {
1880
+ const toInsert = groups.startsWith('\n') ? groups : `\n${groups}`;
1881
+ out = `${base.slice(0, idx)}${toInsert}${base.slice(idx)}`;
1882
+ }
1883
+ else {
1884
+ // Otherwise append.
1885
+ const sep = base.endsWith('\n') || groups.startsWith('\n') ? '' : '\n';
1886
+ out = `${base}${sep}${groups}`;
1887
+ }
1888
+ // Ensure a trailing blank line for prompt separation.
1889
+ if (!out.endsWith('\n\n')) {
1890
+ out = out.endsWith('\n') ? `${out}\n` : `${out}\n\n`;
1891
+ }
1892
+ return out;
1893
+ }
1894
+ /**
1895
+ * Public: tag an Option with a display group for help (root/app/plugin:<id>).
1896
+ */
1897
+ setOptionGroup(opt, group) {
1898
+ GROUP_TAG.set(opt, group);
1899
+ }
1671
1900
  /**
1672
1901
  * Register a plugin for installation (parent level).
1673
1902
  * Installation occurs on first resolveAndLoad() (or explicit install()).
@@ -1706,58 +1935,18 @@ let GetDotenvCli$1 = class GetDotenvCli extends Command {
1706
1935
  for (const p of this._plugins)
1707
1936
  await run(p);
1708
1937
  }
1709
- // Render App/Plugin grouped options appended after default help.
1710
- #renderOptionGroups(cmd) {
1711
- const all = cmd.options ?? [];
1712
- const byGroup = new Map();
1713
- for (const o of all) {
1714
- const opt = o;
1715
- const g = opt.__group;
1716
- if (!g || g === 'base')
1717
- continue; // base handled by default help
1718
- const rows = byGroup.get(g) ?? [];
1719
- rows.push({
1720
- flags: opt.flags ?? '',
1721
- description: opt.description ?? '',
1722
- });
1723
- byGroup.set(g, rows);
1724
- }
1725
- if (byGroup.size === 0)
1726
- return '';
1727
- const renderRows = (title, rows) => {
1728
- const width = Math.min(40, rows.reduce((m, r) => Math.max(m, r.flags.length), 0));
1729
- // Sort within group: short-aliased flags first
1730
- rows.sort((a, b) => {
1731
- const aS = /(^|\s|,)-[A-Za-z]/.test(a.flags) ? 1 : 0;
1732
- const bS = /(^|\s|,)-[A-Za-z]/.test(b.flags) ? 1 : 0;
1733
- return bS - aS || a.flags.localeCompare(b.flags);
1734
- });
1735
- const lines = rows
1736
- .map((r) => {
1737
- const pad = ' '.repeat(Math.max(2, width - r.flags.length + 2));
1738
- return ` ${r.flags}${pad}${r.description}`.trimEnd();
1739
- })
1740
- .join('\n');
1741
- return `\n${title}:\n${lines}\n`;
1742
- };
1743
- let out = '';
1744
- // App options (if any)
1745
- const app = byGroup.get('app');
1746
- if (app && app.length > 0) {
1747
- out += renderRows('App options', app);
1748
- }
1749
- // Plugin groups sorted by id
1750
- const pluginKeys = Array.from(byGroup.keys()).filter((k) => k.startsWith('plugin:'));
1751
- pluginKeys.sort((a, b) => a.localeCompare(b));
1752
- for (const k of pluginKeys) {
1753
- const id = k.slice('plugin:'.length) || '(unknown)';
1754
- const rows = byGroup.get(k) ?? [];
1755
- if (rows.length > 0) {
1756
- out += renderRows(`Plugin options — ${id}`, rows);
1757
- }
1758
- }
1759
- return out;
1760
- }
1938
+ };
1939
+
1940
+ /**
1941
+ * Build a help-time configuration bag for dynamic option descriptions.
1942
+ * Centralizes construction and reduces inline casts at call sites.
1943
+ */
1944
+ const toHelpConfig = (merged, plugins) => {
1945
+ const bag = {
1946
+ ...merged,
1947
+ plugins: plugins ?? {},
1948
+ };
1949
+ return bag;
1761
1950
  };
1762
1951
 
1763
1952
  /** src/cliHost/definePlugin.ts
@@ -1767,26 +1956,59 @@ let GetDotenvCli$1 = class GetDotenvCli extends Command {
1767
1956
  * should use (GetDotenvCliPublic). Using a structural type at the seam avoids
1768
1957
  * nominal class identity issues (private fields) in downstream consumers.
1769
1958
  */
1770
- /**
1771
- * Define a GetDotenv CLI plugin with compositional helpers.
1772
- *
1773
- * @example
1774
- * const parent = definePlugin(\{ id: 'p', setup(cli) \{ /* ... *\/ \} \})
1775
- * .use(childA)
1776
- * .use(childB);
1777
- */
1778
- const definePlugin = (spec) => {
1959
+ /* eslint-disable tsdoc/syntax */
1960
+ function definePlugin(spec) {
1779
1961
  const { children = [], ...rest } = spec;
1780
- const plugin = {
1962
+ // Default to a strict empty-object schema so “no-config” plugins fail fast
1963
+ // on unknown keys and provide a concrete {} at runtime.
1964
+ const effectiveSchema = spec.configSchema ?? z.object({}).strict();
1965
+ // Build base plugin first, then extend with instance-bound helpers.
1966
+ const base = {
1781
1967
  ...rest,
1968
+ // Always carry a schema (strict empty by default) to simplify host logic
1969
+ // and improve inference/ergonomics for plugin authors.
1970
+ configSchema: effectiveSchema,
1782
1971
  children: [...children],
1783
1972
  use(child) {
1784
1973
  this.children.push(child);
1785
1974
  return this;
1786
1975
  },
1787
1976
  };
1788
- return plugin;
1789
- };
1977
+ // Attach instance-bound helpers on the returned plugin object.
1978
+ const extended = base;
1979
+ extended.readConfig = function (_cli) {
1980
+ // Config is stored per-plugin-instance by the host (WeakMap in computeContext).
1981
+ const value = _getPluginConfigForInstance(extended);
1982
+ if (value === undefined) {
1983
+ // Guard: host has not resolved config yet (incorrect lifecycle usage).
1984
+ throw new Error('Plugin config not available. Ensure resolveAndLoad() has been called before readConfig().');
1985
+ }
1986
+ return value;
1987
+ };
1988
+ // Plugin-bound dynamic option factory
1989
+ extended.createPluginDynamicOption = function (cli, flags, desc, parser, defaultValue) {
1990
+ return cli.createDynamicOption(flags, (cfg) => {
1991
+ // Prefer the validated slice stored per instance; fallback to help-bag
1992
+ // (by-id) so top-level `-h` can render effective defaults before resolve.
1993
+ const fromStore = _getPluginConfigForInstance(extended);
1994
+ const id = extended.id;
1995
+ let fromBag;
1996
+ if (!fromStore && id) {
1997
+ const maybe = cfg.plugins[id];
1998
+ if (maybe && typeof maybe === 'object') {
1999
+ fromBag = maybe;
2000
+ }
2001
+ }
2002
+ // Always provide a concrete object to dynamic callbacks:
2003
+ // - With a schema: computeContext stores the parsed object.
2004
+ // - Without a schema: computeContext stores {}.
2005
+ // - Help-time fallback: coalesce to {} when only a by-id bag exists.
2006
+ const cfgVal = (fromStore ?? fromBag ?? {});
2007
+ return desc(cfg, cfgVal);
2008
+ }, parser, defaultValue);
2009
+ };
2010
+ return extended;
2011
+ }
1790
2012
 
1791
2013
  /**
1792
2014
  * GetDotenvCli with root helpers as real class methods.
@@ -1799,9 +2021,9 @@ class GetDotenvCli extends GetDotenvCli$1 {
1799
2021
  * Attach legacy root flags to this CLI instance. Defaults come from
1800
2022
  * baseRootOptionDefaults when none are provided.
1801
2023
  */
1802
- attachRootOptions(defaults, opts) {
2024
+ attachRootOptions(defaults) {
1803
2025
  const d = (defaults ?? baseRootOptionDefaults);
1804
- attachRootOptions(this, d, opts);
2026
+ attachRootOptions(this, d);
1805
2027
  return this;
1806
2028
  }
1807
2029
  /**
@@ -1823,10 +2045,19 @@ class GetDotenvCli extends GetDotenvCli$1 {
1823
2045
  // Build service options and compute context (always-on loader path).
1824
2046
  const serviceOptions = getDotenvCliOptions2Options(merged);
1825
2047
  await this.resolveAndLoad(serviceOptions);
2048
+ // Refresh dynamic option descriptions using resolved config + plugin slices
2049
+ try {
2050
+ const ctx = this.getCtx();
2051
+ const helpCfg = toHelpConfig(merged, ctx?.pluginConfigs ?? {});
2052
+ this.evaluateDynamicOptions(helpCfg);
2053
+ }
2054
+ catch {
2055
+ /* best-effort */
2056
+ }
1826
2057
  // Global validation: once after Phase C using config sources.
1827
2058
  try {
1828
2059
  const ctx = this.getCtx();
1829
- const dotenv = (ctx?.dotenv ?? {});
2060
+ const dotenv = ctx?.dotenv ?? {};
1830
2061
  const sources = await resolveGetDotenvConfigSources(import.meta.url);
1831
2062
  const issues = validateEnvAgainstSources(dotenv, sources);
1832
2063
  if (Array.isArray(issues) && issues.length > 0) {
@@ -1836,9 +2067,8 @@ class GetDotenvCli extends GetDotenvCli$1 {
1836
2067
  issues.forEach((m) => {
1837
2068
  emit(m);
1838
2069
  });
1839
- if (merged.strict) {
2070
+ if (merged.strict)
1840
2071
  process.exit(1);
1841
- }
1842
2072
  }
1843
2073
  }
1844
2074
  catch {
@@ -1857,6 +2087,14 @@ class GetDotenvCli extends GetDotenvCli$1 {
1857
2087
  if (!this.getCtx()) {
1858
2088
  const serviceOptions = getDotenvCliOptions2Options(merged);
1859
2089
  await this.resolveAndLoad(serviceOptions);
2090
+ try {
2091
+ const ctx = this.getCtx();
2092
+ const helpCfg = toHelpConfig(merged, ctx?.pluginConfigs ?? {});
2093
+ this.evaluateDynamicOptions(helpCfg);
2094
+ }
2095
+ catch {
2096
+ /* tolerate */
2097
+ }
1860
2098
  try {
1861
2099
  const ctx = this.getCtx();
1862
2100
  const dotenv = (ctx?.dotenv ?? {});
@@ -1882,23 +2120,52 @@ class GetDotenvCli extends GetDotenvCli$1 {
1882
2120
  return this;
1883
2121
  }
1884
2122
  }
2123
+ /**
2124
+ * Helper to retrieve the merged root options bag from any action handler
2125
+ * that only has access to thisCommand. Avoids structural casts.
2126
+ */
2127
+ const readMergedOptions = (cmd) => {
2128
+ // Ascend to the root command
2129
+ let root = cmd;
2130
+ while (root.parent) {
2131
+ root = root.parent;
2132
+ }
2133
+ const hostAny = root;
2134
+ return typeof hostAny.getOptions === 'function'
2135
+ ? hostAny.getOptions()
2136
+ : root
2137
+ .getDotenvCliOptions;
2138
+ };
1885
2139
 
1886
2140
  // Minimal tokenizer for shell-off execution:
1887
2141
  // Splits by whitespace while preserving quoted segments (single or double quotes).
1888
- const tokenize = (command) => {
2142
+ // Optionally preserve doubled quotes inside quoted segments:
2143
+ // - default: "" => " (Windows/PowerShell style literal-quote escape)
2144
+ // - preserveDoubledQuotes: true => "" stays "" (needed for Node -e payloads)
2145
+ const tokenize = (command, opts) => {
1889
2146
  const out = [];
1890
2147
  let cur = '';
1891
2148
  let quote = null;
2149
+ const preserve = opts && opts.preserveDoubledQuotes === true ? true : false;
1892
2150
  for (let i = 0; i < command.length; i++) {
1893
2151
  const c = command.charAt(i);
1894
2152
  if (quote) {
1895
2153
  if (c === quote) {
1896
- // Support doubled quotes inside a quoted segment (Windows/PowerShell style):
1897
- // "" -> " and '' -> '
2154
+ // Support doubled quotes inside a quoted segment:
2155
+ // default: "" -> " and '' -> ' (Windows/PowerShell style)
2156
+ // preserve: keep as "" to allow empty string literals in Node -e payloads
1898
2157
  const next = command.charAt(i + 1);
1899
2158
  if (next === quote) {
1900
- cur += quote;
1901
- i += 1; // skip the second quote
2159
+ if (preserve) {
2160
+ // Keep "" as-is; append both and continue within the quoted segment.
2161
+ cur += quote + quote;
2162
+ i += 1; // skip the second quote char (we already appended both)
2163
+ }
2164
+ else {
2165
+ // Collapse to a single literal quote
2166
+ cur += quote;
2167
+ i += 1; // skip the second quote
2168
+ }
1902
2169
  }
1903
2170
  else {
1904
2171
  // end of quoted segment
@@ -1972,62 +2239,55 @@ const sanitizeEnv = (env) => {
1972
2239
  const entries = Object.entries(env).filter((e) => typeof e[1] === 'string');
1973
2240
  return entries.length > 0 ? Object.fromEntries(entries) : undefined;
1974
2241
  };
1975
- /**
1976
- * Execute a command and capture stdout/stderr (buffered).
1977
- * - Preserves plain vs shell behavior and argv/string normalization.
1978
- * - Never re-emits stdout/stderr to parent; returns captured buffers.
1979
- * - Supports optional timeout (ms).
1980
- */
1981
- const runCommandResult = async (command, shell, opts = {}) => {
2242
+ async function runCommandResult(command, shell, opts = {}) {
1982
2243
  const envSan = sanitizeEnv(opts.env);
1983
2244
  {
1984
2245
  let file;
1985
2246
  let args = [];
1986
- if (Array.isArray(command)) {
1987
- file = command[0];
1988
- args = command.slice(1).map(stripOuterQuotes);
1989
- }
1990
- else {
2247
+ if (typeof command === 'string') {
1991
2248
  const tokens = tokenize(command);
1992
2249
  file = tokens[0];
1993
2250
  args = tokens.slice(1);
1994
2251
  }
2252
+ else {
2253
+ file = command[0];
2254
+ args = command.slice(1).map(stripOuterQuotes);
2255
+ }
1995
2256
  if (!file)
1996
2257
  return { exitCode: 0, stdout: '', stderr: '' };
1997
2258
  dbg$1('exec:capture (plain)', { file, args });
1998
2259
  try {
1999
- const result = await execa(file, args, {
2260
+ const ok = pickResult((await execa(file, args, {
2000
2261
  ...(opts.cwd !== undefined ? { cwd: opts.cwd } : {}),
2001
2262
  ...(envSan !== undefined ? { env: envSan } : {}),
2002
2263
  stdio: 'pipe',
2003
2264
  ...(opts.timeoutMs !== undefined
2004
2265
  ? { timeout: opts.timeoutMs, killSignal: 'SIGKILL' }
2005
2266
  : {}),
2006
- });
2007
- const ok = pickResult(result);
2267
+ })));
2008
2268
  dbg$1('exit:capture (plain)', { exitCode: ok.exitCode });
2009
2269
  return ok;
2010
2270
  }
2011
- catch (err) {
2012
- const out = pickResult(err);
2271
+ catch (e) {
2272
+ const out = pickResult(e);
2013
2273
  dbg$1('exit:capture:error (plain)', { exitCode: out.exitCode });
2014
2274
  return out;
2015
2275
  }
2016
2276
  }
2017
- };
2018
- const runCommand = async (command, shell, opts) => {
2277
+ }
2278
+ async function runCommand(command, shell, opts) {
2019
2279
  if (shell === false) {
2020
2280
  let file;
2021
2281
  let args = [];
2022
- if (Array.isArray(command)) {
2023
- file = command[0];
2024
- args = command.slice(1).map(stripOuterQuotes);
2025
- }
2026
- else {
2282
+ if (typeof command === 'string') {
2027
2283
  const tokens = tokenize(command);
2028
2284
  file = tokens[0];
2029
2285
  args = tokens.slice(1);
2030
2286
  }
2287
+ else {
2288
+ file = command[0];
2289
+ args = command.slice(1).map(stripOuterQuotes);
2290
+ }
2031
2291
  if (!file)
2032
2292
  return 0;
2033
2293
  dbg$1('exec (plain)', { file, args, stdio: opts.stdio });
@@ -2040,16 +2300,15 @@ const runCommand = async (command, shell, opts) => {
2040
2300
  plainOpts.env = envSan;
2041
2301
  if (opts.stdio !== undefined)
2042
2302
  plainOpts.stdio = opts.stdio;
2043
- const result = await execa(file, args, plainOpts);
2044
- if (opts.stdio === 'pipe' && result.stdout) {
2045
- process.stdout.write(result.stdout + (result.stdout.endsWith('\n') ? '' : '\n'));
2303
+ const ok = pickResult((await execa(file, args, plainOpts)));
2304
+ if (opts.stdio === 'pipe' && ok.stdout) {
2305
+ process.stdout.write(ok.stdout + (ok.stdout.endsWith('\n') ? '' : '\n'));
2046
2306
  }
2047
- const exit = result?.exitCode;
2048
- dbg$1('exit (plain)', { exitCode: exit });
2049
- return typeof exit === 'number' ? exit : Number.NaN;
2307
+ dbg$1('exit (plain)', { exitCode: ok.exitCode });
2308
+ return typeof ok.exitCode === 'number' ? ok.exitCode : Number.NaN;
2050
2309
  }
2051
2310
  else {
2052
- const commandStr = Array.isArray(command) ? command.join(' ') : command;
2311
+ const commandStr = typeof command === 'string' ? command : command.join(' ');
2053
2312
  dbg$1('exec (shell)', {
2054
2313
  shell: typeof shell === 'string' ? shell : 'custom',
2055
2314
  stdio: opts.stdio,
@@ -2063,17 +2322,29 @@ const runCommand = async (command, shell, opts) => {
2063
2322
  shellOpts.env = envSan;
2064
2323
  if (opts.stdio !== undefined)
2065
2324
  shellOpts.stdio = opts.stdio;
2066
- const result = await execaCommand(commandStr, shellOpts);
2067
- const out = result?.stdout;
2068
- if (opts.stdio === 'pipe' && out) {
2069
- process.stdout.write(out + (out.endsWith('\n') ? '' : '\n'));
2325
+ const ok = pickResult((await execaCommand(commandStr, shellOpts)));
2326
+ if (opts.stdio === 'pipe' && ok.stdout) {
2327
+ process.stdout.write(ok.stdout + (ok.stdout.endsWith('\n') ? '' : '\n'));
2070
2328
  }
2071
- const exit = result?.exitCode;
2072
- dbg$1('exit (shell)', { exitCode: exit });
2073
- return typeof exit === 'number' ? exit : Number.NaN;
2329
+ dbg$1('exit (shell)', { exitCode: ok.exitCode });
2330
+ return typeof ok.exitCode === 'number' ? ok.exitCode : Number.NaN;
2074
2331
  }
2075
- };
2332
+ }
2076
2333
 
2334
+ /** src/cliCore/spawnEnv.ts
2335
+ * Build a sanitized environment bag for child processes.
2336
+ *
2337
+ * Requirements addressed:
2338
+ * - Provide a single helper (buildSpawnEnv) to normalize/dedupe child env.
2339
+ * - Drop undefined values (exactOptional semantics).
2340
+ * - On Windows, dedupe keys case-insensitively and prefer the last value,
2341
+ * preserving the latest key's casing. Ensure HOME fallback from USERPROFILE.
2342
+ * Normalize TMP/TEMP consistency when either is present.
2343
+ * - On POSIX, keep keys as-is; when a temp dir key is present (TMPDIR/TMP/TEMP),
2344
+ * ensure TMPDIR exists for downstream consumers that expect it.
2345
+ *
2346
+ * Adapter responsibility: pure mapping; no business logic.
2347
+ */
2077
2348
  const dropUndefined = (bag) => Object.fromEntries(Object.entries(bag).filter((e) => typeof e[1] === 'string'));
2078
2349
  /** Build a sanitized env for child processes from base + overlay. */
2079
2350
  const buildSpawnEnv = (base, overlay) => {
@@ -2338,90 +2609,79 @@ const AwsPluginConfigSchema = z.object({
2338
2609
  regionKey: z.string().default('AWS_REGION').optional(),
2339
2610
  strategy: z.enum(['cli-export', 'none']).default('cli-export').optional(),
2340
2611
  loginOnDemand: z.boolean().default(false).optional(),
2341
- setEnv: z.boolean().default(true).optional(),
2342
- addCtx: z.boolean().default(true).optional(),
2343
2612
  });
2344
2613
 
2345
- const awsPlugin = () => definePlugin({
2346
- id: 'aws',
2347
- // Host validates this slice when the loader path is active.
2348
- configSchema: AwsPluginConfigSchema,
2349
- setup(cli) {
2350
- // Subcommand: aws
2351
- cli
2352
- .ns('aws')
2353
- .description('Establish an AWS session and optionally forward to the AWS CLI')
2354
- .configureHelp({ showGlobalOptions: true })
2355
- .enablePositionalOptions()
2356
- .passThroughOptions()
2357
- .allowUnknownOption(true)
2358
- // Boolean toggles
2359
- .option('--login-on-demand', 'attempt aws sso login on-demand')
2360
- .option('--no-login-on-demand', 'disable sso login on-demand')
2361
- .option('--set-env', 'write resolved values into process.env')
2362
- .option('--no-set-env', 'do not write resolved values into process.env')
2363
- .option('--add-ctx', 'mirror results under ctx.plugins.aws')
2364
- .option('--no-add-ctx', 'do not mirror results under ctx.plugins.aws')
2365
- // Strings / enums
2366
- .option('--profile <string>', 'AWS profile name')
2367
- .option('--region <string>', 'AWS region')
2368
- .option('--default-region <string>', 'fallback region')
2369
- .option('--strategy <string>', 'credential acquisition strategy: cli-export|none')
2370
- // Advanced key overrides
2371
- .option('--profile-key <string>', 'dotenv/config key for local profile')
2372
- .option('--profile-fallback-key <string>', 'fallback dotenv/config key for profile')
2373
- .option('--region-key <string>', 'dotenv/config key for region')
2374
- // Accept any extra operands so Commander does not error when tokens appear after "--".
2375
- .argument('[args...]')
2376
- .action(async (args, opts, thisCommand) => {
2377
- const self = thisCommand;
2378
- const parent = (self.parent ?? null);
2379
- // Access merged root CLI options (installed by passOptions())
2380
- const rootOpts = (parent?.getDotenvCliOptions ?? {});
2381
- const capture = process.env.GETDOTENV_STDIO === 'pipe' ||
2382
- Boolean(rootOpts?.capture);
2383
- const underTests = process.env.GETDOTENV_TEST === '1' ||
2384
- typeof process.env.VITEST_WORKER_ID === 'string';
2385
- // Build overlay cfg from subcommand flags layered over discovered config.
2386
- const ctx = cli.getCtx();
2387
- const cfgBase = (ctx?.pluginConfigs?.['aws'] ??
2388
- {});
2389
- const overlay = {};
2390
- // Map boolean toggles (respect explicit --no-*)
2391
- if (Object.prototype.hasOwnProperty.call(opts, 'loginOnDemand'))
2392
- overlay.loginOnDemand = Boolean(opts.loginOnDemand);
2393
- if (Object.prototype.hasOwnProperty.call(opts, 'setEnv'))
2394
- overlay.setEnv = Boolean(opts.setEnv);
2395
- if (Object.prototype.hasOwnProperty.call(opts, 'addCtx'))
2396
- overlay.addCtx = Boolean(opts.addCtx);
2397
- // Strings/enums
2398
- if (typeof opts.profile === 'string')
2399
- overlay.profile = opts.profile;
2400
- if (typeof opts.region === 'string')
2401
- overlay.region = opts.region;
2402
- if (typeof opts.defaultRegion === 'string')
2403
- overlay.defaultRegion = opts.defaultRegion;
2404
- if (typeof opts.strategy === 'string')
2405
- overlay.strategy =
2406
- opts.strategy;
2407
- // Advanced key overrides
2408
- if (typeof opts.profileKey === 'string')
2409
- overlay.profileKey = opts.profileKey;
2410
- if (typeof opts.profileFallbackKey === 'string')
2411
- overlay.profileFallbackKey = opts.profileFallbackKey;
2412
- if (typeof opts.regionKey === 'string')
2413
- overlay.regionKey = opts.regionKey;
2414
- const cfg = {
2415
- ...cfgBase,
2416
- ...overlay,
2417
- };
2418
- // Resolve current context with overrides
2419
- const out = await resolveAwsContext({
2420
- dotenv: ctx?.dotenv ?? {},
2421
- cfg,
2422
- });
2423
- // Apply env/ctx mirrors per toggles
2424
- if (cfg.setEnv !== false) {
2614
+ const awsPlugin = () => {
2615
+ const plugin = definePlugin({
2616
+ id: 'aws',
2617
+ // Host validates this slice when the loader path is active.
2618
+ configSchema: AwsPluginConfigSchema,
2619
+ setup(cli) {
2620
+ // Subcommand: aws
2621
+ cli
2622
+ .ns('aws')
2623
+ .description('Establish an AWS session and optionally forward to the AWS CLI')
2624
+ .enablePositionalOptions()
2625
+ .passThroughOptions()
2626
+ .allowUnknownOption(true)
2627
+ // Boolean toggles with dynamic help labels (effective defaults)
2628
+ .addOption(plugin.createPluginDynamicOption(cli, '--login-on-demand', (_bag, cfg) => `attempt aws sso login on-demand${cfg.loginOnDemand ? ' (default)' : ''}`))
2629
+ .addOption(plugin.createPluginDynamicOption(cli, '--no-login-on-demand', (_bag, cfg) => `disable sso login on-demand${cfg.loginOnDemand === false ? ' (default)' : ''}`))
2630
+ // Strings / enums
2631
+ .addOption(plugin.createPluginDynamicOption(cli, '--profile <string>', (_bag, cfg) => `AWS profile name${cfg.profile ? ` (default: ${JSON.stringify(cfg.profile)})` : ''}`))
2632
+ .addOption(plugin.createPluginDynamicOption(cli, '--region <string>', (_bag, cfg) => `AWS region${cfg.region ? ` (default: ${JSON.stringify(cfg.region)})` : ''}`))
2633
+ .addOption(plugin.createPluginDynamicOption(cli, '--default-region <string>', (_bag, cfg) => `fallback region${cfg.defaultRegion ? ` (default: ${JSON.stringify(cfg.defaultRegion)})` : ''}`))
2634
+ .addOption(plugin.createPluginDynamicOption(cli, '--strategy <string>', (_bag, cfg) => `credential acquisition strategy: cli-export|none${cfg.strategy ? ` (default: ${JSON.stringify(cfg.strategy)})` : ''}`))
2635
+ // Advanced key overrides
2636
+ .addOption(plugin.createPluginDynamicOption(cli, '--profile-key <string>', (_bag, cfg) => `dotenv/config key for local profile${cfg.profileKey ? ` (default: ${JSON.stringify(cfg.profileKey)})` : ''}`))
2637
+ .addOption(plugin.createPluginDynamicOption(cli, '--profile-fallback-key <string>', (_bag, cfg) => `fallback dotenv/config key for profile${cfg.profileFallbackKey ? ` (default: ${JSON.stringify(cfg.profileFallbackKey)})` : ''}`))
2638
+ .addOption(plugin.createPluginDynamicOption(cli, '--region-key <string>', (_bag, cfg) => `dotenv/config key for region${cfg.regionKey ? ` (default: ${JSON.stringify(cfg.regionKey)})` : ''}`))
2639
+ // Accept any extra operands so Commander does not error when tokens appear after "--".
2640
+ .argument('[args...]')
2641
+ .action(async (args, opts, thisCommand) => {
2642
+ const pluginInst = plugin;
2643
+ const cmdSelf = thisCommand;
2644
+ const parent = (cmdSelf.parent ?? null);
2645
+ // Access merged root CLI options (installed by passOptions())
2646
+ const rootOpts = (parent?.getDotenvCliOptions ?? {});
2647
+ const capture = process.env.GETDOTENV_STDIO === 'pipe' ||
2648
+ Boolean(rootOpts?.capture);
2649
+ const underTests = process.env.GETDOTENV_TEST === '1' ||
2650
+ typeof process.env.VITEST_WORKER_ID === 'string';
2651
+ // Build overlay cfg from subcommand flags layered over discovered config.
2652
+ const ctx = cli.getCtx();
2653
+ const cfgBase = pluginInst.readConfig(cli);
2654
+ const o = opts;
2655
+ const overlay = {};
2656
+ // Map boolean toggles (respect explicit --no-*)
2657
+ if (Object.prototype.hasOwnProperty.call(o, 'loginOnDemand'))
2658
+ overlay.loginOnDemand = Boolean(o.loginOnDemand);
2659
+ // Strings/enums
2660
+ if (typeof o.profile === 'string')
2661
+ overlay.profile = o.profile;
2662
+ if (typeof o.region === 'string')
2663
+ overlay.region = o.region;
2664
+ if (typeof o.defaultRegion === 'string')
2665
+ overlay.defaultRegion = o.defaultRegion;
2666
+ if (typeof o.strategy === 'string')
2667
+ overlay.strategy = o.strategy;
2668
+ // Advanced key overrides
2669
+ if (typeof o.profileKey === 'string')
2670
+ overlay.profileKey = o.profileKey;
2671
+ if (typeof o.profileFallbackKey === 'string')
2672
+ overlay.profileFallbackKey = o.profileFallbackKey;
2673
+ if (typeof o.regionKey === 'string')
2674
+ overlay.regionKey = o.regionKey;
2675
+ const cfg = {
2676
+ ...cfgBase,
2677
+ ...overlay,
2678
+ };
2679
+ // Resolve current context with overrides
2680
+ const out = await resolveAwsContext({
2681
+ dotenv: ctx?.dotenv ?? {},
2682
+ cfg,
2683
+ });
2684
+ // Unconditional env writes (no per-plugin toggle)
2425
2685
  if (out.region) {
2426
2686
  process.env.AWS_REGION = out.region;
2427
2687
  if (!process.env.AWS_DEFAULT_REGION)
@@ -2435,58 +2695,53 @@ const awsPlugin = () => definePlugin({
2435
2695
  process.env.AWS_SESSION_TOKEN = out.credentials.sessionToken;
2436
2696
  }
2437
2697
  }
2438
- }
2439
- if (cfg.addCtx !== false) {
2698
+ // Always publish minimal non-sensitive metadata
2440
2699
  if (ctx) {
2441
2700
  ctx.plugins ??= {};
2442
2701
  ctx.plugins['aws'] = {
2443
2702
  ...(out.profile ? { profile: out.profile } : {}),
2444
2703
  ...(out.region ? { region: out.region } : {}),
2445
- ...(out.credentials ? { credentials: out.credentials } : {}),
2446
2704
  };
2447
2705
  }
2448
- }
2449
- // Forward when positional args are present; otherwise session-only.
2450
- if (Array.isArray(args) && args.length > 0) {
2451
- const argv = ['aws', ...args];
2452
- const shellSetting = resolveShell(rootOpts?.scripts, 'aws', rootOpts?.shell);
2453
- const ctxDotenv = (ctx?.dotenv ?? {});
2454
- const exit = await runCommand(argv, shellSetting, {
2455
- env: buildSpawnEnv(process.env, ctxDotenv),
2456
- stdio: capture ? 'pipe' : 'inherit',
2457
- });
2458
- // Deterministic termination (suppressed under tests)
2459
- if (!underTests) {
2460
- process.exit(typeof exit === 'number' ? exit : 0);
2461
- }
2462
- return;
2463
- }
2464
- else {
2465
- // Session only: low-noise breadcrumb under debug
2466
- if (process.env.GETDOTENV_DEBUG) {
2467
- const log = console;
2468
- log.log('[aws] session established', {
2469
- profile: out.profile,
2470
- region: out.region,
2471
- hasCreds: Boolean(out.credentials),
2706
+ // Forward when positional args are present; otherwise session-only.
2707
+ if (Array.isArray(args) && args.length > 0) {
2708
+ const argv = ['aws', ...args];
2709
+ const shellSetting = resolveShell(rootOpts?.scripts, 'aws', rootOpts?.shell);
2710
+ const exit = await runCommand(argv, shellSetting, {
2711
+ env: buildSpawnEnv(process.env, ctx?.dotenv),
2712
+ stdio: capture ? 'pipe' : 'inherit',
2472
2713
  });
2714
+ // Deterministic termination (suppressed under tests)
2715
+ if (!underTests) {
2716
+ process.exit(typeof exit === 'number' ? exit : 0);
2717
+ }
2718
+ return;
2473
2719
  }
2474
- if (!underTests)
2475
- process.exit(0);
2476
- return;
2477
- }
2478
- });
2479
- },
2480
- async afterResolve(_cli, ctx) {
2481
- const log = console;
2482
- const cfgRaw = (ctx.pluginConfigs?.['aws'] ?? {});
2483
- const cfg = (cfgRaw || {});
2484
- const out = await resolveAwsContext({
2485
- dotenv: ctx.dotenv,
2486
- cfg,
2487
- });
2488
- const { profile, region, credentials } = out;
2489
- if (cfg.setEnv !== false) {
2720
+ else {
2721
+ // Session only: low-noise breadcrumb under debug
2722
+ if (process.env.GETDOTENV_DEBUG) {
2723
+ const log = console;
2724
+ log.log('[aws] session established', {
2725
+ profile: out.profile,
2726
+ region: out.region,
2727
+ hasCreds: Boolean(out.credentials),
2728
+ });
2729
+ }
2730
+ if (!underTests)
2731
+ process.exit(0);
2732
+ return;
2733
+ }
2734
+ });
2735
+ },
2736
+ async afterResolve(_cli, ctx) {
2737
+ const log = console;
2738
+ const cfg = plugin.readConfig(_cli);
2739
+ const out = await resolveAwsContext({
2740
+ dotenv: ctx.dotenv,
2741
+ cfg,
2742
+ });
2743
+ const { profile, region, credentials } = out;
2744
+ // Unconditional env writes in host path
2490
2745
  if (region) {
2491
2746
  process.env.AWS_REGION = region;
2492
2747
  if (!process.env.AWS_DEFAULT_REGION)
@@ -2499,25 +2754,24 @@ const awsPlugin = () => definePlugin({
2499
2754
  process.env.AWS_SESSION_TOKEN = credentials.sessionToken;
2500
2755
  }
2501
2756
  }
2502
- }
2503
- if (cfg.addCtx !== false) {
2757
+ // Always publish minimal non-sensitive metadata
2504
2758
  ctx.plugins ??= {};
2505
2759
  ctx.plugins['aws'] = {
2506
2760
  ...(profile ? { profile } : {}),
2507
2761
  ...(region ? { region } : {}),
2508
- ...(credentials ? { credentials } : {}),
2509
2762
  };
2510
- }
2511
- // Optional: low-noise breadcrumb for diagnostics
2512
- if (process.env.GETDOTENV_DEBUG) {
2513
- log.log('[aws] afterResolve', {
2514
- profile,
2515
- region,
2516
- hasCreds: Boolean(credentials),
2517
- });
2518
- }
2519
- },
2520
- });
2763
+ // Optional: low-noise breadcrumb for diagnostics
2764
+ if (process.env.GETDOTENV_DEBUG) {
2765
+ log.log('[aws] afterResolve', {
2766
+ profile,
2767
+ region,
2768
+ hasCreds: Boolean(credentials),
2769
+ });
2770
+ }
2771
+ },
2772
+ });
2773
+ return plugin;
2774
+ };
2521
2775
 
2522
2776
  const globPaths = async ({ globs, logger, pkgCwd, rootPath, }) => {
2523
2777
  let cwd = process.cwd();
@@ -2542,9 +2796,10 @@ const globPaths = async ({ globs, logger, pkgCwd, rootPath, }) => {
2542
2796
  }
2543
2797
  return { absRootPath, paths };
2544
2798
  };
2545
- const execShellCommandBatch = async ({ command, getDotenvCliOptions, globs, ignoreErrors, list, logger, pkgCwd, rootPath, shell, }) => {
2799
+ const execShellCommandBatch = async ({ command, getDotenvCliOptions, dotenvEnv, globs, ignoreErrors, list, logger, pkgCwd, rootPath, shell, }) => {
2546
2800
  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.
2801
+ Boolean(getDotenvCliOptions?.capture);
2802
+ // Require a command only when not listing. In list mode, a command is optional.
2548
2803
  if (!command && !list) {
2549
2804
  logger.error(`No command provided. Use --command or --list.`);
2550
2805
  process.exit(0);
@@ -2591,12 +2846,25 @@ const execShellCommandBatch = async ({ command, getDotenvCliOptions, globs, igno
2591
2846
  const hasCmd = (typeof command === 'string' && command.length > 0) ||
2592
2847
  (Array.isArray(command) && command.length > 0);
2593
2848
  if (hasCmd) {
2594
- const envBag = getDotenvCliOptions !== undefined
2595
- ? { getDotenvCliOptions: JSON.stringify(getDotenvCliOptions) }
2596
- : undefined;
2849
+ // Compose child env overlay from dotenv (drop undefined) and merged options
2850
+ const overlay = {};
2851
+ if (dotenvEnv) {
2852
+ for (const [k, v] of Object.entries(dotenvEnv)) {
2853
+ if (typeof v === 'string')
2854
+ overlay[k] = v;
2855
+ }
2856
+ }
2857
+ if (getDotenvCliOptions !== undefined) {
2858
+ try {
2859
+ overlay.getDotenvCliOptions = JSON.stringify(getDotenvCliOptions);
2860
+ }
2861
+ catch {
2862
+ // best-effort: omit if serialization fails
2863
+ }
2864
+ }
2597
2865
  await runCommand(command, shell, {
2598
2866
  cwd: path,
2599
- env: buildSpawnEnv(process.env, envBag),
2867
+ env: buildSpawnEnv(process.env, overlay),
2600
2868
  stdio: capture ? 'pipe' : 'inherit',
2601
2869
  });
2602
2870
  }
@@ -2619,7 +2887,7 @@ const execShellCommandBatch = async ({ command, getDotenvCliOptions, globs, igno
2619
2887
  * Build the default "cmd" subcommand action for the batch plugin.
2620
2888
  * Mirrors the original inline implementation with identical behavior.
2621
2889
  */
2622
- const buildDefaultCmdAction = (cli, batchCmd, opts) => async (commandParts, _subOpts, _thisCommand) => {
2890
+ const buildDefaultCmdAction = (plugin, cli, batchCmd, opts) => async (commandParts, _subOpts, _thisCommand) => {
2623
2891
  const loggerLocal = opts.logger ?? console;
2624
2892
  // Guard: when invoked without positional args (e.g., `batch --list`),
2625
2893
  // defer entirely to the parent action handler.
@@ -2631,9 +2899,8 @@ const buildDefaultCmdAction = (cli, batchCmd, opts) => async (commandParts, _sub
2631
2899
  ? argsRaw.filter((t) => t !== '-l' && t !== '--list')
2632
2900
  : argsRaw;
2633
2901
  // Access merged per-plugin config from host context (if any).
2634
- const ctx = cli.getCtx();
2635
- const cfgRaw = (ctx?.pluginConfigs?.['batch'] ?? {});
2636
- const cfg = (cfgRaw || {});
2902
+ const cfg = plugin.readConfig(cli);
2903
+ const dotenvEnv = (cli.getCtx()?.dotenv ?? {});
2637
2904
  // Resolve batch flags from the captured parent (batch) command.
2638
2905
  const raw = batchCmd.opts();
2639
2906
  const listFromParent = !!raw.list;
@@ -2652,6 +2919,7 @@ const buildDefaultCmdAction = (cli, batchCmd, opts) => async (commandParts, _sub
2652
2919
  if (typeof commandOpt === 'string') {
2653
2920
  await execShellCommandBatch({
2654
2921
  command: resolveCommand(scripts, commandOpt),
2922
+ dotenvEnv,
2655
2923
  globs,
2656
2924
  ignoreErrors,
2657
2925
  list: false,
@@ -2663,6 +2931,7 @@ const buildDefaultCmdAction = (cli, batchCmd, opts) => async (commandParts, _sub
2663
2931
  return;
2664
2932
  }
2665
2933
  if (raw.list || localList) {
2934
+ const shellBag = ((batchCmd.parent ?? undefined)?.getDotenvCliOptions ?? {});
2666
2935
  await execShellCommandBatch({
2667
2936
  globs,
2668
2937
  ignoreErrors,
@@ -2670,7 +2939,7 @@ const buildDefaultCmdAction = (cli, batchCmd, opts) => async (commandParts, _sub
2670
2939
  logger: loggerLocal,
2671
2940
  ...(pkgCwd ? { pkgCwd } : {}),
2672
2941
  rootPath,
2673
- shell: (shell ?? false),
2942
+ shell: shell ?? shellBag.shell ?? false,
2674
2943
  });
2675
2944
  return;
2676
2945
  }
@@ -2694,7 +2963,7 @@ const buildDefaultCmdAction = (cli, batchCmd, opts) => async (commandParts, _sub
2694
2963
  logger: loggerLocal,
2695
2964
  ...(pkgCwd ? { pkgCwd } : {}),
2696
2965
  rootPath,
2697
- shell: (shell ?? shellBag.shell ?? false),
2966
+ shell: shell ?? shellBag.shell ?? false,
2698
2967
  });
2699
2968
  return;
2700
2969
  }
@@ -2737,6 +3006,7 @@ const buildDefaultCmdAction = (cli, batchCmd, opts) => async (commandParts, _sub
2737
3006
  }
2738
3007
  await execShellCommandBatch({
2739
3008
  command: commandArg,
3009
+ dotenvEnv,
2740
3010
  ...(envBag ? { getDotenvCliOptions: envBag } : {}),
2741
3011
  globs,
2742
3012
  ignoreErrors,
@@ -2751,12 +3021,11 @@ const buildDefaultCmdAction = (cli, batchCmd, opts) => async (commandParts, _sub
2751
3021
  /**
2752
3022
  * Build the parent "batch" action handler (no explicit subcommand).
2753
3023
  */
2754
- const buildParentAction = (cli, opts) => async (commandParts, thisCommand) => {
2755
- const logger = opts.logger ?? console;
3024
+ const buildParentAction = (plugin, cli, opts) => async (commandParts, thisCommand) => {
3025
+ const loggerLocal = opts.logger ?? console;
2756
3026
  // Ensure context exists (host preSubcommand on root creates if missing).
2757
- const ctx = cli.getCtx();
2758
- const cfgRaw = (ctx?.pluginConfigs?.['batch'] ?? {});
2759
- const cfg = (cfgRaw || {});
3027
+ const dotenvEnv = (cli.getCtx()?.dotenv ?? {});
3028
+ const cfg = plugin.readConfig(cli);
2760
3029
  const raw = thisCommand.opts();
2761
3030
  const commandOpt = typeof raw.command === 'string' ? raw.command : undefined;
2762
3031
  const ignoreErrors = !!raw.ignoreErrors;
@@ -2777,10 +3046,11 @@ const buildParentAction = (cli, opts) => async (commandParts, thisCommand) => {
2777
3046
  const commandArg = resolved;
2778
3047
  await execShellCommandBatch({
2779
3048
  command: commandArg,
3049
+ dotenvEnv,
2780
3050
  globs,
2781
3051
  ignoreErrors,
2782
3052
  list: false,
2783
- logger,
3053
+ logger: loggerLocal,
2784
3054
  ...(pkgCwd ? { pkgCwd } : {}),
2785
3055
  rootPath,
2786
3056
  shell: shellSetting,
@@ -2793,19 +3063,20 @@ const buildParentAction = (cli, opts) => async (commandParts, thisCommand) => {
2793
3063
  if (extra.length > 0)
2794
3064
  globs = [globs, extra].filter(Boolean).join(' ');
2795
3065
  const mergedBag = ((thisCommand.parent ?? null)?.getDotenvCliOptions ?? {});
3066
+ const shellMerged = opts.shell ?? cfg.shell ?? mergedBag.shell ?? false;
2796
3067
  await execShellCommandBatch({
2797
3068
  globs,
2798
3069
  ignoreErrors,
2799
3070
  list: true,
2800
- logger,
3071
+ logger: loggerLocal,
2801
3072
  ...(pkgCwd ? { pkgCwd } : {}),
2802
3073
  rootPath,
2803
- shell: (opts.shell ?? cfg.shell ?? mergedBag.shell ?? false),
3074
+ shell: shellMerged,
2804
3075
  });
2805
3076
  return;
2806
3077
  }
2807
3078
  if (!commandOpt && !list) {
2808
- logger.error(`No command provided. Use --command or --list.`);
3079
+ loggerLocal.error(`No command provided. Use --command or --list.`);
2809
3080
  process.exit(0);
2810
3081
  }
2811
3082
  if (typeof commandOpt === 'string') {
@@ -2814,10 +3085,11 @@ const buildParentAction = (cli, opts) => async (commandParts, thisCommand) => {
2814
3085
  const shellOpt = opts.shell ?? cfg.shell ?? mergedBag.shell;
2815
3086
  await execShellCommandBatch({
2816
3087
  command: resolveCommand(scriptsOpt, commandOpt),
3088
+ dotenvEnv,
2817
3089
  globs,
2818
3090
  ignoreErrors,
2819
3091
  list,
2820
- logger,
3092
+ logger: loggerLocal,
2821
3093
  ...(pkgCwd ? { pkgCwd } : {}),
2822
3094
  rootPath,
2823
3095
  shell: resolveShell(scriptsOpt, commandOpt, shellOpt),
@@ -2826,15 +3098,15 @@ const buildParentAction = (cli, opts) => async (commandParts, thisCommand) => {
2826
3098
  }
2827
3099
  // list only (explicit --list without --command)
2828
3100
  const mergedBag = ((thisCommand.parent ?? null)?.getDotenvCliOptions ?? {});
2829
- const shellOnly = (opts.shell ?? cfg.shell ?? mergedBag.shell ?? false);
3101
+ const shellOnly = opts.shell ?? cfg.shell ?? mergedBag.shell ?? false;
2830
3102
  await execShellCommandBatch({
2831
3103
  globs,
2832
3104
  ignoreErrors,
2833
3105
  list: true,
2834
- logger,
3106
+ logger: loggerLocal,
2835
3107
  ...(pkgCwd ? { pkgCwd } : {}),
2836
3108
  rootPath,
2837
- shell: (shellOnly ?? false),
3109
+ shell: shellOnly,
2838
3110
  });
2839
3111
  };
2840
3112
 
@@ -2857,53 +3129,277 @@ const BatchConfigSchema = z.object({
2857
3129
  /**
2858
3130
  * Batch plugin for the GetDotenv CLI host.
2859
3131
  *
2860
- * Mirrors the legacy batch subcommand behavior without altering the shipped CLI. * Options:
3132
+ * Mirrors the legacy batch subcommand behavior without altering the shipped CLI.
3133
+ * Options:
2861
3134
  * - scripts/shell: used to resolve command and shell behavior per script or global default.
2862
3135
  * - logger: defaults to console.
2863
3136
  */
2864
- const batchPlugin = (opts = {}) => definePlugin({
2865
- id: 'batch',
2866
- // Host validates this when config-loader is enabled; plugins may also
2867
- // re-validate at action time as a safety belt.
2868
- configSchema: BatchConfigSchema,
2869
- setup(cli) {
2870
- const ns = cli.ns('batch');
2871
- const batchCmd = ns; // capture the parent "batch" command for default-subcommand context
2872
- ns.description('Batch command execution across multiple working directories.')
2873
- .enablePositionalOptions()
2874
- .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', '*')
2878
- .option('-c, --command <string>', 'command executed according to the base shell resolution')
2879
- .option('-l, --list', 'list working directories without executing command')
2880
- .option('-e, --ignore-errors', 'ignore errors and continue with next path')
2881
- .argument('[command...]')
2882
- .addCommand(new Command()
2883
- .name('cmd')
2884
- .description('execute command, conflicts with --command option (default subcommand)')
2885
- .enablePositionalOptions()
2886
- .passThroughOptions()
2887
- .argument('[command...]')
2888
- .action(buildDefaultCmdAction(cli, batchCmd, opts)), { isDefault: true })
2889
- .action(buildParentAction(cli, opts));
2890
- },
2891
- });
3137
+ const batchPlugin = (opts = {}) => {
3138
+ const plugin = definePlugin({
3139
+ id: 'batch',
3140
+ // Host validates this when config-loader is enabled; plugins may also
3141
+ // re-validate at action time as a safety belt.
3142
+ configSchema: BatchConfigSchema,
3143
+ setup(cli) {
3144
+ const ns = cli.ns('batch');
3145
+ const batchCmd = ns; // capture the parent "batch" command for default-subcommand context
3146
+ const pluginId = 'batch';
3147
+ const GROUP = `plugin:${pluginId}`;
3148
+ ns.description('Batch command execution across multiple working directories.')
3149
+ .enablePositionalOptions()
3150
+ .passThroughOptions()
3151
+ // Dynamic help: show effective defaults from the merged/interpolated plugin config slice.
3152
+ .addOption((() => {
3153
+ const opt = plugin.createPluginDynamicOption(cli, '-p, --pkg-cwd', (_bag, cfg) => `use nearest package directory as current working directory${cfg.pkgCwd ? ' (default)' : ''}`);
3154
+ cli.setOptionGroup(opt, GROUP);
3155
+ return opt;
3156
+ })())
3157
+ .addOption((() => {
3158
+ const opt = plugin.createPluginDynamicOption(cli, '-r, --root-path <string>', (_bag, cfg) => `path to batch root directory from current working directory (default: ${JSON.stringify(cfg.rootPath || './')})`);
3159
+ cli.setOptionGroup(opt, GROUP);
3160
+ return opt;
3161
+ })())
3162
+ .addOption((() => {
3163
+ const opt = plugin.createPluginDynamicOption(cli, '-g, --globs <string>', (_bag, cfg) => `space-delimited globs from root path (default: ${JSON.stringify(cfg.globs || '*')})`);
3164
+ cli.setOptionGroup(opt, GROUP);
3165
+ return opt;
3166
+ })())
3167
+ .option('-c, --command <string>', 'command executed according to the base shell resolution')
3168
+ .option('-l, --list', 'list working directories without executing command')
3169
+ .option('-e, --ignore-errors', 'ignore errors and continue with next path')
3170
+ .argument('[command...]')
3171
+ .addCommand(new Command()
3172
+ .name('cmd')
3173
+ .description('execute command, conflicts with --command option (default subcommand)')
3174
+ .enablePositionalOptions()
3175
+ .passThroughOptions()
3176
+ .argument('[command...]')
3177
+ .action(buildDefaultCmdAction(plugin, cli, batchCmd, opts)), { isDefault: true })
3178
+ .action(buildParentAction(plugin, cli, opts));
3179
+ },
3180
+ });
3181
+ return plugin;
3182
+ };
2892
3183
 
2893
3184
  const dbg = (...args) => {
2894
3185
  if (process.env.GETDOTENV_DEBUG) {
2895
3186
  // Use stderr to avoid interfering with stdout assertions
2896
3187
  console.error('[getdotenv:alias]', ...args);
2897
3188
  }
2898
- };
3189
+ };
3190
+ // Strip one symmetric outer quote layer
3191
+ const stripOne = (s) => {
3192
+ if (s.length < 2)
3193
+ return s;
3194
+ const a = s.charAt(0);
3195
+ const b = s.charAt(s.length - 1);
3196
+ const symmetric = (a === '"' && b === '"') || (a === "'" && b === "'");
3197
+ return symmetric ? s.slice(1, -1) : s;
3198
+ };
3199
+ async function maybeRunAlias(cli, thisCommand, aliasKey, state) {
3200
+ dbg('alias:maybe:start');
3201
+ const raw = thisCommand.rawArgs ?? [];
3202
+ const childNames = thisCommand.commands.flatMap((c) => [
3203
+ c.name(),
3204
+ ...c.aliases(),
3205
+ ]);
3206
+ const hasSub = childNames.some((n) => raw.includes(n));
3207
+ const o = thisCommand.opts();
3208
+ const val = o[aliasKey];
3209
+ const provided = typeof val === 'string'
3210
+ ? val.length > 0
3211
+ : Array.isArray(val)
3212
+ ? val.length > 0
3213
+ : false;
3214
+ if (!provided || hasSub) {
3215
+ dbg('alias:maybe:skip', { provided, hasSub });
3216
+ return; // not an alias-only invocation
3217
+ }
3218
+ if (state.handled) {
3219
+ dbg('alias:maybe:already-handled');
3220
+ return;
3221
+ }
3222
+ state.handled = true;
3223
+ dbg('alias-only invocation detected');
3224
+ // Merge CLI options and resolve dotenv context.
3225
+ const { merged } = resolveCliOptions(o, baseGetDotenvCliOptions, process.env.getDotenvCliOptions);
3226
+ const mergedBag = merged;
3227
+ const logger = (mergedBag.logger ?? console);
3228
+ const serviceOptions = getDotenvCliOptions2Options(mergedBag);
3229
+ await cli.resolveAndLoad(serviceOptions);
3230
+ // Normalize alias value
3231
+ const joined = typeof val === 'string'
3232
+ ? val
3233
+ : Array.isArray(val)
3234
+ ? val.map(String).join(' ')
3235
+ : '';
3236
+ const expanded = dotenvExpandFromProcessEnv(joined);
3237
+ const input = mergedBag.expand === false
3238
+ ? joined
3239
+ : expanded !== undefined
3240
+ ? expanded
3241
+ : joined;
3242
+ // Scripts: prefer well-formed records; tolerate absent/bad shapes
3243
+ const maybeScripts = mergedBag.scripts;
3244
+ const scripts = maybeScripts && typeof maybeScripts === 'object'
3245
+ ? maybeScripts
3246
+ : undefined;
3247
+ const resolved = resolveCommand(scripts, input);
3248
+ if (mergedBag.debug) {
3249
+ logger.log('\n*** command ***\n', `'${resolved}'`);
3250
+ }
3251
+ // Round-trip CLI options for nested getdotenv invocations. Omit logger
3252
+ // (functions/circulars) and guard JSON serialization to avoid hard failures.
3253
+ const { logger: _omitLogger, ...envBag } = mergedBag;
3254
+ let nestedBag;
3255
+ try {
3256
+ nestedBag = JSON.stringify(envBag);
3257
+ }
3258
+ catch {
3259
+ nestedBag = undefined;
3260
+ }
3261
+ const underTests = process.env.GETDOTENV_TEST === '1' ||
3262
+ typeof process.env.VITEST_WORKER_ID === 'string';
3263
+ const forceExit = process.env.GETDOTENV_FORCE_EXIT === '1';
3264
+ const capture = !underTests &&
3265
+ (process.env.GETDOTENV_STDIO === 'pipe' ||
3266
+ Boolean(mergedBag.capture));
3267
+ dbg('run:start', {
3268
+ capture,
3269
+ shell: mergedBag.shell,
3270
+ });
3271
+ const ctx = cli.getCtx();
3272
+ const dotenv = (ctx?.dotenv ?? {});
3273
+ // Diagnostics: --trace [keys...]
3274
+ const traceOpt = mergedBag.trace;
3275
+ if (traceOpt) {
3276
+ const parentKeys = Object.keys(process.env);
3277
+ const dotenvKeys = Object.keys(dotenv);
3278
+ const allKeys = Array.from(new Set([...parentKeys, ...dotenvKeys])).sort();
3279
+ const keys = Array.isArray(traceOpt) ? traceOpt : allKeys;
3280
+ const childEnvPreview = {
3281
+ ...process.env,
3282
+ ...dotenv,
3283
+ };
3284
+ for (const k of keys) {
3285
+ const parent = process.env[k];
3286
+ const dot = dotenv[k];
3287
+ const final = childEnvPreview[k];
3288
+ const origin = dot !== undefined
3289
+ ? 'dotenv'
3290
+ : parent !== undefined
3291
+ ? 'parent'
3292
+ : 'unset';
3293
+ const redFlag = mergedBag.redact;
3294
+ const redPatterns = mergedBag.redactPatterns;
3295
+ const redOpts = {};
3296
+ if (redFlag)
3297
+ redOpts.redact = true;
3298
+ if (redFlag && Array.isArray(redPatterns))
3299
+ redOpts.redactPatterns = redPatterns;
3300
+ const tripleBag = {};
3301
+ if (parent !== undefined)
3302
+ tripleBag.parent = parent;
3303
+ if (dot !== undefined)
3304
+ tripleBag.dotenv = dot;
3305
+ if (final !== undefined)
3306
+ tripleBag.final = final;
3307
+ const triple = redactTriple(k, tripleBag, redOpts);
3308
+ process.stderr.write(`[trace] key=${k} origin=${origin} parent=${triple.parent ?? ''} dotenv=${triple.dotenv ?? ''} final=${triple.final ?? ''}\n`);
3309
+ const entOpts = {};
3310
+ const warnEntropy = mergedBag.warnEntropy;
3311
+ const entropyThreshold = mergedBag
3312
+ .entropyThreshold;
3313
+ const entropyMinLength = mergedBag
3314
+ .entropyMinLength;
3315
+ const entropyWhitelist = mergedBag.entropyWhitelist;
3316
+ if (typeof warnEntropy === 'boolean')
3317
+ entOpts.warnEntropy = warnEntropy;
3318
+ if (typeof entropyThreshold === 'number')
3319
+ entOpts.entropyThreshold = entropyThreshold;
3320
+ if (typeof entropyMinLength === 'number')
3321
+ entOpts.entropyMinLength = entropyMinLength;
3322
+ if (Array.isArray(entropyWhitelist))
3323
+ entOpts.entropyWhitelist = entropyWhitelist;
3324
+ maybeWarnEntropy(k, final, origin, entOpts, (line) => process.stderr.write(line + '\n'));
3325
+ }
3326
+ }
3327
+ const shellSetting = resolveShell(scripts, input, mergedBag.shell);
3328
+ // Preserve argv array for Node -e snippets under shell-off
3329
+ let commandArg = resolved;
3330
+ if (shellSetting === false && resolved === input) {
3331
+ // Important: preserve doubled quotes within the Node -e payload so
3332
+ // empty string literals ("") survive; Windows-style doubling must not
3333
+ // collapse "" -> " in this path.
3334
+ const parts = tokenize(input, { preserveDoubledQuotes: true });
3335
+ if (parts.length >= 3 &&
3336
+ parts[0]?.toLowerCase() === 'node' &&
3337
+ (parts[1] === '-e' || parts[1] === '--eval')) {
3338
+ // Peel exactly one symmetric outer quote on the code arg
3339
+ parts[2] = stripOne(parts[2] ?? '');
3340
+ // Historical behavior: pass the argv array through unchanged for shell-off.
3341
+ commandArg = parts;
3342
+ }
3343
+ }
3344
+ let exitCode = Number.NaN;
3345
+ try {
3346
+ exitCode = await runCommand(commandArg, shellSetting, {
3347
+ env: buildSpawnEnv(process.env, nestedBag
3348
+ ? {
3349
+ ...dotenv,
3350
+ getDotenvCliOptions: nestedBag,
3351
+ }
3352
+ : {
3353
+ ...dotenv,
3354
+ }),
3355
+ stdio: capture ? 'pipe' : 'inherit',
3356
+ });
3357
+ dbg('run:done', { exitCode });
3358
+ }
3359
+ catch (err) {
3360
+ const code = typeof err.exitCode === 'number'
3361
+ ? err.exitCode
3362
+ : 1;
3363
+ dbg('run:error', { exitCode: code, error: String(err) });
3364
+ if (!underTests) {
3365
+ dbg('process.exit (error path)', { exitCode: code });
3366
+ process.exit(code);
3367
+ }
3368
+ else {
3369
+ dbg('process.exit suppressed for tests (error path)', {
3370
+ exitCode: code,
3371
+ });
3372
+ }
3373
+ return;
3374
+ }
3375
+ if (!Number.isNaN(exitCode)) {
3376
+ dbg('process.exit', { exitCode });
3377
+ process.exit(exitCode);
3378
+ }
3379
+ if (!underTests) {
3380
+ dbg('process.exit (fallback: non-numeric exitCode)', { exitCode: 0 });
3381
+ process.exit(0);
3382
+ }
3383
+ else {
3384
+ dbg('process.exit (fallback suppressed for tests: non-numeric exitCode)', {
3385
+ exitCode: 0,
3386
+ });
3387
+ }
3388
+ if (forceExit) {
3389
+ setImmediate(() => process.exit(Number.isNaN(exitCode) ? 0 : exitCode));
3390
+ }
3391
+ }
3392
+
2899
3393
  const attachParentAlias = (cli, options, _cmd) => {
2900
3394
  const aliasSpec = typeof options.optionAlias === 'string'
2901
- ? { flags: options.optionAlias, description: undefined, expand: true }
3395
+ ? { flags: options.optionAlias, description: undefined}
2902
3396
  : options.optionAlias;
2903
3397
  if (!aliasSpec)
2904
3398
  return;
2905
3399
  const deriveKey = (flags) => {
2906
- dbg('install alias option', flags);
3400
+ if (process.env.GETDOTENV_DEBUG) {
3401
+ console.error('[getdotenv:alias] install alias option', flags);
3402
+ }
2907
3403
  const long = flags.split(/[ ,|]+/).find((f) => f.startsWith('--')) ?? '--cmd';
2908
3404
  const name = long.replace(/^--/, '');
2909
3405
  return name.replace(/-([a-z])/g, (_m, c) => c.toUpperCase());
@@ -2913,256 +3409,24 @@ const attachParentAlias = (cli, options, _cmd) => {
2913
3409
  const desc = aliasSpec.description ??
2914
3410
  'alias of cmd subcommand; provide command tokens (variadic)';
2915
3411
  cli.option(aliasSpec.flags, desc);
2916
- // Tag the just-added parent option for grouped help rendering.
2917
- try {
2918
- const optsArr = cli.options;
2919
- if (Array.isArray(optsArr) && optsArr.length > 0) {
2920
- const last = optsArr[optsArr.length - 1];
2921
- last.__group = 'plugin:cmd';
2922
- }
2923
- }
2924
- catch {
2925
- /* noop */
3412
+ // Tag the just-added parent option for grouped help rendering at the root.
3413
+ const optsArr = cli.options;
3414
+ if (optsArr.length > 0) {
3415
+ const last = optsArr[optsArr.length - 1];
3416
+ if (last)
3417
+ cli.setOptionGroup(last, 'plugin:cmd');
2926
3418
  }
2927
3419
  // Shared alias executor for either preAction or preSubcommand hooks.
2928
3420
  // Ensure we only execute once even if both hooks fire in a single parse.
2929
- let aliasHandled = false;
2930
- const maybeRunAlias = async (thisCommand) => {
2931
- dbg('alias:maybe:start');
2932
- const raw = thisCommand.rawArgs ?? [];
2933
- const childNames = thisCommand.commands.flatMap((c) => [
2934
- c.name(),
2935
- ...c.aliases(),
2936
- ]);
2937
- const hasSub = childNames.some((n) => raw.includes(n));
2938
- // Read alias value from parent opts.
2939
- const o = thisCommand.opts();
2940
- const val = o[aliasKey];
2941
- const provided = typeof val === 'string'
2942
- ? val.length > 0
2943
- : Array.isArray(val)
2944
- ? val.length > 0
2945
- : false;
2946
- if (!provided || hasSub) {
2947
- dbg('alias:maybe:skip', { provided, hasSub });
2948
- return; // not an alias-only invocation
2949
- }
2950
- if (aliasHandled) {
2951
- dbg('alias:maybe:already-handled');
2952
- return;
2953
- }
2954
- aliasHandled = true;
2955
- dbg('alias-only invocation detected');
2956
- // Merge CLI options and resolve dotenv context.
2957
- const { merged } = resolveCliOptions(o,
2958
- // cast through unknown to avoid readonly -> mutable incompatibilities
2959
- baseRootOptionDefaults, process.env.getDotenvCliOptions);
2960
- const logger = merged.logger ?? console;
2961
- const serviceOptions = getDotenvCliOptions2Options(merged);
2962
- await cli.resolveAndLoad(serviceOptions);
2963
- // Normalize alias value.
2964
- const joined = typeof val === 'string'
2965
- ? val
2966
- : Array.isArray(val)
2967
- ? val.map(String).join(' ')
2968
- : '';
2969
- const input = aliasSpec.expand === false
2970
- ? joined
2971
- : (dotenvExpandFromProcessEnv(joined) ?? joined);
2972
- dbg('resolved input', { input });
2973
- const resolved = resolveCommand(merged.scripts, input);
2974
- const lg = logger;
2975
- if (merged.debug) {
2976
- (lg.debug ?? lg.log)('\n*** command ***\n', `'${resolved}'`);
2977
- }
2978
- const { logger: _omit, ...envBag } = merged;
2979
- // Test guard: when running under tests, prefer stdio: 'inherit' to avoid
2980
- // assertions depending on captured stdio; ignore GETDOTENV_STDIO/capture.
2981
- const underTests = process.env.GETDOTENV_TEST === '1' ||
2982
- typeof process.env.VITEST_WORKER_ID === 'string';
2983
- const forceExit = process.env.GETDOTENV_FORCE_EXIT === '1';
2984
- const capture = !underTests &&
2985
- (process.env.GETDOTENV_STDIO === 'pipe' ||
2986
- Boolean(merged.capture));
2987
- dbg('run:start', { capture, shell: merged.shell });
2988
- // Prefer explicit env injection: include resolved dotenv map to avoid leaking
2989
- // parent process.env secrets when exclusions are set.
2990
- const ctx = cli.getCtx();
2991
- const dotenv = (ctx?.dotenv ?? {});
2992
- // Diagnostics: --trace [keys...]
2993
- const traceOpt = merged.trace;
2994
- if (traceOpt) {
2995
- const parentKeys = Object.keys(process.env);
2996
- const dotenvKeys = Object.keys(dotenv);
2997
- const allKeys = Array.from(new Set([...parentKeys, ...dotenvKeys])).sort();
2998
- const keys = Array.isArray(traceOpt) ? traceOpt : allKeys;
2999
- const childEnvPreview = {
3000
- ...process.env,
3001
- ...dotenv,
3002
- };
3003
- for (const k of keys) {
3004
- const parent = process.env[k];
3005
- const dot = dotenv[k];
3006
- const final = childEnvPreview[k];
3007
- const origin = dot !== undefined
3008
- ? 'dotenv'
3009
- : parent !== undefined
3010
- ? 'parent'
3011
- : 'unset';
3012
- // Build redact options and triple bag without undefined-valued fields
3013
- const redOpts = {};
3014
- const redFlag = merged.redact;
3015
- const redPatterns = merged
3016
- .redactPatterns;
3017
- if (redFlag)
3018
- redOpts.redact = true;
3019
- if (redFlag && Array.isArray(redPatterns))
3020
- redOpts.redactPatterns = redPatterns;
3021
- const tripleBag = {};
3022
- if (parent !== undefined)
3023
- tripleBag.parent = parent;
3024
- if (dot !== undefined)
3025
- tripleBag.dotenv = dot;
3026
- if (final !== undefined)
3027
- tripleBag.final = final;
3028
- const triple = redactTriple(k, tripleBag, redOpts);
3029
- process.stderr.write(`[trace] key=${k} origin=${origin} parent=${triple.parent ?? ''} dotenv=${triple.dotenv ?? ''} final=${triple.final ?? ''}\n`);
3030
- const entOpts = {};
3031
- const warnEntropy = merged.warnEntropy;
3032
- const entropyThreshold = merged
3033
- .entropyThreshold;
3034
- const entropyMinLength = merged
3035
- .entropyMinLength;
3036
- const entropyWhitelist = merged
3037
- .entropyWhitelist;
3038
- if (typeof warnEntropy === 'boolean')
3039
- entOpts.warnEntropy = warnEntropy;
3040
- if (typeof entropyThreshold === 'number')
3041
- entOpts.entropyThreshold = entropyThreshold;
3042
- if (typeof entropyMinLength === 'number')
3043
- entOpts.entropyMinLength = entropyMinLength;
3044
- if (Array.isArray(entropyWhitelist))
3045
- entOpts.entropyWhitelist = entropyWhitelist;
3046
- maybeWarnEntropy(k, final, origin, entOpts, (line) => process.stderr.write(line + '\n'));
3047
- }
3048
- }
3049
- let exitCode = Number.NaN;
3050
- try {
3051
- // Resolve shell and preserve argv for Node -e snippets under shell-off.
3052
- const shellSetting = resolveShell(merged.scripts, input, merged.shell);
3053
- let commandArg = resolved;
3054
- /** * Special-case: when shell is OFF and no script alias remap occurred
3055
- * (resolved === input), treat a Node eval payload as an argv array to
3056
- * avoid lossy re-tokenization of the code string.
3057
- *
3058
- * Examples handled:
3059
- * "node -e \"console.log(JSON.stringify(...))\""
3060
- * "node --eval 'console.log(...)'"
3061
- *
3062
- * We peel exactly one pair of symmetric outer quotes from the code
3063
- * argument when present; inner quotes remain untouched.
3064
- */
3065
- if (shellSetting === false && resolved === input) {
3066
- // Helper: strip one symmetric outer quote layer
3067
- const stripOne = (s) => {
3068
- if (s.length < 2)
3069
- return s;
3070
- const a = s.charAt(0);
3071
- const b = s.charAt(s.length - 1);
3072
- const symmetric = (a === '"' && b === '"') || (a === "'" && b === "'");
3073
- return symmetric ? s.slice(1, -1) : s;
3074
- };
3075
- // Normalize whole input once for robust matching
3076
- const normalized = stripOne(input.trim());
3077
- // First try a lightweight regex on the normalized string
3078
- const m = /^\s*node\s+(--eval|-e)\s+([\s\S]+)$/i.exec(normalized);
3079
- if (m && typeof m[1] === 'string' && typeof m[2] === 'string') {
3080
- const evalFlag = m[1];
3081
- let codeArg = m[2].trim();
3082
- codeArg = stripOne(codeArg);
3083
- const flag = evalFlag.startsWith('--') ? '--eval' : '-e';
3084
- commandArg = ['node', flag, codeArg];
3085
- }
3086
- else {
3087
- // Fallback: tokenize and detect node -e/--eval form
3088
- const parts = tokenize(input);
3089
- if (parts.length >= 3) {
3090
- // Narrow under noUncheckedIndexedAccess
3091
- const p0 = parts[0];
3092
- const p1 = parts[1];
3093
- if (p0?.toLowerCase() === 'node' &&
3094
- (p1 === '-e' || p1 === '--eval')) {
3095
- commandArg = parts;
3096
- }
3097
- }
3098
- }
3099
- }
3100
- exitCode = await runCommand(commandArg, shellSetting, {
3101
- env: buildSpawnEnv(process.env, {
3102
- ...dotenv,
3103
- getDotenvCliOptions: JSON.stringify(envBag),
3104
- }),
3105
- stdio: capture ? 'pipe' : 'inherit',
3106
- });
3107
- dbg('run:done', { exitCode });
3108
- }
3109
- catch (err) {
3110
- const code = typeof err.exitCode === 'number'
3111
- ? err.exitCode
3112
- : 1;
3113
- dbg('run:error', { exitCode: code, error: String(err) });
3114
- if (!underTests) {
3115
- dbg('process.exit (error path)', { exitCode: code });
3116
- process.exit(code);
3117
- }
3118
- else {
3119
- dbg('process.exit suppressed for tests (error path)', {
3120
- exitCode: code,
3121
- });
3122
- }
3123
- return;
3124
- }
3125
- if (!Number.isNaN(exitCode)) {
3126
- dbg('process.exit', { exitCode });
3127
- process.exit(exitCode);
3128
- }
3129
- // Fallback: Some environments may not surface a numeric exitCode even on success.
3130
- // Always terminate alias-only invocations outside tests to avoid hanging the process,
3131
- // regardless of capture/GETDOTENV_STDIO. Under tests, suppress to keep the runner alive.
3132
- if (!underTests) {
3133
- dbg('process.exit (fallback: non-numeric exitCode)', { exitCode: 0 });
3134
- process.exit(0);
3135
- }
3136
- else {
3137
- dbg('process.exit (fallback suppressed for tests: non-numeric exitCode)', { exitCode: 0 });
3138
- }
3139
- // Optional last-resort guard: force an exit on the next tick when enabled.
3140
- // Intended for diagnosing environments where the process appears to linger
3141
- // despite reaching the success/error handlers above. Disabled under tests.
3142
- if (forceExit) {
3143
- try {
3144
- if (process.env.GETDOTENV_DEBUG_VERBOSE) {
3145
- const getHandles = process._getActiveHandles;
3146
- const handles = typeof getHandles === 'function' ? getHandles() : [];
3147
- dbg('active handles before forced exit', {
3148
- count: Array.isArray(handles) ? handles.length : undefined,
3149
- });
3150
- }
3151
- }
3152
- catch {
3153
- // best-effort only
3154
- }
3155
- const code = Number.isNaN(exitCode) ? 0 : exitCode;
3156
- dbg('process.exit (forced)', { exitCode: code });
3157
- setImmediate(() => process.exit(code));
3158
- }
3421
+ const aliasState = { handled: false };
3422
+ const maybeRun = async (thisCommand) => {
3423
+ await maybeRunAlias(cli, thisCommand, aliasKey, aliasState);
3159
3424
  };
3160
- // Execute alias-only invocations whether the root handles the action // itself (preAction) or Commander routes to a default subcommand (preSubcommand).
3161
3425
  cli.hook('preAction', async (thisCommand, _actionCommand) => {
3162
- await maybeRunAlias(thisCommand);
3426
+ await maybeRun(thisCommand);
3163
3427
  });
3164
3428
  cli.hook('preSubcommand', async (thisCommand) => {
3165
- await maybeRunAlias(thisCommand);
3429
+ await maybeRun(thisCommand);
3166
3430
  });
3167
3431
  };
3168
3432
 
@@ -3184,10 +3448,10 @@ const cmdPlugin = (options = {}) => definePlugin({
3184
3448
  return name.replace(/-([a-z])/g, (_m, c) => c.toUpperCase());
3185
3449
  };
3186
3450
  const aliasKey = aliasSpec ? deriveKey(aliasSpec.flags) : undefined;
3187
- const cmd = new Command()
3188
- .name('cmd')
3451
+ // Create as a GetDotenvCli child so helpInformation includes a trailing blank line.
3452
+ const cmd = cli
3453
+ .createCommand('cmd')
3189
3454
  .description('Execute command according to the --shell option, conflicts with --command option (default subcommand)')
3190
- .configureHelp({ showGlobalOptions: true })
3191
3455
  .enablePositionalOptions()
3192
3456
  .passThroughOptions()
3193
3457
  .argument('[command...]')
@@ -3258,8 +3522,7 @@ const cmdPlugin = (options = {}) => definePlugin({
3258
3522
  : 'unset';
3259
3523
  // Apply presentation-time redaction (if enabled)
3260
3524
  const redFlag = merged.redact;
3261
- const redPatterns = merged
3262
- .redactPatterns;
3525
+ const redPatterns = merged.redactPatterns;
3263
3526
  const redOpts = {};
3264
3527
  if (redFlag)
3265
3528
  redOpts.redact = true;
@@ -3376,10 +3639,9 @@ const demoPlugin = () => definePlugin({
3376
3639
  // Build a minimal node -e payload via argv array (avoid quoting issues).
3377
3640
  const code = `console.log(process.env.${key} ?? "")`;
3378
3641
  const ctx = cli.getCtx();
3379
- const dotenv = (ctx?.dotenv ?? {});
3380
3642
  // Inherit stdio for an interactive demo. Use --capture for CI.
3381
3643
  await runCommand(['node', '-e', code], false, {
3382
- env: { ...process.env, ...dotenv },
3644
+ env: buildSpawnEnv(process.env, ctx?.dotenv),
3383
3645
  stdio: 'inherit',
3384
3646
  });
3385
3647
  });
@@ -3414,22 +3676,24 @@ const demoPlugin = () => definePlugin({
3414
3676
  const shell = resolveShell(bag?.scripts, input, bag?.shell);
3415
3677
  // Compose child env (parent + ctx.dotenv). This mirrors cmd/batch behavior.
3416
3678
  const ctx = cli.getCtx();
3417
- const dotenv = (ctx?.dotenv ?? {});
3418
3679
  await runCommand(resolved, shell, {
3419
- env: { ...process.env, ...dotenv },
3680
+ env: buildSpawnEnv(process.env, ctx?.dotenv),
3420
3681
  stdio: 'inherit',
3421
3682
  });
3422
3683
  });
3423
3684
  },
3424
3685
  /**
3425
3686
  * 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.
3687
+ * For the demo we emit a single breadcrumb only when GETDOTENV_DEBUG is set,
3688
+ * keeping default runs (tests/CI/smoke) quiet.
3427
3689
  */
3428
3690
  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);
3691
+ if (process.env.GETDOTENV_DEBUG) {
3692
+ const keys = Object.keys(ctx.dotenv);
3693
+ if (keys.length > 0) {
3694
+ // Keep noise low; a single-line breadcrumb is sufficient for the demo.
3695
+ console.error('[demo] afterResolve: dotenv keys loaded:', keys.length);
3696
+ }
3433
3697
  }
3434
3698
  },
3435
3699
  });
@@ -3687,402 +3951,39 @@ const initPlugin = (opts = {}) => definePlugin({
3687
3951
  },
3688
3952
  });
3689
3953
 
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
3954
  function createCli(opts = {}) {
4082
3955
  const alias = typeof opts.alias === 'string' && opts.alias.length > 0
4083
3956
  ? opts.alias
4084
3957
  : 'getdotenv';
4085
3958
  const program = new GetDotenvCli(alias);
3959
+ // Normalize Commander output so help prints always end with a blank line.
3960
+ // This keeps E2E assertions (CRLF and >=2 trailing newlines) portable across
3961
+ // runtimes and capture modes without altering Commander internals.
3962
+ const outputCfg = {
3963
+ writeOut(str) {
3964
+ const txt = typeof str === 'string' ? str : '';
3965
+ const hasTwo = /(?:\r?\n){2,}$/.test(txt);
3966
+ const hasOne = /\r?\n$/.test(txt);
3967
+ const out = hasTwo ? txt : hasOne ? txt + '\n' : txt + '\n\n';
3968
+ try {
3969
+ process.stdout.write(out);
3970
+ }
3971
+ catch {
3972
+ /* ignore */
3973
+ }
3974
+ },
3975
+ writeErr(str) {
3976
+ process.stderr.write(str);
3977
+ },
3978
+ };
3979
+ // Apply to root and recursively to subcommands so all help paths are normalized.
3980
+ program.configureOutput(outputCfg);
3981
+ const applyOutputRecursively = (cmd) => {
3982
+ cmd.configureOutput(outputCfg);
3983
+ for (const child of cmd.commands)
3984
+ applyOutputRecursively(child);
3985
+ };
3986
+ applyOutputRecursively(program);
4086
3987
  // Install base root flags and included plugins; resolve context once per run.
4087
3988
  program
4088
3989
  .attachRootOptions({ loadProcess: false })
@@ -4098,19 +3999,68 @@ function createCli(opts = {}) {
4098
3999
  if (underTests) {
4099
4000
  program.exitOverride((err) => {
4100
4001
  const code = err?.code;
4101
- if (code === 'commander.helpDisplayed' || code === 'commander.version')
4002
+ // Commander printed help already; ensure a trailing blank line for tests/CI capture.
4003
+ if (code === 'commander.helpDisplayed') {
4004
+ try {
4005
+ process.stdout.write('\n');
4006
+ }
4007
+ catch {
4008
+ /* ignore */
4009
+ }
4102
4010
  return;
4011
+ }
4012
+ if (code === 'commander.version') {
4013
+ return;
4014
+ }
4103
4015
  throw err;
4104
4016
  });
4105
4017
  }
4106
4018
  return {
4107
4019
  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;
4020
+ // Help handling:
4021
+ // - Short-circuit ONLY for true top-level -h/--help (no subcommand before flag).
4022
+ // - If a subcommand token appears before -h/--help, defer to Commander
4023
+ // to render that subcommand's help.
4024
+ const helpIdx = argv.findIndex((a) => a === '-h' || a === '--help');
4025
+ if (helpIdx >= 0) {
4026
+ // Build a set of known subcommand names/aliases on the root.
4027
+ const subs = new Set();
4028
+ for (const c of program.commands) {
4029
+ subs.add(c.name());
4030
+ for (const a of c.aliases())
4031
+ subs.add(a);
4032
+ }
4033
+ const hasSubBeforeHelp = argv
4034
+ .slice(0, helpIdx)
4035
+ .some((tok) => subs.has(tok));
4036
+ if (!hasSubBeforeHelp) {
4037
+ await program.brand({
4038
+ name: alias,
4039
+ importMetaUrl: import.meta.url,
4040
+ description: 'Base CLI.',
4041
+ ...(typeof opts.branding === 'string' && opts.branding.length > 0
4042
+ ? { helpHeader: opts.branding }
4043
+ : {}),
4044
+ });
4045
+ // Resolve context once without side effects for help rendering.
4046
+ const ctx = await program.resolveAndLoad({
4047
+ loadProcess: false,
4048
+ log: false,
4049
+ }, { runAfterResolve: false });
4050
+ // Build a defaults-only merged CLI bag for help-time parity (no side effects).
4051
+ const { merged: defaultsMerged } = resolveCliOptions({}, baseRootOptionDefaults, undefined);
4052
+ const helpCfg = toHelpConfig(defaultsMerged, ctx.pluginConfigs ?? {});
4053
+ program.evaluateDynamicOptions(helpCfg);
4054
+ // Suppress output only during unit tests; allow E2E to capture.
4055
+ const piping = process.env.GETDOTENV_STDIO === 'pipe' ||
4056
+ process.env.GETDOTENV_STDOUT === 'pipe';
4057
+ if (!(underTests && !piping)) {
4058
+ program.outputHelp();
4059
+ }
4060
+ return;
4061
+ }
4062
+ // Subcommand token exists before -h: fall through to normal parsing,
4063
+ // letting Commander print that subcommand's help.
4114
4064
  }
4115
4065
  await program.brand({
4116
4066
  name: alias,
@@ -4125,4 +4075,4 @@ function createCli(opts = {}) {
4125
4075
  };
4126
4076
  }
4127
4077
 
4128
- export { buildSpawnEnv, createCli, defineDynamic, dotenvExpand, dotenvExpandAll, dotenvExpandFromProcessEnv, generateGetDotenvCli, getDotenv, getDotenvCliOptions2Options, interpolateDeep };
4078
+ export { GetDotenvCli, buildSpawnEnv, createCli, defineDynamic, defineGetDotenvConfig, definePlugin, defineScripts, dotenvExpand, dotenvExpandAll, dotenvExpandFromProcessEnv, getDotenv, getDotenvCliOptions2Options, interpolateDeep, readMergedOptions };