feat(cli): add login command and refactor shared helpers
This commit is contained in:
parent
0dafeaa70d
commit
4d35e197f0
8 changed files with 296 additions and 49 deletions
25
internal/agent/disk.go
Normal file
25
internal/agent/disk.go
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io/fs"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DirSize returns the total size in bytes of all files under dir.
|
||||||
|
func DirSize(dir string) (int64, error) {
|
||||||
|
var size int64
|
||||||
|
err := filepath.WalkDir(dir, func(_ string, d fs.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return nil // skip unreadable entries
|
||||||
|
}
|
||||||
|
if !d.IsDir() {
|
||||||
|
info, err := d.Info()
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
size += info.Size()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return size, err
|
||||||
|
}
|
||||||
|
|
@ -344,22 +344,7 @@ func runDaemonStart() error {
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// Auto-shutdown after 30 min of idle (no HTTP requests)
|
// Auto-shutdown after 30 min of idle (no HTTP requests)
|
||||||
go func() {
|
go startIdleGuard(ctx, srv, sr.TaskID)
|
||||||
ticker := time.NewTicker(60 * time.Second)
|
|
||||||
defer ticker.Stop()
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return
|
|
||||||
case <-ticker.C:
|
|
||||||
if srv.IdleSince() > 30*time.Minute {
|
|
||||||
log.Printf("[%s] disk stream idle timeout (30m), shutting down", sr.TaskID[:8])
|
|
||||||
cancelStreamTask(sr.TaskID)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wire: WS control actions (pause/cancel/stream pushed from server)
|
// Wire: WS control actions (pause/cancel/stream pushed from server)
|
||||||
|
|
@ -437,7 +422,7 @@ func runDaemonStart() error {
|
||||||
scanInterval = parsed
|
scanInterval = parsed
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
go runAutoScan(ctx, cfg, scanInterval)
|
go runAutoScan(ctx, cfg, scanInterval, agentClient)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start daemon (blocks)
|
// Start daemon (blocks)
|
||||||
|
|
@ -515,7 +500,7 @@ func formatSpeedLog(bps int64) string {
|
||||||
}
|
}
|
||||||
|
|
||||||
// runAutoScan runs a library scan + sync on a timer.
|
// runAutoScan runs a library scan + sync on a timer.
|
||||||
func runAutoScan(ctx context.Context, cfg config.Config, interval time.Duration) {
|
func runAutoScan(ctx context.Context, cfg config.Config, interval time.Duration, ac *agent.Client) {
|
||||||
log.Printf("[auto-scan] enabled: every %s, path: %s", interval, cfg.Library.ScanPath)
|
log.Printf("[auto-scan] enabled: every %s, path: %s", interval, cfg.Library.ScanPath)
|
||||||
|
|
||||||
// Run first scan after a short delay (let daemon stabilize)
|
// Run first scan after a short delay (let daemon stabilize)
|
||||||
|
|
@ -556,13 +541,6 @@ func runAutoScan(ctx context.Context, cfg config.Config, interval time.Duration)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sync to server
|
// Sync to server
|
||||||
apiKey := cfg.Auth.APIKey
|
|
||||||
if apiKey == "" {
|
|
||||||
log.Printf("[auto-scan] no API key, skipping sync")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ac := agent.NewClient(cfg.Auth.APIURL, apiKey, "unarr/"+Version)
|
|
||||||
items := library.BuildSyncItems(cache)
|
items := library.BuildSyncItems(cache)
|
||||||
if len(items) == 0 {
|
if len(items) == 0 {
|
||||||
log.Printf("[auto-scan] no items to sync")
|
log.Printf("[auto-scan] no items to sync")
|
||||||
|
|
@ -605,5 +583,3 @@ func runAutoScan(ctx context.Context, cfg config.Config, interval time.Duration)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildSyncItems moved to internal/library/sync.go as library.BuildSyncItems
|
|
||||||
|
|
|
||||||
|
|
@ -360,18 +360,8 @@ func runInit(apiURLOverride string) error {
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
|
|
||||||
// Features summary
|
// Features summary
|
||||||
features := []string{}
|
if line := formatFeatures(resp.Features); line != "" {
|
||||||
if resp.Features.Torrent {
|
cyan.Printf(" Available: %s\n", line)
|
||||||
features = append(features, "Torrent")
|
|
||||||
}
|
|
||||||
if resp.Features.Debrid {
|
|
||||||
features = append(features, "Debrid")
|
|
||||||
}
|
|
||||||
if resp.Features.Usenet {
|
|
||||||
features = append(features, "Usenet")
|
|
||||||
}
|
|
||||||
if len(features) > 0 {
|
|
||||||
cyan.Printf(" Available: %s\n", strings.Join(features, ", "))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if !installDaemon {
|
if !installDaemon {
|
||||||
|
|
|
||||||
187
internal/cmd/login.go
Normal file
187
internal/cmd/login.go
Normal file
|
|
@ -0,0 +1,187 @@
|
||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/huh"
|
||||||
|
"github.com/fatih/color"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/torrentclaw/unarr/internal/agent"
|
||||||
|
"github.com/torrentclaw/unarr/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newLoginCmd() *cobra.Command {
|
||||||
|
var apiURL string
|
||||||
|
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "login",
|
||||||
|
Aliases: []string{"auth"},
|
||||||
|
Short: "Authenticate with your torrentclaw account",
|
||||||
|
Long: `Log in to your torrentclaw account by opening the browser or pasting
|
||||||
|
your API key manually. Use this when your API key has expired, been
|
||||||
|
revoked, or you want to switch to a different account.
|
||||||
|
|
||||||
|
Unlike 'unarr init', this command only updates your authentication
|
||||||
|
credentials — it does not modify your download directory, daemon
|
||||||
|
settings, or other configuration.`,
|
||||||
|
Example: ` unarr login
|
||||||
|
unarr login --api-url https://custom.server.com`,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
return runLogin(apiURL)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Flags().StringVar(&apiURL, "api-url", "", "API URL override (default: https://torrentclaw.com)")
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func runLogin(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)
|
||||||
|
dim := color.New(color.FgHiBlack)
|
||||||
|
|
||||||
|
fmt.Println()
|
||||||
|
bold.Println(" unarr login")
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
cfg := loadConfig()
|
||||||
|
|
||||||
|
// Determine API URL
|
||||||
|
apiURL := cfg.Auth.APIURL
|
||||||
|
if apiURLOverride != "" {
|
||||||
|
apiURL = apiURLOverride
|
||||||
|
}
|
||||||
|
if apiURL == "" {
|
||||||
|
apiURL = "https://torrentclaw.com"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Authenticate ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
var apiKey string
|
||||||
|
|
||||||
|
// Try browser-based auth first
|
||||||
|
fmt.Println(" Opening browser to connect your account...")
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
browserKey, browserErr := browserAuth(apiURL)
|
||||||
|
if browserErr == nil && strings.HasPrefix(browserKey, "tc_") {
|
||||||
|
apiKey = browserKey
|
||||||
|
green.Println(" ✓ Connected via browser")
|
||||||
|
fmt.Println()
|
||||||
|
} else {
|
||||||
|
// Fallback to manual API key entry
|
||||||
|
if browserErr != nil {
|
||||||
|
dim.Printf(" Could not connect automatically: %s\n", browserErr)
|
||||||
|
}
|
||||||
|
fmt.Println(" Paste your API key instead:")
|
||||||
|
dim.Printf(" (get it from %s/profile?tab=apikey)\n", apiURL)
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
err := huh.NewForm(
|
||||||
|
huh.NewGroup(
|
||||||
|
huh.NewInput().
|
||||||
|
Title("API Key").
|
||||||
|
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 {
|
||||||
|
if errors.Is(err, huh.ErrUserAborted) {
|
||||||
|
fmt.Println("\n Login cancelled.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
apiKey = strings.TrimSpace(apiKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Validate API key ────────────────────────────────────────────
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
// ── Save config (auth fields only) ──────────────────────────────
|
||||||
|
|
||||||
|
cfg.Auth.APIKey = apiKey
|
||||||
|
cfg.Auth.APIURL = apiURL
|
||||||
|
cfg.Agent.ID = agentID
|
||||||
|
cfg.Agent.Name = agentName
|
||||||
|
|
||||||
|
configPath := config.FilePath()
|
||||||
|
if cfgFile != "" {
|
||||||
|
configPath = cfgFile
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := config.Save(cfg, configPath); err != nil {
|
||||||
|
return fmt.Errorf("save config: %w", err)
|
||||||
|
}
|
||||||
|
appCfg = cfg
|
||||||
|
|
||||||
|
fmt.Println()
|
||||||
|
green.Println(" ✓ Credentials saved!")
|
||||||
|
fmt.Printf(" Config: %s\n", configPath)
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
// Features summary
|
||||||
|
if line := formatFeatures(resp.Features); line != "" {
|
||||||
|
color.New(color.FgCyan).Printf(" Available: %s\n", line)
|
||||||
|
fmt.Println()
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Download.Dir == "" {
|
||||||
|
fmt.Println(" Run " + bold.Sprint("unarr init") + " to complete the setup (download directory, daemon).")
|
||||||
|
fmt.Println()
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
@ -64,6 +64,8 @@ Source: https://github.com/torrentclaw/unarr`,
|
||||||
// Getting Started
|
// Getting Started
|
||||||
initCmd := newInitCmd()
|
initCmd := newInitCmd()
|
||||||
initCmd.GroupID = "start"
|
initCmd.GroupID = "start"
|
||||||
|
loginCmd := newLoginCmd()
|
||||||
|
loginCmd.GroupID = "start"
|
||||||
configCmd := newConfigCmd()
|
configCmd := newConfigCmd()
|
||||||
configCmd.GroupID = "start"
|
configCmd.GroupID = "start"
|
||||||
migrateCmd := newMigrateCmd()
|
migrateCmd := newMigrateCmd()
|
||||||
|
|
@ -118,6 +120,7 @@ Source: https://github.com/torrentclaw/unarr`,
|
||||||
rootCmd.AddCommand(
|
rootCmd.AddCommand(
|
||||||
// Getting Started
|
// Getting Started
|
||||||
initCmd,
|
initCmd,
|
||||||
|
loginCmd,
|
||||||
configCmd,
|
configCmd,
|
||||||
migrateCmd,
|
migrateCmd,
|
||||||
// Search & Discovery
|
// Search & Discovery
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,43 @@ func runStatus() error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Account (async fetch) ──
|
||||||
|
type accountResult struct {
|
||||||
|
user agent.UserInfo
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
accountCh := make(chan accountResult, 1)
|
||||||
|
go func() {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
ac := agent.NewClient(cfg.Auth.APIURL, cfg.Auth.APIKey, "unarr/"+Version)
|
||||||
|
resp, err := ac.Register(ctx, agent.RegisterRequest{
|
||||||
|
AgentID: cfg.Agent.ID,
|
||||||
|
Name: cfg.Agent.Name,
|
||||||
|
Version: Version,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
accountCh <- accountResult{err: err}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
accountCh <- accountResult{user: resp.User}
|
||||||
|
}()
|
||||||
|
|
||||||
|
cyan.Println(" Account")
|
||||||
|
ar := <-accountCh
|
||||||
|
if ar.err != nil {
|
||||||
|
dim.Println(" Could not fetch account info")
|
||||||
|
} else {
|
||||||
|
fmt.Printf(" User: %s\n", ar.user.Name)
|
||||||
|
fmt.Printf(" Email: %s\n", ar.user.Email)
|
||||||
|
planColor := dim
|
||||||
|
if ar.user.IsPro {
|
||||||
|
planColor = green
|
||||||
|
}
|
||||||
|
planColor.Printf(" Plan: %s\n", strings.ToUpper(ar.user.Plan))
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
cyan.Println(" Configuration")
|
cyan.Println(" Configuration")
|
||||||
agentID := cfg.Agent.ID
|
agentID := cfg.Agent.ID
|
||||||
if len(agentID) > 8 {
|
if len(agentID) > 8 {
|
||||||
|
|
@ -81,6 +118,9 @@ func runStatus() error {
|
||||||
usedPct := float64(total-free) / float64(total) * 100
|
usedPct := float64(total-free) / float64(total) * 100
|
||||||
cyan.Println(" Disk")
|
cyan.Println(" Disk")
|
||||||
fmt.Printf(" Free: %s / %s (%.0f%% used)\n", formatBytes(free), formatBytes(total), usedPct)
|
fmt.Printf(" Free: %s / %s (%.0f%% used)\n", formatBytes(free), formatBytes(total), usedPct)
|
||||||
|
if dirSize, err := agent.DirSize(cfg.Download.Dir); err == nil {
|
||||||
|
fmt.Printf(" Downloads: %s\n", formatBytes(dirSize))
|
||||||
|
}
|
||||||
if usedPct > 90 {
|
if usedPct > 90 {
|
||||||
yellow.Println(" ⚠ Low disk space!")
|
yellow.Println(" ⚠ Low disk space!")
|
||||||
}
|
}
|
||||||
|
|
@ -163,6 +203,21 @@ func isDaemonAlive(state *agent.DaemonState) bool {
|
||||||
return agent.IsProcessAlive(state.PID)
|
return agent.IsProcessAlive(state.PID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// formatFeatures returns a comma-separated list of available features, or "".
|
||||||
|
func formatFeatures(f agent.FeatureFlags) string {
|
||||||
|
var features []string
|
||||||
|
if f.Torrent {
|
||||||
|
features = append(features, "Torrent")
|
||||||
|
}
|
||||||
|
if f.Debrid {
|
||||||
|
features = append(features, "Debrid")
|
||||||
|
}
|
||||||
|
if f.Usenet {
|
||||||
|
features = append(features, "Usenet")
|
||||||
|
}
|
||||||
|
return strings.Join(features, ", ")
|
||||||
|
}
|
||||||
|
|
||||||
// formatBytes formats bytes into human-readable string.
|
// formatBytes formats bytes into human-readable string.
|
||||||
func formatBytes(b int64) string {
|
func formatBytes(b int64) string {
|
||||||
const unit = 1024
|
const unit = 1024
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,24 @@ import (
|
||||||
"github.com/torrentclaw/unarr/internal/ui"
|
"github.com/torrentclaw/unarr/internal/ui"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// startIdleGuard monitors a stream server and cancels the task after 30 minutes of inactivity.
|
||||||
|
func startIdleGuard(ctx context.Context, srv *engine.StreamServer, taskID string) {
|
||||||
|
ticker := time.NewTicker(60 * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
if srv.IdleSince() > 30*time.Minute {
|
||||||
|
log.Printf("[%s] stream idle timeout (30m no HTTP requests), shutting down", taskID[:8])
|
||||||
|
cancelStreamTask(taskID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// streamRegistry tracks active stream tasks and servers for cancellation.
|
// streamRegistry tracks active stream tasks and servers for cancellation.
|
||||||
var streamRegistry = struct {
|
var streamRegistry = struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
|
|
@ -127,12 +145,11 @@ func handleStreamTask(parentCtx context.Context, at agent.Task, reporter *engine
|
||||||
go watchReporter.Run(ctx)
|
go watchReporter.Run(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6. Unified progress + idle timeout loop
|
// 6. Start idle guard + progress loop
|
||||||
|
go startIdleGuard(ctx, srv, at.ID)
|
||||||
eng.StartProgressLoop(ctx)
|
eng.StartProgressLoop(ctx)
|
||||||
progressTicker := time.NewTicker(3 * time.Second)
|
progressTicker := time.NewTicker(3 * time.Second)
|
||||||
defer progressTicker.Stop()
|
defer progressTicker.Stop()
|
||||||
idleCheck := time.NewTicker(60 * time.Second)
|
|
||||||
defer idleCheck.Stop()
|
|
||||||
completed := false
|
completed := false
|
||||||
|
|
||||||
for {
|
for {
|
||||||
|
|
@ -141,12 +158,6 @@ func handleStreamTask(parentCtx context.Context, at agent.Task, reporter *engine
|
||||||
log.Printf("[%s] stream stopped", at.ID[:8])
|
log.Printf("[%s] stream stopped", at.ID[:8])
|
||||||
return
|
return
|
||||||
|
|
||||||
case <-idleCheck.C:
|
|
||||||
if srv.IdleSince() > 30*time.Minute {
|
|
||||||
log.Printf("[%s] stream idle timeout (30m no HTTP requests), shutting down", at.ID[:8])
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
case <-progressTicker.C:
|
case <-progressTicker.C:
|
||||||
p := eng.Progress()
|
p := eng.Progress()
|
||||||
task.UpdateProgress(engine.Progress{
|
task.UpdateProgress(engine.Progress{
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
// Version is the CLI version. Overridden by goreleaser ldflags at release time.
|
// Version is the CLI version. Overridden by goreleaser ldflags at release time.
|
||||||
var Version = "0.3.4-dev"
|
var Version = "0.4.0"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue