From 695c043e6becae8f21f05433dc595da0b99ce071 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 25 Aug 2026 22:32:05 -0400 Subject: [PATCH] feat(core): refresh unpinned plugins on startup (#45118) --- packages/core/src/plugin/supervisor.ts | 2 +- packages/core/test/npm.test.ts | 42 +++++++++++++++++++++++ packages/util/src/npm.ts | 40 ++++++++++++++++----- packages/www/src/docs/content/plugins.mdx | 5 +-- 4 files changed, 78 insertions(+), 11 deletions(-) diff --git a/packages/core/src/plugin/supervisor.ts b/packages/core/src/plugin/supervisor.ts index 705cdf18106..da010ab69b2 100644 --- a/packages/core/src/plugin/supervisor.ts +++ b/packages/core/src/plugin/supervisor.ts @@ -108,7 +108,7 @@ const load = Effect.fn("PluginSupervisor.load")(function* ( const npm = yield* Npm.Service const entrypoint = path.isAbsolute(operation.target) ? pathToFileURL(operation.target).href - : (yield* npm.add(operation.target, { subpaths: ["server", ""] })).entrypoint + : (yield* npm.add(operation.target, { subpaths: ["server", ""], refresh: true })).entrypoint if (!entrypoint) return yield* Effect.fail(new Error(`Plugin entrypoint not found: ${operation.target}`)) // Bun currently ignores query parameters when caching file:// imports. const source = diff --git a/packages/core/test/npm.test.ts b/packages/core/test/npm.test.ts index fefa36eca3c..23f33455a58 100644 --- a/packages/core/test/npm.test.ts +++ b/packages/core/test/npm.test.ts @@ -1,5 +1,6 @@ import fs from "fs/promises" import path from "path" +import { pathToFileURL } from "url" import { describe, expect, test } from "bun:test" import { Effect } from "effect" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" @@ -221,6 +222,47 @@ describe("Npm.add", () => { await fs.stat(path.join(path.dirname(entry.directory), "fixture-subdirectory-dependency", "package.json")), ).toBeTruthy() }) + + test("refreshes mutable Git packages once per service lifetime and preserves pinned or cached installs", async () => { + await using tmp = await tmpdir() + const fixture = await createGitFixture(tmp.path) + const cache = path.join(tmp.path, "cache") + const repository = pathToFileURL(fixture.repository).href + const mutable = `git+${repository}#fixture-branch` + const pinned = `git+${repository}#${fixture.commit}` + + const first = await Effect.gen(function* () { + const npm = yield* Npm.Service + const mutableEntry = yield* npm.add(mutable, { refresh: true }) + const pinnedEntry = yield* npm.add(pinned, { refresh: true }) + yield* Effect.promise(async () => { + await Bun.write(path.join(fixture.repository, "index.js"), 'export default { root: "second" }\n') + await Bun.$`git -C ${fixture.repository} add .` + await Bun.$`git -C ${fixture.repository} -c user.name=fixture -c user.email=fixture@example.com commit -qm second` + }) + yield* npm.add(mutable, { refresh: true }) + return { mutable: mutableEntry, pinned: pinnedEntry } + }).pipe(Effect.scoped, Effect.provide(npmLayer(cache)), Effect.runPromise) + expect(await Bun.file(path.join(first.mutable.directory, "index.js")).text()).toContain("root: true") + expect(await Bun.file(path.join(first.pinned.directory, "index.js")).text()).toContain("root: true") + + const second = await Effect.gen(function* () { + const npm = yield* Npm.Service + return { + mutable: yield* npm.add(mutable, { refresh: true }), + pinned: yield* npm.add(pinned, { refresh: true }), + } + }).pipe(Effect.scoped, Effect.provide(npmLayer(cache)), Effect.runPromise) + expect(await Bun.file(path.join(second.mutable.directory, "index.js")).text()).toContain('root: "second"') + expect(await Bun.file(path.join(second.pinned.directory, "index.js")).text()).toContain("root: true") + + await fs.rename(fixture.repository, `${fixture.repository}-offline`) + const offline = await Effect.gen(function* () { + const npm = yield* Npm.Service + return yield* npm.add(mutable, { refresh: true }) + }).pipe(Effect.scoped, Effect.provide(npmLayer(cache)), Effect.runPromise) + expect(await Bun.file(path.join(offline.directory, "index.js")).text()).toContain('root: "second"') + }) }) describe("Npm.resolve", () => { diff --git a/packages/util/src/npm.ts b/packages/util/src/npm.ts index 2cffa1ba8ff..7948a943087 100644 --- a/packages/util/src/npm.ts +++ b/packages/util/src/npm.ts @@ -27,7 +27,7 @@ export interface EntryPoint { export interface Interface { readonly add: ( pkg: string, - options?: { readonly subpaths?: readonly string[] }, + options?: { readonly subpaths?: readonly string[]; readonly refresh?: boolean }, ) => Effect.Effect readonly resolve: (pkg: string, options?: { readonly subpaths?: readonly string[] }) => Effect.Effect readonly which: (pkg: string, bin?: string) => Effect.Effect @@ -124,14 +124,16 @@ const layer = Layer.effect( } return pkg }) - const reify = (input: { dir: string; add?: string[] }) => + const refreshed = new Set() + const reify = (input: { dir: string; add?: string[]; update?: boolean }) => 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* NpmConfig.load(input.dir) + const options = input.update ? { ...npmOptions, preferOnline: true, noGitRevCache: true } : npmOptions const arborist = new Arborist({ - ...npmOptions, + ...options, path: input.dir, binLinks: true, progress: false, @@ -141,8 +143,9 @@ const layer = Layer.effect( return yield* Effect.tryPromise({ try: () => arborist.reify({ - ...npmOptions, + ...options, add, + update: input.update, save: true, saveType: "prod", }), @@ -159,19 +162,33 @@ const layer = Layer.effect( }), ) - const add = Effect.fn("Npm.add")(function* (pkg: string, options?: { readonly subpaths?: readonly string[] }) { + const add = Effect.fn("Npm.add")(function* ( + pkg: string, + options?: { readonly subpaths?: readonly string[]; readonly refresh?: boolean }, + ) { const { default: npa } = yield* Effect.promise(() => import("npm-package-arg")) - const parsedName = (() => { + const parsed = (() => { try { - return npa(pkg).name ?? undefined + return npa(pkg) } catch { return undefined } })() + const parsedName = parsed?.name ?? undefined const dir = yield* directory(pkg) const name = yield* installedName(pkg, dir, parsedName) + const cached = yield* afs.existsSafe(path.join(dir, "node_modules", name)) + const refresh = options?.refresh && isMutable(parsed) && !refreshed.has(pkg) - if (yield* afs.existsSafe(path.join(dir, "node_modules", name))) { + if (refresh) { + refreshed.add(pkg) + if (cached) + yield* reify({ dir, add: [pkg], update: true }).pipe( + Effect.catchCause(() => Effect.logWarning("failed to refresh cached package; using installed version")), + ) + } + + if (cached) { return resolveEntryPoint(name, path.join(dir, "node_modules", name), options?.subpaths) } @@ -283,3 +300,10 @@ export async function resolve(...args: Parameters) { export async function which(...args: Parameters) { return runPromise((svc) => svc.which(...args)) } + +function isMutable(parsed: { readonly type: string; readonly gitCommittish?: string | null } | undefined) { + if (!parsed) return false + if (["tag", "range"].includes(parsed.type)) return true + if (parsed.type !== "git") return false + return !/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/i.test(parsed.gitCommittish ?? "") +} diff --git a/packages/www/src/docs/content/plugins.mdx b/packages/www/src/docs/content/plugins.mdx index d3c83a26f51..783ee094b7b 100644 --- a/packages/www/src/docs/content/plugins.mdx +++ b/packages/www/src/docs/content/plugins.mdx @@ -93,8 +93,9 @@ opencode2 plugin add 'github:acme/plugins#main::path:packages/opencode-plugin' Branches, tags, complete commit hashes, and npm's `::path:` repository-subdirectory selectors are supported. Configure local paths directly; tarball and npm alias targets are not accepted by `plugin add`. -Changes under watched config directories reload automatically. Restart OpenCode after changing an installed package -version or an unwatched dependency. +Changes under watched config directories reload automatically. On server startup, OpenCode refreshes unpinned package and +Git plugins once, then uses that result for the lifetime of the server. Exact npm versions and full Git commit hashes stay +pinned. Changes to unwatched local dependencies may still require restarting OpenCode. ```sh touch .opencode/plugins/concise.ts