Revert "feat(tui): load external plugin exports"
This reverts commit fdd6498909.
This commit is contained in:
parent
fdd6498909
commit
46148bbc6c
7 changed files with 55 additions and 129 deletions
|
|
@ -9,7 +9,6 @@ import { ServerConnection } from "../../services/server-connection"
|
||||||
import { Updater } from "../../services/updater"
|
import { Updater } from "../../services/updater"
|
||||||
import { UpdatePreflight } from "../../services/update-preflight"
|
import { UpdatePreflight } from "../../services/update-preflight"
|
||||||
import { Npm } from "@opencode-ai/core/npm"
|
import { Npm } from "@opencode-ai/core/npm"
|
||||||
import { createPluginHost } from "../../plugin-host"
|
|
||||||
|
|
||||||
export default Runtime.handler(Commands, (input) =>
|
export default Runtime.handler(Commands, (input) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
|
|
@ -60,9 +59,10 @@ export default Runtime.handler(Commands, (input) =>
|
||||||
get: () => runPromise(config.get()),
|
get: () => runPromise(config.get()),
|
||||||
update: (update) => runPromise(config.update(update)),
|
update: (update) => runPromise(config.update(update)),
|
||||||
},
|
},
|
||||||
pluginHost: createPluginHost((spec) =>
|
packages: {
|
||||||
runPromise(npm.add(spec, { subpaths: ["tui"] }).pipe(Effect.map((result) => result.entrypoint))),
|
resolve: (spec) =>
|
||||||
),
|
runPromise(npm.add(spec, { subpaths: ["tui"] }).pipe(Effect.map((result) => result.entrypoint))),
|
||||||
|
},
|
||||||
terminalHandoff: () => preflight.finish(),
|
terminalHandoff: () => preflight.finish(),
|
||||||
log: (level, message, tags) => {
|
log: (level, message, tags) => {
|
||||||
const effect =
|
const effect =
|
||||||
|
|
|
||||||
|
|
@ -1,59 +0,0 @@
|
||||||
import type { Plugin } from "@opencode-ai/plugin/v2/tui"
|
|
||||||
import type { PluginHost } from "@opencode-ai/tui/plugin/context"
|
|
||||||
import path from "path"
|
|
||||||
import { stat } from "fs/promises"
|
|
||||||
import { fileURLToPath, pathToFileURL } from "url"
|
|
||||||
|
|
||||||
export function createPluginHost(resolvePackage: (spec: string) => Promise<string | undefined>): PluginHost {
|
|
||||||
return {
|
|
||||||
async load(spec, directory) {
|
|
||||||
const local = spec.startsWith("file://")
|
|
||||||
? new URL(spec)
|
|
||||||
: spec.startsWith("./") || spec.startsWith("../") || path.isAbsolute(spec)
|
|
||||||
? pathToFileURL(path.resolve(directory, spec))
|
|
||||||
: undefined
|
|
||||||
const entrypoint = local ? await resolveLocal(local) : await resolvePackage(spec)
|
|
||||||
if (!entrypoint) return
|
|
||||||
const mod: { readonly default?: unknown } = await import(entrypoint)
|
|
||||||
if (!isPlugin(mod.default)) throw new Error(`Invalid V2 TUI plugin module: ${spec}`)
|
|
||||||
return mod.default
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function resolveLocal(url: URL) {
|
|
||||||
const info = await stat(url)
|
|
||||||
if (info.isFile()) return url.href
|
|
||||||
if (!info.isDirectory()) return
|
|
||||||
const manifest = Bun.file(path.join(fileURLToPath(url), "package.json"))
|
|
||||||
if (await manifest.exists()) {
|
|
||||||
const value: unknown = await manifest.json()
|
|
||||||
if (typeof value === "object" && value !== null && "exports" in value) {
|
|
||||||
const exports = value.exports
|
|
||||||
const target =
|
|
||||||
typeof exports === "object" && exports !== null && "./tui" in exports ? exports["./tui"] : undefined
|
|
||||||
if (typeof target === "string") return pathToFileURL(path.resolve(fileURLToPath(url), target)).href
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return resolve(pathToFileURL(path.join(fileURLToPath(url), "tui")).href)
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolve(specifier: string) {
|
|
||||||
try {
|
|
||||||
return import.meta.resolve(specifier)
|
|
||||||
} catch {
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function isPlugin(value: unknown): value is Plugin.Definition {
|
|
||||||
return (
|
|
||||||
typeof value === "object" &&
|
|
||||||
value !== null &&
|
|
||||||
"id" in value &&
|
|
||||||
typeof value.id === "string" &&
|
|
||||||
value.id.length > 0 &&
|
|
||||||
"setup" in value &&
|
|
||||||
typeof value.setup === "function"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,56 +0,0 @@
|
||||||
import { afterEach, describe, expect, test } from "bun:test"
|
|
||||||
import path from "path"
|
|
||||||
import { createPluginHost } from "../src/plugin-host"
|
|
||||||
|
|
||||||
const directories: string[] = []
|
|
||||||
|
|
||||||
afterEach(async () => {
|
|
||||||
await Promise.all(directories.splice(0).map((directory) => Bun.$`rm -rf ${directory}`.quiet()))
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("plugin host", () => {
|
|
||||||
test("loads the tui export from a local package", async () => {
|
|
||||||
const directory = await temp()
|
|
||||||
await Bun.write(
|
|
||||||
path.join(directory, "package.json"),
|
|
||||||
JSON.stringify({ type: "module", exports: { "./tui": "./src/tui.js" } }),
|
|
||||||
)
|
|
||||||
await Bun.write(
|
|
||||||
path.join(directory, "src/tui.js"),
|
|
||||||
"export default { id: 'example.tui', setup() { return () => {} } }",
|
|
||||||
)
|
|
||||||
|
|
||||||
const plugin = await createPluginHost(async () => undefined).load(directory, directory)
|
|
||||||
|
|
||||||
expect(plugin?.id).toBe("example.tui")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("loads a package resolver tui entrypoint", async () => {
|
|
||||||
const directory = await temp()
|
|
||||||
const entrypoint = path.join(directory, "tui.js")
|
|
||||||
await Bun.write(entrypoint, "export default { id: 'npm.tui', setup() {} }")
|
|
||||||
|
|
||||||
const plugin = await createPluginHost(async (spec) => {
|
|
||||||
expect(spec).toBe("example-plugin")
|
|
||||||
return entrypoint
|
|
||||||
}).load("example-plugin", directory)
|
|
||||||
|
|
||||||
expect(plugin?.id).toBe("npm.tui")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("reports invalid tui exports without terminating the host", async () => {
|
|
||||||
const directory = await temp()
|
|
||||||
const entrypoint = path.join(directory, "tui.js")
|
|
||||||
await Bun.write(entrypoint, "export default { id: 'invalid' }")
|
|
||||||
const host = createPluginHost(async () => entrypoint)
|
|
||||||
|
|
||||||
expect(host.load("invalid-plugin", directory)).rejects.toThrow("Invalid V2 TUI plugin module: invalid-plugin")
|
|
||||||
expect(await host.load("unsupported-plugin", directory).catch(() => undefined)).toBeUndefined()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
async function temp() {
|
|
||||||
const directory = await Bun.$`mktemp -d`.text()
|
|
||||||
directories.push(directory.trim())
|
|
||||||
return directory.trim()
|
|
||||||
}
|
|
||||||
|
|
@ -34,7 +34,6 @@
|
||||||
"./prompt/content": "./src/prompt/content.ts",
|
"./prompt/content": "./src/prompt/content.ts",
|
||||||
"./prompt/display": "./src/prompt/display.ts",
|
"./prompt/display": "./src/prompt/display.ts",
|
||||||
"./plugin/runtime": "./src/plugin/runtime.tsx",
|
"./plugin/runtime": "./src/plugin/runtime.tsx",
|
||||||
"./plugin/context": "./src/plugin/context.tsx",
|
|
||||||
"./plugin/slots": "./src/plugin/slots.tsx",
|
"./plugin/slots": "./src/plugin/slots.tsx",
|
||||||
"./plugin/command-shim": "./src/plugin/command-shim.ts",
|
"./plugin/command-shim": "./src/plugin/command-shim.ts",
|
||||||
"./parsers-config": "./src/parsers-config.ts",
|
"./parsers-config": "./src/parsers-config.ts",
|
||||||
|
|
|
||||||
|
|
@ -78,7 +78,7 @@ import open from "open"
|
||||||
import { PromptRefProvider, usePromptRef } from "./context/prompt"
|
import { PromptRefProvider, usePromptRef } from "./context/prompt"
|
||||||
import { Config, ConfigProvider, useConfig } from "./config"
|
import { Config, ConfigProvider, useConfig } from "./config"
|
||||||
import { createPluginRuntime, PluginRuntimeProvider, usePluginRuntime } from "./plugin/runtime"
|
import { createPluginRuntime, PluginRuntimeProvider, usePluginRuntime } from "./plugin/runtime"
|
||||||
import { PluginProvider, PluginRoute, PluginSlot, usePlugin, type PluginHost } from "./plugin/context"
|
import { PluginProvider, PluginRoute, PluginSlot, usePlugin, type PackageResolver } from "./plugin/context"
|
||||||
import { CommandPaletteDialog } from "./component/command-palette"
|
import { CommandPaletteDialog } from "./component/command-palette"
|
||||||
import { COMMAND_PALETTE_COMMAND, Keymap, type KeymapCommand } from "./context/keymap"
|
import { COMMAND_PALETTE_COMMAND, Keymap, type KeymapCommand } from "./context/keymap"
|
||||||
|
|
||||||
|
|
@ -149,7 +149,7 @@ export type TuiInput = {
|
||||||
}
|
}
|
||||||
args: Args
|
args: Args
|
||||||
config: Config.Interface
|
config: Config.Interface
|
||||||
pluginHost: PluginHost
|
packages: PackageResolver
|
||||||
terminalHandoff?: () => Promise<
|
terminalHandoff?: () => Promise<
|
||||||
| {
|
| {
|
||||||
readonly renderer: CliRenderer
|
readonly renderer: CliRenderer
|
||||||
|
|
@ -346,7 +346,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
||||||
<PromptHistoryProvider>
|
<PromptHistoryProvider>
|
||||||
<PromptRefProvider>
|
<PromptRefProvider>
|
||||||
<EditorContextProvider>
|
<EditorContextProvider>
|
||||||
<PluginProvider pluginHost={input.pluginHost}>
|
<PluginProvider packages={input.packages}>
|
||||||
<App
|
<App
|
||||||
started={appStarted}
|
started={appStarted}
|
||||||
pair={
|
pair={
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,8 @@ import {
|
||||||
type ParentProps,
|
type ParentProps,
|
||||||
} from "solid-js"
|
} from "solid-js"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
|
import { stat } from "fs/promises"
|
||||||
|
import { fileURLToPath, pathToFileURL } from "url"
|
||||||
import type { Context, Page, Slot } from "@opencode-ai/plugin/v2/tui/context"
|
import type { Context, Page, Slot } from "@opencode-ai/plugin/v2/tui/context"
|
||||||
import { createStore, produce, reconcile as reconcileStore } from "solid-js/store"
|
import { createStore, produce, reconcile as reconcileStore } from "solid-js/store"
|
||||||
import { useConfig } from "../config"
|
import { useConfig } from "../config"
|
||||||
|
|
@ -22,8 +24,8 @@ import { useTuiLifecycle } from "../context/runtime"
|
||||||
import { useLocation } from "../context/location"
|
import { useLocation } from "../context/location"
|
||||||
import { builtins } from "./builtins"
|
import { builtins } from "./builtins"
|
||||||
|
|
||||||
export interface PluginHost {
|
export interface PackageResolver {
|
||||||
readonly load: (spec: string, directory: string) => Promise<Plugin.Definition | undefined>
|
readonly resolve: (spec: string) => Promise<string | undefined>
|
||||||
}
|
}
|
||||||
|
|
||||||
type State =
|
type State =
|
||||||
|
|
@ -54,7 +56,7 @@ type Registration = {
|
||||||
|
|
||||||
const PluginContext = createContext<Value>()
|
const PluginContext = createContext<Value>()
|
||||||
|
|
||||||
export function PluginProvider(props: ParentProps<{ pluginHost: PluginHost }>) {
|
export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>) {
|
||||||
const client = useClient()
|
const client = useClient()
|
||||||
const data = useData()
|
const data = useData()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
|
@ -229,7 +231,7 @@ export function PluginProvider(props: ParentProps<{ pluginHost: PluginHost }>) {
|
||||||
|
|
||||||
const options = typeof entry === "string" ? undefined : entry.options
|
const options = typeof entry === "string" ? undefined : entry.options
|
||||||
setStore("states", (items) => [...items, { target, status: "loading" }])
|
setStore("states", (items) => [...items, { target, status: "loading" }])
|
||||||
const plugin = await props.pluginHost.load(target, directory).catch((error) => {
|
const plugin = await loadPlugin(target, directory, props.packages).catch((error) => {
|
||||||
setStore("states", (items) =>
|
setStore("states", (items) =>
|
||||||
items.map((state) =>
|
items.map((state) =>
|
||||||
state.target === target
|
state.target === target
|
||||||
|
|
@ -331,6 +333,46 @@ function matches(selector: string, id: string) {
|
||||||
return selector === "*" || selector === id || (selector.endsWith(".*") && id.startsWith(selector.slice(0, -1)))
|
return selector === "*" || selector === id || (selector.endsWith(".*") && id.startsWith(selector.slice(0, -1)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadPlugin(spec: string, directory: string, packages: PackageResolver) {
|
||||||
|
const local = spec.startsWith("file://")
|
||||||
|
? new URL(spec)
|
||||||
|
: spec.startsWith("./") || spec.startsWith("../") || path.isAbsolute(spec)
|
||||||
|
? pathToFileURL(path.resolve(directory, spec))
|
||||||
|
: undefined
|
||||||
|
const entrypoint = local ? await resolveLocal(local) : await packages.resolve(spec)
|
||||||
|
if (!entrypoint) return
|
||||||
|
const mod: { readonly default?: unknown } = await import(entrypoint)
|
||||||
|
if (!isPlugin(mod.default)) throw new Error(`Invalid V2 TUI plugin module: ${spec}`)
|
||||||
|
return mod.default
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveLocal(url: URL) {
|
||||||
|
const info = await stat(url)
|
||||||
|
if (info.isFile()) return url.href
|
||||||
|
if (!info.isDirectory()) return
|
||||||
|
return resolve(pathToFileURL(path.join(fileURLToPath(url), "tui")).href)
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolve(specifier: string) {
|
||||||
|
try {
|
||||||
|
return import.meta.resolve(specifier)
|
||||||
|
} catch {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPlugin(value: unknown): value is Plugin.Definition {
|
||||||
|
return (
|
||||||
|
typeof value === "object" &&
|
||||||
|
value !== null &&
|
||||||
|
"id" in value &&
|
||||||
|
typeof value.id === "string" &&
|
||||||
|
value.id.length > 0 &&
|
||||||
|
"setup" in value &&
|
||||||
|
typeof value.setup === "function"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function usePlugin() {
|
export function usePlugin() {
|
||||||
const value = useContext(PluginContext)
|
const value = useContext(PluginContext)
|
||||||
if (!value) throw new Error("PluginProvider is missing")
|
if (!value) throw new Error("PluginProvider is missing")
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ test("SIGHUP clears title and disposes scoped resources once", async () => {
|
||||||
run({
|
run({
|
||||||
server: { endpoint: { url: server.url.toString() } },
|
server: { endpoint: { url: server.url.toString() } },
|
||||||
config: { get: async () => ({}), update: async () => ({}) },
|
config: { get: async () => ({}), update: async () => ({}) },
|
||||||
pluginHost: { load: async () => undefined },
|
packages: { resolve: async () => undefined },
|
||||||
args: {},
|
args: {},
|
||||||
log: () => {},
|
log: () => {},
|
||||||
}).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))),
|
}).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))),
|
||||||
|
|
@ -102,7 +102,7 @@ test("session lifecycle updates the terminal title and prints the epilogue after
|
||||||
run({
|
run({
|
||||||
server: { endpoint: { url: server.url.toString() } },
|
server: { endpoint: { url: server.url.toString() } },
|
||||||
config: { get: async () => ({}), update: async () => ({}) },
|
config: { get: async () => ({}), update: async () => ({}) },
|
||||||
pluginHost: { load: async () => undefined },
|
packages: { resolve: async () => undefined },
|
||||||
args: { sessionID: "dummy" },
|
args: { sessionID: "dummy" },
|
||||||
log: () => {},
|
log: () => {},
|
||||||
}).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))),
|
}).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))),
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue