diff --git a/README.md b/README.md index b018e14..eadbc08 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,12 @@ You need `curl`, `tar`, and `sha256sum`. For manual downloads, upgrades, non-root installation, and removal, see the [installation guide](docs/user/INSTALLATION.md). Configuration is stored at `~/.config/calmstoolkit/config.json` with mode `0600`; override it with `--config` or `CALMSTOOLKIT_CONFIG`. +`calmstoolkit config setup` is both a first-run wizard and a configuration +editor. Choose a numbered section to update one feature, `A` to walk through +every section, or `S` to validate and save. Existing values are retained when +you press Enter, optional values can be cleared with `-`, and credentials are +never displayed. Sonarr and Radarr screens support adding, editing, and removing +multiple instances. For Sonarr and Radarr instances, `url` is the address CalmsToolkit uses for API requests. Set the optional `external_url` when browser links must use a different address, such as when the API URL contains a container-only hostname. @@ -50,12 +56,30 @@ calmstoolkit airtime Library airtime lookup calmstoolkit feed Sonarr/Radarr activity calmstoolkit anime AniList search with TVDB mapping calmstoolkit config setup|validate Configuration management +calmstoolkit completion Generate shell completions calmstoolkit doctor Local and service diagnostics calmstoolkit version Build information ``` Run `calmstoolkit --help` for feature flags. Global flags are `--config`, `--output`, `--theme`, `--no-color`, `--timeout`, `--debug`, `--quiet`, and `--strict`. +## Shell completions + +Completion scripts include commands, subcommands, local and persistent flags, +and known values for flags such as `--output`, `--theme`, `--server`, and +`--type`. Generate a script for bash, zsh, or fish: + +```bash +# Bash (user installation; create the directory if needed) +calmstoolkit completion bash > ~/.local/share/bash-completion/completions/calmstoolkit + +# Zsh (the target directory must be in fpath) +calmstoolkit completion zsh > "${fpath[1]}/_calmstoolkit" + +# Fish +calmstoolkit completion fish > ~/.config/fish/completions/calmstoolkit.fish +``` + Output is `auto`, `terminal`, `plain`, `json`, or `ndjson`. Auto uses the rich terminal view only on a capable UTF-8 TTY and otherwise selects plain output. Watch commands require NDJSON rather than JSON for machine output. Diagnostics always go to stderr. ```bash diff --git a/docs/user/CLI_SPEC.md b/docs/user/CLI_SPEC.md index 543d9a7..0de5648 100644 --- a/docs/user/CLI_SPEC.md +++ b/docs/user/CLI_SPEC.md @@ -2,6 +2,47 @@ Configuration precedence is `defaults < configuration file < environment < explicitly supplied flags`. An omitted boolean or interval flag never overwrites its configured value. The config path is explicit `--config`, then `CALMSTOOLKIT_CONFIG`, then `~/.config/calmstoolkit/config.json`. +## Configuration setup + +`calmstoolkit config setup` is the canonical onboarding and editing interface. +Its menu supports editing one section, running every section in sequence, +validating and saving, or quitting without saving. Pressing Enter retains the +displayed value; `-` clears an optional value. Input is validated before the +flow advances. Secrets are displayed as `configured`, and environment-only +credentials are not copied into the configuration file. `--force` starts from +defaults, while an existing file is otherwise edited in place. `--defaults` +writes defaults non-interactively (`--force` is required to replace a file). + +The setup flow exposes every persisted user setting: + +| Section | Settings | +|---|---| +| General | HTTP timeout, color disablement, theme | +| Sonarr instances | name, API URL, optional external/browser URL, API key; add/edit/remove multiple instances | +| Radarr instances | name, API URL, optional external/browser URL, API key; add/edit/remove multiple instances | +| Media streams | enabled server type, Plex URL/token, Jellyfin URL/token, watch interval, history duration | +| Media requests | Overseerr/Jellyseerr URL, API key, verbose diagnostics | +| Media calendar | future days, past days, watch interval, debug diagnostics | +| Media airtime | result limit, past-day window, future-day window, debug diagnostics | +| Arr feed | poll interval, history window, grabbed/imported/failed/deleted/ignored/subtitle visibility, maximum events | +| AniSearch | mapping download URL, optional cache path, result limit | + +`version` is managed by the application and is not prompted. Credentials can +instead be overlaid with `CALMSTOOLKIT_PLEX_TOKEN`, +`CALMSTOOLKIT_JELLYFIN_TOKEN`, `CALMSTOOLKIT_REQUESTS_API_KEY`, and per-instance +`CALMSTOOLKIT_SONARR__API_KEY` or +`CALMSTOOLKIT_RADARR__API_KEY`. Legacy `PLEX_TOKEN`, +`JELLYFIN_TOKEN`, and `OVERSEERR_API_KEY` remain fallback names. + +## Completion + +`calmstoolkit completion ` writes a Cobra-generated completion script to +stdout. Supported shells are `bash`, `zsh`, and `fish`. The scripts +discover the complete command tree, local and persistent flags, and registered +values for finite options including output mode, theme, stream server, and +airtime search type. See the [installation guide](INSTALLATION.md#shell-completions) +for installation commands. + Sonarr and Radarr instances distinguish the API address from the optional browser-facing address: diff --git a/docs/user/INSTALLATION.md b/docs/user/INSTALLATION.md index ff1d65e..82d35ae 100644 --- a/docs/user/INSTALLATION.md +++ b/docs/user/INSTALLATION.md @@ -65,7 +65,47 @@ calmstoolkit doctor The configuration is written to `~/.config/calmstoolkit/config.json` with permissions `0600`. Use `--config` or `CALMSTOOLKIT_CONFIG` to select a different -file. +file. Setup opens a section menu: select one feature to edit, choose `A` for the +complete guided onboarding flow, `S` to validate and save, or `Q` to leave the +file unchanged. Enter accepts the displayed value and `-` clears an optional +value. Existing credentials are shown only as `configured`. + +## Shell completions + +The generated scripts cover commands, subcommands, flags, persistent flags, and +known option values. Create the destination directory first if it does not +already exist. + +### Bash + +For a user-local installation of `bash-completion`: + +```bash +mkdir -p ~/.local/share/bash-completion/completions +calmstoolkit completion bash > ~/.local/share/bash-completion/completions/calmstoolkit +``` + +Start a new shell after installation. Distribution-wide completion directories +vary; `/usr/share/bash-completion/completions/` is common. + +### Zsh + +Write the script to a directory already present in `fpath`, then start a new +shell (or run `compinit`): + +```zsh +calmstoolkit completion zsh > "${fpath[1]}/_calmstoolkit" +autoload -U compinit && compinit +``` + +### Fish + +```bash +mkdir -p ~/.config/fish/completions +calmstoolkit completion fish > ~/.config/fish/completions/calmstoolkit.fish +``` + +Fish discovers the file automatically in new and current sessions. ## Upgrade diff --git a/internal/cli/completion.go b/internal/cli/completion.go new file mode 100644 index 0000000..88eb5fd --- /dev/null +++ b/internal/cli/completion.go @@ -0,0 +1,44 @@ +package cli + +import ( + "fmt" + + "github.com/calmcacil/CalmsToolkit/internal/app" + "github.com/spf13/cobra" +) + +var completionShells = []string{"bash", "zsh", "fish"} + +func fixedCompletions(values ...string) cobra.CompletionFunc { + return func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) { + return values, cobra.ShellCompDirectiveNoFileComp + } +} + +func newCompletionCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "completion ", + Short: "Generate a shell completion script", + Long: "Generate a completion script for bash, zsh, or fish and write it to stdout.", + Args: cobra.ExactArgs(1), + ValidArgs: completionShells, + DisableFlagsInUseLine: true, + RunE: func(cmd *cobra.Command, args []string) error { + root := cmd.Root() + switch args[0] { + case "bash": + return root.GenBashCompletionV2(cmd.OutOrStdout(), true) + case "zsh": + return root.GenZshCompletion(cmd.OutOrStdout()) + case "fish": + return root.GenFishCompletion(cmd.OutOrStdout(), true) + default: + return app.Error(app.ExitUsage, fmt.Errorf("unsupported shell %q (choose bash, zsh, or fish)", args[0])) + } + }, + } + cmd.ValidArgsFunction = func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) { + return completionShells, cobra.ShellCompDirectiveNoFileComp + } + return cmd +} diff --git a/internal/cli/root.go b/internal/cli/root.go index dc183e9..b73451c 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -2,7 +2,6 @@ package cli import ( - "bufio" "context" "errors" "fmt" @@ -10,7 +9,6 @@ import ( "log/slog" "net/http" "os" - "strconv" "strings" "time" @@ -38,11 +36,16 @@ type globalOptions struct { func NewRootCommand(rt *app.Runtime) *cobra.Command { var global globalOptions root := &cobra.Command{ - Use: "calmstoolkit", - Short: "A unified SSH-friendly media toolkit", - SilenceErrors: true, - SilenceUsage: true, - PersistentPreRunE: func(cmd *cobra.Command, _ []string) error { return configureRuntime(cmd, rt, global) }, + Use: "calmstoolkit", + Short: "A unified SSH-friendly media toolkit", + SilenceErrors: true, + SilenceUsage: true, + PersistentPreRunE: func(cmd *cobra.Command, _ []string) error { + if cmd.Name() == "completion" || strings.HasPrefix(cmd.Name(), "__complete") { + return nil + } + return configureRuntime(cmd, rt, global) + }, } root.SetIn(rt.Stdin) root.SetOut(rt.Stdout) @@ -56,7 +59,9 @@ func NewRootCommand(rt *app.Runtime) *cobra.Command { f.BoolVar(&global.debug, "debug", false, "enable redacted diagnostics") f.BoolVar(&global.quiet, "quiet", false, "suppress informational diagnostics") f.BoolVar(&global.strict, "strict", false, "fail with status 3 on partial results") - root.AddCommand(newStreamsCommand(rt), newCalendarCommand(rt), newRequestsCommand(rt), newAirtimeCommand(rt), newFeedCommand(rt), newAnimeCommand(rt), newConfigCommand(rt), newDoctorCommand(rt), newVersionCommand(rt)) + _ = root.RegisterFlagCompletionFunc("output", fixedCompletions("auto", "terminal", "plain", "json", "ndjson")) + _ = root.RegisterFlagCompletionFunc("theme", fixedCompletions("default", "catppuccin-mocha", "catppuccin-latte")) + root.AddCommand(newStreamsCommand(rt), newCalendarCommand(rt), newRequestsCommand(rt), newAirtimeCommand(rt), newFeedCommand(rt), newAnimeCommand(rt), newConfigCommand(rt), newCompletionCommand(), newDoctorCommand(rt), newVersionCommand(rt)) return root } @@ -204,6 +209,7 @@ func newStreamsCommand(rt *app.Runtime) *cobra.Command { f.BoolVar(&watch, "watch", false, "continuously monitor") f.IntVar(&interval, "interval", 0, "watch interval in seconds") f.DurationVar(&history, "history-duration", 0, "session history duration") + _ = cmd.RegisterFlagCompletionFunc("server", fixedCompletions("plex", "jellyfin", "both")) return cmd } @@ -322,6 +328,7 @@ func newAirtimeCommand(rt *app.Runtime) *cobra.Command { f.IntVar(&future, "future", 0, "future days") f.BoolVar(&noBanner, "no-banner", false, "suppress banner") f.BoolVar(&full, "full-season", false, "show full season") + _ = cmd.RegisterFlagCompletionFunc("type", fixedCompletions("auto", "series", "movie")) return cmd } @@ -419,17 +426,30 @@ func newConfigCommand(rt *app.Runtime) *cobra.Command { return nil }} var force, defaults bool - setup := &cobra.Command{Use: "setup", Args: cobra.NoArgs, Short: "Create a secure default configuration", RunE: func(*cobra.Command, []string) error { + setup := &cobra.Command{Use: "setup", Args: cobra.NoArgs, Short: "Interactively configure every CalmsToolkit feature", Long: "Guide the user through general settings, services, feature defaults, and paths, then securely save the complete configuration.", RunE: func(*cobra.Command, []string) error { + if rt.Output == console.OutputJSON || rt.Output == console.OutputNDJSON { + return app.Error(app.ExitUsage, errors.New("config setup is interactive and does not support JSON or NDJSON output")) + } _, statErr := os.Stat(rt.ConfigPath) if statErr == nil && defaults && !force { return app.Error(app.ExitUsage, fmt.Errorf("configuration already exists at %s (use --force to replace)", rt.ConfigPath)) } - cfg := rt.Config + var cfg *config.ToolkitConfig if force || statErr != nil { cfg = config.DefaultToolkitConfig() + } else { + var err error + cfg, err = config.LoadPersistedToolkitConfigAt(rt.ConfigPath) + if err != nil { + return err + } } if !defaults { if err := promptSetup(rt, cfg); err != nil { + if errors.Is(err, errSetupCancelled) { + fmt.Fprintln(rt.Stderr, "Setup cancelled; no changes were saved.") + return nil + } return app.Error(app.ExitUsage, err) } } @@ -439,82 +459,15 @@ func newConfigCommand(rt *app.Runtime) *cobra.Command { if err := cfg.SaveAt(rt.ConfigPath); err != nil { return err } - fmt.Fprintf(rt.Stdout, "Created %s with mode 0600. Add service credentials, then run 'calmstoolkit config validate'.\n", rt.ConfigPath) + fmt.Fprintf(rt.Stdout, "Saved configuration to %s with mode 0600. Run 'calmstoolkit config validate' or 'calmstoolkit doctor' to verify it.\n", rt.ConfigPath) return nil }} setup.Flags().BoolVar(&force, "force", false, "replace existing configuration") - setup.Flags().BoolVar(&defaults, "defaults", false, "write defaults without prompting") + setup.Flags().BoolVar(&defaults, "defaults", false, "write all defaults without prompting") parent.AddCommand(setup, validate) return parent } -func promptSetup(rt *app.Runtime, cfg *config.ToolkitConfig) error { - scanner := bufio.NewScanner(rt.Stdin) - prompt := func(label, current string) (string, error) { - if current == "" { - fmt.Fprintf(rt.Stdout, "%s: ", label) - } else { - display := current - lower := strings.ToLower(label) - if strings.Contains(lower, "token") || strings.Contains(lower, "key") { - display = "configured" - } - fmt.Fprintf(rt.Stdout, "%s [%s]: ", label, display) - } - if !scanner.Scan() { - if err := scanner.Err(); err != nil { - return "", err - } - return "", fmt.Errorf("input ended while reading %s", label) - } - value := strings.TrimSpace(scanner.Text()) - if value == "" { - return current, nil - } - return value, nil - } - var err error - if cfg.General.Timeout, err = prompt("HTTP timeout", cfg.General.Timeout); err != nil { - return err - } - if cfg.General.Theme, err = prompt("Theme", cfg.General.Theme); err != nil { - return err - } - if cfg.MediaStreams.PlexURL, err = prompt("Plex URL", cfg.MediaStreams.PlexURL); err != nil { - return err - } - if cfg.MediaStreams.PlexToken, err = prompt("Plex token", cfg.MediaStreams.PlexToken); err != nil { - return err - } - if cfg.MediaStreams.JellyfinURL, err = prompt("Jellyfin URL", cfg.MediaStreams.JellyfinURL); err != nil { - return err - } - if cfg.MediaStreams.JellyfinToken, err = prompt("Jellyfin token", cfg.MediaStreams.JellyfinToken); err != nil { - return err - } - if cfg.MediaRequests.OverseerrURL, err = prompt("Overseerr/Jellyseerr URL", cfg.MediaRequests.OverseerrURL); err != nil { - return err - } - if cfg.MediaRequests.APIKey, err = prompt("Requests API key", cfg.MediaRequests.APIKey); err != nil { - return err - } - if cfg.AniSearch.MappingURL, err = prompt("AniSearch mapping URL", cfg.AniSearch.MappingURL); err != nil { - return err - } - if cfg.AniSearch.MappingPath, err = prompt("AniSearch mapping cache path", cfg.AniSearch.MappingPath); err != nil { - return err - } - limit, err := prompt("AniSearch result limit", strconv.Itoa(cfg.AniSearch.Limit)) - if err != nil { - return err - } - cfg.AniSearch.Limit, err = strconv.Atoi(limit) - if err != nil { - return fmt.Errorf("AniSearch result limit: %w", err) - } - return nil -} - type doctorCheck struct { Name string `json:"name"` OK bool `json:"ok"` diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go index 7514e88..44dd316 100644 --- a/internal/cli/root_test.go +++ b/internal/cli/root_test.go @@ -18,7 +18,7 @@ func TestRootCommandSurface(t *testing.T) { if code != 0 { t.Fatalf("code=%d stderr=%s", code, stderr.String()) } - for _, name := range []string{"streams", "calendar", "requests", "airtime", "feed", "anime", "config", "doctor", "version"} { + for _, name := range []string{"streams", "calendar", "requests", "airtime", "feed", "anime", "config", "completion", "doctor", "version"} { if !strings.Contains(out.String(), name) { t.Errorf("help missing %q", name) } @@ -121,3 +121,182 @@ func TestConfigSetupEOFDoesNotSave(t *testing.T) { t.Fatalf("configuration was saved after EOF: %v", err) } } + +func TestConfigSetupEditsSectionAndKeepsPromptsOffStdout(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.json") + input := strings.Join([]string{ + "1", "25s", "catppuccin-mocha", "yes", + "s", + }, "\n") + "\n" + var out, stderr bytes.Buffer + code := Execute(context.Background(), strings.NewReader(input), &out, &stderr, []string{"--config", path, "config", "setup"}) + if code != 0 { + t.Fatalf("code=%d stderr=%s", code, stderr.String()) + } + if strings.Contains(out.String(), "Choose a section") || !strings.Contains(out.String(), "Saved configuration") { + t.Fatalf("stdout contains prompts or lacks result: %q", out.String()) + } + if !strings.Contains(stderr.String(), "Configuration sections") { + t.Fatalf("stderr missing setup menu: %q", stderr.String()) + } + cfg, err := config.LoadToolkitConfigAt(path) + if err != nil { + t.Fatal(err) + } + if cfg.General.Timeout != "25s" || cfg.General.Theme != "catppuccin-mocha" || !cfg.General.NoColor { + t.Fatalf("general config not updated: %+v", cfg.General) + } +} + +func TestConfigSetupGuidedFlowCoversEverySection(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.json") + answers := []string{"a"} + answers = append(answers, "", "", "") // General. + answers = append(answers, "d", "d") // Sonarr and Radarr. + answers = append(answers, "", "", "", "", "", "", "") + answers = append(answers, "", "", "") // Requests. + answers = append(answers, "", "", "", "") // Calendar. + answers = append(answers, "", "", "", "") // Airtime. + answers = append(answers, "", "", "", "", "", "", "", "", "") + answers = append(answers, "", "", "") // AniSearch. + answers = append(answers, "s") + var out, stderr bytes.Buffer + code := Execute(context.Background(), strings.NewReader(strings.Join(answers, "\n")+"\n"), &out, &stderr, []string{"--config", path, "config", "setup"}) + if code != 0 { + t.Fatalf("code=%d stderr=%s", code, stderr.String()) + } + for _, section := range []string{"[General]", "[Sonarr Instances]", "[Radarr Instances]", "[Media Streams]", "[Media Requests]", "[Media Calendar]", "[Media Airtime]", "[Arr Feed]", "[AniSearch]"} { + if !strings.Contains(stderr.String(), section) { + t.Errorf("guided setup missing %s", section) + } + } +} + +func TestConfigSetupCanCancelWithoutSaving(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.json") + var out, stderr bytes.Buffer + code := Execute(context.Background(), strings.NewReader("q\n"), &out, &stderr, []string{"--config", path, "config", "setup"}) + if code != 0 { + t.Fatalf("code=%d stderr=%s", code, stderr.String()) + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("configuration was saved after cancellation: %v", err) + } +} + +func TestConfigSetupRejectsMachineOutput(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.json") + for _, mode := range []string{"json", "ndjson"} { + t.Run(mode, func(t *testing.T) { + var out, stderr bytes.Buffer + code := Execute(context.Background(), strings.NewReader(""), &out, &stderr, []string{"--config", path, "--output", mode, "config", "setup", "--defaults"}) + if code != app.ExitUsage || out.Len() != 0 { + t.Fatalf("code=%d stdout=%q stderr=%q", code, out.String(), stderr.String()) + } + }) + } +} + +func TestConfigSetupHonorsCancellation(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.json") + ctx, cancel := context.WithCancel(context.Background()) + cancel() + var out, stderr bytes.Buffer + code := Execute(ctx, strings.NewReader("1\n"), &out, &stderr, []string{"--config", path, "config", "setup"}) + if code != app.ExitInterrupted { + t.Fatalf("code=%d stderr=%q", code, stderr.String()) + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("configuration was saved after cancellation: %v", err) + } +} + +func TestConfigSetupRedactsExistingSecrets(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.json") + cfg := config.DefaultToolkitConfig() + cfg.MediaStreams.PlexToken = "super-secret" + if err := cfg.SaveAt(path); err != nil { + t.Fatal(err) + } + var out, stderr bytes.Buffer + input := "4\n\n\n\n\n\n\n\nq\n" + code := Execute(context.Background(), strings.NewReader(input), &out, &stderr, []string{"--config", path, "config", "setup"}) + if code != 0 { + t.Fatalf("code=%d stderr=%s", code, stderr.String()) + } + if strings.Contains(stderr.String(), "super-secret") || !strings.Contains(stderr.String(), "Plex token (optional when Plex is disabled) [configured]") { + t.Fatalf("secret was not redacted: %q", stderr.String()) + } +} + +func TestConfigSetupDoesNotPersistEnvironmentSecrets(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.json") + cfg := config.DefaultToolkitConfig() + cfg.MediaStreams.PlexToken = "file-secret" + if err := cfg.SaveAt(path); err != nil { + t.Fatal(err) + } + t.Setenv("CALMSTOOLKIT_PLEX_TOKEN", "environment-secret") + var out, stderr bytes.Buffer + code := Execute(context.Background(), strings.NewReader("s\n"), &out, &stderr, []string{"--config", path, "config", "setup"}) + if code != 0 { + t.Fatalf("code=%d stderr=%s", code, stderr.String()) + } + persisted, err := config.LoadPersistedToolkitConfigAt(path) + if err != nil { + t.Fatal(err) + } + if persisted.MediaStreams.PlexToken != "file-secret" { + t.Fatalf("setup persisted environment token: %q", persisted.MediaStreams.PlexToken) + } +} + +func TestCompletionScripts(t *testing.T) { + for _, shell := range []string{"bash", "zsh", "fish"} { + t.Run(shell, func(t *testing.T) { + var out, stderr bytes.Buffer + code := Execute(context.Background(), strings.NewReader(""), &out, &stderr, []string{"--config", filepath.Join(t.TempDir(), "missing.json"), "completion", shell}) + if code != 0 { + t.Fatalf("code=%d stderr=%s", code, stderr.String()) + } + if out.Len() < 100 || !strings.Contains(strings.ToLower(out.String()), "calmstoolkit") { + t.Fatalf("unexpected completion output: %q", out.String()) + } + }) + } +} + +func TestCompletionRejectsUnsupportedShell(t *testing.T) { + var out, stderr bytes.Buffer + code := Execute(context.Background(), strings.NewReader(""), &out, &stderr, []string{"completion", "tcsh"}) + if code != app.ExitUsage || !strings.Contains(stderr.String(), "unsupported shell") { + t.Fatalf("code=%d stderr=%q", code, stderr.String()) + } +} + +func TestCompletionSuggestsFlagValues(t *testing.T) { + var out, stderr bytes.Buffer + code := Execute(context.Background(), strings.NewReader(""), &out, &stderr, []string{"__complete", "streams", "--server", "j"}) + if code != 0 { + t.Fatalf("code=%d stderr=%s", code, stderr.String()) + } + if !strings.Contains(out.String(), "jellyfin") { + t.Fatalf("server value completion missing: %q", out.String()) + } +} + +func TestCompletionDoesNotRequireValidConfiguration(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.json") + if err := os.WriteFile(path, []byte("not json"), 0600); err != nil { + t.Fatal(err) + } + for _, args := range [][]string{ + {"--config", path, "completion", "bash"}, + {"--config", path, "__complete", "streams", "--server", "p"}, + } { + var out, stderr bytes.Buffer + if code := Execute(context.Background(), strings.NewReader(""), &out, &stderr, args); code != 0 { + t.Fatalf("args=%v code=%d stderr=%s", args, code, stderr.String()) + } + } +} diff --git a/internal/cli/setup.go b/internal/cli/setup.go new file mode 100644 index 0000000..503ea14 --- /dev/null +++ b/internal/cli/setup.go @@ -0,0 +1,460 @@ +package cli + +import ( + "bufio" + "errors" + "fmt" + "net/url" + "strconv" + "strings" + "time" + + "github.com/calmcacil/CalmsToolkit/internal/app" + "github.com/calmcacil/CalmsToolkit/internal/colors" + "github.com/calmcacil/CalmsToolkit/internal/config" +) + +type setupPrompter struct { + rt *app.Runtime + scanner *bufio.Scanner +} + +var errSetupCancelled = errors.New("configuration setup cancelled") + +func promptSetup(rt *app.Runtime, cfg *config.ToolkitConfig) error { + p := setupPrompter{rt: rt, scanner: bufio.NewScanner(rt.Stdin)} + fmt.Fprintln(rt.Stderr, "CalmsToolkit configuration setup") + fmt.Fprintln(rt.Stderr, "Press Enter to keep the value in brackets. Enter - to clear an optional value.") + + for { + p.printMenu(cfg) + choice, err := p.read("Choose a section", "", false, false, func(value string) error { + return oneOf(false, "1", "2", "3", "4", "5", "6", "7", "8", "9", "a", "s", "q")(strings.ToLower(value)) + }) + if err != nil { + return err + } + switch strings.ToLower(choice) { + case "1": + err = p.general(cfg) + case "2": + cfg.Sonarr, err = p.instances("Sonarr", cfg.Sonarr) + case "3": + cfg.Radarr, err = p.instances("Radarr", cfg.Radarr) + case "4": + err = p.streams(cfg) + case "5": + err = p.requests(cfg) + case "6": + err = p.calendar(cfg) + case "7": + err = p.airtime(cfg) + case "8": + err = p.feed(cfg) + case "9": + err = p.anime(cfg) + case "a": + err = p.configureAll(cfg) + case "s": + if err := cfg.Validate(); err != nil { + fmt.Fprintf(rt.Stderr, "\nConfiguration is not valid yet:\n%v\n", err) + continue + } + return nil + case "q": + return errSetupCancelled + } + if err != nil { + return err + } + } +} + +func (p *setupPrompter) printMenu(cfg *config.ToolkitConfig) { + streamStatus := cfg.MediaStreams.ServerType + if streamStatus == "" { + streamStatus = "disabled" + } + requestStatus := "URL only" + if cfg.MediaRequests.APIKey != "" { + requestStatus = "configured" + } + fmt.Fprintf(p.rt.Stderr, ` +Configuration sections + 1. General theme=%s, timeout=%s + 2. Sonarr instances %d configured + 3. Radarr instances %d configured + 4. Media streams %s + 5. Media requests %s + 6. Media calendar %d future / %d past days + 7. Media airtime limit=%d + 8. Arr feed interval=%s + 9. AniSearch limit=%d + + A. Configure all sections + S. Validate and save + Q. Quit without saving +`, cfg.General.Theme, cfg.General.Timeout, len(cfg.Sonarr), len(cfg.Radarr), streamStatus, + requestStatus, cfg.MediaCalendar.Days, cfg.MediaCalendar.DaysPast, + cfg.MediaAirtime.Limit, cfg.ArrFeed.PollInterval, cfg.AniSearch.Limit) +} + +func (p *setupPrompter) configureAll(cfg *config.ToolkitConfig) error { + steps := []func() error{ + func() error { return p.general(cfg) }, + func() (err error) { cfg.Sonarr, err = p.instances("Sonarr", cfg.Sonarr); return err }, + func() (err error) { cfg.Radarr, err = p.instances("Radarr", cfg.Radarr); return err }, + func() error { return p.streams(cfg) }, + func() error { return p.requests(cfg) }, + func() error { return p.calendar(cfg) }, + func() error { return p.airtime(cfg) }, + func() error { return p.feed(cfg) }, + func() error { return p.anime(cfg) }, + } + for _, step := range steps { + if err := step(); err != nil { + return err + } + } + return nil +} + +func (p *setupPrompter) section(title, description string) { + fmt.Fprintf(p.rt.Stderr, "\n[%s]\n%s\n", title, description) +} + +func (p *setupPrompter) read(label, current string, secret, optional bool, validate func(string) error) (string, error) { + for { + if err := p.rt.Context.Err(); err != nil { + return "", err + } + display := current + if secret && current != "" { + display = "configured" + } + if display == "" { + fmt.Fprintf(p.rt.Stderr, "%s: ", label) + } else { + fmt.Fprintf(p.rt.Stderr, "%s [%s]: ", label, display) + } + if !p.scanner.Scan() { + if err := p.scanner.Err(); err != nil { + return "", err + } + return "", fmt.Errorf("input ended while reading %s", label) + } + value := strings.TrimSpace(p.scanner.Text()) + if value == "" { + value = current + } else if value == "-" && optional { + value = "" + } + if validate != nil { + if err := validate(value); err != nil { + fmt.Fprintf(p.rt.Stderr, " Invalid value: %v. Try again.\n", err) + continue + } + } + return value, nil + } +} + +func (p *setupPrompter) text(label, current string, optional bool, validate func(string) error) (string, error) { + return p.read(label, current, false, optional, validate) +} + +func (p *setupPrompter) secret(label, current string, optional bool) (string, error) { + return p.read(label, current, true, optional, nil) +} + +func (p *setupPrompter) integer(label string, current, min, max int) (int, error) { + value, err := p.read(label, strconv.Itoa(current), false, false, func(value string) error { + n, err := strconv.Atoi(value) + if err != nil { + return errors.New("enter a whole number") + } + if n < min || (max > 0 && n > max) { + if max > 0 { + return fmt.Errorf("must be between %d and %d", min, max) + } + return fmt.Errorf("must be at least %d", min) + } + return nil + }) + if err != nil { + return 0, err + } + return strconv.Atoi(value) +} + +func (p *setupPrompter) boolean(label string, current bool) (bool, error) { + currentValue := "no" + if current { + currentValue = "yes" + } + value, err := p.read(label+" (yes/no)", currentValue, false, false, func(value string) error { + if _, ok := parseBool(value); !ok { + return errors.New("enter yes or no") + } + return nil + }) + if err != nil { + return false, err + } + result, _ := parseBool(value) + return result, nil +} + +func parseBool(value string) (bool, bool) { + switch strings.ToLower(value) { + case "y", "yes", "true", "1": + return true, true + case "n", "no", "false", "0": + return false, true + default: + return false, false + } +} + +func oneOf(optional bool, values ...string) func(string) error { + return func(value string) error { + if optional && value == "" { + return nil + } + for _, candidate := range values { + if value == candidate { + return nil + } + } + return fmt.Errorf("choose %s", strings.Join(values, ", ")) + } +} + +func httpURL(optional bool) func(string) error { + return func(value string) error { + if optional && value == "" { + return nil + } + u, err := url.ParseRequestURI(value) + if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { + return errors.New("enter a complete http:// or https:// URL") + } + return nil + } +} + +func duration(positive bool) func(string) error { + return func(value string) error { + d, err := time.ParseDuration(value) + if err != nil { + return errors.New("enter a Go duration such as 10s, 5m, or 1h") + } + if positive && d <= 0 { + return errors.New("duration must be positive") + } + return nil + } +} + +func required(value string) error { + if strings.TrimSpace(value) == "" { + return errors.New("value is required") + } + return nil +} + +func (p *setupPrompter) general(cfg *config.ToolkitConfig) (err error) { + p.section("General", "Shared HTTP and terminal presentation defaults.") + if cfg.General.Timeout, err = p.text("HTTP timeout (for example 10s)", cfg.General.Timeout, false, duration(true)); err != nil { + return err + } + if cfg.General.Theme, err = p.text("Theme (default, catppuccin-mocha, catppuccin-latte)", cfg.General.Theme, false, func(value string) error { + if !colors.ValidateTheme(value) { + return errors.New("choose default, catppuccin-mocha, or catppuccin-latte") + } + return nil + }); err != nil { + return err + } + cfg.General.NoColor, err = p.boolean("Disable color by default", cfg.General.NoColor) + return err +} + +func (p *setupPrompter) instances(service string, current []config.ArrInstance) ([]config.ArrInstance, error) { + p.section(service+" Instances", "Add, edit, or remove API endpoints. The external URL is optional and is only used for browser links.") + instances := append([]config.ArrInstance(nil), current...) + for { + fmt.Fprintln(p.rt.Stderr) + for i, instance := range instances { + fmt.Fprintf(p.rt.Stderr, " %d. %s (%s)\n", i+1, instance.Name, instance.URL) + } + fmt.Fprintln(p.rt.Stderr, " A. Add instance") + if len(instances) > 0 { + fmt.Fprintln(p.rt.Stderr, " R. Remove instance") + } + fmt.Fprintln(p.rt.Stderr, " D. Done") + choice, err := p.text("Choose an instance to edit", "", false, func(value string) error { + lower := strings.ToLower(value) + if lower == "a" || lower == "d" || (lower == "r" && len(instances) > 0) { + return nil + } + n, err := strconv.Atoi(value) + if err != nil || n < 1 || n > len(instances) { + return errors.New("choose an instance number, A, R, or D") + } + return nil + }) + if err != nil { + return nil, err + } + switch strings.ToLower(choice) { + case "a": + instance, err := p.instance(service, len(instances)+1, config.ArrInstance{}) + if err != nil { + return nil, err + } + instances = append(instances, instance) + case "r": + n, err := p.integer("Instance number to remove", 1, 1, len(instances)) + if err != nil { + return nil, err + } + instances = append(instances[:n-1], instances[n:]...) + case "d": + return instances, nil + default: + n, _ := strconv.Atoi(choice) + instance, err := p.instance(service, n, instances[n-1]) + if err != nil { + return nil, err + } + instances[n-1] = instance + } + } +} + +func (p *setupPrompter) instance(service string, number int, instance config.ArrInstance) (config.ArrInstance, error) { + prefix := fmt.Sprintf("%s %d", service, number) + var err error + if instance.Name, err = p.text(prefix+" name (for example HD)", instance.Name, false, required); err != nil { + return instance, err + } + if instance.URL, err = p.text(prefix+" API URL", instance.URL, false, httpURL(false)); err != nil { + return instance, err + } + instance.URL = strings.TrimSuffix(instance.URL, "/") + if instance.ExternalURL, err = p.text(prefix+" external URL (optional)", instance.ExternalURL, true, httpURL(true)); err != nil { + return instance, err + } + instance.ExternalURL = strings.TrimSuffix(instance.ExternalURL, "/") + instance.APIKey, err = p.read(prefix+" API key", instance.APIKey, true, false, required) + return instance, err +} + +func (p *setupPrompter) streams(cfg *config.ToolkitConfig) (err error) { + p.section("Media Streams", "Configure Plex, Jellyfin, or both for session monitoring. Choose none to leave integrations disabled.") + if cfg.MediaStreams.ServerType, err = p.text("Enabled servers (plex, jellyfin, both; - disables)", cfg.MediaStreams.ServerType, true, oneOf(true, "plex", "jellyfin", "both")); err != nil { + return err + } + if cfg.MediaStreams.PlexURL, err = p.text("Plex URL", cfg.MediaStreams.PlexURL, true, httpURL(true)); err != nil { + return err + } + if cfg.MediaStreams.PlexToken, err = p.secret("Plex token (optional when Plex is disabled)", cfg.MediaStreams.PlexToken, true); err != nil { + return err + } + if cfg.MediaStreams.JellyfinURL, err = p.text("Jellyfin URL", cfg.MediaStreams.JellyfinURL, true, httpURL(true)); err != nil { + return err + } + if cfg.MediaStreams.JellyfinToken, err = p.secret("Jellyfin token (optional when Jellyfin is disabled)", cfg.MediaStreams.JellyfinToken, true); err != nil { + return err + } + if cfg.MediaStreams.WatchInterval, err = p.integer("Watch interval in seconds", cfg.MediaStreams.WatchInterval, 1, 0); err != nil { + return err + } + cfg.MediaStreams.HistoryDuration, err = p.text("Session history duration (for example 15m)", cfg.MediaStreams.HistoryDuration, false, duration(true)) + return err +} + +func (p *setupPrompter) requests(cfg *config.ToolkitConfig) (err error) { + p.section("Media Requests", "Configure the interactive Overseerr or Jellyseerr requester. The credential may be supplied later through CALMSTOOLKIT_REQUESTS_API_KEY.") + if cfg.MediaRequests.OverseerrURL, err = p.text("Overseerr/Jellyseerr URL", cfg.MediaRequests.OverseerrURL, true, httpURL(true)); err != nil { + return err + } + if cfg.MediaRequests.APIKey, err = p.secret("Requests API key (optional)", cfg.MediaRequests.APIKey, true); err != nil { + return err + } + cfg.MediaRequests.Verbose, err = p.boolean("Verbose request diagnostics", cfg.MediaRequests.Verbose) + return err +} + +func (p *setupPrompter) calendar(cfg *config.ToolkitConfig) (err error) { + p.section("Media Calendar", "Defaults for release range and dashboard refresh behavior.") + if cfg.MediaCalendar.Days, err = p.integer("Future days", cfg.MediaCalendar.Days, 0, 0); err != nil { + return err + } + if cfg.MediaCalendar.DaysPast, err = p.integer("Past days", cfg.MediaCalendar.DaysPast, 0, 0); err != nil { + return err + } + if cfg.MediaCalendar.WatchInterval, err = p.integer("Watch interval in seconds", cfg.MediaCalendar.WatchInterval, 1, 0); err != nil { + return err + } + cfg.MediaCalendar.Debug, err = p.boolean("Calendar debug diagnostics", cfg.MediaCalendar.Debug) + return err +} + +func (p *setupPrompter) airtime(cfg *config.ToolkitConfig) (err error) { + p.section("Media Airtime", "Defaults for Sonarr and Radarr library searches.") + if cfg.MediaAirtime.Limit, err = p.integer("Maximum matches", cfg.MediaAirtime.Limit, 1, 50); err != nil { + return err + } + if cfg.MediaAirtime.PastDays, err = p.integer("Past-day search window", cfg.MediaAirtime.PastDays, 0, 0); err != nil { + return err + } + if cfg.MediaAirtime.FutureDays, err = p.integer("Future-day search window", cfg.MediaAirtime.FutureDays, 0, 0); err != nil { + return err + } + cfg.MediaAirtime.Debug, err = p.boolean("Airtime debug diagnostics", cfg.MediaAirtime.Debug) + return err +} + +func (p *setupPrompter) feed(cfg *config.ToolkitConfig) (err error) { + p.section("Arr Feed", "Defaults for Sonarr and Radarr activity polling and event visibility.") + if cfg.ArrFeed.PollInterval, err = p.text("Poll interval (for example 5s)", cfg.ArrFeed.PollInterval, false, duration(true)); err != nil { + return err + } + if cfg.ArrFeed.HistoryWindow, err = p.text("History window (for example 1h)", cfg.ArrFeed.HistoryWindow, false, duration(true)); err != nil { + return err + } + if cfg.ArrFeed.MaxEvents, err = p.integer("Maximum events (0 means unlimited)", cfg.ArrFeed.MaxEvents, 0, 100); err != nil { + return err + } + fields := []struct { + label string + value *bool + }{ + {"Show grabbed events", &cfg.ArrFeed.ShowGrabbed}, + {"Show imported events", &cfg.ArrFeed.ShowImported}, + {"Show failed events", &cfg.ArrFeed.ShowFailed}, + {"Show deleted events", &cfg.ArrFeed.ShowDeleted}, + {"Show ignored events", &cfg.ArrFeed.ShowIgnored}, + {"Show subtitle details", &cfg.ArrFeed.ShowSubtitles}, + } + for _, field := range fields { + if *field.value, err = p.boolean(field.label, *field.value); err != nil { + return err + } + } + return nil +} + +func (p *setupPrompter) anime(cfg *config.ToolkitConfig) (err error) { + p.section("AniSearch", "Configure the AniList-to-TVDB mapping source, local cache, and result count.") + if cfg.AniSearch.MappingURL, err = p.text("Mapping download URL", cfg.AniSearch.MappingURL, false, httpURL(false)); err != nil { + return err + } + if cfg.AniSearch.MappingPath, err = p.text("Mapping cache path (optional; default uses the user cache)", cfg.AniSearch.MappingPath, true, nil); err != nil { + return err + } + cfg.AniSearch.Limit, err = p.integer("Results per page", cfg.AniSearch.Limit, 1, 50) + return err +} diff --git a/internal/config/config.go b/internal/config/config.go index b1e0c64..bae04df 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -161,7 +161,7 @@ func ResolvePath(explicit string) string { // DefaultToolkitConfig returns a ToolkitConfig with sensible defaults. func DefaultToolkitConfig() *ToolkitConfig { return &ToolkitConfig{ - Version: 1, + Version: CurrentVersion, General: GeneralConfig{ Timeout: "10s", NoColor: false, @@ -220,6 +220,18 @@ func LoadToolkitConfig() (*ToolkitConfig, error) { // LoadToolkitConfigAt loads a configuration file. An empty path uses // CALMSTOOLKIT_CONFIG or the standard user path. func LoadToolkitConfigAt(explicitPath string) (*ToolkitConfig, error) { + cfg, err := LoadPersistedToolkitConfigAt(explicitPath) + if err != nil { + return nil, err + } + ApplyEnvironment(cfg) + return cfg, nil +} + +// LoadPersistedToolkitConfigAt loads only values stored in a configuration +// file, without applying environment overrides. Setup uses it to avoid writing +// environment-only credentials back to disk. +func LoadPersistedToolkitConfigAt(explicitPath string) (*ToolkitConfig, error) { path := ResolvePath(explicitPath) if path == "" { return nil, fmt.Errorf("cannot determine home directory") @@ -228,7 +240,7 @@ func LoadToolkitConfigAt(explicitPath string) (*ToolkitConfig, error) { data, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { - return nil, fmt.Errorf("config not found at %s (run 'make setup')", path) + return nil, fmt.Errorf("config not found at %s (run 'calmstoolkit config setup')", path) } return nil, fmt.Errorf("reading config: %w", err) } @@ -251,7 +263,6 @@ func LoadToolkitConfigAt(explicitPath string) (*ToolkitConfig, error) { cfg.Radarr[i].ExternalURL = strings.TrimSuffix(cfg.Radarr[i].ExternalURL, "/") } - ApplyEnvironment(cfg) return cfg, nil } @@ -295,7 +306,7 @@ func firstEnvironment(primary, legacy, fallback string) string { func (c *ToolkitConfig) Validate() error { var problems []error add := func(format string, args ...any) { problems = append(problems, fmt.Errorf(format, args...)) } - if c.Version < 1 { + if c.Version != CurrentVersion { add("unsupported version: %d", c.Version) } @@ -349,19 +360,22 @@ func (c *ToolkitConfig) Validate() error { if d, err := time.ParseDuration(c.General.Timeout); err != nil || d <= 0 { add("general.timeout: invalid positive duration %q", c.General.Timeout) } + if c.General.Theme != "default" && c.General.Theme != "catppuccin-mocha" && c.General.Theme != "catppuccin-latte" { + add("general.theme: unknown theme %q", c.General.Theme) + } if dt := c.MediaStreams.HistoryDuration; dt != "" { - if _, err := time.ParseDuration(dt); err != nil { - add("media_streams.history_duration: invalid duration %q", dt) + if d, err := time.ParseDuration(dt); err != nil || d <= 0 { + add("media_streams.history_duration: invalid positive duration %q", dt) } } if dt := c.ArrFeed.PollInterval; dt != "" { - if _, err := time.ParseDuration(dt); err != nil { - add("arr_feed.poll_interval: invalid duration %q", dt) + if d, err := time.ParseDuration(dt); err != nil || d <= 0 { + add("arr_feed.poll_interval: invalid positive duration %q", dt) } } if dt := c.ArrFeed.HistoryWindow; dt != "" { - if _, err := time.ParseDuration(dt); err != nil { - add("arr_feed.history_window: invalid duration %q", dt) + if d, err := time.ParseDuration(dt); err != nil || d <= 0 { + add("arr_feed.history_window: invalid positive duration %q", dt) } } @@ -371,6 +385,9 @@ func (c *ToolkitConfig) Validate() error { if c.MediaCalendar.Days < 0 { add("media_calendar.days: must be >= 0") } + if c.MediaCalendar.DaysPast < 0 { + add("media_calendar.days_past: must be >= 0") + } if c.MediaCalendar.WatchInterval < 1 { add("media_calendar.watch_interval: must be >= 1") } @@ -399,6 +416,9 @@ func (c *ToolkitConfig) Validate() error { if c.MediaRequests.OverseerrURL != "" && !validHTTPURL(c.MediaRequests.OverseerrURL) { add("media_requests.overseerr_url: invalid url %q", c.MediaRequests.OverseerrURL) } + if !validHTTPURL(c.AniSearch.MappingURL) { + add("anisearch.mapping_url: invalid url %q", c.AniSearch.MappingURL) + } serverType := c.MediaStreams.ServerType if serverType != "" && serverType != "plex" && serverType != "jellyfin" && serverType != "both" { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 5056704..16fc40c 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -60,6 +60,31 @@ func TestApplyEnvironmentPrecedence(t *testing.T) { } } +func TestLoadPersistedToolkitConfigDoesNotApplyEnvironment(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.json") + cfg := DefaultToolkitConfig() + cfg.MediaStreams.PlexToken = "file" + if err := cfg.SaveAt(path); err != nil { + t.Fatal(err) + } + t.Setenv("CALMSTOOLKIT_PLEX_TOKEN", "environment") + + persisted, err := LoadPersistedToolkitConfigAt(path) + if err != nil { + t.Fatal(err) + } + if persisted.MediaStreams.PlexToken != "file" { + t.Fatalf("persisted token=%q", persisted.MediaStreams.PlexToken) + } + loaded, err := LoadToolkitConfigAt(path) + if err != nil { + t.Fatal(err) + } + if loaded.MediaStreams.PlexToken != "environment" { + t.Fatalf("runtime token=%q", loaded.MediaStreams.PlexToken) + } +} + func TestConfigSaveLoadRoundTrip(t *testing.T) { dir := t.TempDir() origHome := os.Getenv("HOME") @@ -218,6 +243,30 @@ func TestConfigValidate(t *testing.T) { } } +func TestValidateAllScalarConstraints(t *testing.T) { + tests := []struct { + name string + change func(*ToolkitConfig) + want string + }{ + {"theme", func(c *ToolkitConfig) { c.General.Theme = "unknown" }, "general.theme"}, + {"past calendar days", func(c *ToolkitConfig) { c.MediaCalendar.DaysPast = -1 }, "days_past"}, + {"mapping URL", func(c *ToolkitConfig) { c.AniSearch.MappingURL = "invalid" }, "mapping_url"}, + {"stream history duration", func(c *ToolkitConfig) { c.MediaStreams.HistoryDuration = "0s" }, "history_duration"}, + {"feed polling duration", func(c *ToolkitConfig) { c.ArrFeed.PollInterval = "-1s" }, "poll_interval"}, + {"feed history duration", func(c *ToolkitConfig) { c.ArrFeed.HistoryWindow = "0s" }, "history_window"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := DefaultToolkitConfig() + tt.change(cfg) + if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), tt.want) { + t.Fatalf("Validate() error = %v, want %q", err, tt.want) + } + }) + } +} + func TestConfigURLNormalization(t *testing.T) { dir := t.TempDir() cfg := DefaultToolkitConfig()