diff --git a/docs/docs/Features/Plugins.md b/docs/docs/Features/Plugins.md index fb92953..78f36a2 100644 --- a/docs/docs/Features/Plugins.md +++ b/docs/docs/Features/Plugins.md @@ -200,6 +200,44 @@ matcha.notify("Important!", 5) -- shows for 5 seconds matcha.notify("Quick flash", 0.5) -- shows for half a second ``` +### matcha.store_set(key, value) + +Store a string value persistently for this plugin. Each plugin has its own isolated key/value space, so different plugins cannot read or overwrite each other's keys. + +```lua +matcha.store_set("api_key", "sk-...") +matcha.store_set("last_seen_uid", "12345") +``` + +### matcha.store_get(key) + +Retrieve a previously stored string value, or `nil` if the key does not exist. + +```lua +local key = matcha.store_get("api_key") +if key then + matcha.log("found api key") +end +``` + +### matcha.store_delete(key) + +Remove a key from this plugin's storage. Calling `store_delete` on a key that does not exist is a no-op. + +```lua +matcha.store_delete("api_key") +``` + +### matcha.store_keys() + +Return a 1-indexed table of all keys currently stored by this plugin. Useful for iterating over plugin state on startup. + +```lua +for _, key in ipairs(matcha.store_keys()) do + matcha.log("stored key: " .. key) +end +``` + ## Events ### startup diff --git a/plugin/README.md b/plugin/README.md index f5a47bd..1e00ab9 100644 --- a/plugin/README.md +++ b/plugin/README.md @@ -29,6 +29,10 @@ end) | `matcha.bind_key(key, area, description, callback)` | Register a custom keyboard shortcut for a view area (`"inbox"`, `"email_view"`, `"composer"`) | | `matcha.http(options)` | Make an HTTP request (see below) | | `matcha.prompt(placeholder, callback)` | Open a text input overlay in the composer (see below) | +| `matcha.store_set(key, value)` | Store a string value for this plugin | +| `matcha.store_get(key)` | Retrieve a stored string value, or `nil` | +| `matcha.store_delete(key)` | Delete a stored key for this plugin | +| `matcha.store_keys()` | Return a table of stored keys for this plugin | | `matcha.style(text, opts)` | Wrap `text` in lipgloss styling and return an ANSI-styled string (see below) | ## Hook events @@ -72,6 +76,22 @@ end matcha.log("status: " .. res.status) ``` +## Persistent storage + +Plugins can store string key-value data between sessions. Storage is scoped per plugin and written to `~/.config/matcha/plugins//data.json`. Plugins that need structured values can encode them as strings. + +```lua +local matcha = require("matcha") + +-- Store a value +matcha.store_set("api_key", "sk-...") + +-- Retrieve a value +local key = matcha.store_get("api_key") +``` + +Use `matcha.store_delete("api_key")` to remove a value. `matcha.store_keys()` returns a 1-indexed table of all keys stored by the current plugin. + ## User input prompts `matcha.prompt(placeholder, callback)` opens a text input overlay in the composer. When the user presses Enter, the callback receives their input string. Pressing Esc cancels without calling the callback. diff --git a/plugin/api.go b/plugin/api.go index 987923f..55dabaa 100644 --- a/plugin/api.go +++ b/plugin/api.go @@ -20,6 +20,10 @@ func (m *Manager) registerAPI() { "bind_key": m.luaBindKey, "http": m.luaHTTP, "prompt": m.luaPrompt, + "store_set": m.luaStoreSet, + "store_get": m.luaStoreGet, + "store_delete": m.luaStoreDelete, + "store_keys": m.luaStoreKeys, "style": m.luaStyle, }) @@ -73,6 +77,7 @@ func (m *Manager) luaBindKey(L *lua.LState) int { Area: area, Description: description, Fn: fn, + Plugin: m.currentPlugin, }) default: L.ArgError(2, "invalid area: must be \"inbox\", \"email_view\", or \"composer\"") diff --git a/plugin/api_storage_test.go b/plugin/api_storage_test.go new file mode 100644 index 0000000..83c640e --- /dev/null +++ b/plugin/api_storage_test.go @@ -0,0 +1,214 @@ +package plugin + +import ( + "os" + "path/filepath" + "strings" + "testing" + + lua "github.com/yuin/gopher-lua" +) + +func TestLuaStoreRoundTrip(t *testing.T) { + setTestHome(t) + + m := newTestManager() + defer m.Close() + m.currentPlugin = "test_plugin" + + err := m.state.DoString(` + local matcha = require("matcha") + matcha.store_set("token", "abc123") + result = matcha.store_get("token") + `) + if err != nil { + t.Fatal(err) + } + + if got := m.state.GetGlobal("result"); got.String() != "abc123" { + t.Fatalf("expected abc123, got %q", got.String()) + } +} + +func TestLuaStoreSetWithoutPluginContext(t *testing.T) { + setTestHome(t) + + m := newTestManager() + defer m.Close() + + err := m.state.DoString(` + local matcha = require("matcha") + matcha.store_set("token", "abc123") + `) + if err == nil { + t.Fatal("expected store_set to fail without plugin context") + } + if !strings.Contains(err.Error(), "no plugin context") { + t.Fatalf("expected plugin context error, got %v", err) + } +} + +func TestLuaStorePluginsAreIsolated(t *testing.T) { + setTestHome(t) + + m := newTestManager() + defer m.Close() + + pluginA := writePlugin(t, t.TempDir(), "a.lua", ` + local matcha = require("matcha") + matcha.store_set("shared", "a") + `) + pluginB := writePlugin(t, t.TempDir(), "b.lua", ` + local matcha = require("matcha") + matcha.store_set("shared", "b") + `) + + m.loadPlugin("plugin_a", pluginA) + m.loadPlugin("plugin_b", pluginB) + + storeA, err := newPluginStore("plugin_a") + if err != nil { + t.Fatal(err) + } + storeB, err := newPluginStore("plugin_b") + if err != nil { + t.Fatal(err) + } + + gotA, ok := storeA.Get("shared") + if !ok { + t.Fatal("expected plugin_a key") + } + gotB, ok := storeB.Get("shared") + if !ok { + t.Fatal("expected plugin_b key") + } + if gotA != "a" { + t.Fatalf("expected plugin_a value a, got %q", gotA) + } + if gotB != "b" { + t.Fatalf("expected plugin_b value b, got %q", gotB) + } +} + +func TestLuaStoreHookUsesRegisteredPluginContext(t *testing.T) { + setTestHome(t) + + m := newTestManager() + defer m.Close() + + pluginA := writePlugin(t, t.TempDir(), "a.lua", ` + local matcha = require("matcha") + matcha.on("startup", function() + matcha.store_set("hook", "a") + end) + `) + pluginB := writePlugin(t, t.TempDir(), "b.lua", ` + local matcha = require("matcha") + matcha.on("startup", function() + matcha.store_set("hook", "b") + end) + `) + + m.loadPlugin("plugin_a", pluginA) + m.loadPlugin("plugin_b", pluginB) + m.CallHook(HookStartup) + + assertStoredValue(t, "plugin_a", "hook", "a") + assertStoredValue(t, "plugin_b", "hook", "b") +} + +func TestLuaStoreKeyBindingUsesRegisteredPluginContext(t *testing.T) { + setTestHome(t) + + m := newTestManager() + defer m.Close() + + pluginA := writePlugin(t, t.TempDir(), "a.lua", ` + local matcha = require("matcha") + matcha.bind_key("ctrl+a", "inbox", "A", function() + matcha.store_set("binding", "a") + end) + `) + pluginB := writePlugin(t, t.TempDir(), "b.lua", ` + local matcha = require("matcha") + matcha.bind_key("ctrl+b", "inbox", "B", function() + matcha.store_set("binding", "b") + end) + `) + + m.loadPlugin("plugin_a", pluginA) + m.loadPlugin("plugin_b", pluginB) + + bindings := m.Bindings(StatusInbox) + if len(bindings) != 2 { + t.Fatalf("expected 2 bindings, got %d", len(bindings)) + } + for _, binding := range bindings { + m.CallKeyBinding(binding) + } + + assertStoredValue(t, "plugin_a", "binding", "a") + assertStoredValue(t, "plugin_b", "binding", "b") +} + +func TestLuaStoreKeysAndDelete(t *testing.T) { + setTestHome(t) + + m := newTestManager() + defer m.Close() + m.currentPlugin = "test_plugin" + + err := m.state.DoString(` + local matcha = require("matcha") + matcha.store_set("a", "1") + matcha.store_set("b", "2") + matcha.store_delete("a") + keys = matcha.store_keys() + deleted = matcha.store_get("a") + `) + if err != nil { + t.Fatal(err) + } + + if got := m.state.GetGlobal("deleted"); got != lua.LNil { + t.Fatalf("expected deleted key to be nil, got %v", got) + } + + keys, ok := m.state.GetGlobal("keys").(*lua.LTable) + if !ok { + t.Fatalf("expected keys table") + } + if keys.Len() != 1 { + t.Fatalf("expected 1 key, got %d", keys.Len()) + } + if got := keys.RawGetInt(1); got.String() != "b" { + t.Fatalf("expected remaining key b, got %q", got.String()) + } +} + +func writePlugin(t *testing.T, dir, name, body string) string { + t.Helper() + + path := filepath.Join(dir, name) + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + return path +} + +func assertStoredValue(t *testing.T, pluginName, key, want string) { + t.Helper() + + store, err := newPluginStore(pluginName) + if err != nil { + t.Fatal(err) + } + got, ok := store.Get(key) + if !ok { + t.Fatalf("expected %s key %q", pluginName, key) + } + if got != want { + t.Fatalf("expected %s key %q to be %q, got %q", pluginName, key, want, got) + } +} diff --git a/plugin/hooks.go b/plugin/hooks.go index 58f6916..deb819a 100644 --- a/plugin/hooks.go +++ b/plugin/hooks.go @@ -27,9 +27,14 @@ const ( StatusEmailView = "email_view" ) +type registeredHook struct { + fn *lua.LFunction + plugin string +} + // registerHook adds a callback for the given event. func (m *Manager) registerHook(event string, fn *lua.LFunction) { - m.hooks[event] = append(m.hooks[event], fn) + m.hooks[event] = append(m.hooks[event], registeredHook{fn: fn, plugin: m.currentPlugin}) } // CallHook invokes all callbacks registered for the given event. @@ -39,9 +44,15 @@ func (m *Manager) CallHook(event string, args ...lua.LValue) { return } - for _, fn := range callbacks { + previousPlugin := m.currentPlugin + defer func() { + m.currentPlugin = previousPlugin + }() + + for _, hook := range callbacks { + m.currentPlugin = hook.plugin if err := m.state.CallByParam(lua.P{ - Fn: fn, + Fn: hook.fn, NRet: 0, Protect: true, }, args...); err != nil { @@ -64,9 +75,15 @@ func (m *Manager) CallSendHook(event string, to, cc, subject, accountID string) t.RawSetString("subject", lua.LString(subject)) t.RawSetString("account_id", lua.LString(accountID)) - for _, fn := range callbacks { + previousPlugin := m.currentPlugin + defer func() { + m.currentPlugin = previousPlugin + }() + + for _, hook := range callbacks { + m.currentPlugin = hook.plugin if err := L.CallByParam(lua.P{ - Fn: fn, + Fn: hook.fn, NRet: 0, Protect: true, }, t); err != nil { @@ -82,9 +99,15 @@ func (m *Manager) CallFolderHook(event string, folderName string) { return } - for _, fn := range callbacks { + previousPlugin := m.currentPlugin + defer func() { + m.currentPlugin = previousPlugin + }() + + for _, hook := range callbacks { + m.currentPlugin = hook.plugin if err := m.state.CallByParam(lua.P{ - Fn: fn, + Fn: hook.fn, NRet: 0, Protect: true, }, lua.LString(folderName)); err != nil { @@ -109,9 +132,15 @@ func (m *Manager) CallComposerHook(event string, body, subject, to, cc, bcc stri t.RawSetString("cc", lua.LString(cc)) t.RawSetString("bcc", lua.LString(bcc)) - for _, fn := range callbacks { + previousPlugin := m.currentPlugin + defer func() { + m.currentPlugin = previousPlugin + }() + + for _, hook := range callbacks { + m.currentPlugin = hook.plugin if err := L.CallByParam(lua.P{ - Fn: fn, + Fn: hook.fn, NRet: 0, Protect: true, }, t); err != nil { @@ -158,6 +187,12 @@ func (m *Manager) CallBodyRenderHook(email *lua.LTable, rendered, raw string) st // CallKeyBinding invokes a plugin key binding callback with the given arguments. func (m *Manager) CallKeyBinding(binding KeyBinding, args ...lua.LValue) { + previousPlugin := m.currentPlugin + m.currentPlugin = binding.Plugin + defer func() { + m.currentPlugin = previousPlugin + }() + if err := m.state.CallByParam(lua.P{ Fn: binding.Fn, NRet: 0, diff --git a/plugin/plugin.go b/plugin/plugin.go index 82c2ea6..3527dbc 100644 --- a/plugin/plugin.go +++ b/plugin/plugin.go @@ -15,13 +15,16 @@ type KeyBinding struct { Area string // "inbox", "email_view", or "composer" Description string Fn *lua.LFunction + Plugin string } // Manager manages the Lua VM and loaded plugins. type Manager struct { - state *lua.LState - hooks map[string][]*lua.LFunction - plugins []string + state *lua.LState + hooks map[string][]registeredHook + plugins []string + currentPlugin string + stores map[string]*pluginStore // statuses holds persistent status strings per view area, shown in the UI. statuses map[string]string // pendingNotification is set by matcha.notify() and consumed by the orchestrator. @@ -38,7 +41,7 @@ type Manager struct { // NewManager creates a new plugin manager with a Lua VM. func NewManager() *Manager { m := &Manager{ - hooks: make(map[string][]*lua.LFunction), + hooks: make(map[string][]registeredHook), statuses: make(map[string]string), pendingFields: make(map[string]string), } @@ -100,6 +103,12 @@ func (m *Manager) LoadPlugins() { } func (m *Manager) loadPlugin(name, path string) { + previousPlugin := m.currentPlugin + m.currentPlugin = name + defer func() { + m.currentPlugin = previousPlugin + }() + if err := m.state.DoFile(path); err != nil { log.Printf("plugin %q: load error: %v", name, err) return diff --git a/plugin/storage.go b/plugin/storage.go new file mode 100644 index 0000000..9bd5a94 --- /dev/null +++ b/plugin/storage.go @@ -0,0 +1,220 @@ +package plugin + +import ( + "encoding/json" + "errors" + "io/fs" + "os" + "path/filepath" + "regexp" + "sync" + + lua "github.com/yuin/gopher-lua" +) + +var validPluginStoreName = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`) + +type pluginStore struct { + path string + mu sync.Mutex + data map[string]string +} + +func newPluginStore(pluginName string) (*pluginStore, error) { + if !validPluginStoreName.MatchString(pluginName) { + return nil, errors.New("invalid plugin name for storage") + } + + home, err := os.UserHomeDir() + if err != nil { + return nil, err + } + + dir := filepath.Join(home, ".config", "matcha", "plugins", pluginName) + if err := os.MkdirAll(dir, 0o700); err != nil { + return nil, err + } + + s := &pluginStore{ + path: filepath.Join(dir, "data.json"), + data: map[string]string{}, + } + if err := s.load(); err != nil { + return nil, err + } + return s, nil +} + +func (s *pluginStore) load() error { + raw, err := os.ReadFile(s.path) + if errors.Is(err, fs.ErrNotExist) { + return nil + } + if err != nil { + return err + } + if err := json.Unmarshal(raw, &s.data); err != nil { + return err + } + if s.data == nil { + s.data = map[string]string{} + } + return nil +} + +func (s *pluginStore) flush() error { + raw, err := json.MarshalIndent(s.data, "", " ") + if err != nil { + return err + } + + tmp, err := os.CreateTemp(filepath.Dir(s.path), ".data-*.json") + if err != nil { + return err + } + tmpPath := tmp.Name() + defer os.Remove(tmpPath) + + if _, err := tmp.Write(raw); err != nil { + tmp.Close() + return err + } + if err := os.Chmod(tmpPath, 0o600); err != nil { + tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + return os.Rename(tmpPath, s.path) +} + +func (s *pluginStore) Get(k string) (string, bool) { + s.mu.Lock() + defer s.mu.Unlock() + + v, ok := s.data[k] + return v, ok +} + +func (s *pluginStore) Set(k, v string) error { + s.mu.Lock() + defer s.mu.Unlock() + + s.data[k] = v + return s.flush() +} + +func (s *pluginStore) Delete(k string) error { + s.mu.Lock() + defer s.mu.Unlock() + + delete(s.data, k) + return s.flush() +} + +func (s *pluginStore) Keys() []string { + s.mu.Lock() + defer s.mu.Unlock() + + out := make([]string, 0, len(s.data)) + for k := range s.data { + out = append(out, k) + } + return out +} + +func (m *Manager) currentStore() (*pluginStore, error) { + if m.currentPlugin == "" { + return nil, nil + } + if m.stores == nil { + m.stores = make(map[string]*pluginStore) + } + if s, ok := m.stores[m.currentPlugin]; ok { + return s, nil + } + + s, err := newPluginStore(m.currentPlugin) + if err != nil { + return nil, err + } + m.stores[m.currentPlugin] = s + return s, nil +} + +func (m *Manager) luaStoreSet(L *lua.LState) int { + key := L.CheckString(1) + val := L.CheckString(2) + + s, err := m.currentStore() + if err != nil { + L.RaiseError("store_set: %v", err) + return 0 + } + if s == nil { + L.RaiseError("store_set: no plugin context") + return 0 + } + if err := s.Set(key, val); err != nil { + L.RaiseError("store_set: %v", err) + } + return 0 +} + +func (m *Manager) luaStoreGet(L *lua.LState) int { + key := L.CheckString(1) + + s, err := m.currentStore() + if err != nil { + L.RaiseError("store_get: %v", err) + return 0 + } + if s == nil { + L.Push(lua.LNil) + return 1 + } + if v, ok := s.Get(key); ok { + L.Push(lua.LString(v)) + } else { + L.Push(lua.LNil) + } + return 1 +} + +func (m *Manager) luaStoreDelete(L *lua.LState) int { + key := L.CheckString(1) + + s, err := m.currentStore() + if err != nil { + L.RaiseError("store_delete: %v", err) + return 0 + } + if s == nil { + L.RaiseError("store_delete: no plugin context") + return 0 + } + if err := s.Delete(key); err != nil { + L.RaiseError("store_delete: %v", err) + } + return 0 +} + +func (m *Manager) luaStoreKeys(L *lua.LState) int { + s, err := m.currentStore() + if err != nil { + L.RaiseError("store_keys: %v", err) + return 0 + } + if s == nil { + L.Push(L.NewTable()) + return 1 + } + + t := L.NewTable() + for i, key := range s.Keys() { + t.RawSetInt(i+1, lua.LString(key)) + } + L.Push(t) + return 1 +} diff --git a/plugin/storage_test.go b/plugin/storage_test.go new file mode 100644 index 0000000..b73b435 --- /dev/null +++ b/plugin/storage_test.go @@ -0,0 +1,250 @@ +package plugin + +import ( + "fmt" + "os" + "path/filepath" + "reflect" + "runtime" + "strings" + "sync" + "testing" +) + +// setTestHome makes t.TempDir() the effective home directory for the duration +// of the test on both Unix and Windows. Go's os.UserHomeDir() reads $HOME on +// Unix but %USERPROFILE% on Windows, so we set both. +func setTestHome(t *testing.T) string { + t.Helper() + dir := t.TempDir() + t.Setenv("HOME", dir) + if runtime.GOOS == "windows" { + t.Setenv("USERPROFILE", dir) + } + return dir +} + +func TestPluginStoreSetGet(t *testing.T) { + setTestHome(t) + + store, err := newPluginStore("test_plugin") + if err != nil { + t.Fatal(err) + } + + if err := store.Set("token", "abc123"); err != nil { + t.Fatal(err) + } + + got, ok := store.Get("token") + if !ok { + t.Fatal("expected stored key") + } + if got != "abc123" { + t.Fatalf("expected abc123, got %q", got) + } +} + +func TestPluginStoreDelete(t *testing.T) { + setTestHome(t) + + store, err := newPluginStore("test_plugin") + if err != nil { + t.Fatal(err) + } + if err := store.Set("token", "abc123"); err != nil { + t.Fatal(err) + } + if err := store.Delete("token"); err != nil { + t.Fatal(err) + } + + if got, ok := store.Get("token"); ok { + t.Fatalf("expected key to be deleted, got %q", got) + } +} + +func TestPluginStoreKeys(t *testing.T) { + setTestHome(t) + + store, err := newPluginStore("test_plugin") + if err != nil { + t.Fatal(err) + } + if err := store.Set("a", "1"); err != nil { + t.Fatal(err) + } + if err := store.Set("b", "2"); err != nil { + t.Fatal(err) + } + + got := map[string]bool{} + for _, key := range store.Keys() { + got[key] = true + } + + want := map[string]bool{"a": true, "b": true} + if !reflect.DeepEqual(got, want) { + t.Fatalf("expected keys %v, got %v", want, got) + } +} + +func TestPluginStoreKeysEmpty(t *testing.T) { + setTestHome(t) + + store, err := newPluginStore("test_plugin") + if err != nil { + t.Fatal(err) + } + + if keys := store.Keys(); len(keys) != 0 { + t.Fatalf("expected no keys, got %v", keys) + } +} + +func TestPluginStoreConcurrentSets(t *testing.T) { + setTestHome(t) + + store, err := newPluginStore("test_plugin") + if err != nil { + t.Fatal(err) + } + + var wg sync.WaitGroup + for i := 0; i < 20; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + if err := store.Set(fmt.Sprintf("key%d", i), fmt.Sprintf("value%d", i)); err != nil { + t.Errorf("set key%d: %v", i, err) + } + }(i) + } + wg.Wait() + + for i := 0; i < 20; i++ { + key := fmt.Sprintf("key%d", i) + want := fmt.Sprintf("value%d", i) + got, ok := store.Get(key) + if !ok { + t.Fatalf("expected %s to be stored", key) + } + if got != want { + t.Fatalf("expected %s, got %q", want, got) + } + } +} + +func TestPluginStorePersistence(t *testing.T) { + setTestHome(t) + + store, err := newPluginStore("test_plugin") + if err != nil { + t.Fatal(err) + } + if err := store.Set("token", "abc123"); err != nil { + t.Fatal(err) + } + + reloaded, err := newPluginStore("test_plugin") + if err != nil { + t.Fatal(err) + } + + got, ok := reloaded.Get("token") + if !ok { + t.Fatal("expected persisted key") + } + if got != "abc123" { + t.Fatalf("expected abc123, got %q", got) + } +} + +func TestPluginStoreFileMode(t *testing.T) { + setTestHome(t) + + store, err := newPluginStore("test_plugin") + if err != nil { + t.Fatal(err) + } + if err := store.Set("token", "abc123"); err != nil { + t.Fatal(err) + } + + info, err := os.Stat(store.path) + if err != nil { + t.Fatal(err) + } + if runtime.GOOS != "windows" { + if got := info.Mode().Perm(); got != 0o600 { + t.Fatalf("expected mode 0600, got %o", got) + } + } +} + +func TestPluginStoreFileModeAfterOverwrite(t *testing.T) { + setTestHome(t) + + store, err := newPluginStore("test_plugin") + if err != nil { + t.Fatal(err) + } + if err := store.Set("token", "abc123"); err != nil { + t.Fatal(err) + } + if err := os.Chmod(store.path, 0o666); err != nil { + t.Fatal(err) + } + if err := store.Set("token", "def456"); err != nil { + t.Fatal(err) + } + + info, err := os.Stat(store.path) + if err != nil { + t.Fatal(err) + } + if runtime.GOOS != "windows" { + if got := info.Mode().Perm(); got != 0o600 { + t.Fatalf("expected mode 0600 after overwrite, got %o", got) + } + } +} + +func TestNewPluginStoreRejectsInvalidPluginName(t *testing.T) { + setTestHome(t) + + for _, name := range []string{"", ".", "..", "../etc", "foo/bar", `foo\bar`, "foo.bar"} { + t.Run(name, func(t *testing.T) { + if _, err := newPluginStore(name); err == nil { + t.Fatal("expected invalid plugin name error") + } + }) + } +} + +func TestLuaStoreInitErrorPropagates(t *testing.T) { + home := setTestHome(t) + + dir := filepath.Join(home, ".config", "matcha", "plugins", "test_plugin") + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "data.json"), []byte("{"), 0o600); err != nil { + t.Fatal(err) + } + + m := newTestManager() + defer m.Close() + m.currentPlugin = "test_plugin" + + err := m.state.DoString(` + local matcha = require("matcha") + matcha.store_get("token") + `) + if err == nil { + t.Fatal("expected store_get to fail on store init error") + } + if !strings.Contains(err.Error(), "store_get:") { + t.Fatalf("expected store_get error, got %v", err) + } +}