Files
beads/cmd/bd/doctor/version.go
Ryan a11b20960a fix(doctor): UX improvements for diagnostics and daemon (#687)
* fix(doctor): UX improvements for diagnostics and daemon

- Add Repo Fingerprint check to detect when database belongs to a
  different repository (copied .beads dir or git remote URL change)
- Add interactive fix for repo fingerprint with options: update repo ID,
  reinitialize database, or skip
- Add visible warning when daemon takes >5s to start, recommending
  'bd doctor' for diagnosis
- Detect install method (Homebrew vs script) and show only relevant
  upgrade command
- Improve WARNINGS section:
  - Add icons (⚠ or ✖) next to each item
  - Color numbers by severity (yellow for warnings, red for errors)
  - Render entire error lines in red
  - Sort by severity (errors first)
  - Fix alignment with checkmarks above
- Use heavier fail icon (✖) for better visibility
- Add integration and validation tests for doctor fixes

* fix(lint): address errcheck and gosec warnings

- mol_bond.go: explicitly ignore ephStore.Close() error
- beads.go: add nosec for .gitignore file permissions (0644 is standard)
2025-12-22 01:25:23 -08:00

307 lines
8.5 KiB
Go

package doctor
import (
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
)
// CheckCLIVersion checks if the CLI version is up to date.
// Takes cliVersion parameter since it can't access the Version variable from main package.
func CheckCLIVersion(cliVersion string) DoctorCheck {
latestVersion, err := fetchLatestGitHubRelease()
if err != nil {
// Network error or API issue - don't fail, just warn
return DoctorCheck{
Name: "CLI Version",
Status: StatusOK,
Message: fmt.Sprintf("%s (unable to check for updates)", cliVersion),
}
}
if latestVersion == "" || latestVersion == cliVersion {
return DoctorCheck{
Name: "CLI Version",
Status: StatusOK,
Message: fmt.Sprintf("%s (latest)", cliVersion),
}
}
// Compare versions using simple semver-aware comparison
if CompareVersions(latestVersion, cliVersion) > 0 {
upgradeCmd := getUpgradeCommand()
return DoctorCheck{
Name: "CLI Version",
Status: StatusWarning,
Message: fmt.Sprintf("%s (latest: %s)", cliVersion, latestVersion),
Fix: fmt.Sprintf("Upgrade: %s", upgradeCmd),
}
}
return DoctorCheck{
Name: "CLI Version",
Status: StatusOK,
Message: fmt.Sprintf("%s (latest)", cliVersion),
}
}
// getUpgradeCommand returns the appropriate upgrade command based on how bd was installed.
// Detects Homebrew on macOS/Linux, and falls back to the install script on all platforms.
func getUpgradeCommand() string {
// Get the executable path
execPath, err := os.Executable()
if err != nil {
return "curl -fsSL https://raw.githubusercontent.com/steveyegge/beads/main/scripts/install.sh | bash"
}
// Resolve symlinks to get the real path
realPath, err := filepath.EvalSymlinks(execPath)
if err != nil {
realPath = execPath
}
// Normalize to lowercase for comparison
lowerPath := strings.ToLower(realPath)
// Check for Homebrew installation (macOS/Linux)
// Homebrew paths: /opt/homebrew/Cellar/bd, /usr/local/Cellar/bd, /home/linuxbrew/.linuxbrew/Cellar/bd
if strings.Contains(lowerPath, "/cellar/bd/") ||
strings.Contains(lowerPath, "/homebrew/") ||
strings.Contains(lowerPath, "/linuxbrew/") {
return "brew upgrade bd"
}
// Default to install script (works on all platforms including Windows via WSL/Git Bash)
return "curl -fsSL https://raw.githubusercontent.com/steveyegge/beads/main/scripts/install.sh | bash"
}
// localVersionFile is the gitignored file that stores the last bd version used locally.
// Must match the constant in version_tracking.go.
const localVersionFile = ".local_version"
// CheckMetadataVersionTracking checks if version tracking is properly configured.
// Version tracking uses .local_version file (gitignored) to track the last bd version used.
//
// GH#662: This was updated to check .local_version instead of metadata.json:LastBdVersion,
// which is now deprecated.
func CheckMetadataVersionTracking(path string, currentVersion string) DoctorCheck {
beadsDir := filepath.Join(path, ".beads")
localVersionPath := filepath.Join(beadsDir, localVersionFile)
// Read .local_version file
// #nosec G304 - path is constructed from controlled beadsDir + constant
data, err := os.ReadFile(localVersionPath)
if err != nil {
if os.IsNotExist(err) {
// File doesn't exist yet - will be created on next bd command
return DoctorCheck{
Name: "Version Tracking",
Status: StatusWarning,
Message: "Version tracking not initialized",
Detail: "The .local_version file will be created on next bd command",
Fix: "Run any bd command (e.g., 'bd ready') to initialize version tracking",
}
}
// Other error reading file
return DoctorCheck{
Name: "Version Tracking",
Status: StatusError,
Message: "Unable to read .local_version file",
Detail: err.Error(),
Fix: "Check file permissions on .beads/.local_version",
}
}
lastVersion := strings.TrimSpace(string(data))
// Check if file is empty
if lastVersion == "" {
return DoctorCheck{
Name: "Version Tracking",
Status: StatusWarning,
Message: ".local_version file is empty",
Detail: "Version tracking will be initialized on next command",
Fix: "Run any bd command to initialize version tracking",
}
}
// Validate that version is a valid semver-like string
if !IsValidSemver(lastVersion) {
return DoctorCheck{
Name: "Version Tracking",
Status: StatusWarning,
Message: fmt.Sprintf("Invalid version format in .local_version: %q", lastVersion),
Detail: "Expected semver format like '0.24.2'",
Fix: "Run any bd command to reset version tracking to current version",
}
}
// Check if version is very old (> 10 versions behind)
versionDiff := CompareVersions(currentVersion, lastVersion)
if versionDiff > 0 {
// Current version is newer - check how far behind
currentParts := ParseVersionParts(currentVersion)
lastParts := ParseVersionParts(lastVersion)
// Simple heuristic: warn if minor version is 10+ behind or major version differs by 1+
majorDiff := currentParts[0] - lastParts[0]
minorDiff := currentParts[1] - lastParts[1]
if majorDiff >= 1 || (majorDiff == 0 && minorDiff >= 10) {
return DoctorCheck{
Name: "Version Tracking",
Status: StatusWarning,
Message: fmt.Sprintf("Last recorded version is very old: %s (current: %s)", lastVersion, currentVersion),
Detail: "You may have missed important upgrade notifications",
Fix: "Run 'bd upgrade review' to see recent changes",
}
}
// Version is behind but not too old - this is normal after upgrade
return DoctorCheck{
Name: "Version Tracking",
Status: StatusOK,
Message: fmt.Sprintf("Version tracking active (last: %s, current: %s)", lastVersion, currentVersion),
}
}
// Version is current or ahead
return DoctorCheck{
Name: "Version Tracking",
Status: StatusOK,
Message: fmt.Sprintf("Version tracking active (version: %s)", lastVersion),
}
}
// fetchLatestGitHubRelease fetches the latest release version from GitHub API.
func fetchLatestGitHubRelease() (string, error) {
url := "https://api.github.com/repos/steveyegge/beads/releases/latest"
client := &http.Client{
Timeout: 5 * time.Second,
}
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return "", err
}
// Set User-Agent as required by GitHub API
req.Header.Set("User-Agent", "beads-cli-doctor")
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("github api returned status %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
var release struct {
TagName string `json:"tag_name"`
}
if err := json.Unmarshal(body, &release); err != nil {
return "", err
}
// Strip 'v' prefix if present
version := strings.TrimPrefix(release.TagName, "v")
return version, nil
}
// CompareVersions compares two semantic version strings.
// Returns: -1 if v1 < v2, 0 if v1 == v2, 1 if v1 > v2
// Handles versions like "0.20.1", "1.2.3", etc.
func CompareVersions(v1, v2 string) int {
// Split versions into parts
parts1 := strings.Split(v1, ".")
parts2 := strings.Split(v2, ".")
// Compare each part
maxLen := len(parts1)
if len(parts2) > maxLen {
maxLen = len(parts2)
}
for i := 0; i < maxLen; i++ {
var p1, p2 int
// Get part value or default to 0 if part doesn't exist
if i < len(parts1) {
_, _ = fmt.Sscanf(parts1[i], "%d", &p1)
}
if i < len(parts2) {
_, _ = fmt.Sscanf(parts2[i], "%d", &p2)
}
if p1 < p2 {
return -1
}
if p1 > p2 {
return 1
}
}
return 0
}
// IsValidSemver checks if a version string is valid semver-like format (X.Y.Z)
func IsValidSemver(version string) bool {
if version == "" {
return false
}
// Split by dots and ensure all parts are numeric
versionParts := strings.Split(version, ".")
if len(versionParts) < 1 {
return false
}
// Parse each part to ensure it's a valid number
for _, part := range versionParts {
if part == "" {
return false
}
var num int
if _, err := fmt.Sscanf(part, "%d", &num); err != nil {
return false
}
if num < 0 {
return false
}
}
return true
}
// ParseVersionParts parses version string into numeric parts
// Returns [major, minor, patch, ...] or empty slice on error
func ParseVersionParts(version string) []int {
parts := strings.Split(version, ".")
result := make([]int, 0, len(parts))
for _, part := range parts {
var num int
if _, err := fmt.Sscanf(part, "%d", &num); err != nil {
return result
}
result = append(result, num)
}
return result
}