unarr/internal/cmd/daemon_control.go
Deivid Soto 9135332777
Some checks failed
CI / Test (push) Successful in 2m46s
CI / Build (push) Successful in 1m35s
CI / Build-1 (push) Successful in 1m59s
CI / Build-2 (push) Successful in 1m35s
CI / Build-3 (push) Successful in 1m35s
CI / Build-4 (push) Successful in 1m33s
CI / Build-5 (push) Successful in 1m39s
CI / Lint (push) Failing after 2m33s
CI / Coverage (push) Successful in 2m56s
CI / Vet (push) Successful in 2m7s
refactor(sentry): decouple agent import via string-match, rename predicate
2026-05-27 17:03:26 +02:00

335 lines
10 KiB
Go

package cmd
import (
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
"strings"
"time"
"github.com/fatih/color"
"github.com/spf13/cobra"
"github.com/torrentclaw/unarr/internal/agent"
"github.com/torrentclaw/unarr/internal/config"
)
func newDaemonStartCmd() *cobra.Command {
return &cobra.Command{
Use: "start",
Short: "Start the installed daemon service",
Long: `Start the unarr daemon using the system service manager.
Requires 'unarr daemon install' to have been run first.
Linux: systemctl --user start unarr
macOS: launchctl load ~/Library/LaunchAgents/com.torrentclaw.unarr.plist
Windows: schtasks /run /tn unarr`,
Example: ` unarr daemon start`,
RunE: func(cmd *cobra.Command, args []string) error {
return runDaemonSvcStart()
},
}
}
func newDaemonStopCmd() *cobra.Command {
return &cobra.Command{
Use: "stop",
Short: "Stop the running daemon service",
Long: `Stop the unarr daemon service.
Linux: systemctl --user stop unarr
macOS: launchctl unload ~/Library/LaunchAgents/com.torrentclaw.unarr.plist
Windows: sends stop signal via process PID`,
Example: ` unarr daemon stop`,
RunE: func(cmd *cobra.Command, args []string) error {
return runDaemonSvcStop()
},
}
}
func newDaemonRestartCmd() *cobra.Command {
return &cobra.Command{
Use: "restart",
Short: "Restart the daemon service",
Long: `Restart the unarr daemon service.
Linux: systemctl --user restart unarr
macOS: unload + reload launchd agent
Windows: stop by PID + schtasks /run`,
Example: ` unarr daemon restart`,
RunE: func(cmd *cobra.Command, args []string) error {
return runDaemonSvcRestart()
},
}
}
func newDaemonSvcStatusCmd() *cobra.Command {
return &cobra.Command{
Use: "status",
Short: "Show daemon service status",
Long: `Show the current status of the unarr daemon service as reported
by the system service manager, plus local state information.`,
Example: ` unarr daemon status`,
RunE: func(cmd *cobra.Command, args []string) error {
return runDaemonSvcStatus()
},
}
}
func newDaemonLogsCmd() *cobra.Command {
var follow bool
var lines int
cmd := &cobra.Command{
Use: "logs",
Short: "Show daemon logs",
Long: `Show daemon log output.
Linux: streams from journald (journalctl --user -u unarr)
macOS: tails ~/.local/share/unarr/unarr.log
Windows: tails %LOCALAPPDATA%\unarr\unarr.log`,
Example: ` unarr daemon logs
unarr daemon logs -f
unarr daemon logs -n 100 -f`,
RunE: func(cmd *cobra.Command, args []string) error {
return runDaemonLogs(follow, lines)
},
}
cmd.Flags().BoolVarP(&follow, "follow", "f", false, "Follow log output")
cmd.Flags().IntVarP(&lines, "lines", "n", 50, "Number of lines to show")
return cmd
}
func newDaemonReloadCmd() *cobra.Command {
return &cobra.Command{
Use: "reload",
Short: "Reload daemon configuration without restarting",
Long: `Send a reload signal to the running daemon, causing it to
re-read its configuration file without interrupting active downloads.
Linux/macOS: sends SIGUSR1 to the daemon process
Windows: not supported (use 'unarr daemon restart' instead)`,
Example: ` unarr daemon reload`,
RunE: func(cmd *cobra.Command, args []string) error {
return runDaemonReload()
},
}
}
// ── Platform implementations ──────────────────────────────────────────────────
func runDaemonSvcStart() error {
fmt.Println()
switch runtime.GOOS {
case "linux":
if err := svcExec("systemctl", "--user", "start", "unarr"); err != nil {
fmt.Fprintln(os.Stderr, "\n Is the daemon installed? Run 'unarr daemon install' first.")
return fmt.Errorf("start service: %w", err)
}
case "darwin":
home, _ := os.UserHomeDir()
plist := launchdPlistPath(home)
if _, err := os.Stat(plist); err != nil {
return fmt.Errorf("service not installed — run 'unarr daemon install' first")
}
if err := svcExec("launchctl", "load", plist); err != nil {
return fmt.Errorf("load service: %w", err)
}
case "windows":
if err := svcExec("schtasks", "/run", "/tn", "unarr"); err != nil {
fmt.Fprintln(os.Stderr, "\n Is the daemon installed? Run 'unarr daemon install' first.")
return fmt.Errorf("start task: %w", err)
}
default:
return fmt.Errorf("service control not supported on %s", runtime.GOOS)
}
color.New(color.FgGreen).Println(" ✓ Started")
fmt.Println()
return nil
}
func runDaemonSvcStop() error {
fmt.Println()
switch runtime.GOOS {
case "linux":
if err := svcExec("systemctl", "--user", "stop", "unarr"); err != nil {
return fmt.Errorf("stop service: %w", err)
}
case "darwin":
home, _ := os.UserHomeDir()
plist := launchdPlistPath(home)
if err := svcExec("launchctl", "unload", plist); err != nil {
return fmt.Errorf("unload service: %w", err)
}
default:
return stopDaemonByPID()
}
color.New(color.FgGreen).Println(" ✓ Stopped")
fmt.Println()
return nil
}
func runDaemonSvcRestart() error {
switch runtime.GOOS {
case "linux":
fmt.Println()
if err := svcExec("systemctl", "--user", "restart", "unarr"); err != nil {
return fmt.Errorf("restart service: %w", err)
}
color.New(color.FgGreen).Println(" ✓ Restarted")
fmt.Println()
return nil
default:
fmt.Println(" Stopping...")
_ = runDaemonSvcStop()
fmt.Println(" Starting...")
return runDaemonSvcStart()
}
}
func runDaemonSvcStatus() error {
fmt.Println()
switch runtime.GOOS {
case "linux":
// systemctl gives rich formatted output; exit code non-zero when stopped is fine.
svcExec("systemctl", "--user", "status", "--no-pager", "unarr") //nolint:errcheck
case "darwin":
printDaemonStatusDarwin()
case "windows":
svcExec("schtasks", "/query", "/tn", "unarr", "/fo", "LIST") //nolint:errcheck
default:
fmt.Printf(" Service manager not supported on %s\n", runtime.GOOS)
}
printStateInfo()
return nil
}
func runDaemonLogs(follow bool, lines int) error {
switch runtime.GOOS {
case "linux":
args := []string{"--user", "-u", "unarr", "--no-pager", "-n", strconv.Itoa(lines)}
if follow {
// -f implies live output; drop --no-pager so journalctl can control the terminal.
args = []string{"--user", "-u", "unarr", "-f"}
}
return svcExecInteractive("journalctl", args...)
case "darwin":
home, _ := os.UserHomeDir()
logFile := filepath.Join(home, ".local", "share", "unarr", "unarr.log")
if _, err := os.Stat(logFile); err != nil {
fmt.Fprintln(os.Stderr, "The daemon writes this file when running as a launchd service. Run 'unarr daemon install' first.")
return fmt.Errorf("log file not found: %s", logFile)
}
args := []string{"-n", strconv.Itoa(lines)}
if follow {
args = append(args, "-f")
}
args = append(args, logFile)
return svcExecInteractive("tail", args...)
case "windows":
logFile := filepath.Join(config.DataDir(), "unarr.log")
if _, err := os.Stat(logFile); err != nil {
fmt.Fprintln(os.Stderr, "The daemon writes logs here when running. Start it first.")
return fmt.Errorf("log file not found: %s", logFile)
}
var psCmd string
if follow {
psCmd = fmt.Sprintf("Get-Content -Path '%s' -Tail %d -Wait", logFile, lines)
} else {
psCmd = fmt.Sprintf("Get-Content -Path '%s' -Tail %d", logFile, lines)
}
return svcExecInteractive("powershell", "-NonInteractive", "-Command", psCmd)
default:
return fmt.Errorf("log viewing not supported on %s", runtime.GOOS)
}
}
func runDaemonReload() error {
return sendReloadSignal()
}
// ── Helpers ───────────────────────────────────────────────────────────────────
// stopDaemonByPID reads the state file and sends a graceful stop to the daemon PID.
// Used as fallback on platforms without a service manager (and as Windows implementation).
func stopDaemonByPID() error {
state, err := agent.LoadState()
if err != nil {
if errors.Is(err, agent.ErrDaemonNotRunning) {
return err
}
return fmt.Errorf("read daemon state: %w", err)
}
return killPID(state.PID)
}
func launchdPlistPath(home string) string {
return filepath.Join(home, "Library", "LaunchAgents", "com.torrentclaw.unarr.plist")
}
// printDaemonStatusDarwin shows launchd service state by filtering launchctl output.
func printDaemonStatusDarwin() {
out, err := exec.Command("launchctl", "list").Output()
if err != nil {
fmt.Printf(" Could not query launchctl: %v\n", err)
return
}
found := false
for _, line := range strings.Split(string(out), "\n") {
if strings.Contains(line, "unarr") {
// Format: PID ExitCode Label
fmt.Printf(" launchd: %s\n", strings.TrimSpace(line))
found = true
}
}
if !found {
fmt.Println(" launchd: service not loaded")
}
}
// printStateInfo shows information from the local daemon.state.json file.
func printStateInfo() {
state := agent.ReadState()
if state == nil {
color.New(color.FgHiBlack).Println(" State: no state file (daemon not running or crashed)")
fmt.Println()
return
}
dim := color.New(color.FgHiBlack)
fmt.Println()
dim.Println(" Local state:")
fmt.Printf(" PID: %d\n", state.PID)
fmt.Printf(" Status: %s\n", state.Status)
fmt.Printf(" Version: %s\n", state.Version)
fmt.Printf(" Uptime: %s\n", formatDuration(time.Since(state.StartedAt)))
fmt.Printf(" Heartbeat: %s ago\n", formatDuration(time.Since(state.LastHeartbeat)))
fmt.Printf(" Active: %d task(s)\n", state.ActiveTasks)
fmt.Println()
}
// svcExec runs a service management command with output flowing to the terminal.
func svcExec(name string, args ...string) error {
cmd := exec.Command(name, args...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
// svcExecInteractive is like svcExec but also connects stdin (needed for follow/pager modes).
func svcExecInteractive(name string, args ...string) error {
cmd := exec.Command(name, args...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}