packmate
Version:
Your smart and friendly assistant for dependency updates and cleanup.
363 lines (335 loc) • 11.6 kB
JavaScript
import { select, multiselect, isCancel, cancel, intro, outro, note } from '@clack/prompts';
import chalk from 'chalk';
import fs from 'fs';
import path from 'path';
import { execSync } from 'child_process';
import semver from 'semver';
import { createRequire } from 'module';
import process from 'process';
import depcheck from 'depcheck';
import { getUpdateCandidates } from '../src/update-checker.js';
import { runUnusedCheck } from '../src/unused-checker.js';
import { detectPackageManager } from '../src/detect-package-manager.js';
import { installPackages, uninstallPackages } from '../src/install-helper.js';
import { runWithWarningCapture } from '../src/warning-capture.js';
const require = createRequire(import.meta.url);
// --- 버전 추출 ---
function getCurrentVersion(dep) {
try {
const mainPath = require.resolve(`${dep}/package.json`, { paths: [process.cwd()] });
if (mainPath && fs.existsSync(mainPath)) {
return JSON.parse(fs.readFileSync(mainPath, 'utf-8')).version;
}
} catch {}
try {
const pnpmDir = path.resolve(process.cwd(), 'node_modules', '.pnpm');
if (fs.existsSync(pnpmDir)) {
const found = fs.readdirSync(pnpmDir).find((f) => f.startsWith(dep + '@'));
if (found) {
const pkgPath = path.resolve(pnpmDir, found, 'node_modules', dep, 'package.json');
if (fs.existsSync(pkgPath)) {
return JSON.parse(fs.readFileSync(pkgPath, 'utf-8')).version;
}
}
}
} catch {}
return '-';
}
function getNotInstalledPackages() {
const pkgPath = path.resolve(process.cwd(), 'package.json');
if (!fs.existsSync(pkgPath)) return [];
const pkgJson = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
const allDeps = { ...pkgJson.dependencies, ...pkgJson.devDependencies };
const notInstalled = [];
for (const dep of Object.keys(allDeps)) {
const version = getCurrentVersion(dep);
if (!version || version === '-') notInstalled.push(dep);
}
return notInstalled;
}
// 메이저별 추천 버전 리스트
function getRecommendedMajorVersions(versionList) {
const byMajor = {};
versionList.forEach((ver) => {
const parsed = semver.parse(ver);
if (!parsed) return;
const major = parsed.major;
if (!byMajor[major] || semver.gt(ver, byMajor[major])) {
byMajor[major] = ver;
}
});
const recommended = Object.values(byMajor).sort((a, b) => semver.rcompare(a, b));
return recommended;
}
async function main() {
intro(chalk.cyan('📦 Packmate: Dependency Updates & Cleanup'));
// node_modules 체크
const nodeModulesPath = path.resolve(process.cwd(), 'node_modules');
if (!fs.existsSync(nodeModulesPath)) {
note(
chalk.yellow(
'⚠️ The node_modules directory is missing. Please install your dependencies first (e.g., npm install, yarn install, or pnpm install).',
),
'Warning',
);
process.exit(0);
}
const allPkgs = {};
const packageManager = detectPackageManager();
// 1. 업데이트 가능 패키지(최신버전만 조회, 전체버전x)
const updateCandidates = await getUpdateCandidates(packageManager); // 최신 버전만 한 번에 빠르게
updateCandidates.forEach((c) => {
allPkgs[c.name] = {
name: c.name,
current: c.currentVersion,
latest: c.latestVersion,
versions: null, // 전체 버전은 아직 조회 X
status: 'Update Available',
action: 'update',
};
});
// 2. 미사용 패키지(precinct + depcheck 병합)
const unused_precinct = await runUnusedCheck(); // precinct(기존)
// depcheck(깊은 탐지)
const depcheckResult = await depcheck(process.cwd(), {});
const unused_depcheck = depcheckResult.unusedDependencies || [];
// 병합: 확신/의심 unused 구분
const bothUnused = unused_precinct.filter((x) => unused_depcheck.includes(x));
const onlyPrecinct = unused_precinct.filter((x) => !unused_depcheck.includes(x));
const onlyDepcheck = unused_depcheck.filter((x) => !unused_precinct.includes(x));
bothUnused.forEach((dep) => {
if (allPkgs[dep]) return;
allPkgs[dep] = {
name: dep,
current: getCurrentVersion(dep),
latest: '-',
status: 'Unused (Strongly)',
action: 'remove',
confidence: 'high',
};
});
onlyPrecinct.forEach((dep) => {
if (allPkgs[dep]) return;
allPkgs[dep] = {
name: dep,
current: getCurrentVersion(dep),
latest: '-',
status: 'Unused (Precinct only)',
action: 'remove',
confidence: 'medium',
hint: 'precinct 방식에서만 감지됨',
};
});
onlyDepcheck.forEach((dep) => {
if (allPkgs[dep]) return;
allPkgs[dep] = {
name: dep,
current: getCurrentVersion(dep),
latest: '-',
status: 'Unused (Depcheck only)',
action: 'remove',
confidence: 'medium',
hint: 'depcheck 방식에서만 감지됨',
};
});
// 3. 미설치 패키지
const notInstalled = getNotInstalledPackages();
notInstalled.forEach((dep) => {
if (allPkgs[dep]) return;
allPkgs[dep] = {
name: dep,
current: '-',
latest: '-',
status: 'Not Installed',
action: 'install',
};
});
// 4. 이미 최신 패키지
const pkgJson = JSON.parse(fs.readFileSync(path.resolve(process.cwd(), 'package.json'), 'utf-8'));
const declared = { ...pkgJson.dependencies, ...pkgJson.devDependencies };
for (const dep of Object.keys(declared)) {
if (allPkgs[dep]) continue;
const current = getCurrentVersion(dep);
allPkgs[dep] = {
name: dep,
current,
latest: current,
status: 'Latest',
action: 'latest',
};
}
// action 우선순위에 맞게 정렬
const ACTION_ORDER = ['update', 'remove', 'install', 'latest'];
const pkgsSorted = Object.values(allPkgs).sort((a, b) => {
const aIdx = ACTION_ORDER.indexOf(a.action);
const bIdx = ACTION_ORDER.indexOf(b.action);
return aIdx - bIdx;
});
// ---- 프롬프트: 유저 선택 ----
const promptChoices = pkgsSorted.map((pkg) => {
switch (pkg.action) {
case 'install':
return {
label: `${chalk.bold(pkg.name)} ${chalk.cyan('[Not Installed]')}`,
value: `${pkg.name}__install`,
};
case 'update':
return {
label: `${chalk.bold(pkg.name)} ${chalk.yellow(pkg.current)} ${chalk.white('→')} ${chalk.green(pkg.latest)} ${chalk.blue('[Update Available]')}`,
value: `${pkg.name}__update`,
};
case 'remove':
if (pkg.confidence === 'high') {
return {
label: `${chalk.red(pkg.name)} ${chalk.red(pkg.current)} ${chalk.bgRedBright('[Strongly Unused]')}`,
value: `${pkg.name}__remove`,
checked: true,
};
} else {
return {
label: `${chalk.yellow(pkg.name)} ${chalk.yellow(pkg.current)} ${chalk.bgYellowBright('[Warning: Unused only by ' + (pkg.hint?.includes('precinct') ? 'Precinct' : 'Depcheck') + ']')}`,
value: `${pkg.name}__remove`,
hint: chalk.yellow(pkg.hint),
checked: false,
};
}
case 'latest':
default:
return {
label: `${chalk.bold(pkg.name)} ${chalk.green(pkg.current)} ${chalk.gray('[Latest]')}`,
value: `${pkg.name}__latest`,
disabled: true,
};
}
});
const selected = await multiselect({
message: 'Select the packages you want to update/remove/install:',
options: promptChoices,
required: false,
max: 30,
});
if (isCancel(selected)) {
cancel(chalk.red('Operation cancelled.'));
process.exit(0);
}
if (
selected.some((sel) =>
[...onlyPrecinct, ...onlyDepcheck].map((dep) => `${dep}__remove`).includes(sel),
)
) {
note(
chalk.yellow(
'⚠️ 한 쪽 방식에서만 unused로 감지된 패키지는, 빌드 도구/테스트/특수 환경에서 사용될 수 있으니 제거 전 주의하세요!',
),
'Warning',
);
}
const updateTo = [];
for (const sel of selected) {
if (sel.endsWith('__update')) {
const pkgName = sel.split('__')[0];
const pkg = allPkgs[pkgName];
// 이 시점에만 전체 버전 쿼리!
let versionList = [];
try {
const out = execSync(`npm view ${pkgName} versions --json`, { encoding: 'utf-8' });
versionList = JSON.parse(out).reverse();
} catch {
versionList = [pkg.latest];
}
const recommended = getRecommendedMajorVersions(versionList, pkg.current);
const options = [
...versionList
.filter((v) => recommended.includes(v))
.map((v) => ({
label: chalk.green(`${v} [recommended]`),
value: v,
})),
...versionList
.filter((v) => !recommended.includes(v))
.map((v) => ({
label: `${v}`,
value: v,
})),
];
const optionsUnique = options.filter(
(item, idx, arr) => arr.findIndex((o) => o.value === item.value) === idx,
);
let versionChoice;
if (optionsUnique.length > 1) {
versionChoice = await select({
message: `${pkgName} - choose a version to update (current: ${pkg.current}):`,
options: optionsUnique,
});
if (isCancel(versionChoice)) {
cancel(chalk.red('Operation cancelled.'));
process.exit(0);
}
updateTo.push({ name: pkgName, version: versionChoice });
} else {
updateTo.push({ name: pkgName, version: pkg.latest });
}
}
}
// 제거/설치할 패키지 목록 분리
const toRemove = selected
.filter((sel) => sel.endsWith('__remove'))
.map((sel) => sel.split('__')[0]);
const toInstall = selected
.filter((sel) => sel.endsWith('__install'))
.map((sel) => sel.split('__')[0]);
// 실제 업데이트/제거/설치 명령 실행(경고 메시지 실시간 캡처)
for (const item of updateTo) {
let cmd, args;
switch (packageManager) {
case 'pnpm':
cmd = 'pnpm';
args = ['add', `${item.name}@${item.version}`];
break;
case 'yarn':
cmd = 'yarn';
args = ['add', `${item.name}@${item.version}`];
break;
case 'npm':
default:
cmd = 'npm';
args = ['install', `${item.name}@${item.version}`];
break;
}
note(chalk.cyan(`${cmd} ${args.join(' ')}`), 'Command');
const { code, warnings } = await runWithWarningCapture(cmd, args);
if (code === 0) {
note(chalk.green(`✔️ Package update completed: ${item.name}@${item.version}`), 'Success');
} else {
note(chalk.red(`❌ Package update failed: ${item.name}@${item.version}`), 'Failed');
}
if (warnings.length) {
note(
chalk.yellow(
`⚠️ Detected warnings during install/update of ${item.name}:\n` +
warnings.map((w) => ' - ' + w).join('\n'),
),
'Warning',
);
}
}
if (toRemove.length) {
uninstallPackages(toRemove, packageManager);
}
if (toInstall.length) {
installPackages(toInstall, packageManager);
}
if (updateTo.length + toRemove.length + toInstall.length === 0) {
note(chalk.yellow('No operations selected.'), 'Info');
}
// 요약 출력 (unused 분포 등)
note(
chalk.gray(
`Unused packages (both: ${bothUnused.length}, precinct only: ${onlyPrecinct.length}, depcheck only: ${onlyDepcheck.length})`,
),
'Summary',
);
outro(chalk.bold.cyan('Packmate done! 🙌'));
}
main();