first commit

This commit is contained in:
2026-08-14 23:41:57 +08:00
commit 086803a8dd
471 changed files with 91938 additions and 0 deletions
+150
View File
@@ -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()
}
+205
View File
@@ -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")
}
}
+260
View File
@@ -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)
}
+85
View File
@@ -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)
}
}
}
+48
View File
@@ -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")
}
}
+264
View File
@@ -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")
}
}
+38
View File
@@ -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)
}
}
+218
View File
@@ -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
}
+165
View File
@@ -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)
}
}
+122
View File
@@ -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, ", ")
}
+140
View File
@@ -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")
}
}
+110
View File
@@ -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 ""
}
}
+58
View File
@@ -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)
}
}
+183
View File
@@ -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
}
+141
View File
@@ -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
}
+148
View File
@@ -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
}
+126
View File
@@ -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)
}
}
+72
View File
@@ -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
}
+142
View File
@@ -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")
}
}
+86
View File
@@ -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
}
+141
View File
@@ -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")
}
}