feat: плагин-система Lua + Calendar reference plugin

- Lua VM runtime: gopher-lua с песочницей, хуки on_init/on_tick/on_shutdown
- API: verstak.node.* / verstak.db.* / verstak.config.* / verstak.state.*
- API: verstak.worklog.* / verstak.activity.* / verstak.file.*
- API: verstak.schedule.* / verstak.http.* / verstak.ui.*
- Менеджер плагинов: жизненный цикл, инициализация, шаблоны
- Scheduler: фоновые задачи с интервалами
- PluginPage.svelte: контейнер для iframe-панелей плагинов
- Calendar plugin: миграция, категории CRUD, события CRUD
- Calendar: расширенный рекарренс (daily/weekly/monthly/yearly)
- Calendar: связь с узлами Верстака, напоминания, HTTP-праздники
- Calendar: Lua-тест-сьют (15 тестов), Go-интеграционный тест
- fix: query_row использует реальные Column() вместо guessColumns
This commit is contained in:
2026-06-07 14:59:46 +08:00
parent 8cbc87cdad
commit b80941f908
40 changed files with 4366 additions and 233 deletions
+76
View File
@@ -0,0 +1,76 @@
package plugins
import (
"fmt"
lua "github.com/yuin/gopher-lua"
)
// registerAPI creates the global `verstak` table with all API modules.
func registerAPI(vm *LuaVM) {
L := vm.L
// Main verstak table
verstak := L.NewTable()
// Sub-modules
verstak.RawSetString("node", registerNodeAPI(vm))
verstak.RawSetString("worklog", registerWorklogAPI(vm))
verstak.RawSetString("db", registerDBAPI(vm))
verstak.RawSetString("ui", registerUIAPI(vm))
verstak.RawSetString("config", registerConfigAPI(vm))
verstak.RawSetString("state", registerStateAPI(vm))
verstak.RawSetString("activity", registerActivityAPI(vm))
verstak.RawSetString("schedule", registerScheduleAPI(vm))
verstak.RawSetString("http", registerHTTPAPI(vm))
verstak.RawSetString("file", registerFileAPI(vm))
// Register as global
L.SetGlobal("verstak", verstak)
}
// luaTableFromMap converts a Go map to a Lua table.
func luaTableFromMap(L *lua.LState, m map[string]interface{}) *lua.LTable {
tbl := L.NewTable()
for k, v := range m {
tbl.RawSetString(k, toLuaValue(L, v))
}
return tbl
}
// luaTableFromList converts a Go slice to a Lua array table.
func luaTableFromList(L *lua.LState, items []map[string]interface{}) *lua.LTable {
tbl := L.NewTable()
for i, item := range items {
tbl.RawSetInt(i+1, luaTableFromMap(L, item))
}
return tbl
}
// toLuaValue converts a Go value to a Lua value.
func toLuaValue(L *lua.LState, v interface{}) lua.LValue {
switch val := v.(type) {
case nil:
return lua.LNil
case string:
return lua.LString(val)
case int:
return lua.LNumber(val)
case int64:
return lua.LNumber(val)
case float64:
return lua.LNumber(val)
case bool:
return lua.LBool(val)
case []interface{}:
tbl := L.NewTable()
for i, item := range val {
tbl.RawSetInt(i+1, toLuaValue(L, item))
}
return tbl
case map[string]interface{}:
return luaTableFromMap(L, val)
default:
return lua.LString(fmt.Sprintf("%v", v))
}
}
+671
View File
@@ -0,0 +1,671 @@
package plugins
import (
"database/sql"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
lua "github.com/yuin/gopher-lua"
// worklog import is only used in the registerWorklogAPI closure
// where the compiler resolves it via the import statement.
"verstak/internal/core/worklog"
_ "github.com/mattn/go-sqlite3"
)
// ---------------------------------------------------------------------------
// verstak.db.* — plugin's own SQLite database
// ---------------------------------------------------------------------------
// pluginDBMu guards the pluginDB cache.
var pluginDBMu sync.Mutex
var pluginDBs = make(map[string]*sql.DB)
// openPluginDB opens (or returns cached) the plugin's private SQLite DB.
func openPluginDB(vm *LuaVM) (*sql.DB, error) {
name := vm.Plugin.Meta.Name
pluginDBMu.Lock()
defer pluginDBMu.Unlock()
if db, ok := pluginDBs[name]; ok {
return db, nil
}
dbPath := filepath.Join(vm.Plugin.DataDir, "data.db")
os.MkdirAll(vm.Plugin.DataDir, 0o750)
db, err := sql.Open("sqlite3", fmt.Sprintf("file:%s?mode=rwc&_journal_mode=WAL", dbPath))
if err != nil {
return nil, fmt.Errorf("open plugin db: %w", err)
}
pluginDBs[name] = db
return db, nil
}
// closePluginDB closes a plugin's DB.
func closePluginDB(name string) {
pluginDBMu.Lock()
defer pluginDBMu.Unlock()
if db, ok := pluginDBs[name]; ok {
db.Close()
delete(pluginDBs, name)
}
}
func registerDBAPI(vm *LuaVM) *lua.LTable {
L := vm.L
tbl := L.NewTable()
tbl.RawSetString("exec", L.NewFunction(func(L *lua.LState) int {
query := L.CheckString(1)
args := collectArgs(L, 2)
db, err := openPluginDB(vm)
if err != nil {
return pushError(L, err)
}
_, err = db.Exec(query, args...)
if err != nil {
return pushError(L, fmt.Errorf("exec: %w", err))
}
L.Push(lua.LBool(true))
return 1
}))
tbl.RawSetString("query", L.NewFunction(func(L *lua.LState) int {
query := L.CheckString(1)
args := collectArgs(L, 2)
db, err := openPluginDB(vm)
if err != nil {
return pushError(L, err)
}
rows, err := db.Query(query, args...)
if err != nil {
return pushError(L, fmt.Errorf("query: %w", err))
}
defer rows.Close()
cols, _ := rows.Columns()
arr := L.NewTable()
rowIdx := 1
for rows.Next() {
vals := make([]interface{}, len(cols))
ptrs := make([]interface{}, len(cols))
for i := range vals {
ptrs[i] = &vals[i]
}
if err := rows.Scan(ptrs...); err != nil {
return pushError(L, fmt.Errorf("scan: %w", err))
}
row := L.NewTable()
for i, col := range cols {
row.RawSetString(col, goValueToLua(L, vals[i]))
}
arr.RawSetInt(rowIdx, row)
rowIdx++
}
L.Push(arr)
return 1
}))
tbl.RawSetString("query_row", L.NewFunction(func(L *lua.LState) int {
query := L.CheckString(1)
args := collectArgs(L, 2)
db, err := openPluginDB(vm)
if err != nil {
return pushError(L, err)
}
rows, err := db.Query(query, args...)
if err != nil {
return pushError(L, fmt.Errorf("query_row: %w", err))
}
defer rows.Close()
if !rows.Next() {
L.Push(lua.LNil)
return 1
}
cols, _ := rows.Columns()
vals := make([]interface{}, len(cols))
ptrs := make([]interface{}, len(cols))
for i := range vals {
ptrs[i] = &vals[i]
}
if err := rows.Scan(ptrs...); err != nil {
return pushError(L, fmt.Errorf("query_row: %w", err))
}
tbl := L.NewTable()
for i, col := range cols {
tbl.RawSetString(col, goValueToLua(L, vals[i]))
}
L.Push(tbl)
return 1
}))
return tbl
}
// collectArgs extracts SQL arguments from Lua varargs starting at pos.
func collectArgs(L *lua.LState, pos int) []interface{} {
var args []interface{}
top := L.GetTop()
for i := pos; i <= top; i++ {
val := L.Get(i)
args = append(args, luaValueToGo(val))
}
return args
}
// guessColumns returns placeholder column names for query_row.
func guessColumns(query string) []string {
upper := strings.ToUpper(strings.TrimSpace(query))
if strings.HasPrefix(upper, "SELECT COUNT") || strings.Contains(upper, "COUNT(") {
// Single-result aggregate
if strings.Contains(upper, "COUNT(DISTINCT") {
return []string{"count"}
}
return []string{"count"}
}
// Default — caller can use array access
return []string{"col1", "col2", "col3", "col4", "col5", "col6", "col7", "col8"}
}
// ---------------------------------------------------------------------------
// verstak.config.* — JSON config per plugin
// ---------------------------------------------------------------------------
func loadPluginConfig(vm *LuaVM) (map[string]interface{}, error) {
cfgPath := filepath.Join(vm.Plugin.DataDir, "config.json")
b, err := os.ReadFile(cfgPath)
if err != nil {
if os.IsNotExist(err) {
return make(map[string]interface{}), nil
}
return nil, err
}
var m map[string]interface{}
if err := json.Unmarshal(b, &m); err != nil {
return nil, err
}
return m, nil
}
func savePluginConfig(vm *LuaVM, m map[string]interface{}) error {
os.MkdirAll(vm.Plugin.DataDir, 0o750)
b, err := json.MarshalIndent(m, "", " ")
if err != nil {
return err
}
cfgPath := filepath.Join(vm.Plugin.DataDir, "config.json")
return os.WriteFile(cfgPath, b, 0o640)
}
func registerConfigAPI(vm *LuaVM) *lua.LTable {
L := vm.L
tbl := L.NewTable()
// state is a map[string]interface{} (in memory, written to disk on set).
cfg, _ := loadPluginConfig(vm)
tbl.RawSetString("get", L.NewFunction(func(L *lua.LState) int {
key := L.CheckString(1)
val, ok := cfg[key]
if !ok {
L.Push(lua.LNil)
return 1
}
L.Push(toLuaValue(L, val))
return 1
}))
tbl.RawSetString("set", L.NewFunction(func(L *lua.LState) int {
key := L.CheckString(1)
val := luaValueToGo(L.Get(2))
cfg[key] = val
if err := savePluginConfig(vm, cfg); err != nil {
return pushError(L, fmt.Errorf("config.save: %w", err))
}
L.Push(lua.LBool(true))
return 1
}))
tbl.RawSetString("all", L.NewFunction(func(L *lua.LState) int {
L.Push(luaTableFromMap(L, cfg))
return 1
}))
return tbl
}
// ---------------------------------------------------------------------------
// verstak.state.* — already implemented (in-memory map per plugin)
// Still present in api_ext.go — see registerStateAPI below.
// ---------------------------------------------------------------------------
func registerStateAPI(vm *LuaVM) *lua.LTable {
L := vm.L
tbl := L.NewTable()
state := make(map[string]interface{})
tbl.RawSetString("get", L.NewFunction(func(L *lua.LState) int {
key := L.CheckString(1)
val, ok := state[key]
if !ok {
L.Push(lua.LNil)
return 1
}
L.Push(toLuaValue(L, val))
return 1
}))
tbl.RawSetString("set", L.NewFunction(func(L *lua.LState) int {
key := L.CheckString(1)
val := L.Get(2)
state[key] = luaValueToGo(val)
return 0
}))
tbl.RawSetString("delete", L.NewFunction(func(L *lua.LState) int {
key := L.CheckString(1)
delete(state, key)
return 0
}))
return tbl
}
// ---------------------------------------------------------------------------
// verstak.ui.* — send events to the frontend
// ---------------------------------------------------------------------------
func registerUIAPI(vm *LuaVM) *lua.LTable {
L := vm.L
tbl := L.NewTable()
tbl.RawSetString("toast", L.NewFunction(func(L *lua.LState) int {
msg := L.CheckString(1)
typ := L.OptString(2, "info")
// TODO: send event to frontend via Wails events
log.Printf("[lua] toast(%s): %s", typ, msg)
return 0
}))
tbl.RawSetString("navigate_to", L.NewFunction(func(L *lua.LState) int {
page := L.CheckString(1)
// TODO: send navigate event to frontend
log.Printf("[lua] navigate_to: %s", page)
return 0
}))
return tbl
}
// ---------------------------------------------------------------------------
// verstak.schedule.* — recurring tasks (uses the Scheduler)
// ---------------------------------------------------------------------------
func registerScheduleAPI(vm *LuaVM) *lua.LTable {
L := vm.L
tbl := L.NewTable()
tbl.RawSetString("every", L.NewFunction(func(L *lua.LState) int {
interval := L.CheckString(1)
callback := L.CheckString(2)
d, err := time.ParseDuration(interval)
if err != nil {
return pushError(L, fmt.Errorf("schedule.every: invalid duration %q: %w", interval, err))
}
if d < time.Second {
return pushError(L, fmt.Errorf("schedule.every: minimum interval is 1s"))
}
go func() {
ticker := time.NewTicker(d)
defer ticker.Stop()
for {
select {
case <-ticker.C:
if err := vm.CallHook(callback); err != nil {
log.Printf("[lua] schedule %q error: %v", callback, err)
}
case <-vm.done:
return
}
}
}()
return 0
}))
return tbl
}
// ---------------------------------------------------------------------------
// verstak.http.* — simple HTTP requests
// ---------------------------------------------------------------------------
func registerHTTPAPI(vm *LuaVM) *lua.LTable {
L := vm.L
tbl := L.NewTable()
tbl.RawSetString("get", L.NewFunction(func(L *lua.LState) int {
url := L.CheckString(1)
headers := L.OptTable(3, L.NewTable())
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return pushError(L, fmt.Errorf("http.get: %w", err))
}
applyHeaders(req, headers)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return pushError(L, fmt.Errorf("http.get: %w", err))
}
defer resp.Body.Close()
return pushResponse(L, resp)
}))
tbl.RawSetString("post", L.NewFunction(func(L *lua.LState) int {
url := L.CheckString(1)
body := L.Get(2)
headers := L.OptTable(3, L.NewTable())
var bodyReader io.Reader
if body != lua.LNil {
bodyReader = strings.NewReader(lua.LVAsString(body))
}
req, err := http.NewRequest("POST", url, bodyReader)
if err != nil {
return pushError(L, fmt.Errorf("http.post: %w", err))
}
if body != lua.LNil {
req.Header.Set("Content-Type", "application/octet-stream")
}
applyHeaders(req, headers)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return pushError(L, fmt.Errorf("http.post: %w", err))
}
defer resp.Body.Close()
return pushResponse(L, resp)
}))
return tbl
}
func applyHeaders(req *http.Request, tbl *lua.LTable) {
tbl.ForEach(func(k, v lua.LValue) {
key := lua.LVAsString(k)
val := lua.LVAsString(v)
if key != "" && val != "" {
req.Header.Set(key, val)
}
})
}
func pushResponse(L *lua.LState, resp *http.Response) int {
body, _ := io.ReadAll(resp.Body)
tbl := L.NewTable()
tbl.RawSetString("status", lua.LNumber(resp.StatusCode))
tbl.RawSetString("body", lua.LString(string(body)))
headers := L.NewTable()
for k, vals := range resp.Header {
headers.RawSetString(k, lua.LString(strings.Join(vals, ", ")))
}
tbl.RawSetString("headers", headers)
L.Push(tbl)
return 1
}
// ---------------------------------------------------------------------------
// verstak.worklog.* — real worklog operations
// ---------------------------------------------------------------------------
func registerWorklogAPI(vm *LuaVM) *lua.LTable {
L := vm.L
tbl := L.NewTable()
svc := vm.Services
// Helper to check if services are available
checkSvc := func() *worklog.Service {
if svc == nil || svc.WorklogSvc == nil {
return nil
}
return svc.WorklogSvc
}
tbl.RawSetString("add", L.NewFunction(func(L *lua.LState) int {
wsvc := checkSvc()
if wsvc == nil {
return pushError(L, fmt.Errorf("worklog service not available"))
}
nodeID := L.CheckString(1)
summary := L.CheckString(2)
minutes := L.CheckInt(3)
details := L.OptString(4, "")
approx := lua.LVAsBool(L.Get(5))
e, err := wsvc.Add(nodeID, summary, details, minutes, approx, false)
if err != nil {
return pushError(L, fmt.Errorf("worklog.add: %w", err))
}
tbl := L.NewTable()
tbl.RawSetString("id", lua.LString(e.ID))
tbl.RawSetString("node_id", lua.LString(e.NodeID))
tbl.RawSetString("summary", lua.LString(e.Summary))
tbl.RawSetString("minutes", lua.LNumber(*e.Minutes))
tbl.RawSetString("date", lua.LString(e.Date))
tbl.RawSetString("created_at", lua.LString(e.CreatedAt.Format(time.RFC3339)))
L.Push(tbl)
return 1
}))
tbl.RawSetString("list", L.NewFunction(func(L *lua.LState) int {
wsvc := checkSvc()
if wsvc == nil {
return pushError(L, fmt.Errorf("worklog service not available"))
}
nodeID := L.CheckString(1)
entries, err := wsvc.ListByNode(nodeID)
if err != nil {
return pushError(L, fmt.Errorf("worklog.list: %w", err))
}
arr := L.NewTable()
for i, e := range entries {
et := L.NewTable()
et.RawSetString("id", lua.LString(e.ID))
et.RawSetString("node_id", lua.LString(e.NodeID))
et.RawSetString("summary", lua.LString(e.Summary))
if e.Minutes != nil {
et.RawSetString("minutes", lua.LNumber(*e.Minutes))
}
et.RawSetString("date", lua.LString(e.Date))
et.RawSetString("created_at", lua.LString(e.CreatedAt.Format(time.RFC3339)))
arr.RawSetInt(i+1, et)
}
L.Push(arr)
return 1
}))
tbl.RawSetString("summary", L.NewFunction(func(L *lua.LState) int {
wsvc := checkSvc()
if wsvc == nil {
return pushError(L, fmt.Errorf("worklog service not available"))
}
nodeID := L.CheckString(1)
entries, err := wsvc.ListByNode(nodeID)
if err != nil {
return pushError(L, fmt.Errorf("worklog.summary: %w", err))
}
total := 0
for _, e := range entries {
if e.Minutes != nil {
total += *e.Minutes
}
}
tbl := L.NewTable()
tbl.RawSetString("total_minutes", lua.LNumber(total))
tbl.RawSetString("count", lua.LNumber(len(entries)))
L.Push(tbl)
return 1
}))
tbl.RawSetString("delete", L.NewFunction(func(L *lua.LState) int {
wsvc := checkSvc()
if wsvc == nil {
return pushError(L, fmt.Errorf("worklog service not available"))
}
id := L.CheckString(1)
if err := wsvc.Delete(id); err != nil {
return pushError(L, fmt.Errorf("worklog.delete: %w", err))
}
L.Push(lua.LBool(true))
return 1
}))
return tbl
}
// ---------------------------------------------------------------------------
// verstak.activity.* — real activity feed operations
// ---------------------------------------------------------------------------
func registerActivityAPI(vm *LuaVM) *lua.LTable {
L := vm.L
tbl := L.NewTable()
svc := vm.Services
tbl.RawSetString("log", L.NewFunction(func(L *lua.LState) int {
if svc == nil || svc.ActivitySvc == nil {
return pushError(L, fmt.Errorf("activity service not available"))
}
eventType := L.CheckString(1)
title := L.CheckString(2)
targetID := L.OptString(3, "")
nodeID := L.OptString(4, "")
err := svc.ActivitySvc.Record(nodeID, "", targetID, "", eventType, title, "")
if err != nil {
return pushError(L, fmt.Errorf("activity.log: %w", err))
}
L.Push(lua.LBool(true))
return 1
}))
tbl.RawSetString("list", L.NewFunction(func(L *lua.LState) int {
if svc == nil || svc.ActivitySvc == nil {
return pushError(L, fmt.Errorf("activity service not available"))
}
limit := L.OptInt(1, 20)
events, err := svc.ActivitySvc.ListRecent(limit, 0)
if err != nil {
return pushError(L, fmt.Errorf("activity.list: %w", err))
}
arr := L.NewTable()
for i, e := range events {
et := L.NewTable()
et.RawSetString("event_type", lua.LString(e.EventType))
et.RawSetString("title", lua.LString(e.Title))
et.RawSetString("target_id", lua.LString(e.TargetID))
et.RawSetString("node_id", lua.LString(e.NodeID))
et.RawSetString("created_at", lua.LString(e.CreatedAt))
arr.RawSetInt(i+1, et)
}
L.Push(arr)
return 1
}))
return tbl
}
// ---------------------------------------------------------------------------
// verstak.file.* — real file operations
// ---------------------------------------------------------------------------
func registerFileAPI(vm *LuaVM) *lua.LTable {
L := vm.L
tbl := L.NewTable()
svc := vm.Services
tbl.RawSetString("list", L.NewFunction(func(L *lua.LState) int {
if svc == nil || svc.FilesSvc == nil {
return pushError(L, fmt.Errorf("file service not available"))
}
nodeID := L.CheckString(1)
records, err := svc.FilesSvc.ListByNode(nodeID)
if err != nil {
return pushError(L, fmt.Errorf("file.list: %w", err))
}
arr := L.NewTable()
for i, r := range records {
ft := L.NewTable()
ft.RawSetString("id", lua.LString(r.ID))
ft.RawSetString("filename", lua.LString(r.Filename))
ft.RawSetString("path", lua.LString(r.Path))
ft.RawSetString("size", lua.LNumber(r.Size))
ft.RawSetString("mime", lua.LString(r.MIME))
ft.RawSetString("missing", lua.LBool(r.Missing))
ft.RawSetString("created_at", lua.LString(r.CreatedAt.Format(time.RFC3339)))
arr.RawSetInt(i+1, ft)
}
L.Push(arr)
return 1
}))
tbl.RawSetString("read", L.NewFunction(func(L *lua.LState) int {
if svc == nil || svc.FilesSvc == nil {
return pushError(L, fmt.Errorf("file service not available"))
}
id := L.CheckString(1)
content, err := svc.FilesSvc.ReadText(id)
if err != nil {
return pushError(L, fmt.Errorf("file.read: %w", err))
}
L.Push(lua.LString(content))
return 1
}))
return tbl
}
// ---------------------------------------------------------------------------
// Helper: goValueToLua converts a Go interface{} (from sql.Scan) to lua.LValue.
// ---------------------------------------------------------------------------
func goValueToLua(L *lua.LState, v interface{}) lua.LValue {
if v == nil {
return lua.LNil
}
switch val := v.(type) {
case []byte:
return lua.LString(string(val))
case string:
return lua.LString(val)
case int64:
return lua.LNumber(val)
case float64:
return lua.LNumber(val)
case bool:
return lua.LBool(val)
case time.Time:
return lua.LString(val.Format(time.RFC3339))
default:
return lua.LString(fmt.Sprintf("%v", val))
}
}
+226
View File
@@ -0,0 +1,226 @@
package plugins
import (
"fmt"
"verstak/internal/core/nodes"
lua "github.com/yuin/gopher-lua"
)
// nodeToTable converts a Node to a Lua table.
func nodeToTable(L *lua.LState, n nodeOrErr) *lua.LTable {
tbl := L.NewTable()
if n.err != nil {
return tbl
}
tbl.RawSetString("id", lua.LString(n.node.ID))
tbl.RawSetString("title", lua.LString(n.node.Title))
tbl.RawSetString("type", lua.LString(n.node.Type))
tbl.RawSetString("slug", lua.LString(n.node.Slug))
tbl.RawSetString("sort_order", lua.LNumber(n.node.SortOrder))
tbl.RawSetString("archived", lua.LBool(n.node.Archived))
tbl.RawSetString("revision", lua.LNumber(n.node.Revision))
tbl.RawSetString("created_at", lua.LString(n.node.CreatedAt.Format(timeFormat)))
tbl.RawSetString("updated_at", lua.LString(n.node.UpdatedAt.Format(timeFormat)))
if n.node.ParentID != nil {
tbl.RawSetString("parent_id", lua.LString(*n.node.ParentID))
}
if n.node.DeletedAt != nil {
tbl.RawSetString("deleted_at", lua.LString(n.node.DeletedAt.Format(timeFormat)))
}
return tbl
}
// registerNodeAPI registers the verstak.node.* API and returns the table.
func registerNodeAPI(vm *LuaVM) *lua.LTable {
svc := vm.Services
if svc == nil || svc.NodeRepo == nil {
// No services available — return empty table but still register it
emptyTbl := vm.L.NewTable()
vm.L.SetGlobal("verstak_node", emptyTbl)
return emptyTbl
}
L := vm.L
tbl := L.NewTable()
// verstak.node.get(id) → table or nil
tbl.RawSetString("get", L.NewFunction(func(L *lua.LState) int {
id := L.CheckString(1)
n, err := svc.NodeRepo.GetActive(id)
if err != nil {
return pushError(L, err)
}
tbl := nodeToTable(L, nodeOrErr{node: n})
L.Push(tbl)
return 1
}))
// verstak.node.list(parent_id) → array of tables
tbl.RawSetString("list", L.NewFunction(func(L *lua.LState) int {
parentID := L.CheckString(1)
children, err := svc.NodeRepo.ListChildren(parentID, false)
if err != nil {
return pushError(L, err)
}
arr := L.NewTable()
for i, n := range children {
tbl := nodeToTable(L, nodeOrErr{node: &n})
arr.RawSetInt(i+1, tbl)
}
L.Push(arr)
return 1
}))
// verstak.node.create(parent_id, title, type) → table
tbl.RawSetString("create", L.NewFunction(func(L *lua.LState) int {
parentID := lua.LNil
if L.GetTop() >= 1 && L.Get(1) != lua.LNil {
parentID = L.Get(1)
}
title := L.CheckString(2)
typ := L.OptString(3, "document")
_ = L.OptString(4, "") // props (reserved)
var pID *string
if parentID != lua.LNil {
s := lua.LVAsString(parentID)
if s != "" {
pID = &s
}
}
n, err := svc.NodeRepo.Create(pID, typ, title, 0, "", "")
if err != nil {
return pushError(L, err)
}
tbl := nodeToTable(L, nodeOrErr{node: n})
L.Push(tbl)
return 1
}))
// verstak.node.update(id, fields) → success/error
tbl.RawSetString("update", L.NewFunction(func(L *lua.LState) int {
id := L.CheckString(1)
fields := L.CheckTable(2)
titleVal := fields.RawGetString("title")
if titleVal != lua.LNil {
if err := svc.NodeRepo.UpdateTitle(id, lua.LVAsString(titleVal)); err != nil {
return pushError(L, err)
}
}
L.Push(lua.LBool(true))
return 1
}))
// verstak.node.delete(id) → success/error
tbl.RawSetString("delete", L.NewFunction(func(L *lua.LState) int {
id := L.CheckString(1)
if err := svc.NodeRepo.SoftDelete(id); err != nil {
return pushError(L, err)
}
L.Push(lua.LBool(true))
return 1
}))
// verstak.node.search(query) → array of tables
tbl.RawSetString("search", L.NewFunction(func(L *lua.LState) int {
query := L.CheckString(1)
limit := L.OptInt(2, 20)
results, err := svc.NodeRepo.Search(query, limit)
if err != nil {
return pushError(L, err)
}
arr := L.NewTable()
for i, n := range results {
tbl := nodeToTable(L, nodeOrErr{node: &n})
arr.RawSetInt(i+1, tbl)
}
L.Push(arr)
return 1
}))
// verstak.node.roots() → array of root-level nodes
tbl.RawSetString("roots", L.NewFunction(func(L *lua.LState) int {
roots, err := svc.NodeRepo.ListRoots(false)
if err != nil {
return pushError(L, err)
}
arr := L.NewTable()
for i, n := range roots {
tbl := nodeToTable(L, nodeOrErr{node: &n})
arr.RawSetInt(i+1, tbl)
}
L.Push(arr)
return 1
}))
// verstak.node.meta — sub-table
metaTbl := L.NewTable()
metaTbl.RawSetString("get", L.NewFunction(func(L *lua.LState) int {
nodeID := L.CheckString(1)
key := L.CheckString(2)
v, ok, err := svc.NodeRepo.MetaGet(nodeID, key)
if err != nil {
return pushError(L, err)
}
if !ok {
L.Push(lua.LNil)
} else {
L.Push(lua.LString(v))
}
return 1
}))
metaTbl.RawSetString("set", L.NewFunction(func(L *lua.LState) int {
nodeID := L.CheckString(1)
key := L.CheckString(2)
value := L.CheckString(3)
if err := svc.NodeRepo.MetaSet(nodeID, key, value); err != nil {
return pushError(L, err)
}
L.Push(lua.LBool(true))
return 1
}))
metaTbl.RawSetString("list", L.NewFunction(func(L *lua.LState) int {
nodeID := L.CheckString(1)
metas, err := svc.NodeRepo.MetaList(nodeID)
if err != nil {
return pushError(L, err)
}
arr := L.NewTable()
for i, m := range metas {
entry := L.NewTable()
entry.RawSetString("key", lua.LString(m.Key))
entry.RawSetString("value", lua.LString(m.Value))
arr.RawSetInt(i+1, entry)
}
L.Push(arr)
return 1
}))
tbl.RawSetString("meta", metaTbl)
// Set the global
L.SetGlobal("verstak_node", tbl)
// Also add to the main verstak table if it exists
mainTbl := L.GetGlobal("verstak")
if mainTbl != lua.LNil {
if tbl2, ok := mainTbl.(*lua.LTable); ok {
tbl2.RawSetString("node", tbl)
}
}
return tbl
}
// nodeOrErr is a helper to keep node + potential error together.
type nodeOrErr struct {
node *nodes.Node
err error
}
// timeFormat for Lua output.
const timeFormat = "2006-01-02T15:04:05Z07:00"
// This function is used in the text below — comment to satisfy unused import.
var _ = fmt.Sprintf
+92
View File
@@ -0,0 +1,92 @@
package plugins
import (
"fmt"
lua "github.com/yuin/gopher-lua"
)
// pushError pushes an error message as a Lua error (using lua.Errorf-style).
// Returns 0 (no return values — the Lua function will error).
func pushError(L *lua.LState, err error) int {
L.RaiseError("%s", err.Error())
return 0 // unreachable, but satisfies signature
}
// pushResult pushes a single value to Lua stack and returns 1.
func pushResult(L *lua.LState, val lua.LValue) int {
L.Push(val)
return 1
}
// pushOK pushes true (success) to Lua stack.
func pushOK(L *lua.LState) int {
L.Push(lua.LBool(true))
return 1
}
// checkOptString gets an optional string argument at position.
func checkOptString(L *lua.LState, pos int, defaultVal string) string {
if L.GetTop() >= pos {
return L.CheckString(pos)
}
return defaultVal
}
// checkOptInt gets an optional int argument at position.
func checkOptInt(L *lua.LState, pos int, defaultVal int) int {
if L.GetTop() >= pos {
return L.CheckInt(pos)
}
return defaultVal
}
// checkOptTable gets an optional table argument at position.
func checkOptTable(L *lua.LState, pos int) *lua.LTable {
if L.GetTop() >= pos && L.Get(pos).Type() == lua.LTTable {
return L.CheckTable(pos)
}
return nil
}
// tableToMap converts a Lua table to map[string]interface{}.
// Only handles string keys and basic value types.
func tableToMap(tbl *lua.LTable) map[string]interface{} {
m := make(map[string]interface{})
tbl.ForEach(func(key lua.LValue, val lua.LValue) {
k := lua.LVAsString(key)
m[k] = luaValueToGo(val)
})
return m
}
// luaValueToGo converts a Lua value to a Go interface{}.
func luaValueToGo(v lua.LValue) interface{} {
if v == lua.LNil {
return nil
}
switch val := v.(type) {
case lua.LBool:
return bool(val)
case lua.LString:
return string(val)
case lua.LNumber:
return float64(val)
case *lua.LTable:
// Detect if it's an array or map
if val.MaxN() > 0 {
arr := make([]interface{}, 0, val.MaxN())
for i := 1; i <= val.MaxN(); i++ {
arr = append(arr, luaValueToGo(val.RawGetInt(i)))
}
return arr
}
m := make(map[string]interface{})
val.ForEach(func(k lua.LValue, v lua.LValue) {
m[fmt.Sprintf("%v", k)] = luaValueToGo(v)
})
return m
default:
return fmt.Sprintf("%v", v)
}
}
+193 -11
View File
@@ -2,33 +2,92 @@ 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"`
Author string `json:"author"`
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"`
}
// Plugin represents a loaded plugin.
// 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
Active bool
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.
@@ -55,16 +114,139 @@ func (m *Manager) Discover() {
}
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()
}
m.plugins = append(m.plugins, Plugin{
Meta: meta,
Dir: filepath.Join(pluginsDir, e.Name()),
Active: true,
})
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
}
}
}
@@ -0,0 +1,92 @@
package plugins
import (
"log"
"os"
"path/filepath"
lua "github.com/yuin/gopher-lua"
)
// ActivatePlugin fully activates a plugin: creates Lua VM, loads main.lua, starts scheduler.
func (m *Manager) ActivatePlugin(name string) {
for i := range m.plugins {
p := &m.plugins[i]
if p.Meta.Name != name || p.Active {
continue
}
p.Active = true
vm, err := NewLuaVM(p)
if err != nil {
log.Printf("[plugins] %s: activate VM error: %v", name, err)
p.Active = false
return
}
p.vm = vm
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: load main.lua: %v", name, err)
}
}
p.scheduler = NewScheduler(p, vm)
for _, bg := range p.Meta.Background {
if err := p.scheduler.AddTask(bg); err != nil {
log.Printf("[plugins] %s: add task %s: %v", name, bg.ID, err)
}
}
p.scheduler.Start()
if hookName, ok := p.Meta.Hooks["on_init"]; ok {
if err := vm.CallHook(hookName); err != nil {
log.Printf("[plugins] %s: on_init error: %v", name, err)
}
}
log.Printf("[plugins] %s: activated", name)
return
}
}
// DeactivatePlugin stops a plugin's runtime without removing it.
func (m *Manager) DeactivatePlugin(name string) {
for i := range m.plugins {
p := &m.plugins[i]
if p.Meta.Name != name || !p.Active {
continue
}
if p.scheduler != nil {
p.scheduler.Stop()
p.scheduler = nil
}
if hookName, ok := p.Meta.Hooks["on_shutdown"]; ok && p.vm != nil {
_ = p.vm.CallHook(hookName)
}
if p.vm != nil {
p.vm.Close()
p.vm = nil
}
p.Active = false
log.Printf("[plugins] %s: deactivated", name)
return
}
}
// CallPluginHook calls a named Lua function on a specific plugin.
func (m *Manager) CallPluginHook(name, hookName string, args ...lua.LValue) (lua.LValue, error) {
for i := range m.plugins {
if m.plugins[i].Meta.Name == name && m.plugins[i].Active && m.plugins[i].vm != nil {
if fn, ok := m.plugins[i].Meta.Hooks[hookName]; ok {
return m.plugins[i].vm.CallHookWithResult(fn, args...)
}
return m.plugins[i].vm.CallHookWithResult(hookName, args...)
}
}
return lua.LNil, nil
}
+209
View File
@@ -0,0 +1,209 @@
package plugins
import (
"context"
"fmt"
"log"
"os"
"path/filepath"
"strings"
"sync"
"time"
"verstak/internal/core/activity"
"verstak/internal/core/files"
"verstak/internal/core/nodes"
"verstak/internal/core/storage"
"verstak/internal/core/worklog"
lua "github.com/yuin/gopher-lua"
)
// CoreServices bundles core Verstak services for use by the Lua plugin API.
type CoreServices struct {
NodeRepo *nodes.Repository
DB *storage.DB
ActivitySvc *activity.Service
WorklogSvc *worklog.Service
FilesSvc *files.Service
VaultPath string
}
// LuaVM wraps a gopher-lua state for a single plugin.
// Each plugin gets its own isolated state; API functions use vm.Services to call core services.
type LuaVM struct {
L *lua.LState
Plugin *Plugin
Services *CoreServices
mu sync.Mutex
done chan struct{}
// sandbox limits
callTimeout time.Duration
}
// NewLuaVM creates a sandboxed Lua VM for a plugin.
func NewLuaVM(p *Plugin) (*LuaVM, error) {
vm := &LuaVM{
Plugin: p,
callTimeout: 30 * time.Second,
done: make(chan struct{}),
}
L := lua.NewState(lua.Options{
SkipOpenLibs: true, // we selectively open safe libs
})
// Open only safe libraries
for _, pair := range []struct {
lib string
fn lua.LGFunction
}{
{lua.LoadLibName, lua.OpenPackage},
{lua.BaseLibName, lua.OpenBase},
{lua.TabLibName, lua.OpenTable},
{lua.StringLibName, lua.OpenString},
{lua.MathLibName, lua.OpenMath},
{lua.OsLibName, lua.OpenOs},
} {
L.Push(L.NewFunction(pair.fn))
L.Push(lua.LString(pair.lib))
L.Call(1, 0)
}
// Disable dangerous functions
for _, name := range []string{"dofile", "loadfile", "require", "module", "rawequal", "rawget", "rawset", "rawlen", "setfenv", "getfenv"} {
L.SetGlobal(name, lua.LNil)
}
// Restrict os.* to safe subset
osSafe := map[string]bool{
"clock": true, "date": true, "difftime": true, "time": true,
"tmpname": true,
}
if osTable := L.GetGlobal("os"); osTable != lua.LNil {
if tbl, ok := osTable.(*lua.LTable); ok {
for _, k := range []string{"execute", "exit", "remove", "rename", "setlocale", "getenv"} {
tbl.RawSetString(k, lua.LNil)
}
// Only keep safe ones
tbl.ForEach(func(k lua.LValue, v lua.LValue) {
if ks, ok := k.(lua.LString); ok && !osSafe[string(ks)] {
tbl.RawSet(k, lua.LNil)
}
})
}
}
// Redirect print to Go log
L.SetGlobal("print", L.NewFunction(func(L *lua.LState) int {
top := L.GetTop()
var parts []string
for i := 1; i <= top; i++ {
parts = append(parts, L.Get(i).String())
}
msg := strings.Join(parts, " ")
log.Printf("[lua] %s", msg)
return 0
}))
vm.L = L
registerAPI(vm)
return vm, nil
}
// LoadScript loads and executes a Lua file from the plugin directory.
func (vm *LuaVM) LoadScript(filename string) error {
path := filepath.Join(vm.Plugin.Dir, filename)
data, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("read %s: %w", filename, err)
}
vm.mu.Lock()
defer vm.mu.Unlock()
fn, err := vm.L.Load(strings.NewReader(string(data)), filename)
if err != nil {
return fmt.Errorf("load %s: %w", filename, err)
}
vm.L.Push(fn)
_, err = vm.callWithTimeout(0)
return err
}
// CallHook calls a Lua function by name with optional args (no return value expected).
func (vm *LuaVM) CallHook(name string, args ...lua.LValue) error {
_, err := vm.CallHookWithResult(name, args...)
return err
}
// CallHookWithResult calls a Lua function by name and returns its first return value.
func (vm *LuaVM) CallHookWithResult(name string, args ...lua.LValue) (lua.LValue, error) {
vm.mu.Lock()
defer vm.mu.Unlock()
fn := vm.L.GetGlobal(name)
if fn == lua.LNil {
return lua.LNil, nil
}
if _, ok := fn.(*lua.LFunction); !ok {
return lua.LNil, fmt.Errorf("%q is not a function", name)
}
vm.L.Push(fn)
for _, arg := range args {
vm.L.Push(arg)
}
return vm.callWithTimeout(len(args))
}
// Close shuts down the Lua VM.
func (vm *LuaVM) Close() {
close(vm.done)
if vm.L != nil && !vm.L.IsClosed() {
vm.L.Close()
vm.L = nil
}
}
// SetServices sets the core services reference on the VM.
func (vm *LuaVM) SetServices(svc *CoreServices) {
vm.Services = svc
}
// callWithTimeout runs a PCall with a timeout and returns the first LValue.
// nargs is the number of function arguments already on the stack.
// Must be called with vm.mu held.
func (vm *LuaVM) callWithTimeout(nargs int) (lua.LValue, error) {
timeout := vm.callTimeout
if timeout <= 0 {
timeout = 30 * time.Second
}
// Create a cancellable context for timeout
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
// Set context so gopher-lua's main loop checks ctx.Done()
vm.L.SetContext(ctx)
err := vm.L.PCall(nargs, lua.MultRet, nil)
// Remove context after call
vm.L.RemoveContext()
// Collect return value (if any)
ret := lua.LNil
if vm.L.GetTop() > 0 {
ret = vm.L.Get(1)
vm.L.Pop(1)
}
if err != nil {
return ret, err
}
// Check if timeout occurred
if ctx.Err() != nil {
return ret, fmt.Errorf("execution timeout (%s)", timeout)
}
return ret, nil
}
+381
View File
@@ -0,0 +1,381 @@
package plugins
import (
"os"
"path/filepath"
"testing"
lua "github.com/yuin/gopher-lua"
)
func TestLuaVM_HookCall(t *testing.T) {
// Create a temp plugin directory
dir := t.TempDir()
pluginDir := filepath.Join(dir, "testplugin")
if err := os.MkdirAll(pluginDir, 0755); err != nil {
t.Fatal(err)
}
// Write a main.lua
mainLua := `
function on_init()
print("hello from lua")
end
function add(a, b)
return a + b
end
`
if err := os.WriteFile(filepath.Join(pluginDir, "main.lua"), []byte(mainLua), 0644); err != nil {
t.Fatal(err)
}
p := &Plugin{
Meta: Meta{
Name: "testplugin",
Hooks: map[string]string{
"on_init": "on_init",
},
},
Dir: pluginDir,
Active: true,
}
vm, err := NewLuaVM(p)
if err != nil {
t.Fatalf("NewLuaVM: %v", err)
}
defer vm.Close()
// Load main.lua
if err := vm.LoadScript("main.lua"); err != nil {
t.Fatalf("LoadScript: %v", err)
}
// Call on_init hook (should not error)
if err := vm.CallHook("on_init"); err != nil {
t.Fatalf("CallHook(on_init): %v", err)
}
// Call a function with args and check result
// Need to use CallHookWithResult for that
result, err := vm.CallHookWithResult("add", lua.LNumber(3), lua.LNumber(4))
if err != nil {
t.Fatalf("CallHookWithResult(add): %v", err)
}
if num, ok := result.(lua.LNumber); !ok {
t.Fatalf("expected LNumber, got %T", result)
} else if float64(num) != 7 {
t.Fatalf("expected 7, got %v", num)
}
}
func TestLuaVM_MissingHook(t *testing.T) {
dir := t.TempDir()
pluginDir := filepath.Join(dir, "nohooks")
if err := os.MkdirAll(pluginDir, 0755); err != nil {
t.Fatal(err)
}
// Write minimal main.lua (no on_shutdown function)
mainLua := `function on_init() end`
if err := os.WriteFile(filepath.Join(pluginDir, "main.lua"), []byte(mainLua), 0644); err != nil {
t.Fatal(err)
}
p := &Plugin{
Meta: Meta{
Name: "nohooks",
Hooks: map[string]string{
"on_shutdown": "on_shutdown",
},
},
Dir: pluginDir,
Active: true,
}
vm, err := NewLuaVM(p)
if err != nil {
t.Fatalf("NewLuaVM: %v", err)
}
defer vm.Close()
if err := vm.LoadScript("main.lua"); err != nil {
t.Fatalf("LoadScript: %v", err)
}
// Calling a missing hook should return nil error (hooks are optional)
if err := vm.CallHook("on_shutdown"); err != nil {
t.Fatalf("CallHook(on_shutdown) on missing function: %v", err)
}
}
func TestLuaVM_Sandbox(t *testing.T) {
dir := t.TempDir()
pluginDir := filepath.Join(dir, "sandbox")
if err := os.MkdirAll(pluginDir, 0755); err != nil {
t.Fatal(err)
}
// Verify dangerous functions are removed
mainLua := `
function test_sandbox()
local ok, err = pcall(function()
return dofile("/etc/passwd")
end)
return ok, err -- dofile should be nil, so pcall returns false + error
end
`
if err := os.WriteFile(filepath.Join(pluginDir, "main.lua"), []byte(mainLua), 0644); err != nil {
t.Fatal(err)
}
p := &Plugin{
Meta: Meta{Name: "sandbox"},
Dir: pluginDir,
Active: true,
}
vm, err := NewLuaVM(p)
if err != nil {
t.Fatalf("NewLuaVM: %v", err)
}
defer vm.Close()
if err := vm.LoadScript("main.lua"); err != nil {
t.Fatalf("LoadScript: %v", err)
}
result, err := vm.CallHookWithResult("test_sandbox")
if err != nil {
t.Fatalf("CallHook: %v", err)
}
t.Logf("sandbox test result: %v", result)
// dofile is nil, so pcall will catch the error and return false
// The hook returns the first value from Lua, which should be false
if result == lua.LTrue {
t.Error("dofile should not be available in sandbox")
}
}
func TestLuaVM_State(t *testing.T) {
dir := t.TempDir()
pluginDir := filepath.Join(dir, "state")
if err := os.MkdirAll(pluginDir, 0755); err != nil {
t.Fatal(err)
}
// Test verstak.state API
mainLua := `
function test_state()
verstak.state.set("counter", 42)
verstak.state.set("name", "test")
local c = verstak.state.get("counter")
local n = verstak.state.get("name")
return c, n
end
`
if err := os.WriteFile(filepath.Join(pluginDir, "main.lua"), []byte(mainLua), 0644); err != nil {
t.Fatal(err)
}
p := &Plugin{
Meta: Meta{Name: "state"},
Dir: pluginDir,
Active: true,
}
vm, err := NewLuaVM(p)
if err != nil {
t.Fatalf("NewLuaVM: %v", err)
}
defer vm.Close()
if err := vm.LoadScript("main.lua"); err != nil {
t.Fatalf("LoadScript: %v", err)
}
_, err = vm.CallHookWithResult("test_state")
if err != nil {
t.Fatalf("CallHook: %v", err)
}
// verstak.state.set/get should work (first return = counter = 42)
// Currently returns "not implemented" stub error, but we verify the path
t.Log("state test completed (stub — will fail if API wasn't callable)")
}
func TestPluginManager_InitRuntimes(t *testing.T) {
dir := t.TempDir()
pluginsDir := filepath.Join(dir, ".verstak", "plugins", "testp")
if err := os.MkdirAll(pluginsDir, 0755); err != nil {
t.Fatal(err)
}
// Write plugin.json
pj := `{
"name": "testp",
"version": "1.0.0",
"hooks": { "on_init": "on_init" }
}`
if err := os.WriteFile(filepath.Join(pluginsDir, "plugin.json"), []byte(pj), 0644); err != nil {
t.Fatal(err)
}
// Write main.lua
if err := os.WriteFile(filepath.Join(pluginsDir, "main.lua"), []byte(`function on_init() print("manager init ok") end`), 0644); err != nil {
t.Fatal(err)
}
mgr := NewManager(dir)
mgr.Discover()
mgr.InitRuntimes()
defer mgr.CloseRuntimes()
if len(mgr.Plugins()) != 1 {
t.Fatalf("expected 1 plugin, got %d", len(mgr.Plugins()))
}
p := mgr.Plugins()[0]
if p.Meta.Name != "testp" {
t.Fatalf("expected testp, got %s", p.Meta.Name)
}
if !p.Active {
t.Fatal("plugin should be active")
}
if p.vm == nil {
t.Fatal("plugin should have Lua VM")
}
// Call on_init through manager
mgr.CallInitHooks()
}
// TestNodeAPI_CreateAndGet verifies that the verstak_node table is present and callable.
func TestNodeAPI_CreateAndGet(t *testing.T) {
dir := t.TempDir()
pluginDir := filepath.Join(dir, "nodetest")
if err := os.MkdirAll(pluginDir, 0755); err != nil {
t.Fatal(err)
}
p := &Plugin{
Meta: Meta{Name: "nodetest"},
Dir: pluginDir,
}
vm, err := NewLuaVM(p)
if err != nil {
t.Fatalf("NewLuaVM: %v", err)
}
defer vm.Close()
// Verify verstak_node global table exists
nodeTable := vm.L.GetGlobal("verstak_node")
if nodeTable == lua.LNil {
t.Fatal("verstak_node is nil (expected table)")
}
if _, ok := nodeTable.(*lua.LTable); !ok {
t.Fatalf("verstak_node type: %T (expected *lua.LTable)", nodeTable)
}
// Run a Lua snippet that calls verstak_node.get — should error since no services
err = vm.L.DoString(`
local ok, err = pcall(verstak_node.get, "fake-id")
assert(not ok, "expected error from verstak_node.get with no services")
`)
if err != nil {
t.Fatalf("Lua snippet: %v", err)
}
// Verify verstak.node table exists inside main verstak table
mainTbl := vm.L.GetGlobal("verstak")
if mainTbl == lua.LNil {
t.Fatal("verstak global table missing")
}
verstakTbl, ok := mainTbl.(*lua.LTable)
if !ok {
t.Fatalf("verstak type: %T", mainTbl)
}
nodeSubTbl := verstakTbl.RawGetString("node")
if nodeSubTbl == lua.LNil {
t.Fatal("verstak.node is nil")
}
t.Log("node API present and callable without crash")
}
// TestCalendarPlugin_LoadAndRun runs the calendar plugin's Lua test suite
// via the LuaVM to verify migration, CRUD, recurrence, and all other features.
func TestCalendarPlugin_LoadAndRun(t *testing.T) {
dir := t.TempDir()
// Copy the calendar plugin from contrib/plugins/calendar to temp dir
calendarSrc := filepath.Join("..", "..", "..", "contrib", "plugins", "calendar")
pluginDir := filepath.Join(dir, "calendar")
if err := copyDir(calendarSrc, pluginDir); err != nil {
t.Fatalf("copy calendar plugin: %v", err)
}
dataDir := filepath.Join(dir, "calendar_data")
if err := os.MkdirAll(dataDir, 0755); err != nil {
t.Fatal(err)
}
p := &Plugin{
Meta: Meta{
Name: "calendar",
Hooks: map[string]string{
"on_init": "on_init",
},
},
Dir: pluginDir,
DataDir: dataDir,
Active: true,
}
vm, err := NewLuaVM(p)
if err != nil {
t.Fatalf("NewLuaVM: %v", err)
}
defer vm.Close()
// Load main.lua
if err := vm.LoadScript("main.lua"); err != nil {
t.Fatalf("LoadScript(main.lua): %v", err)
}
// Run on_init hook — this runs the migration + default categories
if err := vm.CallHook("on_init"); err != nil {
t.Fatalf("CallHook(on_init): %v", err)
}
// Run the Lua test suite
if err := vm.LoadScript("test_calendar.lua"); err != nil {
t.Fatalf("LoadScript(test_calendar.lua): %v", err)
}
t.Log("Calendar plugin: all Lua API tests passed")
}
// copyDir recursively copies src to dst (skips hidden files).
func copyDir(src, dst string) error {
return filepath.Walk(src, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
rel, err := filepath.Rel(src, path)
if err != nil {
return err
}
target := filepath.Join(dst, rel)
if info.IsDir() {
return os.MkdirAll(target, info.Mode())
}
data, err := os.ReadFile(path)
if err != nil {
return err
}
return os.WriteFile(target, data, info.Mode())
})
}
+125
View File
@@ -0,0 +1,125 @@
package plugins
import (
"fmt"
"log"
"sync"
"time"
)
// Task represents a single background task instance.
type Task struct {
ID string
Interval time.Duration
Script string // relative path to .lua file (or "hook:name" for a Lua function)
IsHook bool // if true, Script is a function name to call via CallHook
stopCh chan struct{}
stopped bool
}
// Scheduler manages background tasks for a plugin.
type Scheduler struct {
plugin *Plugin
vm *LuaVM
tasks []*Task
mu sync.Mutex
wg sync.WaitGroup
}
// NewScheduler creates a scheduler for a plugin.
func NewScheduler(p *Plugin, vm *LuaVM) *Scheduler {
return &Scheduler{
plugin: p,
vm: vm,
}
}
// AddTask adds a task from a BackgroundTask definition.
func (s *Scheduler) AddTask(bg BackgroundTask) error {
dur, err := parseDuration(bg.Interval)
if err != nil {
return fmt.Errorf("task %s: %w", bg.ID, err)
}
s.mu.Lock()
defer s.mu.Unlock()
task := &Task{
ID: bg.ID,
Interval: dur,
Script: bg.Script,
stopCh: make(chan struct{}),
}
s.tasks = append(s.tasks, task)
return nil
}
// Start begins all registered tasks.
func (s *Scheduler) Start() {
s.mu.Lock()
defer s.mu.Unlock()
for _, t := range s.tasks {
if t.stopped {
continue
}
s.wg.Add(1)
go s.runTask(t)
}
}
// Stop cancels all running tasks and waits for them to finish.
func (s *Scheduler) Stop() {
s.mu.Lock()
for _, t := range s.tasks {
if !t.stopped {
close(t.stopCh)
t.stopped = true
}
}
s.mu.Unlock()
s.wg.Wait()
}
func (s *Scheduler) runTask(t *Task) {
defer s.wg.Done()
ticker := time.NewTicker(t.Interval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
s.executeTask(t)
case <-t.stopCh:
return
}
}
}
func (s *Scheduler) executeTask(t *Task) {
if s.vm == nil {
return
}
if t.IsHook {
if err := s.vm.CallHook(t.Script); err != nil {
log.Printf("[plugins] task %s/%s hook error: %v", s.plugin.Meta.Name, t.ID, err)
}
return
}
if err := s.vm.LoadScript(t.Script); err != nil {
log.Printf("[plugins] task %s/%s script error: %v", s.plugin.Meta.Name, t.ID, err)
}
}
// parseDuration parses a human-readable interval like "5m", "1h", "30s".
func parseDuration(s string) (time.Duration, error) {
d, err := time.ParseDuration(s)
if err == nil {
return d, nil
}
// Try cron-like or other formats later
return 0, fmt.Errorf("invalid interval %q: use Go duration format (e.g. 5m, 1h, 30s)", s)
}