Merge PR #1716: prune stale bun update cache (@dragonbaba)
# Conflicts: # packages/coding-agent/test/update-cli.test.ts
This commit is contained in:
@@ -2401,6 +2401,8 @@
|
||||
- Fixed empty assistant stop retry continuations preserving auto-retry state until a non-empty assistant turn completes or recovery reaches its retry cap.
|
||||
- Fixed TTSR rule conditions never matching streamed `edit`/`write` tool calls whose wire format obscures the real content (hashline `+` body rows, apply_patch envelopes, JSON-escaped `write` content). The edit and write tools now expose a `matcherDigest` normalization and TTSR matches against the introduced source text, so rule regexes stay universal regardless of the active edit mode.
|
||||
|
||||
- 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.
|
||||
|
||||
### Changed
|
||||
|
||||
- Changed the JJ utility API to mirror Git's scoped helpers: repository operations now live under `jj.repo` (`root`, `resolve`, `is`, `clearRootCache`), and diff file listing is available as `jj.diff.changedFiles`.
|
||||
@@ -2413,7 +2415,6 @@
|
||||
- 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)).
|
||||
|
||||
## [15.8.0] - 2026-06-02
|
||||
|
||||
### Added
|
||||
|
||||
@@ -272,6 +272,252 @@ 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;
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveBunGlobalNodeModulesDirFromLocations(
|
||||
globalBinDir: string | undefined,
|
||||
cacheDir: string | undefined,
|
||||
): string | undefined {
|
||||
if (globalBinDir && globalBinDir.length > 0) {
|
||||
return path.join(path.dirname(globalBinDir), "install", "global", "node_modules");
|
||||
}
|
||||
if (cacheDir && cacheDir.length > 0) {
|
||||
return path.join(path.dirname(cacheDir), "global", "node_modules");
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function resolveBunGlobalNodeModulesDir(cacheDir: string): Promise<string | undefined> {
|
||||
try {
|
||||
const result = await $`bun pm bin -g`.quiet().nothrow();
|
||||
const globalBinDir = result.exitCode === 0 ? result.text().trim() : undefined;
|
||||
return resolveBunGlobalNodeModulesDirFromLocations(globalBinDir, cacheDir);
|
||||
} catch {
|
||||
return resolveBunGlobalNodeModulesDirFromLocations(undefined, cacheDir);
|
||||
}
|
||||
}
|
||||
|
||||
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(cacheDir);
|
||||
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.
|
||||
*/
|
||||
@@ -524,6 +770,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}`));
|
||||
}
|
||||
}
|
||||
|
||||
async function updateViaHomebrew(expectedVersion: string, force: boolean): Promise<void> {
|
||||
|
||||
@@ -8,7 +8,9 @@ import {
|
||||
buildHomebrewUpdateArgs,
|
||||
buildMiseForceInstallArgs,
|
||||
buildMiseUpgradeArgs,
|
||||
pruneBunInstallCache,
|
||||
replaceBinaryForUpdate,
|
||||
resolveBunGlobalNodeModulesDirFromLocations,
|
||||
resolveUpdateMethodForTest,
|
||||
sweepStaleBackups,
|
||||
} from "@oh-my-pi/pi-coding-agent/cli/update-cli";
|
||||
@@ -137,6 +139,87 @@ describe("update-cli bun install command", () => {
|
||||
expect(args).toContain("@oh-my-pi/pi-natives@15.9.0");
|
||||
expect(args.some(arg => arg.startsWith("@oh-my-pi/pi-natives-"))).toBe(false);
|
||||
});
|
||||
|
||||
it("derives global node_modules from supported bun global locations", () => {
|
||||
expect(resolveBunGlobalNodeModulesDirFromLocations(path.join("home", ".bun", "bin"), undefined)).toBe(
|
||||
path.join("home", ".bun", "install", "global", "node_modules"),
|
||||
);
|
||||
expect(
|
||||
resolveBunGlobalNodeModulesDirFromLocations(undefined, path.join("home", ".bun", "install", "cache")),
|
||||
).toBe(path.join("home", ".bun", "install", "global", "node_modules"));
|
||||
});
|
||||
});
|
||||
|
||||
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", () => {
|
||||
|
||||
Reference in New Issue
Block a user