diff --git a/cmd/wfctl/multi_registry_test.go b/cmd/wfctl/multi_registry_test.go index f3e8541e..b66c2b48 100644 --- a/cmd/wfctl/multi_registry_test.go +++ b/cmd/wfctl/multi_registry_test.go @@ -1,7 +1,10 @@ package main import ( + "encoding/json" "fmt" + "net/http" + "net/http/httptest" "os" "path/filepath" "sort" @@ -75,6 +78,7 @@ func (m *mockRegistrySource) SearchPlugins(query string) ([]PluginSearchResult, Version: manifest.Version, Description: manifest.Description, Tier: manifest.Tier, + Status: manifest.Status, }, Source: m.name, }) @@ -1217,3 +1221,81 @@ func TestLoadRegistryConfig_ExplicitEmptyRegistries(t *testing.T) { t.Errorf("expected 0 registries for explicit empty list, got %d: %v", len(cfg.Registries), cfg.Registries) } } + +// --------------------------------------------------------------------------- +// Status + Private field tests +// --------------------------------------------------------------------------- + +func TestValidateManifest_StatusEnum(t *testing.T) { + cases := []struct { + name string + status string + wantErr bool + }{ + {"empty allowed", "", false}, + {"verified", "verified", false}, + {"experimental", "experimental", false}, + {"deprecated", "deprecated", false}, + {"invalid value", "bogus", true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + m := validManifest() + m.Status = tc.status + errs := ValidateManifest(m, ValidationOptions{}) + hasStatusErr := false + for _, e := range errs { + if e.Field == "status" { + hasStatusErr = true + } + } + if hasStatusErr != tc.wantErr { + t.Fatalf("status=%q wantErr=%v got errs=%v", tc.status, tc.wantErr, errs) + } + }) + } +} + +func TestRegistryManifest_PrivateField(t *testing.T) { + raw := []byte(`{"name":"x","version":"1.0.0","author":"a","description":"d","type":"external","tier":"community","license":"MIT","private":true}`) + var m RegistryManifest + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + if !m.Private { + t.Fatalf("expected Private=true, got %v", m.Private) + } +} + +func TestPluginSummary_StatusPropagation(t *testing.T) { + // Use the real StaticRegistrySource (not a mock) so the test exercises + // the actual Status: e.Status propagation line in SearchPlugins. + index := []staticIndexEntry{{ + Name: "test", Version: "1.0.0", Description: "d", Tier: "community", Status: "experimental", + }} + indexData, err := json.Marshal(index) + if err != nil { + t.Fatal(err) + } + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/index.json" { + w.Header().Set("Content-Type", "application/json") + w.Write(indexData) //nolint:errcheck + } else { + http.NotFound(w, r) + } + })) + defer srv.Close() + + src, err := NewStaticRegistrySource(RegistrySourceConfig{Name: "test-source", URL: srv.URL}) + if err != nil { + t.Fatal(err) + } + summaries, err := src.SearchPlugins("") + if err != nil { + t.Fatal(err) + } + if len(summaries) != 1 || summaries[0].Status != "experimental" { + t.Fatalf("expected status=experimental in summary, got %+v", summaries) + } +} diff --git a/cmd/wfctl/plugin_install.go b/cmd/wfctl/plugin_install.go index f42de9a3..d1c173eb 100644 --- a/cmd/wfctl/plugin_install.go +++ b/cmd/wfctl/plugin_install.go @@ -56,14 +56,18 @@ func runPluginSearch(args []string) error { fmt.Println("No plugins found.") return nil } - fmt.Printf("%-20s %-10s %-12s %-12s %s\n", "NAME", "VERSION", "TIER", "SOURCE", "DESCRIPTION") - fmt.Printf("%-20s %-10s %-12s %-12s %s\n", "----", "-------", "----", "------", "-----------") + fmt.Printf("%-20s %-10s %-12s %-14s %-12s %s\n", "NAME", "VERSION", "TIER", "STATUS", "SOURCE", "DESCRIPTION") + fmt.Printf("%-20s %-10s %-12s %-14s %-12s %s\n", "----", "-------", "----", "------", "------", "-----------") for _, p := range plugins { desc := p.Description if len(desc) > 50 { desc = desc[:47] + "..." } - fmt.Printf("%-20s %-10s %-12s %-12s %s\n", p.Name, p.Version, p.Tier, p.Source, desc) + status := p.Status + if status == "" { + status = "-" + } + fmt.Printf("%-20s %-10s %-12s %-14s %-12s %s\n", p.Name, p.Version, p.Tier, status, p.Source, desc) } return nil } @@ -459,14 +463,15 @@ func runPluginList(args []string) error { return nil } - fmt.Printf("%-20s %-10s %-10s %s\n", "NAME", "VERSION", "TYPE", "DESCRIPTION") - fmt.Printf("%-20s %-10s %-10s %s\n", "----", "-------", "----", "-----------") + fmt.Printf("%-20s %-10s %-10s %-14s %s\n", "NAME", "VERSION", "TYPE", "STATUS", "DESCRIPTION") + fmt.Printf("%-20s %-10s %-10s %-14s %s\n", "----", "-------", "----", "------", "-----------") for _, p := range plugins { desc := p.description if len(desc) > 40 { desc = desc[:37] + "..." } - fmt.Printf("%-20s %-10s %-10s %s\n", p.name, p.version, p.pluginType, desc) + // Status is not persisted to disk on install; render "-" for installed plugins. + fmt.Printf("%-20s %-10s %-10s %-14s %s\n", p.name, p.version, p.pluginType, "-", desc) } return nil } diff --git a/cmd/wfctl/plugin_registry.go b/cmd/wfctl/plugin_registry.go index 03455e99..8f08b3ea 100644 --- a/cmd/wfctl/plugin_registry.go +++ b/cmd/wfctl/plugin_registry.go @@ -31,6 +31,8 @@ type RegistryManifest struct { Source string `json:"source,omitempty"` Type string `json:"type"` Tier string `json:"tier"` + Status string `json:"status,omitempty"` // verified | experimental | deprecated + Private bool `json:"private,omitempty"` // mirrors manifest.json `private` field License string `json:"license"` MinEngineVersion string `json:"minEngineVersion,omitempty"` Repository string `json:"repository,omitempty"` @@ -101,6 +103,7 @@ type PluginSummary struct { Version string Description string Tier string + Status string // verified | experimental | deprecated; "" if not set } // githubContentsEntry is an entry from the GitHub contents API response. @@ -205,6 +208,7 @@ func SearchPlugins(query string) ([]PluginSummary, error) { Version: m.Version, Description: m.Description, Tier: m.Tier, + Status: m.Status, }) } } diff --git a/cmd/wfctl/registry_source.go b/cmd/wfctl/registry_source.go index 56ac363a..db09f69a 100644 --- a/cmd/wfctl/registry_source.go +++ b/cmd/wfctl/registry_source.go @@ -179,6 +179,7 @@ func (g *GitHubRegistrySource) SearchPlugins(query string) ([]PluginSearchResult Version: m.Version, Description: m.Description, Tier: m.Tier, + Status: m.Status, }, Source: g.name, }) @@ -251,6 +252,7 @@ type staticIndexEntry struct { Version string `json:"version"` Description string `json:"description"` Tier string `json:"tier"` + Status string `json:"status,omitempty"` // verified | experimental | deprecated } func (s *StaticRegistrySource) fetchIndex() ([]staticIndexEntry, error) { @@ -283,6 +285,7 @@ func (s *StaticRegistrySource) SearchPlugins(query string) ([]PluginSearchResult Version: e.Version, Description: e.Description, Tier: e.Tier, + Status: e.Status, }, Source: s.name, }) diff --git a/cmd/wfctl/registry_source_test.go b/cmd/wfctl/registry_source_test.go index 0dbf0bc2..f5e61418 100644 --- a/cmd/wfctl/registry_source_test.go +++ b/cmd/wfctl/registry_source_test.go @@ -465,3 +465,27 @@ func TestStaticRegistrySource_EmptyURL(t *testing.T) { t.Fatal("expected error for empty URL, got nil") } } + +// TestStaticRegistrySource_SearchPlugins_StatusPropagation verifies that the +// Status field from staticIndexEntry is propagated through SearchPlugins into +// the returned PluginSearchResult.Status. This exercises the +// `Status: e.Status` line in StaticRegistrySource.SearchPlugins directly. +func TestStaticRegistrySource_SearchPlugins_StatusPropagation(t *testing.T) { + index := []staticIndexEntry{ + {Name: "test-plugin", Version: "1.0.0", Description: "Test plugin", Tier: "community", Status: "experimental"}, + } + srv := buildStaticRegistryServer(t, index, nil) + defer srv.Close() + + src := mustNewStaticRegistrySource(t, RegistrySourceConfig{Name: "test-static", URL: srv.URL}) + results, err := src.SearchPlugins("") + if err != nil { + t.Fatalf("SearchPlugins: %v", err) + } + if len(results) != 1 { + t.Fatalf("expected 1 result, got %d", len(results)) + } + if results[0].Status != "experimental" { + t.Fatalf("expected Status=%q, got %q", "experimental", results[0].Status) + } +} diff --git a/cmd/wfctl/registry_validate.go b/cmd/wfctl/registry_validate.go index 11aba36c..384fc6d4 100644 --- a/cmd/wfctl/registry_validate.go +++ b/cmd/wfctl/registry_validate.go @@ -32,6 +32,7 @@ var semverRegex = regexp.MustCompile(`^\d+\.\d+\.\d+`) var sha256Regex = regexp.MustCompile(`^[0-9a-fA-F]{64}$`) var validPluginTypes = map[string]bool{"builtin": true, "external": true, "ui": true} var validPluginTiers = map[string]bool{"core": true, "community": true, "premium": true} +var validPluginStatuses = map[string]bool{"verified": true, "experimental": true, "deprecated": true} var validDownloadOS = map[string]bool{"linux": true, "darwin": true, "windows": true} var validDownloadArch = map[string]bool{"amd64": true, "arm64": true} @@ -64,6 +65,9 @@ func ValidateManifest(m *RegistryManifest, opts ValidationOptions) []ValidationE } else if !validPluginTiers[m.Tier] { errs = append(errs, ValidationError{Field: "tier", Message: fmt.Sprintf("must be one of: core, community, premium (got %q)", m.Tier)}) } + if m.Status != "" && !validPluginStatuses[m.Status] { + errs = append(errs, ValidationError{Field: "status", Message: fmt.Sprintf("must be one of: verified, experimental, deprecated (got %q)", m.Status)}) + } if m.License == "" { errs = append(errs, ValidationError{Field: "license", Message: "required field is empty"}) }