From 17bdfad4469957efc7e7f530754778c8bbd36cc7 Mon Sep 17 00:00:00 2001 From: Ilyas Salikhov Date: Wed, 6 May 2026 09:12:28 +0300 Subject: [PATCH] Add authentication profiles --- README.md | 7 +- cmd/redmine-openapi-gen/main.go | 25 ++-- docs/usage.md | 28 +++- internal/cli/auth.go | 148 +++++++++++++++++++-- internal/cli/root.go | 31 +++-- internal/cli/root_test.go | 225 +++++++++++++++++++++++++++++++- internal/config/config.go | 30 ++++- internal/redmine/client.go | 2 +- 8 files changed, 452 insertions(+), 44 deletions(-) diff --git a/README.md b/README.md index 9b1e513..6fd6b21 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,12 @@ redmine --version ## Usage ```bash -redmine auth login --host https://redmine.example.com --api-key "$REDMINE_API_KEY" +redmine auth login --profile work --host https://redmine.example.com --api-key "$REDMINE_API_KEY" +redmine auth login --profile client --host https://client-redmine.example.com --api-key "$CLIENT_REDMINE_API_KEY" +redmine auth list +redmine auth use work +redmine auth status +redmine --profile client issue list --limit 20 redmine issue list --limit 20 redmine issue show 123 --include journals redmine issue create --project-id my-project --subject "Fix checkout" diff --git a/cmd/redmine-openapi-gen/main.go b/cmd/redmine-openapi-gen/main.go index 57b923b..cce9443 100644 --- a/cmd/redmine-openapi-gen/main.go +++ b/cmd/redmine-openapi-gen/main.go @@ -726,14 +726,19 @@ func renderDocs(doc *document, ops []openapi.Operation) []byte { buf.WriteString("```bash\n") buf.WriteString("redmine [command] [subcommand] [flags]\n") buf.WriteString("```\n\n") - buf.WriteString("Set `REDMINE_HOST` and `REDMINE_API_KEY`, or run `redmine auth login --host --api-key `. Login validates credentials with `GET /users/current.json` before saving them.\n\n") - buf.WriteString("Common flags: `--host`, `--api-key`, `--username`, `--password`, `--switch-user`, `--output json|yaml|raw`, `--config`.\n\n") + buf.WriteString("Set `REDMINE_HOST` and `REDMINE_API_KEY`, or run `redmine auth login --profile --host --api-key `. Login validates credentials with `GET /users/current.json` before saving them. Successful login stores or updates the selected profile and makes it current.\n\n") + buf.WriteString("Common flags: `--profile`, `--host`, `--api-key`, `--username`, `--password`, `--switch-user`, `--output json|yaml|raw`, `--config`.\n\n") + buf.WriteString("Profile selection order: `--profile`, `REDMINE_PROFILE`, current profile from config, `default`.\n\n") buf.WriteString("Request body commands accept generated body flags, repeated `--field key=value`, or `--body @file.json`.\n\n") renderAuthDocs(&buf) buf.WriteString("## Examples\n\n") buf.WriteString("```bash\n") - buf.WriteString("redmine auth login --host https://redmine.example.com --api-key \"$REDMINE_API_KEY\"\n") + buf.WriteString("redmine auth login --profile work --host https://redmine.example.com --api-key \"$REDMINE_API_KEY\"\n") + buf.WriteString("redmine auth login --profile client --host https://client-redmine.example.com --api-key \"$CLIENT_REDMINE_API_KEY\"\n") + buf.WriteString("redmine auth list\n") + buf.WriteString("redmine auth use work\n") buf.WriteString("redmine auth status\n") + buf.WriteString("redmine --profile client issue list --limit 20\n") buf.WriteString("redmine issue list --limit 20\n") buf.WriteString("redmine issue show 123 --include journals\n") buf.WriteString("redmine issue create --project-id my-project --subject \"Fix checkout\"\n") @@ -769,12 +774,16 @@ func renderDocs(doc *document, ops []openapi.Operation) []byte { func renderAuthDocs(buf *bytes.Buffer) { buf.WriteString("## Authentication\n\n") - buf.WriteString("### `redmine auth login --host --api-key `\n\n") - buf.WriteString("Checks the credentials with `GET /users/current.json` and saves them to the config file only after a successful response. The API key can also be read from stdin with `--stdin`.\n\n") + buf.WriteString("### `redmine auth login --profile --host --api-key `\n\n") + buf.WriteString("Checks the credentials with `GET /users/current.json` and saves them to the selected profile only after a successful response. The selected profile is resolved from `--profile`, `REDMINE_PROFILE`, current config profile, or `default`. Successful login makes the selected profile current. The API key can also be read from stdin with `--stdin`.\n\n") buf.WriteString("### `redmine auth status`\n\n") - buf.WriteString("Loads authentication from flags, environment, or config, calls `GET /users/current.json`, and prints the resolved host, auth method, authenticated user, and status.\n\n") + buf.WriteString("Loads authentication from flags, environment, or the selected profile, calls `GET /users/current.json`, and prints the resolved profile, host, auth method, authenticated user, and status.\n\n") + buf.WriteString("### `redmine auth list`\n\n") + buf.WriteString("Lists saved profiles and marks the current profile.\n\n") + buf.WriteString("### `redmine auth use `\n\n") + buf.WriteString("Sets the current profile used when `--profile` and `REDMINE_PROFILE` are not provided.\n\n") buf.WriteString("### `redmine auth logout`\n\n") - buf.WriteString("Removes the saved config file.\n\n") + buf.WriteString("Removes the selected profile. Use `redmine auth logout --all` to remove the entire config file.\n\n") } func renderOperationDocs(buf *bytes.Buffer, op openapi.Operation) { @@ -873,7 +882,7 @@ func oneLine(value string) string { func reservedFlagName(name string) bool { switch name { - case "api-key", "body", "config", "field", "header", "help", "host", "input", "insecure", "output", "param", "password", "switch-user", "timeout", "username", "version": + case "api-key", "body", "config", "field", "header", "help", "host", "input", "insecure", "output", "param", "password", "profile", "switch-user", "timeout", "username", "version": return true default: return false diff --git a/docs/usage.md b/docs/usage.md index 922abf3..3d6a78c 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -10,31 +10,45 @@ Generated from `Redmine API` `1.7.1+redmine.6.1`. redmine [command] [subcommand] [flags] ``` -Set `REDMINE_HOST` and `REDMINE_API_KEY`, or run `redmine auth login --host --api-key `. Login validates credentials with `GET /users/current.json` before saving them. +Set `REDMINE_HOST` and `REDMINE_API_KEY`, or run `redmine auth login --profile --host --api-key `. Login validates credentials with `GET /users/current.json` before saving them. Successful login stores or updates the selected profile and makes it current. -Common flags: `--host`, `--api-key`, `--username`, `--password`, `--switch-user`, `--output json|yaml|raw`, `--config`. +Common flags: `--profile`, `--host`, `--api-key`, `--username`, `--password`, `--switch-user`, `--output json|yaml|raw`, `--config`. + +Profile selection order: `--profile`, `REDMINE_PROFILE`, current profile from config, `default`. Request body commands accept generated body flags, repeated `--field key=value`, or `--body @file.json`. ## Authentication -### `redmine auth login --host --api-key ` +### `redmine auth login --profile --host --api-key ` -Checks the credentials with `GET /users/current.json` and saves them to the config file only after a successful response. The API key can also be read from stdin with `--stdin`. +Checks the credentials with `GET /users/current.json` and saves them to the selected profile only after a successful response. The selected profile is resolved from `--profile`, `REDMINE_PROFILE`, current config profile, or `default`. Successful login makes the selected profile current. The API key can also be read from stdin with `--stdin`. ### `redmine auth status` -Loads authentication from flags, environment, or config, calls `GET /users/current.json`, and prints the resolved host, auth method, authenticated user, and status. +Loads authentication from flags, environment, or the selected profile, calls `GET /users/current.json`, and prints the resolved profile, host, auth method, authenticated user, and status. + +### `redmine auth list` + +Lists saved profiles and marks the current profile. + +### `redmine auth use ` + +Sets the current profile used when `--profile` and `REDMINE_PROFILE` are not provided. ### `redmine auth logout` -Removes the saved config file. +Removes the selected profile. Use `redmine auth logout --all` to remove the entire config file. ## Examples ```bash -redmine auth login --host https://redmine.example.com --api-key "$REDMINE_API_KEY" +redmine auth login --profile work --host https://redmine.example.com --api-key "$REDMINE_API_KEY" +redmine auth login --profile client --host https://client-redmine.example.com --api-key "$CLIENT_REDMINE_API_KEY" +redmine auth list +redmine auth use work redmine auth status +redmine --profile client issue list --limit 20 redmine issue list --limit 20 redmine issue show 123 --include journals redmine issue create --project-id my-project --subject "Fix checkout" diff --git a/internal/cli/auth.go b/internal/cli/auth.go index 0fe2a33..d48820a 100644 --- a/internal/cli/auth.go +++ b/internal/cli/auth.go @@ -5,6 +5,7 @@ import ( "fmt" "io" "net/http" + "sort" "strings" "github.com/muxx/redmine-cli/internal/config" @@ -45,16 +46,36 @@ func addAuthCommands(root *cobra.Command, opts *rootOptions) { }) auth.AddCommand(&cobra.Command{ + Use: "list", + Short: "List authentication profiles", + RunE: func(cmd *cobra.Command, args []string) error { + return runList(opts) + }, + }) + + auth.AddCommand(&cobra.Command{ + Use: "use ", + Short: "Set the current authentication profile", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runUse(opts, args[0]) + }, + }) + + var logout logoutOptions + logoutCmd := &cobra.Command{ Use: "logout", Short: "Remove saved authentication", RunE: func(cmd *cobra.Command, args []string) error { - if err := config.Remove(opts.configPath); err != nil { - return err - } - _, err := fmt.Fprintln(opts.out, "Logged out") - return err + return runLogout(opts, logout) }, - }) + } + logoutCmd.Flags().BoolVar(&logout.all, "all", false, "Remove all saved profiles") + auth.AddCommand(logoutCmd) +} + +type logoutOptions struct { + all bool } type loginOptions struct { @@ -66,6 +87,15 @@ type loginOptions struct { } func runLogin(cmd *cobra.Command, opts *rootOptions, login loginOptions) error { + fileCfg, err := config.Load(opts.configPath) + if err != nil { + return err + } + profileName := selectedProfileName(opts, fileCfg) + if strings.TrimSpace(profileName) == "" { + return fmt.Errorf("profile name is required") + } + host := firstNonEmpty(login.host, opts.host) if host == "" { return fmt.Errorf("--host is required") @@ -84,20 +114,22 @@ func runLogin(cmd *cobra.Command, opts *rootOptions, login loginOptions) error { return fmt.Errorf("provide --api-key or --username/--password") } - cfg := config.Config{ + profile := config.Profile{ Host: host, APIKey: apiKey, Username: username, Password: password, } - result, err := checkAuth(cmd, opts, resolvedFromConfig(cfg)) + result, err := checkAuth(cmd, opts, resolvedFromProfile(profileName, profile)) if err != nil { return err } - if err := config.Save(opts.configPath, cfg); err != nil { + fileCfg.SetProfile(profileName, profile) + fileCfg.CurrentProfile = profileName + if err := config.Save(opts.configPath, fileCfg); err != nil { return err } - _, err = fmt.Fprintf(opts.out, "Logged in to %s as %s\n", host, result.User) + _, err = fmt.Fprintf(opts.out, "Logged in to %s as %s using profile %s\n", host, result.User, profileName) return err } @@ -115,7 +147,7 @@ func runStatus(cmd *cobra.Command, opts *rootOptions) error { return err } if resolvedCfg.Host == "" { - _, err := fmt.Fprintf(opts.out, "No Redmine authentication configured at %s\n", path) + _, err := fmt.Fprintf(opts.out, "No Redmine authentication configured for profile %s at %s\n", resolvedCfg.Profile, path) return err } result, err := checkAuth(cmd, opts, resolvedCfg) @@ -129,10 +161,102 @@ func runStatus(cmd *cobra.Command, opts *rootOptions) error { case resolvedCfg.Username != "" || resolvedCfg.Password != "": method = "basic" } - _, err = fmt.Fprintf(opts.out, "Host: %s\nAuth: %s\nUser: %s\nStatus: ok\nConfig: %s\n", resolvedCfg.Host, method, result.User, path) + _, err = fmt.Fprintf(opts.out, "Profile: %s\nHost: %s\nAuth: %s\nUser: %s\nStatus: ok\nConfig: %s\n", resolvedCfg.Profile, resolvedCfg.Host, method, result.User, path) + return err +} + +func runList(opts *rootOptions) error { + path := opts.configPath + if path == "" { + defaultPath, err := config.DefaultPath() + if err != nil { + return err + } + path = defaultPath + } + fileCfg, err := config.Load(opts.configPath) + if err != nil { + return err + } + if len(fileCfg.Profiles) == 0 { + _, err := fmt.Fprintf(opts.out, "No Redmine profiles configured at %s\n", path) + return err + } + current := firstNonEmpty(fileCfg.CurrentProfile, config.DefaultProfileName) + _, _ = fmt.Fprintf(opts.out, "Current profile: %s\nProfiles:\n", current) + for _, name := range sortedProfileNames(fileCfg.Profiles) { + marker := " " + if name == current { + marker = "*" + } + _, _ = fmt.Fprintf(opts.out, "%s %s\t%s\n", marker, name, fileCfg.Profiles[name].Host) + } + return nil +} + +func runUse(opts *rootOptions, profileName string) error { + if strings.TrimSpace(profileName) == "" { + return fmt.Errorf("profile name is required") + } + fileCfg, err := config.Load(opts.configPath) + if err != nil { + return err + } + if _, ok := fileCfg.Profiles[profileName]; !ok { + return fmt.Errorf("profile %q is not configured", profileName) + } + fileCfg.CurrentProfile = profileName + if err := config.Save(opts.configPath, fileCfg); err != nil { + return err + } + _, err = fmt.Fprintf(opts.out, "Current profile set to %s\n", profileName) + return err +} + +func runLogout(opts *rootOptions, logout logoutOptions) error { + if logout.all { + if err := config.Remove(opts.configPath); err != nil { + return err + } + _, err := fmt.Fprintln(opts.out, "Logged out from all profiles") + return err + } + + fileCfg, err := config.Load(opts.configPath) + if err != nil { + return err + } + profileName := selectedProfileName(opts, fileCfg) + if _, ok := fileCfg.Profiles[profileName]; !ok { + return fmt.Errorf("profile %q is not configured", profileName) + } + fileCfg.DeleteProfile(profileName) + if len(fileCfg.Profiles) == 0 { + if err := config.Remove(opts.configPath); err != nil { + return err + } + _, err := fmt.Fprintf(opts.out, "Logged out from profile %s\n", profileName) + return err + } + if fileCfg.CurrentProfile == "" { + fileCfg.CurrentProfile = sortedProfileNames(fileCfg.Profiles)[0] + } + if err := config.Save(opts.configPath, fileCfg); err != nil { + return err + } + _, err = fmt.Fprintf(opts.out, "Logged out from profile %s\n", profileName) return err } +func sortedProfileNames(profiles map[string]config.Profile) []string { + names := make([]string, 0, len(profiles)) + for name := range profiles { + names = append(names, name) + } + sort.Strings(names) + return names +} + type authCheckResult struct { User string } diff --git a/internal/cli/root.go b/internal/cli/root.go index 5d7d9d9..1e82164 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -47,6 +47,7 @@ func NewWithIO(version string, in io.Reader, out, errOut io.Writer, httpClient * flags := root.PersistentFlags() flags.StringVar(&opts.configPath, "config", "", "Config file path") + flags.StringVar(&opts.profile, "profile", "", "Authentication profile") flags.StringVar(&opts.host, "host", "", "Redmine base URL") flags.StringVar(&opts.apiKey, "api-key", "", "Redmine API key") flags.StringVar(&opts.username, "username", "", "Basic auth username") @@ -69,6 +70,7 @@ type rootOptions struct { httpClient *http.Client configPath string + profile string host string apiKey string username string @@ -154,7 +156,7 @@ func operationCommand(opts *rootOptions, op openapi.Operation) *cobra.Command { func reservedFlag(name string) bool { switch name { - case "api-key", "body", "config", "field", "header", "help", "host", "input", "insecure", "output", "param", "password", "switch-user", "timeout", "username", "version": + case "api-key", "body", "config", "field", "header", "help", "host", "input", "insecure", "output", "param", "password", "profile", "switch-user", "timeout", "username", "version": return true default: return false @@ -231,6 +233,7 @@ func runOperation(cmd *cobra.Command, opts *rootOptions, op openapi.Operation, f } type resolved struct { + Profile string Host string APIKey string Username string @@ -238,12 +241,13 @@ type resolved struct { SwitchUser string } -func resolvedFromConfig(cfg config.Config) resolved { +func resolvedFromProfile(profileName string, profile config.Profile) resolved { return resolved{ - Host: cfg.Host, - APIKey: cfg.APIKey, - Username: cfg.Username, - Password: cfg.Password, + Profile: profileName, + Host: profile.Host, + APIKey: profile.APIKey, + Username: profile.Username, + Password: profile.Password, } } @@ -252,15 +256,22 @@ func resolvedConfig(opts *rootOptions) (resolved, error) { if err != nil { return resolved{}, err } + profileName := selectedProfileName(opts, fileCfg) + profile := fileCfg.Profiles[profileName] return resolved{ - Host: firstNonEmpty(opts.host, os.Getenv("REDMINE_HOST"), fileCfg.Host), - APIKey: firstNonEmpty(opts.apiKey, os.Getenv("REDMINE_API_KEY"), fileCfg.APIKey), - Username: firstNonEmpty(opts.username, os.Getenv("REDMINE_USERNAME"), fileCfg.Username), - Password: firstNonEmpty(opts.password, os.Getenv("REDMINE_PASSWORD"), fileCfg.Password), + Profile: profileName, + Host: firstNonEmpty(opts.host, os.Getenv("REDMINE_HOST"), profile.Host), + APIKey: firstNonEmpty(opts.apiKey, os.Getenv("REDMINE_API_KEY"), profile.APIKey), + Username: firstNonEmpty(opts.username, os.Getenv("REDMINE_USERNAME"), profile.Username), + Password: firstNonEmpty(opts.password, os.Getenv("REDMINE_PASSWORD"), profile.Password), SwitchUser: firstNonEmpty(opts.switchUser, os.Getenv("REDMINE_SWITCH_USER")), }, nil } +func selectedProfileName(opts *rootOptions, cfg config.Config) string { + return firstNonEmpty(opts.profile, os.Getenv("REDMINE_PROFILE"), cfg.CurrentProfile, config.DefaultProfileName) +} + func collectParameters(generated map[string]*parameterValues, extra []string) (map[string][]string, error) { result := map[string][]string{} for _, item := range generated { diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go index 4522d2a..626136a 100644 --- a/internal/cli/root_test.go +++ b/internal/cli/root_test.go @@ -132,11 +132,57 @@ func TestAuthLoginChecksBeforeSaving(t *testing.T) { if err != nil { t.Fatal(err) } - if cfg.Host != server.URL || cfg.APIKey != "secret" { + profile := cfg.Profiles[config.DefaultProfileName] + if cfg.CurrentProfile != config.DefaultProfileName || profile.Host != server.URL || profile.APIKey != "secret" { t.Fatalf("config = %#v", cfg) } } +func TestAuthLoginWithProfileSetsCurrentProfile(t *testing.T) { + clearRedmineEnv(t) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/users/current.json" { + t.Fatalf("path = %s", r.URL.Path) + } + if got := r.Header.Get("X-Redmine-API-Key"); got != "secret" { + t.Fatalf("api key header = %q", got) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"user":{"login":"profile-user"}}`)) + })) + defer server.Close() + + configPath := t.TempDir() + "/config.yml" + var out bytes.Buffer + var errOut bytes.Buffer + cmd := NewWithIO("test", bytes.NewReader(nil), &out, &errOut, server.Client()) + cmd.SetArgs([]string{ + "--config", configPath, + "auth", "login", + "--profile", "work", + "--host", server.URL, + "--api-key", "secret", + }) + + if err := cmd.Execute(); err != nil { + t.Fatalf("execute: %v\nstderr: %s", err, errOut.String()) + } + if !strings.Contains(out.String(), "using profile work") { + t.Fatalf("output = %s", out.String()) + } + cfg, err := config.Load(configPath) + if err != nil { + t.Fatal(err) + } + if cfg.CurrentProfile != "work" { + t.Fatalf("current profile = %q", cfg.CurrentProfile) + } + if got := cfg.Profiles["work"].Host; got != server.URL { + t.Fatalf("profile host = %q", got) + } +} + func TestAuthLoginDoesNotSaveWhenCheckFails(t *testing.T) { clearRedmineEnv(t) @@ -184,7 +230,12 @@ func TestAuthStatusChecksSavedConfig(t *testing.T) { defer server.Close() configPath := t.TempDir() + "/config.yml" - if err := config.Save(configPath, config.Config{Host: server.URL, APIKey: "secret"}); err != nil { + if err := config.Save(configPath, config.Config{ + CurrentProfile: "work", + Profiles: map[string]config.Profile{ + "work": {Host: server.URL, APIKey: "secret"}, + }, + }); err != nil { t.Fatal(err) } @@ -196,13 +247,180 @@ func TestAuthStatusChecksSavedConfig(t *testing.T) { if err := cmd.Execute(); err != nil { t.Fatalf("execute: %v\nstderr: %s", err, errOut.String()) } - for _, want := range []string{"Auth: api-key", "User: jsmith", "Status: ok"} { + for _, want := range []string{"Profile: work", "Auth: api-key", "User: jsmith", "Status: ok"} { + if !strings.Contains(out.String(), want) { + t.Fatalf("output missing %q: %s", want, out.String()) + } + } +} + +func TestGeneratedCommandUsesSelectedProfile(t *testing.T) { + clearRedmineEnv(t) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/issues/42.json" { + t.Fatalf("path = %s", r.URL.Path) + } + if got := r.Header.Get("X-Redmine-API-Key"); got != "work-secret" { + t.Fatalf("api key header = %q", got) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"issue":{"id":42}}`)) + })) + defer server.Close() + + configPath := t.TempDir() + "/config.yml" + if err := config.Save(configPath, config.Config{ + CurrentProfile: "default", + Profiles: map[string]config.Profile{ + "default": {Host: "https://wrong.example", APIKey: "wrong"}, + "work": {Host: server.URL, APIKey: "work-secret"}, + }, + }); err != nil { + t.Fatal(err) + } + + var out bytes.Buffer + var errOut bytes.Buffer + cmd := NewWithIO("test", bytes.NewReader(nil), &out, &errOut, server.Client()) + cmd.SetArgs([]string{"--config", configPath, "--profile", "work", "issue", "show", "42"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("execute: %v\nstderr: %s", err, errOut.String()) + } + if !bytes.Contains(out.Bytes(), []byte(`"id": 42`)) { + t.Fatalf("output = %s", out.String()) + } +} + +func TestEnvProfileSelectsSavedProfile(t *testing.T) { + clearRedmineEnv(t) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("X-Redmine-API-Key"); got != "env-profile-secret" { + t.Fatalf("api key header = %q", got) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"issue":{"id":42}}`)) + })) + defer server.Close() + + configPath := t.TempDir() + "/config.yml" + if err := config.Save(configPath, config.Config{ + CurrentProfile: "default", + Profiles: map[string]config.Profile{ + "default": {Host: "https://wrong.example", APIKey: "wrong"}, + "ci": {Host: server.URL, APIKey: "env-profile-secret"}, + }, + }); err != nil { + t.Fatal(err) + } + t.Setenv("REDMINE_PROFILE", "ci") + + var out bytes.Buffer + var errOut bytes.Buffer + cmd := NewWithIO("test", bytes.NewReader(nil), &out, &errOut, server.Client()) + cmd.SetArgs([]string{"--config", configPath, "issue", "show", "42"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("execute: %v\nstderr: %s", err, errOut.String()) + } +} + +func TestAuthUseSwitchesCurrentProfile(t *testing.T) { + clearRedmineEnv(t) + + configPath := t.TempDir() + "/config.yml" + if err := config.Save(configPath, config.Config{ + CurrentProfile: "default", + Profiles: map[string]config.Profile{ + "default": {Host: "https://default.example", APIKey: "default-secret"}, + "work": {Host: "https://work.example", APIKey: "work-secret"}, + }, + }); err != nil { + t.Fatal(err) + } + + var out bytes.Buffer + var errOut bytes.Buffer + cmd := NewWithIO("test", bytes.NewReader(nil), &out, &errOut, nil) + cmd.SetArgs([]string{"--config", configPath, "auth", "use", "work"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("execute: %v\nstderr: %s", err, errOut.String()) + } + cfg, err := config.Load(configPath) + if err != nil { + t.Fatal(err) + } + if cfg.CurrentProfile != "work" { + t.Fatalf("current profile = %q", cfg.CurrentProfile) + } +} + +func TestAuthListMarksCurrentProfile(t *testing.T) { + clearRedmineEnv(t) + + configPath := t.TempDir() + "/config.yml" + if err := config.Save(configPath, config.Config{ + CurrentProfile: "work", + Profiles: map[string]config.Profile{ + "default": {Host: "https://default.example", APIKey: "default-secret"}, + "work": {Host: "https://work.example", APIKey: "work-secret"}, + }, + }); err != nil { + t.Fatal(err) + } + + var out bytes.Buffer + var errOut bytes.Buffer + cmd := NewWithIO("test", bytes.NewReader(nil), &out, &errOut, nil) + cmd.SetArgs([]string{"--config", configPath, "auth", "list"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("execute: %v\nstderr: %s", err, errOut.String()) + } + for _, want := range []string{"Current profile: work", "* work\thttps://work.example", " default\thttps://default.example"} { if !strings.Contains(out.String(), want) { t.Fatalf("output missing %q: %s", want, out.String()) } } } +func TestAuthLogoutRemovesSelectedProfileAndKeepsAnotherCurrent(t *testing.T) { + clearRedmineEnv(t) + + configPath := t.TempDir() + "/config.yml" + if err := config.Save(configPath, config.Config{ + CurrentProfile: "work", + Profiles: map[string]config.Profile{ + "default": {Host: "https://default.example", APIKey: "default-secret"}, + "work": {Host: "https://work.example", APIKey: "work-secret"}, + }, + }); err != nil { + t.Fatal(err) + } + + var out bytes.Buffer + var errOut bytes.Buffer + cmd := NewWithIO("test", bytes.NewReader(nil), &out, &errOut, nil) + cmd.SetArgs([]string{"--config", configPath, "auth", "logout"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("execute: %v\nstderr: %s", err, errOut.String()) + } + cfg, err := config.Load(configPath) + if err != nil { + t.Fatal(err) + } + if _, ok := cfg.Profiles["work"]; ok { + t.Fatalf("work profile still exists: %#v", cfg) + } + if cfg.CurrentProfile != "default" { + t.Fatalf("current profile = %q", cfg.CurrentProfile) + } +} + func TestAuthStatusChecksEnvConfig(t *testing.T) { clearRedmineEnv(t) @@ -241,4 +459,5 @@ func clearRedmineEnv(t *testing.T) { t.Setenv("REDMINE_USERNAME", "") t.Setenv("REDMINE_PASSWORD", "") t.Setenv("REDMINE_SWITCH_USER", "") + t.Setenv("REDMINE_PROFILE", "") } diff --git a/internal/config/config.go b/internal/config/config.go index 3fb422b..b91fd7a 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -9,15 +9,22 @@ import ( ) const appName = "redmine-cli" +const DefaultProfileName = "default" -// Config stores authentication defaults for Redmine. -type Config struct { +// Profile stores authentication defaults for one Redmine instance. +type Profile struct { Host string `yaml:"host,omitempty"` APIKey string `yaml:"api_key,omitempty"` Username string `yaml:"username,omitempty"` Password string `yaml:"password,omitempty"` } +// Config stores authentication profiles for Redmine. +type Config struct { + CurrentProfile string `yaml:"current_profile,omitempty"` + Profiles map[string]Profile `yaml:"profiles,omitempty"` +} + // DefaultPath returns the CLI configuration path. func DefaultPath() (string, error) { if path := os.Getenv("REDMINE_CONFIG"); path != "" { @@ -72,6 +79,25 @@ func Save(path string, cfg Config) error { return os.WriteFile(path, data, 0o600) } +// SetProfile stores a profile and makes sure the profiles map exists. +func (c *Config) SetProfile(name string, profile Profile) { + if c.Profiles == nil { + c.Profiles = map[string]Profile{} + } + c.Profiles[name] = profile +} + +// DeleteProfile removes a saved profile. +func (c *Config) DeleteProfile(name string) { + delete(c.Profiles, name) + if len(c.Profiles) == 0 { + c.Profiles = nil + } + if c.CurrentProfile == name { + c.CurrentProfile = "" + } +} + // Remove deletes configuration. A missing file is not an error. func Remove(path string) error { if path == "" { diff --git a/internal/redmine/client.go b/internal/redmine/client.go index 528e587..1869713 100644 --- a/internal/redmine/client.go +++ b/internal/redmine/client.go @@ -73,7 +73,7 @@ func NewHTTPClient(timeout time.Duration, insecure bool) *http.Client { // Do executes a generated operation. func (c Client) Do(ctx context.Context, req Request) (Response, error) { if c.BaseURL == "" { - return Response{}, fmt.Errorf("redmine host is not configured; set REDMINE_HOST or run `redmine auth login`") + return Response{}, fmt.Errorf("redmine host is not configured; set REDMINE_HOST or run `redmine auth login --profile `") } endpoint, err := c.endpoint(req) if err != nil {