summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorDax Raad <[email protected]>2026-04-17 14:08:23 -0400
committerDax Raad <[email protected]>2026-04-17 14:08:45 -0400
commit2f73e73e9d03262fb59d4e942b3e1e073cb76cb9 (patch)
tree620bd4ab144320057ebf8ed625bf4bd0eb1b3fff
parent4c30a78cd9623fe8f3a7c27860a7b8a0cc760e39 (diff)
downloadopencode-2f73e73e9d03262fb59d4e942b3e1e073cb76cb9.tar.gz
opencode-2f73e73e9d03262fb59d4e942b3e1e073cb76cb9.zip
trace npm fully
-rw-r--r--.opencode/opencode.jsonc6
-rw-r--r--packages/opencode/src/cli/cmd/tui/config/tui.ts4
-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.ts5
-rw-r--r--packages/opencode/src/effect/bootstrap-runtime.ts2
-rw-r--r--packages/opencode/src/effect/memo-map.ts3
-rw-r--r--packages/opencode/src/effect/run-service.ts3
-rw-r--r--packages/opencode/src/effect/runtime.ts (renamed from packages/opencode/src/cli/effect/runtime.ts)5
-rw-r--r--packages/opencode/src/npm/effect.ts261
-rw-r--r--packages/opencode/src/plugin/shared.ts2
-rw-r--r--packages/opencode/src/server/routes/instance/httpapi/server.ts2
-rw-r--r--packages/opencode/test/config/config.test.ts2
-rw-r--r--packages/opencode/test/tool/read.test.ts1
-rw-r--r--packages/shared/src/npm.ts249
-rw-r--r--packages/shared/test/npm.test.ts18
16 files changed, 279 insertions, 288 deletions
diff --git a/.opencode/opencode.jsonc b/.opencode/opencode.jsonc
index 8380f7f71..82ab6d1b3 100644
--- a/.opencode/opencode.jsonc
+++ b/.opencode/opencode.jsonc
@@ -1,10 +1,6 @@
{
"$schema": "https://opencode.ai/config.json",
- "provider": {
- "opencode": {
- "options": {},
- },
- },
+ "provider": {},
"permission": {
"edit": {
"packages/opencode/migration/*": "deny",
diff --git a/packages/opencode/src/cli/cmd/tui/config/tui.ts b/packages/opencode/src/cli/cmd/tui/config/tui.ts
index a5c9ae043..abcf11fce 100644
--- a/packages/opencode/src/cli/cmd/tui/config/tui.ts
+++ b/packages/opencode/src/cli/cmd/tui/config/tui.ts
@@ -11,14 +11,14 @@ import { Flag } from "@/flag/flag"
import { isRecord } from "@/util/record"
import { Global } from "@/global"
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
-import { Npm } from "@opencode-ai/shared/npm"
import { CurrentWorkingDirectory } from "./cwd"
import { ConfigPlugin } from "@/config/plugin"
import { ConfigKeybinds } from "@/config/keybinds"
import { InstallationLocal, InstallationVersion } from "@/installation/version"
-import { makeRuntime } from "@/cli/effect/runtime"
+import { makeRuntime } from "@/effect/runtime"
import { Filesystem, Log } from "@/util"
import { ConfigVariable } from "@/config/variable"
+import { Npm } from "@/npm/effect"
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 734106f8a..66497f8b1 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 "@opencode-ai/shared/npm"
+import { Npm } from "@/npm/effect"
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 ebd4a41fc..8980765b7 100644
--- a/packages/opencode/src/config/config.ts
+++ b/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 { EffectFlock } from "@opencode-ai/shared/util/effect-flock"
import { InstanceRef } from "@/effect/instance-ref"
-import { Npm } from "@opencode-ai/shared/npm"
import { ConfigAgent } from "./agent"
import { ConfigMCP } from "./mcp"
import { ConfigModelID } from "./model-id"
@@ -39,6 +38,7 @@ import { ConfigPaths } from "./paths"
import { ConfigFormatter } from "./formatter"
import { ConfigLSP } from "./lsp"
import { ConfigVariable } from "./variable"
+import { Npm } from "@/npm/effect"
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 eae52d636..262d85e7e 100644
--- a/packages/opencode/src/effect/app-runtime.ts
+++ b/packages/opencode/src/effect/app-runtime.ts
@@ -1,5 +1,5 @@
import { Layer, ManagedRuntime } from "effect"
-import { attach, memoMap } from "./run-service"
+import { attach } from "./run-service"
import * as Observability from "./observability"
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
@@ -46,7 +46,8 @@ import { Pty } from "@/pty"
import { Installation } from "@/installation"
import { ShareNext } 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(
Npm.defaultLayer,
diff --git a/packages/opencode/src/effect/bootstrap-runtime.ts b/packages/opencode/src/effect/bootstrap-runtime.ts
index 62b71e58b..37698c43a 100644
--- a/packages/opencode/src/effect/bootstrap-runtime.ts
+++ b/packages/opencode/src/effect/bootstrap-runtime.ts
@@ -1,5 +1,4 @@
import { Layer, ManagedRuntime } from "effect"
-import { memoMap } from "./run-service"
import { Plugin } from "@/plugin"
import { LSP } from "@/lsp"
@@ -12,6 +11,7 @@ import { Snapshot } from "@/snapshot"
import { Bus } from "@/bus"
import { Config } from "@/config"
import * as Observability from "./observability"
+import { memoMap } from "./memo-map"
export const BootstrapLayer = Layer.mergeAll(
Config.defaultLayer,
diff --git a/packages/opencode/src/effect/memo-map.ts b/packages/opencode/src/effect/memo-map.ts
new file mode 100644
index 000000000..c797dbf42
--- /dev/null
+++ b/packages/opencode/src/effect/memo-map.ts
@@ -0,0 +1,3 @@
+import { Layer } from "effect"
+
+export const memoMap = Layer.makeMemoMapUnsafe()
diff --git a/packages/opencode/src/effect/run-service.ts b/packages/opencode/src/effect/run-service.ts
index 28265f9b2..98ff83ea5 100644
--- a/packages/opencode/src/effect/run-service.ts
+++ b/packages/opencode/src/effect/run-service.ts
@@ -6,8 +6,7 @@ import { InstanceRef, WorkspaceRef } from "./instance-ref"
import * as Observability from "./observability"
import { WorkspaceContext } from "@/control-plane/workspace-context"
import type { InstanceContext } from "@/project/instance"
-
-export const memoMap = Layer.makeMemoMapUnsafe()
+import { memoMap } from "./memo-map"
type Refs = {
instance?: InstanceContext
diff --git a/packages/opencode/src/cli/effect/runtime.ts b/packages/opencode/src/effect/runtime.ts
index 57b9f8ede..ad7872f0b 100644
--- a/packages/opencode/src/cli/effect/runtime.ts
+++ b/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"
-
-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>) {
let rt: ManagedRuntime.ManagedRuntime<I, E> | undefined
diff --git a/packages/opencode/src/npm/effect.ts b/packages/opencode/src/npm/effect.ts
new file mode 100644
index 000000000..10b5ff179
--- /dev/null
+++ b/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))
+}
diff --git a/packages/opencode/src/plugin/shared.ts b/packages/opencode/src/plugin/shared.ts
index 11f36c41a..f431204fc 100644
--- a/packages/opencode/src/plugin/shared.ts
+++ b/packages/opencode/src/plugin/shared.ts
@@ -2,9 +2,9 @@ import path from "path"
import { fileURLToPath, pathToFileURL } from "url"
import npa from "npm-package-arg"
import semver from "semver"
-import { Npm } from "../npm"
import { Filesystem } from "@/util"
import { isRecord } from "@/util/record"
+import { Npm } from "@/npm/effect"
// 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/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts
index b4442d640..d012e2c16 100644
--- a/packages/opencode/src/server/routes/instance/httpapi/server.ts
+++ b/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 { InstanceRef, WorkspaceRef } from "@/effect/instance-ref"
import { Observability } from "@/effect"
-import { memoMap } from "@/effect/run-service"
import { Flag } from "@/flag/flag"
import { InstanceBootstrap } from "@/project/bootstrap"
import { Instance } from "@/project/instance"
@@ -15,6 +14,7 @@ import { PermissionApi, permissionHandlers } from "./permission"
import { ProjectApi, projectHandlers } from "./project"
import { ProviderApi, providerHandlers } from "./provider"
import { QuestionApi, questionHandlers } from "./question"
+import { memoMap } from "@/effect/memo-map"
const Query = Schema.Struct({
directory: Schema.optional(Schema.String),
diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts
index a321b558c..7b01ee626 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 "@opencode-ai/shared/npm"
+import { Npm } from "@/npm/effect"
const emptyAccount = Layer.mock(Account.Service)({
active: () => Effect.succeed(Option.none()),
diff --git a/packages/opencode/test/tool/read.test.ts b/packages/opencode/test/tool/read.test.ts
index c3d7074bf..7456990ad 100644
--- a/packages/opencode/test/tool/read.test.ts
+++ b/packages/opencode/test/tool/read.test.ts
@@ -15,7 +15,6 @@ import { Tool } from "../../src/tool"
import { Filesystem } from "../../src/util"
import { provideInstance, tmpdirScoped } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
-import { Npm } from "@opencode-ai/shared/npm"
const FIXTURES_DIR = path.join(import.meta.dir, "fixtures")
diff --git a/packages/shared/src/npm.ts b/packages/shared/src/npm.ts
deleted file mode 100644
index 865e827b3..000000000
--- a/packages/shared/src/npm.ts
+++ /dev/null
@@ -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),
- )
-}
diff --git a/packages/shared/test/npm.test.ts b/packages/shared/test/npm.test.ts
deleted file mode 100644
index 4443d2985..000000000
--- a/packages/shared/test/npm.test.ts
+++ /dev/null
@@ -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)
- })
-})