warn only and ignore plugins without entrypoints, default config via exports (#20284)
This commit is contained in:
@@ -88,6 +88,7 @@ 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 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.
|
||||||
@@ -100,7 +101,10 @@ export default plugin
|
|||||||
|
|
||||||
## Package manifest and install
|
## Package manifest and install
|
||||||
|
|
||||||
Package manifest is read from `package.json` field `oc-plugin`.
|
Install target detection is inferred from `package.json` entrypoints:
|
||||||
|
|
||||||
|
- `server` target when `exports["./server"]` exists or `main` is set.
|
||||||
|
- `tui` target when `exports["./tui"]` exists.
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
|
|
||||||
@@ -108,14 +112,20 @@ Example:
|
|||||||
{
|
{
|
||||||
"name": "@acme/opencode-plugin",
|
"name": "@acme/opencode-plugin",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./dist/index.js",
|
"main": "./dist/server.js",
|
||||||
|
"exports": {
|
||||||
|
"./server": {
|
||||||
|
"import": "./dist/server.js",
|
||||||
|
"config": { "custom": true }
|
||||||
|
},
|
||||||
|
"./tui": {
|
||||||
|
"import": "./dist/tui.js",
|
||||||
|
"config": { "compact": true }
|
||||||
|
}
|
||||||
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"opencode": "^1.0.0"
|
"opencode": "^1.0.0"
|
||||||
},
|
}
|
||||||
"oc-plugin": [
|
|
||||||
["server", { "custom": true }],
|
|
||||||
["tui", { "compact": true }]
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -144,11 +154,12 @@ npm plugins can declare a version compatibility range in `package.json` using th
|
|||||||
- Local installs resolve target dir inside `patchPluginConfig`.
|
- Local installs resolve target dir inside `patchPluginConfig`.
|
||||||
- For local scope, path is `<worktree>/.opencode` only when VCS is git and `worktree !== "/"`; otherwise `<directory>/.opencode`.
|
- For local scope, path is `<worktree>/.opencode` only when VCS is git and `worktree !== "/"`; otherwise `<directory>/.opencode`.
|
||||||
- Root-worktree fallback (`worktree === "/"` uses `<directory>/.opencode`) is covered by regression tests.
|
- Root-worktree fallback (`worktree === "/"` uses `<directory>/.opencode`) is covered by regression tests.
|
||||||
- `patchPluginConfig` applies all declared manifest targets (`server` and/or `tui`) in one call.
|
- `patchPluginConfig` applies all detected targets (`server` and/or `tui`) in one call.
|
||||||
- `patchPluginConfig` returns structured result unions (`ok`, `code`, fields by error kind) instead of custom thrown errors.
|
- `patchPluginConfig` returns structured result unions (`ok`, `code`, fields by error kind) instead of custom thrown errors.
|
||||||
- `patchPluginConfig` serializes per-target config writes with `Flock.acquire(...)`.
|
- `patchPluginConfig` serializes per-target config writes with `Flock.acquire(...)`.
|
||||||
- `patchPluginConfig` uses targeted `jsonc-parser` edits, so existing JSONC comments are preserved when plugin entries are added or replaced.
|
- `patchPluginConfig` uses targeted `jsonc-parser` edits, so existing JSONC comments are preserved when plugin entries are added or replaced.
|
||||||
- npm plugin package installs are executed with `--ignore-scripts`, so package `install` / `postinstall` lifecycle scripts are not run.
|
- npm plugin package installs are executed with `--ignore-scripts`, so package `install` / `postinstall` lifecycle scripts are not run.
|
||||||
|
- `exports["./server"].config` and `exports["./tui"].config` can provide default plugin options written on first install.
|
||||||
- Without `--force`, an already-configured npm package name is a no-op.
|
- Without `--force`, an already-configured npm package name is a no-op.
|
||||||
- With `--force`, replacement matches by package name. If the existing row is `[spec, options]`, those tuple options are kept.
|
- With `--force`, replacement matches by package name. If the existing row is `[spec, options]`, those tuple options are kept.
|
||||||
- Explicit npm specs with a version suffix (for example `pkg@1.2.3`) are pinned. Runtime install requests that exact version and does not run stale/latest checks for newer registry versions.
|
- Explicit npm specs with a version suffix (for example `pkg@1.2.3`) are pinned. Runtime install requests that exact version and does not run stale/latest checks for newer registry versions.
|
||||||
@@ -320,7 +331,6 @@ Slot notes:
|
|||||||
- `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.
|
||||||
- For packages that declare a tuple `tui` target in `oc-plugin`, `api.plugins.install(...)` stages those tuple options so a following `api.plugins.add(spec)` uses them.
|
|
||||||
- If activation fails, the plugin can remain `enabled=true` and `active=false`.
|
- If activation fails, the plugin can remain `enabled=true` and `active=false`.
|
||||||
- `api.lifecycle.signal` is aborted before cleanup runs.
|
- `api.lifecycle.signal` is aborted before cleanup runs.
|
||||||
- `api.lifecycle.onDispose(fn)` registers cleanup and returns an unregister function.
|
- `api.lifecycle.onDispose(fn)` registers cleanup and returns an unregister function.
|
||||||
|
|||||||
@@ -114,8 +114,8 @@ 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 declare supported targets in package.json`)
|
dep.log.error(`"${mod}" does not expose plugin entrypoints in package.json`)
|
||||||
dep.log.info('Expected: "oc-plugin": ["server", "tui"] or tuples like [["tui", { ... }]].')
|
dep.log.info('Expected one of: exports["./tui"], exports["./server"], or package.json main for server.')
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -87,6 +87,11 @@ function fail(message: string, data: Record<string, unknown>) {
|
|||||||
console.error(`[tui.plugin] ${text}`, next)
|
console.error(`[tui.plugin] ${text}`, next)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function warn(message: string, data: Record<string, unknown>) {
|
||||||
|
log.warn(message, data)
|
||||||
|
console.warn(`[tui.plugin] ${message}`, data)
|
||||||
|
}
|
||||||
|
|
||||||
type CleanupResult = { type: "ok" } | { type: "error"; error: unknown } | { type: "timeout" }
|
type CleanupResult = { type: "ok" } | { type: "error"; error: unknown } | { type: "timeout" }
|
||||||
|
|
||||||
function runCleanup(fn: () => unknown, ms: number): Promise<CleanupResult> {
|
function runCleanup(fn: () => unknown, ms: number): Promise<CleanupResult> {
|
||||||
@@ -229,6 +234,15 @@ async function loadExternalPlugin(cfg: TuiConfig.PluginRecord, retry = false): P
|
|||||||
log.info("loading tui plugin", { path: plan.spec, retry })
|
log.info("loading tui plugin", { path: plan.spec, retry })
|
||||||
const resolved = await PluginLoader.resolve(plan, "tui")
|
const resolved = await PluginLoader.resolve(plan, "tui")
|
||||||
if (!resolved.ok) {
|
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") {
|
if (resolved.stage === "install") {
|
||||||
fail("failed to resolve tui plugin", { path: plan.spec, retry, error: resolved.error })
|
fail("failed to resolve tui plugin", { path: plan.spec, retry, error: resolved.error })
|
||||||
return
|
return
|
||||||
@@ -753,7 +767,6 @@ async function addPluginBySpec(state: RuntimeState | undefined, raw: string) {
|
|||||||
return [] as PluginLoad[]
|
return [] as PluginLoad[]
|
||||||
})
|
})
|
||||||
if (!ready.length) {
|
if (!ready.length) {
|
||||||
fail("failed to add tui plugin", { path: next })
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -824,7 +837,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 declare supported targets in package.json`,
|
message: `"${spec}" does not expose plugin entrypoints in package.json`,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -121,7 +121,10 @@ export namespace Config {
|
|||||||
const gitignore = path.join(dir, ".gitignore")
|
const gitignore = path.join(dir, ".gitignore")
|
||||||
const ignore = await Filesystem.exists(gitignore)
|
const ignore = await Filesystem.exists(gitignore)
|
||||||
if (!ignore) {
|
if (!ignore) {
|
||||||
await Filesystem.write(gitignore, ["node_modules", "package.json", "bun.lock", ".gitignore"].join("\n"))
|
await Filesystem.write(
|
||||||
|
gitignore,
|
||||||
|
["node_modules", "package.json", "package-lock.json", "bun.lock", ".gitignore"].join("\n"),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bun can race cache writes on Windows when installs run in parallel across dirs.
|
// Bun can race cache writes on Windows when installs run in parallel across dirs.
|
||||||
|
|||||||
@@ -157,6 +157,14 @@ export namespace Plugin {
|
|||||||
|
|
||||||
const resolved = await PluginLoader.resolve(plan, "server")
|
const resolved = await PluginLoader.resolve(plan, "server")
|
||||||
if (!resolved.ok) {
|
if (!resolved.ok) {
|
||||||
|
if (resolved.stage === "missing") {
|
||||||
|
log.warn("plugin has no server entrypoint", {
|
||||||
|
path: plan.spec,
|
||||||
|
message: resolved.message,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const cause =
|
const cause =
|
||||||
resolved.error instanceof Error ? (resolved.error.cause ?? resolved.error) : resolved.error
|
resolved.error instanceof Error ? (resolved.error.cause ?? resolved.error) : resolved.error
|
||||||
const message = errorMessage(cause)
|
const message = errorMessage(cause)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { ConfigPaths } from "@/config/paths"
|
|||||||
import { Global } from "@/global"
|
import { Global } from "@/global"
|
||||||
import { Filesystem } from "@/util/filesystem"
|
import { Filesystem } from "@/util/filesystem"
|
||||||
import { Flock } from "@/util/flock"
|
import { Flock } from "@/util/flock"
|
||||||
|
import { isRecord } from "@/util/record"
|
||||||
|
|
||||||
import { parsePluginSpecifier, readPluginPackage, resolvePluginTarget } from "./shared"
|
import { parsePluginSpecifier, readPluginPackage, resolvePluginTarget } from "./shared"
|
||||||
|
|
||||||
@@ -101,28 +102,60 @@ function pluginList(data: unknown) {
|
|||||||
return item.plugin
|
return item.plugin
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseTarget(item: unknown): Target | undefined {
|
function exportValue(value: unknown): string | undefined {
|
||||||
if (item === "server" || item === "tui") return { kind: item }
|
if (typeof value === "string") {
|
||||||
if (!Array.isArray(item)) return
|
const next = value.trim()
|
||||||
if (item[0] !== "server" && item[0] !== "tui") return
|
if (next) return next
|
||||||
if (item.length < 2) return { kind: item[0] }
|
return
|
||||||
const opt = item[1]
|
}
|
||||||
if (!opt || typeof opt !== "object" || Array.isArray(opt)) return { kind: item[0] }
|
if (!isRecord(value)) return
|
||||||
return {
|
for (const key of ["import", "default"]) {
|
||||||
kind: item[0],
|
const next = value[key]
|
||||||
opts: opt,
|
if (typeof next !== "string") continue
|
||||||
|
const hit = next.trim()
|
||||||
|
if (!hit) continue
|
||||||
|
return hit
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseTargets(raw: unknown) {
|
function exportOptions(value: unknown): Record<string, unknown> | undefined {
|
||||||
if (!Array.isArray(raw)) return []
|
if (!isRecord(value)) return
|
||||||
const map = new Map<Kind, Target>()
|
const config = value.config
|
||||||
for (const item of raw) {
|
if (!isRecord(config)) return
|
||||||
const hit = parseTarget(item)
|
return config
|
||||||
if (!hit) continue
|
}
|
||||||
map.set(hit.kind, hit)
|
|
||||||
|
function exportTarget(pkg: Record<string, unknown>, kind: Kind) {
|
||||||
|
const exports = pkg.exports
|
||||||
|
if (!isRecord(exports)) return
|
||||||
|
const value = exports[`./${kind}`]
|
||||||
|
const entry = exportValue(value)
|
||||||
|
if (!entry) return
|
||||||
|
return {
|
||||||
|
opts: exportOptions(value),
|
||||||
}
|
}
|
||||||
return [...map.values()]
|
}
|
||||||
|
|
||||||
|
function hasMainTarget(pkg: Record<string, unknown>) {
|
||||||
|
const main = pkg.main
|
||||||
|
if (typeof main !== "string") return false
|
||||||
|
return Boolean(main.trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
function packageTargets(pkg: Record<string, unknown>) {
|
||||||
|
const targets: Target[] = []
|
||||||
|
const server = exportTarget(pkg, "server")
|
||||||
|
if (server) {
|
||||||
|
targets.push({ kind: "server", opts: server.opts })
|
||||||
|
} else if (hasMainTarget(pkg)) {
|
||||||
|
targets.push({ kind: "server" })
|
||||||
|
}
|
||||||
|
|
||||||
|
const tui = exportTarget(pkg, "tui")
|
||||||
|
if (tui) {
|
||||||
|
targets.push({ kind: "tui", opts: tui.opts })
|
||||||
|
}
|
||||||
|
return targets
|
||||||
}
|
}
|
||||||
|
|
||||||
function patch(text: string, path: Array<string | number>, value: unknown, insert = false) {
|
function patch(text: string, path: Array<string | number>, value: unknown, insert = false) {
|
||||||
@@ -260,7 +293,7 @@ export async function readPluginManifest(target: string): Promise<ManifestResult
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const targets = parseTargets(pkg.item.json["oc-plugin"])
|
const targets = packageTargets(pkg.item.json)
|
||||||
if (!targets.length) {
|
if (!targets.length) {
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
@@ -330,7 +363,7 @@ async function patchOne(dir: string, target: Target, spec: string, force: boolea
|
|||||||
}
|
}
|
||||||
|
|
||||||
const list = pluginList(data)
|
const list = pluginList(data)
|
||||||
const item = target.opts ? [spec, target.opts] : spec
|
const item = target.opts ? ([spec, target.opts] as const) : spec
|
||||||
const out = patchPluginList(text, list, spec, item, force)
|
const out = patchPluginList(text, list, spec, item, force)
|
||||||
if (out.mode === "noop") {
|
if (out.mode === "noop") {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -43,7 +43,9 @@ export namespace PluginLoader {
|
|||||||
plan: Plan,
|
plan: Plan,
|
||||||
kind: PluginKind,
|
kind: PluginKind,
|
||||||
): Promise<
|
): Promise<
|
||||||
{ ok: true; value: Resolved } | { ok: false; stage: "install" | "entry" | "compatibility"; error: unknown }
|
| { ok: true; value: Resolved }
|
||||||
|
| { ok: false; stage: "missing"; message: string }
|
||||||
|
| { ok: false; stage: "install" | "entry" | "compatibility"; error: unknown }
|
||||||
> {
|
> {
|
||||||
let target = ""
|
let target = ""
|
||||||
try {
|
try {
|
||||||
@@ -77,8 +79,8 @@ export namespace PluginLoader {
|
|||||||
if (!base.entry) {
|
if (!base.entry) {
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
stage: "entry",
|
stage: "missing",
|
||||||
error: new Error(`Plugin ${plan.spec} entry is empty`),
|
message: `Plugin ${plan.spec} does not expose a ${kind} entrypoint`,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export type PluginEntry = {
|
|||||||
source: PluginSource
|
source: PluginSource
|
||||||
target: string
|
target: string
|
||||||
pkg?: PluginPackage
|
pkg?: PluginPackage
|
||||||
entry: string
|
entry?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const INDEX_FILES = ["index.ts", "index.tsx", "index.js", "index.mjs", "index.cjs"]
|
const INDEX_FILES = ["index.ts", "index.tsx", "index.js", "index.mjs", "index.cjs"]
|
||||||
@@ -128,13 +128,8 @@ async function resolvePluginEntrypoint(spec: string, target: string, kind: Plugi
|
|||||||
if (index) return pathToFileURL(index).href
|
if (index) return pathToFileURL(index).href
|
||||||
}
|
}
|
||||||
|
|
||||||
if (source === "npm") {
|
if (source === "npm") return
|
||||||
throw new TypeError(`Plugin ${spec} must define package.json exports["./tui"]`)
|
if (dir) return
|
||||||
}
|
|
||||||
|
|
||||||
if (dir) {
|
|
||||||
throw new TypeError(`Plugin ${spec} must define package.json exports["./tui"] or include index file`)
|
|
||||||
}
|
|
||||||
|
|
||||||
return target
|
return target
|
||||||
}
|
}
|
||||||
@@ -145,7 +140,7 @@ async function resolvePluginEntrypoint(spec: string, target: string, kind: Plugi
|
|||||||
if (index) return pathToFileURL(index).href
|
if (index) return pathToFileURL(index).href
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new TypeError(`Plugin ${spec} must define package.json exports["./server"] or package.json main`)
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
return target
|
return target
|
||||||
|
|||||||
@@ -21,8 +21,12 @@ test("installs plugin without loading it", async () => {
|
|||||||
{
|
{
|
||||||
name: "demo-install-plugin",
|
name: "demo-install-plugin",
|
||||||
type: "module",
|
type: "module",
|
||||||
main: "./install-plugin.ts",
|
exports: {
|
||||||
"oc-plugin": [["tui", { marker }]],
|
"./tui": {
|
||||||
|
import: "./install-plugin.ts",
|
||||||
|
config: { marker },
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
null,
|
null,
|
||||||
2,
|
2,
|
||||||
@@ -46,7 +50,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")
|
||||||
let cfg: Awaited<ReturnType<typeof TuiConfig.get>> = {
|
const cfg: Awaited<ReturnType<typeof TuiConfig.get>> = {
|
||||||
plugin: [],
|
plugin: [],
|
||||||
plugin_records: undefined,
|
plugin_records: undefined,
|
||||||
}
|
}
|
||||||
@@ -66,17 +70,6 @@ test("installs plugin without loading it", async () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await TuiPluginRuntime.init(api)
|
await TuiPluginRuntime.init(api)
|
||||||
cfg = {
|
|
||||||
plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]],
|
|
||||||
plugin_records: [
|
|
||||||
{
|
|
||||||
item: [tmp.extra.spec, { marker: tmp.extra.marker }],
|
|
||||||
scope: "local",
|
|
||||||
source: path.join(tmp.path, "tui.json"),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
const out = await TuiPluginRuntime.installPlugin(tmp.extra.spec)
|
const out = await TuiPluginRuntime.installPlugin(tmp.extra.spec)
|
||||||
expect(out).toMatchObject({
|
expect(out).toMatchObject({
|
||||||
ok: true,
|
ok: true,
|
||||||
|
|||||||
@@ -304,17 +304,23 @@ test("does not use npm package main for tui entry", async () => {
|
|||||||
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)
|
||||||
const install = spyOn(BunProc, "install").mockResolvedValue(tmp.extra.mod)
|
const install = spyOn(BunProc, "install").mockResolvedValue(tmp.extra.mod)
|
||||||
|
const warn = spyOn(console, "warn").mockImplementation(() => {})
|
||||||
|
const error = spyOn(console, "error").mockImplementation(() => {})
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await TuiPluginRuntime.init(createTuiPluginApi())
|
await TuiPluginRuntime.init(createTuiPluginApi())
|
||||||
await expect(fs.readFile(tmp.extra.marker, "utf8")).rejects.toThrow()
|
await expect(fs.readFile(tmp.extra.marker, "utf8")).rejects.toThrow()
|
||||||
expect(TuiPluginRuntime.list().some((item) => item.spec === tmp.extra.spec)).toBe(false)
|
expect(TuiPluginRuntime.list().some((item) => item.spec === tmp.extra.spec)).toBe(false)
|
||||||
|
expect(error).not.toHaveBeenCalled()
|
||||||
|
expect(warn.mock.calls.some((call) => String(call[0]).includes("tui plugin has no entrypoint"))).toBe(true)
|
||||||
} finally {
|
} finally {
|
||||||
await TuiPluginRuntime.dispose()
|
await TuiPluginRuntime.dispose()
|
||||||
install.mockRestore()
|
install.mockRestore()
|
||||||
cwd.mockRestore()
|
cwd.mockRestore()
|
||||||
get.mockRestore()
|
get.mockRestore()
|
||||||
wait.mockRestore()
|
wait.mockRestore()
|
||||||
|
warn.mockRestore()
|
||||||
|
error.mockRestore()
|
||||||
delete process.env.OPENCODE_PLUGIN_META_FILE
|
delete process.env.OPENCODE_PLUGIN_META_FILE
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -792,6 +792,7 @@ test("installs dependencies in writable OPENCODE_CONFIG_DIR", async () => {
|
|||||||
|
|
||||||
expect(await Filesystem.exists(path.join(tmp.extra, "package.json"))).toBe(true)
|
expect(await Filesystem.exists(path.join(tmp.extra, "package.json"))).toBe(true)
|
||||||
expect(await Filesystem.exists(path.join(tmp.extra, ".gitignore"))).toBe(true)
|
expect(await Filesystem.exists(path.join(tmp.extra, ".gitignore"))).toBe(true)
|
||||||
|
expect(await Filesystem.readText(path.join(tmp.extra, ".gitignore"))).toContain("package-lock.json")
|
||||||
} finally {
|
} finally {
|
||||||
online.mockRestore()
|
online.mockRestore()
|
||||||
run.mockRestore()
|
run.mockRestore()
|
||||||
|
|||||||
@@ -25,6 +25,11 @@ function run(msg: Msg) {
|
|||||||
|
|
||||||
async function plugin(dir: string, kinds: Array<"server" | "tui">) {
|
async function plugin(dir: string, kinds: Array<"server" | "tui">) {
|
||||||
const p = path.join(dir, "plugin")
|
const p = path.join(dir, "plugin")
|
||||||
|
const server = kinds.includes("server")
|
||||||
|
const tui = kinds.includes("tui")
|
||||||
|
const exports: Record<string, string> = {}
|
||||||
|
if (server) exports["./server"] = "./server.js"
|
||||||
|
if (tui) exports["./tui"] = "./tui.js"
|
||||||
await fs.mkdir(p, { recursive: true })
|
await fs.mkdir(p, { recursive: true })
|
||||||
await Bun.write(
|
await Bun.write(
|
||||||
path.join(p, "package.json"),
|
path.join(p, "package.json"),
|
||||||
@@ -32,7 +37,8 @@ async function plugin(dir: string, kinds: Array<"server" | "tui">) {
|
|||||||
{
|
{
|
||||||
name: "acme",
|
name: "acme",
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
"oc-plugin": kinds,
|
...(server ? { main: "./server.js" } : {}),
|
||||||
|
...(Object.keys(exports).length ? { exports } : {}),
|
||||||
},
|
},
|
||||||
null,
|
null,
|
||||||
2,
|
2,
|
||||||
|
|||||||
@@ -55,8 +55,34 @@ function ctxRoot(dir: string): PlugCtx {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function plugin(dir: string, kinds?: unknown) {
|
async function plugin(
|
||||||
|
dir: string,
|
||||||
|
kinds?: Array<"server" | "tui">,
|
||||||
|
opts?: {
|
||||||
|
server?: Record<string, unknown>
|
||||||
|
tui?: Record<string, unknown>
|
||||||
|
},
|
||||||
|
) {
|
||||||
const p = path.join(dir, "plugin")
|
const p = path.join(dir, "plugin")
|
||||||
|
const server = kinds?.includes("server") ?? false
|
||||||
|
const tui = kinds?.includes("tui") ?? false
|
||||||
|
const exports: Record<string, unknown> = {}
|
||||||
|
if (server) {
|
||||||
|
exports["./server"] = opts?.server
|
||||||
|
? {
|
||||||
|
import: "./server.js",
|
||||||
|
config: opts.server,
|
||||||
|
}
|
||||||
|
: "./server.js"
|
||||||
|
}
|
||||||
|
if (tui) {
|
||||||
|
exports["./tui"] = opts?.tui
|
||||||
|
? {
|
||||||
|
import: "./tui.js",
|
||||||
|
config: opts.tui,
|
||||||
|
}
|
||||||
|
: "./tui.js"
|
||||||
|
}
|
||||||
await fs.mkdir(p, { recursive: true })
|
await fs.mkdir(p, { recursive: true })
|
||||||
await Bun.write(
|
await Bun.write(
|
||||||
path.join(p, "package.json"),
|
path.join(p, "package.json"),
|
||||||
@@ -64,7 +90,8 @@ async function plugin(dir: string, kinds?: unknown) {
|
|||||||
{
|
{
|
||||||
name: "acme",
|
name: "acme",
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
...(kinds === undefined ? {} : { "oc-plugin": kinds }),
|
...(server ? { main: "./server.js" } : {}),
|
||||||
|
...(Object.keys(exports).length ? { exports } : {}),
|
||||||
},
|
},
|
||||||
null,
|
null,
|
||||||
2,
|
2,
|
||||||
@@ -99,12 +126,12 @@ describe("plugin.install.task", () => {
|
|||||||
expect(tui.plugin).toEqual(["acme@1.2.3"])
|
expect(tui.plugin).toEqual(["acme@1.2.3"])
|
||||||
})
|
})
|
||||||
|
|
||||||
test("writes default options from tuple manifest targets", async () => {
|
test("writes default options from exports config metadata", async () => {
|
||||||
await using tmp = await tmpdir()
|
await using tmp = await tmpdir()
|
||||||
const target = await plugin(tmp.path, [
|
const target = await plugin(tmp.path, ["server", "tui"], {
|
||||||
["server", { custom: true, other: false }],
|
server: { custom: true, other: false },
|
||||||
["tui", { compact: true }],
|
tui: { compact: true },
|
||||||
])
|
})
|
||||||
const run = createPlugTask(
|
const run = createPlugTask(
|
||||||
{
|
{
|
||||||
mod: "acme@1.2.3",
|
mod: "acme@1.2.3",
|
||||||
|
|||||||
@@ -487,7 +487,7 @@ describe("plugin.loader.shared", () => {
|
|||||||
.catch(() => false)
|
.catch(() => false)
|
||||||
|
|
||||||
expect(called).toBe(false)
|
expect(called).toBe(false)
|
||||||
expect(errors.some((x) => x.includes('exports["./server"]') && x.includes("package.json main"))).toBe(true)
|
expect(errors).toHaveLength(0)
|
||||||
} finally {
|
} finally {
|
||||||
install.mockRestore()
|
install.mockRestore()
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user