diff --git a/packages/opencode/.opencode/package-lock.json b/packages/opencode/.opencode/package-lock.json deleted file mode 100644 index cd3c011efc..0000000000 --- a/packages/opencode/.opencode/package-lock.json +++ /dev/null @@ -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" - } - } - } -} diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index ecdf20c892..97e7a662d0 100644 --- a/packages/opencode/src/config/config.ts +++ b/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 } -type Package = { - dependencies?: Record -} - 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 readonly getGlobal: () => Effect.Effect readonly getConsoleState: () => Effect.Effect - readonly installDependencies: (dir: string, input?: InstallInput) => Effect.Effect readonly update: (config: Info) => Effect.Effect readonly updateGlobal: (config: Info) => Effect.Effect readonly invalidate: (wait?: boolean) => Effect.Effect @@ -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), ) diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index 92c919dc26..1f36312447 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/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() - const hold = Deferred.makeUnsafe() - 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() - const hold = Deferred.makeUnsafe() - - 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 {