first commit
This commit is contained in:
@@ -0,0 +1,177 @@
|
||||
// This file holds the first-run bootstrap: on the first launch (per pigo home),
|
||||
// the built-in skill collections in Manifest are copied into the user's skills
|
||||
// directory so they load as /skill-name commands with no manual install.
|
||||
//
|
||||
// The flow is designed to be silent and non-blocking (a failed bootstrap must
|
||||
// never stop pigo from starting) and idempotent (skills already present are left
|
||||
// untouched, so a user's edits are never clobbered). A state file under the pigo
|
||||
// home records which collections+versions have been installed, so a completed
|
||||
// bootstrap is skipped on later launches and a bumped collection Version
|
||||
// re-triggers installation of any still-missing skills.
|
||||
package builtinskills
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// stateFileName is the bootstrap record kept under the pigo home directory. It
|
||||
// maps a collection name to the version last installed, so Bootstrap can decide
|
||||
// per collection whether work remains.
|
||||
const stateFileName = "builtin-skills.json"
|
||||
|
||||
// state is the on-disk bootstrap record: collection name -> installed version.
|
||||
type state struct {
|
||||
Installed map[string]string `json:"installed"`
|
||||
}
|
||||
|
||||
// Bootstrap installs any not-yet-installed built-in skill collections into
|
||||
// skillsDir, recording progress under pigoHome. It is safe to call on every
|
||||
// launch: collections already recorded at their current Version are skipped, and
|
||||
// individual skills whose target directory already exists are left untouched.
|
||||
//
|
||||
// It never returns an error — bootstrap is best-effort and must not block
|
||||
// startup — but writes a one-line note per failure to logw when logw is non-nil
|
||||
// (callers pass a writer only in debug/verbose mode, keeping normal runs silent).
|
||||
// Empty pigoHome or skillsDir disables bootstrap (home unresolved): nothing is
|
||||
// installed and nothing is logged.
|
||||
func Bootstrap(pigoHome, skillsDir string, logw io.Writer) {
|
||||
bootstrap(Manifest(), pigoHome, skillsDir, logw)
|
||||
}
|
||||
|
||||
// bootstrap is the testable core of Bootstrap, parameterized on the manifest so
|
||||
// tests can inject synthetic collections without touching the embedded set.
|
||||
func bootstrap(sets []Set, pigoHome, skillsDir string, logw io.Writer) {
|
||||
logf := func(format string, a ...any) {
|
||||
if logw != nil {
|
||||
fmt.Fprintf(logw, format, a...)
|
||||
}
|
||||
}
|
||||
if pigoHome == "" || skillsDir == "" {
|
||||
return // home unresolved; nothing we can safely do
|
||||
}
|
||||
|
||||
st := loadState(filepath.Join(pigoHome, stateFileName))
|
||||
|
||||
changed := false
|
||||
for _, set := range sets {
|
||||
// Per-collection version gate: once recorded at this Version the whole
|
||||
// set is skipped, so a skill the user later *deletes* is not restored
|
||||
// (only a Version bump re-triggers install of still-missing skills).
|
||||
// This is deliberate — silently re-adding a removed skill would fight
|
||||
// the user's choice. An empty Version is never "already installed"
|
||||
// (the zero-value lookup would otherwise equal it and skip forever),
|
||||
// so a set with a blank Version always attempts install.
|
||||
if set.Version != "" && st.Installed[set.Name] == set.Version {
|
||||
continue
|
||||
}
|
||||
// installSet reports whether the collection is fully installed (every
|
||||
// skill now present on disk). Only then do we record the version, so a
|
||||
// partial failure re-runs next launch (satisfying "retry on next run").
|
||||
if installSet(set, skillsDir, logf) {
|
||||
if st.Installed == nil {
|
||||
st.Installed = map[string]string{}
|
||||
}
|
||||
st.Installed[set.Name] = set.Version
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
|
||||
if changed {
|
||||
if err := saveState(pigoHome, st); err != nil {
|
||||
logf("builtinskills: could not save state: %v\n", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// installSet copies each skill in the set into skillsDir/<name>/, skipping any
|
||||
// whose target directory already exists (never clobbering a user's copy). A
|
||||
// single skill's failure does not abort the rest. It returns true only when
|
||||
// every named skill is present on disk afterward, so the caller can decide
|
||||
// whether to record the collection as fully installed.
|
||||
func installSet(set Set, skillsDir string, logf func(string, ...any)) bool {
|
||||
if err := os.MkdirAll(skillsDir, 0o755); err != nil {
|
||||
logf("builtinskills: create skills dir %q: %v\n", skillsDir, err)
|
||||
return false
|
||||
}
|
||||
allPresent := true
|
||||
for _, name := range set.Skills {
|
||||
dest := filepath.Join(skillsDir, name)
|
||||
if _, err := os.Stat(dest); err == nil {
|
||||
continue // already present (user copy or prior install): leave it
|
||||
}
|
||||
src := path.Join(set.Root, name)
|
||||
if err := copyTree(set.FS, src, dest); err != nil {
|
||||
logf("builtinskills: install %q: %v\n", name, err)
|
||||
// Remove a half-written tree so a later run starts clean.
|
||||
_ = os.RemoveAll(dest)
|
||||
allPresent = false
|
||||
continue
|
||||
}
|
||||
}
|
||||
return allPresent
|
||||
}
|
||||
|
||||
// copyTree copies the directory tree rooted at src within srcFS to the on-disk
|
||||
// directory dest, creating parents as needed. Files are written 0o644 and
|
||||
// directories 0o755.
|
||||
func copyTree(srcFS fs.FS, src, dest string) error {
|
||||
entries, err := fs.ReadDir(srcFS, src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(dest, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, e := range entries {
|
||||
srcPath := path.Join(src, e.Name())
|
||||
destPath := filepath.Join(dest, e.Name())
|
||||
if e.IsDir() {
|
||||
if err := copyTree(srcFS, srcPath, destPath); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
data, err := fs.ReadFile(srcFS, srcPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(destPath, data, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadState reads the bootstrap record at p. A missing or malformed file yields
|
||||
// an empty state (treated as "nothing installed"), so a corrupt record just
|
||||
// re-triggers a — idempotent — install rather than failing.
|
||||
func loadState(p string) state {
|
||||
data, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
return state{}
|
||||
}
|
||||
var st state
|
||||
if json.Unmarshal(data, &st) != nil {
|
||||
return state{}
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
// saveState writes the bootstrap record under pigoHome, creating the home
|
||||
// directory if needed.
|
||||
func saveState(pigoHome string, st state) error {
|
||||
if err := os.MkdirAll(pigoHome, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := json.MarshalIndent(st, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(filepath.Join(pigoHome, stateFileName), data, 0o644)
|
||||
}
|
||||
Reference in New Issue
Block a user