From d6cea1dffe52fce05781cb6197e3f2bea98dfcc6 Mon Sep 17 00:00:00 2001 From: Snider Date: Mon, 9 Mar 2026 14:05:16 +0000 Subject: [PATCH] feat: add generic AddDaemonCommand with go-process daemon types Provides a reusable daemon CLI command builder that registers start/stop/status/run subcommands. Consumers (go-ai, ide, etc.) call AddDaemonCommand(root, config) with a RunForeground callback for their business logic. Uses go-process for PID file, health server, and daemon lifecycle management. Co-Authored-By: Claude Opus 4.6 --- go.mod | 3 +- go.sum | 6 + pkg/cli/daemon_cmd.go | 262 +++++++++++++++++++++++++++++++++++++ pkg/cli/daemon_cmd_test.go | 44 +++++++ 4 files changed, 314 insertions(+), 1 deletion(-) create mode 100644 pkg/cli/daemon_cmd.go create mode 100644 pkg/cli/daemon_cmd_test.go diff --git a/go.mod b/go.mod index e11cf94..2d9b9b2 100644 --- a/go.mod +++ b/go.mod @@ -10,11 +10,11 @@ require ( ) require ( - forge.lthn.ai/core/go-devops v0.0.3 forge.lthn.ai/core/go-help v0.1.2 forge.lthn.ai/core/go-i18n v0.1.0 forge.lthn.ai/core/go-io v0.0.3 forge.lthn.ai/core/go-log v0.0.1 + forge.lthn.ai/core/lint v0.2.0 github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 github.com/spf13/cobra v1.10.2 @@ -25,6 +25,7 @@ require ( require ( forge.lthn.ai/core/go-inference v0.0.2 // indirect + forge.lthn.ai/core/go-process v0.1.2 // indirect github.com/ProtonMail/go-crypto v1.3.0 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/charmbracelet/colorprofile v0.4.2 // indirect diff --git a/go.sum b/go.sum index 7ce09b5..c16e9d5 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,9 @@ forge.lthn.ai/core/go v0.1.0 h1:Ow/1NTajrrNPO0zgkskEyEGdx4SKpiNqTaqM0txNOYI= forge.lthn.ai/core/go v0.1.0/go.mod h1:lwi0tccAlg5j3k6CfoNJEueBc5l9mUeSBX/x6uY8ZbQ= forge.lthn.ai/core/go-cache v0.1.0 h1:yxPf4bWPZ1jxMnXg8UHBv2xLhet2CRsq5E9PLQYjyj4= +forge.lthn.ai/core/go-cache v0.1.0/go.mod h1:7WbprZVfx/+t4cbJFXMo4sloWk2Eny+rZd8x1Ay9rLk= forge.lthn.ai/core/go-config v0.1.0 h1:bQnlt8MvFvgPisl//jw4IMHMoCcaIt5FLurwYWqlMx0= +forge.lthn.ai/core/go-config v0.1.0/go.mod h1:jsCzg3BykHqlHZs13PDhP/dq8yTZjsiEyZ35q6jA3Aw= forge.lthn.ai/core/go-crypt v0.1.0 h1:92gwdQi7iAwktpvZhL/8Cu+QS6xKCtGP4FJfyInPGnw= forge.lthn.ai/core/go-crypt v0.1.0/go.mod h1:zVAgx6ZiGtC+dbX4R/VKvEPqsEqjyuLl4gQZH9SXBUw= forge.lthn.ai/core/go-devops v0.0.3 h1:tiSZ2x6a/H1A1IYYUmaM+bEuZqT9Hot7KGCEFN6PSYY= @@ -13,9 +15,13 @@ forge.lthn.ai/core/go-i18n v0.1.0/go.mod h1:Q4xsrxuNCl/6NfMv1daria7t1RSiyy8ml+6j forge.lthn.ai/core/go-inference v0.0.2 h1:aHjBkYyLKxLr9tbO4AvzzV/lsZueGq/jeo33SLh113k= forge.lthn.ai/core/go-inference v0.0.2/go.mod h1:jfWz+IJX55wAH98+ic6FEqqGB6/P31CHlg7VY7pxREw= forge.lthn.ai/core/go-io v0.0.3 h1:TlhYpGTyjPgAlbEHyYrVSeUChZPhJXcLZ7D/8IbFqfI= +forge.lthn.ai/core/go-io v0.0.3/go.mod h1:ZlU9OQpsvNFNmTJoaHbFIkisZyc0eCq0p8znVWQLRf0= forge.lthn.ai/core/go-log v0.0.1 h1:x/E6EfF9vixzqiLHQOl2KT25HyBcMc9qiBkomqVlpPg= forge.lthn.ai/core/go-log v0.0.1/go.mod h1:r14MXKOD3LF/sI8XUJQhRk/SZHBE7jAFVuCfgkXoZPw= +forge.lthn.ai/core/go-process v0.1.2 h1:0fdLJq/DPssilN9E5yude/xHNfZRKHghIjo++b5aXgc= +forge.lthn.ai/core/go-process v0.1.2/go.mod h1:9oxVALrZaZCqFe8YDdheIS5bRUV1SBz4tVW/MflAtxM= forge.lthn.ai/core/go-scm v0.0.2 h1:Ue+gS5vxZkDgTvQrqYu9QdaqEezuTV1kZY3TMqM2uho= +forge.lthn.ai/core/lint v0.2.0/go.mod h1:kSWEE3MQ/msM5qnNhnmEGQvg/NmYn6ME7oNmPCZvtkM= github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw= github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= diff --git a/pkg/cli/daemon_cmd.go b/pkg/cli/daemon_cmd.go new file mode 100644 index 0000000..3e0b48e --- /dev/null +++ b/pkg/cli/daemon_cmd.go @@ -0,0 +1,262 @@ +package cli + +import ( + "context" + "fmt" + "net/http" + "os" + "os/exec" + "syscall" + "time" + + "forge.lthn.ai/core/go-process" +) + +// DaemonCommandConfig configures the generic daemon CLI command group. +type DaemonCommandConfig struct { + // Name is the command group name (default: "daemon"). + Name string + + // Description is the short description for the command group. + Description string + + // RunForeground is called when the daemon runs in foreground mode. + // Receives context (cancelled on SIGINT/SIGTERM) and the started Daemon. + // If nil, the run command just blocks until signal. + RunForeground func(ctx context.Context, daemon *process.Daemon) error + + // PIDFile default path. + PIDFile string + + // HealthAddr default address. + HealthAddr string + + // ExtraStartArgs returns additional CLI args to pass when re-execing + // the binary as a background daemon. + ExtraStartArgs func() []string + + // Flags registers custom persistent flags on the daemon command group. + Flags func(cmd *Command) +} + +// AddDaemonCommand registers start/stop/status/run subcommands on root. +func AddDaemonCommand(root *Command, cfg DaemonCommandConfig) { + if cfg.Name == "" { + cfg.Name = "daemon" + } + if cfg.Description == "" { + cfg.Description = "Manage the background daemon" + } + + daemonCmd := NewGroup( + cfg.Name, + cfg.Description, + fmt.Sprintf("Manage the background daemon process.\n\n"+ + "Subcommands:\n"+ + " start - Start the daemon in the background\n"+ + " stop - Stop the running daemon\n"+ + " status - Show daemon status\n"+ + " run - Run in foreground (for development/debugging)"), + ) + + PersistentStringFlag(daemonCmd, &cfg.HealthAddr, "health-addr", "", cfg.HealthAddr, + "Health check endpoint address (empty to disable)") + PersistentStringFlag(daemonCmd, &cfg.PIDFile, "pid-file", "", cfg.PIDFile, + "PID file path (empty to disable)") + + if cfg.Flags != nil { + cfg.Flags(daemonCmd) + } + + startCmd := NewCommand("start", "Start the daemon in the background", + "Re-executes the binary as a background daemon process.\n"+ + "The daemon PID is written to the PID file for later management.", + func(cmd *Command, args []string) error { + return daemonRunStart(cfg) + }, + ) + + stopCmd := NewCommand("stop", "Stop the running daemon", + "Sends SIGTERM to the daemon process identified by the PID file.\n"+ + "Waits for graceful shutdown before returning.", + func(cmd *Command, args []string) error { + return daemonRunStop(cfg) + }, + ) + + statusCmd := NewCommand("status", "Show daemon status", + "Checks if the daemon is running and queries its health endpoint.", + func(cmd *Command, args []string) error { + return daemonRunStatus(cfg) + }, + ) + + runCmd := NewCommand("run", "Run the daemon in the foreground", + "Runs the daemon in the current terminal (blocks until SIGINT/SIGTERM).\n"+ + "Useful for development, debugging, or running under a process manager.", + func(cmd *Command, args []string) error { + return daemonRunForeground(cfg) + }, + ) + + daemonCmd.AddCommand(startCmd, stopCmd, statusCmd, runCmd) + root.AddCommand(daemonCmd) +} + +func daemonRunStart(cfg DaemonCommandConfig) error { + if pid, running := process.ReadPID(cfg.PIDFile); running { + return fmt.Errorf("daemon already running (PID %d)", pid) + } + + exePath, err := os.Executable() + if err != nil { + return fmt.Errorf("failed to find executable: %w", err) + } + + args := []string{cfg.Name, "run", + "--health-addr", cfg.HealthAddr, + "--pid-file", cfg.PIDFile, + } + + if cfg.ExtraStartArgs != nil { + args = append(args, cfg.ExtraStartArgs()...) + } + + cmd := exec.Command(exePath, args...) + cmd.Env = append(os.Environ(), "CORE_DAEMON=1") + cmd.Stdout = nil + cmd.Stderr = nil + cmd.Stdin = nil + cmd.SysProcAttr = &syscall.SysProcAttr{ + Setsid: true, + } + + if err := cmd.Start(); err != nil { + return fmt.Errorf("failed to start daemon: %w", err) + } + + pid := cmd.Process.Pid + _ = cmd.Process.Release() + + if cfg.HealthAddr != "" { + if process.WaitForHealth(cfg.HealthAddr, 5_000) { + LogInfo(fmt.Sprintf("Daemon started (PID %d, health %s)", pid, cfg.HealthAddr)) + } else { + LogInfo(fmt.Sprintf("Daemon started (PID %d, health not yet ready)", pid)) + } + } else { + LogInfo(fmt.Sprintf("Daemon started (PID %d)", pid)) + } + + return nil +} + +func daemonRunStop(cfg DaemonCommandConfig) error { + pid, running := process.ReadPID(cfg.PIDFile) + if !running { + LogInfo("Daemon is not running") + return nil + } + + proc, err := os.FindProcess(pid) + if err != nil { + return fmt.Errorf("failed to find process %d: %w", pid, err) + } + + LogInfo(fmt.Sprintf("Stopping daemon (PID %d)", pid)) + if err := proc.Signal(syscall.SIGTERM); err != nil { + return fmt.Errorf("failed to send SIGTERM to PID %d: %w", pid, err) + } + + deadline := time.Now().Add(30 * time.Second) + for time.Now().Before(deadline) { + if _, still := process.ReadPID(cfg.PIDFile); !still { + LogInfo("Daemon stopped") + return nil + } + time.Sleep(250 * time.Millisecond) + } + + LogWarn("Daemon did not stop within 30s, sending SIGKILL") + _ = proc.Signal(syscall.SIGKILL) + _ = os.Remove(cfg.PIDFile) + LogInfo("Daemon killed") + return nil +} + +func daemonRunStatus(cfg DaemonCommandConfig) error { + pid, running := process.ReadPID(cfg.PIDFile) + if !running { + fmt.Println("Daemon is not running") + return nil + } + + fmt.Printf("Daemon is running (PID %d)\n", pid) + + if cfg.HealthAddr != "" { + healthURL := fmt.Sprintf("http://%s/health", cfg.HealthAddr) + resp, err := http.Get(healthURL) + if err != nil { + fmt.Printf("Health: unreachable (%v)\n", err) + return nil + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusOK { + fmt.Println("Health: ok") + } else { + fmt.Printf("Health: unhealthy (HTTP %d)\n", resp.StatusCode) + } + + readyURL := fmt.Sprintf("http://%s/ready", cfg.HealthAddr) + resp2, err := http.Get(readyURL) + if err == nil { + defer resp2.Body.Close() + if resp2.StatusCode == http.StatusOK { + fmt.Println("Ready: yes") + } else { + fmt.Println("Ready: no") + } + } + } + + return nil +} + +func daemonRunForeground(cfg DaemonCommandConfig) error { + os.Setenv("CORE_DAEMON", "1") + + daemon := process.NewDaemon(process.DaemonOptions{ + PIDFile: cfg.PIDFile, + HealthAddr: cfg.HealthAddr, + ShutdownTimeout: 30 * time.Second, + }) + + if err := daemon.Start(); err != nil { + return fmt.Errorf("failed to start daemon: %w", err) + } + + daemon.SetReady(true) + + ctx := Context() + + if cfg.RunForeground != nil { + svcErr := make(chan error, 1) + go func() { + svcErr <- cfg.RunForeground(ctx, daemon) + }() + + select { + case <-ctx.Done(): + LogInfo("Shutting down daemon") + case err := <-svcErr: + if err != nil { + LogError(fmt.Sprintf("Service exited with error: %v", err)) + } + } + } else { + <-ctx.Done() + } + + return daemon.Stop() +} diff --git a/pkg/cli/daemon_cmd_test.go b/pkg/cli/daemon_cmd_test.go new file mode 100644 index 0000000..fbab05e --- /dev/null +++ b/pkg/cli/daemon_cmd_test.go @@ -0,0 +1,44 @@ +package cli + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAddDaemonCommand_RegistersSubcommands(t *testing.T) { + root := &Command{Use: "test"} + + AddDaemonCommand(root, DaemonCommandConfig{ + Name: "daemon", + PIDFile: "/tmp/test-daemon.pid", + HealthAddr: "127.0.0.1:0", + }) + + // Should have the daemon command + daemonCmd, _, err := root.Find([]string{"daemon"}) + require.NoError(t, err) + require.NotNil(t, daemonCmd) + + // Should have subcommands + var subNames []string + for _, sub := range daemonCmd.Commands() { + subNames = append(subNames, sub.Name()) + } + assert.Contains(t, subNames, "start") + assert.Contains(t, subNames, "stop") + assert.Contains(t, subNames, "status") + assert.Contains(t, subNames, "run") +} + +func TestDaemonCommandConfig_DefaultName(t *testing.T) { + root := &Command{Use: "test"} + + AddDaemonCommand(root, DaemonCommandConfig{}) + + // Should default to "daemon" + daemonCmd, _, err := root.Find([]string{"daemon"}) + require.NoError(t, err) + require.NotNil(t, daemonCmd) +}