feat: add doctor command

This commit is contained in:
2026-03-23 22:46:31 +02:00
parent 0a391224c9
commit 1b4d72443d
4 changed files with 396 additions and 0 deletions

View File

@@ -418,6 +418,22 @@ Removes the configuration file (stored credentials) and clears all cached data.
---
### Diagnostics
```bash
cospend doctor
```
Runs health checks on your setup:
- Config file exists and is readable
- Required fields (domain, user, password) are set
- Nextcloud server is reachable
- Authentication credentials are valid
- Default project (if configured) is accessible
---
## Caching
Project data (members, categories, payment methods, currencies) is cached locally to avoid repeated

154
cmd/doctor.go Normal file
View File

@@ -0,0 +1,154 @@
package cmd
import (
"fmt"
"net/http"
"strings"
"time"
"github.com/chenasraf/cospend-cli/internal/api"
"github.com/chenasraf/cospend-cli/internal/config"
"github.com/spf13/cobra"
)
// NewDoctorCommand creates the doctor command
func NewDoctorCommand() *cobra.Command {
return &cobra.Command{
Use: "doctor",
Short: "Check configuration, connectivity, and authentication",
Long: `Run diagnostic checks to verify that your cospend-cli setup is working correctly.
Checks performed:
- Config file exists and is readable
- Required fields (domain, user, password) are set
- Nextcloud server is reachable
- Authentication credentials are valid
- Default project (if configured) is accessible`,
RunE: runDoctor,
}
}
type checkResult struct {
name string
ok bool
detail string
}
func runDoctor(cmd *cobra.Command, _ []string) error {
cmd.SilenceUsage = true
out := cmd.OutOrStdout()
var results []checkResult
var cfg *config.Config
var canConnect bool
// Check 1: Config file
configPath := config.GetConfigPath()
if configPath == "" {
results = append(results, checkResult{"Config file", false, "not found (run 'cospend init')"})
} else {
var err error
cfg, err = config.LoadFromFile(configPath)
if err != nil {
results = append(results, checkResult{"Config file", false, fmt.Sprintf("error reading %s: %v", configPath, err)})
} else {
results = append(results, checkResult{"Config file", true, configPath})
}
}
// Check 2: Required fields
if cfg != nil {
missing := []string{}
if cfg.Domain == "" {
missing = append(missing, "domain")
}
if cfg.User == "" {
missing = append(missing, "user")
}
if cfg.Password == "" {
missing = append(missing, "password")
}
if len(missing) > 0 {
results = append(results, checkResult{"Required fields", false, fmt.Sprintf("missing: %s", joinWords(missing))})
} else {
results = append(results, checkResult{"Required fields", true, "domain, user, password all set"})
}
}
// Check 3: Server connectivity
if cfg != nil && cfg.Domain != "" {
baseURL := config.NormalizeURL(cfg.Domain)
httpClient := &http.Client{Timeout: 10 * time.Second}
resp, err := httpClient.Get(baseURL + "/status.php")
if err != nil {
results = append(results, checkResult{"Server reachable", false, err.Error()})
} else {
_ = resp.Body.Close()
if resp.StatusCode == http.StatusOK {
results = append(results, checkResult{"Server reachable", true, baseURL})
canConnect = true
} else {
results = append(results, checkResult{"Server reachable", false, fmt.Sprintf("HTTP %d from %s/status.php", resp.StatusCode, baseURL)})
}
}
}
// Check 4: Authentication
if cfg != nil && cfg.Domain != "" && cfg.User != "" && cfg.Password != "" && canConnect {
client := api.NewClient(cfg)
userInfo, err := client.GetUserInfo()
if err != nil {
results = append(results, checkResult{"Authentication", false, fmt.Sprintf("failed: %v", err)})
} else {
detail := fmt.Sprintf("logged in as %s", cfg.User)
if userInfo.Locale != "" {
detail += fmt.Sprintf(" (locale: %s)", userInfo.Locale)
}
results = append(results, checkResult{"Authentication", true, detail})
}
}
// Check 5: Default project
if cfg != nil && cfg.DefaultProject != "" && canConnect {
client := api.NewClient(cfg)
project, err := client.GetProject(cfg.DefaultProject)
if err != nil {
results = append(results, checkResult{"Default project", false, fmt.Sprintf("%s: %v", cfg.DefaultProject, err)})
} else {
results = append(results, checkResult{"Default project", true, fmt.Sprintf("%s (%s)", cfg.DefaultProject, project.Name)})
}
} else if cfg != nil && cfg.DefaultProject == "" {
results = append(results, checkResult{"Default project", true, "not configured (optional)"})
}
// Render results
allOK := true
for _, r := range results {
if r.ok {
_, _ = fmt.Fprintf(out, " [ok] %-17s %s\n", r.name, r.detail)
} else {
_, _ = fmt.Fprintf(out, " [!!] %-17s %s\n", r.name, r.detail)
allOK = false
}
}
_, _ = fmt.Fprintln(out)
if allOK {
_, _ = fmt.Fprintln(out, "All checks passed.")
} else {
_, _ = fmt.Fprintln(out, "Some checks failed. See above for details.")
}
return nil
}
func joinWords(words []string) string {
switch len(words) {
case 0:
return ""
case 1:
return words[0]
default:
return strings.Join(words[:len(words)-1], ", ") + " and " + words[len(words)-1]
}
}

225
cmd/doctor_test.go Normal file
View File

@@ -0,0 +1,225 @@
package cmd
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/chenasraf/cospend-cli/internal/api"
)
func TestNewDoctorCommand(t *testing.T) {
cmd := NewDoctorCommand()
if cmd.Use != "doctor" {
t.Errorf("Wrong Use: %s", cmd.Use)
}
}
func TestDoctorAllPassing(t *testing.T) {
project := api.Project{
ID: "myproject",
Name: "My Project",
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/status.php":
_, _ = w.Write([]byte(`{"installed":true}`))
case "/ocs/v2.php/cloud/user":
_ = json.NewEncoder(w).Encode(makeOCSResponse(200, map[string]string{"locale": "en_US", "language": "en"}))
case "/ocs/v2.php/apps/cospend/api/v1/projects/myproject":
_ = json.NewEncoder(w).Encode(makeOCSResponse(200, project))
}
}))
defer server.Close()
tempDir := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", tempDir)
t.Setenv("XDG_CACHE_HOME", t.TempDir())
t.Setenv("HOME", tempDir)
configDir := filepath.Join(tempDir, "cospend")
_ = os.MkdirAll(configDir, 0700)
configContent := `{"domain": "` + server.URL + `", "user": "testuser", "password": "testpass", "default_project": "myproject"}`
_ = os.WriteFile(filepath.Join(configDir, "cospend.json"), []byte(configContent), 0600)
cmd := NewDoctorCommand()
var stdout bytes.Buffer
cmd.SetOut(&stdout)
err := cmd.Execute()
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
output := stdout.String()
if !bytes.Contains([]byte(output), []byte("[ok] Config file")) {
t.Errorf("Should show config ok, got: %s", output)
}
if !bytes.Contains([]byte(output), []byte("[ok] Required fields")) {
t.Errorf("Should show fields ok, got: %s", output)
}
if !bytes.Contains([]byte(output), []byte("[ok] Server reachable")) {
t.Errorf("Should show server ok, got: %s", output)
}
if !bytes.Contains([]byte(output), []byte("[ok] Authentication")) {
t.Errorf("Should show auth ok, got: %s", output)
}
if !bytes.Contains([]byte(output), []byte("[ok] Default project")) {
t.Errorf("Should show project ok, got: %s", output)
}
if !bytes.Contains([]byte(output), []byte("All checks passed")) {
t.Errorf("Should show all passed, got: %s", output)
}
}
func TestDoctorNoConfig(t *testing.T) {
tempDir := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", tempDir)
t.Setenv("HOME", tempDir)
cmd := NewDoctorCommand()
var stdout bytes.Buffer
cmd.SetOut(&stdout)
err := cmd.Execute()
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
output := stdout.String()
if !bytes.Contains([]byte(output), []byte("[!!] Config file")) {
t.Errorf("Should show config error, got: %s", output)
}
if !bytes.Contains([]byte(output), []byte("Some checks failed")) {
t.Errorf("Should show failure summary, got: %s", output)
}
}
func TestDoctorMissingFields(t *testing.T) {
tempDir := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", tempDir)
t.Setenv("HOME", tempDir)
configDir := filepath.Join(tempDir, "cospend")
_ = os.MkdirAll(configDir, 0700)
_ = os.WriteFile(filepath.Join(configDir, "cospend.json"), []byte(`{"domain": "https://example.com"}`), 0600)
cmd := NewDoctorCommand()
var stdout bytes.Buffer
cmd.SetOut(&stdout)
err := cmd.Execute()
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
output := stdout.String()
if !bytes.Contains([]byte(output), []byte("[!!] Required fields")) {
t.Errorf("Should show missing fields, got: %s", output)
}
if !bytes.Contains([]byte(output), []byte("user and password")) {
t.Errorf("Should list missing fields, got: %s", output)
}
}
func TestDoctorAuthFailure(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/status.php":
_, _ = w.Write([]byte(`{"installed":true}`))
case "/ocs/v2.php/cloud/user":
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte("Unauthorized"))
}
}))
defer server.Close()
tempDir := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", tempDir)
t.Setenv("XDG_CACHE_HOME", t.TempDir())
t.Setenv("HOME", tempDir)
configDir := filepath.Join(tempDir, "cospend")
_ = os.MkdirAll(configDir, 0700)
configContent := `{"domain": "` + server.URL + `", "user": "testuser", "password": "badpass"}`
_ = os.WriteFile(filepath.Join(configDir, "cospend.json"), []byte(configContent), 0600)
cmd := NewDoctorCommand()
var stdout bytes.Buffer
cmd.SetOut(&stdout)
err := cmd.Execute()
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
output := stdout.String()
if !bytes.Contains([]byte(output), []byte("[ok] Server reachable")) {
t.Errorf("Server should be reachable, got: %s", output)
}
if !bytes.Contains([]byte(output), []byte("[!!] Authentication")) {
t.Errorf("Auth should fail, got: %s", output)
}
}
func TestDoctorNoDefaultProject(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/status.php":
_, _ = w.Write([]byte(`{"installed":true}`))
case "/ocs/v2.php/cloud/user":
_ = json.NewEncoder(w).Encode(makeOCSResponse(200, map[string]string{"locale": "en_US", "language": "en"}))
}
}))
defer server.Close()
tempDir := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", tempDir)
t.Setenv("XDG_CACHE_HOME", t.TempDir())
t.Setenv("HOME", tempDir)
configDir := filepath.Join(tempDir, "cospend")
_ = os.MkdirAll(configDir, 0700)
configContent := `{"domain": "` + server.URL + `", "user": "testuser", "password": "testpass"}`
_ = os.WriteFile(filepath.Join(configDir, "cospend.json"), []byte(configContent), 0600)
cmd := NewDoctorCommand()
var stdout bytes.Buffer
cmd.SetOut(&stdout)
err := cmd.Execute()
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
output := stdout.String()
if !bytes.Contains([]byte(output), []byte("not configured (optional)")) {
t.Errorf("Should show default project as optional, got: %s", output)
}
if !bytes.Contains([]byte(output), []byte("All checks passed")) {
t.Errorf("Should pass with no default project, got: %s", output)
}
}
func TestJoinWords(t *testing.T) {
tests := []struct {
input []string
want string
}{
{nil, ""},
{[]string{"a"}, "a"},
{[]string{"a", "b"}, "a and b"},
{[]string{"a", "b", "c"}, "a, b and c"},
}
for _, tt := range tests {
got := joinWords(tt.input)
if got != tt.want {
t.Errorf("joinWords(%v) = %q, want %q", tt.input, got, tt.want)
}
}
}

View File

@@ -39,6 +39,7 @@ func main() {
rootCmd.AddCommand(cmd.NewInfoCommand())
rootCmd.AddCommand(cmd.NewConfigCommand())
rootCmd.AddCommand(cmd.NewLogoutCommand())
rootCmd.AddCommand(cmd.NewDoctorCommand())
rootCmd.PersistentFlags().BoolVarP(&cmd.Debug, "debug", "D", false, "Enable debug output")
rootCmd.PersistentFlags().StringVarP(&cmd.ProjectID, "project", "p", "", "Project ID")