From 37fcb9fad94fc6f251f059b374d3c4f21d51423f Mon Sep 17 00:00:00 2001 From: Deivid Soto Date: Fri, 10 Apr 2026 19:18:13 +0200 Subject: [PATCH] feat(daemon): enhance service management with start, stop, restart, and status commands for Windows --- internal/cmd/daemon.go | 38 ++-- internal/cmd/daemon_control.go | 331 +++++++++++++++++++++++++++++++++ internal/cmd/daemon_install.go | 59 ++++++ internal/cmd/reload_unix.go | 36 ++++ internal/cmd/reload_windows.go | 32 +++- 5 files changed, 479 insertions(+), 17 deletions(-) create mode 100644 internal/cmd/daemon_control.go diff --git a/internal/cmd/daemon.go b/internal/cmd/daemon.go index b6fb402..b8db356 100644 --- a/internal/cmd/daemon.go +++ b/internal/cmd/daemon.go @@ -46,27 +46,20 @@ To run as a background service, use 'unarr daemon install' instead.`, } } -// newStopCmd creates the top-level `unarr stop` placeholder. +// newStopCmd creates the top-level `unarr stop` command. func newStopCmd() *cobra.Command { return &cobra.Command{ Use: "stop", Short: "Stop the running daemon", - Long: `Stop the unarr daemon. + Long: `Stop the unarr daemon gracefully. -If running in the foreground, press Ctrl+C in the terminal where it was started. -If installed as a system service, use your OS service manager: +Reads the daemon PID from the state file and sends a graceful stop signal. +Works regardless of whether the daemon was started in the foreground or as a service. - Linux (systemd): systemctl --user stop unarr - macOS (launchd): launchctl unload ~/Library/LaunchAgents/com.torrentclaw.unarr.plist`, +To stop a service-managed daemon and prevent auto-restart, use 'unarr daemon stop' instead.`, Example: ` unarr stop`, RunE: func(cmd *cobra.Command, args []string) error { - fmt.Println(" Use Ctrl+C in the terminal where the daemon is running.") - fmt.Println() - fmt.Println(" If installed as a service:") - fmt.Println(" Linux: systemctl --user stop unarr") - fmt.Println(" macOS: launchctl unload ~/Library/LaunchAgents/com.torrentclaw.unarr.plist") - fmt.Println() - return nil + return stopDaemonByPID() }, } } @@ -76,17 +69,30 @@ func newDaemonCmd() *cobra.Command { cmd := &cobra.Command{ Use: "daemon ", Short: "Manage the daemon as a system service", - Long: `Install or remove unarr as a system service that starts automatically on boot. + Long: `Install, control and inspect the unarr daemon as a system service. - Linux: Creates a systemd user service (~/.config/systemd/user/unarr.service) - macOS: Creates a launchd agent (~/Library/LaunchAgents/com.torrentclaw.unarr.plist)`, + Linux: systemd user service (~/.config/systemd/user/unarr.service) + macOS: launchd agent (~/Library/LaunchAgents/com.torrentclaw.unarr.plist) + Windows: Task Scheduler task (runs at logon)`, Example: ` unarr daemon install + unarr daemon start + unarr daemon status + unarr daemon logs -f + unarr daemon reload + unarr daemon restart + unarr daemon stop unarr daemon uninstall`, } cmd.AddCommand( newDaemonInstallCmdReal(), newDaemonUninstallCmdReal(), + newDaemonStartCmd(), + newDaemonStopCmd(), + newDaemonRestartCmd(), + newDaemonSvcStatusCmd(), + newDaemonLogsCmd(), + newDaemonReloadCmd(), ) return cmd diff --git a/internal/cmd/daemon_control.go b/internal/cmd/daemon_control.go new file mode 100644 index 0000000..558fb26 --- /dev/null +++ b/internal/cmd/daemon_control.go @@ -0,0 +1,331 @@ +package cmd + +import ( + "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 := agent.ReadState() + if state == nil { + return fmt.Errorf("daemon does not appear to be running (state file not found)") + } + 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() +} diff --git a/internal/cmd/daemon_install.go b/internal/cmd/daemon_install.go index 8f1c0b6..e67e272 100644 --- a/internal/cmd/daemon_install.go +++ b/internal/cmd/daemon_install.go @@ -6,10 +6,14 @@ import ( "os/exec" "path/filepath" "runtime" + "strconv" + "strings" "text/template" "github.com/fatih/color" "github.com/spf13/cobra" + "github.com/torrentclaw/unarr/internal/agent" + "github.com/torrentclaw/unarr/internal/config" ) const systemdTemplate = `[Unit] @@ -123,6 +127,8 @@ func runDaemonInstall() error { return installSystemd(data, green) case "darwin": return installLaunchd(data, green) + case "windows": + return installWindowsTask(data, green) default: return fmt.Errorf("service installation not supported on %s yet", runtime.GOOS) } @@ -228,6 +234,17 @@ func runDaemonUninstall() error { os.Remove(path) green.Printf(" ✓ Removed %s\n", path) + case "windows": + // Stop the running process if any + if state := agent.ReadState(); state != nil { + exec.Command("taskkill", "/pid", strconv.Itoa(state.PID), "/f").Run() + } + out, err := exec.Command("schtasks", "/delete", "/tn", "unarr", "/f").CombinedOutput() + if err != nil && !strings.Contains(string(out), "cannot find") { + return fmt.Errorf("remove scheduled task: %w\n%s", err, strings.TrimSpace(string(out))) + } + green.Println(" ✓ Scheduled task removed") + default: return fmt.Errorf("service uninstall not supported on %s yet", runtime.GOOS) } @@ -235,3 +252,45 @@ func runDaemonUninstall() error { fmt.Println() return nil } + +func installWindowsTask(data serviceData, green *color.Color) error { + logDir := config.DataDir() + os.MkdirAll(logDir, 0o755) + + // Remove any existing task before (re)installing. + exec.Command("schtasks", "/delete", "/tn", "unarr", "/f").Run() + + // Wrap with PowerShell so stdout/stderr are captured to a log file. + psScript := fmt.Sprintf( + `Start-Transcript -Path '%s\unarr.log' -Append -NoClobber; & '%s' start`, + logDir, data.BinPath, + ) + taskCmd := fmt.Sprintf(`powershell.exe -NonInteractive -WindowStyle Hidden -Command "%s"`, psScript) + + out, err := exec.Command("schtasks", + "/create", + "/tn", "unarr", + "/tr", taskCmd, + "/sc", "onlogon", + "/ru", data.User, + "/rl", "highest", + "/f", + ).CombinedOutput() + if err != nil { + return fmt.Errorf("create scheduled task: %w\n%s", err, strings.TrimSpace(string(out))) + } + + fmt.Println() + green.Println(" ✓ Installed! Service will start automatically at next login.") + fmt.Println() + fmt.Println(" To start now:") + fmt.Println(" unarr daemon start") + fmt.Println() + fmt.Println(" Manage with:") + fmt.Println(" unarr daemon status") + fmt.Println(" unarr daemon stop") + fmt.Printf(" unarr daemon logs (log: %s\\unarr.log)\n", logDir) + fmt.Println() + + return nil +} diff --git a/internal/cmd/reload_unix.go b/internal/cmd/reload_unix.go index 8aa9177..056112f 100644 --- a/internal/cmd/reload_unix.go +++ b/internal/cmd/reload_unix.go @@ -3,11 +3,13 @@ package cmd import ( + "fmt" "log" "os" "os/signal" "syscall" + "github.com/fatih/color" "github.com/torrentclaw/unarr/internal/agent" "github.com/torrentclaw/unarr/internal/config" ) @@ -38,3 +40,37 @@ func startReloadWatcher(rc *ReloadableConfig) { } }() } + +// sendReloadSignal sends SIGUSR1 to the running daemon process. +func sendReloadSignal() error { + state := agent.ReadState() + if state == nil { + return fmt.Errorf("daemon does not appear to be running (state file not found)") + } + p, err := os.FindProcess(state.PID) + if err != nil { + return fmt.Errorf("find process %d: %w", state.PID, err) + } + if err := p.Signal(syscall.SIGUSR1); err != nil { + return fmt.Errorf("send reload signal to PID %d: %w", state.PID, err) + } + fmt.Println() + color.New(color.FgGreen).Printf(" ✓ Reload signal sent to daemon (PID %d)\n", state.PID) + fmt.Println(" Config will be re-read shortly.") + fmt.Println() + return nil +} + +// killPID sends SIGTERM to the given PID for a graceful shutdown. +func killPID(pid int) error { + p, err := os.FindProcess(pid) + if err != nil { + return fmt.Errorf("find process %d: %w", pid, err) + } + if err := p.Signal(syscall.SIGTERM); err != nil { + return fmt.Errorf("stop daemon (PID %d): %w", pid, err) + } + color.New(color.FgGreen).Printf(" ✓ Stop signal sent to daemon (PID %d)\n", pid) + fmt.Println() + return nil +} diff --git a/internal/cmd/reload_windows.go b/internal/cmd/reload_windows.go index d9e042e..b70ec66 100644 --- a/internal/cmd/reload_windows.go +++ b/internal/cmd/reload_windows.go @@ -2,7 +2,15 @@ package cmd -import "github.com/torrentclaw/unarr/internal/agent" +import ( + "fmt" + "os" + "os/exec" + "strconv" + + "github.com/fatih/color" + "github.com/torrentclaw/unarr/internal/agent" +) // ReloadableConfig holds a reference to the daemon for hot-reload. type ReloadableConfig struct { @@ -11,3 +19,25 @@ type ReloadableConfig struct { // startReloadWatcher is a no-op on Windows (no SIGUSR1 support). func startReloadWatcher(_ *ReloadableConfig) {} + +// sendReloadSignal is not supported on Windows; instructs the user to restart instead. +func sendReloadSignal() error { + fmt.Println() + color.New(color.FgYellow).Println(" ⚠ Config reload via signal is not supported on Windows.") + fmt.Println(" Use 'unarr daemon restart' to apply configuration changes.") + fmt.Println() + return nil +} + +// killPID stops the daemon process on Windows using taskkill. +func killPID(pid int) error { + cmd := exec.Command("taskkill", "/pid", strconv.Itoa(pid), "/f") + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("stop daemon (PID %d): %w", pid, err) + } + color.New(color.FgGreen).Printf(" ✓ Daemon stopped (PID %d)\n", pid) + fmt.Println() + return nil +}