unarr/internal/cmd/setup.go
Deivid Soto 719429b06e docs: improve CLI help, shell completion, and README
- Add command groups (Getting Started, Search, Downloads, Daemon, System)
- Add shell completion command (bash, zsh, fish, powershell)
- Add flag completions for --type, --quality, --sort, --lang, --genre,
  --country, --method, --player
- Improve Long descriptions and Examples for all commands
- Split doctor disk check into platform-specific files (Unix/Windows)
- Validate infoHash length before truncating (prevent panic)
- Fix references to non-existent 'unarr daemon start' command
- Move stats command to System & Diagnostics group
- Rewrite README with complete documentation, correct config format
  (toml not yaml), all commands, shell completion section
2026-03-28 21:36:27 +01:00

291 lines
7 KiB
Go

package cmd
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"github.com/charmbracelet/huh"
"github.com/fatih/color"
"github.com/google/uuid"
"github.com/spf13/cobra"
"github.com/torrentclaw/torrentclaw-cli/internal/agent"
"github.com/torrentclaw/torrentclaw-cli/internal/config"
)
func newSetupCmd() *cobra.Command {
var apiURL string
cmd := &cobra.Command{
Use: "setup",
Short: "First-time configuration wizard",
Long: `Interactive setup that configures API key, download directory, and
preferred download method.
Opens your browser to create/copy your API key, then walks you through
choosing a download directory, method (torrent, debrid, usenet), and
device name. Validates the API key against the server before saving.
Run this once after installing unarr. To change settings later,
use 'unarr config' or edit ~/.config/unarr/config.toml directly.`,
Example: ` unarr setup
unarr setup --api-url https://custom.server.com`,
RunE: func(cmd *cobra.Command, args []string) error {
return runSetup(apiURL)
},
}
cmd.Flags().StringVar(&apiURL, "api-url", "", "API URL override (default: https://torrentclaw.com)")
return cmd
}
func runSetup(apiURLOverride string) error {
bold := color.New(color.Bold)
green := color.New(color.FgGreen)
cyan := color.New(color.FgCyan)
fmt.Println()
bold.Println(" unarr Setup")
fmt.Println()
cfg := loadConfig()
// Determine API URL
apiURL := cfg.Auth.APIURL
if apiURLOverride != "" {
apiURL = apiURLOverride
}
if apiURL == "" {
apiURL = "https://torrentclaw.com"
}
// Open browser to API keys page
keysURL := apiURL + "/profile?tab=apikey"
fmt.Printf(" Opening %s ...\n", keysURL)
openBrowser(keysURL)
fmt.Println()
// Step 1: API Key
apiKey := cfg.Auth.APIKey
err := huh.NewForm(
huh.NewGroup(
huh.NewInput().
Title("API Key").
Description("Copy it from the page that just opened in your browser").
Placeholder("tc_...").
Value(&apiKey).
Validate(func(s string) error {
s = strings.TrimSpace(s)
if s == "" {
return fmt.Errorf("API key is required")
}
if !strings.HasPrefix(s, "tc_") {
return fmt.Errorf("API key should start with tc_")
}
return nil
}),
),
).Run()
if err != nil {
return err
}
apiKey = strings.TrimSpace(apiKey)
// Validate API key by registering with the server
fmt.Print(" Verifying API key... ")
agentID := cfg.Agent.ID
if agentID == "" {
agentID = uuid.New().String()
}
hostname, _ := os.Hostname()
agentName := cfg.Agent.Name
if agentName == "" {
agentName = hostname
}
ac := agent.NewClient(apiURL, apiKey, "unarr/"+Version)
resp, err := ac.Register(context.Background(), agent.RegisterRequest{
AgentID: agentID,
Name: agentName,
OS: runtime.GOOS,
Arch: runtime.GOARCH,
Version: Version,
DownloadDir: cfg.Download.Dir,
})
if err != nil {
color.Red("FAILED")
fmt.Println()
return fmt.Errorf("API key validation failed: %w", err)
}
green.Println("OK")
fmt.Printf(" Connected as %s (%s) [%s]\n", resp.User.Name, resp.User.Email, strings.ToUpper(resp.User.Plan))
fmt.Println()
// Step 2: Download directory
downloadDir := cfg.Download.Dir
if downloadDir == "" {
downloadDir = defaultDownloadDir()
}
err = huh.NewForm(
huh.NewGroup(
huh.NewInput().
Title("Download Directory").
Description("Where should downloaded files be saved?").
Value(&downloadDir),
),
).Run()
if err != nil {
return err
}
downloadDir = expandHome(strings.TrimSpace(downloadDir))
// Step 3: Preferred download method
method := cfg.Download.PreferredMethod
if method == "" {
method = "auto"
}
methodOptions := []huh.Option[string]{
huh.NewOption("Auto (torrent, debrid when available)", "auto"),
huh.NewOption("Torrent only (BitTorrent P2P)", "torrent"),
}
if resp.Features.Debrid {
methodOptions = append(methodOptions,
huh.NewOption("Debrid only (Real-Debrid, AllDebrid...)", "debrid"),
)
}
if resp.Features.Usenet {
methodOptions = append(methodOptions,
huh.NewOption("Usenet only (requires Pro)", "usenet"),
)
}
err = huh.NewForm(
huh.NewGroup(
huh.NewSelect[string]().
Title("Download Method").
Description("How do you want to download?").
Options(methodOptions...).
Value(&method),
),
).Run()
if err != nil {
return err
}
// Step 4: Agent name
err = huh.NewForm(
huh.NewGroup(
huh.NewInput().
Title("Device Name").
Description("A name for this machine (shown in the web dashboard)").
Value(&agentName),
),
).Run()
if err != nil {
return err
}
// Save config
cfg.Auth.APIKey = apiKey
cfg.Auth.APIURL = apiURL
cfg.Agent.ID = agentID
cfg.Agent.Name = strings.TrimSpace(agentName)
cfg.Download.Dir = downloadDir
cfg.Download.PreferredMethod = method
// Set organize dirs based on download dir
if cfg.Organize.MoviesDir == "" {
cfg.Organize.MoviesDir = filepath.Join(downloadDir, "Movies")
}
if cfg.Organize.TVShowsDir == "" {
cfg.Organize.TVShowsDir = filepath.Join(downloadDir, "TV Shows")
}
// Validate paths before saving
if err := cfg.ValidatePaths(); err != nil {
return fmt.Errorf("unsafe configuration: %w", err)
}
configPath := config.FilePath()
if cfgFile != "" {
configPath = cfgFile
}
if err := config.Save(cfg, configPath); err != nil {
return fmt.Errorf("save config: %w", err)
}
// Summary
fmt.Println()
green.Println(" Setup complete!")
fmt.Println()
fmt.Printf(" User: %s (%s) [%s]\n", resp.User.Name, resp.User.Email, strings.ToUpper(resp.User.Plan))
fmt.Printf(" Downloads: %s\n", downloadDir)
fmt.Printf(" Method: %s\n", method)
fmt.Printf(" Agent: %s (%s)\n", agentName, agentID[:8]+"...")
fmt.Printf(" Config: %s\n", configPath)
fmt.Println()
// Features summary
features := []string{}
if resp.Features.Torrent {
features = append(features, "Torrent")
}
if resp.Features.Debrid {
features = append(features, "Debrid")
}
if resp.Features.Usenet {
features = append(features, "Usenet")
}
cyan.Printf(" Available: %s\n", strings.Join(features, ", "))
fmt.Println()
fmt.Println(" Next: run", bold.Sprint("unarr start"), "to begin downloading")
fmt.Println()
return nil
}
// openBrowser opens a URL in the default browser.
func openBrowser(url string) {
var cmd *exec.Cmd
switch runtime.GOOS {
case "darwin":
cmd = exec.Command("open", url)
case "windows":
cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url)
default: // linux, freebsd
cmd = exec.Command("xdg-open", url)
}
cmd.Start() // fire and forget
}
func defaultDownloadDir() string {
home, _ := os.UserHomeDir()
candidates := []string{
filepath.Join(home, "Media"),
filepath.Join(home, "Downloads", "unarr"),
}
for _, d := range candidates {
if fi, err := os.Stat(d); err == nil && fi.IsDir() {
return d
}
}
return filepath.Join(home, "Media")
}
func expandHome(path string) string {
if strings.HasPrefix(path, "~/") {
home, _ := os.UserHomeDir()
return filepath.Join(home, path[2:])
}
return path
}