UNPKG

@xec-sh/cli

Version:

Xec: The Universal Shell for TypeScript

406 lines 13.6 kB
import * as path from 'path'; import { homedir } from 'os'; import * as yaml from 'js-yaml'; import * as fs from 'fs/promises'; import { deepMerge } from './utils.js'; import { SecretManager } from '../secrets/index.js'; import { TargetResolver } from './target-resolver.js'; import { ConfigValidator } from './config-validator.js'; import { VariableInterpolator } from './variable-interpolator.js'; const DEFAULT_CONFIG = { version: '1.0', targets: { local: { type: 'local' } }, commands: { in: { defaultTimeout: '30s' }, on: { parallel: false }, copy: { compress: true, progress: true }, forward: { dynamic: true }, watch: { interval: 2, clear: true } } }; export class ConfigurationManager { constructor(options = {}) { this.options = options; this.sources = []; this.options.projectRoot = this.options.projectRoot || process.cwd(); this.options.globalConfigDir = this.options.globalConfigDir || path.join(homedir(), '.xec'); this.options.envPrefix = this.options.envPrefix || 'XEC_'; this.secretManager = new SecretManager(this.options.secretProvider); this.interpolator = new VariableInterpolator(this.secretManager); this.validator = new ConfigValidator(); } async load() { this.sources = []; this.merged = undefined; await this.secretManager.initialize(); await this.loadBuiltinDefaults(); await this.loadGlobalConfig(); await this.loadProjectConfig(); await this.loadEnvironmentConfig(); await this.loadProfileConfig(); this.merged = this.mergeConfigurations(); if (this.merged.secrets) { await this.updateSecretProvider({ type: this.merged.secrets.provider, config: this.merged.secrets.config }); } try { this.merged = await this.resolveVariables(this.merged); } catch (error) { if (error.message.includes('Circular variable reference detected')) { if (this.options.strict) { throw error; } else { console.warn(`Config warning: ${error.message}`); } } else { throw error; } } const errors = await this.validator.validate(this.merged); if (errors.length > 0) { if (this.options.strict) { throw new ConfigValidationError('Configuration validation failed', errors); } else { for (const error of errors) { console.warn(`Config warning: ${error.path} - ${error.message}`); } } } return this.merged; } get(path) { if (!this.merged) { throw new Error('Configuration not loaded. Call load() first.'); } return this.getByPath(this.merged, path); } set(path, value) { if (!this.merged) { throw new Error('Configuration not loaded. Call load() first.'); } this.setByPath(this.merged, path, value); } getCurrentProfile() { return this.options.profile || process.env[`${this.options.envPrefix}PROFILE`]; } async useProfile(profileName) { this.options.profile = profileName; await this.load(); } getProfiles() { return Object.keys(this.merged?.profiles || {}); } interpolate(value, context) { const env = {}; for (const [key, value] of Object.entries(process.env)) { if (value !== undefined) { env[key] = value; } } const fullContext = { vars: this.merged?.vars || {}, env, profile: this.getCurrentProfile(), ...context }; return this.interpolator.interpolate(value, fullContext); } getConfig() { if (!this.merged) { throw new Error('Configuration not loaded. Call load() first.'); } return this.merged; } getTargetResolver() { if (!this.merged) { throw new Error('Configuration not loaded. Call load() first.'); } return new TargetResolver(this.merged); } async validate() { if (!this.merged) { throw new Error('Configuration not loaded. Call load() first.'); } return this.validator.validate(this.merged); } async save(filePath) { if (!this.merged) { throw new Error('No configuration to save'); } const targetPath = filePath || path.join(this.options.projectRoot, '.xec', 'config.yaml'); const dir = path.dirname(targetPath); await fs.mkdir(dir, { recursive: true }); const yamlContent = yaml.dump(this.merged, { indent: 2, lineWidth: 120, sortKeys: false }); await fs.writeFile(targetPath, yamlContent, 'utf-8'); } async validateFile(filePath) { const content = await fs.readFile(filePath, 'utf-8'); const config = yaml.load(content); return this.validator.validate(config); } async loadBuiltinDefaults() { this.sources.push({ type: 'builtin', name: 'defaults', priority: 0, config: DEFAULT_CONFIG }); } async loadGlobalConfig() { const globalPath = path.join(this.options.globalConfigDir, 'config.yaml'); try { const content = await fs.readFile(globalPath, 'utf-8'); const config = yaml.load(content); this.sources.push({ type: 'global', path: globalPath, priority: 10, config }); } catch (error) { if (error.code !== 'ENOENT') { console.warn(`Failed to load global config: ${error.message}`); } } } async loadProjectConfig() { const locations = [ path.join(this.options.projectRoot, '.xec', 'config.yaml'), path.join(this.options.projectRoot, '.xec', 'config.yml'), path.join(this.options.projectRoot, 'xec.yaml'), path.join(this.options.projectRoot, 'xec.yml') ]; for (const location of locations) { try { const content = await fs.readFile(location, 'utf-8'); const config = yaml.load(content); this.sources.push({ type: 'project', path: location, priority: 20, config }); break; } catch (error) { if (error.code !== 'ENOENT') { if (this.options.strict && error.name === 'YAMLException') { throw error; } console.warn(`Failed to load project config from ${location}: ${error.message}`); } } } } async loadEnvironmentConfig() { const envConfig = {}; const prefix = this.options.envPrefix; for (const [key, value] of Object.entries(process.env)) { if (key.startsWith(prefix) && key !== `${prefix}PROFILE`) { const path = key .substring(prefix.length) .toLowerCase() .replace(/_/g, '.'); this.setByPath(envConfig, path, value); } } const configPath = process.env[`${prefix}CONFIG`]; if (configPath) { try { const content = await fs.readFile(configPath, 'utf-8'); const config = yaml.load(content); this.sources.push({ type: 'env', path: configPath, priority: 30, config }); } catch (error) { console.warn(`Failed to load config from ${prefix}CONFIG: ${error.message}`); } } if (Object.keys(envConfig).length > 0) { this.sources.push({ type: 'env', name: 'environment', priority: 35, config: envConfig }); } } async loadProfileConfig() { const profileName = this.getCurrentProfile(); if (!profileName) { return; } const resolvedProfile = await this.resolveProfileWithInheritance(profileName); if (resolvedProfile) { const config = { vars: resolvedProfile.vars, targets: resolvedProfile.targets }; if (resolvedProfile.env) { config.scripts = { env: resolvedProfile.env }; } this.sources.push({ type: 'profile', name: profileName, priority: 40, config }); } } async resolveProfileWithInheritance(profileName) { const seen = new Set(); const profiles = []; let currentName = profileName; while (currentName) { if (seen.has(currentName)) { console.warn(`Circular profile inheritance detected: ${currentName}`); break; } seen.add(currentName); let profileConfig; for (const source of this.sources) { if (source.config.profiles?.[currentName]) { profileConfig = source.config.profiles[currentName]; break; } } if (!profileConfig) { const profilePath = path.join(this.options.projectRoot, '.xec', 'profiles', `${currentName}.yaml`); try { const content = await fs.readFile(profilePath, 'utf-8'); profileConfig = yaml.load(content); } catch (error) { if (error.code !== 'ENOENT') { console.warn(`Failed to load profile ${currentName}: ${error.message}`); } } } if (profileConfig) { profiles.unshift(profileConfig); currentName = profileConfig.extends; } else { break; } } if (profiles.length === 0) { return undefined; } if (profiles.length === 1) { return profiles[0]; } const result = {}; for (const profile of profiles) { if (profile.vars) { result.vars = deepMerge(result.vars || {}, profile.vars); } if (profile.targets) { result.targets = deepMerge(result.targets || {}, profile.targets); } if (profile.env) { result.env = { ...result.env, ...profile.env }; } } return result; } mergeConfigurations() { const sorted = [...this.sources].sort((a, b) => a.priority - b.priority); let merged = { version: '1.0' }; for (const source of sorted) { merged = deepMerge(merged, source.config); } return merged; } async resolveVariables(config) { const env = {}; for (const [key, value] of Object.entries(process.env)) { if (value !== undefined) { env[key] = value; } } const context = { vars: config.vars || {}, env, profile: this.getCurrentProfile() }; const configCopy = JSON.parse(JSON.stringify(config)); const tasks = configCopy.tasks; delete configCopy.tasks; const resolved = await this.interpolator.resolveConfig(configCopy, context); if (tasks) { resolved.tasks = tasks; } return resolved; } getByPath(obj, path) { const parts = path.split('.'); let current = obj; for (const part of parts) { if (current == null || typeof current !== 'object') { return undefined; } current = current[part]; } return current; } setByPath(obj, path, value) { const parts = path.split('.'); const lastPart = parts.pop(); let current = obj; for (const part of parts) { if (!(part in current) || typeof current[part] !== 'object') { current[part] = {}; } current = current[part]; } current[lastPart] = value; } getSecretManager() { return this.secretManager; } async updateSecretProvider(config) { this.secretManager = new SecretManager(config); await this.secretManager.initialize(); this.interpolator = new VariableInterpolator(this.secretManager); } } export class ConfigValidationError extends Error { constructor(message, errors) { super(message); this.errors = errors; this.name = 'ConfigValidationError'; } } //# sourceMappingURL=configuration-manager.js.map