xc-mcp
Version:
MCP server that wraps Xcode command-line tools for iOS/macOS development workflows
195 lines • 6.82 kB
JavaScript
import { promises as fs } from 'fs';
import { join } from 'path';
import { homedir } from 'os';
import { randomUUID } from 'crypto';
/**
* ConfigManager handles project-local configuration with auto-learning capabilities.
* Stores `.xc-mcp/config.json` in project directories for persistent preferences.
*
* Features:
* - Project-specific simulator preferences
* - Build history tracking
* - Atomic writes (temp file + rename)
* - Graceful degradation if config unavailable
*/
export class ConfigManager {
configDir;
projectConfigs = new Map();
schemaVersion = '1.0.0';
configFileName = 'config.json';
constructor(projectPath) {
// Determine config directory - prefer .xc-mcp in project root
if (projectPath) {
this.configDir = join(projectPath, '.xc-mcp');
}
else {
// Fallback to user home for global config
this.configDir = join(homedir(), '.xc-mcp');
}
}
/**
* Get the full path to the config file
*/
getConfigPath() {
return join(this.configDir, this.configFileName);
}
/**
* Get project-specific configuration
*/
async getProjectConfig(projectPath) {
// Load from cache if available
const cached = this.projectConfigs.get(projectPath);
if (cached) {
return cached;
}
// Try to load from disk
const diskConfig = await this.loadConfigFromDisk();
if (diskConfig && diskConfig.projectConfigs) {
const configMap = new Map(diskConfig.projectConfigs);
this.projectConfigs = configMap;
const diskConfigValue = configMap.get(projectPath);
if (diskConfigValue) {
return diskConfigValue;
}
}
// Return empty config if not found
const emptyConfig = {};
this.projectConfigs.set(projectPath, emptyConfig);
return emptyConfig;
}
/**
* Update project configuration
*/
async updateProjectConfig(projectPath, updates) {
// Update in-memory config
const currentConfig = await this.getProjectConfig(projectPath);
const updatedConfig = { ...currentConfig, ...updates };
this.projectConfigs.set(projectPath, updatedConfig);
// Save to disk atomically
await this.saveConfigToDisk();
}
/**
* Record a successful build with simulator preference
*/
async recordSuccessfulBuild(projectPath, simulatorUDID, simulatorName) {
const config = await this.getProjectConfig(projectPath);
const updatedConfig = {
...config,
lastBuildTime: Date.now(),
buildCount: (config.buildCount || 0) + 1,
successfulBuilds: (config.successfulBuilds || 0) + 1,
};
// Update simulator preference if provided
if (simulatorUDID) {
updatedConfig.lastUsedSimulator = simulatorUDID;
updatedConfig.lastUsedSimulatorName = simulatorName;
}
this.projectConfigs.set(projectPath, updatedConfig);
await this.saveConfigToDisk();
}
/**
* Get last used simulator for project
*/
async getLastUsedSimulator(projectPath) {
const config = await this.getProjectConfig(projectPath);
return config.lastUsedSimulator;
}
/**
* Get build success rate for project
*/
async getBuildSuccessRate(projectPath) {
const config = await this.getProjectConfig(projectPath);
if (!config.buildCount || config.buildCount === 0) {
return 0;
}
return ((config.successfulBuilds || 0) / config.buildCount) * 100;
}
/**
* Load entire config from disk
*/
async loadConfigFromDisk() {
try {
const configPath = this.getConfigPath();
const content = await fs.readFile(configPath, 'utf8');
const parsed = JSON.parse(content);
// Validate schema version
if (parsed.version !== this.schemaVersion) {
console.warn(`Config schema version mismatch: ${parsed.version} vs ${this.schemaVersion}`);
return null;
}
return parsed;
}
catch (error) {
// File doesn't exist or is corrupted - return null for graceful degradation
if (error instanceof Error &&
'code' in error &&
error.code === 'ENOENT') {
return null; // File doesn't exist yet
}
// JSON parse errors or other corruption - gracefully degrade
return null;
}
}
/**
* Save entire config to disk with atomic writes
*/
async saveConfigToDisk() {
try {
// Ensure directory exists
await fs.mkdir(this.configDir, { recursive: true });
// Convert Map to array for serialization
const configData = {
version: this.schemaVersion,
projectConfigs: Array.from(this.projectConfigs.entries()),
lastUpdated: new Date().toISOString(),
};
const content = JSON.stringify(configData, null, 2);
const configPath = this.getConfigPath();
// Atomic write: write to temp file, then rename
const tempFile = `${configPath}.tmp.${randomUUID()}`;
await fs.writeFile(tempFile, content, 'utf8');
await fs.rename(tempFile, configPath);
}
catch (error) {
console.warn('Failed to save config to disk:', error);
// Gracefully continue - config is still in memory
}
}
/**
* Clear all configurations
*/
async clear() {
this.projectConfigs.clear();
try {
const configPath = this.getConfigPath();
await fs.unlink(configPath);
}
catch (error) {
// File doesn't exist, that's fine - only log other errors
if (error instanceof Error &&
'code' in error &&
error.code !== 'ENOENT') {
console.warn('Failed to delete config file:', error);
}
}
}
/**
* Get all project configurations
*/
getAllProjectConfigs() {
return new Map(this.projectConfigs);
}
}
// Global config manager instance (project-aware)
export function createConfigManager(projectPath) {
return new ConfigManager(projectPath);
}
// Singleton pattern for backward compatibility
let globalConfigManager = null;
export function getConfigManager(projectPath) {
if (!globalConfigManager) {
globalConfigManager = new ConfigManager(projectPath);
}
return globalConfigManager;
}
//# sourceMappingURL=config.js.map