neiko on Discord installed a third-party plugin beside a plugin of their
own, added by its folder. Only the third-party one could be seen. Both
plugins' auth hooks name the same provider id. The host's auths() keeps
the last plugin in plugins.json for an id, as OpenCode does, so the
user's own plugin silently lost it:
- its row read "Signs in to nothing magpie can use";
- its provider was gone from the Add sheet;
- keepUnloaded could list the id a second time, from the
plugin-providers.json kept before.
A package-name clash and a display-name clash were tried as well. Both
plugins stay visible in those cases, because a folder plugin's name is
its path.
What changed:
- host.js:
- init gives each loaded plugin the ids it signs in to (provides) and,
for each id another plugin serves, that plugin's spec (servedBy).
- auths() honours plugins.json's new prefer map (id -> spec).
- Only the serving plugin's provider.models hook runs for the id.
- plugin:
- Clashes() turns provides and servedBy into {id, by, with} per spec.
- Prefer() records a pick and restarts the host.
- Remove drops the removed plugin's picks.
- Re-adding a plugin under a new spec carries its picks over.
- remove, off, options and prefer find a plugin by its exact spec
before its package name (indexOf), so acting on one plugin never
reaches another of the same package.
- keepUnloaded no longer re-adds a provider from before when another
plugin serves its id now.
- GUI:
- /api/plugins rows carry clashes, with the provider's listed name.
- POST /api/plugins/prefer {spec, provider}.
- The serving row says "{other} signs in to {name} too; this one
serves it".
- The other row says, in amber, "{name} is served by {other}, which
signs in to it too", with a "Use for {name}" button.
- New strings in zh, zh-TW, ja and de.
- CLI: `magpie plugin` prints the same lines, with the command that
fixes it, and `magpie plugin use <name> <provider>` makes the pick.
- Docs: docs/subsystems/provider-plugins.md, docs/reference.md and
site/public/docs/{,zh/,ja/}plugins.html.
What was run:
- Reproduced under a sandbox HOME with magpie web on 3562: a fake npm
plugin and a folder plugin, both on id "acme".
- Old binary: the local row had providers [] and /api/providers
listed acme twice.
- New binary: both rows carry the clash; prefer moves acme to the
folder plugin; remove clears the pick and leaves the other plugin
serving acme.
- New Go tests:
- TestTwoPluginsOneProvider, a real Bun host: Prefer, restart,
off/on and Remove.
- TestClashes, TestPluginNamedExactlyFirst and
TestKeepUnloadedLeavesAServedID.
- TestPluginsClashRows (gui) and TestPluginListSaysClash (CLI).
- Each fails when its piece is broken by hand: prefer ignored in
host.js, the keepUnloaded id check removed, name before exact spec,
Remove keeping picks, no provides, no clashes in the rows, no
prefer route.
- plugin-clash.test.cjs, in Chromium and WebKit, in en, zh, zh-TW, ja
and de, at 1100 and 560px: 22/22 pass. It times out on the old
plugins.js.
- gui-de, gui-ja, gui-zh-tw, i18n-keys and every plugin-*.test.cjs:
248 tests.
- 245 passed in the 4-wide run.
- plugin-moved-signin (webkit) failed once in that run, then passed
3/3 alone and 26/26 in a smaller 4-wide rerun.
- gofmt -l; go vet ./...; GOOS=linux and GOOS=windows go vet -tags
nogui ./...; go build ./...
- The full go test -tags nogui ./... under a temp HOME with the caches
pinned: 44 packages ok, none failed.
- After rebasing onto 05917100 (plugins.css conflict with a3a8e257's
chip rules, both kept): plugin, gui and the root plugin tests, vet on
darwin, linux and windows, and the 248 GUI tests above, all green
(248/248 this time).
Not tried: a real third-party plugin, neiko's own plugins, and Windows
beyond vet.
866 lines
26 KiB
Go
866 lines
26 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"
|
|
"maps"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"regexp"
|
|
"slices"
|
|
"strconv"
|
|
"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"
|
|
"github.com/yetone/magpie/internal/update"
|
|
)
|
|
|
|
// 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"`
|
|
// Prefer is the plugin the user picked for a provider id more than
|
|
// one plugin signs in to (a plugin of their own beside a third
|
|
// party's): provider id → that plugin's spec. Without it the last of
|
|
// them in Plugins serves the id, as in OpenCode.
|
|
Prefer map[string]string `json:"prefer,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), Prefer: maps.Clone(l.Prefer)}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// listStamp is plugins.json as it is now, and what is installed: bun's
|
|
// package.json and lockfile in Dir(), which `magpie plugin update` changes
|
|
// without touching plugins.json (#952: a terminal's update to 0.1.18 left
|
|
// the app's host answering with 0.1.17 until it was killed).
|
|
func listStamp() string {
|
|
var b strings.Builder
|
|
for _, p := range []string{listPath(), filepath.Join(Dir(), "package.json"), filepath.Join(Dir(), "bun.lock"), filepath.Join(Dir(), "bun.lockb")} {
|
|
if fi, err := os.Stat(p); err == nil {
|
|
fmt.Fprint(&b, fi.ModTime().UnixNano(), " ", fi.Size())
|
|
}
|
|
b.WriteString(";")
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// hostStale is set when another magpie changed the plugins (magpie plugin
|
|
// add, remove, update 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) { return add(ctx, spec, "") }
|
|
|
|
// add is Add, an npm package installed at the version given when there is
|
|
// one (installAt), its spec kept as given.
|
|
func add(ctx context.Context, spec, version 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 version != "" {
|
|
if err := installAt(ctx, Name(spec), version); err != nil {
|
|
return Entry{}, err
|
|
}
|
|
} else if err := install(ctx, spec); err != nil {
|
|
return Entry{}, err
|
|
}
|
|
}
|
|
if err := notPlugin(Target(spec)); err != nil {
|
|
// installed just now for this, and nothing loads it: taken out again
|
|
if !IsPath(spec) && !slices.Contains(slices.Collect(maps.Values(was)), Name(spec)) {
|
|
if bun, berr := Bun(ctx); berr == nil {
|
|
_ = bunCommand(ctx, bun, Dir(), "remove", "--ignore-scripts", Name(spec)).Run()
|
|
}
|
|
}
|
|
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
|
|
// a provider id the user picked it for stays its (another version,
|
|
// or its package from a git repository in place of npm's)
|
|
for id, s := range l.Prefer {
|
|
if s == l.Plugins[i].Spec {
|
|
l.Prefer[id] = spec
|
|
}
|
|
}
|
|
l.Plugins[i] = e
|
|
} else {
|
|
l.Plugins = append(l.Plugins, e)
|
|
}
|
|
if err := save(l); err != nil {
|
|
return Entry{}, err
|
|
}
|
|
Restart()
|
|
return e, nil
|
|
}
|
|
|
|
// indexFiles are the files a package that names none loads, as the host
|
|
// looks for them (host.js's INDEX_FILES).
|
|
var indexFiles = []string{"index.ts", "index.tsx", "index.js", "index.mjs", "index.cjs"}
|
|
|
|
// notPlugin says why the plugin at target is nothing the host can load,
|
|
// or nil: a package that names no file to import (main, exports), has no
|
|
// index file, and is neither pi's nor a middleware — a command alone
|
|
// (bin), an MCP server like magpie-x-search, is installed fine and then
|
|
// never loads (#1327).
|
|
func notPlugin(target string) error {
|
|
st, err := os.Stat(target)
|
|
if err != nil || !st.IsDir() || IsPi(target) {
|
|
return nil
|
|
}
|
|
if f, _ := Middleware(target); f != "" {
|
|
return nil
|
|
}
|
|
var pkg struct {
|
|
Name string `json:"name"`
|
|
Main string `json:"main"`
|
|
Exports json.RawMessage `json:"exports"`
|
|
Bin json.RawMessage `json:"bin"`
|
|
}
|
|
b, err := os.ReadFile(filepath.Join(target, "package.json"))
|
|
if err != nil || json.Unmarshal(b, &pkg) != nil {
|
|
return nil
|
|
}
|
|
if strings.TrimSpace(pkg.Main) != "" || len(pkg.Exports) > 0 && string(pkg.Exports) != "null" {
|
|
return nil
|
|
}
|
|
for _, f := range indexFiles {
|
|
if _, err := os.Stat(filepath.Join(target, f)); err == nil {
|
|
return nil
|
|
}
|
|
}
|
|
name := pkg.Name
|
|
if name == "" {
|
|
name = filepath.Base(target)
|
|
}
|
|
why := fmt.Sprintf("%s isn't an OpenCode or magpie plugin: its package names no file to load (no main or exports in package.json, no index file)", name)
|
|
if len(pkg.Bin) > 0 && string(pkg.Bin) != "null" {
|
|
why += ", only a command (bin). If it is an MCP server, add it under Library → MCP servers instead"
|
|
}
|
|
return errors.New(why)
|
|
}
|
|
|
|
// Update installs the version of each npm plugin its spec says now
|
|
// (latest, for the most part: npm's newest, asked now, installAt), and
|
|
// fetches each git one again.
|
|
func Update(ctx context.Context) error {
|
|
var errs []error
|
|
for _, e := range Load().Plugins {
|
|
if !IsPath(e.Spec) {
|
|
v := ""
|
|
if !IsGit(e.Spec) && !Pinned(e.Spec) {
|
|
v = newestOf(ctx, Name(e.Spec))
|
|
}
|
|
var err error
|
|
if v != "" {
|
|
err = installAt(ctx, Name(e.Spec), v)
|
|
} else {
|
|
// pinned, a git one, or npm not answering: as its spec says
|
|
err = reinstall(ctx, e.Spec)
|
|
}
|
|
if 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...)
|
|
}
|
|
|
|
// indexOf is the plugin name means in l: the one added as name exactly,
|
|
// else the one of that package. A plugin of the user's own (a folder)
|
|
// beside a third party's is each told apart by its own spec, so taking
|
|
// one away or switching it off never reaches the other.
|
|
func indexOf(l List, name string) int {
|
|
if i := slices.IndexFunc(l.Plugins, func(x Entry) bool { return x.Spec == name }); i >= 0 {
|
|
return i
|
|
}
|
|
return slices.IndexFunc(l.Plugins, func(x Entry) bool { return Name(x.Spec) == name })
|
|
}
|
|
|
|
// Remove takes a plugin off the list (and out of plugins/).
|
|
func Remove(ctx context.Context, name string) error {
|
|
listMu.Lock()
|
|
l := Load()
|
|
i := indexOf(l, 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)
|
|
maps.DeleteFunc(l.Prefer, func(_, s string) bool { return s == e.Spec })
|
|
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 := indexOf(l, 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
|
|
}
|
|
|
|
// Prefer has the plugin named serve provider id, which another plugin
|
|
// signs in to as well (Clash): the host runs it for id from now on, the
|
|
// other one keeping its other providers. A plugin removed takes its picks
|
|
// with it; one switched off leaves the id to the others until it is on
|
|
// again.
|
|
func Prefer(name, id string) error {
|
|
listMu.Lock()
|
|
defer listMu.Unlock()
|
|
l := Load()
|
|
i := indexOf(l, name)
|
|
if i < 0 {
|
|
return fmt.Errorf("no plugin %q", name)
|
|
}
|
|
if id == "" {
|
|
return errors.New("no provider given")
|
|
}
|
|
if l.Prefer == nil {
|
|
l.Prefer = map[string]string{}
|
|
}
|
|
l.Prefer[id] = l.Plugins[i].Spec
|
|
if err := save(l); err != nil {
|
|
return err
|
|
}
|
|
Restart()
|
|
return nil
|
|
}
|
|
|
|
// SetOptions sets the options a plugin is handed (a middleware's
|
|
// ctx.options); nil takes them away.
|
|
func SetOptions(name string, opts map[string]any) error {
|
|
listMu.Lock()
|
|
defer listMu.Unlock()
|
|
l := Load()
|
|
i := indexOf(l, name)
|
|
if i < 0 {
|
|
// a short name, as the community's READMEs write it: param-override
|
|
// for @magpie-community/middleware-param-override
|
|
i = slices.IndexFunc(l.Plugins, func(x Entry) bool { return ShortName(Name(x.Spec)) == name })
|
|
}
|
|
if i < 0 {
|
|
return fmt.Errorf("no plugin %q", name)
|
|
}
|
|
if len(opts) == 0 {
|
|
opts = nil
|
|
}
|
|
l.Plugins[i].Options = opts
|
|
if err := save(l); err != nil {
|
|
return err
|
|
}
|
|
Restart()
|
|
return nil
|
|
}
|
|
|
|
// ShortName is a package's name without its scope and the words every
|
|
// package of its kind has: model-map for @magpie-community/middleware-model-map,
|
|
// zed for @magpie-community/opencode-zed-auth.
|
|
func ShortName(pkg string) string {
|
|
if i := strings.LastIndex(pkg, "/"); i >= 0 {
|
|
pkg = pkg[i+1:]
|
|
}
|
|
pkg = strings.TrimPrefix(strings.TrimPrefix(pkg, "middleware-"), "opencode-")
|
|
return strings.TrimSuffix(pkg, "-auth")
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// installAt installs the version of the npm package given, not a tag, and
|
|
// checks it is the one installed. Asked for pkg@latest, bun resolves
|
|
// "latest" by its own config: a minimumReleaseAge in the user's bunfig
|
|
// holds back every version younger than it, and a registry of the user's
|
|
// own (an .npmrc) may not have the newest yet. Either way bun installs an
|
|
// older version and exits 0, so an update said it was done and nothing
|
|
// changed (sweanng424 on Discord: Factory stayed at 0.1.18 with 0.1.21 out,
|
|
// through restarts and the hourly updates). Asked for the version, bun
|
|
// says why it can't install it.
|
|
func installAt(ctx context.Context, pkg, version string) error {
|
|
if err := install(ctx, pkg+"@"+version); err != nil {
|
|
// what bun said comes first: the page's status line shows the
|
|
// start of a long message
|
|
if m := minAge.FindStringSubmatch(err.Error()); m != nil {
|
|
return fmt.Errorf("%s %s isn't installed yet: Bun's minimumReleaseAge (in a .bunfig.toml) holds back versions published less than %s ago, and magpie installs it once it is older (%w)", pkg, version, ageText(m[1]), err)
|
|
}
|
|
var said []string
|
|
for _, l := range strings.Split(err.Error(), "\n") {
|
|
if l = strings.TrimSpace(l); strings.HasPrefix(l, "error:") {
|
|
said = append(said, strings.TrimSpace(strings.TrimPrefix(l, "error:")))
|
|
}
|
|
}
|
|
if len(said) > 0 {
|
|
return fmt.Errorf("%s %s isn't installed: %s (%w)", pkg, version, strings.Join(said, "; "), err)
|
|
}
|
|
return err
|
|
}
|
|
if got := Installed(pkg); got == "" || update.Newer(version, got) {
|
|
if got == "" {
|
|
got = "nothing"
|
|
}
|
|
return fmt.Errorf("bun add %s@%s installed %s", pkg, version, got)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// minAge is bun's word for a version its minimumReleaseAge holds back.
|
|
var minAge = regexp.MustCompile(`minimum-release-age: (\d+) seconds`)
|
|
|
|
// ageText is a number of seconds as a reader says it: 2 days, 12 hours.
|
|
func ageText(secs string) string {
|
|
n, err := strconv.Atoi(secs)
|
|
switch {
|
|
case err != nil:
|
|
return secs + " seconds"
|
|
case n >= 86400 && n%86400 == 0:
|
|
return plural(n/86400, "day")
|
|
case n >= 3600 && n%3600 == 0:
|
|
return plural(n/3600, "hour")
|
|
case n >= 60 && n%60 == 0:
|
|
return plural(n/60, "minute")
|
|
}
|
|
return plural(n, "second")
|
|
}
|
|
|
|
func plural(n int, unit string) string {
|
|
if n == 1 {
|
|
return "1 " + unit
|
|
}
|
|
return strconv.Itoa(n) + " " + unit + "s"
|
|
}
|
|
|
|
// newestOf is npm's newest version of the package, asked now, "" when npm
|
|
// didn't say; what it says is kept, as Info keeps it (tests stub it).
|
|
var newestOf = func(ctx context.Context, name string) string {
|
|
c, cancel := context.WithTimeout(ctx, checkEach)
|
|
info, err := npmAsk(c, name)
|
|
cancel()
|
|
if err == nil || errors.Is(err, errNotFound) {
|
|
npmMu.Lock()
|
|
npmCached()[name] = npmEntry{info, time.Now()}
|
|
saveNPM()
|
|
npmMu.Unlock()
|
|
}
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return info.Version
|
|
}
|
|
|
|
// 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
|
|
}
|