Compare commits

...
60 changed files with 3442 additions and 262 deletions
+11 -3
View File
@@ -966,6 +966,7 @@
"mime-types": "3.0.2",
"minimatch": "10.2.5",
"npm-package-arg": "13.0.2",
"pacote": "21.5.1",
"resolve.exports": "catalog:",
},
"devDependencies": {
@@ -975,6 +976,7 @@
"@types/node": "catalog:",
"@types/npm-package-arg": "6.1.4",
"@types/npmcli__arborist": "6.3.3",
"@types/pacote": "11.1.8",
"@typescript/native-preview": "catalog:",
},
},
@@ -5812,7 +5814,7 @@
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
"yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
"yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="],
"yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="],
@@ -6642,8 +6644,6 @@
"svgo/commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="],
"tar/yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="],
"temp/rimraf": ["rimraf@2.6.3", "", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "./bin.js" } }, "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA=="],
"tempy/type-fest": ["type-fest@0.16.0", "", {}, "sha512-eaBzG6MxNzEn9kiwvtre90cXaNLkmadMWa1zQMs3XORCXNbsH/OewwbxC5ia9dCxIxnTAsSxXJaa/p5y8DlvJg=="],
@@ -7450,6 +7450,12 @@
"miniflare/sharp/@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.33.5", "", { "os": "win32", "cpu": "x64" }, "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg=="],
"minipass-flush/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
"minipass-pipeline/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
"openid-client/lru-cache/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
"p-locate/p-limit/yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
"pkg-dir/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
@@ -8156,6 +8162,8 @@
"app-builder-lib/@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="],
"app-builder-lib/hosted-git-info/lru-cache/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
"archiver-utils/glob/jackspeak/@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="],
"archiver-utils/glob/minimatch/brace-expansion": ["brace-expansion@2.1.4", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg=="],
+3 -10
View File
@@ -8,7 +8,7 @@ import { Context, Effect, FileSystem, Option, Queue } from "effect"
import { ServerConnection } from "../../services/server-connection"
import { Updater } from "../../services/updater"
import { UpdatePreflight } from "../../services/update-preflight"
import { Npm } from "@opencode-ai/util/npm"
import { TuiPackages } from "../../services/tui-packages"
import { OPENCODE_CHANNEL, OPENCODE_VERSION } from "../../version"
import { Env } from "../../env"
@@ -50,7 +50,7 @@ export default Runtime.handler(Commands, (input) =>
yield* updater.check().pipe(Effect.forkScoped)
preflight.loading()
const config = yield* Config.Service
const npm = yield* Npm.Service
const packages = yield* TuiPackages.make
const fileSystem = yield* FileSystem.FileSystem
const runServicePromise = Effect.runPromiseWith(Context.make(FileSystem.FileSystem, fileSystem))
const context = yield* Effect.context<FileSystem.FileSystem>()
@@ -83,14 +83,7 @@ export default Runtime.handler(Commands, (input) =>
get: () => runPromise(config.get()),
update: (update) => runPromise(config.update(update)),
},
packages: {
resolve: (spec, install = true) =>
runPromise(
(install ? npm.add(spec, { subpaths: ["tui"] }) : npm.resolve(spec, { subpaths: ["tui"] })).pipe(
Effect.map((result) => result.entrypoint),
),
),
},
packages,
environment: requestedServer === undefined ? Env.session() : undefined,
terminalHandoff: () => preflight.finish(),
log: (level, message, tags) => {
+86
View File
@@ -0,0 +1,86 @@
export * as TuiPackages from "./tui-packages"
import { randomUUID } from "node:crypto"
import path from "node:path"
import { Effect, FileSystem, Schema } from "effect"
import { Global } from "@opencode-ai/util/global"
import { Npm } from "@opencode-ai/util/npm"
const Current = Schema.fromJsonString(
Schema.Struct({
entrypoint: Schema.String,
revision: Schema.optional(Schema.String),
}),
)
export const make = Effect.gen(function* () {
const npm = yield* Npm.Service
const global = yield* Global.Service
const fs = yield* FileSystem.FileSystem
const directory = (spec: string) =>
Effect.map(
Effect.promise(() => Npm.cacheKey(spec)),
(key) => path.join(global.cache, "tui-packages", key),
)
const current = Effect.fnUntraced(function* (spec: string) {
const file = path.join(yield* directory(spec), "current.json")
return yield* fs.readFileString(file).pipe(
Effect.flatMap(Schema.decodeUnknownEffect(Current)),
Effect.catchReason("PlatformError", "NotFound", () => Effect.undefined),
)
})
const resolve = Effect.fn("TuiPackages.resolve")(function* (spec: string, install = true) {
if (!install) return yield* npm.resolve(spec, { subpaths: ["tui"] })
return (yield* current(spec)) ?? (yield* npm.add(spec, { subpaths: ["tui"] }))
})
const check = Effect.fn("TuiPackages.check")(function* (spec: string) {
const status = yield* npm.check(spec)
const selected = yield* current(spec)
return selected ? { ...status, installed: selected.revision } : status
})
const update = Effect.fn("TuiPackages.update")(function* (spec: string) {
const status = yield* npm.check(spec)
if (!status.mutable) return yield* Effect.fail(new Error("Pinned packages cannot be updated"))
const dir = yield* directory(spec)
yield* fs.makeDirectory(dir, { recursive: true })
// Arborist root links do not expose the installed package edges.
const root = path.join(yield* fs.realPath(dir), randomUUID())
// Finish non-abortable installation before removing a failed tree. Imported trees stay immutable until GC.
return yield* npm.add(spec, { root, subpaths: ["tui"] }).pipe(
Effect.onError(() => fs.remove(root, { recursive: true, force: true }).pipe(Effect.ignore)),
Effect.uninterruptible,
)
})
const commit = Effect.fn("TuiPackages.commit")(function* (
spec: string,
entry: { readonly entrypoint?: string; readonly revision?: string },
) {
if (!entry.entrypoint) return yield* Effect.fail(new Error("Package has no TUI entrypoint"))
const dir = yield* directory(spec)
const temp = path.join(dir, `${randomUUID()}.tmp`)
yield* fs.makeDirectory(dir, { recursive: true })
return yield* fs
.writeFileString(temp, JSON.stringify({ entrypoint: entry.entrypoint, revision: entry.revision }))
.pipe(
Effect.andThen(fs.rename(temp, path.join(dir, "current.json"))),
Effect.ensuring(fs.remove(temp, { force: true }).pipe(Effect.ignore)),
Effect.uninterruptible,
)
})
return {
resolve: (spec: string, install = true): Promise<{ entrypoint?: string; revision?: string }> =>
runPromise(resolve(spec, install)),
check: (spec: string) => runPromise(check(spec)),
update: (spec: string) => runPromise(update(spec)),
commit: (spec: string, entry: { readonly entrypoint?: string; readonly revision?: string }) =>
runPromise(commit(spec, entry)),
}
})
function runPromise<A, E>(effect: Effect.Effect<A, E>) {
return Effect.runPromise(
effect.pipe(
Effect.mapError((error) => (error instanceof Error && error.cause instanceof Error ? error.cause : error)),
),
)
}
+414
View File
@@ -0,0 +1,414 @@
import { expect, test } from "bun:test"
import fs from "node:fs/promises"
import path from "node:path"
import { pathToFileURL } from "node:url"
import { Effect, FileSystem } from "effect"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { filesystem } from "@opencode-ai/util/effect/app-node-platform"
import { Global } from "@opencode-ai/util/global"
import { Npm } from "@opencode-ai/util/npm"
import { TuiPackages } from "../src/services/tui-packages"
import { tmpdir } from "./fixture/tmpdir"
const versions = ["1.0.0", "1.1.0", "1.2.0", "1.3.0"]
const pluginManifest = (version: string) => ({
name: "@fixture/plugin",
version,
type: "module",
exports: { ".": "./tui.js", "./tui": "./tui.js" },
dependencies: { "@fixture/esm": "^1.0.0", "@fixture/late": "^1.0.0" },
scripts: { postinstall: "bun -e \"throw new Error('lifecycle scripts must not run')\"" },
})
const pluginSource = (version: string) => `
import { values, paths } from '@fixture/esm'
export const graph = { values: ['${version}', ...values], paths: [import.meta.url, ...paths] }
export const late = () => import('./late.js')
${version === "1.2.0" ? "throw new Error('failed import fixture')" : ""}
`
async function registryFixture(directory: string) {
const packages = await Promise.all(
versions.flatMap((version) =>
["plugin", "esm", "cjs", "leaf", "late"].map(async (name) => {
const root = path.join(directory, "registry", name, version)
const manifest =
name === "plugin"
? pluginManifest(version)
: {
name: `@fixture/${name}`,
version,
type: ["cjs", "leaf"].includes(name) ? "commonjs" : "module",
exports: "./index.js",
dependencies:
name === "esm" ? { "@fixture/cjs": "^1.0.0" } : name === "cjs" ? { "@fixture/leaf": "^1.0.0" } : {},
}
await fs.mkdir(path.join(root, "package"), { recursive: true })
await Bun.write(path.join(root, "package", "package.json"), JSON.stringify(manifest))
const source =
name === "plugin"
? pluginSource(version)
: name === "esm"
? `import cjs from '@fixture/cjs'; export const values = ['${version}', ...cjs.values]; export const paths = [import.meta.url, ...cjs.paths]`
: name === "cjs"
? `const leaf = require('@fixture/leaf'); module.exports = { values: ['${version}', ...leaf.values], paths: [__filename, ...leaf.paths] }`
: name === "leaf"
? `module.exports = { values: ['${version}'], paths: [__filename] }`
: `export const value = '${version}'; export const file = import.meta.url`
await Bun.write(path.join(root, "package", name === "plugin" ? "tui.js" : "index.js"), source)
if (name === "plugin")
await Bun.write(path.join(root, "package", "late.js"), "export { value, file } from '@fixture/late'")
await Bun.$`tar -czf package.tgz package`.cwd(root).quiet()
return { name, manifest, tarball: await Bun.file(path.join(root, "package.tgz")).bytes() }
}),
),
)
const state = { latest: "1.0.0", failTarballs: false }
const server = Bun.serve({
hostname: "127.0.0.1",
port: 0,
fetch(request) {
const url = new URL(request.url)
const name = decodeURIComponent(url.pathname).replace("/@fixture/", "")
const published = packages.filter(
(pkg) => pkg.name === name && versions.indexOf(pkg.manifest.version) <= versions.indexOf(state.latest),
)
if (published.length)
return Response.json(
{
name: `@fixture/${name}`,
"dist-tags": { latest: state.latest },
versions: Object.fromEntries(
published.map((pkg) => [
pkg.manifest.version,
{
...pkg.manifest,
dist: { tarball: `${url.origin}/${pkg.name}-${pkg.manifest.version}.tgz` },
},
]),
),
},
{ headers: { "cache-control": "no-cache" } },
)
const pkg = packages.find((pkg) => url.pathname === `/${pkg.name}-${pkg.manifest.version}.tgz`)
return pkg && !state.failTarballs
? new Response(pkg.tarball)
: new Response("missing fixture tarball", { status: 404 })
},
})
return {
state,
async configure(cache: string, spec: string) {
const root = path.join(cache, "packages", await Npm.cacheKey(spec))
await fs.mkdir(root, { recursive: true })
await Bun.write(
path.join(root, ".npmrc"),
`@fixture:registry=${server.url}\ncache=${path.join(directory, "npm-cache")}\nfetch-retries=0\naudit=false\n`,
)
return root
},
async [Symbol.asyncDispose]() {
await server.stop(true)
},
}
}
function resolverFixture(directory: string, options: { cache?: string } = {}) {
const global = Global.make({
...Object.fromEntries(
["home", "data", "cache", "config", "state", "tmp", "bin", "log", "repos"].map((key) => [
key,
path.join(directory, key),
]),
),
...options,
})
return Effect.gen(function* () {
const npm = yield* Npm.Service
const context = yield* Effect.context<Npm.Service | Global.Service | FileSystem.FileSystem>()
return { npm, packages: yield* TuiPackages.make, cold: () => Effect.runPromiseWith(context)(TuiPackages.make) }
}).pipe(
Effect.provide(
LayerNode.compile(LayerNode.group([Npm.node, Global.node, filesystem]), [
[Global.node, Global.layerWith(global)],
]),
),
Effect.runPromise,
)
}
async function snapshot(root: string) {
const files = await Array.fromAsync(
new Bun.Glob("**/*").scan({ cwd: root, dot: true, onlyFiles: true, followSymlinks: true }),
)
return Promise.all(files.sort().map(async (file) => [file, await Bun.file(path.join(root, file)).bytes()]))
}
async function load(entry: { entrypoint?: string }) {
if (!entry.entrypoint) throw new Error("Missing fixture entrypoint")
const module: {
graph: { values: string[]; paths: string[] }
late: () => Promise<{ value: string; file: string }>
local?: { value: string; file: string }
} = await import(entry.entrypoint)
return module
}
async function realpaths(files: string[]) {
return Promise.all(files.map((file) => fs.realpath(file.startsWith("file:") ? new URL(file) : file)))
}
async function writeGitPackage(directory: string, version: string) {
await Bun.write(
path.join(directory, "package.json"),
JSON.stringify({
...pluginManifest(version),
scripts: {},
dependencies: { ...pluginManifest(version).dependencies, "@fixture/local": "file:./local" },
}),
)
await Bun.write(path.join(directory, "tui.js"), `${pluginSource(version)}\nexport { local } from '@fixture/local'`)
await Bun.write(path.join(directory, "late.js"), "export { value, file } from '@fixture/late'")
await fs.mkdir(path.join(directory, "local"), { recursive: true })
await Bun.write(
path.join(directory, "local", "package.json"),
JSON.stringify({
name: "@fixture/local",
version,
type: "module",
exports: "./index.js",
}),
)
await Bun.write(
path.join(directory, "local", "index.js"),
`export const local = { value: '${version}', file: import.meta.url }`,
)
}
test("registry updates isolate the whole graph and publish only after commit, including cold selection", async () => {
await using tmp = await tmpdir()
await using registry = await registryFixture(tmp.path)
const cache = path.join(await fs.realpath(tmp.path), "cache")
const spec = "@fixture/plugin@latest"
const normalRoot = await registry.configure(cache, spec)
const fixture = await resolverFixture(await fs.realpath(tmp.path))
const one = await fixture.packages.resolve(spec)
const old = await load(one)
expect(old.graph.values).toEqual(Array(4).fill("1.0.0"))
const before = await snapshot(normalRoot)
registry.state.latest = "1.1.0"
expect(await fixture.packages.check(spec)).toEqual({ installed: "1.0.0", available: "1.1.0", mutable: true })
const two = await fixture.packages.update(spec)
const fresh = await load(two)
expect(two.revision).toBe("1.1.0")
expect(fresh.graph.values).toEqual(Array(4).fill("1.1.0"))
const oldPaths = await realpaths(old.graph.paths)
const newPaths = await realpaths(fresh.graph.paths)
expect(newPaths.every((file) => file.startsWith(path.join(cache, "tui-packages")))).toBe(true)
expect(oldPaths.every((file) => !newPaths.includes(file))).toBe(true)
expect(await old.late()).toMatchObject({ value: "1.0.0" })
expect(await fresh.late()).toMatchObject({ value: "1.1.0" })
expect((await realpaths([(await old.late()).file]))[0]).toStartWith(normalRoot)
expect((await realpaths([(await fresh.late()).file]))[0]).toStartWith(path.join(cache, "tui-packages"))
expect(await fixture.packages.resolve(spec)).toEqual(one)
expect(await (await fixture.cold()).resolve(spec)).toEqual(one)
expect((await fixture.packages.check(spec)).installed).toBe("1.0.0")
expect(await snapshot(normalRoot)).toEqual(before)
const selected = { entrypoint: two.entrypoint, revision: two.revision }
await fixture.packages.commit(spec, selected)
expect(await fixture.packages.resolve(spec)).toEqual(selected)
expect(await (await fixture.cold()).resolve(spec)).toEqual(selected)
expect(await fixture.packages.check(spec)).toEqual({ installed: "1.1.0", available: "1.1.0", mutable: true })
expect(await fixture.packages.resolve(spec, false)).toEqual(one)
expect(one).toEqual(await Effect.runPromise(fixture.npm.resolve(spec, { subpaths: ["tui"] })))
expect(one).toEqual(await Effect.runPromise(fixture.npm.add(spec, { subpaths: ["tui"] })))
expect(await snapshot(normalRoot)).toEqual(before)
const selectedRoot = path.join(cache, "tui-packages", await Npm.cacheKey(spec))
expect(await Bun.file(path.join(selectedRoot, "current.json")).json()).toEqual(selected)
const pointer = await Bun.file(path.join(selectedRoot, "current.json")).bytes()
registry.state.latest = "1.2.0"
const broken = await fixture.packages.update(spec)
await expect(load(broken)).rejects.toThrow("failed import fixture")
expect(await Bun.file(path.join(broken.directory, "tui.js")).exists()).toBe(true)
expect(await fixture.packages.resolve(spec)).toEqual(selected)
expect(await Bun.file(path.join(selectedRoot, "current.json")).bytes()).toEqual(pointer)
const staged = await fs.readdir(selectedRoot)
registry.state.latest = "1.3.0"
registry.state.failTarballs = true
await expect(fixture.packages.update(spec)).rejects.toMatchObject({ code: "E404" })
expect(await fs.readdir(selectedRoot)).toEqual(staged)
expect(await fixture.packages.resolve(spec)).toEqual(selected)
expect(await Bun.file(path.join(selectedRoot, "current.json")).bytes()).toEqual(pointer)
expect(await snapshot(normalRoot)).toEqual(before)
}, 30_000)
test("Git branch updates use fresh ESM and CJS graphs, retain old late imports, and do not consume normal refresh", async () => {
await using tmp = await tmpdir()
await using registry = await registryFixture(tmp.path)
const repository = path.join(tmp.path, "repository")
await fs.mkdir(repository)
await writeGitPackage(repository, "1.0.0")
await Bun.$`git init -q -b fixture-branch ${repository}`.quiet()
await Bun.$`git -C ${repository} add .`.quiet()
await Bun.$`git -C ${repository} -c user.name=fixture -c user.email=fixture@example.com commit -qm one`.quiet()
const first = (await Bun.$`git -C ${repository} rev-parse HEAD`.text()).trim()
const spec = `git+${pathToFileURL(repository).href}#fixture-branch`
const pinned = `git+${pathToFileURL(repository).href}#${first}`
const cache = path.join(await fs.realpath(tmp.path), "cache")
const normalRoot = await registry.configure(cache, spec)
await registry.configure(cache, pinned)
const fixture = await resolverFixture(await fs.realpath(tmp.path))
const one = await fixture.packages.resolve(spec)
const old = await load(one)
const before = await snapshot(normalRoot)
await writeGitPackage(repository, "1.1.0")
await Bun.$`git -C ${repository} add .`.quiet()
await Bun.$`git -C ${repository} -c user.name=fixture -c user.email=fixture@example.com commit -qm two`.quiet()
const second = (await Bun.$`git -C ${repository} rev-parse HEAD`.text()).trim()
registry.state.latest = "1.1.0"
const two = await fixture.packages.update(spec)
const fresh = await load(two)
expect(one.revision).toBe(first)
expect(two.revision).toBe(second)
expect(old.graph.values).toEqual(Array(4).fill("1.0.0"))
expect(fresh.graph.values).toEqual(Array(4).fill("1.1.0"))
const oldPaths = await realpaths(old.graph.paths)
const newPaths = await realpaths(fresh.graph.paths)
expect(oldPaths.every((file) => !newPaths.includes(file))).toBe(true)
expect(newPaths.every((file) => file.startsWith(path.join(cache, "tui-packages")))).toBe(true)
expect(old.local?.value).toBe("1.0.0")
expect(fresh.local?.value).toBe("1.1.0")
const localPath = (await realpaths([fresh.local?.file ?? ""]))[0]
expect(localPath).toStartWith(path.join(cache, "tui-packages"))
expect((await fs.lstat(path.dirname(localPath))).isSymbolicLink()).toBe(false)
expect(localPath).not.toBe((await realpaths([old.local?.file ?? ""]))[0])
expect((await old.late()).value).toBe("1.0.0")
expect((await fresh.late()).value).toBe("1.1.0")
expect(await fixture.packages.resolve(spec)).toEqual(one)
expect(await (await fixture.cold()).resolve(spec)).toEqual(one)
await fixture.packages.commit(spec, two)
expect(await (await fixture.cold()).resolve(spec)).toEqual({ entrypoint: two.entrypoint, revision: two.revision })
expect(await fixture.packages.resolve(spec, false)).toEqual(one)
expect(await fixture.packages.check(spec)).toEqual({ installed: second, available: second, mutable: true })
expect(await snapshot(normalRoot)).toEqual(before)
await expect(fixture.packages.update(pinned)).rejects.toThrow("Pinned packages cannot be updated")
expect(await fs.readdir(path.join(cache, "tui-packages"))).toEqual([await Npm.cacheKey(spec)])
const pinnedEntry = await Effect.runPromise(
fixture.npm.add(pinned, { root: path.join(await fs.realpath(tmp.path), "pinned"), subpaths: ["tui"] }),
)
expect(pinnedEntry.revision).toBe(first)
expect((await load(pinnedEntry)).graph.values[0]).toBe("1.0.0")
expect((await Effect.runPromise(fixture.npm.add(spec, { refresh: true, subpaths: ["tui"] }))).revision).toBe(second)
}, 30_000)
test("pins and unsupported specs do not stage or select, and fresh registry pins stay exact", async () => {
await using tmp = await tmpdir()
await using registry = await registryFixture(tmp.path)
const cache = path.join(await fs.realpath(tmp.path), "cache")
const spec = "@fixture/plugin@1.0.0"
const root = await registry.configure(cache, spec)
const fixture = await resolverFixture(await fs.realpath(tmp.path))
const one = await fixture.packages.resolve(spec)
const before = await snapshot(root)
registry.state.latest = "1.1.0"
await expect(fixture.packages.update(spec)).rejects.toThrow("Pinned packages cannot be updated")
for (const unsupported of ["./local", "https://example.test/plugin.tgz", "alias@npm:@fixture/plugin@latest"])
await expect(fixture.packages.update(unsupported)).rejects.toThrow(
"Package checks only support registry and Git package specs",
)
expect(await fs.readdir(cache)).not.toContain("tui-packages")
expect(await fixture.packages.resolve(spec)).toEqual(one)
expect(await snapshot(root)).toEqual(before)
const pinned = await Effect.runPromise(
fixture.npm.add(spec, { root: path.join(tmp.path, "pinned"), subpaths: ["tui"] }),
)
expect(pinned.revision).toBe("1.0.0")
expect((await load(pinned)).graph.values[0]).toBe("1.0.0")
expect(await snapshot(root)).toEqual(before)
})
test("independent terminals commit last-writer-wins without deleting either successful graph", async () => {
await using tmp = await tmpdir()
await using registry = await registryFixture(tmp.path)
const spec = "@fixture/plugin@latest"
const cache = path.join(await fs.realpath(tmp.path), "cache")
await registry.configure(cache, spec)
const fixture = await resolverFixture(await fs.realpath(tmp.path))
const other = await fixture.cold()
const [first, second] = await Promise.all([fixture.packages.update(spec), other.update(spec)])
expect(first.entrypoint).not.toBe(second.entrypoint)
await fixture.packages.commit(spec, first)
await other.commit(spec, second)
expect(await (await fixture.cold()).resolve(spec)).toEqual({
entrypoint: second.entrypoint,
revision: second.revision,
})
expect((await load(first)).graph.values).toEqual(Array(4).fill("1.0.0"))
expect((await load(second)).graph.values).toEqual(Array(4).fill("1.0.0"))
const selected = path.join(cache, "tui-packages", await Npm.cacheKey(spec))
expect((await fs.readdir(selected)).filter((file) => file.endsWith(".tmp"))).toEqual([])
})
test("symlink cache aliases resolve normally and prepare canonical Git and registry graphs", async () => {
await using tmp = await tmpdir()
const directory = await fs.realpath(tmp.path)
await using registry = await registryFixture(directory)
const repository = path.join(directory, "repository")
await fs.mkdir(repository)
await writeGitPackage(repository, "1.0.0")
await Bun.$`git init -q -b fixture-branch ${repository}`.quiet()
await Bun.$`git -C ${repository} add .`.quiet()
await Bun.$`git -C ${repository} -c user.name=fixture -c user.email=fixture@example.com commit -qm one`.quiet()
const cache = path.join(directory, "cache")
const alias = path.join(directory, "cache-alias")
await fs.mkdir(cache)
await fs.symlink(cache, alias, "junction")
const normal = await resolverFixture(directory)
const aliased = await resolverFixture(directory, { cache: alias })
const installed = await Promise.all(
[`git+${pathToFileURL(repository).href}#fixture-branch`, "@fixture/plugin@latest"].map(async (spec) => {
await registry.configure(alias, spec)
const entry = await normal.packages.resolve(spec)
expect(await aliased.packages.resolve(spec)).toMatchObject({
entrypoint: entry.entrypoint,
revision: entry.revision,
})
expect(await aliased.packages.resolve(spec, false)).toMatchObject({
entrypoint: entry.entrypoint,
revision: entry.revision,
})
return { spec, entry, module: await load(entry) }
}),
)
const before = await snapshot(path.join(cache, "packages"))
await writeGitPackage(repository, "1.1.0")
await Bun.$`git -C ${repository} add .`.quiet()
await Bun.$`git -C ${repository} -c user.name=fixture -c user.email=fixture@example.com commit -qm two`.quiet()
registry.state.latest = "1.1.0"
for (const old of installed) {
const entry = await aliased.packages.update(old.spec)
const fresh = await load(entry)
expect(entry.directory).toStartWith(path.join(cache, "tui-packages"))
expect(fresh.graph.values).toEqual(Array(4).fill("1.1.0"))
const oldPaths = await realpaths(old.module.graph.paths)
const freshPaths = await realpaths(fresh.graph.paths)
expect(
freshPaths.every((file) => file.startsWith(path.join(cache, "tui-packages")) && !oldPaths.includes(file)),
).toBe(true)
expect((await old.module.late()).value).toBe("1.0.0")
expect((await fresh.late()).value).toBe("1.1.0")
expect((await aliased.packages.resolve(old.spec)).revision).toBe(old.entry.revision)
expect(await snapshot(path.join(cache, "packages"))).toEqual(before)
const selected = { entrypoint: entry.entrypoint, revision: entry.revision }
await aliased.packages.commit(old.spec, selected)
expect(await (await aliased.cold()).resolve(old.spec)).toEqual(selected)
expect(await (await normal.cold()).resolve(old.spec)).toEqual(selected)
expect((await aliased.packages.check(old.spec)).installed).toBe(entry.revision)
expect((await aliased.packages.resolve(old.spec, false)).revision).toBe(old.entry.revision)
expect(
await Bun.file(path.join(alias, "tui-packages", await Npm.cacheKey(old.spec), "current.json")).json(),
).toEqual(selected)
expect(await snapshot(path.join(cache, "packages"))).toEqual(before)
}
expect((await fs.lstat(alias)).isSymbolicLink()).toBe(true)
}, 30_000)
+8
View File
@@ -88,8 +88,16 @@ export type PluginListInput = {
export type PluginListOutput = { readonly location: Location.Info; readonly data: ReadonlyArray<Plugin.Info> }
export type PluginListOperation<E = never> = (input?: PluginListInput) => Effect.Effect<PluginListOutput, E>
export type PluginCheckInput = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly target: string
}
export type PluginCheckOutput = { readonly location: Location.Info; readonly data: Plugin.PackageStatus }
export type PluginCheckOperation<E = never> = (input: PluginCheckInput) => Effect.Effect<PluginCheckOutput, E>
export interface PluginApi<E = never> {
readonly list: PluginListOperation<E>
readonly check: PluginCheckOperation<E>
}
export type SessionListInput = {
+13 -1
View File
@@ -15,6 +15,8 @@ import type {
AgentGetOutput,
PluginListInput,
PluginListOutput,
PluginCheckInput,
PluginCheckOutput,
SessionListInput,
SessionListOutput,
SessionStatsInput,
@@ -318,7 +320,17 @@ const EndpointPluginList = (raw: RawClient["server.plugin"]) => (input?: PluginL
raw["plugin.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
)
const adaptGroupPlugin = (raw: RawClient["server.plugin"]) => ({ list: EndpointPluginList(raw) })
const EndpointPluginCheck = (raw: RawClient["server.plugin"]) => (input: PluginCheckInput) =>
preserveEffect<PluginCheckOutput>()(
raw["plugin.check"]({ query: { location: input["location"] }, payload: { target: input["target"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const adaptGroupPlugin = (raw: RawClient["server.plugin"]) => ({
list: EndpointPluginList(raw),
check: EndpointPluginCheck(raw),
})
const EndpointSessionList = (raw: RawClient["server.session"]) => (input?: SessionListInput) =>
preserveEffect<SessionListOutput>()(
+1
View File
@@ -32,6 +32,7 @@ export { Location } from "@opencode-ai/schema/location"
export { Model } from "@opencode-ai/schema/model"
export { Permission } from "@opencode-ai/schema/permission"
export { PermissionSaved } from "@opencode-ai/schema/permission-saved"
export { Plugin } from "@opencode-ai/schema/plugin"
export { Project } from "@opencode-ai/schema/project"
export { Worktree } from "@opencode-ai/schema/worktree"
export { Provider } from "@opencode-ai/schema/provider"
@@ -9,6 +9,8 @@ import type {
AgentGetOutput,
PluginListInput,
PluginListOutput,
PluginCheckInput,
PluginCheckOutput,
SessionListInput,
SessionListOutput,
SessionStatsInput,
@@ -462,6 +464,19 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
check: (input: PluginCheckInput, requestOptions?: RequestOptions) =>
request<PluginCheckOutput>(
{
method: "POST",
path: `/api/plugin/check`,
query: { location: input["location"] },
body: { target: input["target"] },
successStatus: 200,
declaredStatuses: [400, 401],
empty: false,
},
requestOptions,
),
},
session: {
list: (input?: SessionListInput, requestOptions?: RequestOptions) =>
+20 -2
View File
@@ -16,6 +16,8 @@ export type PluginSource =
| { type: "local"; path: string }
| { type: "sdk" }
export type PluginPackageStatus = { installed?: string; available?: string; mutable: boolean }
export type SessionForkBoundary = { type: "before"; messageID: string } | { type: "through"; messageID: string }
export type MoneyUSD = number
@@ -441,8 +443,8 @@ export type ProviderRequest = {
export type PermissionRule = { action: string; resource: string; effect: PermissionEffect }
export type PluginInfo =
| { id: string; source: PluginSource; status: "active"; tui: boolean }
| { id?: string; source: PluginSource; status: "failed"; error: string; tui: boolean }
| { id: string; source: PluginSource; revision?: string; status: "active"; tui: boolean }
| { id?: string; source: PluginSource; revision?: string; status: "failed"; error: string; tui: boolean }
export type SessionMessageLocationSwitched = {
id: string
@@ -2338,6 +2340,10 @@ export type AgentNotFoundError = {
export const isAgentNotFoundError = (value: unknown): value is AgentNotFoundError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "AgentNotFoundError"
export type PluginCheckError = { readonly _tag: "PluginCheckError"; readonly message: string }
export const isPluginCheckError = (value: unknown): value is PluginCheckError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "PluginCheckError"
export type InvalidCursorError = { readonly _tag: "InvalidCursorError"; readonly message: string }
export const isInvalidCursorError = (value: unknown): value is InvalidCursorError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "InvalidCursorError"
@@ -2548,6 +2554,18 @@ export type PluginListOutput = {
data: Array<PluginInfo>
}
export type PluginCheckInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
readonly target: { readonly target: string }["target"]
}
export type PluginCheckOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: PluginPackageStatus
}
export type SessionListInput = {
readonly workspace?: {
readonly workspace?: string | undefined
@@ -3,6 +3,7 @@ import { Schema } from "effect"
import { Agent } from "@opencode-ai/schema/agent"
import { Config } from "@opencode-ai/schema/config"
import { Model } from "@opencode-ai/schema/model"
import { Plugin } from "@opencode-ai/schema/plugin"
import { Prompt } from "@opencode-ai/schema/prompt"
import { Session } from "@opencode-ai/schema/session"
import { SessionMessage } from "@opencode-ai/schema/session-message"
@@ -13,6 +14,7 @@ test("effect entrypoint exposes canonical Schema contracts", () => {
expect(Client.Agent).toBe(Agent)
expect(Client.Config).toBe(Config)
expect(Client.Model).toBe(Model)
expect(Client.Plugin).toBe(Plugin)
expect(Client.Session).toBe(Session)
})
+35
View File
@@ -8,6 +8,7 @@ import {
Location,
Model,
OpenCode,
Plugin,
Prompt,
Session,
SessionMessage,
@@ -27,6 +28,40 @@ test("health.get decodes the readiness response", async () => {
expect(result).toEqual({ healthy: true, version: "old", pid: 123 })
})
test("plugin.check decodes package status and its declared error", async () => {
const status = { installed: "1.0.0", available: "1.1.0", mutable: true }
const httpClient = HttpClient.make((request) => {
expect(request.method).toBe("POST")
expect(request.url).toBe("http://localhost:3000/api/plugin/check")
expect(Object.fromEntries(request.urlParams.params)).toEqual({ "location[directory]": "/tmp/project" })
const body =
request.body._tag === "Uint8Array" ? JSON.parse(new TextDecoder().decode(request.body.body)) : undefined
expect(Object.keys(body)).toEqual(["target"])
return Effect.succeed(
HttpClientResponse.fromWeb(
request,
body.target === "missing"
? Response.json({ _tag: "PluginCheckError", message: "Not a configured plugin source" }, { status: 400 })
: Response.json({
location: {
directory: "/tmp/project",
project: { id: "proj_test", directory: "/tmp/project", canonical: "/tmp/project" },
},
data: status,
}),
),
)
})
await Effect.gen(function* () {
const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
const location = { directory: "/tmp/project" }
expect((yield* client.plugin.check({ target: "fixture-plugin@^1", location })).data).toEqual(status)
const error = yield* client.plugin.check({ target: "missing", location }).pipe(Effect.flip)
expect(error).toBeInstanceOf(Plugin.CheckError)
expect(error.message).toBe("Not a configured plugin source")
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
})
test("session.get returns the decoded Effect projection", async () => {
const httpClient = HttpClient.make((request) =>
Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(session))),
+29 -1
View File
@@ -1,5 +1,5 @@
import { expect, test } from "bun:test"
import { isSessionNotFoundError, isUnauthorizedError, OpenCode } from "../src/promise/index"
import { isPluginCheckError, isSessionNotFoundError, isUnauthorizedError, OpenCode } from "../src/promise/index"
test("exposes every standard HTTP API group", () => {
const client = OpenCode.make({ baseUrl: "http://localhost:3000" })
@@ -55,6 +55,34 @@ test("exposes every standard HTTP API group", () => {
expect(Object.keys(client.shell)).toEqual(["list", "create", "get", "timeout", "output", "remove"])
expect(Object.keys(client.project)).toEqual(["list", "update", "current"])
expect(Object.keys(client.worktree)).toEqual(["list", "create", "remove", "refresh"])
expect(Object.keys(client.plugin)).toEqual(["list", "check"])
})
test("plugin.check uses the inspection contract and retains declared errors", async () => {
const status = { installed: "1.0.0", available: "1.1.0", mutable: true }
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: async (input, init) => {
const request = input instanceof Request ? input : new Request(input, init)
expect(request.method).toBe("POST")
expect(request.url).toBe("http://localhost:3000/api/plugin/check?location%5Bdirectory%5D=%2Ftmp%2Fproject")
const body = await request.json()
expect(Object.keys(body)).toEqual(["target"])
if (body.target === "missing")
return Response.json({ _tag: "PluginCheckError", message: "Not a configured plugin source" }, { status: 400 })
expect(body.target).toBe("fixture-plugin@^1")
return Response.json({ location: { directory: "/tmp/project" }, data: status })
},
})
expect(
(await client.plugin.check({ target: "fixture-plugin@^1", location: { directory: "/tmp/project" } })).data,
).toEqual(status)
const error = await client.plugin
.check({ target: "missing", location: { directory: "/tmp/project" } })
.catch((cause: unknown) => cause)
expect(isPluginCheckError(error)).toBe(true)
if (!isPluginCheckError(error)) throw new Error("Expected PluginCheckError")
expect(error.message).toBe("Not a configured plugin source")
})
test("config.get returns ordered config entries for a location", async () => {
+9 -3
View File
@@ -1,5 +1,5 @@
export * as Plugin from "./plugin.js"
export { Event, ID, Info, Source } from "@opencode-ai/schema/plugin"
export { CheckError, Event, ID, Info, PackageStatus, Source } from "@opencode-ai/schema/plugin"
import { Plugin } from "@opencode-ai/schema/plugin"
import type { Plugin as PluginDefinition } from "@opencode-ai/plugin/effect/plugin"
@@ -37,6 +37,7 @@ export interface Interface {
export type Versioned = PluginDefinition & {
readonly version: string
readonly revision?: string
readonly source?: Plugin.Source
}
@@ -119,18 +120,22 @@ const layer = Layer.effect(
nextInventory.push(activeInfo(definition))
continue
}
nextInventory.push({
const failed = {
id: definition.id,
source: definition.source ?? { type: "builtin" },
...(definition.revision === undefined ? {} : { revision: definition.revision }),
status: "failed",
error: loaded.error,
tui: definition.tui ?? false,
})
} satisfies Plugin.Info
nextInventory.push(failed)
if (!previous) continue
const restored = yield* load(previous.plugin)
if (restored.scope !== undefined) {
active.set(definition.id, { plugin: previous.plugin, scope: restored.scope })
failed.revision = previous.plugin.revision
if (failed.revision === undefined) delete failed.revision
continue
}
yield* Effect.logError("failed to restore plugin; deactivating", {
@@ -175,6 +180,7 @@ function activeInfo(plugin: Versioned): Plugin.Info {
return {
id: Plugin.ID.make(plugin.id),
source: plugin.source ?? { type: "builtin" },
...(plugin.revision === undefined ? {} : { revision: plugin.revision }),
status: "active",
tui: plugin.tui ?? false,
}
+7
View File
@@ -3,6 +3,7 @@ export * as PluginHost from "./host.js"
import { Plugin } from "@opencode-ai/plugin/effect"
import type { IntegrationMethodRegistration } from "@opencode-ai/plugin/effect/integration"
import { EventManifest } from "@opencode-ai/schema/event-manifest"
import { CheckError } from "@opencode-ai/schema/plugin"
import { ServerConfig } from "@opencode-ai/schema/mcp"
import { App } from "../app.js"
import { Effect, Schema, Stream } from "effect"
@@ -332,6 +333,12 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, p
},
plugin: {
list: () => response(plugin.list()),
check: () =>
Effect.fail(
new CheckError({
message: "Plugin package checks are available through an external client or the Plugins dialog.",
}),
),
},
reference: {
list: () => response(reference.list()),
+11 -4
View File
@@ -3,7 +3,7 @@ export * as PluginModule from "./module.js"
import type { Plugin } from "@opencode-ai/plugin/effect/plugin"
import { Npm } from "@opencode-ai/util/npm"
import { importModule } from "@opencode-ai/util/runtime-import"
import { Effect, Schema } from "effect"
import { Effect, Predicate, Schema } from "effect"
import path from "path"
import { pathToFileURL } from "url"
import type { ConfigPluginSource } from "../config/plugin/source.js"
@@ -34,13 +34,16 @@ const Definition = Schema.Struct({
]),
})
const revisions = new WeakMap<object, string | undefined>()
export const load = Effect.fn("PluginModule.load")(function* (
operation: Extract<ConfigPluginSource.Operation, { type: "add" }>,
) {
const npm = yield* Npm.Service
const entrypoint = path.isAbsolute(operation.target)
? pathToFileURL(operation.target).href
: (yield* npm.add(operation.target, { subpaths: ["server", ""], refresh: true })).entrypoint
const resolved = path.isAbsolute(operation.target)
? { entrypoint: pathToFileURL(operation.target).href, revision: undefined }
: yield* npm.add(operation.target, { subpaths: ["server", ""], refresh: true })
const entrypoint = resolved.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 target = typeof Bun !== "undefined" ? operation.target.replaceAll("\\", "/") : entrypoint
@@ -48,12 +51,16 @@ export const load = Effect.fn("PluginModule.load")(function* (
yield* Effect.log({ msg: "loading plugin", id: operation.target, entrypoint: source })
const mod = yield* Effect.promise(() => importModule(source))
const value = (yield* Schema.decodeUnknownEffect(Definition)(mod)).default
const exported = Predicate.hasProperty(mod, "default") && Predicate.isObject(mod.default) ? mod.default : undefined
// Keep the import-time revision on the original export, not copied namespaces or decoded records.
if (exported && !revisions.has(exported)) revisions.set(exported, resolved.revision)
const plugin = "effect" in value ? value : PluginPromise.fromPromise(value)
return {
id: plugin.id,
tui: plugin.tui,
vcs: plugin.vcs,
version: JSON.stringify(operation),
revision: exported ? revisions.get(exported) : undefined,
source: path.isAbsolute(operation.target)
? { type: "local" as const, path: operation.target }
: { type: "package" as const, package: operation.target },
@@ -1,6 +1,7 @@
export * as PluginSupervisor from "./supervisor-service.js"
import { Context, Effect } from "effect"
import type { Plugin } from "@opencode-ai/schema/plugin"
/**
* Dependency-only supervisor seam. Keep this module free of implementation
@@ -9,6 +10,7 @@ import { Context, Effect } from "effect"
export interface Interface {
/** Wait for the initial plugin generation and startup updates to settle. */
readonly flush: Effect.Effect<void>
readonly check: (target: string) => Effect.Effect<Plugin.PackageStatus, Plugin.CheckError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/PluginSupervisor") {}
+24 -1
View File
@@ -89,6 +89,7 @@ export const layer = Layer.effect(
const instance = yield* InstancePlugins.Service
const sources = yield* ConfigPluginSource.Service
const bus = yield* Bus.Service
const npm = yield* Npm.Service
const ready = yield* Latch.make()
let observed = 0
@@ -136,7 +137,29 @@ export const layer = Layer.effect(
),
Effect.forkScoped({ startImmediately: true }),
)
return Service.of({ flush: ready.await })
return Service.of({
flush: ready.await,
check: Effect.fn("PluginSupervisor.check")(function* (target: string) {
yield* ready.await
const inventory = yield* registry.list()
if (!inventory.some((plugin) => plugin.source.type === "package" && plugin.source.package === target)) {
return yield* new Plugin.CheckError({
message: `Plugin package is not in the current server inventory: ${target}`,
})
}
if (!(yield* Effect.promise(() => Npm.isInstallablePackage(target)))) {
return yield* new Plugin.CheckError({ message: `Unsupported plugin package source: ${target}` })
}
return yield* npm.check(target).pipe(
Effect.mapError(
(error) =>
new Plugin.CheckError({
message: `Failed to check plugin package ${target}: ${error.cause instanceof Error ? error.cause.message : error.message}`,
}),
),
)
}),
})
}),
)
@@ -31,7 +31,10 @@ const it = testEffect(AppNodeBuilder.build(LayerNode.group([FSUtil.node, Bus.nod
const configLayer = Config.testLayer()
const pluginNode = makeLocationNode({
service: PluginSupervisor.Service,
layer: Layer.succeed(PluginSupervisor.Service, PluginSupervisor.Service.of({ flush: Effect.void })),
layer: Layer.succeed(
PluginSupervisor.Service,
PluginSupervisor.Service.of({ flush: Effect.void, check: () => Effect.succeed({ mutable: false }) }),
),
deps: [],
})
@@ -313,7 +316,7 @@ describe("LocationWatcher subscriptions", () => {
Effect.gen(function* () {
const policy = yield* LocationWatcherPolicy.Service
yield* policy.transform((draft) => draft.add([".git"]))
return PluginSupervisor.Service.of({ flush: Effect.void })
return PluginSupervisor.Service.of({ flush: Effect.void, check: () => Effect.succeed({ mutable: false }) })
}),
),
deps: [LocationWatcherPolicy.node],
@@ -12,7 +12,10 @@ export const promptLocationLayer = Layer.effect(
() =>
Layer.merge(
LayerNode.compile(PluginHooks.node),
Layer.succeed(PluginSupervisor.Service, { flush: Effect.void }),
Layer.succeed(PluginSupervisor.Service, {
flush: Effect.void,
check: () => Effect.succeed({ mutable: false }),
}),
) as Layer.Layer<LocationServices>,
),
)
+165 -1
View File
@@ -55,6 +55,62 @@ async function createGitFixture(directory: string) {
return { repository, commit }
}
async function createRegistryFixture(directory: string) {
const tarballs = new Map<string, Uint8Array>()
for (const version of ["1.0.0", "1.1.0"]) {
const root = path.join(directory, version)
await fs.mkdir(path.join(root, "package"), { recursive: true })
await writePackage(path.join(root, "package"), {
name: "@fixture/registry-plugin",
version,
exports: "./index.js",
})
await Bun.write(path.join(root, "package", "index.js"), `export const version = "${version}"\n`)
await Bun.$`tar -czf package.tgz package`.cwd(root)
tarballs.set(version, await Bun.file(path.join(root, "package.tgz")).bytes())
}
const state = { latest: "1.0.0", offline: false, requests: 0, tarballRequests: 0 }
const server = Bun.serve({
hostname: "127.0.0.1",
port: 0,
fetch(request) {
state.requests++
if (state.offline) return new Response("offline", { status: 503 })
const url = new URL(request.url)
if (decodeURIComponent(url.pathname) === "/@fixture/registry-plugin")
return Response.json({
name: "@fixture/registry-plugin",
"dist-tags": { latest: state.latest },
versions: Object.fromEntries(
[...tarballs.keys()].map((version) => [
version,
{ name: "@fixture/registry-plugin", version, dist: { tarball: `${url.origin}/${version}.tgz` } },
]),
),
})
state.tarballRequests++
const tarball = tarballs.get(url.pathname.slice(1).replace(".tgz", ""))
return tarball ? new Response(tarball) : new Response("missing", { status: 404 })
},
})
return {
state,
tarball: `${server.url}1.0.0.tgz`,
async configure(cache: string, spec: string) {
const root = path.join(cache, "packages", await Npm.cacheKey(spec))
await fs.mkdir(root, { recursive: true })
await Bun.write(
path.join(root, ".npmrc"),
`@fixture:registry=${server.url}\ncache=${path.join(directory, "npm-cache")}\nfetch-retries=0\naudit=false\n`,
)
return root
},
async [Symbol.asyncDispose]() {
await server.stop(true)
},
}
}
describe("Npm.sanitize", () => {
test("keeps normal scoped package specs unchanged", () => {
expect(Npm.sanitize("@opencode/acme")).toBe("@opencode/acme")
@@ -131,6 +187,7 @@ describe("Npm.add", () => {
expect(entry.directory).toBe(directory)
expect(entry.entrypoint).toEndWith("/index.js")
expect(entry.revision).toBe("1.0.0")
})
test("falls back to the original spec when parsing fails", async () => {
@@ -198,6 +255,7 @@ describe("Npm.add", () => {
}).pipe(Effect.scoped, Effect.provide(npmLayer(cache)), Effect.runPromise)
expect(entries.added.entrypoint).toEndWith("/index.js")
expect(entries.added.revision).toBe(fixture.commit)
expect(entries.cached).toEqual(entries.added)
expect(entries.resolved).toEqual(entries.added)
expect(
@@ -218,6 +276,7 @@ describe("Npm.add", () => {
expect(entry.directory).toEndWith(path.join("node_modules", "fixture-subdirectory-plugin"))
expect(entry.entrypoint).toEndWith("/index.js")
expect(entry.revision).toBe(fixture.commit)
expect(
await fs.stat(path.join(path.dirname(entry.directory), "fixture-subdirectory-dependency", "package.json")),
).toBeTruthy()
@@ -236,11 +295,27 @@ describe("Npm.add", () => {
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 () => {
const nextCommit = 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`
return Bun.$`git -C ${fixture.repository} rev-parse HEAD`.text().then((value) => value.trim())
})
const lock = path.join(cache, "packages", yield* Effect.promise(() => Npm.cacheKey(mutable)), "package-lock.json")
const before = yield* Effect.promise(() => Bun.file(lock).bytes())
expect((yield* npm.resolve(mutable)).revision).toBe(fixture.commit)
expect(yield* npm.check(mutable)).toEqual({ installed: fixture.commit, available: nextCommit, mutable: true })
expect(yield* npm.check(pinned)).toEqual({ installed: fixture.commit, available: fixture.commit, mutable: false })
expect(yield* Effect.promise(() => Bun.file(lock).bytes())).toEqual(before)
expect(yield* npm.add(mutable)).toEqual(mutableEntry)
expect(mutableEntry.revision).toBe(fixture.commit)
const uncached = `git+${repository}#HEAD`
expect(yield* npm.check(uncached)).toEqual({ installed: undefined, available: nextCommit, mutable: true })
expect(yield* Effect.promise(() => fs.readdir(path.join(cache, "packages")))).not.toContain(
yield* Effect.promise(() => Npm.cacheKey(uncached)),
)
expect((yield* npm.resolve(uncached)).entrypoint).toBeUndefined()
yield* npm.add(mutable, { refresh: true })
return { mutable: mutableEntry, pinned: pinnedEntry }
}).pipe(Effect.scoped, Effect.provide(npmLayer(cache)), Effect.runPromise)
@@ -260,6 +335,8 @@ describe("Npm.add", () => {
await fs.rename(fixture.repository, `${fixture.repository}-offline`)
const offline = await Effect.gen(function* () {
const npm = yield* Npm.Service
expect((yield* npm.resolve(mutable)).revision).toBe(second.mutable.revision)
expect((yield* npm.check(mutable).pipe(Effect.flip))._tag).toBe("NpmInstallFailedError")
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"')
@@ -291,5 +368,92 @@ describe("Npm.resolve", () => {
return yield* npm.resolve(spec, { subpaths: ["tui"] })
}).pipe(Effect.scoped, Effect.provide(npmLayer(cache)), Effect.runPromise)
expect(resolved.entrypoint).toEndWith("/tui.js")
expect(resolved.revision).toBe("1.0.0")
})
})
describe("Npm.check and revision metadata", () => {
test("checks registry tags, ranges, and exact pins without changing or installing packages", async () => {
await using tmp = await tmpdir()
await using registry = await createRegistryFixture(tmp.path)
const cache = path.join(tmp.path, "cache")
const spec = "@fixture/registry-plugin@latest"
const range = "@fixture/registry-plugin@^1.0.0"
const pinned = "@fixture/registry-plugin@1.0.0"
const alias = "alias@npm:@fixture/registry-plugin@latest"
const root = await registry.configure(cache, spec)
const rangeRoot = await registry.configure(cache, range)
const pinnedRoot = await registry.configure(cache, pinned)
await registry.configure(cache, alias)
await Effect.gen(function* () {
const npm = yield* Npm.Service
for (const unsupported of [registry.tarball, alias]) {
const error = yield* npm.check(unsupported).pipe(Effect.flip)
expect(error._tag).toBe("NpmInstallFailedError")
expect(error.cause).toEqual(new Error("Package checks only support registry and Git package specs"))
}
expect(registry.state.requests).toBe(0)
expect(registry.state.tarballRequests).toBe(0)
expect((yield* npm.resolve(spec)).revision).toBeUndefined()
const loaded = yield* npm.add(spec)
expect(loaded.revision).toBe("1.0.0")
const files = [
"package.json",
"package-lock.json",
"node_modules/@fixture/registry-plugin/package.json",
"node_modules/@fixture/registry-plugin/index.js",
]
const before = yield* Effect.promise(() =>
Promise.all(files.map((file) => Bun.file(path.join(root, file)).bytes())),
)
const requests = registry.state.tarballRequests
expect(yield* npm.check(spec)).toEqual({ installed: "1.0.0", available: "1.0.0", mutable: true })
registry.state.latest = "1.1.0"
expect(yield* npm.check(spec)).toEqual({ installed: "1.0.0", available: "1.1.0", mutable: true })
expect(yield* npm.check(range)).toEqual({ installed: undefined, available: "1.1.0", mutable: true })
expect(yield* npm.check(pinned)).toEqual({ installed: undefined, available: "1.0.0", mutable: false })
expect(yield* npm.add(spec)).toEqual(loaded)
expect(yield* npm.resolve(spec)).toEqual(loaded)
expect(
yield* Effect.promise(() => Promise.all(files.map((file) => Bun.file(path.join(root, file)).bytes()))),
).toEqual(before)
expect(registry.state.tarballRequests).toBe(requests)
expect(yield* Effect.promise(() => fs.readdir(rangeRoot))).toEqual([".npmrc"])
expect(yield* Effect.promise(() => fs.readdir(pinnedRoot))).toEqual([".npmrc"])
registry.state.offline = true
expect((yield* npm.check(spec).pipe(Effect.flip))._tag).toBe("NpmInstallFailedError")
expect((yield* npm.resolve(spec)).revision).toBe("1.0.0")
expect(yield* npm.add(spec)).toEqual(loaded)
}).pipe(Effect.scoped, Effect.provide(npmLayer(cache)), Effect.runPromise)
}, 30_000)
test("leaves unknown installed revisions undefined and prefers the wrapper Git lock", async () => {
await using tmp = await tmpdir()
const cache = path.join(tmp.path, "cache")
const commit = "0123456789abcdef0123456789abcdef01234567"
const spec = `git+https://example.com/fixture.git#${commit}`
const root = path.join(cache, "packages", await Npm.cacheKey(spec))
const directory = path.join(root, "node_modules", "fixture")
await fs.mkdir(directory, { recursive: true })
await writePackage(root, { dependencies: { fixture: spec } })
await writePackage(directory, { name: "fixture", exports: "./index.js" })
await Bun.write(path.join(directory, "index.js"), "export default {}\n")
await Effect.gen(function* () {
const npm = yield* Npm.Service
expect((yield* npm.resolve(spec)).revision).toBeUndefined()
expect((yield* npm.add(spec)).revision).toBeUndefined()
const lock = (revision: string) =>
JSON.stringify({
packages: { "node_modules/fixture": { resolved: `git+https://example.com/fixture.git#${revision}` } },
})
yield* Effect.promise(() => Bun.write(path.join(root, "node_modules", ".package-lock.json"), lock(commit)))
expect((yield* npm.resolve(spec)).revision).toBe(commit)
const installed = "f".repeat(40)
yield* Effect.promise(() => Bun.write(path.join(root, "package-lock.json"), lock(installed)))
expect((yield* npm.resolve(spec)).revision).toBe(installed)
yield* Effect.promise(() => fs.rm(directory, { recursive: true }))
expect((yield* npm.resolve(spec)).revision).toBeUndefined()
}).pipe(Effect.scoped, Effect.provide(npmLayer(cache)), Effect.runPromise)
})
})
+16 -12
View File
@@ -450,19 +450,23 @@ describe("Plugin", () => {
}),
})
yield* plugins.activate([versioned(previous)])
yield* plugins.activate([versioned(replacement, "2")])
for (const revision of ["1.0.0", undefined]) {
yield* plugins.activate([{ ...versioned(previous, revision ?? "unknown"), revision }])
yield* plugins.activate([{ ...versioned(replacement, "2"), revision: "2.0.0" }])
expect(yield* plugins.list()).toEqual([
{
id: Plugin.ID.make("managed"),
source: { type: "builtin" },
status: "failed",
error: expect.stringContaining("replacement failed"),
tui: false,
},
])
expect((yield* agents.get(Agent.ID.make("configured")))?.description).toBe("previous")
expect(yield* plugins.list()).toEqual([
{
id: Plugin.ID.make("managed"),
source: { type: "builtin" },
...(revision === undefined ? {} : { revision }),
status: "failed",
error: expect.stringContaining("replacement failed"),
tui: false,
},
])
if (revision === undefined) expect((yield* plugins.list())[0]).not.toHaveProperty("revision")
expect((yield* agents.get(Agent.ID.make("configured")))?.description).toBe("previous")
}
}),
)
+248
View File
@@ -0,0 +1,248 @@
import path from "node:path"
import fs from "node:fs/promises"
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Bus } from "@opencode-ai/core/bus"
import { Location } from "@opencode-ai/core/location"
import { LocationServiceMap } from "@opencode-ai/core/location-services"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginModule } from "@opencode-ai/core/plugin/module"
import { PluginPromise } from "@opencode-ai/core/plugin/promise"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
import { AbsolutePath } from "@opencode-ai/schema/schema"
import { PackageStatus } from "@opencode-ai/schema/plugin"
import { Global } from "@opencode-ai/util/global"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Npm } from "@opencode-ai/util/npm"
import { tempGlobalLayer } from "../fixture/global"
import { tmpdir } from "../fixture/tmpdir"
import { it, testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
const pluginIt = testEffect(PluginTestLayer)
// Bun.build inside bun test panics on Windows; the Node import boundary is platform-independent.
describe.skipIf(process.platform === "win32")("Node plugin revisions", () => {
it.live("keeps the loaded revision across Node CommonJS namespace wrappers", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("plugin-node-revision-")))
const entrypoint = path.join(tmp.path, "plugin.cjs")
const harness = path.join(tmp.path, "harness.ts")
yield* Effect.promise(() =>
fs.symlink(path.join(import.meta.dir, "../../../util/node_modules"), path.join(tmp.path, "node_modules")),
)
yield* Effect.promise(() =>
Bun.write(
harness,
`
import assert from "node:assert/strict"
import { writeFile } from "node:fs/promises"
import { Effect } from "effect"
import { PluginModule } from ${JSON.stringify(path.join(import.meta.dir, "../../src/plugin/module.ts"))}
import { Npm } from ${JSON.stringify(path.join(import.meta.dir, "../../../util/src/npm.ts"))}
const entrypoint = process.argv[2]
const source = (tui) => 'module.exports = { id: "node-revision-plugin", tui: ' + tui + ', effect: () => {} }'
const load = (revision) => Effect.runPromise(PluginModule.load({ type: "add", target: "inspection-plugin@latest", options: {} }).pipe(
Effect.provideService(Npm.Service, {
add: () => Effect.succeed({ directory: "", entrypoint, revision }),
resolve: () => Effect.die("unused resolve"),
check: () => Effect.die("unused check"),
which: () => Effect.die("unused which"),
}),
))
await writeFile(entrypoint, source(false))
const first = await load("1.0.0")
assert.deepEqual({ tui: first.tui, revision: first.revision }, { tui: false, revision: "1.0.0" })
await writeFile(entrypoint, source(true))
const second = await load("2.0.0")
assert.deepEqual({ tui: second.tui, revision: second.revision }, { tui: false, revision: "1.0.0" })
`,
),
)
const result = yield* Effect.promise(() =>
Bun.build({
entrypoints: [harness],
outdir: tmp.path,
naming: "[name].mjs",
target: "node",
conditions: ["node"],
plugins: [
{
name: "externalize-dependencies",
setup(build) {
build.onResolve({ filter: /^[^.#/]/ }, (args) =>
args.path.startsWith("@opencode-ai/") ? undefined : { path: args.path, external: true },
)
},
},
],
}),
)
expect(result.success).toBe(true)
const child = Bun.spawn(["node", path.join(tmp.path, "harness.mjs"), entrypoint], {
cwd: tmp.path,
env: {
PATH: process.env.PATH,
HOME: tmp.path,
XDG_CONFIG_HOME: path.join(tmp.path, "config"),
XDG_DATA_HOME: path.join(tmp.path, "data"),
XDG_CACHE_HOME: path.join(tmp.path, "cache"),
XDG_STATE_HOME: path.join(tmp.path, "state"),
},
stdout: "pipe",
stderr: "pipe",
})
yield* Effect.addFinalizer(() => Effect.sync(() => child.kill()))
expect(
yield* Effect.promise(() =>
Promise.all([child.exited, new Response(child.stdout).text(), new Response(child.stderr).text()]),
),
).toEqual([0, expect.any(String), expect.any(String)])
}),
)
})
pluginIt.live("keeps loaded revision tied to cached module code rather than refreshed disk metadata", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("plugin-revision-")))
const entrypoint = path.join(tmp.path, "plugin.ts")
const source = (tui: boolean) =>
`import { Effect } from ${JSON.stringify(import.meta.resolve("effect"))}\nexport default { id: "revision-plugin", tui: ${tui}, effect: () => Effect.void }\n`
yield* Effect.promise(() => Bun.write(entrypoint, source(false)))
const npm = yield* Npm.Service
const plugins = yield* Plugin.Service
const operation = { type: "add" as const, target: "inspection-plugin@latest", options: {} }
const load = (revision: string, options: Record<string, unknown>) =>
PluginModule.load({ ...operation, options }).pipe(
Effect.provideService(Npm.Service, {
...npm,
add: () => Effect.succeed({ directory: tmp.path, entrypoint, revision }),
}),
)
const first = yield* load("1.0.0", {})
yield* plugins.activate([first])
expect(first.version).toBe(JSON.stringify(operation))
expect(first.source).toEqual({ type: "package", package: operation.target })
expect((yield* plugins.list())[0]?.revision).toBe("1.0.0")
yield* Effect.promise(() => Bun.write(entrypoint, source(true)))
const second = yield* load("2.0.0", { changed: true })
expect(second.tui).toBe(false)
expect(second.revision).toBe("1.0.0")
yield* plugins.activate([second])
expect((yield* plugins.list())[0]?.revision).toBe("1.0.0")
yield* plugins.activate([{ ...second, version: "failed", effect: () => Effect.die("setup failed") }])
expect((yield* plugins.list())[0]).toMatchObject({ status: "failed", revision: "1.0.0" })
expect(Plugin.PackageStatus).toBe(PackageStatus)
}),
)
pluginIt.effect("rejects in-process checks in Effect and Promise setup without waiting for activation", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
yield* plugins.activate([
{
id: "effect-check",
version: "1",
effect: (host) =>
host.plugin.check({ target: "inspection-plugin@latest" }).pipe(
Effect.flip,
Effect.tap((error) =>
Effect.sync(() =>
expect(error).toMatchObject({
_tag: "PluginCheckError",
message: expect.stringContaining("external client"),
}),
),
),
Effect.orDie,
Effect.asVoid,
),
},
{
...PluginPromise.fromPromise({
id: "promise-check",
setup: async (host) => {
await expect(host.plugin.check({ target: "inspection-plugin@latest" })).rejects.toThrow("external client")
},
}),
version: "1",
},
])
expect((yield* plugins.list()).map((plugin) => plugin.status)).toEqual(["active", "active"])
}),
)
it.live("checks configured package sources without installing, writing config, or updating plugins", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("plugin-check-")))
const target = "inspection-plugin@latest"
const unsupported = "https://example.com/plugin.tgz"
const config = JSON.stringify({ plugins: ["-*", target, unsupported] })
const configPath = path.join(tmp.path, "opencode.json")
yield* Effect.promise(() => Bun.write(configPath, config))
let adds = 0
let checks = 0
let fail = false
const context = yield* Layer.build(
AppNodeBuilder.build(LayerNode.group([LocationServiceMap.node, Bus.node]), [
[Global.node, tempGlobalLayer],
[
Npm.node,
Layer.succeed(Npm.Service, {
add: () => {
adds++
return Effect.fail(new Npm.InstallFailedError({ dir: tmp.path }))
},
resolve: () => Effect.succeed({ directory: tmp.path }),
which: () => Effect.undefined,
check: () => {
checks++
if (fail)
return Effect.fail(
new Npm.InstallFailedError({ dir: tmp.path, cause: new Error("registry unavailable") }),
)
return Effect.succeed({ available: "2.0.0", mutable: true })
},
}),
],
]),
)
const locations = yield* LocationServiceMap.Service.pipe(Effect.provide(context))
const bus = yield* Bus.Service.pipe(Effect.provide(context))
yield* Effect.gen(function* () {
const supervisor = yield* PluginSupervisor.Service
const plugins = yield* Plugin.Service
yield* supervisor.flush
const inventory = yield* plugins.list()
const initialAdds = adds
let updates = 0
const unsubscribe = yield* bus.listen((event) =>
Effect.sync(() => {
if (event.type === Plugin.Event.Updated.type) updates++
}),
)
yield* Effect.addFinalizer(() => unsubscribe)
expect(yield* supervisor.check(target)).toEqual({ available: "2.0.0", mutable: true })
expect(yield* supervisor.check("not-configured@latest").pipe(Effect.flip)).toMatchObject({
_tag: "PluginCheckError",
message: expect.stringContaining("server inventory"),
})
expect(yield* supervisor.check(unsupported).pipe(Effect.flip)).toMatchObject({
_tag: "PluginCheckError",
message: `Unsupported plugin package source: ${unsupported}`,
})
expect(checks).toBe(1)
fail = true
expect(yield* supervisor.check(target).pipe(Effect.flip)).toMatchObject({
_tag: "PluginCheckError",
message: expect.stringContaining("registry unavailable"),
})
expect(adds).toBe(initialAdds)
expect(yield* plugins.list()).toEqual(inventory)
expect(updates).toBe(0)
expect(yield* Effect.promise(() => Bun.file(configPath).text())).toBe(config)
}).pipe(Effect.provide(locations.get(Location.Ref.make({ directory: AbsolutePath.make(tmp.path) }))))
}),
)
+1
View File
@@ -38,6 +38,7 @@ const npmLayer = Layer.succeed(
add: () => Effect.succeed({ directory: "", entrypoint: undefined }),
resolve: () => Effect.succeed({ directory: "", entrypoint: undefined }),
which: () => Effect.undefined,
check: () => Effect.succeed({ mutable: false }),
}),
)
+1
View File
@@ -103,6 +103,7 @@ export function host(overrides: Overrides = {}): Plugin.Context {
},
plugin: overrides.plugin ?? {
list: () => Effect.die("unused plugin.list"),
check: () => Effect.die("unused plugin.check"),
},
reference: overrides.reference ?? {
list: () => Effect.die("unused reference.list"),
@@ -25,6 +25,7 @@ function npmEntrypoint(entrypoint?: string) {
add: () => Effect.succeed({ directory: "", entrypoint }),
resolve: () => Effect.succeed({ directory: "", entrypoint }),
which: () => Effect.undefined,
check: () => Effect.succeed({ mutable: false }),
})
}
@@ -17,6 +17,7 @@ const npm = Npm.Service.of({
add: () => Effect.succeed({ directory: "", entrypoint: undefined }),
resolve: () => Effect.succeed({ directory: "", entrypoint: undefined }),
which: () => Effect.undefined,
check: () => Effect.succeed({ mutable: false }),
})
const addPlugin = Effect.fn(function* () {
+4 -1
View File
@@ -83,7 +83,10 @@ const locations = Layer.effect(
}),
Layer.succeed(
PluginSupervisor.Service,
PluginSupervisor.Service.of({ flush: Effect.sync(() => (ready = true)) }),
PluginSupervisor.Service.of({
flush: Effect.sync(() => (ready = true)),
check: () => Effect.succeed({ mutable: false }),
}),
),
)
}),
@@ -85,7 +85,10 @@ const referenceInstructions = Layer.mock(ReferenceInstructions.Service, {
})
const mcpInstructions = Layer.mock(McpInstructions.Service, { load: () => Effect.succeed(Instructions.empty) })
const config = Config.testLayer()
const pluginSupervisor = Layer.succeed(PluginSupervisor.Service, PluginSupervisor.Service.of({ flush: Effect.void }))
const pluginSupervisor = Layer.succeed(
PluginSupervisor.Service,
PluginSupervisor.Service.of({ flush: Effect.void, check: () => Effect.succeed({ mutable: false }) }),
)
const promptCatalog = Layer.mock(Catalog.Service, {
provider: {
get: () => Effect.undefined,
@@ -390,6 +390,7 @@ const layer = Layer.unwrap(
PluginSupervisor.Service,
PluginSupervisor.Service.of({
flush: Effect.suspend(() => state.pluginFlushHook),
check: () => Effect.succeed({ mutable: false }),
}),
)
const promptCatalog = Layer.mock(Catalog.Service, {
+1 -1
View File
@@ -38,7 +38,7 @@ const skills = Layer.mergeAll(
get: (id) => Effect.succeed(id === info.id ? info : undefined),
list: () => Effect.succeed([info]),
}),
Layer.succeed(PluginSupervisor.Service, { flush: Effect.void }),
Layer.succeed(PluginSupervisor.Service, { flush: Effect.void, check: () => Effect.succeed({ mutable: false }) }),
)
const locations = Layer.effect(
LocationServiceMap.Service,
+3 -1
View File
@@ -127,7 +127,9 @@ const shellPluginSupervisor = makeLocationNode({
service: PluginSupervisor.Service,
layer: Layer.effect(
PluginSupervisor.Service,
registerToolPlugin(ShellTool.Plugin).pipe(Effect.as(PluginSupervisor.Service.of({ flush: Effect.void }))),
registerToolPlugin(ShellTool.Plugin).pipe(
Effect.as(PluginSupervisor.Service.of({ flush: Effect.void, check: () => Effect.succeed({ mutable: false }) })),
),
),
deps: [
Config.node,
+3 -1
View File
@@ -100,7 +100,9 @@ const subagentPluginSupervisor = makeLocationNode({
service: PluginSupervisor.Service,
layer: Layer.effect(
PluginSupervisor.Service,
registerToolPlugin(SubagentTool.Plugin).pipe(Effect.as(PluginSupervisor.Service.of({ flush: Effect.void }))),
registerToolPlugin(SubagentTool.Plugin).pipe(
Effect.as(PluginSupervisor.Service.of({ flush: Effect.void, check: () => Effect.succeed({ mutable: false }) })),
),
),
deps: [Agent.node, Config.node, Permission.node, PluginRuntime.node, Tool.node],
})
+1
View File
@@ -289,6 +289,7 @@ export function fromPromise(plugin: Plugin) {
},
plugin: {
list: adaptApiMethod(PluginEndpoints["plugin.list"], host.plugin.list),
check: adaptApiMethod(PluginEndpoints["plugin.check"], host.plugin.check),
},
reference: {
list: adaptApiMethod(ReferenceEndpoints["reference.list"], host.reference.list),
+153
View File
@@ -486,6 +486,123 @@
"summary": "List plugins"
}
},
"/api/plugin/check": {
"post": {
"tags": ["plugin"],
"operationId": "v2.plugin.check",
"parameters": [
{
"name": "location",
"in": "query",
"schema": {
"anyOf": [
{
"type": "object",
"properties": {
"directory": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"workspace": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
}
},
"additionalProperties": false
},
{
"type": "null"
}
]
},
"required": false,
"style": "deepObject",
"explode": true
}
],
"security": [],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"location": {
"$ref": "#/components/schemas/Location.InfoEncoded"
},
"data": {
"$ref": "#/components/schemas/Plugin.PackageStatus"
}
},
"required": ["location", "data"],
"additionalProperties": false
}
}
}
},
"400": {
"description": "PluginCheckError | InvalidRequestError",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/PluginCheckErrorEncoded"
},
{
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
]
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
}
}
}
},
"description": "Inspect installed and available revisions of an active or failed package in the current server inventory without installing it.",
"summary": "Check plugin package",
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"target": {
"type": "string"
}
},
"required": ["target"],
"additionalProperties": false
}
}
},
"required": true
}
}
},
"/api/session": {
"get": {
"tags": ["session"],
@@ -16307,6 +16424,9 @@
"source": {
"$ref": "#/components/schemas/Plugin.Source"
},
"revision": {
"type": "string"
},
"status": {
"type": "string",
"enum": ["active"]
@@ -16327,6 +16447,9 @@
"source": {
"$ref": "#/components/schemas/Plugin.Source"
},
"revision": {
"type": "string"
},
"status": {
"type": "string",
"enum": ["failed"]
@@ -16343,6 +16466,22 @@
}
]
},
"Plugin.PackageStatus": {
"type": "object",
"properties": {
"installed": {
"type": "string"
},
"available": {
"type": "string"
},
"mutable": {
"type": "boolean"
}
},
"required": ["mutable"],
"additionalProperties": false
},
"Plugin.Source": {
"anyOf": [
{
@@ -16397,6 +16536,20 @@
}
]
},
"PluginCheckErrorEncoded": {
"type": "object",
"properties": {
"_tag": {
"type": "string",
"enum": ["PluginCheckError"]
},
"message": {
"type": "string"
}
},
"required": ["_tag", "message"],
"additionalProperties": false
},
"Project": {
"type": "object",
"properties": {
+17
View File
@@ -19,6 +19,23 @@ export const PluginGroup = HttpApiGroup.make("server.plugin")
}),
),
)
.add(
HttpApiEndpoint.post("plugin.check", "/api/plugin/check", {
query: LocationQuery,
payload: Schema.Struct({ target: Schema.String }),
success: Location.response(Plugin.PackageStatus),
error: Plugin.CheckError.annotate({ httpApiStatus: 400 }),
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.plugin.check",
summary: "Check plugin package",
description:
"Inspect installed and available revisions of an active or failed package in the current server inventory without installing it.",
}),
),
)
.annotateMerge(
OpenApi.annotations({
title: "plugin",
+15
View File
@@ -19,12 +19,14 @@ export const Info = Schema.Union([
Schema.Struct({
id: ID,
source: Source,
revision: optional(Schema.String),
status: Schema.Literal("active"),
tui: Schema.Boolean,
}),
Schema.Struct({
id: ID.pipe(optional),
source: Source,
revision: optional(Schema.String),
status: Schema.Literal("failed"),
error: Schema.String,
tui: Schema.Boolean,
@@ -32,6 +34,19 @@ export const Info = Schema.Union([
]).annotate({ identifier: "Plugin.Info" })
export type Info = typeof Info.Type
export const PackageStatus = Schema.Struct({
installed: optional(Schema.String),
available: optional(Schema.String),
mutable: Schema.Boolean,
}).annotate({ identifier: "Plugin.PackageStatus" })
export interface PackageStatus extends Schema.Schema.Type<typeof PackageStatus> {}
export class CheckError extends Schema.TaggedError<CheckError>()(
"PluginCheckError",
{ message: Schema.String },
{ identifier: "PluginCheckError" },
) {}
const Added = ephemeral({
type: "plugin.added",
schema: { id: ID },
+32
View File
@@ -0,0 +1,32 @@
import { expect, test } from "bun:test"
import { Schema } from "effect"
import { Plugin } from "../src/plugin.js"
test("plugin inspection omits absent revisions", () => {
expect(
Schema.encodeSync(Plugin.PackageStatus)({ installed: undefined, available: undefined, mutable: true }),
).toEqual({ mutable: true })
for (const status of ["active", "failed"] as const) {
const info = {
id: Plugin.ID.make("example"),
source: { type: "package" as const, package: "example@latest" },
status,
error: "setup failed",
tui: false,
revision: undefined,
}
expect(Schema.encodeSync(Plugin.Info)(info)).not.toHaveProperty("revision")
expect(Schema.encodeSync(Plugin.Info)({ ...info, revision: "1.2.3" })).toHaveProperty("revision", "1.2.3")
}
})
test("plugin inspection contracts have stable identifiers and readable errors", () => {
expect(Plugin.PackageStatus.ast.annotations?.identifier).toBe("Plugin.PackageStatus")
expect(Plugin.CheckError.ast.annotations?.identifier).toBe("PluginCheckError")
const error = new Plugin.CheckError({ message: "Plugin package is not configured: example" })
expect(error.message).toBe("Plugin package is not configured: example")
expect(Schema.encodeSync(Plugin.CheckError)(error)).toEqual({
_tag: "PluginCheckError",
message: error.message,
})
})
+1 -1
View File
@@ -47,7 +47,7 @@ for (const selection of ["explicit", "default"] as const) {
PluginSupervisor.Service,
Effect.gen(function* () {
const plugins = yield* PluginSupervisor.Service
return { flush: release.open.pipe(Effect.andThen(plugins.flush)) }
return { flush: release.open.pipe(Effect.andThen(plugins.flush)), check: plugins.check }
}),
).pipe(Layer.provide(PluginSupervisor.layer))
const opencode = yield* fixture.sdk.OpenCode.create(
+10 -5
View File
@@ -1,13 +1,18 @@
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor-service"
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { Api } from "../api"
import { response } from "../location"
export const PluginHandler = HttpApiBuilder.group(Api, "server.plugin", (handlers) =>
handlers.handle("plugin.list", () =>
Effect.gen(function* () {
return yield* response(Plugin.Service.use((plugin) => plugin.list()))
}),
),
handlers
.handle("plugin.list", () =>
Effect.gen(function* () {
return yield* response(Plugin.Service.use((plugin) => plugin.list()))
}),
)
.handle("plugin.check", ({ payload }) =>
response(PluginSupervisor.Service.use((supervisor) => supervisor.check(payload.target))),
),
)
@@ -50,6 +50,7 @@ test("plugin readiness stays lazy and resolves the supervisor for every executio
() => new ServiceUnavailableError({ message: "initialization timed out", service: "test" }),
)
const layer = Layer.succeed(PluginSupervisor.Service, {
check: () => Effect.succeed({ mutable: false }),
flush: Effect.sync(() => {
flushes++
}),
+76
View File
@@ -0,0 +1,76 @@
import fs from "node:fs/promises"
import path from "node:path"
import { expect } from "bun:test"
import { Effect, Layer } from "effect"
import { Npm } from "@opencode-ai/util/npm"
import { tmpdir } from "../../core/test/fixture/tmpdir"
import { it } from "../../core/test/lib/effect"
import { ServerFetch } from "../src/fetch"
it.live("plugin check honors Location query, omits unknown revisions, and returns readable 400 errors", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("plugin-check-http-")))
const global = path.join(tmp.path, "global")
const project = path.join(tmp.path, "project")
yield* Effect.promise(() => Promise.all([fs.mkdir(global), fs.mkdir(project)]))
const target = "inspection-plugin@latest"
const config = JSON.stringify({ plugins: ["-*", target] })
yield* Effect.promise(() => Bun.write(path.join(project, "opencode.json"), config))
let fail = false
let checks = 0
const handler = yield* ServerFetch.make(
{ database: { path: ":memory:" }, config: { directory: global }, fs: { filewatcher: false } },
{
overrides: [
[
Npm.node,
Layer.succeed(Npm.Service, {
add: () => Effect.fail(new Npm.InstallFailedError({ dir: tmp.path })),
resolve: () => Effect.succeed({ directory: tmp.path }),
which: () => Effect.undefined,
check: () => {
checks++
if (fail)
return Effect.fail(
new Npm.InstallFailedError({ dir: tmp.path, cause: new Error("registry unavailable") }),
)
return Effect.succeed({ installed: undefined, available: "2.0.0", mutable: true })
},
}),
],
],
},
)
const request = (directory: string, target: string) =>
Effect.promise(async () => {
const url = new URL("http://opencode.local/api/plugin/check")
url.searchParams.set("location[directory]", directory)
const response = await handler(
new Request(url, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ target }),
}),
)
const body: unknown = await response.json()
return { status: response.status, body }
})
const result = yield* request(project, target)
expect(result).toMatchObject({
status: 200,
body: { location: { directory: project }, data: { available: "2.0.0", mutable: true } },
})
expect(result).not.toHaveProperty("body.data.installed")
expect(yield* request(global, target)).toMatchObject({
status: 400,
body: { _tag: "PluginCheckError", message: expect.stringContaining("server inventory") },
})
expect(checks).toBe(1)
fail = true
expect(yield* request(project, target)).toMatchObject({
status: 400,
body: { _tag: "PluginCheckError", message: expect.stringContaining("registry unavailable") },
})
expect(yield* Effect.promise(() => Bun.file(path.join(project, "opencode.json")).text())).toBe(config)
}),
)
+1
View File
@@ -269,6 +269,7 @@ export const Definitions = {
"prompt.autocomplete.complete": keybind("tab", "Complete autocomplete item"),
"permission.prompt.fullscreen": keybind("ctrl+f", "Toggle permission prompt fullscreen"),
"plugins.toggle": keybind("space", "Toggle plugin"),
"plugins.check": keybind("ctrl+r", "Check for plugin updates"),
"dialog.mcp.toggle": keybind("space", "Toggle MCP server"),
"dialog.plugins.install": keybind("shift+i", "Install plugin from plugin dialog"),
@@ -1,10 +1,15 @@
import type { PluginInfo } from "@opencode-ai/client"
import { Plugin } from "@opencode-ai/plugin/tui"
import { createEffect, createMemo, createResource, createSignal, onMount, Show } from "solid-js"
import type { PackageStatus } from "@opencode-ai/schema/plugin"
import type { ScrollBoxRenderable } from "@opentui/core"
import { useTerminalDimensions } from "@opentui/solid"
import { createEffect, createMemo, createResource, createSignal, onCleanup, onMount, Show } from "solid-js"
import { DialogErrorDetails } from "../../component/dialog-error-details"
import { usePlugin } from "../../plugin/context"
import { DialogSelect, type DialogSelectOption } from "../../ui/dialog-select"
import { localSource } from "../../plugin/discovery"
import { DialogSelect, type DialogSelectOption, type DialogSelectRef } from "../../ui/dialog-select"
import { useDialog } from "../../ui/dialog"
import { errorMessage } from "../../util/error"
const id = "opencode.plugins"
@@ -17,6 +22,8 @@ type Entry =
readonly target: string
readonly status: "active" | "inactive" | "failed"
readonly error?: string
readonly revision?: string
readonly builtin?: boolean
}
export function PluginsDialog(props: {
@@ -25,18 +32,30 @@ export function PluginsDialog(props: {
server?: () => readonly PluginInfo[]
}) {
const dialog = useDialog()
const dimensions = useTerminalDimensions()
const [locked, setLocked] = createSignal(false)
const [focused, setFocused] = createSignal<string>()
const [detail, setDetail] = createSignal<Entry>()
const [busy, setBusy] = createSignal<"check" | "update">()
const [list, setList] = createSignal<DialogSelectRef<string>>()
const [filter, setFilter] = createSignal("")
const [detail, setDetail] = createSignal<string>()
const [errorDetail, setErrorDetail] = createSignal(false)
const [errors, setErrors] = createSignal<
Record<string, { operation: "Check" | "Update"; message: string } | undefined>
>({})
const [checks, setChecks] = props.context.storage.memory("checks", {
initial: { packages: {} as Record<string, PackageStatus> },
})
const [initial, setInitial] = createSignal<string>()
const [server] = createResource(
() => (props.server ? undefined : (props.context.location ?? props.context.data.location.default())),
const [server, { refetch }] = createResource(
() => (props.server ? undefined : props.context.data.location.default()),
(location) => props.context.client.plugin.list({ location }).then((result) => result.data),
)
onMount(() => dialog.setSize("medium"))
onCleanup(props.context.data.on("plugin.updated", () => void refetch()))
onCleanup(props.context.data.on("server.connected", () => void refetch()))
const entries = createMemo<Entry[]>(() => {
const builtins: Entry[] = props.plugins
.registered()
const registered = props.plugins.registered()
const builtins: Entry[] = registered
.filter((plugin) => plugin.id !== id && plugin.source === "builtin")
.map((plugin) => ({
key: `tui:${plugin.id}`,
@@ -44,6 +63,7 @@ export function PluginsDialog(props: {
id: plugin.id,
target: plugin.id,
status: plugin.active ? ("active" as const) : ("inactive" as const),
builtin: true,
}))
const external: Entry[] = props.plugins
.list()
@@ -55,6 +75,7 @@ export function PluginsDialog(props: {
target: plugin.target,
status: plugin.status,
error: plugin.status === "failed" ? plugin.error : undefined,
revision: registered.find((item) => item.id === plugin.id)?.revision,
}))
const serverEntries: Entry[] = (props.server?.() ?? server() ?? []).map((plugin) => ({
key: `server:${plugin.id ?? source(plugin, props.context)}`,
@@ -71,9 +92,97 @@ export function PluginsDialog(props: {
const first = entries().find((entry) => entry.runtime === "tui")
if (!first) return
setInitial(first.key)
setFocused(first.key)
})
const owner = (entry: Entry | undefined) => {
if (!entry) return undefined
if (entry.runtime === "server")
return entry.plugin.source.type === "package"
? { runtime: "server" as const, target: entry.plugin.source.package }
: undefined
if (entry.builtin || localSource(entry.target, ".")) return undefined
const companion = (props.server?.() ?? server() ?? []).some(
(plugin) => plugin.source.type === "package" && plugin.source.package === entry.target,
)
return { runtime: companion ? "server" : "tui", target: entry.target }
}
const checkKey = (entry: Entry | undefined) => {
const target = owner(entry)
return JSON.stringify([
target?.runtime,
target?.runtime === "server" ? props.context.data.location.default() : undefined,
target?.target,
])
}
const checked = (entry: Entry | undefined): PackageStatus | undefined => checks.packages[checkKey(entry)]
const revision = (entry: Entry) => (entry.runtime === "server" ? entry.plugin.revision : entry.revision)
const available = (entry: Entry) => {
const value = checked(entry)
return value?.mutable && value.available && value.available !== (revision(entry) ?? value.installed)
}
const canUpdate = (entry: Entry | undefined) =>
entry?.runtime === "tui" &&
owner(entry)?.runtime === "tui" &&
checked(entry)?.mutable === true &&
props.plugins.canUpdate(entry.target)
const entryError = (entry: Entry | undefined) => {
const load = pluginError(entry)
const operation = errors()[checkKey(entry)]
const error = operation ? `${operation.operation} error:\n${operation.message}` : undefined
return load && error ? `Load error:\n${load}\n\n${error}` : (error ?? load)
}
const check = (entry: Entry | undefined) => {
const target = owner(entry)
if (locked() || !entry || !target) return
const key = checkKey(entry)
setLocked(true)
setBusy("check")
setErrors((items) => ({ ...items, [key]: undefined }))
const task =
target.runtime === "server"
? props.context.client.plugin
.check({ target: target.target, location: props.context.data.location.default() })
.then((result) => result.data)
: props.plugins.check(target.target)
void task
.then((result) =>
setChecks((draft) => {
draft.packages[key] = result
}),
)
.catch((cause) => {
setErrors((items) => ({ ...items, [key]: { operation: "Check", message: errorMessage(cause) } }))
props.context.ui.toast.show({ variant: "error", message: "Could not check plugin updates; view details." })
})
.finally(() => {
setBusy()
setLocked(false)
})
}
const update = (entry: Entry) => {
if (locked() || entry.runtime !== "tui" || !canUpdate(entry)) return
const key = checkKey(entry)
setLocked(true)
setBusy("update")
setErrors((items) => ({ ...items, [key]: undefined }))
void props.plugins
.update(entry.target)
.then((result) => {
setChecks((draft) => {
draft.packages[key] = result
})
props.context.ui.toast.show({ variant: "success", message: "Updated and applied in this terminal." })
})
.catch((cause) => {
setErrors((items) => ({ ...items, [key]: { operation: "Update", message: errorMessage(cause) } }))
props.context.ui.toast.show({ variant: "error", message: "Could not update and apply plugin; view details." })
})
.finally(() => {
setBusy()
setLocked(false)
})
}
const options = createMemo(() =>
entries().map(
(entry): DialogSelectOption<string> => ({
@@ -81,11 +190,18 @@ export function PluginsDialog(props: {
value: entry.key,
category: entry.runtime === "tui" ? "TUI" : "Server",
searchText: entry.runtime === "tui" ? entry.target : source(entry.plugin, props.context),
footer: status(entry) === "active" ? undefined : status(entry),
footerColor:
status(entry) === "failed"
? props.context.theme.text.feedback.error.default
: props.context.theme.text.subdued,
footer: entryError(entry)
? "failed"
: available(entry)
? "↑ update"
: checked(entry)?.mutable === false
? "pinned"
: status(entry) === "active"
? undefined
: status(entry),
footerColor: entryError(entry)
? props.context.theme.text.feedback.error.default
: props.context.theme.text.subdued,
gutter:
status(entry) === "active"
? () => <text fg={props.context.theme.text.feedback.success.default}></text>
@@ -95,10 +211,10 @@ export function PluginsDialog(props: {
}),
),
)
const focusedEntry = createMemo(() => entries().find((entry) => entry.key === focused()))
const focusedEntry = createMemo(() => entries().find((entry) => entry.key === list()?.selected?.value))
const focusedTui = createMemo(() => {
const entry = focusedEntry()
if (entry?.runtime !== "tui" || !entry.id) return
if (entry?.runtime !== "tui" || !entry.id) return undefined
return entry
})
const toggleTitle = createMemo(() => {
@@ -125,10 +241,18 @@ export function PluginsDialog(props: {
.finally(() => setLocked(false))
}
const detailEntry = createMemo(() => entries().find((entry) => entry.key === detail()))
const back = () => {
setDetail()
setErrorDetail(false)
dialog.setSize("medium")
}
let scroll: ScrollBoxRenderable | undefined
return (
<box>
<Show
when={detail()}
when={detailEntry()}
fallback={
<DialogSelect
title="Plugins"
@@ -136,29 +260,40 @@ export function PluginsDialog(props: {
current={initial()}
locked={locked()}
preserveSelection={true}
onMove={(option) => setFocused(option.value)}
initialFilter={filter()}
onFilter={setFilter}
ref={setList}
onSelect={(option) => {
const entry = entries().find((entry) => entry.key === option.value)
if (pluginError(entry)) setDetail(entry)
setInitial(option.value)
setDetail(option.value)
}}
actions={
focusedTui()
actions={[
...(focusedTui() && !busy()
? [
{
title: toggleTitle(),
command: "plugins.toggle",
onTrigger: (option) => toggle(entries().find((entry) => entry.key === option.value)),
onTrigger: (option: DialogSelectOption<string>) =>
toggle(entries().find((entry) => entry.key === option.value)),
},
]
: []
}
: []),
...(owner(focusedEntry()) && !busy()
? [
{
title: "check",
command: "plugins.check",
onTrigger: (option: DialogSelectOption<string>) =>
check(entries().find((entry) => entry.key === option.value)),
},
]
: []),
]}
footerHints={!busy() && dimensions().width >= 60 ? [{ title: "enter", label: "details" }] : []}
footer={
<Show when={pluginError(focusedEntry())}>
<text>
<span style={{ fg: props.context.theme.text.default }}>
<b>enter</b>
</span>
<span style={{ fg: props.context.theme.text.subdued }}> view error</span>
<Show when={busy()}>
<text fg={props.context.theme.text.subdued}>
{busy() === "update" ? "Updating & applying..." : "Checking..."}
</text>
</Show>
}
@@ -166,15 +301,112 @@ export function PluginsDialog(props: {
}
>
{(entry) => (
<DialogErrorDetails
title={`${entry().runtime === "tui" ? "TUI" : "Server"} plugin: ${label(entry(), props.context)}`}
error={pluginError(entry()) ?? "Unknown plugin error"}
context={`Status: failed\nRuntime: ${entry().runtime}\nSource: ${pluginSource(entry(), props.context)}`}
onBack={() => {
setDetail()
dialog.setSize("medium")
}}
/>
<Show
when={errorDetail()}
fallback={
<DialogSelect
title={label(entry(), props.context)}
renderFilter={false}
locked={locked()}
titleView={
<box flexGrow={1} flexShrink={1} flexBasis={0} minWidth={0} gap={1}>
<text fg={props.context.theme.text.default} truncate>
<b>{label(entry(), props.context)}</b>
</text>
<scrollbox
ref={(value) => {
scroll = value
}}
width="100%"
height={Math.min(
10,
Math.max(
2,
Math.floor((dimensions().height * 3) / 4) -
8 -
(canUpdate(entry()) ? 2 : 0) -
(entryError(entry()) ? 2 : 0),
),
)}
scrollbarOptions={{ visible: false }}
>
<text width="100%" fg={props.context.theme.text.subdued} wrapMode="word">
{[
`Runtime ${entry().runtime === "server" ? "Server" : "This terminal"}`,
`Status ${status(entry())}`,
`Source ${pluginSource(entry(), props.context)}`,
`Scope ${owner(entry())?.runtime === "server" || entry().runtime === "server" ? `Server: ${props.context.ui.format.path(props.context.data.location.default().directory)}` : "This terminal"}`,
`Loaded ${revision(entry()) ?? "Unknown"}`,
`Installed ${owner(entry()) ? (checked(entry()) ? (checked(entry())?.installed ?? "Unknown") : "Not checked") : "Not applicable"}`,
`Available ${owner(entry()) ? (checked(entry()) ? (checked(entry())?.available ?? "Unknown") : "Not checked") : "Not applicable"}${checked(entry())?.mutable === false ? " (pinned)" : ""}`,
owner(entry())
? owner(entry())?.runtime === "server"
? "Inspection only: server plugins and TUI companions do not support hot apply."
: entry().runtime === "tui" &&
props.plugins.canUpdate(pluginSource(entry(), props.context))
? checked(entry())?.mutable === false
? "Select a newer package spec in cli.json, then reopen the TUI."
: "Check for updates, then Update & Apply in this terminal; no server restart."
: "Inspection only: disabled or unloaded plugins do not support hot apply. Enable first or change cli.json."
: pluginSource(entry(), props.context) === "builtin"
? "Updates with OpenCode itself."
: "Local/SDK source; no package check.",
].join("\n")}
</text>
</scrollbox>
</box>
}
options={[
...(owner(entry()) ? [{ title: "Check for updates", value: "check" }] : []),
...(canUpdate(entry()) ? [{ title: "Update & Apply", value: "update" }] : []),
...(entryError(entry()) ? [{ title: "View error details", value: "error" }] : []),
{ title: "Back to plugins", value: "back" },
]}
onSelect={(option) => {
if (option.value === "check") return check(entry())
if (option.value === "update") return update(entry())
if (option.value === "error") return setErrorDetail(true)
back()
}}
bindings={[
{ bind: "escape", title: "Back", group: "Dialog", run: back },
{ bind: "pageup", title: "Scroll details up", group: "Dialog", run: () => scroll?.scrollBy(-5) },
{ bind: "pagedown", title: "Scroll details down", group: "Dialog", run: () => scroll?.scrollBy(5) },
...(owner(entry())
? [{ id: "plugins.check", title: "Check for updates", group: "Dialog", run: () => check(entry()) }]
: []),
...(entry().runtime === "tui"
? [{ id: "plugins.toggle", title: "Toggle plugin", group: "Dialog", run: () => toggle(entry()) }]
: []),
]}
footer={
<text fg={props.context.theme.text.subdued}>
{busy()
? busy() === "update"
? "Updating & applying..."
: "Checking..."
: entryError(entry())
? "Plugin operation failed; view error."
: available(entry())
? "↑ New revision available."
: checked(entry())?.mutable === false
? "Pinned source."
: "PgUp/PgDn scroll details"}
</text>
}
/>
}
>
<DialogErrorDetails
title={`${entry().runtime === "tui" ? "TUI" : "Server"} plugin: ${label(entry(), props.context)}`}
error={entryError(entry()) ?? "Unknown plugin error"}
context={`Status: ${status(entry())}\nRuntime: ${entry().runtime}\nSource: ${pluginSource(entry(), props.context)}`}
onBack={() => {
setErrorDetail(false)
dialog.setSize("medium")
}}
/>
</Show>
)}
</Show>
</box>
@@ -187,7 +419,7 @@ function label(entry: Entry, context: Plugin.Context) {
}
function pluginSource(entry: Entry, context: Plugin.Context) {
if (entry.runtime === "tui") return entry.target
if (entry.runtime === "tui") return entry.builtin ? "builtin" : entry.target
return source(entry.plugin, context)
}
+219 -83
View File
@@ -1,4 +1,5 @@
import type { PluginInfo } from "@opencode-ai/client"
import type { PackageStatus } from "@opencode-ai/schema/plugin"
import type { Plugin } from "@opencode-ai/plugin/tui"
import { createMarkdownCodeBlockRenderer, type MarkdownCodeBlockRenderer, type MarkdownOptions } from "@opentui/core"
import {
@@ -33,7 +34,10 @@ import { createSourceWatcher } from "./watch"
import { discoverTuiPlugins, freshSpecifier, localSource } from "./discovery"
export interface PackageResolver {
readonly resolve: (spec: string, install?: boolean) => Promise<string | undefined>
readonly resolve: (spec: string, install?: boolean) => Promise<{ entrypoint?: string; revision?: string }>
readonly check: (spec: string) => Promise<PackageStatus>
readonly update: (spec: string) => Promise<{ entrypoint?: string; revision?: string }>
readonly commit: (spec: string, entry: { entrypoint?: string; revision?: string }) => Promise<void>
}
type State =
@@ -45,6 +49,7 @@ type RegisteredPlugin = {
readonly id: string
readonly source: "builtin" | "external"
readonly active: boolean
readonly revision?: string
}
type Value = {
@@ -60,6 +65,9 @@ type Value = {
readonly markdown: () => MarkdownOptions["renderNode"]
readonly activate: (id: string) => Promise<boolean>
readonly deactivate: (id: string) => Promise<boolean>
readonly check: (target: string) => Promise<PackageStatus>
readonly canUpdate: (target: string) => boolean
readonly update: (target: string) => Promise<PackageStatus>
}
type Registration = {
@@ -67,6 +75,7 @@ type Registration = {
source: RegisteredPlugin["source"]
target?: string
version: string
revision?: string
options?: Readonly<Record<string, any>>
active: boolean
routes: Record<string, Page>
@@ -76,10 +85,14 @@ type Registration = {
}
// One entry of the desired plugin generation produced by the resolve phase.
type Desired = Pick<Registration, "plugin" | "source" | "target" | "version" | "options"> & { enabled: boolean }
type Desired = Pick<Registration, "plugin" | "source" | "target" | "version" | "revision" | "options"> & {
enabled: boolean
}
const PluginContext = createContext<Value>()
let sourceVersion = Date.now()
// Re-importing the same specifier can return cached code even if its package changed on disk.
const importedRevisions = new WeakMap<object, string | undefined>()
export function combineMarkdownRenderers(
sources: ReadonlyArray<Readonly<Record<string, MarkdownCodeBlockRenderer>>>,
@@ -102,7 +115,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
ReadonlyArray<
Extract<PluginInfo, { readonly status: "active" }> & { readonly source: { readonly type: "package" } }
>
>([])
>([], { equals: isDeepEqual })
const directory = config.path ? path.dirname(config.path) : process.cwd()
const [store, setStore] = createStore({
ready: false,
@@ -236,6 +249,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
// local entrypoints (see watch.ts for the mechanics), debounced into a
// serialized reconcile so bursts of events rebuild the generation once.
let pending: ReturnType<typeof setTimeout> | undefined
let closing = false
const watcher = createSourceWatcher(() => {
clearTimeout(pending)
pending = setTimeout(() => {
@@ -245,6 +259,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
}, 100)
})
const stopWatching = () => {
closing = true
clearTimeout(pending)
watcher.dispose()
}
@@ -259,84 +274,100 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
// Package resolution failures would otherwise retry a full npm install on
// every watch event; remember them until the configuration changes.
const npmFailures = new Map<string, string>()
const reconcile = async () => {
await Promise.all(props.directories.map(watcher.wait))
const entries = [
...(await discoverTuiPlugins(props.directories)).map((entry) => ({ entry, install: true, server: false })),
...serverPlugins().map((plugin) => ({ entry: plugin.source.package, install: false, server: true })),
...(config.data.plugins ?? []).map((entry) => ({ entry, install: true, server: false })),
]
const reconcile = async (candidate?: Desired) => {
// Explicit package updates retain the current membership, order and manual
// activation mode. Config and source changes still use the full resolve phase.
const desired = new Map<string, Desired>(
candidate ? Object.values(store.registrations).map((item) => [item.plugin.id, toDesired(item)]) : [],
)
const failures: State[] = candidate
? store.states.filter((state) => state.status === "failed" && state.target !== candidate.target)
: []
if (candidate) desired.set(candidate.plugin.id, candidate)
if (!candidate) {
await Promise.all(props.directories.map(watcher.wait))
const entries = [
...(await discoverTuiPlugins(props.directories)).map((entry) => ({ entry, install: true, server: false })),
...serverPlugins().map((plugin) => ({ entry: plugin.source.package, install: false, server: true })),
...(config.data.plugins ?? []).map((entry) => ({ entry, install: true, server: false })),
]
// Resolve: fold entries into one desired generation. A source that fails
// to import keeps its running previous version and only reports failure.
const desired = new Map<string, Desired>()
for (const plugin of builtins)
desired.set(plugin.id, { plugin, source: "builtin", version: "builtin", enabled: true })
const failures: State[] = []
for (const source of entries) {
const entry = source.entry
const target = typeof entry === "string" ? entry : entry.package
if (target.startsWith("-")) {
for (const item of desired.values()) if (matches(target.slice(1), item.plugin.id)) item.enabled = false
continue
}
// Resolve: fold entries into one desired generation. A source that fails
// to import keeps its running previous version and only reports failure.
for (const plugin of builtins)
desired.set(plugin.id, { plugin, source: "builtin", version: "builtin", enabled: true })
for (const source of entries) {
const entry = source.entry
const target = typeof entry === "string" ? entry : entry.package
if (target.startsWith("-")) {
for (const item of desired.values()) if (matches(target.slice(1), item.plugin.id)) item.enabled = false
continue
}
const selected = [...desired.values()].filter((item) => matches(target, item.plugin.id))
if (selected.length || target === "*" || target.endsWith(".*") || target.startsWith("opencode.")) {
for (const item of selected) item.enabled = true
continue
}
const selected = [...desired.values()].filter((item) => matches(target, item.plugin.id))
if (selected.length || target === "*" || target.endsWith(".*") || target.startsWith("opencode.")) {
for (const item of selected) item.enabled = true
continue
}
const options = typeof entry === "string" ? undefined : entry.options
// Watch even when the resolve below fails so fixing a broken plugin reloads it.
const local = localSource(target, directory)
if (local) await watcher.add(fileURLToPath(local))
const previous = Object.values(store.registrations).find((registration) => registration.target === target)
const memo = local ? undefined : npmFailures.get(target)
const resolved = memo
? { status: "failed" as const, error: memo }
: await resolvePlugin(target, local, options, previous, props.packages, source.install, sourceGeneration).catch(
(error) => ({
const options = typeof entry === "string" ? undefined : entry.options
// Watch even when the resolve below fails so fixing a broken plugin reloads it.
const local = localSource(target, directory)
if (local) await watcher.add(fileURLToPath(local))
const previous = Object.values(store.registrations).find((registration) => registration.target === target)
const memo = local ? undefined : npmFailures.get(target)
const resolved = memo
? { status: "failed" as const, error: memo }
: await resolvePlugin(
target,
local,
options,
previous,
props.packages,
source.install,
sourceGeneration,
).catch((error) => ({
status: "failed" as const,
error: errorMessage(error),
}),
)
if (resolved.status === "unsupported") {
if (source.server) continue
failures.push({ target, status: "unsupported" })
continue
}
if (resolved.status === "failed") {
if (!local && !previous) npmFailures.set(target, resolved.error)
failures.push({
}))
if (resolved.status === "unsupported") {
if (source.server) continue
failures.push({ target, status: "unsupported" })
continue
}
if (resolved.status === "failed") {
if (!local && !previous) npmFailures.set(target, resolved.error)
failures.push({
target,
id: previous?.plugin.id,
status: "failed",
error: previous?.active ? `${resolved.error} (previous version still active)` : resolved.error,
})
if (previous) desired.set(previous.plugin.id, toDesired(previous))
continue
}
const setupFailure = setupFailures.get(target)
if (setupFailure && sameGeneration(setupFailure, { version: resolved.version, options }) && previous) {
failures.push({
target,
id: previous.plugin.id,
status: "failed",
error: previous.active ? `${setupFailure.error} (previous version still active)` : setupFailure.error,
})
desired.set(previous.plugin.id, toDesired(previous))
continue
}
setupFailures.delete(target)
desired.set(resolved.plugin.id, {
plugin: resolved.plugin,
source: "external",
target,
id: previous?.plugin.id,
status: "failed",
error: previous?.active ? `${resolved.error} (previous version still active)` : resolved.error,
version: resolved.version,
revision: resolved.revision,
options,
enabled: true,
})
if (previous) desired.set(previous.plugin.id, toDesired(previous))
continue
}
const setupFailure = setupFailures.get(target)
if (setupFailure && sameGeneration(setupFailure, { version: resolved.version, options }) && previous) {
failures.push({
target,
id: previous.plugin.id,
status: "failed",
error: previous.active ? `${setupFailure.error} (previous version still active)` : setupFailure.error,
})
desired.set(previous.plugin.id, toDesired(previous))
continue
}
setupFailures.delete(target)
desired.set(resolved.plugin.id, {
plugin: resolved.plugin,
source: "external",
target,
version: resolved.version,
options,
enabled: true,
})
}
// Compare: unchanged plugins are never touched, and a fully unchanged
@@ -358,9 +389,8 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
: desiredIds.filter((id) => {
const registration = store.registrations[id]!
const item = desired.get(id)!
// enabled derives from config directives alone, so config wins over
// manual dialog toggles on every reconcile — the same semantics
// config saves had before hot reload existed, just more frequent.
// Full reconciles let config directives win over manual toggles;
// a targeted update instead copies each registration's active mode.
return !sameGeneration(registration, item) || registration.active !== item.enabled
})
@@ -400,6 +430,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
const failedTargets = new Set(failures.map((failure) => failure.target))
const states: State[] = [
...(candidate ? store.states.filter((state) => state.status === "unsupported") : []),
...[...desired.values()].flatMap((item): State[] => {
if (item.target === undefined) return []
// A failed reload keeps this item running; the failure entry covers it.
@@ -427,6 +458,84 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
})
setStore("states", reconcileStore(states))
}
const canUpdate = (target: string) => {
const state = store.states.find((state) => state.target === target)
const registration = state && "id" in state && state.id ? store.registrations[state.id] : undefined
return Boolean(
!closing &&
!host.renderer.isDestroyed &&
registration?.active &&
registration.source === "external" &&
registration.target === target &&
!localSource(target, directory) &&
(config.data.plugins ?? []).some((entry) => (typeof entry === "string" ? entry : entry.package) === target) &&
!serverPlugins().some((plugin) => plugin.source.package === target),
)
}
const update = async (target: string) => {
if (!canUpdate(target)) throw new Error(`Not an active CLI-only package plugin: ${target}`)
const requireCurrent = () => {
if (!canUpdate(target))
throw new Error(
`Plugin update aborted: ${target} is no longer eligible in this terminal; cache selection was not saved.`,
)
}
// The companion inventory above deliberately filters to active TUI exports.
// Ownership must include failed and server-only packages, and fail closed.
const inventory = await client.api.plugin.list({ location: data.location.default() })
if (inventory.data.some((plugin) => plugin.source.type === "package" && plugin.source.package === target))
throw new Error("Server plugins and TUI companions support inspection only; hot apply is not supported.")
requireCurrent()
const previous = Object.values(store.registrations).find((item) => item.target === target)!
const entry = await props.packages.update(target)
requireCurrent()
const resolved = await resolvePlugin(
target,
undefined,
previous.options,
previous,
props.packages,
true,
sourceGeneration,
entry,
)
.then((resolved) => {
if (resolved.status === "unsupported") throw new Error(`No V2 TUI entrypoint: ${target}`)
if (resolved.plugin.id !== previous.plugin.id)
throw new Error(`Plugin ID changed from ${previous.plugin.id} to ${resolved.plugin.id}; cannot hot apply.`)
if (resolved.version === previous.version)
throw new Error(`Update did not provide a fresh package graph: ${target}`)
return resolved
})
.catch((error) => {
setStore("states", (states) =>
states.map((state) =>
state.target === target
? {
target,
id: previous.plugin.id,
status: "failed",
error: `${errorMessage(error)} (previous version still active)`,
}
: state,
),
)
throw error
})
requireCurrent()
await reconcile({ ...toDesired(previous), ...resolved, enabled: true })
const state = store.states.find((item) => item.target === target)
if (state?.status === "failed") throw new Error(state.error)
requireCurrent()
// Setup succeeded: this registration owns candidate resources even if the
// pointer write fails. Keep it running and report the persistence failure.
await props.packages.commit(target, entry).catch((error) => {
throw new Error(
`New plugin code is active in this terminal, but its cache selection was not saved: ${errorMessage(error)}`,
)
})
return { installed: entry.revision, available: entry.revision, mutable: true }
}
const slotItems = new WeakMap<SlotRender, Claim<SlotRender>>()
// The mounted slot tree: path -> live <Slot> instance count. Reference
// counted because the same path can be mounted several times (one composer
@@ -534,6 +643,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
id,
source: plugin.source,
active: plugin.active,
revision: plugin.revision,
})),
route: (id, name) => store.registrations[id]?.routes[name]?.render,
slots: { register: registerSlot, resolved },
@@ -542,6 +652,15 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
// toggle mid-reload cannot mix registrations across generations.
activate: (id) => enqueue(() => activate(id)),
deactivate: (id) => enqueue(() => deactivate(id)),
canUpdate,
update: (target) => enqueue(() => update(target)),
check: async (target) => {
if (!store.states.some((state) => state.target === target) || localSource(target, directory))
throw new Error(`Not a configured package plugin: ${target}`)
if (serverPlugins().some((plugin) => plugin.source.package === target))
return (await client.api.plugin.check({ target, location: data.location.default() })).data
return props.packages.check(target)
},
}}
>
{props.children}
@@ -576,12 +695,21 @@ async function resolvePlugin(
packages: PackageResolver,
install: boolean,
sourceGeneration: (entrypoint: string) => Promise<number>,
candidate?: Awaited<ReturnType<PackageResolver["update"]>>,
) {
// Package entrypoints never change within a session, so a loaded previous
// version needs no re-resolution (which could otherwise hit npm).
if (!local && previous && sameOptions(previous.options, options))
return { status: "unchanged" as const, plugin: previous.plugin, version: previous.version }
const entrypoint = local ? await resolveLocal(local) : await packages.resolve(spec, install)
// Keep the loaded graph for options changes, including an explicitly applied
// graph whose cache selection could not be saved. Only update replaces it.
if (!local && previous && !candidate)
return {
status: "unchanged" as const,
plugin: previous.plugin,
version: previous.version,
revision: previous.revision,
}
const resolved = local
? { entrypoint: await resolveLocal(local), revision: undefined }
: (candidate ?? (await packages.resolve(spec, install)))
const entrypoint = resolved.entrypoint
if (!entrypoint) return { status: "unsupported" as const }
// Content remains stable across the several mtimes one save may expose to
// filesystem watchers, while the generation keeps reverted modules fresh.
@@ -589,7 +717,7 @@ async function resolvePlugin(
while (true) {
const version = generation === undefined ? entrypoint : freshSpecifier(entrypoint, generation)
if (previous && previous.version === version && sameOptions(previous.options, options))
return { status: "unchanged" as const, plugin: previous.plugin, version }
return { status: "unchanged" as const, plugin: previous.plugin, version, revision: previous.revision }
const mod: { readonly default?: unknown } = await import(version)
if (generation !== undefined) {
const observed = await sourceGeneration(entrypoint)
@@ -601,7 +729,13 @@ async function resolvePlugin(
}
}
if (!isPlugin(mod.default)) throw new Error(`Invalid V2 TUI plugin module: ${spec}`)
return { status: "loaded" as const, plugin: mod.default, version }
if (!local && !importedRevisions.has(mod)) importedRevisions.set(mod, resolved.revision)
return {
status: "loaded" as const,
plugin: mod.default,
version,
revision: local ? undefined : importedRevisions.get(mod),
}
}
}
@@ -611,6 +745,7 @@ function toRegistration(item: Desired): Registration {
source: item.source,
target: item.target,
version: item.version,
revision: item.revision,
options: snapshotOptions(item.options),
active: false,
routes: {},
@@ -626,6 +761,7 @@ function toDesired(item: Registration): Desired {
source: item.source,
target: item.target,
version: item.version,
revision: item.revision,
options: item.options,
enabled: item.active,
}
+26 -16
View File
@@ -17,6 +17,7 @@ export interface DialogSelectProps<T> {
title: string
titleView?: JSX.Element
placeholder?: string
initialFilter?: string
footer?: JSX.Element
emptyView?: JSX.Element
noMatchView?: JSX.Element
@@ -93,6 +94,7 @@ export function dialogSelectContentWidth(dialogWidth: number) {
export type DialogSelectRef<T> = {
filter: string
filtered: DialogSelectOption<T>[]
selected: DialogSelectOption<T> | undefined
moveTo(value: T): void
}
@@ -111,7 +113,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
const [store, setStore] = createStore({
selected: 0,
filter: "",
filter: props.initialFilter ?? "",
input: "keyboard" as "keyboard" | "mouse",
})
const [focusedAction, setFocusedAction] = createSignal<number>()
@@ -305,21 +307,25 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
})
createEffect(
on([() => store.filter, () => props.current], ([filter, current]) => {
if (filter.length > 0) resetSelection = true
if (filter.length > 0) {
const option = flat()[0]
if (!option) return
moveTo(0, true, false)
scrollAfterLayout(true, option.value)
return
}
if (!current || props.focusCurrent === false) return
const currentIndex = flat().findIndex((opt) => isDeepEqual(opt.value, current))
if (currentIndex < 0) return
moveTo(currentIndex, true)
scrollAfterLayout(true, current)
}),
on(
[() => store.filter, () => props.current],
([filter, current]) => {
if (filter.length > 0) resetSelection = true
if (filter.length > 0) {
const option = flat()[0]
if (!option) return
moveTo(0, true, false)
scrollAfterLayout(true, option.value)
return
}
if (!current || props.focusCurrent === false) return
const currentIndex = flat().findIndex((opt) => isDeepEqual(opt.value, current))
if (currentIndex < 0) return
moveTo(currentIndex, true)
scrollAfterLayout(true, current)
},
{ defer: Boolean(props.initialFilter) },
),
)
function move(direction: number) {
@@ -526,6 +532,9 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
get filtered() {
return filtered()
},
get selected() {
return selected()
},
moveTo(value) {
const index = flat().findIndex((option) => isDeepEqual(option.value, value))
if (index >= 0) moveTo(index, true)
@@ -626,6 +635,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
<Show when={props.renderFilter !== false}>
<box paddingTop={1}>
<input
value={props.initialFilter ?? ""}
onInput={(e) => {
if (props.locked) return
batch(() => {
+78 -13
View File
@@ -32,7 +32,12 @@ test("SIGHUP clears title and disposes scoped resources once", async () => {
app: { name: "test", version: "test", channel: "test" },
server: { endpoint: { url: server.url.toString() } },
config: { get: async () => ({}), update: async () => ({}) },
packages: { resolve: async () => undefined },
packages: {
resolve: async () => ({}),
check: async () => ({ mutable: false }),
update: async () => ({}),
commit: async () => {},
},
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }),
args: {},
log: () => {},
@@ -108,7 +113,12 @@ test("session lifecycle updates the terminal title and prints the epilogue after
app: { name: "test", version: "test", channel: "test" },
server: { endpoint: { url: server.url.toString() } },
config: { get: async () => ({}), update: async () => ({}) },
packages: { resolve: async () => undefined },
packages: {
resolve: async () => ({}),
check: async () => ({ mutable: false }),
update: async () => ({}),
commit: async () => {},
},
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }),
args: { sessionID: "dummy" },
log: () => {},
@@ -182,7 +192,12 @@ test("session title generated while an untitled session is loading remains visib
app: { name: "test", version: "test", channel: "test" },
server: { endpoint: { url: server.url.toString() } },
config: { get: async () => ({}), update: async () => ({}) },
packages: { resolve: async () => undefined },
packages: {
resolve: async () => ({}),
check: async () => ({ mutable: false }),
update: async () => ({}),
commit: async () => {},
},
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }),
args: { sessionID: "dummy" },
log: () => {},
@@ -274,7 +289,12 @@ test("automatic rename refreshes the displayed title before settling, even witho
}),
update: async () => ({}),
},
packages: { resolve: async () => undefined },
packages: {
resolve: async () => ({}),
check: async () => ({ mutable: false }),
update: async () => ({}),
commit: async () => {},
},
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }),
args: { sessionID: session.id },
log: () => {},
@@ -354,7 +374,12 @@ test("session startup prompt is submitted exactly once", async () => {
app: { name: "test", version: "test", channel: "test" },
server: { endpoint: { url: server.url.toString() } },
config: { get: async () => ({}), update: async () => ({}) },
packages: { resolve: async () => undefined },
packages: {
resolve: async () => ({}),
check: async () => ({ mutable: false }),
update: async () => ({}),
commit: async () => {},
},
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }),
args: { sessionID: "dummy", prompt: "RESUME_READY" },
log: () => {},
@@ -435,7 +460,12 @@ test.each([false, true])("uses the resolved launch directory for new prompts (fa
get: async () => ({ animations: false, tabs: { enabled: false }, keybinds: { "session.new": "f6" } }),
update: async () => ({}),
},
packages: { resolve: async () => undefined },
packages: {
resolve: async () => ({}),
check: async () => ({ mutable: false }),
update: async () => ({}),
commit: async () => {},
},
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: ready.resolve }),
args: {},
log: () => {},
@@ -509,7 +539,12 @@ test("error investigations repeatedly seed editable home drafts without creating
get: async () => ({ animations: false, keybinds: { "mcp.list": "f6" } }),
update: async () => ({}),
},
packages: { resolve: async () => undefined },
packages: {
resolve: async () => ({}),
check: async () => ({ mutable: false }),
update: async () => ({}),
commit: async () => {},
},
args: {},
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }),
log: () => {},
@@ -590,7 +625,12 @@ test("shows jump to latest after scrolling one line above the final message", as
}),
update: async () => ({}),
},
packages: { resolve: async () => undefined },
packages: {
resolve: async () => ({}),
check: async () => ({ mutable: false }),
update: async () => ({}),
commit: async () => {},
},
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }),
args: { sessionID: "dummy" },
log: () => {},
@@ -678,7 +718,12 @@ test("new session inherits the active session model", async () => {
app: { name: "test", version: "test", channel: "test" },
server: { endpoint: { url: server.url.toString() } },
config: { get: async () => ({ animations: false }), update: async () => ({}) },
packages: { resolve: async () => undefined },
packages: {
resolve: async () => ({}),
check: async () => ({ mutable: false }),
update: async () => ({}),
commit: async () => {},
},
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }),
args: { sessionID: "dummy" },
log: () => {},
@@ -768,7 +813,12 @@ test("keeps the prompt display stable while a new location catalog loads", async
app: { name: "test", version: "test", channel: "test" },
server: { endpoint: { url: server.url.toString() } },
config: { get: async () => ({ animations: false }), update: async () => ({}) },
packages: { resolve: async () => undefined },
packages: {
resolve: async () => ({}),
check: async () => ({ mutable: false }),
update: async () => ({}),
commit: async () => {},
},
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: ready.resolve }),
args: {},
log: () => {},
@@ -849,7 +899,12 @@ test("configured app bindings execute settings and permission commands", async (
}),
update: async () => ({}),
},
packages: { resolve: async () => undefined },
packages: {
resolve: async () => ({}),
check: async () => ({ mutable: false }),
update: async () => ({}),
commit: async () => {},
},
args: {},
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: ready.resolve }),
log: () => {},
@@ -903,7 +958,12 @@ test("ctrl+c dismisses autocomplete and shell mode before exiting", async () =>
app: { name: "test", version: "test", channel: "test" },
server: { endpoint: { url: server.url.toString() } },
config: { get: async () => ({ animations: false }), update: async () => ({}) },
packages: { resolve: async () => undefined },
packages: {
resolve: async () => ({}),
check: async () => ({ mutable: false }),
update: async () => ({}),
commit: async () => {},
},
args: {},
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: ready.resolve }),
log: () => {},
@@ -1015,7 +1075,12 @@ test.each(["manual", "select"] as const)(
}),
update: async () => ({}),
},
packages: { resolve: async () => undefined },
packages: {
resolve: async () => ({}),
check: async () => ({ mutable: false }),
update: async () => ({}),
commit: async () => {},
},
args: { sessionID: session.id },
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: ready.resolve }),
log: () => {},
+12 -2
View File
@@ -59,7 +59,12 @@ test.each([70, 120])(
app: { name: "test", version: "test", channel: "test" },
server: { endpoint: { url: server.url.toString() } },
config: { get: async () => ({ animations: false }), update: async () => ({}) },
packages: { resolve: async () => undefined },
packages: {
resolve: async () => ({}),
check: async () => ({ mutable: false }),
update: async () => ({}),
commit: async () => {},
},
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: ready.resolve }),
args: { sessionID },
log: () => {},
@@ -184,7 +189,12 @@ test.each(["first", "second"])(
app: { name: "test", version: "test", channel: "test" },
server: { endpoint: { url: server.url.toString() } },
config: { get: async () => ({ animations: false }), update: async () => ({}) },
packages: { resolve: async () => undefined },
packages: {
resolve: async () => ({}),
check: async () => ({ mutable: false }),
update: async () => ({}),
commit: async () => {},
},
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: ready.resolve }),
args: { sessionID },
log: () => {},
@@ -0,0 +1,78 @@
import { Plugin } from "@opencode-ai/plugin/tui"
import { onCleanup } from "solid-js"
import { useConfig } from "../../src/config"
import { useLocation } from "../../src/context/location"
import { useRoute } from "../../src/context/route"
import { usePlugin } from "../../src/plugin/context"
export const probe = {
setups: 0,
cleanups: 0,
updateSetups: [] as string[],
updateCleanups: [] as string[],
updateEvents: [] as string[],
updateSetup: Promise.resolve(),
failUpdate: false,
plugins: (): ReturnType<typeof usePlugin> => {
throw new Error("Plugin probe not mounted")
},
config: (): ReturnType<typeof useConfig> => {
throw new Error("Plugin probe not mounted")
},
location: (): ReturnType<typeof useLocation> => {
throw new Error("Plugin probe not mounted")
},
navigate: (_directory: string): void => {
throw new Error("Plugin probe not mounted")
},
}
export function updatePlugin(version: string) {
return Plugin.define({
id: "fixture.update",
async setup(context) {
probe.updateSetups.push(version)
context.ui.slot({
append: "home.footer",
render: () => {
onCleanup(
context.data.listen((event) => {
if (event.details.id === "evt_apply_probe") probe.updateEvents.push(version)
}),
)
return <text>Update code {version}</text>
},
})
if (version === "B" && probe.failUpdate) throw new Error("Candidate setup failed")
if (version === "B") await probe.updateSetup
return () => {
probe.updateCleanups.push(version)
}
},
})
}
export default Plugin.define({
id: "fixture.inspection",
setup(context) {
if (context.options.fail) throw new Error("Fixture setup failed")
probe.setups++
context.ui.slot({
append: "app",
render: () => {
const plugins = usePlugin()
const config = useConfig()
const location = useLocation()
const route = useRoute()
probe.plugins = () => plugins
probe.config = () => config
probe.location = () => location
probe.navigate = (directory) => route.navigate({ type: "home", location: { directory } })
return null
},
})
return () => {
probe.cleanups++
}
},
})
@@ -0,0 +1 @@
export const version = "A"
@@ -0,0 +1,4 @@
import { updatePlugin } from "../plugin-inspection"
import { version } from "./helper"
export default updatePlugin(version)
@@ -0,0 +1 @@
export const version = "B"
@@ -0,0 +1,4 @@
import { updatePlugin } from "../plugin-inspection"
import { version } from "./helper"
export default updatePlugin(version)
+9 -3
View File
@@ -7,6 +7,7 @@ import path from "node:path"
import { pathToFileURL } from "node:url"
import { createEventStream, createFetch, json } from "./fixture/tui-client"
import { tmpdir } from "./fixture/fixture"
import type { PackageResolver } from "../src/plugin/context"
function lifecyclePluginSource(marker: string, id: string, version: string) {
return `
@@ -57,7 +58,7 @@ async function bootApp(
directory: string,
options?: {
plugins?: unknown[]
resolve?: (spec: string, install?: boolean) => Promise<string | undefined>
resolve?: PackageResolver["resolve"]
},
) {
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
@@ -91,7 +92,12 @@ async function bootApp(
app: { name: "test", version: "test", channel: "test" },
server: { endpoint: { url: server.url.toString() } },
config: { get: async () => ({}), update: async () => ({}) },
packages: { resolve: options?.resolve ?? (async () => undefined) },
packages: {
resolve: options?.resolve ?? (async () => ({})),
check: async () => ({ mutable: false }),
update: async () => ({}),
commit: async () => {},
},
args: {},
log: () => {},
}).pipe(
@@ -128,7 +134,7 @@ test("loads an advertised package TUI entrypoint only from the local cache", asy
],
resolve: async (spec, install) => {
resolutions.push({ spec, install })
return pathToFileURL(entrypoint).href
return { entrypoint: pathToFileURL(entrypoint).href, revision: "1.0.0" }
},
})
@@ -0,0 +1,785 @@
import { expect, test } from "bun:test"
import type { PluginInfo } from "@opencode-ai/client"
import type { PackageStatus } from "@opencode-ai/schema/plugin"
import { Global } from "@opencode-ai/util/global"
import { InputRenderable } from "@opentui/core"
import { createTestRenderer } from "@opentui/core/testing"
import { Effect, FileSystem } from "effect"
import path from "node:path"
import { pathToFileURL } from "node:url"
import type { Info } from "../src/config"
import type { PackageResolver } from "../src/plugin/context"
import { probe } from "./fixture/plugin-inspection"
import { tmpdir } from "./fixture/fixture"
import { createEventStream, createFetch, json } from "./fixture/tui-client"
const target = "fixture-plugin@latest"
const entrypoint = new URL("./fixture/plugin-inspection.tsx", import.meta.url).href
const updateTarget = "fixture-update@latest"
async function updateGraphs(directory: string) {
const sources = await Promise.all(
["a", "b"].map(async (version) => {
const root = new URL(`./fixture/update-${version}/tui.tsx`, import.meta.url)
const entry = path.join(directory, `update-${version}`, "tui.tsx")
await Bun.write(
entry,
(await Bun.file(root).text()).replace('"../plugin-inspection"', JSON.stringify(entrypoint)),
)
await Bun.write(
path.join(directory, `update-${version}`, "helper.ts"),
await Bun.file(new URL("helper.ts", root)).text(),
)
return pathToFileURL(entry).href
}),
)
return { a: sources[0], b: sources[1] }
}
async function boot(
directory: string,
options: {
width?: number
kittyKeyboard?: boolean
plugins?: Info["plugins"]
server?: () => PluginInfo[]
check?: PackageResolver["check"]
resolve?: PackageResolver["resolve"]
update?: PackageResolver["update"]
commit?: PackageResolver["commit"]
inventoryError?: () => boolean
} = {},
) {
const app = await createTestRenderer({
width: options.width ?? 100,
height: 24,
useThread: false,
kittyKeyboard: options.kittyKeyboard ?? true,
})
const location = { directory, project: { id: "proj_fixture", directory, canonical: directory } }
const events = createEventStream()
const requests: { path: string; directory: string | null; target: string }[] = []
const inventories: (string | null)[] = []
const calls = createFetch(async (url, request) => {
if (url.pathname.startsWith("/api/plugin/")) {
const body = await request.json()
requests.push({ path: url.pathname, directory: url.searchParams.get("location[directory]"), target: body.target })
return json({ location, data: { installed: "9.0.0", available: "10.0.0", mutable: true } })
}
if (url.pathname === "/api/plugin") {
inventories.push(url.searchParams.get("location[directory]"))
if (options.inventoryError?.()) return json({ message: "Inventory unavailable" }, { status: 503 })
return json({ location, data: options.server?.() ?? [] })
}
if (url.pathname === "/api/location")
return json({ ...location, directory: url.searchParams.get("location[directory]") ?? directory })
if (url.pathname === "/api/fs/list") return json({ location, data: [] })
}, events)
const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) })
const config: Info = { animations: false, plugins: options.plugins ?? [target], keybinds: { "plugins.list": "f6" } }
const cwd = process.cwd()
process.chdir(directory)
probe.setups = 0
probe.cleanups = 0
probe.updateSetups = []
probe.updateCleanups = []
probe.updateEvents = []
probe.updateSetup = Promise.resolve()
probe.failUpdate = false
const { run } = await import("../src/app")
const task = Effect.runPromise(
run({
app: { name: "test", version: "test", channel: "test" },
server: { endpoint: { url: server.url.toString() } },
config: {
get: async () => structuredClone(config),
update: async (update) => {
update(config)
return structuredClone(config)
},
},
packages: {
resolve: options.resolve ?? (async () => ({ entrypoint, revision: "1.0.0" })),
check: options.check ?? (async () => ({ installed: "1.0.0", available: "2.0.0", mutable: true })),
update: options.update ?? (async () => ({})),
commit: options.commit ?? (async () => {}),
},
terminalHandoff: async () => ({ renderer: app.renderer, mode: "dark", complete: () => {} }),
args: {},
log: () => {},
}).pipe(
Effect.provide(
Global.layerWith(
Object.fromEntries(
["home", "config", "data", "cache", "state", "tmp", "bin", "log", "repos"].map((name) => [
name,
path.join(directory, name),
]),
),
),
),
Effect.provide(FileSystem.layerNoop({})),
),
)
app.renderer.start()
await app.waitForFrame((frame) => frame.includes("Ask anything"), { maxPasses: 100 })
return {
...app,
events,
requests,
inventories,
task,
async [Symbol.asyncDispose]() {
app.renderer.destroy()
await task.finally(async () => {
process.chdir(cwd)
events.disconnect()
await server.stop(true)
})
},
}
}
async function open(app: Awaited<ReturnType<typeof boot>>, search = "fixture.inspection") {
app.mockInput.pressKey("F6")
await app.waitForFrame((frame) => frame.includes("Plugins") && frame.includes("Search"))
await app.waitFor(() => app.renderer.currentFocusedEditor instanceof InputRenderable)
await app.mockInput.typeText(search)
await app.waitForFrame((frame) => frame.includes(search) && !frame.includes("Search"))
}
test("explicit checks are read-only, retain loaded snapshots, and preserve toggle/filter behavior", async () => {
await using tmp = await tmpdir()
const checked: string[] = []
const resolved: string[] = []
let disk = "1.0.0"
await using app = await boot(tmp.path, {
resolve: async (spec) => {
resolved.push(spec)
return { entrypoint, revision: disk }
},
check: async (spec) => {
checked.push(spec)
return { installed: disk, available: "3.0.0", mutable: true }
},
})
await open(app)
expect(checked).toEqual([])
app.mockInput.pressEnter()
const details = await app.waitForFrame((frame) => frame.includes("Loaded") && frame.includes("Check for updates"))
expect(details).toContain("1.0.0")
expect(details).not.toMatch(/Update package|Reload installed|Enable in this terminal/)
disk = "2.0.0"
app.mockInput.pressKey("r", { ctrl: true })
const result = await app.waitForFrame((frame) => frame.includes("New revision available"))
expect(result).toMatch(/Loaded\s+1\.0\.0/)
expect(result).toMatch(/Installed\s+2\.0\.0/)
expect(checked).toEqual([target])
expect(resolved).toEqual([target])
expect([probe.setups, probe.cleanups]).toEqual([1, 0])
expect(app.requests).toEqual([])
await expect(probe.plugins().check("unconfigured-plugin")).rejects.toThrow("Not a configured package")
await expect(probe.plugins().check("opencode.plugins")).rejects.toThrow("Not a configured package")
app.mockInput.pressEscape()
await app.waitForFrame((frame) => frame.includes("↑ update") && frame.includes("disable"))
expect(app.renderer.currentFocusedEditor?.plainText).toBe("fixture.inspection")
app.mockInput.pressKey(" ")
await app.waitForFrame((frame) => frame.includes("enable"))
expect(
probe
.plugins()
.registered()
.find((item) => item.id === "fixture.inspection")?.revision,
).toBe("1.0.0")
app.mockInput.pressKey(" ")
await app.waitForFrame((frame) => frame.includes("disable"))
await probe.config().update((draft) => {
draft.plugins = [{ package: target, options: { flag: true } }]
})
await app.waitFor(() => probe.setups === 3)
expect(
probe
.plugins()
.registered()
.find((item) => item.id === "fixture.inspection")?.revision,
).toBe("1.0.0")
await probe.config().update((draft) => {
draft.plugins = [{ package: target, options: { fail: true } }]
})
await app.waitFor(() =>
probe
.plugins()
.list()
.some((item) => item.status === "failed"),
)
expect(
probe
.plugins()
.registered()
.find((item) => item.id === "fixture.inspection"),
).toMatchObject({ active: true, revision: "1.0.0" })
})
test.each([40, 100])("pinned checks, busy/error details, and CLI spec guidance at width %s", async (width) => {
await using tmp = await tmpdir()
const pending = Promise.withResolvers<PackageStatus>()
let calls = 0
await using app = await boot(tmp.path, {
width,
check: () => {
calls++
return pending.promise
},
})
await open(app)
app.mockInput.pressKey("r", { ctrl: true })
await app.waitForFrame((frame) => frame.includes("Checking..."))
app.mockInput.pressKey("r", { ctrl: true })
expect(calls).toBe(1)
pending.resolve({ available: "2.0.0", mutable: false })
await app.waitForFrame((frame) => frame.includes("pinned"))
app.mockInput.pressEnter()
const frame = await app.waitForFrame((frame) => frame.includes("Pinned source."))
expect(frame).not.toContain("New revision available")
expect(frame).not.toMatch(/Update package|Reload installed/)
if (width === 100) expect(frame).toMatch(/Installed\s+Unknown/)
app.mockInput.pressKey("\x1b[6~")
await app.waitForFrame((frame) => frame.includes("cli.json"))
expect(app.requests).toEqual([])
})
test.each(["active", "failed", "without-tui"])(
"late %s server inventory uses the default check scope and shares results",
async (kind) => {
await using tmp = await tmpdir()
let server: PluginInfo[] = []
await using app = await boot(tmp.path, {
server: () => server,
check: async () => {
throw new Error("Companion must check on server")
},
})
probe.navigate("/projects/active-session")
await app.waitFor(() => probe.location().current?.directory === "/projects/active-session")
await open(app)
const info = {
id: "fixture.server",
source: { type: "package" as const, package: target },
tui: kind === "active",
revision: "9.0.0",
}
server = [
kind === "failed" ? { ...info, status: "failed", error: "Server fixture failed" } : { ...info, status: "active" },
]
app.events.emit({ id: "evt_fixture", created: Date.now(), type: "plugin.updated", data: {} })
app.mockInput.pressKey("c", { ctrl: true })
await app.mockInput.typeText("fixture.server")
await app.waitForFrame((frame) => frame.includes("fixture.server") && frame.includes("Server"))
app.mockInput.pressKey("c", { ctrl: true })
await app.mockInput.typeText("fixture.inspection")
await app.waitForFrame((frame) => frame.includes("fixture.inspection") && !frame.includes("fixture.server"))
app.mockInput.pressKey("r", { ctrl: true })
await app.waitForFrame((frame) => frame.includes("↑ update"))
expect(app.requests).toEqual([{ path: "/api/plugin/check", directory: tmp.path, target }])
app.mockInput.pressEnter()
const details = await app.waitForFrame((frame) => frame.includes("Loaded") && frame.includes("Installed"))
expect(details).toMatch(/Loaded\s+1\.0\.0/)
expect(details).toMatch(/Installed\s+9\.0\.0/)
expect(details).not.toContain("Update & Apply")
app.mockInput.pressKey("\x1b[6~")
await app.waitForFrame((frame) => frame.includes("Inspection only") && frame.includes("hot apply"))
app.mockInput.pressEscape()
await app.waitFor(() => app.renderer.currentFocusedEditor instanceof InputRenderable)
app.mockInput.pressKey("c", { ctrl: true })
await app.mockInput.typeText("fixture.server")
await app.waitForFrame(
(frame) => frame.includes("fixture.server") && frame.includes(kind === "failed" ? "failed" : "↑ update"),
)
app.mockInput.pressEnter()
const shared = await app.waitForFrame((frame) => frame.includes("Installed"))
expect(shared).toMatch(/Installed\s+9\.0\.0/)
expect(app.requests).toHaveLength(1)
},
)
test("failed checks keep existing error investigation accessible", async () => {
await using tmp = await tmpdir()
await using app = await boot(tmp.path, {
check: async () => {
throw new Error("Registry unavailable")
},
})
await probe.config().update((draft) => {
draft.plugins = [{ package: target, options: { fail: true } }]
})
await app.waitFor(() =>
probe
.plugins()
.list()
.some((item) => item.status === "failed"),
)
await open(app)
app.mockInput.pressKey("r", { ctrl: true })
await app.waitForFrame((frame) => frame.includes("failed"))
app.mockInput.pressEnter()
await app.waitForFrame((frame) => frame.includes("View error details"))
app.mockInput.pressArrow("down")
app.mockInput.pressEnter()
const errors = await app.waitForFrame(
(frame) =>
frame.includes("Registry unavailable") &&
frame.includes("Fixture setup failed") &&
frame.includes("i investigate"),
)
expect(errors).toContain("Load error:")
expect(errors).toContain("Check error:")
app.mockInput.pressEscape()
await app.waitForFrame((frame) => frame.includes("View error details"))
})
test.each(["opencode.system", entrypoint])("builtins and local sources have no package check: %s", async (spec) => {
await using tmp = await tmpdir()
await using app = await boot(tmp.path, { plugins: spec.startsWith("opencode.") ? [] : [spec] })
await open(app, spec.startsWith("opencode.") ? "opencode" : "fixture.inspection")
app.mockInput.pressEnter()
const frame = await app.waitForFrame((frame) => frame.includes("Not applicable"))
expect(frame).not.toContain("Check for updates")
expect(frame).not.toMatch(/Update package|Reload installed/)
app.mockInput.pressKey("\x1b[6~")
await app.waitForFrame((frame) =>
frame.includes(spec.startsWith("opencode.") ? "Updates with OpenCode" : "no package check"),
)
expect(app.requests).toEqual([])
})
test.each([false, true])(
"real paging keys expose overflowing package revisions with Kitty %s",
async (kittyKeyboard) => {
await using tmp = await tmpdir()
const source = `git+https://example.com/${"nested-directory/".repeat(16)}plugin.git#main`
const one = "a".repeat(40)
const two = "b".repeat(40)
await Bun.write(path.join(tmp.path, "overflow.ts"), 'export default { id: "fixture.overflow", setup() {} }\n')
await using app = await boot(tmp.path, {
kittyKeyboard,
plugins: [source],
resolve: async () => ({ entrypoint: pathToFileURL(path.join(tmp.path, "overflow.ts")).href, revision: one }),
check: async () => ({ installed: one, available: two, mutable: true }),
})
await open(app, "fixture.overflow")
app.mockInput.pressEnter()
const top = await app.waitForFrame((frame) => frame.includes("Source") && frame.includes("Check for updates"))
expect(top).not.toContain("Available")
app.mockInput.pressKey("r", { ctrl: true })
await app.waitForFrame((frame) => frame.includes("New revision available"))
app.mockInput.pressKey("\x1b[6~")
await app.waitForFrame((frame) => !frame.includes("Runtime"))
app.mockInput.pressKey("\x1b[6~")
const scrolled = await app.waitForFrame((frame) => frame.includes("Installed") && frame.includes("Available"))
expect(scrolled).toContain(one)
expect(scrolled).toContain(two)
app.mockInput.pressKey("\x1b[5~")
app.mockInput.pressKey("\x1b[5~")
await app.waitForFrame((frame) => frame.includes("Runtime") && !frame.includes("Available"))
},
)
test.each([40, 100])(
"Update & Apply swaps only active target code and preserves manual mode at width %s",
async (width) => {
await using tmp = await tmpdir()
const versions = await updateGraphs(tmp.path)
const pending = Promise.withResolvers<Awaited<ReturnType<PackageResolver["update"]>>>()
let selected = { entrypoint: versions.a, revision: "1.0.0" }
const updates: string[] = []
const checks: string[] = []
const resolutions: string[] = []
await using app = await boot(tmp.path, {
width,
plugins: [target, updateTarget],
resolve: async (spec) => {
resolutions.push(spec)
return spec === target ? { entrypoint, revision: "1.0.0" } : selected
},
check: async (spec) => {
checks.push(spec)
return { installed: selected.revision, available: "2.0.0", mutable: true }
},
update: (spec) => {
updates.push(spec)
return pending.promise
},
commit: async (spec, entry) => {
expect(spec).toBe(updateTarget)
expect(probe.updateSetups).toEqual(["A", "B"])
expect(
probe
.plugins()
.registered()
.find((item) => item.id === "fixture.update"),
).toMatchObject({ active: true, revision: "2.0.0" })
selected = { entrypoint: entry.entrypoint!, revision: entry.revision! }
},
})
await app.waitForFrame((frame) => frame.includes("Update code A"))
expect(await probe.plugins().deactivate("opencode.sidebar.context")).toBe(true)
const order = probe
.plugins()
.registered()
.map((item) => item.id)
await open(app, "fixture.update")
app.mockInput.pressEnter()
const unchecked = await app.waitForFrame((frame) => frame.includes("Check for updates"))
expect(unchecked).not.toMatch(/>.*Update & Apply/)
app.mockInput.pressKey("r", { ctrl: true })
await app.waitForFrame((frame) => frame.includes("Update & Apply") && frame.includes("New revision available"))
app.mockInput.pressArrow("down")
app.mockInput.pressEnter()
const busy = await app.waitForFrame((frame) => frame.includes("Updating & applying..."))
expect(busy.split("\n").length).toBeLessThanOrEqual(25)
await app.waitFor(() => updates.length === 1)
app.mockInput.pressEnter()
expect(updates).toEqual([updateTarget])
expect(selected.entrypoint).toBe(versions.a)
expect(probe.updateCleanups).toEqual([])
pending.resolve({ entrypoint: versions.b, revision: "2.0.0" })
await app.waitFor(() => selected.entrypoint === versions.b)
await app.waitForFrame((frame) => !frame.includes("Updating & applying...") && frame.includes("Check for updates"))
if (width === 100) {
const details = await app.waitForFrame((frame) => /Loaded\s+2\.0\.0/.test(frame))
expect(details).toMatch(/Installed\s+2\.0\.0/)
expect(details).toMatch(/Available\s+2\.0\.0/)
expect(details).not.toContain("New revision available")
}
expect(probe.updateSetups).toEqual(["A", "B"])
expect(probe.updateCleanups).toEqual(["A"])
expect([probe.setups, probe.cleanups]).toEqual([1, 0])
expect(
probe
.plugins()
.registered()
.map((item) => item.id),
).toEqual(order)
expect(
probe
.plugins()
.registered()
.find((item) => item.id === "opencode.sidebar.context")?.active,
).toBe(false)
expect(resolutions).toEqual([target, updateTarget])
expect(checks).toEqual([updateTarget])
expect(app.requests).toEqual([])
app.mockInput.pressEscape()
await app.waitFor(() => app.renderer.currentFocusedEditor instanceof InputRenderable)
expect(app.renderer.currentFocusedEditor?.plainText).toBe("fixture.update")
app.mockInput.pressEscape()
await app.waitForFrame((frame) => frame.includes("Update code B"))
await probe.config().update((draft) => {
draft.plugins = [target, { package: updateTarget, options: { flag: true } }]
})
await app.waitFor(() => probe.updateSetups.length === 3)
expect(probe.updateSetups).toEqual(["A", "B", "B"])
expect(resolutions).toEqual([target, updateTarget])
},
)
test("a fresh graph applies changed helper code even with identical root bytes and package revision", async () => {
await using tmp = await tmpdir()
const versions = await updateGraphs(tmp.path)
expect(await Bun.file(new URL(versions.a)).text()).toBe(await Bun.file(new URL(versions.b)).text())
let selected = versions.a
await using app = await boot(tmp.path, {
plugins: [target, updateTarget],
resolve: async (spec) => ({ entrypoint: spec === target ? entrypoint : selected, revision: "1.0.0" }),
update: async () => ({ entrypoint: versions.b, revision: "1.0.0" }),
commit: async (_spec, entry) => {
selected = entry.entrypoint!
},
})
await app.waitForFrame((frame) => frame.includes("Update code A"))
await probe.plugins().deactivate("fixture.inspection")
expect(await probe.plugins().update(updateTarget)).toEqual({ installed: "1.0.0", available: "1.0.0", mutable: true })
await app.waitForFrame((frame) => frame.includes("Update code B"))
expect(selected).toBe(versions.b)
expect(probe.updateSetups).toEqual(["A", "B"])
expect([probe.setups, probe.cleanups]).toEqual([1, 1])
expect(
probe
.plugins()
.registered()
.find((item) => item.id === "fixture.inspection")?.active,
).toBe(false)
})
test.each(["import", "invalid", "id", "setup"])(
"failed %s candidate retains last-good code and cache selection, then permits retry",
async (kind) => {
await using tmp = await tmpdir()
const versions = await updateGraphs(tmp.path)
const bad = pathToFileURL(path.join(tmp.path, "candidate.ts")).href
if (kind === "invalid") await Bun.write(new URL(bad), "export default {}\n")
if (kind === "id") await Bun.write(new URL(bad), 'export default { id: "fixture.changed", setup() {} }\n')
let candidate = kind === "setup" ? versions.b : bad
let selected = versions.a
await using app = await boot(tmp.path, {
plugins: [target, updateTarget],
resolve: async (spec) => ({ entrypoint: spec === target ? entrypoint : selected, revision: "1.0.0" }),
update: async () => ({ entrypoint: candidate, revision: "2.0.0" }),
commit: async (_spec, entry) => {
selected = entry.entrypoint!
},
})
await app.waitForFrame((frame) => frame.includes("Update code A"))
probe.failUpdate = kind === "setup"
await expect(probe.plugins().update(updateTarget)).rejects.toThrow()
await app.waitForFrame((frame) => frame.includes("Update code A"))
expect(selected).toBe(versions.a)
expect(
probe
.plugins()
.registered()
.find((item) => item.id === "fixture.update"),
).toMatchObject({ active: true, revision: "1.0.0" })
expect(
probe
.plugins()
.list()
.find((item) => item.target === updateTarget),
).toMatchObject({ status: "failed", error: expect.stringContaining("previous version still active") })
expect(probe.updateCleanups).toEqual(kind === "setup" ? ["A"] : [])
expect(probe.updateSetups).toEqual(kind === "setup" ? ["A", "B", "A"] : ["A"])
expect([probe.setups, probe.cleanups]).toEqual([1, 0])
expect(probe.plugins().canUpdate(updateTarget)).toBe(true)
app.events.emit({ id: "evt_apply_probe", type: "server.connected", data: {} })
await app.waitFor(() => probe.updateEvents.length > 0)
expect(probe.updateEvents).toEqual(["A"])
candidate = versions.b
probe.failUpdate = false
await probe.plugins().update(updateTarget)
await app.waitForFrame((frame) => frame.includes("Update code B"))
expect(selected).toBe(versions.b)
},
)
test.each(["active", "failed", "without-tui", "read-error"])(
"fresh %s server ownership blocks downloads at the default location",
async (kind) => {
await using tmp = await tmpdir()
let server: PluginInfo[] = []
let readError = false
let updates = 0
await using app = await boot(tmp.path, {
server: () => server,
inventoryError: () => readError,
update: async () => {
updates++
return {}
},
})
probe.navigate("/projects/active-session")
await app.waitFor(() => probe.location().current?.directory === "/projects/active-session")
await open(app)
app.mockInput.pressKey("r", { ctrl: true })
await app.waitForFrame((frame) => frame.includes("↑ update"))
const info = { id: "fixture.server", source: { type: "package" as const, package: target }, tui: kind === "active" }
if (kind === "read-error") readError = true
else
server = [
kind === "failed"
? { ...info, status: "failed", error: "Failed server plugin" }
: { ...info, status: "active" },
]
const reads = app.inventories.length
await expect(probe.plugins().update(target)).rejects.toThrow()
expect(app.inventories.length).toBeGreaterThan(reads)
expect(app.inventories.at(-1)).toBe(tmp.path)
expect(updates).toBe(0)
expect([probe.setups, probe.cleanups]).toEqual([1, 0])
expect(app.requests).toEqual([])
},
)
test("unknown, local, built-in, disabled, failed-initial and pinned packages cannot update", async () => {
await using tmp = await tmpdir()
const versions = await updateGraphs(tmp.path)
const local = pathToFileURL(path.join(tmp.path, "local.ts")).href
await Bun.write(new URL(local), 'export default { id: "fixture.local", setup() {} }\n')
let mutable = false
let updates = 0
await using app = await boot(tmp.path, {
plugins: [target, "fixture-broken@latest", updateTarget, "-fixture.update", local],
resolve: async (spec) => {
if (spec === updateTarget) return { entrypoint: versions.a, revision: "1.0.0" }
if (spec !== target) throw new Error("Initial import unavailable")
return { entrypoint, revision: "1.0.0" }
},
check: async () => ({ mutable }),
update: async () => {
updates++
if (!mutable) throw new Error("Pinned package cannot be updated")
return {}
},
})
for (const spec of ["unknown", local, "opencode.sidebar.context", "fixture-broken@latest", updateTarget]) {
expect(probe.plugins().canUpdate(spec)).toBe(false)
await expect(probe.plugins().update(spec)).rejects.toThrow("Not an active CLI-only")
}
await expect(probe.plugins().update(target)).rejects.toThrow("Pinned")
mutable = true
await probe.plugins().deactivate("fixture.inspection")
expect(probe.plugins().canUpdate(target)).toBe(false)
await expect(probe.plugins().update(target)).rejects.toThrow("Not an active CLI-only")
await open(app)
app.mockInput.pressKey("r", { ctrl: true })
await app.waitForFrame((frame) => !frame.includes("Checking...") && frame.includes("enable"))
app.mockInput.pressEnter()
const detail = await app.waitForFrame((frame) => frame.includes("Check for updates"))
expect(detail).not.toContain("Update & Apply")
app.mockInput.pressKey("\x1b[6~")
await app.waitForFrame((frame) => frame.includes("disabled or unloaded"))
expect(updates).toBe(1)
})
test("cache commit failure truthfully keeps new code owned and exposes the persistence error", async () => {
await using tmp = await tmpdir()
const versions = await updateGraphs(tmp.path)
const selected = versions.a
await using app = await boot(tmp.path, {
plugins: [target, updateTarget],
resolve: async (spec) => ({ entrypoint: spec === target ? entrypoint : selected, revision: "1.0.0" }),
update: async () => ({ entrypoint: versions.b, revision: "2.0.0" }),
commit: async () => {
throw new Error("Cache write denied")
},
})
await app.waitForFrame((frame) => frame.includes("Update code A"))
await open(app, "fixture.update")
app.mockInput.pressKey("r", { ctrl: true })
await app.waitForFrame((frame) => frame.includes("↑ update"))
app.mockInput.pressEnter()
await app.waitForFrame((frame) => frame.includes("Update & Apply"))
app.mockInput.pressArrow("down")
app.mockInput.pressEnter()
await app.waitForFrame((frame) => frame.includes("View error details") && !frame.includes("Updating & applying..."))
const detail = await app.waitForFrame((frame) => /Loaded\s+2\.0\.0/.test(frame))
expect(detail).toMatch(/Installed\s+1\.0\.0/)
app.mockInput.pressArrow("down")
app.mockInput.pressEnter()
await app.waitForFrame((frame) => frame.includes("Update error:") && frame.includes("Cache write denied"))
expect(selected).toBe(versions.a)
expect(
probe
.plugins()
.registered()
.find((item) => item.id === "fixture.update"),
).toMatchObject({ active: true, revision: "2.0.0" })
app.events.emit({ id: "evt_apply_probe", type: "server.connected", data: {} })
await app.waitFor(() => probe.updateEvents.length > 0)
expect(probe.updateEvents).toEqual(["B"])
await probe.plugins().deactivate("fixture.update")
expect(probe.updateCleanups).toEqual(["A", "B"])
})
test("queued updates read ownership only when their serialized operation starts", async () => {
await using tmp = await tmpdir()
const versions = await updateGraphs(tmp.path)
const pending = Promise.withResolvers<Awaited<ReturnType<PackageResolver["update"]>>>()
let inventory: PluginInfo[] = []
let updates = 0
await using app = await boot(tmp.path, {
plugins: [target, updateTarget],
server: () => inventory,
resolve: async (spec) => ({ entrypoint: spec === target ? entrypoint : versions.a, revision: "1.0.0" }),
update: () => {
updates++
return pending.promise
},
})
await app.waitForFrame((frame) => frame.includes("Update code A"))
const first = probe.plugins().update(updateTarget)
await app.waitFor(() => updates === 1)
const queued = probe
.plugins()
.update(updateTarget)
.then(
() => undefined,
(error: unknown) => error,
)
inventory = [
{
source: { type: "package", package: updateTarget },
status: "failed",
tui: false,
error: "Late server owner",
},
]
pending.resolve({ entrypoint: versions.b, revision: "2.0.0" })
await first
expect(await queued).toMatchObject({ message: expect.stringContaining("inspection only") })
expect(updates).toBe(1)
expect(probe.updateSetups).toEqual(["A", "B"])
await app.waitForFrame((frame) => frame.includes("Update code B"))
})
test.each([
{ phase: "download", cause: "removal" },
{ phase: "download", cause: "shutdown" },
{ phase: "setup", cause: "removal" },
{ phase: "setup", cause: "shutdown" },
])("update aborts without publishing when $cause crosses async $phase", async ({ phase, cause }) => {
await using tmp = await tmpdir()
const versions = await updateGraphs(tmp.path)
const downloading = Promise.withResolvers<void>()
const download = Promise.withResolvers<Awaited<ReturnType<PackageResolver["update"]>>>()
const setup = Promise.withResolvers<void>()
let selected = versions.a
let commits = 0
await using app = await boot(tmp.path, {
plugins: [target, updateTarget],
resolve: async (spec) => ({ entrypoint: spec === target ? entrypoint : selected, revision: "1.0.0" }),
update: () => {
downloading.resolve()
return phase === "download" ? download.promise : Promise.resolve({ entrypoint: versions.b, revision: "2.0.0" })
},
commit: async (_spec, entry) => {
commits++
selected = entry.entrypoint!
},
})
await app.waitForFrame((frame) => frame.includes("Update code A"))
probe.updateSetup = setup.promise
const operation = probe
.plugins()
.update(updateTarget)
.then(
() => undefined,
(error: unknown) => error,
)
try {
await downloading.promise
if (phase === "setup") await app.waitFor(() => probe.updateSetups.includes("B"))
if (cause === "shutdown") app.renderer.destroy()
if (cause === "removal")
await probe.config().update((draft) => {
draft.plugins = [target]
})
} finally {
download.resolve({ entrypoint: versions.b, revision: "2.0.0" })
setup.resolve()
}
expect(await operation).toMatchObject({ message: expect.stringContaining("update aborted") })
if (cause === "shutdown") await app.task
if (cause === "removal")
await app.waitFor(
() =>
!probe
.plugins()
.registered()
.some((item) => item.id === "fixture.update"),
)
expect(selected).toBe(versions.a)
expect(commits).toBe(0)
expect(probe.updateSetups).toEqual(phase === "download" ? ["A"] : ["A", "B"])
expect(probe.updateCleanups).toEqual(phase === "download" ? ["A"] : ["A", "B"])
expect(probe.plugins().canUpdate(updateTarget)).toBe(false)
if (cause === "shutdown") expect(probe.plugins().registered()).toEqual([])
})
+6 -1
View File
@@ -103,7 +103,12 @@ test.each([
}),
update: async () => ({}),
},
packages: { resolve: async () => undefined },
packages: {
resolve: async () => ({}),
check: async () => ({ mutable: false }),
update: async () => ({}),
commit: async () => {},
},
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }),
args: { sessionID: "dummy" },
log: () => {},
+2
View File
@@ -53,6 +53,7 @@
"mime-types": "3.0.2",
"minimatch": "10.2.5",
"npm-package-arg": "13.0.2",
"pacote": "21.5.1",
"resolve.exports": "catalog:"
},
"devDependencies": {
@@ -62,6 +63,7 @@
"@types/node": "catalog:",
"@types/npm-package-arg": "6.1.4",
"@types/npmcli__arborist": "6.3.3",
"@types/pacote": "11.1.8",
"@typescript/native-preview": "catalog:"
}
}
+143 -51
View File
@@ -22,14 +22,18 @@ export class InstallFailedError extends Schema.TaggedError<InstallFailedError>()
export interface EntryPoint {
readonly directory: string
readonly entrypoint?: string
readonly revision?: string
}
export interface Interface {
readonly add: (
pkg: string,
options?: { readonly subpaths?: readonly string[]; readonly refresh?: boolean },
options?: { readonly subpaths?: readonly string[]; readonly refresh?: boolean; readonly root?: string },
) => Effect.Effect<EntryPoint, InstallFailedError | EffectFlock.LockError>
readonly resolve: (pkg: string, options?: { readonly subpaths?: readonly string[] }) => Effect.Effect<EntryPoint>
readonly check: (
pkg: string,
) => Effect.Effect<{ installed?: string; available?: string; mutable: boolean }, InstallFailedError>
readonly which: (pkg: string, bin?: string) => Effect.Effect<string | undefined>
}
@@ -99,6 +103,11 @@ interface ArboristTree {
const PackageJson = Schema.Struct({
dependencies: Schema.optional(Schema.Record(Schema.String, Schema.String)),
version: Schema.optional(Schema.String),
})
const PackageLock = Schema.Struct({
packages: Schema.optional(Schema.Record(Schema.String, Schema.Struct({ resolved: Schema.optional(Schema.String) }))),
})
const layer = Layer.effect(
@@ -113,6 +122,15 @@ const layer = Layer.effect(
Effect.promise(() => cacheKey(pkg)),
(key) => path.join(global.cache, "packages", key),
)
const parse = (pkg: string) =>
Effect.promise(async () => {
const { default: npa } = await import("npm-package-arg")
try {
return npa(pkg)
} catch {
return undefined
}
})
const installedName = Effect.fnUntraced(function* (pkg: string, dir: string, parsedName?: string) {
if (parsedName) return parsedName
const manifest = yield* afs
@@ -124,14 +142,57 @@ const layer = Layer.effect(
}
return pkg
})
const installedRevision = Effect.fnUntraced(function* (root: string, name: string, dir: string, type?: string) {
if (type !== "git") {
const manifest = yield* afs
.readJson(path.join(dir, "package.json"))
.pipe(Effect.flatMap(Schema.decodeUnknownEffect(PackageJson)), Effect.option)
return Option.isSome(manifest) ? manifest.value.version : undefined
}
if (!(yield* afs.existsSafe(dir))) return undefined
// The wrapper lock records the installed Git revision, not the package's own dependency lock.
for (const file of [
path.join(root, "package-lock.json"),
path.join(root, "node_modules", ".package-lock.json"),
]) {
const lock = yield* afs
.readJson(file)
.pipe(Effect.flatMap(Schema.decodeUnknownEffect(PackageLock)), Effect.option)
const revision = gitRevision(
Option.isSome(lock) ? lock.value.packages?.[`node_modules/${name}`]?.resolved : undefined,
)
if (revision) return revision
}
return undefined
})
const entry = Effect.fnUntraced(function* (
root: string,
name: string,
dir: string,
type?: string,
subpaths?: readonly string[],
) {
return {
...resolveEntryPoint(name, dir, subpaths),
revision: yield* installedRevision(root, name, dir, type),
}
})
const refreshed = new Set<string>()
const reify = (input: { dir: string; add?: string[]; update?: boolean }) =>
const reify = (input: { dir: string; add?: string[]; update?: boolean; configDir?: 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* NpmConfig.load(input.dir)
const options = input.update ? { ...npmOptions, preferOnline: true, noGitRevCache: true } : npmOptions
const npmOptions = yield* NpmConfig.load(input.configDir ?? input.dir)
const options = input.update
? {
...npmOptions,
preferOnline: true,
noGitRevCache: true,
// Independent roots must copy file dependencies, not link into another tree.
...(input.configDir === undefined ? {} : { installLinks: true }),
}
: npmOptions
const arborist = new Arborist({
...options,
path: input.dir,
@@ -162,64 +223,86 @@ const layer = Layer.effect(
}),
)
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 parsed = (() => {
try {
return npa(pkg)
} catch {
return undefined
const add = Effect.fn("Npm.add")(
function* (pkg: string, options?: Parameters<Interface["add"]>[1]) {
const parsed = yield* parse(pkg)
const parsedName = parsed?.name ?? undefined
const root = yield* directory(pkg)
const dir = options?.root ?? root
const name = yield* installedName(pkg, dir, parsedName)
const cached = options?.root === undefined && (yield* afs.existsSafe(path.join(dir, "node_modules", name)))
const refresh = options?.root === undefined && options?.refresh && isMutable(parsed) && !refreshed.has(pkg)
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")),
)
}
})()
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 (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 yield* entry(dir, name, path.join(dir, "node_modules", name), parsed?.type, options?.subpaths)
}
const tree = yield* reify({
dir,
add: [pkg],
...(options?.root === undefined ? {} : { configDir: root, update: true }),
})
const first = tree.edgesOut.values().next().value?.to
if (!first) {
const installed = yield* installedName(pkg, dir, parsedName)
const result = yield* entry(
dir,
installed,
path.join(dir, "node_modules", installed),
parsed?.type,
options?.subpaths,
)
}
if (cached) {
return resolveEntryPoint(name, path.join(dir, "node_modules", name), options?.subpaths)
}
const tree = yield* reify({ dir, add: [pkg] })
const first = tree.edgesOut.values().next().value?.to
if (!first) {
const installed = yield* installedName(pkg, dir, parsedName)
const result = resolveEntryPoint(installed, path.join(dir, "node_modules", installed), options?.subpaths)
if (result.entrypoint) return result
return yield* new InstallFailedError({ add: [pkg], dir })
}
return resolveEntryPoint(first.name, first.path, options?.subpaths)
}, Effect.scoped)
if (result.entrypoint) return result
return yield* new InstallFailedError({ add: [pkg], dir })
}
return yield* entry(dir, first.name, first.path, parsed?.type, options?.subpaths)
},
(effect, _pkg, options?: Parameters<Interface["add"]>[1]) =>
// Arborist cannot abort its IO; keep the fresh install's lock until it settles.
options?.root === undefined ? Effect.scoped(effect) : Effect.scoped(effect).pipe(Effect.uninterruptible),
)
const resolve = Effect.fn("Npm.resolve")(function* (
pkg: string,
options?: { readonly subpaths?: readonly string[] },
) {
const { default: npa } = yield* Effect.promise(() => import("npm-package-arg"))
const parsedName = (() => {
try {
return npa(pkg).name ?? undefined
} catch {
return undefined
}
})()
const parsed = yield* parse(pkg)
const root = yield* directory(pkg)
const name = yield* installedName(pkg, root, parsedName)
const name = yield* installedName(pkg, root, parsed?.name ?? undefined)
const dir = path.join(root, "node_modules", name)
if (!(yield* afs.existsSafe(dir))) return { directory: dir }
return resolveEntryPoint(name, dir, options?.subpaths)
return yield* entry(root, name, dir, parsed?.type, options?.subpaths)
})
const check = Effect.fn("Npm.check")(function* (pkg: string) {
const parsed = yield* parse(pkg)
const root = yield* directory(pkg)
if (
!parsed ||
(parsed.type !== "git" && !(parsed.name !== undefined && ["version", "range", "tag"].includes(parsed.type)))
)
return yield* new InstallFailedError({
dir: root,
cause: new Error("Package checks only support registry and Git package specs"),
})
const name = yield* installedName(pkg, root, parsed.name ?? undefined)
const installed = yield* installedRevision(root, name, path.join(root, "node_modules", name), parsed.type)
const { manifest, resolve } = yield* Effect.promise(() => import("pacote"))
const options = { ...(yield* NpmConfig.load(root)), preferOnline: true, noGitRevCache: true, ignoreScripts: true }
const available = yield* Effect.tryPromise({
try: async () =>
parsed.type === "git" ? gitRevision(await resolve(pkg, options)) : (await manifest(pkg, options)).version,
catch: (cause) => new InstallFailedError({ dir: root, cause }),
})
return { installed, available, mutable: isMutable(parsed) }
})
const which = Effect.fn("Npm.which")(function* (pkg: string, bin?: string) {
@@ -276,6 +359,7 @@ const layer = Layer.effect(
return Service.of({
add,
resolve,
check,
which,
})
}),
@@ -301,6 +385,14 @@ export async function which(...args: Parameters<Interface["which"]>) {
return runPromise((svc) => svc.which(...args))
}
export async function check(...args: Parameters<Interface["check"]>) {
return runPromise((svc) => svc.check(...args))
}
function gitRevision(resolved: string | undefined) {
return resolved?.match(/#([a-f0-9]{40}|[a-f0-9]{64})(?=::|$)/i)?.[1]
}
function isMutable(parsed: { readonly type: string; readonly gitCommittish?: string | null } | undefined) {
if (!parsed) return false
if (["tag", "range"].includes(parsed.type)) return true
+153
View File
@@ -486,6 +486,123 @@
"summary": "List plugins"
}
},
"/api/plugin/check": {
"post": {
"tags": ["plugin"],
"operationId": "v2.plugin.check",
"parameters": [
{
"name": "location",
"in": "query",
"schema": {
"anyOf": [
{
"type": "object",
"properties": {
"directory": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"workspace": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
}
},
"additionalProperties": false
},
{
"type": "null"
}
]
},
"required": false,
"style": "deepObject",
"explode": true
}
],
"security": [],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"location": {
"$ref": "#/components/schemas/Location.InfoEncoded"
},
"data": {
"$ref": "#/components/schemas/Plugin.PackageStatus"
}
},
"required": ["location", "data"],
"additionalProperties": false
}
}
}
},
"400": {
"description": "PluginCheckError | InvalidRequestError",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/PluginCheckErrorEncoded"
},
{
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
]
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
}
}
}
},
"description": "Inspect installed and available revisions of an active or failed package in the current server inventory without installing it.",
"summary": "Check plugin package",
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"target": {
"type": "string"
}
},
"required": ["target"],
"additionalProperties": false
}
}
},
"required": true
}
}
},
"/api/session": {
"get": {
"tags": ["session"],
@@ -16307,6 +16424,9 @@
"source": {
"$ref": "#/components/schemas/Plugin.Source"
},
"revision": {
"type": "string"
},
"status": {
"type": "string",
"enum": ["active"]
@@ -16327,6 +16447,9 @@
"source": {
"$ref": "#/components/schemas/Plugin.Source"
},
"revision": {
"type": "string"
},
"status": {
"type": "string",
"enum": ["failed"]
@@ -16343,6 +16466,22 @@
}
]
},
"Plugin.PackageStatus": {
"type": "object",
"properties": {
"installed": {
"type": "string"
},
"available": {
"type": "string"
},
"mutable": {
"type": "boolean"
}
},
"required": ["mutable"],
"additionalProperties": false
},
"Plugin.Source": {
"anyOf": [
{
@@ -16397,6 +16536,20 @@
}
]
},
"PluginCheckErrorEncoded": {
"type": "object",
"properties": {
"_tag": {
"type": "string",
"enum": ["PluginCheckError"]
},
"message": {
"type": "string"
}
},
"required": ["_tag", "message"],
"additionalProperties": false
},
"Project": {
"type": "object",
"properties": {
+153
View File
@@ -486,6 +486,123 @@
"summary": "List plugins"
}
},
"/api/plugin/check": {
"post": {
"tags": ["plugin"],
"operationId": "v2.plugin.check",
"parameters": [
{
"name": "location",
"in": "query",
"schema": {
"anyOf": [
{
"type": "object",
"properties": {
"directory": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"workspace": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
}
},
"additionalProperties": false
},
{
"type": "null"
}
]
},
"required": false,
"style": "deepObject",
"explode": true
}
],
"security": [],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"location": {
"$ref": "#/components/schemas/Location.InfoEncoded"
},
"data": {
"$ref": "#/components/schemas/Plugin.PackageStatus"
}
},
"required": ["location", "data"],
"additionalProperties": false
}
}
}
},
"400": {
"description": "PluginCheckError | InvalidRequestError",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/PluginCheckErrorEncoded"
},
{
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
]
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
}
}
}
},
"description": "Inspect installed and available revisions of an active or failed package in the current server inventory without installing it.",
"summary": "Check plugin package",
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"target": {
"type": "string"
}
},
"required": ["target"],
"additionalProperties": false
}
}
},
"required": true
}
}
},
"/api/session": {
"get": {
"tags": ["session"],
@@ -16307,6 +16424,9 @@
"source": {
"$ref": "#/components/schemas/Plugin.Source"
},
"revision": {
"type": "string"
},
"status": {
"type": "string",
"enum": ["active"]
@@ -16327,6 +16447,9 @@
"source": {
"$ref": "#/components/schemas/Plugin.Source"
},
"revision": {
"type": "string"
},
"status": {
"type": "string",
"enum": ["failed"]
@@ -16343,6 +16466,22 @@
}
]
},
"Plugin.PackageStatus": {
"type": "object",
"properties": {
"installed": {
"type": "string"
},
"available": {
"type": "string"
},
"mutable": {
"type": "boolean"
}
},
"required": ["mutable"],
"additionalProperties": false
},
"Plugin.Source": {
"anyOf": [
{
@@ -16397,6 +16536,20 @@
}
]
},
"PluginCheckErrorEncoded": {
"type": "object",
"properties": {
"_tag": {
"type": "string",
"enum": ["PluginCheckError"]
},
"message": {
"type": "string"
}
},
"required": ["_tag", "message"],
"additionalProperties": false
},
"Project": {
"type": "object",
"properties": {
@@ -6,6 +6,46 @@ Plugins configured in `opencode.json(c)` that expose a TUI component are loaded
to build plugins, see [Building plugins](/build/plugins). You do not need to add the same package to `cli.json`. The CLI
gets the active plugin list from the connected OpenCode server, so this also works when the server is remote.
## Inspect plugins
Open **/plugins**, select a row, and press **Enter** to view its runtime, status, source, and scope. **Space** still enables
or disables a TUI plugin in the current terminal.
For package plugins, select **Check for updates**, or press **Ctrl+R** on the selected row. Checks are explicit; OpenCode
does not poll for plugin updates in the background. Details distinguish the **Loaded** revision captured when the code was
loaded, the **Installed** revision currently on disk, and the **Available** revision allowed by the configured package spec.
A small update marker appears after a check finds a different available revision. Unavailable revision metadata is shown
as unknown rather than inferred from the package spec.
Matching server and TUI package rows share a check on the connected server in the default Location used to discover
server plugins. Packages configured only in the CLI are checked on the TUI machine. Checking does not install packages or replace running code.
Exact npm versions and full Git commit hashes remain pinned; change their configured spec to select another revision.
Built-in plugins update with OpenCode, and local files do not have package update checks.
To apply a newer server package revision, use the existing [restart workflow](/plugins), then close and reopen the TUI to
load its companion. Server package rows and their matching TUI rows support inspection, not hot apply.
## Apply CLI-only updates
For an active package configured only in `cli.json`, open its details and select **Update & Apply**. OpenCode installs the
revision allowed by its configured spec into a fresh dependency graph, then uses the existing TUI cleanup/setup lifecycle
to apply it in the current terminal. Neither the terminal nor the connected server needs to restart.
The new graph is selected in a separate TUI cache after successful setup. Newly opened terminals use that selection;
already-running terminals apply their updates independently. Updating does not overwrite files in the server's package
cache or in previously loaded TUI graphs. If importing or setting up a candidate fails, its cache selection is not
published, and the existing lifecycle retains or restores the previous plugin when possible.
This action is limited to active CLI-only package plugins. Disabled plugins, local files, built-ins, exact pins, and
packages in the connected server's inventory do not offer hot apply. Enable a disabled plugin before updating it, and
change an exact pin's configured spec to select another revision. Server ownership must be verified before downloading
an update; reconnect if that verification is unavailable.
Applying code reruns the plugin's setup. Plugin-local state such as drafts and connections can reset unless the plugin
preserves it. Updates are explicit and apply only to the selected plugin; they do not enable unrelated disabled plugins.
## CLI-only plugins
Use `cli.json` for CLI-only plugins. These plugins run locally in the terminal and remain active when the CLI connects
to a remote server: