verstak/internal/core/plugins/manager.go

577 lines
15 KiB
Go

package plugins
import (
"encoding/json"
"fmt"
"log"
"os"
"path/filepath"
lua "github.com/yuin/gopher-lua"
"verstak/internal/core/config"
)
// Meta is the plugin.json descriptor.
type Meta struct {
Name string `json:"name"`
Version string `json:"version"`
Description string `json:"description,omitempty"`
Author string `json:"author,omitempty"`
Hooks map[string]string `json:"hooks,omitempty"`
UI *UIContrib `json:"ui,omitempty"`
Background []BackgroundTask `json:"background_tasks,omitempty"`
NodeTypes []string `json:"node_types,omitempty"`
Panel string `json:"panel,omitempty"`
Templates []string `json:"templates,omitempty"`
Migrations []string `json:"migrations,omitempty"`
}
// UIContrib describes UI contributions from a plugin.
type UIContrib struct {
SidebarItems []SidebarItem `json:"sidebar_items,omitempty"`
NodeTabs []NodeTab `json:"node_tabs,omitempty"`
NodeActions []NodeAction `json:"node_actions,omitempty"`
SettingsPages []SettingsPage `json:"settings_pages,omitempty"`
}
// SidebarItem is a navigation item in the sidebar.
type SidebarItem struct {
ID string `json:"id"`
Label string `json:"label"`
Icon string `json:"icon,omitempty"`
Page string `json:"page"`
}
// NodeTab is an extra tab in the node detail view.
type NodeTab struct {
ID string `json:"id"`
Label string `json:"label"`
Page string `json:"page"`
}
// NodeAction is an action button in the node detail.
type NodeAction struct {
ID string `json:"id"`
Label string `json:"label"`
Icon string `json:"icon,omitempty"`
Page string `json:"page,omitempty"`
}
// SettingsPage is a plugin settings page in the settings dialog.
type SettingsPage struct {
ID string `json:"id"`
Label string `json:"label"`
Page string `json:"page"`
}
// BackgroundTask describes a recurring background task.
type BackgroundTask struct {
ID string `json:"id"`
Interval string `json:"interval"` // e.g. "5m", "1h", "30s"
Script string `json:"script"` // relative path to .lua file
}
// Plugin represents a loaded plugin with its runtime.
type Plugin struct {
Meta Meta
Dir string // absolute path to plugin directory
DataDir string // .verstak/plugins/<name>/data — plugin's own SQLite storage
// Enabled = user wants this plugin running (persisted in config).
// Active = runtime is actually initialized (VM + scheduler exist).
// Transitions: Install → Enable → Activate (runtime) → Deactivate → Disable → Uninstall
Enabled bool
Active bool
Installed bool
HasInstall bool
// Runtime (set after InitRuntime)
vm *LuaVM
scheduler *Scheduler
}
// Manager discovers and loads plugins from .verstak/plugins/.
type Manager struct {
vaultRoot string
plugins []Plugin
// Services exposed to Lua plugin API
Services *CoreServices
}
// NewManager creates a plugin manager for a vault.
func NewManager(vaultRoot string) *Manager {
return &Manager{vaultRoot: vaultRoot}
}
// Discover scans .verstak/plugins/* for plugin.json files.
// Replaces the entire plugin list (idempotent — safe to call multiple times).
// Active/Enabled are always false after Discover — call SyncConfig + InitRuntimes to activate.
func (m *Manager) Discover() {
pluginsDir := filepath.Join(m.vaultRoot, ".verstak", "plugins")
entries, err := os.ReadDir(pluginsDir)
if err != nil {
m.plugins = nil
return // no plugins dir — OK
}
newPlugins := make([]Plugin, 0, len(entries))
for _, e := range entries {
if !e.IsDir() {
continue
}
metaPath := filepath.Join(pluginsDir, e.Name(), "plugin.json")
data, err := os.ReadFile(metaPath)
if err != nil {
continue // no plugin.json — skip
}
var meta Meta
if err := json.Unmarshal(data, &meta); err != nil {
log.Printf("[plugins] %s: invalid plugin.json: %v", e.Name(), err)
continue
}
if meta.Name == "" {
meta.Name = e.Name()
}
dataDir := filepath.Join(pluginsDir, e.Name(), "data")
os.MkdirAll(dataDir, 0o750)
hasInstall := meta.Hooks["on_install"] != ""
if !hasInstall {
log.Printf("[plugins] %s: skipping — no on_install hook (not a managed plugin)", e.Name())
continue
}
newPlugins = append(newPlugins, Plugin{
Meta: meta,
Dir: filepath.Join(pluginsDir, e.Name()),
DataDir: dataDir,
Enabled: false,
Active: false,
Installed: false,
HasInstall: true,
})
}
m.plugins = newPlugins
}
// SyncConfig applies installed and enabled states from AppConfig.
// Call after Discover() and before InitRuntimes().
// Enabled = user wants plugin running. Active = runtime is initialized.
func (m *Manager) SyncConfig(cfg *config.AppConfig) {
if cfg == nil {
return
}
installedSet := make(map[string]bool)
enabledSet := make(map[string]bool)
for _, name := range cfg.InstalledPlugins {
installedSet[name] = true
}
for _, name := range cfg.EnabledPlugins {
enabledSet[name] = true
}
for i := range m.plugins {
m.plugins[i].Installed = installedSet[m.plugins[i].Meta.Name]
m.plugins[i].Enabled = installedSet[m.plugins[i].Meta.Name] && enabledSet[m.plugins[i].Meta.Name]
// Active is NOT set here — it's managed by ActivatePlugin/DeactivatePlugin
}
}
// InitRuntimes creates Lua VMs and schedulers for all enabled plugins.
// Must be called after Discover() and SyncConfig().
func (m *Manager) InitRuntimes() {
for i := range m.plugins {
if !m.plugins[i].Enabled {
continue
}
p := &m.plugins[i]
// Create Lua VM
vm, err := NewLuaVM(p)
if err != nil {
log.Printf("[plugins] %s: failed to create Lua VM: %v", p.Meta.Name, err)
continue
}
p.vm = vm
if m.Services != nil {
vm.SetServices(m.Services)
}
// Load main.lua if it exists
mainPath := filepath.Join(p.Dir, "main.lua")
if _, err := os.Stat(mainPath); err == nil {
if err := vm.LoadScript("main.lua"); err != nil {
log.Printf("[plugins] %s: failed to load main.lua: %v", p.Meta.Name, err)
}
}
// Create scheduler
p.scheduler = NewScheduler(p, vm)
for _, bg := range p.Meta.Background {
if err := p.scheduler.AddTask(bg); err != nil {
log.Printf("[plugins] %s: failed to add task %s: %v", p.Meta.Name, bg.ID, err)
}
}
p.Active = true
}
}
// CallInitHooks calls on_init for all active (runtime-initialized) plugins.
func (m *Manager) CallInitHooks() {
for i := range m.plugins {
if !m.plugins[i].Active {
continue
}
p := &m.plugins[i]
if hookName, ok := p.Meta.Hooks["on_init"]; ok && p.vm != nil {
if err := p.vm.CallHook(hookName); err != nil {
log.Printf("[plugins] %s: on_init error: %v", p.Meta.Name, err)
}
}
}
}
// CallVaultOpenHooks calls on_vault_open for all active plugins.
func (m *Manager) CallVaultOpenHooks(vaultPath string) {
for i := range m.plugins {
if !m.plugins[i].Active {
continue
}
p := &m.plugins[i]
if hookName, ok := p.Meta.Hooks["on_vault_open"]; ok && p.vm != nil {
if err := p.vm.CallHook(hookName, lua.LString(vaultPath)); err != nil {
log.Printf("[plugins] %s: on_vault_open error: %v", p.Meta.Name, err)
}
}
}
}
// StartSchedulers starts background tasks for all active plugins.
func (m *Manager) StartSchedulers() {
for i := range m.plugins {
if !m.plugins[i].Active {
continue
}
p := &m.plugins[i]
if p.scheduler != nil {
p.scheduler.Start()
}
}
}
// StopSchedulers stops all background tasks.
func (m *Manager) StopSchedulers() {
for i := range m.plugins {
p := &m.plugins[i]
if p.scheduler != nil {
p.scheduler.Stop()
p.scheduler = nil
}
}
}
// CallShutdownHooks calls on_shutdown for all active (runtime-initialized) plugins.
func (m *Manager) CallShutdownHooks() {
for i := range m.plugins {
if !m.plugins[i].Active {
continue
}
p := &m.plugins[i]
if hookName, ok := p.Meta.Hooks["on_shutdown"]; ok && p.vm != nil {
if err := p.vm.CallHook(hookName); err != nil {
log.Printf("[plugins] %s: on_shutdown error: %v", p.Meta.Name, err)
}
}
}
}
// CloseRuntimes shuts down all Lua VMs.
func (m *Manager) CloseRuntimes() {
for i := range m.plugins {
p := &m.plugins[i]
if p.vm != nil {
p.vm.Close()
p.vm = nil
}
}
}
// Plugins returns all discovered plugins.
func (m *Manager) Plugins() []Plugin {
return m.plugins
}
// Active returns only active plugins.
func (m *Manager) Active() []Plugin {
var out []Plugin
for _, p := range m.plugins {
if p.Active {
out = append(out, p)
}
}
return out
}
// Templates returns all templates from active plugins.
func (m *Manager) Templates() []TemplateDefinition {
var out []TemplateDefinition
// Plugin templates.
for _, p := range m.Active() {
for _, tmplName := range p.Meta.Templates {
tmplPath := filepath.Join(p.Dir, "templates", tmplName+".json")
data, err := os.ReadFile(tmplPath)
if err != nil {
continue
}
var tmpl TemplateDefinition
if err := json.Unmarshal(data, &tmpl); err != nil {
continue
}
if tmpl.Name == "" {
tmpl.Name = tmplName
}
tmpl.Plugin = p.Meta.Name
out = append(out, tmpl)
}
}
return out
}
// TemplateDefinition describes a predefined tree of nodes.
type TemplateDefinition struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
Icon string `json:"icon,omitempty"`
Plugin string // source plugin name
RootType string `json:"root_type"`
Tree []TreeNode `json:"tree"`
Meta []NodeMeta `json:"meta,omitempty"`
}
// TreeNode is a single item in a template tree.
type TreeNode struct {
Type string `json:"type"`
Title string `json:"title"`
Slug string `json:"slug,omitempty"`
Children []TreeNode `json:"children,omitempty"`
}
// NodeMeta is key-value metadata for the root node.
type NodeMeta struct {
Key string `json:"key"`
Label string `json:"label"`
Type string `json:"type"` // text, url, etc.
}
// Enable marks a plugin as enabled (user wants it running). Plugin must be installed first.
// This does NOT create the runtime — call ActivatePlugin for that.
func (m *Manager) Enable(name string) error {
for i := range m.plugins {
if m.plugins[i].Meta.Name == name {
if !m.plugins[i].Installed {
return fmt.Errorf("plugin %q must be installed first", name)
}
m.plugins[i].Enabled = true
return nil
}
}
return fmt.Errorf("plugin %q not found", name)
}
// Disable marks a plugin as disabled. Does NOT stop the runtime — call DeactivatePlugin for that.
func (m *Manager) Disable(name string) error {
for i := range m.plugins {
if m.plugins[i].Meta.Name == name {
m.plugins[i].Enabled = false
return nil
}
}
return fmt.Errorf("plugin %q not found", name)
}
// IsInstalled returns true if a plugin is marked installed.
func (m *Manager) IsInstalled(name string) bool {
for _, p := range m.plugins {
if p.Meta.Name == name {
return p.Installed
}
}
return false
}
// Install runs a plugin's on_install hook (creates tables, defaults, etc.)
// and marks it as installed in the config.
func (m *Manager) Install(name string) error {
for i := range m.plugins {
if m.plugins[i].Meta.Name != name {
continue
}
p := &m.plugins[i]
if p.Installed {
return fmt.Errorf("plugin %q is already installed", name)
}
hookName := p.Meta.Hooks["on_install"]
if hookName == "" {
return fmt.Errorf("plugin %q does not support install lifecycle", name)
}
// Create a temporary VM to run on_install
vm, err := NewLuaVM(p)
if err != nil {
return fmt.Errorf("create VM: %w", err)
}
defer vm.Close()
if m.Services != nil {
vm.SetServices(m.Services)
}
// Load main.lua so functions are available
mainPath := filepath.Join(p.Dir, "main.lua")
if _, err := os.Stat(mainPath); err == nil {
if err := vm.LoadScript("main.lua"); err != nil {
return fmt.Errorf("load main.lua: %w", err)
}
}
// Call on_install hook
if err := vm.CallHook(hookName); err != nil {
return fmt.Errorf("on_install: %w", err)
}
// Mark installed in config (idempotent — avoid duplicates)
p.Installed = true
appCfg, _ := config.LoadAppConfig()
if appCfg == nil {
appCfg = config.DefaultAppConfig()
}
already := false
for _, n := range appCfg.InstalledPlugins {
if n == name {
already = true
break
}
}
if !already {
appCfg.InstalledPlugins = append(appCfg.InstalledPlugins, name)
}
if err := config.SaveAppConfig(appCfg); err != nil {
return fmt.Errorf("save config: %w", err)
}
return nil
}
return fmt.Errorf("plugin %q not found", name)
}
// Uninstall runs a plugin's on_uninstall hook (drops tables, cleans data),
// disables it first, and removes it from the installed list.
// Does NOT delete plugin files from disk.
func (m *Manager) Uninstall(name string) error {
for i := range m.plugins {
if m.plugins[i].Meta.Name != name {
continue
}
p := &m.plugins[i]
if !p.Installed {
return fmt.Errorf("plugin %q is not installed", name)
}
hookName := p.Meta.Hooks["on_uninstall"]
if hookName == "" {
return fmt.Errorf("plugin %q does not support install lifecycle", name)
}
// First deactivate runtime if active (stop scheduler, call on_shutdown, close VM)
if p.Active {
if p.scheduler != nil {
p.scheduler.Stop()
p.scheduler = nil
}
if hookName, ok := p.Meta.Hooks["on_shutdown"]; ok && p.vm != nil {
_ = p.vm.CallHook(hookName)
}
if p.vm != nil {
p.vm.Close()
p.vm = nil
}
p.Active = false
}
// Create a temporary VM to run on_uninstall
vm, err := NewLuaVM(p)
if err != nil {
return fmt.Errorf("create VM: %w", err)
}
defer vm.Close()
if m.Services != nil {
vm.SetServices(m.Services)
}
mainPath := filepath.Join(p.Dir, "main.lua")
if _, err := os.Stat(mainPath); err == nil {
if err := vm.LoadScript("main.lua"); err != nil {
return fmt.Errorf("load main.lua: %w", err)
}
}
// Call on_uninstall hook
if err := vm.CallHook(hookName); err != nil {
return fmt.Errorf("on_uninstall: %w", err)
}
// Clean plugin data directory
os.RemoveAll(p.DataDir + ".db")
os.MkdirAll(p.DataDir, 0o750)
// Remove from installed list in config
p.Installed = false
p.Enabled = false
appCfg, _ := config.LoadAppConfig()
if appCfg != nil {
var updated []string
for _, n := range appCfg.InstalledPlugins {
if n != name {
updated = append(updated, n)
}
}
appCfg.InstalledPlugins = updated
// Also remove from enabled (can't be enabled if not installed)
var enabled []string
for _, n := range appCfg.EnabledPlugins {
if n != name {
enabled = append(enabled, n)
}
}
appCfg.EnabledPlugins = enabled
config.SaveAppConfig(appCfg)
}
return nil
}
return fmt.Errorf("plugin %q not found", name)
}
// ActiveNames returns names of active plugins.
func (m *Manager) ActiveNames() []string {
var out []string
for _, p := range m.Active() {
out = append(out, p.Meta.Name)
}
return out
}
// MigrationFiles returns paths to SQL migration files from active plugins.
func (m *Manager) MigrationFiles() []string {
var out []string
for _, p := range m.Active() {
for _, mig := range p.Meta.Migrations {
path := filepath.Join(p.Dir, "migrations", mig)
if _, err := os.Stat(path); err == nil {
out = append(out, path)
}
}
}
return out
}