UNPKG

packmate

Version:

Your smart and friendly interactive assistant for dependency updates, security advisories, and cleanup.

489 lines (404 loc) 17.7 kB
import { multiselect, isCancel, cancel, note, confirm } from '@clack/prompts'; import chalk from 'chalk'; import semver from 'semver'; import { SEVERITY_LEVELS } from './security-checker.js'; const SECURITY_RANK = { [SEVERITY_LEVELS.CRITICAL]: 4, [SEVERITY_LEVELS.HIGH]: 3, [SEVERITY_LEVELS.MODERATE]: 2, [SEVERITY_LEVELS.LOW]: 1, [SEVERITY_LEVELS.INFO]: 0, }; function groupDirectVulnerabilities(vulnerabilities = []) { const grouped = new Map(); vulnerabilities .filter((vuln) => vuln.direct) .forEach((vuln) => { if (!grouped.has(vuln.packageName)) { grouped.set(vuln.packageName, []); } grouped.get(vuln.packageName).push(vuln); }); return Array.from(grouped.entries()); } function getTopSeverity(vulnerabilities = []) { return vulnerabilities.reduce((top, vuln) => { const severity = vuln.severity || SEVERITY_LEVELS.LOW; return SECURITY_RANK[severity] > SECURITY_RANK[top] ? severity : top; }, SEVERITY_LEVELS.INFO); } function countBySeverity(vulnerabilities = []) { return vulnerabilities.reduce((counts, vuln) => { const severity = vuln.severity || SEVERITY_LEVELS.LOW; counts[severity] = (counts[severity] || 0) + 1; return counts; }, {}); } function formatSeverityBreakdown(vulnerabilities = []) { const counts = countBySeverity(vulnerabilities); return [ counts[SEVERITY_LEVELS.CRITICAL] ? `${counts[SEVERITY_LEVELS.CRITICAL]} critical` : null, counts[SEVERITY_LEVELS.HIGH] ? `${counts[SEVERITY_LEVELS.HIGH]} high` : null, counts[SEVERITY_LEVELS.MODERATE] ? `${counts[SEVERITY_LEVELS.MODERATE]} moderate` : null, counts[SEVERITY_LEVELS.LOW] ? `${counts[SEVERITY_LEVELS.LOW]} low` : null, counts[SEVERITY_LEVELS.INFO] ? `${counts[SEVERITY_LEVELS.INFO]} info` : null, ].filter(Boolean).join(', '); } function severityColor(severity) { switch (severity) { case SEVERITY_LEVELS.CRITICAL: return chalk.red.bold; case SEVERITY_LEVELS.HIGH: return chalk.yellow; case SEVERITY_LEVELS.MODERATE: return chalk.yellow; case SEVERITY_LEVELS.LOW: case SEVERITY_LEVELS.INFO: default: return chalk.dim; } } function compactSecurityChoice(packageName, vulnerabilities, color) { const suffix = vulnerabilities.length > 1 ? ` ${vulnerabilities.length} advisories` : ''; const breakdown = formatSeverityBreakdown(vulnerabilities); return { label: `${color(packageName)}${chalk.dim(suffix)}`, value: packageName, hint: breakdown || 'security update recommended', }; } function getDirectSecurityGroups(securityResults) { return groupDirectVulnerabilities(securityResults.vulnerabilities || []) .map(([packageName, vulnerabilities]) => ({ packageName, vulnerabilities, topSeverity: getTopSeverity(vulnerabilities), })) .sort((a, b) => { const severityDiff = SECURITY_RANK[b.topSeverity] - SECURITY_RANK[a.topSeverity]; return severityDiff || a.packageName.localeCompare(b.packageName); }); } /** * Update Available Session - Grouped by Patch, Minor, Major updates */ export async function updateAvailableSession(packages, config) { if (!packages || packages.length === 0) { return []; } // Group packages by update type const patchUpdates = packages.filter((p) => { const diff = semver.diff(p.currentVersion, p.latestVersion); return diff === 'patch'; }); const minorUpdates = packages.filter((p) => { const diff = semver.diff(p.currentVersion, p.latestVersion); return diff === 'minor' || diff === 'preminor'; }); const majorUpdates = packages.filter((p) => { const diff = semver.diff(p.currentVersion, p.latestVersion); return diff === 'major' || diff === 'premajor'; }); const selected = []; // Patch Updates if (patchUpdates.length > 0) { console.log('\n' + chalk.cyan.bold(`🔹 Patch Updates (${patchUpdates.length})`)); console.log(chalk.cyan(' Bug fixes and safe updates')); const patchChoices = patchUpdates.map((pkg) => ({ label: `${chalk.bold(pkg.name)} ${chalk.gray(pkg.currentVersion)} ${chalk.white('→')} ${chalk.green(pkg.latestVersion)}`, value: pkg.name, })); const patchSelected = await multiselect({ message: 'Select patch updates (safe):', options: patchChoices, initialValues: config?.ui?.defaultChecked?.updateAvailable ? patchChoices.map((c) => c.value) : [], required: false, }); if (isCancel(patchSelected)) { cancel(chalk.red('Operation cancelled.')); process.exit(0); } patchSelected.forEach((name) => { const pkg = patchUpdates.find((p) => p.name === name); selected.push({ ...pkg, action: 'update' }); }); } // Minor Updates if (minorUpdates.length > 0) { console.log('\n' + chalk.yellow.bold(`🔸 Minor Updates (${minorUpdates.length})`)); console.log(chalk.yellow(' New features added - Backward compatible')); const minorChoices = minorUpdates.map((pkg) => ({ label: `${chalk.bold(pkg.name)} ${chalk.gray(pkg.currentVersion)} ${chalk.white('→')} ${chalk.yellow(pkg.latestVersion)}`, value: pkg.name, })); const minorSelected = await multiselect({ message: 'Select minor updates (relatively safe):', options: minorChoices, initialValues: config?.ui?.defaultChecked?.updateAvailable ? minorChoices.map((c) => c.value) : [], required: false, }); if (isCancel(minorSelected)) { cancel(chalk.red('Operation cancelled.')); process.exit(0); } minorSelected.forEach((name) => { const pkg = minorUpdates.find((p) => p.name === name); selected.push({ ...pkg, action: 'update' }); }); } // Major Updates if (majorUpdates.length > 0) { console.log('\n' + chalk.red.bold(`🔶 Major Updates (${majorUpdates.length})`)); console.log(chalk.red(' ⚠️ Breaking changes possible - Review carefully')); const majorChoices = majorUpdates.map((pkg) => ({ label: `${chalk.bold(pkg.name)} ${chalk.gray(pkg.currentVersion)} ${chalk.white('→')} ${chalk.red(pkg.latestVersion)}`, value: pkg.name, })); const majorSelected = await multiselect({ message: 'Select major updates (caution required):', options: majorChoices, required: false, }); if (isCancel(majorSelected)) { cancel(chalk.red('Operation cancelled.')); process.exit(0); } if (majorSelected.length > 0) { const confirmMajor = await confirm({ message: chalk.yellow(`⚠️ Proceed with ${majorSelected.length} major update(s)? Breaking changes may be included.`), }); if (isCancel(confirmMajor) || !confirmMajor) { note(chalk.yellow('Skipping major updates.'), 'Info'); } else { majorSelected.forEach((name) => { const pkg = majorUpdates.find((p) => p.name === name); selected.push({ ...pkg, action: 'update' }); }); } } } return selected; } /** * Unused Packages Session - Grouped by confidence level */ export async function unusedSession(unusedPackages, config) { if (!unusedPackages || unusedPackages.length === 0) { return []; } const highConfidence = unusedPackages.filter((p) => p.confidence === 'high'); const mediumConfidence = unusedPackages.filter((p) => p.confidence === 'medium'); const lowConfidence = unusedPackages.filter((p) => p.confidence === 'low'); const selected = []; // High Confidence if (highConfidence.length > 0) { console.log('\n' + chalk.red.bold(`🗑️ Unused Packages (High Confidence: ${highConfidence.length})`)); console.log(chalk.red(' Likely safe to remove')); const highChoices = highConfidence.map((pkg) => ({ label: `${chalk.red(pkg.name)} ${chalk.gray(pkg.current)}`, value: pkg.name, })); const highSelected = await multiselect({ message: 'Select packages to remove:', options: highChoices, initialValues: config?.ui?.defaultChecked?.unused ? highChoices.map((c) => c.value) : [], required: false, }); if (isCancel(highSelected)) { cancel(chalk.red('Operation cancelled.')); process.exit(0); } highSelected.forEach((name) => { const pkg = highConfidence.find((p) => p.name === name); selected.push({ ...pkg, action: 'remove' }); }); } // Medium Confidence if (mediumConfidence.length > 0) { console.log('\n' + chalk.yellow.bold(`⚠️ Possibly Unused (Medium Confidence: ${mediumConfidence.length})`)); console.log(chalk.yellow(' May be used by build tools or tests')); const mediumChoices = mediumConfidence.map((pkg) => ({ label: `${chalk.yellow(pkg.name)} ${chalk.gray(pkg.current)} ${chalk.dim('[Possibly Unused]')}`, value: pkg.name, hint: pkg.hint || 'Verification recommended', })); const proceedMedium = await confirm({ message: 'Review medium confidence packages?', initialValue: false, }); if (proceedMedium && !isCancel(proceedMedium)) { const mediumSelected = await multiselect({ message: 'Select packages to remove (caution):', options: mediumChoices, required: false, }); if (!isCancel(mediumSelected)) { mediumSelected.forEach((name) => { const pkg = mediumConfidence.find((p) => p.name === name); selected.push({ ...pkg, action: 'remove' }); }); } } } return selected; } /** * Not Installed Packages Session */ export async function notInstalledSession(packages, config) { if (!packages || packages.length === 0) { return []; } console.log('\n' + chalk.cyan.bold(`📥 Not Installed Packages (${packages.length})`)); console.log(chalk.cyan(' Declared in package.json but not installed')); const choices = packages.map((pkg) => ({ label: `${chalk.bold(pkg.name)} ${chalk.cyan('[Not Installed]')}`, value: pkg.name, })); const selected = await multiselect({ message: 'Select packages to install:', options: choices, initialValues: config?.ui?.defaultChecked?.notInstalled ? choices.map((c) => c.value) : [], required: false, }); if (isCancel(selected)) { cancel(chalk.red('Operation cancelled.')); process.exit(0); } return selected.map((name) => { const pkg = packages.find((p) => p.name === name); return { ...pkg, action: 'install' }; }); } /** * Latest Packages Session (Optional) */ export async function latestSession(packages, config) { if (!packages || packages.length === 0) { return []; } if (config?.ui?.showCurrentPackages !== true) { return []; } console.log('\n' + chalk.green.bold(`✅ Up-to-date Packages (${packages.length})`)); // Display as a simple list, not selectable packages.forEach((pkg) => { console.log(` ${chalk.green('✓')} ${chalk.bold(pkg.name)} ${chalk.gray(pkg.current)}`); }); return []; } /** * Security Vulnerabilities Session - Grouped by severity level */ export async function securitySession(securityResults, config, { excludePackages = [] } = {}) { const { summary } = securityResults; if (summary.total === 0) { console.log('\n' + chalk.green.bold('🛡️ Security Scan Complete')); console.log(chalk.green(' ✅ No vulnerabilities found!')); return []; } const selected = []; const excluded = new Set(excludePackages); const directGroups = getDirectSecurityGroups(securityResults) .filter((group) => !excluded.has(group.packageName)); const directTotal = directGroups.length; if (directTotal === 0) { return []; } const criticalGroups = directGroups.filter((group) => group.topSeverity === SEVERITY_LEVELS.CRITICAL); const highGroups = directGroups.filter((group) => group.topSeverity === SEVERITY_LEVELS.HIGH); const moderateGroups = directGroups.filter((group) => group.topSeverity === SEVERITY_LEVELS.MODERATE); const lowGroups = directGroups.filter((group) => group.topSeverity === SEVERITY_LEVELS.LOW || group.topSeverity === SEVERITY_LEVELS.INFO ); // Critical Vulnerabilities if (criticalGroups.length > 0) { console.log('\n' + chalk.red.bold(`Security Risk: Critical (${criticalGroups.length})`)); console.log(chalk.red(' Update immediately')); const criticalChoices = criticalGroups.map(({ packageName, vulnerabilities }) => compactSecurityChoice(packageName, vulnerabilities, chalk.red.bold) ); const criticalSelected = await multiselect({ message: chalk.red.bold('Select critical updates:'), options: criticalChoices, initialValues: config?.security?.autoSelectCritical !== false ? criticalChoices.map((c) => c.value) : [], required: false, }); if (isCancel(criticalSelected)) { cancel(chalk.red('Operation cancelled.')); process.exit(0); } criticalSelected.forEach((packageName) => { const group = criticalGroups.find((item) => item.packageName === packageName); selected.push({ packageName, vulnerabilities: group.vulnerabilities, action: 'update', priority: 'critical' }); }); } // High Vulnerabilities if (highGroups.length > 0) { console.log('\n' + chalk.yellow.bold(`Security Risk: High (${highGroups.length})`)); console.log(chalk.yellow(' Update recommended')); const highChoices = highGroups.map(({ packageName, vulnerabilities }) => compactSecurityChoice(packageName, vulnerabilities, chalk.yellow) ); const highSelected = await multiselect({ message: 'Select high priority updates:', options: highChoices, initialValues: config?.security?.autoSelectHigh !== false ? highChoices.map((c) => c.value) : [], required: false, }); if (isCancel(highSelected)) { cancel(chalk.red('Operation cancelled.')); process.exit(0); } highSelected.forEach((packageName) => { const group = highGroups.find((item) => item.packageName === packageName); selected.push({ packageName, vulnerabilities: group.vulnerabilities, action: 'update', priority: 'high' }); }); } // Moderate Vulnerabilities if (moderateGroups.length > 0) { console.log('\n' + chalk.yellow.bold(`Security Risk: Moderate (${moderateGroups.length})`)); console.log(chalk.yellow(' Review when convenient')); const moderateChoices = moderateGroups.map(({ packageName, vulnerabilities }) => compactSecurityChoice(packageName, vulnerabilities, chalk.yellow) ); const moderateSelected = await multiselect({ message: 'Select moderate updates:', options: moderateChoices, required: false, }); if (isCancel(moderateSelected)) { cancel(chalk.red('Operation cancelled.')); process.exit(0); } moderateSelected.forEach((packageName) => { const group = moderateGroups.find((item) => item.packageName === packageName); selected.push({ packageName, vulnerabilities: group.vulnerabilities, action: 'update', priority: 'moderate' }); }); } // Low Vulnerabilities (설정에 따라 표시) if (lowGroups.length > 0 && config?.security?.showLowPriority !== false) { console.log('\n' + chalk.dim.bold(`Security Risk: Low (${lowGroups.length})`)); const showLow = await confirm({ message: chalk.dim(`Review low-priority vulnerabilities?`), initialValue: false, }); if (showLow && !isCancel(showLow)) { const lowChoices = lowGroups.map(({ packageName, vulnerabilities, topSeverity }) => compactSecurityChoice(packageName, vulnerabilities, severityColor(topSeverity)) ); const lowSelected = await multiselect({ message: 'Select packages to update (low priority):', options: lowChoices, required: false, }); if (!isCancel(lowSelected)) { lowSelected.forEach((packageName) => { const group = lowGroups.find((item) => item.packageName === packageName); selected.push({ packageName, vulnerabilities: group.vulnerabilities, action: 'update', priority: 'low' }); }); } } } return selected; }