import * as plugins from "./mod.plugins.js"; export interface IInstalledPackage { name: string; version: string; globalDir?: string; packagePath?: string; legacy?: boolean; } export interface IPackageUpdateInfo { name: string; currentVersion: string; latestVersion: string; needsUpdate: boolean; needsMigration?: boolean; globalDir?: string; } export interface IPackageManagerInfo { available: boolean; currentVersion: string; latestVersion: string | null; needsUpdate: boolean; } export interface ILegacyGlobalRootInfo { globalDir: string; packages: IInstalledPackage[]; unmanagedPackageNames: string[]; safeToDelete: boolean; } export interface ILegacyCleanupResult { globalDir: string; deleted: boolean; reason?: string; } export interface IShimSyncResult { name: string; action: "updated" | "removed" | "skipped"; reason?: string; } interface IPnpmListProject { path?: string; dependencies?: Record; } export class PackageManagerUtil { private shell = new plugins.smartshell.Smartshell({ executor: "bash", }); private pnpmCommand: string | null | undefined; public async detectPnpm(): Promise { return Boolean(await this.getPnpmCommand()); } public async getPnpmVersionInfo(): Promise { const available = await this.detectPnpm(); if (!available) { return { available: false, currentVersion: "unknown", latestVersion: null, needsUpdate: false, }; } const currentVersion = await this.getCurrentPnpmVersion(); const latestVersion = await this.getLatestVersion("pnpm", [ "https://registry.npmjs.org", ]); return { available: true, currentVersion, latestVersion, needsUpdate: latestVersion ? this.isNewerVersion(currentVersion, latestVersion) : false, }; } public async getInstalledPackages(): Promise { const packageMap = new Map(); const currentPackages = await this.getCurrentInstalledPackages(); for (const packageInfo of currentPackages) { packageMap.set(packageInfo.name, packageInfo); } const legacyRoots = await this.getLegacyGlobalRoots(); for (const legacyRoot of legacyRoots) { for (const packageInfo of legacyRoot.packages) { if (!packageMap.has(packageInfo.name)) { packageMap.set(packageInfo.name, packageInfo); } } } return Array.from(packageMap.values()).sort((packageA, packageB) => packageA.name.localeCompare(packageB.name), ); } public async getLegacyGlobalRoots(): Promise { const currentGlobalDir = await this.getCurrentGlobalDir(); const baseDirs = new Set(); const pnpmHome = process.env.PNPM_HOME; if (pnpmHome) { baseDirs.add(plugins.path.join(pnpmHome, "global")); } if (currentGlobalDir) { baseDirs.add(plugins.path.dirname(currentGlobalDir)); } const roots: ILegacyGlobalRootInfo[] = []; for (const baseDir of baseDirs) { try { const entries = await plugins.fs.readdir(baseDir, { withFileTypes: true, }); for (const entry of entries) { if (!entry.isDirectory()) { continue; } const globalDir = normalizePath( plugins.path.join(baseDir, entry.name), ); if (currentGlobalDir && pathsAreEqual(globalDir, currentGlobalDir)) { continue; } const rootInfo = await this.inspectGlobalRoot(globalDir, false); if (rootInfo.packages.length > 0) { roots.push(rootInfo); } } } catch { continue; } } return roots; } public async cleanupLegacyGlobalRoots(): Promise { const legacyRoots = await this.getLegacyGlobalRoots(); if (legacyRoots.length === 0) { return []; } const currentPackageNames = new Set( (await this.getCurrentInstalledPackages()).map( (packageInfo) => packageInfo.name, ), ); const cleanupResults: ILegacyCleanupResult[] = []; for (const legacyRoot of legacyRoots) { const missingPackageNames = legacyRoot.packages .map((packageInfo) => packageInfo.name) .filter((packageName) => !currentPackageNames.has(packageName)); if (missingPackageNames.length > 0) { cleanupResults.push({ globalDir: legacyRoot.globalDir, deleted: false, reason: `kept because ${missingPackageNames.join(", ")} are not installed in the current pnpm global root`, }); continue; } if (!legacyRoot.safeToDelete) { cleanupResults.push({ globalDir: legacyRoot.globalDir, deleted: false, reason: legacyRoot.unmanagedPackageNames.length > 0 ? `kept because it also contains ${legacyRoot.unmanagedPackageNames.join(", ")}` : "kept because it is not a managed @git.zone-only global root", }); continue; } const blockingShims = await this.getShimReferences(legacyRoot.globalDir); if (blockingShims === null) { cleanupResults.push({ globalDir: legacyRoot.globalDir, deleted: false, reason: "kept because PNPM_HOME is not set, so command shims could not be verified", }); continue; } if (blockingShims.length > 0) { cleanupResults.push({ globalDir: legacyRoot.globalDir, deleted: false, reason: `kept because command shims still reference it: ${blockingShims.join(", ")}`, }); continue; } try { await plugins.fs.rm(legacyRoot.globalDir, { recursive: true, force: true, }); cleanupResults.push({ globalDir: legacyRoot.globalDir, deleted: true, }); } catch (error) { cleanupResults.push({ globalDir: legacyRoot.globalDir, deleted: false, reason: `delete failed: ${(error as Error).message}`, }); } } return cleanupResults; } public async syncCurrentGlobalShims(): Promise { const pnpmShimDirs = await this.getPnpmShimDirs(); if (!pnpmShimDirs) { return [ { name: "PNPM_HOME", action: "skipped", reason: "PNPM_HOME is not set", }, ]; } const results: IShimSyncResult[] = []; const currentBinNames = new Set(); const currentPackages = await this.getCurrentInstalledPackages(); for (const packageInfo of currentPackages) { if (!packageInfo.packagePath) { continue; } const packageJson = await readJson( plugins.path.join(packageInfo.packagePath, "package.json"), ); const binNames = getPackageBinNames(packageInfo.name, packageJson); const nodeModulesDir = getNodeModulesDir( packageInfo.packagePath, packageInfo.name, ); for (const binName of binNames) { currentBinNames.add(binName); const sourceShim = plugins.path.join(nodeModulesDir, ".bin", binName); for (const pnpmShimDir of pnpmShimDirs) { const destinationShim = plugins.path.join(pnpmShimDir, binName); try { const sourceContent = await plugins.fs.readFile(sourceShim, "utf8"); const sourceStat = await plugins.fs.stat(sourceShim); await plugins.fs.writeFile( destinationShim, rewriteShimForPnpmHome(sourceContent), "utf8", ); await plugins.fs.chmod(destinationShim, sourceStat.mode); results.push({ name: formatShimName(pnpmShimDir, binName), action: "updated", }); } catch (error) { results.push({ name: formatShimName(pnpmShimDir, binName), action: "skipped", reason: (error as Error).message, }); } } } } const legacyRoots = await this.getLegacyGlobalRoots(); const legacyGlobalDirs = legacyRoots.map( (legacyRoot) => legacyRoot.globalDir, ); if (legacyGlobalDirs.length === 0) { return results; } for (const pnpmShimDir of pnpmShimDirs) { try { const entries = await plugins.fs.readdir(pnpmShimDir, { withFileTypes: true, }); for (const entry of entries) { if (!entry.isFile() || currentBinNames.has(entry.name)) { continue; } const filePath = plugins.path.join(pnpmShimDir, entry.name); let content = ""; try { content = await plugins.fs.readFile(filePath, "utf8"); } catch { continue; } if ( !legacyGlobalDirs.some((legacyGlobalDir) => content.includes(legacyGlobalDir), ) ) { continue; } try { await plugins.fs.rm(filePath, { force: true }); results.push({ name: formatShimName(pnpmShimDir, entry.name), action: "removed", }); } catch (error) { results.push({ name: formatShimName(pnpmShimDir, entry.name), action: "skipped", reason: (error as Error).message, }); } } } catch (error) { results.push({ name: pnpmShimDir, action: "skipped", reason: (error as Error).message, }); } } return results; } public async getLatestVersion( packageName: string, registries = [ "https://verdaccio.lossless.digital", "https://registry.npmjs.org", ], ): Promise { for (const registry of registries) { const latest = await this.getLatestVersionFromRegistry( registry, packageName, ); if (latest) { return latest; } } return null; } public async installLatest( packageName: string, version = "latest", ): Promise { const pnpmCommand = await this.getPnpmCommand(); if (!pnpmCommand) { return false; } const packageSpecifier = `${packageName}@${version}`; console.log(` Installing ${packageSpecifier} via pnpm...`); try { const result = await this.shell.exec( `${pnpmCommand} add -g ${shellQuote(packageSpecifier)}`, ); return result.exitCode === 0; } catch { return false; } } public async updatePnpm(targetVersion: string): Promise { const pnpmCommand = await this.getPnpmCommand(); if (!pnpmCommand) { return false; } const neutralDir = process.env.PNPM_HOME || "/tmp"; try { const result = await this.shell.exec( `${pnpmCommand} --dir ${shellQuote(neutralDir)} self-update ${shellQuote(targetVersion)}`, ); this.pnpmCommand = undefined; const currentVersion = await this.getCurrentPnpmVersion(); return ( result.exitCode === 0 && !this.isNewerVersion(currentVersion, targetVersion) ); } catch { return false; } } public isNewerVersion(current: string, latest: string): boolean { const currentParts = normalizeSemver(current); const latestParts = normalizeSemver(latest); for ( let i = 0; i < Math.max(currentParts.length, latestParts.length); i++ ) { const currentPart = currentParts[i] || 0; const latestPart = latestParts[i] || 0; if (latestPart > currentPart) return true; if (latestPart < currentPart) return false; } return false; } private async getCurrentPnpmVersion(): Promise { try { const result = await this.execPnpmSilent("--version 2>/dev/null"); if (!result) { return "unknown"; } const versionMatch = result.stdout.trim().match(/(\d+\.\d+\.\d+)/); return versionMatch?.[1] || "unknown"; } catch { return "unknown"; } } private async getPnpmCommand(): Promise { if (this.pnpmCommand !== undefined) { return this.pnpmCommand; } const candidates = [ "pnpm --pm-on-fail=ignore", "pnpm --config.manage-package-manager-versions=false", "pnpm", ]; for (const candidate of candidates) { try { const result = await this.shell.execSilent( `${candidate} --version 2>/dev/null`, ); if (result.exitCode === 0 && Boolean(result.stdout.trim())) { this.pnpmCommand = candidate; return this.pnpmCommand; } } catch { // Try the next supported pnpm invocation form. } } this.pnpmCommand = null; return this.pnpmCommand; } private async execPnpmSilent(commandArgs: string): Promise { const pnpmCommand = await this.getPnpmCommand(); if (!pnpmCommand) { return null; } return this.shell.execSilent(`${pnpmCommand} ${commandArgs}`); } private async getPnpmListProjects(): Promise { try { const result = await this.execPnpmSilent( "list -g --depth=0 --json 2>/dev/null || true", ); const output = result?.stdout.trim(); if (!output) { return []; } const data = JSON.parse(output); return Array.isArray(data) ? data : [data]; } catch { return []; } } private async getCurrentGlobalDir(): Promise { const listProjects = await this.getPnpmListProjects(); for (const listProject of listProjects) { if (typeof listProject.path === "string" && listProject.path.length > 0) { return normalizeGlobalDir(listProject.path); } } try { const result = await this.execPnpmSilent("root -g 2>/dev/null"); const output = result?.stdout.trim(); return output ? normalizeGlobalDir(output) : null; } catch { return null; } } private async getCurrentInstalledPackages(): Promise { const listProjects = await this.getPnpmListProjects(); const currentGlobalDir = await this.getCurrentGlobalDir(); const packageMap = new Map(); for (const listProject of listProjects) { const globalDir = listProject.path ? normalizeGlobalDir(listProject.path) : currentGlobalDir || undefined; const dependencies = listProject.dependencies || {}; for (const [name, info] of Object.entries(dependencies)) { if (!name.startsWith("@git.zone/")) { continue; } packageMap.set(name, { name, version: getDependencyVersion(info), globalDir, packagePath: getDependencyPackagePath(info), legacy: false, }); } } if (packageMap.size === 0 && currentGlobalDir) { const currentRootInfo = await this.inspectGlobalRoot( currentGlobalDir, true, ); for (const packageInfo of currentRootInfo.packages) { packageMap.set(packageInfo.name, packageInfo); } } return Array.from(packageMap.values()); } private async inspectGlobalRoot( globalDir: string, current: boolean, ): Promise { const normalizedGlobalDir = normalizeGlobalDir(globalDir); const rootPackageJson = await readJson( plugins.path.join(normalizedGlobalDir, "package.json"), ); const dependencies = getDependencyMap(rootPackageJson?.dependencies); const packageMap = new Map(); for (const [name, spec] of Object.entries(dependencies)) { if (!name.startsWith("@git.zone/")) { continue; } packageMap.set(name, { name, version: (await this.getInstalledPackageVersion(normalizedGlobalDir, name)) || normalizeDependencySpec(spec), globalDir: normalizedGlobalDir, packagePath: getPackagePath(normalizedGlobalDir, name), legacy: !current, }); } const gitZoneScopeDir = plugins.path.join( normalizedGlobalDir, "node_modules", "@git.zone", ); try { const entries = await plugins.fs.readdir(gitZoneScopeDir, { withFileTypes: true, }); for (const entry of entries) { if (!entry.isDirectory() && !entry.isSymbolicLink()) { continue; } const name = `@git.zone/${entry.name}`; packageMap.set(name, { name, version: (await this.getInstalledPackageVersion( normalizedGlobalDir, name, )) || "unknown", globalDir: normalizedGlobalDir, packagePath: getPackagePath(normalizedGlobalDir, name), legacy: !current, }); } } catch { // A pnpm global root may be empty or may not have node_modules materialized yet. } const dependencyNames = Object.keys(dependencies); const unmanagedPackageNames = dependencyNames.filter( (packageName) => !packageName.startsWith("@git.zone/"), ); return { globalDir: normalizedGlobalDir, packages: Array.from(packageMap.values()).sort((packageA, packageB) => packageA.name.localeCompare(packageB.name), ), unmanagedPackageNames, safeToDelete: !current && dependencyNames.length > 0 && unmanagedPackageNames.length === 0, }; } private async getInstalledPackageVersion( globalDir: string, packageName: string, ): Promise { const packageJson = await readJson( plugins.path.join( globalDir, "node_modules", ...packageName.split("/"), "package.json", ), ); return typeof packageJson?.version === "string" ? packageJson.version : null; } private async getShimReferences( legacyGlobalDir: string, ): Promise { const pnpmShimDirs = await this.getPnpmShimDirs(); if (!pnpmShimDirs) { return null; } const references: string[] = []; for (const pnpmShimDir of pnpmShimDirs) { try { const entries = await plugins.fs.readdir(pnpmShimDir, { withFileTypes: true, }); for (const entry of entries) { if (!entry.isFile()) { continue; } const filePath = plugins.path.join(pnpmShimDir, entry.name); try { const content = await plugins.fs.readFile(filePath, "utf8"); if (content.includes(legacyGlobalDir)) { references.push(formatShimName(pnpmShimDir, entry.name)); } } catch { // Ignore unreadable or non-text files in PNPM_HOME. } } } catch { return null; } } return references; } private async getPnpmShimDirs(): Promise { const pnpmHome = process.env.PNPM_HOME; if (!pnpmHome) { return null; } const candidateDirs = [plugins.path.join(pnpmHome, "bin"), pnpmHome]; const shimDirs: string[] = []; for (const candidateDir of candidateDirs) { try { const stat = await plugins.fs.stat(candidateDir); if (stat.isDirectory()) { shimDirs.push(normalizePath(candidateDir)); } } catch { // Ignore missing pnpm shim directories. } } return shimDirs.length > 0 ? Array.from(new Set(shimDirs)) : null; } private async getLatestVersionFromRegistry( registry: string, packageName: string, ): Promise { const encodedName = packageName.replace("/", "%2f"); const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 8000); try { const response = await fetch(`${registry}/${encodedName}`, { signal: controller.signal, headers: { accept: "application/json", }, }); 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; } catch { return null; } finally { clearTimeout(timeout); } } } async function readJson(filePath: string): Promise { try { const content = await plugins.fs.readFile(filePath, "utf8"); return JSON.parse(content); } catch { return null; } } function getDependencyMap(value: unknown): Record { if (!value || typeof value !== "object" || Array.isArray(value)) { return {}; } return value as Record; } function getDependencyVersion(info: any): string { if (info && typeof info === "object" && typeof info.version === "string") { return info.version; } if (typeof info === "string") { return normalizeDependencySpec(info); } return "unknown"; } function getDependencyPackagePath(info: any): string | undefined { return info && typeof info === "object" && typeof info.path === "string" ? normalizePath(info.path) : undefined; } function getPackagePath(globalDir: string, packageName: string): string { return plugins.path.join( globalDir, "node_modules", ...packageName.split("/"), ); } function getPackageBinNames(packageName: string, packageJson: any): string[] { const bin = packageJson?.bin; if (typeof bin === "string") { return [getDefaultBinName(packageName)]; } if (!bin || typeof bin !== "object" || Array.isArray(bin)) { return []; } return Object.keys(bin) .filter((binName) => binName.length > 0) .sort(); } function getDefaultBinName(packageName: string): string { const packageNameParts = packageName.split("/"); return packageNameParts[packageNameParts.length - 1] || packageName; } function getNodeModulesDir(packagePath: string, packageName: string): string { return packageName.startsWith("@") ? plugins.path.dirname(plugins.path.dirname(packagePath)) : plugins.path.dirname(packagePath); } function rewriteShimForPnpmHome(content: string): string { const targetMatch = content.match(/^# cmd-shim-target=(.+)$/m); if (!targetMatch?.[1]) { return content; } const absoluteTarget = `"${escapeDoubleQuotedShell(targetMatch[1])}"`; return content.replace( /"\$basedir\/(?:\.\.\/)+store\/[^"\n]+"/g, absoluteTarget, ); } function escapeDoubleQuotedShell(value: string): string { return value.replace(/["\\$`]/g, "\\$&"); } function formatShimName(pnpmShimDir: string, binName: string): string { const pnpmHome = process.env.PNPM_HOME; if (!pnpmHome) { return binName; } const relativeName = plugins.path.relative( pnpmHome, plugins.path.join(pnpmShimDir, binName), ); return relativeName && !relativeName.startsWith("..") ? relativeName : binName; } function normalizeDependencySpec(spec: unknown): string { if (typeof spec !== "string" || spec.length === 0) { return "unknown"; } const versionMatch = spec.match(/\d+\.\d+\.\d+(?:[-+][\w.-]+)?/); return versionMatch?.[0] || spec; } function normalizeGlobalDir(globalDir: string): string { const normalizedPath = normalizePath(globalDir); return plugins.path.basename(normalizedPath) === "node_modules" ? plugins.path.dirname(normalizedPath) : normalizedPath; } function normalizePath(filePath: string): string { return plugins.path.resolve(filePath).replace(/\/+$/, ""); } function pathsAreEqual(pathA: string, pathB: string): boolean { return normalizePath(pathA) === normalizePath(pathB); } function normalizeSemver(version: string): number[] { return version .replace(/^[^\d]*/, "") .split(".") .map((part) => parseInt(part, 10) || 0); } function shellQuote(value: string): string { return `'${value.replaceAll("'", "'\\''")}'`; }