runtime.ts 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985
  1. import "@opentui/solid/runtime-plugin-support"
  2. import {
  3. type TuiDispose,
  4. type TuiPlugin,
  5. type TuiPluginApi,
  6. type TuiPluginInstallResult,
  7. type TuiPluginModule,
  8. type TuiPluginMeta,
  9. type TuiPluginStatus,
  10. type TuiTheme,
  11. } from "@opencode-ai/plugin/tui"
  12. import path from "path"
  13. import { fileURLToPath } from "url"
  14. import { Config } from "@/config/config"
  15. import { TuiConfig } from "@/config/tui"
  16. import { Log } from "@/util/log"
  17. import { errorData, errorMessage } from "@/util/error"
  18. import { isRecord } from "@/util/record"
  19. import { Instance } from "@/project/instance"
  20. import { pluginSource, readPluginId, readV1Plugin, resolvePluginId, type PluginSource } from "@/plugin/shared"
  21. import { PluginLoader } from "@/plugin/loader"
  22. import { PluginMeta } from "@/plugin/meta"
  23. import { installPlugin as installModulePlugin, patchPluginConfig, readPluginManifest } from "@/plugin/install"
  24. import { hasTheme, upsertTheme } from "../context/theme"
  25. import { Global } from "@/global"
  26. import { Filesystem } from "@/util/filesystem"
  27. import { Process } from "@/util/process"
  28. import { Flag } from "@/flag/flag"
  29. import { INTERNAL_TUI_PLUGINS, type InternalTuiPlugin } from "./internal"
  30. import { setupSlots, Slot as View } from "./slots"
  31. import type { HostPluginApi, HostSlots } from "./slots"
  32. type PluginLoad = {
  33. options: Config.PluginOptions | undefined
  34. spec: string
  35. target: string
  36. retry: boolean
  37. source: PluginSource | "internal"
  38. id: string
  39. module: TuiPluginModule
  40. theme_meta: TuiConfig.PluginMeta
  41. theme_root: string
  42. }
  43. type Api = HostPluginApi
  44. type PluginScope = {
  45. lifecycle: TuiPluginApi["lifecycle"]
  46. track: (fn: (() => void) | undefined) => () => void
  47. dispose: () => Promise<void>
  48. }
  49. type PluginEntry = {
  50. id: string
  51. load: PluginLoad
  52. meta: TuiPluginMeta
  53. themes: Record<string, PluginMeta.Theme>
  54. plugin: TuiPlugin
  55. enabled: boolean
  56. scope?: PluginScope
  57. }
  58. type RuntimeState = {
  59. directory: string
  60. api: Api
  61. slots: HostSlots
  62. plugins: PluginEntry[]
  63. plugins_by_id: Map<string, PluginEntry>
  64. pending: Map<string, TuiConfig.PluginRecord>
  65. }
  66. const log = Log.create({ service: "tui.plugin" })
  67. const DISPOSE_TIMEOUT_MS = 5000
  68. const KV_KEY = "plugin_enabled"
  69. function fail(message: string, data: Record<string, unknown>) {
  70. if (!("error" in data)) {
  71. log.error(message, data)
  72. console.error(`[tui.plugin] ${message}`, data)
  73. return
  74. }
  75. const text = `${message}: ${errorMessage(data.error)}`
  76. const next = { ...data, error: errorData(data.error) }
  77. log.error(text, next)
  78. console.error(`[tui.plugin] ${text}`, next)
  79. }
  80. type CleanupResult = { type: "ok" } | { type: "error"; error: unknown } | { type: "timeout" }
  81. function runCleanup(fn: () => unknown, ms: number): Promise<CleanupResult> {
  82. return new Promise((resolve) => {
  83. const timer = setTimeout(() => {
  84. resolve({ type: "timeout" })
  85. }, ms)
  86. Promise.resolve()
  87. .then(fn)
  88. .then(
  89. () => {
  90. resolve({ type: "ok" })
  91. },
  92. (error) => {
  93. resolve({ type: "error", error })
  94. },
  95. )
  96. .finally(() => {
  97. clearTimeout(timer)
  98. })
  99. })
  100. }
  101. function isTheme(value: unknown) {
  102. if (!isRecord(value)) return false
  103. if (!("theme" in value)) return false
  104. if (!isRecord(value.theme)) return false
  105. return true
  106. }
  107. function resolveRoot(root: string) {
  108. if (root.startsWith("file://")) {
  109. const file = fileURLToPath(root)
  110. if (root.endsWith("/")) return file
  111. return path.dirname(file)
  112. }
  113. if (path.isAbsolute(root)) return root
  114. return path.resolve(process.cwd(), root)
  115. }
  116. function createThemeInstaller(
  117. meta: TuiConfig.PluginMeta,
  118. root: string,
  119. spec: string,
  120. plugin: PluginEntry,
  121. ): TuiTheme["install"] {
  122. return async (file) => {
  123. const raw = file.startsWith("file://") ? fileURLToPath(file) : file
  124. const src = path.isAbsolute(raw) ? raw : path.resolve(root, raw)
  125. const name = path.basename(src, path.extname(src))
  126. const source_dir = path.dirname(meta.source)
  127. const local_dir =
  128. path.basename(source_dir) === ".opencode"
  129. ? path.join(source_dir, "themes")
  130. : path.join(source_dir, ".opencode", "themes")
  131. const dest_dir = meta.scope === "local" ? local_dir : path.join(Global.Path.config, "themes")
  132. const dest = path.join(dest_dir, `${name}.json`)
  133. const stat = await Filesystem.statAsync(src)
  134. const mtime = stat ? Math.floor(typeof stat.mtimeMs === "bigint" ? Number(stat.mtimeMs) : stat.mtimeMs) : undefined
  135. const size = stat ? (typeof stat.size === "bigint" ? Number(stat.size) : stat.size) : undefined
  136. const exists = hasTheme(name)
  137. const prev = plugin.themes[name]
  138. if (exists) {
  139. if (plugin.meta.state !== "updated") return
  140. if (!prev) {
  141. if (await Filesystem.exists(dest)) {
  142. plugin.themes[name] = {
  143. src,
  144. dest,
  145. mtime,
  146. size,
  147. }
  148. await PluginMeta.setTheme(plugin.id, name, plugin.themes[name]!).catch((error) => {
  149. log.warn("failed to track tui plugin theme", {
  150. path: spec,
  151. id: plugin.id,
  152. theme: src,
  153. dest,
  154. error,
  155. })
  156. })
  157. }
  158. return
  159. }
  160. if (prev.dest !== dest) return
  161. if (prev.mtime === mtime && prev.size === size) return
  162. }
  163. const text = await Filesystem.readText(src).catch((error) => {
  164. log.warn("failed to read tui plugin theme", { path: spec, theme: src, error })
  165. return
  166. })
  167. if (text === undefined) return
  168. const fail = Symbol()
  169. const data = await Promise.resolve(text)
  170. .then((x) => JSON.parse(x))
  171. .catch((error) => {
  172. log.warn("failed to parse tui plugin theme", { path: spec, theme: src, error })
  173. return fail
  174. })
  175. if (data === fail) return
  176. if (!isTheme(data)) {
  177. log.warn("invalid tui plugin theme", { path: spec, theme: src })
  178. return
  179. }
  180. if (exists || !(await Filesystem.exists(dest))) {
  181. await Filesystem.write(dest, text).catch((error) => {
  182. log.warn("failed to persist tui plugin theme", { path: spec, theme: src, dest, error })
  183. })
  184. }
  185. upsertTheme(name, data)
  186. plugin.themes[name] = {
  187. src,
  188. dest,
  189. mtime,
  190. size,
  191. }
  192. await PluginMeta.setTheme(plugin.id, name, plugin.themes[name]!).catch((error) => {
  193. log.warn("failed to track tui plugin theme", {
  194. path: spec,
  195. id: plugin.id,
  196. theme: src,
  197. dest,
  198. error,
  199. })
  200. })
  201. }
  202. }
  203. async function loadExternalPlugin(cfg: TuiConfig.PluginRecord, retry = false): Promise<PluginLoad | undefined> {
  204. const plan = PluginLoader.plan(cfg.item)
  205. if (plan.deprecated) return
  206. log.info("loading tui plugin", { path: plan.spec, retry })
  207. const resolved = await PluginLoader.resolve(plan, "tui")
  208. if (!resolved.ok) {
  209. if (resolved.stage === "install") {
  210. fail("failed to resolve tui plugin", { path: plan.spec, retry, error: resolved.error })
  211. return
  212. }
  213. if (resolved.stage === "compatibility") {
  214. fail("tui plugin incompatible", { path: plan.spec, retry, error: resolved.error })
  215. return
  216. }
  217. fail("failed to resolve tui plugin entry", { path: plan.spec, retry, error: resolved.error })
  218. return
  219. }
  220. const loaded = await PluginLoader.load(resolved.value)
  221. if (!loaded.ok) {
  222. fail("failed to load tui plugin", {
  223. path: plan.spec,
  224. target: resolved.value.entry,
  225. retry,
  226. error: loaded.error,
  227. })
  228. return
  229. }
  230. const mod = await Promise.resolve()
  231. .then(() => {
  232. return readV1Plugin(loaded.value.mod as Record<string, unknown>, plan.spec, "tui") as TuiPluginModule
  233. })
  234. .catch((error) => {
  235. fail("failed to load tui plugin", {
  236. path: plan.spec,
  237. target: loaded.value.entry,
  238. retry,
  239. error,
  240. })
  241. return
  242. })
  243. if (!mod) return
  244. const id = await resolvePluginId(
  245. loaded.value.source,
  246. plan.spec,
  247. loaded.value.target,
  248. readPluginId(mod.id, plan.spec),
  249. loaded.value.pkg,
  250. ).catch((error) => {
  251. fail("failed to load tui plugin", { path: plan.spec, target: loaded.value.target, retry, error })
  252. return
  253. })
  254. if (!id) return
  255. return {
  256. options: plan.options,
  257. spec: plan.spec,
  258. target: loaded.value.target,
  259. retry,
  260. source: loaded.value.source,
  261. id,
  262. module: mod,
  263. theme_meta: {
  264. scope: cfg.scope,
  265. source: cfg.source,
  266. },
  267. theme_root: loaded.value.pkg?.dir ?? resolveRoot(loaded.value.target),
  268. }
  269. }
  270. function createMeta(
  271. source: PluginLoad["source"],
  272. spec: string,
  273. target: string,
  274. meta: { state: PluginMeta.State; entry: PluginMeta.Entry } | undefined,
  275. id?: string,
  276. ): TuiPluginMeta {
  277. if (meta) {
  278. return {
  279. state: meta.state,
  280. ...meta.entry,
  281. }
  282. }
  283. const now = Date.now()
  284. return {
  285. state: source === "internal" ? "same" : "first",
  286. id: id ?? spec,
  287. source,
  288. spec,
  289. target,
  290. first_time: now,
  291. last_time: now,
  292. time_changed: now,
  293. load_count: 1,
  294. fingerprint: target,
  295. }
  296. }
  297. function loadInternalPlugin(item: InternalTuiPlugin): PluginLoad {
  298. const spec = item.id
  299. const target = spec
  300. return {
  301. options: undefined,
  302. spec,
  303. target,
  304. retry: false,
  305. source: "internal",
  306. id: item.id,
  307. module: item,
  308. theme_meta: {
  309. scope: "global",
  310. source: target,
  311. },
  312. theme_root: process.cwd(),
  313. }
  314. }
  315. function createPluginScope(load: PluginLoad, id: string) {
  316. const ctrl = new AbortController()
  317. let list: { key: symbol; fn: TuiDispose }[] = []
  318. let done = false
  319. const onDispose = (fn: TuiDispose) => {
  320. if (done) return () => {}
  321. const key = Symbol()
  322. list.push({ key, fn })
  323. let drop = false
  324. return () => {
  325. if (drop) return
  326. drop = true
  327. list = list.filter((x) => x.key !== key)
  328. }
  329. }
  330. const track = (fn: (() => void) | undefined) => {
  331. if (!fn) return () => {}
  332. const off = onDispose(fn)
  333. let drop = false
  334. return () => {
  335. if (drop) return
  336. drop = true
  337. off()
  338. fn()
  339. }
  340. }
  341. const lifecycle: TuiPluginApi["lifecycle"] = {
  342. signal: ctrl.signal,
  343. onDispose,
  344. }
  345. const dispose = async () => {
  346. if (done) return
  347. done = true
  348. ctrl.abort()
  349. const queue = [...list].reverse()
  350. list = []
  351. const until = Date.now() + DISPOSE_TIMEOUT_MS
  352. for (const item of queue) {
  353. const left = until - Date.now()
  354. if (left <= 0) {
  355. fail("timed out cleaning up tui plugin", {
  356. path: load.spec,
  357. id,
  358. timeout: DISPOSE_TIMEOUT_MS,
  359. })
  360. break
  361. }
  362. const out = await runCleanup(item.fn, left)
  363. if (out.type === "ok") continue
  364. if (out.type === "timeout") {
  365. fail("timed out cleaning up tui plugin", {
  366. path: load.spec,
  367. id,
  368. timeout: DISPOSE_TIMEOUT_MS,
  369. })
  370. break
  371. }
  372. if (out.type === "error") {
  373. fail("failed to clean up tui plugin", {
  374. path: load.spec,
  375. id,
  376. error: out.error,
  377. })
  378. }
  379. }
  380. }
  381. return {
  382. lifecycle,
  383. track,
  384. dispose,
  385. }
  386. }
  387. function readPluginEnabledMap(value: unknown) {
  388. if (!isRecord(value)) return {}
  389. return Object.fromEntries(
  390. Object.entries(value).filter((item): item is [string, boolean] => typeof item[1] === "boolean"),
  391. )
  392. }
  393. function pluginEnabledState(state: RuntimeState, config: TuiConfig.Info) {
  394. return {
  395. ...readPluginEnabledMap(config.plugin_enabled),
  396. ...readPluginEnabledMap(state.api.kv.get(KV_KEY, {})),
  397. }
  398. }
  399. function writePluginEnabledState(api: Api, id: string, enabled: boolean) {
  400. api.kv.set(KV_KEY, {
  401. ...readPluginEnabledMap(api.kv.get(KV_KEY, {})),
  402. [id]: enabled,
  403. })
  404. }
  405. function listPluginStatus(state: RuntimeState): TuiPluginStatus[] {
  406. return state.plugins.map((plugin) => ({
  407. id: plugin.id,
  408. source: plugin.meta.source,
  409. spec: plugin.meta.spec,
  410. target: plugin.meta.target,
  411. enabled: plugin.enabled,
  412. active: plugin.scope !== undefined,
  413. }))
  414. }
  415. async function deactivatePluginEntry(state: RuntimeState, plugin: PluginEntry, persist: boolean) {
  416. plugin.enabled = false
  417. if (persist) writePluginEnabledState(state.api, plugin.id, false)
  418. if (!plugin.scope) return true
  419. const scope = plugin.scope
  420. plugin.scope = undefined
  421. await scope.dispose()
  422. return true
  423. }
  424. async function activatePluginEntry(state: RuntimeState, plugin: PluginEntry, persist: boolean) {
  425. plugin.enabled = true
  426. if (persist) writePluginEnabledState(state.api, plugin.id, true)
  427. if (plugin.scope) return true
  428. const scope = createPluginScope(plugin.load, plugin.id)
  429. const api = pluginApi(state, plugin, scope, plugin.id)
  430. const ok = await Promise.resolve()
  431. .then(async () => {
  432. await plugin.plugin(api, plugin.load.options, plugin.meta)
  433. return true
  434. })
  435. .catch((error) => {
  436. fail("failed to initialize tui plugin", {
  437. path: plugin.load.spec,
  438. id: plugin.id,
  439. error,
  440. })
  441. return false
  442. })
  443. if (!ok) {
  444. await scope.dispose()
  445. return false
  446. }
  447. if (!plugin.enabled) {
  448. await scope.dispose()
  449. return true
  450. }
  451. plugin.scope = scope
  452. return true
  453. }
  454. async function activatePluginById(state: RuntimeState | undefined, id: string, persist: boolean) {
  455. if (!state) return false
  456. const plugin = state.plugins_by_id.get(id)
  457. if (!plugin) return false
  458. return activatePluginEntry(state, plugin, persist)
  459. }
  460. async function deactivatePluginById(state: RuntimeState | undefined, id: string, persist: boolean) {
  461. if (!state) return false
  462. const plugin = state.plugins_by_id.get(id)
  463. if (!plugin) return false
  464. return deactivatePluginEntry(state, plugin, persist)
  465. }
  466. function pluginApi(runtime: RuntimeState, plugin: PluginEntry, scope: PluginScope, base: string): TuiPluginApi {
  467. const api = runtime.api
  468. const host = runtime.slots
  469. const load = plugin.load
  470. const command: TuiPluginApi["command"] = {
  471. register(cb) {
  472. return scope.track(api.command.register(cb))
  473. },
  474. trigger(value) {
  475. api.command.trigger(value)
  476. },
  477. }
  478. const route: TuiPluginApi["route"] = {
  479. register(list) {
  480. return scope.track(api.route.register(list))
  481. },
  482. navigate(name, params) {
  483. api.route.navigate(name, params)
  484. },
  485. get current() {
  486. return api.route.current
  487. },
  488. }
  489. const theme: TuiPluginApi["theme"] = Object.assign(Object.create(api.theme), {
  490. install: createThemeInstaller(load.theme_meta, load.theme_root, load.spec, plugin),
  491. })
  492. const event: TuiPluginApi["event"] = {
  493. on(type, handler) {
  494. return scope.track(api.event.on(type, handler))
  495. },
  496. }
  497. let count = 0
  498. const slots: TuiPluginApi["slots"] = {
  499. register(plugin) {
  500. const id = count ? `${base}:${count}` : base
  501. count += 1
  502. scope.track(host.register({ ...plugin, id }))
  503. return id
  504. },
  505. }
  506. return {
  507. app: api.app,
  508. command,
  509. route,
  510. ui: api.ui,
  511. keybind: api.keybind,
  512. tuiConfig: api.tuiConfig,
  513. kv: api.kv,
  514. state: api.state,
  515. theme,
  516. get client() {
  517. return api.client
  518. },
  519. scopedClient: api.scopedClient,
  520. workspace: api.workspace,
  521. event,
  522. renderer: api.renderer,
  523. slots,
  524. plugins: {
  525. list() {
  526. return listPluginStatus(runtime)
  527. },
  528. activate(id) {
  529. return activatePluginById(runtime, id, true)
  530. },
  531. deactivate(id) {
  532. return deactivatePluginById(runtime, id, true)
  533. },
  534. add(spec) {
  535. return addPluginBySpec(runtime, spec)
  536. },
  537. install(spec, options) {
  538. return installPluginBySpec(runtime, spec, options?.global)
  539. },
  540. },
  541. lifecycle: scope.lifecycle,
  542. }
  543. }
  544. function addPluginEntry(state: RuntimeState, plugin: PluginEntry) {
  545. if (state.plugins_by_id.has(plugin.id)) {
  546. fail("duplicate tui plugin id", {
  547. id: plugin.id,
  548. path: plugin.load.spec,
  549. })
  550. return false
  551. }
  552. state.plugins_by_id.set(plugin.id, plugin)
  553. state.plugins.push(plugin)
  554. return true
  555. }
  556. function applyInitialPluginEnabledState(state: RuntimeState, config: TuiConfig.Info) {
  557. const map = pluginEnabledState(state, config)
  558. for (const plugin of state.plugins) {
  559. const enabled = map[plugin.id]
  560. if (enabled === undefined) continue
  561. plugin.enabled = enabled
  562. }
  563. }
  564. async function resolveExternalPlugins(list: TuiConfig.PluginRecord[], wait: () => Promise<void>) {
  565. const loaded = await Promise.all(list.map((item) => loadExternalPlugin(item)))
  566. const ready: PluginLoad[] = []
  567. let deps: Promise<void> | undefined
  568. for (let i = 0; i < list.length; i++) {
  569. let entry = loaded[i]
  570. if (!entry) {
  571. const item = list[i]
  572. if (!item) continue
  573. if (pluginSource(Config.pluginSpecifier(item.item)) !== "file") continue
  574. deps ??= wait().catch((error) => {
  575. log.warn("failed waiting for tui plugin dependencies", { error })
  576. })
  577. await deps
  578. entry = await loadExternalPlugin(item, true)
  579. }
  580. if (!entry) continue
  581. ready.push(entry)
  582. }
  583. return ready
  584. }
  585. async function addExternalPluginEntries(state: RuntimeState, ready: PluginLoad[]) {
  586. if (!ready.length) return { plugins: [] as PluginEntry[], ok: true }
  587. const meta = await PluginMeta.touchMany(
  588. ready.map((item) => ({
  589. spec: item.spec,
  590. target: item.target,
  591. id: item.id,
  592. })),
  593. ).catch((error) => {
  594. log.warn("failed to track tui plugins", { error })
  595. return undefined
  596. })
  597. const plugins: PluginEntry[] = []
  598. let ok = true
  599. for (let i = 0; i < ready.length; i++) {
  600. const entry = ready[i]
  601. if (!entry) continue
  602. const hit = meta?.[i]
  603. if (hit && hit.state !== "same") {
  604. log.info("tui plugin metadata updated", {
  605. path: entry.spec,
  606. retry: entry.retry,
  607. state: hit.state,
  608. source: hit.entry.source,
  609. version: hit.entry.version,
  610. modified: hit.entry.modified,
  611. })
  612. }
  613. const row = createMeta(entry.source, entry.spec, entry.target, hit, entry.id)
  614. const themes = hit?.entry.themes ? { ...hit.entry.themes } : {}
  615. const plugin: PluginEntry = {
  616. id: entry.id,
  617. load: entry,
  618. meta: row,
  619. themes,
  620. plugin: entry.module.tui,
  621. enabled: true,
  622. }
  623. if (!addPluginEntry(state, plugin)) {
  624. ok = false
  625. continue
  626. }
  627. plugins.push(plugin)
  628. }
  629. return { plugins, ok }
  630. }
  631. function defaultPluginRecord(state: RuntimeState, spec: string): TuiConfig.PluginRecord {
  632. return {
  633. item: spec,
  634. scope: "local",
  635. source: state.api.state.path.config || path.join(state.directory, ".opencode", "tui.json"),
  636. }
  637. }
  638. function installCause(err: unknown) {
  639. if (!err || typeof err !== "object") return
  640. if (!("cause" in err)) return
  641. return (err as { cause?: unknown }).cause
  642. }
  643. function installDetail(err: unknown) {
  644. const hit = installCause(err) ?? err
  645. if (!(hit instanceof Process.RunFailedError)) {
  646. return {
  647. message: errorMessage(hit),
  648. missing: false,
  649. }
  650. }
  651. const lines = hit.stderr
  652. .toString()
  653. .split(/\r?\n/)
  654. .map((line) => line.trim())
  655. .filter(Boolean)
  656. const errs = lines.filter((line) => line.startsWith("error:")).map((line) => line.replace(/^error:\s*/, ""))
  657. return {
  658. message: errs[0] ?? lines.at(-1) ?? errorMessage(hit),
  659. missing: lines.some((line) => line.includes("No version matching")),
  660. }
  661. }
  662. async function addPluginBySpec(state: RuntimeState | undefined, raw: string) {
  663. if (!state) return false
  664. const spec = raw.trim()
  665. if (!spec) return false
  666. const cfg = state.pending.get(spec) ?? defaultPluginRecord(state, spec)
  667. const next = Config.pluginSpecifier(cfg.item)
  668. if (state.plugins.some((plugin) => plugin.load.spec === next)) {
  669. state.pending.delete(spec)
  670. return true
  671. }
  672. const ready = await Instance.provide({
  673. directory: state.directory,
  674. fn: () => resolveExternalPlugins([cfg], () => TuiConfig.waitForDependencies()),
  675. }).catch((error) => {
  676. fail("failed to add tui plugin", { path: next, error })
  677. return [] as PluginLoad[]
  678. })
  679. if (!ready.length) {
  680. fail("failed to add tui plugin", { path: next })
  681. return false
  682. }
  683. const first = ready[0]
  684. if (!first) {
  685. fail("failed to add tui plugin", { path: next })
  686. return false
  687. }
  688. if (state.plugins_by_id.has(first.id)) {
  689. state.pending.delete(spec)
  690. return true
  691. }
  692. const out = await addExternalPluginEntries(state, [first])
  693. let ok = out.ok && out.plugins.length > 0
  694. for (const plugin of out.plugins) {
  695. const active = await activatePluginEntry(state, plugin, false)
  696. if (!active) ok = false
  697. }
  698. if (ok) state.pending.delete(spec)
  699. if (!ok) {
  700. fail("failed to add tui plugin", { path: next })
  701. }
  702. return ok
  703. }
  704. async function installPluginBySpec(
  705. state: RuntimeState | undefined,
  706. raw: string,
  707. global = false,
  708. ): Promise<TuiPluginInstallResult> {
  709. if (!state) {
  710. return {
  711. ok: false,
  712. message: "Plugin runtime is not ready.",
  713. }
  714. }
  715. const spec = raw.trim()
  716. if (!spec) {
  717. return {
  718. ok: false,
  719. message: "Plugin package name is required",
  720. }
  721. }
  722. const dir = state.api.state.path
  723. if (!dir.directory) {
  724. return {
  725. ok: false,
  726. message: "Paths are still syncing. Try again in a moment.",
  727. }
  728. }
  729. const install = await installModulePlugin(spec)
  730. if (!install.ok) {
  731. const out = installDetail(install.error)
  732. return {
  733. ok: false,
  734. message: out.message,
  735. missing: out.missing,
  736. }
  737. }
  738. const manifest = await readPluginManifest(install.target)
  739. if (!manifest.ok) {
  740. if (manifest.code === "manifest_no_targets") {
  741. return {
  742. ok: false,
  743. message: `"${spec}" does not declare supported targets in package.json`,
  744. }
  745. }
  746. return {
  747. ok: false,
  748. message: `Installed "${spec}" but failed to read ${manifest.file}`,
  749. }
  750. }
  751. const patch = await patchPluginConfig({
  752. spec,
  753. targets: manifest.targets,
  754. global,
  755. vcs: dir.worktree && dir.worktree !== "/" ? "git" : undefined,
  756. worktree: dir.worktree,
  757. directory: dir.directory,
  758. })
  759. if (!patch.ok) {
  760. if (patch.code === "invalid_json") {
  761. return {
  762. ok: false,
  763. message: `Invalid JSON in ${patch.file} (${patch.parse} at line ${patch.line}, column ${patch.col})`,
  764. }
  765. }
  766. return {
  767. ok: false,
  768. message: errorMessage(patch.error),
  769. }
  770. }
  771. const tui = manifest.targets.find((item) => item.kind === "tui")
  772. if (tui) {
  773. const file = patch.items.find((item) => item.kind === "tui")?.file
  774. const item = tui.opts ? ([spec, tui.opts] as Config.PluginSpec) : spec
  775. state.pending.set(spec, {
  776. item,
  777. scope: global ? "global" : "local",
  778. source: (file ?? dir.config) || path.join(patch.dir, "tui.json"),
  779. })
  780. }
  781. return {
  782. ok: true,
  783. dir: patch.dir,
  784. tui: Boolean(tui),
  785. }
  786. }
  787. export namespace TuiPluginRuntime {
  788. let dir = ""
  789. let loaded: Promise<void> | undefined
  790. let runtime: RuntimeState | undefined
  791. export const Slot = View
  792. export async function init(api: HostPluginApi) {
  793. const cwd = process.cwd()
  794. if (loaded) {
  795. if (dir !== cwd) {
  796. throw new Error(`TuiPluginRuntime.init() called with a different working directory. expected=${dir} got=${cwd}`)
  797. }
  798. return loaded
  799. }
  800. dir = cwd
  801. loaded = load(api)
  802. return loaded
  803. }
  804. export function list() {
  805. if (!runtime) return []
  806. return listPluginStatus(runtime)
  807. }
  808. export async function activatePlugin(id: string) {
  809. return activatePluginById(runtime, id, true)
  810. }
  811. export async function deactivatePlugin(id: string) {
  812. return deactivatePluginById(runtime, id, true)
  813. }
  814. export async function addPlugin(spec: string) {
  815. return addPluginBySpec(runtime, spec)
  816. }
  817. export async function installPlugin(spec: string, options?: { global?: boolean }) {
  818. return installPluginBySpec(runtime, spec, options?.global)
  819. }
  820. export async function dispose() {
  821. const task = loaded
  822. loaded = undefined
  823. dir = ""
  824. if (task) await task
  825. const state = runtime
  826. runtime = undefined
  827. if (!state) return
  828. const queue = [...state.plugins].reverse()
  829. for (const plugin of queue) {
  830. await deactivatePluginEntry(state, plugin, false)
  831. }
  832. }
  833. async function load(api: Api) {
  834. const cwd = process.cwd()
  835. const slots = setupSlots(api)
  836. const next: RuntimeState = {
  837. directory: cwd,
  838. api,
  839. slots,
  840. plugins: [],
  841. plugins_by_id: new Map(),
  842. pending: new Map(),
  843. }
  844. runtime = next
  845. await Instance.provide({
  846. directory: cwd,
  847. fn: async () => {
  848. const config = await TuiConfig.get()
  849. const records = Flag.OPENCODE_PURE ? [] : (config.plugin_records ?? [])
  850. if (Flag.OPENCODE_PURE && config.plugin_records?.length) {
  851. log.info("skipping external tui plugins in pure mode", { count: config.plugin_records.length })
  852. }
  853. for (const item of INTERNAL_TUI_PLUGINS) {
  854. log.info("loading internal tui plugin", { id: item.id })
  855. const entry = loadInternalPlugin(item)
  856. const meta = createMeta(entry.source, entry.spec, entry.target, undefined, entry.id)
  857. addPluginEntry(next, {
  858. id: entry.id,
  859. load: entry,
  860. meta,
  861. themes: {},
  862. plugin: entry.module.tui,
  863. enabled: true,
  864. })
  865. }
  866. const ready = await resolveExternalPlugins(records, () => TuiConfig.waitForDependencies())
  867. await addExternalPluginEntries(next, ready)
  868. applyInitialPluginEnabledState(next, config)
  869. for (const plugin of next.plugins) {
  870. if (!plugin.enabled) continue
  871. // Keep plugin execution sequential for deterministic side effects:
  872. // command registration order affects keybind/command precedence,
  873. // route registration is last-wins when ids collide,
  874. // and hook chains rely on stable plugin ordering.
  875. await activatePluginEntry(next, plugin, false)
  876. }
  877. },
  878. }).catch((error) => {
  879. fail("failed to load tui plugins", { directory: cwd, error })
  880. })
  881. }
  882. }