Files
magpie/internal/plugin/market.go
T
yetone 456208945b plugins: a plugin can be installed from a git repository — github:owner/repo, owner/repo, a GitHub URL or a git+https:// one (Lemon on Discord: can plugins be installed directly from git?) — magpie plugin add and the Plugins field took only an npm name or a path, and turned a git spec away as "isn't an npm package name". A git spec now goes to bun add as given and is kept as given in plugins.json; the package it installs is named by its repository's package.json, which bun records as the dependency whose spec it is, so the plugin is known (list, on/off, rm, its version, its README) by that name or by the spec. update and the row's Update run bun update on it, which fetches the repository's commit now where bun add keeps the one bun.lock has, the spec unchanged; npm is never asked for a newer version of it, nor auto-updates it. Adding the same package from another URL, or from npm, takes its entry's place. The Plugins field says it takes a GitHub repo (en+zh); an installed git plugin shows its package's name, an Update button, and a page with its README, no npm link and a Source link to the repository. Nothing that installed before is turned away. TestGitPlugin (a fake bun fetching from local bare repositories, the real one loading the plugin when on PATH) and plugin-git.test.cjs fail without the change; TestIsGit checks what counts as a git spec. go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./... pass; plugin-git, plugin-local, plugin-market, plugin-updates, click-scroll, old-webkit pass.
2026-10-02 02:24:49 +08:00

513 lines
14 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 {
// magpie's community's alone: others' OpenCode plugins weren't
// written against magpie's sign-ins, and are found by a search
if l.Package != "" && pkgName.MatchString(l.Package) && l.Community {
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, or — for a plugin added
// from a folder on this computer or a git repository — the one its folder
// carries.
func Readme(ctx context.Context, name string) (Page, error) {
if IsPath(name) || IsGit(name) {
return folderReadme(Target(name))
}
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
}
// folderReadme is the README a plugin's own folder carries: README.md,
// whatever its case, else a markdown or text one named beside it. A folder
// without one says so, rather than answering with npm's silence.
func folderReadme(dir string) (Page, error) {
ents, err := os.ReadDir(dir)
if err != nil {
return Page{}, err
}
named := map[string]os.DirEntry{}
for _, e := range ents {
if !e.IsDir() {
named[strings.ToLower(e.Name())] = e
}
}
for _, want := range []string{"readme.md", "readme.markdown", "readme.txt", "readme"} {
e, ok := named[want]
if !ok {
continue
}
b, err := os.ReadFile(filepath.Join(dir, e.Name()))
if err != nil {
return Page{}, err
}
const most = 200 << 10
if len(b) > most {
b = b[:most]
}
var at time.Time
if fi, err := e.Info(); err == nil {
at = fi.ModTime()
}
return Page{Readme: string(b), Updated: at}, nil
}
return Page{}, fmt.Errorf("%s has no README: add a README.md beside its package.json", dir)
}
// 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: npm's, or its git
// repository's commit now.
func Upgrade(ctx context.Context, name string) error {
for _, e := range Load().Plugins {
if (Name(e.Spec) == name || e.Spec == name) && IsGit(e.Spec) {
err := reinstall(ctx, e.Spec)
Restart()
return err
}
if Name(e.Spec) == name && !IsPath(e.Spec) {
_, err := Add(ctx, name)
return err
}
}
return fmt.Errorf("no plugin %q", name)
}
// Icon is the icon the market gives the plugin spec, or else any listed
// plugin signing in to provider id; "" when none does. It never goes to
// the network: the list fetched last, else the cached copy, else the one
// built in.
func Icon(spec, id string) string {
marketMu.Lock()
l := marketList
marketMu.Unlock()
if l == nil {
if b, err := os.ReadFile(marketCache()); err == nil {
l, _ = parseMarket(b)
}
if l == nil {
l, _ = parseMarket(builtinMarket)
}
}
pkg := Name(spec)
for _, x := range l {
if x.Package == pkg && x.Icon != "" {
return x.Icon
}
}
if ic, ok := otherIcons[pkg]; ok {
return ic
}
for _, x := range l {
for _, p := range x.Providers {
if p == id && x.Icon != "" {
return x.Icon
}
}
}
if ic, ok := otherIcons[id]; ok {
return ic
}
return ""
}
// otherIcons are the icons of OpenCode plugins the market no longer lists,
// by package, and of the providers they sign in to, so one installed still
// shows its vendor's.
var otherIcons = map[string]string{
"opencode-gemini-auth": "gemini-color",
"opencode-antigravity-auth": "antigravity-color",
"opencode-copilot-auth": "githubcopilot",
"opencode-openai-codex-auth": "openai",
"oc-codex-multi-auth": "openai",
"@ex-machina/opencode-anthropic-auth": "claude-color",
"@servoy/opencode-kiro-auth": "kiro-color",
"opencode-qoder-bridge": "qoder",
"google": "gemini-color",
"github-copilot": "githubcopilot",
"openai": "openai",
"anthropic": "claude-color",
}