package plugins import ( "encoding/json" "log" "os" "path/filepath" lua "github.com/yuin/gopher-lua" ) // 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 Active 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. func (m *Manager) Discover() { pluginsDir := filepath.Join(m.vaultRoot, ".verstak", "plugins") entries, err := os.ReadDir(pluginsDir) if err != nil { return // no plugins dir — OK } 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) m.plugins = append(m.plugins, Plugin{ Meta: meta, Dir: filepath.Join(pluginsDir, e.Name()), DataDir: dataDir, Active: true, }) } } // InitRuntimes creates Lua VMs and schedulers for all active plugins. // Must be called after Discover() and before using plugins. func (m *Manager) InitRuntimes() { for i := range m.plugins { if !m.plugins[i].Active { 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) p.Active = false 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) } } } } // CallInitHooks calls on_init for all active 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() } } } // CallShutdownHooks calls on_shutdown for all active 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 activates a plugin by name. func (m *Manager) Enable(name string) { for i := range m.plugins { if m.plugins[i].Meta.Name == name { m.plugins[i].Active = true return } } } // Disable deactivates a plugin by name. func (m *Manager) Disable(name string) { for i := range m.plugins { if m.plugins[i].Meta.Name == name { m.plugins[i].Active = false return } } } // 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 }