@karmaniverous/get-dotenv 5.2.6 → 6.0.0-1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/README.md +106 -70
  2. package/dist/cliHost.d.ts +232 -226
  3. package/dist/cliHost.mjs +777 -545
  4. package/dist/config.d.ts +7 -2
  5. package/dist/env-overlay.d.ts +21 -9
  6. package/dist/env-overlay.mjs +14 -19
  7. package/dist/getdotenv.cli.mjs +1366 -1163
  8. package/dist/index.d.ts +415 -242
  9. package/dist/index.mjs +1364 -1414
  10. package/dist/plugins-aws.d.ts +149 -94
  11. package/dist/plugins-aws.mjs +307 -195
  12. package/dist/plugins-batch.d.ts +153 -99
  13. package/dist/plugins-batch.mjs +277 -95
  14. package/dist/plugins-cmd.d.ts +140 -94
  15. package/dist/plugins-cmd.mjs +636 -502
  16. package/dist/plugins-demo.d.ts +140 -94
  17. package/dist/plugins-demo.mjs +237 -46
  18. package/dist/plugins-init.d.ts +140 -94
  19. package/dist/plugins-init.mjs +129 -12
  20. package/dist/plugins.d.ts +166 -103
  21. package/dist/plugins.mjs +977 -840
  22. package/package.json +15 -53
  23. package/templates/cli/ts/plugins/hello.ts +27 -6
  24. package/templates/config/js/getdotenv.config.js +1 -1
  25. package/templates/config/ts/getdotenv.config.ts +9 -2
  26. package/dist/cliHost.cjs +0 -1875
  27. package/dist/cliHost.d.cts +0 -409
  28. package/dist/cliHost.d.mts +0 -409
  29. package/dist/config.cjs +0 -252
  30. package/dist/config.d.cts +0 -55
  31. package/dist/config.d.mts +0 -55
  32. package/dist/env-overlay.cjs +0 -163
  33. package/dist/env-overlay.d.cts +0 -50
  34. package/dist/env-overlay.d.mts +0 -50
  35. package/dist/index.cjs +0 -4140
  36. package/dist/index.d.cts +0 -457
  37. package/dist/index.d.mts +0 -457
  38. package/dist/plugins-aws.cjs +0 -667
  39. package/dist/plugins-aws.d.cts +0 -158
  40. package/dist/plugins-aws.d.mts +0 -158
  41. package/dist/plugins-batch.cjs +0 -616
  42. package/dist/plugins-batch.d.cts +0 -180
  43. package/dist/plugins-batch.d.mts +0 -180
  44. package/dist/plugins-cmd.cjs +0 -1113
  45. package/dist/plugins-cmd.d.cts +0 -178
  46. package/dist/plugins-cmd.d.mts +0 -178
  47. package/dist/plugins-demo.cjs +0 -307
  48. package/dist/plugins-demo.d.cts +0 -158
  49. package/dist/plugins-demo.d.mts +0 -158
  50. package/dist/plugins-init.cjs +0 -289
  51. package/dist/plugins-init.d.cts +0 -162
  52. package/dist/plugins-init.d.mts +0 -162
  53. package/dist/plugins.cjs +0 -2283
  54. package/dist/plugins.d.cts +0 -210
  55. package/dist/plugins.d.mts +0 -210
package/dist/plugins.mjs CHANGED
@@ -1,29 +1,47 @@
1
1
  import { execa, execaCommand } from 'execa';
2
2
  import { z } from 'zod';
3
+ import fs from 'fs-extra';
4
+ import path from 'path';
5
+ import { packageDirectory } from 'package-directory';
6
+ import 'url';
7
+ import 'yaml';
8
+ import 'nanoid';
9
+ import 'dotenv';
10
+ import 'crypto';
3
11
  import { Command } from 'commander';
4
12
  import { globby } from 'globby';
5
- import { packageDirectory } from 'package-directory';
6
- import path from 'path';
7
- import fs from 'fs-extra';
8
13
  import { stdin, stdout } from 'node:process';
9
14
  import { createInterface } from 'readline/promises';
10
15
 
11
16
  // Minimal tokenizer for shell-off execution:
12
17
  // Splits by whitespace while preserving quoted segments (single or double quotes).
13
- const tokenize = (command) => {
18
+ // Optionally preserve doubled quotes inside quoted segments:
19
+ // - default: "" => " (Windows/PowerShell style literal-quote escape)
20
+ // - preserveDoubledQuotes: true => "" stays "" (needed for Node -e payloads)
21
+ const tokenize = (command, opts) => {
14
22
  const out = [];
15
23
  let cur = '';
16
24
  let quote = null;
25
+ const preserve = opts && opts.preserveDoubledQuotes === true ? true : false;
17
26
  for (let i = 0; i < command.length; i++) {
18
27
  const c = command.charAt(i);
19
28
  if (quote) {
20
29
  if (c === quote) {
21
- // Support doubled quotes inside a quoted segment (Windows/PowerShell style):
22
- // "" -> " and '' -> '
30
+ // Support doubled quotes inside a quoted segment:
31
+ // default: "" -> " and '' -> ' (Windows/PowerShell style)
32
+ // preserve: keep as "" to allow empty string literals in Node -e payloads
23
33
  const next = command.charAt(i + 1);
24
34
  if (next === quote) {
25
- cur += quote;
26
- i += 1; // skip the second quote
35
+ if (preserve) {
36
+ // Keep "" as-is; append both and continue within the quoted segment.
37
+ cur += quote + quote;
38
+ i += 1; // skip the second quote char (we already appended both)
39
+ }
40
+ else {
41
+ // Collapse to a single literal quote
42
+ cur += quote;
43
+ i += 1; // skip the second quote
44
+ }
27
45
  }
28
46
  else {
29
47
  // end of quoted segment
@@ -97,62 +115,55 @@ const sanitizeEnv = (env) => {
97
115
  const entries = Object.entries(env).filter((e) => typeof e[1] === 'string');
98
116
  return entries.length > 0 ? Object.fromEntries(entries) : undefined;
99
117
  };
100
- /**
101
- * Execute a command and capture stdout/stderr (buffered).
102
- * - Preserves plain vs shell behavior and argv/string normalization.
103
- * - Never re-emits stdout/stderr to parent; returns captured buffers.
104
- * - Supports optional timeout (ms).
105
- */
106
- const runCommandResult = async (command, shell, opts = {}) => {
118
+ async function runCommandResult(command, shell, opts = {}) {
107
119
  const envSan = sanitizeEnv(opts.env);
108
120
  {
109
121
  let file;
110
122
  let args = [];
111
- if (Array.isArray(command)) {
112
- file = command[0];
113
- args = command.slice(1).map(stripOuterQuotes);
114
- }
115
- else {
123
+ if (typeof command === 'string') {
116
124
  const tokens = tokenize(command);
117
125
  file = tokens[0];
118
126
  args = tokens.slice(1);
119
127
  }
128
+ else {
129
+ file = command[0];
130
+ args = command.slice(1).map(stripOuterQuotes);
131
+ }
120
132
  if (!file)
121
133
  return { exitCode: 0, stdout: '', stderr: '' };
122
134
  dbg$1('exec:capture (plain)', { file, args });
123
135
  try {
124
- const result = await execa(file, args, {
136
+ const ok = pickResult((await execa(file, args, {
125
137
  ...(opts.cwd !== undefined ? { cwd: opts.cwd } : {}),
126
138
  ...(envSan !== undefined ? { env: envSan } : {}),
127
139
  stdio: 'pipe',
128
140
  ...(opts.timeoutMs !== undefined
129
141
  ? { timeout: opts.timeoutMs, killSignal: 'SIGKILL' }
130
142
  : {}),
131
- });
132
- const ok = pickResult(result);
143
+ })));
133
144
  dbg$1('exit:capture (plain)', { exitCode: ok.exitCode });
134
145
  return ok;
135
146
  }
136
- catch (err) {
137
- const out = pickResult(err);
147
+ catch (e) {
148
+ const out = pickResult(e);
138
149
  dbg$1('exit:capture:error (plain)', { exitCode: out.exitCode });
139
150
  return out;
140
151
  }
141
152
  }
142
- };
143
- const runCommand = async (command, shell, opts) => {
153
+ }
154
+ async function runCommand(command, shell, opts) {
144
155
  if (shell === false) {
145
156
  let file;
146
157
  let args = [];
147
- if (Array.isArray(command)) {
148
- file = command[0];
149
- args = command.slice(1).map(stripOuterQuotes);
150
- }
151
- else {
158
+ if (typeof command === 'string') {
152
159
  const tokens = tokenize(command);
153
160
  file = tokens[0];
154
161
  args = tokens.slice(1);
155
162
  }
163
+ else {
164
+ file = command[0];
165
+ args = command.slice(1).map(stripOuterQuotes);
166
+ }
156
167
  if (!file)
157
168
  return 0;
158
169
  dbg$1('exec (plain)', { file, args, stdio: opts.stdio });
@@ -165,16 +176,15 @@ const runCommand = async (command, shell, opts) => {
165
176
  plainOpts.env = envSan;
166
177
  if (opts.stdio !== undefined)
167
178
  plainOpts.stdio = opts.stdio;
168
- const result = await execa(file, args, plainOpts);
169
- if (opts.stdio === 'pipe' && result.stdout) {
170
- process.stdout.write(result.stdout + (result.stdout.endsWith('\n') ? '' : '\n'));
179
+ const ok = pickResult((await execa(file, args, plainOpts)));
180
+ if (opts.stdio === 'pipe' && ok.stdout) {
181
+ process.stdout.write(ok.stdout + (ok.stdout.endsWith('\n') ? '' : '\n'));
171
182
  }
172
- const exit = result?.exitCode;
173
- dbg$1('exit (plain)', { exitCode: exit });
174
- return typeof exit === 'number' ? exit : Number.NaN;
183
+ dbg$1('exit (plain)', { exitCode: ok.exitCode });
184
+ return typeof ok.exitCode === 'number' ? ok.exitCode : Number.NaN;
175
185
  }
176
186
  else {
177
- const commandStr = Array.isArray(command) ? command.join(' ') : command;
187
+ const commandStr = typeof command === 'string' ? command : command.join(' ');
178
188
  dbg$1('exec (shell)', {
179
189
  shell: typeof shell === 'string' ? shell : 'custom',
180
190
  stdio: opts.stdio,
@@ -188,17 +198,29 @@ const runCommand = async (command, shell, opts) => {
188
198
  shellOpts.env = envSan;
189
199
  if (opts.stdio !== undefined)
190
200
  shellOpts.stdio = opts.stdio;
191
- const result = await execaCommand(commandStr, shellOpts);
192
- const out = result?.stdout;
193
- if (opts.stdio === 'pipe' && out) {
194
- process.stdout.write(out + (out.endsWith('\n') ? '' : '\n'));
201
+ const ok = pickResult((await execaCommand(commandStr, shellOpts)));
202
+ if (opts.stdio === 'pipe' && ok.stdout) {
203
+ process.stdout.write(ok.stdout + (ok.stdout.endsWith('\n') ? '' : '\n'));
195
204
  }
196
- const exit = result?.exitCode;
197
- dbg$1('exit (shell)', { exitCode: exit });
198
- return typeof exit === 'number' ? exit : Number.NaN;
205
+ dbg$1('exit (shell)', { exitCode: ok.exitCode });
206
+ return typeof ok.exitCode === 'number' ? ok.exitCode : Number.NaN;
199
207
  }
200
- };
208
+ }
201
209
 
210
+ /** src/cliCore/spawnEnv.ts
211
+ * Build a sanitized environment bag for child processes.
212
+ *
213
+ * Requirements addressed:
214
+ * - Provide a single helper (buildSpawnEnv) to normalize/dedupe child env.
215
+ * - Drop undefined values (exactOptional semantics).
216
+ * - On Windows, dedupe keys case-insensitively and prefer the last value,
217
+ * preserving the latest key's casing. Ensure HOME fallback from USERPROFILE.
218
+ * Normalize TMP/TEMP consistency when either is present.
219
+ * - On POSIX, keep keys as-is; when a temp dir key is present (TMPDIR/TMP/TEMP),
220
+ * ensure TMPDIR exists for downstream consumers that expect it.
221
+ *
222
+ * Adapter responsibility: pure mapping; no business logic.
223
+ */
202
224
  const dropUndefined = (bag) => Object.fromEntries(Object.entries(bag).filter((e) => typeof e[1] === 'string'));
203
225
  /** Build a sanitized env for child processes from base + overlay. */
204
226
  const buildSpawnEnv = (base, overlay) => {
@@ -238,9 +260,407 @@ const buildSpawnEnv = (base, overlay) => {
238
260
  if (typeof tmpdir === 'string' && tmpdir.length > 0) {
239
261
  out['TMPDIR'] = tmpdir;
240
262
  }
241
- return out;
263
+ return out;
264
+ };
265
+
266
+ /**
267
+ * Zod schemas for configuration files discovered by the new loader.
268
+ *
269
+ * Notes:
270
+ * - RAW: all fields optional; shapes are stringly-friendly (paths may be string[] or string).
271
+ * - RESOLVED: normalized shapes (paths always string[]).
272
+ * - For JSON/YAML configs, the loader rejects "dynamic" and "schema" (JS/TS-only).
273
+ */
274
+ // String-only env value map
275
+ const stringMap = z.record(z.string(), z.string());
276
+ const envStringMap = z.record(z.string(), stringMap);
277
+ // Allow string[] or single string for "paths" in RAW; normalize later.
278
+ const rawPathsSchema = z.union([z.array(z.string()), z.string()]).optional();
279
+ const getDotenvConfigSchemaRaw = z.object({
280
+ dotenvToken: z.string().optional(),
281
+ privateToken: z.string().optional(),
282
+ paths: rawPathsSchema,
283
+ loadProcess: z.boolean().optional(),
284
+ log: z.boolean().optional(),
285
+ shell: z.union([z.string(), z.boolean()]).optional(),
286
+ scripts: z.record(z.string(), z.unknown()).optional(), // Scripts validation left wide; generator validates elsewhere
287
+ requiredKeys: z.array(z.string()).optional(),
288
+ schema: z.unknown().optional(), // JS/TS-only; loader rejects in JSON/YAML
289
+ vars: stringMap.optional(), // public, global
290
+ envVars: envStringMap.optional(), // public, per-env
291
+ // Dynamic in config (JS/TS only). JSON/YAML loader will reject if set.
292
+ dynamic: z.unknown().optional(),
293
+ // Per-plugin config bag; validated by plugins/host when used.
294
+ plugins: z.record(z.string(), z.unknown()).optional(),
295
+ });
296
+ // Normalize paths to string[]
297
+ const normalizePaths = (p) => p === undefined ? undefined : Array.isArray(p) ? p : [p];
298
+ getDotenvConfigSchemaRaw.transform((raw) => ({
299
+ ...raw,
300
+ paths: normalizePaths(raw.paths),
301
+ }));
302
+
303
+ /**
304
+ * Dotenv expansion utilities.
305
+ *
306
+ * This module implements recursive expansion of environment-variable
307
+ * references in strings and records. It supports both whitespace and
308
+ * bracket syntaxes with optional defaults:
309
+ *
310
+ * - Whitespace: `$VAR[:default]`
311
+ * - Bracketed: `${VAR[:default]}`
312
+ *
313
+ * Escaped dollar signs (`\$`) are preserved.
314
+ * Unknown variables resolve to empty string unless a default is provided.
315
+ */
316
+ /**
317
+ * Like String.prototype.search but returns the last index.
318
+ * @internal
319
+ */
320
+ const searchLast = (str, rgx) => {
321
+ const matches = Array.from(str.matchAll(rgx));
322
+ return matches.length > 0 ? (matches.slice(-1)[0]?.index ?? -1) : -1;
323
+ };
324
+ const replaceMatch = (value, match, ref) => {
325
+ /**
326
+ * @internal
327
+ */
328
+ const group = match[0];
329
+ const key = match[1];
330
+ const defaultValue = match[2];
331
+ if (!key)
332
+ return value;
333
+ const replacement = value.replace(group, ref[key] ?? defaultValue ?? '');
334
+ return interpolate(replacement, ref);
335
+ };
336
+ const interpolate = (value = '', ref = {}) => {
337
+ /**
338
+ * @internal
339
+ */
340
+ // if value is falsy, return it as is
341
+ if (!value)
342
+ return value;
343
+ // get position of last unescaped dollar sign
344
+ const lastUnescapedDollarSignIndex = searchLast(value, /(?!(?<=\\))\$/g);
345
+ // return value if none found
346
+ if (lastUnescapedDollarSignIndex === -1)
347
+ return value;
348
+ // evaluate the value tail
349
+ const tail = value.slice(lastUnescapedDollarSignIndex);
350
+ // find whitespace pattern: $KEY:DEFAULT
351
+ const whitespacePattern = /^\$([\w]+)(?::([^\s]*))?/;
352
+ const whitespaceMatch = whitespacePattern.exec(tail);
353
+ if (whitespaceMatch != null)
354
+ return replaceMatch(value, whitespaceMatch, ref);
355
+ else {
356
+ // find bracket pattern: ${KEY:DEFAULT}
357
+ const bracketPattern = /^\${([\w]+)(?::([^}]*))?}/;
358
+ const bracketMatch = bracketPattern.exec(tail);
359
+ if (bracketMatch != null)
360
+ return replaceMatch(value, bracketMatch, ref);
361
+ }
362
+ return value;
363
+ };
364
+ /**
365
+ * Recursively expands environment variables in a string. Variables may be
366
+ * presented with optional default as `$VAR[:default]` or `${VAR[:default]}`.
367
+ * Unknown variables will expand to an empty string.
368
+ *
369
+ * @param value - The string to expand.
370
+ * @param ref - The reference object to use for variable expansion.
371
+ * @returns The expanded string.
372
+ *
373
+ * @example
374
+ * ```ts
375
+ * process.env.FOO = 'bar';
376
+ * dotenvExpand('Hello $FOO'); // "Hello bar"
377
+ * dotenvExpand('Hello $BAZ:world'); // "Hello world"
378
+ * ```
379
+ *
380
+ * @remarks
381
+ * The expansion is recursive. If a referenced variable itself contains
382
+ * references, those will also be expanded until a stable value is reached.
383
+ * Escaped references (e.g. `\$FOO`) are preserved as literals.
384
+ */
385
+ const dotenvExpand = (value, ref = process.env) => {
386
+ const result = interpolate(value, ref);
387
+ return result ? result.replace(/\\\$/g, '$') : undefined;
388
+ };
389
+ /**
390
+ * Recursively expands environment variables in a string using `process.env` as
391
+ * the expansion reference. Variables may be presented with optional default as
392
+ * `$VAR[:default]` or `${VAR[:default]}`. Unknown variables will expand to an
393
+ * empty string.
394
+ *
395
+ * @param value - The string to expand.
396
+ * @returns The expanded string.
397
+ *
398
+ * @example
399
+ * ```ts
400
+ * process.env.FOO = 'bar';
401
+ * dotenvExpandFromProcessEnv('Hello $FOO'); // "Hello bar"
402
+ * ```
403
+ */
404
+ const dotenvExpandFromProcessEnv = (value) => dotenvExpand(value, process.env);
405
+
406
+ /** src/diagnostics/entropy.ts
407
+ * Entropy diagnostics (presentation-only).
408
+ * - Gated by min length and printable ASCII.
409
+ * - Warn once per key per run when bits/char \>= threshold.
410
+ * - Supports whitelist patterns to suppress known-noise keys.
411
+ */
412
+ const warned = new Set();
413
+ const isPrintableAscii = (s) => /^[\x20-\x7E]+$/.test(s);
414
+ const compile$1 = (patterns) => (patterns ?? []).map((p) => (typeof p === 'string' ? new RegExp(p, 'i') : p));
415
+ const whitelisted = (key, regs) => regs.some((re) => re.test(key));
416
+ const shannonBitsPerChar = (s) => {
417
+ const freq = new Map();
418
+ for (const ch of s)
419
+ freq.set(ch, (freq.get(ch) ?? 0) + 1);
420
+ const n = s.length;
421
+ let h = 0;
422
+ for (const c of freq.values()) {
423
+ const p = c / n;
424
+ h -= p * Math.log2(p);
425
+ }
426
+ return h;
427
+ };
428
+ /**
429
+ * Maybe emit a one-line entropy warning for a key.
430
+ * Caller supplies an `emit(line)` function; the helper ensures once-per-key.
431
+ */
432
+ const maybeWarnEntropy = (key, value, origin, opts, emit) => {
433
+ if (!opts || opts.warnEntropy === false)
434
+ return;
435
+ if (warned.has(key))
436
+ return;
437
+ const v = value ?? '';
438
+ const minLen = Math.max(0, opts.entropyMinLength ?? 16);
439
+ const threshold = opts.entropyThreshold ?? 3.8;
440
+ if (v.length < minLen)
441
+ return;
442
+ if (!isPrintableAscii(v))
443
+ return;
444
+ const wl = compile$1(opts.entropyWhitelist);
445
+ if (whitelisted(key, wl))
446
+ return;
447
+ const bpc = shannonBitsPerChar(v);
448
+ if (bpc >= threshold) {
449
+ warned.add(key);
450
+ emit(`[entropy] key=${key} score=${bpc.toFixed(2)} len=${String(v.length)} origin=${origin}`);
451
+ }
452
+ };
453
+
454
+ const DEFAULT_PATTERNS = [
455
+ '\\bsecret\\b',
456
+ '\\btoken\\b',
457
+ '\\bpass(word)?\\b',
458
+ '\\bapi[_-]?key\\b',
459
+ '\\bkey\\b',
460
+ ];
461
+ const compile = (patterns) => (patterns && patterns.length > 0 ? patterns : DEFAULT_PATTERNS).map((p) => typeof p === 'string' ? new RegExp(p, 'i') : p);
462
+ const shouldRedactKey = (key, regs) => regs.some((re) => re.test(key));
463
+ const MASK = '[redacted]';
464
+ /**
465
+ * Utility to redact three related displayed values (parent/dotenv/final)
466
+ * consistently for trace lines.
467
+ */
468
+ const redactTriple = (key, triple, opts) => {
469
+ if (!opts?.redact)
470
+ return triple;
471
+ const regs = compile(opts.redactPatterns);
472
+ const maskIf = (v) => (v && shouldRedactKey(key, regs) ? MASK : v);
473
+ const out = {};
474
+ const p = maskIf(triple.parent);
475
+ const d = maskIf(triple.dotenv);
476
+ const f = maskIf(triple.final);
477
+ if (p !== undefined)
478
+ out.parent = p;
479
+ if (d !== undefined)
480
+ out.dotenv = d;
481
+ if (f !== undefined)
482
+ out.final = f;
483
+ return out;
484
+ };
485
+
486
+ // Base root CLI defaults (shared; kept untyped here to avoid cross-layer deps).
487
+ const baseRootOptionDefaults = {
488
+ dotenvToken: '.env',
489
+ loadProcess: true,
490
+ logger: console,
491
+ // Diagnostics defaults
492
+ warnEntropy: true,
493
+ entropyThreshold: 3.8,
494
+ entropyMinLength: 16,
495
+ entropyWhitelist: ['^GIT_', '^npm_', '^CI$', 'SHLVL'],
496
+ paths: './',
497
+ pathsDelimiter: ' ',
498
+ privateToken: 'local',
499
+ scripts: {
500
+ 'git-status': {
501
+ cmd: 'git branch --show-current && git status -s -u',
502
+ shell: true,
503
+ },
504
+ },
505
+ shell: true,
506
+ vars: '',
507
+ varsAssignor: '=',
508
+ varsDelimiter: ' ',
509
+ // tri-state flags default to unset unless explicitly provided
510
+ // (debug/log/exclude* resolved via flag utils)
511
+ };
512
+
513
+ const baseGetDotenvCliOptions = baseRootOptionDefaults;
514
+
515
+ /** @internal */
516
+ const isPlainObject = (value) => value !== null &&
517
+ typeof value === 'object' &&
518
+ Object.getPrototypeOf(value) === Object.prototype;
519
+ const mergeInto = (target, source) => {
520
+ for (const [key, sVal] of Object.entries(source)) {
521
+ if (sVal === undefined)
522
+ continue; // do not overwrite with undefined
523
+ const tVal = target[key];
524
+ if (isPlainObject(tVal) && isPlainObject(sVal)) {
525
+ target[key] = mergeInto({ ...tVal }, sVal);
526
+ }
527
+ else if (isPlainObject(sVal)) {
528
+ target[key] = mergeInto({}, sVal);
529
+ }
530
+ else {
531
+ target[key] = sVal;
532
+ }
533
+ }
534
+ return target;
535
+ };
536
+ function defaultsDeep(...layers) {
537
+ const result = layers
538
+ .filter(Boolean)
539
+ .reduce((acc, layer) => mergeInto(acc, layer), {});
540
+ return result;
541
+ }
542
+
543
+ /** src/util/omitUndefined.ts
544
+ * Helpers to drop undefined-valued properties in a typed-friendly way.
545
+ */
546
+ /**
547
+ * Omit keys whose runtime value is undefined from a shallow object.
548
+ * Returns a Partial with non-undefined value types preserved.
549
+ */
550
+ /**
551
+ * Specialized helper for env-like maps: drop undefined and return string-only.
552
+ */
553
+ function omitUndefinedRecord(obj) {
554
+ const out = {};
555
+ for (const [k, v] of Object.entries(obj)) {
556
+ if (v !== undefined)
557
+ out[k] = v;
558
+ }
559
+ return out;
560
+ }
561
+
562
+ // src/GetDotenvOptions.ts
563
+ /**
564
+ * Canonical programmatic options and helpers for get-dotenv.
565
+ *
566
+ * Requirements addressed:
567
+ * - GetDotenvOptions derives from the Zod schema output (single source of truth).
568
+ * - Removed deprecated/compat flags from the public shape (e.g., useConfigLoader).
569
+ * - Provide Vars-aware defineDynamic and a typed config builder defineGetDotenvConfig\<Vars, Env\>().
570
+ * - Preserve existing behavior for defaults resolution and compat converters.
571
+ */
572
+ /**
573
+ * Converts programmatic CLI options to `getDotenv` options.
574
+ *
575
+ * Accepts "stringly" CLI inputs for vars/paths and normalizes them into
576
+ * the programmatic shape. Preserves exactOptionalPropertyTypes semantics by
577
+ * omitting keys when undefined.
578
+ */
579
+ const getDotenvCliOptions2Options = ({ paths, pathsDelimiter, pathsDelimiterPattern, vars, varsAssignor, varsAssignorPattern, varsDelimiter, varsDelimiterPattern,
580
+ // drop CLI-only keys from the pass-through bag
581
+ debug: _debug, scripts: _scripts, ...rest }) => {
582
+ // Split helper for delimited strings or regex patterns
583
+ const splitBy = (value, delim, pattern) => {
584
+ if (!value)
585
+ return [];
586
+ if (pattern)
587
+ return value.split(RegExp(pattern));
588
+ if (typeof delim === 'string')
589
+ return value.split(delim);
590
+ return value.split(' ');
591
+ };
592
+ // Tolerate vars as either a CLI string ("A=1 B=2") or an object map.
593
+ let parsedVars;
594
+ if (typeof vars === 'string') {
595
+ const kvPairs = splitBy(vars, varsDelimiter, varsDelimiterPattern)
596
+ .map((v) => v.split(varsAssignorPattern
597
+ ? RegExp(varsAssignorPattern)
598
+ : (varsAssignor ?? '=')))
599
+ .filter(([k]) => typeof k === 'string' && k.length > 0);
600
+ parsedVars = Object.fromEntries(kvPairs);
601
+ }
602
+ else if (vars && typeof vars === 'object' && !Array.isArray(vars)) {
603
+ // Accept provided object map of string | undefined; drop undefined values
604
+ // in the normalization step below to produce a ProcessEnv-compatible bag.
605
+ parsedVars = Object.fromEntries(Object.entries(vars));
606
+ }
607
+ // Drop undefined-valued entries at the converter stage to match ProcessEnv
608
+ // expectations and the compat test assertions.
609
+ if (parsedVars) {
610
+ parsedVars = omitUndefinedRecord(parsedVars);
611
+ }
612
+ // Tolerate paths as either a delimited string or string[]
613
+ const pathsOut = Array.isArray(paths)
614
+ ? paths.filter((p) => typeof p === 'string')
615
+ : splitBy(paths, pathsDelimiter, pathsDelimiterPattern);
616
+ // Preserve exactOptionalPropertyTypes: only include keys when defined.
617
+ return {
618
+ ...rest,
619
+ ...(pathsOut.length > 0 ? { paths: pathsOut } : {}),
620
+ ...(parsedVars !== undefined ? { vars: parsedVars } : {}),
621
+ };
242
622
  };
243
623
 
624
+ /**
625
+ * Zod schemas for programmatic GetDotenv options.
626
+ *
627
+ * Canonical source of truth for options shape. Public types are derived
628
+ * from these schemas (see consumers via z.output\<\>).
629
+ */
630
+ // Minimal process env representation: string values or undefined to indicate "unset".
631
+ const processEnvSchema = z.record(z.string(), z.string().optional());
632
+ // RAW: all fields optional — undefined means "inherit" from lower layers.
633
+ z.object({
634
+ defaultEnv: z.string().optional(),
635
+ dotenvToken: z.string().optional(),
636
+ dynamicPath: z.string().optional(),
637
+ // Dynamic map is intentionally wide for now; refine once sources are normalized.
638
+ dynamic: z.record(z.string(), z.unknown()).optional(),
639
+ env: z.string().optional(),
640
+ excludeDynamic: z.boolean().optional(),
641
+ excludeEnv: z.boolean().optional(),
642
+ excludeGlobal: z.boolean().optional(),
643
+ excludePrivate: z.boolean().optional(),
644
+ excludePublic: z.boolean().optional(),
645
+ loadProcess: z.boolean().optional(),
646
+ log: z.boolean().optional(),
647
+ logger: z.unknown().optional(),
648
+ outputPath: z.string().optional(),
649
+ paths: z.array(z.string()).optional(),
650
+ privateToken: z.string().optional(),
651
+ vars: processEnvSchema.optional(),
652
+ });
653
+
654
+ /**
655
+ * Instance-bound plugin config store.
656
+ * Host stores the validated/interpolated slice per plugin instance.
657
+ * The store is intentionally private to this module; definePlugin()
658
+ * provides a typed accessor that reads from this store for the calling
659
+ * plugin instance.
660
+ */
661
+ const PLUGIN_CONFIG_STORE = new WeakMap();
662
+ const _getPluginConfigForInstance = (plugin) => PLUGIN_CONFIG_STORE.get(plugin);
663
+
244
664
  /** src/cliHost/definePlugin.ts
245
665
  * Plugin contracts for the GetDotenv CLI host.
246
666
  *
@@ -248,26 +668,59 @@ const buildSpawnEnv = (base, overlay) => {
248
668
  * should use (GetDotenvCliPublic). Using a structural type at the seam avoids
249
669
  * nominal class identity issues (private fields) in downstream consumers.
250
670
  */
251
- /**
252
- * Define a GetDotenv CLI plugin with compositional helpers.
253
- *
254
- * @example
255
- * const parent = definePlugin(\{ id: 'p', setup(cli) \{ /* ... *\/ \} \})
256
- * .use(childA)
257
- * .use(childB);
258
- */
259
- const definePlugin = (spec) => {
671
+ /* eslint-disable tsdoc/syntax */
672
+ function definePlugin(spec) {
260
673
  const { children = [], ...rest } = spec;
261
- const plugin = {
674
+ // Default to a strict empty-object schema so “no-config” plugins fail fast
675
+ // on unknown keys and provide a concrete {} at runtime.
676
+ const effectiveSchema = spec.configSchema ?? z.object({}).strict();
677
+ // Build base plugin first, then extend with instance-bound helpers.
678
+ const base = {
262
679
  ...rest,
680
+ // Always carry a schema (strict empty by default) to simplify host logic
681
+ // and improve inference/ergonomics for plugin authors.
682
+ configSchema: effectiveSchema,
263
683
  children: [...children],
264
684
  use(child) {
265
685
  this.children.push(child);
266
686
  return this;
267
687
  },
268
688
  };
269
- return plugin;
270
- };
689
+ // Attach instance-bound helpers on the returned plugin object.
690
+ const extended = base;
691
+ extended.readConfig = function (_cli) {
692
+ // Config is stored per-plugin-instance by the host (WeakMap in computeContext).
693
+ const value = _getPluginConfigForInstance(extended);
694
+ if (value === undefined) {
695
+ // Guard: host has not resolved config yet (incorrect lifecycle usage).
696
+ throw new Error('Plugin config not available. Ensure resolveAndLoad() has been called before readConfig().');
697
+ }
698
+ return value;
699
+ };
700
+ // Plugin-bound dynamic option factory
701
+ extended.createPluginDynamicOption = function (cli, flags, desc, parser, defaultValue) {
702
+ return cli.createDynamicOption(flags, (cfg) => {
703
+ // Prefer the validated slice stored per instance; fallback to help-bag
704
+ // (by-id) so top-level `-h` can render effective defaults before resolve.
705
+ const fromStore = _getPluginConfigForInstance(extended);
706
+ const id = extended.id;
707
+ let fromBag;
708
+ if (!fromStore && id) {
709
+ const maybe = cfg.plugins[id];
710
+ if (maybe && typeof maybe === 'object') {
711
+ fromBag = maybe;
712
+ }
713
+ }
714
+ // Always provide a concrete object to dynamic callbacks:
715
+ // - With a schema: computeContext stores the parsed object.
716
+ // - Without a schema: computeContext stores {}.
717
+ // - Help-time fallback: coalesce to {} when only a by-id bag exists.
718
+ const cfgVal = (fromStore ?? fromBag ?? {});
719
+ return desc(cfg, cfgVal);
720
+ }, parser, defaultValue);
721
+ };
722
+ return extended;
723
+ }
271
724
 
272
725
  /**
273
726
  * Batch services (neutral): resolve command and shell settings.
@@ -491,90 +944,79 @@ const AwsPluginConfigSchema = z.object({
491
944
  regionKey: z.string().default('AWS_REGION').optional(),
492
945
  strategy: z.enum(['cli-export', 'none']).default('cli-export').optional(),
493
946
  loginOnDemand: z.boolean().default(false).optional(),
494
- setEnv: z.boolean().default(true).optional(),
495
- addCtx: z.boolean().default(true).optional(),
496
947
  });
497
948
 
498
- const awsPlugin = () => definePlugin({
499
- id: 'aws',
500
- // Host validates this slice when the loader path is active.
501
- configSchema: AwsPluginConfigSchema,
502
- setup(cli) {
503
- // Subcommand: aws
504
- cli
505
- .ns('aws')
506
- .description('Establish an AWS session and optionally forward to the AWS CLI')
507
- .configureHelp({ showGlobalOptions: true })
508
- .enablePositionalOptions()
509
- .passThroughOptions()
510
- .allowUnknownOption(true)
511
- // Boolean toggles
512
- .option('--login-on-demand', 'attempt aws sso login on-demand')
513
- .option('--no-login-on-demand', 'disable sso login on-demand')
514
- .option('--set-env', 'write resolved values into process.env')
515
- .option('--no-set-env', 'do not write resolved values into process.env')
516
- .option('--add-ctx', 'mirror results under ctx.plugins.aws')
517
- .option('--no-add-ctx', 'do not mirror results under ctx.plugins.aws')
518
- // Strings / enums
519
- .option('--profile <string>', 'AWS profile name')
520
- .option('--region <string>', 'AWS region')
521
- .option('--default-region <string>', 'fallback region')
522
- .option('--strategy <string>', 'credential acquisition strategy: cli-export|none')
523
- // Advanced key overrides
524
- .option('--profile-key <string>', 'dotenv/config key for local profile')
525
- .option('--profile-fallback-key <string>', 'fallback dotenv/config key for profile')
526
- .option('--region-key <string>', 'dotenv/config key for region')
527
- // Accept any extra operands so Commander does not error when tokens appear after "--".
528
- .argument('[args...]')
529
- .action(async (args, opts, thisCommand) => {
530
- const self = thisCommand;
531
- const parent = (self.parent ?? null);
532
- // Access merged root CLI options (installed by passOptions())
533
- const rootOpts = (parent?.getDotenvCliOptions ?? {});
534
- const capture = process.env.GETDOTENV_STDIO === 'pipe' ||
535
- Boolean(rootOpts?.capture);
536
- const underTests = process.env.GETDOTENV_TEST === '1' ||
537
- typeof process.env.VITEST_WORKER_ID === 'string';
538
- // Build overlay cfg from subcommand flags layered over discovered config.
539
- const ctx = cli.getCtx();
540
- const cfgBase = (ctx?.pluginConfigs?.['aws'] ??
541
- {});
542
- const overlay = {};
543
- // Map boolean toggles (respect explicit --no-*)
544
- if (Object.prototype.hasOwnProperty.call(opts, 'loginOnDemand'))
545
- overlay.loginOnDemand = Boolean(opts.loginOnDemand);
546
- if (Object.prototype.hasOwnProperty.call(opts, 'setEnv'))
547
- overlay.setEnv = Boolean(opts.setEnv);
548
- if (Object.prototype.hasOwnProperty.call(opts, 'addCtx'))
549
- overlay.addCtx = Boolean(opts.addCtx);
550
- // Strings/enums
551
- if (typeof opts.profile === 'string')
552
- overlay.profile = opts.profile;
553
- if (typeof opts.region === 'string')
554
- overlay.region = opts.region;
555
- if (typeof opts.defaultRegion === 'string')
556
- overlay.defaultRegion = opts.defaultRegion;
557
- if (typeof opts.strategy === 'string')
558
- overlay.strategy =
559
- opts.strategy;
560
- // Advanced key overrides
561
- if (typeof opts.profileKey === 'string')
562
- overlay.profileKey = opts.profileKey;
563
- if (typeof opts.profileFallbackKey === 'string')
564
- overlay.profileFallbackKey = opts.profileFallbackKey;
565
- if (typeof opts.regionKey === 'string')
566
- overlay.regionKey = opts.regionKey;
567
- const cfg = {
568
- ...cfgBase,
569
- ...overlay,
570
- };
571
- // Resolve current context with overrides
572
- const out = await resolveAwsContext({
573
- dotenv: ctx?.dotenv ?? {},
574
- cfg,
575
- });
576
- // Apply env/ctx mirrors per toggles
577
- if (cfg.setEnv !== false) {
949
+ const awsPlugin = () => {
950
+ const plugin = definePlugin({
951
+ id: 'aws',
952
+ // Host validates this slice when the loader path is active.
953
+ configSchema: AwsPluginConfigSchema,
954
+ setup(cli) {
955
+ // Subcommand: aws
956
+ cli
957
+ .ns('aws')
958
+ .description('Establish an AWS session and optionally forward to the AWS CLI')
959
+ .enablePositionalOptions()
960
+ .passThroughOptions()
961
+ .allowUnknownOption(true)
962
+ // Boolean toggles with dynamic help labels (effective defaults)
963
+ .addOption(plugin.createPluginDynamicOption(cli, '--login-on-demand', (_bag, cfg) => `attempt aws sso login on-demand${cfg.loginOnDemand ? ' (default)' : ''}`))
964
+ .addOption(plugin.createPluginDynamicOption(cli, '--no-login-on-demand', (_bag, cfg) => `disable sso login on-demand${cfg.loginOnDemand === false ? ' (default)' : ''}`))
965
+ // Strings / enums
966
+ .addOption(plugin.createPluginDynamicOption(cli, '--profile <string>', (_bag, cfg) => `AWS profile name${cfg.profile ? ` (default: ${JSON.stringify(cfg.profile)})` : ''}`))
967
+ .addOption(plugin.createPluginDynamicOption(cli, '--region <string>', (_bag, cfg) => `AWS region${cfg.region ? ` (default: ${JSON.stringify(cfg.region)})` : ''}`))
968
+ .addOption(plugin.createPluginDynamicOption(cli, '--default-region <string>', (_bag, cfg) => `fallback region${cfg.defaultRegion ? ` (default: ${JSON.stringify(cfg.defaultRegion)})` : ''}`))
969
+ .addOption(plugin.createPluginDynamicOption(cli, '--strategy <string>', (_bag, cfg) => `credential acquisition strategy: cli-export|none${cfg.strategy ? ` (default: ${JSON.stringify(cfg.strategy)})` : ''}`))
970
+ // Advanced key overrides
971
+ .addOption(plugin.createPluginDynamicOption(cli, '--profile-key <string>', (_bag, cfg) => `dotenv/config key for local profile${cfg.profileKey ? ` (default: ${JSON.stringify(cfg.profileKey)})` : ''}`))
972
+ .addOption(plugin.createPluginDynamicOption(cli, '--profile-fallback-key <string>', (_bag, cfg) => `fallback dotenv/config key for profile${cfg.profileFallbackKey ? ` (default: ${JSON.stringify(cfg.profileFallbackKey)})` : ''}`))
973
+ .addOption(plugin.createPluginDynamicOption(cli, '--region-key <string>', (_bag, cfg) => `dotenv/config key for region${cfg.regionKey ? ` (default: ${JSON.stringify(cfg.regionKey)})` : ''}`))
974
+ // Accept any extra operands so Commander does not error when tokens appear after "--".
975
+ .argument('[args...]')
976
+ .action(async (args, opts, thisCommand) => {
977
+ const pluginInst = plugin;
978
+ const cmdSelf = thisCommand;
979
+ const parent = (cmdSelf.parent ?? null);
980
+ // Access merged root CLI options (installed by passOptions())
981
+ const rootOpts = (parent?.getDotenvCliOptions ?? {});
982
+ const capture = process.env.GETDOTENV_STDIO === 'pipe' ||
983
+ Boolean(rootOpts?.capture);
984
+ const underTests = process.env.GETDOTENV_TEST === '1' ||
985
+ typeof process.env.VITEST_WORKER_ID === 'string';
986
+ // Build overlay cfg from subcommand flags layered over discovered config.
987
+ const ctx = cli.getCtx();
988
+ const cfgBase = pluginInst.readConfig(cli);
989
+ const o = opts;
990
+ const overlay = {};
991
+ // Map boolean toggles (respect explicit --no-*)
992
+ if (Object.prototype.hasOwnProperty.call(o, 'loginOnDemand'))
993
+ overlay.loginOnDemand = Boolean(o.loginOnDemand);
994
+ // Strings/enums
995
+ if (typeof o.profile === 'string')
996
+ overlay.profile = o.profile;
997
+ if (typeof o.region === 'string')
998
+ overlay.region = o.region;
999
+ if (typeof o.defaultRegion === 'string')
1000
+ overlay.defaultRegion = o.defaultRegion;
1001
+ if (typeof o.strategy === 'string')
1002
+ overlay.strategy = o.strategy;
1003
+ // Advanced key overrides
1004
+ if (typeof o.profileKey === 'string')
1005
+ overlay.profileKey = o.profileKey;
1006
+ if (typeof o.profileFallbackKey === 'string')
1007
+ overlay.profileFallbackKey = o.profileFallbackKey;
1008
+ if (typeof o.regionKey === 'string')
1009
+ overlay.regionKey = o.regionKey;
1010
+ const cfg = {
1011
+ ...cfgBase,
1012
+ ...overlay,
1013
+ };
1014
+ // Resolve current context with overrides
1015
+ const out = await resolveAwsContext({
1016
+ dotenv: ctx?.dotenv ?? {},
1017
+ cfg,
1018
+ });
1019
+ // Unconditional env writes (no per-plugin toggle)
578
1020
  if (out.region) {
579
1021
  process.env.AWS_REGION = out.region;
580
1022
  if (!process.env.AWS_DEFAULT_REGION)
@@ -588,58 +1030,53 @@ const awsPlugin = () => definePlugin({
588
1030
  process.env.AWS_SESSION_TOKEN = out.credentials.sessionToken;
589
1031
  }
590
1032
  }
591
- }
592
- if (cfg.addCtx !== false) {
1033
+ // Always publish minimal non-sensitive metadata
593
1034
  if (ctx) {
594
1035
  ctx.plugins ??= {};
595
1036
  ctx.plugins['aws'] = {
596
1037
  ...(out.profile ? { profile: out.profile } : {}),
597
1038
  ...(out.region ? { region: out.region } : {}),
598
- ...(out.credentials ? { credentials: out.credentials } : {}),
599
1039
  };
600
1040
  }
601
- }
602
- // Forward when positional args are present; otherwise session-only.
603
- if (Array.isArray(args) && args.length > 0) {
604
- const argv = ['aws', ...args];
605
- const shellSetting = resolveShell(rootOpts?.scripts, 'aws', rootOpts?.shell);
606
- const ctxDotenv = (ctx?.dotenv ?? {});
607
- const exit = await runCommand(argv, shellSetting, {
608
- env: buildSpawnEnv(process.env, ctxDotenv),
609
- stdio: capture ? 'pipe' : 'inherit',
610
- });
611
- // Deterministic termination (suppressed under tests)
612
- if (!underTests) {
613
- process.exit(typeof exit === 'number' ? exit : 0);
614
- }
615
- return;
616
- }
617
- else {
618
- // Session only: low-noise breadcrumb under debug
619
- if (process.env.GETDOTENV_DEBUG) {
620
- const log = console;
621
- log.log('[aws] session established', {
622
- profile: out.profile,
623
- region: out.region,
624
- hasCreds: Boolean(out.credentials),
1041
+ // Forward when positional args are present; otherwise session-only.
1042
+ if (Array.isArray(args) && args.length > 0) {
1043
+ const argv = ['aws', ...args];
1044
+ const shellSetting = resolveShell(rootOpts?.scripts, 'aws', rootOpts?.shell);
1045
+ const exit = await runCommand(argv, shellSetting, {
1046
+ env: buildSpawnEnv(process.env, ctx?.dotenv),
1047
+ stdio: capture ? 'pipe' : 'inherit',
625
1048
  });
1049
+ // Deterministic termination (suppressed under tests)
1050
+ if (!underTests) {
1051
+ process.exit(typeof exit === 'number' ? exit : 0);
1052
+ }
1053
+ return;
626
1054
  }
627
- if (!underTests)
628
- process.exit(0);
629
- return;
630
- }
631
- });
632
- },
633
- async afterResolve(_cli, ctx) {
634
- const log = console;
635
- const cfgRaw = (ctx.pluginConfigs?.['aws'] ?? {});
636
- const cfg = (cfgRaw || {});
637
- const out = await resolveAwsContext({
638
- dotenv: ctx.dotenv,
639
- cfg,
640
- });
641
- const { profile, region, credentials } = out;
642
- if (cfg.setEnv !== false) {
1055
+ else {
1056
+ // Session only: low-noise breadcrumb under debug
1057
+ if (process.env.GETDOTENV_DEBUG) {
1058
+ const log = console;
1059
+ log.log('[aws] session established', {
1060
+ profile: out.profile,
1061
+ region: out.region,
1062
+ hasCreds: Boolean(out.credentials),
1063
+ });
1064
+ }
1065
+ if (!underTests)
1066
+ process.exit(0);
1067
+ return;
1068
+ }
1069
+ });
1070
+ },
1071
+ async afterResolve(_cli, ctx) {
1072
+ const log = console;
1073
+ const cfg = plugin.readConfig(_cli);
1074
+ const out = await resolveAwsContext({
1075
+ dotenv: ctx.dotenv,
1076
+ cfg,
1077
+ });
1078
+ const { profile, region, credentials } = out;
1079
+ // Unconditional env writes in host path
643
1080
  if (region) {
644
1081
  process.env.AWS_REGION = region;
645
1082
  if (!process.env.AWS_DEFAULT_REGION)
@@ -652,25 +1089,24 @@ const awsPlugin = () => definePlugin({
652
1089
  process.env.AWS_SESSION_TOKEN = credentials.sessionToken;
653
1090
  }
654
1091
  }
655
- }
656
- if (cfg.addCtx !== false) {
1092
+ // Always publish minimal non-sensitive metadata
657
1093
  ctx.plugins ??= {};
658
1094
  ctx.plugins['aws'] = {
659
1095
  ...(profile ? { profile } : {}),
660
1096
  ...(region ? { region } : {}),
661
- ...(credentials ? { credentials } : {}),
662
1097
  };
663
- }
664
- // Optional: low-noise breadcrumb for diagnostics
665
- if (process.env.GETDOTENV_DEBUG) {
666
- log.log('[aws] afterResolve', {
667
- profile,
668
- region,
669
- hasCreds: Boolean(credentials),
670
- });
671
- }
672
- },
673
- });
1098
+ // Optional: low-noise breadcrumb for diagnostics
1099
+ if (process.env.GETDOTENV_DEBUG) {
1100
+ log.log('[aws] afterResolve', {
1101
+ profile,
1102
+ region,
1103
+ hasCreds: Boolean(credentials),
1104
+ });
1105
+ }
1106
+ },
1107
+ });
1108
+ return plugin;
1109
+ };
674
1110
 
675
1111
  const globPaths = async ({ globs, logger, pkgCwd, rootPath, }) => {
676
1112
  let cwd = process.cwd();
@@ -695,9 +1131,10 @@ const globPaths = async ({ globs, logger, pkgCwd, rootPath, }) => {
695
1131
  }
696
1132
  return { absRootPath, paths };
697
1133
  };
698
- const execShellCommandBatch = async ({ command, getDotenvCliOptions, globs, ignoreErrors, list, logger, pkgCwd, rootPath, shell, }) => {
1134
+ const execShellCommandBatch = async ({ command, getDotenvCliOptions, dotenvEnv, globs, ignoreErrors, list, logger, pkgCwd, rootPath, shell, }) => {
699
1135
  const capture = process.env.GETDOTENV_STDIO === 'pipe' ||
700
- Boolean(getDotenvCliOptions?.capture); // Require a command only when not listing. In list mode, a command is optional.
1136
+ Boolean(getDotenvCliOptions?.capture);
1137
+ // Require a command only when not listing. In list mode, a command is optional.
701
1138
  if (!command && !list) {
702
1139
  logger.error(`No command provided. Use --command or --list.`);
703
1140
  process.exit(0);
@@ -744,12 +1181,25 @@ const execShellCommandBatch = async ({ command, getDotenvCliOptions, globs, igno
744
1181
  const hasCmd = (typeof command === 'string' && command.length > 0) ||
745
1182
  (Array.isArray(command) && command.length > 0);
746
1183
  if (hasCmd) {
747
- const envBag = getDotenvCliOptions !== undefined
748
- ? { getDotenvCliOptions: JSON.stringify(getDotenvCliOptions) }
749
- : undefined;
1184
+ // Compose child env overlay from dotenv (drop undefined) and merged options
1185
+ const overlay = {};
1186
+ if (dotenvEnv) {
1187
+ for (const [k, v] of Object.entries(dotenvEnv)) {
1188
+ if (typeof v === 'string')
1189
+ overlay[k] = v;
1190
+ }
1191
+ }
1192
+ if (getDotenvCliOptions !== undefined) {
1193
+ try {
1194
+ overlay.getDotenvCliOptions = JSON.stringify(getDotenvCliOptions);
1195
+ }
1196
+ catch {
1197
+ // best-effort: omit if serialization fails
1198
+ }
1199
+ }
750
1200
  await runCommand(command, shell, {
751
1201
  cwd: path,
752
- env: buildSpawnEnv(process.env, envBag),
1202
+ env: buildSpawnEnv(process.env, overlay),
753
1203
  stdio: capture ? 'pipe' : 'inherit',
754
1204
  });
755
1205
  }
@@ -772,7 +1222,7 @@ const execShellCommandBatch = async ({ command, getDotenvCliOptions, globs, igno
772
1222
  * Build the default "cmd" subcommand action for the batch plugin.
773
1223
  * Mirrors the original inline implementation with identical behavior.
774
1224
  */
775
- const buildDefaultCmdAction = (cli, batchCmd, opts) => async (commandParts, _subOpts, _thisCommand) => {
1225
+ const buildDefaultCmdAction = (plugin, cli, batchCmd, opts) => async (commandParts, _subOpts, _thisCommand) => {
776
1226
  const loggerLocal = opts.logger ?? console;
777
1227
  // Guard: when invoked without positional args (e.g., `batch --list`),
778
1228
  // defer entirely to the parent action handler.
@@ -784,9 +1234,8 @@ const buildDefaultCmdAction = (cli, batchCmd, opts) => async (commandParts, _sub
784
1234
  ? argsRaw.filter((t) => t !== '-l' && t !== '--list')
785
1235
  : argsRaw;
786
1236
  // Access merged per-plugin config from host context (if any).
787
- const ctx = cli.getCtx();
788
- const cfgRaw = (ctx?.pluginConfigs?.['batch'] ?? {});
789
- const cfg = (cfgRaw || {});
1237
+ const cfg = plugin.readConfig(cli);
1238
+ const dotenvEnv = (cli.getCtx()?.dotenv ?? {});
790
1239
  // Resolve batch flags from the captured parent (batch) command.
791
1240
  const raw = batchCmd.opts();
792
1241
  const listFromParent = !!raw.list;
@@ -805,6 +1254,7 @@ const buildDefaultCmdAction = (cli, batchCmd, opts) => async (commandParts, _sub
805
1254
  if (typeof commandOpt === 'string') {
806
1255
  await execShellCommandBatch({
807
1256
  command: resolveCommand(scripts, commandOpt),
1257
+ dotenvEnv,
808
1258
  globs,
809
1259
  ignoreErrors,
810
1260
  list: false,
@@ -816,6 +1266,7 @@ const buildDefaultCmdAction = (cli, batchCmd, opts) => async (commandParts, _sub
816
1266
  return;
817
1267
  }
818
1268
  if (raw.list || localList) {
1269
+ const shellBag = ((batchCmd.parent ?? undefined)?.getDotenvCliOptions ?? {});
819
1270
  await execShellCommandBatch({
820
1271
  globs,
821
1272
  ignoreErrors,
@@ -823,7 +1274,7 @@ const buildDefaultCmdAction = (cli, batchCmd, opts) => async (commandParts, _sub
823
1274
  logger: loggerLocal,
824
1275
  ...(pkgCwd ? { pkgCwd } : {}),
825
1276
  rootPath,
826
- shell: (shell ?? false),
1277
+ shell: shell ?? shellBag.shell ?? false,
827
1278
  });
828
1279
  return;
829
1280
  }
@@ -847,7 +1298,7 @@ const buildDefaultCmdAction = (cli, batchCmd, opts) => async (commandParts, _sub
847
1298
  logger: loggerLocal,
848
1299
  ...(pkgCwd ? { pkgCwd } : {}),
849
1300
  rootPath,
850
- shell: (shell ?? shellBag.shell ?? false),
1301
+ shell: shell ?? shellBag.shell ?? false,
851
1302
  });
852
1303
  return;
853
1304
  }
@@ -890,6 +1341,7 @@ const buildDefaultCmdAction = (cli, batchCmd, opts) => async (commandParts, _sub
890
1341
  }
891
1342
  await execShellCommandBatch({
892
1343
  command: commandArg,
1344
+ dotenvEnv,
893
1345
  ...(envBag ? { getDotenvCliOptions: envBag } : {}),
894
1346
  globs,
895
1347
  ignoreErrors,
@@ -904,12 +1356,11 @@ const buildDefaultCmdAction = (cli, batchCmd, opts) => async (commandParts, _sub
904
1356
  /**
905
1357
  * Build the parent "batch" action handler (no explicit subcommand).
906
1358
  */
907
- const buildParentAction = (cli, opts) => async (commandParts, thisCommand) => {
908
- const logger = opts.logger ?? console;
1359
+ const buildParentAction = (plugin, cli, opts) => async (commandParts, thisCommand) => {
1360
+ const loggerLocal = opts.logger ?? console;
909
1361
  // Ensure context exists (host preSubcommand on root creates if missing).
910
- const ctx = cli.getCtx();
911
- const cfgRaw = (ctx?.pluginConfigs?.['batch'] ?? {});
912
- const cfg = (cfgRaw || {});
1362
+ const dotenvEnv = (cli.getCtx()?.dotenv ?? {});
1363
+ const cfg = plugin.readConfig(cli);
913
1364
  const raw = thisCommand.opts();
914
1365
  const commandOpt = typeof raw.command === 'string' ? raw.command : undefined;
915
1366
  const ignoreErrors = !!raw.ignoreErrors;
@@ -930,10 +1381,11 @@ const buildParentAction = (cli, opts) => async (commandParts, thisCommand) => {
930
1381
  const commandArg = resolved;
931
1382
  await execShellCommandBatch({
932
1383
  command: commandArg,
1384
+ dotenvEnv,
933
1385
  globs,
934
1386
  ignoreErrors,
935
1387
  list: false,
936
- logger,
1388
+ logger: loggerLocal,
937
1389
  ...(pkgCwd ? { pkgCwd } : {}),
938
1390
  rootPath,
939
1391
  shell: shellSetting,
@@ -946,19 +1398,20 @@ const buildParentAction = (cli, opts) => async (commandParts, thisCommand) => {
946
1398
  if (extra.length > 0)
947
1399
  globs = [globs, extra].filter(Boolean).join(' ');
948
1400
  const mergedBag = ((thisCommand.parent ?? null)?.getDotenvCliOptions ?? {});
1401
+ const shellMerged = opts.shell ?? cfg.shell ?? mergedBag.shell ?? false;
949
1402
  await execShellCommandBatch({
950
1403
  globs,
951
1404
  ignoreErrors,
952
1405
  list: true,
953
- logger,
1406
+ logger: loggerLocal,
954
1407
  ...(pkgCwd ? { pkgCwd } : {}),
955
1408
  rootPath,
956
- shell: (opts.shell ?? cfg.shell ?? mergedBag.shell ?? false),
1409
+ shell: shellMerged,
957
1410
  });
958
1411
  return;
959
1412
  }
960
1413
  if (!commandOpt && !list) {
961
- logger.error(`No command provided. Use --command or --list.`);
1414
+ loggerLocal.error(`No command provided. Use --command or --list.`);
962
1415
  process.exit(0);
963
1416
  }
964
1417
  if (typeof commandOpt === 'string') {
@@ -967,10 +1420,11 @@ const buildParentAction = (cli, opts) => async (commandParts, thisCommand) => {
967
1420
  const shellOpt = opts.shell ?? cfg.shell ?? mergedBag.shell;
968
1421
  await execShellCommandBatch({
969
1422
  command: resolveCommand(scriptsOpt, commandOpt),
1423
+ dotenvEnv,
970
1424
  globs,
971
1425
  ignoreErrors,
972
1426
  list,
973
- logger,
1427
+ logger: loggerLocal,
974
1428
  ...(pkgCwd ? { pkgCwd } : {}),
975
1429
  rootPath,
976
1430
  shell: resolveShell(scriptsOpt, commandOpt, shellOpt),
@@ -979,217 +1433,87 @@ const buildParentAction = (cli, opts) => async (commandParts, thisCommand) => {
979
1433
  }
980
1434
  // list only (explicit --list without --command)
981
1435
  const mergedBag = ((thisCommand.parent ?? null)?.getDotenvCliOptions ?? {});
982
- const shellOnly = (opts.shell ?? cfg.shell ?? mergedBag.shell ?? false);
1436
+ const shellOnly = opts.shell ?? cfg.shell ?? mergedBag.shell ?? false;
983
1437
  await execShellCommandBatch({
984
1438
  globs,
985
1439
  ignoreErrors,
986
1440
  list: true,
987
- logger,
1441
+ logger: loggerLocal,
988
1442
  ...(pkgCwd ? { pkgCwd } : {}),
989
1443
  rootPath,
990
- shell: (shellOnly ?? false),
991
- });
992
- };
993
-
994
- // Per-plugin config schema (optional fields; used as defaults).
995
- const ScriptSchema = z.union([
996
- z.string(),
997
- z.object({
998
- cmd: z.string(),
999
- shell: z.union([z.string(), z.boolean()]).optional(),
1000
- }),
1001
- ]);
1002
- const BatchConfigSchema = z.object({
1003
- scripts: z.record(z.string(), ScriptSchema).optional(),
1004
- shell: z.union([z.string(), z.boolean()]).optional(),
1005
- rootPath: z.string().optional(),
1006
- globs: z.string().optional(),
1007
- pkgCwd: z.boolean().optional(),
1008
- });
1009
-
1010
- /**
1011
- * Batch plugin for the GetDotenv CLI host.
1012
- *
1013
- * Mirrors the legacy batch subcommand behavior without altering the shipped CLI. * Options:
1014
- * - scripts/shell: used to resolve command and shell behavior per script or global default.
1015
- * - logger: defaults to console.
1016
- */
1017
- const batchPlugin = (opts = {}) => definePlugin({
1018
- id: 'batch',
1019
- // Host validates this when config-loader is enabled; plugins may also
1020
- // re-validate at action time as a safety belt.
1021
- configSchema: BatchConfigSchema,
1022
- setup(cli) {
1023
- const ns = cli.ns('batch');
1024
- const batchCmd = ns; // capture the parent "batch" command for default-subcommand context
1025
- ns.description('Batch command execution across multiple working directories.')
1026
- .enablePositionalOptions()
1027
- .passThroughOptions()
1028
- .option('-p, --pkg-cwd', 'use nearest package directory as current working directory')
1029
- .option('-r, --root-path <string>', 'path to batch root directory from current working directory', './')
1030
- .option('-g, --globs <string>', 'space-delimited globs from root path', '*')
1031
- .option('-c, --command <string>', 'command executed according to the base shell resolution')
1032
- .option('-l, --list', 'list working directories without executing command')
1033
- .option('-e, --ignore-errors', 'ignore errors and continue with next path')
1034
- .argument('[command...]')
1035
- .addCommand(new Command()
1036
- .name('cmd')
1037
- .description('execute command, conflicts with --command option (default subcommand)')
1038
- .enablePositionalOptions()
1039
- .passThroughOptions()
1040
- .argument('[command...]')
1041
- .action(buildDefaultCmdAction(cli, batchCmd, opts)), { isDefault: true })
1042
- .action(buildParentAction(cli, opts));
1043
- },
1044
- });
1045
-
1046
- /** src/diagnostics/entropy.ts
1047
- * Entropy diagnostics (presentation-only).
1048
- * - Gated by min length and printable ASCII.
1049
- * - Warn once per key per run when bits/char \>= threshold.
1050
- * - Supports whitelist patterns to suppress known-noise keys.
1051
- */
1052
- const warned = new Set();
1053
- const isPrintableAscii = (s) => /^[\x20-\x7E]+$/.test(s);
1054
- const compile$1 = (patterns) => (patterns ?? []).map((p) => new RegExp(p, 'i'));
1055
- const whitelisted = (key, regs) => regs.some((re) => re.test(key));
1056
- const shannonBitsPerChar = (s) => {
1057
- const freq = new Map();
1058
- for (const ch of s)
1059
- freq.set(ch, (freq.get(ch) ?? 0) + 1);
1060
- const n = s.length;
1061
- let h = 0;
1062
- for (const c of freq.values()) {
1063
- const p = c / n;
1064
- h -= p * Math.log2(p);
1065
- }
1066
- return h;
1067
- };
1068
- /**
1069
- * Maybe emit a one-line entropy warning for a key.
1070
- * Caller supplies an `emit(line)` function; the helper ensures once-per-key.
1071
- */
1072
- const maybeWarnEntropy = (key, value, origin, opts, emit) => {
1073
- if (!opts || opts.warnEntropy === false)
1074
- return;
1075
- if (warned.has(key))
1076
- return;
1077
- const v = value ?? '';
1078
- const minLen = Math.max(0, opts.entropyMinLength ?? 16);
1079
- const threshold = opts.entropyThreshold ?? 3.8;
1080
- if (v.length < minLen)
1081
- return;
1082
- if (!isPrintableAscii(v))
1083
- return;
1084
- const wl = compile$1(opts.entropyWhitelist);
1085
- if (whitelisted(key, wl))
1086
- return;
1087
- const bpc = shannonBitsPerChar(v);
1088
- if (bpc >= threshold) {
1089
- warned.add(key);
1090
- emit(`[entropy] key=${key} score=${bpc.toFixed(2)} len=${String(v.length)} origin=${origin}`);
1091
- }
1092
- };
1093
-
1094
- const DEFAULT_PATTERNS = [
1095
- '\\bsecret\\b',
1096
- '\\btoken\\b',
1097
- '\\bpass(word)?\\b',
1098
- '\\bapi[_-]?key\\b',
1099
- '\\bkey\\b',
1100
- ];
1101
- const compile = (patterns) => (patterns && patterns.length > 0 ? patterns : DEFAULT_PATTERNS).map((p) => new RegExp(p, 'i'));
1102
- const shouldRedactKey = (key, regs) => regs.some((re) => re.test(key));
1103
- const MASK = '[redacted]';
1104
- /**
1105
- * Utility to redact three related displayed values (parent/dotenv/final)
1106
- * consistently for trace lines.
1107
- */
1108
- const redactTriple = (key, triple, opts) => {
1109
- if (!opts?.redact)
1110
- return triple;
1111
- const regs = compile(opts.redactPatterns);
1112
- const maskIf = (v) => (v && shouldRedactKey(key, regs) ? MASK : v);
1113
- const out = {};
1114
- const p = maskIf(triple.parent);
1115
- const d = maskIf(triple.dotenv);
1116
- const f = maskIf(triple.final);
1117
- if (p !== undefined)
1118
- out.parent = p;
1119
- if (d !== undefined)
1120
- out.dotenv = d;
1121
- if (f !== undefined)
1122
- out.final = f;
1123
- return out;
1124
- };
1125
-
1126
- // Base root CLI defaults (shared; kept untyped here to avoid cross-layer deps).
1127
- const baseRootOptionDefaults = {
1128
- dotenvToken: '.env',
1129
- loadProcess: true,
1130
- logger: console,
1131
- // Diagnostics defaults
1132
- warnEntropy: true,
1133
- entropyThreshold: 3.8,
1134
- entropyMinLength: 16,
1135
- entropyWhitelist: ['^GIT_', '^npm_', '^CI$', 'SHLVL'],
1136
- paths: './',
1137
- pathsDelimiter: ' ',
1138
- privateToken: 'local',
1139
- scripts: {
1140
- 'git-status': {
1141
- cmd: 'git branch --show-current && git status -s -u',
1142
- shell: true,
1143
- },
1144
- },
1145
- shell: true,
1146
- vars: '',
1147
- varsAssignor: '=',
1148
- varsDelimiter: ' ',
1149
- // tri-state flags default to unset unless explicitly provided
1150
- // (debug/log/exclude* resolved via flag utils)
1151
- };
1152
-
1153
- /** @internal */
1154
- const isPlainObject = (value) => value !== null &&
1155
- typeof value === 'object' &&
1156
- Object.getPrototypeOf(value) === Object.prototype;
1157
- const mergeInto = (target, source) => {
1158
- for (const [key, sVal] of Object.entries(source)) {
1159
- if (sVal === undefined)
1160
- continue; // do not overwrite with undefined
1161
- const tVal = target[key];
1162
- if (isPlainObject(tVal) && isPlainObject(sVal)) {
1163
- target[key] = mergeInto({ ...tVal }, sVal);
1164
- }
1165
- else if (isPlainObject(sVal)) {
1166
- target[key] = mergeInto({}, sVal);
1167
- }
1168
- else {
1169
- target[key] = sVal;
1170
- }
1171
- }
1172
- return target;
1444
+ shell: shellOnly,
1445
+ });
1173
1446
  };
1447
+
1448
+ // Per-plugin config schema (optional fields; used as defaults).
1449
+ const ScriptSchema = z.union([
1450
+ z.string(),
1451
+ z.object({
1452
+ cmd: z.string(),
1453
+ shell: z.union([z.string(), z.boolean()]).optional(),
1454
+ }),
1455
+ ]);
1456
+ const BatchConfigSchema = z.object({
1457
+ scripts: z.record(z.string(), ScriptSchema).optional(),
1458
+ shell: z.union([z.string(), z.boolean()]).optional(),
1459
+ rootPath: z.string().optional(),
1460
+ globs: z.string().optional(),
1461
+ pkgCwd: z.boolean().optional(),
1462
+ });
1463
+
1174
1464
  /**
1175
- * Perform a deep defaults-style merge across plain objects. *
1176
- * - Only merges plain objects (prototype === Object.prototype).
1177
- * - Arrays and non-objects are replaced, not merged.
1178
- * - `undefined` values are ignored and do not overwrite prior values.
1179
- *
1180
- * @typeParam T - The resulting shape after merging all layers.
1181
- * @param layers - Zero or more partial layers in ascending precedence order.
1182
- * @returns The merged object typed as {@link T}.
1465
+ * Batch plugin for the GetDotenv CLI host.
1183
1466
  *
1184
- * @example
1185
- * defaultsDeep(\{ a: 1, nested: \{ b: 2 \} \}, \{ nested: \{ b: 3, c: 4 \} \})
1186
- * =\> \{ a: 1, nested: \{ b: 3, c: 4 \} \}
1467
+ * Mirrors the legacy batch subcommand behavior without altering the shipped CLI.
1468
+ * Options:
1469
+ * - scripts/shell: used to resolve command and shell behavior per script or global default.
1470
+ * - logger: defaults to console.
1187
1471
  */
1188
- const defaultsDeep = (...layers) => {
1189
- const result = layers
1190
- .filter(Boolean)
1191
- .reduce((acc, layer) => mergeInto(acc, layer), {});
1192
- return result;
1472
+ const batchPlugin = (opts = {}) => {
1473
+ const plugin = definePlugin({
1474
+ id: 'batch',
1475
+ // Host validates this when config-loader is enabled; plugins may also
1476
+ // re-validate at action time as a safety belt.
1477
+ configSchema: BatchConfigSchema,
1478
+ setup(cli) {
1479
+ const ns = cli.ns('batch');
1480
+ const batchCmd = ns; // capture the parent "batch" command for default-subcommand context
1481
+ const pluginId = 'batch';
1482
+ const GROUP = `plugin:${pluginId}`;
1483
+ ns.description('Batch command execution across multiple working directories.')
1484
+ .enablePositionalOptions()
1485
+ .passThroughOptions()
1486
+ // Dynamic help: show effective defaults from the merged/interpolated plugin config slice.
1487
+ .addOption((() => {
1488
+ const opt = plugin.createPluginDynamicOption(cli, '-p, --pkg-cwd', (_bag, cfg) => `use nearest package directory as current working directory${cfg.pkgCwd ? ' (default)' : ''}`);
1489
+ cli.setOptionGroup(opt, GROUP);
1490
+ return opt;
1491
+ })())
1492
+ .addOption((() => {
1493
+ const opt = plugin.createPluginDynamicOption(cli, '-r, --root-path <string>', (_bag, cfg) => `path to batch root directory from current working directory (default: ${JSON.stringify(cfg.rootPath || './')})`);
1494
+ cli.setOptionGroup(opt, GROUP);
1495
+ return opt;
1496
+ })())
1497
+ .addOption((() => {
1498
+ const opt = plugin.createPluginDynamicOption(cli, '-g, --globs <string>', (_bag, cfg) => `space-delimited globs from root path (default: ${JSON.stringify(cfg.globs || '*')})`);
1499
+ cli.setOptionGroup(opt, GROUP);
1500
+ return opt;
1501
+ })())
1502
+ .option('-c, --command <string>', 'command executed according to the base shell resolution')
1503
+ .option('-l, --list', 'list working directories without executing command')
1504
+ .option('-e, --ignore-errors', 'ignore errors and continue with next path')
1505
+ .argument('[command...]')
1506
+ .addCommand(new Command()
1507
+ .name('cmd')
1508
+ .description('execute command, conflicts with --command option (default subcommand)')
1509
+ .enablePositionalOptions()
1510
+ .passThroughOptions()
1511
+ .argument('[command...]')
1512
+ .action(buildDefaultCmdAction(plugin, cli, batchCmd, opts)), { isDefault: true })
1513
+ .action(buildParentAction(plugin, cli, opts));
1514
+ },
1515
+ });
1516
+ return plugin;
1193
1517
  };
1194
1518
 
1195
1519
  /**
@@ -1317,180 +1641,225 @@ const resolveCliOptions = (rawCliOptions, defaults, parentJson) => {
1317
1641
  return cmd !== undefined ? { merged, command: cmd } : { merged };
1318
1642
  };
1319
1643
 
1320
- /**
1321
- * Dotenv expansion utilities.
1322
- *
1323
- * This module implements recursive expansion of environment-variable
1324
- * references in strings and records. It supports both whitespace and
1325
- * bracket syntaxes with optional defaults:
1326
- *
1327
- * - Whitespace: `$VAR[:default]`
1328
- * - Bracketed: `${VAR[:default]}`
1329
- *
1330
- * Escaped dollar signs (`\$`) are preserved.
1331
- * Unknown variables resolve to empty string unless a default is provided.
1332
- */
1333
- /**
1334
- * Like String.prototype.search but returns the last index.
1335
- * @internal
1336
- */
1337
- const searchLast = (str, rgx) => {
1338
- const matches = Array.from(str.matchAll(rgx));
1339
- return matches.length > 0 ? (matches.slice(-1)[0]?.index ?? -1) : -1;
1340
- };
1341
- const replaceMatch = (value, match, ref) => {
1342
- /**
1343
- * @internal
1344
- */
1345
- const group = match[0];
1346
- const key = match[1];
1347
- const defaultValue = match[2];
1348
- if (!key)
1349
- return value;
1350
- const replacement = value.replace(group, ref[key] ?? defaultValue ?? '');
1351
- return interpolate(replacement, ref);
1352
- };
1353
- const interpolate = (value = '', ref = {}) => {
1354
- /**
1355
- * @internal
1356
- */
1357
- // if value is falsy, return it as is
1358
- if (!value)
1359
- return value;
1360
- // get position of last unescaped dollar sign
1361
- const lastUnescapedDollarSignIndex = searchLast(value, /(?!(?<=\\))\$/g);
1362
- // return value if none found
1363
- if (lastUnescapedDollarSignIndex === -1)
1364
- return value;
1365
- // evaluate the value tail
1366
- const tail = value.slice(lastUnescapedDollarSignIndex);
1367
- // find whitespace pattern: $KEY:DEFAULT
1368
- const whitespacePattern = /^\$([\w]+)(?::([^\s]*))?/;
1369
- const whitespaceMatch = whitespacePattern.exec(tail);
1370
- if (whitespaceMatch != null)
1371
- return replaceMatch(value, whitespaceMatch, ref);
1372
- else {
1373
- // find bracket pattern: ${KEY:DEFAULT}
1374
- const bracketPattern = /^\${([\w]+)(?::([^}]*))?}/;
1375
- const bracketMatch = bracketPattern.exec(tail);
1376
- if (bracketMatch != null)
1377
- return replaceMatch(value, bracketMatch, ref);
1644
+ const dbg = (...args) => {
1645
+ if (process.env.GETDOTENV_DEBUG) {
1646
+ // Use stderr to avoid interfering with stdout assertions
1647
+ console.error('[getdotenv:alias]', ...args);
1378
1648
  }
1379
- return value;
1380
1649
  };
1381
- /**
1382
- * Recursively expands environment variables in a string. Variables may be
1383
- * presented with optional default as `$VAR[:default]` or `${VAR[:default]}`.
1384
- * Unknown variables will expand to an empty string.
1385
- *
1386
- * @param value - The string to expand.
1387
- * @param ref - The reference object to use for variable expansion.
1388
- * @returns The expanded string.
1389
- *
1390
- * @example
1391
- * ```ts
1392
- * process.env.FOO = 'bar';
1393
- * dotenvExpand('Hello $FOO'); // "Hello bar"
1394
- * dotenvExpand('Hello $BAZ:world'); // "Hello world"
1395
- * ```
1396
- *
1397
- * @remarks
1398
- * The expansion is recursive. If a referenced variable itself contains
1399
- * references, those will also be expanded until a stable value is reached.
1400
- * Escaped references (e.g. `\$FOO`) are preserved as literals.
1401
- */
1402
- const dotenvExpand = (value, ref = process.env) => {
1403
- const result = interpolate(value, ref);
1404
- return result ? result.replace(/\\\$/g, '$') : undefined;
1650
+ // Strip one symmetric outer quote layer
1651
+ const stripOne = (s) => {
1652
+ if (s.length < 2)
1653
+ return s;
1654
+ const a = s.charAt(0);
1655
+ const b = s.charAt(s.length - 1);
1656
+ const symmetric = (a === '"' && b === '"') || (a === "'" && b === "'");
1657
+ return symmetric ? s.slice(1, -1) : s;
1405
1658
  };
1406
- /**
1407
- * Recursively expands environment variables in a string using `process.env` as
1408
- * the expansion reference. Variables may be presented with optional default as
1409
- * `$VAR[:default]` or `${VAR[:default]}`. Unknown variables will expand to an
1410
- * empty string.
1411
- *
1412
- * @param value - The string to expand.
1413
- * @returns The expanded string.
1414
- *
1415
- * @example
1416
- * ```ts
1417
- * process.env.FOO = 'bar';
1418
- * dotenvExpandFromProcessEnv('Hello $FOO'); // "Hello bar"
1419
- * ```
1420
- */
1421
- const dotenvExpandFromProcessEnv = (value) => dotenvExpand(value, process.env);
1422
-
1423
- // src/GetDotenvOptions.ts
1424
- /**
1425
- * Converts programmatic CLI options to `getDotenv` options. *
1426
- * @param cliOptions - CLI options. Defaults to `{}`.
1427
- *
1428
- * @returns `getDotenv` options.
1429
- */
1430
- const getDotenvCliOptions2Options = ({ paths, pathsDelimiter, pathsDelimiterPattern, vars, varsAssignor, varsAssignorPattern, varsDelimiter, varsDelimiterPattern, ...rest }) => {
1431
- /**
1432
- * Convert CLI-facing string options into {@link GetDotenvOptions}.
1433
- *
1434
- * - 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`
1435
- * pairs (configurable delimiters) into a {@link ProcessEnv}.
1436
- * - Drops CLI-only keys that have no programmatic equivalent.
1437
- *
1438
- * @remarks
1439
- * Follows exact-optional semantics by not emitting undefined-valued entries.
1440
- */
1441
- // Drop CLI-only keys (debug/scripts) without relying on Record casts.
1442
- // Create a shallow copy then delete optional CLI-only keys if present.
1443
- const restObj = { ...rest };
1444
- delete restObj.debug;
1445
- delete restObj.scripts;
1446
- const splitBy = (value, delim, pattern) => (value ? value.split(pattern ? RegExp(pattern) : (delim ?? ' ')) : []);
1447
- // Tolerate vars as either a CLI string ("A=1 B=2") or an object map.
1448
- let parsedVars;
1449
- if (typeof vars === 'string') {
1450
- const kvPairs = splitBy(vars, varsDelimiter, varsDelimiterPattern).map((v) => v.split(varsAssignorPattern
1451
- ? RegExp(varsAssignorPattern)
1452
- : (varsAssignor ?? '=')));
1453
- parsedVars = Object.fromEntries(kvPairs);
1659
+ async function maybeRunAlias(cli, thisCommand, aliasKey, state) {
1660
+ dbg('alias:maybe:start');
1661
+ const raw = thisCommand.rawArgs ?? [];
1662
+ const childNames = thisCommand.commands.flatMap((c) => [
1663
+ c.name(),
1664
+ ...c.aliases(),
1665
+ ]);
1666
+ const hasSub = childNames.some((n) => raw.includes(n));
1667
+ const o = thisCommand.opts();
1668
+ const val = o[aliasKey];
1669
+ const provided = typeof val === 'string'
1670
+ ? val.length > 0
1671
+ : Array.isArray(val)
1672
+ ? val.length > 0
1673
+ : false;
1674
+ if (!provided || hasSub) {
1675
+ dbg('alias:maybe:skip', { provided, hasSub });
1676
+ return; // not an alias-only invocation
1454
1677
  }
1455
- else if (vars && typeof vars === 'object' && !Array.isArray(vars)) {
1456
- // Keep only string or undefined values to match ProcessEnv.
1457
- const entries = Object.entries(vars).filter(([k, v]) => typeof k === 'string' && (typeof v === 'string' || v === undefined));
1458
- parsedVars = Object.fromEntries(entries);
1678
+ if (state.handled) {
1679
+ dbg('alias:maybe:already-handled');
1680
+ return;
1459
1681
  }
1460
- // Drop undefined-valued entries at the converter stage to match ProcessEnv
1461
- // expectations and the compat test assertions.
1462
- if (parsedVars) {
1463
- parsedVars = Object.fromEntries(Object.entries(parsedVars).filter(([, v]) => v !== undefined));
1682
+ state.handled = true;
1683
+ dbg('alias-only invocation detected');
1684
+ // Merge CLI options and resolve dotenv context.
1685
+ const { merged } = resolveCliOptions(o, baseGetDotenvCliOptions, process.env.getDotenvCliOptions);
1686
+ const mergedBag = merged;
1687
+ const logger = (mergedBag.logger ?? console);
1688
+ const serviceOptions = getDotenvCliOptions2Options(mergedBag);
1689
+ await cli.resolveAndLoad(serviceOptions);
1690
+ // Normalize alias value
1691
+ const joined = typeof val === 'string'
1692
+ ? val
1693
+ : Array.isArray(val)
1694
+ ? val.map(String).join(' ')
1695
+ : '';
1696
+ const expanded = dotenvExpandFromProcessEnv(joined);
1697
+ const input = mergedBag.expand === false
1698
+ ? joined
1699
+ : expanded !== undefined
1700
+ ? expanded
1701
+ : joined;
1702
+ // Scripts: prefer well-formed records; tolerate absent/bad shapes
1703
+ const maybeScripts = mergedBag.scripts;
1704
+ const scripts = maybeScripts && typeof maybeScripts === 'object'
1705
+ ? maybeScripts
1706
+ : undefined;
1707
+ const resolved = resolveCommand(scripts, input);
1708
+ if (mergedBag.debug) {
1709
+ logger.log('\n*** command ***\n', `'${resolved}'`);
1464
1710
  }
1465
- // Tolerate paths as either a delimited string or string[]
1466
- // Use a locally cast union type to avoid lint warnings about always-falsy conditions
1467
- // under the RootOptionsShape (which declares paths as string | undefined).
1468
- const pathsAny = paths;
1469
- const pathsOut = Array.isArray(pathsAny)
1470
- ? pathsAny.filter((p) => typeof p === 'string')
1471
- : splitBy(pathsAny, pathsDelimiter, pathsDelimiterPattern);
1472
- // Preserve exactOptionalPropertyTypes: only include keys when defined.
1473
- return {
1474
- ...restObj,
1475
- ...(pathsOut.length > 0 ? { paths: pathsOut } : {}),
1476
- ...(parsedVars !== undefined ? { vars: parsedVars } : {}),
1477
- };
1478
- };
1479
-
1480
- const dbg = (...args) => {
1481
- if (process.env.GETDOTENV_DEBUG) {
1482
- // Use stderr to avoid interfering with stdout assertions
1483
- console.error('[getdotenv:alias]', ...args);
1711
+ // Round-trip CLI options for nested getdotenv invocations. Omit logger
1712
+ // (functions/circulars) and guard JSON serialization to avoid hard failures.
1713
+ const { logger: _omitLogger, ...envBag } = mergedBag;
1714
+ let nestedBag;
1715
+ try {
1716
+ nestedBag = JSON.stringify(envBag);
1484
1717
  }
1485
- };
1718
+ catch {
1719
+ nestedBag = undefined;
1720
+ }
1721
+ const underTests = process.env.GETDOTENV_TEST === '1' ||
1722
+ typeof process.env.VITEST_WORKER_ID === 'string';
1723
+ const forceExit = process.env.GETDOTENV_FORCE_EXIT === '1';
1724
+ const capture = !underTests &&
1725
+ (process.env.GETDOTENV_STDIO === 'pipe' ||
1726
+ Boolean(mergedBag.capture));
1727
+ dbg('run:start', {
1728
+ capture,
1729
+ shell: mergedBag.shell,
1730
+ });
1731
+ const ctx = cli.getCtx();
1732
+ const dotenv = (ctx?.dotenv ?? {});
1733
+ // Diagnostics: --trace [keys...]
1734
+ const traceOpt = mergedBag.trace;
1735
+ if (traceOpt) {
1736
+ const parentKeys = Object.keys(process.env);
1737
+ const dotenvKeys = Object.keys(dotenv);
1738
+ const allKeys = Array.from(new Set([...parentKeys, ...dotenvKeys])).sort();
1739
+ const keys = Array.isArray(traceOpt) ? traceOpt : allKeys;
1740
+ const childEnvPreview = {
1741
+ ...process.env,
1742
+ ...dotenv,
1743
+ };
1744
+ for (const k of keys) {
1745
+ const parent = process.env[k];
1746
+ const dot = dotenv[k];
1747
+ const final = childEnvPreview[k];
1748
+ const origin = dot !== undefined
1749
+ ? 'dotenv'
1750
+ : parent !== undefined
1751
+ ? 'parent'
1752
+ : 'unset';
1753
+ const redFlag = mergedBag.redact;
1754
+ const redPatterns = mergedBag.redactPatterns;
1755
+ const redOpts = {};
1756
+ if (redFlag)
1757
+ redOpts.redact = true;
1758
+ if (redFlag && Array.isArray(redPatterns))
1759
+ redOpts.redactPatterns = redPatterns;
1760
+ const tripleBag = {};
1761
+ if (parent !== undefined)
1762
+ tripleBag.parent = parent;
1763
+ if (dot !== undefined)
1764
+ tripleBag.dotenv = dot;
1765
+ if (final !== undefined)
1766
+ tripleBag.final = final;
1767
+ const triple = redactTriple(k, tripleBag, redOpts);
1768
+ process.stderr.write(`[trace] key=${k} origin=${origin} parent=${triple.parent ?? ''} dotenv=${triple.dotenv ?? ''} final=${triple.final ?? ''}\n`);
1769
+ const entOpts = {};
1770
+ const warnEntropy = mergedBag.warnEntropy;
1771
+ const entropyThreshold = mergedBag
1772
+ .entropyThreshold;
1773
+ const entropyMinLength = mergedBag
1774
+ .entropyMinLength;
1775
+ const entropyWhitelist = mergedBag.entropyWhitelist;
1776
+ if (typeof warnEntropy === 'boolean')
1777
+ entOpts.warnEntropy = warnEntropy;
1778
+ if (typeof entropyThreshold === 'number')
1779
+ entOpts.entropyThreshold = entropyThreshold;
1780
+ if (typeof entropyMinLength === 'number')
1781
+ entOpts.entropyMinLength = entropyMinLength;
1782
+ if (Array.isArray(entropyWhitelist))
1783
+ entOpts.entropyWhitelist = entropyWhitelist;
1784
+ maybeWarnEntropy(k, final, origin, entOpts, (line) => process.stderr.write(line + '\n'));
1785
+ }
1786
+ }
1787
+ const shellSetting = resolveShell(scripts, input, mergedBag.shell);
1788
+ // Preserve argv array for Node -e snippets under shell-off
1789
+ let commandArg = resolved;
1790
+ if (shellSetting === false && resolved === input) {
1791
+ // Important: preserve doubled quotes within the Node -e payload so
1792
+ // empty string literals ("") survive; Windows-style doubling must not
1793
+ // collapse "" -> " in this path.
1794
+ const parts = tokenize(input, { preserveDoubledQuotes: true });
1795
+ if (parts.length >= 3 &&
1796
+ parts[0]?.toLowerCase() === 'node' &&
1797
+ (parts[1] === '-e' || parts[1] === '--eval')) {
1798
+ // Peel exactly one symmetric outer quote on the code arg
1799
+ parts[2] = stripOne(parts[2] ?? '');
1800
+ // Historical behavior: pass the argv array through unchanged for shell-off.
1801
+ commandArg = parts;
1802
+ }
1803
+ }
1804
+ let exitCode = Number.NaN;
1805
+ try {
1806
+ exitCode = await runCommand(commandArg, shellSetting, {
1807
+ env: buildSpawnEnv(process.env, nestedBag
1808
+ ? {
1809
+ ...dotenv,
1810
+ getDotenvCliOptions: nestedBag,
1811
+ }
1812
+ : {
1813
+ ...dotenv,
1814
+ }),
1815
+ stdio: capture ? 'pipe' : 'inherit',
1816
+ });
1817
+ dbg('run:done', { exitCode });
1818
+ }
1819
+ catch (err) {
1820
+ const code = typeof err.exitCode === 'number'
1821
+ ? err.exitCode
1822
+ : 1;
1823
+ dbg('run:error', { exitCode: code, error: String(err) });
1824
+ if (!underTests) {
1825
+ dbg('process.exit (error path)', { exitCode: code });
1826
+ process.exit(code);
1827
+ }
1828
+ else {
1829
+ dbg('process.exit suppressed for tests (error path)', {
1830
+ exitCode: code,
1831
+ });
1832
+ }
1833
+ return;
1834
+ }
1835
+ if (!Number.isNaN(exitCode)) {
1836
+ dbg('process.exit', { exitCode });
1837
+ process.exit(exitCode);
1838
+ }
1839
+ if (!underTests) {
1840
+ dbg('process.exit (fallback: non-numeric exitCode)', { exitCode: 0 });
1841
+ process.exit(0);
1842
+ }
1843
+ else {
1844
+ dbg('process.exit (fallback suppressed for tests: non-numeric exitCode)', {
1845
+ exitCode: 0,
1846
+ });
1847
+ }
1848
+ if (forceExit) {
1849
+ setImmediate(() => process.exit(Number.isNaN(exitCode) ? 0 : exitCode));
1850
+ }
1851
+ }
1852
+
1486
1853
  const attachParentAlias = (cli, options, _cmd) => {
1487
1854
  const aliasSpec = typeof options.optionAlias === 'string'
1488
- ? { flags: options.optionAlias, description: undefined, expand: true }
1855
+ ? { flags: options.optionAlias, description: undefined}
1489
1856
  : options.optionAlias;
1490
1857
  if (!aliasSpec)
1491
1858
  return;
1492
1859
  const deriveKey = (flags) => {
1493
- dbg('install alias option', flags);
1860
+ if (process.env.GETDOTENV_DEBUG) {
1861
+ console.error('[getdotenv:alias] install alias option', flags);
1862
+ }
1494
1863
  const long = flags.split(/[ ,|]+/).find((f) => f.startsWith('--')) ?? '--cmd';
1495
1864
  const name = long.replace(/^--/, '');
1496
1865
  return name.replace(/-([a-z])/g, (_m, c) => c.toUpperCase());
@@ -1500,256 +1869,24 @@ const attachParentAlias = (cli, options, _cmd) => {
1500
1869
  const desc = aliasSpec.description ??
1501
1870
  'alias of cmd subcommand; provide command tokens (variadic)';
1502
1871
  cli.option(aliasSpec.flags, desc);
1503
- // Tag the just-added parent option for grouped help rendering.
1504
- try {
1505
- const optsArr = cli.options;
1506
- if (Array.isArray(optsArr) && optsArr.length > 0) {
1507
- const last = optsArr[optsArr.length - 1];
1508
- last.__group = 'plugin:cmd';
1509
- }
1510
- }
1511
- catch {
1512
- /* noop */
1872
+ // Tag the just-added parent option for grouped help rendering at the root.
1873
+ const optsArr = cli.options;
1874
+ if (optsArr.length > 0) {
1875
+ const last = optsArr[optsArr.length - 1];
1876
+ if (last)
1877
+ cli.setOptionGroup(last, 'plugin:cmd');
1513
1878
  }
1514
1879
  // Shared alias executor for either preAction or preSubcommand hooks.
1515
1880
  // Ensure we only execute once even if both hooks fire in a single parse.
1516
- let aliasHandled = false;
1517
- const maybeRunAlias = async (thisCommand) => {
1518
- dbg('alias:maybe:start');
1519
- const raw = thisCommand.rawArgs ?? [];
1520
- const childNames = thisCommand.commands.flatMap((c) => [
1521
- c.name(),
1522
- ...c.aliases(),
1523
- ]);
1524
- const hasSub = childNames.some((n) => raw.includes(n));
1525
- // Read alias value from parent opts.
1526
- const o = thisCommand.opts();
1527
- const val = o[aliasKey];
1528
- const provided = typeof val === 'string'
1529
- ? val.length > 0
1530
- : Array.isArray(val)
1531
- ? val.length > 0
1532
- : false;
1533
- if (!provided || hasSub) {
1534
- dbg('alias:maybe:skip', { provided, hasSub });
1535
- return; // not an alias-only invocation
1536
- }
1537
- if (aliasHandled) {
1538
- dbg('alias:maybe:already-handled');
1539
- return;
1540
- }
1541
- aliasHandled = true;
1542
- dbg('alias-only invocation detected');
1543
- // Merge CLI options and resolve dotenv context.
1544
- const { merged } = resolveCliOptions(o,
1545
- // cast through unknown to avoid readonly -> mutable incompatibilities
1546
- baseRootOptionDefaults, process.env.getDotenvCliOptions);
1547
- const logger = merged.logger ?? console;
1548
- const serviceOptions = getDotenvCliOptions2Options(merged);
1549
- await cli.resolveAndLoad(serviceOptions);
1550
- // Normalize alias value.
1551
- const joined = typeof val === 'string'
1552
- ? val
1553
- : Array.isArray(val)
1554
- ? val.map(String).join(' ')
1555
- : '';
1556
- const input = aliasSpec.expand === false
1557
- ? joined
1558
- : (dotenvExpandFromProcessEnv(joined) ?? joined);
1559
- dbg('resolved input', { input });
1560
- const resolved = resolveCommand(merged.scripts, input);
1561
- const lg = logger;
1562
- if (merged.debug) {
1563
- (lg.debug ?? lg.log)('\n*** command ***\n', `'${resolved}'`);
1564
- }
1565
- const { logger: _omit, ...envBag } = merged;
1566
- // Test guard: when running under tests, prefer stdio: 'inherit' to avoid
1567
- // assertions depending on captured stdio; ignore GETDOTENV_STDIO/capture.
1568
- const underTests = process.env.GETDOTENV_TEST === '1' ||
1569
- typeof process.env.VITEST_WORKER_ID === 'string';
1570
- const forceExit = process.env.GETDOTENV_FORCE_EXIT === '1';
1571
- const capture = !underTests &&
1572
- (process.env.GETDOTENV_STDIO === 'pipe' ||
1573
- Boolean(merged.capture));
1574
- dbg('run:start', { capture, shell: merged.shell });
1575
- // Prefer explicit env injection: include resolved dotenv map to avoid leaking
1576
- // parent process.env secrets when exclusions are set.
1577
- const ctx = cli.getCtx();
1578
- const dotenv = (ctx?.dotenv ?? {});
1579
- // Diagnostics: --trace [keys...]
1580
- const traceOpt = merged.trace;
1581
- if (traceOpt) {
1582
- const parentKeys = Object.keys(process.env);
1583
- const dotenvKeys = Object.keys(dotenv);
1584
- const allKeys = Array.from(new Set([...parentKeys, ...dotenvKeys])).sort();
1585
- const keys = Array.isArray(traceOpt) ? traceOpt : allKeys;
1586
- const childEnvPreview = {
1587
- ...process.env,
1588
- ...dotenv,
1589
- };
1590
- for (const k of keys) {
1591
- const parent = process.env[k];
1592
- const dot = dotenv[k];
1593
- const final = childEnvPreview[k];
1594
- const origin = dot !== undefined
1595
- ? 'dotenv'
1596
- : parent !== undefined
1597
- ? 'parent'
1598
- : 'unset';
1599
- // Build redact options and triple bag without undefined-valued fields
1600
- const redOpts = {};
1601
- const redFlag = merged.redact;
1602
- const redPatterns = merged
1603
- .redactPatterns;
1604
- if (redFlag)
1605
- redOpts.redact = true;
1606
- if (redFlag && Array.isArray(redPatterns))
1607
- redOpts.redactPatterns = redPatterns;
1608
- const tripleBag = {};
1609
- if (parent !== undefined)
1610
- tripleBag.parent = parent;
1611
- if (dot !== undefined)
1612
- tripleBag.dotenv = dot;
1613
- if (final !== undefined)
1614
- tripleBag.final = final;
1615
- const triple = redactTriple(k, tripleBag, redOpts);
1616
- process.stderr.write(`[trace] key=${k} origin=${origin} parent=${triple.parent ?? ''} dotenv=${triple.dotenv ?? ''} final=${triple.final ?? ''}\n`);
1617
- const entOpts = {};
1618
- const warnEntropy = merged.warnEntropy;
1619
- const entropyThreshold = merged
1620
- .entropyThreshold;
1621
- const entropyMinLength = merged
1622
- .entropyMinLength;
1623
- const entropyWhitelist = merged
1624
- .entropyWhitelist;
1625
- if (typeof warnEntropy === 'boolean')
1626
- entOpts.warnEntropy = warnEntropy;
1627
- if (typeof entropyThreshold === 'number')
1628
- entOpts.entropyThreshold = entropyThreshold;
1629
- if (typeof entropyMinLength === 'number')
1630
- entOpts.entropyMinLength = entropyMinLength;
1631
- if (Array.isArray(entropyWhitelist))
1632
- entOpts.entropyWhitelist = entropyWhitelist;
1633
- maybeWarnEntropy(k, final, origin, entOpts, (line) => process.stderr.write(line + '\n'));
1634
- }
1635
- }
1636
- let exitCode = Number.NaN;
1637
- try {
1638
- // Resolve shell and preserve argv for Node -e snippets under shell-off.
1639
- const shellSetting = resolveShell(merged.scripts, input, merged.shell);
1640
- let commandArg = resolved;
1641
- /** * Special-case: when shell is OFF and no script alias remap occurred
1642
- * (resolved === input), treat a Node eval payload as an argv array to
1643
- * avoid lossy re-tokenization of the code string.
1644
- *
1645
- * Examples handled:
1646
- * "node -e \"console.log(JSON.stringify(...))\""
1647
- * "node --eval 'console.log(...)'"
1648
- *
1649
- * We peel exactly one pair of symmetric outer quotes from the code
1650
- * argument when present; inner quotes remain untouched.
1651
- */
1652
- if (shellSetting === false && resolved === input) {
1653
- // Helper: strip one symmetric outer quote layer
1654
- const stripOne = (s) => {
1655
- if (s.length < 2)
1656
- return s;
1657
- const a = s.charAt(0);
1658
- const b = s.charAt(s.length - 1);
1659
- const symmetric = (a === '"' && b === '"') || (a === "'" && b === "'");
1660
- return symmetric ? s.slice(1, -1) : s;
1661
- };
1662
- // Normalize whole input once for robust matching
1663
- const normalized = stripOne(input.trim());
1664
- // First try a lightweight regex on the normalized string
1665
- const m = /^\s*node\s+(--eval|-e)\s+([\s\S]+)$/i.exec(normalized);
1666
- if (m && typeof m[1] === 'string' && typeof m[2] === 'string') {
1667
- const evalFlag = m[1];
1668
- let codeArg = m[2].trim();
1669
- codeArg = stripOne(codeArg);
1670
- const flag = evalFlag.startsWith('--') ? '--eval' : '-e';
1671
- commandArg = ['node', flag, codeArg];
1672
- }
1673
- else {
1674
- // Fallback: tokenize and detect node -e/--eval form
1675
- const parts = tokenize(input);
1676
- if (parts.length >= 3) {
1677
- // Narrow under noUncheckedIndexedAccess
1678
- const p0 = parts[0];
1679
- const p1 = parts[1];
1680
- if (p0?.toLowerCase() === 'node' &&
1681
- (p1 === '-e' || p1 === '--eval')) {
1682
- commandArg = parts;
1683
- }
1684
- }
1685
- }
1686
- }
1687
- exitCode = await runCommand(commandArg, shellSetting, {
1688
- env: buildSpawnEnv(process.env, {
1689
- ...dotenv,
1690
- getDotenvCliOptions: JSON.stringify(envBag),
1691
- }),
1692
- stdio: capture ? 'pipe' : 'inherit',
1693
- });
1694
- dbg('run:done', { exitCode });
1695
- }
1696
- catch (err) {
1697
- const code = typeof err.exitCode === 'number'
1698
- ? err.exitCode
1699
- : 1;
1700
- dbg('run:error', { exitCode: code, error: String(err) });
1701
- if (!underTests) {
1702
- dbg('process.exit (error path)', { exitCode: code });
1703
- process.exit(code);
1704
- }
1705
- else {
1706
- dbg('process.exit suppressed for tests (error path)', {
1707
- exitCode: code,
1708
- });
1709
- }
1710
- return;
1711
- }
1712
- if (!Number.isNaN(exitCode)) {
1713
- dbg('process.exit', { exitCode });
1714
- process.exit(exitCode);
1715
- }
1716
- // Fallback: Some environments may not surface a numeric exitCode even on success.
1717
- // Always terminate alias-only invocations outside tests to avoid hanging the process,
1718
- // regardless of capture/GETDOTENV_STDIO. Under tests, suppress to keep the runner alive.
1719
- if (!underTests) {
1720
- dbg('process.exit (fallback: non-numeric exitCode)', { exitCode: 0 });
1721
- process.exit(0);
1722
- }
1723
- else {
1724
- dbg('process.exit (fallback suppressed for tests: non-numeric exitCode)', { exitCode: 0 });
1725
- }
1726
- // Optional last-resort guard: force an exit on the next tick when enabled.
1727
- // Intended for diagnosing environments where the process appears to linger
1728
- // despite reaching the success/error handlers above. Disabled under tests.
1729
- if (forceExit) {
1730
- try {
1731
- if (process.env.GETDOTENV_DEBUG_VERBOSE) {
1732
- const getHandles = process._getActiveHandles;
1733
- const handles = typeof getHandles === 'function' ? getHandles() : [];
1734
- dbg('active handles before forced exit', {
1735
- count: Array.isArray(handles) ? handles.length : undefined,
1736
- });
1737
- }
1738
- }
1739
- catch {
1740
- // best-effort only
1741
- }
1742
- const code = Number.isNaN(exitCode) ? 0 : exitCode;
1743
- dbg('process.exit (forced)', { exitCode: code });
1744
- setImmediate(() => process.exit(code));
1745
- }
1881
+ const aliasState = { handled: false };
1882
+ const maybeRun = async (thisCommand) => {
1883
+ await maybeRunAlias(cli, thisCommand, aliasKey, aliasState);
1746
1884
  };
1747
- // Execute alias-only invocations whether the root handles the action // itself (preAction) or Commander routes to a default subcommand (preSubcommand).
1748
1885
  cli.hook('preAction', async (thisCommand, _actionCommand) => {
1749
- await maybeRunAlias(thisCommand);
1886
+ await maybeRun(thisCommand);
1750
1887
  });
1751
1888
  cli.hook('preSubcommand', async (thisCommand) => {
1752
- await maybeRunAlias(thisCommand);
1889
+ await maybeRun(thisCommand);
1753
1890
  });
1754
1891
  };
1755
1892
 
@@ -1771,10 +1908,10 @@ const cmdPlugin = (options = {}) => definePlugin({
1771
1908
  return name.replace(/-([a-z])/g, (_m, c) => c.toUpperCase());
1772
1909
  };
1773
1910
  const aliasKey = aliasSpec ? deriveKey(aliasSpec.flags) : undefined;
1774
- const cmd = new Command()
1775
- .name('cmd')
1911
+ // Create as a GetDotenvCli child so helpInformation includes a trailing blank line.
1912
+ const cmd = cli
1913
+ .createCommand('cmd')
1776
1914
  .description('Execute command according to the --shell option, conflicts with --command option (default subcommand)')
1777
- .configureHelp({ showGlobalOptions: true })
1778
1915
  .enablePositionalOptions()
1779
1916
  .passThroughOptions()
1780
1917
  .argument('[command...]')
@@ -1845,8 +1982,7 @@ const cmdPlugin = (options = {}) => definePlugin({
1845
1982
  : 'unset';
1846
1983
  // Apply presentation-time redaction (if enabled)
1847
1984
  const redFlag = merged.redact;
1848
- const redPatterns = merged
1849
- .redactPatterns;
1985
+ const redPatterns = merged.redactPatterns;
1850
1986
  const redOpts = {};
1851
1987
  if (redFlag)
1852
1988
  redOpts.redact = true;
@@ -1963,10 +2099,9 @@ const demoPlugin = () => definePlugin({
1963
2099
  // Build a minimal node -e payload via argv array (avoid quoting issues).
1964
2100
  const code = `console.log(process.env.${key} ?? "")`;
1965
2101
  const ctx = cli.getCtx();
1966
- const dotenv = (ctx?.dotenv ?? {});
1967
2102
  // Inherit stdio for an interactive demo. Use --capture for CI.
1968
2103
  await runCommand(['node', '-e', code], false, {
1969
- env: { ...process.env, ...dotenv },
2104
+ env: buildSpawnEnv(process.env, ctx?.dotenv),
1970
2105
  stdio: 'inherit',
1971
2106
  });
1972
2107
  });
@@ -2001,22 +2136,24 @@ const demoPlugin = () => definePlugin({
2001
2136
  const shell = resolveShell(bag?.scripts, input, bag?.shell);
2002
2137
  // Compose child env (parent + ctx.dotenv). This mirrors cmd/batch behavior.
2003
2138
  const ctx = cli.getCtx();
2004
- const dotenv = (ctx?.dotenv ?? {});
2005
2139
  await runCommand(resolved, shell, {
2006
- env: { ...process.env, ...dotenv },
2140
+ env: buildSpawnEnv(process.env, ctx?.dotenv),
2007
2141
  stdio: 'inherit',
2008
2142
  });
2009
2143
  });
2010
2144
  },
2011
2145
  /**
2012
2146
  * Optional: afterResolve can initialize per-plugin state using ctx.dotenv.
2013
- * For the demo we just log once to hint where such logic would live.
2147
+ * For the demo we emit a single breadcrumb only when GETDOTENV_DEBUG is set,
2148
+ * keeping default runs (tests/CI/smoke) quiet.
2014
2149
  */
2015
2150
  afterResolve(_cli, ctx) {
2016
- const keys = Object.keys(ctx.dotenv);
2017
- if (keys.length > 0) {
2018
- // Keep noise low; a single-line breadcrumb is sufficient for the demo.
2019
- console.error('[demo] afterResolve: dotenv keys loaded:', keys.length);
2151
+ if (process.env.GETDOTENV_DEBUG) {
2152
+ const keys = Object.keys(ctx.dotenv);
2153
+ if (keys.length > 0) {
2154
+ // Keep noise low; a single-line breadcrumb is sufficient for the demo.
2155
+ console.error('[demo] afterResolve: dotenv keys loaded:', keys.length);
2156
+ }
2020
2157
  }
2021
2158
  },
2022
2159
  });