Skip to content
Draft
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
25 changes: 24 additions & 1 deletion packages/core/test/npm.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,9 +63,10 @@ async function createRegistryFixture(directory: string) {
await writePackage(path.join(root, "package"), {
name: "@fixture/registry-plugin",
version,
exports: "./index.js",
exports: { ".": "./index.js", "./v2": "./v2.js" },
})
await Bun.write(path.join(root, "package", "index.js"), `export const version = "${version}"\n`)
await Bun.write(path.join(root, "package", "v2.js"), `export const subpath = "${version}"\n`)
await Bun.$`tar -czf package.tgz package`.cwd(root)
tarballs.set(version, await Bun.file(path.join(root, "package.tgz")).bytes())
}
Expand Down Expand Up @@ -130,6 +131,8 @@ describe("Npm.isRegistryPackage", () => {
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/v2")).toBe(true)
expect(await Npm.isRegistryPackage("@acme/plugin/v2")).toBe(true)
expect(await Npm.isRegistryPackage("./plugin")).toBe(false)
expect(await Npm.isRegistryPackage("github:acme/plugin")).toBe(false)
expect(await Npm.isRegistryPackage("alias@npm:[email protected]")).toBe(false)
Expand All @@ -139,6 +142,8 @@ describe("Npm.isRegistryPackage", () => {
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("plugin/v2")).toBe(true)
expect(await Npm.isInstallablePackage("@acme/plugin/v2")).toBe(true)
expect(await Npm.isInstallablePackage("github:acme/plugin#main")).toBe(true)
expect(await Npm.isInstallablePackage("git+ssh://[email protected]/acme/plugin.git#main")).toBe(true)
expect(await Npm.isInstallablePackage("[email protected]:acme/plugin.git")).toBe(true)
Expand Down Expand Up @@ -166,6 +171,24 @@ describe("Npm.cacheKey", () => {
})

describe("Npm.add", () => {
test("installs a registry package root for an import subpath", 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/v2"
await registry.configure(cache, spec)

const entry = await Effect.gen(function* () {
const npm = yield* Npm.Service
return yield* npm.add(spec)
}).pipe(Effect.scoped, Effect.provide(npmLayer(cache)), Effect.runPromise)

expect(entry.name).toBe("@fixture/registry-plugin")
expect(entry.specifier).toBe(spec)
expect(entry.directory).toEndWith(path.join("node_modules", "@fixture", "registry-plugin"))
expect(await fs.stat(path.join(entry.directory, "v2.js"))).toBeTruthy()
})

test("locates cached scoped package specs without reifying", async () => {
await using tmp = await tmpdir()
const spec = "@fixture/[email protected]"
Expand Down
8 changes: 5 additions & 3 deletions packages/plugin/src/host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { importModule, resolveModule } from "@opencode/util/runtime-import"
export interface Target {
readonly directory: string
readonly name?: string
readonly specifier?: string
}

export interface Entrypoints {
Expand All @@ -17,11 +18,12 @@ export interface Entrypoints {
export function resolve(target: Target): Entrypoints {
const entry = (subpaths: readonly string[]) => {
for (const subpath of subpaths) {
const specifier = target.name
? [target.name, subpath].filter(Boolean).join("/")
const specifier = target.specifier ?? target.name
const entrypoint = specifier
? [specifier, subpath].filter(Boolean).join("/")
: path.resolve(target.directory, subpath || "index")
try {
return resolveModule(specifier, target.directory)
return resolveModule(entrypoint, target.directory)
} catch (error) {
if (
!(error instanceof Error) ||
Expand Down
15 changes: 15 additions & 0 deletions packages/plugin/test/host.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,21 @@ describe("Host.resolve", () => {
})
})

it("resolves an installed package import subpath", async () => {
await using plugin = await fixture(
{
"package.json": JSON.stringify({ name, exports: { "./v2": "./dist/v2.js" } }),
"dist/v2.js": source,
},
true,
)
assert.deepEqual(Host.resolve({ ...plugin.target, specifier: `${name}/v2` }), {
server: plugin.url("dist/v2.js"),
tui: undefined,
rpc: undefined,
})
})

it("falls back to the root export and uses import rather than require conditions", async () => {
await using plugin = await fixture(
{
Expand Down
37 changes: 33 additions & 4 deletions packages/util/src/npm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export class InstallFailedError extends Schema.TaggedError<InstallFailedError>()
export interface Package {
readonly directory: string
readonly name: string
readonly specifier?: string
readonly version?: string
readonly revision?: string
}
Expand Down Expand Up @@ -55,13 +56,21 @@ export async function cacheKey(pkg: string) {
}

type Target =
| { readonly type: "registry"; readonly name: string; readonly spec: string; readonly mutable: boolean }
| {
readonly type: "registry"
readonly name: string
readonly spec: string
readonly install: string
readonly specifier?: string
readonly mutable: boolean
}
| { readonly type: "git"; readonly name?: string; readonly slug: string; readonly mutable: boolean }

async function parse(pkg: string): Promise<Target | undefined> {
const { default: npa } = await import("npm-package-arg")
try {
const result = npa(pkg)
const subpath = registrySubpath(pkg)
const result = npa(subpath?.name ?? pkg)
if (result.type === "git") {
return {
type: "git",
Expand All @@ -75,13 +84,25 @@ async function parse(pkg: string): Promise<Target | undefined> {
type: "registry",
name: result.name,
spec: result.raw === result.name ? "latest" : result.rawSpec,
install: result.raw,
...(subpath ? { specifier: pkg } : {}),
mutable: result.type !== "version",
}
} catch {
return
}
}

function registrySubpath(pkg: string) {
if (pkg.includes("#") || pkg.includes("\\")) return
const parts = pkg.split("/")
const count = pkg.startsWith("@") ? 2 : 1
if (parts.length <= count) return
const name = parts.slice(0, count).join("/")
if (!pkg.startsWith("@") && (name.includes(":") || name.includes("@"))) return
return { name }
}

function key(pkg: string, target: Target | undefined) {
if (target?.type === "git")
return `git-${target.slug}-${createHash("sha256").update(pkg).digest("hex").slice(0, 12)}`
Expand Down Expand Up @@ -202,6 +223,7 @@ const layer = Layer.effect(
return {
directory: dir,
name,
...(target?.type === "registry" && target.specifier ? { specifier: target.specifier } : {}),
...(version ? { version } : {}),
...(revision ? { revision } : {}),
}
Expand Down Expand Up @@ -269,7 +291,12 @@ const layer = Layer.effect(
const root = yield* fs.realPath(dir).pipe(Effect.mapError((cause) => new InstallFailedError({ dir, cause })))
const staging = path.join(root, `.staging-${startedAt}-${randomUUID()}`)
const staged = yield* Effect.gen(function* () {
const tree = yield* reify({ dir: staging, config: dir, add: [pkg], update })
const tree = yield* reify({
dir: staging,
config: dir,
add: [target?.type === "registry" ? target.install : pkg],
update,
})
const installed = tree.edgesOut.values().next().value?.to
const installedNameValue = installed?.name ?? (yield* installedName(pkg, staging, target))
const result = yield* metadata(
Expand Down Expand Up @@ -375,7 +402,9 @@ const layer = Layer.effect(
const options = { ...(yield* NpmConfig.load(root)), preferOnline: true, noGitRevCache: true, ignoreScripts: true }
const available = yield* Effect.tryPromise({
try: async () =>
target.type === "git" ? gitRevision(await resolve(pkg, options)) : (await manifest(pkg, options)).version,
target.type === "git"
? gitRevision(await resolve(pkg, options))
: (await manifest(target.install, options)).version,
catch: (cause) => new InstallFailedError({ dir: root, cause }),
})
if (!available)
Expand Down
Loading