Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion src/cache-cleaner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 === ".";
}
Expand Down Expand Up @@ -84,7 +86,7 @@ export class CacheCleaner {

private async removeTarget(target: string, removed: string[], warnings: string[], force: boolean): Promise<void> {
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);
Expand Down
4 changes: 2 additions & 2 deletions src/installer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, "..");
}

Expand Down Expand Up @@ -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);
Expand Down
26 changes: 13 additions & 13 deletions tests/cache-cleaner.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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);
});
});
22 changes: 20 additions & 2 deletions tests/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });

Expand All @@ -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 });
}
Expand Down Expand Up @@ -278,3 +282,17 @@ describe("cli clear-cache", () => {
expect(run.stdout).toContain("clear-cache");
});
});

async function spawnCwdHolder(dir: string): Promise<Bun.Subprocess> {
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;
}
32 changes: 13 additions & 19 deletions tests/installer.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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 () => {
Expand Down
12 changes: 8 additions & 4 deletions tests/tarball.test.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,28 @@
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;
}

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<string, unknown> | 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}`);
}
Expand Down
Loading