From 34cdbabaa90dec732837a5215d15ef732109a57b Mon Sep 17 00:00:00 2001 From: Ravi Suhag Date: Fri, 6 Dec 2024 01:36:27 -0600 Subject: [PATCH] feat: improve ux for cli config management --- .gitignore | 2 +- LICENSE | 2 +- cli/cmdx/help.go | 7 +- cli/config/cmd.go | 57 ++++++++++++++++ cli/config/config.go | 157 +++++++++++++++++++------------------------ config/config.go | 2 +- go.sum | 2 +- 7 files changed, 136 insertions(+), 93 deletions(-) create mode 100644 cli/config/cmd.go diff --git a/.gitignore b/.gitignore index fe25d3b..f433cd9 100644 --- a/.gitignore +++ b/.gitignore @@ -5,7 +5,7 @@ *.so *.dylib -# Test binary, built with `go test -c` +# Test binary, built with `go test -cfg` *.test # Output of the go coverage tool, specifically when used with LiteIDE diff --git a/LICENSE b/LICENSE index 261eeb9..55f8534 100644 --- a/LICENSE +++ b/LICENSE @@ -97,7 +97,7 @@ (b) You must cause any modified files to carry prominent notices stating that You changed the files; and - (c) You must retain, in the Source form of any Derivative Works + (cfg) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of diff --git a/cli/cmdx/help.go b/cli/cmdx/help.go index 36cf6ef..ae3efde 100644 --- a/cli/cmdx/help.go +++ b/cli/cmdx/help.go @@ -1,6 +1,7 @@ package cmdx import ( + "errors" "fmt" "strings" @@ -66,7 +67,7 @@ func generateUsage(cmd *cobra.Command) error { // handleFlagError processes flag-related errors, including the special case of help flags. func handleFlagError(cmd *cobra.Command, err error) error { - if err == pflag.ErrHelp { + if errors.Is(err, pflag.ErrHelp) { return err } return err @@ -144,10 +145,12 @@ func buildHelpEntries(cmd *cobra.Command) []helpEntry { if cmd.Example != "" { helpEntries = append(helpEntries, helpEntry{EXAMPLES, cmd.Example}) } + if argsAnnotation, ok := cmd.Annotations["help:environment"]; ok { + helpEntries = append(helpEntries, helpEntry{ENVS, argsAnnotation}) + } if argsAnnotation, ok := cmd.Annotations["help:learn"]; ok { helpEntries = append(helpEntries, helpEntry{LEARN, argsAnnotation}) } - if argsAnnotation, ok := cmd.Annotations["help:feedback"]; ok { helpEntries = append(helpEntries, helpEntry{FEEDBACK, argsAnnotation}) } diff --git a/cli/config/cmd.go b/cli/config/cmd.go new file mode 100644 index 0000000..f5c11c6 --- /dev/null +++ b/cli/config/cmd.go @@ -0,0 +1,57 @@ +package config + +import ( + "fmt" + "log" + + "github.com/spf13/cobra" +) + +// Commands returns a list of Cobra commands for managing the configuration. +func Commands(app string, cfgTemplate interface{}) (*cobra.Command, error) { + cfg, err := New(app) + if err != nil { + return nil, err + } + + cmd := &cobra.Command{ + Use: "config", + Short: "Manage application configuration", + Annotations: map[string]string{ + "group": "core", + }, + } + + cmd.AddCommand( + &cobra.Command{ + Use: "init", + Short: "Initialize configuration with default values", + Annotations: map[string]string{ + "group": "core", + }, + Run: func(cmd *cobra.Command, args []string) { + if err := cfg.Init(cfgTemplate); err != nil { + log.Fatalf("Error initializing config: %v", err) + } + fmt.Println("Configuration initialized successfully.") + }, + }, + &cobra.Command{ + Use: "view", + Short: "View the current configuration", + Annotations: map[string]string{ + "group": "core", + }, + Run: func(cmd *cobra.Command, args []string) { + content, err := cfg.Read() + if err != nil { + log.Fatalf("Error reading config: %v", err) + } + fmt.Println("Current Configuration:") + fmt.Println(content) + }, + }, + ) + + return cmd, nil +} diff --git a/cli/config/config.go b/cli/config/config.go index 4ed512f..22c3226 100644 --- a/cli/config/config.go +++ b/cli/config/config.go @@ -2,70 +2,65 @@ package config import ( "errors" + "fmt" "os" "path/filepath" "runtime" "github.com/mcuadros/go-defaults" + "github.com/raystack/salt/config" "github.com/spf13/pflag" "gopkg.in/yaml.v3" - - "github.com/raystack/salt/config" ) -// Environment variables for configuration paths -const ( - RaystackConfigDirEnv = "RAYSTACK_CONFIG_DIR" - XDGConfigHomeEnv = "XDG_CONFIG_HOME" - AppDataEnv = "AppData" -) - -// ConfigLoaderOpt defines a functional option for configuring the Config object. -type ConfigLoaderOpt func(c *Config) +// Config represents the configuration structure. +type Config struct { + path string + flags *pflag.FlagSet +} -// WithFlags binds command-line flags to configuration values. -func WithFlags(pfs *pflag.FlagSet) ConfigLoaderOpt { - return func(c *Config) { - c.boundFlags = pfs +// New creates a new Config instance for the given application. +func New(app string, opts ...Opts) (*Config, error) { + filePath, err := getConfigFilePath(app) + if err != nil { + return nil, fmt.Errorf("failed to determine config file path: %w", err) } -} -// WithLoaderOptions adds custom loader options for configuration loading. -func WithLoaderOptions(opts ...config.LoaderOption) ConfigLoaderOpt { - return func(c *Config) { - c.loaderOpts = append(c.loaderOpts, opts...) + cfg := &Config{path: filePath} + for _, opt := range opts { + opt(cfg) } + + return cfg, nil } -// SetConfig initializes a new Config object for the specified application. -func SetConfig(app string) *Config { - return &Config{ - filename: configFile(app), +// Opts defines a functional option for configuring the Config object. +type Opts func(c *Config) + +// WithFlags binds command-line flags to configuration values. +func WithFlags(pfs *pflag.FlagSet) Opts { + return func(c *Config) { + c.flags = pfs } } -// Config manages the application's configuration file and related operations. -type Config struct { - filename string - boundFlags *pflag.FlagSet - loaderOpts []config.LoaderOption -} +// Load reads the configuration file into the Config's Data map. +func (c *Config) Load(cfg interface{}) error { + loaderOpts := []config.LoaderOption{config.WithFile(c.path)} -// File returns the path to the configuration file. -func (c *Config) File() string { - return c.filename -} + if c.flags != nil { + loaderOpts = append(loaderOpts, config.WithBindPFlags(c.flags, cfg)) + } -// Defaults populates the given configuration struct with default values. -func (c *Config) Defaults(cfg interface{}) { - defaults.SetDefaults(cfg) + loader := config.NewLoader(loaderOpts...) + return loader.Load(cfg) } // Init initializes the configuration file with default values. func (c *Config) Init(cfg interface{}) error { defaults.SetDefaults(cfg) - if fileExists(c.filename) { + if fileExists(c.path) { return errors.New("configuration file already exists") } @@ -74,77 +69,65 @@ func (c *Config) Init(cfg interface{}) error { // Read reads the content of the configuration file as a string. func (c *Config) Read() (string, error) { - data, err := os.ReadFile(c.filename) - return string(data), err + data, err := os.ReadFile(c.path) + if err != nil { + return "", fmt.Errorf("failed to read configuration file: %w", err) + } + return string(data), nil } -// Write writes the given configuration struct to the configuration file in YAML format. +// Write writes the given struct to the configuration file in YAML format. func (c *Config) Write(cfg interface{}) error { data, err := yaml.Marshal(cfg) if err != nil { - return err + return fmt.Errorf("failed to marshal configuration: %w", err) } - if _, err := os.Stat(c.filename); os.IsNotExist(err) { - _ = os.MkdirAll(configDir("raystack"), 0700) + if err := ensureDir(filepath.Dir(c.path)); err != nil { + return err } - if err := os.WriteFile(c.filename, data, 0655); err != nil { - return err + if err := os.WriteFile(c.path, data, 0655); err != nil { + return fmt.Errorf("failed to write configuration file: %w", err) } return nil } -// Load loads the configuration from the file and applies the provided loader options. -func (c *Config) Load(cfg interface{}, opts ...ConfigLoaderOpt) error { - for _, opt := range opts { - opt(c) +// getConfigFile determines the full path to the configuration file for the application. +func getConfigFilePath(app string) (string, error) { + dirPath := getConfigDir("raystack") + if err := ensureDir(dirPath); err != nil { + return "", err } - - loaderOpts := []config.LoaderOption{config.WithFile(c.filename)} - - if c.boundFlags != nil { - loaderOpts = append(loaderOpts, config.WithBindPFlags(c.boundFlags, cfg)) - } - loaderOpts = append(loaderOpts, c.loaderOpts...) - - loader := config.NewLoader(loaderOpts...) - - return loader.Load(cfg) + return filepath.Join(dirPath, app+".yml"), nil } -// configFile determines the full path to the configuration file for the application. -func configFile(app string) string { - filename := app + ".yml" - return filepath.Join(configDir("raystack"), filename) -} - -// configDir determines the appropriate directory for storing configuration files. -func configDir(root string) string { - var path string - if env := os.Getenv(RaystackConfigDirEnv); env != "" { - path = env - } else if env := os.Getenv(XDGConfigHomeEnv); env != "" { - path = filepath.Join(env, root) - } else if runtime.GOOS == "windows" { - if env := os.Getenv(AppDataEnv); env != "" { - path = filepath.Join(env, root) - } - } else { +// getConfigDir determines the directory for storing configurations. +func getConfigDir(root string) string { + switch { + case envSet("RAYSTACK_CONFIG_DIR"): + return filepath.Join(os.Getenv("RAYSTACK_CONFIG_DIR"), root) + case envSet("XDG_CONFIG_HOME"): + return filepath.Join(os.Getenv("XDG_CONFIG_HOME"), root) + case runtime.GOOS == "windows" && envSet("APPDATA"): + return filepath.Join(os.Getenv("APPDATA"), root) + default: home, _ := os.UserHomeDir() - path = filepath.Join(home, ".config", root) + return filepath.Join(home, ".config", root) } +} - if !dirExists(path) { - _ = os.MkdirAll(filepath.Dir(path), 0755) +// ensureDir ensures that the given directory exists. +func ensureDir(dir string) error { + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("failed to create directory %q: %w", dir, err) } - - return path + return nil } -func dirExists(path string) bool { - f, err := os.Stat(path) - return err == nil && f.IsDir() +// envSet checks if an environment variable is set and non-empty. +func envSet(key string) bool { + return os.Getenv(key) != "" } func fileExists(filename string) bool { diff --git a/config/config.go b/config/config.go index a149fd6..25b595d 100644 --- a/config/config.go +++ b/config/config.go @@ -167,7 +167,7 @@ func (l *Loader) Load(config interface{}) error { func validateStructPtr(value interface{}) error { val := reflect.ValueOf(value) if val.Kind() != reflect.Ptr || val.Elem().Kind() != reflect.Struct { - return errors.New("Load requires a pointer to a struct") + return errors.New("load requires a pointer to a struct") } return nil } diff --git a/go.sum b/go.sum index 8142e6c..274a252 100644 --- a/go.sum +++ b/go.sum @@ -496,7 +496,7 @@ golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roY golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.22.0 h1:gqSGLZqv+AI9lIQzniJ0nZDRG5GBPsSi+DRNHWNz6yA= -golang.org/x/tools v0.22.0/go.mod h1:aCwcsjqvq7Yqt6TNyX7QMU2enbQ/Gt0bo6krSeEri+c= +golang.org/x/tools v0.22.0/go.mod h1:aCwcsjqvq7Yqt6TNyX7QMU2enbQ/Gt0bo6krSeEri+cfg= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=