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 (
|
require (
|
||||||
forge.lthn.ai/core/go-devops v0.0.3
|
|
||||||
forge.lthn.ai/core/go-help v0.1.2
|
forge.lthn.ai/core/go-help v0.1.2
|
||||||
forge.lthn.ai/core/go-i18n v0.1.0
|
forge.lthn.ai/core/go-i18n v0.1.0
|
||||||
forge.lthn.ai/core/go-io v0.0.3
|
forge.lthn.ai/core/go-io v0.0.3
|
||||||
forge.lthn.ai/core/go-log v0.0.1
|
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/bubbletea v1.3.10
|
||||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834
|
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834
|
||||||
github.com/spf13/cobra v1.10.2
|
github.com/spf13/cobra v1.10.2
|
||||||
|
|
@ -25,6 +25,7 @@ require (
|
||||||
|
|
||||||
require (
|
require (
|
||||||
forge.lthn.ai/core/go-inference v0.0.2 // indirect
|
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/ProtonMail/go-crypto v1.3.0 // indirect
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||||
github.com/charmbracelet/colorprofile v0.4.2 // 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 h1:Ow/1NTajrrNPO0zgkskEyEGdx4SKpiNqTaqM0txNOYI=
|
||||||
forge.lthn.ai/core/go v0.1.0/go.mod h1:lwi0tccAlg5j3k6CfoNJEueBc5l9mUeSBX/x6uY8ZbQ=
|
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 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 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 h1:92gwdQi7iAwktpvZhL/8Cu+QS6xKCtGP4FJfyInPGnw=
|
||||||
forge.lthn.ai/core/go-crypt v0.1.0/go.mod h1:zVAgx6ZiGtC+dbX4R/VKvEPqsEqjyuLl4gQZH9SXBUw=
|
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=
|
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 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-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 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 h1:x/E6EfF9vixzqiLHQOl2KT25HyBcMc9qiBkomqVlpPg=
|
||||||
forge.lthn.ai/core/go-log v0.0.1/go.mod h1:r14MXKOD3LF/sI8XUJQhRk/SZHBE7jAFVuCfgkXoZPw=
|
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/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 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw=
|
||||||
github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE=
|
github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE=
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
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