Files
BlackBean/pigo/internal/pkgmgr/lockfile.go
T
2026-08-14 23:41:57 +08:00

184 lines
6.7 KiB
Go

// 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
}