Smart configuration loader
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add pluggable ConfigProvider system

Refactors c12's config loading to use a provider-based architecture:

- Add ConfigProvider interface with name, priority, and load() method
- Built-in providers: overrides, main, rc, packageJson, defaultConfig
- Providers sorted by priority (lower = higher precedence)
- New options.providers allows custom/replacement providers
- Providers can access previously loaded configs via context
- Backward compatible: loadConfig() works unchanged without providers
- Exports: getDefaultProviders, sortProviders, create*Provider helpers

This enables users to:
- Inject custom config sources (env vars, remote config, vault)
- Reorder or remove default sources
- Build configs that depend on other loaded configs

rektide 43be190c c1386131

+877 -171
+2 -1
.oxfmtrc.json
··· 1 1 { 2 - "$schema": "https://unpkg.com/oxfmt/configuration_schema.json" 2 + "$schema": "https://unpkg.com/oxfmt/configuration_schema.json", 3 + "ignore": ["test/.tmp/**"] 3 4 }
+388 -82
layering.md
··· 1 - # c12-layer: Understanding & Extending c12's Configuration Layering 1 + # c12-layer: Configuration Layering Analysis & Enhancement Proposal 2 2 3 - ## Goals 3 + ## Problem Statement 4 4 5 - This document guides analysis of c12's configuration layering system and proposes enhancements for more dynamic, introspectable configuration management. 5 + c12 is a powerful configuration loader, but it has limitations that prevent advanced use cases: 6 + 7 + 1. **Fixed Pipeline**: Sources are hardcoded (overrides → config file → RC → package.json → defaults). Users cannot inject custom sources or reorder the pipeline. 6 8 7 - ## Part 1: Understanding the Current System 9 + 2. **No Drop-in Directories**: Unlike systemd's `*.conf.d/` pattern, there's no way to have a directory of config fragments merged automatically. See [unjs/c12#298](https://github.com/unjs/c12/issues/298). 8 10 9 - ### Questions to Answer 11 + 3. **No Provenance Tracking**: After merge, it's impossible to know which layer contributed a specific key. Debugging configuration issues requires manual bisection. 10 12 11 - 1. **Where does layering happen?** 12 - - How does `defu` merge configurations? 13 - - What is the call graph from `loadConfig()` to final merged config? 13 + 4. **Opaque Lifecycle**: The loading process is a black box—no hooks to observe or modify layers before final merge. 14 + 15 + ### Goals 16 + 17 + 1. Understand c12's current layering internals 18 + 2. Assess feasibility of drop-in directory support 19 + 3. Design a layer registry architecture with provenance tracking 20 + 4. Identify extension points for backward-compatible enhancements 21 + 22 + --- 23 + 24 + ## Findings: How c12 Works Today 25 + 26 + ### Layer Resolution Order 27 + 28 + Priority from highest to lowest, based on [`loader.ts#L157-L164`](src/loader.ts#L157-L164): 14 29 15 - 2. **When in the lifecycle?** 16 - - Map the complete execution pipeline from `loadConfig()` invocation to resolved config 17 - - Identify when each source is loaded, when extends are resolved, when merging occurs 30 + | Priority | Source | Code Location | 31 + |----------|--------|---------------| 32 + | 1 (highest) | `options.overrides` | Passed to loadConfig | 33 + | 2 | Main config file (`<name>.config.ts`) | [`L103-L108`](src/loader.ts#L103-L108) | 34 + | 3 | RC files (cwd → workspace → home) | [`L115-L129`](src/loader.ts#L115-L129) | 35 + | 4 | `package.json[name]` | [`L132-L141`](src/loader.ts#L132-L141) | 36 + | 5 | `options.defaultConfig` | Passed to loadConfig | 37 + | 6 | Extended layers (from `extends` key) | [`L167-L172`](src/loader.ts#L167-L172) | 38 + | 7 (lowest) | `options.defaults` | [`L193-L195`](src/loader.ts#L193-L195) | 18 39 19 - 3. **What is the layer resolution order?** 20 - - Document the priority stack (per README: overrides → config file → RC → global RC → package.json → defaults → extended layers) 21 - - How does environment-specific config (`$development`, `$production`, etc.) factor in? 40 + ### Environment-Specific Config 22 41 23 - ### Deliverables 42 + Handled in [`resolveConfig` L411-L420](src/loader.ts#L411-L420): 43 + - Checks for `$development`, `$production`, `$test` based on `options.envName` 44 + - Also checks `$env.{envName}` object 45 + - Merged with **highest priority** on top of the file's base config 24 46 25 - - **Mermaid sequence diagram**: Show the lifecycle from `loadConfig()` call through to final config 26 - - **Mermaid flowchart**: Show decision points (does RC exist? does config extend? etc.) 27 - - **Code citations**: Link to the actual source locations where merging happens 47 + ### Merge Points 28 48 29 - --- 49 + All merges use `defu` (or custom `options.merger`): 30 50 31 - ## Part 2: Dynamic Configuration Sources 51 + | Operation | Location | Code | 52 + |-----------|----------|------| 53 + | RC sources merge | [`L128`](src/loader.ts#L128) | `_merger({}, ...rcSources)` | 54 + | Package.json values | [`L140`](src/loader.ts#L140) | `_merger({}, ...values)` | 55 + | Main 5-source merge | [`L158-L164`](src/loader.ts#L158-L164) | `_merger(overrides, main, rc, packageJson, defaultConfig)` | 56 + | Extended layers | [`L171`](src/loader.ts#L171) | `_merger(config, ...layers.map(e => e.config))` | 57 + | Env-specific | [`L418`](src/loader.ts#L418) | `_merger(envConfig, res.config)` | 58 + | Final defaults | [`L194`](src/loader.ts#L194) | `_merger(config, defaults)` | 59 + 60 + ### Lifecycle Sequence 61 + 62 + ```mermaid 63 + sequenceDiagram 64 + participant User 65 + participant loadConfig 66 + participant resolveConfig 67 + participant rc9 68 + participant pkg-types 69 + participant extendConfig 70 + participant defu 71 + 72 + User->>loadConfig: loadConfig(options) 73 + loadConfig->>loadConfig: Normalize options 74 + 75 + opt options.dotenv 76 + loadConfig->>loadConfig: setupDotenv() 77 + end 78 + 79 + loadConfig->>resolveConfig: resolveConfig(".", options) 80 + resolveConfig-->>loadConfig: {config, configFile} 81 + 82 + opt options.rcFile 83 + loadConfig->>rc9: read(cwd) 84 + opt options.globalRc 85 + loadConfig->>rc9: read(workspace) 86 + loadConfig->>rc9: readUser(home) 87 + end 88 + loadConfig->>defu: merge RC sources 89 + end 90 + 91 + opt options.packageJson 92 + loadConfig->>pkg-types: readPackageJSON() 93 + end 94 + 95 + loadConfig->>defu: merge(overrides, main, rc, pkg, defaultConfig) 96 + 97 + opt options.extend 98 + loadConfig->>extendConfig: extendConfig(config) 99 + loop each extends source 100 + extendConfig->>resolveConfig: resolveConfig(source) 101 + extendConfig->>extendConfig: recursive 102 + end 103 + loadConfig->>defu: merge(config, ...layers) 104 + end 105 + 106 + opt options.defaults 107 + loadConfig->>defu: merge(config, defaults) 108 + end 109 + 110 + loadConfig-->>User: ResolvedConfig 111 + ``` 32 112 33 - ### Problem Statement 113 + ### Decision Flowchart 34 114 35 - c12 currently has a fixed resolution pipeline. We want to explore: 115 + ```mermaid 116 + flowchart TD 117 + start([loadConfig]) --> normalize[Normalize options] 118 + normalize --> dotenv{dotenv?} 119 + dotenv -->|yes| loadDotenv[setupDotenv] 120 + dotenv -->|no| mainFile 121 + loadDotenv --> mainFile 122 + 123 + mainFile{Config file exists?} -->|yes| loadMain[resolveConfig: load file] 124 + mainFile -->|no| rcCheck 125 + loadMain --> rcCheck 126 + 127 + rcCheck{rcFile?} -->|yes| loadRC[rc9.read cwd] 128 + rcCheck -->|no| pkgCheck 129 + loadRC --> globalRC{globalRc?} 130 + globalRC -->|yes| loadGlobal[rc9.read workspace + home] 131 + globalRC -->|no| mergeRC 132 + loadGlobal --> mergeRC[defu merge RC sources] 133 + mergeRC --> pkgCheck 134 + 135 + pkgCheck{packageJson?} -->|yes| loadPkg[readPackageJSON] 136 + pkgCheck -->|no| mainMerge 137 + loadPkg --> mainMerge 138 + 139 + mainMerge[defu: overrides → main → rc → pkg → defaultConfig] 140 + mainMerge --> extend{extends?} 141 + 142 + extend -->|yes| extendLoop[extendConfig recursively] 143 + extendLoop --> mergeLayers[defu merge layers] 144 + extend -->|no| defaults 145 + mergeLayers --> defaults 146 + 147 + defaults{defaults?} -->|yes| applyDefaults[defu merge defaults] 148 + defaults -->|no| cleanup 149 + applyDefaults --> cleanup 150 + 151 + cleanup --> omitKeys{omit$Keys?} 152 + omitKeys -->|yes| removeKeys[Remove $ prefixed keys] 153 + omitKeys -->|no| done 154 + removeKeys --> done([ResolvedConfig]) 155 + ``` 36 156 37 - 1. **Drop-in config directories** (à la systemd's `*.conf.d/`) 38 - - Reference: https://github.com/unjs/c12/issues/298 39 - - Allow `<name>.config.d/` directories where multiple configs can be dropped in 40 - - Configs sorted alphabetically (or with numeric prefixes like `00-base.ts`, `10-overrides.ts`) 157 + ### Existing Extension Points 41 158 42 - 2. **Pluggable source providers** 43 - - Instead of hardcoded sources (file, RC, package.json), allow registering custom providers 44 - - Examples: environment variables provider, remote config provider, vault/secrets provider 159 + 1. **`options.resolve`** ([`L284-L288`](src/loader.ts#L284-L288)): Custom resolver can intercept any source before default resolution 160 + 2. **`options.merger`** ([`L71`](src/loader.ts#L71)): Replace `defu` with custom merge function 161 + 3. **`options.import`** ([`L385-L386`](src/loader.ts#L385-L386)): Custom module loader 162 + 4. **`extends` key**: Already supports arrays of sources with recursive resolution 45 163 46 - ### Design Questions 164 + ### Current Limitations 47 165 48 - - How would drop-in directories integrate with the existing layer system? 49 - - Should drop-in configs be siblings to extended layers, or a separate concept? 50 - - How do we handle ordering/priority for drop-in files? 166 + 1. **No insertion points**: Can't add sources between existing ones (e.g., between RC and package.json) 167 + 2. **Post-hoc layers**: `ResolvedConfig.layers` preserves sources but only after merge—no pre-merge introspection 168 + 3. **No directory scanning**: `resolveConfig` handles single files only 169 + 4. **No key-level tracking**: `defu` merges destructively with no provenance 51 170 52 171 --- 53 172 54 - ## Part 3: Layer Registry Architecture 173 + ## Proposed Solutions 55 174 56 - ### Vision 175 + ### Solution 1: Drop-in Directory Support 57 176 58 - Transform c12 from a "fixed pipeline config loader" into a "structured config layer manager" with: 177 + **Problem**: Users want `myapp.config.d/` directories with sorted fragments like `00-base.ts`, `10-local.ts`. 59 178 60 - 1. **Explicit layer registry** 61 - - Named, ordered collection of configuration sources 62 - - Each layer has metadata: name, source type, priority, file path(s) 179 + **Approach**: Add a `configDir` source type that scans a directory and sorts files. 63 180 64 - 2. **Two-phase execution** 65 - - **Build phase**: Construct the layer registry, validate sources exist, resolve extends 66 - - **Run phase**: Execute the registry to produce final merged config 181 + ```typescript 182 + // New function in loader.ts 183 + async function loadConfigDir<T>( 184 + dirPath: string, 185 + options: LoadConfigOptions<T> 186 + ): Promise<ConfigLayer<T>[]> { 187 + const dir = resolve(options.cwd!, dirPath); 188 + if (!existsSync(dir)) return []; 189 + 190 + const entries = await readdir(dir); 191 + const configFiles = entries 192 + .filter(f => SUPPORTED_EXTENSIONS.some(ext => f.endsWith(ext))) 193 + .sort(); // Alphabetical: 00-base.ts < 10-overrides.ts 194 + 195 + const layers: ConfigLayer<T>[] = []; 196 + for (const file of configFiles) { 197 + const res = await resolveConfig(join(dir, file), options); 198 + if (res.config) { 199 + layers.push(res); 200 + } 201 + } 202 + return layers; 203 + } 204 + ``` 67 205 68 - 3. **Introspection capabilities** 69 - - Query which layer provided a specific config key 70 - - Trace config value provenance (like Rust's `figment` crate metadata) 71 - - Debug mode showing layer-by-layer merge steps 206 + **Integration Options**: 72 207 73 - ### Inspiration 208 + | Option | Where | Behavior | 209 + |--------|-------|----------| 210 + | A. New rawConfigs source | After `main` | `rawConfigs.configDir = loadConfigDir(...)` | 211 + | B. Auto-extend | In `extendConfig` | Treat `.config.d/` as implicit extends | 212 + | C. User-opt-in | Via `options.configDir` | Explicit enable with priority control | 74 213 75 - - **Rust's figment**: Providers with metadata, value provenance tracking 76 - - **systemd**: Drop-in directories, clear override semantics 77 - - **Kubernetes**: ConfigMap layering, strategic merge patches 214 + **Recommended**: Option C with priority parameter: 215 + ```typescript 216 + loadConfig({ 217 + name: 'myapp', 218 + configDir: { 219 + path: 'myapp.config.d/', 220 + priority: 'after-main' // or 'before-rc', 'after-extends' 221 + } 222 + }) 223 + ``` 224 + 225 + --- 78 226 79 - ### Proposed API Sketch 227 + ### Solution 2: Pluggable Source Providers 228 + 229 + **Problem**: Users want custom sources (env vars, remote config, Vault) integrated into the pipeline. 230 + 231 + **Approach**: Define a `ConfigProvider` interface and allow registration. 80 232 81 233 ```typescript 82 - // Build a layer registry explicitly 83 - const registry = createLayerRegistry({ 84 - name: "myapp" 234 + // New types 235 + interface ConfigProvider<T = any> { 236 + name: string; 237 + priority: number; // Lower = higher priority 238 + load(options: LoadConfigOptions<T>): Promise<ConfigLayer<T> | null>; 239 + } 240 + 241 + // Built-in providers 242 + const builtinProviders: ConfigProvider[] = [ 243 + { name: 'overrides', priority: 0, load: (o) => ({ config: o.overrides }) }, 244 + { name: 'main', priority: 100, load: (o) => resolveConfig('.', o) }, 245 + { name: 'rc', priority: 200, load: loadRcFiles }, 246 + { name: 'packageJson', priority: 300, load: loadPackageJson }, 247 + { name: 'defaultConfig', priority: 400, load: (o) => ({ config: o.defaultConfig }) }, 248 + ]; 249 + 250 + // User registration 251 + loadConfig({ 252 + providers: [ 253 + ...defaultProviders, 254 + { name: 'env', priority: 50, load: envProvider }, 255 + { name: 'vault', priority: 150, load: vaultProvider }, 256 + ] 85 257 }) 86 - .addSource("defaults", { type: "static", config: { ... } }) 87 - .addSource("base-file", { type: "file", path: "myapp.config.ts" }) 88 - .addSource("drop-ins", { type: "directory", path: "myapp.config.d/" }) 89 - .addSource("env-overrides", { type: "env", prefix: "MYAPP_" }) 90 - .addSource("cli-overrides", { type: "static", config: cliArgs }); 258 + ``` 91 259 92 - // Inspect before running 93 - console.log(registry.layers); // See all registered sources 94 - console.log(registry.resolve("database.host")); // Which layer provides this? 260 + **Benefits**: 261 + - Full control over source order 262 + - Clean separation of concerns 263 + - Easy to add/remove/reorder sources 264 + 265 + --- 266 + 267 + ### Solution 3: Layer Registry with Two-Phase Execution 268 + 269 + **Problem**: No way to inspect layers before merge or trace value provenance. 270 + 271 + **Approach**: Separate "build" and "load" phases. 272 + 273 + ```typescript 274 + // New API 275 + interface LayerRegistry<T> { 276 + readonly layers: ReadonlyArray<RegisteredLayer<T>>; 277 + 278 + // Build phase 279 + addSource(name: string, source: SourceDefinition): LayerRegistry<T>; 280 + validate(): Promise<ValidationResult>; 281 + 282 + // Introspection (pre-load) 283 + getLayerByName(name: string): RegisteredLayer<T> | undefined; 284 + 285 + // Execution 286 + load(): Promise<ResolvedConfigWithProvenance<T>>; 287 + } 288 + 289 + interface RegisteredLayer<T> { 290 + name: string; 291 + priority: number; 292 + source: SourceDefinition; 293 + status: 'pending' | 'loaded' | 'not-found' | 'error'; 294 + config?: T; 295 + configFile?: string; 296 + } 297 + 298 + interface ResolvedConfigWithProvenance<T> { 299 + config: T; 300 + layers: RegisteredLayer<T>[]; 301 + provenance: Map<string, LayerProvenance>; // key path → which layer 302 + } 303 + 304 + interface LayerProvenance { 305 + layerName: string; 306 + configFile?: string; 307 + keyPath: string; 308 + } 309 + ``` 310 + 311 + **Usage**: 312 + ```typescript 313 + const registry = createLayerRegistry({ name: 'myapp' }) 314 + .addSource('defaults', { type: 'static', config: { port: 3000 } }) 315 + .addSource('base', { type: 'file', path: 'myapp.config.ts' }) 316 + .addSource('drop-ins', { type: 'directory', path: 'myapp.config.d/' }) 317 + .addSource('env', { type: 'env', prefix: 'MYAPP_' }) 318 + .addSource('cli', { type: 'static', config: cliArgs }); 319 + 320 + // Validate before loading 321 + const validation = await registry.validate(); 322 + if (!validation.ok) { 323 + console.error('Missing sources:', validation.missing); 324 + } 95 325 96 - // Execute to get final config 326 + // Load with provenance 97 327 const { config, provenance } = await registry.load(); 328 + 329 + // Debug: where did database.host come from? 330 + console.log(provenance.get('database.host')); 331 + // → { layerName: 'drop-ins', configFile: 'myapp.config.d/20-database.ts', keyPath: 'database.host' } 98 332 ``` 99 333 100 334 --- 101 335 102 - ## Part 4: Implementation Considerations 336 + ### Solution 4: Provenance-Tracking Merger 337 + 338 + **Problem**: `defu` merges destructively—no way to know which layer contributed a key. 339 + 340 + **Approach**: Wrap merge with a tracking layer. 341 + 342 + ```typescript 343 + function createProvenanceMerger<T>() { 344 + const provenance = new Map<string, LayerProvenance>(); 345 + 346 + function trackingMerger( 347 + layerName: string, 348 + configFile: string | undefined 349 + ): (...sources: T[]) => T { 350 + return (...sources) => { 351 + // Use defu for actual merge 352 + const result = defu(...sources); 353 + 354 + // Track which keys came from which layer 355 + // (Simplified: real impl would deep-traverse) 356 + for (const [key, value] of Object.entries(sources[0] || {})) { 357 + if (value !== undefined && !provenance.has(key)) { 358 + provenance.set(key, { layerName, configFile, keyPath: key }); 359 + } 360 + } 361 + 362 + return result; 363 + }; 364 + } 365 + 366 + return { trackingMerger, provenance }; 367 + } 368 + ``` 369 + 370 + **Deep tracking** would require a recursive merge that records the path for every leaf value. 371 + 372 + --- 373 + 374 + ### Solution 5: Backward-Compatible Integration 375 + 376 + **Problem**: New features must not break existing `loadConfig()` users. 377 + 378 + **Approach**: Layer registry is opt-in; `loadConfig` continues unchanged. 379 + 380 + ```typescript 381 + // Existing API unchanged 382 + const config = await loadConfig({ name: 'myapp' }); 383 + 384 + // New API for power users 385 + const registry = await buildLayerRegistry({ name: 'myapp' }); 386 + const { config, provenance } = await registry.load(); 387 + 388 + // Or: loadConfig with provenance opt-in 389 + const { config, provenance } = await loadConfig({ 390 + name: 'myapp', 391 + trackProvenance: true, // New option 392 + }); 393 + ``` 103 394 104 - ### Backward Compatibility 395 + **Implementation**: Refactor `loadConfig` internals to use registry, but expose existing return type by default. 105 396 106 - - `loadConfig()` should continue working unchanged 107 - - New APIs are opt-in enhancements 397 + --- 108 398 109 - ### Key Extension Points to Identify 399 + ## Implementation Roadmap 110 400 111 - 1. Where can we hook into layer resolution? 112 - 2. Can we intercept/wrap the merge function? 113 - 3. How do we inject additional sources into the pipeline? 401 + ### Phase 1: Drop-in Directories 402 + - Add `loadConfigDir()` function 403 + - Add `options.configDir` to `LoadConfigOptions` 404 + - Integrate into layer collection before extends resolution 405 + - **Effort**: Small, self-contained change 114 406 115 - ### Files to Analyze 407 + ### Phase 2: Provider Interface 408 + - Define `ConfigProvider` interface 409 + - Refactor existing sources as built-in providers 410 + - Add `options.providers` for custom sources 411 + - **Effort**: Medium, requires restructuring loader.ts 116 412 117 - - `src/loader.ts` - Main loading logic 118 - - `src/config.ts` - Config resolution 119 - - Look for `defu` usage patterns 120 - - Look for `extends` resolution logic 413 + ### Phase 3: Layer Registry 414 + - Create `LayerRegistry` class 415 + - Implement two-phase execution 416 + - Add `buildLayerRegistry()` export 417 + - **Effort**: Large, new module 418 + 419 + ### Phase 4: Provenance Tracking 420 + - Implement tracking merger 421 + - Integrate with registry 422 + - Add `trackProvenance` option to loadConfig 423 + - **Effort**: Medium, requires careful deep-object traversal 121 424 122 425 --- 123 426 124 - ## Success Criteria 427 + ## Open Questions 125 428 126 - After this analysis, we should have: 429 + 1. **Priority notation**: Should priorities be numeric (0-1000) or named slots (`before-main`, `after-rc`)? 127 430 128 - 1. Clear understanding of c12's internals with diagrams 129 - 2. Feasibility assessment for drop-in directories 130 - 3. Draft design for layer registry architecture 131 - 4. Identified extension points or required changes to c12 431 + 2. **Drop-in merge order**: Should drop-ins merge left-to-right (later files override) or right-to-left (earlier files have priority)? 432 + 433 + 3. **Provenance granularity**: Track at key level only, or full path (`database.connection.host`)? 434 + 435 + 4. **Async providers**: How to handle slow providers (Vault, remote config) gracefully? 436 + 437 + 5. **Caching**: Should registry cache loaded configs for repeated `.load()` calls?
+13
src/index.ts
··· 4 4 5 5 export * from "./types.ts"; 6 6 7 + export { 8 + type ConfigProvider, 9 + type ProviderContext, 10 + type ProviderResult, 11 + getDefaultProviders, 12 + sortProviders, 13 + createOverridesProvider, 14 + createMainProvider, 15 + createRcProvider, 16 + createPackageJsonProvider, 17 + createDefaultConfigProvider, 18 + } from "./providers.ts"; 19 + 7 20 export { type ConfigWatcher, type WatchConfigOptions, watchConfig } from "./watch.ts";
+50 -85
src/loader.ts
··· 4 4 import { homedir } from "node:os"; 5 5 import { resolve, extname, dirname, basename, join, normalize } from "pathe"; 6 6 import { resolveModulePath } from "exsolve"; 7 - import * as rc9 from "rc9"; 8 7 import { defu } from "defu"; 9 - import { findWorkspaceDir, readPackageJSON } from "pkg-types"; 10 8 import { setupDotenv } from "./dotenv.ts"; 9 + import { 10 + getDefaultProviders, 11 + sortProviders, 12 + type ConfigProvider, 13 + type ProviderContext, 14 + type ProviderResult, 15 + } from "./providers.ts"; 11 16 12 17 import type { 13 18 UserInputConfig, 14 19 ConfigLayerMeta, 15 20 LoadConfigOptions, 16 21 ResolvedConfig, 17 - ResolvableConfig, 18 22 ConfigLayer, 19 23 SourceOptions, 20 24 InputConfig, 21 - ConfigSource, 22 25 ConfigFunctionContext, 23 26 } from "./types.ts"; 24 27 ··· 70 73 // Custom merger 71 74 const _merger = options.merger || defu; 72 75 73 - // Create context 76 + // Create result context 74 77 const r: ResolvedConfig<T, MT> = { 75 78 config: {} as any, 76 79 cwd: options.cwd, ··· 79 82 _configFile: undefined, 80 83 }; 81 84 82 - // prettier-ignore 83 - const rawConfigs: Record< 84 - ConfigSource, 85 - ResolvableConfig<T> | null | undefined 86 - > = { 87 - overrides: options.overrides, 88 - main: undefined, 89 - rc: undefined, 90 - packageJson: undefined, 91 - defaultConfig: options.defaultConfig, 92 - }; 93 - 94 85 // Load dotenv 95 86 if (options.dotenv) { 96 87 await setupDotenv({ ··· 99 90 }); 100 91 } 101 92 102 - // Load main config file 103 - const _mainConfig = await resolveConfig(".", options); 104 - if (_mainConfig.configFile) { 105 - rawConfigs.main = _mainConfig.config; 106 - r.configFile = _mainConfig.configFile; 107 - r._configFile = _mainConfig._configFile; 108 - } 93 + // Get providers (custom or default) 94 + const providers = sortProviders(options.providers ?? getDefaultProviders<T, MT>()); 109 95 110 - if (_mainConfig.meta) { 111 - r.meta = _mainConfig.meta; 112 - } 96 + // Create provider context 97 + const loadedConfigs = new Map<string, T | null | undefined>(); 98 + const providerCtx: ProviderContext<T, MT> = { 99 + options, 100 + merger: _merger, 101 + resolveConfig: (source, opts) => resolveConfig(source, opts), 102 + loadedConfigs, 103 + }; 113 104 114 - // Load rc files 115 - if (options.rcFile) { 116 - const rcSources: T[] = []; 117 - // 1. cwd 118 - rcSources.push(rc9.read({ name: options.rcFile, dir: options.cwd })); 119 - if (options.globalRc) { 120 - // 2. workspace 121 - const workspaceDir = await findWorkspaceDir(options.cwd).catch(() => {}); 122 - if (workspaceDir) { 123 - rcSources.push(rc9.read({ name: options.rcFile, dir: workspaceDir })); 105 + // Load all providers and collect results 106 + const providerResults: Array<{ 107 + provider: ConfigProvider<T, MT>; 108 + result: ProviderResult<T, MT>; 109 + }> = []; 110 + 111 + for (const provider of providers) { 112 + const result = await provider.load(providerCtx); 113 + if (result?.config !== undefined && result?.config !== null) { 114 + loadedConfigs.set(provider.name, result.config); 115 + providerResults.push({ provider, result }); 116 + 117 + // Capture main config metadata 118 + if (provider.name === "main") { 119 + if (result.configFile) r.configFile = result.configFile; 120 + if (result._configFile) r._configFile = result._configFile; 121 + if (result.meta) r.meta = result.meta; 124 122 } 125 - // 3. user home 126 - rcSources.push(rc9.readUser({ name: options.rcFile, dir: options.cwd })); 127 123 } 128 - rawConfigs.rc = _merger({} as T, ...rcSources); 129 124 } 130 125 131 - // Load config from package.json 132 - if (options.packageJson) { 133 - const keys = ( 134 - Array.isArray(options.packageJson) 135 - ? options.packageJson 136 - : [typeof options.packageJson === "string" ? options.packageJson : options.name] 137 - ).filter((t) => t && typeof t === "string"); 138 - const pkgJsonFile = await readPackageJSON(options.cwd).catch(() => {}); 139 - const values = keys.map((key) => pkgJsonFile?.[key]); 140 - rawConfigs.packageJson = _merger({} as T, ...values); 141 - } 126 + // Extract configs in priority order for merging 127 + const configs = providerResults.map((pr) => pr.result.config) as Array<T | null | undefined>; 142 128 143 - // Resolve config sources 144 - const configs = {} as Record<ConfigSource, T | null | undefined>; 145 - // TODO: #253 change order from defaults to overrides in next major version 146 - for (const key in rawConfigs) { 147 - const value = rawConfigs[key as ConfigSource]; 148 - configs[key as ConfigSource] = await (typeof value === "function" 149 - ? value({ configs, rawConfigs }) 150 - : value); 151 - } 152 - 153 - if (Array.isArray(configs.main)) { 154 - // If the main config exports an array, use it directly without merging or extending 155 - r.config = configs.main; 129 + // Check if main config is an array (special case: use directly without merging) 130 + const mainResult = providerResults.find((pr) => pr.provider.name === "main"); 131 + if (Array.isArray(mainResult?.result.config)) { 132 + r.config = mainResult.result.config as T; 156 133 } else { 157 - // Combine sources 158 - r.config = _merger( 159 - configs.overrides, 160 - configs.main, 161 - configs.rc, 162 - configs.packageJson, 163 - configs.defaultConfig, 164 - ) as T; 134 + // Merge all provider configs in priority order 135 + r.config = _merger(...(configs as [T, ...Array<T | null | undefined>])) as T; 165 136 166 137 // Allow extending 167 138 if (options.extend) { ··· 173 144 } 174 145 175 146 // Preserve unmerged sources as layers 176 - const baseLayers: ConfigLayer<T, MT>[] = [ 177 - configs.overrides && { 178 - config: configs.overrides, 179 - configFile: undefined, 180 - cwd: undefined, 181 - }, 182 - { config: configs.main, configFile: options.configFile, cwd: options.cwd }, 183 - configs.rc && { config: configs.rc, configFile: options.rcFile }, 184 - configs.packageJson && { 185 - config: configs.packageJson, 186 - configFile: "package.json", 187 - }, 188 - ].filter((l) => l && l.config) as ConfigLayer<T, MT>[]; 147 + const baseLayers: ConfigLayer<T, MT>[] = providerResults 148 + .filter((pr) => pr.result.layer) 149 + .map((pr) => ({ 150 + ...pr.result.layer, 151 + config: pr.result.config, 152 + })) 153 + .filter((l) => l.config) as ConfigLayer<T, MT>[]; 189 154 190 155 r.layers = [...baseLayers, ...r.layers!]; 191 156
+274
src/providers.ts
··· 1 + import * as rc9 from "rc9"; 2 + import { findWorkspaceDir, readPackageJSON } from "pkg-types"; 3 + 4 + import type { 5 + UserInputConfig, 6 + ConfigLayerMeta, 7 + LoadConfigOptions, 8 + ConfigLayer, 9 + ResolvableConfig, 10 + } from "./types.ts"; 11 + 12 + /** 13 + * Context passed to config providers during loading 14 + */ 15 + export interface ProviderContext< 16 + T extends UserInputConfig = UserInputConfig, 17 + MT extends ConfigLayerMeta = ConfigLayerMeta, 18 + > { 19 + /** Normalized load options */ 20 + options: LoadConfigOptions<T, MT>; 21 + /** Merger function (defu or custom) */ 22 + merger: (...sources: Array<T | null | undefined>) => T; 23 + /** Resolve a config file (used by main provider) */ 24 + resolveConfig: ( 25 + source: string, 26 + options: LoadConfigOptions<T, MT>, 27 + ) => Promise<{ 28 + config?: T; 29 + configFile?: string; 30 + _configFile?: string; 31 + cwd?: string; 32 + meta?: MT; 33 + }>; 34 + /** Results from previously loaded providers (by name) */ 35 + loadedConfigs: Map<string, T | null | undefined>; 36 + } 37 + 38 + /** 39 + * Result returned by a config provider 40 + */ 41 + export interface ProviderResult< 42 + T extends UserInputConfig = UserInputConfig, 43 + MT extends ConfigLayerMeta = ConfigLayerMeta, 44 + > { 45 + /** The loaded configuration (or null/undefined if not found) */ 46 + config: T | null | undefined; 47 + /** Layer metadata for introspection */ 48 + layer?: Partial<ConfigLayer<T, MT>>; 49 + /** Additional metadata to merge into ResolvedConfig */ 50 + meta?: MT; 51 + /** Resolved config file path (for main provider) */ 52 + configFile?: string; 53 + /** Internal config file path */ 54 + _configFile?: string; 55 + } 56 + 57 + /** 58 + * A pluggable configuration source provider 59 + */ 60 + export interface ConfigProvider< 61 + T extends UserInputConfig = UserInputConfig, 62 + MT extends ConfigLayerMeta = ConfigLayerMeta, 63 + > { 64 + /** Unique name for this provider */ 65 + name: string; 66 + /** 67 + * Priority determines merge order. 68 + * Lower numbers = higher priority (merged first, so they "win"). 69 + * Built-in priorities: overrides=100, main=200, rc=300, packageJson=400, defaultConfig=500 70 + */ 71 + priority: number; 72 + /** 73 + * Load configuration from this provider. 74 + * Return null/undefined if this provider has no config to contribute. 75 + */ 76 + load(ctx: ProviderContext<T, MT>): Promise<ProviderResult<T, MT> | null | undefined>; 77 + } 78 + 79 + /** 80 + * Built-in provider: overrides from options 81 + */ 82 + export function createOverridesProvider< 83 + T extends UserInputConfig = UserInputConfig, 84 + MT extends ConfigLayerMeta = ConfigLayerMeta, 85 + >(): ConfigProvider<T, MT> { 86 + return { 87 + name: "overrides", 88 + priority: 100, 89 + async load(ctx) { 90 + const config = await resolveResolvableConfig(ctx.options.overrides, ctx); 91 + if (!config) return null; 92 + return { 93 + config, 94 + layer: { 95 + config, 96 + configFile: undefined, 97 + cwd: undefined, 98 + }, 99 + }; 100 + }, 101 + }; 102 + } 103 + 104 + /** 105 + * Built-in provider: main config file 106 + */ 107 + export function createMainProvider< 108 + T extends UserInputConfig = UserInputConfig, 109 + MT extends ConfigLayerMeta = ConfigLayerMeta, 110 + >(): ConfigProvider<T, MT> { 111 + return { 112 + name: "main", 113 + priority: 200, 114 + async load(ctx) { 115 + const result = await ctx.resolveConfig(".", ctx.options); 116 + if (!result.configFile) return null; 117 + return { 118 + config: result.config, 119 + configFile: result.configFile, 120 + _configFile: result._configFile, 121 + meta: result.meta, 122 + layer: { 123 + config: result.config, 124 + configFile: ctx.options.configFile, 125 + cwd: ctx.options.cwd, 126 + }, 127 + }; 128 + }, 129 + }; 130 + } 131 + 132 + /** 133 + * Built-in provider: RC files (.namerc) 134 + */ 135 + export function createRcProvider< 136 + T extends UserInputConfig = UserInputConfig, 137 + MT extends ConfigLayerMeta = ConfigLayerMeta, 138 + >(): ConfigProvider<T, MT> { 139 + return { 140 + name: "rc", 141 + priority: 300, 142 + async load(ctx) { 143 + const { options, merger } = ctx; 144 + if (!options.rcFile) return null; 145 + 146 + const rcSources: T[] = []; 147 + 148 + // 1. cwd 149 + rcSources.push(rc9.read({ name: options.rcFile, dir: options.cwd })); 150 + 151 + if (options.globalRc) { 152 + // 2. workspace 153 + const workspaceDir = await findWorkspaceDir(options.cwd!).catch(() => {}); 154 + if (workspaceDir) { 155 + rcSources.push(rc9.read({ name: options.rcFile, dir: workspaceDir })); 156 + } 157 + // 3. user home 158 + rcSources.push(rc9.readUser({ name: options.rcFile, dir: options.cwd })); 159 + } 160 + 161 + const config = merger({} as T, ...rcSources); 162 + if (!config || Object.keys(config).length === 0) return null; 163 + 164 + return { 165 + config, 166 + layer: { 167 + config, 168 + configFile: options.rcFile, 169 + }, 170 + }; 171 + }, 172 + }; 173 + } 174 + 175 + /** 176 + * Built-in provider: package.json config 177 + */ 178 + export function createPackageJsonProvider< 179 + T extends UserInputConfig = UserInputConfig, 180 + MT extends ConfigLayerMeta = ConfigLayerMeta, 181 + >(): ConfigProvider<T, MT> { 182 + return { 183 + name: "packageJson", 184 + priority: 400, 185 + async load(ctx) { 186 + const { options, merger } = ctx; 187 + if (!options.packageJson) return null; 188 + 189 + const keys = ( 190 + Array.isArray(options.packageJson) 191 + ? options.packageJson 192 + : [typeof options.packageJson === "string" ? options.packageJson : options.name] 193 + ).filter((t): t is string => typeof t === "string" && t.length > 0); 194 + 195 + const pkgJsonFile = await readPackageJSON(options.cwd!).catch(() => {}); 196 + if (!pkgJsonFile) return null; 197 + 198 + const values = keys.map((key) => pkgJsonFile[key] as T | undefined); 199 + const config = merger({} as T, ...values); 200 + if (!config || Object.keys(config).length === 0) return null; 201 + 202 + return { 203 + config, 204 + layer: { 205 + config, 206 + configFile: "package.json", 207 + }, 208 + }; 209 + }, 210 + }; 211 + } 212 + 213 + /** 214 + * Built-in provider: defaultConfig from options 215 + */ 216 + export function createDefaultConfigProvider< 217 + T extends UserInputConfig = UserInputConfig, 218 + MT extends ConfigLayerMeta = ConfigLayerMeta, 219 + >(): ConfigProvider<T, MT> { 220 + return { 221 + name: "defaultConfig", 222 + priority: 500, 223 + async load(ctx) { 224 + const config = await resolveResolvableConfig(ctx.options.defaultConfig, ctx); 225 + if (!config) return null; 226 + return { config }; 227 + }, 228 + }; 229 + } 230 + 231 + /** 232 + * Get the default set of built-in providers in standard order 233 + */ 234 + export function getDefaultProviders< 235 + T extends UserInputConfig = UserInputConfig, 236 + MT extends ConfigLayerMeta = ConfigLayerMeta, 237 + >(): ConfigProvider<T, MT>[] { 238 + return [ 239 + createOverridesProvider<T, MT>(), 240 + createMainProvider<T, MT>(), 241 + createRcProvider<T, MT>(), 242 + createPackageJsonProvider<T, MT>(), 243 + createDefaultConfigProvider<T, MT>(), 244 + ]; 245 + } 246 + 247 + /** 248 + * Sort providers by priority (lower priority number = higher precedence) 249 + */ 250 + export function sortProviders<T extends UserInputConfig, MT extends ConfigLayerMeta>( 251 + providers: ConfigProvider<T, MT>[], 252 + ): ConfigProvider<T, MT>[] { 253 + return [...providers].sort((a, b) => a.priority - b.priority); 254 + } 255 + 256 + /** 257 + * Helper to resolve a ResolvableConfig (handles functions) 258 + */ 259 + async function resolveResolvableConfig<T extends UserInputConfig, MT extends ConfigLayerMeta>( 260 + value: ResolvableConfig<T> | null | undefined, 261 + ctx: ProviderContext<T, MT>, 262 + ): Promise<T | null | undefined> { 263 + if (typeof value === "function") { 264 + // Build legacy context from loaded configs 265 + const configs: Record<string, T | null | undefined> = {}; 266 + const rawConfigs: Record<string, ResolvableConfig<T> | null | undefined> = {}; 267 + for (const [name, config] of ctx.loadedConfigs) { 268 + configs[name] = config; 269 + rawConfigs[name] = config; 270 + } 271 + return value({ configs, rawConfigs } as any); 272 + } 273 + return value; 274 + }
+24
src/types.ts
··· 1 1 import type { DownloadTemplateOptions } from "giget"; 2 2 import type { DotenvOptions } from "./dotenv.ts"; 3 + import type { ConfigProvider } from "./providers.ts"; 3 4 4 5 export interface ConfigLayerMeta { 5 6 name?: string; ··· 146 147 }; 147 148 148 149 configFileRequired?: boolean; 150 + 151 + /** 152 + * Custom configuration providers. 153 + * 154 + * When specified, these providers are used instead of the built-in sources. 155 + * Use `getDefaultProviders()` and modify the array to customize while 156 + * keeping default behavior. 157 + * 158 + * Providers are sorted by priority (lower = higher precedence) before loading. 159 + * 160 + * @example 161 + * ```ts 162 + * import { getDefaultProviders } from 'c12'; 163 + * 164 + * loadConfig({ 165 + * providers: [ 166 + * ...getDefaultProviders(), 167 + * { name: 'env', priority: 150, load: myEnvProvider } 168 + * ] 169 + * }) 170 + * ``` 171 + */ 172 + providers?: ConfigProvider<T, MT>[]; 149 173 } 150 174 151 175 export type DefineConfig<
+126 -3
test/loader.test.ts
··· 1 1 import { fileURLToPath } from "node:url"; 2 2 import { expect, it, describe } from "vitest"; 3 3 import { normalize } from "pathe"; 4 - import type { ConfigLayer, ConfigLayerMeta, UserInputConfig } from "../src/index.ts"; 5 - import { loadConfig } from "../src/index.ts"; 4 + import type { 5 + ConfigLayer, 6 + ConfigLayerMeta, 7 + ConfigProvider, 8 + UserInputConfig, 9 + } from "../src/index.ts"; 10 + import { getDefaultProviders, loadConfig } from "../src/index.ts"; 6 11 7 12 const r = (path: string) => normalize(fileURLToPath(new URL(path, import.meta.url))); 8 13 const transformPaths = (object: object) => ··· 347 352 configFile: "CUSTOM", 348 353 configFileRequired: true, 349 354 }), 350 - ).rejects.toThrowError("Required config (CUSTOM) cannot be resolved."); 355 + ).rejects.toThrowError(/Required config \(.*CUSTOM\) cannot be resolved\./); 351 356 }); 352 357 353 358 it("loads arrays exported from config without merging", async () => { ··· 375 380 await loadConfig({ 376 381 name: "test", 377 382 cwd: r("./fixture/jsx"), 383 + }); 384 + }); 385 + 386 + describe("providers", () => { 387 + it("uses default providers when none specified", async () => { 388 + const { config, layers } = await loadConfig({ 389 + cwd: r("./fixture"), 390 + name: "test", 391 + overrides: { fromOverrides: true }, 392 + }); 393 + expect(config.fromOverrides).toBe(true); 394 + expect(layers!.length).toBeGreaterThan(0); 395 + }); 396 + 397 + it("allows custom providers to inject config", async () => { 398 + const customProvider: ConfigProvider = { 399 + name: "custom", 400 + priority: 150, // Between overrides (100) and main (200) 401 + async load() { 402 + return { 403 + config: { customValue: "injected", overridden: false }, 404 + layer: { config: { customValue: "injected" }, configFile: "custom-provider" }, 405 + }; 406 + }, 407 + }; 408 + 409 + const { config, layers } = await loadConfig({ 410 + cwd: r("./fixture"), 411 + name: "test", 412 + providers: [...getDefaultProviders(), customProvider], 413 + overrides: { overridden: true }, 414 + }); 415 + 416 + // Custom value should be present 417 + expect(config.customValue).toBe("injected"); 418 + // Overrides should still win (priority 100 < 150) 419 + expect(config.overridden).toBe(true); 420 + // Custom layer should be in layers 421 + const customLayer = layers!.find((l) => l.configFile === "custom-provider"); 422 + expect(customLayer).toBeDefined(); 423 + }); 424 + 425 + it("respects provider priority order", async () => { 426 + const lowPriorityProvider: ConfigProvider = { 427 + name: "low-priority", 428 + priority: 1000, // Very low priority 429 + async load() { 430 + return { config: { testKey: "from-low" } }; 431 + }, 432 + }; 433 + 434 + const highPriorityProvider: ConfigProvider = { 435 + name: "high-priority", 436 + priority: 50, // Very high priority 437 + async load() { 438 + return { config: { testKey: "from-high" } }; 439 + }, 440 + }; 441 + 442 + const { config } = await loadConfig({ 443 + cwd: r("./fixture"), 444 + name: "test", 445 + providers: [lowPriorityProvider, highPriorityProvider], 446 + }); 447 + 448 + // High priority should win 449 + expect(config.testKey).toBe("from-high"); 450 + }); 451 + 452 + it("allows removing default providers", async () => { 453 + // Only use a single static provider 454 + const onlyProvider: ConfigProvider = { 455 + name: "only", 456 + priority: 100, 457 + async load() { 458 + return { config: { onlyThis: true } }; 459 + }, 460 + }; 461 + 462 + const { config, layers } = await loadConfig({ 463 + cwd: r("./fixture"), 464 + name: "test", 465 + providers: [onlyProvider], 466 + }); 467 + 468 + expect(config.onlyThis).toBe(true); 469 + // Should only have the one layer (no main, rc, packageJson) 470 + expect(layers!.length).toBe(0); // No layer metadata provided 471 + }); 472 + 473 + it("provider can access previously loaded configs", async () => { 474 + const firstProvider: ConfigProvider = { 475 + name: "first", 476 + priority: 100, 477 + async load() { 478 + return { config: { firstValue: 42 } }; 479 + }, 480 + }; 481 + 482 + const secondProvider: ConfigProvider = { 483 + name: "second", 484 + priority: 200, 485 + async load(ctx) { 486 + const firstConfig = ctx.loadedConfigs.get("first"); 487 + return { 488 + config: { sawFirst: firstConfig?.firstValue === 42 }, 489 + }; 490 + }, 491 + }; 492 + 493 + const { config } = await loadConfig({ 494 + cwd: r("./fixture"), 495 + name: "test", 496 + providers: [firstProvider, secondProvider], 497 + }); 498 + 499 + expect(config.firstValue).toBe(42); 500 + expect(config.sawFirst).toBe(true); 378 501 }); 379 502 }); 380 503 });