diff --git a/internal/api/handlers/management/plugin_store_test.go b/internal/api/handlers/management/plugin_store_test.go index dfa8c4f1..5a4804a3 100644 --- a/internal/api/handlers/management/plugin_store_test.go +++ b/internal/api/handlers/management/plugin_store_test.go @@ -168,7 +168,7 @@ func TestInstallPluginFromStoreWritesFileAndEnablesConfig(t *testing.T) { pluginStoreRegistryURL: "https://registry.example/registry.json", pluginStoreHTTPClient: fakePluginStoreHTTPClient{ "https://registry.example/registry.json": registryJSON(t), - "https://api.github.com/repos/author-name/cliproxy-sample-provider-plugin/releases/tags/v0.1.0": []byte(`{ + "https://api.github.com/repos/author-name/cliproxy-sample-provider-plugin/releases/latest": []byte(`{ "tag_name": "v0.1.0", "assets": [ {"name": "` + archiveName + `", "browser_download_url": "https://downloads.example/` + archiveName + `"}, @@ -250,7 +250,7 @@ func TestInstallPluginFromStoreOverwritesFilePreservesConfigAndReloads(t *testin pluginStoreRegistryURL: "https://registry.example/registry.json", pluginStoreHTTPClient: fakePluginStoreHTTPClient{ "https://registry.example/registry.json": registryJSON(t), - "https://api.github.com/repos/author-name/cliproxy-sample-provider-plugin/releases/tags/v0.1.0": []byte(`{ + "https://api.github.com/repos/author-name/cliproxy-sample-provider-plugin/releases/latest": []byte(`{ "tag_name": "v0.1.0", "assets": [ {"name": "` + archiveName + `", "browser_download_url": "https://downloads.example/` + archiveName + `"}, diff --git a/internal/pluginstore/github.go b/internal/pluginstore/github.go index 1132b1ca..19fc0e59 100644 --- a/internal/pluginstore/github.go +++ b/internal/pluginstore/github.go @@ -6,6 +6,7 @@ import ( "fmt" "net/http" "net/url" + "os" "strings" "github.com/router-for-me/CLIProxyAPI/v7/internal/httpfetch" @@ -48,16 +49,17 @@ func (c Client) FetchRegistry(ctx context.Context) (Registry, error) { return registry, nil } -func (c Client) FetchRelease(ctx context.Context, plugin Plugin) (Release, error) { +// FetchLatestRelease returns the latest published release of the plugin's +// GitHub repository, mirroring the WebUI panel update check. +func (c Client) FetchLatestRelease(ctx context.Context, plugin Plugin) (Release, error) { owner, repo, errRepository := GitHubRepositoryParts(plugin.Repository) if errRepository != nil { return Release{}, errRepository } releaseURL := fmt.Sprintf( - "https://api.github.com/repos/%s/%s/releases/tags/%s", + "https://api.github.com/repos/%s/%s/releases/latest", url.PathEscape(owner), url.PathEscape(repo), - url.PathEscape("v"+strings.TrimSpace(plugin.Version)), ) data, errDownload := c.get(ctx, releaseURL, "application/vnd.github+json") if errDownload != nil { @@ -70,6 +72,16 @@ func (c Client) FetchRelease(ctx context.Context, plugin Plugin) (Release, error return release, nil } +// ReleaseVersion derives the plugin version from the release tag, stripping a +// leading "v"/"V" and validating the result. +func ReleaseVersion(release Release) (string, error) { + version := normalizeVersion(release.TagName) + if !validPluginVersion(version) { + return "", fmt.Errorf("invalid release tag %q", release.TagName) + } + return version, nil +} + func (c Client) DownloadAsset(ctx context.Context, asset ReleaseAsset) ([]byte, error) { if strings.TrimSpace(asset.BrowserDownloadURL) == "" { return nil, fmt.Errorf("asset %q missing browser_download_url", asset.Name) @@ -78,10 +90,28 @@ func (c Client) DownloadAsset(ctx context.Context, asset ReleaseAsset) ([]byte, } func (c Client) get(ctx context.Context, requestURL string, accept string) ([]byte, error) { - return httpfetch.GetBytes(ctx, c.httpClient(), requestURL, map[string]string{ + headers := map[string]string{ "Accept": accept, "User-Agent": c.userAgent(), - }, 0) + } + if token := gitHubAPIToken(requestURL); token != "" { + headers["Authorization"] = "Bearer " + token + } + return httpfetch.GetBytes(ctx, c.httpClient(), requestURL, headers, 0) +} + +// gitHubAPIToken returns the optional GitHub token for GitHub API requests to +// raise the unauthenticated rate limit, mirroring the management asset updater. +func gitHubAPIToken(requestURL string) string { + parsed, errParse := url.Parse(requestURL) + if errParse != nil || !strings.EqualFold(parsed.Host, "api.github.com") { + return "" + } + gitURL := strings.ToLower(strings.TrimSpace(os.Getenv("GITSTORE_GIT_URL"))) + if !strings.Contains(gitURL, "github.com") { + return "" + } + return strings.TrimSpace(os.Getenv("GITSTORE_GIT_TOKEN")) } func (c Client) httpClient() HTTPDoer { diff --git a/internal/pluginstore/github_test.go b/internal/pluginstore/github_test.go index 39b2c2f9..b96eea58 100644 --- a/internal/pluginstore/github_test.go +++ b/internal/pluginstore/github_test.go @@ -64,6 +64,42 @@ func TestSelectReleaseAssetsRejectsMissingAssets(t *testing.T) { } } +func TestReleaseVersion(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + tagName string + want string + wantErr bool + }{ + {name: "v prefix", tagName: "v1.2.3", want: "1.2.3"}, + {name: "no prefix", tagName: "0.1.0", want: "0.1.0"}, + {name: "whitespace", tagName: " v2.0.0 ", want: "2.0.0"}, + {name: "empty", tagName: "", wantErr: true}, + {name: "non numeric", tagName: "latest", wantErr: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + version, errVersion := ReleaseVersion(Release{TagName: tt.tagName}) + if tt.wantErr { + if errVersion == nil { + t.Fatalf("ReleaseVersion(%q) error = nil", tt.tagName) + } + return + } + if errVersion != nil { + t.Fatalf("ReleaseVersion(%q) error = %v", tt.tagName, errVersion) + } + if version != tt.want { + t.Fatalf("ReleaseVersion(%q) = %q, want %q", tt.tagName, version, tt.want) + } + }) + } +} + func TestParseChecksumsAndVerifyChecksum(t *testing.T) { t.Parallel() diff --git a/internal/pluginstore/install.go b/internal/pluginstore/install.go index 900515c6..314dee05 100644 --- a/internal/pluginstore/install.go +++ b/internal/pluginstore/install.go @@ -49,10 +49,15 @@ func (c Client) Install(ctx context.Context, plugin Plugin, options InstallOptio if loadedPluginInstallBlocked(options) && options.BeforeWrite == nil { return InstallResult{}, ErrLoadedPluginLocked } - release, errRelease := c.FetchRelease(ctx, plugin) + release, errRelease := c.FetchLatestRelease(ctx, plugin) if errRelease != nil { return InstallResult{}, errRelease } + latestVersion, errVersion := ReleaseVersion(release) + if errVersion != nil { + return InstallResult{}, errVersion + } + plugin.Version = latestVersion archiveAsset, checksumAsset, errAssets := SelectReleaseAssets(release, plugin.ID, plugin.Version, options.GOOS, options.GOARCH) if errAssets != nil { return InstallResult{}, errAssets diff --git a/internal/pluginstore/install_test.go b/internal/pluginstore/install_test.go index 4beed53e..573e77bf 100644 --- a/internal/pluginstore/install_test.go +++ b/internal/pluginstore/install_test.go @@ -4,7 +4,10 @@ import ( "archive/zip" "bytes" "context" + "crypto/sha256" + "encoding/hex" "errors" + "io" "net/http" "os" "path/filepath" @@ -249,6 +252,64 @@ func TestInstallArchiveRejectsUnsafeArchives(t *testing.T) { } } +func TestInstallUsesLatestReleaseVersion(t *testing.T) { + t.Parallel() + + root := t.TempDir() + archiveData := makeZip(t, map[string]string{"sample-provider.dylib": "library-data"}) + archiveName := "sample-provider_0.2.0_darwin_arm64.zip" + checksum := sha256.Sum256(archiveData) + client := Client{HTTPClient: mapHTTPDoer{ + "https://api.github.com/repos/author-name/cliproxy-sample-provider-plugin/releases/latest": []byte(`{ + "tag_name": "v0.2.0", + "assets": [ + {"name": "` + archiveName + `", "browser_download_url": "https://downloads.example/` + archiveName + `"}, + {"name": "checksums.txt", "browser_download_url": "https://downloads.example/checksums.txt"} + ] + }`), + "https://downloads.example/" + archiveName: archiveData, + "https://downloads.example/checksums.txt": []byte(hex.EncodeToString(checksum[:]) + " " + archiveName + "\n"), + }} + + result, errInstall := client.Install(context.Background(), testPlugin(), InstallOptions{ + PluginsDir: root, + GOOS: "darwin", + GOARCH: "arm64", + }) + if errInstall != nil { + t.Fatalf("Install() error = %v", errInstall) + } + if result.Version != "0.2.0" { + t.Fatalf("Version = %q, want 0.2.0 from latest release tag", result.Version) + } + data, errRead := os.ReadFile(filepath.Join(root, "darwin", "arm64", "sample-provider.dylib")) + if errRead != nil { + t.Fatalf("ReadFile() error = %v", errRead) + } + if string(data) != "library-data" { + t.Fatalf("installed data = %q", data) + } +} + +func TestInstallRejectsInvalidLatestReleaseTag(t *testing.T) { + t.Parallel() + + client := Client{HTTPClient: mapHTTPDoer{ + "https://api.github.com/repos/author-name/cliproxy-sample-provider-plugin/releases/latest": []byte(`{"tag_name": "latest", "assets": []}`), + }} + _, errInstall := client.Install(context.Background(), testPlugin(), InstallOptions{ + PluginsDir: t.TempDir(), + GOOS: "darwin", + GOARCH: "arm64", + }) + if errInstall == nil { + t.Fatal("Install() error = nil") + } + if !strings.Contains(errInstall.Error(), "invalid release tag") { + t.Fatalf("Install() error = %v, want invalid release tag", errInstall) + } +} + func makeZip(t *testing.T, files map[string]string) []byte { t.Helper() @@ -275,6 +336,26 @@ func (failingHTTPDoer) Do(*http.Request) (*http.Response, error) { return nil, errors.New("network unavailable") } +type mapHTTPDoer map[string][]byte + +func (c mapHTTPDoer) Do(req *http.Request) (*http.Response, error) { + body, ok := c[req.URL.String()] + if !ok { + return &http.Response{ + StatusCode: http.StatusNotFound, + Body: io.NopCloser(strings.NewReader("not found")), + Header: make(http.Header), + Request: req, + }, nil + } + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewReader(body)), + Header: make(http.Header), + Request: req, + }, nil +} + func testPlugin() Plugin { return Plugin{ ID: "sample-provider", -- 2.51.2 From 220b4e5bbd0a825e990e8908e27c0c236f1e55ac Mon Sep 17 00:00:00 2001 From: LTbinglingfeng Date: Sat, 13 Jun 2026 04:05:09 +0800 Subject: [PATCH 2/3] feat(management): resolve plugin store versions from latest releases List entries now show each plugin's latest release version and compute update availability against it, falling back to the registry version when the lookup fails. Lookups run concurrently and are cached per repository with a short failure TTL to respect API rate limits. --- internal/api/handlers/management/handler.go | 2 + .../api/handlers/management/plugin_store.go | 85 ++++++++++++- .../handlers/management/plugin_store_test.go | 115 ++++++++++++++++++ 3 files changed, 199 insertions(+), 3 deletions(-) diff --git a/internal/api/handlers/management/handler.go b/internal/api/handlers/management/handler.go index 98d333d3..ba2ef3c9 100644 --- a/internal/api/handlers/management/handler.go +++ b/internal/api/handlers/management/handler.go @@ -54,6 +54,8 @@ type Handler struct { configReloadHook func(context.Context, *config.Config) pluginStoreRegistryURL string pluginStoreHTTPClient pluginstore.HTTPDoer + pluginReleaseCacheMu sync.Mutex + pluginReleaseCache map[string]pluginReleaseCacheEntry } // NewHandler creates a new management handler instance. diff --git a/internal/api/handlers/management/plugin_store.go b/internal/api/handlers/management/plugin_store.go index 7d8179a7..969e6ce6 100644 --- a/internal/api/handlers/management/plugin_store.go +++ b/internal/api/handlers/management/plugin_store.go @@ -1,11 +1,14 @@ package management import ( + "context" "errors" "fmt" "net/http" "runtime" "strings" + "sync" + "time" "github.com/gin-gonic/gin" "github.com/router-for-me/CLIProxyAPI/v7/internal/config" @@ -17,6 +20,20 @@ import ( log "github.com/sirupsen/logrus" ) +const ( + // pluginReleaseCacheTTL bounds how long a resolved latest release version is + // reused before the GitHub API is queried again. + pluginReleaseCacheTTL = 10 * time.Minute + // pluginReleaseFailureCacheTTL throttles retries after a failed lookup so a + // rate-limited or unreachable API is not hammered on every listing. + pluginReleaseFailureCacheTTL = 30 * time.Second +) + +type pluginReleaseCacheEntry struct { + version string + expiresAt time.Time +} + type pluginStoreListResponse struct { PluginsEnabled bool `json:"plugins_enabled"` PluginsDir string `json:"plugins_dir"` @@ -77,16 +94,23 @@ func (h *Handler) ListPluginStore(c *gin.Context) { return } + latestVersions := h.latestPluginVersions(c.Request.Context(), client, registry.Plugins) + entries := make([]pluginStoreListEntry, 0, len(registry.Plugins)) - for _, plugin := range registry.Plugins { + for index, plugin := range registry.Plugins { status := statuses[plugin.ID] installedVersion := status.InstalledVersion + // Fall back to the registry version when the latest release is unknown. + storeVersion := plugin.Version + if latestVersions[index] != "" { + storeVersion = latestVersions[index] + } entries = append(entries, pluginStoreListEntry{ ID: htmlsanitize.String(plugin.ID), Name: htmlsanitize.String(plugin.Name), Description: htmlsanitize.String(plugin.Description), Author: htmlsanitize.String(plugin.Author), - Version: htmlsanitize.String(plugin.Version), + Version: htmlsanitize.String(storeVersion), Repository: htmlsanitize.String(plugin.Repository), Logo: htmlsanitize.String(plugin.Logo), Homepage: htmlsanitize.String(plugin.Homepage), @@ -99,7 +123,7 @@ func (h *Handler) ListPluginStore(c *gin.Context) { Registered: status.Registered, Enabled: status.Enabled, EffectiveEnabled: status.EffectiveEnabled, - UpdateAvailable: pluginstore.UpdateAvailable(installedVersion, plugin.Version), + UpdateAvailable: pluginstore.UpdateAvailable(installedVersion, storeVersion), }) } @@ -276,6 +300,61 @@ func (h *Handler) newPluginStoreClient(proxyURL string) pluginstore.Client { return pluginstore.Client{HTTPClient: client, RegistryURL: registryURL} } +// latestPluginVersions resolves the latest release version of each registry +// plugin concurrently, returning results positionally aligned with plugins. +// Unresolved entries are left empty so callers can fall back gracefully. +func (h *Handler) latestPluginVersions(ctx context.Context, client pluginstore.Client, plugins []pluginstore.Plugin) []string { + versions := make([]string, len(plugins)) + var wg sync.WaitGroup + for index := range plugins { + wg.Add(1) + go func(index int) { + defer wg.Done() + versions[index] = h.latestPluginVersion(ctx, client, plugins[index]) + }(index) + } + wg.Wait() + return versions +} + +// latestPluginVersion returns the plugin's latest release version, caching +// lookups per repository so repeated listings do not exhaust the GitHub API +// rate limit. Failed lookups are cached for a shorter interval and reported +// as an empty version. +func (h *Handler) latestPluginVersion(ctx context.Context, client pluginstore.Client, plugin pluginstore.Plugin) string { + repository := strings.TrimSpace(plugin.Repository) + if repository == "" { + return "" + } + now := time.Now() + h.pluginReleaseCacheMu.Lock() + entry, found := h.pluginReleaseCache[repository] + h.pluginReleaseCacheMu.Unlock() + if found && now.Before(entry.expiresAt) { + return entry.version + } + + version := "" + ttl := pluginReleaseFailureCacheTTL + release, errRelease := client.FetchLatestRelease(ctx, plugin) + if errRelease != nil { + log.WithError(errRelease).WithField("plugin_id", plugin.ID).Warn("pluginstore: failed to fetch latest release") + } else if latestVersion, errVersion := pluginstore.ReleaseVersion(release); errVersion != nil { + log.WithError(errVersion).WithField("plugin_id", plugin.ID).Warn("pluginstore: invalid latest release tag") + } else { + version = latestVersion + ttl = pluginReleaseCacheTTL + } + + h.pluginReleaseCacheMu.Lock() + if h.pluginReleaseCache == nil { + h.pluginReleaseCache = make(map[string]pluginReleaseCacheEntry) + } + h.pluginReleaseCache[repository] = pluginReleaseCacheEntry{version: version, expiresAt: now.Add(ttl)} + h.pluginReleaseCacheMu.Unlock() + return version +} + func pluginLocalStatuses(pluginsEnabled bool, pluginsDir string, configs map[string]config.PluginInstanceConfig, host *pluginhost.Host) (map[string]pluginLocalStatus, error) { statuses := map[string]pluginLocalStatus{} files, errDiscover := pluginhost.DiscoverPluginFiles(pluginsDir) diff --git a/internal/api/handlers/management/plugin_store_test.go b/internal/api/handlers/management/plugin_store_test.go index 5a4804a3..4cb59b4e 100644 --- a/internal/api/handlers/management/plugin_store_test.go +++ b/internal/api/handlers/management/plugin_store_test.go @@ -15,6 +15,7 @@ import ( "path/filepath" "runtime" "strings" + "sync" "testing" "github.com/gin-gonic/gin" @@ -146,6 +147,98 @@ func TestListPluginStoreEscapesRegistryStrings(t *testing.T) { } } +func TestListPluginStoreShowsLatestReleaseVersionAndCaches(t *testing.T) { + t.Parallel() + gin.SetMode(gin.TestMode) + + httpClient := &countingPluginStoreHTTPClient{responses: fakePluginStoreHTTPClient{ + "https://registry.example/registry.json": registryJSON(t), + "https://api.github.com/repos/author-name/cliproxy-sample-provider-plugin/releases/latest": []byte(`{ + "tag_name": "v0.2.0", + "assets": [] + }`), + }} + h := &Handler{ + cfg: &config.Config{ + Plugins: config.PluginsConfig{ + Enabled: true, + Dir: t.TempDir(), + }, + }, + configFilePath: writeTestConfigFile(t), + pluginStoreRegistryURL: "https://registry.example/registry.json", + pluginStoreHTTPClient: httpClient, + } + + listOnce := func() pluginStoreListResponse { + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodGet, "/v0/management/plugin-store", nil) + h.ListPluginStore(c) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + var body pluginStoreListResponse + if errDecode := json.Unmarshal(rec.Body.Bytes(), &body); errDecode != nil { + t.Fatalf("Unmarshal() error = %v; body=%s", errDecode, rec.Body.String()) + } + return body + } + + for call := 0; call < 2; call++ { + body := listOnce() + if len(body.Plugins) != 1 { + t.Fatalf("plugins len = %d, want 1", len(body.Plugins)) + } + if body.Plugins[0].Version != "0.2.0" { + t.Fatalf("version = %q, want 0.2.0 from latest release tag", body.Plugins[0].Version) + } + } + releaseCalls := httpClient.count("https://api.github.com/repos/author-name/cliproxy-sample-provider-plugin/releases/latest") + if releaseCalls != 1 { + t.Fatalf("latest release fetched %d times, want 1 (cached)", releaseCalls) + } +} + +func TestListPluginStoreFallsBackToRegistryVersion(t *testing.T) { + t.Parallel() + gin.SetMode(gin.TestMode) + + h := &Handler{ + cfg: &config.Config{ + Plugins: config.PluginsConfig{ + Enabled: true, + Dir: t.TempDir(), + }, + }, + configFilePath: writeTestConfigFile(t), + pluginStoreRegistryURL: "https://registry.example/registry.json", + pluginStoreHTTPClient: fakePluginStoreHTTPClient{ + "https://registry.example/registry.json": registryJSON(t), + }, + } + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodGet, "/v0/management/plugin-store", nil) + + h.ListPluginStore(c) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + var body pluginStoreListResponse + if errDecode := json.Unmarshal(rec.Body.Bytes(), &body); errDecode != nil { + t.Fatalf("Unmarshal() error = %v; body=%s", errDecode, rec.Body.String()) + } + if len(body.Plugins) != 1 { + t.Fatalf("plugins len = %d, want 1", len(body.Plugins)) + } + if body.Plugins[0].Version != "0.1.0" { + t.Fatalf("version = %q, want registry fallback 0.1.0", body.Plugins[0].Version) + } +} + func TestInstallPluginFromStoreWritesFileAndEnablesConfig(t *testing.T) { t.Parallel() gin.SetMode(gin.TestMode) @@ -368,6 +461,28 @@ func (c fakePluginStoreHTTPClient) Do(req *http.Request) (*http.Response, error) }, nil } +type countingPluginStoreHTTPClient struct { + responses fakePluginStoreHTTPClient + mu sync.Mutex + counts map[string]int +} + +func (c *countingPluginStoreHTTPClient) Do(req *http.Request) (*http.Response, error) { + c.mu.Lock() + if c.counts == nil { + c.counts = make(map[string]int) + } + c.counts[req.URL.String()]++ + c.mu.Unlock() + return c.responses.Do(req) +} + +func (c *countingPluginStoreHTTPClient) count(url string) int { + c.mu.Lock() + defer c.mu.Unlock() + return c.counts[url] +} + func registryJSON(t *testing.T) []byte { t.Helper() -- 2.51.2 From b2b5d10b759f3525ec250c37e93c5bfe4dc42fec Mon Sep 17 00:00:00 2001 From: LTbinglingfeng Date: Sat, 13 Jun 2026 04:10:11 +0800 Subject: [PATCH 3/3] feat(pluginstore): make registry version field optional The latest release is now the source of truth for plugin versions, so the registry version only serves as a display fallback. Validate its format only when present. --- internal/pluginstore/registry.go | 5 +++-- internal/pluginstore/registry_test.go | 15 +++++++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/internal/pluginstore/registry.go b/internal/pluginstore/registry.go index 6a20fabc..f49a91f8 100644 --- a/internal/pluginstore/registry.go +++ b/internal/pluginstore/registry.go @@ -94,7 +94,6 @@ func ValidatePlugin(plugin Plugin) error { "name": plugin.Name, "description": plugin.Description, "author": plugin.Author, - "version": plugin.Version, "repository": plugin.Repository, } for field, value := range required { @@ -105,7 +104,9 @@ func ValidatePlugin(plugin Plugin) error { if !pluginhost.ValidatePluginID(strings.TrimSpace(plugin.ID)) { return fmt.Errorf("invalid plugin id %q", plugin.ID) } - if !validPluginVersion(strings.TrimSpace(plugin.Version)) { + // The version is optional since the latest release is the source of truth; + // when present it is only used as a display fallback and must be valid. + if version := strings.TrimSpace(plugin.Version); version != "" && !validPluginVersion(version) { return fmt.Errorf("invalid plugin version %q", plugin.Version) } if _, _, errRepository := GitHubRepositoryParts(plugin.Repository); errRepository != nil { diff --git a/internal/pluginstore/registry_test.go b/internal/pluginstore/registry_test.go index d8c89e8d..1f95f4fb 100644 --- a/internal/pluginstore/registry_test.go +++ b/internal/pluginstore/registry_test.go @@ -68,6 +68,21 @@ func TestParseRegistryNormalizesPluginFields(t *testing.T) { } } +func TestValidateRegistryAllowsMissingVersion(t *testing.T) { + t.Parallel() + + registry := Registry{SchemaVersion: 1, Plugins: []Plugin{{ + ID: "sample-provider", + Name: "Sample Provider", + Description: "Adds sample provider support.", + Author: "author-name", + Repository: "https://github.com/author-name/cliproxy-sample-provider-plugin", + }}} + if errValidate := ValidateRegistry(registry); errValidate != nil { + t.Fatalf("ValidateRegistry() error = %v, want nil for missing version", errValidate) + } +} + func TestValidateRegistryRejectsInvalidEntries(t *testing.T) { t.Parallel()