@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 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
- export { resolveConfig, portSchema, parseEnv, parseConfigFile, optionalBooleanSchema, loadConfig, getStateDir, getEnvBoolean, getDataDir, getConfigDir, getCacheDir, env, deepMerge, booleanSchema, ParseError, LoadConfigOptions, Env, ConfigSources };
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
- let content;
9244
- try {
9245
- content = readFileSync(configFilePath, "utf-8");
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 = parseResult.unwrap();
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.1.0-rc.3",
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.1.0-rc.2",
31
- "@outfitter/types": "0.1.0-rc.3",
30
+ "@outfitter/contracts": "0.2.0",
31
+ "@outfitter/types": "0.2.0",
32
32
  "zod": "^4.3.5"
33
33
  },
34
34
  "devDependencies": {