From 5a65bf14ea1ccef49cd80d786e682dc0b9ea2aaf Mon Sep 17 00:00:00 2001 From: Dante Date: Sat, 19 Sep 2026 06:37:41 +0800 Subject: [PATCH] fix(plugin): support package subpath exports --- packages/core/test/npm.test.ts | 25 ++++++++++++++++++++- packages/plugin/src/host.ts | 8 ++++--- packages/plugin/test/host.test.ts | 15 +++++++++++++ packages/util/src/npm.ts | 37 +++++++++++++++++++++++++++---- 4 files changed, 77 insertions(+), 8 deletions(-) diff --git a/packages/core/test/npm.test.ts b/packages/core/test/npm.test.ts index ff73b82a954d..fb743103ded0 100644 --- a/packages/core/test/npm.test.ts +++ b/packages/core/test/npm.test.ts @@ -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()) } @@ -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:plugin@1.0.0")).toBe(false) @@ -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://git@github.com/acme/plugin.git#main")).toBe(true) expect(await Npm.isInstallablePackage("git@github.com:acme/plugin.git")).toBe(true) @@ -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/provider@1.0.0" diff --git a/packages/plugin/src/host.ts b/packages/plugin/src/host.ts index 80a2b03972ca..5d905db2123b 100644 --- a/packages/plugin/src/host.ts +++ b/packages/plugin/src/host.ts @@ -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 { @@ -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) || diff --git a/packages/plugin/test/host.test.ts b/packages/plugin/test/host.test.ts index 7d90cf702594..107eb58a5244 100644 --- a/packages/plugin/test/host.test.ts +++ b/packages/plugin/test/host.test.ts @@ -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( { diff --git a/packages/util/src/npm.ts b/packages/util/src/npm.ts index a2d121b68b85..08af495d26a8 100644 --- a/packages/util/src/npm.ts +++ b/packages/util/src/npm.ts @@ -21,6 +21,7 @@ export class InstallFailedError extends Schema.TaggedError() export interface Package { readonly directory: string readonly name: string + readonly specifier?: string readonly version?: string readonly revision?: string } @@ -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 { 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", @@ -75,6 +84,8 @@ async function parse(pkg: string): Promise { type: "registry", name: result.name, spec: result.raw === result.name ? "latest" : result.rawSpec, + install: result.raw, + ...(subpath ? { specifier: pkg } : {}), mutable: result.type !== "version", } } catch { @@ -82,6 +93,16 @@ async function parse(pkg: string): Promise { } } +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)}` @@ -202,6 +223,7 @@ const layer = Layer.effect( return { directory: dir, name, + ...(target?.type === "registry" && target.specifier ? { specifier: target.specifier } : {}), ...(version ? { version } : {}), ...(revision ? { revision } : {}), } @@ -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( @@ -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)