@optique/env 1.1.0-dev.2087 → 1.1.0-dev.2146

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/README.md CHANGED
@@ -6,7 +6,7 @@ Environment variable support for [Optique].
6
6
  This package provides a type-safe way to bind CLI parsers to environment
7
7
  variables with clear fallback behavior:
8
8
 
9
- CLI arguments > environment variables > defaults.
9
+ CLI arguments > environment variables > *.env* files > defaults.
10
10
 
11
11
  [Optique]: https://optique.dev/
12
12
 
@@ -33,7 +33,10 @@ import { integer, string } from "@optique/core/valueparser";
33
33
  import { runAsync } from "@optique/run";
34
34
  import { bindEnv, bool, createEnvContext } from "@optique/env";
35
35
 
36
- const envContext = createEnvContext({ prefix: "MYAPP_" });
36
+ const envContext = createEnvContext({
37
+ prefix: "MYAPP_",
38
+ envFile: [".env", ".env.local"],
39
+ });
37
40
 
38
41
  const parser = object({
39
42
  host: bindEnv(option("--host", string()), {
@@ -70,6 +73,7 @@ Features
70
73
  - *Type-safe env parsing* with any Optique `ValueParser`
71
74
  - *Common Boolean parser* via `bool()`
72
75
  - *Prefix support* for namespaced environment variables
76
+ - *.env file loading* without mutating `process.env` or `Deno.env`
73
77
  - *Custom env source* for Deno, tests, and custom runtimes
74
78
  - *Composable contexts* with `run()`/`runAsync()`/`runWith()`
75
79
 
package/dist/index.cjs CHANGED
@@ -21,18 +21,232 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
21
21
  }) : target, mod));
22
22
 
23
23
  //#endregion
24
+ const node_fs = __toESM(require("node:fs"));
25
+ const node_path = __toESM(require("node:path"));
24
26
  const __optique_core_annotations = __toESM(require("@optique/core/annotations"));
25
27
  const __optique_core_extension = __toESM(require("@optique/core/extension"));
26
28
  const __optique_core_message = __toESM(require("@optique/core/message"));
27
29
  const __optique_core_valueparser = __toESM(require("@optique/core/valueparser"));
28
30
 
29
31
  //#region src/index.ts
32
+ function getTypeName(value) {
33
+ if (value === null) return "null";
34
+ if (Array.isArray(value)) return "array";
35
+ return typeof value;
36
+ }
30
37
  function defaultEnvSource(key) {
31
38
  const denoGlobal = globalThis.Deno;
32
39
  if (typeof denoGlobal?.env?.get === "function") return denoGlobal.env.get(key);
33
40
  const processGlobal = globalThis.process;
34
41
  return processGlobal?.env?.[key];
35
42
  }
43
+ function isErrnoException(error) {
44
+ return error != null && typeof error === "object" && "code" in error && typeof error.code === "string";
45
+ }
46
+ function normalizeEnvFilePaths(paths) {
47
+ if (paths === false) return [];
48
+ if (paths === true) return [".env"];
49
+ if (typeof paths === "string") return [paths];
50
+ if (Array.isArray(paths)) {
51
+ for (const path of paths) if (typeof path !== "string") throw new TypeError(`Expected envFile paths to be strings, but got: ${getTypeName(path)}.`);
52
+ return paths;
53
+ }
54
+ throw new TypeError(`Expected envFile.paths to be a boolean, string, array, or undefined, but got: ${getTypeName(paths)}.`);
55
+ }
56
+ function normalizeEnvFileOptions(envFile) {
57
+ if (envFile === void 0 || envFile === false) return { paths: [] };
58
+ if (envFile === true || typeof envFile === "string" || Array.isArray(envFile)) return { paths: normalizeEnvFilePaths(envFile) };
59
+ if (envFile == null || typeof envFile !== "object") throw new TypeError(`Expected envFile to be a boolean, string, array, or object, but got: ${getTypeName(envFile)}.`);
60
+ const options = envFile;
61
+ const rawSubstitute = options.substitute;
62
+ if (rawSubstitute !== void 0 && typeof rawSubstitute !== "function") throw new TypeError(`Expected envFile.substitute to be a function or undefined, but got: ${getTypeName(rawSubstitute)}.`);
63
+ return {
64
+ paths: normalizeEnvFilePaths(options.paths ?? true),
65
+ ...rawSubstitute === void 0 ? {} : { substitute: rawSubstitute }
66
+ };
67
+ }
68
+ function isEnvNameStart(character) {
69
+ return /[A-Za-z_]/u.test(character);
70
+ }
71
+ function isEnvNamePart(character) {
72
+ return /[A-Za-z0-9_]/u.test(character);
73
+ }
74
+ function getLineNumber(input, offset) {
75
+ let line = 1;
76
+ for (let index = 0; index < offset; index++) if (input[index] === "\n") line++;
77
+ else if (input[index] === "\r") {
78
+ line++;
79
+ if (input[index + 1] === "\n") index++;
80
+ }
81
+ return line;
82
+ }
83
+ function syntaxError(path, line, messageText) {
84
+ return /* @__PURE__ */ new SyntaxError(`Invalid .env syntax in ${path} at line ${line}: ${messageText}.`);
85
+ }
86
+ function skipLine(input, index) {
87
+ while (index < input.length && input[index] !== "\r" && input[index] !== "\n") index++;
88
+ return index;
89
+ }
90
+ function readNewline(input, index) {
91
+ if (input[index] === "\r" && input[index + 1] === "\n") return index + 2;
92
+ if (input[index] === "\r") return index + 1;
93
+ if (input[index] === "\n") return index + 1;
94
+ return index;
95
+ }
96
+ function skipHorizontalWhitespace(input, index) {
97
+ while (input[index] === " " || input[index] === " ") index++;
98
+ return index;
99
+ }
100
+ function expandEnvValue(rawValue, lookup, substitute, options) {
101
+ let output = "";
102
+ for (let index = 0; index < rawValue.length; index++) {
103
+ const character = rawValue[index];
104
+ if (options.interpretEscapes && character === "\\") {
105
+ const next = rawValue[++index];
106
+ if (next === void 0) output += "\\";
107
+ else if (next === "n") output += "\n";
108
+ else if (next === "r") output += "\r";
109
+ else if (next === "t") output += " ";
110
+ else output += next;
111
+ continue;
112
+ }
113
+ if (!options.allowSubstitution) {
114
+ output += character;
115
+ continue;
116
+ }
117
+ if (character === "`") {
118
+ const end$1 = rawValue.indexOf("`", index + 1);
119
+ if (end$1 < 0) throw syntaxError(options.path, options.startLine, "unterminated command substitution");
120
+ const command = rawValue.slice(index + 1, end$1);
121
+ output += substitute?.(command) ?? "";
122
+ index = end$1;
123
+ continue;
124
+ }
125
+ if (character !== "$") {
126
+ output += character;
127
+ continue;
128
+ }
129
+ if (rawValue[index + 1] === "(") {
130
+ const end$1 = rawValue.indexOf(")", index + 2);
131
+ if (end$1 < 0) throw syntaxError(options.path, options.startLine, "unterminated command substitution");
132
+ const command = rawValue.slice(index + 2, end$1);
133
+ output += substitute?.(command) ?? "";
134
+ index = end$1;
135
+ continue;
136
+ }
137
+ if (rawValue[index + 1] === "{") {
138
+ const end$1 = rawValue.indexOf("}", index + 2);
139
+ if (end$1 < 0) throw syntaxError(options.path, options.startLine, "unterminated variable expansion");
140
+ const key$1 = rawValue.slice(index + 2, end$1);
141
+ output += lookup(key$1) ?? "";
142
+ index = end$1;
143
+ continue;
144
+ }
145
+ const nameStart = rawValue[index + 1];
146
+ if (nameStart === void 0 || !isEnvNameStart(nameStart)) {
147
+ output += character;
148
+ continue;
149
+ }
150
+ let end = index + 2;
151
+ while (end < rawValue.length && isEnvNamePart(rawValue[end])) end++;
152
+ const key = rawValue.slice(index + 1, end);
153
+ output += lookup(key) ?? "";
154
+ index = end - 1;
155
+ }
156
+ return output;
157
+ }
158
+ function parseQuotedEnvValue(input, index, quote, path, line, lookup, substitute) {
159
+ const valueStart = index + 1;
160
+ let cursor = valueStart;
161
+ while (cursor < input.length) {
162
+ const character = input[cursor];
163
+ if (character === "\\" && quote === "\"") {
164
+ cursor += 2;
165
+ continue;
166
+ }
167
+ if (character === quote) {
168
+ const rawValue = input.slice(valueStart, cursor);
169
+ const nextIndex = cursor + 1;
170
+ return {
171
+ value: quote === "'" ? rawValue : expandEnvValue(rawValue, lookup, substitute, {
172
+ path,
173
+ startLine: line,
174
+ interpretEscapes: true,
175
+ allowSubstitution: true
176
+ }),
177
+ nextIndex
178
+ };
179
+ }
180
+ cursor++;
181
+ }
182
+ throw syntaxError(path, line, "unterminated quoted value");
183
+ }
184
+ function parseUnquotedEnvValue(input, index, path, line, lookup, substitute) {
185
+ let cursor = index;
186
+ while (cursor < input.length && input[cursor] !== "\r" && input[cursor] !== "\n") {
187
+ if (input[cursor] === "#" && (cursor === index || /\s/u.test(input[cursor - 1]))) break;
188
+ cursor++;
189
+ }
190
+ const rawValue = input.slice(index, cursor).trimEnd();
191
+ return {
192
+ value: expandEnvValue(rawValue, lookup, substitute, {
193
+ path,
194
+ startLine: line,
195
+ interpretEscapes: false,
196
+ allowSubstitution: true
197
+ }),
198
+ nextIndex: cursor
199
+ };
200
+ }
201
+ function parseEnvFile(input, path, lookup, substitute, outerValues = /* @__PURE__ */ new Map()) {
202
+ const values = /* @__PURE__ */ new Map();
203
+ let index = input.charCodeAt(0) === 65279 ? 1 : 0;
204
+ while (index < input.length) {
205
+ const line = getLineNumber(input, index);
206
+ index = skipHorizontalWhitespace(input, index);
207
+ if (index >= input.length) break;
208
+ if (input[index] === "\r" || input[index] === "\n") {
209
+ index = readNewline(input, index);
210
+ continue;
211
+ }
212
+ if (input[index] === "#") {
213
+ index = readNewline(input, skipLine(input, index));
214
+ continue;
215
+ }
216
+ if (input.startsWith("export", index) && /\s/u.test(input[index + 6] ?? "")) index = skipHorizontalWhitespace(input, index + 6);
217
+ const keyStart = index;
218
+ if (!isEnvNameStart(input[index] ?? "")) throw syntaxError(path, line, "expected KEY=VALUE");
219
+ index++;
220
+ while (index < input.length && isEnvNamePart(input[index])) index++;
221
+ const key = input.slice(keyStart, index);
222
+ index = skipHorizontalWhitespace(input, index);
223
+ if (input[index] !== "=") throw syntaxError(path, line, "expected KEY=VALUE");
224
+ index = skipHorizontalWhitespace(input, index + 1);
225
+ const lineLookup = (lookupKey) => lookup(lookupKey) ?? values.get(lookupKey) ?? outerValues.get(lookupKey);
226
+ const parsed = input[index] === "'" || input[index] === "\"" ? parseQuotedEnvValue(input, index, input[index], path, line, lineLookup, substitute) : parseUnquotedEnvValue(input, index, path, line, lineLookup, substitute);
227
+ values.set(key, parsed.value);
228
+ index = skipHorizontalWhitespace(input, parsed.nextIndex);
229
+ if (input[index] === "#") index = skipLine(input, index);
230
+ else if (index < input.length && input[index] !== "\r" && input[index] !== "\n") throw syntaxError(path, line, "unexpected content after value");
231
+ index = readNewline(input, index);
232
+ }
233
+ return values;
234
+ }
235
+ function loadEnvFileValues(options, source) {
236
+ const values = /* @__PURE__ */ new Map();
237
+ for (const path of options.paths) {
238
+ const absolutePath = (0, node_path.resolve)(path);
239
+ try {
240
+ const contents = (0, node_fs.readFileSync)(absolutePath, "utf8");
241
+ const parsedValues = parseEnvFile(contents, absolutePath, source, options.substitute, values);
242
+ for (const [key, value] of parsedValues) values.set(key, value);
243
+ } catch (error) {
244
+ if (isErrnoException(error) && error.code === "ENOENT") continue;
245
+ throw error;
246
+ }
247
+ }
248
+ return values;
249
+ }
36
250
  /**
37
251
  * Creates an environment context for use with Optique runners.
38
252
  *
@@ -54,16 +268,26 @@ function defaultEnvSource(key) {
54
268
  * @returns A context that provides environment source annotations.
55
269
  * @throws {TypeError} If `prefix` is not a string.
56
270
  * @throws {TypeError} If `source` is not a function.
271
+ * @throws {TypeError} If `envFile` has an invalid shape.
272
+ * @throws {SyntaxError} If an `.env` file contains invalid syntax.
273
+ * @throws {Error} If an `.env` file cannot be read for a reason other than
274
+ * a missing file.
57
275
  * @since 1.0.0
58
276
  */
59
277
  function createEnvContext(options = {}) {
60
278
  const contextId = Symbol(`@optique/env context:${Math.random()}`);
61
279
  const rawSource = options.source;
62
- if (rawSource !== void 0 && typeof rawSource !== "function") throw new TypeError(`Expected source to be a function, but got: ${rawSource === null ? "null" : Array.isArray(rawSource) ? "array" : typeof rawSource}.`);
63
- const source = rawSource ?? defaultEnvSource;
280
+ if (rawSource !== void 0 && typeof rawSource !== "function") throw new TypeError(`Expected source to be a function, but got: ${getTypeName(rawSource)}.`);
281
+ const baseSource = rawSource ?? defaultEnvSource;
64
282
  const rawPrefix = options.prefix;
65
- if (rawPrefix !== void 0 && typeof rawPrefix !== "string") throw new TypeError(`Expected prefix to be a string, but got: ${rawPrefix === null ? "null" : Array.isArray(rawPrefix) ? "array" : typeof rawPrefix}.`);
283
+ if (rawPrefix !== void 0 && typeof rawPrefix !== "string") throw new TypeError(`Expected prefix to be a string, but got: ${getTypeName(rawPrefix)}.`);
66
284
  const prefix = rawPrefix ?? "";
285
+ const envFileOptions = normalizeEnvFileOptions(options.envFile);
286
+ const envFileValues = loadEnvFileValues(envFileOptions, baseSource);
287
+ const source = (key) => {
288
+ const value = baseSource(key);
289
+ return value === void 0 ? envFileValues.get(key) : value;
290
+ };
67
291
  return {
68
292
  id: contextId,
69
293
  prefix,
@@ -124,6 +348,17 @@ function bindEnv(parser, options) {
124
348
  return value != null && typeof value === "object" && envBindStateKey in value;
125
349
  }
126
350
  const deferPromptUntilConfigResolves = parser.shouldDeferCompletion;
351
+ function hasEnvFallback(state) {
352
+ if (options.default !== void 0) return true;
353
+ const annotations = (0, __optique_core_annotations.getAnnotations)(state);
354
+ const sourceData = annotations?.[options.context.id];
355
+ if (sourceData == null) return false;
356
+ return sourceData.source(`${sourceData.prefix}${options.key}`) !== void 0;
357
+ }
358
+ function getInnerState(state) {
359
+ if (!isEnvBindState(state)) return state;
360
+ return state.cliState === void 0 ? (0, __optique_core_extension.inheritAnnotations)(state, parser.initialState) : state.cliState;
361
+ }
127
362
  const boundParser = {
128
363
  mode: parser.mode,
129
364
  $valueType: parser.$valueType,
@@ -136,8 +371,17 @@ function bindEnv(parser, options) {
136
371
  leadingNames: parser.leadingNames,
137
372
  acceptingAnyToken: parser.acceptingAnyToken,
138
373
  initialState: parser.initialState,
374
+ canSkip(state, exec) {
375
+ if (isEnvBindState(state)) {
376
+ if (state.hasCliValue) return parser.canSkip?.(state.cliState, exec) === true;
377
+ if (hasEnvFallback(state)) return true;
378
+ return parser.canSkip?.(getInnerState(state), exec) === true;
379
+ }
380
+ if (hasEnvFallback(state)) return true;
381
+ return parser.canSkip?.(state, exec) === true;
382
+ },
139
383
  getSuggestRuntimeNodes(state, path) {
140
- const innerState = isEnvBindState(state) ? state.cliState === void 0 ? parser.initialState : state.cliState : state;
384
+ const innerState = getInnerState(state);
141
385
  return (0, __optique_core_extension.delegateSuggestNodes)(parser, boundParser, state, path, innerState);
142
386
  },
143
387
  parse: (context) => {
package/dist/index.d.cts CHANGED
@@ -11,6 +11,37 @@ import { NonEmptyString, ValueParser } from "@optique/core/valueparser";
11
11
  * @since 1.0.0
12
12
  */
13
13
  type EnvSource = (key: string) => string | undefined;
14
+ /**
15
+ * Function type for command substitution in `.env` file values.
16
+ *
17
+ * @param command Command text captured from `$(...)` or backtick substitution.
18
+ * @returns Replacement text, or `undefined` to substitute an empty string.
19
+ * @since 1.1.0
20
+ */
21
+ type EnvFileSubstitute = (command: string) => string | undefined;
22
+ /**
23
+ * Path option for `.env` files loaded by {@link createEnvContext}.
24
+ *
25
+ * @since 1.1.0
26
+ */
27
+ type EnvFilePaths = boolean | string | readonly string[];
28
+ /**
29
+ * Options for loading `.env` files.
30
+ *
31
+ * @since 1.1.0
32
+ */
33
+ interface EnvFileOptions {
34
+ /**
35
+ * Path or paths to `.env` files. When `true` or omitted, loads `.env`
36
+ * from the current working directory. Missing files are skipped.
37
+ */
38
+ readonly paths?: EnvFilePaths;
39
+ /**
40
+ * Optional command substitution hook for `$(...)` and backtick forms.
41
+ * Optique never executes commands by itself.
42
+ */
43
+ readonly substitute?: EnvFileSubstitute;
44
+ }
14
45
  /**
15
46
  * Context for environment-variable-based fallback values.
16
47
  *
@@ -44,6 +75,20 @@ interface EnvContextOptions {
44
75
  * @default Runtime-specific source (`Deno.env.get` or `process.env`)
45
76
  */
46
77
  readonly source?: EnvSource;
78
+ /**
79
+ * Path(s) to `.env` files to load as an internal fallback layer.
80
+ *
81
+ * When `true`, searches for `.env` in the current working directory.
82
+ * When a string or array of strings, loads those explicit files.
83
+ * Files are loaded in order; later files override earlier files.
84
+ *
85
+ * Values loaded from `.env` files do not mutate `process.env` or
86
+ * `Deno.env`, and the real environment source remains higher priority.
87
+ *
88
+ * @default undefined
89
+ * @since 1.1.0
90
+ */
91
+ readonly envFile?: EnvFilePaths | EnvFileOptions;
47
92
  }
48
93
  /**
49
94
  * Creates an environment context for use with Optique runners.
@@ -66,6 +111,10 @@ interface EnvContextOptions {
66
111
  * @returns A context that provides environment source annotations.
67
112
  * @throws {TypeError} If `prefix` is not a string.
68
113
  * @throws {TypeError} If `source` is not a function.
114
+ * @throws {TypeError} If `envFile` has an invalid shape.
115
+ * @throws {SyntaxError} If an `.env` file contains invalid syntax.
116
+ * @throws {Error} If an `.env` file cannot be read for a reason other than
117
+ * a missing file.
69
118
  * @since 1.0.0
70
119
  */
71
120
  declare function createEnvContext(options?: EnvContextOptions): EnvContext;
@@ -172,4 +221,4 @@ interface BoolOptions {
172
221
  */
173
222
  declare function bool(options?: BoolOptions): ValueParser<"sync", boolean>;
174
223
  //#endregion
175
- export { BindEnvOptions, BoolOptions, EnvContext, EnvContextOptions, EnvSource, bindEnv, bool, createEnvContext };
224
+ export { BindEnvOptions, BoolOptions, EnvContext, EnvContextOptions, EnvFileOptions, EnvFilePaths, EnvFileSubstitute, EnvSource, bindEnv, bool, createEnvContext };
package/dist/index.d.ts CHANGED
@@ -11,6 +11,37 @@ import { Mode, Parser } from "@optique/core/parser";
11
11
  * @since 1.0.0
12
12
  */
13
13
  type EnvSource = (key: string) => string | undefined;
14
+ /**
15
+ * Function type for command substitution in `.env` file values.
16
+ *
17
+ * @param command Command text captured from `$(...)` or backtick substitution.
18
+ * @returns Replacement text, or `undefined` to substitute an empty string.
19
+ * @since 1.1.0
20
+ */
21
+ type EnvFileSubstitute = (command: string) => string | undefined;
22
+ /**
23
+ * Path option for `.env` files loaded by {@link createEnvContext}.
24
+ *
25
+ * @since 1.1.0
26
+ */
27
+ type EnvFilePaths = boolean | string | readonly string[];
28
+ /**
29
+ * Options for loading `.env` files.
30
+ *
31
+ * @since 1.1.0
32
+ */
33
+ interface EnvFileOptions {
34
+ /**
35
+ * Path or paths to `.env` files. When `true` or omitted, loads `.env`
36
+ * from the current working directory. Missing files are skipped.
37
+ */
38
+ readonly paths?: EnvFilePaths;
39
+ /**
40
+ * Optional command substitution hook for `$(...)` and backtick forms.
41
+ * Optique never executes commands by itself.
42
+ */
43
+ readonly substitute?: EnvFileSubstitute;
44
+ }
14
45
  /**
15
46
  * Context for environment-variable-based fallback values.
16
47
  *
@@ -44,6 +75,20 @@ interface EnvContextOptions {
44
75
  * @default Runtime-specific source (`Deno.env.get` or `process.env`)
45
76
  */
46
77
  readonly source?: EnvSource;
78
+ /**
79
+ * Path(s) to `.env` files to load as an internal fallback layer.
80
+ *
81
+ * When `true`, searches for `.env` in the current working directory.
82
+ * When a string or array of strings, loads those explicit files.
83
+ * Files are loaded in order; later files override earlier files.
84
+ *
85
+ * Values loaded from `.env` files do not mutate `process.env` or
86
+ * `Deno.env`, and the real environment source remains higher priority.
87
+ *
88
+ * @default undefined
89
+ * @since 1.1.0
90
+ */
91
+ readonly envFile?: EnvFilePaths | EnvFileOptions;
47
92
  }
48
93
  /**
49
94
  * Creates an environment context for use with Optique runners.
@@ -66,6 +111,10 @@ interface EnvContextOptions {
66
111
  * @returns A context that provides environment source annotations.
67
112
  * @throws {TypeError} If `prefix` is not a string.
68
113
  * @throws {TypeError} If `source` is not a function.
114
+ * @throws {TypeError} If `envFile` has an invalid shape.
115
+ * @throws {SyntaxError} If an `.env` file contains invalid syntax.
116
+ * @throws {Error} If an `.env` file cannot be read for a reason other than
117
+ * a missing file.
69
118
  * @since 1.0.0
70
119
  */
71
120
  declare function createEnvContext(options?: EnvContextOptions): EnvContext;
@@ -172,4 +221,4 @@ interface BoolOptions {
172
221
  */
173
222
  declare function bool(options?: BoolOptions): ValueParser<"sync", boolean>;
174
223
  //#endregion
175
- export { BindEnvOptions, BoolOptions, EnvContext, EnvContextOptions, EnvSource, bindEnv, bool, createEnvContext };
224
+ export { BindEnvOptions, BoolOptions, EnvContext, EnvContextOptions, EnvFileOptions, EnvFilePaths, EnvFileSubstitute, EnvSource, bindEnv, bool, createEnvContext };
package/dist/index.js CHANGED
@@ -1,15 +1,229 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { resolve } from "node:path";
1
3
  import { getAnnotations } from "@optique/core/annotations";
2
- import { defineTraits, delegateSuggestNodes, dispatchByMode, getTraits, injectAnnotations, isInjectedAnnotationState, mapModeValue, mapSourceMetadata, wrapForMode } from "@optique/core/extension";
4
+ import { defineTraits, delegateSuggestNodes, dispatchByMode, getTraits, inheritAnnotations, injectAnnotations, isInjectedAnnotationState, mapModeValue, mapSourceMetadata, wrapForMode } from "@optique/core/extension";
3
5
  import { envVar, message, valueSet } from "@optique/core/message";
4
6
  import { ensureNonEmptyString, isValueParser } from "@optique/core/valueparser";
5
7
 
6
8
  //#region src/index.ts
9
+ function getTypeName(value) {
10
+ if (value === null) return "null";
11
+ if (Array.isArray(value)) return "array";
12
+ return typeof value;
13
+ }
7
14
  function defaultEnvSource(key) {
8
15
  const denoGlobal = globalThis.Deno;
9
16
  if (typeof denoGlobal?.env?.get === "function") return denoGlobal.env.get(key);
10
17
  const processGlobal = globalThis.process;
11
18
  return processGlobal?.env?.[key];
12
19
  }
20
+ function isErrnoException(error) {
21
+ return error != null && typeof error === "object" && "code" in error && typeof error.code === "string";
22
+ }
23
+ function normalizeEnvFilePaths(paths) {
24
+ if (paths === false) return [];
25
+ if (paths === true) return [".env"];
26
+ if (typeof paths === "string") return [paths];
27
+ if (Array.isArray(paths)) {
28
+ for (const path of paths) if (typeof path !== "string") throw new TypeError(`Expected envFile paths to be strings, but got: ${getTypeName(path)}.`);
29
+ return paths;
30
+ }
31
+ throw new TypeError(`Expected envFile.paths to be a boolean, string, array, or undefined, but got: ${getTypeName(paths)}.`);
32
+ }
33
+ function normalizeEnvFileOptions(envFile) {
34
+ if (envFile === void 0 || envFile === false) return { paths: [] };
35
+ if (envFile === true || typeof envFile === "string" || Array.isArray(envFile)) return { paths: normalizeEnvFilePaths(envFile) };
36
+ if (envFile == null || typeof envFile !== "object") throw new TypeError(`Expected envFile to be a boolean, string, array, or object, but got: ${getTypeName(envFile)}.`);
37
+ const options = envFile;
38
+ const rawSubstitute = options.substitute;
39
+ if (rawSubstitute !== void 0 && typeof rawSubstitute !== "function") throw new TypeError(`Expected envFile.substitute to be a function or undefined, but got: ${getTypeName(rawSubstitute)}.`);
40
+ return {
41
+ paths: normalizeEnvFilePaths(options.paths ?? true),
42
+ ...rawSubstitute === void 0 ? {} : { substitute: rawSubstitute }
43
+ };
44
+ }
45
+ function isEnvNameStart(character) {
46
+ return /[A-Za-z_]/u.test(character);
47
+ }
48
+ function isEnvNamePart(character) {
49
+ return /[A-Za-z0-9_]/u.test(character);
50
+ }
51
+ function getLineNumber(input, offset) {
52
+ let line = 1;
53
+ for (let index = 0; index < offset; index++) if (input[index] === "\n") line++;
54
+ else if (input[index] === "\r") {
55
+ line++;
56
+ if (input[index + 1] === "\n") index++;
57
+ }
58
+ return line;
59
+ }
60
+ function syntaxError(path, line, messageText) {
61
+ return /* @__PURE__ */ new SyntaxError(`Invalid .env syntax in ${path} at line ${line}: ${messageText}.`);
62
+ }
63
+ function skipLine(input, index) {
64
+ while (index < input.length && input[index] !== "\r" && input[index] !== "\n") index++;
65
+ return index;
66
+ }
67
+ function readNewline(input, index) {
68
+ if (input[index] === "\r" && input[index + 1] === "\n") return index + 2;
69
+ if (input[index] === "\r") return index + 1;
70
+ if (input[index] === "\n") return index + 1;
71
+ return index;
72
+ }
73
+ function skipHorizontalWhitespace(input, index) {
74
+ while (input[index] === " " || input[index] === " ") index++;
75
+ return index;
76
+ }
77
+ function expandEnvValue(rawValue, lookup, substitute, options) {
78
+ let output = "";
79
+ for (let index = 0; index < rawValue.length; index++) {
80
+ const character = rawValue[index];
81
+ if (options.interpretEscapes && character === "\\") {
82
+ const next = rawValue[++index];
83
+ if (next === void 0) output += "\\";
84
+ else if (next === "n") output += "\n";
85
+ else if (next === "r") output += "\r";
86
+ else if (next === "t") output += " ";
87
+ else output += next;
88
+ continue;
89
+ }
90
+ if (!options.allowSubstitution) {
91
+ output += character;
92
+ continue;
93
+ }
94
+ if (character === "`") {
95
+ const end$1 = rawValue.indexOf("`", index + 1);
96
+ if (end$1 < 0) throw syntaxError(options.path, options.startLine, "unterminated command substitution");
97
+ const command = rawValue.slice(index + 1, end$1);
98
+ output += substitute?.(command) ?? "";
99
+ index = end$1;
100
+ continue;
101
+ }
102
+ if (character !== "$") {
103
+ output += character;
104
+ continue;
105
+ }
106
+ if (rawValue[index + 1] === "(") {
107
+ const end$1 = rawValue.indexOf(")", index + 2);
108
+ if (end$1 < 0) throw syntaxError(options.path, options.startLine, "unterminated command substitution");
109
+ const command = rawValue.slice(index + 2, end$1);
110
+ output += substitute?.(command) ?? "";
111
+ index = end$1;
112
+ continue;
113
+ }
114
+ if (rawValue[index + 1] === "{") {
115
+ const end$1 = rawValue.indexOf("}", index + 2);
116
+ if (end$1 < 0) throw syntaxError(options.path, options.startLine, "unterminated variable expansion");
117
+ const key$1 = rawValue.slice(index + 2, end$1);
118
+ output += lookup(key$1) ?? "";
119
+ index = end$1;
120
+ continue;
121
+ }
122
+ const nameStart = rawValue[index + 1];
123
+ if (nameStart === void 0 || !isEnvNameStart(nameStart)) {
124
+ output += character;
125
+ continue;
126
+ }
127
+ let end = index + 2;
128
+ while (end < rawValue.length && isEnvNamePart(rawValue[end])) end++;
129
+ const key = rawValue.slice(index + 1, end);
130
+ output += lookup(key) ?? "";
131
+ index = end - 1;
132
+ }
133
+ return output;
134
+ }
135
+ function parseQuotedEnvValue(input, index, quote, path, line, lookup, substitute) {
136
+ const valueStart = index + 1;
137
+ let cursor = valueStart;
138
+ while (cursor < input.length) {
139
+ const character = input[cursor];
140
+ if (character === "\\" && quote === "\"") {
141
+ cursor += 2;
142
+ continue;
143
+ }
144
+ if (character === quote) {
145
+ const rawValue = input.slice(valueStart, cursor);
146
+ const nextIndex = cursor + 1;
147
+ return {
148
+ value: quote === "'" ? rawValue : expandEnvValue(rawValue, lookup, substitute, {
149
+ path,
150
+ startLine: line,
151
+ interpretEscapes: true,
152
+ allowSubstitution: true
153
+ }),
154
+ nextIndex
155
+ };
156
+ }
157
+ cursor++;
158
+ }
159
+ throw syntaxError(path, line, "unterminated quoted value");
160
+ }
161
+ function parseUnquotedEnvValue(input, index, path, line, lookup, substitute) {
162
+ let cursor = index;
163
+ while (cursor < input.length && input[cursor] !== "\r" && input[cursor] !== "\n") {
164
+ if (input[cursor] === "#" && (cursor === index || /\s/u.test(input[cursor - 1]))) break;
165
+ cursor++;
166
+ }
167
+ const rawValue = input.slice(index, cursor).trimEnd();
168
+ return {
169
+ value: expandEnvValue(rawValue, lookup, substitute, {
170
+ path,
171
+ startLine: line,
172
+ interpretEscapes: false,
173
+ allowSubstitution: true
174
+ }),
175
+ nextIndex: cursor
176
+ };
177
+ }
178
+ function parseEnvFile(input, path, lookup, substitute, outerValues = /* @__PURE__ */ new Map()) {
179
+ const values = /* @__PURE__ */ new Map();
180
+ let index = input.charCodeAt(0) === 65279 ? 1 : 0;
181
+ while (index < input.length) {
182
+ const line = getLineNumber(input, index);
183
+ index = skipHorizontalWhitespace(input, index);
184
+ if (index >= input.length) break;
185
+ if (input[index] === "\r" || input[index] === "\n") {
186
+ index = readNewline(input, index);
187
+ continue;
188
+ }
189
+ if (input[index] === "#") {
190
+ index = readNewline(input, skipLine(input, index));
191
+ continue;
192
+ }
193
+ if (input.startsWith("export", index) && /\s/u.test(input[index + 6] ?? "")) index = skipHorizontalWhitespace(input, index + 6);
194
+ const keyStart = index;
195
+ if (!isEnvNameStart(input[index] ?? "")) throw syntaxError(path, line, "expected KEY=VALUE");
196
+ index++;
197
+ while (index < input.length && isEnvNamePart(input[index])) index++;
198
+ const key = input.slice(keyStart, index);
199
+ index = skipHorizontalWhitespace(input, index);
200
+ if (input[index] !== "=") throw syntaxError(path, line, "expected KEY=VALUE");
201
+ index = skipHorizontalWhitespace(input, index + 1);
202
+ const lineLookup = (lookupKey) => lookup(lookupKey) ?? values.get(lookupKey) ?? outerValues.get(lookupKey);
203
+ const parsed = input[index] === "'" || input[index] === "\"" ? parseQuotedEnvValue(input, index, input[index], path, line, lineLookup, substitute) : parseUnquotedEnvValue(input, index, path, line, lineLookup, substitute);
204
+ values.set(key, parsed.value);
205
+ index = skipHorizontalWhitespace(input, parsed.nextIndex);
206
+ if (input[index] === "#") index = skipLine(input, index);
207
+ else if (index < input.length && input[index] !== "\r" && input[index] !== "\n") throw syntaxError(path, line, "unexpected content after value");
208
+ index = readNewline(input, index);
209
+ }
210
+ return values;
211
+ }
212
+ function loadEnvFileValues(options, source) {
213
+ const values = /* @__PURE__ */ new Map();
214
+ for (const path of options.paths) {
215
+ const absolutePath = resolve(path);
216
+ try {
217
+ const contents = readFileSync(absolutePath, "utf8");
218
+ const parsedValues = parseEnvFile(contents, absolutePath, source, options.substitute, values);
219
+ for (const [key, value] of parsedValues) values.set(key, value);
220
+ } catch (error) {
221
+ if (isErrnoException(error) && error.code === "ENOENT") continue;
222
+ throw error;
223
+ }
224
+ }
225
+ return values;
226
+ }
13
227
  /**
14
228
  * Creates an environment context for use with Optique runners.
15
229
  *
@@ -31,16 +245,26 @@ function defaultEnvSource(key) {
31
245
  * @returns A context that provides environment source annotations.
32
246
  * @throws {TypeError} If `prefix` is not a string.
33
247
  * @throws {TypeError} If `source` is not a function.
248
+ * @throws {TypeError} If `envFile` has an invalid shape.
249
+ * @throws {SyntaxError} If an `.env` file contains invalid syntax.
250
+ * @throws {Error} If an `.env` file cannot be read for a reason other than
251
+ * a missing file.
34
252
  * @since 1.0.0
35
253
  */
36
254
  function createEnvContext(options = {}) {
37
255
  const contextId = Symbol(`@optique/env context:${Math.random()}`);
38
256
  const rawSource = options.source;
39
- if (rawSource !== void 0 && typeof rawSource !== "function") throw new TypeError(`Expected source to be a function, but got: ${rawSource === null ? "null" : Array.isArray(rawSource) ? "array" : typeof rawSource}.`);
40
- const source = rawSource ?? defaultEnvSource;
257
+ if (rawSource !== void 0 && typeof rawSource !== "function") throw new TypeError(`Expected source to be a function, but got: ${getTypeName(rawSource)}.`);
258
+ const baseSource = rawSource ?? defaultEnvSource;
41
259
  const rawPrefix = options.prefix;
42
- if (rawPrefix !== void 0 && typeof rawPrefix !== "string") throw new TypeError(`Expected prefix to be a string, but got: ${rawPrefix === null ? "null" : Array.isArray(rawPrefix) ? "array" : typeof rawPrefix}.`);
260
+ if (rawPrefix !== void 0 && typeof rawPrefix !== "string") throw new TypeError(`Expected prefix to be a string, but got: ${getTypeName(rawPrefix)}.`);
43
261
  const prefix = rawPrefix ?? "";
262
+ const envFileOptions = normalizeEnvFileOptions(options.envFile);
263
+ const envFileValues = loadEnvFileValues(envFileOptions, baseSource);
264
+ const source = (key) => {
265
+ const value = baseSource(key);
266
+ return value === void 0 ? envFileValues.get(key) : value;
267
+ };
44
268
  return {
45
269
  id: contextId,
46
270
  prefix,
@@ -101,6 +325,17 @@ function bindEnv(parser, options) {
101
325
  return value != null && typeof value === "object" && envBindStateKey in value;
102
326
  }
103
327
  const deferPromptUntilConfigResolves = parser.shouldDeferCompletion;
328
+ function hasEnvFallback(state) {
329
+ if (options.default !== void 0) return true;
330
+ const annotations = getAnnotations(state);
331
+ const sourceData = annotations?.[options.context.id];
332
+ if (sourceData == null) return false;
333
+ return sourceData.source(`${sourceData.prefix}${options.key}`) !== void 0;
334
+ }
335
+ function getInnerState(state) {
336
+ if (!isEnvBindState(state)) return state;
337
+ return state.cliState === void 0 ? inheritAnnotations(state, parser.initialState) : state.cliState;
338
+ }
104
339
  const boundParser = {
105
340
  mode: parser.mode,
106
341
  $valueType: parser.$valueType,
@@ -113,8 +348,17 @@ function bindEnv(parser, options) {
113
348
  leadingNames: parser.leadingNames,
114
349
  acceptingAnyToken: parser.acceptingAnyToken,
115
350
  initialState: parser.initialState,
351
+ canSkip(state, exec) {
352
+ if (isEnvBindState(state)) {
353
+ if (state.hasCliValue) return parser.canSkip?.(state.cliState, exec) === true;
354
+ if (hasEnvFallback(state)) return true;
355
+ return parser.canSkip?.(getInnerState(state), exec) === true;
356
+ }
357
+ if (hasEnvFallback(state)) return true;
358
+ return parser.canSkip?.(state, exec) === true;
359
+ },
116
360
  getSuggestRuntimeNodes(state, path) {
117
- const innerState = isEnvBindState(state) ? state.cliState === void 0 ? parser.initialState : state.cliState : state;
361
+ const innerState = getInnerState(state);
118
362
  return delegateSuggestNodes(parser, boundParser, state, path, innerState);
119
363
  },
120
364
  parse: (context) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@optique/env",
3
- "version": "1.1.0-dev.2087",
3
+ "version": "1.1.0-dev.2146",
4
4
  "description": "Environment variable support for Optique",
5
5
  "keywords": [
6
6
  "CLI",
@@ -52,22 +52,29 @@
52
52
  "require": "./dist/index.cjs"
53
53
  }
54
54
  },
55
+ "imports": {
56
+ "#src/*.ts": {
57
+ "node": "./dist/*.js",
58
+ "default": "./src/*.ts"
59
+ }
60
+ },
55
61
  "sideEffects": false,
56
62
  "dependencies": {
57
- "@optique/core": "1.1.0-dev.2087+a3bd6720"
63
+ "@optique/core": "1.1.0-dev.2146+5fd0a75e"
58
64
  },
59
65
  "devDependencies": {
60
66
  "@types/node": "^24.0.0",
61
67
  "fast-check": "^4.7.0",
62
68
  "tsdown": "^0.13.0",
63
- "typescript": "^5.8.3"
69
+ "typescript": "^5.8.3",
70
+ "@optique/config": "1.1.0-dev.2146+5fd0a75e"
64
71
  },
65
72
  "scripts": {
66
73
  "build": "tsdown",
67
74
  "prepublish": "tsdown",
68
- "test": "node --experimental-transform-types --test",
75
+ "test": "node --test",
69
76
  "test:bun": "bun test",
70
- "test:deno": "deno test",
71
- "test-all": "tsdown && node --experimental-transform-types --test && bun test && deno test"
77
+ "test:deno": "deno test --allow-read --allow-write --allow-env",
78
+ "test-all": "tsdown && node --test && bun test && deno test --allow-read --allow-write --allow-env"
72
79
  }
73
80
  }