cfn-forge
Version:
CloudFormation deployment automation tool with git-based workflows
865 lines (742 loc) ⢠26.3 kB
JavaScript
/**
* Enhanced Config Command
* Handles both regular configuration and secure AWS credential management
*/
const fs = require('fs');
const path = require('path');
const os = require('os');
const crypto = require('crypto');
const { spawn } = require('child_process');
const inquirer = require('inquirer');
const { logger } = require('../utils/logger');
const CONFIG_DIR = path.join(os.homedir(), '.cfn-forge');
const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
const AWS_CONFIG_FILE = path.join(CONFIG_DIR, 'aws-config.json');
const AWS_PROFILES_FILE = path.join(CONFIG_DIR, 'aws-profiles.json');
const ENCRYPTION_KEY_FILE = path.join(CONFIG_DIR, '.encryption-key');
/**
* Ensure config directory exists
*/
function ensureConfigDir() {
if (!fs.existsSync(CONFIG_DIR)) {
fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
}
}
/**
* Get or create encryption key
*/
function getEncryptionKey() {
ensureConfigDir();
if (fs.existsSync(ENCRYPTION_KEY_FILE)) {
return fs.readFileSync(ENCRYPTION_KEY_FILE, 'utf8').trim();
}
const key = crypto.randomBytes(32).toString('hex');
fs.writeFileSync(ENCRYPTION_KEY_FILE, key, { mode: 0o600 });
return key;
}
/**
* Encrypt sensitive data
*/
function encrypt(text) {
if (!text) return text;
const key = crypto.createHash('sha256').update(getEncryptionKey()).digest();
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv('aes-256-cbc', key, iv);
let encrypted = cipher.update(text, 'utf8', 'hex');
encrypted += cipher.final('hex');
return `${iv.toString('hex')}:${encrypted}`;
}
/**
* Decrypt sensitive data
*/
function decrypt(encryptedText) {
if (!encryptedText || !encryptedText.includes(':')) {
return encryptedText;
}
try {
const key = crypto.createHash('sha256').update(getEncryptionKey()).digest();
const [ivHex, encrypted] = encryptedText.split(':');
const iv = Buffer.from(ivHex, 'hex');
const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv);
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
} catch (error) {
logger.debug(`Decryption failed: ${error.message}`);
return encryptedText;
}
}
/**
* Load configuration file
*/
function loadConfig(filePath) {
if (!fs.existsSync(filePath)) {
return {};
}
try {
const content = fs.readFileSync(filePath, 'utf8');
return JSON.parse(content);
} catch (error) {
logger.debug(`Failed to load config from ${filePath}: ${error.message}`);
return {};
}
}
/**
* Save configuration file
*/
function saveConfig(filePath, config) {
ensureConfigDir();
fs.writeFileSync(filePath, JSON.stringify(config, null, 2), { mode: 0o600 });
}
/**
* List available AWS profiles
*/
function listAWSProfiles() {
const profiles = loadConfig(AWS_PROFILES_FILE);
const currentConfig = loadConfig(AWS_CONFIG_FILE);
logger.info('āļø AWS Profiles');
logger.info('');
if (Object.keys(profiles).length === 0) {
logger.info('No AWS profiles configured.');
logger.info('');
logger.info('Create a profile:');
logger.info(' cfn-forge config --aws --profile my-profile');
return;
}
Object.entries(profiles).forEach(([profileName, profile]) => {
const isDefault = currentConfig.defaultProfile === profileName;
const status = isDefault ? 'šø (default)' : ' ';
const credentials = profile.credentials ? '[encrypted]' : '[not set]';
console.log(`${status} ${profileName}`);
console.log(` Region: ${profile.region || '(not set)'}`);
console.log(` Credentials: ${credentials} ${profile.credentials ? 'stored (encrypted)' : 'external'}`);
if (profile.accountId) {
console.log(` Account: ${profile.accountId}`);
}
if (profile.updatedAt) {
console.log(` Updated: ${new Date(profile.updatedAt).toLocaleString()}`);
}
console.log('');
});
}
/**
* Interactive AWS configuration setup
*/
async function configureAWS(profileName = null) {
logger.info('AWS Configuration Setup');
logger.info('');
// Load existing profiles and current config
const profiles = loadConfig(AWS_PROFILES_FILE);
const currentConfig = loadConfig(AWS_CONFIG_FILE);
// If no profile name provided, ask for it
if (!profileName) {
const existingProfiles = Object.keys(profiles);
const profileQuestions = [
{
type: 'input',
name: 'profileName',
message: 'Profile name:',
default: 'default',
validate: (input) => {
if (!input || !/^[a-zA-Z0-9-_]+$/.test(input)) {
return 'Please enter a valid profile name (alphanumeric, hyphens, underscores only)';
}
return true;
},
},
];
if (existingProfiles.length > 0) {
logger.info(`Existing profiles: ${existingProfiles.join(', ')}`);
logger.info('');
}
const profileAnswer = await inquirer.prompt(profileQuestions);
profileName = profileAnswer.profileName; // eslint-disable-line no-param-reassign
}
const existingProfile = profiles[profileName] || {};
logger.info(`Configuring profile: ${profileName}`);
logger.info('');
const questions = [
{
type: 'input',
name: 'region',
message: 'AWS region:',
default: existingProfile.region || 'us-east-1',
validate: (input) => {
if (!/^[a-z0-9-]+$/.test(input)) {
return 'Please enter a valid AWS region (e.g., us-east-1)';
}
return true;
},
},
{
type: 'input',
name: 'accountId',
message: 'AWS Account ID (optional):',
default: existingProfile.accountId || '',
validate: (input) => {
if (input && !/^\d{12}$/.test(input)) {
return 'Please enter a valid 12-digit AWS Account ID or leave empty';
}
return true;
},
},
{
type: 'input',
name: 'description',
message: 'Profile description (optional):',
default: existingProfile.description || '',
},
{
type: 'confirm',
name: 'storeCredentials',
message: 'Store AWS credentials securely in this profile?',
default: !!existingProfile.credentials,
},
];
const answers = await inquirer.prompt(questions);
const profileConfig = {
region: answers.region,
accountId: answers.accountId || null,
description: answers.description || null,
updatedAt: new Date().toISOString(),
};
if (answers.storeCredentials) {
const credentialQuestions = [
{
type: 'password',
name: 'accessKeyId',
message: 'AWS Access Key ID:',
validate: (input) => {
if (!input || input.length < 16) {
return 'Please enter a valid AWS Access Key ID';
}
return true;
},
},
{
type: 'password',
name: 'secretAccessKey',
message: 'AWS Secret Access Key:',
validate: (input) => {
if (!input || input.length < 32) {
return 'Please enter a valid AWS Secret Access Key';
}
return true;
},
},
{
type: 'password',
name: 'sessionToken',
message: 'AWS Session Token (optional, for temporary credentials):',
default: '',
},
];
const credentials = await inquirer.prompt(credentialQuestions);
// Encrypt sensitive credentials
profileConfig.credentials = {
accessKeyId: encrypt(credentials.accessKeyId),
secretAccessKey: encrypt(credentials.secretAccessKey),
sessionToken: credentials.sessionToken ? encrypt(credentials.sessionToken) : null,
};
logger.warn('Warning: Credentials stored encrypted. Keep your ~/.cfn-forge directory secure!');
} else {
logger.info('You can configure AWS credentials using:');
logger.info(` ⢠AWS CLI: aws configure --profile ${profileName}`);
logger.info(' ⢠Environment variables: AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY');
logger.info(' ⢠IAM roles (recommended for EC2/Lambda)');
}
// Save profile to profiles file
profiles[profileName] = profileConfig;
saveConfig(AWS_PROFILES_FILE, profiles);
// Ask if this should be the default profile
const defaultQuestion = {
type: 'confirm',
name: 'setAsDefault',
message: 'Set this as the default profile for CFN-Forge?',
default: !currentConfig.defaultProfile || currentConfig.defaultProfile === profileName,
};
const defaultAnswer = await inquirer.prompt([defaultQuestion]);
if (defaultAnswer.setAsDefault) {
const updatedConfig = {
...currentConfig,
defaultProfile: profileName,
updatedAt: new Date().toISOString(),
};
saveConfig(AWS_CONFIG_FILE, updatedConfig);
logger.success(`Profile '${profileName}' set as default`);
}
logger.success(`AWS profile '${profileName}' saved successfully!`);
return profileConfig;
}
/**
* Get AWS configuration for a specific profile with decrypted credentials
*/
function getAWSConfig(profileName = null) {
const config = loadConfig(AWS_CONFIG_FILE);
const profiles = loadConfig(AWS_PROFILES_FILE);
// Determine which profile to use
const targetProfile = profileName || config.defaultProfile || 'default';
const profile = profiles[targetProfile];
if (!profile) {
return null;
}
const result = {
profileName: targetProfile,
region: profile.region,
accountId: profile.accountId,
description: profile.description,
updatedAt: profile.updatedAt,
};
if (profile.credentials) {
result.credentials = {
accessKeyId: decrypt(profile.credentials.accessKeyId),
secretAccessKey: decrypt(profile.credentials.secretAccessKey),
sessionToken: profile.credentials.sessionToken ? decrypt(profile.credentials.sessionToken) : null,
};
}
return result;
}
/**
* Switch default profile
*/
async function switchDefaultProfile(profileName) {
const profiles = loadConfig(AWS_PROFILES_FILE);
if (!profiles[profileName]) {
throw new Error(`Profile '${profileName}' does not exist`);
}
const config = loadConfig(AWS_CONFIG_FILE);
const updatedConfig = {
...config,
defaultProfile: profileName,
updatedAt: new Date().toISOString(),
};
saveConfig(AWS_CONFIG_FILE, updatedConfig);
logger.success(`Switched default profile to '${profileName}'`);
return updatedConfig;
}
/**
* Delete an AWS profile
*/
async function deleteAWSProfile(profileName) {
const profiles = loadConfig(AWS_PROFILES_FILE);
const config = loadConfig(AWS_CONFIG_FILE);
if (!profiles[profileName]) {
throw new Error(`Profile '${profileName}' does not exist`);
}
// Confirm deletion
const confirmQuestion = {
type: 'confirm',
name: 'confirmDelete',
message: `Delete AWS profile '${profileName}'? This cannot be undone.`,
default: false,
};
const answer = await inquirer.prompt([confirmQuestion]);
if (!answer.confirmDelete) {
logger.info('Profile deletion cancelled.');
return;
}
delete profiles[profileName];
saveConfig(AWS_PROFILES_FILE, profiles);
// If this was the default profile, clear it
if (config.defaultProfile === profileName) {
const remainingProfiles = Object.keys(profiles);
const updatedConfig = {
...config,
defaultProfile: remainingProfiles.length > 0 ? remainingProfiles[0] : null,
updatedAt: new Date().toISOString(),
};
saveConfig(AWS_CONFIG_FILE, updatedConfig);
if (remainingProfiles.length > 0) {
logger.info(`Default profile switched to '${remainingProfiles[0]}'`);
} else {
logger.info('No default profile set (no profiles remaining)');
}
}
logger.success(`Deleted AWS profile '${profileName}'`);
}
/**
* Set AWS environment variables from stored config
*/
function setAWSEnvironmentVariables(profileName = null) {
const awsConfig = getAWSConfig(profileName);
if (!awsConfig) {
logger.debug(`No AWS configuration found for profile: ${profileName || 'default'}`);
return;
}
if (awsConfig.region) {
process.env.AWS_DEFAULT_REGION = awsConfig.region;
process.env.AWS_REGION = awsConfig.region;
}
// Set profile name for AWS CLI compatibility
if (awsConfig.profileName && awsConfig.profileName !== 'default') {
process.env.AWS_PROFILE = awsConfig.profileName;
}
if (awsConfig.credentials) {
if (awsConfig.credentials.accessKeyId) {
process.env.AWS_ACCESS_KEY_ID = awsConfig.credentials.accessKeyId;
}
if (awsConfig.credentials.secretAccessKey) {
process.env.AWS_SECRET_ACCESS_KEY = awsConfig.credentials.secretAccessKey;
}
if (awsConfig.credentials.sessionToken) {
process.env.AWS_SESSION_TOKEN = awsConfig.credentials.sessionToken;
}
}
logger.debug(`AWS environment configured for profile: ${awsConfig.profileName}`);
}
/**
* List configuration values
*/
function listConfiguration(_showAWS = false) {
const regularConfig = loadConfig(CONFIG_FILE);
const awsConfig = loadConfig(AWS_CONFIG_FILE);
const profiles = loadConfig(AWS_PROFILES_FILE);
logger.info('Configuration Values');
logger.info('');
// Regular configuration
if (Object.keys(regularConfig).length > 0) {
logger.info('General Configuration:');
Object.entries(regularConfig).forEach(([key, value]) => {
if (key === 'defaultProfile') return; // Skip internal keys
console.log(` ${key}: ${value}`);
});
logger.info('');
}
// AWS configuration
if (Object.keys(profiles).length > 0) {
logger.info('āļø AWS Profiles:');
const { defaultProfile } = awsConfig;
Object.entries(profiles).forEach(([profileName, profile]) => {
const isDefault = defaultProfile === profileName;
const status = isDefault ? 'šø (default)' : ' ';
const credentials = profile.credentials ? '[encrypted]' : '[not set]';
console.log(`${status} ${profileName}`);
console.log(` Region: ${profile.region || '(not set)'}`);
console.log(` Credentials: ${credentials} ${profile.credentials ? 'stored (encrypted)' : 'external'}`);
if (profile.accountId) {
console.log(` Account: ${profile.accountId}`);
}
if (profile.description) {
console.log(` Description: ${profile.description}`);
}
if (profile.updatedAt) {
console.log(` Updated: ${new Date(profile.updatedAt).toLocaleString()}`);
}
});
logger.info('');
}
if (Object.keys(regularConfig).length === 0 && Object.keys(profiles).length === 0) {
logger.info('No configuration values set.');
logger.info('');
logger.info('Quick setup:');
logger.info(' cfn-forge config --aws --profile default # Configure AWS profile');
logger.info(' cfn-forge config key value # Set general config');
}
}
/**
* Main config command handler
*/
async function configCommand(key, value, options) {
logger.setContext('command', 'config');
logger.setContext('options', options);
try {
if (options.aws && options.profile) {
await configureAWS(options.profile);
return;
}
if (options.aws && !options.profile) {
await configureAWS();
return;
}
if (options.listProfiles) {
listAWSProfiles();
return;
}
if (options.switchProfile) {
await switchDefaultProfile(options.switchProfile);
return;
}
if (options.deleteProfile) {
await deleteAWSProfile(options.deleteProfile);
return;
}
if (options.list) {
listConfiguration(true);
return;
}
const configFile = CONFIG_FILE;
const config = loadConfig(configFile);
if (key && value !== undefined) {
// Set a configuration value
const finalValue = options.encrypt ? encrypt(value) : value;
config[key] = finalValue;
saveConfig(configFile, config);
const displayValue = options.encrypt ? '(encrypted)' : value;
logger.success(`Set ${key} = ${displayValue}`);
} else if (key && options.unset) {
// Unset a configuration value
if (config[key] !== undefined) {
delete config[key];
saveConfig(configFile, config);
logger.success(`Unset ${key}`);
} else {
logger.warn(`Key '${key}' not found`);
}
} else if (key) {
// Get a configuration value
const configValue = config[key];
if (configValue !== undefined) {
// Try to decrypt if it looks encrypted
if (typeof configValue === 'string' && configValue.includes(':')) {
const decrypted = decrypt(configValue);
if (decrypted !== configValue) {
console.log('(encrypted value - use with caution)');
return;
}
}
console.log(configValue);
} else {
console.log('(not set)');
}
} else {
// No arguments provided - show help
listConfiguration(true);
}
} catch (error) {
logger.errorWithContext(error, 'Config Command');
logger.reportErrorSummary();
process.exit(1);
} finally {
logger.clearContext();
}
}
module.exports = {
configCommand,
getAWSConfig,
setAWSEnvironmentVariables,
switchDefaultProfile,
deleteAWSProfile,
listAWSProfiles,
viewCredentials,
editCredentials,
encrypt,
decrypt,
};
/**
* Securely view AWS credentials in a temporary file
*/
async function viewCredentials(profileName = 'default') {
try {
const profiles = loadConfig(AWS_PROFILES_FILE);
if (!profiles[profileName]) {
logger.error(`Profile '${profileName}' not found`);
return;
}
const profile = profiles[profileName];
if (!profile.credentials) {
logger.info(`Profile '${profileName}' uses external credentials (AWS CLI/environment)`);
logger.info('No stored credentials to view');
return;
}
// Create temporary file with decrypted credentials
const tempDir = path.join(os.tmpdir(), 'cfn-forge-creds');
if (!fs.existsSync(tempDir)) {
fs.mkdirSync(tempDir, { recursive: true, mode: 0o700 });
}
const tempFile = path.join(tempDir, `${profileName}-credentials.txt`);
const credentialContent = [
`CFN-Forge AWS Credentials - Profile: ${profileName}`,
`Generated: ${new Date().toISOString()}`,
'',
'SECURITY WARNING: This file contains sensitive credentials!',
'Delete this file when done viewing.',
'',
`Profile Name: ${profileName}`,
`Region: ${profile.region || 'us-east-1'}`,
`Access Key ID: ${decrypt(profile.credentials.accessKeyId)}`,
`Secret Access Key: ${decrypt(profile.credentials.secretAccessKey)}`,
];
if (profile.credentials.sessionToken) {
credentialContent.push(`Session Token: ${decrypt(profile.credentials.sessionToken)}`);
}
credentialContent.push(
'',
'AWS CLI Format:',
`[${profileName}]`,
`aws_access_key_id = ${decrypt(profile.credentials.accessKeyId)}`,
`aws_secret_access_key = ${decrypt(profile.credentials.secretAccessKey)}`,
);
if (profile.credentials.sessionToken) {
credentialContent.push(`aws_session_token = ${decrypt(profile.credentials.sessionToken)}`);
}
// Write to temporary file with restricted permissions
fs.writeFileSync(tempFile, credentialContent.join('\n'), { mode: 0o600 });
logger.info(`\nš Credentials written to temporary file: ${tempFile}`);
logger.warn('Warning: File contains sensitive data - delete when done!');
// Ask if user wants to open in editor
const { openEditor } = await inquirer.prompt([{
type: 'confirm',
name: 'openEditor',
message: 'Open credentials file in default text editor?',
default: true,
}]);
if (openEditor) {
let editor = process.env.EDITOR;
if (!editor) {
if (process.platform === 'win32') {
editor = 'notepad';
} else if (process.platform === 'darwin') {
editor = 'open -t';
} else {
editor = 'nano';
}
}
logger.info(`Opening with: ${editor}`);
const child = spawn(editor, [tempFile], {
stdio: 'inherit',
shell: true,
});
child.on('close', async () => {
// Ask if user wants to delete the file
const { deleteFile } = await inquirer.prompt([{
type: 'confirm',
name: 'deleteFile',
message: 'Delete the temporary credentials file?',
default: true,
}]);
if (deleteFile) {
fs.unlinkSync(tempFile);
logger.success('Temporary credentials file deleted');
} else {
logger.warn(`Warning: Credentials file remains at: ${tempFile}`);
logger.warn('Warning: Remember to delete it manually when done!');
}
});
}
} catch (error) {
logger.error('Failed to view credentials:', error.message);
}
}
/**
* Edit AWS credentials through a secure temporary file
*/
async function editCredentials(profileName = 'default') {
try {
const profiles = loadConfig(AWS_PROFILES_FILE);
const profile = profiles[profileName] || { region: 'us-east-1' };
const isNewProfile = !profiles[profileName];
// Create temporary file with current credentials for editing
const tempDir = path.join(os.tmpdir(), 'cfn-forge-creds');
if (!fs.existsSync(tempDir)) {
fs.mkdirSync(tempDir, { recursive: true, mode: 0o700 });
}
const tempFile = path.join(tempDir, `${profileName}-edit.txt`);
const editContent = [
`# CFN-Forge AWS Credentials Editor - Profile: ${profileName}`,
'# Save and close this file to update credentials',
'# Delete any line to leave that field unchanged',
'# Lines starting with # are comments and will be ignored',
'',
'# Profile Configuration',
`PROFILE_NAME=${profileName}`,
`REGION=${profile.region || 'us-east-1'}`,
'',
'# AWS Credentials (leave blank to use external credentials)',
`ACCESS_KEY_ID=${profile.credentials ? decrypt(profile.credentials.accessKeyId) : ''}`,
`SECRET_ACCESS_KEY=${profile.credentials ? decrypt(profile.credentials.secretAccessKey) : ''}`,
`SESSION_TOKEN=${profile.credentials && profile.credentials.sessionToken ? decrypt(profile.credentials.sessionToken) : ''}`,
'',
'# Instructions:',
'# - Fill in your AWS credentials above',
'# - Save and close the file to apply changes',
'# - Leave ACCESS_KEY_ID empty to remove stored credentials',
'# - CFN-Forge will fall back to AWS CLI/environment credentials',
];
// Write to temporary file with restricted permissions
fs.writeFileSync(tempFile, editContent.join('\n'), { mode: 0o600 });
logger.info(`\n${isNewProfile ? 'Creating new' : 'Editing'} credentials for profile: ${profileName}`);
logger.info(`š Edit file: ${tempFile}`);
logger.warn('Warning: File contains sensitive data - will be deleted after editing');
let editor = process.env.EDITOR;
if (!editor) {
if (process.platform === 'win32') {
editor = 'notepad';
} else if (process.platform === 'darwin') {
editor = 'open -W -t';
} else {
editor = 'nano';
}
}
logger.info(`Opening with: ${editor}`);
logger.info('Save and close the file to apply changes');
const child = spawn(editor, [tempFile], {
stdio: 'inherit',
shell: true,
});
child.on('close', async () => {
try {
// Read and parse the edited file
const editedContent = fs.readFileSync(tempFile, 'utf8');
const credentials = parseCredentialFile(editedContent);
// Apply changes
if (credentials.ACCESS_KEY_ID && credentials.SECRET_ACCESS_KEY) {
// Store encrypted credentials
profiles[profileName] = {
region: credentials.REGION || 'us-east-1',
credentials: {
accessKeyId: encrypt(credentials.ACCESS_KEY_ID),
secretAccessKey: encrypt(credentials.SECRET_ACCESS_KEY),
sessionToken: credentials.SESSION_TOKEN ? encrypt(credentials.SESSION_TOKEN) : null,
},
updatedAt: new Date().toISOString(),
description: profile.description || null,
accountId: profile.accountId || null,
};
saveConfig(AWS_PROFILES_FILE, profiles);
logger.success(`Credentials updated for profile: ${profileName}`);
logger.warn('Warning: Credentials stored encrypted in ~/.cfn-forge/');
} else if (credentials.ACCESS_KEY_ID === '' && !isNewProfile) {
// Remove stored credentials (fall back to external)
delete profiles[profileName].credentials;
profiles[profileName].region = credentials.REGION || profile.region || 'us-east-1';
profiles[profileName].updatedAt = new Date().toISOString();
saveConfig(AWS_PROFILES_FILE, profiles);
logger.success(`Stored credentials removed for profile: ${profileName}`);
logger.info('Profile will now use AWS CLI/environment credentials');
} else {
logger.info('ā¹ļø No changes made to credentials');
}
// Clean up temporary file
fs.unlinkSync(tempFile);
logger.success('Temporary edit file deleted');
} catch (error) {
logger.error('Failed to process edited credentials:', error.message);
// Still clean up the temp file
try {
fs.unlinkSync(tempFile);
} catch (cleanupError) {
logger.warn('Could not delete temporary file:', tempFile);
}
}
});
} catch (error) {
logger.error('Failed to edit credentials:', error.message);
}
}
/**
* Parse credential file content
*/
function parseCredentialFile(content) {
const credentials = {};
const lines = content.split('\n');
for (const line of lines) {
const trimmed = line.trim();
if (trimmed && !trimmed.startsWith('#')) {
const [key, ...valueParts] = trimmed.split('=');
if (key && valueParts.length > 0) {
credentials[key.trim()] = valueParts.join('=').trim();
}
}
}
return credentials;
}