UNPKG

@blazeinstall/envm

Version:

Advanced Env file Manager CLI tool - Multi-environment management, encryption, and security

1,518 lines (1,307 loc) โ€ข 71.8 kB
#!/usr/bin/env node const { Command } = require('commander'); const { config: loadDotenv, parse } = require('dotenv'); const fs = require('fs'); const path = require('path'); const yaml = require('js-yaml'); const zlib = require('zlib'); const crypto = require('crypto'); const { execSync } = require('child_process'); const program = new Command(); /** * Validates environment configuration against a reference schema * @param {Object} options - CLI options */ function validateEnvironment(options) { try { const envDir = options.path || process.cwd(); // Determine schema file path const schemaFile = options.schema ? path.resolve(envDir, options.schema) : path.join(envDir, '.env.example'); // Determine environment file path const envFile = options.env ? path.resolve(envDir, options.env) : path.join(envDir, '.env'); // Initialize result object let validationResult = { success: true, errors: [], warnings: [], missing: [], extra: [], typeMismatches: [] }; // Load and parse schema file let schemaEnv, envVariables; try { if (fs.existsSync(schemaFile)) { const schemaContent = fs.readFileSync(schemaFile, 'utf-8'); schemaEnv = parse(schemaContent); if (options.verbose) { console.log(`โœ… Loaded schema file: ${path.relative(envDir, schemaFile)}`); } } else { validationResult.errors.push(`Schema file not found: ${path.relative(envDir, schemaFile)}`); validationResult.success = false; reportResults(validationResult, options); return; } } catch (error) { validationResult.errors.push(`Error reading schema file ${path.relative(envDir, schemaFile)}: ${error.message}`); validationResult.success = false; reportResults(validationResult, options); return; } // Load and parse environment file try { if (fs.existsSync(envFile)) { const envContent = fs.readFileSync(envFile, 'utf-8'); envVariables = parse(envContent); if (options.verbose) { console.log(`โœ… Loaded environment file: ${path.relative(envDir, envFile)}`); } } else { validationResult.warnings.push(`Environment file not found: ${path.relative(envDir, envFile)}`); // Add all schema variables as missing validationResult.missing = Object.keys(schemaEnv); reportResults(validationResult, options); return; } } catch (error) { validationResult.errors.push(`Error reading environment file ${path.relative(envDir, envFile)}: ${error.message}`); validationResult.success = false; reportResults(validationResult, options); return; } // Compare variables const schemaKeys = Object.keys(schemaEnv); const envKeys = Object.keys(envVariables); // Find missing variables (in schema but not in env) validationResult.missing = schemaKeys.filter(key => !(key in envVariables)); // Find extra variables (in env but not in schema) validationResult.extra = envKeys.filter(key => !(key in schemaEnv)); // Check for type mismatches Object.keys(schemaEnv).forEach(key => { if (key in envVariables) { const expectedType = detectType(schemaEnv[key]); const actualType = detectType(envVariables[key]); if (expectedType !== actualType) { validationResult.typeMismatches.push({ variable: key, expected: expectedType, actual: actualType, schemaValue: schemaEnv[key], envValue: envVariables[key] }); } } }); // Set success status based on findings validationResult.success = validationResult.missing.length === 0 && validationResult.extra.length === 0 && validationResult.typeMismatches.length === 0 && validationResult.errors.length === 0; reportResults(validationResult, options); } catch (error) { console.error('โŒ Unexpected error during validation:', error.message); if (!options.noExit) { process.exit(1); } } } /** * Detects the type of a dotenv value */ function detectType(value) { const trimVal = value.trim(); // Check for boolean patterns if (/^(true|false)$/i.test(trimVal)) { return 'boolean'; } // Check for number patterns if (/^\d+$/.test(trimVal)) { return 'integer'; } if (/^\d*\.\d+$/.test(trimVal)) { return 'float'; } // Check for array patterns (basic detection) if (trimVal.startsWith('[') && trimVal.endsWith(']')) { return 'array'; } // Check for object patterns (basic detection) if (trimVal.startsWith('{') && trimVal.endsWith('}')) { return 'object'; } // Default to string return 'string'; } /** * Reports validation results */ function reportResults(result, options) { const { success, errors, warnings, missing, extra, typeMismatches } = result; // Show verbose header when verbose is enabled if (options.verbose) { console.log('\n๐Ÿ” Environment Validation Report'); console.log('===================================\n'); } // Report errors if (errors.length > 0) { console.log('โŒ Errors:'); errors.forEach(error => console.log(` โ€ข ${error}`)); console.log(''); } // Report warnings if (warnings.length > 0) { console.log('โš ๏ธ Warnings:'); warnings.forEach(warning => console.log(` โ€ข ${warning}`)); console.log(''); } // Report missing variables if (missing.length > 0) { console.log('๐Ÿ“ญ Missing Variables:'); missing.forEach(variable => { console.log(` โ€ข ${variable}`); if (options.verbose) { // Show schema value if available and verbose const schemaPath = options.schema ? path.resolve(options.path || process.cwd(), options.schema) : path.join(options.path || process.cwd(), '.env.example'); try { if (fs.existsSync(schemaPath)) { const schemaContent = fs.readFileSync(schemaPath, 'utf-8'); const schemaEnv = parse(schemaContent); if (variable in schemaEnv) { console.log(` Schema: "${schemaEnv[variable]}"`); } } } catch (error) { // Ignore schema read errors } } // Try to provide suggestion based on current env file or patterns const envFile = path.join(options.path || process.cwd(), options.env || '.env'); const suggestion = getSuggestionForMissing(envFile, variable); if (suggestion) { console.log(` ๐Ÿ’ก Suggestion: ${variable}=${suggestion}`); } }); console.log(''); } // Report extra variables if (extra.length > 0) { console.log('๐Ÿ“ฆ Extra Variables:'); extra.forEach(variable => console.log(` โ€ข ${variable}`)); console.log(''); } // Report type mismatches if (typeMismatches.length > 0) { console.log('๐Ÿ”„ Type Mismatches:'); typeMismatches.forEach(mismatch => { console.log(` โ€ข ${mismatch.variable}: Expected ${mismatch.expected}, got ${mismatch.actual}`); if (options.verbose) { console.log(` Schema: "${mismatch.schemaValue}"`); console.log(` Environment: "${mismatch.envValue}"`); const suggestion = getTypeSuggestion(mismatch.expected, mismatch.schemaValue); if (suggestion) { console.log(` ๐Ÿ’ก Suggested value: ${suggestion}`); } } }); console.log(''); } // Summary const issueCount = errors.length + missing.length + extra.length + typeMismatches.length; if (success) { console.log('โœ… Validation successful!'); if (options.verbose) { const totalVariables = [...new Set([...missing, ...extra, ...Object.keys(result)])].length; console.log(` All ${totalVariables} variables are correctly configured.`); } } else { if (errors.length > 0 || (options.strict && issueCount > 0)) { console.log('โŒ Validation failed!'); if (!options.noExit) { process.exit(1); } } else { console.log('โš ๏ธ Validation completed with warnings.'); if (issueCount > 1) { console.log(` Found ${issueCount} issues that should be addressed.`); } } } } /** * Generates suggestion for a missing variable */ function getSuggestionForMissing(envFile, variable) { try { // Try to find similar variables in the environment file first if (fs.existsSync(envFile)) { const envContent = fs.readFileSync(envFile, 'utf-8'); const envData = parse(envContent); // Look for similar variables that might provide hints const similarVars = Object.keys(envData).filter(k => k.toLowerCase().includes(variable.toLowerCase().split('_').slice(-1)[0]) || k.toLowerCase().replace(/_/g, '').includes(variable.toLowerCase().replace(/_/g, '')) ); if (similarVars.length > 0) { return envData[similarVars[0]]; // Use the first similar variable's value } } // Common defaults based on variable name patterns const name = variable.toLowerCase(); if (name.includes('port')) return '3000'; if (name.includes('url') || name.includes('endpoint')) return 'http://localhost:3000'; if (name.includes('host') || name.includes('server')) return 'localhost'; if (name.includes('secret') || name.includes('key') || name.includes('token')) { // For JWT secrets, use a shorter pattern; for keys use changeme return name.includes('jwt') ? 'your_jwt_secret_here' : 'changeme'; } if (name.includes('enable') || name.includes('flag') || name.includes('logs')) return 'false'; if (name.includes('timeout')) return '5000'; if (name.includes('user') || name.includes('username')) return 'your_username'; if (name.includes('password') || name.includes('pass')) return 'your_password'; if (name.includes('database') || name.includes('db_')) return 'your_db_name'; if (name.includes('email')) return 'your_email@example.com'; if (name.includes('limit')) return '100'; if (name.includes('rate')) return '60'; if (name.includes('max') || name.includes('size')) return '1000'; } catch (error) { // Ignore errors in suggestion generation } return null; } /** * Generates type correction suggestion */ function getTypeSuggestion(expectedType, schemaValue) { switch (expectedType) { case 'boolean': if (/true|false/i.test(schemaValue.toLowerCase())) { return schemaValue.toLowerCase(); } return 'true'; case 'integer': return '0'; case 'string': return '""'; default: return null; } } /** * Custom .env parser that preserves comments and structure * @param {string} content - The .env file content * @returns {Object} Parsed environment variables and comments */ function parseEnvFile(content) { const lines = content.split('\n'); const envVars = {}; const comments = []; for (let i = 0; i < lines.length; i++) { const line = lines[i].trimLeft(); // Keep indentation for comments if (line.startsWith('#') || line.trim() === '') { // Preserve comments and empty lines comments.push({ line: i + 1, type: line.startsWith('#') ? 'comment' : 'empty', content: line }); continue; } // Parse key=value pairs const match = line.match(/^([^=]+)=(.*)$/); if (match) { const key = match[1].trim(); const value = match[2] || ''; // Remove quotes if present const unquotedValue = value.replace(/^["'](.*)["']$/, '$1'); envVars[key] = unquotedValue; } } return { envVars, comments }; } /** * Checks if .env files are tracked in Git and warns accordingly * @param {string} projectDir - Project directory path * @returns {Object} Git ignore validation result */ function checkGitIgnoreStatus(projectDir) { const result = { isGitRepo: false, trackedEnvFiles: [], gitignoreExists: false, gitignorePatterns: [], warnings: [], recommendations: [] }; try { // Check if it's a git repository result.isGitRepo = fs.existsSync(path.join(projectDir, '.git')); if (!result.isGitRepo) { result.warnings.push('Not a Git repository - Git ignore guard skipped'); return result; } // Find all .env related files const envPatterns = [ '.env', '.env.local', '.env.development', '.env.staging', '.env.production', '.env.test', '.env.*', '*.encrypted' ]; // Execute git ls-files to check tracked files const { spawn } = require('child_process'); // Check each env pattern for (const pattern of envPatterns) { try { const gitCheck = spawn('git', ['ls-files', pattern], { cwd: projectDir, stdio: ['pipe', 'pipe', 'pipe'] }); let stdout = ''; let stderr = ''; gitCheck.stdout.on('data', (data) => { stdout += data.toString(); }); gitCheck.stderr.on('data', (data) => { stderr += data.toString(); }); gitCheck.on('close', (code) => { if (code === 0 && stdout.trim()) { const files = stdout.trim().split('\n').filter(f => f); result.trackedEnvFiles.push(...files); } }); // Wait for command to complete synchronously const exitCode = gitCheck.exitCode || 0; if (exitCode === 0 && stdout.trim()) { const files = stdout.trim().split('\n').filter(f => f); result.trackedEnvFiles.push(...files); } } catch (gitError) { // Git not available or pattern doesn't match files continue; } } // Check for .gitignore const gitignorePath = path.join(projectDir, '.gitignore'); result.gitignoreExists = fs.existsSync(gitignorePath); if (result.gitignoreExists) { const gitignoreContent = fs.readFileSync(gitignorePath, 'utf-8'); const gitignoreLines = gitignoreContent.split('\n'); // Check for env patterns in gitignore result.gitignorePatterns = gitignoreLines.filter(line => { const cleanLine = line.trim(); return !line.startsWith('#') && (cleanLine.includes('.env') || cleanLine.includes('*.encrypted') || cleanLine === 'encrypted'); }); } // Generate warnings and recommendations if (result.trackedEnvFiles.length > 0) { result.warnings.push(`Found ${result.trackedEnvFiles.length} .env file(s) tracked in Git`); result.warnings.push(...result.trackedEnvFiles.map(file => ` โ€ข ${file}`)); result.recommendations.push('Add .env files to .gitignore to prevent credential leaks'); result.recommendations.push('Use: envm gitignore add'); } if (!result.gitignoreExists) { result.warnings.push('No .gitignore file found - create one for security'); result.recommendations.push('Create .gitignore file with: envm gitignore init'); } // Check if .env patterns are in gitignore const hasEnvPatterns = result.gitignorePatterns.length > 0; if (result.trackedEnvFiles.length > 0 && !hasEnvPatterns) { result.warnings.push('No .env patterns found in .gitignore'); } return result; } catch (error) { result.warnings.push(`Git ignore guard check failed: ${error.message}`); return result; } } /** * Manages .gitignore for environment files * @param {Object} options - Command options */ function manageGitIgnore(options) { const projectDir = options.path || process.cwd(); const action = options.action || 'check'; try { console.log('๐Ÿ” Git Ignore Guard - Managing .env file security\n'); const gitignorePath = path.join(projectDir, '.gitignore'); switch (action) { case 'check': case 'status': const status = checkGitIgnoreStatus(projectDir); if (status.warnings.length > 0) { console.log('โš ๏ธ Warnings:'); status.warnings.forEach(warning => console.log(` โ€ข ${warning}`)); console.log(''); } if (status.recommendations.length > 0) { console.log('๐Ÿ’ก Recommendations:'); status.recommendations.forEach(rec => console.log(` โ€ข ${rec}`)); console.log(''); } // Summary console.log('๐Ÿ“Š Summary:'); console.log(` โ€ข Git repository: ${status.isGitRepo ? 'โœ… Yes' : 'โŒ No'}`); console.log(` โ€ข .gitignore file: ${status.gitignoreExists ? 'โœ… Exists' : 'โŒ Missing'}`); console.log(` โ€ข Tracked env files: ${status.trackedEnvFiles.length}`); console.log(` โ€ข Env patterns in gitignore: ${status.gitignorePatterns.length}`); if (status.trackedEnvFiles.length === 0 && status.gitignoreExists) { console.log('\nโœ… Good job! No .env files are tracked in Git'); } break; case 'init': // Create .gitignore if it doesn't exist let gitignoreContent = ''; if (fs.existsSync(gitignorePath)) { console.log('โš ๏ธ .gitignore already exists'); console.log('Use: envm gitignore add'); return; } gitignoreContent = `# Environment files .env .env.* *.encrypted # Node.js node_modules/ npm-debug.log* yarn-debug.log* yarn-error.log* # OS generated files .DS_Store .DS_Store? ._* .Spotlight-V100 .Trashes ehthumbs.db Thumbs.db # IDE files .vscode/ .idea/ *.swp *.swo *~ `; fs.writeFileSync(gitignorePath, gitignoreContent, 'utf-8'); console.log('โœ… Created .gitignore file with env patterns'); console.log('๐Ÿ”’ Your .env files are now protected from Git tracking'); break; case 'add': // Add env patterns to existing .gitignore let existingContent = ''; if (fs.existsSync(gitignorePath)) { existingContent = fs.readFileSync(gitignorePath, 'utf-8'); } else { console.log('โŒ .gitignore file not found'); console.log('Use: envm gitignore init'); return; } const lines = existingContent.split('\n'); const envPatterns = [ '# Environment files', '.env', '.env.*', '*.encrypted' ]; let hasEnvSection = false; envPatterns.forEach(pattern => { if (!existingContent.includes(pattern)) { if (pattern === '# Environment files' && lines.length > 0) { lines.splice(0, 0, ''); // Add blank line lines.splice(0, 0, pattern); } else if (pattern.startsWith('#')) { lines.splice(0, 0, pattern); } else { lines.push(pattern); } } }); fs.writeFileSync(gitignorePath, lines.join('\n'), 'utf-8'); console.log('โœ… Added .env patterns to .gitignore'); console.log('๐Ÿ”’ Environment files are now excluded from Git'); break; case 'clean': // Check if files should be removed from git tracking const cleanStatus = checkGitIgnoreStatus(projectDir); if (cleanStatus.trackedEnvFiles.length === 0) { console.log('โœ… No .env files currently tracked in Git'); return; } console.log('๐Ÿงน Found files that could be removed from Git tracking:'); cleanStatus.trackedEnvFiles.forEach(file => { console.log(` โ€ข ${file}`); }); console.log('\nTo remove these files from Git (but keep them locally):'); console.log(`git rm --cached ${cleanStatus.trackedEnvFiles.join(' ')}`); console.log('\nโš ๏ธ WARNING: This will remove the files from Git history'); console.log(' Make sure you have backups before proceeding'); if (options.force) { console.log('\n๐Ÿงน Removing files from Git tracking...'); const { spawn } = require('child_process'); const gitRm = spawn('git', ['rm', '--cached', ...cleanStatus.trackedEnvFiles], { cwd: projectDir, stdio: 'inherit' }); gitRm.on('close', (code) => { if (code === 0) { console.log('โœ… Files removed from Git tracking'); console.log('๐Ÿ”’ Your credentials are now safe'); } else { console.error('โŒ Failed to remove files from Git tracking'); } }); } break; default: console.error(`โŒ Unknown action: ${action}`); console.error('Available actions: check, status, init, add, clean'); } } catch (error) { console.error(`โŒ Git ignore guard error: ${error.message}`); process.exit(1); } } /** * Exports environment variables to JSON format * @param {Object} envVars - Environment variables object * @param {string} outputFile - Output file path or null for stdout */ function exportToJSON(envVars, outputFile) { const content = JSON.stringify(envVars, null, 2) + '\n'; if (outputFile) { fs.writeFileSync(outputFile, content, 'utf-8'); } else { console.log(content); } } /** * Exports environment variables to YAML format with preserved comments * @param {Object} envVars - Environment variables object * @param {Array} comments - Array of comment objects * @param {string} outputFile - Output file path or null for stdout */ function exportToYAML(envVars, comments, outputFile) { let content = '# Exported environment variables\n'; content += '# Generated by envm export command\n'; content += '\n'; content += yaml.dump(envVars, { lineWidth: -1 }); if (outputFile) { fs.writeFileSync(outputFile, content, 'utf-8'); } else { console.log(content); } } /** * Derives encryption key from password using PBKDF2 * @param {string} password - User password * @param {Buffer} salt - Salt for key derivation * @returns {Buffer} Derived key */ function deriveKey(password, salt) { return crypto.scryptSync(password, salt, 32); // 32 bytes = 256 bits for AES-256 } /** * Encrypts data using AES-256-GCM * @param {Buffer|string} data - Data to encrypt * @param {string} password - Encryption password * @returns {Object} Encrypted data with metadata */ function encryptData(data, password) { const salt = crypto.randomBytes(32); // Generate random salt const iv = crypto.randomBytes(16); // 16 bytes IV for GCM (recommended) const key = deriveKey(password, salt); const cipher = crypto.createCipheriv('aes-256-gcm', key, iv); let encrypted; if (Buffer.isBuffer(data)) { encrypted = Buffer.concat([cipher.update(data), cipher.final()]); } else { encrypted = Buffer.concat([cipher.update(data, 'utf8'), cipher.final()]); } const authTag = cipher.getAuthTag(); return { encrypted, authTag, iv, salt, algorithm: 'aes-256-gcm' }; } /** * Decrypts data using AES-256-GCM * @param {Buffer} encryptedData - Encrypted data * @param {Buffer} authTag - Authentication tag * @param {Buffer} iv - Initialization vector * @param {Buffer} salt - Salt used for key derivation * @param {string} password - Decryption password * @returns {string} Decrypted data */ function decryptData(encryptedData, authTag, iv, salt, password) { const key = deriveKey(password, salt); const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv); decipher.setAuthTag(authTag); let decrypted; try { decrypted = Buffer.concat([decipher.update(encryptedData), decipher.final()]); return decrypted.toString('utf8'); } catch (error) { throw new Error('Decryption failed. Invalid password or corrupted data.'); } } /** * Creates a structured encrypted file with metadata * @param {Object} encryptionResult - Result from encryptData() * @returns {Buffer} Encrypted file content with metadata */ function createEncryptedFile(encryptionResult) { const metadata = { version: '1.0', algorithm: encryptionResult.algorithm, encryptedAt: new Date().toISOString() }; const metadataJson = JSON.stringify(metadata); const metadataLength = Buffer.alloc(4); metadataLength.writeUInt32BE(Buffer.byteLength(metadataJson), 0); // File format: // 4 bytes: metadata length // N bytes: metadata JSON // 4 bytes: salt length // 32 bytes: salt // 4 bytes: IV length // 16 bytes: IV // 4 bytes: auth tag length // 16 bytes: auth tag // Remaining: encrypted data const saltLength = Buffer.alloc(4); saltLength.writeUInt32BE(encryptionResult.salt.length, 0); const ivLength = Buffer.alloc(4); ivLength.writeUInt32BE(encryptionResult.iv.length, 0); const authTagLength = Buffer.alloc(4); authTagLength.writeUInt32BE(encryptionResult.authTag.length, 0); return Buffer.concat([ metadataLength, Buffer.from(metadataJson), saltLength, encryptionResult.salt, ivLength, encryptionResult.iv, authTagLength, encryptionResult.authTag, encryptionResult.encrypted ]); } /** * Parses an encrypted file and extracts components * @param {Buffer} fileContent - Content of encrypted file * @returns {Object} Parsed encrypted file components */ function parseEncryptedFile(fileContent) { let offset = 0; // Read metadata length (4 bytes) const metadataLength = fileContent.readUInt32BE(offset); offset += 4; // Read metadata JSON const metadataJson = fileContent.subarray(offset, offset + metadataLength).toString(); const metadata = JSON.parse(metadataJson); offset += metadataLength; // Read salt length (4 bytes) const saltLength = fileContent.readUInt32BE(offset); offset += 4; // Read salt const salt = fileContent.subarray(offset, offset + saltLength); offset += saltLength; // Read IV length (4 bytes) const ivLength = fileContent.readUInt32BE(offset); offset += 4; // Read IV const iv = fileContent.subarray(offset, offset + ivLength); offset += ivLength; // Read auth tag length (4 bytes) const authTagLength = fileContent.readUInt32BE(offset); offset += 4; // Read auth tag const authTag = fileContent.subarray(offset, offset + authTagLength); offset += authTagLength; // Read encrypted data const encrypted = fileContent.subarray(offset); return { metadata, salt, iv, authTag, encrypted }; } /** * Encrypts an environment file * @param {Object} options - Encryption options */ function encryptEnvironment(options) { const envDir = options.path || process.cwd(); const inputFile = options.env.replace(/\.encrypted$/, ''); // Remove .encrypted if present const inputPath = path.resolve(envDir, inputFile); // Determine output file let outputFile = inputFile; if (!options.output) { // Auto-generate .encrypted file name if (inputFile === '.env') { outputFile = '.env.encrypted'; } else if (inputFile.startsWith('.env.')) { outputFile = `${inputFile}.encrypted`; } else { outputFile = `${inputFile}.encrypted`; } } else { outputFile = options.output; } const outputPath = path.resolve(envDir, outputFile); try { // Validate input file if (!fs.existsSync(inputPath)) { throw new Error(`Input file not found: ${path.relative(process.cwd(), inputPath)}`); } // Create backup before encryption (unless disabled) if (!options.noBackup) { console.log('๐Ÿ“ฆ Creating backup before encryption...'); const backupResult = createBackup(envDir, null, false); // Auto-named backup if (backupResult.success) { console.log(`โœ… Backup created: ${backupResult.backupName}`); } else { console.error(`โŒ Error creating backup: ${backupResult.error}`); if (!options.force) { throw new Error('Backup creation failed. Use --no-backup to skip or --force to continue.'); } } } // Read and parse input file const fileContent = fs.readFileSync(inputPath, 'utf-8'); const { envVars, comments } = parseEnvFile(fileContent); // Handle variable-specific encryption let dataToEncrypt = fileContent; if (options.variable) { if (!envVars[options.variable]) { throw new Error(`Variable '${options.variable}' not found in ${inputFile}`); } // Encrypt only the specified variable const encryptedValue = encryptData(envVars[options.variable], options.key); // Rebuild file content with encrypted variable const lines = fileContent.split('\n'); let outputLines = []; for (const line of lines) { if (line.includes(`${options.variable}=`)) { const prefix = line.split('=')[0]; const encryptedFile = createEncryptedFile(encryptedValue); const base64Encrypted = encryptedFile.toString('base64'); outputLines.push(`${prefix}=ENVM_ENCRYPTED:${base64Encrypted}`); } else if (line.startsWith('#') || line.trim() === '') { outputLines.push(line); } else { // Encrypt other variables too if --variable flag is used const match = line.match(/^([^=]+)=(.*)$/); if (match) { const varName = match[1].trim(); if (varName !== options.variable) { const encryptedVar = encryptData(match[2], options.key); const encryptedVarFile = createEncryptedFile(encryptedVar); const base64EncryptedVar = encryptedVarFile.toString('base64'); outputLines.push(`${varName}=ENVM_ENCRYPTED:${base64EncryptedVar}`); } } } } dataToEncrypt = outputLines.join('\n'); } // Get password (from options or prompt) let password = options.key; if (!password) { // In a real CLI, you'd use readline or a secure password prompt // For now, we'll use environment variable or require explicit key password = process.env.ENVM_ENCRYPTION_KEY; if (!password) { console.error('โŒ Password required. Use --key option or set ENVM_ENCRYPTION_KEY environment variable.'); throw new Error('Encryption password required'); } } // Encrypt the data console.log('๐Ÿ” Encrypting environment file...'); let finalDataToEncrypt = dataToEncrypt; if (!options.variable) { // Full file encryption finalDataToEncrypt = fileContent; } const encryptionResult = encryptData(finalDataToEncrypt, password); const encryptedFile = createEncryptedFile(encryptionResult); // Validate output file doesn't exist (unless forced) if (fs.existsSync(outputPath) && !options.force) { const relativeOutputPath = path.relative(process.cwd(), outputPath); console.error(`โŒ Output file already exists: ${relativeOutputPath}`); console.error('Use --force to overwrite existing file.'); throw new Error('Output file already exists'); } // Write encrypted file fs.writeFileSync(outputPath, encryptedFile); console.log('โœ… Environment file encrypted successfully!'); console.log(` Input: ${path.relative(process.cwd(), inputPath)}`); console.log(` Output: ${path.relative(process.cwd(), outputPath)}`); console.log(` Algorithm: AES-256-GCM`); // Display security warning console.log('\nโš ๏ธ Security Warning:'); console.log(' - Store your encryption password securely'); console.log(' - Never commit encrypted files to version control'); console.log(' - Use strong passwords with mixed characters, numbers, and symbols'); } catch (error) { console.error(`โŒ Encryption failed: ${error.message}`); process.exit(1); } } /** * Decrypts an environment file * @param {Object} options - Decryption options */ function decryptEnvironment(options) { const envDir = options.path || process.cwd(); const inputFile = options.env; // Validate input is encrypted file if (!inputFile.includes('.encrypted')) { console.error('โŒ Input file must be an encrypted .encrypted file'); throw new Error('Invalid input file format'); } const inputPath = path.resolve(envDir, inputFile); // Determine output file (remove .encrypted extension) let outputFile = inputFile; if (inputFile.endsWith('.encrypted')) { if (inputFile === '.env.encrypted') { outputFile = '.env'; } else if (inputFile.startsWith('.env.') && inputFile.endsWith('.encrypted')) { outputFile = inputFile.slice(0, -10); // Remove '.encrypted' } else { outputFile = inputFile.slice(0, -10); // Remove '.encrypted' } } if (options.output) { outputFile = options.output; } const outputPath = path.resolve(envDir, outputFile); try { // Validate input file if (!fs.existsSync(inputPath)) { throw new Error(`Input file not found: ${path.relative(process.cwd(), inputPath)}`); } // Create backup of current file if it exists and --backup-current is used if (options.backupCurrent && fs.existsSync(outputPath)) { console.log('๐Ÿ“ฆ Creating backup of current state...'); const backupResult = createBackup(envDir, `pre_decrypt_${Date.now()}`, false); if (backupResult.success) { console.log(`โœ… Backup created: ${backupResult.backupName}`); } else { console.error(`โŒ Error creating backup: ${backupResult.error}`); process.exit(1); } } // Read encrypted file console.log('๐Ÿ”“ Reading encrypted file...'); const encryptedFileContent = fs.readFileSync(inputPath); // Parse encrypted file let parsedFile; try { parsedFile = parseEncryptedFile(encryptedFileContent); } catch (error) { throw new Error('Invalid encrypted file format'); } // Get password let password = options.key; if (!password) { password = process.env.ENVM_ENCRYPTION_KEY; if (!password) { console.error('โŒ Password required. Use --key option or set ENVM_ENCRYPTION_KEY environment variable.'); throw new Error('Decryption password required'); } } // Decrypt the data console.log('๐Ÿ”“ Decrypting environment file...'); let decryptedContent; try { decryptedContent = decryptData( parsedFile.encrypted, parsedFile.authTag, parsedFile.iv, parsedFile.salt, password ); } catch (error) { throw new Error(error.message); } // Handle variable-specific decryption let finalContent = decryptedContent; if (options.variable) { const lines = decryptedContent.split('\n'); let outputLines = []; for (const line of lines) { const match = line.match(/^([^=]+)=ENVM_ENCRYPTED:(.*)$/); if (match) { const varName = match[1].trim(); const encryptedVarBase64 = match[2]; if (varName === options.variable) { // Decrypt this specific variable try { const encryptedVarBuffer = Buffer.from(encryptedVarBase64, 'base64'); const varParsed = parseEncryptedFile(encryptedVarBuffer); const decryptedVar = decryptData( varParsed.encrypted, varParsed.authTag, varParsed.iv, varParsed.salt, password ); outputLines.push(`${varName}=${decryptedVar}`); } catch (varError) { console.error(`โš ๏ธ Failed to decrypt variable '${varName}': ${varError.message}`); outputLines.push(line); // Keep encrypted version } } else { // Keep other variables encrypted unless --variable specifies otherwise outputLines.push(line); } } else { outputLines.push(line); } } finalContent = outputLines.join('\n'); } // Validate output file doesn't exist (unless forced) if (fs.existsSync(outputPath) && !options.force) { const relativeOutputPath = path.relative(process.cwd(), outputPath); console.error(`โŒ Output file already exists: ${relativeOutputPath}`); console.error('Use --force to overwrite existing file.'); throw new Error('Output file already exists'); } // Write decrypted file fs.writeFileSync(outputPath, finalContent); console.log('โœ… Environment file decrypted successfully!'); console.log(` Input: ${path.relative(process.cwd(), inputPath)}`); console.log(` Output: ${path.relative(process.cwd(), outputPath)}`); console.log(` Algorithm: ${parsedFile.metadata.algorithm}`); console.log(` Encrypted: ${parsedFile.metadata.encryptedAt}`); // Validate file integrity if possible console.log('\n๐Ÿ” File structure preserved:'); try { const { envVars } = parseEnvFile(finalContent); console.log(` Variables found: ${Object.keys(envVars).length}`); if (options.variable) { if (envVars[options.variable]) { console.log(` โœ… Variable '${options.variable}' successfully decrypted`); } else { console.log(` โš ๏ธ Variable '${options.variable}' not found in decrypted content`); } } } catch (error) { console.log(' โš ๏ธ Could not parse decrypted content (may contain encrypted variables)'); } } catch (error) { console.error(`โŒ Decryption failed: ${error.message}`); process.exit(1); } } /** * Main export function * @param {Object} options - Command options */ function exportEnvironment(options) { try { const envDir = options.path || process.cwd(); const envFile = path.resolve(envDir, options.env || '.env'); // Validate format if (!['json', 'yaml'].includes(options.format.toLowerCase())) { console.error(`โŒ Error: Unsupported format '${options.format}'. Supported formats: json, yaml`); process.exit(1); } // Check if input file exists if (!fs.existsSync(envFile)) { console.error(`โŒ Error: Environment file not found: ${path.relative(process.cwd(), envFile)}`); process.exit(1); } // Read and parse the .env file const content = fs.readFileSync(envFile, 'utf-8'); const { envVars, comments } = parseEnvFile(content); // Get output file or default to stdout let outputFile = null; if (options.outputFile) { outputFile = path.resolve(envDir, options.outputFile); // Check if output file directory exists const outputDir = path.dirname(outputFile); if (!fs.existsSync(outputDir)) { console.error(`โŒ Error: Output directory not found: ${path.relative(process.cwd(), outputDir)}`); process.exit(1); } } // Perform export based on format const format = options.format.toLowerCase(); if (format === 'json') { exportToJSON(envVars, outputFile); } else if (format === 'yaml') { exportToYAML(envVars, comments, outputFile); } // Note: Encryption is now handled by the separate 'encrypt' command // Success message if (outputFile) { console.log(`โœ… Successfully exported to ${path.relative(process.cwd(), outputFile)} (format: ${format})`); } else { console.log(`โœ… Successfully exported to stdout (format: ${format})`); } } catch (error) { console.error(`โŒ Error during export: ${error.message}`); process.exit(1); } } program .name('envm') .description('Env File Manager CLI tool for managing .env files') .version('1.0.0'); // Switch command - Switch between different environment configurations program .command('switch <config>') .description('Switch to a different environment configuration') .option('-f, --force', 'Force switch and overwrite .env without confirmation') .option('-p, --path <path>', 'Path where .env files are located (default: current directory)') .option('-b, --backup', 'Create timestamped backup of current .env before switching') .action((config, options) => { const envDir = options.path || process.cwd(); const sourceFile = path.join(envDir, `.env.${config}`); const targetFile = path.join(envDir, '.env'); try { // Check if source file exists if (!fs.existsSync(sourceFile)) { console.error(`Error: .env.${config} file not found in ${envDir}`); console.error('Available environment files:'); // List available .env.* files try { const files = fs.readdirSync(envDir); const envFiles = files.filter(file => file.startsWith('.env.')); if (envFiles.length > 0) { envFiles.forEach(file => console.error(` ${file}`)); } else { console.error(` No .env.* files found in ${envDir}`); } } catch (err) { console.error(` Could not list files in ${envDir}`); } process.exit(1); } // Check if target .env exists and handle backup if (fs.existsSync(targetFile)) { if (!options.force) { console.log(`Warning: ${targetFile} already exists.`); console.log('Use --force to overwrite without confirmation.'); process.exit(1); } // Create backup if requested using new backup system if (options.backup) { console.log('๐Ÿ“ฆ Creating backup before switch...'); const backupResult = createBackup(envDir, null, false); // Auto-named backup, no compression if (backupResult.success) { console.log(`โœ… Backup created: ${backupResult.backupName}`); } else { console.error(`โŒ Error creating backup: ${backupResult.error}`); process.exit(1); } } console.log(`Overwriting existing: ${targetFile}`); } // Perform the switch try { fs.copyFileSync(sourceFile, targetFile); console.log(`Successfully switched to ${config} environment`); console.log(`Source: .env.${config}`); console.log(`Target: .env`); } catch (err) { console.error(`Error switching environment: ${err.message}`); process.exit(1); } } catch (err) { console.error(`Unexpected error: ${err.message}`); process.exit(1); } }); // Validate command - Validate environment configuration program .command('validate') .description('Validate current environment configuration') .option('-p, --path <path>', 'Path where .env files are located (default: current directory)') .option('-e, --env <file>', 'Environment file to validate (default: .env)') .option('-s, --strict', 'Fail validation on any discrepancy (non-zero exit code)') .option('-v, --verbose', 'Provide detailed validation report') .option('--schema <file>', 'Use custom schema file instead of .env.example') .option('--no-exit', 'Do not exit process (for programmatic use)') .action((options) => { validateEnvironment(options); }); // Export command - Export environment variables program .command('export') .description('Export environment variables to JSON or YAML format') .option('-f, --format <format>', 'Output format (json, yaml)', 'json') .option('-e, --env <file>', 'Input environment file (default: .env)', '.env') .option('-o, --output-file <file>', 'Output file path (default: stdout)') .option('-p, --path <path>', 'Working directory (default: current directory)') .action((options) => { exportEnvironment(options); }); // Backup command - Create backup of environment files program .command('backup [name]') .description('Create backup of environment files') .option('-p, --path <path>', 'Path where .env files are located (default: current directory)') .option('-c, --compress', 'Compress the backup files') .option('-l, --list', 'List available backups instead of creating one') .action((name, options) => { if (options.list) { listBackupsCommand(options); return; } try { const projectDir = options.path || process.cwd(); const backupName = name || null; console.log('๐Ÿ”„ Creating backup...'); if (backupName) { console.log(` Backup name: ${backupName}`); } if (options.compress) { console.log(' Compression: enabled'); } const result = createBackup(projectDir, backupName, options.compress); if (!result.success) { console.error(`โŒ Backup failed: ${result.error}`); process.exit(1); } console.log('โœ… Backup created successfully!'); console.log(` Name: ${result.backupName}`); if (result.compressed) { console.log(` Files: ${result.files.join(', ')}`); console.log(` Compressed files: ${result.compressedFiles.join(', ')}`); if (result.totalSize) { console.log(` Total original size: ${Math.round(result.totalSize / 1024)} KB`); } } else { console.log(` Files backed up: ${result.files.join(', ')}`); } console.log(` Location: ${path.relative(process.cwd(), result.backupPath)}`); process.exit(0); } catch (error) { console.error(`โŒ Error during backup: ${error.message}`); process.exit(1); } }); // Restore command - Restore from backup program .command('restore <backup>') .description('Restore environment files from backup') .option('-p, --path <path>', 'Path where .env files are located (default: current directory)') .option('-f, --force', 'Force restore (overwrite existing files without confirmation)') .option('-v, --verify', 'Verify backup integrity before restore') .option('-b, --backup-current', 'Create safety backup of current state before restore') .action((backup, options) => { try { const projectDir = options.path || process.cwd(); console.log('๐Ÿ”„ Restoring from backup...'); console.log(` Target backup: ${backup}`); if (options.verify) { console.log(' Verification: enabled'); } if (options.backupCurrent) { console.log(' Safety backup: enabled'); } const result = restoreFromBackup( projectDir, backup, options.force, options.verify, options.backupCurrent ); if (!result.success) { if (result.wouldOverwrite) { console.error(`โŒ ${result.error}`); } else { console.error(`โŒ Restore failed: ${result.error}`); } process.exit(1); } console.log('โœ… Restore completed successfully!'); console.log(` Backup restored: ${result.backupName}`); console.log(` Files restored: ${result.files.join(', ')}`); if (result.overwritten && result.overwritten.length > 0) { console.log(` Files overwritten: ${result.overwritten.join(', ')}`); } process.exit(0); } catch (error) { console.error(`โŒ Error during restore: ${error.message}`); process.exit(1); } }); // Encrypt command - Encrypt environment files program .command('encrypt <env>') .description('Encrypt environment file using AES-256-GCM encryption') .option('-k, --key <key>', 'Encryption password (required, or use ENVM_ENCRYPTION_KEY env var)') .option('-a, --algorithm <algorithm>', 'Encryption algorithm (default: aes-256-gcm)', 'aes-256-gcm') .option('-o, --output <output>', 'Output file path (default: <input>.encrypted)') .option('-p, --path <path>', 'Path where .env files are located (default: current directory)') .option('-v, --variable <variable>', 'Encrypt only the specified variable (leaves others as ENVM_ENCRYPTED:...)') .option('-f, --force', 'Force overwrite existing output file') .option('--no-backup', 'Skip automatic backup creation before encryption') .action((env, options) => { // Set up options for encryption function const encryptOptions = { env: env, key: options.key || process.env.ENVM_ENCRYPTION_KEY, algorithm: options.algorithm || 'aes-256-gcm', output: options.output, path: options.path, variable: options.variable, force: options.force, noBackup: options.noBackup }; encryptEnvironment(encryptOptions); }); // Decrypt command - Decrypt environment files program .command('decrypt <env>') .description('Decrypt environment file that was encrypted with AES-256-GCM') .option('-k, --key <key>', 'Decryption password (required, or use ENVM_ENCRYPTION_KEY env var)') .option('-o, --output <output>', 'Output file path (default: removes .encrypted extension)') .option('-p, --path <path>', 'Path where encrypted files are located (default: current directory)') .option('-v, --variable <variable>', 'Decrypt only the specified variable (keeps others encrypted)') .option('-f, --force', 'Force overwrite existing output file') .option('-b, --backup-current', 'Create backup of current file before decryption (if file exists)') .action((env, options) => { // Validate that file has .encrypted extension if (!env.includes('.encrypted')) { console.error('โŒ Error: File must be an encrypted .encrypted file'); console.error(' Use: envm decrypt <encrypted-file>.encrypted'); process.exit(1); } // Set up options for decryption function const decryptOptions = { env: env, key: options.key || process.env.ENVM_ENCRYPTION_KEY, output: options.output, path: options.path, variable: options.variable, force: options.force, backupCurrent: options.backupCurrent }; decryptEnvironment(decryptOptions); }); // Global options program .option('-v, --verbose', 'Enable verbose output') .option('-q, --quiet', 'Suppress non-essential output') .option('--config <file>', 'Path to custom configuration file'); /** * Ensures .envm directory structure exists * @param {string} projectDir - Project directory path * @returns {string} Backup directory path */ function ensureBackupDirectory(projectDir) { const envmDir = path.join(projectDir, '.envm'); const backupDir = path.join(envmDir, 'b