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
|
|
@ -8,11 +8,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- Init wizard with daemon install step (`unarr init`, replaces `unarr setup`)
|
||||
- Interactive config menu with 7 categories (`unarr config [category]`)
|
||||
- Clean command to remove temp files, logs, and cached data (`unarr clean`)
|
||||
- Daemon mode with background download management (`unarr start`)
|
||||
- One-shot download command (`unarr download`)
|
||||
- Stream to media player (`unarr stream`)
|
||||
- Setup wizard for first-time configuration (`unarr setup`)
|
||||
- Doctor command for diagnostics (`unarr doctor`)
|
||||
- Status command for daemon monitoring (`unarr status`)
|
||||
- Download engine with torrent support (debrid and usenet coming soon)
|
||||
|
|
|
|||
|
|
@ -53,8 +53,8 @@ make build
|
|||
## Quick Start
|
||||
|
||||
```bash
|
||||
# 1. Run the setup wizard (opens browser for API key)
|
||||
unarr setup
|
||||
# 1. Run the init wizard (opens browser for API key)
|
||||
unarr init
|
||||
|
||||
# 2. Search for content
|
||||
unarr search "breaking bad" --type show --quality 1080p
|
||||
|
|
@ -69,8 +69,8 @@ unarr start
|
|||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `unarr setup` | First-time configuration wizard (API key, download dir, method) |
|
||||
| `unarr config` | Edit configuration interactively |
|
||||
| `unarr init` | First-time configuration wizard (API key, download dir, daemon) |
|
||||
| `unarr config` | Edit all settings interactively (speed, organization, etc.) |
|
||||
|
||||
### Search & Discovery
|
||||
|
||||
|
|
|
|||
|
|
@ -153,7 +153,7 @@ function Install-Docker {
|
|||
Write-Host " mkdir `$env:APPDATA\unarr"
|
||||
Write-Host ""
|
||||
Write-Host " # 2. Run setup (interactive)"
|
||||
Write-Host " docker run -it --rm -v `$env:APPDATA\unarr:/config torrentclaw/unarr setup"
|
||||
Write-Host " docker run -it --rm -v `$env:APPDATA\unarr:/config torrentclaw/unarr init"
|
||||
Write-Host ""
|
||||
Write-Host " # 3. Start daemon"
|
||||
Write-Host " docker run -d --name unarr --restart unless-stopped ``"
|
||||
|
|
@ -238,7 +238,7 @@ function Main {
|
|||
Install-Binary
|
||||
Write-Host ""
|
||||
Write-Host " Run " -NoNewline
|
||||
Write-Host "unarr setup" -ForegroundColor White -NoNewline
|
||||
Write-Host "unarr init" -ForegroundColor White -NoNewline
|
||||
Write-Host " to get started."
|
||||
Write-Host ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -204,7 +204,7 @@ install_docker() {
|
|||
# 2. Run setup (interactive)
|
||||
docker run -it --rm \
|
||||
-v ~/.config/unarr:/config \
|
||||
torrentclaw/unarr setup
|
||||
torrentclaw/unarr init
|
||||
|
||||
# 3. Start daemon
|
||||
docker run -d --name unarr \
|
||||
|
|
@ -310,7 +310,7 @@ main() {
|
|||
case "$METHOD" in
|
||||
binary)
|
||||
install_binary
|
||||
printf "\n Run ${BOLD}unarr setup${RESET} to get started.\n\n"
|
||||
printf "\n Run ${BOLD}unarr init${RESET} to get started.\n\n"
|
||||
;;
|
||||
docker)
|
||||
install_docker
|
||||
|
|
@ -322,7 +322,7 @@ main() {
|
|||
info "Installing via Homebrew..."
|
||||
brew install torrentclaw/tap/unarr
|
||||
ok "Installed via Homebrew"
|
||||
printf "\n Run ${BOLD}unarr setup${RESET} to get started.\n\n"
|
||||
printf "\n Run ${BOLD}unarr init${RESET} to get started.\n\n"
|
||||
;;
|
||||
*)
|
||||
error "Unknown method: $METHOD"
|
||||
|
|
|
|||
|
|
@ -1,118 +0,0 @@
|
|||
package cmd
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/fatih/color"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/torrentclaw/torrentclaw-cli/internal/config"
|
||||
)
|
||||
|
||||
func newConfigCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "config",
|
||||
Short: "Edit configuration interactively",
|
||||
Long: `Edit unarr settings interactively in your terminal.
|
||||
|
||||
Prompts for API URL, API key, and default country. Press Enter to keep
|
||||
the current value. For first-time setup use 'unarr setup' instead.
|
||||
|
||||
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
|
||||
unarr config --config /path/to/config.toml`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return runConfig()
|
||||
},
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runConfig() error {
|
||||
if !isTerminal() {
|
||||
return fmt.Errorf("interactive config requires a terminal (use --api-key flag or env vars instead)")
|
||||
}
|
||||
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
bold := color.New(color.Bold)
|
||||
green := color.New(color.FgGreen)
|
||||
|
||||
cfg := loadConfig()
|
||||
|
||||
fmt.Println()
|
||||
bold.Println(" unarr Configuration")
|
||||
fmt.Println()
|
||||
|
||||
// API URL
|
||||
currentURL := cfg.Auth.APIURL
|
||||
fmt.Printf(" API URL [%s]: ", currentURL)
|
||||
apiURL, _ := reader.ReadString('\n')
|
||||
apiURL = strings.TrimSpace(apiURL)
|
||||
if apiURL == "" {
|
||||
apiURL = currentURL
|
||||
}
|
||||
|
||||
// API Key
|
||||
currentKey := cfg.Auth.APIKey
|
||||
keyDisplay := ""
|
||||
if currentKey != "" {
|
||||
if len(currentKey) > 8 {
|
||||
keyDisplay = currentKey[:8] + "..."
|
||||
} else {
|
||||
keyDisplay = currentKey
|
||||
}
|
||||
}
|
||||
fmt.Printf(" API Key [%s]: ", keyDisplay)
|
||||
apiKey, _ := reader.ReadString('\n')
|
||||
apiKey = strings.TrimSpace(apiKey)
|
||||
if apiKey == "" {
|
||||
apiKey = currentKey
|
||||
}
|
||||
|
||||
// Country
|
||||
currentCountry := cfg.General.Country
|
||||
fmt.Printf(" Default country [%s]: ", currentCountry)
|
||||
country, _ := reader.ReadString('\n')
|
||||
country = strings.TrimSpace(country)
|
||||
if country == "" {
|
||||
country = currentCountry
|
||||
}
|
||||
|
||||
// Apply changes
|
||||
cfg.Auth.APIURL = apiURL
|
||||
cfg.Auth.APIKey = apiKey
|
||||
cfg.General.Country = country
|
||||
|
||||
// Save
|
||||
configPath := config.FilePath()
|
||||
if cfgFile != "" {
|
||||
configPath = cfgFile
|
||||
}
|
||||
|
||||
if err := config.Save(cfg, configPath); err != nil {
|
||||
return fmt.Errorf("could not save config: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
green.Printf(" Configuration saved to %s\n", configPath)
|
||||
fmt.Println()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// isTerminal checks if stdin is a terminal.
|
||||
func isTerminal() bool {
|
||||
fi, err := os.Stdin.Stat()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return fi.Mode()&os.ModeCharDevice != 0
|
||||
}
|
||||
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
|
||||
}
|
||||
|
|
@ -35,7 +35,7 @@ The daemon sends periodic heartbeats and reports download progress back
|
|||
to the web dashboard. Press Ctrl+C to stop gracefully — active downloads
|
||||
get up to 30 seconds to finish.
|
||||
|
||||
Requires: API key, agent ID, and download directory (run 'unarr setup' first).
|
||||
Requires: API key, agent ID, and download directory (run 'unarr init' first).
|
||||
|
||||
To run as a background service, use 'unarr daemon install' instead.`,
|
||||
Example: ` unarr start
|
||||
|
|
@ -99,13 +99,13 @@ func runDaemonStart() error {
|
|||
|
||||
// Validate config
|
||||
if cfg.Auth.APIKey == "" {
|
||||
return fmt.Errorf("no API key configured — run 'unarr setup' first")
|
||||
return fmt.Errorf("no API key configured — run 'unarr init' first")
|
||||
}
|
||||
if cfg.Agent.ID == "" {
|
||||
return fmt.Errorf("no agent ID — run 'unarr setup' first")
|
||||
return fmt.Errorf("no agent ID — run 'unarr init' first")
|
||||
}
|
||||
if cfg.Download.Dir == "" {
|
||||
return fmt.Errorf("no download directory — run 'unarr setup' first")
|
||||
return fmt.Errorf("no download directory — run 'unarr init' first")
|
||||
}
|
||||
|
||||
// Validate configured paths are safe
|
||||
|
|
|
|||
|
|
@ -84,7 +84,7 @@ func runDoctor() error {
|
|||
path = cfgFile
|
||||
}
|
||||
if _, err := os.Stat(path); os.IsNotExist(err) {
|
||||
return path + " (not found, run unarr setup)", fmt.Errorf("missing")
|
||||
return path + " (not found, run unarr init)", fmt.Errorf("missing")
|
||||
}
|
||||
return path, nil
|
||||
})
|
||||
|
|
@ -95,7 +95,7 @@ func runDoctor() error {
|
|||
key = cfg.Auth.APIKey
|
||||
}
|
||||
if key == "" {
|
||||
return "run unarr setup to configure", fmt.Errorf("missing")
|
||||
return "run unarr init to configure", fmt.Errorf("missing")
|
||||
}
|
||||
if len(key) > 8 {
|
||||
return key[:8] + "...", nil
|
||||
|
|
@ -130,7 +130,7 @@ func runDoctor() error {
|
|||
return "no API key", fmt.Errorf("skipped")
|
||||
}
|
||||
if cfg.Agent.ID == "" {
|
||||
return "no agent ID, run unarr setup", fmt.Errorf("not registered")
|
||||
return "no agent ID, run unarr init", fmt.Errorf("not registered")
|
||||
}
|
||||
|
||||
ac := agent.NewClient(cfg.Auth.APIURL, key, "unarr/"+Version)
|
||||
|
|
@ -155,7 +155,7 @@ func runDoctor() error {
|
|||
check("Download directory", func() (string, error) {
|
||||
dir := cfg.Download.Dir
|
||||
if dir == "" {
|
||||
return "not configured, run unarr setup", fmt.Errorf("missing")
|
||||
return "not configured, run unarr init", fmt.Errorf("missing")
|
||||
}
|
||||
fi, err := os.Stat(dir)
|
||||
if os.IsNotExist(err) {
|
||||
|
|
|
|||
56
internal/cmd/helpers.go
Normal file
56
internal/cmd/helpers.go
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
package cmd
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// openBrowser opens a URL in the default browser.
|
||||
func openBrowser(url string) {
|
||||
var c *exec.Cmd
|
||||
switch runtime.GOOS {
|
||||
case "darwin":
|
||||
c = exec.Command("open", url)
|
||||
case "windows":
|
||||
c = exec.Command("rundll32", "url.dll,FileProtocolHandler", url)
|
||||
default: // linux, freebsd
|
||||
c = exec.Command("xdg-open", url)
|
||||
}
|
||||
_ = c.Start() // fire and forget; best-effort
|
||||
}
|
||||
|
||||
// defaultDownloadDir returns a sensible default download directory.
|
||||
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")
|
||||
}
|
||||
|
||||
// expandHome expands a leading ~/ to the user's home directory.
|
||||
func expandHome(path string) string {
|
||||
if strings.HasPrefix(path, "~/") {
|
||||
home, _ := os.UserHomeDir()
|
||||
return filepath.Join(home, path[2:])
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
// isTerminal checks if stdin is a terminal (not piped).
|
||||
func isTerminal() bool {
|
||||
fi, err := os.Stdin.Stat()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return fi.Mode()&os.ModeCharDevice != 0
|
||||
}
|
||||
|
|
@ -2,9 +2,9 @@ package cmd
|
|||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
|
@ -17,25 +17,25 @@ import (
|
|||
"github.com/torrentclaw/torrentclaw-cli/internal/config"
|
||||
)
|
||||
|
||||
func newSetupCmd() *cobra.Command {
|
||||
func newInitCmd() *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.
|
||||
Use: "init",
|
||||
Aliases: []string{"setup"},
|
||||
Short: "First-time configuration wizard",
|
||||
Long: `Interactive setup that connects your account, picks a download directory,
|
||||
and optionally installs the daemon as a background service.
|
||||
|
||||
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.
|
||||
Opens your browser to create/copy your API key, validates it against the
|
||||
server, and saves your configuration.
|
||||
|
||||
Run this once after installing unarr. To change settings later,
|
||||
Run this once after installing unarr. To customize settings later,
|
||||
use 'unarr config' or edit ~/.config/unarr/config.toml directly.`,
|
||||
Example: ` unarr setup
|
||||
unarr setup --api-url https://custom.server.com`,
|
||||
Example: ` unarr init
|
||||
unarr init --api-url https://custom.server.com`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return runSetup(apiURL)
|
||||
return runInit(apiURL)
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -44,13 +44,17 @@ use 'unarr config' or edit ~/.config/unarr/config.toml directly.`,
|
|||
return cmd
|
||||
}
|
||||
|
||||
func runSetup(apiURLOverride string) error {
|
||||
func runInit(apiURLOverride string) error {
|
||||
if !isTerminal() {
|
||||
return fmt.Errorf("interactive mode requires a terminal (use UNARR_API_KEY env var instead)")
|
||||
}
|
||||
|
||||
bold := color.New(color.Bold)
|
||||
green := color.New(color.FgGreen)
|
||||
cyan := color.New(color.FgCyan)
|
||||
|
||||
fmt.Println()
|
||||
bold.Println(" unarr Setup")
|
||||
bold.Println(" unarr init")
|
||||
fmt.Println()
|
||||
|
||||
cfg := loadConfig()
|
||||
|
|
@ -64,18 +68,18 @@ func runSetup(apiURLOverride string) error {
|
|||
apiURL = "https://torrentclaw.com"
|
||||
}
|
||||
|
||||
// Open browser to API keys page
|
||||
// ── Step 1/3: Connect account ───────────────────────────────────
|
||||
|
||||
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").
|
||||
Title("Step 1/3 — API Key").
|
||||
Description("Copy it from the page that just opened in your browser").
|
||||
Placeholder("tc_...").
|
||||
Value(&apiKey).
|
||||
|
|
@ -92,6 +96,10 @@ func runSetup(apiURLOverride string) error {
|
|||
),
|
||||
).Run()
|
||||
if err != nil {
|
||||
if errors.Is(err, huh.ErrUserAborted) {
|
||||
fmt.Println("\n Init cancelled.")
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
apiKey = strings.TrimSpace(apiKey)
|
||||
|
|
@ -129,7 +137,8 @@ func runSetup(apiURLOverride string) error {
|
|||
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
|
||||
// ── Step 2/3: Download directory ────────────────────────────────
|
||||
|
||||
downloadDir := cfg.Download.Dir
|
||||
if downloadDir == "" {
|
||||
downloadDir = defaultDownloadDir()
|
||||
|
|
@ -137,72 +146,53 @@ func runSetup(apiURLOverride string) error {
|
|||
err = huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewInput().
|
||||
Title("Download Directory").
|
||||
Title("Step 2/3 — Download Directory").
|
||||
Description("Where should downloaded files be saved?").
|
||||
Value(&downloadDir),
|
||||
),
|
||||
).Run()
|
||||
if err != nil {
|
||||
if errors.Is(err, huh.ErrUserAborted) {
|
||||
fmt.Println("\n Init cancelled.")
|
||||
return 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"),
|
||||
)
|
||||
}
|
||||
// ── Step 3/3: Install daemon ────────────────────────────────────
|
||||
|
||||
var installDaemon bool
|
||||
err = huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewSelect[string]().
|
||||
Title("Download Method").
|
||||
Description("How do you want to download?").
|
||||
Options(methodOptions...).
|
||||
Value(&method),
|
||||
huh.NewConfirm().
|
||||
Title("Step 3/3 — Install background service?").
|
||||
Description("Starts unarr automatically on boot (systemd/launchd)").
|
||||
Affirmative("Yes, install and start").
|
||||
Negative("No, I'll run it manually").
|
||||
Value(&installDaemon),
|
||||
),
|
||||
).Run()
|
||||
if err != nil {
|
||||
if errors.Is(err, huh.ErrUserAborted) {
|
||||
fmt.Println("\n Init cancelled.")
|
||||
return 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 ─────────────────────────────────────────────────
|
||||
|
||||
// Save config
|
||||
cfg.Auth.APIKey = apiKey
|
||||
cfg.Auth.APIURL = apiURL
|
||||
cfg.Agent.ID = agentID
|
||||
cfg.Agent.Name = strings.TrimSpace(agentName)
|
||||
cfg.Agent.Name = agentName
|
||||
cfg.Download.Dir = downloadDir
|
||||
cfg.Download.PreferredMethod = method
|
||||
|
||||
// Set organize dirs based on download dir
|
||||
if cfg.Download.PreferredMethod == "" {
|
||||
cfg.Download.PreferredMethod = "auto"
|
||||
}
|
||||
|
||||
if cfg.Organize.MoviesDir == "" {
|
||||
cfg.Organize.MoviesDir = filepath.Join(downloadDir, "Movies")
|
||||
}
|
||||
|
|
@ -210,7 +200,6 @@ func runSetup(apiURLOverride string) error {
|
|||
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)
|
||||
}
|
||||
|
|
@ -223,16 +212,27 @@ func runSetup(apiURLOverride string) error {
|
|||
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
|
||||
|
||||
// ── Install daemon (if requested) ───────────────────────────────
|
||||
|
||||
if installDaemon {
|
||||
fmt.Println()
|
||||
if err := runDaemonInstall(); err != nil {
|
||||
color.New(color.FgYellow).Printf(" Could not install daemon: %s\n", err)
|
||||
fmt.Println()
|
||||
fmt.Println(" You can install it later with: " + bold.Sprint("unarr daemon install"))
|
||||
fmt.Println(" Or run manually with: " + bold.Sprint("unarr start"))
|
||||
}
|
||||
}
|
||||
|
||||
// ── Summary ─────────────────────────────────────────────────────
|
||||
|
||||
// Summary
|
||||
fmt.Println()
|
||||
green.Println(" Setup complete!")
|
||||
green.Println(" ✓ unarr is ready!")
|
||||
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.Printf(" Dashboard: %s/downloads\n", apiURL)
|
||||
fmt.Printf(" Config: %s\n", configPath)
|
||||
fmt.Println()
|
||||
|
||||
// Features summary
|
||||
|
|
@ -246,46 +246,21 @@ func runSetup(apiURLOverride string) error {
|
|||
if resp.Features.Usenet {
|
||||
features = append(features, "Usenet")
|
||||
}
|
||||
cyan.Printf(" Available: %s\n", strings.Join(features, ", "))
|
||||
if len(features) > 0 {
|
||||
cyan.Printf(" Available: %s\n", strings.Join(features, ", "))
|
||||
}
|
||||
|
||||
if !installDaemon {
|
||||
fmt.Println()
|
||||
fmt.Println(" Start the daemon:")
|
||||
fmt.Println(" " + bold.Sprint("unarr start") + " foreground (Ctrl+C to stop)")
|
||||
fmt.Println(" " + bold.Sprint("unarr daemon install") + " background service (auto-start on boot)")
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
fmt.Println(" Next: run", bold.Sprint("unarr start"), "to begin downloading")
|
||||
fmt.Println(" Customize speed limits, notifications, and more:")
|
||||
fmt.Println(" " + bold.Sprint("unarr config"))
|
||||
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
|
||||
}
|
||||
|
|
@ -32,7 +32,7 @@ Search 30+ torrent sources, inspect torrent quality, discover popular content,
|
|||
find streaming providers, and manage your media collection — all from your terminal.
|
||||
|
||||
Get started:
|
||||
unarr setup First-time configuration wizard
|
||||
unarr init First-time configuration wizard
|
||||
unarr search "breaking bad" Search for content
|
||||
unarr start Start the download daemon
|
||||
|
||||
|
|
@ -62,8 +62,8 @@ Source: https://github.com/torrentclaw/torrentclaw-cli`,
|
|||
rootCmd.PersistentFlags().BoolVar(&noColor, "no-color", false, "disable colored output")
|
||||
|
||||
// Getting Started
|
||||
setupCmd := newSetupCmd()
|
||||
setupCmd.GroupID = "start"
|
||||
initCmd := newInitCmd()
|
||||
initCmd.GroupID = "start"
|
||||
configCmd := newConfigCmd()
|
||||
configCmd.GroupID = "start"
|
||||
|
||||
|
|
@ -111,7 +111,7 @@ Source: https://github.com/torrentclaw/torrentclaw-cli`,
|
|||
|
||||
rootCmd.AddCommand(
|
||||
// Getting Started
|
||||
setupCmd,
|
||||
initCmd,
|
||||
configCmd,
|
||||
// Search & Discovery
|
||||
searchCmd,
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ func runStatus() error {
|
|||
cfg := loadConfig()
|
||||
|
||||
if cfg.Auth.APIKey == "" {
|
||||
dim.Println(" Not configured. Run 'unarr setup' first.")
|
||||
dim.Println(" Not configured. Run 'unarr init' first.")
|
||||
fmt.Println()
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -72,6 +72,7 @@ type diskFileProvider struct {
|
|||
func (p *diskFileProvider) NewFileReader(_ context.Context) io.ReadSeekCloser {
|
||||
f, err := os.Open(p.path)
|
||||
if err != nil {
|
||||
log.Printf("stream: failed to open %q: %v", p.path, err)
|
||||
return nil
|
||||
}
|
||||
return f
|
||||
|
|
@ -134,6 +135,17 @@ func (ss *StreamServer) Shutdown(ctx context.Context) error {
|
|||
}
|
||||
|
||||
func (ss *StreamServer) handler(w http.ResponseWriter, r *http.Request) {
|
||||
// CORS headers — allow web player from any origin (HTTPS site → localhost)
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, HEAD, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Range")
|
||||
w.Header().Set("Access-Control-Expose-Headers", "Content-Length, Content-Range, Accept-Ranges")
|
||||
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
|
||||
reader := ss.provider.NewFileReader(r.Context())
|
||||
if reader == nil {
|
||||
http.Error(w, "file not found", http.StatusNotFound)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue