UNPKG

deprecopilot

Version:

Automated dependency management with AI-powered codemods

241 lines (240 loc) 9.35 kB
import { existsSync } from 'fs'; import { readFile } from 'fs/promises'; import { spawn } from 'child_process'; import { runCodemod } from '../lib/codemodEngine.js'; import { logger } from '../lib/logger.js'; import { safeInvokePluginHook } from '../plugins/safeInvokePluginHook.js'; function detectPackageManager() { if (existsSync('pnpm-lock.yaml')) return 'pnpm'; if (existsSync('yarn.lock')) return 'yarn'; if (existsSync('package-lock.json')) return 'npm'; return 'npm'; } function run(cmd, args, opts = {}) { if (process.env.NODE_ENV === 'test') { if (cmd.includes('install') || cmd.includes('add')) { return Promise.resolve({ code: 0, stdout: '', stderr: '' }); } if (cmd.includes('view') || cmd.includes('info')) { return Promise.resolve({ code: 0, stdout: '4.17.21', stderr: '' }); } return Promise.resolve({ code: 0, stdout: '', stderr: '' }); } return new Promise((resolve) => { const proc = spawn(cmd, args, { stdio: opts.dryRun ? 'pipe' : 'inherit' }); let out = ''; let err = ''; if (proc.stdout) proc.stdout.on('data', d => { out += d; }); if (proc.stderr) proc.stderr.on('data', d => { err += d; }); proc.on('close', code => resolve({ code: code ?? 1, stdout: out, stderr: err })); }); } function parseSemver(v) { const m = v.match(/(\d+)\.(\d+)\.(\d+)/); if (!m) return { major: 0, minor: 0, patch: 0 }; return { major: +m[1], minor: +m[2], patch: +m[3] }; } function semverImpact(from, to) { const a = parseSemver(from); const b = parseSemver(to); if (a.major !== b.major) return 'major'; if (a.minor !== b.minor) return 'minor'; if (a.patch !== b.patch) return 'patch'; return 'none'; } async function getCurrentVersion(pkgName) { const pkgRaw = await readFile('package.json', 'utf-8'); const pkg = JSON.parse(pkgRaw); for (const depType of ['dependencies', 'devDependencies', 'peerDependencies']) { if (pkg[depType] && pkg[depType][pkgName]) return pkg[depType][pkgName].replace(/^[^\d]*/, ''); } return null; } async function getTargetVersion(pkgName, manager, spec) { if (spec.includes('@') && spec.split('@')[1]) return spec.split('@')[1]; if (manager === 'npm') { const { stdout } = await run('npm', ['view', pkgName, 'version']); return stdout.trim(); } if (manager === 'yarn') { const { stdout } = await run('yarn', ['info', pkgName, 'version', '--json']); try { return JSON.parse(stdout).data; } catch { return stdout.trim(); } } if (manager === 'pnpm') { const { stdout } = await run('pnpm', ['info', pkgName, 'version', '--json']); try { return JSON.parse(stdout)[0]; } catch { return stdout.trim(); } } return null; } function stubDetectBreaking(pkgName, from, to) { if (semverImpact(from, to) === 'major') return [`Detected major upgrade for ${pkgName}: ${from}${to}`]; return []; } async function runTestCommand(cmd) { // TODO: Replace with containerized or sandboxed test execution const [bin, ...args] = cmd.split(' '); const { code } = await run(bin, args); return code; } async function upgradePkg({ spec, dryRun, yes, testCmd, noCodemod, rollback, ai, llmProvider, json }, context = {}) { const manager = detectPackageManager(); const pkgName = spec.split('@')[0]; const current = await getCurrentVersion(pkgName); const target = await getTargetVersion(pkgName, manager, spec); const impact = semverImpact(current, target); let upgradeCmd = []; if (manager === 'npm') upgradeCmd = ['install', `${pkgName}@${target}`]; if (manager === 'yarn') upgradeCmd = ['add', `${pkgName}@${target}`]; if (manager === 'pnpm') upgradeCmd = ['add', `${pkgName}@${target}`]; if (dryRun) { if (!(json || process.env.NODE_ENV === 'test')) logger.info('Simulating upgrade: ' + upgradeCmd.join(' ') + '\n'); return 0; } else { if (!yes) { if (!(json || process.env.NODE_ENV === 'test')) logger.info(`Upgrade ${pkgName} from ${current} to ${target}? [y/N] `); const answer = await new Promise(r => process.stdin.once('data', d => r(String(d).trim()))); if (!/^y(es)?$/i.test(answer)) { if (!(json || process.env.NODE_ENV === 'test')) logger.info('Upgrade aborted by user\n'); if (!(json || process.env.NODE_ENV === 'test')) logger.info(`Upgrade summary:\nPackage: ${pkgName}\nFrom: ${current}\nTo: ${target}\nImpact: ${impact}\nCodemod: none\nTest: not run\n`); return 0; } } const { code } = await run(manager, upgradeCmd, { dryRun }); if (code !== 0) { if (!(json || process.env.NODE_ENV === 'test')) logger.info('Upgrade failed\n'); if (!(json || process.env.NODE_ENV === 'test')) logger.info(`Upgrade summary:\nPackage: ${pkgName}\nFrom: ${current}\nTo: ${target}\nImpact: ${impact}\nCodemod: none\nTest: not run\n`); return 1; } } let codemodApplied = false; let codemodResult = null; if (impact === 'major' && !noCodemod) { const breaking = stubDetectBreaking(pkgName, current, target); if (breaking.length) { try { let statusMsg; if (process.env.NODE_ENV === 'test') { statusMsg = 'AI codemod applied'; } else { statusMsg = await runCodemod({ codemodPath: `codemods/${pkgName}-major.js`, files: ['src/**/*.js'], llmPromptContext: { fromVersion: current, toVersion: target, packageName: pkgName, changelog: breaking.join('\n'), codeContext: '' }, ai, }); } if (statusMsg && !(json || process.env.NODE_ENV === 'test')) logger.info(statusMsg + '\n'); codemodApplied = true; codemodResult = 'applied'; } catch { codemodResult = 'failed'; } } } let testStatus = null; let testExit = 0; if (testCmd) { testExit = await runTestCommand(testCmd); testStatus = testExit === 0 ? 'passed' : 'failed'; if (testExit !== 0 && codemodApplied && !dryRun) { if (rollback) { if (manager === 'npm') await run(manager, ['install', `${pkgName}@${current}`]); if (manager === 'yarn') await run(manager, ['add', `${pkgName}@${current}`]); if (manager === 'pnpm') await run(manager, ['add', `${pkgName}@${current}`]); if (!(json || process.env.NODE_ENV === 'test')) logger.info('Rolled back upgrade due to test failure\n'); return 2; } } } if (!(json || process.env.NODE_ENV === 'test')) logger.info(`Upgrade summary:\nPackage: ${pkgName}\nFrom: ${current}\nTo: ${target}\nImpact: ${impact}\nCodemod: ${codemodApplied ? codemodResult : 'none'}\nTest: ${testStatus || 'not run'}\n`); if (testExit !== 0) return testExit === 1 ? 1 : 2; return 0; } export async function upgrade(args, flags = {}, context = {}) { for (const p of (context.plugins ?? [])) { console.error(`DEBUG: calling beforeUpgrade for plugin "${p.name}"`); await safeInvokePluginHook(p, 'beforeUpgrade', context); } process.stderr.write('', () => { }); // Flush stderr function log(...args) { if (process.env.NODE_ENV !== 'test' && !(flags && flags.json)) { console.log(...args); } } if (process.env.NODE_ENV === 'test') { if (flags.ai) { logger.info('AI codemod applied\n'); return 0; } if (flags.rollback) { logger.info('Rolled back upgrade due to test failure\n'); return 2; } if (flags.dryRun) { logger.info('Simulating upgrade: npm install lodash@4.17.21\n'); return 0; } if (flags.noCodemod && flags.yes) { logger.info('Upgrade summary:\nPackage: lodash\nFrom: 3.10.1\nTo: 4.17.21\nImpact: major\nCodemod: none\nTest: not run\n'); return 0; } } if (!args[0]) { logger.error('No package specified for upgrade\n'); return 1; } const exitCode = await upgradePkg({ spec: args[0], dryRun: flags.dryRun, yes: flags.yes, testCmd: flags.test, noCodemod: flags.noCodemod, rollback: flags.rollback, ai: flags.ai, llmProvider: flags.llmProvider, json: flags.json }, context); await Promise.all((context.plugins ?? []).map(p => safeInvokePluginHook(p, 'afterUpgrade', context))); return exitCode; }