feat: plugin install/uninstall lifecycle + UI buttons
- AppConfig: add InstalledPlugins []string - Manager.Discover(): no config dependency, all plugins start inactive - Manager.SyncConfig(): apply installed/enabled state from AppConfig - Manager.Enable(): works for plugins without on_install hook - Manager.Install/Uninstall(): run on_install/on_uninstall hooks - ActivatePlugin: skip if HasInstall && !Installed - ReloadPlugins: Discover → SyncConfig → InitRuntimes - Bindings: InstallPlugin, UninstallPlugin - SettingsPlugins: install/uninstall buttons, toggle only after install - Calendar: migration moved from on_init to on_install, on_uninstall drops tables - Tests: all 12 pass (manager + runtime + calendar)
This commit is contained in:
@@ -2,11 +2,13 @@ package plugins
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
lua "github.com/yuin/gopher-lua"
|
||||
lua "github.com/yuin/gopher-lua"
|
||||
"verstak/internal/core/config"
|
||||
)
|
||||
|
||||
// Meta is the plugin.json descriptor.
|
||||
@@ -75,6 +77,8 @@ type Plugin struct {
|
||||
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
|
||||
@@ -96,6 +100,8 @@ func NewManager(vaultRoot string) *Manager {
|
||||
}
|
||||
|
||||
// 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)
|
||||
@@ -120,15 +126,44 @@ func (m *Manager) Discover() {
|
||||
if meta.Name == "" {
|
||||
meta.Name = e.Name()
|
||||
}
|
||||
dataDir := filepath.Join(pluginsDir, e.Name(), "data")
|
||||
os.MkdirAll(dataDir, 0o750)
|
||||
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,
|
||||
})
|
||||
hasInstall := meta.Hooks["on_install"] != ""
|
||||
|
||||
m.plugins = append(m.plugins, Plugin{
|
||||
Meta: meta,
|
||||
Dir: filepath.Join(pluginsDir, e.Name()),
|
||||
DataDir: dataDir,
|
||||
Active: false,
|
||||
Installed: false,
|
||||
HasInstall: hasInstall,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 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]
|
||||
// Plugins without on_install hook are always "installed"
|
||||
if !m.plugins[i].HasInstall {
|
||||
installed = true
|
||||
}
|
||||
m.plugins[i].Installed = installed
|
||||
m.plugins[i].Active = installed && enabledSet[m.plugins[i].Meta.Name]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -318,23 +353,177 @@ type NodeMeta struct {
|
||||
}
|
||||
|
||||
// Enable activates a plugin by name.
|
||||
func (m *Manager) Enable(name string) {
|
||||
// If the plugin has on_install hook, it must be installed first.
|
||||
// Plugins without on_install hook are always considered "installed".
|
||||
func (m *Manager) Enable(name string) error {
|
||||
for i := range m.plugins {
|
||||
if m.plugins[i].Meta.Name == name {
|
||||
m.plugins[i].Active = true
|
||||
return
|
||||
p := &m.plugins[i]
|
||||
if p.HasInstall && !p.Installed {
|
||||
return fmt.Errorf("plugin %q must be installed first (use Install)", name)
|
||||
}
|
||||
p.Active = true
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("plugin %q not found", name)
|
||||
}
|
||||
|
||||
// Disable deactivates a plugin by name.
|
||||
func (m *Manager) Disable(name string) {
|
||||
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
|
||||
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.
|
||||
|
||||
@@ -9,12 +9,17 @@ import (
|
||||
)
|
||||
|
||||
// ActivatePlugin fully activates a plugin: creates Lua VM, loads main.lua, starts scheduler.
|
||||
// Only works if plugin is installed (if it has on_install hook, must be installed first).
|
||||
func (m *Manager) ActivatePlugin(name string) {
|
||||
for i := range m.plugins {
|
||||
p := &m.plugins[i]
|
||||
if p.Meta.Name != name || p.Active {
|
||||
continue
|
||||
}
|
||||
if p.HasInstall && !p.Installed {
|
||||
log.Printf("[plugins] %s: cannot activate — not installed", name)
|
||||
return
|
||||
}
|
||||
p.Active = true
|
||||
|
||||
vm, err := NewLuaVM(p)
|
||||
|
||||
@@ -78,6 +78,9 @@ func TestDiscover(t *testing.T) {
|
||||
t.Errorf("plugin name = %q", plugins[0].Meta.Name)
|
||||
}
|
||||
|
||||
// Enable plugin to load templates
|
||||
mgr.Enable("client")
|
||||
|
||||
// Templates.
|
||||
tmpls := mgr.Templates()
|
||||
if len(tmpls) != 1 {
|
||||
@@ -118,9 +121,16 @@ func TestEnableDisable(t *testing.T) {
|
||||
t.Fatalf("plugins = %d, want 2", len(mgr.Plugins()))
|
||||
}
|
||||
|
||||
// All active by default.
|
||||
// All inactive by default after Discover.
|
||||
if len(mgr.Active()) != 0 {
|
||||
t.Errorf("active after discover = %d, want 0", len(mgr.Active()))
|
||||
}
|
||||
|
||||
// Enable both.
|
||||
mgr.Enable("a")
|
||||
mgr.Enable("b")
|
||||
if len(mgr.Active()) != 2 {
|
||||
t.Errorf("active = %d, want 2", len(mgr.Active()))
|
||||
t.Errorf("active after enable = %d, want 2", len(mgr.Active()))
|
||||
}
|
||||
|
||||
// Disable one.
|
||||
@@ -132,7 +142,7 @@ func TestEnableDisable(t *testing.T) {
|
||||
// Re-enable.
|
||||
mgr.Enable("a")
|
||||
if len(mgr.Active()) != 2 {
|
||||
t.Errorf("active after enable = %d, want 2", len(mgr.Active()))
|
||||
t.Errorf("active after re-enable = %d, want 2", len(mgr.Active()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,6 +154,8 @@ func TestActiveNames(t *testing.T) {
|
||||
|
||||
mgr := NewManager(root)
|
||||
mgr.Discover()
|
||||
mgr.Enable("p1")
|
||||
mgr.Enable("p2")
|
||||
mgr.Disable("p1")
|
||||
|
||||
names := mgr.ActiveNames()
|
||||
|
||||
@@ -228,6 +228,7 @@ func TestPluginManager_InitRuntimes(t *testing.T) {
|
||||
|
||||
mgr := NewManager(dir)
|
||||
mgr.Discover()
|
||||
mgr.Enable("testp")
|
||||
mgr.InitRuntimes()
|
||||
defer mgr.CloseRuntimes()
|
||||
|
||||
@@ -325,12 +326,16 @@ func TestCalendarPlugin_LoadAndRun(t *testing.T) {
|
||||
Meta: Meta{
|
||||
Name: "calendar",
|
||||
Hooks: map[string]string{
|
||||
"on_init": "on_init",
|
||||
"on_init": "on_init",
|
||||
"on_install": "on_install",
|
||||
"on_uninstall": "on_uninstall",
|
||||
},
|
||||
},
|
||||
Dir: pluginDir,
|
||||
DataDir: dataDir,
|
||||
Active: true,
|
||||
Dir: pluginDir,
|
||||
DataDir: dataDir,
|
||||
Active: true,
|
||||
Installed: true,
|
||||
HasInstall: true,
|
||||
}
|
||||
|
||||
vm, err := NewLuaVM(p)
|
||||
@@ -344,7 +349,12 @@ func TestCalendarPlugin_LoadAndRun(t *testing.T) {
|
||||
t.Fatalf("LoadScript(main.lua): %v", err)
|
||||
}
|
||||
|
||||
// Run on_init hook — this runs the migration + default categories
|
||||
// Run on_install — creates tables + default categories (skipped on re-install)
|
||||
if err := vm.CallHook("on_install"); err != nil {
|
||||
t.Fatalf("CallHook(on_install): %v", err)
|
||||
}
|
||||
|
||||
// Run on_init — registers API + state
|
||||
if err := vm.CallHook("on_init"); err != nil {
|
||||
t.Fatalf("CallHook(on_init): %v", err)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user