diff --git a/src/cache-cleaner.ts b/src/cache-cleaner.ts index 2351428..0c411b5 100644 --- a/src/cache-cleaner.ts +++ b/src/cache-cleaner.ts @@ -21,6 +21,8 @@ export interface ClearCacheOutcome { export class CacheCleaner { private readonly cache = new NpmCache(); + constructor(private readonly rmFn: typeof rm = rm) {} + public isUnsafePackageName(name: string): boolean { return name.includes("/") || name.includes("\\") || name.includes("..") || name === "."; } @@ -84,7 +86,7 @@ export class CacheCleaner { private async removeTarget(target: string, removed: string[], warnings: string[], force: boolean): Promise { try { - await rm(target, { recursive: true, force }); + await this.rmFn(target, { recursive: true, force }); removed.push(target); } catch (error) { const message = error instanceof Error ? error.message : String(error); diff --git a/src/installer.ts b/src/installer.ts index 713939f..de9fa94 100644 --- a/src/installer.ts +++ b/src/installer.ts @@ -72,7 +72,7 @@ export class Installer { private readonly cache = new NpmCache(); private readonly assetsDir: string; - constructor(assetsDir: string | null = null) { + constructor(assetsDir: string | null = null, private readonly rmFn: typeof rm = rm) { this.assetsDir = assetsDir ?? path.join(import.meta.dirname, ".."); } @@ -171,7 +171,7 @@ export class Installer { for (const target of targets) { if (!(await exists(target))) continue; try { - await rm(target, { recursive: true }); + await this.rmFn(target, { recursive: true }); removed.push(target); } catch (error) { const message = error instanceof Error ? error.message : String(error); diff --git a/tests/cache-cleaner.test.ts b/tests/cache-cleaner.test.ts index eae16fa..7e1b3b2 100644 --- a/tests/cache-cleaner.test.ts +++ b/tests/cache-cleaner.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test, beforeEach, afterEach } from "bun:test"; import { existsSync } from "node:fs"; -import { chmod, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { CacheCleaner } from "../src/cache-cleaner"; @@ -188,17 +188,17 @@ describe("clearCache failure tolerance", () => { test("warns and continues when a removal fails", async () => { const kept = await seedCachedPackage(cacheRoot(), "opencode-architect"); const removed = await seedCachedPackage(cacheRoot(), "opencode-architect@0.1.0"); - await chmod(kept, 0o500); - - try { - const outcome = await cleaner.clear({ packageName: null, all: false, yes: false, dryRun: false }); - - expect(outcome.removed).toContain(removed); - expect(existsSync(removed)).toBe(false); - expect(outcome.warnings.length).toBe(1); - expect(outcome.warnings[0]).toContain(kept); - } finally { - await chmod(kept, 0o700).catch(() => {}); - } + const failingCleaner = new CacheCleaner(async (target, options) => { + if (target === kept) throw new Error("EPERM: simulated removal failure"); + await rm(target, options); + }); + + const outcome = await failingCleaner.clear({ packageName: null, all: false, yes: false, dryRun: false }); + + expect(outcome.removed).toContain(removed); + expect(existsSync(removed)).toBe(false); + expect(existsSync(kept)).toBe(true); + expect(outcome.warnings.length).toBe(1); + expect(outcome.warnings[0]).toContain(kept); }); }); diff --git a/tests/cli.test.ts b/tests/cli.test.ts index 137eb68..df6f228 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -107,7 +107,10 @@ describe("cli", () => { ); await mkdir(blocked, { recursive: true }); await writeFile(path.join(blocked, "index.ts"), "cached"); - await chmod(blocked, 0o500); + const holder = process.platform === "win32" + ? await spawnCwdHolder(blocked) + : null; + if (holder === null) await chmod(blocked, 0o500); try { const run = await runCli(["install"], dir, { XDG_CACHE_HOME: cacheDir }); @@ -116,7 +119,8 @@ describe("cli", () => { expect(run.stderr).toContain(`Could not clear cached package ${path.join(cacheDir, "opencode", "npm", "opencode-architect@latest")}`); expect(existsSync(path.join(cacheDir, "opencode", "npm", "opencode-architect"))).toBe(false); } finally { - await chmod(blocked, 0o700).catch(() => {}); + if (holder) holder.kill(); + else await chmod(blocked, 0o700).catch(() => {}); await rm(dir, { recursive: true, force: true }); await rm(cacheDir, { recursive: true, force: true }); } @@ -278,3 +282,17 @@ describe("cli clear-cache", () => { expect(run.stdout).toContain("clear-cache"); }); }); + +async function spawnCwdHolder(dir: string): Promise { + const readyFile = path.join(dir, ".holder-ready"); + const holder = Bun.spawn( + [process.execPath, "-e", "require('node:fs').writeFileSync(process.env.READY_FILE, '1'); setInterval(() => {}, 1e9)"], + { cwd: dir, env: { ...process.env, READY_FILE: readyFile }, stdout: "ignore", stderr: "ignore" }, + ); + const deadline = Date.now() + 5000; + while (!existsSync(readyFile)) { + if (Date.now() > deadline || holder.exitCode !== null) throw new Error("cwd holder failed to start"); + await new Promise((resolve) => setTimeout(resolve, 25)); + } + return holder; +} diff --git a/tests/installer.test.ts b/tests/installer.test.ts index 7613620..d0d0b62 100644 --- a/tests/installer.test.ts +++ b/tests/installer.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test, beforeEach, afterEach } from "bun:test"; import { existsSync } from "node:fs"; -import { chmod, mkdir, mkdtemp, readFile, readdir, rename, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, readdir, rename, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { Installer, contentHash, type Manifest, type Scope } from "../src/installer"; @@ -471,25 +471,19 @@ describe("Installer.install cache pruning", () => { test("removal failure warns but the install still succeeds", async () => { await seedCache(); - const blocked = path.join( - cacheRoot(), - "opencode-architect@latest", - "1738848000000", - "node_modules", - "opencode-architect", - ); - await chmod(blocked, 0o500); - try { - const outcome = await install("local"); + const blocked = path.join(cacheRoot(), "opencode-architect@latest"); + const failingInstaller = new Installer(null, async (target, options) => { + if (target === blocked) throw new Error("EPERM: simulated removal failure"); + await rm(target, options); + }); - expect(outcome.action).toBe("installed"); - expect(outcome.cacheWarnings).toHaveLength(1); - expect(outcome.cacheWarnings[0]).toContain(path.join(cacheRoot(), "opencode-architect@latest")); - expect(existsSync(path.join(cacheRoot(), "opencode-architect"))).toBe(false); - expect(existsSync(path.join(cacheRoot(), "opencode-architect@latest"))).toBe(true); - } finally { - await chmod(blocked, 0o700).catch(() => {}); - } + const outcome = await failingInstaller.install("local", { force: false, mode: "plugin", projectDir }); + + expect(outcome.action).toBe("installed"); + expect(outcome.cacheWarnings).toHaveLength(1); + expect(outcome.cacheWarnings[0]).toContain(blocked); + expect(existsSync(path.join(cacheRoot(), "opencode-architect"))).toBe(false); + expect(existsSync(blocked)).toBe(true); }); test("cache root honors XDG_CACHE_HOME with no cache present", async () => { diff --git a/tests/tarball.test.ts b/tests/tarball.test.ts index e913ee8..074a445 100644 --- a/tests/tarball.test.ts +++ b/tests/tarball.test.ts @@ -1,9 +1,11 @@ import { describe, expect, test } from "bun:test"; import { execSync } from "node:child_process"; import { readFileSync, readdirSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; const packageJson = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf-8")); -const ROOT = new URL("..", import.meta.url).pathname; +const ROOT = fileURLToPath(new URL("..", import.meta.url)); interface PackFile { path: string; @@ -11,14 +13,16 @@ interface PackFile { function packDryRun(): PackFile[] { const output = execSync("npm pack --dry-run --json", { encoding: "utf-8", cwd: ROOT }); - return JSON.parse(output)[0].files as PackFile[]; + const parsed = JSON.parse(output) as Record | unknown[]; + const entry = Array.isArray(parsed) ? parsed[0] : parsed[packageJson.name]; + return (entry as { files: PackFile[] }).files; } function walkDisk(entry: string): string[] { - const base = `${ROOT}${entry}`; + const base = path.join(ROOT, entry); const files: string[] = []; const visit = (relative: string) => { - for (const item of readdirSync(`${base}${relative}`, { withFileTypes: true })) { + for (const item of readdirSync(path.join(base, relative), { withFileTypes: true })) { if (item.isDirectory()) visit(`${relative}/${item.name}`); else files.push(`${entry}${relative}/${item.name}`); }