@outfitter/config 0.3.3 → 0.4.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.
@@ -0,0 +1,71 @@
1
+ // @bun
2
+ import {
3
+ CircularExtendsError,
4
+ ParseError,
5
+ deepMerge,
6
+ parseConfigFile
7
+ } from "./config-s4swz8m3.js";
8
+
9
+ // packages/config/src/internal/extends.ts
10
+ import { existsSync, readFileSync } from "fs";
11
+ import { dirname, isAbsolute, resolve } from "path";
12
+ import { NotFoundError, Result } from "@outfitter/contracts";
13
+ function resolveExtendsPath(extendsValue, fromFile) {
14
+ if (isAbsolute(extendsValue)) {
15
+ return extendsValue;
16
+ }
17
+ return resolve(dirname(fromFile), extendsValue);
18
+ }
19
+ function loadConfigFileWithExtends(filePath, visited = new Set) {
20
+ const normalizedPath = resolve(filePath);
21
+ if (visited.has(normalizedPath)) {
22
+ return Result.err(new CircularExtendsError({
23
+ message: `Circular extends detected: ${[...visited, normalizedPath].join(" -> ")}`,
24
+ chain: [...visited, normalizedPath]
25
+ }));
26
+ }
27
+ if (!existsSync(filePath)) {
28
+ return Result.err(new NotFoundError({
29
+ message: `Config file not found: ${filePath}`,
30
+ resourceType: "config",
31
+ resourceId: filePath
32
+ }));
33
+ }
34
+ let content;
35
+ try {
36
+ content = readFileSync(filePath, "utf-8");
37
+ } catch {
38
+ return Result.err(new NotFoundError({
39
+ message: `Failed to read config file: ${filePath}`,
40
+ resourceType: "config",
41
+ resourceId: filePath
42
+ }));
43
+ }
44
+ const filename = filePath.split("/").pop() ?? "config";
45
+ const parseResult = parseConfigFile(content, filename);
46
+ if (parseResult.isErr()) {
47
+ return Result.err(parseResult.error);
48
+ }
49
+ const parsed = parseResult.unwrap();
50
+ const extendsValue = parsed["extends"];
51
+ if (extendsValue === undefined) {
52
+ return Result.ok(parsed);
53
+ }
54
+ if (typeof extendsValue !== "string") {
55
+ return Result.err(new ParseError({
56
+ message: `Invalid "extends" value in ${filePath}: expected string, got ${typeof extendsValue}`,
57
+ filename: filePath
58
+ }));
59
+ }
60
+ visited.add(normalizedPath);
61
+ const extendsPath = resolveExtendsPath(extendsValue, filePath);
62
+ const baseResult = loadConfigFileWithExtends(extendsPath, visited);
63
+ if (baseResult.isErr()) {
64
+ return Result.err(baseResult.error);
65
+ }
66
+ const baseConfig = baseResult.unwrap();
67
+ const { extends: __, ...currentConfig } = parsed;
68
+ return Result.ok(deepMerge(baseConfig, currentConfig));
69
+ }
70
+
71
+ export { resolveExtendsPath, loadConfigFileWithExtends };
@@ -0,0 +1,87 @@
1
+ // @bun
2
+ import {
3
+ loadConfigFileWithExtends
4
+ } from "./config-aje2en96.js";
5
+
6
+ // packages/config/src/internal/loading.ts
7
+ import { existsSync } from "fs";
8
+ import { join } from "path";
9
+ import {
10
+ formatZodIssues,
11
+ NotFoundError,
12
+ Result,
13
+ ValidationError
14
+ } from "@outfitter/contracts";
15
+ var CONFIG_EXTENSIONS = ["toml", "yaml", "yml", "json", "jsonc", "json5"];
16
+ function findConfigFile(dir) {
17
+ for (const ext of CONFIG_EXTENSIONS) {
18
+ const filePath = join(dir, `config.${ext}`);
19
+ if (existsSync(filePath)) {
20
+ return filePath;
21
+ }
22
+ }
23
+ return;
24
+ }
25
+ function getDefaultSearchPaths(appName) {
26
+ const xdgConfigHome = process.env["XDG_CONFIG_HOME"];
27
+ const home = process.env["HOME"] ?? "";
28
+ const defaultConfigPath = join(home, ".config", appName);
29
+ if (xdgConfigHome) {
30
+ const xdgPath = join(xdgConfigHome, appName);
31
+ if (xdgPath !== defaultConfigPath) {
32
+ return [xdgPath, defaultConfigPath];
33
+ }
34
+ }
35
+ return [defaultConfigPath];
36
+ }
37
+ function isZodSchema(value) {
38
+ return typeof value === "object" && value !== null && "safeParse" in value && typeof value["safeParse"] === "function";
39
+ }
40
+ function loadConfig(appName, schemaOrOptions, maybeOptions) {
41
+ let schema;
42
+ let options;
43
+ if (schemaOrOptions !== undefined) {
44
+ if (isZodSchema(schemaOrOptions)) {
45
+ schema = schemaOrOptions;
46
+ options = maybeOptions;
47
+ } else {
48
+ options = schemaOrOptions;
49
+ }
50
+ }
51
+ const searchPaths = options?.searchPaths ? options.searchPaths.map((p) => join(p, appName)) : getDefaultSearchPaths(appName);
52
+ let configFilePath;
53
+ for (const searchPath of searchPaths) {
54
+ const found = findConfigFile(searchPath);
55
+ if (found) {
56
+ configFilePath = found;
57
+ break;
58
+ }
59
+ }
60
+ if (!configFilePath) {
61
+ return Result.err(new NotFoundError({
62
+ message: `Configuration file not found for ${appName}`,
63
+ resourceType: "config",
64
+ resourceId: appName
65
+ }));
66
+ }
67
+ const loadResult = loadConfigFileWithExtends(configFilePath);
68
+ if (loadResult.isErr()) {
69
+ return Result.err(loadResult.error);
70
+ }
71
+ const parsed = loadResult.unwrap();
72
+ if (!schema) {
73
+ return Result.ok(parsed);
74
+ }
75
+ const validateResult = schema.safeParse(parsed);
76
+ if (!validateResult.success) {
77
+ const fullMessage = formatZodIssues(validateResult.error.issues);
78
+ const firstPath = validateResult.error.issues[0]?.path?.join(".");
79
+ return Result.err(new ValidationError({
80
+ message: fullMessage,
81
+ ...firstPath ? { field: firstPath } : {}
82
+ }));
83
+ }
84
+ return Result.ok(validateResult.data);
85
+ }
86
+
87
+ export { loadConfig };
@@ -0,0 +1,29 @@
1
+ // @bun
2
+ // packages/config/src/internal/xdg.ts
3
+ import { join } from "path";
4
+ function getConfigDir(appName) {
5
+ const xdgConfigHome = process.env["XDG_CONFIG_HOME"];
6
+ const home = process.env["HOME"] ?? "";
7
+ const baseDir = xdgConfigHome ?? join(home, ".config");
8
+ return join(baseDir, appName);
9
+ }
10
+ function getDataDir(appName) {
11
+ const xdgDataHome = process.env["XDG_DATA_HOME"];
12
+ const home = process.env["HOME"] ?? "";
13
+ const baseDir = xdgDataHome ?? join(home, ".local", "share");
14
+ return join(baseDir, appName);
15
+ }
16
+ function getCacheDir(appName) {
17
+ const xdgCacheHome = process.env["XDG_CACHE_HOME"];
18
+ const home = process.env["HOME"] ?? "";
19
+ const baseDir = xdgCacheHome ?? join(home, ".cache");
20
+ return join(baseDir, appName);
21
+ }
22
+ function getStateDir(appName) {
23
+ const xdgStateHome = process.env["XDG_STATE_HOME"];
24
+ const home = process.env["HOME"] ?? "";
25
+ const baseDir = xdgStateHome ?? join(home, ".local", "state");
26
+ return join(baseDir, appName);
27
+ }
28
+
29
+ export { getConfigDir, getDataDir, getCacheDir, getStateDir };
@@ -0,0 +1,97 @@
1
+ // @bun
2
+ // packages/config/src/internal/parsing.ts
3
+ import { Result, TaggedError } from "@outfitter/contracts";
4
+ import { parse as parseToml } from "smol-toml";
5
+ import { parse as parseYaml } from "yaml";
6
+ var ParseErrorBase = TaggedError("ParseError")();
7
+
8
+ class ParseError extends ParseErrorBase {
9
+ category = "validation";
10
+ }
11
+ var CircularExtendsErrorBase = TaggedError("CircularExtendsError")();
12
+
13
+ class CircularExtendsError extends CircularExtendsErrorBase {
14
+ category = "validation";
15
+ }
16
+ function isPlainObject(value) {
17
+ if (value === null || typeof value !== "object") {
18
+ return false;
19
+ }
20
+ if (Array.isArray(value)) {
21
+ return false;
22
+ }
23
+ return true;
24
+ }
25
+ function deepMerge(target, source) {
26
+ const result = { ...target };
27
+ for (const key of Object.keys(source)) {
28
+ const sourceValue = source[key];
29
+ const targetValue = result[key];
30
+ if (sourceValue === undefined) {
31
+ continue;
32
+ }
33
+ if (sourceValue === null) {
34
+ result[key] = null;
35
+ continue;
36
+ }
37
+ if (Array.isArray(sourceValue)) {
38
+ result[key] = sourceValue;
39
+ continue;
40
+ }
41
+ if (isPlainObject(sourceValue) && isPlainObject(targetValue)) {
42
+ result[key] = deepMerge(targetValue, sourceValue);
43
+ continue;
44
+ }
45
+ result[key] = sourceValue;
46
+ }
47
+ return result;
48
+ }
49
+ function getExtension(filename) {
50
+ const lastDot = filename.lastIndexOf(".");
51
+ if (lastDot === -1) {
52
+ return "";
53
+ }
54
+ return filename.slice(lastDot + 1).toLowerCase();
55
+ }
56
+ function parseConfigFile(content, filename) {
57
+ const ext = getExtension(filename);
58
+ try {
59
+ switch (ext) {
60
+ case "toml": {
61
+ const parsed = parseToml(content);
62
+ return Result.ok(parsed);
63
+ }
64
+ case "yaml":
65
+ case "yml": {
66
+ const parsed = parseYaml(content, { merge: true });
67
+ if (parsed === null || typeof parsed !== "object") {
68
+ return Result.ok({});
69
+ }
70
+ return Result.ok(parsed);
71
+ }
72
+ case "json": {
73
+ const parsed = JSON.parse(content);
74
+ return Result.ok(parsed);
75
+ }
76
+ case "jsonc":
77
+ case "json5": {
78
+ const parsed = Bun.JSON5.parse(content);
79
+ return Result.ok(parsed);
80
+ }
81
+ default: {
82
+ return Result.err(new ParseError({
83
+ message: `Unsupported config file extension: .${ext}`,
84
+ filename
85
+ }));
86
+ }
87
+ }
88
+ } catch (error) {
89
+ const message = error instanceof Error ? error.message : "Unknown parse error";
90
+ return Result.err(new ParseError({
91
+ message: `Failed to parse ${filename}: ${message}`,
92
+ filename
93
+ }));
94
+ }
95
+ }
96
+
97
+ export { ParseError, CircularExtendsError, deepMerge, parseConfigFile };
@@ -0,0 +1,129 @@
1
+ import { TaggedErrorClass } from "@outfitter/contracts";
2
+ import { Result } from "@outfitter/contracts";
3
+ type ParseErrorFields = {
4
+ /** Human-readable error message describing the parse failure */
5
+ message: string;
6
+ /** Name of the file that failed to parse */
7
+ filename: string;
8
+ /** Line number where the error occurred (if available) */
9
+ line?: number;
10
+ /** Column number where the error occurred (if available) */
11
+ column?: number;
12
+ };
13
+ declare const ParseErrorBase: TaggedErrorClass<"ParseError", ParseErrorFields>;
14
+ /**
15
+ * Error thrown when a configuration file cannot be parsed.
16
+ *
17
+ * Contains details about the parse failure including the filename
18
+ * and optionally the line/column where the error occurred.
19
+ *
20
+ * @example
21
+ * ```typescript
22
+ * const result = parseConfigFile("invalid toml [", "config.toml");
23
+ * if (result.isErr() && result.error._tag === "ParseError") {
24
+ * console.error(`Parse error in ${result.error.filename}: ${result.error.message}`);
25
+ * }
26
+ * ```
27
+ */
28
+ declare class ParseError extends ParseErrorBase {
29
+ readonly category: "validation";
30
+ }
31
+ type CircularExtendsErrorFields = {
32
+ /** Human-readable error message */
33
+ message: string;
34
+ /** The config file paths that form the circular reference */
35
+ chain: string[];
36
+ };
37
+ declare const CircularExtendsErrorBase: TaggedErrorClass<"CircularExtendsError", CircularExtendsErrorFields>;
38
+ /**
39
+ * Error thrown when a circular extends reference is detected.
40
+ */
41
+ declare class CircularExtendsError extends CircularExtendsErrorBase {
42
+ readonly category: "validation";
43
+ }
44
+ /**
45
+ * Deep merge two objects with configurable merge semantics.
46
+ *
47
+ * Merge behavior:
48
+ * - Recursively merges nested plain objects
49
+ * - Arrays are replaced (not concatenated)
50
+ * - `null` explicitly replaces the target value
51
+ * - `undefined` is skipped (does not override)
52
+ *
53
+ * @typeParam T - The type of the target object
54
+ * @param target - Base object to merge into (not mutated)
55
+ * @param source - Object with values to merge
56
+ * @returns New object with merged values
57
+ *
58
+ * @example
59
+ * ```typescript
60
+ * const defaults = { server: { port: 3000, host: "localhost" } };
61
+ * const overrides = { server: { port: 8080 } };
62
+ *
63
+ * const merged = deepMerge(defaults, overrides);
64
+ * // { server: { port: 8080, host: "localhost" } }
65
+ * ```
66
+ *
67
+ * @example
68
+ * ```typescript
69
+ * // Arrays replace, not merge
70
+ * const target = { tags: ["a", "b"] };
71
+ * const source = { tags: ["c"] };
72
+ * deepMerge(target, source); // { tags: ["c"] }
73
+ *
74
+ * // undefined is skipped
75
+ * const base = { a: 1, b: 2 };
76
+ * deepMerge(base, { a: undefined, b: 3 }); // { a: 1, b: 3 }
77
+ *
78
+ * // null explicitly replaces
79
+ * deepMerge(base, { a: null }); // { a: null, b: 2 }
80
+ * ```
81
+ */
82
+ declare function deepMerge<T extends object>(target: T, source: Partial<T>): T;
83
+ /**
84
+ * Parse configuration file content based on filename extension.
85
+ *
86
+ * Supports multiple formats:
87
+ * - `.toml` - Parsed with smol-toml (preferred for config)
88
+ * - `.yaml`, `.yml` - Parsed with yaml (merge key support enabled)
89
+ * - `.json` - Parsed with strict JSON.parse
90
+ * - `.jsonc` - Parsed with json5 compatibility (comments/trailing commas)
91
+ * - `.json5` - Parsed with json5 (comments and trailing commas allowed)
92
+ *
93
+ * @param content - Raw file content to parse
94
+ * @param filename - Filename used to determine format (by extension)
95
+ * @returns Result containing parsed object or ParseError
96
+ *
97
+ * @example
98
+ * ```typescript
99
+ * const toml = `
100
+ * [server]
101
+ * port = 3000
102
+ * host = "localhost"
103
+ * `;
104
+ *
105
+ * const result = parseConfigFile(toml, "config.toml");
106
+ * if (result.isOk()) {
107
+ * console.log(result.value.server.port); // 3000
108
+ * }
109
+ * ```
110
+ *
111
+ * @example
112
+ * ```typescript
113
+ * // YAML with anchors/aliases
114
+ * const yaml = `
115
+ * defaults: &defaults
116
+ * timeout: 5000
117
+ * server:
118
+ * <<: *defaults
119
+ * port: 3000
120
+ * `;
121
+ *
122
+ * const result = parseConfigFile(yaml, "config.yaml");
123
+ * if (result.isOk()) {
124
+ * console.log(result.value.server.timeout); // 5000
125
+ * }
126
+ * ```
127
+ */
128
+ declare function parseConfigFile(content: string, filename: string): Result<Record<string, unknown>, InstanceType<typeof ParseError>>;
129
+ export { ParseError, CircularExtendsError, deepMerge, parseConfigFile };
package/package.json CHANGED
@@ -1,11 +1,24 @@
1
1
  {
2
2
  "name": "@outfitter/config",
3
+ "version": "0.4.0",
3
4
  "description": "XDG-compliant config loading with schema validation for Outfitter",
4
- "version": "0.3.3",
5
- "type": "module",
5
+ "keywords": [
6
+ "config",
7
+ "outfitter",
8
+ "typescript",
9
+ "xdg"
10
+ ],
11
+ "license": "MIT",
12
+ "repository": {
13
+ "type": "git",
14
+ "url": "https://github.com/outfitter-dev/outfitter.git",
15
+ "directory": "packages/config"
16
+ },
6
17
  "files": [
7
18
  "dist"
8
19
  ],
20
+ "type": "module",
21
+ "sideEffects": false,
9
22
  "module": "./dist/index.js",
10
23
  "types": "./dist/index.d.ts",
11
24
  "exports": {
@@ -29,40 +42,27 @@
29
42
  },
30
43
  "./package.json": "./package.json"
31
44
  },
32
- "sideEffects": false,
45
+ "publishConfig": {
46
+ "access": "public"
47
+ },
33
48
  "scripts": {
34
- "build": "bunup --filter @outfitter/config",
35
- "lint": "biome lint ./src",
36
- "lint:fix": "biome lint --write ./src",
49
+ "build": "cd ../.. && bash ./scripts/run-bunup-with-lock.sh bunup --filter @outfitter/config",
50
+ "lint": "oxlint ./src",
51
+ "lint:fix": "oxlint --fix ./src",
37
52
  "test": "bun test",
38
53
  "typecheck": "tsc --noEmit",
39
54
  "clean": "rm -rf dist",
40
55
  "prepublishOnly": "bun ../../scripts/check-publish-manifest.ts"
41
56
  },
42
57
  "dependencies": {
43
- "@outfitter/contracts": "0.4.1",
44
- "@outfitter/types": "0.2.3",
58
+ "@outfitter/contracts": "0.5.0",
59
+ "@outfitter/types": "0.2.5",
45
60
  "smol-toml": "^1.6.0",
46
61
  "yaml": "^2.8.2",
47
62
  "zod": "^4.3.5"
48
63
  },
49
64
  "devDependencies": {
50
- "@types/bun": "latest",
51
- "typescript": "^5.8.0"
52
- },
53
- "keywords": [
54
- "outfitter",
55
- "config",
56
- "xdg",
57
- "typescript"
58
- ],
59
- "license": "MIT",
60
- "repository": {
61
- "type": "git",
62
- "url": "https://github.com/outfitter-dev/outfitter.git",
63
- "directory": "packages/config"
64
- },
65
- "publishConfig": {
66
- "access": "public"
65
+ "@types/bun": "^1.3.9",
66
+ "typescript": "^5.9.3"
67
67
  }
68
68
  }