// Package cmd provides CLI commands for the gt tool. package cmd import ( "context" "encoding/json" "fmt" "os" "os/exec" "path/filepath" "regexp" "strconv" "strings" "sync" "time" "github.com/steveyegge/gastown/internal/state" ) // MinBeadsVersion is the minimum required beads version for Gas Town. // This version must include custom type support (bd-i54l). const MinBeadsVersion = "0.44.0" // beadsVersion represents a parsed semantic version. type beadsVersion struct { major int minor int patch int } // parseBeadsVersion parses a version string like "0.44.0" into components. func parseBeadsVersion(v string) (beadsVersion, error) { // Strip leading 'v' if present v = strings.TrimPrefix(v, "v") // Split on dots parts := strings.Split(v, ".") if len(parts) < 2 { return beadsVersion{}, fmt.Errorf("invalid version format: %s", v) } major, err := strconv.Atoi(parts[0]) if err != nil { return beadsVersion{}, fmt.Errorf("invalid major version: %s", parts[0]) } minor, err := strconv.Atoi(parts[1]) if err != nil { return beadsVersion{}, fmt.Errorf("invalid minor version: %s", parts[1]) } patch := 0 if len(parts) >= 3 { // Handle versions like "0.44.0-dev" - take only numeric prefix patchStr := parts[2] if idx := strings.IndexFunc(patchStr, func(r rune) bool { return r < '0' || r > '9' }); idx != -1 { patchStr = patchStr[:idx] } if patchStr != "" { patch, err = strconv.Atoi(patchStr) if err != nil { return beadsVersion{}, fmt.Errorf("invalid patch version: %s", parts[2]) } } } return beadsVersion{major: major, minor: minor, patch: patch}, nil } // compare returns -1 if v < other, 0 if equal, 1 if v > other. func (v beadsVersion) compare(other beadsVersion) int { if v.major != other.major { if v.major < other.major { return -1 } return 1 } if v.minor != other.minor { if v.minor < other.minor { return -1 } return 1 } if v.patch != other.patch { if v.patch < other.patch { return -1 } return 1 } return 0 } // Pre-compiled regex for beads version parsing var beadsVersionRe = regexp.MustCompile(`bd version (\d+\.\d+(?:\.\d+)?(?:-\w+)?)`) // versionCacheTTL is how long a cached version check remains valid. // 24 hours is reasonable since version upgrades are infrequent. const versionCacheTTL = 24 * time.Hour // versionCache stores the result of a beads version check. type versionCache struct { Version string `json:"version"` CheckedAt time.Time `json:"checked_at"` Valid bool `json:"valid"` // true if version meets minimum requirement } // versionCachePath returns the path to the version cache file. func versionCachePath() string { return filepath.Join(state.CacheDir(), "beads-version.json") } // loadVersionCache reads the cached version check result. func loadVersionCache() (*versionCache, error) { data, err := os.ReadFile(versionCachePath()) if err != nil { return nil, err } var cache versionCache if err := json.Unmarshal(data, &cache); err != nil { return nil, err } return &cache, nil } // saveVersionCache writes the version check result to cache. func saveVersionCache(c *versionCache) error { dir := state.CacheDir() if err := os.MkdirAll(dir, 0755); err != nil { return err } data, err := json.MarshalIndent(c, "", " ") if err != nil { return err } // Atomic write via temp file tmp := versionCachePath() + ".tmp" if err := os.WriteFile(tmp, data, 0600); err != nil { return err } return os.Rename(tmp, versionCachePath()) } // isCacheFresh returns true if the cache is within the TTL. func (c *versionCache) isCacheFresh() bool { return time.Since(c.CheckedAt) < versionCacheTTL } func getBeadsVersion() (string, error) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() cmd := exec.CommandContext(ctx, "bd", "version") output, err := cmd.Output() if err != nil { if ctx.Err() == context.DeadlineExceeded { return "", fmt.Errorf("bd version check timed out") } if exitErr, ok := err.(*exec.ExitError); ok { return "", fmt.Errorf("bd version failed: %s", string(exitErr.Stderr)) } return "", fmt.Errorf("failed to run bd: %w (is beads installed?)", err) } // Parse output like "bd version 0.44.0 (dev)" // or "bd version 0.44.0" matches := beadsVersionRe.FindStringSubmatch(string(output)) if len(matches) < 2 { return "", fmt.Errorf("could not parse beads version from: %s", strings.TrimSpace(string(output))) } return matches[1], nil } var ( cachedVersionCheckResult error versionCheckOnce sync.Once ) // CheckBeadsVersion verifies that the installed beads version meets the minimum requirement. // Returns nil if the version is sufficient, or an error with details if not. // The check is performed only once per process execution. func CheckBeadsVersion() error { versionCheckOnce.Do(func() { cachedVersionCheckResult = checkBeadsVersionInternal() }) return cachedVersionCheckResult } func checkBeadsVersionInternal() error { // Try to use cached result first to avoid subprocess spawning if cache, err := loadVersionCache(); err == nil && cache.isCacheFresh() { if cache.Valid { return nil // Cached successful check } // Cached failure - still need to check (version might have been upgraded) } installedStr, err := getBeadsVersion() if err != nil { // On timeout, try to use stale cache or gracefully degrade if strings.Contains(err.Error(), "timed out") { if cache, cacheErr := loadVersionCache(); cacheErr == nil && cache.Valid { // Use stale cache but warn fmt.Fprintf(os.Stderr, "Warning: bd version check timed out, using cached result (v%s)\n", cache.Version) return nil } // No cache available - gracefully degrade with warning fmt.Fprintf(os.Stderr, "Warning: bd version check timed out (high system load?), proceeding anyway\n") return nil } return fmt.Errorf("cannot verify beads version: %w", err) } installed, err := parseBeadsVersion(installedStr) if err != nil { return fmt.Errorf("cannot parse installed beads version %q: %w", installedStr, err) } required, err := parseBeadsVersion(MinBeadsVersion) if err != nil { // This would be a bug in our code return fmt.Errorf("cannot parse required beads version %q: %w", MinBeadsVersion, err) } valid := installed.compare(required) >= 0 // Cache the result _ = saveVersionCache(&versionCache{ Version: installedStr, CheckedAt: time.Now(), Valid: valid, }) if !valid { return fmt.Errorf("beads version %s is required, but %s is installed\n\nPlease upgrade beads: go install github.com/steveyegge/beads/cmd/bd@latest", MinBeadsVersion, installedStr) } return nil }