verstak-desktop/internal/core/plugin/plugin.go

378 lines
12 KiB
Go

// Package plugin provides plugin discovery, manifest parsing, and lifecycle management.
package plugin
import (
"encoding/json"
"fmt"
"log"
"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"`
OpenProviders []ContributionOpenProvider `json:"openProviders,omitempty"`
WorkspaceItems []ContributionWorkspaceItem `json:"workspaceItems,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"`
}
// OpenProviderSupport describes a resource shape an open provider can handle.
type OpenProviderSupport struct {
Kind string `json:"kind"`
Mime []string `json:"mime,omitempty"`
Extensions []string `json:"extensions,omitempty"`
Contexts []string `json:"contexts,omitempty"`
}
// ContributionOpenProvider represents an editor/viewer provider contribution.
type ContributionOpenProvider struct {
ID string `json:"id"`
Title string `json:"title"`
Priority int `json:"priority,omitempty"`
Component string `json:"component"`
Supports []OpenProviderSupport `json:"supports"`
}
// ContributionWorkspaceItem represents a workspace tool contribution.
type ContributionWorkspaceItem struct {
ID string `json:"id"`
Title string `json:"title"`
Icon string `json:"icon,omitempty"`
Component string `json:"component"`
}
// 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")
}
if m.Contributes != nil {
for i, provider := range m.Contributes.OpenProviders {
if provider.ID == "" {
errs.add("contributes.openProviders[%d].id is required", i)
}
if provider.Title == "" {
errs.add("contributes.openProviders[%d].title is required", i)
}
if provider.Component == "" {
errs.add("contributes.openProviders[%d].component is required", i)
}
if len(provider.Supports) == 0 {
errs.add("contributes.openProviders[%d].supports must have at least one entry", i)
}
for j, support := range provider.Supports {
if support.Kind == "" {
errs.add("contributes.openProviders[%d].supports[%d].kind is required", i, j)
}
}
}
}
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 ──────────────────────────────────────────────
// FormatDiscoverySummary returns a human-readable summary of discovered plugins.
func FormatDiscoverySummary(plugins []Plugin) string {
if len(plugins) == 0 {
return "no plugins found"
}
ids := make([]string, 0, len(plugins))
for _, p := range plugins {
ids = append(ids, p.Manifest.ID+"@"+p.Manifest.Version)
}
return fmt.Sprintf("%d plugin(s): %s", len(plugins), strings.Join(ids, ", "))
}
// 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]string)
log.Printf("[discovery] start: %d dir(s): %v", len(dirs), dirs)
for _, dir := range dirs {
entries, err := os.ReadDir(dir)
if os.IsNotExist(err) {
log.Printf("[discovery] dir %q: does not exist (skip)", dir)
continue
}
if err != nil {
errs = append(errs, fmt.Errorf("reading plugin directory %s: %w", dir, err))
log.Printf("[discovery] dir %q: error: %v", dir, err)
continue
}
log.Printf("[discovery] dir %q: %d entries", dir, len(entries))
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) {
log.Printf("[discovery] %s: no plugin.json (skip)", entry.Name())
continue
}
plugin, err := loadPlugin(pluginDir)
if err != nil {
errs = append(errs, fmt.Errorf("plugin %s: %w", entry.Name(), err))
log.Printf("[discovery] %s: load error: %v", entry.Name(), err)
continue
}
if existingPath, ok := seen[plugin.Manifest.ID]; ok {
errs = append(errs, fmt.Errorf("duplicate plugin ID %q in %s (already loaded from %s); first plugin wins", plugin.Manifest.ID, pluginDir, existingPath))
log.Printf("[discovery] %s: duplicate ID %q in %s (already loaded from %s; skip)", entry.Name(), plugin.Manifest.ID, pluginDir, existingPath)
continue
}
seen[plugin.Manifest.ID] = pluginDir
plugins = append(plugins, plugin)
log.Printf("[discovery] %s: ✅ %s@%s", entry.Name(), plugin.Manifest.ID, plugin.Manifest.Version)
}
}
log.Printf("[discovery] end: %d plugin(s) found, %d error(s)", len(plugins), len(errs))
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
}