feat: replace setup with init wizard + interactive config menu

- `unarr init` (alias: `unarr setup`): streamlined 3-step wizard
  (API key, download dir, daemon install). Removed method/name prompts
  — auto-configured from defaults.
- `unarr config [category]`: interactive menu with 7 categories
  (downloads, organization, notifications, device, region, connection,
  advanced). Direct access via `unarr config downloads`, etc.
- Extract shared helpers (openBrowser, expandHome, isTerminal) to
  helpers.go. Delete old setup.go and config.go.
- Update all "unarr setup" references to "unarr init" across daemon,
  doctor, status, README, install scripts.
This commit is contained in:
Deivid Soto 2026-03-29 12:09:03 +02:00
parent 35e5298f23
commit 0b6c6849b1
13 changed files with 541 additions and 248 deletions

View file

@ -1,291 +0,0 @@
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
}