diff --git a/packages/core/src/npm.ts b/packages/core/src/npm.ts index 30e12cff1206..18c615862151 100644 --- a/packages/core/src/npm.ts +++ b/packages/core/src/npm.ts @@ -2,6 +2,7 @@ export * as Npm from "./npm" import path from "path" import npa from "npm-package-arg" +import semver from "semver" import { Effect, Schema, Context, Layer, Option, FileSystem } from "effect" import { NodeFileSystem } from "@effect/platform-node" import { FSUtil } from "./fs-util" @@ -24,8 +25,15 @@ export interface EntryPoint { readonly entrypoint?: string } +export interface AddOptions { + readonly refresh?: boolean +} + export interface Interface { - readonly add: (pkg: string) => Effect.Effect + readonly add: ( + pkg: string, + options?: AddOptions, + ) => Effect.Effect readonly install: ( dir: string, input?: { @@ -69,6 +77,27 @@ interface ArboristTree { edgesOut: Map } +export function isMutableRegistrySpec(spec: string) { + try { + const hit = npa(spec) + if (!hit.name) return false + if (hit.type === "git" || hit.type === "remote" || hit.type === "directory" || hit.type === "file") return false + if (hit.type === "version") return false + if (hit.type === "tag" && hit.fetchSpec && hit.fetchSpec !== "latest" && hit.fetchSpec !== "*") return false + return true + } catch { + return false + } +} + +export function shouldRefreshInstall(installed: string | undefined, registry: string | undefined) { + if (!installed || !registry) return false + const installedVersion = semver.valid(installed) + const registryVersion = semver.valid(registry) + if (!installedVersion || !registryVersion) return installed !== registry + return semver.gt(registryVersion, installedVersion) +} + const layer = Layer.effect( Service, Effect.gen(function* () { @@ -112,7 +141,27 @@ const layer = Layer.effect( }), ) - const add = Effect.fn("Npm.add")(function* (pkg: string) { + const fetchRegistryVersion = (name: string, cacheDir: string) => + Effect.gen(function* () { + const registry = yield* NpmConfig.registry(cacheDir) + const encoded = name.startsWith("@") ? name.replace("/", "%2F") : name + const response = yield* Effect.tryPromise({ + try: () => + fetch(`${registry}/${encoded}`, { + headers: { Accept: "application/vnd.npm.install-v1+json; q=1.0, application/json; q=0.8" }, + signal: AbortSignal.timeout(5_000), + }), + catch: () => undefined, + }).pipe(Effect.orElseSucceed(() => undefined)) + if (!response?.ok) return undefined + const data = yield* Effect.tryPromise({ + try: () => response.json() as Promise<{ "dist-tags"?: Record }>, + catch: () => undefined, + }).pipe(Effect.orElseSucceed(() => undefined)) + return data?.["dist-tags"]?.latest + }) + + const add = Effect.fn("Npm.add")(function* (pkg: string, options?: AddOptions) { const dir = directory(pkg) const name = (() => { try { @@ -121,9 +170,23 @@ const layer = Layer.effect( return pkg } })() - - if (yield* afs.existsSafe(path.join(dir, "node_modules", name))) { - return resolveEntryPoint(name, path.join(dir, "node_modules", name)) + const modulePath = path.join(dir, "node_modules", name) + + if (yield* afs.existsSafe(modulePath)) { + const refresh = + options?.refresh || + (isMutableRegistrySpec(pkg) && + shouldRefreshInstall( + yield* afs + .readJson(path.join(modulePath, "package.json")) + .pipe( + Effect.map((json) => (json as { version?: string }).version), + Effect.catch(() => Effect.succeed(undefined)), + ), + yield* fetchRegistryVersion(name, dir), + )) + if (!refresh) return resolveEntryPoint(name, modulePath) + yield* fs.remove(path.join(dir, "package-lock.json")).pipe(Effect.orElseSucceed(() => {})) } const tree = yield* reify({ dir, add: [pkg] }) @@ -260,8 +323,8 @@ export async function install(...args: Parameters) { return runPromise((svc) => svc.install(...args)) } -export async function add(...args: Parameters) { - return runPromise((svc) => svc.add(...args)) +export async function add(pkg: string, options?: AddOptions) { + return runPromise((svc) => svc.add(pkg, options)) } export async function which(...args: Parameters) { diff --git a/packages/core/test/npm.test.ts b/packages/core/test/npm.test.ts index 7e4a5763bf08..31f1dce0da47 100644 --- a/packages/core/test/npm.test.ts +++ b/packages/core/test/npm.test.ts @@ -35,7 +35,41 @@ describe("Npm.sanitize", () => { }) }) +describe("Npm.isMutableRegistrySpec", () => { + test("treats latest and bare registry specs as mutable", () => { + expect(Npm.isMutableRegistrySpec("acme@latest")).toBe(true) + expect(Npm.isMutableRegistrySpec("acme@^1.0.0")).toBe(true) + expect(Npm.isMutableRegistrySpec("@opencode/acme@latest")).toBe(true) + }) + + test("treats pinned versions and non-registry specs as immutable", () => { + expect(Npm.isMutableRegistrySpec("acme@1.2.3")).toBe(false) + expect(Npm.isMutableRegistrySpec("acme@beta")).toBe(false) + expect(Npm.isMutableRegistrySpec("acme@git+https://github.com/opencode/acme.git")).toBe(false) + expect(Npm.isMutableRegistrySpec(`acme@file:${path.join("/tmp", "acme")}`)).toBe(false) + }) +}) + +describe("Npm.shouldRefreshInstall", () => { + test("refreshes when registry version is newer", () => { + expect(Npm.shouldRefreshInstall("1.0.0", "2.0.0")).toBe(true) + expect(Npm.shouldRefreshInstall("2.0.0", "2.0.0")).toBe(false) + expect(Npm.shouldRefreshInstall("2.0.0", "1.0.0")).toBe(false) + }) + + test("returns false when either version is missing", () => { + expect(Npm.shouldRefreshInstall(undefined, "2.0.0")).toBe(false) + expect(Npm.shouldRefreshInstall("1.0.0", undefined)).toBe(false) + }) +}) + describe("Npm.add", () => { + const add = (spec: string, cache: string, options?: Npm.AddOptions) => + Effect.gen(function* () { + const npm = yield* Npm.Service + return yield* npm.add(spec, options) + }).pipe(Effect.scoped, Effect.provide(npmLayer(cache)), Effect.runPromise) + 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")) @@ -48,13 +82,143 @@ describe("Npm.add", () => { 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 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) + const entry = await add(spec, path.join(tmp.path, "cache")) expect(entry.entrypoint).toBeDefined() }) + + test("refreshes an existing package cache when requested", async () => { + await using tmp = await tmpdir() + const cache = path.join(tmp.path, "cache") + const oldSource = path.join(tmp.path, "fixture-provider-old") + const source = path.join(tmp.path, "fixture-provider-new") + const spec = `fixture-provider@file:${source}` + const dir = path.join(cache, "packages", Npm.sanitize(spec)) + const cached = path.join(dir, "node_modules", "fixture-provider") + + await fs.mkdir(oldSource, { recursive: true }) + await writePackage(oldSource, { + name: "fixture-provider", + version: "1.0.0", + main: "index.js", + }) + await Bun.write(path.join(oldSource, "index.js"), "export const fixture = false\n") + await fs.mkdir(source, { recursive: true }) + await writePackage(source, { + name: "fixture-provider", + version: "2.0.0", + main: "index.js", + }) + await Bun.write(path.join(source, "index.js"), "export const fixture = true\n") + await fs.mkdir(dir, { recursive: true }) + await writePackage(dir, { + dependencies: { + "fixture-provider": `file:${oldSource}`, + }, + }) + await fs.mkdir(cached, { recursive: true }) + await writePackage(cached, { + name: "fixture-provider", + version: "1.0.0", + main: "index.js", + }) + await Bun.write(path.join(cached, "index.js"), "export const cached = true\n") + + await add(spec, cache) + expect(JSON.parse(await Bun.file(path.join(cached, "package.json")).text()).version).toBe("1.0.0") + + await add(spec, cache, { refresh: true }) + expect(JSON.parse(await Bun.file(path.join(cached, "package.json")).text()).version).toBe("2.0.0") + expect(JSON.parse(await Bun.file(path.join(dir, "package.json")).text()).dependencies["fixture-provider"]).toContain( + "fixture-provider-new", + ) + }) + + test("keeps pinned cache when registry has a newer version", async () => { + await using tmp = await tmpdir() + const cache = path.join(tmp.path, "cache") + const source = path.join(tmp.path, "fixture-provider") + const spec = `fixture-provider@file:${source}` + const dir = path.join(cache, "packages", Npm.sanitize(spec)) + const cached = path.join(dir, "node_modules", "fixture-provider") + const lockfile = path.join(dir, "package-lock.json") + + await fs.mkdir(source, { recursive: true }) + await writePackage(source, { + name: "fixture-provider", + version: "1.0.0", + main: "index.js", + }) + await Bun.write(path.join(source, "index.js"), "export const fixture = true\n") + await fs.mkdir(cached, { recursive: true }) + await writePackage(cached, { + name: "fixture-provider", + version: "1.0.0", + main: "index.js", + }) + await Bun.write(path.join(cached, "index.js"), "export const cached = true\n") + await Bun.write(lockfile, "{}") + + const fetch = globalThis.fetch + globalThis.fetch = (async () => { + throw new Error("registry fetch should not run for pinned file specs") + }) as unknown as typeof fetch + try { + await add(spec, cache) + expect(JSON.parse(await Bun.file(path.join(cached, "package.json")).text()).version).toBe("1.0.0") + expect(await Bun.file(lockfile).exists()).toBe(true) + } finally { + globalThis.fetch = fetch + } + }) + + test("detects stale latest installs when registry reports a newer version", async () => { + await using tmp = await tmpdir() + const cache = path.join(tmp.path, "cache") + const oldSource = path.join(tmp.path, "fixture-provider-old") + const spec = "fixture-provider@latest" + const dir = path.join(cache, "packages", Npm.sanitize(spec)) + const cached = path.join(dir, "node_modules", "fixture-provider") + const lockfile = path.join(dir, "package-lock.json") + + await fs.mkdir(oldSource, { recursive: true }) + await writePackage(oldSource, { + name: "fixture-provider", + version: "1.0.0", + main: "index.js", + }) + await Bun.write(path.join(oldSource, "index.js"), "export const fixture = false\n") + await fs.mkdir(dir, { recursive: true }) + await writePackage(dir, { + dependencies: { + "fixture-provider": `file:${oldSource}`, + }, + }) + await fs.mkdir(cached, { recursive: true }) + await writePackage(cached, { + name: "fixture-provider", + version: "1.0.0", + main: "index.js", + }) + await Bun.write(path.join(cached, "index.js"), "export const cached = true\n") + await Bun.write(lockfile, "{}") + + const fetch = globalThis.fetch + globalThis.fetch = (async (input: string | Request | URL) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url + if (url.endsWith("/fixture-provider")) { + return new Response(JSON.stringify({ "dist-tags": { latest: "2.0.0" } }), { status: 200 }) + } + return fetch(input) + }) as unknown as typeof fetch + try { + await expect(add(spec, cache)).rejects.toThrow() + expect(await Bun.file(lockfile).exists()).toBe(false) + expect(JSON.parse(await Bun.file(path.join(cached, "package.json")).text()).version).toBe("1.0.0") + } finally { + globalThis.fetch = fetch + } + }) }) describe("Npm.install", () => {