@gordon1210/depup
Version:
a dependency upgrade tool for node projects
308 lines (307 loc) • 13.5 kB
JavaScript
import { execSync } from "child_process";
import fs from "fs/promises";
import { globby } from "globby";
import pLimit from "p-limit";
import packageJson from "package-json";
import path from "path";
import { useEffect, useState } from "react";
import semver from "semver";
import { detectWorkspaces, getDisplayVersion } from "../utils.js";
export function usePackageData() {
const [packages, setPackages] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
(async () => {
try {
const root = process.cwd();
const workspaces = await detectWorkspaces(root);
const packageJsonPaths = workspaces.length > 0
? await globby(workspaces.map((ws) => `${ws}/package.json`), { cwd: root, absolute: true })
: [path.join(root, "package.json")];
// const pkgs: PackageInfo[] = [];
// Create a concurrency limit with a lower number to prevent rate limiting
const limit = pLimit(5);
const tasks = [];
for (const pkgPath of packageJsonPaths) {
tasks.push(processPackageJson(pkgPath, setPackages, limit));
}
// Process package.json files sequentially to avoid race conditions
for (let i = 0; i < tasks.length; i += 3) {
await Promise.all(tasks.slice(i, i + 3));
}
//setPackages(pkgs);
setLoading(false);
}
catch (error) {
console.error("Error scanning dependencies:", error);
setError(error);
setLoading(false);
}
})();
// Add a safety timeout to prevent infinite loading
const timeoutId = setTimeout(() => {
setLoading(false);
}, 120000); // 2 minute timeout
return () => clearTimeout(timeoutId);
}, []);
const updatePackages = (updatedPackages) => {
setPackages(updatedPackages);
};
// Helper function to get clean version without prefixes for installation
const getInstallVersion = (pkg) => {
// First try the selected version type
let version;
switch (pkg.targetVersionType) {
case "patch":
version = pkg.patchVersion;
break;
case "minor":
version = pkg.minorVersion;
break;
case "latest":
version = !semver.prerelease(pkg.latestVersion)
? pkg.latestVersion
: undefined;
break;
case "prerelease":
version = pkg.prereleaseVersion;
break;
}
// If the selected version type doesn't have a value, implement a fallback strategy
if (!version) {
// Try in order: patch -> minor -> latest -> prerelease
version =
pkg.patchVersion ||
pkg.minorVersion ||
(!semver.prerelease(pkg.latestVersion)
? pkg.latestVersion
: undefined) ||
pkg.prereleaseVersion;
// If we found a fallback version, log it
if (version) {
console.warn(`No ${pkg.targetVersionType} version available for ${pkg.name}. ` +
`Falling back to ${version}.`);
}
}
return version;
};
const updateDependencies = async (toUpdate) => {
const updates = new Map();
// First pass: collect all updates to make
for (const pkg of toUpdate) {
const relPath = path.relative(process.cwd(), pkg.packagePath);
const displayVersion = getDisplayVersion(pkg);
// Skip if we don't have a valid display version
if (!displayVersion) {
console.warn(`Skipping ${pkg.name}: Unable to determine display version.`);
continue;
}
const installVersion = getInstallVersion(pkg);
// Skip if we don't have a valid installation version
if (!installVersion) {
console.warn(`Skipping ${pkg.name}: Unable to determine installation version.`);
continue;
}
// Skip if current version is the same as target version
const currentSemver = semver.valid(semver.coerce(pkg.currentVersion)) || pkg.currentVersion;
const targetSemver = semver.valid(semver.coerce(installVersion)) || installVersion;
if (currentSemver === targetSemver ||
semver.eq(currentSemver, targetSemver)) {
console.log(`\nSkipping ${pkg.name} in ${relPath || "."}: Already at version ${currentSemver}`);
continue;
}
// Save this update for the specific package.json file
if (!updates.has(pkg.packageJsonPath)) {
updates.set(pkg.packageJsonPath, {
path: pkg.packageJsonPath,
directory: pkg.packagePath,
changes: [],
});
}
// Add this package to the list of updates for this package.json
updates.get(pkg.packageJsonPath)?.changes.push({
name: pkg.name,
currentVersion: pkg.currentVersion,
newVersion: installVersion,
displayVersion: displayVersion,
});
}
// If there are no valid updates, return early
if (updates.size === 0) {
console.log("No updates to apply.");
return;
}
// Now apply all updates at once
for (const update of updates.values()) {
try {
// Read the package.json file
const packageJsonContent = JSON.parse(await fs.readFile(update.path, "utf8"));
// Apply all changes to this package.json
let didModify = false;
for (const change of update.changes) {
const relPath = path.relative(process.cwd(), update.directory);
console.log(`\nUpdating ${change.name} in ${relPath || "."} to ${change.displayVersion}`);
// Update in dependencies or devDependencies as appropriate, preserving prefix
if (packageJsonContent.dependencies?.[change.name]) {
// Get the original version string to preserve prefix
const originalVersion = packageJsonContent.dependencies[change.name];
// Extract the prefix properly - handles ^, ~, >=, <=, >, <, =, etc.
const versionNumber = semver.valid(semver.coerce(originalVersion)) || "";
const prefix = versionNumber
? originalVersion.substring(0, originalVersion.indexOf(versionNumber))
: "";
// Apply the same prefix to the new version
packageJsonContent.dependencies[change.name] =
`${prefix}${change.newVersion}`;
didModify = true;
}
if (packageJsonContent.devDependencies?.[change.name]) {
// Get the original version string to preserve prefix
const originalVersion = packageJsonContent.devDependencies[change.name];
// Extract the prefix properly - handles ^, ~, >=, <=, >, <, =, etc.
const versionNumber = semver.valid(semver.coerce(originalVersion)) || "";
const prefix = versionNumber
? originalVersion.substring(0, originalVersion.indexOf(versionNumber))
: "";
// Apply the same prefix to the new version
packageJsonContent.devDependencies[change.name] =
`${prefix}${change.newVersion}`;
didModify = true;
}
}
if (didModify) {
// Write the updated package.json back to disk
await fs.writeFile(update.path, JSON.stringify(packageJsonContent, null, 2) + "\n", "utf8");
}
}
catch (error) {
console.error(`Error updating ${update.path}:`, error);
}
}
// Run a single pnpm install command at the end
console.log("\nRunning pnpm install to update all dependencies...");
execSync("pnpm install", { stdio: "inherit" });
console.log("\nDependencies updated successfully!");
};
return { packages, loading, error, updatePackages, updateDependencies };
}
async function processPackageJson(pkgPath, setPackages, limit) {
try {
const content = JSON.parse(await fs.readFile(pkgPath, "utf8"));
const deps = Object.assign({}, content.dependencies, content.devDependencies);
const depTasks = Object.entries(deps).map(([dep, version]) => processDependency(dep, version, pkgPath, setPackages, limit));
// Process dependencies in batches to avoid overwhelming the Promise queue
for (let i = 0; i < depTasks.length; i += 10) {
await Promise.all(depTasks.slice(i, i + 10));
}
}
catch (error) {
// Skip invalid package.json
console.error(`Error reading ${pkgPath}:`, error);
}
}
async function processDependency(dep, version, pkgPath, setPackages, limit) {
try {
const current = semver.minVersion(version);
if (!current) {
return;
}
const pkgMeta = await limit(() => packageJson(dep, { allVersions: true }).catch(() => null));
if (!pkgMeta) {
return;
}
const all = Object.keys(pkgMeta.versions)
.filter((v) => semver.valid(v) && semver.gt(v, current))
.sort(semver.compare);
const stableVersions = all.filter((v) => !semver.prerelease(v));
const prerelease = all.find((v) => semver.prerelease(v));
let patch;
let minor;
for (const v of stableVersions) {
if (!patch && semver.diff(current, v) === "patch") {
patch = v;
continue;
}
if (!minor && semver.diff(current, v) === "minor") {
minor = v;
continue;
}
}
const latest = stableVersions.at(-1);
if (!(patch || minor || prerelease || latest)) {
return;
}
const displayVersion = getDisplayVersion({
name: dep,
currentVersion: version,
latestVersion: latest,
patchVersion: patch,
minorVersion: minor || patch,
displayVersion: "",
packagePath: "",
packageJsonPath: "",
selected: false,
disabled: false,
targetVersionType: "patch",
prereleaseVersion: prerelease,
}) || version;
setPackages((prev) => {
const updated = [...prev];
const index = updated.findIndex((p) => p.name === dep && p.packagePath === path.dirname(pkgPath));
if (index !== -1) {
updated[index] = {
...updated[index],
latestVersion: latest,
patchVersion: patch,
minorVersion: minor || patch,
displayVersion,
targetVersionType: "patch",
lastTargetVersionType: "patch",
};
return updated;
}
return [
...updated,
{
name: dep,
currentVersion: version,
latestVersion: latest,
patchVersion: patch,
minorVersion: minor || patch,
displayVersion,
packagePath: path.dirname(pkgPath),
packageJsonPath: pkgPath,
selected: false,
disabled: false,
targetVersionType: "patch",
prereleaseVersion: prerelease,
lastTargetVersionType: "patch",
},
];
});
// pkgs.push({
// name: dep,
// currentVersion: version,
// latestVersion: latest!,
// patchVersion: patch,
// minorVersion: minor || patch,
// displayVersion,
// packagePath: path.dirname(pkgPath),
// packageJsonPath: pkgPath,
// selected: false,
// disabled: false,
// targetVersionType: "patch",
// prereleaseVersion: prerelease,
// lastTargetVersionType: "patch",
// });
}
catch (error) {
// Skip this dependency if there's an error
// no console.error when error is "TypeError: Invalid comparator: workspace"
if (error.message.includes("Invalid comparator: workspace")) {
return;
}
console.error(`Error processing ${dep}:`, error);
}
}