Files
magpie/internal/plugin/market.go
T
yetone 9ec237d4e6 gui: a Plugins tab, a market to find and install provider plugins in (在产品里要有插件市场功能,整个插件市场要美观易用), and a way to it from the providers list (供应商列表里有个精致优雅的按钮) — Discover lists the magpie community's plugins, then OpenCode's community's by weekly downloads, each card with its logo, publisher, summary, downloads, version and one button that takes it all the way: Install (Bun fetched first if need be), Sign in, Signed in; a package not on npm yet says Coming soon. Typing searches the list and, after a pause, npm, with the field keeping its focus. A card opens the plugin's page: version, downloads, licence, updated, what it signs in to, npm/source/homepage links, and its README (no images, HTML or scripts; only http(s) links, opened outside). Installed lists each plugin with its providers' sign-ins, a newer version to update to, Update all, switch off and remove; Settings' Plugins section moves here. The list comes from registry.json in magpie-community/plugins, kept for 6 hours, falling back to the copy fetched last and then the one built in (MAGPIE_PLUGIN_MARKET overrides, "off" never asks). Under Add provider a "More subscriptions in Plugins" pill opens it; the add sheet ends in a Plugins tile, and a search that finds nothing offers to look for a plugin. The header, seven tabs now, draws them in at the narrowest window. plugin-market.test.cjs (Chromium, WebKit, en/zh) and market_test.go fail without the change; add-button, plugin-signin, usage-ledger, text-size, old-webkit, click-scroll pass. go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./... pass.
2026-09-30 17:57:46 +08:00

408 lines
11 KiB
Go

package plugin
import (
"context"
_ "embed"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"sync"
"time"
)
// The plugin market: the plugins magpie suggests, from the community
// repo's registry.json (a copy is built in, for when GitHub can't be
// reached), each with what npm says of it now; and npm's search for the
// rest.
//go:embed market.json
var builtinMarket []byte
// MarketURL is where the list is kept; MAGPIE_PLUGIN_MARKET moves it, and
// "off" keeps to the built-in copy.
const MarketURL = "https://raw.githubusercontent.com/magpie-community/plugins/main/registry.json"
// Listing is a plugin the market suggests.
type Listing struct {
Package string `json:"package"`
Name string `json:"name"`
Icon string `json:"icon,omitempty"`
Providers []string `json:"providers,omitempty"` // OpenCode's ids of those it signs in to
Community bool `json:"community,omitempty"` // written by magpie's community
Replaces string `json:"replaces,omitempty"` // the built-in subscription it does the work of
Summary map[string]string `json:"summary,omitempty"` // by language: en, zh
}
type registry struct {
Plugins []Listing `json:"plugins"`
}
// NPM is what npm says of a package now.
type NPM struct {
Version string `json:"version,omitempty"` // latest; none when npm has no such package
Description string `json:"description,omitempty"`
Publisher string `json:"publisher,omitempty"`
License string `json:"license,omitempty"`
Homepage string `json:"homepage,omitempty"`
Repository string `json:"repository,omitempty"`
Weekly int `json:"weekly"` // downloads last week
}
var (
marketMu sync.Mutex
marketList []Listing
marketAt time.Time
npmMu sync.Mutex
npmCache = map[string]npmEntry{}
)
type npmEntry struct {
info NPM
ok bool
at time.Time
}
func marketCache() string {
if x := os.Getenv("XDG_CACHE_HOME"); x != "" {
return filepath.Join(x, "magpie", "plugin-market.json")
}
home, _ := os.UserHomeDir()
return filepath.Join(home, ".cache", "magpie", "plugin-market.json")
}
func parseMarket(b []byte) ([]Listing, error) {
var r registry
if err := json.Unmarshal(b, &r); err != nil {
return nil, err
}
out := r.Plugins[:0]
for _, l := range r.Plugins {
if l.Package != "" && pkgName.MatchString(l.Package) {
if l.Name == "" {
l.Name = l.Package
}
out = append(out, l)
}
}
if len(out) == 0 {
return nil, errors.New("the list has no plugins")
}
return out, nil
}
// Market is the list of plugins magpie suggests: fetched at most every six
// hours, else the copy fetched last, else the one built in.
func Market(ctx context.Context) []Listing {
marketMu.Lock()
defer marketMu.Unlock()
if marketList != nil && time.Since(marketAt) < 6*time.Hour {
return marketList
}
src := os.Getenv("MAGPIE_PLUGIN_MARKET")
if src == "" {
src = MarketURL
}
if src != "off" {
c, cancel := context.WithTimeout(ctx, 6*time.Second)
b, err := fetchJSON(c, src, 1<<20)
cancel()
if err == nil {
if l, err := parseMarket(b); err == nil {
marketList, marketAt = l, time.Now()
_ = os.MkdirAll(filepath.Dir(marketCache()), 0o755)
_ = os.WriteFile(marketCache(), b, 0o644)
return l
}
}
if b, err := os.ReadFile(marketCache()); err == nil {
if l, err := parseMarket(b); err == nil {
// tried again in a minute, not six hours
marketList, marketAt = l, time.Now().Add(-6*time.Hour+time.Minute)
return l
}
}
}
l, _ := parseMarket(builtinMarket)
marketList, marketAt = l, time.Now().Add(-6*time.Hour+time.Minute)
return l
}
func fetchJSON(ctx context.Context, u string, limit int64) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, "GET", u, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", "magpie")
req.Header.Set("Accept", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
if res.StatusCode == http.StatusNotFound {
return nil, errNotFound
}
if res.StatusCode != http.StatusOK {
return nil, fmt.Errorf("%s: %s", u, res.Status)
}
return io.ReadAll(io.LimitReader(res.Body, limit))
}
var errNotFound = errors.New("not found")
// npmPath is a package's name as the registry's paths take it.
func npmPath(name string) string { return strings.Replace(url.PathEscape(name), "%40", "@", 1) }
// Info is what npm says of each package, asked at most hourly and all at
// once; a package npm doesn't have has no Version.
func Info(ctx context.Context, names []string) map[string]NPM {
out := map[string]NPM{}
var wg sync.WaitGroup
var mu sync.Mutex
for _, n := range names {
npmMu.Lock()
e, hit := npmCache[n]
npmMu.Unlock()
if hit && time.Since(e.at) < time.Hour {
out[n] = e.info
continue
}
wg.Add(1)
go func(n string) {
defer wg.Done()
info, ok := npmInfo(ctx, n)
mu.Lock()
out[n] = info
mu.Unlock()
if ok {
npmMu.Lock()
npmCache[n] = npmEntry{info, true, time.Now()}
npmMu.Unlock()
} else if hit {
mu.Lock()
out[n] = e.info // npm didn't answer: what it said last
mu.Unlock()
}
}(n)
}
wg.Wait()
return out
}
type npmLatest struct {
Version string `json:"version"`
Description string `json:"description"`
License any `json:"license"`
Homepage string `json:"homepage"`
Repository any `json:"repository"`
Author any `json:"author"`
NPMUser struct {
Name string `json:"name"`
} `json:"_npmUser"`
}
func person(v any) string {
switch x := v.(type) {
case string:
if i := strings.IndexAny(x, "<("); i > 0 {
return strings.TrimSpace(x[:i])
}
return x
case map[string]any:
s, _ := x["name"].(string)
return s
}
return ""
}
// repoURL is a package.json repository as a web address.
func repoURL(v any) string {
s, _ := v.(string)
if m, ok := v.(map[string]any); ok {
s, _ = m["url"].(string)
}
s = strings.TrimPrefix(s, "git+")
s = strings.TrimSuffix(s, ".git")
s = strings.Replace(s, "git://", "https://", 1)
s = strings.Replace(s, "ssh://git@", "https://", 1)
if strings.HasPrefix(s, "github:") {
s = "https://github.com/" + strings.TrimPrefix(s, "github:")
} else if !strings.Contains(s, "://") && strings.Count(s, "/") == 1 {
s = "https://github.com/" + s
}
if !strings.HasPrefix(s, "https://") {
return ""
}
return s
}
func npmInfo(ctx context.Context, name string) (NPM, bool) {
ctx, cancel := context.WithTimeout(ctx, 8*time.Second)
defer cancel()
var info NPM
var wg sync.WaitGroup
var latestErr error
wg.Add(2)
go func() {
defer wg.Done()
b, err := fetchJSON(ctx, "https://registry.npmjs.org/"+npmPath(name)+"/latest", 1<<20)
if err != nil {
latestErr = err
return
}
var l npmLatest
if err := json.Unmarshal(b, &l); err != nil {
latestErr = err
return
}
info.Version, info.Description, info.Homepage = l.Version, l.Description, l.Homepage
info.License, _ = l.License.(string)
info.Repository = repoURL(l.Repository)
info.Publisher = person(l.Author)
if info.Publisher == "" {
info.Publisher = l.NPMUser.Name
}
}()
var weekly int
go func() {
defer wg.Done()
b, err := fetchJSON(ctx, "https://api.npmjs.org/downloads/point/last-week/"+npmPath(name), 64<<10)
if err != nil {
return
}
var d struct{ Downloads int }
if json.Unmarshal(b, &d) == nil {
weekly = d.Downloads
}
}()
wg.Wait()
info.Weekly = weekly
if latestErr != nil {
return NPM{}, errors.Is(latestErr, errNotFound) // not on npm: known, and kept
}
return info, true
}
// Hit is a package npm's search found.
type Hit struct {
Package string `json:"package"`
NPM
}
// Search asks npm for OpenCode plugins matching q.
func Search(ctx context.Context, q string) ([]Hit, error) {
q = strings.TrimSpace(q)
if q == "" {
return []Hit{}, nil
}
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
v := url.Values{"text": {q + " opencode"}, "size": {"30"}}
b, err := fetchJSON(ctx, "https://registry.npmjs.org/-/v1/search?"+v.Encode(), 4<<20)
if err != nil {
return nil, err
}
var r struct {
Objects []struct {
Package struct {
Name string `json:"name"`
Version string `json:"version"`
Description string `json:"description"`
Keywords []string `json:"keywords"`
License string `json:"license"`
Publisher struct {
Username string `json:"username"`
} `json:"publisher"`
Links struct {
Homepage string `json:"homepage"`
Repository string `json:"repository"`
} `json:"links"`
} `json:"package"`
Downloads struct {
Weekly int `json:"weekly"`
} `json:"downloads"`
} `json:"objects"`
}
if err := json.Unmarshal(b, &r); err != nil {
return nil, err
}
out := []Hit{}
for _, o := range r.Objects {
p := o.Package
// a plugin, not a tool that mentions OpenCode: its name says so,
// or its keywords name an OpenCode plugin
text := strings.ToLower(p.Name + " " + strings.Join(p.Keywords, " "))
if !strings.Contains(text, "opencode") || !(strings.Contains(text, "auth") || strings.Contains(text, "plugin") || strings.Contains(text, "provider")) {
continue
}
out = append(out, Hit{Package: p.Name, NPM: NPM{
Version: p.Version, Description: p.Description, Publisher: p.Publisher.Username, License: p.License,
Homepage: p.Links.Homepage, Repository: repoURL(p.Links.Repository), Weekly: o.Downloads.Weekly,
}})
}
return out, nil
}
// Page is a package's page: its README and when it last changed.
type Page struct {
Readme string `json:"readme"`
Updated time.Time `json:"updated"`
}
// Readme is the package's README, as npm has it.
func Readme(ctx context.Context, name string) (Page, error) {
if !pkgName.MatchString(name) {
return Page{}, fmt.Errorf("%q isn't an npm package name", name)
}
ctx, cancel := context.WithTimeout(ctx, 12*time.Second)
defer cancel()
b, err := fetchJSON(ctx, "https://registry.npmjs.org/"+npmPath(name), 32<<20)
if err != nil {
return Page{}, err
}
var d struct {
Readme string `json:"readme"`
Time map[string]time.Time `json:"time"`
}
if err := json.Unmarshal(b, &d); err != nil {
return Page{}, err
}
const most = 200 << 10
if len(d.Readme) > most {
d.Readme = d.Readme[:most]
}
return Page{Readme: d.Readme, Updated: d.Time["modified"]}, nil
}
// Installed is the version of the plugin spec installed, "" for a path or
// one not installed.
func Installed(spec string) string {
if IsPath(spec) {
return ""
}
b, err := os.ReadFile(filepath.Join(Target(spec), "package.json"))
if err != nil {
return ""
}
var p struct{ Version string }
_ = json.Unmarshal(b, &p)
return p.Version
}
// Upgrade installs the newest version of one plugin.
func Upgrade(ctx context.Context, name string) error {
for _, e := range Load().Plugins {
if Name(e.Spec) == name && !IsPath(e.Spec) {
_, err := Add(ctx, name)
return err
}
}
return fmt.Errorf("no plugin %q", name)
}