fix(cli): keep auth token in global config

This commit is contained in:
saltbo
2026-06-06 02:01:02 -04:00
parent a5c5cee590
commit ce170cbd41
3 changed files with 219 additions and 23 deletions
+144 -1
View File
@@ -2,8 +2,10 @@ package config
import (
"errors"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"time"
@@ -40,6 +42,7 @@ const (
func Defaults(v *viper.Viper) {
home, _ := os.UserHomeDir()
v.SetDefault("server_url", "http://localhost:5173")
v.SetDefault("token", "")
v.SetDefault("downloader.engine", "auto")
v.SetDefault("downloader.download_dir", filepath.Join(home, "Downloads", "zpan"))
v.SetDefault("downloader.state_dir", defaultStateDir(home))
@@ -85,7 +88,7 @@ func Load(v *viper.Viper) (Config, error) {
cfg := Config{
ServerURL: strings.TrimRight(v.GetString("server_url"), "/"),
Token: v.GetString("downloader.token"),
Token: v.GetString("token"),
Engine: v.GetString("downloader.engine"),
DownloadDir: v.GetString("downloader.download_dir"),
StateDir: v.GetString("downloader.state_dir"),
@@ -132,6 +135,146 @@ func explicitValue(v *viper.Viper, key string) bool {
return v.IsSet(key)
}
func WriteDefaultConfig(path string) error {
home, _ := os.UserHomeDir()
cfg := Config{
ServerURL: "http://localhost:5173",
Engine: "auto",
DownloadDir: filepath.Join(home, "Downloads", "zpan"),
StateDir: defaultStateDir(home),
PollInterval: 5 * time.Second,
MaxConcurrentTasks: 2,
SeedEnabled: true,
SeedDuration: time.Hour,
SeedCacheLimit: 10_000_000_000,
SeedRatio: 0,
}
return createConfigFile(path, defaultConfigYAML(cfg))
}
func WriteConfig(path string, cfg Config, token string) error {
cfg.Token = token
return writeConfigFile(path, configYAML(cfg, false))
}
func createConfigFile(path string, content string) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if err != nil {
return err
}
defer file.Close()
_, err = file.WriteString(content)
return err
}
func writeConfigFile(path string, content string) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
return os.WriteFile(path, []byte(content), 0o600)
}
func defaultConfigYAML(cfg Config) string {
return "# ZPan CLI configuration\n" +
"# token is written automatically after device login.\n" +
"# token: \"\"\n\n" +
configYAML(cfg, true)
}
func configYAML(cfg Config, includeRuntimeHints bool) string {
var b strings.Builder
fmt.Fprintf(&b, "server_url: %s\n", yamlString(cfg.ServerURL))
if cfg.Token != "" {
fmt.Fprintf(&b, "token: %s\n", yamlString(cfg.Token))
}
b.WriteString("downloader:\n")
fmt.Fprintf(&b, " engine: %s\n", yamlString(nonEmpty(cfg.Engine, "auto")))
fmt.Fprintf(&b, " download_dir: %s\n", yamlString(cfg.DownloadDir))
fmt.Fprintf(&b, " state_dir: %s\n", yamlString(cfg.StateDir))
fmt.Fprintf(&b, " poll_interval: %s\n", yamlString(formatDuration(cfg.PollInterval, "5s")))
fmt.Fprintf(&b, " max_concurrent_tasks: %d\n", cfg.MaxConcurrentTasks)
b.WriteString(" seed:\n")
fmt.Fprintf(&b, " enabled: %t\n", cfg.SeedEnabled)
fmt.Fprintf(&b, " duration: %s\n", yamlString(formatDuration(cfg.SeedDuration, "1h")))
fmt.Fprintf(&b, " cache_limit: %s\n", yamlString(formatSeedCacheLimit(cfg.SeedCacheLimit)))
fmt.Fprintf(&b, " ratio: %s\n", strconv.FormatFloat(cfg.SeedRatio, 'f', -1, 64))
if shouldWriteAria2Config(cfg) {
b.WriteString(" aria2:\n")
fmt.Fprintf(&b, " url: %s\n", yamlString(nonEmpty(cfg.Aria2URL, DefaultAria2URL)))
if cfg.Aria2Secret != "" {
fmt.Fprintf(&b, " secret: %s\n", yamlString(cfg.Aria2Secret))
}
}
if shouldWriteQBittorrentConfig(cfg) {
b.WriteString(" qbittorrent:\n")
fmt.Fprintf(&b, " url: %s\n", yamlString(nonEmpty(cfg.QBittorrentURL, DefaultQBittorrentURL)))
if cfg.QBittorrentUser != "" {
fmt.Fprintf(&b, " username: %s\n", yamlString(cfg.QBittorrentUser))
}
if cfg.QBittorrentPass != "" {
fmt.Fprintf(&b, " password: %s\n", yamlString(cfg.QBittorrentPass))
}
}
if includeRuntimeHints {
b.WriteString("\n")
b.WriteString(" # To connect an external aria2 runtime, set engine to \"aria2\"\n")
b.WriteString(" # and uncomment this block.\n")
fmt.Fprintf(&b, " # aria2:\n")
fmt.Fprintf(&b, " # url: %s\n", yamlString(DefaultAria2URL))
fmt.Fprintf(&b, " # secret: %s\n", yamlString("optional-rpc-secret"))
b.WriteString("\n")
b.WriteString(" # To connect an external qBittorrent runtime, set engine to \"qbittorrent\"\n")
b.WriteString(" # and uncomment this block.\n")
fmt.Fprintf(&b, " # qbittorrent:\n")
fmt.Fprintf(&b, " # url: %s\n", yamlString(DefaultQBittorrentURL))
fmt.Fprintf(&b, " # username: %s\n", yamlString("admin"))
fmt.Fprintf(&b, " # password: %s\n", yamlString("password"))
}
return b.String()
}
func shouldWriteAria2Config(cfg Config) bool {
return strings.EqualFold(cfg.Engine, "aria2") || cfg.Aria2Configured
}
func shouldWriteQBittorrentConfig(cfg Config) bool {
return strings.EqualFold(cfg.Engine, "qbittorrent") || cfg.QBittorrentConfigured
}
func yamlString(value string) string {
return strconv.Quote(value)
}
func nonEmpty(value string, fallback string) string {
if value == "" {
return fallback
}
return value
}
func formatSeedCacheLimit(value int64) string {
if value == 10_000_000_000 {
return "10GB"
}
return strconv.FormatInt(value, 10)
}
func formatDuration(value time.Duration, fallback string) string {
if value == 0 {
return fallback
}
if value == time.Hour {
return "1h"
}
if value%time.Second == 0 {
return value.String()
}
return value.String()
}
func parseBytes(value string) (int64, error) {
value = strings.TrimSpace(value)
if value == "" {
+70 -2
View File
@@ -1,6 +1,9 @@
package config
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
@@ -10,7 +13,7 @@ import (
func TestLoadParsesSeedPolicy(t *testing.T) {
v := viper.New()
v.Set("server_url", "http://localhost:5173")
v.Set("downloader.token", "token")
v.Set("token", "token")
v.Set("downloader.seed.enabled", true)
v.Set("downloader.seed.duration", "30m")
v.Set("downloader.seed.cache_limit", "10GB")
@@ -32,6 +35,9 @@ func TestLoadParsesSeedPolicy(t *testing.T) {
if cfg.SeedRatio != 1.5 {
t.Fatalf("expected seed ratio 1.5, got %f", cfg.SeedRatio)
}
if cfg.Token != "token" {
t.Fatalf("expected global token to be loaded, got %q", cfg.Token)
}
}
func TestLoadUsesSafeSeedDefaults(t *testing.T) {
@@ -101,7 +107,7 @@ func TestLoadRejectsInvalidSeedPolicy(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
v := viper.New()
v.Set("server_url", "http://localhost:5173")
v.Set("downloader.token", "token")
v.Set("token", "token")
v.Set(tt.key, tt.value)
if _, err := Load(v); err == nil {
@@ -110,3 +116,65 @@ func TestLoadRejectsInvalidSeedPolicy(t *testing.T) {
})
}
}
func TestWriteDefaultConfigWritesCommentedRuntimeHints(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.yaml")
if err := WriteDefaultConfig(path); err != nil {
t.Fatal(err)
}
content, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
text := string(content)
if strings.Contains(text, "token:") && !strings.Contains(text, "# token:") {
t.Fatalf("expected token to be commented in default config, got:\n%s", text)
}
if hasConfigLine(text, " aria2:") {
t.Fatalf("default config should not enable aria2 runtime block, got:\n%s", text)
}
if hasConfigLine(text, " qbittorrent:") {
t.Fatalf("default config should not enable qbittorrent runtime block, got:\n%s", text)
}
if !strings.Contains(text, " # url: \"ws://127.0.0.1:6800/jsonrpc\"") {
t.Fatalf("expected commented aria2 runtime hint, got:\n%s", text)
}
}
func TestWriteConfigStoresGlobalTokenAndOmitsDefaultRuntimeBlocks(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.yaml")
cfg := Config{
ServerURL: "https://zpan.space",
Engine: "auto",
DownloadDir: "/downloads",
StateDir: "/state",
PollInterval: 5 * time.Second,
MaxConcurrentTasks: 2,
SeedEnabled: true,
SeedDuration: time.Hour,
SeedCacheLimit: 10_000_000_000,
}
if err := WriteConfig(path, cfg, "download-token"); err != nil {
t.Fatal(err)
}
content, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
text := string(content)
if !strings.Contains(text, "token: \"download-token\"") {
t.Fatalf("expected global token, got:\n%s", text)
}
if strings.Contains(text, "downloader:\n token:") || hasConfigLine(text, " aria2:") || hasConfigLine(text, " qbittorrent:") {
t.Fatalf("expected no downloader token or default runtime blocks, got:\n%s", text)
}
}
func hasConfigLine(text string, line string) bool {
for _, candidate := range strings.Split(text, "\n") {
if candidate == line {
return true
}
}
return false
}
+5 -20
View File
@@ -6,7 +6,6 @@ import (
"log/slog"
"os"
"os/signal"
"path/filepath"
"runtime"
"strings"
"syscall"
@@ -101,7 +100,7 @@ func upCommand(v *viper.Viper, cfgFile *string) *cobra.Command {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
if cfg.Token == "" {
registered, err := registerDownloaderWithDeviceLogin(ctx, cmd, v, cfg, *cfgFile)
registered, err := registerDownloaderWithDeviceLogin(ctx, cmd, cfg, *cfgFile)
if err != nil {
return err
}
@@ -125,12 +124,7 @@ func configCommand(v *viper.Viper, cfgFile *string) *cobra.Command {
Use: "init",
Short: "Create a default config file",
RunE: func(cmd *cobra.Command, args []string) error {
config.Defaults(v)
v.SetConfigFile(*cfgFile)
if err := os.MkdirAll(filepath.Dir(*cfgFile), 0o755); err != nil {
return err
}
return v.SafeWriteConfigAs(*cfgFile)
return config.WriteDefaultConfig(*cfgFile)
},
})
return cmd
@@ -139,7 +133,6 @@ func configCommand(v *viper.Viper, cfgFile *string) *cobra.Command {
func registerDownloaderWithDeviceLogin(
ctx context.Context,
cmd *cobra.Command,
v *viper.Viper,
cfg config.Config,
cfgFile string,
) (client.CreateDownloaderResponse, error) {
@@ -170,7 +163,7 @@ func registerDownloaderWithDeviceLogin(
return client.CreateDownloaderResponse{}, err
}
slog.Info("downloader registered", "downloader_id", registered.Downloader.ID)
if err := saveRegisteredDownloaderConfig(v, cfg, cfgFile, registered.Token); err != nil {
if err := saveRegisteredDownloaderConfig(cfg, cfgFile, registered.Token); err != nil {
return client.CreateDownloaderResponse{}, err
}
fmt.Fprintf(cmd.OutOrStdout(), "Downloader registered: %s\n", registered.Downloader.ID)
@@ -178,16 +171,8 @@ func registerDownloaderWithDeviceLogin(
return registered, nil
}
func saveRegisteredDownloaderConfig(v *viper.Viper, cfg config.Config, cfgFile string, token string) error {
v.Set("server_url", cfg.ServerURL)
v.Set("downloader.token", token)
if err := os.MkdirAll(filepath.Dir(cfgFile), 0o755); err != nil {
return err
}
if _, err := os.Stat(cfgFile); err == nil {
return v.WriteConfigAs(cfgFile)
}
return v.SafeWriteConfigAs(cfgFile)
func saveRegisteredDownloaderConfig(cfg config.Config, cfgFile string, token string) error {
return config.WriteConfig(cfgFile, cfg, token)
}
func downloaderName() string {