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