refactor(daemon): consolidate CLI to subcommands with semantic styling (#1006)
Merging with plan to address in follow-up commits: - Deduplicate daemon_start.go validation logic (call startDaemon()) - Silence deprecation warnings in --json mode for agent ergonomics - Consolidate DaemonStatusReport with DaemonHealthReport - Remove unused outputStatusJSON() - Add tests for new subcommands
This commit is contained in:
@@ -34,14 +34,16 @@ The daemon will:
|
||||
- Auto-import when remote changes detected
|
||||
|
||||
Common operations:
|
||||
bd daemon --start Start the daemon (background)
|
||||
bd daemon --start --foreground Start in foreground (for systemd/supervisord)
|
||||
bd daemon --stop Stop a running daemon
|
||||
bd daemon --stop-all Stop ALL running bd daemons
|
||||
bd daemon --status Check if daemon is running
|
||||
bd daemon --health Check daemon health and metrics
|
||||
bd daemon start Start the daemon (background)
|
||||
bd daemon start --foreground Start in foreground (for systemd/supervisord)
|
||||
bd daemon stop Stop current workspace daemon
|
||||
bd daemon status Show daemon status
|
||||
bd daemon status --all Show all daemons with health check
|
||||
bd daemon logs View daemon logs
|
||||
bd daemon restart Restart daemon
|
||||
bd daemon killall Stop all running daemons
|
||||
|
||||
Run 'bd daemon' with no flags to see available options.`,
|
||||
Run 'bd daemon --help' to see all subcommands.`,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
start, _ := cmd.Flags().GetBool("start")
|
||||
stop, _ := cmd.Flags().GetBool("stop")
|
||||
@@ -65,6 +67,23 @@ Run 'bd daemon' with no flags to see available options.`,
|
||||
return
|
||||
}
|
||||
|
||||
// Show deprecation warnings for flag-based actions
|
||||
if start {
|
||||
fmt.Fprintf(os.Stderr, "Warning: --start is deprecated, use 'bd daemon start' instead\n")
|
||||
}
|
||||
if stop {
|
||||
fmt.Fprintf(os.Stderr, "Warning: --stop is deprecated, use 'bd daemon stop' instead\n")
|
||||
}
|
||||
if stopAll {
|
||||
fmt.Fprintf(os.Stderr, "Warning: --stop-all is deprecated, use 'bd daemon killall' instead\n")
|
||||
}
|
||||
if status {
|
||||
fmt.Fprintf(os.Stderr, "Warning: --status is deprecated, use 'bd daemon status' instead\n")
|
||||
}
|
||||
if health {
|
||||
fmt.Fprintf(os.Stderr, "Warning: --health is deprecated, use 'bd daemon status --all' instead\n")
|
||||
}
|
||||
|
||||
// If auto-commit/auto-push flags weren't explicitly provided, read from config
|
||||
// GH#871: Read from config.yaml first (team-shared), then fall back to SQLite (legacy)
|
||||
// (skip if --stop, --status, --health, --metrics)
|
||||
@@ -206,16 +225,22 @@ Run 'bd daemon' with no flags to see available options.`,
|
||||
}
|
||||
|
||||
func init() {
|
||||
daemonCmd.Flags().Bool("start", false, "Start the daemon")
|
||||
// Register subcommands (preferred interface)
|
||||
daemonCmd.AddCommand(daemonStartCmd)
|
||||
daemonCmd.AddCommand(daemonStatusCmd)
|
||||
// Note: stop, restart, logs, killall, list, health subcommands are registered in daemons.go
|
||||
|
||||
// Legacy flags (deprecated - use subcommands instead)
|
||||
daemonCmd.Flags().Bool("start", false, "Start the daemon (deprecated: use 'bd daemon start')")
|
||||
daemonCmd.Flags().Duration("interval", 5*time.Second, "Sync check interval")
|
||||
daemonCmd.Flags().Bool("auto-commit", false, "Automatically commit changes")
|
||||
daemonCmd.Flags().Bool("auto-push", false, "Automatically push commits")
|
||||
daemonCmd.Flags().Bool("auto-pull", false, "Automatically pull from remote (default: true when sync.branch configured)")
|
||||
daemonCmd.Flags().Bool("local", false, "Run in local-only mode (no git required, no sync)")
|
||||
daemonCmd.Flags().Bool("stop", false, "Stop running daemon")
|
||||
daemonCmd.Flags().Bool("stop-all", false, "Stop all running bd daemons")
|
||||
daemonCmd.Flags().Bool("status", false, "Show daemon status")
|
||||
daemonCmd.Flags().Bool("health", false, "Check daemon health and metrics")
|
||||
daemonCmd.Flags().Bool("stop", false, "Stop running daemon (deprecated: use 'bd daemon stop')")
|
||||
daemonCmd.Flags().Bool("stop-all", false, "Stop all running bd daemons (deprecated: use 'bd daemon killall')")
|
||||
daemonCmd.Flags().Bool("status", false, "Show daemon status (deprecated: use 'bd daemon status')")
|
||||
daemonCmd.Flags().Bool("health", false, "Check daemon health (deprecated: use 'bd daemon status --all')")
|
||||
daemonCmd.Flags().Bool("metrics", false, "Show detailed daemon metrics")
|
||||
daemonCmd.Flags().String("log", "", "Log file path (default: .beads/daemon.log)")
|
||||
daemonCmd.Flags().Bool("foreground", false, "Run in foreground (don't daemonize)")
|
||||
|
||||
151
cmd/bd/daemon_start.go
Normal file
151
cmd/bd/daemon_start.go
Normal file
@@ -0,0 +1,151 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steveyegge/beads/internal/beads"
|
||||
"github.com/steveyegge/beads/internal/rpc"
|
||||
)
|
||||
|
||||
var daemonStartCmd = &cobra.Command{
|
||||
Use: "start",
|
||||
Short: "Start the background daemon",
|
||||
Long: `Start the background daemon that automatically syncs issues with git remote.
|
||||
|
||||
The daemon will:
|
||||
- Poll for changes at configurable intervals (default: 5 seconds)
|
||||
- Export pending database changes to JSONL
|
||||
- Auto-commit changes if --auto-commit flag set
|
||||
- Auto-push commits if --auto-push flag set
|
||||
- Pull remote changes periodically
|
||||
- Auto-import when remote changes detected
|
||||
|
||||
Examples:
|
||||
bd daemon start # Start with defaults
|
||||
bd daemon start --auto-commit # Enable auto-commit
|
||||
bd daemon start --auto-push # Enable auto-push (implies --auto-commit)
|
||||
bd daemon start --foreground # Run in foreground (for systemd/supervisord)
|
||||
bd daemon start --local # Local-only mode (no git sync)`,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
interval, _ := cmd.Flags().GetDuration("interval")
|
||||
autoCommit, _ := cmd.Flags().GetBool("auto-commit")
|
||||
autoPush, _ := cmd.Flags().GetBool("auto-push")
|
||||
autoPull, _ := cmd.Flags().GetBool("auto-pull")
|
||||
localMode, _ := cmd.Flags().GetBool("local")
|
||||
logFile, _ := cmd.Flags().GetString("log")
|
||||
foreground, _ := cmd.Flags().GetBool("foreground")
|
||||
logLevel, _ := cmd.Flags().GetString("log-level")
|
||||
logJSON, _ := cmd.Flags().GetBool("log-json")
|
||||
|
||||
// Load auto-commit/push/pull defaults from env vars, config, or sync-branch
|
||||
autoCommit, autoPush, autoPull = loadDaemonAutoSettings(cmd, autoCommit, autoPush, autoPull)
|
||||
|
||||
if interval <= 0 {
|
||||
fmt.Fprintf(os.Stderr, "Error: interval must be positive (got %v)\n", interval)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
pidFile, err := getPIDFilePath()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Skip daemon-running check if we're the forked child (BD_DAEMON_FOREGROUND=1)
|
||||
if os.Getenv("BD_DAEMON_FOREGROUND") != "1" {
|
||||
// Check if daemon is already running
|
||||
if isRunning, pid := isDaemonRunning(pidFile); isRunning {
|
||||
// Check if running daemon has compatible version
|
||||
socketPath := getSocketPathForPID(pidFile)
|
||||
if client, err := rpc.TryConnectWithTimeout(socketPath, 1*time.Second); err == nil && client != nil {
|
||||
health, healthErr := client.Health()
|
||||
_ = client.Close()
|
||||
|
||||
// If we can check version and it's compatible, exit
|
||||
if healthErr == nil && health.Compatible {
|
||||
fmt.Fprintf(os.Stderr, "Error: daemon already running (PID %d, version %s)\n", pid, health.Version)
|
||||
fmt.Fprintf(os.Stderr, "Use 'bd daemon stop' to stop it first\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Version mismatch - auto-stop old daemon
|
||||
if healthErr == nil && !health.Compatible {
|
||||
fmt.Fprintf(os.Stderr, "Warning: daemon version mismatch (daemon: %s, client: %s)\n", health.Version, Version)
|
||||
fmt.Fprintf(os.Stderr, "Stopping old daemon and starting new one...\n")
|
||||
stopDaemon(pidFile)
|
||||
}
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, "Error: daemon already running (PID %d)\n", pid)
|
||||
fmt.Fprintf(os.Stderr, "Use 'bd daemon stop' to stop it first\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate --local mode constraints
|
||||
if localMode {
|
||||
if autoCommit {
|
||||
fmt.Fprintf(os.Stderr, "Error: --auto-commit cannot be used with --local mode\n")
|
||||
fmt.Fprintf(os.Stderr, "Hint: --local mode runs without git, so commits are not possible\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
if autoPush {
|
||||
fmt.Fprintf(os.Stderr, "Error: --auto-push cannot be used with --local mode\n")
|
||||
fmt.Fprintf(os.Stderr, "Hint: --local mode runs without git, so pushes are not possible\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// Validate we're in a git repo (skip in local mode)
|
||||
if !localMode && !isGitRepo() {
|
||||
fmt.Fprintf(os.Stderr, "Error: not in a git repository\n")
|
||||
fmt.Fprintf(os.Stderr, "Hint: run 'git init' to initialize a repository, or use --local for local-only mode\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Check for upstream if auto-push enabled
|
||||
if autoPush && !gitHasUpstream() {
|
||||
fmt.Fprintf(os.Stderr, "Error: no upstream configured (required for --auto-push)\n")
|
||||
fmt.Fprintf(os.Stderr, "Hint: git push -u origin <branch-name>\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Warn if starting daemon in a git worktree
|
||||
if dbPath == "" {
|
||||
if foundDB := beads.FindDatabasePath(); foundDB != "" {
|
||||
dbPath = foundDB
|
||||
}
|
||||
}
|
||||
if dbPath != "" {
|
||||
warnWorktreeDaemon(dbPath)
|
||||
}
|
||||
|
||||
// Start daemon
|
||||
if localMode {
|
||||
fmt.Printf("Starting bd daemon in LOCAL mode (interval: %v, no git sync)\n", interval)
|
||||
} else {
|
||||
fmt.Printf("Starting bd daemon (interval: %v, auto-commit: %v, auto-push: %v, auto-pull: %v)\n",
|
||||
interval, autoCommit, autoPush, autoPull)
|
||||
}
|
||||
if logFile != "" {
|
||||
fmt.Printf("Logging to: %s\n", logFile)
|
||||
}
|
||||
|
||||
startDaemon(interval, autoCommit, autoPush, autoPull, localMode, foreground, logFile, pidFile, logLevel, logJSON)
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
daemonStartCmd.Flags().Duration("interval", 5*time.Second, "Sync check interval")
|
||||
daemonStartCmd.Flags().Bool("auto-commit", false, "Automatically commit changes")
|
||||
daemonStartCmd.Flags().Bool("auto-push", false, "Automatically push commits")
|
||||
daemonStartCmd.Flags().Bool("auto-pull", false, "Automatically pull from remote")
|
||||
daemonStartCmd.Flags().Bool("local", false, "Run in local-only mode (no git required, no sync)")
|
||||
daemonStartCmd.Flags().String("log", "", "Log file path (default: .beads/daemon.log)")
|
||||
daemonStartCmd.Flags().Bool("foreground", false, "Run in foreground (don't daemonize)")
|
||||
daemonStartCmd.Flags().String("log-level", "info", "Log level (debug, info, warn, error)")
|
||||
daemonStartCmd.Flags().Bool("log-json", false, "Output logs in JSON format")
|
||||
}
|
||||
451
cmd/bd/daemon_status.go
Normal file
451
cmd/bd/daemon_status.go
Normal file
@@ -0,0 +1,451 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steveyegge/beads/internal/daemon"
|
||||
"github.com/steveyegge/beads/internal/rpc"
|
||||
"github.com/steveyegge/beads/internal/ui"
|
||||
)
|
||||
|
||||
// DaemonStatusReport is a single daemon status entry for JSON output
|
||||
type DaemonStatusReport struct {
|
||||
Workspace string `json:"workspace"`
|
||||
PID int `json:"pid,omitempty"`
|
||||
Version string `json:"version,omitempty"`
|
||||
Status string `json:"status"`
|
||||
Issue string `json:"issue,omitempty"`
|
||||
Started string `json:"started,omitempty"`
|
||||
UptimeSeconds float64 `json:"uptime_seconds,omitempty"`
|
||||
AutoCommit bool `json:"auto_commit,omitempty"`
|
||||
AutoPush bool `json:"auto_push,omitempty"`
|
||||
AutoPull bool `json:"auto_pull,omitempty"`
|
||||
LocalMode bool `json:"local_mode,omitempty"`
|
||||
SyncInterval string `json:"sync_interval,omitempty"`
|
||||
DaemonMode string `json:"daemon_mode,omitempty"`
|
||||
LogPath string `json:"log_path,omitempty"`
|
||||
VersionMismatch bool `json:"version_mismatch,omitempty"`
|
||||
IsCurrent bool `json:"is_current,omitempty"`
|
||||
}
|
||||
|
||||
// DaemonStatusAllResponse is returned for --all mode
|
||||
type DaemonStatusAllResponse struct {
|
||||
Total int `json:"total"`
|
||||
Healthy int `json:"healthy"`
|
||||
Outdated int `json:"outdated"`
|
||||
Stale int `json:"stale"`
|
||||
Unresponsive int `json:"unresponsive"`
|
||||
Daemons []DaemonStatusReport `json:"daemons"`
|
||||
}
|
||||
|
||||
var daemonStatusCmd = &cobra.Command{
|
||||
Use: "status",
|
||||
Short: "Show daemon status",
|
||||
Long: `Show status of the current workspace's daemon, or all daemons with --all.
|
||||
|
||||
Examples:
|
||||
bd daemon status # Current workspace daemon
|
||||
bd daemon status --all # All running daemons`,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
showAll, _ := cmd.Flags().GetBool("all")
|
||||
|
||||
if showAll {
|
||||
showAllDaemonsStatus(cmd)
|
||||
} else {
|
||||
showCurrentDaemonStatus()
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
daemonStatusCmd.Flags().Bool("all", false, "Show status of all daemons")
|
||||
daemonStatusCmd.Flags().StringSlice("search", nil, "Directories to search for daemons (with --all)")
|
||||
}
|
||||
|
||||
// shortenPath replaces home directory with ~ for display
|
||||
func shortenPath(p string) string {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return p
|
||||
}
|
||||
if strings.HasPrefix(p, home) {
|
||||
return "~" + p[len(home):]
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// formatRelativeTime formats a time as relative (e.g., "2h ago")
|
||||
func formatRelativeTime(t time.Time) string {
|
||||
d := time.Since(t)
|
||||
if d < time.Minute {
|
||||
return "just now"
|
||||
} else if d < time.Hour {
|
||||
mins := int(d.Minutes())
|
||||
if mins == 1 {
|
||||
return "1m ago"
|
||||
}
|
||||
return fmt.Sprintf("%dm ago", mins)
|
||||
} else if d < 24*time.Hour {
|
||||
hours := int(d.Hours())
|
||||
if hours == 1 {
|
||||
return "1h ago"
|
||||
}
|
||||
return fmt.Sprintf("%dh ago", hours)
|
||||
}
|
||||
days := int(d.Hours() / 24)
|
||||
if days == 1 {
|
||||
return "1d ago"
|
||||
}
|
||||
return fmt.Sprintf("%dd ago", days)
|
||||
}
|
||||
|
||||
// formatBoolIcon returns a styled checkmark or dash for boolean values
|
||||
func formatBoolIcon(enabled bool) string {
|
||||
if enabled {
|
||||
return ui.RenderPass(ui.IconPass)
|
||||
}
|
||||
return ui.RenderMuted("-")
|
||||
}
|
||||
|
||||
// renderDaemonStatusIcon renders status with semantic styling
|
||||
func renderDaemonStatusIcon(status string) string {
|
||||
switch status {
|
||||
case "healthy", "running":
|
||||
return ui.RenderPass(ui.IconPass + " " + status)
|
||||
case "outdated", "version_mismatch":
|
||||
return ui.RenderWarn(ui.IconWarn + " outdated")
|
||||
case "stale":
|
||||
return ui.RenderWarn(ui.IconWarn + " stale")
|
||||
case "unresponsive":
|
||||
return ui.RenderFail(ui.IconFail + " unresponsive")
|
||||
case "not_running":
|
||||
return ui.RenderMuted("○ not running")
|
||||
default:
|
||||
return status
|
||||
}
|
||||
}
|
||||
|
||||
// showCurrentDaemonStatus shows detailed status for current workspace daemon
|
||||
func showCurrentDaemonStatus() {
|
||||
pidFile, err := getPIDFilePath()
|
||||
if err != nil {
|
||||
if jsonOutput {
|
||||
outputJSON(map[string]string{"error": err.Error()})
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
}
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
beadsDir := filepath.Dir(pidFile)
|
||||
socketPath := filepath.Join(beadsDir, "bd.sock")
|
||||
workspacePath := filepath.Dir(beadsDir)
|
||||
|
||||
// Check if daemon is running
|
||||
isRunning, pid := isDaemonRunning(pidFile)
|
||||
if !isRunning {
|
||||
if jsonOutput {
|
||||
outputJSON(DaemonStatusReport{
|
||||
Workspace: workspacePath,
|
||||
Status: "not_running",
|
||||
})
|
||||
} else {
|
||||
fmt.Printf("%s\n\n", renderDaemonStatusIcon("not_running"))
|
||||
fmt.Printf(" Workspace: %s\n", shortenPath(workspacePath))
|
||||
fmt.Printf("\n To start: bd daemon start\n")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Get detailed status via RPC
|
||||
var rpcStatus *rpc.StatusResponse
|
||||
if client, err := rpc.TryConnectWithTimeout(socketPath, 1*time.Second); err == nil && client != nil {
|
||||
if status, err := client.Status(); err == nil {
|
||||
rpcStatus = status
|
||||
}
|
||||
_ = client.Close()
|
||||
}
|
||||
|
||||
// Get started time from PID file
|
||||
var startedTime time.Time
|
||||
if info, err := os.Stat(pidFile); err == nil {
|
||||
startedTime = info.ModTime()
|
||||
}
|
||||
|
||||
// Determine daemon version and check for mismatch
|
||||
daemonVersion := ""
|
||||
versionMismatch := false
|
||||
if rpcStatus != nil {
|
||||
daemonVersion = rpcStatus.Version
|
||||
if daemonVersion != "" && daemonVersion != Version {
|
||||
versionMismatch = true
|
||||
}
|
||||
}
|
||||
|
||||
// Determine status
|
||||
status := "running"
|
||||
issue := ""
|
||||
if versionMismatch {
|
||||
status = "outdated"
|
||||
issue = fmt.Sprintf("daemon %s != cli %s", daemonVersion, Version)
|
||||
}
|
||||
|
||||
// Get log path
|
||||
logPath := filepath.Join(beadsDir, "daemon.log")
|
||||
if _, err := os.Stat(logPath); err != nil {
|
||||
logPath = ""
|
||||
}
|
||||
|
||||
if jsonOutput {
|
||||
report := DaemonStatusReport{
|
||||
Workspace: workspacePath,
|
||||
PID: pid,
|
||||
Version: daemonVersion,
|
||||
Status: status,
|
||||
Issue: issue,
|
||||
LogPath: logPath,
|
||||
VersionMismatch: versionMismatch,
|
||||
IsCurrent: true,
|
||||
}
|
||||
if !startedTime.IsZero() {
|
||||
report.Started = startedTime.Format(time.RFC3339)
|
||||
}
|
||||
if rpcStatus != nil {
|
||||
report.UptimeSeconds = rpcStatus.UptimeSeconds
|
||||
report.AutoCommit = rpcStatus.AutoCommit
|
||||
report.AutoPush = rpcStatus.AutoPush
|
||||
report.AutoPull = rpcStatus.AutoPull
|
||||
report.LocalMode = rpcStatus.LocalMode
|
||||
report.SyncInterval = rpcStatus.SyncInterval
|
||||
report.DaemonMode = rpcStatus.DaemonMode
|
||||
}
|
||||
outputJSON(report)
|
||||
return
|
||||
}
|
||||
|
||||
// Human-readable output with semantic styling
|
||||
// Status line
|
||||
versionStr := ""
|
||||
if daemonVersion != "" {
|
||||
versionStr = fmt.Sprintf(", v%s", daemonVersion)
|
||||
}
|
||||
if versionMismatch {
|
||||
fmt.Printf("%s (PID %d%s)\n", renderDaemonStatusIcon("outdated"), pid, versionStr)
|
||||
fmt.Printf(" %s\n\n", ui.RenderWarn(fmt.Sprintf("CLI version: %s", Version)))
|
||||
} else {
|
||||
fmt.Printf("%s (PID %d%s)\n\n", renderDaemonStatusIcon("running"), pid, versionStr)
|
||||
}
|
||||
|
||||
// Details
|
||||
fmt.Printf(" Workspace: %s\n", shortenPath(workspacePath))
|
||||
if !startedTime.IsZero() {
|
||||
fmt.Printf(" Started: %s (%s)\n", startedTime.Format("2006-01-02 15:04:05"), formatRelativeTime(startedTime))
|
||||
}
|
||||
|
||||
if rpcStatus != nil {
|
||||
fmt.Printf(" Mode: %s\n", rpcStatus.DaemonMode)
|
||||
fmt.Printf(" Interval: %s\n", rpcStatus.SyncInterval)
|
||||
|
||||
// Compact sync flags display
|
||||
syncFlags := []string{}
|
||||
if rpcStatus.AutoCommit {
|
||||
syncFlags = append(syncFlags, ui.RenderPass(ui.IconPass)+" commit")
|
||||
}
|
||||
if rpcStatus.AutoPush {
|
||||
syncFlags = append(syncFlags, ui.RenderPass(ui.IconPass)+" push")
|
||||
}
|
||||
if rpcStatus.AutoPull {
|
||||
syncFlags = append(syncFlags, ui.RenderPass(ui.IconPass)+" pull")
|
||||
}
|
||||
if len(syncFlags) > 0 {
|
||||
fmt.Printf(" Sync: %s\n", strings.Join(syncFlags, " "))
|
||||
} else {
|
||||
fmt.Printf(" Sync: %s\n", ui.RenderMuted("none"))
|
||||
}
|
||||
|
||||
if rpcStatus.LocalMode {
|
||||
fmt.Printf(" Local: %s\n", ui.RenderWarn("yes (no git sync)"))
|
||||
}
|
||||
}
|
||||
|
||||
if logPath != "" {
|
||||
// Show relative path for log
|
||||
relLog := ".beads/daemon.log"
|
||||
fmt.Printf(" Log: %s\n", relLog)
|
||||
}
|
||||
|
||||
// Show hint about other daemons
|
||||
daemons, err := daemon.DiscoverDaemons(nil)
|
||||
if err == nil {
|
||||
aliveCount := 0
|
||||
for _, d := range daemons {
|
||||
if d.Alive {
|
||||
aliveCount++
|
||||
}
|
||||
}
|
||||
if aliveCount > 1 {
|
||||
fmt.Printf("\n %s\n", ui.RenderMuted(fmt.Sprintf("%d other daemon(s) running (bd daemon status --all)", aliveCount-1)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// showAllDaemonsStatus shows status of all daemons
|
||||
func showAllDaemonsStatus(cmd *cobra.Command) {
|
||||
searchRoots, _ := cmd.Flags().GetStringSlice("search")
|
||||
|
||||
// Discover daemons
|
||||
daemons, err := daemon.DiscoverDaemons(searchRoots)
|
||||
if err != nil {
|
||||
if jsonOutput {
|
||||
outputJSON(map[string]string{"error": err.Error()})
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, "Error discovering daemons: %v\n", err)
|
||||
}
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Auto-cleanup stale sockets
|
||||
if cleaned, err := daemon.CleanupStaleSockets(daemons); err == nil && cleaned > 0 && !jsonOutput {
|
||||
fmt.Fprintf(os.Stderr, "Cleaned up %d stale socket(s)\n", cleaned)
|
||||
}
|
||||
|
||||
// Get current workspace to mark it
|
||||
currentWorkspace := ""
|
||||
if pidFile, err := getPIDFilePath(); err == nil {
|
||||
beadsDir := filepath.Dir(pidFile)
|
||||
currentWorkspace = filepath.Dir(beadsDir)
|
||||
}
|
||||
|
||||
currentVersion := Version
|
||||
var reports []DaemonStatusReport
|
||||
healthyCount := 0
|
||||
outdatedCount := 0
|
||||
staleCount := 0
|
||||
unresponsiveCount := 0
|
||||
|
||||
for _, d := range daemons {
|
||||
report := DaemonStatusReport{
|
||||
Workspace: d.WorkspacePath,
|
||||
PID: d.PID,
|
||||
Version: d.Version,
|
||||
IsCurrent: d.WorkspacePath == currentWorkspace,
|
||||
}
|
||||
|
||||
if !d.Alive {
|
||||
report.Status = "stale"
|
||||
report.Issue = d.Error
|
||||
staleCount++
|
||||
} else if d.Version != "" && d.Version != currentVersion {
|
||||
report.Status = "outdated"
|
||||
report.Issue = fmt.Sprintf("daemon %s != cli %s", d.Version, currentVersion)
|
||||
report.VersionMismatch = true
|
||||
outdatedCount++
|
||||
} else {
|
||||
report.Status = "healthy"
|
||||
healthyCount++
|
||||
}
|
||||
|
||||
reports = append(reports, report)
|
||||
}
|
||||
|
||||
if jsonOutput {
|
||||
outputJSON(DaemonStatusAllResponse{
|
||||
Total: len(reports),
|
||||
Healthy: healthyCount,
|
||||
Outdated: outdatedCount,
|
||||
Stale: staleCount,
|
||||
Unresponsive: unresponsiveCount,
|
||||
Daemons: reports,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Human-readable output
|
||||
if len(reports) == 0 {
|
||||
fmt.Println("No daemons found")
|
||||
return
|
||||
}
|
||||
|
||||
// Summary line
|
||||
fmt.Printf("Daemons: %d total", len(reports))
|
||||
if healthyCount > 0 {
|
||||
fmt.Printf(", %s", ui.RenderPass(fmt.Sprintf("%d healthy", healthyCount)))
|
||||
}
|
||||
if outdatedCount > 0 {
|
||||
fmt.Printf(", %s", ui.RenderWarn(fmt.Sprintf("%d outdated", outdatedCount)))
|
||||
}
|
||||
if staleCount > 0 {
|
||||
fmt.Printf(", %s", ui.RenderWarn(fmt.Sprintf("%d stale", staleCount)))
|
||||
}
|
||||
if unresponsiveCount > 0 {
|
||||
fmt.Printf(", %s", ui.RenderFail(fmt.Sprintf("%d unresponsive", unresponsiveCount)))
|
||||
}
|
||||
fmt.Println()
|
||||
fmt.Println()
|
||||
|
||||
// Table
|
||||
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
||||
_, _ = fmt.Fprintln(w, " WORKSPACE\tPID\tVERSION\tSTATUS")
|
||||
|
||||
for _, r := range reports {
|
||||
workspace := shortenPath(r.Workspace)
|
||||
if workspace == "" {
|
||||
workspace = "(unknown)"
|
||||
}
|
||||
|
||||
// Add arrow for current workspace
|
||||
prefix := " "
|
||||
if r.IsCurrent {
|
||||
prefix = ui.RenderAccent("→ ")
|
||||
}
|
||||
|
||||
pidStr := "-"
|
||||
if r.PID != 0 {
|
||||
pidStr = fmt.Sprintf("%d", r.PID)
|
||||
}
|
||||
|
||||
version := r.Version
|
||||
if version == "" {
|
||||
version = "-"
|
||||
}
|
||||
|
||||
// Render status with icon and color
|
||||
var statusDisplay string
|
||||
switch r.Status {
|
||||
case "healthy":
|
||||
statusDisplay = ui.RenderPass(ui.IconPass + " healthy")
|
||||
case "outdated":
|
||||
statusDisplay = ui.RenderWarn(ui.IconWarn + " outdated")
|
||||
// Add version hint
|
||||
statusDisplay += ui.RenderMuted(fmt.Sprintf(" (cli: %s)", currentVersion))
|
||||
case "stale":
|
||||
statusDisplay = ui.RenderWarn(ui.IconWarn + " stale")
|
||||
case "unresponsive":
|
||||
statusDisplay = ui.RenderFail(ui.IconFail + " unresponsive")
|
||||
default:
|
||||
statusDisplay = r.Status
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(w, "%s%s\t%s\t%s\t%s\n",
|
||||
prefix, workspace, pidStr, version, statusDisplay)
|
||||
}
|
||||
_ = w.Flush()
|
||||
|
||||
// Exit with error if there are issues
|
||||
if outdatedCount > 0 || staleCount > 0 || unresponsiveCount > 0 {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// outputStatusJSON outputs the status as JSON (helper to avoid duplicating json.Marshal)
|
||||
func outputStatusJSON(v interface{}) {
|
||||
data, _ := json.MarshalIndent(v, "", " ")
|
||||
fmt.Println(string(data))
|
||||
}
|
||||
Reference in New Issue
Block a user