cfn-forge
Version:
CloudFormation deployment automation tool with git-based workflows
574 lines (492 loc) ⢠17.9 kB
JavaScript
/**
* Update command implementation
* Handles updating cfn-forge from GitHub repository
*/
const { execSync } = require('child_process');
const semver = require('semver');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { logger } = require('../utils/logger');
const pkg = require('../../../package.json');
const GITHUB_REPO = 'dmleblanc/cfn-forge';
const DEFAULT_BRANCH = 'main';
const TOKEN_FILE = path.join(os.homedir(), '.cfn-forge', 'github-token');
/**
* Get stored GitHub token
*/
function getStoredToken() {
try {
if (fs.existsSync(TOKEN_FILE)) {
return fs.readFileSync(TOKEN_FILE, 'utf8').trim();
}
} catch (error) {
logger.debug(`Could not read stored token: ${error.message}`);
}
return null;
}
/**
* Store GitHub token securely
*/
function storeToken(token) {
try {
const tokenDir = path.dirname(TOKEN_FILE);
if (!fs.existsSync(tokenDir)) {
fs.mkdirSync(tokenDir, { recursive: true });
}
fs.writeFileSync(TOKEN_FILE, token, { mode: 0o600 });
logger.debug('GitHub token stored securely');
return true;
} catch (error) {
logger.warn(`Could not store token: ${error.message}`);
return false;
}
}
/**
* Prompt for GitHub access token
*/
async function promptForToken() {
logger.info('\nš GitHub Access Token Required');
logger.info('This repository is private and requires authentication.');
logger.info('Please provide a GitHub Personal Access Token with repo access.');
logger.info('');
logger.info('To create a token:');
logger.info('1. Go to https://github.com/settings/tokens');
logger.info('2. Click "Generate new token" ā "Generate new token (classic)"');
logger.info('3. Select "repo" scope for private repository access');
logger.info('4. Copy the generated token');
logger.info('');
const inquirer = require('inquirer');
const { token } = await inquirer.prompt([{
type: 'password',
name: 'token',
message: 'GitHub Personal Access Token:',
validate: (input) => {
if (!input || input.length < 10) {
return 'Please enter a valid GitHub token';
}
return true;
},
}]);
const shouldStore = await logger.confirm(
'Store this token securely for future use?',
true,
);
if (shouldStore) {
storeToken(token);
logger.success('Token stored in ~/.cfn-forge/github-token');
}
return token;
}
/**
* Get latest commit info from GitHub API
*/
async function getLatestCommitInfo(branch = DEFAULT_BRANCH, token = null) {
try {
const https = require('https');
const url = `https://api.github.com/repos/${GITHUB_REPO}/commits/${branch}`;
const headers = {
'User-Agent': 'cfn-forge-cli',
};
if (token) {
headers.Authorization = `token ${token}`;
}
return new Promise((resolve, reject) => {
const req = https.get(url, { headers }, (res) => {
let data = '';
res.on('data', (chunk) => {
data += chunk;
});
res.on('end', () => {
try {
const parsed = JSON.parse(data);
// Handle GitHub API errors
if (parsed.message && parsed.message.includes('Not Found')) {
// This could mean the repo is private or the branch doesn't exist
// We'll let the calling code handle authentication retry
reject(new Error('PRIVATE_REPO_OR_BRANCH_NOT_FOUND'));
return;
}
if (parsed.message && parsed.status) {
reject(new Error(`GitHub API Error: ${parsed.message}`));
return;
}
if (!parsed.sha || !parsed.commit) {
throw new Error('Invalid response from GitHub API');
}
resolve({
sha: parsed.sha.substring(0, 7),
message: parsed.commit.message.split('\n')[0],
date: parsed.commit.committer.date,
author: parsed.commit.author.name,
});
} catch (error) {
logger.debug(`GitHub API response: ${data}`);
reject(error);
}
});
});
req.on('error', reject);
req.setTimeout(5000, () => {
req.destroy();
reject(new Error('Request timeout'));
});
});
} catch (error) {
logger.debug(`Failed to fetch commit info: ${error.message}`);
return null;
}
}
/**
* Get current installed version info
*/
function getCurrentVersionInfo() {
try {
// Try to get git commit if installed from git
const gitCommit = execSync('npm list -g cfn-forge --depth=0 2>/dev/null | grep cfn-forge', {
encoding: 'utf8',
stdio: 'pipe',
});
const match = gitCommit.match(/git\+.*#([a-f0-9]+)/);
if (match) {
return {
version: pkg.version,
commit: match[1].substring(0, 7),
source: 'git',
};
}
} catch (error) {
// Ignore errors, fall back to package version
}
return {
version: pkg.version,
commit: null,
source: 'npm',
};
}
/**
* Validate update can be performed safely
*/
async function validateUpdate(installUrl) {
try {
// Test if we can access the repository without actually installing
logger.debug('Validating update source...');
// Create a temporary directory for validation
const tempDir = path.join(os.tmpdir(), `cfn-forge-validate-${Date.now()}`);
fs.mkdirSync(tempDir, { recursive: true });
try {
// Test clone to validate access
execSync(`git clone --depth 1 "${installUrl.replace('git+', '').replace('#dev', '')}" "${tempDir}"`, {
stdio: 'pipe',
encoding: 'utf8',
});
// Clean up temp directory
execSync(`rm -rf "${tempDir}"`);
return true;
} catch (error) {
// Clean up temp directory on failure
try {
execSync(`rm -rf "${tempDir}"`);
} catch (cleanupError) {
// Ignore cleanup errors
}
throw error;
}
} catch (error) {
logger.debug(`Validation failed: ${error.message}`);
return false;
}
}
/**
* Robust atomic update mechanism
*/
async function performAtomicUpdate(installUrl, currentVersion) {
logger.info('š”ļø Starting atomic update process...');
// Step 1: Validate update source
logger.info('1ļøā£ Validating update source...');
const isValid = await validateUpdate(installUrl);
if (!isValid) {
throw new Error('Update source validation failed. Cannot proceed safely.');
}
logger.success('ā
Update source validated');
// Step 2: Create backup information
logger.info('2ļøā£ Preparing update...');
const backupInfo = {
version: currentVersion,
timestamp: new Date().toISOString(),
installUrl,
};
const backupPath = path.join(os.tmpdir(), `cfn-forge-backup-${Date.now()}.json`);
fs.writeFileSync(backupPath, JSON.stringify(backupInfo, null, 2));
logger.debug(`Backup info stored at: ${backupPath}`);
// Step 3: Download and prepare new version (without installing)
logger.info('3ļøā£ Downloading new version...');
const tempInstallDir = path.join(os.tmpdir(), `cfn-forge-new-${Date.now()}`);
try {
// Clone to temporary location to verify it works
const repoUrl = installUrl.replace('git+', '').split('#')[0];
const branch = installUrl.includes('#') ? installUrl.split('#')[1] : 'main';
execSync(`git clone --depth 1 --branch ${branch} "${repoUrl}" "${tempInstallDir}"`, {
stdio: 'pipe',
encoding: 'utf8',
});
// Verify package.json exists and is valid
const newPackageJson = path.join(tempInstallDir, 'package.json');
if (!fs.existsSync(newPackageJson)) {
throw new Error('Invalid package: no package.json found');
}
const newPkg = JSON.parse(fs.readFileSync(newPackageJson, 'utf8'));
logger.success(`ā
New version ${newPkg.version} ready for installation`);
// Step 4: Perform atomic swap
logger.info('4ļøā£ Installing new version...');
// Use npm pack approach for reliability
const originalCwd = process.cwd();
process.chdir(tempInstallDir);
try {
execSync('npm pack', { stdio: 'pipe', encoding: 'utf8' });
const tarball = `${newPkg.name}-${newPkg.version}.tgz`;
// Uninstall old version
logger.info('šļø Removing old version...');
execSync('npm uninstall -g cfn-forge', { stdio: 'pipe', encoding: 'utf8' });
// Install new version
logger.info('š¦ Installing new version...');
execSync(`npm install -g "${tarball}"`, { stdio: 'pipe', encoding: 'utf8' });
// Step 5: Verify installation
logger.info('5ļøā£ Verifying installation...');
let newVersion;
try {
const versionOutput = execSync('cfn-forge --version', { encoding: 'utf8', stderr: 'pipe' });
// Extract version number from output, handling any format
const lines = versionOutput.trim().split('\n');
newVersion = lines[lines.length - 1].replace(/[^0-9.]/g, '');
if (!newVersion || !newVersion.match(/^\d+\.\d+\.\d+$/)) {
// If we can't parse version, but command succeeded, assume it worked
newVersion = 'installed successfully';
}
} catch (versionError) {
// If version command fails, the installation likely failed
throw new Error(`Installation verification failed: ${versionError.message}`);
}
// Success!
logger.info('');
logger.success('š ======================================');
logger.success('š CFN-FORGE UPDATE SUCCESSFUL! ');
logger.success('š ======================================');
logger.info('');
logger.info('š Version Update:');
logger.info(` Old: ${currentVersion}`);
logger.info(` New: ${newVersion}`);
logger.info('');
logger.success('ā
Update completed successfully!');
logger.info('š You can now use the latest cfn-forge features');
logger.info('');
// Send notification (macOS)
if (process.platform === 'darwin') {
try {
execSync(`osascript -e 'display notification "CFN-Forge updated successfully to ${newVersion}" with title "Update Complete" sound name "Glass"'`, {
stdio: 'ignore',
});
} catch (notifError) {
// Ignore notification errors
}
}
} finally {
process.chdir(originalCwd);
}
} catch (error) {
logger.error('ā Update failed during installation');
logger.info('š Attempting recovery...');
// Attempt to restore from backup info if possible
try {
// Try to reinstall the original version using repository method
logger.info('š¦ Restoring original installation...');
execSync(`npm install -g git+https://github.com/${GITHUB_REPO}.git#${backupInfo.version}`, {
stdio: 'pipe',
encoding: 'utf8',
});
logger.warn('ā ļø Restored to previous version. Update failed but system is stable.');
} catch (restoreError) {
logger.error('ā Recovery failed. Manual reinstallation required:');
logger.info(` cd /path/to/cfn-forge && npm pack && npm install -g cfn-forge-${currentVersion}.tgz`);
}
throw error;
} finally {
// Cleanup temporary files
try {
execSync(`rm -rf "${tempInstallDir}"`);
fs.unlinkSync(backupPath);
} catch (cleanupError) {
// Ignore cleanup errors
}
}
}
/**
* Install cfn-forge from GitHub
*/
async function installFromGitHub(branch, commit, token = null) {
let installUrl;
if (commit) {
installUrl = token
? `git+https://${token}@github.com/${GITHUB_REPO}.git#${commit}`
: `git+https://github.com/${GITHUB_REPO}.git#${commit}`;
} else {
installUrl = token
? `git+https://${token}@github.com/${GITHUB_REPO}.git#${branch}`
: `git+https://github.com/${GITHUB_REPO}.git#${branch}`;
}
// For token-based URLs, we need to adjust format for git clone
const repoUrl = installUrl.replace('git+', '');
logger.debug(`Install URL: ${installUrl}`);
// Use robust atomic update mechanism
try {
await performAtomicUpdate(repoUrl, pkg.version);
} catch (error) {
if (error.message.includes('permission denied')) {
logger.error('Permission denied. Try running with sudo or configure npm permissions.');
logger.info('See: https://docs.npmjs.com/resolving-eacces-permissions-errors-when-installing-packages-globally');
} else if (error.message.includes('publickey') || error.message.includes('authentication')) {
logger.error('GitHub authentication failed. Make sure your token has proper permissions.');
logger.info('Run: cfn-forge update --token (to update your token)');
} else {
logger.error(`Installation failed: ${error.message}`);
logger.info('š” You can try the manual approach:');
logger.info(' cd /path/to/cfn-forge-repo && npm pack && npm install -g cfn-forge-*.tgz');
}
throw error;
}
}
/**
* Check for available updates
*/
async function checkForUpdates(options = {}) {
const branch = options.branch || DEFAULT_BRANCH;
logger.info(`Checking for updates on ${branch} branch...`);
const currentInfo = getCurrentVersionInfo();
logger.info(`Current version: ${currentInfo.version}${currentInfo.commit ? ` (${currentInfo.commit})` : ''}`);
// Try with stored token first
let token = getStoredToken();
let latestInfo = await getLatestCommitInfo(branch, token);
let authError = null;
// If API call failed, check if it's an authentication issue
if (!latestInfo) {
try {
// Try to get the error details
await getLatestCommitInfo(branch, token);
} catch (error) {
authError = error;
}
}
// If API call failed and it looks like authentication is needed
if (!latestInfo && (!token || authError?.message?.includes('PRIVATE_REPO_OR_BRANCH_NOT_FOUND'))) {
logger.warn('Repository appears to be private or requires authentication');
const shouldAuthenticate = await logger.confirm(
'Would you like to provide a GitHub access token?',
true,
);
if (shouldAuthenticate) {
token = await promptForToken();
try {
latestInfo = await getLatestCommitInfo(branch, token);
} catch (error) {
if (error.message.includes('PRIVATE_REPO_OR_BRANCH_NOT_FOUND')) {
logger.error(`Branch '${branch}' not found in repository ${GITHUB_REPO}`);
logger.info('Make sure the branch name is correct and you have access to the repository');
}
}
}
}
if (!latestInfo) {
logger.warn('Could not fetch latest commit information from GitHub API');
logger.info('You can still update by running: cfn-forge update --force');
return { hasUpdate: false, apiError: true, token };
}
logger.info(`Latest commit: ${latestInfo.sha} - ${latestInfo.message}`);
logger.info(`Author: ${latestInfo.author} on ${new Date(latestInfo.date).toLocaleDateString()}`);
const hasUpdate = currentInfo.commit !== latestInfo.sha;
// Additional check: if we don't have commit info, always allow update
const shouldAllowUpdate = hasUpdate || !currentInfo.commit;
if (shouldAllowUpdate) {
if (hasUpdate) {
logger.success('š¦ Update available!');
} else {
logger.success('š Reinstall available (same commit, ensuring latest)');
}
} else {
logger.success('ā
You have the latest version');
}
return {
hasUpdate: shouldAllowUpdate,
current: currentInfo,
latest: latestInfo,
token,
};
}
/**
* Main update command handler
*/
async function updateCfnForge(options = {}) {
try {
logger.section('CFN-Forge Update');
const branch = options.branch || DEFAULT_BRANCH;
const checkOnly = options.check;
const { commit } = options;
// Handle token option
if (options.token !== undefined) {
if (typeof options.token === 'string') {
// Token provided directly as argument
storeToken(options.token);
logger.success('GitHub token stored successfully');
} else {
// No token provided, prompt for it
await promptForToken();
logger.success('GitHub token updated successfully');
}
return;
}
if (checkOnly) {
await checkForUpdates({ branch });
return;
}
if (commit) {
logger.info(`Updating to specific commit: ${commit}`);
const token = getStoredToken();
await installFromGitHub(branch, commit, token);
return;
}
// Check for updates first
const updateInfo = await checkForUpdates({ branch });
if (updateInfo.apiError && !options.force) {
logger.info('Cannot check for updates. Use --force to install anyway.');
return;
}
if (!updateInfo.hasUpdate && !options.force && !updateInfo.apiError) {
logger.info('No update needed. Use --force to reinstall anyway.');
return;
}
if (updateInfo.hasUpdate) {
logger.info(`Installing update: ${updateInfo.latest.sha}`);
logger.info(`Change: ${updateInfo.latest.message}`);
}
// Confirm update (unless --yes flag is passed)
if (!process.argv.includes('--yes') && !process.argv.includes('-y')) {
const shouldUpdate = await logger.confirm(
`Update cfn-forge from ${branch} branch?`,
true,
);
if (!shouldUpdate) {
logger.info('Update cancelled');
return;
}
}
await installFromGitHub(branch, commit, updateInfo.token);
logger.success('š Update complete!');
logger.info('Run cfn-forge --help to see available commands');
} catch (error) {
logger.error(`Update failed: ${error.message}`);
if (process.env.DEBUG) {
console.error(error.stack);
}
process.exit(1);
}
}
module.exports = { updateCfnForge };