Skip to content
This repository was archived by the owner on Feb 6, 2026. It is now read-only.

Commit 96a58b0

Browse files
authored
feat: add config show, test, and clear commands (#106)
* 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 * fix: check error return values from color print functions
1 parent 873c329 commit 96a58b0

8 files changed

Lines changed: 459 additions & 0 deletions

File tree

internal/cmd/configcmd/clear.go

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
package configcmd
2+
3+
import (
4+
"fmt"
5+
"os"
6+
7+
"github.com/fatih/color"
8+
"github.com/spf13/cobra"
9+
10+
"github.com/open-cli-collective/confluence-cli/internal/config"
11+
)
12+
13+
// NewCmdClear creates the config clear command.
14+
func NewCmdClear() *cobra.Command {
15+
cmd := &cobra.Command{
16+
Use: "clear",
17+
Short: "Remove stored configuration",
18+
Long: `Delete the cfl configuration file. Environment variables will still be used if set.`,
19+
Example: ` # Clear config
20+
cfl config clear`,
21+
RunE: func(cmd *cobra.Command, _ []string) error {
22+
noColor, _ := cmd.Flags().GetBool("no-color")
23+
return runClear(noColor)
24+
},
25+
}
26+
27+
return cmd
28+
}
29+
30+
func runClear(noColor bool) error {
31+
if noColor {
32+
color.NoColor = true
33+
}
34+
35+
configPath := config.DefaultConfigPath()
36+
37+
err := os.Remove(configPath)
38+
if err != nil && !os.IsNotExist(err) {
39+
return fmt.Errorf("failed to remove config file: %w", err)
40+
}
41+
42+
green := color.New(color.FgGreen)
43+
dim := color.New(color.Faint)
44+
45+
if os.IsNotExist(err) {
46+
_, _ = green.Printf("✓ No config file to remove\n")
47+
} else {
48+
_, _ = green.Printf("✓ Configuration cleared from %s\n", configPath)
49+
}
50+
51+
// Check if env vars are set
52+
envVars := []string{"CFL_URL", "CFL_EMAIL", "CFL_API_TOKEN", "CFL_DEFAULT_SPACE",
53+
"ATLASSIAN_URL", "ATLASSIAN_EMAIL", "ATLASSIAN_API_TOKEN"}
54+
var activeVars []string
55+
for _, v := range envVars {
56+
if os.Getenv(v) != "" {
57+
activeVars = append(activeVars, v)
58+
}
59+
}
60+
61+
if len(activeVars) > 0 {
62+
_, _ = dim.Printf("\nNote: Environment variables will still be used: %s\n", fmt.Sprintf("%v", activeVars))
63+
}
64+
65+
return nil
66+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
package configcmd
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"testing"
7+
8+
"github.com/stretchr/testify/assert"
9+
"github.com/stretchr/testify/require"
10+
11+
"github.com/open-cli-collective/confluence-cli/internal/config"
12+
)
13+
14+
func TestRunClear_WithExistingConfig(t *testing.T) {
15+
tmpDir := t.TempDir()
16+
xdgDir := filepath.Join(tmpDir, "cfl")
17+
os.MkdirAll(xdgDir, 0755)
18+
19+
origXDG := os.Getenv("XDG_CONFIG_HOME")
20+
os.Setenv("XDG_CONFIG_HOME", tmpDir)
21+
defer os.Setenv("XDG_CONFIG_HOME", origXDG)
22+
23+
cfg := &config.Config{
24+
URL: "https://test.atlassian.net/wiki",
25+
Email: "test@example.com",
26+
APIToken: "test-token",
27+
}
28+
configPath := filepath.Join(xdgDir, "config.yml")
29+
require.NoError(t, cfg.Save(configPath))
30+
31+
err := runClear(true)
32+
require.NoError(t, err)
33+
34+
// Verify file is deleted
35+
_, err = os.Stat(configPath)
36+
assert.True(t, os.IsNotExist(err))
37+
}
38+
39+
func TestRunClear_NoConfigFile(t *testing.T) {
40+
origXDG := os.Getenv("XDG_CONFIG_HOME")
41+
os.Setenv("XDG_CONFIG_HOME", t.TempDir())
42+
defer os.Setenv("XDG_CONFIG_HOME", origXDG)
43+
44+
// Should not error even if file doesn't exist
45+
err := runClear(true)
46+
require.NoError(t, err)
47+
}
48+
49+
func TestRunClear_Idempotent(t *testing.T) {
50+
origXDG := os.Getenv("XDG_CONFIG_HOME")
51+
os.Setenv("XDG_CONFIG_HOME", t.TempDir())
52+
defer os.Setenv("XDG_CONFIG_HOME", origXDG)
53+
54+
// Running twice should succeed
55+
require.NoError(t, runClear(true))
56+
require.NoError(t, runClear(true))
57+
}

internal/cmd/configcmd/config.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
// Package configcmd provides config management commands.
2+
package configcmd
3+
4+
import (
5+
"github.com/spf13/cobra"
6+
)
7+
8+
// NewCmdConfig creates the config command.
9+
func NewCmdConfig() *cobra.Command {
10+
cmd := &cobra.Command{
11+
Use: "config",
12+
Short: "Manage cfl configuration",
13+
Long: `Commands for viewing, testing, and clearing cfl configuration.`,
14+
}
15+
16+
cmd.AddCommand(NewCmdShow())
17+
cmd.AddCommand(NewCmdTest())
18+
cmd.AddCommand(NewCmdClear())
19+
20+
return cmd
21+
}

internal/cmd/configcmd/show.go

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
package configcmd
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"strings"
7+
8+
"github.com/fatih/color"
9+
"github.com/spf13/cobra"
10+
11+
"github.com/open-cli-collective/confluence-cli/internal/config"
12+
)
13+
14+
// NewCmdShow creates the config show command.
15+
func NewCmdShow() *cobra.Command {
16+
cmd := &cobra.Command{
17+
Use: "show",
18+
Short: "Display current configuration",
19+
Long: `Display the current cfl configuration with credential source indicators.`,
20+
Example: ` # Show current config
21+
cfl config show`,
22+
RunE: func(cmd *cobra.Command, _ []string) error {
23+
noColor, _ := cmd.Flags().GetBool("no-color")
24+
return runShow(noColor)
25+
},
26+
}
27+
28+
return cmd
29+
}
30+
31+
func runShow(noColor bool) error {
32+
if noColor {
33+
color.NoColor = true
34+
}
35+
36+
configPath := config.DefaultConfigPath()
37+
38+
// Load file config (may not exist)
39+
fileCfg, fileErr := config.Load(configPath)
40+
if fileErr != nil {
41+
fileCfg = &config.Config{}
42+
}
43+
44+
// Load full config with env overrides
45+
cfg, _ := config.LoadWithEnv(configPath)
46+
47+
bold := color.New(color.Bold)
48+
dim := color.New(color.Faint)
49+
50+
printField := func(label, value, fileValue string, envVars ...string) {
51+
_, _ = bold.Printf("%-12s", label+":")
52+
if value == "" {
53+
_, _ = dim.Println("-")
54+
return
55+
}
56+
57+
// Mask tokens
58+
display := value
59+
if strings.Contains(strings.ToLower(label), "token") && len(value) > 8 {
60+
display = value[:4] + strings.Repeat("*", len(value)-8) + value[len(value)-4:]
61+
}
62+
63+
fmt.Print(display)
64+
65+
// Determine source
66+
source := "config"
67+
if fileErr != nil {
68+
source = "-"
69+
}
70+
for _, envVar := range envVars {
71+
if v := os.Getenv(envVar); v != "" && v == value {
72+
source = envVar
73+
break
74+
}
75+
}
76+
if fileValue != value && source == "config" {
77+
source = "-"
78+
}
79+
80+
_, _ = dim.Printf(" (source: %s)\n", source)
81+
}
82+
83+
printField("URL", cfg.URL, fileCfg.URL, "CFL_URL", "ATLASSIAN_URL")
84+
printField("Email", cfg.Email, fileCfg.Email, "CFL_EMAIL", "ATLASSIAN_EMAIL")
85+
printField("API Token", cfg.APIToken, fileCfg.APIToken, "CFL_API_TOKEN", "ATLASSIAN_API_TOKEN")
86+
printField("Space", cfg.DefaultSpace, fileCfg.DefaultSpace, "CFL_DEFAULT_SPACE")
87+
88+
fmt.Println()
89+
_, _ = dim.Printf("Config file: %s\n", configPath)
90+
if fileErr != nil {
91+
_, _ = dim.Println("(file not found)")
92+
}
93+
94+
return nil
95+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
package configcmd
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"testing"
7+
8+
"github.com/stretchr/testify/require"
9+
10+
"github.com/open-cli-collective/confluence-cli/internal/config"
11+
)
12+
13+
func TestRunShow_WithConfigFile(t *testing.T) {
14+
tmpDir := t.TempDir()
15+
configPath := filepath.Join(tmpDir, "config.yml")
16+
17+
cfg := &config.Config{
18+
URL: "https://test.atlassian.net/wiki",
19+
Email: "test@example.com",
20+
APIToken: "test-token-value",
21+
DefaultSpace: "DEV",
22+
}
23+
require.NoError(t, cfg.Save(configPath))
24+
25+
// Override default config path for test
26+
origXDG := os.Getenv("XDG_CONFIG_HOME")
27+
os.Setenv("XDG_CONFIG_HOME", tmpDir)
28+
defer os.Setenv("XDG_CONFIG_HOME", origXDG)
29+
30+
// Ensure XDG path matches
31+
xdgDir := filepath.Join(tmpDir, "cfl")
32+
os.MkdirAll(xdgDir, 0755)
33+
xdgPath := filepath.Join(xdgDir, "config.yml")
34+
require.NoError(t, cfg.Save(xdgPath))
35+
36+
err := runShow(true)
37+
require.NoError(t, err)
38+
}
39+
40+
func TestRunShow_NoConfigFile(t *testing.T) {
41+
// Clear env vars
42+
for _, v := range []string{"CFL_URL", "CFL_EMAIL", "CFL_API_TOKEN", "CFL_DEFAULT_SPACE",
43+
"ATLASSIAN_URL", "ATLASSIAN_EMAIL", "ATLASSIAN_API_TOKEN"} {
44+
orig := os.Getenv(v)
45+
os.Unsetenv(v)
46+
defer os.Setenv(v, orig)
47+
}
48+
49+
origXDG := os.Getenv("XDG_CONFIG_HOME")
50+
os.Setenv("XDG_CONFIG_HOME", t.TempDir())
51+
defer os.Setenv("XDG_CONFIG_HOME", origXDG)
52+
53+
err := runShow(true)
54+
require.NoError(t, err)
55+
}

internal/cmd/configcmd/test.go

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
package configcmd
2+
3+
import (
4+
"fmt"
5+
"net/http"
6+
"time"
7+
8+
"github.com/fatih/color"
9+
"github.com/spf13/cobra"
10+
11+
"github.com/open-cli-collective/confluence-cli/internal/config"
12+
)
13+
14+
// NewCmdTest creates the config test command.
15+
func NewCmdTest() *cobra.Command {
16+
cmd := &cobra.Command{
17+
Use: "test",
18+
Short: "Test connectivity with configured credentials",
19+
Long: `Test that cfl can connect to your Confluence instance with the current configuration.`,
20+
Example: ` # Test connection
21+
cfl config test`,
22+
RunE: func(cmd *cobra.Command, _ []string) error {
23+
noColor, _ := cmd.Flags().GetBool("no-color")
24+
return runTest(noColor, nil)
25+
},
26+
}
27+
28+
return cmd
29+
}
30+
31+
func runTest(noColor bool, httpClient *http.Client, cfgs ...*config.Config) error {
32+
if noColor {
33+
color.NoColor = true
34+
}
35+
36+
var cfg *config.Config
37+
if len(cfgs) > 0 && cfgs[0] != nil {
38+
cfg = cfgs[0]
39+
} else {
40+
var err error
41+
cfg, err = config.LoadWithEnv(config.DefaultConfigPath())
42+
if err != nil {
43+
return fmt.Errorf("failed to load config: %w (run 'cfl init' to configure)", err)
44+
}
45+
46+
if err := cfg.Validate(); err != nil {
47+
return fmt.Errorf("invalid config: %w (run 'cfl init' to configure)", err)
48+
}
49+
}
50+
51+
green := color.New(color.FgGreen)
52+
red := color.New(color.FgRed)
53+
54+
fmt.Printf("Testing connection to %s...\n", cfg.URL)
55+
56+
if httpClient == nil {
57+
httpClient = &http.Client{Timeout: 10 * time.Second}
58+
}
59+
60+
req, err := http.NewRequest("GET", cfg.URL+"/api/v2/spaces?limit=1", nil)
61+
if err != nil {
62+
return err
63+
}
64+
65+
req.SetBasicAuth(cfg.Email, cfg.APIToken)
66+
req.Header.Set("Accept", "application/json")
67+
68+
resp, err := httpClient.Do(req)
69+
if err != nil {
70+
_, _ = red.Println("✗ Connection failed:", err)
71+
fmt.Println("\nCheck your URL with: cfl config show")
72+
fmt.Println("Reconfigure with: cfl init")
73+
return fmt.Errorf("connection failed: %w", err)
74+
}
75+
defer func() { _ = resp.Body.Close() }()
76+
77+
if resp.StatusCode == 401 {
78+
_, _ = red.Println("✗ Authentication failed: 401 Unauthorized")
79+
fmt.Println("\nCheck your credentials with: cfl config show")
80+
fmt.Println("Reconfigure with: cfl init")
81+
return fmt.Errorf("authentication failed")
82+
}
83+
if resp.StatusCode == 403 {
84+
_, _ = red.Println("✗ Access denied: 403 Forbidden")
85+
fmt.Println("\nCheck your permissions.")
86+
return fmt.Errorf("access denied")
87+
}
88+
if resp.StatusCode != 200 {
89+
_, _ = red.Printf("✗ Unexpected response: %d\n", resp.StatusCode)
90+
return fmt.Errorf("unexpected status code: %d", resp.StatusCode)
91+
}
92+
93+
_, _ = green.Println("✓ Authentication successful")
94+
_, _ = green.Println("✓ API access verified")
95+
fmt.Printf("\nAuthenticated as: %s\n", cfg.Email)
96+
97+
return nil
98+
}

0 commit comments

Comments
 (0)