mirror of
https://gh-proxy.org/https://github.com/klinsmaya/CLIProxyAPI.git
synced 2026-11-09 18:06:50 +08:00
feat(plugins): sync home plugin manifests
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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 ©Node
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user