verstak/internal/core/plugins/manager.go

362 lines
9.1 KiB
Go

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/<name>/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
}