* 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)
307 lines
8.5 KiB
Go
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
|
|
}
|