mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-03 18:34:44 +00:00
Replace exposed layer graph assembly with opaque declarations, checked substitutions, and lifetime-aware compilation. Preserve deep replacement, ordered startup, and Effect-owned resource lifetimes; migrate callers and verify source and published package contracts.
295 lines
13 KiB
TypeScript
295 lines
13 KiB
TypeScript
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-ai/core/effect/app-node-builder"
|
|
import { Global } from "@opencode-ai/util/global"
|
|
import { Npm } from "@opencode-ai/util/npm"
|
|
import { tmpdir } from "./fixture/tmpdir"
|
|
|
|
const win = process.platform === "win32"
|
|
|
|
const writePackage = (dir: string, pkg: Record<string, unknown>) =>
|
|
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 }
|
|
}
|
|
|
|
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("preserves registry keys and hashes Git specs", async () => {
|
|
expect(await Npm.cacheKey("@opencode/acme@1.0.0")).toBe(Npm.sanitize("@opencode/acme@1.0.0"))
|
|
const spec = "git+ssh://git@github.com/acme/plugin.git#main"
|
|
expect(await Npm.cacheKey(spec)).toMatch(/^git-[a-f0-9]{64}$/)
|
|
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("resolves 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",
|
|
"packages",
|
|
Npm.sanitize(spec),
|
|
"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.entrypoint).toEndWith("/index.js")
|
|
})
|
|
|
|
test("falls back to the original spec when parsing fails", async () => {
|
|
await using tmp = await tmpdir()
|
|
const spec = "fixture provider"
|
|
const directory = path.join(tmp.path, "cache", "packages", Npm.sanitize(spec), "node_modules", spec)
|
|
await fs.mkdir(directory, { recursive: true })
|
|
await writePackage(directory, { name: spec, 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.entrypoint).toEndWith("/index.js")
|
|
})
|
|
|
|
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", "packages", Npm.sanitize(spec)), { recursive: true })
|
|
|
|
const entries = await Effect.gen(function* () {
|
|
const npm = yield* Npm.Service
|
|
return {
|
|
tui: yield* npm.add(spec, { subpaths: ["tui", ""] }),
|
|
fallback: yield* npm.add(spec, { subpaths: ["missing", ""] }),
|
|
}
|
|
}).pipe(Effect.scoped, Effect.provide(npmLayer(path.join(tmp.path, "cache"))), Effect.runPromise)
|
|
|
|
expect(entries.tui.entrypoint).toEndWith("/tui.js")
|
|
expect(entries.fallback.entrypoint).toEndWith("/index.js")
|
|
})
|
|
|
|
test("installs and resolves named and unnamed Git packages with dependencies", async () => {
|
|
await using tmp = await tmpdir()
|
|
const fixture = await createGitFixture(tmp.path)
|
|
const cache = path.join(tmp.path, "cache")
|
|
const specs = [
|
|
`git+file://${fixture.repository}#${fixture.commit}`,
|
|
`fixture-named-plugin@git+file://${fixture.repository}#fixture-branch`,
|
|
]
|
|
|
|
for (const spec of specs) {
|
|
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(cache)), Effect.runPromise)
|
|
|
|
expect(entries.added.entrypoint).toEndWith("/index.js")
|
|
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("packages", await Npm.cacheKey(spec), "node_modules"))
|
|
}
|
|
})
|
|
|
|
test("installs a Git package from an npm ::path: subdirectory", async () => {
|
|
await using tmp = await tmpdir()
|
|
const fixture = await createGitFixture(tmp.path)
|
|
const spec = `git+file://${fixture.repository}#${fixture.commit}::path:packages/subdirectory-plugin`
|
|
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).toEndWith(path.join("node_modules", "fixture-subdirectory-plugin"))
|
|
expect(entry.entrypoint).toEndWith("/index.js")
|
|
expect(
|
|
await fs.stat(path.join(path.dirname(entry.directory), "fixture-subdirectory-dependency", "package.json")),
|
|
).toBeTruthy()
|
|
})
|
|
|
|
// Several real Git installs and refreshes exceed Bun's default timeout on Windows.
|
|
test("refreshes mutable Git packages once per service lifetime and preserves pinned or cached 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 first = await Effect.gen(function* () {
|
|
const npm = yield* Npm.Service
|
|
const mutableEntry = yield* npm.add(mutable)
|
|
const pinnedEntry = yield* npm.add(pinned, { refresh: true })
|
|
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`
|
|
})
|
|
yield* npm.add(mutable, { refresh: true })
|
|
return { mutable: mutableEntry, pinned: pinnedEntry }
|
|
}).pipe(Effect.scoped, Effect.provide(npmLayer(cache)), Effect.runPromise)
|
|
expect(await Bun.file(path.join(first.mutable.directory, "index.js")).text()).toContain("root: true")
|
|
expect(await Bun.file(path.join(first.pinned.directory, "index.js")).text()).toContain("root: true")
|
|
|
|
const second = await Effect.gen(function* () {
|
|
const npm = yield* Npm.Service
|
|
return {
|
|
mutable: yield* npm.add(mutable, { refresh: true }),
|
|
pinned: yield* npm.add(pinned, { refresh: true }),
|
|
}
|
|
}).pipe(Effect.scoped, Effect.provide(npmLayer(cache)), Effect.runPromise)
|
|
expect(await Bun.file(path.join(second.mutable.directory, "index.js")).text()).toContain('root: "second"')
|
|
expect(await Bun.file(path.join(second.pinned.directory, "index.js")).text()).toContain("root: true")
|
|
|
|
await fs.rename(fixture.repository, `${fixture.repository}-offline`)
|
|
const offline = await Effect.gen(function* () {
|
|
const npm = yield* Npm.Service
|
|
return yield* npm.add(mutable, { refresh: true })
|
|
}).pipe(Effect.scoped, Effect.provide(npmLayer(cache)), Effect.runPromise)
|
|
expect(await Bun.file(path.join(offline.directory, "index.js")).text()).toContain('root: "second"')
|
|
}, 30_000)
|
|
})
|
|
|
|
describe("Npm.resolve", () => {
|
|
test("resolves a TUI entrypoint only when the package is already cached", 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, "packages", Npm.sanitize(spec), "node_modules", "fixture-plugin")
|
|
const missing = await Effect.gen(function* () {
|
|
const npm = yield* Npm.Service
|
|
return yield* npm.resolve(spec, { subpaths: ["tui"] })
|
|
}).pipe(Effect.scoped, Effect.provide(npmLayer(cache)), Effect.runPromise)
|
|
expect(missing.entrypoint).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, { subpaths: ["tui"] })
|
|
}).pipe(Effect.scoped, Effect.provide(npmLayer(cache)), Effect.runPromise)
|
|
expect(resolved.entrypoint).toEndWith("/tui.js")
|
|
})
|
|
})
|