From 2f73e73e9d03262fb59d4e942b3e1e073cb76cb9 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Fri, 17 Apr 2026 14:08:23 -0400 Subject: [PATCH] trace npm fully --- .opencode/opencode.jsonc | 6 +- .../opencode/src/cli/cmd/tui/config/tui.ts | 4 +- packages/opencode/src/cli/cmd/tui/layer.ts | 2 +- packages/opencode/src/config/config.ts | 2 +- packages/opencode/src/effect/app-runtime.ts | 5 +- .../opencode/src/effect/bootstrap-runtime.ts | 2 +- packages/opencode/src/effect/memo-map.ts | 3 + packages/opencode/src/effect/run-service.ts | 3 +- .../opencode/src/{cli => }/effect/runtime.ts | 5 +- packages/opencode/src/npm/effect.ts | 261 ++++++++++++++++++ packages/opencode/src/plugin/shared.ts | 2 +- .../server/routes/instance/httpapi/server.ts | 2 +- packages/opencode/test/config/config.test.ts | 2 +- packages/opencode/test/tool/read.test.ts | 1 - packages/shared/src/npm.ts | 249 ----------------- packages/shared/test/npm.test.ts | 18 -- 16 files changed, 279 insertions(+), 288 deletions(-) create mode 100644 packages/opencode/src/effect/memo-map.ts rename packages/opencode/src/{cli => }/effect/runtime.ts (90%) create mode 100644 packages/opencode/src/npm/effect.ts delete mode 100644 packages/shared/src/npm.ts delete mode 100644 packages/shared/test/npm.test.ts diff --git a/.opencode/opencode.jsonc b/.opencode/opencode.jsonc index 8380f7f719..82ab6d1b35 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 a5c9ae0430..abcf11fcef 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 734106f8a6..66497f8b1a 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 ebd4a41fcb..8980765b79 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 eae52d6366..262d85e7ea 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 62b71e58b1..37698c43a5 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 0000000000..c797dbf42e --- /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 28265f9b27..98ff83ea59 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 similarity index 90% rename from packages/opencode/src/cli/effect/runtime.ts rename to packages/opencode/src/effect/runtime.ts index 57b9f8ede9..ad7872f0b5 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(service: Context.Service, layer: Layer.Layer) { let rt: ManagedRuntime.ManagedRuntime | undefined diff --git a/packages/opencode/src/npm/effect.ts b/packages/opencode/src/npm/effect.ts new file mode 100644 index 0000000000..10b5ff179c --- /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()("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 +} + +export interface Interface { + readonly add: (pkg: string) => Effect.Effect + readonly install: ( + dir: string, + input?: { add: string[] }, + ) => Effect.Effect + readonly outdated: (pkg: string, cachedVersion: string) => Effect.Effect + readonly which: (pkg: string) => Effect.Effect> +} + +export class Service extends Context.Service()("@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 + 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 +} + +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 + }).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() + 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 } + 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() + return Option.some(path.join(binDir, resolved.value)) + }).pipe( + Effect.scoped, + Effect.orElseSucceed(() => Option.none()), + ) + }) + + 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) { + return runPromise((svc) => svc.install(...args)) +} + +export async function add(...args: Parameters) { + return runPromise((svc) => svc.add(...args)) +} diff --git a/packages/opencode/src/plugin/shared.ts b/packages/opencode/src/plugin/shared.ts index 11f36c41ae..f431204fc4 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 b4442d6400..d012e2c166 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 a321b558cf..7b01ee626a 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 c3d7074bfb..7456990ad0 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 865e827b31..0000000000 --- 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()("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 - } - - export interface Interface { - readonly add: (pkg: string) => Effect.Effect - readonly install: ( - dir: string, - input?: { add: string[] }, - ) => Effect.Effect - readonly outdated: (pkg: string, cachedVersion: string) => Effect.Effect - readonly which: (pkg: string) => Effect.Effect> - } - - export class Service extends Context.Service()("@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 - 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 - } - - 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 - }).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() - 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 } - 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() - return Option.some(path.join(binDir, resolved.value)) - }).pipe( - Effect.scoped, - Effect.orElseSucceed(() => Option.none()), - ) - }) - - 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 4443d2985c..0000000000 --- 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/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) - }) -})