UNPKG

@kya-os/cli

Version:

CLI for KYA-OS MCP-I setup and management

498 lines 18.7 kB
import { existsSync, readFileSync, writeFileSync, accessSync, constants, unlinkSync, } from "fs"; import { join } from "path"; export class EnvManager { constructor(projectRoot = process.cwd()) { this.projectRoot = projectRoot; } /** * Scan for all .env files with detailed analysis */ scanEnvFiles() { // Files to scan (excluding test/example files) const candidateFiles = [ ".env.local", // Highest priority - local development secrets ".env", // Standard env file ".env.development", ".env.dev", ".env.production", ".env.prod", ]; const envFiles = []; for (const filename of candidateFiles) { const filepath = join(this.projectRoot, filename); const exists = existsSync(filepath); let writable = false; let hasMcpIdentity = false; let mcpVariables = {}; if (exists) { // Check write permissions try { accessSync(filepath, constants.W_OK); writable = true; } catch { writable = false; } // Parse for MCP-I variables mcpVariables = this.parseEnvFile(filepath); hasMcpIdentity = Object.keys(mcpVariables).length > 0; } else { // Check if we can create the file (directory write permission) try { accessSync(this.projectRoot, constants.W_OK); writable = true; } catch { writable = false; } } envFiles.push({ path: filepath, filename, exists, writable, hasMcpIdentity, mcpVariables, }); } return envFiles; } /** * Determine the best target file using precedence rules */ selectTargetEnvFile(envFiles) { const warnings = []; // Rule 1: Prefer .env.local if it exists and is writable const envLocal = envFiles.find((f) => f.filename === ".env.local"); if (envLocal?.exists && envLocal.writable) { return { target: envLocal, warnings }; } // Rule 2: Prefer .env if it exists and is writable const envDefault = envFiles.find((f) => f.filename === ".env"); if (envDefault?.exists && envDefault.writable) { return { target: envDefault, warnings }; } // Rule 3: Create .env.local if possible (safer than .env) if (envLocal?.writable && !envLocal.exists) { return { target: envLocal, warnings }; } // Rule 4: Create .env if .env.local isn't writable if (envDefault?.writable && !envDefault.exists) { warnings.push("Unable to create .env.local, falling back to .env"); return { target: envDefault, warnings }; } // Check for permission issues const writableFiles = envFiles.filter((f) => f.writable); if (writableFiles.length === 0) { warnings.push("No writable .env files found. Check file permissions."); return { target: null, warnings }; } // Fallback to first writable file const fallback = writableFiles[0]; warnings.push(`Using ${fallback.filename} as fallback target`); return { target: fallback, warnings }; } /** * Check for conflicts across multiple .env files */ detectConflicts(envFiles, newVariables) { const conflicts = []; for (const envFile of envFiles) { if (!envFile.exists || !envFile.hasMcpIdentity) continue; for (const [key, newValue] of Object.entries(newVariables)) { const existingValue = envFile.mcpVariables[key]; if (existingValue && existingValue !== newValue) { conflicts.push({ file: envFile.filename, variable: key, value: existingValue, }); } } } return conflicts; } /** * Smart insertion/update of MCP-I variables */ async smartInsertMcpVariables(variables, options = {}) { const envFiles = this.scanEnvFiles(); const { target, warnings } = this.selectTargetEnvFile(envFiles); if (!target) { return { success: false, targetFile: "", operation: "created", warnings: [...warnings, "No suitable target file found"], }; } // Detect conflicts const conflicts = this.detectConflicts(envFiles, variables); if (conflicts.length > 0 && !options.force) { warnings.push(`Found conflicting values in other .env files. Using ${target.filename} as authoritative.`); } try { let operation; if (!target.exists) { await this.createEnvFile(target.path, variables); operation = "created"; } else if (target.hasMcpIdentity) { // Create backup before updating const backupPath = `${target.path}.bak`; writeFileSync(backupPath, readFileSync(target.path, "utf-8"), "utf-8"); await this.updateExistingEnvFile(target.path, variables); operation = "replaced"; // Clean up backup after successful update if (existsSync(backupPath)) { unlinkSync(backupPath); } } else { // Create backup before appending const backupPath = `${target.path}.bak`; writeFileSync(backupPath, readFileSync(target.path, "utf-8"), "utf-8"); await this.appendToEnvFile(target.path, variables); operation = "updated"; // Clean up backup after successful update if (existsSync(backupPath)) { unlinkSync(backupPath); } } return { success: true, targetFile: target.filename, operation, warnings, conflicts: conflicts.length > 0 ? conflicts : undefined, }; } catch (error) { // Restore from backup if available const backupPath = `${target.path}.bak`; if (existsSync(backupPath)) { try { writeFileSync(target.path, readFileSync(backupPath, "utf-8"), "utf-8"); unlinkSync(backupPath); warnings.push("Restored from backup after error"); } catch (restoreError) { warnings.push("Failed to restore from backup"); } } return { success: false, targetFile: target.filename, operation: "created", warnings: [...warnings, `Failed to write file: ${error.message}`], }; } } /** * Parse .env file and extract MCP-I variables */ parseEnvFile(filepath) { if (!existsSync(filepath)) { return {}; } const content = readFileSync(filepath, "utf-8"); const vars = {}; // Parse each line content.split("\n").forEach((line) => { // Skip comments and empty lines const trimmed = line.trim(); if (!trimmed || trimmed.startsWith("#")) return; // Match KEY=VALUE format (with optional quotes) const match = trimmed.match(/^([A-Z_][A-Z0-9_]*)\s*=\s*(.*)$/); if (!match) return; const [, key, value] = match; // Only extract MCP-I variables if (key.startsWith("MCP_IDENTITY_")) { // Remove surrounding quotes if present const cleanValue = value.replace(/^["']|["']$/g, ""); vars[key] = cleanValue; } }); return vars; } /** * Create a new .env file */ async createEnvFile(filepath, variables) { // Create backup if file already exists (shouldn't happen, but safety check) if (existsSync(filepath)) { const backupPath = `${filepath}.bak`; writeFileSync(backupPath, readFileSync(filepath, "utf-8"), "utf-8"); } let content = "# MCP-I Identity Configuration\n"; content += `# Generated by kya-os CLI on ${new Date().toISOString()}\n`; content += "# IMPORTANT: Do not commit this file to version control!\n\n"; Object.entries(variables).forEach(([key, value]) => { // Escape quotes in values const escapedValue = value.replace(/"/g, '\\"'); content += `${key}="${escapedValue}"\n`; }); writeFileSync(filepath, content, "utf-8"); } /** * Update existing .env file by replacing MCP-I variables */ async updateExistingEnvFile(filepath, variables) { const content = readFileSync(filepath, "utf-8"); const lines = content.split("\n"); const updatedLines = []; const processedKeys = new Set(); // Process existing lines for (const line of lines) { const trimmed = line.trim(); // Check if this is an MCP-I variable line const match = trimmed.match(/^(MCP_IDENTITY_[A-Z0-9_]*)\s*=/); if (match) { const key = match[1]; if (key in variables) { // Replace with new value const escapedValue = variables[key].replace(/"/g, '\\"'); updatedLines.push(`${key}="${escapedValue}"`); processedKeys.add(key); } else { // Keep existing line for unknown MCP-I variables updatedLines.push(line); } } else { // Keep non-MCP-I lines as-is updatedLines.push(line); } } // Add any new MCP-I variables that weren't in the file const newKeys = Object.keys(variables).filter((key) => !processedKeys.has(key)); if (newKeys.length > 0) { updatedLines.push(""); // Add blank line updatedLines.push("# MCP-I Identity Configuration (updated)"); for (const key of newKeys) { const escapedValue = variables[key].replace(/"/g, '\\"'); updatedLines.push(`${key}="${escapedValue}"`); } } writeFileSync(filepath, updatedLines.join("\n"), "utf-8"); } /** * Append MCP-I variables to existing .env file */ async appendToEnvFile(filepath, variables) { let content = readFileSync(filepath, "utf-8"); // Ensure file ends with newline if (content && !content.endsWith("\n")) { content += "\n"; } // Add MCP-I section content += "\n# MCP-I Identity Configuration (auto-generated)\n"; content += `# Added by kya-os CLI on ${new Date().toISOString()}\n`; Object.entries(variables).forEach(([key, value]) => { const escapedValue = value.replace(/"/g, '\\"'); content += `${key}="${escapedValue}"\n`; }); writeFileSync(filepath, content, "utf-8"); } /** * Find existing .env files in the project */ findExistingEnvFiles() { const possibleFiles = [ ".env", ".env.local", ".env.development", ".env.dev", ]; return possibleFiles.filter((file) => existsSync(join(this.projectRoot, file))); } /** * Update or create environment file, merging with existing content */ async updateEnvFile(filename, variables, options = {}) { const filepath = join(this.projectRoot, filename); let content = ""; let hasExistingContent = false; // Read existing content if file exists if (existsSync(filepath)) { const existingContent = readFileSync(filepath, "utf-8"); hasExistingContent = true; // Parse existing content, removing old MCP-I variables const lines = existingContent.split("\n"); const filteredLines = lines.filter((line) => { const trimmed = line.trim(); // Keep non-MCP_IDENTITY lines and comments return !trimmed.startsWith("MCP_IDENTITY_") || trimmed.startsWith("#"); }); content = filteredLines.join("\n"); // Ensure there's a newline at the end if content exists if (content.trim() && !content.endsWith("\n")) { content += "\n"; } } // Add MCP-I section if (hasExistingContent) { content += "\n# MCP-I Identity Configuration (updated by kya-os CLI)\n"; } else { content += "# MCP-I Identity Configuration\n"; content += `# Generated by kya-os CLI on ${new Date().toISOString()}\n`; } if (options.example) { content += "# Copy these values to your actual .env file\n"; content += "# DO NOT commit this file with real values\n\n"; // Use actual values for example file (user specifically asked for this) Object.entries(variables).forEach(([key, value]) => { content += `${key}="${value}"\n`; }); } else { content += "# IMPORTANT: Do not commit this file to version control!\n\n"; // Use real values Object.entries(variables).forEach(([key, value]) => { content += `${key}="${value}"\n`; }); } // Write file writeFileSync(filepath, content, "utf-8"); } /** * Read environment variables from file */ readEnvFile(filename) { const filepath = join(this.projectRoot, filename); if (!existsSync(filepath)) { return {}; } const content = readFileSync(filepath, "utf-8"); const vars = {}; // Parse env file content.split("\n").forEach((line) => { // Skip comments and empty lines if (line.startsWith("#") || !line.trim()) return; const [key, ...valueParts] = line.split("="); const value = valueParts.join("=").replace(/^["']|["']$/g, ""); if (key && value && key.startsWith("MCP_IDENTITY_")) { vars[key] = value; } }); return vars; } /** * Check if .gitignore includes env files */ checkGitignore(envFiles) { const gitignorePath = join(this.projectRoot, ".gitignore"); const missing = []; const ignored = []; if (!existsSync(gitignorePath)) { return { missing: envFiles, ignored: [] }; } const gitignoreContent = readFileSync(gitignorePath, "utf-8"); const patterns = gitignoreContent .split("\n") .filter((line) => line.trim() && !line.startsWith("#")); envFiles.forEach((file) => { const isIgnored = patterns.some((pattern) => { // Simple pattern matching (could be enhanced) return (pattern === file || pattern === `/${file}` || (pattern.endsWith("*") && file.startsWith(pattern.slice(0, -1)))); }); if (isIgnored) { ignored.push(file); } else { missing.push(file); } }); return { missing, ignored }; } /** * Add files to .gitignore */ addToGitignore(files) { const gitignorePath = join(this.projectRoot, ".gitignore"); // Read existing content or create new let content = existsSync(gitignorePath) ? readFileSync(gitignorePath, "utf-8") : ""; // Add newline if file doesn't end with one if (content && !content.endsWith("\n")) { content += "\n"; } // Add section for MCP-I content += "\n# MCP-I Identity Files\n"; files.forEach((file) => { content += `${file}\n`; }); writeFileSync(gitignorePath, content, "utf-8"); } /** * Get environment variables from process.env */ getFromProcess() { const vars = {}; Object.keys(process.env).forEach((key) => { if (key.startsWith("MCP_IDENTITY_")) { vars[key] = process.env[key]; } }); return vars; } /** * Format environment variables for display */ formatForDisplay(vars, hideSecrets = true) { let output = ""; Object.entries(vars).forEach(([key, value]) => { if (!value) return; if (hideSecrets && key.includes("PRIVATE_KEY")) { // Show only first/last few characters of private key const masked = value.substring(0, 8) + "..." + value.substring(value.length - 8); output += `${key}="${masked}"\n`; } else { output += `${key}="${value}"\n`; } }); return output; } /** * Format for clipboard (no masking) */ formatForClipboard(vars) { let output = ""; Object.entries(vars).forEach(([key, value]) => { if (value) { output += `${key}="${value}"\n`; } }); return output; } /** * Check if all required variables are present */ validateVariables(vars) { const required = [ "MCP_IDENTITY_AGENT_DID", "MCP_IDENTITY_AGENT_PUBLIC_KEY", "MCP_IDENTITY_AGENT_PUBLIC_KEY", "MCP_IDENTITY_AGENT_ID", "MCP_IDENTITY_AGENT_SLUG", ]; const missing = required.filter((key) => !vars[key]); return { valid: missing.length === 0, missing, }; } } //# sourceMappingURL=env-manager.js.map