import { readFileSync } from 'fs'; import { ConfigLoaderError } from '../errors'; import type { ConfigLoader } from '../types'; export interface EnvLoaderOptions { convertCase?: boolean; parseJson?: boolean; parseValues?: boolean; nestedDelimiter?: string; } export class EnvLoader implements ConfigLoader { readonly priority = 100; // Highest priority constructor( private prefix = '', private options: EnvLoaderOptions = {} ) { this.options = { convertCase: false, parseJson: true, parseValues: true, nestedDelimiter: '_', ...options, }; } load(): Record { try { // Load root .env file - try multiple possible locations const possiblePaths = ['./.env', '../.env', '../../.env']; for (const path of possiblePaths) { this.loadEnvFile(path); } const config: Record = {}; const envVars = process.env; for (const [key, value] of Object.entries(envVars)) { if (this.prefix && !key.startsWith(this.prefix)) { continue; } const configKey = this.prefix ? key.slice(this.prefix.length) : key; if (!this.options.convertCase && !this.options.nestedDelimiter) { // Simple case - just keep the key as is config[configKey] = this.parseValue(value || ''); } else { // Handle nested structure or case conversion this.setConfigValue(config, configKey, value || ''); } } return config; } catch (error) { throw new ConfigLoaderError(`Failed to load environment variables: ${error}`, 'EnvLoader'); } } private setConfigValue(config: Record, key: string, value: string): void { const parsedValue = this.parseValue(value); try { // Handle provider-specific environment variables (only for application usage, not tests) if (!this.prefix && !this.options.convertCase) { const providerMapping = this.getProviderMapping(key); if (providerMapping) { this.setNestedValue(config, providerMapping.path, parsedValue); return; } } if (this.options.convertCase) { // Convert to camelCase const camelKey = this.toCamelCase(key); config[camelKey] = parsedValue; } else if ( this.options.nestedDelimiter && this.options.nestedDelimiter !== '_' && key.includes(this.options.nestedDelimiter) ) { // Handle nested delimiter (e.g., APP__NAME -> { APP: { NAME: value } }) const parts = key.split(this.options.nestedDelimiter); this.setNestedValue(config, parts, parsedValue); } else { // Convert to nested structure based on underscores, or keep as-is if no underscores if (key.includes('_')) { const path = key.toLowerCase().split('_'); this.setNestedValue(config, path, parsedValue); } else { // Single key without underscores - keep original case config[key] = parsedValue; } } } catch { // Skip environment variables that can't be set (readonly properties) // This is expected behavior for system environment variables } } private setNestedValue(obj: Record, path: string[], value: unknown): boolean { if (path.length === 0) { return false; // Cannot set value on empty path } const lastKey = path.pop(); if (!lastKey) { return false; // This should never happen due to length check above } try { const target = path.reduce((acc, key) => { if (!acc[key] || typeof acc[key] !== 'object') { acc[key] = {}; } return acc[key] as Record; }, obj); (target as Record)[lastKey] = value; return true; } catch { // If we can't assign to any property (readonly), skip this env var silently return false; } } private toCamelCase(str: string): string { return str.toLowerCase().replace(/_([a-z])/g, (_, char) => char.toUpperCase()); } private getProviderMapping(envKey: string): { path: string[] } | null { // Provider-specific and special environment variable mappings const providerMappings: Record = { // WebShare provider mappings WEBSHARE_API_KEY: ['webshare', 'apiKey'], WEBSHARE_API_URL: ['webshare', 'apiUrl'], WEBSHARE_ENABLED: ['webshare', 'enabled'], // EOD provider mappings EOD_API_KEY: ['providers', 'eod', 'apiKey'], EOD_BASE_URL: ['providers', 'eod', 'baseUrl'], EOD_TIER: ['providers', 'eod', 'tier'], EOD_ENABLED: ['providers', 'eod', 'enabled'], EOD_PRIORITY: ['providers', 'eod', 'priority'], // Interactive Brokers provider mappings IB_GATEWAY_HOST: ['providers', 'ib', 'gateway', 'host'], IB_GATEWAY_PORT: ['providers', 'ib', 'gateway', 'port'], IB_CLIENT_ID: ['providers', 'ib', 'gateway', 'clientId'], IB_ACCOUNT: ['providers', 'ib', 'account'], IB_MARKET_DATA_TYPE: ['providers', 'ib', 'marketDataType'], IB_ENABLED: ['providers', 'ib', 'enabled'], IB_PRIORITY: ['providers', 'ib', 'priority'], // QuoteMedia provider mappings QM_USERNAME: ['providers', 'qm', 'username'], QM_PASSWORD: ['providers', 'qm', 'password'], QM_BASE_URL: ['providers', 'qm', 'baseUrl'], QM_WEBMASTER_ID: ['providers', 'qm', 'webmasterId'], QM_ENABLED: ['providers', 'qm', 'enabled'], QM_PRIORITY: ['providers', 'qm', 'priority'], // Yahoo Finance provider mappings YAHOO_BASE_URL: ['providers', 'yahoo', 'baseUrl'], YAHOO_COOKIE_JAR: ['providers', 'yahoo', 'cookieJar'], YAHOO_CRUMB: ['providers', 'yahoo', 'crumb'], YAHOO_ENABLED: ['providers', 'yahoo', 'enabled'], YAHOO_PRIORITY: ['providers', 'yahoo', 'priority'], // General application config mappings NAME: ['name'], VERSION: ['version'], // Log mappings (using LOG_ prefix for all) LOG_LEVEL: ['log', 'level'], LOG_FORMAT: ['log', 'format'], LOG_HIDE_OBJECT: ['log', 'hideObject'], LOG_LOKI_ENABLED: ['log', 'loki', 'enabled'], LOG_LOKI_HOST: ['log', 'loki', 'host'], LOG_LOKI_PORT: ['log', 'loki', 'port'], // Special mappings to avoid conflicts DEBUG_MODE: ['debug'], }; const mapping = providerMappings[envKey]; return mapping ? { path: mapping } : null; } private parseValue(value: string): unknown { if (!this.options.parseValues && !this.options.parseJson) { return value; } // Try to parse as JSON first if enabled if (this.options.parseJson) { try { return JSON.parse(value); } catch { // Not JSON, continue with other parsing } } if (!this.options.parseValues) { return value; } // Handle booleans if (value.toLowerCase() === 'true') { return true; } if (value.toLowerCase() === 'false') { return false; } // Handle numbers const num = Number(value); if (!isNaN(num) && value !== '') { return num; } // Handle null/undefined if (value.toLowerCase() === 'null') { return null; } if (value.toLowerCase() === 'undefined') { return undefined; } // Return as string return value; } private loadEnvFile(filePath: string): void { try { const envContent = readFileSync(filePath, 'utf-8'); const lines = envContent.split('\n'); for (const line of lines) { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith('#')) { continue; // Skip empty lines and comments } const equalIndex = trimmed.indexOf('='); if (equalIndex === -1) { continue; // Skip lines without = } const key = trimmed.substring(0, equalIndex).trim(); let value = trimmed.substring(equalIndex + 1).trim(); // Remove surrounding quotes if present if ( (value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")) ) { value = value.slice(1, -1); } // Only set if not already set (allows override precedence) if (!(key in process.env)) { process.env[key] = value; } } } catch (error: unknown) { // File not found is not an error (env files are optional) if (error && typeof error === 'object' && 'code' in error && error.code !== 'ENOENT') { // eslint-disable-next-line no-console console.warn( `Warning: Could not load env file ${filePath}:`, error instanceof Error ? error.message : String(error) ); } } } }