Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
25 changes: 17 additions & 8 deletions cmd/redmine-openapi-gen/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <url> --api-key <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 <name> --host <url> --api-key <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")
Expand Down Expand Up @@ -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 <url> --api-key <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 <name> --host <url> --api-key <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 <profile>`\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) {
Expand Down Expand Up @@ -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
Expand Down
28 changes: 21 additions & 7 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <url> --api-key <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 <name> --host <url> --api-key <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 <url> --api-key <key>`
### `redmine auth login --profile <name> --host <url> --api-key <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 <profile>`

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"
Expand Down
148 changes: 136 additions & 12 deletions internal/cli/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"fmt"
"io"
"net/http"
"sort"
"strings"

"github.com/muxx/redmine-cli/internal/config"
Expand Down Expand Up @@ -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 <profile>",
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 {
Expand All @@ -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")
Expand All @@ -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
}

Expand All @@ -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)
Expand All @@ -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
}
Expand Down
Loading