Files
tools/ts/mod_update/classes.packagemanager.ts

321 lines
9.8 KiB
TypeScript

import * as plugins from './mod.plugins.js';
export type TPackageManager = 'npm' | 'yarn' | 'pnpm';
export interface IInstalledPackage {
name: string;
version: string;
packageManager: TPackageManager;
}
export interface IPackageUpdateInfo {
name: string;
currentVersion: string;
latestVersion: string;
packageManager: TPackageManager;
needsUpdate: boolean;
}
export interface IPackageManagerInfo {
name: TPackageManager;
available: boolean;
detectionMethod?: 'which' | 'version-command';
path?: string;
currentVersion?: string;
latestVersion?: string | null;
needsUpdate?: boolean;
}
export class PackageManagerUtil {
private shell = new plugins.smartshell.Smartshell({
executor: 'bash',
});
/**
* Check if a package manager is available on the system
* Uses multiple detection methods for robustness across different shell contexts
*/
public async isAvailable(pm: TPackageManager, verbose = false): Promise<boolean> {
const info = await this.detectPackageManager(pm, verbose);
return info.available;
}
/**
* Detect a package manager and return detailed info
*/
public async detectPackageManager(pm: TPackageManager, verbose = false): Promise<IPackageManagerInfo> {
const info: IPackageManagerInfo = { name: pm, available: false };
// Primary method: try 'which' command
try {
const whichResult = await this.shell.execSilent(`which ${pm} 2>/dev/null`);
if (whichResult.exitCode === 0 && whichResult.stdout.trim()) {
info.available = true;
info.detectionMethod = 'which';
info.path = whichResult.stdout.trim();
if (verbose) {
console.log(` Checking ${pm}... found via 'which' at ${info.path}`);
}
return info;
}
} catch {
// Continue to fallback
}
// Fallback method: try running pm --version directly
// This can find PMs that are available but not in PATH for 'which'
try {
const versionResult = await this.shell.execSilent(`${pm} --version 2>/dev/null`);
if (versionResult.exitCode === 0 && versionResult.stdout.trim()) {
info.available = true;
info.detectionMethod = 'version-command';
if (verbose) {
console.log(` Checking ${pm}... found via '--version' (which failed)`);
}
return info;
}
} catch {
// Not available
}
if (verbose) {
console.log(` Checking ${pm}... not found`);
}
return info;
}
/**
* Get the current and latest version of a package manager
*/
public async getPackageManagerVersion(pm: TPackageManager): Promise<{ current: string; latest: string | null }> {
let current = 'unknown';
let latest: string | null = null;
// Get current version
try {
const result = await this.shell.execSilent(`${pm} --version 2>/dev/null`);
if (result.exitCode === 0 && result.stdout.trim()) {
// Parse version from output - handle different formats
const output = result.stdout.trim();
// npm: "10.2.0", pnpm: "8.15.0", yarn: "1.22.19"
// Some may include prefix like "v1.22.19"
const versionMatch = output.match(/(\d+\.\d+\.\d+)/);
if (versionMatch) {
current = versionMatch[1];
}
}
} catch {
// Keep as unknown
}
// Get latest version from npm registry
try {
const result = await this.shell.execSilent(`npm view ${pm} version 2>/dev/null`);
if (result.exitCode === 0 && result.stdout.trim()) {
latest = result.stdout.trim();
}
} catch {
// Keep as null
}
return { current, latest };
}
/**
* Get all globally installed @git.zone packages for a package manager
*/
public async getInstalledPackages(pm: TPackageManager): Promise<IInstalledPackage[]> {
const packages: IInstalledPackage[] = [];
try {
let result;
switch (pm) {
case 'npm':
result = await this.shell.execSilent('npm list -g --depth=0 --json 2>/dev/null || true');
break;
case 'yarn':
result = await this.shell.execSilent('yarn global list --depth=0 --json 2>/dev/null || true');
break;
case 'pnpm':
result = await this.shell.execSilent('pnpm list -g --depth=0 --json 2>/dev/null || true');
break;
}
const output = result.stdout.trim();
if (!output) {
return packages;
}
if (pm === 'npm') {
try {
const data = JSON.parse(output);
const deps = data.dependencies || {};
for (const [name, info] of Object.entries(deps)) {
if (name.startsWith('@git.zone/')) {
packages.push({
name,
version: (info as any).version || 'unknown',
packageManager: pm,
});
}
}
} catch {
// JSON parse failed
}
} else if (pm === 'pnpm') {
// pnpm returns an array of objects
try {
const data = JSON.parse(output);
// Handle array format from pnpm
const dataArray = Array.isArray(data) ? data : [data];
for (const item of dataArray) {
const deps = item.dependencies || {};
for (const [name, info] of Object.entries(deps)) {
if (name.startsWith('@git.zone/')) {
packages.push({
name,
version: (info as any).version || 'unknown',
packageManager: pm,
});
}
}
}
} catch {
// JSON parse failed
}
} else if (pm === 'yarn') {
// Yarn global list --json outputs multiple JSON lines
const lines = output.split('\n').filter(l => l.trim());
for (const line of lines) {
try {
const data = JSON.parse(line);
if (data.type === 'tree' && data.data && data.data.trees) {
for (const tree of data.data.trees) {
const parsed = PackageManagerUtil.parseYarnPackageName(tree.name || '');
if (parsed.name.startsWith('@git.zone/')) {
packages.push({
name: parsed.name,
version: parsed.version,
packageManager: pm,
});
}
}
}
} catch {
// Skip invalid JSON lines
}
}
}
} catch {
// Command failed, return empty array
}
return packages;
}
/**
* Get the latest version of a package from npm registry
* Tries private registry (verdaccio.lossless.digital) first via API, then falls back to public npm
*/
public async getLatestVersion(packageName: string): Promise<string | null> {
// URL-encode the package name for scoped packages (@scope/name -> @scope%2fname)
const encodedName = packageName.replace('/', '%2f');
// Try private registry first via direct API call (npm view doesn't work reliably)
try {
const result = await this.shell.execSilent(
`curl -sf "https://verdaccio.lossless.digital/${encodedName}" 2>/dev/null`
);
if (result.exitCode === 0 && result.stdout.trim()) {
const data = JSON.parse(result.stdout.trim());
if (data['dist-tags']?.latest) {
return data['dist-tags'].latest;
}
}
} catch {
// Continue to public registry
}
// Fall back to public npm
try {
const result = await this.shell.execSilent(`npm view ${packageName} version 2>/dev/null`);
if (result.exitCode === 0 && result.stdout.trim()) {
return result.stdout.trim();
}
} catch {
// Command failed
}
return null;
}
/**
* Execute an update for a package using the specified package manager
*/
public async executeUpdate(pm: TPackageManager, packageName: string): Promise<boolean> {
let command: string;
switch (pm) {
case 'npm':
command = `npm install -g ${packageName}@latest`;
break;
case 'yarn':
command = `yarn global add ${packageName}@latest`;
break;
case 'pnpm':
command = `pnpm add -g ${packageName}@latest`;
break;
}
console.log(` Updating ${packageName} via ${pm}...`);
try {
const result = await this.shell.exec(command);
return result.exitCode === 0;
} catch {
return false;
}
}
/**
* Parse a yarn package name string like "@git.zone/cli@1.0.0" into name and version.
* Handles scoped packages correctly by splitting on the last '@' (version separator).
*/
public static parseYarnPackageName(fullName: string): { name: string; version: string } {
if (!fullName) {
return { name: '', version: 'unknown' };
}
const lastAtIndex = fullName.lastIndexOf('@');
// If lastAtIndex is 0, the string is just "@something" with no version
// If lastAtIndex is -1, there's no '@' at all
if (lastAtIndex <= 0) {
return { name: fullName, version: 'unknown' };
}
return {
name: fullName.substring(0, lastAtIndex),
version: fullName.substring(lastAtIndex + 1) || 'unknown',
};
}
/**
* Compare two semver versions
* Returns true if latest > current
*/
public isNewerVersion(current: string, latest: string): boolean {
const cleanVersion = (v: string) => v.replace(/^[^\d]*/, '');
const currentClean = cleanVersion(current);
const latestClean = cleanVersion(latest);
const currentParts = currentClean.split('.').map(n => parseInt(n, 10) || 0);
const latestParts = latestClean.split('.').map(n => parseInt(n, 10) || 0);
for (let i = 0; i < Math.max(currentParts.length, latestParts.length); i++) {
const curr = currentParts[i] || 0;
const lat = latestParts[i] || 0;
if (lat > curr) return true;
if (lat < curr) return false;
}
return false;
}
}