@outfitter/config 0.3.4 → 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.
- package/dist/index.d.ts +4 -275
- package/dist/index.js +20 -245
- package/dist/internal/extends.d.ts +13 -0
- package/dist/internal/extends.js +10 -0
- package/dist/internal/loading.d.ts +3 -0
- package/dist/internal/loading.js +9 -0
- package/dist/internal/parsing.d.ts +2 -0
- package/dist/internal/parsing.js +13 -0
- package/dist/internal/xdg.d.ts +2 -0
- package/dist/internal/xdg.js +13 -0
- package/dist/shared/@outfitter/config-6449x3br.d.ts +81 -0
- package/dist/shared/@outfitter/config-7dyshh4r.d.ts +105 -0
- package/dist/shared/@outfitter/config-aje2en96.js +71 -0
- package/dist/shared/@outfitter/config-br341dr7.js +87 -0
- package/dist/shared/@outfitter/config-pf9xp78h.js +29 -0
- package/dist/shared/@outfitter/config-s4swz8m3.js +97 -0
- package/dist/shared/@outfitter/config-sp6gradd.d.ts +129 -0
- package/package.json +4 -4
package/dist/index.d.ts
CHANGED
|
@@ -1,214 +1,10 @@
|
|
|
1
1
|
import { Env, booleanSchema, env, getEnvBoolean, optionalBooleanSchema, parseEnv, portSchema } from "./shared/@outfitter/config-veqxf02x.js";
|
|
2
2
|
import { EnvironmentDefaults, OutfitterEnv, getEnvironment, getEnvironmentDefaults } from "./shared/@outfitter/config-wawavx3r.js";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
3
|
+
import { getCacheDir, getConfigDir, getDataDir, getStateDir } from "./shared/@outfitter/config-6449x3br.js";
|
|
4
|
+
import { LoadConfigOptions, loadConfig } from "./shared/@outfitter/config-7dyshh4r.js";
|
|
5
|
+
import { CircularExtendsError, ParseError, deepMerge, parseConfigFile } from "./shared/@outfitter/config-sp6gradd.js";
|
|
6
|
+
import { Result, ValidationError } from "@outfitter/contracts";
|
|
5
7
|
import { ZodSchema } from "zod";
|
|
6
|
-
type ParseErrorFields = {
|
|
7
|
-
/** Human-readable error message describing the parse failure */
|
|
8
|
-
message: string;
|
|
9
|
-
/** Name of the file that failed to parse */
|
|
10
|
-
filename: string;
|
|
11
|
-
/** Line number where the error occurred (if available) */
|
|
12
|
-
line?: number;
|
|
13
|
-
/** Column number where the error occurred (if available) */
|
|
14
|
-
column?: number;
|
|
15
|
-
};
|
|
16
|
-
declare const ParseErrorBase: TaggedErrorClass<"ParseError", ParseErrorFields>;
|
|
17
|
-
/**
|
|
18
|
-
* Error thrown when a configuration file cannot be parsed.
|
|
19
|
-
*
|
|
20
|
-
* Contains details about the parse failure including the filename
|
|
21
|
-
* and optionally the line/column where the error occurred.
|
|
22
|
-
*
|
|
23
|
-
* @example
|
|
24
|
-
* ```typescript
|
|
25
|
-
* const result = parseConfigFile("invalid toml [", "config.toml");
|
|
26
|
-
* if (result.isErr() && result.error._tag === "ParseError") {
|
|
27
|
-
* console.error(`Parse error in ${result.error.filename}: ${result.error.message}`);
|
|
28
|
-
* }
|
|
29
|
-
* ```
|
|
30
|
-
*/
|
|
31
|
-
declare class ParseError extends ParseErrorBase {
|
|
32
|
-
readonly category: "validation";
|
|
33
|
-
}
|
|
34
|
-
type CircularExtendsErrorFields = {
|
|
35
|
-
/** Human-readable error message */
|
|
36
|
-
message: string;
|
|
37
|
-
/** The config file paths that form the circular reference */
|
|
38
|
-
chain: string[];
|
|
39
|
-
};
|
|
40
|
-
declare const CircularExtendsErrorBase: TaggedErrorClass<"CircularExtendsError", CircularExtendsErrorFields>;
|
|
41
|
-
/**
|
|
42
|
-
* Error thrown when a circular extends reference is detected.
|
|
43
|
-
*/
|
|
44
|
-
declare class CircularExtendsError extends CircularExtendsErrorBase {
|
|
45
|
-
readonly category: "validation";
|
|
46
|
-
}
|
|
47
|
-
/**
|
|
48
|
-
* Get the XDG config directory for an application.
|
|
49
|
-
*
|
|
50
|
-
* Uses `XDG_CONFIG_HOME` if set, otherwise defaults to `~/.config`.
|
|
51
|
-
* This follows the XDG Base Directory Specification for storing
|
|
52
|
-
* user-specific configuration files.
|
|
53
|
-
*
|
|
54
|
-
* @param appName - Application name used as subdirectory
|
|
55
|
-
* @returns Absolute path to the application's config directory
|
|
56
|
-
*
|
|
57
|
-
* @example
|
|
58
|
-
* ```typescript
|
|
59
|
-
* // With XDG_CONFIG_HOME="/custom/config"
|
|
60
|
-
* getConfigDir("myapp"); // "/custom/config/myapp"
|
|
61
|
-
*
|
|
62
|
-
* // Without XDG_CONFIG_HOME (uses default)
|
|
63
|
-
* getConfigDir("myapp"); // "/home/user/.config/myapp"
|
|
64
|
-
* ```
|
|
65
|
-
*/
|
|
66
|
-
declare function getConfigDir(appName: string): string;
|
|
67
|
-
/**
|
|
68
|
-
* Get the XDG data directory for an application.
|
|
69
|
-
*
|
|
70
|
-
* Uses `XDG_DATA_HOME` if set, otherwise defaults to `~/.local/share`.
|
|
71
|
-
* This follows the XDG Base Directory Specification for storing
|
|
72
|
-
* user-specific data files (databases, generated content, etc.).
|
|
73
|
-
*
|
|
74
|
-
* @param appName - Application name used as subdirectory
|
|
75
|
-
* @returns Absolute path to the application's data directory
|
|
76
|
-
*
|
|
77
|
-
* @example
|
|
78
|
-
* ```typescript
|
|
79
|
-
* // With XDG_DATA_HOME="/custom/data"
|
|
80
|
-
* getDataDir("myapp"); // "/custom/data/myapp"
|
|
81
|
-
*
|
|
82
|
-
* // Without XDG_DATA_HOME (uses default)
|
|
83
|
-
* getDataDir("myapp"); // "/home/user/.local/share/myapp"
|
|
84
|
-
* ```
|
|
85
|
-
*/
|
|
86
|
-
declare function getDataDir(appName: string): string;
|
|
87
|
-
/**
|
|
88
|
-
* Get the XDG cache directory for an application.
|
|
89
|
-
*
|
|
90
|
-
* Uses `XDG_CACHE_HOME` if set, otherwise defaults to `~/.cache`.
|
|
91
|
-
* This follows the XDG Base Directory Specification for storing
|
|
92
|
-
* non-essential cached data that can be regenerated.
|
|
93
|
-
*
|
|
94
|
-
* @param appName - Application name used as subdirectory
|
|
95
|
-
* @returns Absolute path to the application's cache directory
|
|
96
|
-
*
|
|
97
|
-
* @example
|
|
98
|
-
* ```typescript
|
|
99
|
-
* // With XDG_CACHE_HOME="/custom/cache"
|
|
100
|
-
* getCacheDir("myapp"); // "/custom/cache/myapp"
|
|
101
|
-
*
|
|
102
|
-
* // Without XDG_CACHE_HOME (uses default)
|
|
103
|
-
* getCacheDir("myapp"); // "/home/user/.cache/myapp"
|
|
104
|
-
* ```
|
|
105
|
-
*/
|
|
106
|
-
declare function getCacheDir(appName: string): string;
|
|
107
|
-
/**
|
|
108
|
-
* Get the XDG state directory for an application.
|
|
109
|
-
*
|
|
110
|
-
* Uses `XDG_STATE_HOME` if set, otherwise defaults to `~/.local/state`.
|
|
111
|
-
* This follows the XDG Base Directory Specification for storing
|
|
112
|
-
* state data that should persist between restarts (logs, history, etc.).
|
|
113
|
-
*
|
|
114
|
-
* @param appName - Application name used as subdirectory
|
|
115
|
-
* @returns Absolute path to the application's state directory
|
|
116
|
-
*
|
|
117
|
-
* @example
|
|
118
|
-
* ```typescript
|
|
119
|
-
* // With XDG_STATE_HOME="/custom/state"
|
|
120
|
-
* getStateDir("myapp"); // "/custom/state/myapp"
|
|
121
|
-
*
|
|
122
|
-
* // Without XDG_STATE_HOME (uses default)
|
|
123
|
-
* getStateDir("myapp"); // "/home/user/.local/state/myapp"
|
|
124
|
-
* ```
|
|
125
|
-
*/
|
|
126
|
-
declare function getStateDir(appName: string): string;
|
|
127
|
-
/**
|
|
128
|
-
* Deep merge two objects with configurable merge semantics.
|
|
129
|
-
*
|
|
130
|
-
* Merge behavior:
|
|
131
|
-
* - Recursively merges nested plain objects
|
|
132
|
-
* - Arrays are replaced (not concatenated)
|
|
133
|
-
* - `null` explicitly replaces the target value
|
|
134
|
-
* - `undefined` is skipped (does not override)
|
|
135
|
-
*
|
|
136
|
-
* @typeParam T - The type of the target object
|
|
137
|
-
* @param target - Base object to merge into (not mutated)
|
|
138
|
-
* @param source - Object with values to merge
|
|
139
|
-
* @returns New object with merged values
|
|
140
|
-
*
|
|
141
|
-
* @example
|
|
142
|
-
* ```typescript
|
|
143
|
-
* const defaults = { server: { port: 3000, host: "localhost" } };
|
|
144
|
-
* const overrides = { server: { port: 8080 } };
|
|
145
|
-
*
|
|
146
|
-
* const merged = deepMerge(defaults, overrides);
|
|
147
|
-
* // { server: { port: 8080, host: "localhost" } }
|
|
148
|
-
* ```
|
|
149
|
-
*
|
|
150
|
-
* @example
|
|
151
|
-
* ```typescript
|
|
152
|
-
* // Arrays replace, not merge
|
|
153
|
-
* const target = { tags: ["a", "b"] };
|
|
154
|
-
* const source = { tags: ["c"] };
|
|
155
|
-
* deepMerge(target, source); // { tags: ["c"] }
|
|
156
|
-
*
|
|
157
|
-
* // undefined is skipped
|
|
158
|
-
* const base = { a: 1, b: 2 };
|
|
159
|
-
* deepMerge(base, { a: undefined, b: 3 }); // { a: 1, b: 3 }
|
|
160
|
-
*
|
|
161
|
-
* // null explicitly replaces
|
|
162
|
-
* deepMerge(base, { a: null }); // { a: null, b: 2 }
|
|
163
|
-
* ```
|
|
164
|
-
*/
|
|
165
|
-
declare function deepMerge<T extends object>(target: T, source: Partial<T>): T;
|
|
166
|
-
/**
|
|
167
|
-
* Parse configuration file content based on filename extension.
|
|
168
|
-
*
|
|
169
|
-
* Supports multiple formats:
|
|
170
|
-
* - `.toml` - Parsed with smol-toml (preferred for config)
|
|
171
|
-
* - `.yaml`, `.yml` - Parsed with yaml (merge key support enabled)
|
|
172
|
-
* - `.json` - Parsed with strict JSON.parse
|
|
173
|
-
* - `.jsonc` - Parsed with json5 compatibility (comments/trailing commas)
|
|
174
|
-
* - `.json5` - Parsed with json5 (comments and trailing commas allowed)
|
|
175
|
-
*
|
|
176
|
-
* @param content - Raw file content to parse
|
|
177
|
-
* @param filename - Filename used to determine format (by extension)
|
|
178
|
-
* @returns Result containing parsed object or ParseError
|
|
179
|
-
*
|
|
180
|
-
* @example
|
|
181
|
-
* ```typescript
|
|
182
|
-
* const toml = `
|
|
183
|
-
* [server]
|
|
184
|
-
* port = 3000
|
|
185
|
-
* host = "localhost"
|
|
186
|
-
* `;
|
|
187
|
-
*
|
|
188
|
-
* const result = parseConfigFile(toml, "config.toml");
|
|
189
|
-
* if (result.isOk()) {
|
|
190
|
-
* console.log(result.value.server.port); // 3000
|
|
191
|
-
* }
|
|
192
|
-
* ```
|
|
193
|
-
*
|
|
194
|
-
* @example
|
|
195
|
-
* ```typescript
|
|
196
|
-
* // YAML with anchors/aliases
|
|
197
|
-
* const yaml = `
|
|
198
|
-
* defaults: &defaults
|
|
199
|
-
* timeout: 5000
|
|
200
|
-
* server:
|
|
201
|
-
* <<: *defaults
|
|
202
|
-
* port: 3000
|
|
203
|
-
* `;
|
|
204
|
-
*
|
|
205
|
-
* const result = parseConfigFile(yaml, "config.yaml");
|
|
206
|
-
* if (result.isOk()) {
|
|
207
|
-
* console.log(result.value.server.timeout); // 5000
|
|
208
|
-
* }
|
|
209
|
-
* ```
|
|
210
|
-
*/
|
|
211
|
-
declare function parseConfigFile(content: string, filename: string): Result<Record<string, unknown>, InstanceType<typeof ParseError>>;
|
|
212
8
|
/**
|
|
213
9
|
* Configuration sources for multi-layer resolution.
|
|
214
10
|
*
|
|
@@ -274,73 +70,6 @@ interface ConfigSources<T> {
|
|
|
274
70
|
*/
|
|
275
71
|
declare function resolveConfig<T>(schema: ZodSchema<T>, sources: ConfigSources<T>): Result<T, InstanceType<typeof ValidationError> | InstanceType<typeof ParseError>>;
|
|
276
72
|
/**
|
|
277
|
-
* Options for the {@link loadConfig} function.
|
|
278
|
-
*
|
|
279
|
-
* @example
|
|
280
|
-
* ```typescript
|
|
281
|
-
* const options: LoadConfigOptions = {
|
|
282
|
-
* searchPaths: ["/etc/myapp", "/opt/myapp/config"],
|
|
283
|
-
* };
|
|
284
|
-
* ```
|
|
285
|
-
*/
|
|
286
|
-
interface LoadConfigOptions {
|
|
287
|
-
/**
|
|
288
|
-
* Custom search paths to check for config files.
|
|
289
|
-
* When provided, overrides the default XDG-based search paths.
|
|
290
|
-
* Paths are searched in order; first match wins.
|
|
291
|
-
*/
|
|
292
|
-
searchPaths?: string[];
|
|
293
|
-
}
|
|
294
|
-
/**
|
|
295
|
-
* Load configuration for an application from XDG-compliant paths.
|
|
296
|
-
*
|
|
297
|
-
* Search order (first found wins):
|
|
298
|
-
* 1. Custom `searchPaths` if provided in options
|
|
299
|
-
* 2. `$XDG_CONFIG_HOME/{appName}/config.{ext}`
|
|
300
|
-
* 3. `~/.config/{appName}/config.{ext}`
|
|
301
|
-
*
|
|
302
|
-
* File format preference: `.toml` > `.yaml` > `.yml` > `.json` > `.jsonc` > `.json5`
|
|
303
|
-
*
|
|
304
|
-
* @typeParam T - The configuration type (inferred from schema)
|
|
305
|
-
* @param appName - Application name for XDG directory lookup
|
|
306
|
-
* @param schema - Zod schema for validation
|
|
307
|
-
* @param options - Optional configuration (custom search paths)
|
|
308
|
-
* @returns Result containing validated config or NotFoundError/ValidationError/ParseError
|
|
309
|
-
*
|
|
310
|
-
* @example
|
|
311
|
-
* ```typescript
|
|
312
|
-
* import { loadConfig } from "@outfitter/config";
|
|
313
|
-
* import { z } from "zod";
|
|
314
|
-
*
|
|
315
|
-
* const AppConfigSchema = z.object({
|
|
316
|
-
* apiKey: z.string(),
|
|
317
|
-
* timeout: z.number().default(5000),
|
|
318
|
-
* features: z.object({
|
|
319
|
-
* darkMode: z.boolean().default(false),
|
|
320
|
-
* }),
|
|
321
|
-
* });
|
|
322
|
-
*
|
|
323
|
-
* // Searches ~/.config/myapp/config.{toml,yaml,json,...}
|
|
324
|
-
* const result = await loadConfig("myapp", AppConfigSchema);
|
|
325
|
-
*
|
|
326
|
-
* if (result.isOk()) {
|
|
327
|
-
* console.log("API Key:", result.value.apiKey);
|
|
328
|
-
* console.log("Timeout:", result.value.timeout);
|
|
329
|
-
* } else {
|
|
330
|
-
* console.error("Failed to load config:", result.error.message);
|
|
331
|
-
* }
|
|
332
|
-
* ```
|
|
333
|
-
*
|
|
334
|
-
* @example
|
|
335
|
-
* ```typescript
|
|
336
|
-
* // With custom search paths
|
|
337
|
-
* const result = await loadConfig("myapp", AppConfigSchema, {
|
|
338
|
-
* searchPaths: ["/etc/myapp", "/opt/myapp/config"],
|
|
339
|
-
* });
|
|
340
|
-
* ```
|
|
341
|
-
*/
|
|
342
|
-
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>>;
|
|
343
|
-
/**
|
|
344
73
|
* Map environment variables to config object based on prefix.
|
|
345
74
|
*
|
|
346
75
|
* Environment variables are mapped as follows:
|
package/dist/index.js
CHANGED
|
@@ -11,132 +11,25 @@ import {
|
|
|
11
11
|
getEnvironment,
|
|
12
12
|
getEnvironmentDefaults
|
|
13
13
|
} from "./shared/@outfitter/config-w3pwcpr2.js";
|
|
14
|
-
|
|
15
|
-
// packages/config/src/index.ts
|
|
16
|
-
import { existsSync, readFileSync } from "fs";
|
|
17
|
-
import { dirname, isAbsolute, join, resolve } from "path";
|
|
18
14
|
import {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
} from "
|
|
24
|
-
import {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
15
|
+
getCacheDir,
|
|
16
|
+
getConfigDir,
|
|
17
|
+
getDataDir,
|
|
18
|
+
getStateDir
|
|
19
|
+
} from "./shared/@outfitter/config-pf9xp78h.js";
|
|
20
|
+
import {
|
|
21
|
+
loadConfig
|
|
22
|
+
} from "./shared/@outfitter/config-br341dr7.js";
|
|
23
|
+
import"./shared/@outfitter/config-aje2en96.js";
|
|
24
|
+
import {
|
|
25
|
+
CircularExtendsError,
|
|
26
|
+
ParseError,
|
|
27
|
+
deepMerge,
|
|
28
|
+
parseConfigFile
|
|
29
|
+
} from "./shared/@outfitter/config-s4swz8m3.js";
|
|
32
30
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
}
|
|
36
|
-
function getConfigDir(appName) {
|
|
37
|
-
const xdgConfigHome = process.env["XDG_CONFIG_HOME"];
|
|
38
|
-
const home = process.env["HOME"] ?? "";
|
|
39
|
-
const baseDir = xdgConfigHome ?? join(home, ".config");
|
|
40
|
-
return join(baseDir, appName);
|
|
41
|
-
}
|
|
42
|
-
function getDataDir(appName) {
|
|
43
|
-
const xdgDataHome = process.env["XDG_DATA_HOME"];
|
|
44
|
-
const home = process.env["HOME"] ?? "";
|
|
45
|
-
const baseDir = xdgDataHome ?? join(home, ".local", "share");
|
|
46
|
-
return join(baseDir, appName);
|
|
47
|
-
}
|
|
48
|
-
function getCacheDir(appName) {
|
|
49
|
-
const xdgCacheHome = process.env["XDG_CACHE_HOME"];
|
|
50
|
-
const home = process.env["HOME"] ?? "";
|
|
51
|
-
const baseDir = xdgCacheHome ?? join(home, ".cache");
|
|
52
|
-
return join(baseDir, appName);
|
|
53
|
-
}
|
|
54
|
-
function getStateDir(appName) {
|
|
55
|
-
const xdgStateHome = process.env["XDG_STATE_HOME"];
|
|
56
|
-
const home = process.env["HOME"] ?? "";
|
|
57
|
-
const baseDir = xdgStateHome ?? join(home, ".local", "state");
|
|
58
|
-
return join(baseDir, appName);
|
|
59
|
-
}
|
|
60
|
-
function isPlainObject(value) {
|
|
61
|
-
if (value === null || typeof value !== "object") {
|
|
62
|
-
return false;
|
|
63
|
-
}
|
|
64
|
-
if (Array.isArray(value)) {
|
|
65
|
-
return false;
|
|
66
|
-
}
|
|
67
|
-
return true;
|
|
68
|
-
}
|
|
69
|
-
function deepMerge(target, source) {
|
|
70
|
-
const result = { ...target };
|
|
71
|
-
for (const key of Object.keys(source)) {
|
|
72
|
-
const sourceValue = source[key];
|
|
73
|
-
const targetValue = result[key];
|
|
74
|
-
if (sourceValue === undefined) {
|
|
75
|
-
continue;
|
|
76
|
-
}
|
|
77
|
-
if (sourceValue === null) {
|
|
78
|
-
result[key] = null;
|
|
79
|
-
continue;
|
|
80
|
-
}
|
|
81
|
-
if (Array.isArray(sourceValue)) {
|
|
82
|
-
result[key] = sourceValue;
|
|
83
|
-
continue;
|
|
84
|
-
}
|
|
85
|
-
if (isPlainObject(sourceValue) && isPlainObject(targetValue)) {
|
|
86
|
-
result[key] = deepMerge(targetValue, sourceValue);
|
|
87
|
-
continue;
|
|
88
|
-
}
|
|
89
|
-
result[key] = sourceValue;
|
|
90
|
-
}
|
|
91
|
-
return result;
|
|
92
|
-
}
|
|
93
|
-
function getExtension(filename) {
|
|
94
|
-
const lastDot = filename.lastIndexOf(".");
|
|
95
|
-
if (lastDot === -1) {
|
|
96
|
-
return "";
|
|
97
|
-
}
|
|
98
|
-
return filename.slice(lastDot + 1).toLowerCase();
|
|
99
|
-
}
|
|
100
|
-
function parseConfigFile(content, filename) {
|
|
101
|
-
const ext = getExtension(filename);
|
|
102
|
-
try {
|
|
103
|
-
switch (ext) {
|
|
104
|
-
case "toml": {
|
|
105
|
-
const parsed = parseToml(content);
|
|
106
|
-
return Result.ok(parsed);
|
|
107
|
-
}
|
|
108
|
-
case "yaml":
|
|
109
|
-
case "yml": {
|
|
110
|
-
const parsed = parseYaml(content, { merge: true });
|
|
111
|
-
if (parsed === null || typeof parsed !== "object") {
|
|
112
|
-
return Result.ok({});
|
|
113
|
-
}
|
|
114
|
-
return Result.ok(parsed);
|
|
115
|
-
}
|
|
116
|
-
case "json": {
|
|
117
|
-
const parsed = JSON.parse(content);
|
|
118
|
-
return Result.ok(parsed);
|
|
119
|
-
}
|
|
120
|
-
case "jsonc":
|
|
121
|
-
case "json5": {
|
|
122
|
-
const parsed = Bun.JSON5.parse(content);
|
|
123
|
-
return Result.ok(parsed);
|
|
124
|
-
}
|
|
125
|
-
default: {
|
|
126
|
-
return Result.err(new ParseError({
|
|
127
|
-
message: `Unsupported config file extension: .${ext}`,
|
|
128
|
-
filename
|
|
129
|
-
}));
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
} catch (error) {
|
|
133
|
-
const message = error instanceof Error ? error.message : "Unknown parse error";
|
|
134
|
-
return Result.err(new ParseError({
|
|
135
|
-
message: `Failed to parse ${filename}: ${message}`,
|
|
136
|
-
filename
|
|
137
|
-
}));
|
|
138
|
-
}
|
|
139
|
-
}
|
|
31
|
+
// packages/config/src/index.ts
|
|
32
|
+
import { formatZodIssues, Result, ValidationError } from "@outfitter/contracts";
|
|
140
33
|
function resolveConfig(schema, sources) {
|
|
141
34
|
let merged = {};
|
|
142
35
|
if (sources.defaults) {
|
|
@@ -153,133 +46,15 @@ function resolveConfig(schema, sources) {
|
|
|
153
46
|
}
|
|
154
47
|
const parseResult = schema.safeParse(merged);
|
|
155
48
|
if (!parseResult.success) {
|
|
156
|
-
const
|
|
157
|
-
const
|
|
158
|
-
const path = firstIssue?.path?.join(".") ?? "";
|
|
159
|
-
const message = firstIssue?.message ?? "Validation failed";
|
|
160
|
-
const fullMessage = path ? `${path}: ${message}` : message;
|
|
49
|
+
const fullMessage = formatZodIssues(parseResult.error.issues);
|
|
50
|
+
const firstPath = parseResult.error.issues[0]?.path?.join(".");
|
|
161
51
|
return Result.err(new ValidationError({
|
|
162
52
|
message: fullMessage,
|
|
163
|
-
...
|
|
53
|
+
...firstPath ? { field: firstPath } : {}
|
|
164
54
|
}));
|
|
165
55
|
}
|
|
166
56
|
return Result.ok(parseResult.data);
|
|
167
57
|
}
|
|
168
|
-
var CONFIG_EXTENSIONS = ["toml", "yaml", "yml", "json", "jsonc", "json5"];
|
|
169
|
-
function findConfigFile(dir) {
|
|
170
|
-
for (const ext of CONFIG_EXTENSIONS) {
|
|
171
|
-
const filePath = join(dir, `config.${ext}`);
|
|
172
|
-
if (existsSync(filePath)) {
|
|
173
|
-
return filePath;
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
return;
|
|
177
|
-
}
|
|
178
|
-
function getDefaultSearchPaths(appName) {
|
|
179
|
-
const xdgConfigHome = process.env["XDG_CONFIG_HOME"];
|
|
180
|
-
const home = process.env["HOME"] ?? "";
|
|
181
|
-
const defaultConfigPath = join(home, ".config", appName);
|
|
182
|
-
if (xdgConfigHome) {
|
|
183
|
-
const xdgPath = join(xdgConfigHome, appName);
|
|
184
|
-
if (xdgPath !== defaultConfigPath) {
|
|
185
|
-
return [xdgPath, defaultConfigPath];
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
return [defaultConfigPath];
|
|
189
|
-
}
|
|
190
|
-
function loadConfig(appName, schema, options) {
|
|
191
|
-
const searchPaths = options?.searchPaths ? options.searchPaths.map((p) => join(p, appName)) : getDefaultSearchPaths(appName);
|
|
192
|
-
let configFilePath;
|
|
193
|
-
for (const searchPath of searchPaths) {
|
|
194
|
-
const found = findConfigFile(searchPath);
|
|
195
|
-
if (found) {
|
|
196
|
-
configFilePath = found;
|
|
197
|
-
break;
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
if (!configFilePath) {
|
|
201
|
-
return Result.err(new NotFoundError({
|
|
202
|
-
message: `Configuration file not found for ${appName}`,
|
|
203
|
-
resourceType: "config",
|
|
204
|
-
resourceId: appName
|
|
205
|
-
}));
|
|
206
|
-
}
|
|
207
|
-
const loadResult = loadConfigFileWithExtends(configFilePath);
|
|
208
|
-
if (loadResult.isErr()) {
|
|
209
|
-
return Result.err(loadResult.error);
|
|
210
|
-
}
|
|
211
|
-
const parsed = loadResult.unwrap();
|
|
212
|
-
const validateResult = schema.safeParse(parsed);
|
|
213
|
-
if (!validateResult.success) {
|
|
214
|
-
const issues = validateResult.error.issues;
|
|
215
|
-
const firstIssue = issues[0];
|
|
216
|
-
const path = firstIssue?.path?.join(".") ?? "";
|
|
217
|
-
const message = firstIssue?.message ?? "Validation failed";
|
|
218
|
-
const fullMessage = path ? `${path}: ${message}` : message;
|
|
219
|
-
return Result.err(new ValidationError({
|
|
220
|
-
message: fullMessage,
|
|
221
|
-
...path ? { field: path } : {}
|
|
222
|
-
}));
|
|
223
|
-
}
|
|
224
|
-
return Result.ok(validateResult.data);
|
|
225
|
-
}
|
|
226
|
-
function resolveExtendsPath(extendsValue, fromFile) {
|
|
227
|
-
if (isAbsolute(extendsValue)) {
|
|
228
|
-
return extendsValue;
|
|
229
|
-
}
|
|
230
|
-
return resolve(dirname(fromFile), extendsValue);
|
|
231
|
-
}
|
|
232
|
-
function loadConfigFileWithExtends(filePath, visited = new Set) {
|
|
233
|
-
const normalizedPath = resolve(filePath);
|
|
234
|
-
if (visited.has(normalizedPath)) {
|
|
235
|
-
return Result.err(new CircularExtendsError({
|
|
236
|
-
message: `Circular extends detected: ${[...visited, normalizedPath].join(" -> ")}`,
|
|
237
|
-
chain: [...visited, normalizedPath]
|
|
238
|
-
}));
|
|
239
|
-
}
|
|
240
|
-
if (!existsSync(filePath)) {
|
|
241
|
-
return Result.err(new NotFoundError({
|
|
242
|
-
message: `Config file not found: ${filePath}`,
|
|
243
|
-
resourceType: "config",
|
|
244
|
-
resourceId: filePath
|
|
245
|
-
}));
|
|
246
|
-
}
|
|
247
|
-
let content;
|
|
248
|
-
try {
|
|
249
|
-
content = readFileSync(filePath, "utf-8");
|
|
250
|
-
} catch {
|
|
251
|
-
return Result.err(new NotFoundError({
|
|
252
|
-
message: `Failed to read config file: ${filePath}`,
|
|
253
|
-
resourceType: "config",
|
|
254
|
-
resourceId: filePath
|
|
255
|
-
}));
|
|
256
|
-
}
|
|
257
|
-
const filename = filePath.split("/").pop() ?? "config";
|
|
258
|
-
const parseResult = parseConfigFile(content, filename);
|
|
259
|
-
if (parseResult.isErr()) {
|
|
260
|
-
return Result.err(parseResult.error);
|
|
261
|
-
}
|
|
262
|
-
const parsed = parseResult.unwrap();
|
|
263
|
-
const extendsValue = parsed["extends"];
|
|
264
|
-
if (extendsValue === undefined) {
|
|
265
|
-
return Result.ok(parsed);
|
|
266
|
-
}
|
|
267
|
-
if (typeof extendsValue !== "string") {
|
|
268
|
-
return Result.err(new ParseError({
|
|
269
|
-
message: `Invalid "extends" value in ${filePath}: expected string, got ${typeof extendsValue}`,
|
|
270
|
-
filename: filePath
|
|
271
|
-
}));
|
|
272
|
-
}
|
|
273
|
-
visited.add(normalizedPath);
|
|
274
|
-
const extendsPath = resolveExtendsPath(extendsValue, filePath);
|
|
275
|
-
const baseResult = loadConfigFileWithExtends(extendsPath, visited);
|
|
276
|
-
if (baseResult.isErr()) {
|
|
277
|
-
return Result.err(baseResult.error);
|
|
278
|
-
}
|
|
279
|
-
const baseConfig = baseResult.unwrap();
|
|
280
|
-
const { extends: __, ...currentConfig } = parsed;
|
|
281
|
-
return Result.ok(deepMerge(baseConfig, currentConfig));
|
|
282
|
-
}
|
|
283
58
|
function mapEnvToConfig(prefix, _schema) {
|
|
284
59
|
const result = {};
|
|
285
60
|
const prefixWithUnderscore = `${prefix}_`;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { CircularExtendsError, ParseError } from "../shared/@outfitter/config-sp6gradd.js";
|
|
2
|
+
import { NotFoundError, Result } from "@outfitter/contracts";
|
|
3
|
+
/**
|
|
4
|
+
* Resolve an extends path relative to the config file that contains it.
|
|
5
|
+
* @internal
|
|
6
|
+
*/
|
|
7
|
+
declare function resolveExtendsPath(extendsValue: string, fromFile: string): string;
|
|
8
|
+
/**
|
|
9
|
+
* Load a config file and recursively resolve any extends references.
|
|
10
|
+
* @internal
|
|
11
|
+
*/
|
|
12
|
+
declare function loadConfigFileWithExtends(filePath: string, visited?: Set<string>): Result<Record<string, unknown>, InstanceType<typeof NotFoundError> | InstanceType<typeof ParseError> | InstanceType<typeof CircularExtendsError>>;
|
|
13
|
+
export { resolveExtendsPath, loadConfigFileWithExtends };
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Get the XDG config directory for an application.
|
|
3
|
+
*
|
|
4
|
+
* Uses `XDG_CONFIG_HOME` if set, otherwise defaults to `~/.config`.
|
|
5
|
+
* This follows the XDG Base Directory Specification for storing
|
|
6
|
+
* user-specific configuration files.
|
|
7
|
+
*
|
|
8
|
+
* @param appName - Application name used as subdirectory
|
|
9
|
+
* @returns Absolute path to the application's config directory
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* ```typescript
|
|
13
|
+
* // With XDG_CONFIG_HOME="/custom/config"
|
|
14
|
+
* getConfigDir("myapp"); // "/custom/config/myapp"
|
|
15
|
+
*
|
|
16
|
+
* // Without XDG_CONFIG_HOME (uses default)
|
|
17
|
+
* getConfigDir("myapp"); // "/home/user/.config/myapp"
|
|
18
|
+
* ```
|
|
19
|
+
*/
|
|
20
|
+
declare function getConfigDir(appName: string): string;
|
|
21
|
+
/**
|
|
22
|
+
* Get the XDG data directory for an application.
|
|
23
|
+
*
|
|
24
|
+
* Uses `XDG_DATA_HOME` if set, otherwise defaults to `~/.local/share`.
|
|
25
|
+
* This follows the XDG Base Directory Specification for storing
|
|
26
|
+
* user-specific data files (databases, generated content, etc.).
|
|
27
|
+
*
|
|
28
|
+
* @param appName - Application name used as subdirectory
|
|
29
|
+
* @returns Absolute path to the application's data directory
|
|
30
|
+
*
|
|
31
|
+
* @example
|
|
32
|
+
* ```typescript
|
|
33
|
+
* // With XDG_DATA_HOME="/custom/data"
|
|
34
|
+
* getDataDir("myapp"); // "/custom/data/myapp"
|
|
35
|
+
*
|
|
36
|
+
* // Without XDG_DATA_HOME (uses default)
|
|
37
|
+
* getDataDir("myapp"); // "/home/user/.local/share/myapp"
|
|
38
|
+
* ```
|
|
39
|
+
*/
|
|
40
|
+
declare function getDataDir(appName: string): string;
|
|
41
|
+
/**
|
|
42
|
+
* Get the XDG cache directory for an application.
|
|
43
|
+
*
|
|
44
|
+
* Uses `XDG_CACHE_HOME` if set, otherwise defaults to `~/.cache`.
|
|
45
|
+
* This follows the XDG Base Directory Specification for storing
|
|
46
|
+
* non-essential cached data that can be regenerated.
|
|
47
|
+
*
|
|
48
|
+
* @param appName - Application name used as subdirectory
|
|
49
|
+
* @returns Absolute path to the application's cache directory
|
|
50
|
+
*
|
|
51
|
+
* @example
|
|
52
|
+
* ```typescript
|
|
53
|
+
* // With XDG_CACHE_HOME="/custom/cache"
|
|
54
|
+
* getCacheDir("myapp"); // "/custom/cache/myapp"
|
|
55
|
+
*
|
|
56
|
+
* // Without XDG_CACHE_HOME (uses default)
|
|
57
|
+
* getCacheDir("myapp"); // "/home/user/.cache/myapp"
|
|
58
|
+
* ```
|
|
59
|
+
*/
|
|
60
|
+
declare function getCacheDir(appName: string): string;
|
|
61
|
+
/**
|
|
62
|
+
* Get the XDG state directory for an application.
|
|
63
|
+
*
|
|
64
|
+
* Uses `XDG_STATE_HOME` if set, otherwise defaults to `~/.local/state`.
|
|
65
|
+
* This follows the XDG Base Directory Specification for storing
|
|
66
|
+
* state data that should persist between restarts (logs, history, etc.).
|
|
67
|
+
*
|
|
68
|
+
* @param appName - Application name used as subdirectory
|
|
69
|
+
* @returns Absolute path to the application's state directory
|
|
70
|
+
*
|
|
71
|
+
* @example
|
|
72
|
+
* ```typescript
|
|
73
|
+
* // With XDG_STATE_HOME="/custom/state"
|
|
74
|
+
* getStateDir("myapp"); // "/custom/state/myapp"
|
|
75
|
+
*
|
|
76
|
+
* // Without XDG_STATE_HOME (uses default)
|
|
77
|
+
* getStateDir("myapp"); // "/home/user/.local/state/myapp"
|
|
78
|
+
* ```
|
|
79
|
+
*/
|
|
80
|
+
declare function getStateDir(appName: string): string;
|
|
81
|
+
export { getConfigDir, getDataDir, getCacheDir, getStateDir };
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { CircularExtendsError, ParseError } from "./config-sp6gradd.js";
|
|
2
|
+
import { NotFoundError, Result, ValidationError } from "@outfitter/contracts";
|
|
3
|
+
import { ZodSchema } from "zod";
|
|
4
|
+
/**
|
|
5
|
+
* Options for the {@link loadConfig} function.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```typescript
|
|
9
|
+
* const options: LoadConfigOptions = {
|
|
10
|
+
* searchPaths: ["/etc/myapp", "/opt/myapp/config"],
|
|
11
|
+
* };
|
|
12
|
+
* ```
|
|
13
|
+
*/
|
|
14
|
+
interface LoadConfigOptions {
|
|
15
|
+
/**
|
|
16
|
+
* Custom search paths to check for config files.
|
|
17
|
+
* When provided, overrides the default XDG-based search paths.
|
|
18
|
+
* Note: `appName` is appended to each path (e.g., `"/etc/myapp"` becomes `"/etc/myapp/{appName}"`).
|
|
19
|
+
* Paths are searched in order; first match wins.
|
|
20
|
+
*/
|
|
21
|
+
searchPaths?: string[];
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Load configuration for an application from XDG-compliant paths.
|
|
25
|
+
*
|
|
26
|
+
* Search order (first found wins):
|
|
27
|
+
* 1. Custom `searchPaths` if provided in options
|
|
28
|
+
* 2. `$XDG_CONFIG_HOME/{appName}/config.{ext}`
|
|
29
|
+
* 3. `~/.config/{appName}/config.{ext}`
|
|
30
|
+
*
|
|
31
|
+
* File format preference: `.toml` > `.yaml` > `.yml` > `.json` > `.jsonc` > `.json5`
|
|
32
|
+
*
|
|
33
|
+
* When called without a schema, returns the raw parsed config as `unknown`.
|
|
34
|
+
* When called with a schema, returns the validated typed config.
|
|
35
|
+
*
|
|
36
|
+
* @param appName - Application name for XDG directory lookup
|
|
37
|
+
* @returns Result containing raw config or NotFoundError/ParseError/CircularExtendsError
|
|
38
|
+
*
|
|
39
|
+
* @example
|
|
40
|
+
* ```typescript
|
|
41
|
+
* // Without schema — returns raw parsed config
|
|
42
|
+
* const result = loadConfig("myapp");
|
|
43
|
+
* if (result.isOk()) {
|
|
44
|
+
* const config = result.value; // type: unknown
|
|
45
|
+
* }
|
|
46
|
+
* ```
|
|
47
|
+
*/
|
|
48
|
+
declare function loadConfig(appName: string): Result<unknown, InstanceType<typeof NotFoundError> | InstanceType<typeof ParseError> | InstanceType<typeof CircularExtendsError>>;
|
|
49
|
+
/**
|
|
50
|
+
* Load configuration for an application from XDG-compliant paths.
|
|
51
|
+
*
|
|
52
|
+
* @param appName - Application name for XDG directory lookup
|
|
53
|
+
* @param options - Configuration options (custom search paths)
|
|
54
|
+
* @returns Result containing raw config or NotFoundError/ParseError/CircularExtendsError
|
|
55
|
+
*
|
|
56
|
+
* @example
|
|
57
|
+
* ```typescript
|
|
58
|
+
* // Without schema, with custom search paths
|
|
59
|
+
* const result = loadConfig("myapp", { searchPaths: ["/etc/myapp"] });
|
|
60
|
+
* ```
|
|
61
|
+
*/
|
|
62
|
+
declare function loadConfig(appName: string, options: LoadConfigOptions): Result<unknown, InstanceType<typeof NotFoundError> | InstanceType<typeof ParseError> | InstanceType<typeof CircularExtendsError>>;
|
|
63
|
+
/**
|
|
64
|
+
* Load configuration for an application from XDG-compliant paths.
|
|
65
|
+
*
|
|
66
|
+
* @typeParam T - The configuration type (inferred from schema)
|
|
67
|
+
* @param appName - Application name for XDG directory lookup
|
|
68
|
+
* @param schema - Zod schema for validation
|
|
69
|
+
* @returns Result containing validated config or NotFoundError/ValidationError/ParseError/CircularExtendsError
|
|
70
|
+
*
|
|
71
|
+
* @example
|
|
72
|
+
* ```typescript
|
|
73
|
+
* import { loadConfig } from "@outfitter/config";
|
|
74
|
+
* import { z } from "zod";
|
|
75
|
+
*
|
|
76
|
+
* const AppConfigSchema = z.object({
|
|
77
|
+
* apiKey: z.string(),
|
|
78
|
+
* timeout: z.number().default(5000),
|
|
79
|
+
* });
|
|
80
|
+
*
|
|
81
|
+
* const result = loadConfig("myapp", AppConfigSchema);
|
|
82
|
+
* if (result.isOk()) {
|
|
83
|
+
* console.log(result.value.apiKey); // typed!
|
|
84
|
+
* }
|
|
85
|
+
* ```
|
|
86
|
+
*/
|
|
87
|
+
declare function loadConfig<T>(appName: string, schema: ZodSchema<T>): Result<T, InstanceType<typeof NotFoundError> | InstanceType<typeof ValidationError> | InstanceType<typeof ParseError> | InstanceType<typeof CircularExtendsError>>;
|
|
88
|
+
/**
|
|
89
|
+
* Load configuration for an application from XDG-compliant paths.
|
|
90
|
+
*
|
|
91
|
+
* @typeParam T - The configuration type (inferred from schema)
|
|
92
|
+
* @param appName - Application name for XDG directory lookup
|
|
93
|
+
* @param schema - Zod schema for validation
|
|
94
|
+
* @param options - Configuration options (custom search paths)
|
|
95
|
+
* @returns Result containing validated config or NotFoundError/ValidationError/ParseError/CircularExtendsError
|
|
96
|
+
*
|
|
97
|
+
* @example
|
|
98
|
+
* ```typescript
|
|
99
|
+
* const result = loadConfig("myapp", AppConfigSchema, {
|
|
100
|
+
* searchPaths: ["/etc/myapp", "/opt/myapp/config"],
|
|
101
|
+
* });
|
|
102
|
+
* ```
|
|
103
|
+
*/
|
|
104
|
+
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>>;
|
|
105
|
+
export { LoadConfigOptions, loadConfig };
|
|
@@ -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,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@outfitter/config",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "XDG-compliant config loading with schema validation for Outfitter",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"config",
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
"access": "public"
|
|
47
47
|
},
|
|
48
48
|
"scripts": {
|
|
49
|
-
"build": "cd ../.. && bunup --filter @outfitter/config",
|
|
49
|
+
"build": "cd ../.. && bash ./scripts/run-bunup-with-lock.sh bunup --filter @outfitter/config",
|
|
50
50
|
"lint": "oxlint ./src",
|
|
51
51
|
"lint:fix": "oxlint --fix ./src",
|
|
52
52
|
"test": "bun test",
|
|
@@ -55,8 +55,8 @@
|
|
|
55
55
|
"prepublishOnly": "bun ../../scripts/check-publish-manifest.ts"
|
|
56
56
|
},
|
|
57
57
|
"dependencies": {
|
|
58
|
-
"@outfitter/contracts": "0.
|
|
59
|
-
"@outfitter/types": "0.2.
|
|
58
|
+
"@outfitter/contracts": "0.5.0",
|
|
59
|
+
"@outfitter/types": "0.2.5",
|
|
60
60
|
"smol-toml": "^1.6.0",
|
|
61
61
|
"yaml": "^2.8.2",
|
|
62
62
|
"zod": "^4.3.5"
|