fix(sdk,cli): evict stale local bundle cache entries, add cache info/clear commands (#60, #78, #79, #95)#113
Conversation
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 <noreply@anthropic.com>
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/<bundle>/.mpak-meta.json exists and contains the correct version after a pull. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ing them (NimbleBrainInc#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 <noreply@anthropic.com>
…fo (NimbleBrainInc#95) Local bundles cached under _local/<hash> 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
mgoldsborough
left a comment
There was a problem hiding this comment.
Bundling 4 issues into one PR works here, and the platform-guard fix (#78), eviction logic (#95), and cache info/clear commands look solid. Five blockers before merge — three are inline; the two below touch files that aren't in this diff, so I'm including them in this summary.
Blocker 1 — packages/cli/src/commands/packages/run.ts:152-164 not updated for the new checkForUpdate signature
#79 turned checkForUpdate from Promise<string | null> into Promise<UpdateCheckResult> (always an object). The call site in run.ts was not updated:
.then((latestVersion) => {
if (latestVersion) { // ALWAYS truthy now — object is always returned
process.stderr.write(
`\n=> Update available: ${server.name} ${server.version} -> ${latestVersion}\n` ...Production effect on every mpak run @scope/pkg:
- when up-to-date → spurious
=> Update available: pkg 1.0.0 -> [object Object]printed - when an update is available → message prints
[object Object]instead of the version - when the check fails → spurious
Update availableprinted - the
.catch(() => …)is dead —checkForUpdatecannot reject; failures now return{status:'check-failed'}
TypeScript didn't catch it because object truthiness and template-literal coercion are both valid.
Fix:
.then((result) => {
if (result.status === 'update-available') {
process.stderr.write(
`\n=> Update available: ${server.name} ${server.version} -> ${result.latestVersion}\n` +
` Run 'mpak run ${server.name} --update' to update\n`,
);
}
});Blocker 2 — packages/cli/tests/bundles/run.test.ts:319, 332, 342 hide blocker 1
These mocks still use the old string | null shape:
mockCheckForUpdate.mockResolvedValue('2.0.0'); // line 319
mockCheckForUpdate.mockResolvedValue(null); // line 332
mockCheckForUpdate.mockRejectedValue(new Error('network timeout')); // line 342The real checkForUpdate cannot return any of those values anymore — it returns UpdateCheckResult and never rejects. Update the mocks to the new shapes ({status:'update-available', latestVersion:'2.0.0'}, {status:'up-to-date'}, {status:'check-failed', reason:'network timeout'}). Once updated, the existing assertions in this block should fail until run.ts is fixed — which is the behavior we want.
Also worth fixing
- PR body says "SDK: 253 tests passing" — actual run is 252 passing, 1 pre-existing
validate.test.tsfailure that reproduces onmain. Reword like thempak-webtypecheck note: "252 passing, 1 pre-existing failure unrelated to this PR." - Optional polish:
checkForUpdatecollapses "not cached" and "TTL-throttled" into{status:'up-to-date'}. Consumers happen not to care, but the public SDK type is now lying. Consider askippedvariant or document the conflation. getCacheInforeads.mpak-local-meta.jsonwith rawJSON.parsewhile the rest of the cache usesreadJsonFromFile+ Zod. Worth aLocalMetaSchemafor consistency.
What looks good: platform check is symmetrical across both guards with tight unit tests; eviction runs on every prepare (not just on extract); eviction key is manifest.name, not parent dir; outdated cleanly switches on the union and surfaces stderr; new cache-info / cache-clear tests cover empty/prompt/abort/force/freed-size paths.
|
|
||
| // 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 }); |
There was a problem hiding this comment.
This writes to and deletes from the developer's real ~/.mpak/cache/. Anyone who runs pnpm test -- tests/integration locally loses their cached @nimblebraininc/echo bundle and ends up with a Linux/x64 artifact in the cache on whatever host they're on.
The integration suite is excluded from default pnpm test, so CI is fine — but local dev is a footgun.
Fix: extend tests/integration/helpers.ts so run() accepts an env map, then point this test at a tmp MPAK_HOME:
const mpakHome = mkdtempSync(join(tmpdir(), 'mpak-int-'));
const cacheMetaPath = join(mpakHome, 'cache', 'nimblebraininc-echo', '.mpak-meta.json');
await run(
`bundle pull ${TEST_BUNDLE} --os linux --arch x64 --output ${outputPath}`,
{ MPAK_HOME: mpakHome },
);| } finally { | ||
| rmSync(tempPath, { force: true }); | ||
| } | ||
| } |
There was a problem hiding this comment.
extractBundle (this function) and downloadAndExtract (lines 440-467) are now ~95% duplicates — same temp-path scheme, same rmSync of cacheDir, same extractZip, same writeCacheMetadata. The only delta is the download step.
This is the kind of duplication that drifts: the next change to one (preserving lastCheckedAt on re-extract, switching temp dirs, adding fsync, tightening collisions) will silently miss the other.
Suggested collapse:
private async downloadAndExtract(name: string, downloadInfo: DownloadInfo): Promise<void> {
const data = await this.mpakClient.downloadContent(downloadInfo.url, downloadInfo.bundle.sha256);
await this.extractBundle(name, data, downloadInfo.bundle);
}| beforeEach(() => { | ||
| vi.mocked(writeFileSync).mockClear(); | ||
| mockDownloadBundle = vi.fn().mockResolvedValue({ data: bundleData, metadata }); | ||
| mockExtractBundle = vi.fn().mockResolvedValue(undefined); |
There was a problem hiding this comment.
The mock is set up but no test asserts that extractBundle is actually invoked — the whole point of #60 is unverified at the unit level. The integration test covers it end-to-end, but a unit assertion catches a missed wire-up in a few ms instead of a 30s network test.
Add to the happy-path test:
expect(mockExtractBundle).toHaveBeenCalledWith('@scope/bundle', bundleData, metadata);While we're here: with --json set, pull.ts returns before calling extractBundle, so mpak pull --json still doesn't fix #60 for scripted callers. Worth either moving the extract above the JSON return, or adding a --json test that asserts the asymmetry so the choice is recorded.
Summary
This PR bundles four fixes and features from the QA backlog:
#78 — Check platform in
loadBundlecache guardsloadBundlewas returning a cached bundle even when the cached platform (os/arch) didn't match the current host. Added platform comparison to both the early-return and the registry short-circuit checks.#60 — Populate bundle cache after
bundle pullbundle pulldownloaded the bundle to disk but didn't extract it into the cache, so a subsequentbundle runwould re-download unnecessarily. Fixed by callingextractBundleafter the download inpull.ts.#79 — Surface
checkForUpdatefailures instead of swallowing themcheckForUpdatereturnednullon both "up to date" and "network error", making the two indistinguishable. Changed the return type to a discriminated union (up-to-date | update-available | check-failed) and wrapped the body in try/catch. Theoutdatedcommand now emits a stderr warning forcheck-failedentries instead of silently skipping them.#95 — Evict stale local bundle cache entries +
mpak cache info/clear(phase 1)Root cause: Local bundles are cached under
_local/<hash(absolutePath)>/. Rebuilding a bundle with a new versioned filename (mcp-foo-v0.1.0.mcpb→v0.1.1.mcpb) produces a new hash and a new cache entry — the old one is never evicted. One developer accumulated 2.1 GB from 33 stale local entries.Fix: After preparing a local bundle,
evictOtherLocalBundles(bundleName, currentHash)scans_local/for other entries sharing the samemanifest.nameand removes them. One entry per bundle, always. Eviction key ismanifest.name(not parent directory) to avoid false positives when multiple different bundles share a dist folder.New SDK surface:
MpakBundleCache.evictOtherLocalBundles(bundleName, currentHash)— called automatically on every local bundle prepareMpakBundleCache.getCacheInfo()→CacheInfo— returns registry and local entries with per-entry disk sizes and a totaldirSizeBytes(dir)helper inhelpers.tsCacheInfo,RegistryCacheEntry,LocalCacheEntryexported from public indexNew CLI commands:
mpak cache info— two-section table (registry / local) with sizes and pull/extract dates;--jsonsupportedmpak cache clear— deletes~/.mpak/cache/entirely; shows size to be freed, prompts for confirmation,--forceskips promptTest plan
pnpm --filter @nimblebrain/mpak-sdk test)pnpm --filter @nimblebrain/mpak test)_local/<hash>entrympak cache infoshows registry and local bundles with correct sizesmpak cache info --jsonoutputs valid JSON matchingCacheInfoshapempak cache clearprompts, reports freed space, deletes cache directorympak cache clear --forceskips promptmpak cache clearon empty cache prints "already empty" without error@nimblebrain/mpak-webtypecheck failure is unrelated to this PR (reproduced onmain)Closes #60, #78, #79, #95
🤖 Generated with Claude Code