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:
2026-06-07 15:28:37 +08:00
parent b80941f908
commit e99ff984b1
10 changed files with 1358 additions and 161 deletions
+203 -14
View File
@@ -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)
+15 -3
View File
@@ -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()
+15 -5
View File
@@ -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)
}