Dax 4 месяцев назад
Родитель
Сommit
a9b62d67df

+ 1 - 0
packages/core/package.json

@@ -7,6 +7,7 @@
   "private": true,
   "scripts": {
     "test": "bun test",
+    "test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml",
     "typecheck": "tsgo --noEmit"
   },
   "bin": {

+ 40 - 0
packages/core/src/npm-config.ts

@@ -0,0 +1,40 @@
+export * as NpmConfig from "./npm-config"
+
+import { fileURLToPath } from "url"
+// @ts-expect-error npm does not publish types for this internal config API.
+import Config from "@npmcli/config"
+// @ts-expect-error npm does not publish types for this internal config API.
+import { definitions, flatten, nerfDarts, shorthands } from "@npmcli/config/lib/definitions/index.js"
+import { Effect } from "effect"
+
+const npmPath = fileURLToPath(new URL("..", import.meta.url))
+
+export const load = (dir: string) =>
+  Effect.tryPromise({
+    try: async () => {
+      const config = new Config({
+        npmPath,
+        cwd: dir,
+        env: { ...process.env },
+        argv: [process.execPath, process.execPath],
+        execPath: process.execPath,
+        platform: process.platform,
+        definitions,
+        flatten,
+        nerfDarts,
+        shorthands,
+        warn: false,
+      })
+      await config.load()
+      return config.flat as Record<string, unknown>
+    },
+    catch: (cause) => cause,
+  }).pipe(Effect.orElseSucceed(() => ({}) as Record<string, unknown>))
+
+export const registry = (dir: string) =>
+  load(dir).pipe(
+    Effect.map((config) => {
+      const registry = typeof config.registry === "string" ? config.registry : "https://registry.npmjs.org"
+      return registry.endsWith("/") ? registry.slice(0, -1) : registry
+    }),
+  )

+ 3 - 83
packages/core/src/npm.ts

@@ -1,22 +1,14 @@
 export * as Npm from "./npm"
 
 import path from "path"
-import { fileURLToPath } from "url"
 import npa from "npm-package-arg"
-import semver from "semver"
-// @ts-expect-error npm does not publish types for this internal config API.
-import Config from "@npmcli/config"
-// @ts-expect-error npm does not publish types for this internal config API.
-import { definitions, flatten, nerfDarts, shorthands } from "@npmcli/config/lib/definitions/index.js"
-import { Effect, Schema, Context, Layer, Option, FileSystem, Stream } from "effect"
+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"
 import { makeRuntime } from "./effect/runtime"
-import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
-
-import { CrossSpawnSpawner } from "./cross-spawn-spawner"
+import { NpmConfig } from "./npm-config"
 
 export class InstallFailedError extends Schema.TaggedErrorClass<InstallFailedError>()("NpmInstallFailedError", {
   add: Schema.Array(Schema.String).pipe(Schema.optional),
@@ -40,46 +32,18 @@ export interface Interface {
       }[]
     },
   ) => Effect.Effect<void, EffectFlock.LockError | InstallFailedError>
-  readonly outdated: (pkg: string, cachedVersion: string) => Effect.Effect<boolean>
   readonly which: (pkg: string, bin?: string) => Effect.Effect<Option.Option<string>>
 }
 
 export class Service extends Context.Service<Service, Interface>()("@opencode/Npm") {}
 
 const illegal = process.platform === "win32" ? new Set(["<", ">", ":", '"', "|", "?", "*"]) : undefined
-const npmPath = fileURLToPath(new URL("..", import.meta.url))
 
 export function sanitize(pkg: string) {
   if (!illegal) return pkg
   return Array.from(pkg, (char) => (illegal.has(char) || char.charCodeAt(0) < 32 ? "_" : char)).join("")
 }
 
-const loadOptions = (dir: string) =>
-  Effect.tryPromise({
-    try: async () => {
-      const config = new Config({
-        npmPath,
-        cwd: dir,
-        env: { ...process.env },
-        argv: [process.execPath, process.execPath],
-        execPath: process.execPath,
-        platform: process.platform,
-        definitions,
-        flatten,
-        nerfDarts,
-        shorthands,
-        warn: false,
-      })
-      await config.load()
-      return config.flat
-    },
-    catch: (cause) =>
-      new InstallFailedError({
-        cause,
-        dir,
-      }),
-  })
-
 const resolveEntryPoint = (name: string, dir: string): EntryPoint => {
   let entrypoint: Option.Option<string>
   try {
@@ -110,39 +74,13 @@ export const layer = Layer.effect(
     const global = yield* Global.Service
     const fs = yield* FileSystem.FileSystem
     const flock = yield* EffectFlock.Service
-    const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
     const directory = (pkg: string) => path.join(global.cache, "packages", sanitize(pkg))
-    const runView = Effect.fnUntraced(function* (cmd: string[]) {
-      const handle = yield* spawner.spawn(
-        ChildProcess.make(cmd[0], cmd.slice(1), {
-          extendEnv: true,
-        }),
-      )
-      const [stdout, stderr] = yield* Effect.all(
-        [Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))],
-        { concurrency: 2 },
-      )
-      const code = yield* handle.exitCode
-      if (code !== 0 || !stdout.trim()) {
-        return yield* Effect.fail(stderr || stdout || `Failed to run ${cmd.join(" ")}`)
-      }
-      return yield* Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.String))(stdout)
-    }, Effect.scoped)
-    const viewLatestVersion = Effect.fnUntraced(function* (pkg: string) {
-      return yield* runView(["npm", "view", pkg, "dist-tags.latest", "--json"]).pipe(
-        Effect.catch(() =>
-          runView(["pnpm", "view", pkg, "dist-tags.latest", "--json"]).pipe(
-            Effect.catch(() => runView(["bun", "pm", "view", pkg, "dist-tags.latest", "--json"])),
-          ),
-        ),
-      )
-    })
     const reify = (input: { dir: string; add?: string[] }) =>
       Effect.gen(function* () {
         yield* flock.acquire(`npm-install:${input.dir}`)
         const { Arborist } = yield* Effect.promise(() => import("@npmcli/arborist"))
         const add = input.add ?? []
-        const npmOptions = yield* loadOptions(input.dir)
+        const npmOptions = yield* NpmConfig.load(input.dir)
         const arborist = new Arborist({
           ...npmOptions,
           path: input.dir,
@@ -172,18 +110,6 @@ export const layer = Layer.effect(
         }),
       )
 
-    const outdated = Effect.fn("Npm.outdated")(function* (pkg: string, cachedVersion: string) {
-      const latestVersion = yield* viewLatestVersion(pkg).pipe(Effect.option)
-      if (Option.isNone(latestVersion)) {
-        return false
-      }
-
-      const range = /[\s^~*xX<>|=]/.test(cachedVersion)
-      if (range) return !semver.satisfies(latestVersion.value, cachedVersion)
-
-      return semver.lt(cachedVersion, latestVersion.value)
-    })
-
     const add = Effect.fn("Npm.add")(function* (pkg: string) {
       const dir = directory(pkg)
       const name = (() => {
@@ -309,7 +235,6 @@ export const layer = Layer.effect(
     return Service.of({
       add,
       install,
-      outdated,
       which,
     })
   }),
@@ -320,7 +245,6 @@ export const defaultLayer = layer.pipe(
   Layer.provide(AppFileSystem.layer),
   Layer.provide(Global.layer),
   Layer.provide(NodeFileSystem.layer),
-  Layer.provide(CrossSpawnSpawner.defaultLayer),
 )
 
 const { runPromise } = makeRuntime(Service, defaultLayer)
@@ -337,10 +261,6 @@ export async function add(...args: Parameters<Interface["add"]>) {
   }
 }
 
-export async function outdated(...args: Parameters<Interface["outdated"]>) {
-  return runPromise((svc) => svc.outdated(...args))
-}
-
 export async function which(...args: Parameters<Interface["which"]>) {
   const resolved = await runPromise((svc) => svc.which(...args))
   return Option.getOrUndefined(resolved)

+ 13 - 0
packages/core/test/fixture/tmpdir.ts

@@ -0,0 +1,13 @@
+import fs from "fs/promises"
+import { tmpdir as osTmpdir } from "os"
+import path from "path"
+
+export const tmpdir = async () => {
+  const dir = await fs.mkdtemp(path.join(osTmpdir(), "opencode-core-test-"))
+  return {
+    path: dir,
+    async [Symbol.asyncDispose]() {
+      await fs.rm(dir, { recursive: true, force: true })
+    },
+  }
+}

+ 51 - 0
packages/core/test/npm-config.test.ts

@@ -0,0 +1,51 @@
+import path from "path"
+import { describe, expect, test } from "bun:test"
+import { Effect } from "effect"
+import { NpmConfig } from "@opencode-ai/core/npm-config"
+import { tmpdir } from "./fixture/tmpdir"
+
+describe("NpmConfig.load", () => {
+  test("reads registry from project .npmrc", async () => {
+    await using tmp = await tmpdir()
+    await Bun.write(path.join(tmp.path, ".npmrc"), "registry=https://registry.example.test/\n")
+
+    const config = await Effect.runPromise(NpmConfig.load(tmp.path))
+
+    expect(config.registry).toBe("https://registry.example.test/")
+  })
+
+  test("reads scoped registries from project .npmrc", async () => {
+    await using tmp = await tmpdir()
+    await Bun.write(path.join(tmp.path, ".npmrc"), "@acme:registry=https://npm.acme.test/\n")
+
+    const config = await Effect.runPromise(NpmConfig.load(tmp.path))
+
+    expect(config["@acme:registry"]).toBe("https://npm.acme.test/")
+  })
+
+  test("flattens boolean and list options", async () => {
+    await using tmp = await tmpdir()
+    await Bun.write(path.join(tmp.path, ".npmrc"), "ignore-scripts=true\nomit[]=dev\nomit[]=optional\n")
+
+    const config = await Effect.runPromise(NpmConfig.load(tmp.path))
+
+    expect(config.ignoreScripts).toBe(true)
+    expect(config.omit).toEqual(["dev", "optional"])
+  })
+})
+
+describe("NpmConfig.registry", () => {
+  test("normalizes configured registry without trailing slash", async () => {
+    await using tmp = await tmpdir()
+    await Bun.write(path.join(tmp.path, ".npmrc"), "registry=https://registry.example.test/\n")
+
+    await expect(Effect.runPromise(NpmConfig.registry(tmp.path))).resolves.toBe("https://registry.example.test")
+  })
+
+  test("leaves configured registry without trailing slash unchanged", async () => {
+    await using tmp = await tmpdir()
+    await Bun.write(path.join(tmp.path, ".npmrc"), "registry=https://registry.example.test\n")
+
+    await expect(Effect.runPromise(NpmConfig.registry(tmp.path))).resolves.toBe("https://registry.example.test")
+  })
+})

+ 56 - 0
packages/core/test/npm.test.ts

@@ -0,0 +1,56 @@
+import fs from "fs/promises"
+import path from "path"
+import { describe, expect, test } from "bun:test"
+import { Npm } from "@opencode-ai/core/npm"
+import { tmpdir } from "./fixture/tmpdir"
+
+const win = process.platform === "win32"
+
+const writePackage = (dir: string, pkg: Record<string, unknown>) =>
+  Bun.write(
+    path.join(dir, "package.json"),
+    JSON.stringify({
+      version: "1.0.0",
+      ...pkg,
+    }),
+  )
+
+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)
+  })
+})
+
+describe("Npm.install", () => {
+  test("respects omit from project .npmrc", async () => {
+    await using tmp = await tmpdir()
+
+    await writePackage(tmp.path, {
+      name: "fixture",
+      dependencies: {
+        "prod-pkg": "file:./prod-pkg",
+      },
+      devDependencies: {
+        "dev-pkg": "file:./dev-pkg",
+      },
+    })
+    await Bun.write(path.join(tmp.path, ".npmrc"), "omit=dev\n")
+    await fs.mkdir(path.join(tmp.path, "prod-pkg"))
+    await fs.mkdir(path.join(tmp.path, "dev-pkg"))
+    await writePackage(path.join(tmp.path, "prod-pkg"), { name: "prod-pkg" })
+    await writePackage(path.join(tmp.path, "dev-pkg"), { name: "dev-pkg" })
+
+    await Npm.install(tmp.path)
+
+    await expect(fs.stat(path.join(tmp.path, "node_modules", "prod-pkg"))).resolves.toBeDefined()
+    await expect(fs.stat(path.join(tmp.path, "node_modules", "dev-pkg"))).rejects.toThrow()
+  })
+})

+ 1 - 2
packages/opencode/src/cli/cmd/uninstall.ts

@@ -1,7 +1,6 @@
 import type { Argv } from "yargs"
 import { UI } from "../ui"
 import * as prompts from "@clack/prompts"
-import { AppRuntime } from "@/effect/app-runtime"
 import { Installation } from "../../installation"
 import { Global } from "@opencode-ai/core/global"
 import fs from "fs/promises"
@@ -58,7 +57,7 @@ export const UninstallCommand = {
     UI.empty()
     prompts.intro("Uninstall OpenCode")
 
-    const method = await AppRuntime.runPromise(Installation.Service.use((svc) => svc.method()))
+    const method = await Installation.method()
     prompts.log.info(`Installation method: ${method}`)
 
     const targets = await collectRemovalTargets(args, method)

+ 3 - 8
packages/opencode/src/cli/cmd/upgrade.ts

@@ -1,7 +1,6 @@
 import type { Argv } from "yargs"
 import { UI } from "../ui"
 import * as prompts from "@clack/prompts"
-import { AppRuntime } from "@/effect/app-runtime"
 import { Installation } from "../../installation"
 import { InstallationVersion } from "@opencode-ai/core/installation/version"
 
@@ -26,7 +25,7 @@ export const UpgradeCommand = {
     UI.println(UI.logo("  "))
     UI.empty()
     prompts.intro("Upgrade")
-    const detectedMethod = await AppRuntime.runPromise(Installation.Service.use((svc) => svc.method()))
+    const detectedMethod = await Installation.method()
     const method = (args.method as Installation.Method) ?? detectedMethod
     if (method === "unknown") {
       prompts.log.error(`opencode is installed to ${process.execPath} and may be managed by a package manager`)
@@ -44,9 +43,7 @@ export const UpgradeCommand = {
       }
     }
     prompts.log.info("Using method: " + method)
-    const target = args.target
-      ? args.target.replace(/^v/, "")
-      : await AppRuntime.runPromise(Installation.Service.use((svc) => svc.latest()))
+    const target = args.target ? args.target.replace(/^v/, "") : await Installation.latest()
 
     if (InstallationVersion === target) {
       prompts.log.warn(`opencode upgrade skipped: ${target} is already installed`)
@@ -57,9 +54,7 @@ export const UpgradeCommand = {
     prompts.log.info(`From ${InstallationVersion} → ${target}`)
     const spinner = prompts.spinner()
     spinner.start("Upgrading...")
-    const err = await AppRuntime.runPromise(Installation.Service.use((svc) => svc.upgrade(method, target))).catch(
-      (err) => err,
-    )
+    const err = await Installation.upgrade(method, target).catch((err) => err)
     if (err) {
       spinner.stop("Upgrade failed", 1)
       if (err instanceof Installation.UpgradeFailedError) {

+ 3 - 3
packages/opencode/src/cli/upgrade.ts

@@ -8,8 +8,8 @@ import { InstallationVersion } from "@opencode-ai/core/installation/version"
 export async function upgrade() {
   const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.getGlobal()))
   if (config.autoupdate === false || Flag.OPENCODE_DISABLE_AUTOUPDATE) return
-  const method = await AppRuntime.runPromise(Installation.Service.use((svc) => svc.method()))
-  const latest = await AppRuntime.runPromise(Installation.Service.use((svc) => svc.latest(method))).catch(() => {})
+  const method = await Installation.method()
+  const latest = await Installation.latest(method).catch(() => {})
   if (!latest) return
 
   if (Flag.OPENCODE_ALWAYS_NOTIFY_UPDATE) {
@@ -27,7 +27,7 @@ export async function upgrade() {
   }
 
   if (method === "unknown") return
-  await AppRuntime.runPromise(Installation.Service.use((svc) => svc.upgrade(method, latest)))
+  await Installation.upgrade(method, latest)
     .then(() => Bus.publish(Installation.Event.Updated, { version: latest }))
     .catch(() => {})
 }

+ 15 - 14
packages/opencode/src/installation/index.ts

@@ -8,9 +8,10 @@ import z from "zod"
 import { BusEvent } from "@/bus/bus-event"
 import { Flag } from "@opencode-ai/core/flag/flag"
 import { Log } from "../util"
-
+import { makeRuntime } from "@opencode-ai/core/effect/runtime"
 import semver from "semver"
 import { InstallationChannel, InstallationVersion } from "@opencode-ai/core/installation/version"
+import { NpmConfig } from "@opencode-ai/core/npm-config"
 
 const log = Log.create({ service: "installation" })
 
@@ -132,18 +133,6 @@ export const layer: Layer.Layer<Service, never, HttpClient.HttpClient | ChildPro
         Effect.catch(() => Effect.succeed({ code: ChildProcessSpawner.ExitCode(1), stdout: "", stderr: "" })),
       )
 
-      // Use the package manager's resolver so registries, mirrors, auth, proxies, and dist-tags match upgrade behavior.
-      const viewVersion = Effect.fnUntraced(function* (method: "npm" | "pnpm" | "bun", spec: string) {
-        const args = method === "bun" ? ["pm", "view", spec, "version", "--json"] : ["view", spec, "version", "--json"]
-        const result = yield* run([method, ...args])
-        if (result.code !== 0 || !result.stdout.trim()) {
-          return yield* new UpgradeFailedError({
-            stderr: result.stderr || result.stdout || `Failed to resolve ${spec}`,
-          })
-        }
-        return yield* Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.String))(result.stdout)
-      })
-
       const getBrewFormula = Effect.fnUntraced(function* () {
         const tapFormula = yield* text(["brew", "list", "--formula", "anomalyco/tap/opencode"])
         if (tapFormula.includes("opencode")) return "anomalyco/tap/opencode"
@@ -235,7 +224,13 @@ export const layer: Layer.Layer<Service, never, HttpClient.HttpClient | ChildPro
           }
 
           if (detectedMethod === "npm" || detectedMethod === "bun" || detectedMethod === "pnpm") {
-            return yield* viewVersion(detectedMethod, `opencode-ai@${InstallationChannel}`)
+            const response = yield* httpOk.execute(
+              HttpClientRequest.get(
+                `${yield* NpmConfig.registry(process.cwd())}/opencode-ai/${InstallationChannel}`,
+              ).pipe(HttpClientRequest.acceptJson),
+            )
+            const data = yield* HttpClientResponse.schemaBodyJson(NpmPackage)(response)
+            return data.version
           }
 
           if (detectedMethod === "choco") {
@@ -335,4 +330,10 @@ export const defaultLayer = layer.pipe(
   Layer.provide(CrossSpawnSpawner.defaultLayer),
 )
 
+const { runPromise } = makeRuntime(Service, defaultLayer)
+
+export const latest = (...args: Parameters<Interface["latest"]>) => runPromise((s) => s.latest(...args))
+export const method = () => runPromise((s) => s.method())
+export const upgrade = (...args: Parameters<Interface["upgrade"]>) => runPromise((s) => s.upgrade(...args))
+
 export * as Installation from "."

+ 0 - 1
packages/opencode/test/config/config.test.ts

@@ -988,7 +988,6 @@ test("installs dependencies in writable OPENCODE_CONFIG_DIR", async () => {
   const noopNpm = Layer.mock(Npm.Service)({
     install: () => Effect.void,
     add: () => Effect.die("not implemented"),
-    outdated: () => Effect.succeed(false),
     which: () => Effect.succeed(Option.none()),
   })
   const testLayer = Config.layer.pipe(

+ 21 - 39
packages/opencode/test/installation/installation.test.ts

@@ -69,64 +69,46 @@ describe("installation", () => {
       expect(result).toBe("4.0.0-beta.1")
     })
 
-    test("reads npm versions via npm view", async () => {
-      const calls: string[][] = []
-      const layer = testLayer(
-        () => {
-          throw new Error("unexpected http request")
-        },
-        (cmd, args) => {
-          calls.push([cmd, ...args])
-          if (cmd === "npm" && args[0] === "view") return '"1.5.0"\n'
-          return ""
-        },
-      )
+    test("reads npm versions via registry", async () => {
+      const calls: string[] = []
+      const layer = testLayer((request) => {
+        calls.push(request.url)
+        return jsonResponse({ version: "1.5.0" })
+      })
 
       const result = await Effect.runPromise(
         Installation.Service.use((svc) => svc.latest("npm")).pipe(Effect.provide(layer)),
       )
       expect(result).toBe("1.5.0")
-      expect(calls).toContainEqual(["npm", "view", `opencode-ai@${InstallationChannel}`, "version", "--json"])
+      expect(calls).toContain(`https://registry.npmjs.org/opencode-ai/${InstallationChannel}`)
     })
 
-    test("reads npm versions via bun pm view", async () => {
-      const calls: string[][] = []
-      const layer = testLayer(
-        () => {
-          throw new Error("unexpected http request")
-        },
-        (cmd, args) => {
-          calls.push([cmd, ...args])
-          if (cmd === "bun" && args[0] === "pm") return '"1.6.0"\n'
-          return ""
-        },
-      )
+    test("reads bun versions via registry", async () => {
+      const calls: string[] = []
+      const layer = testLayer((request) => {
+        calls.push(request.url)
+        return jsonResponse({ version: "1.6.0" })
+      })
 
       const result = await Effect.runPromise(
         Installation.Service.use((svc) => svc.latest("bun")).pipe(Effect.provide(layer)),
       )
       expect(result).toBe("1.6.0")
-      expect(calls).toContainEqual(["bun", "pm", "view", `opencode-ai@${InstallationChannel}`, "version", "--json"])
+      expect(calls).toContain(`https://registry.npmjs.org/opencode-ai/${InstallationChannel}`)
     })
 
-    test("reads npm versions via pnpm view", async () => {
-      const calls: string[][] = []
-      const layer = testLayer(
-        () => {
-          throw new Error("unexpected http request")
-        },
-        (cmd, args) => {
-          calls.push([cmd, ...args])
-          if (cmd === "pnpm" && args[0] === "view") return '"1.7.0"\n'
-          return ""
-        },
-      )
+    test("reads pnpm versions via registry", async () => {
+      const calls: string[] = []
+      const layer = testLayer((request) => {
+        calls.push(request.url)
+        return jsonResponse({ version: "1.7.0" })
+      })
 
       const result = await Effect.runPromise(
         Installation.Service.use((svc) => svc.latest("pnpm")).pipe(Effect.provide(layer)),
       )
       expect(result).toBe("1.7.0")
-      expect(calls).toContainEqual(["pnpm", "view", `opencode-ai@${InstallationChannel}`, "version", "--json"])
+      expect(calls).toContain(`https://registry.npmjs.org/opencode-ai/${InstallationChannel}`)
     })
 
     test("reads scoop manifest versions", async () => {

+ 0 - 143
packages/opencode/test/npm.test.ts

@@ -1,143 +0,0 @@
-import fs from "fs/promises"
-import path from "path"
-import { describe, expect, test } from "bun:test"
-import { Effect, Layer, Stream } from "effect"
-import { NodeFileSystem } from "@effect/platform-node"
-import { AppFileSystem } from "@opencode-ai/core/filesystem"
-import { Global } from "@opencode-ai/core/global"
-import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
-import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
-import { Npm } from "@opencode-ai/core/npm"
-import { tmpdir } from "./fixture/fixture"
-
-const win = process.platform === "win32"
-const encoder = new TextEncoder()
-function mockSpawner(handler: (cmd: string, args: readonly string[]) => string = () => "") {
-  const spawner = ChildProcessSpawner.make((command) => {
-    const std = ChildProcess.isStandardCommand(command) ? command : undefined
-    const output = handler(std?.command ?? "", std?.args ?? [])
-    return Effect.succeed(
-      ChildProcessSpawner.makeHandle({
-        pid: ChildProcessSpawner.ProcessId(0),
-        exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)),
-        isRunning: Effect.succeed(false),
-        kill: () => Effect.void,
-        stdin: { [Symbol.for("effect/Sink/TypeId")]: Symbol.for("effect/Sink/TypeId") } as any,
-        stdout: output ? Stream.make(encoder.encode(output)) : Stream.empty,
-        stderr: Stream.empty,
-        all: Stream.empty,
-        getInputFd: () => ({ [Symbol.for("effect/Sink/TypeId")]: Symbol.for("effect/Sink/TypeId") }) as any,
-        getOutputFd: () => Stream.empty,
-        unref: Effect.succeed(Effect.void),
-      }),
-    )
-  })
-  return Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner)
-}
-
-function testLayer(spawnHandler?: (cmd: string, args: readonly string[]) => string) {
-  return Npm.layer.pipe(
-    Layer.provide(mockSpawner(spawnHandler)),
-    Layer.provide(EffectFlock.layer),
-    Layer.provide(AppFileSystem.layer),
-    Layer.provide(Global.layer),
-    Layer.provide(NodeFileSystem.layer),
-  )
-}
-
-const writePackage = (dir: string, pkg: Record<string, unknown>) =>
-  Bun.write(
-    path.join(dir, "package.json"),
-    JSON.stringify({
-      version: "1.0.0",
-      ...pkg,
-    }),
-  )
-
-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)
-  })
-})
-
-describe("Npm.install", () => {
-  test("respects omit from project .npmrc", async () => {
-    await using tmp = await tmpdir()
-
-    await writePackage(tmp.path, {
-      name: "fixture",
-      dependencies: {
-        "prod-pkg": "file:./prod-pkg",
-      },
-      devDependencies: {
-        "dev-pkg": "file:./dev-pkg",
-      },
-    })
-    await Bun.write(path.join(tmp.path, ".npmrc"), "omit=dev\n")
-    await fs.mkdir(path.join(tmp.path, "prod-pkg"))
-    await fs.mkdir(path.join(tmp.path, "dev-pkg"))
-    await writePackage(path.join(tmp.path, "prod-pkg"), { name: "prod-pkg" })
-    await writePackage(path.join(tmp.path, "dev-pkg"), { name: "dev-pkg" })
-
-    await Npm.install(tmp.path)
-
-    await expect(fs.stat(path.join(tmp.path, "node_modules", "prod-pkg"))).resolves.toBeDefined()
-    await expect(fs.stat(path.join(tmp.path, "node_modules", "dev-pkg"))).rejects.toThrow()
-  })
-})
-
-describe("Npm.outdated", () => {
-  test("checks latest via npm view", async () => {
-    const calls: string[][] = []
-    const layer = testLayer((cmd, args) => {
-      calls.push([cmd, ...args])
-      if (cmd === "npm" && args[0] === "view") return '"2.0.0"\n'
-      return ""
-    })
-
-    const result = await Effect.runPromise(
-      Npm.Service.use((svc) => svc.outdated("example", "1.0.0")).pipe(Effect.provide(layer)),
-    )
-
-    expect(result).toBe(true)
-    expect(calls).toContainEqual(["npm", "view", "example", "dist-tags.latest", "--json"])
-  })
-
-  test("keeps range comparison behavior", async () => {
-    const layer = testLayer((cmd, args) => {
-      if (cmd === "npm" && args[0] === "view") return '"2.3.0"\n'
-      return ""
-    })
-
-    const result = await Effect.runPromise(
-      Npm.Service.use((svc) => svc.outdated("example", "^2.0.0")).pipe(Effect.provide(layer)),
-    )
-
-    expect(result).toBe(false)
-  })
-
-  test("falls back when npm view is unavailable", async () => {
-    const calls: string[][] = []
-    const layer = testLayer((cmd, args) => {
-      calls.push([cmd, ...args])
-      if (cmd === "pnpm" && args[0] === "view") return '"2.0.0"\n'
-      return ""
-    })
-
-    const result = await Effect.runPromise(
-      Npm.Service.use((svc) => svc.outdated("example", "1.0.0")).pipe(Effect.provide(layer)),
-    )
-
-    expect(result).toBe(true)
-    expect(calls).toContainEqual(["npm", "view", "example", "dist-tags.latest", "--json"])
-    expect(calls).toContainEqual(["pnpm", "view", "example", "dist-tags.latest", "--json"])
-  })
-})