import * as plugins from './smartupdate.plugins.js'; import { UpdateCacheManager } from './smartupdate.classes.cachemanager.js'; import { UpdateNotifier } from './smartupdate.classes.notifier.js'; import { DEFAULT_CACHE_DURATION_HOURS, MILLISECONDS_PER_MINUTE, MINUTES_PER_HOUR } from './smartupdate.constants.js'; import type { ICacheStatus, ISmartUpdateOptions, IUpdateCheckOptions, IUpdateCheckResult, TCachedUpdateStatus, } from './smartupdate.interfaces.js'; import { SmartUpdateError, RegistryUnavailableError, PackageNotFoundError, InvalidPackageNameError, InvalidVersionError, } from './smartupdate.errors.js'; /** * SmartUpdate - Elegant update checking for Node.js packages * * @example * ```typescript * const smartUpdate = new SmartUpdate(); * const result = await smartUpdate.checkForUpdate({ * packageName: 'lodash', * currentVersion: '1.0.0', * changelogUrl: 'https://example.com/changelog' * }); * * if (result.status === 'update-available') { * console.log(`Update available: ${result.latestVersion}`); * } * ``` */ export class SmartUpdate { public readonly npmRegistry: plugins.smartnpm.NpmRegistry; private cacheManager: UpdateCacheManager; private notifier: UpdateNotifier; private options: ISmartUpdateOptions; /** * @deprecated Use the options parameter instead of kvStore property */ public kvStore: plugins.npmextra.KeyValueStore>; constructor(options: ISmartUpdateOptions = {}) { this.options = options; // Initialize npm registry this.npmRegistry = new plugins.smartnpm.NpmRegistry(options.npmRegistryOptions || {}); // Calculate cache duration in milliseconds const cacheDuration = options.cacheDuration || { hours: DEFAULT_CACHE_DURATION_HOURS }; const cacheDurationMs = (cacheDuration.hours || 0) * MINUTES_PER_HOUR * MILLISECONDS_PER_MINUTE + (cacheDuration.minutes || 0) * MILLISECONDS_PER_MINUTE + (cacheDuration.seconds || 0) * 1000; // Initialize cache manager this.cacheManager = new UpdateCacheManager({ durationMs: cacheDurationMs || DEFAULT_CACHE_DURATION_HOURS * MINUTES_PER_HOUR * MILLISECONDS_PER_MINUTE, storeIdentifier: options.cacheStore?.storeIdentifier, storeType: options.cacheStore?.storeType, customPath: options.cacheStore?.customPath, }); // Initialize notifier this.notifier = new UpdateNotifier({ logLevel: options.logLevel || 'INFO', useColors: !options.noColor, customLogger: options.customLogger, }); // Backward compatibility: expose kvStore this.kvStore = this.cacheManager.kvStore; } /** * Check for updates with caching (primarily for CLI use) * * @deprecated Use checkForUpdate with cacheStrategy: 'time-based' instead * @param packageName - The npm package name to check * @param currentVersion - The current version to compare against * @param changelogUrl - Optional URL to open if update is available * @returns Promise resolving to true if update available, false otherwise * * @example * ```typescript * const hasUpdate = await smartUpdate.checkForCli('my-cli', '1.0.0', 'https://changelog.url'); * ``` */ public async checkForCli( packageName: string, currentVersion: string, changelogUrl?: string ): Promise { const result = await this.checkForUpdate({ packageName, currentVersion, changelogUrl, cacheStrategy: 'time-based', }); return ( result.status === 'update-available' || (result.status === 'check-skipped' && !!result.latestVersion && this.safeIsUpdateAvailable(result.latestVersion, currentVersion)) ); } /** * Check for updates (modern API) * * @param options - Update check options * @returns Promise resolving to detailed update check result * * @example * ```typescript * const result = await smartUpdate.checkForUpdate({ * packageName: 'lodash', * currentVersion: '1.0.0', * changelogUrl: 'https://changelog.url', * openChangelog: true, * cacheStrategy: 'time-based' * }); * * console.log(result.status); // 'up-to-date' | 'update-available' | 'check-skipped' | 'error' * ``` */ public async checkForUpdate(options: IUpdateCheckOptions): Promise { const { packageName, currentVersion, changelogUrl, openChangelog = true, cacheStrategy = 'time-based', } = options; const checkTime = new Date(); try { this.validatePackageName(packageName); const versionLocal = this.createSmartVersion(currentVersion); const registryUrl = this.getRegistryUrl(); // Check if we should use cache or check registry const cacheCheck = await this.cacheManager.shouldCheckRegistry(packageName, cacheStrategy, { currentVersion, registryUrl, }); // If we should skip the check due to cache if (!cacheCheck.shouldCheck) { const skippedResult: IUpdateCheckResult = { status: 'check-skipped', packageName, currentVersion, latestVersion: cacheCheck.cacheStatus?.latestVersion, checkTime, cacheHit: true, nextCheckTime: cacheCheck.nextCheckTime, reason: cacheStrategy === 'always' ? 'Cached result reused' : 'Rate limited - checked recently', }; this.notifier.notifyUpdateCheckResult(skippedResult); return skippedResult; } // Fetch package info from registry const npmPackage = await this.getNpmPackageFromRegistry(packageName); // Compare versions const versionNpm = this.createSmartVersion(npmPackage.version); const status: TCachedUpdateStatus = versionNpm.greaterThan(versionLocal) ? 'update-available' : 'up-to-date'; const result: IUpdateCheckResult = { status, packageName: npmPackage.name, currentVersion, latestVersion: npmPackage.version, checkTime, cacheHit: false, }; // Notify user this.notifier.notifyUpdateCheckResult(result); // If update is available, handle changelog if (result.status === 'update-available' && changelogUrl && openChangelog && !process.env.CI) { this.notifier.notifyOpeningChangelog(); await plugins.smartopen.openUrl(changelogUrl).catch((error) => { this.notifier.warn( `Could not open changelog: ${error instanceof Error ? error.message : String(error)}` ); }); } // Cache both positive and negative checks to avoid repeated registry hits. const cacheStatus = this.cacheManager.createCacheStatus(npmPackage.version, false, { currentVersion, registryUrl, status, }); await this.cacheManager.setCached(packageName, cacheStatus); return result; } catch (error) { if (error instanceof RegistryUnavailableError) { this.notifier.notifyRegistryError(); } else { this.notifier.error(error instanceof Error ? error.message : String(error)); } return { status: 'error', packageName, currentVersion, checkTime, cacheHit: false, error: error instanceof Error ? error : new Error(String(error)), }; } } /** * Simple check for updates without caching * * @deprecated Use checkForUpdate with cacheStrategy: 'never' instead * @param packageName - The npm package name to check * @param currentVersion - The current version to compare against * @param changelogUrl - Optional URL to open if update is available * @returns Promise resolving to true if update available, false otherwise * * @example * ```typescript * const hasUpdate = await smartUpdate.check('lodash', '1.0.0'); * ``` */ public async check( packageName: string, currentVersion: string, changelogUrl?: string ): Promise { const result = await this.checkForUpdate({ packageName, currentVersion, changelogUrl, cacheStrategy: 'never', }); return result.status === 'update-available'; } /** * Get the latest version of a package * * @param packageName - The npm package name * @returns Promise resolving to the latest version string * * @example * ```typescript * const latestVersion = await smartUpdate.getLatestVersion('lodash'); * console.log(latestVersion); // e.g., "4.17.21" * ``` */ public async getLatestVersion(packageName: string): Promise { this.validatePackageName(packageName); const npmPackage = await this.getNpmPackageFromRegistry(packageName); return npmPackage.version; } /** * Clear the cache for a specific package * * @param packageName - The package name to clear cache for * * @example * ```typescript * await smartUpdate.clearCache('lodash'); * ``` */ public async clearCache(packageName: string): Promise { await this.cacheManager.clearCache(packageName); } /** * Fetch package information from the npm registry * @private */ private async getNpmPackageFromRegistry(packageName: string): Promise { this.notifier.notifyCheckingForUpdate(packageName); try { const npmPackage = await this.npmRegistry.getPackageInfo(packageName); if (!npmPackage?.version) { throw new PackageNotFoundError(packageName); } return npmPackage; } catch (error) { if (error instanceof SmartUpdateError) { throw error; } const message = error instanceof Error ? error.message : String(error); const lowerMessage = message.toLowerCase(); if (lowerMessage.includes('not found') || lowerMessage.includes('404')) { throw new PackageNotFoundError(packageName); } throw new RegistryUnavailableError(message); } } private validatePackageName(packageName: string): void { if (!packageName || packageName.trim() !== packageName || /\s/.test(packageName)) { throw new InvalidPackageNameError(packageName); } } private createSmartVersion(version: string): plugins.smartversion.SmartVersion { try { return new plugins.smartversion.SmartVersion(version); } catch (error) { throw new InvalidVersionError(version, error instanceof Error ? error.message : String(error)); } } private isUpdateAvailable(latestVersion: string, currentVersion: string): boolean { const versionNpm = this.createSmartVersion(latestVersion); const versionLocal = this.createSmartVersion(currentVersion); return versionNpm.greaterThan(versionLocal); } private safeIsUpdateAvailable(latestVersion: string, currentVersion: string): boolean { try { return this.isUpdateAvailable(latestVersion, currentVersion); } catch { return false; } } private getRegistryUrl(): string { return this.npmRegistry.options.npmRegistryUrl || 'https://registry.npmjs.org'; } }