mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-29 22:22:11 +00:00
feat(core): refresh unpinned plugins on startup (#45118)
This commit is contained in:
parent
d53456da3b
commit
695c043e6b
4 changed files with 78 additions and 11 deletions
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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<EntryPoint, InstallFailedError | EffectFlock.LockError>
|
||||
readonly resolve: (pkg: string, options?: { readonly subpaths?: readonly string[] }) => Effect.Effect<EntryPoint>
|
||||
readonly which: (pkg: string, bin?: string) => Effect.Effect<string | undefined>
|
||||
|
|
@ -124,14 +124,16 @@ const layer = Layer.effect(
|
|||
}
|
||||
return pkg
|
||||
})
|
||||
const reify = (input: { dir: string; add?: string[] }) =>
|
||||
const refreshed = new Set<string>()
|
||||
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<Interface["resolve"]>) {
|
|||
export async function which(...args: Parameters<Interface["which"]>) {
|
||||
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 ?? "")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue