moved folders around

This commit is contained in:
Boki 2025-06-21 18:27:00 -04:00
parent 4f89affc2b
commit 36cb84b343
202 changed files with 1160 additions and 660 deletions

196
libs/core/config/src/cli.ts Normal file
View file

@ -0,0 +1,196 @@
#!/usr/bin/env bun
/* eslint-disable no-console */
import { parseArgs } from 'util';
import { join } from 'path';
import { ConfigManager } from './config-manager';
import { appConfigSchema } from './schemas';
import {
validateConfig,
formatValidationResult,
checkDeprecations,
checkRequiredEnvVars,
validateCompleteness
} from './utils/validation';
import { redactSecrets } from './utils/secrets';
import type { Environment } from './types';
interface CliOptions {
config?: string;
env?: string;
validate?: boolean;
show?: boolean;
check?: boolean;
json?: boolean;
help?: boolean;
}
const DEPRECATIONS = {
'service.legacyMode': 'Use service.mode instead',
'database.redis': 'Use database.dragonfly instead',
};
const REQUIRED_PATHS = [
'service.name',
'service.port',
'database.postgres.host',
'database.postgres.database',
];
const REQUIRED_ENV_VARS = [
'NODE_ENV',
];
const SECRET_PATHS = [
'database.postgres.password',
'database.mongodb.uri',
'providers.quoteMedia.apiKey',
'providers.interactiveBrokers.clientId',
];
function printUsage() {
console.log(`
Stock Bot Configuration CLI
Usage: bun run config-cli [options]
Options:
--config <path> Path to config directory (default: ./config)
--env <env> Environment to use (development, test, production)
--validate Validate configuration against schema
--show Show current configuration (secrets redacted)
--check Run all configuration checks
--json Output in JSON format
--help Show this help message
Examples:
# Validate configuration
bun run config-cli --validate
# Show configuration for production
bun run config-cli --env production --show
# Run all checks
bun run config-cli --check
# Output configuration as JSON
bun run config-cli --show --json
`);
}
async function main() {
const { values } = parseArgs({
args: process.argv.slice(2),
options: {
config: { type: 'string' },
env: { type: 'string' },
validate: { type: 'boolean' },
show: { type: 'boolean' },
check: { type: 'boolean' },
json: { type: 'boolean' },
help: { type: 'boolean' },
},
}) as { values: CliOptions };
if (values.help) {
printUsage();
process.exit(0);
}
const configPath = values.config || join(process.cwd(), 'config');
const environment = values.env as Environment;
try {
const manager = new ConfigManager({
configPath,
environment,
});
const config = await manager.initialize(appConfigSchema);
if (values.validate) {
const result = validateConfig(config, appConfigSchema);
if (values.json) {
console.log(JSON.stringify(result, null, 2));
} else {
console.log(formatValidationResult(result));
}
process.exit(result.valid ? 0 : 1);
}
if (values.show) {
const redacted = redactSecrets(config, SECRET_PATHS);
if (values.json) {
console.log(JSON.stringify(redacted, null, 2));
} else {
console.log('Current Configuration:');
console.log(JSON.stringify(redacted, null, 2));
}
}
if (values.check) {
console.log('Running configuration checks...\n');
// Schema validation
console.log('1. Schema Validation:');
const schemaResult = validateConfig(config, appConfigSchema);
console.log(formatValidationResult(schemaResult));
console.log();
// Environment variables
console.log('2. Required Environment Variables:');
const envResult = checkRequiredEnvVars(REQUIRED_ENV_VARS);
console.log(formatValidationResult(envResult));
console.log();
// Required paths
console.log('3. Required Configuration Paths:');
const pathResult = validateCompleteness(config, REQUIRED_PATHS);
console.log(formatValidationResult(pathResult));
console.log();
// Deprecations
console.log('4. Deprecation Warnings:');
const warnings = checkDeprecations(config, DEPRECATIONS);
if (warnings && warnings.length > 0) {
for (const warning of warnings) {
console.log(` ⚠️ ${warning.path}: ${warning.message}`);
}
} else {
console.log(' ✅ No deprecated options found');
}
console.log();
// Overall result
const allValid = schemaResult.valid && envResult.valid && pathResult.valid;
if (allValid) {
console.log('✅ All configuration checks passed!');
process.exit(0);
} else {
console.log('❌ Some configuration checks failed');
process.exit(1);
}
}
if (!values.validate && !values.show && !values.check) {
console.log('No action specified. Use --help for usage information.');
process.exit(1);
}
} catch (error) {
if (values.json) {
console.error(JSON.stringify({ error: String(error) }));
} else {
console.error('Error:', error);
}
process.exit(1);
}
}
// Run CLI
if (import.meta.main) {
main();
}

View file

@ -0,0 +1,228 @@
import { join } from 'path';
import { z } from 'zod';
import { EnvLoader } from './loaders/env.loader';
import { FileLoader } from './loaders/file.loader';
import { ConfigError, ConfigValidationError } from './errors';
import {
ConfigLoader,
ConfigManagerOptions,
ConfigSchema,
DeepPartial,
Environment,
} from './types';
export class ConfigManager<T = Record<string, unknown>> {
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<string, unknown>[] = [];
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<string, unknown>)['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<R = unknown>(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<string, unknown>)[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<T>): void {
if (!this.config) {
throw new ConfigError('Configuration not initialized. Call initialize() first.');
}
const updated = this.deepMerge(
this.config as Record<string, unknown>,
updates as Record<string, unknown>
) 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<S extends ConfigSchema>(schema: S): z.infer<S> {
const config = this.get();
return schema.parse(config);
}
/**
* Create a typed configuration getter
*/
createTypedGetter<S extends z.ZodSchema>(schema: S): () => z.infer<S> {
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<string, unknown>[]): Record<string, unknown> {
const result: Record<string, unknown> = {};
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<string, unknown>) || ({} as Record<string, unknown>),
value as Record<string, unknown>
);
} else {
result[key] = value;
}
}
}
return result;
}
}

View file

@ -0,0 +1,20 @@
export class ConfigError extends Error {
constructor(message: string) {
super(message);
this.name = 'ConfigError';
}
}
export class ConfigValidationError extends ConfigError {
constructor(message: string, public errors: unknown) {
super(message);
this.name = 'ConfigValidationError';
}
}
export class ConfigLoaderError extends ConfigError {
constructor(message: string, public loader: string) {
super(`${loader}: ${message}`);
this.name = 'ConfigLoaderError';
}
}

View file

@ -0,0 +1,188 @@
// Import necessary types for singleton
import { EnvLoader } from './loaders/env.loader';
import { FileLoader } from './loaders/file.loader';
import { ConfigManager } from './config-manager';
import { AppConfig, appConfigSchema } from './schemas';
// Create singleton instance
let configInstance: ConfigManager<AppConfig> | null = null;
// Synchronously load critical env vars for early initialization
function loadCriticalEnvVarsSync(): void {
// Load .env file synchronously if it exists
try {
const fs = require('fs');
const path = require('path');
const envPath = path.resolve(process.cwd(), '.env');
if (fs.existsSync(envPath)) {
const envContent = fs.readFileSync(envPath, 'utf-8');
const lines = envContent.split('\n');
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) {
continue;
}
const equalIndex = trimmed.indexOf('=');
if (equalIndex === -1) {
continue;
}
const key = trimmed.substring(0, equalIndex).trim();
let value = trimmed.substring(equalIndex + 1).trim();
// Remove surrounding quotes
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
// Only set if not already set
if (!(key in process.env)) {
process.env[key] = value;
}
}
}
} catch {
// Ignore errors - env file is optional
}
}
// Load critical env vars immediately
loadCriticalEnvVarsSync();
/**
* Initialize the global configuration synchronously.
*
* This loads configuration from all sources in the correct hierarchy:
* 1. Schema defaults (lowest priority)
* 2. default.json
* 3. [environment].json (e.g., development.json)
* 4. .env file values
* 5. process.env values (highest priority)
*/
export function initializeConfig(configPath?: string): AppConfig {
if (!configInstance) {
configInstance = new ConfigManager<AppConfig>({
configPath,
});
}
return configInstance.initialize(appConfigSchema);
}
/**
* Initialize configuration for a service in a monorepo.
* Automatically loads configs from:
* 1. Root config directory (../../config)
* 2. Service-specific config directory (./config)
* 3. Environment variables
*/
export function initializeServiceConfig(): AppConfig {
if (!configInstance) {
const environment = process.env.NODE_ENV || 'development';
configInstance = new ConfigManager<AppConfig>({
loaders: [
new FileLoader('../../config', environment), // Root config
new FileLoader('./config', environment), // Service config
new EnvLoader(''), // Environment variables
],
});
}
return configInstance.initialize(appConfigSchema);
}
/**
* Get the current configuration
*/
export function getConfig(): AppConfig {
if (!configInstance) {
throw new Error('Configuration not initialized. Call initializeConfig() first.');
}
return configInstance.get();
}
/**
* Get configuration manager instance
*/
export function getConfigManager(): ConfigManager<AppConfig> {
if (!configInstance) {
throw new Error('Configuration not initialized. Call initializeConfig() first.');
}
return configInstance;
}
/**
* Reset configuration (useful for testing)
*/
export function resetConfig(): void {
if (configInstance) {
configInstance.reset();
configInstance = null;
}
}
// Export convenience functions for common configs
export function getDatabaseConfig() {
return getConfig().database;
}
export function getServiceConfig() {
return getConfig().service;
}
export function getLogConfig() {
return getConfig().log;
}
// Deprecated alias for backward compatibility
export function getLoggingConfig() {
return getConfig().log;
}
export function getProviderConfig(provider: string) {
const providers = getConfig().providers;
if (!providers || !(provider in providers)) {
throw new Error(`Provider configuration not found: ${provider}`);
}
return (providers as Record<string, unknown>)[provider];
}
export function getQueueConfig() {
return getConfig().queue;
}
// Export environment helpers
export function isDevelopment(): boolean {
return getConfig().environment === 'development';
}
export function isProduction(): boolean {
return getConfig().environment === 'production';
}
export function isTest(): boolean {
return getConfig().environment === 'test';
}
// Export all schemas
export * from './schemas';
// Export types
export * from './types';
// Export errors
export * from './errors';
// Export loaders
export { EnvLoader } from './loaders/env.loader';
export { FileLoader } from './loaders/file.loader';
// Export ConfigManager
export { ConfigManager } from './config-manager';
// Export utilities
export * from './utils/secrets';
export * from './utils/validation';

View file

@ -0,0 +1,277 @@
import { readFileSync } from 'fs';
import { ConfigLoaderError } from '../errors';
import { 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)
);
}
}
}
}

View file

@ -0,0 +1,68 @@
import { existsSync, readFileSync } from 'fs';
import { join } from 'path';
import { ConfigLoaderError } from '../errors';
import { ConfigLoader } from '../types';
export class FileLoader implements ConfigLoader {
readonly priority = 50; // Medium priority
constructor(
private configPath: string,
private environment: string
) {}
load(): Record<string, unknown> {
try {
const configs: Record<string, unknown>[] = [];
// Load default config
const defaultConfig = this.loadFile('default.json');
if (defaultConfig) {
configs.push(defaultConfig);
}
// Load environment-specific config
const envConfig = this.loadFile(`${this.environment}.json`);
if (envConfig) {
configs.push(envConfig);
}
// Merge configs (later configs override earlier ones)
return this.deepMerge(...configs);
} catch (error) {
throw new ConfigLoaderError(`Failed to load configuration files: ${error}`, 'FileLoader');
}
}
private loadFile(filename: string): Record<string, unknown> | null {
const filepath = join(this.configPath, filename);
if (!existsSync(filepath)) {
return null;
}
const content = readFileSync(filepath, 'utf-8');
return JSON.parse(content);
}
private deepMerge(...objects: Record<string, unknown>[]): Record<string, unknown> {
const result: Record<string, unknown> = {};
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 !== null) {
result[key] = this.deepMerge(
(result[key] as Record<string, unknown>) || {},
value as Record<string, unknown>
);
} else {
result[key] = value;
}
}
}
return result;
}
}

View file

@ -0,0 +1,10 @@
import { z } from 'zod';
export const environmentSchema = z.enum(['development', 'test', 'production']);
export const baseConfigSchema = z.object({
environment: environmentSchema.optional(),
name: z.string().optional(),
version: z.string().optional(),
debug: z.boolean().default(false),
});

View file

@ -0,0 +1,60 @@
import { z } from 'zod';
// PostgreSQL configuration
export const postgresConfigSchema = z.object({
host: z.string().default('localhost'),
port: z.number().default(5432),
database: z.string(),
user: z.string(),
password: z.string(),
ssl: z.boolean().default(false),
poolSize: z.number().min(1).max(100).default(10),
connectionTimeout: z.number().default(30000),
idleTimeout: z.number().default(10000),
});
// QuestDB configuration
export const questdbConfigSchema = z.object({
host: z.string().default('localhost'),
ilpPort: z.number().default(9009),
httpPort: z.number().default(9000),
pgPort: z.number().default(8812),
database: z.string().default('questdb'),
user: z.string().default('admin'),
password: z.string().default('quest'),
bufferSize: z.number().default(65536),
flushInterval: z.number().default(1000),
});
// MongoDB configuration
export const mongodbConfigSchema = z.object({
uri: z.string().url().optional(),
host: z.string().default('localhost'),
port: z.number().default(27017),
database: z.string(),
user: z.string().optional(),
password: z.string().optional(),
authSource: z.string().default('admin'),
replicaSet: z.string().optional(),
poolSize: z.number().min(1).max(100).default(10),
});
// Dragonfly/Redis configuration
export const dragonflyConfigSchema = z.object({
host: z.string().default('localhost'),
port: z.number().default(6379),
password: z.string().optional(),
db: z.number().min(0).max(15).default(0),
keyPrefix: z.string().optional(),
ttl: z.number().optional(),
maxRetries: z.number().default(3),
retryDelay: z.number().default(100),
});
// Combined database configuration
export const databaseConfigSchema = z.object({
postgres: postgresConfigSchema,
questdb: questdbConfigSchema,
mongodb: mongodbConfigSchema,
dragonfly: dragonflyConfigSchema,
});

View file

@ -0,0 +1,98 @@
export * from './base.schema';
export * from './database.schema';
export * from './provider.schema';
export * from './service.schema';
import { z } from 'zod';
import { baseConfigSchema, environmentSchema } from './base.schema';
import { providerConfigSchema, webshareProviderConfigSchema } from './provider.schema';
import { httpConfigSchema, queueConfigSchema } from './service.schema';
// Flexible service schema with defaults
const flexibleServiceConfigSchema = z.object({
name: z.string().default('default-service'),
port: z.number().min(1).max(65535).default(3000),
host: z.string().default('0.0.0.0'),
healthCheckPath: z.string().default('/health'),
metricsPath: z.string().default('/metrics'),
shutdownTimeout: z.number().default(30000),
cors: z.object({
enabled: z.boolean().default(true),
origin: z.union([z.string(), z.array(z.string())]).default('*'),
credentials: z.boolean().default(true),
}).default({}),
}).default({});
// Flexible database schema with defaults
const flexibleDatabaseConfigSchema = z.object({
postgres: z.object({
host: z.string().default('localhost'),
port: z.number().default(5432),
database: z.string().default('test_db'),
user: z.string().default('test_user'),
password: z.string().default('test_pass'),
ssl: z.boolean().default(false),
poolSize: z.number().min(1).max(100).default(10),
connectionTimeout: z.number().default(30000),
idleTimeout: z.number().default(10000),
}).default({}),
questdb: z.object({
host: z.string().default('localhost'),
ilpPort: z.number().default(9009),
httpPort: z.number().default(9000),
pgPort: z.number().default(8812),
database: z.string().default('questdb'),
user: z.string().default('admin'),
password: z.string().default('quest'),
bufferSize: z.number().default(65536),
flushInterval: z.number().default(1000),
}).default({}),
mongodb: z.object({
uri: z.string().url().optional(),
host: z.string().default('localhost'),
port: z.number().default(27017),
database: z.string().default('test_mongo'),
user: z.string().optional(),
password: z.string().optional(),
authSource: z.string().default('admin'),
replicaSet: z.string().optional(),
poolSize: z.number().min(1).max(100).default(10),
}).default({}),
dragonfly: z.object({
host: z.string().default('localhost'),
port: z.number().default(6379),
password: z.string().optional(),
db: z.number().min(0).max(15).default(0),
keyPrefix: z.string().optional(),
ttl: z.number().optional(),
maxRetries: z.number().default(3),
retryDelay: z.number().default(100),
}).default({}),
}).default({});
// Flexible log schema with defaults (renamed from logging)
const flexibleLogConfigSchema = z.object({
level: z.enum(['trace', 'debug', 'info', 'warn', 'error', 'fatal']).default('info'),
format: z.enum(['json', 'pretty']).default('json'),
hideObject: z.boolean().default(false),
loki: z.object({
enabled: z.boolean().default(false),
host: z.string().default('localhost'),
port: z.number().default(3100),
labels: z.record(z.string()).default({}),
}).optional(),
}).default({});
// Complete application configuration schema
export const appConfigSchema = baseConfigSchema.extend({
environment: environmentSchema.default('development'),
service: flexibleServiceConfigSchema,
log: flexibleLogConfigSchema,
database: flexibleDatabaseConfigSchema,
queue: queueConfigSchema.optional(),
http: httpConfigSchema.optional(),
providers: providerConfigSchema.optional(),
webshare: webshareProviderConfigSchema.optional(),
});
export type AppConfig = z.infer<typeof appConfigSchema>;

View file

@ -0,0 +1,74 @@
import { z } from 'zod';
// Base provider configuration
export const baseProviderConfigSchema = z.object({
name: z.string(),
enabled: z.boolean().default(true),
priority: z.number().default(0),
rateLimit: z.object({
maxRequests: z.number().default(100),
windowMs: z.number().default(60000),
}).optional(),
timeout: z.number().default(30000),
retries: z.number().default(3),
});
// EOD Historical Data provider
export const eodProviderConfigSchema = baseProviderConfigSchema.extend({
apiKey: z.string(),
baseUrl: z.string().default('https://eodhistoricaldata.com/api'),
tier: z.enum(['free', 'fundamentals', 'all-in-one']).default('free'),
});
// Interactive Brokers provider
export const ibProviderConfigSchema = baseProviderConfigSchema.extend({
gateway: z.object({
host: z.string().default('localhost'),
port: z.number().default(5000),
clientId: z.number().default(1),
}),
account: z.string().optional(),
marketDataType: z.enum(['live', 'delayed', 'frozen']).default('delayed'),
});
// QuoteMedia provider
export const qmProviderConfigSchema = baseProviderConfigSchema.extend({
username: z.string(),
password: z.string(),
baseUrl: z.string().default('https://app.quotemedia.com/quotetools'),
webmasterId: z.string(),
});
// Yahoo Finance provider
export const yahooProviderConfigSchema = baseProviderConfigSchema.extend({
baseUrl: z.string().default('https://query1.finance.yahoo.com'),
cookieJar: z.boolean().default(true),
crumb: z.string().optional(),
});
// WebShare proxy provider
export const webshareProviderConfigSchema = z.object({
apiKey: z.string().optional(),
apiUrl: z.string().default('https://proxy.webshare.io/api/v2/'),
enabled: z.boolean().default(true),
});
// Combined provider configuration
export const providerConfigSchema = z.object({
eod: eodProviderConfigSchema.optional(),
ib: ibProviderConfigSchema.optional(),
qm: qmProviderConfigSchema.optional(),
yahoo: yahooProviderConfigSchema.optional(),
webshare: webshareProviderConfigSchema.optional(),
});
// Dynamic provider configuration type
export type ProviderName = 'eod' | 'ib' | 'qm' | 'yahoo' | 'webshare';
export const providerSchemas = {
eod: eodProviderConfigSchema,
ib: ibProviderConfigSchema,
qm: qmProviderConfigSchema,
yahoo: yahooProviderConfigSchema,
webshare: webshareProviderConfigSchema,
} as const;

View file

@ -0,0 +1,63 @@
import { z } from 'zod';
// Common service configuration
export const serviceConfigSchema = z.object({
name: z.string(),
port: z.number().min(1).max(65535),
host: z.string().default('0.0.0.0'),
healthCheckPath: z.string().default('/health'),
metricsPath: z.string().default('/metrics'),
shutdownTimeout: z.number().default(30000),
cors: z.object({
enabled: z.boolean().default(true),
origin: z.union([z.string(), z.array(z.string())]).default('*'),
credentials: z.boolean().default(true),
}).default({}),
});
// Logging configuration
export const loggingConfigSchema = z.object({
level: z.enum(['trace', 'debug', 'info', 'warn', 'error', 'fatal']).default('info'),
format: z.enum(['json', 'pretty']).default('json'),
loki: z.object({
enabled: z.boolean().default(false),
host: z.string().default('localhost'),
port: z.number().default(3100),
labels: z.record(z.string()).default({}),
}).optional(),
});
// Queue configuration
export const queueConfigSchema = z.object({
redis: z.object({
host: z.string().default('localhost'),
port: z.number().default(6379),
password: z.string().optional(),
db: z.number().default(1),
}),
defaultJobOptions: z.object({
attempts: z.number().default(3),
backoff: z.object({
type: z.enum(['exponential', 'fixed']).default('exponential'),
delay: z.number().default(1000),
}).default({}),
removeOnComplete: z.number().default(10),
removeOnFail: z.number().default(5),
}).default({}),
});
// HTTP client configuration
export const httpConfigSchema = z.object({
timeout: z.number().default(30000),
retries: z.number().default(3),
retryDelay: z.number().default(1000),
userAgent: z.string().optional(),
proxy: z.object({
enabled: z.boolean().default(false),
url: z.string().url().optional(),
auth: z.object({
username: z.string(),
password: z.string(),
}).optional(),
}).optional(),
});

View file

@ -0,0 +1,28 @@
import { z } from 'zod';
export type Environment = 'development' | 'test' | 'production';
export interface ConfigLoader {
load(): Record<string, unknown>;
readonly priority: number;
}
export interface ConfigManagerOptions {
environment?: Environment;
configPath?: string;
loaders?: ConfigLoader[];
}
export type DeepPartial<T> = T extends object
? {
[P in keyof T]?: DeepPartial<T[P]>;
}
: T;
export type ConfigSchema = z.ZodSchema<unknown>;
export interface ProviderConfig {
name: string;
enabled: boolean;
[key: string]: unknown;
}

View file

@ -0,0 +1,183 @@
import { z } from 'zod';
/**
* Secret value wrapper to prevent accidental logging
*/
export class SecretValue<T = string> {
private readonly value: T;
private readonly masked: string;
constructor(value: T, mask: string = '***') {
this.value = value;
this.masked = mask;
}
/**
* Get the actual secret value
* @param reason - Required reason for accessing the secret
*/
reveal(reason: string): T {
if (!reason) {
throw new Error('Reason required for revealing secret value');
}
return this.value;
}
/**
* Get masked representation
*/
toString(): string {
return this.masked;
}
/**
* Prevent JSON serialization of actual value
*/
toJSON(): string {
return this.masked;
}
/**
* Check if value matches without revealing it
*/
equals(other: T): boolean {
return this.value === other;
}
/**
* Transform the secret value
*/
map<R>(fn: (value: T) => R, reason: string): SecretValue<R> {
return new SecretValue(fn(this.reveal(reason)));
}
}
/**
* Zod schema for secret values
*/
export const secretSchema = <T extends z.ZodTypeAny>(_schema: T) => {
return z.custom<SecretValue<z.infer<T>>>(
(val) => val instanceof SecretValue,
{
message: 'Expected SecretValue instance',
}
);
};
/**
* Transform string to SecretValue in Zod schema
*/
export const secretStringSchema = z
.string()
.transform((val) => new SecretValue(val));
/**
* Create a secret value
*/
export function secret<T = string>(value: T, mask?: string): SecretValue<T> {
return new SecretValue(value, mask);
}
/**
* Check if a value is a secret
*/
export function isSecret(value: unknown): value is SecretValue {
return value instanceof SecretValue;
}
/**
* Redact secrets from an object
*/
export function redactSecrets<T extends Record<string, any>>(
obj: T,
secretPaths: string[] = []
): T {
const result = { ...obj };
// Redact known secret paths
for (const path of secretPaths) {
const keys = path.split('.');
let current: any = result;
for (let i = 0; i < keys.length - 1; i++) {
const key = keys[i];
if (key && current[key] && typeof current[key] === 'object') {
current = current[key];
} else {
break;
}
}
const lastKey = keys[keys.length - 1];
if (current && lastKey && lastKey in current) {
current[lastKey] = '***REDACTED***';
}
}
// Recursively redact SecretValue instances
function redactSecretValues(obj: any): any {
if (obj === null || obj === undefined) {
return obj;
}
if (isSecret(obj)) {
return obj.toString();
}
if (Array.isArray(obj)) {
return obj.map(redactSecretValues);
}
if (typeof obj === 'object') {
const result: any = {};
for (const [key, value] of Object.entries(obj)) {
result[key] = redactSecretValues(value);
}
return result;
}
return obj;
}
return redactSecretValues(result);
}
/**
* Environment variable names that should be treated as secrets
*/
export const COMMON_SECRET_PATTERNS = [
/password/i,
/secret/i,
/key/i,
/token/i,
/credential/i,
/private/i,
/auth/i,
/api[-_]?key/i,
];
/**
* Check if an environment variable name indicates a secret
*/
export function isSecretEnvVar(name: string): boolean {
return COMMON_SECRET_PATTERNS.some(pattern => pattern.test(name));
}
/**
* Wrap environment variables that look like secrets
*/
export function wrapSecretEnvVars(
env: Record<string, string | undefined>
): Record<string, string | SecretValue | undefined> {
const result: Record<string, string | SecretValue | undefined> = {};
for (const [key, value] of Object.entries(env)) {
if (value !== undefined && isSecretEnvVar(key)) {
result[key] = new SecretValue(value, `***${key}***`);
} else {
result[key] = value;
}
}
return result;
}

View file

@ -0,0 +1,195 @@
import { z } from 'zod';
export interface ValidationResult {
valid: boolean;
errors?: Array<{
path: string;
message: string;
expected?: string;
received?: string;
}>;
warnings?: Array<{
path: string;
message: string;
}>;
}
/**
* Validate configuration against a schema
*/
export function validateConfig<T>(
config: unknown,
schema: z.ZodSchema<T>
): ValidationResult {
try {
schema.parse(config);
return { valid: true };
} catch (error) {
if (error instanceof z.ZodError) {
const errors = error.errors.map(err => ({
path: err.path.join('.'),
message: err.message,
expected: 'expected' in err ? String(err.expected) : undefined,
received: 'received' in err ? String(err.received) : undefined,
}));
return { valid: false, errors };
}
throw error;
}
}
/**
* Check for deprecated configuration options
*/
export function checkDeprecations(
config: Record<string, unknown>,
deprecations: Record<string, string>
): ValidationResult['warnings'] {
const warnings: ValidationResult['warnings'] = [];
function checkObject(obj: Record<string, unknown>, path: string[] = []): void {
for (const [key, value] of Object.entries(obj)) {
const currentPath = [...path, key];
const pathStr = currentPath.join('.');
if (pathStr in deprecations) {
const deprecationMessage = deprecations[pathStr];
if (deprecationMessage) {
warnings?.push({
path: pathStr,
message: deprecationMessage,
});
}
}
if (value && typeof value === 'object' && !Array.isArray(value)) {
checkObject(value as Record<string, unknown>, currentPath);
}
}
}
checkObject(config);
return warnings;
}
/**
* Check for required environment variables
*/
export function checkRequiredEnvVars(
required: string[]
): ValidationResult {
const errors: ValidationResult['errors'] = [];
for (const envVar of required) {
if (!process.env[envVar]) {
errors.push({
path: `env.${envVar}`,
message: `Required environment variable ${envVar} is not set`,
});
}
}
return {
valid: errors.length === 0,
errors: errors.length > 0 ? errors : undefined,
};
}
/**
* Validate configuration completeness
*/
export function validateCompleteness(
config: Record<string, any>,
required: string[]
): ValidationResult {
const errors: ValidationResult['errors'] = [];
for (const path of required) {
const keys = path.split('.');
let current: any = config;
let found = true;
for (const key of keys) {
if (current && typeof current === 'object' && key in current) {
current = current[key];
} else {
found = false;
break;
}
}
if (!found || current === undefined || current === null) {
errors.push({
path,
message: `Required configuration value is missing`,
});
}
}
return {
valid: errors.length === 0,
errors: errors.length > 0 ? errors : undefined,
};
}
/**
* Format validation result for display
*/
export function formatValidationResult(result: ValidationResult): string {
const lines: string[] = [];
if (result.valid) {
lines.push('✅ Configuration is valid');
} else {
lines.push('❌ Configuration validation failed');
}
if (result.errors && result.errors.length > 0) {
lines.push('\nErrors:');
for (const error of result.errors) {
lines.push(` - ${error.path}: ${error.message}`);
if (error.expected && error.received) {
lines.push(` Expected: ${error.expected}, Received: ${error.received}`);
}
}
}
if (result.warnings && result.warnings.length > 0) {
lines.push('\nWarnings:');
for (const warning of result.warnings) {
lines.push(` - ${warning.path}: ${warning.message}`);
}
}
return lines.join('\n');
}
/**
* Create a strict schema that doesn't allow extra properties
*/
export function createStrictSchema<T extends z.ZodRawShape>(
shape: T
): z.ZodObject<T, 'strict'> {
return z.object(shape).strict();
}
/**
* Merge multiple schemas
*/
export function mergeSchemas<T extends z.ZodSchema[]>(
...schemas: T
): z.ZodIntersection<T[0], T[1]> {
if (schemas.length < 2) {
throw new Error('At least two schemas required for merge');
}
let result = schemas[0]!.and(schemas[1]!);
for (let i = 2; i < schemas.length; i++) {
result = result.and(schemas[i]!) as any;
}
return result as any;
}