npm.ts 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249
  1. import path from "path"
  2. import semver from "semver"
  3. import { Arborist } from "@npmcli/arborist"
  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. export namespace Npm {
  10. export class InstallFailedError extends Schema.TaggedErrorClass<InstallFailedError>()("NpmInstallFailedError", {
  11. pkg: Schema.String,
  12. cause: Schema.optional(Schema.Defect),
  13. }) {}
  14. export interface EntryPoint {
  15. readonly directory: string
  16. readonly entrypoint: Option.Option<string>
  17. }
  18. export interface Interface {
  19. readonly add: (pkg: string) => Effect.Effect<EntryPoint, InstallFailedError>
  20. readonly install: (dir: string) => Effect.Effect<void>
  21. readonly outdated: (pkg: string, cachedVersion: string) => Effect.Effect<boolean>
  22. readonly which: (pkg: string) => Effect.Effect<Option.Option<string>>
  23. }
  24. export class Service extends Context.Service<Service, Interface>()("@opencode/Npm") {}
  25. const illegal = process.platform === "win32" ? new Set(["<", ">", ":", '"', "|", "?", "*"]) : undefined
  26. export function sanitize(pkg: string) {
  27. if (!illegal) return pkg
  28. return Array.from(pkg, (char) => (illegal.has(char) || char.charCodeAt(0) < 32 ? "_" : char)).join("")
  29. }
  30. const resolveEntryPoint = (name: string, dir: string): EntryPoint => {
  31. let entrypoint: Option.Option<string>
  32. try {
  33. const resolved = typeof Bun !== "undefined" ? import.meta.resolve(name, dir) : import.meta.resolve(dir)
  34. entrypoint = Option.some(resolved)
  35. } catch {
  36. entrypoint = Option.none()
  37. }
  38. return {
  39. directory: dir,
  40. entrypoint,
  41. }
  42. }
  43. interface ArboristNode {
  44. name: string
  45. path: string
  46. }
  47. interface ArboristTree {
  48. edgesOut: Map<string, { to?: ArboristNode }>
  49. }
  50. export const layer = Layer.effect(
  51. Service,
  52. Effect.gen(function* () {
  53. const afs = yield* AppFileSystem.Service
  54. const global = yield* Global.Service
  55. const fs = yield* FileSystem.FileSystem
  56. const flock = yield* EffectFlock.Service
  57. const directory = (pkg: string) => path.join(global.cache, "packages", sanitize(pkg))
  58. const outdated = Effect.fn("Npm.outdated")(function* (pkg: string, cachedVersion: string) {
  59. const response = yield* Effect.tryPromise({
  60. try: () => fetch(`https://registry.npmjs.org/${pkg}`),
  61. catch: () => undefined,
  62. }).pipe(Effect.orElseSucceed(() => undefined))
  63. if (!response || !response.ok) {
  64. return false
  65. }
  66. const data = yield* Effect.tryPromise({
  67. try: () => response.json() as Promise<{ "dist-tags"?: { latest?: string } }>,
  68. catch: () => undefined,
  69. }).pipe(Effect.orElseSucceed(() => undefined))
  70. const latestVersion = data?.["dist-tags"]?.latest
  71. if (!latestVersion) {
  72. return false
  73. }
  74. const range = /[\s^~*xX<>|=]/.test(cachedVersion)
  75. if (range) return !semver.satisfies(latestVersion, cachedVersion)
  76. return semver.lt(cachedVersion, latestVersion)
  77. })
  78. const add = Effect.fn("Npm.add")(function* (pkg: string) {
  79. const dir = directory(pkg)
  80. yield* flock.acquire(`npm-install:${dir}`)
  81. const arborist = new Arborist({
  82. path: dir,
  83. binLinks: true,
  84. progress: false,
  85. savePrefix: "",
  86. ignoreScripts: true,
  87. })
  88. const tree = yield* Effect.tryPromise({
  89. try: () => arborist.loadVirtual().catch(() => undefined),
  90. catch: () => undefined,
  91. }).pipe(Effect.orElseSucceed(() => undefined)) as Effect.Effect<ArboristTree | undefined>
  92. if (tree) {
  93. const first = tree.edgesOut.values().next().value?.to
  94. if (first) {
  95. return resolveEntryPoint(first.name, first.path)
  96. }
  97. }
  98. const result = yield* Effect.tryPromise({
  99. try: () =>
  100. arborist.reify({
  101. add: [pkg],
  102. save: true,
  103. saveType: "prod",
  104. }),
  105. catch: (cause) => new InstallFailedError({ pkg, cause }),
  106. }) as Effect.Effect<ArboristTree, InstallFailedError>
  107. const first = result.edgesOut.values().next().value?.to
  108. if (!first) {
  109. return yield* new InstallFailedError({ pkg })
  110. }
  111. return resolveEntryPoint(first.name, first.path)
  112. }, Effect.scoped)
  113. const install = Effect.fn("Npm.install")(function* (dir: string) {
  114. yield* flock.acquire(`npm-install:${dir}`)
  115. const reify = Effect.fnUntraced(function* () {
  116. const arb = new Arborist({
  117. path: dir,
  118. binLinks: true,
  119. progress: false,
  120. savePrefix: "",
  121. ignoreScripts: true,
  122. })
  123. yield* Effect.tryPromise({
  124. try: () => arb.reify().catch(() => {}),
  125. catch: () => {},
  126. }).pipe(Effect.orElseSucceed(() => {}))
  127. })
  128. const nodeModulesExists = yield* afs.existsSafe(path.join(dir, "node_modules"))
  129. if (!nodeModulesExists) {
  130. yield* reify()
  131. return
  132. }
  133. const pkg = yield* afs.readJson(path.join(dir, "package.json")).pipe(Effect.orElseSucceed(() => ({})))
  134. const lock = yield* afs.readJson(path.join(dir, "package-lock.json")).pipe(Effect.orElseSucceed(() => ({})))
  135. const pkgAny = pkg as any
  136. const lockAny = lock as any
  137. const declared = new Set([
  138. ...Object.keys(pkgAny?.dependencies || {}),
  139. ...Object.keys(pkgAny?.devDependencies || {}),
  140. ...Object.keys(pkgAny?.peerDependencies || {}),
  141. ...Object.keys(pkgAny?.optionalDependencies || {}),
  142. ])
  143. const root = lockAny?.packages?.[""] || {}
  144. const locked = new Set([
  145. ...Object.keys(root?.dependencies || {}),
  146. ...Object.keys(root?.devDependencies || {}),
  147. ...Object.keys(root?.peerDependencies || {}),
  148. ...Object.keys(root?.optionalDependencies || {}),
  149. ])
  150. for (const name of declared) {
  151. if (!locked.has(name)) {
  152. yield* reify()
  153. return
  154. }
  155. }
  156. }, Effect.scoped)
  157. const which = Effect.fn("Npm.which")(function* (pkg: string) {
  158. const dir = directory(pkg)
  159. const binDir = path.join(dir, "node_modules", ".bin")
  160. const pick = Effect.fnUntraced(function* () {
  161. const files = yield* fs.readDirectory(binDir).pipe(Effect.catch(() => Effect.succeed([] as string[])))
  162. if (files.length === 0) return Option.none<string>()
  163. if (files.length === 1) return Option.some(files[0])
  164. const pkgJson = yield* afs.readJson(path.join(dir, "node_modules", pkg, "package.json")).pipe(Effect.option)
  165. if (Option.isSome(pkgJson)) {
  166. const parsed = pkgJson.value as { bin?: string | Record<string, string> }
  167. if (parsed?.bin) {
  168. const unscoped = pkg.startsWith("@") ? pkg.split("/")[1] : pkg
  169. const bin = parsed.bin
  170. if (typeof bin === "string") return Option.some(unscoped)
  171. const keys = Object.keys(bin)
  172. if (keys.length === 1) return Option.some(keys[0])
  173. return bin[unscoped] ? Option.some(unscoped) : Option.some(keys[0])
  174. }
  175. }
  176. return Option.some(files[0])
  177. })
  178. return yield* Effect.gen(function* () {
  179. const bin = yield* pick()
  180. if (Option.isSome(bin)) {
  181. return Option.some(path.join(binDir, bin.value))
  182. }
  183. yield* fs.remove(path.join(dir, "package-lock.json")).pipe(Effect.orElseSucceed(() => {}))
  184. yield* add(pkg)
  185. const resolved = yield* pick()
  186. if (Option.isNone(resolved)) return Option.none<string>()
  187. return Option.some(path.join(binDir, resolved.value))
  188. }).pipe(
  189. Effect.scoped,
  190. Effect.orElseSucceed(() => Option.none<string>()),
  191. )
  192. })
  193. return Service.of({
  194. add,
  195. install,
  196. outdated,
  197. which,
  198. })
  199. }),
  200. )
  201. export const defaultLayer = layer.pipe(
  202. Layer.provide(EffectFlock.layer),
  203. Layer.provide(AppFileSystem.layer),
  204. Layer.provide(Global.layer),
  205. Layer.provide(NodeFileSystem.layer),
  206. )
  207. }