feat: plugin discovery, capability/contribution/permission registries, Plugin Manager UI

This commit is contained in:
2026-06-16 12:00:43 +08:00
parent 08f7328d87
commit cf0a962b46
1326 changed files with 385268 additions and 0 deletions
+116
View File
@@ -0,0 +1,116 @@
// Package capability provides a registry for plugin capabilities.
package capability
import (
"fmt"
"sort"
"sync"
)
// Registry tracks available capabilities and which plugins provide them.
type Registry struct {
mu sync.RWMutex
capabilities map[string]*Entry // capability name -> entry
}
// Entry represents a capability and its provider.
type Entry struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
PluginID string `json:"pluginId"`
Status string `json:"status"` // "stable", "draft", "deprecated"
}
// NewRegistry creates a new capability registry.
func NewRegistry() *Registry {
return &Registry{
capabilities: make(map[string]*Entry),
}
}
// Register adds a capability provided by a plugin.
func (r *Registry) Register(pluginID string, capabilities []string) error {
r.mu.Lock()
defer r.mu.Unlock()
for _, name := range capabilities {
if existing, ok := r.capabilities[name]; ok {
return fmt.Errorf("capability %q already registered by plugin %q", name, existing.PluginID)
}
r.capabilities[name] = &Entry{
Name: name,
PluginID: pluginID,
Status: "draft",
}
}
return nil
}
// Unregister removes all capabilities provided by a plugin.
func (r *Registry) Unregister(pluginID string) {
r.mu.Lock()
defer r.mu.Unlock()
for name, entry := range r.capabilities {
if entry.PluginID == pluginID {
delete(r.capabilities, name)
}
}
}
// Has checks if a capability is registered.
func (r *Registry) Has(name string) bool {
r.mu.RLock()
defer r.mu.RUnlock()
_, ok := r.capabilities[name]
return ok
}
// Get returns a capability entry by name.
func (r *Registry) Get(name string) *Entry {
r.mu.RLock()
defer r.mu.RUnlock()
return r.capabilities[name]
}
// List returns all registered capabilities, sorted by name.
func (r *Registry) List() []Entry {
r.mu.RLock()
defer r.mu.RUnlock()
entries := make([]Entry, 0, len(r.capabilities))
for _, e := range r.capabilities {
entries = append(entries, *e)
}
sort.Slice(entries, func(i, j int) bool {
return entries[i].Name < entries[j].Name
})
return entries
}
// Available returns the set of available capability names.
func (r *Registry) Available() map[string]bool {
r.mu.RLock()
defer r.mu.RUnlock()
result := make(map[string]bool, len(r.capabilities))
for name := range r.capabilities {
result[name] = true
}
return result
}
// CheckRequired verifies that all required capabilities are present.
// Returns missing capabilities.
func (r *Registry) CheckRequired(required []string) []string {
r.mu.RLock()
defer r.mu.RUnlock()
var missing []string
for _, capName := range required {
if _, ok := r.capabilities[capName]; !ok {
missing = append(missing, capName)
}
}
return missing
}
+286
View File
@@ -0,0 +1,286 @@
// Package contribution provides a registry for plugin contribution points.
package contribution
import (
"sort"
"sync"
"github.com/verstak/verstak-desktop/internal/core/plugin"
)
// Registry tracks all contributions registered by plugins.
type Registry struct {
mu sync.RWMutex
views []ContributionView
commands []ContributionCommand
settingsPanels []ContributionSettingsPanel
sidebarItems []ContributionSidebarItem
fileActions []ContributionAction
noteActions []ContributionAction
contextMenus []ContributionContextMenuEntry
searchProviders []ContributionSearchProvider
activityProviders []ContributionActivityProvider
statusBarItems []ContributionStatusBarItem
}
type ContributionView struct {
PluginID string `json:"pluginId"`
Item plugin.ContributionView `json:"item"`
}
type ContributionCommand struct {
PluginID string `json:"pluginId"`
Item plugin.ContributionCommand `json:"item"`
}
type ContributionSettingsPanel struct {
PluginID string `json:"pluginId"`
Item plugin.ContributionSettingsPanel `json:"item"`
}
type ContributionSidebarItem struct {
PluginID string `json:"pluginId"`
Item plugin.ContributionSidebarItem `json:"item"`
}
type ContributionAction struct {
PluginID string `json:"pluginId"`
Item plugin.ContributionAction `json:"item"`
}
type ContributionContextMenuEntry struct {
PluginID string `json:"pluginId"`
Item plugin.ContributionContextMenuEntry `json:"item"`
}
type ContributionSearchProvider struct {
PluginID string `json:"pluginId"`
Item plugin.ContributionSearchProvider `json:"item"`
}
type ContributionActivityProvider struct {
PluginID string `json:"pluginId"`
Item plugin.ContributionActivityProvider `json:"item"`
}
type ContributionStatusBarItem struct {
PluginID string `json:"pluginId"`
Item plugin.ContributionStatusBarItem `json:"item"`
}
// NewRegistry creates a new contribution registry.
func NewRegistry() *Registry {
return &Registry{}
}
// Register adds all contributions from a plugin.
func (r *Registry) Register(pluginID string, c *plugin.Contributions) {
r.mu.Lock()
defer r.mu.Unlock()
for _, item := range c.Views {
r.views = append(r.views, ContributionView{PluginID: pluginID, Item: item})
}
for _, item := range c.Commands {
r.commands = append(r.commands, ContributionCommand{PluginID: pluginID, Item: item})
}
for _, item := range c.SettingsPanels {
r.settingsPanels = append(r.settingsPanels, ContributionSettingsPanel{PluginID: pluginID, Item: item})
}
for _, item := range c.SidebarItems {
r.sidebarItems = append(r.sidebarItems, ContributionSidebarItem{PluginID: pluginID, Item: item})
}
for _, item := range c.FileActions {
r.fileActions = append(r.fileActions, ContributionAction{PluginID: pluginID, Item: item})
}
for _, item := range c.NoteActions {
r.noteActions = append(r.noteActions, ContributionAction{PluginID: pluginID, Item: item})
}
for _, item := range c.ContextMenuEntries {
r.contextMenus = append(r.contextMenus, ContributionContextMenuEntry{PluginID: pluginID, Item: item})
}
for _, item := range c.SearchProviders {
r.searchProviders = append(r.searchProviders, ContributionSearchProvider{PluginID: pluginID, Item: item})
}
for _, item := range c.ActivityProviders {
r.activityProviders = append(r.activityProviders, ContributionActivityProvider{PluginID: pluginID, Item: item})
}
for _, item := range c.StatusBarItems {
r.statusBarItems = append(r.statusBarItems, ContributionStatusBarItem{PluginID: pluginID, Item: item})
}
}
// Unregister removes all contributions from a plugin.
func (r *Registry) Unregister(pluginID string) {
r.mu.Lock()
defer r.mu.Unlock()
r.views = removeViews(r.views, pluginID)
r.commands = removeCommands(r.commands, pluginID)
r.settingsPanels = removeSettingsPanels(r.settingsPanels, pluginID)
r.sidebarItems = removeSidebarItems(r.sidebarItems, pluginID)
r.fileActions = removeActions(r.fileActions, pluginID)
r.noteActions = removeActions(r.noteActions, pluginID)
r.contextMenus = removeContextMenus(r.contextMenus, pluginID)
r.searchProviders = removeSearchProviders(r.searchProviders, pluginID)
r.activityProviders = removeActivityProviders(r.activityProviders, pluginID)
r.statusBarItems = removeStatusBarItems(r.statusBarItems, pluginID)
}
// Getters — sorted for deterministic display.
func (r *Registry) Views() []ContributionView {
r.mu.RLock()
defer r.mu.RUnlock()
result := make([]ContributionView, len(r.views))
copy(result, r.views)
sort.Slice(result, func(i, j int) bool { return result[i].Item.ID < result[j].Item.ID })
return result
}
func (r *Registry) Commands() []ContributionCommand {
r.mu.RLock()
defer r.mu.RUnlock()
result := make([]ContributionCommand, len(r.commands))
copy(result, r.commands)
sort.Slice(result, func(i, j int) bool { return result[i].Item.ID < result[j].Item.ID })
return result
}
func (r *Registry) SettingsPanels() []ContributionSettingsPanel {
r.mu.RLock()
defer r.mu.RUnlock()
result := make([]ContributionSettingsPanel, len(r.settingsPanels))
copy(result, r.settingsPanels)
sort.Slice(result, func(i, j int) bool { return result[i].Item.ID < result[j].Item.ID })
return result
}
func (r *Registry) SidebarItems() []ContributionSidebarItem {
r.mu.RLock()
defer r.mu.RUnlock()
result := make([]ContributionSidebarItem, len(r.sidebarItems))
copy(result, r.sidebarItems)
sort.Slice(result, func(i, j int) bool { return result[i].Item.ID < result[j].Item.ID })
return result
}
func (r *Registry) FileActions() []ContributionAction {
r.mu.RLock()
defer r.mu.RUnlock()
result := make([]ContributionAction, len(r.fileActions))
copy(result, r.fileActions)
sort.Slice(result, func(i, j int) bool { return result[i].Item.ID < result[j].Item.ID })
return result
}
func (r *Registry) NoteActions() []ContributionAction {
r.mu.RLock()
defer r.mu.RUnlock()
result := make([]ContributionAction, len(r.noteActions))
copy(result, r.noteActions)
sort.Slice(result, func(i, j int) bool { return result[i].Item.ID < result[j].Item.ID })
return result
}
func (r *Registry) SearchProviders() []ContributionSearchProvider {
r.mu.RLock()
defer r.mu.RUnlock()
result := make([]ContributionSearchProvider, len(r.searchProviders))
copy(result, r.searchProviders)
sort.Slice(result, func(i, j int) bool { return result[i].Item.ID < result[j].Item.ID })
return result
}
// ─── Remove helpers ─────────────────────────────────────────
func removeViews(items []ContributionView, pluginID string) []ContributionView {
var result []ContributionView
for _, item := range items {
if item.PluginID != pluginID {
result = append(result, item)
}
}
return result
}
func removeCommands(items []ContributionCommand, pluginID string) []ContributionCommand {
var result []ContributionCommand
for _, item := range items {
if item.PluginID != pluginID {
result = append(result, item)
}
}
return result
}
func removeSettingsPanels(items []ContributionSettingsPanel, pluginID string) []ContributionSettingsPanel {
var result []ContributionSettingsPanel
for _, item := range items {
if item.PluginID != pluginID {
result = append(result, item)
}
}
return result
}
func removeSidebarItems(items []ContributionSidebarItem, pluginID string) []ContributionSidebarItem {
var result []ContributionSidebarItem
for _, item := range items {
if item.PluginID != pluginID {
result = append(result, item)
}
}
return result
}
func removeActions(items []ContributionAction, pluginID string) []ContributionAction {
var result []ContributionAction
for _, item := range items {
if item.PluginID != pluginID {
result = append(result, item)
}
}
return result
}
func removeContextMenus(items []ContributionContextMenuEntry, pluginID string) []ContributionContextMenuEntry {
var result []ContributionContextMenuEntry
for _, item := range items {
if item.PluginID != pluginID {
result = append(result, item)
}
}
return result
}
func removeSearchProviders(items []ContributionSearchProvider, pluginID string) []ContributionSearchProvider {
var result []ContributionSearchProvider
for _, item := range items {
if item.PluginID != pluginID {
result = append(result, item)
}
}
return result
}
func removeActivityProviders(items []ContributionActivityProvider, pluginID string) []ContributionActivityProvider {
var result []ContributionActivityProvider
for _, item := range items {
if item.PluginID != pluginID {
result = append(result, item)
}
}
return result
}
func removeStatusBarItems(items []ContributionStatusBarItem, pluginID string) []ContributionStatusBarItem {
var result []ContributionStatusBarItem
for _, item := range items {
if item.PluginID != pluginID {
result = append(result, item)
}
}
return result
}
+59
View File
@@ -0,0 +1,59 @@
// Package events provides an in-process event bus for plugin and core communication.
package events
import (
"sync"
)
// Handler is a function that processes an event.
type Handler func(event Event)
// Event represents a named event with a payload.
type Event struct {
Name string `json:"name"`
Timestamp string `json:"timestamp"`
Payload interface{} `json:"payload"`
}
// Bus is a simple in-process event bus.
type Bus struct {
mu sync.RWMutex
handlers map[string][]Handler
}
// NewBus creates a new event bus.
func NewBus() *Bus {
return &Bus{
handlers: make(map[string][]Handler),
}
}
// Subscribe registers a handler for an event name.
func (b *Bus) Subscribe(event string, handler Handler) {
b.mu.Lock()
defer b.mu.Unlock()
b.handlers[event] = append(b.handlers[event], handler)
}
// Unsubscribe removes all handlers for a plugin (matched by prefix or exact).
// For now, a simple version: clear all handlers for a given event name.
func (b *Bus) Unsubscribe(event string) {
b.mu.Lock()
defer b.mu.Unlock()
delete(b.handlers, event)
}
// Publish dispatches an event to all registered handlers.
func (b *Bus) Publish(event Event) {
b.mu.RLock()
handlers, ok := b.handlers[event.Name]
b.mu.RUnlock()
if !ok {
return
}
for _, handler := range handlers {
handler(event)
}
}
+81
View File
@@ -0,0 +1,81 @@
// Package permissions provides a registry for plugin permissions.
package permissions
import (
"sort"
"sync"
)
// Entry describes a known permission.
type Entry struct {
Name string `json:"name"`
Description string `json:"description"`
Dangerous bool `json:"dangerous"`
}
// Registry tracks known permissions and their safety levels.
type Registry struct {
mu sync.RWMutex
permissions map[string]Entry
}
// NewRegistry creates a new permissions registry.
func NewRegistry() *Registry {
r := &Registry{
permissions: make(map[string]Entry),
}
r.registerDefaults()
return r
}
func (r *Registry) registerDefaults() {
defaults := []Entry{
{Name: "vault.read", Description: "Read vault files and metadata", Dangerous: false},
{Name: "vault.write", Description: "Write vault files and metadata", Dangerous: true},
{Name: "vault.watch", Description: "Watch vault file changes", Dangerous: false},
{Name: "storage.namespace", Description: "Read/write plugin's own storage namespace", Dangerous: false},
{Name: "storage.migrations", Description: "Run database migrations in plugin namespace", Dangerous: false},
{Name: "events.publish", Description: "Publish events to the event bus", Dangerous: false},
{Name: "events.subscribe", Description: "Subscribe to events on the event bus", Dangerous: false},
{Name: "ui.register", Description: "Register UI components and contributions", Dangerous: false},
{Name: "commands.register", Description: "Register command palette commands", Dangerous: false},
{Name: "network.local", Description: "Connect to localhost network services", Dangerous: false},
{Name: "network.remote", Description: "Connect to remote network services", Dangerous: true},
{Name: "process.spawn", Description: "Spawn external processes", Dangerous: true},
{Name: "secrets.read", Description: "Read secrets from the secret store", Dangerous: true},
{Name: "secrets.write", Description: "Write secrets to the secret store", Dangerous: true},
{Name: "sync.participate", Description: "Participate in vault sync", Dangerous: true},
}
for _, e := range defaults {
r.permissions[e.Name] = e
}
}
// Get returns permission info by name.
func (r *Registry) Get(name string) (Entry, bool) {
r.mu.RLock()
defer r.mu.RUnlock()
e, ok := r.permissions[name]
return e, ok
}
// IsDangerous checks if a permission is marked dangerous.
func (r *Registry) IsDangerous(name string) bool {
e, ok := r.Get(name)
return ok && e.Dangerous
}
// List returns all known permissions, sorted by name.
func (r *Registry) List() []Entry {
r.mu.RLock()
defer r.mu.RUnlock()
entries := make([]Entry, 0, len(r.permissions))
for _, e := range r.permissions {
entries = append(entries, e)
}
sort.Slice(entries, func(i, j int) bool {
return entries[i].Name < entries[j].Name
})
return entries
}
+304
View File
@@ -0,0 +1,304 @@
// Package plugin provides plugin discovery, manifest parsing, and lifecycle management.
package plugin
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
)
// Manifest represents a Verstak plugin.json manifest.
type Manifest struct {
SchemaVersion int `json:"schemaVersion"`
ID string `json:"id"`
Name string `json:"name"`
Version string `json:"version"`
APIVersion string `json:"apiVersion"`
Description string `json:"description,omitempty"`
Source string `json:"source,omitempty"`
Icon string `json:"icon,omitempty"`
Provides []string `json:"provides"`
Requires []string `json:"requires,omitempty"`
OptionalRequires []string `json:"optionalRequires,omitempty"`
Permissions []string `json:"permissions"`
Frontend *FrontendConfig `json:"frontend,omitempty"`
Backend *BackendConfig `json:"backend,omitempty"`
Migrations *MigrationConfig `json:"migrations,omitempty"`
Contributes *Contributions `json:"contributes,omitempty"`
Sync *SyncConfig `json:"sync,omitempty"`
}
// FrontendConfig describes the plugin's frontend bundle.
type FrontendConfig struct {
Entry string `json:"entry"`
Style string `json:"style,omitempty"`
}
// BackendConfig describes the plugin's backend sidecar.
type BackendConfig struct {
Type string `json:"type"`
Entry map[string]string `json:"entry"`
HealthCheck *HealthCheckConfig `json:"healthCheck,omitempty"`
}
// HealthCheckConfig describes sidecar health check.
type HealthCheckConfig struct {
Type string `json:"type,omitempty"`
Timeout int `json:"timeout,omitempty"`
}
// MigrationConfig describes DB migrations.
type MigrationConfig struct {
Path string `json:"path,omitempty"`
}
// Contributions describes UI and action contributions.
type Contributions struct {
Views []ContributionView `json:"views,omitempty"`
Commands []ContributionCommand `json:"commands,omitempty"`
SettingsPanels []ContributionSettingsPanel `json:"settingsPanels,omitempty"`
SidebarItems []ContributionSidebarItem `json:"sidebarItems,omitempty"`
FileActions []ContributionAction `json:"fileActions,omitempty"`
NoteActions []ContributionAction `json:"noteActions,omitempty"`
ContextMenuEntries []ContributionContextMenuEntry `json:"contextMenuEntries,omitempty"`
SearchProviders []ContributionSearchProvider `json:"searchProviders,omitempty"`
ActivityProviders []ContributionActivityProvider `json:"activityProviders,omitempty"`
StatusBarItems []ContributionStatusBarItem `json:"statusBarItems,omitempty"`
}
// ContributionView represents a view contribution.
type ContributionView struct {
ID string `json:"id"`
Title string `json:"title"`
Icon string `json:"icon,omitempty"`
Component string `json:"component"`
}
// ContributionCommand represents a command palette command.
type ContributionCommand struct {
ID string `json:"id"`
Title string `json:"title"`
Keybinding string `json:"keybinding,omitempty"`
Icon string `json:"icon,omitempty"`
Handler string `json:"handler,omitempty"`
}
// ContributionSettingsPanel represents a settings panel.
type ContributionSettingsPanel struct {
ID string `json:"id"`
Title string `json:"title"`
Component string `json:"component"`
Icon string `json:"icon,omitempty"`
}
// ContributionSidebarItem represents a sidebar item.
type ContributionSidebarItem struct {
ID string `json:"id"`
Title string `json:"title"`
Icon string `json:"icon,omitempty"`
View string `json:"view"`
Position int `json:"position,omitempty"`
}
// ContributionAction represents a file or note action.
type ContributionAction struct {
ID string `json:"id"`
Label string `json:"label"`
Icon string `json:"icon,omitempty"`
Capability string `json:"capability,omitempty"`
Handler string `json:"handler,omitempty"`
}
// ContributionContextMenuEntry represents a context menu entry.
type ContributionContextMenuEntry struct {
ID string `json:"id"`
Label string `json:"label"`
Context string `json:"context"`
Group string `json:"group,omitempty"`
Capability string `json:"capability,omitempty"`
Handler string `json:"handler,omitempty"`
}
// ContributionSearchProvider represents a search provider.
type ContributionSearchProvider struct {
ID string `json:"id"`
Label string `json:"label"`
Handler string `json:"handler"`
}
// ContributionActivityProvider represents an activity provider.
type ContributionActivityProvider struct {
ID string `json:"id"`
Events []string `json:"events,omitempty"`
Handler string `json:"handler"`
}
// ContributionStatusBarItem represents a status bar item.
type ContributionStatusBarItem struct {
ID string `json:"id"`
Label string `json:"label"`
Position string `json:"position,omitempty"`
Handler string `json:"handler,omitempty"`
}
// SyncConfig describes plugin sync configuration.
type SyncConfig struct {
Namespaces []string `json:"namespaces,omitempty"`
Participate bool `json:"participate,omitempty"`
}
// Status represents the current state of a plugin.
type Status string
const (
StatusDiscovered Status = "discovered"
StatusDisabled Status = "disabled"
StatusLoading Status = "loading"
StatusLoaded Status = "loaded"
StatusDegraded Status = "degraded"
StatusFailed Status = "failed"
StatusIncompatible Status = "incompatible"
StatusMissingRequiredCapability Status = "missing-required-capability"
)
// Plugin represents a loaded plugin instance.
type Plugin struct {
Manifest Manifest `json:"manifest"`
Status Status `json:"status"`
Error string `json:"error,omitempty"`
Enabled bool `json:"enabled"`
RootPath string `json:"rootPath"`
}
// validationErrors tracks manifest validation issues.
type validationErrors struct {
errors []string
}
func (v *validationErrors) add(format string, args ...interface{}) {
v.errors = append(v.errors, fmt.Sprintf(format, args...))
}
// ValidateManifest checks a manifest for required fields and valid values.
func ValidateManifest(m *Manifest) []string {
var errs validationErrors
if m.SchemaVersion != 1 {
errs.add("schemaVersion must be 1, got %d", m.SchemaVersion)
}
if m.ID == "" {
errs.add("id is required")
} else if !isValidPluginID(m.ID) {
errs.add("id %q must match pattern: alphanumeric, dots, hyphens", m.ID)
}
if m.Name == "" {
errs.add("name is required")
}
if m.Version == "" {
errs.add("version is required")
}
if m.APIVersion == "" {
errs.add("apiVersion is required")
}
if len(m.Provides) == 0 {
errs.add("provides must have at least one capability")
}
if len(m.Permissions) == 0 {
errs.add("permissions must have at least one permission")
}
return errs.errors
}
func isValidPluginID(id string) bool {
if id == "" {
return false
}
for _, r := range id {
if !isAllowedInID(r) {
return false
}
}
return true
}
func isAllowedInID(r rune) bool {
return (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') ||
(r >= '0' && r <= '9') || r == '.' || r == '-'
}
// ─── Discovery ──────────────────────────────────────────────
// DiscoverPlugins scans the given directories for plugin.json manifests.
func DiscoverPlugins(dirs []string) ([]Plugin, []error) {
var plugins []Plugin
var errs []error
seen := make(map[string]bool)
for _, dir := range dirs {
entries, err := os.ReadDir(dir)
if os.IsNotExist(err) {
continue
}
if err != nil {
errs = append(errs, fmt.Errorf("reading plugin directory %s: %w", dir, err))
continue
}
for _, entry := range entries {
if !entry.IsDir() {
continue
}
pluginDir := filepath.Join(dir, entry.Name())
manifestPath := filepath.Join(pluginDir, "plugin.json")
if _, err := os.Stat(manifestPath); os.IsNotExist(err) {
continue
}
plugin, err := loadPlugin(pluginDir)
if err != nil {
errs = append(errs, fmt.Errorf("plugin %s: %w", entry.Name(), err))
continue
}
if seen[plugin.Manifest.ID] {
errs = append(errs, fmt.Errorf("duplicate plugin ID %q in %s", plugin.Manifest.ID, pluginDir))
continue
}
seen[plugin.Manifest.ID] = true
plugins = append(plugins, plugin)
}
}
return plugins, errs
}
// loadPlugin reads and validates a plugin from its directory.
func loadPlugin(pluginDir string) (Plugin, error) {
manifestPath := filepath.Join(pluginDir, "plugin.json")
data, err := os.ReadFile(manifestPath)
if err != nil {
return Plugin{}, fmt.Errorf("reading manifest: %w", err)
}
var m Manifest
if err := json.Unmarshal(data, &m); err != nil {
return Plugin{}, fmt.Errorf("parsing manifest: %w", err)
}
if errs := ValidateManifest(&m); len(errs) > 0 {
return Plugin{}, fmt.Errorf("invalid manifest: %s", strings.Join(errs, "; "))
}
return Plugin{
Manifest: m,
Status: StatusDiscovered,
Enabled: true,
RootPath: pluginDir,
}, nil
}