Skip to content
Open
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
77 changes: 70 additions & 7 deletions packages/core/src/npm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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<EntryPoint, InstallFailedError | EffectFlock.LockError>
readonly add: (
pkg: string,
options?: AddOptions,
) => Effect.Effect<EntryPoint, InstallFailedError | EffectFlock.LockError>
readonly install: (
dir: string,
input?: {
Expand Down Expand Up @@ -69,6 +77,27 @@ interface ArboristTree {
edgesOut: Map<string, { to?: ArboristNode }>
}

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* () {
Expand Down Expand Up @@ -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<string, string> }>,
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 {
Expand All @@ -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] })
Expand Down Expand Up @@ -260,8 +323,8 @@ export async function install(...args: Parameters<Interface["install"]>) {
return runPromise((svc) => svc.install(...args))
}

export async function add(...args: Parameters<Interface["add"]>) {
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<Interface["which"]>) {
Expand Down
172 changes: 168 additions & 4 deletions packages/core/test/npm.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand All @@ -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", () => {
Expand Down
Loading