From 2b73a7c3a1c1242826435c6e11d71bc4c9ce9693 Mon Sep 17 00:00:00 2001 From: Ravi Suhag Date: Tue, 26 Nov 2024 23:43:00 -0600 Subject: [PATCH] chore: refactor cli/cmdx package --- cli/cmdx/README.md | 193 +++++++++++++++++++++ cli/cmdx/config.go | 70 ++++---- cli/cmdx/docs.go | 40 ++++- cli/cmdx/errors.go | 35 ++++ cli/cmdx/help.go | 190 ++++++++++---------- cli/cmdx/hook.go | 20 ++- cli/cmdx/ref.go | 31 ++-- cli/cmdx/shell.go | 7 +- cli/cmdx/topic.go | 29 +++- cli/cmdx/{util.go => utils.go} | 22 +-- cli/printer/README.md | 185 ++++++++++++++++++++ cli/printer/{color.go => colors.go} | 77 ++++---- cli/printer/file.go | 63 ++++++- cli/printer/markdown.go | 53 ++++-- cli/printer/progress.go | 17 ++ cli/printer/spinner.go | 38 +++- cli/printer/table.go | 28 ++- cli/printer/text.go | 47 ++--- cli/prompt/README.md | 4 +- cli/release/README.md | 72 ++++++++ cli/release/release.go | 122 +++++++++++++ cli/{version => release}/release_test.go | 14 +- cli/terminal/README.md | 4 +- cli/version/README.md | 100 ----------- cli/version/release.go | 120 ------------- telemetry/otelhhtpclient/http_transport.go | 2 +- testing/dockertestx/cortex.go | 2 +- testing/dockertestx/minio.go | 2 +- testing/dockertestx/minio_migrate.go | 2 +- testing/dockertestx/nginx.go | 2 +- testing/dockertestx/postgres.go | 2 +- testing/dockertestx/spicedb.go | 2 +- testing/dockertestx/spicedb_migrate.go | 2 +- 33 files changed, 1088 insertions(+), 509 deletions(-) create mode 100644 cli/cmdx/README.md create mode 100644 cli/cmdx/errors.go rename cli/cmdx/{util.go => utils.go} (74%) create mode 100644 cli/printer/README.md rename cli/printer/{color.go => colors.go} (50%) create mode 100644 cli/release/README.md create mode 100644 cli/release/release.go rename cli/{version => release}/release_test.go (88%) delete mode 100644 cli/version/README.md delete mode 100644 cli/version/release.go diff --git a/cli/cmdx/README.md b/cli/cmdx/README.md new file mode 100644 index 0000000..57856b3 --- /dev/null +++ b/cli/cmdx/README.md @@ -0,0 +1,193 @@ + +# cmdx + +`cmdx` is a utility package designed to enhance the functionality of [Cobra](https://github.com/spf13/cobra), a popular Go library for creating command-line interfaces. It provides various helper functions and features to streamline CLI development, such as custom help topics, shell completion, command annotations, and client-specific configurations. + +## Features + +- **Help Topics**: Add custom help topics with descriptions and examples. +- **Shell Completions**: Generate completion scripts for Bash, Zsh, Fish, and PowerShell. +- **Command Reference**: Generate markdown documentation for all commands. +- **Client Hooks**: Apply custom configurations to commands annotated with `client:true`. + + +## Installation + +To install the `cmdx` package, add it to your project using `go get`: + +```bash +go get github.com/raystack/salt/cli/cmdx +``` + +## Usages + +### SetHelpTopicCmd + +Provides a way to define custom help topics that appear in the `help` command. + +#### Example Usage +```go +topic := map[string]string{ + "short": "Environment variables help", + "long": "Detailed information about environment variables used by the CLI.", + "example": "$ mycli help env", +} + +rootCmd.AddCommand(cmdx.SetHelpTopicCmd("env", topic)) +``` + +#### Output +```bash +$ mycli help env +Detailed information about environment variables used by the CLI. + +EXAMPLES + + $ mycli help env +``` + +--- + +### SetCompletionCmd + +Adds a `completion` command to generate shell completion scripts for Bash, Zsh, Fish, and PowerShell. + +#### Example Usage +```go +completionCmd := cmdx.SetCompletionCmd("mycli") +rootCmd.AddCommand(completionCmd) +``` + +#### Command Output +```bash +# Generate Bash completion script +$ mycli completion bash + +# Generate Zsh completion script +$ mycli completion zsh + +# Generate Fish completion script +$ mycli completion fish +``` + +#### Supported Shells +- **Bash**: Use `mycli completion bash`. +- **Zsh**: Use `mycli completion zsh`. +- **Fish**: Use `mycli completion fish`. +- **PowerShell**: Use `mycli completion powershell`. + +--- + +### SetRefCmd + +Adds a `reference` command to generate markdown documentation for all commands in the CLI hierarchy. + +#### Example Usage +```go +refCmd := cmdx.SetRefCmd(rootCmd) +rootCmd.AddCommand(refCmd) +``` + +#### Command Output +```bash +$ mycli reference +# mycli reference + +## `example` + +A sample subcommand for the CLI. + +## `another` + +Another example subcommand. +``` + +--- + +### SetClientHook + +Applies a custom function to commands annotated with `client:true`. Useful for client-specific configurations. + +#### Example Usage +```go +cmdx.SetClientHook(rootCmd, func(cmd *cobra.Command) { + cmd.PersistentPreRun = func(cmd *cobra.Command, args []string) { + fmt.Println("Executing client-specific setup for", cmd.Name()) + } +}) +``` + +#### Command Example +```go +clientCmd := &cobra.Command{ + Use: "client-action", + Short: "A client-specific action", + Annotations: map[string]string{ + "client": "true", + }, +} +rootCmd.AddCommand(clientCmd) +``` + +#### Output +```bash +$ mycli client-action +Executing client-specific setup for client-action +``` + +--- + +## Examples + +Adding all features to a CLI + +```go +package main + +import ( + "fmt" + "github.com/spf13/cobra" + "github.com/raystack/salt/cli/cmdx" +) + +func main() { + rootCmd := &cobra.Command{ + Use: "mycli", + Short: "A custom CLI tool", + } + + // Add Help Topic + topic := map[string]string{ + "short": "Environment variables help", + "long": "Details about environment variables used by the CLI.", + "example": "$ mycli help env", + } + rootCmd.AddCommand(cmdx.SetHelpTopicCmd("env", topic)) + + // Add Completion Command + rootCmd.AddCommand(cmdx.SetCompletionCmd("mycli")) + + // Add Reference Command + rootCmd.AddCommand(cmdx.SetRefCmd(rootCmd)) + + // Add Client Hook + clientCmd := &cobra.Command{ + Use: "client-action", + Short: "A client-specific action", + Annotations: map[string]string{ + "client": "true", + }, + } + rootCmd.AddCommand(clientCmd) + + cmdx.SetClientHook(rootCmd, func(cmd *cobra.Command) { + cmd.PersistentPreRun = func(cmd *cobra.Command, args []string) { + fmt.Println("Executing client-specific setup for", cmd.Name()) + } + }) + + if err := rootCmd.Execute(); err != nil { + fmt.Println("Error:", err) + } +} +``` \ No newline at end of file diff --git a/cli/cmdx/config.go b/cli/cmdx/config.go index bfe880a..855ca4a 100644 --- a/cli/cmdx/config.go +++ b/cli/cmdx/config.go @@ -13,64 +13,72 @@ import ( "github.com/raystack/salt/config" ) +// Environment variables for configuration paths const ( - RAYSTACK_CONFIG_DIR = "RAYSTACK_CONFIG_DIR" - XDG_CONFIG_HOME = "XDG_CONFIG_HOME" - APP_DATA = "AppData" - LOCAL_APP_DATA = "LocalAppData" + 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) +// WithFlags binds command-line flags to configuration values. func WithFlags(pfs *pflag.FlagSet) ConfigLoaderOpt { return func(c *Config) { - c.boundedPFlags = pfs + c.boundFlags = pfs } } +// WithLoaderOptions adds custom loader options for configuration loading. func WithLoaderOptions(opts ...config.LoaderOption) ConfigLoaderOpt { return func(c *Config) { c.loaderOpts = append(c.loaderOpts, opts...) } } -// SetConfig allows to set a client config file. It is used to -// load and save a config file for command line clients. +// SetConfig initializes a new Config object for the specified application. func SetConfig(app string) *Config { return &Config{ filename: configFile(app), } } +// Config manages the application's configuration file and related operations. type Config struct { - filename string - boundedPFlags *pflag.FlagSet - loaderOpts []config.LoaderOption + filename string + boundFlags *pflag.FlagSet + loaderOpts []config.LoaderOption } +// File returns the path to the configuration file. func (c *Config) File() string { return c.filename } +// Defaults populates the given configuration struct with default values. func (c *Config) Defaults(cfg interface{}) { defaults.SetDefaults(cfg) } +// Init initializes the configuration file with default values. func (c *Config) Init(cfg interface{}) error { defaults.SetDefaults(cfg) - if fileExist(c.filename) { - return errors.New("config file already exists") + if fileExists(c.filename) { + return errors.New("configuration file already exists") } return c.Write(cfg) } +// Read reads the content of the configuration file as a string. func (c *Config) Read() (string, error) { - cfg, err := os.ReadFile(c.filename) - return string(cfg), err + data, err := os.ReadFile(c.filename) + return string(data), err } +// Write writes the given configuration struct to the configuration file in YAML format. func (c *Config) Write(cfg interface{}) error { data, err := yaml.Marshal(cfg) if err != nil { @@ -87,6 +95,7 @@ func (c *Config) Write(cfg interface{}) error { 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) @@ -94,35 +103,36 @@ func (c *Config) Load(cfg interface{}, opts ...ConfigLoaderOpt) error { loaderOpts := []config.LoaderOption{config.WithFile(c.filename)} - if c.boundedPFlags != nil { - loaderOpts = append(loaderOpts, config.WithBindPFlags(c.boundedPFlags, cfg)) + if c.boundFlags != nil { + loaderOpts = append(loaderOpts, config.WithBindPFlags(c.boundFlags, cfg)) } loaderOpts = append(loaderOpts, c.loaderOpts...) loader := config.NewLoader(loaderOpts...) - if err := loader.Load(cfg); err != nil { - return err - } - return nil + return loader.Load(cfg) } +// configFile determines the full path to the configuration file for the application. func configFile(app string) string { - file := app + ".yml" - return filepath.Join(configDir("raystack"), file) + 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 a := os.Getenv(RAYSTACK_CONFIG_DIR); a != "" { - path = a - } else if b := os.Getenv(XDG_CONFIG_HOME); b != "" { - path = filepath.Join(b, root) - } else if c := os.Getenv(APP_DATA); runtime.GOOS == "windows" && c != "" { - path = filepath.Join(c, root) + 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 { - d, _ := os.UserHomeDir() - path = filepath.Join(d, ".config", root) + home, _ := os.UserHomeDir() + path = filepath.Join(home, ".config", root) } if !dirExists(path) { diff --git a/cli/cmdx/docs.go b/cli/cmdx/docs.go index 7a479db..aaf672a 100644 --- a/cli/cmdx/docs.go +++ b/cli/cmdx/docs.go @@ -1,6 +1,7 @@ package cmdx import ( + "fmt" "os" "path/filepath" @@ -8,18 +9,30 @@ import ( "github.com/spf13/cobra/doc" ) -// GenerateMarkdownTree generate cobra cmd commands tree as markdown file -// rootOutputPath determines the folder where the markdown files are written +// GenerateMarkdownTree generates a Markdown documentation tree for all commands +// in the given Cobra command hierarchy. +// +// Parameters: +// - rootOutputPath: The root directory where the Markdown files will be generated. +// - cmd: The root Cobra command whose hierarchy will be documented. +// +// Returns: +// - An error if any part of the process (file creation, directory creation) fails. +// +// Example Usage: +// +// rootCmd := &cobra.Command{Use: "mycli"} +// cmdx.GenerateMarkdownTree("./docs", rootCmd) func GenerateMarkdownTree(rootOutputPath string, cmd *cobra.Command) error { dirFilePath := filepath.Join(rootOutputPath, cmd.Name()) - if len(cmd.Commands()) != 0 { - if _, err := os.Stat(dirFilePath); os.IsNotExist(err) { - if err := os.Mkdir(dirFilePath, os.ModePerm); err != nil { - return err - } + if len(cmd.Commands()) > 0 { + if err := ensureDir(dirFilePath); err != nil { + return fmt.Errorf("failed to create directory for command %q: %w", cmd.Name(), err) } for _, subCmd := range cmd.Commands() { - GenerateMarkdownTree(dirFilePath, subCmd) + if err := GenerateMarkdownTree(dirFilePath, subCmd); err != nil { + return err + } } } else { outFilePath := filepath.Join(rootOutputPath, cmd.Name()) @@ -29,6 +42,7 @@ func GenerateMarkdownTree(rootOutputPath string, cmd *cobra.Command) error { if err != nil { return err } + defer f.Close() return doc.GenMarkdownCustom(cmd, f, func(s string) string { return filepath.Join(dirFilePath, s) @@ -37,3 +51,13 @@ func GenerateMarkdownTree(rootOutputPath string, cmd *cobra.Command) error { return nil } + +// ensureDir ensures that the given directory exists, creating it if necessary. +func ensureDir(path string) error { + if _, err := os.Stat(path); os.IsNotExist(err) { + if err := os.MkdirAll(path, os.ModePerm); err != nil { + return err + } + } + return nil +} diff --git a/cli/cmdx/errors.go b/cli/cmdx/errors.go new file mode 100644 index 0000000..df276c5 --- /dev/null +++ b/cli/cmdx/errors.go @@ -0,0 +1,35 @@ +package cmdx + +import "strings" + +// IsCmdErr checks if the given error is related to a Cobra command error. +// +// This is useful for distinguishing between user errors (e.g., incorrect commands or flags) +// and program errors, allowing the application to display appropriate messages. +// +// Parameters: +// - err: The error to check. +// +// Returns: +// - true if the error message contains any known Cobra command error keywords. +// - false otherwise. +func IsCmdErr(err error) bool { + if err == nil { + return false + } + + // Known Cobra command error keywords + cmdErrorKeywords := []string{ + "unknown command", + "unknown flag", + "unknown shorthand flag", + } + + errMessage := err.Error() + for _, keyword := range cmdErrorKeywords { + if strings.Contains(errMessage, keyword) { + return true + } + } + return false +} diff --git a/cli/cmdx/help.go b/cli/cmdx/help.go index 6fc8fe9..fee5bb2 100644 --- a/cli/cmdx/help.go +++ b/cli/cmdx/help.go @@ -8,6 +8,7 @@ import ( "github.com/spf13/pflag" ) +// Section Titles for Help Output const ( USAGE = "Usage" CORECMD = "Core commands" @@ -22,185 +23,176 @@ const ( FEEDBACK = "Feedback" ) -// SetHelp sets a custom help and usage function. -// It allows to group commands in different sections -// based on cobra commands annotations. +// SetHelp configures custom help and usage functions for a Cobra command. +// It organizes commands into sections based on annotations and provides enhanced error handling. func SetHelp(cmd *cobra.Command) { cmd.PersistentFlags().Bool("help", false, "Show help for command") cmd.SetHelpFunc(func(cmd *cobra.Command, args []string) { - rootHelpFunc(cmd, args) + displayHelp(cmd, args) }) - cmd.SetUsageFunc(rootUsageFunc) - cmd.SetFlagErrorFunc(rootFlagErrorFunc) + cmd.SetUsageFunc(generateUsage) + cmd.SetFlagErrorFunc(handleFlagError) } -func rootUsageFunc(command *cobra.Command) error { - command.Printf("Usage: %s", command.UseLine()) +// generateUsage customizes the usage function for a command. +func generateUsage(cmd *cobra.Command) error { + cmd.Printf("Usage: %s\n", cmd.UseLine()) - subcommands := command.Commands() + subcommands := cmd.Commands() if len(subcommands) > 0 { - command.Print("\n\nAvailable commands:\n") - for _, c := range subcommands { - if c.Hidden { - continue + cmd.Print("\nAvailable commands:\n") + for _, subCmd := range subcommands { + if !subCmd.Hidden { + cmd.Printf(" %s\n", subCmd.Name()) } - command.Printf(" %s\n", c.Name()) } - return nil } - flagUsages := command.LocalFlags().FlagUsages() + flagUsages := cmd.LocalFlags().FlagUsages() if flagUsages != "" { - command.Println("\n\nFlags:") - command.Print(indent(dedent(flagUsages), " ")) + cmd.Println("\nFlags:") + cmd.Print(indent(dedent(flagUsages), " ")) } return nil } -func rootFlagErrorFunc(cmd *cobra.Command, err error) 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 { return err } return err } -func rootHelpFunc(command *cobra.Command, args []string) { - if isRootCmd(command.Parent()) && len(args) >= 2 && args[1] != "--help" && args[1] != "-h" { - nestedSuggestFunc(command, args[1]) +// displayHelp generates a custom help message for a Cobra command. +func displayHelp(cmd *cobra.Command, args []string) { + if isRootCommand(cmd.Parent()) && len(args) >= 2 && args[1] != "--help" && args[1] != "-h" { + showSuggestions(cmd, args[1]) return } - coreCommands := []string{} + helpEntries := buildHelpEntries(cmd) + printHelpEntries(cmd, helpEntries) +} + +// buildHelpEntries constructs a structured help message for a command. +func buildHelpEntries(cmd *cobra.Command) []helpEntry { + var coreCommands, helpCommands, otherCommands []string groupCommands := map[string][]string{} - helpCommands := []string{} - otherCommands := []string{} - for _, c := range command.Commands() { + for _, c := range cmd.Commands() { if c.Short == "" || c.Hidden { continue } - s := rpad(c.Name(), c.NamePadding()+3) + c.Short - - g, ok := c.Annotations["group"] - if ok && g == "core" { - coreCommands = append(coreCommands, s) - } else if ok && g == "help" { - helpCommands = append(helpCommands, s) - } else if ok && g != "" { - groupCommands[g] = append(groupCommands[g], s) + + entry := fmt.Sprintf("%s%s", rpad(c.Name(), c.NamePadding()+3), c.Short) + if group, ok := c.Annotations["group"]; ok { + switch group { + case "core": + coreCommands = append(coreCommands, entry) + case "help": + helpCommands = append(helpCommands, entry) + default: + groupCommands[group] = append(groupCommands[group], entry) + } } else { - otherCommands = append(otherCommands, s) + otherCommands = append(otherCommands, entry) } } - // If there are no core and other commands, assume everything is a core command + // Treat all commands as core if no groups are specified if len(coreCommands) == 0 && len(groupCommands) == 0 { coreCommands = otherCommands otherCommands = []string{} } - type helpEntry struct { - Title string - Body string - } - - text := command.Long - - if text == "" { - text = command.Short - } - helpEntries := []helpEntry{} - if text != "" { + if text := cmd.Long; text != "" { helpEntries = append(helpEntries, helpEntry{"", text}) } - helpEntries = append(helpEntries, helpEntry{USAGE, command.UseLine()}) - + helpEntries = append(helpEntries, helpEntry{USAGE, cmd.UseLine()}) if len(coreCommands) > 0 { helpEntries = append(helpEntries, helpEntry{CORECMD, strings.Join(coreCommands, "\n")}) } - - for name, cmds := range groupCommands { - if len(cmds) > 0 { - helpEntries = append(helpEntries, helpEntry{fmt.Sprint(toTitle(name) + " commands"), strings.Join(cmds, "\n")}) - } + for group, cmds := range groupCommands { + helpEntries = append(helpEntries, helpEntry{fmt.Sprintf("%s commands", toTitle(group)), strings.Join(cmds, "\n")}) } - if len(otherCommands) > 0 { helpEntries = append(helpEntries, helpEntry{OTHERCMD, strings.Join(otherCommands, "\n")}) } - if len(helpCommands) > 0 { helpEntries = append(helpEntries, helpEntry{HELPCMD, strings.Join(helpCommands, "\n")}) } - - flagUsages := command.LocalFlags().FlagUsages() - if flagUsages != "" { + if flagUsages := cmd.LocalFlags().FlagUsages(); flagUsages != "" { helpEntries = append(helpEntries, helpEntry{FLAGS, dedent(flagUsages)}) } - - inheritedFlagUsages := command.InheritedFlags().FlagUsages() - if inheritedFlagUsages != "" { + if inheritedFlagUsages := cmd.InheritedFlags().FlagUsages(); inheritedFlagUsages != "" { helpEntries = append(helpEntries, helpEntry{IFLAGS, dedent(inheritedFlagUsages)}) } - - if _, ok := command.Annotations["help:arguments"]; ok { - helpEntries = append(helpEntries, helpEntry{ARGUMENTS, command.Annotations["help:arguments"]}) + if argsAnnotation, ok := cmd.Annotations["help:arguments"]; ok { + helpEntries = append(helpEntries, helpEntry{ARGUMENTS, argsAnnotation}) } - - if command.Example != "" { - helpEntries = append(helpEntries, helpEntry{EXAMPLES, command.Example}) + if cmd.Example != "" { + helpEntries = append(helpEntries, helpEntry{EXAMPLES, cmd.Example}) } - - if _, ok := command.Annotations["help:learn"]; ok { - helpEntries = append(helpEntries, helpEntry{LEARN, command.Annotations["help:learn"]}) + if argsAnnotation, ok := cmd.Annotations["help:learn"]; ok { + helpEntries = append(helpEntries, helpEntry{LEARN, argsAnnotation}) } - if _, ok := command.Annotations["help:feedback"]; ok { - helpEntries = append(helpEntries, helpEntry{FEEDBACK, command.Annotations["help:feedback"]}) + if argsAnnotation, ok := cmd.Annotations["help:feedback"]; ok { + helpEntries = append(helpEntries, helpEntry{FEEDBACK, argsAnnotation}) } + return helpEntries +} - out := command.OutOrStdout() - for _, e := range helpEntries { - if e.Title != "" { - // If there is a title, add indentation to each line in the body - fmt.Fprintln(out, bold(e.Title)) - fmt.Fprintln(out, indent(strings.Trim(e.Body, "\r\n"), " ")) +// printHelpEntries displays help entries to the command's output. +func printHelpEntries(cmd *cobra.Command, entries []helpEntry) { + out := cmd.OutOrStdout() + for _, entry := range entries { + if entry.Title != "" { + fmt.Fprintln(out, bold(entry.Title)) + fmt.Fprintln(out, indent(strings.Trim(entry.Body, "\r\n"), " ")) } else { - // If there is no title print the body as is - fmt.Println(e.Body) + fmt.Fprintln(out, entry.Body) } fmt.Fprintln(out) } } -// Display helpful error message in case subcommand name was mistyped. -func nestedSuggestFunc(command *cobra.Command, arg string) { - command.Printf("unknown command %q for %q\n", arg, command.CommandPath()) +// showSuggestions displays suggestions for mistyped subcommands. +func showSuggestions(cmd *cobra.Command, arg string) { + cmd.Printf("unknown command %q for %q\n", arg, cmd.CommandPath()) - var candidates []string + var suggestions []string if arg == "help" { - candidates = []string{"--help"} + suggestions = []string{"--help"} } else { - if command.SuggestionsMinimumDistance <= 0 { - command.SuggestionsMinimumDistance = 2 + if cmd.SuggestionsMinimumDistance <= 0 { + cmd.SuggestionsMinimumDistance = 2 } - candidates = command.SuggestionsFor(arg) + suggestions = cmd.SuggestionsFor(arg) } - if len(candidates) > 0 { - command.Print("\nDid you mean this?\n") - for _, c := range candidates { - command.Printf("\t%s\n", c) + if len(suggestions) > 0 { + cmd.Println("\nDid you mean this?") + for _, suggestion := range suggestions { + cmd.Printf(" %s\n", suggestion) } } - command.Print("\n") - _ = rootUsageFunc(command) + cmd.Println() + _ = generateUsage(cmd) +} + +// isRootCommand checks if the given command is the root command. +func isRootCommand(cmd *cobra.Command) bool { + return cmd != nil && !cmd.HasParent() } -func isRootCmd(command *cobra.Command) bool { - return command != nil && !command.HasParent() +// Utility types and functions +type helpEntry struct { + Title string + Body string } diff --git a/cli/cmdx/hook.go b/cli/cmdx/hook.go index 670d235..5842d5e 100644 --- a/cli/cmdx/hook.go +++ b/cli/cmdx/hook.go @@ -2,8 +2,24 @@ package cmdx import "github.com/spf13/cobra" -// SetClientHook applies custom cobra config specific -// for client or cmd that have annotation `client:true` +// SetClientHook recursively applies a custom function to all commands +// with the annotation `client:true` in the given Cobra command tree. +// +// This is particularly useful for applying client-specific configurations +// to commands annotated as "client". +// +// Parameters: +// - rootCmd: The root Cobra command to start traversing from. +// - applyFunc: A function that applies the desired configuration +// to commands with the `client:true` annotation. +// +// Example Usage: +// +// cmdx.SetClientHook(rootCmd, func(cmd *cobra.Command) { +// cmd.PersistentPreRun = func(cmd *cobra.Command, args []string) { +// fmt.Println("Client-specific setup") +// } +// }) func SetClientHook(rootCmd *cobra.Command, applyFunc func(cmd *cobra.Command)) { for _, subCmd := range rootCmd.Commands() { if subCmd.Annotations != nil && subCmd.Annotations["client"] == "true" { diff --git a/cli/cmdx/ref.go b/cli/cmdx/ref.go index 54d95ed..93612c3 100644 --- a/cli/cmdx/ref.go +++ b/cli/cmdx/ref.go @@ -11,10 +11,11 @@ import ( "github.com/spf13/cobra" ) -// SetRefCmd is used to generate the reference documentation -// in markdown format for the command tree. -// This should be added on the root command and can -// be used as `help reference` or `reference help`. +// SetRefCmd adds a `reference` command to the root command to generate +// comprehensive reference documentation for the command tree. +// +// The `reference` command outputs the documentation in markdown format +// and supports a `--plain` flag to control whether ANSI colors are used. func SetRefCmd(root *cobra.Command) *cobra.Command { var isPlain bool cmd := &cobra.Command{ @@ -31,39 +32,43 @@ func SetRefCmd(root *cobra.Command) *cobra.Command { return cmd } +// referenceHelpFn generates the output for the `reference` command. +// It renders the documentation either as plain markdown or with ANSI color. func referenceHelpFn(isPlain *bool) func(*cobra.Command, []string) { return func(cmd *cobra.Command, args []string) { var ( - md string - err error + output string + err error ) if *isPlain { - md = cmd.Long + output = cmd.Long } else { - md, err = printer.Markdown(cmd.Long) + output, err = printer.Markdown(cmd.Long) if err != nil { - fmt.Println(err) + fmt.Println("Error generating markdown:", err) return } } - fmt.Print(md) + fmt.Print(output) } } +// referenceLong generates the complete reference documentation +// for the command tree in markdown format. func referenceLong(cmd *cobra.Command) string { buf := bytes.NewBufferString(fmt.Sprintf("# %s reference\n\n", cmd.Name())) for _, c := range cmd.Commands() { if c.Hidden { continue } - cmdRef(buf, c, 2) + generateCommandReference(buf, c, 2) } return buf.String() } -func cmdRef(w io.Writer, cmd *cobra.Command, depth int) { +func generateCommandReference(w io.Writer, cmd *cobra.Command, depth int) { // Name + Description fmt.Fprintf(w, "%s `%s`\n\n", strings.Repeat("#", depth), cmd.UseLine()) fmt.Fprintf(w, "%s\n\n", cmd.Short) @@ -77,6 +82,6 @@ func cmdRef(w io.Writer, cmd *cobra.Command, depth int) { if c.Hidden { continue } - cmdRef(w, c, depth+1) + generateCommandReference(w, c, depth+1) } } diff --git a/cli/cmdx/shell.go b/cli/cmdx/shell.go index 6ceaa93..6aae0e8 100644 --- a/cli/cmdx/shell.go +++ b/cli/cmdx/shell.go @@ -7,9 +7,10 @@ import ( "github.com/spf13/cobra" ) -// SetCompletionCmd is used to generate the completion script in -// bash, zsh, fish, and powershell. It should be added on the root -// command and can be used as `completion bash` or `completion zsh`. +// SetCompletionCmd creates a `completion` command to generate shell completion scripts. +// +// The command supports generating scripts for Bash, Zsh, Fish, and PowerShell. +// It should be added to the root command of the application. func SetCompletionCmd(exec string) *cobra.Command { var execs []interface{} for i := 0; i < 12; i++ { diff --git a/cli/cmdx/topic.go b/cli/cmdx/topic.go index dbbaa13..da4fef5 100644 --- a/cli/cmdx/topic.go +++ b/cli/cmdx/topic.go @@ -4,10 +4,29 @@ import ( "github.com/spf13/cobra" ) -// SetHelpTopicCmd sets the help topic command. -// This should be added on the root command. e.g. +// SetHelpTopicCmd creates a custom help topic command. // -// topic := map[string]string{"short": "Env","long": "Environment","example": "example",} +// This function allows you to define a help topic that provides detailed information +// about a specific subject. The generated command should be added to the root command. +// +// Parameters: +// - title: The name of the help topic (e.g., "env"). +// - topic: A map containing the following keys: +// - "short": A brief description of the topic. +// - "long": A detailed explanation of the topic. +// - "example": An example usage of the topic. +// +// Returns: +// - A pointer to the configured help topic `cobra.Command`. +// +// Example: +// +// topic := map[string]string{ +// "short": "Environment variables help", +// "long": "Details about environment variables used by the CLI.", +// "example": "$ mycli help env", +// } +// rootCmd.AddCommand(cmdx.SetHelpTopicCmd("env", topic)) func SetHelpTopicCmd(title string, topic map[string]string) *cobra.Command { cmd := &cobra.Command{ Use: title, @@ -29,12 +48,12 @@ func SetHelpTopicCmd(title string, topic map[string]string) *cobra.Command { func helpTopicHelpFunc(command *cobra.Command, args []string) { command.Print(command.Long) if command.Example != "" { - command.Printf("\n\nEXAMPLES\n") + command.Printf("\nEXAMPLES\n") command.Print(indent(command.Example, " ")) } } func helpTopicUsageFunc(command *cobra.Command) error { - command.Printf("Usage: %s help %s", command.Root().Name(), command.Use) + command.Printf("Usage: %s help %s\n", command.Root().Name(), command.Use) return nil } diff --git a/cli/cmdx/util.go b/cli/cmdx/utils.go similarity index 74% rename from cli/cmdx/util.go rename to cli/cmdx/utils.go index 3776da0..45521da 100644 --- a/cli/cmdx/util.go +++ b/cli/cmdx/utils.go @@ -12,26 +12,6 @@ import ( "golang.org/x/text/language" ) -// IsCmdErr returns true if erorr is cobra command error. -// This is useful for distinguishing between a human error -// and a program error and displaying the correct message. -func IsCmdErr(err error) bool { - errstr := err.Error() - - strs := []string{ - "unknown command", - "unknown flag", - "unknown shorthand flag", - } - - for _, str := range strs { - if strings.Contains(errstr, str) { - return true - } - } - return false -} - // rpad adds padding to the right of a string. func rpad(s string, padding int) string { template := fmt.Sprintf("%%-%ds ", padding) @@ -78,7 +58,7 @@ func dirExists(path string) bool { return err == nil && f.IsDir() } -func fileExist(filename string) bool { +func fileExists(filename string) bool { _, err := os.Stat(filename) return err == nil } diff --git a/cli/printer/README.md b/cli/printer/README.md new file mode 100644 index 0000000..e61dc82 --- /dev/null +++ b/cli/printer/README.md @@ -0,0 +1,185 @@ +# Printer + +The `printer` package provides utilities for terminal-based output formatting, including colorized text, progress indicators, markdown rendering, and more. It is designed for building rich and user-friendly CLI applications. + +## Features + +- **Text Formatting**: Bold, italic, and colorized text. +- **Progress Indicators**: Spinners and progress bars for long-running tasks. +- **Markdown Rendering**: Render Markdown with terminal-friendly styles. +- **Structured Output**: YAML and JSON rendering with support for pretty-printing. +- **Icons**: Visual indicators like success and failure icons. + +## Installation + +Install the package using: + +```bash +go get github.com/raystack/salt/cli/printer +``` + +## Usage + +### Text Formatting + +#### Basic Colors +```go +package main + +import ( + "fmt" + "github.com/raystack/salt/cli/printer" +) + +func main() { + fmt.Println(printer.Green("Success!")) + fmt.Println(printer.Red("Error!")) + fmt.Println(printer.Cyanf("Hello, %s!", "World")) +} +``` + +Supported Colors: +- **Green**: `printer.Green`, `printer.Greenf` +- **Red**: `printer.Red`, `printer.Redf` +- **Yellow**: `printer.Yellow`, `printer.Yellowf` +- **Cyan**: `printer.Cyan`, `printer.Cyanf` +- **Grey**: `printer.Grey`, `printer.Greyf` +- **Blue**: `printer.Blue`, `printer.Bluef` +- **Magenta**: `printer.Magenta`, `printer.Magentaf` + +#### Bold and Italic Text +```go +fmt.Println(printer.Bold("This is bold text.")) +fmt.Println(printer.Italic("This is italic text.")) +``` + +### Progress Indicators + +#### Spinner +```go +package main + +import ( + "time" + "github.com/yourusername/printer" +) + +func main() { + indicator := printer.Spin("Processing") + time.Sleep(2 * time.Second) // Simulate work + indicator.Stop() +} +``` + +#### Progress Bar +```go +package main + +import ( + "time" + "github.com/yourusername/printer" +) + +func main() { + bar := printer.Progress(100, "Downloading") + for i := 0; i <= 100; i++ { + time.Sleep(50 * time.Millisecond) // Simulate work + bar.Add(1) + } +} +``` + +### Markdown Rendering + +#### Render Markdown +```go +package main + +import ( + "fmt" + "github.com/yourusername/printer" +) + +func main() { + output, err := printer.Markdown("# Hello, Markdown!") + if err != nil { + fmt.Println("Error rendering markdown:", err) + return + } + fmt.Println(output) +} +``` + +#### Render Markdown with Word Wrap +```go +output, err := printer.MarkdownWithWrap("# Hello, Markdown!", 80) +if err != nil { + fmt.Println("Error rendering markdown:", err) + return +} +fmt.Println(output) +``` + +### File Rendering + +#### YAML +```go +package main + +import ( + "github.com/yourusername/printer" +) + +func main() { + data := map[string]string{"name": "John", "age": "30"} + err := printer.YAML(data) + if err != nil { + fmt.Println("Error:", err) + } +} +``` + +#### JSON +```go +package main + +import ( + "github.com/yourusername/printer" +) + +func main() { + data := map[string]string{"name": "John", "age": "30"} + err := printer.JSON(data) + if err != nil { + fmt.Println("Error:", err) + } +} +``` + +#### Pretty JSON +```go +package main + +import ( + "github.com/yourusername/printer" +) + +func main() { + data := map[string]string{"name": "John", "age": "30"} + err := printer.PrettyJSON(data) + if err != nil { + fmt.Println("Error:", err) + } +} +``` + +### Icons + +#### Visual Indicators +```go +fmt.Println(printer.FailureIcon(), printer.Red("Task failed.")) +``` + +## Themes + +The package automatically detects the terminal’s background and switches between light and dark themes. Supported colors can be customized by modifying the `Theme` struct. \ No newline at end of file diff --git a/cli/printer/color.go b/cli/printer/colors.go similarity index 50% rename from cli/printer/color.go rename to cli/printer/colors.go index b16c619..1d700a2 100644 --- a/cli/printer/color.go +++ b/cli/printer/colors.go @@ -8,56 +8,47 @@ import ( var tp = termenv.EnvColorProfile() -// Theme represents a color theme. +// Theme defines a collection of colors for terminal outputs. type Theme struct { - ColorGreen termenv.Color - ColorYellow termenv.Color - ColorCyan termenv.Color - ColorRed termenv.Color - ColorGrey termenv.Color - ColorBlue termenv.Color - ColorMagenta termenv.Color + Green termenv.Color + Yellow termenv.Color + Cyan termenv.Color + Red termenv.Color + Grey termenv.Color + Blue termenv.Color + Magenta termenv.Color } var themes = map[string]Theme{ "light": { - ColorGreen: tp.Color("#005F00"), - ColorYellow: tp.Color("#FFAF00"), - ColorCyan: tp.Color("#0087FF"), - ColorRed: tp.Color("#D70000"), - ColorGrey: tp.Color("#303030"), - ColorBlue: tp.Color("#000087"), - ColorMagenta: tp.Color("#AF00FF"), + Green: tp.Color("#005F00"), + Yellow: tp.Color("#FFAF00"), + Cyan: tp.Color("#0087FF"), + Red: tp.Color("#D70000"), + Grey: tp.Color("#303030"), + Blue: tp.Color("#000087"), + Magenta: tp.Color("#AF00FF"), }, "dark": { - ColorGreen: tp.Color("#A8CC8C"), - ColorYellow: tp.Color("#DBAB79"), - ColorCyan: tp.Color("#66C2CD"), - ColorRed: tp.Color("#E88388"), - ColorGrey: tp.Color("#B9BFCA"), - ColorBlue: tp.Color("#71BEF2"), - ColorMagenta: tp.Color("#D290E4"), + Green: tp.Color("#A8CC8C"), + Yellow: tp.Color("#DBAB79"), + Cyan: tp.Color("#66C2CD"), + Red: tp.Color("#E88388"), + Grey: tp.Color("#B9BFCA"), + Blue: tp.Color("#71BEF2"), + Magenta: tp.Color("#D290E4"), }, } -// ColorScheme is a color scheme. -type ColorScheme struct { - theme Theme -} - -// NewColorScheme returns a new ColorScheme with the given theme. -func NewColorScheme() *ColorScheme { +// NewTheme initializes a Theme based on the terminal background (light or dark). +func NewTheme() Theme { if !termenv.HasDarkBackground() { - return &ColorScheme{ - theme: themes["light"], - } - } - return &ColorScheme{ - theme: themes["dark"], + return themes["light"] } + return themes["dark"] } -var cs = NewColorScheme() +var theme = NewTheme() func bold(t ...string) string { return termenv.String(t...).Bold().String() @@ -76,7 +67,7 @@ func italicf(t string, args ...interface{}) string { } func Green(t ...string) string { - return termenv.String(t...).Foreground(cs.theme.ColorGreen).String() + return termenv.String(t...).Foreground(theme.Green).String() } func Greenf(t string, args ...interface{}) string { @@ -84,7 +75,7 @@ func Greenf(t string, args ...interface{}) string { } func Yellow(t ...string) string { - return termenv.String(t...).Foreground(cs.theme.ColorYellow).String() + return termenv.String(t...).Foreground(theme.Yellow).String() } func Yellowf(t string, args ...interface{}) string { @@ -92,7 +83,7 @@ func Yellowf(t string, args ...interface{}) string { } func Cyan(t ...string) string { - return termenv.String(t...).Foreground(cs.theme.ColorCyan).String() + return termenv.String(t...).Foreground(theme.Cyan).String() } func Cyanf(t string, args ...interface{}) string { @@ -100,7 +91,7 @@ func Cyanf(t string, args ...interface{}) string { } func Red(t ...string) string { - return termenv.String(t...).Foreground(cs.theme.ColorRed).String() + return termenv.String(t...).Foreground(theme.Red).String() } func Redf(t string, args ...interface{}) string { @@ -108,7 +99,7 @@ func Redf(t string, args ...interface{}) string { } func Grey(t ...string) string { - return termenv.String(t...).Foreground(cs.theme.ColorGrey).String() + return termenv.String(t...).Foreground(theme.Grey).String() } func Greyf(t string, args ...interface{}) string { @@ -116,7 +107,7 @@ func Greyf(t string, args ...interface{}) string { } func Blue(t ...string) string { - return termenv.String(t...).Foreground(cs.theme.ColorBlue).String() + return termenv.String(t...).Foreground(theme.Blue).String() } func Bluef(t string, args ...interface{}) string { @@ -124,7 +115,7 @@ func Bluef(t string, args ...interface{}) string { } func Magenta(t ...string) string { - return termenv.String(t...).Foreground(cs.theme.ColorMagenta).String() + return termenv.String(t...).Foreground(theme.Magenta).String() } func Magentaf(t string, args ...interface{}) string { diff --git a/cli/printer/file.go b/cli/printer/file.go index 65c4262..833025c 100644 --- a/cli/printer/file.go +++ b/cli/printer/file.go @@ -7,32 +7,86 @@ import ( "gopkg.in/yaml.v3" ) -// YAML prints the data as YAML. +// YAML prints the given data in YAML format. +// +// Parameters: +// - data: The data to be marshaled into YAML and printed. +// +// Returns: +// - An error if the data cannot be marshaled into YAML. +// +// Example Usage: +// +// config := map[string]string{"key": "value"} +// err := printer.YAML(config) func YAML(data interface{}) error { return File(data, "yaml") } -// JSON prints the data as JSON. +// JSON prints the given data in JSON format. +// +// Parameters: +// - data: The data to be marshaled into JSON and printed. +// +// Returns: +// - An error if the data cannot be marshaled into JSON. +// +// Example Usage: +// +// config := map[string]string{"key": "value"} +// err := printer.JSON(config) func JSON(data interface{}) error { return File(data, "json") } -// PrettyJSON prints the data as pretty JSON. +// PrettyJSON prints the given data in pretty-printed JSON format. +// +// Parameters: +// - data: The data to be marshaled into indented JSON and printed. +// +// Returns: +// - An error if the data cannot be marshaled into JSON. +// +// Example Usage: +// +// config := map[string]string{"key": "value"} +// err := printer.PrettyJSON(config) func PrettyJSON(data interface{}) error { return File(data, "prettyjson") } -// File prints the data for a given format +// File marshals and prints the given data in the specified format. +// +// Supported formats: +// - "yaml": Prints the data as YAML. +// - "json": Prints the data as compact JSON. +// - "prettyjson": Prints the data as pretty-printed JSON. +// +// Parameters: +// - data: The data to be marshaled and printed. +// - format: The desired output format ("yaml", "json", or "prettyjson"). +// +// Returns: +// - An error if the data cannot be marshaled into the specified format or if the format is unsupported. +// +// Example Usage: +// +// config := map[string]string{"key": "value"} +// err := printer.File(config, "yaml") func File(data interface{}, format string) (err error) { var output []byte switch format { case "yaml": + // Marshal the data into YAML format. output, err = yaml.Marshal(data) case "json": + // Marshal the data into compact JSON format. output, err = json.Marshal(data) case "prettyjson": + // Marshal the data into pretty-printed JSON format. output, err = json.MarshalIndent(data, "", "\t") default: + // Return an error for unsupported formats. return fmt.Errorf("unknown format: %v", format) } @@ -40,6 +94,7 @@ func File(data interface{}, format string) (err error) { return err } + // Print the marshaled data to stdout. fmt.Println(string(output)) return nil } diff --git a/cli/printer/markdown.go b/cli/printer/markdown.go index 9427a6c..55e4d1b 100644 --- a/cli/printer/markdown.go +++ b/cli/printer/markdown.go @@ -6,8 +6,11 @@ import ( "github.com/charmbracelet/glamour" ) +// RenderOpts is a type alias for a slice of glamour.TermRendererOption, +// representing the rendering options for the markdown renderer. type RenderOpts []glamour.TermRendererOption +// This ensures the rendered markdown has no extra indentation or margins, providing a compact view. func withoutIndentation() glamour.TermRendererOption { overrides := []byte(` { @@ -22,40 +25,70 @@ func withoutIndentation() glamour.TermRendererOption { return glamour.WithStylesFromJSONBytes(overrides) } +// This ensures the rendered markdown does not wrap lines, useful for wide terminals. func withoutWrap() glamour.TermRendererOption { return glamour.WithWordWrap(0) } +// render applies the given rendering options to the provided markdown text. func render(text string, opts RenderOpts) (string, error) { - // Glamour rendering preserves carriage return characters in code blocks, but - // we need to ensure that no such characters are present in the output. + // Ensure input text uses consistent line endings. text = strings.ReplaceAll(text, "\r\n", "\n") + // Create a new terminal renderer with the provided options. tr, err := glamour.NewTermRenderer(opts...) if err != nil { return "", err } + // Render the markdown text and return the result. return tr.Render(text) } +// Markdown renders the given markdown text with default options. +// +// This includes automatic styling, emoji rendering, no indentation, and no word wrapping. +// +// Parameters: +// - text: The markdown text to render. +// +// Returns: +// - The rendered markdown string. +// - An error if rendering fails. +// +// Example Usage: +// +// output, err := printer.Markdown("# Hello, Markdown!") func Markdown(text string) (string, error) { opts := RenderOpts{ - glamour.WithAutoStyle(), - glamour.WithEmoji(), - withoutIndentation(), - withoutWrap(), + glamour.WithAutoStyle(), // Automatically determine styling based on terminal settings. + glamour.WithEmoji(), // Enable emoji rendering. + withoutIndentation(), // Disable indentation for a compact view. + withoutWrap(), // Disable word wrapping. } return render(text, opts) } +// MarkdownWithWrap renders the given markdown text with a specified word wrapping width. +// +// Parameters: +// - text: The markdown text to render. +// - wrap: The desired word wrapping width (e.g., 80 for 80 characters). +// +// Returns: +// - The rendered markdown string. +// - An error if rendering fails. +// +// Example Usage: +// +// output, err := printer.MarkdownWithWrap("# Hello, Markdown!", 80) func MarkdownWithWrap(text string, wrap int) (string, error) { opts := RenderOpts{ - glamour.WithAutoStyle(), - glamour.WithEmoji(), - glamour.WithWordWrap(wrap), - withoutIndentation(), + glamour.WithAutoStyle(), // Automatically determine styling based on terminal settings. + glamour.WithEmoji(), // Enable emoji rendering. + glamour.WithWordWrap(wrap), // Enable word wrapping with the specified width. + withoutIndentation(), // Disable indentation for a compact view. } return render(text, opts) diff --git a/cli/printer/progress.go b/cli/printer/progress.go index 58c6f80..3ee85f5 100644 --- a/cli/printer/progress.go +++ b/cli/printer/progress.go @@ -4,6 +4,23 @@ import ( "github.com/schollz/progressbar/v3" ) +// Progress creates and returns a progress bar for tracking the progress of an operation. +// +// The progress bar supports color, shows a description, and displays the current progress count. +// +// Parameters: +// - max: The maximum value of the progress bar, indicating 100% completion. +// - description: A brief description of the task associated with the progress bar. +// +// Returns: +// - A pointer to a `progressbar.ProgressBar` instance for managing the progress. +// +// Example Usage: +// +// bar := printer.Progress(100, "Downloading files") +// for i := 0; i < 100; i++ { +// bar.Add(1) // Increment progress by 1. +// } func Progress(max int, description string) *progressbar.ProgressBar { bar := progressbar.NewOptions( max, diff --git a/cli/printer/spinner.go b/cli/printer/spinner.go index 3084705..627f7fd 100644 --- a/cli/printer/spinner.go +++ b/cli/printer/spinner.go @@ -8,10 +8,21 @@ import ( "github.com/briandowns/spinner" ) +// Indicator represents a terminal spinner used for indicating progress or ongoing operations. type Indicator struct { - spinner *spinner.Spinner + spinner *spinner.Spinner // The spinner instance. } +// Stop halts the spinner animation. +// +// This method ensures the spinner is stopped gracefully. If the spinner is nil (e.g., when the +// terminal does not support TTY), the method does nothing. +// +// Example Usage: +// +// indicator := printer.Spin("Loading") +// // Perform some operation... +// indicator.Stop() func (s *Indicator) Stop() { if s.spinner == nil { return @@ -19,17 +30,42 @@ func (s *Indicator) Stop() { s.spinner.Stop() } +// Spin creates and starts a terminal spinner to indicate an ongoing operation. +// +// The spinner uses a predefined character set and updates at a fixed interval. It automatically +// disables itself if the terminal does not support TTY. +// +// Parameters: +// - label: A string to prefix the spinner (e.g., "Loading"). +// +// Returns: +// - An *Indicator instance that manages the spinner lifecycle. +// +// Example Usage: +// +// indicator := printer.Spin("Processing data") +// // Perform some long-running operation... +// indicator.Stop() func Spin(label string) *Indicator { + // Predefined spinner character set (dots style). set := spinner.CharSets[11] + + // Check if the terminal supports TTY; if not, return a no-op Indicator. if !terminal.IsTTY() { return &Indicator{} } + + // Create a new spinner instance with a 120ms update interval and cyan color. s := spinner.New(set, 120*time.Millisecond, spinner.WithColor("fgCyan")) + + // Add a label prefix if provided. if label != "" { s.Prefix = label + " " } + // Start the spinner animation. s.Start() + // Return the Indicator wrapping the spinner instance. return &Indicator{s} } diff --git a/cli/printer/table.go b/cli/printer/table.go index c13ff4b..8ac10c6 100644 --- a/cli/printer/table.go +++ b/cli/printer/table.go @@ -2,14 +2,36 @@ package printer import ( "io" - "os" "github.com/olekukonko/tablewriter" ) -// Table writes a terminal-friendly table of the values to the target. +// Table renders a terminal-friendly table to the provided writer. +// +// Create a table with customized formatting and styles, +// suitable for displaying data in CLI applications. +// +// Parameters: +// - target: The `io.Writer` where the table will be written (e.g., os.Stdout). +// - rows: A 2D slice of strings representing the table rows and columns. +// Each inner slice represents a single row, with its elements as column values. +// +// Example Usage: +// +// rows := [][]string{ +// {"ID", "Name", "Age"}, +// {"1", "Alice", "30"}, +// {"2", "Bob", "25"}, +// } +// printer.Table(os.Stdout, rows) +// +// Behavior: +// - Disables text wrapping for better terminal rendering. +// - Aligns headers and rows to the left. +// - Removes borders and separators for a clean look. +// - Formats the table using tab padding for better alignment in terminals. func Table(target io.Writer, rows [][]string) { - table := tablewriter.NewWriter(os.Stdout) + table := tablewriter.NewWriter(target) table.SetAutoWrapText(false) table.SetAutoFormatHeaders(true) table.SetHeaderAlignment(tablewriter.ALIGN_LEFT) diff --git a/cli/printer/text.go b/cli/printer/text.go index 5256a3b..d371de6 100644 --- a/cli/printer/text.go +++ b/cli/printer/text.go @@ -4,106 +4,97 @@ import ( "fmt" ) +// Success prints the given message(s) in green to indicate success. func Success(t ...string) { fmt.Print(Green(t...)) } +// Successln prints the given message(s) in green with a newline. func Successln(t ...string) { fmt.Println(Green(t...)) } +// Successf formats and prints the success message in green. func Successf(t string, args ...interface{}) { fmt.Print(Greenf(t, args...)) } +// Warning prints the given message(s) in yellow to indicate a warning. func Warning(t ...string) { fmt.Print(Yellow(t...)) } +// Warningln prints the given message(s) in yellow with a newline. func Warningln(t ...string) { fmt.Println(Yellow(t...)) } +// Warningf formats and prints the warning message in yellow. func Warningf(t string, args ...interface{}) { fmt.Print(Yellowf(t, args...)) } +// Error prints the given message(s) in red to indicate an error. func Error(t ...string) { fmt.Print(Red(t...)) } +// Errorln prints the given message(s) in red with a newline. func Errorln(t ...string) { fmt.Println(Red(t...)) } +// Errorf formats and prints the error message in red. func Errorf(t string, args ...interface{}) { fmt.Print(Redf(t, args...)) } +// Info prints the given message(s) in cyan to indicate informational messages. func Info(t ...string) { fmt.Print(Cyan(t...)) } +// Infoln prints the given message(s) in cyan with a newline. func Infoln(t ...string) { fmt.Println(Cyan(t...)) } +// Infof formats and prints the informational message in cyan. func Infof(t string, args ...interface{}) { fmt.Print(Cyanf(t, args...)) } +// Bold prints the given message(s) in bold style. func Bold(t ...string) { fmt.Print(bold(t...)) } +// Boldln prints the given message(s) in bold style with a newline. func Boldln(t ...string) { fmt.Println(bold(t...)) } +// Boldf formats and prints the message in bold style. func Boldf(t string, args ...interface{}) { fmt.Print(boldf(t, args...)) } +// Italic prints the given message(s) in italic style. func Italic(t ...string) { fmt.Print(italic(t...)) } +// Italicln prints the given message(s) in italic style with a newline. func Italicln(t ...string) { fmt.Println(italic(t...)) } +// Italicf formats and prints the message in italic style. func Italicf(t string, args ...interface{}) { fmt.Print(italicf(t, args...)) } -func Text(t ...string) { - fmt.Print(Grey(t...)) -} - -func Textln(t ...string) { - fmt.Println(Grey(t...)) -} - -func Textf(t string, args ...interface{}) { - fmt.Print(Greyf(t, args...)) -} - -func SuccessIcon() { - fmt.Print(Green("✓")) -} - -func WarningIcon() { - fmt.Print(Yellow("!")) -} - -func ErrorIcon() { - fmt.Print(Red("✗")) -} - -func InfoIcon() { - fmt.Print(Cyan("⛭")) -} - +// Space prints a single space to the output. func Space() { fmt.Print(" ") } diff --git a/cli/prompt/README.md b/cli/prompt/README.md index dcabda8..da5e71a 100644 --- a/cli/prompt/README.md +++ b/cli/prompt/README.md @@ -14,7 +14,7 @@ The `prompt` package simplifies interactive CLI input using the `survey` library Add the package to your Go project: ```bash -go get github.com/raystack/salt/prompt +go get github.com/raystack/salt/cli/prompt ``` ## Usage @@ -26,7 +26,7 @@ package main import ( "fmt" - "github.com/raystack/salt/prompt" + "github.com/raystack/salt/cli/prompt" ) func main() { diff --git a/cli/release/README.md b/cli/release/README.md new file mode 100644 index 0000000..3e33cf1 --- /dev/null +++ b/cli/release/README.md @@ -0,0 +1,72 @@ +# Version + +The `version` package provides utilities to fetch and compare software version information from GitHub releases. It helps check if a newer version is available and generates update notices. + +## Features + +- **Fetch Release Information**: Retrieve the latest release details from a GitHub repository. +- **Version Comparison**: Compare semantic versions to determine if an update is needed. +- **Update Notifications**: Generate user-friendly messages if a newer version is available. + +## Installation + +To include this package in your Go project, use: + +```bash +go get github.com/raystack/salt/cli/release +``` + +## Usage + +### 1. Fetching Release Information + +You can use the `FetchInfo` function to fetch the latest release details from a GitHub repository. + +```go +package main + +import ( + "fmt" + "github.com/raystack/salt/cli/release" +) + +func main() { + releaseURL := "https://api.github.com/repos/raystack/optimus/releases/latest" + info, err := release.FetchInfo(releaseURL) + if err != nil { + fmt.Println("Error fetching release info:", err) + return + } + fmt.Printf("Latest Version: %s\nDownload URL: %s\n", info.Version, info.TarURL) +} +``` + +### 2. Comparing Versions + +Use `CompareVersions` to check if the current version is up-to-date with the latest release. + +```go +current := "1.2.3" +latest := "1.2.4" +isLatest, err := version.CompareVersions(current, latest) +if err != nil { + fmt.Println("Error comparing versions:", err) +} else if isLatest { + fmt.Println("You are using the latest release!") +} else { + fmt.Println("A newer release is available.") +} +``` + +### 3. Generating Update Notices + +`UpdateNotice` generates a message prompting the user to update if a newer version is available. + +```go +notice := version.CheckForUpdate("1.0.0", "raystack/optimus") +if notice != "" { + fmt.Println(notice) +} else { + fmt.Println("You are up-to-date!") +} +``` \ No newline at end of file diff --git a/cli/release/release.go b/cli/release/release.go new file mode 100644 index 0000000..f2ef914 --- /dev/null +++ b/cli/release/release.go @@ -0,0 +1,122 @@ +package release + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "time" + + "github.com/hashicorp/go-version" + "github.com/pkg/errors" +) + +var ( + // Timeout sets the HTTP client timeout for fetching release info. + Timeout = time.Second * 1 + + // APIFormat is the GitHub API URL template to fetch the latest release of a repository. + APIFormat = "https://api.github.com/repos/%s/releases/latest" +) + +// Info holds information about a software release. +type Info struct { + Version string // Version of the release + TarURL string // Tarball URL of the release +} + +// FetchInfo fetches details related to the latest release from the provided URL. +// +// Parameters: +// - releaseURL: The URL to fetch the latest release information from. +// Example: "https://api.github.com/repos/raystack/optimus/releases/latest" +// +// Returns: +// - An *Info struct containing the release and tarball URL. +// - An error if the HTTP request or response parsing fails. +func FetchInfo(url string) (*Info, error) { + httpClient := http.Client{Timeout: Timeout} + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return nil, errors.Wrap(err, "failed to create HTTP request") + } + req.Header.Set("User-Agent", "raystack/salt") + + resp, err := httpClient.Do(req) + if err != nil { + return nil, errors.Wrapf(err, "failed to fetch release information from URL: %s", url) + } + defer func() { + if resp.Body != nil { + resp.Body.Close() + } + }() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status code %d from URL: %s", resp.StatusCode, url) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, errors.Wrap(err, "failed to read response body") + } + + var data struct { + TagName string `json:"tag_name"` + Tarball string `json:"tarball_url"` + } + if err = json.Unmarshal(body, &data); err != nil { + return nil, errors.Wrapf(err, "failed to parse JSON response") + } + + return &Info{ + Version: data.TagName, + TarURL: data.Tarball, + }, nil +} + +// CompareVersions compares the current release with the latest release. +// +// Parameters: +// - currVersion: The current release string. +// - latestVersion: The latest release string. +// +// Returns: +// - true if the current release is greater than or equal to the latest release. +// - An error if release parsing fails. +func CompareVersions(current, latest string) (bool, error) { + currentVersion, err := version.NewVersion(current) + if err != nil { + return false, errors.Wrap(err, "invalid current version format") + } + + latestVersion, err := version.NewVersion(latest) + if err != nil { + return false, errors.Wrap(err, "invalid latest version format") + } + + return currentVersion.GreaterThanOrEqual(latestVersion), nil +} + +// CheckForUpdate generates a message indicating if an update is available. +// +// Parameters: +// - currentVersion: The current version string (e.g., "v1.0.0"). +// - repo: The GitHub repository in the format "owner/repo". +// +// Returns: +// - A string containing the update message if a newer version is available. +// - An empty string if the current version is up-to-date or if an error occurs. +func CheckForUpdate(currentVersion, repo string) string { + releaseURL := fmt.Sprintf(APIFormat, repo) + info, err := FetchInfo(releaseURL) + if err != nil { + return "" + } + + isLatest, err := CompareVersions(currentVersion, info.Version) + if err != nil || isLatest { + return "" + } + + return fmt.Sprintf("A new release (%s) is available. consider updating to latest version.", info.Version) +} diff --git a/cli/version/release_test.go b/cli/release/release_test.go similarity index 88% rename from cli/version/release_test.go rename to cli/release/release_test.go index fa291cf..d542d9b 100644 --- a/cli/version/release_test.go +++ b/cli/release/release_test.go @@ -1,4 +1,4 @@ -package version +package release import ( "net/http" @@ -20,7 +20,7 @@ func TestReleaseInfo_Success(t *testing.T) { })) defer server.Close() - info, err := ReleaseInfo(server.URL) + info, err := FetchInfo(server.URL) assert.NoError(t, err) assert.Equal(t, "v1.2.3", info.Version) assert.Equal(t, "https://example.com/tarball/v1.2.3", info.TarURL) @@ -33,7 +33,7 @@ func TestReleaseInfo_Failure(t *testing.T) { })) defer server.Close() - info, err := ReleaseInfo(server.URL) + info, err := FetchInfo(server.URL) assert.Error(t, err) assert.Nil(t, info) } @@ -46,13 +46,13 @@ func TestReleaseInfo_InvalidJSON(t *testing.T) { })) defer server.Close() - info, err := ReleaseInfo(server.URL) + info, err := FetchInfo(server.URL) assert.Error(t, err) assert.Nil(t, info) } func TestIsCurrentLatest(t *testing.T) { - // Test cases for version comparison + // Test cases for release comparison tests := []struct { currVersion string latestVersion string @@ -67,7 +67,7 @@ func TestIsCurrentLatest(t *testing.T) { } for _, test := range tests { - result, err := IsCurrentLatest(test.currVersion, test.latestVersion) + result, err := CompareVersions(test.currVersion, test.latestVersion) if test.shouldError { assert.Error(t, err) } else { @@ -84,6 +84,6 @@ func TestUpdateNotice_ErrorHandling(t *testing.T) { })) defer server.Close() - notice := UpdateNotice("1.0.0", server.URL) + notice := CheckForUpdate("1.0.0", server.URL) assert.Equal(t, "", notice) } diff --git a/cli/terminal/README.md b/cli/terminal/README.md index 2fe0d90..dc9f617 100644 --- a/cli/terminal/README.md +++ b/cli/terminal/README.md @@ -16,7 +16,7 @@ The `terminal` package provides a collection of utilities to manage terminal int To include this package in your Go project, use: ```bash -go get github.com/raystack/salt +go get github.com/raystack/salt/ ``` ## Usage @@ -30,7 +30,7 @@ package main import ( "fmt" - "github.com/raystack/salt/terminal" + "github.com/raystack/salt/cli/terminal" ) func main() { diff --git a/cli/version/README.md b/cli/version/README.md deleted file mode 100644 index 55274ab..0000000 --- a/cli/version/README.md +++ /dev/null @@ -1,100 +0,0 @@ -# Version - -The `version` package provides utilities to fetch and compare software version information from GitHub releases. It helps check if a newer version is available and generates update notices. - -## Features - -- **Fetch Release Information**: Retrieve the latest release details from a GitHub repository. -- **Version Comparison**: Compare semantic versions to determine if an update is needed. -- **Update Notifications**: Generate user-friendly messages if a newer version is available. - -## Installation - -To include this package in your Go project, use: - -```bash -go get github.com/raystack/salt/version -``` - -## Usage - -### 1. Fetching Release Information - -You can use the `ReleaseInfo` function to fetch the latest release details from a GitHub repository. - -```go -package main - -import ( - "fmt" - "github.com/raystack/salt/version" -) - -func main() { - releaseURL := "https://api.github.com/repos/raystack/optimus/releases/latest" - info, err := version.ReleaseInfo(releaseURL) - if err != nil { - fmt.Println("Error fetching release info:", err) - return - } - fmt.Printf("Latest Version: %s\nDownload URL: %s\n", info.Version, info.TarURL) -} -``` - -### 2. Comparing Versions - -Use `IsCurrentLatest` to check if the current version is up-to-date with the latest release. - -```go -currVersion := "1.2.3" -latestVersion := "1.2.4" -isLatest, err := version.IsCurrentLatest(currVersion, latestVersion) -if err != nil { - fmt.Println("Error comparing versions:", err) -} else if isLatest { - fmt.Println("You are using the latest version!") -} else { - fmt.Println("A newer version is available.") -} -``` - -### 3. Generating Update Notices - -`UpdateNotice` generates a message prompting the user to update if a newer version is available. - -```go -notice := version.UpdateNotice("1.0.0", "raystack/optimus") -if notice != "" { - fmt.Println(notice) -} else { - fmt.Println("You are up-to-date!") -} -``` - -## API Reference - -### Functions - -- `ReleaseInfo(releaseURL string) (*Info, error)`: Fetches the latest release information from the given GitHub API URL. -- `IsCurrentLatest(currVersion, latestVersion string) (bool, error)`: Compares the current version with the latest version using semantic versioning. -- `UpdateNotice(currentVersion, githubRepo string) string`: Returns an update notice if a newer version is available, or an empty string if up-to-date. - -### Structs - -- `type Info`: Contains details about a release. - - `Version`: The version string (e.g., "v1.2.3"). - - `TarURL`: The tarball URL for downloading the release. - -## Environment Variables - -- The `User-Agent` header in HTTP requests is set to `raystack/salt` to comply with GitHub's API requirements. - -## Error Handling - -- Uses `github.com/pkg/errors` to wrap errors for better error context. -- Returns errors when HTTP requests fail, or when JSON parsing or version comparison fails. - -## Dependencies - -- `github.com/hashicorp/go-version`: For semantic version comparison. -- `github.com/pkg/errors`: For enhanced error wrapping. diff --git a/cli/version/release.go b/cli/version/release.go deleted file mode 100644 index f6edbb9..0000000 --- a/cli/version/release.go +++ /dev/null @@ -1,120 +0,0 @@ -package version - -import ( - "encoding/json" - "fmt" - "io" - "net/http" - "time" - - "github.com/hashicorp/go-version" - "github.com/pkg/errors" -) - -var ( - // ReleaseInfoTimeout sets the HTTP client timeout for fetching release info. - ReleaseInfoTimeout = time.Second * 1 - - // Release is the GitHub API URL template to fetch the latest release of a repository. - Release = "https://api.github.com/repos/%s/releases/latest" -) - -// Info holds information about a software release. -type Info struct { - Version string // Version of the release - TarURL string // Tarball URL of the release -} - -// ReleaseInfo fetches details related to the latest release from the provided URL. -// -// Parameters: -// - releaseURL: The URL to fetch the latest release information from. -// Example: "https://api.github.com/repos/raystack/optimus/releases/latest" -// -// Returns: -// - An *Info struct containing the version and tarball URL. -// - An error if the HTTP request or response parsing fails. -func ReleaseInfo(releaseURL string) (*Info, error) { - httpClient := http.Client{ - Timeout: ReleaseInfoTimeout, - } - req, err := http.NewRequest(http.MethodGet, releaseURL, nil) - if err != nil { - return nil, errors.Wrap(err, "failed to create request") - } - req.Header.Set("User-Agent", "raystack/salt") - - resp, err := httpClient.Do(req) - if err != nil { - return nil, errors.Wrapf(err, "failed to reach releaseURL: %s", releaseURL) - } - defer func() { - if resp.Body != nil { - resp.Body.Close() - } - }() - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("failed to reach releaseURL: %s, status code: %d", releaseURL, resp.StatusCode) - } - - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, errors.Wrap(err, "failed to read response body") - } - - var releaseData struct { - TagName string `json:"tag_name"` - Tarball string `json:"tarball_url"` - } - if err = json.Unmarshal(body, &releaseData); err != nil { - return nil, errors.Wrapf(err, "failed to parse JSON response: %s", string(body)) - } - - return &Info{ - Version: releaseData.TagName, - TarURL: releaseData.Tarball, - }, nil -} - -// IsCurrentLatest compares the current version with the latest version. -// -// Parameters: -// - currVersion: The current version string. -// - latestVersion: The latest version string. -// -// Returns: -// - true if the current version is greater than or equal to the latest version. -// - An error if version parsing fails. -func IsCurrentLatest(currVersion, latestVersion string) (bool, error) { - currentV, err := version.NewVersion(currVersion) - if err != nil { - return false, errors.Wrap(err, "failed to parse current version") - } - latestV, err := version.NewVersion(latestVersion) - if err != nil { - return false, errors.Wrap(err, "failed to parse latest version") - } - return currentV.GreaterThanOrEqual(latestV), nil -} - -// UpdateNotice generates a notice message if a newer version is available. -// -// Parameters: -// - currentVersion: The current version string. -// - githubRepo: The GitHub repository in the format "owner/repo". -// -// Returns: -// - A string message prompting the user to update if a newer version is available. -// - An empty string if there are no updates or if any errors occur. -func UpdateNotice(currentVersion, githubRepo string) string { - info, err := ReleaseInfo(fmt.Sprintf(Release, githubRepo)) - if err != nil { - return "" - } - latestVersion := info.Version - isCurrentLatest, err := IsCurrentLatest(currentVersion, latestVersion) - if err != nil || isCurrentLatest { - return "" - } - return fmt.Sprintf("A new release (%s) is available, consider updating the client.", latestVersion) -} diff --git a/telemetry/otelhhtpclient/http_transport.go b/telemetry/otelhhtpclient/http_transport.go index 93568cc..f828fa7 100644 --- a/telemetry/otelhhtpclient/http_transport.go +++ b/telemetry/otelhhtpclient/http_transport.go @@ -18,7 +18,7 @@ const ( metricClientRequestSize = "http.client.request.size" metricClientResponseSize = "http.client.response.size" attributeNetProtoName = "network.protocol.name" - attributeNetProtoVersion = "network.protocol.version" + attributeNetProtoVersion = "network.protocol.release" attributeServerPort = "server.port" attributeServerAddress = "server.address" attributeHTTPRoute = "http.route" diff --git a/testing/dockertestx/cortex.go b/testing/dockertestx/cortex.go index 87969bd..495762c 100644 --- a/testing/dockertestx/cortex.go +++ b/testing/dockertestx/cortex.go @@ -22,7 +22,7 @@ func CortexWithDockertestNetwork(network *dockertest.Network) dockerCortexOption } } -// CortexWithDockertestNetwork is an option to assign version tag +// CortexWithDockertestNetwork is an option to assign release tag // of a `quay.io/cortexproject/cortex` image func CortexWithVersionTag(versionTag string) dockerCortexOption { return func(dc *dockerCortex) { diff --git a/testing/dockertestx/minio.go b/testing/dockertestx/minio.go index 014f62d..16c6a2b 100644 --- a/testing/dockertestx/minio.go +++ b/testing/dockertestx/minio.go @@ -25,7 +25,7 @@ func MinioWithDockertestNetwork(network *dockertest.Network) dockerMinioOption { } } -// MinioWithVersionTag is an option to assign version tag +// MinioWithVersionTag is an option to assign release tag // of a `quay.io/minio/minio` image func MinioWithVersionTag(versionTag string) dockerMinioOption { return func(dm *dockerMinio) { diff --git a/testing/dockertestx/minio_migrate.go b/testing/dockertestx/minio_migrate.go index 0288ad0..7cb35b2 100644 --- a/testing/dockertestx/minio_migrate.go +++ b/testing/dockertestx/minio_migrate.go @@ -21,7 +21,7 @@ func MigrateMinioWithDockertestNetwork(network *dockertest.Network) dockerMigrat } } -// MigrateMinioWithVersionTag is an option to assign version tag +// MigrateMinioWithVersionTag is an option to assign release tag // of a `minio/mc` image func MigrateMinioWithVersionTag(versionTag string) dockerMigrateMinioOption { return func(dm *dockerMigrateMinio) { diff --git a/testing/dockertestx/nginx.go b/testing/dockertestx/nginx.go index 5786c90..a2e45d4 100644 --- a/testing/dockertestx/nginx.go +++ b/testing/dockertestx/nginx.go @@ -43,7 +43,7 @@ func NginxWithDockertestNetwork(network *dockertest.Network) dockerNginxOption { } } -// NginxWithVersionTag is an option to assign version tag +// NginxWithVersionTag is an option to assign release tag // of a `nginx` image func NginxWithVersionTag(versionTag string) dockerNginxOption { return func(dc *dockerNginx) { diff --git a/testing/dockertestx/postgres.go b/testing/dockertestx/postgres.go index 7220eef..59e9343 100644 --- a/testing/dockertestx/postgres.go +++ b/testing/dockertestx/postgres.go @@ -53,7 +53,7 @@ func PostgresWithDetail( } } -// PostgresWithVersionTag is an option to assign version tag +// PostgresWithVersionTag is an option to assign release tag // of a `postgres` image func PostgresWithVersionTag(versionTag string) dockerPostgresOption { return func(dpg *dockerPostgres) { diff --git a/testing/dockertestx/spicedb.go b/testing/dockertestx/spicedb.go index 143e8ba..bb7b3c4 100644 --- a/testing/dockertestx/spicedb.go +++ b/testing/dockertestx/spicedb.go @@ -37,7 +37,7 @@ func SpiceDBWithDockertestNetwork(network *dockertest.Network) dockerSpiceDBOpti } } -// SpiceDBWithVersionTag is an option to assign version tag +// SpiceDBWithVersionTag is an option to assign release tag // of a `quay.io/authzed/spicedb` image func SpiceDBWithVersionTag(versionTag string) dockerSpiceDBOption { return func(dsp *dockerSpiceDB) { diff --git a/testing/dockertestx/spicedb_migrate.go b/testing/dockertestx/spicedb_migrate.go index ec34e43..cc2dd78 100644 --- a/testing/dockertestx/spicedb_migrate.go +++ b/testing/dockertestx/spicedb_migrate.go @@ -18,7 +18,7 @@ func MigrateSpiceDBWithDockertestNetwork(network *dockertest.Network) dockerMigr } } -// MigrateSpiceDBWithVersionTag is an option to assign version tag +// MigrateSpiceDBWithVersionTag is an option to assign release tag // of a `quay.io/authzed/spicedb` image func MigrateSpiceDBWithVersionTag(versionTag string) dockerMigrateSpiceDBOption { return func(dm *dockerMigrateSpiceDB) {