Files
magpie/internal/provider/migrate.go
T

1112 lines
33 KiB
Go

package provider
// Retiring a built-in subscription. Each subscription with a mover below
// has a community plugin that does what the built-in does
// (github.com/magpie-community/plugins). Moving one puts its accounts onto
// the plugin under the same id, so agents on zed/<model>, the model picks,
// routing and fallbacks carry on as they were:
//
// 1. the plugin is installed, if it isn't;
// 2. each account's sign-in is kept as the plugin keeps one, in the order
// and on or off as the user had it;
// 3. each is tried as a request would try it (the plugin's loader and its
// model list for that account), and every model the built-in has in use
// must be one the plugin serves;
// 4. only then does the id change hands: the built-in's saved accounts go
// out of logins.json into migrations.json, kept for going back.
//
// Anything short of that puts everything back as it was — the plugin's
// newer tokens included, since a refresh token used once is spent — and
// the built-in carries on. MoveBack undoes a move the same way.
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"log"
"maps"
"net/http"
"os"
"path/filepath"
"regexp"
"slices"
"sort"
"strconv"
"strings"
"sync"
"time"
"github.com/yetone/magpie/internal/filememo"
"github.com/yetone/magpie/internal/plugin"
"github.com/yetone/magpie/internal/update"
)
// A migration's states.
const (
MoveMoving = "moving" // under way, or cut short (put back at the next try)
MovePlugin = "plugin" // the plugin has the id
MovedBack = "back" // the user moved it back: it stays built-in
MoveFailed = "failed" // the last try failed: the built-in carries on
)
// Retiring are the built-ins magpie moves onto their plugins by itself,
// at start-up; the rest move only when the user asks.
var Retiring = []string{}
// moveRetry is how long a failed move waits before it is tried again.
const moveRetry = 6 * time.Hour
// Migration is where one built-in's move stands.
type Migration struct {
State string `json:"state"`
Package string `json:"package,omitempty"`
At time.Time `json:"at"`
Err string `json:"error,omitempty"`
// Accounts are the plugin's accounts the move made or took over.
Accounts []movedAccount `json:"accounts,omitempty"`
// Backup is the built-in's accounts as logins.json kept them.
Backup []savedLogin `json:"backup,omitempty"`
// Kept is what else of the built-in's the move set aside (Kiro's key).
Kept json.RawMessage `json:"kept,omitempty"`
// Installed is whether the move installed the plugin: going back takes
// it away again when it serves nothing else.
Installed bool `json:"installed,omitempty"`
// Host is the built-in's API host, which the plugin's provider shows.
Host string `json:"host,omitempty"`
// Why is a failed move's reason, for the page to say in the user's
// language.
Why *MoveWhy `json:"why,omitempty"`
}
// MoveWhy is why a move didn't go through: Code names the case, Args fill
// in its sentence.
//
// offline magpie couldn't reach npm (or Bun's download) to install the plugin
// install npm couldn't install it: line
// lapsed every account of name needs signing in again
// unserved the plugin doesn't serve models (their names)
// account user doesn't work through the plugin: error
type MoveWhy struct {
Code string `json:"code"`
Args map[string]string `json:"args,omitempty"`
}
// moveError is a failed move's error in words a user reads, with its why.
type moveError struct {
why MoveWhy
msg string
err error // what lay under it, for the log
}
func (e *moveError) Error() string { return e.msg }
func (e *moveError) Unwrap() error { return e.err }
// WhyOf is the reason a move failed, nil for one it has no words for.
func WhyOf(err error) *MoveWhy {
var me *moveError
if errors.As(err, &me) {
w := me.why
return &w
}
return nil
}
// unreachable is an install that never got through to npm (or to Bun's
// download): Bun's words for it, and Go's.
var unreachable = regexp.MustCompile(`(?i)ConnectionRefused|ConnectionClosed|FailedToOpenSocket|ENOTFOUND|ETIMEDOUT|ECONNRESET|ECONNREFUSED|EAI_AGAIN|Unable to connect|network|no such host|dial tcp|i/o timeout|TLS handshake`)
// installFailed says why the plugin couldn't be installed.
func installFailed(pkg string, err error) error {
if unreachable.MatchString(err.Error()) {
return &moveError{MoveWhy{Code: "offline"}, "magpie couldn't reach npm to install the plugin. Check the network or proxy, then try again.", err}
}
line := strings.TrimSpace(err.Error())
if ls := strings.Split(line, "\n"); len(ls) > 1 {
line = strings.TrimSpace(ls[len(ls)-1])
}
return &moveError{MoveWhy{Code: "install", Args: map[string]string{"line": line}}, fmt.Sprintf("npm couldn't install %s: %s", pkg, line), err}
}
// nameOf is the built-in id's name, as the providers list shows it.
func nameOf(id string) string {
for _, p := range All() {
if p.ID == id && !p.IsPlugin() && p.Name != "" {
return p.Name
}
}
return id
}
type movedAccount struct {
Key string `json:"key"`
User string `json:"user"`
Own bool `json:"own,omitempty"`
// Was is what the plugin kept at Key before, when the user had signed
// in to the same account through the plugin already.
Was map[string]any `json:"was,omitempty"`
// Sent is (a hash of) the sign-in given to the plugin: a plugin still
// holding it renewed nothing, so the built-in's own is as new.
Sent string `json:"sent,omitempty"`
}
// signinHash tells two sign-ins apart without keeping either.
func signinHash(a map[string]any) string {
h := sha256.Sum256([]byte(jsonText(a)))
return hex.EncodeToString(h[:8])
}
// renewedSince are the built-in's accounts whose sign-in isn't the one
// sent: the built-in renewed them while the move went on (a request, a
// refresh) and, a refresh token being spent once used, the plugin's copy
// may be dead.
func renewedSince(mv *mover, moved []movedAccount) []string {
now, err := mv.out()
if err != nil {
return nil
}
var out []string
for _, ma := range moved {
if ma.Own {
continue
}
for _, a := range now {
if strings.EqualFold(a.User, ma.User) && signinHash(a.Auth) != ma.Sent {
out = append(out, ma.User)
}
}
}
return out
}
// Moving is one of a built-in's accounts on its way to the plugin.
type Moving struct {
User string
First, On bool
// Lapsed is an account the vendor already refused: it goes along, but
// isn't tried.
Lapsed bool
// Plan is the plan the account showed, kept beside it until the
// plugin tells it anew.
Plan string
// Own is the agent's own sign-in (its CLI's, its app's), which the
// plugin reads where the agent keeps it, as the built-in did.
Own bool
Auth map[string]any
}
// A mover carries one built-in's accounts to its plugin and back.
type mover struct {
pkg string
// min is the oldest version of pkg that runs the accounts as moved
min string
// agents are the logins.json agents its accounts are saved under.
agents []string
// out is the built-in's accounts as the plugin's sign-ins, the one in
// use first.
out func() ([]Moving, error)
// back puts one of the plugin's sign-ins back into the built-in's
// saved accounts: into the one it came from (user), else a new one.
// It gives the accounts and the one it wrote to.
back func(ls []savedLogin, user string, auth map[string]any) ([]savedLogin, string, error)
// take and give set aside what else of the built-in's the plugin now
// has, and put it back.
take func() (json.RawMessage, error)
give func(json.RawMessage) error
// settle finishes the sign-ins handed back once logins.json is let go:
// what else of the built-in's they carry (a key onto its provider),
// whose saving syncs the agents, which read the accounts.
settle func(auths map[string]map[string]any) error
}
var movers = map[string]*mover{}
// errStays is a back's answer for a sign-in the built-in has nowhere to
// keep (one made in the plugin's own way): it stays with the plugin, which
// lists it under its own id once the built-in has the id again.
var errStays = errors.New("stays with the plugin")
// Movable is whether the built-in id has a plugin it can move to.
func Movable(id string) bool { return movers[id] != nil }
// MoveCandidate is a built-in with accounts its plugin could run instead.
type MoveCandidate struct {
ID string `json:"id"`
Name string `json:"name"`
Package string `json:"package"`
Accounts int `json:"accounts"`
}
// MoveCandidates are the built-ins not on their plugins that have accounts
// to move, by name.
func MoveCandidates() []MoveCandidate {
out := []MoveCandidate{}
for id, mv := range movers {
if Moved(id) {
continue
}
if accts, err := mv.out(); err == nil && len(accts) > 0 {
out = append(out, MoveCandidate{ID: id, Name: nameOf(id), Package: mv.pkg, Accounts: len(accts)})
}
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out
}
// MovePackage is the plugin the built-in id moves to.
func MovePackage(id string) string {
if m := movers[id]; m != nil {
return m.pkg
}
return ""
}
func migrationsPath() string { return filepath.Join(filepath.Dir(Path()), "migrations.json") }
var migrationsMu sync.Mutex
func readMigrations() map[string]Migration {
m, _ := filememo.Read("migrations", migrationsPath(), func(b []byte) (map[string]Migration, error) {
var m map[string]Migration
_ = json.Unmarshal(b, &m)
return m, nil
})
return m
}
// MigrationOf is where the built-in id's move stands.
func MigrationOf(id string) (Migration, bool) {
m, ok := readMigrations()[id]
return m, ok
}
// Moved is whether the built-in id's accounts are its plugin's now.
func Moved(id string) bool {
m, ok := MigrationOf(id)
return ok && m.State == MovePlugin
}
// movedAgent is whether the logins.json agent's accounts are a moved
// built-in's, its plugin's now.
func movedAgent(agent string) bool {
for id, mv := range movers {
if slices.Contains(mv.agents, agent) && Moved(id) {
return true
}
}
return false
}
// OnPlugins are the built-ins moved onto their plugins.
func OnPlugins() []string {
var out []string
for id, m := range readMigrations() {
if m.State == MovePlugin && movers[id] != nil {
out = append(out, id)
}
}
sort.Strings(out)
return out
}
func movingNow(id string) bool {
m, ok := MigrationOf(id)
return ok && m.State == MoveMoving
}
func setMigration(id string, f func(m *Migration)) error {
migrationsMu.Lock()
defer migrationsMu.Unlock()
all := map[string]Migration{}
for k, v := range readMigrations() {
all[k] = v
}
m := all[id]
f(&m)
all[id] = m
b, err := json.MarshalIndent(all, "", " ")
if err != nil {
return err
}
return writePrivate(migrationsPath(), append(b, '\n'))
}
// lockMoves keeps two magpies (the app and a CLI, a dev build beside a
// release) from moving at once.
func lockMoves() (func(), error) {
p := migrationsPath() + ".lock"
if err := os.MkdirAll(filepath.Dir(p), 0o700); err != nil {
return nil, err
}
for range 2 {
f, err := os.OpenFile(p, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
if err == nil {
fmt.Fprint(f, os.Getpid())
f.Close()
return func() { os.Remove(p) }, nil
}
// one left by a magpie that died moving (killed, or stopped
// mid-move) holds nothing
if b, rerr := os.ReadFile(p); rerr == nil {
if pid, perr := strconv.Atoi(strings.TrimSpace(string(b))); perr == nil && pid != os.Getpid() && !update.Alive(pid) {
os.Remove(p)
continue
}
}
if fi, serr := os.Stat(p); serr == nil && time.Since(fi.ModTime()) > 15*time.Minute {
os.Remove(p)
continue
}
return nil, errors.New("another magpie is moving subscriptions to plugins")
}
return nil, errors.New("another magpie is moving subscriptions to plugins")
}
// Hooks the tests stand in for.
var (
installPlugin = func(ctx context.Context, pkg, min string) error {
for _, e := range plugin.Load().Plugins {
if plugin.PackageName(e.Spec) != pkg {
continue
}
if e.Off {
return fmt.Errorf("%s is turned off in Plugins", pkg)
}
v := plugin.Version(e.Spec)
if min == "" || !update.Newer(min, v) {
return nil
}
if plugin.IsPath(e.Spec) {
return fmt.Errorf("%s at %s is %s; moving needs %s or newer", pkg, e.Spec, v, min)
}
break
}
_, 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)
}
}
return err
}
pluginProviders = plugin.Providers
removePlugin = plugin.Remove
)
// modelsInUse are the models the built-in id serves now: the ones agents,
// picks and routing can name.
var modelsInUse = func(id string) []string {
for _, p := range All() {
if p.ID == id && !p.IsPlugin() {
var out []string
for _, m := range p.Exposed() {
out = append(out, m.ID)
}
return out
}
}
return nil
}
// Move puts the built-in id's accounts onto its plugin (see above). An
// error leaves the built-in as it was.
func Move(ctx context.Context, id string) error {
mv := movers[id]
if mv == nil {
return fmt.Errorf("%s has no plugin to move to", id)
}
unlock, err := lockMoves()
if err != nil {
return err
}
defer unlock()
if Moved(id) {
return nil
}
if movingNow(id) {
putBack(ctx, id, mv, "")
}
err = move(ctx, id, mv)
if err != nil {
_ = setMigration(id, func(m *Migration) {
*m = Migration{State: MoveFailed, Package: mv.pkg, At: time.Now().UTC().Truncate(time.Second), Err: err.Error(), Why: WhyOf(err)}
})
if me := (*moveError)(nil); errors.As(err, &me) && me.err != nil {
log.Printf("moving %s to its plugin: %s (%s)", id, me.msg, me.err)
}
}
return err
}
func move(ctx context.Context, id string, mv *mover) (err error) {
inUse := modelsInUse(id)
var host string
if p, err := Find(id); err == nil && !p.IsPlugin() {
host = p.Host()
}
accts, err := mv.out()
if err != nil {
return err
}
if len(accts) == 0 {
return fmt.Errorf("no %s account to move", id)
}
// every account lapsed: nothing to try the plugin with, so nothing is
// installed for it
if !slices.ContainsFunc(accts, func(a Moving) bool { return !a.Lapsed }) {
return lapsedAll(id)
}
had := pluginListed(mv.pkg)
if err := installPlugin(ctx, mv.pkg, mv.min); err != nil {
return installFailed(mv.pkg, err)
}
installed := !had
// a plugin the move installed goes with a move that failed, which
// leaves nothing of it behind: no provider saying it's signed out
defer func() {
if err != nil && installed {
if rerr := removePlugin(context.WithoutCancel(ctx), mv.pkg); rerr != nil {
log.Printf("removing %s after a failed move: %s", mv.pkg, rerr)
}
}
}()
pps, err := pluginProviders(ctx)
if err != nil {
return err
}
var pp plugin.Provider
for _, p := range pps {
if p.ID == id {
if plugin.PackageName(p.Spec) != mv.pkg {
return fmt.Errorf("%s is served by another plugin, %s", id, p.Spec)
}
pp = p
}
}
if pp.ID == "" {
return fmt.Errorf("%s doesn't serve %s", mv.pkg, id)
}
before := plugin.Auths(id)
if err := setMigration(id, func(m *Migration) {
*m = Migration{State: MoveMoving, Package: mv.pkg, At: time.Now().UTC().Truncate(time.Second)}
}); err != nil {
return err
}
defer func() {
if err != nil {
putBack(ctx, id, mv, err.Error())
}
}()
var moved []movedAccount
for _, a := range accts {
key, err := plugin.Import(ctx, id, a.Auth)
if err != nil {
return fmt.Errorf("%s: %w", a.User, err)
}
ma := movedAccount{Key: key, User: a.User, Own: a.Own, Was: before[key], Sent: signinHash(a.Auth)}
moved = append(moved, ma)
if err := setMigration(id, func(m *Migration) { m.Accounts = moved }); err != nil {
return err
}
}
if err := keepOrder(pp, accts, moved); err != nil {
return err
}
tried := false
for i, a := range accts {
if a.Lapsed {
continue
}
// through the account's proxy, as its requests go
c, err := plugin.Check(ViaLogin(ctx, id, a.User), id, moved[i].Key)
if err == nil && (c.Refused != "" || refused(c.Usage)) {
// the vendor turned the sign-in away: marked, as the built-in
// marked it, and along untried, as a lapsed one goes
notePluginLapse(pp, moved[i].Key, http.StatusUnauthorized)
continue
}
if err != nil && signInGone.MatchString(err.Error()) {
// the plugin says in words the sign-in is gone (Grok's past
// its time): it moves along untried, as a lapsed one does, and
// unmarked, as the plugin's own reads leave its accounts
continue
}
if err != nil {
return &moveError{MoveWhy{Code: "account", Args: map[string]string{"user": a.User, "error": err.Error()}}, fmt.Sprintf("%s doesn't work through the plugin: %s", a.User, err), err}
}
if !tried {
if missing := slices.DeleteFunc(slices.Clone(inUse), func(m string) bool { return slices.Contains(c.Models, m) }); len(missing) > 0 {
names := strings.Join(modelNames(id, missing), ", ")
return &moveError{MoveWhy{Code: "unserved", Args: map[string]string{"models": names}}, fmt.Sprintf("the plugin doesn't serve %s. Untick them under Models, or keep the built-in.", names), nil}
}
tried = true
}
}
if !tried {
return lapsedAll(id)
}
return commitMove(id, mv, moved, installed, host)
}
func lapsedAll(id string) error {
name := nameOf(id)
return &moveError{MoveWhy{Code: "lapsed", Args: map[string]string{"name": name}}, fmt.Sprintf("every %s account needs signing in again. Sign one in, then move.", name), nil}
}
// refused is a check's usage read saying the vendor turned the account's
// sign-in away, read as the usage page reads it: a models hook may fall
// back to a list it keeps, but the read asks the vendor of the account.
func refused(u *plugin.Usage) bool {
return u != nil && (u.SignIn == "expired" || u.SignIn == "" && signInGone.MatchString(u.Error))
}
// modelNames are the ids' names, as the built-in id lists them.
func modelNames(id string, ids []string) []string {
names := map[string]string{}
for _, p := range All() {
if p.ID == id && !p.IsPlugin() {
for _, m := range p.Available() {
names[m.ID] = m.Name
}
}
}
var out []string
for _, m := range ids {
if names[m] != "" {
m = names[m]
}
out = append(out, m)
}
return out
}
// pluginListed is whether the package is in the plugin list already.
func pluginListed(pkg string) bool {
return slices.ContainsFunc(plugin.Load().Plugins, func(e plugin.Entry) bool { return plugin.PackageName(e.Spec) == pkg })
}
// keepOrder lists the accounts moved as the built-in had them: the one in
// use first, the rest on or off. What the user had signed in to through
// the plugin already keeps its place.
func keepOrder(pp plugin.Provider, accts []Moving, moved []movedAccount) error {
agent := pluginAgent(pp)
loginsMu.Lock()
defer loginsMu.Unlock()
ls := readLogins()
first := ""
for i, a := range accts {
if a.First && moved[i].Was == nil {
first = moved[i].Key
}
}
for i, a := range accts {
ma := moved[i]
if ma.Was != nil {
continue
}
ls = slices.DeleteFunc(ls, func(l savedLogin) bool { return l.Agent == agent && l.Home == ma.Key })
l := savedLogin{Agent: agent, User: a.User, Plan: a.Plan, Home: ma.Key, On: a.On || a.First, First: ma.Key == first, Seen: time.Now().UTC().Truncate(time.Second)}
if a.Lapsed {
l.Lapsed = lapsedText(pp, a.User)
}
ls = append(ls, l)
}
if first != "" {
for i := range ls {
if ls[i].Agent == agent && ls[i].Home != first {
ls[i].First = false
}
}
}
return writeLogins(ls)
}
// commitMove hands the id over: the built-in's accounts go aside.
func commitMove(id string, mv *mover, moved []movedAccount, installed bool, host string) error {
if r := renewedSince(mv, moved); len(r) > 0 {
return fmt.Errorf("%s renewed while moving; tried again later", strings.Join(r, ", "))
}
var kept json.RawMessage
if mv.take != nil {
k, err := mv.take()
if err != nil {
return err
}
kept = k
}
loginsMu.Lock()
var backup []savedLogin
for _, l := range readLogins() {
if slices.Contains(mv.agents, l.Agent) {
backup = append(backup, l)
}
}
loginsMu.Unlock()
if err := setMigration(id, func(m *Migration) {
*m = Migration{State: MovePlugin, Package: mv.pkg, At: time.Now().UTC().Truncate(time.Second), Accounts: moved, Backup: backup, Kept: kept, Installed: installed, Host: host}
}); err != nil {
if mv.give != nil {
_ = mv.give(kept)
}
return err
}
tidyMoved(id)
return nil
}
// tidyMoved takes a moved built-in's accounts out of logins.json (into its
// backup, if one was left behind when a magpie stopped between the two).
func tidyMoved(id string) {
mv := movers[id]
if mv == nil || !Moved(id) {
return
}
loginsMu.Lock()
ls := readLogins()
var left []savedLogin
ls = slices.DeleteFunc(ls, func(l savedLogin) bool {
if slices.Contains(mv.agents, l.Agent) {
left = append(left, l)
return true
}
return false
})
if len(left) > 0 {
_ = writeLogins(ls)
}
loginsMu.Unlock()
if len(left) == 0 {
return
}
_ = setMigration(id, func(m *Migration) {
for _, l := range left {
if !slices.ContainsFunc(m.Backup, func(b savedLogin) bool { return sameMoved(b, l) }) {
m.Backup = append(m.Backup, l)
}
}
})
}
func sameMoved(a, b savedLogin) bool {
return a.Agent == b.Agent && strings.EqualFold(a.User, b.User) && a.Home == b.Home
}
// putBack undoes a move cut short: the plugin's sign-ins go back into the
// built-in's accounts (its newer tokens with them), then out of the
// plugin — but for one the user had there already, which gets back what
// it had.
func putBack(ctx context.Context, id string, mv *mover, why string) {
m, _ := MigrationOf(id)
var keys []string
for _, ma := range m.Accounts {
keys = append(keys, ma.Key)
}
renewed := renewedSince(mv, m.Accounts)
skip := func(ma movedAccount, a map[string]any) bool {
return signinHash(a) == ma.Sent || slices.ContainsFunc(renewed, func(u string) bool { return strings.EqualFold(u, ma.User) })
}
_, _, _ = handBack(ctx, id, mv, m.Accounts, keys, skip, nil, func(ls []savedLogin, _ map[string]string) []savedLogin { return ls })
for _, ma := range m.Accounts {
if ma.Was != nil {
_ = plugin.Restore(ctx, id, ma.Key, ma.Was)
}
}
// the plugin's rows keepOrder wrote go with its accounts, but for one
// the user had there before
loginsMu.Lock()
ls := readLogins()
if n := len(ls); n > 0 {
ls = slices.DeleteFunc(ls, func(l savedLogin) bool {
return l.Agent == "plugin:"+id && slices.ContainsFunc(m.Accounts, func(ma movedAccount) bool { return ma.Key == l.Home && ma.Was == nil })
})
if len(ls) != n {
_ = writeLogins(ls)
}
}
loginsMu.Unlock()
_ = setMigration(id, func(m *Migration) {
*m = Migration{State: MoveFailed, Package: mv.pkg, At: time.Now().UTC().Truncate(time.Second), Err: why}
})
}
// handBack writes the plugin's accounts keys back into the built-in's
// saved accounts, then takes them out of the plugin. They are written
// before they are taken, so a magpie stopped between the two loses no
// sign-in; one the plugin renewed in between is written again. arrange
// finishes the accounts written (given each key's user). An account that
// can't go back stops it all before anything is written.
func handBack(ctx context.Context, id string, mv *mover, accts []movedAccount, keys []string, skip func(movedAccount, map[string]any) bool, prep, arrange func([]savedLogin, map[string]string) []savedLogin) (map[string]string, []error, error) {
var stays []string
byKey := map[string]movedAccount{}
for _, ma := range accts {
byKey[ma.Key] = ma
}
write := func(auths map[string]map[string]any) (map[string]string, []error, error) {
loginsMu.Lock()
defer loginsMu.Unlock()
ls := readLogins()
if prep != nil {
ls = prep(ls, nil)
}
users := map[string]string{}
var errs []error
stays = nil
for _, k := range keys {
ma := byKey[k]
if ma.Own {
users[k] = ma.User
continue
}
a := auths[k]
if a == nil {
continue
}
if skip != nil && skip(ma, a) {
users[k] = ma.User
continue
}
next, u, err := mv.back(ls, ma.User, a)
if errors.Is(err, errStays) {
stays = append(stays, k)
continue
}
if err != nil {
errs = append(errs, err)
continue
}
ls, users[k] = next, u
}
if len(errs) > 0 {
return users, errs, nil
}
return users, nil, writeLogins(arrange(ls, users))
}
settle := func(auths map[string]map[string]any) error {
if mv.settle == nil {
return nil
}
mine := map[string]map[string]any{}
for _, k := range keys {
if a := auths[k]; a != nil && !byKey[k].Own {
mine[k] = a
}
}
return mv.settle(mine)
}
seen := plugin.Auths(id)
users, errs, err := write(seen)
if err == nil && len(errs) == 0 {
err = settle(seen)
}
if err != nil || len(errs) > 0 {
return users, errs, err
}
keys = slices.DeleteFunc(slices.Clone(keys), func(k string) bool { return slices.Contains(stays, k) })
if len(keys) == 0 {
return users, nil, nil
}
taken, err := plugin.Take(ctx, id, keys)
if err != nil {
return users, nil, err
}
for _, k := range keys {
if t := taken[k]; t != nil && jsonText(t) != jsonText(seen[k]) {
users, errs, err := write(taken)
if err == nil && len(errs) == 0 {
err = settle(taken)
}
return users, errs, err
}
}
return users, nil, nil
}
// MoveBack gives the id back to the built-in: its accounts as they were
// set aside, with what the plugin has of them now (newer tokens, accounts
// signed in to since), in the plugin's order; then the plugin signs out.
func MoveBack(ctx context.Context, id string) error {
return moveBack(ctx, id, true)
}
// moveBack is MoveBack; tidy takes away a plugin the move installed that
// serves nothing else now, which releasePlugin, about to remove or turn
// off the plugin itself, leaves to its caller.
func moveBack(ctx context.Context, id string, tidy bool) error {
mv := movers[id]
if mv == nil {
return fmt.Errorf("%s isn't a built-in subscription", id)
}
unlock, err := lockMoves()
if err != nil {
return err
}
defer unlock()
m, ok := MigrationOf(id)
if !ok || m.State != MovePlugin {
return nil
}
var order []plugin.Account
if pp, ok := pluginOfAgent("plugin:" + id); ok {
for _, l := range pluginLogins(pp) {
order = append(order, l.acct)
}
}
keys := slices.Collect(maps.Keys(plugin.Auths(id)))
sort.SliceStable(keys, func(i, j int) bool {
if a, b := indexOfKey(order, keys[i]), indexOfKey(order, keys[j]); a != b {
return a < b
}
return keys[i] < keys[j]
})
onOf := pluginOn(id)
// the backup first, for back to write into the accounts it came from
prep := func(ls []savedLogin, _ map[string]string) []savedLogin {
for _, b := range m.Backup {
// the agent's own sign-in is one row: one written while moved
// (the agent signed in to another account meanwhile) gives
// way to the one set aside, which takes the agent's user anew
if b.own() {
ls = slices.DeleteFunc(ls, func(l savedLogin) bool { return l.Agent == b.Agent && l.own() && !sameMoved(l, b) })
}
if !slices.ContainsFunc(ls, func(l savedLogin) bool { return sameMoved(l, b) }) {
ls = append(ls, b)
}
}
return ls
}
arrange := func(ls []savedLogin, users map[string]string) []savedLogin {
// an account the vendor refused through the plugin goes back
// marked, as the built-in had marked it: back writes the plugin's
// sign-in as one just made, but the plugin's mark is the newer word
lapsed := map[string]string{}
for _, l := range ls {
if u := users[l.Home]; l.Agent == "plugin:"+id && u != "" && l.Lapsed != "" {
lapsed[strings.ToLower(u)] = l.Lapsed
}
}
// the plugin's rows of the accounts going back go with them, or
// the accounts page lists each twice, the plugin's copy empty
ls = slices.DeleteFunc(ls, func(l savedLogin) bool {
_, back := users[l.Home]
return l.Agent == "plugin:"+id && (back || len(keys) == 0)
})
firstUser := ""
for i, k := range keys {
user := users[k]
if user == "" {
continue
}
if firstUser == "" && i == 0 {
firstUser = user
}
for j := range ls {
if slices.Contains(mv.agents, ls[j].Agent) && strings.EqualFold(ls[j].User, user) {
if on, ok := onOf[k]; ok {
ls[j].On = on
}
}
}
}
for j := range ls {
if t := lapsed[strings.ToLower(ls[j].User)]; t != "" && slices.Contains(mv.agents, ls[j].Agent) && !ls[j].own() {
ls[j].Lapsed = t
}
}
if firstUser != "" {
for j := range ls {
if slices.Contains(mv.agents, ls[j].Agent) {
ls[j].First = strings.EqualFold(ls[j].User, firstUser) && !ls[j].own()
}
}
}
return ls
}
// the backup goes back even with no account left in the plugin
if len(keys) == 0 {
loginsMu.Lock()
err := writeLogins(arrange(prep(readLogins(), nil), nil))
loginsMu.Unlock()
if err != nil {
return err
}
} else {
_, errs, err := handBack(ctx, id, mv, m.Accounts, keys, nil, prep, arrange)
if err != nil {
tidyMoved(id)
return err
}
if len(errs) > 0 {
// the plugin keeps the id and every account
return errors.Join(errs...)
}
}
if mv.give != nil && len(m.Kept) > 0 {
if err := mv.give(m.Kept); err != nil {
return err
}
}
if err := setMigration(id, func(m *Migration) {
*m = Migration{State: MovedBack, Package: mv.pkg, At: time.Now().UTC().Truncate(time.Second)}
}); err != nil {
return err
}
if tidy && m.Installed && !servesAnything(mv.pkg) {
if err := removePlugin(context.WithoutCancel(ctx), mv.pkg); err != nil {
log.Printf("removing %s, installed by %s's move: %s", mv.pkg, id, err)
}
}
return nil
}
// servesAnything is whether the plugin package has an account signed in
// to any of its providers, or a built-in moved onto it.
func servesAnything(pkg string) bool {
for _, p := range plugin.Cached() {
if plugin.PackageName(p.Spec) == pkg && len(plugin.Auths(p.ID)) > 0 {
return true
}
}
for _, id := range OnPlugins() {
if m, _ := MigrationOf(id); m.Package == pkg {
return true
}
}
return false
}
func indexOfKey(order []plugin.Account, key string) int {
if i := slices.IndexFunc(order, func(a plugin.Account) bool { return a.Key == key }); i >= 0 {
return i
}
return len(order)
}
// pluginOn is which of the plugin provider id's accounts are on.
func pluginOn(id string) map[string]bool {
out := map[string]bool{}
loginsMu.Lock()
defer loginsMu.Unlock()
for _, l := range readLogins() {
if l.Agent == "plugin:"+id {
out[l.Home] = l.On || l.First
}
}
return out
}
// MoveRetiring moves the built-ins being retired that have accounts, at
// start-up: once, or again moveRetry after one failed. One the user moved
// back stays.
func MoveRetiring(ctx context.Context) map[string]error {
out := map[string]error{}
for _, id := range Retiring {
m, ok := MigrationOf(id)
switch {
case ok && m.State == MovePlugin:
tidyMoved(id)
continue
case ok && m.State == MovedBack:
continue
case ok && m.State == MoveFailed && time.Since(m.At) < moveRetry:
continue
}
if accts, err := movers[id].out(); err != nil || len(accts) == 0 {
continue
}
out[id] = Move(ctx, id)
}
return out
}
// keepMovedCurrent updates the plugin of each built-in moved onto one to
// the version its move needs, when older: a built-in came up to date with
// magpie, and its plugin does too. One turned off or run from a folder is
// left as it is.
func keepMovedCurrent(ctx context.Context) {
for _, id := range OnPlugins() {
mv := movers[id]
if mv.min == "" {
continue
}
if err := installPlugin(ctx, mv.pkg, mv.min); err != nil {
log.Printf("updating %s's plugin: %s", id, err)
}
}
}
// KeepRetiringMoved moves the built-ins being retired, run by the magpie
// serving the gateway (one magpie, never two at once): a little after it
// starts, then every hour, so a failed move is tried again once moveRetry
// has gone. It also keeps each moved built-in's plugin up to date.
func KeepRetiringMoved(ctx context.Context) {
t := time.NewTimer(20 * time.Second)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
}
keepMovedCurrent(ctx)
for id, err := range MoveRetiring(ctx) {
if err != nil {
log.Printf("moving %s to its plugin: %s (it stays built-in)", id, err)
} else {
log.Printf("moved %s to its plugin, %s", id, MovePackage(id))
}
}
t.Reset(time.Hour)
}
}
// MovedOnto are the built-ins moved onto the plugin named (its spec or
// package name).
func MovedOnto(name string) []string {
var out []string
for _, e := range plugin.Load().Plugins {
if plugin.Name(e.Spec) != name && e.Spec != name {
continue
}
pkg := plugin.PackageName(e.Spec)
for _, id := range OnPlugins() {
if m, _ := MigrationOf(id); m.Package == pkg && !slices.Contains(out, id) {
out = append(out, id)
}
}
}
return out
}
// releasePlugin moves the built-ins on the plugin named back first, so
// taking the plugin away leaves none of them without its accounts.
func releasePlugin(ctx context.Context, name string) error {
for _, id := range MovedOnto(name) {
if err := moveBack(ctx, id, false); err != nil {
return fmt.Errorf("moving %s back to the built-in: %w", id, err)
}
}
return nil
}
// RemovePlugin removes the plugin named, the built-ins moved onto it moved
// back to themselves first.
func RemovePlugin(ctx context.Context, name string) error {
if err := releasePlugin(ctx, name); err != nil {
return err
}
return plugin.Remove(ctx, name)
}
// SetPluginOff turns the plugin named off, the built-ins moved onto it
// moved back to themselves first, or back on.
func SetPluginOff(ctx context.Context, name string, off bool) error {
if off {
if err := releasePlugin(ctx, name); err != nil {
return err
}
}
return plugin.SetOff(name, off)
}