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
367
internal/cmd/config_menu.go
Normal file
367
internal/cmd/config_menu.go
Normal file
|
|
@ -0,0 +1,367 @@
|
|||
package cmd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/huh"
|
||||
"github.com/fatih/color"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/torrentclaw/torrentclaw-cli/internal/config"
|
||||
)
|
||||
|
||||
var configCategories = []string{"downloads", "organization", "notifications", "device", "region", "connection", "advanced"}
|
||||
|
||||
func newConfigCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "config [category]",
|
||||
Short: "Edit settings interactively",
|
||||
Long: `Edit unarr settings interactively with a category-based menu.
|
||||
|
||||
Categories:
|
||||
downloads Download directory, method, speed limits, concurrency
|
||||
organization Auto-sort into Movies / TV Shows folders
|
||||
notifications Desktop notifications
|
||||
device Agent name
|
||||
region Country and language
|
||||
connection API URL, API key
|
||||
advanced Daemon poll & heartbeat intervals
|
||||
|
||||
Run without arguments to see the full menu, or specify a category
|
||||
to jump directly to it.
|
||||
|
||||
Config file: ~/.config/unarr/config.toml
|
||||
Environment variables override config file values:
|
||||
UNARR_API_KEY API key
|
||||
UNARR_API_URL API URL
|
||||
UNARR_COUNTRY Default country code
|
||||
UNARR_DOWNLOAD_DIR Download directory`,
|
||||
Example: ` unarr config # Interactive menu
|
||||
unarr config downloads # Jump to downloads settings
|
||||
unarr config region # Jump to region settings`,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
ValidArgs: configCategories,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
category := ""
|
||||
if len(args) == 1 {
|
||||
category = args[0]
|
||||
}
|
||||
return runConfigMenu(category)
|
||||
},
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runConfigMenu(category string) error {
|
||||
if !isTerminal() {
|
||||
return fmt.Errorf("interactive config requires a terminal (use UNARR_* env vars instead)")
|
||||
}
|
||||
|
||||
bold := color.New(color.Bold)
|
||||
green := color.New(color.FgGreen)
|
||||
dim := color.New(color.FgHiBlack)
|
||||
|
||||
cfg := loadConfig()
|
||||
original := cfg // snapshot for change detection
|
||||
|
||||
fmt.Println()
|
||||
bold.Println(" unarr config")
|
||||
fmt.Println()
|
||||
|
||||
// Direct category access
|
||||
if category != "" {
|
||||
if err := runCategory(&cfg, category); err != nil {
|
||||
if errors.Is(err, huh.ErrUserAborted) {
|
||||
fmt.Println("\n Cancelled.")
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
return saveIfChanged(cfg, original, green, dim)
|
||||
}
|
||||
|
||||
// Menu loop
|
||||
for {
|
||||
var choice string
|
||||
err := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewSelect[string]().
|
||||
Title("Settings").
|
||||
Options(
|
||||
huh.NewOption("Downloads — directory, method, speed limits", "downloads"),
|
||||
huh.NewOption("Organization — auto-sort Movies & TV Shows", "organization"),
|
||||
huh.NewOption("Notifications — desktop notifications", "notifications"),
|
||||
huh.NewOption("Device — agent name", "device"),
|
||||
huh.NewOption("Region — country & language", "region"),
|
||||
huh.NewOption("Connection — API URL & key", "connection"),
|
||||
huh.NewOption("Advanced — daemon intervals", "advanced"),
|
||||
huh.NewOption("Done — save & exit", "done"),
|
||||
).
|
||||
Value(&choice),
|
||||
),
|
||||
).Run()
|
||||
if err != nil {
|
||||
if errors.Is(err, huh.ErrUserAborted) {
|
||||
return saveIfChanged(cfg, original, green, dim)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
if choice == "done" {
|
||||
return saveIfChanged(cfg, original, green, dim)
|
||||
}
|
||||
|
||||
if err := runCategory(&cfg, choice); err != nil {
|
||||
if errors.Is(err, huh.ErrUserAborted) {
|
||||
continue // back to menu
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func runCategory(cfg *config.Config, category string) error {
|
||||
switch category {
|
||||
case "downloads":
|
||||
return configDownloads(cfg)
|
||||
case "organization":
|
||||
return configOrganization(cfg)
|
||||
case "notifications":
|
||||
return configNotifications(cfg)
|
||||
case "device":
|
||||
return configDevice(cfg)
|
||||
case "region":
|
||||
return configRegion(cfg)
|
||||
case "connection":
|
||||
return configConnection(cfg)
|
||||
case "advanced":
|
||||
return configAdvanced(cfg)
|
||||
default:
|
||||
return fmt.Errorf("unknown category %q — valid: %s", category, strings.Join(configCategories, ", "))
|
||||
}
|
||||
}
|
||||
|
||||
func configDownloads(cfg *config.Config) error {
|
||||
concurrent := strconv.Itoa(cfg.Download.MaxConcurrent)
|
||||
validConcurrent := map[string]bool{"1": true, "2": true, "3": true, "4": true, "5": true, "6": true, "8": true, "10": true}
|
||||
if !validConcurrent[concurrent] {
|
||||
concurrent = "3"
|
||||
}
|
||||
|
||||
validMethods := map[string]bool{"auto": true, "torrent": true, "debrid": true, "usenet": true}
|
||||
if !validMethods[cfg.Download.PreferredMethod] {
|
||||
cfg.Download.PreferredMethod = "auto"
|
||||
}
|
||||
|
||||
err := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewInput().
|
||||
Title("Download directory").
|
||||
Value(&cfg.Download.Dir),
|
||||
huh.NewSelect[string]().
|
||||
Title("Preferred method").
|
||||
Options(
|
||||
huh.NewOption("Auto (torrent + debrid when available)", "auto"),
|
||||
huh.NewOption("Torrent only (BitTorrent P2P)", "torrent"),
|
||||
huh.NewOption("Debrid only (Real-Debrid, AllDebrid...)", "debrid"),
|
||||
huh.NewOption("Usenet only (requires Pro)", "usenet"),
|
||||
).
|
||||
Value(&cfg.Download.PreferredMethod),
|
||||
huh.NewSelect[string]().
|
||||
Title("Max concurrent downloads").
|
||||
Options(
|
||||
huh.NewOption("1", "1"),
|
||||
huh.NewOption("2", "2"),
|
||||
huh.NewOption("3 (default)", "3"),
|
||||
huh.NewOption("4", "4"),
|
||||
huh.NewOption("5", "5"),
|
||||
huh.NewOption("6", "6"),
|
||||
huh.NewOption("8", "8"),
|
||||
huh.NewOption("10", "10"),
|
||||
).
|
||||
Value(&concurrent),
|
||||
huh.NewInput().
|
||||
Title("Max download speed").
|
||||
Description("0 = unlimited. Examples: 10MB, 500KB").
|
||||
Value(&cfg.Download.MaxDownloadSpeed).
|
||||
Validate(validateSpeed),
|
||||
huh.NewInput().
|
||||
Title("Max upload speed").
|
||||
Description("0 = unlimited. Examples: 1MB, 500KB").
|
||||
Value(&cfg.Download.MaxUploadSpeed).
|
||||
Validate(validateSpeed),
|
||||
),
|
||||
).Run()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cfg.Download.Dir = expandHome(strings.TrimSpace(cfg.Download.Dir))
|
||||
n, _ := strconv.Atoi(concurrent)
|
||||
if n > 0 {
|
||||
cfg.Download.MaxConcurrent = n
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func configOrganization(cfg *config.Config) error {
|
||||
err := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewConfirm().
|
||||
Title("Auto-organize downloads?").
|
||||
Description("Sort files into Movies and TV Shows subdirectories").
|
||||
Value(&cfg.Organize.Enabled),
|
||||
huh.NewInput().
|
||||
Title("Movies directory").
|
||||
Value(&cfg.Organize.MoviesDir),
|
||||
huh.NewInput().
|
||||
Title("TV Shows directory").
|
||||
Value(&cfg.Organize.TVShowsDir),
|
||||
),
|
||||
).Run()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg.Organize.MoviesDir = expandHome(strings.TrimSpace(cfg.Organize.MoviesDir))
|
||||
cfg.Organize.TVShowsDir = expandHome(strings.TrimSpace(cfg.Organize.TVShowsDir))
|
||||
return nil
|
||||
}
|
||||
|
||||
func configNotifications(cfg *config.Config) error {
|
||||
return huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewConfirm().
|
||||
Title("Desktop notifications?").
|
||||
Description("Show a notification when a download completes").
|
||||
Value(&cfg.Notifications.Enabled),
|
||||
),
|
||||
).Run()
|
||||
}
|
||||
|
||||
func configDevice(cfg *config.Config) error {
|
||||
dim := color.New(color.FgHiBlack)
|
||||
if cfg.Agent.ID != "" {
|
||||
dim.Printf(" Agent ID: %s\n\n", cfg.Agent.ID)
|
||||
}
|
||||
|
||||
return huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewInput().
|
||||
Title("Agent name").
|
||||
Description("Shown in the web dashboard").
|
||||
Value(&cfg.Agent.Name),
|
||||
),
|
||||
).Run()
|
||||
}
|
||||
|
||||
func configRegion(cfg *config.Config) error {
|
||||
return huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewInput().
|
||||
Title("Country").
|
||||
Description("ISO code for streaming providers (US, ES, DE, GB...)").
|
||||
Placeholder("US").
|
||||
Value(&cfg.General.Country),
|
||||
huh.NewInput().
|
||||
Title("Locale").
|
||||
Description("Language for content metadata (en, es, de, fr...)").
|
||||
Placeholder("en").
|
||||
Value(&cfg.General.Locale),
|
||||
),
|
||||
).Run()
|
||||
}
|
||||
|
||||
func configConnection(cfg *config.Config) error {
|
||||
keyDesc := "Current: (none)"
|
||||
if k := cfg.Auth.APIKey; len(k) > 8 {
|
||||
keyDesc = "Current: " + k[:8] + "..."
|
||||
}
|
||||
|
||||
return huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewInput().
|
||||
Title("API URL").
|
||||
Value(&cfg.Auth.APIURL),
|
||||
huh.NewInput().
|
||||
Title("API Key").
|
||||
Description(keyDesc).
|
||||
EchoMode(huh.EchoModePassword).
|
||||
Value(&cfg.Auth.APIKey),
|
||||
),
|
||||
).Run()
|
||||
}
|
||||
|
||||
func configAdvanced(cfg *config.Config) error {
|
||||
return huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewInput().
|
||||
Title("Poll interval").
|
||||
Description("How often to check for new tasks (e.g. 30s, 1m)").
|
||||
Value(&cfg.Daemon.PollInterval).
|
||||
Validate(validateDuration),
|
||||
huh.NewInput().
|
||||
Title("Heartbeat interval").
|
||||
Description("How often to send heartbeat to server (e.g. 30s, 1m)").
|
||||
Value(&cfg.Daemon.HeartbeatInterval).
|
||||
Validate(validateDuration),
|
||||
),
|
||||
).Run()
|
||||
}
|
||||
|
||||
// ── Validators ──────────────────────────────────────────────────────
|
||||
|
||||
func validateSpeed(s string) error {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" || s == "0" {
|
||||
return nil
|
||||
}
|
||||
if _, err := config.ParseSpeed(s); err != nil {
|
||||
return fmt.Errorf("invalid speed: %s (e.g. 10MB, 500KB, 0)", s)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateDuration(s string) error {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
if _, err := time.ParseDuration(s); err != nil {
|
||||
return fmt.Errorf("invalid duration: %s (e.g. 30s, 1m, 5m)", s)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ── Save logic ──────────────────────────────────────────────────────
|
||||
|
||||
func saveIfChanged(cfg, original config.Config, green, dim *color.Color) error {
|
||||
if reflect.DeepEqual(cfg, original) {
|
||||
dim.Println(" No changes made.")
|
||||
fmt.Println()
|
||||
return nil
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
appCfg = cfg // update cached config so subsequent calls see the new values
|
||||
|
||||
fmt.Println()
|
||||
green.Printf(" ✓ Configuration saved to %s\n", configPath)
|
||||
fmt.Println()
|
||||
return nil
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue