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//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") // Ensure plugins directory exists if err := os.MkdirAll(pluginsDir, 0o750); err != nil { log.Printf("[plugins] failed to create plugins dir: %v", err) m.plugins = nil return } entries, err := os.ReadDir(pluginsDir) if err != nil { m.plugins = nil return } 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 }