// 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/.pkg/ ← full extracted package (a dir; Discover skips it) // $PIGO_HOME/plugins/ ← 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) }