From 7a3e6cad997b8a41994fffe524fbbee496bff948 Mon Sep 17 00:00:00 2001 From: shwetank-dev Date: Sun, 10 May 2026 14:05:28 -0400 Subject: [PATCH 1/6] fix(sdk): check platform in loadBundle cache guards (#78) Both early-return guards in loadBundle compared only version, so a cached darwin bundle was returned on a linux host when versions matched. Move detectPlatform() above guard 1 and add os/arch checks to both guards. Adds unit tests for platform mismatch, platform match, and force flag. Co-Authored-By: Claude Sonnet 4.6 --- packages/sdk-typescript/src/cache.ts | 13 +++- packages/sdk-typescript/tests/cache.test.ts | 73 ++++++++++++++++++++- 2 files changed, 83 insertions(+), 3 deletions(-) diff --git a/packages/sdk-typescript/src/cache.ts b/packages/sdk-typescript/src/cache.ts index d15873e..b2dd0a2 100644 --- a/packages/sdk-typescript/src/cache.ts +++ b/packages/sdk-typescript/src/cache.ts @@ -183,7 +183,9 @@ export class MpakBundleCache { options?: { version?: string; force?: boolean }, ): Promise<{ cacheDir: string; version: string; pulled: boolean }> { const { version: requestedVersion, force = false } = options ?? {}; + const cacheDir = this.getBundleCacheDirName(name); + const platform = MpakClient.detectPlatform(); let cachedMeta: CacheMetadata | null = null; try { @@ -203,13 +205,14 @@ export class MpakBundleCache { if ( !options?.force && !!cachedMeta && + cachedMeta.platform.os === platform.os && + cachedMeta.platform.arch === platform.arch && (!requestedVersion || isSemverEqual(cachedMeta.version, requestedVersion)) ) { return { cacheDir, version: cachedMeta.version, pulled: false }; } // Get download info from registry - const platform = MpakClient.detectPlatform(); const downloadInfo = await this.mpakClient.getBundleDownload( name, requestedVersion ?? 'latest', @@ -217,7 +220,13 @@ export class MpakBundleCache { ); // Registry resolved to the same version we already have — skip download - if (!force && cachedMeta && isSemverEqual(cachedMeta.version, downloadInfo.bundle.version)) { + if ( + !force && + cachedMeta && + cachedMeta.platform.os === platform.os && + cachedMeta.platform.arch === platform.arch && + isSemverEqual(cachedMeta.version, downloadInfo.bundle.version) + ) { // Update lastCheckedAt since we just verified with the registry this.writeCacheMetadata(name, { ...cachedMeta, diff --git a/packages/sdk-typescript/tests/cache.test.ts b/packages/sdk-typescript/tests/cache.test.ts index 3726508..ee71879 100644 --- a/packages/sdk-typescript/tests/cache.test.ts +++ b/packages/sdk-typescript/tests/cache.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { MpakBundleCache } from '../src/cache.js'; -import type { MpakClient } from '../src/client.js'; +import { MpakClient } from '../src/client.js'; import { MpakCacheCorruptedError } from '../src/errors.js'; // --------------------------------------------------------------------------- @@ -72,6 +72,7 @@ describe('MpakBundleCache', () => { }); afterEach(() => { + vi.restoreAllMocks(); rmSync(testDir, { recursive: true, force: true }); }); @@ -410,4 +411,74 @@ describe('MpakBundleCache', () => { expect(meta?.lastCheckedAt).toBeDefined(); }); }); + + // ------------------------------------------------------------------------- + // loadBundle — platform guard fixes (#78) + // ------------------------------------------------------------------------- + + describe('loadBundle', () => { + const fakeDownloadInfo = { + url: 'https://example.com/bundle.mcpb', + bundle: { + name: '@scope/name', + version: '1.0.0', + platform: { os: 'linux', arch: 'x64' }, + sha256: 'deadbeef', + size: 1000, + }, + }; + + it('re-downloads when cached platform does not match current platform', async () => { + const client = mockClient({ + getBundleDownload: vi.fn().mockResolvedValue(fakeDownloadInfo), + }); + const cache = new MpakBundleCache(client, { mpakHome: testDir }); + + // Cache has darwin/arm64 + seedCacheEntry(testDir, 'scope-name', { manifest: validManifest, metadata: validMetadata }); + + // Host is linux/x64 + vi.spyOn(MpakClient, 'detectPlatform').mockReturnValue({ os: 'linux', arch: 'x64' }); + vi.spyOn(cache as any, 'downloadAndExtract').mockResolvedValue(undefined); + + await cache.loadBundle('@scope/name'); + + expect(client.getBundleDownload).toHaveBeenCalled(); + }); + + it('uses cache and skips registry when platform and version match', async () => { + const client = mockClient({ + getBundleDownload: vi.fn(), + }); + const cache = new MpakBundleCache(client, { mpakHome: testDir }); + + // Cache has darwin/arm64 + seedCacheEntry(testDir, 'scope-name', { manifest: validManifest, metadata: validMetadata }); + + // Host is also darwin/arm64 + vi.spyOn(MpakClient, 'detectPlatform').mockReturnValue({ os: 'darwin', arch: 'arm64' }); + + await cache.loadBundle('@scope/name'); + + expect(client.getBundleDownload).not.toHaveBeenCalled(); + }); + + it('re-downloads when force is true even if platform and version match', async () => { + const client = mockClient({ + getBundleDownload: vi.fn().mockResolvedValue(fakeDownloadInfo), + }); + const cache = new MpakBundleCache(client, { mpakHome: testDir }); + + // Cache has darwin/arm64 + seedCacheEntry(testDir, 'scope-name', { manifest: validManifest, metadata: validMetadata }); + + // Host matches cache platform + vi.spyOn(MpakClient, 'detectPlatform').mockReturnValue({ os: 'darwin', arch: 'arm64' }); + vi.spyOn(cache as any, 'downloadAndExtract').mockResolvedValue(undefined); + + await cache.loadBundle('@scope/name', { force: true }); + + expect(client.getBundleDownload).toHaveBeenCalled(); + }); + }); }); From af580829c045b3245c8bb83a30769081780739ac Mon Sep 17 00:00:00 2001 From: shwetank-dev Date: Sun, 10 May 2026 15:18:53 -0400 Subject: [PATCH 2/6] fix(cli): populate bundle cache after pull (#60) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bundle pull downloaded bytes to disk but never updated ~/.mpak/cache/, so bundle run would continue using the old version until an explicit update was triggered. Added MpakBundleCache.extractBundle(name, data, bundle) which takes already-downloaded bytes and extracts them into the cache — reusing the bytes already in memory from downloadBundle so no second network call is made. pull.ts calls extractBundle after writeFileSync. Added integration test that asserts ~/.mpak/cache//.mpak-meta.json exists and contains the correct version after a pull. Co-Authored-By: Claude Sonnet 4.6 --- packages/cli/src/commands/packages/pull.ts | 1 + packages/cli/tests/bundles/pull.test.ts | 4 +++ .../integration/bundle.integration.test.ts | 24 ++++++++++++-- packages/sdk-typescript/src/cache.ts | 32 +++++++++++++++++++ 4 files changed, 59 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/commands/packages/pull.ts b/packages/cli/src/commands/packages/pull.ts index 7602f7c..3346e35 100644 --- a/packages/cli/src/commands/packages/pull.ts +++ b/packages/cli/src/commands/packages/pull.ts @@ -45,6 +45,7 @@ export async function handlePull(packageSpec: string, options: PullOptions = {}) logger.info(`\n=> Downloading to ${outputPath}...`); writeFileSync(outputPath, data); + await mpak.bundleCache.extractBundle(name, data, metadata); logger.info(`\n=> Bundle downloaded successfully!`); logger.info(` File: ${outputPath}`); diff --git a/packages/cli/tests/bundles/pull.test.ts b/packages/cli/tests/bundles/pull.test.ts index 6ebdf58..a2c7205 100644 --- a/packages/cli/tests/bundles/pull.test.ts +++ b/packages/cli/tests/bundles/pull.test.ts @@ -11,11 +11,13 @@ import { handlePull } from '../../src/commands/packages/pull.js'; vi.mock('fs', () => ({ writeFileSync: vi.fn() })); let mockDownloadBundle: ReturnType; +let mockExtractBundle: ReturnType; vi.mock('../../src/utils/config.js', () => ({ get mpak() { return { client: { downloadBundle: mockDownloadBundle } as unknown as MpakClient, + bundleCache: { extractBundle: mockExtractBundle }, }; }, })); @@ -55,6 +57,7 @@ describe('handlePull', () => { beforeEach(() => { vi.mocked(writeFileSync).mockClear(); mockDownloadBundle = vi.fn().mockResolvedValue({ data: bundleData, metadata }); + mockExtractBundle = vi.fn().mockResolvedValue(undefined); stdoutSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); stderrSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); }); @@ -140,4 +143,5 @@ describe('handlePull', () => { expect(stderrSpy).toHaveBeenCalledWith(expect.stringContaining('Bundle not found')); }); + }); diff --git a/packages/cli/tests/integration/bundle.integration.test.ts b/packages/cli/tests/integration/bundle.integration.test.ts index bc91375..d349460 100644 --- a/packages/cli/tests/integration/bundle.integration.test.ts +++ b/packages/cli/tests/integration/bundle.integration.test.ts @@ -1,5 +1,5 @@ -import { existsSync, rmSync } from 'node:fs'; -import { tmpdir } from 'node:os'; +import { existsSync, readFileSync, rmSync } from 'node:fs'; +import { homedir, tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; import { run } from './helpers.js'; @@ -34,6 +34,26 @@ describe('bundle pull', () => { expect(stderr).not.toContain('[Error]'); }, 30000); + it('populates the bundle cache after pull', async () => { + outputPath = join(tmpdir(), `mpak-test-cache-${Date.now()}.mcpb`); + const cacheMetaPath = join(homedir(), '.mpak', 'cache', 'nimblebraininc-echo', '.mpak-meta.json'); + + // Remove any existing cache entry so we get a clean result + const cacheDir = join(homedir(), '.mpak', 'cache', 'nimblebraininc-echo'); + if (existsSync(cacheDir)) rmSync(cacheDir, { recursive: true, force: true }); + + const { exitCode } = await run( + `bundle pull ${TEST_BUNDLE} --os linux --arch x64 --output ${outputPath}`, + ); + + expect(exitCode).toBe(0); + expect(existsSync(cacheMetaPath)).toBe(true); + const meta = JSON.parse(readFileSync(cacheMetaPath, 'utf8')); + expect(meta.version).toMatch(/^\d+\.\d+\.\d+/); + expect(meta.platform.os).toBe('linux'); + expect(meta.platform.arch).toBe('x64'); + }, 30000); + it('outputs valid JSON metadata with --json flag', async () => { outputPath = join(tmpdir(), `mpak-test-json-${Date.now()}.mcpb`); diff --git a/packages/sdk-typescript/src/cache.ts b/packages/sdk-typescript/src/cache.ts index b2dd0a2..3e081b8 100644 --- a/packages/sdk-typescript/src/cache.ts +++ b/packages/sdk-typescript/src/cache.ts @@ -275,6 +275,38 @@ export class MpakBundleCache { } } + /** + * Extract pre-downloaded bundle bytes into the cache. + * Use this when bytes are already in memory (e.g. after `bundle pull`) to + * avoid a second download. + */ + async extractBundle( + name: string, + data: Uint8Array, + bundle: DownloadInfo['bundle'], + ): Promise { + const cacheDir = this.getBundleCacheDirName(name); + const tempPath = join(tmpdir(), `mpak-${Date.now()}-${randomUUID().slice(0, 8)}.mcpb`); + + try { + writeFileSync(tempPath, data); + + if (existsSync(cacheDir)) { + rmSync(cacheDir, { recursive: true, force: true }); + } + + await extractZip(tempPath, cacheDir, this.extractOptions()); + + this.writeCacheMetadata(name, { + version: bundle.version, + pulledAt: new Date().toISOString(), + platform: bundle.platform, + }); + } finally { + rmSync(tempPath, { force: true }); + } + } + // =========================================================================== // Private methods // =========================================================================== From ca994bc8bbb8798fbd65210cb2066b5b045b3d2c Mon Sep 17 00:00:00 2001 From: shwetank-dev Date: Sun, 10 May 2026 15:47:02 -0400 Subject: [PATCH 3/6] fix(sdk): surface check failures in checkForUpdate instead of swallowing them (#79) Previously checkForUpdate returned string | null, making a network error or registry failure indistinguishable from "bundle is up to date". Both returned null, so getOutdatedBundles would silently report all bundles as current even when checks were failing. Replace the return type with a discriminated union (UpdateCheckResult) with three variants: up-to-date, update-available, and check-failed. The check-failed variant carries the failure reason so callers can warn the user rather than silently swallowing the error. Update getOutdatedBundles in outdated.ts to emit a stderr warning on check-failed instead of treating it as up-to-date. Update all affected tests and export the new type from the SDK index. Co-Authored-By: Claude Sonnet 4.6 --- .../cli/src/commands/packages/outdated.ts | 24 ++++------ packages/cli/tests/bundles/outdated.test.ts | 8 +++- packages/cli/tests/bundles/update.test.ts | 30 ++++++++++-- packages/sdk-typescript/src/cache.ts | 48 ++++++++++++------- packages/sdk-typescript/src/index.ts | 2 +- packages/sdk-typescript/tests/cache.test.ts | 30 ++++++------ 6 files changed, 91 insertions(+), 51 deletions(-) diff --git a/packages/cli/src/commands/packages/outdated.ts b/packages/cli/src/commands/packages/outdated.ts index 3d5c044..f7b1be9 100644 --- a/packages/cli/src/commands/packages/outdated.ts +++ b/packages/cli/src/commands/packages/outdated.ts @@ -24,20 +24,16 @@ export async function getOutdatedBundles(): Promise { await Promise.all( cached.map(async (bundle) => { - try { - const latest = await mpak.bundleCache.checkForUpdate(bundle.name, { force: true }); - if (latest) { - results.push({ - name: bundle.name, - current: bundle.version, - latest, - pulledAt: bundle.pulledAt, - }); - } - } catch { - process.stderr.write( - `=> Warning: could not check ${bundle.name} (may have been removed from registry)\n`, - ); + const result = await mpak.bundleCache.checkForUpdate(bundle.name, { force: true }); + if (result.status === 'update-available') { + results.push({ + name: bundle.name, + current: bundle.version, + latest: result.latestVersion, + pulledAt: bundle.pulledAt, + }); + } else if (result.status === 'check-failed') { + process.stderr.write(`=> Warning: could not check ${bundle.name}: ${result.reason}\n`); } }), ); diff --git a/packages/cli/tests/bundles/outdated.test.ts b/packages/cli/tests/bundles/outdated.test.ts index 6592954..5f7c24e 100644 --- a/packages/cli/tests/bundles/outdated.test.ts +++ b/packages/cli/tests/bundles/outdated.test.ts @@ -145,7 +145,7 @@ describe('getOutdatedBundles', () => { expect(result[1]!.name).toBe('@scope/zebra'); }); - it('skips bundles that fail to resolve from registry', async () => { + it('warns and skips bundles that fail to resolve from registry', async () => { seedCacheEntry(testDir, 'scope-exists', { manifest: validManifest('@scope/exists', '1.0.0'), metadata: validMetadata('1.0.0'), @@ -159,9 +159,15 @@ describe('getOutdatedBundles', () => { { mpakHome: testDir }, ); + const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + const result = await getOutdatedBundles(); + expect(result).toHaveLength(1); expect(result[0]!.name).toBe('@scope/exists'); + expect(stderrSpy).toHaveBeenCalledWith( + expect.stringContaining('could not check @scope/deleted'), + ); }); it('ignores TTL and always checks the registry', async () => { diff --git a/packages/cli/tests/bundles/update.test.ts b/packages/cli/tests/bundles/update.test.ts index 3b48414..41abf86 100644 --- a/packages/cli/tests/bundles/update.test.ts +++ b/packages/cli/tests/bundles/update.test.ts @@ -30,6 +30,7 @@ let stdout: string; let stderr: string; beforeEach(() => { + vi.clearAllMocks(); stdout = ''; stderr = ''; vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { @@ -138,7 +139,9 @@ describe('handleUpdate — bulk update', () => { }, ]); mockCheckForUpdate.mockImplementation(async (name: string) => { - return name === '@scope/a' ? '2.0.0' : '3.0.0'; + return name === '@scope/a' + ? { status: 'update-available', latestVersion: '2.0.0' } + : { status: 'update-available', latestVersion: '3.0.0' }; }); mockLoadBundle.mockImplementation(async (name: string) => { const versions: Record = { '@scope/a': '2.0.0', '@scope/b': '3.0.0' }; @@ -168,7 +171,7 @@ describe('handleUpdate — bulk update', () => { cacheDir: '/cache/bad', }, ]); - mockCheckForUpdate.mockImplementation(async () => '2.0.0'); + mockCheckForUpdate.mockResolvedValue({ status: 'update-available', latestVersion: '2.0.0' }); mockLoadBundle.mockImplementation(async (name: string) => { if (name === '@scope/bad') throw new MpakNotFoundError('@scope/bad@latest'); return { cacheDir: '/cache/good', version: '2.0.0', pulled: true }; @@ -189,7 +192,7 @@ describe('handleUpdate — bulk update', () => { cacheDir: '/cache/a', }, ]); - mockCheckForUpdate.mockResolvedValue('2.0.0'); + mockCheckForUpdate.mockResolvedValue({ status: 'update-available', latestVersion: '2.0.0' }); mockLoadBundle.mockRejectedValue(new MpakNetworkError('timeout')); await expect(handleUpdate(undefined)).rejects.toThrow('process.exit called'); @@ -198,6 +201,25 @@ describe('handleUpdate — bulk update', () => { expect(stderr).toContain('All updates failed'); }); + it('warns and skips bundles whose update check fails', async () => { + mockListCachedBundles.mockReturnValue([ + { + name: '@scope/a', + version: '1.0.0', + pulledAt: '2025-01-01T00:00:00.000Z', + cacheDir: '/cache/a', + }, + ]); + mockCheckForUpdate.mockResolvedValue({ status: 'check-failed', reason: 'timeout' }); + + const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + + await handleUpdate(undefined); + + expect(stderrSpy).toHaveBeenCalledWith(expect.stringContaining('could not check @scope/a')); + expect(mockLoadBundle).not.toHaveBeenCalled(); + }); + it('outputs JSON for bulk update with --json', async () => { mockListCachedBundles.mockReturnValue([ { @@ -207,7 +229,7 @@ describe('handleUpdate — bulk update', () => { cacheDir: '/cache/a', }, ]); - mockCheckForUpdate.mockResolvedValue('2.0.0'); + mockCheckForUpdate.mockResolvedValue({ status: 'update-available', latestVersion: '2.0.0' }); mockLoadBundle.mockResolvedValue({ cacheDir: '/cache/a', version: '2.0.0', pulled: true }); await handleUpdate(undefined, { json: true }); diff --git a/packages/sdk-typescript/src/cache.ts b/packages/sdk-typescript/src/cache.ts index 3e081b8..3d332bb 100644 --- a/packages/sdk-typescript/src/cache.ts +++ b/packages/sdk-typescript/src/cache.ts @@ -13,6 +13,11 @@ import { MpakClient } from './client.js'; import { MpakCacheCorruptedError } from './errors.js'; import { extractZip, isSemverEqual, readJsonFromFile, UPDATE_CHECK_TTL_MS } from './helpers.js'; +export type UpdateCheckResult = + | { status: 'up-to-date' } + | { status: 'update-available'; latestVersion: string } + | { status: 'check-failed'; reason: string }; + export interface MpakBundleCacheOptions { mpakHome?: string; /** @@ -241,22 +246,28 @@ export class MpakBundleCache { } /** - * Fire-and-forget background check for bundle updates. - * Return the latest version string if an update is available, null otherwise (not cached, skipped, up-to-date, or error). - * The caller can just check `if (result) { console.log("update available: " + result) }` + * Check whether a newer version of a cached bundle is available in the registry. + * + * Returns a discriminated union so callers can distinguish "up to date", + * "update available", and "check failed" — unlike a `string | null` return + * where `null` is ambiguous between "up to date" and "network error". + * * @param packageName - Scoped package name (e.g. `@scope/bundle`) */ - async checkForUpdate(packageName: string, options?: { force?: boolean }): Promise { - const cachedMeta = this.getBundleMetadata(packageName); - if (!cachedMeta) return null; - - // Skip if checked within the TTL (unless force is set) - if (!options?.force && cachedMeta.lastCheckedAt) { - const elapsed = Date.now() - new Date(cachedMeta.lastCheckedAt).getTime(); - if (elapsed < UPDATE_CHECK_TTL_MS) return null; - } - + async checkForUpdate( + packageName: string, + options?: { force?: boolean }, + ): Promise { try { + const cachedMeta = this.getBundleMetadata(packageName); + if (!cachedMeta) return { status: 'up-to-date' }; + + // Skip if checked within the TTL (unless force is set) + if (!options?.force && cachedMeta.lastCheckedAt) { + const elapsed = Date.now() - new Date(cachedMeta.lastCheckedAt).getTime(); + if (elapsed < UPDATE_CHECK_TTL_MS) return { status: 'up-to-date' }; + } + const detail = await this.mpakClient.getBundle(packageName); // Update lastCheckedAt regardless of whether there's an update @@ -266,12 +277,15 @@ export class MpakBundleCache { }); if (!isSemverEqual(detail.latest_version, cachedMeta.version)) { - return detail.latest_version; + return { status: 'update-available', latestVersion: detail.latest_version }; } - return null; - } catch { - return null; + return { status: 'up-to-date' }; + } catch (err) { + return { + status: 'check-failed', + reason: err instanceof Error ? err.message : String(err), + }; } } diff --git a/packages/sdk-typescript/src/index.ts b/packages/sdk-typescript/src/index.ts index 6afdb1e..e3ae36d 100644 --- a/packages/sdk-typescript/src/index.ts +++ b/packages/sdk-typescript/src/index.ts @@ -30,7 +30,7 @@ export type { export { MpakConfigManager } from './config-manager.js'; export type { MpakConfigManagerOptions, PackageConfig } from './config-manager.js'; export { MpakBundleCache } from './cache.js'; -export type { MpakBundleCacheOptions } from './cache.js'; +export type { MpakBundleCacheOptions, UpdateCheckResult } from './cache.js'; export { MpakClient } from './client.js'; export type { MpakClientConfig, ServerSearchParams } from './types.js'; diff --git a/packages/sdk-typescript/tests/cache.test.ts b/packages/sdk-typescript/tests/cache.test.ts index ee71879..f71e1b1 100644 --- a/packages/sdk-typescript/tests/cache.test.ts +++ b/packages/sdk-typescript/tests/cache.test.ts @@ -300,12 +300,12 @@ describe('MpakBundleCache', () => { // ------------------------------------------------------------------------- describe('checkForUpdate', () => { - it('returns null when bundle is not cached', async () => { + it('returns up-to-date when bundle is not cached', async () => { const cache = new MpakBundleCache(mockClient(), { mpakHome: testDir }); - expect(await cache.checkForUpdate('@scope/name')).toBeNull(); + expect((await cache.checkForUpdate('@scope/name')).status).toBe('up-to-date'); }); - it('returns latest version when update is available', async () => { + it('returns update-available with latest version when update exists', async () => { const client = mockClient({ getBundle: vi.fn().mockResolvedValue({ latest_version: '2.0.0' }), }); @@ -315,10 +315,11 @@ describe('MpakBundleCache', () => { metadata: validMetadata, }); - expect(await cache.checkForUpdate('@scope/name')).toBe('2.0.0'); + const result = await cache.checkForUpdate('@scope/name'); + expect(result).toEqual({ status: 'update-available', latestVersion: '2.0.0' }); }); - it('returns null when already up to date', async () => { + it('returns up-to-date when already on latest version', async () => { const client = mockClient({ getBundle: vi.fn().mockResolvedValue({ latest_version: '1.0.0' }), }); @@ -328,10 +329,10 @@ describe('MpakBundleCache', () => { metadata: validMetadata, }); - expect(await cache.checkForUpdate('@scope/name')).toBeNull(); + expect((await cache.checkForUpdate('@scope/name')).status).toBe('up-to-date'); }); - it('returns null when within TTL window', async () => { + it('returns up-to-date within TTL window without calling registry', async () => { const client = mockClient({ getBundle: vi.fn(), }); @@ -344,12 +345,11 @@ describe('MpakBundleCache', () => { }, }); - expect(await cache.checkForUpdate('@scope/name')).toBeNull(); - // Should not have called the API + expect((await cache.checkForUpdate('@scope/name')).status).toBe('up-to-date'); expect(client.getBundle).not.toHaveBeenCalled(); }); - it('returns null on network error', async () => { + it('returns check-failed with reason on network error', async () => { const client = mockClient({ getBundle: vi.fn().mockRejectedValue(new Error('network down')), }); @@ -359,7 +359,8 @@ describe('MpakBundleCache', () => { metadata: validMetadata, }); - expect(await cache.checkForUpdate('@scope/name')).toBeNull(); + const result = await cache.checkForUpdate('@scope/name'); + expect(result).toEqual({ status: 'check-failed', reason: 'network down' }); }); it('bypasses TTL when force is true', async () => { @@ -375,11 +376,12 @@ describe('MpakBundleCache', () => { }, }); - expect(await cache.checkForUpdate('@scope/name', { force: true })).toBe('2.0.0'); + const result = await cache.checkForUpdate('@scope/name', { force: true }); + expect(result).toEqual({ status: 'update-available', latestVersion: '2.0.0' }); expect(client.getBundle).toHaveBeenCalledWith('@scope/name'); }); - it('returns null when force is true but already up to date', async () => { + it('returns up-to-date when force is true but already on latest version', async () => { const client = mockClient({ getBundle: vi.fn().mockResolvedValue({ latest_version: '1.0.0' }), }); @@ -392,7 +394,7 @@ describe('MpakBundleCache', () => { }, }); - expect(await cache.checkForUpdate('@scope/name', { force: true })).toBeNull(); + expect((await cache.checkForUpdate('@scope/name', { force: true })).status).toBe('up-to-date'); }); it('updates lastCheckedAt after successful check', async () => { From 6aad77ca9e3063d91c83123790d03fb50b88b642 Mon Sep 17 00:00:00 2001 From: shwetank-dev Date: Sun, 10 May 2026 19:55:27 -0400 Subject: [PATCH 4/6] fix(sdk,cli): evict stale local bundle cache entries and add cache info (#95) Local bundles cached under _local/ were keyed by absolute path, so rebuilding a bundle with a new versioned filename (v0.1.0 -> v0.1.1) produced a new cache entry each time without ever evicting the old one. Fix: after preparing a local bundle, scan _local/ for other entries sharing the same manifest.name and remove them. One entry per bundle, always. Also adds getCacheInfo() to MpakBundleCache, which returns registry and local entries with per-entry disk sizes and a total. Surfaces this via `mpak cache info` (--json supported). New helpers: - MpakBundleCache.evictOtherLocalBundles(bundleName, currentHash) - MpakBundleCache.getCacheInfo() -> CacheInfo - dirSizeBytes(dir) in helpers.ts Co-Authored-By: Claude Sonnet 4.6 --- packages/cli/src/commands/cache/index.ts | 1 + packages/cli/src/commands/cache/info.ts | 55 +++++++ packages/cli/src/program.ts | 15 ++ packages/cli/tests/cache/info.test.ts | 151 ++++++++++++++++++++ packages/sdk-typescript/src/cache.ts | 96 ++++++++++++- packages/sdk-typescript/src/helpers.ts | 19 +++ packages/sdk-typescript/src/index.ts | 2 +- packages/sdk-typescript/src/mpakSDK.ts | 2 + packages/sdk-typescript/tests/cache.test.ts | 79 ++++++++++ packages/sdk-typescript/tests/mpak.test.ts | 21 +++ 10 files changed, 438 insertions(+), 3 deletions(-) create mode 100644 packages/cli/src/commands/cache/index.ts create mode 100644 packages/cli/src/commands/cache/info.ts create mode 100644 packages/cli/tests/cache/info.test.ts diff --git a/packages/cli/src/commands/cache/index.ts b/packages/cli/src/commands/cache/index.ts new file mode 100644 index 0000000..54c8f0e --- /dev/null +++ b/packages/cli/src/commands/cache/index.ts @@ -0,0 +1 @@ +export { handleCacheInfo } from './info.js'; diff --git a/packages/cli/src/commands/cache/info.ts b/packages/cli/src/commands/cache/info.ts new file mode 100644 index 0000000..fe763a0 --- /dev/null +++ b/packages/cli/src/commands/cache/info.ts @@ -0,0 +1,55 @@ +import { mpak } from '../../utils/config.js'; +import { formatSize, logger, table } from '../../utils/format.js'; + +export interface CacheInfoOptions { + json?: boolean; +} + +export async function handleCacheInfo(options: CacheInfoOptions = {}): Promise { + const info = mpak.bundleCache.getCacheInfo(); + + if (options.json) { + console.log(JSON.stringify(info, null, 2)); + return; + } + + if (info.registryBundles.length === 0 && info.localBundles.length === 0) { + logger.info('Cache is empty.'); + return; + } + + if (info.registryBundles.length > 0) { + logger.info('Registry bundles:\n'); + logger.info( + table( + ['Bundle', 'Version', 'Pulled', 'Size'], + info.registryBundles.map((b) => [ + b.name, + b.version, + b.pulledAt.slice(0, 10), + formatSize(b.bytes), + ]), + { rightAlign: [3] }, + ), + ); + logger.info(''); + } + + if (info.localBundles.length > 0) { + logger.info('Local bundles:\n'); + logger.info( + table( + ['Path', 'Extracted', 'Size'], + info.localBundles.map((b) => [ + b.localPath, + b.extractedAt.slice(0, 10), + formatSize(b.bytes), + ]), + { rightAlign: [2] }, + ), + ); + logger.info(''); + } + + logger.info(`Total: ${formatSize(info.totalBytes)}`); +} diff --git a/packages/cli/src/program.ts b/packages/cli/src/program.ts index 1b3661e..ba8ab8b 100644 --- a/packages/cli/src/program.ts +++ b/packages/cli/src/program.ts @@ -23,6 +23,7 @@ import { handleSkillInstall, handleSkillList, } from "./commands/skills/index.js"; +import { handleCacheInfo } from "./commands/cache/index.js"; /** * Creates and configures the CLI program @@ -264,6 +265,20 @@ export function createProgram(): Command { await handleConfigClear(packageName, key); }); + // ========================================================================== + // Cache commands + // ========================================================================== + + const cache = program.command("cache").description("Manage the local bundle cache"); + + cache + .command("info") + .description("Show cache contents and disk usage") + .option("--json", "Output as JSON") + .action(async (options) => { + await handleCacheInfo(options); + }); + // ========================================================================== // Shell completion // ========================================================================== diff --git a/packages/cli/tests/cache/info.test.ts b/packages/cli/tests/cache/info.test.ts new file mode 100644 index 0000000..175cdb7 --- /dev/null +++ b/packages/cli/tests/cache/info.test.ts @@ -0,0 +1,151 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { MpakBundleCache, type MpakClient } from '@nimblebrain/mpak-sdk'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { handleCacheInfo } from '../../src/commands/cache/info.js'; + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +const validManifest = (name: string, version: string) => ({ + manifest_version: '0.3', + name, + version, + description: 'Test bundle', + server: { + type: 'node' as const, + entry_point: 'index.js', + mcp_config: { command: 'node', args: ['${__dirname}/index.js'] }, + }, +}); + +const validMetadata = (version: string) => ({ + version, + pulledAt: '2026-05-10T00:00:00.000Z', + platform: { os: 'darwin', arch: 'arm64' }, +}); + +function seedRegistryEntry( + mpakHome: string, + dirName: string, + opts: { manifest?: object; metadata?: object }, +) { + const dir = join(mpakHome, 'cache', dirName); + mkdirSync(dir, { recursive: true }); + if (opts.manifest) writeFileSync(join(dir, 'manifest.json'), JSON.stringify(opts.manifest)); + if (opts.metadata) writeFileSync(join(dir, '.mpak-meta.json'), JSON.stringify(opts.metadata)); + writeFileSync(join(dir, 'index.js'), 'x'.repeat(1024)); +} + +function seedLocalEntry(mpakHome: string, hash: string, localPath: string) { + const dir = join(mpakHome, 'cache', '_local', hash); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, '.mpak-local-meta.json'), + JSON.stringify({ localPath, extractedAt: '2026-05-10T00:00:00.000Z' }), + ); + writeFileSync(join(dir, 'index.js'), 'x'.repeat(512)); +} + +function mockClient(): MpakClient { + return {} as unknown as MpakClient; +} + +// --------------------------------------------------------------------------- +// Mock the mpak singleton +// --------------------------------------------------------------------------- + +let currentCache: MpakBundleCache; + +vi.mock('../../src/utils/config.js', () => ({ + get mpak() { + return { bundleCache: currentCache }; + }, +})); + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('handleCacheInfo', () => { + let testDir: string; + + beforeEach(() => { + testDir = mkdtempSync(join(tmpdir(), 'mpak-cache-info-test-')); + }); + + afterEach(() => { + vi.restoreAllMocks(); + rmSync(testDir, { recursive: true, force: true }); + }); + + it('prints "Cache is empty" when nothing is cached', async () => { + currentCache = new MpakBundleCache(mockClient(), { mpakHome: testDir }); + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + await handleCacheInfo(); + + expect(spy).toHaveBeenCalledWith(expect.stringContaining('Cache is empty')); + }); + + it('lists registry bundles with name, version, and size', async () => { + seedRegistryEntry(testDir, 'scope-foo', { + manifest: validManifest('@scope/foo', '1.2.0'), + metadata: validMetadata('1.2.0'), + }); + currentCache = new MpakBundleCache(mockClient(), { mpakHome: testDir }); + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + await handleCacheInfo(); + + const output = spy.mock.calls.map((c) => c[0]).join(''); + expect(output).toContain('@scope/foo'); + expect(output).toContain('1.2.0'); + expect(output).toContain('2026-05-10'); + }); + + it('lists local bundles with path and size', async () => { + seedLocalEntry(testDir, 'abc123', '/project/dist/mcp-foo-v0.1.1.mcpb'); + currentCache = new MpakBundleCache(mockClient(), { mpakHome: testDir }); + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + await handleCacheInfo(); + + const output = spy.mock.calls.map((c) => c[0]).join(''); + expect(output).toContain('/project/dist/mcp-foo-v0.1.1.mcpb'); + expect(output).toContain('2026-05-10'); + }); + + it('prints total size', async () => { + seedRegistryEntry(testDir, 'scope-foo', { + manifest: validManifest('@scope/foo', '1.0.0'), + metadata: validMetadata('1.0.0'), + }); + seedLocalEntry(testDir, 'abc123', '/project/dist/mcp-foo.mcpb'); + currentCache = new MpakBundleCache(mockClient(), { mpakHome: testDir }); + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + await handleCacheInfo(); + + const output = spy.mock.calls.map((c) => c[0]).join(''); + expect(output).toContain('Total:'); + }); + + it('outputs JSON when --json is set', async () => { + seedRegistryEntry(testDir, 'scope-foo', { + manifest: validManifest('@scope/foo', '2.0.0'), + metadata: validMetadata('2.0.0'), + }); + currentCache = new MpakBundleCache(mockClient(), { mpakHome: testDir }); + const spy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + await handleCacheInfo({ json: true }); + + const raw = JSON.parse(spy.mock.calls[0]![0] as string); + expect(raw.registryBundles[0].name).toBe('@scope/foo'); + expect(raw.registryBundles[0].bytes).toBeGreaterThan(0); + expect(raw.totalBytes).toBeGreaterThan(0); + }); +}); diff --git a/packages/sdk-typescript/src/cache.ts b/packages/sdk-typescript/src/cache.ts index 3d332bb..1f4d0bd 100644 --- a/packages/sdk-typescript/src/cache.ts +++ b/packages/sdk-typescript/src/cache.ts @@ -1,5 +1,5 @@ import { randomUUID } from 'node:crypto'; -import { existsSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; +import { existsSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { homedir, tmpdir } from 'node:os'; import { join } from 'node:path'; import type { @@ -11,13 +11,33 @@ import type { import { CacheMetadataSchema, McpbManifestSchema } from '@nimblebrain/mpak-schemas'; import { MpakClient } from './client.js'; import { MpakCacheCorruptedError } from './errors.js'; -import { extractZip, isSemverEqual, readJsonFromFile, UPDATE_CHECK_TTL_MS } from './helpers.js'; +import { dirSizeBytes, extractZip, isSemverEqual, readJsonFromFile, UPDATE_CHECK_TTL_MS } from './helpers.js'; export type UpdateCheckResult = | { status: 'up-to-date' } | { status: 'update-available'; latestVersion: string } | { status: 'check-failed'; reason: string }; +export interface RegistryCacheEntry { + name: string; + version: string; + pulledAt: string; + bytes: number; +} + +export interface LocalCacheEntry { + hash: string; + localPath: string; + extractedAt: string; + bytes: number; +} + +export interface CacheInfo { + registryBundles: RegistryCacheEntry[]; + localBundles: LocalCacheEntry[]; + totalBytes: number; +} + export interface MpakBundleCacheOptions { mpakHome?: string; /** @@ -156,6 +176,78 @@ export class MpakBundleCache { return bundles; } + /** + * Evict all `_local/` entries for the same bundle name except the current one. + * Called after a local bundle is prepared so stale entries from previous path-keyed + * extractions (e.g. v0.1.0 → v0.1.1 renames) don't accumulate on disk. + */ + evictOtherLocalBundles(bundleName: string, currentHash: string): void { + const localDir = join(this.cacheHome, '_local'); + if (!existsSync(localDir)) return; + + for (const entry of readdirSync(localDir, { withFileTypes: true })) { + if (!entry.isDirectory() || entry.name === currentHash) continue; + try { + const manifest = readJsonFromFile( + join(localDir, entry.name, 'manifest.json'), + McpbManifestSchema, + ); + if (manifest.name === bundleName) { + rmSync(join(localDir, entry.name), { recursive: true, force: true }); + } + } catch { + // corrupt or missing manifest — skip + } + } + } + + /** + * Return a snapshot of everything in the cache: registry bundles, local bundles, + * and their disk usage. Skips entries with missing or corrupt metadata. + */ + getCacheInfo(): CacheInfo { + const registryBundles: RegistryCacheEntry[] = []; + const localBundles: LocalCacheEntry[] = []; + + for (const bundle of this.listCachedBundles()) { + registryBundles.push({ + name: bundle.name, + version: bundle.version, + pulledAt: bundle.pulledAt, + bytes: dirSizeBytes(bundle.cacheDir), + }); + } + + const localDir = join(this.cacheHome, '_local'); + if (existsSync(localDir)) { + for (const entry of readdirSync(localDir, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const entryDir = join(localDir, entry.name); + try { + const raw = JSON.parse(readFileSync(join(entryDir, '.mpak-local-meta.json'), 'utf8')) as { + localPath?: string; + extractedAt?: string; + }; + if (!raw.localPath || !raw.extractedAt) continue; + localBundles.push({ + hash: entry.name, + localPath: raw.localPath, + extractedAt: raw.extractedAt, + bytes: dirSizeBytes(entryDir), + }); + } catch { + // corrupt or missing meta — skip + } + } + } + + const totalBytes = + registryBundles.reduce((s, b) => s + b.bytes, 0) + + localBundles.reduce((s, b) => s + b.bytes, 0); + + return { registryBundles, localBundles, totalBytes }; + } + /** * Remove a cached bundle from disk. * @returns `true` if the bundle was cached and removed, `false` if it wasn't cached. diff --git a/packages/sdk-typescript/src/helpers.ts b/packages/sdk-typescript/src/helpers.ts index eb63cba..7bb6796 100644 --- a/packages/sdk-typescript/src/helpers.ts +++ b/packages/sdk-typescript/src/helpers.ts @@ -4,6 +4,7 @@ import { createWriteStream, existsSync, mkdirSync, + readdirSync, readFileSync, statSync, } from 'node:fs'; @@ -333,3 +334,21 @@ export function readJsonFromFile(filePath: string, schem return result.data; } + +/** + * Recursively sum the byte size of all files under `dir`. + * Returns 0 if the directory does not exist. + */ +export function dirSizeBytes(dir: string): number { + if (!existsSync(dir)) return 0; + let total = 0; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const entryPath = join(dir, entry.name); + if (entry.isDirectory()) { + total += dirSizeBytes(entryPath); + } else if (entry.isFile()) { + total += statSync(entryPath).size; + } + } + return total; +} diff --git a/packages/sdk-typescript/src/index.ts b/packages/sdk-typescript/src/index.ts index e3ae36d..03e6bf0 100644 --- a/packages/sdk-typescript/src/index.ts +++ b/packages/sdk-typescript/src/index.ts @@ -30,7 +30,7 @@ export type { export { MpakConfigManager } from './config-manager.js'; export type { MpakConfigManagerOptions, PackageConfig } from './config-manager.js'; export { MpakBundleCache } from './cache.js'; -export type { MpakBundleCacheOptions, UpdateCheckResult } from './cache.js'; +export type { CacheInfo, LocalCacheEntry, MpakBundleCacheOptions, RegistryCacheEntry, UpdateCheckResult } from './cache.js'; export { MpakClient } from './client.js'; export type { MpakClientConfig, ServerSearchParams } from './types.js'; diff --git a/packages/sdk-typescript/src/mpakSDK.ts b/packages/sdk-typescript/src/mpakSDK.ts index 661a58c..4f7c304 100644 --- a/packages/sdk-typescript/src/mpakSDK.ts +++ b/packages/sdk-typescript/src/mpakSDK.ts @@ -293,6 +293,8 @@ export class Mpak { ); } + this.bundleCache.evictOtherLocalBundles(manifest.name, hash); + return { cacheDir, name: manifest.name, version: manifest.version, manifest }; } diff --git a/packages/sdk-typescript/tests/cache.test.ts b/packages/sdk-typescript/tests/cache.test.ts index f71e1b1..8478bf7 100644 --- a/packages/sdk-typescript/tests/cache.test.ts +++ b/packages/sdk-typescript/tests/cache.test.ts @@ -483,4 +483,83 @@ describe('MpakBundleCache', () => { expect(client.getBundleDownload).toHaveBeenCalled(); }); }); + + // ------------------------------------------------------------------------- + // getCacheInfo + // ------------------------------------------------------------------------- + + describe('getCacheInfo', () => { + it('returns empty lists when cache does not exist', () => { + const cache = new MpakBundleCache(mockClient(), { mpakHome: testDir }); + const info = cache.getCacheInfo(); + expect(info.registryBundles).toEqual([]); + expect(info.localBundles).toEqual([]); + expect(info.totalBytes).toBe(0); + }); + + it('reports registry bundles with size', () => { + const cache = new MpakBundleCache(mockClient(), { mpakHome: testDir }); + const dir = seedCacheEntry(testDir, 'scope-name', { + manifest: validManifest, + metadata: validMetadata, + }); + writeFileSync(join(dir, 'index.js'), 'x'.repeat(100)); + + const info = cache.getCacheInfo(); + + expect(info.registryBundles).toHaveLength(1); + expect(info.registryBundles[0].name).toBe('@scope/name'); + expect(info.registryBundles[0].version).toBe('1.0.0'); + expect(info.registryBundles[0].bytes).toBeGreaterThan(0); + }); + + it('reports local bundles with size', () => { + const cache = new MpakBundleCache(mockClient(), { mpakHome: testDir }); + const localDir = join(testDir, 'cache', '_local', 'abc123'); + mkdirSync(localDir, { recursive: true }); + writeFileSync( + join(localDir, '.mpak-local-meta.json'), + JSON.stringify({ localPath: '/some/bundle.mcpb', extractedAt: '2026-05-10T00:00:00.000Z' }), + ); + writeFileSync(join(localDir, 'index.js'), 'x'.repeat(200)); + + const info = cache.getCacheInfo(); + + expect(info.localBundles).toHaveLength(1); + expect(info.localBundles[0].hash).toBe('abc123'); + expect(info.localBundles[0].localPath).toBe('/some/bundle.mcpb'); + expect(info.localBundles[0].bytes).toBeGreaterThan(0); + }); + + it('totalBytes is the sum of all entries', () => { + const cache = new MpakBundleCache(mockClient(), { mpakHome: testDir }); + + const registryDir = seedCacheEntry(testDir, 'scope-name', { + manifest: validManifest, + metadata: validMetadata, + }); + writeFileSync(join(registryDir, 'data.bin'), Buffer.alloc(500)); + + const localDir = join(testDir, 'cache', '_local', 'def456'); + mkdirSync(localDir, { recursive: true }); + writeFileSync( + join(localDir, '.mpak-local-meta.json'), + JSON.stringify({ localPath: '/some/bundle.mcpb', extractedAt: '2026-05-10T00:00:00.000Z' }), + ); + writeFileSync(join(localDir, 'data.bin'), Buffer.alloc(300)); + + const info = cache.getCacheInfo(); + expect(info.totalBytes).toBe(info.registryBundles[0].bytes + info.localBundles[0].bytes); + }); + + it('skips local entries with missing or corrupt meta', () => { + const cache = new MpakBundleCache(mockClient(), { mpakHome: testDir }); + const localDir = join(testDir, 'cache', '_local', 'corrupt'); + mkdirSync(localDir, { recursive: true }); + writeFileSync(join(localDir, '.mpak-local-meta.json'), 'not json'); + + const info = cache.getCacheInfo(); + expect(info.localBundles).toHaveLength(0); + }); + }); }); diff --git a/packages/sdk-typescript/tests/mpak.test.ts b/packages/sdk-typescript/tests/mpak.test.ts index 0d2d71d..ad40ff2 100644 --- a/packages/sdk-typescript/tests/mpak.test.ts +++ b/packages/sdk-typescript/tests/mpak.test.ts @@ -996,6 +996,27 @@ describe('Mpak facade', () => { await expect(sdk.prepareServer({ local: mcpbPath })).rejects.toThrow(MpakConfigError); }); + + it('evicts stale _local entries for the same bundle name when path changes', async () => { + const sdk = new Mpak({ mpakHome: testDir }); + + const v1Dir = join(testDir, 'v1'); + const v2Dir = join(testDir, 'v2'); + mkdirSync(v1Dir); + mkdirSync(v2Dir); + + const v1Path = createMcpbBundle(v1Dir, nodeManifest); + const v2Path = createMcpbBundle(v2Dir, nodeManifest); + + const result1 = await sdk.prepareServer({ local: v1Path }); + expect(existsSync(result1.cwd)).toBe(true); + + const result2 = await sdk.prepareServer({ local: v2Path }); + expect(existsSync(result2.cwd)).toBe(true); + + // v1 entry should have been evicted since v2 has the same bundle name + expect(existsSync(result1.cwd)).toBe(false); + }); }); // =========================================================================== From ac3a99a0ea693b3b1d5dba2cae83482b1830dbab Mon Sep 17 00:00:00 2001 From: shwetank-dev Date: Sun, 10 May 2026 20:00:46 -0400 Subject: [PATCH 5/6] feat(cli): add mpak cache clear command (#95) Deletes the entire ~/.mpak/cache/ directory and reports how much disk space was freed. Prompts for confirmation before deleting; --force skips the prompt for scripted/non-interactive use. Co-Authored-By: Claude Sonnet 4.6 --- packages/cli/src/commands/cache/clear.ts | 49 ++++++++ packages/cli/src/commands/cache/index.ts | 1 + packages/cli/src/program.ts | 10 +- packages/cli/tests/cache/clear.test.ts | 147 +++++++++++++++++++++++ 4 files changed, 206 insertions(+), 1 deletion(-) create mode 100644 packages/cli/src/commands/cache/clear.ts create mode 100644 packages/cli/tests/cache/clear.test.ts diff --git a/packages/cli/src/commands/cache/clear.ts b/packages/cli/src/commands/cache/clear.ts new file mode 100644 index 0000000..673c2c8 --- /dev/null +++ b/packages/cli/src/commands/cache/clear.ts @@ -0,0 +1,49 @@ +import { createInterface } from 'node:readline'; +import { existsSync, rmSync } from 'node:fs'; +import { mpak } from '../../utils/config.js'; +import { formatSize, logger } from '../../utils/format.js'; + +export interface CacheClearOptions { + force?: boolean; +} + +async function confirmPrompt(question: string): Promise { + const rl = createInterface({ input: process.stdin, output: process.stderr }); + return new Promise((resolve) => { + rl.question(question, (answer) => { + rl.close(); + resolve(answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes'); + }); + }); +} + +export async function handleCacheClear( + options: CacheClearOptions = {}, + _confirm = confirmPrompt, +): Promise { + const info = mpak.bundleCache.getCacheInfo(); + const entryCount = info.registryBundles.length + info.localBundles.length; + + if (entryCount === 0) { + logger.info('Cache is already empty.'); + return; + } + + const sizeStr = formatSize(info.totalBytes); + const summary = `${entryCount} bundle(s), ${sizeStr}`; + + if (!options.force) { + const ok = await _confirm(`Clear the entire cache (${summary})? [y/N] `); + if (!ok) { + logger.info('Aborted.'); + return; + } + } + + const cacheHome = mpak.bundleCache.cacheHome; + if (existsSync(cacheHome)) { + rmSync(cacheHome, { recursive: true, force: true }); + } + + logger.info(`Cleared cache. Freed ${sizeStr}.`); +} diff --git a/packages/cli/src/commands/cache/index.ts b/packages/cli/src/commands/cache/index.ts index 54c8f0e..97ca400 100644 --- a/packages/cli/src/commands/cache/index.ts +++ b/packages/cli/src/commands/cache/index.ts @@ -1 +1,2 @@ export { handleCacheInfo } from './info.js'; +export { handleCacheClear } from './clear.js'; diff --git a/packages/cli/src/program.ts b/packages/cli/src/program.ts index ba8ab8b..5c17515 100644 --- a/packages/cli/src/program.ts +++ b/packages/cli/src/program.ts @@ -23,7 +23,7 @@ import { handleSkillInstall, handleSkillList, } from "./commands/skills/index.js"; -import { handleCacheInfo } from "./commands/cache/index.js"; +import { handleCacheInfo, handleCacheClear } from "./commands/cache/index.js"; /** * Creates and configures the CLI program @@ -279,6 +279,14 @@ export function createProgram(): Command { await handleCacheInfo(options); }); + cache + .command("clear") + .description("Delete all cached bundles and free disk space") + .option("--force", "Skip confirmation prompt") + .action(async (options) => { + await handleCacheClear(options); + }); + // ========================================================================== // Shell completion // ========================================================================== diff --git a/packages/cli/tests/cache/clear.test.ts b/packages/cli/tests/cache/clear.test.ts new file mode 100644 index 0000000..1a7f726 --- /dev/null +++ b/packages/cli/tests/cache/clear.test.ts @@ -0,0 +1,147 @@ +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { MpakBundleCache, type MpakClient } from '@nimblebrain/mpak-sdk'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { handleCacheClear } from '../../src/commands/cache/clear.js'; + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +const validManifest = (name: string, version: string) => ({ + manifest_version: '0.3', + name, + version, + description: 'Test bundle', + server: { + type: 'node' as const, + entry_point: 'index.js', + mcp_config: { command: 'node', args: ['${__dirname}/index.js'] }, + }, +}); + +const validMetadata = (version: string) => ({ + version, + pulledAt: '2026-05-10T00:00:00.000Z', + platform: { os: 'darwin', arch: 'arm64' }, +}); + +function seedRegistryEntry(mpakHome: string, dirName: string) { + const dir = join(mpakHome, 'cache', dirName); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, 'manifest.json'), JSON.stringify(validManifest('@scope/foo', '1.0.0'))); + writeFileSync(join(dir, '.mpak-meta.json'), JSON.stringify(validMetadata('1.0.0'))); + writeFileSync(join(dir, 'index.js'), 'x'.repeat(1024)); + return dir; +} + +function mockClient(): MpakClient { + return {} as unknown as MpakClient; +} + +// --------------------------------------------------------------------------- +// Mock the mpak singleton +// --------------------------------------------------------------------------- + +let currentCache: MpakBundleCache; + +vi.mock('../../src/utils/config.js', () => ({ + get mpak() { + return { bundleCache: currentCache }; + }, +})); + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('handleCacheClear', () => { + let testDir: string; + + beforeEach(() => { + testDir = mkdtempSync(join(tmpdir(), 'mpak-cache-clear-test-')); + }); + + afterEach(() => { + vi.restoreAllMocks(); + rmSync(testDir, { recursive: true, force: true }); + }); + + it('reports already empty when nothing is cached', async () => { + currentCache = new MpakBundleCache(mockClient(), { mpakHome: testDir }); + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + await handleCacheClear({}, vi.fn()); + + expect(spy).toHaveBeenCalledWith(expect.stringContaining('already empty')); + }); + + it('prompts for confirmation when --force is not set', async () => { + seedRegistryEntry(testDir, 'scope-foo'); + currentCache = new MpakBundleCache(mockClient(), { mpakHome: testDir }); + vi.spyOn(console, 'error').mockImplementation(() => {}); + + const confirm = vi.fn().mockResolvedValue(true); + await handleCacheClear({}, confirm); + + expect(confirm).toHaveBeenCalledOnce(); + expect(confirm).toHaveBeenCalledWith(expect.stringContaining('Clear the entire cache')); + }); + + it('aborts without deleting when user declines', async () => { + const cacheDir = seedRegistryEntry(testDir, 'scope-foo'); + currentCache = new MpakBundleCache(mockClient(), { mpakHome: testDir }); + vi.spyOn(console, 'error').mockImplementation(() => {}); + + await handleCacheClear({}, vi.fn().mockResolvedValue(false)); + + expect(existsSync(cacheDir)).toBe(true); + }); + + it('deletes the cache directory when confirmed', async () => { + seedRegistryEntry(testDir, 'scope-foo'); + currentCache = new MpakBundleCache(mockClient(), { mpakHome: testDir }); + vi.spyOn(console, 'error').mockImplementation(() => {}); + + await handleCacheClear({}, vi.fn().mockResolvedValue(true)); + + expect(existsSync(join(testDir, 'cache'))).toBe(false); + }); + + it('skips confirmation prompt when --force is set', async () => { + seedRegistryEntry(testDir, 'scope-foo'); + currentCache = new MpakBundleCache(mockClient(), { mpakHome: testDir }); + vi.spyOn(console, 'error').mockImplementation(() => {}); + + const confirm = vi.fn(); + await handleCacheClear({ force: true }, confirm); + + expect(confirm).not.toHaveBeenCalled(); + expect(existsSync(join(testDir, 'cache'))).toBe(false); + }); + + it('reports freed size after clearing', async () => { + seedRegistryEntry(testDir, 'scope-foo'); + currentCache = new MpakBundleCache(mockClient(), { mpakHome: testDir }); + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + await handleCacheClear({ force: true }, vi.fn()); + + const output = spy.mock.calls.map((c) => c[0]).join(''); + expect(output).toContain('Freed'); + }); + + it('includes entry count and size in the confirmation prompt', async () => { + seedRegistryEntry(testDir, 'scope-foo'); + currentCache = new MpakBundleCache(mockClient(), { mpakHome: testDir }); + vi.spyOn(console, 'error').mockImplementation(() => {}); + + const confirm = vi.fn().mockResolvedValue(false); + await handleCacheClear({}, confirm); + + const prompt = confirm.mock.calls[0]![0] as string; + expect(prompt).toContain('1 bundle(s)'); + expect(prompt).toMatch(/\d+(\.\d+)? (B|KB|MB)/); + }); +}); From f3826c2e812ff8289e539c9c331eba18e3406726 Mon Sep 17 00:00:00 2001 From: shwetank-dev Date: Sun, 10 May 2026 20:14:12 -0400 Subject: [PATCH 6/6] chore(sdk): fix prettier formatting in cache.ts, index.ts, cache.test.ts Co-Authored-By: Claude Sonnet 4.6 --- packages/sdk-typescript/src/cache.ts | 8 +++++++- packages/sdk-typescript/src/index.ts | 8 +++++++- packages/sdk-typescript/tests/cache.test.ts | 4 +++- 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/packages/sdk-typescript/src/cache.ts b/packages/sdk-typescript/src/cache.ts index 1f4d0bd..0048e03 100644 --- a/packages/sdk-typescript/src/cache.ts +++ b/packages/sdk-typescript/src/cache.ts @@ -11,7 +11,13 @@ import type { import { CacheMetadataSchema, McpbManifestSchema } from '@nimblebrain/mpak-schemas'; import { MpakClient } from './client.js'; import { MpakCacheCorruptedError } from './errors.js'; -import { dirSizeBytes, extractZip, isSemverEqual, readJsonFromFile, UPDATE_CHECK_TTL_MS } from './helpers.js'; +import { + dirSizeBytes, + extractZip, + isSemverEqual, + readJsonFromFile, + UPDATE_CHECK_TTL_MS, +} from './helpers.js'; export type UpdateCheckResult = | { status: 'up-to-date' } diff --git a/packages/sdk-typescript/src/index.ts b/packages/sdk-typescript/src/index.ts index 03e6bf0..1de4b81 100644 --- a/packages/sdk-typescript/src/index.ts +++ b/packages/sdk-typescript/src/index.ts @@ -30,7 +30,13 @@ export type { export { MpakConfigManager } from './config-manager.js'; export type { MpakConfigManagerOptions, PackageConfig } from './config-manager.js'; export { MpakBundleCache } from './cache.js'; -export type { CacheInfo, LocalCacheEntry, MpakBundleCacheOptions, RegistryCacheEntry, UpdateCheckResult } from './cache.js'; +export type { + CacheInfo, + LocalCacheEntry, + MpakBundleCacheOptions, + RegistryCacheEntry, + UpdateCheckResult, +} from './cache.js'; export { MpakClient } from './client.js'; export type { MpakClientConfig, ServerSearchParams } from './types.js'; diff --git a/packages/sdk-typescript/tests/cache.test.ts b/packages/sdk-typescript/tests/cache.test.ts index 8478bf7..c004038 100644 --- a/packages/sdk-typescript/tests/cache.test.ts +++ b/packages/sdk-typescript/tests/cache.test.ts @@ -394,7 +394,9 @@ describe('MpakBundleCache', () => { }, }); - expect((await cache.checkForUpdate('@scope/name', { force: true })).status).toBe('up-to-date'); + expect((await cache.checkForUpdate('@scope/name', { force: true })).status).toBe( + 'up-to-date', + ); }); it('updates lastCheckedAt after successful check', async () => {