diff --git a/changelog.md b/changelog.md index bcdb63c..9772914 100644 --- a/changelog.md +++ b/changelog.md @@ -2,6 +2,12 @@ ## Pending +### Fixes + +- repair `gitzone tools update` for pnpm 11 global installs + - Detects and migrates legacy pnpm global roots such as `PNPM_HOME/global/5`. + - Runs global pnpm maintenance with project package-manager switching disabled. + - Refreshes pnpm v10 and v11 command shims before deleting stale managed global roots. ## 2026-05-14 - 2.19.1 diff --git a/ts/mod_tools/classes.packagemanager.ts b/ts/mod_tools/classes.packagemanager.ts index 809c70b..721232b 100644 --- a/ts/mod_tools/classes.packagemanager.ts +++ b/ts/mod_tools/classes.packagemanager.ts @@ -3,6 +3,9 @@ import * as plugins from "./mod.plugins.js"; export interface IInstalledPackage { name: string; version: string; + globalDir?: string; + packagePath?: string; + legacy?: boolean; } export interface IPackageUpdateInfo { @@ -10,6 +13,8 @@ export interface IPackageUpdateInfo { currentVersion: string; latestVersion: string; needsUpdate: boolean; + needsMigration?: boolean; + globalDir?: string; } export interface IPackageManagerInfo { @@ -19,18 +24,38 @@ export interface IPackageManagerInfo { 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 { - try { - const result = await this.shell.execSilent("pnpm --version 2>/dev/null"); - return result.exitCode === 0 && Boolean(result.stdout.trim()); - } catch { - return false; - } + return Boolean(await this.getPnpmCommand()); } public async getPnpmVersionInfo(): Promise { @@ -45,53 +70,300 @@ export class PackageManagerUtil { } const currentVersion = await this.getCurrentPnpmVersion(); - const latestVersion = await this.getLatestVersion("pnpm", ["https://registry.npmjs.org"]); + const latestVersion = await this.getLatestVersion("pnpm", [ + "https://registry.npmjs.org", + ]); return { available: true, currentVersion, latestVersion, - needsUpdate: latestVersion ? this.isNewerVersion(currentVersion, latestVersion) : false, + needsUpdate: latestVersion + ? this.isNewerVersion(currentVersion, latestVersion) + : false, }; } public async getInstalledPackages(): Promise { - const packages: IInstalledPackage[] = []; + const packageMap = new Map(); + const currentPackages = await this.getCurrentInstalledPackages(); - try { - const result = await this.shell.execSilent("pnpm list -g --depth=0 --json 2>/dev/null || true"); - const output = result.stdout.trim(); - if (!output) { - return packages; - } - - const data = JSON.parse(output); - const dataArray = Array.isArray(data) ? data : [data]; - for (const item of dataArray) { - const dependencies = item.dependencies || {}; - for (const [name, info] of Object.entries(dependencies)) { - if (!name.startsWith("@git.zone/")) { - continue; - } - packages.push({ - name, - version: (info as any).version || "unknown", - }); - } - } - } catch { - return packages; + for (const packageInfo of currentPackages) { + packageMap.set(packageInfo.name, packageInfo); } - return packages; + 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"], + registries = [ + "https://verdaccio.lossless.digital", + "https://registry.npmjs.org", + ], ): Promise { for (const registry of registries) { - const latest = await this.getLatestVersionFromRegistry(registry, packageName); + const latest = await this.getLatestVersionFromRegistry( + registry, + packageName, + ); if (latest) { return latest; } @@ -99,23 +371,60 @@ export class PackageManagerUtil { return null; } - public async installLatest(packageName: string): Promise { - const packageSpecifier = `${packageName}@latest`; + 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(`pnpm add -g ${shellQuote(packageSpecifier)}`); + 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++) { + 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; @@ -127,7 +436,10 @@ export class PackageManagerUtil { private async getCurrentPnpmVersion(): Promise { try { - const result = await this.shell.execSilent("pnpm --version 2>/dev/null"); + 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 { @@ -135,6 +447,265 @@ export class PackageManagerUtil { } } + 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, @@ -164,6 +735,125 @@ export class PackageManagerUtil { } } +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]*/, "") diff --git a/ts/mod_tools/index.ts b/ts/mod_tools/index.ts index 84cf573..217b80b 100644 --- a/ts/mod_tools/index.ts +++ b/ts/mod_tools/index.ts @@ -51,7 +51,9 @@ async function runUpdate(argvArg: any, mode: ICliMode): Promise { const pnpmInfo = await pmUtil.getPnpmVersionInfo(); if (!pnpmInfo.available) { - console.log("pnpm is required for gitzone tools update, but it was not found."); + console.log( + "pnpm is required for gitzone tools update, but it was not found.", + ); return; } @@ -59,18 +61,25 @@ async function runUpdate(argvArg: any, mode: ICliMode): Promise { console.log(" Name Current Latest Status"); console.log(" ----------------------------------------------"); const latestPnpm = (pnpmInfo.latestVersion || "unknown").padEnd(12); - const pnpmStatus = pnpmInfo.latestVersion === null - ? "? Version unknown" - : pnpmInfo.needsUpdate - ? "Update available" - : "Up to date"; - console.log(` ${"pnpm".padEnd(9)}${pnpmInfo.currentVersion.padEnd(12)}${latestPnpm}${pnpmStatus}`); + const pnpmStatus = + pnpmInfo.latestVersion === null + ? "? Version unknown" + : pnpmInfo.needsUpdate + ? "Update available" + : "Up to date"; + console.log( + ` ${"pnpm".padEnd(9)}${pnpmInfo.currentVersion.padEnd(12)}${latestPnpm}${pnpmStatus}`, + ); console.log(""); if (verbose) { console.log("Using pnpm as the supported global package manager.\n"); } + const pnpmNeedsUpdate = Boolean( + pnpmInfo.latestVersion && pnpmInfo.needsUpdate, + ); + const selfUpdated = await handleSelfUpdate(pmUtil, mode); if (selfUpdated) { return; @@ -78,39 +87,79 @@ async function runUpdate(argvArg: any, mode: ICliMode): Promise { const installedPackages = await pmUtil.getInstalledPackages(); const packageInfos = await getPackageUpdateInfos(pmUtil, installedPackages); + const legacyRoots = await pmUtil.getLegacyGlobalRoots(); + const legacyCleanupNeeded = legacyRoots.length > 0; if (packageInfos.length === 0) { console.log("No managed @git.zone packages found installed globally."); - return; - } - - console.log("Installed @git.zone packages:\n"); - console.log(" Package Current Latest Status"); - console.log(" ------------------------------------------------------------"); - for (const packageInfo of packageInfos) { - const status = packageInfo.latestVersion === "unknown" - ? "? Version unknown" - : packageInfo.needsUpdate - ? "Update available" - : "Up to date"; + } else { + console.log("Installed @git.zone packages:\n"); console.log( - ` ${packageInfo.name.padEnd(28)}${packageInfo.currentVersion.padEnd(12)}${packageInfo.latestVersion.padEnd(12)}${status}`, + " Package Current Latest Status", ); + console.log( + " ------------------------------------------------------------", + ); + for (const packageInfo of packageInfos) { + console.log( + ` ${packageInfo.name.padEnd(28)}${packageInfo.currentVersion.padEnd(12)}${packageInfo.latestVersion.padEnd(12)}${getPackageStatus(packageInfo)}`, + ); + } + console.log(""); + + await printMissingPackages(pmUtil, installedPackages); } - console.log(""); - await printMissingPackages(pmUtil, installedPackages); + const packagesToUpdate = packageInfos.filter( + (packageInfo) => packageInfo.needsUpdate || packageInfo.needsMigration, + ); + const packagesToMigrate = packageInfos.filter( + (packageInfo) => packageInfo.needsMigration, + ); - const packagesToUpdate = packageInfos.filter((packageInfo) => packageInfo.needsUpdate); - if (packagesToUpdate.length === 0) { + if (packagesToMigrate.length > 0) { + console.log( + `Detected ${packagesToMigrate.length} package(s) in legacy pnpm global roots.`, + ); + if (verbose) { + for (const packageInfo of packagesToMigrate) { + console.log( + ` ${packageInfo.name} -> ${packageInfo.globalDir || "unknown"}`, + ); + } + } + console.log(""); + } else if (legacyCleanupNeeded) { + console.log( + `Detected ${legacyRoots.length} legacy pnpm global root(s) for cleanup.`, + ); + if (verbose) { + for (const legacyRoot of legacyRoots) { + console.log(` ${legacyRoot.globalDir}`); + } + } + console.log(""); + } + + if ( + packagesToUpdate.length === 0 && + !pnpmNeedsUpdate && + !legacyCleanupNeeded + ) { console.log("All managed packages are up to date."); return; } - console.log(`Found ${packagesToUpdate.length} package(s) with available updates.\n`); + const actionCount = + packagesToUpdate.length + + (pnpmNeedsUpdate ? 1 : 0) + + (legacyCleanupNeeded ? 1 : 0); + console.log(`Found ${actionCount} update action(s).\n`); if (!mode.yes && !mode.interactive) { - console.log("Run gitzone tools update -y to update without prompts."); + console.log( + "Run gitzone tools update -y to update, migrate, and cleanup without prompts.", + ); return; } @@ -120,7 +169,7 @@ async function runUpdate(argvArg: any, mode: ICliMode): Promise { const answer = await interactInstance.askQuestion({ type: "confirm", name: "confirmUpdate", - message: "Do you want to update these packages?", + message: "Do you want to update, migrate, and cleanup these tools?", default: true, }); shouldUpdate = answer.value === true; @@ -131,7 +180,35 @@ async function runUpdate(argvArg: any, mode: ICliMode): Promise { return; } - await installPackages(pmUtil, packagesToUpdate.map((packageInfo) => packageInfo.name), "updated"); + if (pnpmNeedsUpdate && pnpmInfo.latestVersion) { + console.log(`Updating pnpm to ${pnpmInfo.latestVersion}...`); + const success = await pmUtil.updatePnpm(pnpmInfo.latestVersion); + console.log( + success + ? "pnpm updated successfully.\n" + : "pnpm update failed. Continuing with package updates.\n", + ); + } + + const installResult = + packagesToUpdate.length > 0 + ? await installPackages( + pmUtil, + packagesToUpdate.map((packageInfo) => ({ + name: packageInfo.name, + version: + packageInfo.latestVersion !== "unknown" + ? packageInfo.latestVersion + : undefined, + })), + "updated", + ) + : { successCount: 0, failCount: 0 }; + + if (packagesToUpdate.length > 0 || legacyCleanupNeeded) { + await syncCurrentGlobalShims(pmUtil); + await cleanupLegacyInstalls(pmUtil); + } } async function runInstall(argvArg: any, mode: ICliMode): Promise { @@ -142,7 +219,9 @@ async function runInstall(argvArg: any, mode: ICliMode): Promise { const pnpmAvailable = await pmUtil.detectPnpm(); if (!pnpmAvailable) { - console.log("pnpm is required for gitzone tools install, but it was not found."); + console.log( + "pnpm is required for gitzone tools install, but it was not found.", + ); return; } @@ -151,8 +230,12 @@ async function runInstall(argvArg: any, mode: ICliMode): Promise { } const installedPackages = await pmUtil.getInstalledPackages(); - const installedNames = new Set(installedPackages.map((packageInfo) => packageInfo.name)); - const missingPackages = GITZONE_PACKAGES.filter((packageName) => !installedNames.has(packageName)); + const installedNames = new Set( + installedPackages.map((packageInfo) => packageInfo.name), + ); + const missingPackages = GITZONE_PACKAGES.filter( + (packageName) => !installedNames.has(packageName), + ); if (missingPackages.length === 0) { console.log("All managed @git.zone packages are already installed."); @@ -163,7 +246,9 @@ async function runInstall(argvArg: any, mode: ICliMode): Promise { if (!mode.yes && !mode.interactive) { await printPackageListWithLatest(pmUtil, missingPackages); - console.log("Run gitzone tools install -y to install all missing packages without prompts."); + console.log( + "Run gitzone tools install -y to install all missing packages without prompts.", + ); return; } @@ -210,7 +295,9 @@ async function handleSelfUpdate( return false; } - console.log(` @git.zone/cli ${currentVersion} -> ${latestVersion} Update available\n`); + console.log( + ` @git.zone/cli ${currentVersion} -> ${latestVersion} Update available\n`, + ); if (!mode.yes && !mode.interactive) { console.log("Run gitzone tools update -y to update gitzone first."); @@ -236,11 +323,15 @@ async function handleSelfUpdate( const success = await pmUtil.installLatest("@git.zone/cli"); if (!success) { - console.log("\ngitzone self-update failed. Continuing with the current version.\n"); + console.log( + "\ngitzone self-update failed. Continuing with the current version.\n", + ); return false; } - console.log("\ngitzone has been updated. Re-run gitzone tools update to check remaining packages."); + console.log( + "\ngitzone has been updated. Re-run gitzone tools update to check remaining packages.", + ); return true; } @@ -261,17 +352,39 @@ async function getPackageUpdateInfos( needsUpdate: latestVersion ? pmUtil.isNewerVersion(installedPackage.version, latestVersion) : false, + needsMigration: installedPackage.legacy === true, + globalDir: installedPackage.globalDir, }); } return packageInfos; } +function getPackageStatus(packageInfo: IPackageUpdateInfo): string { + if (packageInfo.latestVersion === "unknown") { + return "? Version unknown"; + } + if (packageInfo.needsUpdate && packageInfo.needsMigration) { + return "Update + migrate"; + } + if (packageInfo.needsUpdate) { + return "Update available"; + } + if (packageInfo.needsMigration) { + return "Migrate global root"; + } + return "Up to date"; +} + async function printMissingPackages( pmUtil: PackageManagerUtil, installedPackages: IInstalledPackage[], ): Promise { - const installedNames = new Set(installedPackages.map((packageInfo) => packageInfo.name)); - const missingPackages = GITZONE_PACKAGES.filter((packageName) => !installedNames.has(packageName)); + const installedNames = new Set( + installedPackages.map((packageInfo) => packageInfo.name), + ); + const missingPackages = GITZONE_PACKAGES.filter( + (packageName) => !installedNames.has(packageName), + ); if (missingPackages.length === 0) { return; } @@ -296,14 +409,18 @@ async function printPackageListWithLatest( async function installPackages( pmUtil: PackageManagerUtil, - packageNames: string[], + packageSpecs: Array, action: "installed" | "updated", -): Promise { +): Promise<{ successCount: number; failCount: number }> { let successCount = 0; let failCount = 0; - for (const packageName of packageNames) { - const success = await pmUtil.installLatest(packageName); + for (const packageSpec of packageSpecs) { + const packageName = + typeof packageSpec === "string" ? packageSpec : packageSpec.name; + const packageVersion = + typeof packageSpec === "string" ? undefined : packageSpec.version; + const success = await pmUtil.installLatest(packageName, packageVersion); if (success) { console.log(` ${packageName} ${action} successfully`); successCount++; @@ -319,6 +436,56 @@ async function installPackages( } else { console.log(`${successCount} package(s) ${action}, ${failCount} failed.`); } + + return { successCount, failCount }; +} + +async function cleanupLegacyInstalls( + pmUtil: PackageManagerUtil, +): Promise { + const cleanupResults = await pmUtil.cleanupLegacyGlobalRoots(); + if (cleanupResults.length === 0) { + return; + } + + console.log("Legacy pnpm global roots:\n"); + for (const cleanupResult of cleanupResults) { + if (cleanupResult.deleted) { + console.log(` ${cleanupResult.globalDir} deleted`); + } else { + console.log( + ` ${cleanupResult.globalDir} kept (${cleanupResult.reason || "unknown reason"})`, + ); + } + } + console.log(""); +} + +async function syncCurrentGlobalShims( + pmUtil: PackageManagerUtil, +): Promise { + const shimResults = await pmUtil.syncCurrentGlobalShims(); + const changedResults = shimResults.filter( + (shimResult) => shimResult.action !== "skipped", + ); + const skippedResults = shimResults.filter( + (shimResult) => shimResult.action === "skipped", + ); + + if (changedResults.length === 0 && skippedResults.length === 0) { + return; + } + + console.log("Command shims:\n"); + for (const shimResult of changedResults) { + console.log(` ${shimResult.name} ${shimResult.action}`); + } + for (const shimResult of skippedResults) { + console.log( + ` ${shimResult.name} skipped (${shimResult.reason || "unknown reason"})`, + ); + } + console.log(""); } export function showHelp(mode?: ICliMode): void { @@ -327,12 +494,21 @@ export function showHelp(mode?: ICliMode): void { name: "gitzone tools", usage: "gitzone tools [options]", commands: [ - { name: "update", description: "Check and update globally installed @git.zone packages" }, - { name: "install", description: "Install missing managed @git.zone packages" }, + { + name: "update", + description: "Check and update globally installed @git.zone packages", + }, + { + name: "install", + description: "Install missing managed @git.zone packages", + }, ], flags: [ { flag: "-y, --yes", description: "Run without confirmation prompts" }, - { flag: "-v, --verbose", description: "Show package manager diagnostics" }, + { + flag: "-v, --verbose", + description: "Show package manager diagnostics", + }, ], packageManager: "pnpm", managedPackages: GITZONE_PACKAGES, @@ -344,8 +520,12 @@ export function showHelp(mode?: ICliMode): void { console.log("Usage: gitzone tools [options]"); console.log(""); console.log("Commands:"); - console.log(" update Check and update globally installed @git.zone packages"); - console.log(" install Install missing managed @git.zone packages"); + console.log( + " update Check and update globally installed @git.zone packages", + ); + console.log( + " install Install missing managed @git.zone packages", + ); console.log(""); console.log("Options:"); console.log(" -y, --yes Run without confirmation prompts"); diff --git a/ts/plugins.ts b/ts/plugins.ts index a4611a9..580b1c4 100644 --- a/ts/plugins.ts +++ b/ts/plugins.ts @@ -2,6 +2,7 @@ import * as smartlog from '@push.rocks/smartlog'; import * as smartlogDestinationLocal from '@push.rocks/smartlog-destination-local'; import * as smartconfig from '@push.rocks/smartconfig'; import * as path from 'path'; +import * as fs from 'node:fs/promises'; import * as projectinfo from '@push.rocks/projectinfo'; import * as smartcli from '@push.rocks/smartcli'; import * as smartpath from '@push.rocks/smartpath'; @@ -22,6 +23,7 @@ export { smartlogDestinationLocal, smartconfig, path, + fs, projectinfo, smartcli, smartpath,