Implement milestone 6b workbench routing skeleton
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// DirResolveOptions makes plugin directory resolution testable.
|
||||
type DirResolveOptions struct {
|
||||
EnvPluginDir string
|
||||
CWD string
|
||||
ExecutablePath string
|
||||
UserConfigDir string
|
||||
HomeDir string
|
||||
}
|
||||
|
||||
// ResolveDiscoveryDirs returns plugin discovery directories in priority order:
|
||||
// explicit env override, dev ./plugins, packaged binary-adjacent plugins, user plugins.
|
||||
func ResolveDiscoveryDirs(opts DirResolveOptions) []string {
|
||||
var dirs []string
|
||||
add := func(path string) {
|
||||
if path == "" {
|
||||
return
|
||||
}
|
||||
cleaned := filepath.Clean(path)
|
||||
for _, existing := range dirs {
|
||||
if existing == cleaned {
|
||||
return
|
||||
}
|
||||
}
|
||||
dirs = append(dirs, cleaned)
|
||||
}
|
||||
|
||||
if opts.EnvPluginDir != "" {
|
||||
for _, path := range filepath.SplitList(opts.EnvPluginDir) {
|
||||
add(path)
|
||||
}
|
||||
}
|
||||
|
||||
if opts.CWD != "" {
|
||||
add(filepath.Join(opts.CWD, "plugins"))
|
||||
}
|
||||
|
||||
if opts.ExecutablePath != "" {
|
||||
add(filepath.Join(filepath.Dir(opts.ExecutablePath), "plugins"))
|
||||
}
|
||||
|
||||
if opts.UserConfigDir != "" {
|
||||
add(filepath.Join(opts.UserConfigDir, "verstak", "plugins"))
|
||||
} else if opts.HomeDir != "" {
|
||||
add(filepath.Join(opts.HomeDir, ".config", "verstak", "plugins"))
|
||||
}
|
||||
|
||||
return dirs
|
||||
}
|
||||
|
||||
// DefaultDiscoveryDirs resolves discovery directories from the current process.
|
||||
func DefaultDiscoveryDirs() []string {
|
||||
cwd, _ := os.Getwd()
|
||||
exe, _ := os.Executable()
|
||||
userConfig, _ := os.UserConfigDir()
|
||||
home, _ := os.UserHomeDir()
|
||||
return ResolveDiscoveryDirs(DirResolveOptions{
|
||||
EnvPluginDir: strings.TrimSpace(os.Getenv("VERSTAK_PLUGIN_DIR")),
|
||||
CWD: cwd,
|
||||
ExecutablePath: exe,
|
||||
UserConfigDir: userConfig,
|
||||
HomeDir: home,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestResolveDiscoveryDirs_EnvCwdBinaryUserDedup(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
envDir := filepath.Join(root, "env-plugins")
|
||||
cwdDir := filepath.Join(root, "repo", "plugins")
|
||||
binaryDir := filepath.Join(root, "app", "plugins")
|
||||
userConfigDir := filepath.Join(root, "config")
|
||||
|
||||
got := ResolveDiscoveryDirs(DirResolveOptions{
|
||||
EnvPluginDir: envDir + string(filepath.ListSeparator) + cwdDir,
|
||||
CWD: filepath.Join(root, "repo"),
|
||||
ExecutablePath: filepath.Join(root, "app", "verstak-desktop"),
|
||||
UserConfigDir: userConfigDir,
|
||||
})
|
||||
|
||||
want := []string{
|
||||
envDir,
|
||||
cwdDir,
|
||||
binaryDir,
|
||||
filepath.Join(userConfigDir, "verstak", "plugins"),
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("ResolveDiscoveryDirs() = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveDiscoveryDirs_UsesCwdWhenExecutablePathMissing(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
got := ResolveDiscoveryDirs(DirResolveOptions{
|
||||
CWD: root,
|
||||
HomeDir: filepath.Join(root, "home"),
|
||||
})
|
||||
|
||||
wantFirst := filepath.Join(root, "plugins")
|
||||
if got[0] != wantFirst {
|
||||
t.Fatalf("first plugin dir = %q, want cwd plugins %q", got[0], wantFirst)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveDiscoveryDirs_FallsBackToHomeConfigDir(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
got := ResolveDiscoveryDirs(DirResolveOptions{
|
||||
HomeDir: filepath.Join(root, "home"),
|
||||
})
|
||||
|
||||
want := []string{filepath.Join(root, "home", ".config", "verstak", "plugins")}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("ResolveDiscoveryDirs() = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveDiscoveryDirs_NormalizesAndDeduplicatesPaths(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
cwd := filepath.Join(root, "repo")
|
||||
got := ResolveDiscoveryDirs(DirResolveOptions{
|
||||
EnvPluginDir: filepath.Join(cwd, ".", "plugins") + string(filepath.ListSeparator) + filepath.Join(cwd, "plugins"),
|
||||
CWD: cwd,
|
||||
})
|
||||
|
||||
want := []string{filepath.Join(cwd, "plugins")}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("ResolveDiscoveryDirs() = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
@@ -67,6 +67,7 @@ type Contributions struct {
|
||||
SearchProviders []ContributionSearchProvider `json:"searchProviders,omitempty"`
|
||||
ActivityProviders []ContributionActivityProvider `json:"activityProviders,omitempty"`
|
||||
StatusBarItems []ContributionStatusBarItem `json:"statusBarItems,omitempty"`
|
||||
OpenProviders []ContributionOpenProvider `json:"openProviders,omitempty"`
|
||||
}
|
||||
|
||||
// ContributionView represents a view contribution.
|
||||
@@ -144,6 +145,23 @@ type ContributionStatusBarItem struct {
|
||||
Handler string `json:"handler,omitempty"`
|
||||
}
|
||||
|
||||
// OpenProviderSupport describes a resource shape an open provider can handle.
|
||||
type OpenProviderSupport struct {
|
||||
Kind string `json:"kind"`
|
||||
Mime []string `json:"mime,omitempty"`
|
||||
Extensions []string `json:"extensions,omitempty"`
|
||||
Contexts []string `json:"contexts,omitempty"`
|
||||
}
|
||||
|
||||
// ContributionOpenProvider represents an editor/viewer provider contribution.
|
||||
type ContributionOpenProvider struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Priority int `json:"priority,omitempty"`
|
||||
Component string `json:"component"`
|
||||
Supports []OpenProviderSupport `json:"supports"`
|
||||
}
|
||||
|
||||
// SyncConfig describes plugin sync configuration.
|
||||
type SyncConfig struct {
|
||||
Namespaces []string `json:"namespaces,omitempty"`
|
||||
@@ -209,6 +227,27 @@ func ValidateManifest(m *Manifest) []string {
|
||||
if len(m.Permissions) == 0 {
|
||||
errs.add("permissions must have at least one permission")
|
||||
}
|
||||
if m.Contributes != nil {
|
||||
for i, provider := range m.Contributes.OpenProviders {
|
||||
if provider.ID == "" {
|
||||
errs.add("contributes.openProviders[%d].id is required", i)
|
||||
}
|
||||
if provider.Title == "" {
|
||||
errs.add("contributes.openProviders[%d].title is required", i)
|
||||
}
|
||||
if provider.Component == "" {
|
||||
errs.add("contributes.openProviders[%d].component is required", i)
|
||||
}
|
||||
if len(provider.Supports) == 0 {
|
||||
errs.add("contributes.openProviders[%d].supports must have at least one entry", i)
|
||||
}
|
||||
for j, support := range provider.Supports {
|
||||
if support.Kind == "" {
|
||||
errs.add("contributes.openProviders[%d].supports[%d].kind is required", i, j)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return errs.errors
|
||||
}
|
||||
@@ -249,7 +288,7 @@ func DiscoverPlugins(dirs []string) ([]Plugin, []error) {
|
||||
var plugins []Plugin
|
||||
var errs []error
|
||||
|
||||
seen := make(map[string]bool)
|
||||
seen := make(map[string]string)
|
||||
|
||||
log.Printf("[discovery] start: %d dir(s): %v", len(dirs), dirs)
|
||||
|
||||
@@ -287,12 +326,12 @@ func DiscoverPlugins(dirs []string) ([]Plugin, []error) {
|
||||
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)
|
||||
if existingPath, ok := seen[plugin.Manifest.ID]; ok {
|
||||
errs = append(errs, fmt.Errorf("duplicate plugin ID %q in %s (already loaded from %s); first plugin wins", plugin.Manifest.ID, pluginDir, existingPath))
|
||||
log.Printf("[discovery] %s: duplicate ID %q in %s (already loaded from %s; skip)", entry.Name(), plugin.Manifest.ID, pluginDir, existingPath)
|
||||
continue
|
||||
}
|
||||
seen[plugin.Manifest.ID] = true
|
||||
seen[plugin.Manifest.ID] = pluginDir
|
||||
plugins = append(plugins, plugin)
|
||||
log.Printf("[discovery] %s: ✅ %s@%s", entry.Name(), plugin.Manifest.ID, plugin.Manifest.Version)
|
||||
}
|
||||
|
||||
@@ -149,6 +149,90 @@ func TestDiscoverPlugins_DuplicateID(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscoverPlugins_DuplicateIDAcrossDirs_FirstWinsAndReportsBothPaths(t *testing.T) {
|
||||
dir1 := t.TempDir()
|
||||
dir2 := t.TempDir()
|
||||
firstPath := createTempPlugin(t, dir1, "shared.plugin", "First")
|
||||
|
||||
secondPath := filepath.Join(dir2, "other-name")
|
||||
if err := os.MkdirAll(secondPath, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
manifest := `{
|
||||
"schemaVersion": 1,
|
||||
"id": "shared.plugin",
|
||||
"name": "Second",
|
||||
"version": "2.0.0",
|
||||
"apiVersion": "1.0",
|
||||
"provides": ["shared.plugin.second.cap"],
|
||||
"permissions": ["vault.read"]
|
||||
}`
|
||||
if err := os.WriteFile(filepath.Join(secondPath, "plugin.json"), []byte(manifest), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
plugins, errs := DiscoverPlugins([]string{dir1, dir2})
|
||||
if len(plugins) != 1 {
|
||||
t.Fatalf("expected first plugin only, got %d", len(plugins))
|
||||
}
|
||||
if plugins[0].RootPath != firstPath {
|
||||
t.Fatalf("winner path = %q, want %q", plugins[0].RootPath, firstPath)
|
||||
}
|
||||
|
||||
combined := ""
|
||||
for _, err := range errs {
|
||||
combined += err.Error()
|
||||
}
|
||||
if !strings.Contains(combined, "duplicate plugin ID") {
|
||||
t.Fatalf("expected duplicate error, got %v", errs)
|
||||
}
|
||||
if !strings.Contains(combined, firstPath) || !strings.Contains(combined, secondPath) {
|
||||
t.Fatalf("duplicate error should include both paths; got %q", combined)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateManifest_OpenProviders(t *testing.T) {
|
||||
valid := &Manifest{
|
||||
SchemaVersion: 1,
|
||||
ID: "editor.plugin",
|
||||
Name: "Editor",
|
||||
Version: "1.0.0",
|
||||
APIVersion: "1.0",
|
||||
Provides: []string{"editor.text"},
|
||||
Permissions: []string{"workbench.open"},
|
||||
Contributes: &Contributions{
|
||||
OpenProviders: []ContributionOpenProvider{{
|
||||
ID: "editor.text",
|
||||
Title: "Text Editor",
|
||||
Component: "TextEditor",
|
||||
Supports: []OpenProviderSupport{{
|
||||
Kind: "vault-file",
|
||||
Extensions: []string{".txt"},
|
||||
Contexts: []string{"generic-text"},
|
||||
}},
|
||||
}},
|
||||
},
|
||||
}
|
||||
if errs := ValidateManifest(valid); len(errs) != 0 {
|
||||
t.Fatalf("valid manifest errors = %v", errs)
|
||||
}
|
||||
|
||||
invalid := *valid
|
||||
invalid.Contributes = &Contributions{
|
||||
OpenProviders: []ContributionOpenProvider{{
|
||||
ID: "broken",
|
||||
Title: "Broken",
|
||||
Component: "",
|
||||
Supports: []OpenProviderSupport{{}},
|
||||
}},
|
||||
}
|
||||
errs := ValidateManifest(&invalid)
|
||||
combined := strings.Join(errs, "\n")
|
||||
if !strings.Contains(combined, "component is required") || !strings.Contains(combined, "kind is required") {
|
||||
t.Fatalf("expected open provider validation errors, got %v", errs)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDiscoverPlugins_MultipleDirs ensures discovery scans multiple directories.
|
||||
func TestDiscoverPlugins_MultipleDirs(t *testing.T) {
|
||||
dir1 := t.TempDir()
|
||||
|
||||
Reference in New Issue
Block a user