cfn-forge
Version:
CloudFormation deployment automation tool with git-based workflows
1,639 lines (1,424 loc) • 90.8 kB
JavaScript
/**
* 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