From 3d1a73cf9ead19a10c19bcc36cdce96a20d425b0 Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Sun, 10 May 2026 15:05:00 +0000 Subject: [PATCH] fix(smartupdate): improve update check caching, validation, and error handling --- .smartconfig.json | 23 ++- changelog.md | 12 +- deno.lock | 2 - package.json | 1 - pnpm-lock.yaml | 3 - readme.plan.md | 22 +++ test/test.node+bun+deno.ts | 209 +++++++++++++++++-------- ts/index.ts | 3 + ts/smartupdate.classes.cachemanager.ts | 59 +++++-- ts/smartupdate.classes.smartupdate.ts | 122 ++++++++++++--- ts/smartupdate.interfaces.ts | 31 +++- ts/smartupdate.plugins.ts | 3 +- 12 files changed, 376 insertions(+), 114 deletions(-) create mode 100644 readme.plan.md diff --git a/.smartconfig.json b/.smartconfig.json index b2a2701..3386499 100644 --- a/.smartconfig.json +++ b/.smartconfig.json @@ -1,5 +1,6 @@ { "@git.zone/cli": { + "schemaVersion": 2, "projectType": "npm", "module": { "githost": "code.foss.global", @@ -11,11 +12,23 @@ "projectDomain": "push.rocks" }, "release": { - "registries": [ - "https://verdaccio.lossless.digital", - "https://registry.npmjs.org" - ], - "accessLevel": "public" + "targets": { + "git": { + "enabled": true, + "remote": "origin" + }, + "npm": { + "enabled": false, + "registries": [ + "https://verdaccio.lossless.digital", + "https://registry.npmjs.org" + ], + "accessLevel": "public" + }, + "docker": { + "enabled": false + } + } } }, "@git.zone/tsdoc": { diff --git a/changelog.md b/changelog.md index c7d15e2..0e0f2df 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,15 @@ # Changelog +## Pending + +### Fixes + +- improve update check caching, validation, and error handling (smartupdate) + - cache both update-available and up-to-date results with package version and registry context to avoid stale or incorrect cache reuse + - return cached CLI update results correctly for time-based checks and handle changelog opening failures without unhandled rejections + - validate package names and versions before registry access, preserve specific error types, and add deterministic cache-focused tests + - add configurable cache store options and remove the unused smarttime dependency + ## 2026-05-01 - 2.0.7 - fix(build) modernize build configuration and tighten TypeScript typings @@ -89,4 +99,4 @@ Delivered multiple early improvements across versions 1.0.1 to 1.0.10. - Improved update information output - Fixed a bug that reset the timer - Updated dependencies -- Included compile and maintenance-related updates \ No newline at end of file +- Included compile and maintenance-related updates diff --git a/deno.lock b/deno.lock index 8d2e36e..f8b57c7 100644 --- a/deno.lock +++ b/deno.lock @@ -8,7 +8,6 @@ "npm:@push.rocks/npmextra@^5.3.3": "5.3.3", "npm:@push.rocks/smartnpm@^2.0.6": "2.0.6", "npm:@push.rocks/smartopen@2": "2.0.0", - "npm:@push.rocks/smarttime@^4.2.3": "4.2.3", "npm:@push.rocks/smartversion@^3.1.0": "3.1.0", "npm:@types/lodash.clonedeep@^4.5.9": "4.5.9", "npm:@types/node@^25.6.0": "25.6.0" @@ -6742,7 +6741,6 @@ "npm:@push.rocks/npmextra@^5.3.3", "npm:@push.rocks/smartnpm@^2.0.6", "npm:@push.rocks/smartopen@2", - "npm:@push.rocks/smarttime@^4.2.3", "npm:@push.rocks/smartversion@^3.1.0", "npm:@types/lodash.clonedeep@^4.5.9", "npm:@types/node@^25.6.0" diff --git a/package.json b/package.json index 7a56533..2de2332 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,6 @@ "@push.rocks/npmextra": "^5.3.3", "@push.rocks/smartnpm": "^2.0.6", "@push.rocks/smartopen": "^2.0.0", - "@push.rocks/smarttime": "^4.2.3", "@push.rocks/smartversion": "^3.1.0" }, "files": [ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 85b8739..2061d96 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,9 +20,6 @@ importers: '@push.rocks/smartopen': specifier: ^2.0.0 version: 2.0.0 - '@push.rocks/smarttime': - specifier: ^4.2.3 - version: 4.2.3 '@push.rocks/smartversion': specifier: ^3.1.0 version: 3.1.0 diff --git a/readme.plan.md b/readme.plan.md new file mode 100644 index 0000000..c7d8f43 --- /dev/null +++ b/readme.plan.md @@ -0,0 +1,22 @@ +# smartupdate improvement plan + +Status: implemented. + +## Goal + +Make update checks predictable, cache-safe, and testable without changing the core public API shape. + +## Implementation + +- Fix CLI cache behavior so cached update information still reports an available update and time-based cache entries expire. +- Cache both `update-available` and `up-to-date` results so normal repeated CLI runs do not keep hitting the registry. +- Store cache context with each entry and only reuse cache entries for the same package, current version, and registry. +- Preserve more useful error types for invalid package names, invalid versions, package lookup failures, and registry failures. +- Await and catch changelog opening so browser failures do not become unhandled promise rejections. +- Replace live npm tests with deterministic registry stubs and ephemeral cache storage. +- Remove unused dependency surface. + +## Verification + +- Run `pnpm test`. +- Run `pnpm run build`. diff --git a/test/test.node+bun+deno.ts b/test/test.node+bun+deno.ts index 4e336bf..c91d418 100644 --- a/test/test.node+bun+deno.ts +++ b/test/test.node+bun+deno.ts @@ -1,126 +1,213 @@ import { expect, tap } from '@git.zone/tstest/tapbundle'; +import type * as smartnpm from '@push.rocks/smartnpm'; import * as smartupdate from '../ts/index.js'; -let testSmartUpdate: smartupdate.SmartUpdate; -let silentSmartUpdate: smartupdate.SmartUpdate; +const createMockedSmartUpdate = (versionsArg: Record) => { + const checkedPackages: string[] = []; + const testSmartUpdate = new smartupdate.SmartUpdate({ + logLevel: 'SILENT', + noColor: true, + cacheDuration: { minutes: 30 }, + cacheStore: { storeType: 'ephemeral' }, + }); + + testSmartUpdate.npmRegistry.getPackageInfo = async (packageNameArg: string) => { + checkedPackages.push(packageNameArg); + const version = versionsArg[packageNameArg]; + if (!version) { + throw new Error(`Package not found: ${packageNameArg}`); + } + return { + name: packageNameArg, + version, + } as unknown as smartnpm.NpmPackage; + }; + + return { testSmartUpdate, checkedPackages }; +}; -// Test suite for backward compatibility tap.test('backward compatibility: should create an instance of SmartUpdate', async () => { - testSmartUpdate = new smartupdate.SmartUpdate(); + const { testSmartUpdate } = createMockedSmartUpdate({ lodash: '4.17.21' }); expect(testSmartUpdate).toBeInstanceOf(smartupdate.SmartUpdate); }); tap.test('backward compatibility: should check for a npm module using old API', async () => { + const { testSmartUpdate } = createMockedSmartUpdate({ lodash: '4.17.21' }); const result = await testSmartUpdate.check('lodash', '1.0.5'); expect(result).toBeTrue(); }); -// Test suite for new modern API -tap.test('modern API: should create SmartUpdate with custom options', async () => { - silentSmartUpdate = new smartupdate.SmartUpdate({ - logLevel: 'SILENT', - noColor: true, - cacheDuration: { minutes: 30 }, - }); - expect(silentSmartUpdate).toBeInstanceOf(smartupdate.SmartUpdate); -}); - tap.test('modern API: checkForUpdate should return rich result object', async () => { - const result = await silentSmartUpdate.checkForUpdate({ + const { testSmartUpdate } = createMockedSmartUpdate({ lodash: '4.17.21' }); + const result = await testSmartUpdate.checkForUpdate({ packageName: 'lodash', currentVersion: '1.0.0', cacheStrategy: 'never', }); - // Verify result structure expect(result).toBeTypeOf('object'); - expect(result.status).toBeTypeOf('string'); + expect(result.status).toEqual('update-available'); expect(result.packageName).toEqual('lodash'); expect(result.currentVersion).toEqual('1.0.0'); + expect(result.latestVersion).toEqual('4.17.21'); expect(result.checkTime).toBeInstanceOf(Date); - expect(result.cacheHit).toBeTypeOf('boolean'); - - // Should have update available (1.0.0 is old) - expect(result.status).toEqual('update-available'); - expect(result.latestVersion).toBeTypeOf('string'); + expect(result.cacheHit).toBeFalse(); }); tap.test('modern API: checkForUpdate with up-to-date version', async () => { - const result = await silentSmartUpdate.checkForUpdate({ + const { testSmartUpdate } = createMockedSmartUpdate({ '@push.rocks/smartversion': '3.1.0' }); + const result = await testSmartUpdate.checkForUpdate({ packageName: '@push.rocks/smartversion', - currentVersion: '999.999.999', // Future version + currentVersion: '999.999.999', cacheStrategy: 'never', }); expect(result.status).toEqual('up-to-date'); - expect(result.latestVersion).toBeTypeOf('string'); + expect(result.latestVersion).toEqual('3.1.0'); }); tap.test('modern API: getLatestVersion utility method', async () => { - const latestVersion = await silentSmartUpdate.getLatestVersion('lodash'); - expect(latestVersion).toBeTypeOf('string'); - expect(latestVersion).toMatch(/^\d+\.\d+\.\d+/); // Semver format + const { testSmartUpdate } = createMockedSmartUpdate({ lodash: '4.17.21' }); + const latestVersion = await testSmartUpdate.getLatestVersion('lodash'); + expect(latestVersion).toEqual('4.17.21'); }); tap.test('modern API: error handling for non-existent package', async () => { - const result = await silentSmartUpdate.checkForUpdate({ + const { testSmartUpdate } = createMockedSmartUpdate({}); + const result = await testSmartUpdate.checkForUpdate({ packageName: 'this-package-definitely-does-not-exist-12345', currentVersion: '1.0.0', cacheStrategy: 'never', }); expect(result.status).toEqual('error'); - expect(result.error).toBeInstanceOf(Error); + expect(result.error).toBeInstanceOf(smartupdate.PackageNotFoundError); }); -tap.test('modern API: cache strategy works', async () => { - // Clear cache first to ensure clean state - const testPackage = 'express'; - try { - await silentSmartUpdate.clearCache(testPackage); - } catch (e) { - // Cache might not exist, that's fine - } +tap.test('modern API: invalid versions are reported before registry access', async () => { + const { testSmartUpdate, checkedPackages } = createMockedSmartUpdate({ lodash: '4.17.21' }); + const result = await testSmartUpdate.checkForUpdate({ + packageName: 'lodash', + currentVersion: 'not-a-version', + cacheStrategy: 'never', + }); - // First check - should hit registry with 'never' strategy - const result1 = await silentSmartUpdate.checkForUpdate({ - packageName: testPackage, + expect(result.status).toEqual('error'); + expect(result.error).toBeInstanceOf(smartupdate.InvalidVersionError); + expect(checkedPackages).toHaveLength(0); +}); + +tap.test('modern API: invalid package names are reported before registry access', async () => { + const { testSmartUpdate, checkedPackages } = createMockedSmartUpdate({ lodash: '4.17.21' }); + const result = await testSmartUpdate.checkForUpdate({ + packageName: 'invalid package name', currentVersion: '1.0.0', cacheStrategy: 'never', }); - expect(result1.cacheHit).toBeFalse(); - expect(result1.status).toEqual('update-available'); + expect(result.status).toEqual('error'); + expect(result.error).toBeInstanceOf(smartupdate.InvalidPackageNameError); + expect(checkedPackages).toHaveLength(0); +}); - // Do a second check with time-based that will create cache - const result2 = await silentSmartUpdate.checkForUpdate({ - packageName: testPackage, +tap.test('modern API: caches update-available results for time-based checks', async () => { + const { testSmartUpdate, checkedPackages } = createMockedSmartUpdate({ express: '5.0.0' }); + + const result1 = await testSmartUpdate.checkForUpdate({ + packageName: 'express', + currentVersion: '1.0.0', + cacheStrategy: 'time-based', + }); + const result2 = await testSmartUpdate.checkForUpdate({ + packageName: 'express', currentVersion: '1.0.0', cacheStrategy: 'time-based', }); - // Third immediate check - should use cache with 'always' strategy - const result3 = await silentSmartUpdate.checkForUpdate({ - packageName: testPackage, - currentVersion: '1.0.0', - cacheStrategy: 'always', - }); - - expect(result3.cacheHit).toBeTrue(); - expect(result3.status).toEqual('check-skipped'); - // nextCheckTime may or may not be set depending on cache strategy implementation - if (result3.nextCheckTime) { - expect(result3.nextCheckTime).toBeInstanceOf(Date); - } + expect(result1.status).toEqual('update-available'); + expect(result2.status).toEqual('check-skipped'); + expect(result2.cacheHit).toBeTrue(); + expect(result2.latestVersion).toEqual('5.0.0'); + expect(checkedPackages).toHaveLength(1); }); -tap.test('modern API: exports all types and classes', async () => { - // Verify exports +tap.test('modern API: caches up-to-date results for time-based checks', async () => { + const { testSmartUpdate, checkedPackages } = createMockedSmartUpdate({ express: '5.0.0' }); + + const result1 = await testSmartUpdate.checkForUpdate({ + packageName: 'express', + currentVersion: '5.0.0', + cacheStrategy: 'time-based', + }); + const result2 = await testSmartUpdate.checkForUpdate({ + packageName: 'express', + currentVersion: '5.0.0', + cacheStrategy: 'time-based', + }); + + expect(result1.status).toEqual('up-to-date'); + expect(result2.status).toEqual('check-skipped'); + expect(result2.cacheHit).toBeTrue(); + expect(result2.latestVersion).toEqual('5.0.0'); + expect(checkedPackages).toHaveLength(1); +}); + +tap.test('modern API: cache entries are scoped to the checked current version', async () => { + const { testSmartUpdate, checkedPackages } = createMockedSmartUpdate({ express: '5.0.0' }); + + const result1 = await testSmartUpdate.checkForUpdate({ + packageName: 'express', + currentVersion: '1.0.0', + cacheStrategy: 'time-based', + }); + const result2 = await testSmartUpdate.checkForUpdate({ + packageName: 'express', + currentVersion: '5.0.0', + cacheStrategy: 'time-based', + }); + + expect(result1.status).toEqual('update-available'); + expect(result2.status).toEqual('up-to-date'); + expect(result2.cacheHit).toBeFalse(); + expect(checkedPackages).toHaveLength(2); +}); + +tap.test('modern API: checkForCli returns true for cached known updates', async () => { + const { testSmartUpdate, checkedPackages } = createMockedSmartUpdate({ 'my-cli': '2.0.0' }); + + const result1 = await testSmartUpdate.checkForCli('my-cli', '1.0.0'); + const result2 = await testSmartUpdate.checkForCli('my-cli', '1.0.0'); + + expect(result1).toBeTrue(); + expect(result2).toBeTrue(); + expect(checkedPackages).toHaveLength(1); +}); + +tap.test('cache manager: clears package-specific and complete cache data', async () => { + const cacheManager = new smartupdate.UpdateCacheManager({ + durationMs: 60_000, + storeType: 'ephemeral', + }); + + await cacheManager.setCached('one', cacheManager.createCacheStatus('1.0.0')); + await cacheManager.setCached('two', cacheManager.createCacheStatus('2.0.0')); + + await cacheManager.clearCache('one'); + expect(await cacheManager.getCached('one')).toBeNull(); + expect(await cacheManager.getCached('two')).toBeTypeOf('object'); + + await cacheManager.clearCache(); + expect(await cacheManager.getCached('two')).toBeNull(); +}); + +tap.test('modern API: exports all runtime classes and constants', async () => { expect(smartupdate.SmartUpdate).toBeTypeOf('function'); expect(smartupdate.UpdateCacheManager).toBeTypeOf('function'); expect(smartupdate.UpdateNotifier).toBeTypeOf('function'); expect(smartupdate.RegistryUnavailableError).toBeTypeOf('function'); expect(smartupdate.PackageNotFoundError).toBeTypeOf('function'); + expect(smartupdate.InvalidVersionError).toBeTypeOf('function'); + expect(smartupdate.InvalidPackageNameError).toBeTypeOf('function'); expect(smartupdate.LOG_LEVELS).toBeTypeOf('object'); }); diff --git a/ts/index.ts b/ts/index.ts index f15b450..f2886e0 100644 --- a/ts/index.ts +++ b/ts/index.ts @@ -13,6 +13,9 @@ export type { ICacheStatus, ICacheOptions, INotificationOptions, + TCacheStrategy, + TCacheStoreType, + TCachedUpdateStatus, } from './smartupdate.interfaces.js'; // Error classes diff --git a/ts/smartupdate.classes.cachemanager.ts b/ts/smartupdate.classes.cachemanager.ts index 1b3f9f4..bbfb59b 100644 --- a/ts/smartupdate.classes.cachemanager.ts +++ b/ts/smartupdate.classes.cachemanager.ts @@ -1,18 +1,21 @@ import * as plugins from './smartupdate.plugins.js'; -import type { ICacheStatus, ICacheOptions } from './smartupdate.interfaces.js'; +import type { ICacheStatus, ICacheOptions, TCacheStrategy, TCachedUpdateStatus } from './smartupdate.interfaces.js'; + +type TCacheStoreData = Record; /** * Manages caching of update check results */ export class UpdateCacheManager { - public readonly kvStore: plugins.npmextra.KeyValueStore; + public readonly kvStore: plugins.npmextra.KeyValueStore; private cacheDurationMs: number; constructor(options: ICacheOptions) { this.cacheDurationMs = options.durationMs; - this.kvStore = new plugins.npmextra.KeyValueStore({ - typeArg: 'userHomeDir', + this.kvStore = new plugins.npmextra.KeyValueStore({ + typeArg: options.storeType || 'userHomeDir', identityArg: options.storeIdentifier || 'global_smartupdate', + customPath: options.customPath, }); } @@ -27,7 +30,7 @@ export class UpdateCacheManager { * Get cached status for a package */ public async getCached(packageName: string): Promise { - return await this.kvStore.readKey(packageName); + return (await this.kvStore.readKey(packageName)) || null; } /** @@ -42,11 +45,12 @@ export class UpdateCacheManager { */ public async clearCache(packageName?: string): Promise { if (packageName) { - await this.kvStore.deleteKey(packageName); + const cacheData = await this.kvStore.readAll(); + delete cacheData[packageName]; + await this.kvStore.wipe(); + await this.kvStore.writeAll(cacheData); } else { - // Clear all keys - this requires reading all keys first - // For now, we'll skip implementing full cache clear as it requires more kvStore API - throw new Error('Clearing all cache entries is not yet implemented'); + await this.kvStore.wipe(); } } @@ -56,7 +60,11 @@ export class UpdateCacheManager { */ public async shouldCheckRegistry( packageName: string, - strategy: 'always' | 'never' | 'time-based' = 'time-based' + strategy: TCacheStrategy = 'time-based', + cacheContext: { + currentVersion?: string; + registryUrl?: string; + } = {} ): Promise<{ shouldCheck: boolean; cacheStatus?: ICacheStatus; @@ -72,7 +80,7 @@ export class UpdateCacheManager { const cacheStatus = await this.getCached(packageName); // No cache exists - if (!cacheStatus) { + if (!cacheStatus || !this.cacheMatchesContext(cacheStatus, cacheContext)) { return { shouldCheck: true }; } @@ -106,11 +114,38 @@ export class UpdateCacheManager { /** * Create a new cache status object */ - public createCacheStatus(latestVersion: string, performedUpgrade: boolean = false): ICacheStatus { + public createCacheStatus( + latestVersion: string, + performedUpgrade: boolean = false, + metadata: { + currentVersion?: string; + registryUrl?: string; + status?: TCachedUpdateStatus; + } = {} + ): ICacheStatus { return { lastCheck: Date.now(), latestVersion, + currentVersion: metadata.currentVersion, + registryUrl: metadata.registryUrl, + status: metadata.status, performedUpgrade, }; } + + private cacheMatchesContext( + cacheStatus: ICacheStatus, + cacheContext: { + currentVersion?: string; + registryUrl?: string; + } + ): boolean { + if (cacheContext.currentVersion && cacheStatus.currentVersion !== cacheContext.currentVersion) { + return false; + } + if (cacheContext.registryUrl && cacheStatus.registryUrl !== cacheContext.registryUrl) { + return false; + } + return true; + } } diff --git a/ts/smartupdate.classes.smartupdate.ts b/ts/smartupdate.classes.smartupdate.ts index 111b273..72a5065 100644 --- a/ts/smartupdate.classes.smartupdate.ts +++ b/ts/smartupdate.classes.smartupdate.ts @@ -3,13 +3,18 @@ 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'; /** @@ -38,7 +43,7 @@ export class SmartUpdate { /** * @deprecated Use the options parameter instead of kvStore property */ - public kvStore: plugins.npmextra.KeyValueStore; + public kvStore: plugins.npmextra.KeyValueStore>; constructor(options: ISmartUpdateOptions = {}) { this.options = options; @@ -56,6 +61,9 @@ export class SmartUpdate { // 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 @@ -72,7 +80,7 @@ export class SmartUpdate { /** * Check for updates with caching (primarily for CLI use) * - * @deprecated Use checkForUpdate with cacheStrategy: 'always' instead + * @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 @@ -92,10 +100,15 @@ export class SmartUpdate { packageName, currentVersion, changelogUrl, - cacheStrategy: 'always', + cacheStrategy: 'time-based', }); - return result.status === 'update-available'; + return ( + result.status === 'update-available' || + (result.status === 'check-skipped' && + !!result.latestVersion && + this.safeIsUpdateAvailable(result.latestVersion, currentVersion)) + ); } /** @@ -129,12 +142,19 @@ export class SmartUpdate { 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); + const cacheCheck = await this.cacheManager.shouldCheckRegistry(packageName, cacheStrategy, { + currentVersion, + registryUrl, + }); // If we should skip the check due to cache if (!cacheCheck.shouldCheck) { - return { + const skippedResult: IUpdateCheckResult = { status: 'check-skipped', packageName, currentVersion, @@ -142,23 +162,26 @@ export class SmartUpdate { checkTime, cacheHit: true, nextCheckTime: cacheCheck.nextCheckTime, - reason: 'Rate limited - checked recently', + 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); - if (!npmPackage) { - throw new RegistryUnavailableError(); - } - // Compare versions - const versionNpm = new plugins.smartversion.SmartVersion(npmPackage.version); - const versionLocal = new plugins.smartversion.SmartVersion(currentVersion); + const versionNpm = this.createSmartVersion(npmPackage.version); + const status: TCachedUpdateStatus = versionNpm.greaterThan(versionLocal) + ? 'update-available' + : 'up-to-date'; const result: IUpdateCheckResult = { - status: versionNpm.greaterThan(versionLocal) ? 'update-available' : 'up-to-date', + status, packageName: npmPackage.name, currentVersion, latestVersion: npmPackage.version, @@ -172,14 +195,20 @@ export class SmartUpdate { // If update is available, handle changelog if (result.status === 'update-available' && changelogUrl && openChangelog && !process.env.CI) { this.notifier.notifyOpeningChangelog(); - plugins.smartopen.openUrl(changelogUrl); + await plugins.smartopen.openUrl(changelogUrl).catch((error) => { + this.notifier.warn( + `Could not open changelog: ${error instanceof Error ? error.message : String(error)}` + ); + }); } - // Update cache if there's an update - if (result.status === 'update-available') { - const cacheStatus = this.cacheManager.createCacheStatus(npmPackage.version, false); - await this.cacheManager.setCached(packageName, cacheStatus); - } + // 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) { @@ -242,10 +271,8 @@ export class SmartUpdate { * ``` */ public async getLatestVersion(packageName: string): Promise { + this.validatePackageName(packageName); const npmPackage = await this.getNpmPackageFromRegistry(packageName); - if (!npmPackage) { - throw new PackageNotFoundError(packageName); - } return npmPackage.version; } @@ -267,13 +294,58 @@ export class SmartUpdate { * Fetch package information from the npm registry * @private */ - private async getNpmPackageFromRegistry(packageName: string): Promise { + 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) { - return null; + 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'; + } } diff --git a/ts/smartupdate.interfaces.ts b/ts/smartupdate.interfaces.ts index 6bb351b..f6e3ca2 100644 --- a/ts/smartupdate.interfaces.ts +++ b/ts/smartupdate.interfaces.ts @@ -1,12 +1,19 @@ import type { TLogLevel } from './smartupdate.constants.js'; import type * as smartnpm from '@push.rocks/smartnpm'; +export type TCacheStrategy = 'always' | 'never' | 'time-based'; +export type TCacheStoreType = 'custom' | 'userHomeDir' | 'ephemeral'; +export type TCachedUpdateStatus = 'up-to-date' | 'update-available'; + /** * Cache status stored for each package */ export interface ICacheStatus { lastCheck: number; latestVersion: string; + currentVersion?: string; + registryUrl?: string; + status?: TCachedUpdateStatus; performedUpgrade: boolean; } @@ -46,6 +53,16 @@ export interface ISmartUpdateOptions { * @default false */ noColor?: boolean; + + /** + * Cache storage configuration + * @default { storeType: 'userHomeDir', storeIdentifier: 'global_smartupdate' } + */ + cacheStore?: { + storeType?: TCacheStoreType; + storeIdentifier?: string; + customPath?: string; + }; } /** @@ -77,12 +94,12 @@ export interface IUpdateCheckOptions { /** * Cache strategy for this check - * - 'always': Always check cache first (default for CLI) + * - 'always': Always use an existing matching cache entry * - 'never': Always check registry, bypass cache * - 'time-based': Check based on cache duration * @default 'time-based' */ - cacheStrategy?: 'always' | 'never' | 'time-based'; + cacheStrategy?: TCacheStrategy; } /** @@ -148,6 +165,16 @@ export interface ICacheOptions { * Identifier for the key-value store */ storeIdentifier?: string; + + /** + * Key-value store backend + */ + storeType?: TCacheStoreType; + + /** + * Custom path for custom key-value stores + */ + customPath?: string; } /** diff --git a/ts/smartupdate.plugins.ts b/ts/smartupdate.plugins.ts index cf62a94..c0e870d 100644 --- a/ts/smartupdate.plugins.ts +++ b/ts/smartupdate.plugins.ts @@ -2,7 +2,6 @@ import * as consolecolor from '@push.rocks/consolecolor'; import * as npmextra from '@push.rocks/npmextra'; import * as smartnpm from '@push.rocks/smartnpm'; import * as smartopen from '@push.rocks/smartopen'; -import * as smarttime from '@push.rocks/smarttime'; import * as smartversion from '@push.rocks/smartversion'; -export { consolecolor, npmextra, smartnpm, smartopen, smarttime, smartversion }; +export { consolecolor, npmextra, smartnpm, smartopen, smartversion };