import path from "path" import semver from "semver" import { Effect, Schema, Context, Layer, Option, FileSystem } from "effect" import { NodeFileSystem } from "@effect/platform-node" import { AppFileSystem } from "./filesystem" import { Global } from "./global" import { EffectFlock } from "./util/effect-flock" export namespace Npm { export class InstallFailedError extends Schema.TaggedErrorClass()("NpmInstallFailedError", { pkg: Schema.String, cause: Schema.optional(Schema.Defect), }) {} export interface EntryPoint { readonly directory: string readonly entrypoint: Option.Option } export interface Interface { readonly add: (pkg: string) => Effect.Effect readonly install: (dir: string, input?: { add: string[] }) => Effect.Effect readonly outdated: (pkg: string, cachedVersion: string) => Effect.Effect readonly which: (pkg: string) => Effect.Effect> } export class Service extends Context.Service()("@opencode/Npm") {} const illegal = process.platform === "win32" ? new Set(["<", ">", ":", '"', "|", "?", "*"]) : undefined export function sanitize(pkg: string) { if (!illegal) return pkg return Array.from(pkg, (char) => (illegal.has(char) || char.charCodeAt(0) < 32 ? "_" : char)).join("") } const resolveEntryPoint = (name: string, dir: string): EntryPoint => { let entrypoint: Option.Option try { const resolved = typeof Bun !== "undefined" ? import.meta.resolve(name, dir) : import.meta.resolve(dir) entrypoint = Option.some(resolved) } catch { entrypoint = Option.none() } return { directory: dir, entrypoint, } } interface ArboristNode { name: string path: string } interface ArboristTree { edgesOut: Map } export const layer = Layer.effect( Service, Effect.gen(function* () { const afs = yield* AppFileSystem.Service const global = yield* Global.Service const fs = yield* FileSystem.FileSystem const flock = yield* EffectFlock.Service const directory = (pkg: string) => path.join(global.cache, "packages", sanitize(pkg)) const outdated = Effect.fn("Npm.outdated")(function* (pkg: string, cachedVersion: string) { const response = yield* Effect.tryPromise({ try: () => fetch(`https://registry.npmjs.org/${pkg}`), catch: () => undefined, }).pipe(Effect.orElseSucceed(() => undefined)) if (!response || !response.ok) { return false } const data = yield* Effect.tryPromise({ try: () => response.json() as Promise<{ "dist-tags"?: { latest?: string } }>, catch: () => undefined, }).pipe(Effect.orElseSucceed(() => undefined)) const latestVersion = data?.["dist-tags"]?.latest if (!latestVersion) { return false } const range = /[\s^~*xX<>|=]/.test(cachedVersion) if (range) return !semver.satisfies(latestVersion, cachedVersion) return semver.lt(cachedVersion, latestVersion) }) const add = Effect.fn("Npm.add")(function* (pkg: string) { const { Arborist } = yield* Effect.promise(() => import("@npmcli/arborist")) const dir = directory(pkg) yield* flock.acquire(`npm-install:${dir}`) const arborist = new Arborist({ path: dir, binLinks: true, progress: false, savePrefix: "", ignoreScripts: true, }) const tree = yield* Effect.tryPromise({ try: () => arborist.loadVirtual().catch(() => undefined), catch: () => undefined, }).pipe(Effect.orElseSucceed(() => undefined)) as Effect.Effect if (tree) { const first = tree.edgesOut.values().next().value?.to if (first) { return resolveEntryPoint(first.name, first.path) } } const result = yield* Effect.tryPromise({ try: () => arborist.reify({ add: [pkg], save: true, saveType: "prod", }), catch: (cause) => new InstallFailedError({ pkg, cause }), }) as Effect.Effect const first = result.edgesOut.values().next().value?.to if (!first) { return yield* new InstallFailedError({ pkg }) } return resolveEntryPoint(first.name, first.path) }, Effect.scoped) const install = Effect.fn("Npm.install")(function* (dir: string, input?: { add: string[] }) { const canWrite = yield* afs.access(dir, { writable: true }).pipe( Effect.as(true), Effect.orElseSucceed(() => false), ) if (!canWrite) return yield* flock.acquire(`npm-install:${dir}`) const reify = Effect.fn("Npm.reify")(function* () { const { Arborist } = yield* Effect.promise(() => import("@npmcli/arborist")) const arb = new Arborist({ path: dir, binLinks: true, progress: false, savePrefix: "", ignoreScripts: true, }) yield* Effect.tryPromise({ try: () => arb .reify({ add: input?.add || [], save: true, saveType: "prod", }) .catch(() => {}), catch: () => {}, }).pipe(Effect.orElseSucceed(() => {})) }) const nodeModulesExists = yield* afs.existsSafe(path.join(dir, "node_modules")) if (!nodeModulesExists) { yield* reify() return } const pkg = yield* afs.readJson(path.join(dir, "package.json")).pipe(Effect.orElseSucceed(() => ({}))) const lock = yield* afs.readJson(path.join(dir, "package-lock.json")).pipe(Effect.orElseSucceed(() => ({}))) const pkgAny = pkg as any const lockAny = lock as any yield* Effect.gen(function* () { const declared = new Set([ ...Object.keys(pkgAny?.dependencies || {}), ...Object.keys(pkgAny?.devDependencies || {}), ...Object.keys(pkgAny?.peerDependencies || {}), ...Object.keys(pkgAny?.optionalDependencies || {}), ...(input?.add || []), ]) const root = lockAny?.packages?.[""] || {} const locked = new Set([ ...Object.keys(root?.dependencies || {}), ...Object.keys(root?.devDependencies || {}), ...Object.keys(root?.peerDependencies || {}), ...Object.keys(root?.optionalDependencies || {}), ]) for (const name of declared) { if (!locked.has(name)) { yield* reify() return } } }).pipe(Effect.withSpan("Npm.checkDirty")) return }, Effect.scoped) const which = Effect.fn("Npm.which")(function* (pkg: string) { const dir = directory(pkg) const binDir = path.join(dir, "node_modules", ".bin") const pick = Effect.fnUntraced(function* () { const files = yield* fs.readDirectory(binDir).pipe(Effect.catch(() => Effect.succeed([] as string[]))) if (files.length === 0) return Option.none() if (files.length === 1) return Option.some(files[0]) const pkgJson = yield* afs.readJson(path.join(dir, "node_modules", pkg, "package.json")).pipe(Effect.option) if (Option.isSome(pkgJson)) { const parsed = pkgJson.value as { bin?: string | Record } if (parsed?.bin) { const unscoped = pkg.startsWith("@") ? pkg.split("/")[1] : pkg const bin = parsed.bin if (typeof bin === "string") return Option.some(unscoped) const keys = Object.keys(bin) if (keys.length === 1) return Option.some(keys[0]) return bin[unscoped] ? Option.some(unscoped) : Option.some(keys[0]) } } return Option.some(files[0]) }) return yield* Effect.gen(function* () { const bin = yield* pick() if (Option.isSome(bin)) { return Option.some(path.join(binDir, bin.value)) } yield* fs.remove(path.join(dir, "package-lock.json")).pipe(Effect.orElseSucceed(() => {})) yield* add(pkg) const resolved = yield* pick() if (Option.isNone(resolved)) return Option.none() return Option.some(path.join(binDir, resolved.value)) }).pipe( Effect.scoped, Effect.orElseSucceed(() => Option.none()), ) }) return Service.of({ add, install, outdated, which, }) }), ) export const defaultLayer = layer.pipe( Layer.provide(EffectFlock.layer), Layer.provide(AppFileSystem.layer), Layer.provide(Global.layer), Layer.provide(NodeFileSystem.layer), ) }