From dc566deea1edda4173bbbd2bab07a9d88ab733de Mon Sep 17 00:00:00 2001 From: Cyril David Date: Tue, 1 Sep 2020 09:06:42 -0700 Subject: [PATCH] Initial sketch: The objective was to play out the use of cobra without any inits. This work was heavily inspired from stripe-cli. --- internal/ansi/ansi.go | 196 ++++++++++++++++ internal/cmd/actions.go | 143 ++++++++++++ internal/cmd/root.go | 61 +++++ internal/cmd/templates.go | 223 +++++++++++++++++++ internal/cmd/triggers.go | 39 ++++ internal/config/config.go | 319 +++++++++++++++++++++++++++ internal/config/config_test.go | 20 ++ internal/config/profile.go | 180 +++++++++++++++ internal/config/profile_test.go | 99 +++++++++ internal/validators/cmds.go | 71 ++++++ internal/validators/cmds_test.go | 48 ++++ internal/validators/validate.go | 160 ++++++++++++++ internal/validators/validate_test.go | 88 ++++++++ 13 files changed, 1647 insertions(+) create mode 100644 internal/ansi/ansi.go create mode 100644 internal/cmd/actions.go create mode 100644 internal/cmd/root.go create mode 100644 internal/cmd/templates.go create mode 100644 internal/cmd/triggers.go create mode 100644 internal/config/config.go create mode 100644 internal/config/config_test.go create mode 100644 internal/config/profile.go create mode 100644 internal/config/profile_test.go create mode 100644 internal/validators/cmds.go create mode 100644 internal/validators/cmds_test.go create mode 100644 internal/validators/validate.go create mode 100644 internal/validators/validate_test.go diff --git a/internal/ansi/ansi.go b/internal/ansi/ansi.go new file mode 100644 index 000000000..8970b9254 --- /dev/null +++ b/internal/ansi/ansi.go @@ -0,0 +1,196 @@ +package ansi + +import ( + "fmt" + "io" + "os" + "runtime" + "time" + + "github.com/briandowns/spinner" + "github.com/logrusorgru/aurora" + "github.com/tidwall/pretty" + "golang.org/x/crypto/ssh/terminal" +) + +var darkTerminalStyle = &pretty.Style{ + Key: [2]string{"\x1B[34m", "\x1B[0m"}, + String: [2]string{"\x1B[30m", "\x1B[0m"}, + Number: [2]string{"\x1B[94m", "\x1B[0m"}, + True: [2]string{"\x1B[35m", "\x1B[0m"}, + False: [2]string{"\x1B[35m", "\x1B[0m"}, + Null: [2]string{"\x1B[31m", "\x1B[0m"}, +} + +// ForceColors forces the use of colors and other ANSI sequences. +var ForceColors = false + +// DisableColors disables all colors and other ANSI sequences. +var DisableColors = false + +// EnvironmentOverrideColors overs coloring based on `CLICOLOR` and +// `CLICOLOR_FORCE`. Cf. https://bixense.com/clicolors/ +var EnvironmentOverrideColors = true + +// Bold returns bolded text if the writer supports colors +func Bold(text string) string { + color := Color(os.Stdout) + return color.Sprintf(color.Bold(text)) +} + +// Color returns an aurora.Aurora instance with colors enabled or disabled +// depending on whether the writer supports colors. +func Color(w io.Writer) aurora.Aurora { + return aurora.NewAurora(shouldUseColors(w)) +} + +// ColorizeJSON returns a colorized version of the input JSON, if the writer +// supports colors. +func ColorizeJSON(json string, darkStyle bool, w io.Writer) string { + if !shouldUseColors(w) { + return json + } + + style := (*pretty.Style)(nil) + if darkStyle { + style = darkTerminalStyle + } + + return string(pretty.Color([]byte(json), style)) +} + +// ColorizeStatus returns a colorized number for HTTP status code +func ColorizeStatus(status int) aurora.Value { + color := Color(os.Stdout) + + switch { + case status >= 500: + return color.Red(status).Bold() + case status >= 300: + return color.Yellow(status).Bold() + default: + return color.Green(status).Bold() + } +} + +// Faint returns slightly offset color text if the writer supports it +func Faint(text string) string { + color := Color(os.Stdout) + return color.Sprintf(color.Faint(text)) +} + +// Italic returns italicized text if the writer supports it. +func Italic(text string) string { + color := Color(os.Stdout) + return color.Sprintf(color.Italic(text)) +} + +// Linkify returns an ANSI escape sequence with an hyperlink, if the writer +// supports colors. +func Linkify(text, url string, w io.Writer) string { + if !shouldUseColors(w) { + return text + } + + // See https://gist.github.com/egmontkob/eb114294efbcd5adb1944c9f3cb5feda + // for more information about this escape sequence. + return fmt.Sprintf("\x1b]8;;%s\x1b\\%s\x1b]8;;\x1b\\", url, text) +} + +type charset = []string + +func getCharset() charset { + // See https://github.com/briandowns/spinner#available-character-sets for + // list of available charsets + if runtime.GOOS == "windows" { + // Less fancy, but uses ASCII characters so works with Windows default + // console. + return spinner.CharSets[8] + } + return spinner.CharSets[11] +} + +const duration = time.Duration(100) * time.Millisecond + +// StartNewSpinner starts a new spinner with the given message. If the writer is not +// a terminal or doesn't support colors, it simply prints the message. +func StartNewSpinner(msg string, w io.Writer) *spinner.Spinner { + if !isTerminal(w) || !shouldUseColors(w) { + fmt.Fprintln(w, msg) + return nil + } + + s := spinner.New(getCharset(), duration) + s.Writer = w + + if msg != "" { + s.Suffix = " " + msg + } + + s.Start() + + return s +} + +// StartSpinner updates an existing spinner's message, and starts it if it was stopped +func StartSpinner(s *spinner.Spinner, msg string, w io.Writer) { + if s == nil { + fmt.Fprintln(w, msg) + return + } + if msg != "" { + s.Suffix = " " + msg + } + if !s.Active() { + s.Start() + } +} + +// StopSpinner stops a spinner with the given message. If the writer is not +// a terminal or doesn't support colors, it simply prints the message. +func StopSpinner(s *spinner.Spinner, msg string, w io.Writer) { + if !isTerminal(w) || !shouldUseColors(w) { + fmt.Fprintln(w, msg) + return + } + + if msg != "" { + s.FinalMSG = "> " + msg + "\n" + } + + s.Stop() +} + +// StrikeThrough returns struck though text if the writer supports colors +func StrikeThrough(text string) string { + color := Color(os.Stdout) + return color.Sprintf(color.StrikeThrough(text)) +} + +func isTerminal(w io.Writer) bool { + switch v := w.(type) { + case *os.File: + return terminal.IsTerminal(int(v.Fd())) + default: + return false + } +} + +func shouldUseColors(w io.Writer) bool { + useColors := ForceColors || isTerminal(w) + + if EnvironmentOverrideColors { + force, ok := os.LookupEnv("CLICOLOR_FORCE") + + switch { + case ok && force != "0": + useColors = true + case ok && force == "0": + useColors = false + case os.Getenv("CLICOLOR") == "0": + useColors = false + } + } + + return useColors && !DisableColors +} diff --git a/internal/cmd/actions.go b/internal/cmd/actions.go new file mode 100644 index 000000000..dac48b2db --- /dev/null +++ b/internal/cmd/actions.go @@ -0,0 +1,143 @@ +package cmd + +import ( + "fmt" + + "github.com/auth0/auth0-cli/internal/config" + "github.com/spf13/cobra" +) + +func actionsCmd(cfg *config.Config) *cobra.Command { + cmd := &cobra.Command{ + Use: "actions", + Short: "manage resources for actions.", + } + + cmd.SetUsageTemplate(resourceUsageTemplate()) + cmd.AddCommand(listActionsCmd(cfg)) + cmd.AddCommand(createActionCmd(cfg)) + cmd.AddCommand(renameActionCmd(cfg)) + cmd.AddCommand(deleteActionCmd(cfg)) + cmd.AddCommand(deployActionCmd(cfg)) + + return cmd +} + +func listActionsCmd(cfg *config.Config) *cobra.Command { + var flags struct { + trigger string + } + + cmd := &cobra.Command{ + Use: "list", + Short: "List existing actions", + Long: `List all actions within a tenant.`, + RunE: func(cmd *cobra.Command, args []string) error { + fmt.Println("list called") + return nil + }, + } + + cmd.Flags().StringVarP(&flags.trigger, + "trigger", "t", "", "Only list actions within this trigger.", + ) + + return cmd +} + +func createActionCmd(cfg *config.Config) *cobra.Command { + var flags struct { + trigger string + } + + cmd := &cobra.Command{ + Use: "create ", + Short: "Create an action.", + Long: `Creates an action, and generates a few files for working with actions: + +- code.js - function signature. +- testdata.json - sample payload for testing the action. +`, + RunE: func(cmd *cobra.Command, args []string) error { + fmt.Println("create called") + return nil + }, + } + + cmd.Flags().StringVarP(&flags.trigger, + "trigger", "t", "", "Supported trigger for the action.", + ) + + return cmd +} + +func deployActionCmd(cfg *config.Config) *cobra.Command { + cmd := &cobra.Command{ + Use: "deploy ", + Short: "Deploy an action.", + Long: `Deploy an action. This creates a new version. + +The deploy lifecycle is as follows: + +1. Build the code artifact. Produces a new version. +2. Route production traffic at it. +3. Bind it to the associated trigger (if not already bound). +`, + RunE: func(cmd *cobra.Command, args []string) error { + fmt.Println("deploy called") + return nil + }, + } + + return cmd +} + +func renameActionCmd(cfg *config.Config) *cobra.Command { + cmd := &cobra.Command{ + Use: "rename ", + Short: "Rename an existing action.", + Long: `Renames an action. If any generated files are found those files are also renamed.: + +The following generated files will be moved: + +- code.js +- testdata.json +`, + RunE: func(cmd *cobra.Command, args []string) error { + fmt.Println("rename called") + return nil + }, + } + + return cmd +} + +func deleteActionCmd(cfg *config.Config) *cobra.Command { + var flags struct { + confirm string + } + + cmd := &cobra.Command{ + Use: "delete ", + Short: "Delete an existing action.", + Long: `Deletes an existing action. Only actions not bound to triggers can be deleted. + +To delete an action already bound, you have to: + +1. Remove it from the trigger. +2. Delete the action after. + +Note that all code artifacts will also be deleted. +`, + RunE: func(cmd *cobra.Command, args []string) error { + fmt.Println("delete called") + return nil + }, + } + + cmd.Flags().StringVarP(&flags.confirm, + "confirm", "c", "", "Confirm the action name to be deleted.", + ) + + return cmd +} diff --git a/internal/cmd/root.go b/internal/cmd/root.go new file mode 100644 index 000000000..6c8b36fb9 --- /dev/null +++ b/internal/cmd/root.go @@ -0,0 +1,61 @@ +package cmd + +import ( + "fmt" + "os" + + "github.com/auth0/auth0-cli/internal/config" + "github.com/spf13/afero" + "github.com/spf13/cobra" +) + +// Execute is the primary entrypoint of the CLI app. +func Execute() { + // fs is a mock friendly os.File interface. + fs := afero.NewOsFs() + + // cfg contains tenant related information, e.g. `travel0-dev`, + // `travel0-prod`. some of its information can be source via: + // 1. env var (e.g. AUTH0_API_KEY) + // 2. global flag (e.g. --api-key) + // 3. toml file (e.g. api_key = "..." in ~/.config/auth0/config.toml) + cfg := &config.Config{} + + rootCmd := &cobra.Command{ + Use: "auth0", + SilenceUsage: true, + SilenceErrors: true, + Short: "Command-line tool to interact with Auth0.", + Long: "Command-line tool to interact with Auth0.\n" + getLogin(&fs, cfg), + + PersistentPreRun: func(cmd *cobra.Command, args []string) { + // We want cfg.Init to run for any command since it provides the + // underlying tenant information we'll need to fulfill the user's + // request. + cfg.Init() + }, + } + + rootCmd.SetUsageTemplate(namespaceUsageTemplate()) + rootCmd.PersistentFlags().StringVar(&cfg.Profile.APIKey, + "api-key", "", "Your API key to use for the command") + rootCmd.PersistentFlags().StringVar(&cfg.Color, + "color", "", "turn on/off color output (on, off, auto)") + rootCmd.PersistentFlags().StringVar(&cfg.ProfilesFile, + "config", "", "config file (default is $HOME/.config/auth0/config.toml)") + rootCmd.PersistentFlags().StringVar(&cfg.Profile.DeviceName, + "device-name", "", "device name") + rootCmd.PersistentFlags().StringVar(&cfg.LogLevel, + "log-level", "info", "log level (debug, info, warn, error)") + rootCmd.PersistentFlags().StringVarP(&cfg.Profile.ProfileName, + "tenant", "", "default", "the tenant info to read from for config") + rootCmd.Flags().BoolP("version", "v", false, "Get the version of the Auth0 CLI") + + rootCmd.AddCommand(actionsCmd(cfg)) + rootCmd.AddCommand(triggersCmd(cfg)) + + if err := rootCmd.Execute(); err != nil { + fmt.Println(err) + os.Exit(1) + } +} diff --git a/internal/cmd/templates.go b/internal/cmd/templates.go new file mode 100644 index 000000000..4d1baf4ee --- /dev/null +++ b/internal/cmd/templates.go @@ -0,0 +1,223 @@ +package cmd + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/auth0/auth0-cli/internal/ansi" + "github.com/auth0/auth0-cli/internal/config" + "github.com/spf13/afero" + "github.com/spf13/cobra" + "github.com/spf13/pflag" + "golang.org/x/crypto/ssh/terminal" +) + +// +// Public functions +// + +// WrappedInheritedFlagUsages returns a string containing the usage information +// for all flags which were inherited from parent commands, wrapped to the +// terminal's width. +func WrappedInheritedFlagUsages(cmd *cobra.Command) string { + return cmd.InheritedFlags().FlagUsagesWrapped(getTerminalWidth()) +} + +// WrappedLocalFlagUsages returns a string containing the usage information +// for all flags specifically set in the current command, wrapped to the +// terminal's width. +func WrappedLocalFlagUsages(cmd *cobra.Command) string { + return cmd.LocalFlags().FlagUsagesWrapped(getTerminalWidth()) +} + +// WrappedRequestParamsFlagUsages returns a string containing the usage +// information for all request parameters flags, i.e. flags used in operation +// commands to set values for request parameters. The string is wrapped to the +// terminal's width. +func WrappedRequestParamsFlagUsages(cmd *cobra.Command) string { + var sb strings.Builder + + // We're cheating a little bit in thie method: we're not actually wrapping + // anything, just printing out the flag names and assuming that no name + // will be long enough to go over the terminal's width. + // We do this instead of using pflag's `FlagUsagesWrapped` function because + // we don't want to print the types (all request parameters flags are + // defined as strings in the CLI, but it would be confusing to print that + // out as a lot of them are not strings in the API). + // If/when we do add help strings for request parameters flags, we'll have + // to do actual wrapping. + cmd.LocalFlags().VisitAll(func(flag *pflag.Flag) { + if _, ok := flag.Annotations["request"]; ok { + sb.WriteString(fmt.Sprintf(" --%s\n", flag.Name)) + } + }) + + return sb.String() +} + +// WrappedNonRequestParamsFlagUsages returns a string containing the usage +// information for all non-request parameters flags. The string is wrapped to +// the terminal's width. +func WrappedNonRequestParamsFlagUsages(cmd *cobra.Command) string { + nonRequestParamsFlags := pflag.NewFlagSet("request", pflag.ExitOnError) + + cmd.LocalFlags().VisitAll(func(flag *pflag.Flag) { + if _, ok := flag.Annotations["request"]; !ok { + nonRequestParamsFlags.AddFlag(flag) + } + }) + + return nonRequestParamsFlags.FlagUsagesWrapped(getTerminalWidth()) +} + +// +// Private functions +// + +func getLogin(fs *afero.Fs, cfg *config.Config) string { + // We're checking against the path because we don't initialize the config + // at this point of execution. + path := cfg.GetConfigFolder(os.Getenv("XDG_CONFIG_HOME")) + file := filepath.Join(path, "config.toml") + + exists, _ := afero.Exists(*fs, file) + + if !exists { + return ` +Before using the CLI, you'll need to login: + + $ auth0 login + +If you're working with multiple tenants, you can run +the login command with the --tenant and --region flag: + + $ auth0 login --tenant travel0 --region us` + } + + return "" +} + +func getUsageTemplate() string { + return fmt.Sprintf(`%s{{if .Runnable}} + {{.UseLine}}{{end}}{{if .HasAvailableSubCommands}} + {{.CommandPath}} [parameters...]{{end}}{{if gt (len .Aliases) 0}} + +%s + {{.NameAndAliases}}{{end}}{{if .HasExample}} + +%s +{{.Example}}{{end}}{{if .HasAvailableSubCommands}} + +%s{{range .Commands}}{{if (or .IsAvailableCommand (eq .Name "help"))}} + {{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{if .HasAvailableLocalFlags}} + +%s +{{WrappedLocalFlagUsages . | trimTrailingWhitespaces}}{{end}}{{if .HasAvailableInheritedFlags}} + +%s +{{WrappedInheritedFlagUsages . | trimTrailingWhitespaces}}{{end}}{{if .HasHelpSubCommands}} + +%s{{range .Commands}}{{if .IsAdditionalHelpTopicCommand}} + {{rpad .CommandPath .CommandPathPadding}} {{.Short}}{{end}}{{end}}{{end}}{{if .HasAvailableSubCommands}} + +Use "{{.CommandPath}} [command] --help" for more information about a command.{{end}} +`, + ansi.Faint("Usage:"), + ansi.Faint("Aliases:"), + ansi.Faint("Examples:"), + ansi.Faint("Available Resources:"), + ansi.Faint("Flags:"), + ansi.Faint("Global Flags:"), + ansi.Faint("Additional help topics:"), + ) +} + +func namespaceUsageTemplate() string { + return fmt.Sprintf(`%s{{if .Runnable}} + {{.UseLine}}{{end}}{{if .HasAvailableSubCommands}} + {{.CommandPath}} [parameters...]{{end}}{{if gt (len .Aliases) 0}} + +%s + {{.NameAndAliases}}{{end}}{{if .HasExample}} + +%s +{{.Example}}{{end}}{{if .HasAvailableSubCommands}} + +%s{{range .Commands}}{{if (.IsAvailableCommand)}} + {{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{if .HasAvailableLocalFlags}} + +%s +{{WrappedLocalFlagUsages . | trimTrailingWhitespaces}}{{end}}{{if .HasAvailableInheritedFlags}} + +%s +{{WrappedInheritedFlagUsages . | trimTrailingWhitespaces}}{{end}}{{if .HasHelpSubCommands}} + +%s{{range .Commands}}{{if .IsAdditionalHelpTopicCommand}} + {{rpad .CommandPath .CommandPathPadding}} {{.Short}}{{end}}{{end}}{{end}}{{if .HasAvailableSubCommands}} + +Use "{{.CommandPath}} [command] --help" for more information about a command.{{end}} +`, + ansi.Faint("Usage:"), + ansi.Faint("Aliases:"), + ansi.Faint("Examples:"), + ansi.Faint("Available Resources:"), + ansi.Faint("Flags:"), + ansi.Faint("Global Flags:"), + ansi.Faint("Additional help topics:"), + ) +} + +func resourceUsageTemplate() string { + return fmt.Sprintf(`%s{{if .Runnable}} + {{.UseLine}}{{end}}{{if .HasAvailableSubCommands}} + {{.CommandPath}} [parameters...]{{end}}{{if gt (len .Aliases) 0}} + +%s + {{.NameAndAliases}}{{end}}{{if .HasExample}} + +%s +{{.Example}}{{end}}{{if .HasAvailableSubCommands}} + +%s{{range .Commands}}{{if (or .IsAvailableCommand (eq .Name "help"))}} + {{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{if .HasAvailableLocalFlags}} + +%s +{{WrappedLocalFlagUsages . | trimTrailingWhitespaces}}{{end}}{{if .HasAvailableInheritedFlags}} + +%s +{{WrappedInheritedFlagUsages . | trimTrailingWhitespaces}}{{end}}{{if .HasHelpSubCommands}} + +%s{{range .Commands}}{{if .IsAdditionalHelpTopicCommand}} + {{rpad .CommandPath .CommandPathPadding}} {{.Short}}{{end}}{{end}}{{end}}{{if .HasAvailableSubCommands}} + +Use "{{.CommandPath}} [command] --help" for more information about a command.{{end}} +`, + ansi.Faint("Usage:"), + ansi.Faint("Aliases:"), + ansi.Faint("Examples:"), + ansi.Faint("Available Operations:"), + ansi.Faint("Flags:"), + ansi.Faint("Global Flags:"), + ansi.Faint("Additional help topics:"), + ) +} + +func getTerminalWidth() int { + var width int + + width, _, err := terminal.GetSize(0) + if err != nil { + width = 80 + } + + return width +} + +func init() { + cobra.AddTemplateFunc("WrappedInheritedFlagUsages", WrappedInheritedFlagUsages) + cobra.AddTemplateFunc("WrappedLocalFlagUsages", WrappedLocalFlagUsages) + cobra.AddTemplateFunc("WrappedRequestParamsFlagUsages", WrappedRequestParamsFlagUsages) + cobra.AddTemplateFunc("WrappedNonRequestParamsFlagUsages", WrappedNonRequestParamsFlagUsages) +} diff --git a/internal/cmd/triggers.go b/internal/cmd/triggers.go new file mode 100644 index 000000000..bca50c05c --- /dev/null +++ b/internal/cmd/triggers.go @@ -0,0 +1,39 @@ +package cmd + +import ( + "fmt" + + "github.com/auth0/auth0-cli/internal/config" + "github.com/spf13/cobra" +) + +func triggersCmd(cfg *config.Config) *cobra.Command { + cmd := &cobra.Command{ + Use: "triggers", + Short: "manage resources for triggers.", + } + + cmd.SetUsageTemplate(resourceUsageTemplate()) + cmd.AddCommand(listTriggersCmd()) + + return cmd +} + +func listTriggersCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "A brief description of your command", + Long: `A longer description that spans multiple lines and likely contains examples +and usage of using your command. For example: + +Cobra is a CLI library for Go that empowers applications. +This application is a tool to generate the needed files +to quickly create a Cobra application.`, + RunE: func(cmd *cobra.Command, args []string) error { + fmt.Println("list triggers") + return nil + }, + } + + return cmd +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 000000000..e32afddb9 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,319 @@ +package config + +import ( + "bytes" + "fmt" + "io/ioutil" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "time" + + "github.com/BurntSushi/toml" + "github.com/auth0/auth0-cli/internal/ansi" + "github.com/mitchellh/go-homedir" + log "github.com/sirupsen/logrus" + "github.com/spf13/viper" + prefixed "github.com/x-cray/logrus-prefixed-formatter" +) + +const ( + // ColorOn represnets the on-state for colors + ColorOn = "on" + + // ColorOff represents the off-state for colors + ColorOff = "off" + + // ColorAuto represents the auto-state for colors + ColorAuto = "auto" +) + +// Config handles all overall configuration for the CLI +type Config struct { + Color string + LogLevel string + Profile Profile + ProfilesFile string +} + +// GetConfigFolder retrieves the folder where the profiles file is stored It +// searches for the xdg environment path first and will secondarily place it in +// the home directory +func (c *Config) GetConfigFolder(xdgPath string) string { + configPath := xdgPath + + log.WithFields(log.Fields{ + "prefix": "config.Config.GetProfilesFolder", + "path": configPath, + }).Debug("Using profiles file") + + if configPath == "" { + home, err := homedir.Dir() + if err != nil { + fmt.Println(err) + os.Exit(1) + } + + configPath = filepath.Join(home, ".config") + } + + return filepath.Join(configPath, "auth0") +} + +// Init reads in profiles file and ENV variables if set. +func (c *Config) Init() { + logFormatter := &prefixed.TextFormatter{ + FullTimestamp: true, + TimestampFormat: time.RFC1123, + } + + if c.ProfilesFile != "" { + viper.SetConfigFile(c.ProfilesFile) + } else { + configFolder := c.GetConfigFolder(os.Getenv("XDG_CONFIG_HOME")) + configFile := filepath.Join(configFolder, "config.toml") + c.ProfilesFile = configFile + viper.SetConfigType("toml") + viper.SetConfigFile(configFile) + viper.SetConfigPermissions(os.FileMode(0600)) + + // Try to change permissions manually, because we used to create files + // with default permissions (0644) + err := os.Chmod(configFile, os.FileMode(0600)) + if err != nil && !os.IsNotExist(err) { + log.Fatalf("%s", err) + } + } + + // If a profiles file is found, read it in. + if err := viper.ReadInConfig(); err == nil { + log.WithFields(log.Fields{ + "prefix": "config.Config.InitConfig", + "path": viper.ConfigFileUsed(), + }).Debug("Using profiles file") + } + + if c.Profile.DeviceName == "" { + deviceName, err := os.Hostname() + if err != nil { + deviceName = "unknown" + } + + c.Profile.DeviceName = deviceName + } + + color, err := c.Profile.GetColor() + if err != nil { + log.Fatalf("%s", err) + } + + switch color { + case ColorOn: + ansi.ForceColors = true + logFormatter.ForceColors = true + case ColorOff: + ansi.DisableColors = true + logFormatter.DisableColors = true + case ColorAuto: + // Nothing to do + default: + log.Fatalf("Unrecognized color value: %s. Expected one of on, off, auto.", c.Color) + } + + log.SetFormatter(logFormatter) + + // Set log level + switch c.LogLevel { + case "debug": + log.SetLevel(log.DebugLevel) + case "info": + log.SetLevel(log.InfoLevel) + case "warn": + log.SetLevel(log.WarnLevel) + case "error": + log.SetLevel(log.ErrorLevel) + default: + log.Fatalf("Unrecognized log level value: %s. Expected one of debug, info, warn, error.", c.LogLevel) + } +} + +// EditConfig opens the configuration file in the default editor. +func (c *Config) EditConfig() error { + var err error + + switch runtime.GOOS { + case "darwin", "linux": + editor := os.Getenv("EDITOR") + if editor == "" { + editor = "vi" + } + + cmd := exec.Command(editor, c.ProfilesFile) + // Some editors detect whether they have control of stdin/out and will + // fail if they do not. + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + + return cmd.Run() + case "windows": + // As far as I can tell, Windows doesn't have an easily accesible or + // comparable option to $EDITOR, so default to notepad for now + err = exec.Command("notepad", c.ProfilesFile).Run() + default: + err = fmt.Errorf("unsupported platform") + } + + return err +} + +// PrintConfig outputs the contents of the configuration file. +func (c *Config) PrintConfig() error { + if c.Profile.ProfileName == "default" { + configFile, err := ioutil.ReadFile(c.ProfilesFile) + if err != nil { + return err + } + + fmt.Print(string(configFile)) + } else { + configs := viper.GetStringMapString(c.Profile.ProfileName) + + if len(configs) > 0 { + fmt.Printf("[%s]\n", c.Profile.ProfileName) + for field, value := range configs { + fmt.Printf(" %s=%s\n", field, value) + } + } + } + + return nil +} + +// RemoveProfile removes the profile whose name matches the provided +// profileName from the config file. +func (c *Config) RemoveProfile(profileName string) error { + runtimeViper := viper.GetViper() + var err error + + for field, value := range runtimeViper.AllSettings() { + if isProfile(value) && field == profileName { + runtimeViper, err = removeKey(runtimeViper, field) + if err != nil { + return err + } + } + } + + return syncConfig(runtimeViper) +} + +// RemoveAllProfiles removes all the profiles from the config file. +func (c *Config) RemoveAllProfiles() error { + runtimeViper := viper.GetViper() + var err error + + for field, value := range runtimeViper.AllSettings() { + if isProfile(value) { + runtimeViper, err = removeKey(runtimeViper, field) + if err != nil { + return err + } + } + } + + return syncConfig(runtimeViper) +} + +// isProfile identifies whether a value in the config pertains to a profile. +func isProfile(value interface{}) bool { + // TODO: ianjabour - ideally find a better way to identify projects in config + _, ok := value.(map[string]interface{}) + return ok +} + +// syncConfig merges a runtimeViper instance with the config file being used. +func syncConfig(runtimeViper *viper.Viper) error { + runtimeViper.MergeInConfig() + profilesFile := viper.ConfigFileUsed() + runtimeViper.SetConfigFile(profilesFile) + // Ensure we preserve the config file type + runtimeViper.SetConfigType(filepath.Ext(profilesFile)) + + err := runtimeViper.WriteConfig() + if err != nil { + return err + } + + return nil +} + +// Temporary workaround until https://github.com/spf13/viper/pull/519 can remove a key from viper +func removeKey(v *viper.Viper, key string) (*viper.Viper, error) { + configMap := v.AllSettings() + path := strings.Split(key, ".") + lastKey := strings.ToLower(path[len(path)-1]) + deepestMap := deepSearch(configMap, path[0:len(path)-1]) + delete(deepestMap, lastKey) + + buf := new(bytes.Buffer) + + encodeErr := toml.NewEncoder(buf).Encode(configMap) + if encodeErr != nil { + return nil, encodeErr + } + + nv := viper.New() + nv.SetConfigType("toml") // hint to viper that we've encoded the data as toml + + err := nv.ReadConfig(buf) + if err != nil { + return nil, err + } + + return nv, nil +} + +func makePath(path string) error { + dir := filepath.Dir(path) + + if _, err := os.Stat(dir); os.IsNotExist(err) { + err = os.MkdirAll(dir, os.ModePerm) + if err != nil { + return err + } + } + + return nil +} + +// taken from https://github.com/spf13/viper/blob/master/util.go#L199, +// we need this to delete configs, remove when viper supprts unset natively +func deepSearch(m map[string]interface{}, path []string) map[string]interface{} { + for _, k := range path { + m2, ok := m[k] + if !ok { + // intermediate key does not exist + // => create it and continue from there + m3 := make(map[string]interface{}) + m[k] = m3 + m = m3 + + continue + } + + m3, ok := m2.(map[string]interface{}) + if !ok { + // intermediate key is a value + // => replace with a new map + m3 = make(map[string]interface{}) + m[k] = m3 + } + + // continue search from here + m = m3 + } + + return m +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 000000000..274adc7fe --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,20 @@ +package config + +import ( + "testing" + + "github.com/spf13/viper" + "github.com/stretchr/testify/require" +) + +func TestRemoveKey(t *testing.T) { + v := viper.New() + v.Set("remove", "me") + v.Set("stay", "here") + + nv, err := removeKey(v, "remove") + require.NoError(t, err) + + require.EqualValues(t, []string{"stay"}, nv.AllKeys()) + require.ElementsMatch(t, []string{"stay", "remove"}, v.AllKeys()) +} diff --git a/internal/config/profile.go b/internal/config/profile.go new file mode 100644 index 000000000..6be84e196 --- /dev/null +++ b/internal/config/profile.go @@ -0,0 +1,180 @@ +package config + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/auth0/auth0-cli/internal/validators" + "github.com/spf13/viper" +) + +// Profile handles all things related to managing the project specific configurations +type Profile struct { + DeviceName string + ProfileName string + APIKey string + TerminalPOSDeviceID string +} + +// CreateProfile creates a profile when logging in +func (p *Profile) CreateProfile() error { + writeErr := p.writeProfile(viper.GetViper()) + if writeErr != nil { + return writeErr + } + + return nil +} + +// GetColor gets the color setting for the user based on the flag or the +// persisted color stored in the config file +func (p *Profile) GetColor() (string, error) { + color := viper.GetString("color") + if color != "" { + return color, nil + } + + color = viper.GetString(p.GetConfigField("color")) + switch color { + case "", ColorAuto: + return ColorAuto, nil + case ColorOn: + return ColorOn, nil + case ColorOff: + return ColorOff, nil + default: + return "", fmt.Errorf("color value not supported: %s", color) + } +} + +// GetDeviceName returns the configured device name +func (p *Profile) GetDeviceName() (string, error) { + if os.Getenv("AUTH0_DEVICE_NAME") != "" { + return os.Getenv("AUTH0_DEVICE_NAME"), nil + } + + if p.DeviceName != "" { + return p.DeviceName, nil + } + + if err := viper.ReadInConfig(); err == nil { + return viper.GetString(p.GetConfigField("device_name")), nil + } + + return "", errors.New("your device name has not been configured. Use `auth0 login` to set your device name") +} + +// GetAPIKey will return the existing key for the given profile +func (p *Profile) GetAPIKey() (string, error) { + envKey := os.Getenv("AUTH0_API_KEY") + if envKey != "" { + err := validators.APIKey(envKey) + if err != nil { + return "", err + } + + return envKey, nil + } + + if p.APIKey != "" { + err := validators.APIKey(p.APIKey) + if err != nil { + return "", err + } + + return p.APIKey, nil + } + + // Try to fetch the API key from the configuration file + if err := viper.ReadInConfig(); err == nil { + key := viper.GetString(p.GetConfigField("api_key")) + + err := validators.APIKey(key) + if err != nil { + return "", err + } + + return key, nil + } + + return "", errors.New("your API key has not been configured. Use `auth0 login` to set your API key") +} + +// GetTerminalPOSDeviceID returns the device id from the config for Terminal quickstart to use +func (p *Profile) GetTerminalPOSDeviceID() string { + if err := viper.ReadInConfig(); err == nil { + return viper.GetString(p.GetConfigField("terminal_pos_device_id")) + } + + return "" +} + +// GetConfigField returns the configuration field for the specific profile +func (p *Profile) GetConfigField(field string) string { + return p.ProfileName + "." + field +} + +// RegisterAlias registers an alias for a given key. +func (p *Profile) RegisterAlias(alias, key string) { + viper.RegisterAlias(p.GetConfigField(alias), p.GetConfigField(key)) +} + +// WriteConfigField updates a configuration field and writes the updated +// configuration to disk. +func (p *Profile) WriteConfigField(field, value string) error { + viper.Set(p.GetConfigField(field), value) + return viper.WriteConfig() +} + +// DeleteConfigField deletes a configuration field. +func (p *Profile) DeleteConfigField(field string) error { + v, err := removeKey(viper.GetViper(), p.GetConfigField(field)) + if err != nil { + return err + } + + return p.writeProfile(v) +} + +func (p *Profile) writeProfile(runtimeViper *viper.Viper) error { + profilesFile := viper.ConfigFileUsed() + + err := makePath(profilesFile) + if err != nil { + return err + } + + if p.DeviceName != "" { + runtimeViper.Set(p.GetConfigField("device_name"), strings.TrimSpace(p.DeviceName)) + } + + runtimeViper.MergeInConfig() + + runtimeViper.SetConfigFile(profilesFile) + + // Ensure we preserve the config file type + runtimeViper.SetConfigType(filepath.Ext(profilesFile)) + + err = runtimeViper.WriteConfig() + if err != nil { + return err + } + + return nil +} + +func (p *Profile) safeRemove(v *viper.Viper, key string) *viper.Viper { + if v.IsSet(p.GetConfigField(key)) { + newViper, err := removeKey(v, p.GetConfigField(key)) + if err == nil { + // I don't want to fail the entire login process on not being able to remove + // the old secret_key field so if there's no error + return newViper + } + } + + return v +} diff --git a/internal/config/profile_test.go b/internal/config/profile_test.go new file mode 100644 index 000000000..cd9e625d4 --- /dev/null +++ b/internal/config/profile_test.go @@ -0,0 +1,99 @@ +package config + +import ( + "fmt" + "io/ioutil" + "os" + "path/filepath" + "testing" + + "github.com/spf13/viper" + "github.com/stretchr/testify/require" +) + +func TestWriteProfile(t *testing.T) { + profilesFile := filepath.Join(os.TempDir(), "stripe", "config.toml") + p := Profile{ + DeviceName: "st-testing", + ProfileName: "tests", + } + + c := &Config{ + Color: "auto", + LogLevel: "info", + Profile: p, + ProfilesFile: profilesFile, + } + c.Init() + + v := viper.New() + + fmt.Println(profilesFile) + + err := p.writeProfile(v) + require.NoError(t, err) + + require.FileExists(t, c.ProfilesFile) + + configValues := helperLoadBytes(t, c.ProfilesFile) + expectedConfig := ` +[tests] + device_name = "st-testing" +` + require.EqualValues(t, expectedConfig, string(configValues)) + + cleanUp(c.ProfilesFile) +} + +func TestWriteProfilesMerge(t *testing.T) { + profilesFile := filepath.Join(os.TempDir(), "stripe", "config.toml") + p := Profile{ + ProfileName: "tests", + DeviceName: "st-testing", + } + + c := &Config{ + Color: "auto", + LogLevel: "info", + Profile: p, + ProfilesFile: profilesFile, + } + c.Init() + + v := viper.New() + writeErr := p.writeProfile(v) + + require.NoError(t, writeErr) + require.FileExists(t, c.ProfilesFile) + + p.ProfileName = "tests-merge" + writeErrTwo := p.writeProfile(v) + require.NoError(t, writeErrTwo) + require.FileExists(t, c.ProfilesFile) + + configValues := helperLoadBytes(t, c.ProfilesFile) + expectedConfig := ` +[tests] + device_name = "st-testing" + +[tests-merge] + device_name = "st-testing" +` + + require.EqualValues(t, expectedConfig, string(configValues)) + + cleanUp(c.ProfilesFile) +} + +func helperLoadBytes(t *testing.T, name string) []byte { + bytes, err := ioutil.ReadFile(name) + if err != nil { + t.Fatal(err) + } + + return bytes +} + +func cleanUp(file string) { + os.Remove(file) +} diff --git a/internal/validators/cmds.go b/internal/validators/cmds.go new file mode 100644 index 000000000..1850a82f9 --- /dev/null +++ b/internal/validators/cmds.go @@ -0,0 +1,71 @@ +package validators + +import ( + "errors" + "fmt" + + "github.com/spf13/cobra" +) + +// NoArgs is a validator for commands to print an error when an argument is provided +func NoArgs(cmd *cobra.Command, args []string) error { + errorMessage := fmt.Sprintf( + "`%s` does not take any positional arguments. See `%s --help` for supported flags and usage", + cmd.CommandPath(), + cmd.CommandPath(), + ) + + if len(args) > 0 { + return errors.New(errorMessage) + } + + return nil +} + +// ExactArgs is a validator for commands to print an error when the number provided +// is different than the arguments passed in +func ExactArgs(num int) cobra.PositionalArgs { + return func(cmd *cobra.Command, args []string) error { + argument := "positional argument" + if num != 1 { + argument = "positional arguments" + } + + errorMessage := fmt.Sprintf( + "`%s` requires exactly %d %s. See `%s --help` for supported flags and usage", + cmd.CommandPath(), + num, + argument, + cmd.CommandPath(), + ) + + if len(args) != num { + return errors.New(errorMessage) + } + return nil + } +} + +// MaximumNArgs is a validator for commands to print an error when the provided +// args are greater than the maximum amount +func MaximumNArgs(num int) cobra.PositionalArgs { + return func(cmd *cobra.Command, args []string) error { + argument := "positional argument" + if num > 1 { + argument = "positional arguments" + } + + errorMessage := fmt.Sprintf( + "`%s` accepts at maximum %d %s. See `%s --help` for supported flags and usage", + cmd.CommandPath(), + num, + argument, + cmd.CommandPath(), + ) + + if len(args) > num { + return errors.New(errorMessage) + } + return nil + } +} diff --git a/internal/validators/cmds_test.go b/internal/validators/cmds_test.go new file mode 100644 index 000000000..17278b180 --- /dev/null +++ b/internal/validators/cmds_test.go @@ -0,0 +1,48 @@ +package validators + +import ( + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/require" +) + +func TestNoArgs(t *testing.T) { + c := &cobra.Command{Use: "c"} + args := []string{} + + result := NoArgs(c, args) + require.Nil(t, result) +} + +func TestNoArgsWithArgs(t *testing.T) { + c := &cobra.Command{Use: "c"} + args := []string{"foo"} + + result := NoArgs(c, args) + require.EqualError(t, result, "`c` does not take any positional arguments. See `c --help` for supported flags and usage") +} + +func TestExactArgs(t *testing.T) { + c := &cobra.Command{Use: "c"} + args := []string{"foo"} + + result := ExactArgs(1)(c, args) + require.Nil(t, result) +} + +func TestExactArgsTooMany(t *testing.T) { + c := &cobra.Command{Use: "c"} + args := []string{"foo", "bar"} + + result := ExactArgs(1)(c, args) + require.EqualError(t, result, "`c` requires exactly 1 positional argument. See `c --help` for supported flags and usage") +} + +func TestExactArgsTooManyMoreThan1(t *testing.T) { + c := &cobra.Command{Use: "c"} + args := []string{"foo", "bar", "baz"} + + result := ExactArgs(2)(c, args) + require.EqualError(t, result, "`c` requires exactly 2 positional arguments. See `c --help` for supported flags and usage") +} diff --git a/internal/validators/validate.go b/internal/validators/validate.go new file mode 100644 index 000000000..98c2b3c94 --- /dev/null +++ b/internal/validators/validate.go @@ -0,0 +1,160 @@ +package validators + +import ( + "errors" + "fmt" + "net/http" + "strconv" + "strings" +) + +// ArgValidator is an argument validator. It accepts a string and returns an +// error if the string is invalid, or nil otherwise. +type ArgValidator func(string) error + +// CallNonEmptyArray calls an argument validator on all non-empty elements of +// a string array. +func CallNonEmptyArray(validator ArgValidator, values []string) error { + if len(values) == 0 { + return nil + } + + for _, value := range values { + err := CallNonEmpty(validator, value) + if err != nil { + return err + } + } + + return nil +} + +// CallNonEmpty calls an argument validator on a string if the string is not +// empty. +func CallNonEmpty(validator ArgValidator, value string) error { + if value == "" { + return nil + } + + return validator(value) +} + +// APIKey validates that a string looks like an API key. +func APIKey(input string) error { + if len(input) == 0 { + return errors.New("you have not configured API keys yet. To do so, run `auth0 login` which will configure your API keys from Auth0") + } else if len(input) < 12 { + return errors.New("the API key provided is too short, it must be at least 12 characters long") + } + + return nil +} + +// APIKeyNotRestricted validates that a string looks like a secret API key and is not a restricted key. +func APIKeyNotRestricted(input string) error { + if len(input) == 0 { + return errors.New("you have not configured API keys yet. To do so, run `auth0 login` which will configure your API keys from Auth0") + } else if len(input) < 12 { + return errors.New("the API key provided is too short, it must be at least 12 characters long") + } + + keyParts := strings.Split(input, "_") + if len(keyParts) < 3 { + return errors.New("you are using a legacy-style API key which is unsupported by the CLI. Please generate a new test mode API key") + } + + if keyParts[0] != "sk" || keyParts[0] == "rk" { + return errors.New("this CLI command only supports using a secret key. Please re-run using the --api-key flag override with your secret API key") + } + + return nil +} + +// Account validates that a string is an acceptable account filter. +func Account(account string) error { + accountUpper := strings.ToUpper(account) + + if accountUpper == "CONNECT_IN" || accountUpper == "CONNECT_OUT" || accountUpper == "SELF" { + return nil + } + + return fmt.Errorf("%s is not an acceptable account filter (CONNECT_IN, CONNECT_OUT, SELF)", account) +} + +// HTTPMethod validates that a string is an acceptable HTTP method. +func HTTPMethod(method string) error { + methodUpper := strings.ToUpper(method) + + if methodUpper == http.MethodGet || methodUpper == http.MethodPost || methodUpper == http.MethodDelete { + return nil + } + + return fmt.Errorf("%s is not an acceptable HTTP method (GET, POST, DELETE)", method) +} + +// RequestSource validates that a string is an acceptable request source. +func RequestSource(source string) error { + sourceUpper := strings.ToUpper(source) + + if sourceUpper == "API" || sourceUpper == "DASHBOARD" { + return nil + } + + return fmt.Errorf("%s is not an acceptable source (API, DASHBOARD)", source) +} + +// RequestStatus validates that a string is an acceptable request status. +func RequestStatus(status string) error { + statusUpper := strings.ToUpper(status) + + if statusUpper == "SUCCEEDED" || statusUpper == "FAILED" { + return nil + } + + return fmt.Errorf("%s is not an acceptable request status (SUCCEEDED, FAILED)", status) +} + +// StatusCode validates that a provided status code is within the range of +// those used in the Auth0 API. +func StatusCode(code string) error { + num, err := strconv.Atoi(code) + if err != nil { + return err + } + + if num >= 200 && num < 300 { + return nil + } + + if num >= 400 && num < 600 { + return nil + } + + return fmt.Errorf("Provided status code %s is not in the range of acceptable status codes (200's, 400's, 500's)", code) +} + +// StatusCodeType validates that a provided status code type is one of those +// used in the Auth0 API. +func StatusCodeType(code string) error { + codeUpper := strings.ToUpper(code) + + if codeUpper != "2XX" && codeUpper != "4XX" && codeUpper != "5XX" { + return fmt.Errorf("Provided status code type %s is not a valid type (2XX, 4XX, 5XX)", code) + } + + return nil +} + +// OneDollar validates that a provided number is at least 100 (ie. 1 dollar) +func OneDollar(number string) error { + num, err := strconv.Atoi(number) + if err != nil { + return fmt.Errorf("Provided amount %v to charge should be an integer (eg. 100)", number) + } + + if num >= 100 { + return nil + } + + return fmt.Errorf("Provided amount %v to charge is not at least 100", number) +} diff --git a/internal/validators/validate_test.go b/internal/validators/validate_test.go new file mode 100644 index 000000000..7e4cd9100 --- /dev/null +++ b/internal/validators/validate_test.go @@ -0,0 +1,88 @@ +package validators + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestNoKey(t *testing.T) { + err := APIKey("") + require.EqualError(t, err, "you have not configured API keys yet. To do so, run `auth0 login` which will configure your API keys from Auth0") +} + +func TestKeyTooShort(t *testing.T) { + err := APIKey("123") + require.EqualError(t, err, "the API key provided is too short, it must be at least 12 characters long") +} + +func TestHTTPMethod(t *testing.T) { + err := HTTPMethod("GET") + require.NoError(t, err) +} + +func TestHTTPMethodInvalid(t *testing.T) { + err := HTTPMethod("invalid") + require.Equal(t, "invalid is not an acceptable HTTP method (GET, POST, DELETE)", fmt.Sprintf("%s", err)) +} + +func TestHTTPMethodLowercase(t *testing.T) { + err := HTTPMethod("post") + require.NoError(t, err) +} + +func TestRequestSourceAPI(t *testing.T) { + err := RequestSource("API") + require.NoError(t, err) +} + +func TestRequestSourceDashboard(t *testing.T) { + err := RequestSource("dashboard") + require.NoError(t, err) +} + +func TestRequestStatusSucceeded(t *testing.T) { + err := RequestStatus("succeeded") + require.NoError(t, err) +} + +func TestRequestStatusFailed(t *testing.T) { + err := RequestStatus("failed") + require.NoError(t, err) +} + +func TestRequestStatusInvalid(t *testing.T) { + err := RequestStatus("invalid") + require.Equal(t, "invalid is not an acceptable request status (SUCCEEDED, FAILED)", fmt.Sprintf("%s", err)) +} + +func TestRequestSourceInvalid(t *testing.T) { + err := RequestSource("invalid") + require.Equal(t, "invalid is not an acceptable source (API, DASHBOARD)", fmt.Sprintf("%s", err)) +} + +func TestStatusCode(t *testing.T) { + err := StatusCode("200") + require.NoError(t, err) +} + +func TestStatusCodeUnusedInStripe(t *testing.T) { + err := StatusCode("300") + require.Equal(t, "Provided status code 300 is not in the range of acceptable status codes (200's, 400's, 500's)", fmt.Sprintf("%s", err)) +} + +func TestStatusCodeType(t *testing.T) { + err := StatusCodeType("2Xx") + require.NoError(t, err) +} + +func TestStatusCodeTypeUnusedInStripe(t *testing.T) { + err := StatusCodeType("3XX") + require.Equal(t, "Provided status code type 3XX is not a valid type (2XX, 4XX, 5XX)", fmt.Sprintf("%s", err)) +} + +func TestStatusCodeNotXs(t *testing.T) { + err := StatusCodeType("201") + require.Equal(t, "Provided status code type 201 is not a valid type (2XX, 4XX, 5XX)", fmt.Sprintf("%s", err)) +}