trace npm fully

This commit is contained in:
Dax Raad
2026-04-17 14:08:23 -04:00
parent 4c30a78cd9
commit 2f73e73e9d
16 changed files with 279 additions and 288 deletions

View File

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

View File

@@ -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" })

View File

@@ -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))

View File

@@ -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" })

View File

@@ -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,

View File

@@ -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,

View File

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

View File

@@ -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"
import { memoMap } from "./memo-map"
export const memoMap = Layer.makeMemoMapUnsafe()
type Refs = { type Refs = {
instance?: InstanceContext instance?: InstanceContext

View File

@@ -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"
import { memoMap } from "./memo-map"
export const memoMap = Layer.makeMemoMapUnsafe()
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

View File

@@ -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))
}

View File

@@ -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"]

View File

@@ -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),

View File

@@ -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()),

View File

@@ -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")

View File

@@ -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),
)
}

View File

@@ -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/acme@1.0.0")).toBe("@opencode/acme@1.0.0")
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)
})
})