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.
This commit is contained in:
yetone committed 2026-10-09 12:57:56 +08:00
1 parent 8e0e7d3c40
commit 4b56c8672d
7 files changed
+296 -16

No files matched your search

+4 -2
View File
@@ -615,7 +615,7 @@ func Installed(spec string) string {
}
// Upgrade installs the newest version of one plugin: npm's, or its git
// repository's commit now.
// 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) {
@@ -624,7 +624,9 @@ func Upgrade(ctx context.Context, name string) error {
return err
}
if Name(e.Spec) == name && !IsPath(e.Spec) {
_, err := Add(ctx, name)
// 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
}
}
+106 -4
View File
@@ -28,6 +28,7 @@ import (
"path/filepath"
"regexp"
"slices"
"strconv"
"strings"
"sync"
"sync/atomic"
@@ -37,6 +38,7 @@ import (
"github.com/yetone/magpie/internal/proc"
"github.com/yetone/magpie/internal/settings"
"github.com/yetone/magpie/internal/steady"
"github.com/yetone/magpie/internal/update"
)
// Entry is one plugin the user added.
@@ -337,7 +339,11 @@ func Target(spec string) string {
// Add installs a plugin and adds it to the list, in place of one of the
// same package. A package is installed with its scripts left unrun.
func Add(ctx context.Context, spec string) (Entry, error) {
func Add(ctx context.Context, spec string) (Entry, error) { return add(ctx, spec, "") }
// add is Add, an npm package installed at the version given when there is
// one (installAt), its spec kept as given.
func add(ctx context.Context, spec, version string) (Entry, error) {
spec = strings.TrimSpace(spec)
if spec == "" {
return Entry{}, errors.New("no plugin given")
@@ -368,7 +374,11 @@ func Add(ctx context.Context, spec string) (Entry, error) {
if Name(spec) == spec {
spec += "@latest"
}
if err := install(ctx, spec); err != nil {
if version != "" {
if err := installAt(ctx, Name(spec), version); err != nil {
return Entry{}, err
}
} else if err := install(ctx, spec); err != nil {
return Entry{}, err
}
}
@@ -455,12 +465,24 @@ func notPlugin(target string) error {
}
// Update installs the version of each npm plugin its spec says now
// (latest, for the most part), and fetches each git one again.
// (latest, for the most part: npm's newest, asked now, installAt), and
// fetches each git one again.
func Update(ctx context.Context) error {
var errs []error
for _, e := range Load().Plugins {
if !IsPath(e.Spec) {
if err := reinstall(ctx, e.Spec); err != nil {
v := ""
if !IsGit(e.Spec) && !Pinned(e.Spec) {
v = newestOf(ctx, Name(e.Spec))
}
var err error
if v != "" {
err = installAt(ctx, Name(e.Spec), v)
} else {
// pinned, a git one, or npm not answering: as its spec says
err = reinstall(ctx, e.Spec)
}
if err != nil {
errs = append(errs, fmt.Errorf("%s: %w", e.Spec, err))
continue
}
@@ -575,6 +597,86 @@ func install(ctx context.Context, spec string) error {
return nil
}
// installAt installs the version of the npm package given, not a tag, and
// checks it is the one installed. Asked for pkg@latest, bun resolves
// "latest" by its own config: a minimumReleaseAge in the user's bunfig
// holds back every version younger than it, and a registry of the user's
// own (an .npmrc) may not have the newest yet. Either way bun installs an
// older version and exits 0, so an update said it was done and nothing
// changed (sweanng424 on Discord: Factory stayed at 0.1.18 with 0.1.21 out,
// through restarts and the hourly updates). Asked for the version, bun
// says why it can't install it.
func installAt(ctx context.Context, pkg, version string) error {
if err := install(ctx, pkg+"@"+version); err != nil {
// what bun said comes first: the page's status line shows the
// start of a long message
if m := minAge.FindStringSubmatch(err.Error()); m != nil {
return fmt.Errorf("%s %s isn't installed yet: Bun's minimumReleaseAge (in a .bunfig.toml) holds back versions published less than %s ago, and magpie installs it once it is older (%w)", pkg, version, ageText(m[1]), err)
}
var said []string
for _, l := range strings.Split(err.Error(), "\n") {
if l = strings.TrimSpace(l); strings.HasPrefix(l, "error:") {
said = append(said, strings.TrimSpace(strings.TrimPrefix(l, "error:")))
}
}
if len(said) > 0 {
return fmt.Errorf("%s %s isn't installed: %s (%w)", pkg, version, strings.Join(said, "; "), err)
}
return err
}
if got := Installed(pkg); got == "" || update.Newer(version, got) {
if got == "" {
got = "nothing"
}
return fmt.Errorf("bun add %s@%s installed %s", pkg, version, got)
}
return nil
}
// minAge is bun's word for a version its minimumReleaseAge holds back.
var minAge = regexp.MustCompile(`minimum-release-age: (\d+) seconds`)
// ageText is a number of seconds as a reader says it: 2 days, 12 hours.
func ageText(secs string) string {
n, err := strconv.Atoi(secs)
switch {
case err != nil:
return secs + " seconds"
case n >= 86400 && n%86400 == 0:
return plural(n/86400, "day")
case n >= 3600 && n%3600 == 0:
return plural(n/3600, "hour")
case n >= 60 && n%60 == 0:
return plural(n/60, "minute")
}
return plural(n, "second")
}
func plural(n int, unit string) string {
if n == 1 {
return "1 " + unit
}
return strconv.Itoa(n) + " " + unit + "s"
}
// newestOf is npm's newest version of the package, asked now, "" when npm
// didn't say; what it says is kept, as Info keeps it (tests stub it).
var newestOf = func(ctx context.Context, name string) string {
c, cancel := context.WithTimeout(ctx, checkEach)
info, err := npmAsk(c, name)
cancel()
if err == nil || errors.Is(err, errNotFound) {
npmMu.Lock()
npmCached()[name] = npmEntry{info, time.Now()}
saveNPM()
npmMu.Unlock()
}
if err != nil {
return ""
}
return info.Version
}
// piAgent is the package pi's extensions import pi from; the host loads
// them with it (pi.js), whichever of its names they import.
const piAgent = "@earendil-works/pi-coding-agent"
+165
View File
@@ -0,0 +1,165 @@
package plugin
import (
"archive/tar"
"bytes"
"compress/gzip"
"context"
"crypto/sha1"
"crypto/sha512"
"encoding/base64"
"encoding/hex"
"encoding/json"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// npmTarball is an npm package's tarball: package/package.json and an
// index.js the host can load.
func npmTarball(t *testing.T, name, version string) []byte {
t.Helper()
var buf bytes.Buffer
gz := gzip.NewWriter(&buf)
tw := tar.NewWriter(gz)
pj, _ := json.Marshal(map[string]string{"name": name, "version": version, "main": "index.js", "type": "module"})
for _, f := range []struct {
name string
body []byte
}{{"package/package.json", pj}, {"package/index.js", []byte("export default async () => ({})\n")}} {
if err := tw.WriteHeader(&tar.Header{Name: f.name, Mode: 0o644, Size: int64(len(f.body)), ModTime: time.Unix(1700000000, 0)}); err != nil {
t.Fatal(err)
}
tw.Write(f.body)
}
tw.Close()
gz.Close()
return buf.Bytes()
}
// A registry of the package's versions, published at the times given,
// npm's newest last: what bun and magpie both ask.
func heldRegistry(t *testing.T, pkg string, published map[string]time.Time, newest string) {
t.Helper()
tarballs := map[string][]byte{}
var base string
fakeNPM(t, func(w http.ResponseWriter, r *http.Request) {
p, _ := url.PathUnescape(r.URL.Path)
if strings.HasPrefix(p, "/downloads/") {
w.Write([]byte(`{"downloads":1}`))
return
}
if strings.HasPrefix(p, "/tarballs/") {
w.Write(tarballs[strings.TrimPrefix(p, "/tarballs/")])
return
}
if p == "/"+pkg+"/latest" {
json.NewEncoder(w).Encode(map[string]string{"name": pkg, "version": newest})
return
}
if p != "/"+pkg {
http.NotFound(w, r)
return
}
versions, times := map[string]any{}, map[string]string{}
for v, at := range published {
tgz := tarballs[v]
s512, s1 := sha512.Sum512(tgz), sha1.Sum(tgz)
versions[v] = map[string]any{"name": pkg, "version": v, "main": "index.js", "dist": map[string]string{
"tarball": base + "/tarballs/" + v,
"integrity": "sha512-" + base64.StdEncoding.EncodeToString(s512[:]),
"shasum": hex.EncodeToString(s1[:]),
}}
times[v] = at.UTC().Format(time.RFC3339Nano)
}
times["modified"] = published[newest].UTC().Format(time.RFC3339Nano)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{"name": pkg, "dist-tags": map[string]string{"latest": newest}, "versions": versions, "time": times})
})
base = npmRegistry
for v := range published {
tarballs[v] = npmTarball(t, pkg, v)
}
t.Setenv("BUN_CONFIG_REGISTRY", base+"/")
}
// An update bun won't install says why, and nothing says it was made
// (sweanng424 on Discord: Factory stayed at 0.1.18 with 0.1.21 out, through
// restarts and the hourly updates). A minimumReleaseAge in the user's
// bunfig made bun take the newest version older than it for "latest", and
// exit 0: magpie's Update, Update all, the hourly update and the moved
// built-in's update each said nothing, or that it was updated.
func TestUpdateBunHoldsBackSaysWhy(t *testing.T) {
sandbox(t)
const pkg = "@magpie-community/opencode-factory-auth"
heldRegistry(t, pkg, map[string]time.Time{"0.1.18": time.Now().Add(-72 * time.Hour), "0.1.21": time.Now().Add(-time.Hour)}, "0.1.21")
home, _ := os.UserHomeDir()
bunfig := []byte("[install]\nminimumReleaseAge = 172800\n")
for _, p := range []string{filepath.Join(home, ".bunfig.toml"), filepath.Join(os.Getenv("XDG_CONFIG_HOME"), ".bunfig.toml")} {
os.MkdirAll(filepath.Dir(p), 0o755)
if err := os.WriteFile(p, bunfig, 0o644); err != nil {
t.Fatal(err)
}
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
defer cancel()
// installed when 0.1.18 was the newest, unpinned, as a move installs it
if _, err := Add(ctx, pkg+"@0.1.18"); err != nil {
t.Fatal(err)
}
if err := save(List{Plugins: []Entry{{Spec: pkg + "@latest"}}}); err != nil {
t.Fatal(err)
}
// what bun does with "latest" here: the older version, and no error
if err := install(ctx, pkg+"@latest"); err != nil {
t.Fatal(err)
}
if v := Installed(pkg); v != "0.1.18" {
t.Skipf("bun took %q for latest: it didn't read the bunfig in %s", v, home)
}
err := Upgrade(ctx, pkg)
if err == nil || !strings.HasPrefix(err.Error(), pkg+" 0.1.21 isn't installed yet: Bun's minimumReleaseAge (in a .bunfig.toml) holds back versions published less than 2 days ago") || !strings.Contains(err.Error(), "blocked by minimum-release-age") {
t.Fatalf("Update with 0.1.21 held back by Bun: %v, want bun's reason (installed %s)", err, Installed(pkg))
}
if err := Update(ctx); err == nil || !strings.Contains(err.Error(), "minimum-release-age") {
t.Fatalf("Update all: %v, want bun's reason", err)
}
u, err := CheckUpdates(ctx)
if err == nil || !strings.Contains(err.Error(), "minimum-release-age") {
t.Fatalf("the hourly update: %v, want bun's reason", err)
}
if len(u.Updated) != 0 {
t.Fatalf("the hourly update said it updated: %+v", u.Updated)
}
if v := Installed(pkg); v != "0.1.18" {
t.Fatalf("installed %s", v)
}
// once bun takes it, Update installs it, and the plugin stays unpinned
for _, p := range []string{filepath.Join(home, ".bunfig.toml"), filepath.Join(os.Getenv("XDG_CONFIG_HOME"), ".bunfig.toml")} {
os.Remove(p)
}
if err := Upgrade(ctx, pkg); err != nil {
t.Fatal(err)
}
if v := Installed(pkg); v != "0.1.21" {
t.Fatalf("installed %s after the update, want 0.1.21", v)
}
if l := Load().Plugins; len(l) != 1 || l[0].Spec != pkg+"@latest" {
t.Fatalf("plugins = %+v, want %s@latest", l, pkg)
}
}
func TestAgeText(t *testing.T) {
for in, want := range map[string]string{"172800": "2 days", "86400": "1 day", "43200": "12 hours", "600": "10 minutes", "90": "90 seconds", "x": "x seconds"} {
if got := ageText(in); got != want {
t.Errorf("ageText(%s) = %q, want %q", in, got, want)
}
}
}
+4 -3
View File
@@ -91,8 +91,9 @@ var (
}
return out
}
// installLatest installs the package's newest version (tests stub it)
installLatest = func(ctx context.Context, pkg string) error { return install(ctx, pkg+"@latest") }
// installNewest installs the version of the package npm has as its
// newest (installAt; tests stub it)
installNewest = installAt
)
func updatesPath() string { return filepath.Join(settings.Dir(), "plugin-updates.json") }
@@ -170,7 +171,7 @@ func CheckUpdates(ctx context.Context) (Updates, error) {
waiting = append(waiting, Waiting{Spec: e.Spec, Package: pkg, Version: have, Latest: now})
continue
}
if err := installLatest(ctx, pkg); err != nil {
if err := installNewest(ctx, pkg, now); err != nil {
errs = append(errs, err)
log.Printf("updating the plugin %s: %s", pkg, err)
waiting = append(waiting, Waiting{Spec: e.Spec, Package: pkg, Version: have, Latest: now})
+3 -3
View File
@@ -60,9 +60,9 @@ func TestCheckNow(t *testing.T) {
npmCached()[p] = npmEntry{NPM{Version: "0.1.2"}, time.Now().Add(-time.Minute)}
}
npmMu.Unlock()
was := installLatest
t.Cleanup(func() { installLatest = was })
installLatest = func(_ context.Context, pkg string) error { t.Errorf("a check installed %s", pkg); return nil }
was := installNewest
t.Cleanup(func() { installNewest = was })
installNewest = func(_ context.Context, pkg, _ string) error { t.Errorf("a check installed %s", pkg); return nil }
c := CheckNow(context.Background())
got := map[string]string{}
+3 -3
View File
@@ -50,10 +50,10 @@ func TestCheckUpdates(t *testing.T) {
installedAt(t, fresh, "0.2.0")
latest := map[string]string{ours: "0.1.4", pinned: "0.1.4", theirs: "1.0.0", off: "0.1.4", fresh: "0.2.0"}
var installed []string
was, wasInstall := latestOf, installLatest
t.Cleanup(func() { latestOf, installLatest = was, wasInstall })
was, wasInstall := latestOf, installNewest
t.Cleanup(func() { latestOf, installNewest = was, wasInstall })
latestOf = func(context.Context, []string) map[string]string { return latest }
installLatest = func(_ context.Context, pkg string) error {
installNewest = func(_ context.Context, pkg, _ string) error {
installed = append(installed, pkg)
installedAt(t, pkg, latest[pkg])
return nil
+11 -1
View File
@@ -396,6 +396,7 @@ func lockMoves() (func(), error) {
// Hooks the tests stand in for.
var (
installPlugin = func(ctx context.Context, pkg, min string) error {
older := false
for _, e := range plugin.Load().Plugins {
if plugin.PackageName(e.Spec) != pkg {
continue
@@ -410,9 +411,18 @@ var (
if plugin.IsPath(e.Spec) {
return fmt.Errorf("%s at %s is %s; moving needs %s or newer", pkg, e.Spec, v, min)
}
older = !plugin.IsGit(e.Spec)
break
}
_, err := plugin.Add(ctx, pkg)
var err error
if older {
// npm's newest, which bun says why it won't install (a
// minimumReleaseAge), where bun's own latest is an older
// version without a word (sweanng424 on Discord)
err = plugin.Upgrade(ctx, pkg)
} else {
_, err = plugin.Add(ctx, pkg)
}
if err == nil && min != "" {
if v := plugin.Version(pkg); update.Newer(min, v) {
return fmt.Errorf("%s %s is installed; moving needs %s or newer", pkg, v, min)