cfn-forge
Version:
CloudFormation deployment automation tool with git-based workflows
383 lines (324 loc) • 11.3 kB
JavaScript
/**
* Logger utility for consistent CLI output
*/
const chalk = require('chalk');
const ora = require('ora');
class Logger {
constructor() {
this.debugEnabled = process.env.DEBUG || process.env.CFN_FORGE_DEBUG;
this.errorHistory = [];
this.context = {};
this.timestampEnabled = process.env.CFN_FORGE_TIMESTAMPS || false;
}
formatTimestamp() {
if (!this.timestampEnabled) return '';
const now = new Date();
const time = now.toLocaleTimeString('en-US', {
hour12: false,
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
const date = now.toLocaleDateString('en-US', {
month: '2-digit',
day: '2-digit',
});
return `${chalk.gray(`[${date} ${time}]`)} `;
}
enableTimestamps() {
this.timestampEnabled = true;
}
disableTimestamps() {
this.timestampEnabled = false;
}
info(message) {
console.log(`${this.formatTimestamp()}${chalk.white('[INFO]')} ${message}`);
}
success(message) {
console.log(`${this.formatTimestamp()}${chalk.green('[OK]')} ${message}`);
}
warn(message) {
console.log(`${this.formatTimestamp()}${chalk.yellow('[WARN]')} ${message}`);
}
error(message) {
console.error(`${this.formatTimestamp()}${chalk.red('[ERROR]')} ${message}`);
}
debug(message) {
if (this.debugEnabled) {
console.log(`${this.formatTimestamp()}${chalk.white('[DEBUG]')} ${chalk.white(message)}`);
}
}
log(message) {
console.log(`${this.formatTimestamp()}${message}`);
}
spinner(text) {
return ora({
text,
spinner: 'dots',
color: 'blue',
});
}
table(data) {
console.table(data);
}
json(data, pretty = true) {
if (pretty) {
console.log(JSON.stringify(data, null, 2));
} else {
console.log(JSON.stringify(data));
}
}
box(title, content) {
const boxen = require('boxen');
console.log(boxen(content, {
title,
padding: 1,
margin: 1,
borderStyle: 'round',
borderColor: 'blue',
}));
}
section(title) {
console.log();
console.log(chalk.white.bold(`=== ${title} ===`));
console.log();
}
subsection(title) {
console.log();
console.log(chalk.cyan(`--- ${title} ---`));
}
list(items, ordered = false) {
items.forEach((item, index) => {
const prefix = ordered ? `${index + 1}.` : '•';
console.log(` ${prefix} ${item}`);
});
}
progress(current, total, message = '') {
const percentage = Math.round((current / total) * 100);
const filled = Math.round((current / total) * 20);
const bar = '█'.repeat(filled) + '░'.repeat(20 - filled);
process.stdout.write(`\r${bar} ${percentage}% ${message}`);
if (current === total) {
process.stdout.write('\n');
}
}
clear() {
process.stdout.write('\x1Bc');
}
divider() {
console.log(chalk.white('─'.repeat(process.stdout.columns || 80)));
}
async confirm(message, defaultValue = false) {
const inquirer = require('inquirer');
const { confirmed } = await inquirer.prompt([{
type: 'confirm',
name: 'confirmed',
message,
default: defaultValue,
}]);
return confirmed;
}
formatError(error) {
let message = error.message || 'Unknown error';
// AWS-specific error formatting
if (error.code) {
switch (error.code) {
case 'CredentialsError':
message = 'AWS credentials not configured';
break;
case 'ValidationError':
message = `CloudFormation validation error: ${message}`;
break;
case 'AccessDenied':
message = 'AWS access denied - check your permissions';
break;
case 'NoSuchBucket':
message = 'S3 bucket not found';
break;
case 'StackNotFound':
message = 'CloudFormation stack not found';
break;
}
}
return message;
}
formatDuration(ms) {
if (ms < 1000) return `${ms}ms`;
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
if (ms < 3600000) return `${Math.floor(ms / 60000)}m ${Math.floor((ms % 60000) / 1000)}s`;
return `${Math.floor(ms / 3600000)}h ${Math.floor((ms % 3600000) / 60000)}m`;
}
formatSize(bytes) {
const units = ['B', 'KB', 'MB', 'GB'];
let size = bytes;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
return `${size.toFixed(1)} ${units[unitIndex]}`;
}
formatCost(amount, currency = 'USD') {
const formatter = new Intl.NumberFormat('en-US', {
style: 'currency',
currency,
});
return formatter.format(amount);
}
// Enhanced error handling methods
setContext(key, value) {
this.context[key] = value;
}
clearContext() {
this.context = {};
}
errorWithContext(error, operation = null) {
const timestamp = new Date().toISOString();
const errorInfo = {
timestamp,
operation,
message: error.message,
code: error.code,
stack: this.debugEnabled ? error.stack : null,
context: { ...this.context },
};
this.errorHistory.push(errorInfo);
// Format and display error
const formattedMessage = this.formatDetailedError(error, operation);
console.error(`${this.formatTimestamp()}${chalk.red('[ERROR]')} ${formattedMessage}`);
// Show context if available
if (Object.keys(this.context).length > 0) {
try {
// Create a safe copy of context without circular references
const safeContext = JSON.parse(JSON.stringify(this.context, (key, value) => {
if (value && typeof value === 'object' && value.constructor && value.constructor.name === 'Command') {
return '[Command Object]';
}
return value;
}));
this.debug(`Context: ${JSON.stringify(safeContext, null, 2)}`);
} catch (error) {
this.debug('Context: [Unable to serialize - contains circular references]');
}
}
return errorInfo;
}
formatDetailedError(error, operation = null) {
let message = error.message || 'Unknown error';
const prefix = operation ? `[${operation}] ` : '';
// AWS-specific error handling
if (error.code) {
const suggestion = this.getErrorSuggestion(error.code, error.message);
if (suggestion) {
message += `\n\n${chalk.cyan('Suggestion')}: ${suggestion}`;
}
}
// Add stack trace in debug mode
if (this.debugEnabled && error.stack) {
message += `\n\n${chalk.white('Stack trace:')}\n${chalk.white(error.stack)}`;
}
return `${prefix}${message}`;
}
getErrorSuggestion(errorCode, errorMessage) {
const suggestions = {
CredentialsError: 'Run `aws configure` or `cfn-forge config --aws` to set up credentials',
ValidationError: 'Check your CloudFormation template syntax with `cfn-forge validate`',
AccessDenied: 'Verify your AWS permissions or try a different AWS profile',
NoSuchBucket: 'Create the S3 bucket or check the bucket name configuration',
StackNotFound: 'The CloudFormation stack may not exist yet - try deploying first',
ThrottlingException: 'AWS is rate limiting requests - wait a moment and try again',
LimitExceededException: 'AWS service limits reached - check your account limits',
InvalidParameterValue: 'Check parameter values in your .cfn-forge.yaml configuration',
AlreadyExistsException: 'Resource already exists - use --force or check for naming conflicts',
InsufficientCapabilitiesException: 'Add IAM capabilities to your CloudFormation template',
ENOENT: 'File or directory not found - check file paths in your configuration',
EACCES: 'Permission denied - check file permissions or run with appropriate privileges',
ENOTDIR: 'Expected directory but found file - check your path configuration',
EISDIR: 'Expected file but found directory - check your path configuration',
};
// Check for specific error message patterns
if (errorMessage) {
if (errorMessage.includes('template format error')) {
return 'Validate your CloudFormation template format and fix syntax errors';
}
if (errorMessage.includes('git')) {
return 'Initialize git repository: `git init && git add . && git commit -m "Initial commit"`';
}
if (errorMessage.includes('AWS_ACCESS_KEY')) {
return 'Set AWS credentials: `export AWS_ACCESS_KEY_ID=...` and `export AWS_SECRET_ACCESS_KEY=...`';
}
if (errorMessage.includes('region')) {
return 'Set AWS region: `export AWS_DEFAULT_REGION=us-east-1` or configure in .cfn-forge.yaml';
}
}
return suggestions[errorCode];
}
reportErrorSummary() {
if (this.errorHistory.length === 0) {
this.success('No errors encountered during execution');
return;
}
this.section('Error Summary');
this.errorHistory.forEach((error, index) => {
console.log(`${index + 1}. ${chalk.red(error.operation || 'Unknown operation')}`);
console.log(` ${error.message}`);
console.log(` ${chalk.white(error.timestamp)}`);
});
this.subsection('Troubleshooting');
this.info('For detailed debugging, run with DEBUG=1');
this.info('For AWS issues, check: aws sts get-caller-identity');
this.info('For git issues, check: git status');
}
fatal(error, operation = null, exitCode = 1) {
this.errorWithContext(error, operation);
this.reportErrorSummary();
process.exit(exitCode);
}
async withErrorHandling(operation, asyncFn, context = {}) {
this.setContext('operation', operation);
Object.entries(context).forEach(([key, value]) => {
this.setContext(key, value);
});
try {
this.debug(`Starting operation: ${operation}`);
const startTime = Date.now();
const result = await asyncFn();
const duration = Date.now() - startTime;
this.debug(`Completed operation: ${operation} (${this.formatDuration(duration)})`);
return result;
} catch (error) {
this.errorWithContext(error, operation);
throw error;
} finally {
this.clearContext();
}
}
trackOperation(operation, context = {}) {
return {
start: () => {
this.setContext('operation', operation);
Object.entries(context).forEach(([key, value]) => {
this.setContext(key, value);
});
this.debug(`Starting: ${operation}`);
return Date.now();
},
success: (startTime, message = null) => {
const duration = Date.now() - startTime;
this.debug(`[OK] ${operation} completed (${this.formatDuration(duration)})`);
if (message) this.success(message);
this.clearContext();
},
error: (error, startTime = null) => {
const duration = startTime ? Date.now() - startTime : null;
const durationMsg = duration ? ` (failed after ${this.formatDuration(duration)})` : '';
this.errorWithContext(error, `${operation}${durationMsg}`);
this.clearContext();
return error;
},
};
}
}
// Create singleton instance
const logger = new Logger();
module.exports = { logger, Logger };