UNPKG

cfn-forge

Version:

CloudFormation deployment automation tool with git-based workflows

574 lines (492 loc) • 17.9 kB
/** * 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 };