Add "__complete" to noDbCommands list so that Cobra's internal completion command can run without requiring a beads database to be present. Previously, running shell completions (e.g., `bd show <TAB>`) in a directory without a .beads database would fail with "no beads database found" error. Now completions return empty results gracefully when no database exists, allowing basic command completion to work everywhere while issue ID completion still requires a database. Added integration test that verifies __complete command works without a database.
935 lines
33 KiB
Go
935 lines
33 KiB
Go
package main
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"os"
|
||
"os/exec"
|
||
"os/signal"
|
||
"path/filepath"
|
||
"runtime/pprof"
|
||
"runtime/trace"
|
||
"slices"
|
||
"strings"
|
||
"sync"
|
||
"syscall"
|
||
"time"
|
||
|
||
"github.com/spf13/cobra"
|
||
"github.com/steveyegge/beads/internal/beads"
|
||
"github.com/steveyegge/beads/internal/config"
|
||
"github.com/steveyegge/beads/internal/configfile"
|
||
"github.com/steveyegge/beads/internal/debug"
|
||
"github.com/steveyegge/beads/internal/hooks"
|
||
"github.com/steveyegge/beads/internal/molecules"
|
||
"github.com/steveyegge/beads/internal/rpc"
|
||
"github.com/steveyegge/beads/internal/storage"
|
||
"github.com/steveyegge/beads/internal/storage/factory"
|
||
"github.com/steveyegge/beads/internal/storage/memory"
|
||
"github.com/steveyegge/beads/internal/utils"
|
||
)
|
||
|
||
var (
|
||
dbPath string
|
||
actor string
|
||
store storage.Storage
|
||
jsonOutput bool
|
||
daemonStatus DaemonStatus // Tracks daemon connection state for current command
|
||
|
||
// Daemon mode
|
||
daemonClient *rpc.Client // RPC client when daemon is running
|
||
noDaemon bool // Force direct mode (no daemon)
|
||
|
||
// Signal-aware context for graceful cancellation
|
||
rootCtx context.Context
|
||
rootCancel context.CancelFunc
|
||
|
||
// Auto-flush state
|
||
autoFlushEnabled = true // Can be disabled with --no-auto-flush
|
||
flushMutex sync.Mutex
|
||
storeMutex sync.Mutex // Protects store access from background goroutine
|
||
storeActive = false // Tracks if store is available
|
||
flushFailureCount = 0 // Consecutive flush failures
|
||
lastFlushError error // Last flush error for debugging
|
||
|
||
// Auto-flush manager (event-driven, fixes race condition)
|
||
flushManager *FlushManager
|
||
|
||
// Hook runner for extensibility
|
||
hookRunner *hooks.Runner
|
||
|
||
// skipFinalFlush is set by sync command when sync.branch mode completes successfully.
|
||
// This prevents PersistentPostRun from re-exporting and dirtying the working directory.
|
||
skipFinalFlush = false
|
||
|
||
// Auto-import state
|
||
autoImportEnabled = true // Can be disabled with --no-auto-import
|
||
|
||
// Version upgrade tracking
|
||
versionUpgradeDetected = false // Set to true if bd version changed since last run
|
||
previousVersion = "" // The last bd version user had (empty = first run or unknown)
|
||
upgradeAcknowledged = false // Set to true after showing upgrade notification once per session
|
||
)
|
||
var (
|
||
noAutoFlush bool
|
||
noAutoImport bool
|
||
sandboxMode bool
|
||
allowStale bool // Use --allow-stale: skip staleness check (emergency escape hatch)
|
||
noDb bool // Use --no-db mode: load from JSONL, write back after each command
|
||
readonlyMode bool // Read-only mode: block write operations (for worker sandboxes)
|
||
lockTimeout time.Duration // SQLite busy_timeout (default 30s, 0 = fail immediately)
|
||
profileEnabled bool
|
||
profileFile *os.File
|
||
traceFile *os.File
|
||
verboseFlag bool // Enable verbose/debug output
|
||
quietFlag bool // Suppress non-essential output
|
||
)
|
||
|
||
// readOnlyCommands lists commands that only read from the database.
|
||
// These commands open SQLite in read-only mode to avoid modifying the
|
||
// database file (which breaks file watchers). See GH#804.
|
||
var readOnlyCommands = map[string]bool{
|
||
"list": true,
|
||
"ready": true,
|
||
"show": true,
|
||
"stats": true,
|
||
"blocked": true,
|
||
"count": true,
|
||
"search": true,
|
||
"graph": true,
|
||
"duplicates": true,
|
||
"comments": true, // list comments (not add)
|
||
// NOTE: "export" is NOT read-only - it writes to clear dirty issues and update jsonl_file_hash
|
||
}
|
||
|
||
// isReadOnlyCommand returns true if the command only reads from the database.
|
||
// This is used to open SQLite in read-only mode, preventing file modifications
|
||
// that would trigger file watchers. See GH#804.
|
||
func isReadOnlyCommand(cmdName string) bool {
|
||
return readOnlyCommands[cmdName]
|
||
}
|
||
|
||
// getActorWithGit returns the actor for audit trails with git config fallback.
|
||
// Priority: --actor flag > BD_ACTOR env > BEADS_ACTOR env > git config user.name > $USER > "unknown"
|
||
// This provides a sensible default for developers: their git identity is used unless
|
||
// explicitly overridden
|
||
func getActorWithGit() string {
|
||
// If actor is already set (from --actor flag), use it
|
||
if actor != "" {
|
||
return actor
|
||
}
|
||
|
||
// Check BD_ACTOR env var (primary env override)
|
||
if bdActor := os.Getenv("BD_ACTOR"); bdActor != "" {
|
||
return bdActor
|
||
}
|
||
|
||
// Check BEADS_ACTOR env var (alias for MCP/integration compatibility)
|
||
if beadsActor := os.Getenv("BEADS_ACTOR"); beadsActor != "" {
|
||
return beadsActor
|
||
}
|
||
|
||
// Try git config user.name - the natural default for a git-native tool
|
||
if out, err := exec.Command("git", "config", "user.name").Output(); err == nil {
|
||
if gitUser := strings.TrimSpace(string(out)); gitUser != "" {
|
||
return gitUser
|
||
}
|
||
}
|
||
|
||
// Fall back to system username
|
||
if user := os.Getenv("USER"); user != "" {
|
||
return user
|
||
}
|
||
|
||
return "unknown"
|
||
}
|
||
|
||
// getOwner returns the human owner for CV attribution.
|
||
// Priority: GIT_AUTHOR_EMAIL env > git config user.email > "" (empty)
|
||
// This is the foundation for HOP CV (curriculum vitae) chains per Decision 008.
|
||
// Unlike actor (which tracks who executed), owner tracks the human responsible.
|
||
func getOwner() string {
|
||
// Check GIT_AUTHOR_EMAIL first - this is set during git commit operations
|
||
if authorEmail := os.Getenv("GIT_AUTHOR_EMAIL"); authorEmail != "" {
|
||
return authorEmail
|
||
}
|
||
|
||
// Fall back to git config user.email - the natural default
|
||
if out, err := exec.Command("git", "config", "user.email").Output(); err == nil {
|
||
if gitEmail := strings.TrimSpace(string(out)); gitEmail != "" {
|
||
return gitEmail
|
||
}
|
||
}
|
||
|
||
// Return empty if no email found (owner is optional)
|
||
return ""
|
||
}
|
||
|
||
func init() {
|
||
// Initialize viper configuration
|
||
if err := config.Initialize(); err != nil {
|
||
fmt.Fprintf(os.Stderr, "Warning: failed to initialize config: %v\n", err)
|
||
}
|
||
|
||
// Add command groups for organized help output
|
||
rootCmd.AddGroup(
|
||
&cobra.Group{ID: GroupMaintenance, Title: "Maintenance:"},
|
||
&cobra.Group{ID: GroupIntegrations, Title: "Integrations & Advanced:"},
|
||
)
|
||
|
||
// Register persistent flags
|
||
rootCmd.PersistentFlags().StringVar(&dbPath, "db", "", "Database path (default: auto-discover .beads/*.db)")
|
||
rootCmd.PersistentFlags().StringVar(&actor, "actor", "", "Actor name for audit trail (default: $BD_ACTOR, git user.name, $USER)")
|
||
rootCmd.PersistentFlags().BoolVar(&jsonOutput, "json", false, "Output in JSON format")
|
||
rootCmd.PersistentFlags().BoolVar(&noDaemon, "no-daemon", false, "Force direct storage mode, bypass daemon if running")
|
||
rootCmd.PersistentFlags().BoolVar(&noAutoFlush, "no-auto-flush", false, "Disable automatic JSONL sync after CRUD operations")
|
||
rootCmd.PersistentFlags().BoolVar(&noAutoImport, "no-auto-import", false, "Disable automatic JSONL import when newer than DB")
|
||
rootCmd.PersistentFlags().BoolVar(&sandboxMode, "sandbox", false, "Sandbox mode: disables daemon and auto-sync")
|
||
rootCmd.PersistentFlags().BoolVar(&allowStale, "allow-stale", false, "Allow operations on potentially stale data (skip staleness check)")
|
||
rootCmd.PersistentFlags().BoolVar(&noDb, "no-db", false, "Use no-db mode: load from JSONL, no SQLite")
|
||
rootCmd.PersistentFlags().BoolVar(&readonlyMode, "readonly", false, "Read-only mode: block write operations (for worker sandboxes)")
|
||
rootCmd.PersistentFlags().DurationVar(&lockTimeout, "lock-timeout", 30*time.Second, "SQLite busy timeout (0 = fail immediately if locked)")
|
||
rootCmd.PersistentFlags().BoolVar(&profileEnabled, "profile", false, "Generate CPU profile for performance analysis")
|
||
rootCmd.PersistentFlags().BoolVarP(&verboseFlag, "verbose", "v", false, "Enable verbose/debug output")
|
||
rootCmd.PersistentFlags().BoolVarP(&quietFlag, "quiet", "q", false, "Suppress non-essential output (errors only)")
|
||
|
||
// Add --version flag to root command (same behavior as version subcommand)
|
||
rootCmd.Flags().BoolP("version", "V", false, "Print version information")
|
||
|
||
// Command groups for organized help output (Tufte-inspired)
|
||
rootCmd.AddGroup(&cobra.Group{ID: "issues", Title: "Working With Issues:"})
|
||
rootCmd.AddGroup(&cobra.Group{ID: "views", Title: "Views & Reports:"})
|
||
rootCmd.AddGroup(&cobra.Group{ID: "deps", Title: "Dependencies & Structure:"})
|
||
rootCmd.AddGroup(&cobra.Group{ID: "sync", Title: "Sync & Data:"})
|
||
rootCmd.AddGroup(&cobra.Group{ID: "setup", Title: "Setup & Configuration:"})
|
||
// NOTE: Many maintenance commands (clean, cleanup, compact, validate, repair-deps)
|
||
// should eventually be consolidated into 'bd doctor' and 'bd doctor --fix' to simplify
|
||
// the user experience. The doctor command can detect issues and offer fixes interactively.
|
||
rootCmd.AddGroup(&cobra.Group{ID: "maint", Title: "Maintenance:"})
|
||
rootCmd.AddGroup(&cobra.Group{ID: "advanced", Title: "Integrations & Advanced:"})
|
||
|
||
// Custom help function with semantic coloring (Tufte-inspired)
|
||
// Note: Usage output (shown on errors) is not styled to avoid recursion issues
|
||
rootCmd.SetHelpFunc(colorizedHelpFunc)
|
||
}
|
||
|
||
var rootCmd = &cobra.Command{
|
||
Use: "bd",
|
||
Short: "bd - Dependency-aware issue tracker",
|
||
Long: `Issues chained together like beads. A lightweight issue tracker with first-class dependency support.`,
|
||
Run: func(cmd *cobra.Command, args []string) {
|
||
// Handle --version flag on root command
|
||
if v, _ := cmd.Flags().GetBool("version"); v {
|
||
fmt.Printf("bd version %s (%s)\n", Version, Build)
|
||
return
|
||
}
|
||
// No subcommand - show help
|
||
_ = cmd.Help()
|
||
},
|
||
PersistentPreRun: func(cmd *cobra.Command, args []string) {
|
||
// Initialize CommandContext to hold runtime state (replaces scattered globals)
|
||
initCommandContext()
|
||
|
||
// Set up signal-aware context for graceful cancellation
|
||
rootCtx, rootCancel = signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||
|
||
// Signal orchestrator daemon about bd activity (best-effort, for exponential backoff)
|
||
defer signalOrchestratorActivity()
|
||
|
||
// Apply verbosity flags early (before any output)
|
||
debug.SetVerbose(verboseFlag)
|
||
debug.SetQuiet(quietFlag)
|
||
|
||
// Apply viper configuration if flags weren't explicitly set
|
||
// Priority: flags > viper (config file + env vars) > defaults
|
||
// Do this BEFORE early-return so init/version/help respect config
|
||
|
||
// Track flag overrides for notification (only in verbose mode)
|
||
flagOverrides := make(map[string]struct {
|
||
Value interface{}
|
||
WasSet bool
|
||
})
|
||
|
||
// If flag wasn't explicitly set, use viper value
|
||
if !cmd.Flags().Changed("json") {
|
||
jsonOutput = config.GetBool("json")
|
||
} else {
|
||
flagOverrides["json"] = struct {
|
||
Value interface{}
|
||
WasSet bool
|
||
}{jsonOutput, true}
|
||
}
|
||
if !cmd.Flags().Changed("no-daemon") {
|
||
noDaemon = config.GetBool("no-daemon")
|
||
} else {
|
||
flagOverrides["no-daemon"] = struct {
|
||
Value interface{}
|
||
WasSet bool
|
||
}{noDaemon, true}
|
||
}
|
||
if !cmd.Flags().Changed("no-auto-flush") {
|
||
noAutoFlush = config.GetBool("no-auto-flush")
|
||
} else {
|
||
flagOverrides["no-auto-flush"] = struct {
|
||
Value interface{}
|
||
WasSet bool
|
||
}{noAutoFlush, true}
|
||
}
|
||
if !cmd.Flags().Changed("no-auto-import") {
|
||
noAutoImport = config.GetBool("no-auto-import")
|
||
} else {
|
||
flagOverrides["no-auto-import"] = struct {
|
||
Value interface{}
|
||
WasSet bool
|
||
}{noAutoImport, true}
|
||
}
|
||
if !cmd.Flags().Changed("no-db") {
|
||
noDb = config.GetBool("no-db")
|
||
} else {
|
||
flagOverrides["no-db"] = struct {
|
||
Value interface{}
|
||
WasSet bool
|
||
}{noDb, true}
|
||
}
|
||
if !cmd.Flags().Changed("readonly") {
|
||
readonlyMode = config.GetBool("readonly")
|
||
} else {
|
||
flagOverrides["readonly"] = struct {
|
||
Value interface{}
|
||
WasSet bool
|
||
}{readonlyMode, true}
|
||
}
|
||
if !cmd.Flags().Changed("lock-timeout") {
|
||
lockTimeout = config.GetDuration("lock-timeout")
|
||
} else {
|
||
flagOverrides["lock-timeout"] = struct {
|
||
Value interface{}
|
||
WasSet bool
|
||
}{lockTimeout, true}
|
||
}
|
||
if !cmd.Flags().Changed("db") && dbPath == "" {
|
||
dbPath = config.GetString("db")
|
||
} else if cmd.Flags().Changed("db") {
|
||
flagOverrides["db"] = struct {
|
||
Value interface{}
|
||
WasSet bool
|
||
}{dbPath, true}
|
||
}
|
||
if !cmd.Flags().Changed("actor") && actor == "" {
|
||
actor = config.GetString("actor")
|
||
} else if cmd.Flags().Changed("actor") {
|
||
flagOverrides["actor"] = struct {
|
||
Value interface{}
|
||
WasSet bool
|
||
}{actor, true}
|
||
}
|
||
|
||
// Check for and log configuration overrides (only in verbose mode)
|
||
if verboseFlag {
|
||
overrides := config.CheckOverrides(flagOverrides)
|
||
for _, override := range overrides {
|
||
config.LogOverride(override)
|
||
}
|
||
}
|
||
|
||
// Protect forks from accidentally committing upstream issue database
|
||
ensureForkProtection()
|
||
|
||
// Performance profiling setup
|
||
// When --profile is enabled, force direct mode to capture actual database operations
|
||
// rather than just RPC serialization/network overhead. This gives accurate profiles
|
||
// of the storage layer, query performance, and business logic.
|
||
if profileEnabled {
|
||
noDaemon = true
|
||
timestamp := time.Now().Format("20060102-150405")
|
||
if f, _ := os.Create(fmt.Sprintf("bd-profile-%s-%s.prof", cmd.Name(), timestamp)); f != nil {
|
||
profileFile = f
|
||
_ = pprof.StartCPUProfile(f)
|
||
}
|
||
if f, _ := os.Create(fmt.Sprintf("bd-trace-%s-%s.out", cmd.Name(), timestamp)); f != nil {
|
||
traceFile = f
|
||
_ = trace.Start(f)
|
||
}
|
||
}
|
||
|
||
// Skip database initialization for commands that don't need a database
|
||
noDbCommands := []string{
|
||
cmdDaemon,
|
||
"__complete", // Cobra's internal completion command (shell completions work without db)
|
||
"__completeNoDesc", // Cobra's completion without descriptions (used by fish)
|
||
"bash",
|
||
"completion",
|
||
"doctor",
|
||
"fish",
|
||
"help",
|
||
"hooks",
|
||
"human",
|
||
"init",
|
||
"merge",
|
||
"onboard",
|
||
"powershell",
|
||
"prime",
|
||
"quickstart",
|
||
"repair",
|
||
"resolve-conflicts",
|
||
"setup",
|
||
"version",
|
||
"zsh",
|
||
}
|
||
// Check both the command name and parent command name for subcommands
|
||
cmdName := cmd.Name()
|
||
if cmd.Parent() != nil {
|
||
parentName := cmd.Parent().Name()
|
||
if slices.Contains(noDbCommands, parentName) {
|
||
return
|
||
}
|
||
}
|
||
if slices.Contains(noDbCommands, cmdName) {
|
||
return
|
||
}
|
||
|
||
// Skip for root command with no subcommand (just shows help)
|
||
if cmd.Parent() == nil && cmdName == "bd" {
|
||
return
|
||
}
|
||
|
||
// Also skip for --version flag on root command (cmdName would be "bd")
|
||
if v, _ := cmd.Flags().GetBool("version"); v {
|
||
return
|
||
}
|
||
|
||
// Auto-detect sandboxed environment (Phase 2 for GH #353)
|
||
// Only auto-enable if user hasn't explicitly set --sandbox or --no-daemon
|
||
if !cmd.Flags().Changed("sandbox") && !cmd.Flags().Changed("no-daemon") {
|
||
if isSandboxed() {
|
||
sandboxMode = true
|
||
fmt.Fprintf(os.Stderr, "ℹ️ Sandbox detected, using direct mode\n")
|
||
}
|
||
}
|
||
|
||
// If sandbox mode is set, enable all sandbox flags
|
||
if sandboxMode {
|
||
noDaemon = true
|
||
noAutoFlush = true
|
||
noAutoImport = true
|
||
// Use shorter lock timeout in sandbox mode unless explicitly set
|
||
if !cmd.Flags().Changed("lock-timeout") {
|
||
lockTimeout = 100 * time.Millisecond
|
||
}
|
||
}
|
||
|
||
// Force direct mode for human-only interactive commands
|
||
// edit: can take minutes in $EDITOR, daemon connection times out (GH #227)
|
||
if cmd.Name() == "edit" {
|
||
noDaemon = true
|
||
}
|
||
|
||
// Set auto-flush based on flag (invert no-auto-flush)
|
||
autoFlushEnabled = !noAutoFlush
|
||
|
||
// Set auto-import based on flag (invert no-auto-import)
|
||
autoImportEnabled = !noAutoImport
|
||
|
||
// Handle --no-db mode: load from JSONL, use in-memory storage
|
||
if noDb {
|
||
if err := initializeNoDbMode(); err != nil {
|
||
fmt.Fprintf(os.Stderr, "Error initializing --no-db mode: %v\n", err)
|
||
os.Exit(1)
|
||
}
|
||
|
||
// Set actor for audit trail
|
||
actor = getActorWithGit()
|
||
|
||
// Skip daemon and SQLite initialization - we're in memory mode
|
||
return
|
||
}
|
||
|
||
// Initialize database path
|
||
if dbPath == "" {
|
||
// Use public API to find database (same logic as extensions)
|
||
if foundDB := beads.FindDatabasePath(); foundDB != "" {
|
||
dbPath = foundDB
|
||
} else {
|
||
// No database found - check if this is JSONL-only mode
|
||
beadsDir := beads.FindBeadsDir()
|
||
if beadsDir != "" {
|
||
jsonlPath := filepath.Join(beadsDir, "issues.jsonl")
|
||
|
||
// Check if JSONL exists and config.yaml has no-db: true
|
||
jsonlExists := false
|
||
if _, err := os.Stat(jsonlPath); err == nil {
|
||
jsonlExists = true
|
||
}
|
||
|
||
// Use proper YAML parsing to detect no-db mode
|
||
isNoDbMode := isNoDbModeConfigured(beadsDir)
|
||
|
||
// If JSONL-only mode is configured, auto-enable it
|
||
if jsonlExists && isNoDbMode {
|
||
noDb = true
|
||
if err := initializeNoDbMode(); err != nil {
|
||
fmt.Fprintf(os.Stderr, "Error initializing JSONL-only mode: %v\n", err)
|
||
os.Exit(1)
|
||
}
|
||
// Set actor for audit trail
|
||
actor = getActorWithGit()
|
||
return
|
||
}
|
||
}
|
||
|
||
// Allow some commands to run without a database
|
||
// - import: auto-initializes database if missing
|
||
// - setup: creates editor integration files (no DB needed)
|
||
// - config set/get for yaml-only keys: writes to config.yaml, not SQLite (GH#536)
|
||
isYamlOnlyConfigOp := false
|
||
if (cmd.Name() == "set" || cmd.Name() == "get") && cmd.Parent() != nil && cmd.Parent().Name() == "config" {
|
||
if len(args) > 0 && config.IsYamlOnlyKey(args[0]) {
|
||
isYamlOnlyConfigOp = true
|
||
}
|
||
}
|
||
|
||
// Allow read-only commands to auto-bootstrap from JSONL (GH#b09)
|
||
// This enables `bd --no-daemon show` after cold-start when DB is missing
|
||
canAutoBootstrap := false
|
||
if isReadOnlyCommand(cmd.Name()) && beadsDir != "" {
|
||
jsonlPath := filepath.Join(beadsDir, "issues.jsonl")
|
||
if _, err := os.Stat(jsonlPath); err == nil {
|
||
canAutoBootstrap = true
|
||
debug.Logf("cold-start bootstrap: JSONL exists, allowing auto-create for %s", cmd.Name())
|
||
}
|
||
}
|
||
|
||
if cmd.Name() != "import" && cmd.Name() != "setup" && !isYamlOnlyConfigOp && !canAutoBootstrap {
|
||
// No database found - provide context-aware error message
|
||
fmt.Fprintf(os.Stderr, "Error: no beads database found\n")
|
||
|
||
// Check if JSONL exists without no-db mode configured
|
||
if beadsDir != "" {
|
||
jsonlPath := filepath.Join(beadsDir, "issues.jsonl")
|
||
if _, err := os.Stat(jsonlPath); err == nil {
|
||
// JSONL exists but no-db mode not configured
|
||
fmt.Fprintf(os.Stderr, "\nFound JSONL file: %s\n", jsonlPath)
|
||
fmt.Fprintf(os.Stderr, "This looks like a fresh clone or JSONL-only project.\n\n")
|
||
fmt.Fprintf(os.Stderr, "Options:\n")
|
||
fmt.Fprintf(os.Stderr, " • Run 'bd init' to create database and import issues\n")
|
||
fmt.Fprintf(os.Stderr, " • Use 'bd --no-db %s' for JSONL-only mode\n", cmd.Name())
|
||
fmt.Fprintf(os.Stderr, " • Add 'no-db: true' to .beads/config.yaml for permanent JSONL-only mode\n")
|
||
os.Exit(1)
|
||
}
|
||
}
|
||
|
||
// Generic error - no beads directory or JSONL found
|
||
fmt.Fprintf(os.Stderr, "Hint: run 'bd init' to create a database in the current directory\n")
|
||
fmt.Fprintf(os.Stderr, " or use 'bd --no-db' to work with JSONL only (no SQLite)\n")
|
||
fmt.Fprintf(os.Stderr, " or set BEADS_DIR to point to your .beads directory\n")
|
||
os.Exit(1)
|
||
}
|
||
// For import/setup commands, set default database path
|
||
// Invariant: dbPath must always be absolute for filepath.Rel() compatibility
|
||
// in daemon sync-branch code path. Use CanonicalizePath for OS-agnostic
|
||
// handling (symlinks, case normalization on macOS).
|
||
dbPath = utils.CanonicalizePath(filepath.Join(".beads", beads.CanonicalDatabaseName))
|
||
}
|
||
}
|
||
|
||
// Set actor for audit trail
|
||
actor = getActorWithGit()
|
||
|
||
// Track bd version changes
|
||
// Best-effort tracking - failures are silent
|
||
trackBdVersion()
|
||
|
||
// Initialize daemon status
|
||
socketPath := getSocketPath()
|
||
daemonStatus = DaemonStatus{
|
||
Mode: "direct",
|
||
Connected: false,
|
||
Degraded: true,
|
||
SocketPath: socketPath,
|
||
AutoStartEnabled: shouldAutoStartDaemon(),
|
||
FallbackReason: FallbackNone,
|
||
}
|
||
|
||
// Doctor should always run in direct mode. It's specifically used to diagnose and
|
||
// repair daemon/DB issues, so attempting to connect to (or auto-start) a daemon
|
||
// can add noise and timeouts.
|
||
if cmd.Name() == "doctor" {
|
||
noDaemon = true
|
||
}
|
||
|
||
// Wisp operations auto-bypass daemon
|
||
// Wisps are ephemeral (Ephemeral=true) and never exported to JSONL,
|
||
// so daemon can't help anyway. This reduces friction in wisp workflows.
|
||
if isWispOperation(cmd, args) {
|
||
noDaemon = true
|
||
daemonStatus.FallbackReason = FallbackWispOperation
|
||
debug.Logf("wisp operation detected, using direct mode")
|
||
}
|
||
|
||
// Try to connect to daemon first (unless --no-daemon flag is set or worktree safety check fails)
|
||
if noDaemon {
|
||
// Only set FallbackFlagNoDaemon if not already set by auto-bypass logic
|
||
if daemonStatus.FallbackReason == FallbackNone {
|
||
daemonStatus.FallbackReason = FallbackFlagNoDaemon
|
||
debug.Logf("--no-daemon flag set, using direct mode")
|
||
}
|
||
} else if shouldDisableDaemonForWorktree() {
|
||
// In a git worktree without sync-branch configured - daemon is unsafe
|
||
// because all worktrees share the same .beads directory and the daemon
|
||
// would commit to whatever branch its working directory has checked out.
|
||
daemonStatus.FallbackReason = FallbackWorktreeSafety
|
||
debug.Logf("git worktree detected without sync-branch, using direct mode for safety")
|
||
} else {
|
||
// Attempt daemon connection
|
||
client, err := rpc.TryConnect(socketPath)
|
||
if err == nil && client != nil {
|
||
// Set expected database path for validation
|
||
if dbPath != "" {
|
||
absDBPath, _ := filepath.Abs(dbPath)
|
||
client.SetDatabasePath(absDBPath)
|
||
}
|
||
|
||
// Perform health check
|
||
health, healthErr := client.Health()
|
||
if healthErr == nil && health.Status == statusHealthy {
|
||
// Check version compatibility
|
||
if !health.Compatible {
|
||
debug.Logf("daemon version mismatch (daemon: %s, client: %s), restarting daemon",
|
||
health.Version, Version)
|
||
_ = client.Close()
|
||
|
||
// Kill old daemon and restart with new version
|
||
if restartDaemonForVersionMismatch() {
|
||
// Retry connection after restart
|
||
client, err = rpc.TryConnect(socketPath)
|
||
if err == nil && client != nil {
|
||
if dbPath != "" {
|
||
absDBPath, _ := filepath.Abs(dbPath)
|
||
client.SetDatabasePath(absDBPath)
|
||
}
|
||
health, healthErr = client.Health()
|
||
if healthErr == nil && health.Status == statusHealthy {
|
||
client.SetActor(actor)
|
||
daemonClient = client
|
||
daemonStatus.Mode = cmdDaemon
|
||
daemonStatus.Connected = true
|
||
daemonStatus.Degraded = false
|
||
daemonStatus.Health = health.Status
|
||
debug.Logf("connected to restarted daemon (version: %s)", health.Version)
|
||
warnWorktreeDaemon(dbPath)
|
||
return
|
||
}
|
||
}
|
||
}
|
||
// If restart failed, fall through to direct mode
|
||
daemonStatus.FallbackReason = FallbackHealthFailed
|
||
daemonStatus.Detail = fmt.Sprintf("version mismatch (daemon: %s, client: %s) and restart failed",
|
||
health.Version, Version)
|
||
} else {
|
||
// Daemon is healthy and compatible - use it
|
||
client.SetActor(actor)
|
||
daemonClient = client
|
||
daemonStatus.Mode = cmdDaemon
|
||
daemonStatus.Connected = true
|
||
daemonStatus.Degraded = false
|
||
daemonStatus.Health = health.Status
|
||
debug.Logf("connected to daemon at %s (health: %s)", socketPath, health.Status)
|
||
// Warn if using daemon with git worktrees
|
||
warnWorktreeDaemon(dbPath)
|
||
return // Skip direct storage initialization
|
||
}
|
||
} else {
|
||
// Health check failed or daemon unhealthy
|
||
_ = client.Close()
|
||
daemonStatus.FallbackReason = FallbackHealthFailed
|
||
if healthErr != nil {
|
||
daemonStatus.Detail = healthErr.Error()
|
||
debug.Logf("daemon health check failed: %v", healthErr)
|
||
} else {
|
||
daemonStatus.Health = health.Status
|
||
daemonStatus.Detail = health.Error
|
||
debug.Logf("daemon unhealthy (status=%s): %s", health.Status, health.Error)
|
||
}
|
||
}
|
||
} else {
|
||
// Connection failed
|
||
daemonStatus.FallbackReason = FallbackConnectFailed
|
||
if err != nil {
|
||
daemonStatus.Detail = err.Error()
|
||
debug.Logf("daemon connect failed at %s: %v", socketPath, err)
|
||
}
|
||
}
|
||
|
||
// Daemon not running or unhealthy - try auto-start if enabled
|
||
if daemonStatus.AutoStartEnabled {
|
||
daemonStatus.AutoStartAttempted = true
|
||
debug.Logf("attempting to auto-start daemon")
|
||
startTime := time.Now()
|
||
if tryAutoStartDaemon(socketPath) {
|
||
// Retry connection after auto-start
|
||
client, err := rpc.TryConnect(socketPath)
|
||
if err == nil && client != nil {
|
||
// Set expected database path for validation
|
||
if dbPath != "" {
|
||
absDBPath, _ := filepath.Abs(dbPath)
|
||
client.SetDatabasePath(absDBPath)
|
||
}
|
||
|
||
// Check health of auto-started daemon
|
||
health, healthErr := client.Health()
|
||
if healthErr == nil && health.Status == statusHealthy {
|
||
client.SetActor(actor)
|
||
daemonClient = client
|
||
daemonStatus.Mode = cmdDaemon
|
||
daemonStatus.Connected = true
|
||
daemonStatus.Degraded = false
|
||
daemonStatus.AutoStartSucceeded = true
|
||
daemonStatus.Health = health.Status
|
||
daemonStatus.FallbackReason = FallbackNone
|
||
elapsed := time.Since(startTime).Milliseconds()
|
||
debug.Logf("auto-start succeeded; connected at %s in %dms", socketPath, elapsed)
|
||
// Warn if using daemon with git worktrees
|
||
warnWorktreeDaemon(dbPath)
|
||
return // Skip direct storage initialization
|
||
} else {
|
||
// Auto-started daemon is unhealthy
|
||
_ = client.Close()
|
||
daemonStatus.FallbackReason = FallbackHealthFailed
|
||
if healthErr != nil {
|
||
daemonStatus.Detail = healthErr.Error()
|
||
} else {
|
||
daemonStatus.Health = health.Status
|
||
daemonStatus.Detail = health.Error
|
||
}
|
||
debug.Logf("auto-started daemon is unhealthy; falling back to direct mode")
|
||
}
|
||
} else {
|
||
// Auto-start completed but connection still failed
|
||
daemonStatus.FallbackReason = FallbackAutoStartFailed
|
||
if err != nil {
|
||
daemonStatus.Detail = err.Error()
|
||
}
|
||
// Check for daemon-error file to provide better error message
|
||
if beadsDir := filepath.Dir(socketPath); beadsDir != "" {
|
||
errFile := filepath.Join(beadsDir, "daemon-error")
|
||
// nolint:gosec // G304: errFile is derived from secure beads directory
|
||
if errMsg, readErr := os.ReadFile(errFile); readErr == nil && len(errMsg) > 0 {
|
||
fmt.Fprintf(os.Stderr, "\n%s\n", string(errMsg))
|
||
daemonStatus.Detail = string(errMsg)
|
||
}
|
||
}
|
||
debug.Logf("auto-start did not yield a running daemon; falling back to direct mode")
|
||
}
|
||
} else {
|
||
// Auto-start itself failed
|
||
daemonStatus.FallbackReason = FallbackAutoStartFailed
|
||
debug.Logf("auto-start failed; falling back to direct mode")
|
||
}
|
||
} else {
|
||
// Auto-start disabled - preserve the actual failure reason
|
||
// Don't override connect_failed or health_failed with auto_start_disabled
|
||
// This preserves important diagnostic info (daemon crashed vs not running)
|
||
debug.Logf("auto-start disabled by BEADS_AUTO_START_DAEMON")
|
||
}
|
||
|
||
// Emit BD_VERBOSE warning if falling back to direct mode
|
||
if os.Getenv("BD_VERBOSE") != "" {
|
||
emitVerboseWarning()
|
||
}
|
||
|
||
debug.Logf("using direct mode (reason: %s)", daemonStatus.FallbackReason)
|
||
}
|
||
|
||
// Check if this is a read-only command (GH#804)
|
||
// Read-only commands open SQLite in read-only mode to avoid modifying
|
||
// the database file (which breaks file watchers).
|
||
useReadOnly := isReadOnlyCommand(cmd.Name())
|
||
|
||
// Auto-migrate database on version bump
|
||
// Skip for read-only commands - they can't write anyway
|
||
// Do this AFTER daemon check but BEFORE opening database for main operation
|
||
// This ensures: 1) no daemon has DB open, 2) we don't open DB twice
|
||
if !useReadOnly {
|
||
autoMigrateOnVersionBump(dbPath)
|
||
}
|
||
|
||
// Fall back to direct storage access
|
||
var err error
|
||
var needsBootstrap bool // Track if DB needs initial import (GH#b09)
|
||
beadsDir := filepath.Dir(dbPath)
|
||
|
||
// Detect backend from metadata.json
|
||
backend := factory.GetBackendFromConfig(beadsDir)
|
||
|
||
// Create storage with appropriate options
|
||
opts := factory.Options{
|
||
ReadOnly: useReadOnly,
|
||
LockTimeout: lockTimeout,
|
||
}
|
||
|
||
if backend == configfile.BackendDolt {
|
||
// For Dolt, use the dolt subdirectory
|
||
doltPath := filepath.Join(beadsDir, "dolt")
|
||
store, err = factory.NewWithOptions(rootCtx, backend, doltPath, opts)
|
||
} else {
|
||
// SQLite backend
|
||
store, err = factory.NewWithOptions(rootCtx, backend, dbPath, opts)
|
||
if err != nil && useReadOnly {
|
||
// If read-only fails (e.g., DB doesn't exist), fall back to read-write
|
||
// This handles the case where user runs "bd list" before "bd init"
|
||
debug.Logf("read-only open failed, falling back to read-write: %v", err)
|
||
opts.ReadOnly = false
|
||
store, err = factory.NewWithOptions(rootCtx, backend, dbPath, opts)
|
||
needsBootstrap = true // New DB needs auto-import (GH#b09)
|
||
}
|
||
}
|
||
|
||
if err != nil {
|
||
// Check for fresh clone scenario
|
||
if handleFreshCloneError(err, beadsDir) {
|
||
os.Exit(1)
|
||
}
|
||
fmt.Fprintf(os.Stderr, "Error: failed to open database: %v\n", err)
|
||
os.Exit(1)
|
||
}
|
||
|
||
// Mark store as active for flush goroutine safety
|
||
storeMutex.Lock()
|
||
storeActive = true
|
||
storeMutex.Unlock()
|
||
|
||
// Initialize flush manager (fixes race condition in auto-flush)
|
||
// Skip FlushManager creation in sandbox mode - no background goroutines needed
|
||
// (improves Windows exit behavior and container scenarios)
|
||
// Skip for read-only commands - they don't write anything (GH#804)
|
||
// For in-process test scenarios where commands run multiple times,
|
||
// we create a new manager each time. Shutdown() is idempotent so
|
||
// PostRun can safely shutdown whichever manager is active.
|
||
if !sandboxMode && !useReadOnly {
|
||
flushManager = NewFlushManager(autoFlushEnabled, getDebounceDuration())
|
||
}
|
||
|
||
// Initialize hook runner
|
||
// dbPath is .beads/something.db, so workspace root is parent of .beads
|
||
if dbPath != "" {
|
||
beadsDir := filepath.Dir(dbPath)
|
||
hookRunner = hooks.NewRunner(filepath.Join(beadsDir, "hooks"))
|
||
}
|
||
|
||
// Warn if multiple databases detected in directory hierarchy
|
||
warnMultipleDatabases(dbPath)
|
||
|
||
// Auto-import if JSONL is newer than DB (e.g., after git pull)
|
||
// Skip for import command itself to avoid recursion
|
||
// Skip for delete command to prevent resurrection of deleted issues
|
||
// Skip if sync --dry-run to avoid modifying DB in dry-run mode
|
||
// Skip for read-only commands - they can't write anyway (GH#804)
|
||
// Exception: allow auto-import for read-only commands that fell back to
|
||
// read-write mode due to missing DB (needsBootstrap) - fixes GH#b09
|
||
if cmd.Name() != "import" && cmd.Name() != "delete" && autoImportEnabled && (!useReadOnly || needsBootstrap) {
|
||
// Check if this is sync command with --dry-run flag
|
||
if cmd.Name() == "sync" {
|
||
if dryRun, _ := cmd.Flags().GetBool("dry-run"); dryRun {
|
||
// Skip auto-import in dry-run mode
|
||
debug.Logf("auto-import skipped for sync --dry-run")
|
||
} else {
|
||
autoImportIfNewer()
|
||
}
|
||
} else {
|
||
autoImportIfNewer()
|
||
}
|
||
}
|
||
|
||
// Load molecule templates from hierarchical catalog locations
|
||
// Templates are loaded after auto-import to ensure the database is up-to-date.
|
||
// Skip for import command to avoid conflicts during import operations.
|
||
if cmd.Name() != "import" && store != nil {
|
||
beadsDir := filepath.Dir(dbPath)
|
||
loader := molecules.NewLoader(store)
|
||
if result, err := loader.LoadAll(rootCtx, beadsDir); err != nil {
|
||
debug.Logf("warning: failed to load molecules: %v", err)
|
||
} else if result.Loaded > 0 {
|
||
debug.Logf("loaded %d molecules from %v", result.Loaded, result.Sources)
|
||
}
|
||
}
|
||
|
||
// Tips (including sync conflict proactive checks) are shown via maybeShowTip()
|
||
// after successful command execution, not in PreRun
|
||
|
||
// Sync all state to CommandContext for unified access
|
||
syncCommandContext()
|
||
},
|
||
PersistentPostRun: func(cmd *cobra.Command, args []string) {
|
||
// Handle --no-db mode: write memory storage back to JSONL
|
||
if noDb {
|
||
if store != nil {
|
||
// Determine beads directory (respect BEADS_DIR)
|
||
var beadsDir string
|
||
if envDir := os.Getenv("BEADS_DIR"); envDir != "" {
|
||
// Canonicalize the path
|
||
beadsDir = utils.CanonicalizePath(envDir)
|
||
} else {
|
||
// Fall back to current directory
|
||
cwd, err := os.Getwd()
|
||
if err != nil {
|
||
fmt.Fprintf(os.Stderr, "Error: failed to get current directory: %v\n", err)
|
||
os.Exit(1)
|
||
}
|
||
beadsDir = filepath.Join(cwd, ".beads")
|
||
}
|
||
|
||
if memStore, ok := store.(*memory.MemoryStorage); ok {
|
||
if err := writeIssuesToJSONL(memStore, beadsDir); err != nil {
|
||
fmt.Fprintf(os.Stderr, "Error: failed to write JSONL: %v\n", err)
|
||
os.Exit(1)
|
||
}
|
||
}
|
||
}
|
||
return
|
||
}
|
||
|
||
// Close daemon client if we're using it
|
||
if daemonClient != nil {
|
||
_ = daemonClient.Close()
|
||
return
|
||
}
|
||
|
||
// Otherwise, handle direct mode cleanup
|
||
// Shutdown flush manager (performs final flush if needed)
|
||
// Skip if sync command already handled export and restore (sync.branch mode)
|
||
if flushManager != nil && !skipFinalFlush {
|
||
if err := flushManager.Shutdown(); err != nil {
|
||
fmt.Fprintf(os.Stderr, "Warning: flush manager shutdown error: %v\n", err)
|
||
}
|
||
}
|
||
|
||
// Signal that store is closing (prevents background flush from accessing closed store)
|
||
storeMutex.Lock()
|
||
storeActive = false
|
||
storeMutex.Unlock()
|
||
|
||
if store != nil {
|
||
_ = store.Close()
|
||
}
|
||
if profileFile != nil {
|
||
pprof.StopCPUProfile()
|
||
_ = profileFile.Close()
|
||
}
|
||
if traceFile != nil {
|
||
trace.Stop()
|
||
_ = traceFile.Close()
|
||
}
|
||
|
||
// Cancel the signal context to clean up resources
|
||
if rootCancel != nil {
|
||
rootCancel()
|
||
}
|
||
},
|
||
}
|
||
|
||
func main() {
|
||
if err := rootCmd.Execute(); err != nil {
|
||
os.Exit(1)
|
||
}
|
||
}
|