import fs from "fs/promises" import path from "path" import { pathToFileURL } from "url" import { describe, expect, test } from "bun:test" import { Effect } from "effect" import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder" import { Global } from "@opencode/util/global" import { Npm } from "@opencode/util/npm" import { tmpdir } from "./fixture/tmpdir" const win = process.platform === "win32" const writePackage = (dir: string, pkg: Record) => Bun.write( path.join(dir, "package.json"), JSON.stringify({ version: "1.0.0", ...pkg, }), ) const npmLayer = (cache: string) => AppNodeBuilder.build(Npm.node, [Global.node.replace(Global.layerWith({ cache, state: path.join(cache, "state") }))]) async function createGitFixture(directory: string) { const repository = path.join(directory, "repository") await fs.mkdir(path.join(repository, "dependency"), { recursive: true }) await writePackage(repository, { name: "fixture-git-plugin", exports: "./index.js", dependencies: { "fixture-dependency": "file:./dependency" }, }) await writePackage(path.join(repository, "dependency"), { name: "fixture-dependency", exports: "./index.js" }) await Bun.write(path.join(repository, "index.js"), "export default { root: true }\n") await Bun.write(path.join(repository, "dependency", "index.js"), "export const dependency = true\n") const subdirectory = path.join(repository, "packages", "subdirectory-plugin") await fs.mkdir(path.join(subdirectory, "dependency"), { recursive: true }) await writePackage(subdirectory, { name: "fixture-subdirectory-plugin", exports: "./index.js", dependencies: { "fixture-subdirectory-dependency": "file:./dependency" }, }) await writePackage(path.join(subdirectory, "dependency"), { name: "fixture-subdirectory-dependency", exports: "./index.js", }) await Bun.write(path.join(subdirectory, "index.js"), "export default { subdirectory: true }\n") await Bun.write(path.join(subdirectory, "dependency", "index.js"), "export const dependency = true\n") await Bun.$`git init -q -b fixture-branch ${repository}` await Bun.$`git -C ${repository} add .` await Bun.$`git -C ${repository} -c user.name=fixture -c user.email=fixture@example.com commit -qm fixture` const commit = await Bun.$`git -C ${repository} rev-parse HEAD`.text().then((value) => value.trim()) return { repository, commit } } async function createRegistryFixture(directory: string) { const tarballs = new Map() for (const version of ["1.0.0", "1.1.0"]) { const root = path.join(directory, version) await fs.mkdir(path.join(root, "package"), { recursive: true }) await writePackage(path.join(root, "package"), { name: "@fixture/registry-plugin", version, exports: "./index.js", }) await Bun.write(path.join(root, "package", "index.js"), `export const version = "${version}"\n`) await Bun.$`tar -czf package.tgz package`.cwd(root) tarballs.set(version, await Bun.file(path.join(root, "package.tgz")).bytes()) } const state = { latest: "1.0.0", audits: 0 } const server = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch(request) { const url = new URL(request.url) if (url.pathname.startsWith("/-/npm/v1/security/")) { state.audits++ return Response.json({}) } if (decodeURIComponent(url.pathname) === "/@fixture/registry-plugin") return Response.json({ name: "@fixture/registry-plugin", "dist-tags": { latest: state.latest }, versions: Object.fromEntries( [...tarballs.keys()].map((version) => [ version, { name: "@fixture/registry-plugin", version, dist: { tarball: `${url.origin}/${version}.tgz` } }, ]), ), }) const tarball = tarballs.get(url.pathname.slice(1).replace(".tgz", "")) return tarball ? new Response(tarball) : new Response("missing", { status: 404 }) }, }) return { state, async configure(cache: string, spec: string) { const root = path.join(cache, "npm", await Npm.cacheKey(spec)) await fs.mkdir(root, { recursive: true }) await Bun.write( path.join(root, ".npmrc"), `registry=${server.url}\n@fixture:registry=${server.url}\ncache=${path.join(directory, "npm-cache")}\nfetch-retries=0\naudit=true\n`, ) return root }, async [Symbol.asyncDispose]() { await server.stop(true) }, } } describe("Npm.sanitize", () => { test("keeps normal scoped package specs unchanged", () => { expect(Npm.sanitize("@opencode/acme")).toBe("@opencode/acme") expect(Npm.sanitize("@opencode/acme@1.0.0")).toBe("@opencode/acme@1.0.0") expect(Npm.sanitize("prettier")).toBe("prettier") }) test("handles git https specs", () => { const spec = "acme@git+https://github.com/opencode/acme.git" const expected = win ? "acme@git+https_//github.com/opencode/acme.git" : spec expect(Npm.sanitize(spec)).toBe(expected) }) }) describe("Npm.isRegistryPackage", () => { test("accepts registry packages and rejects unsupported install targets", async () => { expect(await Npm.isRegistryPackage("plugin")).toBe(true) expect(await Npm.isRegistryPackage("@acme/plugin@beta")).toBe(true) expect(await Npm.isRegistryPackage("plugin@^1.2.0")).toBe(true) expect(await Npm.isRegistryPackage("./plugin")).toBe(false) expect(await Npm.isRegistryPackage("github:acme/plugin")).toBe(false) expect(await Npm.isRegistryPackage("alias@npm:plugin@1.0.0")).toBe(false) }) }) describe("Npm.isInstallablePackage", () => { test("accepts registry and npm-compatible Git specs", async () => { expect(await Npm.isInstallablePackage("plugin@^1.2.0")).toBe(true) expect(await Npm.isInstallablePackage("github:acme/plugin#main")).toBe(true) expect(await Npm.isInstallablePackage("git+ssh://git@github.com/acme/plugin.git#main")).toBe(true) expect(await Npm.isInstallablePackage("git@github.com:acme/plugin.git")).toBe(true) expect( await Npm.isInstallablePackage( "git+https://github.com/acme/plugins.git#0123456789abcdef0123456789abcdef01234567::path:packages/plugin", ), ).toBe(true) expect(await Npm.isInstallablePackage("./plugin")).toBe(false) expect(await Npm.isInstallablePackage("https://example.com/plugin.tgz")).toBe(false) expect(await Npm.isInstallablePackage("alias@npm:plugin@1.0.0")).toBe(false) }) }) describe("Npm.cacheKey", () => { test("canonicalizes registry keys and hashes Git specs", async () => { expect(await Npm.cacheKey("@opencode/acme@1.0.0")).toBe(Npm.sanitize("@opencode/acme@1.0.0")) expect(await Npm.cacheKey("plugin")).toBe(Npm.sanitize("plugin@latest")) expect(await Npm.cacheKey("@opencode/acme")).toBe(Npm.sanitize("@opencode/acme@latest")) const spec = "git+ssh://git@github.com/acme/plugin.git#main" expect(await Npm.cacheKey(spec)).toMatch(/^git-plugin-[a-f0-9]{12}$/) expect(await Npm.cacheKey(spec)).toBe(await Npm.cacheKey(spec)) expect(await Npm.cacheKey(`${spec}-other`)).not.toBe(await Npm.cacheKey(spec)) }) }) describe("Npm.add", () => { test("locates cached scoped package specs without reifying", async () => { await using tmp = await tmpdir() const spec = "@fixture/provider@1.0.0" const directory = path.join( tmp.path, "cache", "npm", await Npm.cacheKey(spec), "1000", "node_modules", "@fixture", "provider", ) await fs.mkdir(directory, { recursive: true }) await writePackage(directory, { name: "@fixture/provider", exports: "./index.js" }) await Bun.write(path.join(directory, "index.js"), "export const fixture = true\n") const entry = await Effect.gen(function* () { const npm = yield* Npm.Service return yield* npm.add(spec) }).pipe(Effect.scoped, Effect.provide(npmLayer(path.join(tmp.path, "cache"))), Effect.runPromise) expect(entry.directory).toBe(directory) expect(entry.name).toBe("@fixture/provider") }) test("reifies when package cache directory exists without the package installed", async () => { await using tmp = await tmpdir() await fs.mkdir(path.join(tmp.path, "fixture-provider")) await writePackage(path.join(tmp.path, "fixture-provider"), { name: "fixture-provider", exports: { ".": "./index.js", "./tui": "./tui.js", }, }) await Bun.write(path.join(tmp.path, "fixture-provider", "index.js"), "export const fixture = true\n") await Bun.write(path.join(tmp.path, "fixture-provider", "tui.js"), "export const tui = true\n") const spec = `fixture-provider@file:${path.join(tmp.path, "fixture-provider")}` await fs.mkdir(path.join(tmp.path, "cache", "npm", Npm.sanitize(spec)), { recursive: true }) const entries = await Effect.gen(function* () { const npm = yield* Npm.Service return { added: yield* npm.add(spec), cached: yield* npm.add(spec), } }).pipe(Effect.scoped, Effect.provide(npmLayer(path.join(tmp.path, "cache"))), Effect.runPromise) expect(await fs.stat(path.join(entries.added.directory, "package.json"))).toBeTruthy() expect(entries.cached).toEqual(entries.added) }) test.each(["unnamed", "named"])("installs and locates %s Git packages with dependencies", async (kind) => { await using tmp = await tmpdir() const fixture = await createGitFixture(tmp.path) const spec = kind === "unnamed" ? `git+file://${fixture.repository}#${fixture.commit}` : `fixture-named-plugin@git+file://${fixture.repository}#fixture-branch` const entries = await Effect.gen(function* () { const npm = yield* Npm.Service return { added: yield* npm.add(spec), cached: yield* npm.add(spec), resolved: yield* npm.resolve(spec), } }).pipe(Effect.scoped, Effect.provide(npmLayer(path.join(tmp.path, "cache"))), Effect.runPromise) expect(entries.added.directory).toEndWith(path.join("node_modules", entries.added.name)) expect(entries.added.version).toBe(fixture.commit) expect(entries.cached).toEqual(entries.added) expect(entries.resolved).toEqual(entries.added) expect( await fs.stat(path.join(path.dirname(entries.added.directory), "fixture-dependency", "package.json")), ).toBeTruthy() expect(entries.added.directory).toContain(path.join("npm", await Npm.cacheKey(spec))) expect(entries.added.directory).toContain("node_modules") }) test.each(["commit", "branch"])("installs a Git package from an npm ::path: subdirectory at a %s", async (ref) => { await using tmp = await tmpdir() const fixture = await createGitFixture(tmp.path) const spec = `git+file://${fixture.repository}#${ref === "commit" ? fixture.commit : "fixture-branch"}::path:packages/subdirectory-plugin` const result = await Effect.gen(function* () { const npm = yield* Npm.Service return { added: yield* npm.add(spec), outdated: yield* npm.check(spec) } }).pipe(Effect.scoped, Effect.provide(npmLayer(path.join(tmp.path, "cache"))), Effect.runPromise) expect(result.added.directory).toEndWith(path.join("node_modules", "fixture-subdirectory-plugin")) expect(result.added.name).toBe("fixture-subdirectory-plugin") expect(result.added.version).toBe(fixture.commit) expect(await Bun.file(path.join(result.added.directory, "index.js")).text()).toContain("subdirectory: true") expect( await fs.stat(path.join(path.dirname(result.added.directory), "fixture-subdirectory-dependency", "package.json")), ).toBeTruthy() expect(result.outdated).toBeFalse() }) // Several real Git installs and updates exceed Bun's default timeout on Windows. test("checks and updates mutable Git packages without changing pinned installs", async () => { await using tmp = await tmpdir() const fixture = await createGitFixture(tmp.path) const cache = path.join(tmp.path, "cache") const repository = pathToFileURL(fixture.repository).href const mutable = `git+${repository}#fixture-branch` const pinned = `git+${repository}#${fixture.commit}` const result = await Effect.gen(function* () { const npm = yield* Npm.Service const mutableEntry = yield* npm.add(mutable) yield* npm.add(pinned) yield* Effect.promise(async () => { await Bun.write(path.join(fixture.repository, "index.js"), 'export default { root: "second" }\n') await Bun.$`git -C ${fixture.repository} add .` await Bun.$`git -C ${fixture.repository} -c user.name=fixture -c user.email=fixture@example.com commit -qm second` }) const before = yield* Effect.promise(() => Bun.file(path.join(mutableEntry.directory, "index.js")).text()) const outdated = yield* npm.check(mutable) const pinnedOutdated = yield* npm.check(pinned) const unchanged = yield* Effect.promise(() => Bun.file(path.join(mutableEntry.directory, "index.js")).text()) const updated = yield* npm.update(mutable) const pinnedUpdated = yield* npm.update(pinned) return { before, outdated, pinnedOutdated, unchanged, updated: yield* Effect.promise(() => Bun.file(path.join(updated.directory, "index.js")).text()), pinned: yield* Effect.promise(() => Bun.file(path.join(pinnedUpdated.directory, "index.js")).text()), current: yield* npm.check(mutable), } }).pipe(Effect.scoped, Effect.provide(npmLayer(cache)), Effect.runPromise) expect(result.before).toContain("root: true") expect(result.outdated).toBeTrue() expect(result.pinnedOutdated).toBeFalse() expect(result.unchanged).toContain("root: true") expect(result.updated).toContain('root: "second"') expect(result.pinned).toContain("root: true") expect(result.current).toBeFalse() }, 30_000) // Symlink creation needs elevated privileges on Windows. test.skipIf(win)("records Git revisions when the cache directory is reached through a symlink", async () => { await using tmp = await tmpdir() const fixture = await createGitFixture(tmp.path) await fs.mkdir(path.join(tmp.path, "cache")) await fs.symlink(path.join(tmp.path, "cache"), path.join(tmp.path, "link")) const mutable = `git+${pathToFileURL(fixture.repository).href}#fixture-branch` const result = await Effect.gen(function* () { const npm = yield* Npm.Service const added = yield* npm.add(mutable) const current = yield* npm.check(mutable) yield* Effect.promise(async () => { await Bun.write(path.join(fixture.repository, "index.js"), 'export default { root: "second" }\n') await Bun.$`git -C ${fixture.repository} add .` await Bun.$`git -C ${fixture.repository} -c user.name=fixture -c user.email=fixture@example.com commit -qm second` }) return { added, current, outdated: yield* npm.check(mutable) } }).pipe(Effect.scoped, Effect.provide(npmLayer(path.join(tmp.path, "link"))), Effect.runPromise) expect(result.added.version).toBe(fixture.commit) expect(result.current).toBeFalse() expect(result.outdated).toBeTrue() }, 30_000) }) describe("Npm.resolve", () => { test("locates a cached package without installing it", async () => { await using tmp = await tmpdir() const cache = path.join(tmp.path, "cache") const spec = "fixture-plugin@1.0.0" const directory = path.join(cache, "npm", Npm.sanitize(spec), "1000", "node_modules", "fixture-plugin") const missing = await Effect.gen(function* () { const npm = yield* Npm.Service return yield* npm.resolve(spec) }).pipe(Effect.scoped, Effect.provide(npmLayer(cache)), Effect.runPromise) expect(missing.version).toBeUndefined() await fs.mkdir(directory, { recursive: true }) await writePackage(directory, { name: "fixture-plugin", exports: { ".": "./index.js", "./tui": "./tui.js" }, }) await Bun.write(path.join(directory, "index.js"), "export default {}\n") await Bun.write(path.join(directory, "tui.js"), "export default {}\n") const resolved = await Effect.gen(function* () { const npm = yield* Npm.Service return yield* npm.resolve(spec) }).pipe(Effect.scoped, Effect.provide(npmLayer(cache)), Effect.runPromise) expect(resolved.directory).toBe(directory) expect(resolved.version).toBe("1.0.0") }) }) describe("Npm.check and Npm.update", () => { test("installs and updates without requesting registry audits", async () => { await using tmp = await tmpdir() await using registry = await createRegistryFixture(tmp.path) const cache = path.join(tmp.path, "cache") const spec = "@fixture/registry-plugin@latest" await registry.configure(cache, spec) await Effect.gen(function* () { const npm = yield* Npm.Service expect((yield* npm.add(spec)).version).toBe("1.0.0") expect(registry.state.audits).toBe(0) registry.state.latest = "1.1.0" expect((yield* npm.update(spec)).version).toBe("1.1.0") expect(registry.state.audits).toBe(0) expect((yield* npm.resolve(spec)).version).toBe("1.1.0") }).pipe(Effect.scoped, Effect.provide(npmLayer(cache)), Effect.runPromise) }) test("checks a mutable registry target without mutation and explicitly updates it", async () => { await using tmp = await tmpdir() await using registry = await createRegistryFixture(tmp.path) const cache = path.join(tmp.path, "cache") const mutable = "@fixture/registry-plugin@latest" const root = await registry.configure(cache, mutable) const result = await Effect.gen(function* () { const npm = yield* Npm.Service const installed = yield* npm.add(mutable) const current = yield* npm.check(mutable) registry.state.latest = "1.1.0" const outdated = yield* npm.check(mutable) const before = yield* Effect.promise(() => Bun.file(path.join(installed.directory, "index.js")).text()) yield* Effect.promise(() => Promise.all([fs.mkdir(path.join(root, "1")), fs.mkdir(path.join(root, "2"))])) const updated = yield* npm.update(mutable) const unchanged = yield* npm.update(mutable) return { current, outdated, before, after: yield* Effect.promise(() => Bun.file(path.join(updated.directory, "index.js")).text()), version: updated.version, changedDirectory: installed.directory !== updated.directory, unchangedDirectory: unchanged.directory === updated.directory, generations: yield* Effect.promise(() => fs.readdir(root)), updated: yield* npm.check(mutable), } }).pipe(Effect.scoped, Effect.provide(npmLayer(cache)), Effect.runPromise) expect(result.current).toBeFalse() expect(result.outdated).toBeTrue() expect(result.before).toContain('version = "1.0.0"') expect(result.after).toContain('version = "1.1.0"') expect(result.version).toBe("1.1.0") expect(result.changedDirectory).toBeTrue() expect(result.unchangedDirectory).toBeTrue() expect(result.generations).not.toContain("1") expect(result.generations).not.toContain("2") expect(result.updated).toBeFalse() }) test("never reports a pinned registry target as outdated", async () => { await using tmp = await tmpdir() await using registry = await createRegistryFixture(tmp.path) const cache = path.join(tmp.path, "cache") const pinned = "@fixture/registry-plugin@1.0.0" await registry.configure(cache, pinned) const outdated = await Effect.gen(function* () { const npm = yield* Npm.Service yield* npm.add(pinned) registry.state.latest = "1.1.0" return yield* npm.check(pinned) }).pipe(Effect.scoped, Effect.provide(npmLayer(cache)), Effect.runPromise) expect(outdated).toBeFalse() }) })