import { join } from 'path'; import { z } from 'zod'; import { ConfigManagerOptions, Environment, ConfigLoader, DeepPartial, ConfigSchema } from './types'; import { ConfigError, ConfigValidationError } from './errors'; import { EnvLoader } from './loaders/env.loader'; import { FileLoader } from './loaders/file.loader'; export class ConfigManager> { private config: T | null = null; private loaders: ConfigLoader[]; private environment: Environment; private schema?: ConfigSchema; constructor(options: ConfigManagerOptions = {}) { this.environment = options.environment || this.detectEnvironment(); // Default loaders if none provided if (options.loaders) { this.loaders = options.loaders; } else { const configPath = options.configPath || join(process.cwd(), 'config'); this.loaders = [ new FileLoader(configPath, this.environment), new EnvLoader(''), // No prefix for env vars to match our .env file ]; } } /** * Initialize the configuration by loading from all sources synchronously. */ initialize(schema?: ConfigSchema): T { if (this.config) { return this.config; } this.schema = schema; // Sort loaders by priority (higher priority last) const sortedLoaders = [...this.loaders].sort((a, b) => a.priority - b.priority); // Load configurations from all sources const configs: Record[] = []; for (const loader of sortedLoaders) { // Assuming all loaders now have a synchronous `load` method const config = loader.load(); if (config && Object.keys(config).length > 0) { configs.push(config); } } // Merge all configurations const mergedConfig = this.deepMerge(...configs) as T; // Add environment if not present if (typeof mergedConfig === 'object' && mergedConfig !== null && !('environment' in mergedConfig)) { (mergedConfig as Record)['environment'] = this.environment; } // Validate if schema provided if (this.schema) { try { this.config = this.schema.parse(mergedConfig) as T; } catch (error) { if (error instanceof z.ZodError) { throw new ConfigValidationError( 'Configuration validation failed', error.errors ); } throw error; } } else { this.config = mergedConfig; } return this.config; } /** * Get the current configuration */ get(): T { if (!this.config) { throw new ConfigError('Configuration not initialized. Call initialize() first.'); } return this.config; } /** * Get a specific configuration value by path */ getValue(path: string): R { const config = this.get(); const keys = path.split('.'); let value: unknown = config; for (const key of keys) { if (value && typeof value === 'object' && key in value) { value = (value as Record)[key]; } else { throw new ConfigError(`Configuration key not found: ${path}`); } } return value as R; } /** * Check if a configuration path exists */ has(path: string): boolean { try { this.getValue(path); return true; } catch { return false; } } /** * Update configuration at runtime (useful for testing) */ set(updates: DeepPartial): void { if (!this.config) { throw new ConfigError('Configuration not initialized. Call initialize() first.'); } const updated = this.deepMerge(this.config as Record, updates as Record) as T; // Re-validate if schema is present if (this.schema) { try { this.config = this.schema.parse(updated) as T; } catch (error) { if (error instanceof z.ZodError) { throw new ConfigValidationError( 'Configuration validation failed after update', error.errors ); } throw error; } } else { this.config = updated; } } /** * Get the current environment */ getEnvironment(): Environment { return this.environment; } /** * Reset configuration (useful for testing) */ reset(): void { this.config = null; } /** * Validate configuration against a schema */ validate(schema: S): z.infer { const config = this.get(); return schema.parse(config); } /** * Create a typed configuration getter */ createTypedGetter(schema: S): () => z.infer { return () => this.validate(schema); } private detectEnvironment(): Environment { const env = process.env.NODE_ENV?.toLowerCase(); switch (env) { case 'production': case 'prod': return 'production'; case 'test': return 'test'; case 'development': case 'dev': default: return 'development'; } } private deepMerge(...objects: Record[]): Record { const result: Record = {}; for (const obj of objects) { for (const [key, value] of Object.entries(obj)) { if (value === null || value === undefined) { result[key] = value; } else if ( typeof value === 'object' && !Array.isArray(value) && !(value instanceof Date) && !(value instanceof RegExp) ) { result[key] = this.deepMerge( (result[key] as Record) || {} as Record, value as Record ); } else { result[key] = value; } } } return result; } }