From f8a11d88fe81563f94b1995fcea18a1087789511 Mon Sep 17 00:00:00 2001 From: Gianluca Carucci Date: Sat, 11 Apr 2026 20:20:30 +0200 Subject: [PATCH 1/7] [#189] test: add failing tests for cache-bypass when customUrl provided - AC-1: cache exists + remote --source triggers download - AC-2: different --source URL triggers download - Both tests FAIL on current code (bug reproduction) - Task: T-1 Refs: #189 --- .../src/kb-manager/kb-availability.test.ts | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) 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..c2103833 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,90 @@ 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 () => { + vi.clearAllMocks() + 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 download from different customUrl even when cache exists (AC-2)', async () => { + vi.clearAllMocks() + 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 { From 0ddc286dafa60a3975e5c841f78b73cd480f652a Mon Sep 17 00:00:00 2001 From: Gianluca Carucci Date: Sat, 11 Apr 2026 20:21:20 +0200 Subject: [PATCH 2/7] [#189] fix: bypass cache when --source/customUrl is provided - Add clearCachedKB to cache-manager.ts (safe no-op if missing) - ensureKBAvailable skips cache early-return when customUrl is truthy - Clears existing cache before re-installation for atomic replacement - Task: T-2 Refs: #189 --- apps/pair-cli/src/kb-manager/cache-manager.ts | 11 +++++++++++ apps/pair-cli/src/kb-manager/kb-availability.ts | 10 +++++++--- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/apps/pair-cli/src/kb-manager/cache-manager.ts b/apps/pair-cli/src/kb-manager/cache-manager.ts index 3c7e8492..f345f984 100644 --- a/apps/pair-cli/src/kb-manager/cache-manager.ts +++ b/apps/pair-cli/src/kb-manager/cache-manager.ts @@ -23,8 +23,19 @@ export async function ensureCacheDirectory( await fs.mkdir(cachePath, { recursive: true }) } +export async function clearCachedKB( + version: string, + fs: FileSystemService, +): Promise { + const cachePath = getCachedKBPath(version) + if (fs.existsSync(cachePath)) { + await fs.rm(cachePath, { recursive: true }) + } +} + export default { getCachedKBPath, isKBCached, ensureCacheDirectory, + clearCachedKB, } diff --git a/apps/pair-cli/src/kb-manager/kb-availability.ts b/apps/pair-cli/src/kb-manager/kb-availability.ts index 63d761f0..75b6c45f 100644 --- a/apps/pair-cli/src/kb-manager/kb-availability.ts +++ b/apps/pair-cli/src/kb-manager/kb-availability.ts @@ -38,10 +38,14 @@ 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) { + await cacheManager.clearCachedKB(version, fs) + } else { + const cached = await isKBCached(version, fs) + if (cached) { + return cachePath + } } const sourceUrl = deps.customUrl || urlUtils.buildGithubReleaseUrl(version) From af82d3e10ec92062fba8accc1bfd2dfe84f2f27c Mon Sep 17 00:00:00 2001 From: Gianluca Carucci Date: Sat, 11 Apr 2026 20:22:14 +0200 Subject: [PATCH 3/7] [#189] test: add regression tests for cache-bypass edge cases - AC-3: no --source preserves cache-hit (no HTTP calls) - AC-4: local path re-install when cache exists - AC-5: failed remote download behavior - clearCachedKB unit tests (remove + no-op) - Task: T-3 Refs: #189 --- .../src/kb-manager/cache-manager.test.ts | 18 +++++ .../src/kb-manager/kb-availability.test.ts | 77 +++++++++++++++++++ 2 files changed, 95 insertions(+) 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..13f9c9df 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,24 @@ describe('cache-manager', () => { expect(res).toBe(false) }) + it('clearCachedKB removes existing cache directory', async () => { + const cachePath = join(homedir(), '.pair', 'kb', '0.2.0') + const fs = new InMemoryFileSystemService( + { [cachePath + '/manifest.json']: '{}' }, + '/', + '/', + ) + expect(fs.existsSync(cachePath)).toBe(true) + await cacheManager.clearCachedKB('0.2.0', fs) + expect(fs.existsSync(cachePath)).toBe(false) + }) + + it('clearCachedKB is no-op when cache does not exist', async () => { + const fs = new InMemoryFileSystemService({}, '/', '/') + // Should not throw + await cacheManager.clearCachedKB('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/kb-availability.test.ts b/apps/pair-cli/src/kb-manager/kb-availability.test.ts index c2103833..2ab58f5b 100644 --- a/apps/pair-cli/src/kb-manager/kb-availability.test.ts +++ b/apps/pair-cli/src/kb-manager/kb-availability.test.ts @@ -506,6 +506,83 @@ describe('KB Manager - Cache bypass when customUrl provided', () => { 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 preserve cache when remote customUrl download fails (AC-5)', async () => { + vi.clearAllMocks() + + 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() + + // Note: clearCachedKB runs before download attempt, so cache IS removed. + // This is acceptable per story notes: "existing installers download to temp + // zip then extract. If extraction fails, cleanup runs." + }) + it('should download from different customUrl even when cache exists (AC-2)', async () => { vi.clearAllMocks() const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) From 0b1ccd524e1b1be6898428295eb92f190fe807a5 Mon Sep 17 00:00:00 2001 From: Gianluca Carucci Date: Sat, 11 Apr 2026 20:22:48 +0200 Subject: [PATCH 4/7] [#189] style: apply prettier formatting Refs: #189 --- apps/pair-cli/src/kb-manager/cache-manager.test.ts | 6 +----- apps/pair-cli/src/kb-manager/cache-manager.ts | 5 +---- apps/pair-cli/src/kb-manager/kb-availability.test.ts | 4 +--- 3 files changed, 3 insertions(+), 12 deletions(-) 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 13f9c9df..730c0e64 100644 --- a/apps/pair-cli/src/kb-manager/cache-manager.test.ts +++ b/apps/pair-cli/src/kb-manager/cache-manager.test.ts @@ -25,11 +25,7 @@ describe('cache-manager', () => { it('clearCachedKB removes existing cache directory', async () => { const cachePath = join(homedir(), '.pair', 'kb', '0.2.0') - const fs = new InMemoryFileSystemService( - { [cachePath + '/manifest.json']: '{}' }, - '/', - '/', - ) + const fs = new InMemoryFileSystemService({ [cachePath + '/manifest.json']: '{}' }, '/', '/') expect(fs.existsSync(cachePath)).toBe(true) await cacheManager.clearCachedKB('0.2.0', fs) expect(fs.existsSync(cachePath)).toBe(false) diff --git a/apps/pair-cli/src/kb-manager/cache-manager.ts b/apps/pair-cli/src/kb-manager/cache-manager.ts index f345f984..4a14ec94 100644 --- a/apps/pair-cli/src/kb-manager/cache-manager.ts +++ b/apps/pair-cli/src/kb-manager/cache-manager.ts @@ -23,10 +23,7 @@ export async function ensureCacheDirectory( await fs.mkdir(cachePath, { recursive: true }) } -export async function clearCachedKB( - version: string, - fs: FileSystemService, -): Promise { +export async function clearCachedKB(version: string, fs: FileSystemService): Promise { const cachePath = getCachedKBPath(version) if (fs.existsSync(cachePath)) { await fs.rm(cachePath, { recursive: true }) 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 2ab58f5b..a592d622 100644 --- a/apps/pair-cli/src/kb-manager/kb-availability.test.ts +++ b/apps/pair-cli/src/kb-manager/kb-availability.test.ts @@ -564,9 +564,7 @@ describe('KB Manager - Cache bypass when customUrl provided', () => { ) const failingUrl = 'https://failing.example.com/kb.zip' - const headResponse = toIncomingMessage( - buildTestResponse(200, { 'content-length': '0' }), - ) + const headResponse = toIncomingMessage(buildTestResponse(200, { 'content-length': '0' })) const checksumResp = toIncomingMessage(buildTestResponse(404)) const fileResp = toIncomingMessage(buildTestResponse(404)) From afe95c1a9fcb91a3e469a0a41f80832ba2e4bf88 Mon Sep 17 00:00:00 2001 From: Gianluca Carucci Date: Sat, 11 Apr 2026 20:23:01 +0200 Subject: [PATCH 5/7] [#189] chore: add changeset for cache-bypass fix Refs: #189 --- .changeset/fix-source-cache-bypass.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/fix-source-cache-bypass.md diff --git a/.changeset/fix-source-cache-bypass.md b/.changeset/fix-source-cache-bypass.md new file mode 100644 index 00000000..89cedfc5 --- /dev/null +++ b/.changeset/fix-source-cache-bypass.md @@ -0,0 +1,5 @@ +--- +'@pair/pair-cli': patch +--- + +fix: `--source` now bypasses cache when KB already exists, ensuring the specified URL/path is always downloaded and applied From f731c760e5a5c5676a02fd9187438f3a4785259a Mon Sep 17 00:00:00 2001 From: Gianluca Carucci Date: Sat, 11 Apr 2026 20:31:07 +0200 Subject: [PATCH 6/7] [#189] chore: remove changeset (release deferred) Refs: #189 --- .changeset/fix-source-cache-bypass.md | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 .changeset/fix-source-cache-bypass.md diff --git a/.changeset/fix-source-cache-bypass.md b/.changeset/fix-source-cache-bypass.md deleted file mode 100644 index 89cedfc5..00000000 --- a/.changeset/fix-source-cache-bypass.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@pair/pair-cli': patch ---- - -fix: `--source` now bypasses cache when KB already exists, ensuring the specified URL/path is always downloaded and applied From 41dd97c26c21f6415b550715f7756fd6287dd7b0 Mon Sep 17 00:00:00 2001 From: Gianluca Carucci Date: Sat, 11 Apr 2026 20:41:28 +0200 Subject: [PATCH 7/7] [#189] fix: atomic cache replacement + review fixes - Replace clearCachedKB with backup/restore pattern (backupCachedKB, restoreCachedKB, removeBackupKB) for atomic cache replacement - Cache now restored on failed download (AC-5 fully satisfied) - Extract installFromSource helper to avoid try/catch duplication - Fix misleading AC-5 test title + add cache preservation assertions - Remove unnecessary vi.clearAllMocks() in AC-1/AC-2 tests Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/kb-manager/cache-manager.test.ts | 38 ++++++++++++++++--- apps/pair-cli/src/kb-manager/cache-manager.ts | 28 ++++++++++++-- .../src/kb-manager/kb-availability.test.ts | 12 ++---- .../src/kb-manager/kb-availability.ts | 29 ++++++++++---- 4 files changed, 82 insertions(+), 25 deletions(-) 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 730c0e64..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,18 +23,44 @@ describe('cache-manager', () => { expect(res).toBe(false) }) - it('clearCachedKB removes existing cache directory', async () => { + 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']: '{}' }, '/', '/') - expect(fs.existsSync(cachePath)).toBe(true) - await cacheManager.clearCachedKB('0.2.0', fs) + 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('clearCachedKB is no-op when cache does not exist', async () => { + it('removeBackupKB is no-op when no backup exists', async () => { const fs = new InMemoryFileSystemService({}, '/', '/') - // Should not throw - await cacheManager.clearCachedKB('0.2.0', fs) + await cacheManager.removeBackupKB('0.2.0', fs) }) it('ensureCacheDirectory creates directory', async () => { diff --git a/apps/pair-cli/src/kb-manager/cache-manager.ts b/apps/pair-cli/src/kb-manager/cache-manager.ts index 4a14ec94..4fedf6a4 100644 --- a/apps/pair-cli/src/kb-manager/cache-manager.ts +++ b/apps/pair-cli/src/kb-manager/cache-manager.ts @@ -23,10 +23,30 @@ export async function ensureCacheDirectory( await fs.mkdir(cachePath, { recursive: true }) } -export async function clearCachedKB(version: string, fs: FileSystemService): Promise { +const BACKUP_SUFFIX = '.bak' + +export async function backupCachedKB(version: string, fs: FileSystemService): Promise { const cachePath = getCachedKBPath(version) if (fs.existsSync(cachePath)) { - await fs.rm(cachePath, { recursive: true }) + 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 }) } } @@ -34,5 +54,7 @@ export default { getCachedKBPath, isKBCached, ensureCacheDirectory, - clearCachedKB, + 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 a592d622..5aa805ff 100644 --- a/apps/pair-cli/src/kb-manager/kb-availability.test.ts +++ b/apps/pair-cli/src/kb-manager/kb-availability.test.ts @@ -465,7 +465,6 @@ describe('KB Manager - Cache bypass when customUrl provided', () => { const expectedCachePath = join(homedir(), '.pair', 'kb', testVersion) it('should download from remote customUrl even when cache exists (AC-1)', async () => { - vi.clearAllMocks() const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) // Pre-seed cache so isKBCached returns true @@ -551,9 +550,7 @@ describe('KB Manager - Cache bypass when customUrl provided', () => { expect(httpClient.getUrls()).toHaveLength(0) }) - it('should preserve cache when remote customUrl download fails (AC-5)', async () => { - vi.clearAllMocks() - + it('should restore cache when remote customUrl download fails (AC-5)', async () => { const fs = new InMemoryFileSystemService( { [expectedCachePath + '/manifest.json']: '{"version": "0.2.0"}', @@ -576,13 +573,12 @@ describe('KB Manager - Cache bypass when customUrl provided', () => { ensureKBAvailable(testVersion, { httpClient, fs, customUrl: failingUrl }), ).rejects.toThrow() - // Note: clearCachedKB runs before download attempt, so cache IS removed. - // This is acceptable per story notes: "existing installers download to temp - // zip then extract. If extraction fails, cleanup runs." + // 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 () => { - vi.clearAllMocks() const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) // Pre-seed cache from a "previous" source diff --git a/apps/pair-cli/src/kb-manager/kb-availability.ts b/apps/pair-cli/src/kb-manager/kb-availability.ts index 75b6c45f..0fedc42c 100644 --- a/apps/pair-cli/src/kb-manager/kb-availability.ts +++ b/apps/pair-cli/src/kb-manager/kb-availability.ts @@ -39,28 +39,41 @@ export async function ensureKBAvailable(version: string, deps: KBManagerDeps): P const fs = deps.fs const cachePath = getCachedKBPath(version) - if (deps.customUrl) { - await cacheManager.clearCachedKB(version, fs) - } else { + 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