277 lines
8.7 KiB
TypeScript
277 lines
8.7 KiB
TypeScript
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<string, unknown> {
|
|
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<string, unknown> = {};
|
|
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<string, unknown>, 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<string, unknown>, 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<string, unknown>;
|
|
}, obj);
|
|
|
|
(target as Record<string, unknown>)[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<string, string[]> = {
|
|
// 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)
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|