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:
Snider 2026-03-09 14:05:16 +00:00
parent de579ad01d
commit d6cea1dffe
4 changed files with 314 additions and 1 deletions

3
go.mod
View file

@ -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
View file

@ -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
View 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()
}

View 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)
}