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 <noreply@anthropic.com>
This commit is contained in:
parent
de579ad01d
commit
d6cea1dffe
4 changed files with 314 additions and 1 deletions
3
go.mod
3
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
|
||||
|
|
|
|||
6
go.sum
6
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=
|
||||
|
|
|
|||
262
pkg/cli/daemon_cmd.go
Normal file
262
pkg/cli/daemon_cmd.go
Normal file
|
|
@ -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()
|
||||
}
|
||||
44
pkg/cli/daemon_cmd_test.go
Normal file
44
pkg/cli/daemon_cmd_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue