548 lines
14 KiB
Go
548 lines
14 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
|
|
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.
|
|
// Sets Installed=true for all plugins (they need Install call to set up DB).
|
|
// Active is always false after Discover — call SyncConfig or Enable to activate.
|
|
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)
|
|
|
|
hasInstall := meta.Hooks["on_install"] != ""
|
|
if !hasInstall {
|
|
log.Printf("[plugins] %s: skipping — no on_install hook (not a managed plugin)", e.Name())
|
|
continue
|
|
}
|
|
|
|
m.plugins = append(m.plugins, Plugin{
|
|
Meta: meta,
|
|
Dir: filepath.Join(pluginsDir, e.Name()),
|
|
DataDir: dataDir,
|
|
Active: false,
|
|
Installed: false,
|
|
HasInstall: true,
|
|
})
|
|
}
|
|
}
|
|
|
|
// SyncConfig applies installed and enabled states from AppConfig.
|
|
// Call after Discover() and before InitRuntimes().
|
|
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 {
|
|
installed := installedSet[m.plugins[i].Meta.Name]
|
|
m.plugins[i].Installed = installed
|
|
m.plugins[i].Active = installed && enabledSet[m.plugins[i].Meta.Name]
|
|
}
|
|
}
|
|
|
|
// 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. Plugin must be installed first.
|
|
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].Active = true
|
|
return nil
|
|
}
|
|
}
|
|
return fmt.Errorf("plugin %q not found", name)
|
|
}
|
|
|
|
// Disable deactivates a plugin by name.
|
|
func (m *Manager) Disable(name string) error {
|
|
for i := range m.plugins {
|
|
if m.plugins[i].Meta.Name == name {
|
|
m.plugins[i].Active = 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
|
|
p.Installed = true
|
|
appCfg, _ := config.LoadAppConfig()
|
|
if appCfg == nil {
|
|
appCfg = config.DefaultAppConfig()
|
|
}
|
|
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 disable if active
|
|
if p.Active {
|
|
p.Active = false
|
|
}
|
|
|
|
// Close existing runtime if any
|
|
if p.vm != nil {
|
|
p.vm.Close()
|
|
p.vm = nil
|
|
}
|
|
|
|
// 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") // remove SQLite file
|
|
os.MkdirAll(p.DataDir, 0o750) // recreate for future install
|
|
|
|
// Remove from installed list in config
|
|
p.Installed = 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
|
|
}
|