feat(cli): add config command and fix viper usage
- Add `dumber config` command to open config in $VISUAL/$EDITOR - Add `--path` flag to print config file path - Fix duplicate keys in config.json by using viper.SafeWriteConfigAs - Fix config not re-reading newly created default file - Remove config writing on every load/reload (bad practice)
- commit
- 38db1f3d6f0f36de7726152f1c12d7e2eb110a3e
- author
- bamen · · 2025-10-07 18:37 UTC
- committer
- bamen · · 2025-10-07 18:45 UTC
- parent
- dc65ba3
- signature
- unsigned
4 files · +71 -70
M CHANGELOG.md +4 -0
| @@ -7,10 +7,14 @@ All notable changes to this project will be documented in this file. | |||
| 7 | 7 | ### Added | |
| 8 | 8 | - ENV=dev support to isolate test builds from production config/data in .dev/dumber/ directory | |
| 9 | 9 | - Content filtering whitelist config (Twitch enabled by default) | |
| 10 | + | - `dumber config` command to open config file in $VISUAL/$EDITOR or print path with `--path` | |
| 10 | 11 | ||
| 11 | 12 | ### Fixed | |
| 12 | 13 | - Enabled missing WebKitGTK6 features: WebRTC, MediaSource, LocalStorage, WebAudio, MediaStream, Clipboard | |
| 13 | 14 | - Cosmetic filter duplicate injection in frames | |
| 15 | + | - Config file duplicate keys (snake_case/camelCase) by using Viper's SafeWriteConfigAs | |
| 16 | + | - Config not reading newly created default config file | |
| 17 | + | - Config writing to disk on every load/reload | |
| 14 | 18 | ||
| 15 | 19 | ## [0.11.0] - 2025-10-07 | |
| 16 | 20 | ||
| @@ -7,10 +7,14 @@ All notable changes to this project will be documented in this file. | |||||
| 7 | ### Added | 7 | ### Added | ||
| 8 | - ENV=dev support to isolate test builds from production config/data in .dev/dumber/ directory | 8 | - ENV=dev support to isolate test builds from production config/data in .dev/dumber/ directory | ||
| 9 | - Content filtering whitelist config (Twitch enabled by default) | 9 | - Content filtering whitelist config (Twitch enabled by default) | ||
| 10 | + | - `dumber config` command to open config file in $VISUAL/$EDITOR or print path with `--path` | |||
| 10 | 11 | ||||
| 11 | ### Fixed | 12 | ### Fixed | ||
| 12 | - Enabled missing WebKitGTK6 features: WebRTC, MediaSource, LocalStorage, WebAudio, MediaStream, Clipboard | 13 | - Enabled missing WebKitGTK6 features: WebRTC, MediaSource, LocalStorage, WebAudio, MediaStream, Clipboard | ||
| 13 | - Cosmetic filter duplicate injection in frames | 14 | - Cosmetic filter duplicate injection in frames | ||
| 15 | + | - Config file duplicate keys (snake_case/camelCase) by using Viper's SafeWriteConfigAs | |||
| 16 | + | - Config not reading newly created default config file | |||
| 17 | + | - Config writing to disk on every load/reload | |||
| 14 | 18 | ||||
| 15 | ## [0.11.0] - 2025-10-07 | 19 | ## [0.11.0] - 2025-10-07 | ||
| 16 | 20 | ||||
A internal/cli/config.go +60 -0
| @@ -0,0 +1,60 @@ | |||
| 1 | + | package cli | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "os" | |
| 6 | + | "os/exec" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/dumber/internal/config" | |
| 9 | + | "github.com/spf13/cobra" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | // NewConfigCmd creates the config command | |
| 13 | + | func NewConfigCmd() *cobra.Command { | |
| 14 | + | cmd := &cobra.Command{ | |
| 15 | + | Use: "config", | |
| 16 | + | Short: "Manage dumber configuration", | |
| 17 | + | Long: `Open the configuration file in your editor or print its path.`, | |
| 18 | + | RunE: openConfig, | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | cmd.Flags().Bool("path", false, "Print the full path of the config file") | |
| 22 | + | ||
| 23 | + | return cmd | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | // openConfig opens the config file in the user's editor or prints its path | |
| 27 | + | func openConfig(cmd *cobra.Command, _ []string) error { | |
| 28 | + | configPath, err := config.GetConfigFile() | |
| 29 | + | if err != nil { | |
| 30 | + | return fmt.Errorf("failed to get config file path: %w", err) | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | // If --path flag is set, just print the path | |
| 34 | + | printPath, _ := cmd.Flags().GetBool("path") | |
| 35 | + | if printPath { | |
| 36 | + | fmt.Println(configPath) | |
| 37 | + | return nil | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | // Get editor from environment (prefer $VISUAL, fallback to $EDITOR) | |
| 41 | + | editor := os.Getenv("VISUAL") | |
| 42 | + | if editor == "" { | |
| 43 | + | editor = os.Getenv("EDITOR") | |
| 44 | + | } | |
| 45 | + | if editor == "" { | |
| 46 | + | return fmt.Errorf("no editor defined: set $VISUAL or $EDITOR environment variable") | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | // Open the config file in the editor | |
| 50 | + | editorCmd := exec.Command(editor, configPath) | |
| 51 | + | editorCmd.Stdin = os.Stdin | |
| 52 | + | editorCmd.Stdout = os.Stdout | |
| 53 | + | editorCmd.Stderr = os.Stderr | |
| 54 | + | ||
| 55 | + | if err := editorCmd.Run(); err != nil { | |
| 56 | + | return fmt.Errorf("failed to open editor: %w", err) | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | return nil | |
| 60 | + | } | |
| @@ -0,0 +1,60 @@ | |||||
| 1 | + | package cli | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | "os" | |||
| 6 | + | "os/exec" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/dumber/internal/config" | |||
| 9 | + | "github.com/spf13/cobra" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | // NewConfigCmd creates the config command | |||
| 13 | + | func NewConfigCmd() *cobra.Command { | |||
| 14 | + | cmd := &cobra.Command{ | |||
| 15 | + | Use: "config", | |||
| 16 | + | Short: "Manage dumber configuration", | |||
| 17 | + | Long: `Open the configuration file in your editor or print its path.`, | |||
| 18 | + | RunE: openConfig, | |||
| 19 | + | } | |||
| 20 | + | ||||
| 21 | + | cmd.Flags().Bool("path", false, "Print the full path of the config file") | |||
| 22 | + | ||||
| 23 | + | return cmd | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | // openConfig opens the config file in the user's editor or prints its path | |||
| 27 | + | func openConfig(cmd *cobra.Command, _ []string) error { | |||
| 28 | + | configPath, err := config.GetConfigFile() | |||
| 29 | + | if err != nil { | |||
| 30 | + | return fmt.Errorf("failed to get config file path: %w", err) | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | // If --path flag is set, just print the path | |||
| 34 | + | printPath, _ := cmd.Flags().GetBool("path") | |||
| 35 | + | if printPath { | |||
| 36 | + | fmt.Println(configPath) | |||
| 37 | + | return nil | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | // Get editor from environment (prefer $VISUAL, fallback to $EDITOR) | |||
| 41 | + | editor := os.Getenv("VISUAL") | |||
| 42 | + | if editor == "" { | |||
| 43 | + | editor = os.Getenv("EDITOR") | |||
| 44 | + | } | |||
| 45 | + | if editor == "" { | |||
| 46 | + | return fmt.Errorf("no editor defined: set $VISUAL or $EDITOR environment variable") | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | // Open the config file in the editor | |||
| 50 | + | editorCmd := exec.Command(editor, configPath) | |||
| 51 | + | editorCmd.Stdin = os.Stdin | |||
| 52 | + | editorCmd.Stdout = os.Stdout | |||
| 53 | + | editorCmd.Stderr = os.Stderr | |||
| 54 | + | ||||
| 55 | + | if err := editorCmd.Run(); err != nil { | |||
| 56 | + | return fmt.Errorf("failed to open editor: %w", err) | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | return nil | |||
| 60 | + | } | |||
M internal/cli/root.go +1 -0
| @@ -188,6 +188,7 @@ func NewRootCmd(version, commit, buildDate string) *cobra.Command { | |||
| 188 | 188 | rootCmd.AddCommand(NewHistoryCmd()) | |
| 189 | 189 | rootCmd.AddCommand(NewPurgeCmd()) | |
| 190 | 190 | rootCmd.AddCommand(NewLogsCmd()) | |
| 191 | + | rootCmd.AddCommand(NewConfigCmd()) | |
| 191 | 192 | ||
| 192 | 193 | return rootCmd | |
| 193 | 194 | } | |
| @@ -188,6 +188,7 @@ func NewRootCmd(version, commit, buildDate string) *cobra.Command { | |||||
| 188 | rootCmd.AddCommand(NewHistoryCmd()) | 188 | rootCmd.AddCommand(NewHistoryCmd()) | ||
| 189 | rootCmd.AddCommand(NewPurgeCmd()) | 189 | rootCmd.AddCommand(NewPurgeCmd()) | ||
| 190 | rootCmd.AddCommand(NewLogsCmd()) | 190 | rootCmd.AddCommand(NewLogsCmd()) | ||
| 191 | + | rootCmd.AddCommand(NewConfigCmd()) | |||
| 191 | 192 | ||||
| 192 | return rootCmd | 193 | return rootCmd | ||
| 193 | } | 194 | } | ||
M internal/config/config.go +6 -70
| @@ -2,7 +2,6 @@ | |||
| 2 | 2 | package config | |
| 3 | 3 | ||
| 4 | 4 | import ( | |
| 5 | - | "encoding/json" | |
| 6 | 5 | "errors" | |
| 7 | 6 | "fmt" | |
| 8 | 7 | "os" | |
| @@ -472,6 +471,10 @@ func (m *Manager) Load() error { | |||
| 472 | 471 | if err := m.createDefaultConfig(); err != nil { | |
| 473 | 472 | return fmt.Errorf("failed to create default config: %w", err) | |
| 474 | 473 | } | |
| 474 | + | // Re-read the newly created config file | |
| 475 | + | if err := m.viper.ReadInConfig(); err != nil { | |
| 476 | + | return fmt.Errorf("failed to read newly created config: %w", err) | |
| 477 | + | } | |
| 475 | 478 | } else { | |
| 476 | 479 | return fmt.Errorf("failed to read config file: %w", err) | |
| 477 | 480 | } | |
| @@ -515,8 +518,6 @@ func (m *Manager) Load() error { | |||
| 515 | 518 | // Validate and configure codec preferences | |
| 516 | 519 | config = m.validateAndConfigureCodecPreferences(config) | |
| 517 | 520 | ||
| 518 | - | m.ensurePersistedDefaults(config) | |
| 519 | - | ||
| 520 | 521 | m.config = config | |
| 521 | 522 | return nil | |
| 522 | 523 | } | |
| @@ -615,8 +616,6 @@ func (m *Manager) reload() error { | |||
| 615 | 616 | // Validate and configure codec preferences | |
| 616 | 617 | config = m.validateAndConfigureCodecPreferences(config) | |
| 617 | 618 | ||
| 618 | - | m.ensurePersistedDefaults(config) | |
| 619 | - | ||
| 620 | 619 | m.config = config | |
| 621 | 620 | return nil | |
| 622 | 621 | } | |
| @@ -747,59 +746,6 @@ func (m *Manager) setDefaults() { | |||
| 747 | 746 | m.viper.SetDefault("workspace.styling.border_radius", defaults.Workspace.Styling.BorderRadius) | |
| 748 | 747 | } | |
| 749 | 748 | ||
| 750 | - | func (m *Manager) ensurePersistedDefaults(cfg *Config) { | |
| 751 | - | if cfg == nil { | |
| 752 | - | return | |
| 753 | - | } | |
| 754 | - | ||
| 755 | - | if m.viper == nil { | |
| 756 | - | return | |
| 757 | - | } | |
| 758 | - | ||
| 759 | - | // Only persist when the key is missing from the user config file. | |
| 760 | - | if m.viper.InConfig("use_dom_zoom") { | |
| 761 | - | return | |
| 762 | - | } | |
| 763 | - | ||
| 764 | - | cfgFile := m.viper.ConfigFileUsed() | |
| 765 | - | if cfgFile == "" { | |
| 766 | - | return | |
| 767 | - | } | |
| 768 | - | ||
| 769 | - | m.viper.Set("use_dom_zoom", cfg.UseDomZoom) | |
| 770 | - | if err := m.viper.WriteConfig(); err != nil { | |
| 771 | - | logging.Warn(fmt.Sprintf("Config: failed to persist use_dom_zoom default to %s: %v", cfgFile, err)) | |
| 772 | - | return | |
| 773 | - | } | |
| 774 | - | ||
| 775 | - | logging.Info(fmt.Sprintf("Config: persisted missing use_dom_zoom default (%v) to %s", cfg.UseDomZoom, cfgFile)) | |
| 776 | - | } | |
| 777 | - | ||
| 778 | - | // persistDefaultZoomIfMissing persists the default_zoom value to the config file if it's not already set. | |
| 779 | - | func (m *Manager) persistDefaultZoomIfMissing(cfg *Config) { | |
| 780 | - | if cfg == nil { | |
| 781 | - | return | |
| 782 | - | } | |
| 783 | - | ||
| 784 | - | // Only persist when the key is missing from the user config file. | |
| 785 | - | if m.viper.InConfig("default_zoom") { | |
| 786 | - | return | |
| 787 | - | } | |
| 788 | - | ||
| 789 | - | cfgFile := m.viper.ConfigFileUsed() | |
| 790 | - | if cfgFile == "" { | |
| 791 | - | return | |
| 792 | - | } | |
| 793 | - | ||
| 794 | - | m.viper.Set("default_zoom", cfg.DefaultZoom) | |
| 795 | - | if err := m.viper.WriteConfig(); err != nil { | |
| 796 | - | logging.Warn(fmt.Sprintf("Config: failed to persist default_zoom default to %s: %v", cfgFile, err)) | |
| 797 | - | return | |
| 798 | - | } | |
| 799 | - | ||
| 800 | - | logging.Info(fmt.Sprintf("Config: persisted missing default_zoom default (%.2f) to %s", cfg.DefaultZoom, cfgFile)) | |
| 801 | - | } | |
| 802 | - | ||
| 803 | 749 | // createDefaultConfig creates a default configuration file. | |
| 804 | 750 | func (m *Manager) createDefaultConfig() error { | |
| 805 | 751 | configFile, err := GetConfigFile() | |
| @@ -812,18 +758,8 @@ func (m *Manager) createDefaultConfig() error { | |||
| 812 | 758 | return err | |
| 813 | 759 | } | |
| 814 | 760 | ||
| 815 | - | // Get the default configuration | |
| 816 | - | defaultConfig := DefaultConfig() | |
| 817 | - | ||
| 818 | - | // Marshal to JSON with proper indentation | |
| 819 | - | // Note: Go 1.20+ automatically sorts map keys in JSON output | |
| 820 | - | configData, err := json.MarshalIndent(defaultConfig, "", " ") | |
| 821 | - | if err != nil { | |
| 822 | - | return fmt.Errorf("failed to marshal default config: %w", err) | |
| 823 | - | } | |
| 824 | - | ||
| 825 | - | // Write JSON config file | |
| 826 | - | if err := os.WriteFile(configFile, configData, filePerm); err != nil { | |
| 761 | + | // Use Viper's SafeWriteConfigAs to avoid duplicate keys | |
| 762 | + | if err := m.viper.SafeWriteConfigAs(configFile); err != nil { | |
| 827 | 763 | return fmt.Errorf("failed to write config file: %w", err) | |
| 828 | 764 | } | |
| 829 | 765 | ||
| @@ -2,7 +2,6 @@ | |||||
| 2 | package config | 2 | package config | ||
| 3 | 3 | ||||
| 4 | import ( | 4 | import ( | ||
| 5 | - | "encoding/json" | |||
| 6 | "errors" | 5 | "errors" | ||
| 7 | "fmt" | 6 | "fmt" | ||
| 8 | "os" | 7 | "os" | ||
| @@ -472,6 +471,10 @@ func (m *Manager) Load() error { | |||||
| 472 | if err := m.createDefaultConfig(); err != nil { | 471 | if err := m.createDefaultConfig(); err != nil { | ||
| 473 | return fmt.Errorf("failed to create default config: %w", err) | 472 | return fmt.Errorf("failed to create default config: %w", err) | ||
| 474 | } | 473 | } | ||
| 474 | + | // Re-read the newly created config file | |||
| 475 | + | if err := m.viper.ReadInConfig(); err != nil { | |||
| 476 | + | return fmt.Errorf("failed to read newly created config: %w", err) | |||
| 477 | + | } | |||
| 475 | } else { | 478 | } else { | ||
| 476 | return fmt.Errorf("failed to read config file: %w", err) | 479 | return fmt.Errorf("failed to read config file: %w", err) | ||
| 477 | } | 480 | } | ||
| @@ -515,8 +518,6 @@ func (m *Manager) Load() error { | |||||
| 515 | // Validate and configure codec preferences | 518 | // Validate and configure codec preferences | ||
| 516 | config = m.validateAndConfigureCodecPreferences(config) | 519 | config = m.validateAndConfigureCodecPreferences(config) | ||
| 517 | 520 | ||||
| 518 | - | m.ensurePersistedDefaults(config) | |||
| 519 | - | ||||
| 520 | m.config = config | 521 | m.config = config | ||
| 521 | return nil | 522 | return nil | ||
| 522 | } | 523 | } | ||
| @@ -615,8 +616,6 @@ func (m *Manager) reload() error { | |||||
| 615 | // Validate and configure codec preferences | 616 | // Validate and configure codec preferences | ||
| 616 | config = m.validateAndConfigureCodecPreferences(config) | 617 | config = m.validateAndConfigureCodecPreferences(config) | ||
| 617 | 618 | ||||
| 618 | - | m.ensurePersistedDefaults(config) | |||
| 619 | - | ||||
| 620 | m.config = config | 619 | m.config = config | ||
| 621 | return nil | 620 | return nil | ||
| 622 | } | 621 | } | ||
| @@ -747,59 +746,6 @@ func (m *Manager) setDefaults() { | |||||
| 747 | m.viper.SetDefault("workspace.styling.border_radius", defaults.Workspace.Styling.BorderRadius) | 746 | m.viper.SetDefault("workspace.styling.border_radius", defaults.Workspace.Styling.BorderRadius) | ||
| 748 | } | 747 | } | ||
| 749 | 748 | ||||
| 750 | - | func (m *Manager) ensurePersistedDefaults(cfg *Config) { | |||
| 751 | - | if cfg == nil { | |||
| 752 | - | return | |||
| 753 | - | } | |||
| 754 | - | ||||
| 755 | - | if m.viper == nil { | |||
| 756 | - | return | |||
| 757 | - | } | |||
| 758 | - | ||||
| 759 | - | // Only persist when the key is missing from the user config file. | |||
| 760 | - | if m.viper.InConfig("use_dom_zoom") { | |||
| 761 | - | return | |||
| 762 | - | } | |||
| 763 | - | ||||
| 764 | - | cfgFile := m.viper.ConfigFileUsed() | |||
| 765 | - | if cfgFile == "" { | |||
| 766 | - | return | |||
| 767 | - | } | |||
| 768 | - | ||||
| 769 | - | m.viper.Set("use_dom_zoom", cfg.UseDomZoom) | |||
| 770 | - | if err := m.viper.WriteConfig(); err != nil { | |||
| 771 | - | logging.Warn(fmt.Sprintf("Config: failed to persist use_dom_zoom default to %s: %v", cfgFile, err)) | |||
| 772 | - | return | |||
| 773 | - | } | |||
| 774 | - | ||||
| 775 | - | logging.Info(fmt.Sprintf("Config: persisted missing use_dom_zoom default (%v) to %s", cfg.UseDomZoom, cfgFile)) | |||
| 776 | - | } | |||
| 777 | - | ||||
| 778 | - | // persistDefaultZoomIfMissing persists the default_zoom value to the config file if it's not already set. | |||
| 779 | - | func (m *Manager) persistDefaultZoomIfMissing(cfg *Config) { | |||
| 780 | - | if cfg == nil { | |||
| 781 | - | return | |||
| 782 | - | } | |||
| 783 | - | ||||
| 784 | - | // Only persist when the key is missing from the user config file. | |||
| 785 | - | if m.viper.InConfig("default_zoom") { | |||
| 786 | - | return | |||
| 787 | - | } | |||
| 788 | - | ||||
| 789 | - | cfgFile := m.viper.ConfigFileUsed() | |||
| 790 | - | if cfgFile == "" { | |||
| 791 | - | return | |||
| 792 | - | } | |||
| 793 | - | ||||
| 794 | - | m.viper.Set("default_zoom", cfg.DefaultZoom) | |||
| 795 | - | if err := m.viper.WriteConfig(); err != nil { | |||
| 796 | - | logging.Warn(fmt.Sprintf("Config: failed to persist default_zoom default to %s: %v", cfgFile, err)) | |||
| 797 | - | return | |||
| 798 | - | } | |||
| 799 | - | ||||
| 800 | - | logging.Info(fmt.Sprintf("Config: persisted missing default_zoom default (%.2f) to %s", cfg.DefaultZoom, cfgFile)) | |||
| 801 | - | } | |||
| 802 | - | ||||
| 803 | // createDefaultConfig creates a default configuration file. | 749 | // createDefaultConfig creates a default configuration file. | ||
| 804 | func (m *Manager) createDefaultConfig() error { | 750 | func (m *Manager) createDefaultConfig() error { | ||
| 805 | configFile, err := GetConfigFile() | 751 | configFile, err := GetConfigFile() | ||
| @@ -812,18 +758,8 @@ func (m *Manager) createDefaultConfig() error { | |||||
| 812 | return err | 758 | return err | ||
| 813 | } | 759 | } | ||
| 814 | 760 | ||||
| 815 | - | // Get the default configuration | 761 | + | // Use Viper's SafeWriteConfigAs to avoid duplicate keys |
| 816 | - | defaultConfig := DefaultConfig() | 762 | + | if err := m.viper.SafeWriteConfigAs(configFile); err != nil { |
| 817 | - | ||||
| 818 | - | // Marshal to JSON with proper indentation | |||
| 819 | - | // Note: Go 1.20+ automatically sorts map keys in JSON output | |||
| 820 | - | configData, err := json.MarshalIndent(defaultConfig, "", " ") | |||
| 821 | - | if err != nil { | |||
| 822 | - | return fmt.Errorf("failed to marshal default config: %w", err) | |||
| 823 | - | } | |||
| 824 | - | ||||
| 825 | - | // Write JSON config file | |||
| 826 | - | if err := os.WriteFile(configFile, configData, filePerm); err != nil { | |||
| 827 | return fmt.Errorf("failed to write config file: %w", err) | 763 | return fmt.Errorf("failed to write config file: %w", err) | ||
| 828 | } | 764 | } | ||
| 829 | 765 | ||||