Refactor plugin/config loading, add theme-only plugin package support (#20556)
parent
854484babf
commit
f6fd43e574
|
|
@ -10,6 +10,7 @@ Technical reference for the current TUI plugin system.
|
||||||
- Package plugins can be installed from CLI or TUI.
|
- Package plugins can be installed from CLI or TUI.
|
||||||
- v1 plugin modules are target-exclusive: a module can export `server` or `tui`, never both.
|
- v1 plugin modules are target-exclusive: a module can export `server` or `tui`, never both.
|
||||||
- Server runtime keeps v0 legacy fallback (function exports / enumerated exports) after v1 parsing.
|
- Server runtime keeps v0 legacy fallback (function exports / enumerated exports) after v1 parsing.
|
||||||
|
- npm packages can be TUI theme-only via `package.json["oc-themes"]` without a `./tui` entrypoint.
|
||||||
|
|
||||||
## TUI config
|
## TUI config
|
||||||
|
|
||||||
|
|
@ -88,7 +89,8 @@ export default plugin
|
||||||
- If package `exports` exists, loader only resolves `./tui` or `./server`; it never falls back to `exports["."]`.
|
- If package `exports` exists, loader only resolves `./tui` or `./server`; it never falls back to `exports["."]`.
|
||||||
- For npm package specs, TUI does not use `package.json` `main` as a fallback entry.
|
- For npm package specs, TUI does not use `package.json` `main` as a fallback entry.
|
||||||
- `package.json` `main` is only used for server plugin entrypoint resolution.
|
- `package.json` `main` is only used for server plugin entrypoint resolution.
|
||||||
- If a configured plugin has no target-specific entrypoint, it is skipped with a warning (not a load failure).
|
- If a configured TUI package has no `./tui` entrypoint and no valid `oc-themes`, it is skipped with a warning (not a load failure).
|
||||||
|
- If a configured TUI package has no `./tui` entrypoint but has valid `oc-themes`, runtime creates a no-op module record and still loads it for theme sync and plugin state.
|
||||||
- If a package supports both server and TUI, use separate files and package `exports` (`./server` and `./tui`) so each target resolves to a target-only module.
|
- If a package supports both server and TUI, use separate files and package `exports` (`./server` and `./tui`) so each target resolves to a target-only module.
|
||||||
- File/path plugins must export a non-empty `id`.
|
- File/path plugins must export a non-empty `id`.
|
||||||
- npm plugins may omit `id`; package `name` is used.
|
- npm plugins may omit `id`; package `name` is used.
|
||||||
|
|
@ -101,10 +103,18 @@ export default plugin
|
||||||
|
|
||||||
## Package manifest and install
|
## Package manifest and install
|
||||||
|
|
||||||
Install target detection is inferred from `package.json` entrypoints:
|
Install target detection is inferred from `package.json` entrypoints and theme metadata:
|
||||||
|
|
||||||
- `server` target when `exports["./server"]` exists or `main` is set.
|
- `server` target when `exports["./server"]` exists or `main` is set.
|
||||||
- `tui` target when `exports["./tui"]` exists.
|
- `tui` target when `exports["./tui"]` exists.
|
||||||
|
- `tui` target when `oc-themes` exists and resolves to a non-empty set of valid package-relative theme paths.
|
||||||
|
|
||||||
|
`oc-themes` rules:
|
||||||
|
|
||||||
|
- `oc-themes` is an array of relative paths.
|
||||||
|
- Absolute paths and `file://` paths are rejected.
|
||||||
|
- Resolved theme paths must stay inside the package directory.
|
||||||
|
- Invalid `oc-themes` causes manifest read failure for install.
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
|
|
||||||
|
|
@ -289,9 +299,12 @@ Theme install behavior:
|
||||||
|
|
||||||
- Relative theme paths are resolved from the plugin root.
|
- Relative theme paths are resolved from the plugin root.
|
||||||
- Theme name is the JSON basename.
|
- Theme name is the JSON basename.
|
||||||
|
- `api.theme.install(...)` and `oc-themes` auto-sync share the same installer path.
|
||||||
|
- Theme copy/write runs under cross-process lock key `tui-theme:<dest>`.
|
||||||
- First install writes only when the destination file is missing.
|
- First install writes only when the destination file is missing.
|
||||||
- If the theme name already exists, install is skipped unless plugin metadata state is `updated`.
|
- If the theme name already exists, install is skipped unless plugin metadata state is `updated`.
|
||||||
- On `updated`, host only rewrites themes previously tracked for that plugin and only when source `mtime`/`size` changed.
|
- On `updated`, host skips rewrite when tracked `mtime`/`size` is unchanged.
|
||||||
|
- When a theme already exists and state is not `updated`, host can still persist theme metadata when destination already exists.
|
||||||
- Local plugins persist installed themes under the local `.opencode/themes` area near the plugin config source.
|
- Local plugins persist installed themes under the local `.opencode/themes` area near the plugin config source.
|
||||||
- Global plugins persist installed themes under the global `themes` dir.
|
- Global plugins persist installed themes under the global `themes` dir.
|
||||||
- Invalid or unreadable theme files are ignored.
|
- Invalid or unreadable theme files are ignored.
|
||||||
|
|
@ -328,6 +341,7 @@ Slot notes:
|
||||||
- `api.plugins.add(spec)` treats the input as the runtime plugin spec and loads it without re-reading `tui.json`.
|
- `api.plugins.add(spec)` treats the input as the runtime plugin spec and loads it without re-reading `tui.json`.
|
||||||
- `api.plugins.add(spec)` no-ops when that resolved spec (or resolved plugin id) is already loaded.
|
- `api.plugins.add(spec)` no-ops when that resolved spec (or resolved plugin id) is already loaded.
|
||||||
- `api.plugins.add(spec)` assumes enabled and always attempts initialization (it does not consult config/KV enable state).
|
- `api.plugins.add(spec)` assumes enabled and always attempts initialization (it does not consult config/KV enable state).
|
||||||
|
- `api.plugins.add(spec)` can load theme-only packages (`oc-themes` with no `./tui`) as runtime entries.
|
||||||
- `api.plugins.install(spec, { global? })` runs install -> manifest read -> config patch using the same helper flow as CLI install.
|
- `api.plugins.install(spec, { global? })` runs install -> manifest read -> config patch using the same helper flow as CLI install.
|
||||||
- `api.plugins.install(...)` returns either `{ ok: false, message, missing? }` or `{ ok: true, dir, tui }`.
|
- `api.plugins.install(...)` returns either `{ ok: false, message, missing? }` or `{ ok: true, dir, tui }`.
|
||||||
- `api.plugins.install(...)` does not load plugins into the current session. Call `api.plugins.add(spec)` to load after install.
|
- `api.plugins.install(...)` does not load plugins into the current session. Call `api.plugins.add(spec)` to load after install.
|
||||||
|
|
@ -357,7 +371,11 @@ Metadata is persisted by plugin id.
|
||||||
- External TUI plugins load from `tuiConfig.plugin`.
|
- External TUI plugins load from `tuiConfig.plugin`.
|
||||||
- `--pure` / `OPENCODE_PURE` skips external TUI plugins only.
|
- `--pure` / `OPENCODE_PURE` skips external TUI plugins only.
|
||||||
- External plugin resolution and import are parallel.
|
- External plugin resolution and import are parallel.
|
||||||
|
- Packages with no `./tui` entrypoint and valid `oc-themes` are loaded as synthetic no-op TUI plugin modules.
|
||||||
|
- Theme-only packages loaded this way appear in `api.plugins.list()` and plugin manager rows like other external plugins.
|
||||||
|
- Packages with no `./tui` entrypoint and no valid `oc-themes` are skipped with warning.
|
||||||
- External plugin activation is sequential to keep command, route, and side-effect order deterministic.
|
- External plugin activation is sequential to keep command, route, and side-effect order deterministic.
|
||||||
|
- Theme auto-sync from `oc-themes` runs before plugin `tui(...)` execution and only on metadata state `first` or `updated`.
|
||||||
- File plugins that fail initially are retried once after waiting for config dependency installation.
|
- File plugins that fail initially are retried once after waiting for config dependency installation.
|
||||||
- Runtime add uses the same external loader path, including the file-plugin retry after dependency wait.
|
- Runtime add uses the same external loader path, including the file-plugin retry after dependency wait.
|
||||||
- Runtime add skips duplicates by resolved spec and returns `true` when the spec is already loaded.
|
- Runtime add skips duplicates by resolved spec and returns `true` when the spec is already loaded.
|
||||||
|
|
@ -400,6 +418,7 @@ The plugin manager is exposed as a command with title `Plugins` and value `plugi
|
||||||
- Install is blocked until `api.state.path.directory` is available; current guard message is `Paths are still syncing. Try again in a moment.`.
|
- Install is blocked until `api.state.path.directory` is available; current guard message is `Paths are still syncing. Try again in a moment.`.
|
||||||
- Manager install uses `api.plugins.install(spec, { global })`.
|
- Manager install uses `api.plugins.install(spec, { global })`.
|
||||||
- If the installed package has no `tui` target (`tui=false`), manager reports that and does not expect a runtime load.
|
- If the installed package has no `tui` target (`tui=false`), manager reports that and does not expect a runtime load.
|
||||||
|
- `tui` target detection includes `exports["./tui"]` and valid `oc-themes`.
|
||||||
- If install reports `tui=true`, manager then calls `api.plugins.add(spec)`.
|
- If install reports `tui=true`, manager then calls `api.plugins.add(spec)`.
|
||||||
- If runtime add fails, TUI shows a warning and restart remains the fallback.
|
- If runtime add fails, TUI shows a warning and restart remains the fallback.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -115,7 +115,9 @@ export function createPlugTask(input: PlugInput, dep: PlugDeps = defaultPlugDeps
|
||||||
if (manifest.code === "manifest_no_targets") {
|
if (manifest.code === "manifest_no_targets") {
|
||||||
inspect.stop("No plugin targets found", 1)
|
inspect.stop("No plugin targets found", 1)
|
||||||
dep.log.error(`"${mod}" does not expose plugin entrypoints in package.json`)
|
dep.log.error(`"${mod}" does not expose plugin entrypoints in package.json`)
|
||||||
dep.log.info('Expected one of: exports["./tui"], exports["./server"], or package.json main for server.')
|
dep.log.info(
|
||||||
|
'Expected one of: exports["./tui"], exports["./server"], package.json main for server, or package.json["oc-themes"] for tui themes.',
|
||||||
|
)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,14 @@ import { Log } from "@/util/log"
|
||||||
import { errorData, errorMessage } from "@/util/error"
|
import { errorData, errorMessage } from "@/util/error"
|
||||||
import { isRecord } from "@/util/record"
|
import { isRecord } from "@/util/record"
|
||||||
import { Instance } from "@/project/instance"
|
import { Instance } from "@/project/instance"
|
||||||
import { pluginSource, readPluginId, readV1Plugin, resolvePluginId, type PluginSource } from "@/plugin/shared"
|
import {
|
||||||
|
readPackageThemes,
|
||||||
|
readPluginId,
|
||||||
|
readV1Plugin,
|
||||||
|
resolvePluginId,
|
||||||
|
type PluginPackage,
|
||||||
|
type PluginSource,
|
||||||
|
} from "@/plugin/shared"
|
||||||
import { PluginLoader } from "@/plugin/loader"
|
import { PluginLoader } from "@/plugin/loader"
|
||||||
import { PluginMeta } from "@/plugin/meta"
|
import { PluginMeta } from "@/plugin/meta"
|
||||||
import { installPlugin as installModulePlugin, patchPluginConfig, readPluginManifest } from "@/plugin/install"
|
import { installPlugin as installModulePlugin, patchPluginConfig, readPluginManifest } from "@/plugin/install"
|
||||||
|
|
@ -26,6 +33,7 @@ import { hasTheme, upsertTheme } from "../context/theme"
|
||||||
import { Global } from "@/global"
|
import { Global } from "@/global"
|
||||||
import { Filesystem } from "@/util/filesystem"
|
import { Filesystem } from "@/util/filesystem"
|
||||||
import { Process } from "@/util/process"
|
import { Process } from "@/util/process"
|
||||||
|
import { Flock } from "@/util/flock"
|
||||||
import { Flag } from "@/flag/flag"
|
import { Flag } from "@/flag/flag"
|
||||||
import { INTERNAL_TUI_PLUGINS, type InternalTuiPlugin } from "./internal"
|
import { INTERNAL_TUI_PLUGINS, type InternalTuiPlugin } from "./internal"
|
||||||
import { setupSlots, Slot as View } from "./slots"
|
import { setupSlots, Slot as View } from "./slots"
|
||||||
|
|
@ -39,8 +47,9 @@ type PluginLoad = {
|
||||||
source: PluginSource | "internal"
|
source: PluginSource | "internal"
|
||||||
id: string
|
id: string
|
||||||
module: TuiPluginModule
|
module: TuiPluginModule
|
||||||
theme_meta: TuiConfig.PluginMeta
|
origin: Config.PluginOrigin
|
||||||
theme_root: string
|
theme_root: string
|
||||||
|
theme_files: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
type Api = HostPluginApi
|
type Api = HostPluginApi
|
||||||
|
|
@ -67,12 +76,15 @@ type RuntimeState = {
|
||||||
slots: HostSlots
|
slots: HostSlots
|
||||||
plugins: PluginEntry[]
|
plugins: PluginEntry[]
|
||||||
plugins_by_id: Map<string, PluginEntry>
|
plugins_by_id: Map<string, PluginEntry>
|
||||||
pending: Map<string, TuiConfig.PluginRecord>
|
pending: Map<string, Config.PluginOrigin>
|
||||||
}
|
}
|
||||||
|
|
||||||
const log = Log.create({ service: "tui.plugin" })
|
const log = Log.create({ service: "tui.plugin" })
|
||||||
const DISPOSE_TIMEOUT_MS = 5000
|
const DISPOSE_TIMEOUT_MS = 5000
|
||||||
const KV_KEY = "plugin_enabled"
|
const KV_KEY = "plugin_enabled"
|
||||||
|
const EMPTY_TUI: TuiPluginModule = {
|
||||||
|
tui: async () => {},
|
||||||
|
}
|
||||||
|
|
||||||
function fail(message: string, data: Record<string, unknown>) {
|
function fail(message: string, data: Record<string, unknown>) {
|
||||||
if (!("error" in data)) {
|
if (!("error" in data)) {
|
||||||
|
|
@ -134,7 +146,7 @@ function resolveRoot(root: string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function createThemeInstaller(
|
function createThemeInstaller(
|
||||||
meta: TuiConfig.PluginMeta,
|
meta: Config.PluginOrigin,
|
||||||
root: string,
|
root: string,
|
||||||
spec: string,
|
spec: string,
|
||||||
plugin: PluginEntry,
|
plugin: PluginEntry,
|
||||||
|
|
@ -153,162 +165,73 @@ function createThemeInstaller(
|
||||||
const stat = await Filesystem.statAsync(src)
|
const stat = await Filesystem.statAsync(src)
|
||||||
const mtime = stat ? Math.floor(typeof stat.mtimeMs === "bigint" ? Number(stat.mtimeMs) : stat.mtimeMs) : undefined
|
const mtime = stat ? Math.floor(typeof stat.mtimeMs === "bigint" ? Number(stat.mtimeMs) : stat.mtimeMs) : undefined
|
||||||
const size = stat ? (typeof stat.size === "bigint" ? Number(stat.size) : stat.size) : undefined
|
const size = stat ? (typeof stat.size === "bigint" ? Number(stat.size) : stat.size) : undefined
|
||||||
const exists = hasTheme(name)
|
const info = {
|
||||||
const prev = plugin.themes[name]
|
|
||||||
|
|
||||||
if (exists) {
|
|
||||||
if (plugin.meta.state !== "updated") return
|
|
||||||
if (!prev) {
|
|
||||||
if (await Filesystem.exists(dest)) {
|
|
||||||
plugin.themes[name] = {
|
|
||||||
src,
|
|
||||||
dest,
|
|
||||||
mtime,
|
|
||||||
size,
|
|
||||||
}
|
|
||||||
await PluginMeta.setTheme(plugin.id, name, plugin.themes[name]!).catch((error) => {
|
|
||||||
log.warn("failed to track tui plugin theme", {
|
|
||||||
path: spec,
|
|
||||||
id: plugin.id,
|
|
||||||
theme: src,
|
|
||||||
dest,
|
|
||||||
error,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (prev.dest !== dest) return
|
|
||||||
if (prev.mtime === mtime && prev.size === size) return
|
|
||||||
}
|
|
||||||
|
|
||||||
const text = await Filesystem.readText(src).catch((error) => {
|
|
||||||
log.warn("failed to read tui plugin theme", { path: spec, theme: src, error })
|
|
||||||
return
|
|
||||||
})
|
|
||||||
if (text === undefined) return
|
|
||||||
|
|
||||||
const fail = Symbol()
|
|
||||||
const data = await Promise.resolve(text)
|
|
||||||
.then((x) => JSON.parse(x))
|
|
||||||
.catch((error) => {
|
|
||||||
log.warn("failed to parse tui plugin theme", { path: spec, theme: src, error })
|
|
||||||
return fail
|
|
||||||
})
|
|
||||||
if (data === fail) return
|
|
||||||
|
|
||||||
if (!isTheme(data)) {
|
|
||||||
log.warn("invalid tui plugin theme", { path: spec, theme: src })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (exists || !(await Filesystem.exists(dest))) {
|
|
||||||
await Filesystem.write(dest, text).catch((error) => {
|
|
||||||
log.warn("failed to persist tui plugin theme", { path: spec, theme: src, dest, error })
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
upsertTheme(name, data)
|
|
||||||
plugin.themes[name] = {
|
|
||||||
src,
|
src,
|
||||||
dest,
|
dest,
|
||||||
mtime,
|
mtime,
|
||||||
size,
|
size,
|
||||||
}
|
}
|
||||||
await PluginMeta.setTheme(plugin.id, name, plugin.themes[name]!).catch((error) => {
|
|
||||||
log.warn("failed to track tui plugin theme", {
|
await Flock.withLock(`tui-theme:${dest}`, async () => {
|
||||||
path: spec,
|
const save = async () => {
|
||||||
id: plugin.id,
|
plugin.themes[name] = info
|
||||||
theme: src,
|
await PluginMeta.setTheme(plugin.id, name, info).catch((error) => {
|
||||||
dest,
|
log.warn("failed to track tui plugin theme", {
|
||||||
error,
|
path: spec,
|
||||||
|
id: plugin.id,
|
||||||
|
theme: src,
|
||||||
|
dest,
|
||||||
|
error,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const exists = hasTheme(name)
|
||||||
|
const prev = plugin.themes[name]
|
||||||
|
if (exists) {
|
||||||
|
if (plugin.meta.state !== "updated") {
|
||||||
|
if (!prev && (await Filesystem.exists(dest))) {
|
||||||
|
await save()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (prev?.dest === dest && prev.mtime === mtime && prev.size === size) return
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = await Filesystem.readText(src).catch((error) => {
|
||||||
|
log.warn("failed to read tui plugin theme", { path: spec, theme: src, error })
|
||||||
|
return
|
||||||
})
|
})
|
||||||
|
if (text === undefined) return
|
||||||
|
|
||||||
|
const fail = Symbol()
|
||||||
|
const data = await Promise.resolve(text)
|
||||||
|
.then((x) => JSON.parse(x))
|
||||||
|
.catch((error) => {
|
||||||
|
log.warn("failed to parse tui plugin theme", { path: spec, theme: src, error })
|
||||||
|
return fail
|
||||||
|
})
|
||||||
|
if (data === fail) return
|
||||||
|
|
||||||
|
if (!isTheme(data)) {
|
||||||
|
log.warn("invalid tui plugin theme", { path: spec, theme: src })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (exists || !(await Filesystem.exists(dest))) {
|
||||||
|
await Filesystem.write(dest, text).catch((error) => {
|
||||||
|
log.warn("failed to persist tui plugin theme", { path: spec, theme: src, dest, error })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
upsertTheme(name, data)
|
||||||
|
await save()
|
||||||
|
}).catch((error) => {
|
||||||
|
log.warn("failed to lock tui plugin theme install", { path: spec, theme: src, dest, error })
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadExternalPlugin(cfg: TuiConfig.PluginRecord, retry = false): Promise<PluginLoad | undefined> {
|
|
||||||
const plan = PluginLoader.plan(cfg.item)
|
|
||||||
if (plan.deprecated) return
|
|
||||||
|
|
||||||
log.info("loading tui plugin", { path: plan.spec, retry })
|
|
||||||
const resolved = await PluginLoader.resolve(plan, "tui")
|
|
||||||
if (!resolved.ok) {
|
|
||||||
if (resolved.stage === "missing") {
|
|
||||||
warn("tui plugin has no entrypoint", {
|
|
||||||
path: plan.spec,
|
|
||||||
retry,
|
|
||||||
message: resolved.message,
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (resolved.stage === "install") {
|
|
||||||
fail("failed to resolve tui plugin", { path: plan.spec, retry, error: resolved.error })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (resolved.stage === "compatibility") {
|
|
||||||
fail("tui plugin incompatible", { path: plan.spec, retry, error: resolved.error })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
fail("failed to resolve tui plugin entry", { path: plan.spec, retry, error: resolved.error })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const loaded = await PluginLoader.load(resolved.value)
|
|
||||||
if (!loaded.ok) {
|
|
||||||
fail("failed to load tui plugin", {
|
|
||||||
path: plan.spec,
|
|
||||||
target: resolved.value.entry,
|
|
||||||
retry,
|
|
||||||
error: loaded.error,
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const mod = await Promise.resolve()
|
|
||||||
.then(() => {
|
|
||||||
return readV1Plugin(loaded.value.mod as Record<string, unknown>, plan.spec, "tui") as TuiPluginModule
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
fail("failed to load tui plugin", {
|
|
||||||
path: plan.spec,
|
|
||||||
target: loaded.value.entry,
|
|
||||||
retry,
|
|
||||||
error,
|
|
||||||
})
|
|
||||||
return
|
|
||||||
})
|
|
||||||
if (!mod) return
|
|
||||||
|
|
||||||
const id = await resolvePluginId(
|
|
||||||
loaded.value.source,
|
|
||||||
plan.spec,
|
|
||||||
loaded.value.target,
|
|
||||||
readPluginId(mod.id, plan.spec),
|
|
||||||
loaded.value.pkg,
|
|
||||||
).catch((error) => {
|
|
||||||
fail("failed to load tui plugin", { path: plan.spec, target: loaded.value.target, retry, error })
|
|
||||||
return
|
|
||||||
})
|
|
||||||
if (!id) return
|
|
||||||
|
|
||||||
return {
|
|
||||||
options: plan.options,
|
|
||||||
spec: plan.spec,
|
|
||||||
target: loaded.value.target,
|
|
||||||
retry,
|
|
||||||
source: loaded.value.source,
|
|
||||||
id,
|
|
||||||
module: mod,
|
|
||||||
theme_meta: {
|
|
||||||
scope: cfg.scope,
|
|
||||||
source: cfg.source,
|
|
||||||
},
|
|
||||||
theme_root: loaded.value.pkg?.dir ?? resolveRoot(loaded.value.target),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function createMeta(
|
function createMeta(
|
||||||
source: PluginLoad["source"],
|
source: PluginLoad["source"],
|
||||||
spec: string,
|
spec: string,
|
||||||
|
|
@ -350,11 +273,38 @@ function loadInternalPlugin(item: InternalTuiPlugin): PluginLoad {
|
||||||
source: "internal",
|
source: "internal",
|
||||||
id: item.id,
|
id: item.id,
|
||||||
module: item,
|
module: item,
|
||||||
theme_meta: {
|
origin: {
|
||||||
|
spec,
|
||||||
scope: "global",
|
scope: "global",
|
||||||
source: target,
|
source: target,
|
||||||
},
|
},
|
||||||
theme_root: process.cwd(),
|
theme_root: process.cwd(),
|
||||||
|
theme_files: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readThemeFiles(spec: string, pkg?: PluginPackage) {
|
||||||
|
if (!pkg) return [] as string[]
|
||||||
|
return Promise.resolve()
|
||||||
|
.then(() => readPackageThemes(spec, pkg))
|
||||||
|
.catch((error) => {
|
||||||
|
warn("invalid tui plugin oc-themes", {
|
||||||
|
path: spec,
|
||||||
|
pkg: pkg.pkg,
|
||||||
|
error,
|
||||||
|
})
|
||||||
|
return [] as string[]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncPluginThemes(plugin: PluginEntry) {
|
||||||
|
if (!plugin.load.theme_files.length) return
|
||||||
|
if (plugin.meta.state === "same") return
|
||||||
|
const install = createThemeInstaller(plugin.load.origin, plugin.load.theme_root, plugin.load.spec, plugin)
|
||||||
|
for (const file of plugin.load.theme_files) {
|
||||||
|
await install(file).catch((error) => {
|
||||||
|
warn("failed to sync tui plugin oc-themes", { path: plugin.load.spec, id: plugin.id, theme: file, error })
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -489,6 +439,7 @@ async function activatePluginEntry(state: RuntimeState, plugin: PluginEntry, per
|
||||||
const api = pluginApi(state, plugin, scope, plugin.id)
|
const api = pluginApi(state, plugin, scope, plugin.id)
|
||||||
const ok = await Promise.resolve()
|
const ok = await Promise.resolve()
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
|
await syncPluginThemes(plugin)
|
||||||
await plugin.plugin(api, plugin.load.options, plugin.meta)
|
await plugin.plugin(api, plugin.load.options, plugin.meta)
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
|
@ -555,7 +506,7 @@ function pluginApi(runtime: RuntimeState, plugin: PluginEntry, scope: PluginScop
|
||||||
}
|
}
|
||||||
|
|
||||||
const theme: TuiPluginApi["theme"] = Object.assign(Object.create(api.theme), {
|
const theme: TuiPluginApi["theme"] = Object.assign(Object.create(api.theme), {
|
||||||
install: createThemeInstaller(load.theme_meta, load.theme_root, load.spec, plugin),
|
install: createThemeInstaller(load.origin, load.theme_root, load.spec, plugin),
|
||||||
})
|
})
|
||||||
|
|
||||||
const event: TuiPluginApi["event"] = {
|
const event: TuiPluginApi["event"] = {
|
||||||
|
|
@ -637,28 +588,108 @@ function applyInitialPluginEnabledState(state: RuntimeState, config: TuiConfig.I
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resolveExternalPlugins(list: TuiConfig.PluginRecord[], wait: () => Promise<void>) {
|
async function resolveExternalPlugins(list: Config.PluginOrigin[], wait: () => Promise<void>) {
|
||||||
const loaded = await Promise.all(list.map((item) => loadExternalPlugin(item)))
|
return PluginLoader.loadExternal({
|
||||||
const ready: PluginLoad[] = []
|
items: list,
|
||||||
let deps: Promise<void> | undefined
|
kind: "tui",
|
||||||
|
wait: async () => {
|
||||||
for (let i = 0; i < list.length; i++) {
|
await wait().catch((error) => {
|
||||||
let entry = loaded[i]
|
|
||||||
if (!entry) {
|
|
||||||
const item = list[i]
|
|
||||||
if (!item) continue
|
|
||||||
if (pluginSource(Config.pluginSpecifier(item.item)) !== "file") continue
|
|
||||||
deps ??= wait().catch((error) => {
|
|
||||||
log.warn("failed waiting for tui plugin dependencies", { error })
|
log.warn("failed waiting for tui plugin dependencies", { error })
|
||||||
})
|
})
|
||||||
await deps
|
},
|
||||||
entry = await loadExternalPlugin(item, true)
|
finish: async (loaded, origin, retry) => {
|
||||||
}
|
const mod = await Promise.resolve()
|
||||||
if (!entry) continue
|
.then(() => readV1Plugin(loaded.mod as Record<string, unknown>, loaded.spec, "tui") as TuiPluginModule)
|
||||||
ready.push(entry)
|
.catch((error) => {
|
||||||
}
|
fail("failed to load tui plugin", {
|
||||||
|
path: loaded.spec,
|
||||||
|
target: loaded.entry,
|
||||||
|
retry,
|
||||||
|
error,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
})
|
||||||
|
if (!mod) return
|
||||||
|
|
||||||
return ready
|
const id = await resolvePluginId(
|
||||||
|
loaded.source,
|
||||||
|
loaded.spec,
|
||||||
|
loaded.target,
|
||||||
|
readPluginId(mod.id, loaded.spec),
|
||||||
|
loaded.pkg,
|
||||||
|
).catch((error) => {
|
||||||
|
fail("failed to load tui plugin", { path: loaded.spec, target: loaded.target, retry, error })
|
||||||
|
return
|
||||||
|
})
|
||||||
|
if (!id) return
|
||||||
|
|
||||||
|
const theme_files = await readThemeFiles(loaded.spec, loaded.pkg)
|
||||||
|
|
||||||
|
return {
|
||||||
|
options: loaded.options,
|
||||||
|
spec: loaded.spec,
|
||||||
|
target: loaded.target,
|
||||||
|
retry,
|
||||||
|
source: loaded.source,
|
||||||
|
id,
|
||||||
|
module: mod,
|
||||||
|
origin,
|
||||||
|
theme_root: loaded.pkg?.dir ?? resolveRoot(loaded.target),
|
||||||
|
theme_files,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
missing: async (loaded, origin, retry) => {
|
||||||
|
const theme_files = await readThemeFiles(loaded.spec, loaded.pkg)
|
||||||
|
if (!theme_files.length) return
|
||||||
|
|
||||||
|
const name =
|
||||||
|
typeof loaded.pkg?.json.name === "string" && loaded.pkg.json.name.trim().length > 0
|
||||||
|
? loaded.pkg.json.name.trim()
|
||||||
|
: undefined
|
||||||
|
const id = await resolvePluginId(loaded.source, loaded.spec, loaded.target, name, loaded.pkg).catch((error) => {
|
||||||
|
fail("failed to load tui plugin", { path: loaded.spec, target: loaded.target, retry, error })
|
||||||
|
return
|
||||||
|
})
|
||||||
|
if (!id) return
|
||||||
|
|
||||||
|
return {
|
||||||
|
options: loaded.options,
|
||||||
|
spec: loaded.spec,
|
||||||
|
target: loaded.target,
|
||||||
|
retry,
|
||||||
|
source: loaded.source,
|
||||||
|
id,
|
||||||
|
module: EMPTY_TUI,
|
||||||
|
origin,
|
||||||
|
theme_root: loaded.pkg?.dir ?? resolveRoot(loaded.target),
|
||||||
|
theme_files,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
report: {
|
||||||
|
start(candidate, retry) {
|
||||||
|
log.info("loading tui plugin", { path: candidate.plan.spec, retry })
|
||||||
|
},
|
||||||
|
missing(candidate, retry, message) {
|
||||||
|
warn("tui plugin has no entrypoint", { path: candidate.plan.spec, retry, message })
|
||||||
|
},
|
||||||
|
error(candidate, retry, stage, error, resolved) {
|
||||||
|
const spec = candidate.plan.spec
|
||||||
|
if (stage === "install") {
|
||||||
|
fail("failed to resolve tui plugin", { path: spec, retry, error })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (stage === "compatibility") {
|
||||||
|
fail("tui plugin incompatible", { path: spec, retry, error })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (stage === "entry") {
|
||||||
|
fail("failed to resolve tui plugin entry", { path: spec, retry, error })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fail("failed to load tui plugin", { path: spec, target: resolved?.entry, retry, error })
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async function addExternalPluginEntries(state: RuntimeState, ready: PluginLoad[]) {
|
async function addExternalPluginEntries(state: RuntimeState, ready: PluginLoad[]) {
|
||||||
|
|
@ -692,12 +723,12 @@ async function addExternalPluginEntries(state: RuntimeState, ready: PluginLoad[]
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const row = createMeta(entry.source, entry.spec, entry.target, hit, entry.id)
|
const info = createMeta(entry.source, entry.spec, entry.target, hit, entry.id)
|
||||||
const themes = hit?.entry.themes ? { ...hit.entry.themes } : {}
|
const themes = hit?.entry.themes ? { ...hit.entry.themes } : {}
|
||||||
const plugin: PluginEntry = {
|
const plugin: PluginEntry = {
|
||||||
id: entry.id,
|
id: entry.id,
|
||||||
load: entry,
|
load: entry,
|
||||||
meta: row,
|
meta: info,
|
||||||
themes,
|
themes,
|
||||||
plugin: entry.module.tui,
|
plugin: entry.module.tui,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
|
@ -712,9 +743,9 @@ async function addExternalPluginEntries(state: RuntimeState, ready: PluginLoad[]
|
||||||
return { plugins, ok }
|
return { plugins, ok }
|
||||||
}
|
}
|
||||||
|
|
||||||
function defaultPluginRecord(state: RuntimeState, spec: string): TuiConfig.PluginRecord {
|
function defaultPluginOrigin(state: RuntimeState, spec: string): Config.PluginOrigin {
|
||||||
return {
|
return {
|
||||||
item: spec,
|
spec,
|
||||||
scope: "local",
|
scope: "local",
|
||||||
source: state.api.state.path.config || path.join(state.directory, ".opencode", "tui.json"),
|
source: state.api.state.path.config || path.join(state.directory, ".opencode", "tui.json"),
|
||||||
}
|
}
|
||||||
|
|
@ -752,8 +783,8 @@ async function addPluginBySpec(state: RuntimeState | undefined, raw: string) {
|
||||||
const spec = raw.trim()
|
const spec = raw.trim()
|
||||||
if (!spec) return false
|
if (!spec) return false
|
||||||
|
|
||||||
const cfg = state.pending.get(spec) ?? defaultPluginRecord(state, spec)
|
const cfg = state.pending.get(spec) ?? defaultPluginOrigin(state, spec)
|
||||||
const next = Config.pluginSpecifier(cfg.item)
|
const next = Config.pluginSpecifier(cfg.spec)
|
||||||
if (state.plugins.some((plugin) => plugin.load.spec === next)) {
|
if (state.plugins.some((plugin) => plugin.load.spec === next)) {
|
||||||
state.pending.delete(spec)
|
state.pending.delete(spec)
|
||||||
return true
|
return true
|
||||||
|
|
@ -837,7 +868,7 @@ async function installPluginBySpec(
|
||||||
if (manifest.code === "manifest_no_targets") {
|
if (manifest.code === "manifest_no_targets") {
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
message: `"${spec}" does not expose plugin entrypoints in package.json`,
|
message: `"${spec}" does not expose plugin entrypoints or oc-themes in package.json`,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -872,9 +903,9 @@ async function installPluginBySpec(
|
||||||
const tui = manifest.targets.find((item) => item.kind === "tui")
|
const tui = manifest.targets.find((item) => item.kind === "tui")
|
||||||
if (tui) {
|
if (tui) {
|
||||||
const file = patch.items.find((item) => item.kind === "tui")?.file
|
const file = patch.items.find((item) => item.kind === "tui")?.file
|
||||||
const item = tui.opts ? ([spec, tui.opts] as Config.PluginSpec) : spec
|
const next = tui.opts ? ([spec, tui.opts] as Config.PluginSpec) : spec
|
||||||
state.pending.set(spec, {
|
state.pending.set(spec, {
|
||||||
item,
|
spec: next,
|
||||||
scope: global ? "global" : "local",
|
scope: global ? "global" : "local",
|
||||||
source: (file ?? dir.config) || path.join(patch.dir, "tui.json"),
|
source: (file ?? dir.config) || path.join(patch.dir, "tui.json"),
|
||||||
})
|
})
|
||||||
|
|
@ -959,9 +990,9 @@ export namespace TuiPluginRuntime {
|
||||||
directory: cwd,
|
directory: cwd,
|
||||||
fn: async () => {
|
fn: async () => {
|
||||||
const config = await TuiConfig.get()
|
const config = await TuiConfig.get()
|
||||||
const records = Flag.OPENCODE_PURE ? [] : (config.plugin_records ?? [])
|
const records = Flag.OPENCODE_PURE ? [] : (config.plugin_origins ?? [])
|
||||||
if (Flag.OPENCODE_PURE && config.plugin_records?.length) {
|
if (Flag.OPENCODE_PURE && config.plugin_origins?.length) {
|
||||||
log.info("skipping external tui plugins in pure mode", { count: config.plugin_records.length })
|
log.info("skipping external tui plugins in pure mode", { count: config.plugin_origins.length })
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const item of INTERNAL_TUI_PLUGINS) {
|
for (const item of INTERNAL_TUI_PLUGINS) {
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,12 @@ export namespace Config {
|
||||||
|
|
||||||
export type PluginOptions = z.infer<typeof PluginOptions>
|
export type PluginOptions = z.infer<typeof PluginOptions>
|
||||||
export type PluginSpec = z.infer<typeof PluginSpec>
|
export type PluginSpec = z.infer<typeof PluginSpec>
|
||||||
|
export type PluginScope = "global" | "local"
|
||||||
|
export type PluginOrigin = {
|
||||||
|
spec: PluginSpec
|
||||||
|
source: string
|
||||||
|
scope: PluginScope
|
||||||
|
}
|
||||||
|
|
||||||
const log = Log.create({ service: "config" })
|
const log = Log.create({ service: "config" })
|
||||||
|
|
||||||
|
|
@ -72,9 +78,6 @@ export namespace Config {
|
||||||
// Custom merge function that concatenates array fields instead of replacing them
|
// Custom merge function that concatenates array fields instead of replacing them
|
||||||
function mergeConfigConcatArrays(target: Info, source: Info): Info {
|
function mergeConfigConcatArrays(target: Info, source: Info): Info {
|
||||||
const merged = mergeDeep(target, source)
|
const merged = mergeDeep(target, source)
|
||||||
if (target.plugin && source.plugin) {
|
|
||||||
merged.plugin = Array.from(new Set([...target.plugin, ...source.plugin]))
|
|
||||||
}
|
|
||||||
if (target.instructions && source.instructions) {
|
if (target.instructions && source.instructions) {
|
||||||
merged.instructions = Array.from(new Set([...target.instructions, ...source.instructions]))
|
merged.instructions = Array.from(new Set([...target.instructions, ...source.instructions]))
|
||||||
}
|
}
|
||||||
|
|
@ -297,31 +300,19 @@ export namespace Config {
|
||||||
return resolved
|
return resolved
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export function deduplicatePluginOrigins(plugins: PluginOrigin[]): PluginOrigin[] {
|
||||||
* Deduplicates plugins by name, with later entries (higher priority) winning.
|
const seen = new Set<string>()
|
||||||
* Priority order (highest to lowest):
|
const list: PluginOrigin[] = []
|
||||||
* 1. Local plugin/ directory
|
|
||||||
* 2. Local opencode.json
|
|
||||||
* 3. Global plugin/ directory
|
|
||||||
* 4. Global opencode.json
|
|
||||||
*
|
|
||||||
* Since plugins are added in low-to-high priority order,
|
|
||||||
* we reverse, deduplicate (keeping first occurrence), then restore order.
|
|
||||||
*/
|
|
||||||
export function deduplicatePlugins(plugins: PluginSpec[]): PluginSpec[] {
|
|
||||||
const seenNames = new Set<string>()
|
|
||||||
const uniqueSpecifiers: PluginSpec[] = []
|
|
||||||
|
|
||||||
for (const specifier of plugins.toReversed()) {
|
for (const plugin of plugins.toReversed()) {
|
||||||
const spec = pluginSpecifier(specifier)
|
const spec = pluginSpecifier(plugin.spec)
|
||||||
const name = spec.startsWith("file://") ? spec : parsePluginSpecifier(spec).pkg
|
const name = spec.startsWith("file://") ? spec : parsePluginSpecifier(spec).pkg
|
||||||
if (!seenNames.has(name)) {
|
if (seen.has(name)) continue
|
||||||
seenNames.add(name)
|
seen.add(name)
|
||||||
uniqueSpecifiers.push(specifier)
|
list.push(plugin)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return uniqueSpecifiers.toReversed()
|
return list.toReversed()
|
||||||
}
|
}
|
||||||
|
|
||||||
export const McpLocal = z
|
export const McpLocal = z
|
||||||
|
|
@ -997,7 +988,9 @@ export namespace Config {
|
||||||
ref: "Config",
|
ref: "Config",
|
||||||
})
|
})
|
||||||
|
|
||||||
export type Info = z.output<typeof Info>
|
export type Info = z.output<typeof Info> & {
|
||||||
|
plugin_origins?: PluginOrigin[]
|
||||||
|
}
|
||||||
|
|
||||||
type State = {
|
type State = {
|
||||||
config: Info
|
config: Info
|
||||||
|
|
@ -1044,6 +1037,11 @@ export namespace Config {
|
||||||
}, input)
|
}, input)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function writable(info: Info) {
|
||||||
|
const { plugin_origins, ...next } = info
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
function parseConfig(text: string, filepath: string): Info {
|
function parseConfig(text: string, filepath: string): Info {
|
||||||
const errors: JsoncParseError[] = []
|
const errors: JsoncParseError[] = []
|
||||||
const data = parseJsonc(text, errors, { allowTrailingComma: true })
|
const data = parseJsonc(text, errors, { allowTrailingComma: true })
|
||||||
|
|
@ -1208,6 +1206,30 @@ export namespace Config {
|
||||||
const auth = yield* authSvc.all().pipe(Effect.orDie)
|
const auth = yield* authSvc.all().pipe(Effect.orDie)
|
||||||
|
|
||||||
let result: Info = {}
|
let result: Info = {}
|
||||||
|
|
||||||
|
const scope = (source: string): PluginScope => {
|
||||||
|
if (source.startsWith("http://") || source.startsWith("https://")) return "global"
|
||||||
|
if (source === "OPENCODE_CONFIG_CONTENT") return "local"
|
||||||
|
if (Instance.containsPath(source)) return "local"
|
||||||
|
return "global"
|
||||||
|
}
|
||||||
|
|
||||||
|
const track = (source: string, list: PluginSpec[] | undefined, kind?: PluginScope) => {
|
||||||
|
if (!list?.length) return
|
||||||
|
const hit = kind ?? scope(source)
|
||||||
|
const plugins = deduplicatePluginOrigins([
|
||||||
|
...(result.plugin_origins ?? []),
|
||||||
|
...list.map((spec) => ({ spec, source, scope: hit })),
|
||||||
|
])
|
||||||
|
result.plugin = plugins.map((item) => item.spec)
|
||||||
|
result.plugin_origins = plugins
|
||||||
|
}
|
||||||
|
|
||||||
|
const merge = (source: string, next: Info, kind?: PluginScope) => {
|
||||||
|
result = mergeConfigConcatArrays(result, next)
|
||||||
|
track(source, next.plugin, kind)
|
||||||
|
}
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(auth)) {
|
for (const [key, value] of Object.entries(auth)) {
|
||||||
if (value.type === "wellknown") {
|
if (value.type === "wellknown") {
|
||||||
const url = key.replace(/\/+$/, "")
|
const url = key.replace(/\/+$/, "")
|
||||||
|
|
@ -1220,21 +1242,21 @@ export namespace Config {
|
||||||
const wellknown = (yield* Effect.promise(() => response.json())) as any
|
const wellknown = (yield* Effect.promise(() => response.json())) as any
|
||||||
const remoteConfig = wellknown.config ?? {}
|
const remoteConfig = wellknown.config ?? {}
|
||||||
if (!remoteConfig.$schema) remoteConfig.$schema = "https://opencode.ai/config.json"
|
if (!remoteConfig.$schema) remoteConfig.$schema = "https://opencode.ai/config.json"
|
||||||
result = mergeConfigConcatArrays(
|
const source = `${url}/.well-known/opencode`
|
||||||
result,
|
const next = yield* loadConfig(JSON.stringify(remoteConfig), {
|
||||||
yield* loadConfig(JSON.stringify(remoteConfig), {
|
dir: path.dirname(source),
|
||||||
dir: path.dirname(`${url}/.well-known/opencode`),
|
source,
|
||||||
source: `${url}/.well-known/opencode`,
|
})
|
||||||
}),
|
merge(source, next, "global")
|
||||||
)
|
|
||||||
log.debug("loaded remote config from well-known", { url })
|
log.debug("loaded remote config from well-known", { url })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
result = mergeConfigConcatArrays(result, yield* getGlobal())
|
const global = yield* getGlobal()
|
||||||
|
merge(Global.Path.config, global, "global")
|
||||||
|
|
||||||
if (Flag.OPENCODE_CONFIG) {
|
if (Flag.OPENCODE_CONFIG) {
|
||||||
result = mergeConfigConcatArrays(result, yield* loadFile(Flag.OPENCODE_CONFIG))
|
merge(Flag.OPENCODE_CONFIG, yield* loadFile(Flag.OPENCODE_CONFIG))
|
||||||
log.debug("loaded custom config", { path: Flag.OPENCODE_CONFIG })
|
log.debug("loaded custom config", { path: Flag.OPENCODE_CONFIG })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1242,7 +1264,7 @@ export namespace Config {
|
||||||
for (const file of yield* Effect.promise(() =>
|
for (const file of yield* Effect.promise(() =>
|
||||||
ConfigPaths.projectFiles("opencode", ctx.directory, ctx.worktree),
|
ConfigPaths.projectFiles("opencode", ctx.directory, ctx.worktree),
|
||||||
)) {
|
)) {
|
||||||
result = mergeConfigConcatArrays(result, yield* loadFile(file))
|
merge(file, yield* loadFile(file), "local")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1260,9 +1282,10 @@ export namespace Config {
|
||||||
|
|
||||||
for (const dir of unique(directories)) {
|
for (const dir of unique(directories)) {
|
||||||
if (dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) {
|
if (dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) {
|
||||||
for (const file of ["opencode.jsonc", "opencode.json"]) {
|
for (const file of ["opencode.json", "opencode.jsonc"]) {
|
||||||
log.debug(`loading config from ${path.join(dir, file)}`)
|
const source = path.join(dir, file)
|
||||||
result = mergeConfigConcatArrays(result, yield* loadFile(path.join(dir, file)))
|
log.debug(`loading config from ${source}`)
|
||||||
|
merge(source, yield* loadFile(source))
|
||||||
result.agent ??= {}
|
result.agent ??= {}
|
||||||
result.mode ??= {}
|
result.mode ??= {}
|
||||||
result.plugin ??= []
|
result.plugin ??= []
|
||||||
|
|
@ -1280,17 +1303,17 @@ export namespace Config {
|
||||||
result.command = mergeDeep(result.command ?? {}, yield* Effect.promise(() => loadCommand(dir)))
|
result.command = mergeDeep(result.command ?? {}, yield* Effect.promise(() => loadCommand(dir)))
|
||||||
result.agent = mergeDeep(result.agent, yield* Effect.promise(() => loadAgent(dir)))
|
result.agent = mergeDeep(result.agent, yield* Effect.promise(() => loadAgent(dir)))
|
||||||
result.agent = mergeDeep(result.agent, yield* Effect.promise(() => loadMode(dir)))
|
result.agent = mergeDeep(result.agent, yield* Effect.promise(() => loadMode(dir)))
|
||||||
result.plugin.push(...(yield* Effect.promise(() => loadPlugin(dir))))
|
const list = yield* Effect.promise(() => loadPlugin(dir))
|
||||||
|
track(dir, list)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (process.env.OPENCODE_CONFIG_CONTENT) {
|
if (process.env.OPENCODE_CONFIG_CONTENT) {
|
||||||
result = mergeConfigConcatArrays(
|
const source = "OPENCODE_CONFIG_CONTENT"
|
||||||
result,
|
const next = yield* loadConfig(process.env.OPENCODE_CONFIG_CONTENT, {
|
||||||
yield* loadConfig(process.env.OPENCODE_CONFIG_CONTENT, {
|
dir: ctx.directory,
|
||||||
dir: ctx.directory,
|
source,
|
||||||
source: "OPENCODE_CONFIG_CONTENT",
|
})
|
||||||
}),
|
merge(source, next, "local")
|
||||||
)
|
|
||||||
log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT")
|
log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1309,13 +1332,12 @@ export namespace Config {
|
||||||
|
|
||||||
const config = Option.getOrUndefined(configOpt)
|
const config = Option.getOrUndefined(configOpt)
|
||||||
if (config) {
|
if (config) {
|
||||||
result = mergeConfigConcatArrays(
|
const source = `${active.url}/api/config`
|
||||||
result,
|
const next = yield* loadConfig(JSON.stringify(config), {
|
||||||
yield* loadConfig(JSON.stringify(config), {
|
dir: path.dirname(source),
|
||||||
dir: path.dirname(`${active.url}/api/config`),
|
source,
|
||||||
source: `${active.url}/api/config`,
|
})
|
||||||
}),
|
merge(source, next, "global")
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}).pipe(
|
}).pipe(
|
||||||
Effect.catch((err) => {
|
Effect.catch((err) => {
|
||||||
|
|
@ -1328,8 +1350,9 @@ export namespace Config {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (existsSync(managedDir)) {
|
if (existsSync(managedDir)) {
|
||||||
for (const file of ["opencode.jsonc", "opencode.json"]) {
|
for (const file of ["opencode.json", "opencode.jsonc"]) {
|
||||||
result = mergeConfigConcatArrays(result, yield* loadFile(path.join(managedDir, file)))
|
const source = path.join(managedDir, file)
|
||||||
|
merge(source, yield* loadFile(source), "global")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1372,8 +1395,6 @@ export namespace Config {
|
||||||
result.compaction = { ...result.compaction, prune: false }
|
result.compaction = { ...result.compaction, prune: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
result.plugin = deduplicatePlugins(result.plugin ?? [])
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
config: result,
|
config: result,
|
||||||
directories,
|
directories,
|
||||||
|
|
@ -1403,7 +1424,9 @@ export namespace Config {
|
||||||
const dir = yield* InstanceState.directory
|
const dir = yield* InstanceState.directory
|
||||||
const file = path.join(dir, "config.json")
|
const file = path.join(dir, "config.json")
|
||||||
const existing = yield* loadFile(file)
|
const existing = yield* loadFile(file)
|
||||||
yield* fs.writeFileString(file, JSON.stringify(mergeDeep(existing, config), null, 2)).pipe(Effect.orDie)
|
yield* fs
|
||||||
|
.writeFileString(file, JSON.stringify(mergeDeep(writable(existing), writable(config)), null, 2))
|
||||||
|
.pipe(Effect.orDie)
|
||||||
yield* Effect.promise(() => Instance.dispose())
|
yield* Effect.promise(() => Instance.dispose())
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -1427,15 +1450,16 @@ export namespace Config {
|
||||||
const updateGlobal = Effect.fn("Config.updateGlobal")(function* (config: Info) {
|
const updateGlobal = Effect.fn("Config.updateGlobal")(function* (config: Info) {
|
||||||
const file = globalConfigFile()
|
const file = globalConfigFile()
|
||||||
const before = (yield* readConfigFile(file)) ?? "{}"
|
const before = (yield* readConfigFile(file)) ?? "{}"
|
||||||
|
const input = writable(config)
|
||||||
|
|
||||||
let next: Info
|
let next: Info
|
||||||
if (!file.endsWith(".jsonc")) {
|
if (!file.endsWith(".jsonc")) {
|
||||||
const existing = parseConfig(before, file)
|
const existing = parseConfig(before, file)
|
||||||
const merged = mergeDeep(existing, config)
|
const merged = mergeDeep(writable(existing), input)
|
||||||
yield* fs.writeFileString(file, JSON.stringify(merged, null, 2)).pipe(Effect.orDie)
|
yield* fs.writeFileString(file, JSON.stringify(merged, null, 2)).pipe(Effect.orDie)
|
||||||
next = merged
|
next = merged
|
||||||
} else {
|
} else {
|
||||||
const updated = patchJsonc(before, config)
|
const updated = patchJsonc(before, input)
|
||||||
next = parseConfig(updated, file)
|
next = parseConfig(updated, file)
|
||||||
yield* fs.writeFileString(file, updated).pipe(Effect.orDie)
|
yield* fs.writeFileString(file, updated).pipe(Effect.orDie)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,14 +9,7 @@ import { Global } from "@/global"
|
||||||
|
|
||||||
export namespace ConfigPaths {
|
export namespace ConfigPaths {
|
||||||
export async function projectFiles(name: string, directory: string, worktree: string) {
|
export async function projectFiles(name: string, directory: string, worktree: string) {
|
||||||
const files: string[] = []
|
return Filesystem.findUp([`${name}.json`, `${name}.jsonc`], directory, worktree, { rootFirst: true })
|
||||||
for (const file of [`${name}.jsonc`, `${name}.json`]) {
|
|
||||||
const found = await Filesystem.findUp(file, directory, worktree)
|
|
||||||
for (const resolved of found.toReversed()) {
|
|
||||||
files.push(resolved)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return files
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function directories(directory: string, worktree: string) {
|
export async function directories(directory: string, worktree: string) {
|
||||||
|
|
@ -43,7 +36,7 @@ export namespace ConfigPaths {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fileInDirectory(dir: string, name: string) {
|
export function fileInDirectory(dir: string, name: string) {
|
||||||
return [path.join(dir, `${name}.jsonc`), path.join(dir, `${name}.json`)]
|
return [path.join(dir, `${name}.json`), path.join(dir, `${name}.jsonc`)]
|
||||||
}
|
}
|
||||||
|
|
||||||
export const JsonError = NamedError.create(
|
export const JsonError = NamedError.create(
|
||||||
|
|
|
||||||
|
|
@ -3,72 +3,33 @@ import z from "zod"
|
||||||
import { mergeDeep, unique } from "remeda"
|
import { mergeDeep, unique } from "remeda"
|
||||||
import { Config } from "./config"
|
import { Config } from "./config"
|
||||||
import { ConfigPaths } from "./paths"
|
import { ConfigPaths } from "./paths"
|
||||||
import { migrateTuiConfig } from "./migrate-tui-config"
|
import { migrateTuiConfig } from "./tui-migrate"
|
||||||
import { TuiInfo } from "./tui-schema"
|
import { TuiInfo } from "./tui-schema"
|
||||||
import { Instance } from "@/project/instance"
|
import { Instance } from "@/project/instance"
|
||||||
import { Flag } from "@/flag/flag"
|
import { Flag } from "@/flag/flag"
|
||||||
import { Log } from "@/util/log"
|
import { Log } from "@/util/log"
|
||||||
import { isRecord } from "@/util/record"
|
import { isRecord } from "@/util/record"
|
||||||
import { Global } from "@/global"
|
import { Global } from "@/global"
|
||||||
import { parsePluginSpecifier } from "@/plugin/shared"
|
|
||||||
|
|
||||||
export namespace TuiConfig {
|
export namespace TuiConfig {
|
||||||
const log = Log.create({ service: "tui.config" })
|
const log = Log.create({ service: "tui.config" })
|
||||||
|
|
||||||
export const Info = TuiInfo
|
export const Info = TuiInfo
|
||||||
|
|
||||||
export type PluginMeta = {
|
|
||||||
scope: "global" | "local"
|
|
||||||
source: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type PluginRecord = {
|
|
||||||
item: Config.PluginSpec
|
|
||||||
scope: PluginMeta["scope"]
|
|
||||||
source: string
|
|
||||||
}
|
|
||||||
|
|
||||||
type PluginEntry = {
|
|
||||||
item: Config.PluginSpec
|
|
||||||
meta: PluginMeta
|
|
||||||
}
|
|
||||||
|
|
||||||
type Acc = {
|
type Acc = {
|
||||||
result: Info
|
result: Info
|
||||||
entries: PluginEntry[]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Info = z.output<typeof Info> & {
|
export type Info = z.output<typeof Info> & {
|
||||||
// Internal resolved plugin list used by runtime loading.
|
// Internal resolved plugin list used by runtime loading.
|
||||||
plugin_records?: PluginRecord[]
|
plugin_origins?: Config.PluginOrigin[]
|
||||||
}
|
}
|
||||||
|
|
||||||
function pluginScope(file: string): PluginMeta["scope"] {
|
function pluginScope(file: string): Config.PluginScope {
|
||||||
if (Instance.containsPath(file)) return "local"
|
if (Instance.containsPath(file)) return "local"
|
||||||
return "global"
|
return "global"
|
||||||
}
|
}
|
||||||
|
|
||||||
function dedupePlugins(list: PluginEntry[]) {
|
|
||||||
const seen = new Set<string>()
|
|
||||||
const result: PluginEntry[] = []
|
|
||||||
for (const item of list.toReversed()) {
|
|
||||||
const spec = Config.pluginSpecifier(item.item)
|
|
||||||
const name = spec.startsWith("file://") ? spec : parsePluginSpecifier(spec).pkg
|
|
||||||
if (seen.has(name)) continue
|
|
||||||
seen.add(name)
|
|
||||||
result.push(item)
|
|
||||||
}
|
|
||||||
return result.toReversed()
|
|
||||||
}
|
|
||||||
|
|
||||||
function mergeInfo(target: Info, source: Info): Info {
|
|
||||||
const merged = mergeDeep(target, source)
|
|
||||||
if (target.plugin && source.plugin) {
|
|
||||||
merged.plugin = [...target.plugin, ...source.plugin]
|
|
||||||
}
|
|
||||||
return merged
|
|
||||||
}
|
|
||||||
|
|
||||||
function customPath() {
|
function customPath() {
|
||||||
return Flag.OPENCODE_TUI_CONFIG
|
return Flag.OPENCODE_TUI_CONFIG
|
||||||
}
|
}
|
||||||
|
|
@ -95,19 +56,16 @@ export namespace TuiConfig {
|
||||||
|
|
||||||
async function mergeFile(acc: Acc, file: string) {
|
async function mergeFile(acc: Acc, file: string) {
|
||||||
const data = await loadFile(file)
|
const data = await loadFile(file)
|
||||||
acc.result = mergeInfo(acc.result, data)
|
acc.result = mergeDeep(acc.result, data)
|
||||||
if (!data.plugin?.length) return
|
if (!data.plugin?.length) return
|
||||||
|
|
||||||
const scope = pluginScope(file)
|
const scope = pluginScope(file)
|
||||||
for (const item of data.plugin) {
|
const plugins = Config.deduplicatePluginOrigins([
|
||||||
acc.entries.push({
|
...(acc.result.plugin_origins ?? []),
|
||||||
item,
|
...data.plugin.map((spec) => ({ spec, scope, source: file })),
|
||||||
meta: {
|
])
|
||||||
scope,
|
acc.result.plugin = plugins.map((item) => item.spec)
|
||||||
source: file,
|
acc.result.plugin_origins = plugins
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const state = Instance.state(async () => {
|
const state = Instance.state(async () => {
|
||||||
|
|
@ -125,7 +83,6 @@ export namespace TuiConfig {
|
||||||
|
|
||||||
const acc: Acc = {
|
const acc: Acc = {
|
||||||
result: {},
|
result: {},
|
||||||
entries: [],
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const file of ConfigPaths.fileInDirectory(Global.Path.config, "tui")) {
|
for (const file of ConfigPaths.fileInDirectory(Global.Path.config, "tui")) {
|
||||||
|
|
@ -154,15 +111,7 @@ export namespace TuiConfig {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const merged = dedupePlugins(acc.entries)
|
|
||||||
acc.result.keybinds = Config.Keybinds.parse(acc.result.keybinds ?? {})
|
acc.result.keybinds = Config.Keybinds.parse(acc.result.keybinds ?? {})
|
||||||
const list = merged.map((item) => ({
|
|
||||||
item: item.item,
|
|
||||||
scope: item.meta.scope,
|
|
||||||
source: item.meta.source,
|
|
||||||
}))
|
|
||||||
acc.result.plugin = list.map((item) => item.item)
|
|
||||||
acc.result.plugin_records = list.length ? list : undefined
|
|
||||||
|
|
||||||
const deps: Promise<void>[] = []
|
const deps: Promise<void>[] = []
|
||||||
if (acc.result.plugin?.length) {
|
if (acc.result.plugin?.length) {
|
||||||
|
|
|
||||||
|
|
@ -24,10 +24,6 @@ export namespace Plugin {
|
||||||
hooks: Hooks[]
|
hooks: Hooks[]
|
||||||
}
|
}
|
||||||
|
|
||||||
type Loaded = {
|
|
||||||
row: PluginLoader.Loaded
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hook names that follow the (input, output) => Promise<void> trigger pattern
|
// Hook names that follow the (input, output) => Promise<void> trigger pattern
|
||||||
type TriggerName = {
|
type TriggerName = {
|
||||||
[K in keyof Hooks]-?: NonNullable<Hooks[K]> extends (input: any, output: any) => Promise<void> ? K : never
|
[K in keyof Hooks]-?: NonNullable<Hooks[K]> extends (input: any, output: any) => Promise<void> ? K : never
|
||||||
|
|
@ -78,22 +74,20 @@ export namespace Plugin {
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
async function applyPlugin(load: Loaded, input: PluginInput, hooks: Hooks[]) {
|
function publishPluginError(message: string) {
|
||||||
const plugin = readV1Plugin(load.row.mod, load.row.spec, "server", "detect")
|
Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyPlugin(load: PluginLoader.Loaded, input: PluginInput, hooks: Hooks[]) {
|
||||||
|
const plugin = readV1Plugin(load.mod, load.spec, "server", "detect")
|
||||||
if (plugin) {
|
if (plugin) {
|
||||||
await resolvePluginId(
|
await resolvePluginId(load.source, load.spec, load.target, readPluginId(plugin.id, load.spec), load.pkg)
|
||||||
load.row.source,
|
hooks.push(await (plugin as PluginModule).server(input, load.options))
|
||||||
load.row.spec,
|
|
||||||
load.row.target,
|
|
||||||
readPluginId(plugin.id, load.row.spec),
|
|
||||||
load.row.pkg,
|
|
||||||
)
|
|
||||||
hooks.push(await (plugin as PluginModule).server(input, load.row.options))
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const server of getLegacyPlugins(load.row.mod)) {
|
for (const server of getLegacyPlugins(load.mod)) {
|
||||||
hooks.push(await server(input, load.row.options))
|
hooks.push(await server(input, load.options))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -142,87 +136,52 @@ export namespace Plugin {
|
||||||
if (init._tag === "Some") hooks.push(init.value)
|
if (init._tag === "Some") hooks.push(init.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
const plugins = Flag.OPENCODE_PURE ? [] : (cfg.plugin ?? [])
|
const plugins = Flag.OPENCODE_PURE ? [] : (cfg.plugin_origins ?? [])
|
||||||
if (Flag.OPENCODE_PURE && cfg.plugin?.length) {
|
if (Flag.OPENCODE_PURE && cfg.plugin_origins?.length) {
|
||||||
log.info("skipping external plugins in pure mode", { count: cfg.plugin.length })
|
log.info("skipping external plugins in pure mode", { count: cfg.plugin_origins.length })
|
||||||
}
|
}
|
||||||
if (plugins.length) yield* config.waitForDependencies()
|
if (plugins.length) yield* config.waitForDependencies()
|
||||||
|
|
||||||
const loaded = yield* Effect.promise(() =>
|
const loaded = yield* Effect.promise(() =>
|
||||||
Promise.all(
|
PluginLoader.loadExternal({
|
||||||
plugins.map(async (item) => {
|
items: plugins,
|
||||||
const plan = PluginLoader.plan(item)
|
kind: "server",
|
||||||
if (plan.deprecated) return
|
report: {
|
||||||
log.info("loading plugin", { path: plan.spec })
|
start(candidate) {
|
||||||
|
log.info("loading plugin", { path: candidate.plan.spec })
|
||||||
|
},
|
||||||
|
missing(candidate, _retry, message) {
|
||||||
|
log.warn("plugin has no server entrypoint", { path: candidate.plan.spec, message })
|
||||||
|
},
|
||||||
|
error(candidate, _retry, stage, error, resolved) {
|
||||||
|
const spec = candidate.plan.spec
|
||||||
|
const cause = error instanceof Error ? (error.cause ?? error) : error
|
||||||
|
const message = stage === "load" ? errorMessage(error) : errorMessage(cause)
|
||||||
|
|
||||||
const resolved = await PluginLoader.resolve(plan, "server")
|
if (stage === "install") {
|
||||||
if (!resolved.ok) {
|
const parsed = parsePluginSpecifier(spec)
|
||||||
if (resolved.stage === "missing") {
|
log.error("failed to install plugin", { pkg: parsed.pkg, version: parsed.version, error: message })
|
||||||
log.warn("plugin has no server entrypoint", {
|
publishPluginError(`Failed to install plugin ${parsed.pkg}@${parsed.version}: ${message}`)
|
||||||
path: plan.spec,
|
|
||||||
message: resolved.message,
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const cause =
|
if (stage === "compatibility") {
|
||||||
resolved.error instanceof Error ? (resolved.error.cause ?? resolved.error) : resolved.error
|
log.warn("plugin incompatible", { path: spec, error: message })
|
||||||
const message = errorMessage(cause)
|
publishPluginError(`Plugin ${spec} skipped: ${message}`)
|
||||||
|
|
||||||
if (resolved.stage === "install") {
|
|
||||||
const parsed = parsePluginSpecifier(plan.spec)
|
|
||||||
log.error("failed to install plugin", {
|
|
||||||
pkg: parsed.pkg,
|
|
||||||
version: parsed.version,
|
|
||||||
error: message,
|
|
||||||
})
|
|
||||||
Bus.publish(Session.Event.Error, {
|
|
||||||
error: new NamedError.Unknown({
|
|
||||||
message: `Failed to install plugin ${parsed.pkg}@${parsed.version}: ${message}`,
|
|
||||||
}).toObject(),
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (resolved.stage === "compatibility") {
|
if (stage === "entry") {
|
||||||
log.warn("plugin incompatible", { path: plan.spec, error: message })
|
log.error("failed to resolve plugin server entry", { path: spec, error: message })
|
||||||
Bus.publish(Session.Event.Error, {
|
publishPluginError(`Failed to load plugin ${spec}: ${message}`)
|
||||||
error: new NamedError.Unknown({
|
|
||||||
message: `Plugin ${plan.spec} skipped: ${message}`,
|
|
||||||
}).toObject(),
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
log.error("failed to resolve plugin server entry", {
|
log.error("failed to load plugin", { path: spec, target: resolved?.entry, error: message })
|
||||||
path: plan.spec,
|
publishPluginError(`Failed to load plugin ${spec}: ${message}`)
|
||||||
error: message,
|
},
|
||||||
})
|
},
|
||||||
Bus.publish(Session.Event.Error, {
|
}),
|
||||||
error: new NamedError.Unknown({
|
|
||||||
message: `Failed to load plugin ${plan.spec}: ${message}`,
|
|
||||||
}).toObject(),
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const mod = await PluginLoader.load(resolved.value)
|
|
||||||
if (!mod.ok) {
|
|
||||||
const message = errorMessage(mod.error)
|
|
||||||
log.error("failed to load plugin", { path: plan.spec, target: resolved.value.entry, error: message })
|
|
||||||
Bus.publish(Session.Event.Error, {
|
|
||||||
error: new NamedError.Unknown({
|
|
||||||
message: `Failed to load plugin ${plan.spec}: ${message}`,
|
|
||||||
}).toObject(),
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
row: mod.value,
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
for (const load of loaded) {
|
for (const load of loaded) {
|
||||||
if (!load) continue
|
if (!load) continue
|
||||||
|
|
@ -233,14 +192,14 @@ export namespace Plugin {
|
||||||
try: () => applyPlugin(load, input, hooks),
|
try: () => applyPlugin(load, input, hooks),
|
||||||
catch: (err) => {
|
catch: (err) => {
|
||||||
const message = errorMessage(err)
|
const message = errorMessage(err)
|
||||||
log.error("failed to load plugin", { path: load.row.spec, error: message })
|
log.error("failed to load plugin", { path: load.spec, error: message })
|
||||||
return message
|
return message
|
||||||
},
|
},
|
||||||
}).pipe(
|
}).pipe(
|
||||||
Effect.catch((message) =>
|
Effect.catch((message) =>
|
||||||
bus.publish(Session.Event.Error, {
|
bus.publish(Session.Event.Error, {
|
||||||
error: new NamedError.Unknown({
|
error: new NamedError.Unknown({
|
||||||
message: `Failed to load plugin ${load.row.spec}: ${message}`,
|
message: `Failed to load plugin ${load.spec}: ${message}`,
|
||||||
}).toObject(),
|
}).toObject(),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ import { Filesystem } from "@/util/filesystem"
|
||||||
import { Flock } from "@/util/flock"
|
import { Flock } from "@/util/flock"
|
||||||
import { isRecord } from "@/util/record"
|
import { isRecord } from "@/util/record"
|
||||||
|
|
||||||
import { parsePluginSpecifier, readPluginPackage, resolvePluginTarget } from "./shared"
|
import { parsePluginSpecifier, readPackageThemes, readPluginPackage, resolvePluginTarget } from "./shared"
|
||||||
|
|
||||||
type Mode = "noop" | "add" | "replace"
|
type Mode = "noop" | "add" | "replace"
|
||||||
type Kind = "server" | "tui"
|
type Kind = "server" | "tui"
|
||||||
|
|
@ -142,19 +142,26 @@ function hasMainTarget(pkg: Record<string, unknown>) {
|
||||||
return Boolean(main.trim())
|
return Boolean(main.trim())
|
||||||
}
|
}
|
||||||
|
|
||||||
function packageTargets(pkg: Record<string, unknown>) {
|
function packageTargets(pkg: { json: Record<string, unknown>; dir: string; pkg: string }) {
|
||||||
|
const spec =
|
||||||
|
typeof pkg.json.name === "string" && pkg.json.name.trim().length > 0 ? pkg.json.name.trim() : path.basename(pkg.dir)
|
||||||
const targets: Target[] = []
|
const targets: Target[] = []
|
||||||
const server = exportTarget(pkg, "server")
|
const server = exportTarget(pkg.json, "server")
|
||||||
if (server) {
|
if (server) {
|
||||||
targets.push({ kind: "server", opts: server.opts })
|
targets.push({ kind: "server", opts: server.opts })
|
||||||
} else if (hasMainTarget(pkg)) {
|
} else if (hasMainTarget(pkg.json)) {
|
||||||
targets.push({ kind: "server" })
|
targets.push({ kind: "server" })
|
||||||
}
|
}
|
||||||
|
|
||||||
const tui = exportTarget(pkg, "tui")
|
const tui = exportTarget(pkg.json, "tui")
|
||||||
if (tui) {
|
if (tui) {
|
||||||
targets.push({ kind: "tui", opts: tui.opts })
|
targets.push({ kind: "tui", opts: tui.opts })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!targets.some((item) => item.kind === "tui") && readPackageThemes(spec, pkg).length) {
|
||||||
|
targets.push({ kind: "tui" })
|
||||||
|
}
|
||||||
|
|
||||||
return targets
|
return targets
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -293,8 +300,23 @@ export async function readPluginManifest(target: string): Promise<ManifestResult
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const targets = packageTargets(pkg.item.json)
|
const targets = await Promise.resolve()
|
||||||
if (!targets.length) {
|
.then(() => packageTargets(pkg.item))
|
||||||
|
.then(
|
||||||
|
(item) => ({ ok: true as const, item }),
|
||||||
|
(error: unknown) => ({ ok: false as const, error }),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!targets.ok) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
code: "manifest_read_failed",
|
||||||
|
file: pkg.item.pkg,
|
||||||
|
error: targets.error,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!targets.item.length) {
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
code: "manifest_no_targets",
|
code: "manifest_no_targets",
|
||||||
|
|
@ -304,7 +326,7 @@ export async function readPluginManifest(target: string): Promise<ManifestResult
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
targets,
|
targets: targets.item,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import {
|
||||||
checkPluginCompatibility,
|
checkPluginCompatibility,
|
||||||
createPluginEntry,
|
createPluginEntry,
|
||||||
isDeprecatedPlugin,
|
isDeprecatedPlugin,
|
||||||
|
pluginSource,
|
||||||
resolvePluginTarget,
|
resolvePluginTarget,
|
||||||
type PluginKind,
|
type PluginKind,
|
||||||
type PluginPackage,
|
type PluginPackage,
|
||||||
|
|
@ -12,31 +13,42 @@ import {
|
||||||
|
|
||||||
export namespace PluginLoader {
|
export namespace PluginLoader {
|
||||||
export type Plan = {
|
export type Plan = {
|
||||||
item: Config.PluginSpec
|
|
||||||
spec: string
|
spec: string
|
||||||
options: Config.PluginOptions | undefined
|
options: Config.PluginOptions | undefined
|
||||||
deprecated: boolean
|
deprecated: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Resolved = Plan & {
|
export type Resolved = Plan & {
|
||||||
source: PluginSource
|
source: PluginSource
|
||||||
target: string
|
target: string
|
||||||
entry: string
|
entry: string
|
||||||
pkg?: PluginPackage
|
pkg?: PluginPackage
|
||||||
}
|
}
|
||||||
|
export type Missing = Plan & {
|
||||||
|
source: PluginSource
|
||||||
|
target: string
|
||||||
|
pkg?: PluginPackage
|
||||||
|
message: string
|
||||||
|
}
|
||||||
export type Loaded = Resolved & {
|
export type Loaded = Resolved & {
|
||||||
mod: Record<string, unknown>
|
mod: Record<string, unknown>
|
||||||
}
|
}
|
||||||
|
|
||||||
export function plan(item: Config.PluginSpec): Plan {
|
type Candidate = { origin: Config.PluginOrigin; plan: Plan }
|
||||||
|
type Report = {
|
||||||
|
start?: (candidate: Candidate, retry: boolean) => void
|
||||||
|
missing?: (candidate: Candidate, retry: boolean, message: string, resolved: Missing) => void
|
||||||
|
error?: (
|
||||||
|
candidate: Candidate,
|
||||||
|
retry: boolean,
|
||||||
|
stage: "install" | "entry" | "compatibility" | "load",
|
||||||
|
error: unknown,
|
||||||
|
resolved?: Resolved,
|
||||||
|
) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function plan(item: Config.PluginSpec): Plan {
|
||||||
const spec = Config.pluginSpecifier(item)
|
const spec = Config.pluginSpecifier(item)
|
||||||
return {
|
return { spec, options: Config.pluginOptions(item), deprecated: isDeprecatedPlugin(spec) }
|
||||||
item,
|
|
||||||
spec,
|
|
||||||
options: Config.pluginOptions(item),
|
|
||||||
deprecated: isDeprecatedPlugin(spec),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function resolve(
|
export async function resolve(
|
||||||
|
|
@ -44,68 +56,44 @@ export namespace PluginLoader {
|
||||||
kind: PluginKind,
|
kind: PluginKind,
|
||||||
): Promise<
|
): Promise<
|
||||||
| { ok: true; value: Resolved }
|
| { ok: true; value: Resolved }
|
||||||
| { ok: false; stage: "missing"; message: string }
|
| { ok: false; stage: "missing"; value: Missing }
|
||||||
| { ok: false; stage: "install" | "entry" | "compatibility"; error: unknown }
|
| { ok: false; stage: "install" | "entry" | "compatibility"; error: unknown }
|
||||||
> {
|
> {
|
||||||
let target = ""
|
let target = ""
|
||||||
try {
|
try {
|
||||||
target = await resolvePluginTarget(plan.spec)
|
target = await resolvePluginTarget(plan.spec)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return { ok: false, stage: "install", error }
|
||||||
ok: false,
|
|
||||||
stage: "install",
|
|
||||||
error,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!target) {
|
|
||||||
return {
|
|
||||||
ok: false,
|
|
||||||
stage: "install",
|
|
||||||
error: new Error(`Plugin ${plan.spec} target is empty`),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
if (!target) return { ok: false, stage: "install", error: new Error(`Plugin ${plan.spec} target is empty`) }
|
||||||
|
|
||||||
let base
|
let base
|
||||||
try {
|
try {
|
||||||
base = await createPluginEntry(plan.spec, target, kind)
|
base = await createPluginEntry(plan.spec, target, kind)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return { ok: false, stage: "entry", error }
|
||||||
ok: false,
|
|
||||||
stage: "entry",
|
|
||||||
error,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
if (!base.entry)
|
||||||
if (!base.entry) {
|
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
stage: "missing",
|
stage: "missing",
|
||||||
message: `Plugin ${plan.spec} does not expose a ${kind} entrypoint`,
|
value: {
|
||||||
|
...plan,
|
||||||
|
source: base.source,
|
||||||
|
target: base.target,
|
||||||
|
pkg: base.pkg,
|
||||||
|
message: `Plugin ${plan.spec} does not expose a ${kind} entrypoint`,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (base.source === "npm") {
|
if (base.source === "npm") {
|
||||||
try {
|
try {
|
||||||
await checkPluginCompatibility(base.target, Installation.VERSION, base.pkg)
|
await checkPluginCompatibility(base.target, Installation.VERSION, base.pkg)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return { ok: false, stage: "compatibility", error }
|
||||||
ok: false,
|
|
||||||
stage: "compatibility",
|
|
||||||
error,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return { ok: true, value: { ...plan, source: base.source, target: base.target, entry: base.entry, pkg: base.pkg } }
|
||||||
return {
|
|
||||||
ok: true,
|
|
||||||
value: {
|
|
||||||
...plan,
|
|
||||||
source: base.source,
|
|
||||||
target: base.target,
|
|
||||||
entry: base.entry,
|
|
||||||
pkg: base.pkg,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function load(row: Resolved): Promise<{ ok: true; value: Loaded } | { ok: false; error: unknown }> {
|
export async function load(row: Resolved): Promise<{ ok: true; value: Loaded } | { ok: false; error: unknown }> {
|
||||||
|
|
@ -113,25 +101,74 @@ export namespace PluginLoader {
|
||||||
try {
|
try {
|
||||||
mod = await import(row.entry)
|
mod = await import(row.entry)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return { ok: false, error }
|
||||||
ok: false,
|
}
|
||||||
error,
|
if (!mod) return { ok: false, error: new Error(`Plugin ${row.spec} module is empty`) }
|
||||||
|
return { ok: true, value: { ...row, mod } }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function attempt<R>(
|
||||||
|
candidate: Candidate,
|
||||||
|
kind: PluginKind,
|
||||||
|
retry: boolean,
|
||||||
|
finish: ((load: Loaded, origin: Config.PluginOrigin, retry: boolean) => Promise<R | undefined>) | undefined,
|
||||||
|
missing: ((value: Missing, origin: Config.PluginOrigin, retry: boolean) => Promise<R | undefined>) | undefined,
|
||||||
|
report: Report | undefined,
|
||||||
|
): Promise<R | undefined> {
|
||||||
|
const plan = candidate.plan
|
||||||
|
if (plan.deprecated) return
|
||||||
|
report?.start?.(candidate, retry)
|
||||||
|
const resolved = await resolve(plan, kind)
|
||||||
|
if (!resolved.ok) {
|
||||||
|
if (resolved.stage === "missing") {
|
||||||
|
if (missing) {
|
||||||
|
const value = await missing(resolved.value, candidate.origin, retry)
|
||||||
|
if (value !== undefined) return value
|
||||||
|
}
|
||||||
|
report?.missing?.(candidate, retry, resolved.value.message, resolved.value)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
report?.error?.(candidate, retry, resolved.stage, resolved.error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const loaded = await load(resolved.value)
|
||||||
|
if (!loaded.ok) {
|
||||||
|
report?.error?.(candidate, retry, "load", loaded.error, resolved.value)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!finish) return loaded.value as R
|
||||||
|
return finish(loaded.value, candidate.origin, retry)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Input<R> = {
|
||||||
|
items: Config.PluginOrigin[]
|
||||||
|
kind: PluginKind
|
||||||
|
wait?: () => Promise<void>
|
||||||
|
finish?: (load: Loaded, origin: Config.PluginOrigin, retry: boolean) => Promise<R | undefined>
|
||||||
|
missing?: (value: Missing, origin: Config.PluginOrigin, retry: boolean) => Promise<R | undefined>
|
||||||
|
report?: Report
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadExternal<R = Loaded>(input: Input<R>): Promise<R[]> {
|
||||||
|
const candidates = input.items.map((origin) => ({ origin, plan: plan(origin.spec) }))
|
||||||
|
const list: Array<Promise<R | undefined>> = []
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
list.push(attempt(candidate, input.kind, false, input.finish, input.missing, input.report))
|
||||||
|
}
|
||||||
|
const out = await Promise.all(list)
|
||||||
|
if (input.wait) {
|
||||||
|
let deps: Promise<void> | undefined
|
||||||
|
for (let i = 0; i < candidates.length; i++) {
|
||||||
|
if (out[i] !== undefined) continue
|
||||||
|
const candidate = candidates[i]
|
||||||
|
if (!candidate || pluginSource(candidate.plan.spec) !== "file") continue
|
||||||
|
deps ??= input.wait()
|
||||||
|
await deps
|
||||||
|
out[i] = await attempt(candidate, input.kind, true, input.finish, input.missing, input.report)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const ready: R[] = []
|
||||||
if (!mod) {
|
for (const item of out) if (item !== undefined) ready.push(item)
|
||||||
return {
|
return ready
|
||||||
ok: false,
|
|
||||||
error: new Error(`Plugin ${row.spec} module is empty`),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
ok: true,
|
|
||||||
value: {
|
|
||||||
...row,
|
|
||||||
mod,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,10 @@ function resolveExportPath(raw: string, dir: string) {
|
||||||
return path.resolve(dir, raw)
|
return path.resolve(dir, raw)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isAbsolutePath(raw: string) {
|
||||||
|
return path.isAbsolute(raw) || /^[A-Za-z]:[\\/]/.test(raw)
|
||||||
|
}
|
||||||
|
|
||||||
function extractExportValue(value: unknown): string | undefined {
|
function extractExportValue(value: unknown): string | undefined {
|
||||||
if (typeof value === "string") return value
|
if (typeof value === "string") return value
|
||||||
if (!isRecord(value)) return undefined
|
if (!isRecord(value)) return undefined
|
||||||
|
|
@ -68,14 +72,18 @@ function packageMain(pkg: PluginPackage) {
|
||||||
return next
|
return next
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolvePackagePath(spec: string, raw: string, kind: PluginKind, pkg: PluginPackage) {
|
function resolvePackageFile(spec: string, raw: string, kind: string, pkg: PluginPackage) {
|
||||||
const resolved = resolveExportPath(raw, pkg.dir)
|
const resolved = resolveExportPath(raw, pkg.dir)
|
||||||
const root = Filesystem.resolve(pkg.dir)
|
const root = Filesystem.resolve(pkg.dir)
|
||||||
const next = Filesystem.resolve(resolved)
|
const next = Filesystem.resolve(resolved)
|
||||||
if (!Filesystem.contains(root, next)) {
|
if (!Filesystem.contains(root, next)) {
|
||||||
throw new Error(`Plugin ${spec} resolved ${kind} entry outside plugin directory`)
|
throw new Error(`Plugin ${spec} resolved ${kind} entry outside plugin directory`)
|
||||||
}
|
}
|
||||||
return pathToFileURL(next).href
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolvePackagePath(spec: string, raw: string, kind: PluginKind, pkg: PluginPackage) {
|
||||||
|
return pathToFileURL(resolvePackageFile(spec, raw, kind, pkg)).href
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolvePackageEntrypoint(spec: string, kind: PluginKind, pkg: PluginPackage) {
|
function resolvePackageEntrypoint(spec: string, kind: PluginKind, pkg: PluginPackage) {
|
||||||
|
|
@ -106,7 +114,7 @@ async function resolveDirectoryIndex(dir: string) {
|
||||||
async function resolveTargetDirectory(target: string) {
|
async function resolveTargetDirectory(target: string) {
|
||||||
const file = targetPath(target)
|
const file = targetPath(target)
|
||||||
if (!file) return
|
if (!file) return
|
||||||
const stat = Filesystem.stat(file)
|
const stat = await Filesystem.statAsync(file)
|
||||||
if (!stat?.isDirectory()) return
|
if (!stat?.isDirectory()) return
|
||||||
return file
|
return file
|
||||||
}
|
}
|
||||||
|
|
@ -147,13 +155,13 @@ async function resolvePluginEntrypoint(spec: string, target: string, kind: Plugi
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isPathPluginSpec(spec: string) {
|
export function isPathPluginSpec(spec: string) {
|
||||||
return spec.startsWith("file://") || spec.startsWith(".") || path.isAbsolute(spec) || /^[A-Za-z]:[\\/]/.test(spec)
|
return spec.startsWith("file://") || spec.startsWith(".") || isAbsolutePath(spec)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function resolvePathPluginTarget(spec: string) {
|
export async function resolvePathPluginTarget(spec: string) {
|
||||||
const raw = spec.startsWith("file://") ? fileURLToPath(spec) : spec
|
const raw = spec.startsWith("file://") ? fileURLToPath(spec) : spec
|
||||||
const file = path.isAbsolute(raw) || /^[A-Za-z]:[\\/]/.test(raw) ? raw : path.resolve(raw)
|
const file = path.isAbsolute(raw) || /^[A-Za-z]:[\\/]/.test(raw) ? raw : path.resolve(raw)
|
||||||
const stat = Filesystem.stat(file)
|
const stat = await Filesystem.statAsync(file)
|
||||||
if (!stat?.isDirectory()) {
|
if (!stat?.isDirectory()) {
|
||||||
if (spec.startsWith("file://")) return spec
|
if (spec.startsWith("file://")) return spec
|
||||||
return pathToFileURL(file).href
|
return pathToFileURL(file).href
|
||||||
|
|
@ -190,7 +198,7 @@ export async function resolvePluginTarget(spec: string, parsed = parsePluginSpec
|
||||||
|
|
||||||
export async function readPluginPackage(target: string): Promise<PluginPackage> {
|
export async function readPluginPackage(target: string): Promise<PluginPackage> {
|
||||||
const file = target.startsWith("file://") ? fileURLToPath(target) : target
|
const file = target.startsWith("file://") ? fileURLToPath(target) : target
|
||||||
const stat = Filesystem.stat(file)
|
const stat = await Filesystem.statAsync(file)
|
||||||
const dir = stat?.isDirectory() ? file : path.dirname(file)
|
const dir = stat?.isDirectory() ? file : path.dirname(file)
|
||||||
const pkg = path.join(dir, "package.json")
|
const pkg = path.join(dir, "package.json")
|
||||||
const json = await Filesystem.readJson<Record<string, unknown>>(pkg)
|
const json = await Filesystem.readJson<Record<string, unknown>>(pkg)
|
||||||
|
|
@ -211,6 +219,32 @@ export async function createPluginEntry(spec: string, target: string, kind: Plug
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function readPackageThemes(spec: string, pkg: PluginPackage) {
|
||||||
|
const field = pkg.json["oc-themes"]
|
||||||
|
if (field === undefined) return []
|
||||||
|
if (!Array.isArray(field)) {
|
||||||
|
throw new TypeError(`Plugin ${spec} has invalid oc-themes field`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const list = field.map((item) => {
|
||||||
|
if (typeof item !== "string") {
|
||||||
|
throw new TypeError(`Plugin ${spec} has invalid oc-themes entry`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = item.trim()
|
||||||
|
if (!raw) {
|
||||||
|
throw new TypeError(`Plugin ${spec} has empty oc-themes entry`)
|
||||||
|
}
|
||||||
|
if (raw.startsWith("file://") || isAbsolutePath(raw)) {
|
||||||
|
throw new TypeError(`Plugin ${spec} oc-themes entry must be relative: ${item}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolvePackageFile(spec, raw, "oc-themes", pkg)
|
||||||
|
})
|
||||||
|
|
||||||
|
return Array.from(new Set(list))
|
||||||
|
}
|
||||||
|
|
||||||
export function readPluginId(id: unknown, spec: string) {
|
export function readPluginId(id: unknown, spec: string) {
|
||||||
if (id === undefined) return
|
if (id === undefined) return
|
||||||
if (typeof id !== "string") throw new TypeError(`Plugin ${spec} has invalid id type ${typeof id}`)
|
if (typeof id !== "string") throw new TypeError(`Plugin ${spec} has invalid id type ${typeof id}`)
|
||||||
|
|
|
||||||
|
|
@ -166,17 +166,42 @@ export namespace Filesystem {
|
||||||
return !relative(parent, child).startsWith("..")
|
return !relative(parent, child).startsWith("..")
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function findUp(target: string, start: string, stop?: string) {
|
export async function findUp(
|
||||||
|
target: string,
|
||||||
|
start: string,
|
||||||
|
stop?: string,
|
||||||
|
options?: { rootFirst?: boolean },
|
||||||
|
): Promise<string[]>
|
||||||
|
export async function findUp(
|
||||||
|
target: string[],
|
||||||
|
start: string,
|
||||||
|
stop?: string,
|
||||||
|
options?: { rootFirst?: boolean },
|
||||||
|
): Promise<string[]>
|
||||||
|
export async function findUp(
|
||||||
|
target: string | string[],
|
||||||
|
start: string,
|
||||||
|
stop?: string,
|
||||||
|
options?: { rootFirst?: boolean },
|
||||||
|
) {
|
||||||
|
const dirs = [start]
|
||||||
let current = start
|
let current = start
|
||||||
const result = []
|
|
||||||
while (true) {
|
while (true) {
|
||||||
const search = join(current, target)
|
|
||||||
if (await exists(search)) result.push(search)
|
|
||||||
if (stop === current) break
|
if (stop === current) break
|
||||||
const parent = dirname(current)
|
const parent = dirname(current)
|
||||||
if (parent === current) break
|
if (parent === current) break
|
||||||
|
dirs.push(parent)
|
||||||
current = parent
|
current = parent
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const targets = Array.isArray(target) ? target : [target]
|
||||||
|
const result = []
|
||||||
|
for (const dir of options?.rootFirst ? dirs.toReversed() : dirs) {
|
||||||
|
for (const item of targets) {
|
||||||
|
const search = join(dir, item)
|
||||||
|
if (await exists(search)) result.push(search)
|
||||||
|
}
|
||||||
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@ test("adds tui plugin at runtime from spec", async () => {
|
||||||
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
|
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
|
||||||
const get = spyOn(TuiConfig, "get").mockResolvedValue({
|
const get = spyOn(TuiConfig, "get").mockResolvedValue({
|
||||||
plugin: [],
|
plugin: [],
|
||||||
plugin_records: undefined,
|
plugin_origins: undefined,
|
||||||
})
|
})
|
||||||
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
|
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
|
||||||
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
|
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
|
||||||
|
|
@ -59,3 +59,49 @@ test("adds tui plugin at runtime from spec", async () => {
|
||||||
delete process.env.OPENCODE_PLUGIN_META_FILE
|
delete process.env.OPENCODE_PLUGIN_META_FILE
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("retries runtime add for file plugins after dependency wait", async () => {
|
||||||
|
await using tmp = await tmpdir({
|
||||||
|
init: async (dir) => {
|
||||||
|
const mod = path.join(dir, "retry-plugin")
|
||||||
|
const spec = pathToFileURL(mod).href
|
||||||
|
const marker = path.join(dir, "retry-add.txt")
|
||||||
|
await fs.mkdir(mod, { recursive: true })
|
||||||
|
return { mod, spec, marker }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
|
||||||
|
const get = spyOn(TuiConfig, "get").mockResolvedValue({
|
||||||
|
plugin: [],
|
||||||
|
plugin_origins: undefined,
|
||||||
|
})
|
||||||
|
const wait = spyOn(TuiConfig, "waitForDependencies").mockImplementation(async () => {
|
||||||
|
await Bun.write(
|
||||||
|
path.join(tmp.extra.mod, "index.ts"),
|
||||||
|
`export default {
|
||||||
|
id: "demo.add.retry",
|
||||||
|
tui: async () => {
|
||||||
|
await Bun.write(${JSON.stringify(tmp.extra.marker)}, "called")
|
||||||
|
},
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await TuiPluginRuntime.init(createTuiPluginApi())
|
||||||
|
|
||||||
|
await expect(TuiPluginRuntime.addPlugin(tmp.extra.spec)).resolves.toBe(true)
|
||||||
|
await expect(fs.readFile(tmp.extra.marker, "utf8")).resolves.toBe("called")
|
||||||
|
expect(wait).toHaveBeenCalledTimes(1)
|
||||||
|
expect(TuiPluginRuntime.list().find((item) => item.id === "demo.add.retry")?.active).toBe(true)
|
||||||
|
} finally {
|
||||||
|
await TuiPluginRuntime.dispose()
|
||||||
|
cwd.mockRestore()
|
||||||
|
get.mockRestore()
|
||||||
|
wait.mockRestore()
|
||||||
|
delete process.env.OPENCODE_PLUGIN_META_FILE
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,7 @@ test("installs plugin without loading it", async () => {
|
||||||
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
|
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
|
||||||
const cfg: Awaited<ReturnType<typeof TuiConfig.get>> = {
|
const cfg: Awaited<ReturnType<typeof TuiConfig.get>> = {
|
||||||
plugin: [],
|
plugin: [],
|
||||||
plugin_records: undefined,
|
plugin_origins: undefined,
|
||||||
}
|
}
|
||||||
const get = spyOn(TuiConfig, "get").mockImplementation(async () => cfg)
|
const get = spyOn(TuiConfig, "get").mockImplementation(async () => cfg)
|
||||||
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
|
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
|
||||||
|
|
|
||||||
|
|
@ -46,9 +46,9 @@ test("loads npm tui plugin from package ./tui export", async () => {
|
||||||
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
|
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
|
||||||
const get = spyOn(TuiConfig, "get").mockResolvedValue({
|
const get = spyOn(TuiConfig, "get").mockResolvedValue({
|
||||||
plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]],
|
plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]],
|
||||||
plugin_records: [
|
plugin_origins: [
|
||||||
{
|
{
|
||||||
item: [tmp.extra.spec, { marker: tmp.extra.marker }],
|
spec: [tmp.extra.spec, { marker: tmp.extra.marker }],
|
||||||
scope: "local",
|
scope: "local",
|
||||||
source: path.join(tmp.path, "tui.json"),
|
source: path.join(tmp.path, "tui.json"),
|
||||||
},
|
},
|
||||||
|
|
@ -108,9 +108,9 @@ test("does not use npm package exports dot for tui entry", async () => {
|
||||||
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
|
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
|
||||||
const get = spyOn(TuiConfig, "get").mockResolvedValue({
|
const get = spyOn(TuiConfig, "get").mockResolvedValue({
|
||||||
plugin: [tmp.extra.spec],
|
plugin: [tmp.extra.spec],
|
||||||
plugin_records: [
|
plugin_origins: [
|
||||||
{
|
{
|
||||||
item: tmp.extra.spec,
|
spec: tmp.extra.spec,
|
||||||
scope: "local",
|
scope: "local",
|
||||||
source: path.join(tmp.path, "tui.json"),
|
source: path.join(tmp.path, "tui.json"),
|
||||||
},
|
},
|
||||||
|
|
@ -171,9 +171,9 @@ test("rejects npm tui export that resolves outside plugin directory", async () =
|
||||||
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
|
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
|
||||||
const get = spyOn(TuiConfig, "get").mockResolvedValue({
|
const get = spyOn(TuiConfig, "get").mockResolvedValue({
|
||||||
plugin: [tmp.extra.spec],
|
plugin: [tmp.extra.spec],
|
||||||
plugin_records: [
|
plugin_origins: [
|
||||||
{
|
{
|
||||||
item: tmp.extra.spec,
|
spec: tmp.extra.spec,
|
||||||
scope: "local",
|
scope: "local",
|
||||||
source: path.join(tmp.path, "tui.json"),
|
source: path.join(tmp.path, "tui.json"),
|
||||||
},
|
},
|
||||||
|
|
@ -234,9 +234,9 @@ test("rejects npm tui plugin that exports server and tui together", async () =>
|
||||||
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
|
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
|
||||||
const get = spyOn(TuiConfig, "get").mockResolvedValue({
|
const get = spyOn(TuiConfig, "get").mockResolvedValue({
|
||||||
plugin: [tmp.extra.spec],
|
plugin: [tmp.extra.spec],
|
||||||
plugin_records: [
|
plugin_origins: [
|
||||||
{
|
{
|
||||||
item: tmp.extra.spec,
|
spec: tmp.extra.spec,
|
||||||
scope: "local",
|
scope: "local",
|
||||||
source: path.join(tmp.path, "tui.json"),
|
source: path.join(tmp.path, "tui.json"),
|
||||||
},
|
},
|
||||||
|
|
@ -293,9 +293,9 @@ test("does not use npm package main for tui entry", async () => {
|
||||||
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
|
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
|
||||||
const get = spyOn(TuiConfig, "get").mockResolvedValue({
|
const get = spyOn(TuiConfig, "get").mockResolvedValue({
|
||||||
plugin: [tmp.extra.spec],
|
plugin: [tmp.extra.spec],
|
||||||
plugin_records: [
|
plugin_origins: [
|
||||||
{
|
{
|
||||||
item: tmp.extra.spec,
|
spec: tmp.extra.spec,
|
||||||
scope: "local",
|
scope: "local",
|
||||||
source: path.join(tmp.path, "tui.json"),
|
source: path.join(tmp.path, "tui.json"),
|
||||||
},
|
},
|
||||||
|
|
@ -359,9 +359,9 @@ test("does not use directory package main for tui entry", async () => {
|
||||||
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
|
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
|
||||||
const get = spyOn(TuiConfig, "get").mockResolvedValue({
|
const get = spyOn(TuiConfig, "get").mockResolvedValue({
|
||||||
plugin: [tmp.extra.spec],
|
plugin: [tmp.extra.spec],
|
||||||
plugin_records: [
|
plugin_origins: [
|
||||||
{
|
{
|
||||||
item: tmp.extra.spec,
|
spec: tmp.extra.spec,
|
||||||
scope: "local",
|
scope: "local",
|
||||||
source: path.join(tmp.path, "tui.json"),
|
source: path.join(tmp.path, "tui.json"),
|
||||||
},
|
},
|
||||||
|
|
@ -407,9 +407,9 @@ test("uses directory index fallback for tui when package.json is missing", async
|
||||||
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
|
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
|
||||||
const get = spyOn(TuiConfig, "get").mockResolvedValue({
|
const get = spyOn(TuiConfig, "get").mockResolvedValue({
|
||||||
plugin: [tmp.extra.spec],
|
plugin: [tmp.extra.spec],
|
||||||
plugin_records: [
|
plugin_origins: [
|
||||||
{
|
{
|
||||||
item: tmp.extra.spec,
|
spec: tmp.extra.spec,
|
||||||
scope: "local",
|
scope: "local",
|
||||||
source: path.join(tmp.path, "tui.json"),
|
source: path.join(tmp.path, "tui.json"),
|
||||||
},
|
},
|
||||||
|
|
@ -465,9 +465,9 @@ test("uses npm package name when tui plugin id is omitted", async () => {
|
||||||
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
|
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
|
||||||
const get = spyOn(TuiConfig, "get").mockResolvedValue({
|
const get = spyOn(TuiConfig, "get").mockResolvedValue({
|
||||||
plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]],
|
plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]],
|
||||||
plugin_records: [
|
plugin_origins: [
|
||||||
{
|
{
|
||||||
item: [tmp.extra.spec, { marker: tmp.extra.marker }],
|
spec: [tmp.extra.spec, { marker: tmp.extra.marker }],
|
||||||
scope: "local",
|
scope: "local",
|
||||||
source: path.join(tmp.path, "tui.json"),
|
source: path.join(tmp.path, "tui.json"),
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -39,9 +39,9 @@ test("skips external tui plugins in pure mode", async () => {
|
||||||
|
|
||||||
const get = spyOn(TuiConfig, "get").mockResolvedValue({
|
const get = spyOn(TuiConfig, "get").mockResolvedValue({
|
||||||
plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]],
|
plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]],
|
||||||
plugin_records: [
|
plugin_origins: [
|
||||||
{
|
{
|
||||||
item: [tmp.extra.spec, { marker: tmp.extra.marker }],
|
spec: [tmp.extra.spec, { marker: tmp.extra.marker }],
|
||||||
scope: "local",
|
scope: "local",
|
||||||
source: path.join(tmp.path, "tui.json"),
|
source: path.join(tmp.path, "tui.json"),
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -468,14 +468,14 @@ test("continues loading when a plugin is missing config metadata", async () => {
|
||||||
[tmp.extra.goodSpec, { marker: tmp.extra.goodMarker }],
|
[tmp.extra.goodSpec, { marker: tmp.extra.goodMarker }],
|
||||||
tmp.extra.bareSpec,
|
tmp.extra.bareSpec,
|
||||||
],
|
],
|
||||||
plugin_records: [
|
plugin_origins: [
|
||||||
{
|
{
|
||||||
item: [tmp.extra.goodSpec, { marker: tmp.extra.goodMarker }],
|
spec: [tmp.extra.goodSpec, { marker: tmp.extra.goodMarker }],
|
||||||
scope: "local",
|
scope: "local",
|
||||||
source: path.join(tmp.path, "tui.json"),
|
source: path.join(tmp.path, "tui.json"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
item: tmp.extra.bareSpec,
|
spec: tmp.extra.bareSpec,
|
||||||
scope: "local",
|
scope: "local",
|
||||||
source: path.join(tmp.path, "tui.json"),
|
source: path.join(tmp.path, "tui.json"),
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -44,9 +44,9 @@ test("toggles plugin runtime state by exported id", async () => {
|
||||||
plugin_enabled: {
|
plugin_enabled: {
|
||||||
"demo.toggle": false,
|
"demo.toggle": false,
|
||||||
},
|
},
|
||||||
plugin_records: [
|
plugin_origins: [
|
||||||
{
|
{
|
||||||
item: [tmp.extra.spec, { marker: tmp.extra.marker }],
|
spec: [tmp.extra.spec, { marker: tmp.extra.marker }],
|
||||||
scope: "local",
|
scope: "local",
|
||||||
source: path.join(tmp.path, "tui.json"),
|
source: path.join(tmp.path, "tui.json"),
|
||||||
},
|
},
|
||||||
|
|
@ -122,9 +122,9 @@ test("kv plugin_enabled overrides tui config on startup", async () => {
|
||||||
plugin_enabled: {
|
plugin_enabled: {
|
||||||
"demo.startup": false,
|
"demo.startup": false,
|
||||||
},
|
},
|
||||||
plugin_records: [
|
plugin_origins: [
|
||||||
{
|
{
|
||||||
item: [tmp.extra.spec, { marker: tmp.extra.marker }],
|
spec: [tmp.extra.spec, { marker: tmp.extra.marker }],
|
||||||
scope: "local",
|
scope: "local",
|
||||||
source: path.join(tmp.path, "tui.json"),
|
source: path.join(tmp.path, "tui.json"),
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { test, expect, describe, mock, afterEach, spyOn } from "bun:test"
|
import { test, expect, describe, mock, afterEach, beforeEach, spyOn } from "bun:test"
|
||||||
import { Effect, Layer, Option } from "effect"
|
import { Effect, Layer, Option } from "effect"
|
||||||
import { NodeFileSystem, NodePath } from "@effect/platform-node"
|
import { NodeFileSystem, NodePath } from "@effect/platform-node"
|
||||||
import { Config } from "../../src/config/config"
|
import { Config } from "../../src/config/config"
|
||||||
|
|
@ -34,8 +34,13 @@ const emptyAuth = Layer.mock(Auth.Service)({
|
||||||
// Get managed config directory from environment (set in preload.ts)
|
// Get managed config directory from environment (set in preload.ts)
|
||||||
const managedConfigDir = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR!
|
const managedConfigDir = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR!
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await Config.invalidate(true)
|
||||||
|
})
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
await fs.rm(managedConfigDir, { force: true, recursive: true }).catch(() => {})
|
await fs.rm(managedConfigDir, { force: true, recursive: true }).catch(() => {})
|
||||||
|
await Config.invalidate(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
async function writeManagedSettings(settings: object, filename = "opencode.json") {
|
async function writeManagedSettings(settings: object, filename = "opencode.json") {
|
||||||
|
|
@ -169,7 +174,7 @@ test("loads JSONC config file", async () => {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test("merges multiple config files with correct precedence", async () => {
|
test("jsonc overrides json in the same directory", async () => {
|
||||||
await using tmp = await tmpdir({
|
await using tmp = await tmpdir({
|
||||||
init: async (dir) => {
|
init: async (dir) => {
|
||||||
await writeConfig(
|
await writeConfig(
|
||||||
|
|
@ -191,7 +196,7 @@ test("merges multiple config files with correct precedence", async () => {
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: async () => {
|
fn: async () => {
|
||||||
const config = await Config.get()
|
const config = await Config.get()
|
||||||
expect(config.model).toBe("override")
|
expect(config.model).toBe("base")
|
||||||
expect(config.username).toBe("base")
|
expect(config.username).toBe("base")
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
@ -1174,6 +1179,51 @@ test("deduplicates duplicate plugins from global and local configs", async () =>
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("keeps plugin origins aligned with merged plugin list", async () => {
|
||||||
|
await using tmp = await tmpdir({
|
||||||
|
init: async (dir) => {
|
||||||
|
const project = path.join(dir, "project")
|
||||||
|
const local = path.join(project, ".opencode")
|
||||||
|
await fs.mkdir(local, { recursive: true })
|
||||||
|
|
||||||
|
await Filesystem.write(
|
||||||
|
path.join(dir, "opencode.json"),
|
||||||
|
JSON.stringify({
|
||||||
|
$schema: "https://opencode.ai/config.json",
|
||||||
|
plugin: [["shared-plugin@1.0.0", { source: "global" }], "global-only@1.0.0"],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
await Filesystem.write(
|
||||||
|
path.join(local, "opencode.json"),
|
||||||
|
JSON.stringify({
|
||||||
|
$schema: "https://opencode.ai/config.json",
|
||||||
|
plugin: [["shared-plugin@2.0.0", { source: "local" }], "local-only@1.0.0"],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await Instance.provide({
|
||||||
|
directory: path.join(tmp.path, "project"),
|
||||||
|
fn: async () => {
|
||||||
|
const cfg = await Config.get()
|
||||||
|
const plugins = cfg.plugin ?? []
|
||||||
|
const origins = cfg.plugin_origins ?? []
|
||||||
|
const names = plugins.map((item) => Config.pluginSpecifier(item))
|
||||||
|
|
||||||
|
expect(names).toContain("shared-plugin@2.0.0")
|
||||||
|
expect(names).not.toContain("shared-plugin@1.0.0")
|
||||||
|
expect(names).toContain("global-only@1.0.0")
|
||||||
|
expect(names).toContain("local-only@1.0.0")
|
||||||
|
|
||||||
|
expect(origins.map((item) => item.spec)).toEqual(plugins)
|
||||||
|
const hit = origins.find((item) => Config.pluginSpecifier(item.spec) === "shared-plugin@2.0.0")
|
||||||
|
expect(hit?.scope).toBe("local")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
// Legacy tools migration tests
|
// Legacy tools migration tests
|
||||||
|
|
||||||
test("migrates legacy tools config to permissions - allow", async () => {
|
test("migrates legacy tools config to permissions - allow", async () => {
|
||||||
|
|
@ -1550,7 +1600,7 @@ test("project config can override MCP server enabled status", async () => {
|
||||||
init: async (dir) => {
|
init: async (dir) => {
|
||||||
// Simulates a base config (like from remote .well-known) with disabled MCP
|
// Simulates a base config (like from remote .well-known) with disabled MCP
|
||||||
await Filesystem.write(
|
await Filesystem.write(
|
||||||
path.join(dir, "opencode.jsonc"),
|
path.join(dir, "opencode.json"),
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
$schema: "https://opencode.ai/config.json",
|
$schema: "https://opencode.ai/config.json",
|
||||||
mcp: {
|
mcp: {
|
||||||
|
|
@ -1569,7 +1619,7 @@ test("project config can override MCP server enabled status", async () => {
|
||||||
)
|
)
|
||||||
// Project config enables just jira
|
// Project config enables just jira
|
||||||
await Filesystem.write(
|
await Filesystem.write(
|
||||||
path.join(dir, "opencode.json"),
|
path.join(dir, "opencode.jsonc"),
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
$schema: "https://opencode.ai/config.json",
|
$schema: "https://opencode.ai/config.json",
|
||||||
mcp: {
|
mcp: {
|
||||||
|
|
@ -1608,7 +1658,7 @@ test("MCP config deep merges preserving base config properties", async () => {
|
||||||
init: async (dir) => {
|
init: async (dir) => {
|
||||||
// Base config with full MCP definition
|
// Base config with full MCP definition
|
||||||
await Filesystem.write(
|
await Filesystem.write(
|
||||||
path.join(dir, "opencode.jsonc"),
|
path.join(dir, "opencode.json"),
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
$schema: "https://opencode.ai/config.json",
|
$schema: "https://opencode.ai/config.json",
|
||||||
mcp: {
|
mcp: {
|
||||||
|
|
@ -1625,7 +1675,7 @@ test("MCP config deep merges preserving base config properties", async () => {
|
||||||
)
|
)
|
||||||
// Override just enables it, should preserve other properties
|
// Override just enables it, should preserve other properties
|
||||||
await Filesystem.write(
|
await Filesystem.write(
|
||||||
path.join(dir, "opencode.json"),
|
path.join(dir, "opencode.jsonc"),
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
$schema: "https://opencode.ai/config.json",
|
$schema: "https://opencode.ai/config.json",
|
||||||
mcp: {
|
mcp: {
|
||||||
|
|
@ -1875,11 +1925,20 @@ describe("resolvePluginSpec", () => {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("deduplicatePlugins", () => {
|
describe("deduplicatePluginOrigins", () => {
|
||||||
|
const dedupe = (plugins: Config.PluginSpec[]) =>
|
||||||
|
Config.deduplicatePluginOrigins(
|
||||||
|
plugins.map((spec) => ({
|
||||||
|
spec,
|
||||||
|
source: "",
|
||||||
|
scope: "global" as const,
|
||||||
|
})),
|
||||||
|
).map((item) => item.spec)
|
||||||
|
|
||||||
test("removes duplicates keeping higher priority (later entries)", () => {
|
test("removes duplicates keeping higher priority (later entries)", () => {
|
||||||
const plugins = ["global-plugin@1.0.0", "shared-plugin@1.0.0", "local-plugin@2.0.0", "shared-plugin@2.0.0"]
|
const plugins = ["global-plugin@1.0.0", "shared-plugin@1.0.0", "local-plugin@2.0.0", "shared-plugin@2.0.0"]
|
||||||
|
|
||||||
const result = Config.deduplicatePlugins(plugins)
|
const result = dedupe(plugins)
|
||||||
|
|
||||||
expect(result).toContain("global-plugin@1.0.0")
|
expect(result).toContain("global-plugin@1.0.0")
|
||||||
expect(result).toContain("local-plugin@2.0.0")
|
expect(result).toContain("local-plugin@2.0.0")
|
||||||
|
|
@ -1891,7 +1950,7 @@ describe("deduplicatePlugins", () => {
|
||||||
test("keeps path plugins separate from package plugins", () => {
|
test("keeps path plugins separate from package plugins", () => {
|
||||||
const plugins = ["oh-my-opencode@2.4.3", "file:///project/.opencode/plugin/oh-my-opencode.js"]
|
const plugins = ["oh-my-opencode@2.4.3", "file:///project/.opencode/plugin/oh-my-opencode.js"]
|
||||||
|
|
||||||
const result = Config.deduplicatePlugins(plugins)
|
const result = dedupe(plugins)
|
||||||
|
|
||||||
expect(result).toEqual(plugins)
|
expect(result).toEqual(plugins)
|
||||||
})
|
})
|
||||||
|
|
@ -1899,7 +1958,7 @@ describe("deduplicatePlugins", () => {
|
||||||
test("deduplicates direct path plugins by exact spec", () => {
|
test("deduplicates direct path plugins by exact spec", () => {
|
||||||
const plugins = ["file:///project/.opencode/plugin/demo.ts", "file:///project/.opencode/plugin/demo.ts"]
|
const plugins = ["file:///project/.opencode/plugin/demo.ts", "file:///project/.opencode/plugin/demo.ts"]
|
||||||
|
|
||||||
const result = Config.deduplicatePlugins(plugins)
|
const result = dedupe(plugins)
|
||||||
|
|
||||||
expect(result).toEqual(["file:///project/.opencode/plugin/demo.ts"])
|
expect(result).toEqual(["file:///project/.opencode/plugin/demo.ts"])
|
||||||
})
|
})
|
||||||
|
|
@ -1907,7 +1966,7 @@ describe("deduplicatePlugins", () => {
|
||||||
test("preserves order of remaining plugins", () => {
|
test("preserves order of remaining plugins", () => {
|
||||||
const plugins = ["a-plugin@1.0.0", "b-plugin@1.0.0", "c-plugin@1.0.0"]
|
const plugins = ["a-plugin@1.0.0", "b-plugin@1.0.0", "c-plugin@1.0.0"]
|
||||||
|
|
||||||
const result = Config.deduplicatePlugins(plugins)
|
const result = dedupe(plugins)
|
||||||
|
|
||||||
expect(result).toEqual(["a-plugin@1.0.0", "b-plugin@1.0.0", "c-plugin@1.0.0"])
|
expect(result).toEqual(["a-plugin@1.0.0", "b-plugin@1.0.0", "c-plugin@1.0.0"])
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,99 @@
|
||||||
import { afterEach, expect, test } from "bun:test"
|
import { afterEach, beforeEach, expect, test } from "bun:test"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import fs from "fs/promises"
|
import fs from "fs/promises"
|
||||||
import { tmpdir } from "../fixture/fixture"
|
import { tmpdir } from "../fixture/fixture"
|
||||||
import { Instance } from "../../src/project/instance"
|
import { Instance } from "../../src/project/instance"
|
||||||
|
import { Config } from "../../src/config/config"
|
||||||
import { TuiConfig } from "../../src/config/tui"
|
import { TuiConfig } from "../../src/config/tui"
|
||||||
import { Global } from "../../src/global"
|
import { Global } from "../../src/global"
|
||||||
import { Filesystem } from "../../src/util/filesystem"
|
import { Filesystem } from "../../src/util/filesystem"
|
||||||
|
|
||||||
const managedConfigDir = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR!
|
const managedConfigDir = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR!
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await Config.invalidate(true)
|
||||||
|
})
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
delete process.env.OPENCODE_CONFIG
|
delete process.env.OPENCODE_CONFIG
|
||||||
delete process.env.OPENCODE_TUI_CONFIG
|
delete process.env.OPENCODE_TUI_CONFIG
|
||||||
|
await fs.rm(path.join(Global.Path.config, "opencode.json"), { force: true }).catch(() => {})
|
||||||
|
await fs.rm(path.join(Global.Path.config, "opencode.jsonc"), { force: true }).catch(() => {})
|
||||||
await fs.rm(path.join(Global.Path.config, "tui.json"), { force: true }).catch(() => {})
|
await fs.rm(path.join(Global.Path.config, "tui.json"), { force: true }).catch(() => {})
|
||||||
await fs.rm(path.join(Global.Path.config, "tui.jsonc"), { force: true }).catch(() => {})
|
await fs.rm(path.join(Global.Path.config, "tui.jsonc"), { force: true }).catch(() => {})
|
||||||
await fs.rm(managedConfigDir, { force: true, recursive: true }).catch(() => {})
|
await fs.rm(managedConfigDir, { force: true, recursive: true }).catch(() => {})
|
||||||
|
await Config.invalidate(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("keeps server and tui plugin merge semantics aligned", async () => {
|
||||||
|
await using tmp = await tmpdir({
|
||||||
|
init: async (dir) => {
|
||||||
|
const local = path.join(dir, ".opencode")
|
||||||
|
await fs.mkdir(local, { recursive: true })
|
||||||
|
|
||||||
|
await Bun.write(
|
||||||
|
path.join(Global.Path.config, "opencode.json"),
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
plugin: [["shared-plugin@1.0.0", { source: "global" }], "global-only@1.0.0"],
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
await Bun.write(
|
||||||
|
path.join(Global.Path.config, "tui.json"),
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
plugin: [["shared-plugin@1.0.0", { source: "global" }], "global-only@1.0.0"],
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
await Bun.write(
|
||||||
|
path.join(local, "opencode.json"),
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
plugin: [["shared-plugin@2.0.0", { source: "local" }], "local-only@1.0.0"],
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
await Bun.write(
|
||||||
|
path.join(local, "tui.json"),
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
plugin: [["shared-plugin@2.0.0", { source: "local" }], "local-only@1.0.0"],
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async () => {
|
||||||
|
const server = await Config.get()
|
||||||
|
const tui = await TuiConfig.get()
|
||||||
|
const serverPlugins = (server.plugin ?? []).map((item) => Config.pluginSpecifier(item))
|
||||||
|
const tuiPlugins = (tui.plugin ?? []).map((item) => Config.pluginSpecifier(item))
|
||||||
|
|
||||||
|
expect(serverPlugins).toEqual(tuiPlugins)
|
||||||
|
expect(serverPlugins).toContain("shared-plugin@2.0.0")
|
||||||
|
expect(serverPlugins).not.toContain("shared-plugin@1.0.0")
|
||||||
|
|
||||||
|
const serverOrigins = server.plugin_origins ?? []
|
||||||
|
const tuiOrigins = tui.plugin_origins ?? []
|
||||||
|
expect(serverOrigins.map((item) => Config.pluginSpecifier(item.spec))).toEqual(serverPlugins)
|
||||||
|
expect(tuiOrigins.map((item) => Config.pluginSpecifier(item.spec))).toEqual(tuiPlugins)
|
||||||
|
expect(serverOrigins.map((item) => item.scope)).toEqual(tuiOrigins.map((item) => item.scope))
|
||||||
|
},
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test("loads tui config with the same precedence order as server config paths", async () => {
|
test("loads tui config with the same precedence order as server config paths", async () => {
|
||||||
|
|
@ -476,9 +555,9 @@ test("loads managed tui config and gives it highest precedence", async () => {
|
||||||
const config = await TuiConfig.get()
|
const config = await TuiConfig.get()
|
||||||
expect(config.theme).toBe("managed-theme")
|
expect(config.theme).toBe("managed-theme")
|
||||||
expect(config.plugin).toEqual(["shared-plugin@2.0.0"])
|
expect(config.plugin).toEqual(["shared-plugin@2.0.0"])
|
||||||
expect(config.plugin_records).toEqual([
|
expect(config.plugin_origins).toEqual([
|
||||||
{
|
{
|
||||||
item: "shared-plugin@2.0.0",
|
spec: "shared-plugin@2.0.0",
|
||||||
scope: "global",
|
scope: "global",
|
||||||
source: path.join(managedConfigDir, "tui.json"),
|
source: path.join(managedConfigDir, "tui.json"),
|
||||||
},
|
},
|
||||||
|
|
@ -540,9 +619,9 @@ test("supports tuple plugin specs with options in tui.json", async () => {
|
||||||
fn: async () => {
|
fn: async () => {
|
||||||
const config = await TuiConfig.get()
|
const config = await TuiConfig.get()
|
||||||
expect(config.plugin).toEqual([["acme-plugin@1.2.3", { enabled: true, label: "demo" }]])
|
expect(config.plugin).toEqual([["acme-plugin@1.2.3", { enabled: true, label: "demo" }]])
|
||||||
expect(config.plugin_records).toEqual([
|
expect(config.plugin_origins).toEqual([
|
||||||
{
|
{
|
||||||
item: ["acme-plugin@1.2.3", { enabled: true, label: "demo" }],
|
spec: ["acme-plugin@1.2.3", { enabled: true, label: "demo" }],
|
||||||
scope: "local",
|
scope: "local",
|
||||||
source: path.join(tmp.path, "tui.json"),
|
source: path.join(tmp.path, "tui.json"),
|
||||||
},
|
},
|
||||||
|
|
@ -580,14 +659,14 @@ test("deduplicates tuple plugin specs by name with higher precedence winning", a
|
||||||
["acme-plugin@2.0.0", { source: "project" }],
|
["acme-plugin@2.0.0", { source: "project" }],
|
||||||
["second-plugin@3.0.0", { source: "project" }],
|
["second-plugin@3.0.0", { source: "project" }],
|
||||||
])
|
])
|
||||||
expect(config.plugin_records).toEqual([
|
expect(config.plugin_origins).toEqual([
|
||||||
{
|
{
|
||||||
item: ["acme-plugin@2.0.0", { source: "project" }],
|
spec: ["acme-plugin@2.0.0", { source: "project" }],
|
||||||
scope: "local",
|
scope: "local",
|
||||||
source: path.join(tmp.path, "tui.json"),
|
source: path.join(tmp.path, "tui.json"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
item: ["second-plugin@3.0.0", { source: "project" }],
|
spec: ["second-plugin@3.0.0", { source: "project" }],
|
||||||
scope: "local",
|
scope: "local",
|
||||||
source: path.join(tmp.path, "tui.json"),
|
source: path.join(tmp.path, "tui.json"),
|
||||||
},
|
},
|
||||||
|
|
@ -619,14 +698,14 @@ test("tracks global and local plugin metadata in merged tui config", async () =>
|
||||||
fn: async () => {
|
fn: async () => {
|
||||||
const config = await TuiConfig.get()
|
const config = await TuiConfig.get()
|
||||||
expect(config.plugin).toEqual(["global-plugin@1.0.0", "local-plugin@2.0.0"])
|
expect(config.plugin).toEqual(["global-plugin@1.0.0", "local-plugin@2.0.0"])
|
||||||
expect(config.plugin_records).toEqual([
|
expect(config.plugin_origins).toEqual([
|
||||||
{
|
{
|
||||||
item: "global-plugin@1.0.0",
|
spec: "global-plugin@1.0.0",
|
||||||
scope: "global",
|
scope: "global",
|
||||||
source: path.join(Global.Path.config, "tui.json"),
|
source: path.join(Global.Path.config, "tui.json"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
item: "local-plugin@2.0.0",
|
spec: "local-plugin@2.0.0",
|
||||||
scope: "local",
|
scope: "local",
|
||||||
source: path.join(tmp.path, "tui.json"),
|
source: path.join(tmp.path, "tui.json"),
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -6,14 +6,14 @@ type PluginSpec = string | [string, Record<string, unknown>]
|
||||||
|
|
||||||
export function mockTuiRuntime(dir: string, plugin: PluginSpec[]) {
|
export function mockTuiRuntime(dir: string, plugin: PluginSpec[]) {
|
||||||
process.env.OPENCODE_PLUGIN_META_FILE = path.join(dir, "plugin-meta.json")
|
process.env.OPENCODE_PLUGIN_META_FILE = path.join(dir, "plugin-meta.json")
|
||||||
const plugin_records = plugin.map((item) => ({
|
const plugin_origins = plugin.map((spec) => ({
|
||||||
item,
|
spec,
|
||||||
scope: "local" as const,
|
scope: "local" as const,
|
||||||
source: path.join(dir, "tui.json"),
|
source: path.join(dir, "tui.json"),
|
||||||
}))
|
}))
|
||||||
const get = spyOn(TuiConfig, "get").mockResolvedValue({
|
const get = spyOn(TuiConfig, "get").mockResolvedValue({
|
||||||
plugin,
|
plugin,
|
||||||
plugin_records,
|
plugin_origins,
|
||||||
})
|
})
|
||||||
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
|
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
|
||||||
const cwd = spyOn(process, "cwd").mockImplementation(() => dir)
|
const cwd = spyOn(process, "cwd").mockImplementation(() => dir)
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,7 @@ async function plugin(
|
||||||
server?: Record<string, unknown>
|
server?: Record<string, unknown>
|
||||||
tui?: Record<string, unknown>
|
tui?: Record<string, unknown>
|
||||||
},
|
},
|
||||||
|
themes?: string[],
|
||||||
) {
|
) {
|
||||||
const p = path.join(dir, "plugin")
|
const p = path.join(dir, "plugin")
|
||||||
const server = kinds?.includes("server") ?? false
|
const server = kinds?.includes("server") ?? false
|
||||||
|
|
@ -92,6 +93,7 @@ async function plugin(
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
...(server ? { main: "./server.js" } : {}),
|
...(server ? { main: "./server.js" } : {}),
|
||||||
...(Object.keys(exports).length ? { exports } : {}),
|
...(Object.keys(exports).length ? { exports } : {}),
|
||||||
|
...(themes?.length ? { "oc-themes": themes } : {}),
|
||||||
},
|
},
|
||||||
null,
|
null,
|
||||||
2,
|
2,
|
||||||
|
|
@ -438,6 +440,43 @@ describe("plugin.install.task", () => {
|
||||||
expect(await Filesystem.exists(path.join(tmp.path, ".opencode", "opencode.jsonc"))).toBe(false)
|
expect(await Filesystem.exists(path.join(tmp.path, ".opencode", "opencode.jsonc"))).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("writes tui config for oc-themes-only packages", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const target = await plugin(tmp.path, undefined, undefined, ["themes/forest.json"])
|
||||||
|
await fs.mkdir(path.join(target, "themes"), { recursive: true })
|
||||||
|
await Bun.write(path.join(target, "themes", "forest.json"), JSON.stringify({ theme: { text: "#fff" } }, null, 2))
|
||||||
|
const run = createPlugTask(
|
||||||
|
{
|
||||||
|
mod: "acme@1.2.3",
|
||||||
|
},
|
||||||
|
deps(path.join(tmp.path, "global"), target),
|
||||||
|
)
|
||||||
|
|
||||||
|
const ok = await run(ctx(tmp.path))
|
||||||
|
expect(ok).toBe(true)
|
||||||
|
expect(await Filesystem.exists(path.join(tmp.path, ".opencode", "tui.jsonc"))).toBe(true)
|
||||||
|
expect(await Filesystem.exists(path.join(tmp.path, ".opencode", "opencode.jsonc"))).toBe(false)
|
||||||
|
|
||||||
|
const tui = await read(path.join(tmp.path, ".opencode", "tui.jsonc"))
|
||||||
|
expect(tui.plugin).toEqual(["acme@1.2.3"])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns false for oc-themes outside plugin directory", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const target = await plugin(tmp.path, undefined, undefined, ["../outside.json"])
|
||||||
|
const run = createPlugTask(
|
||||||
|
{
|
||||||
|
mod: "acme@1.2.3",
|
||||||
|
},
|
||||||
|
deps(path.join(tmp.path, "global"), target),
|
||||||
|
)
|
||||||
|
|
||||||
|
const ok = await run(ctx(tmp.path))
|
||||||
|
expect(ok).toBe(false)
|
||||||
|
expect(await Filesystem.exists(path.join(tmp.path, ".opencode", "tui.jsonc"))).toBe(false)
|
||||||
|
expect(await Filesystem.exists(path.join(tmp.path, ".opencode", "opencode.jsonc"))).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
test("force replaces version in both server and tui configs", async () => {
|
test("force replaces version in both server and tui configs", async () => {
|
||||||
await using tmp = await tmpdir()
|
await using tmp = await tmpdir()
|
||||||
const target = await plugin(tmp.path, ["server", "tui"])
|
const target = await plugin(tmp.path, ["server", "tui"])
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,8 @@ const disableDefault = process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS
|
||||||
process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS = "1"
|
process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS = "1"
|
||||||
|
|
||||||
const { Plugin } = await import("../../src/plugin/index")
|
const { Plugin } = await import("../../src/plugin/index")
|
||||||
|
const { PluginLoader } = await import("../../src/plugin/loader")
|
||||||
|
const { readPackageThemes } = await import("../../src/plugin/shared")
|
||||||
const { Instance } = await import("../../src/project/instance")
|
const { Instance } = await import("../../src/project/instance")
|
||||||
const { Npm } = await import("../../src/npm")
|
const { Npm } = await import("../../src/npm")
|
||||||
const { Bus } = await import("../../src/bus")
|
const { Bus } = await import("../../src/bus")
|
||||||
|
|
@ -833,4 +835,302 @@ export default {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("reads oc-themes from package manifest", async () => {
|
||||||
|
await using tmp = await tmpdir({
|
||||||
|
init: async (dir) => {
|
||||||
|
const mod = path.join(dir, "mod")
|
||||||
|
await fs.mkdir(path.join(mod, "themes"), { recursive: true })
|
||||||
|
await Bun.write(
|
||||||
|
path.join(mod, "package.json"),
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
name: "acme-plugin",
|
||||||
|
version: "1.0.0",
|
||||||
|
"oc-themes": ["themes/one.json", "./themes/one.json", "themes/two.json"],
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
return { mod }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const file = path.join(tmp.extra.mod, "package.json")
|
||||||
|
const json = await Filesystem.readJson<Record<string, unknown>>(file)
|
||||||
|
const list = readPackageThemes("acme-plugin", {
|
||||||
|
dir: tmp.extra.mod,
|
||||||
|
pkg: file,
|
||||||
|
json,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(list).toEqual([
|
||||||
|
Filesystem.resolve(path.join(tmp.extra.mod, "themes", "one.json")),
|
||||||
|
Filesystem.resolve(path.join(tmp.extra.mod, "themes", "two.json")),
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("handles no-entrypoint tui packages via missing callback", async () => {
|
||||||
|
await using tmp = await tmpdir({
|
||||||
|
init: async (dir) => {
|
||||||
|
const mod = path.join(dir, "mods", "acme-plugin")
|
||||||
|
await fs.mkdir(path.join(mod, "themes"), { recursive: true })
|
||||||
|
await Bun.write(
|
||||||
|
path.join(mod, "package.json"),
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
name: "acme-plugin",
|
||||||
|
version: "1.0.0",
|
||||||
|
"oc-themes": ["themes/night.json"],
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
await Bun.write(path.join(mod, "themes", "night.json"), "{}\n")
|
||||||
|
return { mod }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
|
||||||
|
const missing: string[] = []
|
||||||
|
|
||||||
|
try {
|
||||||
|
const loaded = await PluginLoader.loadExternal({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
spec: "acme-plugin@1.0.0",
|
||||||
|
scope: "local" as const,
|
||||||
|
source: tmp.path,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
kind: "tui",
|
||||||
|
missing: async (item) => {
|
||||||
|
if (!item.pkg) return
|
||||||
|
const themes = readPackageThemes(item.spec, item.pkg)
|
||||||
|
if (!themes.length) return
|
||||||
|
return {
|
||||||
|
spec: item.spec,
|
||||||
|
target: item.target,
|
||||||
|
themes,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
report: {
|
||||||
|
missing(_candidate, _retry, message) {
|
||||||
|
missing.push(message)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(loaded).toEqual([
|
||||||
|
{
|
||||||
|
spec: "acme-plugin@1.0.0",
|
||||||
|
target: tmp.extra.mod,
|
||||||
|
themes: [Filesystem.resolve(path.join(tmp.extra.mod, "themes", "night.json"))],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
expect(missing).toHaveLength(0)
|
||||||
|
} finally {
|
||||||
|
install.mockRestore()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("passes package metadata for entrypoint tui plugins", async () => {
|
||||||
|
await using tmp = await tmpdir({
|
||||||
|
init: async (dir) => {
|
||||||
|
const mod = path.join(dir, "mods", "acme-plugin")
|
||||||
|
await fs.mkdir(path.join(mod, "themes"), { recursive: true })
|
||||||
|
await Bun.write(
|
||||||
|
path.join(mod, "package.json"),
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
name: "acme-plugin",
|
||||||
|
version: "1.0.0",
|
||||||
|
exports: {
|
||||||
|
"./tui": "./tui.js",
|
||||||
|
},
|
||||||
|
"oc-themes": ["themes/night.json"],
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
await Bun.write(path.join(mod, "tui.js"), 'export default { id: "demo", tui: async () => {} }\n')
|
||||||
|
await Bun.write(path.join(mod, "themes", "night.json"), "{}\n")
|
||||||
|
return { mod }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
|
||||||
|
|
||||||
|
try {
|
||||||
|
const loaded = await PluginLoader.loadExternal({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
spec: "acme-plugin@1.0.0",
|
||||||
|
scope: "local" as const,
|
||||||
|
source: tmp.path,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
kind: "tui",
|
||||||
|
finish: async (item) => {
|
||||||
|
if (!item.pkg) return
|
||||||
|
return {
|
||||||
|
spec: item.spec,
|
||||||
|
themes: readPackageThemes(item.spec, item.pkg),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(loaded).toEqual([
|
||||||
|
{
|
||||||
|
spec: "acme-plugin@1.0.0",
|
||||||
|
themes: [Filesystem.resolve(path.join(tmp.extra.mod, "themes", "night.json"))],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
} finally {
|
||||||
|
install.mockRestore()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("rejects oc-themes path traversal", async () => {
|
||||||
|
await using tmp = await tmpdir({
|
||||||
|
init: async (dir) => {
|
||||||
|
const mod = path.join(dir, "mod")
|
||||||
|
await fs.mkdir(mod, { recursive: true })
|
||||||
|
const file = path.join(mod, "package.json")
|
||||||
|
await Bun.write(file, JSON.stringify({ name: "acme", "oc-themes": ["../escape.json"] }, null, 2))
|
||||||
|
return { mod, file }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const json = await Filesystem.readJson<Record<string, unknown>>(tmp.extra.file)
|
||||||
|
expect(() =>
|
||||||
|
readPackageThemes("acme", {
|
||||||
|
dir: tmp.extra.mod,
|
||||||
|
pkg: tmp.extra.file,
|
||||||
|
json,
|
||||||
|
}),
|
||||||
|
).toThrow("outside plugin directory")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("retries failed file plugins once after wait and keeps order", async () => {
|
||||||
|
await using tmp = await tmpdir({
|
||||||
|
init: async (dir) => {
|
||||||
|
const a = path.join(dir, "a")
|
||||||
|
const b = path.join(dir, "b")
|
||||||
|
const aSpec = pathToFileURL(a).href
|
||||||
|
const bSpec = pathToFileURL(b).href
|
||||||
|
await fs.mkdir(a, { recursive: true })
|
||||||
|
await fs.mkdir(b, { recursive: true })
|
||||||
|
return { a, b, aSpec, bSpec }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
let wait = 0
|
||||||
|
const calls: Array<[string, boolean]> = []
|
||||||
|
|
||||||
|
const loaded = await PluginLoader.loadExternal({
|
||||||
|
items: [tmp.extra.aSpec, tmp.extra.bSpec].map((spec) => ({
|
||||||
|
spec,
|
||||||
|
scope: "local" as const,
|
||||||
|
source: tmp.path,
|
||||||
|
})),
|
||||||
|
kind: "tui",
|
||||||
|
wait: async () => {
|
||||||
|
wait += 1
|
||||||
|
await Bun.write(path.join(tmp.extra.a, "index.ts"), "export default {}\n")
|
||||||
|
await Bun.write(path.join(tmp.extra.b, "index.ts"), "export default {}\n")
|
||||||
|
},
|
||||||
|
report: {
|
||||||
|
start(candidate, retry) {
|
||||||
|
calls.push([candidate.plan.spec, retry])
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(wait).toBe(1)
|
||||||
|
expect(calls).toEqual([
|
||||||
|
[tmp.extra.aSpec, false],
|
||||||
|
[tmp.extra.bSpec, false],
|
||||||
|
[tmp.extra.aSpec, true],
|
||||||
|
[tmp.extra.bSpec, true],
|
||||||
|
])
|
||||||
|
expect(loaded.map((item) => item.spec)).toEqual([tmp.extra.aSpec, tmp.extra.bSpec])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("retries file plugins when finish returns undefined", async () => {
|
||||||
|
await using tmp = await tmpdir({
|
||||||
|
init: async (dir) => {
|
||||||
|
const file = path.join(dir, "plugin.ts")
|
||||||
|
const spec = pathToFileURL(file).href
|
||||||
|
await Bun.write(file, "export default {}\n")
|
||||||
|
return { spec }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
let wait = 0
|
||||||
|
let count = 0
|
||||||
|
|
||||||
|
const loaded = await PluginLoader.loadExternal({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
spec: tmp.extra.spec,
|
||||||
|
scope: "local" as const,
|
||||||
|
source: tmp.path,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
kind: "tui",
|
||||||
|
wait: async () => {
|
||||||
|
wait += 1
|
||||||
|
},
|
||||||
|
finish: async (load, _item, retry) => {
|
||||||
|
count += 1
|
||||||
|
if (!retry) return
|
||||||
|
return {
|
||||||
|
retry,
|
||||||
|
spec: load.spec,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(wait).toBe(1)
|
||||||
|
expect(count).toBe(2)
|
||||||
|
expect(loaded).toEqual([{ retry: true, spec: tmp.extra.spec }])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("does not wait or retry npm plugin failures", async () => {
|
||||||
|
const install = spyOn(Npm, "add").mockRejectedValue(new Error("boom"))
|
||||||
|
let wait = 0
|
||||||
|
const errors: Array<[string, boolean]> = []
|
||||||
|
|
||||||
|
try {
|
||||||
|
const loaded = await PluginLoader.loadExternal({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
spec: "acme-plugin@1.0.0",
|
||||||
|
scope: "local" as const,
|
||||||
|
source: "test",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
kind: "tui",
|
||||||
|
wait: async () => {
|
||||||
|
wait += 1
|
||||||
|
},
|
||||||
|
report: {
|
||||||
|
error(_candidate, retry, stage) {
|
||||||
|
errors.push([stage, retry])
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(loaded).toEqual([])
|
||||||
|
expect(wait).toBe(0)
|
||||||
|
expect(errors).toEqual([["install", false]])
|
||||||
|
} finally {
|
||||||
|
install.mockRestore()
|
||||||
|
}
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -83,6 +83,95 @@ describe("filesystem", () => {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("findUp()", () => {
|
||||||
|
test("keeps previous nearest-first behavior for single target", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const parent = path.join(tmp.path, "parent")
|
||||||
|
const child = path.join(parent, "child")
|
||||||
|
await fs.mkdir(child, { recursive: true })
|
||||||
|
await fs.writeFile(path.join(tmp.path, "marker"), "root", "utf-8")
|
||||||
|
await fs.writeFile(path.join(parent, "marker"), "parent", "utf-8")
|
||||||
|
|
||||||
|
const result = await Filesystem.findUp("marker", child, tmp.path)
|
||||||
|
|
||||||
|
expect(result).toEqual([path.join(parent, "marker"), path.join(tmp.path, "marker")])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("respects stop boundary", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const parent = path.join(tmp.path, "parent")
|
||||||
|
const child = path.join(parent, "child")
|
||||||
|
await fs.mkdir(child, { recursive: true })
|
||||||
|
await fs.writeFile(path.join(tmp.path, "marker"), "root", "utf-8")
|
||||||
|
await fs.writeFile(path.join(parent, "marker"), "parent", "utf-8")
|
||||||
|
|
||||||
|
const result = await Filesystem.findUp("marker", child, parent)
|
||||||
|
|
||||||
|
expect(result).toEqual([path.join(parent, "marker")])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("supports multiple targets with nearest-first default ordering", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const parent = path.join(tmp.path, "parent")
|
||||||
|
const child = path.join(parent, "child")
|
||||||
|
await fs.mkdir(child, { recursive: true })
|
||||||
|
|
||||||
|
await fs.writeFile(path.join(parent, "cfg.jsonc"), "{}", "utf-8")
|
||||||
|
await fs.writeFile(path.join(tmp.path, "cfg.json"), "{}", "utf-8")
|
||||||
|
await fs.writeFile(path.join(tmp.path, "cfg.jsonc"), "{}", "utf-8")
|
||||||
|
|
||||||
|
const result = await Filesystem.findUp(["cfg.json", "cfg.jsonc"], child, tmp.path)
|
||||||
|
|
||||||
|
expect(result).toEqual([
|
||||||
|
path.join(parent, "cfg.jsonc"),
|
||||||
|
path.join(tmp.path, "cfg.json"),
|
||||||
|
path.join(tmp.path, "cfg.jsonc"),
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("supports rootFirst ordering for multiple targets", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const parent = path.join(tmp.path, "parent")
|
||||||
|
const child = path.join(parent, "child")
|
||||||
|
await fs.mkdir(child, { recursive: true })
|
||||||
|
|
||||||
|
await fs.writeFile(path.join(parent, "cfg.jsonc"), "{}", "utf-8")
|
||||||
|
await fs.writeFile(path.join(tmp.path, "cfg.json"), "{}", "utf-8")
|
||||||
|
await fs.writeFile(path.join(tmp.path, "cfg.jsonc"), "{}", "utf-8")
|
||||||
|
|
||||||
|
const result = await Filesystem.findUp(["cfg.json", "cfg.jsonc"], child, tmp.path, { rootFirst: true })
|
||||||
|
|
||||||
|
expect(result).toEqual([
|
||||||
|
path.join(tmp.path, "cfg.json"),
|
||||||
|
path.join(tmp.path, "cfg.jsonc"),
|
||||||
|
path.join(parent, "cfg.jsonc"),
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("rootFirst preserves json then jsonc order per directory", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const project = path.join(tmp.path, "project")
|
||||||
|
const nested = path.join(project, "nested")
|
||||||
|
await fs.mkdir(nested, { recursive: true })
|
||||||
|
|
||||||
|
await fs.writeFile(path.join(tmp.path, "opencode.json"), "{}", "utf-8")
|
||||||
|
await fs.writeFile(path.join(tmp.path, "opencode.jsonc"), "{}", "utf-8")
|
||||||
|
await fs.writeFile(path.join(project, "opencode.json"), "{}", "utf-8")
|
||||||
|
await fs.writeFile(path.join(project, "opencode.jsonc"), "{}", "utf-8")
|
||||||
|
|
||||||
|
const result = await Filesystem.findUp(["opencode.json", "opencode.jsonc"], nested, tmp.path, {
|
||||||
|
rootFirst: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result).toEqual([
|
||||||
|
path.join(tmp.path, "opencode.json"),
|
||||||
|
path.join(tmp.path, "opencode.jsonc"),
|
||||||
|
path.join(project, "opencode.json"),
|
||||||
|
path.join(project, "opencode.jsonc"),
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe("readText()", () => {
|
describe("readText()", () => {
|
||||||
test("reads file content", async () => {
|
test("reads file content", async () => {
|
||||||
await using tmp = await tmpdir()
|
await using tmp = await tmpdir()
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue