@karmaniverous/get-dotenv 5.2.5 → 6.0.0-0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +63 -67
  2. package/dist/cliHost.cjs +765 -549
  3. package/dist/cliHost.d.cts +128 -84
  4. package/dist/cliHost.d.mts +128 -84
  5. package/dist/cliHost.d.ts +128 -84
  6. package/dist/cliHost.mjs +765 -549
  7. package/dist/getdotenv.cli.mjs +915 -685
  8. package/dist/index.cjs +959 -1006
  9. package/dist/index.d.cts +18 -178
  10. package/dist/index.d.mts +18 -178
  11. package/dist/index.d.ts +18 -178
  12. package/dist/index.mjs +960 -1006
  13. package/dist/plugins-aws.cjs +0 -1
  14. package/dist/plugins-aws.d.cts +8 -78
  15. package/dist/plugins-aws.d.mts +8 -78
  16. package/dist/plugins-aws.d.ts +8 -78
  17. package/dist/plugins-aws.mjs +0 -1
  18. package/dist/plugins-batch.cjs +53 -11
  19. package/dist/plugins-batch.d.cts +10 -79
  20. package/dist/plugins-batch.d.mts +10 -79
  21. package/dist/plugins-batch.d.ts +10 -79
  22. package/dist/plugins-batch.mjs +53 -11
  23. package/dist/plugins-cmd.cjs +162 -1555
  24. package/dist/plugins-cmd.d.cts +8 -78
  25. package/dist/plugins-cmd.d.mts +8 -78
  26. package/dist/plugins-cmd.d.ts +8 -78
  27. package/dist/plugins-cmd.mjs +162 -1554
  28. package/dist/plugins-demo.cjs +52 -7
  29. package/dist/plugins-demo.d.cts +8 -78
  30. package/dist/plugins-demo.d.mts +8 -78
  31. package/dist/plugins-demo.d.ts +8 -78
  32. package/dist/plugins-demo.mjs +52 -7
  33. package/dist/plugins-init.d.cts +8 -78
  34. package/dist/plugins-init.d.mts +8 -78
  35. package/dist/plugins-init.d.ts +8 -78
  36. package/dist/plugins.cjs +283 -1630
  37. package/dist/plugins.d.cts +10 -79
  38. package/dist/plugins.d.mts +10 -79
  39. package/dist/plugins.d.ts +10 -79
  40. package/dist/plugins.mjs +285 -1631
  41. package/package.json +4 -2
@@ -1,11 +1,11 @@
1
1
  #!/usr/bin/env node
2
- import { Command, Option } from 'commander';
3
2
  import fs from 'fs-extra';
4
3
  import { packageDirectory } from 'package-directory';
5
- import url, { fileURLToPath, pathToFileURL } from 'url';
6
4
  import path, { join, extname } from 'path';
7
- import { z } from 'zod';
5
+ import url, { fileURLToPath, pathToFileURL } from 'url';
8
6
  import YAML from 'yaml';
7
+ import { z } from 'zod';
8
+ import { Option, Command } from 'commander';
9
9
  import { nanoid } from 'nanoid';
10
10
  import { parse } from 'dotenv';
11
11
  import { createHash } from 'crypto';
@@ -41,8 +41,6 @@ const baseRootOptionDefaults = {
41
41
  // (debug/log/exclude* resolved via flag utils)
42
42
  };
43
43
 
44
- const baseGetDotenvCliOptions = baseRootOptionDefaults;
45
-
46
44
  /** @internal */
47
45
  const isPlainObject$1 = (value) => value !== null &&
48
46
  typeof value === 'object' &&
@@ -85,134 +83,130 @@ const defaultsDeep = (...layers) => {
85
83
  return result;
86
84
  };
87
85
 
88
- // src/GetDotenvOptions.ts
89
- const getDotenvOptionsFilename = 'getdotenv.config.json';
90
86
  /**
91
- * Converts programmatic CLI options to `getDotenv` options. *
92
- * @param cliOptions - CLI options. Defaults to `{}`.
87
+ * Resolve a tri-state optional boolean flag under exactOptionalPropertyTypes.
88
+ * - If the user explicitly enabled the flag, return true.
89
+ * - If the user explicitly disabled (the "...-off" variant), return undefined (unset).
90
+ * - Otherwise, adopt the default (true → set; false/undefined → unset).
93
91
  *
94
- * @returns `getDotenv` options.
92
+ * @param exclude - The "on" flag value as parsed by Commander.
93
+ * @param excludeOff - The "off" toggle (present when specified) as parsed by Commander.
94
+ * @param defaultValue - The generator default to adopt when no explicit toggle is present.
95
+ * @returns boolean | undefined — use `undefined` to indicate "unset" (do not emit).
96
+ *
97
+ * @example
98
+ * ```ts
99
+ * resolveExclusion(undefined, undefined, true); // => true
100
+ * ```
95
101
  */
96
- const getDotenvCliOptions2Options = ({ paths, pathsDelimiter, pathsDelimiterPattern, vars, varsAssignor, varsAssignorPattern, varsDelimiter, varsDelimiterPattern, ...rest }) => {
97
- /**
98
- * Convert CLI-facing string options into {@link GetDotenvOptions}.
99
- *
100
- * - 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`
101
- * pairs (configurable delimiters) into a {@link ProcessEnv}.
102
- * - Drops CLI-only keys that have no programmatic equivalent.
103
- *
104
- * @remarks
105
- * Follows exact-optional semantics by not emitting undefined-valued entries.
106
- */
107
- // Drop CLI-only keys (debug/scripts) without relying on Record casts.
108
- // Create a shallow copy then delete optional CLI-only keys if present.
109
- const restObj = { ...rest };
110
- delete restObj.debug;
111
- delete restObj.scripts;
112
- const splitBy = (value, delim, pattern) => (value ? value.split(pattern ? RegExp(pattern) : (delim ?? ' ')) : []);
113
- // Tolerate vars as either a CLI string ("A=1 B=2") or an object map.
114
- let parsedVars;
115
- if (typeof vars === 'string') {
116
- const kvPairs = splitBy(vars, varsDelimiter, varsDelimiterPattern).map((v) => v.split(varsAssignorPattern
117
- ? RegExp(varsAssignorPattern)
118
- : (varsAssignor ?? '=')));
119
- parsedVars = Object.fromEntries(kvPairs);
120
- }
121
- else if (vars && typeof vars === 'object' && !Array.isArray(vars)) {
122
- // Keep only string or undefined values to match ProcessEnv.
123
- const entries = Object.entries(vars).filter(([k, v]) => typeof k === 'string' && (typeof v === 'string' || v === undefined));
124
- parsedVars = Object.fromEntries(entries);
125
- }
126
- // Drop undefined-valued entries at the converter stage to match ProcessEnv
127
- // expectations and the compat test assertions.
128
- if (parsedVars) {
129
- parsedVars = Object.fromEntries(Object.entries(parsedVars).filter(([, v]) => v !== undefined));
130
- }
131
- // Tolerate paths as either a delimited string or string[]
132
- // Use a locally cast union type to avoid lint warnings about always-falsy conditions
133
- // under the RootOptionsShape (which declares paths as string | undefined).
134
- const pathsAny = paths;
135
- const pathsOut = Array.isArray(pathsAny)
136
- ? pathsAny.filter((p) => typeof p === 'string')
137
- : splitBy(pathsAny, pathsDelimiter, pathsDelimiterPattern);
138
- // Preserve exactOptionalPropertyTypes: only include keys when defined.
139
- return {
140
- ...restObj,
141
- ...(pathsOut.length > 0 ? { paths: pathsOut } : {}),
142
- ...(parsedVars !== undefined ? { vars: parsedVars } : {}),
143
- };
144
- };
145
- const resolveGetDotenvOptions = async (customOptions) => {
146
- /**
147
- * Resolve {@link GetDotenvOptions} by layering defaults in ascending precedence:
148
- *
149
- * 1. Base defaults derived from the CLI generator defaults
150
- * ({@link baseGetDotenvCliOptions}).
151
- * 2. Local project overrides from a `getdotenv.config.json` in the nearest
152
- * package root (if present).
153
- * 3. The provided {@link customOptions}.
154
- *
155
- * The result preserves explicit empty values and drops only `undefined`.
156
- *
157
- * @returns Fully-resolved {@link GetDotenvOptions}.
158
- *
159
- * @example
160
- * ```ts
161
- * const options = await resolveGetDotenvOptions({ env: 'dev' });
162
- * ```
163
- */
164
- const localPkgDir = await packageDirectory();
165
- const localOptionsPath = localPkgDir
166
- ? join(localPkgDir, getDotenvOptionsFilename)
167
- : undefined;
168
- const localOptions = (localOptionsPath && (await fs.exists(localOptionsPath))
169
- ? JSON.parse((await fs.readFile(localOptionsPath)).toString())
170
- : {});
171
- // Merge order: base < local < custom (custom has highest precedence)
172
- const mergedCli = defaultsDeep(baseGetDotenvCliOptions, localOptions);
173
- const defaultsFromCli = getDotenvCliOptions2Options(mergedCli);
174
- const result = defaultsDeep(defaultsFromCli, customOptions);
175
- return {
176
- ...result, // Keep explicit empty strings/zeros; drop only undefined
177
- vars: Object.fromEntries(Object.entries(result.vars ?? {}).filter(([, v]) => v !== undefined)),
178
- };
102
+ const resolveExclusion = (exclude, excludeOff, defaultValue) => exclude ? true : excludeOff ? undefined : defaultValue ? true : undefined;
103
+ /**
104
+ * Resolve an optional flag with "--exclude-all" overrides.
105
+ * If excludeAll is set and the individual "...-off" is not, force true.
106
+ * If excludeAllOff is set and the individual flag is not explicitly set, unset.
107
+ * Otherwise, adopt the default (true set; false/undefined unset).
108
+ *
109
+ * @param exclude - Individual include/exclude flag.
110
+ * @param excludeOff - Individual "...-off" flag.
111
+ * @param defaultValue - Default for the individual flag.
112
+ * @param excludeAll - Global "exclude-all" flag.
113
+ * @param excludeAllOff - Global "exclude-all-off" flag.
114
+ *
115
+ * @example
116
+ * resolveExclusionAll(undefined, undefined, false, true, undefined) =\> true
117
+ */
118
+ const resolveExclusionAll = (exclude, excludeOff, defaultValue, excludeAll, excludeAllOff) =>
119
+ // Order of precedence:
120
+ // 1) Individual explicit "on" wins outright.
121
+ // 2) Individual explicit "off" wins over any global.
122
+ // 3) Global exclude-all forces true when not explicitly turned off.
123
+ // 4) Global exclude-all-off unsets when the individual wasn't explicitly enabled.
124
+ // 5) Fall back to the default (true => set; false/undefined => unset).
125
+ (() => {
126
+ // Individual "on"
127
+ if (exclude === true)
128
+ return true;
129
+ // Individual "off"
130
+ if (excludeOff === true)
131
+ return undefined;
132
+ // Global "exclude-all" ON (unless explicitly turned off)
133
+ if (excludeAll === true)
134
+ return true;
135
+ // Global "exclude-all-off" (unless explicitly enabled)
136
+ if (excludeAllOff === true)
137
+ return undefined;
138
+ // Default
139
+ return defaultValue ? true : undefined;
140
+ })();
141
+ /**
142
+ * exactOptionalPropertyTypes-safe setter for optional boolean flags:
143
+ * delete when undefined; assign when defined — without requiring an index signature on T.
144
+ *
145
+ * @typeParam T - Target object type.
146
+ * @param obj - The object to write to.
147
+ * @param key - The optional boolean property key of {@link T}.
148
+ * @param value - The value to set or `undefined` to unset.
149
+ *
150
+ * @remarks
151
+ * Writes through a local `Record<string, unknown>` view to avoid requiring an index signature on {@link T}.
152
+ */
153
+ const setOptionalFlag = (obj, key, value) => {
154
+ const target = obj;
155
+ const k = key;
156
+ // eslint-disable-next-line @typescript-eslint/no-dynamic-delete
157
+ if (value === undefined)
158
+ delete target[k];
159
+ else
160
+ target[k] = value;
179
161
  };
180
162
 
181
163
  /**
182
- * Zod schemas for programmatic GetDotenv options.
183
- *
184
- * NOTE: These schemas are introduced without wiring to avoid behavior changes.
185
- * Legacy paths continue to use existing types/logic. The new plugin host will
186
- * use these schemas in strict mode; legacy paths will adopt them in warn mode
187
- * later per the staged plan.
164
+ * Merge and normalize raw Commander options (current + parent + defaults)
165
+ * into a GetDotenvCliOptions-like object. Types are intentionally wide to
166
+ * avoid cross-layer coupling; callers may cast as needed.
188
167
  */
189
- // Minimal process env representation: string values or undefined to indicate "unset".
190
- const processEnvSchema = z.record(z.string(), z.string().optional());
191
- // RAW: all fields optional — undefined means "inherit" from lower layers.
192
- const getDotenvOptionsSchemaRaw = z.object({
193
- defaultEnv: z.string().optional(),
194
- dotenvToken: z.string().optional(),
195
- dynamicPath: z.string().optional(),
196
- // Dynamic map is intentionally wide for now; refine once sources are normalized.
197
- dynamic: z.record(z.string(), z.unknown()).optional(),
198
- env: z.string().optional(),
199
- excludeDynamic: z.boolean().optional(),
200
- excludeEnv: z.boolean().optional(),
201
- excludeGlobal: z.boolean().optional(),
202
- excludePrivate: z.boolean().optional(),
203
- excludePublic: z.boolean().optional(),
204
- loadProcess: z.boolean().optional(),
205
- log: z.boolean().optional(),
206
- outputPath: z.string().optional(),
207
- paths: z.array(z.string()).optional(),
208
- privateToken: z.string().optional(),
209
- vars: processEnvSchema.optional(),
210
- // Host-only feature flag: guarded integration of config loader/overlay
211
- useConfigLoader: z.boolean().optional(),
212
- });
213
- // RESOLVED: service-boundary contract (post-inheritance).
214
- // For Step A, keep identical to RAW (no behavior change). Later stages will// materialize required defaults and narrow shapes as resolution is wired.
215
- const getDotenvOptionsSchemaResolved = getDotenvOptionsSchemaRaw;
168
+ const resolveCliOptions = (rawCliOptions, defaults, parentJson) => {
169
+ const parent = typeof parentJson === 'string' && parentJson.length > 0
170
+ ? JSON.parse(parentJson)
171
+ : undefined;
172
+ const { command, debugOff, excludeAll, excludeAllOff, excludeDynamicOff, excludeEnvOff, excludeGlobalOff, excludePrivateOff, excludePublicOff, loadProcessOff, logOff, entropyWarn, entropyWarnOff, scripts, shellOff, ...rest } = rawCliOptions;
173
+ const current = { ...rest };
174
+ if (typeof scripts === 'string') {
175
+ try {
176
+ current.scripts = JSON.parse(scripts);
177
+ }
178
+ catch {
179
+ // ignore parse errors; leave scripts undefined
180
+ }
181
+ }
182
+ const merged = defaultsDeep({}, defaults, parent ?? {}, current);
183
+ const d = defaults;
184
+ setOptionalFlag(merged, 'debug', resolveExclusion(merged.debug, debugOff, d.debug));
185
+ setOptionalFlag(merged, 'excludeDynamic', resolveExclusionAll(merged.excludeDynamic, excludeDynamicOff, d.excludeDynamic, excludeAll, excludeAllOff));
186
+ setOptionalFlag(merged, 'excludeEnv', resolveExclusionAll(merged.excludeEnv, excludeEnvOff, d.excludeEnv, excludeAll, excludeAllOff));
187
+ setOptionalFlag(merged, 'excludeGlobal', resolveExclusionAll(merged.excludeGlobal, excludeGlobalOff, d.excludeGlobal, excludeAll, excludeAllOff));
188
+ setOptionalFlag(merged, 'excludePrivate', resolveExclusionAll(merged.excludePrivate, excludePrivateOff, d.excludePrivate, excludeAll, excludeAllOff));
189
+ setOptionalFlag(merged, 'excludePublic', resolveExclusionAll(merged.excludePublic, excludePublicOff, d.excludePublic, excludeAll, excludeAllOff));
190
+ setOptionalFlag(merged, 'log', resolveExclusion(merged.log, logOff, d.log));
191
+ setOptionalFlag(merged, 'loadProcess', resolveExclusion(merged.loadProcess, loadProcessOff, d.loadProcess));
192
+ // warnEntropy (tri-state)
193
+ setOptionalFlag(merged, 'warnEntropy', resolveExclusion(merged.warnEntropy, entropyWarnOff, d.warnEntropy));
194
+ // Normalize shell for predictability: explicit default shell per OS.
195
+ const defaultShell = process.platform === 'win32' ? 'powershell.exe' : '/bin/bash';
196
+ let resolvedShell = merged.shell;
197
+ if (shellOff)
198
+ resolvedShell = false;
199
+ else if (resolvedShell === true || resolvedShell === undefined) {
200
+ resolvedShell = defaultShell;
201
+ }
202
+ else if (typeof resolvedShell !== 'string' &&
203
+ typeof defaults.shell === 'string') {
204
+ resolvedShell = defaults.shell;
205
+ }
206
+ merged.shell = resolvedShell;
207
+ const cmd = typeof command === 'string' ? command : undefined;
208
+ return cmd !== undefined ? { merged, command: cmd } : { merged };
209
+ };
216
210
 
217
211
  /**
218
212
  * Zod schemas for configuration files discovered by the new loader.
@@ -451,6 +445,169 @@ const resolveGetDotenvConfigSources = async (importMetaUrl) => {
451
445
  return result;
452
446
  };
453
447
 
448
+ /**
449
+ * Validate a composed env against config-provided validation surfaces.
450
+ * Precedence for validation definitions:
451
+ * project.local -\> project.public -\> packaged
452
+ *
453
+ * Behavior:
454
+ * - If a JS/TS `schema` is present, use schema.safeParse(finalEnv).
455
+ * - Else if `requiredKeys` is present, check presence (value !== undefined).
456
+ * - Returns a flat list of issue strings; caller decides warn vs fail.
457
+ */
458
+ const validateEnvAgainstSources = (finalEnv, sources) => {
459
+ const pick = (getter) => {
460
+ const pl = sources.project?.local;
461
+ const pp = sources.project?.public;
462
+ const pk = sources.packaged;
463
+ return ((pl && getter(pl)) ||
464
+ (pp && getter(pp)) ||
465
+ (pk && getter(pk)) ||
466
+ undefined);
467
+ };
468
+ const schema = pick((cfg) => cfg['schema']);
469
+ if (schema &&
470
+ typeof schema.safeParse === 'function') {
471
+ try {
472
+ const parsed = schema.safeParse(finalEnv);
473
+ if (!parsed.success) {
474
+ // Try to render zod-style issues when available.
475
+ const err = parsed.error;
476
+ const issues = Array.isArray(err.issues) && err.issues.length > 0
477
+ ? err.issues.map((i) => {
478
+ const path = Array.isArray(i.path) ? i.path.join('.') : '';
479
+ const msg = i.message ?? 'Invalid value';
480
+ return path ? `[schema] ${path}: ${msg}` : `[schema] ${msg}`;
481
+ })
482
+ : ['[schema] validation failed'];
483
+ return issues;
484
+ }
485
+ return [];
486
+ }
487
+ catch {
488
+ // If schema invocation fails, surface a single diagnostic.
489
+ return [
490
+ '[schema] validation failed (unable to execute schema.safeParse)',
491
+ ];
492
+ }
493
+ }
494
+ const requiredKeys = pick((cfg) => cfg['requiredKeys']);
495
+ if (Array.isArray(requiredKeys) && requiredKeys.length > 0) {
496
+ const missing = requiredKeys.filter((k) => finalEnv[k] === undefined);
497
+ if (missing.length > 0) {
498
+ return missing.map((k) => `[requiredKeys] missing: ${k}`);
499
+ }
500
+ }
501
+ return [];
502
+ };
503
+
504
+ const baseGetDotenvCliOptions = baseRootOptionDefaults;
505
+
506
+ // src/GetDotenvOptions.ts
507
+ const getDotenvOptionsFilename = 'getdotenv.config.json';
508
+ /**
509
+ * Converts programmatic CLI options to `getDotenv` options. *
510
+ * @param cliOptions - CLI options. Defaults to `{}`.
511
+ *
512
+ * @returns `getDotenv` options.
513
+ */
514
+ const getDotenvCliOptions2Options = ({ paths, pathsDelimiter, pathsDelimiterPattern, vars, varsAssignor, varsAssignorPattern, varsDelimiter, varsDelimiterPattern, ...rest }) => {
515
+ /**
516
+ * Convert CLI-facing string options into {@link GetDotenvOptions}.
517
+ *
518
+ * - 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`
519
+ * pairs (configurable delimiters) into a {@link ProcessEnv}.
520
+ * - Drops CLI-only keys that have no programmatic equivalent.
521
+ *
522
+ * @remarks
523
+ * Follows exact-optional semantics by not emitting undefined-valued entries.
524
+ */
525
+ // Drop CLI-only keys (debug/scripts) without relying on Record casts.
526
+ // Create a shallow copy then delete optional CLI-only keys if present.
527
+ const restObj = { ...rest };
528
+ delete restObj.debug;
529
+ delete restObj.scripts;
530
+ const splitBy = (value, delim, pattern) => (value ? value.split(pattern ? RegExp(pattern) : (delim ?? ' ')) : []);
531
+ // Tolerate vars as either a CLI string ("A=1 B=2") or an object map.
532
+ let parsedVars;
533
+ if (typeof vars === 'string') {
534
+ const kvPairs = splitBy(vars, varsDelimiter, varsDelimiterPattern).map((v) => v.split(varsAssignorPattern
535
+ ? RegExp(varsAssignorPattern)
536
+ : (varsAssignor ?? '=')));
537
+ parsedVars = Object.fromEntries(kvPairs);
538
+ }
539
+ else if (vars && typeof vars === 'object' && !Array.isArray(vars)) {
540
+ // Keep only string or undefined values to match ProcessEnv.
541
+ const entries = Object.entries(vars).filter(([k, v]) => typeof k === 'string' && (typeof v === 'string' || v === undefined));
542
+ parsedVars = Object.fromEntries(entries);
543
+ }
544
+ // Drop undefined-valued entries at the converter stage to match ProcessEnv
545
+ // expectations and the compat test assertions.
546
+ if (parsedVars) {
547
+ parsedVars = Object.fromEntries(Object.entries(parsedVars).filter(([, v]) => v !== undefined));
548
+ }
549
+ // Tolerate paths as either a delimited string or string[]
550
+ // Use a locally cast union type to avoid lint warnings about always-falsy conditions
551
+ // under the RootOptionsShape (which declares paths as string | undefined).
552
+ const pathsAny = paths;
553
+ const pathsOut = Array.isArray(pathsAny)
554
+ ? pathsAny.filter((p) => typeof p === 'string')
555
+ : splitBy(pathsAny, pathsDelimiter, pathsDelimiterPattern);
556
+ // Preserve exactOptionalPropertyTypes: only include keys when defined.
557
+ return {
558
+ ...restObj,
559
+ ...(pathsOut.length > 0 ? { paths: pathsOut } : {}),
560
+ ...(parsedVars !== undefined ? { vars: parsedVars } : {}),
561
+ };
562
+ };
563
+ const resolveGetDotenvOptions = async (customOptions) => {
564
+ /**
565
+ * Resolve {@link GetDotenvOptions} by layering defaults in ascending precedence:
566
+ *
567
+ * 1. Base defaults derived from the CLI generator defaults
568
+ * ({@link baseGetDotenvCliOptions}).
569
+ * 2. Local project overrides from a `getdotenv.config.json` in the nearest
570
+ * package root (if present).
571
+ * 3. The provided {@link customOptions}.
572
+ *
573
+ * The result preserves explicit empty values and drops only `undefined`.
574
+ *
575
+ * @returns Fully-resolved {@link GetDotenvOptions}.
576
+ *
577
+ * @example
578
+ * ```ts
579
+ * const options = await resolveGetDotenvOptions({ env: 'dev' });
580
+ * ```
581
+ */
582
+ const localPkgDir = await packageDirectory();
583
+ const localOptionsPath = localPkgDir
584
+ ? join(localPkgDir, getDotenvOptionsFilename)
585
+ : undefined;
586
+ // Safely read local CLI-facing defaults (defensive typing to satisfy strict linting).
587
+ let localOptions = {};
588
+ if (localOptionsPath && (await fs.exists(localOptionsPath))) {
589
+ try {
590
+ const txt = await fs.readFile(localOptionsPath, 'utf-8');
591
+ const parsed = JSON.parse(txt);
592
+ if (parsed && typeof parsed === 'object') {
593
+ localOptions = parsed;
594
+ }
595
+ }
596
+ catch {
597
+ // Malformed or unreadable local options are treated as absent.
598
+ localOptions = {};
599
+ }
600
+ }
601
+ // Merge order: base < local < custom (custom has highest precedence)
602
+ const mergedCli = defaultsDeep(baseGetDotenvCliOptions, localOptions);
603
+ const defaultsFromCli = getDotenvCliOptions2Options(mergedCli);
604
+ const result = defaultsDeep(defaultsFromCli, customOptions);
605
+ return {
606
+ ...result, // Keep explicit empty strings/zeros; drop only undefined
607
+ vars: Object.fromEntries(Object.entries(result.vars ?? {}).filter(([, v]) => v !== undefined)),
608
+ };
609
+ };
610
+
454
611
  /**
455
612
  * Dotenv expansion utilities.
456
613
  *
@@ -571,21 +728,231 @@ const dotenvExpandAll = (values = {}, options = {}) => Object.keys(values).reduc
571
728
  return acc;
572
729
  }, {});
573
730
  /**
574
- * Recursively expands environment variables in a string using `process.env` as
575
- * the expansion reference. Variables may be presented with optional default as
576
- * `$VAR[:default]` or `${VAR[:default]}`. Unknown variables will expand to an
577
- * empty string.
578
- *
579
- * @param value - The string to expand.
580
- * @returns The expanded string.
731
+ * Recursively expands environment variables in a string using `process.env` as
732
+ * the expansion reference. Variables may be presented with optional default as
733
+ * `$VAR[:default]` or `${VAR[:default]}`. Unknown variables will expand to an
734
+ * empty string.
735
+ *
736
+ * @param value - The string to expand.
737
+ * @returns The expanded string.
738
+ *
739
+ * @example
740
+ * ```ts
741
+ * process.env.FOO = 'bar';
742
+ * dotenvExpandFromProcessEnv('Hello $FOO'); // "Hello bar"
743
+ * ```
744
+ */
745
+ const dotenvExpandFromProcessEnv = (value) => dotenvExpand(value, process.env);
746
+
747
+ /* eslint-disable @typescript-eslint/no-deprecated */
748
+ /**
749
+ * Attach root flags to a GetDotenvCli instance.
750
+ * - Host-only: program is typed as GetDotenvCli and supports dynamicOption/createDynamicOption.
751
+ * - Any flag that displays an effective default in help uses dynamic descriptions.
752
+ */
753
+ const attachRootOptions = (program, defaults, opts) => {
754
+ // Install temporary wrappers to tag all options added here as "base" for grouped help.
755
+ const GROUP = 'base';
756
+ const tagLatest = (cmd, group) => {
757
+ const optsArr = cmd.options;
758
+ if (Array.isArray(optsArr) && optsArr.length > 0) {
759
+ const last = optsArr[optsArr.length - 1];
760
+ last.__group = group;
761
+ }
762
+ };
763
+ const originalAddOption = program.addOption.bind(program);
764
+ const originalOption = program.option.bind(program);
765
+ program.addOption = function patchedAdd(opt) {
766
+ opt.__group = GROUP;
767
+ return originalAddOption(opt);
768
+ };
769
+ program.option = function patchedOption(...args) {
770
+ const ret = originalOption(...args);
771
+ tagLatest(this, GROUP);
772
+ return ret;
773
+ };
774
+ const { defaultEnv, dotenvToken, dynamicPath, env, outputPath, paths, pathsDelimiter, pathsDelimiterPattern, privateToken, scripts, varsAssignor, varsAssignorPattern, varsDelimiter, varsDelimiterPattern, } = defaults ?? {};
775
+ const va = typeof defaults?.varsAssignor === 'string' ? defaults.varsAssignor : '=';
776
+ const vd = typeof defaults?.varsDelimiter === 'string' ? defaults.varsDelimiter : ' ';
777
+ // Helper: append (default) tags for ON/OFF toggles
778
+ const onOff = (on, isDefault) => on
779
+ ? `ON${isDefault ? ' (default)' : ''}`
780
+ : `OFF${isDefault ? ' (default)' : ''}`;
781
+ let p = program
782
+ .enablePositionalOptions()
783
+ .passThroughOptions()
784
+ .option('-e, --env <string>', `target environment (dotenv-expanded)`, dotenvExpandFromProcessEnv, env);
785
+ p = p.option('-v, --vars <string>', `extra variables expressed as delimited key-value pairs (dotenv-expanded): ${[
786
+ ['KEY1', 'VAL1'],
787
+ ['KEY2', 'VAL2'],
788
+ ]
789
+ .map((v) => v.join(va))
790
+ .join(vd)}`, dotenvExpandFromProcessEnv);
791
+ if (opts?.includeCommandOption === true) {
792
+ p = p.option('-c, --command <string>', 'command executed according to the --shell option, conflicts with cmd subcommand (dotenv-expanded)', dotenvExpandFromProcessEnv);
793
+ }
794
+ // Output path (interpolated later; help can remain static)
795
+ p = p.option('-o, --output-path <string>', 'consolidated output file (dotenv-expanded)', dotenvExpandFromProcessEnv, outputPath);
796
+ // Shell ON (string or boolean true => default shell)
797
+ p = p
798
+ .addOption(program
799
+ .createDynamicOption('-s, --shell [string]', (cfg) => {
800
+ const s = cfg.shell;
801
+ let tag = '';
802
+ if (typeof s === 'boolean' && s)
803
+ tag = ' (default OS shell)';
804
+ else if (typeof s === 'string' && s.length > 0)
805
+ tag = ` (default ${s})`;
806
+ return `command execution shell, no argument for default OS shell or provide shell string${tag}`;
807
+ })
808
+ .conflicts('shellOff'))
809
+ // Shell OFF
810
+ .addOption(program
811
+ .createDynamicOption('-S, --shell-off', (cfg) => {
812
+ const s = cfg.shell;
813
+ return `command execution shell OFF${s === false ? ' (default)' : ''}`;
814
+ })
815
+ .conflicts('shell'));
816
+ // Load process ON/OFF (dynamic defaults)
817
+ p = p
818
+ .addOption(program
819
+ .createDynamicOption('-p, --load-process', (cfg) => `load variables to process.env ${onOff(true, Boolean(cfg.loadProcess))}`)
820
+ .conflicts('loadProcessOff'))
821
+ .addOption(program
822
+ .createDynamicOption('-P, --load-process-off', (cfg) => `load variables to process.env ${onOff(false, !cfg.loadProcess)}`)
823
+ .conflicts('loadProcess'));
824
+ // Exclusion master toggle (dynamic)
825
+ p = p
826
+ .addOption(program
827
+ .createDynamicOption('-a, --exclude-all', (cfg) => {
828
+ const c = cfg;
829
+ const allOn = !!c.excludeDynamic &&
830
+ ((!!c.excludeEnv && !!c.excludeGlobal) ||
831
+ (!!c.excludePrivate && !!c.excludePublic));
832
+ const suffix = allOn ? ' (default)' : '';
833
+ return `exclude all dotenv variables from loading ON${suffix}`;
834
+ })
835
+ .conflicts('excludeAllOff'))
836
+ .addOption(new Option('-A, --exclude-all-off', `exclude all dotenv variables from loading OFF (default)`).conflicts('excludeAll'));
837
+ // Per-family exclusions (dynamic defaults)
838
+ p = p
839
+ .addOption(program
840
+ .createDynamicOption('-z, --exclude-dynamic', (cfg) => `exclude dynamic dotenv variables from loading ${onOff(true, Boolean(cfg.excludeDynamic))}`)
841
+ .conflicts('excludeDynamicOff'))
842
+ .addOption(program
843
+ .createDynamicOption('-Z, --exclude-dynamic-off', (cfg) => `exclude dynamic dotenv variables from loading ${onOff(false, !cfg.excludeDynamic)}`)
844
+ .conflicts('excludeDynamic'))
845
+ .addOption(program
846
+ .createDynamicOption('-n, --exclude-env', (cfg) => `exclude environment-specific dotenv variables from loading ${onOff(true, Boolean(cfg.excludeEnv))}`)
847
+ .conflicts('excludeEnvOff'))
848
+ .addOption(program
849
+ .createDynamicOption('-N, --exclude-env-off', (cfg) => `exclude environment-specific dotenv variables from loading ${onOff(false, !cfg.excludeEnv)}`)
850
+ .conflicts('excludeEnv'))
851
+ .addOption(program
852
+ .createDynamicOption('-g, --exclude-global', (cfg) => `exclude global dotenv variables from loading ${onOff(true, Boolean(cfg.excludeGlobal))}`)
853
+ .conflicts('excludeGlobalOff'))
854
+ .addOption(program
855
+ .createDynamicOption('-G, --exclude-global-off', (cfg) => `exclude global dotenv variables from loading ${onOff(false, !cfg.excludeGlobal)}`)
856
+ .conflicts('excludeGlobal'))
857
+ .addOption(program
858
+ .createDynamicOption('-r, --exclude-private', (cfg) => `exclude private dotenv variables from loading ${onOff(true, Boolean(cfg.excludePrivate))}`)
859
+ .conflicts('excludePrivateOff'))
860
+ .addOption(program
861
+ .createDynamicOption('-R, --exclude-private-off', (cfg) => `exclude private dotenv variables from loading ${onOff(false, !cfg.excludePrivate)}`)
862
+ .conflicts('excludePrivate'))
863
+ .addOption(program
864
+ .createDynamicOption('-u, --exclude-public', (cfg) => `exclude public dotenv variables from loading ${onOff(true, Boolean(cfg.excludePublic))}`)
865
+ .conflicts('excludePublicOff'))
866
+ .addOption(program
867
+ .createDynamicOption('-U, --exclude-public-off', (cfg) => `exclude public dotenv variables from loading ${onOff(false, !cfg.excludePublic)}`)
868
+ .conflicts('excludePublic'));
869
+ // Log ON/OFF (dynamic)
870
+ p = p
871
+ .addOption(program
872
+ .createDynamicOption('-l, --log', (cfg) => `console log loaded variables ${onOff(true, Boolean(cfg.log))}`)
873
+ .conflicts('logOff'))
874
+ .addOption(program
875
+ .createDynamicOption('-L, --log-off', (cfg) => `console log loaded variables ${onOff(false, !cfg.log)}`)
876
+ .conflicts('log'));
877
+ // Capture flag (no default display; static)
878
+ p = p.option('--capture', 'capture child process stdio for commands (tests/CI)');
879
+ // Core bootstrap/static flags (kept static in help)
880
+ p = p
881
+ .option('--default-env <string>', 'default target environment', dotenvExpandFromProcessEnv, defaultEnv)
882
+ .option('--dotenv-token <string>', 'dotenv-expanded token indicating a dotenv file', dotenvExpandFromProcessEnv, dotenvToken)
883
+ .option('--dynamic-path <string>', 'dynamic variables path (.js or .ts; .ts is auto-compiled when esbuild is available, otherwise precompile)', dotenvExpandFromProcessEnv, dynamicPath)
884
+ .option('--paths <string>', 'dotenv-expanded delimited list of paths to dotenv directory', dotenvExpandFromProcessEnv, paths)
885
+ .option('--paths-delimiter <string>', 'paths delimiter string', pathsDelimiter)
886
+ .option('--paths-delimiter-pattern <string>', 'paths delimiter regex pattern', pathsDelimiterPattern)
887
+ .option('--private-token <string>', 'dotenv-expanded token indicating private variables', dotenvExpandFromProcessEnv, privateToken)
888
+ .option('--vars-delimiter <string>', 'vars delimiter string', varsDelimiter)
889
+ .option('--vars-delimiter-pattern <string>', 'vars delimiter regex pattern', varsDelimiterPattern)
890
+ .option('--vars-assignor <string>', 'vars assignment operator string', varsAssignor)
891
+ .option('--vars-assignor-pattern <string>', 'vars assignment operator regex pattern', varsAssignorPattern)
892
+ // Hidden scripts pipe-through (stringified)
893
+ .addOption(new Option('--scripts <string>')
894
+ .default(JSON.stringify(scripts))
895
+ .hideHelp());
896
+ // Diagnostics / validation / entropy
897
+ p = p
898
+ .option('--trace [keys...]', 'emit diagnostics for child env composition (optional keys)')
899
+ .option('--strict', 'fail on env validation errors (schema/requiredKeys)');
900
+ p = p
901
+ .addOption(program
902
+ .createDynamicOption('--entropy-warn', (cfg) => {
903
+ const warn = cfg.warnEntropy;
904
+ // Default is effectively ON when warnEntropy is true or undefined.
905
+ return `enable entropy warnings${warn === false ? '' : ' (default on)'}`;
906
+ })
907
+ .conflicts('entropyWarnOff'))
908
+ .addOption(program
909
+ .createDynamicOption('--entropy-warn-off', (cfg) => `disable entropy warnings${cfg.warnEntropy === false ? ' (default)' : ''}`)
910
+ .conflicts('entropyWarn'))
911
+ .option('--entropy-threshold <number>', 'entropy bits/char threshold (default 3.8)')
912
+ .option('--entropy-min-length <number>', 'min length to examine for entropy (default 16)')
913
+ .option('--entropy-whitelist <pattern...>', 'suppress entropy warnings when key matches any regex pattern')
914
+ .option('--redact-pattern <pattern...>', 'additional key-match regex patterns to trigger redaction');
915
+ // Restore original methods
916
+ program.addOption = originalAddOption;
917
+ program.option = originalOption;
918
+ return p;
919
+ };
920
+
921
+ /**
922
+ * Zod schemas for programmatic GetDotenv options.
581
923
  *
582
- * @example
583
- * ```ts
584
- * process.env.FOO = 'bar';
585
- * dotenvExpandFromProcessEnv('Hello $FOO'); // "Hello bar"
586
- * ```
924
+ * NOTE: These schemas are introduced without wiring to avoid behavior changes.
925
+ * Legacy paths continue to use existing types/logic. The new plugin host will
926
+ * use these schemas in strict mode; legacy paths will adopt them in warn mode
927
+ * later per the staged plan.
587
928
  */
588
- const dotenvExpandFromProcessEnv = (value) => dotenvExpand(value, process.env);
929
+ // Minimal process env representation: string values or undefined to indicate "unset".
930
+ const processEnvSchema = z.record(z.string(), z.string().optional());
931
+ // RAW: all fields optional — undefined means "inherit" from lower layers.
932
+ const getDotenvOptionsSchemaRaw = z.object({
933
+ defaultEnv: z.string().optional(),
934
+ dotenvToken: z.string().optional(),
935
+ dynamicPath: z.string().optional(),
936
+ // Dynamic map is intentionally wide for now; refine once sources are normalized.
937
+ dynamic: z.record(z.string(), z.unknown()).optional(),
938
+ env: z.string().optional(),
939
+ excludeDynamic: z.boolean().optional(),
940
+ excludeEnv: z.boolean().optional(),
941
+ excludeGlobal: z.boolean().optional(),
942
+ excludePrivate: z.boolean().optional(),
943
+ excludePublic: z.boolean().optional(),
944
+ loadProcess: z.boolean().optional(),
945
+ log: z.boolean().optional(),
946
+ outputPath: z.string().optional(),
947
+ paths: z.array(z.string()).optional(),
948
+ privateToken: z.string().optional(),
949
+ vars: processEnvSchema.optional(),
950
+ // Host-only feature flag: guarded integration of config loader/overlay
951
+ useConfigLoader: z.boolean().optional(),
952
+ });
953
+ // RESOLVED: service-boundary contract (post-inheritance).
954
+ // For Step A, keep identical to RAW (no behavior change). Later stages will// materialize required defaults and narrow shapes as resolution is wired.
955
+ const getDotenvOptionsSchemaResolved = getDotenvOptionsSchemaRaw;
589
956
 
590
957
  const applyKv = (current, kv) => {
591
958
  if (!kv || Object.keys(kv).length === 0)
@@ -1184,6 +1551,8 @@ const computeContext = async (customOptions, plugins, hostMetaUrl) => {
1184
1551
  };
1185
1552
  };
1186
1553
 
1554
+ // Dynamic help support: attach a private symbol to Option for description fns.
1555
+ const DYN_DESC_SYM = Symbol('getdotenv.dynamic.description');
1187
1556
  const HOST_META_URL = import.meta.url;
1188
1557
  const CTX_SYMBOL = Symbol('GetDotenvCli.ctx');
1189
1558
  const OPTS_SYMBOL = Symbol('GetDotenvCli.options');
@@ -1199,13 +1568,20 @@ const HELP_HEADER_SYMBOL = Symbol('GetDotenvCli.helpHeader');
1199
1568
  *
1200
1569
  * NOTE: This host is additive and does not alter the legacy CLI.
1201
1570
  */
1202
- class GetDotenvCli extends Command {
1571
+ let GetDotenvCli$1 = class GetDotenvCli extends Command {
1203
1572
  /** Registered top-level plugins (composition happens via .use()) */
1204
1573
  _plugins = [];
1205
1574
  /** One-time installation guard */
1206
1575
  _installed = false;
1207
1576
  /** Optional header line to prepend in help output */
1208
1577
  [HELP_HEADER_SYMBOL];
1578
+ /**
1579
+ * Create a subcommand using the same subclass, preserving helpers like
1580
+ * dynamicOption on children.
1581
+ */
1582
+ createCommand(name) {
1583
+ return new this.constructor(name);
1584
+ }
1209
1585
  constructor(alias = 'getdotenv') {
1210
1586
  super(alias);
1211
1587
  // Ensure subcommands that use passThroughOptions can be attached safely.
@@ -1213,15 +1589,18 @@ class GetDotenvCli extends Command {
1213
1589
  // child uses passThroughOptions.
1214
1590
  this.enablePositionalOptions();
1215
1591
  // Configure grouped help: show only base options in default "Options";
1216
- // append App/Plugin sections after default help.
1592
+ // we will insert App/Plugin sections before Commands in helpInformation().
1217
1593
  this.configureHelp({
1218
1594
  visibleOptions: (cmd) => {
1219
- const all = cmd.options ??
1220
- [];
1221
- const base = all.filter((opt) => {
1222
- const group = opt.__group;
1223
- return group === 'base';
1224
- });
1595
+ const all = cmd.options ?? [];
1596
+ const parent = cmd.parent ?? null;
1597
+ const isRoot = parent === null;
1598
+ const list = isRoot
1599
+ ? all.filter((opt) => {
1600
+ const group = opt.__group;
1601
+ return group === 'base';
1602
+ })
1603
+ : all.slice(); // subcommands: show all options (their own "Options:" block)
1225
1604
  // Sort: short-aliased options first, then long-only; stable by flags.
1226
1605
  const hasShort = (opt) => {
1227
1606
  const flags = opt.flags ?? '';
@@ -1229,19 +1608,18 @@ class GetDotenvCli extends Command {
1229
1608
  return /(^|\s|,)-[A-Za-z]/.test(flags);
1230
1609
  };
1231
1610
  const byFlags = (opt) => opt.flags ?? '';
1232
- base.sort((a, b) => {
1611
+ list.sort((a, b) => {
1233
1612
  const aS = hasShort(a) ? 1 : 0;
1234
1613
  const bS = hasShort(b) ? 1 : 0;
1235
1614
  return bS - aS || byFlags(a).localeCompare(byFlags(b));
1236
1615
  });
1237
- return base;
1616
+ return list;
1238
1617
  },
1239
1618
  });
1240
1619
  this.addHelpText('beforeAll', () => {
1241
1620
  const header = this[HELP_HEADER_SYMBOL];
1242
1621
  return header && header.length > 0 ? `${header}\n\n` : '';
1243
1622
  });
1244
- this.addHelpText('afterAll', (ctx) => this.#renderOptionGroups(ctx.command));
1245
1623
  // Skeleton preSubcommand hook: produce a context if absent, without
1246
1624
  // mutating process.env. The passOptions hook (when installed) will // compute the final context using merged CLI options; keeping
1247
1625
  // loadProcess=false here avoids leaking dotenv values into the parent
@@ -1253,9 +1631,15 @@ class GetDotenvCli extends Command {
1253
1631
  });
1254
1632
  }
1255
1633
  /**
1256
- * Resolve options (strict) and compute dotenv context. * Stores the context on the instance under a symbol.
1634
+ * Resolve options (strict) and compute dotenv context.
1635
+ * Stores the context on the instance under a symbol.
1636
+ *
1637
+ * Options:
1638
+ * - opts.runAfterResolve (default true): when false, skips running plugin
1639
+ * afterResolve hooks. Useful for top-level help rendering to avoid
1640
+ * long-running side-effects while still evaluating dynamic help text.
1257
1641
  */
1258
- async resolveAndLoad(customOptions = {}) {
1642
+ async resolveAndLoad(customOptions = {}, opts) {
1259
1643
  // Resolve defaults, then validate strictly under the new host.
1260
1644
  const optionsResolved = await resolveGetDotenvOptions(customOptions);
1261
1645
  getDotenvOptionsSchemaResolved.parse(optionsResolved);
@@ -1266,9 +1650,64 @@ class GetDotenvCli extends Command {
1266
1650
  ctx;
1267
1651
  // Ensure plugins are installed exactly once, then run afterResolve.
1268
1652
  await this.install();
1269
- await this._runAfterResolve(ctx);
1653
+ if (opts?.runAfterResolve ?? true) {
1654
+ await this._runAfterResolve(ctx);
1655
+ }
1270
1656
  return ctx;
1271
1657
  }
1658
+ /**
1659
+ * Create a Commander Option that computes its description at help time.
1660
+ * The returned Option may be configured (conflicts, default, parser) and
1661
+ * added via addOption().
1662
+ */
1663
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters
1664
+ createDynamicOption(flags, desc, parser, defaultValue) {
1665
+ const opt = new Option(flags, '');
1666
+ // Keep the function on a private symbol so it survives through Commander.
1667
+ opt[DYN_DESC_SYM] = desc;
1668
+ if (parser)
1669
+ opt.argParser(parser);
1670
+ if (defaultValue !== undefined)
1671
+ opt.default(defaultValue);
1672
+ return opt;
1673
+ }
1674
+ /**
1675
+ * Chainable helper mirroring .option(), but with a dynamic description.
1676
+ * Equivalent to addOption(createDynamicOption(...)).
1677
+ */
1678
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters
1679
+ dynamicOption(flags, desc, parser, defaultValue) {
1680
+ const opt = this.createDynamicOption(flags, desc, parser, defaultValue);
1681
+ this.addOption(opt);
1682
+ return this;
1683
+ }
1684
+ /**
1685
+ * Evaluate dynamic descriptions for this command and all descendants using
1686
+ * the provided resolved configuration. Mutates the Option.description in
1687
+ * place so Commander help renders updated text.
1688
+ */
1689
+ evaluateDynamicOptions(resolved) {
1690
+ const visit = (cmd) => {
1691
+ const arr = cmd.options ?? [];
1692
+ for (const o of arr) {
1693
+ const dyn = o[DYN_DESC_SYM];
1694
+ if (typeof dyn === 'function') {
1695
+ try {
1696
+ const txt = dyn(resolved);
1697
+ // Commander Option has a public "description" field used by help.
1698
+ o.description = txt;
1699
+ }
1700
+ catch {
1701
+ // Best-effort: leave description as-is on evaluation failure.
1702
+ }
1703
+ }
1704
+ }
1705
+ const children = cmd.commands ?? [];
1706
+ for (const c of children)
1707
+ visit(c);
1708
+ };
1709
+ visit(this);
1710
+ }
1272
1711
  /**
1273
1712
  * Retrieve the current invocation context (if any).
1274
1713
  */
@@ -1298,6 +1737,7 @@ class GetDotenvCli extends Command {
1298
1737
  tagAppOptions(fn) {
1299
1738
  const root = this;
1300
1739
  const originalAddOption = root.addOption.bind(root);
1740
+ // eslint-disable-next-line @typescript-eslint/no-deprecated
1301
1741
  const originalOption = root.option.bind(root);
1302
1742
  const tagLatest = (cmd, group) => {
1303
1743
  const optsArr = cmd.options;
@@ -1310,6 +1750,7 @@ class GetDotenvCli extends Command {
1310
1750
  opt.__group = 'app';
1311
1751
  return originalAddOption(opt);
1312
1752
  };
1753
+ // eslint-disable-next-line @typescript-eslint/no-deprecated
1313
1754
  root.option = function patchedOption(...args) {
1314
1755
  const ret = originalOption(...args);
1315
1756
  tagLatest(this, 'app');
@@ -1320,6 +1761,7 @@ class GetDotenvCli extends Command {
1320
1761
  }
1321
1762
  finally {
1322
1763
  root.addOption = originalAddOption;
1764
+ // eslint-disable-next-line @typescript-eslint/no-deprecated
1323
1765
  root.option = originalOption;
1324
1766
  }
1325
1767
  }
@@ -1365,6 +1807,40 @@ class GetDotenvCli extends Command {
1365
1807
  }
1366
1808
  return this;
1367
1809
  }
1810
+ /**
1811
+ * Insert grouped plugin/app options between "Options" and "Commands" for
1812
+ * hybrid ordering. Applies to root and any parent command.
1813
+ */
1814
+ helpInformation() {
1815
+ // Base help text first (includes beforeAll/after hooks).
1816
+ const base = super.helpInformation();
1817
+ const groups = this.#renderOptionGroups(this);
1818
+ const block = typeof groups === 'string' ? groups.trim() : '';
1819
+ let out = base;
1820
+ if (!block) {
1821
+ // Ensure a trailing blank line even when no extra groups render.
1822
+ if (!out.endsWith('\n\n'))
1823
+ out = out.endsWith('\n') ? `${out}\n` : `${out}\n\n`;
1824
+ return out;
1825
+ }
1826
+ // Insert just before "Commands:" when present.
1827
+ const marker = '\nCommands:';
1828
+ const idx = base.indexOf(marker);
1829
+ if (idx >= 0) {
1830
+ const toInsert = groups.startsWith('\n') ? groups : `\n${groups}`;
1831
+ out = `${base.slice(0, idx)}${toInsert}${base.slice(idx)}`;
1832
+ }
1833
+ else {
1834
+ // Otherwise append.
1835
+ const sep = base.endsWith('\n') || groups.startsWith('\n') ? '' : '\n';
1836
+ out = `${base}${sep}${groups}`;
1837
+ }
1838
+ // Ensure a trailing blank line for prompt separation.
1839
+ if (!out.endsWith('\n\n')) {
1840
+ out = out.endsWith('\n') ? `${out}\n` : `${out}\n\n`;
1841
+ }
1842
+ return out;
1843
+ }
1368
1844
  /**
1369
1845
  * Register a plugin for installation (parent level).
1370
1846
  * Installation occurs on first resolveAndLoad() (or explicit install()).
@@ -1403,7 +1879,7 @@ class GetDotenvCli extends Command {
1403
1879
  for (const p of this._plugins)
1404
1880
  await run(p);
1405
1881
  }
1406
- // Render App/Plugin grouped options appended after default help.
1882
+ // Render App/Plugin grouped options (used by helpInformation override).
1407
1883
  #renderOptionGroups(cmd) {
1408
1884
  const all = cmd.options ?? [];
1409
1885
  const byGroup = new Map();
@@ -1443,371 +1919,98 @@ class GetDotenvCli extends Command {
1443
1919
  if (app && app.length > 0) {
1444
1920
  out += renderRows('App options', app);
1445
1921
  }
1446
- // Plugin groups sorted by id
1447
- const pluginKeys = Array.from(byGroup.keys()).filter((k) => k.startsWith('plugin:'));
1448
- pluginKeys.sort((a, b) => a.localeCompare(b));
1449
- for (const k of pluginKeys) {
1450
- const id = k.slice('plugin:'.length) || '(unknown)';
1451
- const rows = byGroup.get(k) ?? [];
1452
- if (rows.length > 0) {
1453
- out += renderRows(`Plugin options — ${id}`, rows);
1454
- }
1455
- }
1456
- return out;
1457
- }
1458
- }
1459
-
1460
- /**
1461
- * Validate a composed env against config-provided validation surfaces.
1462
- * Precedence for validation definitions:
1463
- * project.local -\> project.public -\> packaged
1464
- *
1465
- * Behavior:
1466
- * - If a JS/TS `schema` is present, use schema.safeParse(finalEnv).
1467
- * - Else if `requiredKeys` is present, check presence (value !== undefined).
1468
- * - Returns a flat list of issue strings; caller decides warn vs fail.
1469
- */
1470
- const validateEnvAgainstSources = (finalEnv, sources) => {
1471
- const pick = (getter) => {
1472
- const pl = sources.project?.local;
1473
- const pp = sources.project?.public;
1474
- const pk = sources.packaged;
1475
- return ((pl && getter(pl)) ||
1476
- (pp && getter(pp)) ||
1477
- (pk && getter(pk)) ||
1478
- undefined);
1479
- };
1480
- const schema = pick((cfg) => cfg['schema']);
1481
- if (schema &&
1482
- typeof schema.safeParse === 'function') {
1483
- try {
1484
- const parsed = schema.safeParse(finalEnv);
1485
- if (!parsed.success) {
1486
- // Try to render zod-style issues when available.
1487
- const err = parsed.error;
1488
- const issues = Array.isArray(err.issues) && err.issues.length > 0
1489
- ? err.issues.map((i) => {
1490
- const path = Array.isArray(i.path) ? i.path.join('.') : '';
1491
- const msg = i.message ?? 'Invalid value';
1492
- return path ? `[schema] ${path}: ${msg}` : `[schema] ${msg}`;
1493
- })
1494
- : ['[schema] validation failed'];
1495
- return issues;
1496
- }
1497
- return [];
1498
- }
1499
- catch {
1500
- // If schema invocation fails, surface a single diagnostic.
1501
- return [
1502
- '[schema] validation failed (unable to execute schema.safeParse)',
1503
- ];
1504
- }
1505
- }
1506
- const requiredKeys = pick((cfg) => cfg['requiredKeys']);
1507
- if (Array.isArray(requiredKeys) && requiredKeys.length > 0) {
1508
- const missing = requiredKeys.filter((k) => finalEnv[k] === undefined);
1509
- if (missing.length > 0) {
1510
- return missing.map((k) => `[requiredKeys] missing: ${k}`);
1511
- }
1512
- }
1513
- return [];
1514
- };
1515
-
1516
- /**
1517
- * Attach legacy root flags to a Commander program.
1518
- * Uses provided defaults to render help labels without coupling to generators.
1519
- */
1520
- const attachRootOptions = (program, defaults, opts) => {
1521
- // Install temporary wrappers to tag all options added here as "base".
1522
- const GROUP = 'base';
1523
- const tagLatest = (cmd, group) => {
1524
- const optsArr = cmd.options;
1525
- if (Array.isArray(optsArr) && optsArr.length > 0) {
1526
- const last = optsArr[optsArr.length - 1];
1527
- last.__group = group;
1528
- }
1529
- };
1530
- const originalAddOption = program.addOption.bind(program);
1531
- const originalOption = program.option.bind(program);
1532
- program.addOption = function patchedAdd(opt) {
1533
- // Tag before adding, in case consumers inspect the Option directly.
1534
- opt.__group = GROUP;
1535
- const ret = originalAddOption(opt);
1536
- return ret;
1537
- };
1538
- program.option = function patchedOption(...args) {
1539
- const ret = originalOption(...args);
1540
- tagLatest(this, GROUP);
1541
- return ret;
1542
- };
1543
- const { defaultEnv, dotenvToken, dynamicPath, env, excludeDynamic, excludeEnv, excludeGlobal, excludePrivate, excludePublic, loadProcess, log, outputPath, paths, pathsDelimiter, pathsDelimiterPattern, privateToken, scripts, shell, varsAssignor, varsAssignorPattern, varsDelimiter, varsDelimiterPattern, } = defaults ?? {};
1544
- const va = typeof defaults?.varsAssignor === 'string' ? defaults.varsAssignor : '=';
1545
- const vd = typeof defaults?.varsDelimiter === 'string' ? defaults.varsDelimiter : ' ';
1546
- // Build initial chain.
1547
- let p = program
1548
- .enablePositionalOptions()
1549
- .passThroughOptions()
1550
- .option('-e, --env <string>', `target environment (dotenv-expanded)`, dotenvExpandFromProcessEnv, env);
1551
- p = p.option('-v, --vars <string>', `extra variables expressed as delimited key-value pairs (dotenv-expanded): ${[
1552
- ['KEY1', 'VAL1'],
1553
- ['KEY2', 'VAL2'],
1554
- ]
1555
- .map((v) => v.join(va))
1556
- .join(vd)}`, dotenvExpandFromProcessEnv);
1557
- // Optional legacy root command flag (kept for generated CLI compatibility).
1558
- // Default is OFF; the generator opts in explicitly.
1559
- if (opts?.includeCommandOption === true) {
1560
- p = p.option('-c, --command <string>', 'command executed according to the --shell option, conflicts with cmd subcommand (dotenv-expanded)', dotenvExpandFromProcessEnv);
1561
- }
1562
- p = p
1563
- .option('-o, --output-path <string>', 'consolidated output file (dotenv-expanded)', dotenvExpandFromProcessEnv, outputPath)
1564
- .addOption(new Option('-s, --shell [string]', (() => {
1565
- let defaultLabel = '';
1566
- if (shell !== undefined) {
1567
- if (typeof shell === 'boolean') {
1568
- defaultLabel = ' (default OS shell)';
1569
- }
1570
- else if (typeof shell === 'string') {
1571
- // Safe string interpolation
1572
- defaultLabel = ` (default ${shell})`;
1573
- }
1574
- }
1575
- return `command execution shell, no argument for default OS shell or provide shell string${defaultLabel}`;
1576
- })()).conflicts('shellOff'))
1577
- .addOption(new Option('-S, --shell-off', `command execution shell OFF${!shell ? ' (default)' : ''}`).conflicts('shell'))
1578
- .addOption(new Option('-p, --load-process', `load variables to process.env ON${loadProcess ? ' (default)' : ''}`).conflicts('loadProcessOff'))
1579
- .addOption(new Option('-P, --load-process-off', `load variables to process.env OFF${!loadProcess ? ' (default)' : ''}`).conflicts('loadProcess'))
1580
- .addOption(new Option('-a, --exclude-all', `exclude all dotenv variables from loading ON${excludeDynamic &&
1581
- ((excludeEnv && excludeGlobal) || (excludePrivate && excludePublic))
1582
- ? ' (default)'
1583
- : ''}`).conflicts('excludeAllOff'))
1584
- .addOption(new Option('-A, --exclude-all-off', `exclude all dotenv variables from loading OFF (default)`).conflicts('excludeAll'))
1585
- .addOption(new Option('-z, --exclude-dynamic', `exclude dynamic dotenv variables from loading ON${excludeDynamic ? ' (default)' : ''}`).conflicts('excludeDynamicOff'))
1586
- .addOption(new Option('-Z, --exclude-dynamic-off', `exclude dynamic dotenv variables from loading OFF${!excludeDynamic ? ' (default)' : ''}`).conflicts('excludeDynamic'))
1587
- .addOption(new Option('-n, --exclude-env', `exclude environment-specific dotenv variables from loading${excludeEnv ? ' (default)' : ''}`).conflicts('excludeEnvOff'))
1588
- .addOption(new Option('-N, --exclude-env-off', `exclude environment-specific dotenv variables from loading OFF${!excludeEnv ? ' (default)' : ''}`).conflicts('excludeEnv'))
1589
- .addOption(new Option('-g, --exclude-global', `exclude global dotenv variables from loading ON${excludeGlobal ? ' (default)' : ''}`).conflicts('excludeGlobalOff'))
1590
- .addOption(new Option('-G, --exclude-global-off', `exclude global dotenv variables from loading OFF${!excludeGlobal ? ' (default)' : ''}`).conflicts('excludeGlobal'))
1591
- .addOption(new Option('-r, --exclude-private', `exclude private dotenv variables from loading ON${excludePrivate ? ' (default)' : ''}`).conflicts('excludePrivateOff'))
1592
- .addOption(new Option('-R, --exclude-private-off', `exclude private dotenv variables from loading OFF${!excludePrivate ? ' (default)' : ''}`).conflicts('excludePrivate'))
1593
- .addOption(new Option('-u, --exclude-public', `exclude public dotenv variables from loading ON${excludePublic ? ' (default)' : ''}`).conflicts('excludePublicOff'))
1594
- .addOption(new Option('-U, --exclude-public-off', `exclude public dotenv variables from loading OFF${!excludePublic ? ' (default)' : ''}`).conflicts('excludePublic'))
1595
- .addOption(new Option('-l, --log', `console log loaded variables ON${log ? ' (default)' : ''}`).conflicts('logOff'))
1596
- .addOption(new Option('-L, --log-off', `console log loaded variables OFF${!log ? ' (default)' : ''}`).conflicts('log'))
1597
- .option('--capture', 'capture child process stdio for commands (tests/CI)')
1598
- .option('--redact', 'mask secret-like values in logs/trace (presentation-only)')
1599
- .option('--default-env <string>', 'default target environment', dotenvExpandFromProcessEnv, defaultEnv)
1600
- .option('--dotenv-token <string>', 'dotenv-expanded token indicating a dotenv file', dotenvExpandFromProcessEnv, dotenvToken)
1601
- .option('--dynamic-path <string>', 'dynamic variables path (.js or .ts; .ts is auto-compiled when esbuild is available, otherwise precompile)', dotenvExpandFromProcessEnv, dynamicPath)
1602
- .option('--paths <string>', 'dotenv-expanded delimited list of paths to dotenv directory', dotenvExpandFromProcessEnv, paths)
1603
- .option('--paths-delimiter <string>', 'paths delimiter string', pathsDelimiter)
1604
- .option('--paths-delimiter-pattern <string>', 'paths delimiter regex pattern', pathsDelimiterPattern)
1605
- .option('--private-token <string>', 'dotenv-expanded token indicating private variables', dotenvExpandFromProcessEnv, privateToken)
1606
- .option('--vars-delimiter <string>', 'vars delimiter string', varsDelimiter)
1607
- .option('--vars-delimiter-pattern <string>', 'vars delimiter regex pattern', varsDelimiterPattern)
1608
- .option('--vars-assignor <string>', 'vars assignment operator string', varsAssignor)
1609
- .option('--vars-assignor-pattern <string>', 'vars assignment operator regex pattern', varsAssignorPattern)
1610
- // Hidden scripts pipe-through (stringified)
1611
- .addOption(new Option('--scripts <string>')
1612
- .default(JSON.stringify(scripts))
1613
- .hideHelp());
1614
- // Diagnostics: opt-in tracing; optional variadic keys after the flag.
1615
- p = p.option('--trace [keys...]', 'emit diagnostics for child env composition (optional keys)');
1616
- // Validation: strict mode fails on env validation issues (warn by default).
1617
- p = p.option('--strict', 'fail on env validation errors (schema/requiredKeys)');
1618
- // Entropy diagnostics (presentation-only)
1619
- p = p
1620
- .addOption(new Option('--entropy-warn', 'enable entropy warnings (default on)').conflicts('entropyWarnOff'))
1621
- .addOption(new Option('--entropy-warn-off', 'disable entropy warnings').conflicts('entropyWarn'))
1622
- .option('--entropy-threshold <number>', 'entropy bits/char threshold (default 3.8)')
1623
- .option('--entropy-min-length <number>', 'min length to examine for entropy (default 16)')
1624
- .option('--entropy-whitelist <pattern...>', 'suppress entropy warnings when key matches any regex pattern')
1625
- .option('--redact-pattern <pattern...>', 'additional key-match regex patterns to trigger redaction');
1626
- // Restore original methods to avoid tagging future additions outside base.
1627
- program.addOption = originalAddOption;
1628
- program.option = originalOption;
1629
- return p;
1630
- };
1631
-
1632
- /**
1633
- * Resolve a tri-state optional boolean flag under exactOptionalPropertyTypes.
1634
- * - If the user explicitly enabled the flag, return true.
1635
- * - If the user explicitly disabled (the "...-off" variant), return undefined (unset).
1636
- * - Otherwise, adopt the default (true → set; false/undefined → unset).
1637
- *
1638
- * @param exclude - The "on" flag value as parsed by Commander.
1639
- * @param excludeOff - The "off" toggle (present when specified) as parsed by Commander.
1640
- * @param defaultValue - The generator default to adopt when no explicit toggle is present.
1641
- * @returns boolean | undefined — use `undefined` to indicate "unset" (do not emit).
1642
- *
1643
- * @example
1644
- * ```ts
1645
- * resolveExclusion(undefined, undefined, true); // => true
1646
- * ```
1647
- */
1648
- const resolveExclusion = (exclude, excludeOff, defaultValue) => exclude ? true : excludeOff ? undefined : defaultValue ? true : undefined;
1649
- /**
1650
- * Resolve an optional flag with "--exclude-all" overrides.
1651
- * If excludeAll is set and the individual "...-off" is not, force true.
1652
- * If excludeAllOff is set and the individual flag is not explicitly set, unset.
1653
- * Otherwise, adopt the default (true → set; false/undefined → unset).
1654
- *
1655
- * @param exclude - Individual include/exclude flag.
1656
- * @param excludeOff - Individual "...-off" flag.
1657
- * @param defaultValue - Default for the individual flag.
1658
- * @param excludeAll - Global "exclude-all" flag.
1659
- * @param excludeAllOff - Global "exclude-all-off" flag.
1922
+ // Plugin groups sorted by id
1923
+ const pluginKeys = Array.from(byGroup.keys()).filter((k) => k.startsWith('plugin:'));
1924
+ const currentName = cmd.name?.() ?? '';
1925
+ pluginKeys.sort((a, b) => a.localeCompare(b));
1926
+ for (const k of pluginKeys) {
1927
+ const id = k.slice('plugin:'.length) || '(unknown)';
1928
+ const rows = byGroup.get(k) ?? [];
1929
+ // Do not show a "Plugin options — <self>" section on the command that owns those options.
1930
+ // Only child-injected plugin groups should render at this level.
1931
+ if (rows.length > 0 && id !== currentName) {
1932
+ out += renderRows(`Plugin options — ${id}`, rows);
1933
+ }
1934
+ }
1935
+ return out;
1936
+ }
1937
+ };
1938
+
1939
+ /** src/cliHost/definePlugin.ts
1940
+ * Plugin contracts for the GetDotenv CLI host.
1660
1941
  *
1661
- * @example
1662
- * resolveExclusionAll(undefined, undefined, false, true, undefined) =\> true
1942
+ * This module exposes a structural public interface for the host that plugins
1943
+ * should use (GetDotenvCliPublic). Using a structural type at the seam avoids
1944
+ * nominal class identity issues (private fields) in downstream consumers.
1663
1945
  */
1664
- const resolveExclusionAll = (exclude, excludeOff, defaultValue, excludeAll, excludeAllOff) =>
1665
- // Order of precedence:
1666
- // 1) Individual explicit "on" wins outright.
1667
- // 2) Individual explicit "off" wins over any global.
1668
- // 3) Global exclude-all forces true when not explicitly turned off.
1669
- // 4) Global exclude-all-off unsets when the individual wasn't explicitly enabled.
1670
- // 5) Fall back to the default (true => set; false/undefined => unset).
1671
- (() => {
1672
- // Individual "on"
1673
- if (exclude === true)
1674
- return true;
1675
- // Individual "off"
1676
- if (excludeOff === true)
1677
- return undefined;
1678
- // Global "exclude-all" ON (unless explicitly turned off)
1679
- if (excludeAll === true)
1680
- return true;
1681
- // Global "exclude-all-off" (unless explicitly enabled)
1682
- if (excludeAllOff === true)
1683
- return undefined;
1684
- // Default
1685
- return defaultValue ? true : undefined;
1686
- })();
1687
1946
  /**
1688
- * exactOptionalPropertyTypes-safe setter for optional boolean flags:
1689
- * delete when undefined; assign when defined — without requiring an index signature on T.
1690
- *
1691
- * @typeParam T - Target object type.
1692
- * @param obj - The object to write to.
1693
- * @param key - The optional boolean property key of {@link T}.
1694
- * @param value - The value to set or `undefined` to unset.
1947
+ * Define a GetDotenv CLI plugin with compositional helpers.
1695
1948
  *
1696
- * @remarks
1697
- * Writes through a local `Record<string, unknown>` view to avoid requiring an index signature on {@link T}.
1949
+ * @example
1950
+ * const parent = definePlugin(\{ id: 'p', setup(cli) \{ /* ... *\/ \} \})
1951
+ * .use(childA)
1952
+ * .use(childB);
1698
1953
  */
1699
- const setOptionalFlag = (obj, key, value) => {
1700
- const target = obj;
1701
- const k = key;
1702
- // eslint-disable-next-line @typescript-eslint/no-dynamic-delete
1703
- if (value === undefined)
1704
- delete target[k];
1705
- else
1706
- target[k] = value;
1954
+ const definePlugin = (spec) => {
1955
+ const { children = [], ...rest } = spec;
1956
+ const plugin = {
1957
+ ...rest,
1958
+ children: [...children],
1959
+ use(child) {
1960
+ this.children.push(child);
1961
+ return this;
1962
+ },
1963
+ };
1964
+ return plugin;
1707
1965
  };
1708
1966
 
1709
1967
  /**
1710
- * Merge and normalize raw Commander options (current + parent + defaults)
1711
- * into a GetDotenvCliOptions-like object. Types are intentionally wide to
1712
- * avoid cross-layer coupling; callers may cast as needed.
1968
+ * GetDotenvCli with root helpers as real class methods.
1969
+ * - attachRootOptions: installs legacy/base root flags on the command.
1970
+ * - passOptions: merges flags (parent \< current), computes dotenv context once,
1971
+ * runs validation, and persists merged options for nested flows.
1713
1972
  */
1714
- const resolveCliOptions = (rawCliOptions, defaults, parentJson) => {
1715
- const parent = typeof parentJson === 'string' && parentJson.length > 0
1716
- ? JSON.parse(parentJson)
1717
- : undefined;
1718
- const { command, debugOff, excludeAll, excludeAllOff, excludeDynamicOff, excludeEnvOff, excludeGlobalOff, excludePrivateOff, excludePublicOff, loadProcessOff, logOff, entropyWarn, entropyWarnOff, scripts, shellOff, ...rest } = rawCliOptions;
1719
- const current = { ...rest };
1720
- if (typeof scripts === 'string') {
1721
- try {
1722
- current.scripts = JSON.parse(scripts);
1723
- }
1724
- catch {
1725
- // ignore parse errors; leave scripts undefined
1726
- }
1727
- }
1728
- const merged = defaultsDeep({}, defaults, parent ?? {}, current);
1729
- const d = defaults;
1730
- setOptionalFlag(merged, 'debug', resolveExclusion(merged.debug, debugOff, d.debug));
1731
- setOptionalFlag(merged, 'excludeDynamic', resolveExclusionAll(merged.excludeDynamic, excludeDynamicOff, d.excludeDynamic, excludeAll, excludeAllOff));
1732
- setOptionalFlag(merged, 'excludeEnv', resolveExclusionAll(merged.excludeEnv, excludeEnvOff, d.excludeEnv, excludeAll, excludeAllOff));
1733
- setOptionalFlag(merged, 'excludeGlobal', resolveExclusionAll(merged.excludeGlobal, excludeGlobalOff, d.excludeGlobal, excludeAll, excludeAllOff));
1734
- setOptionalFlag(merged, 'excludePrivate', resolveExclusionAll(merged.excludePrivate, excludePrivateOff, d.excludePrivate, excludeAll, excludeAllOff));
1735
- setOptionalFlag(merged, 'excludePublic', resolveExclusionAll(merged.excludePublic, excludePublicOff, d.excludePublic, excludeAll, excludeAllOff));
1736
- setOptionalFlag(merged, 'log', resolveExclusion(merged.log, logOff, d.log));
1737
- setOptionalFlag(merged, 'loadProcess', resolveExclusion(merged.loadProcess, loadProcessOff, d.loadProcess));
1738
- // warnEntropy (tri-state)
1739
- setOptionalFlag(merged, 'warnEntropy', resolveExclusion(merged.warnEntropy, entropyWarnOff, d.warnEntropy));
1740
- // Normalize shell for predictability: explicit default shell per OS.
1741
- const defaultShell = process.platform === 'win32' ? 'powershell.exe' : '/bin/bash';
1742
- let resolvedShell = merged.shell;
1743
- if (shellOff)
1744
- resolvedShell = false;
1745
- else if (resolvedShell === true || resolvedShell === undefined) {
1746
- resolvedShell = defaultShell;
1747
- }
1748
- else if (typeof resolvedShell !== 'string' &&
1749
- typeof defaults.shell === 'string') {
1750
- resolvedShell = defaults.shell;
1973
+ class GetDotenvCli extends GetDotenvCli$1 {
1974
+ /**
1975
+ * Attach legacy root flags to this CLI instance. Defaults come from
1976
+ * baseRootOptionDefaults when none are provided.
1977
+ */
1978
+ attachRootOptions(defaults, opts) {
1979
+ const d = (defaults ?? baseRootOptionDefaults);
1980
+ attachRootOptions(this, d, opts);
1981
+ return this;
1751
1982
  }
1752
- merged.shell = resolvedShell;
1753
- const cmd = typeof command === 'string' ? command : undefined;
1754
- return cmd !== undefined ? { merged, command: cmd } : { merged };
1755
- };
1756
-
1757
- GetDotenvCli.prototype.attachRootOptions = function (defaults, opts) {
1758
- const d = (defaults ?? baseRootOptionDefaults);
1759
- attachRootOptions(this, d, opts);
1760
- return this;
1761
- };
1762
- GetDotenvCli.prototype.passOptions = function (defaults) {
1763
- const d = (defaults ?? baseRootOptionDefaults);
1764
- this.hook('preSubcommand', async (thisCommand) => {
1765
- const raw = thisCommand.opts();
1766
- const { merged } = resolveCliOptions(raw, d, process.env.getDotenvCliOptions);
1767
- // Persist merged options for nested invocations (batch exec).
1768
- thisCommand.getDotenvCliOptions =
1769
- merged;
1770
- // Also store on the host for downstream ergonomic accessors.
1771
- this._setOptionsBag(merged);
1772
- // Build service options and compute context (always-on config loader path).
1773
- const serviceOptions = getDotenvCliOptions2Options(merged);
1774
- await this.resolveAndLoad(serviceOptions);
1775
- // Global validation: once after Phase C using config sources.
1776
- try {
1777
- const ctx = this.getCtx();
1778
- const dotenv = (ctx?.dotenv ?? {});
1779
- const sources = await resolveGetDotenvConfigSources(import.meta.url);
1780
- const issues = validateEnvAgainstSources(dotenv, sources);
1781
- if (Array.isArray(issues) && issues.length > 0) {
1782
- const logger = (merged.logger ??
1783
- console);
1784
- const emit = logger.error ?? logger.log;
1785
- issues.forEach((m) => {
1786
- emit(m);
1787
- });
1788
- if (merged.strict) {
1789
- // Deterministic failure under strict mode
1790
- process.exit(1);
1791
- }
1792
- }
1793
- }
1794
- catch {
1795
- // Be tolerant: validation errors reported above; unexpected failures here
1796
- // should not crash non-strict flows.
1797
- }
1798
- });
1799
- // Also handle root-level flows (no subcommand) so option-aliases can run
1800
- // with the same merged options and context without duplicating logic.
1801
- this.hook('preAction', async (thisCommand) => {
1802
- const raw = thisCommand.opts();
1803
- const { merged } = resolveCliOptions(raw, d, process.env.getDotenvCliOptions);
1804
- thisCommand.getDotenvCliOptions =
1805
- merged;
1806
- this._setOptionsBag(merged);
1807
- // Avoid duplicate heavy work if a context is already present.
1808
- if (!this.getCtx()) {
1983
+ /**
1984
+ * Install preSubcommand/preAction hooks that:
1985
+ * - Merge options (parent round-trip + current invocation) using resolveCliOptions.
1986
+ * - Persist the merged bag on the current command and on the host (for ergonomics).
1987
+ * - Compute the dotenv context once via resolveAndLoad(serviceOptions).
1988
+ * - Validate the composed env against discovered config (warn or --strict fail).
1989
+ */
1990
+ passOptions(defaults) {
1991
+ const d = (defaults ?? baseRootOptionDefaults);
1992
+ this.hook('preSubcommand', async (thisCommand) => {
1993
+ const raw = thisCommand.opts();
1994
+ const { merged } = resolveCliOptions(raw, d, process.env.getDotenvCliOptions);
1995
+ // Persist merged options (for nested behavior and ergonomic access).
1996
+ thisCommand.getDotenvCliOptions =
1997
+ merged;
1998
+ this._setOptionsBag(merged);
1999
+ // Build service options and compute context (always-on loader path).
1809
2000
  const serviceOptions = getDotenvCliOptions2Options(merged);
1810
2001
  await this.resolveAndLoad(serviceOptions);
2002
+ // Refresh dynamic option descriptions using resolved config + plugin slices
2003
+ try {
2004
+ const ctx = this.getCtx();
2005
+ this.evaluateDynamicOptions({
2006
+ ...ctx?.optionsResolved,
2007
+ plugins: ctx?.pluginConfigs ?? {},
2008
+ });
2009
+ }
2010
+ catch {
2011
+ /* best-effort */
2012
+ }
2013
+ // Global validation: once after Phase C using config sources.
1811
2014
  try {
1812
2015
  const ctx = this.getCtx();
1813
2016
  const dotenv = (ctx?.dotenv ?? {});
@@ -1826,12 +2029,56 @@ GetDotenvCli.prototype.passOptions = function (defaults) {
1826
2029
  }
1827
2030
  }
1828
2031
  catch {
1829
- // Tolerate validation side-effects in non-strict mode
2032
+ // Be tolerant: do not crash non-strict flows on unexpected validator failures.
1830
2033
  }
1831
- }
1832
- });
1833
- return this;
1834
- };
2034
+ });
2035
+ // Also handle root-level flows (no subcommand) so option-aliases can run
2036
+ // with the same merged options and context without duplicating logic.
2037
+ this.hook('preAction', async (thisCommand) => {
2038
+ const raw = thisCommand.opts();
2039
+ const { merged } = resolveCliOptions(raw, d, process.env.getDotenvCliOptions);
2040
+ thisCommand.getDotenvCliOptions =
2041
+ merged;
2042
+ this._setOptionsBag(merged);
2043
+ // Avoid duplicate heavy work if a context is already present.
2044
+ if (!this.getCtx()) {
2045
+ const serviceOptions = getDotenvCliOptions2Options(merged);
2046
+ await this.resolveAndLoad(serviceOptions);
2047
+ try {
2048
+ const ctx = this.getCtx();
2049
+ this.evaluateDynamicOptions({
2050
+ ...ctx?.optionsResolved,
2051
+ plugins: ctx?.pluginConfigs ?? {},
2052
+ });
2053
+ }
2054
+ catch {
2055
+ /* tolerate */
2056
+ }
2057
+ try {
2058
+ const ctx = this.getCtx();
2059
+ const dotenv = (ctx?.dotenv ?? {});
2060
+ const sources = await resolveGetDotenvConfigSources(import.meta.url);
2061
+ const issues = validateEnvAgainstSources(dotenv, sources);
2062
+ if (Array.isArray(issues) && issues.length > 0) {
2063
+ const logger = (merged
2064
+ .logger ?? console);
2065
+ const emit = logger.error ?? logger.log;
2066
+ issues.forEach((m) => {
2067
+ emit(m);
2068
+ });
2069
+ if (merged.strict) {
2070
+ process.exit(1);
2071
+ }
2072
+ }
2073
+ }
2074
+ catch {
2075
+ // Tolerate validation side-effects in non-strict mode.
2076
+ }
2077
+ }
2078
+ });
2079
+ return this;
2080
+ }
2081
+ }
1835
2082
 
1836
2083
  // Minimal tokenizer for shell-off execution:
1837
2084
  // Splits by whitespace while preserving quoted segments (single or double quotes).
@@ -2066,34 +2313,6 @@ const buildSpawnEnv = (base, overlay) => {
2066
2313
  return out;
2067
2314
  };
2068
2315
 
2069
- /** src/cliHost/definePlugin.ts
2070
- * Plugin contracts for the GetDotenv CLI host.
2071
- *
2072
- * This module exposes a structural public interface for the host that plugins
2073
- * should use (GetDotenvCliPublic). Using a structural type at the seam avoids
2074
- * nominal class identity issues (private fields) in downstream consumers.
2075
- */
2076
- /**
2077
- * Define a GetDotenv CLI plugin with compositional helpers.
2078
- *
2079
- * @example
2080
- * const parent = definePlugin(\{ id: 'p', setup(cli) \{ /* ... *\/ \} \})
2081
- * .use(childA)
2082
- * .use(childB);
2083
- */
2084
- const definePlugin = (spec) => {
2085
- const { children = [], ...rest } = spec;
2086
- const plugin = {
2087
- ...rest,
2088
- children: [...children],
2089
- use(child) {
2090
- this.children.push(child);
2091
- return this;
2092
- },
2093
- };
2094
- return plugin;
2095
- };
2096
-
2097
2316
  /**
2098
2317
  * Batch services (neutral): resolve command and shell settings.
2099
2318
  * Shared by the generator path and the batch plugin to avoid circular deps.
@@ -2329,7 +2548,6 @@ const awsPlugin = () => definePlugin({
2329
2548
  cli
2330
2549
  .ns('aws')
2331
2550
  .description('Establish an AWS session and optionally forward to the AWS CLI')
2332
- .configureHelp({ showGlobalOptions: true })
2333
2551
  .enablePositionalOptions()
2334
2552
  .passThroughOptions()
2335
2553
  .allowUnknownOption(true)
@@ -2520,9 +2738,10 @@ const globPaths = async ({ globs, logger, pkgCwd, rootPath, }) => {
2520
2738
  }
2521
2739
  return { absRootPath, paths };
2522
2740
  };
2523
- const execShellCommandBatch = async ({ command, getDotenvCliOptions, globs, ignoreErrors, list, logger, pkgCwd, rootPath, shell, }) => {
2741
+ const execShellCommandBatch = async ({ command, getDotenvCliOptions, dotenvEnv, globs, ignoreErrors, list, logger, pkgCwd, rootPath, shell, }) => {
2524
2742
  const capture = process.env.GETDOTENV_STDIO === 'pipe' ||
2525
- Boolean(getDotenvCliOptions?.capture); // Require a command only when not listing. In list mode, a command is optional.
2743
+ Boolean(getDotenvCliOptions?.capture);
2744
+ // Require a command only when not listing. In list mode, a command is optional.
2526
2745
  if (!command && !list) {
2527
2746
  logger.error(`No command provided. Use --command or --list.`);
2528
2747
  process.exit(0);
@@ -2569,12 +2788,25 @@ const execShellCommandBatch = async ({ command, getDotenvCliOptions, globs, igno
2569
2788
  const hasCmd = (typeof command === 'string' && command.length > 0) ||
2570
2789
  (Array.isArray(command) && command.length > 0);
2571
2790
  if (hasCmd) {
2572
- const envBag = getDotenvCliOptions !== undefined
2573
- ? { getDotenvCliOptions: JSON.stringify(getDotenvCliOptions) }
2574
- : undefined;
2791
+ // Compose child env overlay from dotenv (drop undefined) and merged options
2792
+ const overlay = {};
2793
+ if (dotenvEnv) {
2794
+ for (const [k, v] of Object.entries(dotenvEnv)) {
2795
+ if (typeof v === 'string')
2796
+ overlay[k] = v;
2797
+ }
2798
+ }
2799
+ if (getDotenvCliOptions !== undefined) {
2800
+ try {
2801
+ overlay.getDotenvCliOptions = JSON.stringify(getDotenvCliOptions);
2802
+ }
2803
+ catch {
2804
+ // best-effort: omit if serialization fails
2805
+ }
2806
+ }
2575
2807
  await runCommand(command, shell, {
2576
2808
  cwd: path,
2577
- env: buildSpawnEnv(process.env, envBag),
2809
+ env: buildSpawnEnv(process.env, overlay),
2578
2810
  stdio: capture ? 'pipe' : 'inherit',
2579
2811
  });
2580
2812
  }
@@ -2612,6 +2844,7 @@ const buildDefaultCmdAction = (cli, batchCmd, opts) => async (commandParts, _sub
2612
2844
  const ctx = cli.getCtx();
2613
2845
  const cfgRaw = (ctx?.pluginConfigs?.['batch'] ?? {});
2614
2846
  const cfg = (cfgRaw || {});
2847
+ const dotenvEnv = (ctx?.dotenv ?? {});
2615
2848
  // Resolve batch flags from the captured parent (batch) command.
2616
2849
  const raw = batchCmd.opts();
2617
2850
  const listFromParent = !!raw.list;
@@ -2630,6 +2863,7 @@ const buildDefaultCmdAction = (cli, batchCmd, opts) => async (commandParts, _sub
2630
2863
  if (typeof commandOpt === 'string') {
2631
2864
  await execShellCommandBatch({
2632
2865
  command: resolveCommand(scripts, commandOpt),
2866
+ dotenvEnv,
2633
2867
  globs,
2634
2868
  ignoreErrors,
2635
2869
  list: false,
@@ -2641,6 +2875,7 @@ const buildDefaultCmdAction = (cli, batchCmd, opts) => async (commandParts, _sub
2641
2875
  return;
2642
2876
  }
2643
2877
  if (raw.list || localList) {
2878
+ const shellBag = ((batchCmd.parent ?? undefined)?.getDotenvCliOptions ?? {});
2644
2879
  await execShellCommandBatch({
2645
2880
  globs,
2646
2881
  ignoreErrors,
@@ -2648,7 +2883,7 @@ const buildDefaultCmdAction = (cli, batchCmd, opts) => async (commandParts, _sub
2648
2883
  logger: loggerLocal,
2649
2884
  ...(pkgCwd ? { pkgCwd } : {}),
2650
2885
  rootPath,
2651
- shell: (shell ?? false),
2886
+ shell: (shell ?? shellBag.shell ?? false),
2652
2887
  });
2653
2888
  return;
2654
2889
  }
@@ -2715,6 +2950,7 @@ const buildDefaultCmdAction = (cli, batchCmd, opts) => async (commandParts, _sub
2715
2950
  }
2716
2951
  await execShellCommandBatch({
2717
2952
  command: commandArg,
2953
+ dotenvEnv,
2718
2954
  ...(envBag ? { getDotenvCliOptions: envBag } : {}),
2719
2955
  globs,
2720
2956
  ignoreErrors,
@@ -2733,6 +2969,7 @@ const buildParentAction = (cli, opts) => async (commandParts, thisCommand) => {
2733
2969
  const logger = opts.logger ?? console;
2734
2970
  // Ensure context exists (host preSubcommand on root creates if missing).
2735
2971
  const ctx = cli.getCtx();
2972
+ const dotenvEnv = (ctx?.dotenv ?? {});
2736
2973
  const cfgRaw = (ctx?.pluginConfigs?.['batch'] ?? {});
2737
2974
  const cfg = (cfgRaw || {});
2738
2975
  const raw = thisCommand.opts();
@@ -2755,6 +2992,7 @@ const buildParentAction = (cli, opts) => async (commandParts, thisCommand) => {
2755
2992
  const commandArg = resolved;
2756
2993
  await execShellCommandBatch({
2757
2994
  command: commandArg,
2995
+ dotenvEnv,
2758
2996
  globs,
2759
2997
  ignoreErrors,
2760
2998
  list: false,
@@ -2792,6 +3030,7 @@ const buildParentAction = (cli, opts) => async (commandParts, thisCommand) => {
2792
3030
  const shellOpt = opts.shell ?? cfg.shell ?? mergedBag.shell;
2793
3031
  await execShellCommandBatch({
2794
3032
  command: resolveCommand(scriptsOpt, commandOpt),
3033
+ dotenvEnv,
2795
3034
  globs,
2796
3035
  ignoreErrors,
2797
3036
  list,
@@ -2835,7 +3074,8 @@ const BatchConfigSchema = z.object({
2835
3074
  /**
2836
3075
  * Batch plugin for the GetDotenv CLI host.
2837
3076
  *
2838
- * Mirrors the legacy batch subcommand behavior without altering the shipped CLI. * Options:
3077
+ * Mirrors the legacy batch subcommand behavior without altering the shipped CLI.
3078
+ * Options:
2839
3079
  * - scripts/shell: used to resolve command and shell behavior per script or global default.
2840
3080
  * - logger: defaults to console.
2841
3081
  */
@@ -2847,12 +3087,32 @@ const batchPlugin = (opts = {}) => definePlugin({
2847
3087
  setup(cli) {
2848
3088
  const ns = cli.ns('batch');
2849
3089
  const batchCmd = ns; // capture the parent "batch" command for default-subcommand context
3090
+ const host = cli;
3091
+ const pluginId = 'batch';
3092
+ const GROUP = `plugin:${pluginId}`;
2850
3093
  ns.description('Batch command execution across multiple working directories.')
2851
3094
  .enablePositionalOptions()
2852
3095
  .passThroughOptions()
2853
- .option('-p, --pkg-cwd', 'use nearest package directory as current working directory')
2854
- .option('-r, --root-path <string>', 'path to batch root directory from current working directory', './')
2855
- .option('-g, --globs <string>', 'space-delimited globs from root path', '*')
3096
+ // Dynamic help: show effective defaults from the merged/interpolated plugin config slice.
3097
+ .addOption((() => {
3098
+ const opt = host.createDynamicOption('-p, --pkg-cwd', (cfg) => {
3099
+ const slice = cfg.plugins.batch ?? {};
3100
+ const on = !!slice.pkgCwd;
3101
+ return `use nearest package directory as current working directory${on ? ' (default)' : ''}`;
3102
+ });
3103
+ opt.__group = GROUP;
3104
+ return opt;
3105
+ })())
3106
+ .addOption((() => {
3107
+ const opt = host.createDynamicOption('-r, --root-path <string>', (cfg) => `path to batch root directory from current working directory (default: ${JSON.stringify(cfg.plugins.batch?.rootPath || './')})`);
3108
+ opt.__group = GROUP;
3109
+ return opt;
3110
+ })())
3111
+ .addOption((() => {
3112
+ const opt = host.createDynamicOption('-g, --globs <string>', (cfg) => `space-delimited globs from root path (default: ${JSON.stringify(cfg.plugins.batch?.globs || '*')})`);
3113
+ opt.__group = GROUP;
3114
+ return opt;
3115
+ })())
2856
3116
  .option('-c, --command <string>', 'command executed according to the base shell resolution')
2857
3117
  .option('-l, --list', 'list working directories without executing command')
2858
3118
  .option('-e, --ignore-errors', 'ignore errors and continue with next path')
@@ -3162,10 +3422,10 @@ const cmdPlugin = (options = {}) => definePlugin({
3162
3422
  return name.replace(/-([a-z])/g, (_m, c) => c.toUpperCase());
3163
3423
  };
3164
3424
  const aliasKey = aliasSpec ? deriveKey(aliasSpec.flags) : undefined;
3165
- const cmd = new Command()
3166
- .name('cmd')
3167
- .description('Batch execute command according to the --shell option, conflicts with --command option (default subcommand)')
3168
- .configureHelp({ showGlobalOptions: true })
3425
+ // Create as a GetDotenvCli child so helpInformation includes a trailing blank line.
3426
+ const cmd = cli
3427
+ .createCommand('cmd')
3428
+ .description('Execute command according to the --shell option, conflicts with --command option (default subcommand)')
3169
3429
  .enablePositionalOptions()
3170
3430
  .passThroughOptions()
3171
3431
  .argument('[command...]')
@@ -3357,7 +3617,7 @@ const demoPlugin = () => definePlugin({
3357
3617
  const dotenv = (ctx?.dotenv ?? {});
3358
3618
  // Inherit stdio for an interactive demo. Use --capture for CI.
3359
3619
  await runCommand(['node', '-e', code], false, {
3360
- env: { ...process.env, ...dotenv },
3620
+ env: buildSpawnEnv(process.env, dotenv),
3361
3621
  stdio: 'inherit',
3362
3622
  });
3363
3623
  });
@@ -3394,20 +3654,23 @@ const demoPlugin = () => definePlugin({
3394
3654
  const ctx = cli.getCtx();
3395
3655
  const dotenv = (ctx?.dotenv ?? {});
3396
3656
  await runCommand(resolved, shell, {
3397
- env: { ...process.env, ...dotenv },
3657
+ env: buildSpawnEnv(process.env, dotenv),
3398
3658
  stdio: 'inherit',
3399
3659
  });
3400
3660
  });
3401
3661
  },
3402
3662
  /**
3403
3663
  * Optional: afterResolve can initialize per-plugin state using ctx.dotenv.
3404
- * For the demo we just log once to hint where such logic would live.
3664
+ * For the demo we emit a single breadcrumb only when GETDOTENV_DEBUG is set,
3665
+ * keeping default runs (tests/CI/smoke) quiet.
3405
3666
  */
3406
3667
  afterResolve(_cli, ctx) {
3407
- const keys = Object.keys(ctx.dotenv);
3408
- if (keys.length > 0) {
3409
- // Keep noise low; a single-line breadcrumb is sufficient for the demo.
3410
- console.error('[demo] afterResolve: dotenv keys loaded:', keys.length);
3668
+ if (process.env.GETDOTENV_DEBUG) {
3669
+ const keys = Object.keys(ctx.dotenv);
3670
+ if (keys.length > 0) {
3671
+ // Keep noise low; a single-line breadcrumb is sufficient for the demo.
3672
+ console.error('[demo] afterResolve: dotenv keys loaded:', keys.length);
3673
+ }
3411
3674
  }
3412
3675
  },
3413
3676
  });
@@ -3665,126 +3928,40 @@ const initPlugin = (opts = {}) => definePlugin({
3665
3928
  },
3666
3929
  });
3667
3930
 
3668
- const cmdCommand = new Command()
3669
- .name('cmd')
3670
- .description('execute command, conflicts with --command option (default subcommand)')
3671
- .enablePositionalOptions()
3672
- .passThroughOptions()
3673
- .argument('[command...]')
3674
- .action(async (commandParts, _options, thisCommand) => {
3675
- if (!thisCommand.parent)
3676
- throw new Error(`unable to resolve parent command`);
3677
- if (!thisCommand.parent.parent)
3678
- throw new Error(`unable to resolve root command`);
3679
- const { getDotenvCliOptions: { logger = console, ...getDotenvCliOptions }, } = thisCommand.parent.parent;
3680
- const raw = thisCommand.parent.opts();
3681
- const ignoreErrors = !!raw.ignoreErrors;
3682
- const globs = typeof raw.globs === 'string' ? raw.globs : '*';
3683
- const list = !!raw.list;
3684
- const pkgCwd = !!raw.pkgCwd;
3685
- const rootPath = typeof raw.rootPath === 'string' ? raw.rootPath : './';
3686
- // Execute command.
3687
- const args = Array.isArray(commandParts) ? commandParts : [];
3688
- // When no positional tokens are provided (e.g., option form `-c/--command`),
3689
- // the preSubcommand hook handles execution. Avoid a duplicate call here.
3690
- if (args.length === 0)
3691
- return;
3692
- const command = args.map(String).join(' ');
3693
- await execShellCommandBatch({
3694
- command: resolveCommand(getDotenvCliOptions.scripts, command),
3695
- getDotenvCliOptions,
3696
- globs,
3697
- ignoreErrors,
3698
- list,
3699
- logger,
3700
- pkgCwd,
3701
- rootPath,
3702
- // execa expects string | boolean | URL for `shell`. We normalize earlier;
3703
- // scripts[name].shell overrides take precedence and may be boolean or string.
3704
- shell: resolveShell(getDotenvCliOptions.scripts, command, getDotenvCliOptions.shell),
3705
- });
3706
- });
3707
-
3708
- new Command()
3709
- .name('batch')
3710
- .description('Batch command execution across multiple working directories.')
3711
- .enablePositionalOptions()
3712
- .passThroughOptions()
3713
- .option('-p, --pkg-cwd', 'use nearest package directory as current working directory')
3714
- .option('-r, --root-path <string>', 'path to batch root directory from current working directory', './')
3715
- .option('-g, --globs <string>', 'space-delimited globs from root path', '*')
3716
- .option('-c, --command <string>', 'command executed according to the base --shell option, conflicts with cmd subcommand (dotenv-expanded)', dotenvExpandFromProcessEnv)
3717
- .option('-l, --list', 'list working directories without executing command')
3718
- .option('-e, --ignore-errors', 'ignore errors and continue with next path')
3719
- .hook('preSubcommand', async (thisCommand) => {
3720
- if (!thisCommand.parent)
3721
- throw new Error(`unable to resolve root command`);
3722
- const { getDotenvCliOptions: { logger = console, ...getDotenvCliOptions }, } = thisCommand.parent;
3723
- const raw = thisCommand.opts();
3724
- const commandOpt = typeof raw.command === 'string' ? raw.command : undefined;
3725
- const ignoreErrors = !!raw.ignoreErrors;
3726
- const globs = typeof raw.globs === 'string' ? raw.globs : '*';
3727
- const list = !!raw.list;
3728
- const pkgCwd = !!raw.pkgCwd;
3729
- const rootPath = typeof raw.rootPath === 'string' ? raw.rootPath : './';
3730
- const argCount = thisCommand.args.length;
3731
- if (typeof commandOpt === 'string' && argCount > 0) {
3732
- logger.error(`--command option conflicts with cmd subcommand.`);
3733
- process.exit(0);
3734
- }
3735
- // Execute command.
3736
- if (typeof commandOpt === 'string')
3737
- await execShellCommandBatch({
3738
- command: resolveCommand(getDotenvCliOptions.scripts, commandOpt),
3739
- getDotenvCliOptions,
3740
- globs,
3741
- ignoreErrors,
3742
- list,
3743
- logger,
3744
- pkgCwd,
3745
- rootPath,
3746
- // execa expects string | boolean | URL for `shell`. We normalize earlier;
3747
- // scripts[name].shell overrides take precedence and may be boolean or string.
3748
- shell: resolveShell(getDotenvCliOptions.scripts, commandOpt, getDotenvCliOptions.shell),
3749
- });
3750
- })
3751
- .addCommand(cmdCommand, { isDefault: true });
3752
-
3753
- new Command()
3754
- .name('cmd')
3755
- .description('Execute command according to the --shell option, conflicts with --command option (default subcommand)')
3756
- .configureHelp({ showGlobalOptions: true })
3757
- .enablePositionalOptions()
3758
- .passThroughOptions()
3759
- .argument('[command...]')
3760
- .action(async (commandParts, _options, thisCommand) => {
3761
- const args = Array.isArray(commandParts) ? commandParts : [];
3762
- if (args.length === 0)
3763
- return;
3764
- if (!thisCommand.parent)
3765
- throw new Error('parent command not found');
3766
- const { getDotenvCliOptions: { logger = console, ...getDotenvCliOptions }, } = thisCommand.parent;
3767
- const command = args.map(String).join(' ');
3768
- const cmd = resolveCommand(getDotenvCliOptions.scripts, command);
3769
- if (getDotenvCliOptions.debug)
3770
- logger.log('\n*** command ***\n', `'${cmd}'`);
3771
- await execaCommand(cmd, {
3772
- env: {
3773
- ...process.env,
3774
- getDotenvCliOptions: JSON.stringify(getDotenvCliOptions),
3775
- },
3776
- // execa expects string | boolean | URL; we normalize in generator
3777
- // and allow script-level overrides.
3778
- shell: resolveShell(getDotenvCliOptions.scripts, command, getDotenvCliOptions.shell),
3779
- stdio: 'inherit',
3780
- });
3781
- });
3782
-
3783
3931
  function createCli(opts = {}) {
3784
3932
  const alias = typeof opts.alias === 'string' && opts.alias.length > 0
3785
3933
  ? opts.alias
3786
3934
  : 'getdotenv';
3787
3935
  const program = new GetDotenvCli(alias);
3936
+ // Normalize Commander output so help prints always end with a blank line.
3937
+ // This keeps E2E assertions (CRLF and >=2 trailing newlines) portable across
3938
+ // runtimes and capture modes without altering Commander internals.
3939
+ const outputCfg = {
3940
+ writeOut(str) {
3941
+ const txt = typeof str === 'string' ? str : '';
3942
+ const hasTwo = /(?:\r?\n){2,}$/.test(txt);
3943
+ const hasOne = /\r?\n$/.test(txt);
3944
+ const out = hasTwo ? txt : hasOne ? txt + '\n' : txt + '\n\n';
3945
+ try {
3946
+ process.stdout.write(out);
3947
+ }
3948
+ catch {
3949
+ /* ignore */
3950
+ }
3951
+ },
3952
+ writeErr(str) {
3953
+ process.stderr.write(str);
3954
+ },
3955
+ };
3956
+ // Apply to root and recursively to subcommands so all help paths are normalized.
3957
+ program.configureOutput(outputCfg);
3958
+ const applyOutputRecursively = (cmd) => {
3959
+ cmd.configureOutput(outputCfg);
3960
+ const kids = cmd.commands ?? [];
3961
+ for (const child of kids)
3962
+ applyOutputRecursively(child);
3963
+ };
3964
+ applyOutputRecursively(program);
3788
3965
  // Install base root flags and included plugins; resolve context once per run.
3789
3966
  program
3790
3967
  .attachRootOptions({ loadProcess: false })
@@ -3800,19 +3977,72 @@ function createCli(opts = {}) {
3800
3977
  if (underTests) {
3801
3978
  program.exitOverride((err) => {
3802
3979
  const code = err?.code;
3803
- if (code === 'commander.helpDisplayed' || code === 'commander.version')
3980
+ // Commander printed help already; ensure a trailing blank line for tests/CI capture.
3981
+ if (code === 'commander.helpDisplayed') {
3982
+ try {
3983
+ process.stdout.write('\n');
3984
+ }
3985
+ catch {
3986
+ /* ignore */
3987
+ }
3988
+ return;
3989
+ }
3990
+ if (code === 'commander.version') {
3804
3991
  return;
3992
+ }
3805
3993
  throw err;
3806
3994
  });
3807
3995
  }
3808
3996
  return {
3809
3997
  async run(argv) {
3810
- // Always short-circuit help to avoid Commander-triggered process.exit
3811
- // across environments (CJS/ESM) and to return immediately under dynamic
3812
- // ESM without performing extra IO. Prints help and returns.
3813
- if (argv.some((a) => a === '-h' || a === '--help')) {
3814
- program.outputHelp();
3815
- return;
3998
+ // Help handling:
3999
+ // - Short-circuit ONLY for true top-level -h/--help (no subcommand before flag).
4000
+ // - If a subcommand token appears before -h/--help, defer to Commander
4001
+ // to render that subcommand's help.
4002
+ const helpIdx = argv.findIndex((a) => a === '-h' || a === '--help');
4003
+ if (helpIdx >= 0) {
4004
+ // Build a set of known subcommand names/aliases on the root.
4005
+ const subs = new Set();
4006
+ const cmds = program.commands ?? [];
4007
+ for (const c of cmds) {
4008
+ subs.add(c.name());
4009
+ for (const a of c.aliases())
4010
+ subs.add(a);
4011
+ }
4012
+ const hasSubBeforeHelp = argv
4013
+ .slice(0, helpIdx)
4014
+ .some((tok) => subs.has(tok));
4015
+ if (!hasSubBeforeHelp) {
4016
+ await program.brand({
4017
+ name: alias,
4018
+ importMetaUrl: import.meta.url,
4019
+ description: 'Base CLI.',
4020
+ ...(typeof opts.branding === 'string' && opts.branding.length > 0
4021
+ ? { helpHeader: opts.branding }
4022
+ : {}),
4023
+ });
4024
+ // Resolve context once without side effects for help rendering.
4025
+ const ctx = await program.resolveAndLoad({
4026
+ loadProcess: false,
4027
+ log: false,
4028
+ }, { runAfterResolve: false });
4029
+ program.evaluateDynamicOptions({
4030
+ ...ctx.optionsResolved,
4031
+ plugins: ctx.pluginConfigs ?? {},
4032
+ });
4033
+ // Suppress output only during unit tests; allow E2E to capture.
4034
+ const piping = process.env.GETDOTENV_STDIO === 'pipe' ||
4035
+ process.env.GETDOTENV_STDOUT === 'pipe';
4036
+ if (underTests && !piping) {
4037
+ void program.helpInformation();
4038
+ }
4039
+ else {
4040
+ program.outputHelp();
4041
+ }
4042
+ return;
4043
+ }
4044
+ // Subcommand token exists before -h: fall through to normal parsing,
4045
+ // letting Commander print that subcommand's help.
3816
4046
  }
3817
4047
  await program.brand({
3818
4048
  name: alias,