summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorDax <[email protected]>2026-04-17 14:58:37 -0400
committerGitHub <[email protected]>2026-04-17 18:58:37 +0000
commit467be08e679d82c20164870f067eb759abe5f6ec (patch)
treef71da136ccb53497c9c4f453367f83f0691b2d33
parentbbb422d1250da1400c9c228d363bebb336e238ca (diff)
downloadopencode-467be08e679d82c20164870f067eb759abe5f6ec.tar.gz
opencode-467be08e679d82c20164870f067eb759abe5f6ec.zip
refactor: consolidate npm exports and trace flock acquisition (#23151)
-rw-r--r--packages/opencode/src/cli/cmd/tui/config/tui.ts2
-rw-r--r--packages/opencode/src/cli/cmd/tui/layer.ts2
-rw-r--r--packages/opencode/src/config/config.ts2
-rw-r--r--packages/opencode/src/effect/app-runtime.ts2
-rw-r--r--packages/opencode/src/npm/effect.ts258
-rw-r--r--packages/opencode/src/npm/index.ts401
-rw-r--r--packages/opencode/src/plugin/shared.ts2
-rw-r--r--packages/opencode/test/cli/tui/plugin-loader-entrypoint.test.ts15
-rw-r--r--packages/opencode/test/config/config.test.ts2
-rw-r--r--packages/opencode/test/plugin/loader-shared.test.ts24
-rw-r--r--packages/shared/src/util/effect-flock.ts87
11 files changed, 308 insertions, 489 deletions
diff --git a/packages/opencode/src/cli/cmd/tui/config/tui.ts b/packages/opencode/src/cli/cmd/tui/config/tui.ts
index abcf11fce..179046e02 100644
--- a/packages/opencode/src/cli/cmd/tui/config/tui.ts
+++ b/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 { Filesystem, Log } from "@/util"
import { ConfigVariable } from "@/config/variable"
-import { Npm } from "@/npm/effect"
+import { Npm } from "@/npm"
const log = Log.create({ service: "tui.config" })
diff --git a/packages/opencode/src/cli/cmd/tui/layer.ts b/packages/opencode/src/cli/cmd/tui/layer.ts
index 66497f8b1..64cba08e8 100644
--- a/packages/opencode/src/cli/cmd/tui/layer.ts
+++ b/packages/opencode/src/cli/cmd/tui/layer.ts
@@ -1,6 +1,6 @@
import { Layer } from "effect"
import { TuiConfig } from "./config/tui"
-import { Npm } from "@/npm/effect"
+import { Npm } from "@/npm"
import { Observability } from "@/effect/observability"
export const CliLayer = Observability.layer.pipe(Layer.merge(TuiConfig.layer), Layer.provide(Npm.defaultLayer))
diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts
index 8980765b7..459f76961 100644
--- a/packages/opencode/src/config/config.ts
+++ b/packages/opencode/src/config/config.ts
@@ -38,7 +38,7 @@ import { ConfigPaths } from "./paths"
import { ConfigFormatter } from "./formatter"
import { ConfigLSP } from "./lsp"
import { ConfigVariable } from "./variable"
-import { Npm } from "@/npm/effect"
+import { Npm } from "@/npm"
const log = Log.create({ service: "config" })
diff --git a/packages/opencode/src/effect/app-runtime.ts b/packages/opencode/src/effect/app-runtime.ts
index 262d85e7e..d68e00a32 100644
--- a/packages/opencode/src/effect/app-runtime.ts
+++ b/packages/opencode/src/effect/app-runtime.ts
@@ -46,7 +46,7 @@ import { Pty } from "@/pty"
import { Installation } from "@/installation"
import { ShareNext } from "@/share"
import { SessionShare } from "@/share"
-import { Npm } from "@/npm/effect"
+import { Npm } from "@/npm"
import { memoMap } from "./memo-map"
export const AppLayer = Layer.mergeAll(
diff --git a/packages/opencode/src/npm/effect.ts b/packages/opencode/src/npm/effect.ts
deleted file mode 100644
index 5968f1451..000000000
--- a/packages/opencode/src/npm/effect.ts
+++ /dev/null
@@ -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))
-}
diff --git a/packages/opencode/src/npm/index.ts b/packages/opencode/src/npm/index.ts
index 425b27f42..f24259819 100644
--- a/packages/opencode/src/npm/index.ts
+++ b/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 { 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) {
if (!illegal) return pkg
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 {
- 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,
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
- }
- }
+ }, 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)
+}
diff --git a/packages/opencode/src/plugin/shared.ts b/packages/opencode/src/plugin/shared.ts
index f431204fc..ca821216d 100644
--- a/packages/opencode/src/plugin/shared.ts
+++ b/packages/opencode/src/plugin/shared.ts
@@ -4,7 +4,7 @@ import npa from "npm-package-arg"
import semver from "semver"
import { Filesystem } from "@/util"
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
export const DEPRECATED_PLUGIN_PACKAGES = ["opencode-openai-codex-auth", "opencode-copilot-auth"]
diff --git a/packages/opencode/test/cli/tui/plugin-loader-entrypoint.test.ts b/packages/opencode/test/cli/tui/plugin-loader-entrypoint.test.ts
index c6c25fcc1..74236afae 100644
--- a/packages/opencode/test/cli/tui/plugin-loader-entrypoint.test.ts
+++ b/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 fs from "fs/promises"
import path from "path"
@@ -6,7 +5,7 @@ import { pathToFileURL } from "url"
import { tmpdir } from "../../fixture/fixture"
import { createTuiPluginApi } from "../../fixture/tui-plugin"
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")
@@ -57,7 +56,7 @@ test("loads npm tui plugin from package ./tui export", async () => {
}
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
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 {
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 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 {
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 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 {
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 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 {
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 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 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 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 {
await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts
index 7b01ee626..9f2bf9db9 100644
--- a/packages/opencode/test/config/config.test.ts
+++ b/packages/opencode/test/config/config.test.ts
@@ -27,7 +27,7 @@ import { Global } from "../../src/global"
import { ProjectID } from "../../src/project/schema"
import { Filesystem } from "../../src/util"
import { ConfigPlugin } from "@/config/plugin"
-import { Npm } from "@/npm/effect"
+import { Npm } from "@/npm"
const emptyAccount = Layer.mock(Account.Service)({
active: () => Effect.succeed(Option.none()),
diff --git a/packages/opencode/test/plugin/loader-shared.test.ts b/packages/opencode/test/plugin/loader-shared.test.ts
index 8e3ad5ea0..83e9d71b4 100644
--- a/packages/opencode/test/plugin/loader-shared.test.ts
+++ b/packages/opencode/test/plugin/loader-shared.test.ts
@@ -1,5 +1,5 @@
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 path from "path"
import { pathToFileURL } from "url"
@@ -13,7 +13,7 @@ const { Plugin } = await import("../../src/plugin/index")
const { PluginLoader } = await import("../../src/plugin/loader")
const { readPackageThemes } = await import("../../src/plugin/shared")
const { Instance } = await import("../../src/project/instance")
-const { Npm } = await import("../../src/npm/effect")
+const { Npm } = await import("../../src/npm")
afterAll(() => {
if (disableDefault === undefined) {
@@ -239,8 +239,8 @@ describe("plugin.loader.shared", () => {
})
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 {
@@ -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 {
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 {
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 {
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 {
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 {
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 {
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[] = []
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 {
const loaded = await PluginLoader.loadExternal({
diff --git a/packages/shared/src/util/effect-flock.ts b/packages/shared/src/util/effect-flock.ts
index 3e00afc9e..16bcf091b 100644
--- a/packages/shared/src/util/effect-flock.ts
+++ b/packages/shared/src/util/effect-flock.ts
@@ -165,55 +165,60 @@ export namespace EffectFlock {
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) --
const acquireHandle = (lockfile: string, key: string): Effect.Effect<Handle, LockError> =>
- tryAcquireLockDir(lockfile).pipe(
+ tryAcquireLockDir(lockfile, key).pipe(
Effect.retry({
while: (err) => err._tag === "NotAcquired",
schedule: retrySchedule,