()
| 436 | * Only runs once per day for Ant users. |
| 437 | */ |
| 438 | export async function cleanupNpmCacheForAnthropicPackages(): Promise<void> { |
| 439 | const markerPath = join(getClaudeConfigHomeDir(), '.npm-cache-cleanup') |
| 440 | |
| 441 | try { |
| 442 | const stat = await fs.stat(markerPath) |
| 443 | if (Date.now() - stat.mtimeMs < ONE_DAY_MS) { |
| 444 | logForDebugging('npm cache cleanup: skipping, ran recently') |
| 445 | return |
| 446 | } |
| 447 | } catch { |
| 448 | // File doesn't exist, proceed with cleanup |
| 449 | } |
| 450 | |
| 451 | try { |
| 452 | await lockfile.lock(markerPath, { retries: 0, realpath: false }) |
| 453 | } catch { |
| 454 | logForDebugging('npm cache cleanup: skipping, lock held') |
| 455 | return |
| 456 | } |
| 457 | |
| 458 | logForDebugging('npm cache cleanup: starting') |
| 459 | |
| 460 | const npmCachePath = join(homedir(), '.npm', '_cacache') |
| 461 | |
| 462 | const NPM_CACHE_RETENTION_COUNT = 5 |
| 463 | |
| 464 | const startTime = Date.now() |
| 465 | try { |
| 466 | const cacache = await import('cacache') |
| 467 | const cutoff = startTime - ONE_DAY_MS |
| 468 | |
| 469 | // Stream index entries and collect all Anthropic package entries. |
| 470 | // Previous implementation used cacache.verify() which does a full |
| 471 | // integrity check + GC of the ENTIRE cache — O(all content blobs). |
| 472 | // On large caches this took 60+ seconds and blocked the event loop. |
| 473 | const stream = cacache.ls.stream(npmCachePath) |
| 474 | const anthropicEntries: { key: string; time: number }[] = [] |
| 475 | for await (const entry of stream as AsyncIterable<{ |
| 476 | key: string |
| 477 | time: number |
| 478 | }>) { |
| 479 | if (entry.key.includes('@anthropic-ai/claude-')) { |
| 480 | anthropicEntries.push({ key: entry.key, time: entry.time }) |
| 481 | } |
| 482 | } |
| 483 | |
| 484 | // Group by package name (everything before the last @version separator) |
| 485 | const byPackage = new Map<string, { key: string; time: number }[]>() |
| 486 | for (const entry of anthropicEntries) { |
| 487 | const atVersionIdx = entry.key.lastIndexOf('@') |
| 488 | const pkgName = |
| 489 | atVersionIdx > 0 ? entry.key.slice(0, atVersionIdx) : entry.key |
| 490 | const existing = byPackage.get(pkgName) ?? [] |
| 491 | existing.push(entry) |
| 492 | byPackage.set(pkgName, existing) |
| 493 | } |
| 494 | |
| 495 | // Remove entries older than 1 day OR beyond the top N most recent per package |
no test coverage detected