config.ts•10.3 kB
import * as fs from 'fs';
import * as path from 'path';
/**
* Configuration Manager
*
* Manages configuration settings with validation, environment variable support,
* and hierarchical configuration (defaults < config file < environment variables).
*/
export class ConfigManager {
private static instance: ConfigManager;
private config: Record<string, any>;
private configPath: string;
private constructor() {
this.configPath = path.join(process.cwd(), 'watchtower.config.json');
this.config = this.loadConfig();
}
static getInstance(): ConfigManager {
if (!ConfigManager.instance) {
ConfigManager.instance = new ConfigManager();
}
return ConfigManager.instance;
}
/**
* Load configuration with hierarchical merging
*/
private loadConfig(): Record<string, any> {
const defaults = this.getDefaultConfig();
let fileConfig: Record<string, any> = {};
let envConfig: Record<string, any> = {};
// Load from config file if it exists
try {
if (fs.existsSync(this.configPath)) {
const fileContent = fs.readFileSync(this.configPath, 'utf-8');
fileConfig = JSON.parse(fileContent);
}
} catch (error) {
console.warn(`Failed to load config file ${this.configPath}:`, (error as Error).message);
}
// Load from environment variables
envConfig = this.getEnvConfig();
// Hierarchical merge: defaults < file < environment
return this.mergeConfigs(defaults, fileConfig, envConfig);
}
/**
* Get default configuration
*/
private getDefaultConfig(): Record<string, any> {
return {
server: {
port: 0, // Use dynamic port
host: '127.0.0.1',
maxConnections: 10,
idleTimeout: 300000, // 5 minutes
},
mcp: {
name: 'watchtower',
description: 'Windows-native MCP ⇄ DAP bridge',
version: '0.1.0-alpha',
maxTools: 50,
},
debugging: {
defaultTimeout: 30000, // 30 seconds
maxSessions: 5,
sessionTimeout: 3600000, // 1 hour
enableMetrics: true,
enableTracing: true,
},
transport: {
type: 'stdio', // stdio or tcp
tcpPort: 4711, // For attach scenarios
tcpHost: '127.0.0.1',
},
adapters: {
autoDiscover: true,
windows: {
vsdbg: {
name: 'Visual Studio Debugger',
path: 'C:\\Program Files\\Microsoft Visual Studio\\2022\\Community\\Common7\\IDE\\vsdbg.exe',
requiredPaths: ['C:\\Program Files\\Microsoft Visual Studio\\2022\\Common7\\IDE'],
versionRequirements: {
min: '17.0.0',
recommended: '17.8.0',
},
installHints: [
'Download Visual Studio Installer: https://visualstudio.microsoft.com/downloads/',
'Install "Desktop development with C#" workload',
'Ensure vsdbg.exe is in your PATH or provide full path',
],
},
netcoredbg: {
name: '.NET Core Debugger',
path: 'C:\\Program Files\\dotnet\\tools\\netcoredbg.exe',
requiredPaths: ['C:\\Program Files\\dotnet'],
versionRequirements: {
min: '6.0.0',
recommended: '7.0.0',
},
installHints: [
'Install .NET SDK: https://dotnet.microsoft.com/download',
'netcoredbg will be automatically installed with .NET tools',
],
},
'vscode-js-debug': {
name: 'JavaScript Debugger',
path: '%USERPROFILE%\\.vscode\\extensions\\ms-vscode.js-debug-1.xx.x\\out\\node\\debugAdapter.js',
requiredPaths: ['%USERPROFILE%\\.vscode\\extensions\\ms-vscode.js-debug-*'],
versionRequirements: {
min: '1.x.x',
recommended: '1.80.0',
},
installHints: [
'Install Visual Studio Code: https://code.visualstudio.com/',
'Install JavaScript Debugger extension: ms-vscode.js-debug',
'Restart VS Code after installation',
],
},
debugpy: {
name: 'Python Debugger',
path: 'C:\\Users\\%USERNAME%\\AppData\\Local\\Programs\\Python\\Python39\\Scripts\\debugpy.exe',
requiredPaths: [
'C:\\Users\\%USERNAME%\\AppData\\Local\\Programs\\Python\\Python39\\Scripts',
],
versionRequirements: {
min: '1.6.0',
recommended: '1.8.0',
},
installHints: [
'Install Python: https://python.org',
'Install debugpy: pip install debugpy',
'Ensure Python is in your PATH',
],
},
dap: {
name: 'Generic DAP Adapter',
path: null, // Will be discovered dynamically
requiredPaths: [],
versionRequirements: {},
installHints: [
'Place DAP adapter executable in PATH or specify full path',
'Ensure adapter supports DAP protocol',
],
},
},
},
security: {
redactSensitiveData: true,
maxLogSize: 1024 * 1024, // 1MB
logRotation: true,
allowedOrigins: ['*'],
requireAuth: false,
},
metrics: {
enabled: true,
otlpEndpoint: 'http://localhost:4318',
serviceName: 'watchtower',
exportInterval: 60000, // 1 minute
},
logging: {
level: 'info',
format: 'json',
destination: 'stdout',
file: {
path: 'watchtower.log',
maxSize: '10MB',
maxFiles: 5,
},
},
};
}
/**
* Extract configuration from environment variables
*/
private getEnvConfig(): Record<string, any> {
const envConfig: Record<string, any> = {};
const envToConfigMap: Record<string, string> = {
WATCHTOWER_SERVER_PORT: 'server.port',
WATCHTOWER_SERVER_HOST: 'server.host',
WATCHTOWER_DEBUGGING_DEFAULT_TIMEOUT: 'debugging.defaultTimeout',
WATCHTOWER_DEBUGGING_MAX_SESSIONS: 'debugging.maxSessions',
WATCHTOWER_TRANSPORT_TYPE: 'transport.type',
WATCHTOWER_METRICS_ENABLED: 'metrics.enabled',
WATCHTOWER_LOGGING_LEVEL: 'logging.level',
};
for (const [envKey, configKey] of Object.entries(envToConfigMap)) {
if (process.env[envKey]) {
this.setNestedValue(envConfig, configKey, this.parseEnvValue(process.env[envKey]));
}
}
return envConfig;
}
/**
* Parse environment variable value with type conversion
*/
private parseEnvValue(value: string): any {
// Boolean
if (value === 'true') return true;
if (value === 'false') return false;
// Number
if (/^-?\d+$/.test(value)) {
return parseInt(value, 10);
}
if (/^-?\d+\.\d+$/.test(value)) {
return parseFloat(value);
}
// String
return value;
}
/**
* Set nested value in object using dot notation
*/
private setNestedValue(obj: Record<string, any>, path: string, value: any): void {
const keys = path.split('.');
let current = obj;
for (let i = 0; i < keys.length - 1; i++) {
const key = keys[i];
if (key && !(key in current)) {
current[key] = {};
}
current = current[key as keyof typeof current];
}
current[keys[keys.length - 1] as keyof typeof current] = value;
}
/**
* Deep merge objects
*/
private mergeConfigs(...configs: Record<string, any>[]): Record<string, any> {
const result: Record<string, any> = {};
for (const config of configs) {
this.deepMerge(result, config);
}
return result;
}
/**
* Deep merge two objects
*/
private deepMerge(target: Record<string, any>, source: Record<string, any>): void {
for (const key in source) {
if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) {
if (!target[key]) {
target[key] = {};
}
this.deepMerge(target[key], source[key]);
} else {
target[key] = source[key];
}
}
}
/**
* Get configuration value
*/
get<T>(key: string, defaultValue?: T): T {
const value = this.getNestedValue(this.config, key);
return value !== undefined ? value : (defaultValue as T);
}
/**
* Get nested value using dot notation
*/
private getNestedValue(obj: Record<string, any>, path: string): any {
const keys = path.split('.');
let current = obj;
for (const key of keys) {
if (current && typeof current === 'object' && key in current) {
current = current[key];
} else {
return undefined;
}
}
return current;
}
/**
* Set configuration value
*/
set(key: string, value: any): void {
this.setNestedValue(this.config, key, value);
this.saveConfig();
}
/**
* Get entire configuration
*/
getAll(): Record<string, any> {
return { ...this.config };
}
/**
* Save configuration to file
*/
private saveConfig(): void {
try {
fs.writeFileSync(this.configPath, JSON.stringify(this.config, null, 2));
} catch (error) {
console.warn(`Failed to save config file ${this.configPath}:`, (error as Error).message);
}
}
/**
* Reload configuration from files and environment
*/
reload(): void {
this.config = this.loadConfig();
}
/**
* Validate configuration
*/
validate(): { valid: boolean; errors: string[] } {
const errors: string[] = [];
// Validate server configuration
if (this.get('server.port', 0) < 0 || this.get('server.port', 0) > 65535) {
errors.push('Invalid server port');
}
// Validate debugging configuration
if (this.get('debugging.maxSessions', 1) <= 0) {
errors.push('maxSessions must be positive');
}
// Validate transport configuration
const transportType = this.get('transport.type', 'stdio');
if (transportType !== 'stdio' && transportType !== 'tcp') {
errors.push('transport.type must be either "stdio" or "tcp"');
}
return {
valid: errors.length === 0,
errors,
};
}
}