@kya-os/cli
Version:
CLI for KYA-OS MCP-I setup and management
498 lines • 18.7 kB
JavaScript
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