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 new file mode 100644 index 0000000..97ca400 --- /dev/null +++ b/packages/cli/src/commands/cache/index.ts @@ -0,0 +1,2 @@ +export { handleCacheInfo } from './info.js'; +export { handleCacheClear } from './clear.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/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/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/src/program.ts b/packages/cli/src/program.ts index 1b3661e..5c17515 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, handleCacheClear } from "./commands/cache/index.js"; /** * Creates and configures the CLI program @@ -264,6 +265,28 @@ 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); + }); + + 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/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/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/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/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)/); + }); +}); 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/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 d15873e..0048e03 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,7 +11,38 @@ 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; @@ -151,6 +182,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. @@ -183,7 +286,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 +308,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 +323,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, @@ -232,22 +344,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 @@ -257,12 +375,47 @@ 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), + }; + } + } + + /** + * 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 }); } } 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 6afdb1e..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 { MpakBundleCacheOptions } 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 3726508..c004038 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 }); }); @@ -299,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' }), }); @@ -314,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' }), }); @@ -327,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(), }); @@ -343,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')), }); @@ -358,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 () => { @@ -374,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' }), }); @@ -391,7 +394,9 @@ 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 () => { @@ -410,4 +415,153 @@ 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(); + }); + }); + + // ------------------------------------------------------------------------- + // 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); + }); }); // ===========================================================================