Просмотр исходного кода

refactor: consolidate npm exports and trace flock acquisition (#23151)

Dax 3 дней назад
Родитель
Сommit
467be08e67

+ 1 - 1
packages/opencode/src/cli/cmd/tui/config/tui.ts

@@ -18,7 +18,7 @@ import { InstallationLocal, InstallationVersion } from "@/installation/version"
 import { makeRuntime } from "@/effect/runtime"
 import { makeRuntime } from "@/effect/runtime"
 import { Filesystem, Log } from "@/util"
 import { Filesystem, Log } from "@/util"
 import { ConfigVariable } from "@/config/variable"
 import { ConfigVariable } from "@/config/variable"
-import { Npm } from "@/npm/effect"
+import { Npm } from "@/npm"
 
 
 const log = Log.create({ service: "tui.config" })
 const log = Log.create({ service: "tui.config" })
 
 

+ 1 - 1
packages/opencode/src/cli/cmd/tui/layer.ts

@@ -1,6 +1,6 @@
 import { Layer } from "effect"
 import { Layer } from "effect"
 import { TuiConfig } from "./config/tui"
 import { TuiConfig } from "./config/tui"
-import { Npm } from "@/npm/effect"
+import { Npm } from "@/npm"
 import { Observability } from "@/effect/observability"
 import { Observability } from "@/effect/observability"
 
 
 export const CliLayer = Observability.layer.pipe(Layer.merge(TuiConfig.layer), Layer.provide(Npm.defaultLayer))
 export const CliLayer = Observability.layer.pipe(Layer.merge(TuiConfig.layer), Layer.provide(Npm.defaultLayer))

+ 1 - 1
packages/opencode/src/config/config.ts

@@ -38,7 +38,7 @@ import { ConfigPaths } from "./paths"
 import { ConfigFormatter } from "./formatter"
 import { ConfigFormatter } from "./formatter"
 import { ConfigLSP } from "./lsp"
 import { ConfigLSP } from "./lsp"
 import { ConfigVariable } from "./variable"
 import { ConfigVariable } from "./variable"
-import { Npm } from "@/npm/effect"
+import { Npm } from "@/npm"
 
 
 const log = Log.create({ service: "config" })
 const log = Log.create({ service: "config" })
 
 

+ 1 - 1
packages/opencode/src/effect/app-runtime.ts

@@ -46,7 +46,7 @@ import { Pty } from "@/pty"
 import { Installation } from "@/installation"
 import { Installation } from "@/installation"
 import { ShareNext } from "@/share"
 import { ShareNext } from "@/share"
 import { SessionShare } from "@/share"
 import { SessionShare } from "@/share"
-import { Npm } from "@/npm/effect"
+import { Npm } from "@/npm"
 import { memoMap } from "./memo-map"
 import { memoMap } from "./memo-map"
 
 
 export const AppLayer = Layer.mergeAll(
 export const AppLayer = Layer.mergeAll(

+ 0 - 258
packages/opencode/src/npm/effect.ts

@@ -1,258 +0,0 @@
-export * as Npm from "./effect"
-
-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 "@opencode-ai/shared/filesystem"
-import { Global } from "@opencode-ai/shared/global"
-import { EffectFlock } from "@opencode-ai/shared/util/effect-flock"
-
-import { makeRuntime } from "../effect/runtime"
-
-export class InstallFailedError extends Schema.TaggedErrorClass<InstallFailedError>()("NpmInstallFailedError", {
-  add: Schema.Array(Schema.String).pipe(Schema.optional),
-  dir: Schema.String,
-  cause: Schema.optional(Schema.Defect),
-}) {}
-
-export interface EntryPoint {
-  readonly directory: string
-  readonly entrypoint: Option.Option<string>
-}
-
-export interface Interface {
-  readonly add: (pkg: string) => Effect.Effect<EntryPoint, InstallFailedError | EffectFlock.LockError>
-  readonly install: (
-    dir: string,
-    input?: { add: string[] },
-  ) => Effect.Effect<void, EffectFlock.LockError | InstallFailedError>
-  readonly outdated: (pkg: string, cachedVersion: string) => Effect.Effect<boolean>
-  readonly which: (pkg: string) => Effect.Effect<Option.Option<string>>
-}
-
-export class Service extends Context.Service<Service, Interface>()("@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<string>
-  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<string, { to?: ArboristNode }>
-}
-
-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 reify = (input: { dir: string; add?: string[] }) =>
-      Effect.gen(function* () {
-        yield* flock.acquire(`npm-install:${input.dir}`)
-        const { Arborist } = yield* Effect.promise(() => import("@npmcli/arborist"))
-        const arborist = new Arborist({
-          path: input.dir,
-          binLinks: true,
-          progress: false,
-          savePrefix: "",
-          ignoreScripts: true,
-        })
-        return yield* Effect.tryPromise({
-          try: () =>
-            arborist.reify({
-              add: input?.add || [],
-              save: true,
-              saveType: "prod",
-            }),
-          catch: (cause) =>
-            new InstallFailedError({
-              cause,
-              add: input?.add,
-              dir: input.dir,
-            }),
-        }) as Effect.Effect<ArboristTree, InstallFailedError>
-      }).pipe(
-        Effect.withSpan("Npm.reify", {
-          attributes: input,
-        }),
-      )
-
-    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 dir = directory(pkg)
-
-      const tree = yield* reify({ dir, add: [pkg] })
-      const first = tree.edgesOut.values().next().value?.to
-      if (!first) return yield* new InstallFailedError({ add: [pkg], dir })
-      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* Effect.gen(function* () {
-        const nodeModulesExists = yield* afs.existsSafe(path.join(dir, "node_modules"))
-        if (!nodeModulesExists) {
-          yield* reify({ add: input?.add, dir })
-          return
-        }
-      }).pipe(Effect.withSpan("Npm.checkNodeModules"))
-
-      yield* Effect.gen(function* () {
-        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
-        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({ dir, add: input?.add })
-            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<string>()
-        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<string, string> }
-          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<string>()
-        return Option.some(path.join(binDir, resolved.value))
-      }).pipe(
-        Effect.scoped,
-        Effect.orElseSucceed(() => Option.none<string>()),
-      )
-    })
-
-    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),
-)
-
-const { runPromise } = makeRuntime(Service, defaultLayer)
-
-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))
-}

+ 237 - 164
packages/opencode/src/npm/index.ts

@@ -1,198 +1,271 @@
-import semver from "semver"
-import z from "zod"
-import { NamedError } from "@opencode-ai/shared/util/error"
-import { Global } from "../global"
-import { Log } from "../util"
+export * as Npm from "."
+
 import path from "path"
 import path from "path"
-import { readdir, rm } from "fs/promises"
-import { Filesystem } from "@/util"
-import { Flock } from "@opencode-ai/shared/util/flock"
+import semver from "semver"
+import { Effect, Schema, Context, Layer, Option, FileSystem } from "effect"
+import { NodeFileSystem } from "@effect/platform-node"
+import { AppFileSystem } from "@opencode-ai/shared/filesystem"
+import { Global } from "@opencode-ai/shared/global"
+import { EffectFlock } from "@opencode-ai/shared/util/effect-flock"
 
 
-const log = Log.create({ service: "npm" })
-const illegal = process.platform === "win32" ? new Set(["<", ">", ":", '"', "|", "?", "*"]) : undefined
+import { makeRuntime } from "../effect/runtime"
 
 
-export const InstallFailedError = NamedError.create(
-  "NpmInstallFailedError",
-  z.object({
-    pkg: z.string(),
-  }),
-)
+export class InstallFailedError extends Schema.TaggedErrorClass<InstallFailedError>()("NpmInstallFailedError", {
+  add: Schema.Array(Schema.String).pipe(Schema.optional),
+  dir: Schema.String,
+  cause: Schema.optional(Schema.Defect),
+}) {}
+
+export interface EntryPoint {
+  readonly directory: string
+  readonly entrypoint: Option.Option<string>
+}
+
+export interface Interface {
+  readonly add: (pkg: string) => Effect.Effect<EntryPoint, InstallFailedError | EffectFlock.LockError>
+  readonly install: (
+    dir: string,
+    input?: { add: string[] },
+  ) => Effect.Effect<void, EffectFlock.LockError | InstallFailedError>
+  readonly outdated: (pkg: string, cachedVersion: string) => Effect.Effect<boolean>
+  readonly which: (pkg: string) => Effect.Effect<Option.Option<string>>
+}
+
+export class Service extends Context.Service<Service, Interface>()("@opencode/Npm") {}
+
+const illegal = process.platform === "win32" ? new Set(["<", ">", ":", '"', "|", "?", "*"]) : undefined
 
 
 export function sanitize(pkg: string) {
 export function sanitize(pkg: string) {
   if (!illegal) return pkg
   if (!illegal) return pkg
   return Array.from(pkg, (char) => (illegal.has(char) || char.charCodeAt(0) < 32 ? "_" : char)).join("")
   return Array.from(pkg, (char) => (illegal.has(char) || char.charCodeAt(0) < 32 ? "_" : char)).join("")
 }
 }
 
 
-function directory(pkg: string) {
-  return path.join(Global.Path.cache, "packages", sanitize(pkg))
-}
-
-function resolveEntryPoint(name: string, dir: string) {
-  let entrypoint: string | undefined
+const resolveEntryPoint = (name: string, dir: string): EntryPoint => {
+  let entrypoint: Option.Option<string>
   try {
   try {
-    entrypoint = typeof Bun !== "undefined" ? import.meta.resolve(name, dir) : import.meta.resolve(dir)
-  } catch {}
-  const result = {
+    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,
     directory: dir,
     entrypoint,
     entrypoint,
   }
   }
-  return result
 }
 }
 
 
-export async function outdated(pkg: string, cachedVersion: string): Promise<boolean> {
-  const response = await fetch(`https://registry.npmjs.org/${pkg}`)
-  if (!response.ok) {
-    log.warn("Failed to resolve latest version, using cached", { pkg, cachedVersion })
-    return false
-  }
+interface ArboristNode {
+  name: string
+  path: string
+}
 
 
-  const data = (await response.json()) as { "dist-tags"?: { latest?: string } }
-  const latestVersion = data?.["dist-tags"]?.latest
-  if (!latestVersion) {
-    log.warn("No latest version found, using cached", { pkg, cachedVersion })
-    return false
-  }
+interface ArboristTree {
+  edgesOut: Map<string, { to?: ArboristNode }>
+}
 
 
-  const range = /[\s^~*xX<>|=]/.test(cachedVersion)
-  if (range) return !semver.satisfies(latestVersion, cachedVersion)
+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 reify = (input: { dir: string; add?: string[] }) =>
+      Effect.gen(function* () {
+        yield* flock.acquire(`npm-install:${input.dir}`)
+        const { Arborist } = yield* Effect.promise(() => import("@npmcli/arborist"))
+        const arborist = new Arborist({
+          path: input.dir,
+          binLinks: true,
+          progress: false,
+          savePrefix: "",
+          ignoreScripts: true,
+        })
+        return yield* Effect.tryPromise({
+          try: () =>
+            arborist.reify({
+              add: input?.add || [],
+              save: true,
+              saveType: "prod",
+            }),
+          catch: (cause) =>
+            new InstallFailedError({
+              cause,
+              add: input?.add,
+              dir: input.dir,
+            }),
+        }) as Effect.Effect<ArboristTree, InstallFailedError>
+      }).pipe(
+        Effect.withSpan("Npm.reify", {
+          attributes: input,
+        }),
+      )
 
 
-  return semver.lt(cachedVersion, latestVersion)
-}
+    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))
 
 
-export async function add(pkg: string) {
-  const { Arborist } = await import("@npmcli/arborist")
-  const dir = directory(pkg)
-  await using _ = await Flock.acquire(`npm-install:${Filesystem.resolve(dir)}`)
-  log.info("installing package", {
-    pkg,
-  })
-
-  const arborist = new Arborist({
-    path: dir,
-    binLinks: true,
-    progress: false,
-    savePrefix: "",
-    ignoreScripts: true,
-  })
-  const tree = await arborist.loadVirtual().catch(() => {})
-  if (tree) {
-    const first = tree.edgesOut.values().next().value?.to
-    if (first) {
-      return resolveEntryPoint(first.name, first.path)
-    }
-  }
+      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)
 
 
-  const result = await arborist
-    .reify({
-      add: [pkg],
-      save: true,
-      saveType: "prod",
+      return semver.lt(cachedVersion, latestVersion)
     })
     })
-    .catch((cause) => {
-      throw new InstallFailedError(
-        { pkg },
-        {
-          cause,
-        },
+
+    const add = Effect.fn("Npm.add")(function* (pkg: string) {
+      const dir = directory(pkg)
+
+      const tree = yield* reify({ dir, add: [pkg] })
+      const first = tree.edgesOut.values().next().value?.to
+      if (!first) return yield* new InstallFailedError({ add: [pkg], dir })
+      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
 
 
-  const first = result.edgesOut.values().next().value?.to
-  if (!first) throw new InstallFailedError({ pkg })
-  return resolveEntryPoint(first.name, first.path)
-}
+      yield* Effect.gen(function* () {
+        const nodeModulesExists = yield* afs.existsSafe(path.join(dir, "node_modules"))
+        if (!nodeModulesExists) {
+          yield* reify({ add: input?.add, dir })
+          return
+        }
+      }).pipe(Effect.withSpan("Npm.checkNodeModules"))
 
 
-export async function install(dir: string) {
-  await using _ = await Flock.acquire(`npm-install:${dir}`)
-  log.info("checking dependencies", { dir })
-
-  const reify = async () => {
-    const { Arborist } = await import("@npmcli/arborist")
-    const arb = new Arborist({
-      path: dir,
-      binLinks: true,
-      progress: false,
-      savePrefix: "",
-      ignoreScripts: true,
-    })
-    await arb.reify().catch(() => {})
-  }
+      yield* Effect.gen(function* () {
+        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(() => ({})))
 
 
-  if (!(await Filesystem.exists(path.join(dir, "node_modules")))) {
-    log.info("node_modules missing, reifying")
-    await reify()
-    return
-  }
+        const pkgAny = pkg as any
+        const lockAny = lock as any
+        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({ dir, add: input?.add })
+            return
+          }
+        }
+      }).pipe(Effect.withSpan("Npm.checkDirty"))
 
 
-  type PackageDeps = Record<string, string>
-  type PackageJson = {
-    dependencies?: PackageDeps
-    devDependencies?: PackageDeps
-    peerDependencies?: PackageDeps
-    optionalDependencies?: PackageDeps
-  }
-  const pkg: PackageJson = await Filesystem.readJson<PackageJson>(path.join(dir, "package.json")).catch(() => ({}))
-  const lock: { packages?: Record<string, PackageJson> } = await Filesystem.readJson<{
-    packages?: Record<string, PackageJson>
-  }>(path.join(dir, "package-lock.json")).catch(() => ({}))
-
-  const declared = new Set([
-    ...Object.keys(pkg.dependencies || {}),
-    ...Object.keys(pkg.devDependencies || {}),
-    ...Object.keys(pkg.peerDependencies || {}),
-    ...Object.keys(pkg.optionalDependencies || {}),
-  ])
-
-  const root = lock.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)) {
-      log.info("dependency not in lock file, reifying", { name })
-      await reify()
       return
       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<string>()
+        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<string, string> }
+          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))
+        }
 
 
-  log.info("dependencies in sync")
+        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<string>()
+        return Option.some(path.join(binDir, resolved.value))
+      }).pipe(
+        Effect.scoped,
+        Effect.orElseSucceed(() => Option.none<string>()),
+      )
+    })
+
+    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),
+)
+
+const { runPromise } = makeRuntime(Service, defaultLayer)
+
+export async function install(...args: Parameters<Interface["install"]>) {
+  return runPromise((svc) => svc.install(...args))
 }
 }
 
 
-export async function which(pkg: string) {
-  const dir = directory(pkg)
-  const binDir = path.join(dir, "node_modules", ".bin")
-
-  const pick = async () => {
-    const files = await readdir(binDir).catch(() => [])
-    if (files.length === 0) return undefined
-    if (files.length === 1) return files[0]
-    // Multiple binaries — resolve from package.json bin field like npx does
-    const pkgJson = await Filesystem.readJson<{ bin?: string | Record<string, string> }>(
-      path.join(dir, "node_modules", pkg, "package.json"),
-    ).catch(() => undefined)
-    if (pkgJson?.bin) {
-      const unscoped = pkg.startsWith("@") ? pkg.split("/")[1] : pkg
-      const bin = pkgJson.bin
-      if (typeof bin === "string") return unscoped
-      const keys = Object.keys(bin)
-      if (keys.length === 1) return keys[0]
-      return bin[unscoped] ? unscoped : keys[0]
-    }
-    return files[0]
+export async function add(...args: Parameters<Interface["add"]>) {
+  const entry = await runPromise((svc) => svc.add(...args))
+  return {
+    directory: entry.directory,
+    entrypoint: Option.getOrUndefined(entry.entrypoint),
   }
   }
+}
 
 
-  const bin = await pick()
-  if (bin) return path.join(binDir, bin)
-
-  await rm(path.join(dir, "package-lock.json"), { force: true })
-  await add(pkg)
-  const resolved = await pick()
-  if (!resolved) return
-  return path.join(binDir, resolved)
+export async function outdated(...args: Parameters<Interface["outdated"]>) {
+  return runPromise((svc) => svc.outdated(...args))
 }
 }
 
 
-export * as Npm from "."
+export async function which(...args: Parameters<Interface["which"]>) {
+  const resolved = await runPromise((svc) => svc.which(...args))
+  return Option.getOrUndefined(resolved)
+}

+ 1 - 1
packages/opencode/src/plugin/shared.ts

@@ -4,7 +4,7 @@ import npa from "npm-package-arg"
 import semver from "semver"
 import semver from "semver"
 import { Filesystem } from "@/util"
 import { Filesystem } from "@/util"
 import { isRecord } from "@/util/record"
 import { isRecord } from "@/util/record"
-import { Npm } from "@/npm/effect"
+import { Npm } from "@/npm"
 
 
 // Old npm package names for plugins that are now built-in
 // Old npm package names for plugins that are now built-in
 export const DEPRECATED_PLUGIN_PACKAGES = ["opencode-openai-codex-auth", "opencode-copilot-auth"]
 export const DEPRECATED_PLUGIN_PACKAGES = ["opencode-openai-codex-auth", "opencode-copilot-auth"]

+ 7 - 8
packages/opencode/test/cli/tui/plugin-loader-entrypoint.test.ts

@@ -1,4 +1,3 @@
-import { Option } from "effect"
 import { expect, spyOn, test } from "bun:test"
 import { expect, spyOn, test } from "bun:test"
 import fs from "fs/promises"
 import fs from "fs/promises"
 import path from "path"
 import path from "path"
@@ -6,7 +5,7 @@ import { pathToFileURL } from "url"
 import { tmpdir } from "../../fixture/fixture"
 import { tmpdir } from "../../fixture/fixture"
 import { createTuiPluginApi } from "../../fixture/tui-plugin"
 import { createTuiPluginApi } from "../../fixture/tui-plugin"
 import { TuiConfig } from "../../../src/cli/cmd/tui/config/tui"
 import { TuiConfig } from "../../../src/cli/cmd/tui/config/tui"
-import { Npm } from "../../../src/npm/effect"
+import { Npm } from "../../../src/npm"
 
 
 const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime")
 const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime")
 
 
@@ -57,7 +56,7 @@ test("loads npm tui plugin from package ./tui export", async () => {
   }
   }
   const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
   const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
   const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
   const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
-  const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: Option.none() })
+  const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
 
 
   try {
   try {
     await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
     await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
@@ -118,7 +117,7 @@ test("does not use npm package exports dot for tui entry", async () => {
   }
   }
   const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
   const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
   const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
   const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
-  const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: Option.none() })
+  const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
 
 
   try {
   try {
     await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
     await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
@@ -180,7 +179,7 @@ test("rejects npm tui export that resolves outside plugin directory", async () =
   }
   }
   const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
   const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
   const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
   const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
-  const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: Option.none() })
+  const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
 
 
   try {
   try {
     await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
     await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
@@ -242,7 +241,7 @@ test("rejects npm tui plugin that exports server and tui together", async () =>
   }
   }
   const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
   const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
   const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
   const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
-  const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: Option.none() })
+  const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
 
 
   try {
   try {
     await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
     await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
@@ -300,7 +299,7 @@ test("does not use npm package main for tui entry", async () => {
   }
   }
   const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
   const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
   const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
   const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
-  const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: Option.none() })
+  const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
   const warn = spyOn(console, "warn").mockImplementation(() => {})
   const warn = spyOn(console, "warn").mockImplementation(() => {})
   const error = spyOn(console, "error").mockImplementation(() => {})
   const error = spyOn(console, "error").mockImplementation(() => {})
 
 
@@ -469,7 +468,7 @@ test("uses npm package name when tui plugin id is omitted", async () => {
   }
   }
   const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
   const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
   const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
   const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
-  const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: Option.none() })
+  const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
 
 
   try {
   try {
     await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
     await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })

+ 1 - 1
packages/opencode/test/config/config.test.ts

@@ -27,7 +27,7 @@ import { Global } from "../../src/global"
 import { ProjectID } from "../../src/project/schema"
 import { ProjectID } from "../../src/project/schema"
 import { Filesystem } from "../../src/util"
 import { Filesystem } from "../../src/util"
 import { ConfigPlugin } from "@/config/plugin"
 import { ConfigPlugin } from "@/config/plugin"
-import { Npm } from "@/npm/effect"
+import { Npm } from "@/npm"
 
 
 const emptyAccount = Layer.mock(Account.Service)({
 const emptyAccount = Layer.mock(Account.Service)({
   active: () => Effect.succeed(Option.none()),
   active: () => Effect.succeed(Option.none()),

+ 12 - 12
packages/opencode/test/plugin/loader-shared.test.ts

@@ -1,5 +1,5 @@
 import { afterAll, afterEach, describe, expect, spyOn, test } from "bun:test"
 import { afterAll, afterEach, describe, expect, spyOn, test } from "bun:test"
-import { Effect, Option } from "effect"
+import { Effect } from "effect"
 import fs from "fs/promises"
 import fs from "fs/promises"
 import path from "path"
 import path from "path"
 import { pathToFileURL } from "url"
 import { pathToFileURL } from "url"
@@ -13,7 +13,7 @@ const { Plugin } = await import("../../src/plugin/index")
 const { PluginLoader } = await import("../../src/plugin/loader")
 const { PluginLoader } = await import("../../src/plugin/loader")
 const { readPackageThemes } = await import("../../src/plugin/shared")
 const { readPackageThemes } = await import("../../src/plugin/shared")
 const { Instance } = await import("../../src/project/instance")
 const { Instance } = await import("../../src/project/instance")
-const { Npm } = await import("../../src/npm/effect")
+const { Npm } = await import("../../src/npm")
 
 
 afterAll(() => {
 afterAll(() => {
   if (disableDefault === undefined) {
   if (disableDefault === undefined) {
@@ -239,8 +239,8 @@ describe("plugin.loader.shared", () => {
     })
     })
 
 
     const add = spyOn(Npm, "add").mockImplementation(async (pkg) => {
     const add = spyOn(Npm, "add").mockImplementation(async (pkg) => {
-      if (pkg === "acme-plugin") return { directory: tmp.extra.acme, entrypoint: Option.none() }
-      return { directory: tmp.extra.scope, entrypoint: Option.none() }
+      if (pkg === "acme-plugin") return { directory: tmp.extra.acme, entrypoint: undefined }
+      return { directory: tmp.extra.scope, entrypoint: undefined }
     })
     })
 
 
     try {
     try {
@@ -301,7 +301,7 @@ describe("plugin.loader.shared", () => {
       },
       },
     })
     })
 
 
-    const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: Option.none() })
+    const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
 
 
     try {
     try {
       await load(tmp.path)
       await load(tmp.path)
@@ -358,7 +358,7 @@ describe("plugin.loader.shared", () => {
       },
       },
     })
     })
 
 
-    const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: Option.none() })
+    const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
 
 
     try {
     try {
       await load(tmp.path)
       await load(tmp.path)
@@ -410,7 +410,7 @@ describe("plugin.loader.shared", () => {
       },
       },
     })
     })
 
 
-    const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: Option.none() })
+    const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
 
 
     try {
     try {
       await load(tmp.path)
       await load(tmp.path)
@@ -455,7 +455,7 @@ describe("plugin.loader.shared", () => {
       },
       },
     })
     })
 
 
-    const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: Option.none() })
+    const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
 
 
     try {
     try {
       await load(tmp.path)
       await load(tmp.path)
@@ -518,7 +518,7 @@ describe("plugin.loader.shared", () => {
       },
       },
     })
     })
 
 
-    const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: Option.none() })
+    const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
 
 
     try {
     try {
       await load(tmp.path)
       await load(tmp.path)
@@ -548,7 +548,7 @@ describe("plugin.loader.shared", () => {
       },
       },
     })
     })
 
 
-    const install = spyOn(Npm, "add").mockResolvedValue({ directory: "", entrypoint: Option.none() })
+    const install = spyOn(Npm, "add").mockResolvedValue({ directory: "", entrypoint: undefined })
 
 
     try {
     try {
       await load(tmp.path)
       await load(tmp.path)
@@ -927,7 +927,7 @@ export default {
       },
       },
     })
     })
 
 
-    const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: Option.none() })
+    const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
     const missing: string[] = []
     const missing: string[] = []
 
 
     try {
     try {
@@ -996,7 +996,7 @@ export default {
       },
       },
     })
     })
 
 
-    const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: Option.none() })
+    const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
 
 
     try {
     try {
       const loaded = await PluginLoader.loadExternal({
       const loaded = await PluginLoader.loadExternal({

+ 46 - 41
packages/shared/src/util/effect-flock.ts

@@ -165,55 +165,60 @@ export namespace EffectFlock {
 
 
       type Handle = { token: string; metaPath: string; heartbeatPath: string; lockDir: string }
       type Handle = { token: string; metaPath: string; heartbeatPath: string; lockDir: string }
 
 
-      const tryAcquireLockDir = Effect.fn("EffectFlock.tryAcquire")(function* (lockDir: string) {
-        const token = randomUUID()
-        const metaPath = path.join(lockDir, "meta.json")
-        const heartbeatPath = path.join(lockDir, "heartbeat")
-
-        // Atomic mkdir — the POSIX lock primitive
-        const created = yield* atomicMkdir(lockDir)
-
-        if (!created) {
-          if (!(yield* isStale(lockDir, heartbeatPath, metaPath))) return yield* new NotAcquired()
-
-          // Stale — race for breaker ownership
-          const breakerPath = lockDir + ".breaker"
-
-          const claimed = yield* fs.makeDirectory(breakerPath, { mode: 0o700 }).pipe(
-            Effect.as(true),
-            Effect.catchIf(
-              (e) => e.reason._tag === "AlreadyExists",
-              () => cleanStaleBreaker(breakerPath),
-            ),
-            Effect.catchIf(isPathGone, () => Effect.succeed(false)),
-            Effect.orDie,
-          )
-
-          if (!claimed) return yield* new NotAcquired()
-
-          // We own the breaker — double-check staleness, nuke, recreate
-          const recreated = yield* Effect.gen(function* () {
-            if (!(yield* isStale(lockDir, heartbeatPath, metaPath))) return false
-            yield* forceRemove(lockDir)
-            return yield* atomicMkdir(lockDir)
-          }).pipe(Effect.ensuring(forceRemove(breakerPath)))
+      const tryAcquireLockDir = (lockDir: string, key: string) =>
+        Effect.gen(function* () {
+          const token = randomUUID()
+          const metaPath = path.join(lockDir, "meta.json")
+          const heartbeatPath = path.join(lockDir, "heartbeat")
+
+          // Atomic mkdir — the POSIX lock primitive
+          const created = yield* atomicMkdir(lockDir)
+
+          if (!created) {
+            if (!(yield* isStale(lockDir, heartbeatPath, metaPath))) return yield* new NotAcquired()
+
+            // Stale — race for breaker ownership
+            const breakerPath = lockDir + ".breaker"
+
+            const claimed = yield* fs.makeDirectory(breakerPath, { mode: 0o700 }).pipe(
+              Effect.as(true),
+              Effect.catchIf(
+                (e) => e.reason._tag === "AlreadyExists",
+                () => cleanStaleBreaker(breakerPath),
+              ),
+              Effect.catchIf(isPathGone, () => Effect.succeed(false)),
+              Effect.orDie,
+            )
+
+            if (!claimed) return yield* new NotAcquired()
+
+            // We own the breaker — double-check staleness, nuke, recreate
+            const recreated = yield* Effect.gen(function* () {
+              if (!(yield* isStale(lockDir, heartbeatPath, metaPath))) return false
+              yield* forceRemove(lockDir)
+              return yield* atomicMkdir(lockDir)
+            }).pipe(Effect.ensuring(forceRemove(breakerPath)))
 
 
-          if (!recreated) return yield* new NotAcquired()
-        }
+            if (!recreated) return yield* new NotAcquired()
+          }
 
 
-        // We own the lock dir — write heartbeat + meta with exclusive create
-        yield* exclusiveWrite(heartbeatPath, "", lockDir, "heartbeat already existed")
+          // We own the lock dir — write heartbeat + meta with exclusive create
+          yield* exclusiveWrite(heartbeatPath, "", lockDir, "heartbeat already existed")
 
 
-        const metaJson = encodeMeta({ token, pid: process.pid, hostname, createdAt: new Date().toISOString() })
-        yield* exclusiveWrite(metaPath, metaJson, lockDir, "meta.json already existed")
+          const metaJson = encodeMeta({ token, pid: process.pid, hostname, createdAt: new Date().toISOString() })
+          yield* exclusiveWrite(metaPath, metaJson, lockDir, "meta.json already existed")
 
 
-        return { token, metaPath, heartbeatPath, lockDir } satisfies Handle
-      })
+          return { token, metaPath, heartbeatPath, lockDir } satisfies Handle
+        }).pipe(
+          Effect.withSpan("EffectFlock.tryAcquire", {
+            attributes: { key },
+          }),
+        )
 
 
       // -- retry wrapper (preserves Handle type) --
       // -- retry wrapper (preserves Handle type) --
 
 
       const acquireHandle = (lockfile: string, key: string): Effect.Effect<Handle, LockError> =>
       const acquireHandle = (lockfile: string, key: string): Effect.Effect<Handle, LockError> =>
-        tryAcquireLockDir(lockfile).pipe(
+        tryAcquireLockDir(lockfile, key).pipe(
           Effect.retry({
           Effect.retry({
             while: (err) => err._tag === "NotAcquired",
             while: (err) => err._tag === "NotAcquired",
             schedule: retrySchedule,
             schedule: retrySchedule,