diff --git a/packages/core/src/npm.ts b/packages/core/src/npm.ts index 30e12cff1206..9beb577d76ab 100644 --- a/packages/core/src/npm.ts +++ b/packages/core/src/npm.ts @@ -47,6 +47,18 @@ export function sanitize(pkg: string) { return Array.from(pkg, (char) => (illegal.has(char) || char.charCodeAt(0) < 32 ? "_" : char)).join("") } +// Canonical cache root for a specifier (#48514): bare names share the +// @latest root so plugin, provider and V1 config-ref loaders all resolve +// the same install instead of keying the cache by raw specifier string. +export function cacheKey(pkg: string) { + try { + const parsed = npa(pkg) + return parsed.name && parsed.raw === parsed.name ? `${parsed.name}@latest` : pkg + } catch { + return pkg + } +} + const resolveEntryPoint = (name: string, dir: string): EntryPoint => { let entrypoint: string | undefined try { @@ -113,7 +125,7 @@ const layer = Layer.effect( ) const add = Effect.fn("Npm.add")(function* (pkg: string) { - const dir = directory(pkg) + const dir = directory(cacheKey(pkg)) const name = (() => { try { return npa(pkg).name ?? pkg @@ -190,7 +202,7 @@ const layer = Layer.effect( }, Effect.scoped) const which = Effect.fn("Npm.which")(function* (pkg: string, bin?: string) { - const dir = directory(pkg) + const dir = directory(cacheKey(pkg)) const binDir = path.join(dir, "node_modules", ".bin") const pick = Effect.fnUntraced(function* () { diff --git a/packages/core/test/npm.test.ts b/packages/core/test/npm.test.ts index 7e4a5763bf08..30c27cf7d7b6 100644 --- a/packages/core/test/npm.test.ts +++ b/packages/core/test/npm.test.ts @@ -35,6 +35,22 @@ describe("Npm.sanitize", () => { }) }) +describe("Npm.cacheKey", () => { + test("bare names share the @latest cache root", () => { + expect(Npm.cacheKey("prettier")).toBe("prettier@latest") + expect(Npm.cacheKey("prettier@latest")).toBe("prettier@latest") + expect(Npm.cacheKey("@scope/pkg")).toBe("@scope/pkg@latest") + expect(Npm.cacheKey("@scope/pkg@latest")).toBe("@scope/pkg@latest") + }) + + test("versioned and non-registry specs keep their own root", () => { + expect(Npm.cacheKey("prettier@1.2.3")).toBe("prettier@1.2.3") + expect(Npm.cacheKey("prettier@^2")).toBe("prettier@^2") + const spec = "acme@git+https://github.com/opencode/acme.git" + expect(Npm.cacheKey(spec)).toBe(spec) + }) +}) + describe("Npm.add", () => { test("reifies when package cache directory exists without the package installed", async () => { await using tmp = await tmpdir()