فهرست منبع

fix: restore .gitignore logic for config dirs and migrate to shared Npm service (#22772)

Dax 13 ساعت پیش
والد
کامیت
ef90b93205
3فایلهای تغییر یافته به همراه37 افزوده شده و 224 حذف شده
  1. 0 31
      packages/opencode/.opencode/package-lock.json
  2. 10 55
      packages/opencode/src/config/config.ts
  3. 27 138
      packages/opencode/test/config/config.test.ts

+ 0 - 31
packages/opencode/.opencode/package-lock.json

@@ -1,31 +0,0 @@
-{
-  "name": ".opencode",
-  "lockfileVersion": 3,
-  "requires": true,
-  "packages": {
-    "": {
-      "dependencies": {
-        "@opencode-ai/plugin": "*"
-      }
-    },
-    "node_modules/@opencode-ai/plugin": {
-      "version": "1.2.6",
-      "license": "MIT",
-      "dependencies": {
-        "@opencode-ai/sdk": "1.2.6",
-        "zod": "4.1.8"
-      }
-    },
-    "node_modules/@opencode-ai/sdk": {
-      "version": "1.2.6",
-      "license": "MIT"
-    },
-    "node_modules/zod": {
-      "version": "4.1.8",
-      "license": "MIT",
-      "funding": {
-        "url": "https://github.com/sponsors/colinhacks"
-      }
-    }
-  }
-}

+ 10 - 55
packages/opencode/src/config/config.ts

@@ -20,8 +20,7 @@ import {
 } from "jsonc-parser"
 import { Instance, type InstanceContext } from "../project/instance"
 import * as LSPServer from "../lsp/server"
-import { Installation } from "@/installation"
-import { InstallationVersion } from "@/installation/version"
+import { InstallationLocal, InstallationVersion } from "@/installation/version"
 import * as ConfigMarkdown from "./markdown"
 import { existsSync } from "fs"
 import { Bus } from "@/bus"
@@ -38,8 +37,8 @@ import { Context, Duration, Effect, Exit, Fiber, Layer, Option } from "effect"
 import { EffectFlock } from "@opencode-ai/shared/util/effect-flock"
 
 import { isPathPluginSpec, parsePluginSpecifier, resolvePathPluginTarget } from "@/plugin/shared"
-import { Npm } from "../npm"
 import { InstanceRef } from "@/effect/instance-ref"
+import { Npm } from "@opencode-ai/shared/npm"
 
 const ModelId = z.string().meta({ $ref: "https://models.dev/model-schema.json#/$defs/Model" })
 const PluginOptions = z.record(z.string(), z.unknown())
@@ -141,10 +140,6 @@ export type InstallInput = {
   waitTick?: (input: { dir: string; attempt: number; delay: number; waited: number }) => void | Promise<void>
 }
 
-type Package = {
-  dependencies?: Record<string, string>
-}
-
 function rel(item: string, patterns: string[]) {
   const normalizedItem = item.replaceAll("\\", "/")
   for (const pattern of patterns) {
@@ -1059,7 +1054,6 @@ export interface Interface {
   readonly get: () => Effect.Effect<Info>
   readonly getGlobal: () => Effect.Effect<Info>
   readonly getConsoleState: () => Effect.Effect<ConsoleState>
-  readonly installDependencies: (dir: string, input?: InstallInput) => Effect.Effect<void, AppFileSystem.Error>
   readonly update: (config: Info) => Effect.Effect<void>
   readonly updateGlobal: (config: Info) => Effect.Effect<Info>
   readonly invalidate: (wait?: boolean) => Effect.Effect<void>
@@ -1146,18 +1140,14 @@ export const ConfigDirectoryTypoError = NamedError.create(
   }),
 )
 
-export const layer: Layer.Layer<
-  Service,
-  never,
-  AppFileSystem.Service | Auth.Service | Account.Service | Env.Service | EffectFlock.Service
-> = Layer.effect(
+export const layer = Layer.effect(
   Service,
   Effect.gen(function* () {
     const fs = yield* AppFileSystem.Service
     const authSvc = yield* Auth.Service
     const accountSvc = yield* Account.Service
     const env = yield* Env.Service
-    const flock = yield* EffectFlock.Service
+    const npmSvc = yield* Npm.Service
 
     const readConfigFile = Effect.fnUntraced(function* (filepath: string) {
       return yield* fs.readFileString(filepath).pipe(
@@ -1263,53 +1253,18 @@ export const layer: Layer.Layer<
       return yield* cachedGlobal
     })
 
-    const install = Effect.fn("Config.install")(function* (dir: string) {
-      const pkg = path.join(dir, "package.json")
+    const setupConfigDir = Effect.fnUntraced(function* (dir: string) {
       const gitignore = path.join(dir, ".gitignore")
-      const plugin = path.join(dir, "node_modules", "@opencode-ai", "plugin", "package.json")
-      const target = Installation.isLocal() ? "*" : InstallationVersion
-      const json = yield* fs.readJson(pkg).pipe(
-        Effect.catch(() => Effect.succeed({} satisfies Package)),
-        Effect.map((x): Package => (isRecord(x) ? (x as Package) : {})),
-      )
-      const hasDep = json.dependencies?.["@opencode-ai/plugin"] === target
       const hasIgnore = yield* fs.existsSafe(gitignore)
-      const hasPkg = yield* fs.existsSafe(plugin)
-
-      if (!hasDep) {
-        yield* fs.writeJson(pkg, {
-          ...json,
-          dependencies: {
-            ...json.dependencies,
-            "@opencode-ai/plugin": target,
-          },
-        })
-      }
-
       if (!hasIgnore) {
         yield* fs.writeFileString(
           gitignore,
           ["node_modules", "package.json", "package-lock.json", "bun.lock", ".gitignore"].join("\n"),
         )
       }
-
-      if (hasDep && hasIgnore && hasPkg) return
-
-      yield* Effect.promise(() => Npm.install(dir))
-    })
-
-    const installDependencies = Effect.fn("Config.installDependencies")(function* (dir: string, _input?: InstallInput) {
-      if (
-        !(yield* fs.access(dir, { writable: true }).pipe(
-          Effect.as(true),
-          Effect.orElseSucceed(() => false),
-        ))
-      )
-        return
-
-      const key = process.platform === "win32" ? "config-install:win32" : `config-install:${AppFileSystem.resolve(dir)}`
-
-      yield* flock.withLock(install(dir), key).pipe(Effect.orDie)
+      yield* npmSvc.install(dir, {
+        add: ["@opencode-ai/plugin" + (InstallationLocal ? "" : "@" + InstallationVersion)],
+      })
     })
 
     const loadInstanceState = Effect.fn("Config.loadInstanceState")(function* (ctx: InstanceContext) {
@@ -1404,7 +1359,7 @@ export const layer: Layer.Layer<
           }
         }
 
-        const dep = yield* installDependencies(dir).pipe(
+        const dep = yield* setupConfigDir(dir).pipe(
           Effect.exit,
           Effect.tap((exit) =>
             Exit.isFailure(exit)
@@ -1611,7 +1566,6 @@ export const layer: Layer.Layer<
       get,
       getGlobal,
       getConsoleState,
-      installDependencies,
       update,
       updateGlobal,
       invalidate,
@@ -1627,4 +1581,5 @@ export const defaultLayer = layer.pipe(
   Layer.provide(Env.defaultLayer),
   Layer.provide(Auth.defaultLayer),
   Layer.provide(Account.defaultLayer),
+  Layer.provide(Npm.defaultLayer),
 )

+ 27 - 138
packages/opencode/test/config/config.test.ts

@@ -25,8 +25,8 @@ import { Global } from "../../src/global"
 import { ProjectID } from "../../src/project/schema"
 import { Filesystem } from "../../src/util"
 import * as Network from "../../src/util/network"
-import { Npm } from "../../src/npm"
 import { ConfigPlugin } from "@/config/plugin"
+import { Npm } from "@opencode-ai/shared/npm"
 
 const emptyAccount = Layer.mock(Account.Service)({
   active: () => Effect.succeed(Option.none()),
@@ -46,6 +46,7 @@ const layer = Config.layer.pipe(
   Layer.provide(emptyAuth),
   Layer.provide(emptyAccount),
   Layer.provideMerge(infra),
+  Layer.provide(Npm.defaultLayer),
 )
 
 const it = testEffect(layer)
@@ -60,9 +61,6 @@ const listDirs = () =>
 const ready = () =>
   Effect.runPromise(Config.Service.use((svc) => svc.waitForDependencies()).pipe(Effect.scoped, Effect.provide(layer)))
 
-const installDeps = (dir: string, input?: Config.InstallInput) =>
-  Config.Service.use((svc) => svc.installDependencies(dir, input))
-
 // Get managed config directory from environment (set in preload.ts)
 const managedConfigDir = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR!
 
@@ -355,7 +353,7 @@ test("resolves env templates in account config with account token", async () =>
           expect(config.provider?.["opencode"]?.options?.apiKey).toBe("st_test_token")
         }),
       ),
-    ).pipe(Effect.scoped, Effect.provide(layer), Effect.runPromise)
+    ).pipe(Effect.scoped, Effect.provide(layer), Effect.provide(Npm.defaultLayer), Effect.runPromise)
   } finally {
     if (originalControlToken !== undefined) {
       process.env["OPENCODE_CONSOLE_TOKEN"] = originalControlToken
@@ -820,156 +818,45 @@ test("installs dependencies in writable OPENCODE_CONFIG_DIR", async () => {
 
   const prev = process.env.OPENCODE_CONFIG_DIR
   process.env.OPENCODE_CONFIG_DIR = tmp.extra
-  const online = spyOn(Network, "online").mockReturnValue(false)
-  const install = spyOn(Npm, "install").mockImplementation(async (dir: string) => {
-    const mod = path.join(dir, "node_modules", "@opencode-ai", "plugin")
-    await fs.mkdir(mod, { recursive: true })
-    await Filesystem.write(
-      path.join(mod, "package.json"),
-      JSON.stringify({ name: "@opencode-ai/plugin", version: "1.0.0" }),
-    )
+
+  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(
+    Layer.provide(testFlock),
+    Layer.provide(AppFileSystem.defaultLayer),
+    Layer.provide(Env.defaultLayer),
+    Layer.provide(emptyAuth),
+    Layer.provide(emptyAccount),
+    Layer.provideMerge(infra),
+    Layer.provide(noopNpm),
+  )
 
   try {
     await Instance.provide({
       directory: tmp.path,
       fn: async () => {
-        await load()
-        await ready()
+        await Effect.runPromise(Config.Service.use((svc) => svc.get()).pipe(Effect.scoped, Effect.provide(testLayer)))
+        await Effect.runPromise(
+          Config.Service.use((svc) => svc.waitForDependencies()).pipe(Effect.scoped, Effect.provide(testLayer)),
+        )
       },
     })
 
-    expect(await Filesystem.exists(path.join(tmp.extra, "package.json"))).toBe(true)
     expect(await Filesystem.exists(path.join(tmp.extra, ".gitignore"))).toBe(true)
     expect(await Filesystem.readText(path.join(tmp.extra, ".gitignore"))).toContain("package-lock.json")
   } finally {
-    online.mockRestore()
-    install.mockRestore()
     if (prev === undefined) delete process.env.OPENCODE_CONFIG_DIR
     else process.env.OPENCODE_CONFIG_DIR = prev
   }
 })
 
-it.live("dedupes concurrent config dependency installs for the same dir", () =>
-  Effect.gen(function* () {
-    const tmp = yield* tmpdirScoped()
-    const dir = path.join(tmp, "a")
-    yield* Effect.promise(() => fs.mkdir(dir, { recursive: true }))
-
-    let calls = 0
-    const online = spyOn(Network, "online").mockReturnValue(false)
-    const ready = Deferred.makeUnsafe<void>()
-    const hold = Deferred.makeUnsafe<void>()
-    const target = path.normalize(dir)
-    const run = spyOn(Npm, "install").mockImplementation(async (d: string) => {
-      if (path.normalize(d) !== target) return
-      calls += 1
-      Deferred.doneUnsafe(ready, Effect.void)
-      await Effect.runPromise(Deferred.await(hold))
-      const mod = path.join(d, "node_modules", "@opencode-ai", "plugin")
-      await fs.mkdir(mod, { recursive: true })
-      await Filesystem.write(
-        path.join(mod, "package.json"),
-        JSON.stringify({ name: "@opencode-ai/plugin", version: "1.0.0" }),
-      )
-    })
-
-    yield* Effect.addFinalizer(() =>
-      Effect.sync(() => {
-        online.mockRestore()
-        run.mockRestore()
-      }),
-    )
-
-    const first = yield* installDeps(dir).pipe(Effect.forkScoped)
-    yield* Deferred.await(ready)
-
-    let done = false
-    const second = yield* installDeps(dir).pipe(
-      Effect.tap(() =>
-        Effect.sync(() => {
-          done = true
-        }),
-      ),
-      Effect.forkScoped,
-    )
-
-    // Give the second fiber time to hit the lock retry loop
-    yield* Effect.sleep(500)
-    expect(done).toBe(false)
-
-    yield* Deferred.succeed(hold, void 0)
-    yield* Fiber.join(first)
-    yield* Fiber.join(second)
-
-    expect(calls).toBe(1)
-    expect(yield* Effect.promise(() => Filesystem.exists(path.join(dir, "package.json")))).toBe(true)
-  }),
-)
-
-it.live("serializes config dependency installs across dirs", () =>
-  Effect.gen(function* () {
-    if (process.platform !== "win32") return
-
-    const tmp = yield* tmpdirScoped()
-    const a = path.join(tmp, "a")
-    const b = path.join(tmp, "b")
-    yield* Effect.promise(() => fs.mkdir(a, { recursive: true }))
-    yield* Effect.promise(() => fs.mkdir(b, { recursive: true }))
-
-    let calls = 0
-    let open = 0
-    let peak = 0
-    const ready = Deferred.makeUnsafe<void>()
-    const hold = Deferred.makeUnsafe<void>()
-
-    const online = spyOn(Network, "online").mockReturnValue(false)
-    const run = spyOn(Npm, "install").mockImplementation(async (dir: string) => {
-      const cwd = path.normalize(dir)
-      const hit = cwd === path.normalize(a) || cwd === path.normalize(b)
-      if (hit) {
-        calls += 1
-        open += 1
-        peak = Math.max(peak, open)
-        if (calls === 1) {
-          Deferred.doneUnsafe(ready, Effect.void)
-          await Effect.runPromise(Deferred.await(hold))
-        }
-      }
-      const mod = path.join(cwd, "node_modules", "@opencode-ai", "plugin")
-      await fs.mkdir(mod, { recursive: true })
-      await Filesystem.write(
-        path.join(mod, "package.json"),
-        JSON.stringify({ name: "@opencode-ai/plugin", version: "1.0.0" }),
-      )
-      if (hit) {
-        open -= 1
-      }
-    })
-
-    yield* Effect.addFinalizer(() =>
-      Effect.sync(() => {
-        online.mockRestore()
-        run.mockRestore()
-      }),
-    )
-
-    const first = yield* installDeps(a).pipe(Effect.forkScoped)
-    yield* Deferred.await(ready)
-
-    const second = yield* installDeps(b).pipe(Effect.forkScoped)
-    // Give the second fiber time to hit the lock retry loop
-    yield* Effect.sleep(500)
-    expect(peak).toBe(1)
-
-    yield* Deferred.succeed(hold, void 0)
-    yield* Fiber.join(first)
-    yield* Fiber.join(second)
-
-    expect(calls).toBe(2)
-    expect(peak).toBe(1)
-  }),
-)
+// Note: deduplication and serialization of npm installs is now handled by the
+// shared Npm.Service (via EffectFlock). Those behaviors are tested in the shared
+// package's npm tests, not here.
 
 test("resolves scoped npm plugins in config", async () => {
   await using tmp = await tmpdir({
@@ -1831,6 +1718,7 @@ test("project config overrides remote well-known config", async () => {
     Layer.provide(fakeAuth),
     Layer.provide(emptyAccount),
     Layer.provideMerge(infra),
+    Layer.provide(Npm.defaultLayer),
   )
 
   try {
@@ -1888,6 +1776,7 @@ test("wellknown URL with trailing slash is normalized", async () => {
     Layer.provide(fakeAuth),
     Layer.provide(emptyAccount),
     Layer.provideMerge(infra),
+    Layer.provide(Npm.defaultLayer),
   )
 
   try {