feat(plugins): sync home plugin manifests

This commit is contained in:
hkfires
2026-06-22 22:25:44 +08:00
parent 079ec51f50
commit c58da381c7
16 changed files with 1371 additions and 18 deletions
+8
View File
@@ -22,6 +22,7 @@ import (
"github.com/router-for-me/CLIProxyAPI/v7/internal/cmd"
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
"github.com/router-for-me/CLIProxyAPI/v7/internal/home"
"github.com/router-for-me/CLIProxyAPI/v7/internal/homeplugins"
"github.com/router-for-me/CLIProxyAPI/v7/internal/logging"
"github.com/router-for-me/CLIProxyAPI/v7/internal/managementasset"
"github.com/router-for-me/CLIProxyAPI/v7/internal/misc"
@@ -299,6 +300,13 @@ func main() {
parsed.Home = homeCfg
parsed.Port = 8317 // Default to 8317 for home mode, can be overridden by home config
parsed.UsageStatisticsEnabled = true
ctxHomePlugins, cancelHomePlugins := context.WithTimeout(context.Background(), 30*time.Second)
errHomePlugins := homeplugins.Sync(ctxHomePlugins, parsed, pluginHost)
cancelHomePlugins()
if errHomePlugins != nil {
log.Errorf("failed to fetch plugins from home: %v", errHomePlugins)
return
}
cfg = parsed
// Keep a non-empty config path for downstream components (log paths, management assets, etc),
+183
View File
@@ -0,0 +1,183 @@
package homeplugins
import (
"context"
"fmt"
"net/http"
"runtime"
"strings"
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
"github.com/router-for-me/CLIProxyAPI/v7/internal/util"
sdkconfig "github.com/router-for-me/CLIProxyAPI/v7/sdk/config"
sdkpluginstore "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginstore"
"golang.org/x/sys/cpu"
"gopkg.in/yaml.v3"
)
type Platform struct {
GOOS string `json:"goos"`
GOARCH string `json:"goarch"`
Variant string `json:"variant,omitempty"`
}
type PluginRuntime interface {
PluginBusy(id string) bool
UnloadPlugin(id string) bool
}
// CurrentPlatform reports the platform used by pluginhost discovery.
func CurrentPlatform() Platform {
return Platform{
GOOS: runtime.GOOS,
GOARCH: runtime.GOARCH,
Variant: cpuVariant(),
}
}
func NormalizePlatform(platform Platform) Platform {
goos := strings.ToLower(strings.TrimSpace(platform.GOOS))
switch goos {
case "mac", "macos", "osx":
goos = "darwin"
}
goarch := strings.ToLower(strings.TrimSpace(platform.GOARCH))
switch goarch {
case "x64", "x86_64":
goarch = "amd64"
case "aarch64":
goarch = "arm64"
}
variant := strings.ToLower(strings.TrimSpace(platform.Variant))
return Platform{GOOS: goos, GOARCH: goarch, Variant: variant}
}
func Sync(ctx context.Context, cfg *config.Config, pluginRuntime PluginRuntime) error {
return SyncPlatform(ctx, cfg, pluginRuntime, CurrentPlatform())
}
func SyncPlatform(ctx context.Context, cfg *config.Config, pluginRuntime PluginRuntime, platform Platform) error {
if cfg == nil || !cfg.Home.Enabled || !cfg.Plugins.Enabled {
return nil
}
platform = NormalizePlatform(platform)
if platform.GOOS == "" {
return fmt.Errorf("home plugins: goos is required")
}
if platform.GOARCH == "" {
return fmt.Errorf("home plugins: goarch is required")
}
root := strings.TrimSpace(cfg.Plugins.Dir)
if root == "" {
root = "plugins"
}
client := newPluginStoreClient(cfg)
for id, item := range cfg.Plugins.Configs {
if !pluginConfigEnabled(item) {
continue
}
manifest, okManifest, errManifest := storeManifestFromPluginConfig(id, item)
if errManifest != nil {
return errManifest
}
if !okManifest {
continue
}
if errSync := installManifest(ctx, client, manifest, root, platform, pluginRuntime); errSync != nil {
return errSync
}
}
return nil
}
func installManifest(ctx context.Context, client sdkpluginstore.Client, manifest sdkpluginstore.Manifest, root string, platform Platform, pluginRuntime PluginRuntime) error {
id := strings.TrimSpace(manifest.ID)
if id == "" {
return fmt.Errorf("home plugins: manifest plugin id is empty")
}
pluginIsBusy := func() bool {
return pluginRuntime != nil && pluginRuntime.PluginBusy(id)
}
_, errInstall := client.InstallManifest(ctx, manifest, sdkpluginstore.InstallOptions{
PluginsDir: root,
GOOS: platform.GOOS,
GOARCH: platform.GOARCH,
PluginLoaded: pluginIsBusy,
BeforeWrite: func() error {
if !pluginIsBusy() {
return nil
}
if pluginRuntime == nil || !pluginRuntime.UnloadPlugin(id) && pluginIsBusy() {
return sdkpluginstore.ErrLoadedPluginLocked
}
return nil
},
})
if errInstall != nil {
return fmt.Errorf("home plugins: install %s: %w", id, errInstall)
}
return nil
}
func storeManifestFromPluginConfig(id string, item config.PluginInstanceConfig) (sdkpluginstore.Manifest, bool, error) {
if item.Raw.Kind == 0 {
return sdkpluginstore.Manifest{}, false, nil
}
storeNode := yamlMappingValue(&item.Raw, "store")
if storeNode == nil || storeNode.Kind == 0 {
return sdkpluginstore.Manifest{}, false, nil
}
var manifest sdkpluginstore.Manifest
if errDecode := storeNode.Decode(&manifest); errDecode != nil {
return sdkpluginstore.Manifest{}, false, fmt.Errorf("home plugins: decode store manifest for %s: %w", id, errDecode)
}
if strings.TrimSpace(manifest.ID) == "" {
manifest.ID = strings.TrimSpace(id)
}
if errValidate := manifest.Validate(); errValidate != nil {
return sdkpluginstore.Manifest{}, false, fmt.Errorf("home plugins: invalid store manifest for %s: %w", id, errValidate)
}
return manifest, true, nil
}
func yamlMappingValue(node *yaml.Node, key string) *yaml.Node {
if node == nil || node.Kind != yaml.MappingNode {
return nil
}
for i := 0; i+1 < len(node.Content); i += 2 {
keyNode := node.Content[i]
if keyNode == nil || keyNode.Value != key {
continue
}
return node.Content[i+1]
}
return nil
}
var newPluginStoreClient = func(cfg *config.Config) sdkpluginstore.Client {
client := &http.Client{}
if cfg != nil && strings.TrimSpace(cfg.ProxyURL) != "" {
util.SetProxy(&sdkconfig.SDKConfig{ProxyURL: strings.TrimSpace(cfg.ProxyURL)}, client)
}
return sdkpluginstore.NewClient(client, "")
}
func pluginConfigEnabled(item config.PluginInstanceConfig) bool {
return item.Enabled != nil && *item.Enabled
}
func cpuVariant() string {
if runtime.GOARCH != "amd64" {
return ""
}
if cpu.X86.HasAVX512F && cpu.X86.HasAVX512BW && cpu.X86.HasAVX512CD && cpu.X86.HasAVX512DQ && cpu.X86.HasAVX512VL {
return "v4"
}
if cpu.X86.HasAVX && cpu.X86.HasAVX2 && cpu.X86.HasBMI1 && cpu.X86.HasBMI2 && cpu.X86.HasFMA {
return "v3"
}
if cpu.X86.HasSSE3 && cpu.X86.HasSSSE3 && cpu.X86.HasSSE41 && cpu.X86.HasSSE42 && cpu.X86.HasPOPCNT {
return "v2"
}
return "v1"
}
+231
View File
@@ -0,0 +1,231 @@
package homeplugins
import (
"archive/zip"
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
sdkpluginstore "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginstore"
"gopkg.in/yaml.v3"
)
type fakePluginRuntime struct {
busy bool
unloaded []string
}
func (r *fakePluginRuntime) PluginBusy(id string) bool {
return r.busy
}
func (r *fakePluginRuntime) UnloadPlugin(id string) bool {
r.unloaded = append(r.unloaded, id)
r.busy = false
return true
}
func TestSyncPlatformInstallsManifestArtifact(t *testing.T) {
root := t.TempDir()
archiveData := makeZip(t, map[string]string{"sample.dll": "library-data"})
archiveName := "sample_0.2.0_windows_amd64.zip"
checksum := sha256.Sum256(archiveData)
httpClient := mapHTTPDoer{
"https://api.github.com/repos/owner/sample-plugin/releases/tags/v0.2.0": []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"),
}
restore := replacePluginStoreClientForTest(httpClient)
defer restore()
if errSync := SyncPlatform(context.Background(), syncTestConfig(t, root), nil, Platform{GOOS: "windows", GOARCH: "amd64"}); errSync != nil {
t.Fatalf("SyncPlatform() error = %v", errSync)
}
target := filepath.Join(root, "windows", "amd64", "sample.dll")
got, errRead := os.ReadFile(target)
if errRead != nil {
t.Fatalf("read target: %v", errRead)
}
if string(got) != "library-data" {
t.Fatalf("target data = %q, want library-data", string(got))
}
}
func TestSyncPlatformSkipsIdenticalBusyPlugin(t *testing.T) {
root := t.TempDir()
targetDir := filepath.Join(root, "windows", "amd64")
if errMkdir := os.MkdirAll(targetDir, 0o755); errMkdir != nil {
t.Fatalf("MkdirAll() error = %v", errMkdir)
}
target := filepath.Join(targetDir, "sample.dll")
if errWrite := os.WriteFile(target, []byte("library-data"), 0o644); errWrite != nil {
t.Fatalf("WriteFile() error = %v", errWrite)
}
archiveData := makeZip(t, map[string]string{"sample.dll": "library-data"})
archiveName := "sample_0.2.0_windows_amd64.zip"
checksum := sha256.Sum256(archiveData)
httpClient := mapHTTPDoer{
"https://api.github.com/repos/owner/sample-plugin/releases/tags/v0.2.0": []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"),
}
restore := replacePluginStoreClientForTest(httpClient)
defer restore()
runtime := &fakePluginRuntime{busy: true}
if errSync := SyncPlatform(context.Background(), syncTestConfig(t, root), runtime, Platform{GOOS: "windows", GOARCH: "amd64"}); errSync != nil {
t.Fatalf("SyncPlatform() error = %v", errSync)
}
if len(runtime.unloaded) != 0 {
t.Fatalf("UnloadPlugin() calls = %v, want none", runtime.unloaded)
}
got, errRead := os.ReadFile(target)
if errRead != nil {
t.Fatalf("read target: %v", errRead)
}
if string(got) != "library-data" {
t.Fatalf("target data = %q, want library-data", string(got))
}
}
func TestSyncPlatformSkipsConfigWithoutManifest(t *testing.T) {
restore := replacePluginStoreClientForTest(mapHTTPDoer{})
defer restore()
cfg := &config.Config{
Home: config.HomeConfig{Enabled: true},
Plugins: config.PluginsConfig{
Enabled: true,
Dir: t.TempDir(),
Configs: map[string]config.PluginInstanceConfig{
"sample": pluginConfigFromYAML(t, `enabled: true`),
},
},
}
if errSync := SyncPlatform(context.Background(), cfg, nil, Platform{GOOS: "linux", GOARCH: "amd64"}); errSync != nil {
t.Fatalf("SyncPlatform() error = %v", errSync)
}
}
func TestSyncPlatformRejectsInvalidManifest(t *testing.T) {
cfg := &config.Config{
Home: config.HomeConfig{Enabled: true},
Plugins: config.PluginsConfig{
Enabled: true,
Dir: t.TempDir(),
Configs: map[string]config.PluginInstanceConfig{
"sample": pluginConfigFromYAML(t, `
enabled: true
store:
id: sample
`),
},
},
}
if errSync := SyncPlatform(context.Background(), cfg, nil, Platform{GOOS: "linux", GOARCH: "amd64"}); errSync == nil {
t.Fatal("SyncPlatform() error = nil, want invalid manifest")
}
}
func syncTestConfig(t *testing.T, root string) *config.Config {
t.Helper()
return &config.Config{
Home: config.HomeConfig{Enabled: true},
Plugins: config.PluginsConfig{
Enabled: true,
Dir: root,
Configs: map[string]config.PluginInstanceConfig{
"sample": pluginConfigFromYAML(t, `
enabled: true
store:
id: sample
name: Sample
description: Adds sample support.
author: owner
version: 0.2.0
release-tag: v0.2.0
repository: https://github.com/owner/sample-plugin
`),
},
},
}
}
func pluginConfigFromYAML(t *testing.T, text string) config.PluginInstanceConfig {
t.Helper()
var item config.PluginInstanceConfig
if errUnmarshal := yaml.Unmarshal([]byte(text), &item); errUnmarshal != nil {
t.Fatalf("unmarshal plugin config: %v", errUnmarshal)
}
return item
}
func replacePluginStoreClientForTest(httpClient sdkpluginstore.HTTPDoer) func() {
previous := newPluginStoreClient
newPluginStoreClient = func(cfg *config.Config) sdkpluginstore.Client {
return sdkpluginstore.NewClient(httpClient, "")
}
return func() {
newPluginStoreClient = previous
}
}
func makeZip(t *testing.T, files map[string]string) []byte {
t.Helper()
var buffer bytes.Buffer
writer := zip.NewWriter(&buffer)
for name, content := range files {
file, errCreate := writer.Create(name)
if errCreate != nil {
t.Fatalf("Create(%s) error = %v", name, errCreate)
}
if _, errWrite := file.Write([]byte(content)); errWrite != nil {
t.Fatalf("Write(%s) error = %v", name, errWrite)
}
}
if errClose := writer.Close(); errClose != nil {
t.Fatalf("Close() error = %v", errClose)
}
return buffer.Bytes()
}
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
}
+72
View File
@@ -328,6 +328,78 @@ func (h *Host) callPollLogin(ctx context.Context, record capabilityRecord, provi
return resp, true, nil
}
func (h *Host) RefreshAuth(ctx context.Context, auth *coreauth.Auth) (refreshed *coreauth.Auth, handled bool, err error) {
if h == nil || auth == nil {
return nil, false, nil
}
record := h.authProviderRecord(authProvider(auth))
if record == nil || record.plugin.Capabilities.AuthProvider == nil {
return nil, false, nil
}
defer func() {
if recovered := recover(); recovered != nil {
h.fusePlugin(record.id, "AuthProvider.RefreshAuth", recovered)
refreshed = nil
handled = true
err = fmt.Errorf("auth provider refresh panic: %v", recovered)
}
}()
pluginResp, errRefresh := record.plugin.Capabilities.AuthProvider.RefreshAuth(ctx, pluginapi.AuthRefreshRequest{
AuthID: authID(auth),
AuthProvider: authProvider(auth),
StorageJSON: storageJSONFromAuth(auth),
Metadata: cloneAnyMap(authMetadata(auth)),
Attributes: authAttributes(auth),
Host: h.hostConfigSummary(),
HTTPClient: h.newHTTPClient(auth),
})
if errRefresh != nil {
return nil, true, errRefresh
}
data := pluginResp.Auth
if strings.TrimSpace(data.Provider) == "" {
data.Provider = authProvider(auth)
}
if strings.TrimSpace(data.ID) == "" {
data.ID = authID(auth)
}
if strings.TrimSpace(data.FileName) == "" {
data.FileName = auth.FileName
}
if strings.TrimSpace(data.Label) == "" {
data.Label = auth.Label
}
if strings.TrimSpace(data.Prefix) == "" {
data.Prefix = auth.Prefix
}
if strings.TrimSpace(data.ProxyURL) == "" {
data.ProxyURL = auth.ProxyURL
}
if len(data.Metadata) == 0 {
data.Metadata = cloneAnyMap(auth.Metadata)
}
if len(data.Attributes) == 0 {
data.Attributes = cloneStringMap(auth.Attributes)
}
if len(data.StorageJSON) == 0 {
data.StorageJSON = storageJSONFromAuth(auth)
}
if pluginResp.NextRefreshAfter.IsZero() {
data.NextRefreshAfter = auth.NextRefreshAfter
} else {
data.NextRefreshAfter = pluginResp.NextRefreshAfter
}
next := h.AuthDataToCoreAuth(data, "", data.FileName)
if next == nil {
return nil, true, fmt.Errorf("auth provider refresh returned invalid auth data")
}
next.Index = auth.Index
next.CreatedAt = auth.CreatedAt
next.UpdatedAt = auth.UpdatedAt
return next, true, nil
}
func (h *Host) AuthDataToCoreAuth(data pluginapi.AuthData, path, fileName string) *coreauth.Auth {
authDir := ""
if h != nil {
+47
View File
@@ -267,6 +267,53 @@ func TestPollLoginPassesProviderStateHostAndHTTPClient(t *testing.T) {
}
}
func TestRefreshAuthPreservesAuthIndex(t *testing.T) {
host := newHostWithRecords(capabilityRecord{
id: "auth-plugin",
plugin: pluginapi.Plugin{
Capabilities: pluginapi.Capabilities{
AuthProvider: fakeAuthProvider{
identifier: "plugin-provider",
refreshAuth: func(ctx context.Context, req pluginapi.AuthRefreshRequest) (pluginapi.AuthRefreshResponse, error) {
if req.AuthID != "auth-1" || req.AuthProvider != "plugin-provider" {
t.Fatalf("RefreshAuth request = %#v, want auth id/provider", req)
}
return pluginapi.AuthRefreshResponse{
Auth: pluginapi.AuthData{
Metadata: map[string]any{"access_token": "new-token"},
},
}, nil
},
},
},
},
})
auth := host.AuthDataToCoreAuth(pluginapi.AuthData{
Provider: "plugin-provider",
ID: "auth-1",
Metadata: map[string]any{"access_token": "old-token"},
}, "", "")
if auth == nil {
t.Fatal("AuthDataToCoreAuth() = nil, want auth")
}
auth.Index = "home-index-1"
refreshed, handled, errRefresh := host.RefreshAuth(context.Background(), auth)
if errRefresh != nil {
t.Fatalf("RefreshAuth() error = %v", errRefresh)
}
if !handled || refreshed == nil {
t.Fatalf("RefreshAuth() handled=%t auth=%#v, want refreshed auth", handled, refreshed)
}
if refreshed.Index != "home-index-1" {
t.Fatalf("RefreshAuth() index = %q, want home-index-1", refreshed.Index)
}
if got := refreshed.Metadata["access_token"]; got != "new-token" {
t.Fatalf("RefreshAuth() access_token = %q, want new-token", got)
}
}
func TestHostAuthDataToCoreAuthRejectsMissingProviderAndUsesAuthDir(t *testing.T) {
authDir := t.TempDir()
host := New()
+27
View File
@@ -72,6 +72,33 @@ func (c Client) FetchLatestRelease(ctx context.Context, plugin Plugin) (Release,
return release, nil
}
// FetchReleaseByTag returns a published release by its exact GitHub tag.
func (c Client) FetchReleaseByTag(ctx context.Context, plugin Plugin, tag string) (Release, error) {
owner, repo, errRepository := GitHubRepositoryParts(plugin.Repository)
if errRepository != nil {
return Release{}, errRepository
}
tag = strings.TrimSpace(tag)
if tag == "" {
return Release{}, fmt.Errorf("release tag is required")
}
releaseURL := fmt.Sprintf(
"https://api.github.com/repos/%s/%s/releases/tags/%s",
url.PathEscape(owner),
url.PathEscape(repo),
url.PathEscape(tag),
)
data, errDownload := c.get(ctx, releaseURL, "application/vnd.github+json")
if errDownload != nil {
return Release{}, errDownload
}
var release Release
if errDecode := json.Unmarshal(data, &release); errDecode != nil {
return Release{}, fmt.Errorf("decode release: %w", errDecode)
}
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) {
+154 -5
View File
@@ -11,10 +11,11 @@ import (
"path"
"path/filepath"
"runtime"
"sort"
"strings"
"github.com/router-for-me/CLIProxyAPI/v7/internal/pluginhost"
log "github.com/sirupsen/logrus"
"golang.org/x/sys/cpu"
)
type InstallOptions struct {
@@ -39,6 +40,7 @@ type InstallResult struct {
Version string `json:"version"`
Path string `json:"path"`
Overwritten bool `json:"overwritten"`
Skipped bool `json:"skipped"`
}
func (c Client) Install(ctx context.Context, plugin Plugin, options InstallOptions) (InstallResult, error) {
@@ -58,6 +60,42 @@ func (c Client) Install(ctx context.Context, plugin Plugin, options InstallOptio
return InstallResult{}, errVersion
}
plugin.Version = latestVersion
return c.installRelease(ctx, plugin, release, latestVersion, options)
}
// InstallVersion installs a plugin artifact from a fixed release tag/version.
func (c Client) InstallVersion(ctx context.Context, plugin Plugin, releaseTag string, version string, options InstallOptions) (InstallResult, error) {
if errValidate := ValidatePlugin(plugin); errValidate != nil {
return InstallResult{}, errValidate
}
options = normalizeInstallOptions(options)
if loadedPluginInstallBlocked(options) && options.BeforeWrite == nil {
return InstallResult{}, ErrLoadedPluginLocked
}
version = normalizeVersion(version)
if !validPluginVersion(version) {
return InstallResult{}, fmt.Errorf("invalid plugin version %q", version)
}
releaseTag = strings.TrimSpace(releaseTag)
if releaseTag == "" {
releaseTag = version
}
release, errRelease := c.FetchReleaseByTag(ctx, plugin, releaseTag)
if errRelease != nil {
return InstallResult{}, errRelease
}
releaseVersion, errVersion := ReleaseVersion(release)
if errVersion != nil {
return InstallResult{}, errVersion
}
if releaseVersion != version {
return InstallResult{}, fmt.Errorf("release tag %q resolved version %q, want %q", releaseTag, releaseVersion, version)
}
plugin.Version = version
return c.installRelease(ctx, plugin, release, version, options)
}
func (c Client) installRelease(ctx context.Context, plugin Plugin, release Release, version string, options InstallOptions) (InstallResult, error) {
archiveAsset, checksumAsset, errAssets := SelectReleaseAssets(release, plugin.ID, plugin.Version, options.GOOS, options.GOARCH)
if errAssets != nil {
return InstallResult{}, errAssets
@@ -77,13 +115,14 @@ func (c Client) Install(ctx context.Context, plugin Plugin, options InstallOptio
if errVerify := VerifyChecksum(archiveAsset.Name, archiveData, checksums); errVerify != nil {
return InstallResult{}, errVerify
}
plugin.Version = version
return InstallArchive(archiveData, plugin, options)
}
func InstallArchive(archiveData []byte, plugin Plugin, options InstallOptions) (InstallResult, error) {
options = normalizeInstallOptions(options)
id := strings.TrimSpace(plugin.ID)
if !pluginhost.ValidatePluginID(id) {
if !validPluginID(id) {
return InstallResult{}, fmt.Errorf("invalid plugin id %q", plugin.ID)
}
reader, errZip := zip.NewReader(bytes.NewReader(archiveData), int64(len(archiveData)))
@@ -106,6 +145,21 @@ func InstallArchive(archiveData []byte, plugin Plugin, options InstallOptions) (
} else if !errors.Is(errStat, os.ErrNotExist) {
return InstallResult{}, fmt.Errorf("stat target plugin: %w", errStat)
}
if overwritten {
existingData, errReadExisting := os.ReadFile(targetPath)
if errReadExisting != nil {
return InstallResult{}, fmt.Errorf("read target plugin: %w", errReadExisting)
}
if bytes.Equal(existingData, libraryData) {
return InstallResult{
ID: id,
Version: strings.TrimSpace(plugin.Version),
Path: targetPath,
Overwritten: true,
Skipped: true,
}, nil
}
}
// Re-check immediately before writing: the plugin may have been loaded
// while the archive was being downloaded and verified.
if options.BeforeWrite != nil {
@@ -128,11 +182,11 @@ func InstallArchive(archiveData []byte, plugin Plugin, options InstallOptions) (
}
func installTargetPath(options InstallOptions, id string) (string, error) {
defaultPath := filepath.Join(options.PluginsDir, options.GOOS, options.GOARCH, id+pluginhost.PluginExtension(options.GOOS))
defaultPath := filepath.Join(options.PluginsDir, options.GOOS, options.GOARCH, id+pluginExtension(options.GOOS))
if options.GOOS != runtime.GOOS || options.GOARCH != runtime.GOARCH {
return defaultPath, nil
}
files, errDiscover := pluginhost.DiscoverPluginFiles(options.PluginsDir)
files, errDiscover := discoverCurrentPluginFiles(options.PluginsDir)
if errDiscover != nil {
return "", fmt.Errorf("discover current plugin files: %w", errDiscover)
}
@@ -145,7 +199,7 @@ func installTargetPath(options InstallOptions, id string) (string, error) {
}
func readTargetLibrary(reader *zip.Reader, id string, goos string) ([]byte, os.FileMode, error) {
targetName := strings.TrimSpace(id) + pluginhost.PluginExtension(goos)
targetName := strings.TrimSpace(id) + pluginExtension(goos)
var target *zip.File
for _, file := range reader.File {
cleanedName, errClean := cleanZipName(file.Name)
@@ -223,6 +277,101 @@ func hasDynamicLibraryExtension(name string) bool {
return strings.HasSuffix(lowerName, ".dylib") || strings.HasSuffix(lowerName, ".so") || strings.HasSuffix(lowerName, ".dll")
}
type pluginFileInfo struct {
ID string
Path string
}
func discoverCurrentPluginFiles(root string) ([]pluginFileInfo, error) {
root = strings.TrimSpace(root)
if root == "" {
root = "plugins"
}
candidates := pluginCandidateDirs(root, runtime.GOOS, runtime.GOARCH, cpuVariant())
extension := pluginExtension(runtime.GOOS)
selected := make([]pluginFileInfo, 0)
seen := make(map[string]struct{})
for _, dir := range candidates {
entries, errReadDir := os.ReadDir(dir)
if errReadDir != nil {
if os.IsNotExist(errReadDir) {
continue
}
return nil, errReadDir
}
files := make([]string, 0, len(entries))
for _, entry := range entries {
if entry == nil || !entry.Type().IsRegular() {
continue
}
if strings.HasSuffix(strings.ToLower(entry.Name()), extension) {
files = append(files, filepath.Join(dir, entry.Name()))
}
}
sort.Strings(files)
for _, path := range files {
id := pluginIDFromPath(path)
if !validPluginID(id) {
continue
}
if _, exists := seen[id]; exists {
continue
}
seen[id] = struct{}{}
selected = append(selected, pluginFileInfo{ID: id, Path: path})
}
}
return selected, nil
}
func pluginCandidateDirs(root string, goos string, goarch string, variant string) []string {
dirs := make([]string, 0, 3)
if variant != "" {
dirs = append(dirs, filepath.Join(root, goos, goarch+"-"+variant))
}
dirs = append(dirs, filepath.Join(root, goos, goarch))
dirs = append(dirs, root)
return dirs
}
func pluginIDFromPath(path string) string {
base := filepath.Base(path)
lowerBase := strings.ToLower(base)
for _, extension := range []string{".so", ".dylib", ".dll"} {
if strings.HasSuffix(lowerBase, extension) {
return base[:len(base)-len(extension)]
}
}
return base
}
func pluginExtension(goos string) string {
switch strings.ToLower(strings.TrimSpace(goos)) {
case "darwin", "mac", "macos", "osx":
return ".dylib"
case "windows":
return ".dll"
default:
return ".so"
}
}
func cpuVariant() string {
if runtime.GOARCH != "amd64" {
return ""
}
if cpu.X86.HasAVX512F && cpu.X86.HasAVX512BW && cpu.X86.HasAVX512CD && cpu.X86.HasAVX512DQ && cpu.X86.HasAVX512VL {
return "v4"
}
if cpu.X86.HasAVX && cpu.X86.HasAVX2 && cpu.X86.HasBMI1 && cpu.X86.HasBMI2 && cpu.X86.HasFMA {
return "v3"
}
if cpu.X86.HasSSE3 && cpu.X86.HasSSSE3 && cpu.X86.HasSSE41 && cpu.X86.HasSSE42 && cpu.X86.HasPOPCNT {
return "v2"
}
return "v1"
}
func writeFileAtomic(targetPath string, data []byte, mode os.FileMode) error {
targetDir := filepath.Dir(targetPath)
if errMkdir := os.MkdirAll(targetDir, 0o755); errMkdir != nil {
+88 -4
View File
@@ -14,8 +14,6 @@ import (
"runtime"
"strings"
"testing"
"github.com/router-for-me/CLIProxyAPI/v7/internal/pluginhost"
)
func TestInstallBlocksLoadedWindowsPlugin(t *testing.T) {
@@ -114,6 +112,53 @@ func TestInstallArchivePreparesLoadedWindowsPluginBeforeWrite(t *testing.T) {
}
}
func TestInstallArchiveSkipsIdenticalLoadedWindowsPlugin(t *testing.T) {
t.Parallel()
root := t.TempDir()
targetDir := filepath.Join(root, "windows", "amd64")
if errMkdir := os.MkdirAll(targetDir, 0o755); errMkdir != nil {
t.Fatalf("MkdirAll() error = %v", errMkdir)
}
targetPath := filepath.Join(targetDir, "sample-provider.dll")
if errWrite := os.WriteFile(targetPath, []byte("same"), 0o644); errWrite != nil {
t.Fatalf("WriteFile() error = %v", errWrite)
}
beforeWriteCalled := false
result, errInstall := InstallArchive(makeZip(t, map[string]string{
"sample-provider.dll": "same",
}), testPlugin(), InstallOptions{
PluginsDir: root,
GOOS: "windows",
GOARCH: "amd64",
PluginLoaded: func() bool { return true },
BeforeWrite: func() error {
beforeWriteCalled = true
return errors.New("before write should not run")
},
})
if errInstall != nil {
t.Fatalf("InstallArchive() error = %v", errInstall)
}
if beforeWriteCalled {
t.Fatal("BeforeWrite was called for identical artifact")
}
if !result.Overwritten {
t.Fatal("Overwritten = false, want true")
}
if !result.Skipped {
t.Fatal("Skipped = false, want true")
}
data, errRead := os.ReadFile(targetPath)
if errRead != nil {
t.Fatalf("ReadFile() error = %v", errRead)
}
if string(data) != "same" {
t.Fatalf("installed data = %q, want same", data)
}
}
func TestInstallArchiveWritesPlatformPlugin(t *testing.T) {
t.Parallel()
@@ -164,13 +209,13 @@ func TestInstallArchiveOverwritesRuntimeSelectedPlugin(t *testing.T) {
t.Parallel()
root := t.TempDir()
existingPath := filepath.Join(root, "sample-provider"+pluginhost.PluginExtension(runtime.GOOS))
existingPath := filepath.Join(root, "sample-provider"+pluginExtension(runtime.GOOS))
if errWrite := os.WriteFile(existingPath, []byte("old"), 0o644); errWrite != nil {
t.Fatalf("WriteFile() error = %v", errWrite)
}
result, errInstall := InstallArchive(makeZip(t, map[string]string{
"sample-provider" + pluginhost.PluginExtension(runtime.GOOS): "new",
"sample-provider" + pluginExtension(runtime.GOOS): "new",
}), testPlugin(), InstallOptions{PluginsDir: root, GOOS: runtime.GOOS, GOARCH: runtime.GOARCH})
if errInstall != nil {
t.Fatalf("InstallArchive() error = %v", errInstall)
@@ -291,6 +336,45 @@ func TestInstallUsesLatestReleaseVersion(t *testing.T) {
}
}
func TestInstallVersionUsesPinnedReleaseTag(t *testing.T) {
t.Parallel()
root := t.TempDir()
archiveData := makeZip(t, map[string]string{"sample-provider.so": "library-data"})
archiveName := "sample-provider_0.3.0_linux_amd64.zip"
checksum := sha256.Sum256(archiveData)
client := Client{HTTPClient: mapHTTPDoer{
"https://api.github.com/repos/author-name/cliproxy-sample-provider-plugin/releases/tags/v0.3.0": []byte(`{
"tag_name": "v0.3.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.InstallVersion(context.Background(), testPlugin(), "v0.3.0", "0.3.0", InstallOptions{
PluginsDir: root,
GOOS: "linux",
GOARCH: "amd64",
})
if errInstall != nil {
t.Fatalf("InstallVersion() error = %v", errInstall)
}
if result.Version != "0.3.0" {
t.Fatalf("Version = %q, want 0.3.0", result.Version)
}
data, errRead := os.ReadFile(filepath.Join(root, "linux", "amd64", "sample-provider.so"))
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()
+6 -3
View File
@@ -9,8 +9,6 @@ import (
"net/url"
"regexp"
"strings"
"github.com/router-for-me/CLIProxyAPI/v7/internal/pluginhost"
)
const (
@@ -21,6 +19,7 @@ const (
)
var pluginVersionPattern = regexp.MustCompile(`^[0-9][0-9A-Za-z.+-]*$`)
var pluginIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$`)
type Source struct {
ID string `json:"id"`
@@ -159,7 +158,7 @@ func ValidatePlugin(plugin Plugin) error {
return fmt.Errorf("missing required field %s", field)
}
}
if !pluginhost.ValidatePluginID(strings.TrimSpace(plugin.ID)) {
if !validPluginID(strings.TrimSpace(plugin.ID)) {
return fmt.Errorf("invalid plugin id %q", plugin.ID)
}
// The version is optional since the latest release is the source of truth;
@@ -177,6 +176,10 @@ func validPluginVersion(version string) bool {
return version != "" && !strings.HasPrefix(version, "v") && pluginVersionPattern.MatchString(version)
}
func validPluginID(id string) bool {
return pluginIDPattern.MatchString(id)
}
func GitHubRepositoryParts(repository string) (string, string, error) {
repository = strings.TrimSpace(repository)
parsed, errParse := url.Parse(repository)
@@ -92,4 +92,7 @@ func TestRefreshAuthViaHomeAcceptsAuthEnvelope(t *testing.T) {
if got := updated.Metadata["access_token"]; got != "new-access-token" {
t.Fatalf("updated access_token = %q, want new-access-token", got)
}
if updated.Index != "home-index-1" {
t.Fatalf("updated auth_index = %q, want home-index-1", updated.Index)
}
}
+4 -2
View File
@@ -379,8 +379,10 @@ func (a *Auth) EnsureIndex() string {
if a == nil {
return ""
}
if a.indexAssigned && a.Index != "" {
return a.Index
if existingIndex := strings.TrimSpace(a.Index); existingIndex != "" {
a.Index = existingIndex
a.indexAssigned = true
return existingIndex
}
seed := a.indexSeed()
+15
View File
@@ -0,0 +1,15 @@
package cliproxy
import (
"context"
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
"github.com/router-for-me/CLIProxyAPI/v7/internal/homeplugins"
)
func (s *Service) syncHomePlugins(ctx context.Context, cfg *config.Config) error {
if s == nil || cfg == nil || !cfg.Home.Enabled || !cfg.Plugins.Enabled {
return nil
}
return homeplugins.Sync(ctx, cfg, s.pluginHost)
}
+16 -4
View File
@@ -1424,15 +1424,24 @@ func forceHomeRuntimeConfig(cfg *config.Config) {
}
func (s *Service) applyHomeOverlay(remoteCfg *config.Config) {
if errApply := s.applyHomeOverlayContext(context.Background(), remoteCfg); errApply != nil {
log.Warnf("failed to apply home config payload: %v", errApply)
}
}
func (s *Service) applyHomeOverlayContext(ctx context.Context, remoteCfg *config.Config) error {
if s == nil || remoteCfg == nil {
return
return nil
}
if ctx == nil {
ctx = context.Background()
}
s.cfgMu.RLock()
baseCfg := s.cfg
s.cfgMu.RUnlock()
if baseCfg == nil {
return
return nil
}
merged := *remoteCfg
@@ -1443,7 +1452,11 @@ func (s *Service) applyHomeOverlay(remoteCfg *config.Config) {
forceHomeRuntimeConfig(&merged)
logHomeConfigChanges(baseCfg, &merged)
if errSync := s.syncHomePlugins(ctx, &merged); errSync != nil {
return errSync
}
s.applyConfigUpdate(&merged)
return nil
}
func logHomeConfigChanges(oldCfg, newCfg *config.Config) {
@@ -1567,8 +1580,7 @@ func (s *Service) startHomeSubscriber(ctx context.Context) {
log.Warnf("failed to parse home config payload: %v", err)
return err
}
s.applyHomeOverlay(parsed)
return nil
return s.applyHomeOverlayContext(homeCtx, parsed)
})
s.startHomeUsageForwarder(homeCtx, client)
s.homeLogForwarder = logging.StartHomeAppLogForwarder(0)
+278
View File
@@ -0,0 +1,278 @@
package pluginhost
import (
"context"
internalconfig "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
internalpluginhost "github.com/router-for-me/CLIProxyAPI/v7/internal/pluginhost"
internalregistry "github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi"
"gopkg.in/yaml.v3"
)
// ModelInfo describes a plugin-provided model using public plugin SDK types.
type ModelInfo = pluginapi.ModelInfo
// ThinkingSupport describes plugin-provided thinking controls.
type ThinkingSupport = pluginapi.ThinkingSupport
// OAuthModelAlias defines a model ID alias for OAuth/file-backed auth channels.
type OAuthModelAlias struct {
Name string
Alias string
Fork bool
}
// RuntimeConfig is the public plugin host configuration used by embedders.
type RuntimeConfig struct {
Enabled bool
Dir string
AuthDir string
ProxyURL string
ForceModelPrefix bool
OAuthModelAlias map[string][]OAuthModelAlias
OAuthExcludedModels map[string][]string
Configs map[string]PluginInstanceConfig
}
// PluginInstanceConfig stores host-owned plugin settings and the original plugin YAML subtree.
type PluginInstanceConfig struct {
Enabled *bool
Priority int
Raw yaml.Node
}
// AuthModelResult is the public result for per-auth model discovery.
type AuthModelResult struct {
Provider string
Models []ModelInfo
Auth *coreauth.Auth
Handled bool
Err error
}
// Host wraps the internal plugin host behind a public SDK surface.
type Host struct {
inner *internalpluginhost.Host
}
// New creates a plugin host.
func New() *Host {
return &Host{inner: internalpluginhost.New()}
}
// ApplyConfig applies plugin runtime configuration.
func (h *Host) ApplyConfig(ctx context.Context, cfg RuntimeConfig) {
if h == nil || h.inner == nil {
return
}
internalCfg := runtimeConfigToInternalConfig(cfg)
h.inner.ApplyConfig(ctx, internalCfg)
}
// ShutdownAll unloads every active plugin.
func (h *Host) ShutdownAll() {
if h == nil || h.inner == nil {
return
}
h.inner.ShutdownAll()
}
// ParseAuth lets plugin auth providers parse a credential payload.
func (h *Host) ParseAuth(ctx context.Context, req pluginapi.AuthParseRequest) (*coreauth.Auth, bool, error) {
if h == nil || h.inner == nil {
return nil, false, nil
}
return h.inner.ParseAuth(ctx, req)
}
// ModelsForAuth lets plugin model providers discover auth-bound models.
func (h *Host) ModelsForAuth(ctx context.Context, auth *coreauth.Auth) AuthModelResult {
if h == nil || h.inner == nil {
return AuthModelResult{}
}
result := h.inner.ModelsForAuth(ctx, auth)
return AuthModelResult{
Provider: result.Provider,
Models: registryModelsToPluginModels(result.Models),
Auth: result.Auth,
Handled: result.Handled,
Err: result.Err,
}
}
// ModelsForProvider returns static models registered for a provider by plugins.
func (h *Host) ModelsForProvider(provider string) []ModelInfo {
if h == nil || h.inner == nil {
return nil
}
return registryModelsToPluginModels(h.inner.ModelsForProvider(provider))
}
// RefreshAuth lets plugin auth providers refresh a credential.
func (h *Host) RefreshAuth(ctx context.Context, auth *coreauth.Auth) (*coreauth.Auth, bool, error) {
if h == nil || h.inner == nil {
return nil, false, nil
}
return h.inner.RefreshAuth(ctx, auth)
}
// PickAuth lets a scheduler plugin choose an auth candidate.
func (h *Host) PickAuth(ctx context.Context, req pluginapi.SchedulerPickRequest) (pluginapi.SchedulerPickResponse, bool, error) {
if h == nil || h.inner == nil {
return pluginapi.SchedulerPickResponse{}, false, nil
}
return h.inner.PickAuth(ctx, req)
}
// HasScheduler reports whether any active plugin provides a scheduler.
func (h *Host) HasScheduler() bool {
return h != nil && h.inner != nil && h.inner.HasScheduler()
}
func runtimeConfigToInternalConfig(cfg RuntimeConfig) *internalconfig.Config {
out := &internalconfig.Config{
SDKConfig: internalconfig.SDKConfig{
ProxyURL: cfg.ProxyURL,
ForceModelPrefix: cfg.ForceModelPrefix,
},
AuthDir: cfg.AuthDir,
OAuthExcludedModels: cloneStringSliceMap(cfg.OAuthExcludedModels),
OAuthModelAlias: oauthModelAliasToInternal(cfg.OAuthModelAlias),
Plugins: internalconfig.PluginsConfig{
Enabled: cfg.Enabled,
Dir: cfg.Dir,
Configs: pluginConfigsToInternal(cfg.Configs),
},
}
out.NormalizePluginsConfig()
out.SanitizeOAuthModelAlias()
return out
}
func pluginConfigsToInternal(in map[string]PluginInstanceConfig) map[string]internalconfig.PluginInstanceConfig {
if len(in) == 0 {
return nil
}
out := make(map[string]internalconfig.PluginInstanceConfig, len(in))
for id, item := range in {
out[id] = internalconfig.PluginInstanceConfig{
Enabled: item.Enabled,
Priority: item.Priority,
Raw: *deepCopyYAMLNode(&item.Raw),
}
}
return out
}
func oauthModelAliasToInternal(in map[string][]OAuthModelAlias) map[string][]internalconfig.OAuthModelAlias {
if len(in) == 0 {
return nil
}
out := make(map[string][]internalconfig.OAuthModelAlias, len(in))
for provider, aliases := range in {
if len(aliases) == 0 {
continue
}
items := make([]internalconfig.OAuthModelAlias, 0, len(aliases))
for _, alias := range aliases {
items = append(items, internalconfig.OAuthModelAlias{
Name: alias.Name,
Alias: alias.Alias,
Fork: alias.Fork,
})
}
out[provider] = items
}
if len(out) == 0 {
return nil
}
return out
}
func registryModelsToPluginModels(models []*internalregistry.ModelInfo) []ModelInfo {
if len(models) == 0 {
return nil
}
out := make([]ModelInfo, 0, len(models))
for _, model := range models {
if model == nil {
continue
}
out = append(out, registryModelToPluginModel(model))
}
return out
}
func registryModelToPluginModel(model *internalregistry.ModelInfo) ModelInfo {
if model == nil {
return ModelInfo{}
}
return ModelInfo{
ID: model.ID,
Object: model.Object,
Created: model.Created,
OwnedBy: model.OwnedBy,
Type: model.Type,
DisplayName: model.DisplayName,
Name: model.Name,
Version: model.Version,
Description: model.Description,
InputTokenLimit: int64(model.InputTokenLimit),
OutputTokenLimit: int64(model.OutputTokenLimit),
SupportedGenerationMethods: cloneStringSlice(model.SupportedGenerationMethods),
ContextLength: int64(model.ContextLength),
MaxCompletionTokens: int64(model.MaxCompletionTokens),
SupportedParameters: cloneStringSlice(model.SupportedParameters),
SupportedInputModalities: cloneStringSlice(model.SupportedInputModalities),
SupportedOutputModalities: cloneStringSlice(model.SupportedOutputModalities),
Thinking: thinkingSupportToPlugin(model.Thinking),
UserDefined: model.UserDefined,
}
}
func thinkingSupportToPlugin(thinking *internalregistry.ThinkingSupport) *ThinkingSupport {
if thinking == nil {
return nil
}
return &ThinkingSupport{
Min: thinking.Min,
Max: thinking.Max,
ZeroAllowed: thinking.ZeroAllowed,
DynamicAllowed: thinking.DynamicAllowed,
Levels: cloneStringSlice(thinking.Levels),
}
}
func cloneStringSlice(in []string) []string {
if len(in) == 0 {
return nil
}
return append([]string(nil), in...)
}
func cloneStringSliceMap(in map[string][]string) map[string][]string {
if len(in) == 0 {
return nil
}
out := make(map[string][]string, len(in))
for key, values := range in {
out[key] = cloneStringSlice(values)
}
return out
}
func deepCopyYAMLNode(node *yaml.Node) *yaml.Node {
if node == nil {
return &yaml.Node{}
}
copyNode := *node
if len(node.Content) > 0 {
copyNode.Content = make([]*yaml.Node, 0, len(node.Content))
for _, child := range node.Content {
copyNode.Content = append(copyNode.Content, deepCopyYAMLNode(child))
}
}
return &copyNode
}
+172
View File
@@ -0,0 +1,172 @@
// Package pluginstore exposes plugin registry and artifact installation helpers
// for embedders such as CLIProxyAPIHome.
package pluginstore
import (
"context"
"fmt"
"net/http"
"strings"
internalpluginstore "github.com/router-for-me/CLIProxyAPI/v7/internal/pluginstore"
)
const (
DefaultRegistryURL = internalpluginstore.DefaultRegistryURL
DefaultSourceID = internalpluginstore.DefaultSourceID
DefaultSourceName = internalpluginstore.DefaultSourceName
SchemaVersion = internalpluginstore.SchemaVersion
)
type Source = internalpluginstore.Source
type Registry = internalpluginstore.Registry
type Plugin = internalpluginstore.Plugin
type Release = internalpluginstore.Release
type ReleaseAsset = internalpluginstore.ReleaseAsset
type InstallOptions = internalpluginstore.InstallOptions
type InstallResult = internalpluginstore.InstallResult
type HTTPDoer interface {
Do(*http.Request) (*http.Response, error)
}
var ErrLoadedPluginLocked = internalpluginstore.ErrLoadedPluginLocked
type Client struct {
inner internalpluginstore.Client
}
type Manifest struct {
ID string `yaml:"id,omitempty" json:"id,omitempty"`
Name string `yaml:"name,omitempty" json:"name,omitempty"`
Description string `yaml:"description,omitempty" json:"description,omitempty"`
Author string `yaml:"author,omitempty" json:"author,omitempty"`
Version string `yaml:"version,omitempty" json:"version,omitempty"`
ReleaseTag string `yaml:"release-tag,omitempty" json:"release_tag,omitempty"`
Repository string `yaml:"repository,omitempty" json:"repository,omitempty"`
Logo string `yaml:"logo,omitempty" json:"logo,omitempty"`
Homepage string `yaml:"homepage,omitempty" json:"homepage,omitempty"`
License string `yaml:"license,omitempty" json:"license,omitempty"`
Tags []string `yaml:"tags,omitempty" json:"tags,omitempty"`
SourceID string `yaml:"source-id,omitempty" json:"source_id,omitempty"`
SourceName string `yaml:"source-name,omitempty" json:"source_name,omitempty"`
SourceURL string `yaml:"source-url,omitempty" json:"source_url,omitempty"`
}
func NewClient(httpClient HTTPDoer, registryURL string) Client {
return Client{inner: internalpluginstore.Client{
HTTPClient: httpClient,
RegistryURL: strings.TrimSpace(registryURL),
}}
}
func DefaultSource() Source {
return internalpluginstore.DefaultSource()
}
func NormalizeSources(registryURLs []string) ([]Source, error) {
return internalpluginstore.NormalizeSources(registryURLs)
}
func SourceID(registryURL string) string {
return internalpluginstore.SourceID(registryURL)
}
func ValidatePlugin(plugin Plugin) error {
return internalpluginstore.ValidatePlugin(plugin)
}
func UpdateAvailable(installed, latest string) bool {
return internalpluginstore.UpdateAvailable(installed, latest)
}
func ReleaseVersion(release Release) (string, error) {
return internalpluginstore.ReleaseVersion(release)
}
func ManifestFromRelease(source Source, plugin Plugin, release Release) (Manifest, error) {
version, errVersion := internalpluginstore.ReleaseVersion(release)
if errVersion != nil {
return Manifest{}, errVersion
}
return Manifest{
ID: strings.TrimSpace(plugin.ID),
Name: strings.TrimSpace(plugin.Name),
Description: strings.TrimSpace(plugin.Description),
Author: strings.TrimSpace(plugin.Author),
Version: version,
ReleaseTag: strings.TrimSpace(release.TagName),
Repository: strings.TrimSpace(plugin.Repository),
Logo: strings.TrimSpace(plugin.Logo),
Homepage: strings.TrimSpace(plugin.Homepage),
License: strings.TrimSpace(plugin.License),
Tags: append([]string(nil), plugin.Tags...),
SourceID: strings.TrimSpace(source.ID),
SourceName: strings.TrimSpace(source.Name),
SourceURL: strings.TrimSpace(source.URL),
}, nil
}
func (m Manifest) Plugin() Plugin {
return Plugin{
ID: strings.TrimSpace(m.ID),
Name: strings.TrimSpace(m.Name),
Description: strings.TrimSpace(m.Description),
Author: strings.TrimSpace(m.Author),
Version: strings.TrimSpace(m.Version),
Repository: strings.TrimSpace(m.Repository),
Logo: strings.TrimSpace(m.Logo),
Homepage: strings.TrimSpace(m.Homepage),
License: strings.TrimSpace(m.License),
Tags: append([]string(nil), m.Tags...),
}
}
func (m Manifest) Validate() error {
version := strings.TrimSpace(m.Version)
if version == "" {
return fmt.Errorf("missing required field version")
}
releaseTag := strings.TrimSpace(m.ReleaseTag)
if releaseTag == "" {
return fmt.Errorf("missing required field release-tag")
}
if errValidate := internalpluginstore.ValidatePlugin(m.Plugin()); errValidate != nil {
return errValidate
}
releaseVersion, errVersion := internalpluginstore.ReleaseVersion(internalpluginstore.Release{TagName: releaseTag})
if errVersion != nil {
return errVersion
}
if releaseVersion != version {
return fmt.Errorf("release-tag %q resolves version %q, want %q", releaseTag, releaseVersion, version)
}
return nil
}
func (c Client) FetchRegistry(ctx context.Context) (Registry, error) {
return c.inner.FetchRegistry(ctx)
}
func (c Client) FetchLatestRelease(ctx context.Context, plugin Plugin) (Release, error) {
return c.inner.FetchLatestRelease(ctx, plugin)
}
func (c Client) FetchReleaseByTag(ctx context.Context, plugin Plugin, tag string) (Release, error) {
return c.inner.FetchReleaseByTag(ctx, plugin, tag)
}
func (c Client) Install(ctx context.Context, plugin Plugin, options InstallOptions) (InstallResult, error) {
return c.inner.Install(ctx, plugin, options)
}
func (c Client) InstallVersion(ctx context.Context, plugin Plugin, releaseTag string, version string, options InstallOptions) (InstallResult, error) {
return c.inner.InstallVersion(ctx, plugin, releaseTag, version, options)
}
func (c Client) InstallManifest(ctx context.Context, manifest Manifest, options InstallOptions) (InstallResult, error) {
if errValidate := manifest.Validate(); errValidate != nil {
return InstallResult{}, errValidate
}
return c.InstallVersion(ctx, manifest.Plugin(), manifest.ReleaseTag, manifest.Version, options)
}
+67
View File
@@ -0,0 +1,67 @@
package pluginstore
import (
"strings"
"testing"
)
func TestManifestValidateRequiresPinnedReleaseTag(t *testing.T) {
manifest := validTestManifest()
manifest.ReleaseTag = ""
errValidate := manifest.Validate()
if errValidate == nil {
t.Fatal("Validate() error = nil, want release-tag error")
}
if !strings.Contains(errValidate.Error(), "release-tag") {
t.Fatalf("Validate() error = %v, want release-tag", errValidate)
}
}
func TestManifestValidateRejectsReleaseTagVersionMismatch(t *testing.T) {
manifest := validTestManifest()
manifest.ReleaseTag = "v0.3.0"
errValidate := manifest.Validate()
if errValidate == nil {
t.Fatal("Validate() error = nil, want version mismatch")
}
if !strings.Contains(errValidate.Error(), "resolves version") {
t.Fatalf("Validate() error = %v, want version mismatch", errValidate)
}
}
func TestManifestFromReleaseBuildsPinnedManifest(t *testing.T) {
manifest, errManifest := ManifestFromRelease(
DefaultSource(),
Plugin{
ID: "sample-provider",
Name: "Sample Provider",
Description: "Adds sample provider support.",
Author: "author-name",
Repository: "https://github.com/author-name/sample-provider",
},
Release{TagName: "v0.2.0"},
)
if errManifest != nil {
t.Fatalf("ManifestFromRelease() error = %v", errManifest)
}
if errValidate := manifest.Validate(); errValidate != nil {
t.Fatalf("Validate() error = %v", errValidate)
}
if manifest.Version != "0.2.0" || manifest.ReleaseTag != "v0.2.0" {
t.Fatalf("manifest version fields = %q/%q, want 0.2.0/v0.2.0", manifest.Version, manifest.ReleaseTag)
}
}
func validTestManifest() Manifest {
return Manifest{
ID: "sample-provider",
Name: "Sample Provider",
Description: "Adds sample provider support.",
Author: "author-name",
Version: "0.2.0",
ReleaseTag: "v0.2.0",
Repository: "https://github.com/author-name/sample-provider",
}
}