Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions apps/pair-cli/src/kb-manager/cache-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
30 changes: 30 additions & 0 deletions apps/pair-cli/src/kb-manager/cache-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean> {
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<void> {
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<void> {
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,
}
155 changes: 155 additions & 0 deletions apps/pair-cli/src/kb-manager/kb-availability.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
33 changes: 25 additions & 8 deletions apps/pair-cli/src/kb-manager/kb-availability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,25 +38,42 @@ function buildInstallerDeps(deps: KBManagerDeps): InstallerDeps {
export async function ensureKBAvailable(version: string, deps: KBManagerDeps): Promise<string> {
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<string> {
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
Expand Down
Loading