From ba341c6cac5ed1ef867ef127245a42b5e33d4946 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Mon, 21 Sep 2026 14:36:52 -0500 Subject: [PATCH] fix(core): resolve npm package entrypoint to a file under Node (#50413) --- packages/core/src/npm.ts | 10 ++++- packages/core/test/npm.test.ts | 70 ++++++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 1 deletion(-) diff --git a/packages/core/src/npm.ts b/packages/core/src/npm.ts index 30e12cff120..94e573d12da 100644 --- a/packages/core/src/npm.ts +++ b/packages/core/src/npm.ts @@ -1,6 +1,8 @@ export * as Npm from "./npm" import path from "path" +import { createRequire } from "module" +import { pathToFileURL } from "url" import npa from "npm-package-arg" import { Effect, Schema, Context, Layer, Option, FileSystem } from "effect" import { NodeFileSystem } from "@effect/platform-node" @@ -50,7 +52,13 @@ export function sanitize(pkg: string) { const resolveEntryPoint = (name: string, dir: string): EntryPoint => { let entrypoint: string | undefined try { - entrypoint = typeof Bun !== "undefined" ? import.meta.resolve(name, dir) : import.meta.resolve(dir) + // Node only honors the parent argument behind --experimental-import-meta-resolve, and + // import() of the bare package directory fails with ERR_UNSUPPORTED_DIR_IMPORT. require + // resolution picks the "require"/"default" export target, which import() loads fine. + entrypoint = + typeof Bun !== "undefined" + ? import.meta.resolve(name, dir) + : pathToFileURL(createRequire(path.join(dir, "package.json")).resolve(name)).href } catch { entrypoint = undefined } diff --git a/packages/core/test/npm.test.ts b/packages/core/test/npm.test.ts index 7e4a5763bf0..dfca7a49ff5 100644 --- a/packages/core/test/npm.test.ts +++ b/packages/core/test/npm.test.ts @@ -1,10 +1,12 @@ import fs from "fs/promises" import path from "path" +import { pathToFileURL } from "url" import { describe, expect, test } from "bun:test" import { Effect, Option } from "effect" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { Global } from "@opencode-ai/core/global" import { Npm } from "@opencode-ai/core/npm" +import { which } from "@opencode-ai/core/util/which" import { tmpdir } from "./fixture/tmpdir" const win = process.platform === "win32" @@ -55,6 +57,74 @@ describe("Npm.add", () => { expect(entry.entrypoint).toBeDefined() }) + + // The Desktop sidecar runs the server under Node, where import.meta.resolve cannot take a + // parent URL. Exercise the real Node branch instead of the Bun one the test runner uses. + test("resolves an importable file URL under Node", async () => { + await using tmp = await tmpdir() + const node = which("node") + if (!node) throw new Error("Node is required for the Npm Node runtime test") + + const bundle = await Bun.build({ + entrypoints: [path.join(import.meta.dir, "../src/npm.ts")], + target: "node", + format: "esm", + }) + expect(bundle.success).toBe(true) + const entry = path.join(tmp.path, "npm.mjs") + await Bun.write(entry, bundle.outputs[0]) + + const dual = path.join(tmp.path, "dual-provider") + await writePackage(dual, { + name: "dual-provider", + exports: { ".": { import: "./dist/index.mjs", require: "./dist/index.js" } }, + }) + await Bun.write(path.join(dual, "dist", "index.mjs"), "export const createDual = () => 'esm'\n") + await Bun.write(path.join(dual, "dist", "index.js"), "exports.createDual = () => 'cjs'\n") + + const scoped = path.join(tmp.path, "scoped-provider") + await writePackage(scoped, { + name: "@fixture/scoped-provider", + type: "module", + exports: "./dist/index.js", + }) + await Bun.write(path.join(scoped, "dist", "index.js"), "export const createScoped = () => 'scoped'\n") + + const proc = Bun.spawn( + [ + node, + "--input-type=module", + "-e", + ` + import assert from "node:assert/strict" + import { Npm } from ${JSON.stringify(pathToFileURL(entry).href)} + assert.equal(typeof Bun, "undefined") + for (const [spec, name] of [ + [${JSON.stringify(`dual-provider@file:${dual}`)}, "createDual"], + [${JSON.stringify(`@fixture/scoped-provider@file:${scoped}`)}, "createScoped"], + ]) { + const result = await Npm.add(spec) + assert.ok(result.entrypoint?.startsWith("file://"), "entrypoint is a file URL: " + result.entrypoint) + const mod = await import(result.entrypoint) + assert.equal(typeof mod[name], "function", "module exports " + name) + } + process.exit(0) + `, + ], + { + env: { ...process.env, XDG_CACHE_HOME: path.join(tmp.path, "cache") }, + stdout: "pipe", + stderr: "pipe", + }, + ) + const [stdout, stderr, code] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]) + expect(stderr, stdout).toBe("") + expect(code).toBe(0) + }, 30_000) }) describe("Npm.install", () => {