diff --git a/apps/pair-cli/src/kb-manager/cache-manager.test.ts b/apps/pair-cli/src/kb-manager/cache-manager.test.ts index 93dd75ff..c3a39d37 100644 --- a/apps/pair-cli/src/kb-manager/cache-manager.test.ts +++ b/apps/pair-cli/src/kb-manager/cache-manager.test.ts @@ -23,6 +23,46 @@ describe('cache-manager', () => { expect(res).toBe(false) }) + it('backupCachedKB moves cache to .bak and returns true', async () => { + const cachePath = join(homedir(), '.pair', 'kb', '0.2.0') + const fs = new InMemoryFileSystemService({ [cachePath + '/manifest.json']: '{}' }, '/', '/') + const result = await cacheManager.backupCachedKB('0.2.0', fs) + expect(result).toBe(true) + expect(fs.existsSync(cachePath)).toBe(false) + expect(fs.existsSync(cachePath + '.bak')).toBe(true) + }) + + it('backupCachedKB returns false when cache does not exist', async () => { + const fs = new InMemoryFileSystemService({}, '/', '/') + const result = await cacheManager.backupCachedKB('0.2.0', fs) + expect(result).toBe(false) + }) + + it('restoreCachedKB moves .bak back to cache', async () => { + const cachePath = join(homedir(), '.pair', 'kb', '0.2.0') + const fs = new InMemoryFileSystemService({ [cachePath + '.bak/manifest.json']: '{}' }, '/', '/') + await cacheManager.restoreCachedKB('0.2.0', fs) + expect(fs.existsSync(cachePath)).toBe(true) + expect(fs.existsSync(cachePath + '.bak')).toBe(false) + }) + + it('restoreCachedKB is no-op when no backup exists', async () => { + const fs = new InMemoryFileSystemService({}, '/', '/') + await cacheManager.restoreCachedKB('0.2.0', fs) + }) + + it('removeBackupKB deletes .bak directory', async () => { + const cachePath = join(homedir(), '.pair', 'kb', '0.2.0') + const fs = new InMemoryFileSystemService({ [cachePath + '.bak/manifest.json']: '{}' }, '/', '/') + await cacheManager.removeBackupKB('0.2.0', fs) + expect(fs.existsSync(cachePath + '.bak')).toBe(false) + }) + + it('removeBackupKB is no-op when no backup exists', async () => { + const fs = new InMemoryFileSystemService({}, '/', '/') + await cacheManager.removeBackupKB('0.2.0', fs) + }) + it('ensureCacheDirectory creates directory', async () => { const fs = new InMemoryFileSystemService({}, '/', '/') const path = cacheManager.getCachedKBPath('0.2.0') diff --git a/apps/pair-cli/src/kb-manager/cache-manager.ts b/apps/pair-cli/src/kb-manager/cache-manager.ts index 3c7e8492..4fedf6a4 100644 --- a/apps/pair-cli/src/kb-manager/cache-manager.ts +++ b/apps/pair-cli/src/kb-manager/cache-manager.ts @@ -23,8 +23,38 @@ export async function ensureCacheDirectory( await fs.mkdir(cachePath, { recursive: true }) } +const BACKUP_SUFFIX = '.bak' + +export async function backupCachedKB(version: string, fs: FileSystemService): Promise { + const cachePath = getCachedKBPath(version) + if (fs.existsSync(cachePath)) { + await fs.rename(cachePath, cachePath + BACKUP_SUFFIX) + return true + } + return false +} + +export async function restoreCachedKB(version: string, fs: FileSystemService): Promise { + const cachePath = getCachedKBPath(version) + const backupPath = cachePath + BACKUP_SUFFIX + if (fs.existsSync(backupPath)) { + await fs.rename(backupPath, cachePath) + } +} + +export async function removeBackupKB(version: string, fs: FileSystemService): Promise { + const cachePath = getCachedKBPath(version) + const backupPath = cachePath + BACKUP_SUFFIX + if (fs.existsSync(backupPath)) { + await fs.rm(backupPath, { recursive: true }) + } +} + export default { getCachedKBPath, isKBCached, ensureCacheDirectory, + backupCachedKB, + restoreCachedKB, + removeBackupKB, } diff --git a/apps/pair-cli/src/kb-manager/kb-availability.test.ts b/apps/pair-cli/src/kb-manager/kb-availability.test.ts index 01a71e32..5aa805ff 100644 --- a/apps/pair-cli/src/kb-manager/kb-availability.test.ts +++ b/apps/pair-cli/src/kb-manager/kb-availability.test.ts @@ -460,6 +460,161 @@ describe('KB manager integration - local directory paths via customUrl', () => { }) }) +describe('KB Manager - Cache bypass when customUrl provided', () => { + const testVersion = '0.2.0' + const expectedCachePath = join(homedir(), '.pair', 'kb', testVersion) + + it('should download from remote customUrl even when cache exists (AC-1)', async () => { + const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + // Pre-seed cache so isKBCached returns true + const fs = new InMemoryFileSystemService( + { + [expectedCachePath + '/manifest.json']: '{"version": "0.2.0"}', + [expectedCachePath + '/.pair/knowledge/test.md']: 'old content', + }, + '/', + '/', + ) + + const customUrl = 'https://custom.example.com/new-kb.zip' + const zipContent = { + 'manifest.json': JSON.stringify({ version: '0.2.0' }), + '.pair/knowledge/test.md': 'new content', + } + const validZipData = JSON.stringify(zipContent) + + const headResponse = toIncomingMessage( + buildTestResponse(200, { 'content-length': validZipData.length.toString() }), + ) + const checksumResp = toIncomingMessage(buildTestResponse(404)) + const fileResp = toIncomingMessage( + buildTestResponse(200, { 'content-length': validZipData.length.toString() }, validZipData), + ) + + const httpClient = new MockHttpClientService() + httpClient.setRequestResponses([headResponse]) + httpClient.setGetResponses([fileResp, checksumResp]) + + const result = await ensureKBAvailable(testVersion, { httpClient, fs, customUrl }) + + // Should have downloaded (httpClient was called), not just returned cache + expect(httpClient.getUrls()[0]).toBe(customUrl) + expect(result).toBe(expectedCachePath) + + consoleLogSpy.mockRestore() + }) + + it('should preserve cache-hit when no customUrl provided (AC-3)', async () => { + const fs = new InMemoryFileSystemService( + { + [expectedCachePath + '/manifest.json']: '{"version": "0.2.0"}', + [expectedCachePath + '/.pair/knowledge/test.md']: 'cached content', + }, + '/', + '/', + ) + + const httpClient = new MockHttpClientService() + + // No customUrl — should return cache immediately without any HTTP calls + const result = await ensureKBAvailable(testVersion, { httpClient, fs }) + + expect(result).toBe(expectedCachePath) + expect(httpClient.getUrls()).toHaveLength(0) + }) + + it('should re-install from local path when cache exists (AC-4)', async () => { + const localPath = '/local/kb/dataset' + const fs = new InMemoryFileSystemService( + { + [expectedCachePath + '/manifest.json']: '{"version": "0.2.0"}', + [localPath + '/AGENTS.md']: 'local agents', + [localPath + '/.pair/knowledge/index.md']: '# Local KB', + }, + '/', + '/', + ) + + const httpClient = new MockHttpClientService() + + const result = await ensureKBAvailable(testVersion, { + httpClient, + fs, + customUrl: localPath, + }) + + // Should have installed from local path, not returned stale cache + expect(result).toBeDefined() + // No HTTP calls for local path + expect(httpClient.getUrls()).toHaveLength(0) + }) + + it('should restore cache when remote customUrl download fails (AC-5)', async () => { + const fs = new InMemoryFileSystemService( + { + [expectedCachePath + '/manifest.json']: '{"version": "0.2.0"}', + [expectedCachePath + '/.pair/knowledge/test.md']: 'cached content', + }, + '/', + '/', + ) + + const failingUrl = 'https://failing.example.com/kb.zip' + const headResponse = toIncomingMessage(buildTestResponse(200, { 'content-length': '0' })) + const checksumResp = toIncomingMessage(buildTestResponse(404)) + const fileResp = toIncomingMessage(buildTestResponse(404)) + + const httpClient = new MockHttpClientService() + httpClient.setRequestResponses([headResponse]) + httpClient.setGetResponses([fileResp, checksumResp]) + + await expect( + ensureKBAvailable(testVersion, { httpClient, fs, customUrl: failingUrl }), + ).rejects.toThrow() + + // Cache is restored from backup after failed download (atomic replacement) + expect(fs.existsSync(expectedCachePath)).toBe(true) + expect(fs.existsSync(expectedCachePath + '.bak')).toBe(false) + }) + + it('should download from different customUrl even when cache exists (AC-2)', async () => { + const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + // Pre-seed cache from a "previous" source + const fs = new InMemoryFileSystemService( + { + [expectedCachePath + '/manifest.json']: '{"version": "0.2.0"}', + }, + '/', + '/', + ) + + const differentUrl = 'https://other-source.example.com/kb-v2.zip' + const zipContent = { 'manifest.json': JSON.stringify({ version: '0.2.0' }) } + const validZipData = JSON.stringify(zipContent) + + const headResponse = toIncomingMessage( + buildTestResponse(200, { 'content-length': validZipData.length.toString() }), + ) + const checksumResp = toIncomingMessage(buildTestResponse(404)) + const fileResp = toIncomingMessage( + buildTestResponse(200, { 'content-length': validZipData.length.toString() }, validZipData), + ) + + const httpClient = new MockHttpClientService() + httpClient.setRequestResponses([headResponse]) + httpClient.setGetResponses([fileResp, checksumResp]) + + const result = await ensureKBAvailable(testVersion, { httpClient, fs, customUrl: differentUrl }) + + expect(httpClient.getUrls()[0]).toBe(differentUrl) + expect(result).toBe(expectedCachePath) + + consoleLogSpy.mockRestore() + }) +}) + // Helper functions function createMockFsWithoutLocal() { return { diff --git a/apps/pair-cli/src/kb-manager/kb-availability.ts b/apps/pair-cli/src/kb-manager/kb-availability.ts index 63d761f0..0fedc42c 100644 --- a/apps/pair-cli/src/kb-manager/kb-availability.ts +++ b/apps/pair-cli/src/kb-manager/kb-availability.ts @@ -38,25 +38,42 @@ function buildInstallerDeps(deps: KBManagerDeps): InstallerDeps { export async function ensureKBAvailable(version: string, deps: KBManagerDeps): Promise { const fs = deps.fs const cachePath = getCachedKBPath(version) - const cached = await isKBCached(version, fs) - if (cached) { - return cachePath + if (!deps.customUrl) { + const cached = await isKBCached(version, fs) + if (cached) { + return cachePath + } + } + + const hadCache = deps.customUrl ? await cacheManager.backupCachedKB(version, fs) : false + + try { + const result = await installFromSource(version, cachePath, deps) + if (hadCache) await cacheManager.removeBackupKB(version, fs) + return result + } catch (err) { + if (hadCache) await cacheManager.restoreCachedKB(version, fs) + throw err } +} +async function installFromSource( + version: string, + cachePath: string, + deps: KBManagerDeps, +): Promise { const sourceUrl = deps.customUrl || urlUtils.buildGithubReleaseUrl(version) const installerDeps = buildInstallerDeps(deps) + const fs = deps.fs // Check if source is a local path instead of a remote URL const sourceType = detectSourceType(sourceUrl, fs) if (sourceType !== SourceType.REMOTE_URL) { if (sourceUrl.endsWith('.zip')) { - // Local ZIP file - return await installKBFromLocalZip(version, sourceUrl, fs, deps.skipVerify) - } else { - // Local directory - return await installKBFromLocalDirectory(version, sourceUrl, fs) + return installKBFromLocalZip(version, sourceUrl, fs, deps.skipVerify) } + return installKBFromLocalDirectory(version, sourceUrl, fs) } // Remote URL - use standard download