fix(coding-agent): prune stale bun update cache

修复 omp update 更新后清理旧版 Bun 安装缓存。
This commit is contained in:
Zaun
2026-06-02 21:02:33 +08:00
parent b92aa30b24
commit c2cbc89c7a
3 changed files with 328 additions and 1 deletions
+247
View File
@@ -175,6 +175,245 @@ function compareVersions(a: string, b: string): number {
return 0;
}
interface BunInstallCachePruneResult {
scannedPackages: number;
removedEntries: number;
}
interface BunCachePackageGroup {
actualDirs: Map<string, string[]>;
markerDir?: string;
markerEntries: Map<string, string[]>;
}
function stripBunCacheVersionSuffix(name: string): string {
const suffixIndex = name.indexOf("@@@");
return suffixIndex === -1 ? name : name.slice(0, suffixIndex);
}
function compareSemverIdentifier(a: string, b: string): number {
const aNumber = /^\d+$/.test(a);
const bNumber = /^\d+$/.test(b);
if (aNumber && bNumber) return Number(a) - Number(b);
if (aNumber) return -1;
if (bNumber) return 1;
return a.localeCompare(b);
}
function compareSemverLikeVersions(a: string, b: string): number {
const [aCoreWithPrerelease] = a.split("+", 1);
const [bCoreWithPrerelease] = b.split("+", 1);
const [aCore, aPrerelease] = aCoreWithPrerelease.split("-", 2);
const [bCore, bPrerelease] = bCoreWithPrerelease.split("-", 2);
const aParts = aCore.split(".");
const bParts = bCore.split(".");
for (let i = 0; i < Math.max(aParts.length, bParts.length); i++) {
const diff = Number(aParts[i] ?? 0) - Number(bParts[i] ?? 0);
if (diff !== 0 && Number.isFinite(diff)) return diff;
}
if (!aPrerelease && !bPrerelease) return 0;
if (!aPrerelease) return 1;
if (!bPrerelease) return -1;
const aPrereleaseParts = aPrerelease.split(".");
const bPrereleaseParts = bPrerelease.split(".");
for (let i = 0; i < Math.max(aPrereleaseParts.length, bPrereleaseParts.length); i++) {
const aPart = aPrereleaseParts[i];
const bPart = bPrereleaseParts[i];
if (aPart === undefined) return -1;
if (bPart === undefined) return 1;
const diff = compareSemverIdentifier(aPart, bPart);
if (diff !== 0) return diff;
}
return 0;
}
async function readdirIfExists(dir: string): Promise<fs.Dirent[]> {
try {
return await fs.promises.readdir(dir, { withFileTypes: true });
} catch (err) {
if (isEnoent(err)) return [];
throw err;
}
}
function getBunCacheGroup(groups: Map<string, BunCachePackageGroup>, packageName: string): BunCachePackageGroup {
let group = groups.get(packageName);
if (!group) {
group = { actualDirs: new Map(), markerEntries: new Map() };
groups.set(packageName, group);
}
return group;
}
function addVersionPath(entries: Map<string, string[]>, version: string, entryPath: string): void {
const paths = entries.get(version);
if (paths) {
paths.push(entryPath);
return;
}
entries.set(version, [entryPath]);
}
async function addBunCacheActualDir(
groups: Map<string, BunCachePackageGroup>,
dirPath: string,
packageNames: Set<string> | undefined,
): Promise<void> {
try {
const manifest = (await Bun.file(path.join(dirPath, "package.json")).json()) as Partial<
Record<"name" | "version", unknown>
>;
if (typeof manifest.name !== "string" || typeof manifest.version !== "string") return;
if (packageNames && !packageNames.has(manifest.name)) return;
const group = getBunCacheGroup(groups, manifest.name);
addVersionPath(group.actualDirs, manifest.version, dirPath);
} catch (err) {
if (isEnoent(err)) return;
throw err;
}
}
async function addBunCacheMarkerDir(
groups: Map<string, BunCachePackageGroup>,
packageName: string,
markerDir: string,
packageNames: Set<string> | undefined,
): Promise<void> {
if (packageNames && !packageNames.has(packageName)) return;
const markerEntries = await readdirIfExists(markerDir);
const group = getBunCacheGroup(groups, packageName);
group.markerDir = markerDir;
for (const entry of markerEntries) {
const cacheVersion = stripBunCacheVersionSuffix(entry.name);
addVersionPath(group.markerEntries, cacheVersion, path.join(markerDir, entry.name));
}
}
async function collectBunCacheGroups(
cacheDir: string,
packageNames: Set<string> | undefined,
): Promise<Map<string, BunCachePackageGroup>> {
const groups = new Map<string, BunCachePackageGroup>();
for (const entry of await readdirIfExists(cacheDir)) {
if (!entry.isDirectory()) continue;
const entryPath = path.join(cacheDir, entry.name);
if (entry.name.startsWith("@")) {
for (const scopedEntry of await readdirIfExists(entryPath)) {
if (!scopedEntry.isDirectory()) continue;
const scopedEntryPath = path.join(entryPath, scopedEntry.name);
const versionSeparator = scopedEntry.name.lastIndexOf("@");
if (versionSeparator === -1) {
await addBunCacheMarkerDir(groups, `${entry.name}/${scopedEntry.name}`, scopedEntryPath, packageNames);
} else {
await addBunCacheActualDir(groups, scopedEntryPath, packageNames);
}
}
continue;
}
const versionSeparator = entry.name.lastIndexOf("@");
if (versionSeparator === -1) {
await addBunCacheMarkerDir(groups, entry.name, entryPath, packageNames);
} else {
await addBunCacheActualDir(groups, entryPath, packageNames);
}
}
return groups;
}
async function removeCacheEntries(paths: string[]): Promise<number> {
for (const entryPath of paths) {
await fs.promises.rm(entryPath, { recursive: true, force: true });
}
return paths.length;
}
/**
* Prune Bun's package cache so each package keeps only its newest cached version.
*
* Bun stores package cache entries as both a package marker directory
* (`react/19.2.6@@@1`) and a materialized package directory
* (`react@19.2.6@@@1`). Global `omp` updates can leave one full copy per
* release. The marker and materialized entries are removed together so the
* cache stays internally consistent.
*/
export async function pruneBunInstallCache(
cacheDir: string,
packageNames?: Set<string>,
): Promise<BunInstallCachePruneResult> {
const groups = await collectBunCacheGroups(cacheDir, packageNames);
let scannedPackages = 0;
let removedEntries = 0;
for (const group of groups.values()) {
if (group.actualDirs.size === 0) continue;
scannedPackages++;
let latestVersion: string | undefined;
for (const version of group.actualDirs.keys()) {
if (!latestVersion || compareSemverLikeVersions(version, latestVersion) > 0) latestVersion = version;
}
if (!latestVersion) continue;
for (const [version, paths] of group.actualDirs) {
if (version !== latestVersion) removedEntries += await removeCacheEntries(paths);
}
for (const [version, paths] of group.markerEntries) {
if (version !== latestVersion) removedEntries += await removeCacheEntries(paths);
}
}
return { scannedPackages, removedEntries };
}
async function resolveBunInstallCacheDir(): Promise<string | undefined> {
try {
const result = await $`bun pm cache`.quiet().nothrow();
if (result.exitCode !== 0) return undefined;
const output = result.text().trim();
return output.length > 0 ? output : undefined;
} catch {
return undefined;
}
}
async function resolveBunGlobalNodeModulesDir(): Promise<string | undefined> {
try {
const result = await $`bun pm ls -g`.quiet().nothrow();
if (result.exitCode !== 0) return undefined;
const output = result.text();
const firstLineEnd = output.indexOf("\n");
const firstLine = (firstLineEnd === -1 ? output : output.slice(0, firstLineEnd)).trim();
const marker = " node_modules ";
const markerIndex = firstLine.lastIndexOf(marker);
if (markerIndex === -1) return undefined;
return path.join(firstLine.slice(0, markerIndex), "node_modules");
} catch {
return undefined;
}
}
async function collectInstalledPackageNames(nodeModulesDir: string): Promise<Set<string>> {
const packageNames = new Set<string>();
for (const entry of await readdirIfExists(nodeModulesDir)) {
if (!entry.isDirectory() || entry.name === ".bin") continue;
if (entry.name.startsWith("@")) {
for (const scopedEntry of await readdirIfExists(path.join(nodeModulesDir, entry.name))) {
if (scopedEntry.isDirectory()) packageNames.add(`${entry.name}/${scopedEntry.name}`);
}
continue;
}
packageNames.add(entry.name);
}
return packageNames;
}
async function pruneBunCacheAfterGlobalInstall(): Promise<BunInstallCachePruneResult | undefined> {
const cacheDir = await resolveBunInstallCacheDir();
if (!cacheDir) return undefined;
const globalNodeModulesDir = await resolveBunGlobalNodeModulesDir();
const packageNames = globalNodeModulesDir
? await collectInstalledPackageNames(globalNodeModulesDir)
: new Set<string>();
if (packageNames.size === 0 && !path.basename(cacheDir).toLowerCase().includes("omp")) return undefined;
return await pruneBunInstallCache(cacheDir, packageNames.size === 0 ? undefined : packageNames);
}
/**
* Get the appropriate binary name for this platform.
*/
@@ -336,6 +575,14 @@ async function updateViaBun(expectedVersion: string): Promise<void> {
}
await printVerification(expectedVersion);
try {
const pruneResult = await pruneBunCacheAfterGlobalInstall();
if (pruneResult && pruneResult.removedEntries > 0) {
console.log(chalk.dim(`Pruned ${pruneResult.removedEntries} stale Bun cache entries`));
}
} catch (err) {
console.log(chalk.yellow(`Warning: could not prune stale Bun cache entries: ${err}`));
}
}
/**