From c2cbc89c7a190e01e0bd301c56a2fc1021b09752 Mon Sep 17 00:00:00 2001 From: Zaun <2311993475@qq.com> Date: Tue, 2 Jun 2026 21:02:33 +0800 Subject: [PATCH] fix(coding-agent): prune stale bun update cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修复 omp update 更新后清理旧版 Bun 安装缓存。 --- packages/coding-agent/CHANGELOG.md | 3 + packages/coding-agent/src/cli/update-cli.ts | 247 ++++++++++++++++++ packages/coding-agent/test/update-cli.test.ts | 79 +++++- 3 files changed, 328 insertions(+), 1 deletion(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index af364e4d1..b6d8da6c4 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -24,6 +24,9 @@ - Fixed an unhandled `EPIPE` rejection when an MCP stdio server exits between returning the `initialize` response and the client's `notifications/initialized` send. `StdioTransport.notify()` and `#sendResponse()` now route stdin writes through a shared helper that catches synchronous sink failures: `notify()` tears the transport down (firing `onClose`) and surfaces a `Transport closed while sending notification` rejection so `connectToServer()` treats the handshake as a failed connection instead of returning a "connected" handle wrapping a dead transport; `#sendResponse()` stays silent because a dead subprocess has no use for the response. `StdioTransport.close()` is now the authoritative resource teardown — it no longer early-returns when `#handleClose()` has already flipped `#connected`, so the subprocess and read loop are always cleaned up (including in the `connectToServer()` failure path) ([#1710](https://github.com/can1357/oh-my-pi/issues/1710)). - Fixed startup model resolution ignoring cached discovery rows for special built-in providers (`google-antigravity`, `google-gemini-cli`, `openai-codex`) until the background refresh completed ([#1721](https://github.com/can1357/oh-my-pi/issues/1721)). - Fixed Windows clipboard-image paste keeping `Ctrl+V` unregistered by default. The TUI now registers `Ctrl+V` plus the Windows Terminal-safe `Alt+V` fallback, and the keybinding docs call out when to use the fallback ([#1708](https://github.com/can1357/oh-my-pi/issues/1708)). +### Fixed + +- Fixed `omp update` leaving older Bun install-cache copies of globally installed `omp` packages behind. After a successful Bun global install, the update command now prunes stale `pkg@version@@@N` cache directories and their marker entries for packages present in Bun's global `node_modules` tree, while leaving unrelated shared-cache packages untouched. ## [15.8.0] - 2026-06-02 diff --git a/packages/coding-agent/src/cli/update-cli.ts b/packages/coding-agent/src/cli/update-cli.ts index 8593a957b..591a4fb2b 100644 --- a/packages/coding-agent/src/cli/update-cli.ts +++ b/packages/coding-agent/src/cli/update-cli.ts @@ -175,6 +175,245 @@ function compareVersions(a: string, b: string): number { return 0; } +interface BunInstallCachePruneResult { + scannedPackages: number; + removedEntries: number; +} + +interface BunCachePackageGroup { + actualDirs: Map; + markerDir?: string; + markerEntries: Map; +} + +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 { + try { + return await fs.promises.readdir(dir, { withFileTypes: true }); + } catch (err) { + if (isEnoent(err)) return []; + throw err; + } +} + +function getBunCacheGroup(groups: Map, 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, 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, + dirPath: string, + packageNames: Set | undefined, +): Promise { + 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, + packageName: string, + markerDir: string, + packageNames: Set | undefined, +): Promise { + 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 | undefined, +): Promise> { + const groups = new Map(); + 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 { + 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, +): Promise { + 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 { + 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 { + 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> { + const packageNames = new Set(); + 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 { + const cacheDir = await resolveBunInstallCacheDir(); + if (!cacheDir) return undefined; + const globalNodeModulesDir = await resolveBunGlobalNodeModulesDir(); + const packageNames = globalNodeModulesDir + ? await collectInstalledPackageNames(globalNodeModulesDir) + : new Set(); + 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 { } 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}`)); + } } /** diff --git a/packages/coding-agent/test/update-cli.test.ts b/packages/coding-agent/test/update-cli.test.ts index 038fcba15..3be104fac 100644 --- a/packages/coding-agent/test/update-cli.test.ts +++ b/packages/coding-agent/test/update-cli.test.ts @@ -2,7 +2,12 @@ import { afterEach, describe, expect, it } from "bun:test"; import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; -import { buildBunInstallArgs, replaceBinaryForUpdate, resolveUpdateMethodForTest } from "../src/cli/update-cli"; +import { + buildBunInstallArgs, + pruneBunInstallCache, + replaceBinaryForUpdate, + resolveUpdateMethodForTest, +} from "../src/cli/update-cli"; const tempDirs: string[] = []; @@ -55,6 +60,78 @@ describe("update-cli bun install command", () => { }); }); +describe("update-cli bun cache pruning", () => { + it("keeps only the newest cached version for filtered global install packages", async () => { + const dir = await makeTempDir(); + await Bun.write(path.join(dir, "react", "18.3.1@@@1"), ""); + await Bun.write(path.join(dir, "react", "19.2.6@@@1"), ""); + await Bun.write( + path.join(dir, "react@18.3.1@@@1", "package.json"), + JSON.stringify({ name: "react", version: "18.3.1" }), + ); + await Bun.write( + path.join(dir, "react@19.2.6@@@1", "package.json"), + JSON.stringify({ name: "react", version: "19.2.6" }), + ); + await Bun.write(path.join(dir, "@oh-my-pi", "pi-utils", "15.7.6@@@1"), ""); + await Bun.write(path.join(dir, "@oh-my-pi", "pi-utils", "15.8.0@@@1"), ""); + await Bun.write( + path.join(dir, "@oh-my-pi", "pi-utils@15.7.6@@@1", "package.json"), + JSON.stringify({ name: "@oh-my-pi/pi-utils", version: "15.7.6" }), + ); + await Bun.write( + path.join(dir, "@oh-my-pi", "pi-utils@15.8.0@@@1", "package.json"), + JSON.stringify({ name: "@oh-my-pi/pi-utils", version: "15.8.0" }), + ); + await Bun.write(path.join(dir, "chalk", "4.1.2@@@1"), ""); + await Bun.write(path.join(dir, "chalk", "5.6.2@@@1"), ""); + await Bun.write( + path.join(dir, "chalk@4.1.2@@@1", "package.json"), + JSON.stringify({ name: "chalk", version: "4.1.2" }), + ); + await Bun.write( + path.join(dir, "chalk@5.6.2@@@1", "package.json"), + JSON.stringify({ name: "chalk", version: "5.6.2" }), + ); + + const result = await pruneBunInstallCache(dir, new Set(["react", "@oh-my-pi/pi-utils"])); + + expect(result).toEqual({ scannedPackages: 2, removedEntries: 4 }); + expect(await Bun.file(path.join(dir, "react", "18.3.1@@@1")).exists()).toBe(false); + expect(await Bun.file(path.join(dir, "react@18.3.1@@@1", "package.json")).exists()).toBe(false); + expect(await Bun.file(path.join(dir, "react", "19.2.6@@@1")).exists()).toBe(true); + expect(await Bun.file(path.join(dir, "react@19.2.6@@@1", "package.json")).exists()).toBe(true); + expect(await Bun.file(path.join(dir, "@oh-my-pi", "pi-utils", "15.7.6@@@1")).exists()).toBe(false); + expect(await Bun.file(path.join(dir, "@oh-my-pi", "pi-utils@15.7.6@@@1", "package.json")).exists()).toBe(false); + expect(await Bun.file(path.join(dir, "@oh-my-pi", "pi-utils", "15.8.0@@@1")).exists()).toBe(true); + expect(await Bun.file(path.join(dir, "@oh-my-pi", "pi-utils@15.8.0@@@1", "package.json")).exists()).toBe(true); + expect(await Bun.file(path.join(dir, "chalk", "4.1.2@@@1")).exists()).toBe(true); + expect(await Bun.file(path.join(dir, "chalk@4.1.2@@@1", "package.json")).exists()).toBe(true); + }); + + it("treats a stable release as newer than a matching prerelease", async () => { + const dir = await makeTempDir(); + await Bun.write(path.join(dir, "pkg", "1.0.0-beta.1@@@1"), ""); + await Bun.write(path.join(dir, "pkg", "1.0.0@@@1"), ""); + await Bun.write( + path.join(dir, "pkg@1.0.0-beta.1@@@1", "package.json"), + JSON.stringify({ name: "pkg", version: "1.0.0-beta.1" }), + ); + await Bun.write( + path.join(dir, "pkg@1.0.0@@@1", "package.json"), + JSON.stringify({ name: "pkg", version: "1.0.0" }), + ); + + const result = await pruneBunInstallCache(dir); + + expect(result).toEqual({ scannedPackages: 1, removedEntries: 2 }); + expect(await Bun.file(path.join(dir, "pkg", "1.0.0-beta.1@@@1")).exists()).toBe(false); + expect(await Bun.file(path.join(dir, "pkg@1.0.0-beta.1@@@1", "package.json")).exists()).toBe(false); + expect(await Bun.file(path.join(dir, "pkg", "1.0.0@@@1")).exists()).toBe(true); + expect(await Bun.file(path.join(dir, "pkg@1.0.0@@@1", "package.json")).exists()).toBe(true); + }); +}); + describe("update-cli binary replacement", () => { it("restores the previous binary when the replacement fails verification", async () => { const dir = await makeTempDir();