mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-27 12:06:22 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
844125a92c | ||
|
|
cec3dda4a4 |
@@ -71,7 +71,6 @@ jobs:
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
@@ -111,16 +110,9 @@ jobs:
|
||||
|
||||
- name: Run unit tests
|
||||
timeout-minutes: 20
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
GITHUB_ACTIONS=false bun turbo test
|
||||
exit 0
|
||||
fi
|
||||
GITHUB_ACTIONS=false bun turbo test --affected
|
||||
run: GITHUB_ACTIONS=false bun turbo test
|
||||
env:
|
||||
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}
|
||||
TURBO_SCM_BASE: ${{ github.event.pull_request.base.sha || github.event.before }}
|
||||
TURBO_SCM_HEAD: ${{ github.sha }}
|
||||
|
||||
- name: Verify published codemode package
|
||||
if: runner.os == 'Linux'
|
||||
@@ -130,15 +122,8 @@ jobs:
|
||||
- name: Verify packed workerd SDK
|
||||
if: runner.os == 'Linux'
|
||||
timeout-minutes: 15
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
bun turbo verify:package --filter=@opencode-ai/sdk
|
||||
exit 0
|
||||
fi
|
||||
bun turbo verify:package --affected --filter=@opencode-ai/sdk
|
||||
env:
|
||||
TURBO_SCM_BASE: ${{ github.event.pull_request.base.sha || github.event.before }}
|
||||
TURBO_SCM_HEAD: ${{ github.sha }}
|
||||
working-directory: packages/sdk
|
||||
run: bun run verify:package
|
||||
|
||||
- name: Verify compiled service lifecycle
|
||||
if: always()
|
||||
|
||||
@@ -972,7 +972,6 @@
|
||||
"dependencies": {
|
||||
"@effect/opentelemetry": "catalog:",
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@effect/platform-node-shared": "catalog:",
|
||||
"@npmcli/arborist": "catalog:",
|
||||
"@npmcli/config": "10.8.1",
|
||||
"@opentelemetry/api": "1.9.0",
|
||||
@@ -986,7 +985,6 @@
|
||||
"mime-types": "3.0.2",
|
||||
"minimatch": "10.2.5",
|
||||
"npm-package-arg": "13.0.2",
|
||||
"pacote": "21.5.1",
|
||||
"resolve.exports": "catalog:",
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -996,7 +994,6 @@
|
||||
"@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:",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -92,15 +92,6 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
initialOffset: () => (input.pinned() ? Number.MAX_SAFE_INTEGER : 0),
|
||||
initialMeasurementsCache: initialMeasurements,
|
||||
estimateSize: () => fallbackItemSize,
|
||||
// Do not replace this with TanStack's default measurer: without a ResizeObserver entry,
|
||||
// it returns the cached height instead of reading the element (TanStack/virtual#1183).
|
||||
// Restored sessions, deferred tools, and rewrapped content can then keep stale heights;
|
||||
// our fixed-height, overflow-clipped rows will hide their content. Keep observer entries
|
||||
// on the cheap precomputed path, but make explicit measurements read the real height.
|
||||
measureElement: (element, entry) => {
|
||||
const box = entry?.borderBoxSize[0]
|
||||
return box ? Math.round(box.blockSize) : element.offsetHeight
|
||||
},
|
||||
scrollToFn: (offset, options, instance) => {
|
||||
if (virtualContent) virtualContent.style.height = `${instance.getTotalSize()}px`
|
||||
elementScroll(offset, options, instance)
|
||||
|
||||
@@ -6,7 +6,6 @@ import { LocalProvider } from "@/providers/models/selection"
|
||||
import type { ServerConnection } from "@/runtime/server/registry"
|
||||
import { sessionHref } from "@/shell/routes/session"
|
||||
import { useData } from "@/runtime/server/current"
|
||||
import { useServerSDK } from "@/runtime/server/client"
|
||||
import { useTabs } from "@/shell/tabs/tabs"
|
||||
|
||||
export function SessionUIProvider(
|
||||
@@ -18,7 +17,6 @@ export function SessionUIProvider(
|
||||
const navigate = useNavigate()
|
||||
const params = useParams()
|
||||
const data = useData()
|
||||
const serverSDK = useServerSDK()
|
||||
const tabs = useTabs()
|
||||
const directory = () => props.directory
|
||||
const href = (sessionID: string) => sessionHref(props.server, sessionID)
|
||||
@@ -55,7 +53,6 @@ export function SessionUIProvider(
|
||||
data={sessionUIData()}
|
||||
directory={directory()}
|
||||
sessionID={params.id}
|
||||
shellOutput={(input) => serverSDK.api.shell.output(input)}
|
||||
onNavigateToSession={navigateToSession}
|
||||
onSessionHref={href}
|
||||
>
|
||||
|
||||
@@ -113,32 +113,6 @@ test("reactive count updates preserve measured row sizes", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("explicit measurement refreshes a cached row size with a custom measurer", () => {
|
||||
const root = document.createElement("div")
|
||||
const element = document.createElement("div")
|
||||
element.dataset.index = "0"
|
||||
Object.defineProperty(element, "offsetHeight", { value: 120 })
|
||||
|
||||
const virtualizer = new Virtualizer<HTMLDivElement, HTMLDivElement>({
|
||||
count: 1,
|
||||
estimateSize: () => 60,
|
||||
initialRect: { width: 400, height: 200 },
|
||||
getScrollElement: () => root,
|
||||
scrollToFn: () => {},
|
||||
observeElementRect: () => {},
|
||||
observeElementOffset: () => {},
|
||||
measureElement: (node) => node.offsetHeight,
|
||||
})
|
||||
|
||||
virtualizer.getTotalSize()
|
||||
virtualizer.resizeItem(0, 60)
|
||||
virtualizer._willUpdate()
|
||||
virtualizer.measureElement(element)
|
||||
|
||||
expect(virtualizer.itemSizeCache.get(0)).toBe(120)
|
||||
expect(virtualizer.getTotalSize()).toBe(120)
|
||||
})
|
||||
|
||||
test("initial rect projects rows before a scroll element connects", () => {
|
||||
createRoot((dispose) => {
|
||||
const virtualizer = createVirtualizer<HTMLDivElement, HTMLDivElement>({
|
||||
|
||||
@@ -181,7 +181,7 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
|
||||
Spec.make("add", {
|
||||
description: "Install a plugin and add it to the global configuration",
|
||||
params: {
|
||||
package: Argument.string("package").pipe(Argument.withDescription("npm registry or Git package specifier")),
|
||||
package: Argument.string("package").pipe(Argument.withDescription("npm registry package specifier")),
|
||||
},
|
||||
}),
|
||||
Spec.make("remove", {
|
||||
|
||||
@@ -13,8 +13,10 @@ import { Config } from "../../../config"
|
||||
export default Runtime.handler(
|
||||
Commands.commands.plugin.commands.add,
|
||||
Effect.fn("cli.plugin.add")(function* (input) {
|
||||
if (!(yield* Effect.promise(() => Npm.isInstallablePackage(input.package))))
|
||||
return yield* Effect.fail(new Error("Plugin target must be an npm registry package or Git package specifier"))
|
||||
if (!(yield* Effect.promise(() => Npm.isRegistryPackage(input.package))))
|
||||
return yield* Effect.fail(
|
||||
new Error("Plugin target must be an npm registry package name, version, tag, or semver range"),
|
||||
)
|
||||
const npm = yield* Npm.Service
|
||||
const installed = yield* npm.add(input.package, { subpaths: ["server", ""] })
|
||||
const tui = yield* npm.resolve(input.package, { subpaths: ["tui"] })
|
||||
|
||||
@@ -88,35 +88,8 @@ 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
|
||||
}
|
||||
export type PluginCheckOutput = { readonly location: Location.Info; readonly data: ReadonlyArray<Plugin.UpdateInfo> }
|
||||
export type PluginCheckOperation<E = never> = (input?: PluginCheckInput) => Effect.Effect<PluginCheckOutput, E>
|
||||
|
||||
export type PluginUpdateInput = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
readonly name: string
|
||||
}
|
||||
export type PluginUpdateOutput = { readonly location: Location.Info; readonly data: Plugin.UpdateResult }
|
||||
export type PluginUpdateOperation<E = never> = (input: PluginUpdateInput) => Effect.Effect<PluginUpdateOutput, E>
|
||||
|
||||
export type PluginUpdateAllInput = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}
|
||||
export type PluginUpdateAllOutput = {
|
||||
readonly location: Location.Info
|
||||
readonly data: ReadonlyArray<Plugin.UpdateResult>
|
||||
}
|
||||
export type PluginUpdateAllOperation<E = never> = (
|
||||
input?: PluginUpdateAllInput,
|
||||
) => Effect.Effect<PluginUpdateAllOutput, E>
|
||||
|
||||
export interface PluginApi<E = never> {
|
||||
readonly list: PluginListOperation<E>
|
||||
readonly check: PluginCheckOperation<E>
|
||||
readonly update: PluginUpdateOperation<E>
|
||||
readonly updateAll: PluginUpdateAllOperation<E>
|
||||
}
|
||||
|
||||
export type SessionListInput = {
|
||||
|
||||
@@ -15,12 +15,6 @@ import type {
|
||||
AgentGetOutput,
|
||||
PluginListInput,
|
||||
PluginListOutput,
|
||||
PluginCheckInput,
|
||||
PluginCheckOutput,
|
||||
PluginUpdateInput,
|
||||
PluginUpdateOutput,
|
||||
PluginUpdateAllInput,
|
||||
PluginUpdateAllOutput,
|
||||
SessionListInput,
|
||||
SessionListOutput,
|
||||
SessionStatsInput,
|
||||
@@ -319,29 +313,7 @@ const EndpointPluginList = (raw: RawClient["server.plugin"]) => (input?: PluginL
|
||||
raw["plugin.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const EndpointPluginCheck = (raw: RawClient["server.plugin"]) => (input?: PluginCheckInput) =>
|
||||
preserveEffect<PluginCheckOutput>()(
|
||||
raw["plugin.check"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const EndpointPluginUpdate = (raw: RawClient["server.plugin"]) => (input: PluginUpdateInput) =>
|
||||
preserveEffect<PluginUpdateOutput>()(
|
||||
raw["plugin.update"]({ query: { location: input["location"] }, payload: { name: input["name"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
),
|
||||
)
|
||||
|
||||
const EndpointPluginUpdateAll = (raw: RawClient["server.plugin"]) => (input?: PluginUpdateAllInput) =>
|
||||
preserveEffect<PluginUpdateAllOutput>()(
|
||||
raw["plugin.updateAll"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const adaptGroupPlugin = (raw: RawClient["server.plugin"]) => ({
|
||||
list: EndpointPluginList(raw),
|
||||
check: EndpointPluginCheck(raw),
|
||||
update: EndpointPluginUpdate(raw),
|
||||
updateAll: EndpointPluginUpdateAll(raw),
|
||||
})
|
||||
const adaptGroupPlugin = (raw: RawClient["server.plugin"]) => ({ list: EndpointPluginList(raw) })
|
||||
|
||||
const EndpointSessionList = (raw: RawClient["server.session"]) => (input?: SessionListInput) =>
|
||||
preserveEffect<SessionListOutput>()(
|
||||
|
||||
@@ -9,12 +9,6 @@ import type {
|
||||
AgentGetOutput,
|
||||
PluginListInput,
|
||||
PluginListOutput,
|
||||
PluginCheckInput,
|
||||
PluginCheckOutput,
|
||||
PluginUpdateInput,
|
||||
PluginUpdateOutput,
|
||||
PluginUpdateAllInput,
|
||||
PluginUpdateAllOutput,
|
||||
SessionListInput,
|
||||
SessionListOutput,
|
||||
SessionStatsInput,
|
||||
@@ -463,43 +457,6 @@ export function make(options: ClientOptions) {
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
check: (input?: PluginCheckInput, requestOptions?: RequestOptions) =>
|
||||
request<PluginCheckOutput>(
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/plugin/update`,
|
||||
query: { location: input?.["location"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [401, 400],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
update: (input: PluginUpdateInput, requestOptions?: RequestOptions) =>
|
||||
request<PluginUpdateOutput>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/plugin/update`,
|
||||
query: { location: input["location"] },
|
||||
body: { name: input["name"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [401, 400],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
updateAll: (input?: PluginUpdateAllInput, requestOptions?: RequestOptions) =>
|
||||
request<PluginUpdateAllOutput>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/plugin/update-all`,
|
||||
query: { location: input?.["location"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [401, 400],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
},
|
||||
session: {
|
||||
list: (input?: SessionListInput, requestOptions?: RequestOptions) =>
|
||||
|
||||
@@ -426,24 +426,6 @@ export type PluginInfo =
|
||||
| { id: string; source: PluginSource; status: "active"; tui: boolean }
|
||||
| { id?: string; source: PluginSource; status: "failed"; error: string; tui: boolean }
|
||||
|
||||
export type PluginUpdateInfo = {
|
||||
name: string
|
||||
source: PluginSource
|
||||
status: "not-updateable" | "pinned" | "up-to-date" | "available" | "failed"
|
||||
currentVersion?: string
|
||||
latestVersion?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
export type PluginUpdateResult = {
|
||||
name: string
|
||||
source: PluginSource
|
||||
status: "not-updateable" | "pinned" | "up-to-date" | "updated" | "failed"
|
||||
previousVersion?: string
|
||||
version?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
export type SessionMessageLocationSwitched = {
|
||||
id: string
|
||||
metadata?: { [x: string]: JsonValue }
|
||||
@@ -2478,40 +2460,6 @@ export type PluginListOutput = {
|
||||
data: Array<PluginInfo>
|
||||
}
|
||||
|
||||
export type PluginCheckInput = {
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}["location"]
|
||||
}
|
||||
|
||||
export type PluginCheckOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||
data: Array<PluginUpdateInfo>
|
||||
}
|
||||
|
||||
export type PluginUpdateInput = {
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}["location"]
|
||||
readonly name: { readonly name: string }["name"]
|
||||
}
|
||||
|
||||
export type PluginUpdateOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||
data: PluginUpdateResult
|
||||
}
|
||||
|
||||
export type PluginUpdateAllInput = {
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}["location"]
|
||||
}
|
||||
|
||||
export type PluginUpdateAllOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||
data: Array<PluginUpdateResult>
|
||||
}
|
||||
|
||||
export type SessionListInput = {
|
||||
readonly workspace?: {
|
||||
readonly workspace?: string | undefined
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export * as Plugin from "./plugin.js"
|
||||
export { Event, ID, Info, Source, UpdateInfo, UpdateResult } from "@opencode-ai/schema/plugin"
|
||||
export { Event, ID, Info, Source } from "@opencode-ai/schema/plugin"
|
||||
|
||||
import { Plugin } from "@opencode-ai/schema/plugin"
|
||||
import type { Plugin as PluginDefinition } from "@opencode-ai/plugin/effect/plugin"
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
export * as PluginSupervisor from "./supervisor-service.js"
|
||||
|
||||
import { Context, Effect } from "effect"
|
||||
import { Plugin } from "@opencode-ai/schema/plugin"
|
||||
|
||||
/**
|
||||
* Dependency-only supervisor seam. Keep this module free of implementation
|
||||
@@ -10,15 +9,6 @@ import { Plugin } from "@opencode-ai/schema/plugin"
|
||||
export interface Interface {
|
||||
/** Wait for the initial plugin generation and startup updates to settle. */
|
||||
readonly flush: Effect.Effect<void>
|
||||
readonly check: () => Effect.Effect<Plugin.UpdateInfo[]>
|
||||
readonly update: (name: string) => Effect.Effect<Plugin.UpdateResult>
|
||||
readonly updateAll: () => Effect.Effect<Plugin.UpdateResult[]>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/PluginSupervisor") {}
|
||||
|
||||
export const noUpdates = {
|
||||
check: () => Effect.succeed([]),
|
||||
update: () => Effect.die("Plugin updates unavailable"),
|
||||
updateAll: () => Effect.succeed([]),
|
||||
} satisfies Omit<Interface, "flush">
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
export * as PluginSupervisor from "./supervisor.js"
|
||||
export { Service, type Interface, noUpdates } from "./supervisor-service.js"
|
||||
export { Service, type Interface } from "./supervisor-service.js"
|
||||
|
||||
import type { Plugin as PluginDefinition } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Event } from "@opencode-ai/schema/config"
|
||||
import { Cause, Effect, Latch, Layer, Schema, Semaphore, Stream } from "effect"
|
||||
import { Cause, Effect, Latch, Layer, Schema, Stream } from "effect"
|
||||
import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
import { ConfigPluginSource } from "../config/plugin/source.js"
|
||||
@@ -106,30 +106,25 @@ const load = Effect.fn("PluginSupervisor.load")(function* (
|
||||
operation: Extract<ConfigPluginSource.Operation, { type: "add" }>,
|
||||
) {
|
||||
const npm = yield* Npm.Service
|
||||
const local = path.isAbsolute(operation.target)
|
||||
const installed = local
|
||||
? { entrypoint: pathToFileURL(operation.target).href, revision: operation.mtime?.toString() }
|
||||
: yield* npm.add(operation.target, { subpaths: ["server", ""] })
|
||||
const entrypoint = installed.entrypoint
|
||||
const entrypoint = path.isAbsolute(operation.target)
|
||||
? pathToFileURL(operation.target).href
|
||||
: (yield* npm.add(operation.target, { subpaths: ["server", ""] })).entrypoint
|
||||
if (!entrypoint) return yield* Effect.fail(new Error(`Plugin entrypoint not found: ${operation.target}`))
|
||||
// Bun currently ignores query parameters when caching file:// imports.
|
||||
const source = local
|
||||
? operation.mtime === undefined
|
||||
const source =
|
||||
operation.mtime === undefined
|
||||
? entrypoint
|
||||
: typeof Bun !== "undefined"
|
||||
? `${operation.target.replaceAll("\\", "/")}?mtime=${operation.mtime}`
|
||||
: `${entrypoint}?mtime=${operation.mtime}`
|
||||
: installed.revision
|
||||
? `${entrypoint}?revision=${encodeURIComponent(installed.revision)}`
|
||||
: entrypoint
|
||||
yield* Effect.log({ msg: "loading plugin", local })
|
||||
yield* Effect.log({ msg: "loading plugin", id: operation.target, entrypoint: source })
|
||||
const mod = yield* Effect.promise(() => importModule(source))
|
||||
const value = (yield* Schema.decodeUnknownEffect(PluginModule)(mod)).default
|
||||
const plugin = "effect" in value ? value : PluginPromise.fromPromise(value)
|
||||
return {
|
||||
id: plugin.id,
|
||||
tui: plugin.tui,
|
||||
version: `${JSON.stringify(operation)}:${installed.revision ?? ""}`,
|
||||
version: JSON.stringify(operation),
|
||||
source: pluginSource(operation.target),
|
||||
effect: (host) => plugin.effect({ ...host, options: operation.options }),
|
||||
} satisfies Plugin.Versioned
|
||||
@@ -139,16 +134,15 @@ export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* Plugin.Service
|
||||
const npm = yield* Npm.Service
|
||||
const sdk = yield* SdkPlugins.Service
|
||||
const sources = yield* ConfigPluginSource.Service
|
||||
const bus = yield* Bus.Service
|
||||
const ready = yield* Latch.make()
|
||||
const activationLock = Semaphore.makeUnsafe(1)
|
||||
const internal = yield* PluginInternal.list()
|
||||
let observed = 0
|
||||
|
||||
const activate = Effect.fn("PluginSupervisor.activate")(function* () {
|
||||
// Resolve OpenCode's internal plugins with their privileged Location services.
|
||||
const internal = yield* PluginInternal.list()
|
||||
// Combine internal plugins with host-contributed SDK plugins in boot order.
|
||||
const pre = [
|
||||
...internal.pre.map((plugin) => ({ ...plugin, version: "internal", source: { type: "builtin" as const } })),
|
||||
@@ -165,73 +159,6 @@ export const layer = Layer.effect(
|
||||
// Replace the active generation in one scoped, batched activation.
|
||||
yield* registry.activate(resolved.plugins, resolved.failures)
|
||||
})
|
||||
const checkOne = Effect.fn("PluginSupervisor.checkOne")(function* (info: Plugin.Info) {
|
||||
const name = pluginName(info)
|
||||
if (info.source.type !== "package") {
|
||||
return { name, source: info.source, status: "not-updateable" } satisfies Plugin.UpdateInfo
|
||||
}
|
||||
const source = info.source
|
||||
return yield* npm.check(source.package).pipe(
|
||||
Effect.map(
|
||||
(update): Plugin.UpdateInfo => ({
|
||||
name,
|
||||
source: info.source,
|
||||
status: update.pinned
|
||||
? "pinned"
|
||||
: !update.updateable
|
||||
? "not-updateable"
|
||||
: update.updateAvailable
|
||||
? "available"
|
||||
: "up-to-date",
|
||||
currentVersion: update.currentVersion,
|
||||
latestVersion: update.latestVersion,
|
||||
}),
|
||||
),
|
||||
Effect.catchCause(() =>
|
||||
Effect.succeed({
|
||||
name,
|
||||
source: info.source,
|
||||
status: "failed",
|
||||
error: "Failed to check plugin update",
|
||||
} satisfies Plugin.UpdateInfo),
|
||||
),
|
||||
)
|
||||
})
|
||||
const check = Effect.fn("PluginSupervisor.check")(function* () {
|
||||
return yield* Effect.forEach(yield* registry.list(), checkOne, { concurrency: "unbounded" })
|
||||
})
|
||||
const updateOne = Effect.fn("PluginSupervisor.updateOne")(function* (info: Plugin.Info) {
|
||||
const name = pluginName(info)
|
||||
if (info.source.type !== "package") {
|
||||
return { name, source: info.source, status: "not-updateable" } satisfies Plugin.UpdateResult
|
||||
}
|
||||
const source = info.source
|
||||
return yield* npm.update(source.package).pipe(
|
||||
Effect.map(
|
||||
(update): Plugin.UpdateResult => ({
|
||||
name,
|
||||
source: info.source,
|
||||
status: update.pinned
|
||||
? "pinned"
|
||||
: !update.updateable
|
||||
? "not-updateable"
|
||||
: update.updated
|
||||
? "updated"
|
||||
: "up-to-date",
|
||||
previousVersion: update.previousVersion,
|
||||
version: update.latestVersion ?? update.currentVersion,
|
||||
}),
|
||||
),
|
||||
Effect.catchCause(() =>
|
||||
Effect.succeed({
|
||||
name,
|
||||
source: info.source,
|
||||
status: "failed",
|
||||
error: "Failed to update plugin",
|
||||
} satisfies Plugin.UpdateResult),
|
||||
),
|
||||
)
|
||||
})
|
||||
const updates = Stream.merge(sources.changes(), bus.subscribe([Event.Updated, SdkPlugins.Updated])).pipe(
|
||||
// Make accepted work visible to flush before coalescing the burst.
|
||||
Stream.mapEffect(() =>
|
||||
@@ -242,73 +169,19 @@ export const layer = Layer.effect(
|
||||
}),
|
||||
),
|
||||
)
|
||||
const reload = (packages: readonly string[]) =>
|
||||
activationLock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
yield* activate().pipe(Effect.scoped, Effect.provideService(Npm.Service, npm))
|
||||
const failed = (yield* registry.list()).flatMap((info) =>
|
||||
info.status === "failed" && info.source.type === "package" && packages.includes(info.source.package)
|
||||
? [info.source.package]
|
||||
: [],
|
||||
)
|
||||
if (failed.length === 0) return failed
|
||||
yield* Effect.forEach(failed, (pkg) =>
|
||||
npm
|
||||
.rollback(pkg)
|
||||
.pipe(
|
||||
Effect.catchCause((cause) => Effect.logError("failed to restore plugin package revision", { cause })),
|
||||
),
|
||||
)
|
||||
yield* activate().pipe(Effect.scoped, Effect.provideService(Npm.Service, npm))
|
||||
return failed
|
||||
}),
|
||||
)
|
||||
yield* Stream.concat(Stream.succeed(0), updates).pipe(
|
||||
// Keep observing updates while activation runs, retaining only the latest generation request.
|
||||
Stream.buffer({ capacity: 1, strategy: "sliding" }),
|
||||
Stream.debounce("100 millis"),
|
||||
Stream.runForEach((target) =>
|
||||
Effect.gen(function* () {
|
||||
yield* activationLock.withPermit(activate())
|
||||
yield* activate()
|
||||
if (observed === target) yield* ready.open
|
||||
}).pipe(Effect.catchCause((cause) => Effect.logError("failed to reload plugins", { cause }))),
|
||||
),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
return Service.of({
|
||||
flush: ready.await,
|
||||
check,
|
||||
update: Effect.fn("PluginSupervisor.update")(function* (name) {
|
||||
const info = (yield* registry.list()).find((info) => pluginName(info) === name || info.id === name)
|
||||
if (!info) {
|
||||
return {
|
||||
name,
|
||||
source: { type: "package", package: name },
|
||||
status: "failed",
|
||||
error: "Plugin not found",
|
||||
}
|
||||
}
|
||||
const result = yield* updateOne(info)
|
||||
if (result.status === "updated" && result.source.type === "package") {
|
||||
const failed = yield* reload([result.source.package])
|
||||
if (failed.length > 0)
|
||||
return { ...result, status: "failed" as const, error: "Updated plugin failed to activate" }
|
||||
}
|
||||
return result
|
||||
}),
|
||||
updateAll: Effect.fn("PluginSupervisor.updateAll")(function* () {
|
||||
const results = yield* Effect.forEach(yield* registry.list(), updateOne)
|
||||
const packages = results.flatMap((result) =>
|
||||
result.status === "updated" && result.source.type === "package" ? [result.source.package] : [],
|
||||
)
|
||||
if (packages.length === 0) return results
|
||||
const failed = yield* reload(packages)
|
||||
return results.map((result) => {
|
||||
if (result.source.type !== "package" || !failed.includes(result.source.package)) return result
|
||||
return { ...result, status: "failed" as const, error: "Updated plugin failed to activate" }
|
||||
})
|
||||
}),
|
||||
})
|
||||
return Service.of({ flush: ready.await })
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -326,10 +199,4 @@ function pluginSource(target: string): Plugin.Source {
|
||||
return { type: "package", package: target }
|
||||
}
|
||||
|
||||
function pluginName(info: Plugin.Info) {
|
||||
if (info.source.type === "package") return info.source.package
|
||||
if (info.source.type === "local") return info.source.path
|
||||
return info.id ?? info.source.type
|
||||
}
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: nodeDeps })
|
||||
|
||||
@@ -48,6 +48,7 @@ import { Skill } from "./skill.js"
|
||||
import { Job } from "./job.js"
|
||||
import { Command } from "./command.js"
|
||||
import { Shell } from "./shell.js"
|
||||
import { ShellOutput } from "./shell/output.js"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Shell as ShellSchema } from "@opencode-ai/schema/shell"
|
||||
import { KeyedMutex } from "./effect/keyed-mutex.js"
|
||||
@@ -694,9 +695,9 @@ const layer = Layer.effect(
|
||||
),
|
||||
)
|
||||
const output = terminal.retained
|
||||
? yield* shell
|
||||
.output(started.id, { limit: SHELL_MAX_CAPTURE_BYTES })
|
||||
.pipe(Effect.catchTag("Shell.NotFoundError", () => Effect.succeed(missingShellOutput())))
|
||||
? yield* ShellOutput.preview(started, shell).pipe(
|
||||
Effect.catchTag("Shell.NotFoundError", () => Effect.succeed(missingShellOutput())),
|
||||
)
|
||||
: missingShellOutput()
|
||||
return { shell: terminal.info, output }
|
||||
}).pipe(Effect.provide(locations.get(session.location)))
|
||||
@@ -1120,9 +1121,6 @@ function positiveInt(value: string | null) {
|
||||
return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined
|
||||
}
|
||||
|
||||
// Mirrors the shell tool's in-memory preview safety limit.
|
||||
const SHELL_MAX_CAPTURE_BYTES = 1024 * 1024
|
||||
|
||||
export const node = makeGlobalNode({
|
||||
service: Service,
|
||||
layer: layer.pipe(Layer.orDie),
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
export * as ShellOutput from "./output.js"
|
||||
|
||||
import { Effect } from "effect"
|
||||
import type { Info } from "@opencode-ai/schema/shell"
|
||||
import { Config } from "../config.js"
|
||||
import { Shell } from "../shell.js"
|
||||
import { ToolOutput } from "../tool-output.js"
|
||||
|
||||
export const preview = Effect.fn("ShellOutput.preview")(function* (info: Info, shell: Shell.Interface) {
|
||||
const config = yield* Config.Service
|
||||
const configured = Config.latest(yield* config.entries(), "tool_output")
|
||||
const maxLines = configured?.max_lines ?? ToolOutput.MAX_LINES
|
||||
const maxBytes = configured?.max_bytes ?? ToolOutput.MAX_BYTES
|
||||
const latest = yield* shell.output(info.id, { cursor: Number.MAX_SAFE_INTEGER })
|
||||
const page = yield* shell.output(info.id, {
|
||||
cursor: Math.max(0, latest.size - maxBytes),
|
||||
limit: maxBytes,
|
||||
})
|
||||
const lines = page.output.split("\n")
|
||||
if (page.output.endsWith("\n")) lines.pop()
|
||||
const truncated = latest.size > maxBytes || lines.length > maxLines
|
||||
const output = lines.length > maxLines ? lines.slice(-maxLines).join("\n") : page.output
|
||||
const notice = truncated ? `\n\n[output truncated; full output saved to: ${info.file}]` : ""
|
||||
return { ...page, output: `${output || "(no output)"}${notice}`, truncated }
|
||||
})
|
||||
@@ -12,9 +12,9 @@ import { PluginRuntime } from "../../plugin/runtime.js"
|
||||
import { NonNegativeInt } from "../../schema.js"
|
||||
import { SessionSchema } from "../../session/schema.js"
|
||||
import { Shell } from "../../shell.js"
|
||||
import { ShellOutput } from "../../shell/output.js"
|
||||
import { ShellParse } from "../../shell/parse.js"
|
||||
import { ShellSelect } from "../../shell/select.js"
|
||||
import { ToolOutput } from "../../tool-output.js"
|
||||
|
||||
export const name = "shell"
|
||||
export const DEFAULT_TIMEOUT_MS = 2 * 60 * 1_000
|
||||
@@ -239,29 +239,11 @@ export const Plugin = {
|
||||
)
|
||||
yield* context.progress({ shellID: info.id })
|
||||
|
||||
const captureShell = Effect.fnUntraced(function* () {
|
||||
const configured = Config.latest(yield* config.entries(), "tool_output")
|
||||
const maxLines = configured?.max_lines ?? ToolOutput.MAX_LINES
|
||||
const maxBytes = configured?.max_bytes ?? ToolOutput.MAX_BYTES
|
||||
const latest = yield* shell.output(info.id, { cursor: Number.MAX_SAFE_INTEGER })
|
||||
const page = yield* shell.output(info.id, {
|
||||
cursor: Math.max(0, latest.size - maxBytes),
|
||||
limit: maxBytes,
|
||||
})
|
||||
const lines = page.output.split("\n")
|
||||
if (page.output.endsWith("\n")) lines.pop()
|
||||
const truncated = latest.size > maxBytes || lines.length > maxLines
|
||||
const output = lines.length > maxLines ? lines.slice(-maxLines).join("\n") : page.output
|
||||
const notice = truncated ? `\n\n[output truncated; full output saved to: ${info.file}]` : ""
|
||||
return {
|
||||
output: `${output || "(no output)"}${notice}`,
|
||||
truncated,
|
||||
}
|
||||
})
|
||||
|
||||
const settleShell = Effect.fnUntraced(function* () {
|
||||
const final = yield* shell.wait(info.id)
|
||||
const capture = yield* captureShell()
|
||||
const capture = yield* ShellOutput.preview(info, shell).pipe(
|
||||
Effect.provideService(Config.Service, config),
|
||||
)
|
||||
|
||||
// `exit` is optionalKey in the Output schema; a present-but-undefined key
|
||||
// fails output encoding, so omit it when the process has no exit code.
|
||||
|
||||
@@ -37,30 +37,6 @@ const staticIt = testEffect(
|
||||
)
|
||||
|
||||
describe("PluginSupervisor config", () => {
|
||||
it.live("reports local and builtin plugins as not updateable", () => {
|
||||
const plugin = path.join(import.meta.dir, "../plugin/fixtures/config-promise-plugin.ts")
|
||||
return withLocation(
|
||||
{ plugins: [plugin] },
|
||||
Effect.gen(function* () {
|
||||
yield* ready()
|
||||
const supervisor = yield* PluginSupervisor.Service
|
||||
const updates = yield* supervisor.check()
|
||||
|
||||
expect(updates.find((update) => update.name === plugin)).toEqual({
|
||||
name: plugin,
|
||||
source: { type: "local", path: plugin },
|
||||
status: "not-updateable",
|
||||
})
|
||||
expect(updates.find((update) => update.source.type === "builtin")?.status).toBe("not-updateable")
|
||||
expect(yield* supervisor.update(plugin)).toEqual({
|
||||
name: plugin,
|
||||
source: { type: "local", path: plugin },
|
||||
status: "not-updateable",
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it.live("applies selectors in order", () =>
|
||||
withLocation(
|
||||
{ plugins: ["-opencode.provider.*", "opencode.provider.openai"] },
|
||||
|
||||
@@ -25,18 +25,13 @@ import { host } from "../plugin/host"
|
||||
|
||||
type WatcherEvent = { file: string; event: "add" | "change" | "unlink" }
|
||||
const describeNative = process.env.CI ? describe.skip : describe
|
||||
const pluginUpdates = {
|
||||
check: () => Effect.succeed([]),
|
||||
update: () => Effect.die("unused"),
|
||||
updateAll: () => Effect.succeed([]),
|
||||
}
|
||||
|
||||
const it = testEffect(AppNodeBuilder.build(LayerNode.group([FSUtil.node, Bus.node])))
|
||||
|
||||
const configLayer = Config.testLayer()
|
||||
const pluginNode = makeLocationNode({
|
||||
service: PluginSupervisor.Service,
|
||||
layer: Layer.succeed(PluginSupervisor.Service, PluginSupervisor.Service.of({ ...pluginUpdates, flush: Effect.void })),
|
||||
layer: Layer.succeed(PluginSupervisor.Service, PluginSupervisor.Service.of({ flush: Effect.void })),
|
||||
deps: [],
|
||||
})
|
||||
|
||||
@@ -319,7 +314,7 @@ describe("LocationWatcher subscriptions", () => {
|
||||
Effect.gen(function* () {
|
||||
const policy = yield* LocationWatcherPolicy.Service
|
||||
yield* policy.transform((draft) => draft.add([".git"]))
|
||||
return PluginSupervisor.Service.of({ ...pluginUpdates, flush: Effect.void })
|
||||
return PluginSupervisor.Service.of({ flush: Effect.void })
|
||||
}),
|
||||
),
|
||||
deps: [LocationWatcherPolicy.node],
|
||||
|
||||
@@ -1,178 +0,0 @@
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { fileURLToPath, pathToFileURL } from "url"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Npm } from "@opencode-ai/util/npm"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
|
||||
const npmLayer = (cache: string) =>
|
||||
AppNodeBuilder.build(Npm.node, [[Global.node, Global.layerWith({ cache, state: path.join(cache, "state") })]])
|
||||
|
||||
describe("Npm plugin updates", () => {
|
||||
test("checks a moving Git ref without installing and explicitly updates its cached revision", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const repository = path.join(tmp.path, "plugin")
|
||||
const cache = path.join(tmp.path, "cache")
|
||||
await fs.mkdir(repository)
|
||||
await Bun.write(
|
||||
path.join(repository, "package.json"),
|
||||
JSON.stringify({ name: "fixture-plugin", version: "1.0.0", exports: "./index.js" }),
|
||||
)
|
||||
await Bun.write(path.join(repository, "index.js"), "export default 'first'\n")
|
||||
await Bun.$`git init -q -b main ${repository}`
|
||||
await commit(repository, "first")
|
||||
const first = await revision(repository)
|
||||
const spec = `git+${pathToFileURL(repository).href}#main`
|
||||
const layer = npmLayer(cache)
|
||||
|
||||
const installed = await Effect.gen(function* () {
|
||||
const npm = yield* Npm.Service
|
||||
return yield* npm.add(spec)
|
||||
}).pipe(Effect.scoped, Effect.provide(layer), Effect.runPromise)
|
||||
expect(installed.revision).toBe(first)
|
||||
|
||||
await Bun.write(path.join(repository, "index.js"), "export default 'second'\n")
|
||||
await commit(repository, "second")
|
||||
const second = await revision(repository)
|
||||
|
||||
const checked = await Effect.gen(function* () {
|
||||
const npm = yield* Npm.Service
|
||||
return yield* npm.check(spec)
|
||||
}).pipe(Effect.provide(layer), Effect.runPromise)
|
||||
expect(checked).toMatchObject({
|
||||
currentVersion: first,
|
||||
latestVersion: second,
|
||||
updateAvailable: true,
|
||||
})
|
||||
|
||||
const updated = await Effect.gen(function* () {
|
||||
const npm = yield* Npm.Service
|
||||
return yield* npm.update(spec)
|
||||
}).pipe(Effect.scoped, Effect.provide(layer), Effect.runPromise)
|
||||
expect(updated).toMatchObject({
|
||||
previousVersion: first,
|
||||
currentVersion: second,
|
||||
updated: true,
|
||||
})
|
||||
|
||||
const current = await Effect.gen(function* () {
|
||||
const npm = yield* Npm.Service
|
||||
return yield* npm.add(spec)
|
||||
}).pipe(Effect.scoped, Effect.provide(layer), Effect.runPromise)
|
||||
expect(current.directory).not.toBe(installed.directory)
|
||||
expect(current.revision).toBe(second)
|
||||
if (!current.entrypoint) throw new Error("Updated plugin entrypoint missing")
|
||||
expect(
|
||||
await Bun.file(
|
||||
current.entrypoint.startsWith("file:") ? fileURLToPath(current.entrypoint) : current.entrypoint,
|
||||
).text(),
|
||||
).toContain("second")
|
||||
|
||||
const unchanged = await Effect.gen(function* () {
|
||||
const npm = yield* Npm.Service
|
||||
return yield* npm.update(spec)
|
||||
}).pipe(Effect.scoped, Effect.provide(layer), Effect.runPromise)
|
||||
expect(unchanged).toMatchObject({
|
||||
previousVersion: second,
|
||||
currentVersion: second,
|
||||
latestVersion: second,
|
||||
updateAvailable: false,
|
||||
updated: false,
|
||||
})
|
||||
expect(
|
||||
await Effect.gen(function* () {
|
||||
const npm = yield* Npm.Service
|
||||
return yield* npm.resolve(spec)
|
||||
}).pipe(Effect.provide(layer), Effect.runPromise),
|
||||
).toMatchObject({ directory: current.directory, revision: second })
|
||||
|
||||
await Effect.gen(function* () {
|
||||
const npm = yield* Npm.Service
|
||||
yield* npm.rollback(spec)
|
||||
}).pipe(Effect.provide(layer), Effect.runPromise)
|
||||
expect(
|
||||
await Effect.gen(function* () {
|
||||
const npm = yield* Npm.Service
|
||||
return yield* npm.resolve(spec)
|
||||
}).pipe(Effect.provide(layer), Effect.runPromise),
|
||||
).toMatchObject({ directory: installed.directory, revision: first })
|
||||
})
|
||||
|
||||
test("resolves a Git semver selector without installing HEAD outside the configured range", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const repository = path.join(tmp.path, "plugin")
|
||||
const cache = path.join(tmp.path, "cache")
|
||||
await fs.mkdir(repository)
|
||||
await writePlugin(repository, "1.0.0", "one")
|
||||
await Bun.$`git init -q -b main ${repository}`
|
||||
await commit(repository, "one")
|
||||
await Bun.$`git -C ${repository} tag v1.0.0`
|
||||
const first = await revision(repository)
|
||||
const spec = `git+${pathToFileURL(repository).href}#semver:^1`
|
||||
const layer = npmLayer(cache)
|
||||
|
||||
const installed = await Effect.gen(function* () {
|
||||
const npm = yield* Npm.Service
|
||||
return yield* npm.add(spec)
|
||||
}).pipe(Effect.scoped, Effect.provide(layer), Effect.runPromise)
|
||||
expect(installed.revision).toBe(first)
|
||||
|
||||
await writePlugin(repository, "1.1.0", "one-one")
|
||||
await commit(repository, "one-one")
|
||||
await Bun.$`git -C ${repository} tag v1.1.0`
|
||||
const latest = await revision(repository)
|
||||
await writePlugin(repository, "2.0.0", "two")
|
||||
await commit(repository, "two")
|
||||
await Bun.$`git -C ${repository} tag v2.0.0`
|
||||
expect(await revision(repository)).not.toBe(latest)
|
||||
|
||||
const checked = await Effect.gen(function* () {
|
||||
const npm = yield* Npm.Service
|
||||
return yield* npm.check(spec)
|
||||
}).pipe(Effect.provide(layer), Effect.runPromise)
|
||||
expect(checked).toMatchObject({ currentVersion: first, latestVersion: latest, updateAvailable: true })
|
||||
|
||||
const updated = await Effect.gen(function* () {
|
||||
const npm = yield* Npm.Service
|
||||
return yield* npm.update(spec)
|
||||
}).pipe(Effect.scoped, Effect.provide(layer), Effect.runPromise)
|
||||
expect(updated).toMatchObject({ currentVersion: latest, latestVersion: latest, updated: true })
|
||||
})
|
||||
|
||||
test("fails checks for missing Git refs", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const repository = path.join(tmp.path, "plugin")
|
||||
await fs.mkdir(repository)
|
||||
await writePlugin(repository, "1.0.0", "one")
|
||||
await Bun.$`git init -q -b main ${repository}`
|
||||
await commit(repository, "one")
|
||||
const layer = npmLayer(path.join(tmp.path, "cache"))
|
||||
|
||||
await expect(
|
||||
Effect.gen(function* () {
|
||||
const npm = yield* Npm.Service
|
||||
return yield* npm.check(`git+${pathToFileURL(repository).href}#missing`)
|
||||
}).pipe(Effect.provide(layer), Effect.runPromise),
|
||||
).rejects.toMatchObject({ _tag: "NpmInstallFailedError" })
|
||||
})
|
||||
})
|
||||
|
||||
async function writePlugin(repository: string, version: string, value: string) {
|
||||
await Bun.write(
|
||||
path.join(repository, "package.json"),
|
||||
JSON.stringify({ name: "fixture-plugin", version, exports: "./index.js" }),
|
||||
)
|
||||
await Bun.write(path.join(repository, "index.js"), `export default '${value}'\n`)
|
||||
}
|
||||
|
||||
async function commit(repository: string, message: string) {
|
||||
await Bun.$`git -C ${repository} add .`
|
||||
await Bun.$`git -C ${repository} -c user.name=fixture -c user.email=fixture@example.com commit -qm ${message}`
|
||||
}
|
||||
|
||||
async function revision(repository: string) {
|
||||
return Bun.$`git -C ${repository} rev-parse HEAD`.text().then((value) => value.trim())
|
||||
}
|
||||
@@ -21,39 +21,6 @@ const writePackage = (dir: string, pkg: Record<string, unknown>) =>
|
||||
const npmLayer = (cache: string) =>
|
||||
AppNodeBuilder.build(Npm.node, [[Global.node, Global.layerWith({ cache, state: path.join(cache, "state") })]])
|
||||
|
||||
async function createGitFixture(directory: string) {
|
||||
const repository = path.join(directory, "repository")
|
||||
await fs.mkdir(path.join(repository, "dependency"), { recursive: true })
|
||||
await writePackage(repository, {
|
||||
name: "fixture-git-plugin",
|
||||
exports: "./index.js",
|
||||
dependencies: { "fixture-dependency": "file:./dependency" },
|
||||
})
|
||||
await writePackage(path.join(repository, "dependency"), { name: "fixture-dependency", exports: "./index.js" })
|
||||
await Bun.write(path.join(repository, "index.js"), "export default { root: true }\n")
|
||||
await Bun.write(path.join(repository, "dependency", "index.js"), "export const dependency = true\n")
|
||||
|
||||
const subdirectory = path.join(repository, "packages", "subdirectory-plugin")
|
||||
await fs.mkdir(path.join(subdirectory, "dependency"), { recursive: true })
|
||||
await writePackage(subdirectory, {
|
||||
name: "fixture-subdirectory-plugin",
|
||||
exports: "./index.js",
|
||||
dependencies: { "fixture-subdirectory-dependency": "file:./dependency" },
|
||||
})
|
||||
await writePackage(path.join(subdirectory, "dependency"), {
|
||||
name: "fixture-subdirectory-dependency",
|
||||
exports: "./index.js",
|
||||
})
|
||||
await Bun.write(path.join(subdirectory, "index.js"), "export default { subdirectory: true }\n")
|
||||
await Bun.write(path.join(subdirectory, "dependency", "index.js"), "export const dependency = true\n")
|
||||
|
||||
await Bun.$`git init -q -b fixture-branch ${repository}`
|
||||
await Bun.$`git -C ${repository} add .`
|
||||
await Bun.$`git -C ${repository} -c user.name=fixture -c user.email=fixture@example.com commit -qm fixture`
|
||||
const commit = await Bun.$`git -C ${repository} rev-parse HEAD`.text().then((value) => value.trim())
|
||||
return { repository, commit }
|
||||
}
|
||||
|
||||
describe("Npm.sanitize", () => {
|
||||
test("keeps normal scoped package specs unchanged", () => {
|
||||
expect(Npm.sanitize("@opencode/acme")).toBe("@opencode/acme")
|
||||
@@ -79,33 +46,6 @@ describe("Npm.isRegistryPackage", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("Npm.isInstallablePackage", () => {
|
||||
test("accepts registry and npm-compatible Git specs", async () => {
|
||||
expect(await Npm.isInstallablePackage("plugin@^1.2.0")).toBe(true)
|
||||
expect(await Npm.isInstallablePackage("github:acme/plugin#main")).toBe(true)
|
||||
expect(await Npm.isInstallablePackage("git+ssh://git@github.com/acme/plugin.git#main")).toBe(true)
|
||||
expect(await Npm.isInstallablePackage("git@github.com:acme/plugin.git")).toBe(true)
|
||||
expect(
|
||||
await Npm.isInstallablePackage(
|
||||
"git+https://github.com/acme/plugins.git#0123456789abcdef0123456789abcdef01234567::path:packages/plugin",
|
||||
),
|
||||
).toBe(true)
|
||||
expect(await Npm.isInstallablePackage("./plugin")).toBe(false)
|
||||
expect(await Npm.isInstallablePackage("https://example.com/plugin.tgz")).toBe(false)
|
||||
expect(await Npm.isInstallablePackage("alias@npm:plugin@1.0.0")).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Npm.cacheKey", () => {
|
||||
test("preserves registry keys and hashes Git specs", async () => {
|
||||
expect(await Npm.cacheKey("@opencode/acme@1.0.0")).toBe(Npm.sanitize("@opencode/acme@1.0.0"))
|
||||
const spec = "git+ssh://git@github.com/acme/plugin.git#main"
|
||||
expect(await Npm.cacheKey(spec)).toMatch(/^git-[a-f0-9]{64}$/)
|
||||
expect(await Npm.cacheKey(spec)).toBe(await Npm.cacheKey(spec))
|
||||
expect(await Npm.cacheKey(`${spec}-other`)).not.toBe(await Npm.cacheKey(spec))
|
||||
})
|
||||
})
|
||||
|
||||
describe("Npm.add", () => {
|
||||
test("resolves cached scoped package specs without reifying", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
@@ -176,51 +116,6 @@ describe("Npm.add", () => {
|
||||
expect(entries.tui.entrypoint).toEndWith("/tui.js")
|
||||
expect(entries.fallback.entrypoint).toEndWith("/index.js")
|
||||
})
|
||||
|
||||
test("installs and resolves named and unnamed Git packages with dependencies", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const fixture = await createGitFixture(tmp.path)
|
||||
const cache = path.join(tmp.path, "cache")
|
||||
const specs = [
|
||||
`git+file://${fixture.repository}#${fixture.commit}`,
|
||||
`fixture-named-plugin@git+file://${fixture.repository}#fixture-branch`,
|
||||
]
|
||||
|
||||
for (const spec of specs) {
|
||||
const entries = await Effect.gen(function* () {
|
||||
const npm = yield* Npm.Service
|
||||
return {
|
||||
added: yield* npm.add(spec),
|
||||
cached: yield* npm.add(spec),
|
||||
resolved: yield* npm.resolve(spec),
|
||||
}
|
||||
}).pipe(Effect.scoped, Effect.provide(npmLayer(cache)), Effect.runPromise)
|
||||
|
||||
expect(entries.added.entrypoint).toEndWith("/index.js")
|
||||
expect(entries.cached).toEqual(entries.added)
|
||||
expect(entries.resolved).toEqual(entries.added)
|
||||
expect(
|
||||
await fs.stat(path.join(path.dirname(entries.added.directory), "fixture-dependency", "package.json")),
|
||||
).toBeTruthy()
|
||||
expect(entries.added.directory).toContain(path.join("packages", await Npm.cacheKey(spec), "node_modules"))
|
||||
}
|
||||
})
|
||||
|
||||
test("installs a Git package from an npm ::path: subdirectory", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const fixture = await createGitFixture(tmp.path)
|
||||
const spec = `git+file://${fixture.repository}#${fixture.commit}::path:packages/subdirectory-plugin`
|
||||
const entry = await Effect.gen(function* () {
|
||||
const npm = yield* Npm.Service
|
||||
return yield* npm.add(spec)
|
||||
}).pipe(Effect.scoped, Effect.provide(npmLayer(path.join(tmp.path, "cache"))), Effect.runPromise)
|
||||
|
||||
expect(entry.directory).toEndWith(path.join("node_modules", "fixture-subdirectory-plugin"))
|
||||
expect(entry.entrypoint).toEndWith("/index.js")
|
||||
expect(
|
||||
await fs.stat(path.join(path.dirname(entry.directory), "fixture-subdirectory-dependency", "package.json")),
|
||||
).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe("Npm.resolve", () => {
|
||||
|
||||
@@ -35,10 +35,7 @@ const npmLayer = Layer.succeed(
|
||||
Npm.Service,
|
||||
Npm.Service.of({
|
||||
add: () => Effect.succeed({ directory: "", entrypoint: undefined }),
|
||||
check: () => Effect.succeed({ updateable: false, pinned: false, updateAvailable: false }),
|
||||
resolve: () => Effect.succeed({ directory: "", entrypoint: undefined }),
|
||||
update: () => Effect.succeed({ updateable: false, pinned: false, updateAvailable: false, updated: false }),
|
||||
rollback: () => Effect.void,
|
||||
which: () => Effect.undefined,
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -23,10 +23,7 @@ const itWithAISDK = testEffect(Layer.mergeAll(PluginTestLayer, AppNodeBuilder.bu
|
||||
function npmEntrypoint(entrypoint?: string) {
|
||||
return Npm.Service.of({
|
||||
add: () => Effect.succeed({ directory: "", entrypoint }),
|
||||
check: () => Effect.succeed({ updateable: false, pinned: false, updateAvailable: false }),
|
||||
resolve: () => Effect.succeed({ directory: "", entrypoint }),
|
||||
update: () => Effect.succeed({ updateable: false, pinned: false, updateAvailable: false, updated: false }),
|
||||
rollback: () => Effect.void,
|
||||
which: () => Effect.undefined,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -14,10 +14,7 @@ const fixtureProvider = new URL("./fixtures/provider-factory.ts", import.meta.ur
|
||||
const it = testEffect(PluginTestLayer)
|
||||
const npm = Npm.Service.of({
|
||||
add: () => Effect.succeed({ directory: "", entrypoint: undefined }),
|
||||
check: () => Effect.succeed({ updateable: false, pinned: false, updateAvailable: false }),
|
||||
resolve: () => Effect.succeed({ directory: "", entrypoint: undefined }),
|
||||
update: () => Effect.succeed({ updateable: false, pinned: false, updateAvailable: false, updated: false }),
|
||||
rollback: () => Effect.void,
|
||||
which: () => Effect.undefined,
|
||||
})
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ import { SessionEvent } from "@opencode-ai/core/session/event"
|
||||
import { SessionTable } from "@opencode-ai/core/session/sql"
|
||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||
import { SessionTransfer } from "@opencode-ai/core/session/transfer"
|
||||
import { ToolOutput } from "@opencode-ai/core/tool-output"
|
||||
import { Workspace } from "@opencode-ai/core/workspace"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { globalProjectLayer } from "./lib/project"
|
||||
@@ -888,6 +889,61 @@ describe("Session.create", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("truncates direct shell output using the shared shell preview policy", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
const created = yield* session.create({
|
||||
location: Location.Ref.make({ directory: AbsolutePath.make(directory) }),
|
||||
})
|
||||
const bytes = ToolOutput.MAX_BYTES + 1024
|
||||
const command =
|
||||
process.platform === "win32"
|
||||
? `[Console]::Out.Write('output-start' + ('x' * ${bytes}) + 'output-end')`
|
||||
: `printf output-start; head -c ${bytes} /dev/zero | tr '\\0' 'x'; printf output-end`
|
||||
|
||||
yield* session.shell({ sessionID: created.id, command })
|
||||
|
||||
const messages = yield* session.messages({ sessionID: created.id, order: "asc" })
|
||||
const shell = messages.find((message): message is SessionMessage.Shell => message.type === "shell")
|
||||
expect(shell?.output?.truncated).toBe(true)
|
||||
expect(shell?.output?.output).not.toContain("output-start")
|
||||
expect(shell?.output?.output).toContain("output-end")
|
||||
expect(shell?.output?.output).toContain("output truncated; full output saved to:")
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("applies configured output limits to direct shell commands", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(
|
||||
path.join(directory, "opencode.json"),
|
||||
JSON.stringify({ tool_output: { max_lines: 2, max_bytes: 1_000 } }),
|
||||
),
|
||||
)
|
||||
const session = yield* Session.Service
|
||||
const created = yield* session.create({
|
||||
location: Location.Ref.make({ directory: AbsolutePath.make(directory) }),
|
||||
})
|
||||
const command =
|
||||
process.platform === "win32"
|
||||
? "[Console]::Out.Write('one' + [Environment]::NewLine + 'two' + [Environment]::NewLine + 'three')"
|
||||
: "printf 'one\\ntwo\\nthree'"
|
||||
|
||||
yield* session.shell({ sessionID: created.id, command })
|
||||
|
||||
const messages = yield* session.messages({ sessionID: created.id, order: "asc" })
|
||||
const shell = messages.find((message): message is SessionMessage.Shell => message.type === "shell")
|
||||
expect(shell?.output?.truncated).toBe(true)
|
||||
expect(shell?.output?.output).not.toContain("one")
|
||||
expect(shell?.output?.output.replaceAll("\r\n", "\n")).toStartWith("two\nthree")
|
||||
expect(shell?.output?.output).toContain("output truncated; full output saved to:")
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("still emits shell ended for a failing command", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -110,7 +110,7 @@ const discovery = Layer.mock(InstructionDiscovery.Service, {
|
||||
const skills = Layer.mock(SkillInstructions.Service, { load: () => Effect.succeed(Instructions.empty) })
|
||||
const references = Layer.mock(ReferenceInstructions.Service, { load: () => Effect.succeed(Instructions.empty) })
|
||||
const mcp = Layer.mock(McpInstructions.Service, { load: () => Effect.succeed(Instructions.empty) })
|
||||
const plugins = Layer.mock(PluginSupervisor.Service, { ...PluginSupervisor.noUpdates, flush: Effect.void })
|
||||
const plugins = Layer.mock(PluginSupervisor.Service, { flush: Effect.void })
|
||||
const tools = Layer.mock(Tool.Service, {
|
||||
snapshot: () =>
|
||||
Effect.succeed({
|
||||
|
||||
@@ -76,14 +76,12 @@ const locations = Layer.effect(
|
||||
Layer.mock(Snapshot.Service, {
|
||||
capture: () =>
|
||||
ready ? Effect.undefined : Effect.die(new Error("Snapshot used before plugins were ready")),
|
||||
restore: () => (ready ? Effect.void : Effect.die(new Error("Snapshot used before plugins were ready"))),
|
||||
restore: () =>
|
||||
ready ? Effect.void : Effect.die(new Error("Snapshot used before plugins were ready")),
|
||||
}),
|
||||
Layer.succeed(
|
||||
PluginSupervisor.Service,
|
||||
PluginSupervisor.Service.of({
|
||||
...PluginSupervisor.noUpdates,
|
||||
flush: Effect.sync(() => (ready = true)),
|
||||
}),
|
||||
PluginSupervisor.Service.of({ flush: Effect.sync(() => (ready = true)) }),
|
||||
),
|
||||
)
|
||||
}),
|
||||
|
||||
@@ -85,10 +85,7 @@ 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({ ...PluginSupervisor.noUpdates, flush: Effect.void }),
|
||||
)
|
||||
const pluginSupervisor = Layer.succeed(PluginSupervisor.Service, PluginSupervisor.Service.of({ flush: Effect.void }))
|
||||
const promptCatalog = Layer.mock(Catalog.Service, {
|
||||
provider: {
|
||||
get: () => Effect.undefined,
|
||||
|
||||
@@ -374,7 +374,6 @@ let pluginFlushHook = Effect.void
|
||||
const pluginSupervisor = Layer.succeed(
|
||||
PluginSupervisor.Service,
|
||||
PluginSupervisor.Service.of({
|
||||
...PluginSupervisor.noUpdates,
|
||||
flush: Effect.suspend(() => pluginFlushHook),
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -125,9 +125,7 @@ const shellPluginSupervisor = makeLocationNode({
|
||||
service: PluginSupervisor.Service,
|
||||
layer: Layer.effect(
|
||||
PluginSupervisor.Service,
|
||||
registerToolPlugin(ShellTool.Plugin).pipe(
|
||||
Effect.as(PluginSupervisor.Service.of({ ...PluginSupervisor.noUpdates, flush: Effect.void })),
|
||||
),
|
||||
registerToolPlugin(ShellTool.Plugin).pipe(Effect.as(PluginSupervisor.Service.of({ flush: Effect.void }))),
|
||||
),
|
||||
deps: [
|
||||
Config.node,
|
||||
|
||||
@@ -100,9 +100,7 @@ const subagentPluginSupervisor = makeLocationNode({
|
||||
service: PluginSupervisor.Service,
|
||||
layer: Layer.effect(
|
||||
PluginSupervisor.Service,
|
||||
registerToolPlugin(SubagentTool.Plugin).pipe(
|
||||
Effect.as(PluginSupervisor.Service.of({ ...PluginSupervisor.noUpdates, flush: Effect.void })),
|
||||
),
|
||||
registerToolPlugin(SubagentTool.Plugin).pipe(Effect.as(PluginSupervisor.Service.of({ flush: Effect.void }))),
|
||||
),
|
||||
deps: [Agent.node, Config.node, Permission.node, PluginRuntime.node, Tool.node],
|
||||
})
|
||||
|
||||
@@ -31,7 +31,7 @@ export interface Context {
|
||||
readonly mcp: MCPDomain
|
||||
readonly generate: GenerateApi<unknown>
|
||||
readonly permission: PermissionDomain
|
||||
readonly plugin: Pick<PluginApi<unknown>, "list">
|
||||
readonly plugin: PluginApi<unknown>
|
||||
readonly reference: ReferenceDomain
|
||||
readonly session: SessionDomain
|
||||
readonly shell: ShellDomain
|
||||
|
||||
@@ -30,7 +30,7 @@ export interface Context {
|
||||
readonly mcp: MCPDomain
|
||||
readonly generate: GenerateApi
|
||||
readonly permission: PermissionDomain
|
||||
readonly plugin: Pick<PluginApi, "list">
|
||||
readonly plugin: PluginApi
|
||||
readonly reference: ReferenceDomain
|
||||
readonly session: SessionDomain
|
||||
readonly shell: ShellDomain
|
||||
|
||||
@@ -486,306 +486,6 @@
|
||||
"summary": "List plugins"
|
||||
}
|
||||
},
|
||||
"/api/plugin/update": {
|
||||
"get": {
|
||||
"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": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/Plugin.UpdateInfo"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["location", "data"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "InvalidRequestError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "UnauthorizedError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Check configured plugins for explicitly available updates.",
|
||||
"summary": "Check plugin updates"
|
||||
},
|
||||
"post": {
|
||||
"tags": ["plugin"],
|
||||
"operationId": "v2.plugin.update",
|
||||
"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.UpdateResult"
|
||||
}
|
||||
},
|
||||
"required": ["location", "data"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "InvalidRequestError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "UnauthorizedError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Explicitly update one configured plugin and reload plugins when it changes.",
|
||||
"summary": "Update plugin",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["name"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/plugin/update-all": {
|
||||
"post": {
|
||||
"tags": ["plugin"],
|
||||
"operationId": "v2.plugin.updateAll",
|
||||
"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": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/Plugin.UpdateResult"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["location", "data"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "InvalidRequestError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "UnauthorizedError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Explicitly update every updateable configured plugin and reload changed plugins.",
|
||||
"summary": "Update plugins"
|
||||
}
|
||||
},
|
||||
"/api/session": {
|
||||
"get": {
|
||||
"tags": ["session"],
|
||||
@@ -15146,9 +14846,6 @@
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object"
|
||||
},
|
||||
"methods": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
@@ -15659,9 +15356,6 @@
|
||||
"reasoningField": {
|
||||
"$ref": "#/components/schemas/Model.ReasoningField"
|
||||
},
|
||||
"requireReasoning": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"maxTokensField": {
|
||||
"$ref": "#/components/schemas/Model.MaxTokensField"
|
||||
},
|
||||
@@ -16295,58 +15989,6 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"Plugin.UpdateInfo": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"source": {
|
||||
"$ref": "#/components/schemas/Plugin.Source"
|
||||
},
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": ["not-updateable", "pinned", "up-to-date", "available", "failed"]
|
||||
},
|
||||
"currentVersion": {
|
||||
"type": "string"
|
||||
},
|
||||
"latestVersion": {
|
||||
"type": "string"
|
||||
},
|
||||
"error": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["name", "source", "status"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Plugin.UpdateResult": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"source": {
|
||||
"$ref": "#/components/schemas/Plugin.Source"
|
||||
},
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": ["not-updateable", "pinned", "up-to-date", "updated", "failed"]
|
||||
},
|
||||
"previousVersion": {
|
||||
"type": "string"
|
||||
},
|
||||
"version": {
|
||||
"type": "string"
|
||||
},
|
||||
"error": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["name", "source", "status"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Project": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -19,49 +19,6 @@ export const PluginGroup = HttpApiGroup.make("server.plugin")
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.get("plugin.check", "/api/plugin/update", {
|
||||
query: LocationQuery,
|
||||
success: Location.response(Schema.Array(Plugin.UpdateInfo)),
|
||||
})
|
||||
.annotateMerge(locationQueryOpenApi)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.plugin.check",
|
||||
summary: "Check plugin updates",
|
||||
description: "Check configured plugins for explicitly available updates.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("plugin.update", "/api/plugin/update", {
|
||||
payload: Schema.Struct({ name: Schema.String }),
|
||||
query: LocationQuery,
|
||||
success: Location.response(Plugin.UpdateResult),
|
||||
})
|
||||
.annotateMerge(locationQueryOpenApi)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.plugin.update",
|
||||
summary: "Update plugin",
|
||||
description: "Explicitly update one configured plugin and reload plugins when it changes.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("plugin.updateAll", "/api/plugin/update-all", {
|
||||
query: LocationQuery,
|
||||
success: Location.response(Schema.Array(Plugin.UpdateResult)),
|
||||
})
|
||||
.annotateMerge(locationQueryOpenApi)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.plugin.updateAll",
|
||||
summary: "Update plugins",
|
||||
description: "Explicitly update every updateable configured plugin and reload changed plugins.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
title: "plugin",
|
||||
|
||||
@@ -32,26 +32,6 @@ export const Info = Schema.Union([
|
||||
]).annotate({ identifier: "Plugin.Info" })
|
||||
export type Info = typeof Info.Type
|
||||
|
||||
export interface UpdateInfo extends Schema.Schema.Type<typeof UpdateInfo> {}
|
||||
export const UpdateInfo = Schema.Struct({
|
||||
name: Schema.String,
|
||||
source: Source,
|
||||
status: Schema.Literals(["not-updateable", "pinned", "up-to-date", "available", "failed"]),
|
||||
currentVersion: Schema.String.pipe(optional),
|
||||
latestVersion: Schema.String.pipe(optional),
|
||||
error: Schema.String.pipe(optional),
|
||||
}).annotate({ identifier: "Plugin.UpdateInfo" })
|
||||
|
||||
export interface UpdateResult extends Schema.Schema.Type<typeof UpdateResult> {}
|
||||
export const UpdateResult = Schema.Struct({
|
||||
name: Schema.String,
|
||||
source: Source,
|
||||
status: Schema.Literals(["not-updateable", "pinned", "up-to-date", "updated", "failed"]),
|
||||
previousVersion: Schema.String.pipe(optional),
|
||||
version: Schema.String.pipe(optional),
|
||||
error: Schema.String.pipe(optional),
|
||||
}).annotate({ identifier: "Plugin.UpdateResult" })
|
||||
|
||||
const Added = ephemeral({
|
||||
type: "plugin.added",
|
||||
schema: { id: ID },
|
||||
|
||||
@@ -173,10 +173,6 @@ for (const module of modules) {
|
||||
const sdk = archives.get("@opencode-ai/sdk")
|
||||
if (!sdk) throw new Error("Packed SDK archive was not created")
|
||||
await $`npm install --ignore-scripts --no-audit --no-fund --package-lock=false ${sdk} wrangler@4.110.0`.cwd(consumer)
|
||||
const runtimes = (await $`npm ls effect --all --parseable`.cwd(consumer).text()).trim().split("\n")
|
||||
if (runtimes.length !== 1) {
|
||||
throw new Error(`Packed SDK consumer resolved multiple Effect runtimes:\n${runtimes.join("\n")}`)
|
||||
}
|
||||
await $`bun imports.mjs`.cwd(consumer)
|
||||
await $`bun --conditions=workerd imports.mjs`.cwd(consumer)
|
||||
await $`node_modules/.bin/wrangler deploy --dry-run --config wrangler.jsonc --outdir dist`.cwd(consumer)
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
import { Plugin } from "@opencode-ai/core/plugin"
|
||||
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
|
||||
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", () => response(Plugin.Service.use((plugin) => plugin.list())))
|
||||
.handle("plugin.check", () => response(PluginSupervisor.Service.use((plugins) => plugins.check())))
|
||||
.handle("plugin.update", (ctx) =>
|
||||
response(PluginSupervisor.Service.use((plugins) => plugins.update(ctx.payload.name))),
|
||||
)
|
||||
.handle("plugin.updateAll", () => response(PluginSupervisor.Service.use((plugins) => plugins.updateAll()))),
|
||||
handlers.handle("plugin.list", () =>
|
||||
Effect.gen(function* () {
|
||||
return yield* response(Plugin.Service.use((plugin) => plugin.list()))
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -96,35 +96,6 @@ it.live("serves unauthenticated and answers CORS preflight when no password is c
|
||||
}).pipe(Effect.scoped),
|
||||
)
|
||||
|
||||
it.live("serves plugin update operations through the HttpApi", () =>
|
||||
Effect.gen(function* () {
|
||||
const handler = yield* ServerFetch.make(options)
|
||||
const check = yield* Effect.promise(() => handler(new Request("http://opencode.local/api/plugin/update")))
|
||||
expect(check.status).toBe(200)
|
||||
expect(yield* Effect.promise(() => check.json())).toMatchObject({ data: expect.any(Array) })
|
||||
|
||||
const update = yield* Effect.promise(() =>
|
||||
handler(
|
||||
new Request("http://opencode.local/api/plugin/update", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ name: "missing-plugin" }),
|
||||
}),
|
||||
),
|
||||
)
|
||||
expect(update.status).toBe(200)
|
||||
expect(yield* Effect.promise(() => update.json())).toMatchObject({
|
||||
data: { name: "missing-plugin", status: "failed", error: "Plugin not found" },
|
||||
})
|
||||
|
||||
const updateAll = yield* Effect.promise(() =>
|
||||
handler(new Request("http://opencode.local/api/plugin/update-all", { method: "POST" })),
|
||||
)
|
||||
expect(updateAll.status).toBe(200)
|
||||
expect(yield* Effect.promise(() => updateAll.json())).toMatchObject({ data: expect.any(Array) })
|
||||
}).pipe(Effect.scoped),
|
||||
)
|
||||
|
||||
it.live("cancels a stale OpenAI OAuth callback server before falling back", () =>
|
||||
Effect.gen(function* () {
|
||||
const requests: string[] = []
|
||||
|
||||
@@ -50,12 +50,9 @@ 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([]),
|
||||
flush: Effect.sync(() => {
|
||||
flushes++
|
||||
}),
|
||||
update: () => Effect.die("unused"),
|
||||
updateAll: () => Effect.succeed([]),
|
||||
})
|
||||
|
||||
expect(flushes).toBe(0)
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
import type {
|
||||
FileDiffInfo,
|
||||
SessionInfo,
|
||||
SessionStatus,
|
||||
ShellOutputInput,
|
||||
ShellOutputOutput,
|
||||
} from "@opencode-ai/client/promise"
|
||||
import type { FileDiffInfo, SessionInfo, SessionStatus } from "@opencode-ai/client/promise"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
|
||||
|
||||
@@ -46,7 +40,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
data: Data
|
||||
directory: string
|
||||
sessionID?: string
|
||||
shellOutput?: (input: ShellOutputInput) => Promise<ShellOutputOutput>
|
||||
onNavigateToSession?: NavigateToSessionFn
|
||||
onSessionHref?: SessionHrefFn
|
||||
}) => {
|
||||
@@ -62,7 +55,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
},
|
||||
navigateToSession: props.onNavigateToSession,
|
||||
sessionHref: props.onSessionHref,
|
||||
shellOutput: props.shellOutput,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
createSignal,
|
||||
For,
|
||||
Match,
|
||||
onCleanup,
|
||||
onMount,
|
||||
Show,
|
||||
Switch,
|
||||
@@ -1295,41 +1294,15 @@ ToolRegistry.register({
|
||||
name: "shell",
|
||||
render(props) {
|
||||
const i18n = useI18n()
|
||||
const data = useData()
|
||||
const streaming = () => props.status === "streaming"
|
||||
const pending = () => streaming() || props.status === "running" || props.metadata.status === "running"
|
||||
const sawStreaming = streaming()
|
||||
const [streamed, setStreamed] = createSignal("")
|
||||
createEffect(() => {
|
||||
const id = props.metadata.shellID
|
||||
const shellOutput = data.shellOutput
|
||||
if (typeof id !== "string" || !pending() || !shellOutput) return
|
||||
const directory = data.directory
|
||||
let cursor = 0
|
||||
let loading = false
|
||||
let disposed = false
|
||||
const load = async () => {
|
||||
if (loading) return
|
||||
loading = true
|
||||
const response = await shellOutput({ id, location: { directory }, cursor }).catch(() => undefined)
|
||||
if (disposed) return
|
||||
if (response?.data.output) setStreamed((output) => output + response.data.output)
|
||||
if (response) cursor = response.data.cursor
|
||||
loading = false
|
||||
}
|
||||
void load()
|
||||
const interval = setInterval(() => void load(), 1_000)
|
||||
onCleanup(() => {
|
||||
disposed = true
|
||||
clearInterval(interval)
|
||||
})
|
||||
})
|
||||
const command = () => {
|
||||
if (typeof props.input.command === "string") return props.input.command
|
||||
if (typeof props.metadata.command === "string") return props.metadata.command
|
||||
return ""
|
||||
}
|
||||
const output = createMemo(() => stripAnsi((pending() && streamed()) || props.output || "").replace(/\r\n?/g, "\n"))
|
||||
const output = createMemo(() => stripAnsi(props.output ?? "").replace(/\r\n?/g, "\n"))
|
||||
return (
|
||||
<BasicTool
|
||||
{...props}
|
||||
|
||||
@@ -102,7 +102,6 @@ export type PromptRef = {
|
||||
}
|
||||
|
||||
const DRAFT_RETENTION_MIN_CHARS = 20
|
||||
const revealedPromptMetadata = new WeakSet<object>()
|
||||
|
||||
function randomIndex(count: number) {
|
||||
if (count <= 0) return 0
|
||||
@@ -1609,20 +1608,12 @@ export function Prompt(props: PromptProps) {
|
||||
return promptDisplay().agentColor ?? theme.border.default
|
||||
})
|
||||
const agentLabel = createMemo(() => (store.mode === "shell" ? "Shell" : promptDisplay().agentLabel))
|
||||
const animateMetadata = !revealedPromptMetadata.has(local)
|
||||
const metadataAnimationsEnabled = () => animationsEnabled() && animateMetadata
|
||||
const agentMetaAlpha = createFadeIn(() => !!agentLabel(), metadataAnimationsEnabled)
|
||||
const modelMetaAlpha = createFadeIn(
|
||||
() => !!promptDisplay().agentLabel && store.mode === "normal",
|
||||
metadataAnimationsEnabled,
|
||||
)
|
||||
const agentMetaAlpha = createFadeIn(() => !!agentLabel(), animationsEnabled)
|
||||
const modelMetaAlpha = createFadeIn(() => !!promptDisplay().agentLabel && store.mode === "normal", animationsEnabled)
|
||||
const variantMetaAlpha = createFadeIn(
|
||||
() => !!promptDisplay().agentLabel && store.mode === "normal" && !!promptDisplay().variant,
|
||||
metadataAnimationsEnabled,
|
||||
animationsEnabled,
|
||||
)
|
||||
createEffect(() => {
|
||||
if (agentLabel()) revealedPromptMetadata.add(local)
|
||||
})
|
||||
const borderHighlight = createMemo(() => tint(theme.border.default, highlight(), agentMetaAlpha()))
|
||||
const footerInput = () => ({ sessionID: props.sessionID, mode: store.mode })
|
||||
|
||||
|
||||
@@ -258,8 +258,6 @@ export const Definitions = {
|
||||
"plugins.toggle": keybind("space", "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+r", "Update selected plugin"),
|
||||
"dialog.plugins.update_all": keybind("ctrl+shift+r", "Update all plugins"),
|
||||
|
||||
"terminal.suspend": keybind("ctrl+z", "Suspend terminal"),
|
||||
"terminal.title.toggle": keybind("none", "Toggle terminal title"),
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import type { PluginInfo, PluginUpdateInfo, PluginUpdateResult } from "@opencode-ai/client"
|
||||
|
||||
export type UpdateEntry = PluginUpdateInfo | PluginUpdateResult
|
||||
|
||||
export function matchesPluginUpdate(plugin: PluginInfo, update: UpdateEntry) {
|
||||
if (plugin.source.type !== update.source.type) return false
|
||||
if (plugin.source.type === "package" && update.source.type === "package") {
|
||||
return plugin.source.package === update.source.package
|
||||
}
|
||||
if (plugin.source.type === "local" && update.source.type === "local") return plugin.source.path === update.source.path
|
||||
return plugin.id === update.name
|
||||
}
|
||||
|
||||
export function pluginServerKey(plugin: PluginInfo) {
|
||||
if (plugin.id) return `server:${plugin.id}`
|
||||
if (plugin.source.type === "package") return `server:package:${plugin.source.package}`
|
||||
if (plugin.source.type === "local") return `server:local:${plugin.source.path}`
|
||||
return `server:${plugin.source.type}`
|
||||
}
|
||||
@@ -1,17 +1,15 @@
|
||||
import type { PluginInfo, PluginUpdateInfo, PluginUpdateResult } from "@opencode-ai/client"
|
||||
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 { DialogErrorDetails } from "../../component/dialog-error-details"
|
||||
import { usePlugin } from "../../plugin/context"
|
||||
import { DialogSelect, type DialogSelectOption } from "../../ui/dialog-select"
|
||||
import { useDialog } from "../../ui/dialog"
|
||||
import { errorMessage } from "../../util/error"
|
||||
import { matchesPluginUpdate, pluginServerKey, type UpdateEntry } from "./plugins-model"
|
||||
|
||||
const id = "opencode.plugins"
|
||||
|
||||
type Entry =
|
||||
| { readonly key: string; readonly runtime: "server"; readonly plugin: PluginInfo; readonly update?: UpdateEntry }
|
||||
| { readonly key: string; readonly runtime: "server"; readonly plugin: PluginInfo }
|
||||
| {
|
||||
readonly key: string
|
||||
readonly runtime: "tui"
|
||||
@@ -21,47 +19,21 @@ type Entry =
|
||||
readonly error?: string
|
||||
}
|
||||
|
||||
type PluginUpdateOperations = {
|
||||
check(): Promise<readonly PluginUpdateInfo[]>
|
||||
update(name: string): Promise<PluginUpdateResult>
|
||||
updateAll(): Promise<readonly PluginUpdateResult[]>
|
||||
}
|
||||
|
||||
export type PluginRegistry = Pick<ReturnType<typeof usePlugin>, "registered" | "list" | "activate" | "deactivate">
|
||||
|
||||
export function PluginsDialog(props: {
|
||||
context: Plugin.Context
|
||||
plugins: PluginRegistry
|
||||
plugins: ReturnType<typeof usePlugin>
|
||||
server?: () => readonly PluginInfo[]
|
||||
updates?: PluginUpdateOperations
|
||||
}) {
|
||||
const dialog = useDialog()
|
||||
const [locked, setLocked] = createSignal(false)
|
||||
const [focused, setFocused] = createSignal<string>()
|
||||
const [detail, setDetail] = createSignal<Entry>()
|
||||
const [initial, setInitial] = createSignal<string>()
|
||||
const [checking, setChecking] = createSignal(true)
|
||||
const [operationError, setOperationError] = createSignal(false)
|
||||
const [updateEntries, setUpdateEntries] = createSignal<readonly UpdateEntry[]>([])
|
||||
const [updating, setUpdating] = createSignal<string>()
|
||||
const location = () => props.context.location ?? props.context.data.location.default()
|
||||
const operations: PluginUpdateOperations = props.updates ?? {
|
||||
check: () => props.context.client.plugin.check({ location: location() }).then((result) => result.data),
|
||||
update: (name) => props.context.client.plugin.update({ name, location: location() }).then((result) => result.data),
|
||||
updateAll: () => props.context.client.plugin.updateAll({ location: location() }).then((result) => result.data),
|
||||
}
|
||||
const [server, { refetch: refetchServer }] = createResource(
|
||||
() => (props.server ? undefined : location()),
|
||||
const [server] = 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")
|
||||
void operations
|
||||
.check()
|
||||
.then(setUpdateEntries)
|
||||
.catch(() => setOperationError(true))
|
||||
.finally(() => setChecking(false))
|
||||
})
|
||||
onMount(() => dialog.setSize("medium"))
|
||||
const entries = createMemo<Entry[]>(() => {
|
||||
const builtins: Entry[] = props.plugins
|
||||
.registered()
|
||||
@@ -84,12 +56,10 @@ export function PluginsDialog(props: {
|
||||
status: plugin.status,
|
||||
error: plugin.status === "failed" ? plugin.error : undefined,
|
||||
}))
|
||||
const runtime = props.server?.() ?? server() ?? []
|
||||
const serverEntries: Entry[] = runtime.map((plugin) => ({
|
||||
key: pluginServerKey(plugin),
|
||||
runtime: "server",
|
||||
const serverEntries: Entry[] = (props.server?.() ?? server() ?? []).map((plugin) => ({
|
||||
key: `server:${plugin.id ?? source(plugin, props.context)}`,
|
||||
runtime: "server" as const,
|
||||
plugin,
|
||||
update: updateEntries().find((update) => matchesPluginUpdate(plugin, update)),
|
||||
}))
|
||||
return [
|
||||
...[...builtins, ...external].sort((a, b) => label(a, props.context).localeCompare(label(b, props.context))),
|
||||
@@ -111,15 +81,11 @@ 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: statusLabel(entry, checking(), updating()),
|
||||
footer: status(entry) === "active" ? undefined : status(entry),
|
||||
footerColor:
|
||||
status(entry) === "failed" || updateStatus(entry) === "failed"
|
||||
status(entry) === "failed"
|
||||
? props.context.theme.text.feedback.error.default
|
||||
: updateStatus(entry) === "updated" || updateStatus(entry) === "up-to-date"
|
||||
? props.context.theme.text.feedback.success.default
|
||||
: updateStatus(entry) === "available"
|
||||
? props.context.theme.text.feedback.info.default
|
||||
: props.context.theme.text.subdued,
|
||||
: props.context.theme.text.subdued,
|
||||
gutter:
|
||||
status(entry) === "active"
|
||||
? () => <text fg={props.context.theme.text.feedback.success.default}>✓</text>
|
||||
@@ -130,11 +96,6 @@ export function PluginsDialog(props: {
|
||||
),
|
||||
)
|
||||
const focusedEntry = createMemo(() => entries().find((entry) => entry.key === focused()))
|
||||
const focusedUpdate = createMemo(() => {
|
||||
const entry = focusedEntry()
|
||||
if (entry?.runtime !== "server") return
|
||||
return entry.update
|
||||
})
|
||||
const focusedTui = createMemo(() => {
|
||||
const entry = focusedEntry()
|
||||
if (entry?.runtime !== "tui" || !entry.id) return
|
||||
@@ -158,38 +119,11 @@ export function PluginsDialog(props: {
|
||||
.catch((cause) => {
|
||||
props.context.ui.toast.show({
|
||||
variant: "error",
|
||||
message: errorMessage(cause),
|
||||
message: cause instanceof Error ? cause.message : String(cause),
|
||||
})
|
||||
})
|
||||
.finally(() => setLocked(false))
|
||||
}
|
||||
const runUpdate = (updating: string, operation: () => Promise<void>) => {
|
||||
if (locked()) return
|
||||
setLocked(true)
|
||||
setUpdating(updating)
|
||||
setOperationError(false)
|
||||
void operation()
|
||||
.catch((cause) => {
|
||||
setOperationError(true)
|
||||
props.context.ui.toast.show({ variant: "error", message: errorMessage(cause) })
|
||||
})
|
||||
.finally(() => {
|
||||
setUpdating()
|
||||
setLocked(false)
|
||||
})
|
||||
}
|
||||
const update = (name: string) =>
|
||||
runUpdate(name, async () => {
|
||||
const result = await operations.update(name)
|
||||
setUpdateEntries((current) => current.map((entry) => (entry.name === name ? result : entry)))
|
||||
if (!props.server && result.status === "updated") await refetchServer()
|
||||
})
|
||||
const updateAll = () =>
|
||||
runUpdate("*", async () => {
|
||||
const results = await operations.updateAll()
|
||||
setUpdateEntries(results)
|
||||
if (!props.server && results.some((result) => result.status === "updated")) await refetchServer()
|
||||
})
|
||||
|
||||
return (
|
||||
<box>
|
||||
@@ -207,47 +141,19 @@ export function PluginsDialog(props: {
|
||||
const entry = entries().find((entry) => entry.key === option.value)
|
||||
if (pluginError(entry)) setDetail(entry)
|
||||
}}
|
||||
actions={[
|
||||
...(focusedTui()
|
||||
actions={
|
||||
focusedTui()
|
||||
? [
|
||||
{
|
||||
title: toggleTitle(),
|
||||
command: "plugins.toggle",
|
||||
onTrigger: (option: DialogSelectOption<string>) =>
|
||||
toggle(entries().find((entry) => entry.key === option.value)),
|
||||
onTrigger: (option) => toggle(entries().find((entry) => entry.key === option.value)),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(focusedUpdate()?.status === "available"
|
||||
? [
|
||||
{
|
||||
title: "update",
|
||||
command: "dialog.plugins.update",
|
||||
onTrigger: (_option: DialogSelectOption<string>) => update(focusedUpdate()!.name),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(updateEntries().some((entry) => entry.status === "available")
|
||||
? [
|
||||
{
|
||||
title: "update all",
|
||||
command: "dialog.plugins.update_all",
|
||||
selection: "none" as const,
|
||||
side: "right" as const,
|
||||
onTrigger: updateAll,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]}
|
||||
: []
|
||||
}
|
||||
footer={
|
||||
<Show
|
||||
when={pluginError(focusedEntry())}
|
||||
fallback={
|
||||
<Show when={operationError()}>
|
||||
<text fg={props.context.theme.text.feedback.error.default}>Plugin update failed</text>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<Show when={pluginError(focusedEntry())}>
|
||||
<text>
|
||||
<span style={{ fg: props.context.theme.text.default }}>
|
||||
<b>enter</b>
|
||||
@@ -290,35 +196,8 @@ function status(entry: Entry) {
|
||||
return entry.status
|
||||
}
|
||||
|
||||
function updateStatus(entry: Entry) {
|
||||
if (entry.runtime !== "server") return
|
||||
return entry.update?.status
|
||||
}
|
||||
|
||||
function statusLabel(entry: Entry, checking: boolean, updating: string | undefined) {
|
||||
if (entry.runtime === "tui") return status(entry) === "active" ? undefined : status(entry)
|
||||
const update = entry.update
|
||||
if (updating === "*" || (update && updating === update.name)) return "updating …"
|
||||
if (entry.plugin.status === "failed") return "failed"
|
||||
if (!update) return checking ? "checking …" : undefined
|
||||
if (update.status === "not-updateable") return update.source.type === "local" ? "local" : undefined
|
||||
const currentVersion =
|
||||
"currentVersion" in update ? update.currentVersion : "version" in update ? update.version : undefined
|
||||
if (update.status === "pinned") return currentVersion ? `${currentVersion} pinned` : "pinned"
|
||||
if (update.status === "up-to-date") return currentVersion ? `${currentVersion} up to date` : "up to date"
|
||||
if (update.status === "available") {
|
||||
return `${update.currentVersion ?? "installed"} → ${update.latestVersion ?? "update available"}`
|
||||
}
|
||||
if (update.status === "updated") return `${update.previousVersion ?? "installed"} → ${update.version ?? "updated"} ✓`
|
||||
return "failed"
|
||||
}
|
||||
|
||||
function pluginError(entry: Entry | undefined) {
|
||||
if (entry?.runtime === "server") {
|
||||
if (entry.plugin.status === "failed") return entry.plugin.error
|
||||
if (entry.update?.status === "failed") return entry.update.error
|
||||
return
|
||||
}
|
||||
if (entry?.runtime === "server") return entry.plugin.status === "failed" ? entry.plugin.error : undefined
|
||||
return entry?.error
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ import { useTerminalDimensions } from "@opentui/solid"
|
||||
import { createSignal, For, type JSX } from "solid-js"
|
||||
import { StoryFooter } from "./footer"
|
||||
import { mermanLayoutsStory } from "./merman-layouts"
|
||||
import { pluginUpdatesStory } from "./plugin-updates"
|
||||
import { sessionTabsStory } from "./session-tabs"
|
||||
import { sessionLocationMissingStory } from "./session-location-missing"
|
||||
|
||||
@@ -17,7 +16,7 @@ export type Story = {
|
||||
render: (context: Plugin.Context) => JSX.Element
|
||||
}
|
||||
|
||||
const stories: Story[] = [mermanLayoutsStory, sessionTabsStory, sessionLocationMissingStory, pluginUpdatesStory]
|
||||
const stories: Story[] = [mermanLayoutsStory, sessionTabsStory, sessionLocationMissingStory]
|
||||
|
||||
function Commands(props: { context: Plugin.Context }) {
|
||||
props.context.keymap.layer(() => ({
|
||||
|
||||
@@ -1,184 +0,0 @@
|
||||
import type { PluginInfo, PluginUpdateInfo, PluginUpdateResult } from "@opencode-ai/client"
|
||||
import type { Plugin } from "@opencode-ai/plugin/tui"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
import { onMount } from "solid-js"
|
||||
import { PluginsDialog, type PluginRegistry } from "../plugins"
|
||||
import type { Story } from "./index"
|
||||
import { StoryFooter } from "./footer"
|
||||
|
||||
const server: PluginInfo[] = [
|
||||
{
|
||||
id: "fixture.analytics",
|
||||
source: { type: "package", package: "@fixture/analytics@latest" },
|
||||
status: "active",
|
||||
tui: false,
|
||||
},
|
||||
{
|
||||
id: "fixture.theme",
|
||||
source: { type: "package", package: "@fixture/theme@2.4.0" },
|
||||
status: "active",
|
||||
tui: false,
|
||||
},
|
||||
{
|
||||
id: "fixture.local",
|
||||
source: { type: "local", path: "/fixture/plugins/local.ts" },
|
||||
status: "active",
|
||||
tui: false,
|
||||
},
|
||||
{
|
||||
source: { type: "package", package: "@fixture/broken@latest" },
|
||||
status: "failed",
|
||||
error: "Plugin entrypoint could not be loaded",
|
||||
tui: false,
|
||||
},
|
||||
]
|
||||
|
||||
const initial = (): PluginUpdateInfo[] => [
|
||||
{
|
||||
name: "@fixture/analytics@latest",
|
||||
source: { type: "package", package: "@fixture/analytics@latest" },
|
||||
status: "available",
|
||||
currentVersion: "1.8.0",
|
||||
latestVersion: "1.9.0",
|
||||
},
|
||||
{
|
||||
name: "@fixture/theme@2.4.0",
|
||||
source: { type: "package", package: "@fixture/theme@2.4.0" },
|
||||
status: "pinned",
|
||||
currentVersion: "2.4.0",
|
||||
},
|
||||
{
|
||||
name: "/fixture/plugins/local.ts",
|
||||
source: { type: "local", path: "/fixture/plugins/local.ts" },
|
||||
status: "not-updateable",
|
||||
},
|
||||
{
|
||||
name: "@fixture/broken@latest",
|
||||
source: { type: "package", package: "@fixture/broken@latest" },
|
||||
status: "failed",
|
||||
error: "Registry request failed with status 503",
|
||||
},
|
||||
]
|
||||
|
||||
const plugins: PluginRegistry = {
|
||||
registered: () => [{ id: "fixture.ui", source: "builtin", active: true }],
|
||||
list: () => [],
|
||||
activate: async () => true,
|
||||
deactivate: async () => true,
|
||||
}
|
||||
|
||||
function PluginUpdatesStory(props: { context: Plugin.Context }) {
|
||||
const dimensions = useTerminalDimensions()
|
||||
const theme = props.context.theme.contextual.elevated
|
||||
let fixture = initial()
|
||||
|
||||
const wait = () => new Promise<void>((resolve) => setTimeout(resolve, 500))
|
||||
const open = () =>
|
||||
props.context.ui.dialog.show(() => (
|
||||
<PluginsDialog
|
||||
context={props.context}
|
||||
plugins={plugins}
|
||||
server={() => server}
|
||||
updates={{
|
||||
async check() {
|
||||
await wait()
|
||||
return fixture
|
||||
},
|
||||
async update(name) {
|
||||
await wait()
|
||||
const current = fixture.find((entry) => entry.name === name)
|
||||
const result: PluginUpdateResult = {
|
||||
name,
|
||||
source: current?.source ?? { type: "package", package: name },
|
||||
status: "updated",
|
||||
previousVersion: current?.currentVersion,
|
||||
version: current?.latestVersion,
|
||||
}
|
||||
fixture = fixture.map((entry) =>
|
||||
entry.name === name ? { ...entry, status: "up-to-date", currentVersion: entry.latestVersion } : entry,
|
||||
)
|
||||
return result
|
||||
},
|
||||
async updateAll() {
|
||||
await wait()
|
||||
const results: PluginUpdateResult[] = fixture.map((entry) =>
|
||||
entry.status === "available"
|
||||
? {
|
||||
name: entry.name,
|
||||
source: entry.source,
|
||||
status: "updated",
|
||||
previousVersion: entry.currentVersion,
|
||||
version: entry.latestVersion,
|
||||
}
|
||||
: {
|
||||
name: entry.name,
|
||||
source: entry.source,
|
||||
status: entry.status,
|
||||
version: entry.currentVersion,
|
||||
error: entry.error,
|
||||
},
|
||||
)
|
||||
fixture = initial().map((entry) =>
|
||||
entry.status === "available"
|
||||
? { ...entry, status: "up-to-date", currentVersion: entry.latestVersion }
|
||||
: entry,
|
||||
)
|
||||
return results
|
||||
},
|
||||
}}
|
||||
/>
|
||||
))
|
||||
|
||||
props.context.keymap.layer(() => ({
|
||||
commands: [
|
||||
{
|
||||
bind: "escape",
|
||||
title: "Back to storybook",
|
||||
group: "Storybook",
|
||||
run: () => props.context.ui.router.navigate({ type: "plugin", name: "storybook" }),
|
||||
},
|
||||
{ bind: "return", title: "Open plugin controls", group: "Storybook", run: open },
|
||||
{
|
||||
bind: "r",
|
||||
title: "Reset plugin controls",
|
||||
group: "Storybook",
|
||||
run() {
|
||||
fixture = initial()
|
||||
open()
|
||||
},
|
||||
},
|
||||
],
|
||||
}))
|
||||
|
||||
onMount(open)
|
||||
|
||||
return (
|
||||
<box
|
||||
width={dimensions().width}
|
||||
height={dimensions().height}
|
||||
flexDirection="column"
|
||||
backgroundColor={theme.background.default}
|
||||
>
|
||||
<box flexGrow={1} paddingLeft={2} paddingTop={1}>
|
||||
<text fg={theme.text.default}>Plugin update controls fixture</text>
|
||||
<text fg={theme.text.subdued}>The production Plugins dialog opens automatically.</text>
|
||||
</box>
|
||||
<StoryFooter
|
||||
context={props.context}
|
||||
title="storybook / plugin updates"
|
||||
status="fixture API · 500ms operations"
|
||||
controls={[
|
||||
{ shortcut: "enter", label: "open" },
|
||||
{ shortcut: "r", label: "reset" },
|
||||
{ shortcut: "esc", label: "back" },
|
||||
]}
|
||||
/>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
export const pluginUpdatesStory: Story = {
|
||||
id: "plugin-updates",
|
||||
title: "Plugin update controls",
|
||||
render: (context) => <PluginUpdatesStory context={context} />,
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
import type { PluginInfo, PluginUpdateInfo } from "@opencode-ai/client"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { matchesPluginUpdate, pluginServerKey } from "../../src/feature-plugins/system/plugins-model"
|
||||
|
||||
const builtin = (id: string): PluginInfo => ({ id, source: { type: "builtin" }, status: "active", tui: false })
|
||||
|
||||
describe("plugin update row identity", () => {
|
||||
test("does not join unrelated built-in plugins by source type", () => {
|
||||
const update: PluginUpdateInfo = {
|
||||
name: "fixture.second",
|
||||
source: { type: "builtin" },
|
||||
status: "not-updateable",
|
||||
}
|
||||
|
||||
expect(matchesPluginUpdate(builtin("fixture.first"), update)).toBe(false)
|
||||
expect(matchesPluginUpdate(builtin("fixture.second"), update)).toBe(true)
|
||||
})
|
||||
|
||||
test("joins package and local plugins only by their exact source", () => {
|
||||
const pkg = {
|
||||
id: "fixture.package",
|
||||
source: { type: "package", package: "@fixture/package@latest" },
|
||||
status: "active",
|
||||
tui: false,
|
||||
} satisfies PluginInfo
|
||||
const local = {
|
||||
id: "fixture.local",
|
||||
source: { type: "local", path: "/fixture/local.ts" },
|
||||
status: "active",
|
||||
tui: false,
|
||||
} satisfies PluginInfo
|
||||
|
||||
expect(
|
||||
matchesPluginUpdate(pkg, {
|
||||
name: "@fixture/package@latest",
|
||||
source: { type: "package", package: "@fixture/package@latest" },
|
||||
status: "available",
|
||||
}),
|
||||
).toBe(true)
|
||||
expect(
|
||||
matchesPluginUpdate(pkg, {
|
||||
name: "fixture.package",
|
||||
source: { type: "package", package: "@fixture/other@latest" },
|
||||
status: "available",
|
||||
}),
|
||||
).toBe(false)
|
||||
expect(
|
||||
matchesPluginUpdate(local, {
|
||||
name: "/fixture/other.ts",
|
||||
source: { type: "local", path: "/fixture/other.ts" },
|
||||
status: "not-updateable",
|
||||
}),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
test("derives stable keys only from runtime identity", () => {
|
||||
const plugin = builtin("fixture.stable")
|
||||
|
||||
expect(pluginServerKey(plugin)).toBe("server:fixture.stable")
|
||||
expect(pluginServerKey(plugin)).toBe(pluginServerKey({ ...plugin }))
|
||||
})
|
||||
})
|
||||
@@ -39,7 +39,6 @@
|
||||
"dependencies": {
|
||||
"@effect/opentelemetry": "catalog:",
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@effect/platform-node-shared": "catalog:",
|
||||
"@npmcli/arborist": "catalog:",
|
||||
"@npmcli/config": "10.8.1",
|
||||
"@opentelemetry/api": "1.9.0",
|
||||
@@ -53,7 +52,6 @@
|
||||
"mime-types": "3.0.2",
|
||||
"minimatch": "10.2.5",
|
||||
"npm-package-arg": "13.0.2",
|
||||
"pacote": "21.5.1",
|
||||
"resolve.exports": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -63,7 +61,6 @@
|
||||
"@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:"
|
||||
}
|
||||
}
|
||||
|
||||
+18
-284
@@ -1,8 +1,8 @@
|
||||
export * as Npm from "./npm.js"
|
||||
|
||||
import path from "path"
|
||||
import { createHash, randomUUID } from "node:crypto"
|
||||
import { Effect, Schema, Context, Layer, Option, FileSystem } from "effect"
|
||||
import * as NodeFileSystem from "@effect/platform-node/NodeFileSystem"
|
||||
import { FSUtil } from "./fs-util.js"
|
||||
import { Global } from "./global.js"
|
||||
import { EffectFlock } from "./effect-flock.js"
|
||||
@@ -22,20 +22,6 @@ export class InstallFailedError extends Schema.TaggedError<InstallFailedError>()
|
||||
export interface EntryPoint {
|
||||
readonly directory: string
|
||||
readonly entrypoint?: string
|
||||
readonly revision?: string
|
||||
}
|
||||
|
||||
export interface UpdateInfo {
|
||||
readonly updateable: boolean
|
||||
readonly pinned: boolean
|
||||
readonly currentVersion?: string
|
||||
readonly latestVersion?: string
|
||||
readonly updateAvailable: boolean
|
||||
}
|
||||
|
||||
export interface UpdateResult extends UpdateInfo {
|
||||
readonly previousVersion?: string
|
||||
readonly updated: boolean
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
@@ -44,9 +30,6 @@ export interface Interface {
|
||||
options?: { readonly subpaths?: readonly 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<UpdateInfo, InstallFailedError | EffectFlock.LockError>
|
||||
readonly update: (pkg: string) => Effect.Effect<UpdateResult, InstallFailedError | EffectFlock.LockError>
|
||||
readonly rollback: (pkg: string) => Effect.Effect<void, InstallFailedError>
|
||||
readonly which: (pkg: string, bin?: string) => Effect.Effect<string | undefined>
|
||||
}
|
||||
|
||||
@@ -69,43 +52,6 @@ export async function isRegistryPackage(pkg: string) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function isInstallablePackage(pkg: string) {
|
||||
const { default: npa } = await import("npm-package-arg")
|
||||
try {
|
||||
const result = npa(pkg)
|
||||
return result.type === "git" || (result.name !== undefined && ["version", "range", "tag"].includes(result.type))
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export async function updatePolicy(pkg: string) {
|
||||
const { default: npa } = await import("npm-package-arg")
|
||||
try {
|
||||
const parsed = npa(pkg)
|
||||
const registry = ["version", "range", "tag"].includes(parsed.type)
|
||||
const git = ["git", "hosted"].includes(parsed.type)
|
||||
if (parsed.type === "version" || (git && /^[a-f0-9]{40,64}$/i.test(parsed.gitCommittish ?? ""))) {
|
||||
return "pinned" as const
|
||||
}
|
||||
if (registry || git) return "mutable" as const
|
||||
return "unsupported" as const
|
||||
} catch {
|
||||
return "unsupported" as const
|
||||
}
|
||||
}
|
||||
|
||||
export async function cacheKey(pkg: string) {
|
||||
const { default: npa } = await import("npm-package-arg")
|
||||
try {
|
||||
if (npa(pkg).type === "git") {
|
||||
return `git-${createHash("sha256").update(pkg).digest("hex")}`
|
||||
}
|
||||
} catch {
|
||||
// Preserve the existing fallback for invalid and non-registry package strings.
|
||||
}
|
||||
return sanitize(pkg)
|
||||
}
|
||||
const resolveEntryPoint = (name: string, dir: string, subpaths: readonly string[] = [""]): EntryPoint => {
|
||||
const entrypoint = subpaths
|
||||
.map((subpath) => {
|
||||
@@ -125,29 +71,12 @@ const resolveEntryPoint = (name: string, dir: string, subpaths: readonly string[
|
||||
interface ArboristNode {
|
||||
name: string
|
||||
path: string
|
||||
version?: string
|
||||
resolved?: string
|
||||
}
|
||||
|
||||
interface ArboristTree {
|
||||
edgesOut: Map<string, { to?: ArboristNode }>
|
||||
}
|
||||
|
||||
const PackageJson = Schema.Struct({
|
||||
version: Schema.optional(Schema.String),
|
||||
dependencies: Schema.optional(Schema.Record(Schema.String, Schema.String)),
|
||||
})
|
||||
const PackageLock = Schema.Struct({
|
||||
packages: Schema.optional(
|
||||
Schema.Record(
|
||||
Schema.String,
|
||||
Schema.Struct({
|
||||
version: Schema.optional(Schema.String),
|
||||
resolved: Schema.optional(Schema.String),
|
||||
}),
|
||||
),
|
||||
),
|
||||
})
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
@@ -155,63 +84,15 @@ const layer = Layer.effect(
|
||||
const global = yield* Global.Service
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const flock = yield* EffectFlock.Service
|
||||
const directory = (pkg: string) =>
|
||||
Effect.map(
|
||||
Effect.promise(() => cacheKey(pkg)),
|
||||
(key) => path.join(global.cache, "packages", key),
|
||||
)
|
||||
const activeDirectory = Effect.fnUntraced(function* (dir: string) {
|
||||
const active = yield* afs
|
||||
.readFileStringSafe(path.join(dir, ".opencode-active"))
|
||||
.pipe(Effect.orElseSucceed(() => undefined))
|
||||
if (!active) return dir
|
||||
const resolved = path.resolve(active.trim())
|
||||
if (!resolved.startsWith(`${dir}-revision-`)) return dir
|
||||
return resolved
|
||||
})
|
||||
const installedName = Effect.fnUntraced(function* (pkg: string, dir: string, parsedName?: string) {
|
||||
if (parsedName) return parsedName
|
||||
const manifest = yield* afs
|
||||
.readJson(path.join(dir, "package.json"))
|
||||
.pipe(Effect.flatMap(Schema.decodeUnknownEffect(PackageJson)), Effect.option)
|
||||
if (Option.isSome(manifest)) {
|
||||
const name = Object.keys(manifest.value.dependencies ?? {})[0]
|
||||
if (name) return name
|
||||
}
|
||||
return pkg
|
||||
})
|
||||
const installed = Effect.fnUntraced(function* (pkg: string, dir: string, parsedName?: string, git = false) {
|
||||
const name = yield* installedName(pkg, dir, parsedName)
|
||||
const directory = path.join(dir, "node_modules", name)
|
||||
const manifest = yield* afs
|
||||
.readJson(path.join(directory, "package.json"))
|
||||
.pipe(Effect.flatMap(Schema.decodeUnknownEffect(PackageJson)), Effect.option)
|
||||
const version = Option.isSome(manifest) ? manifest.value.version : undefined
|
||||
if (version && !git) return { name, directory, version }
|
||||
const lock = yield* afs
|
||||
.readJson(path.join(dir, "package-lock.json"))
|
||||
.pipe(Effect.flatMap(Schema.decodeUnknownEffect(PackageLock)), Effect.option)
|
||||
const entry = Option.isSome(lock) ? lock.value.packages?.[`node_modules/${name}`] : undefined
|
||||
return {
|
||||
name,
|
||||
directory,
|
||||
version: packageVersion({
|
||||
name,
|
||||
path: directory,
|
||||
version: version ?? entry?.version,
|
||||
resolved: entry?.resolved,
|
||||
}),
|
||||
}
|
||||
})
|
||||
const reify = (input: { dir: string; add?: string[]; update?: boolean }) =>
|
||||
const directory = (pkg: string) => path.join(global.cache, "packages", sanitize(pkg))
|
||||
const reify = (input: { dir: string; add?: 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 } : npmOptions
|
||||
const arborist = new Arborist({
|
||||
...options,
|
||||
...npmOptions,
|
||||
path: input.dir,
|
||||
binLinks: true,
|
||||
progress: false,
|
||||
@@ -221,9 +102,8 @@ const layer = Layer.effect(
|
||||
return yield* Effect.tryPromise({
|
||||
try: () =>
|
||||
arborist.reify({
|
||||
...options,
|
||||
...npmOptions,
|
||||
add,
|
||||
update: input.update,
|
||||
save: true,
|
||||
saveType: "prod",
|
||||
}),
|
||||
@@ -240,152 +120,29 @@ const layer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
const parse = Effect.fnUntraced(function* (pkg: string) {
|
||||
const { default: npa } = yield* Effect.promise(() => import("npm-package-arg"))
|
||||
return yield* Effect.try({
|
||||
try: () => npa(pkg),
|
||||
catch: (cause) => new InstallFailedError({ cause, dir: path.join(global.cache, "packages") }),
|
||||
})
|
||||
})
|
||||
const check = Effect.fn("Npm.check")(function* (pkg: string) {
|
||||
const parsed = yield* parse(pkg)
|
||||
const policy = yield* Effect.promise(() => updatePolicy(pkg))
|
||||
if (policy !== "mutable") {
|
||||
const version =
|
||||
parsed.type === "version"
|
||||
? (parsed.fetchSpec ?? undefined)
|
||||
: policy === "pinned"
|
||||
? (parsed.gitCommittish ?? undefined)
|
||||
: undefined
|
||||
return {
|
||||
updateable: false,
|
||||
pinned: policy === "pinned",
|
||||
currentVersion: version,
|
||||
latestVersion: version,
|
||||
updateAvailable: false,
|
||||
}
|
||||
}
|
||||
|
||||
const root = yield* directory(pkg)
|
||||
const dir = yield* activeDirectory(root)
|
||||
const current = yield* installed(pkg, dir, parsed.name ?? undefined, ["git", "hosted"].includes(parsed.type))
|
||||
const npmOptions = yield* NpmConfig.load(root)
|
||||
const { default: pacote } = yield* Effect.promise(() => import("pacote"))
|
||||
const latestVersion = yield* Effect.tryPromise({
|
||||
try: async () => {
|
||||
if (["git", "hosted"].includes(parsed.type)) {
|
||||
const resolved = await pacote.resolve(pkg, {
|
||||
...npmOptions,
|
||||
preferOnline: true,
|
||||
noGitRevCache: true,
|
||||
})
|
||||
const revision = resolved.match(/#([a-f0-9]{40,64})$/i)?.[1]
|
||||
if (!revision) throw new Error("Resolved Git package did not include a commit")
|
||||
return revision
|
||||
}
|
||||
const manifest = await pacote.manifest(pkg, { ...npmOptions, preferOnline: true })
|
||||
return manifest.version
|
||||
},
|
||||
catch: (cause) => new InstallFailedError({ cause, add: [pkg], dir }),
|
||||
})
|
||||
return {
|
||||
updateable: true,
|
||||
pinned: false,
|
||||
currentVersion: current.version,
|
||||
latestVersion,
|
||||
updateAvailable: latestVersion !== undefined && current.version !== latestVersion,
|
||||
}
|
||||
})
|
||||
|
||||
const update = Effect.fn("Npm.update")(function* (pkg: string) {
|
||||
const checked = yield* check(pkg)
|
||||
if (!checked.updateAvailable) {
|
||||
return { ...checked, previousVersion: checked.currentVersion, updated: false }
|
||||
}
|
||||
const parsed = yield* parse(pkg)
|
||||
const root = yield* directory(pkg)
|
||||
yield* flock.acquire(`npm-install:${root}`)
|
||||
const dir = `${root}-revision-${randomUUID()}`
|
||||
const target =
|
||||
["git", "hosted"].includes(parsed.type) && checked.latestVersion ? pinGitSpec(pkg, checked.latestVersion) : pkg
|
||||
const tree = yield* reify({ dir, add: [target], update: true })
|
||||
const node = parsed.name ? tree.edgesOut.get(parsed.name)?.to : tree.edgesOut.values().next().value?.to
|
||||
const version = packageVersion(node)
|
||||
if (!version || version !== checked.latestVersion) {
|
||||
return yield* new InstallFailedError({
|
||||
cause: new Error(`Installed package identity did not match checked update: ${version ?? "missing"}`),
|
||||
add: [target],
|
||||
dir,
|
||||
})
|
||||
}
|
||||
const previousDirectory = yield* activeDirectory(root)
|
||||
yield* afs
|
||||
.writeWithDirs(path.join(root, ".opencode-previous"), previousDirectory)
|
||||
.pipe(Effect.mapError((cause) => new InstallFailedError({ cause, dir: root })))
|
||||
yield* afs
|
||||
.writeWithDirs(path.join(root, ".opencode-active"), dir)
|
||||
.pipe(Effect.mapError((cause) => new InstallFailedError({ cause, dir: root })))
|
||||
return {
|
||||
updateable: true,
|
||||
pinned: false,
|
||||
currentVersion: version,
|
||||
latestVersion: checked.latestVersion,
|
||||
updateAvailable: false,
|
||||
previousVersion: checked.currentVersion,
|
||||
updated: version !== checked.currentVersion,
|
||||
}
|
||||
}, Effect.scoped)
|
||||
|
||||
const rollback = Effect.fn("Npm.rollback")(function* (pkg: string) {
|
||||
const root = yield* directory(pkg)
|
||||
const previous = yield* afs
|
||||
.readFileStringSafe(path.join(root, ".opencode-previous"))
|
||||
.pipe(Effect.mapError((cause) => new InstallFailedError({ cause, dir: root })))
|
||||
if (!previous) return
|
||||
const resolved = path.resolve(previous.trim())
|
||||
if (resolved !== root && !resolved.startsWith(`${root}-revision-`)) return
|
||||
if (resolved === root) {
|
||||
yield* fs
|
||||
.remove(path.join(root, ".opencode-active"))
|
||||
.pipe(Effect.mapError((cause) => new InstallFailedError({ cause, dir: root })))
|
||||
return
|
||||
}
|
||||
yield* afs
|
||||
.writeWithDirs(path.join(root, ".opencode-active"), resolved)
|
||||
.pipe(Effect.mapError((cause) => new InstallFailedError({ cause, dir: root })))
|
||||
})
|
||||
|
||||
const add = Effect.fn("Npm.add")(function* (pkg: string, options?: { readonly subpaths?: readonly string[] }) {
|
||||
const { default: npa } = yield* Effect.promise(() => import("npm-package-arg"))
|
||||
const root = yield* directory(pkg)
|
||||
const dir = yield* activeDirectory(root)
|
||||
const parsed = (() => {
|
||||
const dir = directory(pkg)
|
||||
const name = (() => {
|
||||
try {
|
||||
return npa(pkg)
|
||||
return npa(pkg).name ?? pkg
|
||||
} catch {
|
||||
return undefined
|
||||
return pkg
|
||||
}
|
||||
})()
|
||||
const parsedName = parsed?.name ?? undefined
|
||||
const name = yield* installedName(pkg, dir, parsedName)
|
||||
|
||||
if (yield* afs.existsSafe(path.join(dir, "node_modules", name))) {
|
||||
const result = resolveEntryPoint(name, path.join(dir, "node_modules", name), options?.subpaths)
|
||||
return {
|
||||
...result,
|
||||
revision: (yield* installed(pkg, dir, parsedName, ["git", "hosted"].includes(parsed?.type ?? ""))).version,
|
||||
}
|
||||
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)
|
||||
const result = resolveEntryPoint(name, path.join(dir, "node_modules", name), options?.subpaths)
|
||||
if (result.entrypoint) return result
|
||||
return yield* new InstallFailedError({ add: [pkg], dir })
|
||||
}
|
||||
return { ...resolveEntryPoint(first.name, first.path, options?.subpaths), revision: packageVersion(first) }
|
||||
return resolveEntryPoint(first.name, first.path, options?.subpaths)
|
||||
}, Effect.scoped)
|
||||
|
||||
const resolve = Effect.fn("Npm.resolve")(function* (
|
||||
@@ -393,26 +150,20 @@ const layer = Layer.effect(
|
||||
options?: { readonly subpaths?: readonly string[] },
|
||||
) {
|
||||
const { default: npa } = yield* Effect.promise(() => import("npm-package-arg"))
|
||||
const parsed = (() => {
|
||||
const name = (() => {
|
||||
try {
|
||||
return npa(pkg)
|
||||
return npa(pkg).name ?? pkg
|
||||
} catch {
|
||||
return undefined
|
||||
return pkg
|
||||
}
|
||||
})()
|
||||
const parsedName = parsed?.name ?? undefined
|
||||
const root = yield* activeDirectory(yield* directory(pkg))
|
||||
const name = yield* installedName(pkg, root, parsedName)
|
||||
const dir = path.join(root, "node_modules", name)
|
||||
const dir = path.join(directory(pkg), "node_modules", name)
|
||||
if (!(yield* afs.existsSafe(dir))) return { directory: dir }
|
||||
return {
|
||||
...resolveEntryPoint(name, dir, options?.subpaths),
|
||||
revision: (yield* installed(pkg, root, parsedName, ["git", "hosted"].includes(parsed?.type ?? ""))).version,
|
||||
}
|
||||
return resolveEntryPoint(name, dir, options?.subpaths)
|
||||
})
|
||||
|
||||
const which = Effect.fn("Npm.which")(function* (pkg: string, bin?: string) {
|
||||
const dir = yield* activeDirectory(yield* directory(pkg))
|
||||
const dir = directory(pkg)
|
||||
const binDir = path.join(dir, "node_modules", ".bin")
|
||||
|
||||
const pick = Effect.fnUntraced(function* () {
|
||||
@@ -464,10 +215,7 @@ const layer = Layer.effect(
|
||||
|
||||
return Service.of({
|
||||
add,
|
||||
check,
|
||||
resolve,
|
||||
rollback,
|
||||
update,
|
||||
which,
|
||||
})
|
||||
}),
|
||||
@@ -492,17 +240,3 @@ export async function resolve(...args: Parameters<Interface["resolve"]>) {
|
||||
export async function which(...args: Parameters<Interface["which"]>) {
|
||||
return runPromise((svc) => svc.which(...args))
|
||||
}
|
||||
|
||||
function packageVersion(node: ArboristNode | undefined) {
|
||||
if (!node) return undefined
|
||||
const commit = node.resolved?.match(/#([a-f0-9]{40,64})$/i)?.[1]
|
||||
return commit ?? node.version ?? node.resolved
|
||||
}
|
||||
|
||||
function pinGitSpec(pkg: string, commit: string) {
|
||||
const subdirectory = pkg.indexOf("::")
|
||||
const source = subdirectory === -1 ? pkg : pkg.slice(0, subdirectory)
|
||||
const suffix = subdirectory === -1 ? "" : pkg.slice(subdirectory)
|
||||
const hash = source.lastIndexOf("#")
|
||||
return `${hash === -1 ? `${source}#` : source.slice(0, hash + 1)}${commit}${suffix}`
|
||||
}
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { cacheKey, updatePolicy } from "../src/npm.js"
|
||||
|
||||
describe("Npm.updatePolicy", () => {
|
||||
test.each([
|
||||
["example-plugin", "mutable"],
|
||||
["example-plugin@latest", "mutable"],
|
||||
["example-plugin@next", "mutable"],
|
||||
["example-plugin@^1.2.3", "mutable"],
|
||||
["example-plugin@1.x", "mutable"],
|
||||
["github:example/plugin", "mutable"],
|
||||
["github:example/plugin#main", "mutable"],
|
||||
["git+https://example.com/plugin.git#release", "mutable"],
|
||||
["example-plugin@1.2.3", "pinned"],
|
||||
[`github:example/plugin#${"a".repeat(40)}`, "pinned"],
|
||||
[`git+https://example.com/plugin.git#${"b".repeat(64)}`, "pinned"],
|
||||
["./plugin.ts", "unsupported"],
|
||||
["file:./plugin.ts", "unsupported"],
|
||||
] as const)("classifies %s as %s", async (spec, expected) => {
|
||||
expect(await updatePolicy(spec)).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
||||
test("hashes Git specs before using them as global cache paths", async () => {
|
||||
const key = await cacheKey("git+https://user:secret@example.com/plugin.git#main")
|
||||
|
||||
expect(key).toMatch(/^git-[a-f0-9]{64}$/)
|
||||
expect(key).not.toContain("secret")
|
||||
})
|
||||
@@ -486,306 +486,6 @@
|
||||
"summary": "List plugins"
|
||||
}
|
||||
},
|
||||
"/api/plugin/update": {
|
||||
"get": {
|
||||
"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": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/Plugin.UpdateInfo"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["location", "data"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "InvalidRequestError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "UnauthorizedError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Check configured plugins for explicitly available updates.",
|
||||
"summary": "Check plugin updates"
|
||||
},
|
||||
"post": {
|
||||
"tags": ["plugin"],
|
||||
"operationId": "v2.plugin.update",
|
||||
"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.UpdateResult"
|
||||
}
|
||||
},
|
||||
"required": ["location", "data"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "InvalidRequestError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "UnauthorizedError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Explicitly update one configured plugin and reload plugins when it changes.",
|
||||
"summary": "Update plugin",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["name"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/plugin/update-all": {
|
||||
"post": {
|
||||
"tags": ["plugin"],
|
||||
"operationId": "v2.plugin.updateAll",
|
||||
"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": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/Plugin.UpdateResult"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["location", "data"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "InvalidRequestError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "UnauthorizedError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Explicitly update every updateable configured plugin and reload changed plugins.",
|
||||
"summary": "Update plugins"
|
||||
}
|
||||
},
|
||||
"/api/session": {
|
||||
"get": {
|
||||
"tags": ["session"],
|
||||
@@ -15146,9 +14846,6 @@
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object"
|
||||
},
|
||||
"methods": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
@@ -15659,9 +15356,6 @@
|
||||
"reasoningField": {
|
||||
"$ref": "#/components/schemas/Model.ReasoningField"
|
||||
},
|
||||
"requireReasoning": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"maxTokensField": {
|
||||
"$ref": "#/components/schemas/Model.MaxTokensField"
|
||||
},
|
||||
@@ -16295,58 +15989,6 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"Plugin.UpdateInfo": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"source": {
|
||||
"$ref": "#/components/schemas/Plugin.Source"
|
||||
},
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": ["not-updateable", "pinned", "up-to-date", "available", "failed"]
|
||||
},
|
||||
"currentVersion": {
|
||||
"type": "string"
|
||||
},
|
||||
"latestVersion": {
|
||||
"type": "string"
|
||||
},
|
||||
"error": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["name", "source", "status"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Plugin.UpdateResult": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"source": {
|
||||
"$ref": "#/components/schemas/Plugin.Source"
|
||||
},
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": ["not-updateable", "pinned", "up-to-date", "updated", "failed"]
|
||||
},
|
||||
"previousVersion": {
|
||||
"type": "string"
|
||||
},
|
||||
"version": {
|
||||
"type": "string"
|
||||
},
|
||||
"error": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["name", "source", "status"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Project": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -486,306 +486,6 @@
|
||||
"summary": "List plugins"
|
||||
}
|
||||
},
|
||||
"/api/plugin/update": {
|
||||
"get": {
|
||||
"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": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/Plugin.UpdateInfo"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["location", "data"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "InvalidRequestError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "UnauthorizedError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Check configured plugins for explicitly available updates.",
|
||||
"summary": "Check plugin updates"
|
||||
},
|
||||
"post": {
|
||||
"tags": ["plugin"],
|
||||
"operationId": "v2.plugin.update",
|
||||
"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.UpdateResult"
|
||||
}
|
||||
},
|
||||
"required": ["location", "data"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "InvalidRequestError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "UnauthorizedError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Explicitly update one configured plugin and reload plugins when it changes.",
|
||||
"summary": "Update plugin",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["name"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/plugin/update-all": {
|
||||
"post": {
|
||||
"tags": ["plugin"],
|
||||
"operationId": "v2.plugin.updateAll",
|
||||
"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": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/Plugin.UpdateResult"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["location", "data"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "InvalidRequestError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "UnauthorizedError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Explicitly update every updateable configured plugin and reload changed plugins.",
|
||||
"summary": "Update plugins"
|
||||
}
|
||||
},
|
||||
"/api/session": {
|
||||
"get": {
|
||||
"tags": ["session"],
|
||||
@@ -15146,9 +14846,6 @@
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object"
|
||||
},
|
||||
"methods": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
@@ -15659,9 +15356,6 @@
|
||||
"reasoningField": {
|
||||
"$ref": "#/components/schemas/Model.ReasoningField"
|
||||
},
|
||||
"requireReasoning": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"maxTokensField": {
|
||||
"$ref": "#/components/schemas/Model.MaxTokensField"
|
||||
},
|
||||
@@ -16295,58 +15989,6 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"Plugin.UpdateInfo": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"source": {
|
||||
"$ref": "#/components/schemas/Plugin.Source"
|
||||
},
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": ["not-updateable", "pinned", "up-to-date", "available", "failed"]
|
||||
},
|
||||
"currentVersion": {
|
||||
"type": "string"
|
||||
},
|
||||
"latestVersion": {
|
||||
"type": "string"
|
||||
},
|
||||
"error": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["name", "source", "status"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Plugin.UpdateResult": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"source": {
|
||||
"$ref": "#/components/schemas/Plugin.Source"
|
||||
},
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": ["not-updateable", "pinned", "up-to-date", "updated", "failed"]
|
||||
},
|
||||
"previousVersion": {
|
||||
"type": "string"
|
||||
},
|
||||
"version": {
|
||||
"type": "string"
|
||||
},
|
||||
"error": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["name", "source", "status"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Project": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -308,31 +308,29 @@ Unknown command IDs are rejected.
|
||||
|
||||
## Dialogs And Autocomplete
|
||||
|
||||
| ID | Default | Description |
|
||||
| ------------------------------ | -------------- | ----------------------------------- |
|
||||
| `dialog.select.prev` | `up,ctrl+p` | Move to previous dialog item |
|
||||
| `dialog.select.next` | `down,ctrl+n` | Move to next dialog item |
|
||||
| `dialog.select.page_up` | `pageup` | Move up one page in dialog |
|
||||
| `dialog.select.page_down` | `pagedown` | Move down one page in dialog |
|
||||
| `dialog.select.home` | `home` | Move to first dialog item |
|
||||
| `dialog.select.end` | `end` | Move to last dialog item |
|
||||
| `dialog.select.submit` | `return` | Submit selected dialog item |
|
||||
| `dialog.prompt.submit` | `return` | Submit dialog prompt |
|
||||
| `dialog.worktree.generate` | `tab` | Generate worktree name |
|
||||
| `dialog.move_session.new` | `ctrl+m` | New worktree |
|
||||
| `dialog.move_session.delete` | `ctrl+d` | Delete worktree |
|
||||
| `dialog.move_session.refresh` | `ctrl+r` | Refresh worktrees |
|
||||
| `prompt.autocomplete.prev` | `up,ctrl+p` | Move to previous autocomplete item |
|
||||
| `prompt.autocomplete.next` | `down,ctrl+n` | Move to next autocomplete item |
|
||||
| `prompt.autocomplete.hide` | `escape` | Hide autocomplete |
|
||||
| `prompt.autocomplete.select` | `return` | Select autocomplete item |
|
||||
| `prompt.autocomplete.complete` | `tab` | Complete autocomplete item |
|
||||
| `permission.prompt.fullscreen` | `ctrl+f` | Toggle permission prompt fullscreen |
|
||||
| `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+r` | Update selected plugin |
|
||||
| `dialog.plugins.update_all` | `ctrl+shift+r` | Update all plugins |
|
||||
| ID | Default | Description |
|
||||
| ------------------------------ | ------------- | ----------------------------------- |
|
||||
| `dialog.select.prev` | `up,ctrl+p` | Move to previous dialog item |
|
||||
| `dialog.select.next` | `down,ctrl+n` | Move to next dialog item |
|
||||
| `dialog.select.page_up` | `pageup` | Move up one page in dialog |
|
||||
| `dialog.select.page_down` | `pagedown` | Move down one page in dialog |
|
||||
| `dialog.select.home` | `home` | Move to first dialog item |
|
||||
| `dialog.select.end` | `end` | Move to last dialog item |
|
||||
| `dialog.select.submit` | `return` | Submit selected dialog item |
|
||||
| `dialog.prompt.submit` | `return` | Submit dialog prompt |
|
||||
| `dialog.worktree.generate` | `tab` | Generate worktree name |
|
||||
| `dialog.move_session.new` | `ctrl+m` | New worktree |
|
||||
| `dialog.move_session.delete` | `ctrl+d` | Delete worktree |
|
||||
| `dialog.move_session.refresh` | `ctrl+r` | Refresh worktrees |
|
||||
| `prompt.autocomplete.prev` | `up,ctrl+p` | Move to previous autocomplete item |
|
||||
| `prompt.autocomplete.next` | `down,ctrl+n` | Move to next autocomplete item |
|
||||
| `prompt.autocomplete.hide` | `escape` | Hide autocomplete |
|
||||
| `prompt.autocomplete.select` | `return` | Select autocomplete item |
|
||||
| `prompt.autocomplete.complete` | `tab` | Complete autocomplete item |
|
||||
| `permission.prompt.fullscreen` | `ctrl+f` | Toggle permission prompt fullscreen |
|
||||
| `plugins.toggle` | `space` | Toggle plugin |
|
||||
| `dialog.mcp.toggle` | `space` | Toggle MCP server |
|
||||
| `dialog.plugins.install` | `shift+i` | Install plugin from plugin dialog |
|
||||
|
||||
## Terminal And Plugins
|
||||
|
||||
|
||||
@@ -6,16 +6,6 @@ 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.
|
||||
|
||||
## Manage installed plugins
|
||||
|
||||
Run `/plugins`, or open **Plugins** from the command palette, to inspect server and CLI plugins. Select an installed server
|
||||
package or Git plugin, then use the actions in the dialog footer:
|
||||
|
||||
- `ctrl+r` updates the selected plugin when an update is available.
|
||||
- `ctrl+shift+r` updates every plugin with an available update.
|
||||
|
||||
The dialog marks pinned and local plugins that cannot be updated automatically.
|
||||
|
||||
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:
|
||||
|
||||
|
||||
@@ -79,23 +79,15 @@ opencode2 plugin list --builtin
|
||||
opencode2 plugin remove opencode-acme-plugin@1.2.0
|
||||
```
|
||||
|
||||
Package installation accepts npm names with versions, tags, or ranges, plus npm-compatible Git package specifications.
|
||||
Git repositories can use hosted shortcuts, HTTPS, or SSH, including private repositories available through your existing
|
||||
Git credentials.
|
||||
Package installation accepts npm names with versions, tags, or ranges. Configure local paths directly instead of using
|
||||
Git, tarball, or npm alias targets with `plugin add`.
|
||||
|
||||
```sh
|
||||
opencode2 plugin add @acme/opencode-plugin@beta
|
||||
opencode2 plugin add github:acme/opencode-plugin
|
||||
opencode2 plugin add git+ssh://git@github.com/acme/opencode-plugin.git#main
|
||||
opencode2 plugin add 'github:acme/plugins#main::path:packages/opencode-plugin'
|
||||
```
|
||||
|
||||
Branches, tags, complete commit hashes, and npm's `::path:` repository-subdirectory selectors are supported. Configure
|
||||
local paths directly; tarball and npm alias targets are not accepted by `plugin add`.
|
||||
|
||||
Changes under watched config directories reload automatically. Package and Git plugins refresh only when you explicitly
|
||||
request an update; OpenCode installs the matching revision and reloads the plugin without a restart. Changes to unwatched
|
||||
local dependencies may still require restarting OpenCode.
|
||||
Changes under watched config directories reload automatically. Restart OpenCode after changing an installed package
|
||||
version or an unwatched dependency.
|
||||
|
||||
```sh
|
||||
touch .opencode/plugins/concise.ts
|
||||
|
||||
-10
@@ -1,12 +1,5 @@
|
||||
{
|
||||
"$schema": "https://v2-8-13.turborepo.dev/schema.json",
|
||||
"globalDependencies": [
|
||||
"patches/**",
|
||||
"bunfig.toml",
|
||||
".npmrc",
|
||||
".github/actions/setup-bun/**",
|
||||
".github/workflows/test.yml"
|
||||
],
|
||||
"globalEnv": ["CI", "OPENCODE_DISABLE_SHARE"],
|
||||
"globalPassThroughEnv": ["CI", "OPENCODE_DISABLE_SHARE"],
|
||||
"tasks": {
|
||||
@@ -60,9 +53,6 @@
|
||||
"@opencode-ai/session-ui#test": {
|
||||
"dependsOn": ["^build"],
|
||||
"outputs": []
|
||||
},
|
||||
"@opencode-ai/sdk#verify:package": {
|
||||
"outputs": []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user