diff --git a/cmd/root.go b/cmd/root.go index 33346a65d..e0bad74b3 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -23,6 +23,7 @@ import ( "os" "path/filepath" + "github.com/kitops-ml/kitops/pkg/cmd/config" "github.com/kitops-ml/kitops/pkg/cmd/dev" "github.com/kitops-ml/kitops/pkg/cmd/diff" "github.com/kitops-ml/kitops/pkg/cmd/info" @@ -73,33 +74,44 @@ func RunCommand() *cobra.Command { PersistentPreRunE: func(cmd *cobra.Command, args []string) error { output.SetOut(cmd.OutOrStdout()) output.SetErr(cmd.ErrOrStderr()) - if err := output.SetLogLevelFromString(opts.loglevel); err != nil { - return output.Fatalln(err) - } - output.SetProgressBars(opts.progressBars) - - switch opts.verbosity { - case 0: - break - case 1: - output.Debugf("Setting verbosity to %s", output.LogLevelDebug) - output.SetLogLevel(output.LogLevelDebug) - case 2: - output.Debugf("Setting verbosity to %s", output.LogLevelTrace) - output.SetLogLevel(output.LogLevelTrace) - default: - output.Debugf("Setting verbosity to %s and disabling progress bars", output.LogLevelTrace) - output.SetLogLevel(output.LogLevelTrace) - output.SetProgressBars("none") - } - configHome, err := getConfigHome(opts) + configHome, err := getConfigHomePath(opts) if err != nil { output.Errorf("Failed to read base config directory") output.Infof("Use the --config flag or set the $%s environment variable to provide a default", constants.KitopsHomeEnvVar) output.Debugf("Error: %s", err) return errors.New("exit") } + + configYamlPath := constants.ConfigYamlPath(configHome) + configStruct, loadErr := config.LoadConfigFileHelper(configYamlPath) + if loadErr != nil && !errors.Is(loadErr, os.ErrNotExist) { + return loadErr + } + + if cmd.Flags().Changed("log-level") || configStruct.LogLevel == "" { + if err := output.SetLogLevelFromString(opts.loglevel); err != nil { + return output.Fatalln(err) + } + } else { + if err := output.SetLogLevelFromString(configStruct.LogLevel); err != nil { + output.Errorf("Invalid log level: %s", err) + output.SetLogLevelFromString(opts.loglevel) + } + } + + if cmd.Flags().Changed("progress") || configStruct.ProgressBars == "" { + output.SetProgressBars(opts.progressBars) + } else { + output.SetProgressBars(configStruct.ProgressBars) + } + + if cmd.Flags().Changed("verbose") || configStruct.Verbosity == 0 { + setVerbosity(opts.verbosity) + } else { + setVerbosity(configStruct.Verbosity) + } + ctx := context.WithValue(cmd.Context(), constants.ConfigKey{}, configHome) cache.SetCacheHome(constants.CachePath(configHome)) cmd.SetContext(ctx) @@ -167,6 +179,7 @@ func addSubcommands(rootCmd *cobra.Command) { rootCmd.AddCommand(diff.DiffCommand()) rootCmd.AddCommand(kitimport.ImportCommand()) rootCmd.AddCommand(kitcache.CacheCommand()) + rootCmd.AddCommand(config.ConfigCommand()) } // Execute adds all child commands to the root command and sets flags appropriately. @@ -178,7 +191,7 @@ func Execute() { } } -func getConfigHome(opts *rootOptions) (string, error) { +func getConfigHomePath(opts *rootOptions) (string, error) { if opts.configHome != "" { output.Debugf("Using config directory from flag: %s", opts.configHome) absHome, err := filepath.Abs(opts.configHome) @@ -198,10 +211,28 @@ func getConfigHome(opts *rootOptions) (string, error) { return absHome, nil } - defaultHome, err := constants.DefaultConfigPath() - if err != nil { - return "", err + defaultHome, defaultHomeErr := constants.DefaultConfigPath() + if defaultHomeErr != nil { + return "", defaultHomeErr } + output.Debugf("Using default config directory: %s", defaultHome) return defaultHome, nil } + +func setVerbosity(verbosity int) { + switch verbosity { + case 0: + break + case 1: + output.Debugf("Setting verbosity to %s", output.LogLevelDebug) + output.SetLogLevel(output.LogLevelDebug) + case 2: + output.Debugf("Setting verbosity to %s", output.LogLevelTrace) + output.SetLogLevel(output.LogLevelTrace) + default: + output.Debugf("Setting verbosity to %s and disabling progress bars", output.LogLevelTrace) + output.SetLogLevel(output.LogLevelTrace) + output.SetProgressBars("none") + } +} diff --git a/pkg/cmd/config/cmd.go b/pkg/cmd/config/cmd.go new file mode 100644 index 000000000..52e528282 --- /dev/null +++ b/pkg/cmd/config/cmd.go @@ -0,0 +1,150 @@ +package config + +import ( + "encoding/json" + + "fmt" + + "github.com/kitops-ml/kitops/pkg/lib/constants" + "github.com/kitops-ml/kitops/pkg/lib/util" + "github.com/kitops-ml/kitops/pkg/output" + "github.com/spf13/cobra" + "strings" +) + +type Config struct { + Verbosity int `yaml:"verbosity" json:"verbosity"` + LogLevel string `yaml:"logLevel" json:"logLevel"` + ProgressBars string `yaml:"progressBars" json:"progressBars"` +} + +func ConfigCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "config", + Short: configShortDesc, + Long: configLongDesc, + } + cmd.AddCommand(configSetCommand()) + cmd.AddCommand(configGetCommand()) + cmd.AddCommand(configListCommand()) + cmd.AddCommand(configResetCommand()) + + return cmd +} + +func configSetCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "set KEY VALUE", + Short: setConfigShortDesc, + Long: setConfigLongDesc, + RunE: runSetCommand, + } + cmd.Args = cobra.ExactArgs(2) + + return cmd +} + +func runSetCommand(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + path, ok := ctx.Value(constants.ConfigKey{}).(string) + if !ok { + return fmt.Errorf("failed to retrieve config path from context") + } + if err := setConfig(args[0], args[1], path); err != nil { + return output.Fatalf("%s", err) + } + return nil +} + +func configGetCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "get KEY", + Short: getConfigShortDesc, + Long: getConfigLongDesc, + RunE: runGetCommand, + } + cmd.Args = cobra.ExactArgs(1) + + return cmd +} + +func runGetCommand(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + path, ok := ctx.Value(constants.ConfigKey{}).(string) + if !ok { + return fmt.Errorf("failed to retrieve config path from context") + } + val, err := getConfig(args[0], path) + if err != nil { + return output.Fatalf("%s", err) + } + fmt.Fprintln(output.GetOut(), val) + return nil +} + +func configListCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: listConfigShortDesc, + Long: listConfigLongDesc, + RunE: runListCommand, + } + cmd.Args = cobra.NoArgs + + return cmd +} + +func runListCommand(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + path, ok := ctx.Value(constants.ConfigKey{}).(string) + if !ok { + return fmt.Errorf("failed to retrieve config path from context") + } + configStruct, err := listConfig(path) + if err != nil { + return output.Fatalf("%s", err) + } + + list, jsonErr := json.MarshalIndent(configStruct, "", " ") + + if jsonErr != nil { + return jsonErr + } + + output.Infoln(string(list)) + + return nil +} + +func configResetCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "reset", + Short: resetConfigShortDesc, + Long: resetConfigLongDesc, + RunE: runResetCommand, + } + cmd.Args = cobra.NoArgs + + return cmd +} + +func runResetCommand(cmd *cobra.Command, args []string) error { + warning := "Warning: this action is destructive and cannot be undone.Proceed? (y/N): " + + choice, choiceErr := util.PromptForInput(warning, false) + if choiceErr != nil { + return choiceErr + } + + if !strings.EqualFold(choice, "y") && !strings.EqualFold(choice, "yes") { + return nil + } + + ctx := cmd.Context() + path, ok := ctx.Value(constants.ConfigKey{}).(string) + if !ok { + return fmt.Errorf("failed to retrieve config path from context") + } + + return resetConfig(path) +} diff --git a/pkg/cmd/config/config.go b/pkg/cmd/config/config.go new file mode 100644 index 000000000..d3699ceb1 --- /dev/null +++ b/pkg/cmd/config/config.go @@ -0,0 +1,122 @@ +package config + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strconv" + + "github.com/kitops-ml/kitops/pkg/lib/constants" + "gopkg.in/yaml.v3" +) + +func setConfig(key, value, path string) error { + + configYamlPath := constants.ConfigYamlPath(path) + + configStruct, loadConfigErr := LoadConfigFileHelper(configYamlPath) + if loadConfigErr != nil && !errors.Is(loadConfigErr, os.ErrNotExist) { + return loadConfigErr + } + + switch key { + case "logLevel": + configStruct.LogLevel = value + case "progressBars": + configStruct.ProgressBars = value + case "verbosity": + intValue, err := strconv.Atoi(value) + if err != nil { + return err + } + configStruct.Verbosity = intValue + default: + return fmt.Errorf("invalid config key: %s", key) + } + + if err := saveConfigFile(configStruct, configYamlPath); err != nil { + return fmt.Errorf("failed to save setting: %w", err) + } + + return nil +} + +func getConfig(key, path string) (string, error) { + configYamlPath := constants.ConfigYamlPath(path) + + configStruct, loadErr := LoadConfigFileHelper(configYamlPath) + if loadErr != nil { + return "", loadErr + } + + switch key { + case "logLevel": + return configStruct.LogLevel, nil + case "progressBars": + return configStruct.ProgressBars, nil + case "verbosity": + stringValue := strconv.Itoa(configStruct.Verbosity) + return stringValue, nil + default: + return "", fmt.Errorf("invalid config key: %s", key) + } + +} + +func listConfig(path string) (Config, error) { + configYamlPath := constants.ConfigYamlPath(path) + + configStruct, loadErr := LoadConfigFileHelper(configYamlPath) + if loadErr != nil { + return Config{}, loadErr + } + + return configStruct, nil +} + +func resetConfig(path string) error { + configYamlPath := constants.ConfigYamlPath(path) + + if err := os.Remove(configYamlPath); err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + + return nil +} + +func LoadConfigFileHelper(configYamlPath string) (Config, error) { + data, readErr := os.ReadFile(configYamlPath) + var cfg Config + + if readErr != nil { + if !errors.Is(readErr, os.ErrNotExist) { + return cfg, readErr + } + return cfg, fmt.Errorf("config file does not exist: %w", readErr) + } + + unmarshErr := yaml.Unmarshal(data, &cfg) + if unmarshErr != nil { + return cfg, fmt.Errorf("failed to unmarshal data: %w", unmarshErr) + } + + return cfg, nil +} + +func saveConfigFile(configStruct Config, configYamlPath string) error { + yamlconfigStruct, marshErr := yaml.Marshal(configStruct) + if marshErr != nil { + return fmt.Errorf("failed to marshal data: %w", marshErr) + } + + configDir := filepath.Dir(configYamlPath) + if err := os.MkdirAll(configDir, 0755); err != nil { + return fmt.Errorf("failed to create config directory: %w", err) + } + + if writeErr := os.WriteFile(configYamlPath, yamlconfigStruct, 0644); writeErr != nil { + return fmt.Errorf("failed to set to config file: %w", writeErr) + } + return nil +} diff --git a/pkg/cmd/config/usage.go b/pkg/cmd/config/usage.go new file mode 100644 index 000000000..2b9bbe8b1 --- /dev/null +++ b/pkg/cmd/config/usage.go @@ -0,0 +1,27 @@ +package config + +const ( + configShortDesc = `Manage global configuration settings for the kitops CLI` + configLongDesc = `The config command allows you to view and modify persistent settings for the Kitops CLI. + These settings are saved to a local configuration file, + allowing you to establish default behaviors—such as default log levels or storage paths— + without needing to pass flags manually on every command execution. + Use the available subcommands to set, get, list, or reset your preferences.` + + setConfigShortDesc = `Set a configuration value for Kitops` + setConfigLongDesc = `Sets a specific configuration key to a given value in the local config.yaml file. + If the configuration file or directory does not exist, + it will be created automatically in the default cross-platform location.` + + getConfigShortDesc = `Retrieve the value of a configuration key` + getConfigLongDesc = `Retrieves the currently set value for a specific configuration key from the KitOps configuration file. + If the key exists, its value will be printed to standard output. If the key is not found, the command will return an error.` + + listConfigShortDesc = `List all saved configuration settings` + listConfigLongDesc = `Prints a complete, alphabetically sorted list of all key-value pairs currently stored in the config.yaml file. + If no configuration file has been created yet, the command will exit quietly without outputting any text.` + + resetConfigShortDesc = `Clear all saved configuration settings` + resetConfigLongDesc = `Permanently removes all key-value pairs from the config.yaml file, returning the CLI to its default, unconfigured state. + Warning: This action is destructive and cannot be undone.` +) diff --git a/pkg/lib/constants/consts.go b/pkg/lib/constants/consts.go index 85b2b272c..1ba5ddad2 100644 --- a/pkg/lib/constants/consts.go +++ b/pkg/lib/constants/consts.go @@ -40,6 +40,7 @@ const ( StorageSubpath = "storage" CacheSubpath = "cache" CredentialsSubpath = "credentials.json" + ConfigYamlSubpath = "config.yaml" HarnessSubpath = "harness" HarnessProcessFile = "process.pid" HarnessLogFile = "harness.log" @@ -137,6 +138,10 @@ func CredentialsPath(configBase string) string { return filepath.Join(configBase, CredentialsSubpath) } +func ConfigYamlPath(configBase string) string { + return filepath.Join(configBase, ConfigYamlSubpath) +} + func CachePath(configBase string) string { return filepath.Join(configBase, CacheSubpath) }