feat(plugin): update plugins from the TUI dialog (#46699)

This commit is contained in:
Dax
2026-09-01 21:41:55 -04:00
committed by GitHub
parent 95d788f8eb
commit 01093db365
16 changed files with 157 additions and 65 deletions
@@ -25,9 +25,12 @@ export default Runtime.handler(
selected,
(item) =>
(item.runtime === "Server"
? Effect.promise(() => result.client.plugin.update({ location: result.location, target: item.target }))
? Effect.promise(() => result.client.plugin.update({ location: result.location, targets: [item.target] }))
: npm.update(item.target, { subpaths: ["tui"] }).pipe(Effect.asVoid)
).pipe(Effect.exit, Effect.map((result) => ({ item, result }))),
).pipe(
Effect.exit,
Effect.map((result) => ({ item, result })),
),
{ concurrency: "unbounded" },
)
for (const item of updated) {
+1 -1
View File
@@ -97,7 +97,7 @@ export type PluginCheckOperation<E = never> = (input?: PluginCheckInput) => Effe
export type PluginUpdateInput = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly target: string
readonly targets: ReadonlyArray<string>
}
export type PluginUpdateOutput = void
export type PluginUpdateOperation<E = never> = (input: PluginUpdateInput) => Effect.Effect<PluginUpdateOutput, E>
@@ -335,7 +335,7 @@ const EndpointPluginCheck = (raw: RawClient["server.plugin"]) => (input?: Plugin
const EndpointPluginUpdate = (raw: RawClient["server.plugin"]) => (input: PluginUpdateInput) =>
preserveEffect<PluginUpdateOutput>()(
raw["plugin.update"]({ query: { location: input["location"] }, payload: { target: input["target"] } }).pipe(
raw["plugin.update"]({ query: { location: input["location"] }, payload: { targets: input["targets"] } }).pipe(
Effect.mapError(mapClientError),
),
)
@@ -489,7 +489,7 @@ export function make(options: ClientOptions) {
method: "POST",
path: `/api/plugin/update`,
query: { location: input["location"] },
body: { target: input["target"] },
body: { targets: input["targets"] },
successStatus: 204,
declaredStatuses: [400, 503, 401],
empty: true,
@@ -12,7 +12,7 @@ export type PermissionEffect = "allow" | "deny" | "ask"
export type PluginSource =
| { type: "builtin" }
| { type: "package"; target: string; version?: string; outdated?: true }
| { type: "package"; target: string; version?: string; outdated?: true; updating?: true }
| { type: "local"; path: string }
| { type: "sdk" }
@@ -2601,7 +2601,7 @@ export type PluginUpdateInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
readonly target: { readonly target: string }["target"]
readonly targets: { readonly targets: ReadonlyArray<string> }["targets"]
}
export type PluginUpdateOutput = void
+12 -2
View File
@@ -106,6 +106,7 @@ export const layer = Layer.effectDiscard(
yield* Effect.addFinalizer(registry.close)
let packages = new Set<string>()
let outdated = new Set<string>()
const updating = new Set<string>()
let generation = 0
let observed = 0
@@ -128,7 +129,13 @@ export const layer = Layer.effectDiscard(
// Activate everything available locally before waiting on missing package installs.
const immediate = yield* resolve(pre, post, operations, false)
const source = (source: Plugin.Source) =>
source.type === "package" && outdated.has(source.target) ? { ...source, outdated: true as const } : source
source.type === "package"
? {
...source,
...(outdated.has(source.target) ? { outdated: true as const } : {}),
...(updating.has(source.target) ? { updating: true as const } : {}),
}
: source
const apply = (resolved: typeof immediate) =>
registry.activate(
resolved.plugins.map((plugin) => (plugin.source ? { ...plugin, source: source(plugin.source) } : plugin)),
@@ -163,7 +170,10 @@ export const layer = Layer.effectDiscard(
updates.changes().pipe(
Stream.filter((update) => packages.has(update.target)),
Stream.tap((update) =>
Effect.sync(() => (update.outdated ? outdated.add(update.target) : outdated.delete(update.target))),
Effect.sync(() => {
update.outdated ? outdated.add(update.target) : outdated.delete(update.target)
update.updating ? updating.add(update.target) : updating.delete(update.target)
}),
),
Stream.map(() => undefined),
),
+27 -8
View File
@@ -1,6 +1,6 @@
export * as PluginUpdate from "./update.js"
import { Clock, Context, Effect, Layer, Option, PubSub, Stream } from "effect"
import { Clock, Context, Effect, Exit, Layer, Option, PubSub, Stream } from "effect"
import { Npm } from "@opencode-ai/util/npm"
import { EffectFlock } from "@opencode-ai/util/effect-flock"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
@@ -8,10 +8,16 @@ import { KeyedMutex } from "../effect/keyed-mutex.js"
const interval = 24 * 60 * 60 * 1_000
interface Change {
readonly target: string
readonly outdated: boolean
readonly updating: boolean
}
export interface Interface {
readonly check: (target: string, options?: { readonly refresh?: boolean }) => Effect.Effect<boolean>
readonly update: (target: string) => Effect.Effect<void, Npm.InstallFailedError | EffectFlock.LockError>
readonly changes: () => Stream.Stream<{ readonly target: string; readonly outdated: boolean }>
readonly changes: () => Stream.Stream<Change>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/PluginUpdate") {}
@@ -22,7 +28,7 @@ const layer = Layer.effect(
const npm = yield* Npm.Service
const locks = KeyedMutex.makeUnsafe<string>()
const status = new Map<string, { readonly outdated: boolean; readonly checkedAt: number }>()
const changes = yield* PubSub.unbounded<{ readonly target: string; readonly outdated: boolean }>()
const changes = yield* PubSub.unbounded<Change>()
return Service.of({
check: (target, options) =>
@@ -37,15 +43,28 @@ const layer = Layer.effect(
)
const value = Option.getOrElse(outdated, () => current?.outdated ?? false)
status.set(target, { outdated: value, checkedAt })
if ((current?.outdated ?? false) !== value) yield* PubSub.publish(changes, { target, outdated: value })
if ((current?.outdated ?? false) !== value)
yield* PubSub.publish(changes, { target, outdated: value, updating: false })
return value
}),
),
// Serialize per target so a concurrent update cannot clear the in-progress flag of another still running.
update: (target) =>
npm.update(target).pipe(
Effect.tap(() => Effect.sync(() => status.delete(target))),
Effect.tap(() => PubSub.publish(changes, { target, outdated: false })),
Effect.asVoid,
locks.withLock(target)(
Effect.gen(function* () {
const outdated = status.get(target)?.outdated ?? false
yield* PubSub.publish(changes, { target, outdated, updating: true })
yield* npm.update(target).pipe(
Effect.tap(() => Effect.sync(() => status.delete(target))),
Effect.onExit((exit) =>
PubSub.publish(changes, {
target,
outdated: Exit.isSuccess(exit) ? false : outdated,
updating: false,
}),
),
)
}),
),
changes: () => Stream.fromPubSub(changes),
})
+12 -5
View File
@@ -701,19 +701,22 @@
}
}
},
"description": "Update one package plugin and notify active locations to reload it.",
"summary": "Update plugin",
"description": "Update package plugins concurrently and notify active locations to reload them. Responds once every update has finished; fails when any update fails.",
"summary": "Update plugins",
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"target": {
"type": "string"
"targets": {
"type": "array",
"items": {
"type": "string"
}
}
},
"required": ["target"],
"required": ["targets"],
"additionalProperties": false
}
}
@@ -16844,6 +16847,10 @@
"outdated": {
"type": "boolean",
"enum": [true]
},
"updating": {
"type": "boolean",
"enum": [true]
}
},
"required": ["type", "target"],
+4 -3
View File
@@ -39,7 +39,7 @@ export const PluginGroup = HttpApiGroup.make("server.plugin")
.add(
HttpApiEndpoint.post("plugin.update", "/api/plugin/update", {
query: LocationQuery,
payload: Schema.Struct({ target: Schema.String }),
payload: Schema.Struct({ targets: Schema.Array(Schema.String) }),
success: HttpApiSchema.NoContent,
error: [InvalidRequestError, ServiceUnavailableError],
})
@@ -47,8 +47,9 @@ export const PluginGroup = HttpApiGroup.make("server.plugin")
.annotateMerge(
OpenApi.annotations({
identifier: "v2.plugin.update",
summary: "Update plugin",
description: "Update one package plugin and notify active locations to reload it.",
summary: "Update plugins",
description:
"Update package plugins concurrently and notify active locations to reload them. Responds once every update has finished; fails when any update fails.",
}),
),
)
+1
View File
@@ -14,6 +14,7 @@ export const Source = Schema.Union([
target: Schema.String,
version: Schema.String.pipe(optional),
outdated: Schema.Literal(true).pipe(optional),
updating: Schema.Literal(true).pipe(optional),
}),
Schema.Struct({ type: Schema.Literal("local"), path: Schema.String }),
Schema.Struct({ type: Schema.Literal("sdk") }),
+22 -16
View File
@@ -1,7 +1,7 @@
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginUpdate } from "@opencode-ai/core/plugin/update"
import { InvalidRequestError, ServiceUnavailableError } from "@opencode-ai/protocol/errors"
import { Cause, Effect } from "effect"
import { Cause, Effect, Exit } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { Api } from "../api"
import { response } from "../location"
@@ -45,6 +45,7 @@ export const PluginHandler = HttpApiBuilder.group(Api, "server.plugin", (handler
target: plugin.source.target,
...(plugin.source.version ? { version: plugin.source.version } : {}),
...(outdated.get(plugin.source.target) ? { outdated: true as const } : {}),
...(plugin.source.updating ? { updating: true as const } : {}),
},
}
}),
@@ -56,26 +57,31 @@ export const PluginHandler = HttpApiBuilder.group(Api, "server.plugin", (handler
Effect.gen(function* () {
const plugins = yield* Plugin.Service
yield* plugins.awaitActivation
if (
!(yield* plugins.list()).some(
(plugin) => plugin.source.type === "package" && plugin.source.target === ctx.payload.target,
)
const inventory = new Set(
(yield* plugins.list()).flatMap((plugin) => (plugin.source.type === "package" ? [plugin.source.target] : [])),
)
const unknown = ctx.payload.targets.filter((target) => !inventory.has(target))
if (unknown.length)
return yield* new InvalidRequestError({
message: `Plugin package is not in the current server inventory: ${ctx.payload.target}`,
field: "target",
message: `Plugin packages are not in the current server inventory: ${unknown.join(", ")}`,
field: "targets",
})
const updates = yield* PluginUpdate.Service
yield* updates.update(ctx.payload.target).pipe(
Effect.catchCause((cause) =>
Effect.fail(
new ServiceUnavailableError({
message: `Failed to update plugin package ${ctx.payload.target}: ${Cause.pretty(cause)}`,
service: "plugin",
}),
// Let every update run to completion instead of interrupting the rest on the first failure.
const failures = yield* Effect.forEach(
ctx.payload.targets,
(target) =>
updates.update(target).pipe(
Effect.exit,
Effect.map((exit) => (Exit.isFailure(exit) ? [`${target}: ${Cause.pretty(exit.cause)}`] : [])),
),
),
)
{ concurrency: "unbounded" },
).pipe(Effect.map((results) => results.flat()))
if (failures.length)
return yield* new ServiceUnavailableError({
message: `Failed to update plugin packages: ${failures.join("; ")}`,
service: "plugin",
})
}),
),
)
+1
View File
@@ -271,6 +271,7 @@ export const Definitions = {
"plugins.toggle": keybind("return", "Toggle plugin"),
"dialog.mcp.toggle": keybind("space", "Toggle MCP server"),
"dialog.plugins.install": keybind("shift+i", "Install plugin from plugin dialog"),
"dialog.plugins.update": keybind("ctrl+u", "Update plugin from plugin dialog"),
"terminal.suspend": keybind("ctrl+z", "Suspend terminal"),
"terminal.title.toggle": keybind("none", "Toggle terminal title"),
@@ -1,6 +1,6 @@
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 { 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"
@@ -30,11 +30,12 @@ export function PluginsDialog(props: {
const [focused, setFocused] = createSignal<string>()
const [detail, setDetail] = createSignal<Entry>()
const [showInternal, setShowInternal] = createSignal(false)
const [server] = createResource(
const [server, { refetch }] = createResource(
() => (props.server ? undefined : (props.context.location ?? 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()))
const entries = createMemo<Entry[]>(() => {
const builtins: Entry[] = props.plugins
.registered()
@@ -127,6 +128,23 @@ export function PluginsDialog(props: {
})
.finally(() => setLocked(false))
}
const update = (entry: Entry | undefined) => {
if (entry?.runtime !== "server" || entry.plugin.source.type !== "package" || !updatable(entry)) return
props.context.client.plugin
.update({
location: props.context.location ?? props.context.data.location.default(),
targets: [entry.plugin.source.target],
})
.then(() =>
props.context.ui.toast.show({ variant: "success", message: `Updated plugin ${label(entry, props.context)}` }),
)
.catch((cause) => {
props.context.ui.toast.show({
variant: "error",
message: cause instanceof Error ? cause.message : String(cause),
})
})
}
return (
<box>
@@ -160,17 +178,20 @@ export function PluginsDialog(props: {
return toggle(entry)
if (pluginError(entry)) setDetail(entry)
}}
actions={
focusedTui()
? [
{
title: toggleTitle(),
command: "plugins.toggle",
onTrigger: (option) => toggle(entries().find((entry) => entry.key === option.value)),
},
]
: []
}
actions={[
{
title: toggleTitle(),
command: "plugins.toggle",
hidden: !focusedTui(),
onTrigger: (option) => toggle(entries().find((entry) => entry.key === option.value)),
},
{
title: "update",
command: "dialog.plugins.update",
hidden: !updatable(focusedEntry()),
onTrigger: (option) => update(entries().find((entry) => entry.key === option.value)),
},
]}
footer={
<Show when={pluginError(focusedEntry())}>
<text>
@@ -227,13 +248,21 @@ function outdated(entry: Entry) {
return entry.runtime === "server" && entry.plugin.source.type === "package" && entry.plugin.source.outdated === true
}
function updating(entry: Entry) {
return entry.runtime === "server" && entry.plugin.source.type === "package" && entry.plugin.source.updating === true
}
function updatable(entry: Entry | undefined) {
return entry !== undefined && outdated(entry) && !updating(entry)
}
function footer(entry: Entry) {
const details = [
...(status(entry) === "active" ? [] : [status(entry)]),
...(entry.runtime === "server" && entry.plugin.source.type === "package" && entry.plugin.source.version
? [displayVersion(entry.plugin.source.version)]
: []),
...(outdated(entry) ? ["update available"] : []),
...(updating(entry) ? ["updating"] : outdated(entry) ? ["update available"] : []),
]
return details.length ? details.join(", ") : undefined
}
+12 -5
View File
@@ -701,19 +701,22 @@
}
}
},
"description": "Update one package plugin and notify active locations to reload it.",
"summary": "Update plugin",
"description": "Update package plugins concurrently and notify active locations to reload them. Responds once every update has finished; fails when any update fails.",
"summary": "Update plugins",
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"target": {
"type": "string"
"targets": {
"type": "array",
"items": {
"type": "string"
}
}
},
"required": ["target"],
"required": ["targets"],
"additionalProperties": false
}
}
@@ -16844,6 +16847,10 @@
"outdated": {
"type": "boolean",
"enum": [true]
},
"updating": {
"type": "boolean",
"enum": [true]
}
},
"required": ["type", "target"],
+12 -5
View File
@@ -701,19 +701,22 @@
}
}
},
"description": "Update one package plugin and notify active locations to reload it.",
"summary": "Update plugin",
"description": "Update package plugins concurrently and notify active locations to reload them. Responds once every update has finished; fails when any update fails.",
"summary": "Update plugins",
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"target": {
"type": "string"
"targets": {
"type": "array",
"items": {
"type": "string"
}
}
},
"required": ["target"],
"required": ["targets"],
"additionalProperties": false
}
}
@@ -16844,6 +16847,10 @@
"outdated": {
"type": "boolean",
"enum": [true]
},
"updating": {
"type": "boolean",
"enum": [true]
}
},
"required": ["type", "target"],
@@ -344,6 +344,7 @@ The retired `diff.toggle`, `diff.expand`, `diff.expand_all`, `diff.collapse`, an
| `plugins.toggle` | `space` | Toggle plugin |
| `dialog.mcp.toggle` | `space` | Toggle MCP server |
| `dialog.plugins.install` | `shift+i` | Install plugin from plugin dialog |
| `dialog.plugins.update` | `ctrl+u` | Update plugin from plugin dialog |
## Terminal And Plugins