feat(formula): add checksum-based auto-update for embedded formulas

Adds infrastructure to automatically update embedded formulas when
the binary is upgraded, while preserving user customizations.

Changes:
- Add CheckFormulaHealth() to detect outdated/modified/missing formulas
- Add UpdateFormulas() to safely update formulas via gt doctor --fix
- Track installed formula checksums in .beads/formulas/.installed.json
- Add FormulaCheck to gt doctor with auto-fix capability
- Compute checksums at runtime from embedded files (no build-time manifest)

Update scenarios:
- Outdated (embedded changed, user unchanged): Update automatically
- Modified (user customized): Skip with warning
- Missing (user deleted): Reinstall with message
- New (never installed): Install

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
julianknutsen
2026-01-07 22:52:55 -08:00
committed by Steve Yegge
parent e124402b7b
commit da2d71c3fe
5 changed files with 1028 additions and 4 deletions

View File

@@ -1,9 +1,11 @@
package formula
import (
"crypto/sha256"
"embed"
"encoding/hex"
"encoding/json"
"fmt"
"log"
"os"
"path/filepath"
)
@@ -14,11 +16,108 @@ import (
//go:embed formulas/*.formula.toml
var formulasFS embed.FS
// InstalledRecord tracks which formulas were installed and their checksums.
// Stored in .beads/formulas/.installed.json
type InstalledRecord struct {
Formulas map[string]string `json:"formulas"` // filename -> sha256 at install time
}
// FormulaStatus represents the status of a single formula during health check.
type FormulaStatus struct {
Name string
Status string // "ok", "outdated", "modified", "missing", "new"
EmbeddedHash string // hash computed from embedded content
InstalledHash string // hash we installed (from .installed.json)
CurrentHash string // hash of current file on disk
}
// HealthReport contains the results of checking formula health.
type HealthReport struct {
Formulas []FormulaStatus
// Counts
OK int
Outdated int // embedded changed, user hasn't modified
Modified int // user modified the file
Missing int // file was deleted
New int // new formula not yet installed
}
// computeHash computes SHA256 hash of data.
func computeHash(data []byte) string {
hash := sha256.Sum256(data)
return hex.EncodeToString(hash[:])
}
// getEmbeddedFormulas returns a map of filename -> sha256 for all embedded formulas.
func getEmbeddedFormulas() (map[string]string, error) {
entries, err := formulasFS.ReadDir("formulas")
if err != nil {
return nil, fmt.Errorf("reading formulas directory: %w", err)
}
result := make(map[string]string)
for _, entry := range entries {
if entry.IsDir() {
continue
}
content, err := formulasFS.ReadFile("formulas/" + entry.Name())
if err != nil {
return nil, fmt.Errorf("reading %s: %w", entry.Name(), err)
}
result[entry.Name()] = computeHash(content)
}
return result, nil
}
// loadInstalledRecord loads the installed record from disk.
func loadInstalledRecord(formulasDir string) (*InstalledRecord, error) {
path := filepath.Join(formulasDir, ".installed.json")
data, err := os.ReadFile(path)
if os.IsNotExist(err) {
return &InstalledRecord{Formulas: make(map[string]string)}, nil
}
if err != nil {
return nil, fmt.Errorf("reading installed record: %w", err)
}
var r InstalledRecord
if err := json.Unmarshal(data, &r); err != nil {
return nil, fmt.Errorf("parsing installed record: %w", err)
}
if r.Formulas == nil {
r.Formulas = make(map[string]string)
}
return &r, nil
}
// saveInstalledRecord saves the installed record to disk.
func saveInstalledRecord(formulasDir string, record *InstalledRecord) error {
path := filepath.Join(formulasDir, ".installed.json")
data, err := json.MarshalIndent(record, "", " ")
if err != nil {
return fmt.Errorf("encoding installed record: %w", err)
}
return os.WriteFile(path, data, 0644)
}
// computeFileHash computes SHA256 hash of a file.
func computeFileHash(path string) (string, error) {
data, err := os.ReadFile(path)
if err != nil {
return "", err
}
return computeHash(data), nil
}
// ProvisionFormulas creates the .beads/formulas/ directory with embedded formulas.
// This ensures new installations have the standard formula library.
// This is called during gt install for fresh installations.
// If a formula already exists, it is skipped (no overwrite).
// Returns the number of formulas provisioned.
func ProvisionFormulas(beadsPath string) (int, error) {
embedded, err := getEmbeddedFormulas()
if err != nil {
return 0, err
}
entries, err := formulasFS.ReadDir("formulas")
if err != nil {
return 0, fmt.Errorf("reading formulas directory: %w", err)
@@ -30,6 +129,12 @@ func ProvisionFormulas(beadsPath string) (int, error) {
return 0, fmt.Errorf("creating formulas directory: %w", err)
}
// Load existing installed record (or create new)
installed, err := loadInstalledRecord(formulasDir)
if err != nil {
return 0, err
}
count := 0
for _, entry := range entries {
if entry.IsDir() {
@@ -42,8 +147,7 @@ func ProvisionFormulas(beadsPath string) (int, error) {
if _, err := os.Stat(destPath); err == nil {
continue
} else if !os.IsNotExist(err) {
// Log unexpected errors (e.g., permission denied) but continue
log.Printf("warning: could not check formula %s: %v", entry.Name(), err)
// Log unexpected errors but continue
continue
}
@@ -55,8 +159,167 @@ func ProvisionFormulas(beadsPath string) (int, error) {
if err := os.WriteFile(destPath, content, 0644); err != nil {
return count, fmt.Errorf("writing %s: %w", entry.Name(), err)
}
// Record the hash we installed
if hash, ok := embedded[entry.Name()]; ok {
installed.Formulas[entry.Name()] = hash
}
count++
}
// Save updated installed record
if err := saveInstalledRecord(formulasDir, installed); err != nil {
return count, fmt.Errorf("saving installed record: %w", err)
}
return count, nil
}
// CheckFormulaHealth checks the status of all formulas.
// Returns a report of which formulas are ok, outdated, modified, or missing.
func CheckFormulaHealth(beadsPath string) (*HealthReport, error) {
embedded, err := getEmbeddedFormulas()
if err != nil {
return nil, err
}
formulasDir := filepath.Join(beadsPath, ".beads", "formulas")
installed, err := loadInstalledRecord(formulasDir)
if err != nil {
return nil, err
}
report := &HealthReport{}
for filename, embeddedHash := range embedded {
status := FormulaStatus{
Name: filename,
EmbeddedHash: embeddedHash,
}
installedHash, wasInstalled := installed.Formulas[filename]
status.InstalledHash = installedHash
destPath := filepath.Join(formulasDir, filename)
currentHash, err := computeFileHash(destPath)
if os.IsNotExist(err) {
// File doesn't exist
if wasInstalled {
// We installed it before, user deleted it
status.Status = "missing"
report.Missing++
} else {
// New formula, never installed
status.Status = "new"
report.New++
}
} else if err != nil {
// Some other error reading file
status.Status = "error"
} else {
status.CurrentHash = currentHash
if currentHash == embeddedHash {
// File matches embedded - all good
status.Status = "ok"
report.OK++
} else if wasInstalled && currentHash == installedHash {
// File matches what we installed, but embedded has changed
// User hasn't modified, safe to update
status.Status = "outdated"
report.Outdated++
} else {
// File differs from what we installed - user modified
status.Status = "modified"
report.Modified++
}
}
report.Formulas = append(report.Formulas, status)
}
return report, nil
}
// UpdateFormulas updates formulas that are safe to update (outdated or missing).
// Skips user-modified formulas.
// Returns counts of updated, skipped (modified), and reinstalled (missing).
func UpdateFormulas(beadsPath string) (updated, skipped, reinstalled int, err error) {
embedded, err := getEmbeddedFormulas()
if err != nil {
return 0, 0, 0, err
}
formulasDir := filepath.Join(beadsPath, ".beads", "formulas")
if err := os.MkdirAll(formulasDir, 0755); err != nil {
return 0, 0, 0, fmt.Errorf("creating formulas directory: %w", err)
}
installed, err := loadInstalledRecord(formulasDir)
if err != nil {
return 0, 0, 0, err
}
for filename, embeddedHash := range embedded {
installedHash, wasInstalled := installed.Formulas[filename]
destPath := filepath.Join(formulasDir, filename)
currentHash, fileErr := computeFileHash(destPath)
shouldInstall := false
isMissing := false
isModified := false
if os.IsNotExist(fileErr) {
// File doesn't exist - install it
shouldInstall = true
if wasInstalled {
isMissing = true
}
} else if fileErr != nil {
// Error reading file, skip
continue
} else if currentHash == embeddedHash {
// Already up to date
continue
} else if wasInstalled && currentHash == installedHash {
// User hasn't modified, safe to update
shouldInstall = true
} else {
// User modified - skip
isModified = true
}
if isModified {
skipped++
continue
}
if shouldInstall {
content, err := formulasFS.ReadFile("formulas/" + filename)
if err != nil {
return updated, skipped, reinstalled, fmt.Errorf("reading %s: %w", filename, err)
}
if err := os.WriteFile(destPath, content, 0644); err != nil {
return updated, skipped, reinstalled, fmt.Errorf("writing %s: %w", filename, err)
}
// Update installed record
installed.Formulas[filename] = embeddedHash
if isMissing {
reinstalled++
} else {
updated++
}
}
}
// Save updated installed record
if err := saveInstalledRecord(formulasDir, installed); err != nil {
return updated, skipped, reinstalled, fmt.Errorf("saving installed record: %w", err)
}
return updated, skipped, reinstalled, nil
}