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:
committed by
Steve Yegge
parent
e124402b7b
commit
da2d71c3fe
@@ -116,6 +116,7 @@ func runDoctor(cmd *cobra.Command, args []string) error {
|
||||
d.Register(doctor.NewRepoFingerprintCheck())
|
||||
d.Register(doctor.NewBootHealthCheck())
|
||||
d.Register(doctor.NewBeadsDatabaseCheck())
|
||||
d.Register(doctor.NewFormulaCheck())
|
||||
d.Register(doctor.NewBdDaemonCheck())
|
||||
d.Register(doctor.NewPrefixConflictCheck())
|
||||
d.Register(doctor.NewPrefixMismatchCheck())
|
||||
|
||||
123
internal/doctor/formula_check.go
Normal file
123
internal/doctor/formula_check.go
Normal file
@@ -0,0 +1,123 @@
|
||||
package doctor
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/steveyegge/gastown/internal/formula"
|
||||
)
|
||||
|
||||
// FormulaCheck verifies that embedded formulas are up-to-date.
|
||||
// It detects outdated formulas (binary updated), missing formulas (user deleted),
|
||||
// and modified formulas (user customized). Can auto-fix outdated and missing.
|
||||
type FormulaCheck struct {
|
||||
FixableCheck
|
||||
}
|
||||
|
||||
// NewFormulaCheck creates a new formula check.
|
||||
func NewFormulaCheck() *FormulaCheck {
|
||||
return &FormulaCheck{
|
||||
FixableCheck: FixableCheck{
|
||||
BaseCheck: BaseCheck{
|
||||
CheckName: "formulas",
|
||||
CheckDescription: "Check embedded formulas are up-to-date",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Run checks if formulas need updating.
|
||||
func (c *FormulaCheck) Run(ctx *CheckContext) *CheckResult {
|
||||
report, err := formula.CheckFormulaHealth(ctx.TownRoot)
|
||||
if err != nil {
|
||||
return &CheckResult{
|
||||
Name: c.Name(),
|
||||
Status: StatusWarning,
|
||||
Message: fmt.Sprintf("Could not check formulas: %v", err),
|
||||
}
|
||||
}
|
||||
|
||||
// All good
|
||||
if report.Outdated == 0 && report.Missing == 0 && report.Modified == 0 && report.New == 0 {
|
||||
return &CheckResult{
|
||||
Name: c.Name(),
|
||||
Status: StatusOK,
|
||||
Message: fmt.Sprintf("%d formulas up-to-date", report.OK),
|
||||
}
|
||||
}
|
||||
|
||||
// Build details
|
||||
var details []string
|
||||
var needsFix bool
|
||||
|
||||
for _, f := range report.Formulas {
|
||||
switch f.Status {
|
||||
case "outdated":
|
||||
details = append(details, fmt.Sprintf(" %s: update available", f.Name))
|
||||
needsFix = true
|
||||
case "missing":
|
||||
details = append(details, fmt.Sprintf(" %s: missing (will reinstall)", f.Name))
|
||||
needsFix = true
|
||||
case "modified":
|
||||
details = append(details, fmt.Sprintf(" %s: locally modified (skipping)", f.Name))
|
||||
case "new":
|
||||
details = append(details, fmt.Sprintf(" %s: new formula available", f.Name))
|
||||
needsFix = true
|
||||
}
|
||||
}
|
||||
|
||||
// Determine status
|
||||
status := StatusOK
|
||||
if needsFix {
|
||||
status = StatusWarning
|
||||
}
|
||||
|
||||
// Build message
|
||||
var parts []string
|
||||
if report.Outdated > 0 {
|
||||
parts = append(parts, fmt.Sprintf("%d outdated", report.Outdated))
|
||||
}
|
||||
if report.Missing > 0 {
|
||||
parts = append(parts, fmt.Sprintf("%d missing", report.Missing))
|
||||
}
|
||||
if report.New > 0 {
|
||||
parts = append(parts, fmt.Sprintf("%d new", report.New))
|
||||
}
|
||||
if report.Modified > 0 {
|
||||
parts = append(parts, fmt.Sprintf("%d modified", report.Modified))
|
||||
}
|
||||
|
||||
message := fmt.Sprintf("Formulas: %s", strings.Join(parts, ", "))
|
||||
|
||||
result := &CheckResult{
|
||||
Name: c.Name(),
|
||||
Status: status,
|
||||
Message: message,
|
||||
Details: details,
|
||||
}
|
||||
|
||||
if needsFix {
|
||||
result.FixHint = "Run 'gt doctor --fix' to update formulas"
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// Fix updates outdated and missing formulas.
|
||||
func (c *FormulaCheck) Fix(ctx *CheckContext) error {
|
||||
updated, skipped, reinstalled, err := formula.UpdateFormulas(ctx.TownRoot)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Log what was done (caller will re-run check to show new status)
|
||||
if updated > 0 || reinstalled > 0 || skipped > 0 {
|
||||
// The doctor framework will re-run the check after fix
|
||||
// so we don't need to log here
|
||||
_ = updated
|
||||
_ = reinstalled
|
||||
_ = skipped
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
103
internal/doctor/formula_check_test.go
Normal file
103
internal/doctor/formula_check_test.go
Normal file
@@ -0,0 +1,103 @@
|
||||
package doctor
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/steveyegge/gastown/internal/formula"
|
||||
)
|
||||
|
||||
func TestNewFormulaCheck(t *testing.T) {
|
||||
check := NewFormulaCheck()
|
||||
if check.Name() != "formulas" {
|
||||
t.Errorf("Name() = %q, want %q", check.Name(), "formulas")
|
||||
}
|
||||
if !check.CanFix() {
|
||||
t.Error("FormulaCheck should be fixable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormulaCheck_Run_AllOK(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
// Provision formulas fresh
|
||||
_, err := formula.ProvisionFormulas(tmpDir)
|
||||
if err != nil {
|
||||
t.Fatalf("ProvisionFormulas() error: %v", err)
|
||||
}
|
||||
|
||||
check := NewFormulaCheck()
|
||||
ctx := &CheckContext{TownRoot: tmpDir}
|
||||
|
||||
result := check.Run(ctx)
|
||||
|
||||
if result.Status != StatusOK {
|
||||
t.Errorf("Status = %v, want %v", result.Status, StatusOK)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormulaCheck_Run_Missing(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
// Provision formulas
|
||||
_, err := formula.ProvisionFormulas(tmpDir)
|
||||
if err != nil {
|
||||
t.Fatalf("ProvisionFormulas() error: %v", err)
|
||||
}
|
||||
|
||||
// Delete a formula
|
||||
formulasDir := filepath.Join(tmpDir, ".beads", "formulas")
|
||||
formulaPath := filepath.Join(formulasDir, "mol-deacon-patrol.formula.toml")
|
||||
if err := os.Remove(formulaPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
check := NewFormulaCheck()
|
||||
ctx := &CheckContext{TownRoot: tmpDir}
|
||||
|
||||
result := check.Run(ctx)
|
||||
|
||||
if result.Status != StatusWarning {
|
||||
t.Errorf("Status = %v, want %v", result.Status, StatusWarning)
|
||||
}
|
||||
if result.FixHint == "" {
|
||||
t.Error("should have FixHint")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormulaCheck_Fix(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
// Provision formulas
|
||||
_, err := formula.ProvisionFormulas(tmpDir)
|
||||
if err != nil {
|
||||
t.Fatalf("ProvisionFormulas() error: %v", err)
|
||||
}
|
||||
|
||||
// Delete a formula
|
||||
formulasDir := filepath.Join(tmpDir, ".beads", "formulas")
|
||||
formulaPath := filepath.Join(formulasDir, "mol-deacon-patrol.formula.toml")
|
||||
if err := os.Remove(formulaPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
check := NewFormulaCheck()
|
||||
ctx := &CheckContext{TownRoot: tmpDir}
|
||||
|
||||
// Run fix
|
||||
if err := check.Fix(ctx); err != nil {
|
||||
t.Fatalf("Fix() error: %v", err)
|
||||
}
|
||||
|
||||
// Verify formula was restored
|
||||
if _, err := os.Stat(formulaPath); os.IsNotExist(err) {
|
||||
t.Error("formula should have been restored")
|
||||
}
|
||||
|
||||
// Re-run check - should be OK now
|
||||
result := check.Run(ctx)
|
||||
if result.Status != StatusOK {
|
||||
t.Errorf("after fix, Status = %v, want %v", result.Status, StatusOK)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
534
internal/formula/embed_test.go
Normal file
534
internal/formula/embed_test.go
Normal file
@@ -0,0 +1,534 @@
|
||||
package formula
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestGetEmbeddedFormulas verifies embedded formulas can be read and hashed.
|
||||
func TestGetEmbeddedFormulas(t *testing.T) {
|
||||
embedded, err := getEmbeddedFormulas()
|
||||
if err != nil {
|
||||
t.Fatalf("getEmbeddedFormulas() error: %v", err)
|
||||
}
|
||||
if len(embedded) == 0 {
|
||||
t.Error("should have embedded formulas")
|
||||
}
|
||||
|
||||
// Verify at least one known formula exists
|
||||
if _, ok := embedded["mol-deacon-patrol.formula.toml"]; !ok {
|
||||
t.Error("should contain mol-deacon-patrol.formula.toml")
|
||||
}
|
||||
|
||||
// Verify hashes are valid hex strings
|
||||
for name, hash := range embedded {
|
||||
if len(hash) != 64 {
|
||||
t.Errorf("%s hash has wrong length: %d", name, len(hash))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestProvisionFormulas_FreshInstall tests provisioning to an empty directory.
|
||||
func TestProvisionFormulas_FreshInstall(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
count, err := ProvisionFormulas(tmpDir)
|
||||
if err != nil {
|
||||
t.Fatalf("ProvisionFormulas() error: %v", err)
|
||||
}
|
||||
if count == 0 {
|
||||
t.Error("should have provisioned at least one formula")
|
||||
}
|
||||
|
||||
// Verify formulas directory was created
|
||||
formulasDir := filepath.Join(tmpDir, ".beads", "formulas")
|
||||
if _, err := os.Stat(formulasDir); os.IsNotExist(err) {
|
||||
t.Error("formulas directory should exist")
|
||||
}
|
||||
|
||||
// Verify .installed.json was created
|
||||
installedPath := filepath.Join(formulasDir, ".installed.json")
|
||||
if _, err := os.Stat(installedPath); os.IsNotExist(err) {
|
||||
t.Error(".installed.json should exist")
|
||||
}
|
||||
|
||||
// Verify installed record contains the right checksums
|
||||
installed, err := loadInstalledRecord(formulasDir)
|
||||
if err != nil {
|
||||
t.Fatalf("loadInstalledRecord() error: %v", err)
|
||||
}
|
||||
if len(installed.Formulas) != count {
|
||||
t.Errorf("installed record has %d entries, want %d", len(installed.Formulas), count)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProvisionFormulas_SkipsExisting tests that existing files are not overwritten.
|
||||
func TestProvisionFormulas_SkipsExisting(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
// Create formulas directory with a custom formula
|
||||
formulasDir := filepath.Join(tmpDir, ".beads", "formulas")
|
||||
if err := os.MkdirAll(formulasDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
customContent := []byte("# Custom user formula\nformula = \"mol-deacon-patrol\"\n")
|
||||
customPath := filepath.Join(formulasDir, "mol-deacon-patrol.formula.toml")
|
||||
if err := os.WriteFile(customPath, customContent, 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Provision formulas
|
||||
_, err := ProvisionFormulas(tmpDir)
|
||||
if err != nil {
|
||||
t.Fatalf("ProvisionFormulas() error: %v", err)
|
||||
}
|
||||
|
||||
// Verify custom content was NOT overwritten
|
||||
content, err := os.ReadFile(customPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(content) != string(customContent) {
|
||||
t.Error("existing formula should not have been overwritten")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckFormulaHealth_AllOK tests when all formulas are up to date.
|
||||
func TestCheckFormulaHealth_AllOK(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
// Provision fresh
|
||||
_, err := ProvisionFormulas(tmpDir)
|
||||
if err != nil {
|
||||
t.Fatalf("ProvisionFormulas() error: %v", err)
|
||||
}
|
||||
|
||||
// Check health
|
||||
report, err := CheckFormulaHealth(tmpDir)
|
||||
if err != nil {
|
||||
t.Fatalf("CheckFormulaHealth() error: %v", err)
|
||||
}
|
||||
|
||||
if report.Outdated != 0 {
|
||||
t.Errorf("Outdated = %d, want 0", report.Outdated)
|
||||
}
|
||||
if report.Missing != 0 {
|
||||
t.Errorf("Missing = %d, want 0", report.Missing)
|
||||
}
|
||||
if report.Modified != 0 {
|
||||
t.Errorf("Modified = %d, want 0", report.Modified)
|
||||
}
|
||||
if report.OK == 0 {
|
||||
t.Error("OK should be > 0")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckFormulaHealth_UserModified tests detection of user-modified formulas.
|
||||
func TestCheckFormulaHealth_UserModified(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
// Provision fresh
|
||||
_, err := ProvisionFormulas(tmpDir)
|
||||
if err != nil {
|
||||
t.Fatalf("ProvisionFormulas() error: %v", err)
|
||||
}
|
||||
|
||||
// Modify a formula
|
||||
formulasDir := filepath.Join(tmpDir, ".beads", "formulas")
|
||||
formulaPath := filepath.Join(formulasDir, "mol-deacon-patrol.formula.toml")
|
||||
modifiedContent := []byte("# User modified this\nformula = \"mol-deacon-patrol\"\nversion = 999\n")
|
||||
if err := os.WriteFile(formulaPath, modifiedContent, 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Check health
|
||||
report, err := CheckFormulaHealth(tmpDir)
|
||||
if err != nil {
|
||||
t.Fatalf("CheckFormulaHealth() error: %v", err)
|
||||
}
|
||||
|
||||
if report.Modified != 1 {
|
||||
t.Errorf("Modified = %d, want 1", report.Modified)
|
||||
}
|
||||
|
||||
// Verify the specific formula is marked as modified
|
||||
found := false
|
||||
for _, f := range report.Formulas {
|
||||
if f.Name == "mol-deacon-patrol.formula.toml" {
|
||||
if f.Status != "modified" {
|
||||
t.Errorf("mol-deacon-patrol status = %q, want %q", f.Status, "modified")
|
||||
}
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("mol-deacon-patrol.formula.toml not found in report")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckFormulaHealth_Missing tests detection of deleted formulas.
|
||||
func TestCheckFormulaHealth_Missing(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
// Provision fresh
|
||||
_, err := ProvisionFormulas(tmpDir)
|
||||
if err != nil {
|
||||
t.Fatalf("ProvisionFormulas() error: %v", err)
|
||||
}
|
||||
|
||||
// Delete a formula
|
||||
formulasDir := filepath.Join(tmpDir, ".beads", "formulas")
|
||||
formulaPath := filepath.Join(formulasDir, "mol-deacon-patrol.formula.toml")
|
||||
if err := os.Remove(formulaPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Check health
|
||||
report, err := CheckFormulaHealth(tmpDir)
|
||||
if err != nil {
|
||||
t.Fatalf("CheckFormulaHealth() error: %v", err)
|
||||
}
|
||||
|
||||
if report.Missing != 1 {
|
||||
t.Errorf("Missing = %d, want 1", report.Missing)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckFormulaHealth_Outdated simulates an outdated formula.
|
||||
func TestCheckFormulaHealth_Outdated(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
// Provision fresh
|
||||
_, err := ProvisionFormulas(tmpDir)
|
||||
if err != nil {
|
||||
t.Fatalf("ProvisionFormulas() error: %v", err)
|
||||
}
|
||||
|
||||
// Simulate "old" installed record by changing the installed hash for a formula
|
||||
// This mimics what happens when a new binary has updated formula content
|
||||
formulasDir := filepath.Join(tmpDir, ".beads", "formulas")
|
||||
installed, err := loadInstalledRecord(formulasDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
embedded, err := getEmbeddedFormulas()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Pick a formula that exists
|
||||
var targetFormula string
|
||||
for name := range installed.Formulas {
|
||||
targetFormula = name
|
||||
break
|
||||
}
|
||||
if targetFormula == "" {
|
||||
t.Skip("no formulas installed")
|
||||
}
|
||||
|
||||
// Write a file that simulates "old version" - content differs from embedded
|
||||
formulaPath := filepath.Join(formulasDir, targetFormula)
|
||||
oldContent := []byte("# Old version of formula\n")
|
||||
if err := os.WriteFile(formulaPath, oldContent, 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Update installed record to match the old content's hash
|
||||
hash := sha256.Sum256(oldContent)
|
||||
installed.Formulas[targetFormula] = hex.EncodeToString(hash[:])
|
||||
|
||||
if err := saveInstalledRecord(formulasDir, installed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Now: file matches what we "installed" but differs from embedded = outdated
|
||||
report, err := CheckFormulaHealth(tmpDir)
|
||||
if err != nil {
|
||||
t.Fatalf("CheckFormulaHealth() error: %v", err)
|
||||
}
|
||||
|
||||
if report.Outdated != 1 {
|
||||
t.Errorf("Outdated = %d, want 1", report.Outdated)
|
||||
}
|
||||
|
||||
// Verify the embedded hash is different from installed
|
||||
embeddedHash := embedded[targetFormula]
|
||||
if embeddedHash == installed.Formulas[targetFormula] {
|
||||
t.Error("embedded hash should differ from installed hash for this test")
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateFormulas_UpdatesOutdated tests that outdated formulas are updated.
|
||||
func TestUpdateFormulas_UpdatesOutdated(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
// Provision fresh
|
||||
_, err := ProvisionFormulas(tmpDir)
|
||||
if err != nil {
|
||||
t.Fatalf("ProvisionFormulas() error: %v", err)
|
||||
}
|
||||
|
||||
// Simulate outdated formula
|
||||
formulasDir := filepath.Join(tmpDir, ".beads", "formulas")
|
||||
installed, err := loadInstalledRecord(formulasDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var targetFormula string
|
||||
for name := range installed.Formulas {
|
||||
targetFormula = name
|
||||
break
|
||||
}
|
||||
if targetFormula == "" {
|
||||
t.Skip("no formulas installed")
|
||||
}
|
||||
|
||||
// Write old content
|
||||
formulaPath := filepath.Join(formulasDir, targetFormula)
|
||||
oldContent := []byte("# Old version\n")
|
||||
if err := os.WriteFile(formulaPath, oldContent, 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Update installed record with old content's hash
|
||||
hash := sha256.Sum256(oldContent)
|
||||
installed.Formulas[targetFormula] = hex.EncodeToString(hash[:])
|
||||
if err := saveInstalledRecord(formulasDir, installed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Run update
|
||||
updated, skipped, reinstalled, err := UpdateFormulas(tmpDir)
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateFormulas() error: %v", err)
|
||||
}
|
||||
|
||||
if updated != 1 {
|
||||
t.Errorf("updated = %d, want 1", updated)
|
||||
}
|
||||
if skipped != 0 {
|
||||
t.Errorf("skipped = %d, want 0", skipped)
|
||||
}
|
||||
if reinstalled != 0 {
|
||||
t.Errorf("reinstalled = %d, want 0", reinstalled)
|
||||
}
|
||||
|
||||
// Verify file was updated
|
||||
report, err := CheckFormulaHealth(tmpDir)
|
||||
if err != nil {
|
||||
t.Fatalf("CheckFormulaHealth() error: %v", err)
|
||||
}
|
||||
if report.Outdated != 0 {
|
||||
t.Errorf("after update, Outdated = %d, want 0", report.Outdated)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateFormulas_SkipsModified tests that user-modified formulas are skipped.
|
||||
func TestUpdateFormulas_SkipsModified(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
// Provision fresh
|
||||
_, err := ProvisionFormulas(tmpDir)
|
||||
if err != nil {
|
||||
t.Fatalf("ProvisionFormulas() error: %v", err)
|
||||
}
|
||||
|
||||
// Modify a formula (user customization)
|
||||
formulasDir := filepath.Join(tmpDir, ".beads", "formulas")
|
||||
installed, err := loadInstalledRecord(formulasDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var targetFormula string
|
||||
for name := range installed.Formulas {
|
||||
targetFormula = name
|
||||
break
|
||||
}
|
||||
if targetFormula == "" {
|
||||
t.Skip("no formulas installed")
|
||||
}
|
||||
|
||||
// Write different content that doesn't match installed hash
|
||||
formulaPath := filepath.Join(formulasDir, targetFormula)
|
||||
modifiedContent := []byte("# User customized this formula\nformula = \"custom\"\n")
|
||||
if err := os.WriteFile(formulaPath, modifiedContent, 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Run update - should skip the modified formula
|
||||
_, skipped, _, err := UpdateFormulas(tmpDir)
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateFormulas() error: %v", err)
|
||||
}
|
||||
|
||||
if skipped != 1 {
|
||||
t.Errorf("skipped = %d, want 1", skipped)
|
||||
}
|
||||
|
||||
// Verify file was NOT changed
|
||||
content, err := os.ReadFile(formulaPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(content) != string(modifiedContent) {
|
||||
t.Error("modified formula should not have been changed")
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateFormulas_ReinstallsMissing tests that deleted formulas are reinstalled.
|
||||
func TestUpdateFormulas_ReinstallsMissing(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
// Provision fresh
|
||||
_, err := ProvisionFormulas(tmpDir)
|
||||
if err != nil {
|
||||
t.Fatalf("ProvisionFormulas() error: %v", err)
|
||||
}
|
||||
|
||||
// Delete a formula
|
||||
formulasDir := filepath.Join(tmpDir, ".beads", "formulas")
|
||||
installed, err := loadInstalledRecord(formulasDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var targetFormula string
|
||||
for name := range installed.Formulas {
|
||||
targetFormula = name
|
||||
break
|
||||
}
|
||||
if targetFormula == "" {
|
||||
t.Skip("no formulas installed")
|
||||
}
|
||||
|
||||
formulaPath := filepath.Join(formulasDir, targetFormula)
|
||||
if err := os.Remove(formulaPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Run update
|
||||
_, _, reinstalled, err := UpdateFormulas(tmpDir)
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateFormulas() error: %v", err)
|
||||
}
|
||||
|
||||
if reinstalled != 1 {
|
||||
t.Errorf("reinstalled = %d, want 1", reinstalled)
|
||||
}
|
||||
|
||||
// Verify file was restored
|
||||
if _, err := os.Stat(formulaPath); os.IsNotExist(err) {
|
||||
t.Error("missing formula should have been reinstalled")
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateFormulas_InstallsNew tests that new formulas are installed.
|
||||
func TestUpdateFormulas_InstallsNew(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
// Create directory structure but with empty installed record
|
||||
formulasDir := filepath.Join(tmpDir, ".beads", "formulas")
|
||||
if err := os.MkdirAll(formulasDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Write empty installed record
|
||||
emptyInstalled := &InstalledRecord{Formulas: make(map[string]string)}
|
||||
if err := saveInstalledRecord(formulasDir, emptyInstalled); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Run update - should install all formulas as "new"
|
||||
updated, skipped, reinstalled, err := UpdateFormulas(tmpDir)
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateFormulas() error: %v", err)
|
||||
}
|
||||
|
||||
// All formulas should be installed
|
||||
embedded, err := getEmbeddedFormulas()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
total := updated + reinstalled
|
||||
if total != len(embedded) {
|
||||
t.Errorf("total installed = %d, want %d", total, len(embedded))
|
||||
}
|
||||
if skipped != 0 {
|
||||
t.Errorf("skipped = %d, want 0", skipped)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInstalledRecordPersistence tests that the installed record survives across operations.
|
||||
func TestInstalledRecordPersistence(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
// Provision
|
||||
count, err := ProvisionFormulas(tmpDir)
|
||||
if err != nil {
|
||||
t.Fatalf("ProvisionFormulas() error: %v", err)
|
||||
}
|
||||
|
||||
// Load and verify
|
||||
formulasDir := filepath.Join(tmpDir, ".beads", "formulas")
|
||||
installed, err := loadInstalledRecord(formulasDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(installed.Formulas) != count {
|
||||
t.Errorf("installed has %d formulas, want %d", len(installed.Formulas), count)
|
||||
}
|
||||
|
||||
// Verify file is valid JSON
|
||||
installedPath := filepath.Join(formulasDir, ".installed.json")
|
||||
data, err := os.ReadFile(installedPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var decoded InstalledRecord
|
||||
if err := json.Unmarshal(data, &decoded); err != nil {
|
||||
t.Errorf("installed.json is not valid JSON: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckFormulaHealth_NewFormula tests detection of new formulas that were never installed.
|
||||
func TestCheckFormulaHealth_NewFormula(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
// Create formulas directory with empty installed record
|
||||
formulasDir := filepath.Join(tmpDir, ".beads", "formulas")
|
||||
if err := os.MkdirAll(formulasDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Write empty installed record - simulates pre-existing install without this formula
|
||||
emptyInstalled := &InstalledRecord{Formulas: make(map[string]string)}
|
||||
if err := saveInstalledRecord(formulasDir, emptyInstalled); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Check health - all embedded formulas should be "new"
|
||||
report, err := CheckFormulaHealth(tmpDir)
|
||||
if err != nil {
|
||||
t.Fatalf("CheckFormulaHealth() error: %v", err)
|
||||
}
|
||||
|
||||
embedded, _ := getEmbeddedFormulas()
|
||||
if report.New != len(embedded) {
|
||||
t.Errorf("New = %d, want %d", report.New, len(embedded))
|
||||
}
|
||||
if report.OK != 0 {
|
||||
t.Errorf("OK = %d, want 0", report.OK)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user