@optique/env 1.1.0-dev.2096 → 1.1.0-dev.2148
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 +6 -2
- package/dist/index.cjs +227 -3
- package/dist/index.d.cts +50 -1
- package/dist/index.d.ts +50 -1
- package/dist/index.js +227 -3
- package/package.json +13 -6
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({
|
|
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: ${
|
|
63
|
-
const
|
|
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: ${
|
|
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,
|
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
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: ${
|
|
40
|
-
const
|
|
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: ${
|
|
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,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@optique/env",
|
|
3
|
-
"version": "1.1.0-dev.
|
|
3
|
+
"version": "1.1.0-dev.2148",
|
|
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.
|
|
63
|
+
"@optique/core": "1.1.0-dev.2148+ab56ac96"
|
|
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.2148+ab56ac96"
|
|
64
71
|
},
|
|
65
72
|
"scripts": {
|
|
66
73
|
"build": "tsdown",
|
|
67
74
|
"prepublish": "tsdown",
|
|
68
|
-
"test": "node --
|
|
75
|
+
"test": "node --test",
|
|
69
76
|
"test:bun": "bun test",
|
|
70
|
-
"test:deno": "deno test",
|
|
71
|
-
"test-all": "tsdown && node --
|
|
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
|
}
|