feat(deps): Auto-install beads (bd) when missing (GHI #22)

- Add internal/deps package for dependency management
- Check for bd before gt install and gt rig add
- Auto-install bd via go install if missing
- Version check warns if bd is too old (min: 0.43.0)

Closes #22

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
mayor
2026-01-02 12:23:55 -08:00
committed by Steve Yegge
parent 3093bf40ac
commit f3b7640563
4 changed files with 221 additions and 0 deletions

View File

@@ -11,6 +11,7 @@ import (
"github.com/spf13/cobra"
"github.com/steveyegge/gastown/internal/config"
"github.com/steveyegge/gastown/internal/deps"
"github.com/steveyegge/gastown/internal/style"
"github.com/steveyegge/gastown/internal/templates"
"github.com/steveyegge/gastown/internal/workspace"
@@ -105,6 +106,13 @@ func runInstall(cmd *cobra.Command, args []string) error {
style.PrintWarning("Creating HQ inside existing workspace at %s", existingRoot)
}
// Ensure beads (bd) is available before proceeding
if !installNoBeads {
if err := deps.EnsureBeads(true); err != nil {
return fmt.Errorf("beads dependency check failed: %w", err)
}
}
fmt.Printf("%s Creating Gas Town HQ at %s\n\n",
style.Bold.Render("🏭"), style.Dim.Render(absPath))

View File

@@ -11,6 +11,7 @@ import (
"github.com/spf13/cobra"
"github.com/steveyegge/gastown/internal/beads"
"github.com/steveyegge/gastown/internal/config"
"github.com/steveyegge/gastown/internal/deps"
"github.com/steveyegge/gastown/internal/git"
"github.com/steveyegge/gastown/internal/polecat"
"github.com/steveyegge/gastown/internal/refinery"
@@ -193,6 +194,11 @@ func runRigAdd(cmd *cobra.Command, args []string) error {
name := args[0]
gitURL := args[1]
// Ensure beads (bd) is available before proceeding
if err := deps.EnsureBeads(true); err != nil {
return fmt.Errorf("beads dependency check failed: %w", err)
}
// Find workspace
townRoot, err := workspace.FindFromCwdOrError()
if err != nil {

146
internal/deps/beads.go Normal file
View File

@@ -0,0 +1,146 @@
// Package deps manages external dependencies for Gas Town.
package deps
import (
"fmt"
"os/exec"
"regexp"
"strconv"
"strings"
)
// MinBeadsVersion is the minimum compatible beads version for this Gas Town release.
// Update this when Gas Town requires new beads features.
const MinBeadsVersion = "0.43.0"
// BeadsInstallPath is the go install path for beads.
const BeadsInstallPath = "github.com/steveyegge/beads/cmd/bd@latest"
// BeadsStatus represents the state of the beads installation.
type BeadsStatus int
const (
BeadsOK BeadsStatus = iota // bd found, version compatible
BeadsNotFound // bd not in PATH
BeadsTooOld // bd found but version too old
BeadsUnknown // bd found but couldn't parse version
)
// CheckBeads checks if bd is installed and compatible.
// Returns status and the installed version (if found).
func CheckBeads() (BeadsStatus, string) {
// Check if bd exists in PATH
path, err := exec.LookPath("bd")
if err != nil {
return BeadsNotFound, ""
}
_ = path // bd found
// Get version
cmd := exec.Command("bd", "version")
output, err := cmd.Output()
if err != nil {
return BeadsUnknown, ""
}
version := parseBeadsVersion(string(output))
if version == "" {
return BeadsUnknown, ""
}
// Compare versions
if compareVersions(version, MinBeadsVersion) < 0 {
return BeadsTooOld, version
}
return BeadsOK, version
}
// EnsureBeads checks for bd and installs it if missing or outdated.
// Returns nil if bd is available and compatible.
// If autoInstall is true, will attempt to install bd when missing.
func EnsureBeads(autoInstall bool) error {
status, version := CheckBeads()
switch status {
case BeadsOK:
return nil
case BeadsNotFound:
if !autoInstall {
return fmt.Errorf("beads (bd) not found in PATH\n\nInstall with: go install %s", BeadsInstallPath)
}
return installBeads()
case BeadsTooOld:
return fmt.Errorf("beads version %s is too old (minimum: %s)\n\nUpgrade with: go install %s",
version, MinBeadsVersion, BeadsInstallPath)
case BeadsUnknown:
// Found bd but couldn't determine version - proceed with warning
return nil
}
return nil
}
// installBeads runs go install to install the latest beads.
func installBeads() error {
fmt.Printf(" beads (bd) not found. Installing...\n")
cmd := exec.Command("go", "install", BeadsInstallPath)
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("failed to install beads: %s\n%s", err, string(output))
}
// Verify installation
status, version := CheckBeads()
if status == BeadsNotFound {
return fmt.Errorf("beads installed but not in PATH - ensure $GOPATH/bin is in your PATH")
}
if status == BeadsTooOld {
return fmt.Errorf("installed beads %s but minimum required is %s", version, MinBeadsVersion)
}
fmt.Printf(" ✓ Installed beads %s\n", version)
return nil
}
// parseBeadsVersion extracts version from "bd version X.Y.Z ..." output.
func parseBeadsVersion(output string) string {
// Match patterns like "bd version 0.43.0" or "bd version 0.43.0 (dev: ...)"
re := regexp.MustCompile(`bd version (\d+\.\d+\.\d+)`)
matches := re.FindStringSubmatch(output)
if len(matches) >= 2 {
return matches[1]
}
return ""
}
// compareVersions compares two semver strings.
// Returns -1 if a < b, 0 if a == b, 1 if a > b.
func compareVersions(a, b string) int {
aParts := parseVersion(a)
bParts := parseVersion(b)
for i := 0; i < 3; i++ {
if aParts[i] < bParts[i] {
return -1
}
if aParts[i] > bParts[i] {
return 1
}
}
return 0
}
// parseVersion parses "X.Y.Z" into [3]int.
func parseVersion(v string) [3]int {
var parts [3]int
split := strings.Split(v, ".")
for i := 0; i < 3 && i < len(split); i++ {
parts[i], _ = strconv.Atoi(split[i])
}
return parts
}

View File

@@ -0,0 +1,61 @@
package deps
import "testing"
func TestParseBeadsVersion(t *testing.T) {
tests := []struct {
input string
expected string
}{
{"bd version 0.43.0 (dev: main@3e1378e122c6)", "0.43.0"},
{"bd version 0.43.0", "0.43.0"},
{"bd version 1.2.3", "1.2.3"},
{"bd version 10.20.30 (release)", "10.20.30"},
{"some other output", ""},
{"", ""},
}
for _, tt := range tests {
result := parseBeadsVersion(tt.input)
if result != tt.expected {
t.Errorf("parseBeadsVersion(%q) = %q, want %q", tt.input, result, tt.expected)
}
}
}
func TestCompareVersions(t *testing.T) {
tests := []struct {
a, b string
expected int
}{
{"0.43.0", "0.43.0", 0},
{"0.43.0", "0.42.0", 1},
{"0.42.0", "0.43.0", -1},
{"1.0.0", "0.99.99", 1},
{"0.43.1", "0.43.0", 1},
{"0.43.0", "0.43.1", -1},
}
for _, tt := range tests {
result := compareVersions(tt.a, tt.b)
if result != tt.expected {
t.Errorf("compareVersions(%q, %q) = %d, want %d", tt.a, tt.b, result, tt.expected)
}
}
}
func TestCheckBeads(t *testing.T) {
// This test depends on whether bd is installed in the test environment
status, version := CheckBeads()
// We expect bd to be installed in dev environment
if status == BeadsNotFound {
t.Skip("bd not installed, skipping integration test")
}
if status == BeadsOK && version == "" {
t.Error("CheckBeads returned BeadsOK but empty version")
}
t.Logf("CheckBeads: status=%d, version=%s", status, version)
}