fix(install): Copy embedded formulas to new installations
When `gt install` creates a new HQ, formulas were not being provisioned to `.beads/formulas/`. This embeds the formula library into the gt binary and copies them during installation. - Add go:generate directive to copy formulas from .beads/formulas/ - Add internal/formula/embed.go with ProvisionFormulas() function - Call ProvisionFormulas() from runInstall() after beads init - Add generate target to Makefile (build depends on it) - Add TestInstallFormulasProvisioned integration test - Log warning if formula stat fails with unexpected error 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -33,3 +33,6 @@ state.json
|
||||
|
||||
# Clone-specific CLAUDE.md (regenerated locally per clone)
|
||||
CLAUDE.md
|
||||
|
||||
# Generated by go:generate from .beads/formulas/
|
||||
internal/formula/formulas/
|
||||
|
||||
7
Makefile
7
Makefile
@@ -1,4 +1,4 @@
|
||||
.PHONY: build install clean test
|
||||
.PHONY: build install clean test generate
|
||||
|
||||
BINARY := gt
|
||||
BUILD_DIR := .
|
||||
@@ -12,7 +12,10 @@ LDFLAGS := -X github.com/steveyegge/gastown/internal/cmd.Version=$(VERSION) \
|
||||
-X github.com/steveyegge/gastown/internal/cmd.Commit=$(COMMIT) \
|
||||
-X github.com/steveyegge/gastown/internal/cmd.BuildTime=$(BUILD_TIME)
|
||||
|
||||
build:
|
||||
generate:
|
||||
go generate ./...
|
||||
|
||||
build: generate
|
||||
go build -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY) ./cmd/gt
|
||||
ifeq ($(shell uname),Darwin)
|
||||
@codesign -s - -f $(BUILD_DIR)/$(BINARY) 2>/dev/null || true
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steveyegge/gastown/internal/config"
|
||||
"github.com/steveyegge/gastown/internal/deps"
|
||||
"github.com/steveyegge/gastown/internal/formula"
|
||||
"github.com/steveyegge/gastown/internal/session"
|
||||
"github.com/steveyegge/gastown/internal/style"
|
||||
"github.com/steveyegge/gastown/internal/templates"
|
||||
@@ -195,6 +196,14 @@ func runInstall(cmd *cobra.Command, args []string) error {
|
||||
fmt.Printf(" %s Could not initialize town beads: %v\n", style.Dim.Render("⚠"), err)
|
||||
} else {
|
||||
fmt.Printf(" ✓ Initialized .beads/ (town-level beads with hq- prefix)\n")
|
||||
|
||||
// Provision embedded formulas to .beads/formulas/
|
||||
if count, err := formula.ProvisionFormulas(absPath); err != nil {
|
||||
// Non-fatal: formulas are optional, just convenience
|
||||
fmt.Printf(" %s Could not provision formulas: %v\n", style.Dim.Render("⚠"), err)
|
||||
} else if count > 0 {
|
||||
fmt.Printf(" ✓ Provisioned %d formulas\n", count)
|
||||
}
|
||||
}
|
||||
|
||||
// NOTE: Agent beads (gt-deacon, gt-mayor) are created by gt rig add,
|
||||
|
||||
@@ -163,6 +163,60 @@ func TestInstallIdempotent(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestInstallFormulasProvisioned validates that embedded formulas are copied
|
||||
// to .beads/formulas/ during installation.
|
||||
func TestInstallFormulasProvisioned(t *testing.T) {
|
||||
// Skip if bd is not available
|
||||
if _, err := exec.LookPath("bd"); err != nil {
|
||||
t.Skip("bd not installed, skipping formulas test")
|
||||
}
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
hqPath := filepath.Join(tmpDir, "test-hq")
|
||||
|
||||
gtBinary := buildGT(t)
|
||||
|
||||
// Run gt install (includes beads and formula provisioning)
|
||||
cmd := exec.Command(gtBinary, "install", hqPath)
|
||||
cmd.Env = append(os.Environ(), "HOME="+tmpDir)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("gt install failed: %v\nOutput: %s", err, output)
|
||||
}
|
||||
|
||||
// Verify .beads/formulas/ directory exists
|
||||
formulasDir := filepath.Join(hqPath, ".beads", "formulas")
|
||||
assertDirExists(t, formulasDir, ".beads/formulas/")
|
||||
|
||||
// Verify at least some expected formulas exist
|
||||
expectedFormulas := []string{
|
||||
"mol-deacon-patrol.formula.toml",
|
||||
"mol-refinery-patrol.formula.toml",
|
||||
"code-review.formula.toml",
|
||||
}
|
||||
for _, f := range expectedFormulas {
|
||||
formulaPath := filepath.Join(formulasDir, f)
|
||||
assertFileExists(t, formulaPath, f)
|
||||
}
|
||||
|
||||
// Verify the count matches embedded formulas
|
||||
entries, err := os.ReadDir(formulasDir)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read formulas dir: %v", err)
|
||||
}
|
||||
// Count only formula files (not directories)
|
||||
var fileCount int
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
fileCount++
|
||||
}
|
||||
}
|
||||
// Should have at least 20 formulas (allows for some variation)
|
||||
if fileCount < 20 {
|
||||
t.Errorf("expected at least 20 formulas, got %d", fileCount)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInstallNoBeadsFlag validates that --no-beads skips beads initialization.
|
||||
func TestInstallNoBeadsFlag(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
62
internal/formula/embed.go
Normal file
62
internal/formula/embed.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package formula
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// Generate formulas directory from canonical source at .beads/formulas/
|
||||
//go:generate sh -c "rm -rf formulas && mkdir -p formulas && cp ../../.beads/formulas/*.formula.toml ../../.beads/formulas/*.formula.json formulas/ 2>/dev/null || cp ../../.beads/formulas/*.formula.toml formulas/"
|
||||
|
||||
//go:embed formulas/*.formula.toml formulas/*.formula.json
|
||||
var formulasFS embed.FS
|
||||
|
||||
// ProvisionFormulas creates the .beads/formulas/ directory with embedded formulas.
|
||||
// This ensures new installations have the standard formula library.
|
||||
// If a formula already exists, it is skipped (no overwrite).
|
||||
// Returns the number of formulas provisioned.
|
||||
func ProvisionFormulas(beadsPath string) (int, error) {
|
||||
entries, err := formulasFS.ReadDir("formulas")
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("reading formulas directory: %w", err)
|
||||
}
|
||||
|
||||
// Create .beads/formulas/ directory
|
||||
formulasDir := filepath.Join(beadsPath, ".beads", "formulas")
|
||||
if err := os.MkdirAll(formulasDir, 0755); err != nil {
|
||||
return 0, fmt.Errorf("creating formulas directory: %w", err)
|
||||
}
|
||||
|
||||
count := 0
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
destPath := filepath.Join(formulasDir, entry.Name())
|
||||
|
||||
// Skip if formula already exists (don't overwrite user customizations)
|
||||
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)
|
||||
continue
|
||||
}
|
||||
|
||||
content, err := formulasFS.ReadFile("formulas/" + entry.Name())
|
||||
if err != nil {
|
||||
return count, fmt.Errorf("reading %s: %w", entry.Name(), err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(destPath, content, 0644); err != nil {
|
||||
return count, fmt.Errorf("writing %s: %w", entry.Name(), err)
|
||||
}
|
||||
count++
|
||||
}
|
||||
|
||||
return count, nil
|
||||
}
|
||||
Reference in New Issue
Block a user