321 lines
9.8 KiB
TypeScript
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;
|
|
}
|
|
}
|