From 39e62a755b96cb3d33c1134a323ddf439fa6b29d Mon Sep 17 00:00:00 2001 From: piekstra Date: Thu, 29 Jan 2026 15:27:24 -0500 Subject: [PATCH] feat(config): add init command and config test for guided setup [#19] - Add `jtk init` command for interactive guided setup wizard - Prompts for URL, email, and API token - Verifies connection before saving (--no-verify to skip) - Supports non-interactive mode via flags - Detects existing config and prompts for overwrite - Add `jtk config test` command for connectivity verification - Tests authentication and API access - Provides clear pass/fail status - Shows troubleshooting suggestions on failure Closes #19 --- cmd/jtk/main.go | 2 + internal/cmd/configcmd/configcmd.go | 55 ++++++++ internal/cmd/initcmd/initcmd.go | 193 ++++++++++++++++++++++++++++ 3 files changed, 250 insertions(+) create mode 100644 internal/cmd/initcmd/initcmd.go diff --git a/cmd/jtk/main.go b/cmd/jtk/main.go index cce332b..4254180 100644 --- a/cmd/jtk/main.go +++ b/cmd/jtk/main.go @@ -8,6 +8,7 @@ import ( "github.com/open-cli-collective/jira-ticket-cli/internal/cmd/comments" "github.com/open-cli-collective/jira-ticket-cli/internal/cmd/completion" "github.com/open-cli-collective/jira-ticket-cli/internal/cmd/configcmd" + "github.com/open-cli-collective/jira-ticket-cli/internal/cmd/initcmd" "github.com/open-cli-collective/jira-ticket-cli/internal/cmd/issues" "github.com/open-cli-collective/jira-ticket-cli/internal/cmd/me" "github.com/open-cli-collective/jira-ticket-cli/internal/cmd/root" @@ -28,6 +29,7 @@ func run() error { rootCmd, opts := root.NewCmd() // Register all commands + initcmd.Register(rootCmd, opts) configcmd.Register(rootCmd, opts) issues.Register(rootCmd, opts) transitions.Register(rootCmd, opts) diff --git a/internal/cmd/configcmd/configcmd.go b/internal/cmd/configcmd/configcmd.go index cd76611..6eaf880 100644 --- a/internal/cmd/configcmd/configcmd.go +++ b/internal/cmd/configcmd/configcmd.go @@ -20,6 +20,7 @@ func Register(parent *cobra.Command, opts *root.Options) { cmd.AddCommand(newSetCmd(opts)) cmd.AddCommand(newShowCmd(opts)) cmd.AddCommand(newClearCmd(opts)) + cmd.AddCommand(newTestCmd(opts)) parent.AddCommand(cmd) } @@ -197,3 +198,57 @@ func getAPITokenSource() string { } return "-" } + +func newTestCmd(opts *root.Options) *cobra.Command { + return &cobra.Command{ + Use: "test", + Short: "Test connection to Jira", + Long: `Verify that jtk can connect to Jira with the current configuration. + +This command tests authentication and API access, providing clear +pass/fail status and troubleshooting suggestions on failure.`, + Example: ` # Test connection + jtk config test`, + RunE: func(cmd *cobra.Command, args []string) error { + v := opts.View() + + url := config.GetURL() + if url == "" { + v.Error("No Jira URL configured") + v.Println("") + v.Info("Configure with: jtk init") + v.Info("Or set environment variable: JIRA_URL") + return nil + } + + v.Println("Testing connection to %s...", url) + v.Println("") + + client, err := opts.APIClient() + if err != nil { + v.Error("Failed to create client: %v", err) + v.Println("") + v.Info("Check your configuration with: jtk config show") + v.Info("Reconfigure with: jtk init") + return nil + } + + user, err := client.GetCurrentUser() + if err != nil { + v.Error("Authentication failed: %v", err) + v.Println("") + v.Info("Check your credentials with: jtk config show") + v.Info("Reconfigure with: jtk init") + return nil + } + + v.Success("Authentication successful") + v.Success("API access verified") + v.Println("") + v.Println("Authenticated as: %s (%s)", user.DisplayName, user.EmailAddress) + v.Println("Account ID: %s", user.AccountID) + + return nil + }, + } +} diff --git a/internal/cmd/initcmd/initcmd.go b/internal/cmd/initcmd/initcmd.go new file mode 100644 index 0000000..aa00368 --- /dev/null +++ b/internal/cmd/initcmd/initcmd.go @@ -0,0 +1,193 @@ +package initcmd + +import ( + "bufio" + "fmt" + "strings" + + "github.com/spf13/cobra" + + "github.com/open-cli-collective/jira-ticket-cli/api" + "github.com/open-cli-collective/jira-ticket-cli/internal/cmd/root" + "github.com/open-cli-collective/jira-ticket-cli/internal/config" +) + +// Register registers the init command +func Register(parent *cobra.Command, opts *root.Options) { + var url, email, token string + var noVerify bool + + cmd := &cobra.Command{ + Use: "init", + Short: "Initialize jtk with guided setup", + Long: `Interactive setup wizard for configuring jtk. + +Prompts for your Jira URL, email, and API token, then verifies +the connection before saving the configuration. + +Get your API token from: https://id.atlassian.com/manage-profile/security/api-tokens`, + Example: ` # Interactive setup + jtk init + + # Non-interactive setup + jtk init --url https://mycompany.atlassian.net --email user@example.com --token YOUR_TOKEN + + # Skip connection verification + jtk init --no-verify`, + RunE: func(cmd *cobra.Command, args []string) error { + return runInit(opts, url, email, token, noVerify) + }, + } + + cmd.Flags().StringVar(&url, "url", "", "Jira URL (e.g., https://mycompany.atlassian.net)") + cmd.Flags().StringVar(&email, "email", "", "Email address for authentication") + cmd.Flags().StringVar(&token, "token", "", "API token") + cmd.Flags().BoolVar(&noVerify, "no-verify", false, "Skip connection verification") + + parent.AddCommand(cmd) +} + +func runInit(opts *root.Options, url, email, token string, noVerify bool) error { + v := opts.View() + reader := bufio.NewReader(opts.Stdin) + + v.Println("Jira CLI Setup") + v.Println("") + + // Check for existing config + existingCfg, _ := config.Load() + if existingCfg.URL != "" || existingCfg.Email != "" || existingCfg.APIToken != "" { + v.Warning("Existing configuration found at %s", config.Path()) + v.Println("") + + overwrite, err := promptYesNo(reader, "Overwrite existing configuration?", false) + if err != nil { + return err + } + if !overwrite { + v.Info("Setup cancelled") + return nil + } + v.Println("") + } + + // Prompt for URL if not provided + if url == "" { + v.Println("Enter your Jira URL") + v.Println(" Examples: https://mycompany.atlassian.net") + v.Println(" https://jira.internal.corp.com") + v.Println("") + + var err error + url, err = promptRequired(reader, "URL") + if err != nil { + return err + } + } + url = config.NormalizeURL(url) + + // Prompt for email if not provided + if email == "" { + v.Println("") + var err error + email, err = promptRequired(reader, "Email") + if err != nil { + return err + } + } + + // Prompt for token if not provided + if token == "" { + v.Println("") + v.Println("Get your API token from:") + v.Println(" https://id.atlassian.com/manage-profile/security/api-tokens") + v.Println("") + + var err error + token, err = promptRequired(reader, "API Token") + if err != nil { + return err + } + } + + v.Println("") + + // Verify connection unless --no-verify + if !noVerify { + v.Println("Testing connection...") + + client, err := api.New(api.ClientConfig{ + URL: url, + Email: email, + APIToken: token, + }) + if err != nil { + return fmt.Errorf("failed to create client: %w", err) + } + + user, err := client.GetCurrentUser() + if err != nil { + v.Error("Connection failed: %v", err) + v.Println("") + v.Info("Check your credentials and try again") + return fmt.Errorf("authentication failed") + } + + v.Success("Connected to %s", url) + v.Success("Authenticated as %s (%s)", user.DisplayName, user.EmailAddress) + v.Println("") + } + + // Save configuration + cfg := &config.Config{ + URL: url, + Email: email, + APIToken: token, + } + + if err := config.Save(cfg); err != nil { + return fmt.Errorf("failed to save configuration: %w", err) + } + + v.Success("Configuration saved to %s", config.Path()) + v.Println("") + v.Println("Try it out:") + v.Println(" jtk me") + v.Println(" jtk issues list --project ") + + return nil +} + +func promptRequired(reader *bufio.Reader, label string) (string, error) { + for { + fmt.Printf("%s: ", label) + input, err := reader.ReadString('\n') + if err != nil { + return "", err + } + input = strings.TrimSpace(input) + if input != "" { + return input, nil + } + fmt.Printf(" %s is required\n", label) + } +} + +func promptYesNo(reader *bufio.Reader, question string, defaultYes bool) (bool, error) { + suffix := " [y/N]: " + if defaultYes { + suffix = " [Y/n]: " + } + + fmt.Print(question + suffix) + input, err := reader.ReadString('\n') + if err != nil { + return false, err + } + input = strings.TrimSpace(strings.ToLower(input)) + + if input == "" { + return defaultYes, nil + } + return input == "y" || input == "yes", nil +}