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:
parent
35e5298f23
commit
0b6c6849b1
13 changed files with 541 additions and 248 deletions
|
|
@ -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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue