Files
magpie/internal/plugin/store.go
T
yetone 456208945b plugins: a plugin can be installed from a git repository — github:owner/repo, owner/repo, a GitHub URL or a git+https:// one (Lemon on Discord: can plugins be installed directly from git?) — magpie plugin add and the Plugins field took only an npm name or a path, and turned a git spec away as "isn't an npm package name". A git spec now goes to bun add as given and is kept as given in plugins.json; the package it installs is named by its repository's package.json, which bun records as the dependency whose spec it is, so the plugin is known (list, on/off, rm, its version, its README) by that name or by the spec. update and the row's Update run bun update on it, which fetches the repository's commit now where bun add keeps the one bun.lock has, the spec unchanged; npm is never asked for a newer version of it, nor auto-updates it. Adding the same package from another URL, or from npm, takes its entry's place. The Plugins field says it takes a GitHub repo (en+zh); an installed git plugin shows its package's name, an Update button, and a page with its README, no npm link and a Source link to the repository. Nothing that installed before is turned away. TestGitPlugin (a fake bun fetching from local bare repositories, the real one loading the plugin when on PATH) and plugin-git.test.cjs fail without the change; TestIsGit checks what counts as a git spec. go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./... pass; plugin-git, plugin-local, plugin-market, plugin-updates, click-scroll, old-webkit pass.
2026-10-02 02:24:49 +08:00

451 lines
13 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"
"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") }
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
}