@outfitter/config 0.1.0-rc.3 → 0.2.0
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 +2 -1
- package/dist/index.d.ts +46 -3
- package/dist/index.js +97 -18
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -68,7 +68,7 @@ if (result.isOk()) {
|
|
|
68
68
|
2. `$XDG_CONFIG_HOME/{appName}/config.{ext}`
|
|
69
69
|
3. `~/.config/{appName}/config.{ext}`
|
|
70
70
|
|
|
71
|
-
**File Format Preference:** `.toml` > `.yaml` > `.yml` > `.json` > `.json5`
|
|
71
|
+
**File Format Preference:** `.toml` > `.yaml` > `.yml` > `.json` > `.jsonc` > `.json5`
|
|
72
72
|
|
|
73
73
|
**Returns:** `Result<T, NotFoundError | ValidationError | ParseError>`
|
|
74
74
|
|
|
@@ -267,6 +267,7 @@ Higher precedence sources override lower ones. Nested objects are deep-merged.
|
|
|
267
267
|
| `.toml` | smol-toml | Preferred for configuration |
|
|
268
268
|
| `.yaml`, `.yml` | yaml | YAML anchors/aliases supported |
|
|
269
269
|
| `.json` | JSON.parse | Strict parsing |
|
|
270
|
+
| `.jsonc` | json5 | JSON with comments and trailing commas |
|
|
270
271
|
| `.json5` | json5 | Comments and trailing commas allowed |
|
|
271
272
|
|
|
272
273
|
---
|
package/dist/index.d.ts
CHANGED
|
@@ -171,6 +171,19 @@ declare const ParseErrorBase: TaggedErrorClass<"ParseError", ParseErrorFields>;
|
|
|
171
171
|
declare class ParseError extends ParseErrorBase {
|
|
172
172
|
readonly category: "validation";
|
|
173
173
|
}
|
|
174
|
+
type CircularExtendsErrorFields = {
|
|
175
|
+
/** Human-readable error message */
|
|
176
|
+
message: string;
|
|
177
|
+
/** The config file paths that form the circular reference */
|
|
178
|
+
chain: string[];
|
|
179
|
+
};
|
|
180
|
+
declare const CircularExtendsErrorBase: TaggedErrorClass<"CircularExtendsError", CircularExtendsErrorFields>;
|
|
181
|
+
/**
|
|
182
|
+
* Error thrown when a circular extends reference is detected.
|
|
183
|
+
*/
|
|
184
|
+
declare class CircularExtendsError extends CircularExtendsErrorBase {
|
|
185
|
+
readonly category: "validation";
|
|
186
|
+
}
|
|
174
187
|
/**
|
|
175
188
|
* Get the XDG config directory for an application.
|
|
176
189
|
*
|
|
@@ -297,6 +310,7 @@ declare function deepMerge<T extends object>(target: T, source: Partial<T>): T;
|
|
|
297
310
|
* - `.toml` - Parsed with smol-toml (preferred for config)
|
|
298
311
|
* - `.yaml`, `.yml` - Parsed with yaml (merge key support enabled)
|
|
299
312
|
* - `.json` - Parsed with strict JSON.parse
|
|
313
|
+
* - `.jsonc` - Parsed with json5 compatibility (comments/trailing commas)
|
|
300
314
|
* - `.json5` - Parsed with json5 (comments and trailing commas allowed)
|
|
301
315
|
*
|
|
302
316
|
* @param content - Raw file content to parse
|
|
@@ -425,7 +439,7 @@ interface LoadConfigOptions {
|
|
|
425
439
|
* 2. `$XDG_CONFIG_HOME/{appName}/config.{ext}`
|
|
426
440
|
* 3. `~/.config/{appName}/config.{ext}`
|
|
427
441
|
*
|
|
428
|
-
* File format preference: `.toml` > `.yaml` > `.yml` > `.json` > `.json5`
|
|
442
|
+
* File format preference: `.toml` > `.yaml` > `.yml` > `.json` > `.jsonc` > `.json5`
|
|
429
443
|
*
|
|
430
444
|
* @typeParam T - The configuration type (inferred from schema)
|
|
431
445
|
* @param appName - Application name for XDG directory lookup
|
|
@@ -465,5 +479,34 @@ interface LoadConfigOptions {
|
|
|
465
479
|
* });
|
|
466
480
|
* ```
|
|
467
481
|
*/
|
|
468
|
-
declare function loadConfig<T>(appName: string, schema: ZodSchema<T>, options?: LoadConfigOptions): Result<T, InstanceType<typeof NotFoundError> | InstanceType<typeof ValidationError> | InstanceType<typeof ParseError>>;
|
|
469
|
-
|
|
482
|
+
declare function loadConfig<T>(appName: string, schema: ZodSchema<T>, options?: LoadConfigOptions): Result<T, InstanceType<typeof NotFoundError> | InstanceType<typeof ValidationError> | InstanceType<typeof ParseError> | InstanceType<typeof CircularExtendsError>>;
|
|
483
|
+
/**
|
|
484
|
+
* Map environment variables to config object based on prefix.
|
|
485
|
+
*
|
|
486
|
+
* Environment variables are mapped as follows:
|
|
487
|
+
* - `PREFIX_KEY` -> `{ key: value }`
|
|
488
|
+
* - `PREFIX_NESTED__KEY` -> `{ nested: { key: value } }`
|
|
489
|
+
*
|
|
490
|
+
* Only returns values for keys that have matching env vars set.
|
|
491
|
+
* Values are returned as strings; use Zod coercion for type conversion.
|
|
492
|
+
*
|
|
493
|
+
* @param prefix - Environment variable prefix (e.g., "MYAPP")
|
|
494
|
+
* @param schema - Zod schema to determine valid keys (optional, for filtering)
|
|
495
|
+
* @returns Partial config object with mapped values
|
|
496
|
+
*
|
|
497
|
+
* @example
|
|
498
|
+
* ```typescript
|
|
499
|
+
* // With MYAPP_PORT=8080 and MYAPP_DB__HOST=localhost
|
|
500
|
+
* const schema = z.object({
|
|
501
|
+
* port: z.coerce.number(),
|
|
502
|
+
* db: z.object({ host: z.string() }),
|
|
503
|
+
* });
|
|
504
|
+
*
|
|
505
|
+
* const envConfig = mapEnvToConfig("MYAPP", schema);
|
|
506
|
+
* // { port: "8080", db: { host: "localhost" } }
|
|
507
|
+
*
|
|
508
|
+
* const result = resolveConfig(schema, { env: envConfig });
|
|
509
|
+
* ```
|
|
510
|
+
*/
|
|
511
|
+
declare function mapEnvToConfig<T>(prefix: string, _schema?: ZodSchema<T>): Partial<T>;
|
|
512
|
+
export { resolveConfig, portSchema, parseEnv, parseConfigFile, optionalBooleanSchema, mapEnvToConfig, loadConfig, getStateDir, getEnvBoolean, getDataDir, getConfigDir, getCacheDir, env, deepMerge, booleanSchema, ParseError, LoadConfigOptions, Env, ConfigSources, CircularExtendsError };
|
package/dist/index.js
CHANGED
|
@@ -8069,7 +8069,7 @@ function getEnvBoolean(key) {
|
|
|
8069
8069
|
// src/index.ts
|
|
8070
8070
|
var import_json5 = __toESM(require_lib(), 1);
|
|
8071
8071
|
import { existsSync, readFileSync } from "node:fs";
|
|
8072
|
-
import { join } from "node:path";
|
|
8072
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
8073
8073
|
import {
|
|
8074
8074
|
NotFoundError,
|
|
8075
8075
|
Result,
|
|
@@ -9070,6 +9070,11 @@ var ParseErrorBase = TaggedError("ParseError")();
|
|
|
9070
9070
|
class ParseError extends ParseErrorBase {
|
|
9071
9071
|
category = "validation";
|
|
9072
9072
|
}
|
|
9073
|
+
var CircularExtendsErrorBase = TaggedError("CircularExtendsError")();
|
|
9074
|
+
|
|
9075
|
+
class CircularExtendsError extends CircularExtendsErrorBase {
|
|
9076
|
+
category = "validation";
|
|
9077
|
+
}
|
|
9073
9078
|
function getConfigDir(appName) {
|
|
9074
9079
|
const xdgConfigHome = process.env["XDG_CONFIG_HOME"];
|
|
9075
9080
|
const home = process.env["HOME"] ?? "";
|
|
@@ -9154,6 +9159,7 @@ function parseConfigFile(content, filename) {
|
|
|
9154
9159
|
const parsed = JSON.parse(content);
|
|
9155
9160
|
return Result.ok(parsed);
|
|
9156
9161
|
}
|
|
9162
|
+
case "jsonc":
|
|
9157
9163
|
case "json5": {
|
|
9158
9164
|
const parsed = import_json5.default.parse(content);
|
|
9159
9165
|
return Result.ok(parsed);
|
|
@@ -9201,7 +9207,7 @@ function resolveConfig(schema, sources) {
|
|
|
9201
9207
|
}
|
|
9202
9208
|
return Result.ok(parseResult.data);
|
|
9203
9209
|
}
|
|
9204
|
-
var CONFIG_EXTENSIONS = ["toml", "yaml", "yml", "json", "json5"];
|
|
9210
|
+
var CONFIG_EXTENSIONS = ["toml", "yaml", "yml", "json", "jsonc", "json5"];
|
|
9205
9211
|
function findConfigFile(dir) {
|
|
9206
9212
|
for (const ext of CONFIG_EXTENSIONS) {
|
|
9207
9213
|
const filePath = join(dir, `config.${ext}`);
|
|
@@ -9240,22 +9246,11 @@ function loadConfig(appName, schema, options) {
|
|
|
9240
9246
|
resourceId: appName
|
|
9241
9247
|
}));
|
|
9242
9248
|
}
|
|
9243
|
-
|
|
9244
|
-
|
|
9245
|
-
|
|
9246
|
-
} catch {
|
|
9247
|
-
return Result.err(new NotFoundError({
|
|
9248
|
-
message: `Failed to read config file: ${configFilePath}`,
|
|
9249
|
-
resourceType: "config",
|
|
9250
|
-
resourceId: configFilePath
|
|
9251
|
-
}));
|
|
9252
|
-
}
|
|
9253
|
-
const filename = configFilePath.split("/").pop() ?? "config";
|
|
9254
|
-
const parseResult = parseConfigFile(content, filename);
|
|
9255
|
-
if (parseResult.isErr()) {
|
|
9256
|
-
return Result.err(parseResult.error);
|
|
9249
|
+
const loadResult = loadConfigFileWithExtends(configFilePath);
|
|
9250
|
+
if (loadResult.isErr()) {
|
|
9251
|
+
return Result.err(loadResult.error);
|
|
9257
9252
|
}
|
|
9258
|
-
const parsed =
|
|
9253
|
+
const parsed = loadResult.unwrap();
|
|
9259
9254
|
const validateResult = schema.safeParse(parsed);
|
|
9260
9255
|
if (!validateResult.success) {
|
|
9261
9256
|
const issues = validateResult.error.issues;
|
|
@@ -9270,12 +9265,95 @@ function loadConfig(appName, schema, options) {
|
|
|
9270
9265
|
}
|
|
9271
9266
|
return Result.ok(validateResult.data);
|
|
9272
9267
|
}
|
|
9268
|
+
function resolveExtendsPath(extendsValue, fromFile) {
|
|
9269
|
+
if (isAbsolute(extendsValue)) {
|
|
9270
|
+
return extendsValue;
|
|
9271
|
+
}
|
|
9272
|
+
return resolve(dirname(fromFile), extendsValue);
|
|
9273
|
+
}
|
|
9274
|
+
function loadConfigFileWithExtends(filePath, visited = new Set) {
|
|
9275
|
+
const normalizedPath = resolve(filePath);
|
|
9276
|
+
if (visited.has(normalizedPath)) {
|
|
9277
|
+
return Result.err(new CircularExtendsError({
|
|
9278
|
+
message: `Circular extends detected: ${[...visited, normalizedPath].join(" -> ")}`,
|
|
9279
|
+
chain: [...visited, normalizedPath]
|
|
9280
|
+
}));
|
|
9281
|
+
}
|
|
9282
|
+
if (!existsSync(filePath)) {
|
|
9283
|
+
return Result.err(new NotFoundError({
|
|
9284
|
+
message: `Config file not found: ${filePath}`,
|
|
9285
|
+
resourceType: "config",
|
|
9286
|
+
resourceId: filePath
|
|
9287
|
+
}));
|
|
9288
|
+
}
|
|
9289
|
+
let content;
|
|
9290
|
+
try {
|
|
9291
|
+
content = readFileSync(filePath, "utf-8");
|
|
9292
|
+
} catch {
|
|
9293
|
+
return Result.err(new NotFoundError({
|
|
9294
|
+
message: `Failed to read config file: ${filePath}`,
|
|
9295
|
+
resourceType: "config",
|
|
9296
|
+
resourceId: filePath
|
|
9297
|
+
}));
|
|
9298
|
+
}
|
|
9299
|
+
const filename = filePath.split("/").pop() ?? "config";
|
|
9300
|
+
const parseResult = parseConfigFile(content, filename);
|
|
9301
|
+
if (parseResult.isErr()) {
|
|
9302
|
+
return Result.err(parseResult.error);
|
|
9303
|
+
}
|
|
9304
|
+
const parsed = parseResult.unwrap();
|
|
9305
|
+
const extendsValue = parsed["extends"];
|
|
9306
|
+
if (extendsValue === undefined) {
|
|
9307
|
+
return Result.ok(parsed);
|
|
9308
|
+
}
|
|
9309
|
+
if (typeof extendsValue !== "string") {
|
|
9310
|
+
return Result.err(new ParseError({
|
|
9311
|
+
message: `Invalid "extends" value in ${filePath}: expected string, got ${typeof extendsValue}`,
|
|
9312
|
+
filename: filePath
|
|
9313
|
+
}));
|
|
9314
|
+
}
|
|
9315
|
+
visited.add(normalizedPath);
|
|
9316
|
+
const extendsPath = resolveExtendsPath(extendsValue, filePath);
|
|
9317
|
+
const baseResult = loadConfigFileWithExtends(extendsPath, visited);
|
|
9318
|
+
if (baseResult.isErr()) {
|
|
9319
|
+
return Result.err(baseResult.error);
|
|
9320
|
+
}
|
|
9321
|
+
const baseConfig = baseResult.unwrap();
|
|
9322
|
+
const { extends: __, ...currentConfig } = parsed;
|
|
9323
|
+
return Result.ok(deepMerge(baseConfig, currentConfig));
|
|
9324
|
+
}
|
|
9325
|
+
function mapEnvToConfig(prefix, _schema) {
|
|
9326
|
+
const result = {};
|
|
9327
|
+
const prefixWithUnderscore = `${prefix}_`;
|
|
9328
|
+
for (const [key, value] of Object.entries(process.env)) {
|
|
9329
|
+
if (!key.startsWith(prefixWithUnderscore) || value === undefined) {
|
|
9330
|
+
continue;
|
|
9331
|
+
}
|
|
9332
|
+
const configPath = key.slice(prefixWithUnderscore.length).toLowerCase().split("__");
|
|
9333
|
+
let current = result;
|
|
9334
|
+
for (let i = 0;i < configPath.length - 1; i++) {
|
|
9335
|
+
const segment = configPath[i];
|
|
9336
|
+
if (segment === undefined)
|
|
9337
|
+
continue;
|
|
9338
|
+
if (!(segment in current)) {
|
|
9339
|
+
current[segment] = {};
|
|
9340
|
+
}
|
|
9341
|
+
current = current[segment];
|
|
9342
|
+
}
|
|
9343
|
+
const lastSegment = configPath.at(-1);
|
|
9344
|
+
if (lastSegment !== undefined) {
|
|
9345
|
+
current[lastSegment] = value;
|
|
9346
|
+
}
|
|
9347
|
+
}
|
|
9348
|
+
return result;
|
|
9349
|
+
}
|
|
9273
9350
|
export {
|
|
9274
9351
|
resolveConfig,
|
|
9275
9352
|
portSchema,
|
|
9276
9353
|
parseEnv,
|
|
9277
9354
|
parseConfigFile,
|
|
9278
9355
|
optionalBooleanSchema,
|
|
9356
|
+
mapEnvToConfig,
|
|
9279
9357
|
loadConfig,
|
|
9280
9358
|
getStateDir,
|
|
9281
9359
|
getEnvBoolean,
|
|
@@ -9285,5 +9363,6 @@ export {
|
|
|
9285
9363
|
env,
|
|
9286
9364
|
deepMerge,
|
|
9287
9365
|
booleanSchema,
|
|
9288
|
-
ParseError
|
|
9366
|
+
ParseError,
|
|
9367
|
+
CircularExtendsError
|
|
9289
9368
|
};
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@outfitter/config",
|
|
3
3
|
"description": "XDG-compliant config loading with schema validation for Outfitter",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.2.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"files": [
|
|
7
7
|
"dist"
|
|
@@ -27,8 +27,8 @@
|
|
|
27
27
|
"clean": "rm -rf dist"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
|
-
"@outfitter/contracts": "0.
|
|
31
|
-
"@outfitter/types": "0.
|
|
30
|
+
"@outfitter/contracts": "0.2.0",
|
|
31
|
+
"@outfitter/types": "0.2.0",
|
|
32
32
|
"zod": "^4.3.5"
|
|
33
33
|
},
|
|
34
34
|
"devDependencies": {
|