feat(daemon): enhance service management with start, stop, restart, and status commands for Windows
This commit is contained in:
parent
debf77005f
commit
37fcb9fad9
5 changed files with 479 additions and 17 deletions
|
|
@ -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 {
|
func newStopCmd() *cobra.Command {
|
||||||
return &cobra.Command{
|
return &cobra.Command{
|
||||||
Use: "stop",
|
Use: "stop",
|
||||||
Short: "Stop the running daemon",
|
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.
|
Reads the daemon PID from the state file and sends a graceful stop signal.
|
||||||
If installed as a system service, use your OS service manager:
|
Works regardless of whether the daemon was started in the foreground or as a service.
|
||||||
|
|
||||||
Linux (systemd): systemctl --user stop unarr
|
To stop a service-managed daemon and prevent auto-restart, use 'unarr daemon stop' instead.`,
|
||||||
macOS (launchd): launchctl unload ~/Library/LaunchAgents/com.torrentclaw.unarr.plist`,
|
|
||||||
Example: ` unarr stop`,
|
Example: ` unarr stop`,
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
fmt.Println(" Use Ctrl+C in the terminal where the daemon is running.")
|
return stopDaemonByPID()
|
||||||
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
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -76,17 +69,30 @@ func newDaemonCmd() *cobra.Command {
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "daemon <command>",
|
Use: "daemon <command>",
|
||||||
Short: "Manage the daemon as a system service",
|
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)
|
Linux: systemd user service (~/.config/systemd/user/unarr.service)
|
||||||
macOS: Creates a launchd agent (~/Library/LaunchAgents/com.torrentclaw.unarr.plist)`,
|
macOS: launchd agent (~/Library/LaunchAgents/com.torrentclaw.unarr.plist)
|
||||||
|
Windows: Task Scheduler task (runs at logon)`,
|
||||||
Example: ` unarr daemon install
|
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`,
|
unarr daemon uninstall`,
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.AddCommand(
|
cmd.AddCommand(
|
||||||
newDaemonInstallCmdReal(),
|
newDaemonInstallCmdReal(),
|
||||||
newDaemonUninstallCmdReal(),
|
newDaemonUninstallCmdReal(),
|
||||||
|
newDaemonStartCmd(),
|
||||||
|
newDaemonStopCmd(),
|
||||||
|
newDaemonRestartCmd(),
|
||||||
|
newDaemonSvcStatusCmd(),
|
||||||
|
newDaemonLogsCmd(),
|
||||||
|
newDaemonReloadCmd(),
|
||||||
)
|
)
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
|
|
|
||||||
331
internal/cmd/daemon_control.go
Normal file
331
internal/cmd/daemon_control.go
Normal file
|
|
@ -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()
|
||||||
|
}
|
||||||
|
|
@ -6,10 +6,14 @@ import (
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
"text/template"
|
"text/template"
|
||||||
|
|
||||||
"github.com/fatih/color"
|
"github.com/fatih/color"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/torrentclaw/unarr/internal/agent"
|
||||||
|
"github.com/torrentclaw/unarr/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
const systemdTemplate = `[Unit]
|
const systemdTemplate = `[Unit]
|
||||||
|
|
@ -123,6 +127,8 @@ func runDaemonInstall() error {
|
||||||
return installSystemd(data, green)
|
return installSystemd(data, green)
|
||||||
case "darwin":
|
case "darwin":
|
||||||
return installLaunchd(data, green)
|
return installLaunchd(data, green)
|
||||||
|
case "windows":
|
||||||
|
return installWindowsTask(data, green)
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("service installation not supported on %s yet", runtime.GOOS)
|
return fmt.Errorf("service installation not supported on %s yet", runtime.GOOS)
|
||||||
}
|
}
|
||||||
|
|
@ -228,6 +234,17 @@ func runDaemonUninstall() error {
|
||||||
os.Remove(path)
|
os.Remove(path)
|
||||||
green.Printf(" ✓ Removed %s\n", 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:
|
default:
|
||||||
return fmt.Errorf("service uninstall not supported on %s yet", runtime.GOOS)
|
return fmt.Errorf("service uninstall not supported on %s yet", runtime.GOOS)
|
||||||
}
|
}
|
||||||
|
|
@ -235,3 +252,45 @@ func runDaemonUninstall() error {
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
return nil
|
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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,13 @@
|
||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
|
||||||
|
"github.com/fatih/color"
|
||||||
"github.com/torrentclaw/unarr/internal/agent"
|
"github.com/torrentclaw/unarr/internal/agent"
|
||||||
"github.com/torrentclaw/unarr/internal/config"
|
"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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,15 @@
|
||||||
|
|
||||||
package cmd
|
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.
|
// ReloadableConfig holds a reference to the daemon for hot-reload.
|
||||||
type ReloadableConfig struct {
|
type ReloadableConfig struct {
|
||||||
|
|
@ -11,3 +19,25 @@ type ReloadableConfig struct {
|
||||||
|
|
||||||
// startReloadWatcher is a no-op on Windows (no SIGUSR1 support).
|
// startReloadWatcher is a no-op on Windows (no SIGUSR1 support).
|
||||||
func startReloadWatcher(_ *ReloadableConfig) {}
|
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
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue