feat: add default-project setting

This commit is contained in:
2026-03-23 22:37:22 +02:00
parent 05fa043a36
commit 59cb401946
5 changed files with 470 additions and 3 deletions

View File

@@ -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
View 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
View 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))
}
}

View File

@@ -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
View File

@@ -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")