@xec-sh/cli
Version:
Xec: The Universal Shell for TypeScript
406 lines • 13.6 kB
JavaScript
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