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.
823 lines
20 KiB
Go
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)
|
|
}
|