import { mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { homedir, tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { parseChecksum, provision, sha256, type ProvisionEffects } from "./install.js"; import { archiveFor, assetFor, eagerTools, platformKey, toolsFor, toolsForExtension, TOOLS } from "./manifest.js"; const PAYLOAD = Buffer.from("binary contents"); function effects(overrides: Partial = {}): ProvisionEffects { return { which: () => null, fetchBytes: async () => PAYLOAD, fetchText: async () => "", run: () => {}, extract: (_archive, _kind, binary, dir) => { const path = join(dir, binary); writeFileSync(path, PAYLOAD); return path; }, ...overrides, }; } function release(version: string, assets: string[]): string { return JSON.stringify({ tag_name: version, assets: assets.map((name) => ({ name, browser_download_url: `https://example.test/${name}` })), }); } describe("provisioning decisions", () => { let dir: string; beforeEach(() => { dir = mkdtempSync(join(tmpdir(), "provision-test-")); }); afterEach(() => { rmSync(dir, { recursive: true, force: true }); }); it("never touches the network when the binary already resolves", async () => { const fetchBytes = vi.fn(); const outcome = await provision( effects({ which: () => "/usr/bin/rg", fetchBytes }), toolsFor("rg"), dir, ); expect(outcome).toEqual({ status: "present", binary: "rg", path: "/usr/bin/rg" }); expect(fetchBytes).not.toHaveBeenCalled(); }); it("verifies a published digest and records that it did", async () => { const asset = assetFor(toolsFor("rg")[0], platformKey(), "15.2.0")!; const outcome = await provision( effects({ fetchText: async (url) => url.endsWith(".sha256") ? `${sha256(PAYLOAD)} ${asset}` : release("15.2.0", [asset, `${asset}.sha256`]), }), toolsFor("rg"), dir, ); expect(outcome.status).toBe("installed"); expect(outcome).toMatchObject({ verification: "checksum", version: "15.2.0" }); }); it("refuses to install when the digest does not match", async () => { const asset = assetFor(toolsFor("rg")[0], platformKey(), "15.2.0")!; const outcome = await provision( effects({ fetchText: async (url) => url.endsWith(".sha256") ? `${"0".repeat(64)} ${asset}` : release("15.2.0", [asset, `${asset}.sha256`]), }), toolsFor("rg"), dir, ); expect(outcome.status).toBe("failed"); expect((outcome as any).reason).toMatch(/digest mismatch/); }); it("reports verification honestly as none when the publisher ships no digest", async () => { // rust-analyzer publishes release assets without a checksum file. Claiming // otherwise would misrepresent what was actually checked. const standalone = toolsFor("rust-analyzer").find((tool) => tool.source.kind === "github")!; const asset = assetFor(standalone, platformKey(), "2026-09-21")!; const outcome = await provision( effects({ fetchText: async () => release("2026-09-21", [asset]) }), [standalone], dir, ); expect(outcome).toMatchObject({ status: "installed", verification: "none" }); }); it("falls through to the next candidate when a toolchain is absent", async () => { const candidates = toolsFor("rust-analyzer"); expect(candidates[0].source.kind).toBe("toolchain"); const asset = assetFor(candidates[1], platformKey(), "2026-09-21")!; const run = vi.fn(); const outcome = await provision( effects({ which: () => null, run, fetchText: async () => release("2026-09-21", [asset]) }), candidates, dir, ); // rustup is absent, so the toolchain candidate is skipped without running. expect(run).not.toHaveBeenCalled(); expect(outcome).toMatchObject({ status: "installed", verification: "none" }); }); it("reports the rustup-shim shape: the toolchain succeeds, the binary still is not there", async () => { const toolchain = toolsFor("rust-analyzer").find((tool) => tool.source.kind === "toolchain")!; const outcome = await provision( effects({ which: (cmd) => (cmd === "rustup" ? "/home/u/.cargo/bin/rustup" : null) }), [toolchain], dir, ); expect(outcome.status).toBe("failed"); expect((outcome as any).reason).toMatch(/still not on PATH/); }); it("says which platform is unserved rather than failing opaquely", async () => { const outcome = await provision( effects({ fetchText: async () => release("15.2.0", ["ripgrep-15.2.0-sparc-unknown-solaris.tar.gz"]) }), toolsFor("rg"), dir, ); expect(outcome.status).toBe("failed"); expect((outcome as any).reason).toMatch(/no asset named|publishes no asset/); }); it("gives up with every candidate's reason, not just the last", async () => { const outcome = await provision( effects({ fetchText: async () => { throw new Error("network unreachable"); } }), toolsFor("rust-analyzer"), dir, ); expect(outcome.status).toBe("failed"); expect((outcome as any).reason).toMatch(/rustup is not on PATH/); expect((outcome as any).reason).toMatch(/network unreachable/); }); }); describe("the digest format publishers actually ship", () => { it("accepts the sha256sum form and the bare digest", () => { const digest = "a".repeat(64); expect(parseChecksum(`${digest} ripgrep.tar.gz`)).toBe(digest); expect(parseChecksum(`${digest}\n`)).toBe(digest); expect(parseChecksum(`${digest.toUpperCase()} f`)).toBe(digest); }); it("rejects anything that is not a digest instead of guessing", () => { expect(parseChecksum("")).toBeNull(); expect(parseChecksum("not a checksum")).toBeNull(); expect(parseChecksum("abc123 short.tar.gz")).toBeNull(); expect(parseChecksum("")).toBeNull(); }); }); describe("the manifest", () => { it("provisions search and the code graph eagerly, language servers on demand", () => { expect(eagerTools().map((tool) => tool.binary).sort()).toEqual(["codebase-memory-mcp", "rg"]); for (const tool of TOOLS) { if (!tool.eager) expect(tool.extensions?.length ?? 0).toBeGreaterThan(0); } }); it("routes a file extension to the servers that handle it", () => { expect(toolsForExtension(".rs").map((tool) => tool.binary)).toEqual(["rust-analyzer", "rust-analyzer"]); expect(toolsForExtension(".go").map((tool) => tool.binary)).toEqual(["gopls"]); expect(toolsForExtension(".ts")[0].binary).toBe("typescript-language-server"); // A language pi-pi does not provision is distinct from one that failed. expect(toolsForExtension(".java")).toEqual([]); }); it("publishes an asset for every platform pi-pi is expected to run on", () => { for (const key of ["linux-x64", "linux-arm64", "darwin-x64", "darwin-arm64", "win32-x64"]) { expect(assetFor(toolsFor("rg")[0], key, "15.2.0")).toBeTruthy(); } }); it("prefers the toolchain over a standalone binary where one owns the version", () => { // A standalone rust-analyzer beside a rustup toolchain can disagree with // the project's Rust version, so rustup is tried first. expect(toolsFor("rust-analyzer")[0].source.kind).toBe("toolchain"); }); }); describe("landing the binary", () => { let dir: string; let staging: string; beforeEach(() => { // Destination and staging must sit on DIFFERENT filesystems, which is the // real arrangement: pi-pi installs under the user's home and extractors // stage in the system temp dir. Putting both in tmpdir() is what let an // EXDEV failure ship green. dir = mkdtempSync(join(homedir(), ".pi-pi-provision-test-")); staging = mkdtempSync(join(tmpdir(), "provision-stage-")); }); afterEach(() => { rmSync(dir, { recursive: true, force: true }); rmSync(staging, { recursive: true, force: true }); }); // The extractor stages in a temp dir and the destination is under the user's // home. Those are routinely separate filesystems — /tmp as tmpfs is the Linux // default — and a bare rename across them fails with EXDEV, which no test // extracting straight into the destination can catch. it("installs from a staging directory on another filesystem", async () => { const outcome = await provision( effects({ fetchText: async () => release("1.0.0", ["rg-1.0.0-x86_64.tar.gz"]), extract: (_archive, _kind, binary) => { const path = join(staging, binary); writeFileSync(path, PAYLOAD); return path; }, }), [{ binary: "rg", purpose: "test", eager: true, source: { kind: "github", repo: "o/r", archive: "tar.gz", asset: { byPlatform: { [platformKey()]: "rg-{version}-x86_64.tar.gz" } }, }, verification: "checksum", }], dir, ); expect(outcome.status).toBe("installed"); expect(readFileSync(join(dir, "rg"))).toEqual(PAYLOAD); // Nothing half-written may survive under the name the agent will run. expect(readdirSync(dir)).toEqual(["rg"]); }); it("leaves no partial file behind when landing fails", async () => { const outcome = await provision( effects({ fetchText: async () => release("1.0.0", ["rg-1.0.0-x86_64.tar.gz"]), extract: () => join(staging, "never-written"), }), [{ binary: "rg", purpose: "test", eager: true, source: { kind: "github", repo: "o/r", archive: "tar.gz", asset: { byPlatform: { [platformKey()]: "rg-{version}-x86_64.tar.gz" } }, }, verification: "checksum", }], dir, ); expect(outcome.status).toBe("failed"); expect(readdirSync(dir)).toEqual([]); }); }); describe("per-platform packing", () => { // rust-analyzer ships .gz everywhere except Windows, where it ships .zip. // Taking the tool's default format would hand zip bytes to gunzip, so the // Windows install fails at extraction — on the platform this all came from. it("unpacks each platform's asset in the format that platform publishes", () => { const tool = toolsFor("rust-analyzer").find((candidate) => candidate.source.kind === "github")!; expect(assetFor(tool, "win32-x64", "2026-09-21")).toMatch(/\.zip$/); expect(archiveFor(tool, "win32-x64")).toBe("zip"); expect(assetFor(tool, "linux-x64", "2026-09-21")).toMatch(/\.gz$/); expect(archiveFor(tool, "linux-x64")).toBe("gz"); }); it("falls back to the tool's own format where no platform overrides it", () => { const tool = toolsFor("rg").find((candidate) => candidate.source.kind === "github")!; for (const key of ["linux-x64", "win32-x64", "darwin-arm64"]) { expect(archiveFor(tool, key)).toBe(key.startsWith("win32") ? "zip" : "tar.gz"); } }); it("every declared asset's suffix matches the archive kind chosen for it", () => { for (const tool of TOOLS) { if (tool.source.kind !== "github") continue; for (const key of Object.keys(tool.source.asset.byPlatform)) { const asset = assetFor(tool, key, "1.0.0")!; const kind = archiveFor(tool, key); const expected = kind === "tar.gz" ? ".tar.gz" : kind === "zip" ? ".zip" : kind === "gz" ? ".gz" : ""; // Named so a failure says which tool and platform disagree. if (expected) expect(`${tool.binary}/${key} ends ${expected}`).toBe(`${tool.binary}/${key} ends ${asset.slice(asset.lastIndexOf(expected)) === expected ? expected : asset}`); } } }); }); describe("deferring to what the machine already has", () => { let dir: string; beforeEach(() => { dir = mkdtempSync(join(tmpdir(), "provision-host-")); }); afterEach(() => { rmSync(dir, { recursive: true, force: true }); }); // The user's binary matches their toolchain; a downloaded one need not. Any // network call here is a bug, not an inefficiency: it can replace a working // rustup component with a release that disagrees with the project's Rust. it("touches no network when the binary is already on PATH", async () => { const fetchBytes = vi.fn(async () => PAYLOAD); const fetchText = vi.fn(async () => ""); const run = vi.fn(); const outcome = await provision( effects({ which: () => "/usr/bin/rg", fetchBytes, fetchText, run }), toolsFor("rg"), dir, ); expect(outcome).toEqual({ status: "present", binary: "rg", path: "/usr/bin/rg" }); expect(fetchBytes).not.toHaveBeenCalled(); expect(fetchText).not.toHaveBeenCalled(); expect(run).not.toHaveBeenCalled(); expect(readdirSync(dir)).toEqual([]); }); it("defers to a host binary for every tool it knows how to install", async () => { for (const tool of TOOLS) { const fetchText = vi.fn(async () => ""); const run = vi.fn(); const outcome = await provision( effects({ which: () => `/usr/bin/${tool.binary}`, fetchText, run }), [tool], dir, ); expect(outcome.status).toBe("present"); // npm and toolchain installs happen through run(), not a fetch. expect(run).not.toHaveBeenCalled(); expect(fetchText).not.toHaveBeenCalled(); } }); });