@karmaniverous/get-dotenv 5.2.6 → 6.0.0-1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/README.md +106 -70
  2. package/dist/cliHost.d.ts +232 -226
  3. package/dist/cliHost.mjs +777 -545
  4. package/dist/config.d.ts +7 -2
  5. package/dist/env-overlay.d.ts +21 -9
  6. package/dist/env-overlay.mjs +14 -19
  7. package/dist/getdotenv.cli.mjs +1366 -1163
  8. package/dist/index.d.ts +415 -242
  9. package/dist/index.mjs +1364 -1414
  10. package/dist/plugins-aws.d.ts +149 -94
  11. package/dist/plugins-aws.mjs +307 -195
  12. package/dist/plugins-batch.d.ts +153 -99
  13. package/dist/plugins-batch.mjs +277 -95
  14. package/dist/plugins-cmd.d.ts +140 -94
  15. package/dist/plugins-cmd.mjs +636 -502
  16. package/dist/plugins-demo.d.ts +140 -94
  17. package/dist/plugins-demo.mjs +237 -46
  18. package/dist/plugins-init.d.ts +140 -94
  19. package/dist/plugins-init.mjs +129 -12
  20. package/dist/plugins.d.ts +166 -103
  21. package/dist/plugins.mjs +977 -840
  22. package/package.json +15 -53
  23. package/templates/cli/ts/plugins/hello.ts +27 -6
  24. package/templates/config/js/getdotenv.config.js +1 -1
  25. package/templates/config/ts/getdotenv.config.ts +9 -2
  26. package/dist/cliHost.cjs +0 -1875
  27. package/dist/cliHost.d.cts +0 -409
  28. package/dist/cliHost.d.mts +0 -409
  29. package/dist/config.cjs +0 -252
  30. package/dist/config.d.cts +0 -55
  31. package/dist/config.d.mts +0 -55
  32. package/dist/env-overlay.cjs +0 -163
  33. package/dist/env-overlay.d.cts +0 -50
  34. package/dist/env-overlay.d.mts +0 -50
  35. package/dist/index.cjs +0 -4140
  36. package/dist/index.d.cts +0 -457
  37. package/dist/index.d.mts +0 -457
  38. package/dist/plugins-aws.cjs +0 -667
  39. package/dist/plugins-aws.d.cts +0 -158
  40. package/dist/plugins-aws.d.mts +0 -158
  41. package/dist/plugins-batch.cjs +0 -616
  42. package/dist/plugins-batch.d.cts +0 -180
  43. package/dist/plugins-batch.d.mts +0 -180
  44. package/dist/plugins-cmd.cjs +0 -1113
  45. package/dist/plugins-cmd.d.cts +0 -178
  46. package/dist/plugins-cmd.d.mts +0 -178
  47. package/dist/plugins-demo.cjs +0 -307
  48. package/dist/plugins-demo.d.cts +0 -158
  49. package/dist/plugins-demo.d.mts +0 -158
  50. package/dist/plugins-init.cjs +0 -289
  51. package/dist/plugins-init.d.cts +0 -162
  52. package/dist/plugins-init.d.mts +0 -162
  53. package/dist/plugins.cjs +0 -2283
  54. package/dist/plugins.d.cts +0 -210
  55. package/dist/plugins.d.mts +0 -210
@@ -1,25 +1,43 @@
1
- import { Command } from 'commander';
2
1
  import { execa, execaCommand } from 'execa';
2
+ import { z } from 'zod';
3
3
  import 'fs-extra';
4
- import 'package-directory';
5
4
  import 'path';
5
+ import 'package-directory';
6
+ import 'url';
7
+ import 'yaml';
8
+ import 'nanoid';
9
+ import 'dotenv';
10
+ import 'crypto';
6
11
 
7
12
  // Minimal tokenizer for shell-off execution:
8
13
  // Splits by whitespace while preserving quoted segments (single or double quotes).
9
- const tokenize = (command) => {
14
+ // Optionally preserve doubled quotes inside quoted segments:
15
+ // - default: "" => " (Windows/PowerShell style literal-quote escape)
16
+ // - preserveDoubledQuotes: true => "" stays "" (needed for Node -e payloads)
17
+ const tokenize = (command, opts) => {
10
18
  const out = [];
11
19
  let cur = '';
12
20
  let quote = null;
21
+ const preserve = opts && opts.preserveDoubledQuotes === true ? true : false;
13
22
  for (let i = 0; i < command.length; i++) {
14
23
  const c = command.charAt(i);
15
24
  if (quote) {
16
25
  if (c === quote) {
17
- // Support doubled quotes inside a quoted segment (Windows/PowerShell style):
18
- // "" -> " and '' -> '
26
+ // Support doubled quotes inside a quoted segment:
27
+ // default: "" -> " and '' -> ' (Windows/PowerShell style)
28
+ // preserve: keep as "" to allow empty string literals in Node -e payloads
19
29
  const next = command.charAt(i + 1);
20
30
  if (next === quote) {
21
- cur += quote;
22
- i += 1; // skip the second quote
31
+ if (preserve) {
32
+ // Keep "" as-is; append both and continue within the quoted segment.
33
+ cur += quote + quote;
34
+ i += 1; // skip the second quote char (we already appended both)
35
+ }
36
+ else {
37
+ // Collapse to a single literal quote
38
+ cur += quote;
39
+ i += 1; // skip the second quote
40
+ }
23
41
  }
24
42
  else {
25
43
  // end of quoted segment
@@ -74,6 +92,17 @@ const stripOuterQuotes = (s) => {
74
92
  }
75
93
  return out;
76
94
  };
95
+ // Extract exitCode/stdout/stderr from execa result or error in a tolerant way.
96
+ const pickResult = (r) => {
97
+ const exit = r.exitCode;
98
+ const stdoutVal = r.stdout;
99
+ const stderrVal = r.stderr;
100
+ return {
101
+ exitCode: typeof exit === 'number' ? exit : Number.NaN,
102
+ stdout: typeof stdoutVal === 'string' ? stdoutVal : '',
103
+ stderr: typeof stderrVal === 'string' ? stderrVal : '',
104
+ };
105
+ };
77
106
  // Convert NodeJS.ProcessEnv (string | undefined values) to the shape execa
78
107
  // expects (Readonly<Partial<Record<string, string>>>), dropping undefineds.
79
108
  const sanitizeEnv = (env) => {
@@ -82,19 +111,19 @@ const sanitizeEnv = (env) => {
82
111
  const entries = Object.entries(env).filter((e) => typeof e[1] === 'string');
83
112
  return entries.length > 0 ? Object.fromEntries(entries) : undefined;
84
113
  };
85
- const runCommand = async (command, shell, opts) => {
114
+ async function runCommand(command, shell, opts) {
86
115
  if (shell === false) {
87
116
  let file;
88
117
  let args = [];
89
- if (Array.isArray(command)) {
90
- file = command[0];
91
- args = command.slice(1).map(stripOuterQuotes);
92
- }
93
- else {
118
+ if (typeof command === 'string') {
94
119
  const tokens = tokenize(command);
95
120
  file = tokens[0];
96
121
  args = tokens.slice(1);
97
122
  }
123
+ else {
124
+ file = command[0];
125
+ args = command.slice(1).map(stripOuterQuotes);
126
+ }
98
127
  if (!file)
99
128
  return 0;
100
129
  dbg$1('exec (plain)', { file, args, stdio: opts.stdio });
@@ -107,16 +136,15 @@ const runCommand = async (command, shell, opts) => {
107
136
  plainOpts.env = envSan;
108
137
  if (opts.stdio !== undefined)
109
138
  plainOpts.stdio = opts.stdio;
110
- const result = await execa(file, args, plainOpts);
111
- if (opts.stdio === 'pipe' && result.stdout) {
112
- process.stdout.write(result.stdout + (result.stdout.endsWith('\n') ? '' : '\n'));
139
+ const ok = pickResult((await execa(file, args, plainOpts)));
140
+ if (opts.stdio === 'pipe' && ok.stdout) {
141
+ process.stdout.write(ok.stdout + (ok.stdout.endsWith('\n') ? '' : '\n'));
113
142
  }
114
- const exit = result?.exitCode;
115
- dbg$1('exit (plain)', { exitCode: exit });
116
- return typeof exit === 'number' ? exit : Number.NaN;
143
+ dbg$1('exit (plain)', { exitCode: ok.exitCode });
144
+ return typeof ok.exitCode === 'number' ? ok.exitCode : Number.NaN;
117
145
  }
118
146
  else {
119
- const commandStr = Array.isArray(command) ? command.join(' ') : command;
147
+ const commandStr = typeof command === 'string' ? command : command.join(' ');
120
148
  dbg$1('exec (shell)', {
121
149
  shell: typeof shell === 'string' ? shell : 'custom',
122
150
  stdio: opts.stdio,
@@ -130,17 +158,29 @@ const runCommand = async (command, shell, opts) => {
130
158
  shellOpts.env = envSan;
131
159
  if (opts.stdio !== undefined)
132
160
  shellOpts.stdio = opts.stdio;
133
- const result = await execaCommand(commandStr, shellOpts);
134
- const out = result?.stdout;
135
- if (opts.stdio === 'pipe' && out) {
136
- process.stdout.write(out + (out.endsWith('\n') ? '' : '\n'));
161
+ const ok = pickResult((await execaCommand(commandStr, shellOpts)));
162
+ if (opts.stdio === 'pipe' && ok.stdout) {
163
+ process.stdout.write(ok.stdout + (ok.stdout.endsWith('\n') ? '' : '\n'));
137
164
  }
138
- const exit = result?.exitCode;
139
- dbg$1('exit (shell)', { exitCode: exit });
140
- return typeof exit === 'number' ? exit : Number.NaN;
165
+ dbg$1('exit (shell)', { exitCode: ok.exitCode });
166
+ return typeof ok.exitCode === 'number' ? ok.exitCode : Number.NaN;
141
167
  }
142
- };
168
+ }
143
169
 
170
+ /** src/cliCore/spawnEnv.ts
171
+ * Build a sanitized environment bag for child processes.
172
+ *
173
+ * Requirements addressed:
174
+ * - Provide a single helper (buildSpawnEnv) to normalize/dedupe child env.
175
+ * - Drop undefined values (exactOptional semantics).
176
+ * - On Windows, dedupe keys case-insensitively and prefer the last value,
177
+ * preserving the latest key's casing. Ensure HOME fallback from USERPROFILE.
178
+ * Normalize TMP/TEMP consistency when either is present.
179
+ * - On POSIX, keep keys as-is; when a temp dir key is present (TMPDIR/TMP/TEMP),
180
+ * ensure TMPDIR exists for downstream consumers that expect it.
181
+ *
182
+ * Adapter responsibility: pure mapping; no business logic.
183
+ */
144
184
  const dropUndefined = (bag) => Object.fromEntries(Object.entries(bag).filter((e) => typeof e[1] === 'string'));
145
185
  /** Build a sanitized env for child processes from base + overlay. */
146
186
  const buildSpawnEnv = (base, overlay) => {
@@ -183,33 +223,145 @@ const buildSpawnEnv = (base, overlay) => {
183
223
  return out;
184
224
  };
185
225
 
186
- /** src/cliHost/definePlugin.ts
187
- * Plugin contracts for the GetDotenv CLI host.
226
+ /**
227
+ * Zod schemas for configuration files discovered by the new loader.
188
228
  *
189
- * This module exposes a structural public interface for the host that plugins
190
- * should use (GetDotenvCliPublic). Using a structural type at the seam avoids
191
- * nominal class identity issues (private fields) in downstream consumers.
229
+ * Notes:
230
+ * - RAW: all fields optional; shapes are stringly-friendly (paths may be string[] or string).
231
+ * - RESOLVED: normalized shapes (paths always string[]).
232
+ * - For JSON/YAML configs, the loader rejects "dynamic" and "schema" (JS/TS-only).
192
233
  */
234
+ // String-only env value map
235
+ const stringMap = z.record(z.string(), z.string());
236
+ const envStringMap = z.record(z.string(), stringMap);
237
+ // Allow string[] or single string for "paths" in RAW; normalize later.
238
+ const rawPathsSchema = z.union([z.array(z.string()), z.string()]).optional();
239
+ const getDotenvConfigSchemaRaw = z.object({
240
+ dotenvToken: z.string().optional(),
241
+ privateToken: z.string().optional(),
242
+ paths: rawPathsSchema,
243
+ loadProcess: z.boolean().optional(),
244
+ log: z.boolean().optional(),
245
+ shell: z.union([z.string(), z.boolean()]).optional(),
246
+ scripts: z.record(z.string(), z.unknown()).optional(), // Scripts validation left wide; generator validates elsewhere
247
+ requiredKeys: z.array(z.string()).optional(),
248
+ schema: z.unknown().optional(), // JS/TS-only; loader rejects in JSON/YAML
249
+ vars: stringMap.optional(), // public, global
250
+ envVars: envStringMap.optional(), // public, per-env
251
+ // Dynamic in config (JS/TS only). JSON/YAML loader will reject if set.
252
+ dynamic: z.unknown().optional(),
253
+ // Per-plugin config bag; validated by plugins/host when used.
254
+ plugins: z.record(z.string(), z.unknown()).optional(),
255
+ });
256
+ // Normalize paths to string[]
257
+ const normalizePaths = (p) => p === undefined ? undefined : Array.isArray(p) ? p : [p];
258
+ getDotenvConfigSchemaRaw.transform((raw) => ({
259
+ ...raw,
260
+ paths: normalizePaths(raw.paths),
261
+ }));
262
+
193
263
  /**
194
- * Define a GetDotenv CLI plugin with compositional helpers.
264
+ * Dotenv expansion utilities.
265
+ *
266
+ * This module implements recursive expansion of environment-variable
267
+ * references in strings and records. It supports both whitespace and
268
+ * bracket syntaxes with optional defaults:
269
+ *
270
+ * - Whitespace: `$VAR[:default]`
271
+ * - Bracketed: `${VAR[:default]}`
272
+ *
273
+ * Escaped dollar signs (`\$`) are preserved.
274
+ * Unknown variables resolve to empty string unless a default is provided.
275
+ */
276
+ /**
277
+ * Like String.prototype.search but returns the last index.
278
+ * @internal
279
+ */
280
+ const searchLast = (str, rgx) => {
281
+ const matches = Array.from(str.matchAll(rgx));
282
+ return matches.length > 0 ? (matches.slice(-1)[0]?.index ?? -1) : -1;
283
+ };
284
+ const replaceMatch = (value, match, ref) => {
285
+ /**
286
+ * @internal
287
+ */
288
+ const group = match[0];
289
+ const key = match[1];
290
+ const defaultValue = match[2];
291
+ if (!key)
292
+ return value;
293
+ const replacement = value.replace(group, ref[key] ?? defaultValue ?? '');
294
+ return interpolate(replacement, ref);
295
+ };
296
+ const interpolate = (value = '', ref = {}) => {
297
+ /**
298
+ * @internal
299
+ */
300
+ // if value is falsy, return it as is
301
+ if (!value)
302
+ return value;
303
+ // get position of last unescaped dollar sign
304
+ const lastUnescapedDollarSignIndex = searchLast(value, /(?!(?<=\\))\$/g);
305
+ // return value if none found
306
+ if (lastUnescapedDollarSignIndex === -1)
307
+ return value;
308
+ // evaluate the value tail
309
+ const tail = value.slice(lastUnescapedDollarSignIndex);
310
+ // find whitespace pattern: $KEY:DEFAULT
311
+ const whitespacePattern = /^\$([\w]+)(?::([^\s]*))?/;
312
+ const whitespaceMatch = whitespacePattern.exec(tail);
313
+ if (whitespaceMatch != null)
314
+ return replaceMatch(value, whitespaceMatch, ref);
315
+ else {
316
+ // find bracket pattern: ${KEY:DEFAULT}
317
+ const bracketPattern = /^\${([\w]+)(?::([^}]*))?}/;
318
+ const bracketMatch = bracketPattern.exec(tail);
319
+ if (bracketMatch != null)
320
+ return replaceMatch(value, bracketMatch, ref);
321
+ }
322
+ return value;
323
+ };
324
+ /**
325
+ * Recursively expands environment variables in a string. Variables may be
326
+ * presented with optional default as `$VAR[:default]` or `${VAR[:default]}`.
327
+ * Unknown variables will expand to an empty string.
328
+ *
329
+ * @param value - The string to expand.
330
+ * @param ref - The reference object to use for variable expansion.
331
+ * @returns The expanded string.
195
332
  *
196
333
  * @example
197
- * const parent = definePlugin(\{ id: 'p', setup(cli) \{ /* ... *\/ \} \})
198
- * .use(childA)
199
- * .use(childB);
334
+ * ```ts
335
+ * process.env.FOO = 'bar';
336
+ * dotenvExpand('Hello $FOO'); // "Hello bar"
337
+ * dotenvExpand('Hello $BAZ:world'); // "Hello world"
338
+ * ```
339
+ *
340
+ * @remarks
341
+ * The expansion is recursive. If a referenced variable itself contains
342
+ * references, those will also be expanded until a stable value is reached.
343
+ * Escaped references (e.g. `\$FOO`) are preserved as literals.
200
344
  */
201
- const definePlugin = (spec) => {
202
- const { children = [], ...rest } = spec;
203
- const plugin = {
204
- ...rest,
205
- children: [...children],
206
- use(child) {
207
- this.children.push(child);
208
- return this;
209
- },
210
- };
211
- return plugin;
345
+ const dotenvExpand = (value, ref = process.env) => {
346
+ const result = interpolate(value, ref);
347
+ return result ? result.replace(/\\\$/g, '$') : undefined;
212
348
  };
349
+ /**
350
+ * Recursively expands environment variables in a string using `process.env` as
351
+ * the expansion reference. Variables may be presented with optional default as
352
+ * `$VAR[:default]` or `${VAR[:default]}`. Unknown variables will expand to an
353
+ * empty string.
354
+ *
355
+ * @param value - The string to expand.
356
+ * @returns The expanded string.
357
+ *
358
+ * @example
359
+ * ```ts
360
+ * process.env.FOO = 'bar';
361
+ * dotenvExpandFromProcessEnv('Hello $FOO'); // "Hello bar"
362
+ * ```
363
+ */
364
+ const dotenvExpandFromProcessEnv = (value) => dotenvExpand(value, process.env);
213
365
 
214
366
  /** src/diagnostics/entropy.ts
215
367
  * Entropy diagnostics (presentation-only).
@@ -219,7 +371,7 @@ const definePlugin = (spec) => {
219
371
  */
220
372
  const warned = new Set();
221
373
  const isPrintableAscii = (s) => /^[\x20-\x7E]+$/.test(s);
222
- const compile$1 = (patterns) => (patterns ?? []).map((p) => new RegExp(p, 'i'));
374
+ const compile$1 = (patterns) => (patterns ?? []).map((p) => (typeof p === 'string' ? new RegExp(p, 'i') : p));
223
375
  const whitelisted = (key, regs) => regs.some((re) => re.test(key));
224
376
  const shannonBitsPerChar = (s) => {
225
377
  const freq = new Map();
@@ -266,7 +418,7 @@ const DEFAULT_PATTERNS = [
266
418
  '\\bapi[_-]?key\\b',
267
419
  '\\bkey\\b',
268
420
  ];
269
- const compile = (patterns) => (patterns && patterns.length > 0 ? patterns : DEFAULT_PATTERNS).map((p) => new RegExp(p, 'i'));
421
+ const compile = (patterns) => (patterns && patterns.length > 0 ? patterns : DEFAULT_PATTERNS).map((p) => typeof p === 'string' ? new RegExp(p, 'i') : p);
270
422
  const shouldRedactKey = (key, regs) => regs.some((re) => re.test(key));
271
423
  const MASK = '[redacted]';
272
424
  /**
@@ -291,34 +443,6 @@ const redactTriple = (key, triple, opts) => {
291
443
  return out;
292
444
  };
293
445
 
294
- /**
295
- * Batch services (neutral): resolve command and shell settings.
296
- * Shared by the generator path and the batch plugin to avoid circular deps.
297
- */
298
- /**
299
- * Resolve a command string from the {@link Scripts} table.
300
- * A script may be expressed as a string or an object with a `cmd` property.
301
- *
302
- * @param scripts - Optional scripts table.
303
- * @param command - User-provided command name or string.
304
- * @returns Resolved command string (falls back to the provided command).
305
- */
306
- const resolveCommand = (scripts, command) => scripts && typeof scripts[command] === 'object'
307
- ? scripts[command].cmd
308
- : (scripts?.[command] ?? command);
309
- /**
310
- * Resolve the shell setting for a given command:
311
- * - If the script entry is an object, prefer its `shell` override.
312
- * - Otherwise use the provided `shell` (string | boolean).
313
- *
314
- * @param scripts - Optional scripts table.
315
- * @param command - User-provided command name or string.
316
- * @param shell - Global shell preference (string | boolean).
317
- */
318
- const resolveShell = (scripts, command, shell) => scripts && typeof scripts[command] === 'object'
319
- ? (scripts[command].shell ?? false)
320
- : (shell ?? false);
321
-
322
446
  // Base root CLI defaults (shared; kept untyped here to avoid cross-layer deps).
323
447
  const baseRootOptionDefaults = {
324
448
  dotenvToken: '.env',
@@ -346,6 +470,8 @@ const baseRootOptionDefaults = {
346
470
  // (debug/log/exclude* resolved via flag utils)
347
471
  };
348
472
 
473
+ const baseGetDotenvCliOptions = baseRootOptionDefaults;
474
+
349
475
  /** @internal */
350
476
  const isPlainObject = (value) => value !== null &&
351
477
  typeof value === 'object' &&
@@ -367,27 +493,223 @@ const mergeInto = (target, source) => {
367
493
  }
368
494
  return target;
369
495
  };
370
- /**
371
- * Perform a deep defaults-style merge across plain objects. *
372
- * - Only merges plain objects (prototype === Object.prototype).
373
- * - Arrays and non-objects are replaced, not merged.
374
- * - `undefined` values are ignored and do not overwrite prior values.
375
- *
376
- * @typeParam T - The resulting shape after merging all layers.
377
- * @param layers - Zero or more partial layers in ascending precedence order.
378
- * @returns The merged object typed as {@link T}.
379
- *
380
- * @example
381
- * defaultsDeep(\{ a: 1, nested: \{ b: 2 \} \}, \{ nested: \{ b: 3, c: 4 \} \})
382
- * =\> \{ a: 1, nested: \{ b: 3, c: 4 \} \}
383
- */
384
- const defaultsDeep = (...layers) => {
496
+ function defaultsDeep(...layers) {
385
497
  const result = layers
386
498
  .filter(Boolean)
387
499
  .reduce((acc, layer) => mergeInto(acc, layer), {});
388
500
  return result;
501
+ }
502
+
503
+ /** src/util/omitUndefined.ts
504
+ * Helpers to drop undefined-valued properties in a typed-friendly way.
505
+ */
506
+ /**
507
+ * Omit keys whose runtime value is undefined from a shallow object.
508
+ * Returns a Partial with non-undefined value types preserved.
509
+ */
510
+ /**
511
+ * Specialized helper for env-like maps: drop undefined and return string-only.
512
+ */
513
+ function omitUndefinedRecord(obj) {
514
+ const out = {};
515
+ for (const [k, v] of Object.entries(obj)) {
516
+ if (v !== undefined)
517
+ out[k] = v;
518
+ }
519
+ return out;
520
+ }
521
+
522
+ // src/GetDotenvOptions.ts
523
+ /**
524
+ * Canonical programmatic options and helpers for get-dotenv.
525
+ *
526
+ * Requirements addressed:
527
+ * - GetDotenvOptions derives from the Zod schema output (single source of truth).
528
+ * - Removed deprecated/compat flags from the public shape (e.g., useConfigLoader).
529
+ * - Provide Vars-aware defineDynamic and a typed config builder defineGetDotenvConfig\<Vars, Env\>().
530
+ * - Preserve existing behavior for defaults resolution and compat converters.
531
+ */
532
+ /**
533
+ * Converts programmatic CLI options to `getDotenv` options.
534
+ *
535
+ * Accepts "stringly" CLI inputs for vars/paths and normalizes them into
536
+ * the programmatic shape. Preserves exactOptionalPropertyTypes semantics by
537
+ * omitting keys when undefined.
538
+ */
539
+ const getDotenvCliOptions2Options = ({ paths, pathsDelimiter, pathsDelimiterPattern, vars, varsAssignor, varsAssignorPattern, varsDelimiter, varsDelimiterPattern,
540
+ // drop CLI-only keys from the pass-through bag
541
+ debug: _debug, scripts: _scripts, ...rest }) => {
542
+ // Split helper for delimited strings or regex patterns
543
+ const splitBy = (value, delim, pattern) => {
544
+ if (!value)
545
+ return [];
546
+ if (pattern)
547
+ return value.split(RegExp(pattern));
548
+ if (typeof delim === 'string')
549
+ return value.split(delim);
550
+ return value.split(' ');
551
+ };
552
+ // Tolerate vars as either a CLI string ("A=1 B=2") or an object map.
553
+ let parsedVars;
554
+ if (typeof vars === 'string') {
555
+ const kvPairs = splitBy(vars, varsDelimiter, varsDelimiterPattern)
556
+ .map((v) => v.split(varsAssignorPattern
557
+ ? RegExp(varsAssignorPattern)
558
+ : (varsAssignor ?? '=')))
559
+ .filter(([k]) => typeof k === 'string' && k.length > 0);
560
+ parsedVars = Object.fromEntries(kvPairs);
561
+ }
562
+ else if (vars && typeof vars === 'object' && !Array.isArray(vars)) {
563
+ // Accept provided object map of string | undefined; drop undefined values
564
+ // in the normalization step below to produce a ProcessEnv-compatible bag.
565
+ parsedVars = Object.fromEntries(Object.entries(vars));
566
+ }
567
+ // Drop undefined-valued entries at the converter stage to match ProcessEnv
568
+ // expectations and the compat test assertions.
569
+ if (parsedVars) {
570
+ parsedVars = omitUndefinedRecord(parsedVars);
571
+ }
572
+ // Tolerate paths as either a delimited string or string[]
573
+ const pathsOut = Array.isArray(paths)
574
+ ? paths.filter((p) => typeof p === 'string')
575
+ : splitBy(paths, pathsDelimiter, pathsDelimiterPattern);
576
+ // Preserve exactOptionalPropertyTypes: only include keys when defined.
577
+ return {
578
+ ...rest,
579
+ ...(pathsOut.length > 0 ? { paths: pathsOut } : {}),
580
+ ...(parsedVars !== undefined ? { vars: parsedVars } : {}),
581
+ };
389
582
  };
390
583
 
584
+ /**
585
+ * Zod schemas for programmatic GetDotenv options.
586
+ *
587
+ * Canonical source of truth for options shape. Public types are derived
588
+ * from these schemas (see consumers via z.output\<\>).
589
+ */
590
+ // Minimal process env representation: string values or undefined to indicate "unset".
591
+ const processEnvSchema = z.record(z.string(), z.string().optional());
592
+ // RAW: all fields optional — undefined means "inherit" from lower layers.
593
+ z.object({
594
+ defaultEnv: z.string().optional(),
595
+ dotenvToken: z.string().optional(),
596
+ dynamicPath: z.string().optional(),
597
+ // Dynamic map is intentionally wide for now; refine once sources are normalized.
598
+ dynamic: z.record(z.string(), z.unknown()).optional(),
599
+ env: z.string().optional(),
600
+ excludeDynamic: z.boolean().optional(),
601
+ excludeEnv: z.boolean().optional(),
602
+ excludeGlobal: z.boolean().optional(),
603
+ excludePrivate: z.boolean().optional(),
604
+ excludePublic: z.boolean().optional(),
605
+ loadProcess: z.boolean().optional(),
606
+ log: z.boolean().optional(),
607
+ logger: z.unknown().optional(),
608
+ outputPath: z.string().optional(),
609
+ paths: z.array(z.string()).optional(),
610
+ privateToken: z.string().optional(),
611
+ vars: processEnvSchema.optional(),
612
+ });
613
+
614
+ /**
615
+ * Instance-bound plugin config store.
616
+ * Host stores the validated/interpolated slice per plugin instance.
617
+ * The store is intentionally private to this module; definePlugin()
618
+ * provides a typed accessor that reads from this store for the calling
619
+ * plugin instance.
620
+ */
621
+ const PLUGIN_CONFIG_STORE = new WeakMap();
622
+ const _getPluginConfigForInstance = (plugin) => PLUGIN_CONFIG_STORE.get(plugin);
623
+
624
+ /** src/cliHost/definePlugin.ts
625
+ * Plugin contracts for the GetDotenv CLI host.
626
+ *
627
+ * This module exposes a structural public interface for the host that plugins
628
+ * should use (GetDotenvCliPublic). Using a structural type at the seam avoids
629
+ * nominal class identity issues (private fields) in downstream consumers.
630
+ */
631
+ /* eslint-disable tsdoc/syntax */
632
+ function definePlugin(spec) {
633
+ const { children = [], ...rest } = spec;
634
+ // Default to a strict empty-object schema so “no-config” plugins fail fast
635
+ // on unknown keys and provide a concrete {} at runtime.
636
+ const effectiveSchema = spec.configSchema ?? z.object({}).strict();
637
+ // Build base plugin first, then extend with instance-bound helpers.
638
+ const base = {
639
+ ...rest,
640
+ // Always carry a schema (strict empty by default) to simplify host logic
641
+ // and improve inference/ergonomics for plugin authors.
642
+ configSchema: effectiveSchema,
643
+ children: [...children],
644
+ use(child) {
645
+ this.children.push(child);
646
+ return this;
647
+ },
648
+ };
649
+ // Attach instance-bound helpers on the returned plugin object.
650
+ const extended = base;
651
+ extended.readConfig = function (_cli) {
652
+ // Config is stored per-plugin-instance by the host (WeakMap in computeContext).
653
+ const value = _getPluginConfigForInstance(extended);
654
+ if (value === undefined) {
655
+ // Guard: host has not resolved config yet (incorrect lifecycle usage).
656
+ throw new Error('Plugin config not available. Ensure resolveAndLoad() has been called before readConfig().');
657
+ }
658
+ return value;
659
+ };
660
+ // Plugin-bound dynamic option factory
661
+ extended.createPluginDynamicOption = function (cli, flags, desc, parser, defaultValue) {
662
+ return cli.createDynamicOption(flags, (cfg) => {
663
+ // Prefer the validated slice stored per instance; fallback to help-bag
664
+ // (by-id) so top-level `-h` can render effective defaults before resolve.
665
+ const fromStore = _getPluginConfigForInstance(extended);
666
+ const id = extended.id;
667
+ let fromBag;
668
+ if (!fromStore && id) {
669
+ const maybe = cfg.plugins[id];
670
+ if (maybe && typeof maybe === 'object') {
671
+ fromBag = maybe;
672
+ }
673
+ }
674
+ // Always provide a concrete object to dynamic callbacks:
675
+ // - With a schema: computeContext stores the parsed object.
676
+ // - Without a schema: computeContext stores {}.
677
+ // - Help-time fallback: coalesce to {} when only a by-id bag exists.
678
+ const cfgVal = (fromStore ?? fromBag ?? {});
679
+ return desc(cfg, cfgVal);
680
+ }, parser, defaultValue);
681
+ };
682
+ return extended;
683
+ }
684
+
685
+ /**
686
+ * Batch services (neutral): resolve command and shell settings.
687
+ * Shared by the generator path and the batch plugin to avoid circular deps.
688
+ */
689
+ /**
690
+ * Resolve a command string from the {@link Scripts} table.
691
+ * A script may be expressed as a string or an object with a `cmd` property.
692
+ *
693
+ * @param scripts - Optional scripts table.
694
+ * @param command - User-provided command name or string.
695
+ * @returns Resolved command string (falls back to the provided command).
696
+ */
697
+ const resolveCommand = (scripts, command) => scripts && typeof scripts[command] === 'object'
698
+ ? scripts[command].cmd
699
+ : (scripts?.[command] ?? command);
700
+ /**
701
+ * Resolve the shell setting for a given command:
702
+ * - If the script entry is an object, prefer its `shell` override.
703
+ * - Otherwise use the provided `shell` (string | boolean).
704
+ *
705
+ * @param scripts - Optional scripts table.
706
+ * @param command - User-provided command name or string.
707
+ * @param shell - Global shell preference (string | boolean).
708
+ */
709
+ const resolveShell = (scripts, command, shell) => scripts && typeof scripts[command] === 'object'
710
+ ? (scripts[command].shell ?? false)
711
+ : (shell ?? false);
712
+
391
713
  /**
392
714
  * Resolve a tri-state optional boolean flag under exactOptionalPropertyTypes.
393
715
  * - If the user explicitly enabled the flag, return true.
@@ -513,180 +835,225 @@ const resolveCliOptions = (rawCliOptions, defaults, parentJson) => {
513
835
  return cmd !== undefined ? { merged, command: cmd } : { merged };
514
836
  };
515
837
 
516
- /**
517
- * Dotenv expansion utilities.
518
- *
519
- * This module implements recursive expansion of environment-variable
520
- * references in strings and records. It supports both whitespace and
521
- * bracket syntaxes with optional defaults:
522
- *
523
- * - Whitespace: `$VAR[:default]`
524
- * - Bracketed: `${VAR[:default]}`
525
- *
526
- * Escaped dollar signs (`\$`) are preserved.
527
- * Unknown variables resolve to empty string unless a default is provided.
528
- */
529
- /**
530
- * Like String.prototype.search but returns the last index.
531
- * @internal
532
- */
533
- const searchLast = (str, rgx) => {
534
- const matches = Array.from(str.matchAll(rgx));
535
- return matches.length > 0 ? (matches.slice(-1)[0]?.index ?? -1) : -1;
536
- };
537
- const replaceMatch = (value, match, ref) => {
538
- /**
539
- * @internal
540
- */
541
- const group = match[0];
542
- const key = match[1];
543
- const defaultValue = match[2];
544
- if (!key)
545
- return value;
546
- const replacement = value.replace(group, ref[key] ?? defaultValue ?? '');
547
- return interpolate(replacement, ref);
548
- };
549
- const interpolate = (value = '', ref = {}) => {
550
- /**
551
- * @internal
552
- */
553
- // if value is falsy, return it as is
554
- if (!value)
555
- return value;
556
- // get position of last unescaped dollar sign
557
- const lastUnescapedDollarSignIndex = searchLast(value, /(?!(?<=\\))\$/g);
558
- // return value if none found
559
- if (lastUnescapedDollarSignIndex === -1)
560
- return value;
561
- // evaluate the value tail
562
- const tail = value.slice(lastUnescapedDollarSignIndex);
563
- // find whitespace pattern: $KEY:DEFAULT
564
- const whitespacePattern = /^\$([\w]+)(?::([^\s]*))?/;
565
- const whitespaceMatch = whitespacePattern.exec(tail);
566
- if (whitespaceMatch != null)
567
- return replaceMatch(value, whitespaceMatch, ref);
568
- else {
569
- // find bracket pattern: ${KEY:DEFAULT}
570
- const bracketPattern = /^\${([\w]+)(?::([^}]*))?}/;
571
- const bracketMatch = bracketPattern.exec(tail);
572
- if (bracketMatch != null)
573
- return replaceMatch(value, bracketMatch, ref);
838
+ const dbg = (...args) => {
839
+ if (process.env.GETDOTENV_DEBUG) {
840
+ // Use stderr to avoid interfering with stdout assertions
841
+ console.error('[getdotenv:alias]', ...args);
574
842
  }
575
- return value;
576
843
  };
577
- /**
578
- * Recursively expands environment variables in a string. Variables may be
579
- * presented with optional default as `$VAR[:default]` or `${VAR[:default]}`.
580
- * Unknown variables will expand to an empty string.
581
- *
582
- * @param value - The string to expand.
583
- * @param ref - The reference object to use for variable expansion.
584
- * @returns The expanded string.
585
- *
586
- * @example
587
- * ```ts
588
- * process.env.FOO = 'bar';
589
- * dotenvExpand('Hello $FOO'); // "Hello bar"
590
- * dotenvExpand('Hello $BAZ:world'); // "Hello world"
591
- * ```
592
- *
593
- * @remarks
594
- * The expansion is recursive. If a referenced variable itself contains
595
- * references, those will also be expanded until a stable value is reached.
596
- * Escaped references (e.g. `\$FOO`) are preserved as literals.
597
- */
598
- const dotenvExpand = (value, ref = process.env) => {
599
- const result = interpolate(value, ref);
600
- return result ? result.replace(/\\\$/g, '$') : undefined;
844
+ // Strip one symmetric outer quote layer
845
+ const stripOne = (s) => {
846
+ if (s.length < 2)
847
+ return s;
848
+ const a = s.charAt(0);
849
+ const b = s.charAt(s.length - 1);
850
+ const symmetric = (a === '"' && b === '"') || (a === "'" && b === "'");
851
+ return symmetric ? s.slice(1, -1) : s;
601
852
  };
602
- /**
603
- * Recursively expands environment variables in a string using `process.env` as
604
- * the expansion reference. Variables may be presented with optional default as
605
- * `$VAR[:default]` or `${VAR[:default]}`. Unknown variables will expand to an
606
- * empty string.
607
- *
608
- * @param value - The string to expand.
609
- * @returns The expanded string.
610
- *
611
- * @example
612
- * ```ts
613
- * process.env.FOO = 'bar';
614
- * dotenvExpandFromProcessEnv('Hello $FOO'); // "Hello bar"
615
- * ```
616
- */
617
- const dotenvExpandFromProcessEnv = (value) => dotenvExpand(value, process.env);
618
-
619
- // src/GetDotenvOptions.ts
620
- /**
621
- * Converts programmatic CLI options to `getDotenv` options. *
622
- * @param cliOptions - CLI options. Defaults to `{}`.
623
- *
624
- * @returns `getDotenv` options.
625
- */
626
- const getDotenvCliOptions2Options = ({ paths, pathsDelimiter, pathsDelimiterPattern, vars, varsAssignor, varsAssignorPattern, varsDelimiter, varsDelimiterPattern, ...rest }) => {
627
- /**
628
- * Convert CLI-facing string options into {@link GetDotenvOptions}.
629
- *
630
- * - 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`
631
- * pairs (configurable delimiters) into a {@link ProcessEnv}.
632
- * - Drops CLI-only keys that have no programmatic equivalent.
633
- *
634
- * @remarks
635
- * Follows exact-optional semantics by not emitting undefined-valued entries.
636
- */
637
- // Drop CLI-only keys (debug/scripts) without relying on Record casts.
638
- // Create a shallow copy then delete optional CLI-only keys if present.
639
- const restObj = { ...rest };
640
- delete restObj.debug;
641
- delete restObj.scripts;
642
- const splitBy = (value, delim, pattern) => (value ? value.split(pattern ? RegExp(pattern) : (delim ?? ' ')) : []);
643
- // Tolerate vars as either a CLI string ("A=1 B=2") or an object map.
644
- let parsedVars;
645
- if (typeof vars === 'string') {
646
- const kvPairs = splitBy(vars, varsDelimiter, varsDelimiterPattern).map((v) => v.split(varsAssignorPattern
647
- ? RegExp(varsAssignorPattern)
648
- : (varsAssignor ?? '=')));
649
- parsedVars = Object.fromEntries(kvPairs);
853
+ async function maybeRunAlias(cli, thisCommand, aliasKey, state) {
854
+ dbg('alias:maybe:start');
855
+ const raw = thisCommand.rawArgs ?? [];
856
+ const childNames = thisCommand.commands.flatMap((c) => [
857
+ c.name(),
858
+ ...c.aliases(),
859
+ ]);
860
+ const hasSub = childNames.some((n) => raw.includes(n));
861
+ const o = thisCommand.opts();
862
+ const val = o[aliasKey];
863
+ const provided = typeof val === 'string'
864
+ ? val.length > 0
865
+ : Array.isArray(val)
866
+ ? val.length > 0
867
+ : false;
868
+ if (!provided || hasSub) {
869
+ dbg('alias:maybe:skip', { provided, hasSub });
870
+ return; // not an alias-only invocation
650
871
  }
651
- else if (vars && typeof vars === 'object' && !Array.isArray(vars)) {
652
- // Keep only string or undefined values to match ProcessEnv.
653
- const entries = Object.entries(vars).filter(([k, v]) => typeof k === 'string' && (typeof v === 'string' || v === undefined));
654
- parsedVars = Object.fromEntries(entries);
872
+ if (state.handled) {
873
+ dbg('alias:maybe:already-handled');
874
+ return;
655
875
  }
656
- // Drop undefined-valued entries at the converter stage to match ProcessEnv
657
- // expectations and the compat test assertions.
658
- if (parsedVars) {
659
- parsedVars = Object.fromEntries(Object.entries(parsedVars).filter(([, v]) => v !== undefined));
876
+ state.handled = true;
877
+ dbg('alias-only invocation detected');
878
+ // Merge CLI options and resolve dotenv context.
879
+ const { merged } = resolveCliOptions(o, baseGetDotenvCliOptions, process.env.getDotenvCliOptions);
880
+ const mergedBag = merged;
881
+ const logger = (mergedBag.logger ?? console);
882
+ const serviceOptions = getDotenvCliOptions2Options(mergedBag);
883
+ await cli.resolveAndLoad(serviceOptions);
884
+ // Normalize alias value
885
+ const joined = typeof val === 'string'
886
+ ? val
887
+ : Array.isArray(val)
888
+ ? val.map(String).join(' ')
889
+ : '';
890
+ const expanded = dotenvExpandFromProcessEnv(joined);
891
+ const input = mergedBag.expand === false
892
+ ? joined
893
+ : expanded !== undefined
894
+ ? expanded
895
+ : joined;
896
+ // Scripts: prefer well-formed records; tolerate absent/bad shapes
897
+ const maybeScripts = mergedBag.scripts;
898
+ const scripts = maybeScripts && typeof maybeScripts === 'object'
899
+ ? maybeScripts
900
+ : undefined;
901
+ const resolved = resolveCommand(scripts, input);
902
+ if (mergedBag.debug) {
903
+ logger.log('\n*** command ***\n', `'${resolved}'`);
660
904
  }
661
- // Tolerate paths as either a delimited string or string[]
662
- // Use a locally cast union type to avoid lint warnings about always-falsy conditions
663
- // under the RootOptionsShape (which declares paths as string | undefined).
664
- const pathsAny = paths;
665
- const pathsOut = Array.isArray(pathsAny)
666
- ? pathsAny.filter((p) => typeof p === 'string')
667
- : splitBy(pathsAny, pathsDelimiter, pathsDelimiterPattern);
668
- // Preserve exactOptionalPropertyTypes: only include keys when defined.
669
- return {
670
- ...restObj,
671
- ...(pathsOut.length > 0 ? { paths: pathsOut } : {}),
672
- ...(parsedVars !== undefined ? { vars: parsedVars } : {}),
673
- };
674
- };
675
-
676
- const dbg = (...args) => {
677
- if (process.env.GETDOTENV_DEBUG) {
678
- // Use stderr to avoid interfering with stdout assertions
679
- console.error('[getdotenv:alias]', ...args);
905
+ // Round-trip CLI options for nested getdotenv invocations. Omit logger
906
+ // (functions/circulars) and guard JSON serialization to avoid hard failures.
907
+ const { logger: _omitLogger, ...envBag } = mergedBag;
908
+ let nestedBag;
909
+ try {
910
+ nestedBag = JSON.stringify(envBag);
680
911
  }
681
- };
912
+ catch {
913
+ nestedBag = undefined;
914
+ }
915
+ const underTests = process.env.GETDOTENV_TEST === '1' ||
916
+ typeof process.env.VITEST_WORKER_ID === 'string';
917
+ const forceExit = process.env.GETDOTENV_FORCE_EXIT === '1';
918
+ const capture = !underTests &&
919
+ (process.env.GETDOTENV_STDIO === 'pipe' ||
920
+ Boolean(mergedBag.capture));
921
+ dbg('run:start', {
922
+ capture,
923
+ shell: mergedBag.shell,
924
+ });
925
+ const ctx = cli.getCtx();
926
+ const dotenv = (ctx?.dotenv ?? {});
927
+ // Diagnostics: --trace [keys...]
928
+ const traceOpt = mergedBag.trace;
929
+ if (traceOpt) {
930
+ const parentKeys = Object.keys(process.env);
931
+ const dotenvKeys = Object.keys(dotenv);
932
+ const allKeys = Array.from(new Set([...parentKeys, ...dotenvKeys])).sort();
933
+ const keys = Array.isArray(traceOpt) ? traceOpt : allKeys;
934
+ const childEnvPreview = {
935
+ ...process.env,
936
+ ...dotenv,
937
+ };
938
+ for (const k of keys) {
939
+ const parent = process.env[k];
940
+ const dot = dotenv[k];
941
+ const final = childEnvPreview[k];
942
+ const origin = dot !== undefined
943
+ ? 'dotenv'
944
+ : parent !== undefined
945
+ ? 'parent'
946
+ : 'unset';
947
+ const redFlag = mergedBag.redact;
948
+ const redPatterns = mergedBag.redactPatterns;
949
+ const redOpts = {};
950
+ if (redFlag)
951
+ redOpts.redact = true;
952
+ if (redFlag && Array.isArray(redPatterns))
953
+ redOpts.redactPatterns = redPatterns;
954
+ const tripleBag = {};
955
+ if (parent !== undefined)
956
+ tripleBag.parent = parent;
957
+ if (dot !== undefined)
958
+ tripleBag.dotenv = dot;
959
+ if (final !== undefined)
960
+ tripleBag.final = final;
961
+ const triple = redactTriple(k, tripleBag, redOpts);
962
+ process.stderr.write(`[trace] key=${k} origin=${origin} parent=${triple.parent ?? ''} dotenv=${triple.dotenv ?? ''} final=${triple.final ?? ''}\n`);
963
+ const entOpts = {};
964
+ const warnEntropy = mergedBag.warnEntropy;
965
+ const entropyThreshold = mergedBag
966
+ .entropyThreshold;
967
+ const entropyMinLength = mergedBag
968
+ .entropyMinLength;
969
+ const entropyWhitelist = mergedBag.entropyWhitelist;
970
+ if (typeof warnEntropy === 'boolean')
971
+ entOpts.warnEntropy = warnEntropy;
972
+ if (typeof entropyThreshold === 'number')
973
+ entOpts.entropyThreshold = entropyThreshold;
974
+ if (typeof entropyMinLength === 'number')
975
+ entOpts.entropyMinLength = entropyMinLength;
976
+ if (Array.isArray(entropyWhitelist))
977
+ entOpts.entropyWhitelist = entropyWhitelist;
978
+ maybeWarnEntropy(k, final, origin, entOpts, (line) => process.stderr.write(line + '\n'));
979
+ }
980
+ }
981
+ const shellSetting = resolveShell(scripts, input, mergedBag.shell);
982
+ // Preserve argv array for Node -e snippets under shell-off
983
+ let commandArg = resolved;
984
+ if (shellSetting === false && resolved === input) {
985
+ // Important: preserve doubled quotes within the Node -e payload so
986
+ // empty string literals ("") survive; Windows-style doubling must not
987
+ // collapse "" -> " in this path.
988
+ const parts = tokenize(input, { preserveDoubledQuotes: true });
989
+ if (parts.length >= 3 &&
990
+ parts[0]?.toLowerCase() === 'node' &&
991
+ (parts[1] === '-e' || parts[1] === '--eval')) {
992
+ // Peel exactly one symmetric outer quote on the code arg
993
+ parts[2] = stripOne(parts[2] ?? '');
994
+ // Historical behavior: pass the argv array through unchanged for shell-off.
995
+ commandArg = parts;
996
+ }
997
+ }
998
+ let exitCode = Number.NaN;
999
+ try {
1000
+ exitCode = await runCommand(commandArg, shellSetting, {
1001
+ env: buildSpawnEnv(process.env, nestedBag
1002
+ ? {
1003
+ ...dotenv,
1004
+ getDotenvCliOptions: nestedBag,
1005
+ }
1006
+ : {
1007
+ ...dotenv,
1008
+ }),
1009
+ stdio: capture ? 'pipe' : 'inherit',
1010
+ });
1011
+ dbg('run:done', { exitCode });
1012
+ }
1013
+ catch (err) {
1014
+ const code = typeof err.exitCode === 'number'
1015
+ ? err.exitCode
1016
+ : 1;
1017
+ dbg('run:error', { exitCode: code, error: String(err) });
1018
+ if (!underTests) {
1019
+ dbg('process.exit (error path)', { exitCode: code });
1020
+ process.exit(code);
1021
+ }
1022
+ else {
1023
+ dbg('process.exit suppressed for tests (error path)', {
1024
+ exitCode: code,
1025
+ });
1026
+ }
1027
+ return;
1028
+ }
1029
+ if (!Number.isNaN(exitCode)) {
1030
+ dbg('process.exit', { exitCode });
1031
+ process.exit(exitCode);
1032
+ }
1033
+ if (!underTests) {
1034
+ dbg('process.exit (fallback: non-numeric exitCode)', { exitCode: 0 });
1035
+ process.exit(0);
1036
+ }
1037
+ else {
1038
+ dbg('process.exit (fallback suppressed for tests: non-numeric exitCode)', {
1039
+ exitCode: 0,
1040
+ });
1041
+ }
1042
+ if (forceExit) {
1043
+ setImmediate(() => process.exit(Number.isNaN(exitCode) ? 0 : exitCode));
1044
+ }
1045
+ }
1046
+
682
1047
  const attachParentAlias = (cli, options, _cmd) => {
683
1048
  const aliasSpec = typeof options.optionAlias === 'string'
684
- ? { flags: options.optionAlias, description: undefined, expand: true }
1049
+ ? { flags: options.optionAlias, description: undefined}
685
1050
  : options.optionAlias;
686
1051
  if (!aliasSpec)
687
1052
  return;
688
1053
  const deriveKey = (flags) => {
689
- dbg('install alias option', flags);
1054
+ if (process.env.GETDOTENV_DEBUG) {
1055
+ console.error('[getdotenv:alias] install alias option', flags);
1056
+ }
690
1057
  const long = flags.split(/[ ,|]+/).find((f) => f.startsWith('--')) ?? '--cmd';
691
1058
  const name = long.replace(/^--/, '');
692
1059
  return name.replace(/-([a-z])/g, (_m, c) => c.toUpperCase());
@@ -696,256 +1063,24 @@ const attachParentAlias = (cli, options, _cmd) => {
696
1063
  const desc = aliasSpec.description ??
697
1064
  'alias of cmd subcommand; provide command tokens (variadic)';
698
1065
  cli.option(aliasSpec.flags, desc);
699
- // Tag the just-added parent option for grouped help rendering.
700
- try {
701
- const optsArr = cli.options;
702
- if (Array.isArray(optsArr) && optsArr.length > 0) {
703
- const last = optsArr[optsArr.length - 1];
704
- last.__group = 'plugin:cmd';
705
- }
706
- }
707
- catch {
708
- /* noop */
1066
+ // Tag the just-added parent option for grouped help rendering at the root.
1067
+ const optsArr = cli.options;
1068
+ if (optsArr.length > 0) {
1069
+ const last = optsArr[optsArr.length - 1];
1070
+ if (last)
1071
+ cli.setOptionGroup(last, 'plugin:cmd');
709
1072
  }
710
1073
  // Shared alias executor for either preAction or preSubcommand hooks.
711
1074
  // Ensure we only execute once even if both hooks fire in a single parse.
712
- let aliasHandled = false;
713
- const maybeRunAlias = async (thisCommand) => {
714
- dbg('alias:maybe:start');
715
- const raw = thisCommand.rawArgs ?? [];
716
- const childNames = thisCommand.commands.flatMap((c) => [
717
- c.name(),
718
- ...c.aliases(),
719
- ]);
720
- const hasSub = childNames.some((n) => raw.includes(n));
721
- // Read alias value from parent opts.
722
- const o = thisCommand.opts();
723
- const val = o[aliasKey];
724
- const provided = typeof val === 'string'
725
- ? val.length > 0
726
- : Array.isArray(val)
727
- ? val.length > 0
728
- : false;
729
- if (!provided || hasSub) {
730
- dbg('alias:maybe:skip', { provided, hasSub });
731
- return; // not an alias-only invocation
732
- }
733
- if (aliasHandled) {
734
- dbg('alias:maybe:already-handled');
735
- return;
736
- }
737
- aliasHandled = true;
738
- dbg('alias-only invocation detected');
739
- // Merge CLI options and resolve dotenv context.
740
- const { merged } = resolveCliOptions(o,
741
- // cast through unknown to avoid readonly -> mutable incompatibilities
742
- baseRootOptionDefaults, process.env.getDotenvCliOptions);
743
- const logger = merged.logger ?? console;
744
- const serviceOptions = getDotenvCliOptions2Options(merged);
745
- await cli.resolveAndLoad(serviceOptions);
746
- // Normalize alias value.
747
- const joined = typeof val === 'string'
748
- ? val
749
- : Array.isArray(val)
750
- ? val.map(String).join(' ')
751
- : '';
752
- const input = aliasSpec.expand === false
753
- ? joined
754
- : (dotenvExpandFromProcessEnv(joined) ?? joined);
755
- dbg('resolved input', { input });
756
- const resolved = resolveCommand(merged.scripts, input);
757
- const lg = logger;
758
- if (merged.debug) {
759
- (lg.debug ?? lg.log)('\n*** command ***\n', `'${resolved}'`);
760
- }
761
- const { logger: _omit, ...envBag } = merged;
762
- // Test guard: when running under tests, prefer stdio: 'inherit' to avoid
763
- // assertions depending on captured stdio; ignore GETDOTENV_STDIO/capture.
764
- const underTests = process.env.GETDOTENV_TEST === '1' ||
765
- typeof process.env.VITEST_WORKER_ID === 'string';
766
- const forceExit = process.env.GETDOTENV_FORCE_EXIT === '1';
767
- const capture = !underTests &&
768
- (process.env.GETDOTENV_STDIO === 'pipe' ||
769
- Boolean(merged.capture));
770
- dbg('run:start', { capture, shell: merged.shell });
771
- // Prefer explicit env injection: include resolved dotenv map to avoid leaking
772
- // parent process.env secrets when exclusions are set.
773
- const ctx = cli.getCtx();
774
- const dotenv = (ctx?.dotenv ?? {});
775
- // Diagnostics: --trace [keys...]
776
- const traceOpt = merged.trace;
777
- if (traceOpt) {
778
- const parentKeys = Object.keys(process.env);
779
- const dotenvKeys = Object.keys(dotenv);
780
- const allKeys = Array.from(new Set([...parentKeys, ...dotenvKeys])).sort();
781
- const keys = Array.isArray(traceOpt) ? traceOpt : allKeys;
782
- const childEnvPreview = {
783
- ...process.env,
784
- ...dotenv,
785
- };
786
- for (const k of keys) {
787
- const parent = process.env[k];
788
- const dot = dotenv[k];
789
- const final = childEnvPreview[k];
790
- const origin = dot !== undefined
791
- ? 'dotenv'
792
- : parent !== undefined
793
- ? 'parent'
794
- : 'unset';
795
- // Build redact options and triple bag without undefined-valued fields
796
- const redOpts = {};
797
- const redFlag = merged.redact;
798
- const redPatterns = merged
799
- .redactPatterns;
800
- if (redFlag)
801
- redOpts.redact = true;
802
- if (redFlag && Array.isArray(redPatterns))
803
- redOpts.redactPatterns = redPatterns;
804
- const tripleBag = {};
805
- if (parent !== undefined)
806
- tripleBag.parent = parent;
807
- if (dot !== undefined)
808
- tripleBag.dotenv = dot;
809
- if (final !== undefined)
810
- tripleBag.final = final;
811
- const triple = redactTriple(k, tripleBag, redOpts);
812
- process.stderr.write(`[trace] key=${k} origin=${origin} parent=${triple.parent ?? ''} dotenv=${triple.dotenv ?? ''} final=${triple.final ?? ''}\n`);
813
- const entOpts = {};
814
- const warnEntropy = merged.warnEntropy;
815
- const entropyThreshold = merged
816
- .entropyThreshold;
817
- const entropyMinLength = merged
818
- .entropyMinLength;
819
- const entropyWhitelist = merged
820
- .entropyWhitelist;
821
- if (typeof warnEntropy === 'boolean')
822
- entOpts.warnEntropy = warnEntropy;
823
- if (typeof entropyThreshold === 'number')
824
- entOpts.entropyThreshold = entropyThreshold;
825
- if (typeof entropyMinLength === 'number')
826
- entOpts.entropyMinLength = entropyMinLength;
827
- if (Array.isArray(entropyWhitelist))
828
- entOpts.entropyWhitelist = entropyWhitelist;
829
- maybeWarnEntropy(k, final, origin, entOpts, (line) => process.stderr.write(line + '\n'));
830
- }
831
- }
832
- let exitCode = Number.NaN;
833
- try {
834
- // Resolve shell and preserve argv for Node -e snippets under shell-off.
835
- const shellSetting = resolveShell(merged.scripts, input, merged.shell);
836
- let commandArg = resolved;
837
- /** * Special-case: when shell is OFF and no script alias remap occurred
838
- * (resolved === input), treat a Node eval payload as an argv array to
839
- * avoid lossy re-tokenization of the code string.
840
- *
841
- * Examples handled:
842
- * "node -e \"console.log(JSON.stringify(...))\""
843
- * "node --eval 'console.log(...)'"
844
- *
845
- * We peel exactly one pair of symmetric outer quotes from the code
846
- * argument when present; inner quotes remain untouched.
847
- */
848
- if (shellSetting === false && resolved === input) {
849
- // Helper: strip one symmetric outer quote layer
850
- const stripOne = (s) => {
851
- if (s.length < 2)
852
- return s;
853
- const a = s.charAt(0);
854
- const b = s.charAt(s.length - 1);
855
- const symmetric = (a === '"' && b === '"') || (a === "'" && b === "'");
856
- return symmetric ? s.slice(1, -1) : s;
857
- };
858
- // Normalize whole input once for robust matching
859
- const normalized = stripOne(input.trim());
860
- // First try a lightweight regex on the normalized string
861
- const m = /^\s*node\s+(--eval|-e)\s+([\s\S]+)$/i.exec(normalized);
862
- if (m && typeof m[1] === 'string' && typeof m[2] === 'string') {
863
- const evalFlag = m[1];
864
- let codeArg = m[2].trim();
865
- codeArg = stripOne(codeArg);
866
- const flag = evalFlag.startsWith('--') ? '--eval' : '-e';
867
- commandArg = ['node', flag, codeArg];
868
- }
869
- else {
870
- // Fallback: tokenize and detect node -e/--eval form
871
- const parts = tokenize(input);
872
- if (parts.length >= 3) {
873
- // Narrow under noUncheckedIndexedAccess
874
- const p0 = parts[0];
875
- const p1 = parts[1];
876
- if (p0?.toLowerCase() === 'node' &&
877
- (p1 === '-e' || p1 === '--eval')) {
878
- commandArg = parts;
879
- }
880
- }
881
- }
882
- }
883
- exitCode = await runCommand(commandArg, shellSetting, {
884
- env: buildSpawnEnv(process.env, {
885
- ...dotenv,
886
- getDotenvCliOptions: JSON.stringify(envBag),
887
- }),
888
- stdio: capture ? 'pipe' : 'inherit',
889
- });
890
- dbg('run:done', { exitCode });
891
- }
892
- catch (err) {
893
- const code = typeof err.exitCode === 'number'
894
- ? err.exitCode
895
- : 1;
896
- dbg('run:error', { exitCode: code, error: String(err) });
897
- if (!underTests) {
898
- dbg('process.exit (error path)', { exitCode: code });
899
- process.exit(code);
900
- }
901
- else {
902
- dbg('process.exit suppressed for tests (error path)', {
903
- exitCode: code,
904
- });
905
- }
906
- return;
907
- }
908
- if (!Number.isNaN(exitCode)) {
909
- dbg('process.exit', { exitCode });
910
- process.exit(exitCode);
911
- }
912
- // Fallback: Some environments may not surface a numeric exitCode even on success.
913
- // Always terminate alias-only invocations outside tests to avoid hanging the process,
914
- // regardless of capture/GETDOTENV_STDIO. Under tests, suppress to keep the runner alive.
915
- if (!underTests) {
916
- dbg('process.exit (fallback: non-numeric exitCode)', { exitCode: 0 });
917
- process.exit(0);
918
- }
919
- else {
920
- dbg('process.exit (fallback suppressed for tests: non-numeric exitCode)', { exitCode: 0 });
921
- }
922
- // Optional last-resort guard: force an exit on the next tick when enabled.
923
- // Intended for diagnosing environments where the process appears to linger
924
- // despite reaching the success/error handlers above. Disabled under tests.
925
- if (forceExit) {
926
- try {
927
- if (process.env.GETDOTENV_DEBUG_VERBOSE) {
928
- const getHandles = process._getActiveHandles;
929
- const handles = typeof getHandles === 'function' ? getHandles() : [];
930
- dbg('active handles before forced exit', {
931
- count: Array.isArray(handles) ? handles.length : undefined,
932
- });
933
- }
934
- }
935
- catch {
936
- // best-effort only
937
- }
938
- const code = Number.isNaN(exitCode) ? 0 : exitCode;
939
- dbg('process.exit (forced)', { exitCode: code });
940
- setImmediate(() => process.exit(code));
941
- }
1075
+ const aliasState = { handled: false };
1076
+ const maybeRun = async (thisCommand) => {
1077
+ await maybeRunAlias(cli, thisCommand, aliasKey, aliasState);
942
1078
  };
943
- // Execute alias-only invocations whether the root handles the action // itself (preAction) or Commander routes to a default subcommand (preSubcommand).
944
1079
  cli.hook('preAction', async (thisCommand, _actionCommand) => {
945
- await maybeRunAlias(thisCommand);
1080
+ await maybeRun(thisCommand);
946
1081
  });
947
1082
  cli.hook('preSubcommand', async (thisCommand) => {
948
- await maybeRunAlias(thisCommand);
1083
+ await maybeRun(thisCommand);
949
1084
  });
950
1085
  };
951
1086
 
@@ -967,10 +1102,10 @@ const cmdPlugin = (options = {}) => definePlugin({
967
1102
  return name.replace(/-([a-z])/g, (_m, c) => c.toUpperCase());
968
1103
  };
969
1104
  const aliasKey = aliasSpec ? deriveKey(aliasSpec.flags) : undefined;
970
- const cmd = new Command()
971
- .name('cmd')
1105
+ // Create as a GetDotenvCli child so helpInformation includes a trailing blank line.
1106
+ const cmd = cli
1107
+ .createCommand('cmd')
972
1108
  .description('Execute command according to the --shell option, conflicts with --command option (default subcommand)')
973
- .configureHelp({ showGlobalOptions: true })
974
1109
  .enablePositionalOptions()
975
1110
  .passThroughOptions()
976
1111
  .argument('[command...]')
@@ -1041,8 +1176,7 @@ const cmdPlugin = (options = {}) => definePlugin({
1041
1176
  : 'unset';
1042
1177
  // Apply presentation-time redaction (if enabled)
1043
1178
  const redFlag = merged.redact;
1044
- const redPatterns = merged
1045
- .redactPatterns;
1179
+ const redPatterns = merged.redactPatterns;
1046
1180
  const redOpts = {};
1047
1181
  if (redFlag)
1048
1182
  redOpts.redact = true;