diff --git a/internal/cmd/configcmd/clear.go b/internal/cmd/configcmd/clear.go new file mode 100644 index 0000000..94d915f --- /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..39eb4bc --- /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..4e09c68 --- /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())