From 154c6142b28fdca2848106e9ef995f1cad434e06 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Tue, 19 May 2026 11:15:12 -0400 Subject: [PATCH 1/4] feat(wfctl): add status + private to RegistryManifest + PluginSummary - Adds optional Status (verified|experimental|deprecated) to RegistryManifest aligning with workflow-registry schema extension. - Adds Private bool to RegistryManifest mirroring existing manifest.json field (was silently discarded). - Propagates Status through PluginSummary + SearchPlugins callsites so wfctl plugin list and wfctl marketplace search surface the verification status. - Adds enum validation in ValidateManifest. - Updates staticIndexEntry to carry status from index.json. Co-Authored-By: Claude Opus 4.7 (1M context) --- cmd/wfctl/multi_registry_test.go | 71 ++++++++++++++++++++++++++++++++ cmd/wfctl/plugin_install.go | 10 +++-- cmd/wfctl/plugin_registry.go | 4 ++ cmd/wfctl/registry_source.go | 3 ++ cmd/wfctl/registry_validate.go | 4 ++ 5 files changed, 89 insertions(+), 3 deletions(-) diff --git a/cmd/wfctl/multi_registry_test.go b/cmd/wfctl/multi_registry_test.go index f3e8541e..5661a1e9 100644 --- a/cmd/wfctl/multi_registry_test.go +++ b/cmd/wfctl/multi_registry_test.go @@ -1,6 +1,7 @@ package main import ( + "encoding/json" "fmt" "os" "path/filepath" @@ -75,6 +76,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 +1219,72 @@ 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) { + m := &RegistryManifest{ + Name: "test", + Version: "1.0.0", + Author: "a", + Description: "d", + Type: "external", + Tier: "community", + License: "MIT", + Status: "experimental", + } + src := &mockRegistrySource{ + name: "test-source", + manifests: map[string]*RegistryManifest{"test": m}, + } + 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..6c9c8c74 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 } 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_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"}) } From 796fb29e90ebdb8a7018c5507be4ae1d2ad4bc1f Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Tue, 19 May 2026 11:24:40 -0400 Subject: [PATCH 2/4] fix(wfctl): add STATUS column to wfctl plugin list output runPluginList was missed in the initial implementation. Status is not persisted to disk on install so renders as "-" for installed plugins, keeping table alignment consistent with marketplace search output. Co-Authored-By: Claude Opus 4.7 (1M context) --- cmd/wfctl/plugin_install.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/cmd/wfctl/plugin_install.go b/cmd/wfctl/plugin_install.go index 6c9c8c74..d1c173eb 100644 --- a/cmd/wfctl/plugin_install.go +++ b/cmd/wfctl/plugin_install.go @@ -463,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 } From 9ad6b9b0cd2a19532b44086fb8914de176f44ee2 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Tue, 19 May 2026 11:32:19 -0400 Subject: [PATCH 3/4] test(wfctl): add StaticRegistrySource status propagation test TestStaticRegistrySource_SearchPlugins_StatusPropagation uses httptest.NewServer + buildStaticRegistryServer to exercise the `Status: e.Status` line in StaticRegistrySource.SearchPlugins directly, so removal of that line would cause the test to fail. Co-Authored-By: Claude Opus 4.7 (1M context) --- cmd/wfctl/registry_source_test.go | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) 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) + } +} From cb3d905f54e82ec937fc0e99850d3871c414ed17 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Tue, 19 May 2026 11:35:26 -0400 Subject: [PATCH 4/4] test(wfctl): refactor TestPluginSummary_StatusPropagation to real source Replace mockRegistrySource with a real StaticRegistrySource backed by httptest.NewServer so the test exercises the actual Status: e.Status propagation line in StaticRegistrySource.SearchPlugins. Removing that line now directly fails this test. Co-Authored-By: Claude Opus 4.7 (1M context) --- cmd/wfctl/multi_registry_test.go | 35 +++++++++++++++++++++----------- 1 file changed, 23 insertions(+), 12 deletions(-) diff --git a/cmd/wfctl/multi_registry_test.go b/cmd/wfctl/multi_registry_test.go index 5661a1e9..b66c2b48 100644 --- a/cmd/wfctl/multi_registry_test.go +++ b/cmd/wfctl/multi_registry_test.go @@ -3,6 +3,8 @@ package main import ( "encoding/json" "fmt" + "net/http" + "net/http/httptest" "os" "path/filepath" "sort" @@ -1266,19 +1268,28 @@ func TestRegistryManifest_PrivateField(t *testing.T) { } func TestPluginSummary_StatusPropagation(t *testing.T) { - m := &RegistryManifest{ - Name: "test", - Version: "1.0.0", - Author: "a", - Description: "d", - Type: "external", - Tier: "community", - License: "MIT", - Status: "experimental", + // 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) } - src := &mockRegistrySource{ - name: "test-source", - manifests: map[string]*RegistryManifest{"test": m}, + 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 {