mirror of
https://github.com/chenasraf/cospend-cli.git
synced 2026-05-17 17:38:04 +00:00
feat: add default-project setting
This commit is contained in:
38
README.md
38
README.md
@@ -19,6 +19,7 @@ add and list expenses directly from your terminal without opening the web interf
|
||||
- **Case-insensitive** matching for all lookups
|
||||
- **Currency code support** (e.g., `usd`, `eur`, `gbp`) with automatic symbol resolution
|
||||
- **Local caching** of project data with 1-hour TTL for faster subsequent calls
|
||||
- **Default project** - set once with `config set`, no need to pass `-p` every time
|
||||
- **Global project flag** - set `-p` before the command for easy shell aliases
|
||||
- **Secure browser login** - OAuth-style authentication with 2FA support
|
||||
- Cross-platform support: **macOS**, **Linux**, and **Windows**
|
||||
@@ -152,6 +153,18 @@ cospend-home add "Groceries" 25.50
|
||||
cospend-home list
|
||||
```
|
||||
|
||||
You can also set a default project so you don't need `-p` at all:
|
||||
|
||||
```bash
|
||||
cospend config set default-project myproject
|
||||
|
||||
# Now these work without -p:
|
||||
cospend add "Groceries" 25.50
|
||||
cospend list
|
||||
```
|
||||
|
||||
The `-p` flag always takes precedence over the default project.
|
||||
|
||||
---
|
||||
|
||||
### Adding Expenses
|
||||
@@ -370,6 +383,31 @@ cospend projects --all
|
||||
|
||||
---
|
||||
|
||||
### Managing Configuration
|
||||
|
||||
```bash
|
||||
cospend config set <key> <value>
|
||||
cospend config get <key>
|
||||
```
|
||||
|
||||
#### Supported Keys
|
||||
|
||||
| Key | Description |
|
||||
| ------------------- | -------------------------------------------------- |
|
||||
| `default-project` | Default project ID (used when `-p` is not specified) |
|
||||
|
||||
#### Examples
|
||||
|
||||
```bash
|
||||
# Set a default project
|
||||
cospend config set default-project myproject
|
||||
|
||||
# View the current default project
|
||||
cospend config get default-project
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Caching
|
||||
|
||||
Project data (members, categories, payment methods, currencies) is cached locally to avoid repeated
|
||||
|
||||
114
cmd/config.go
Normal file
114
cmd/config.go
Normal file
@@ -0,0 +1,114 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/chenasraf/cospend-cli/internal/config"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// NewConfigCommand creates the config command with subcommands
|
||||
func NewConfigCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "config",
|
||||
Short: "Manage CLI configuration",
|
||||
Long: `View and modify cospend-cli configuration settings.`,
|
||||
}
|
||||
|
||||
cmd.AddCommand(newConfigSetCommand())
|
||||
cmd.AddCommand(newConfigGetCommand())
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newConfigSetCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "set <key> <value>",
|
||||
Short: "Set a configuration value",
|
||||
Long: `Set a configuration value.
|
||||
|
||||
Supported keys:
|
||||
default-project Default project ID (used when -p is not specified)
|
||||
|
||||
Examples:
|
||||
cospend config set default-project myproject`,
|
||||
Args: cobra.ExactArgs(2),
|
||||
RunE: runConfigSet,
|
||||
}
|
||||
}
|
||||
|
||||
func newConfigGetCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "get <key>",
|
||||
Short: "Get a configuration value",
|
||||
Long: `Get a configuration value.
|
||||
|
||||
Supported keys:
|
||||
default-project Default project ID (used when -p is not specified)
|
||||
|
||||
Examples:
|
||||
cospend config get default-project`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: runConfigGet,
|
||||
}
|
||||
}
|
||||
|
||||
func runConfigSet(cmd *cobra.Command, args []string) error {
|
||||
key := args[0]
|
||||
value := args[1]
|
||||
|
||||
cmd.SilenceUsage = true
|
||||
|
||||
configPath := config.GetConfigPath()
|
||||
if configPath == "" {
|
||||
return fmt.Errorf("no config file found (run 'cospend init' first)")
|
||||
}
|
||||
|
||||
cfg, err := config.LoadFromFile(configPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading config: %w", err)
|
||||
}
|
||||
|
||||
switch key {
|
||||
case "default-project":
|
||||
cfg.DefaultProject = value
|
||||
default:
|
||||
return fmt.Errorf("unknown config key: %s", key)
|
||||
}
|
||||
|
||||
if _, err := config.SaveToPath(cfg, configPath); err != nil {
|
||||
return fmt.Errorf("saving config: %w", err)
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Set %s = %s\n", key, value)
|
||||
return nil
|
||||
}
|
||||
|
||||
func runConfigGet(cmd *cobra.Command, args []string) error {
|
||||
key := args[0]
|
||||
|
||||
cmd.SilenceUsage = true
|
||||
|
||||
configPath := config.GetConfigPath()
|
||||
if configPath == "" {
|
||||
return fmt.Errorf("no config file found (run 'cospend init' first)")
|
||||
}
|
||||
|
||||
cfg, err := config.LoadFromFile(configPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading config: %w", err)
|
||||
}
|
||||
|
||||
switch key {
|
||||
case "default-project":
|
||||
if cfg.DefaultProject == "" {
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), "(not set)")
|
||||
} else {
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), cfg.DefaultProject)
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("unknown config key: %s", key)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
276
cmd/config_test.go
Normal file
276
cmd/config_test.go
Normal file
@@ -0,0 +1,276 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewConfigCommand(t *testing.T) {
|
||||
cmd := NewConfigCommand()
|
||||
|
||||
if cmd.Use != "config" {
|
||||
t.Errorf("Wrong Use: %s", cmd.Use)
|
||||
}
|
||||
|
||||
// Should have set and get subcommands
|
||||
subCmds := cmd.Commands()
|
||||
names := make(map[string]bool)
|
||||
for _, c := range subCmds {
|
||||
names[c.Name()] = true
|
||||
}
|
||||
if !names["set"] {
|
||||
t.Error("Missing 'set' subcommand")
|
||||
}
|
||||
if !names["get"] {
|
||||
t.Error("Missing 'get' subcommand")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigSetDefaultProject(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
t.Setenv("XDG_CONFIG_HOME", tempDir)
|
||||
t.Setenv("HOME", tempDir)
|
||||
|
||||
// Create initial 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")
|
||||
initialConfig := `{"domain": "https://example.com", "user": "alice", "password": "pass"}`
|
||||
if err := os.WriteFile(configPath, []byte(initialConfig), 0600); err != nil {
|
||||
t.Fatalf("Failed to write config: %v", err)
|
||||
}
|
||||
|
||||
cmd := NewConfigCommand()
|
||||
var stdout bytes.Buffer
|
||||
cmd.SetOut(&stdout)
|
||||
cmd.SetArgs([]string{"set", "default-project", "myproject"})
|
||||
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if !bytes.Contains(stdout.Bytes(), []byte("Set default-project = myproject")) {
|
||||
t.Errorf("Expected success message, got: %s", stdout.String())
|
||||
}
|
||||
|
||||
// Verify it was saved
|
||||
data, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read config: %v", err)
|
||||
}
|
||||
if !bytes.Contains(data, []byte("myproject")) {
|
||||
t.Errorf("Config file should contain 'myproject', got: %s", string(data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigGetDefaultProject(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
t.Setenv("XDG_CONFIG_HOME", tempDir)
|
||||
t.Setenv("HOME", tempDir)
|
||||
|
||||
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")
|
||||
configContent := `{"domain": "https://example.com", "user": "alice", "password": "pass", "default_project": "myproject"}`
|
||||
if err := os.WriteFile(configPath, []byte(configContent), 0600); err != nil {
|
||||
t.Fatalf("Failed to write config: %v", err)
|
||||
}
|
||||
|
||||
cmd := NewConfigCommand()
|
||||
var stdout bytes.Buffer
|
||||
cmd.SetOut(&stdout)
|
||||
cmd.SetArgs([]string{"get", "default-project"})
|
||||
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if !bytes.Contains(stdout.Bytes(), []byte("myproject")) {
|
||||
t.Errorf("Expected 'myproject', got: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigGetDefaultProjectNotSet(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
t.Setenv("XDG_CONFIG_HOME", tempDir)
|
||||
t.Setenv("HOME", tempDir)
|
||||
|
||||
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")
|
||||
configContent := `{"domain": "https://example.com", "user": "alice", "password": "pass"}`
|
||||
if err := os.WriteFile(configPath, []byte(configContent), 0600); err != nil {
|
||||
t.Fatalf("Failed to write config: %v", err)
|
||||
}
|
||||
|
||||
cmd := NewConfigCommand()
|
||||
var stdout bytes.Buffer
|
||||
cmd.SetOut(&stdout)
|
||||
cmd.SetArgs([]string{"get", "default-project"})
|
||||
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if !bytes.Contains(stdout.Bytes(), []byte("(not set)")) {
|
||||
t.Errorf("Expected '(not set)', got: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigSetUnknownKey(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
t.Setenv("XDG_CONFIG_HOME", tempDir)
|
||||
t.Setenv("HOME", tempDir)
|
||||
|
||||
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 := NewConfigCommand()
|
||||
cmd.SetArgs([]string{"set", "unknown-key", "value"})
|
||||
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Error("Expected error for unknown key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigNoConfigFile(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
t.Setenv("XDG_CONFIG_HOME", tempDir)
|
||||
t.Setenv("HOME", tempDir)
|
||||
|
||||
cmd := NewConfigCommand()
|
||||
cmd.SetArgs([]string{"get", "default-project"})
|
||||
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Error("Expected error when no config file exists")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigSetPreservesExistingFields(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
t.Setenv("XDG_CONFIG_HOME", tempDir)
|
||||
t.Setenv("HOME", tempDir)
|
||||
|
||||
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")
|
||||
initialConfig := `{"domain": "https://example.com", "user": "alice", "password": "secret123"}`
|
||||
if err := os.WriteFile(configPath, []byte(initialConfig), 0600); err != nil {
|
||||
t.Fatalf("Failed to write config: %v", err)
|
||||
}
|
||||
|
||||
cmd := NewConfigCommand()
|
||||
var stdout bytes.Buffer
|
||||
cmd.SetOut(&stdout)
|
||||
cmd.SetArgs([]string{"set", "default-project", "myproject"})
|
||||
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Verify existing fields are preserved
|
||||
data, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read config: %v", err)
|
||||
}
|
||||
content := string(data)
|
||||
if !bytes.Contains(data, []byte("https://example.com")) {
|
||||
t.Errorf("Domain should be preserved, got: %s", content)
|
||||
}
|
||||
if !bytes.Contains(data, []byte("alice")) {
|
||||
t.Errorf("User should be preserved, got: %s", content)
|
||||
}
|
||||
if !bytes.Contains(data, []byte("secret123")) {
|
||||
t.Errorf("Password should be preserved, got: %s", content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigSetYAML(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
t.Setenv("XDG_CONFIG_HOME", tempDir)
|
||||
t.Setenv("HOME", tempDir)
|
||||
|
||||
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.yaml")
|
||||
initialConfig := "domain: https://example.com\nuser: alice\npassword: pass\n"
|
||||
if err := os.WriteFile(configPath, []byte(initialConfig), 0600); err != nil {
|
||||
t.Fatalf("Failed to write config: %v", err)
|
||||
}
|
||||
|
||||
cmd := NewConfigCommand()
|
||||
var stdout bytes.Buffer
|
||||
cmd.SetOut(&stdout)
|
||||
cmd.SetArgs([]string{"set", "default-project", "yamlproject"})
|
||||
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read config: %v", err)
|
||||
}
|
||||
if !bytes.Contains(data, []byte("yamlproject")) {
|
||||
t.Errorf("Config should contain 'yamlproject', got: %s", string(data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigSetTOML(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
t.Setenv("XDG_CONFIG_HOME", tempDir)
|
||||
t.Setenv("HOME", tempDir)
|
||||
|
||||
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.toml")
|
||||
initialConfig := "domain = \"https://example.com\"\nuser = \"alice\"\npassword = \"pass\"\n"
|
||||
if err := os.WriteFile(configPath, []byte(initialConfig), 0600); err != nil {
|
||||
t.Fatalf("Failed to write config: %v", err)
|
||||
}
|
||||
|
||||
cmd := NewConfigCommand()
|
||||
var stdout bytes.Buffer
|
||||
cmd.SetOut(&stdout)
|
||||
cmd.SetArgs([]string{"set", "default-project", "tomlproject"})
|
||||
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read config: %v", err)
|
||||
}
|
||||
if !bytes.Contains(data, []byte("tomlproject")) {
|
||||
t.Errorf("Config should contain 'tomlproject', got: %s", string(data))
|
||||
}
|
||||
}
|
||||
@@ -27,9 +27,10 @@ func NormalizeURL(url string) string {
|
||||
|
||||
// Config holds the Nextcloud configuration
|
||||
type Config struct {
|
||||
Domain string `json:"domain" yaml:"domain" toml:"domain"`
|
||||
User string `json:"user" yaml:"user" toml:"user"`
|
||||
Password string `json:"password" yaml:"password" toml:"password"`
|
||||
Domain string `json:"domain" yaml:"domain" toml:"domain"`
|
||||
User string `json:"user" yaml:"user" toml:"user"`
|
||||
Password string `json:"password" yaml:"password" toml:"password"`
|
||||
DefaultProject string `json:"default_project,omitempty" yaml:"default_project,omitempty" toml:"default_project,omitempty"`
|
||||
}
|
||||
|
||||
// configExtensions lists supported config file extensions in order of preference
|
||||
@@ -205,11 +206,39 @@ func SaveToPath(cfg *Config, path string) (string, error) {
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// LoadRaw reads configuration without validating required fields.
|
||||
// Useful for reading optional settings like DefaultProject.
|
||||
func LoadRaw() *Config {
|
||||
var cfg Config
|
||||
|
||||
if configPath := GetConfigPath(); configPath != "" {
|
||||
fileCfg, err := LoadFromFile(configPath)
|
||||
if err == nil {
|
||||
cfg = *fileCfg
|
||||
}
|
||||
}
|
||||
|
||||
if domain := os.Getenv("NEXTCLOUD_DOMAIN"); domain != "" {
|
||||
cfg.Domain = domain
|
||||
}
|
||||
if user := os.Getenv("NEXTCLOUD_USER"); user != "" {
|
||||
cfg.User = user
|
||||
}
|
||||
if password := os.Getenv("NEXTCLOUD_PASSWORD"); password != "" {
|
||||
cfg.Password = password
|
||||
}
|
||||
|
||||
return &cfg
|
||||
}
|
||||
|
||||
// tomlMarshal encodes config to TOML format
|
||||
func tomlMarshal(cfg *Config) ([]byte, error) {
|
||||
content := fmt.Sprintf(`domain = %q
|
||||
user = %q
|
||||
password = %q
|
||||
`, cfg.Domain, cfg.User, cfg.Password)
|
||||
if cfg.DefaultProject != "" {
|
||||
content += fmt.Sprintf("default_project = %q\n", cfg.DefaultProject)
|
||||
}
|
||||
return []byte(content), nil
|
||||
}
|
||||
|
||||
10
main.go
10
main.go
@@ -6,6 +6,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/chenasraf/cospend-cli/cmd"
|
||||
"github.com/chenasraf/cospend-cli/internal/config"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -19,6 +20,14 @@ func main() {
|
||||
Long: `cospend is a command-line interface for adding expenses to Nextcloud Cospend projects.`,
|
||||
Version: strings.TrimSpace(version),
|
||||
TraverseChildren: true,
|
||||
PersistentPreRun: func(c *cobra.Command, args []string) {
|
||||
// Apply default project from config if -p not explicitly set
|
||||
if cmd.ProjectID == "" {
|
||||
if raw := config.LoadRaw(); raw.DefaultProject != "" {
|
||||
cmd.ProjectID = raw.DefaultProject
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
rootCmd.AddCommand(cmd.NewAddCommand())
|
||||
@@ -28,6 +37,7 @@ func main() {
|
||||
rootCmd.AddCommand(cmd.NewEditCommand())
|
||||
rootCmd.AddCommand(cmd.NewProjectsCommand())
|
||||
rootCmd.AddCommand(cmd.NewInfoCommand())
|
||||
rootCmd.AddCommand(cmd.NewConfigCommand())
|
||||
|
||||
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