diff --git a/cmd/smoke-platform/main.go b/cmd/smoke-platform/main.go
index f2fb171..f10230b 100644
--- a/cmd/smoke-platform/main.go
+++ b/cmd/smoke-platform/main.go
@@ -3,15 +3,20 @@
package main
import (
+ "flag"
"fmt"
"os"
"path/filepath"
"github.com/verstak/verstak-desktop/internal/core/capability"
"github.com/verstak/verstak-desktop/internal/core/plugin"
+ "github.com/verstak/verstak-desktop/internal/core/pluginstate"
+ "github.com/verstak/verstak-desktop/internal/core/vault"
)
func main() {
+ testEnableDisable := flag.Bool("test-enable-disable", false, "Test enable/disable lifecycle")
+ flag.Parse()
exitCode := 0
defer func() {
os.Exit(exitCode)
@@ -20,6 +25,11 @@ func main() {
root, _ := os.Getwd()
pluginDir := filepath.Join(root, "plugins")
+ if *testEnableDisable {
+ runEnableDisableTest(root)
+ return
+ }
+
fmt.Printf("=== smoke-platform: headless plugin verification ===\n\n")
fmt.Printf(" plugin dir: %s\n", pluginDir)
@@ -256,3 +266,171 @@ func main() {
exitCode = 1
}
}
+
+// runEnableDisableTest tests the enable/disable lifecycle with vault plugin state.
+func runEnableDisableTest(root string) {
+ exitCode := 0
+ defer func() {
+ os.Exit(exitCode)
+ }()
+
+ fmt.Printf("=== smoke-platform: enable/disable test ===\n\n")
+
+ // Create a temp vault
+ tmpDir, err := os.MkdirTemp("", "verstak-smoke-*")
+ if err != nil {
+ fmt.Printf(" ❌ failed to create temp dir: %v\n", err)
+ exitCode = 1
+ return
+ }
+ defer os.RemoveAll(tmpDir)
+
+ vaultPath := filepath.Join(tmpDir, "testvault")
+ fmt.Printf(" vault path: %s\n", vaultPath)
+
+ // Initialize vault
+ v := vault.NewVault(nil)
+ if err := v.CreateVault(vaultPath); err != nil {
+ fmt.Printf(" ❌ create vault: %v\n", err)
+ exitCode = 1
+ return
+ }
+ fmt.Printf(" ✅ vault created\n")
+
+ // Open the vault at the path returned by CreateVault (path/VerstakVault)
+ openedPath := v.GetVaultPath()
+ if err := v.OpenVault(openedPath); err != nil {
+ fmt.Printf(" ❌ open vault: %v\n", err)
+ exitCode = 1
+ return
+ }
+ fmt.Printf(" ✅ vault opened at %s\n", openedPath)
+
+ // Initialize plugin state
+ psm := pluginstate.NewManager(v)
+ if err := psm.Load(); err != nil {
+ fmt.Printf(" ❌ load plugin state: %v\n", err)
+ exitCode = 1
+ return
+ }
+ fmt.Printf(" ✅ plugin state loaded\n")
+
+ // Discover plugins
+ pluginDir := filepath.Join(root, "plugins")
+ plugins, _ := plugin.DiscoverPlugins([]string{pluginDir})
+ if len(plugins) == 0 {
+ fmt.Printf(" ❌ no plugins discovered\n")
+ exitCode = 1
+ return
+ }
+ fmt.Printf(" ✅ discovered %d plugin(s)\n", len(plugins))
+
+ // Find platform-test
+ var target *plugin.Plugin
+ for i, p := range plugins {
+ if p.Manifest.ID == "verstak.platform-test" {
+ target = &plugins[i]
+ break
+ }
+ }
+ if target == nil {
+ fmt.Printf(" ❌ platform-test not found\n")
+ exitCode = 1
+ return
+ }
+ fmt.Printf(" ✅ platform-test found\n")
+
+ // Register capabilities
+ reg := capability.NewRegistry()
+ coreCaps := []string{
+ "verstak/core/plugin-manager/v1",
+ "verstak/core/capability-registry/v1",
+ "verstak/core/contribution-registry/v1",
+ "verstak/core/permissions/v1",
+ "verstak/core/events/v1",
+ }
+ reg.Register("verstak-desktop", coreCaps)
+ reg.Register("verstak-desktop", []string{"verstak/core/vault/v1"})
+ for _, cap := range target.Manifest.Provides {
+ reg.Register(target.Manifest.ID, []string{cap})
+ }
+ totalCaps := len(reg.List())
+ fmt.Printf(" ✅ registered %d capabilities (core + plugin)\n", totalCaps)
+
+ // ── Test 1: Disable platform-test ──
+ fmt.Printf("\n[disable]\n")
+ if err := psm.DisablePlugin("verstak.platform-test"); err != nil {
+ fmt.Printf(" ❌ disable: %v\n", err)
+ exitCode = 1
+ return
+ }
+ fmt.Printf(" ✅ disabled platform-test\n")
+
+ if !psm.IsDisabled("verstak.platform-test") {
+ fmt.Printf(" ❌ IsDisabled returned false after disable\n")
+ exitCode = 1
+ return
+ }
+ fmt.Printf(" ✅ IsDisabled: true\n")
+
+ if psm.IsEnabled("verstak.platform-test") {
+ fmt.Printf(" ❌ IsEnabled returned true after disable\n")
+ exitCode = 1
+ return
+ }
+ fmt.Printf(" ✅ IsEnabled: false\n")
+
+ // Check plugins.json
+ state := psm.Get()
+ found := false
+ for _, dp := range state.DesiredPlugins {
+ if dp.ID == "verstak.platform-test" {
+ found = true
+ break
+ }
+ }
+ if found {
+ fmt.Printf(" ✅ platform-test in desiredPlugins\n")
+ } else {
+ fmt.Printf(" ℹ️ platform-test not in desiredPlugins (ok if not recorded)\n")
+ }
+
+ // ── Test 2: Enable platform-test ──
+ fmt.Printf("\n[enable]\n")
+ if err := psm.EnablePlugin("verstak.platform-test"); err != nil {
+ fmt.Printf(" ❌ enable: %v\n", err)
+ exitCode = 1
+ return
+ }
+ fmt.Printf(" ✅ enabled platform-test\n")
+
+ if !psm.IsEnabled("verstak.platform-test") {
+ fmt.Printf(" ❌ IsEnabled returned false after enable\n")
+ exitCode = 1
+ return
+ }
+ fmt.Printf(" ✅ IsEnabled: true\n")
+
+ if psm.IsDisabled("verstak.platform-test") {
+ fmt.Printf(" ❌ IsDisabled returned true after enable\n")
+ exitCode = 1
+ return
+ }
+ fmt.Printf(" ✅ IsDisabled: false\n")
+
+ // ── Test 3: Verify plugins.json on disk ──
+ fmt.Printf("\n[plugins.json verification]\n")
+ statePath := filepath.Join(v.GetVaultPath(), ".verstak", "plugins.json")
+ data, err := os.ReadFile(statePath)
+ if err != nil {
+ fmt.Printf(" ❌ read plugins.json: %v\n", err)
+ exitCode = 1
+ return
+ }
+ fmt.Printf(" ✅ plugins.json exists on disk\n")
+ fmt.Printf(" content:\n%s\n", string(data))
+
+ // ── Summary ──
+ fmt.Printf("\n=== summary ===\n")
+ fmt.Printf("✅ enable/disable test passed\n")
+}
diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte
index cda3144..dc0ba03 100644
--- a/frontend/src/App.svelte
+++ b/frontend/src/App.svelte
@@ -2,23 +2,75 @@
import PluginManager from './lib/plugin-manager/PluginManager.svelte';
import Sidebar from './lib/shell/Sidebar.svelte';
import ViewContainer from './lib/shell/ViewContainer.svelte';
+ import VaultSelection from './lib/shell/VaultSelection.svelte';
+ import * as App from '../wailsjs/go/api/App';
let currentView = 'plugin-manager';
+ let vaultStatus = { status: 'unknown', path: '', vaultId: '' };
+ let needsVaultSelection = false;
+ let loading = true;
+
+ async function checkVault() {
+ loading = true;
+ try {
+ const settings = await App.GetAppSettings();
+ vaultStatus = await App.GetVaultStatus() || { status: 'unknown', path: '', vaultId: '' };
+
+ if (!settings.currentVaultPath || vaultStatus.status !== 'open') {
+ needsVaultSelection = true;
+ } else {
+ needsVaultSelection = false;
+ }
+ } catch (e) {
+ console.error('[App] startup check failed:', e);
+ needsVaultSelection = true;
+ }
+ loading = false;
+ }
+
+ function onVaultOpened() {
+ needsVaultSelection = false;
+ vaultStatus = { status: 'open', path: '', vaultId: '' };
+ }
+
+ // Listen for vault-opened event from VaultSelection
+ if (typeof window !== 'undefined') {
+ window.addEventListener('verstak:vault-opened', onVaultOpened);
+ }
+
+ checkVault();
-
-
+{#if loading}
+
+{:else if needsVaultSelection}
+
+{:else}
+
+
-
- {#if currentView === 'plugin-manager'}
-
- {:else}
-
- {/if}
-
-
+
+ {#if currentView === 'plugin-manager'}
+
+ {:else}
+
+ {/if}
+
+
+{/if}
diff --git a/frontend/src/lib/plugin-manager/PluginCard.svelte b/frontend/src/lib/plugin-manager/PluginCard.svelte
index 45c7ab0..8b412ee 100644
--- a/frontend/src/lib/plugin-manager/PluginCard.svelte
+++ b/frontend/src/lib/plugin-manager/PluginCard.svelte
@@ -2,8 +2,14 @@
export let p = {};
export let capabilities = [];
export let permissions = [];
+ export let contributions = {};
+ export let vaultOpen = false;
export let onSettings = () => {};
+ export let onEnable = () => {};
+ export let onDisable = () => {};
+ $: m = p.manifest || {};
+ $: pluginId = m.id || 'unknown';
$: hasSettingsPanel = (contributions.settingsPanels || []).some(sp => sp.pluginId === pluginId);
$: hasUIPermission = (m.permissions || []).includes('ui.register');
$: hasStoragePermission = (m.permissions || []).includes('storage.namespace');
@@ -20,8 +26,6 @@
discovered: '#a0a0b8',
}[p.status] || '#a0a0b8');
- $: pluginId = m.id || 'unknown';
-
$: contribCounts = {
views: (contributions.views || []).filter(v => v.pluginId === pluginId).length,
commands: (contributions.commands || []).filter(c => c.pluginId === pluginId).length,
@@ -54,13 +58,16 @@
$: missingOptional = (m.optionalRequires || []).filter(opt =>
!capabilities.some(c => c.name === opt)
);
+
+ $: isDisabled = p.status === 'disabled' || !p.enabled;
+ $: canToggle = p.status !== 'failed' && p.status !== 'incompatible' && p.status !== 'missing-required-capability' && p.status !== 'discovered';
-
+
@@ -347,6 +368,7 @@
.card-actions {
display: flex;
+ align-items: center;
gap: 0.5rem;
margin-top: 0.75rem;
padding-top: 0.5rem;
@@ -366,4 +388,40 @@
.btn-settings:hover {
background: #1a3a5c;
}
+
+ .btn-enable {
+ background: #4ecca3;
+ color: #1a1a2e;
+ border: none;
+ padding: 0.3rem 0.75rem;
+ border-radius: 4px;
+ cursor: pointer;
+ font-size: 0.8rem;
+ font-weight: 600;
+ }
+
+ .btn-enable:hover {
+ background: #3dbb92;
+ }
+
+ .btn-disable {
+ background: #533483;
+ color: #e0e0f0;
+ border: none;
+ padding: 0.3rem 0.75rem;
+ border-radius: 4px;
+ cursor: pointer;
+ font-size: 0.8rem;
+ font-weight: 600;
+ }
+
+ .btn-disable:hover {
+ background: #6b44a0;
+ }
+
+ .vault-hint {
+ color: #666;
+ font-size: 0.75rem;
+ font-style: italic;
+ }
diff --git a/frontend/src/lib/plugin-manager/PluginManager.svelte b/frontend/src/lib/plugin-manager/PluginManager.svelte
index 7015275..9a4af4c 100644
--- a/frontend/src/lib/plugin-manager/PluginManager.svelte
+++ b/frontend/src/lib/plugin-manager/PluginManager.svelte
@@ -1,7 +1,7 @@
+
+
+
+
+
+
Verstak
+
Choose a vault to get started
+
+
+ {#if error}
+
+ ⚠
+ {error}
+
+ {/if}
+
+
+
+
Create New Vault
+
Create a new vault folder. This will be your workspace.
+
+
+
+
+
+
+
+
Open Existing Vault
+
Open a vault that already exists on this computer.
+
+
+
+
+
+
+
+ {#if recentVaults.length > 0}
+
+
Recent Vaults
+
+ {#each recentVaults as path}
+ -
+
+
+ {/each}
+
+
+ {/if}
+
+
+
+
diff --git a/internal/api/app.go b/internal/api/app.go
index b8e65ae..3026b76 100644
--- a/internal/api/app.go
+++ b/internal/api/app.go
@@ -191,6 +191,17 @@ func (a *App) ReloadPlugins() (int, string) {
if p.Manifest.Contributes != nil {
a.contribRegistry.Register(p.Manifest.ID, p.Manifest.Contributes)
}
+
+ // Record as desired plugin in vault state (only if vault is open)
+ if a.pluginState != nil && a.vault != nil && a.vault.GetVaultStatus() == vault.StatusOpen {
+ source := p.Manifest.Source
+ if source == "" {
+ source = "unknown"
+ }
+ if err := a.pluginState.RecordDesiredPlugin(p.Manifest.ID, p.Manifest.Version, source); err != nil {
+ log.Printf("[plugin] %s: failed to record desired: %v", p.Manifest.ID, err)
+ }
+ }
}
a.plugins = plugins
@@ -387,6 +398,35 @@ func (a *App) UpdateAppSettings(patch map[string]interface{}) string {
return ""
}
+// SetCurrentVault sets the current vault path in app settings and re-opens the vault.
+func (a *App) SetCurrentVault(path string) string {
+ if a.appSettings == nil {
+ return "app settings not initialized"
+ }
+ if a.vault == nil {
+ return "vault service not initialized"
+ }
+ // Try to open the vault first
+ if err := a.vault.OpenVault(path); err != nil {
+ return fmt.Sprintf("failed to open vault: %v", err)
+ }
+ // Save to app settings
+ if err := a.appSettings.SetCurrentVault(path); err != nil {
+ return fmt.Sprintf("failed to save app settings: %v", err)
+ }
+ // Load plugin state for the vault
+ if a.pluginState != nil {
+ if err := a.pluginState.Load(); err != nil {
+ log.Printf("[api] SetCurrentVault: warning loading plugin state: %v", err)
+ }
+ }
+ // Register vault capability
+ if err := a.capRegistry.Register("verstak-desktop", []string{"verstak/core/vault/v1"}); err != nil {
+ log.Printf("[api] SetCurrentVault: failed to register vault capability: %v", err)
+ }
+ return ""
+}
+
// ─── Vault Plugin State API ────────────────────────────────
// GetVaultPluginState returns the current vault plugin state.
@@ -426,6 +466,17 @@ func (a *App) DisablePlugin(pluginID string) string {
return ""
}
+// RecordDesiredPlugin records a plugin as desired for this vault.
+func (a *App) RecordDesiredPlugin(pluginID, version, source string) string {
+ if a.pluginState == nil {
+ return "plugin state not initialized"
+ }
+ if err := a.pluginState.RecordDesiredPlugin(pluginID, version, source); err != nil {
+ return err.Error()
+ }
+ return ""
+}
+
// ContributionSummary aggregates all contribution types for the frontend.
type ContributionSummary struct {
Views []contribution.ContributionView `json:"views"`
diff --git a/internal/core/appsettings/manager.go b/internal/core/appsettings/manager.go
index 69d4dec..2fb026a 100644
--- a/internal/core/appsettings/manager.go
+++ b/internal/core/appsettings/manager.go
@@ -8,7 +8,6 @@ import (
"fmt"
"os"
"path/filepath"
- "sort"
"sync"
"time"
)
@@ -237,19 +236,11 @@ func addRecent(list []string, path string, max int) []string {
filtered = append(filtered, p)
}
}
- // Prepend
+ // Prepend (most recent first)
result := append([]string{path}, filtered...)
// Trim
if len(result) > max {
result = result[:max]
}
- sort.SliceStable(result, func(i, j int) bool {
- return result[i] < result[j]
- })
- // Actually keep insertion order: prepend is correct, just trim
- result = append([]string{path}, filtered...)
- if len(result) > max {
- result = result[:max]
- }
return result
}
diff --git a/main.go b/main.go
index d8d122c..9ba85bf 100644
--- a/main.go
+++ b/main.go
@@ -170,6 +170,17 @@ func main() {
log.Printf("[plugin] %s: contributions registered", p.Manifest.ID)
}
+ // Record as desired plugin in vault state (only if vault is open)
+ if pluginStateMgr != nil && vaultService.GetVaultStatus() == vault.StatusOpen {
+ source := p.Manifest.Source
+ if source == "" {
+ source = "unknown"
+ }
+ if err := pluginStateMgr.RecordDesiredPlugin(p.Manifest.ID, p.Manifest.Version, source); err != nil {
+ log.Printf("[plugin] %s: failed to record desired: %v", p.Manifest.ID, err)
+ }
+ }
+
log.Printf("[plugin] %s: status=%s", p.Manifest.ID, p.Status)
}
diff --git a/scripts/smoke-platform.sh b/scripts/smoke-platform.sh
index a3719f8..5f7f162 100755
--- a/scripts/smoke-platform.sh
+++ b/scripts/smoke-platform.sh
@@ -63,5 +63,15 @@ if [ "$SMOKE_EXIT" -ne 0 ]; then
exit 1
fi
+# ── test enable/disable via Go smoke ──
+echo ""
+echo "[go smoke: enable/disable]"
+(cd "$ROOT" && go run -mod=mod ./cmd/smoke-platform/ -test-enable-disable 2>&1)
+SMOKE_ED_EXIT=$?
+if [ "$SMOKE_ED_EXIT" -ne 0 ]; then
+ echo " ❌ smoke-platform: enable/disable test failed"
+ exit 1
+fi
+
echo ""
echo "✅ smoke-platform done"