Skip to content

Commit

Permalink
feat: improve ux for cli config management
Browse files Browse the repository at this point in the history
  • Loading branch information
ravisuhag committed Dec 6, 2024
1 parent 6ee762e commit 34cdbab
Show file tree
Hide file tree
Showing 7 changed files with 136 additions and 93 deletions.
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion LICENSE
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 5 additions & 2 deletions cli/cmdx/help.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package cmdx

import (
"errors"
"fmt"
"strings"

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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})
}
Expand Down
57 changes: 57 additions & 0 deletions cli/config/cmd.go
Original file line number Diff line number Diff line change
@@ -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
}
157 changes: 70 additions & 87 deletions cli/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}

Expand All @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
2 changes: 1 addition & 1 deletion go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down

0 comments on commit 34cdbab

Please sign in to comment.