unarr/internal/cmd/daemon_install.go

238 lines
6.1 KiB
Go

package cmd
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"text/template"
"github.com/fatih/color"
"github.com/spf13/cobra"
)
const systemdTemplate = `[Unit]
Description=unarr download daemon
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
ExecStart={{.BinPath}} start
Restart=always
RestartSec=10
User={{.User}}
Environment=HOME={{.Home}}
[Install]
WantedBy=multi-user.target
`
const launchdTemplate = `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.torrentclaw.unarr</string>
<key>ProgramArguments</key>
<array>
<string>{{.BinPath}}</string>
<string>start</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>{{.LogDir}}/unarr.log</string>
<key>StandardErrorPath</key>
<string>{{.LogDir}}/unarr.err.log</string>
</dict>
</plist>
`
func newDaemonInstallCmdReal() *cobra.Command {
return &cobra.Command{
Use: "install",
Short: "Install daemon as a system service (systemd/launchd)",
Long: `Install the unarr daemon as a system service so it starts automatically on boot.
Linux: Creates a systemd user service (~/.config/systemd/user/unarr.service)
Enables lingering so the service runs without an active login session.
macOS: Creates a launchd user agent (~/Library/LaunchAgents/com.torrentclaw.unarr.plist)
The service is enabled and started immediately after installation.
No sudo or root access is required (uses user-level service managers).`,
Example: ` unarr daemon install`,
RunE: func(cmd *cobra.Command, args []string) error {
return runDaemonInstall()
},
}
}
func newDaemonUninstallCmdReal() *cobra.Command {
return &cobra.Command{
Use: "uninstall",
Short: "Remove daemon system service",
Long: `Stop the daemon and remove the system service created by 'unarr daemon install'.
Removes the service file and disables automatic startup on boot.`,
Example: ` unarr daemon uninstall`,
RunE: func(cmd *cobra.Command, args []string) error {
return runDaemonUninstall()
},
}
}
type serviceData struct {
BinPath string
User string
Home string
LogDir string
}
func runDaemonInstall() error {
binPath, err := os.Executable()
if err != nil {
return fmt.Errorf("find executable: %w", err)
}
binPath, _ = filepath.EvalSymlinks(binPath)
home, _ := os.UserHomeDir()
user := os.Getenv("USER")
if user == "" {
user = os.Getenv("USERNAME")
}
data := serviceData{
BinPath: binPath,
User: user,
Home: home,
LogDir: filepath.Join(home, ".local", "share", "unarr"),
}
bold := color.New(color.Bold)
green := color.New(color.FgGreen)
fmt.Println()
bold.Println(" unarr daemon install")
fmt.Println()
switch runtime.GOOS {
case "linux":
return installSystemd(data, green)
case "darwin":
return installLaunchd(data, green)
default:
return fmt.Errorf("service installation not supported on %s yet", runtime.GOOS)
}
}
func installSystemd(data serviceData, green *color.Color) error {
// User-level systemd service (no sudo needed)
dir := filepath.Join(data.Home, ".config", "systemd", "user")
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("create systemd dir: %w", err)
}
path := filepath.Join(dir, "unarr.service")
f, err := os.Create(path)
if err != nil {
return fmt.Errorf("create service file: %w", err)
}
defer f.Close()
tmpl := template.Must(template.New("systemd").Parse(systemdTemplate))
if err := tmpl.Execute(f, data); err != nil {
return fmt.Errorf("write service file: %w", err)
}
fmt.Printf(" Created: %s\n", path)
// Enable and start
exec.Command("systemctl", "--user", "daemon-reload").Run()
exec.Command("systemctl", "--user", "enable", "unarr").Run()
exec.Command("systemctl", "--user", "start", "unarr").Run()
// Enable lingering so user services run without login session
exec.Command("loginctl", "enable-linger", data.User).Run()
fmt.Println()
green.Println(" ✓ Installed and started!")
fmt.Println()
fmt.Println(" Manage with:")
fmt.Println(" systemctl --user status unarr")
fmt.Println(" systemctl --user restart unarr")
fmt.Println(" journalctl --user -u unarr -f")
fmt.Println()
return nil
}
func installLaunchd(data serviceData, green *color.Color) error {
os.MkdirAll(data.LogDir, 0o755)
dir := filepath.Join(data.Home, "Library", "LaunchAgents")
os.MkdirAll(dir, 0o755)
path := filepath.Join(dir, "com.torrentclaw.unarr.plist")
f, err := os.Create(path)
if err != nil {
return fmt.Errorf("create plist: %w", err)
}
defer f.Close()
tmpl := template.Must(template.New("launchd").Parse(launchdTemplate))
if err := tmpl.Execute(f, data); err != nil {
return fmt.Errorf("write plist: %w", err)
}
fmt.Printf(" Created: %s\n", path)
exec.Command("launchctl", "load", path).Run()
fmt.Println()
green.Println(" ✓ Installed and loaded!")
fmt.Println()
fmt.Println(" Manage with:")
fmt.Println(" launchctl list | grep unarr")
fmt.Println(" launchctl unload " + path)
fmt.Println(" tail -f " + filepath.Join(data.LogDir, "unarr.log"))
fmt.Println()
return nil
}
func runDaemonUninstall() error {
home, _ := os.UserHomeDir()
bold := color.New(color.Bold)
green := color.New(color.FgGreen)
fmt.Println()
bold.Println(" unarr daemon uninstall")
fmt.Println()
switch runtime.GOOS {
case "linux":
exec.Command("systemctl", "--user", "stop", "unarr").Run()
exec.Command("systemctl", "--user", "disable", "unarr").Run()
path := filepath.Join(home, ".config", "systemd", "user", "unarr.service")
os.Remove(path)
exec.Command("systemctl", "--user", "daemon-reload").Run()
green.Printf(" ✓ Removed %s\n", path)
case "darwin":
path := filepath.Join(home, "Library", "LaunchAgents", "com.torrentclaw.unarr.plist")
exec.Command("launchctl", "unload", path).Run()
os.Remove(path)
green.Printf(" ✓ Removed %s\n", path)
default:
return fmt.Errorf("service uninstall not supported on %s yet", runtime.GOOS)
}
fmt.Println()
return nil
}