mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-08 18:30:00 -04:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5a026d74ce | |||
| 4ca6961f7f | |||
| 78aae7dadd |
@@ -0,0 +1,193 @@
|
|||||||
|
# Plugin architecture
|
||||||
|
|
||||||
|
This is a working note for reorganizing plugin code while the codebase migrates to Effect.
|
||||||
|
|
||||||
|
## Current shape
|
||||||
|
|
||||||
|
The main problem is that one conceptual system is split across a few large modules with overlapping responsibilities.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
A[ConfigPlugin origins] --> B[plugin/shared.ts]
|
||||||
|
A --> C[plugin/loader.ts]
|
||||||
|
A --> D[plugin/install.ts]
|
||||||
|
|
||||||
|
B --> C
|
||||||
|
B --> E[plugin/index.ts server runtime]
|
||||||
|
B --> F[tui/plugin/runtime.ts TUI runtime]
|
||||||
|
B --> D
|
||||||
|
|
||||||
|
C --> E
|
||||||
|
C --> F
|
||||||
|
|
||||||
|
D --> G[cli/cmd/plug.ts]
|
||||||
|
D --> F
|
||||||
|
|
||||||
|
H[plugin/meta.ts] --> F
|
||||||
|
|
||||||
|
I[npm/index.ts] --> B
|
||||||
|
I --> C
|
||||||
|
I --> D
|
||||||
|
|
||||||
|
style B fill:#3a2f1f,stroke:#c98a2b,color:#fff
|
||||||
|
style C fill:#3a2f1f,stroke:#c98a2b,color:#fff
|
||||||
|
style D fill:#3a2f1f,stroke:#c98a2b,color:#fff
|
||||||
|
style E fill:#4a1f1f,stroke:#d46a6a,color:#fff
|
||||||
|
style F fill:#4a1f1f,stroke:#d46a6a,color:#fff
|
||||||
|
```
|
||||||
|
|
||||||
|
### What is mixed together today
|
||||||
|
|
||||||
|
- `src/plugin/shared.ts`
|
||||||
|
spec parsing, package reading, entry resolution, compatibility checks, theme discovery, module validation, id resolution
|
||||||
|
- `src/plugin/loader.ts`
|
||||||
|
plan building, target resolution, import, retry rules, reporting hooks
|
||||||
|
- `src/plugin/install.ts`
|
||||||
|
install wrapper, manifest inspection, config patching, file locking
|
||||||
|
- `src/plugin/index.ts`
|
||||||
|
server plugin runtime, hook loading, config fanout, event subscription
|
||||||
|
- `src/cli/cmd/tui/plugin/runtime.ts`
|
||||||
|
TUI runtime state, loading, activation, API adaptation, theme sync, install flow, pending state, process singleton
|
||||||
|
- `src/plugin/meta.ts`
|
||||||
|
file-backed mutable plugin metadata store
|
||||||
|
|
||||||
|
## Target shape
|
||||||
|
|
||||||
|
The redesign should split stateless plugin plumbing from stateful runtimes.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
subgraph Pure[Pure helpers]
|
||||||
|
S1[plugin/spec.ts]
|
||||||
|
S2[plugin/module.ts]
|
||||||
|
S3[plugin/manifest.ts]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Effects[Effect functions]
|
||||||
|
E1[plugin/package.ts]
|
||||||
|
E2[plugin/external.ts]
|
||||||
|
E3[plugin/install.ts]
|
||||||
|
T2[tui/plugin/theme.ts]
|
||||||
|
T3[tui/plugin/api.ts]
|
||||||
|
T4[tui/plugin/scope.ts]
|
||||||
|
T5[tui/plugin/activation.ts]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Services[Effect services]
|
||||||
|
SV1[plugin/meta-store.ts PluginMetaStore.Service]
|
||||||
|
SV2[plugin/server.ts PluginServer.Service]
|
||||||
|
SV3[tui/plugin/manager.ts TuiPluginManager.Service]
|
||||||
|
end
|
||||||
|
|
||||||
|
Cfg[ConfigPlugin Origins] --> S1
|
||||||
|
S1 --> E1
|
||||||
|
S1 --> E2
|
||||||
|
S2 --> E2
|
||||||
|
S3 --> E3
|
||||||
|
E1 --> E2
|
||||||
|
E1 --> E3
|
||||||
|
E2 --> SV2
|
||||||
|
E2 --> SV3
|
||||||
|
T2 --> SV3
|
||||||
|
T3 --> SV3
|
||||||
|
T4 --> SV3
|
||||||
|
T5 --> SV3
|
||||||
|
SV1 --> SV3
|
||||||
|
E3 --> CLI[cli/cmd/plug.ts]
|
||||||
|
E3 --> SV3
|
||||||
|
|
||||||
|
style Pure fill:#1f3a2a,stroke:#4fa06b,color:#fff
|
||||||
|
style Effects fill:#1f2f4a,stroke:#5c8fda,color:#fff
|
||||||
|
style Services fill:#3b1f4a,stroke:#b070d6,color:#fff
|
||||||
|
```
|
||||||
|
|
||||||
|
## Module boundaries
|
||||||
|
|
||||||
|
### Pure helpers
|
||||||
|
|
||||||
|
- `src/plugin/spec.ts`
|
||||||
|
parse specifiers, detect npm vs file, normalize ids
|
||||||
|
- `src/plugin/module.ts`
|
||||||
|
validate exported module shape, extract `id`, read v1 server or TUI modules
|
||||||
|
- `src/plugin/manifest.ts`
|
||||||
|
derive package capabilities from package metadata
|
||||||
|
|
||||||
|
These should not touch the filesystem or global state.
|
||||||
|
|
||||||
|
### Effect functions
|
||||||
|
|
||||||
|
- `src/plugin/package.ts`
|
||||||
|
read `package.json`, check compatibility, read theme files
|
||||||
|
- `src/plugin/external.ts`
|
||||||
|
resolve targets, resolve entrypoints, import modules, retry local file plugins after dependency prep
|
||||||
|
- `src/plugin/install.ts`
|
||||||
|
shared install and config-patch workflow used by CLI and TUI
|
||||||
|
- `src/cli/cmd/tui/plugin/theme.ts`
|
||||||
|
sync and persist themes
|
||||||
|
- `src/cli/cmd/tui/plugin/api.ts`
|
||||||
|
adapt host API to plugin API
|
||||||
|
- `src/cli/cmd/tui/plugin/scope.ts`
|
||||||
|
lifecycle resource helpers
|
||||||
|
- `src/cli/cmd/tui/plugin/activation.ts`
|
||||||
|
activate and deactivate one plugin entry
|
||||||
|
|
||||||
|
These are composable functions that return `Effect`, but do not own long-lived mutable state.
|
||||||
|
|
||||||
|
### Services
|
||||||
|
|
||||||
|
- `PluginMetaStore.Service`
|
||||||
|
owns the metadata file and lock-backed updates
|
||||||
|
- `PluginServer.Service`
|
||||||
|
owns loaded server hooks and bus subscription state per project/worktree via `InstanceState`
|
||||||
|
- `TuiPluginManager.Service`
|
||||||
|
owns loaded TUI entries, enabled state, pending installs, and activation lifecycle
|
||||||
|
|
||||||
|
## Runtime split
|
||||||
|
|
||||||
|
### Server side
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
A[PluginServer.Service] --> B[build plugin input]
|
||||||
|
A --> C[load internal plugins]
|
||||||
|
A --> D[load external plugins]
|
||||||
|
D --> E[plugin/external.ts]
|
||||||
|
A --> F[notify config]
|
||||||
|
A --> G[subscribe bus events]
|
||||||
|
```
|
||||||
|
|
||||||
|
### TUI side
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
A[TuiPluginManager.Service] --> B[load internal entries]
|
||||||
|
A --> C[load external entries]
|
||||||
|
C --> D[plugin/external.ts]
|
||||||
|
A --> E[track plugin metadata]
|
||||||
|
E --> F[PluginMetaStore.Service]
|
||||||
|
A --> G[activate and deactivate]
|
||||||
|
G --> H[tui/plugin/activation.ts]
|
||||||
|
A --> I[sync themes]
|
||||||
|
I --> J[tui/plugin/theme.ts]
|
||||||
|
A --> K[install and configure plugin]
|
||||||
|
K --> L[plugin/install.ts]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Design rules
|
||||||
|
|
||||||
|
- Keep orchestration readable at the top level.
|
||||||
|
- Put state in services, not module globals.
|
||||||
|
- Prefer typed results over callback-driven reporting.
|
||||||
|
- Share install/configure workflow between CLI and TUI.
|
||||||
|
- Keep plugin discovery parallel, but keep activation and hook registration sequential.
|
||||||
|
- Preserve the special cases that already matter:
|
||||||
|
theme-only TUI plugins, legacy server plugins, local file-plugin retry after dependency prep.
|
||||||
|
|
||||||
|
## Suggested migration order
|
||||||
|
|
||||||
|
1. Split `shared.ts` into `spec.ts`, `module.ts`, `manifest.ts`, and `package.ts` without changing behavior.
|
||||||
|
2. Replace `loader.ts` with flat exports in `external.ts` and return typed result values instead of report callbacks.
|
||||||
|
3. Collapse duplicated install flow into one shared `plugin/install.ts` workflow used by CLI and TUI.
|
||||||
|
4. Convert `meta.ts` into `PluginMetaStore.Service`.
|
||||||
|
5. Shrink `plugin/index.ts` into a thin `PluginServer.Service` composition root.
|
||||||
|
6. Break up `tui/plugin/runtime.ts` and move its mutable runtime state into `TuiPluginManager.Service`.
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# plug
|
||||||
|
|
||||||
|
Type-only sketch for a cleaner plugin architecture.
|
||||||
|
|
||||||
|
This folder is intentionally not wired into the application yet.
|
||||||
|
It exists so the shape of a redesign is easy to inspect without mixing design work with runtime changes.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- `common.ts`
|
||||||
|
shared helper types used by the rest of the sketch
|
||||||
|
- `spec.ts`
|
||||||
|
plugin declaration and normalization types
|
||||||
|
- `package.ts`
|
||||||
|
package metadata and capability inspection types
|
||||||
|
- `module.ts`
|
||||||
|
imported module shape and validation types
|
||||||
|
- `external.ts`
|
||||||
|
external plugin load pipeline types
|
||||||
|
- `meta.ts`
|
||||||
|
plugin metadata store types
|
||||||
|
- `install.ts`
|
||||||
|
install and config patch workflow types
|
||||||
|
- `server.ts`
|
||||||
|
server plugin service types
|
||||||
|
- `tui.ts`
|
||||||
|
TUI plugin manager service types
|
||||||
|
|
||||||
|
## Reading order
|
||||||
|
|
||||||
|
If you want the sketch to build up from small concepts to runtime orchestration, read the files in this order:
|
||||||
|
|
||||||
|
1. `spec.ts`
|
||||||
|
Start here for the basic nouns: plugin kinds, sources, declarations, config origins, and normalized candidates.
|
||||||
|
2. `package.ts`
|
||||||
|
Next read how package metadata is described after a plugin target has been resolved.
|
||||||
|
3. `module.ts`
|
||||||
|
Then read the imported module shapes and validation results for v1 and legacy modules.
|
||||||
|
4. `external.ts`
|
||||||
|
This is the shared external loading pipeline that connects spec parsing, package inspection, and module import.
|
||||||
|
5. `meta.ts`
|
||||||
|
Read this next to see what state should be persisted across runs and why it belongs behind a service.
|
||||||
|
6. `install.ts`
|
||||||
|
This describes the install, manifest, and config patch workflow shared by CLI and TUI.
|
||||||
|
7. `server.ts`
|
||||||
|
Read the server runtime service after the lower-level pipeline files, since it mainly composes those pieces.
|
||||||
|
8. `tui.ts`
|
||||||
|
Read this last because it has the largest runtime surface and depends on most of the earlier concepts.
|
||||||
|
9. `common.ts`
|
||||||
|
This file is only shared utility typing. You can skim it first or ignore it until you see a helper type you want to expand.
|
||||||
|
|
||||||
|
## Intent
|
||||||
|
|
||||||
|
- Stateful parts are described as service interfaces.
|
||||||
|
- Stateless parts are described as function types returning `Effect`.
|
||||||
|
- The comments explain what each type is for and where it would sit in the architecture.
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
/**
|
||||||
|
* Small helper alias used throughout this folder so the design signatures stay readable.
|
||||||
|
*
|
||||||
|
* These files are only a type sketch, so we reference the Effect type without creating any runtime code.
|
||||||
|
*/
|
||||||
|
export type Fx<Success, Error = never, Requirements = never> = import("effect").Effect.Effect<
|
||||||
|
Success,
|
||||||
|
Error,
|
||||||
|
Requirements
|
||||||
|
>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standard success result shape used by the sketch files.
|
||||||
|
*
|
||||||
|
* The current plugin code already uses similar tagged unions in a few places.
|
||||||
|
*/
|
||||||
|
export type Ok<Value> = {
|
||||||
|
ok: true
|
||||||
|
value: Value
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standard failure result shape used by the sketch files.
|
||||||
|
*
|
||||||
|
* `code` stays machine-friendly while the extra data explains the specific failure.
|
||||||
|
*/
|
||||||
|
export type Failure<Code extends string, Data> = {
|
||||||
|
ok: false
|
||||||
|
code: Code
|
||||||
|
} & Data
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic loaded module namespace.
|
||||||
|
*
|
||||||
|
* Dynamic imports return a namespace object, and the next stage decides whether that namespace is a
|
||||||
|
* v1 module, a legacy server export set, or an invalid module.
|
||||||
|
*/
|
||||||
|
export type ModuleNamespace = Record<string, unknown>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared shape for objects that carry both a configured spec and its resolved on-disk target.
|
||||||
|
*/
|
||||||
|
export interface SpecTarget {
|
||||||
|
/** The original normalized plugin spec, for example `pkg@1.2.3` or `file:///.../plugin.ts`. */
|
||||||
|
readonly spec: string
|
||||||
|
/** The resolved install location or local file URL that later stages work against. */
|
||||||
|
readonly target: string
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import type { Candidate, Kind, Options, Origin, Source } from "./spec"
|
||||||
|
import type { Fx, ModuleNamespace, SpecTarget } from "./common"
|
||||||
|
import type { PackageRecord } from "./package"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalized external plugin plan before any filesystem or npm work happens.
|
||||||
|
*/
|
||||||
|
export interface Plan {
|
||||||
|
/** Original normalized string spec. */
|
||||||
|
readonly spec: string
|
||||||
|
/** Optional inline config tuple payload. */
|
||||||
|
readonly options: Options | undefined
|
||||||
|
/** Whether the package is deprecated because the functionality is now built in. */
|
||||||
|
readonly deprecated: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* External plugin that has been resolved to a concrete on-disk entrypoint.
|
||||||
|
*/
|
||||||
|
export interface Resolved extends SpecTarget {
|
||||||
|
/** Options to forward when the plugin is instantiated. */
|
||||||
|
readonly options: Options | undefined
|
||||||
|
/** Whether the plugin came from a file path or npm install. */
|
||||||
|
readonly source: Source
|
||||||
|
/** JavaScript module entrypoint that can be dynamically imported. */
|
||||||
|
readonly entry: string
|
||||||
|
/** Loaded package metadata when a package.json exists. */
|
||||||
|
readonly pkg: PackageRecord | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* External plugin target that was found but does not expose the requested runtime entrypoint.
|
||||||
|
*
|
||||||
|
* This is a first-class result because TUI still cares about theme-only packages.
|
||||||
|
*/
|
||||||
|
export interface MissingEntrypoint extends SpecTarget {
|
||||||
|
/** Options to forward if some later stage still wants to keep the plugin record. */
|
||||||
|
readonly options: Options | undefined
|
||||||
|
/** Whether the target came from a file path or npm install. */
|
||||||
|
readonly source: Source
|
||||||
|
/** Loaded package metadata when a package.json exists. */
|
||||||
|
readonly pkg: PackageRecord | undefined
|
||||||
|
/** Human-readable explanation of what was missing. */
|
||||||
|
readonly message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolved plugin whose module has been imported successfully.
|
||||||
|
*/
|
||||||
|
export interface Loaded extends Resolved {
|
||||||
|
/** Raw dynamic import namespace. */
|
||||||
|
readonly module: ModuleNamespace
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pipeline stages where a plugin load can fail.
|
||||||
|
*/
|
||||||
|
export type FailureStage = "install" | "entry" | "compatibility" | "load"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* External load failure record.
|
||||||
|
*
|
||||||
|
* This replaces the current callback-heavy report object with an explicit value.
|
||||||
|
*/
|
||||||
|
export interface Failure {
|
||||||
|
/** Which configured plugin was being processed. */
|
||||||
|
readonly candidate: Candidate
|
||||||
|
/** Which pass failed. */
|
||||||
|
readonly stage: FailureStage
|
||||||
|
/** Whether the failure happened during the retry-after-dependencies pass. */
|
||||||
|
readonly retry: boolean
|
||||||
|
/** Underlying failure object. */
|
||||||
|
readonly error: unknown
|
||||||
|
/** Best-known resolution details when the failure happened after entry resolution. */
|
||||||
|
readonly resolved: Resolved | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of processing one configured external plugin.
|
||||||
|
*/
|
||||||
|
export type AttemptResult =
|
||||||
|
| {
|
||||||
|
/** Successfully resolved and imported plugin module. */
|
||||||
|
readonly type: "loaded"
|
||||||
|
readonly origin: Origin
|
||||||
|
readonly retry: boolean
|
||||||
|
readonly value: Loaded
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
/** Target exists but does not expose the requested runtime entrypoint. */
|
||||||
|
readonly type: "missing"
|
||||||
|
readonly origin: Origin
|
||||||
|
readonly retry: boolean
|
||||||
|
readonly value: MissingEntrypoint
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
/** Operational failure during install, resolution, compatibility, or import. */
|
||||||
|
readonly type: "failed"
|
||||||
|
readonly value: Failure
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Input to the external loading workflow.
|
||||||
|
*/
|
||||||
|
export interface LoadRequest {
|
||||||
|
/** Ordered merged config origins to process. */
|
||||||
|
readonly items: readonly Origin[]
|
||||||
|
/** Which runtime is asking for plugins. */
|
||||||
|
readonly kind: Kind
|
||||||
|
/**
|
||||||
|
* Optional dependency-prep effect.
|
||||||
|
*
|
||||||
|
* If provided, file plugins that failed during the first pass may be retried after this effect completes.
|
||||||
|
*/
|
||||||
|
readonly waitForDependencies: Fx<void> | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Intended signature for the shared external loader.
|
||||||
|
*
|
||||||
|
* The return value preserves order and gives callers explicit success, missing-entry, and failure results.
|
||||||
|
*/
|
||||||
|
export type LoadExternal = (request: LoadRequest) => Fx<readonly AttemptResult[]>
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import type { Origin } from "./spec"
|
||||||
|
import type { Failure, Fx, Ok } from "./common"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How a config patch changed one file.
|
||||||
|
*/
|
||||||
|
export type PatchMode = "noop" | "add" | "replace"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Why a package is considered to target one runtime.
|
||||||
|
*/
|
||||||
|
export type TargetReason = "server-export" | "tui-export" | "package-main" | "themes"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One runtime target inferred from an installed package.
|
||||||
|
*/
|
||||||
|
export interface Target {
|
||||||
|
/** Which runtime should receive a config entry. */
|
||||||
|
readonly kind: "server" | "tui"
|
||||||
|
/** Optional default options to write alongside the plugin spec. */
|
||||||
|
readonly options: Record<string, unknown> | undefined
|
||||||
|
/** Why this runtime target was inferred from package metadata. */
|
||||||
|
readonly reason: TargetReason
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* High-level package manifest summary returned by plugin inspection.
|
||||||
|
*/
|
||||||
|
export interface Manifest {
|
||||||
|
/** Installed or resolved target used to inspect the package. */
|
||||||
|
readonly target: string
|
||||||
|
/** Runtime targets inferred from that package. */
|
||||||
|
readonly targets: readonly Target[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Context needed when patching config files after install.
|
||||||
|
*/
|
||||||
|
export interface PatchRequest {
|
||||||
|
/** Plugin spec to add or replace. */
|
||||||
|
readonly spec: string
|
||||||
|
/** Runtime targets that should be written into config. */
|
||||||
|
readonly targets: readonly Target[]
|
||||||
|
/** Whether an existing npm package entry may be replaced by package name. */
|
||||||
|
readonly force: boolean
|
||||||
|
/** Whether the write should go to the global config directory. */
|
||||||
|
readonly global: boolean
|
||||||
|
/** VCS hint used to decide whether the worktree root should own the local config write. */
|
||||||
|
readonly vcs: string | undefined
|
||||||
|
/** Current worktree path. */
|
||||||
|
readonly worktree: string
|
||||||
|
/** Current working directory. */
|
||||||
|
readonly directory: string
|
||||||
|
/** Optional explicit global config directory override. */
|
||||||
|
readonly config: string | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One config file mutation performed during patching.
|
||||||
|
*/
|
||||||
|
export interface PatchItem {
|
||||||
|
/** Which runtime file was touched. */
|
||||||
|
readonly kind: "server" | "tui"
|
||||||
|
/** Whether the plugin row was added, replaced, or already present. */
|
||||||
|
readonly mode: PatchMode
|
||||||
|
/** Concrete config file path that was inspected or written. */
|
||||||
|
readonly file: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Final result of a successful config patch operation.
|
||||||
|
*/
|
||||||
|
export interface PatchSuccess {
|
||||||
|
/** Directory that owned the config write. */
|
||||||
|
readonly dir: string
|
||||||
|
/** Per-runtime write details. */
|
||||||
|
readonly items: readonly PatchItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared error union for install and config patch workflows.
|
||||||
|
*/
|
||||||
|
export type InstallFailure =
|
||||||
|
| Failure<"install_failed", { error: unknown }>
|
||||||
|
| Failure<"manifest_read_failed", { file: string; error: unknown }>
|
||||||
|
| Failure<"manifest_no_targets", { file: string }>
|
||||||
|
| Failure<"invalid_json", { kind: "server" | "tui"; file: string; line: number; col: number; parse: string }>
|
||||||
|
| Failure<"patch_failed", { kind: "server" | "tui"; error: unknown }>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Current low-level install result shape.
|
||||||
|
*/
|
||||||
|
export type InstallResult = Ok<{ target: string }>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Current low-level manifest inspection result shape.
|
||||||
|
*/
|
||||||
|
export type ManifestResult = Ok<{ manifest: Manifest }> | InstallFailure
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Current low-level patch result shape.
|
||||||
|
*/
|
||||||
|
export type PatchResult = Ok<PatchSuccess> | InstallFailure
|
||||||
|
|
||||||
|
/**
|
||||||
|
* High-level request for the full install-and-configure workflow.
|
||||||
|
*/
|
||||||
|
export interface InstallAndConfigureRequest {
|
||||||
|
/** Raw package spec entered by the user. */
|
||||||
|
readonly spec: string
|
||||||
|
/** Whether the installed plugin should be written to global config. */
|
||||||
|
readonly global: boolean
|
||||||
|
/** Whether an already-configured npm package may be replaced by package name. */
|
||||||
|
readonly force: boolean
|
||||||
|
/** Current working directory. */
|
||||||
|
readonly directory: string
|
||||||
|
/** Current worktree root. */
|
||||||
|
readonly worktree: string
|
||||||
|
/** VCS hint used when picking the local config directory. */
|
||||||
|
readonly vcs: string | undefined
|
||||||
|
/** Optional explicit global config directory override. */
|
||||||
|
readonly config: string | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of the full install-and-configure workflow.
|
||||||
|
*
|
||||||
|
* The optional `origin` field is useful for the TUI runtime, which wants to track newly configured
|
||||||
|
* plugins before they are actually activated.
|
||||||
|
*/
|
||||||
|
export interface InstallAndConfigureSuccess {
|
||||||
|
/** Resolved install target returned by the package install step. */
|
||||||
|
readonly target: string
|
||||||
|
/** Inferred package manifest. */
|
||||||
|
readonly manifest: Manifest
|
||||||
|
/** Config patch details. */
|
||||||
|
readonly patch: PatchSuccess
|
||||||
|
/** Newly configured TUI origin when the package exposed a TUI target. */
|
||||||
|
readonly origin: Origin | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Intended shared workflow used by both `opencode plug` and the TUI runtime.
|
||||||
|
*/
|
||||||
|
export type InstallAndConfigure = (
|
||||||
|
request: InstallAndConfigureRequest,
|
||||||
|
) => Fx<InstallAndConfigureSuccess, InstallFailure>
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import type { Fx } from "./common"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* External sources tracked in the metadata file.
|
||||||
|
*
|
||||||
|
* Internal plugins are intentionally excluded because the metadata file is for user-configurable external plugins.
|
||||||
|
*/
|
||||||
|
export type Source = "file" | "npm"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persisted information about one installed theme file provided by a TUI plugin.
|
||||||
|
*/
|
||||||
|
export interface ThemeEntry {
|
||||||
|
/** Original theme file inside the plugin package or local plugin root. */
|
||||||
|
readonly src: string
|
||||||
|
/** Final persisted destination in the user's themes directory. */
|
||||||
|
readonly dest: string
|
||||||
|
/** Source file modification time used to detect changes. */
|
||||||
|
readonly mtime: number | undefined
|
||||||
|
/** Source file size used to detect changes. */
|
||||||
|
readonly size: number | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persisted metadata about one external plugin id.
|
||||||
|
*/
|
||||||
|
export interface Entry {
|
||||||
|
/** Logical runtime id. */
|
||||||
|
readonly id: string
|
||||||
|
/** Whether this record describes a file plugin or npm plugin. */
|
||||||
|
readonly source: Source
|
||||||
|
/** Normalized config spec that produced the record. */
|
||||||
|
readonly spec: string
|
||||||
|
/** Resolved install directory or file target. */
|
||||||
|
readonly target: string
|
||||||
|
/** Requested npm version or range from the original spec, when relevant. */
|
||||||
|
readonly requested: string | undefined
|
||||||
|
/** Installed package version, when relevant. */
|
||||||
|
readonly version: string | undefined
|
||||||
|
/** Modified time for local file plugins, when relevant. */
|
||||||
|
readonly modified: number | undefined
|
||||||
|
/** First time this plugin id was seen. */
|
||||||
|
readonly first_time: number
|
||||||
|
/** Most recent time this plugin id was loaded. */
|
||||||
|
readonly last_time: number
|
||||||
|
/** Most recent time the record fingerprint changed. */
|
||||||
|
readonly time_changed: number
|
||||||
|
/** Number of times the plugin has been loaded. */
|
||||||
|
readonly load_count: number
|
||||||
|
/** Compact identity used to decide whether the plugin changed between runs. */
|
||||||
|
readonly fingerprint: string
|
||||||
|
/** Theme files installed on behalf of this plugin. */
|
||||||
|
readonly themes: Readonly<Record<string, ThemeEntry>> | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How the latest touch compared with the previously persisted record.
|
||||||
|
*/
|
||||||
|
export type TouchState = "first" | "updated" | "same"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Input required to update metadata for one external plugin load.
|
||||||
|
*/
|
||||||
|
export interface TouchInput {
|
||||||
|
/** Normalized config spec. */
|
||||||
|
readonly spec: string
|
||||||
|
/** Resolved install target or file target. */
|
||||||
|
readonly target: string
|
||||||
|
/** Logical runtime id. */
|
||||||
|
readonly id: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result returned after updating metadata for one plugin.
|
||||||
|
*/
|
||||||
|
export interface TouchResult {
|
||||||
|
/** Whether the plugin is new, changed, or unchanged compared with the previous record. */
|
||||||
|
readonly state: TouchState
|
||||||
|
/** The full persisted entry after the update. */
|
||||||
|
readonly entry: Entry
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entire on-disk metadata store keyed by runtime plugin id.
|
||||||
|
*/
|
||||||
|
export type Store = Readonly<Record<string, Entry>>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stateful service responsible for the plugin metadata file.
|
||||||
|
*
|
||||||
|
* This is a strong service boundary because it owns a lock, persistence format, and mutation rules.
|
||||||
|
*/
|
||||||
|
export interface Interface {
|
||||||
|
/** Update metadata for one plugin load. */
|
||||||
|
readonly touch: (input: TouchInput) => Fx<TouchResult>
|
||||||
|
/** Update metadata for many plugin loads in a single locked write. */
|
||||||
|
readonly touchMany: (input: readonly TouchInput[]) => Fx<readonly TouchResult[]>
|
||||||
|
/** Persist one installed theme record under an existing plugin id. */
|
||||||
|
readonly setTheme: (input: { id: string; name: string; theme: ThemeEntry }) => Fx<void>
|
||||||
|
/** Read the entire current metadata store. */
|
||||||
|
readonly list: () => Fx<Store>
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import type { Plugin as ServerPluginFactory, PluginModule as ServerPluginModule } from "@opencode-ai/plugin"
|
||||||
|
import type { TuiPluginModule } from "@opencode-ai/plugin/tui"
|
||||||
|
import type { ModuleNamespace } from "./common"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validation mode for imported plugin modules.
|
||||||
|
*
|
||||||
|
* `strict` means the module must clearly match the expected target shape.
|
||||||
|
* `detect` means the loader is probing for a v1 module before falling back to older compatibility paths.
|
||||||
|
*/
|
||||||
|
export type ValidationMode = "strict" | "detect"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Current v1 server module shape.
|
||||||
|
*
|
||||||
|
* A v1 module is target-exclusive, so it must not export both `server` and `tui` from the same default export.
|
||||||
|
*/
|
||||||
|
export type V1ServerModule = {
|
||||||
|
/** Optional logical plugin id. npm plugins may omit this and fall back to package name. */
|
||||||
|
readonly id?: string
|
||||||
|
/** Server plugin factory used to create hook handlers. */
|
||||||
|
readonly server: ServerPluginModule["server"]
|
||||||
|
/** Explicitly absent to make the target-exclusive shape obvious. */
|
||||||
|
readonly tui?: never
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Current v1 TUI module shape.
|
||||||
|
*/
|
||||||
|
export type V1TuiModule = {
|
||||||
|
/** Optional logical plugin id. */
|
||||||
|
readonly id?: string
|
||||||
|
/** TUI plugin factory used to register commands, routes, and UI hooks. */
|
||||||
|
readonly tui: TuiPluginModule["tui"]
|
||||||
|
/** Explicitly absent to make the target-exclusive shape obvious. */
|
||||||
|
readonly server?: never
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Union of the currently supported v1 module shapes.
|
||||||
|
*/
|
||||||
|
export type V1Module = V1ServerModule | V1TuiModule
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Older server plugin export styles still supported for backward compatibility.
|
||||||
|
*
|
||||||
|
* These only exist on the server side.
|
||||||
|
*/
|
||||||
|
export type LegacyServerExport =
|
||||||
|
| ServerPluginFactory
|
||||||
|
| {
|
||||||
|
readonly server: ServerPluginFactory
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of validating an imported module namespace.
|
||||||
|
*/
|
||||||
|
export type ValidationResult =
|
||||||
|
| {
|
||||||
|
/** Namespace matched the modern v1 shape. */
|
||||||
|
readonly type: "v1"
|
||||||
|
/** Parsed target-exclusive default export. */
|
||||||
|
readonly module: V1Module
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
/** Namespace did not match v1 but did contain legacy server plugin exports. */
|
||||||
|
readonly type: "legacy-server"
|
||||||
|
/** Distinct legacy server plugin factories extracted from the namespace. */
|
||||||
|
readonly exports: readonly LegacyServerExport[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Intended signature for the module validation function.
|
||||||
|
*
|
||||||
|
* This stays separate from the external loader so module-shape rules are easy to inspect on their own.
|
||||||
|
*/
|
||||||
|
export type ValidateModule = (
|
||||||
|
namespace: ModuleNamespace,
|
||||||
|
input: {
|
||||||
|
/** Human-readable spec used in error messages. */
|
||||||
|
readonly spec: string
|
||||||
|
/** Which runtime is being validated. */
|
||||||
|
readonly kind: "server" | "tui"
|
||||||
|
/** Whether this is a hard validation pass or a soft probe. */
|
||||||
|
readonly mode: ValidationMode
|
||||||
|
},
|
||||||
|
) => ValidationResult | undefined
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import type { Kind, Source } from "./spec"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Raw package.json object for a plugin package.
|
||||||
|
*
|
||||||
|
* The real implementation would read this from disk and then derive higher-level capability types
|
||||||
|
* from it.
|
||||||
|
*/
|
||||||
|
export type PackageJson = Record<string, unknown>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Package metadata resolved from a plugin target on disk.
|
||||||
|
*/
|
||||||
|
export interface PackageRecord {
|
||||||
|
/** Root directory that owns the package.json. */
|
||||||
|
readonly dir: string
|
||||||
|
/** Absolute path to the package.json file. */
|
||||||
|
readonly pkg: string
|
||||||
|
/** Parsed package.json content. */
|
||||||
|
readonly json: PackageJson
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Why a runtime target was inferred from a package.
|
||||||
|
*
|
||||||
|
* This makes install and diagnostics code easier to read because the source of a capability is explicit.
|
||||||
|
*/
|
||||||
|
export type CapabilityReason = "server-export" | "tui-export" | "package-main" | "themes"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One runtime capability inferred from package metadata.
|
||||||
|
*/
|
||||||
|
export interface Capability {
|
||||||
|
/** Which runtime this capability belongs to. */
|
||||||
|
readonly kind: Kind
|
||||||
|
/** Why this capability exists. */
|
||||||
|
readonly reason: CapabilityReason
|
||||||
|
/** Optional default config written during install when the package provides it. */
|
||||||
|
readonly options: Record<string, unknown> | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Summary of theme assets declared by a package.
|
||||||
|
*/
|
||||||
|
export interface ThemeManifest {
|
||||||
|
/** Directory that relative theme paths should be resolved from. */
|
||||||
|
readonly root: string
|
||||||
|
/** Package-relative theme files that survived validation. */
|
||||||
|
readonly files: readonly string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compatibility information declared by an npm package.
|
||||||
|
*/
|
||||||
|
export interface Compatibility {
|
||||||
|
/** Whether the compatibility gate was actually checked. */
|
||||||
|
readonly checked: boolean
|
||||||
|
/** The running opencode version used during the check. */
|
||||||
|
readonly runtimeVersion: string
|
||||||
|
/** The declared `engines.opencode` range, if any. */
|
||||||
|
readonly range: string | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Package inspection result after a target has been resolved.
|
||||||
|
*/
|
||||||
|
export interface PackageResolution {
|
||||||
|
/** Normalized plugin spec. */
|
||||||
|
readonly spec: string
|
||||||
|
/** External source kind. */
|
||||||
|
readonly source: Source
|
||||||
|
/** Resolved install directory or file URL. */
|
||||||
|
readonly target: string
|
||||||
|
/** Loaded package metadata when a package.json exists. */
|
||||||
|
readonly pkg: PackageRecord | undefined
|
||||||
|
/** Entrypoint picked for the requested runtime target, if one exists. */
|
||||||
|
readonly entry: string | undefined
|
||||||
|
/** Runtime capabilities inferred from the package metadata. */
|
||||||
|
readonly capabilities: readonly Capability[]
|
||||||
|
/** Validated theme manifest when the package declares `oc-themes`. */
|
||||||
|
readonly themes: ThemeManifest | undefined
|
||||||
|
/** Compatibility info for npm packages. */
|
||||||
|
readonly compatibility: Compatibility | undefined
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import type { Hooks, Plugin as ServerPluginFactory, PluginInput } from "@opencode-ai/plugin"
|
||||||
|
import type { Fx } from "./common"
|
||||||
|
import type { Loaded } from "./external"
|
||||||
|
import type { Source } from "./spec"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Built-in server plugin that ships with the app and does not go through the external loader.
|
||||||
|
*/
|
||||||
|
export interface InternalPlugin {
|
||||||
|
/** Stable id used for diagnostics and duplicate detection. */
|
||||||
|
readonly id: string
|
||||||
|
/** Factory that creates one `Hooks` object when the plugin service starts. */
|
||||||
|
readonly plugin: ServerPluginFactory
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One successfully instantiated server plugin hook set.
|
||||||
|
*/
|
||||||
|
export interface HookEntry {
|
||||||
|
/** Stable runtime id. */
|
||||||
|
readonly id: string
|
||||||
|
/** Normalized spec that produced this hook set. */
|
||||||
|
readonly spec: string
|
||||||
|
/** Whether the plugin came from npm, a file path, or built-in code. */
|
||||||
|
readonly source: Source | "internal"
|
||||||
|
/** Hook handlers returned by the plugin factory. */
|
||||||
|
readonly hooks: Hooks
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stateful data owned by the server plugin runtime per project/worktree instance.
|
||||||
|
*/
|
||||||
|
export interface State {
|
||||||
|
/** Fully initialized hook sets in the order they should run. */
|
||||||
|
readonly loaded: readonly HookEntry[]
|
||||||
|
/** Flat hook list kept for the existing trigger API. */
|
||||||
|
readonly hooks: readonly Hooks[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook names that follow the trigger pattern `(input, output) => Promise<void>`.
|
||||||
|
*/
|
||||||
|
export type TriggerName = {
|
||||||
|
[Name in keyof Hooks]-?: NonNullable<Hooks[Name]> extends (input: infer _Input, output: infer _Output) => Promise<void>
|
||||||
|
? Name
|
||||||
|
: never
|
||||||
|
}[keyof Hooks]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Input type for one triggerable hook name.
|
||||||
|
*/
|
||||||
|
export type TriggerInput<Name extends TriggerName> = Parameters<Required<Hooks>[Name]>[0]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Output accumulator type for one triggerable hook name.
|
||||||
|
*/
|
||||||
|
export type TriggerOutput<Name extends TriggerName> = Parameters<Required<Hooks>[Name]>[1]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Context assembled before any server plugins are instantiated.
|
||||||
|
*/
|
||||||
|
export interface RuntimeContext {
|
||||||
|
/** Rich plugin input passed to every server plugin factory. */
|
||||||
|
readonly input: PluginInput
|
||||||
|
/** Whether external plugins should be skipped because the runtime is in pure mode. */
|
||||||
|
readonly pure: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stateless adapter that turns a loaded external module into one or more server hook sets.
|
||||||
|
*
|
||||||
|
* The return type is plural because legacy server modules can still expose multiple plugin factories.
|
||||||
|
*/
|
||||||
|
export type ApplyLoaded = (load: Loaded, input: PluginInput) => Fx<readonly Hooks[]>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public service surface for the server plugin runtime.
|
||||||
|
*
|
||||||
|
* The implementation would be backed by `InstanceState` because loaded hooks and bus subscriptions are
|
||||||
|
* scoped to one project/worktree instance.
|
||||||
|
*/
|
||||||
|
export interface Interface {
|
||||||
|
/** Ensure the per-instance plugin state has been initialized. */
|
||||||
|
readonly init: () => Fx<void>
|
||||||
|
/** Return the currently loaded hook objects. */
|
||||||
|
readonly list: () => Fx<readonly Hooks[]>
|
||||||
|
/** Trigger one hook name across loaded plugins while preserving plugin order. */
|
||||||
|
readonly trigger: <Name extends TriggerName>(
|
||||||
|
name: Name,
|
||||||
|
input: TriggerInput<Name>,
|
||||||
|
output: TriggerOutput<Name>,
|
||||||
|
) => Fx<TriggerOutput<Name>>
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import type {
|
||||||
|
Options as ConfigPluginOptions,
|
||||||
|
Origin as ConfigPluginOrigin,
|
||||||
|
Scope as ConfigPluginScope,
|
||||||
|
Spec as ConfigPluginSpec,
|
||||||
|
} from "@/config/plugin"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The two external plugin sources supported by the current system.
|
||||||
|
*
|
||||||
|
* `file` means a local path or `file://` URL.
|
||||||
|
* `npm` means an installable package spec.
|
||||||
|
*/
|
||||||
|
export type Source = "file" | "npm"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `internal` is used by the TUI runtime for built-in plugins that are shipped with the app.
|
||||||
|
*
|
||||||
|
* It is kept separate from `Source` because it is not part of the external plugin loading pipeline.
|
||||||
|
*/
|
||||||
|
export type RuntimeSource = Source | "internal"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The two runtime targets a package can expose.
|
||||||
|
*/
|
||||||
|
export type Kind = "server" | "tui"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inline config tuple options forwarded to the plugin factory.
|
||||||
|
*/
|
||||||
|
export type Options = ConfigPluginOptions
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Raw plugin config entry as it appears in `opencode.json` or `tui.json`.
|
||||||
|
*/
|
||||||
|
export type Input = ConfigPluginSpec
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Config provenance attached to a plugin declaration after config merging.
|
||||||
|
*
|
||||||
|
* This answers "which config file declared this plugin?" so follow-up writes can go back to the
|
||||||
|
* right place.
|
||||||
|
*/
|
||||||
|
export type Origin = ConfigPluginOrigin
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a config origin should behave like a global or project-local plugin declaration.
|
||||||
|
*/
|
||||||
|
export type Scope = ConfigPluginScope
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parsed npm-style package identity.
|
||||||
|
*
|
||||||
|
* This is the identity used for dedupe and for better install error messages.
|
||||||
|
*/
|
||||||
|
export interface ParsedSpecifier {
|
||||||
|
/** Package name portion of the spec. For file specs this will usually just be the original string. */
|
||||||
|
readonly pkg: string
|
||||||
|
/** Version request portion of the spec. Bare package names typically normalize to `latest`. */
|
||||||
|
readonly version: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalized external plugin declaration.
|
||||||
|
*
|
||||||
|
* This is the shape that downstream loading code should work with instead of raw config tuples.
|
||||||
|
*/
|
||||||
|
export interface Declared {
|
||||||
|
/** Original config provenance. */
|
||||||
|
readonly origin: Origin
|
||||||
|
/** Normalized string spec extracted from the raw config value. */
|
||||||
|
readonly spec: string
|
||||||
|
/** Optional config tuple payload that should be forwarded to the plugin factory. */
|
||||||
|
readonly options: Options | undefined
|
||||||
|
/** Whether this should be treated as a file plugin or npm plugin. */
|
||||||
|
readonly source: Source
|
||||||
|
/** Whether the package name maps to a built-in plugin and should therefore be ignored. */
|
||||||
|
readonly deprecated: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Candidate item passed into the external load pipeline.
|
||||||
|
*
|
||||||
|
* The name exists to make it obvious that the plugin has not been resolved or imported yet.
|
||||||
|
*/
|
||||||
|
export interface Candidate {
|
||||||
|
/** Original config entry and provenance. */
|
||||||
|
readonly origin: Origin
|
||||||
|
/** Normalized declaration derived from that config entry. */
|
||||||
|
readonly declared: Declared
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import type {
|
||||||
|
TuiDispose,
|
||||||
|
TuiPlugin,
|
||||||
|
TuiPluginApi,
|
||||||
|
TuiPluginInstallResult,
|
||||||
|
TuiPluginMeta,
|
||||||
|
TuiPluginModule,
|
||||||
|
TuiPluginStatus,
|
||||||
|
} from "@opencode-ai/plugin/tui"
|
||||||
|
import type { Info as TuiConfigInfo } from "@/cli/cmd/tui/config/tui"
|
||||||
|
import type { HostPluginApi, HostSlots } from "@/cli/cmd/tui/plugin/slots"
|
||||||
|
import type { Origin, RuntimeSource } from "./spec"
|
||||||
|
import type { Fx } from "./common"
|
||||||
|
import type { ThemeEntry, TouchResult } from "./meta"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loaded TUI plugin before it is wrapped in runtime state.
|
||||||
|
*/
|
||||||
|
export interface PluginLoad {
|
||||||
|
/** Inline config tuple payload forwarded to the plugin factory. */
|
||||||
|
readonly options: Record<string, unknown> | undefined
|
||||||
|
/** Normalized string spec. */
|
||||||
|
readonly spec: string
|
||||||
|
/** Resolved install target or file target. */
|
||||||
|
readonly target: string
|
||||||
|
/** Whether this load happened during the retry-after-dependencies pass. */
|
||||||
|
readonly retry: boolean
|
||||||
|
/** Whether this plugin is external or built in. */
|
||||||
|
readonly source: RuntimeSource
|
||||||
|
/** Stable runtime id. */
|
||||||
|
readonly id: string
|
||||||
|
/** Target-exclusive TUI module. */
|
||||||
|
readonly module: TuiPluginModule
|
||||||
|
/** Config provenance. Internal plugins still get a synthetic origin for consistency. */
|
||||||
|
readonly origin: Origin
|
||||||
|
/** Root used to resolve package-relative theme files. */
|
||||||
|
readonly theme_root: string
|
||||||
|
/** Valid theme files discovered from `oc-themes`. */
|
||||||
|
readonly theme_files: readonly string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One plugin-owned lifecycle scope.
|
||||||
|
*
|
||||||
|
* The current runtime models this manually with cleanup functions and an `AbortController`.
|
||||||
|
*/
|
||||||
|
export interface PluginScope {
|
||||||
|
/** Lifecycle object exposed to the plugin API. */
|
||||||
|
readonly lifecycle: TuiPluginApi["lifecycle"]
|
||||||
|
/** Register a plain cleanup callback and return an unregister function. */
|
||||||
|
readonly track: (fn: TuiDispose | undefined) => () => void
|
||||||
|
/** Dispose the scope and run cleanup callbacks in reverse order. */
|
||||||
|
readonly dispose: () => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One plugin entry tracked by the TUI manager.
|
||||||
|
*/
|
||||||
|
export interface PluginEntry {
|
||||||
|
/** Stable runtime id. */
|
||||||
|
readonly id: string
|
||||||
|
/** Raw load details used for diagnostics and reload decisions. */
|
||||||
|
readonly load: PluginLoad
|
||||||
|
/** Metadata exposed to plugin code and the UI. */
|
||||||
|
readonly meta: TuiPluginMeta
|
||||||
|
/** Persisted theme install records keyed by theme name. */
|
||||||
|
readonly themes: Readonly<Record<string, ThemeEntry>>
|
||||||
|
/** Concrete TUI plugin factory. */
|
||||||
|
readonly plugin: TuiPlugin
|
||||||
|
/** Current enabled state. */
|
||||||
|
readonly enabled: boolean
|
||||||
|
/** Live lifecycle scope when the plugin is active. */
|
||||||
|
readonly scope: PluginScope | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of running one plugin cleanup callback.
|
||||||
|
*/
|
||||||
|
export type CleanupResult =
|
||||||
|
| { readonly type: "ok" }
|
||||||
|
| { readonly type: "error"; readonly error: unknown }
|
||||||
|
| { readonly type: "timeout" }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stateful data owned by the TUI plugin manager.
|
||||||
|
*/
|
||||||
|
export interface State {
|
||||||
|
/** Current directory the runtime was initialized for. */
|
||||||
|
readonly directory: string
|
||||||
|
/** Host-side API exposed to plugins. */
|
||||||
|
readonly api: HostPluginApi
|
||||||
|
/** Slot registry used by TUI plugins. */
|
||||||
|
readonly slots: HostSlots
|
||||||
|
/** Ordered plugin list used for activation, disposal, and status display. */
|
||||||
|
readonly plugins: readonly PluginEntry[]
|
||||||
|
/** Fast lookup by plugin id. */
|
||||||
|
readonly plugins_by_id: ReadonlyMap<string, PluginEntry>
|
||||||
|
/** Newly installed plugin origins waiting to be added to the live runtime. */
|
||||||
|
readonly pending: ReadonlyMap<string, Origin>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of tracking newly loaded external plugins in the metadata store.
|
||||||
|
*/
|
||||||
|
export interface MetadataBatch {
|
||||||
|
/** Metadata touch results in the same order as the loaded plugins. */
|
||||||
|
readonly results: readonly TouchResult[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public service surface for the TUI plugin runtime.
|
||||||
|
*
|
||||||
|
* This would replace the current module-global singleton state with an explicit service.
|
||||||
|
*/
|
||||||
|
export interface Interface {
|
||||||
|
/** Initialize the runtime for one working directory and config snapshot. */
|
||||||
|
readonly init: (input: { api: HostPluginApi; config: TuiConfigInfo }) => Fx<void>
|
||||||
|
/** Return current status rows for the TUI plugin UI. */
|
||||||
|
readonly list: () => Fx<readonly TuiPluginStatus[]>
|
||||||
|
/** Enable one plugin id. */
|
||||||
|
readonly activate: (id: string) => Fx<boolean>
|
||||||
|
/** Disable one plugin id. */
|
||||||
|
readonly deactivate: (id: string) => Fx<boolean>
|
||||||
|
/** Add one already-configured plugin spec to the live runtime. */
|
||||||
|
readonly add: (spec: string) => Fx<boolean>
|
||||||
|
/** Install a package and patch config, returning the current TUI-facing result type. */
|
||||||
|
readonly install: (spec: string, options: { global?: boolean } | undefined) => Fx<TuiPluginInstallResult>
|
||||||
|
/** Dispose all active plugins in reverse order. */
|
||||||
|
readonly dispose: () => Fx<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stateless helper signature for creating one active plugin scope.
|
||||||
|
*/
|
||||||
|
export type CreateScope = (input: { load: PluginLoad; id: string; disposeTimeoutMs: number }) => PluginScope
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stateless helper signature for activating one plugin entry.
|
||||||
|
*/
|
||||||
|
export type ActivateEntry = (input: {
|
||||||
|
state: State
|
||||||
|
plugin: PluginEntry
|
||||||
|
persist: boolean
|
||||||
|
}) => Fx<boolean>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stateless helper signature for deactivating one plugin entry.
|
||||||
|
*/
|
||||||
|
export type DeactivateEntry = (input: {
|
||||||
|
state: State
|
||||||
|
plugin: PluginEntry
|
||||||
|
persist: boolean
|
||||||
|
}) => Fx<boolean>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stateless helper signature for syncing theme files for one plugin entry.
|
||||||
|
*/
|
||||||
|
export type SyncThemes = (plugin: PluginEntry) => Fx<void>
|
||||||
@@ -12,31 +12,41 @@ import { ConfigPlugin } from "@/config/plugin"
|
|||||||
import { InstallationVersion } from "@/installation/version"
|
import { InstallationVersion } from "@/installation/version"
|
||||||
|
|
||||||
export namespace PluginLoader {
|
export namespace PluginLoader {
|
||||||
|
// A normalized plugin declaration derived from config before any filesystem or npm work happens.
|
||||||
export type Plan = {
|
export type Plan = {
|
||||||
spec: string
|
spec: string
|
||||||
options: ConfigPlugin.Options | undefined
|
options: ConfigPlugin.Options | undefined
|
||||||
deprecated: boolean
|
deprecated: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A plugin that has been resolved to a concrete target and entrypoint on disk.
|
||||||
export type Resolved = Plan & {
|
export type Resolved = Plan & {
|
||||||
source: PluginSource
|
source: PluginSource
|
||||||
target: string
|
target: string
|
||||||
entry: string
|
entry: string
|
||||||
pkg?: PluginPackage
|
pkg?: PluginPackage
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A plugin target we could inspect, but which does not expose the requested kind of entrypoint.
|
||||||
export type Missing = Plan & {
|
export type Missing = Plan & {
|
||||||
source: PluginSource
|
source: PluginSource
|
||||||
target: string
|
target: string
|
||||||
pkg?: PluginPackage
|
pkg?: PluginPackage
|
||||||
message: string
|
message: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A resolved plugin whose module has been imported successfully.
|
||||||
export type Loaded = Resolved & {
|
export type Loaded = Resolved & {
|
||||||
mod: Record<string, unknown>
|
mod: Record<string, unknown>
|
||||||
}
|
}
|
||||||
|
|
||||||
type Candidate = { origin: ConfigPlugin.Origin; plan: Plan }
|
type Candidate = { origin: ConfigPlugin.Origin; plan: Plan }
|
||||||
type Report = {
|
type Report = {
|
||||||
|
// Called before each attempt so callers can log initial load attempts and retries uniformly.
|
||||||
start?: (candidate: Candidate, retry: boolean) => void
|
start?: (candidate: Candidate, retry: boolean) => void
|
||||||
|
// Called when the package exists but does not provide the requested entrypoint.
|
||||||
missing?: (candidate: Candidate, retry: boolean, message: string, resolved: Missing) => void
|
missing?: (candidate: Candidate, retry: boolean, message: string, resolved: Missing) => void
|
||||||
|
// Called for operational failures such as install, compatibility, or dynamic import errors.
|
||||||
error?: (
|
error?: (
|
||||||
candidate: Candidate,
|
candidate: Candidate,
|
||||||
retry: boolean,
|
retry: boolean,
|
||||||
@@ -46,11 +56,16 @@ export namespace PluginLoader {
|
|||||||
) => void
|
) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Normalize a config item into the loader's internal representation.
|
||||||
function plan(item: ConfigPlugin.Spec): Plan {
|
function plan(item: ConfigPlugin.Spec): Plan {
|
||||||
const spec = ConfigPlugin.pluginSpecifier(item)
|
const spec = ConfigPlugin.pluginSpecifier(item)
|
||||||
return { spec, options: ConfigPlugin.pluginOptions(item), deprecated: isDeprecatedPlugin(spec) }
|
return { spec, options: ConfigPlugin.pluginOptions(item), deprecated: isDeprecatedPlugin(spec) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resolve a configured plugin into a concrete entrypoint that can later be imported.
|
||||||
|
//
|
||||||
|
// The stages here intentionally separate install/target resolution, entrypoint detection,
|
||||||
|
// and compatibility checks so callers can report the exact reason a plugin was skipped.
|
||||||
export async function resolve(
|
export async function resolve(
|
||||||
plan: Plan,
|
plan: Plan,
|
||||||
kind: PluginKind,
|
kind: PluginKind,
|
||||||
@@ -59,6 +74,7 @@ export namespace PluginLoader {
|
|||||||
| { ok: false; stage: "missing"; value: Missing }
|
| { ok: false; stage: "missing"; value: Missing }
|
||||||
| { ok: false; stage: "install" | "entry" | "compatibility"; error: unknown }
|
| { ok: false; stage: "install" | "entry" | "compatibility"; error: unknown }
|
||||||
> {
|
> {
|
||||||
|
// First make sure the plugin exists locally, installing npm plugins on demand.
|
||||||
let target = ""
|
let target = ""
|
||||||
try {
|
try {
|
||||||
target = await resolvePluginTarget(plan.spec)
|
target = await resolvePluginTarget(plan.spec)
|
||||||
@@ -67,6 +83,7 @@ export namespace PluginLoader {
|
|||||||
}
|
}
|
||||||
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`) }
|
||||||
|
|
||||||
|
// Then inspect the target for the requested server/tui entrypoint.
|
||||||
let base
|
let base
|
||||||
try {
|
try {
|
||||||
base = await createPluginEntry(plan.spec, target, kind)
|
base = await createPluginEntry(plan.spec, target, kind)
|
||||||
@@ -86,6 +103,8 @@ export namespace PluginLoader {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// npm plugins can declare which opencode versions they support; file plugins are treated
|
||||||
|
// as local development code and skip this compatibility gate.
|
||||||
if (base.source === "npm") {
|
if (base.source === "npm") {
|
||||||
try {
|
try {
|
||||||
await checkPluginCompatibility(base.target, InstallationVersion, base.pkg)
|
await checkPluginCompatibility(base.target, InstallationVersion, base.pkg)
|
||||||
@@ -96,6 +115,7 @@ export namespace PluginLoader {
|
|||||||
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 } }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Import the resolved module only after all earlier validation has succeeded.
|
||||||
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 }> {
|
||||||
let mod
|
let mod
|
||||||
try {
|
try {
|
||||||
@@ -107,6 +127,8 @@ export namespace PluginLoader {
|
|||||||
return { ok: true, value: { ...row, mod } }
|
return { ok: true, value: { ...row, mod } }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Run one candidate through the full pipeline: resolve, optionally surface a missing entry,
|
||||||
|
// import the module, and finally let the caller transform the loaded plugin into any result type.
|
||||||
async function attempt<R>(
|
async function attempt<R>(
|
||||||
candidate: Candidate,
|
candidate: Candidate,
|
||||||
kind: PluginKind,
|
kind: PluginKind,
|
||||||
@@ -116,11 +138,17 @@ export namespace PluginLoader {
|
|||||||
report: Report | undefined,
|
report: Report | undefined,
|
||||||
): Promise<R | undefined> {
|
): Promise<R | undefined> {
|
||||||
const plan = candidate.plan
|
const plan = candidate.plan
|
||||||
|
|
||||||
|
// Deprecated plugin packages are silently ignored because they are now built in.
|
||||||
if (plan.deprecated) return
|
if (plan.deprecated) return
|
||||||
|
|
||||||
report?.start?.(candidate, retry)
|
report?.start?.(candidate, retry)
|
||||||
|
|
||||||
const resolved = await resolve(plan, kind)
|
const resolved = await resolve(plan, kind)
|
||||||
if (!resolved.ok) {
|
if (!resolved.ok) {
|
||||||
if (resolved.stage === "missing") {
|
if (resolved.stage === "missing") {
|
||||||
|
// Missing entrypoints are handled separately so callers can still inspect package metadata,
|
||||||
|
// for example to load theme files from a tui plugin package that has no code entrypoint.
|
||||||
if (missing) {
|
if (missing) {
|
||||||
const value = await missing(resolved.value, candidate.origin, retry)
|
const value = await missing(resolved.value, candidate.origin, retry)
|
||||||
if (value !== undefined) return value
|
if (value !== undefined) return value
|
||||||
@@ -131,11 +159,15 @@ export namespace PluginLoader {
|
|||||||
report?.error?.(candidate, retry, resolved.stage, resolved.error)
|
report?.error?.(candidate, retry, resolved.stage, resolved.error)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const loaded = await load(resolved.value)
|
const loaded = await load(resolved.value)
|
||||||
if (!loaded.ok) {
|
if (!loaded.ok) {
|
||||||
report?.error?.(candidate, retry, "load", loaded.error, resolved.value)
|
report?.error?.(candidate, retry, "load", loaded.error, resolved.value)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The default behavior is to return the successfully loaded plugin as-is, but callers can
|
||||||
|
// provide a finisher to adapt the result into a more specific runtime shape.
|
||||||
if (!finish) return loaded.value as R
|
if (!finish) return loaded.value as R
|
||||||
return finish(loaded.value, candidate.origin, retry)
|
return finish(loaded.value, candidate.origin, retry)
|
||||||
}
|
}
|
||||||
@@ -149,6 +181,11 @@ export namespace PluginLoader {
|
|||||||
report?: Report
|
report?: Report
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resolve and load all configured plugins in parallel.
|
||||||
|
//
|
||||||
|
// If `wait` is provided, file-based plugins that initially failed are retried once after the
|
||||||
|
// caller finishes preparing dependencies. This supports local plugins that depend on an install
|
||||||
|
// step happening elsewhere before their entrypoint becomes loadable.
|
||||||
export async function loadExternal<R = Loaded>(input: Input<R>): Promise<R[]> {
|
export async function loadExternal<R = Loaded>(input: Input<R>): Promise<R[]> {
|
||||||
const candidates = input.items.map((origin) => ({ origin, plan: plan(origin.spec) }))
|
const candidates = input.items.map((origin) => ({ origin, plan: plan(origin.spec) }))
|
||||||
const list: Array<Promise<R | undefined>> = []
|
const list: Array<Promise<R | undefined>> = []
|
||||||
@@ -160,6 +197,9 @@ export namespace PluginLoader {
|
|||||||
let deps: Promise<void> | undefined
|
let deps: Promise<void> | undefined
|
||||||
for (let i = 0; i < candidates.length; i++) {
|
for (let i = 0; i < candidates.length; i++) {
|
||||||
if (out[i] !== undefined) continue
|
if (out[i] !== undefined) continue
|
||||||
|
|
||||||
|
// Only local file plugins are retried. npm plugins already attempted installation during
|
||||||
|
// the first pass, while file plugins may need the caller's dependency preparation to finish.
|
||||||
const candidate = candidates[i]
|
const candidate = candidates[i]
|
||||||
if (!candidate || pluginSource(candidate.plan.spec) !== "file") continue
|
if (!candidate || pluginSource(candidate.plan.spec) !== "file") continue
|
||||||
deps ??= input.wait()
|
deps ??= input.wait()
|
||||||
@@ -167,6 +207,8 @@ export namespace PluginLoader {
|
|||||||
out[i] = await attempt(candidate, input.kind, true, input.finish, input.missing, input.report)
|
out[i] = await attempt(candidate, input.kind, true, input.finish, input.missing, input.report)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Drop skipped/failed entries while preserving the successful result order.
|
||||||
const ready: R[] = []
|
const ready: R[] = []
|
||||||
for (const item of out) if (item !== undefined) ready.push(item)
|
for (const item of out) if (item !== undefined) ready.push(item)
|
||||||
return ready
|
return ready
|
||||||
|
|||||||
Reference in New Issue
Block a user