From 40f4b8b8567dc3327d2b86912a7d682f9cd49a3b Mon Sep 17 00:00:00 2001 From: LTbinglingfeng Date: Sat, 13 Jun 2026 04:00:05 +0800 Subject: [PATCH 1/3] feat(pluginstore): fetch and install plugins from latest release Replace the tag-pinned release lookup with the repository latest release endpoint. Derive the plugin version from the release tag, validate it, and attach an optional token to API requests to raise the rate limit. --- .../handlers/management/plugin_store_test.go | 4 +- internal/pluginstore/github.go | 40 +++++++-- internal/pluginstore/github_test.go | 36 +++++++++ internal/pluginstore/install.go | 7 +- internal/pluginstore/install_test.go | 81 +++++++++++++++++++ 5 files changed, 160 insertions(+), 8 deletions(-) diff --git a/internal/api/handlers/management/plugin_store_test.go b/internal/api/handlers/management/plugin_store_test.go index dfa8c4f19..5a4804a36 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 1132b1cab..19fc0e591 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 39b2c2f9a..b96eea584 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 900515c6d..314dee05e 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 4beed53e3..573e77bfd 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", 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 98d333d33..ba2ef3c9b 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 7d8179a78..969e6ce64 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 5a4804a36..4cb59b4e4 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() 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 6a20fabce..f49a91f83 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 d8c89e8d6..1f95f4fbb 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()