Przeglądaj źródła

trace npm fully

Dax Raad 3 dni temu
rodzic
commit
2f73e73e9d

+ 1 - 5
.opencode/opencode.jsonc

@@ -1,10 +1,6 @@
 {
 {
   "$schema": "https://opencode.ai/config.json",
   "$schema": "https://opencode.ai/config.json",
-  "provider": {
-    "opencode": {
-      "options": {},
-    },
-  },
+  "provider": {},
   "permission": {
   "permission": {
     "edit": {
     "edit": {
       "packages/opencode/migration/*": "deny",
       "packages/opencode/migration/*": "deny",

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

@@ -11,14 +11,14 @@ import { Flag } from "@/flag/flag"
 import { isRecord } from "@/util/record"
 import { isRecord } from "@/util/record"
 import { Global } from "@/global"
 import { Global } from "@/global"
 import { AppFileSystem } from "@opencode-ai/shared/filesystem"
 import { AppFileSystem } from "@opencode-ai/shared/filesystem"
-import { Npm } from "@opencode-ai/shared/npm"
 import { CurrentWorkingDirectory } from "./cwd"
 import { CurrentWorkingDirectory } from "./cwd"
 import { ConfigPlugin } from "@/config/plugin"
 import { ConfigPlugin } from "@/config/plugin"
 import { ConfigKeybinds } from "@/config/keybinds"
 import { ConfigKeybinds } from "@/config/keybinds"
 import { InstallationLocal, InstallationVersion } from "@/installation/version"
 import { InstallationLocal, InstallationVersion } from "@/installation/version"
-import { makeRuntime } from "@/cli/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"
 
 
 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 "@opencode-ai/shared/npm"
+import { Npm } from "@/npm/effect"
 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

@@ -24,7 +24,6 @@ import { InstanceState } from "@/effect"
 import { Context, Duration, Effect, Exit, Fiber, Layer, Option } from "effect"
 import { Context, Duration, Effect, Exit, Fiber, Layer, Option } from "effect"
 import { EffectFlock } from "@opencode-ai/shared/util/effect-flock"
 import { EffectFlock } from "@opencode-ai/shared/util/effect-flock"
 import { InstanceRef } from "@/effect/instance-ref"
 import { InstanceRef } from "@/effect/instance-ref"
-import { Npm } from "@opencode-ai/shared/npm"
 import { ConfigAgent } from "./agent"
 import { ConfigAgent } from "./agent"
 import { ConfigMCP } from "./mcp"
 import { ConfigMCP } from "./mcp"
 import { ConfigModelID } from "./model-id"
 import { ConfigModelID } from "./model-id"
@@ -39,6 +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"
 
 
 const log = Log.create({ service: "config" })
 const log = Log.create({ service: "config" })
 
 

+ 3 - 2
packages/opencode/src/effect/app-runtime.ts

@@ -1,5 +1,5 @@
 import { Layer, ManagedRuntime } from "effect"
 import { Layer, ManagedRuntime } from "effect"
-import { attach, memoMap } from "./run-service"
+import { attach } from "./run-service"
 import * as Observability from "./observability"
 import * as Observability from "./observability"
 
 
 import { AppFileSystem } from "@opencode-ai/shared/filesystem"
 import { AppFileSystem } from "@opencode-ai/shared/filesystem"
@@ -46,7 +46,8 @@ 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 "@opencode-ai/shared/npm"
+import { Npm } from "@/npm/effect"
+import { memoMap } from "./memo-map"
 
 
 export const AppLayer = Layer.mergeAll(
 export const AppLayer = Layer.mergeAll(
   Npm.defaultLayer,
   Npm.defaultLayer,

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

@@ -1,5 +1,4 @@
 import { Layer, ManagedRuntime } from "effect"
 import { Layer, ManagedRuntime } from "effect"
-import { memoMap } from "./run-service"
 
 
 import { Plugin } from "@/plugin"
 import { Plugin } from "@/plugin"
 import { LSP } from "@/lsp"
 import { LSP } from "@/lsp"
@@ -12,6 +11,7 @@ import { Snapshot } from "@/snapshot"
 import { Bus } from "@/bus"
 import { Bus } from "@/bus"
 import { Config } from "@/config"
 import { Config } from "@/config"
 import * as Observability from "./observability"
 import * as Observability from "./observability"
+import { memoMap } from "./memo-map"
 
 
 export const BootstrapLayer = Layer.mergeAll(
 export const BootstrapLayer = Layer.mergeAll(
   Config.defaultLayer,
   Config.defaultLayer,

+ 3 - 0
packages/opencode/src/effect/memo-map.ts

@@ -0,0 +1,3 @@
+import { Layer } from "effect"
+
+export const memoMap = Layer.makeMemoMapUnsafe()

+ 1 - 2
packages/opencode/src/effect/run-service.ts

@@ -6,8 +6,7 @@ import { InstanceRef, WorkspaceRef } from "./instance-ref"
 import * as Observability from "./observability"
 import * as Observability from "./observability"
 import { WorkspaceContext } from "@/control-plane/workspace-context"
 import { WorkspaceContext } from "@/control-plane/workspace-context"
 import type { InstanceContext } from "@/project/instance"
 import type { InstanceContext } from "@/project/instance"
-
-export const memoMap = Layer.makeMemoMapUnsafe()
+import { memoMap } from "./memo-map"
 
 
 type Refs = {
 type Refs = {
   instance?: InstanceContext
   instance?: InstanceContext

+ 2 - 3
packages/opencode/src/cli/effect/runtime.ts → packages/opencode/src/effect/runtime.ts

@@ -1,7 +1,6 @@
-import { Observability } from "@/effect/observability"
+import { Observability } from "./observability"
 import { Layer, type Context, ManagedRuntime, type Effect } from "effect"
 import { Layer, type Context, ManagedRuntime, type Effect } from "effect"
-
-export const memoMap = Layer.makeMemoMapUnsafe()
+import { memoMap } from "./memo-map"
 
 
 export function makeRuntime<I, S, E>(service: Context.Service<I, S>, layer: Layer.Layer<I, E>) {
 export function makeRuntime<I, S, E>(service: Context.Service<I, S>, layer: Layer.Layer<I, E>) {
   let rt: ManagedRuntime.ManagedRuntime<I, E> | undefined
   let rt: ManagedRuntime.ManagedRuntime<I, E> | undefined

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

@@ -0,0 +1,261 @@
+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 }>
+}
+
+const reify = (input: { dir: string; add?: string[] }) =>
+  Effect.gen(function* () {
+    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,
+    }),
+  )
+
+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 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)
+      yield* flock.acquire(`npm-install:${dir}`)
+
+      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* flock.acquire(`npm-install:${dir}`)
+
+      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))
+}

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

@@ -2,9 +2,9 @@ import path from "path"
 import { fileURLToPath, pathToFileURL } from "url"
 import { fileURLToPath, pathToFileURL } from "url"
 import npa from "npm-package-arg"
 import npa from "npm-package-arg"
 import semver from "semver"
 import semver from "semver"
-import { Npm } from "../npm"
 import { Filesystem } from "@/util"
 import { Filesystem } from "@/util"
 import { isRecord } from "@/util/record"
 import { isRecord } from "@/util/record"
+import { Npm } from "@/npm/effect"
 
 
 // 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"]

+ 1 - 1
packages/opencode/src/server/routes/instance/httpapi/server.ts

@@ -4,7 +4,6 @@ import { HttpRouter, HttpServer, HttpServerRequest } from "effect/unstable/http"
 import { AppRuntime } from "@/effect/app-runtime"
 import { AppRuntime } from "@/effect/app-runtime"
 import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref"
 import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref"
 import { Observability } from "@/effect"
 import { Observability } from "@/effect"
-import { memoMap } from "@/effect/run-service"
 import { Flag } from "@/flag/flag"
 import { Flag } from "@/flag/flag"
 import { InstanceBootstrap } from "@/project/bootstrap"
 import { InstanceBootstrap } from "@/project/bootstrap"
 import { Instance } from "@/project/instance"
 import { Instance } from "@/project/instance"
@@ -15,6 +14,7 @@ import { PermissionApi, permissionHandlers } from "./permission"
 import { ProjectApi, projectHandlers } from "./project"
 import { ProjectApi, projectHandlers } from "./project"
 import { ProviderApi, providerHandlers } from "./provider"
 import { ProviderApi, providerHandlers } from "./provider"
 import { QuestionApi, questionHandlers } from "./question"
 import { QuestionApi, questionHandlers } from "./question"
+import { memoMap } from "@/effect/memo-map"
 
 
 const Query = Schema.Struct({
 const Query = Schema.Struct({
   directory: Schema.optional(Schema.String),
   directory: Schema.optional(Schema.String),

+ 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 "@opencode-ai/shared/npm"
+import { Npm } from "@/npm/effect"
 
 
 const emptyAccount = Layer.mock(Account.Service)({
 const emptyAccount = Layer.mock(Account.Service)({
   active: () => Effect.succeed(Option.none()),
   active: () => Effect.succeed(Option.none()),

+ 0 - 1
packages/opencode/test/tool/read.test.ts

@@ -15,7 +15,6 @@ import { Tool } from "../../src/tool"
 import { Filesystem } from "../../src/util"
 import { Filesystem } from "../../src/util"
 import { provideInstance, tmpdirScoped } from "../fixture/fixture"
 import { provideInstance, tmpdirScoped } from "../fixture/fixture"
 import { testEffect } from "../lib/effect"
 import { testEffect } from "../lib/effect"
-import { Npm } from "@opencode-ai/shared/npm"
 
 
 const FIXTURES_DIR = path.join(import.meta.dir, "fixtures")
 const FIXTURES_DIR = path.join(import.meta.dir, "fixtures")
 
 

+ 0 - 249
packages/shared/src/npm.ts

@@ -1,249 +0,0 @@
-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 "./filesystem"
-import { Global } from "./global"
-import { EffectFlock } from "./util/effect-flock"
-
-export namespace Npm {
-  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 }>
-  }
-
-  const reify = (input: { dir: string; add?: string[] }) =>
-    Effect.gen(function* () {
-      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,
-      }),
-    )
-
-  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 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)
-        yield* flock.acquire(`npm-install:${dir}`)
-
-        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* flock.acquire(`npm-install:${dir}`)
-
-        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),
-  )
-}

+ 0 - 18
packages/shared/test/npm.test.ts

@@ -1,18 +0,0 @@
-import { describe, expect, test } from "bun:test"
-import { Npm } from "@opencode-ai/shared/npm"
-
-const win = process.platform === "win32"
-
-describe("Npm.sanitize", () => {
-  test("keeps normal scoped package specs unchanged", () => {
-    expect(Npm.sanitize("@opencode/acme")).toBe("@opencode/acme")
-    expect(Npm.sanitize("@opencode/[email protected]")).toBe("@opencode/[email protected]")
-    expect(Npm.sanitize("prettier")).toBe("prettier")
-  })
-
-  test("handles git https specs", () => {
-    const spec = "acme@git+https://github.com/opencode/acme.git"
-    const expected = win ? "acme@git+https_//github.com/opencode/acme.git" : spec
-    expect(Npm.sanitize(spec)).toBe(expected)
-  })
-})