first commit
This commit is contained in:
@@ -0,0 +1,260 @@
|
||||
// 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)
|
||||
}
|
||||
Reference in New Issue
Block a user