Files
yetone 4b56c8672d plugin: an update Bun holds back says why, instead of saying it was made (sweanng424 on Discord)
What: Update, Update all, the hourly auto-update and the move's install
ask npm for the plugin's newest version and have bun install that exact
version (installAt), then check it is the one installed. When bun won't,
the error says why, reason first for the status pill: "<pkg> 0.1.21 isn't
installed yet: Bun's minimumReleaseAge (in a .bunfig.toml) holds back
versions published less than 2 days ago, and magpie installs it once it
is older (bun's own words)". Another bun error (a registry of the user's
own without the version) is "<pkg> <v> isn't installed: <bun's error:
lines>". Specs stay as they were (name@latest); pinned and git plugins,
and npm not answering, install as their spec says, as before.

Why: the Factory card stayed at 0.1.18 with 0.1.21 out, through clicking
update and restarts. bun add pkg@latest resolves "latest" by bun's own
config: a minimumReleaseAge in the user's ~/.bunfig.toml or
$XDG_CONFIG_HOME/.bunfig.toml makes it install the newest version older
than that and exit 0. 172800s (2 days) gives exactly 0.1.18 (0.1.19-21
were published 10-08). So every update path succeeded with nothing
changed, and the page said "updated to v0.1.21". Asked for 0.1.21 itself,
bun fails with "blocked by minimum-release-age: 172800 seconds". The same
code serves every moved plugin (Cursor, Devin, Kiro, Qoder, ...), so all
of them had it. npm view: 0.1.21 is latest; npmmirror has it too.

Run:
- TestUpdateBunHoldsBackSaysWhy (new): real bun 1.4.2 against a fake
  registry with 0.1.18 (3 days old) and 0.1.21 (1 hour), a bunfig with
  minimumReleaseAge = 172800. On the old code it fails: "Update with
  0.1.21 held back by Bun: <nil>, want bun's reason (installed 0.1.18)".
  Breaking Update alone fails at "Update all: <nil>", breaking the hourly
  update alone at "the hourly update: <nil>". Without the bunfig it
  installs 0.1.21 and keeps the spec @latest.
- Real use: magpie web (nogui build) under a sandbox HOME, real npm, with
  the bunfig: add factory-auth@0.1.18, spec @latest, POST
  /api/plugins/upgrade and /update return the reason above, the card
  stays 0.1.18; bunfig removed, upgrade installs 0.1.21.
- Ruled out on macOS and the Windows box: plain upgrade 0.1.18 -> 0.1.21
  works there without a bunfig, host running or not.
- gofmt -l; go vet (darwin, GOOS=linux, GOOS=windows, -tags nogui) on
  internal/plugin, internal/provider, internal/gui; go build (darwin),
  GOOS=linux and GOOS=windows -tags nogui builds; go test -tags nogui ./...
  under a temp HOME, all ok.
2026-10-09 12:57:56 +08:00

691 lines
19 KiB
Go

package plugin
import (
"context"
_ "embed"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"slices"
"strings"
"sync"
"time"
"github.com/yetone/magpie/internal/appdir"
"github.com/yetone/magpie/internal/source"
)
// 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
Kind string `json:"kind,omitempty"` // "middleware" for gateway middleware; none for a provider
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 // nil until read from npmCacheFile
)
// npmEntry is what npm said of a package, and when; kept on disk beside
// the market's copy, so a magpie just started shows it before npm answers.
type npmEntry struct {
Info NPM `json:"info"`
At time.Time `json:"at"`
}
// Where npm is asked, moved by tests; how many packages are asked at once;
// how long one is waited for, and all of them.
var (
npmRegistry = "https://registry.npmjs.org"
npmDownloads = "https://api.npmjs.org"
npmAtOnce = 6
npmEach = 6 * time.Second
npmAll = 8 * time.Second
)
func npmCacheFile() string { return filepath.Join(filepath.Dir(marketCache()), "plugin-npm.json") }
// npmCached is npmCache, read from disk the first time; npmMu held.
func npmCached() map[string]npmEntry {
if npmCache == nil {
npmCache = map[string]npmEntry{}
if b, err := os.ReadFile(npmCacheFile()); err == nil {
_ = json.Unmarshal(b, &npmCache)
}
}
return npmCache
}
// ReloadInfo forgets what npm said, as kept in memory: the next ask reads
// it from disk again, as a magpie just started does.
func ReloadInfo() {
npmMu.Lock()
npmCache = nil
npmMu.Unlock()
}
// saveNPM writes npmCache to disk; npmMu held.
func saveNPM() {
b, err := json.Marshal(npmCache)
if err != nil {
return
}
_ = os.MkdirAll(filepath.Dir(npmCacheFile()), 0o755)
tmp := npmCacheFile() + ".tmp"
if os.WriteFile(tmp, b, 0o644) == nil {
_ = os.Rename(tmp, npmCacheFile())
}
}
// InfoCached is what npm said last of each package it was asked of, however
// long ago, without asking again; a package never asked isn't in it.
func InfoCached(names []string) map[string]NPM {
npmMu.Lock()
defer npmMu.Unlock()
c := npmCached()
out := map[string]NPM{}
for _, n := range names {
if e, ok := c[n]; ok {
out[n] = e.Info
}
}
return out
}
func marketCache() string { return filepath.Join(appdir.Cache(), "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)
// the list names the packages installed: a copy of the very file
// may stand in for it (with 「国内镜像」), a proxy may not
b, err := fetchJSONFaithful(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
}
// RefreshMarket has the next Market fetch the list again, as with the
// 「国内镜像」 switch just turned on; the one held is kept till then.
func RefreshMarket() {
marketMu.Lock()
marketAt = time.Time{}
marketMu.Unlock()
}
func fetchJSON(ctx context.Context, u string, limit int64) ([]byte, error) {
return fetchJSONFrom(ctx, u, limit, source.Do)
}
func fetchJSONFaithful(ctx context.Context, u string, limit int64) ([]byte, error) {
return fetchJSONFrom(ctx, u, limit, source.DoFaithful)
}
func fetchJSONFrom(ctx context.Context, u string, limit int64, do func(*http.Client, *http.Request) (*http.Response, error)) ([]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 := do(http.DefaultClient, 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, &statusError{URL: u, Code: res.StatusCode, Status: res.Status}
}
return io.ReadAll(io.LimitReader(res.Body, limit))
}
var errNotFound = errors.New("not found")
// statusError is a server's answer other than 200 or 404.
type statusError struct {
URL string
Code int
Status string
}
func (e *statusError) Error() string { return e.URL + ": " + e.Status }
// 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: a few at a
// time, and for no longer than npmAll in all (or ctx). A package npm didn't
// answer for in time is what it said last, or missing when it never has;
// its answer, when it comes, is kept for the next time. A package npm
// doesn't have has no Version.
func Info(ctx context.Context, names []string) map[string]NPM {
out := map[string]NPM{}
var mu sync.Mutex
var wg sync.WaitGroup
sem := make(chan struct{}, npmAtOnce)
ctx, cancel := context.WithTimeout(ctx, npmAll)
defer cancel()
// asked apart from ctx: one that answers after the page stopped
// waiting is still kept
bg := context.WithoutCancel(ctx)
asked := map[string]bool{}
npmMu.Lock()
cache := npmCached()
for _, n := range names {
if asked[n] || !pkgName.MatchString(n) {
continue
}
asked[n] = true
e, hit := cache[n]
if hit {
out[n] = e.Info // what it said last, until it says otherwise
if time.Since(e.At) < time.Hour {
continue
}
}
wg.Add(1)
go func(n string) {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
c, cancel := context.WithTimeout(bg, npmEach)
info, ok := npmInfo(c, n)
cancel()
if !ok {
return // npm didn't answer: what it said last
}
npmMu.Lock()
npmCached()[n] = npmEntry{info, time.Now()}
saveNPM()
npmMu.Unlock()
mu.Lock()
if ctx.Err() == nil {
out[n] = info
}
mu.Unlock()
}(n)
}
npmMu.Unlock()
done := make(chan struct{})
go func() { wg.Wait(); close(done) }()
select {
case <-done:
case <-ctx.Done():
}
mu.Lock()
defer mu.Unlock()
res := make(map[string]NPM, len(out))
for k, v := range out {
res[k] = v
}
return res
}
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"`
// Main and Exports are what importing it loads (pkgEntry)
Main string `json:"main"`
Exports json.RawMessage `json:"exports"`
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) {
info, err := npmAsk(ctx, name)
if err != nil {
return NPM{}, errors.Is(err, errNotFound) // not on npm: known, and kept
}
return info, true
}
// npmAsk is what npm says of the package now, or why it said nothing:
// errNotFound when it has no such package.
func npmAsk(ctx context.Context, name string) (NPM, error) {
var info NPM
var wg sync.WaitGroup
var latestErr error
wg.Add(2)
go func() {
defer wg.Done()
b, err := fetchJSON(ctx, npmRegistry+"/"+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, npmDownloads+"/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{}, latestErr
}
return info, nil
}
// Hit is a package npm's search found.
type Hit struct {
Package string `json:"package"`
NPM
}
// Search asks npm for plugins matching q: OpenCode plugins, and pi
// packages, which list "pi-package" among their keywords as pi's own
// gallery asks.
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()
var (
wg sync.WaitGroup
oc, pi []Hit
ocE, piE error
)
wg.Add(2)
go func() {
defer wg.Done()
// a plugin, not a tool that mentions OpenCode: its name says so,
// or its keywords name an OpenCode plugin
oc, ocE = searchNPM(ctx, q+" opencode", func(text string, _ []string) bool {
return strings.Contains(text, "opencode") && (strings.Contains(text, "auth") || strings.Contains(text, "plugin") || strings.Contains(text, "provider"))
})
}()
go func() {
defer wg.Done()
// a pi package that signs in to a provider or brings one; one
// that only adds pi a command or a tool has nothing for magpie
pi, piE = searchNPM(ctx, "keywords:pi-package "+q, func(text string, kw []string) bool {
return slices.Contains(kw, "pi-package") && (strings.Contains(text, "auth") || strings.Contains(text, "provider"))
})
}()
wg.Wait()
if ocE != nil && piE != nil {
return nil, ocE
}
out, seen := []Hit{}, map[string]bool{}
for _, h := range append(oc, pi...) {
if !seen[h.Package] {
seen[h.Package] = true
out = append(out, h)
}
}
return out, nil
}
// searchNPM is npm's search for text, the packages keep says are plugins
// (given their name and keywords, lowercased, and the keywords).
func searchNPM(ctx context.Context, text string, keep func(string, []string) bool) ([]Hit, error) {
v := url.Values{"text": {text}, "size": {"30"}}
b, err := fetchJSON(ctx, npmRegistry+"/-/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
}
var out []Hit
for _, o := range r.Objects {
p := o.Package
if !keep(strings.ToLower(p.Name+" "+strings.Join(p.Keywords, " ")), p.Keywords) {
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 IsGit(name) {
// one not installed yet (a repository tagged magpie-plugin): its
// README as GitHub has it
if _, err := os.Stat(Target(name)); err != nil {
if repo := githubRepo(name); repo != "" {
return githubReadme(ctx, repo)
}
}
}
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, npmRegistry+"/"+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. One bun won't install says why.
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) {
// the version npm has now, not bun's "latest" (installAt);
// npm not answering, bun's as before
_, err := add(ctx, name, newestOf(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",
}