npm.ts 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271
  1. export * as Npm from "./npm"
  2. import path from "path"
  3. import npa from "npm-package-arg"
  4. import { Effect, Schema, Context, Layer, Option, FileSystem } from "effect"
  5. import { NodeFileSystem } from "@effect/platform-node"
  6. import { AppFileSystem } from "./filesystem"
  7. import { Global } from "./global"
  8. import { EffectFlock } from "./util/effect-flock"
  9. import { makeRuntime } from "./effect/runtime"
  10. import { NpmConfig } from "./npm-config"
  11. export class InstallFailedError extends Schema.TaggedErrorClass<InstallFailedError>()("NpmInstallFailedError", {
  12. add: Schema.Array(Schema.String).pipe(Schema.optional),
  13. dir: Schema.String,
  14. cause: Schema.optional(Schema.Defect),
  15. }) {}
  16. export interface EntryPoint {
  17. readonly directory: string
  18. readonly entrypoint: Option.Option<string>
  19. }
  20. export interface Interface {
  21. readonly add: (pkg: string) => Effect.Effect<EntryPoint, InstallFailedError | EffectFlock.LockError>
  22. readonly install: (
  23. dir: string,
  24. input?: {
  25. add: {
  26. name: string
  27. version?: string
  28. }[]
  29. },
  30. ) => Effect.Effect<void, EffectFlock.LockError | InstallFailedError>
  31. readonly which: (pkg: string, bin?: string) => Effect.Effect<Option.Option<string>>
  32. }
  33. export class Service extends Context.Service<Service, Interface>()("@opencode/Npm") {}
  34. const illegal = process.platform === "win32" ? new Set(["<", ">", ":", '"', "|", "?", "*"]) : undefined
  35. export function sanitize(pkg: string) {
  36. if (!illegal) return pkg
  37. return Array.from(pkg, (char) => (illegal.has(char) || char.charCodeAt(0) < 32 ? "_" : char)).join("")
  38. }
  39. const resolveEntryPoint = (name: string, dir: string): EntryPoint => {
  40. let entrypoint: Option.Option<string>
  41. try {
  42. const resolved = typeof Bun !== "undefined" ? import.meta.resolve(name, dir) : import.meta.resolve(dir)
  43. entrypoint = Option.some(resolved)
  44. } catch {
  45. entrypoint = Option.none()
  46. }
  47. return {
  48. directory: dir,
  49. entrypoint,
  50. }
  51. }
  52. interface ArboristNode {
  53. name: string
  54. path: string
  55. }
  56. interface ArboristTree {
  57. edgesOut: Map<string, { to?: ArboristNode }>
  58. }
  59. export const layer = Layer.effect(
  60. Service,
  61. Effect.gen(function* () {
  62. const afs = yield* AppFileSystem.Service
  63. const global = yield* Global.Service
  64. const fs = yield* FileSystem.FileSystem
  65. const flock = yield* EffectFlock.Service
  66. const directory = (pkg: string) => path.join(global.cache, "packages", sanitize(pkg))
  67. const reify = (input: { dir: string; add?: string[] }) =>
  68. Effect.gen(function* () {
  69. yield* flock.acquire(`npm-install:${input.dir}`)
  70. const { Arborist } = yield* Effect.promise(() => import("@npmcli/arborist"))
  71. const add = input.add ?? []
  72. const npmOptions = yield* NpmConfig.load(input.dir)
  73. const arborist = new Arborist({
  74. ...npmOptions,
  75. path: input.dir,
  76. binLinks: true,
  77. progress: false,
  78. savePrefix: "",
  79. ignoreScripts: true,
  80. })
  81. return yield* Effect.tryPromise({
  82. try: () =>
  83. arborist.reify({
  84. ...npmOptions,
  85. add,
  86. save: true,
  87. saveType: "prod",
  88. }),
  89. catch: (cause) =>
  90. new InstallFailedError({
  91. cause,
  92. add,
  93. dir: input.dir,
  94. }),
  95. }) as Effect.Effect<ArboristTree, InstallFailedError>
  96. }).pipe(
  97. Effect.withSpan("Npm.reify", {
  98. attributes: input,
  99. }),
  100. )
  101. const add = Effect.fn("Npm.add")(function* (pkg: string) {
  102. const dir = directory(pkg)
  103. const name = (() => {
  104. try {
  105. return npa(pkg).name ?? pkg
  106. } catch {
  107. return pkg
  108. }
  109. })()
  110. if (yield* afs.existsSafe(path.join(dir, "node_modules", name))) {
  111. return resolveEntryPoint(name, path.join(dir, "node_modules", name))
  112. }
  113. const tree = yield* reify({ dir, add: [pkg] })
  114. const first = tree.edgesOut.values().next().value?.to
  115. if (!first) {
  116. const result = resolveEntryPoint(name, path.join(dir, "node_modules", name))
  117. if (Option.isSome(result.entrypoint)) return result
  118. return yield* new InstallFailedError({ add: [pkg], dir })
  119. }
  120. return resolveEntryPoint(first.name, first.path)
  121. }, Effect.scoped)
  122. const install: Interface["install"] = Effect.fn("Npm.install")(function* (dir, input) {
  123. const canWrite = yield* afs.access(dir, { writable: true }).pipe(
  124. Effect.as(true),
  125. Effect.orElseSucceed(() => false),
  126. )
  127. if (!canWrite) return
  128. const add = input?.add.map((pkg) => [pkg.name, pkg.version].filter(Boolean).join("@")) ?? []
  129. if (
  130. yield* Effect.gen(function* () {
  131. const nodeModulesExists = yield* afs.existsSafe(path.join(dir, "node_modules"))
  132. if (!nodeModulesExists) {
  133. yield* reify({ add, dir })
  134. return true
  135. }
  136. return false
  137. }).pipe(Effect.withSpan("Npm.checkNodeModules"))
  138. )
  139. return
  140. yield* Effect.gen(function* () {
  141. const pkg = yield* afs.readJson(path.join(dir, "package.json")).pipe(Effect.orElseSucceed(() => ({})))
  142. const lock = yield* afs.readJson(path.join(dir, "package-lock.json")).pipe(Effect.orElseSucceed(() => ({})))
  143. const pkgAny = pkg as any
  144. const lockAny = lock as any
  145. const declared = new Set([
  146. ...Object.keys(pkgAny?.dependencies || {}),
  147. ...Object.keys(pkgAny?.devDependencies || {}),
  148. ...Object.keys(pkgAny?.peerDependencies || {}),
  149. ...Object.keys(pkgAny?.optionalDependencies || {}),
  150. ...(input?.add || []).map((pkg) => pkg.name),
  151. ])
  152. const root = lockAny?.packages?.[""] || {}
  153. const locked = new Set([
  154. ...Object.keys(root?.dependencies || {}),
  155. ...Object.keys(root?.devDependencies || {}),
  156. ...Object.keys(root?.peerDependencies || {}),
  157. ...Object.keys(root?.optionalDependencies || {}),
  158. ])
  159. for (const name of declared) {
  160. if (!locked.has(name)) {
  161. yield* reify({ dir, add })
  162. return
  163. }
  164. }
  165. }).pipe(Effect.withSpan("Npm.checkDirty"))
  166. return
  167. }, Effect.scoped)
  168. const which = Effect.fn("Npm.which")(function* (pkg: string, bin?: string) {
  169. const dir = directory(pkg)
  170. const binDir = path.join(dir, "node_modules", ".bin")
  171. const pick = Effect.fnUntraced(function* () {
  172. const files = yield* fs.readDirectory(binDir).pipe(Effect.catch(() => Effect.succeed([] as string[])))
  173. if (files.length === 0) return Option.none<string>()
  174. // Caller picked a specific bin (e.g. pyright exposes both `pyright` and
  175. // `pyright-langserver`); trust the hint if the package provides it.
  176. if (bin) return files.includes(bin) ? Option.some(bin) : Option.none<string>()
  177. if (files.length === 1) return Option.some(files[0])
  178. const pkgJson = yield* afs.readJson(path.join(dir, "node_modules", pkg, "package.json")).pipe(Effect.option)
  179. if (Option.isSome(pkgJson)) {
  180. const parsed = pkgJson.value as { bin?: string | Record<string, string> }
  181. if (parsed?.bin) {
  182. const unscoped = pkg.startsWith("@") ? pkg.split("/")[1] : pkg
  183. const parsedBin = parsed.bin
  184. if (typeof parsedBin === "string") return Option.some(unscoped)
  185. const keys = Object.keys(parsedBin)
  186. if (keys.length === 1) return Option.some(keys[0])
  187. return parsedBin[unscoped] ? Option.some(unscoped) : Option.some(keys[0])
  188. }
  189. }
  190. return Option.some(files[0])
  191. })
  192. return yield* Effect.gen(function* () {
  193. const bin = yield* pick()
  194. if (Option.isSome(bin)) {
  195. return Option.some(path.join(binDir, bin.value))
  196. }
  197. yield* fs.remove(path.join(dir, "package-lock.json")).pipe(Effect.orElseSucceed(() => {}))
  198. yield* add(pkg)
  199. const resolved = yield* pick()
  200. if (Option.isNone(resolved)) return Option.none<string>()
  201. return Option.some(path.join(binDir, resolved.value))
  202. }).pipe(
  203. Effect.scoped,
  204. Effect.orElseSucceed(() => Option.none<string>()),
  205. )
  206. })
  207. return Service.of({
  208. add,
  209. install,
  210. which,
  211. })
  212. }),
  213. )
  214. export const defaultLayer = layer.pipe(
  215. Layer.provide(EffectFlock.layer),
  216. Layer.provide(AppFileSystem.layer),
  217. Layer.provide(Global.layer),
  218. Layer.provide(NodeFileSystem.layer),
  219. )
  220. const { runPromise } = makeRuntime(Service, defaultLayer)
  221. export async function install(...args: Parameters<Interface["install"]>) {
  222. return runPromise((svc) => svc.install(...args))
  223. }
  224. export async function add(...args: Parameters<Interface["add"]>) {
  225. const entry = await runPromise((svc) => svc.add(...args))
  226. return {
  227. directory: entry.directory,
  228. entrypoint: Option.getOrUndefined(entry.entrypoint),
  229. }
  230. }
  231. export async function which(...args: Parameters<Interface["which"]>) {
  232. const resolved = await runPromise((svc) => svc.which(...args))
  233. return Option.getOrUndefined(resolved)
  234. }