deprecopilot
Version:
Automated dependency management with AI-powered codemods
241 lines (240 loc) • 9.35 kB
JavaScript
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;
}