Files
beads/cmd/bd/doctor.go
Steve Yegge d12b5b7221 Fix bd doctor to respect custom database names from config.json
Fixes #197: bd doctor was hardcoded to look for beads.db and didn't
check config.json for custom database names like beady.db.

Now both checkDatabaseVersion() and checkIDFormat() check config.json
first before falling back to the canonical database name.
2025-11-01 11:56:48 -07:00

823 lines
20 KiB
Go

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"
"github.com/steveyegge/beads/internal/configfile"
"github.com/steveyegge/beads/internal/daemon"
_ "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)
- Multiple database files
- Multiple JSONL files
- Daemon health (version mismatches, stale processes)
- Database-JSONL sync status
- File permissions
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
// Check 5: Multiple database files
multiDBCheck := checkMultipleDatabases(path)
result.Checks = append(result.Checks, multiDBCheck)
if multiDBCheck.Status == statusWarning || multiDBCheck.Status == statusError {
result.OverallOK = false
}
// Check 6: Multiple JSONL files
multiJSONLCheck := checkMultipleJSONLFiles(path)
result.Checks = append(result.Checks, multiJSONLCheck)
if multiJSONLCheck.Status == statusWarning || multiJSONLCheck.Status == statusError {
result.OverallOK = false
}
// Check 7: Daemon health
daemonCheck := checkDaemonStatus(path)
result.Checks = append(result.Checks, daemonCheck)
if daemonCheck.Status == statusWarning || daemonCheck.Status == statusError {
result.OverallOK = false
}
// Check 8: Database-JSONL sync
syncCheck := checkDatabaseJSONLSync(path)
result.Checks = append(result.Checks, syncCheck)
if syncCheck.Status == statusWarning || syncCheck.Status == statusError {
result.OverallOK = false
}
// Check 9: Permissions
permCheck := checkPermissions(path)
result.Checks = append(result.Checks, permCheck)
if permCheck.Status == statusError {
result.OverallOK = false
}
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")
// Check config.json first for custom database name
var dbPath string
if cfg, err := configfile.Load(beadsDir); err == nil && cfg != nil && cfg.Database != "" {
dbPath = cfg.DatabasePath(beadsDir)
} else {
// Fall back to canonical database name
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")
// Check config.json first for custom database name
var dbPath string
if cfg, err := configfile.Load(beadsDir); err == nil && cfg != nil && cfg.Database != "" {
dbPath = cfg.DatabasePath(beadsDir)
} else {
// Fall back to canonical database name
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 checkMultipleDatabases(path string) doctorCheck {
beadsDir := filepath.Join(path, ".beads")
// Find all .db files (excluding backups and vc.db)
files, err := filepath.Glob(filepath.Join(beadsDir, "*.db"))
if err != nil {
return doctorCheck{
Name: "Database Files",
Status: statusError,
Message: "Unable to check for multiple databases",
}
}
// Filter out backups and vc.db
var dbFiles []string
for _, f := range files {
base := filepath.Base(f)
if !strings.HasSuffix(base, ".backup.db") && base != "vc.db" {
dbFiles = append(dbFiles, base)
}
}
if len(dbFiles) == 0 {
return doctorCheck{
Name: "Database Files",
Status: statusOK,
Message: "No database files (JSONL-only mode)",
}
}
if len(dbFiles) == 1 {
return doctorCheck{
Name: "Database Files",
Status: statusOK,
Message: "Single database file",
}
}
// Multiple databases found
return doctorCheck{
Name: "Database Files",
Status: statusWarning,
Message: fmt.Sprintf("Multiple database files found: %s", strings.Join(dbFiles, ", ")),
Fix: "Run 'bd migrate' to consolidate databases or manually remove old .db files",
}
}
func checkMultipleJSONLFiles(path string) doctorCheck {
beadsDir := filepath.Join(path, ".beads")
var jsonlFiles []string
for _, name := range []string{"issues.jsonl", "beads.jsonl"} {
jsonlPath := filepath.Join(beadsDir, name)
if _, err := os.Stat(jsonlPath); err == nil {
jsonlFiles = append(jsonlFiles, name)
}
}
if len(jsonlFiles) == 0 {
return doctorCheck{
Name: "JSONL Files",
Status: statusOK,
Message: "No JSONL files found (database-only mode)",
}
}
if len(jsonlFiles) == 1 {
return doctorCheck{
Name: "JSONL Files",
Status: statusOK,
Message: fmt.Sprintf("Using %s", jsonlFiles[0]),
}
}
// Multiple JSONL files found
return doctorCheck{
Name: "JSONL Files",
Status: statusWarning,
Message: fmt.Sprintf("Multiple JSONL files found: %s", strings.Join(jsonlFiles, ", ")),
Fix: "Standardize on one JSONL file (issues.jsonl recommended). Delete or rename the other.",
}
}
func checkDaemonStatus(path string) doctorCheck {
// Import daemon discovery from internal package
daemons, err := daemon.DiscoverDaemons([]string{path})
if err != nil {
return doctorCheck{
Name: "Daemon Health",
Status: statusWarning,
Message: "Unable to check daemon health",
Detail: err.Error(),
}
}
// Filter to this workspace
var workspaceDaemons []daemon.DaemonInfo
for _, d := range daemons {
if d.WorkspacePath == path {
workspaceDaemons = append(workspaceDaemons, d)
}
}
if len(workspaceDaemons) == 0 {
return doctorCheck{
Name: "Daemon Health",
Status: statusOK,
Message: "No daemon running (will auto-start on next command)",
}
}
// Check for version mismatches
for _, d := range workspaceDaemons {
if !d.Alive {
return doctorCheck{
Name: "Daemon Health",
Status: statusWarning,
Message: "Stale daemon detected",
Fix: "Run 'bd daemons killall' to clean up stale daemons",
}
}
if d.Version != Version {
return doctorCheck{
Name: "Daemon Health",
Status: statusWarning,
Message: fmt.Sprintf("Version mismatch (daemon: %s, CLI: %s)", d.Version, Version),
Fix: "Run 'bd daemons killall' to restart daemons with current version",
}
}
}
return doctorCheck{
Name: "Daemon Health",
Status: statusOK,
Message: fmt.Sprintf("Daemon running (PID %d, version %s)", workspaceDaemons[0].PID, workspaceDaemons[0].Version),
}
}
func checkDatabaseJSONLSync(path string) doctorCheck {
beadsDir := filepath.Join(path, ".beads")
dbPath := filepath.Join(beadsDir, beads.CanonicalDatabaseName)
// Find JSONL file
var jsonlPath string
for _, name := range []string{"issues.jsonl", "beads.jsonl"} {
path := filepath.Join(beadsDir, name)
if _, err := os.Stat(path); err == nil {
jsonlPath = path
break
}
}
// If no database, skip this check
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
return doctorCheck{
Name: "DB-JSONL Sync",
Status: statusOK,
Message: "N/A (no database)",
}
}
// If no JSONL, skip this check
if jsonlPath == "" {
return doctorCheck{
Name: "DB-JSONL Sync",
Status: statusOK,
Message: "N/A (no JSONL file)",
}
}
// Compare modification times
dbInfo, err := os.Stat(dbPath)
if err != nil {
return doctorCheck{
Name: "DB-JSONL Sync",
Status: statusWarning,
Message: "Unable to check database file",
}
}
jsonlInfo, err := os.Stat(jsonlPath)
if err != nil {
return doctorCheck{
Name: "DB-JSONL Sync",
Status: statusWarning,
Message: "Unable to check JSONL file",
}
}
// If JSONL is newer, warn about potential sync issue
if jsonlInfo.ModTime().After(dbInfo.ModTime()) {
timeDiff := jsonlInfo.ModTime().Sub(dbInfo.ModTime())
if timeDiff > 30*time.Second {
return doctorCheck{
Name: "DB-JSONL Sync",
Status: statusWarning,
Message: "JSONL is newer than database",
Fix: "Run 'bd sync --import-only' to import JSONL updates",
}
}
}
return doctorCheck{
Name: "DB-JSONL Sync",
Status: statusOK,
Message: "Database and JSONL are in sync",
}
}
func checkPermissions(path string) doctorCheck {
beadsDir := filepath.Join(path, ".beads")
// Check if .beads/ is writable
testFile := filepath.Join(beadsDir, ".doctor-test-write")
if err := os.WriteFile(testFile, []byte("test"), 0600); err != nil {
return doctorCheck{
Name: "Permissions",
Status: statusError,
Message: ".beads/ directory is not writable",
Fix: fmt.Sprintf("Fix permissions: chmod u+w %s", beadsDir),
}
}
_ = os.Remove(testFile) // Clean up test file (intentionally ignore error)
// Check database permissions
dbPath := filepath.Join(beadsDir, beads.CanonicalDatabaseName)
if _, err := os.Stat(dbPath); err == nil {
// Try to open database
db, err := sql.Open("sqlite", dbPath)
if err != nil {
return doctorCheck{
Name: "Permissions",
Status: statusError,
Message: "Database file exists but cannot be opened",
Fix: fmt.Sprintf("Check database permissions: %s", dbPath),
}
}
_ = db.Close() // Intentionally ignore close error
// Try a write test
db, err = sql.Open("sqlite", dbPath)
if err == nil {
_, err = db.Exec("SELECT 1")
_ = db.Close() // Intentionally ignore close error
if err != nil {
return doctorCheck{
Name: "Permissions",
Status: statusError,
Message: "Database file is not readable",
Fix: fmt.Sprintf("Fix permissions: chmod u+rw %s", dbPath),
}
}
}
}
return doctorCheck{
Name: "Permissions",
Status: statusOK,
Message: "All permissions OK",
}
}
func init() {
doctorCmd.Flags().Bool("json", false, "Output JSON format")
rootCmd.AddCommand(doctorCmd)
}