// 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:`. 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 }