261 lines
8.4 KiB
Go
261 lines
8.4 KiB
Go
// This file distributes a classified pi extension (including MCP adapters) into
|
|
// pigo's plugins directory so internal/plugin.Discover picks it up (#158).
|
|
//
|
|
// plugin.Discover launches every *executable regular file* directly inside
|
|
// $PIGO_HOME/plugins, ignoring subdirectories. An npm extension, however, is a
|
|
// whole package tree with a "bin" entry pointing at its real entrypoint, and
|
|
// that entrypoint usually needs its sibling files present to run. So we cannot
|
|
// just drop a single file in.
|
|
//
|
|
// The layout we lay down reconciles the two:
|
|
//
|
|
// $PIGO_HOME/plugins/<name>.pkg/ ← full extracted package (a dir; Discover skips it)
|
|
// $PIGO_HOME/plugins/<name> ← executable launcher (a file; Discover runs it)
|
|
//
|
|
// The launcher is a tiny shell script that execs the package's bin entrypoint,
|
|
// forwarding argv. The bin file is made executable and relies on its own
|
|
// shebang (matching how npm itself installs bins), so both Node scripts and
|
|
// native binaries work. Every file laid down is returned so the lockfile can
|
|
// remove exactly what was created on uninstall.
|
|
package pkgmgr
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
|
|
"github.com/smallnest/pigo/internal/pihost"
|
|
)
|
|
|
|
// DistributeExtension copies the extension package at pkgDir into the plugins
|
|
// directory and writes a launcher that plugin.Discover will run. It returns the
|
|
// absolute paths of every file (and the payload dir) it created, for the
|
|
// lockfile. An empty plugins dir (home unavailable) is an error.
|
|
func DistributeExtension(pkgDir, name string) ([]string, error) {
|
|
if runtime.GOOS == "windows" {
|
|
return nil, fmt.Errorf("pkgmgr: extension install is not supported on windows yet")
|
|
}
|
|
pluginsDir := PluginsDir()
|
|
if pluginsDir == "" {
|
|
return nil, fmt.Errorf("pkgmgr: cannot resolve plugins dir (PIGO_HOME/home unavailable)")
|
|
}
|
|
|
|
binRel, err := extensionBin(pkgDir, name)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if err := os.MkdirAll(pluginsDir, 0o755); err != nil {
|
|
return nil, fmt.Errorf("pkgmgr: create plugins dir: %w", err)
|
|
}
|
|
|
|
payloadDir := filepath.Join(pluginsDir, name+".pkg")
|
|
// A stale payload from a prior install must not shadow the new one.
|
|
if err := os.RemoveAll(payloadDir); err != nil {
|
|
return nil, fmt.Errorf("pkgmgr: clear old payload %q: %w", payloadDir, err)
|
|
}
|
|
created, err := copyTree(pkgDir, payloadDir)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Make the bin entrypoint executable; it carries its own shebang.
|
|
binAbs := filepath.Join(payloadDir, filepath.FromSlash(binRel))
|
|
if err := os.Chmod(binAbs, 0o755); err != nil {
|
|
return nil, fmt.Errorf("pkgmgr: chmod bin %q: %w", binAbs, err)
|
|
}
|
|
|
|
launcher := filepath.Join(pluginsDir, name)
|
|
|
|
// A pi extension is a JS module loaded by pi's runtime, not a native binary
|
|
// or JSON-RPC server, so it cannot be exec'd directly. Instead we drop the
|
|
// embedded Node host next to the payload and point the launcher at it. A
|
|
// native/JSON-RPC plugin keeps the historical direct-exec launcher.
|
|
if isPiExtension(pkgDir, binRel) {
|
|
hostAbs := filepath.Join(payloadDir, ".pihost.mjs")
|
|
if err := os.WriteFile(hostAbs, pihost.Script, 0o644); err != nil {
|
|
return nil, fmt.Errorf("pkgmgr: write pi host %q: %w", hostAbs, err)
|
|
}
|
|
script := fmt.Sprintf(
|
|
"#!/bin/sh\n"+
|
|
"command -v node >/dev/null 2>&1 || { echo \"pigo: node not found on PATH; pi extension %s skipped\" >&2; exit 127; }\n"+
|
|
"exec node %s %s \"$@\"\n",
|
|
name, shellQuote(hostAbs), shellQuote(payloadDir),
|
|
)
|
|
if err := os.WriteFile(launcher, []byte(script), 0o755); err != nil {
|
|
return nil, fmt.Errorf("pkgmgr: write launcher %q: %w", launcher, err)
|
|
}
|
|
created = append(created, payloadDir, hostAbs, launcher)
|
|
return created, nil
|
|
}
|
|
|
|
script := fmt.Sprintf("#!/bin/sh\nexec %s \"$@\"\n", shellQuote(binAbs))
|
|
if err := os.WriteFile(launcher, []byte(script), 0o755); err != nil {
|
|
return nil, fmt.Errorf("pkgmgr: write launcher %q: %w", launcher, err)
|
|
}
|
|
|
|
created = append(created, payloadDir, launcher)
|
|
return created, nil
|
|
}
|
|
|
|
// isPiExtension reports whether the entrypoint should be run under pigo's Node
|
|
// host rather than exec'd directly. The pi.extensions declaration is
|
|
// authoritative and checked first; otherwise the resolved bin extension
|
|
// (.js/.mjs/.cjs) identifies a JS module that needs the host. Native binaries
|
|
// and self-hosted JSON-RPC servers match neither and keep the direct launcher.
|
|
func isPiExtension(pkgDir, binRel string) bool {
|
|
if data, err := os.ReadFile(filepath.Join(pkgDir, "package.json")); err == nil {
|
|
var pj struct {
|
|
Pi struct {
|
|
Extensions []string `json:"extensions"`
|
|
} `json:"pi"`
|
|
}
|
|
if json.Unmarshal(data, &pj) == nil {
|
|
for _, p := range pj.Pi.Extensions {
|
|
if p != "" {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
switch strings.ToLower(filepath.Ext(binRel)) {
|
|
case ".js", ".mjs", ".cjs":
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// extensionBin resolves the package's entrypoint (relative to the package root)
|
|
// from package.json. It prefers npm's "bin" field (a string, or a {command:
|
|
// path} object keyed by the package name), matching how npm installs bins. When
|
|
// there is no "bin" — the common case for pi extensions, which declare their
|
|
// entrypoint in the pi metadata rather than as an npm bin — it falls back to the
|
|
// first path listed in "pi.extensions", then to "main".
|
|
func extensionBin(pkgDir, name string) (string, error) {
|
|
data, err := os.ReadFile(filepath.Join(pkgDir, "package.json"))
|
|
if err != nil {
|
|
return "", fmt.Errorf("pkgmgr: read package.json: %w", err)
|
|
}
|
|
var pj struct {
|
|
Bin json.RawMessage `json:"bin"`
|
|
Main string `json:"main"`
|
|
Pi struct {
|
|
Extensions []string `json:"extensions"`
|
|
} `json:"pi"`
|
|
}
|
|
if err := json.Unmarshal(data, &pj); err != nil {
|
|
return "", fmt.Errorf("pkgmgr: parse package.json: %w", err)
|
|
}
|
|
|
|
// 1. npm "bin": string form.
|
|
var s string
|
|
if err := json.Unmarshal(pj.Bin, &s); err == nil && s != "" {
|
|
return s, nil
|
|
}
|
|
// npm "bin": object form {command: path}.
|
|
var m map[string]string
|
|
if err := json.Unmarshal(pj.Bin, &m); err == nil && len(m) > 0 {
|
|
if p, ok := m[name]; ok && p != "" {
|
|
return p, nil
|
|
}
|
|
// npm-scoped name: bin key is often the unscoped base name.
|
|
if p, ok := m[filepath.Base(name)]; ok && p != "" {
|
|
return p, nil
|
|
}
|
|
for _, p := range m {
|
|
if p != "" {
|
|
return p, nil
|
|
}
|
|
}
|
|
}
|
|
|
|
// 2. pi metadata entrypoint: pi.extensions is the pi-ecosystem convention.
|
|
for _, p := range pj.Pi.Extensions {
|
|
if p != "" {
|
|
return p, nil
|
|
}
|
|
}
|
|
|
|
// 3. npm "main" as a last resort.
|
|
if pj.Main != "" {
|
|
return pj.Main, nil
|
|
}
|
|
|
|
return "", fmt.Errorf("pkgmgr: extension %q has no bin, pi.extensions, or main entrypoint in package.json", name)
|
|
}
|
|
|
|
// copyTree recursively copies src into dst (created), preserving file modes and
|
|
// relative structure. It returns the absolute paths of every regular file it
|
|
// wrote (not directories), so callers can record precisely what was laid down.
|
|
func copyTree(src, dst string) ([]string, error) {
|
|
var files []string
|
|
err := filepath.Walk(src, func(path string, info os.FileInfo, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rel, err := filepath.Rel(src, path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
target := filepath.Join(dst, rel)
|
|
switch {
|
|
case info.IsDir():
|
|
return os.MkdirAll(target, 0o755)
|
|
case info.Mode().IsRegular():
|
|
if err := copyFile(path, target, info.Mode()); err != nil {
|
|
return err
|
|
}
|
|
files = append(files, target)
|
|
return nil
|
|
default:
|
|
// Skip symlinks/devices — npm packages are files + dirs.
|
|
return nil
|
|
}
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("pkgmgr: copy package tree: %w", err)
|
|
}
|
|
return files, nil
|
|
}
|
|
|
|
// copyFile copies a single regular file from src to dst with the given mode.
|
|
func copyFile(src, dst string, mode os.FileMode) error {
|
|
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
|
return err
|
|
}
|
|
in, err := os.Open(src)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer in.Close()
|
|
out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, err := io.Copy(out, in); err != nil {
|
|
out.Close()
|
|
return err
|
|
}
|
|
return out.Close()
|
|
}
|
|
|
|
// shellQuote wraps s in single quotes for safe embedding in a /bin/sh script,
|
|
// escaping any embedded single quotes.
|
|
func shellQuote(s string) string {
|
|
quoted := make([]byte, 0, len(s)+2)
|
|
quoted = append(quoted, '\'')
|
|
for i := 0; i < len(s); i++ {
|
|
if s[i] == '\'' {
|
|
quoted = append(quoted, '\'', '\\', '\'', '\'')
|
|
continue
|
|
}
|
|
quoted = append(quoted, s[i])
|
|
}
|
|
quoted = append(quoted, '\'')
|
|
return string(quoted)
|
|
}
|