@karmaniverous/get-dotenv 5.2.5 → 5.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cliHost.cjs CHANGED
@@ -3,15 +3,267 @@
3
3
  var commander = require('commander');
4
4
  var fs = require('fs-extra');
5
5
  var packageDirectory = require('package-directory');
6
- var url = require('url');
7
6
  var path = require('path');
8
- var zod = require('zod');
7
+ var url = require('url');
9
8
  var YAML = require('yaml');
9
+ var zod = require('zod');
10
10
  var nanoid = require('nanoid');
11
11
  var dotenv = require('dotenv');
12
12
  var crypto = require('crypto');
13
13
 
14
14
  var _documentCurrentScript = typeof document !== 'undefined' ? document.currentScript : null;
15
+ /**
16
+ * Dotenv expansion utilities.
17
+ *
18
+ * This module implements recursive expansion of environment-variable
19
+ * references in strings and records. It supports both whitespace and
20
+ * bracket syntaxes with optional defaults:
21
+ *
22
+ * - Whitespace: `$VAR[:default]`
23
+ * - Bracketed: `${VAR[:default]}`
24
+ *
25
+ * Escaped dollar signs (`\$`) are preserved.
26
+ * Unknown variables resolve to empty string unless a default is provided.
27
+ */
28
+ /**
29
+ * Like String.prototype.search but returns the last index.
30
+ * @internal
31
+ */
32
+ const searchLast = (str, rgx) => {
33
+ const matches = Array.from(str.matchAll(rgx));
34
+ return matches.length > 0 ? (matches.slice(-1)[0]?.index ?? -1) : -1;
35
+ };
36
+ const replaceMatch = (value, match, ref) => {
37
+ /**
38
+ * @internal
39
+ */
40
+ const group = match[0];
41
+ const key = match[1];
42
+ const defaultValue = match[2];
43
+ if (!key)
44
+ return value;
45
+ const replacement = value.replace(group, ref[key] ?? defaultValue ?? '');
46
+ return interpolate(replacement, ref);
47
+ };
48
+ const interpolate = (value = '', ref = {}) => {
49
+ /**
50
+ * @internal
51
+ */
52
+ // if value is falsy, return it as is
53
+ if (!value)
54
+ return value;
55
+ // get position of last unescaped dollar sign
56
+ const lastUnescapedDollarSignIndex = searchLast(value, /(?!(?<=\\))\$/g);
57
+ // return value if none found
58
+ if (lastUnescapedDollarSignIndex === -1)
59
+ return value;
60
+ // evaluate the value tail
61
+ const tail = value.slice(lastUnescapedDollarSignIndex);
62
+ // find whitespace pattern: $KEY:DEFAULT
63
+ const whitespacePattern = /^\$([\w]+)(?::([^\s]*))?/;
64
+ const whitespaceMatch = whitespacePattern.exec(tail);
65
+ if (whitespaceMatch != null)
66
+ return replaceMatch(value, whitespaceMatch, ref);
67
+ else {
68
+ // find bracket pattern: ${KEY:DEFAULT}
69
+ const bracketPattern = /^\${([\w]+)(?::([^}]*))?}/;
70
+ const bracketMatch = bracketPattern.exec(tail);
71
+ if (bracketMatch != null)
72
+ return replaceMatch(value, bracketMatch, ref);
73
+ }
74
+ return value;
75
+ };
76
+ /**
77
+ * Recursively expands environment variables in a string. Variables may be
78
+ * presented with optional default as `$VAR[:default]` or `${VAR[:default]}`.
79
+ * Unknown variables will expand to an empty string.
80
+ *
81
+ * @param value - The string to expand.
82
+ * @param ref - The reference object to use for variable expansion.
83
+ * @returns The expanded string.
84
+ *
85
+ * @example
86
+ * ```ts
87
+ * process.env.FOO = 'bar';
88
+ * dotenvExpand('Hello $FOO'); // "Hello bar"
89
+ * dotenvExpand('Hello $BAZ:world'); // "Hello world"
90
+ * ```
91
+ *
92
+ * @remarks
93
+ * The expansion is recursive. If a referenced variable itself contains
94
+ * references, those will also be expanded until a stable value is reached.
95
+ * Escaped references (e.g. `\$FOO`) are preserved as literals.
96
+ */
97
+ const dotenvExpand = (value, ref = process.env) => {
98
+ const result = interpolate(value, ref);
99
+ return result ? result.replace(/\\\$/g, '$') : undefined;
100
+ };
101
+ /**
102
+ * Recursively expands environment variables in the values of a JSON object.
103
+ * Variables may be presented with optional default as `$VAR[:default]` or
104
+ * `${VAR[:default]}`. Unknown variables will expand to an empty string.
105
+ *
106
+ * @param values - The values object to expand.
107
+ * @param options - Expansion options.
108
+ * @returns The value object with expanded string values.
109
+ *
110
+ * @example
111
+ * ```ts
112
+ * process.env.FOO = 'bar';
113
+ * dotenvExpandAll({ A: '$FOO', B: 'x${FOO}y' });
114
+ * // => { A: "bar", B: "xbary" }
115
+ * ```
116
+ *
117
+ * @remarks
118
+ * Options:
119
+ * - ref: The reference object to use for expansion (defaults to process.env).
120
+ * - progressive: Whether to progressively add expanded values to the set of
121
+ * reference keys.
122
+ *
123
+ * When `progressive` is true, each expanded key becomes available for
124
+ * subsequent expansions in the same object (left-to-right by object key order).
125
+ */
126
+ const dotenvExpandAll = (values = {}, options = {}) => Object.keys(values).reduce((acc, key) => {
127
+ const { ref = process.env, progressive = false } = options;
128
+ acc[key] = dotenvExpand(values[key], {
129
+ ...ref,
130
+ ...(progressive ? acc : {}),
131
+ });
132
+ return acc;
133
+ }, {});
134
+ /**
135
+ * Recursively expands environment variables in a string using `process.env` as
136
+ * the expansion reference. Variables may be presented with optional default as
137
+ * `$VAR[:default]` or `${VAR[:default]}`. Unknown variables will expand to an
138
+ * empty string.
139
+ *
140
+ * @param value - The string to expand.
141
+ * @returns The expanded string.
142
+ *
143
+ * @example
144
+ * ```ts
145
+ * process.env.FOO = 'bar';
146
+ * dotenvExpandFromProcessEnv('Hello $FOO'); // "Hello bar"
147
+ * ```
148
+ */
149
+ const dotenvExpandFromProcessEnv = (value) => dotenvExpand(value, process.env);
150
+
151
+ /**
152
+ * Attach legacy root flags to a Commander program.
153
+ * Uses provided defaults to render help labels without coupling to generators.
154
+ */
155
+ const attachRootOptions = (program, defaults, opts) => {
156
+ // Install temporary wrappers to tag all options added here as "base".
157
+ const GROUP = 'base';
158
+ const tagLatest = (cmd, group) => {
159
+ const optsArr = cmd.options;
160
+ if (Array.isArray(optsArr) && optsArr.length > 0) {
161
+ const last = optsArr[optsArr.length - 1];
162
+ last.__group = group;
163
+ }
164
+ };
165
+ const originalAddOption = program.addOption.bind(program);
166
+ const originalOption = program.option.bind(program);
167
+ program.addOption = function patchedAdd(opt) {
168
+ // Tag before adding, in case consumers inspect the Option directly.
169
+ opt.__group = GROUP;
170
+ const ret = originalAddOption(opt);
171
+ return ret;
172
+ };
173
+ program.option = function patchedOption(...args) {
174
+ const ret = originalOption(...args);
175
+ tagLatest(this, GROUP);
176
+ return ret;
177
+ };
178
+ const { defaultEnv, dotenvToken, dynamicPath, env, excludeDynamic, excludeEnv, excludeGlobal, excludePrivate, excludePublic, loadProcess, log, outputPath, paths, pathsDelimiter, pathsDelimiterPattern, privateToken, scripts, shell, varsAssignor, varsAssignorPattern, varsDelimiter, varsDelimiterPattern, } = defaults ?? {};
179
+ const va = typeof defaults?.varsAssignor === 'string' ? defaults.varsAssignor : '=';
180
+ const vd = typeof defaults?.varsDelimiter === 'string' ? defaults.varsDelimiter : ' ';
181
+ // Build initial chain.
182
+ let p = program
183
+ .enablePositionalOptions()
184
+ .passThroughOptions()
185
+ .option('-e, --env <string>', `target environment (dotenv-expanded)`, dotenvExpandFromProcessEnv, env);
186
+ p = p.option('-v, --vars <string>', `extra variables expressed as delimited key-value pairs (dotenv-expanded): ${[
187
+ ['KEY1', 'VAL1'],
188
+ ['KEY2', 'VAL2'],
189
+ ]
190
+ .map((v) => v.join(va))
191
+ .join(vd)}`, dotenvExpandFromProcessEnv);
192
+ // Optional legacy root command flag (kept for generated CLI compatibility).
193
+ // Default is OFF; the generator opts in explicitly.
194
+ if (opts?.includeCommandOption === true) {
195
+ p = p.option('-c, --command <string>', 'command executed according to the --shell option, conflicts with cmd subcommand (dotenv-expanded)', dotenvExpandFromProcessEnv);
196
+ }
197
+ p = p
198
+ .option('-o, --output-path <string>', 'consolidated output file (dotenv-expanded)', dotenvExpandFromProcessEnv, outputPath)
199
+ .addOption(new commander.Option('-s, --shell [string]', (() => {
200
+ let defaultLabel = '';
201
+ if (shell !== undefined) {
202
+ if (typeof shell === 'boolean') {
203
+ defaultLabel = ' (default OS shell)';
204
+ }
205
+ else if (typeof shell === 'string') {
206
+ // Safe string interpolation
207
+ defaultLabel = ` (default ${shell})`;
208
+ }
209
+ }
210
+ return `command execution shell, no argument for default OS shell or provide shell string${defaultLabel}`;
211
+ })()).conflicts('shellOff'))
212
+ .addOption(new commander.Option('-S, --shell-off', `command execution shell OFF${!shell ? ' (default)' : ''}`).conflicts('shell'))
213
+ .addOption(new commander.Option('-p, --load-process', `load variables to process.env ON${loadProcess ? ' (default)' : ''}`).conflicts('loadProcessOff'))
214
+ .addOption(new commander.Option('-P, --load-process-off', `load variables to process.env OFF${!loadProcess ? ' (default)' : ''}`).conflicts('loadProcess'))
215
+ .addOption(new commander.Option('-a, --exclude-all', `exclude all dotenv variables from loading ON${excludeDynamic &&
216
+ ((excludeEnv && excludeGlobal) || (excludePrivate && excludePublic))
217
+ ? ' (default)'
218
+ : ''}`).conflicts('excludeAllOff'))
219
+ .addOption(new commander.Option('-A, --exclude-all-off', `exclude all dotenv variables from loading OFF (default)`).conflicts('excludeAll'))
220
+ .addOption(new commander.Option('-z, --exclude-dynamic', `exclude dynamic dotenv variables from loading ON${excludeDynamic ? ' (default)' : ''}`).conflicts('excludeDynamicOff'))
221
+ .addOption(new commander.Option('-Z, --exclude-dynamic-off', `exclude dynamic dotenv variables from loading OFF${!excludeDynamic ? ' (default)' : ''}`).conflicts('excludeDynamic'))
222
+ .addOption(new commander.Option('-n, --exclude-env', `exclude environment-specific dotenv variables from loading${excludeEnv ? ' (default)' : ''}`).conflicts('excludeEnvOff'))
223
+ .addOption(new commander.Option('-N, --exclude-env-off', `exclude environment-specific dotenv variables from loading OFF${!excludeEnv ? ' (default)' : ''}`).conflicts('excludeEnv'))
224
+ .addOption(new commander.Option('-g, --exclude-global', `exclude global dotenv variables from loading ON${excludeGlobal ? ' (default)' : ''}`).conflicts('excludeGlobalOff'))
225
+ .addOption(new commander.Option('-G, --exclude-global-off', `exclude global dotenv variables from loading OFF${!excludeGlobal ? ' (default)' : ''}`).conflicts('excludeGlobal'))
226
+ .addOption(new commander.Option('-r, --exclude-private', `exclude private dotenv variables from loading ON${excludePrivate ? ' (default)' : ''}`).conflicts('excludePrivateOff'))
227
+ .addOption(new commander.Option('-R, --exclude-private-off', `exclude private dotenv variables from loading OFF${!excludePrivate ? ' (default)' : ''}`).conflicts('excludePrivate'))
228
+ .addOption(new commander.Option('-u, --exclude-public', `exclude public dotenv variables from loading ON${excludePublic ? ' (default)' : ''}`).conflicts('excludePublicOff'))
229
+ .addOption(new commander.Option('-U, --exclude-public-off', `exclude public dotenv variables from loading OFF${!excludePublic ? ' (default)' : ''}`).conflicts('excludePublic'))
230
+ .addOption(new commander.Option('-l, --log', `console log loaded variables ON${log ? ' (default)' : ''}`).conflicts('logOff'))
231
+ .addOption(new commander.Option('-L, --log-off', `console log loaded variables OFF${!log ? ' (default)' : ''}`).conflicts('log'))
232
+ .option('--capture', 'capture child process stdio for commands (tests/CI)')
233
+ .option('--redact', 'mask secret-like values in logs/trace (presentation-only)')
234
+ .option('--default-env <string>', 'default target environment', dotenvExpandFromProcessEnv, defaultEnv)
235
+ .option('--dotenv-token <string>', 'dotenv-expanded token indicating a dotenv file', dotenvExpandFromProcessEnv, dotenvToken)
236
+ .option('--dynamic-path <string>', 'dynamic variables path (.js or .ts; .ts is auto-compiled when esbuild is available, otherwise precompile)', dotenvExpandFromProcessEnv, dynamicPath)
237
+ .option('--paths <string>', 'dotenv-expanded delimited list of paths to dotenv directory', dotenvExpandFromProcessEnv, paths)
238
+ .option('--paths-delimiter <string>', 'paths delimiter string', pathsDelimiter)
239
+ .option('--paths-delimiter-pattern <string>', 'paths delimiter regex pattern', pathsDelimiterPattern)
240
+ .option('--private-token <string>', 'dotenv-expanded token indicating private variables', dotenvExpandFromProcessEnv, privateToken)
241
+ .option('--vars-delimiter <string>', 'vars delimiter string', varsDelimiter)
242
+ .option('--vars-delimiter-pattern <string>', 'vars delimiter regex pattern', varsDelimiterPattern)
243
+ .option('--vars-assignor <string>', 'vars assignment operator string', varsAssignor)
244
+ .option('--vars-assignor-pattern <string>', 'vars assignment operator regex pattern', varsAssignorPattern)
245
+ // Hidden scripts pipe-through (stringified)
246
+ .addOption(new commander.Option('--scripts <string>')
247
+ .default(JSON.stringify(scripts))
248
+ .hideHelp());
249
+ // Diagnostics: opt-in tracing; optional variadic keys after the flag.
250
+ p = p.option('--trace [keys...]', 'emit diagnostics for child env composition (optional keys)');
251
+ // Validation: strict mode fails on env validation issues (warn by default).
252
+ p = p.option('--strict', 'fail on env validation errors (schema/requiredKeys)');
253
+ // Entropy diagnostics (presentation-only)
254
+ p = p
255
+ .addOption(new commander.Option('--entropy-warn', 'enable entropy warnings (default on)').conflicts('entropyWarnOff'))
256
+ .addOption(new commander.Option('--entropy-warn-off', 'disable entropy warnings').conflicts('entropyWarn'))
257
+ .option('--entropy-threshold <number>', 'entropy bits/char threshold (default 3.8)')
258
+ .option('--entropy-min-length <number>', 'min length to examine for entropy (default 16)')
259
+ .option('--entropy-whitelist <pattern...>', 'suppress entropy warnings when key matches any regex pattern')
260
+ .option('--redact-pattern <pattern...>', 'additional key-match regex patterns to trigger redaction');
261
+ // Restore original methods to avoid tagging future additions outside base.
262
+ program.addOption = originalAddOption;
263
+ program.option = originalOption;
264
+ return p;
265
+ };
266
+
15
267
  // Base root CLI defaults (shared; kept untyped here to avoid cross-layer deps).
16
268
  const baseRootOptionDefaults = {
17
269
  dotenvToken: '.env',
@@ -39,8 +291,6 @@ const baseRootOptionDefaults = {
39
291
  // (debug/log/exclude* resolved via flag utils)
40
292
  };
41
293
 
42
- const baseGetDotenvCliOptions = baseRootOptionDefaults;
43
-
44
294
  /** @internal */
45
295
  const isPlainObject$1 = (value) => value !== null &&
46
296
  typeof value === 'object' &&
@@ -83,134 +333,130 @@ const defaultsDeep = (...layers) => {
83
333
  return result;
84
334
  };
85
335
 
86
- // src/GetDotenvOptions.ts
87
- const getDotenvOptionsFilename = 'getdotenv.config.json';
88
336
  /**
89
- * Converts programmatic CLI options to `getDotenv` options. *
90
- * @param cliOptions - CLI options. Defaults to `{}`.
337
+ * Resolve a tri-state optional boolean flag under exactOptionalPropertyTypes.
338
+ * - If the user explicitly enabled the flag, return true.
339
+ * - If the user explicitly disabled (the "...-off" variant), return undefined (unset).
340
+ * - Otherwise, adopt the default (true → set; false/undefined → unset).
91
341
  *
92
- * @returns `getDotenv` options.
342
+ * @param exclude - The "on" flag value as parsed by Commander.
343
+ * @param excludeOff - The "off" toggle (present when specified) as parsed by Commander.
344
+ * @param defaultValue - The generator default to adopt when no explicit toggle is present.
345
+ * @returns boolean | undefined — use `undefined` to indicate "unset" (do not emit).
346
+ *
347
+ * @example
348
+ * ```ts
349
+ * resolveExclusion(undefined, undefined, true); // => true
350
+ * ```
93
351
  */
94
- const getDotenvCliOptions2Options = ({ paths, pathsDelimiter, pathsDelimiterPattern, vars, varsAssignor, varsAssignorPattern, varsDelimiter, varsDelimiterPattern, ...rest }) => {
95
- /**
96
- * Convert CLI-facing string options into {@link GetDotenvOptions}.
97
- *
98
- * - 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`
99
- * pairs (configurable delimiters) into a {@link ProcessEnv}.
100
- * - Drops CLI-only keys that have no programmatic equivalent.
101
- *
102
- * @remarks
103
- * Follows exact-optional semantics by not emitting undefined-valued entries.
104
- */
105
- // Drop CLI-only keys (debug/scripts) without relying on Record casts.
106
- // Create a shallow copy then delete optional CLI-only keys if present.
107
- const restObj = { ...rest };
108
- delete restObj.debug;
109
- delete restObj.scripts;
110
- const splitBy = (value, delim, pattern) => (value ? value.split(pattern ? RegExp(pattern) : (delim ?? ' ')) : []);
111
- // Tolerate vars as either a CLI string ("A=1 B=2") or an object map.
112
- let parsedVars;
113
- if (typeof vars === 'string') {
114
- const kvPairs = splitBy(vars, varsDelimiter, varsDelimiterPattern).map((v) => v.split(varsAssignorPattern
115
- ? RegExp(varsAssignorPattern)
116
- : (varsAssignor ?? '=')));
117
- parsedVars = Object.fromEntries(kvPairs);
118
- }
119
- else if (vars && typeof vars === 'object' && !Array.isArray(vars)) {
120
- // Keep only string or undefined values to match ProcessEnv.
121
- const entries = Object.entries(vars).filter(([k, v]) => typeof k === 'string' && (typeof v === 'string' || v === undefined));
122
- parsedVars = Object.fromEntries(entries);
123
- }
124
- // Drop undefined-valued entries at the converter stage to match ProcessEnv
125
- // expectations and the compat test assertions.
126
- if (parsedVars) {
127
- parsedVars = Object.fromEntries(Object.entries(parsedVars).filter(([, v]) => v !== undefined));
128
- }
129
- // Tolerate paths as either a delimited string or string[]
130
- // Use a locally cast union type to avoid lint warnings about always-falsy conditions
131
- // under the RootOptionsShape (which declares paths as string | undefined).
132
- const pathsAny = paths;
133
- const pathsOut = Array.isArray(pathsAny)
134
- ? pathsAny.filter((p) => typeof p === 'string')
135
- : splitBy(pathsAny, pathsDelimiter, pathsDelimiterPattern);
136
- // Preserve exactOptionalPropertyTypes: only include keys when defined.
137
- return {
138
- ...restObj,
139
- ...(pathsOut.length > 0 ? { paths: pathsOut } : {}),
140
- ...(parsedVars !== undefined ? { vars: parsedVars } : {}),
141
- };
142
- };
143
- const resolveGetDotenvOptions = async (customOptions) => {
144
- /**
145
- * Resolve {@link GetDotenvOptions} by layering defaults in ascending precedence:
146
- *
147
- * 1. Base defaults derived from the CLI generator defaults
148
- * ({@link baseGetDotenvCliOptions}).
149
- * 2. Local project overrides from a `getdotenv.config.json` in the nearest
150
- * package root (if present).
151
- * 3. The provided {@link customOptions}.
152
- *
153
- * The result preserves explicit empty values and drops only `undefined`.
154
- *
155
- * @returns Fully-resolved {@link GetDotenvOptions}.
156
- *
157
- * @example
158
- * ```ts
159
- * const options = await resolveGetDotenvOptions({ env: 'dev' });
160
- * ```
161
- */
162
- const localPkgDir = await packageDirectory.packageDirectory();
163
- const localOptionsPath = localPkgDir
164
- ? path.join(localPkgDir, getDotenvOptionsFilename)
165
- : undefined;
166
- const localOptions = (localOptionsPath && (await fs.exists(localOptionsPath))
167
- ? JSON.parse((await fs.readFile(localOptionsPath)).toString())
168
- : {});
169
- // Merge order: base < local < custom (custom has highest precedence)
170
- const mergedCli = defaultsDeep(baseGetDotenvCliOptions, localOptions);
171
- const defaultsFromCli = getDotenvCliOptions2Options(mergedCli);
172
- const result = defaultsDeep(defaultsFromCli, customOptions);
173
- return {
174
- ...result, // Keep explicit empty strings/zeros; drop only undefined
175
- vars: Object.fromEntries(Object.entries(result.vars ?? {}).filter(([, v]) => v !== undefined)),
176
- };
352
+ const resolveExclusion = (exclude, excludeOff, defaultValue) => exclude ? true : excludeOff ? undefined : defaultValue ? true : undefined;
353
+ /**
354
+ * Resolve an optional flag with "--exclude-all" overrides.
355
+ * If excludeAll is set and the individual "...-off" is not, force true.
356
+ * If excludeAllOff is set and the individual flag is not explicitly set, unset.
357
+ * Otherwise, adopt the default (true set; false/undefined unset).
358
+ *
359
+ * @param exclude - Individual include/exclude flag.
360
+ * @param excludeOff - Individual "...-off" flag.
361
+ * @param defaultValue - Default for the individual flag.
362
+ * @param excludeAll - Global "exclude-all" flag.
363
+ * @param excludeAllOff - Global "exclude-all-off" flag.
364
+ *
365
+ * @example
366
+ * resolveExclusionAll(undefined, undefined, false, true, undefined) =\> true
367
+ */
368
+ const resolveExclusionAll = (exclude, excludeOff, defaultValue, excludeAll, excludeAllOff) =>
369
+ // Order of precedence:
370
+ // 1) Individual explicit "on" wins outright.
371
+ // 2) Individual explicit "off" wins over any global.
372
+ // 3) Global exclude-all forces true when not explicitly turned off.
373
+ // 4) Global exclude-all-off unsets when the individual wasn't explicitly enabled.
374
+ // 5) Fall back to the default (true => set; false/undefined => unset).
375
+ (() => {
376
+ // Individual "on"
377
+ if (exclude === true)
378
+ return true;
379
+ // Individual "off"
380
+ if (excludeOff === true)
381
+ return undefined;
382
+ // Global "exclude-all" ON (unless explicitly turned off)
383
+ if (excludeAll === true)
384
+ return true;
385
+ // Global "exclude-all-off" (unless explicitly enabled)
386
+ if (excludeAllOff === true)
387
+ return undefined;
388
+ // Default
389
+ return defaultValue ? true : undefined;
390
+ })();
391
+ /**
392
+ * exactOptionalPropertyTypes-safe setter for optional boolean flags:
393
+ * delete when undefined; assign when defined — without requiring an index signature on T.
394
+ *
395
+ * @typeParam T - Target object type.
396
+ * @param obj - The object to write to.
397
+ * @param key - The optional boolean property key of {@link T}.
398
+ * @param value - The value to set or `undefined` to unset.
399
+ *
400
+ * @remarks
401
+ * Writes through a local `Record<string, unknown>` view to avoid requiring an index signature on {@link T}.
402
+ */
403
+ const setOptionalFlag = (obj, key, value) => {
404
+ const target = obj;
405
+ const k = key;
406
+ // eslint-disable-next-line @typescript-eslint/no-dynamic-delete
407
+ if (value === undefined)
408
+ delete target[k];
409
+ else
410
+ target[k] = value;
177
411
  };
178
412
 
179
413
  /**
180
- * Zod schemas for programmatic GetDotenv options.
181
- *
182
- * NOTE: These schemas are introduced without wiring to avoid behavior changes.
183
- * Legacy paths continue to use existing types/logic. The new plugin host will
184
- * use these schemas in strict mode; legacy paths will adopt them in warn mode
185
- * later per the staged plan.
414
+ * Merge and normalize raw Commander options (current + parent + defaults)
415
+ * into a GetDotenvCliOptions-like object. Types are intentionally wide to
416
+ * avoid cross-layer coupling; callers may cast as needed.
186
417
  */
187
- // Minimal process env representation: string values or undefined to indicate "unset".
188
- const processEnvSchema = zod.z.record(zod.z.string(), zod.z.string().optional());
189
- // RAW: all fields optional — undefined means "inherit" from lower layers.
190
- const getDotenvOptionsSchemaRaw = zod.z.object({
191
- defaultEnv: zod.z.string().optional(),
192
- dotenvToken: zod.z.string().optional(),
193
- dynamicPath: zod.z.string().optional(),
194
- // Dynamic map is intentionally wide for now; refine once sources are normalized.
195
- dynamic: zod.z.record(zod.z.string(), zod.z.unknown()).optional(),
196
- env: zod.z.string().optional(),
197
- excludeDynamic: zod.z.boolean().optional(),
198
- excludeEnv: zod.z.boolean().optional(),
199
- excludeGlobal: zod.z.boolean().optional(),
200
- excludePrivate: zod.z.boolean().optional(),
201
- excludePublic: zod.z.boolean().optional(),
202
- loadProcess: zod.z.boolean().optional(),
203
- log: zod.z.boolean().optional(),
204
- outputPath: zod.z.string().optional(),
205
- paths: zod.z.array(zod.z.string()).optional(),
206
- privateToken: zod.z.string().optional(),
207
- vars: processEnvSchema.optional(),
208
- // Host-only feature flag: guarded integration of config loader/overlay
209
- useConfigLoader: zod.z.boolean().optional(),
210
- });
211
- // RESOLVED: service-boundary contract (post-inheritance).
212
- // For Step A, keep identical to RAW (no behavior change). Later stages will// materialize required defaults and narrow shapes as resolution is wired.
213
- const getDotenvOptionsSchemaResolved = getDotenvOptionsSchemaRaw;
418
+ const resolveCliOptions = (rawCliOptions, defaults, parentJson) => {
419
+ const parent = typeof parentJson === 'string' && parentJson.length > 0
420
+ ? JSON.parse(parentJson)
421
+ : undefined;
422
+ const { command, debugOff, excludeAll, excludeAllOff, excludeDynamicOff, excludeEnvOff, excludeGlobalOff, excludePrivateOff, excludePublicOff, loadProcessOff, logOff, entropyWarn, entropyWarnOff, scripts, shellOff, ...rest } = rawCliOptions;
423
+ const current = { ...rest };
424
+ if (typeof scripts === 'string') {
425
+ try {
426
+ current.scripts = JSON.parse(scripts);
427
+ }
428
+ catch {
429
+ // ignore parse errors; leave scripts undefined
430
+ }
431
+ }
432
+ const merged = defaultsDeep({}, defaults, parent ?? {}, current);
433
+ const d = defaults;
434
+ setOptionalFlag(merged, 'debug', resolveExclusion(merged.debug, debugOff, d.debug));
435
+ setOptionalFlag(merged, 'excludeDynamic', resolveExclusionAll(merged.excludeDynamic, excludeDynamicOff, d.excludeDynamic, excludeAll, excludeAllOff));
436
+ setOptionalFlag(merged, 'excludeEnv', resolveExclusionAll(merged.excludeEnv, excludeEnvOff, d.excludeEnv, excludeAll, excludeAllOff));
437
+ setOptionalFlag(merged, 'excludeGlobal', resolveExclusionAll(merged.excludeGlobal, excludeGlobalOff, d.excludeGlobal, excludeAll, excludeAllOff));
438
+ setOptionalFlag(merged, 'excludePrivate', resolveExclusionAll(merged.excludePrivate, excludePrivateOff, d.excludePrivate, excludeAll, excludeAllOff));
439
+ setOptionalFlag(merged, 'excludePublic', resolveExclusionAll(merged.excludePublic, excludePublicOff, d.excludePublic, excludeAll, excludeAllOff));
440
+ setOptionalFlag(merged, 'log', resolveExclusion(merged.log, logOff, d.log));
441
+ setOptionalFlag(merged, 'loadProcess', resolveExclusion(merged.loadProcess, loadProcessOff, d.loadProcess));
442
+ // warnEntropy (tri-state)
443
+ setOptionalFlag(merged, 'warnEntropy', resolveExclusion(merged.warnEntropy, entropyWarnOff, d.warnEntropy));
444
+ // Normalize shell for predictability: explicit default shell per OS.
445
+ const defaultShell = process.platform === 'win32' ? 'powershell.exe' : '/bin/bash';
446
+ let resolvedShell = merged.shell;
447
+ if (shellOff)
448
+ resolvedShell = false;
449
+ else if (resolvedShell === true || resolvedShell === undefined) {
450
+ resolvedShell = defaultShell;
451
+ }
452
+ else if (typeof resolvedShell !== 'string' &&
453
+ typeof defaults.shell === 'string') {
454
+ resolvedShell = defaults.shell;
455
+ }
456
+ merged.shell = resolvedShell;
457
+ const cmd = typeof command === 'string' ? command : undefined;
458
+ return cmd !== undefined ? { merged, command: cmd } : { merged };
459
+ };
214
460
 
215
461
  /**
216
462
  * Zod schemas for configuration files discovered by the new loader.
@@ -450,160 +696,211 @@ const resolveGetDotenvConfigSources = async (importMetaUrl) => {
450
696
  };
451
697
 
452
698
  /**
453
- * Dotenv expansion utilities.
454
- *
455
- * This module implements recursive expansion of environment-variable
456
- * references in strings and records. It supports both whitespace and
457
- * bracket syntaxes with optional defaults:
458
- *
459
- * - Whitespace: `$VAR[:default]`
460
- * - Bracketed: `${VAR[:default]}`
699
+ * Validate a composed env against config-provided validation surfaces.
700
+ * Precedence for validation definitions:
701
+ * project.local -\> project.public -\> packaged
461
702
  *
462
- * Escaped dollar signs (`\$`) are preserved.
463
- * Unknown variables resolve to empty string unless a default is provided.
464
- */
465
- /**
466
- * Like String.prototype.search but returns the last index.
467
- * @internal
703
+ * Behavior:
704
+ * - If a JS/TS `schema` is present, use schema.safeParse(finalEnv).
705
+ * - Else if `requiredKeys` is present, check presence (value !== undefined).
706
+ * - Returns a flat list of issue strings; caller decides warn vs fail.
468
707
  */
469
- const searchLast = (str, rgx) => {
470
- const matches = Array.from(str.matchAll(rgx));
471
- return matches.length > 0 ? (matches.slice(-1)[0]?.index ?? -1) : -1;
708
+ const validateEnvAgainstSources = (finalEnv, sources) => {
709
+ const pick = (getter) => {
710
+ const pl = sources.project?.local;
711
+ const pp = sources.project?.public;
712
+ const pk = sources.packaged;
713
+ return ((pl && getter(pl)) ||
714
+ (pp && getter(pp)) ||
715
+ (pk && getter(pk)) ||
716
+ undefined);
717
+ };
718
+ const schema = pick((cfg) => cfg['schema']);
719
+ if (schema &&
720
+ typeof schema.safeParse === 'function') {
721
+ try {
722
+ const parsed = schema.safeParse(finalEnv);
723
+ if (!parsed.success) {
724
+ // Try to render zod-style issues when available.
725
+ const err = parsed.error;
726
+ const issues = Array.isArray(err.issues) && err.issues.length > 0
727
+ ? err.issues.map((i) => {
728
+ const path = Array.isArray(i.path) ? i.path.join('.') : '';
729
+ const msg = i.message ?? 'Invalid value';
730
+ return path ? `[schema] ${path}: ${msg}` : `[schema] ${msg}`;
731
+ })
732
+ : ['[schema] validation failed'];
733
+ return issues;
734
+ }
735
+ return [];
736
+ }
737
+ catch {
738
+ // If schema invocation fails, surface a single diagnostic.
739
+ return [
740
+ '[schema] validation failed (unable to execute schema.safeParse)',
741
+ ];
742
+ }
743
+ }
744
+ const requiredKeys = pick((cfg) => cfg['requiredKeys']);
745
+ if (Array.isArray(requiredKeys) && requiredKeys.length > 0) {
746
+ const missing = requiredKeys.filter((k) => finalEnv[k] === undefined);
747
+ if (missing.length > 0) {
748
+ return missing.map((k) => `[requiredKeys] missing: ${k}`);
749
+ }
750
+ }
751
+ return [];
472
752
  };
473
- const replaceMatch = (value, match, ref) => {
753
+
754
+ const baseGetDotenvCliOptions = baseRootOptionDefaults;
755
+
756
+ // src/GetDotenvOptions.ts
757
+ const getDotenvOptionsFilename = 'getdotenv.config.json';
758
+ /**
759
+ * Converts programmatic CLI options to `getDotenv` options. *
760
+ * @param cliOptions - CLI options. Defaults to `{}`.
761
+ *
762
+ * @returns `getDotenv` options.
763
+ */
764
+ const getDotenvCliOptions2Options = ({ paths, pathsDelimiter, pathsDelimiterPattern, vars, varsAssignor, varsAssignorPattern, varsDelimiter, varsDelimiterPattern, ...rest }) => {
474
765
  /**
475
- * @internal
766
+ * Convert CLI-facing string options into {@link GetDotenvOptions}.
767
+ *
768
+ * - 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`
769
+ * pairs (configurable delimiters) into a {@link ProcessEnv}.
770
+ * - Drops CLI-only keys that have no programmatic equivalent.
771
+ *
772
+ * @remarks
773
+ * Follows exact-optional semantics by not emitting undefined-valued entries.
476
774
  */
477
- const group = match[0];
478
- const key = match[1];
479
- const defaultValue = match[2];
480
- if (!key)
481
- return value;
482
- const replacement = value.replace(group, ref[key] ?? defaultValue ?? '');
483
- return interpolate(replacement, ref);
775
+ // Drop CLI-only keys (debug/scripts) without relying on Record casts.
776
+ // Create a shallow copy then delete optional CLI-only keys if present.
777
+ const restObj = { ...rest };
778
+ delete restObj.debug;
779
+ delete restObj.scripts;
780
+ const splitBy = (value, delim, pattern) => (value ? value.split(pattern ? RegExp(pattern) : (delim ?? ' ')) : []);
781
+ // Tolerate vars as either a CLI string ("A=1 B=2") or an object map.
782
+ let parsedVars;
783
+ if (typeof vars === 'string') {
784
+ const kvPairs = splitBy(vars, varsDelimiter, varsDelimiterPattern).map((v) => v.split(varsAssignorPattern
785
+ ? RegExp(varsAssignorPattern)
786
+ : (varsAssignor ?? '=')));
787
+ parsedVars = Object.fromEntries(kvPairs);
788
+ }
789
+ else if (vars && typeof vars === 'object' && !Array.isArray(vars)) {
790
+ // Keep only string or undefined values to match ProcessEnv.
791
+ const entries = Object.entries(vars).filter(([k, v]) => typeof k === 'string' && (typeof v === 'string' || v === undefined));
792
+ parsedVars = Object.fromEntries(entries);
793
+ }
794
+ // Drop undefined-valued entries at the converter stage to match ProcessEnv
795
+ // expectations and the compat test assertions.
796
+ if (parsedVars) {
797
+ parsedVars = Object.fromEntries(Object.entries(parsedVars).filter(([, v]) => v !== undefined));
798
+ }
799
+ // Tolerate paths as either a delimited string or string[]
800
+ // Use a locally cast union type to avoid lint warnings about always-falsy conditions
801
+ // under the RootOptionsShape (which declares paths as string | undefined).
802
+ const pathsAny = paths;
803
+ const pathsOut = Array.isArray(pathsAny)
804
+ ? pathsAny.filter((p) => typeof p === 'string')
805
+ : splitBy(pathsAny, pathsDelimiter, pathsDelimiterPattern);
806
+ // Preserve exactOptionalPropertyTypes: only include keys when defined.
807
+ return {
808
+ ...restObj,
809
+ ...(pathsOut.length > 0 ? { paths: pathsOut } : {}),
810
+ ...(parsedVars !== undefined ? { vars: parsedVars } : {}),
811
+ };
484
812
  };
485
- const interpolate = (value = '', ref = {}) => {
813
+ const resolveGetDotenvOptions = async (customOptions) => {
486
814
  /**
487
- * @internal
815
+ * Resolve {@link GetDotenvOptions} by layering defaults in ascending precedence:
816
+ *
817
+ * 1. Base defaults derived from the CLI generator defaults
818
+ * ({@link baseGetDotenvCliOptions}).
819
+ * 2. Local project overrides from a `getdotenv.config.json` in the nearest
820
+ * package root (if present).
821
+ * 3. The provided {@link customOptions}.
822
+ *
823
+ * The result preserves explicit empty values and drops only `undefined`.
824
+ *
825
+ * @returns Fully-resolved {@link GetDotenvOptions}.
826
+ *
827
+ * @example
828
+ * ```ts
829
+ * const options = await resolveGetDotenvOptions({ env: 'dev' });
830
+ * ```
488
831
  */
489
- // if value is falsy, return it as is
490
- if (!value)
491
- return value;
492
- // get position of last unescaped dollar sign
493
- const lastUnescapedDollarSignIndex = searchLast(value, /(?!(?<=\\))\$/g);
494
- // return value if none found
495
- if (lastUnescapedDollarSignIndex === -1)
496
- return value;
497
- // evaluate the value tail
498
- const tail = value.slice(lastUnescapedDollarSignIndex);
499
- // find whitespace pattern: $KEY:DEFAULT
500
- const whitespacePattern = /^\$([\w]+)(?::([^\s]*))?/;
501
- const whitespaceMatch = whitespacePattern.exec(tail);
502
- if (whitespaceMatch != null)
503
- return replaceMatch(value, whitespaceMatch, ref);
504
- else {
505
- // find bracket pattern: ${KEY:DEFAULT}
506
- const bracketPattern = /^\${([\w]+)(?::([^}]*))?}/;
507
- const bracketMatch = bracketPattern.exec(tail);
508
- if (bracketMatch != null)
509
- return replaceMatch(value, bracketMatch, ref);
510
- }
511
- return value;
832
+ const localPkgDir = await packageDirectory.packageDirectory();
833
+ const localOptionsPath = localPkgDir
834
+ ? path.join(localPkgDir, getDotenvOptionsFilename)
835
+ : undefined;
836
+ const localOptions = (localOptionsPath && (await fs.exists(localOptionsPath))
837
+ ? JSON.parse((await fs.readFile(localOptionsPath)).toString())
838
+ : {});
839
+ // Merge order: base < local < custom (custom has highest precedence)
840
+ const mergedCli = defaultsDeep(baseGetDotenvCliOptions, localOptions);
841
+ const defaultsFromCli = getDotenvCliOptions2Options(mergedCli);
842
+ const result = defaultsDeep(defaultsFromCli, customOptions);
843
+ return {
844
+ ...result, // Keep explicit empty strings/zeros; drop only undefined
845
+ vars: Object.fromEntries(Object.entries(result.vars ?? {}).filter(([, v]) => v !== undefined)),
846
+ };
512
847
  };
848
+
513
849
  /**
514
- * Recursively expands environment variables in a string. Variables may be
515
- * presented with optional default as `$VAR[:default]` or `${VAR[:default]}`.
516
- * Unknown variables will expand to an empty string.
517
- *
518
- * @param value - The string to expand.
519
- * @param ref - The reference object to use for variable expansion.
520
- * @returns The expanded string.
521
- *
522
- * @example
523
- * ```ts
524
- * process.env.FOO = 'bar';
525
- * dotenvExpand('Hello $FOO'); // "Hello bar"
526
- * dotenvExpand('Hello $BAZ:world'); // "Hello world"
527
- * ```
850
+ * Zod schemas for programmatic GetDotenv options.
528
851
  *
529
- * @remarks
530
- * The expansion is recursive. If a referenced variable itself contains
531
- * references, those will also be expanded until a stable value is reached.
532
- * Escaped references (e.g. `\$FOO`) are preserved as literals.
852
+ * NOTE: These schemas are introduced without wiring to avoid behavior changes.
853
+ * Legacy paths continue to use existing types/logic. The new plugin host will
854
+ * use these schemas in strict mode; legacy paths will adopt them in warn mode
855
+ * later per the staged plan.
533
856
  */
534
- const dotenvExpand = (value, ref = process.env) => {
535
- const result = interpolate(value, ref);
536
- return result ? result.replace(/\\\$/g, '$') : undefined;
857
+ // Minimal process env representation: string values or undefined to indicate "unset".
858
+ const processEnvSchema = zod.z.record(zod.z.string(), zod.z.string().optional());
859
+ // RAW: all fields optional undefined means "inherit" from lower layers.
860
+ const getDotenvOptionsSchemaRaw = zod.z.object({
861
+ defaultEnv: zod.z.string().optional(),
862
+ dotenvToken: zod.z.string().optional(),
863
+ dynamicPath: zod.z.string().optional(),
864
+ // Dynamic map is intentionally wide for now; refine once sources are normalized.
865
+ dynamic: zod.z.record(zod.z.string(), zod.z.unknown()).optional(),
866
+ env: zod.z.string().optional(),
867
+ excludeDynamic: zod.z.boolean().optional(),
868
+ excludeEnv: zod.z.boolean().optional(),
869
+ excludeGlobal: zod.z.boolean().optional(),
870
+ excludePrivate: zod.z.boolean().optional(),
871
+ excludePublic: zod.z.boolean().optional(),
872
+ loadProcess: zod.z.boolean().optional(),
873
+ log: zod.z.boolean().optional(),
874
+ outputPath: zod.z.string().optional(),
875
+ paths: zod.z.array(zod.z.string()).optional(),
876
+ privateToken: zod.z.string().optional(),
877
+ vars: processEnvSchema.optional(),
878
+ // Host-only feature flag: guarded integration of config loader/overlay
879
+ useConfigLoader: zod.z.boolean().optional(),
880
+ });
881
+ // RESOLVED: service-boundary contract (post-inheritance).
882
+ // For Step A, keep identical to RAW (no behavior change). Later stages will// materialize required defaults and narrow shapes as resolution is wired.
883
+ const getDotenvOptionsSchemaResolved = getDotenvOptionsSchemaRaw;
884
+
885
+ const applyKv = (current, kv) => {
886
+ if (!kv || Object.keys(kv).length === 0)
887
+ return current;
888
+ const expanded = dotenvExpandAll(kv, { ref: current, progressive: true });
889
+ return { ...current, ...expanded };
890
+ };
891
+ const applyConfigSlice = (current, cfg, env) => {
892
+ if (!cfg)
893
+ return current;
894
+ // kind axis: global then env (env overrides global)
895
+ const afterGlobal = applyKv(current, cfg.vars);
896
+ const envKv = env && cfg.envVars ? cfg.envVars[env] : undefined;
897
+ return applyKv(afterGlobal, envKv);
537
898
  };
538
899
  /**
539
- * Recursively expands environment variables in the values of a JSON object.
540
- * Variables may be presented with optional default as `$VAR[:default]` or
541
- * `${VAR[:default]}`. Unknown variables will expand to an empty string.
542
- *
543
- * @param values - The values object to expand.
544
- * @param options - Expansion options.
545
- * @returns The value object with expanded string values.
546
- *
547
- * @example
548
- * ```ts
549
- * process.env.FOO = 'bar';
550
- * dotenvExpandAll({ A: '$FOO', B: 'x${FOO}y' });
551
- * // => { A: "bar", B: "xbary" }
552
- * ```
553
- *
554
- * @remarks
555
- * Options:
556
- * - ref: The reference object to use for expansion (defaults to process.env).
557
- * - progressive: Whether to progressively add expanded values to the set of
558
- * reference keys.
559
- *
560
- * When `progressive` is true, each expanded key becomes available for
561
- * subsequent expansions in the same object (left-to-right by object key order).
562
- */
563
- const dotenvExpandAll = (values = {}, options = {}) => Object.keys(values).reduce((acc, key) => {
564
- const { ref = process.env, progressive = false } = options;
565
- acc[key] = dotenvExpand(values[key], {
566
- ...ref,
567
- ...(progressive ? acc : {}),
568
- });
569
- return acc;
570
- }, {});
571
- /**
572
- * Recursively expands environment variables in a string using `process.env` as
573
- * the expansion reference. Variables may be presented with optional default as
574
- * `$VAR[:default]` or `${VAR[:default]}`. Unknown variables will expand to an
575
- * empty string.
576
- *
577
- * @param value - The string to expand.
578
- * @returns The expanded string.
579
- *
580
- * @example
581
- * ```ts
582
- * process.env.FOO = 'bar';
583
- * dotenvExpandFromProcessEnv('Hello $FOO'); // "Hello bar"
584
- * ```
585
- */
586
- const dotenvExpandFromProcessEnv = (value) => dotenvExpand(value, process.env);
587
-
588
- const applyKv = (current, kv) => {
589
- if (!kv || Object.keys(kv).length === 0)
590
- return current;
591
- const expanded = dotenvExpandAll(kv, { ref: current, progressive: true });
592
- return { ...current, ...expanded };
593
- };
594
- const applyConfigSlice = (current, cfg, env) => {
595
- if (!cfg)
596
- return current;
597
- // kind axis: global then env (env overrides global)
598
- const afterGlobal = applyKv(current, cfg.vars);
599
- const envKv = env && cfg.envVars ? cfg.envVars[env] : undefined;
600
- return applyKv(afterGlobal, envKv);
601
- };
602
- /**
603
- * Overlay config-provided values onto a base ProcessEnv using precedence axes:
604
- * - kind: env \> global
605
- * - privacy: local \> public
606
- * - source: project \> packaged \> base
900
+ * Overlay config-provided values onto a base ProcessEnv using precedence axes:
901
+ * - kind: env \> global
902
+ * - privacy: local \> public
903
+ * - source: project \> packaged \> base
607
904
  *
608
905
  * Programmatic explicit vars (if provided) override all config slices.
609
906
  * Progressive expansion is applied within each slice.
@@ -1176,7 +1473,7 @@ const HELP_HEADER_SYMBOL = Symbol('GetDotenvCli.helpHeader');
1176
1473
  *
1177
1474
  * NOTE: This host is additive and does not alter the legacy CLI.
1178
1475
  */
1179
- class GetDotenvCli extends commander.Command {
1476
+ let GetDotenvCli$1 = class GetDotenvCli extends commander.Command {
1180
1477
  /** Registered top-level plugins (composition happens via .use()) */
1181
1478
  _plugins = [];
1182
1479
  /** One-time installation guard */
@@ -1254,560 +1551,184 @@ class GetDotenvCli extends commander.Command {
1254
1551
  }
1255
1552
  /**
1256
1553
  * Retrieve the merged root CLI options bag (if set by passOptions()).
1257
- * Downstream-safe: no generics required.
1258
- */
1259
- getOptions() {
1260
- return this[OPTS_SYMBOL];
1261
- }
1262
- /** Internal: set the merged root options bag for this run. */
1263
- _setOptionsBag(bag) {
1264
- this[OPTS_SYMBOL] = bag;
1265
- }
1266
- /** * Convenience helper to create a namespaced subcommand.
1267
- */
1268
- ns(name) {
1269
- return this.command(name);
1270
- }
1271
- /**
1272
- * Tag options added during the provided callback as 'app' for grouped help.
1273
- * Allows downstream apps to demarcate their root-level options.
1274
- */
1275
- tagAppOptions(fn) {
1276
- const root = this;
1277
- const originalAddOption = root.addOption.bind(root);
1278
- const originalOption = root.option.bind(root);
1279
- const tagLatest = (cmd, group) => {
1280
- const optsArr = cmd.options;
1281
- if (Array.isArray(optsArr) && optsArr.length > 0) {
1282
- const last = optsArr[optsArr.length - 1];
1283
- last.__group = group;
1284
- }
1285
- };
1286
- root.addOption = function patchedAdd(opt) {
1287
- opt.__group = 'app';
1288
- return originalAddOption(opt);
1289
- };
1290
- root.option = function patchedOption(...args) {
1291
- const ret = originalOption(...args);
1292
- tagLatest(this, 'app');
1293
- return ret;
1294
- };
1295
- try {
1296
- return fn(root);
1297
- }
1298
- finally {
1299
- root.addOption = originalAddOption;
1300
- root.option = originalOption;
1301
- }
1302
- }
1303
- /**
1304
- * Branding helper: set CLI name/description/version and optional help header.
1305
- * If version is omitted and importMetaUrl is provided, attempts to read the
1306
- * nearest package.json version (best-effort; non-fatal on failure).
1307
- */
1308
- async brand(args) {
1309
- const { name, description, version, importMetaUrl, helpHeader } = args;
1310
- if (typeof name === 'string' && name.length > 0)
1311
- this.name(name);
1312
- if (typeof description === 'string')
1313
- this.description(description);
1314
- let v = version;
1315
- if (!v && importMetaUrl) {
1316
- try {
1317
- const fromUrl = url.fileURLToPath(importMetaUrl);
1318
- const pkgDir = await packageDirectory.packageDirectory({ cwd: fromUrl });
1319
- if (pkgDir) {
1320
- const txt = await fs.readFile(`${pkgDir}/package.json`, 'utf-8');
1321
- const pkg = JSON.parse(txt);
1322
- if (pkg.version)
1323
- v = pkg.version;
1324
- }
1325
- }
1326
- catch {
1327
- // best-effort only
1328
- }
1329
- }
1330
- if (v)
1331
- this.version(v);
1332
- // Help header:
1333
- // - If caller provides helpHeader, use it.
1334
- // - Otherwise, when a version is known, default to "<name> v<version>".
1335
- if (typeof helpHeader === 'string') {
1336
- this[HELP_HEADER_SYMBOL] = helpHeader;
1337
- }
1338
- else if (v) {
1339
- // Use the current command name (possibly overridden by 'name' above).
1340
- const header = `${this.name()} v${v}`;
1341
- this[HELP_HEADER_SYMBOL] = header;
1342
- }
1343
- return this;
1344
- }
1345
- /**
1346
- * Register a plugin for installation (parent level).
1347
- * Installation occurs on first resolveAndLoad() (or explicit install()).
1348
- */
1349
- use(plugin) {
1350
- this._plugins.push(plugin);
1351
- // Immediately run setup so subcommands exist before parsing.
1352
- const setupOne = (p) => {
1353
- p.setup(this);
1354
- for (const child of p.children)
1355
- setupOne(child);
1356
- };
1357
- setupOne(plugin);
1358
- return this;
1359
- }
1360
- /**
1361
- * Install all registered plugins in parent → children (pre-order).
1362
- * Runs only once per CLI instance.
1363
- */
1364
- async install() {
1365
- // Setup is performed immediately in use(); here we only guard for afterResolve.
1366
- this._installed = true;
1367
- // Satisfy require-await without altering behavior.
1368
- await Promise.resolve();
1369
- }
1370
- /**
1371
- * Run afterResolve hooks for all plugins (parent → children).
1372
- */
1373
- async _runAfterResolve(ctx) {
1374
- const run = async (p) => {
1375
- if (p.afterResolve)
1376
- await p.afterResolve(this, ctx);
1377
- for (const child of p.children)
1378
- await run(child);
1379
- };
1380
- for (const p of this._plugins)
1381
- await run(p);
1382
- }
1383
- // Render App/Plugin grouped options appended after default help.
1384
- #renderOptionGroups(cmd) {
1385
- const all = cmd.options ?? [];
1386
- const byGroup = new Map();
1387
- for (const o of all) {
1388
- const opt = o;
1389
- const g = opt.__group;
1390
- if (!g || g === 'base')
1391
- continue; // base handled by default help
1392
- const rows = byGroup.get(g) ?? [];
1393
- rows.push({
1394
- flags: opt.flags ?? '',
1395
- description: opt.description ?? '',
1396
- });
1397
- byGroup.set(g, rows);
1398
- }
1399
- if (byGroup.size === 0)
1400
- return '';
1401
- const renderRows = (title, rows) => {
1402
- const width = Math.min(40, rows.reduce((m, r) => Math.max(m, r.flags.length), 0));
1403
- // Sort within group: short-aliased flags first
1404
- rows.sort((a, b) => {
1405
- const aS = /(^|\s|,)-[A-Za-z]/.test(a.flags) ? 1 : 0;
1406
- const bS = /(^|\s|,)-[A-Za-z]/.test(b.flags) ? 1 : 0;
1407
- return bS - aS || a.flags.localeCompare(b.flags);
1408
- });
1409
- const lines = rows
1410
- .map((r) => {
1411
- const pad = ' '.repeat(Math.max(2, width - r.flags.length + 2));
1412
- return ` ${r.flags}${pad}${r.description}`.trimEnd();
1413
- })
1414
- .join('\n');
1415
- return `\n${title}:\n${lines}\n`;
1416
- };
1417
- let out = '';
1418
- // App options (if any)
1419
- const app = byGroup.get('app');
1420
- if (app && app.length > 0) {
1421
- out += renderRows('App options', app);
1422
- }
1423
- // Plugin groups sorted by id
1424
- const pluginKeys = Array.from(byGroup.keys()).filter((k) => k.startsWith('plugin:'));
1425
- pluginKeys.sort((a, b) => a.localeCompare(b));
1426
- for (const k of pluginKeys) {
1427
- const id = k.slice('plugin:'.length) || '(unknown)';
1428
- const rows = byGroup.get(k) ?? [];
1429
- if (rows.length > 0) {
1430
- out += renderRows(`Plugin options — ${id}`, rows);
1431
- }
1432
- }
1433
- return out;
1434
- }
1435
- }
1436
-
1437
- /**
1438
- * Validate a composed env against config-provided validation surfaces.
1439
- * Precedence for validation definitions:
1440
- * project.local -\> project.public -\> packaged
1441
- *
1442
- * Behavior:
1443
- * - If a JS/TS `schema` is present, use schema.safeParse(finalEnv).
1444
- * - Else if `requiredKeys` is present, check presence (value !== undefined).
1445
- * - Returns a flat list of issue strings; caller decides warn vs fail.
1446
- */
1447
- const validateEnvAgainstSources = (finalEnv, sources) => {
1448
- const pick = (getter) => {
1449
- const pl = sources.project?.local;
1450
- const pp = sources.project?.public;
1451
- const pk = sources.packaged;
1452
- return ((pl && getter(pl)) ||
1453
- (pp && getter(pp)) ||
1454
- (pk && getter(pk)) ||
1455
- undefined);
1456
- };
1457
- const schema = pick((cfg) => cfg['schema']);
1458
- if (schema &&
1459
- typeof schema.safeParse === 'function') {
1460
- try {
1461
- const parsed = schema.safeParse(finalEnv);
1462
- if (!parsed.success) {
1463
- // Try to render zod-style issues when available.
1464
- const err = parsed.error;
1465
- const issues = Array.isArray(err.issues) && err.issues.length > 0
1466
- ? err.issues.map((i) => {
1467
- const path = Array.isArray(i.path) ? i.path.join('.') : '';
1468
- const msg = i.message ?? 'Invalid value';
1469
- return path ? `[schema] ${path}: ${msg}` : `[schema] ${msg}`;
1470
- })
1471
- : ['[schema] validation failed'];
1472
- return issues;
1473
- }
1474
- return [];
1475
- }
1476
- catch {
1477
- // If schema invocation fails, surface a single diagnostic.
1478
- return [
1479
- '[schema] validation failed (unable to execute schema.safeParse)',
1480
- ];
1481
- }
1482
- }
1483
- const requiredKeys = pick((cfg) => cfg['requiredKeys']);
1484
- if (Array.isArray(requiredKeys) && requiredKeys.length > 0) {
1485
- const missing = requiredKeys.filter((k) => finalEnv[k] === undefined);
1486
- if (missing.length > 0) {
1487
- return missing.map((k) => `[requiredKeys] missing: ${k}`);
1488
- }
1489
- }
1490
- return [];
1491
- };
1492
-
1493
- /**
1494
- * Attach legacy root flags to a Commander program.
1495
- * Uses provided defaults to render help labels without coupling to generators.
1496
- */
1497
- const attachRootOptions = (program, defaults, opts) => {
1498
- // Install temporary wrappers to tag all options added here as "base".
1499
- const GROUP = 'base';
1500
- const tagLatest = (cmd, group) => {
1501
- const optsArr = cmd.options;
1502
- if (Array.isArray(optsArr) && optsArr.length > 0) {
1503
- const last = optsArr[optsArr.length - 1];
1504
- last.__group = group;
1505
- }
1506
- };
1507
- const originalAddOption = program.addOption.bind(program);
1508
- const originalOption = program.option.bind(program);
1509
- program.addOption = function patchedAdd(opt) {
1510
- // Tag before adding, in case consumers inspect the Option directly.
1511
- opt.__group = GROUP;
1512
- const ret = originalAddOption(opt);
1513
- return ret;
1514
- };
1515
- program.option = function patchedOption(...args) {
1516
- const ret = originalOption(...args);
1517
- tagLatest(this, GROUP);
1518
- return ret;
1519
- };
1520
- const { defaultEnv, dotenvToken, dynamicPath, env, excludeDynamic, excludeEnv, excludeGlobal, excludePrivate, excludePublic, loadProcess, log, outputPath, paths, pathsDelimiter, pathsDelimiterPattern, privateToken, scripts, shell, varsAssignor, varsAssignorPattern, varsDelimiter, varsDelimiterPattern, } = defaults ?? {};
1521
- const va = typeof defaults?.varsAssignor === 'string' ? defaults.varsAssignor : '=';
1522
- const vd = typeof defaults?.varsDelimiter === 'string' ? defaults.varsDelimiter : ' ';
1523
- // Build initial chain.
1524
- let p = program
1525
- .enablePositionalOptions()
1526
- .passThroughOptions()
1527
- .option('-e, --env <string>', `target environment (dotenv-expanded)`, dotenvExpandFromProcessEnv, env);
1528
- p = p.option('-v, --vars <string>', `extra variables expressed as delimited key-value pairs (dotenv-expanded): ${[
1529
- ['KEY1', 'VAL1'],
1530
- ['KEY2', 'VAL2'],
1531
- ]
1532
- .map((v) => v.join(va))
1533
- .join(vd)}`, dotenvExpandFromProcessEnv);
1534
- // Optional legacy root command flag (kept for generated CLI compatibility).
1535
- // Default is OFF; the generator opts in explicitly.
1536
- if (opts?.includeCommandOption === true) {
1537
- p = p.option('-c, --command <string>', 'command executed according to the --shell option, conflicts with cmd subcommand (dotenv-expanded)', dotenvExpandFromProcessEnv);
1538
- }
1539
- p = p
1540
- .option('-o, --output-path <string>', 'consolidated output file (dotenv-expanded)', dotenvExpandFromProcessEnv, outputPath)
1541
- .addOption(new commander.Option('-s, --shell [string]', (() => {
1542
- let defaultLabel = '';
1543
- if (shell !== undefined) {
1544
- if (typeof shell === 'boolean') {
1545
- defaultLabel = ' (default OS shell)';
1546
- }
1547
- else if (typeof shell === 'string') {
1548
- // Safe string interpolation
1549
- defaultLabel = ` (default ${shell})`;
1550
- }
1551
- }
1552
- return `command execution shell, no argument for default OS shell or provide shell string${defaultLabel}`;
1553
- })()).conflicts('shellOff'))
1554
- .addOption(new commander.Option('-S, --shell-off', `command execution shell OFF${!shell ? ' (default)' : ''}`).conflicts('shell'))
1555
- .addOption(new commander.Option('-p, --load-process', `load variables to process.env ON${loadProcess ? ' (default)' : ''}`).conflicts('loadProcessOff'))
1556
- .addOption(new commander.Option('-P, --load-process-off', `load variables to process.env OFF${!loadProcess ? ' (default)' : ''}`).conflicts('loadProcess'))
1557
- .addOption(new commander.Option('-a, --exclude-all', `exclude all dotenv variables from loading ON${excludeDynamic &&
1558
- ((excludeEnv && excludeGlobal) || (excludePrivate && excludePublic))
1559
- ? ' (default)'
1560
- : ''}`).conflicts('excludeAllOff'))
1561
- .addOption(new commander.Option('-A, --exclude-all-off', `exclude all dotenv variables from loading OFF (default)`).conflicts('excludeAll'))
1562
- .addOption(new commander.Option('-z, --exclude-dynamic', `exclude dynamic dotenv variables from loading ON${excludeDynamic ? ' (default)' : ''}`).conflicts('excludeDynamicOff'))
1563
- .addOption(new commander.Option('-Z, --exclude-dynamic-off', `exclude dynamic dotenv variables from loading OFF${!excludeDynamic ? ' (default)' : ''}`).conflicts('excludeDynamic'))
1564
- .addOption(new commander.Option('-n, --exclude-env', `exclude environment-specific dotenv variables from loading${excludeEnv ? ' (default)' : ''}`).conflicts('excludeEnvOff'))
1565
- .addOption(new commander.Option('-N, --exclude-env-off', `exclude environment-specific dotenv variables from loading OFF${!excludeEnv ? ' (default)' : ''}`).conflicts('excludeEnv'))
1566
- .addOption(new commander.Option('-g, --exclude-global', `exclude global dotenv variables from loading ON${excludeGlobal ? ' (default)' : ''}`).conflicts('excludeGlobalOff'))
1567
- .addOption(new commander.Option('-G, --exclude-global-off', `exclude global dotenv variables from loading OFF${!excludeGlobal ? ' (default)' : ''}`).conflicts('excludeGlobal'))
1568
- .addOption(new commander.Option('-r, --exclude-private', `exclude private dotenv variables from loading ON${excludePrivate ? ' (default)' : ''}`).conflicts('excludePrivateOff'))
1569
- .addOption(new commander.Option('-R, --exclude-private-off', `exclude private dotenv variables from loading OFF${!excludePrivate ? ' (default)' : ''}`).conflicts('excludePrivate'))
1570
- .addOption(new commander.Option('-u, --exclude-public', `exclude public dotenv variables from loading ON${excludePublic ? ' (default)' : ''}`).conflicts('excludePublicOff'))
1571
- .addOption(new commander.Option('-U, --exclude-public-off', `exclude public dotenv variables from loading OFF${!excludePublic ? ' (default)' : ''}`).conflicts('excludePublic'))
1572
- .addOption(new commander.Option('-l, --log', `console log loaded variables ON${log ? ' (default)' : ''}`).conflicts('logOff'))
1573
- .addOption(new commander.Option('-L, --log-off', `console log loaded variables OFF${!log ? ' (default)' : ''}`).conflicts('log'))
1574
- .option('--capture', 'capture child process stdio for commands (tests/CI)')
1575
- .option('--redact', 'mask secret-like values in logs/trace (presentation-only)')
1576
- .option('--default-env <string>', 'default target environment', dotenvExpandFromProcessEnv, defaultEnv)
1577
- .option('--dotenv-token <string>', 'dotenv-expanded token indicating a dotenv file', dotenvExpandFromProcessEnv, dotenvToken)
1578
- .option('--dynamic-path <string>', 'dynamic variables path (.js or .ts; .ts is auto-compiled when esbuild is available, otherwise precompile)', dotenvExpandFromProcessEnv, dynamicPath)
1579
- .option('--paths <string>', 'dotenv-expanded delimited list of paths to dotenv directory', dotenvExpandFromProcessEnv, paths)
1580
- .option('--paths-delimiter <string>', 'paths delimiter string', pathsDelimiter)
1581
- .option('--paths-delimiter-pattern <string>', 'paths delimiter regex pattern', pathsDelimiterPattern)
1582
- .option('--private-token <string>', 'dotenv-expanded token indicating private variables', dotenvExpandFromProcessEnv, privateToken)
1583
- .option('--vars-delimiter <string>', 'vars delimiter string', varsDelimiter)
1584
- .option('--vars-delimiter-pattern <string>', 'vars delimiter regex pattern', varsDelimiterPattern)
1585
- .option('--vars-assignor <string>', 'vars assignment operator string', varsAssignor)
1586
- .option('--vars-assignor-pattern <string>', 'vars assignment operator regex pattern', varsAssignorPattern)
1587
- // Hidden scripts pipe-through (stringified)
1588
- .addOption(new commander.Option('--scripts <string>')
1589
- .default(JSON.stringify(scripts))
1590
- .hideHelp());
1591
- // Diagnostics: opt-in tracing; optional variadic keys after the flag.
1592
- p = p.option('--trace [keys...]', 'emit diagnostics for child env composition (optional keys)');
1593
- // Validation: strict mode fails on env validation issues (warn by default).
1594
- p = p.option('--strict', 'fail on env validation errors (schema/requiredKeys)');
1595
- // Entropy diagnostics (presentation-only)
1596
- p = p
1597
- .addOption(new commander.Option('--entropy-warn', 'enable entropy warnings (default on)').conflicts('entropyWarnOff'))
1598
- .addOption(new commander.Option('--entropy-warn-off', 'disable entropy warnings').conflicts('entropyWarn'))
1599
- .option('--entropy-threshold <number>', 'entropy bits/char threshold (default 3.8)')
1600
- .option('--entropy-min-length <number>', 'min length to examine for entropy (default 16)')
1601
- .option('--entropy-whitelist <pattern...>', 'suppress entropy warnings when key matches any regex pattern')
1602
- .option('--redact-pattern <pattern...>', 'additional key-match regex patterns to trigger redaction');
1603
- // Restore original methods to avoid tagging future additions outside base.
1604
- program.addOption = originalAddOption;
1605
- program.option = originalOption;
1606
- return p;
1607
- };
1608
-
1609
- /**
1610
- * Resolve a tri-state optional boolean flag under exactOptionalPropertyTypes.
1611
- * - If the user explicitly enabled the flag, return true.
1612
- * - If the user explicitly disabled (the "...-off" variant), return undefined (unset).
1613
- * - Otherwise, adopt the default (true → set; false/undefined → unset).
1614
- *
1615
- * @param exclude - The "on" flag value as parsed by Commander.
1616
- * @param excludeOff - The "off" toggle (present when specified) as parsed by Commander.
1617
- * @param defaultValue - The generator default to adopt when no explicit toggle is present.
1618
- * @returns boolean | undefined — use `undefined` to indicate "unset" (do not emit).
1619
- *
1620
- * @example
1621
- * ```ts
1622
- * resolveExclusion(undefined, undefined, true); // => true
1623
- * ```
1624
- */
1625
- const resolveExclusion = (exclude, excludeOff, defaultValue) => exclude ? true : excludeOff ? undefined : defaultValue ? true : undefined;
1626
- /**
1627
- * Resolve an optional flag with "--exclude-all" overrides.
1628
- * If excludeAll is set and the individual "...-off" is not, force true.
1629
- * If excludeAllOff is set and the individual flag is not explicitly set, unset.
1630
- * Otherwise, adopt the default (true → set; false/undefined → unset).
1631
- *
1632
- * @param exclude - Individual include/exclude flag.
1633
- * @param excludeOff - Individual "...-off" flag.
1634
- * @param defaultValue - Default for the individual flag.
1635
- * @param excludeAll - Global "exclude-all" flag.
1636
- * @param excludeAllOff - Global "exclude-all-off" flag.
1637
- *
1638
- * @example
1639
- * resolveExclusionAll(undefined, undefined, false, true, undefined) =\> true
1640
- */
1641
- const resolveExclusionAll = (exclude, excludeOff, defaultValue, excludeAll, excludeAllOff) =>
1642
- // Order of precedence:
1643
- // 1) Individual explicit "on" wins outright.
1644
- // 2) Individual explicit "off" wins over any global.
1645
- // 3) Global exclude-all forces true when not explicitly turned off.
1646
- // 4) Global exclude-all-off unsets when the individual wasn't explicitly enabled.
1647
- // 5) Fall back to the default (true => set; false/undefined => unset).
1648
- (() => {
1649
- // Individual "on"
1650
- if (exclude === true)
1651
- return true;
1652
- // Individual "off"
1653
- if (excludeOff === true)
1654
- return undefined;
1655
- // Global "exclude-all" ON (unless explicitly turned off)
1656
- if (excludeAll === true)
1657
- return true;
1658
- // Global "exclude-all-off" (unless explicitly enabled)
1659
- if (excludeAllOff === true)
1660
- return undefined;
1661
- // Default
1662
- return defaultValue ? true : undefined;
1663
- })();
1664
- /**
1665
- * exactOptionalPropertyTypes-safe setter for optional boolean flags:
1666
- * delete when undefined; assign when defined — without requiring an index signature on T.
1667
- *
1668
- * @typeParam T - Target object type.
1669
- * @param obj - The object to write to.
1670
- * @param key - The optional boolean property key of {@link T}.
1671
- * @param value - The value to set or `undefined` to unset.
1672
- *
1673
- * @remarks
1674
- * Writes through a local `Record<string, unknown>` view to avoid requiring an index signature on {@link T}.
1675
- */
1676
- const setOptionalFlag = (obj, key, value) => {
1677
- const target = obj;
1678
- const k = key;
1679
- // eslint-disable-next-line @typescript-eslint/no-dynamic-delete
1680
- if (value === undefined)
1681
- delete target[k];
1682
- else
1683
- target[k] = value;
1684
- };
1685
-
1686
- /**
1687
- * Merge and normalize raw Commander options (current + parent + defaults)
1688
- * into a GetDotenvCliOptions-like object. Types are intentionally wide to
1689
- * avoid cross-layer coupling; callers may cast as needed.
1690
- */
1691
- const resolveCliOptions = (rawCliOptions, defaults, parentJson) => {
1692
- const parent = typeof parentJson === 'string' && parentJson.length > 0
1693
- ? JSON.parse(parentJson)
1694
- : undefined;
1695
- const { command, debugOff, excludeAll, excludeAllOff, excludeDynamicOff, excludeEnvOff, excludeGlobalOff, excludePrivateOff, excludePublicOff, loadProcessOff, logOff, entropyWarn, entropyWarnOff, scripts, shellOff, ...rest } = rawCliOptions;
1696
- const current = { ...rest };
1697
- if (typeof scripts === 'string') {
1698
- try {
1699
- current.scripts = JSON.parse(scripts);
1700
- }
1701
- catch {
1702
- // ignore parse errors; leave scripts undefined
1703
- }
1554
+ * Downstream-safe: no generics required.
1555
+ */
1556
+ getOptions() {
1557
+ return this[OPTS_SYMBOL];
1704
1558
  }
1705
- const merged = defaultsDeep({}, defaults, parent ?? {}, current);
1706
- const d = defaults;
1707
- setOptionalFlag(merged, 'debug', resolveExclusion(merged.debug, debugOff, d.debug));
1708
- setOptionalFlag(merged, 'excludeDynamic', resolveExclusionAll(merged.excludeDynamic, excludeDynamicOff, d.excludeDynamic, excludeAll, excludeAllOff));
1709
- setOptionalFlag(merged, 'excludeEnv', resolveExclusionAll(merged.excludeEnv, excludeEnvOff, d.excludeEnv, excludeAll, excludeAllOff));
1710
- setOptionalFlag(merged, 'excludeGlobal', resolveExclusionAll(merged.excludeGlobal, excludeGlobalOff, d.excludeGlobal, excludeAll, excludeAllOff));
1711
- setOptionalFlag(merged, 'excludePrivate', resolveExclusionAll(merged.excludePrivate, excludePrivateOff, d.excludePrivate, excludeAll, excludeAllOff));
1712
- setOptionalFlag(merged, 'excludePublic', resolveExclusionAll(merged.excludePublic, excludePublicOff, d.excludePublic, excludeAll, excludeAllOff));
1713
- setOptionalFlag(merged, 'log', resolveExclusion(merged.log, logOff, d.log));
1714
- setOptionalFlag(merged, 'loadProcess', resolveExclusion(merged.loadProcess, loadProcessOff, d.loadProcess));
1715
- // warnEntropy (tri-state)
1716
- setOptionalFlag(merged, 'warnEntropy', resolveExclusion(merged.warnEntropy, entropyWarnOff, d.warnEntropy));
1717
- // Normalize shell for predictability: explicit default shell per OS.
1718
- const defaultShell = process.platform === 'win32' ? 'powershell.exe' : '/bin/bash';
1719
- let resolvedShell = merged.shell;
1720
- if (shellOff)
1721
- resolvedShell = false;
1722
- else if (resolvedShell === true || resolvedShell === undefined) {
1723
- resolvedShell = defaultShell;
1559
+ /** Internal: set the merged root options bag for this run. */
1560
+ _setOptionsBag(bag) {
1561
+ this[OPTS_SYMBOL] = bag;
1724
1562
  }
1725
- else if (typeof resolvedShell !== 'string' &&
1726
- typeof defaults.shell === 'string') {
1727
- resolvedShell = defaults.shell;
1563
+ /** * Convenience helper to create a namespaced subcommand.
1564
+ */
1565
+ ns(name) {
1566
+ return this.command(name);
1728
1567
  }
1729
- merged.shell = resolvedShell;
1730
- const cmd = typeof command === 'string' ? command : undefined;
1731
- return cmd !== undefined ? { merged, command: cmd } : { merged };
1732
- };
1733
-
1734
- GetDotenvCli.prototype.attachRootOptions = function (defaults, opts) {
1735
- const d = (defaults ?? baseRootOptionDefaults);
1736
- attachRootOptions(this, d, opts);
1737
- return this;
1738
- };
1739
- GetDotenvCli.prototype.passOptions = function (defaults) {
1740
- const d = (defaults ?? baseRootOptionDefaults);
1741
- this.hook('preSubcommand', async (thisCommand) => {
1742
- const raw = thisCommand.opts();
1743
- const { merged } = resolveCliOptions(raw, d, process.env.getDotenvCliOptions);
1744
- // Persist merged options for nested invocations (batch exec).
1745
- thisCommand.getDotenvCliOptions =
1746
- merged;
1747
- // Also store on the host for downstream ergonomic accessors.
1748
- this._setOptionsBag(merged);
1749
- // Build service options and compute context (always-on config loader path).
1750
- const serviceOptions = getDotenvCliOptions2Options(merged);
1751
- await this.resolveAndLoad(serviceOptions);
1752
- // Global validation: once after Phase C using config sources.
1753
- try {
1754
- const ctx = this.getCtx();
1755
- const dotenv = (ctx?.dotenv ?? {});
1756
- const sources = await resolveGetDotenvConfigSources((typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === 'SCRIPT' && _documentCurrentScript.src || new URL('cliHost.cjs', document.baseURI).href)));
1757
- const issues = validateEnvAgainstSources(dotenv, sources);
1758
- if (Array.isArray(issues) && issues.length > 0) {
1759
- const logger = (merged.logger ??
1760
- console);
1761
- const emit = logger.error ?? logger.log;
1762
- issues.forEach((m) => {
1763
- emit(m);
1764
- });
1765
- if (merged.strict) {
1766
- // Deterministic failure under strict mode
1767
- process.exit(1);
1768
- }
1568
+ /**
1569
+ * Tag options added during the provided callback as 'app' for grouped help.
1570
+ * Allows downstream apps to demarcate their root-level options.
1571
+ */
1572
+ tagAppOptions(fn) {
1573
+ const root = this;
1574
+ const originalAddOption = root.addOption.bind(root);
1575
+ const originalOption = root.option.bind(root);
1576
+ const tagLatest = (cmd, group) => {
1577
+ const optsArr = cmd.options;
1578
+ if (Array.isArray(optsArr) && optsArr.length > 0) {
1579
+ const last = optsArr[optsArr.length - 1];
1580
+ last.__group = group;
1769
1581
  }
1582
+ };
1583
+ root.addOption = function patchedAdd(opt) {
1584
+ opt.__group = 'app';
1585
+ return originalAddOption(opt);
1586
+ };
1587
+ root.option = function patchedOption(...args) {
1588
+ const ret = originalOption(...args);
1589
+ tagLatest(this, 'app');
1590
+ return ret;
1591
+ };
1592
+ try {
1593
+ return fn(root);
1770
1594
  }
1771
- catch {
1772
- // Be tolerant: validation errors reported above; unexpected failures here
1773
- // should not crash non-strict flows.
1595
+ finally {
1596
+ root.addOption = originalAddOption;
1597
+ root.option = originalOption;
1774
1598
  }
1775
- });
1776
- // Also handle root-level flows (no subcommand) so option-aliases can run
1777
- // with the same merged options and context without duplicating logic.
1778
- this.hook('preAction', async (thisCommand) => {
1779
- const raw = thisCommand.opts();
1780
- const { merged } = resolveCliOptions(raw, d, process.env.getDotenvCliOptions);
1781
- thisCommand.getDotenvCliOptions =
1782
- merged;
1783
- this._setOptionsBag(merged);
1784
- // Avoid duplicate heavy work if a context is already present.
1785
- if (!this.getCtx()) {
1786
- const serviceOptions = getDotenvCliOptions2Options(merged);
1787
- await this.resolveAndLoad(serviceOptions);
1599
+ }
1600
+ /**
1601
+ * Branding helper: set CLI name/description/version and optional help header.
1602
+ * If version is omitted and importMetaUrl is provided, attempts to read the
1603
+ * nearest package.json version (best-effort; non-fatal on failure).
1604
+ */
1605
+ async brand(args) {
1606
+ const { name, description, version, importMetaUrl, helpHeader } = args;
1607
+ if (typeof name === 'string' && name.length > 0)
1608
+ this.name(name);
1609
+ if (typeof description === 'string')
1610
+ this.description(description);
1611
+ let v = version;
1612
+ if (!v && importMetaUrl) {
1788
1613
  try {
1789
- const ctx = this.getCtx();
1790
- const dotenv = (ctx?.dotenv ?? {});
1791
- const sources = await resolveGetDotenvConfigSources((typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === 'SCRIPT' && _documentCurrentScript.src || new URL('cliHost.cjs', document.baseURI).href)));
1792
- const issues = validateEnvAgainstSources(dotenv, sources);
1793
- if (Array.isArray(issues) && issues.length > 0) {
1794
- const logger = (merged
1795
- .logger ?? console);
1796
- const emit = logger.error ?? logger.log;
1797
- issues.forEach((m) => {
1798
- emit(m);
1799
- });
1800
- if (merged.strict) {
1801
- process.exit(1);
1802
- }
1614
+ const fromUrl = url.fileURLToPath(importMetaUrl);
1615
+ const pkgDir = await packageDirectory.packageDirectory({ cwd: fromUrl });
1616
+ if (pkgDir) {
1617
+ const txt = await fs.readFile(`${pkgDir}/package.json`, 'utf-8');
1618
+ const pkg = JSON.parse(txt);
1619
+ if (pkg.version)
1620
+ v = pkg.version;
1803
1621
  }
1804
1622
  }
1805
1623
  catch {
1806
- // Tolerate validation side-effects in non-strict mode
1624
+ // best-effort only
1807
1625
  }
1808
1626
  }
1809
- });
1810
- return this;
1627
+ if (v)
1628
+ this.version(v);
1629
+ // Help header:
1630
+ // - If caller provides helpHeader, use it.
1631
+ // - Otherwise, when a version is known, default to "<name> v<version>".
1632
+ if (typeof helpHeader === 'string') {
1633
+ this[HELP_HEADER_SYMBOL] = helpHeader;
1634
+ }
1635
+ else if (v) {
1636
+ // Use the current command name (possibly overridden by 'name' above).
1637
+ const header = `${this.name()} v${v}`;
1638
+ this[HELP_HEADER_SYMBOL] = header;
1639
+ }
1640
+ return this;
1641
+ }
1642
+ /**
1643
+ * Register a plugin for installation (parent level).
1644
+ * Installation occurs on first resolveAndLoad() (or explicit install()).
1645
+ */
1646
+ use(plugin) {
1647
+ this._plugins.push(plugin);
1648
+ // Immediately run setup so subcommands exist before parsing.
1649
+ const setupOne = (p) => {
1650
+ p.setup(this);
1651
+ for (const child of p.children)
1652
+ setupOne(child);
1653
+ };
1654
+ setupOne(plugin);
1655
+ return this;
1656
+ }
1657
+ /**
1658
+ * Install all registered plugins in parent → children (pre-order).
1659
+ * Runs only once per CLI instance.
1660
+ */
1661
+ async install() {
1662
+ // Setup is performed immediately in use(); here we only guard for afterResolve.
1663
+ this._installed = true;
1664
+ // Satisfy require-await without altering behavior.
1665
+ await Promise.resolve();
1666
+ }
1667
+ /**
1668
+ * Run afterResolve hooks for all plugins (parent → children).
1669
+ */
1670
+ async _runAfterResolve(ctx) {
1671
+ const run = async (p) => {
1672
+ if (p.afterResolve)
1673
+ await p.afterResolve(this, ctx);
1674
+ for (const child of p.children)
1675
+ await run(child);
1676
+ };
1677
+ for (const p of this._plugins)
1678
+ await run(p);
1679
+ }
1680
+ // Render App/Plugin grouped options appended after default help.
1681
+ #renderOptionGroups(cmd) {
1682
+ const all = cmd.options ?? [];
1683
+ const byGroup = new Map();
1684
+ for (const o of all) {
1685
+ const opt = o;
1686
+ const g = opt.__group;
1687
+ if (!g || g === 'base')
1688
+ continue; // base handled by default help
1689
+ const rows = byGroup.get(g) ?? [];
1690
+ rows.push({
1691
+ flags: opt.flags ?? '',
1692
+ description: opt.description ?? '',
1693
+ });
1694
+ byGroup.set(g, rows);
1695
+ }
1696
+ if (byGroup.size === 0)
1697
+ return '';
1698
+ const renderRows = (title, rows) => {
1699
+ const width = Math.min(40, rows.reduce((m, r) => Math.max(m, r.flags.length), 0));
1700
+ // Sort within group: short-aliased flags first
1701
+ rows.sort((a, b) => {
1702
+ const aS = /(^|\s|,)-[A-Za-z]/.test(a.flags) ? 1 : 0;
1703
+ const bS = /(^|\s|,)-[A-Za-z]/.test(b.flags) ? 1 : 0;
1704
+ return bS - aS || a.flags.localeCompare(b.flags);
1705
+ });
1706
+ const lines = rows
1707
+ .map((r) => {
1708
+ const pad = ' '.repeat(Math.max(2, width - r.flags.length + 2));
1709
+ return ` ${r.flags}${pad}${r.description}`.trimEnd();
1710
+ })
1711
+ .join('\n');
1712
+ return `\n${title}:\n${lines}\n`;
1713
+ };
1714
+ let out = '';
1715
+ // App options (if any)
1716
+ const app = byGroup.get('app');
1717
+ if (app && app.length > 0) {
1718
+ out += renderRows('App options', app);
1719
+ }
1720
+ // Plugin groups sorted by id
1721
+ const pluginKeys = Array.from(byGroup.keys()).filter((k) => k.startsWith('plugin:'));
1722
+ pluginKeys.sort((a, b) => a.localeCompare(b));
1723
+ for (const k of pluginKeys) {
1724
+ const id = k.slice('plugin:'.length) || '(unknown)';
1725
+ const rows = byGroup.get(k) ?? [];
1726
+ if (rows.length > 0) {
1727
+ out += renderRows(`Plugin options — ${id}`, rows);
1728
+ }
1729
+ }
1730
+ return out;
1731
+ }
1811
1732
  };
1812
1733
 
1813
1734
  /** src/cliHost/definePlugin.ts
@@ -1838,8 +1759,100 @@ const definePlugin = (spec) => {
1838
1759
  return plugin;
1839
1760
  };
1840
1761
 
1841
- // Ensure attachRootOptions() and passOptions() are available whenever the
1842
- // /cliHost subpath is imported (unconditional for downstream hosts).
1762
+ /**
1763
+ * GetDotenvCli with root helpers as real class methods.
1764
+ * - attachRootOptions: installs legacy/base root flags on the command.
1765
+ * - passOptions: merges flags (parent \< current), computes dotenv context once,
1766
+ * runs validation, and persists merged options for nested flows.
1767
+ */
1768
+ class GetDotenvCli extends GetDotenvCli$1 {
1769
+ /**
1770
+ * Attach legacy root flags to this CLI instance. Defaults come from
1771
+ * baseRootOptionDefaults when none are provided.
1772
+ */
1773
+ attachRootOptions(defaults, opts) {
1774
+ const d = (defaults ?? baseRootOptionDefaults);
1775
+ attachRootOptions(this, d, opts);
1776
+ return this;
1777
+ }
1778
+ /**
1779
+ * Install preSubcommand/preAction hooks that:
1780
+ * - Merge options (parent round-trip + current invocation) using resolveCliOptions.
1781
+ * - Persist the merged bag on the current command and on the host (for ergonomics).
1782
+ * - Compute the dotenv context once via resolveAndLoad(serviceOptions).
1783
+ * - Validate the composed env against discovered config (warn or --strict fail).
1784
+ */
1785
+ passOptions(defaults) {
1786
+ const d = (defaults ?? baseRootOptionDefaults);
1787
+ this.hook('preSubcommand', async (thisCommand) => {
1788
+ const raw = thisCommand.opts();
1789
+ const { merged } = resolveCliOptions(raw, d, process.env.getDotenvCliOptions);
1790
+ // Persist merged options (for nested behavior and ergonomic access).
1791
+ thisCommand.getDotenvCliOptions =
1792
+ merged;
1793
+ this._setOptionsBag(merged);
1794
+ // Build service options and compute context (always-on loader path).
1795
+ const serviceOptions = getDotenvCliOptions2Options(merged);
1796
+ await this.resolveAndLoad(serviceOptions);
1797
+ // Global validation: once after Phase C using config sources.
1798
+ try {
1799
+ const ctx = this.getCtx();
1800
+ const dotenv = (ctx?.dotenv ?? {});
1801
+ const sources = await resolveGetDotenvConfigSources((typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === 'SCRIPT' && _documentCurrentScript.src || new URL('cliHost.cjs', document.baseURI).href)));
1802
+ const issues = validateEnvAgainstSources(dotenv, sources);
1803
+ if (Array.isArray(issues) && issues.length > 0) {
1804
+ const logger = (merged
1805
+ .logger ?? console);
1806
+ const emit = logger.error ?? logger.log;
1807
+ issues.forEach((m) => {
1808
+ emit(m);
1809
+ });
1810
+ if (merged.strict) {
1811
+ process.exit(1);
1812
+ }
1813
+ }
1814
+ }
1815
+ catch {
1816
+ // Be tolerant: do not crash non-strict flows on unexpected validator failures.
1817
+ }
1818
+ });
1819
+ // Also handle root-level flows (no subcommand) so option-aliases can run
1820
+ // with the same merged options and context without duplicating logic.
1821
+ this.hook('preAction', async (thisCommand) => {
1822
+ const raw = thisCommand.opts();
1823
+ const { merged } = resolveCliOptions(raw, d, process.env.getDotenvCliOptions);
1824
+ thisCommand.getDotenvCliOptions =
1825
+ merged;
1826
+ this._setOptionsBag(merged);
1827
+ // Avoid duplicate heavy work if a context is already present.
1828
+ if (!this.getCtx()) {
1829
+ const serviceOptions = getDotenvCliOptions2Options(merged);
1830
+ await this.resolveAndLoad(serviceOptions);
1831
+ try {
1832
+ const ctx = this.getCtx();
1833
+ const dotenv = (ctx?.dotenv ?? {});
1834
+ const sources = await resolveGetDotenvConfigSources((typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === 'SCRIPT' && _documentCurrentScript.src || new URL('cliHost.cjs', document.baseURI).href)));
1835
+ const issues = validateEnvAgainstSources(dotenv, sources);
1836
+ if (Array.isArray(issues) && issues.length > 0) {
1837
+ const logger = (merged
1838
+ .logger ?? console);
1839
+ const emit = logger.error ?? logger.log;
1840
+ issues.forEach((m) => {
1841
+ emit(m);
1842
+ });
1843
+ if (merged.strict) {
1844
+ process.exit(1);
1845
+ }
1846
+ }
1847
+ }
1848
+ catch {
1849
+ // Tolerate validation side-effects in non-strict mode.
1850
+ }
1851
+ }
1852
+ });
1853
+ return this;
1854
+ }
1855
+ }
1843
1856
  /**
1844
1857
  * Helper to retrieve the merged root options bag from any action handler
1845
1858
  * that only has access to thisCommand. Avoids structural casts.