mirror of
https://github.com/chenasraf/cospend-cli.git
synced 2026-05-18 01:39:03 +00:00
feat: add doctor command
This commit is contained in:
16
README.md
16
README.md
@@ -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
154
cmd/doctor.go
Normal 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
225
cmd/doctor_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
1
main.go
1
main.go
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user