npm.ts 8.5 KB

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