Files
beads/cmd/bd/doctor/version.go
Ryan e9be35e374 refactor(doctor): split doctor.go into modular package files (#653)
* refactor(doctor): split doctor.go into modular package files

Split the 3,171-line doctor.go into logical sub-files within the
cmd/bd/doctor/ package, reducing the main file to 834 lines (74% reduction).

New package structure:
- types.go: DoctorCheck struct and status constants
- installation.go: CheckInstallation, CheckMultipleDatabases, CheckPermissions
- git.go: CheckGitHooks, CheckMergeDriver, CheckSyncBranch* checks
- database.go: CheckDatabaseVersion, CheckSchemaCompatibility, CheckDatabaseJSONLSync
- version.go: CheckCLIVersion, CheckMetadataVersionTracking, CompareVersions
- integrity.go: CheckIDFormat, CheckDependencyCycles, CheckTombstones
- daemon.go: CheckDaemonStatus, CheckVersionMismatch
- quick.go: Quick checks for sync-branch and hooks

Updated tests to use exported doctor.CheckXxx() functions and
doctor.StatusXxx constants.

* fix(doctor): suppress gosec G204 false positives

Add #nosec G204 comments to exec.Command calls in CheckSyncBranchHealth
where variables come from trusted sources (config files or hardcoded
values like "main"/"master"/"origin"), not untrusted user input.
2025-12-19 17:29:36 -08:00

269 lines
7.1 KiB
Go

package doctor
import (
"encoding/json"
"fmt"
"io"
"net/http"
"path/filepath"
"strings"
"time"
"github.com/steveyegge/beads/internal/configfile"
)
// 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 {
upgradeCmds := ` • Homebrew: brew upgrade bd
• Script: curl -fsSL https://raw.githubusercontent.com/steveyegge/beads/main/scripts/install.sh | bash`
return DoctorCheck{
Name: "CLI Version",
Status: StatusWarning,
Message: fmt.Sprintf("%s (latest: %s)", cliVersion, latestVersion),
Fix: fmt.Sprintf("Upgrade to latest version:\n%s", upgradeCmds),
}
}
return DoctorCheck{
Name: "CLI Version",
Status: StatusOK,
Message: fmt.Sprintf("%s (latest)", cliVersion),
}
}
// CheckMetadataVersionTracking checks if metadata.json has proper version tracking.
func CheckMetadataVersionTracking(path string, currentVersion string) DoctorCheck {
beadsDir := filepath.Join(path, ".beads")
// Load metadata.json
cfg, err := configfile.Load(beadsDir)
if err != nil {
return DoctorCheck{
Name: "Metadata Version Tracking",
Status: StatusError,
Message: "Unable to read metadata.json",
Detail: err.Error(),
Fix: "Ensure metadata.json exists and is valid JSON. Run 'bd init' if needed.",
}
}
// Check if metadata.json exists
if cfg == nil {
return DoctorCheck{
Name: "Metadata Version Tracking",
Status: StatusWarning,
Message: "metadata.json not found",
Fix: "Run any bd command to create metadata.json with version tracking",
}
}
// Check if LastBdVersion field is present
if cfg.LastBdVersion == "" {
return DoctorCheck{
Name: "Metadata Version Tracking",
Status: StatusWarning,
Message: "LastBdVersion field is empty (first run)",
Detail: "Version tracking will be initialized on next command",
Fix: "Run any bd command to initialize version tracking",
}
}
// Validate that LastBdVersion is a valid semver-like string
// Simple validation: should be X.Y.Z format where X, Y, Z are numbers
if !IsValidSemver(cfg.LastBdVersion) {
return DoctorCheck{
Name: "Metadata Version Tracking",
Status: StatusWarning,
Message: fmt.Sprintf("LastBdVersion has invalid format: %q", cfg.LastBdVersion),
Detail: "Expected semver format like '0.24.2'",
Fix: "Run any bd command to reset version tracking to current version",
}
}
// Check if LastBdVersion is very old (> 10 versions behind)
// Calculate version distance
versionDiff := CompareVersions(currentVersion, cfg.LastBdVersion)
if versionDiff > 0 {
// Current version is newer - check how far behind
currentParts := ParseVersionParts(currentVersion)
lastParts := ParseVersionParts(cfg.LastBdVersion)
// 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: "Metadata Version Tracking",
Status: StatusWarning,
Message: fmt.Sprintf("LastBdVersion is very old: %s (current: %s)", cfg.LastBdVersion, 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
return DoctorCheck{
Name: "Metadata Version Tracking",
Status: StatusOK,
Message: fmt.Sprintf("Version tracking active (last: %s, current: %s)", cfg.LastBdVersion, currentVersion),
}
}
// Version is current or ahead (shouldn't happen, but handle it)
return DoctorCheck{
Name: "Metadata Version Tracking",
Status: StatusOK,
Message: fmt.Sprintf("Version tracking active (version: %s)", cfg.LastBdVersion),
}
}
// 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
}