first commit
This commit is contained in:
@@ -0,0 +1,150 @@
|
||||
// This file classifies a fetched pi package into its type(s) — extension,
|
||||
// skill, prompt, or theme (#157). Classification reads the package's
|
||||
// package.json: pi packages carry a "pi" metadata block declaring what they
|
||||
// provide, and pigo also falls back to structural signals (a bin entry, a
|
||||
// SKILL.md, a commands/ dir) so a package that omits explicit metadata but
|
||||
// clearly is one type is still recognized.
|
||||
//
|
||||
// A single package may be several types at once — the npm catalog has combined
|
||||
// "extensionskill" entries — so Classify returns a set. When nothing matches,
|
||||
// it returns an error rather than guessing, so `pigo install` fails clearly on
|
||||
// a package that isn't a pi package.
|
||||
//
|
||||
// NOTE on metadata shape: the exact pi metadata field names are taken from the
|
||||
// pi package conventions (a top-level "pi" object with a "type" string or
|
||||
// "types" array, and/or per-capability keys). Both the explicit "pi.type(s)"
|
||||
// form and structural fallbacks are honored so classification is robust to
|
||||
// packages that under-declare. See docs/issue#0157.html for the assumptions.
|
||||
package pkgmgr
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
)
|
||||
|
||||
// packageJSON is the subset of an npm package.json pigo reads for classification
|
||||
// and versioning. Unknown fields are ignored.
|
||||
type packageJSON struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
// Bin is npm's executable declaration: either a string path or a
|
||||
// {name: path} object. Its presence signals an extension.
|
||||
Bin json.RawMessage `json:"bin,omitempty"`
|
||||
// Pi is the pi-specific metadata block declaring the package's capabilities.
|
||||
Pi *piMeta `json:"pi,omitempty"`
|
||||
}
|
||||
|
||||
// piMeta is the "pi" block of a package.json. It supports either a single
|
||||
// "type" or a "types" list, plus per-capability keys so a package can declare,
|
||||
// e.g., both an extension and a skill.
|
||||
//
|
||||
// The pi ecosystem convention (observed across published packages such as
|
||||
// pi-simplify, pi-mcp-adapter, pi-spark, pi-ask-user) declares capabilities as
|
||||
// PLURAL arrays of paths — "extensions", "skills", "prompts", "themes" — each
|
||||
// listing the files/dirs that provide that capability. We also accept the
|
||||
// singular forms ("extension", "skill", ...) so a package that declares a single
|
||||
// capability object is still recognized. Any present value (array or object)
|
||||
// registers that type; only its presence matters for classification.
|
||||
type piMeta struct {
|
||||
Type string `json:"type,omitempty"`
|
||||
Types []string `json:"types,omitempty"`
|
||||
Extension json.RawMessage `json:"extension,omitempty"`
|
||||
Extensions json.RawMessage `json:"extensions,omitempty"`
|
||||
Skill json.RawMessage `json:"skill,omitempty"`
|
||||
Skills json.RawMessage `json:"skills,omitempty"`
|
||||
Prompt json.RawMessage `json:"prompt,omitempty"`
|
||||
Prompts json.RawMessage `json:"prompts,omitempty"`
|
||||
Theme json.RawMessage `json:"theme,omitempty"`
|
||||
Themes json.RawMessage `json:"themes,omitempty"`
|
||||
}
|
||||
|
||||
// Classify inspects the fetched package directory and returns the set of pi
|
||||
// package types it provides, along with the package name and version read from
|
||||
// package.json. It returns an error when package.json is missing/unreadable or
|
||||
// when no known pi type can be determined.
|
||||
func Classify(pkgDir string) (name, version string, types []PackageType, err error) {
|
||||
pjPath := filepath.Join(pkgDir, "package.json")
|
||||
data, err := os.ReadFile(pjPath)
|
||||
if err != nil {
|
||||
return "", "", nil, fmt.Errorf("pkgmgr: read package.json: %w", err)
|
||||
}
|
||||
var pj packageJSON
|
||||
if err := json.Unmarshal(data, &pj); err != nil {
|
||||
return "", "", nil, fmt.Errorf("pkgmgr: parse package.json: %w", err)
|
||||
}
|
||||
|
||||
set := map[PackageType]bool{}
|
||||
|
||||
// 1. Explicit pi metadata wins.
|
||||
if pj.Pi != nil {
|
||||
for _, t := range append(pj.Pi.Types, pj.Pi.Type) {
|
||||
if pt, ok := normalizeType(t); ok {
|
||||
set[pt] = true
|
||||
}
|
||||
}
|
||||
if len(pj.Pi.Extension) > 0 || len(pj.Pi.Extensions) > 0 {
|
||||
set[TypeExtension] = true
|
||||
}
|
||||
if len(pj.Pi.Skill) > 0 || len(pj.Pi.Skills) > 0 {
|
||||
set[TypeSkill] = true
|
||||
}
|
||||
if len(pj.Pi.Prompt) > 0 || len(pj.Pi.Prompts) > 0 {
|
||||
set[TypePrompt] = true
|
||||
}
|
||||
if len(pj.Pi.Theme) > 0 || len(pj.Pi.Themes) > 0 {
|
||||
set[TypeTheme] = true
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Structural fallbacks for packages that under-declare.
|
||||
if len(pj.Bin) > 0 {
|
||||
set[TypeExtension] = true
|
||||
}
|
||||
if fileExists(filepath.Join(pkgDir, "SKILL.md")) {
|
||||
set[TypeSkill] = true
|
||||
}
|
||||
if dirExists(filepath.Join(pkgDir, "commands")) {
|
||||
set[TypePrompt] = true
|
||||
}
|
||||
|
||||
if len(set) == 0 {
|
||||
return "", "", nil, fmt.Errorf("unrecognized pi package: no known pi metadata")
|
||||
}
|
||||
|
||||
types = make([]PackageType, 0, len(set))
|
||||
for t := range set {
|
||||
types = append(types, t)
|
||||
}
|
||||
slices.Sort(types)
|
||||
return pj.Name, pj.Version, types, nil
|
||||
}
|
||||
|
||||
// normalizeType maps a pi metadata type string to a PackageType, reporting
|
||||
// whether it is recognized.
|
||||
func normalizeType(s string) (PackageType, bool) {
|
||||
switch PackageType(s) {
|
||||
case TypeExtension:
|
||||
return TypeExtension, true
|
||||
case TypeSkill:
|
||||
return TypeSkill, true
|
||||
case TypePrompt:
|
||||
return TypePrompt, true
|
||||
case TypeTheme:
|
||||
return TypeTheme, true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func fileExists(path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
return err == nil && !info.IsDir()
|
||||
}
|
||||
|
||||
func dirExists(path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
return err == nil && info.IsDir()
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
package pkgmgr
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// writePkg writes a package.json (and optional extra files) into a fresh temp
|
||||
// dir and returns the dir. extraFiles maps a relative path to its contents; a
|
||||
// path ending in "/" is created as a directory.
|
||||
func writePkg(t *testing.T, packageJSON string, extraFiles map[string]string) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "package.json"), []byte(packageJSON), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for name, body := range extraFiles {
|
||||
p := filepath.Join(dir, name)
|
||||
if body == "" && name[len(name)-1] == '/' {
|
||||
if err := os.MkdirAll(p, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
// TestClassifyExplicitType verifies a single explicit pi.type is recognized.
|
||||
func TestClassifyExplicitType(t *testing.T) {
|
||||
dir := writePkg(t, `{"name":"pi-web","version":"1.0.0","pi":{"type":"skill"}}`, nil)
|
||||
name, version, types, err := Classify(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("Classify: %v", err)
|
||||
}
|
||||
if name != "pi-web" || version != "1.0.0" {
|
||||
t.Errorf("name/version = %q/%q, want pi-web/1.0.0", name, version)
|
||||
}
|
||||
if !reflect.DeepEqual(types, []PackageType{TypeSkill}) {
|
||||
t.Errorf("types = %v, want [skill]", types)
|
||||
}
|
||||
}
|
||||
|
||||
// TestClassifyMultiType verifies a package declaring several types via pi.types
|
||||
// returns them all, sorted.
|
||||
func TestClassifyMultiType(t *testing.T) {
|
||||
dir := writePkg(t, `{"name":"combo","version":"2.0.0","pi":{"types":["extension","skill"]}}`, nil)
|
||||
_, _, types, err := Classify(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("Classify: %v", err)
|
||||
}
|
||||
want := []PackageType{TypeExtension, TypeSkill}
|
||||
if !reflect.DeepEqual(types, want) {
|
||||
t.Errorf("types = %v, want %v", types, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestClassifyPerCapabilityKeys verifies pi.extension + pi.theme blocks both
|
||||
// register their types.
|
||||
func TestClassifyPerCapabilityKeys(t *testing.T) {
|
||||
dir := writePkg(t, `{"name":"x","version":"0.1.0","pi":{"extension":{"cmd":"x"},"theme":{}}}`, nil)
|
||||
_, _, types, err := Classify(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("Classify: %v", err)
|
||||
}
|
||||
want := []PackageType{TypeExtension, TypeTheme}
|
||||
if !reflect.DeepEqual(types, want) {
|
||||
t.Errorf("types = %v, want %v", types, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestClassifyPluralCapabilityKeys verifies the pi-ecosystem convention of
|
||||
// plural path arrays (pi.extensions, pi.skills, ...) registers each type. This
|
||||
// is the shape published packages actually use (pi-simplify, pi-ask-user, ...).
|
||||
func TestClassifyPluralCapabilityKeys(t *testing.T) {
|
||||
dir := writePkg(t, `{"name":"pi-ask-user","version":"1.0.0","pi":{"extensions":["./index.ts"],"skills":["./skills"]}}`, nil)
|
||||
_, _, types, err := Classify(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("Classify: %v", err)
|
||||
}
|
||||
want := []PackageType{TypeExtension, TypeSkill}
|
||||
if !reflect.DeepEqual(types, want) {
|
||||
t.Errorf("types = %v, want %v", types, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestClassifyPluralExtensionsOnly verifies a pure extension declared via
|
||||
// pi.extensions (no bin) classifies as an extension — the pi-simplify case.
|
||||
func TestClassifyPluralExtensionsOnly(t *testing.T) {
|
||||
dir := writePkg(t, `{"name":"pi-simplify","version":"0.2.3","pi":{"extensions":["dist/index.js"]}}`, nil)
|
||||
_, _, types, err := Classify(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("Classify: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(types, []PackageType{TypeExtension}) {
|
||||
t.Errorf("types = %v, want [extension]", types)
|
||||
}
|
||||
}
|
||||
|
||||
// TestClassifyStructuralBin verifies a bare package with a bin entry is an
|
||||
// extension even without pi metadata.
|
||||
func TestClassifyStructuralBin(t *testing.T) {
|
||||
dir := writePkg(t, `{"name":"pi-mcp-adapter","version":"1.0.0","bin":{"pi-mcp-adapter":"./index.js"}}`, nil)
|
||||
_, _, types, err := Classify(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("Classify: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(types, []PackageType{TypeExtension}) {
|
||||
t.Errorf("types = %v, want [extension]", types)
|
||||
}
|
||||
}
|
||||
|
||||
// TestClassifyStructuralSkillMd verifies a SKILL.md file signals a skill.
|
||||
func TestClassifyStructuralSkillMd(t *testing.T) {
|
||||
dir := writePkg(t, `{"name":"pi-skill","version":"1.0.0"}`, map[string]string{
|
||||
"SKILL.md": "# a skill",
|
||||
})
|
||||
_, _, types, err := Classify(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("Classify: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(types, []PackageType{TypeSkill}) {
|
||||
t.Errorf("types = %v, want [skill]", types)
|
||||
}
|
||||
}
|
||||
|
||||
// TestClassifyStructuralCommandsDir verifies a commands/ dir signals a prompt.
|
||||
func TestClassifyStructuralCommandsDir(t *testing.T) {
|
||||
dir := writePkg(t, `{"name":"pi-cmds","version":"1.0.0"}`, map[string]string{
|
||||
"commands/hello.md": "hi",
|
||||
})
|
||||
_, _, types, err := Classify(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("Classify: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(types, []PackageType{TypePrompt}) {
|
||||
t.Errorf("types = %v, want [prompt]", types)
|
||||
}
|
||||
}
|
||||
|
||||
// TestClassifyExplicitAndStructural verifies explicit metadata and structural
|
||||
// signals union together (skill via SKILL.md, extension via pi.type).
|
||||
func TestClassifyExplicitAndStructural(t *testing.T) {
|
||||
dir := writePkg(t, `{"name":"both","version":"1.0.0","pi":{"type":"extension"}}`, map[string]string{
|
||||
"SKILL.md": "# skill",
|
||||
})
|
||||
_, _, types, err := Classify(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("Classify: %v", err)
|
||||
}
|
||||
want := []PackageType{TypeExtension, TypeSkill}
|
||||
if !reflect.DeepEqual(types, want) {
|
||||
t.Errorf("types = %v, want %v", types, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestClassifyUnknown verifies a plain npm package with no pi signals errors.
|
||||
func TestClassifyUnknown(t *testing.T) {
|
||||
dir := writePkg(t, `{"name":"lodash","version":"4.17.21"}`, nil)
|
||||
_, _, _, err := Classify(dir)
|
||||
if err == nil {
|
||||
t.Fatal("Classify of non-pi package = nil error, want error")
|
||||
}
|
||||
if !contains(err.Error(), "unrecognized pi package") {
|
||||
t.Errorf("error = %q, want to mention 'unrecognized pi package'", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestClassifyMissingPackageJSON verifies a missing package.json errors.
|
||||
func TestClassifyMissingPackageJSON(t *testing.T) {
|
||||
if _, _, _, err := Classify(t.TempDir()); err == nil {
|
||||
t.Fatal("Classify with no package.json = nil error, want error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestClassifyCorruptPackageJSON verifies malformed JSON errors clearly.
|
||||
func TestClassifyCorruptPackageJSON(t *testing.T) {
|
||||
dir := writePkg(t, `{not valid json`, nil)
|
||||
_, _, _, err := Classify(dir)
|
||||
if err == nil {
|
||||
t.Fatal("Classify with corrupt package.json = nil error, want error")
|
||||
}
|
||||
if !contains(err.Error(), "parse package.json") {
|
||||
t.Errorf("error = %q, want to mention 'parse package.json'", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestClassifyUnknownTypeStringIgnored verifies an unrecognized pi.type string
|
||||
// is ignored (not fatal) but leaves the package unclassified if nothing else
|
||||
// matches.
|
||||
func TestClassifyUnknownTypeStringIgnored(t *testing.T) {
|
||||
dir := writePkg(t, `{"name":"x","version":"1.0.0","pi":{"type":"widget"}}`, nil)
|
||||
_, _, _, err := Classify(dir)
|
||||
if err == nil {
|
||||
t.Fatal("Classify with only unknown pi.type = nil error, want error")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
// This file distributes a classified pi prompt/command package into pigo's
|
||||
// prompts directory so runtime.LoadUserCommandsDir picks it up (#160, #342).
|
||||
//
|
||||
// pigo loads declarative slash commands from $PIGO_HOME/prompts/*.md (and the
|
||||
// legacy $PIGO_HOME/commands/*.md) non-recursively: each markdown file defines
|
||||
// a "/name" command, named after the file, whose body is the prompt template.
|
||||
// A pi prompt package ships one or more such templates, conventionally under a
|
||||
// "prompts/" subdirectory (the pi convention); the legacy "commands/" subdir is
|
||||
// a fallback, and some packages place the .md files at the package root.
|
||||
//
|
||||
// Distribution copies those .md files (flattened, since the loader is
|
||||
// non-recursive) into $PIGO_HOME/prompts/. Every file laid down is returned so
|
||||
// the lockfile can remove precisely what was installed.
|
||||
package pkgmgr
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// DistributePrompt copies the prompt package's command templates at pkgDir into
|
||||
// the commands directory. It looks first in "<pkgDir>/commands" for *.md files
|
||||
// and falls back to *.md at the package root. It returns the absolute paths of
|
||||
// every file it created, for the lockfile. An unresolvable commands dir, or a
|
||||
// package with no command templates, is an error.
|
||||
func DistributePrompt(pkgDir, name string) ([]string, error) {
|
||||
promptsDir := PromptsDir()
|
||||
if promptsDir == "" {
|
||||
return nil, fmt.Errorf("pkgmgr: cannot resolve prompts dir (PIGO_HOME/home unavailable)")
|
||||
}
|
||||
|
||||
// Prefer the pi-aligned prompts/ subdir, then the legacy commands/ subdir,
|
||||
// then root-level *.md as a last resort.
|
||||
srcDir := ""
|
||||
for _, sub := range []string{"prompts", "commands"} {
|
||||
if dirExists(filepath.Join(pkgDir, sub)) {
|
||||
srcDir = filepath.Join(pkgDir, sub)
|
||||
break
|
||||
}
|
||||
}
|
||||
if srcDir == "" {
|
||||
srcDir = pkgDir // fall back to root-level *.md
|
||||
}
|
||||
entries, err := os.ReadDir(srcDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pkgmgr: read prompt source dir: %w", err)
|
||||
}
|
||||
|
||||
var mds []string
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || !strings.EqualFold(filepath.Ext(e.Name()), ".md") {
|
||||
continue
|
||||
}
|
||||
// A root-level README.md is not a command template; skip it when we've
|
||||
// fallen back to the package root.
|
||||
if srcDir == pkgDir && strings.EqualFold(e.Name(), "README.md") {
|
||||
continue
|
||||
}
|
||||
mds = append(mds, e.Name())
|
||||
}
|
||||
if len(mds) == 0 {
|
||||
return nil, fmt.Errorf("pkgmgr: prompt %q has no prompt templates (*.md)", name)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(promptsDir, 0o755); err != nil {
|
||||
return nil, fmt.Errorf("pkgmgr: create prompts dir: %w", err)
|
||||
}
|
||||
|
||||
created := make([]string, 0, len(mds))
|
||||
for _, md := range mds {
|
||||
src := filepath.Join(srcDir, md)
|
||||
dst := filepath.Join(promptsDir, md)
|
||||
info, statErr := os.Stat(src)
|
||||
if statErr != nil {
|
||||
return created, fmt.Errorf("pkgmgr: stat command %q: %w", src, statErr)
|
||||
}
|
||||
if err := copyFile(src, dst, info.Mode()); err != nil {
|
||||
return created, fmt.Errorf("pkgmgr: copy command %q: %w", md, err)
|
||||
}
|
||||
created = append(created, dst)
|
||||
}
|
||||
return created, nil
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package pkgmgr
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestDistributePromptPromptsDirPreferred verifies the pi-aligned prompts/
|
||||
// subdir is preferred over the legacy commands/ subdir (#342): only the
|
||||
// prompts/ templates are installed.
|
||||
func TestDistributePromptPromptsDirPreferred(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("PIGO_HOME", home)
|
||||
|
||||
pkg := writePkg(t, `{"name":"pi-prompts","version":"1.0.0","pi":{"type":"prompt"}}`, map[string]string{
|
||||
"prompts/review.md": "---\ndescription: review\n---\nReview $ARGUMENTS",
|
||||
"commands/legacy.md": "Legacy $ARGUMENTS",
|
||||
})
|
||||
|
||||
files, err := DistributePrompt(pkg, "pi-prompts")
|
||||
if err != nil {
|
||||
t.Fatalf("DistributePrompt: %v", err)
|
||||
}
|
||||
if len(files) != 1 {
|
||||
t.Fatalf("created %d files, want 1 (prompts/ preferred over commands/): %v", len(files), files)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(home, "prompts", "review.md")); err != nil {
|
||||
t.Errorf("review.md not placed in prompts/: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(home, "prompts", "legacy.md")); !os.IsNotExist(err) {
|
||||
t.Errorf("legacy.md from commands/ should not be installed when prompts/ exists: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDistributePromptCommandsDirFallback verifies the legacy commands/ subdir
|
||||
// is used when prompts/ is absent, installing into ~/.pigo/prompts.
|
||||
func TestDistributePromptCommandsDirFallback(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("PIGO_HOME", home)
|
||||
|
||||
pkg := writePkg(t, `{"name":"pi-prompts","version":"1.0.0","pi":{"type":"prompt"}}`, map[string]string{
|
||||
"commands/review.md": "---\ndescription: review\n---\nReview $ARGUMENTS",
|
||||
"commands/explain.md": "Explain $ARGUMENTS",
|
||||
"README.md": "readme",
|
||||
})
|
||||
|
||||
files, err := DistributePrompt(pkg, "pi-prompts")
|
||||
if err != nil {
|
||||
t.Fatalf("DistributePrompt: %v", err)
|
||||
}
|
||||
if len(files) != 2 {
|
||||
t.Errorf("created %d files, want 2: %v", len(files), files)
|
||||
}
|
||||
for _, want := range []string{"review.md", "explain.md"} {
|
||||
if _, err := os.Stat(filepath.Join(home, "prompts", want)); err != nil {
|
||||
t.Errorf("%s not placed in prompts/: %v", want, err)
|
||||
}
|
||||
}
|
||||
// Root README.md is not copied (commands/ was used, not root).
|
||||
if _, err := os.Stat(filepath.Join(home, "prompts", "README.md")); !os.IsNotExist(err) {
|
||||
t.Errorf("root README.md leaked into prompts: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDistributePromptRootFallback verifies root-level *.md are used when
|
||||
// neither prompts/ nor commands/ exists, skipping README.md, installing into
|
||||
// ~/.pigo/prompts.
|
||||
func TestDistributePromptRootFallback(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("PIGO_HOME", home)
|
||||
|
||||
pkg := writePkg(t, `{"name":"pi-p","version":"1.0.0","pi":{"type":"prompt"}}`, map[string]string{
|
||||
"summarize.md": "Summarize $ARGUMENTS",
|
||||
"README.md": "readme",
|
||||
})
|
||||
|
||||
files, err := DistributePrompt(pkg, "pi-p")
|
||||
if err != nil {
|
||||
t.Fatalf("DistributePrompt: %v", err)
|
||||
}
|
||||
if len(files) != 1 {
|
||||
t.Fatalf("created %d files, want 1: %v", len(files), files)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(home, "prompts", "summarize.md")); err != nil {
|
||||
t.Errorf("summarize.md not placed in prompts/: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(home, "prompts", "README.md")); !os.IsNotExist(err) {
|
||||
t.Errorf("README.md should be skipped at root fallback: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDistributePromptNone verifies a package with no prompt templates errors.
|
||||
func TestDistributePromptNone(t *testing.T) {
|
||||
t.Setenv("PIGO_HOME", t.TempDir())
|
||||
pkg := writePkg(t, `{"name":"pi-empty","version":"1.0.0"}`, nil)
|
||||
if _, err := DistributePrompt(pkg, "pi-empty"); err == nil {
|
||||
t.Fatal("DistributePrompt with no templates = nil error, want error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDistributePromptReturnsPromptsPaths verifies the returned paths (recorded
|
||||
// in the lockfile) are under ~/.pigo/prompts, so uninstall removes precisely
|
||||
// what was installed.
|
||||
func TestDistributePromptReturnsPromptsPaths(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("PIGO_HOME", home)
|
||||
pkg := writePkg(t, `{"name":"pi-p","version":"1.0.0","pi":{"type":"prompt"}}`, map[string]string{
|
||||
"prompts/x.md": "X $ARGUMENTS",
|
||||
})
|
||||
files, err := DistributePrompt(pkg, "pi-p")
|
||||
if err != nil {
|
||||
t.Fatalf("DistributePrompt: %v", err)
|
||||
}
|
||||
wantDir := filepath.Join(home, "prompts")
|
||||
for _, f := range files {
|
||||
if filepath.Dir(f) != wantDir {
|
||||
t.Errorf("installed path %q not under %s", f, wantDir)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
// This file distributes a classified pi skill into pigo's skills directory so
|
||||
// runtime.LoadSkillsDir picks it up (#159).
|
||||
//
|
||||
// pigo loads skills from the skills dir (SkillsDir): ~/.agents/skills, or
|
||||
// PIGO_SKILLS_DIR when set. LoadSkillsDir recognizes the nested layout
|
||||
// "<skillsDir>/<name>/SKILL.md" (a directory per skill whose SKILL.md holds the
|
||||
// YAML frontmatter). An npm skill package is exactly such a bundle — a SKILL.md
|
||||
// plus its supporting files — so distribution is a straight copy of the package
|
||||
// tree into "<skillsDir>/<name>/".
|
||||
//
|
||||
// As with extensions, every file laid down is returned so the lockfile can
|
||||
// remove precisely what was installed on uninstall.
|
||||
package pkgmgr
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// DistributeSkill copies the skill package at pkgDir into the skills directory
|
||||
// under a "<name>/" subdirectory, where runtime.LoadSkillsDir discovers it via
|
||||
// its SKILL.md. It returns the absolute paths of every file (and the skill dir)
|
||||
// it created, for the lockfile. An unresolvable skills dir, or a package with no
|
||||
// SKILL.md, is an error.
|
||||
func DistributeSkill(pkgDir, name string) ([]string, error) {
|
||||
skillsDir := SkillsDir()
|
||||
if skillsDir == "" {
|
||||
return nil, fmt.Errorf("pkgmgr: cannot resolve skills dir (home unavailable)")
|
||||
}
|
||||
if !fileExists(filepath.Join(pkgDir, "SKILL.md")) {
|
||||
return nil, fmt.Errorf("pkgmgr: skill %q has no SKILL.md", name)
|
||||
}
|
||||
|
||||
dest := filepath.Join(skillsDir, name)
|
||||
// A stale skill from a prior install must not linger alongside the new one.
|
||||
if err := os.RemoveAll(dest); err != nil {
|
||||
return nil, fmt.Errorf("pkgmgr: clear old skill %q: %w", dest, err)
|
||||
}
|
||||
if err := os.MkdirAll(skillsDir, 0o755); err != nil {
|
||||
return nil, fmt.Errorf("pkgmgr: create skills dir: %w", err)
|
||||
}
|
||||
files, err := copyTree(pkgDir, dest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return append(files, dest), nil
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package pkgmgr
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestDistributeSkill verifies a skill package copies into skillsDir/<name>/
|
||||
// with its SKILL.md and supporting files, discoverable by LoadSkillsDir's
|
||||
// nested layout.
|
||||
func TestDistributeSkill(t *testing.T) {
|
||||
skills := t.TempDir()
|
||||
t.Setenv("PIGO_SKILLS_DIR", skills)
|
||||
|
||||
pkg := writePkg(t, `{"name":"pi-writing","version":"1.0.0","pi":{"type":"skill"}}`, map[string]string{
|
||||
"SKILL.md": "---\nname: writing\ndescription: help writing\n---\nbody",
|
||||
"references/tips.md": "tips",
|
||||
})
|
||||
|
||||
files, err := DistributeSkill(pkg, "pi-writing")
|
||||
if err != nil {
|
||||
t.Fatalf("DistributeSkill: %v", err)
|
||||
}
|
||||
|
||||
// SKILL.md must land at <skillsDir>/<name>/SKILL.md (nested layout).
|
||||
skillMd := filepath.Join(skills, "pi-writing", "SKILL.md")
|
||||
if _, err := os.Stat(skillMd); err != nil {
|
||||
t.Fatalf("SKILL.md not placed: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(skills, "pi-writing", "references", "tips.md")); err != nil {
|
||||
t.Errorf("supporting file not copied: %v", err)
|
||||
}
|
||||
|
||||
var sawSkillMd bool
|
||||
for _, f := range files {
|
||||
if f == skillMd {
|
||||
sawSkillMd = true
|
||||
}
|
||||
}
|
||||
if !sawSkillMd {
|
||||
t.Errorf("created files %v missing SKILL.md", files)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDistributeSkillReinstallReplaces verifies reinstalling clears stale files.
|
||||
func TestDistributeSkillReinstallReplaces(t *testing.T) {
|
||||
skills := t.TempDir()
|
||||
t.Setenv("PIGO_SKILLS_DIR", skills)
|
||||
|
||||
pkg1 := writePkg(t, `{"name":"pi-s","version":"1.0.0"}`, map[string]string{
|
||||
"SKILL.md": "---\nname: s\ndescription: d\n---\n",
|
||||
"old.md": "old",
|
||||
})
|
||||
if _, err := DistributeSkill(pkg1, "pi-s"); err != nil {
|
||||
t.Fatalf("first install: %v", err)
|
||||
}
|
||||
pkg2 := writePkg(t, `{"name":"pi-s","version":"2.0.0"}`, map[string]string{
|
||||
"SKILL.md": "---\nname: s\ndescription: d2\n---\n",
|
||||
})
|
||||
if _, err := DistributeSkill(pkg2, "pi-s"); err != nil {
|
||||
t.Fatalf("reinstall: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(skills, "pi-s", "old.md")); !os.IsNotExist(err) {
|
||||
t.Errorf("stale skill file survived reinstall: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDistributeSkillNoSkillMd verifies a package without SKILL.md errors.
|
||||
func TestDistributeSkillNoSkillMd(t *testing.T) {
|
||||
t.Setenv("PIGO_SKILLS_DIR", t.TempDir())
|
||||
pkg := writePkg(t, `{"name":"pi-noskill","version":"1.0.0"}`, nil)
|
||||
if _, err := DistributeSkill(pkg, "pi-noskill"); err == nil {
|
||||
t.Fatal("DistributeSkill without SKILL.md = nil error, want error")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
package pkgmgr
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestDistributeExtensionStringBin verifies a package with a string "bin"
|
||||
// installs a launcher + payload tree and reports created files.
|
||||
func TestDistributeExtensionStringBin(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("extension install not supported on windows")
|
||||
}
|
||||
home := t.TempDir()
|
||||
t.Setenv("PIGO_HOME", home)
|
||||
|
||||
pkg := writePkg(t, `{"name":"pi-demo","version":"1.0.0","bin":"./cli.js"}`, map[string]string{
|
||||
"cli.js": "#!/usr/bin/env node\nconsole.log('hi')\n",
|
||||
"lib/x.js": "module.exports=1\n",
|
||||
})
|
||||
|
||||
files, err := DistributeExtension(pkg, "pi-demo")
|
||||
if err != nil {
|
||||
t.Fatalf("DistributeExtension: %v", err)
|
||||
}
|
||||
|
||||
launcher := filepath.Join(home, "plugins", "pi-demo")
|
||||
info, err := os.Stat(launcher)
|
||||
if err != nil {
|
||||
t.Fatalf("stat launcher: %v", err)
|
||||
}
|
||||
if info.Mode()&0o111 == 0 {
|
||||
t.Errorf("launcher not executable: mode %v", info.Mode())
|
||||
}
|
||||
|
||||
// The payload bin must exist and be executable.
|
||||
binAbs := filepath.Join(home, "plugins", "pi-demo.pkg", "cli.js")
|
||||
bi, err := os.Stat(binAbs)
|
||||
if err != nil {
|
||||
t.Fatalf("stat payload bin: %v", err)
|
||||
}
|
||||
if bi.Mode()&0o111 == 0 {
|
||||
t.Errorf("payload bin not executable: mode %v", bi.Mode())
|
||||
}
|
||||
|
||||
// Sibling files copied.
|
||||
if _, err := os.Stat(filepath.Join(home, "plugins", "pi-demo.pkg", "lib", "x.js")); err != nil {
|
||||
t.Errorf("sibling file not copied: %v", err)
|
||||
}
|
||||
|
||||
// created list includes launcher and payload dir.
|
||||
var sawLauncher bool
|
||||
for _, f := range files {
|
||||
if f == launcher {
|
||||
sawLauncher = true
|
||||
}
|
||||
}
|
||||
if !sawLauncher {
|
||||
t.Errorf("created files %v missing launcher", files)
|
||||
}
|
||||
|
||||
// A ".js" bin is a pi extension, so the launcher runs the Node host and a
|
||||
// .pihost.mjs is dropped beside the payload.
|
||||
host := filepath.Join(home, "plugins", "pi-demo.pkg", ".pihost.mjs")
|
||||
if _, err := os.Stat(host); err != nil {
|
||||
t.Errorf("expected embedded pi host at %q: %v", host, err)
|
||||
}
|
||||
script, _ := os.ReadFile(launcher)
|
||||
if !contains(string(script), "exec node ") || !contains(string(script), host) {
|
||||
t.Errorf("launcher script = %q, want node host exec of %q", script, host)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDistributeExtensionObjectBin verifies the {command: path} bin form,
|
||||
// preferring the entry keyed by package name.
|
||||
func TestDistributeExtensionObjectBin(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("extension install not supported on windows")
|
||||
}
|
||||
home := t.TempDir()
|
||||
t.Setenv("PIGO_HOME", home)
|
||||
|
||||
pkg := writePkg(t, `{"name":"pi-adapter","version":"2.0.0","bin":{"pi-adapter":"./main.js","other":"./other.js"}}`, map[string]string{
|
||||
"main.js": "#!/usr/bin/env node\n",
|
||||
"other.js": "#!/usr/bin/env node\n",
|
||||
})
|
||||
|
||||
if _, err := DistributeExtension(pkg, "pi-adapter"); err != nil {
|
||||
t.Fatalf("DistributeExtension: %v", err)
|
||||
}
|
||||
|
||||
binAbs := filepath.Join(home, "plugins", "pi-adapter.pkg", "main.js")
|
||||
if bi, err := os.Stat(binAbs); err != nil || bi.Mode()&0o111 == 0 {
|
||||
t.Errorf("expected main.js executable, err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDistributeExtensionReinstallReplaces verifies a second install clears the
|
||||
// stale payload rather than merging it.
|
||||
func TestDistributeExtensionReinstallReplaces(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("extension install not supported on windows")
|
||||
}
|
||||
home := t.TempDir()
|
||||
t.Setenv("PIGO_HOME", home)
|
||||
|
||||
pkg1 := writePkg(t, `{"name":"pi-x","version":"1.0.0","bin":"./a.js"}`, map[string]string{
|
||||
"a.js": "#!/usr/bin/env node\n",
|
||||
"gone.js": "old\n",
|
||||
})
|
||||
if _, err := DistributeExtension(pkg1, "pi-x"); err != nil {
|
||||
t.Fatalf("first install: %v", err)
|
||||
}
|
||||
|
||||
pkg2 := writePkg(t, `{"name":"pi-x","version":"2.0.0","bin":"./a.js"}`, map[string]string{
|
||||
"a.js": "#!/usr/bin/env node\n",
|
||||
})
|
||||
if _, err := DistributeExtension(pkg2, "pi-x"); err != nil {
|
||||
t.Fatalf("reinstall: %v", err)
|
||||
}
|
||||
|
||||
// The file only present in the first install must be gone.
|
||||
if _, err := os.Stat(filepath.Join(home, "plugins", "pi-x.pkg", "gone.js")); !os.IsNotExist(err) {
|
||||
t.Errorf("stale file survived reinstall: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDistributeExtensionPiExtensionsEntry verifies a pi extension with no npm
|
||||
// "bin" resolves its entrypoint from pi.extensions (the pi-simplify shape).
|
||||
func TestDistributeExtensionPiExtensionsEntry(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("extension install not supported on windows")
|
||||
}
|
||||
home := t.TempDir()
|
||||
t.Setenv("PIGO_HOME", home)
|
||||
|
||||
pkg := writePkg(t, `{"name":"pi-simplify","version":"0.2.3","pi":{"extensions":["dist/index.js"]}}`, map[string]string{
|
||||
"dist/index.js": "#!/usr/bin/env node\nconsole.log('hi')\n",
|
||||
})
|
||||
|
||||
files, err := DistributeExtension(pkg, "pi-simplify")
|
||||
if err != nil {
|
||||
t.Fatalf("DistributeExtension: %v", err)
|
||||
}
|
||||
|
||||
binAbs := filepath.Join(home, "plugins", "pi-simplify.pkg", "dist", "index.js")
|
||||
bi, err := os.Stat(binAbs)
|
||||
if err != nil {
|
||||
t.Fatalf("stat payload bin: %v", err)
|
||||
}
|
||||
if bi.Mode()&0o111 == 0 {
|
||||
t.Errorf("payload bin not executable: mode %v", bi.Mode())
|
||||
}
|
||||
|
||||
// A pi.extensions package must run under the Node host, dropping .pihost.mjs
|
||||
// and pointing the launcher at `node <host> <pkgDir>`.
|
||||
launcher := filepath.Join(home, "plugins", "pi-simplify")
|
||||
host := filepath.Join(home, "plugins", "pi-simplify.pkg", ".pihost.mjs")
|
||||
pkgDir := filepath.Join(home, "plugins", "pi-simplify.pkg")
|
||||
if _, err := os.Stat(host); err != nil {
|
||||
t.Errorf("expected embedded pi host at %q: %v", host, err)
|
||||
}
|
||||
script, _ := os.ReadFile(launcher)
|
||||
if !contains(string(script), "exec node ") || !contains(string(script), host) || !contains(string(script), pkgDir) {
|
||||
t.Errorf("launcher script = %q, want node host exec of %q with pkgDir %q", script, host, pkgDir)
|
||||
}
|
||||
if !contains(string(script), "node not found") {
|
||||
t.Errorf("launcher script = %q, missing node-absent guard", script)
|
||||
}
|
||||
|
||||
// Both the host and the launcher must be recorded for uninstall.
|
||||
var sawHost, sawLauncher bool
|
||||
for _, f := range files {
|
||||
switch f {
|
||||
case host:
|
||||
sawHost = true
|
||||
case launcher:
|
||||
sawLauncher = true
|
||||
}
|
||||
}
|
||||
if !sawHost || !sawLauncher {
|
||||
t.Errorf("created files %v missing host or launcher", files)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDistributeExtensionBinaryBinDirectExec verifies a native binary bin (no
|
||||
// pi.extensions, non-JS extension) keeps the historical direct-exec launcher —
|
||||
// no Node host, no .pihost.mjs.
|
||||
func TestDistributeExtensionBinaryBinDirectExec(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("extension install not supported on windows")
|
||||
}
|
||||
home := t.TempDir()
|
||||
t.Setenv("PIGO_HOME", home)
|
||||
|
||||
pkg := writePkg(t, `{"name":"pi-native","version":"1.0.0","bin":"./server"}`, map[string]string{
|
||||
"server": "#!/usr/bin/env node\n",
|
||||
})
|
||||
|
||||
files, err := DistributeExtension(pkg, "pi-native")
|
||||
if err != nil {
|
||||
t.Fatalf("DistributeExtension: %v", err)
|
||||
}
|
||||
|
||||
launcher := filepath.Join(home, "plugins", "pi-native")
|
||||
binAbs := filepath.Join(home, "plugins", "pi-native.pkg", "server")
|
||||
script, _ := os.ReadFile(launcher)
|
||||
if !contains(string(script), binAbs) {
|
||||
t.Errorf("launcher script = %q, want direct exec of %q", script, binAbs)
|
||||
}
|
||||
if contains(string(script), "exec node ") {
|
||||
t.Errorf("binary bin launcher = %q, should not run the Node host", script)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(home, "plugins", "pi-native.pkg", ".pihost.mjs")); !os.IsNotExist(err) {
|
||||
t.Errorf("binary bin should not drop .pihost.mjs, err=%v", err)
|
||||
}
|
||||
|
||||
// The launcher and payload dir must be recorded; the host must not be.
|
||||
var sawLauncher bool
|
||||
for _, f := range files {
|
||||
if f == launcher {
|
||||
sawLauncher = true
|
||||
}
|
||||
}
|
||||
if !sawLauncher {
|
||||
t.Errorf("created files %v missing launcher", files)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDistributeExtensionMainEntry verifies the "main" field is used as a
|
||||
// last-resort entrypoint when neither bin nor pi.extensions is present.
|
||||
func TestDistributeExtensionMainEntry(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("extension install not supported on windows")
|
||||
}
|
||||
home := t.TempDir()
|
||||
t.Setenv("PIGO_HOME", home)
|
||||
|
||||
pkg := writePkg(t, `{"name":"pi-main","version":"1.0.0","main":"./dist/index.js"}`, map[string]string{
|
||||
"dist/index.js": "#!/usr/bin/env node\n",
|
||||
})
|
||||
|
||||
if _, err := DistributeExtension(pkg, "pi-main"); err != nil {
|
||||
t.Fatalf("DistributeExtension: %v", err)
|
||||
}
|
||||
binAbs := filepath.Join(home, "plugins", "pi-main.pkg", "dist", "index.js")
|
||||
if bi, err := os.Stat(binAbs); err != nil || bi.Mode()&0o111 == 0 {
|
||||
t.Errorf("expected main entrypoint executable, err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDistributeExtensionNoBin verifies a package without a bin errors.
|
||||
func TestDistributeExtensionNoBin(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("extension install not supported on windows")
|
||||
}
|
||||
t.Setenv("PIGO_HOME", t.TempDir())
|
||||
pkg := writePkg(t, `{"name":"pi-nobin","version":"1.0.0"}`, nil)
|
||||
if _, err := DistributeExtension(pkg, "pi-nobin"); err == nil {
|
||||
t.Fatal("DistributeExtension without bin = nil error, want error")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// This file distributes a classified pi theme into pigo's themes directory
|
||||
// (#161). Unlike extensions/skills/prompts, pigo has no theme runtime yet, so a
|
||||
// theme is simply *stored* under $PIGO_HOME/themes/<name>/ for a future
|
||||
// consumer — no launcher, no discovery wiring. Storing it (rather than dropping
|
||||
// it) keeps the install/list/uninstall/update lifecycle uniform: the theme has
|
||||
// a home, the lockfile records its files, and uninstall can remove it cleanly.
|
||||
package pkgmgr
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// DistributeTheme copies the theme package at pkgDir into the themes directory
|
||||
// under a "<name>/" subdirectory. Because pigo has no theme runtime yet, this
|
||||
// only stores the theme; there is no discovery step. It returns the absolute
|
||||
// paths of every file (and the theme dir) it created, for the lockfile. An
|
||||
// unresolvable themes dir is an error.
|
||||
func DistributeTheme(pkgDir, name string) ([]string, error) {
|
||||
themesDir := ThemesDir()
|
||||
if themesDir == "" {
|
||||
return nil, fmt.Errorf("pkgmgr: cannot resolve themes dir (PIGO_HOME/home unavailable)")
|
||||
}
|
||||
dest := filepath.Join(themesDir, name)
|
||||
// A stale theme from a prior install must not linger alongside the new one.
|
||||
if err := os.RemoveAll(dest); err != nil {
|
||||
return nil, fmt.Errorf("pkgmgr: clear old theme %q: %w", dest, err)
|
||||
}
|
||||
if err := os.MkdirAll(themesDir, 0o755); err != nil {
|
||||
return nil, fmt.Errorf("pkgmgr: create themes dir: %w", err)
|
||||
}
|
||||
files, err := copyTree(pkgDir, dest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return append(files, dest), nil
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package pkgmgr
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestDistributeTheme verifies a theme package is stored under
|
||||
// $PIGO_HOME/themes/<name>/ with its files intact.
|
||||
func TestDistributeTheme(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("PIGO_HOME", home)
|
||||
|
||||
pkg := writePkg(t, `{"name":"pi-dark","version":"1.0.0","pi":{"type":"theme"}}`, map[string]string{
|
||||
"theme.json": `{"bg":"#000"}`,
|
||||
"assets/logo.txt": "logo",
|
||||
})
|
||||
|
||||
files, err := DistributeTheme(pkg, "pi-dark")
|
||||
if err != nil {
|
||||
t.Fatalf("DistributeTheme: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(home, "themes", "pi-dark", "theme.json")); err != nil {
|
||||
t.Errorf("theme.json not stored: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(home, "themes", "pi-dark", "assets", "logo.txt")); err != nil {
|
||||
t.Errorf("theme asset not stored: %v", err)
|
||||
}
|
||||
if len(files) == 0 {
|
||||
t.Error("created files empty")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDistributeThemeReinstallReplaces verifies reinstall clears stale files.
|
||||
func TestDistributeThemeReinstallReplaces(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("PIGO_HOME", home)
|
||||
|
||||
pkg1 := writePkg(t, `{"name":"pi-t","version":"1.0.0"}`, map[string]string{
|
||||
"theme.json": `{}`,
|
||||
"old.txt": "old",
|
||||
})
|
||||
if _, err := DistributeTheme(pkg1, "pi-t"); err != nil {
|
||||
t.Fatalf("first install: %v", err)
|
||||
}
|
||||
pkg2 := writePkg(t, `{"name":"pi-t","version":"2.0.0"}`, map[string]string{
|
||||
"theme.json": `{}`,
|
||||
})
|
||||
if _, err := DistributeTheme(pkg2, "pi-t"); err != nil {
|
||||
t.Fatalf("reinstall: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(home, "themes", "pi-t", "old.txt")); !os.IsNotExist(err) {
|
||||
t.Errorf("stale theme file survived reinstall: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
// This file fetches a pi package's contents from npm (#156). Rather than
|
||||
// implement an npm registry client, pigo shells out to the user's installed
|
||||
// `npm` — specifically `npm pack`, which downloads a package as a .tgz tarball
|
||||
// without running install scripts. pigo then extracts that tarball into a
|
||||
// temporary directory for the classify/distribute steps that follow.
|
||||
//
|
||||
// The fetch is deliberately side-effect-light: `npm pack` neither installs
|
||||
// dependencies nor runs lifecycle scripts, so downloading a package cannot
|
||||
// execute its code. Running the extracted extension is a separate, later step.
|
||||
//
|
||||
// npm packs every package into a top-level "package/" directory inside the
|
||||
// tarball; Fetch returns the path to that extracted directory.
|
||||
package pkgmgr
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"compress/gzip"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// FetchResult describes a package fetched to a temporary directory.
|
||||
type FetchResult struct {
|
||||
// Dir is the extracted package directory (the tarball's "package/" root).
|
||||
Dir string
|
||||
// TempRoot is the temporary directory holding Dir and the tarball; the
|
||||
// caller must Cleanup it when done.
|
||||
TempRoot string
|
||||
}
|
||||
|
||||
// Cleanup removes the temporary directory tree created by Fetch. Safe to call
|
||||
// on a zero FetchResult (no-op).
|
||||
func (r FetchResult) Cleanup() error {
|
||||
if r.TempRoot == "" {
|
||||
return nil
|
||||
}
|
||||
return os.RemoveAll(r.TempRoot)
|
||||
}
|
||||
|
||||
// npmExecutable is the npm binary name; a variable so tests can stub it.
|
||||
var npmExecutable = "npm"
|
||||
|
||||
// EnsureNPM reports an actionable error when npm is not on PATH. The install
|
||||
// command calls this before doing any work so it fails fast with guidance
|
||||
// rather than deep inside a fetch.
|
||||
func EnsureNPM() error {
|
||||
if _, err := exec.LookPath(npmExecutable); err != nil {
|
||||
return fmt.Errorf("npm not found; install Node.js/npm to use pigo install")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Fetch downloads the package named by ref using `npm pack` and extracts it into
|
||||
// a fresh temporary directory. On success the caller owns the returned
|
||||
// FetchResult and must call Cleanup. On any failure the temporary directory is
|
||||
// removed before returning, so a failed fetch leaves nothing behind.
|
||||
//
|
||||
// npm's own error output (unknown package, network failure, auth) is included
|
||||
// in the returned error so the user sees why the fetch failed.
|
||||
func Fetch(ref PackageRef) (FetchResult, error) {
|
||||
if err := EnsureNPM(); err != nil {
|
||||
return FetchResult{}, err
|
||||
}
|
||||
|
||||
tmp, err := os.MkdirTemp("", "pigo-pkg-*")
|
||||
if err != nil {
|
||||
return FetchResult{}, fmt.Errorf("pkgmgr: create temp dir: %w", err)
|
||||
}
|
||||
// From here on, remove tmp on any error path.
|
||||
fail := func(e error) (FetchResult, error) {
|
||||
_ = os.RemoveAll(tmp)
|
||||
return FetchResult{}, e
|
||||
}
|
||||
|
||||
spec := ref.Name
|
||||
if ref.Version != "" {
|
||||
spec += "@" + ref.Version
|
||||
}
|
||||
|
||||
// `npm pack <spec>` writes a .tgz into --pack-destination and prints its
|
||||
// filename. --ignore-scripts guards against packing-time script execution.
|
||||
cmd := exec.Command(npmExecutable, "pack", spec,
|
||||
"--pack-destination", tmp,
|
||||
"--ignore-scripts",
|
||||
"--loglevel", "error")
|
||||
var stderr strings.Builder
|
||||
cmd.Stderr = &stderr
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
msg := strings.TrimSpace(stderr.String())
|
||||
if msg == "" {
|
||||
msg = err.Error()
|
||||
}
|
||||
return fail(fmt.Errorf("npm pack %s failed: %s", spec, msg))
|
||||
}
|
||||
|
||||
tarball, err := locateTarball(tmp, out)
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
|
||||
dest := filepath.Join(tmp, "extracted")
|
||||
if err := extractTarGz(tarball, dest); err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
// npm packs into a top-level "package/" directory.
|
||||
pkgDir := filepath.Join(dest, "package")
|
||||
if _, err := os.Stat(pkgDir); err != nil {
|
||||
return fail(fmt.Errorf("pkgmgr: extracted tarball missing package/ dir: %w", err))
|
||||
}
|
||||
return FetchResult{Dir: pkgDir, TempRoot: tmp}, nil
|
||||
}
|
||||
|
||||
// locateTarball resolves the .tgz path that `npm pack` produced. npm prints the
|
||||
// tarball filename on stdout; when that is unhelpful we fall back to scanning
|
||||
// the destination directory for a single .tgz.
|
||||
func locateTarball(dir string, packStdout []byte) (string, error) {
|
||||
if name := strings.TrimSpace(string(packStdout)); name != "" {
|
||||
// npm may print just the filename; join to dir if it isn't absolute.
|
||||
cand := name
|
||||
if !filepath.IsAbs(cand) {
|
||||
cand = filepath.Join(dir, filepath.Base(name))
|
||||
}
|
||||
if _, err := os.Stat(cand); err == nil {
|
||||
return cand, nil
|
||||
}
|
||||
}
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("pkgmgr: read pack dir: %w", err)
|
||||
}
|
||||
for _, e := range entries {
|
||||
if strings.HasSuffix(e.Name(), ".tgz") {
|
||||
return filepath.Join(dir, e.Name()), nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("pkgmgr: npm pack produced no .tgz in %s", dir)
|
||||
}
|
||||
|
||||
// extractTarGz extracts a gzip-compressed tar archive into dest, creating dest.
|
||||
// It guards against path traversal (a "../" entry escaping dest) and skips any
|
||||
// entry that is not a regular file or directory.
|
||||
func extractTarGz(tarball, dest string) error {
|
||||
f, err := os.Open(tarball)
|
||||
if err != nil {
|
||||
return fmt.Errorf("pkgmgr: open tarball: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
gz, err := gzip.NewReader(f)
|
||||
if err != nil {
|
||||
return fmt.Errorf("pkgmgr: gzip reader: %w", err)
|
||||
}
|
||||
defer gz.Close()
|
||||
|
||||
if err := os.MkdirAll(dest, 0o755); err != nil {
|
||||
return fmt.Errorf("pkgmgr: create extract dir: %w", err)
|
||||
}
|
||||
|
||||
tr := tar.NewReader(gz)
|
||||
for {
|
||||
hdr, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("pkgmgr: read tar: %w", err)
|
||||
}
|
||||
target, err := safeJoin(dest, hdr.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch hdr.Typeflag {
|
||||
case tar.TypeDir:
|
||||
if err := os.MkdirAll(target, 0o755); err != nil {
|
||||
return fmt.Errorf("pkgmgr: mkdir %q: %w", target, err)
|
||||
}
|
||||
case tar.TypeReg:
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
|
||||
return fmt.Errorf("pkgmgr: mkdir parent of %q: %w", target, err)
|
||||
}
|
||||
if err := writeFile(target, tr, os.FileMode(hdr.Mode)); err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
// Skip symlinks, devices, etc. — npm packages are files + dirs.
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// safeJoin joins name onto base, rejecting any result that escapes base (a
|
||||
// tarball path-traversal guard).
|
||||
func safeJoin(base, name string) (string, error) {
|
||||
target := filepath.Join(base, name)
|
||||
cleanBase := filepath.Clean(base) + string(os.PathSeparator)
|
||||
if target != filepath.Clean(base) && !strings.HasPrefix(target, cleanBase) {
|
||||
return "", fmt.Errorf("pkgmgr: tarball entry %q escapes extract dir", name)
|
||||
}
|
||||
return target, nil
|
||||
}
|
||||
|
||||
// writeFile writes the tar entry body to target with the given mode.
|
||||
func writeFile(target string, r io.Reader, mode os.FileMode) error {
|
||||
out, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode)
|
||||
if err != nil {
|
||||
return fmt.Errorf("pkgmgr: create %q: %w", target, err)
|
||||
}
|
||||
defer out.Close()
|
||||
if _, err := io.Copy(out, r); err != nil {
|
||||
return fmt.Errorf("pkgmgr: write %q: %w", target, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package pkgmgr
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"compress/gzip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// makeTarGz writes a gzip tarball at path containing the given files, each under
|
||||
// a top-level "package/" dir (mirroring npm pack layout).
|
||||
func makeTarGz(t *testing.T, path string, files map[string]string) {
|
||||
t.Helper()
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer f.Close()
|
||||
gz := gzip.NewWriter(f)
|
||||
defer gz.Close()
|
||||
tw := tar.NewWriter(gz)
|
||||
defer tw.Close()
|
||||
for name, body := range files {
|
||||
hdr := &tar.Header{Name: name, Mode: 0o644, Size: int64(len(body)), Typeflag: tar.TypeReg}
|
||||
if err := tw.WriteHeader(hdr); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := tw.Write([]byte(body)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestExtractTarGz verifies a tarball extracts with its files intact.
|
||||
func TestExtractTarGz(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
tarball := filepath.Join(tmp, "pkg.tgz")
|
||||
makeTarGz(t, tarball, map[string]string{
|
||||
"package/package.json": `{"name":"x","version":"1.0.0"}`,
|
||||
"package/index.js": "console.log('hi')",
|
||||
})
|
||||
dest := filepath.Join(tmp, "out")
|
||||
if err := extractTarGz(tarball, dest); err != nil {
|
||||
t.Fatalf("extractTarGz: %v", err)
|
||||
}
|
||||
got, err := os.ReadFile(filepath.Join(dest, "package", "package.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("read extracted: %v", err)
|
||||
}
|
||||
if string(got) != `{"name":"x","version":"1.0.0"}` {
|
||||
t.Errorf("extracted content = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSafeJoinRejectsTraversal verifies a "../" tarball entry is rejected.
|
||||
func TestSafeJoinRejectsTraversal(t *testing.T) {
|
||||
if _, err := safeJoin("/tmp/extract", "../../etc/passwd"); err == nil {
|
||||
t.Error("safeJoin allowed path traversal, want error")
|
||||
}
|
||||
if _, err := safeJoin("/tmp/extract", "package/index.js"); err != nil {
|
||||
t.Errorf("safeJoin rejected legit path: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEnsureNPMMissing verifies a clear error when npm is absent.
|
||||
func TestEnsureNPMMissing(t *testing.T) {
|
||||
old := npmExecutable
|
||||
npmExecutable = "definitely-not-a-real-binary-xyz"
|
||||
defer func() { npmExecutable = old }()
|
||||
err := EnsureNPM()
|
||||
if err == nil {
|
||||
t.Fatal("EnsureNPM with missing npm = nil, want error")
|
||||
}
|
||||
if !contains(err.Error(), "npm not found") {
|
||||
t.Errorf("error = %q, want to mention 'npm not found'", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFetchWithFakeNPM drives Fetch end-to-end using a fake `npm` on PATH that
|
||||
// produces a tarball, verifying extraction and cleanup without a real registry.
|
||||
func TestFetchWithFakeNPM(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("fake npm shell script is POSIX-only")
|
||||
}
|
||||
binDir := t.TempDir()
|
||||
// The fake npm: on `pack`, copy a prebuilt tarball into --pack-destination
|
||||
// and print its filename, mimicking real npm pack output.
|
||||
srcTarball := filepath.Join(binDir, "src.tgz")
|
||||
makeTarGz(t, srcTarball, map[string]string{
|
||||
"package/package.json": `{"name":"pi-demo","version":"2.0.0"}`,
|
||||
})
|
||||
fakeNPM := filepath.Join(binDir, "npm")
|
||||
script := `#!/bin/sh
|
||||
# args: pack <spec> --pack-destination <dir> --ignore-scripts --loglevel error
|
||||
dest=""
|
||||
prev=""
|
||||
for a in "$@"; do
|
||||
if [ "$prev" = "--pack-destination" ]; then dest="$a"; fi
|
||||
prev="$a"
|
||||
done
|
||||
cp "` + srcTarball + `" "$dest/pi-demo-2.0.0.tgz"
|
||||
echo "pi-demo-2.0.0.tgz"
|
||||
`
|
||||
if err := os.WriteFile(fakeNPM, []byte(script), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||
|
||||
ref, err := ParsePackageRef("npm:pi-demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res, err := Fetch(ref)
|
||||
if err != nil {
|
||||
t.Fatalf("Fetch: %v", err)
|
||||
}
|
||||
defer res.Cleanup()
|
||||
|
||||
pj, err := os.ReadFile(filepath.Join(res.Dir, "package.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("read fetched package.json: %v", err)
|
||||
}
|
||||
if !contains(string(pj), `"pi-demo"`) {
|
||||
t.Errorf("package.json = %q", pj)
|
||||
}
|
||||
|
||||
// Cleanup removes the temp root.
|
||||
root := res.TempRoot
|
||||
if err := res.Cleanup(); err != nil {
|
||||
t.Fatalf("Cleanup: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(root); !os.IsNotExist(err) {
|
||||
t.Errorf("temp root still exists after Cleanup: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFetchNPMFailurePropagates verifies npm's error is surfaced and no temp
|
||||
// dir is left behind.
|
||||
func TestFetchNPMFailurePropagates(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("fake npm shell script is POSIX-only")
|
||||
}
|
||||
binDir := t.TempDir()
|
||||
fakeNPM := filepath.Join(binDir, "npm")
|
||||
script := `#!/bin/sh
|
||||
echo "npm error code E404" >&2
|
||||
echo "npm error 404 Not Found - GET https://registry.npmjs.org/nope" >&2
|
||||
exit 1
|
||||
`
|
||||
if err := os.WriteFile(fakeNPM, []byte(script), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||
|
||||
ref, _ := ParsePackageRef("npm:nope")
|
||||
_, err := Fetch(ref)
|
||||
if err == nil {
|
||||
t.Fatal("Fetch of failing npm = nil error, want error")
|
||||
}
|
||||
if !contains(err.Error(), "E404") {
|
||||
t.Errorf("error = %q, want npm stderr included", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
// This file is the install orchestrator (#162): it ties together the pieces the
|
||||
// earlier issues built — fetch (#156), classify (#157), the per-type
|
||||
// distributors (#158-#161), and the lockfile (#154) — into the single
|
||||
// `pigo install npm:<name>` flow.
|
||||
//
|
||||
// The flow is: parse the reference, fetch+extract the package to a temp dir,
|
||||
// classify it into one or more pi types, distribute each type to its target
|
||||
// directory, then record everything laid down in the lockfile so list/uninstall
|
||||
// /update can act on it. The temp dir is always cleaned up.
|
||||
package pkgmgr
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// InstallResult reports what an install did, for the CLI to print.
|
||||
type InstallResult struct {
|
||||
Name string
|
||||
Version string
|
||||
Types []PackageType
|
||||
// Files is every path laid down on disk across all distributed types.
|
||||
Files []string
|
||||
}
|
||||
|
||||
// Install fetches, classifies, and distributes the package named by rawRef
|
||||
// (e.g. "npm:pi-mcp-adapter"), then records it in the lockfile at lockfilePath.
|
||||
// Progress is written to logw when non-nil. It returns a summary of the install.
|
||||
//
|
||||
// The install is type-driven: a package classified as several types (e.g.
|
||||
// extension+skill) is distributed to each corresponding directory. If any
|
||||
// distribution step fails, the error is returned; already-written files are left
|
||||
// for the caller/uninstall to reconcile via a re-run (distribution is
|
||||
// idempotent — each distributor clears its own stale target first).
|
||||
func Install(rawRef, lockfilePath string, logw io.Writer) (InstallResult, error) {
|
||||
logf := func(format string, a ...any) {
|
||||
if logw != nil {
|
||||
fmt.Fprintf(logw, format, a...)
|
||||
}
|
||||
}
|
||||
|
||||
ref, err := ParsePackageRef(rawRef)
|
||||
if err != nil {
|
||||
return InstallResult{}, err
|
||||
}
|
||||
|
||||
logf("Fetching %s ...\n", ref.String())
|
||||
fetched, err := Fetch(ref)
|
||||
if err != nil {
|
||||
return InstallResult{}, err
|
||||
}
|
||||
defer fetched.Cleanup()
|
||||
|
||||
return installFetched(fetched.Dir, ref, lockfilePath, logf)
|
||||
}
|
||||
|
||||
// installFetched runs the post-fetch half of an install: classify the already
|
||||
// extracted package at pkgDir, distribute each type, and record the result in
|
||||
// the lockfile. It is shared by Install (#162) and Update (#164) so both go
|
||||
// through the same classify→distribute→lockfile path.
|
||||
func installFetched(pkgDir string, ref PackageRef, lockfilePath string, logf func(string, ...any)) (InstallResult, error) {
|
||||
name, version, types, err := Classify(pkgDir)
|
||||
if err != nil {
|
||||
return InstallResult{}, err
|
||||
}
|
||||
logf("Installing %s@%s (%s)\n", name, version, joinTypes(types))
|
||||
|
||||
var files []string
|
||||
for _, t := range types {
|
||||
created, derr := distribute(t, pkgDir, name)
|
||||
if derr != nil {
|
||||
return InstallResult{}, derr
|
||||
}
|
||||
files = append(files, created...)
|
||||
}
|
||||
sort.Strings(files)
|
||||
|
||||
lf, err := Load(lockfilePath)
|
||||
if err != nil {
|
||||
return InstallResult{}, err
|
||||
}
|
||||
lf.Set(InstalledPackage{
|
||||
Name: name,
|
||||
Source: ref.String(),
|
||||
Version: version,
|
||||
Types: types,
|
||||
Files: files,
|
||||
})
|
||||
if err := lf.Save(); err != nil {
|
||||
return InstallResult{}, err
|
||||
}
|
||||
|
||||
return InstallResult{Name: name, Version: version, Types: types, Files: files}, nil
|
||||
}
|
||||
|
||||
// distribute routes one package type to its distributor. An unknown type is an
|
||||
// error (Classify should never produce one, but guard anyway).
|
||||
func distribute(t PackageType, pkgDir, name string) ([]string, error) {
|
||||
switch t {
|
||||
case TypeExtension:
|
||||
return DistributeExtension(pkgDir, name)
|
||||
case TypeSkill:
|
||||
return DistributeSkill(pkgDir, name)
|
||||
case TypePrompt:
|
||||
return DistributePrompt(pkgDir, name)
|
||||
case TypeTheme:
|
||||
return DistributeTheme(pkgDir, name)
|
||||
default:
|
||||
return nil, fmt.Errorf("pkgmgr: cannot distribute unknown type %q", t)
|
||||
}
|
||||
}
|
||||
|
||||
// joinTypes renders a type slice as a comma-separated string for logging.
|
||||
func joinTypes(types []PackageType) string {
|
||||
parts := make([]string, len(types))
|
||||
for i, t := range types {
|
||||
parts[i] = string(t)
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package pkgmgr
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// fakeNPMForInstall writes a fake `npm` onto PATH that packs a prebuilt tarball
|
||||
// (built from files) into --pack-destination, mimicking `npm pack`.
|
||||
func fakeNPMForInstall(t *testing.T, tarballFiles map[string]string, packName string) {
|
||||
t.Helper()
|
||||
binDir := t.TempDir()
|
||||
srcTarball := filepath.Join(binDir, "src.tgz")
|
||||
makeTarGz(t, srcTarball, tarballFiles)
|
||||
script := `#!/bin/sh
|
||||
dest=""
|
||||
prev=""
|
||||
for a in "$@"; do
|
||||
if [ "$prev" = "--pack-destination" ]; then dest="$a"; fi
|
||||
prev="$a"
|
||||
done
|
||||
cp "` + srcTarball + `" "$dest/` + packName + `"
|
||||
echo "` + packName + `"
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(binDir, "npm"), []byte(script), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||
}
|
||||
|
||||
// TestInstallExtensionEndToEnd drives Install for an extension package through
|
||||
// fetch (fake npm) → classify → distribute → lockfile.
|
||||
func TestInstallExtensionEndToEnd(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("fake npm shell script + extension install are POSIX-only")
|
||||
}
|
||||
home := t.TempDir()
|
||||
t.Setenv("PIGO_HOME", home)
|
||||
|
||||
fakeNPMForInstall(t, map[string]string{
|
||||
"package/package.json": `{"name":"pi-demo","version":"1.2.0","bin":"./cli.js"}`,
|
||||
"package/cli.js": "#!/usr/bin/env node\nconsole.log('hi')\n",
|
||||
}, "pi-demo-1.2.0.tgz")
|
||||
|
||||
lockPath := filepath.Join(home, "packages.json")
|
||||
res, err := Install("npm:pi-demo", lockPath, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Install: %v", err)
|
||||
}
|
||||
if res.Name != "pi-demo" || res.Version != "1.2.0" {
|
||||
t.Errorf("result = %+v, want pi-demo@1.2.0", res)
|
||||
}
|
||||
if len(res.Types) != 1 || res.Types[0] != TypeExtension {
|
||||
t.Errorf("types = %v, want [extension]", res.Types)
|
||||
}
|
||||
|
||||
// Launcher exists in plugins.
|
||||
if _, err := os.Stat(filepath.Join(home, "plugins", "pi-demo")); err != nil {
|
||||
t.Errorf("launcher not installed: %v", err)
|
||||
}
|
||||
|
||||
// Lockfile records the package.
|
||||
lf, err := Load(lockPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Load lockfile: %v", err)
|
||||
}
|
||||
p, ok := lf.Get("pi-demo")
|
||||
if !ok {
|
||||
t.Fatal("lockfile missing pi-demo")
|
||||
}
|
||||
if p.Source != "npm:pi-demo" || p.Version != "1.2.0" {
|
||||
t.Errorf("lockfile entry = %+v", p)
|
||||
}
|
||||
if len(p.Files) == 0 {
|
||||
t.Error("lockfile entry has no files")
|
||||
}
|
||||
}
|
||||
|
||||
// TestInstallMultiType drives Install for a package that is both extension and
|
||||
// skill, verifying both distributions happen and both types are recorded.
|
||||
func TestInstallMultiType(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("POSIX-only")
|
||||
}
|
||||
home := t.TempDir()
|
||||
skills := t.TempDir()
|
||||
t.Setenv("PIGO_HOME", home)
|
||||
t.Setenv("PIGO_SKILLS_DIR", skills)
|
||||
|
||||
fakeNPMForInstall(t, map[string]string{
|
||||
"package/package.json": `{"name":"combo","version":"1.0.0","bin":"./x.js","pi":{"types":["extension","skill"]}}`,
|
||||
"package/x.js": "#!/usr/bin/env node\n",
|
||||
"package/SKILL.md": "---\nname: combo\ndescription: d\n---\nbody",
|
||||
}, "combo-1.0.0.tgz")
|
||||
|
||||
res, err := Install("npm:combo", filepath.Join(home, "packages.json"), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Install: %v", err)
|
||||
}
|
||||
if len(res.Types) != 2 {
|
||||
t.Errorf("types = %v, want extension+skill", res.Types)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(home, "plugins", "combo")); err != nil {
|
||||
t.Errorf("extension launcher missing: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(skills, "combo", "SKILL.md")); err != nil {
|
||||
t.Errorf("skill not installed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInstallUnrecognized verifies a non-pi package fails install with a clear
|
||||
// error and writes no lockfile entry.
|
||||
func TestInstallUnrecognized(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("POSIX-only")
|
||||
}
|
||||
home := t.TempDir()
|
||||
t.Setenv("PIGO_HOME", home)
|
||||
|
||||
fakeNPMForInstall(t, map[string]string{
|
||||
"package/package.json": `{"name":"lodash","version":"4.0.0"}`,
|
||||
}, "lodash-4.0.0.tgz")
|
||||
|
||||
lockPath := filepath.Join(home, "packages.json")
|
||||
if _, err := Install("npm:lodash", lockPath, nil); err == nil {
|
||||
t.Fatal("Install of non-pi package = nil error, want error")
|
||||
}
|
||||
if _, err := os.Stat(lockPath); !os.IsNotExist(err) {
|
||||
t.Errorf("lockfile written for failed install: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInstallBadRef verifies an invalid reference is rejected before any fetch.
|
||||
func TestInstallBadRef(t *testing.T) {
|
||||
if _, err := Install("github:owner/repo", filepath.Join(t.TempDir(), "packages.json"), nil); err == nil {
|
||||
t.Fatal("Install with non-npm ref = nil error, want error")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
// This file defines pigo's install directory layout (#154): where each pi
|
||||
// package type is placed so pigo's existing discovery mechanisms load it with
|
||||
// no extra configuration. The paths intentionally match the conventions already
|
||||
// used elsewhere in cmd/pigo and internal/*:
|
||||
//
|
||||
// - extensions → $PIGO_HOME/plugins (internal/plugin.Discover)
|
||||
// - prompts → $PIGO_HOME/commands (runtime.LoadUserCommandsDir)
|
||||
// - themes → $PIGO_HOME/themes (no runtime consumer yet)
|
||||
// - skills → skills dir (~/.agents/skills, PIGO_SKILLS_DIR override)
|
||||
//
|
||||
// Skills are the one exception to the $PIGO_HOME root: pigo loads skills from
|
||||
// ~/.agents/skills (overridable with PIGO_SKILLS_DIR), so SkillsDir honors that
|
||||
// rather than nesting under $PIGO_HOME.
|
||||
package pkgmgr
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// Home returns the pigo home directory: $PIGO_HOME, or ~/.pigo when unset. It
|
||||
// returns "" when the home directory cannot be resolved and no override is set,
|
||||
// matching trust.DefaultPath's "unavailable" contract.
|
||||
func Home() string {
|
||||
if dir := os.Getenv("PIGO_HOME"); dir != "" {
|
||||
return dir
|
||||
}
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(home, ".pigo")
|
||||
}
|
||||
|
||||
// PluginsDir returns $PIGO_HOME/plugins, where installed extensions (including
|
||||
// MCP adapters) are laid down for internal/plugin.Discover. It returns "" when
|
||||
// Home is unavailable.
|
||||
func PluginsDir() string {
|
||||
h := Home()
|
||||
if h == "" {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(h, "plugins")
|
||||
}
|
||||
|
||||
// CommandsDir returns $PIGO_HOME/commands, the legacy location for installed
|
||||
// prompt/command templates (still loaded by runtime.LoadUserCommandsDir for
|
||||
// back-compat). New installs go to PromptsDir. It returns "" when Home is
|
||||
// unavailable.
|
||||
func CommandsDir() string {
|
||||
h := Home()
|
||||
if h == "" {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(h, "commands")
|
||||
}
|
||||
|
||||
// PromptsDir returns $PIGO_HOME/prompts, the pi-aligned location where installed
|
||||
// prompt templates are laid down for runtime.LoadUserCommandsDir (which loads
|
||||
// both prompts/ and the legacy commands/). It returns "" when Home is
|
||||
// unavailable.
|
||||
func PromptsDir() string {
|
||||
h := Home()
|
||||
if h == "" {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(h, "prompts")
|
||||
}
|
||||
|
||||
// ThemesDir returns $PIGO_HOME/themes, where installed themes are stored. pigo
|
||||
// has no theme runtime yet, so this is a holding location for a future consumer.
|
||||
// It returns "" when Home is unavailable.
|
||||
func ThemesDir() string {
|
||||
h := Home()
|
||||
if h == "" {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(h, "themes")
|
||||
}
|
||||
|
||||
// SkillsDir returns the directory installed skills are placed in: PIGO_SKILLS_DIR
|
||||
// when set, else ~/.agents/skills — matching cmd/pigo's skill loader. It returns
|
||||
// "" when the home directory cannot be resolved and no override is set.
|
||||
func SkillsDir() string {
|
||||
if dir := os.Getenv("PIGO_SKILLS_DIR"); dir != "" {
|
||||
return dir
|
||||
}
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(home, ".agents", "skills")
|
||||
}
|
||||
|
||||
// DirForType returns the install directory for a package type, or "" when the
|
||||
// type is unknown or the underlying home directory is unavailable.
|
||||
func DirForType(t PackageType) string {
|
||||
switch t {
|
||||
case TypeExtension:
|
||||
return PluginsDir()
|
||||
case TypePrompt:
|
||||
return CommandsDir()
|
||||
case TypeTheme:
|
||||
return ThemesDir()
|
||||
case TypeSkill:
|
||||
return SkillsDir()
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package pkgmgr
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestHomeHonorsPIGOHOME verifies Home prefers PIGO_HOME over the default.
|
||||
func TestHomeHonorsPIGOHOME(t *testing.T) {
|
||||
t.Setenv("PIGO_HOME", "/custom/pigo")
|
||||
if got := Home(); got != "/custom/pigo" {
|
||||
t.Errorf("Home() = %q, want /custom/pigo", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTypeDirsUnderHome verifies the plugins/commands/themes dirs nest under
|
||||
// $PIGO_HOME.
|
||||
func TestTypeDirsUnderHome(t *testing.T) {
|
||||
t.Setenv("PIGO_HOME", "/custom/pigo")
|
||||
cases := map[PackageType]string{
|
||||
TypeExtension: "/custom/pigo/plugins",
|
||||
TypePrompt: "/custom/pigo/commands",
|
||||
TypeTheme: "/custom/pigo/themes",
|
||||
}
|
||||
for typ, want := range cases {
|
||||
if got := DirForType(typ); got != want {
|
||||
t.Errorf("DirForType(%s) = %q, want %q", typ, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSkillsDirHonorsOverride verifies skills use PIGO_SKILLS_DIR, not $PIGO_HOME.
|
||||
func TestSkillsDirHonorsOverride(t *testing.T) {
|
||||
t.Setenv("PIGO_SKILLS_DIR", "/custom/skills")
|
||||
if got := SkillsDir(); got != "/custom/skills" {
|
||||
t.Errorf("SkillsDir() = %q, want /custom/skills", got)
|
||||
}
|
||||
if got := DirForType(TypeSkill); got != "/custom/skills" {
|
||||
t.Errorf("DirForType(skill) = %q, want /custom/skills", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSkillsDirDefault verifies skills default to ~/.agents/skills.
|
||||
func TestSkillsDirDefault(t *testing.T) {
|
||||
t.Setenv("PIGO_SKILLS_DIR", "")
|
||||
t.Setenv("HOME", "/home/tester")
|
||||
want := filepath.Join("/home/tester", ".agents", "skills")
|
||||
if got := SkillsDir(); got != want {
|
||||
t.Errorf("SkillsDir() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDirForUnknownType verifies an unknown type yields "".
|
||||
func TestDirForUnknownType(t *testing.T) {
|
||||
if got := DirForType(PackageType("bogus")); got != "" {
|
||||
t.Errorf("DirForType(bogus) = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
// Package pkgmgr implements pigo's pi-package installer state and layout
|
||||
// (#154). A pi package is an add-on published to npm — an extension (often an
|
||||
// MCP adapter), a skill, a prompt/command template, or a theme — installed with
|
||||
// `pigo install npm:<name>`. This package owns two concerns that the install /
|
||||
// list / uninstall / update commands all build on:
|
||||
//
|
||||
// - The lockfile: a JSON record at $PIGO_HOME/packages.json of every installed
|
||||
// package (name, source, version, types, and the exact files laid down on
|
||||
// disk). It is the source of truth for list/uninstall/update, so removal and
|
||||
// upgrade can find and clean up precisely what an install created.
|
||||
// - The directory layout: where each package type is placed so pigo's existing
|
||||
// discovery mechanisms pick it up without extra configuration — extensions
|
||||
// under $PIGO_HOME/plugins, skills under the skills dir, prompts under
|
||||
// $PIGO_HOME/commands, themes under $PIGO_HOME/themes.
|
||||
//
|
||||
// This file defines the lockfile wire types and their load/save, mirroring the
|
||||
// conventions already used by internal/trust: a missing file is an empty
|
||||
// lockfile (not an error), while a present-but-malformed file is a hard error so
|
||||
// a corrupted store is surfaced rather than silently overwritten.
|
||||
package pkgmgr
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// PackageType is one of the pi package kinds pigo can install. A single package
|
||||
// may declare several (the npm catalog has combined "extensionskill" entries),
|
||||
// so an InstalledPackage carries a slice of these.
|
||||
type PackageType string
|
||||
|
||||
const (
|
||||
// TypeExtension is an executable extension (including MCP adapters); it is
|
||||
// laid down under $PIGO_HOME/plugins and discovered by internal/plugin.
|
||||
TypeExtension PackageType = "extension"
|
||||
// TypeSkill is a skill bundle placed under the skills directory.
|
||||
TypeSkill PackageType = "skill"
|
||||
// TypePrompt is a prompt/command template placed under $PIGO_HOME/commands.
|
||||
TypePrompt PackageType = "prompt"
|
||||
// TypeTheme is a theme; pigo has no theme runtime yet, so it is only stored
|
||||
// under $PIGO_HOME/themes for a future consumer.
|
||||
TypeTheme PackageType = "theme"
|
||||
)
|
||||
|
||||
// InstalledPackage is one entry in the lockfile: everything needed to describe,
|
||||
// upgrade, or remove a package that was installed.
|
||||
type InstalledPackage struct {
|
||||
// Name is the package's identifier (the npm package name).
|
||||
Name string `json:"name"`
|
||||
// Source is the original install reference, e.g. "npm:pi-mcp-adapter".
|
||||
Source string `json:"source"`
|
||||
// Version is the resolved, installed version string.
|
||||
Version string `json:"version"`
|
||||
// Types are the pi package kinds this package was classified as (one or more).
|
||||
Types []PackageType `json:"types"`
|
||||
// Files are the absolute paths of every file laid down on disk for this
|
||||
// package, so uninstall/update can remove exactly what was created.
|
||||
Files []string `json:"files"`
|
||||
}
|
||||
|
||||
// Lockfile is the on-disk record of all installed packages, keyed by package
|
||||
// name. The zero value is not usable; obtain one via Load.
|
||||
type Lockfile struct {
|
||||
// Version is the lockfile schema version, for forward migration.
|
||||
Version int `json:"version"`
|
||||
// Packages maps package name to its installed record.
|
||||
Packages map[string]InstalledPackage `json:"packages"`
|
||||
|
||||
// path is where Save writes; not serialized.
|
||||
path string `json:"-"`
|
||||
}
|
||||
|
||||
// lockfileVersion is the current schema version written by Save.
|
||||
const lockfileVersion = 1
|
||||
|
||||
// DefaultLockfilePath returns the lockfile location: $PIGO_HOME/packages.json,
|
||||
// or ~/.pigo/packages.json when PIGO_HOME is unset. It returns "" when the home
|
||||
// directory cannot be resolved and no override is set, mirroring
|
||||
// trust.DefaultPath so the caller can treat the store as unavailable rather than
|
||||
// guessing a path.
|
||||
func DefaultLockfilePath() string {
|
||||
if dir := os.Getenv("PIGO_HOME"); dir != "" {
|
||||
return filepath.Join(dir, "packages.json")
|
||||
}
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(home, ".pigo", "packages.json")
|
||||
}
|
||||
|
||||
// Load reads the lockfile at path. A missing file is not an error: it yields an
|
||||
// empty lockfile whose Save will create the file. A present-but-malformed file
|
||||
// is a hard error so a corrupted store is surfaced rather than silently
|
||||
// overwritten. An empty path yields an in-memory-only lockfile (Save is a no-op).
|
||||
func Load(path string) (*Lockfile, error) {
|
||||
lf := &Lockfile{
|
||||
Version: lockfileVersion,
|
||||
Packages: make(map[string]InstalledPackage),
|
||||
path: path,
|
||||
}
|
||||
if path == "" {
|
||||
return lf, nil
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return lf, nil // no lockfile yet → empty
|
||||
}
|
||||
return nil, fmt.Errorf("pkgmgr: read lockfile %q: %w", path, err)
|
||||
}
|
||||
if err := json.Unmarshal(data, lf); err != nil {
|
||||
return nil, fmt.Errorf("pkgmgr: parse lockfile %q: %w", path, err)
|
||||
}
|
||||
if lf.Packages == nil {
|
||||
lf.Packages = make(map[string]InstalledPackage)
|
||||
}
|
||||
lf.path = path
|
||||
return lf, nil
|
||||
}
|
||||
|
||||
// Save writes the lockfile to its path as human-readable, indented JSON, with
|
||||
// package keys in sorted order for a stable diff. Save creates the parent
|
||||
// directory if needed. It is a no-op when the lockfile has no path (empty-path
|
||||
// Load), so in-memory use never touches disk.
|
||||
func (lf *Lockfile) Save() error {
|
||||
if lf.path == "" {
|
||||
return nil
|
||||
}
|
||||
if lf.Version == 0 {
|
||||
lf.Version = lockfileVersion
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(lf.path), 0o755); err != nil {
|
||||
return fmt.Errorf("pkgmgr: create lockfile dir: %w", err)
|
||||
}
|
||||
data, err := json.MarshalIndent(lf, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("pkgmgr: encode lockfile: %w", err)
|
||||
}
|
||||
data = append(data, '\n')
|
||||
if err := os.WriteFile(lf.path, data, 0o644); err != nil {
|
||||
return fmt.Errorf("pkgmgr: write lockfile %q: %w", lf.path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get returns the installed record for name and whether it exists.
|
||||
func (lf *Lockfile) Get(name string) (InstalledPackage, bool) {
|
||||
p, ok := lf.Packages[name]
|
||||
return p, ok
|
||||
}
|
||||
|
||||
// Set records (or replaces) a package entry in memory. Call Save to persist.
|
||||
func (lf *Lockfile) Set(p InstalledPackage) {
|
||||
if lf.Packages == nil {
|
||||
lf.Packages = make(map[string]InstalledPackage)
|
||||
}
|
||||
lf.Packages[p.Name] = p
|
||||
}
|
||||
|
||||
// Remove deletes the entry for name in memory, reporting whether it existed.
|
||||
// Call Save to persist.
|
||||
func (lf *Lockfile) Remove(name string) bool {
|
||||
if _, ok := lf.Packages[name]; !ok {
|
||||
return false
|
||||
}
|
||||
delete(lf.Packages, name)
|
||||
return true
|
||||
}
|
||||
|
||||
// List returns all installed packages sorted by name, for stable `pigo list`
|
||||
// output.
|
||||
func (lf *Lockfile) List() []InstalledPackage {
|
||||
out := make([]InstalledPackage, 0, len(lf.Packages))
|
||||
for _, p := range lf.Packages {
|
||||
out = append(out, p)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package pkgmgr
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestLoadMissingFileIsEmpty verifies a missing lockfile yields an empty,
|
||||
// usable lockfile rather than an error.
|
||||
func TestLoadMissingFileIsEmpty(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "packages.json")
|
||||
lf, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Load missing file: %v", err)
|
||||
}
|
||||
if len(lf.Packages) != 0 {
|
||||
t.Errorf("expected empty lockfile, got %d packages", len(lf.Packages))
|
||||
}
|
||||
if lf.Version != lockfileVersion {
|
||||
t.Errorf("version = %d, want %d", lf.Version, lockfileVersion)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSaveThenLoadRoundTrips verifies a written lockfile reads back identically.
|
||||
func TestSaveThenLoadRoundTrips(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "sub", "packages.json") // sub dir must be created
|
||||
lf, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
lf.Set(InstalledPackage{
|
||||
Name: "pi-mcp-adapter",
|
||||
Source: "npm:pi-mcp-adapter",
|
||||
Version: "1.2.3",
|
||||
Types: []PackageType{TypeExtension},
|
||||
Files: []string{"/home/u/.pigo/plugins/pi-mcp-adapter"},
|
||||
})
|
||||
lf.Set(InstalledPackage{
|
||||
Name: "pi-web-access",
|
||||
Source: "npm:pi-web-access",
|
||||
Version: "0.1.0",
|
||||
Types: []PackageType{TypeExtension, TypeSkill},
|
||||
Files: []string{"/home/u/.pigo/plugins/pi-web-access"},
|
||||
})
|
||||
if err := lf.Save(); err != nil {
|
||||
t.Fatalf("Save: %v", err)
|
||||
}
|
||||
|
||||
got, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("reload: %v", err)
|
||||
}
|
||||
if len(got.Packages) != 2 {
|
||||
t.Fatalf("reloaded %d packages, want 2", len(got.Packages))
|
||||
}
|
||||
p, ok := got.Get("pi-web-access")
|
||||
if !ok {
|
||||
t.Fatal("pi-web-access missing after reload")
|
||||
}
|
||||
if p.Version != "0.1.0" || len(p.Types) != 2 {
|
||||
t.Errorf("pi-web-access = %+v, unexpected", p)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSaveIsIndentedJSON verifies the on-disk format is human-readable.
|
||||
func TestSaveIsIndentedJSON(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "packages.json")
|
||||
lf, _ := Load(path)
|
||||
lf.Set(InstalledPackage{Name: "x", Source: "npm:x", Version: "1", Types: []PackageType{TypeSkill}})
|
||||
if err := lf.Save(); err != nil {
|
||||
t.Fatalf("Save: %v", err)
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
if !contains(string(data), "\n ") {
|
||||
t.Errorf("expected indented JSON, got:\n%s", data)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadCorruptFileIsError verifies a malformed lockfile is surfaced, never
|
||||
// silently overwritten.
|
||||
func TestLoadCorruptFileIsError(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "packages.json")
|
||||
if err := os.WriteFile(path, []byte("{not json"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := Load(path); err == nil {
|
||||
t.Fatal("expected error for corrupt lockfile, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRemove verifies Remove reports existence and deletes the entry.
|
||||
func TestRemove(t *testing.T) {
|
||||
lf, _ := Load("") // in-memory
|
||||
lf.Set(InstalledPackage{Name: "a", Source: "npm:a", Version: "1"})
|
||||
if !lf.Remove("a") {
|
||||
t.Error("Remove(a) = false, want true")
|
||||
}
|
||||
if lf.Remove("a") {
|
||||
t.Error("Remove(a) second time = true, want false")
|
||||
}
|
||||
if _, ok := lf.Get("a"); ok {
|
||||
t.Error("a still present after Remove")
|
||||
}
|
||||
}
|
||||
|
||||
// TestListSorted verifies List returns packages ordered by name.
|
||||
func TestListSorted(t *testing.T) {
|
||||
lf, _ := Load("")
|
||||
lf.Set(InstalledPackage{Name: "zebra", Source: "npm:zebra"})
|
||||
lf.Set(InstalledPackage{Name: "alpha", Source: "npm:alpha"})
|
||||
lf.Set(InstalledPackage{Name: "mango", Source: "npm:mango"})
|
||||
got := lf.List()
|
||||
want := []string{"alpha", "mango", "zebra"}
|
||||
for i, p := range got {
|
||||
if p.Name != want[i] {
|
||||
t.Errorf("List()[%d] = %q, want %q", i, p.Name, want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestEmptyPathSaveIsNoop verifies an in-memory lockfile never touches disk.
|
||||
func TestEmptyPathSaveIsNoop(t *testing.T) {
|
||||
lf, _ := Load("")
|
||||
lf.Set(InstalledPackage{Name: "a"})
|
||||
if err := lf.Save(); err != nil {
|
||||
t.Errorf("Save on empty-path lockfile: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func contains(s, sub string) bool {
|
||||
for i := 0; i+len(sub) <= len(s); i++ {
|
||||
if s[i:i+len(sub)] == sub {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
// This file parses and validates the package reference a user passes to
|
||||
// `pigo install` (#155). pi packages are published to npm, so a reference looks
|
||||
// like `npm:pi-mcp-adapter`, `npm:@scope/name`, or either form with a version
|
||||
// suffix (`npm:pi-mcp-adapter@1.2.3`). Parsing is deliberately strict: an
|
||||
// unsupported source prefix or an invalid npm package name is rejected up front,
|
||||
// so the install command fails fast with a clear message rather than handing a
|
||||
// bad name to npm.
|
||||
//
|
||||
// Only the `npm:` source is supported this release (PRD Non-Goals exclude
|
||||
// github:/file:), so Registry is effectively always "npm"; it is kept explicit
|
||||
// so a future source can be added without changing callers.
|
||||
package pkgmgr
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Registry identifies where a package is fetched from. Only npm is supported.
|
||||
type Registry string
|
||||
|
||||
// RegistryNPM is the npm registry, the only supported source this release.
|
||||
const RegistryNPM Registry = "npm"
|
||||
|
||||
// PackageRef is a parsed, validated install reference.
|
||||
type PackageRef struct {
|
||||
// Registry is the source registry (always RegistryNPM this release).
|
||||
Registry Registry
|
||||
// Name is the bare package name, e.g. "pi-mcp-adapter" or "@scope/name".
|
||||
Name string
|
||||
// Version is the requested version (the part after '@'), or "" for latest.
|
||||
Version string
|
||||
// Raw is the original reference as typed, e.g. "npm:pi-mcp-adapter@1.2.3".
|
||||
Raw string
|
||||
}
|
||||
|
||||
// String returns the canonical reference, reconstructed from the parsed parts.
|
||||
func (r PackageRef) String() string {
|
||||
s := string(r.Registry) + ":" + r.Name
|
||||
if r.Version != "" {
|
||||
s += "@" + r.Version
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// ParsePackageRef parses an install reference of the form `npm:<name>[@version]`,
|
||||
// where <name> is a plain (`pi-mcp-adapter`) or scoped (`@scope/name`) npm
|
||||
// package name. It returns an error when the source prefix is missing or
|
||||
// unsupported, or when the package name is invalid.
|
||||
func ParsePackageRef(ref string) (PackageRef, error) {
|
||||
raw := strings.TrimSpace(ref)
|
||||
prefix, rest, found := strings.Cut(raw, ":")
|
||||
if !found || prefix == "" {
|
||||
return PackageRef{}, fmt.Errorf("unsupported package source, expected npm:<name>")
|
||||
}
|
||||
if Registry(prefix) != RegistryNPM {
|
||||
return PackageRef{}, fmt.Errorf("unsupported package source %q, expected npm:<name>", prefix)
|
||||
}
|
||||
if rest == "" {
|
||||
return PackageRef{}, fmt.Errorf("missing package name, expected npm:<name>")
|
||||
}
|
||||
|
||||
name, version := splitNameVersion(rest)
|
||||
if err := validateNPMName(name); err != nil {
|
||||
return PackageRef{}, err
|
||||
}
|
||||
return PackageRef{
|
||||
Registry: RegistryNPM,
|
||||
Name: name,
|
||||
Version: version,
|
||||
Raw: raw,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// splitNameVersion separates a name from an optional trailing "@version". A
|
||||
// leading '@' (scoped package) is preserved: the split happens on the LAST '@'
|
||||
// only when it is not the scope marker, so "@scope/name@1.2.3" yields
|
||||
// ("@scope/name", "1.2.3") and "@scope/name" yields ("@scope/name", "").
|
||||
func splitNameVersion(s string) (name, version string) {
|
||||
// For a scoped name, the first char is '@' and is not a version separator.
|
||||
searchFrom := 0
|
||||
if strings.HasPrefix(s, "@") {
|
||||
searchFrom = 1
|
||||
}
|
||||
if idx := strings.LastIndex(s[searchFrom:], "@"); idx >= 0 {
|
||||
at := searchFrom + idx
|
||||
return s[:at], s[at+1:]
|
||||
}
|
||||
return s, ""
|
||||
}
|
||||
|
||||
// validateNPMName checks a bare npm package name against the parts of the npm
|
||||
// naming rules that matter for safely handing the name to the npm CLI: non-empty,
|
||||
// no whitespace or control characters, no shell-hostile characters, reasonable
|
||||
// length, and — for scoped names — a well-formed "@scope/name" shape. It does not
|
||||
// aim to replicate every nuance of npm's validate-npm-package-name; it rejects
|
||||
// the classes of input that would be unsafe or clearly wrong.
|
||||
func validateNPMName(name string) error {
|
||||
if name == "" {
|
||||
return fmt.Errorf("invalid npm package name: empty")
|
||||
}
|
||||
if len(name) > 214 {
|
||||
return fmt.Errorf("invalid npm package name %q: exceeds 214 characters", name)
|
||||
}
|
||||
if name != strings.ToLower(name) {
|
||||
return fmt.Errorf("invalid npm package name %q: must be lowercase", name)
|
||||
}
|
||||
for _, r := range name {
|
||||
if r <= ' ' || r == '\x7f' {
|
||||
return fmt.Errorf("invalid npm package name %q: contains whitespace or control character", name)
|
||||
}
|
||||
switch r {
|
||||
case '"', '\'', '\\', '`', '$', '(', ')', '<', '>', '|', ';', '&', '*', '?', '#', '%', '^', '{', '}', '[', ']', ',', '!', '~', '=', '+', ':':
|
||||
return fmt.Errorf("invalid npm package name %q: contains illegal character %q", name, string(r))
|
||||
}
|
||||
}
|
||||
|
||||
if strings.HasPrefix(name, "@") {
|
||||
return validateScopedName(name)
|
||||
}
|
||||
// Unscoped names may not start with '.' or '_'.
|
||||
if name[0] == '.' || name[0] == '_' {
|
||||
return fmt.Errorf("invalid npm package name %q: may not start with '.' or '_'", name)
|
||||
}
|
||||
if strings.Contains(name, "/") {
|
||||
return fmt.Errorf("invalid npm package name %q: only scoped names may contain '/'", name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateScopedName validates the "@scope/name" shape for a scoped package.
|
||||
func validateScopedName(name string) error {
|
||||
rest := strings.TrimPrefix(name, "@")
|
||||
scope, pkg, found := strings.Cut(rest, "/")
|
||||
if !found {
|
||||
return fmt.Errorf("invalid scoped package name %q: expected @scope/name", name)
|
||||
}
|
||||
if scope == "" || pkg == "" {
|
||||
return fmt.Errorf("invalid scoped package name %q: empty scope or name", name)
|
||||
}
|
||||
if strings.Contains(pkg, "/") {
|
||||
return fmt.Errorf("invalid scoped package name %q: too many '/' separators", name)
|
||||
}
|
||||
if pkg[0] == '.' || pkg[0] == '_' {
|
||||
return fmt.Errorf("invalid scoped package name %q: name may not start with '.' or '_'", name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package pkgmgr
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestParsePlainName verifies a plain npm reference parses.
|
||||
func TestParsePlainName(t *testing.T) {
|
||||
r, err := ParsePackageRef("npm:pi-mcp-adapter")
|
||||
if err != nil {
|
||||
t.Fatalf("ParsePackageRef: %v", err)
|
||||
}
|
||||
if r.Registry != RegistryNPM {
|
||||
t.Errorf("Registry = %q, want npm", r.Registry)
|
||||
}
|
||||
if r.Name != "pi-mcp-adapter" {
|
||||
t.Errorf("Name = %q, want pi-mcp-adapter", r.Name)
|
||||
}
|
||||
if r.Version != "" {
|
||||
t.Errorf("Version = %q, want empty", r.Version)
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseScopedName verifies a scoped npm reference parses with the leading
|
||||
// '@' preserved and no false version split.
|
||||
func TestParseScopedName(t *testing.T) {
|
||||
r, err := ParsePackageRef("npm:@gotgenes/pi-subagents")
|
||||
if err != nil {
|
||||
t.Fatalf("ParsePackageRef: %v", err)
|
||||
}
|
||||
if r.Name != "@gotgenes/pi-subagents" {
|
||||
t.Errorf("Name = %q, want @gotgenes/pi-subagents", r.Name)
|
||||
}
|
||||
if r.Version != "" {
|
||||
t.Errorf("Version = %q, want empty", r.Version)
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseWithVersion verifies the "@version" suffix splits off correctly for
|
||||
// both plain and scoped names.
|
||||
func TestParseWithVersion(t *testing.T) {
|
||||
cases := []struct {
|
||||
ref, name, version string
|
||||
}{
|
||||
{"npm:pi-mcp-adapter@1.2.3", "pi-mcp-adapter", "1.2.3"},
|
||||
{"npm:@scope/name@0.1.0", "@scope/name", "0.1.0"},
|
||||
{"npm:@scope/name", "@scope/name", ""},
|
||||
{"npm:pkg@latest", "pkg", "latest"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
r, err := ParsePackageRef(c.ref)
|
||||
if err != nil {
|
||||
t.Errorf("ParsePackageRef(%q): %v", c.ref, err)
|
||||
continue
|
||||
}
|
||||
if r.Name != c.name || r.Version != c.version {
|
||||
t.Errorf("ParsePackageRef(%q) = {%q, %q}, want {%q, %q}", c.ref, r.Name, r.Version, c.name, c.version)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseMissingPrefix verifies a reference without npm: is rejected.
|
||||
func TestParseMissingPrefix(t *testing.T) {
|
||||
for _, ref := range []string{"pi-mcp-adapter", ""} {
|
||||
if _, err := ParsePackageRef(ref); err == nil {
|
||||
t.Errorf("ParsePackageRef(%q) = nil error, want error", ref)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseUnsupportedPrefix verifies non-npm sources are rejected (github:/file:).
|
||||
func TestParseUnsupportedPrefix(t *testing.T) {
|
||||
for _, ref := range []string{"github:owner/repo", "file:./local", "pypi:foo"} {
|
||||
if _, err := ParsePackageRef(ref); err == nil {
|
||||
t.Errorf("ParsePackageRef(%q) = nil error, want error", ref)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseInvalidName verifies illegal npm names are rejected.
|
||||
func TestParseInvalidName(t *testing.T) {
|
||||
cases := []string{
|
||||
"npm:has space", // whitespace
|
||||
"npm:UPPER", // uppercase
|
||||
"npm:bad;rm -rf", // shell metacharacters
|
||||
"npm:.hidden", // leading dot
|
||||
"npm:_underscore", // leading underscore
|
||||
"npm:@scope", // scope without name
|
||||
"npm:@/name", // empty scope
|
||||
"npm:@scope/", // empty name
|
||||
"npm:a/b/c", // too many slashes (unscoped with slash)
|
||||
"npm:", // empty name after prefix
|
||||
}
|
||||
for _, ref := range cases {
|
||||
if _, err := ParsePackageRef(ref); err == nil {
|
||||
t.Errorf("ParsePackageRef(%q) = nil error, want error", ref)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRefStringRoundTrips verifies String reconstructs the canonical reference.
|
||||
func TestRefStringRoundTrips(t *testing.T) {
|
||||
cases := []string{
|
||||
"npm:pi-mcp-adapter",
|
||||
"npm:pi-mcp-adapter@1.2.3",
|
||||
"npm:@scope/name@0.1.0",
|
||||
}
|
||||
for _, ref := range cases {
|
||||
r, err := ParsePackageRef(ref)
|
||||
if err != nil {
|
||||
t.Fatalf("ParsePackageRef(%q): %v", ref, err)
|
||||
}
|
||||
if got := r.String(); got != ref {
|
||||
t.Errorf("String() = %q, want %q", got, ref)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseTrimsWhitespace verifies surrounding whitespace is tolerated.
|
||||
func TestParseTrimsWhitespace(t *testing.T) {
|
||||
r, err := ParsePackageRef(" npm:pi-web-access ")
|
||||
if err != nil {
|
||||
t.Fatalf("ParsePackageRef: %v", err)
|
||||
}
|
||||
if r.Name != "pi-web-access" {
|
||||
t.Errorf("Name = %q, want pi-web-access", r.Name)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
// This file implements the list and uninstall operations (#163) on top of the
|
||||
// lockfile (#154). Both are lightweight lockfile operations that complement the
|
||||
// install flow (#162):
|
||||
//
|
||||
// - Listing just reads the lockfile and returns its entries (sorted by name).
|
||||
// - Uninstalling removes every file the install laid down (recorded in the
|
||||
// lockfile entry's Files), then drops the entry and saves. A file that is
|
||||
// already gone is skipped so a partial prior removal still converges, and
|
||||
// directory entries are removed with RemoveAll so a package's payload dir
|
||||
// (e.g. plugins/<name>.pkg) comes out whole.
|
||||
package pkgmgr
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// ListInstalled returns every package recorded in the lockfile at lockfilePath,
|
||||
// sorted by name. A missing lockfile yields an empty slice (no packages yet),
|
||||
// mirroring Load's missing-is-empty convention.
|
||||
func ListInstalled(lockfilePath string) ([]InstalledPackage, error) {
|
||||
lf, err := Load(lockfilePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return lf.List(), nil
|
||||
}
|
||||
|
||||
// Uninstall removes the package named name: it deletes every file the install
|
||||
// recorded, then removes the lockfile entry and saves. Progress is written to
|
||||
// logw when non-nil. Removing a package that is not installed is an error.
|
||||
//
|
||||
// Files are removed longest-path-first so a directory entry is deleted after
|
||||
// its contents; each path is removed with RemoveAll so both plain files and
|
||||
// payload directories are handled, and an already-absent path is not an error
|
||||
// (the goal state is "gone"). The lockfile entry is dropped even if some file
|
||||
// removals were no-ops, so uninstall always converges the record.
|
||||
func Uninstall(name, lockfilePath string, logw io.Writer) error {
|
||||
logf := func(format string, a ...any) {
|
||||
if logw != nil {
|
||||
fmt.Fprintf(logw, format, a...)
|
||||
}
|
||||
}
|
||||
|
||||
lf, err := Load(lockfilePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p, ok := lf.Get(name)
|
||||
if !ok {
|
||||
return fmt.Errorf("package not installed: %s", name)
|
||||
}
|
||||
|
||||
// Remove deepest paths first so directory entries are cleared after any
|
||||
// nested file entries recorded alongside them.
|
||||
files := append([]string(nil), p.Files...)
|
||||
sort.Slice(files, func(i, j int) bool { return len(files[i]) > len(files[j]) })
|
||||
for _, f := range files {
|
||||
if err := os.RemoveAll(f); err != nil {
|
||||
return fmt.Errorf("pkgmgr: remove %q: %w", f, err)
|
||||
}
|
||||
}
|
||||
logf("Removed %d path(s) for %s\n", len(files), name)
|
||||
|
||||
lf.Remove(name)
|
||||
if err := lf.Save(); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package pkgmgr
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestListInstalled verifies ListInstalled returns entries sorted by name and
|
||||
// an empty slice when no lockfile exists.
|
||||
func TestListInstalled(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
lockPath := filepath.Join(home, "packages.json")
|
||||
|
||||
// No lockfile yet → empty.
|
||||
got, err := ListInstalled(lockPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ListInstalled (empty): %v", err)
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Errorf("empty list = %v, want none", got)
|
||||
}
|
||||
|
||||
// Seed two packages out of order; expect sorted by name.
|
||||
lf, err := Load(lockPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lf.Set(InstalledPackage{Name: "zeta", Source: "npm:zeta", Version: "1.0.0", Types: []PackageType{TypeSkill}})
|
||||
lf.Set(InstalledPackage{Name: "alpha", Source: "npm:alpha", Version: "2.0.0", Types: []PackageType{TypeExtension}})
|
||||
if err := lf.Save(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err = ListInstalled(lockPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ListInstalled: %v", err)
|
||||
}
|
||||
if len(got) != 2 || got[0].Name != "alpha" || got[1].Name != "zeta" {
|
||||
t.Errorf("list = %+v, want [alpha zeta] sorted", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUninstallRemovesFilesAndEntry verifies uninstall deletes the recorded
|
||||
// files (and payload dirs) and drops the lockfile entry.
|
||||
func TestUninstallRemovesFilesAndEntry(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
lockPath := filepath.Join(home, "packages.json")
|
||||
|
||||
// Lay down a payload dir + a file inside it, and a standalone launcher file.
|
||||
payload := filepath.Join(home, "plugins", "demo.pkg")
|
||||
if err := os.MkdirAll(payload, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
inner := filepath.Join(payload, "cli.js")
|
||||
if err := os.WriteFile(inner, []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
launcher := filepath.Join(home, "plugins", "demo")
|
||||
if err := os.WriteFile(launcher, []byte("#!/bin/sh\n"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
lf, err := Load(lockPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lf.Set(InstalledPackage{
|
||||
Name: "demo",
|
||||
Source: "npm:demo",
|
||||
Version: "1.0.0",
|
||||
Types: []PackageType{TypeExtension},
|
||||
Files: []string{inner, payload, launcher},
|
||||
})
|
||||
if err := lf.Save(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := Uninstall("demo", lockPath, nil); err != nil {
|
||||
t.Fatalf("Uninstall: %v", err)
|
||||
}
|
||||
|
||||
// Files gone.
|
||||
if _, err := os.Stat(payload); !os.IsNotExist(err) {
|
||||
t.Errorf("payload dir still present: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(launcher); !os.IsNotExist(err) {
|
||||
t.Errorf("launcher still present: %v", err)
|
||||
}
|
||||
// Lockfile entry gone.
|
||||
lf2, err := Load(lockPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := lf2.Get("demo"); ok {
|
||||
t.Error("lockfile still has demo after uninstall")
|
||||
}
|
||||
}
|
||||
|
||||
// TestUninstallMissingFilesSkipped verifies uninstall converges (removes the
|
||||
// entry) even when some recorded files are already gone.
|
||||
func TestUninstallMissingFilesSkipped(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
lockPath := filepath.Join(home, "packages.json")
|
||||
|
||||
present := filepath.Join(home, "commands", "x.md")
|
||||
if err := os.MkdirAll(filepath.Dir(present), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(present, []byte("body"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
missing := filepath.Join(home, "commands", "gone.md") // never created
|
||||
|
||||
lf, err := Load(lockPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lf.Set(InstalledPackage{Name: "cmds", Version: "1.0.0", Types: []PackageType{TypePrompt}, Files: []string{present, missing}})
|
||||
if err := lf.Save(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := Uninstall("cmds", lockPath, nil); err != nil {
|
||||
t.Fatalf("Uninstall with a missing file: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(present); !os.IsNotExist(err) {
|
||||
t.Errorf("present file not removed: %v", err)
|
||||
}
|
||||
lf2, _ := Load(lockPath)
|
||||
if _, ok := lf2.Get("cmds"); ok {
|
||||
t.Error("entry not removed after uninstall")
|
||||
}
|
||||
}
|
||||
|
||||
// TestUninstallNotInstalled verifies uninstalling an unknown package errors.
|
||||
func TestUninstallNotInstalled(t *testing.T) {
|
||||
lockPath := filepath.Join(t.TempDir(), "packages.json")
|
||||
if err := Uninstall("nope", lockPath, nil); err == nil {
|
||||
t.Fatal("Uninstall of missing package = nil error, want error")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
// This file implements the update operation (#164): bring an installed package
|
||||
// up to the latest version published on npm. Update builds on the pieces the
|
||||
// earlier issues provided — the lockfile (#154) records what is installed and
|
||||
// from which source, Fetch (#156) pulls the latest tarball, and the shared
|
||||
// installFetched path (#162) re-classifies and re-distributes.
|
||||
//
|
||||
// The flow per package is: look up its recorded source, fetch the latest
|
||||
// version, and compare against the installed version. If unchanged, it is a
|
||||
// no-op ("up to date"). Otherwise the old files are removed and the freshly
|
||||
// fetched version is distributed and recorded. Fetch/classify happen before any
|
||||
// removal, so a fetch failure leaves the existing install untouched.
|
||||
package pkgmgr
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
)
|
||||
|
||||
// UpdateResult reports what Update did for one package.
|
||||
type UpdateResult struct {
|
||||
Name string
|
||||
// OldVersion is the version that was installed before the update.
|
||||
OldVersion string
|
||||
// NewVersion is the version after the update (== OldVersion when no-op).
|
||||
NewVersion string
|
||||
// Updated is true when a newer version was fetched and installed.
|
||||
Updated bool
|
||||
}
|
||||
|
||||
// Update brings the single installed package name up to the latest version from
|
||||
// its recorded source. Progress is written to logw when non-nil. Updating a
|
||||
// package that is not installed is an error. When the installed version is
|
||||
// already latest, it is a no-op and Updated is false.
|
||||
//
|
||||
// The latest tarball is fetched and classified before the old files are
|
||||
// removed, so a fetch or classify failure leaves the prior install intact.
|
||||
func Update(name, lockfilePath string, logw io.Writer) (UpdateResult, error) {
|
||||
logf := func(format string, a ...any) {
|
||||
if logw != nil {
|
||||
fmt.Fprintf(logw, format, a...)
|
||||
}
|
||||
}
|
||||
|
||||
lf, err := Load(lockfilePath)
|
||||
if err != nil {
|
||||
return UpdateResult{}, err
|
||||
}
|
||||
p, ok := lf.Get(name)
|
||||
if !ok {
|
||||
return UpdateResult{}, fmt.Errorf("package not installed: %s", name)
|
||||
}
|
||||
|
||||
// Resolve the source to its latest version by dropping any pinned version,
|
||||
// so `update` always targets the newest published release.
|
||||
ref, err := ParsePackageRef(p.Source)
|
||||
if err != nil {
|
||||
return UpdateResult{}, fmt.Errorf("pkgmgr: bad recorded source for %s: %w", name, err)
|
||||
}
|
||||
ref.Version = ""
|
||||
|
||||
logf("Fetching %s ...\n", ref.String())
|
||||
fetched, err := Fetch(ref)
|
||||
if err != nil {
|
||||
return UpdateResult{}, err // old install untouched
|
||||
}
|
||||
defer fetched.Cleanup()
|
||||
|
||||
_, newVersion, _, err := Classify(fetched.Dir)
|
||||
if err != nil {
|
||||
return UpdateResult{}, err // old install untouched
|
||||
}
|
||||
if newVersion == p.Version {
|
||||
logf("%s is up to date\n", name)
|
||||
return UpdateResult{Name: name, OldVersion: p.Version, NewVersion: p.Version, Updated: false}, nil
|
||||
}
|
||||
|
||||
// Newer version fetched: remove the old files, then distribute the new one.
|
||||
if err := Uninstall(name, lockfilePath, logw); err != nil {
|
||||
return UpdateResult{}, err
|
||||
}
|
||||
if _, err := installFetched(fetched.Dir, ref, lockfilePath, logf); err != nil {
|
||||
return UpdateResult{}, err
|
||||
}
|
||||
logf("Updated %s %s -> %s\n", name, p.Version, newVersion)
|
||||
return UpdateResult{Name: name, OldVersion: p.Version, NewVersion: newVersion, Updated: true}, nil
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package pkgmgr
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// fakeNPMVersioned writes a fake `npm` onto PATH that packs whichever tarball is
|
||||
// currently pointed to by a small "which version" file, so a test can flip the
|
||||
// version `npm pack` returns between Install and Update.
|
||||
func fakeNPMVersioned(t *testing.T, tarballs map[string]map[string]string, whichFile string) {
|
||||
t.Helper()
|
||||
binDir := t.TempDir()
|
||||
// Build every tarball once under binDir; the script copies the one named in
|
||||
// whichFile.
|
||||
for ver, files := range tarballs {
|
||||
makeTarGz(t, filepath.Join(binDir, ver+".tgz"), files)
|
||||
}
|
||||
// Script reads whichFile -> version, then copies <version>.tgz as its pack
|
||||
// output and echoes the tarball name (mimicking `npm pack`).
|
||||
script := `#!/bin/sh
|
||||
dest=""
|
||||
prev=""
|
||||
for a in "$@"; do
|
||||
if [ "$prev" = "--pack-destination" ]; then dest="$a"; fi
|
||||
prev="$a"
|
||||
done
|
||||
ver=$(cat "` + whichFile + `")
|
||||
cp "` + binDir + `/$ver.tgz" "$dest/$ver.tgz"
|
||||
echo "$ver.tgz"
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(binDir, "npm"), []byte(script), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||
}
|
||||
|
||||
// TestUpdateToNewerVersion installs v1.0.0 then updates to v2.0.0, verifying the
|
||||
// lockfile version is bumped and the new payload is in place.
|
||||
func TestUpdateToNewerVersion(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("fake npm shell script is POSIX-only")
|
||||
}
|
||||
home := t.TempDir()
|
||||
t.Setenv("PIGO_HOME", home)
|
||||
whichFile := filepath.Join(t.TempDir(), "which")
|
||||
|
||||
tarballs := map[string]map[string]string{
|
||||
"1.0.0": {
|
||||
"package/package.json": `{"name":"pi-demo","version":"1.0.0","bin":"./cli.js"}`,
|
||||
"package/cli.js": "#!/usr/bin/env node\n// v1\n",
|
||||
},
|
||||
"2.0.0": {
|
||||
"package/package.json": `{"name":"pi-demo","version":"2.0.0","bin":"./cli.js"}`,
|
||||
"package/cli.js": "#!/usr/bin/env node\n// v2\n",
|
||||
},
|
||||
}
|
||||
fakeNPMVersioned(t, tarballs, whichFile)
|
||||
|
||||
lockPath := filepath.Join(home, "packages.json")
|
||||
|
||||
// Install v1.
|
||||
if err := os.WriteFile(whichFile, []byte("1.0.0"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := Install("npm:pi-demo", lockPath, nil); err != nil {
|
||||
t.Fatalf("Install v1: %v", err)
|
||||
}
|
||||
|
||||
// Flip to v2 and update.
|
||||
if err := os.WriteFile(whichFile, []byte("2.0.0"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res, err := Update("pi-demo", lockPath, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Update: %v", err)
|
||||
}
|
||||
if !res.Updated || res.OldVersion != "1.0.0" || res.NewVersion != "2.0.0" {
|
||||
t.Errorf("update result = %+v, want 1.0.0->2.0.0 updated", res)
|
||||
}
|
||||
|
||||
lf, err := Load(lockPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p, ok := lf.Get("pi-demo")
|
||||
if !ok || p.Version != "2.0.0" {
|
||||
t.Errorf("lockfile version after update = %+v, want 2.0.0", p)
|
||||
}
|
||||
// v2 payload content is present.
|
||||
data, err := os.ReadFile(filepath.Join(home, "plugins", "pi-demo.pkg", "cli.js"))
|
||||
if err != nil {
|
||||
t.Fatalf("read updated payload: %v", err)
|
||||
}
|
||||
if want := "// v2"; !contains(string(data), want) {
|
||||
t.Errorf("payload = %q, want to contain %q", string(data), want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateUpToDate installs v1.0.0 and updates against the same version,
|
||||
// expecting a no-op (Updated false, version unchanged).
|
||||
func TestUpdateUpToDate(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("POSIX-only")
|
||||
}
|
||||
home := t.TempDir()
|
||||
t.Setenv("PIGO_HOME", home)
|
||||
whichFile := filepath.Join(t.TempDir(), "which")
|
||||
|
||||
tarballs := map[string]map[string]string{
|
||||
"1.0.0": {
|
||||
"package/package.json": `{"name":"pi-demo","version":"1.0.0","bin":"./cli.js"}`,
|
||||
"package/cli.js": "#!/usr/bin/env node\n",
|
||||
},
|
||||
}
|
||||
fakeNPMVersioned(t, tarballs, whichFile)
|
||||
if err := os.WriteFile(whichFile, []byte("1.0.0"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
lockPath := filepath.Join(home, "packages.json")
|
||||
if _, err := Install("npm:pi-demo", lockPath, nil); err != nil {
|
||||
t.Fatalf("Install: %v", err)
|
||||
}
|
||||
res, err := Update("pi-demo", lockPath, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Update: %v", err)
|
||||
}
|
||||
if res.Updated {
|
||||
t.Errorf("update result = %+v, want no-op (Updated false)", res)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateNotInstalled verifies updating an unknown package errors.
|
||||
func TestUpdateNotInstalled(t *testing.T) {
|
||||
if _, err := Update("nope", filepath.Join(t.TempDir(), "packages.json"), nil); err == nil {
|
||||
t.Fatal("Update of missing package = nil error, want error")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user