diff --git a/internal/plugin/market.go b/internal/plugin/market.go index 125dd6f8..f9c9b954 100644 --- a/internal/plugin/market.go +++ b/internal/plugin/market.go @@ -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 } } diff --git a/internal/plugin/store.go b/internal/plugin/store.go index 1918381d..58c178cf 100644 --- a/internal/plugin/store.go +++ b/internal/plugin/store.go @@ -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" diff --git a/internal/plugin/update_held_test.go b/internal/plugin/update_held_test.go new file mode 100644 index 00000000..e181c816 --- /dev/null +++ b/internal/plugin/update_held_test.go @@ -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) + } + } +} diff --git a/internal/plugin/updates.go b/internal/plugin/updates.go index 7e66b834..d72f4888 100644 --- a/internal/plugin/updates.go +++ b/internal/plugin/updates.go @@ -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}) diff --git a/internal/plugin/updates_check_test.go b/internal/plugin/updates_check_test.go index 9d503b0f..afba1d7a 100644 --- a/internal/plugin/updates_check_test.go +++ b/internal/plugin/updates_check_test.go @@ -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{} diff --git a/internal/plugin/updates_test.go b/internal/plugin/updates_test.go index 97a7418e..5d099570 100644 --- a/internal/plugin/updates_test.go +++ b/internal/plugin/updates_test.go @@ -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 diff --git a/internal/provider/migrate.go b/internal/provider/migrate.go index 4b993cbf..fd50545a 100644 --- a/internal/provider/migrate.go +++ b/internal/provider/migrate.go @@ -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)