From 08bfe133d099e7542e8e95ce9ab477599220445a Mon Sep 17 00:00:00 2001 From: Ryan <2199132+rsnodgrass@users.noreply.github.com> Date: Fri, 31 Oct 2025 11:41:13 -0700 Subject: [PATCH] Add 'bd doctor' command to sanity check installation (#189) * Add bd doctor command for installation health checks Implements a comprehensive health check command similar to claude doctor that validates beads installation and provides actionable recommendations. Features: - Installation check (.beads/ directory exists) - Database version verification (compares with CLI version) - ID format detection (hash-based vs sequential) - CLI version check (fetches latest from GitHub) - Storage type detection (SQLite vs JSONL-only mode) - Tree-style output with color-coded warnings - JSON output for scripting (--json flag) - Actionable fix recommendations for each issue Implementation improvements: - Status constants instead of magic strings - Semantic version comparison (fixes 0.10.0 vs 0.9.9 edge case) - Documented defer pattern for intentional error ignore - Comprehensive test coverage including version comparison edge cases - Clean integration using slices.Contains for command list Usage: bd doctor # Check current directory bd doctor /path/to/repo # Check specific repository bd doctor --json # Machine-readable output * Simplify bd doctor documentation in README Reduce verbose health check section to 2 lines as requested. * Fix bd doctor to handle JSONL-only mode for ID format check When no SQLite database exists (JSONL-only mode), skip the ID format check instead of showing an error. This prevents the confusing 'Unable to query issues' error when the installation is actually fine. --- README.md | 4 + cmd/bd/doctor.go | 501 ++++++++++++++++++++++++++++++++++++++++++ cmd/bd/doctor_test.go | 173 +++++++++++++++ cmd/bd/main.go | 4 +- 4 files changed, 681 insertions(+), 1 deletion(-) create mode 100644 cmd/bd/doctor.go create mode 100644 cmd/bd/doctor_test.go diff --git a/README.md b/README.md index abdb6e02..4ea625c2 100644 --- a/README.md +++ b/README.md @@ -273,6 +273,10 @@ bd info ## Usage +### Health Check + +Check installation health: `bd doctor` validates your `.beads/` setup, database version, ID format, and CLI version. Provides actionable fixes for any issues found. + ### Creating Issues ```bash diff --git a/cmd/bd/doctor.go b/cmd/bd/doctor.go new file mode 100644 index 00000000..f3d1f5bf --- /dev/null +++ b/cmd/bd/doctor.go @@ -0,0 +1,501 @@ +package main + +import ( + "database/sql" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "github.com/fatih/color" + "github.com/spf13/cobra" + "github.com/steveyegge/beads" + _ "modernc.org/sqlite" +) + +// Status constants for doctor checks +const ( + statusOK = "ok" + statusWarning = "warning" + statusError = "error" +) + +type doctorCheck struct { + Name string `json:"name"` + Status string `json:"status"` // statusOK, statusWarning, or statusError + Message string `json:"message"` + Detail string `json:"detail,omitempty"` // Additional detail like storage type + Fix string `json:"fix,omitempty"` +} + +type doctorResult struct { + Path string `json:"path"` + Checks []doctorCheck `json:"checks"` + OverallOK bool `json:"overall_ok"` + CLIVersion string `json:"cli_version"` +} + +var doctorCmd = &cobra.Command{ + Use: "doctor [path]", + Short: "Check beads installation health", + Long: `Sanity check the beads installation for the current directory or specified path. + +This command checks: + - If .beads/ directory exists + - Database version and schema compatibility + - Whether using hash-based vs sequential IDs + - If CLI version is current (checks GitHub releases) + +Examples: + bd doctor # Check current directory + bd doctor /path/to/repo # Check specific repository + bd doctor --json # Machine-readable output`, + Run: func(cmd *cobra.Command, args []string) { + // Get json flag from command + jsonOutput, _ := cmd.Flags().GetBool("json") + + // Determine path to check + checkPath := "." + if len(args) > 0 { + checkPath = args[0] + } + + // Convert to absolute path + absPath, err := filepath.Abs(checkPath) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: failed to resolve path: %v\n", err) + os.Exit(1) + } + + // Run diagnostics + result := runDiagnostics(absPath) + + // Output results + if jsonOutput { + outputJSON(result) + } else { + printDiagnostics(result) + } + + // Exit with error if any checks failed + if !result.OverallOK { + os.Exit(1) + } + }, +} + +func runDiagnostics(path string) doctorResult { + result := doctorResult{ + Path: path, + CLIVersion: Version, + OverallOK: true, + } + + // Check 1: Installation (.beads/ directory) + installCheck := checkInstallation(path) + result.Checks = append(result.Checks, installCheck) + if installCheck.Status != statusOK { + result.OverallOK = false + // If no .beads/, skip other checks + return result + } + + // Check 2: Database version + dbCheck := checkDatabaseVersion(path) + result.Checks = append(result.Checks, dbCheck) + if dbCheck.Status == statusError { + result.OverallOK = false + } + + // Check 3: ID format (hash vs sequential) + idCheck := checkIDFormat(path) + result.Checks = append(result.Checks, idCheck) + if idCheck.Status == statusWarning { + result.OverallOK = false + } + + // Check 4: CLI version (GitHub) + versionCheck := checkCLIVersion() + result.Checks = append(result.Checks, versionCheck) + // Don't fail overall check for outdated CLI, just warn + + return result +} + +func checkInstallation(path string) doctorCheck { + beadsDir := filepath.Join(path, ".beads") + if _, err := os.Stat(beadsDir); os.IsNotExist(err) { + // Auto-detect prefix from directory name + prefix := filepath.Base(path) + prefix = strings.TrimRight(prefix, "-") + + return doctorCheck{ + Name: "Installation", + Status: statusError, + Message: "No .beads/ directory found", + Fix: fmt.Sprintf("Run 'bd init --prefix %s' to initialize beads", prefix), + } + } + + return doctorCheck{ + Name: "Installation", + Status: statusOK, + Message: ".beads/ directory found", + } +} + +func checkDatabaseVersion(path string) doctorCheck { + beadsDir := filepath.Join(path, ".beads") + dbPath := filepath.Join(beadsDir, beads.CanonicalDatabaseName) + + // Check if database file exists + if _, err := os.Stat(dbPath); os.IsNotExist(err) { + // Check if JSONL exists (--no-db mode) + jsonlPath := filepath.Join(beadsDir, "issues.jsonl") + if _, err := os.Stat(jsonlPath); err == nil { + return doctorCheck{ + Name: "Database", + Status: statusOK, + Message: "JSONL-only mode", + Detail: "Using issues.jsonl (no SQLite database)", + } + } + + return doctorCheck{ + Name: "Database", + Status: statusError, + Message: "No beads.db found", + Fix: "Run 'bd init' to create database", + } + } + + // Get database version + dbVersion := getDatabaseVersionFromPath(dbPath) + + if dbVersion == "unknown" { + return doctorCheck{ + Name: "Database", + Status: statusError, + Message: "Unable to read database version", + Detail: "Storage: SQLite", + Fix: "Database may be corrupted. Try 'bd migrate'", + } + } + + if dbVersion == "pre-0.17.5" { + return doctorCheck{ + Name: "Database", + Status: statusWarning, + Message: fmt.Sprintf("version %s (very old)", dbVersion), + Detail: "Storage: SQLite", + Fix: "Run 'bd migrate' to upgrade database schema", + } + } + + if dbVersion != Version { + return doctorCheck{ + Name: "Database", + Status: statusWarning, + Message: fmt.Sprintf("version %s (CLI: %s)", dbVersion, Version), + Detail: "Storage: SQLite", + Fix: "Run 'bd migrate' to sync database with CLI version", + } + } + + return doctorCheck{ + Name: "Database", + Status: statusOK, + Message: fmt.Sprintf("version %s", dbVersion), + Detail: "Storage: SQLite", + } +} + +func checkIDFormat(path string) doctorCheck { + beadsDir := filepath.Join(path, ".beads") + dbPath := filepath.Join(beadsDir, beads.CanonicalDatabaseName) + + // Check if using JSONL-only mode + if _, err := os.Stat(dbPath); os.IsNotExist(err) { + // Check if JSONL exists (--no-db mode) + jsonlPath := filepath.Join(beadsDir, "issues.jsonl") + if _, err := os.Stat(jsonlPath); err == nil { + return doctorCheck{ + Name: "Issue IDs", + Status: statusOK, + Message: "N/A (JSONL-only mode)", + } + } + // No database and no JSONL + return doctorCheck{ + Name: "Issue IDs", + Status: statusOK, + Message: "No issues yet (will use hash-based IDs)", + } + } + + // Open database + db, err := sql.Open("sqlite", dbPath+"?mode=ro") + if err != nil { + return doctorCheck{ + Name: "Issue IDs", + Status: statusError, + Message: "Unable to open database", + } + } + defer func() { _ = db.Close() }() // Intentionally ignore close error + + // Get first issue to check ID format + var issueID string + err = db.QueryRow("SELECT id FROM issues ORDER BY created_at LIMIT 1").Scan(&issueID) + if err == sql.ErrNoRows { + return doctorCheck{ + Name: "Issue IDs", + Status: statusOK, + Message: "No issues yet (will use hash-based IDs)", + } + } + if err != nil { + return doctorCheck{ + Name: "Issue IDs", + Status: statusError, + Message: "Unable to query issues", + } + } + + // Detect ID format + if isHashID(issueID) { + return doctorCheck{ + Name: "Issue IDs", + Status: statusOK, + Message: "hash-based ✓", + } + } + + // Sequential IDs - recommend migration + return doctorCheck{ + Name: "Issue IDs", + Status: statusWarning, + Message: "sequential (e.g., bd-1, bd-2, ...)", + Fix: "Run 'bd migrate --to-hash-ids' to upgrade (prevents ID collisions in multi-worker scenarios)", + } +} + +func checkCLIVersion() 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)", Version), + } + } + + if latestVersion == "" || latestVersion == Version { + return doctorCheck{ + Name: "CLI Version", + Status: statusOK, + Message: fmt.Sprintf("%s (latest)", Version), + } + } + + // Compare versions using simple semver-aware comparison + if compareVersions(latestVersion, Version) > 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)", Version, latestVersion), + Fix: fmt.Sprintf("Upgrade to latest version:\n%s", upgradeCmds), + } + } + + return doctorCheck{ + Name: "CLI Version", + Status: statusOK, + Message: fmt.Sprintf("%s (latest)", Version), + } +} + +func getDatabaseVersionFromPath(dbPath string) string { + db, err := sql.Open("sqlite", dbPath+"?mode=ro") + if err != nil { + return "unknown" + } + defer db.Close() + + // Try to read version from metadata table + var version string + err = db.QueryRow("SELECT value FROM metadata WHERE key = 'bd_version'").Scan(&version) + if err == nil { + return version + } + + // Check if metadata table exists + var tableName string + err = db.QueryRow(` + SELECT name FROM sqlite_master + WHERE type='table' AND name='metadata' + `).Scan(&tableName) + + if err == sql.ErrNoRows { + return "pre-0.17.5" + } + + return "unknown" +} + +// Note: isHashID is defined in migrate_hash_ids.go to avoid duplication + +// 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 +} + +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 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 +} + +func printDiagnostics(result doctorResult) { + // Print header + fmt.Println("\nDiagnostics") + + // Print each check with tree formatting + for i, check := range result.Checks { + // Determine prefix + prefix := "├" + if i == len(result.Checks)-1 { + prefix = "└" + } + + // Format status indicator + var statusIcon string + switch check.Status { + case statusOK: + statusIcon = "" + case statusWarning: + statusIcon = color.YellowString(" ⚠") + case statusError: + statusIcon = color.RedString(" ✗") + } + + // Print main check line + fmt.Printf(" %s %s: %s%s\n", prefix, check.Name, check.Message, statusIcon) + + // Print detail if present (indented under the check) + if check.Detail != "" { + detailPrefix := "│" + if i == len(result.Checks)-1 { + detailPrefix = " " + } + fmt.Printf(" %s %s\n", detailPrefix, color.New(color.Faint).Sprint(check.Detail)) + } + } + + fmt.Println() + + // Print warnings/errors with fixes + hasIssues := false + for _, check := range result.Checks { + if check.Status != statusOK && check.Fix != "" { + if !hasIssues { + hasIssues = true + } + + switch check.Status { + case statusWarning: + color.Yellow("⚠ Warning: %s\n", check.Message) + case statusError: + color.Red("✗ Error: %s\n", check.Message) + } + + fmt.Printf(" Fix: %s\n\n", check.Fix) + } + } + + if !hasIssues { + color.Green("✓ All checks passed\n") + } +} + +func init() { + doctorCmd.Flags().Bool("json", false, "Output JSON format") + rootCmd.AddCommand(doctorCmd) +} diff --git a/cmd/bd/doctor_test.go b/cmd/bd/doctor_test.go new file mode 100644 index 00000000..c4f44224 --- /dev/null +++ b/cmd/bd/doctor_test.go @@ -0,0 +1,173 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +func TestDoctorNoBeadsDir(t *testing.T) { + // Create temporary directory + tmpDir := t.TempDir() + + // Run diagnostics + result := runDiagnostics(tmpDir) + + // Should fail overall + if result.OverallOK { + t.Error("Expected OverallOK to be false when .beads/ directory is missing") + } + + // Check installation check failed + if len(result.Checks) == 0 { + t.Fatal("Expected at least one check") + } + + installCheck := result.Checks[0] + if installCheck.Name != "Installation" { + t.Errorf("Expected first check to be Installation, got %s", installCheck.Name) + } + if installCheck.Status != "error" { + t.Errorf("Expected Installation status to be error, got %s", installCheck.Status) + } + if installCheck.Fix == "" { + t.Error("Expected Installation check to have a fix") + } +} + +func TestDoctorWithBeadsDir(t *testing.T) { + // Create temporary directory with .beads + tmpDir := t.TempDir() + beadsDir := filepath.Join(tmpDir, ".beads") + if err := os.Mkdir(beadsDir, 0750); err != nil { + t.Fatal(err) + } + + // Run diagnostics + result := runDiagnostics(tmpDir) + + // Should have installation check passing + if len(result.Checks) == 0 { + t.Fatal("Expected at least one check") + } + + installCheck := result.Checks[0] + if installCheck.Name != "Installation" { + t.Errorf("Expected first check to be Installation, got %s", installCheck.Name) + } + if installCheck.Status != "ok" { + t.Errorf("Expected Installation status to be ok, got %s", installCheck.Status) + } +} + +func TestDoctorJSONOutput(t *testing.T) { + // Create temporary directory + tmpDir := t.TempDir() + + // Run diagnostics + result := runDiagnostics(tmpDir) + + // Marshal to JSON to verify structure + jsonBytes, err := json.Marshal(result) + if err != nil { + t.Fatalf("Failed to marshal result to JSON: %v", err) + } + + // Unmarshal back to verify structure + var decoded doctorResult + if err := json.Unmarshal(jsonBytes, &decoded); err != nil { + t.Fatalf("Failed to unmarshal JSON: %v", err) + } + + // Verify key fields + if decoded.Path != result.Path { + t.Errorf("Path mismatch: %s != %s", decoded.Path, result.Path) + } + if decoded.CLIVersion != result.CLIVersion { + t.Errorf("CLIVersion mismatch: %s != %s", decoded.CLIVersion, result.CLIVersion) + } + if decoded.OverallOK != result.OverallOK { + t.Errorf("OverallOK mismatch: %v != %v", decoded.OverallOK, result.OverallOK) + } + if len(decoded.Checks) != len(result.Checks) { + t.Errorf("Checks length mismatch: %d != %d", len(decoded.Checks), len(result.Checks)) + } +} + +// Note: isHashID is tested in migrate_hash_ids_test.go + +func TestCheckInstallation(t *testing.T) { + // Test with missing .beads directory + tmpDir := t.TempDir() + check := checkInstallation(tmpDir) + + if check.Status != statusError { + t.Errorf("Expected error status, got %s", check.Status) + } + if check.Fix == "" { + t.Error("Expected fix to be provided") + } + + // Test with existing .beads directory + beadsDir := filepath.Join(tmpDir, ".beads") + if err := os.Mkdir(beadsDir, 0750); err != nil { + t.Fatal(err) + } + + check = checkInstallation(tmpDir) + if check.Status != statusOK { + t.Errorf("Expected ok status, got %s", check.Status) + } +} + +func TestCheckDatabaseVersionJSONLMode(t *testing.T) { + // Create temporary directory with .beads but no database + tmpDir := t.TempDir() + beadsDir := filepath.Join(tmpDir, ".beads") + if err := os.Mkdir(beadsDir, 0750); err != nil { + t.Fatal(err) + } + + // Create empty issues.jsonl to simulate --no-db mode + jsonlPath := filepath.Join(beadsDir, "issues.jsonl") + if err := os.WriteFile(jsonlPath, []byte{}, 0644); err != nil { + t.Fatal(err) + } + + check := checkDatabaseVersion(tmpDir) + + if check.Status != statusOK { + t.Errorf("Expected ok status for JSONL mode, got %s", check.Status) + } + if check.Message != "JSONL-only mode" { + t.Errorf("Expected JSONL-only mode message, got %s", check.Message) + } + if check.Detail == "" { + t.Error("Expected detail field to be set for JSONL mode") + } +} + +func TestCompareVersions(t *testing.T) { + tests := []struct { + v1 string + v2 string + expected int + }{ + {"0.20.1", "0.20.1", 0}, // Equal + {"0.20.1", "0.20.0", 1}, // v1 > v2 + {"0.20.0", "0.20.1", -1}, // v1 < v2 + {"0.10.0", "0.9.9", 1}, // Major.minor comparison + {"1.0.0", "0.99.99", 1}, // Major version difference + {"0.20.1", "0.3.0", 1}, // String comparison would fail this + {"1.2", "1.2.0", 0}, // Different length, equal + {"1.2.1", "1.2", 1}, // Different length, v1 > v2 + } + + for _, tc := range tests { + result := compareVersions(tc.v1, tc.v2) + if result != tc.expected { + t.Errorf("compareVersions(%q, %q) = %d, expected %d", tc.v1, tc.v2, result, tc.expected) + } + } +} diff --git a/cmd/bd/main.go b/cmd/bd/main.go index fc31c055..cb792601 100644 --- a/cmd/bd/main.go +++ b/cmd/bd/main.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "path/filepath" + "slices" "strings" "sync" "time" @@ -111,7 +112,8 @@ var rootCmd = &cobra.Command{ } // Skip database initialization for commands that don't need a database - if cmd.Name() == "init" || cmd.Name() == cmdDaemon || cmd.Name() == "help" || cmd.Name() == "version" || cmd.Name() == "quickstart" { + noDbCommands := []string{"init", cmdDaemon, "help", "version", "quickstart", "doctor"} + if slices.Contains(noDbCommands, cmd.Name()) { return }