import { afterEach, describe, expect, it, type Mock, spyOn, vi } from "bun:test"; import { createHash } from "node:crypto"; import * as nodeFs from "node:fs"; import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; import * as pluginCli from "@oh-my-pi/pi-coding-agent/cli/plugin-cli"; import * as updateCli from "@oh-my-pi/pi-coding-agent/cli/update-cli"; import { buildBunInstallArgs, buildHomebrewUpdateArgs, buildMiseForceInstallArgs, buildMiseUpgradeArgs, buildNpmInstallArgs, buildRenameCleanupPackages, downloadVerifiedBinary, isMuslLinuxForTest, migrateRenamedInstall, parseUpdateArgs, pruneBunInstallCache, type ReleaseInfo, type RenameMigrationSteps, replaceBinaryForUpdate, resolveBunGlobalNodeModulesDirFromLocations, resolveReleaseBinaryAsset, resolveReleaseDist, resolveReleaseRename, resolveUpdateMethodForTest, resolveUpdateTargetFromPathForTest, shouldForceBinaryUpdate, sweepStaleUpdateArtifacts, updateViaBinaryAt, updateViaShimTakeover, } from "@oh-my-pi/pi-coding-agent/cli/update-cli"; import Update from "@oh-my-pi/pi-coding-agent/commands/update"; import { removeWithRetries } from "@oh-my-pi/pi-utils"; import type { CliConfig } from "@oh-my-pi/pi-utils/cli"; import { getThemeByName, setThemeInstance } from "../src/modes/theme/theme"; const tempDirs: string[] = []; async function makeTempDir(): Promise { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-update-test-")); tempDirs.push(dir); return dir; } afterEach(async () => { vi.restoreAllMocks(); await Promise.all(tempDirs.splice(0).map(dir => removeWithRetries(dir))); }); const TEST_CONFIG: CliConfig = { bin: "omp", version: "0.0.0-test", commands: new Map(), }; describe("update command plugin dispatch", () => { it("routes -l to plugin upgrade instead of the app updater", async () => { const pluginSpy = spyOn(pluginCli, "runPluginCommand").mockResolvedValue(undefined); const updateSpy = spyOn(updateCli, "runUpdateCommand").mockResolvedValue(undefined); const command = new Update(["-l"], TEST_CONFIG); await command.run(); expect(pluginSpy).toHaveBeenCalledWith({ action: "upgrade", args: [], flags: {} }); expect(updateSpy).not.toHaveBeenCalled(); }); it("keeps normal update flags on the app updater path", async () => { const pluginSpy = spyOn(pluginCli, "runPluginCommand").mockResolvedValue(undefined); const updateSpy = spyOn(updateCli, "runUpdateCommand").mockResolvedValue(undefined); const command = new Update(["--check", "--force"], TEST_CONFIG); await command.run(); expect(updateSpy).toHaveBeenCalledWith({ force: true, check: true }); expect(pluginSpy).not.toHaveBeenCalled(); }); }); describe("parseUpdateArgs", () => { it("preserves the legacy plugin update shorthand", () => { expect(parseUpdateArgs(["update", "-l"])).toEqual({ force: false, check: false, plugins: true }); }); }); describe("update-cli libc detection", () => { it("does not mistake an installed musl loader for a glibc host", () => { expect( isMuslLinuxForTest({ platform: "linux", alpineRelease: false, lddOutput: "ldd (Ubuntu GLIBC 2.39-0ubuntu8.7) 2.39", }), ).toBe(false); }); it("recognizes a musl host from ldd output", () => { expect( isMuslLinuxForTest({ platform: "linux", alpineRelease: false, lddOutput: "musl libc (x86_64)", }), ).toBe(true); }); }); describe("update-cli install target detection", () => { it("leaves Nix store installations under Nix management", () => { const method = resolveUpdateMethodForTest( "/nix/store/0123456789-omp-17.2.15/bin/omp", "/nix/store/9876543210-bun-1.3.14/bin", ); expect(method).toBe("nix"); }); it("uses bun update when prioritized omp is inside bun global bin", () => { const method = resolveUpdateMethodForTest("/Users/test/.bun/bin/omp", "/Users/test/.bun/bin"); expect(method).toBe("bun"); }); it("uses npm update when prioritized omp is inside an npm global bin", () => { const method = resolveUpdateMethodForTest("/Users/test/.npm-global/bin/omp", undefined, { npmBinDir: "/Users/test/.npm-global/bin", }); expect(method).toBe("npm"); }); it("uses npm update for Windows npm command shims even when no package-manager bin dirs were detected", () => { const method = resolveUpdateMethodForTest("C:\\Users\\test\\AppData\\Roaming\\npm\\omp.cmd", undefined); expect(method).toBe("npm"); }); it("uses binary update when a plain file in the npm global bin dir is the standalone binary, not an npm symlink", () => { // Regression: with `npm prefix -g` pointed at the installer's default // (~/.local), directory containment alone misclassified the standalone // binary as npm-managed, so `npm install -g` failed with EEXIST refusing // to overwrite the existing executable. const method = resolveUpdateMethodForTest("/home/u/.local/bin/omp", undefined, { npmBinDir: "/home/u/.local/bin", ompIsRegularFile: true, }); expect(method).toBe("binary"); }); it("uses binary update when a plain file in the bun global bin dir is the standalone binary", () => { const method = resolveUpdateMethodForTest("/home/u/.local/bin/omp", "/home/u/.local/bin", { ompIsRegularFile: true, }); expect(method).toBe("binary"); }); it("keeps bun update for regular-file entries in the bun global bin dir on Windows, where bun writes .exe shims", () => { // On Windows a bun-managed global install is a regular-file .exe // launcher, not a symlink, so the standalone-binary override must not // apply there — it would clobber the shim with a raw binary. Paths use // forward slashes so the lexical containment check works on the POSIX // host running this suite; the platform gate is what is under test. const platformDescriptor = Object.getOwnPropertyDescriptor(process, "platform"); if (!platformDescriptor) throw new Error("process.platform descriptor missing"); Object.defineProperty(process, "platform", { ...platformDescriptor, value: "win32" }); try { const method = resolveUpdateMethodForTest("C:/Users/test/.bun/bin/omp.exe", "C:/Users/test/.bun/bin", { ompIsRegularFile: true, }); expect(method).toBe("bun"); } finally { Object.defineProperty(process, "platform", platformDescriptor); } }); it("still uses npm update when the npm global bin entry is a package-manager symlink, not a plain file", () => { const method = resolveUpdateMethodForTest("/home/u/.local/bin/omp", undefined, { npmBinDir: "/home/u/.local/bin", ompIsRegularFile: false, }); expect(method).toBe("npm"); }); it("updates the resolved standalone binary behind a foreign npm-bin alias", async () => { const dir = await makeTempDir(); const npmBinDir = path.join(dir, ".npm-global", "bin"); const standalonePath = path.join(dir, ".local", "bin", "omp"); const aliasPath = path.join(npmBinDir, "omp"); await fs.mkdir(npmBinDir, { recursive: true }); await fs.mkdir(path.dirname(standalonePath), { recursive: true }); await Bun.write(standalonePath, "binary"); await fs.symlink(standalonePath, aliasPath); const target = resolveUpdateTargetFromPathForTest(aliasPath, undefined, { allowPackageManagers: true, npmBinDir, }); expect(target).toEqual({ method: "binary", path: standalonePath, replacesSymlink: false }); expect(await fs.readlink(aliasPath)).toBe(standalonePath); }); it("uses npm update when the bin symlink resolves into npm's global install tree", () => { const method = resolveUpdateMethodForTest("/home/u/.npm-global/bin/omp", undefined, { npmBinDir: "/home/u/.npm-global/bin", ompRealpath: "/home/u/.npm-global/lib/node_modules/@oh-my-pi/pi-coding-agent/dist/cli.js", }); expect(method).toBe("npm"); }); it("uses binary update when the bun global bin entry is a foreign alias symlink", () => { const method = resolveUpdateMethodForTest("/home/u/.bun/bin/omp", "/home/u/.bun/bin", { ompRealpath: "/home/u/.local/bin/omp", }); expect(method).toBe("binary"); }); it("uses bun update when the bin symlink resolves into bun's global install tree", () => { const method = resolveUpdateMethodForTest("/home/u/.bun/bin/omp", "/home/u/.bun/bin", { ompRealpath: "/home/u/.bun/install/global/node_modules/@oh-my-pi/pi-coding-agent/dist/cli.js", }); expect(method).toBe("bun"); }); it("uses binary update when prioritized omp is outside bun global bin", () => { const method = resolveUpdateMethodForTest("/Users/test/.local/bin/omp", "/Users/test/.bun/bin"); expect(method).toBe("binary"); }); it("uses binary update when bun global bin cannot be resolved", () => { const method = resolveUpdateMethodForTest("/Users/test/.local/bin/omp", undefined); expect(method).toBe("binary"); }); it("uses Homebrew update when prioritized omp resolves into the Homebrew formula", async () => { const dir = await makeTempDir(); const prefix = path.join(dir, "opt", "omp"); const linkedBin = path.join(dir, "bin"); await fs.mkdir(path.join(prefix, "bin"), { recursive: true }); await fs.mkdir(linkedBin, { recursive: true }); await Bun.write(path.join(prefix, "bin", "omp"), "binary"); await fs.symlink(path.join(prefix, "bin", "omp"), path.join(linkedBin, "omp")); const method = resolveUpdateMethodForTest(path.join(linkedBin, "omp"), "/Users/test/.bun/bin", { homebrewPrefix: prefix, }); expect(method).toBe("brew"); }); it("uses mise update when prioritized omp is in an active mise bin path", () => { const method = resolveUpdateMethodForTest( "/Users/test/.local/share/mise/installs/github-can1357-oh-my-pi/latest/bin/omp", undefined, { miseBinDirs: ["/Users/test/.local/share/mise/installs/github-can1357-oh-my-pi/latest/bin"], }, ); expect(method).toBe("mise"); }); it("uses mise update when prioritized omp is a mise shim", () => { const method = resolveUpdateMethodForTest("/Users/test/.local/share/mise/shims/omp", undefined, { miseDataDir: "/Users/test/.local/share/mise", }); expect(method).toBe("mise"); }); }); describe("update-cli package manager commands", () => { it("targets the Homebrew tap formula and switches to reinstall for forced updates", () => { expect(buildHomebrewUpdateArgs(false)).toEqual(["upgrade", "can1357/tap/omp"]); expect(buildHomebrewUpdateArgs(true)).toEqual(["reinstall", "can1357/tap/omp"]); }); it("targets the mise GitHub backend tool and force-reinstalls the checked version when requested", () => { expect(buildMiseUpgradeArgs()).toEqual(["upgrade", "github:can1357/oh-my-pi", "--bump"]); expect(buildMiseForceInstallArgs("15.10.5")).toEqual(["install", "--force", "github:can1357/oh-my-pi@15.10.5"]); }); it("pins npm package installs to the official registry and the checked native package versions", () => { const args = buildNpmInstallArgs("16.3.15", "win32-x64"); expect(args.slice(0, 2)).toEqual(["install", "-g"]); expect(args).toContain("--registry=https://registry.npmjs.org/"); expect(args).toContain("@oh-my-pi/pi-coding-agent@16.3.15"); expect(args).toContain("@oh-my-pi/pi-natives@16.3.15"); expect(args).toContain("@oh-my-pi/pi-natives-win32-x64@16.3.15"); }); }); describe("update-cli npm rename contract", () => { it("parses a well-formed omp.rename pointer and rejects malformed ones", () => { expect(resolveReleaseRename({ omp: { rename: { package: "@new/omp", natives: "@new/natives" } } })).toEqual({ pkg: "@new/omp", natives: "@new/natives", }); expect(resolveReleaseRename({ omp: { rename: { package: "@new/omp" } } })).toEqual({ pkg: "@new/omp", natives: undefined, }); expect(resolveReleaseRename({ omp: { rename: { package: "" } } })).toBeUndefined(); expect(resolveReleaseRename({ omp: { rename: "@new/omp" } })).toBeUndefined(); expect(resolveReleaseRename({ omp: {} })).toBeUndefined(); expect(resolveReleaseRename(undefined)).toBeUndefined(); }); it("installs renamed package names in lock-step, with no old-name leftovers in the argv", () => { const packages = { pkg: "@new/omp", natives: "@new/natives" }; const bunArgs = buildBunInstallArgs("17.0.0", "linux-x64", packages); expect(bunArgs).toContain("@new/omp@17.0.0"); expect(bunArgs).toContain("@new/natives@17.0.0"); expect(bunArgs).toContain("@new/natives-linux-x64@17.0.0"); expect(bunArgs.some(arg => arg.startsWith("@oh-my-pi/"))).toBe(false); expect(buildNpmInstallArgs("17.0.0", "linux-x64", packages)).toContain("@new/omp@17.0.0"); }); it("adds --force to npm argv only for rename migrations so the old package's bin can be clobbered", () => { const packages = { pkg: "@new/omp", natives: "@new/natives" }; expect(buildNpmInstallArgs("17.0.0", "linux-x64", packages, { force: true })).toContain("--force"); expect(buildNpmInstallArgs("16.3.15", "win32-x64")).not.toContain("--force"); }); it("removes the old agent package and its natives companions when both names moved", () => { const packages = { pkg: "@new/omp", natives: "@new/natives" }; expect(buildRenameCleanupPackages(packages, "darwin-arm64")).toEqual([ "@oh-my-pi/pi-coding-agent", "@oh-my-pi/pi-natives", "@oh-my-pi/pi-natives-darwin-arm64", ]); expect(buildRenameCleanupPackages(packages, "linux-arm")).toEqual([ "@oh-my-pi/pi-coding-agent", "@oh-my-pi/pi-natives", ]); }); it("keeps the natives packages on an agent-only rename so cleanup cannot strip the addon the new install pinned", () => { const packages = { pkg: "@new/omp", natives: "@oh-my-pi/pi-natives" }; expect(buildRenameCleanupPackages(packages, "darwin-arm64")).toEqual(["@oh-my-pi/pi-coding-agent"]); expect(buildRenameCleanupPackages(packages, "linux-arm")).toEqual(["@oh-my-pi/pi-coding-agent"]); }); }); describe("migrateRenamedInstall transaction", () => { const release: ReleaseInfo = { tag: "v999.1.0", version: "999.1.0", packages: { pkg: "@new/omp", natives: "@new/natives" }, }; function scriptedSteps(script: { install: number[]; removeOld?: number; verify: boolean[] }): { steps: RenameMigrationSteps; calls: string[]; } { const calls: string[] = []; let installs = 0; let verifies = 0; return { calls, steps: { async install() { calls.push("install"); return script.install[installs++] ?? 0; }, async removeOld() { calls.push("removeOld"); return script.removeOld ?? 0; }, async verify() { calls.push("verify"); return script.verify[verifies++] ? { ok: true, actual: "999.1.0", path: "/bin/omp" } : { ok: false, path: "/bin/omp" }; }, }, }; } it("never touches the old install when the new install fails", async () => { vi.spyOn(console, "log").mockImplementation(() => {}); const { steps, calls } = scriptedSteps({ install: [1], verify: [] }); await expect(migrateRenamedInstall(release, steps)).rejects.toThrow("left untouched"); expect(calls).toEqual(["install"]); }); it("installs the new package before removing the old one and verifies the result", async () => { vi.spyOn(console, "log").mockImplementation(() => {}); const { steps, calls } = scriptedSteps({ install: [0], verify: [true] }); await migrateRenamedInstall(release, steps); expect(calls).toEqual(["install", "removeOld", "verify"]); }); it("restores the bin link by reinstalling when old-package removal breaks verification", async () => { vi.spyOn(console, "log").mockImplementation(() => {}); const { steps, calls } = scriptedSteps({ install: [0, 0], verify: [false, true] }); await migrateRenamedInstall(release, steps); expect(calls).toEqual(["install", "removeOld", "verify", "install", "verify"]); }); it("treats old-package removal failure as a warning when the new install verifies", async () => { const logs: string[] = []; vi.spyOn(console, "log").mockImplementation(message => { logs.push(String(message)); }); const { steps, calls } = scriptedSteps({ install: [0], removeOld: 1, verify: [true] }); await migrateRenamedInstall(release, steps); expect(calls).toEqual(["install", "removeOld", "verify"]); expect(logs.some(line => line.includes("could not remove the old"))).toBe(true); }); it("aborts with a recovery hint when verification still fails after the restore install", async () => { vi.spyOn(console, "log").mockImplementation(() => {}); const { steps, calls } = scriptedSteps({ install: [0, 0], verify: [false, false] }); await expect(migrateRenamedInstall(release, steps)).rejects.toThrow("curl -fsSL https://omp.sh/install"); expect(calls).toEqual(["install", "removeOld", "verify", "install", "verify"]); }); }); describe("update-cli bun install command", () => { it("pins the official npm registry and bypasses the manifest cache so a stale mirror or snapshot cannot mask a freshly published version", () => { // Regression: omp queries https://registry.npmjs.org//latest directly. // The install MUST hit the same registry, otherwise: // - a lagging mirror (corp proxy, Taobao, …) rejects the version with // `No version matching "X" (but package exists)`, // - or bun's local manifest snapshot does the same when the user's bun // is already pointed at the official registry but its cache predates // the release. // See https://github.com/can1357/oh-my-pi/issues/1686. const args = buildBunInstallArgs("15.7.6", "linux-x64"); expect(args.slice(0, 5)).toEqual([ "install", "-g", "--no-cache", "--registry=https://registry.npmjs.org/", "@oh-my-pi/pi-coding-agent@15.7.6", ]); }); it("pins the native addon core and the platform-specific leaf to the same version so the loader sentinel cannot drift on supported tags", () => { // Regression: bun install -g @ would update only the top-level // package, leaving @oh-my-pi/pi-natives and @oh-my-pi/pi-natives- // at their previous version. The next launch then loaded a stale .node // file and aborted at validateLoadedBindings with `The .node file on // disk is from a different release than this loader`. See // https://github.com/can1357/oh-my-pi/issues/1824. for (const tag of ["linux-x64", "linux-arm64", "darwin-x64", "darwin-arm64", "win32-x64"]) { const args = buildBunInstallArgs("15.9.0", tag); expect(args).toContain("@oh-my-pi/pi-natives@15.9.0"); expect(args).toContain(`@oh-my-pi/pi-natives-${tag}@15.9.0`); } }); it("omits the leaf on unsupported platform tags so an EBADPLATFORM swap does not mask the underlying `no matching version` error", () => { // Defensive: an unsupported tag (e.g. linux-arm32) still installs the // core natives package — which will fail at module load if the platform // truly is unsupported — but we never request a leaf the release // pipeline doesn't publish, otherwise bun aborts with EBADPLATFORM // and hides the real diagnostic from `loadNative`'s aggregated error. const args = buildBunInstallArgs("15.9.0", "linux-arm"); 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("keeps current registry-qualified marker entries with their materialized package", async () => { const dir = await makeTempDir(); await Bun.write(path.join(dir, "pkg", "1.0.0@@registry.npmjs.org@@@1"), ""); await Bun.write( path.join(dir, "pkg@1.0.0@@registry.npmjs.org@@@1", "package.json"), JSON.stringify({ name: "pkg", version: "1.0.0" }), ); const result = await pruneBunInstallCache(dir, new Set(["pkg"])); expect(result).toEqual({ scannedPackages: 1, removedEntries: 0 }); expect(await Bun.file(path.join(dir, "pkg", "1.0.0@@registry.npmjs.org@@@1")).exists()).toBe(true); expect(await Bun.file(path.join(dir, "pkg@1.0.0@@registry.npmjs.org@@@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); }); it("compares numeric version segments without precision loss", async () => { const dir = await makeTempDir(); const older = "1.0.99999999999999999999"; const newer = "1.0.100000000000000000000"; await Bun.write(path.join(dir, "pkg", `${older}@@@1`), ""); await Bun.write(path.join(dir, "pkg", `${newer}@@@1`), ""); await Bun.write( path.join(dir, `pkg@${older}@@@1`, "package.json"), JSON.stringify({ name: "pkg", version: older }), ); await Bun.write( path.join(dir, `pkg@${newer}@@@1`, "package.json"), JSON.stringify({ name: "pkg", version: newer }), ); const result = await pruneBunInstallCache(dir, new Set(["pkg"])); expect(result).toEqual({ scannedPackages: 1, removedEntries: 2 }); expect(await Bun.file(path.join(dir, "pkg", `${older}@@@1`)).exists()).toBe(false); expect(await Bun.file(path.join(dir, `pkg@${older}@@@1`, "package.json")).exists()).toBe(false); expect(await Bun.file(path.join(dir, "pkg", `${newer}@@@1`)).exists()).toBe(true); expect(await Bun.file(path.join(dir, `pkg@${newer}@@@1`, "package.json")).exists()).toBe(true); }); }); describe("update-cli release binary integrity", () => { const tag = "v17.1.2"; const binaryName = "omp-linux-x64"; const url = `https://github.com/can1357/oh-my-pi/releases/download/${tag}/${binaryName}`; const content = "verified binary"; const digest = `sha256:${createHash("sha256").update(content).digest("hex")}`; function releaseAsset(overrides: Record = {}): Record { return { tag_name: tag, draft: false, prerelease: false, assets: [ { name: binaryName, state: "uploaded", size: Buffer.byteLength(content), digest, browser_download_url: url, ...overrides, }, ], }; } it("selects an uploaded asset with a valid SHA-256 digest", () => { expect(resolveReleaseBinaryAsset(releaseAsset(), tag, binaryName)).toEqual({ url, size: Buffer.byteLength(content), digest, }); }); it("rejects missing and unsupported release asset digests", () => { expect(() => resolveReleaseBinaryAsset(releaseAsset({ digest: null }), tag, binaryName)).toThrow("has no digest"); expect(() => resolveReleaseBinaryAsset(releaseAsset({ digest: "sha512:abc" }), tag, binaryName)).toThrow( "has an unsupported digest", ); }); it("rejects release metadata that does not identify one exact stable asset", () => { expect(() => resolveReleaseBinaryAsset({ ...releaseAsset(), prerelease: true }, tag, binaryName)).toThrow( "is not a published stable release", ); expect(() => resolveReleaseBinaryAsset({ ...releaseAsset(), assets: [] }, tag, binaryName)).toThrow( `has 0 assets named ${binaryName}`, ); expect(() => resolveReleaseBinaryAsset( { ...releaseAsset(), assets: [releaseAsset().assets, releaseAsset().assets].flat() }, tag, binaryName, ), ).toThrow(`has 2 assets named ${binaryName}`); expect(() => resolveReleaseBinaryAsset( releaseAsset({ browser_download_url: "https://example.com/omp-linux-x64" }), tag, binaryName, ), ).toThrow("has an unexpected download URL"); }); it("writes a download only after its size and digest match", async () => { const dir = await makeTempDir(); const targetPath = path.join(dir, binaryName); await downloadVerifiedBinary({ url, targetPath, expectedSize: Buffer.byteLength(content), expectedDigest: digest, fetchImpl: async () => new Response(content), }); expect(await Bun.file(targetPath).text()).toBe(content); expect((await fs.stat(targetPath)).mode & 0o777).toBe(0o755); }); it("aborts the response stream as soon as it exceeds the expected size", async () => { const dir = await makeTempDir(); const targetPath = path.join(dir, binaryName); let pulls = 0; const body = new ReadableStream( { pull(controller) { pulls++; controller.enqueue(new Uint8Array(pulls === 1 ? 2 : 1)); if (pulls === 2) controller.close(); }, }, { highWaterMark: 0 }, ); await expect( downloadVerifiedBinary({ url, targetPath, expectedSize: 1, expectedDigest: digest, fetchImpl: async () => new Response(body), }), ).rejects.toThrow("received at least 2"); expect(pulls).toBe(1); expect(await Bun.file(targetPath).exists()).toBe(false); }); it("wraps a timeout during body streaming with a friendly message", async () => { const dir = await makeTempDir(); const targetPath = path.join(dir, binaryName); const body = new ReadableStream( { pull(controller) { controller.enqueue(new Uint8Array(1)); controller.error(new DOMException("The operation timed out.", "TimeoutError")); }, }, { highWaterMark: 0 }, ); await expect( downloadVerifiedBinary({ url, targetPath, expectedSize: Buffer.byteLength(content), expectedDigest: digest, fetchImpl: async () => new Response(body), }), ).rejects.toThrow("Timed out downloading release binary after 15 minutes"); expect(await Bun.file(targetPath).exists()).toBe(false); }); it("removes downloads whose size or digest does not match", async () => { const dir = await makeTempDir(); const targetPath = path.join(dir, binaryName); const fetchImpl = async () => new Response(content); await expect( downloadVerifiedBinary({ url, targetPath, expectedSize: Buffer.byteLength(content) + 1, expectedDigest: digest, fetchImpl, }), ).rejects.toThrow("size mismatch"); expect(await Bun.file(targetPath).exists()).toBe(false); await expect( downloadVerifiedBinary({ url, targetPath, expectedSize: Buffer.byteLength(content), expectedDigest: `sha256:${createHash("sha256").update("different binary").digest("hex")}`, fetchImpl, }), ).rejects.toThrow("digest mismatch"); expect(await Bun.file(targetPath).exists()).toBe(false); }); it("rejects an altered version-reporting executable before replacing the installed binary", async () => { const dir = await makeTempDir(); const targetPath = path.join(dir, binaryName); const installed = "#!/bin/sh\necho omp/17.0.8\n"; const altered = "#!/bin/sh\necho omp/17.1.2\n"; const expectedDigest = `sha256:${createHash("sha256") .update("x".repeat(Buffer.byteLength(altered))) .digest("hex")}`; await Bun.write(targetPath, installed); await fs.chmod(targetPath, 0o755); const metadataAuthorizations: Array = []; const fetchImpl = async (input: string | URL | Request, init?: RequestInit): Promise => { const requestUrl = String(input); if (requestUrl.startsWith("https://api.github.com/")) { metadataAuthorizations.push(new Headers(init?.headers).get("Authorization")); return new Response( JSON.stringify( releaseAsset({ size: Buffer.byteLength(altered), digest: expectedDigest, }), ), ); } if (requestUrl === url) return new Response(altered); throw new Error(`Unexpected request: ${requestUrl}`); }; const previousGitHubToken = Bun.env.GITHUB_TOKEN; Bun.env.GITHUB_TOKEN = "test-token"; try { await expect( updateViaBinaryAt(targetPath, "17.1.2", { binaryName, fetchImpl, }), ).rejects.toThrow("digest mismatch"); expect(metadataAuthorizations).toEqual(["Bearer test-token"]); expect(await Bun.file(targetPath).text()).toBe(installed); expect((await fs.stat(targetPath)).mode & 0o777).toBe(0o755); const newResidue = (await fs.readdir(dir)).filter(name => name.endsWith(".new")); expect(newResidue).toEqual([]); } finally { if (previousGitHubToken === undefined) delete Bun.env.GITHUB_TOKEN; else Bun.env.GITHUB_TOKEN = previousGitHubToken; } }); it("explains how to authenticate after an anonymous GitHub API rate limit", async () => { const dir = await makeTempDir(); const targetPath = path.join(dir, binaryName); const fetchImpl = async () => new Response(null, { status: 403, statusText: "rate limit exceeded" }); await expect( updateViaBinaryAt(targetPath, "17.1.2", { binaryName, fetchImpl, githubToken: "", }), ).rejects.toThrow("retry later or set GITHUB_TOKEN or GH_TOKEN"); expect(await Bun.file(targetPath).exists()).toBe(false); }); }); describe("update-cli binary replacement", () => { it("restores the previous binary when the replacement fails verification", async () => { const dir = await makeTempDir(); const targetPath = path.join(dir, "omp"); const tempPath = `${targetPath}.new`; const backupPath = `${targetPath}.bak`; await Bun.write(targetPath, "old binary"); await Bun.write(tempPath, "broken binary"); await expect( replaceBinaryForUpdate({ targetPath, tempPath, backupPath, expectedVersion: "15.1.8", verifyInstalledVersion: async () => ({ ok: false, path: targetPath }), }), ).rejects.toThrow("restored previous omp binary"); expect(await Bun.file(targetPath).text()).toBe("old binary"); expect(await Bun.file(tempPath).exists()).toBe(false); expect(await Bun.file(backupPath).exists()).toBe(false); }); it("keeps the replacement only after it reports the expected version", async () => { const dir = await makeTempDir(); const targetPath = path.join(dir, "omp"); const tempPath = `${targetPath}.new`; const backupPath = `${targetPath}.bak`; await Bun.write(targetPath, "old binary"); await Bun.write(tempPath, "new binary"); await replaceBinaryForUpdate({ targetPath, tempPath, backupPath, expectedVersion: "15.1.8", verifyInstalledVersion: async () => ({ ok: true, actual: "15.1.8", path: targetPath }), }); expect(await Bun.file(targetPath).text()).toBe("new binary"); expect(await Bun.file(tempPath).exists()).toBe(false); expect(await Bun.file(backupPath).exists()).toBe(false); }); }); describe("update-cli binary replacement on locked backups", () => { it("treats an EPERM on backup cleanup as a successful, completed update", async () => { // Regression: on Windows the binary moved aside during the swap is still // the running process image, so unlinking it throws EPERM. That cleanup // failure must not turn a verified swap into "Update failed" (issue #845). const dir = await makeTempDir(); const targetPath = path.join(dir, "omp.exe"); const tempPath = `${targetPath}.new`; const backupPath = `${targetPath}.1700000000000.4242.bak`; await Bun.write(targetPath, "old binary"); await Bun.write(tempPath, "new binary"); const realUnlink = nodeFs.promises.unlink.bind(nodeFs.promises); const spy = spyOn(nodeFs.promises, "unlink").mockImplementation(async (p: nodeFs.PathLike) => { if (String(p) === backupPath) { const err = new Error(`EPERM: operation not permitted, unlink '${p}'`) as NodeJS.ErrnoException; err.code = "EPERM"; throw err; } return realUnlink(p); }); try { const result = await replaceBinaryForUpdate({ targetPath, tempPath, backupPath, expectedVersion: "15.1.8", verifyInstalledVersion: async () => ({ ok: true, actual: "15.1.8", path: targetPath }), }); expect(result.ok).toBe(true); } finally { spy.mockRestore(); } // New binary is installed and the temp consumed even though the locked // backup survives; the next run's sweep reclaims it once it is unlocked. expect(await Bun.file(targetPath).text()).toBe("new binary"); expect(await Bun.file(tempPath).exists()).toBe(false); expect(await Bun.file(backupPath).text()).toBe("old binary"); }); }); describe("update-cli stale update artifact sweep", () => { it("reclaims timestamped and legacy backups and orphaned temps while sparing in-progress temps and unrelated files", async () => { const dir = await makeTempDir(); const targetPath = path.join(dir, "omp.exe"); await Bun.write(targetPath, "current binary"); await Bun.write(`${targetPath}.bak`, "legacy backup"); await Bun.write(`${targetPath}.1700000000000.4242.bak`, "timestamped backup"); await Bun.write(`${targetPath}.1800000000000.99.bak`, "another backup"); // Orphaned temp files from a hard-killed download: reaped once older than // the download window. Legacy fixed name and timestamped name both count. const stale = new Date(Date.now() - 60 * 60 * 1000); await Bun.write(`${targetPath}.new`, "legacy temp"); await fs.utimes(`${targetPath}.new`, stale, stale); await Bun.write(`${targetPath}.1700000000000.4242.new`, "timestamped temp"); await fs.utimes(`${targetPath}.1700000000000.4242.new`, stale, stale); // Must survive: a fresh temp still belongs to a concurrent, in-progress // download (unique per attempt), plus foreign basenames and non-numeric // middle segments. await Bun.write(`${targetPath}.9999999999999.7.new`, "in-progress temp"); await Bun.write(path.join(dir, "notes.bak"), "keep me"); await Bun.write(`${targetPath}.config.bak`, "keep me too"); await Bun.write(`${targetPath}.config.new`, "keep me three"); await sweepStaleUpdateArtifacts(targetPath); expect(await Bun.file(targetPath).exists()).toBe(true); expect(await Bun.file(`${targetPath}.bak`).exists()).toBe(false); expect(await Bun.file(`${targetPath}.1700000000000.4242.bak`).exists()).toBe(false); expect(await Bun.file(`${targetPath}.1800000000000.99.bak`).exists()).toBe(false); expect(await Bun.file(`${targetPath}.new`).exists()).toBe(false); expect(await Bun.file(`${targetPath}.1700000000000.4242.new`).exists()).toBe(false); expect(await Bun.file(`${targetPath}.9999999999999.7.new`).exists()).toBe(true); expect(await Bun.file(path.join(dir, "notes.bak")).exists()).toBe(true); expect(await Bun.file(`${targetPath}.config.bak`).exists()).toBe(true); expect(await Bun.file(`${targetPath}.config.new`).exists()).toBe(true); }); }); describe("update-cli binary-only release gating", () => { it("honors an explicit omp.dist field from the registry manifest", () => { expect(resolveReleaseDist({ omp: { dist: "binary" } })).toBe("binary"); expect(resolveReleaseDist({ omp: { dist: "npm" } })).toBe("npm"); }); it("treats unknown dist values as binary-only", () => { expect(resolveReleaseDist({ omp: { dist: "cargo" } })).toBe("binary"); }); it("returns undefined when the manifest carries no dist field", () => { expect(resolveReleaseDist({ version: "1.2.3" })).toBeUndefined(); expect(resolveReleaseDist({ omp: {} })).toBeUndefined(); expect(resolveReleaseDist(undefined)).toBeUndefined(); }); it("forces binary updates when dist is binary regardless of version", () => { expect(shouldForceBinaryUpdate({ version: "1.2.3", dist: "binary" }, "1.2.2")).toBe(true); }); it("allows package-manager updates across majors when dist is explicitly npm", () => { expect(shouldForceBinaryUpdate({ version: "2.0.0", dist: "npm" }, "1.9.0")).toBe(false); }); it("forces binary updates on a major bump without a dist field", () => { expect(shouldForceBinaryUpdate({ version: "2.0.0" }, "1.9.0")).toBe(true); expect(shouldForceBinaryUpdate({ version: "2.0.0-rc.1" }, "1.9.0")).toBe(true); }); it("keeps package-manager updates within the same major and on downgrades", () => { expect(shouldForceBinaryUpdate({ version: "1.10.0" }, "1.9.0")).toBe(false); expect(shouldForceBinaryUpdate({ version: "1.0.0" }, "2.0.0")).toBe(false); }); }); describe("update-cli script-shim takeover", () => { const version = "18.0.0"; const binaryName = "omp-windows-x64.exe"; const url = `https://github.com/can1357/oh-my-pi/releases/download/v${version}/${binaryName}`; function makeFetch(content: string): (input: string | URL | Request) => Promise { const digest = `sha256:${createHash("sha256").update(content).digest("hex")}`; return async (input: string | URL | Request): Promise => { const requestUrl = String(input); if (requestUrl.startsWith("https://api.github.com/")) { return new Response( JSON.stringify({ tag_name: `v${version}`, draft: false, prerelease: false, assets: [ { name: binaryName, state: "uploaded", size: Buffer.byteLength(content), digest, browser_download_url: url, }, ], }), ); } if (requestUrl === url) return new Response(content); throw new Error(`Unexpected request: ${requestUrl}`); }; } const shims: Record = { omp: "#!/bin/sh\nnode omp.js\n", "omp.cmd": "@node omp.js %*\n", "omp.ps1": "node omp.js @args\n", }; async function writeShims(dir: string): Promise { for (const name in shims) { await Bun.write(path.join(dir, name), shims[name]); } } it("installs omp.exe beside the shims and retires them", async () => { const dir = await makeTempDir(); await writeShims(dir); // Real executable, no injected verifier: the takeover must verify the // exe by explicit path — $which cached the shim path before it was // renamed away, so a PATH re-resolution would fail here. const exe = `#!/bin/sh\necho omp/${version}\n`; await updateViaShimTakeover(path.join(dir, "omp.cmd"), version, { binaryName, fetchImpl: makeFetch(exe), githubToken: "test-token", }); expect(await Bun.file(path.join(dir, "omp.exe")).text()).toBe(exe); for (const name in shims) { expect(await Bun.file(path.join(dir, name)).exists()).toBe(false); } const residue = (await fs.readdir(dir)).filter(name => name.endsWith(".bak") || name.endsWith(".new")); expect(residue).toEqual([]); }); it("restores the shims and removes the exe when the exe reports the wrong version", async () => { const dir = await makeTempDir(); await writeShims(dir); // Executable runs but reports the previous version -> full rollback. const exe = "#!/bin/sh\necho omp/17.2.12\n"; await expect( updateViaShimTakeover(path.join(dir, "omp.cmd"), version, { binaryName, fetchImpl: makeFetch(exe), githubToken: "test-token", }), ).rejects.toThrow(/still reports 17\.2\.12 \(expected 18\.0\.0\); restored previous omp launcher/); expect(await Bun.file(path.join(dir, "omp.exe")).exists()).toBe(false); for (const name in shims) { expect(await Bun.file(path.join(dir, name)).text()).toBe(shims[name]); } const residue = (await fs.readdir(dir)).filter(name => name.endsWith(".bak") || name.endsWith(".new")); expect(residue).toEqual([]); }); function renameLockingPs1(): Mock { const realRename = nodeFs.promises.rename; return spyOn(nodeFs.promises, "rename").mockImplementation(async (from, to) => { if (path.basename(String(from)) === "omp.ps1") { throw Object.assign(new Error("EPERM: file is locked"), { code: "EPERM" }); } return await realRename(from, to); }); } it("rewrites an immovable precedence-winning shim as a forwarder to the exe", async () => { const dir = await makeTempDir(); await writeShims(dir); const exe = `#!/bin/sh\necho omp/${version}\n`; const renameSpy = renameLockingPs1(); try { await updateViaShimTakeover(path.join(dir, "omp.cmd"), version, { binaryName, fetchImpl: makeFetch(exe), githubToken: "test-token", }); } finally { renameSpy.mockRestore(); } expect(await Bun.file(path.join(dir, "omp.exe")).text()).toBe(exe); expect(await Bun.file(path.join(dir, "omp")).exists()).toBe(false); expect(await Bun.file(path.join(dir, "omp.cmd")).exists()).toBe(false); // PowerShell resolves .ps1 before .exe: the locked shim must now exec // the new binary instead of keeping its old body. expect(await Bun.file(path.join(dir, "omp.ps1")).text()).toContain('& "$PSScriptRoot\\omp.exe" @args'); }); it("restores a forwarded shim's original body when verification fails", async () => { const dir = await makeTempDir(); await writeShims(dir); const exe = "#!/bin/sh\necho omp/17.2.12\n"; const renameSpy = renameLockingPs1(); try { await expect( updateViaShimTakeover(path.join(dir, "omp.cmd"), version, { binaryName, fetchImpl: makeFetch(exe), githubToken: "test-token", }), ).rejects.toThrow("restored previous omp launcher"); } finally { renameSpy.mockRestore(); } expect(await Bun.file(path.join(dir, "omp.exe")).exists()).toBe(false); for (const name in shims) { expect(await Bun.file(path.join(dir, name)).text()).toBe(shims[name]); } }); }); describe("update-cli concurrent binary updates", () => { const version = "999.0.0"; const binaryName = "omp-linux-x64"; const url = `https://github.com/can1357/oh-my-pi/releases/download/v${version}/${binaryName}`; const payload = Buffer.alloc(2048, 0x41); const digest = `sha256:${createHash("sha256").update(payload).digest("hex")}`; function metadata(): Response { return Response.json({ tag_name: `v${version}`, draft: false, prerelease: false, assets: [{ name: binaryName, state: "uploaded", size: payload.byteLength, digest, browser_download_url: url }], }); } const fastFetch = async (input: string | URL | Request): Promise => { const requestUrl = String(input); if (requestUrl.startsWith("https://api.github.com/")) return metadata(); if (requestUrl === url) return new Response(payload); throw new Error(`Unexpected request: ${requestUrl}`); }; const verify = async () => ({ ok: true, actual: version }); async function prepare(): Promise<{ dir: string; targetPath: string }> { const loadedTheme = await getThemeByName("dark"); if (!loadedTheme) throw new Error("theme unavailable"); setThemeInstance(loadedTheme); vi.spyOn(console, "log").mockImplementation(() => {}); const dir = await makeTempDir(); const targetPath = path.join(dir, "omp"); await Bun.write(targetPath, "old binary"); return { dir, targetPath }; } // Regression for #8434: two overlapping `omp update` runs must not share a // temp path. Run A downloads slowly and only finishes after run B has fully // installed. With the old fixed `.new` temp name, B's pre-download // unlink deleted A's temp file, so A's chmod failed with ENOENT even though // its size + digest passed. Unique temp paths keep the two runs independent. it("lets an overlapping slow run install after a fast run completes, instead of failing chmod with ENOENT", async () => { const { dir, targetPath } = await prepare(); const aWroteFirstChunk = Promise.withResolvers(); const letAFinish = Promise.withResolvers(); const slowFetch = async (input: string | URL | Request): Promise => { const requestUrl = String(input); if (requestUrl.startsWith("https://api.github.com/")) return metadata(); if (requestUrl === url) { return new Response( new ReadableStream({ async start(controller) { controller.enqueue(payload.subarray(0, 1024)); aWroteFirstChunk.resolve(); await letAFinish.promise; controller.enqueue(payload.subarray(1024)); controller.close(); }, }), ); } throw new Error(`Unexpected request: ${requestUrl}`); }; const runA = updateViaBinaryAt(targetPath, version, { binaryName, fetchImpl: slowFetch, verifyInstalledVersion: verify, }); await aWroteFirstChunk.promise; await updateViaBinaryAt(targetPath, version, { binaryName, fetchImpl: fastFetch, verifyInstalledVersion: verify, }); letAFinish.resolve(); await runA; expect(await Bun.file(targetPath).bytes()).toEqual(new Uint8Array(payload)); const residue = (await fs.readdir(dir)).filter(name => name.endsWith(".new")); expect(residue).toEqual([]); }); // Regression: a failed verification must still roll back its own backup even // when another update completes while it is held. The per-target lock // serializes the swap + sweep, so the concurrent run's sweep cannot reclaim // the live backup before the rollback renames it back. it("rolls back its backup when verification fails while another update runs", async () => { const { dir, targetPath } = await prepare(); const enteredVerify = Promise.withResolvers(); const releaseVerify = Promise.withResolvers(); const failingVerify = async () => { enteredVerify.resolve(); await releaseVerify.promise; return { ok: false, actual: "0.0.0", path: targetPath }; }; const runA = updateViaBinaryAt(targetPath, version, { binaryName, fetchImpl: fastFetch, verifyInstalledVersion: failingVerify, }); await enteredVerify.promise; const runB = updateViaBinaryAt(targetPath, version, { binaryName, fetchImpl: fastFetch, verifyInstalledVersion: verify, }); releaseVerify.resolve(); await expect(runA).rejects.toThrow(/still reports 0\.0\.0 \(expected 999\.0\.0\)/); await runB; expect(await Bun.file(targetPath).bytes()).toEqual(new Uint8Array(payload)); const residue = (await fs.readdir(dir)).filter(name => name.endsWith(".bak") || name.endsWith(".new")); expect(residue).toEqual([]); }); });