This commit is contained in:
Steve Yegge
2025-11-20 19:38:14 -05:00
8 changed files with 363 additions and 15 deletions
+77 -10
View File
@@ -17,6 +17,7 @@ import (
_ "github.com/ncruces/go-sqlite3/embed"
"github.com/spf13/cobra"
"github.com/steveyegge/beads/cmd/bd/doctor"
"github.com/steveyegge/beads/cmd/bd/doctor/fix"
"github.com/steveyegge/beads/internal/beads"
"github.com/steveyegge/beads/internal/configfile"
"github.com/steveyegge/beads/internal/daemon"
@@ -133,19 +134,85 @@ func init() {
}
func applyFixes(result doctorResult) {
// Collect all fixable issues
var fixableIssues []doctorCheck
for _, check := range result.Checks {
if check.Status == statusWarning || check.Status == statusError {
switch check.Name {
case "Gitignore":
fmt.Println("Fixing .beads/.gitignore...")
if err := doctor.FixGitignore(); err != nil {
fmt.Fprintf(os.Stderr, " Error: %v\n", err)
} else {
fmt.Println(" ✓ Updated .beads/.gitignore")
}
}
if (check.Status == statusWarning || check.Status == statusError) && check.Fix != "" {
fixableIssues = append(fixableIssues, check)
}
}
if len(fixableIssues) == 0 {
fmt.Println("\nNo fixable issues found.")
return
}
// Show what will be fixed
fmt.Println("\nFixable issues:")
for i, issue := range fixableIssues {
fmt.Printf(" %d. %s: %s\n", i+1, issue.Name, issue.Message)
}
// Ask for confirmation
fmt.Printf("\nThis will attempt to fix %d issue(s). Continue? (Y/n): ", len(fixableIssues))
reader := bufio.NewReader(os.Stdin)
response, err := reader.ReadString('\n')
if err != nil {
fmt.Fprintf(os.Stderr, "Error reading input: %v\n", err)
return
}
response = strings.TrimSpace(strings.ToLower(response))
if response != "" && response != "y" && response != "yes" {
fmt.Println("Fix cancelled.")
return
}
// Apply fixes
fmt.Println("\nApplying fixes...")
fixedCount := 0
errorCount := 0
for _, check := range fixableIssues {
fmt.Printf("\nFixing %s...\n", check.Name)
var err error
switch check.Name {
case "Gitignore":
err = doctor.FixGitignore()
case "Git Hooks":
err = fix.GitHooks(result.Path)
case "Daemon Health":
err = fix.Daemon(result.Path)
case "DB-JSONL Sync":
err = fix.DBJSONLSync(result.Path)
case "Permissions":
err = fix.Permissions(result.Path)
case "Database":
err = fix.DatabaseVersion(result.Path)
case "Schema Compatibility":
err = fix.SchemaCompatibility(result.Path)
default:
fmt.Printf(" ⚠ No automatic fix available for %s\n", check.Name)
fmt.Printf(" Manual fix: %s\n", check.Fix)
continue
}
if err != nil {
errorCount++
color.Red(" ✗ Error: %v\n", err)
fmt.Printf(" Manual fix: %s\n", check.Fix)
} else {
fixedCount++
color.Green(" ✓ Fixed\n")
}
}
// Summary
fmt.Printf("\nFix summary: %d fixed, %d errors\n", fixedCount, errorCount)
if errorCount > 0 {
fmt.Println("\nSome fixes failed. Please review the errors above and apply manual fixes as needed.")
}
}
func runDiagnostics(path string) doctorResult {
+49
View File
@@ -0,0 +1,49 @@
package fix
import (
"fmt"
"os"
"os/exec"
"path/filepath"
)
// getBdBinary returns the path to the bd binary to use for fix operations.
// It prefers the current executable to avoid command injection attacks.
func getBdBinary() (string, error) {
// Prefer current executable for security
exe, err := os.Executable()
if err == nil {
// Resolve symlinks to get the real binary path
realPath, err := filepath.EvalSymlinks(exe)
if err == nil {
return realPath, nil
}
return exe, nil
}
// Fallback to PATH lookup with validation
bdPath, err := exec.LookPath("bd")
if err != nil {
return "", fmt.Errorf("bd binary not found in PATH: %w", err)
}
return bdPath, nil
}
// validateBeadsWorkspace ensures the path is a valid beads workspace before
// attempting any fix operations. This prevents path traversal attacks.
func validateBeadsWorkspace(path string) error {
// Convert to absolute path
absPath, err := filepath.Abs(path)
if err != nil {
return fmt.Errorf("invalid path: %w", err)
}
// Check for .beads directory
beadsDir := filepath.Join(absPath, ".beads")
if _, err := os.Stat(beadsDir); os.IsNotExist(err) {
return fmt.Errorf("not a beads workspace: .beads directory not found at %s", absPath)
}
return nil
}
+48
View File
@@ -0,0 +1,48 @@
package fix
import (
"fmt"
"os"
"os/exec"
"path/filepath"
)
// Daemon fixes daemon issues (stale sockets, version mismatches, duplicates)
// by running bd daemons killall
func Daemon(path string) error {
// Validate workspace
if err := validateBeadsWorkspace(path); err != nil {
return err
}
beadsDir := filepath.Join(path, ".beads")
socketPath := filepath.Join(beadsDir, "bd.sock")
// Check if there's actually a socket or daemon issue to fix
hasSocket := false
if _, err := os.Stat(socketPath); err == nil {
hasSocket = true
}
if !hasSocket {
// No socket, nothing to clean up
return nil
}
// Get bd binary path
bdBinary, err := getBdBinary()
if err != nil {
return err
}
// Run bd daemons killall to clean up stale daemons
cmd := exec.Command(bdBinary, "daemons", "killall") // #nosec G204 -- bdBinary from validated executable path
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("failed to clean up daemons: %w", err)
}
return nil
}
+40
View File
@@ -0,0 +1,40 @@
package fix
import (
"fmt"
"os"
"os/exec"
"path/filepath"
)
// GitHooks fixes missing or broken git hooks by calling bd hooks install
func GitHooks(path string) error {
// Validate workspace
if err := validateBeadsWorkspace(path); err != nil {
return err
}
// Check if we're in a git repository
gitDir := filepath.Join(path, ".git")
if _, err := os.Stat(gitDir); os.IsNotExist(err) {
return fmt.Errorf("not a git repository")
}
// Get bd binary path
bdBinary, err := getBdBinary()
if err != nil {
return err
}
// Run bd hooks install
cmd := exec.Command(bdBinary, "hooks", "install") // #nosec G204 -- bdBinary from validated executable path
cmd.Dir = path // Set working directory without changing process dir
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("failed to install hooks: %w", err)
}
return nil
}
+38
View File
@@ -0,0 +1,38 @@
package fix
import (
"fmt"
"os"
"os/exec"
)
// DatabaseVersion fixes database version mismatches by running bd migrate
func DatabaseVersion(path string) error {
// Validate workspace
if err := validateBeadsWorkspace(path); err != nil {
return err
}
// Get bd binary path
bdBinary, err := getBdBinary()
if err != nil {
return err
}
// Run bd migrate
cmd := exec.Command(bdBinary, "migrate") // #nosec G204 -- bdBinary from validated executable path
cmd.Dir = path // Set working directory without changing process dir
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("failed to migrate database: %w", err)
}
return nil
}
// SchemaCompatibility fixes schema compatibility issues by running bd migrate
func SchemaCompatibility(path string) error {
return DatabaseVersion(path)
}
+49
View File
@@ -0,0 +1,49 @@
package fix
import (
"fmt"
"os"
"path/filepath"
)
// Permissions fixes file permission issues in the .beads directory
func Permissions(path string) error {
// Validate workspace
if err := validateBeadsWorkspace(path); err != nil {
return err
}
beadsDir := filepath.Join(path, ".beads")
// Check if .beads/ directory exists
info, err := os.Stat(beadsDir)
if err != nil {
return fmt.Errorf("failed to stat .beads directory: %w", err)
}
// Ensure .beads directory has exactly 0700 permissions (owner rwx only)
expectedDirMode := os.FileMode(0700)
if info.Mode().Perm() != expectedDirMode {
if err := os.Chmod(beadsDir, expectedDirMode); err != nil {
return fmt.Errorf("failed to fix .beads directory permissions: %w", err)
}
}
// Fix permissions on database file if it exists
dbPath := filepath.Join(beadsDir, "beads.db")
if dbInfo, err := os.Stat(dbPath); err == nil {
// Ensure database has exactly 0600 permissions (owner rw only)
expectedFileMode := os.FileMode(0600)
currentPerms := dbInfo.Mode().Perm()
requiredPerms := os.FileMode(0600)
// Check if we have both read and write for owner
if currentPerms&requiredPerms != requiredPerms {
if err := os.Chmod(dbPath, expectedFileMode); err != nil {
return fmt.Errorf("failed to fix database permissions: %w", err)
}
}
}
return nil
}
+58
View File
@@ -0,0 +1,58 @@
package fix
import (
"fmt"
"os"
"os/exec"
"path/filepath"
)
// DBJSONLSync fixes database-JSONL sync issues by running bd sync --import-only
func DBJSONLSync(path string) error {
// Validate workspace
if err := validateBeadsWorkspace(path); err != nil {
return err
}
beadsDir := filepath.Join(path, ".beads")
// Check if both database and JSONL exist
dbPath := filepath.Join(beadsDir, "beads.db")
jsonlPath := filepath.Join(beadsDir, "issues.jsonl")
beadsJSONLPath := filepath.Join(beadsDir, "beads.jsonl")
hasDB := false
if _, err := os.Stat(dbPath); err == nil {
hasDB = true
}
hasJSONL := false
if _, err := os.Stat(jsonlPath); err == nil {
hasJSONL = true
} else if _, err := os.Stat(beadsJSONLPath); err == nil {
hasJSONL = true
}
if !hasDB || !hasJSONL {
// Nothing to sync
return nil
}
// Get bd binary path
bdBinary, err := getBdBinary()
if err != nil {
return err
}
// Run bd sync --import-only to import JSONL updates
cmd := exec.Command(bdBinary, "sync", "--import-only") // #nosec G204 -- bdBinary from validated executable path
cmd.Dir = path // Set working directory without changing process dir
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("failed to sync database with JSONL: %w", err)
}
return nil
}
+4 -5
View File
@@ -93,12 +93,11 @@ func CheckGitignore() DoctorCheck {
// FixGitignore updates .beads/.gitignore to the current template
func FixGitignore() error {
gitignorePath := filepath.Join(".beads", ".gitignore")
// Write canonical template
// #nosec G306 -- 0600 is appropriate for gitignore
if err := os.WriteFile(gitignorePath, []byte(GitignoreTemplate), 0600); err != nil {
// Write canonical template with standard git file permissions (world-readable)
if err := os.WriteFile(gitignorePath, []byte(GitignoreTemplate), 0644); err != nil {
return err
}
return nil
}