Files
magpie/internal/plugin/store.go
T
yetone 123594489a plugin: a host stopped mid-renewal saves the new token first, and two hosts never write each other's sign-ins away — a stopped host (quit, plugin update, restart) was killed 2 s after stdin closed even while it renewed a sign-in through auth.refresh, after the vendor had spent the old refresh token, so the account was signed out; and while a host restarts the old one and the new one both rewrote all of plugin-auth.json from what each had read, so one host's save could drop the other's accounts or newer token. host.js now tells magpie how many renewals are under way (event "renewing"), and stop gives a host still renewing after the 2 s up to 15 s in all, until the renewal is saved; with none it stops as fast as before. Every change to plugin-auth.json (setAuth, removeAuth, settle, take, auth.set, a renewal's save, and magpie's SignOut when no host runs) is made under plugin-auth.json.lock (O_EXCL, broken when older than 10 s), reading the file afresh and changing only its own accounts; a renewal's save checks under the lock that the stored sign-in is still the one it renewed, so an older host never writes a stale token over a newer one. TestStopLetsARenewalEnd (a 4 s refresh, the host restarted in the middle: old code kept the spent token) and TestTwoHostsKeepEachOthersSignIns (two hosts saving 40 accounts each at once: old code lost 38 of 80) fail on the old code and pass; TestStopWithoutRenewalIsQuick keeps the quick stop. go vet, builds (darwin, GOOS=linux, GOOS=windows) and go test -tags nogui -count=1 ./... under a temp HOME pass but TestProvidersAnswerWhileListsComeIn and TestClaudeSignedOut, timing tests that pass alone and in their packages rerun. Not tried with real plugin accounts.
2026-10-03 15:47:10 +08:00

479 lines
14 KiB
Go

// Package plugin runs OpenCode's provider plugins for magpie.
//
// A subscription a vendor may object to being used outside its own app is
// not built into magpie: a plugin signs in to it and makes its requests,
// the way OpenCode's plugins do (opencode-gemini-auth and the like), and
// magpie runs those same plugins. They are npm packages (or local files)
// written against OpenCode's plugin API (@opencode-ai/plugin, v1): their
// auth hook gives the sign-in methods and a loader whose fetch carries the
// requests, their config and provider hooks the models. magpie runs them
// under Bun, downloaded the first time a plugin is added, in one process
// (host.js) it talks to over stdin and stdout.
//
// What magpie keeps: plugins.json, the plugins and the config handed to
// them (OpenCode's, its provider section: a plugin's options); the npm
// packages under plugins/; plugin-auth.json, the sign-ins, in OpenCode's
// auth.json shape (so one can be moved between the two).
package plugin
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"slices"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/yetone/magpie/internal/netproxy"
"github.com/yetone/magpie/internal/proc"
"github.com/yetone/magpie/internal/settings"
"github.com/yetone/magpie/internal/steady"
)
// Entry is one plugin the user added.
type Entry struct {
// Spec is how it was added: an npm package (name, name@version), a git
// repository (github:owner/repo, git+https://…) or a path to a file
// or folder.
Spec string `json:"spec"`
// Off is set on a plugin the user turned off.
Off bool `json:"off,omitempty"`
// Options are handed to the plugin as its second argument, as
// OpenCode's ["name", {options}] does.
Options map[string]any `json:"options,omitempty"`
}
// List is plugins.json.
type List struct {
Plugins []Entry `json:"plugins"`
// Config is OpenCode's config the plugins are handed: its provider
// section says what a plugin reads of it
// (provider.google.options.projectId).
Config map[string]any `json:"config,omitempty"`
}
var listMu sync.Mutex
// Dir is the folder magpie installs npm plugins into.
func Dir() string { return filepath.Join(settings.Dir(), "plugins") }
// AuthPath is the plugins' sign-ins, OpenCode's auth.json in shape.
func AuthPath() string { return filepath.Join(settings.Dir(), "plugin-auth.json") }
// authLockStale is how long plugin-auth.json.lock is held at most: one
// older was left by a host or a magpie that died holding it.
const authLockStale = 10 * time.Second
// lockAuth takes plugin-auth.json.lock, which host.js takes too, for a
// change to plugin-auth.json read afresh under it: two hosts (one being
// restarted) and magpie never write each other's accounts away. It gives
// the unlock.
func lockAuth() func() {
lock := AuthPath() + ".lock"
start := time.Now()
for {
f, err := os.OpenFile(lock, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
if err == nil {
f.Close()
return func() { os.Remove(lock) }
}
if !os.IsExist(err) && !os.IsPermission(err) {
return func() {} // no folder to lock in: no file to change either
}
if fi, err := os.Stat(lock); err == nil && time.Since(fi.ModTime()) > authLockStale || time.Since(start) > 2*authLockStale {
os.Remove(lock)
}
time.Sleep(5 * time.Millisecond)
}
}
func listPath() string { return filepath.Join(settings.Dir(), "plugins.json") }
// Load reads plugins.json.
func Load() List {
var l List
if b, err := steady.ReadFile(listPath()); err == nil {
_ = json.Unmarshal(b, &l)
}
return l
}
// writeWhole writes b to p by a rename, so a magpie or the host reading
// p meanwhile reads the old file or the new one, never one half-written;
// read back with steady.ReadFile, which waits out the rename on Windows.
func writeWhole(p string, b []byte) error {
f, err := os.CreateTemp(filepath.Dir(p), filepath.Base(p)+".*")
if err != nil {
return err
}
_, err = f.Write(b)
if cerr := f.Close(); err == nil {
err = cerr
}
if err == nil {
err = steady.Rename(f.Name(), p)
}
if err != nil {
os.Remove(f.Name())
}
return err
}
func save(l List) error {
b, err := json.MarshalIndent(l, "", " ")
if err != nil {
return err
}
if err := os.MkdirAll(settings.Dir(), 0o700); err != nil {
return err
}
if err := writeWhole(listPath(), append(b, '\n')); err != nil {
return err
}
listSeen.Lock()
listSeen.stamp, listSeen.set = listStamp(), true
listSeen.Unlock()
return nil
}
// listSeen is plugins.json as this magpie last wrote or read it.
var listSeen struct {
sync.Mutex
stamp string
set bool
}
func listStamp() string {
fi, err := os.Stat(listPath())
if err != nil {
return ""
}
return fmt.Sprint(fi.ModTime().UnixNano(), fi.Size())
}
// hostStale is set when another magpie changed the plugins (magpie plugin
// add, remove or move in a terminal while the app runs): the host running
// has the old ones loaded.
var hostStale atomic.Bool
// checkList notices plugins.json changed by another magpie: the host is
// started again with the plugins as they are, and the providers the other
// magpie last saw asked for are the ones known meanwhile.
func checkList() {
st := listStamp()
listSeen.Lock()
moved := listSeen.set && listSeen.stamp != st
listSeen.stamp, listSeen.set = st, true
listSeen.Unlock()
if !moved {
return
}
provMu.Lock()
provCache = nil
provMu.Unlock()
hostStale.Store(true)
changed()
}
// IsPath is whether spec names a file or folder rather than a package,
// as OpenCode tells them apart.
func IsPath(spec string) bool {
return strings.HasPrefix(spec, "file://") || strings.HasPrefix(spec, ".") || filepath.IsAbs(spec) || regexp.MustCompile(`^[A-Za-z]:[\\/]`).MatchString(spec)
}
var pkgName = regexp.MustCompile(`^(@[a-z0-9][a-z0-9._~-]*/)?[a-z0-9][a-z0-9._~-]*$`)
// gitSpec is a package bun fetches from a git repository, not npm:
// github:owner/repo[#ref] (gitlab: and bitbucket: alike), a git+https://,
// git+ssh://, git+file:// or git:// URL, a GitHub, GitLab or Bitbucket
// page's URL, or owner/repo, GitHub's shorthand.
var gitSpec = regexp.MustCompile(`^(?:(?:github|gitlab|bitbucket):|git\+[a-z]+://|git://)\S+$|^https?://(?:www\.)?(?:github\.com|gitlab\.com|bitbucket\.org)/\S+$|^[A-Za-z0-9][A-Za-z0-9_.-]*/[A-Za-z0-9_.-]+(?:#\S+)?$`)
// IsGit is whether spec names a git repository rather than an npm package
// or a path. The package bun installs from it is named by its own
// package.json, not by spec.
func IsGit(spec string) bool { return !IsPath(spec) && gitSpec.MatchString(spec) }
// gitName is the package bun installed from the git spec: the dependency
// of plugins/package.json bun added it as (it keeps spec as given), ""
// when there is none.
func gitName(spec string) string {
var pj struct {
Dependencies map[string]string `json:"dependencies"`
}
b, err := os.ReadFile(filepath.Join(Dir(), "package.json"))
if err != nil || json.Unmarshal(b, &pj) != nil {
return ""
}
for name, s := range pj.Dependencies {
if s == spec {
return name
}
}
return ""
}
// Name is the package spec names, without its version: "@scope/x" of
// "@scope/x@1.2", the package installed from a git repository (the spec
// itself until it is), or the path.
func Name(spec string) string {
if IsPath(spec) {
return spec
}
if IsGit(spec) {
if n := gitName(spec); n != "" {
return n
}
return spec
}
at := strings.LastIndex(spec, "@")
if at > 0 {
return spec[:at]
}
return spec
}
// Target is where the plugin is to load from: the installed package's
// folder, or the path.
func Target(spec string) string {
if IsPath(spec) {
p := strings.TrimPrefix(spec, "file://")
if abs, err := filepath.Abs(p); err == nil {
return abs
}
return p
}
return filepath.Join(Dir(), "node_modules", filepath.FromSlash(Name(spec)))
}
// 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) {
spec = strings.TrimSpace(spec)
if spec == "" {
return Entry{}, errors.New("no plugin given")
}
// the package each plugin is before the install, which may take a git
// one's name over (one repository in place of another, or of npm's)
was := map[string]string{}
for _, x := range Load().Plugins {
was[x.Spec] = Name(x.Spec)
}
if IsPath(spec) {
t := Target(spec)
if _, err := os.Stat(t); err != nil {
return Entry{}, err
}
spec = t
} else if IsGit(spec) {
if err := install(ctx, spec); err != nil {
return Entry{}, err
}
if Name(spec) == spec {
return Entry{}, fmt.Errorf("bun installed %s but plugins/package.json doesn't list it", spec)
}
} else {
if !pkgName.MatchString(Name(spec)) {
return Entry{}, fmt.Errorf("%q isn't an npm package name", spec)
}
if Name(spec) == spec {
spec += "@latest"
}
if err := install(ctx, spec); err != nil {
return Entry{}, err
}
}
listMu.Lock()
defer listMu.Unlock()
l := Load()
e := Entry{Spec: spec}
name := Name(spec)
if i := slices.IndexFunc(l.Plugins, func(x Entry) bool {
n, ok := was[x.Spec]
if !ok {
n = Name(x.Spec)
}
return x.Spec == spec || n == name
}); i >= 0 {
e.Options = l.Plugins[i].Options
l.Plugins[i] = e
} else {
l.Plugins = append(l.Plugins, e)
}
if err := save(l); err != nil {
return Entry{}, err
}
Restart()
return e, nil
}
// Update installs the version of each npm plugin its spec says now
// (latest, for the most part), and fetches each git one again.
func Update(ctx context.Context) error {
var errs []error
for _, e := range Load().Plugins {
if IsPath(e.Spec) {
continue
}
if err := reinstall(ctx, e.Spec); err != nil {
errs = append(errs, fmt.Errorf("%s: %w", e.Spec, err))
}
}
Restart()
return errors.Join(errs...)
}
// Remove takes a plugin off the list (and out of plugins/).
func Remove(ctx context.Context, name string) error {
listMu.Lock()
l := Load()
i := slices.IndexFunc(l.Plugins, func(x Entry) bool { return Name(x.Spec) == name || x.Spec == name })
if i < 0 {
listMu.Unlock()
return fmt.Errorf("no plugin %q", name)
}
e := l.Plugins[i]
l.Plugins = slices.Delete(l.Plugins, i, i+1)
err := save(l)
listMu.Unlock()
if err != nil {
return err
}
if !IsPath(e.Spec) {
if bun, err := Bun(ctx); err == nil {
_ = bunCommand(ctx, bun, Dir(), "remove", "--ignore-scripts", Name(e.Spec)).Run()
}
}
Restart()
return nil
}
// SetOff turns a plugin off or back on.
func SetOff(name string, off bool) error {
listMu.Lock()
defer listMu.Unlock()
l := Load()
i := slices.IndexFunc(l.Plugins, func(x Entry) bool { return Name(x.Spec) == name || x.Spec == name })
if i < 0 {
return fmt.Errorf("no plugin %q", name)
}
l.Plugins[i].Off = off
if err := save(l); err != nil {
return err
}
Restart()
return nil
}
// SetConfig sets the OpenCode config the plugins are handed.
func SetConfig(cfg map[string]any) error {
listMu.Lock()
defer listMu.Unlock()
l := Load()
l.Config = cfg
if err := save(l); err != nil {
return err
}
Restart()
return nil
}
// install puts an npm package into plugins/ with bun add, its scripts
// left unrun as OpenCode leaves them.
func install(ctx context.Context, spec string) error {
bun, err := Bun(ctx)
if err != nil {
return err
}
if err := os.MkdirAll(Dir(), 0o755); err != nil {
return err
}
pj := filepath.Join(Dir(), "package.json")
if _, err := os.Stat(pj); err != nil {
if err := os.WriteFile(pj, []byte("{\n \"name\": \"magpie-plugins\",\n \"private\": true\n}\n"), 0o644); err != nil {
return err
}
}
out, err := bunCommand(ctx, bun, Dir(), "add", "--ignore-scripts", spec).CombinedOutput()
if err != nil {
return fmt.Errorf("bun add %s: %v: %s", spec, err, lastLines(string(out), 6))
}
return nil
}
// reinstall installs spec again: an npm one with bun add, a git one with
// bun update, which fetches its branch's (or tag's) commit now where bun
// add keeps the one bun.lock has.
func reinstall(ctx context.Context, spec string) error {
name := Name(spec)
if !IsGit(spec) || name == spec {
return install(ctx, spec)
}
bun, err := Bun(ctx)
if err != nil {
return err
}
out, err := bunCommand(ctx, bun, Dir(), "update", "--ignore-scripts", name).CombinedOutput()
if err != nil {
return fmt.Errorf("bun update %s: %v: %s", name, err, lastLines(string(out), 6))
}
return nil
}
func lastLines(s string, n int) string {
ls := strings.Split(strings.TrimSpace(s), "\n")
if len(ls) > n {
ls = ls[len(ls)-n:]
}
return strings.Join(ls, "\n")
}
func command(ctx context.Context, name string, args ...string) *exec.Cmd {
return proc.CommandContext(ctx, name, args...)
}
// env is the plugins' environment: magpie's, with its proxy.
func env() []string { return netproxy.Env(os.Environ()) }
// Version is the version of the package spec installed (or checked out
// at its path), "" when there is none.
func Version(spec string) string {
dir := Target(spec)
if fi, err := os.Stat(dir); err == nil && !fi.IsDir() {
dir = filepath.Dir(dir)
}
var pj struct {
Version string `json:"version"`
}
if b, err := os.ReadFile(filepath.Join(dir, "package.json")); err == nil && json.Unmarshal(b, &pj) == nil {
return pj.Version
}
return ""
}
// PackageName is the npm package spec is: its name, or, for a plugin added
// from a folder or file (a checkout of it), the name its package.json gives.
func PackageName(spec string) string {
if !IsPath(spec) {
return Name(spec)
}
dir := Target(spec)
if fi, err := os.Stat(dir); err == nil && !fi.IsDir() {
dir = filepath.Dir(dir)
}
var pj struct {
Name string `json:"name"`
}
if b, err := os.ReadFile(filepath.Join(dir, "package.json")); err == nil && json.Unmarshal(b, &pj) == nil && pj.Name != "" {
return pj.Name
}
return spec
}