Files
magpie/internal/plugin/store.go
T

626 lines
18 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 (
"bytes"
"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") }
// pluginsList is plugins.json as last read, with the bytes it was parsed
// from. The file is read every time: an edit that keeps the file's size and
// time (a swap of two accounts of one length, a replaced file whose time is
// put back) would be missed by a stamp, and only the parse is reused.
var pluginsList struct {
sync.Mutex
path string
raw []byte
list List
}
// list is plugins.json, read now. What it gives is shared and must not be
// changed; Load gives the mutable copy.
func list() List {
path := listPath()
b, err := steady.ReadFile(path)
if err != nil {
// Do not cache read failures; a later call will read the file again.
return List{}
}
pluginsList.Lock()
defer pluginsList.Unlock()
if pluginsList.path == path && bytes.Equal(pluginsList.raw, b) {
return pluginsList.list
}
var l List
// Preserve partially decoded values on type errors, as Load has always done.
_ = json.Unmarshal(b, &l)
pluginsList.path, pluginsList.raw, pluginsList.list = path, b, l
return l
}
// Load reads plugins.json. What it gives is the caller's to change at every
// level: the mutators (Add, Remove, SetOff, SetConfig) each get their own
// copy, so nothing they change reaches what list keeps.
func Load() List {
l := list()
return List{Plugins: clonePlugins(l.Plugins), Config: cloneMap(l.Config)}
}
// clonePlugins copies the entries, their options with them.
func clonePlugins(es []Entry) []Entry {
if es == nil {
return nil
}
out := make([]Entry, len(es))
for i, e := range es {
out[i] = e
out[i].Options = cloneMap(e.Options)
}
return out
}
// cloneMap copies a JSON object and everything under it.
func cloneMap(m map[string]any) map[string]any {
if m == nil {
return nil
}
out := make(map[string]any, len(m))
for k, v := range m {
out[k] = cloneJSON(v)
}
return out
}
// cloneJSON copies a JSON value: Unmarshal into any gives only objects,
// arrays, strings, numbers, bools and nil, so this reaches every part of one.
func cloneJSON(v any) any {
switch x := v.(type) {
case map[string]any:
return cloneMap(x)
case []any:
out := make([]any, len(x))
for i, e := range x {
out[i] = cloneJSON(e)
}
return out
default:
return v
}
}
// 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
}
}
if err := ensurePi(ctx, Target(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) {
if err := reinstall(ctx, e.Spec); err != nil {
errs = append(errs, fmt.Errorf("%s: %w", e.Spec, err))
continue
}
}
if err := ensurePi(ctx, Target(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
}
// 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"
// IsPi is whether the plugin at target is pi's (a pi package or
// extension) rather than OpenCode's, as host.js's pi.js tells them.
func IsPi(target string) bool {
st, err := os.Stat(target)
if err != nil {
return false
}
if !st.IsDir() {
b, err := os.ReadFile(target)
if err != nil {
return false
}
s := string(b)
for _, n := range []string{piAgent, "@mariozechner/pi-coding-agent"} {
if strings.Contains(s, `"`+n+`"`) || strings.Contains(s, `'`+n+`'`) {
return true
}
}
return false
}
b, err := os.ReadFile(filepath.Join(target, "package.json"))
if err != nil {
return false
}
var pkg struct {
Pi any `json:"pi"`
Keywords []string `json:"keywords"`
Deps map[string]string `json:"dependencies"`
Peers map[string]string `json:"peerDependencies"`
}
if json.Unmarshal(b, &pkg) != nil {
return false
}
if _, ok := pkg.Pi.(map[string]any); ok || slices.Contains(pkg.Keywords, "pi-package") {
return true
}
for _, d := range []map[string]string{pkg.Deps, pkg.Peers} {
if _, ok := d[piAgent]; ok {
return true
}
if _, ok := d["@mariozechner/pi-coding-agent"]; ok {
return true
}
}
return false
}
// ensurePi installs pi for a pi plugin that came without it (one that
// doesn't name it among what it depends on, or one on disk), as pi itself
// is what loads it.
func ensurePi(ctx context.Context, target string) error {
if !IsPi(target) {
return nil
}
if _, err := os.Stat(filepath.Join(Dir(), "node_modules", piAgent, "package.json")); err == nil {
return nil
}
if _, err := os.Stat(filepath.Join(target, "node_modules", piAgent, "package.json")); err == nil {
return nil
}
return install(ctx, piAgent)
}
// 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
}