diff --git a/build/bin/verstak-desktop b/build/bin/verstak-desktop index f251fb9..45b83bf 100755 Binary files a/build/bin/verstak-desktop and b/build/bin/verstak-desktop differ diff --git a/frontend/src/lib/plugin-manager/PluginManager.svelte b/frontend/src/lib/plugin-manager/PluginManager.svelte index 9acd0cd..11bee15 100644 --- a/frontend/src/lib/plugin-manager/PluginManager.svelte +++ b/frontend/src/lib/plugin-manager/PluginManager.svelte @@ -7,16 +7,31 @@ let permissions = []; let loading = true; let error = ''; + let hasConfig = true; + + // Wails binding call with timeout + async function rpc(promise, label, timeoutMs = 8000) { + const timeout = new Promise((_, reject) => + setTimeout(() => reject(new Error(`${label}: timeout (${timeoutMs}ms)`)), timeoutMs) + ); + return await Promise.race([promise, timeout]); + } async function loadData() { loading = true; error = ''; try { - plugins = await window.go.api.App.GetPlugins(); - capabilities = await window.go.api.App.GetCapabilities(); - permissions = await window.go.api.App.GetPermissions(); + const [p, caps, perms] = await Promise.all([ + rpc(window.go.api.App.GetPlugins(), 'GetPlugins'), + rpc(window.go.api.App.GetCapabilities(), 'GetCapabilities'), + rpc(window.go.api.App.GetPermissions(), 'GetPermissions'), + ]); + plugins = p; + capabilities = caps; + permissions = perms; } catch (e) { error = String(e); + console.error('[PluginManager] load error:', e); } finally { loading = false; } @@ -27,8 +42,16 @@ }); async function reload() { - await window.go.api.App.ReloadPlugins(); - await loadData(); + loading = true; + error = ''; + try { + await rpc(window.go.api.App.ReloadPlugins(), 'ReloadPlugins'); + await loadData(); + } catch (e) { + error = String(e); + console.error('[PluginManager] reload error:', e); + loading = false; + } } $: totalPlugins = plugins.length; @@ -47,7 +70,11 @@ {#if loading}
Scanning plugin directories...
{:else if error} -
Error: {error}
+
+
+
{error}
+ +
{:else}
@@ -59,8 +86,14 @@ {#if plugins.length === 0}
-

No plugins discovered.

-

Place plugins in ~/.config/verstak/plugins/ or ./plugins/

+
📂
+

No plugins found

+

Plugin directories scanned:

+ +

Place a plugin folder with plugin.json in one of these directories and click Reload.

{:else}
@@ -143,10 +176,37 @@ color: #e94560; } + .error-icon { + font-size: 2rem; + margin-bottom: 0.5rem; + } + + .error-message { + font-family: monospace; + font-size: 0.85rem; + margin-bottom: 1rem; + word-break: break-word; + } + + .retry-btn { + background: #0f3460; + color: #e0e0e0; + border: 1px solid #533483; + padding: 0.4rem 1rem; + border-radius: 6px; + cursor: pointer; + font-size: 0.85rem; + } + + .retry-btn:hover { + background: #533483; + } + .summary { display: flex; gap: 0.5rem; margin-bottom: 1rem; + flex-wrap: wrap; } .badge { @@ -167,12 +227,29 @@ border: 1px dashed #0f3460; } + .empty-icon { + font-size: 2rem; + margin-bottom: 0.5rem; + } + .hint { font-size: 0.85rem; margin-top: 0.5rem; opacity: 0.7; } + .hint-list { + list-style: none; + padding: 0; + margin: 0.5rem 0; + font-size: 0.8rem; + opacity: 0.7; + } + + .hint-list li { + margin: 0.25rem 0; + } + .hint code { background: #0f3460; padding: 0.1rem 0.3rem; diff --git a/frontend/wailsjs/go/api/App.d.ts b/frontend/wailsjs/go/api/App.d.ts index e776530..5b688b2 100755 --- a/frontend/wailsjs/go/api/App.d.ts +++ b/frontend/wailsjs/go/api/App.d.ts @@ -13,6 +13,6 @@ export function GetPermissions():Promise>; export function GetPlugins():Promise>; -export function ReloadPlugins():Promise; +export function ReloadPlugins():Promise; export function Startup():Promise; diff --git a/internal/api/app.go b/internal/api/app.go index af5f69c..94bd5e1 100644 --- a/internal/api/app.go +++ b/internal/api/app.go @@ -2,6 +2,11 @@ package api import ( + "log" + "os" + "path/filepath" + "strings" + "github.com/verstak/verstak-desktop/internal/core/capability" "github.com/verstak/verstak-desktop/internal/core/contribution" "github.com/verstak/verstak-desktop/internal/core/events" @@ -37,6 +42,7 @@ func NewApp( // Startup is called when the app starts. func (a *App) Startup() error { + log.Printf("[api] App.Startup: initialized with %d plugins", len(a.plugins)) return nil } @@ -44,17 +50,22 @@ func (a *App) Startup() error { // GetPlugins returns all discovered plugins. func (a *App) GetPlugins() []plugin.Plugin { + log.Printf("[api] GetPlugins: returning %d plugins", len(a.plugins)) return a.plugins } // GetCapabilities returns all registered capabilities. func (a *App) GetCapabilities() []capability.Entry { - return a.capRegistry.List() + entries := a.capRegistry.List() + log.Printf("[api] GetCapabilities: returning %d entries", len(entries)) + return entries } // GetPermissions returns all known permissions. func (a *App) GetPermissions() []permissions.Entry { - return a.permRegistry.List() + entries := a.permRegistry.List() + log.Printf("[api] GetPermissions: returning %d entries", len(entries)) + return entries } // GetContributions returns all registered contributions. @@ -70,14 +81,58 @@ func (a *App) GetContributions() ContributionSummary { } } -// ReloadPlugins re-discovers plugins from disk. -func (a *App) ReloadPlugins() { +// expandPath resolves "~" to the user's home directory. +func expandPath(path string) string { + if strings.HasPrefix(path, "~/") { + home, err := os.UserHomeDir() + if err != nil { + log.Printf("[api] expandPath: cannot get home dir: %v", err) + return path + } + return filepath.Join(home, path[2:]) + } + return path +} + +// ReloadPlugins re-discovers plugins from disk and returns a summary. +func (a *App) ReloadPlugins() (int, string) { discoveryDirs := []string{ "~/.config/verstak/plugins", "./plugins", } - plugins, _ := plugin.DiscoverPlugins(discoveryDirs) + + // Expand tilde in all paths + for i, d := range discoveryDirs { + discoveryDirs[i] = expandPath(d) + } + + log.Printf("[api] ReloadPlugins: scanning dirs: %v", discoveryDirs) + + plugins, errs := plugin.DiscoverPlugins(discoveryDirs) a.plugins = plugins + + var buf strings.Builder + buf.WriteString("discovery complete") + if len(plugins) > 0 { + buf.WriteString(": ") + buf.WriteString(plugin.FormatDiscoverySummary(plugins)) + } + + if len(errs) > 0 { + log.Printf("[api] ReloadPlugins: %d warning(s)", len(errs)) + for _, e := range errs { + log.Printf("[api] discovery warning: %v", e) + } + } + + log.Printf("[api] ReloadPlugins: discovered %d plugin(s)", len(plugins)) + + discoveryDirsStr := strings.Join(discoveryDirs, ", ") + summary := buf.String() + + log.Printf("[api] ReloadPlugins: dirs=[%s] %s", discoveryDirsStr, summary) + + return len(plugins), summary } // ContributionSummary aggregates all contribution types for the frontend. diff --git a/internal/core/plugin/plugin.go b/internal/core/plugin/plugin.go index 573732c..f4c7868 100644 --- a/internal/core/plugin/plugin.go +++ b/internal/core/plugin/plugin.go @@ -4,6 +4,7 @@ package plugin import ( "encoding/json" "fmt" + "log" "os" "path/filepath" "strings" @@ -231,6 +232,18 @@ func isAllowedInID(r rune) bool { // ─── Discovery ────────────────────────────────────────────── +// FormatDiscoverySummary returns a human-readable summary of discovered plugins. +func FormatDiscoverySummary(plugins []Plugin) string { + if len(plugins) == 0 { + return "no plugins found" + } + ids := make([]string, 0, len(plugins)) + for _, p := range plugins { + ids = append(ids, p.Manifest.ID+"@"+p.Manifest.Version) + } + return fmt.Sprintf("%d plugin(s): %s", len(plugins), strings.Join(ids, ", ")) +} + // DiscoverPlugins scans the given directories for plugin.json manifests. func DiscoverPlugins(dirs []string) ([]Plugin, []error) { var plugins []Plugin @@ -238,16 +251,22 @@ func DiscoverPlugins(dirs []string) ([]Plugin, []error) { seen := make(map[string]bool) + log.Printf("[discovery] start: %d dir(s): %v", len(dirs), dirs) + for _, dir := range dirs { entries, err := os.ReadDir(dir) if os.IsNotExist(err) { + log.Printf("[discovery] dir %q: does not exist (skip)", dir) continue } if err != nil { errs = append(errs, fmt.Errorf("reading plugin directory %s: %w", dir, err)) + log.Printf("[discovery] dir %q: error: %v", dir, err) continue } + log.Printf("[discovery] dir %q: %d entries", dir, len(entries)) + for _, entry := range entries { if !entry.IsDir() { continue @@ -257,24 +276,30 @@ func DiscoverPlugins(dirs []string) ([]Plugin, []error) { manifestPath := filepath.Join(pluginDir, "plugin.json") if _, err := os.Stat(manifestPath); os.IsNotExist(err) { + log.Printf("[discovery] %s: no plugin.json (skip)", entry.Name()) continue } plugin, err := loadPlugin(pluginDir) if err != nil { errs = append(errs, fmt.Errorf("plugin %s: %w", entry.Name(), err)) + log.Printf("[discovery] %s: load error: %v", entry.Name(), err) continue } if seen[plugin.Manifest.ID] { errs = append(errs, fmt.Errorf("duplicate plugin ID %q in %s", plugin.Manifest.ID, pluginDir)) + log.Printf("[discovery] %s: duplicate ID %q (skip)", entry.Name(), plugin.Manifest.ID) continue } seen[plugin.Manifest.ID] = true plugins = append(plugins, plugin) + log.Printf("[discovery] %s: ✅ %s@%s", entry.Name(), plugin.Manifest.ID, plugin.Manifest.Version) } } + log.Printf("[discovery] end: %d plugin(s) found, %d error(s)", len(plugins), len(errs)) + return plugins, errs } diff --git a/internal/core/plugin/plugin_test.go b/internal/core/plugin/plugin_test.go new file mode 100644 index 0000000..47f13e3 --- /dev/null +++ b/internal/core/plugin/plugin_test.go @@ -0,0 +1,212 @@ +package plugin + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// createTempPlugin creates a minimal valid plugin directory for testing. +func createTempPlugin(t *testing.T, dir, id, name string) string { + t.Helper() + pluginDir := filepath.Join(dir, id) + if err := os.MkdirAll(pluginDir, 0755); err != nil { + t.Fatal(err) + } + manifest := `{ + "schemaVersion": 1, + "id": "` + id + `", + "name": "` + name + `", + "version": "1.0.0", + "apiVersion": "1.0", + "provides": ["` + id + `.cap1"], + "permissions": ["vault.read"] + }` + if err := os.WriteFile(filepath.Join(pluginDir, "plugin.json"), []byte(manifest), 0644); err != nil { + t.Fatal(err) + } + return pluginDir +} + +// TestDiscoverPlugins_EmptyDir tests discovery on a directory that does not exist. +// It should return empty results, not hang or error out. +func TestDiscoverPlugins_EmptyDir(t *testing.T) { + plugins, errs := DiscoverPlugins([]string{"/tmp/nonexistent-plugin-dir-12345"}) + if len(plugins) != 0 { + t.Errorf("expected 0 plugins, got %d", len(plugins)) + } + if len(errs) != 0 { + t.Errorf("expected 0 errors, got %d: %v", len(errs), errs) + } +} + +// TestDiscoverPlugins_MissingDir tests discovery where the directory does not exist. +// It should not be treated as an error — just skip. +func TestDiscoverPlugins_MissingDir(t *testing.T) { + plugins, errs := DiscoverPlugins([]string{"/tmp/missing-dir-99999"}) + if len(plugins) != 0 { + t.Errorf("expected 0 plugins, got %d", len(plugins)) + } + if len(errs) != 0 { + t.Errorf("expected 0 errors for missing dir, got %d", len(errs)) + } +} + +// TestDiscoverPlugins_ValidPlugin tests discovery of a single valid plugin. +func TestDiscoverPlugins_ValidPlugin(t *testing.T) { + dir := t.TempDir() + createTempPlugin(t, dir, "test.plugin", "Test Plugin") + + plugins, errs := DiscoverPlugins([]string{dir}) + if len(plugins) != 1 { + t.Fatalf("expected 1 plugin, got %d", len(plugins)) + } + if plugins[0].Manifest.ID != "test.plugin" { + t.Errorf("expected id 'test.plugin', got %q", plugins[0].Manifest.ID) + } + if !plugins[0].Enabled { + t.Error("expected plugin to be enabled by default") + } + if len(errs) > 0 { + t.Errorf("expected 0 errors, got %d: %v", len(errs), errs) + } +} + +// TestDiscoverPlugins_NoManifest ensures subdirs without plugin.json are skipped. +func TestDiscoverPlugins_NoManifest(t *testing.T) { + dir := t.TempDir() + noManifestDir := filepath.Join(dir, "no-manifest") + if err := os.MkdirAll(noManifestDir, 0755); err != nil { + t.Fatal(err) + } + + plugins, errs := DiscoverPlugins([]string{dir}) + if len(plugins) != 0 { + t.Errorf("expected 0 plugins (no manifest), got %d", len(plugins)) + } + if len(errs) != 0 { + t.Errorf("expected 0 errors, got %d", len(errs)) + } +} + +// TestDiscoverPlugins_BrokenJSON ensures a corrupted manifest is reported as error +// but does not crash or hang discovery. +func TestDiscoverPlugins_BrokenJSON(t *testing.T) { + dir := t.TempDir() + pluginDir := filepath.Join(dir, "broken") + if err := os.MkdirAll(pluginDir, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(pluginDir, "plugin.json"), []byte("{invalid json}"), 0644); err != nil { + t.Fatal(err) + } + + plugins, errs := DiscoverPlugins([]string{dir}) + if len(plugins) != 0 { + t.Errorf("expected 0 plugins (broken manifest), got %d", len(plugins)) + } + if len(errs) == 0 { + t.Error("expected at least 1 error for broken manifest, got 0") + } +} + +// TestDiscoverPlugins_DuplicateID ensures duplicate plugin IDs are detected. +func TestDiscoverPlugins_DuplicateID(t *testing.T) { + dir := t.TempDir() + createTempPlugin(t, dir, "dup-one", "First") + + // Create a second plugin with the same ID (duplicate) + pluginDir2 := filepath.Join(dir, "dup-two") + if err := os.MkdirAll(pluginDir2, 0755); err != nil { + t.Fatal(err) + } + manifest := `{ + "schemaVersion": 1, + "id": "dup-one", + "name": "Second", + "version": "1.0.0", + "apiVersion": "1.0", + "provides": ["dup-one.cap1"], + "permissions": ["vault.read"] + }` + if err := os.WriteFile(filepath.Join(pluginDir2, "plugin.json"), []byte(manifest), 0644); err != nil { + t.Fatal(err) + } + + plugins, errs := DiscoverPlugins([]string{dir}) + if len(plugins) != 1 { + t.Errorf("expected 1 plugin (deduped), got %d", len(plugins)) + } + hasDupError := false + for _, e := range errs { + if strings.Contains(e.Error(), "duplicate") { + hasDupError = true + } + } + if !hasDupError { + t.Error("expected duplicate plugin ID error") + } +} + +// TestDiscoverPlugins_MultipleDirs ensures discovery scans multiple directories. +func TestDiscoverPlugins_MultipleDirs(t *testing.T) { + dir1 := t.TempDir() + dir2 := t.TempDir() + createTempPlugin(t, dir1, "alpha.plugin", "Alpha") + createTempPlugin(t, dir2, "beta.plugin", "Beta") + + plugins, errs := DiscoverPlugins([]string{dir1, dir2}) + if len(plugins) != 2 { + t.Fatalf("expected 2 plugins, got %d", len(plugins)) + } + if len(errs) > 0 { + t.Errorf("expected 0 errors, got %d: %v", len(errs), errs) + } +} + +// TestDiscoverPlugins_AllowsNonexistentDirs ensures that a mix of valid and +// nonexistent directories doesn't cause issues. +func TestDiscoverPlugins_AllowsNonexistentDirs(t *testing.T) { + dir := t.TempDir() + createTempPlugin(t, dir, "survivor.plugin", "Survivor") + + plugins, errs := DiscoverPlugins([]string{ + "/tmp/missing-aaa-88888", + dir, + "/tmp/missing-bbb-99999", + }) + if len(plugins) != 1 { + t.Fatalf("expected 1 plugin, got %d", len(plugins)) + } + if plugins[0].Manifest.ID != "survivor.plugin" { + t.Errorf("expected 'survivor.plugin', got %q", plugins[0].Manifest.ID) + } + if len(errs) > 0 { + t.Errorf("expected 0 errors, got %d: %v", len(errs), errs) + } +} + +// TestFormatDiscoverySummary on empty list. +func TestFormatDiscoverySummary_Empty(t *testing.T) { + s := FormatDiscoverySummary(nil) + if s != "no plugins found" { + t.Errorf("expected 'no plugins found', got %q", s) + } +} + +// TestFormatDiscoverySummary on populated list. +func TestFormatDiscoverySummary_Plugins(t *testing.T) { + plugins := []Plugin{ + { + Manifest: Manifest{ID: "test.one", Version: "1.0.0"}, + }, + { + Manifest: Manifest{ID: "test.two", Version: "2.0.0"}, + }, + } + s := FormatDiscoverySummary(plugins) + if s != "2 plugin(s): test.one@1.0.0, test.two@2.0.0" { + t.Errorf("unexpected summary: %q", s) + } +} diff --git a/main.go b/main.go index 475d67d..055a63f 100644 --- a/main.go +++ b/main.go @@ -3,6 +3,9 @@ package main import ( "embed" "log" + "os" + "path/filepath" + "strings" "github.com/wailsapp/wails/v2" "github.com/wailsapp/wails/v2/pkg/options" @@ -19,6 +22,19 @@ import ( //go:embed frontend/dist var assets embed.FS +// expandPath resolves "~" to the user's home directory. +func expandPath(path string) string { + if strings.HasPrefix(path, "~/") { + home, err := os.UserHomeDir() + if err != nil { + log.Printf("[main] expandPath: cannot get home dir: %v", err) + return path + } + return filepath.Join(home, path[2:]) + } + return path +} + func main() { // ─── Initialize Core Registries ────────────────────────── capRegistry := capability.NewRegistry() @@ -32,6 +48,13 @@ func main() { "./plugins", } + // Expand tilde in all paths + for i, d := range discoveryDirs { + discoveryDirs[i] = expandPath(d) + } + + log.Printf("[main] plugin dirs: %v", discoveryDirs) + plugins, discErrors := plugin.DiscoverPlugins(discoveryDirs) for _, err := range discErrors { log.Printf("[plugin] discovery warning: %v", err)