221 lines
6.4 KiB
Go
221 lines
6.4 KiB
Go
package plugin
|
|
|
|
// Plugin updates, as magpie keeps itself up to date: the community's
|
|
// plugins (@magpie-community/*, the ones the built-in subscriptions move
|
|
// onto) update by themselves, a little after magpie starts and every few
|
|
// hours; anyone else's new version waits for the reader, who sees a dot
|
|
// on Plugins and updates it with a click. A plugin pinned to a version
|
|
// stays on it. Updating never cuts a reply streaming through a plugin:
|
|
// the host it runs on finishes it (see Restart).
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"log"
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/yetone/magpie/internal/settings"
|
|
"github.com/yetone/magpie/internal/steady"
|
|
"github.com/yetone/magpie/internal/update"
|
|
)
|
|
|
|
// Updates is what magpie's last look at npm found for the plugins.
|
|
type Updates struct {
|
|
Checked time.Time `json:"checked"`
|
|
// Waiting are the plugins with a newer version that magpie leaves to
|
|
// the reader: someone else's, or one pinned to a version.
|
|
Waiting []Waiting `json:"waiting"`
|
|
// Updated are the plugins magpie updated by itself, the latest last.
|
|
Updated []Updated `json:"updated"`
|
|
}
|
|
|
|
// Waiting is a plugin with a newer version on npm than the one installed.
|
|
type Waiting struct {
|
|
Spec string `json:"spec"`
|
|
Package string `json:"package"`
|
|
Version string `json:"version"`
|
|
Latest string `json:"latest"`
|
|
}
|
|
|
|
// Updated is a plugin magpie updated by itself.
|
|
type Updated struct {
|
|
Package string `json:"package"`
|
|
From string `json:"from"`
|
|
To string `json:"to"`
|
|
At time.Time `json:"at"`
|
|
}
|
|
|
|
const (
|
|
// updateEvery is how often magpie looks for plugin updates
|
|
updateEvery = 6 * time.Hour
|
|
// keepUpdated is how many updates magpie remembers making
|
|
keepUpdated = 20
|
|
)
|
|
|
|
// Official is whether the package is the community's, magpie's own,
|
|
// which magpie keeps up to date by itself.
|
|
func Official(pkg string) bool { return strings.HasPrefix(pkg, "@magpie-community/") }
|
|
|
|
// Pinned is whether spec names a version (or a range or a tag other than
|
|
// latest): the user chose it, and it stays. A git one's version is its
|
|
// repository's, not npm's: it is never pinned, and never asked of npm.
|
|
func Pinned(spec string) bool {
|
|
if IsPath(spec) || IsGit(spec) {
|
|
return false
|
|
}
|
|
v := strings.TrimPrefix(spec, Name(spec))
|
|
return v != "" && v != "@latest"
|
|
}
|
|
|
|
var (
|
|
updatesMu sync.Mutex
|
|
// latestOf is the version npm has of each package, "" for one it
|
|
// didn't tell (tests stub it)
|
|
latestOf = func(ctx context.Context, names []string) map[string]string {
|
|
out := map[string]string{}
|
|
for n, i := range Info(ctx, names) {
|
|
out[n] = i.Version
|
|
}
|
|
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") }
|
|
)
|
|
|
|
func updatesPath() string { return filepath.Join(settings.Dir(), "plugin-updates.json") }
|
|
|
|
func readUpdates() Updates {
|
|
var u Updates
|
|
if b, err := steady.ReadFile(updatesPath()); err == nil {
|
|
_ = json.Unmarshal(b, &u)
|
|
}
|
|
return u
|
|
}
|
|
|
|
// PendingUpdates is what the last look found, less what has changed since:
|
|
// a plugin updated, removed or switched off since waits no more.
|
|
func PendingUpdates() Updates {
|
|
updatesMu.Lock()
|
|
defer updatesMu.Unlock()
|
|
u := readUpdates()
|
|
have := map[string]Entry{}
|
|
for _, e := range Load().Plugins {
|
|
have[Name(e.Spec)] = e
|
|
}
|
|
u.Waiting = slices.DeleteFunc(u.Waiting, func(w Waiting) bool {
|
|
e, ok := have[w.Package]
|
|
return !ok || e.Off || !update.Newer(w.Latest, Installed(e.Spec))
|
|
})
|
|
if u.Waiting == nil {
|
|
u.Waiting = []Waiting{}
|
|
}
|
|
if u.Updated == nil {
|
|
u.Updated = []Updated{}
|
|
}
|
|
return u
|
|
}
|
|
|
|
// LastUpdated is the update magpie made by itself to the package since
|
|
// the time given, if it made one.
|
|
func LastUpdated(pkg string, since time.Time) (Updated, bool) {
|
|
u := PendingUpdates()
|
|
for i := len(u.Updated) - 1; i >= 0; i-- {
|
|
if x := u.Updated[i]; x.Package == pkg && x.At.After(since) {
|
|
return x, true
|
|
}
|
|
}
|
|
return Updated{}, false
|
|
}
|
|
|
|
// CheckUpdates asks npm for each plugin's newest version, updates the
|
|
// community's (unpinned, switched on) to it and notes the others' as
|
|
// waiting. The plugins updated are loaded again, a reply streaming
|
|
// through the old ones finishing first.
|
|
func CheckUpdates(ctx context.Context) (Updates, error) {
|
|
var es []Entry
|
|
var names []string
|
|
for _, e := range Load().Plugins {
|
|
// a git one's package may be on npm too, as someone else's
|
|
if !IsPath(e.Spec) && !IsGit(e.Spec) {
|
|
es = append(es, e)
|
|
names = append(names, Name(e.Spec))
|
|
}
|
|
}
|
|
latest := map[string]string{}
|
|
if len(names) > 0 {
|
|
latest = latestOf(ctx, names)
|
|
}
|
|
var waiting []Waiting
|
|
var made []Updated
|
|
var errs []error
|
|
for _, e := range es {
|
|
pkg, have, now := Name(e.Spec), Installed(e.Spec), latest[Name(e.Spec)]
|
|
if e.Off || have == "" || now == "" || !update.Newer(now, have) {
|
|
continue
|
|
}
|
|
if !Official(pkg) || Pinned(e.Spec) {
|
|
waiting = append(waiting, Waiting{Spec: e.Spec, Package: pkg, Version: have, Latest: now})
|
|
continue
|
|
}
|
|
if err := installLatest(ctx, pkg); 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})
|
|
continue
|
|
}
|
|
v := Installed(e.Spec)
|
|
if v == "" {
|
|
v = now
|
|
}
|
|
made = append(made, Updated{Package: pkg, From: have, To: v, At: time.Now().UTC().Truncate(time.Second)})
|
|
log.Printf("updated the plugin %s from %s to %s", pkg, have, v)
|
|
}
|
|
updatesMu.Lock()
|
|
u := readUpdates()
|
|
u.Checked = time.Now().UTC().Truncate(time.Second)
|
|
u.Waiting = waiting
|
|
u.Updated = append(u.Updated, made...)
|
|
if n := len(u.Updated); n > keepUpdated {
|
|
u.Updated = u.Updated[n-keepUpdated:]
|
|
}
|
|
if b, err := json.MarshalIndent(u, "", " "); err == nil {
|
|
if err := os.MkdirAll(settings.Dir(), 0o700); err == nil {
|
|
_ = writeWhole(updatesPath(), b)
|
|
}
|
|
}
|
|
updatesMu.Unlock()
|
|
if len(made) > 0 {
|
|
Restart()
|
|
}
|
|
if len(errs) > 0 {
|
|
return u, errs[0]
|
|
}
|
|
return u, nil
|
|
}
|
|
|
|
// KeepUpdated looks for plugin updates a little after magpie starts, then
|
|
// every updateEvery, run by the magpie serving the gateway (one magpie,
|
|
// never two at once).
|
|
func KeepUpdated(ctx context.Context) {
|
|
t := time.NewTimer(30 * time.Second)
|
|
defer t.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-t.C:
|
|
}
|
|
if len(Load().Plugins) > 0 {
|
|
cctx, cancel := context.WithTimeout(ctx, 10*time.Minute)
|
|
_, _ = CheckUpdates(cctx)
|
|
cancel()
|
|
}
|
|
t.Reset(updateEvery)
|
|
}
|
|
}
|