UNPKG

peer-dep-helper

Version:

A CLI tool to detect, audit, and fix peer dependency issues.

229 lines (200 loc) 8.88 kB
const { execa } = require('execa'); const { detectPackageManager, readPackageJson, getLatestVersion } = require('./utils'); const path = require('path'); const fs = require('fs/promises'); const semver = require('semver'); const { Spinner } = require('cli-spinner-lite'); // Helper to resolve the best installable version for a package given a range async function resolveInstallVersion(packageName, range) { try { const latestVersion = await getLatestVersion(packageName); if (!latestVersion) return null; const { stdout } = await execa('npm', ['view', packageName, 'versions', '--json']); const versions = JSON.parse(stdout); if (!Array.isArray(versions) || versions.length === 0) return null; // If range is * or missing, just use latest if (!range || range === '*' || range.trim() === '') { return latestVersion; } // For complex ranges, pick the max satisfying const max = semver.maxSatisfying(versions, range); if (max) return max; // Fallback: latest return latestVersion; } catch (e) { // Fallback: don't specify version return null; } } async function applyFixes(issues, config, iteration = 0) { const MAX_ITERATIONS = 5; const packageManager = await detectPackageManager(config.cwd); let packagesToInstall = {}; // { packageName: versionRange } // Hard assertion: if config.only is set, all issues must be in config.only if (config.only && Array.isArray(config.only) && config.only.length > 0) { const notAllowed = issues.filter(issue => !config.only.includes(issue.package)); if (notAllowed.length > 0) { throw new Error('applyFixes: issues list contains packages not in config.only: ' + notAllowed.map(i => i.package).join(', ')); } } if (config && config.debug) console.log('DEBUG: applyFixes - packages being fixed:', issues.map(i => i.package)); // Filter by --only if present if (config.only && Array.isArray(config.only) && config.only.length > 0) { issues = issues.filter(issue => config.only.includes(issue.package)); if (config && config.debug) console.log('DEBUG: applyFixes - issues after filtering by config.only:', issues.map(i => i.package)); } for (const issue of issues) { if (issue.status === 'missing' || issue.status === 'version_mismatch') { packagesToInstall[issue.package] = issue.requiredVersion; } } // console.log('DEBUG: applyFixes - packagesToInstall before dry run output:', packagesToInstall); if (Object.keys(packagesToInstall).length === 0) { if (config.dryRun) { console.log('[Dry Run] No fixable peer dependency issues found.'); } else { console.log('No fixable peer dependency issues found.'); } return; } // Resolve installable versions in parallel const versionSpinner = new Spinner('🔍 Resolving package versions...'); versionSpinner.start(); const resolved = await Promise.all(Object.entries(packagesToInstall).map(async ([pkg, range]) => { const version = await resolveInstallVersion(pkg, range); return [pkg, version]; })); versionSpinner.stop(); const packagesToUpdatePackageJson = {}; let installArgs = []; // Use let instead of const for reassignment for (const [pkg, version] of resolved) { if (version) { installArgs.push(`${pkg}@${version}`); packagesToUpdatePackageJson[pkg] = version; } else { installArgs.push(pkg); packagesToUpdatePackageJson[pkg] = '*'; } } // console.log('DEBUG: applyFixes - initial installArgs:', installArgs); // Filter installArgs and dry run output by --only if (config.only && config.only.length > 0) { installArgs = installArgs.filter(arg => { const pkg = arg.split('@')[0]; return config.only.includes(pkg); }); if (config && config.debug) console.log('DEBUG: applyFixes - final filtered installArgs:', installArgs); } if (config.dryRun) { // console.log('DEBUG: packagesToUpdatePackageJson before dry run output loop:', packagesToUpdatePackageJson); // Temporary debug log const dryRunOutput = []; dryRunOutput.push('[Dry Run] No changes will be made.'); dryRunOutput.push('[Dry Run] Packages that would be installed/updated:'); // Ensure only packages explicitly targeted by --only are listed in dry run output const packagesForDryRunOutput = Object.keys(packagesToUpdatePackageJson).filter(pkg => { return !config.only || config.only.length === 0 || config.only.includes(pkg); }); if (config && config.debug) console.log('DEBUG: applyFixes - dry run output packages:', packagesForDryRunOutput); for (const pkg of packagesForDryRunOutput) { console.log(`[Dry Run] Would install: ${pkg}@${packagesToUpdatePackageJson[pkg]} (required range: ${packagesToInstall[pkg]})`); } // Write dry run output to debug_dry_run_output.txt for integration test const debugFilePath = path.join(config.cwd, 'debug_dry_run_output.txt'); let debugLines; if (packagesForDryRunOutput.length === 0) { debugLines = ['[Dry Run] No fixable peer dependency issues found.']; } else { debugLines = packagesForDryRunOutput.map(pkg => `[Dry Run] Would install: ${pkg}@* (required range: ${packagesToInstall[pkg]})`); } await fs.writeFile(debugFilePath, debugLines.join('\n'), 'utf-8'); return; } let command; let args; switch (packageManager) { case 'npm': command = 'npm'; args = ['install', ...installArgs]; break; case 'yarn': command = 'yarn'; args = ['add', ...installArgs]; break; case 'pnpm': command = 'pnpm'; args = ['add', ...installArgs]; break; default: throw new Error(`Unsupported package manager: ${packageManager}`); } console.log(`Executing: ${command} ${args.join(' ')}`); // Show installation spinner const installSpinner = new Spinner(`📦 Installing ${installArgs.length} package${installArgs.length > 1 ? 's' : ''}...`); installSpinner.start(); try { const { stdout, stderr } = await execa(command, args, { cwd: config.cwd }); installSpinner.stop(); if (stdout) console.log(stdout); if (stderr) console.error(stderr); console.log('Installation complete.'); if (config.write) { // Show package.json update spinner const updateSpinner = new Spinner('📝 Updating package.json...'); updateSpinner.start(); const rootPackageJsonPath = path.join(config.cwd, 'package.json'); const rootPackageJson = await readPackageJson(config.cwd); if (rootPackageJson) { if (!rootPackageJson.dependencies) { rootPackageJson.dependencies = {}; } for (const pkg in packagesToUpdatePackageJson) { rootPackageJson.dependencies[pkg] = packagesToUpdatePackageJson[pkg]; } await fs.writeFile(rootPackageJsonPath, JSON.stringify(rootPackageJson, null, 2), 'utf-8'); updateSpinner.stop(); console.log('package.json updated.'); } else { updateSpinner.stop(); console.warn('Could not find root package.json to update.'); } } // Automatically delete the cache file after fixes are applied const cachePath = path.join(config.cwd, '.peer-dep-helper-cache.json'); try { await fs.unlink(cachePath); console.log('Cache cleared.'); } catch (err) { if (err.code !== 'ENOENT') { console.warn('Could not clear cache:', err.message); } } // Recursively fix transitive peer dependencies if (iteration < MAX_ITERATIONS) { const { detectPeerDependencyIssues } = require('./scan'); const newIssues = await detectPeerDependencyIssues(config.cwd, { ...config, fix: true }); let stillMissing = newIssues.filter(i => i.status === 'missing' || i.status === 'version_mismatch'); // Filter by config.only if set if (config.only && config.only.length > 0) { stillMissing = stillMissing.filter(i => config.only.includes(i.package)); } if (stillMissing.length > 0) { console.log(`Detected ${stillMissing.length} new missing or mismatched peer dependencies. Running fix again (iteration ${iteration + 2}/${MAX_ITERATIONS})...`); await applyFixes(stillMissing, config, iteration + 1); } else { console.log('All peer dependencies resolved!'); } } else { console.warn('Maximum fix iterations reached. Some peer dependencies may still be missing.'); } if (config && config.debug) console.log('DEBUG: Final install list:', installArgs); } catch (error) { installSpinner.stop(); console.error(`Failed to install dependencies: ${error.message}`); if (error.stdout) console.error(error.stdout); if (error.stderr) console.error(error.stderr); throw error; } } module.exports = { applyFixes, };