UNPKG

taskwerk

Version:

A task management CLI for developers and AI agents working together

403 lines (346 loc) 9.82 kB
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs'; import { join, dirname } from 'path'; import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'; import { getConfigSchema, getDefaultConfig, getSensitiveFields } from './schema.js'; import { ConfigurationError } from '../errors/index.js'; import { mergeEnvConfig } from './env-loader.js'; const CONFIG_DIR = '.taskwerk'; const CONFIG_FILE = 'config.yml'; const CONFIG_PATH = join(CONFIG_DIR, CONFIG_FILE); /** * Configuration manager for Taskwerk */ export class ConfigManager { constructor(configPath = null) { this.configPath = configPath || CONFIG_PATH; this.config = null; this.schema = getConfigSchema(); this.sensitiveFields = getSensitiveFields(); } /** * Load configuration from file */ load() { try { if (!existsSync(this.configPath)) { // Create default config if it doesn't exist this.config = getDefaultConfig(); // Don't automatically save - let the user configure it first return this.config; } const configContent = readFileSync(this.configPath, 'utf8'); let parsedConfig; // Support both YAML and JSON if (this.configPath.endsWith('.yml') || this.configPath.endsWith('.yaml')) { parsedConfig = parseYaml(configContent); } else if (this.configPath.endsWith('.json')) { parsedConfig = JSON.parse(configContent); } else { // Try YAML first, then JSON try { parsedConfig = parseYaml(configContent); } catch { parsedConfig = JSON.parse(configContent); } } // Merge with defaults this.config = this.mergeWithDefaults(parsedConfig); // Merge environment variables (they take precedence) this.config = mergeEnvConfig(this.config); // Validate configuration this.validate(); return this.config; } catch (error) { if (error instanceof ConfigurationError) { throw error; } throw new ConfigurationError( `Failed to load configuration: ${error.message}`, 'configPath', this.configPath ); } } /** * Save configuration to file */ save() { try { // Ensure config directory exists const dir = dirname(this.configPath); if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }); } // Mask sensitive fields before saving const configToSave = this.maskSensitiveFields(this.config); let content; if (this.configPath.endsWith('.yml') || this.configPath.endsWith('.yaml')) { content = stringifyYaml(configToSave, { indent: 2, lineWidth: 80, }); } else { content = JSON.stringify(configToSave, null, 2); } writeFileSync(this.configPath, content, 'utf8'); } catch (error) { throw new ConfigurationError( `Failed to save configuration: ${error.message}`, 'configPath', this.configPath ); } } /** * Get a configuration value */ get(path, defaultValue = undefined) { if (!this.config) { this.load(); } const parts = path.split('.'); let value = this.config; for (const part of parts) { if (value && typeof value === 'object' && part in value) { value = value[part]; } else { return defaultValue; } } return value; } /** * Set a configuration value */ set(path, value) { if (!this.config) { this.load(); } const parts = path.split('.'); const lastPart = parts.pop(); let target = this.config; // Navigate to the parent object for (const part of parts) { if (!(part in target)) { target[part] = {}; } target = target[part]; } // Set the value target[lastPart] = value; // Validate after setting this.validate(); } /** * Delete a configuration value */ delete(path) { if (!this.config) { this.load(); } const parts = path.split('.'); const lastPart = parts.pop(); let target = this.config; // Navigate to the parent object for (const part of parts) { if (!(part in target)) { return false; } target = target[part]; } if (lastPart in target) { delete target[lastPart]; return true; } return false; } /** * Alias for delete method (used by config command) */ unset(path) { return this.delete(path); } /** * Reset configuration to defaults */ reset() { this.config = getDefaultConfig(); this.save(); } /** * Merge configuration with defaults */ mergeWithDefaults(config) { const defaults = getDefaultConfig(); return this.deepMerge(defaults, config); } /** * Deep merge two objects */ deepMerge(target, source) { const output = { ...target }; if (this.isObject(target) && this.isObject(source)) { Object.keys(source).forEach(key => { if (this.isObject(source[key])) { if (!(key in target)) { output[key] = source[key]; } else { output[key] = this.deepMerge(target[key], source[key]); } } else { output[key] = source[key]; } }); } return output; } /** * Check if value is a plain object */ isObject(item) { return item && typeof item === 'object' && !Array.isArray(item); } /** * Validate configuration against schema */ validate() { const errors = []; this.validateObject(this.config, this.schema, '', errors); if (errors.length > 0) { throw new ConfigurationError( `Configuration validation failed: ${errors.join(', ')}`, 'validation', errors ); } } /** * Recursively validate an object against a schema */ validateObject(obj, schema, path, errors) { if (schema.type !== 'object') { return; } // Check for unknown properties if (schema.additionalProperties === false) { for (const key of Object.keys(obj)) { if (!(key in schema.properties)) { errors.push(`Unknown property: ${path}${key}`); } } } // Check required properties const requiredProps = schema.required || []; for (const requiredProp of requiredProps) { if (!(requiredProp in obj)) { const fullPath = path ? `${path}.${requiredProp}` : requiredProp; errors.push(`Missing required property: ${fullPath}`); } } // Validate each property that exists for (const [key, propSchema] of Object.entries(schema.properties || {})) { const fullPath = path ? `${path}.${key}` : key; const value = obj[key]; if (value !== undefined) { this.validateValue(value, propSchema, fullPath, errors); } } } /** * Validate a value against a schema */ validateValue(value, schema, path, errors) { // Type validation if (schema.type) { const type = Array.isArray(value) ? 'array' : typeof value; // Special handling for integer type if (schema.type === 'integer') { if (type !== 'number' || !Number.isInteger(value)) { errors.push(`Invalid type for ${path}: expected integer, got ${type}`); return; } } else if (type !== schema.type) { errors.push(`Invalid type for ${path}: expected ${schema.type}, got ${type}`); return; } } // Enum validation if (schema.enum && !schema.enum.includes(value)) { errors.push(`Invalid value for ${path}: must be one of ${schema.enum.join(', ')}`); } // String pattern validation if (schema.pattern && typeof value === 'string') { const regex = new RegExp(schema.pattern); if (!regex.test(value)) { errors.push(`Invalid format for ${path}: must match pattern ${schema.pattern}`); } } // Number range validation if (typeof value === 'number') { if (schema.minimum !== undefined && value < schema.minimum) { errors.push(`Value for ${path} must be >= ${schema.minimum}`); } if (schema.maximum !== undefined && value > schema.maximum) { errors.push(`Value for ${path} must be <= ${schema.maximum}`); } } // Nested object validation if (schema.type === 'object' && schema.properties) { this.validateObject(value, schema, path, errors); } } /** * Mask sensitive fields in configuration */ maskSensitiveFields(config) { const masked = JSON.parse(JSON.stringify(config)); // Deep clone for (const field of this.sensitiveFields) { const parts = field.split('.'); let target = masked; for (let i = 0; i < parts.length - 1; i++) { if (target[parts[i]]) { target = target[parts[i]]; } else { break; } } const lastPart = parts[parts.length - 1]; if (target[lastPart]) { target[lastPart] = '********'; } } return masked; } /** * Get configuration with masked sensitive fields */ getMasked() { if (!this.config) { this.load(); } return this.maskSensitiveFields(this.config); } /** * Export configuration as JSON */ toJSON() { return this.getMasked(); } } // Singleton instance let configManagerInstance = null; /** * Get the configuration manager instance */ export function getConfigManager(configPath = null) { if (!configManagerInstance) { configManagerInstance = new ConfigManager(configPath); } return configManagerInstance; } /** * Reset the configuration manager instance */ export function resetConfigManager() { configManagerInstance = null; }