UNPKG

cfn-forge

Version:

CloudFormation deployment automation tool with git-based workflows

1,639 lines (1,424 loc) 90.8 kB
/** * Interactive Mode for CFN-Forge * * Provides an interactive menu when cfn-forge is run without commands */ const inquirer = require('inquirer'); const chalk = require('chalk'); const { execSync } = require('child_process'); const path = require('path'); const { getValidationStatus } = require('../utils/validation-status'); const { viewCredentials, editCredentials } = require('./config'); /** * Get cross-platform default editor */ function getDefaultEditor() { // Check environment variables first if (process.env.EDITOR) return process.env.EDITOR; if (process.env.VISUAL) return process.env.VISUAL; // Platform-specific defaults switch (process.platform) { case 'win32': // Try common Windows editors try { execSync('code --version', { stdio: 'pipe' }); return 'code'; } catch { try { execSync('notepad.exe', { stdio: 'pipe' }); return 'notepad'; } catch { return 'notepad'; // Fallback to notepad anyway } } case 'darwin': // macOS prefers nano or vim return 'nano'; case 'linux': default: // Linux/Unix systems return 'nano'; } } /** * Check if current directory is a git repository */ function checkGitRepository() { try { execSync('git rev-parse --is-inside-work-tree', { stdio: 'pipe' }); return true; } catch (error) { return false; } } /** * Display stylized welcome banner */ function displayWelcomeBanner() { console.log(chalk.cyan(` _____ _____ _ _ _____ / ____| ___| \\ | | | ___| | | | |_ | \\| |_____| |_ ___ _ __ __ _ ___ | | | _| | . \` |_____| _/ _ \\| '__/ _\` |/ _ \\ | |____| | | |\\ | | || (_) | | | (_| | __/ \\_____|_| |_| \\_| |_| \\___/|_| \\__, |\\___| / | |___/ `)); console.log(chalk.white(`v${require('../../../package.json').version} - CloudFormation Deployment Automation\n`)); } /** * Quick start guide for new users */ function displayQuickStart() { console.log(chalk.yellow('Quick Start:')); console.log(chalk.white(' cfn-forge init')); console.log(chalk.white(' cfn-forge deploy')); console.log(); console.log(chalk.white('Help: ') + chalk.white('cfn-forge --help')); console.log(); } /** * Interactive command menu */ async function runInteractiveMenu() { // Check if we're in a project const fs = require('fs'); const inProject = fs.existsSync('.cfn-forge.yaml'); // Check git repository status const hasGitRepo = checkGitRepository(); // Get current validation status const validationStatus = getValidationStatus(); let validateMenuText; switch (validationStatus.status) { case 'valid': validateMenuText = chalk.greenBright(`Validate templates ✓ (${validationStatus.summary})`); break; case 'invalid': validateMenuText = chalk.redBright(`Validate templates ✗ (${validationStatus.summary} - press enter to view)`); break; case 'no-files': validateMenuText = chalk.yellowBright(`Validate templates (${validationStatus.summary})`); break; default: validateMenuText = 'Validate templates'; } let choices; if (inProject) { // In a project - show deployment and configuration options choices = [ { name: 'Deploy a stack', value: 'deploy' }, { name: 'Check stack status', value: 'status' }, { name: 'Deploy on local commits', value: 'watch' }, { name: 'Edit CloudFormation template', value: 'edit' }, { name: 'Edit parameter values', value: 'edit-params' }, { name: `${validateMenuText}`, value: 'validate' }, { name: 'Configure', value: 'configure' }, { name: 'Exit', value: 'exit' }, ]; } else { // Not in a project - show getting started options choices = [ { name: 'Initialize a new project', value: 'init' }, { name: 'Configure', value: 'configure' }, { name: 'Exit', value: 'exit' }, ]; } const { action } = await inquirer.prompt([ { type: 'list', name: 'action', message: 'What would you like to do?', choices, pageSize: 50, }, ]); return action; } /** * Edit CloudFormation template in terminal */ async function editTemplate() { const fs = require('fs'); // Look for template files in common locations const templateCandidates = [ 'template.yaml', 'template.yml', 'cloudformation.yaml', 'cloudformation.yml', 'stack.yaml', 'stack.yml', ]; let templateFile = null; // Check if we're in a cfn-forge project if (fs.existsSync('.cfn-forge.yaml')) { try { const yaml = require('js-yaml'); const config = yaml.load(fs.readFileSync('.cfn-forge.yaml', 'utf8')); if (config.templates && config.templates.main) { templateFile = config.templates.main; } } catch (error) { // Continue with candidate search } } // If no template specified in config, find the first existing candidate if (!templateFile || !fs.existsSync(templateFile)) { templateFile = templateCandidates.find((file) => fs.existsSync(file)); } if (!templateFile) { console.log(chalk.yellow('\nNo CloudFormation template found.')); console.log(chalk.white(`Looking for: ${templateCandidates.join(', ')}`)); const { createTemplate } = await inquirer.prompt([ { type: 'confirm', name: 'createTemplate', message: 'Would you like to create a new template.yaml?', default: true, }, ]); if (createTemplate) { templateFile = 'template.yaml'; const basicTemplate = `AWSTemplateFormatVersion: '2010-09-09' Description: 'CloudFormation template' Parameters: Environment: Type: String Default: dev AllowedValues: [dev, staging, prod] Resources: # Add your AWS resources here Outputs: # Add your stack outputs here `; fs.writeFileSync(templateFile, basicTemplate); console.log(chalk.greenBright(`\nCreated ${templateFile}`)); } else { return; } } // Determine editor to use const editor = getDefaultEditor(); console.log(chalk.white(`\nOpening ${templateFile} with ${editor}...`)); console.log(chalk.white('Save and exit when done editing.\n')); try { execSync(`${editor} "${templateFile}"`, { stdio: 'inherit' }); console.log(chalk.greenBright('\nTemplate editing completed.')); } catch (error) { console.log(chalk.magenta('\nEditor exited with error.')); if (error.status === 130) { console.log(chalk.white('(Editing was cancelled)')); } } } /** * Edit CloudFormation parameter values in terminal */ async function editParameters() { const fs = require('fs'); const yaml = require('js-yaml'); // Find template file first let templateFile = null; const templateCandidates = [ 'template.yaml', 'template.yml', 'cloudformation.yaml', 'cloudformation.yml', 'stack.yaml', 'stack.yml', ]; // Check if we're in a cfn-forge project if (fs.existsSync('.cfn-forge.yaml')) { try { const config = yaml.load(fs.readFileSync('.cfn-forge.yaml', 'utf8')); if (config.templates && config.templates.main) { templateFile = config.templates.main; } } catch (error) { // Continue with candidate search } } // If no template specified in config, find the first existing candidate if (!templateFile || !fs.existsSync(templateFile)) { templateFile = templateCandidates.find((file) => fs.existsSync(file)); } if (!templateFile) { console.log(chalk.yellow('\nNo CloudFormation template found.')); console.log(chalk.white(`Looking for: ${templateCandidates.join(', ')}`)); console.log(chalk.white('Create a template first, then edit parameters.\n')); return; } // Look for existing parameter files const parameterFiles = [ 'parameters.json', 'parameters.yaml', 'parameters.yml', 'params.json', 'params.yaml', 'params.yml', ].filter((file) => fs.existsSync(file)); // Also check for environment-specific parameter files const envParamFiles = []; ['dev', 'staging', 'prod'].forEach((env) => { [`parameters-${env}.json`, `parameters-${env}.yaml`, `params-${env}.json`].forEach((file) => { if (fs.existsSync(file)) { envParamFiles.push(file); } }); }); let selectedFile = null; if (parameterFiles.length > 0 || envParamFiles.length > 0) { // Let user choose which parameter file to edit const allFiles = [...parameterFiles, ...envParamFiles]; if (allFiles.length === 1) { selectedFile = allFiles[0]; console.log(chalk.white(`\nFound parameter file: ${selectedFile}`)); } else { const { file } = await inquirer.prompt([ { type: 'list', name: 'file', message: 'Which parameter file would you like to edit?', choices: [ ...allFiles.map((file) => ({ name: file, value: file })), { name: 'Create new parameter file', value: 'new' }, ], }, ]); selectedFile = file; } } else { console.log(chalk.yellow('\nNo parameter files found.')); const { createNew } = await inquirer.prompt([ { type: 'confirm', name: 'createNew', message: 'Would you like to create a new parameter file?', default: true, }, ]); if (!createNew) { return; } selectedFile = 'new'; } // Handle creating new parameter file if (selectedFile === 'new') { const { fileName, format } = await inquirer.prompt([ { type: 'input', name: 'fileName', message: 'Parameter file name:', default: 'parameters', validate: (input) => input.trim().length > 0 || 'File name is required', }, { type: 'list', name: 'format', message: 'File format:', choices: [ { name: 'JSON (.json)', value: 'json' }, { name: 'YAML (.yaml)', value: 'yaml' }, ], default: 'json', }, ]); selectedFile = `${fileName}.${format}`; // Extract parameters from template and create parameter file try { const templateContent = fs.readFileSync(templateFile, 'utf8'); // Configure YAML loader to handle CloudFormation intrinsic functions const CloudFormationSchema = yaml.DEFAULT_SCHEMA.extend([ new yaml.Type('!Ref', { kind: 'scalar', construct: (data) => ({ 'Fn::Ref': data }) }), new yaml.Type('!Sub', { kind: 'scalar', construct: (data) => ({ 'Fn::Sub': data }) }), new yaml.Type('!GetAtt', { kind: 'sequence', construct: (data) => ({ 'Fn::GetAtt': data }) }), new yaml.Type('!GetAZs', { kind: 'scalar', construct: (data) => ({ 'Fn::GetAZs': data }) }), new yaml.Type('!ImportValue', { kind: 'scalar', construct: (data) => ({ 'Fn::ImportValue': data }) }), new yaml.Type('!Join', { kind: 'sequence', construct: (data) => ({ 'Fn::Join': data }) }), new yaml.Type('!Select', { kind: 'sequence', construct: (data) => ({ 'Fn::Select': data }) }), new yaml.Type('!Split', { kind: 'sequence', construct: (data) => ({ 'Fn::Split': data }) }), new yaml.Type('!Base64', { kind: 'scalar', construct: (data) => ({ 'Fn::Base64': data }) }), new yaml.Type('!Cidr', { kind: 'sequence', construct: (data) => ({ 'Fn::Cidr': data }) }), new yaml.Type('!FindInMap', { kind: 'sequence', construct: (data) => ({ 'Fn::FindInMap': data }) }), new yaml.Type('!If', { kind: 'sequence', construct: (data) => ({ 'Fn::If': data }) }), new yaml.Type('!Not', { kind: 'sequence', construct: (data) => ({ 'Fn::Not': data }) }), new yaml.Type('!Equals', { kind: 'sequence', construct: (data) => ({ 'Fn::Equals': data }) }), new yaml.Type('!And', { kind: 'sequence', construct: (data) => ({ 'Fn::And': data }) }), new yaml.Type('!Or', { kind: 'sequence', construct: (data) => ({ 'Fn::Or': data }) }), new yaml.Type('!Condition', { kind: 'scalar', construct: (data) => ({ Condition: data }) }), ]); const template = yaml.load(templateContent, { schema: CloudFormationSchema }); const parameters = template.Parameters || {}; let parameterContent; if (format === 'json') { const paramObj = {}; Object.keys(parameters).forEach((paramName) => { const param = parameters[paramName]; paramObj[paramName] = param.Default || ''; }); parameterContent = JSON.stringify(paramObj, null, 2); } else { const paramLines = Object.keys(parameters).map((paramName) => { const param = parameters[paramName]; const defaultVal = param.Default || ''; return `${paramName}: "${defaultVal}" # ${param.Description || param.Type}`; }); parameterContent = `${paramLines.join('\n')}\n`; } fs.writeFileSync(selectedFile, parameterContent); console.log(chalk.greenBright(`\nCreated ${selectedFile} with template parameters`)); } catch (error) { console.log(chalk.magenta(`\nError reading template: ${error.message}`)); return; } } // Open parameter file in editor const editor = getDefaultEditor(); console.log(chalk.white(`\nOpening ${selectedFile} with ${editor}...`)); console.log(chalk.white('Save and exit when done editing parameters.\n')); try { execSync(`${editor} "${selectedFile}"`, { stdio: 'inherit' }); console.log(chalk.greenBright('\nParameter editing completed.')); // Offer to validate the parameter file const { validate } = await inquirer.prompt([ { type: 'confirm', name: 'validate', message: 'Would you like to validate the parameter file format?', default: true, }, ]); if (validate) { try { if (selectedFile.endsWith('.json')) { JSON.parse(fs.readFileSync(selectedFile, 'utf8')); console.log(chalk.greenBright('JSON format is valid.')); } else { yaml.load(fs.readFileSync(selectedFile, 'utf8')); console.log(chalk.greenBright('YAML format is valid.')); } } catch (error) { console.log(chalk.magenta(`Format validation failed: ${error.message}`)); console.log(chalk.white('You may need to fix the file format.')); } } } catch (error) { console.log(chalk.magenta('\nEditor exited with error.')); if (error.status === 130) { console.log(chalk.white('(Editing was cancelled)')); } } } /** * Show detailed validation diagnostics */ async function showValidationDiagnostics() { console.log(chalk.white('\n=== Template & Parameter Validation Diagnostics ===\n')); // Import the full validation command const validateCommand = require('./validate'); try { // Run validation with detailed output await validateCommand({}); } catch (error) { // Validation command handles its own errors and exits // This catch is just in case } console.log(); // Add spacing // Ask user what to do next const { nextAction } = await inquirer.prompt([ { type: 'list', name: 'nextAction', message: 'What would you like to do?', choices: [ { name: 'Return to main menu', value: 'menu' }, { name: 'Edit CloudFormation template', value: 'edit-template' }, { name: 'Edit parameter values', value: 'edit-params' }, { name: 'Exit', value: 'exit' }, ], pageSize: 50, }, ]); switch (nextAction) { case 'edit-template': await editTemplate(); break; case 'edit-params': await editParameters(); break; case 'exit': clearSensitiveData(); console.log(chalk.white('👋 Goodbye!')); process.exit(0); case 'menu': default: // Return to menu (function will return true) break; } } /** * Execute selected command */ async function executeCommand(command) { const binPath = path.join(__dirname, '../../../bin/cfn-forge'); switch (command) { case 'exit': clearSensitiveData(); console.log(chalk.white('👋 Goodbye!')); process.exit(0); case 'edit': await editTemplate(); return true; case 'edit-params': await editParameters(); return true; case 'validate': await showValidationDiagnostics(); return true; case 'status': await checkStackStatus(); return true; case 'aws-config': await configureAWSCredentials(); return true; case 'configure': await showConfigureMenu(); return true; case 'init': // For init, we can run it in interactive mode try { execSync(`node ${binPath} init`, { stdio: 'inherit' }); } catch (error) { // User may have cancelled, that's ok } return true; case 'deploy': // Deploy once (one-time deployment) try { execSync(`node ${binPath} deploy --once`, { stdio: 'inherit' }); } catch (error) { // User may have cancelled or deployment failed, that's ok } return true; case 'watch': await deployOnLocalCommits(); return true; default: // Show helpful message for unknown commands console.log(chalk.yellow(`\nUnknown command: ${command}\n`)); console.log(chalk.white('Run \'cfn-forge --help\' for available commands.\n')); return true; } } /** * Deploy on local commits with clear user information */ async function deployOnLocalCommits() { console.log(chalk.blue('\n=== Deploy on Local Commits ===\n')); console.log(chalk.white('This feature will:')); console.log(chalk.gray(' • Monitor your LOCAL git repository')); console.log(chalk.gray(' • Deploy to AWS when you make a commit')); console.log(chalk.gray(' • Only watch commits YOU make locally')); console.log(chalk.yellow(' • Will NOT monitor remote/team changes')); console.log(chalk.yellow(' • Will NOT watch GitHub/GitLab\n')); console.log(chalk.white('Current Configuration:')); // Get current branch try { const currentBranch = execSync('git rev-parse --abbrev-ref HEAD', { encoding: 'utf8' }).trim(); console.log(chalk.gray(` • Branch: ${currentBranch}`)); } catch (error) { console.log(chalk.red(' • ERROR: Not in a git repository')); return; } // Get deployment environment const fs = require('fs'); const yaml = require('js-yaml'); let environment = 'dev'; // default try { const config = yaml.load(fs.readFileSync('.cfn-forge.yaml', 'utf8')); const environments = Object.keys(config.environments || {}); if (environments.length > 1) { console.log(chalk.gray(` • Available environments: ${environments.join(', ')}`)); const { selectedEnv } = await inquirer.prompt([ { type: 'list', name: 'selectedEnv', message: 'Which environment to deploy to?', choices: environments, default: 'dev', }, ]); environment = selectedEnv; } else if (environments.length === 1) { environment = environments[0]; } } catch (error) { console.log(chalk.yellow(' • Using default environment: dev')); } console.log(chalk.gray(` • Environment: ${environment}`)); console.log(chalk.gray(' • Check interval: 30 seconds')); console.log(chalk.gray(' • Auto-deploy: ENABLED\n')); // Safety confirmation const { confirm } = await inquirer.prompt([ { type: 'confirm', name: 'confirm', message: chalk.yellow('Warning: Each local commit will trigger an AWS deployment. Continue?'), default: false, }, ]); if (!confirm) { console.log(chalk.gray('\nCancelled.\n')); return; } // Additional options const { options } = await inquirer.prompt([ { type: 'checkbox', name: 'options', message: 'Safety options:', choices: [ { name: 'Show deployment preview before deploying', value: 'preview', checked: true }, { name: 'Require confirmation for each deployment', value: 'confirm', checked: true }, { name: 'Only deploy commits with [deploy] in message', value: 'tag' }, ], }, ]); console.log(chalk.blue('\nStarting local commit monitor...\n')); console.log(chalk.gray('Press Ctrl+C to stop monitoring\n')); // Build command with options let watchCommand = `node ${path.join(__dirname, '../../../bin/cfn-forge')} watch --env ${environment}`; if (options.includes('preview')) { watchCommand += ' --preview'; } if (options.includes('confirm')) { watchCommand += ' --confirm'; } if (options.includes('tag')) { watchCommand += ' --tag-required'; } try { execSync(watchCommand, { stdio: 'inherit' }); } catch (error) { // User cancelled with Ctrl+C console.log(chalk.gray('\n\nMonitoring stopped.\n')); } } /** * Clear sensitive information from screen */ function clearSensitiveData() { // Clear screen (like running 'clear' command) process.stdout.write('\u001b[2J'); // Clear entire screen process.stdout.write('\u001b[H'); // Move cursor to top-left // Show a clean goodbye message console.log(chalk.gray(`Session cleared for security at ${new Date().toLocaleTimeString()}`)); } /** * Set up graceful exit handlers including ESC key */ function setupEscapeKeyHandler() { // Only set up if we're in a TTY environment if (!process.stdin.isTTY) { return; } // Handle Ctrl+C gracefully process.on('SIGINT', () => { clearSensitiveData(); console.log(chalk.white('👋 Goodbye!')); process.exit(0); }); // Handle other termination signals process.on('SIGTERM', () => { clearSensitiveData(); console.log(chalk.white('👋 Goodbye!')); process.exit(0); }); // Set up raw mode to capture escape key const originalRawMode = process.stdin.isRaw; process.stdin.setRawMode(true); process.stdin.resume(); process.stdin.setEncoding('utf8'); // Listen for key presses const keyHandler = (key) => { // ESC key (ASCII 27 or \u001b) if (key === '\u001b') { cleanup(); clearSensitiveData(); console.log(chalk.white('👋 Goodbye!')); process.exit(0); } // Ctrl+C (ASCII 3) if (key === '\u0003') { cleanup(); clearSensitiveData(); console.log(chalk.white('👋 Goodbye!')); process.exit(0); } }; // Clean up function const cleanup = () => { process.stdin.removeListener('data', keyHandler); if (process.stdin.isTTY) { process.stdin.setRawMode(originalRawMode); } }; // Add the key handler process.stdin.on('data', keyHandler); // Clean up on normal exit process.on('exit', () => { cleanup(); clearSensitiveData(); }); // Add note about exit methods console.log(chalk.white('Tip: Press ESC or Ctrl+C anytime to exit\n')); } /** * Main interactive mode entry point */ async function interactiveMode(options = {}) { // Set up escape key handler setupEscapeKeyHandler(); // Always show welcome banner displayWelcomeBanner(); // Don't run interactive menu in non-TTY environments (CI/CD, pipes, etc.) if (!process.stdin.isTTY) { displayQuickStart(); console.log(chalk.white('\nNote: Interactive mode requires a terminal. Exiting.\n')); return; } // Check if in a cfn-forge project const fs = require('fs'); const inProject = fs.existsSync('.cfn-forge.yaml'); const hasGitRepo = checkGitRepository(); if (!inProject) { console.log(chalk.yellow('No CFN-Forge project found in current directory.')); if (hasGitRepo) { console.log(chalk.white('Git repository detected\n')); } else { console.log(chalk.yellow('No git repository\n')); } const { shouldInit } = await inquirer.prompt([ { type: 'confirm', name: 'shouldInit', message: 'Would you like to initialize a new project?', default: true, }, ]); if (shouldInit) { try { const binPath = path.join(__dirname, '../../../bin/cfn-forge'); execSync(`node ${binPath} init`, { stdio: 'inherit' }); } catch (error) { // User may have cancelled console.log(chalk.white('\nProject initialization cancelled.\n')); } return; } } else { console.log(chalk.greenBright('CFN-Forge project detected')); if (hasGitRepo) { console.log(chalk.white('Git repository detected\n')); } else { console.log(chalk.yellow('No git repository\n')); } } // Main interaction loop let continueLoop = true; while (continueLoop) { try { const action = await runInteractiveMenu(); continueLoop = await executeCommand(action); if (continueLoop && action !== 'exit' && action !== 'configure') { // Add some spacing before showing menu again console.log(); } } catch (error) { if (error.isTtyError) { // Prompt couldn't be rendered in the current environment displayQuickStart(); break; } else if (error.name === 'ExitPromptError') { // User pressed Ctrl+C to cancel prompt console.log(chalk.white('\n\nGoodbye!\n')); break; } else { // Something else went wrong console.error(chalk.magenta('Interactive mode error:'), error.message); break; } } } } /** * Check CloudFormation stack status */ async function checkStackStatus() { const fs = require('fs'); const yaml = require('js-yaml'); console.log(chalk.white('\n=== CloudFormation Stack Status ===\n')); // Load project configuration if (!fs.existsSync('.cfn-forge.yaml')) { console.log(chalk.red('Error: Not in a CFN-Forge project directory')); console.log(chalk.gray(' Run "cfn-forge init" to create a project\n')); return; } try { const config = yaml.load(fs.readFileSync('.cfn-forge.yaml', 'utf8')); const environments = Object.keys(config.environments || {}); if (environments.length === 0) { console.log(chalk.yellow('Warning: No environments configured in .cfn-forge.yaml')); console.log(chalk.gray(' Add environments to check stack status\n')); return; } let selectedEnvironment = null; // If multiple environments, let user choose if (environments.length > 1) { const { environment } = await inquirer.prompt([ { type: 'list', name: 'environment', message: 'Which environment would you like to check?', choices: [ ...environments.map((env) => ({ name: env, value: env })), { name: 'Check all environments', value: 'all' }, ], }, ]); selectedEnvironment = environment; } else { selectedEnvironment = environments[0]; } // Check status for selected environment(s) if (selectedEnvironment === 'all') { for (const env of environments) { await checkEnvironmentStatus(config, env); if (env !== environments[environments.length - 1]) { console.log(''); // Add spacing between environments } } } else { await checkEnvironmentStatus(config, selectedEnvironment); } } catch (error) { console.log(chalk.red(`Error checking stack status: ${error.message}`)); } await inquirer.prompt([{ type: 'input', name: 'continue', message: 'Press Enter to continue...', default: '', }]); } /** * Check status for a specific environment */ async function checkEnvironmentStatus(config, environmentName) { const environment = config.environments[environmentName]; if (!environment) { console.log(chalk.red(`Error: Environment '${environmentName}' not found in configuration`)); return; } console.log(chalk.white(`Checking environment: ${chalk.cyan(environmentName)}`)); // Construct stack name using same logic as deploy command const stackName = environment.stackName || `${config.cloudformation?.stackName || config.project?.name || 'unnamed'}${environment.stackSuffix || ''}`; const region = environment.region || 'us-east-1'; const profile = environment.profile || 'default'; console.log(chalk.gray(` Stack Name: ${stackName}`)); console.log(chalk.gray(` Region: ${region}`)); console.log(chalk.gray(` Profile: ${profile}`)); try { // Use AWS CLI to check stack status (this avoids needing AWS SDK setup) const { execSync } = require('child_process'); // Set AWS profile if specified const env = { ...process.env }; if (profile !== 'default') { env.AWS_PROFILE = profile; } env.AWS_DEFAULT_REGION = region; // Try to describe the stack const command = `aws cloudformation describe-stacks --stack-name "${stackName}"`; try { const output = execSync(command, { encoding: 'utf8', env, stdio: ['pipe', 'pipe', 'pipe'], }); const result = JSON.parse(output); const stack = result.Stacks[0]; // Display stack status const status = stack.StackStatus; const statusColor = getStatusColor(status); console.log(` Status: ${statusColor(status)}`); if (stack.CreationTime) { console.log(chalk.gray(` Created: ${new Date(stack.CreationTime).toLocaleString()}`)); } if (stack.LastUpdatedTime) { console.log(chalk.gray(` Last Updated: ${new Date(stack.LastUpdatedTime).toLocaleString()}`)); } if (stack.StackStatusReason) { console.log(chalk.gray(` Reason: ${stack.StackStatusReason}`)); } // Show current validation timestamp const now = new Date(); console.log(chalk.green(` Validated online: ${now.toLocaleString()}`)); // Show outputs if any if (stack.Outputs && stack.Outputs.length > 0) { console.log(chalk.white(' Outputs:')); for (const output of stack.Outputs) { console.log(chalk.gray(` ${output.OutputKey}: ${output.OutputValue}`)); } } // Show parameters if any if (stack.Parameters && stack.Parameters.length > 0) { console.log(chalk.white(' Parameters:')); for (const param of stack.Parameters) { console.log(chalk.gray(` ${param.ParameterKey}: ${param.ParameterValue}`)); } } // Check for template drift if template exists await checkTemplateDrift(config, environmentName, stackName, env); } catch (awsError) { const errorOutput = awsError.stderr || awsError.message; if (errorOutput.includes('does not exist')) { console.log(chalk.yellow(` Status: ${chalk.yellow('Stack not deployed')}`)); console.log(chalk.gray(' This stack has not been created yet')); } else if (errorOutput.includes('Unable to locate credentials')) { console.log(chalk.red(` Status: ${chalk.red('Credentials Error')}`)); console.log(chalk.gray(' Configure AWS credentials to check stack status')); } else if (errorOutput.includes('is not authorized')) { console.log(chalk.red(` Status: ${chalk.red('Access Denied')}`)); console.log(chalk.gray(' Check your AWS permissions for CloudFormation')); } else { console.log(chalk.red(` Status: ${chalk.red('Error')}`)); console.log(chalk.gray(` ${errorOutput.split('\n')[0]}`)); } } } catch (error) { console.log(chalk.red(` Status: ${chalk.red('Check Failed')}`)); console.log(chalk.gray(` ${error.message}`)); } } /** * Check for template drift */ async function checkTemplateDrift(config, environmentName, stackName, awsEnv) { try { const environment = config.environments[environmentName]; const templatePath = environment.template || config.cloudformation?.template; if (!templatePath) { return; // No template to compare } // Check if template file exists const fs = require('fs'); if (!fs.existsSync(templatePath)) { return; // Template file not found } console.log(chalk.white(' Template:')); // Use CloudFormation client for template comparison const CloudFormationClient = require('../../lib/core/aws/CloudFormationClient'); const cfnClient = new CloudFormationClient({ region: awsEnv.AWS_DEFAULT_REGION, }); try { // Read local template const localTemplate = fs.readFileSync(templatePath, 'utf8'); // First validate template using AWS CloudFormation native validation const validationResult = await cfnClient.validateTemplate(localTemplate); if (!validationResult.valid) { console.log(chalk.red(` Template validation failed: ${validationResult.error}`)); return; } // Compare templates const comparison = await cfnClient.compareTemplates(stackName, localTemplate); if (comparison.hasChanges) { console.log(chalk.yellow(' Local differs from deployed')); console.log(chalk.gray(' Run \'cfn-forge drift --show-diff\' for details')); } else { console.log(chalk.green(' Local matches deployed template')); } } catch (error) { console.log(chalk.gray(` Could not compare: ${error.message}`)); } } catch (error) { // Silently fail - drift check is optional console.log(chalk.gray(` Drift check failed: ${error.message}`)); } } /** * Get color for CloudFormation stack status */ function getStatusColor(status) { if (status.includes('COMPLETE') && !status.includes('ROLLBACK')) { return chalk.green; } if (status.includes('IN_PROGRESS')) { return chalk.yellow; } if (status.includes('FAILED') || status.includes('ROLLBACK')) { return chalk.red; } return chalk.gray; } /** * Show configuration submenu */ async function showConfigureMenu() { while (true) { console.log(chalk.white('\n=== Configuration ===\n')); const { action } = await inquirer.prompt([ { type: 'list', name: 'action', message: 'What would you like to configure?', choices: [ { name: 'AWS credentials', value: 'aws-credentials' }, { name: 'Application settings', value: 'app-settings' }, { name: 'Recovery (restore/reinitialize config)', value: 'recovery' }, { name: 'Back to main menu', value: 'back' }, ], pageSize: 50, }, ]); switch (action) { case 'aws-credentials': await configureAWSCredentials(); break; case 'app-settings': await configureAppSettings(); break; case 'recovery': await recoverConfiguration(); break; case 'back': return; default: return; } } } /** * Configure application settings via unified text editor */ async function configureAppSettings() { const fs = require('fs'); const path = require('path'); const { spawn } = require('child_process'); console.log(chalk.white('\n=== Application Settings ===\n')); // Check if in a CFN-Forge project const configFile = '.cfn-forge.yaml'; if (!fs.existsSync(configFile)) { console.log(chalk.red('Error: Not in a CFN-Forge project directory')); console.log(chalk.gray(' Run "cfn-forge init" to create a project\n')); return; } // Create automatic backup before editing const backupDir = '.cfn-forge-backups'; if (!fs.existsSync(backupDir)) { fs.mkdirSync(backupDir); } const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); const backupFile = path.join(backupDir, `.cfn-forge.yaml.backup.${timestamp}`); // Store the original content and file stats for change detection const originalContent = fs.readFileSync(configFile, 'utf8'); const originalMtime = fs.statSync(configFile).mtime; // Create backup fs.writeFileSync(backupFile, originalContent); console.log(chalk.gray(`Backup created: ${backupFile}`)); console.log(chalk.white('Opening your application settings for editing...')); console.log(chalk.gray(`File: ${path.resolve(configFile)}`)); console.log(chalk.yellow('\nNote: This will open your actual .cfn-forge.yaml file')); console.log(chalk.gray(' • Edit the file directly - no parsing or conversion')); console.log(chalk.gray(' • All comments and formatting will be preserved')); console.log(chalk.gray(' • Save and close the editor to keep your changes')); console.log(chalk.gray(' • Use Ctrl+C to cancel without saving\n')); const { confirm } = await inquirer.prompt([ { type: 'confirm', name: 'confirm', message: 'Open .cfn-forge.yaml in your text editor?', default: true, }, ]); if (!confirm) { console.log(chalk.gray('Cancelled.')); return; } // Determine the best editor to use with proper platform handling let editorCmd; let editorArgs; if (process.env.EDITOR) { // Use user's preferred editor editorCmd = process.env.EDITOR; editorArgs = [configFile]; } else { // Platform-specific defaults switch (process.platform) { case 'win32': editorCmd = 'notepad'; editorArgs = [configFile]; break; case 'darwin': // Use open with wait flag and TextEdit specifically editorCmd = 'open'; editorArgs = ['-W', '-a', 'TextEdit', configFile]; break; default: // Linux and others editorCmd = 'nano'; editorArgs = [configFile]; break; } } console.log(chalk.white(`Opening with: ${editorCmd} ${editorArgs.join(' ')}`)); console.log(chalk.gray('Tip: Make your changes and save the file.\n')); const child = spawn(editorCmd, editorArgs, { stdio: 'inherit', shell: process.platform === 'win32', // Only use shell on Windows }); child.on('close', (code) => { try { // Check if file still exists if (!fs.existsSync(configFile)) { console.log(chalk.red('Error: .cfn-forge.yaml was deleted!')); console.log(chalk.yellow('Please restore the file or run "cfn-forge init" to recreate it.')); return; } const newMtime = fs.statSync(configFile).mtime; const newContent = fs.readFileSync(configFile, 'utf8'); // Check if file was actually modified if (newMtime.getTime() === originalMtime.getTime() && newContent === originalContent) { console.log(chalk.gray('ℹ️ No changes detected.')); return; } console.log(chalk.green('\nApplication settings updated successfully!')); console.log(chalk.white('Your changes have been saved to .cfn-forge.yaml')); console.log(chalk.white('\nNote: Settings will take effect immediately.')); } catch (error) { console.log(chalk.red(`\nError reading configuration: ${error.message}`)); } }); child.on('error', (error) => { console.log(chalk.red(`\nFailed to open editor: ${error.message}`)); console.log(chalk.white('\nYou can manually edit the settings file at:')); console.log(chalk.gray(' .cfn-forge.yaml')); }); } /** * Smart parameter file management with custom paths */ async function editParametersNew() { const fs = require('fs'); const yaml = require('js-yaml'); console.log(chalk.white('\n=== Parameter File Management ===\n')); // Check if in a CFN-Forge project if (!fs.existsSync('.cfn-forge.yaml')) { console.log(chalk.red('Error: Not in a CFN-Forge project directory')); console.log(chalk.gray(' Run "cfn-forge init" to create a project\n')); return; } // Load project configuration let config = {}; try { config = yaml.load(fs.readFileSync('.cfn-forge.yaml', 'utf8')) || {}; } catch (error) { console.log(chalk.yellow('Warning: Could not load project configuration')); return; } const environments = Object.keys(config.environments || {}); if (environments.length === 0) { console.log(chalk.yellow('Warning: No environments configured in .cfn-forge.yaml')); console.log(chalk.gray(' Configure environments first in Application Settings\n')); return; } // Check parameter files for each environment const envStatus = []; environments.forEach((env) => { const envConfig = config.environments[env]; const paramFile = envConfig.parametersFile || `parameters-${env}.json`; const exists = fs.existsSync(paramFile); envStatus.push({ env, paramFile, exists, isCustomPath: !!envConfig.parametersFile, }); }); // Display current status const activeEnv = config.activeEnvironment || environments[0]; console.log(chalk.white('Environment Parameter Files:')); envStatus.forEach(({ env, paramFile, exists, isCustomPath, }) => { const status = exists ? chalk.green('✓') : chalk.yellow('✗'); const pathType = isCustomPath ? chalk.blue(' (custom)') : chalk.gray(' (default)'); const activeMarker = env === activeEnv ? chalk.cyan(' (active)') : ''; console.log(` ${status} ${env}: ${paramFile}${pathType}${activeMarker}`); }); console.log(''); const { action } = await inquirer.prompt([ { type: 'list', name: 'action', message: 'What would you like to do?', choices: [ { name: `Edit parameters for active environment (${activeEnv})`, value: 'edit-active' }, { name: 'Edit parameters for specific environment', value: 'edit-specific' }, { name: 'Create missing parameter files', value: 'create-missing' }, { name: 'Set active environment', value: 'set-active' }, { name: 'Back to main menu', value: 'back' }, ], }, ]); switch (action) { case 'edit-active': await editEnvironmentParametersWithPath(activeEnv, config); break; case 'edit-specific': const { selectedEnv } = await inquirer.prompt([ { type: 'list', name: 'selectedEnv', message: 'Which environment?', choices: environments.map((env) => ({ name: env, value: env })), }, ]); await editEnvironmentParametersWithPath(selectedEnv, config); break; case 'create-missing': await createMissingParameterFilesWithPaths(envStatus, config); break; case 'set-active': await setActiveEnvironmentNew(environments, config); break; case 'back': } } /** * Edit parameters for environment using configured path */ async function editEnvironmentParametersWithPath(environment, config) { const fs = require('fs'); const path = require('path'); const envConfig = config.environments[environment] || {}; const paramFile = envConfig.parametersFile || `parameters-${environment}.json`; // Check if parameter file exists if (!fs.existsSync(paramFile)) { console.log(chalk.yellow(`\nWarning: Parameter file not found: ${paramFile}`)); const { action } = await inquirer.prompt([ { type: 'list', name: 'action', message: 'What would you like to do?', choices: [ { name: `Create ${paramFile} with template parameters`, value: 'create' }, { name: 'Create empty parameter file', value: 'create-empty' }, { name: 'Select different file', value: 'select' }, { name: 'Back to menu', value: 'back' }, ], }, ]); switch (action) { case 'create': await createParameterFileWithTemplate(paramFile, environment, config); break; case 'create-empty': await createEmptyParameterFile(paramFile, environment); break; case 'select': const newFile = await selectParameterFile(); if (newFile) { await editFileInEditor(newFile, environment); } return; case 'back': return; } } // Open file in editor await editFileInEditor(paramFile, environment); } /** * Create parameter file based on template parameters */ async function createParameterFileWithTemplate(paramFile, environment, config) { const fs = require('fs'); const yaml = require('js-yaml'); // Look for template file const templateCandidates = [ 'template.yaml', 'template.yml', 'cloudformation.yaml', 'cloudformation.yml', 'stack.yaml', 'stack.yml', ]; const templateFile = templateCandidates.find((file) => fs.existsSync(file)); if (!templateFile) { console.log(chalk.yellow('No template file found. Creating empty parameter file.')); await createEmptyParameterFile(paramFile, environment); return; } try { // Parse template to extract parameters const templateContent = fs.readFileSync(templateFile, 'utf8'); let template; if (templateFile.endsWith('.json')) { template = JSON.parse(templateContent); } else { template = yaml.load(templateContent); } const templateParams = template.Parameters || {}; // Create parameter array with environment-specific defaults const parameterArray = [ { ParameterKey: 'Environment', ParameterValue: environment, }, { ParameterKey: 'ProjectName', ParameterValue: config.projectName || 'my-project', }, ]; // Add template parameters Object.keys(templateParams).forEach((paramKey) => { if (paramKey !== 'Environment' && paramKey !== 'ProjectName') { const param = templateParams[paramKey]; parameterArray.push({ ParameterKey: paramKey, ParameterValue: param.Default || '', }); } }); // Ensure directory exists const dir = path.dirname(paramFile); if (dir !== '.' && !fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); } // Write parameter file const content = JSON.stringify(parameterArray, null, 2); fs.writeFileSync(paramFile, content); console.log(chalk.green(`Created ${paramFile} with ${parameterArray.length} parameters from ${templateFile}`)); } catch (error) { console.log(chalk.yellow(`Warning: Could not parse template (${error.message}). Creating basic parameter file.`)); await createEmptyParameterFile(paramFile, environment); } } /** * Create empty parameter file with basic structure */ async function createEmptyParameterFile(paramFile, environment) { const fs = require('fs'); const path = require('path'); // Ensure directory exists const dir = path.dirname(paramFile); if (dir !== '.' && !fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); } const isYaml = paramFile.endsWith('.yaml') || paramFile.endsWith('.yml'); let content; if (isYaml) { content = `# CloudFormation Parameters for ${environment} environment\n\nEnvironment: ${environment}\nProjectName: my-project\n`; } else { content = JSON.stringify([ { ParameterKey: 'Environment', ParameterValue: environment, }, { ParameterKey: 'ProjectName', ParameterValue: 'my-project', }, ], null, 2); } fs.writeFileSync(paramFile, content); console.log(chalk.green(`Created ${paramFile}`)); } /** * Let user select an existing parameter file */ async function selectParameterFile() { const fs = require('fs'); const glob = require('glob'); // Find all potential parameter files const patterns = [ 'parameters*.json', 'parameters*.yaml', 'parameters*.yml', 'params*.json', 'params*.yaml', 'params*.yml', '**/parameters*.json', '**/params*.json', ]; const files = []; patterns.forEach((pattern) => { try { const matches = glob.sync(pattern, { ignore: ['node_modules/**'] }); files.push(...matches); } catch (error) { // Ignore glob errors } }); const uniqueFiles = [...new Set(files)].filter((file) => fs.existsSync(file)); if (uniqueFiles.length === 0) { console.log(chalk.yellow('No existing parameter files found.')); return null; } const { selectedFile } = await inquirer.prompt([ { type: 'list', name: 'selectedFile', message: 'Select existing parameter file:', choices: uniqueFiles.map((file) => ({ name: file, value: file })), }, ]); return selectedFile; } /** * Open file in text editor */ async function editFileInEditor(paramFile, environment) { // Determine the best editor to use with proper platform handling let editorCmd; let editorArgs; if (process.env.EDITOR) { // Use user's preferred editor editorCmd = process.env.EDITOR; editorArgs = [paramFile]; } else { // Platform-specific defaults switch (process.platform) { case 'win32': editorCmd = 'notepad'; editorArgs = [paramFile]; break; case 'darwin': // Use open with wait flag and TextEdit specifically editorCmd = 'open'; editorArgs = ['-W', '-a', 'TextEdit', paramFile]; break; default: // Linux and others editorCmd = 'nano'; editorArgs = [paramFile]; break; } } console.log(chalk.white(`\nOpening ${paramFile} for environment: ${chalk.cyan(environment)}`)); console.log(chalk.gray(`This file contains parameters for the ${environment} environment\n`)); try { const { spawn } = require('child_process'); const child = spawn(editorCmd, editorArgs, { stdio: 'inherit', shell: process.platform === 'win32', // Only use shell on Windows }); child.on('close', (code) => { if (code === 0) { console.log(chalk.green(`\nParameter file ${paramFile} updated`)); console.log(chalk.white(`Note: Changes will apply on next deployment to ${environment}`)); } else { console.log(chalk.yellow(`\nWarning: Editor closed with code ${code}`)); } }); } catch (error) { c