diff --git a/package.json b/package.json index 93c229b..4ea2380 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@openstellar/tool-search", - "version": "0.2.0", + "version": "0.2.1", "description": "Tool search plugin for OpenCode — BM25 and regex search to discover tools on demand, reducing context usage", "type": "module", "main": "./dist/index.js", diff --git a/src/hooks/auto-update-checker.ts b/src/hooks/auto-update-checker.ts index 80c6391..df7c171 100644 --- a/src/hooks/auto-update-checker.ts +++ b/src/hooks/auto-update-checker.ts @@ -4,14 +4,16 @@ import { readFileSync, existsSync, rmSync } from 'node:fs'; import { homedir, platform } from 'node:os'; import { env } from 'node:process'; import { gt, valid } from 'semver'; +import { resolveRegistryUrl, buildDistTagsUrl } from './npm-registry.js'; const PACKAGE_SCOPE = '@openstellar'; const PACKAGE_NAME = '@openstellar/tool-search'; -const NPM_REGISTRY_URL = `https://registry.npmjs.org/${PACKAGE_NAME}/dist-tags`; const NPM_FETCH_TIMEOUT = 5000; +export type UpdateCheckOutcome = 'up-to-date' | 'update-staged' | 'invalidation-failed' | 'check-failed'; + export interface UpdateCheckResult { - needsUpdate: boolean; + outcome: UpdateCheckOutcome; currentVersion: string | null; latestVersion: string | null; error?: string; @@ -36,12 +38,20 @@ export function getCurrentVersion(): string | null { return null; } +async function defaultGetLatestVersionUrl(): Promise { + const { url } = await resolveRegistryUrl(); + return buildDistTagsUrl(url, PACKAGE_NAME)!; +} + export async function getLatestVersion(): Promise { + const distTagsUrl = await defaultGetLatestVersionUrl(); + if (!distTagsUrl) return null; + const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), NPM_FETCH_TIMEOUT); try { - const response = await fetch(NPM_REGISTRY_URL, { + const response = await fetch(distTagsUrl, { signal: controller.signal, headers: { Accept: 'application/json' }, }); @@ -66,49 +76,62 @@ function getPossibleCacheRoots(): string[] { return cacheDirs; } -export function invalidatePackageCache(): boolean { - const cacheRoots = getPossibleCacheRoots(); +export function getPackageCacheTargets(cacheRoot: string): string[] { + return [join(cacheRoot, PACKAGE_NAME), join(cacheRoot, `${PACKAGE_NAME}@latest`)]; +} + +export interface CacheInvalidationEffects { + existsSync: typeof existsSync; + rmSync: typeof rmSync; +} + +const defaultCacheInvalidationEffects: CacheInvalidationEffects = { existsSync, rmSync }; + +export function invalidatePackageCache( + cacheRoots = getPossibleCacheRoots(), + effects: CacheInvalidationEffects = defaultCacheInvalidationEffects, +): boolean { const seen = new Set(); let removed = false; + let removalFailed = false; for (const root of cacheRoots) { if (seen.has(root)) continue; seen.add(root); - if (!existsSync(root)) continue; - - const packageDir = join(root, PACKAGE_NAME); - if (existsSync(packageDir)) { + for (const target of getPackageCacheTargets(root)) { + let exists: boolean; try { - rmSync(packageDir, { recursive: true, force: true }); - removed = true; + exists = effects.existsSync(target); } catch { + removalFailed = true; + continue; } - } - - const specDir = join(root, `${PACKAGE_NAME}@latest`); - if (existsSync(specDir)) { + if (!exists) continue; try { - rmSync(specDir, { recursive: true, force: true }); + effects.rmSync(target, { recursive: true, force: true }); removed = true; } catch { + removalFailed = true; } } } - return removed; + return removed && !removalFailed; } export function isNewerVersion(latest: string, current: string): boolean { return valid(latest) !== null && valid(current) !== null && gt(latest, current); } -interface UpdateCheckEffects { +export interface UpdateCheckEffects { getCurrentVersion: () => string | null; + getLatestVersionUrl?: () => Promise; getLatestVersion: () => Promise; invalidatePackageCache: () => boolean; } const defaultUpdateCheckEffects: UpdateCheckEffects = { getCurrentVersion, + getLatestVersionUrl: defaultGetLatestVersionUrl, getLatestVersion, invalidatePackageCache, }; @@ -116,50 +139,93 @@ const defaultUpdateCheckEffects: UpdateCheckEffects = { export async function checkForUpdate( effects: UpdateCheckEffects = defaultUpdateCheckEffects, ): Promise { - const currentVersion = effects.getCurrentVersion(); + let currentVersion: string | null; + try { + currentVersion = effects.getCurrentVersion(); + } catch (error) { + return { outcome: 'check-failed', currentVersion: null, latestVersion: null, error: error instanceof Error ? error.message : 'Could not determine current version' }; + } if (!currentVersion) { - return { - needsUpdate: false, - currentVersion: null, - latestVersion: null, - error: 'Could not determine current version', - }; + return { outcome: 'check-failed', currentVersion: null, latestVersion: null, error: 'Could not determine current version' }; } let latestVersion: string | null; try { - latestVersion = await effects.getLatestVersion(); + if (effects.getLatestVersionUrl) { + const distTagsUrl = await effects.getLatestVersionUrl(); + if (distTagsUrl) { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), NPM_FETCH_TIMEOUT); + try { + const response = await fetch(distTagsUrl, { + signal: controller.signal, + headers: { Accept: 'application/json' }, + }); + if (response.ok) { + const data = (await response.json()) as Record; + latestVersion = data.latest ?? null; + } else { + latestVersion = null; + } + } finally { + clearTimeout(timeoutId); + } + } else { + latestVersion = null; + } + } else { + latestVersion = await effects.getLatestVersion(); + } } catch { latestVersion = null; } if (!latestVersion) { + return { outcome: 'check-failed', currentVersion, latestVersion: null, error: 'Could not fetch latest version from npm' }; + } + if (valid(currentVersion) === null || valid(latestVersion) === null) { return { - needsUpdate: false, + outcome: 'check-failed', currentVersion, - latestVersion: null, - error: 'Could not fetch latest version from npm', + latestVersion, + error: 'Could not compare package versions', }; } if (!isNewerVersion(latestVersion, currentVersion)) { - return { needsUpdate: false, currentVersion, latestVersion }; + return { outcome: 'up-to-date', currentVersion, latestVersion }; } - effects.invalidatePackageCache(); - return { needsUpdate: true, currentVersion, latestVersion }; + let invalidated: boolean; + try { + invalidated = effects.invalidatePackageCache(); + } catch (error) { + return { + outcome: 'invalidation-failed', + currentVersion, + latestVersion, + error: error instanceof Error ? error.message : 'Could not invalidate the package cache', + }; + } + if (!invalidated) { + return { outcome: 'invalidation-failed', currentVersion, latestVersion, error: 'Could not invalidate the package cache' }; + } + return { outcome: 'update-staged', currentVersion, latestVersion }; } export function formatUpdateMessage(result: UpdateCheckResult): { title: string; message: string; - variant: 'info' | 'success' | 'warning'; + variant: 'info' | 'success' | 'warning' | 'error'; } { - if (!result.needsUpdate || !result.latestVersion) { - return { title: 'Tool Search', message: 'Up-to-date', variant: 'info' }; + if (result.outcome === 'update-staged' && result.latestVersion) { + return { + title: 'Tool Search Update', + message: `v${result.currentVersion} -> v${result.latestVersion}. Restart OpenCode to apply.`, + variant: 'warning', + }; + } + if (result.outcome === 'check-failed' || result.outcome === 'invalidation-failed') { + return { title: 'Tool Search Update Check', message: result.error ?? 'Update check failed.', variant: 'error' }; } - return { - title: 'Tool Search Update', - message: `v${result.currentVersion} -> v${result.latestVersion}. Restart OpenCode to apply.`, - variant: 'warning', - }; + return { title: 'Tool Search', message: 'Up-to-date', variant: 'info' }; } diff --git a/src/hooks/npm-registry.ts b/src/hooks/npm-registry.ts new file mode 100644 index 0000000..365d59d --- /dev/null +++ b/src/hooks/npm-registry.ts @@ -0,0 +1,145 @@ +import { execFile as nodeExecFile } from 'node:child_process'; + +function execFileAsync( + cmd: string, + args: string[], + opts: { cwd?: string; timeout?: number }, +): Promise<{ stdout: string; stderr: string }> { + return new Promise((resolve, reject) => { + nodeExecFile(cmd, args, opts, (err, stdout, stderr) => { + if (err) reject(err); + else resolve({ stdout, stderr }); + }); + }); +} + +const FALLBACK_REGISTRY = 'https://registry.npmjs.org'; +const NPM_CONFIG_TIMEOUT = 5000; + +// --------------------------------------------------------------------------- +// ResolveEffects – injected execFile for testability (no real npm in tests) +// --------------------------------------------------------------------------- + +export interface ResolveEffects { + execFile: ( + cmd: string, + args: string[], + opts: { cwd?: string; timeout?: number }, + ) => Promise<{ stdout: string; stderr: string }>; +} + +async function defaultExecFile( + cmd: string, + args: string[], + opts: { cwd?: string; timeout?: number }, +): Promise<{ stdout: string; stderr: string }> { + const { stdout } = await execFileAsync(cmd, args, opts); + return { stdout, stderr: '' }; +} + +// --------------------------------------------------------------------------- +// parseRegistryUrl +// --------------------------------------------------------------------------- + +/** + * Validate a raw registry URL string. + * + * Accepts already-trimmed output (trims internally). Returns the normalised + * URL on success, `null` when the input is invalid: non-http(s) protocol, + * contains credentials, query-string, fragment, or is otherwise malformed. + */ +export function parseRegistryUrl(raw: string): string | null { + const trimmed = raw.trim(); + if (!trimmed) return null; + + let url: URL; + try { + url = new URL(trimmed); + } catch { + return null; + } + + if (url.protocol !== 'http:' && url.protocol !== 'https:') return null; + if (url.username !== '' || url.password !== '') return null; + if (url.search !== '' || url.hash !== '') return null; + + return url.href; +} + +// --------------------------------------------------------------------------- +// buildDistTagsUrl +// --------------------------------------------------------------------------- + +/** + * Build a dist-tags endpoint URL from a validated registry base and a package + * name. Preserves the registry's base path, percent-encodes the scoped + * package name exactly once, and rejects invalid / unsupported inputs. + * + * Returns `null` when either argument is invalid. + */ +export function buildDistTagsUrl( + registryBase: string, + packageName: string, +): string | null { + const validBase = parseRegistryUrl(registryBase); + if (!validBase) return null; + + const trimmedPkg = packageName.trim(); + if (!trimmedPkg) return null; + + const base = validBase.endsWith('/') ? validBase.slice(0, -1) : validBase; + // Percent-encode the scoped prefix once: @scope/pkg → %40scope%2Fpkg + const encoded = trimmedPkg.replace(/@/g, '%40').replace(/\//g, '%2F'); + + return `${base}/-/package/${encoded}/dist-tags`; +} + +// --------------------------------------------------------------------------- +// tryParseRegistryConfig – parse stdout of `npm config get` +// --------------------------------------------------------------------------- + +function tryParseRegistryConfig(output: string): string | null { + return parseRegistryUrl(output); +} + +// --------------------------------------------------------------------------- +// resolveRegistryUrl +// --------------------------------------------------------------------------- + +/** + * Determine the npm registry base URL by probing `npm config` for the scoped + * `@openstellar:registry`, then the global `registry`, falling back to the + * public npmjs registry when either is missing or npm is unavailable. + * + * Uses injected `effects.execFile` so tests never invoke the real npm binary. + */ +export async function resolveRegistryUrl( + effects?: ResolveEffects, +): Promise<{ url: string; source: 'scoped' | 'default' | 'fallback' }> { + const exec = effects?.execFile ?? defaultExecFile; + + // 1. Scoped registry: @openstellar:registry + try { + const { stdout } = await exec('npm', ['config', 'get', '@openstellar:registry'], { + timeout: NPM_CONFIG_TIMEOUT, + }); + const url = tryParseRegistryConfig(stdout); + if (url) return { url, source: 'scoped' }; + } catch { + // npm unavailable, timeout, or non-zero exit – fall through + } + + // 2. Default registry: registry + try { + const { stdout } = await exec('npm', ['config', 'get', 'registry'], { + timeout: NPM_CONFIG_TIMEOUT, + }); + const url = tryParseRegistryConfig(stdout); + if (url) return { url, source: 'default' }; + } catch { + // fall through + } + + // 3. Hard-coded fallback + return { url: FALLBACK_REGISTRY, source: 'fallback' }; +} diff --git a/src/plugin.ts b/src/plugin.ts index decfcea..1d903ce 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -60,7 +60,8 @@ export const ToolSearchPlugin: Plugin = async (ctx, options?: PluginOptions): Pr let deferrals = 0; let total = 0; let alerted = false; - let hasCheckedForUpdate = false; + let updateCheckInFlight: Promise | null = null; + let updateStaged = false; setTimeout(() => { toast(ctx, 'Tool Search', 'Active — tools will be deferred on first prompt.', 'info', 4000); @@ -145,15 +146,26 @@ export const ToolSearchPlugin: Plugin = async (ctx, options?: PluginOptions): Pr }, event: async ({ event }) => { - if (event.type !== 'session.created') return; - if (hasCheckedForUpdate) return; - hasCheckedForUpdate = true; - - const result = await checkForUpdate(); - if (result.needsUpdate && result.latestVersion) { - const msg = formatUpdateMessage(result); - toast(ctx, msg.title, msg.message, msg.variant, 6000); + if (event.type !== 'session.created' || updateStaged) return; + if (!updateCheckInFlight) { + updateCheckInFlight = (async () => { + try { + const result = await checkForUpdate(); + const msg = formatUpdateMessage(result); + if (result.outcome === 'update-staged') { + updateStaged = true; + toast(ctx, msg.title, msg.message, msg.variant, 6000); + } else if (result.outcome !== 'up-to-date') { + toast(ctx, msg.title, msg.message, msg.variant, 6000); + } + } catch (error) { + toast(ctx, 'Tool Search Update Check', error instanceof Error ? error.message : 'Update check failed.', 'error', 6000); + } finally { + updateCheckInFlight = null; + } + })(); } + await updateCheckInFlight; }, }; }; diff --git a/tests/auto-update-cache-target.test.ts b/tests/auto-update-cache-target.test.ts new file mode 100644 index 0000000..f9fc565 --- /dev/null +++ b/tests/auto-update-cache-target.test.ts @@ -0,0 +1,51 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + existsSync, + mkdtempSync, + mkdirSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { join } from 'node:path'; + +vi.mock('node:os', async () => { + const actual = await vi.importActual('node:os'); + return { + ...actual, + homedir: () => process.env.TOOL_SEARCH_TEST_HOME ?? actual.homedir(), + platform: () => 'darwin', + }; +}); + +describe('auto-update-checker cache target characterization', () => { + let testHome: string; + + afterEach(() => { + if (testHome) rmSync(testHome, { recursive: true, force: true }); + delete process.env.TOOL_SEARCH_TEST_HOME; + vi.resetModules(); + }); + + it('removes only the latest tool-search wrapper while preserving sibling wrappers', async () => { + testHome = mkdtempSync(join(process.cwd(), '.tmp-tool-search-cache-')); + process.env.TOOL_SEARCH_TEST_HOME = testHome; + + const packages = join(testHome, '.cache', 'opencode', 'packages'); + const target = join(packages, '@openstellar', 'tool-search@latest'); + const scopedSibling = join(packages, '@openstellar', 'other-plugin@latest'); + const unscopedSibling = join(packages, 'other-plugin@latest'); + for (const wrapper of [target, scopedSibling, unscopedSibling]) { + mkdirSync(join(wrapper, 'node_modules', '@openstellar', 'tool-search'), { recursive: true }); + writeFileSync(join(wrapper, 'package.json'), '{}'); + writeFileSync(join(wrapper, 'package-lock.json'), '{}'); + writeFileSync(join(wrapper, 'node_modules', '@openstellar', 'tool-search', 'package.json'), '{}'); + } + + const { invalidatePackageCache } = await import('../src/hooks/auto-update-checker.js'); + expect(invalidatePackageCache()).toBe(true); + + expect(existsSync(target)).toBe(false); + expect(existsSync(scopedSibling)).toBe(true); + expect(existsSync(unscopedSibling)).toBe(true); + }); +}); diff --git a/tests/auto-update-checker.test.ts b/tests/auto-update-checker.test.ts index 2e91454..981843c 100644 --- a/tests/auto-update-checker.test.ts +++ b/tests/auto-update-checker.test.ts @@ -1,8 +1,19 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; +import { invalidatePackageCache } from '../src/hooks/auto-update-checker.js'; import { checkForUpdate, + getPackageCacheTargets, isNewerVersion, } from '../src/hooks/auto-update-checker.js'; +import { + parseRegistryUrl, + buildDistTagsUrl, + resolveRegistryUrl, +} from '../src/hooks/npm-registry.js'; + +// --------------------------------------------------------------------------- +// Existing tests – unchanged +// --------------------------------------------------------------------------- describe('auto-update-checker : isNewerVersion', () => { it.each([ @@ -24,6 +35,15 @@ describe('auto-update-checker : isNewerVersion', () => { }); }); +describe('auto-update-checker : cache targets', () => { + it('builds only wrapper targets for the package and latest spec', () => { + expect(getPackageCacheTargets('/tmp/packages')).toEqual([ + '/tmp/packages/@openstellar/tool-search', + '/tmp/packages/@openstellar/tool-search@latest', + ]); + }); +}); + describe('auto-update-checker : checkForUpdate lifecycle', () => { const runCheck = async ( currentVersion: string | null, @@ -45,6 +65,68 @@ describe('auto-update-checker : checkForUpdate lifecycle', () => { return { result, invalidations }; }; + it('returns false for nonexistent, mixed, and failed filesystem targets', () => { + expect(invalidatePackageCache(['/cache'], { existsSync: () => false, rmSync: vi.fn() })).toBe(false); + expect(invalidatePackageCache(['/cache'], { existsSync: () => true, rmSync: () => { throw new Error('remove failed'); } })).toBe(false); + let calls = 0; + expect(invalidatePackageCache(['/cache', '/cache-2'], { existsSync: () => true, rmSync: () => { calls += 1; if (calls === 2) throw new Error('remove failed'); } })).toBe(false); + }); + + it('returns check-failed when getCurrentVersion throws', async () => { + await expect(checkForUpdate({ + getCurrentVersion: () => { throw new Error('version read failed'); }, + getLatestVersion: async () => '1.0.1', + invalidatePackageCache: () => true, + })).resolves.toEqual({ + outcome: 'check-failed', + currentVersion: null, + latestVersion: null, + error: 'version read failed', + }); + }); + + it('returns invalidation-failed when invalidatePackageCache throws', async () => { + await expect(checkForUpdate({ + getCurrentVersion: () => '1.0.0', + getLatestVersion: async () => '1.0.1', + invalidatePackageCache: () => { throw new Error('remove exploded'); }, + })).resolves.toEqual({ + outcome: 'invalidation-failed', + currentVersion: '1.0.0', + latestVersion: '1.0.1', + error: 'remove exploded', + }); + }); + + it.each([ + ['malformed current version', 'latest', '1.0.1'], + ['malformed latest version', '1.0.0', 'latest'], + ])('returns check-failed for %s', async (_label, currentVersion, latestVersion) => { + await expect(checkForUpdate({ + getCurrentVersion: () => currentVersion, + getLatestVersion: async () => latestVersion, + invalidatePackageCache: () => true, + })).resolves.toEqual({ + outcome: 'check-failed', + currentVersion, + latestVersion, + error: 'Could not compare package versions', + }); + }); + + it('reports invalidation failure without staging an update', async () => { + const result = await checkForUpdate({ + getCurrentVersion: () => '1.0.0', + getLatestVersion: async () => '1.0.1', + invalidatePackageCache: () => false, + }); + + expect(result).toMatchObject({ + outcome: 'invalidation-failed', + error: 'Could not invalidate the package cache', + }); + }); + it.each([ ['newer remote version', '1.0.0', '1.0.1', false, true], ['equal remote version', '1.0.0', '1.0.0', false, false], @@ -56,7 +138,307 @@ describe('auto-update-checker : checkForUpdate lifecycle', () => { ['missing current version', null, '1.0.1', false, false], ])('invalidates only for %s', async (_caseName, current, latest, fetchError, needsUpdate) => { const { result, invalidations } = await runCheck(current, latest, fetchError); - expect(result.needsUpdate).toBe(needsUpdate); + expect(result.outcome).toBe(needsUpdate ? 'update-staged' : (result.error ? 'check-failed' : 'up-to-date')); expect(invalidations).toBe(needsUpdate ? 1 : 0); }); }); + +// --------------------------------------------------------------------------- +// npm-registry : parseRegistryUrl +// --------------------------------------------------------------------------- + +describe('npm-registry : parseRegistryUrl', () => { + it('accepts a valid https URL', () => { + expect(parseRegistryUrl('https://registry.npmjs.org')).toBe( + 'https://registry.npmjs.org/', + ); + }); + + it('accepts a valid http URL', () => { + expect(parseRegistryUrl('http://localhost:4873')).toBe( + 'http://localhost:4873/', + ); + }); + + it('accepts a URL with a base path', () => { + expect(parseRegistryUrl('https://my.company.com/npm')).toBe( + 'https://my.company.com/npm', + ); + }); + + it('returns null for a URL with credentials', () => { + expect(parseRegistryUrl('https://token:secret@registry.npmjs.org')).toBeNull(); + }); + + it('returns null for a URL with a username only', () => { + expect(parseRegistryUrl('https://user@registry.npmjs.org')).toBeNull(); + }); + + it('returns null for a URL with a query string', () => { + expect(parseRegistryUrl('https://registry.npmjs.org?q=1')).toBeNull(); + }); + + it('returns null for a URL with a fragment', () => { + expect(parseRegistryUrl('https://registry.npmjs.org#section')).toBeNull(); + }); + + it('returns null for a malformed URL', () => { + expect(parseRegistryUrl('not-a-url')).toBeNull(); + }); + + it('returns null for an empty string', () => { + expect(parseRegistryUrl('')).toBeNull(); + }); + + it('returns null for whitespace-only input', () => { + expect(parseRegistryUrl(' ')).toBeNull(); + }); + + it('returns null for CRLF-only input', () => { + expect(parseRegistryUrl('\r\n')).toBeNull(); + }); + + it('trims whitespace before validating', () => { + expect(parseRegistryUrl(' https://registry.npmjs.org ')).toBe( + 'https://registry.npmjs.org/', + ); + }); + + it('trims trailing newlines before validating', () => { + expect(parseRegistryUrl('https://registry.npmjs.org\n')).toBe( + 'https://registry.npmjs.org/', + ); + }); + + it('returns null for a non-http protocol', () => { + expect(parseRegistryUrl('ftp://registry.npmjs.org')).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// npm-registry : buildDistTagsUrl +// --------------------------------------------------------------------------- + +describe('npm-registry : buildDistTagsUrl', () => { + it('builds a dist-tags URL preserving the base path', () => { + const url = buildDistTagsUrl( + 'https://my-company.com/npm', + 'my-package', + ); + expect(url).toBe( + 'https://my-company.com/npm/-/package/my-package/dist-tags', + ); + }); + + it('strips trailing slash from the registry base', () => { + const url = buildDistTagsUrl( + 'https://registry.npmjs.org/', + 'my-package', + ); + expect(url).toBe( + 'https://registry.npmjs.org/-/package/my-package/dist-tags', + ); + }); + + it('percent-encodes a scoped package once', () => { + const url = buildDistTagsUrl( + 'https://registry.npmjs.org', + '@scope/my-package', + ); + expect(url).toBe( + 'https://registry.npmjs.org/-/package/%40scope%2Fmy-package/dist-tags', + ); + }); + + it('returns null when the registry URL is invalid', () => { + expect(buildDistTagsUrl('not-a-url', 'pkg')).toBeNull(); + }); + + it('returns null when the registry URL has credentials', () => { + expect( + buildDistTagsUrl('https://user:pass@registry.npmjs.org', 'pkg'), + ).toBeNull(); + }); + + it('returns null when the package name is empty', () => { + expect( + buildDistTagsUrl('https://registry.npmjs.org', ''), + ).toBeNull(); + }); + + it('returns null when the package name is whitespace', () => { + expect( + buildDistTagsUrl('https://registry.npmjs.org', ' '), + ).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// npm-registry : resolveRegistryUrl +// --------------------------------------------------------------------------- + +describe('npm-registry : resolveRegistryUrl', () => { + it('returns the scoped registry when npm config provides one', async () => { + const execFile = vi.fn().mockResolvedValue({ + stdout: 'https://scoped-registry.example.com/npm\n', + stderr: '', + }); + const result = await resolveRegistryUrl({ execFile }); + expect(result).toEqual({ + url: 'https://scoped-registry.example.com/npm', + source: 'scoped', + }); + expect(execFile).toHaveBeenCalledWith( + 'npm', + ['config', 'get', '@openstellar:registry'], + expect.objectContaining({ timeout: expect.any(Number) }), + ); + }); + + it('falls through to default registry when scoped is empty', async () => { + const execFile = vi.fn() + .mockResolvedValueOnce({ stdout: '\n', stderr: '' }) // scoped → empty + .mockResolvedValueOnce({ stdout: 'https://default-registry.example.com\n', stderr: '' }); // default + const result = await resolveRegistryUrl({ execFile }); + expect(result).toEqual({ + url: 'https://default-registry.example.com/', + source: 'default', + }); + }); + + it('falls through to fallback when both are empty', async () => { + const execFile = vi.fn().mockResolvedValue({ stdout: '\n', stderr: '' }); + const result = await resolveRegistryUrl({ execFile }); + expect(result).toEqual({ + url: 'https://registry.npmjs.org', + source: 'fallback', + }); + }); + + it('falls through to fallback when npm is unavailable', async () => { + const execFile = vi.fn().mockRejectedValue( + new Error('ENOENT: npm not found'), + ); + const result = await resolveRegistryUrl({ execFile }); + expect(result).toEqual({ + url: 'https://registry.npmjs.org', + source: 'fallback', + }); + }); + + it('falls through to fallback on subprocess timeout', async () => { + const execFile = vi.fn().mockRejectedValue( + new Error('spawn npm ENOENT'), + ); + const result = await resolveRegistryUrl({ execFile }); + expect(result).toEqual({ + url: 'https://registry.npmjs.org', + source: 'fallback', + }); + }); + + it('falls through to fallback on non-zero exit', async () => { + const execFile = vi.fn().mockRejectedValue( + new Error('command failed'), + ); + const result = await resolveRegistryUrl({ execFile }); + expect(result).toEqual({ + url: 'https://registry.npmjs.org', + source: 'fallback', + }); + }); + + it('rejects scoped registry output with credentials', async () => { + const execFile = vi.fn().mockResolvedValue({ + stdout: 'https://token:secret@registry.npmjs.org\n', + stderr: '', + }); + const result = await resolveRegistryUrl({ execFile }); + // Scoped registry is invalid (has credentials), so falls to default + // Default also returns empty since we only called scoped + expect(result.source).toBe('fallback'); + }); + + it('rejects invalid URL from npm config', async () => { + const execFile = vi.fn() + .mockResolvedValueOnce({ stdout: 'not-a-url\n', stderr: '' }) // scoped → invalid + .mockResolvedValueOnce({ stdout: 'https://default-registry.example.com\n', stderr: '' }); // default + const result = await resolveRegistryUrl({ execFile }); + expect(result).toEqual({ + url: 'https://default-registry.example.com/', + source: 'default', + }); + }); +}); + +// --------------------------------------------------------------------------- +// auto-update-checker : getLatestVersionUrl wiring +// --------------------------------------------------------------------------- + +describe('auto-update-checker : getLatestVersionUrl wiring', () => { + it('getLatestVersion uses the resolved URL from getLatestVersionUrl effect', async () => { + const distTagsUrl = + 'https://custom-registry.example.com/-/package/%40openstellar%2Ftool-search/dist-tags'; + + // Mock fetch to capture the URL it was called with + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + json: async () => ({ latest: '1.0.1' }), + } as Response); + + const result = await checkForUpdate({ + getCurrentVersion: () => '1.0.0', + getLatestVersionUrl: async () => distTagsUrl, + getLatestVersion: async () => { + throw new Error('should not be called'); + }, + invalidatePackageCache: () => true, + }); + + expect(fetchSpy).toHaveBeenCalledWith( + distTagsUrl, + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ); + expect(result).toMatchObject({ + outcome: 'update-staged', + currentVersion: '1.0.0', + latestVersion: '1.0.1', + }); + fetchSpy.mockRestore(); + }); + + it('falls back to getLatestVersion when getLatestVersionUrl is not provided', async () => { + let called = false; + const result = await checkForUpdate({ + getCurrentVersion: () => '1.0.0', + getLatestVersion: async () => { + called = true; + return '1.0.1'; + }, + invalidatePackageCache: () => true, + }); + + expect(called).toBe(true); + expect(result).toMatchObject({ + outcome: 'update-staged', + currentVersion: '1.0.0', + latestVersion: '1.0.1', + }); + }); + + it('returns check-failed when getLatestVersionUrl returns null', async () => { + const result = await checkForUpdate({ + getCurrentVersion: () => '1.0.0', + getLatestVersionUrl: async () => null as unknown as string, + getLatestVersion: async () => { + throw new Error('should not be called'); + }, + invalidatePackageCache: () => true, + }); + + expect(result).toMatchObject({ + outcome: 'check-failed', + error: 'Could not fetch latest version from npm', + }); + }); +}); diff --git a/tests/plugin-auto-update-lifecycle.test.ts b/tests/plugin-auto-update-lifecycle.test.ts new file mode 100644 index 0000000..1e70b05 --- /dev/null +++ b/tests/plugin-auto-update-lifecycle.test.ts @@ -0,0 +1,158 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const { checkForUpdate, mockedFormatUpdateMessage } = vi.hoisted(() => ({ + checkForUpdate: vi.fn(async (): Promise => ({ + outcome: 'up-to-date' as const, + currentVersion: '0.2.0', + latestVersion: '0.2.0', + })), + mockedFormatUpdateMessage: vi.fn((result: any) => { + if (result.outcome === 'update-staged') return { title: 'Tool Search Update', message: `v${result.currentVersion} -> v${result.latestVersion}. Restart OpenCode to apply.`, variant: 'warning' as const }; + if (result.outcome === 'check-failed' || result.outcome === 'invalidation-failed') return { title: 'Tool Search Update Check', message: result.error, variant: 'error' as const }; + return { title: 'Tool Search', message: 'Up-to-date', variant: 'info' as const }; + }), +})); + +vi.mock('../src/hooks/auto-update-checker.js', async () => { + const actual = await vi.importActual('../src/hooks/auto-update-checker.js'); + return { ...actual, checkForUpdate, formatUpdateMessage: mockedFormatUpdateMessage }; +}); + +import { ToolSearchPlugin } from '../src/plugin.js'; + +describe('ToolSearchPlugin update-check lifecycle', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.runOnlyPendingTimers(); + vi.useRealTimers(); + checkForUpdate.mockReset(); + mockedFormatUpdateMessage.mockClear(); + }); + + const createContext = () => ({ + client: { tui: { showToast: vi.fn(async () => {}) } }, + }); + + it('rechecks after a later sequential completed session.created event', async () => { + checkForUpdate.mockResolvedValue({ outcome: 'up-to-date', currentVersion: '0.2.0', latestVersion: '0.2.0' }); + const hooks = await ToolSearchPlugin(createContext() as any, { embedding: { enabled: false } } as any); + + await hooks.event!({ event: { type: 'session.created' } } as any); + await hooks.event!({ event: { type: 'session.created' } } as any); + + expect(checkForUpdate).toHaveBeenCalledTimes(2); + }); + + it('deduplicates concurrent session.created events and awaits the shared promise', async () => { + let resolveCheck!: (value: any) => void; + checkForUpdate.mockReturnValueOnce(new Promise((resolve) => { resolveCheck = resolve; })); + const hooks = await ToolSearchPlugin(createContext() as any, { embedding: { enabled: false } } as any); + + const first = hooks.event!({ event: { type: 'session.created' } } as any); + const second = hooks.event!({ event: { type: 'session.created' } } as any); + let secondSettled = false; + void second.then(() => { secondSettled = true; }); + await Promise.resolve(); + expect(secondSettled).toBe(false); + resolveCheck({ outcome: 'up-to-date', currentVersion: '0.2.0', latestVersion: '0.2.0' }); + await Promise.all([first, second]); + + expect(checkForUpdate).toHaveBeenCalledTimes(1); + }); + + it.each([ + ['check failure', { outcome: 'check-failed', currentVersion: '0.2.0', latestVersion: null, error: 'registry unavailable' }], + ['invalidation failure', { outcome: 'invalidation-failed', currentVersion: '0.1.0', latestVersion: '0.2.0', error: 'cache target unavailable' }], + ])('retries after %s', async (_label, failedResult) => { + checkForUpdate.mockResolvedValueOnce(failedResult as any).mockResolvedValueOnce({ outcome: 'up-to-date', currentVersion: '0.2.0', latestVersion: '0.2.0' }); + const hooks = await ToolSearchPlugin(createContext() as any, { embedding: { enabled: false } } as any); + + await hooks.event!({ event: { type: 'session.created' } } as any); + await hooks.event!({ event: { type: 'session.created' } } as any); + + expect(checkForUpdate).toHaveBeenCalledTimes(2); + }); + + it('formats all four outcomes with the real formatter', async () => { + expect((await vi.importActual('../src/hooks/auto-update-checker.js')).formatUpdateMessage({ outcome: 'up-to-date', currentVersion: '0.2.0', latestVersion: '0.2.0' })).toEqual({ title: 'Tool Search', message: 'Up-to-date', variant: 'info' }); + expect((await vi.importActual('../src/hooks/auto-update-checker.js')).formatUpdateMessage({ outcome: 'update-staged', currentVersion: '0.1.0', latestVersion: '0.2.0' })).toEqual({ title: 'Tool Search Update', message: 'v0.1.0 -> v0.2.0. Restart OpenCode to apply.', variant: 'warning' }); + expect((await vi.importActual('../src/hooks/auto-update-checker.js')).formatUpdateMessage({ outcome: 'invalidation-failed', currentVersion: '0.1.0', latestVersion: '0.2.0', error: 'remove failed' })).toEqual({ title: 'Tool Search Update Check', message: 'remove failed', variant: 'error' }); + expect((await vi.importActual('../src/hooks/auto-update-checker.js')).formatUpdateMessage({ outcome: 'check-failed', currentVersion: null, latestVersion: null, error: 'registry failed' })).toEqual({ title: 'Tool Search Update Check', message: 'registry failed', variant: 'error' }); + }); + + it('latches after staged update and suppresses later check and toast', async () => { + const context = createContext(); + checkForUpdate.mockImplementation(async () => ({ outcome: 'update-staged' as const, currentVersion: '0.1.0', latestVersion: '0.2.0' })); + const hooks = await ToolSearchPlugin(context as any, { embedding: { enabled: false } } as any); + await hooks.event!({ event: { type: 'session.created' } } as any); + await vi.advanceTimersByTimeAsync(100); + expect(context.client.tui.showToast).toHaveBeenCalledWith({ body: { + title: 'Tool Search Update', + message: 'v0.1.0 -> v0.2.0. Restart OpenCode to apply.', + variant: 'warning', + duration: 6000, + }}); + const callsAfterFirst = checkForUpdate.mock.calls.length; + const toastsAfterFirst = context.client.tui.showToast.mock.calls.length; + await hooks.event!({ event: { type: 'session.created' } } as any); + await vi.advanceTimersByTimeAsync(100); + expect(checkForUpdate).toHaveBeenCalledTimes(callsAfterFirst); + expect(context.client.tui.showToast).toHaveBeenCalledTimes(toastsAfterFirst); + }); + + it('notifies only staged and failed outcomes, using the centralized formatter', async () => { + const context = createContext(); + const results: any[] = [ + { outcome: 'up-to-date', currentVersion: '0.2.0', latestVersion: '0.2.0' }, + { outcome: 'invalidation-failed', currentVersion: '0.1.0', latestVersion: '0.2.0', error: 'target removal failed' }, + { outcome: 'check-failed', currentVersion: '0.2.0', latestVersion: null, error: 'registry failed' }, + { outcome: 'update-staged', currentVersion: '0.1.0', latestVersion: '0.2.0' }, + ]; + checkForUpdate.mockImplementation(async () => results.shift()); + const hooks = await ToolSearchPlugin(context as any, { embedding: { enabled: false } } as any); + + for (let i = 0; i < 4; i += 1) { + await hooks.event!({ event: { type: 'session.created' } } as any); + } + await hooks.event!({ event: { type: 'session.created' } } as any); + await hooks.event!({ event: { type: 'session.created' } } as any); + await hooks.event!({ event: { type: 'session.created' } } as any); + await vi.advanceTimersByTimeAsync(100); + + expect(mockedFormatUpdateMessage).toHaveBeenCalledTimes(4); + expect(context.client.tui.showToast).toHaveBeenCalledTimes(3); + expect(context.client.tui.showToast).toHaveBeenNthCalledWith(1, { body: { + title: 'Tool Search Update Check', message: 'target removal failed', variant: 'error', duration: 6000, + }}); + expect(context.client.tui.showToast).toHaveBeenNthCalledWith(2, { body: { + title: 'Tool Search Update Check', message: 'registry failed', variant: 'error', duration: 6000, + }}); + expect(context.client.tui.showToast).toHaveBeenNthCalledWith(3, { body: { + title: 'Tool Search Update', message: 'v0.1.0 -> v0.2.0. Restart OpenCode to apply.', variant: 'warning', duration: 6000, + }}); + }); + + it('notifies when the checker throws and permits a later retry', async () => { + const context = createContext(); + checkForUpdate.mockRejectedValueOnce(new Error('unexpected checker failure')).mockResolvedValueOnce({ outcome: 'up-to-date', currentVersion: '0.2.0', latestVersion: '0.2.0' }); + const hooks = await ToolSearchPlugin(context as any, { embedding: { enabled: false } } as any); + + await hooks.event!({ event: { type: 'session.created' } } as any); + await hooks.event!({ event: { type: 'session.created' } } as any); + await vi.advanceTimersByTimeAsync(100); + + expect(checkForUpdate).toHaveBeenCalledTimes(2); + expect(context.client.tui.showToast).toHaveBeenCalledTimes(1); + }); + + it('ignores non-session events', async () => { + const hooks = await ToolSearchPlugin(createContext() as any, { embedding: { enabled: false } } as any); + + await hooks.event!({ event: { type: 'session.deleted' } } as any); + + expect(checkForUpdate).not.toHaveBeenCalled(); + }); +});