From 345d7c0cd118920212269180f6beb356b7de2f5e Mon Sep 17 00:00:00 2001 From: Chen Asraf Date: Mon, 23 Mar 2026 22:40:06 +0200 Subject: [PATCH] feat: add logout command --- README.md | 10 ++++ cmd/logout.go | 58 ++++++++++++++++++ cmd/logout_test.go | 128 ++++++++++++++++++++++++++++++++++++++++ internal/cache/cache.go | 5 ++ main.go | 1 + 5 files changed, 202 insertions(+) create mode 100644 cmd/logout.go create mode 100644 cmd/logout_test.go diff --git a/README.md b/README.md index e3c1513..75772b5 100644 --- a/README.md +++ b/README.md @@ -408,6 +408,16 @@ cospend config get default-project --- +### Logging Out + +```bash +cospend logout +``` + +Removes the configuration file (stored credentials) and clears all cached data. + +--- + ## Caching Project data (members, categories, payment methods, currencies) is cached locally to avoid repeated diff --git a/cmd/logout.go b/cmd/logout.go new file mode 100644 index 0000000..8ec756a --- /dev/null +++ b/cmd/logout.go @@ -0,0 +1,58 @@ +package cmd + +import ( + "fmt" + "os" + + "github.com/chenasraf/cospend-cli/internal/cache" + "github.com/chenasraf/cospend-cli/internal/config" + "github.com/spf13/cobra" +) + +// NewLogoutCommand creates the logout command +func NewLogoutCommand() *cobra.Command { + return &cobra.Command{ + Use: "logout", + Short: "Remove configuration and cached data", + Long: `Remove the configuration file and clear all cached data. + +This effectively logs you out by removing your stored credentials +and any cached project data.`, + RunE: runLogout, + } +} + +func runLogout(cmd *cobra.Command, _ []string) error { + cmd.SilenceUsage = true + + out := cmd.OutOrStdout() + removed := false + + // Remove config file + configPath := config.GetConfigPath() + if configPath != "" { + if err := os.Remove(configPath); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("removing config file: %w", err) + } + _, _ = fmt.Fprintf(out, "Removed config: %s\n", configPath) + removed = true + } + + // Remove cache directory + cacheDir := cache.GetCacheDir() + if info, err := os.Stat(cacheDir); err == nil && info.IsDir() { + if err := os.RemoveAll(cacheDir); err != nil { + return fmt.Errorf("removing cache directory: %w", err) + } + _, _ = fmt.Fprintf(out, "Removed cache: %s\n", cacheDir) + removed = true + } + + if !removed { + _, _ = fmt.Fprintln(out, "Nothing to remove (no config or cache found).") + } else { + _, _ = fmt.Fprintln(out, "Logged out successfully.") + } + + return nil +} diff --git a/cmd/logout_test.go b/cmd/logout_test.go new file mode 100644 index 0000000..2602cfc --- /dev/null +++ b/cmd/logout_test.go @@ -0,0 +1,128 @@ +package cmd + +import ( + "bytes" + "os" + "path/filepath" + "testing" +) + +func TestNewLogoutCommand(t *testing.T) { + cmd := NewLogoutCommand() + if cmd.Use != "logout" { + t.Errorf("Wrong Use: %s", cmd.Use) + } +} + +func TestLogoutRemovesConfigAndCache(t *testing.T) { + tempDir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tempDir) + t.Setenv("XDG_CACHE_HOME", tempDir) + t.Setenv("HOME", tempDir) + + // Create config file + configDir := filepath.Join(tempDir, "cospend") + if err := os.MkdirAll(configDir, 0700); err != nil { + t.Fatalf("Failed to create config dir: %v", err) + } + configPath := filepath.Join(configDir, "cospend.json") + if err := os.WriteFile(configPath, []byte(`{"domain":"x","user":"u","password":"p"}`), 0600); err != nil { + t.Fatalf("Failed to write config: %v", err) + } + + // Create cache directory with a file in a separate temp dir + cacheTempDir := t.TempDir() + t.Setenv("XDG_CACHE_HOME", cacheTempDir) + cacheDir := filepath.Join(cacheTempDir, "cospend") + if err := os.MkdirAll(cacheDir, 0755); err != nil { + t.Fatalf("Failed to create cache dir: %v", err) + } + cachePath := filepath.Join(cacheDir, "test-project.json") + if err := os.WriteFile(cachePath, []byte(`{}`), 0600); err != nil { + t.Fatalf("Failed to write cache: %v", err) + } + + cmd := NewLogoutCommand() + var stdout bytes.Buffer + cmd.SetOut(&stdout) + + err := cmd.Execute() + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + // Config should be removed + if _, err := os.Stat(configPath); !os.IsNotExist(err) { + t.Error("Config file should have been removed") + } + + // Cache dir should be removed + if _, err := os.Stat(cacheDir); !os.IsNotExist(err) { + t.Error("Cache directory should have been removed") + } + + output := stdout.String() + if !bytes.Contains([]byte(output), []byte("Logged out successfully")) { + t.Errorf("Expected success message, got: %s", output) + } +} + +func TestLogoutNothingToRemove(t *testing.T) { + tempDir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tempDir) + t.Setenv("XDG_CACHE_HOME", tempDir) + t.Setenv("HOME", tempDir) + + cmd := NewLogoutCommand() + 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("Nothing to remove")) { + t.Errorf("Expected 'Nothing to remove' message, got: %s", output) + } +} + +func TestLogoutConfigOnly(t *testing.T) { + tempDir := t.TempDir() + cacheTempDir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tempDir) + t.Setenv("XDG_CACHE_HOME", cacheTempDir) + t.Setenv("HOME", tempDir) + + // Create config file only (no cache) + configDir := filepath.Join(tempDir, "cospend") + if err := os.MkdirAll(configDir, 0700); err != nil { + t.Fatalf("Failed to create config dir: %v", err) + } + configPath := filepath.Join(configDir, "cospend.json") + if err := os.WriteFile(configPath, []byte(`{"domain":"x","user":"u","password":"p"}`), 0600); err != nil { + t.Fatalf("Failed to write config: %v", err) + } + + cmd := NewLogoutCommand() + var stdout bytes.Buffer + cmd.SetOut(&stdout) + + err := cmd.Execute() + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + if _, err := os.Stat(configPath); !os.IsNotExist(err) { + t.Error("Config file should have been removed") + } + + output := stdout.String() + if !bytes.Contains([]byte(output), []byte("Removed config")) { + t.Errorf("Expected 'Removed config' message, got: %s", output) + } + if !bytes.Contains([]byte(output), []byte("Logged out successfully")) { + t.Errorf("Expected success message, got: %s", output) + } +} diff --git a/internal/cache/cache.go b/internal/cache/cache.go index b9db071..eaee623 100644 --- a/internal/cache/cache.go +++ b/internal/cache/cache.go @@ -149,6 +149,11 @@ func getCacheHome() string { return xdg.CacheHome } +// GetCacheDir returns the cache directory path +func GetCacheDir() string { + return filepath.Join(getCacheHome(), appName) +} + // getCachePath returns the cache file path for a project func getCachePath(projectID string) (string, error) { cacheDir := filepath.Join(getCacheHome(), appName) diff --git a/main.go b/main.go index 23faf55..dedbff3 100644 --- a/main.go +++ b/main.go @@ -38,6 +38,7 @@ func main() { rootCmd.AddCommand(cmd.NewProjectsCommand()) rootCmd.AddCommand(cmd.NewInfoCommand()) rootCmd.AddCommand(cmd.NewConfigCommand()) + rootCmd.AddCommand(cmd.NewLogoutCommand()) rootCmd.PersistentFlags().BoolVarP(&cmd.Debug, "debug", "D", false, "Enable debug output") rootCmd.PersistentFlags().StringVarP(&cmd.ProjectID, "project", "p", "", "Project ID")