@outfitter/config 0.1.0-rc.2 → 0.1.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/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
  *
@@ -465,5 +478,34 @@ interface LoadConfigOptions {
465
478
  * });
466
479
  * ```
467
480
  */
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 };
481
+ 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>>;
482
+ /**
483
+ * Map environment variables to config object based on prefix.
484
+ *
485
+ * Environment variables are mapped as follows:
486
+ * - `PREFIX_KEY` -> `{ key: value }`
487
+ * - `PREFIX_NESTED__KEY` -> `{ nested: { key: value } }`
488
+ *
489
+ * Only returns values for keys that have matching env vars set.
490
+ * Values are returned as strings; use Zod coercion for type conversion.
491
+ *
492
+ * @param prefix - Environment variable prefix (e.g., "MYAPP")
493
+ * @param schema - Zod schema to determine valid keys (optional, for filtering)
494
+ * @returns Partial config object with mapped values
495
+ *
496
+ * @example
497
+ * ```typescript
498
+ * // With MYAPP_PORT=8080 and MYAPP_DB__HOST=localhost
499
+ * const schema = z.object({
500
+ * port: z.coerce.number(),
501
+ * db: z.object({ host: z.string() }),
502
+ * });
503
+ *
504
+ * const envConfig = mapEnvToConfig("MYAPP", schema);
505
+ * // { port: "8080", db: { host: "localhost" } }
506
+ *
507
+ * const result = resolveConfig(schema, { env: envConfig });
508
+ * ```
509
+ */
510
+ declare function mapEnvToConfig<T>(prefix: string, _schema?: ZodSchema<T>): Partial<T>;
511
+ 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"] ?? "";
@@ -9240,22 +9245,11 @@ function loadConfig(appName, schema, options) {
9240
9245
  resourceId: appName
9241
9246
  }));
9242
9247
  }
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);
9248
+ const loadResult = loadConfigFileWithExtends(configFilePath);
9249
+ if (loadResult.isErr()) {
9250
+ return Result.err(loadResult.error);
9257
9251
  }
9258
- const parsed = parseResult.unwrap();
9252
+ const parsed = loadResult.unwrap();
9259
9253
  const validateResult = schema.safeParse(parsed);
9260
9254
  if (!validateResult.success) {
9261
9255
  const issues = validateResult.error.issues;
@@ -9270,12 +9264,95 @@ function loadConfig(appName, schema, options) {
9270
9264
  }
9271
9265
  return Result.ok(validateResult.data);
9272
9266
  }
9267
+ function resolveExtendsPath(extendsValue, fromFile) {
9268
+ if (isAbsolute(extendsValue)) {
9269
+ return extendsValue;
9270
+ }
9271
+ return resolve(dirname(fromFile), extendsValue);
9272
+ }
9273
+ function loadConfigFileWithExtends(filePath, visited = new Set) {
9274
+ const normalizedPath = resolve(filePath);
9275
+ if (visited.has(normalizedPath)) {
9276
+ return Result.err(new CircularExtendsError({
9277
+ message: `Circular extends detected: ${[...visited, normalizedPath].join(" -> ")}`,
9278
+ chain: [...visited, normalizedPath]
9279
+ }));
9280
+ }
9281
+ if (!existsSync(filePath)) {
9282
+ return Result.err(new NotFoundError({
9283
+ message: `Config file not found: ${filePath}`,
9284
+ resourceType: "config",
9285
+ resourceId: filePath
9286
+ }));
9287
+ }
9288
+ let content;
9289
+ try {
9290
+ content = readFileSync(filePath, "utf-8");
9291
+ } catch {
9292
+ return Result.err(new NotFoundError({
9293
+ message: `Failed to read config file: ${filePath}`,
9294
+ resourceType: "config",
9295
+ resourceId: filePath
9296
+ }));
9297
+ }
9298
+ const filename = filePath.split("/").pop() ?? "config";
9299
+ const parseResult = parseConfigFile(content, filename);
9300
+ if (parseResult.isErr()) {
9301
+ return Result.err(parseResult.error);
9302
+ }
9303
+ const parsed = parseResult.unwrap();
9304
+ const extendsValue = parsed["extends"];
9305
+ if (extendsValue === undefined) {
9306
+ return Result.ok(parsed);
9307
+ }
9308
+ if (typeof extendsValue !== "string") {
9309
+ return Result.err(new ParseError({
9310
+ message: `Invalid "extends" value in ${filePath}: expected string, got ${typeof extendsValue}`,
9311
+ filename: filePath
9312
+ }));
9313
+ }
9314
+ visited.add(normalizedPath);
9315
+ const extendsPath = resolveExtendsPath(extendsValue, filePath);
9316
+ const baseResult = loadConfigFileWithExtends(extendsPath, visited);
9317
+ if (baseResult.isErr()) {
9318
+ return Result.err(baseResult.error);
9319
+ }
9320
+ const baseConfig = baseResult.unwrap();
9321
+ const { extends: __, ...currentConfig } = parsed;
9322
+ return Result.ok(deepMerge(baseConfig, currentConfig));
9323
+ }
9324
+ function mapEnvToConfig(prefix, _schema) {
9325
+ const result = {};
9326
+ const prefixWithUnderscore = `${prefix}_`;
9327
+ for (const [key, value] of Object.entries(process.env)) {
9328
+ if (!key.startsWith(prefixWithUnderscore) || value === undefined) {
9329
+ continue;
9330
+ }
9331
+ const configPath = key.slice(prefixWithUnderscore.length).toLowerCase().split("__");
9332
+ let current = result;
9333
+ for (let i = 0;i < configPath.length - 1; i++) {
9334
+ const segment = configPath[i];
9335
+ if (segment === undefined)
9336
+ continue;
9337
+ if (!(segment in current)) {
9338
+ current[segment] = {};
9339
+ }
9340
+ current = current[segment];
9341
+ }
9342
+ const lastSegment = configPath.at(-1);
9343
+ if (lastSegment !== undefined) {
9344
+ current[lastSegment] = value;
9345
+ }
9346
+ }
9347
+ return result;
9348
+ }
9273
9349
  export {
9274
9350
  resolveConfig,
9275
9351
  portSchema,
9276
9352
  parseEnv,
9277
9353
  parseConfigFile,
9278
9354
  optionalBooleanSchema,
9355
+ mapEnvToConfig,
9279
9356
  loadConfig,
9280
9357
  getStateDir,
9281
9358
  getEnvBoolean,
@@ -9285,5 +9362,6 @@ export {
9285
9362
  env,
9286
9363
  deepMerge,
9287
9364
  booleanSchema,
9288
- ParseError
9365
+ ParseError,
9366
+ CircularExtendsError
9289
9367
  };
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.2",
4
+ "version": "0.1.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": "workspace:*",
31
- "@outfitter/types": "workspace:*",
30
+ "@outfitter/contracts": "0.1.0",
31
+ "@outfitter/types": "0.1.0",
32
32
  "zod": "^4.3.5"
33
33
  },
34
34
  "devDependencies": {