From 9d097e14773af00c1446e84f5229a57ade542503 Mon Sep 17 00:00:00 2001 From: piekstra Date: Tue, 27 Jan 2026 17:54:41 -0500 Subject: [PATCH 1/2] feat: add config show, test, and clear commands - `cfl config show`: display current config with credential source indicators - `cfl config test`: verify connectivity with configured credentials - `cfl config clear`: remove stored configuration file Fixes #94 --- internal/cmd/configcmd/clear.go | 66 +++++++++++++++++++ internal/cmd/configcmd/clear_test.go | 57 ++++++++++++++++ internal/cmd/configcmd/config.go | 21 ++++++ internal/cmd/configcmd/show.go | 95 +++++++++++++++++++++++++++ internal/cmd/configcmd/show_test.go | 55 ++++++++++++++++ internal/cmd/configcmd/test.go | 98 ++++++++++++++++++++++++++++ internal/cmd/configcmd/test_test.go | 65 ++++++++++++++++++ internal/cmd/root/root.go | 2 + 8 files changed, 459 insertions(+) create mode 100644 internal/cmd/configcmd/clear.go create mode 100644 internal/cmd/configcmd/clear_test.go create mode 100644 internal/cmd/configcmd/config.go create mode 100644 internal/cmd/configcmd/show.go create mode 100644 internal/cmd/configcmd/show_test.go create mode 100644 internal/cmd/configcmd/test.go create mode 100644 internal/cmd/configcmd/test_test.go diff --git a/internal/cmd/configcmd/clear.go b/internal/cmd/configcmd/clear.go new file mode 100644 index 0000000..290f190 --- /dev/null +++ b/internal/cmd/configcmd/clear.go @@ -0,0 +1,66 @@ +package configcmd + +import ( + "fmt" + "os" + + "github.com/fatih/color" + "github.com/spf13/cobra" + + "github.com/open-cli-collective/confluence-cli/internal/config" +) + +// NewCmdClear creates the config clear command. +func NewCmdClear() *cobra.Command { + cmd := &cobra.Command{ + Use: "clear", + Short: "Remove stored configuration", + Long: `Delete the cfl configuration file. Environment variables will still be used if set.`, + Example: ` # Clear config + cfl config clear`, + RunE: func(cmd *cobra.Command, _ []string) error { + noColor, _ := cmd.Flags().GetBool("no-color") + return runClear(noColor) + }, + } + + return cmd +} + +func runClear(noColor bool) error { + if noColor { + color.NoColor = true + } + + configPath := config.DefaultConfigPath() + + err := os.Remove(configPath) + if err != nil && !os.IsNotExist(err) { + return fmt.Errorf("failed to remove config file: %w", err) + } + + green := color.New(color.FgGreen) + dim := color.New(color.Faint) + + if os.IsNotExist(err) { + green.Printf("✓ No config file to remove\n") + } else { + green.Printf("✓ Configuration cleared from %s\n", configPath) + } + + // Check if env vars are set + envVars := []string{"CFL_URL", "CFL_EMAIL", "CFL_API_TOKEN", "CFL_DEFAULT_SPACE", + "ATLASSIAN_URL", "ATLASSIAN_EMAIL", "ATLASSIAN_API_TOKEN"} + var activeVars []string + for _, v := range envVars { + if os.Getenv(v) != "" { + activeVars = append(activeVars, v) + } + } + + if len(activeVars) > 0 { + dim.Printf("\nNote: Environment variables will still be used: %s\n", fmt.Sprintf("%v", activeVars)) + } + + return nil +} diff --git a/internal/cmd/configcmd/clear_test.go b/internal/cmd/configcmd/clear_test.go new file mode 100644 index 0000000..b046285 --- /dev/null +++ b/internal/cmd/configcmd/clear_test.go @@ -0,0 +1,57 @@ +package configcmd + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/open-cli-collective/confluence-cli/internal/config" +) + +func TestRunClear_WithExistingConfig(t *testing.T) { + tmpDir := t.TempDir() + xdgDir := filepath.Join(tmpDir, "cfl") + os.MkdirAll(xdgDir, 0755) + + origXDG := os.Getenv("XDG_CONFIG_HOME") + os.Setenv("XDG_CONFIG_HOME", tmpDir) + defer os.Setenv("XDG_CONFIG_HOME", origXDG) + + cfg := &config.Config{ + URL: "https://test.atlassian.net/wiki", + Email: "test@example.com", + APIToken: "test-token", + } + configPath := filepath.Join(xdgDir, "config.yml") + require.NoError(t, cfg.Save(configPath)) + + err := runClear(true) + require.NoError(t, err) + + // Verify file is deleted + _, err = os.Stat(configPath) + assert.True(t, os.IsNotExist(err)) +} + +func TestRunClear_NoConfigFile(t *testing.T) { + origXDG := os.Getenv("XDG_CONFIG_HOME") + os.Setenv("XDG_CONFIG_HOME", t.TempDir()) + defer os.Setenv("XDG_CONFIG_HOME", origXDG) + + // Should not error even if file doesn't exist + err := runClear(true) + require.NoError(t, err) +} + +func TestRunClear_Idempotent(t *testing.T) { + origXDG := os.Getenv("XDG_CONFIG_HOME") + os.Setenv("XDG_CONFIG_HOME", t.TempDir()) + defer os.Setenv("XDG_CONFIG_HOME", origXDG) + + // Running twice should succeed + require.NoError(t, runClear(true)) + require.NoError(t, runClear(true)) +} diff --git a/internal/cmd/configcmd/config.go b/internal/cmd/configcmd/config.go new file mode 100644 index 0000000..bb84ce7 --- /dev/null +++ b/internal/cmd/configcmd/config.go @@ -0,0 +1,21 @@ +// Package configcmd provides config management commands. +package configcmd + +import ( + "github.com/spf13/cobra" +) + +// NewCmdConfig creates the config command. +func NewCmdConfig() *cobra.Command { + cmd := &cobra.Command{ + Use: "config", + Short: "Manage cfl configuration", + Long: `Commands for viewing, testing, and clearing cfl configuration.`, + } + + cmd.AddCommand(NewCmdShow()) + cmd.AddCommand(NewCmdTest()) + cmd.AddCommand(NewCmdClear()) + + return cmd +} diff --git a/internal/cmd/configcmd/show.go b/internal/cmd/configcmd/show.go new file mode 100644 index 0000000..0c35ba4 --- /dev/null +++ b/internal/cmd/configcmd/show.go @@ -0,0 +1,95 @@ +package configcmd + +import ( + "fmt" + "os" + "strings" + + "github.com/fatih/color" + "github.com/spf13/cobra" + + "github.com/open-cli-collective/confluence-cli/internal/config" +) + +// NewCmdShow creates the config show command. +func NewCmdShow() *cobra.Command { + cmd := &cobra.Command{ + Use: "show", + Short: "Display current configuration", + Long: `Display the current cfl configuration with credential source indicators.`, + Example: ` # Show current config + cfl config show`, + RunE: func(cmd *cobra.Command, _ []string) error { + noColor, _ := cmd.Flags().GetBool("no-color") + return runShow(noColor) + }, + } + + return cmd +} + +func runShow(noColor bool) error { + if noColor { + color.NoColor = true + } + + configPath := config.DefaultConfigPath() + + // Load file config (may not exist) + fileCfg, fileErr := config.Load(configPath) + if fileErr != nil { + fileCfg = &config.Config{} + } + + // Load full config with env overrides + cfg, _ := config.LoadWithEnv(configPath) + + bold := color.New(color.Bold) + dim := color.New(color.Faint) + + printField := func(label, value, fileValue string, envVars ...string) { + bold.Printf("%-12s", label+":") + if value == "" { + dim.Println("-") + return + } + + // Mask tokens + display := value + if strings.Contains(strings.ToLower(label), "token") && len(value) > 8 { + display = value[:4] + strings.Repeat("*", len(value)-8) + value[len(value)-4:] + } + + fmt.Print(display) + + // Determine source + source := "config" + if fileErr != nil { + source = "-" + } + for _, envVar := range envVars { + if v := os.Getenv(envVar); v != "" && v == value { + source = envVar + break + } + } + if fileValue != value && source == "config" { + source = "-" + } + + dim.Printf(" (source: %s)\n", source) + } + + printField("URL", cfg.URL, fileCfg.URL, "CFL_URL", "ATLASSIAN_URL") + printField("Email", cfg.Email, fileCfg.Email, "CFL_EMAIL", "ATLASSIAN_EMAIL") + printField("API Token", cfg.APIToken, fileCfg.APIToken, "CFL_API_TOKEN", "ATLASSIAN_API_TOKEN") + printField("Space", cfg.DefaultSpace, fileCfg.DefaultSpace, "CFL_DEFAULT_SPACE") + + fmt.Println() + dim.Printf("Config file: %s\n", configPath) + if fileErr != nil { + dim.Println("(file not found)") + } + + return nil +} diff --git a/internal/cmd/configcmd/show_test.go b/internal/cmd/configcmd/show_test.go new file mode 100644 index 0000000..3c80e26 --- /dev/null +++ b/internal/cmd/configcmd/show_test.go @@ -0,0 +1,55 @@ +package configcmd + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/open-cli-collective/confluence-cli/internal/config" +) + +func TestRunShow_WithConfigFile(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.yml") + + cfg := &config.Config{ + URL: "https://test.atlassian.net/wiki", + Email: "test@example.com", + APIToken: "test-token-value", + DefaultSpace: "DEV", + } + require.NoError(t, cfg.Save(configPath)) + + // Override default config path for test + origXDG := os.Getenv("XDG_CONFIG_HOME") + os.Setenv("XDG_CONFIG_HOME", tmpDir) + defer os.Setenv("XDG_CONFIG_HOME", origXDG) + + // Ensure XDG path matches + xdgDir := filepath.Join(tmpDir, "cfl") + os.MkdirAll(xdgDir, 0755) + xdgPath := filepath.Join(xdgDir, "config.yml") + require.NoError(t, cfg.Save(xdgPath)) + + err := runShow(true) + require.NoError(t, err) +} + +func TestRunShow_NoConfigFile(t *testing.T) { + // Clear env vars + for _, v := range []string{"CFL_URL", "CFL_EMAIL", "CFL_API_TOKEN", "CFL_DEFAULT_SPACE", + "ATLASSIAN_URL", "ATLASSIAN_EMAIL", "ATLASSIAN_API_TOKEN"} { + orig := os.Getenv(v) + os.Unsetenv(v) + defer os.Setenv(v, orig) + } + + origXDG := os.Getenv("XDG_CONFIG_HOME") + os.Setenv("XDG_CONFIG_HOME", t.TempDir()) + defer os.Setenv("XDG_CONFIG_HOME", origXDG) + + err := runShow(true) + require.NoError(t, err) +} diff --git a/internal/cmd/configcmd/test.go b/internal/cmd/configcmd/test.go new file mode 100644 index 0000000..49a8e3c --- /dev/null +++ b/internal/cmd/configcmd/test.go @@ -0,0 +1,98 @@ +package configcmd + +import ( + "fmt" + "net/http" + "time" + + "github.com/fatih/color" + "github.com/spf13/cobra" + + "github.com/open-cli-collective/confluence-cli/internal/config" +) + +// NewCmdTest creates the config test command. +func NewCmdTest() *cobra.Command { + cmd := &cobra.Command{ + Use: "test", + Short: "Test connectivity with configured credentials", + Long: `Test that cfl can connect to your Confluence instance with the current configuration.`, + Example: ` # Test connection + cfl config test`, + RunE: func(cmd *cobra.Command, _ []string) error { + noColor, _ := cmd.Flags().GetBool("no-color") + return runTest(noColor, nil) + }, + } + + return cmd +} + +func runTest(noColor bool, httpClient *http.Client, cfgs ...*config.Config) error { + if noColor { + color.NoColor = true + } + + var cfg *config.Config + if len(cfgs) > 0 && cfgs[0] != nil { + cfg = cfgs[0] + } else { + var err error + cfg, err = config.LoadWithEnv(config.DefaultConfigPath()) + if err != nil { + return fmt.Errorf("failed to load config: %w (run 'cfl init' to configure)", err) + } + + if err := cfg.Validate(); err != nil { + return fmt.Errorf("invalid config: %w (run 'cfl init' to configure)", err) + } + } + + green := color.New(color.FgGreen) + red := color.New(color.FgRed) + + fmt.Printf("Testing connection to %s...\n", cfg.URL) + + if httpClient == nil { + httpClient = &http.Client{Timeout: 10 * time.Second} + } + + req, err := http.NewRequest("GET", cfg.URL+"/api/v2/spaces?limit=1", nil) + if err != nil { + return err + } + + req.SetBasicAuth(cfg.Email, cfg.APIToken) + req.Header.Set("Accept", "application/json") + + resp, err := httpClient.Do(req) + if err != nil { + red.Println("✗ Connection failed:", err) + fmt.Println("\nCheck your URL with: cfl config show") + fmt.Println("Reconfigure with: cfl init") + return fmt.Errorf("connection failed: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode == 401 { + red.Println("✗ Authentication failed: 401 Unauthorized") + fmt.Println("\nCheck your credentials with: cfl config show") + fmt.Println("Reconfigure with: cfl init") + return fmt.Errorf("authentication failed") + } + if resp.StatusCode == 403 { + red.Println("✗ Access denied: 403 Forbidden") + fmt.Println("\nCheck your permissions.") + return fmt.Errorf("access denied") + } + if resp.StatusCode != 200 { + red.Printf("✗ Unexpected response: %d\n", resp.StatusCode) + return fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + green.Println("✓ Authentication successful") + green.Println("✓ API access verified") + fmt.Printf("\nAuthenticated as: %s\n", cfg.Email) + + return nil +} diff --git a/internal/cmd/configcmd/test_test.go b/internal/cmd/configcmd/test_test.go new file mode 100644 index 0000000..bd2baab --- /dev/null +++ b/internal/cmd/configcmd/test_test.go @@ -0,0 +1,65 @@ +package configcmd + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/open-cli-collective/confluence-cli/internal/config" +) + +func testConfig(serverURL string) *config.Config { + return &config.Config{ + URL: serverURL, + Email: "test@example.com", + APIToken: "test-token", + } +} + +func TestRunTest_Success(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"results": []}`)) + })) + defer server.Close() + + err := runTest(true, nil, testConfig(server.URL)) + require.NoError(t, err) +} + +func TestRunTest_AuthFailure(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + w.Write([]byte(`{"message": "Unauthorized"}`)) + })) + defer server.Close() + + err := runTest(true, nil, testConfig(server.URL)) + require.Error(t, err) + assert.Contains(t, err.Error(), "authentication failed") +} + +func TestRunTest_Forbidden(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusForbidden) + })) + defer server.Close() + + err := runTest(true, nil, testConfig(server.URL)) + require.Error(t, err) + assert.Contains(t, err.Error(), "access denied") +} + +func TestRunTest_ServerError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer server.Close() + + err := runTest(true, nil, testConfig(server.URL)) + require.Error(t, err) + assert.Contains(t, err.Error(), "unexpected status code: 500") +} diff --git a/internal/cmd/root/root.go b/internal/cmd/root/root.go index 741f0a2..4085df1 100644 --- a/internal/cmd/root/root.go +++ b/internal/cmd/root/root.go @@ -6,6 +6,7 @@ import ( "github.com/open-cli-collective/confluence-cli/internal/cmd/attachment" "github.com/open-cli-collective/confluence-cli/internal/cmd/completion" + "github.com/open-cli-collective/confluence-cli/internal/cmd/configcmd" initcmd "github.com/open-cli-collective/confluence-cli/internal/cmd/init" "github.com/open-cli-collective/confluence-cli/internal/cmd/page" "github.com/open-cli-collective/confluence-cli/internal/cmd/search" @@ -39,6 +40,7 @@ Get started by running: cfl init`, // Subcommands cmd.AddCommand(initcmd.NewCmdInit()) + cmd.AddCommand(configcmd.NewCmdConfig()) cmd.AddCommand(page.NewCmdPage()) cmd.AddCommand(space.NewCmdSpace()) cmd.AddCommand(attachment.NewCmdAttachment()) From 565ef3af515a32763e5d0904ec3ce3aaa5b8d727 Mon Sep 17 00:00:00 2001 From: piekstra Date: Tue, 27 Jan 2026 20:50:27 -0500 Subject: [PATCH 2/2] fix: check error return values from color print functions --- internal/cmd/configcmd/clear.go | 6 +++--- internal/cmd/configcmd/show.go | 10 +++++----- internal/cmd/configcmd/test.go | 12 ++++++------ 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/internal/cmd/configcmd/clear.go b/internal/cmd/configcmd/clear.go index 290f190..94d915f 100644 --- a/internal/cmd/configcmd/clear.go +++ b/internal/cmd/configcmd/clear.go @@ -43,9 +43,9 @@ func runClear(noColor bool) error { dim := color.New(color.Faint) if os.IsNotExist(err) { - green.Printf("✓ No config file to remove\n") + _, _ = green.Printf("✓ No config file to remove\n") } else { - green.Printf("✓ Configuration cleared from %s\n", configPath) + _, _ = green.Printf("✓ Configuration cleared from %s\n", configPath) } // Check if env vars are set @@ -59,7 +59,7 @@ func runClear(noColor bool) error { } if len(activeVars) > 0 { - dim.Printf("\nNote: Environment variables will still be used: %s\n", fmt.Sprintf("%v", activeVars)) + _, _ = dim.Printf("\nNote: Environment variables will still be used: %s\n", fmt.Sprintf("%v", activeVars)) } return nil diff --git a/internal/cmd/configcmd/show.go b/internal/cmd/configcmd/show.go index 0c35ba4..39eb4bc 100644 --- a/internal/cmd/configcmd/show.go +++ b/internal/cmd/configcmd/show.go @@ -48,9 +48,9 @@ func runShow(noColor bool) error { dim := color.New(color.Faint) printField := func(label, value, fileValue string, envVars ...string) { - bold.Printf("%-12s", label+":") + _, _ = bold.Printf("%-12s", label+":") if value == "" { - dim.Println("-") + _, _ = dim.Println("-") return } @@ -77,7 +77,7 @@ func runShow(noColor bool) error { source = "-" } - dim.Printf(" (source: %s)\n", source) + _, _ = dim.Printf(" (source: %s)\n", source) } printField("URL", cfg.URL, fileCfg.URL, "CFL_URL", "ATLASSIAN_URL") @@ -86,9 +86,9 @@ func runShow(noColor bool) error { printField("Space", cfg.DefaultSpace, fileCfg.DefaultSpace, "CFL_DEFAULT_SPACE") fmt.Println() - dim.Printf("Config file: %s\n", configPath) + _, _ = dim.Printf("Config file: %s\n", configPath) if fileErr != nil { - dim.Println("(file not found)") + _, _ = dim.Println("(file not found)") } return nil diff --git a/internal/cmd/configcmd/test.go b/internal/cmd/configcmd/test.go index 49a8e3c..4e09c68 100644 --- a/internal/cmd/configcmd/test.go +++ b/internal/cmd/configcmd/test.go @@ -67,7 +67,7 @@ func runTest(noColor bool, httpClient *http.Client, cfgs ...*config.Config) erro resp, err := httpClient.Do(req) if err != nil { - red.Println("✗ Connection failed:", err) + _, _ = red.Println("✗ Connection failed:", err) fmt.Println("\nCheck your URL with: cfl config show") fmt.Println("Reconfigure with: cfl init") return fmt.Errorf("connection failed: %w", err) @@ -75,23 +75,23 @@ func runTest(noColor bool, httpClient *http.Client, cfgs ...*config.Config) erro defer func() { _ = resp.Body.Close() }() if resp.StatusCode == 401 { - red.Println("✗ Authentication failed: 401 Unauthorized") + _, _ = red.Println("✗ Authentication failed: 401 Unauthorized") fmt.Println("\nCheck your credentials with: cfl config show") fmt.Println("Reconfigure with: cfl init") return fmt.Errorf("authentication failed") } if resp.StatusCode == 403 { - red.Println("✗ Access denied: 403 Forbidden") + _, _ = red.Println("✗ Access denied: 403 Forbidden") fmt.Println("\nCheck your permissions.") return fmt.Errorf("access denied") } if resp.StatusCode != 200 { - red.Printf("✗ Unexpected response: %d\n", resp.StatusCode) + _, _ = red.Printf("✗ Unexpected response: %d\n", resp.StatusCode) return fmt.Errorf("unexpected status code: %d", resp.StatusCode) } - green.Println("✓ Authentication successful") - green.Println("✓ API access verified") + _, _ = green.Println("✓ Authentication successful") + _, _ = green.Println("✓ API access verified") fmt.Printf("\nAuthenticated as: %s\n", cfg.Email) return nil