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.
This commit is contained in:
Ryan
2025-10-31 11:41:13 -07:00
committed by GitHub
parent 053edadf14
commit 08bfe133d0
4 changed files with 681 additions and 1 deletions

View File

@@ -273,6 +273,10 @@ bd info
## Usage ## 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 ### Creating Issues
```bash ```bash

501
cmd/bd/doctor.go Normal file
View File

@@ -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)
}

173
cmd/bd/doctor_test.go Normal file
View File

@@ -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)
}
}
}

View File

@@ -4,6 +4,7 @@ import (
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"slices"
"strings" "strings"
"sync" "sync"
"time" "time"
@@ -111,7 +112,8 @@ var rootCmd = &cobra.Command{
} }
// Skip database initialization for commands that don't need a database // 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 return
} }