mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-31 14:06:21 +00:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
32cefb3688 | ||
|
|
d09b3540fe | ||
|
|
e9140c8593 | ||
|
|
ac0fcbb042 | ||
|
|
0a02102ab3 | ||
|
|
52e3a3f3c7 | ||
|
|
2d7abb8437 | ||
|
|
1914657dd7 | ||
|
|
74f83cb226 | ||
|
|
ee62824e4f |
@@ -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=="],
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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)),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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>()(
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
@@ -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))),
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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") {}
|
||||
|
||||
@@ -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>,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -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) }))))
|
||||
}),
|
||||
)
|
||||
@@ -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 }),
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -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* () {
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
@@ -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(
|
||||
|
||||
@@ -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++
|
||||
}),
|
||||
|
||||
@@ -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)
|
||||
}),
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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: () => {},
|
||||
|
||||
@@ -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)
|
||||
@@ -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([])
|
||||
})
|
||||
@@ -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: () => {},
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
Reference in New Issue
Block a user