Merge branch 'main' of github.com:steveyegge/beads
This commit is contained in:
@@ -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
501
cmd/bd/doctor.go
Normal 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
173
cmd/bd/doctor_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user