Add `unarr vpn` (status/enable/disable, with `status --check`) to manage the managed WireGuard split-tunnel from the CLI. The daemon now reports its split-tunnel state (active, mode, exit server) to the web on register and on every sync, and sends its agent id when fetching the VPN config so the web can arbitrate the single WireGuard slot (1 VPNResellers account = 1 WG keypair = 1 concurrent connection): the first agent claims it; the rest are told to run OpenVPN on their own host (1 WireGuard + up to 9 OpenVPN = 10). `status --check` passes probe=1 so it validates provisioning without claiming the slot. VPNActive drops omitempty so a downed tunnel reaches the server and frees the slot. Bumps to 0.9.2 with CHANGELOG + README VPN section.
233 lines
5.8 KiB
Go
233 lines
5.8 KiB
Go
package cmd
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
|
|
"github.com/fatih/color"
|
|
"github.com/spf13/cobra"
|
|
tc "github.com/torrentclaw/go-client"
|
|
"github.com/torrentclaw/unarr/internal/config"
|
|
"github.com/torrentclaw/unarr/internal/sentry"
|
|
"github.com/torrentclaw/unarr/internal/upgrade"
|
|
)
|
|
|
|
var (
|
|
cfgFile string
|
|
apiKeyFlag string
|
|
jsonOut bool
|
|
noColor bool
|
|
rootCmd *cobra.Command
|
|
apiClient *tc.Client
|
|
appCfg config.Config
|
|
cfgLoaded bool
|
|
)
|
|
|
|
func init() {
|
|
rootCmd = &cobra.Command{
|
|
Use: "unarr",
|
|
Short: "unarr — torrent search and management",
|
|
Long: `unarr is a powerful terminal tool for torrent search and management.
|
|
|
|
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 init First-time configuration wizard
|
|
unarr search "breaking bad" Search for content
|
|
unarr start Start the download daemon
|
|
|
|
Documentation: https://torrentclaw.com/cli
|
|
Source: https://github.com/torrentclaw/unarr`,
|
|
PersistentPreRun: func(cmd *cobra.Command, args []string) {
|
|
if noColor || os.Getenv("NO_COLOR") != "" {
|
|
color.NoColor = true
|
|
}
|
|
// Self-updater fetches releases from the configured host (default
|
|
// torrentclaw.com), not GitHub — so mirrors / onion / staging /
|
|
// UNARR_API_URL all route updates correctly.
|
|
upgrade.SetBaseURL(loadConfig().Auth.APIURL)
|
|
},
|
|
SilenceUsage: true,
|
|
SilenceErrors: true,
|
|
}
|
|
|
|
// Command groups for organized help output
|
|
rootCmd.AddGroup(
|
|
&cobra.Group{ID: "start", Title: "Getting Started:"},
|
|
&cobra.Group{ID: "search", Title: "Search & Discovery:"},
|
|
&cobra.Group{ID: "download", Title: "Downloads & Streaming:"},
|
|
&cobra.Group{ID: "daemon", Title: "Daemon Management:"},
|
|
&cobra.Group{ID: "system", Title: "System & Diagnostics:"},
|
|
)
|
|
|
|
rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default ~/.config/unarr/config.toml)")
|
|
rootCmd.PersistentFlags().StringVar(&apiKeyFlag, "api-key", "", "API key (overrides config file and env)")
|
|
rootCmd.PersistentFlags().BoolVar(&jsonOut, "json", false, "output as JSON (for piping)")
|
|
rootCmd.PersistentFlags().BoolVar(&noColor, "no-color", false, "disable colored output")
|
|
|
|
// Getting Started
|
|
initCmd := newInitCmd()
|
|
initCmd.GroupID = "start"
|
|
loginCmd := newLoginCmd()
|
|
loginCmd.GroupID = "start"
|
|
configCmd := newConfigCmd()
|
|
configCmd.GroupID = "start"
|
|
migrateCmd := newMigrateCmd()
|
|
migrateCmd.GroupID = "start"
|
|
|
|
// Search & Discovery
|
|
searchCmd := newSearchCmd()
|
|
searchCmd.GroupID = "search"
|
|
inspectCmd := newInspectCmd()
|
|
inspectCmd.GroupID = "search"
|
|
popularCmd := newPopularCmd()
|
|
popularCmd.GroupID = "search"
|
|
recentCmd := newRecentCmd()
|
|
recentCmd.GroupID = "search"
|
|
watchCmd := newWatchCmd()
|
|
watchCmd.GroupID = "search"
|
|
|
|
// Downloads & Streaming
|
|
downloadCmd := newDownloadCmd()
|
|
downloadCmd.GroupID = "download"
|
|
streamCmd := newStreamCmd()
|
|
streamCmd.GroupID = "download"
|
|
|
|
// Daemon Management
|
|
startCmd := newStartCmd()
|
|
startCmd.GroupID = "daemon"
|
|
stopCmd := newStopCmd()
|
|
stopCmd.GroupID = "daemon"
|
|
statusCmd := newStatusCmd()
|
|
statusCmd.GroupID = "daemon"
|
|
daemonCmd := newDaemonCmd()
|
|
daemonCmd.GroupID = "daemon"
|
|
vpnCmd := newVPNCmd()
|
|
vpnCmd.GroupID = "daemon"
|
|
|
|
// System & Diagnostics
|
|
statsCmd := newStatsCmd()
|
|
statsCmd.GroupID = "system"
|
|
doctorCmd := newDoctorCmd()
|
|
doctorCmd.GroupID = "system"
|
|
probeHWAccelCmd := newProbeHWAccelCmd()
|
|
probeHWAccelCmd.GroupID = "system"
|
|
cleanCmd := newCleanCmd()
|
|
cleanCmd.GroupID = "system"
|
|
mirrorsCmd := newMirrorsCmd()
|
|
mirrorsCmd.GroupID = "system"
|
|
selfUpdateCmd := newSelfUpdateCmd()
|
|
selfUpdateCmd.GroupID = "system"
|
|
versionCmd := newVersionCmd()
|
|
versionCmd.GroupID = "system"
|
|
completionCmd := newCompletionCmd()
|
|
completionCmd.GroupID = "system"
|
|
|
|
// Library
|
|
scanCmd := newScanCmd()
|
|
scanCmd.GroupID = "search"
|
|
|
|
rootCmd.AddCommand(
|
|
// Getting Started
|
|
initCmd,
|
|
loginCmd,
|
|
configCmd,
|
|
migrateCmd,
|
|
// Search & Discovery
|
|
searchCmd,
|
|
inspectCmd,
|
|
popularCmd,
|
|
recentCmd,
|
|
watchCmd,
|
|
// Downloads & Streaming
|
|
downloadCmd,
|
|
streamCmd,
|
|
// Daemon Management
|
|
startCmd,
|
|
stopCmd,
|
|
statusCmd,
|
|
daemonCmd,
|
|
vpnCmd,
|
|
// System & Diagnostics
|
|
statsCmd,
|
|
doctorCmd,
|
|
probeHWAccelCmd,
|
|
cleanCmd,
|
|
mirrorsCmd,
|
|
selfUpdateCmd,
|
|
versionCmd,
|
|
completionCmd,
|
|
// Library
|
|
scanCmd,
|
|
// Alias: upgrade → self-update
|
|
newUpgradeCmd(),
|
|
)
|
|
}
|
|
|
|
// Execute runs the root command.
|
|
func Execute() {
|
|
if err := rootCmd.Execute(); err != nil {
|
|
// Report to Sentry with command context
|
|
command := ""
|
|
if cmd, _, cerr := rootCmd.Find(os.Args[1:]); cerr == nil && cmd != nil && cmd != rootCmd {
|
|
command = cmd.Name()
|
|
}
|
|
sentry.CaptureError(err, command)
|
|
sentry.Close() // Flush before os.Exit (defers don't run after os.Exit)
|
|
|
|
fmt.Fprintln(os.Stderr, color.RedString("Error: %s", err))
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
// loadConfig loads config once (lazy initialization).
|
|
func loadConfig() config.Config {
|
|
if cfgLoaded {
|
|
return appCfg
|
|
}
|
|
|
|
var err error
|
|
appCfg, err = config.Load(cfgFile)
|
|
if err != nil {
|
|
fmt.Fprintln(os.Stderr, color.YellowString("Warning: config load failed: %s", err))
|
|
appCfg = config.Default()
|
|
}
|
|
|
|
appCfg.ApplyEnvOverrides()
|
|
cfgLoaded = true
|
|
|
|
if appCfg.Agent.ID != "" {
|
|
sentry.SetUser(appCfg.Agent.ID)
|
|
}
|
|
|
|
return appCfg
|
|
}
|
|
|
|
// getClient returns a configured API client, initializing it on first use.
|
|
func getClient() *tc.Client {
|
|
if apiClient != nil {
|
|
return apiClient
|
|
}
|
|
|
|
cfg := loadConfig()
|
|
|
|
var opts []tc.Option
|
|
|
|
if cfg.Auth.APIURL != "" {
|
|
opts = append(opts, tc.WithBaseURL(cfg.Auth.APIURL))
|
|
}
|
|
|
|
apiKey := apiKeyFlag
|
|
if apiKey == "" {
|
|
apiKey = cfg.Auth.APIKey
|
|
}
|
|
if apiKey != "" {
|
|
opts = append(opts, tc.WithAPIKey(apiKey))
|
|
}
|
|
|
|
opts = append(opts, tc.WithUserAgent("unarr/"+Version))
|
|
|
|
apiClient = tc.NewClient(opts...)
|
|
return apiClient
|
|
}
|