fix(tools): respect pnpm maturity policy during updates
This commit is contained in:
@@ -1,5 +1,14 @@
|
||||
# Changelog
|
||||
|
||||
## Pending
|
||||
|
||||
### Fixes
|
||||
|
||||
- make `gitzone tools update` respect pnpm maturity policy and finish cleanup
|
||||
- Selects mature pnpm and managed tool versions when `minimumReleaseAge` is active.
|
||||
- Refreshes command shims after self-updates and successful pnpm-only updates.
|
||||
- Avoids counting retained mixed legacy pnpm roots as perpetual cleanup actions.
|
||||
|
||||
## 2026-06-03 - 2.19.5
|
||||
|
||||
### Fixes
|
||||
|
||||
@@ -70,7 +70,7 @@ export class PackageManagerUtil {
|
||||
}
|
||||
|
||||
const currentVersion = await this.getCurrentPnpmVersion();
|
||||
const latestVersion = await this.getLatestVersion("pnpm", [
|
||||
const latestVersion = await this.getLatestMatureVersion("pnpm", [
|
||||
"https://registry.npmjs.org",
|
||||
]);
|
||||
|
||||
@@ -371,6 +371,51 @@ export class PackageManagerUtil {
|
||||
return null;
|
||||
}
|
||||
|
||||
public async getLatestMatureVersion(
|
||||
packageName: string,
|
||||
registries = [
|
||||
"https://verdaccio.lossless.digital",
|
||||
"https://registry.npmjs.org",
|
||||
],
|
||||
): Promise<string | null> {
|
||||
const minimumReleaseAgeMinutes = await this.getMinimumReleaseAgeMinutes();
|
||||
|
||||
for (const registry of registries) {
|
||||
const metadata = await this.getPackageMetadataFromRegistry(
|
||||
registry,
|
||||
packageName,
|
||||
);
|
||||
if (!metadata) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const latest = getLatestVersionFromMetadata(metadata);
|
||||
if (!latest && !minimumReleaseAgeMinutes) {
|
||||
continue;
|
||||
}
|
||||
if (!minimumReleaseAgeMinutes) {
|
||||
return latest;
|
||||
}
|
||||
|
||||
const minimumReleaseAgeExcluded = latest
|
||||
? await this.isMinimumReleaseAgeExcluded(packageName, latest)
|
||||
: false;
|
||||
if (latest && minimumReleaseAgeExcluded) {
|
||||
return latest;
|
||||
}
|
||||
|
||||
const matureLatest = getLatestMatureVersionFromMetadata(
|
||||
metadata,
|
||||
minimumReleaseAgeMinutes,
|
||||
);
|
||||
if (matureLatest) {
|
||||
return matureLatest;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public async installLatest(
|
||||
packageName: string,
|
||||
version = "latest",
|
||||
@@ -484,6 +529,42 @@ export class PackageManagerUtil {
|
||||
return this.shell.execSilent(`${pnpmCommand} ${commandArgs}`);
|
||||
}
|
||||
|
||||
private async getMinimumReleaseAgeMinutes(): Promise<number> {
|
||||
try {
|
||||
const result = await this.execPnpmSilent(
|
||||
"config get minimum-release-age 2>/dev/null",
|
||||
);
|
||||
const rawValue = result?.stdout.trim() || "";
|
||||
const value = Number.parseInt(rawValue, 10);
|
||||
if (Number.isFinite(value) && value >= 0) {
|
||||
return value;
|
||||
}
|
||||
|
||||
const currentVersion = await this.getCurrentPnpmVersion();
|
||||
return getDefaultMinimumReleaseAgeMinutes(currentVersion);
|
||||
} catch {
|
||||
const currentVersion = await this.getCurrentPnpmVersion();
|
||||
return getDefaultMinimumReleaseAgeMinutes(currentVersion);
|
||||
}
|
||||
}
|
||||
|
||||
private async isMinimumReleaseAgeExcluded(
|
||||
packageName: string,
|
||||
version: string,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const result = await this.execPnpmSilent(
|
||||
"config get minimum-release-age-exclude 2>/dev/null",
|
||||
);
|
||||
const rawPatterns = parsePnpmConfigStringList(result?.stdout || "");
|
||||
return rawPatterns.some((pattern) =>
|
||||
packageSelectorMatches(packageName, version, pattern),
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async getPnpmListProjects(): Promise<IPnpmListProject[]> {
|
||||
try {
|
||||
const result = await this.execPnpmSilent(
|
||||
@@ -710,6 +791,17 @@ export class PackageManagerUtil {
|
||||
registry: string,
|
||||
packageName: string,
|
||||
): Promise<string | null> {
|
||||
const data = await this.getPackageMetadataFromRegistry(
|
||||
registry,
|
||||
packageName,
|
||||
);
|
||||
return data ? getLatestVersionFromMetadata(data) : null;
|
||||
}
|
||||
|
||||
private async getPackageMetadataFromRegistry(
|
||||
registry: string,
|
||||
packageName: string,
|
||||
): Promise<any | null> {
|
||||
const encodedName = packageName.replace("/", "%2f");
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 8000);
|
||||
@@ -724,9 +816,7 @@ export class PackageManagerUtil {
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
const data = await response.json();
|
||||
const latest = (data as any)["dist-tags"]?.latest;
|
||||
return typeof latest === "string" && latest.length > 0 ? latest : null;
|
||||
return await response.json();
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
@@ -767,6 +857,163 @@ function getDependencyPackagePath(info: any): string | undefined {
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function getLatestVersionFromMetadata(metadata: any): string | null {
|
||||
const latest = metadata?.["dist-tags"]?.latest;
|
||||
return typeof latest === "string" && latest.length > 0 ? latest : null;
|
||||
}
|
||||
|
||||
function getDefaultMinimumReleaseAgeMinutes(currentPnpmVersion: string): number {
|
||||
const majorVersion = normalizeSemver(currentPnpmVersion)[0] || 0;
|
||||
return majorVersion >= 11 ? 1440 : 0;
|
||||
}
|
||||
|
||||
function getLatestMatureVersionFromMetadata(
|
||||
metadata: any,
|
||||
minimumReleaseAgeMinutes: number,
|
||||
): string | null {
|
||||
const versions = metadata?.versions;
|
||||
const versionTimes = metadata?.time;
|
||||
if (
|
||||
!versions ||
|
||||
typeof versions !== "object" ||
|
||||
!versionTimes ||
|
||||
typeof versionTimes !== "object"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cutoffTimestamp = Date.now() - minimumReleaseAgeMinutes * 60 * 1000;
|
||||
return Object.keys(versions)
|
||||
.filter((version) => /^\d+\.\d+\.\d+$/.test(version))
|
||||
.filter((version) => {
|
||||
const publishedAt = Date.parse(versionTimes[version]);
|
||||
return Number.isFinite(publishedAt) && publishedAt <= cutoffTimestamp;
|
||||
})
|
||||
.sort(compareSemverDescending)[0] || null;
|
||||
}
|
||||
|
||||
function compareSemverDescending(versionA: string, versionB: string): number {
|
||||
const versionAParts = normalizeSemver(versionA);
|
||||
const versionBParts = normalizeSemver(versionB);
|
||||
|
||||
for (
|
||||
let i = 0;
|
||||
i < Math.max(versionAParts.length, versionBParts.length);
|
||||
i++
|
||||
) {
|
||||
const versionAPart = versionAParts[i] || 0;
|
||||
const versionBPart = versionBParts[i] || 0;
|
||||
if (versionAPart !== versionBPart) {
|
||||
return versionBPart - versionAPart;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
function parsePnpmConfigStringList(rawValue: string): string[] {
|
||||
const trimmedValue = rawValue.trim();
|
||||
if (
|
||||
!trimmedValue ||
|
||||
trimmedValue === "undefined" ||
|
||||
trimmedValue === "null"
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const parsedValue = JSON.parse(trimmedValue);
|
||||
if (Array.isArray(parsedValue)) {
|
||||
return parsedValue
|
||||
.filter((item) => typeof item === "string")
|
||||
.map((item) => normalizePnpmConfigListItem(item))
|
||||
.filter((item) => item.length > 0);
|
||||
}
|
||||
if (typeof parsedValue === "string") {
|
||||
return [normalizePnpmConfigListItem(parsedValue)].filter(
|
||||
(item) => item.length > 0,
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// pnpm commonly prints string arrays as comma-delimited text.
|
||||
}
|
||||
|
||||
return trimmedValue
|
||||
.split(/[\n,]/)
|
||||
.map((item) => normalizePnpmConfigListItem(item))
|
||||
.filter((item) => item.length > 0);
|
||||
}
|
||||
|
||||
function normalizePnpmConfigListItem(rawItem: string): string {
|
||||
return rawItem
|
||||
.trim()
|
||||
.replace(/^-\s*/, "")
|
||||
.replace(/^\[/, "")
|
||||
.replace(/\]$/, "")
|
||||
.trim()
|
||||
.replace(/^['"]/, "")
|
||||
.replace(/['"]$/, "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function packageSelectorMatches(
|
||||
packageName: string,
|
||||
version: string,
|
||||
pattern: string,
|
||||
): boolean {
|
||||
const selectorParts = splitPackageSelector(pattern);
|
||||
if (!globPatternMatches(packageName, selectorParts.packagePattern)) {
|
||||
return false;
|
||||
}
|
||||
if (!selectorParts.versionSelector) {
|
||||
return true;
|
||||
}
|
||||
return selectorParts.versionSelector
|
||||
.split("||")
|
||||
.map((versionSelector) => versionSelector.trim())
|
||||
.some((versionSelector) => {
|
||||
const packageVersionPrefix = `${packageName}@`;
|
||||
const normalizedVersionSelector = versionSelector.startsWith(
|
||||
packageVersionPrefix,
|
||||
)
|
||||
? versionSelector.slice(packageVersionPrefix.length)
|
||||
: versionSelector;
|
||||
return normalizedVersionSelector === version;
|
||||
});
|
||||
}
|
||||
|
||||
function splitPackageSelector(pattern: string): {
|
||||
packagePattern: string;
|
||||
versionSelector: string | null;
|
||||
} {
|
||||
const versionSeparatorIndex = pattern.startsWith("@")
|
||||
? pattern.indexOf("@", 1)
|
||||
: pattern.indexOf("@");
|
||||
if (versionSeparatorIndex <= 0) {
|
||||
return {
|
||||
packagePattern: pattern,
|
||||
versionSelector: null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
packagePattern: pattern.slice(0, versionSeparatorIndex),
|
||||
versionSelector: pattern.slice(versionSeparatorIndex + 1).trim() || null,
|
||||
};
|
||||
}
|
||||
|
||||
function globPatternMatches(value: string, pattern: string): boolean {
|
||||
const escapedPattern = pattern
|
||||
.split("*")
|
||||
.map((patternPart) => escapeRegExp(patternPart))
|
||||
.join(".*");
|
||||
return new RegExp(`^${escapedPattern}$`).test(value);
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function getPackagePath(globalDir: string, packageName: string): string {
|
||||
return plugins.path.join(
|
||||
globalDir,
|
||||
|
||||
+31
-13
@@ -88,7 +88,10 @@ async function runUpdate(argvArg: any, mode: ICliMode): Promise<void> {
|
||||
const installedPackages = await pmUtil.getInstalledPackages();
|
||||
const packageInfos = await getPackageUpdateInfos(pmUtil, installedPackages);
|
||||
const legacyRoots = await pmUtil.getLegacyGlobalRoots();
|
||||
const legacyCleanupNeeded = legacyRoots.length > 0;
|
||||
const legacyCleanupRoots = legacyRoots.filter(
|
||||
(legacyRoot) => legacyRoot.safeToDelete,
|
||||
);
|
||||
const legacyCleanupNeeded = legacyCleanupRoots.length > 0;
|
||||
|
||||
if (packageInfos.length === 0) {
|
||||
console.log("No managed @git.zone packages found installed globally.");
|
||||
@@ -131,10 +134,10 @@ async function runUpdate(argvArg: any, mode: ICliMode): Promise<void> {
|
||||
console.log("");
|
||||
} else if (legacyCleanupNeeded) {
|
||||
console.log(
|
||||
`Detected ${legacyRoots.length} legacy pnpm global root(s) for cleanup.`,
|
||||
`Detected ${legacyCleanupRoots.length} legacy pnpm global root(s) for cleanup.`,
|
||||
);
|
||||
if (verbose) {
|
||||
for (const legacyRoot of legacyRoots) {
|
||||
for (const legacyRoot of legacyCleanupRoots) {
|
||||
console.log(` ${legacyRoot.globalDir}`);
|
||||
}
|
||||
}
|
||||
@@ -180,11 +183,12 @@ async function runUpdate(argvArg: any, mode: ICliMode): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
let pnpmUpdated = false;
|
||||
if (pnpmNeedsUpdate && pnpmInfo.latestVersion) {
|
||||
console.log(`Updating pnpm to ${pnpmInfo.latestVersion}...`);
|
||||
const success = await pmUtil.updatePnpm(pnpmInfo.latestVersion);
|
||||
pnpmUpdated = await pmUtil.updatePnpm(pnpmInfo.latestVersion);
|
||||
console.log(
|
||||
success
|
||||
pnpmUpdated
|
||||
? "pnpm updated successfully.\n"
|
||||
: "pnpm update failed. Continuing with package updates.\n",
|
||||
);
|
||||
@@ -205,8 +209,11 @@ async function runUpdate(argvArg: any, mode: ICliMode): Promise<void> {
|
||||
)
|
||||
: { successCount: 0, failCount: 0 };
|
||||
|
||||
if (packagesToUpdate.length > 0 || legacyCleanupNeeded) {
|
||||
if (packagesToUpdate.length > 0 || legacyCleanupNeeded || pnpmUpdated) {
|
||||
await syncCurrentGlobalShims(pmUtil);
|
||||
}
|
||||
|
||||
if (packagesToUpdate.length > 0 || legacyCleanupNeeded) {
|
||||
await cleanupLegacyInstalls(pmUtil);
|
||||
}
|
||||
}
|
||||
@@ -256,7 +263,7 @@ async function runInstall(argvArg: any, mode: ICliMode): Promise<void> {
|
||||
if (!mode.yes) {
|
||||
const choicesWithVersions: Array<{ name: string; value: string }> = [];
|
||||
for (const packageName of missingPackages) {
|
||||
const latest = await pmUtil.getLatestVersion(packageName);
|
||||
const latest = await pmUtil.getLatestMatureVersion(packageName);
|
||||
choicesWithVersions.push({
|
||||
name: `${packageName}${latest ? `@${latest}` : ""}`,
|
||||
value: packageName,
|
||||
@@ -288,7 +295,7 @@ async function handleSelfUpdate(
|
||||
): Promise<boolean> {
|
||||
console.log("Checking for gitzone self-update...\n");
|
||||
const currentVersion = commitinfo.version;
|
||||
const latestVersion = await pmUtil.getLatestVersion("@git.zone/cli");
|
||||
const latestVersion = await pmUtil.getLatestMatureVersion("@git.zone/cli");
|
||||
|
||||
if (!latestVersion || !pmUtil.isNewerVersion(currentVersion, latestVersion)) {
|
||||
console.log(` @git.zone/cli ${currentVersion} Up to date\n`);
|
||||
@@ -321,7 +328,7 @@ async function handleSelfUpdate(
|
||||
return false;
|
||||
}
|
||||
|
||||
const success = await pmUtil.installLatest("@git.zone/cli");
|
||||
const success = await pmUtil.installLatest("@git.zone/cli", latestVersion);
|
||||
if (!success) {
|
||||
console.log(
|
||||
"\ngitzone self-update failed. Continuing with the current version.\n",
|
||||
@@ -329,8 +336,10 @@ async function handleSelfUpdate(
|
||||
return false;
|
||||
}
|
||||
|
||||
await syncCurrentGlobalShims(pmUtil);
|
||||
await cleanupLegacyInstalls(pmUtil);
|
||||
console.log(
|
||||
"\ngitzone has been updated. Re-run gitzone tools update to check remaining packages.",
|
||||
"\ngitzone has been updated and command shims have been refreshed. Re-run gitzone tools update to check remaining packages.",
|
||||
);
|
||||
return true;
|
||||
}
|
||||
@@ -344,7 +353,9 @@ async function getPackageUpdateInfos(
|
||||
if (!GITZONE_PACKAGES.includes(installedPackage.name)) {
|
||||
continue;
|
||||
}
|
||||
const latestVersion = await pmUtil.getLatestVersion(installedPackage.name);
|
||||
const latestVersion = await pmUtil.getLatestMatureVersion(
|
||||
installedPackage.name,
|
||||
);
|
||||
packageInfos.push({
|
||||
name: installedPackage.name,
|
||||
currentVersion: installedPackage.version,
|
||||
@@ -401,7 +412,7 @@ async function printPackageListWithLatest(
|
||||
console.log(" Package Latest");
|
||||
console.log(" ----------------------------------------");
|
||||
for (const packageName of packageNames) {
|
||||
const latest = await pmUtil.getLatestVersion(packageName);
|
||||
const latest = await pmUtil.getLatestMatureVersion(packageName);
|
||||
console.log(` ${packageName.padEnd(28)} ${latest || "unknown"}`);
|
||||
}
|
||||
console.log("");
|
||||
@@ -420,7 +431,14 @@ async function installPackages(
|
||||
typeof packageSpec === "string" ? packageSpec : packageSpec.name;
|
||||
const packageVersion =
|
||||
typeof packageSpec === "string" ? undefined : packageSpec.version;
|
||||
const success = await pmUtil.installLatest(packageName, packageVersion);
|
||||
const effectivePackageVersion =
|
||||
packageVersion ||
|
||||
(await pmUtil.getLatestMatureVersion(packageName)) ||
|
||||
undefined;
|
||||
const success = await pmUtil.installLatest(
|
||||
packageName,
|
||||
effectivePackageVersion,
|
||||
);
|
||||
if (success) {
|
||||
console.log(` ${packageName} ${action} successfully`);
|
||||
successCount++;
|
||||
|
||||
Reference in New Issue
Block a user