UNPKG

taskwerk

Version:

A task management CLI for developers and AI agents working together

728 lines (619 loc) 18.5 kB
import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync, statSync } from 'fs'; import { join, dirname } from 'path'; import { homedir } from 'os'; 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'; import { Logger } from '../logging/logger.js'; import { obfuscateObject, deobfuscateObject } from './crypto.js'; const CONFIG_DIR = '.taskwerk'; const CONFIG_FILE = 'config.yml'; /** * Configuration source types */ export const ConfigSource = { DEFAULT: 'default', GLOBAL: 'global', LOCAL: 'local', ENV: 'env', }; /** * Get global configuration path */ export function getGlobalConfigPath() { // Check XDG_CONFIG_HOME first const xdgConfig = process.env.XDG_CONFIG_HOME; if (xdgConfig) { return join(xdgConfig, 'taskwerk', 'config.yml'); } // Default to ~/.config/taskwerk/config.yml const configPath = join(homedir(), '.config', 'taskwerk', 'config.yml'); // Check for legacy ~/.taskwerk/config.yml const legacyPath = join(homedir(), '.taskwerk', 'config.yml'); if (existsSync(legacyPath) && !existsSync(configPath)) { return legacyPath; } // Also check for JSON variants const configJsonPath = configPath.replace('.yml', '.json'); const legacyJsonPath = legacyPath.replace('.yml', '.json'); if (existsSync(configJsonPath)) { return configJsonPath; } if (existsSync(legacyJsonPath)) { return legacyJsonPath; } return configPath; } /** * Enhanced configuration manager with global/local support */ export class GlobalConfigManager { constructor(localPath = null) { this.localPath = localPath || join(CONFIG_DIR, CONFIG_FILE); this.globalPath = getGlobalConfigPath(); this.logger = new Logger('config'); // Configuration layers this.defaultConfig = null; this.globalConfig = null; this.localConfig = null; this.envConfig = null; this.mergedConfig = null; // Track source of each config value this.configSources = new Map(); this.schema = getConfigSchema(); this.sensitiveFields = getSensitiveFields(); } /** * Load all configuration layers */ load() { try { // 1. Load default configuration this.defaultConfig = getDefaultConfig(); this.trackSources(this.defaultConfig, ConfigSource.DEFAULT); // 2. Load global configuration if exists if (existsSync(this.globalPath)) { this.globalConfig = this.loadConfigFile(this.globalPath); this.trackSources(this.globalConfig, ConfigSource.GLOBAL); this.checkFilePermissions(this.globalPath, true); } // 3. Load local configuration if exists if (existsSync(this.localPath)) { this.localConfig = this.loadConfigFile(this.localPath); this.trackSources(this.localConfig, ConfigSource.LOCAL); } // 4. Load environment configuration this.envConfig = mergeEnvConfig({}); this.trackSources(this.envConfig, ConfigSource.ENV); // 5. Merge all configurations this.mergedConfig = this.mergeConfigs(); // 6. Validate final configuration this.validate(); return this.mergedConfig; } catch (error) { if (error instanceof ConfigurationError) { throw error; } throw new ConfigurationError(`Failed to load configuration: ${error.message}`, 'load', { localPath: this.localPath, globalPath: this.globalPath, }); } } /** * Load configuration from a file */ loadConfigFile(filePath) { const content = readFileSync(filePath, 'utf8'); let config; // Support both YAML and JSON if (filePath.endsWith('.yml') || filePath.endsWith('.yaml')) { config = parseYaml(content); } else if (filePath.endsWith('.json')) { config = JSON.parse(content); } else { // Try YAML first, then JSON try { config = parseYaml(content); } catch { config = JSON.parse(content); } } // Deobfuscate sensitive fields after loading return deobfuscateObject(config, this.sensitiveFields); } /** * Save configuration to file * @param {boolean} global - Save to global config instead of local */ save(global = false) { const configPath = global ? this.globalPath : this.localPath; const configToSave = global ? this.globalConfig : this.localConfig; if (!configToSave) { throw new ConfigurationError( `No ${global ? 'global' : 'local'} configuration to save`, 'save', { configPath } ); } try { // Ensure directory exists const dir = dirname(configPath); if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }); } // Obfuscate sensitive fields before saving const obfuscatedConfig = obfuscateObject(configToSave, this.sensitiveFields); // Serialize based on file extension let content; if (configPath.endsWith('.yml') || configPath.endsWith('.yaml')) { content = stringifyYaml(obfuscatedConfig, { indent: 2, lineWidth: 80, }); } else { content = JSON.stringify(obfuscatedConfig, null, 2); } writeFileSync(configPath, content, 'utf8'); // Set secure permissions for global config if (global && configPath.includes(homedir())) { try { chmodSync(configPath, 0o600); // User read/write only } catch (error) { this.logger.warn(`Could not set secure permissions on ${configPath}: ${error.message}`); } } } catch (error) { throw new ConfigurationError( `Failed to save ${global ? 'global' : 'local'} configuration: ${error.message}`, 'save', { configPath } ); } } /** * Get a configuration value */ get(path, defaultValue = undefined) { if (!this.mergedConfig) { this.load(); } const parts = path.split('.'); let value = this.mergedConfig; 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 * @param {string} path - Configuration path * @param {any} value - Value to set * @param {boolean} global - Set in global config instead of local */ set(path, value, global = false) { if (!this.mergedConfig) { this.load(); } // Initialize target config if needed if (global && !this.globalConfig) { this.globalConfig = {}; } else if (!global && !this.localConfig) { this.localConfig = {}; } const targetConfig = global ? this.globalConfig : this.localConfig; const parts = path.split('.'); const lastPart = parts.pop(); let target = targetConfig; // 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; // Update source tracking this.configSources.set(path, global ? ConfigSource.GLOBAL : ConfigSource.LOCAL); // Re-merge configurations this.mergedConfig = this.mergeConfigs(); // Validate after setting this.validate(); } /** * Delete a configuration value * @param {string} path - Configuration path * @param {boolean} global - Delete from global config */ delete(path, global = false) { if (!this.mergedConfig) { this.load(); } const targetConfig = global ? this.globalConfig : this.localConfig; if (!targetConfig) { return false; } const parts = path.split('.'); const lastPart = parts.pop(); let target = targetConfig; // 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]; // Re-merge configurations this.mergedConfig = this.mergeConfigs(); return true; } return false; } /** * Get configuration source for a path */ getSource(path) { return this.configSources.get(path) || ConfigSource.DEFAULT; } /** * Get configuration with source information */ getWithSources() { if (!this.mergedConfig) { this.load(); } const result = {}; const addWithSource = (obj, path = '') => { for (const [key, value] of Object.entries(obj)) { const fullPath = path ? `${path}.${key}` : key; if (this.isObject(value)) { result[key] = {}; addWithSource(value, fullPath); } else { result[key] = { value: value, source: this.getSource(fullPath), }; } } }; addWithSource(this.mergedConfig); return result; } /** * Merge configurations in priority order */ mergeConfigs() { let merged = {}; // Merge in order: defaults → global → local → env if (this.defaultConfig) { merged = this.deepMerge(merged, this.defaultConfig); } if (this.globalConfig) { merged = this.deepMerge(merged, this.globalConfig); } if (this.localConfig) { merged = this.deepMerge(merged, this.localConfig); } if (this.envConfig) { merged = this.deepMerge(merged, this.envConfig); } return merged; } /** * Track sources of configuration values */ trackSources(config, source, path = '') { if (!config) { return; } for (const [key, value] of Object.entries(config)) { const fullPath = path ? `${path}.${key}` : key; if (this.isObject(value)) { this.trackSources(value, source, fullPath); } else if (value !== undefined) { // Only track if not already tracked by higher priority source const currentSource = this.configSources.get(fullPath); const sourcePriority = { [ConfigSource.DEFAULT]: 0, [ConfigSource.GLOBAL]: 1, [ConfigSource.LOCAL]: 2, [ConfigSource.ENV]: 3, }; if (!currentSource || sourcePriority[source] > sourcePriority[currentSource]) { this.configSources.set(fullPath, source); } } } } /** * Check file permissions for security */ checkFilePermissions(filePath, isGlobal = false) { try { const stats = statSync(filePath); const mode = stats.mode & parseInt('777', 8); // Check if file is world-readable if (mode & 0o004) { const message = `Configuration file ${filePath} is world-readable. This may expose sensitive data.`; if (isGlobal && this.hasSensitiveData(isGlobal ? this.globalConfig : this.localConfig)) { this.logger.warn(message); console.warn(`⚠️ ${message}`); console.warn(` Run: chmod 600 "${filePath}" to fix`); } } } catch (error) { // Ignore permission check errors } } /** * Check if config contains sensitive data */ hasSensitiveData(config) { if (!config) { return false; } for (const field of this.sensitiveFields) { const value = this.getValueByPath(config, field); if (value && value !== '********') { return true; } } return false; } /** * Get value by path from object */ getValueByPath(obj, path) { const parts = path.split('.'); let value = obj; for (const part of parts) { if (value && typeof value === 'object' && part in value) { value = value[part]; } else { return undefined; } } return value; } /** * 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 */ validate() { const errors = []; this.validateObject(this.mergedConfig, 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; 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 masked configuration */ getMasked() { if (!this.mergedConfig) { this.load(); } return this.maskSensitiveFields(this.mergedConfig); } /** * Get global configuration (masked) */ getGlobalMasked() { if (!this.globalConfig) { return null; } return this.maskSensitiveFields(this.globalConfig); } /** * Get local configuration (masked) */ getLocalMasked() { if (!this.localConfig) { return null; } return this.maskSensitiveFields(this.localConfig); } /** * Migrate local config to global */ async migrateToGlobal() { if (!this.localConfig) { throw new ConfigurationError('No local configuration to migrate', 'migrate'); } // Merge with existing global config if any if (this.globalConfig) { this.globalConfig = this.deepMerge(this.globalConfig, this.localConfig); } else { this.globalConfig = { ...this.localConfig }; } // Save global config this.save(true); // Clear local config this.localConfig = {}; this.save(false); return true; } /** * Copy global config to local */ async copyFromGlobal() { if (!this.globalConfig) { throw new ConfigurationError('No global configuration to copy', 'copy'); } // Merge with existing local config if any if (this.localConfig) { this.localConfig = this.deepMerge(this.localConfig, this.globalConfig); } else { this.localConfig = { ...this.globalConfig }; } // Save local config this.save(false); return true; } /** * Clear configuration * @param {boolean} global - Clear global config instead of local */ clear(global = false) { if (global) { this.globalConfig = {}; this.save(true); } else { this.localConfig = {}; this.save(false); } // Reload to update merged config this.load(); } } // Singleton instance let configManagerInstance = null; /** * Get the global configuration manager instance */ export function getGlobalConfigManager(localPath = null) { if (!configManagerInstance) { configManagerInstance = new GlobalConfigManager(localPath); } return configManagerInstance; } /** * Reset the global configuration manager instance */ export function resetGlobalConfigManager() { configManagerInstance = null; }