diff --git a/cli/cmdx/doc.go b/cli/cmdx/doc.go deleted file mode 100644 index f5e60f5..0000000 --- a/cli/cmdx/doc.go +++ /dev/null @@ -1,82 +0,0 @@ -// Package cmdx extends the capabilities of the Cobra library to build advanced CLI tools. -// It provides features such as custom help, shell completion, reference documentation generation, -// help topics, and client-specific hooks. -// -// # Features -// -// 1. **Custom Help**: -// Enhance the default help output with a structured and detailed format. -// -// 2. **Reference Command**: -// Generate markdown documentation for the entire CLI command tree. -// -// 3. **Shell Completion**: -// Generate shell completion scripts for Bash, Zsh, Fish, and PowerShell. -// -// 4. **Help Topics**: -// Add custom help topics to provide detailed information about specific subjects. -// -// 5. **Client Hooks**: -// Apply custom logic to commands annotated with `client:true`. -// -// # Example -// -// The following example demonstrates how to use the cmdx package: -// -// package main -// -// import ( -// "fmt" -// "github.com/spf13/cobra" -// "github.com/your-username/cmdx" -// ) -// -// func main() { -// rootCmd := &cobra.Command{ -// Use: "mycli", -// Short: "A sample CLI tool", -// } -// -// // Define help topics -// helpTopics := []cmdx.HelpTopic{ -// { -// Name: "env", -// Short: "Environment variables help", -// Long: "Details about environment variables used by the CLI.", -// Example: "$ mycli help env", -// }, -// } -// -// // Define hooks -// hooks := []cmdx.HookBehavior{ -// { -// Name: "setup", -// Behavior: func(cmd *cobra.Command) { -// cmd.PersistentPreRun = func(cmd *cobra.Command, args []string) { -// fmt.Println("Setting up for", cmd.Name()) -// } -// }, -// }, -// } -// -// // Create the Commander with configurations -// manager := cmdx.NewCommander( -// rootCmd, -// cmdx.WithTopics(helpTopics), -// cmdx.WithHooks(hooks), -// cmdx.EnableConfig(), -// cmdx.EnableDocs(), -// ) -// -// // Initialize the manager -// if err := manager.Initialize(); err != nil { -// fmt.Println("Error initializing CLI:", err) -// return -// } -// -// // Execute the CLI -// if err := rootCmd.Execute(); err != nil { -// fmt.Println("Command execution failed:", err) -// } -// } -package cmdx diff --git a/cli/cmdx/utils.go b/cli/cmdx/utils.go deleted file mode 100644 index b00e7c8..0000000 --- a/cli/cmdx/utils.go +++ /dev/null @@ -1,63 +0,0 @@ -package cmdx - -import ( - "bytes" - "fmt" - "regexp" - "strings" - - "github.com/muesli/termenv" - "golang.org/x/text/cases" - "golang.org/x/text/language" -) - -// rpad adds padding to the right of a string. -func rpad(s string, padding int) string { - template := fmt.Sprintf("%%-%ds ", padding) - return fmt.Sprintf(template, s) -} - -func dedent(s string) string { - lines := strings.Split(s, "\n") - minIndent := -1 - - for _, l := range lines { - if len(l) == 0 { - continue - } - - indent := len(l) - len(strings.TrimLeft(l, " ")) - if minIndent == -1 || indent < minIndent { - minIndent = indent - } - } - - if minIndent <= 0 { - return s - } - - var buf bytes.Buffer - for _, l := range lines { - fmt.Fprintln(&buf, strings.TrimPrefix(l, strings.Repeat(" ", minIndent))) - } - return strings.TrimSuffix(buf.String(), "\n") -} - -var lineRE = regexp.MustCompile(`(?m)^`) - -func indent(s, indent string) string { - if len(strings.TrimSpace(s)) == 0 { - return s - } - return lineRE.ReplaceAllLiteralString(s, indent) -} - -func toTitle(text string) string { - heading := cases.Title(language.Und).String(text) - return heading -} - -func bold(text string) termenv.Style { - h := termenv.String(text).Bold() - return h -} diff --git a/cli/cmdx/markdown.go b/cli/commander/codex.go similarity index 89% rename from cli/cmdx/markdown.go rename to cli/commander/codex.go index 73d9eda..9636ee0 100644 --- a/cli/cmdx/markdown.go +++ b/cli/commander/codex.go @@ -1,4 +1,4 @@ -package cmdx +package commander import ( "fmt" @@ -9,9 +9,9 @@ import ( "github.com/spf13/cobra/doc" ) -// AddMarkdownCommand integrates a hidden `markdown` command into the root command. +// addMarkdownCommand integrates a hidden `markdown` command into the root command. // This command generates a Markdown documentation tree for all commands in the hierarchy. -func (m *Commander) AddMarkdownCommand(outputPath string) { +func (m *Manager) addMarkdownCommand(outputPath string) { markdownCmd := &cobra.Command{ Use: "markdown", Short: "Generate Markdown documentation for all commands", @@ -35,7 +35,7 @@ func (m *Commander) AddMarkdownCommand(outputPath string) { // // Returns: // - An error if any part of the process (file creation, directory creation) fails. -func (m *Commander) generateMarkdownTree(rootOutputPath string, cmd *cobra.Command) error { +func (m *Manager) generateMarkdownTree(rootOutputPath string, cmd *cobra.Command) error { dirFilePath := filepath.Join(rootOutputPath, cmd.Name()) // Handle subcommands by creating a directory and iterating through subcommands. diff --git a/cli/cmdx/completion.go b/cli/commander/completion.go similarity index 68% rename from cli/cmdx/completion.go rename to cli/commander/completion.go index d6a12dd..08a0ed8 100644 --- a/cli/cmdx/completion.go +++ b/cli/commander/completion.go @@ -1,4 +1,4 @@ -package cmdx +package commander import ( "os" @@ -7,21 +7,14 @@ import ( "github.com/spf13/cobra" ) -// AddCompletionCommand adds a `completion` command to the CLI. -// -// The completion command generates shell completion scripts for Bash, Zsh, -// Fish, and PowerShell. -// -// Example: -// -// manager := cmdx.NewCommander(rootCmd) -// manager.AddCompletionCommand() -// +// addCompletionCommand adds a `completion` command to the CLI. +// The `completion` command generates shell completion scripts +// for Bash, Zsh, Fish, and PowerShell. // Usage: // // $ mycli completion bash // $ mycli completion zsh -func (m *Commander) AddCompletionCommand() { +func (m *Manager) addCompletionCommand() { summary := m.generateCompletionSummary(m.RootCmd.Use) completionCmd := &cobra.Command{ @@ -31,28 +24,25 @@ func (m *Commander) AddCompletionCommand() { DisableFlagsInUseLine: true, ValidArgs: []string{"bash", "zsh", "fish", "powershell"}, Args: cobra.ExactValidArgs(1), - Run: m.runCompletionCommand, + Run: func(cmd *cobra.Command, args []string) { + switch args[0] { + case "bash": + cmd.Root().GenBashCompletion(os.Stdout) + case "zsh": + cmd.Root().GenZshCompletion(os.Stdout) + case "fish": + cmd.Root().GenFishCompletion(os.Stdout, true) + case "powershell": + cmd.Root().GenPowerShellCompletionWithDesc(os.Stdout) + } + }, } m.RootCmd.AddCommand(completionCmd) } -// runCompletionCommand executes the appropriate shell completion generation logic. -func (m *Commander) runCompletionCommand(cmd *cobra.Command, args []string) { - switch args[0] { - case "bash": - cmd.Root().GenBashCompletion(os.Stdout) - case "zsh": - cmd.Root().GenZshCompletion(os.Stdout) - case "fish": - cmd.Root().GenFishCompletion(os.Stdout, true) - case "powershell": - cmd.Root().GenPowerShellCompletionWithDesc(os.Stdout) - } -} - // generateCompletionSummary creates the long description for the `completion` command. -func (m *Commander) generateCompletionSummary(exec string) string { +func (m *Manager) generateCompletionSummary(exec string) string { var execs []interface{} for i := 0; i < 12; i++ { execs = append(execs, exec) diff --git a/cli/cmdx/hooks.go b/cli/commander/hooks.go similarity index 60% rename from cli/cmdx/hooks.go rename to cli/commander/hooks.go index 1182a7e..2554c21 100644 --- a/cli/cmdx/hooks.go +++ b/cli/commander/hooks.go @@ -1,7 +1,7 @@ -package cmdx +package commander -// AddClientHooks applies all configured hooks to commands annotated with `client:true`. -func (m *Commander) AddClientHooks() { +// addClientHooks applies all configured hooks to commands annotated with `client:true`. +func (m *Manager) addClientHooks() { for _, cmd := range m.RootCmd.Commands() { for _, hook := range m.Hooks { if cmd.Annotations["client"] == "true" { diff --git a/cli/cmdx/help.go b/cli/commander/layout.go similarity index 82% rename from cli/cmdx/help.go rename to cli/commander/layout.go index b738dd3..580f2b2 100644 --- a/cli/cmdx/help.go +++ b/cli/commander/layout.go @@ -1,10 +1,16 @@ -package cmdx +package commander import ( + "bytes" "errors" "fmt" + "regexp" "strings" + "github.com/muesli/termenv" + "golang.org/x/text/cases" + "golang.org/x/text/language" + "github.com/spf13/cobra" "github.com/spf13/pflag" ) @@ -24,16 +30,10 @@ const ( feedback = "Feedback" ) -// SetCustomHelp configures a custom help function for the CLI. -// +// setCustomHelp configures a custom help function for the CLI. // The custom help function organizes commands into sections and provides // detailed error messages for incorrect flag usage. -// -// Example: -// -// manager := cmdx.NewCommander(rootCmd) -// manager.SetCustomHelp() -func (m *Commander) SetCustomHelp() { +func (m *Manager) setCustomHelp() { m.RootCmd.PersistentFlags().Bool("help", false, "Show help for command") m.RootCmd.SetHelpFunc(func(cmd *cobra.Command, args []string) { @@ -206,3 +206,54 @@ type helpEntry struct { Title string Body string } + +// rpad adds padding to the right of a string. +func rpad(s string, padding int) string { + template := fmt.Sprintf("%%-%ds ", padding) + return fmt.Sprintf(template, s) +} + +func dedent(s string) string { + lines := strings.Split(s, "\n") + minIndent := -1 + + for _, l := range lines { + if len(l) == 0 { + continue + } + + indent := len(l) - len(strings.TrimLeft(l, " ")) + if minIndent == -1 || indent < minIndent { + minIndent = indent + } + } + + if minIndent <= 0 { + return s + } + + var buf bytes.Buffer + for _, l := range lines { + fmt.Fprintln(&buf, strings.TrimPrefix(l, strings.Repeat(" ", minIndent))) + } + return strings.TrimSuffix(buf.String(), "\n") +} + +var lineRE = regexp.MustCompile(`(?m)^`) + +func indent(s, indent string) string { + if len(strings.TrimSpace(s)) == 0 { + return s + } + return lineRE.ReplaceAllLiteralString(s, indent) +} + +func toTitle(text string) string { + heading := cases.Title(language.Und).String(text) + return heading +} + +func bold(text string) termenv.Style { + h := termenv.String(text).Bold() + return h +} diff --git a/cli/cmdx/cmdx.go b/cli/commander/manager.go similarity index 67% rename from cli/cmdx/cmdx.go rename to cli/commander/manager.go index 5a2a0e0..50e9a5d 100644 --- a/cli/cmdx/cmdx.go +++ b/cli/commander/manager.go @@ -1,4 +1,4 @@ -package cmdx +package commander import ( "strings" @@ -6,8 +6,8 @@ import ( "github.com/spf13/cobra" ) -// Commander manages and configures features for a CLI tool. -type Commander struct { +// Manager manages and configures features for a CLI tool. +type Manager struct { RootCmd *cobra.Command Help bool // Enable custom help. Reference bool // Enable reference command. @@ -32,19 +32,19 @@ type HookBehavior struct { Behavior func(cmd *cobra.Command) // Function to apply to commands. } -// NewCommander creates a new CLI Commander using the provided root command and optional configurations. +// New creates a new CLI Manager using the provided root command and optional configurations. // // Parameters: // - rootCmd: The root Cobra command for the CLI. -// - options: Functional options for configuring the Commander. +// - options: Functional options for configuring the Manager. // // Example: // // rootCmd := &cobra.Command{Use: "mycli"} // manager := cmdx.NewCommander(rootCmd, cmdx.WithTopics(...), cmdx.WithHooks(...)) -func NewCommander(rootCmd *cobra.Command, options ...func(*Commander)) *Commander { - // Create Commander with defaults - manager := &Commander{ +func New(rootCmd *cobra.Command, options ...func(*Manager)) *Manager { + // Create Manager with defaults + manager := &Manager{ RootCmd: rootCmd, Help: true, // Default enabled Reference: true, // Default enabled @@ -62,51 +62,49 @@ func NewCommander(rootCmd *cobra.Command, options ...func(*Commander)) *Commande return manager } -// Init sets up the CLI features based on the Commander's configuration. -// +// Init sets up the CLI features based on the Manager's configuration. // It enables or disables features like custom help, reference documentation, -// shell completion, help topics, and client hooks based on the Commander's settings. -func (m *Commander) Init() { +// shell completion, help topics, and client hooks based on the Manager's settings. +func (m *Manager) Init() { if m.Help { - m.SetCustomHelp() + m.setCustomHelp() } if m.Reference { - m.AddReferenceCommand() + m.addReferenceCommand() } if m.Completion { - m.AddCompletionCommand() + m.addCompletionCommand() } if m.Docs { - m.AddMarkdownCommand("./docs") + m.addMarkdownCommand("./docs") } if len(m.Topics) > 0 { - m.AddHelpTopics() + m.addHelpTopics() } if len(m.Hooks) > 0 { - m.AddClientHooks() + m.addClientHooks() } } -// WithTopics sets the help topics for the Commander. -func WithTopics(topics []HelpTopic) func(*Commander) { - return func(m *Commander) { +// WithTopics sets the help topics for the Manager. +func WithTopics(topics []HelpTopic) func(*Manager) { + return func(m *Manager) { m.Topics = topics } } -// WithHooks sets the hook behaviors for the Commander. -func WithHooks(hooks []HookBehavior) func(*Commander) { - return func(m *Commander) { +// WithHooks sets the hook behaviors for the Manager. +func WithHooks(hooks []HookBehavior) func(*Manager) { + return func(m *Manager) { m.Hooks = hooks } } -// IsCLIErr checks if the given error is related to a Cobra command error. -// +// IsCommandErr 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. -func IsCLIErr(err error) bool { +func IsCommandErr(err error) bool { if err == nil { return false } diff --git a/cli/cmdx/reference.go b/cli/commander/reference.go similarity index 78% rename from cli/cmdx/reference.go rename to cli/commander/reference.go index a537c08..aea327f 100644 --- a/cli/cmdx/reference.go +++ b/cli/commander/reference.go @@ -1,4 +1,4 @@ -package cmdx +package commander import ( "bytes" @@ -11,16 +11,10 @@ import ( "github.com/spf13/cobra" ) -// AddReferenceCommand adds a `reference` command to the CLI. -// -// The reference command generates markdown documentation for all commands +// addReferenceCommand adds a `reference` command to the CLI. +// The `reference` command generates markdown documentation for all commands // in the CLI command tree. -// -// Example: -// -// manager := cmdx.NewCommander(rootCmd) -// manager.AddReferenceCommand() -func (m *Commander) AddReferenceCommand() { +func (m *Manager) addReferenceCommand() { var isPlain bool refCmd := &cobra.Command{ Use: "reference", @@ -39,7 +33,7 @@ func (m *Commander) AddReferenceCommand() { // runReferenceCommand handles the output generation for the `reference` command. // It renders the documentation either as plain markdown or with ANSI color. -func (m *Commander) runReferenceCommand(isPlain *bool) func(cmd *cobra.Command, args []string) { +func (m *Manager) runReferenceCommand(isPlain *bool) func(cmd *cobra.Command, args []string) { return func(cmd *cobra.Command, args []string) { var ( output string @@ -62,7 +56,7 @@ func (m *Commander) runReferenceCommand(isPlain *bool) func(cmd *cobra.Command, // generateReferenceMarkdown generates a complete markdown representation // of the command tree for the `reference` command. -func (m *Commander) generateReferenceMarkdown() string { +func (m *Manager) generateReferenceMarkdown() string { buf := bytes.NewBufferString(fmt.Sprintf("# %s reference\n\n", m.RootCmd.Name())) for _, c := range m.RootCmd.Commands() { if c.Hidden { @@ -75,7 +69,7 @@ func (m *Commander) generateReferenceMarkdown() string { // generateCommandReference recursively generates markdown for a given command // and its subcommands. -func (m *Commander) generateCommandReference(w io.Writer, cmd *cobra.Command, depth int) { +func (m *Manager) 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) diff --git a/cli/cmdx/topics.go b/cli/commander/topics.go similarity index 87% rename from cli/cmdx/topics.go rename to cli/commander/topics.go index 2901f58..96383f2 100644 --- a/cli/cmdx/topics.go +++ b/cli/commander/topics.go @@ -1,4 +1,4 @@ -package cmdx +package commander import ( "fmt" @@ -6,18 +6,18 @@ import ( "github.com/spf13/cobra" ) -// AddHelpTopics adds all configured help topics to the CLI. +// addHelpTopics adds all configured help topics to the CLI. // // Help topics provide detailed information about specific subjects, // such as environment variables or configuration. -func (m *Commander) AddHelpTopics() { +func (m *Manager) addHelpTopics() { for _, topic := range m.Topics { m.addHelpTopicCommand(topic) } } // addHelpTopicCommand adds a single help topic command to the CLI. -func (m *Commander) addHelpTopicCommand(topic HelpTopic) { +func (m *Manager) addHelpTopicCommand(topic HelpTopic) { helpCmd := &cobra.Command{ Use: topic.Name, Short: topic.Short, diff --git a/cli/config/commands.go b/cli/config/commands.go deleted file mode 100644 index f5c11c6..0000000 --- a/cli/config/commands.go +++ /dev/null @@ -1,57 +0,0 @@ -package config - -import ( - "fmt" - "log" - - "github.com/spf13/cobra" -) - -// Commands returns a list of Cobra commands for managing the configuration. -func Commands(app string, cfgTemplate interface{}) (*cobra.Command, error) { - cfg, err := New(app) - if err != nil { - return nil, err - } - - cmd := &cobra.Command{ - Use: "config", - Short: "Manage application configuration", - Annotations: map[string]string{ - "group": "core", - }, - } - - cmd.AddCommand( - &cobra.Command{ - Use: "init", - Short: "Initialize configuration with default values", - Annotations: map[string]string{ - "group": "core", - }, - Run: func(cmd *cobra.Command, args []string) { - if err := cfg.Init(cfgTemplate); err != nil { - log.Fatalf("Error initializing config: %v", err) - } - fmt.Println("Configuration initialized successfully.") - }, - }, - &cobra.Command{ - Use: "view", - Short: "View the current configuration", - Annotations: map[string]string{ - "group": "core", - }, - Run: func(cmd *cobra.Command, args []string) { - content, err := cfg.Read() - if err != nil { - log.Fatalf("Error reading config: %v", err) - } - fmt.Println("Current Configuration:") - fmt.Println(content) - }, - }, - ) - - return cmd, nil -} diff --git a/cli/config/config.go b/cli/config/config.go deleted file mode 100644 index 1ce89c3..0000000 --- a/cli/config/config.go +++ /dev/null @@ -1,136 +0,0 @@ -package config - -import ( - "errors" - "fmt" - "os" - "path/filepath" - "runtime" - - "github.com/mcuadros/go-defaults" - "github.com/raystack/salt/config" - "github.com/spf13/pflag" - "gopkg.in/yaml.v3" -) - -// Config represents the configuration structure. -type Config struct { - path string - flags *pflag.FlagSet -} - -// New creates a new Config instance for the given application. -func New(app string, opts ...Opts) (*Config, error) { - filePath, err := getConfigFilePath(app) - if err != nil { - return nil, fmt.Errorf("failed to determine config file path: %w", err) - } - - cfg := &Config{path: filePath} - for _, opt := range opts { - opt(cfg) - } - - return cfg, nil -} - -// Opts defines a functional option for configuring the Config object. -type Opts func(c *Config) - -// WithFlags binds command-line flags to configuration values. -func WithFlags(pfs *pflag.FlagSet) Opts { - return func(c *Config) { - c.flags = pfs - } -} - -// Load reads the configuration file into the Config's Data map. -func (c *Config) Load(cfg interface{}) error { - loaderOpts := []config.Option{config.WithFile(c.path)} - - if c.flags != nil { - loaderOpts = append(loaderOpts, config.WithFlags(c.flags)) - } - - loader := config.NewLoader(loaderOpts...) - return loader.Load(cfg) -} - -// Init initializes the configuration file with default values. -func (c *Config) Init(cfg interface{}) error { - defaults.SetDefaults(cfg) - - if fileExists(c.path) { - 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) { - data, err := os.ReadFile(c.path) - if err != nil { - return "", fmt.Errorf("failed to read configuration file: %w", err) - } - return string(data), nil -} - -// Write writes the given struct to the configuration file in YAML format. -func (c *Config) Write(cfg interface{}) error { - data, err := yaml.Marshal(cfg) - if err != nil { - return fmt.Errorf("failed to marshal configuration: %w", err) - } - - if err := ensureDir(filepath.Dir(c.path)); err != nil { - return err - } - - if err := os.WriteFile(c.path, data, 0655); err != nil { - return fmt.Errorf("failed to write configuration file: %w", err) - } - return nil -} - -// getConfigFile determines the full path to the configuration file for the application. -func getConfigFilePath(app string) (string, error) { - dirPath := getConfigDir("raystack") - if err := ensureDir(dirPath); err != nil { - return "", err - } - return filepath.Join(dirPath, app+".yml"), nil -} - -// getConfigDir determines the directory for storing configurations. -func getConfigDir(root string) string { - switch { - case envSet("RAYSTACK_CONFIG_DIR"): - return filepath.Join(os.Getenv("RAYSTACK_CONFIG_DIR"), root) - case envSet("XDG_CONFIG_HOME"): - return filepath.Join(os.Getenv("XDG_CONFIG_HOME"), root) - case runtime.GOOS == "windows" && envSet("APPDATA"): - return filepath.Join(os.Getenv("APPDATA"), root) - default: - home, _ := os.UserHomeDir() - return filepath.Join(home, ".config", root) - } -} - -// ensureDir ensures that the given directory exists. -func ensureDir(dir string) error { - if err := os.MkdirAll(dir, 0755); err != nil { - return fmt.Errorf("failed to create directory %q: %w", dir, err) - } - return nil -} - -// envSet checks if an environment variable is set and non-empty. -func envSet(key string) bool { - return os.Getenv(key) != "" -} - -func fileExists(filename string) bool { - _, err := os.Stat(filename) - return err == nil -} diff --git a/cli/printer/README.md b/cli/printer/README.md deleted file mode 100644 index e61dc82..0000000 --- a/cli/printer/README.md +++ /dev/null @@ -1,185 +0,0 @@ -# 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/colors.go b/cli/printer/colors.go index 1d700a2..c5fc0e1 100644 --- a/cli/printer/colors.go +++ b/cli/printer/colors.go @@ -50,78 +50,76 @@ func NewTheme() Theme { var theme = NewTheme() -func bold(t ...string) string { - return termenv.String(t...).Bold().String() -} - -func boldf(t string, args ...interface{}) string { - return bold(fmt.Sprintf(t, args...)) -} - -func italic(t ...string) string { - return termenv.String(t...).Italic().String() -} - -func italicf(t string, args ...interface{}) string { - return italic(fmt.Sprintf(t, args...)) +// formatColorize applies the given color to the formatted text. +func formatColorize(color termenv.Color, t string, args ...interface{}) string { + return colorize(color, fmt.Sprintf(t, args...)) } func Green(t ...string) string { - return termenv.String(t...).Foreground(theme.Green).String() + return colorize(theme.Green, t...) } func Greenf(t string, args ...interface{}) string { - return Green(fmt.Sprintf(t, args...)) + return formatColorize(theme.Green, t, args...) } func Yellow(t ...string) string { - return termenv.String(t...).Foreground(theme.Yellow).String() + return colorize(theme.Yellow, t...) } func Yellowf(t string, args ...interface{}) string { - return Yellow(fmt.Sprintf(t, args...)) + return formatColorize(theme.Yellow, t, args...) } func Cyan(t ...string) string { - return termenv.String(t...).Foreground(theme.Cyan).String() + return colorize(theme.Cyan, t...) } func Cyanf(t string, args ...interface{}) string { - return Cyan(fmt.Sprintf(t, args...)) + return formatColorize(theme.Cyan, t, args...) } func Red(t ...string) string { - return termenv.String(t...).Foreground(theme.Red).String() + return colorize(theme.Red, t...) } func Redf(t string, args ...interface{}) string { - return Red(fmt.Sprintf(t, args...)) + return formatColorize(theme.Red, t, args...) } func Grey(t ...string) string { - return termenv.String(t...).Foreground(theme.Grey).String() + return colorize(theme.Grey, t...) } func Greyf(t string, args ...interface{}) string { - return Grey(fmt.Sprintf(t, args...)) + return formatColorize(theme.Grey, t, args...) } func Blue(t ...string) string { - return termenv.String(t...).Foreground(theme.Blue).String() + return colorize(theme.Blue, t...) } func Bluef(t string, args ...interface{}) string { - return Blue(fmt.Sprintf(t, args...)) + return formatColorize(theme.Blue, t, args...) } func Magenta(t ...string) string { - return termenv.String(t...).Foreground(theme.Magenta).String() + return colorize(theme.Magenta, t...) } func Magentaf(t string, args ...interface{}) string { - return Magenta(fmt.Sprintf(t, args...)) + return formatColorize(theme.Magenta, t, args...) +} + +func Icon(name string) string { + icons := map[string]string{"failure": "✘", "success": "✔", "info": "ℹ", "warning": "⚠"} + if icon, exists := icons[name]; exists { + return icon + } + return "" } -func FailureIcon() string { - return termenv.String("✘").String() +// colorize applies the given color to the text. +func colorize(color termenv.Color, t ...string) string { + return termenv.String(t...).Foreground(color).String() } diff --git a/cli/printer/file.go b/cli/printer/file.go deleted file mode 100644 index 833025c..0000000 --- a/cli/printer/file.go +++ /dev/null @@ -1,100 +0,0 @@ -package printer - -import ( - "encoding/json" - "fmt" - - "gopkg.in/yaml.v3" -) - -// 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 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 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 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) - } - - if err != nil { - 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 55e4d1b..aafb98e 100644 --- a/cli/printer/markdown.go +++ b/cli/printer/markdown.go @@ -25,7 +25,7 @@ func withoutIndentation() glamour.TermRendererOption { return glamour.WithStylesFromJSONBytes(overrides) } -// This ensures the rendered markdown does not wrap lines, useful for wide terminals. +// withoutWrap ensures the rendered markdown does not wrap lines, useful for wide terminals. func withoutWrap() glamour.TermRendererOption { return glamour.WithWordWrap(0) } @@ -35,30 +35,15 @@ func render(text string, opts RenderOpts) (string, error) { // 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(), // Automatically determine styling based on terminal settings. @@ -71,18 +56,6 @@ func Markdown(text string) (string, error) { } // 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(), // Automatically determine styling based on terminal settings. diff --git a/cli/printer/progress.go b/cli/printer/progress.go index 3ee85f5..da6e1ef 100644 --- a/cli/printer/progress.go +++ b/cli/printer/progress.go @@ -6,15 +6,10 @@ import ( // 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") diff --git a/cli/printer/spinner.go b/cli/printer/spinner.go index 627f7fd..6a66d0d 100644 --- a/cli/printer/spinner.go +++ b/cli/printer/spinner.go @@ -3,9 +3,8 @@ package printer import ( "time" - "github.com/raystack/salt/cli/terminal" - "github.com/briandowns/spinner" + "github.com/raystack/salt/cli/terminator" ) // Indicator represents a terminal spinner used for indicating progress or ongoing operations. @@ -51,7 +50,7 @@ func Spin(label string) *Indicator { set := spinner.CharSets[11] // Check if the terminal supports TTY; if not, return a no-op Indicator. - if !terminal.IsTTY() { + if !terminator.IsTTY() { return &Indicator{} } diff --git a/cli/printer/structured.go b/cli/printer/structured.go new file mode 100644 index 0000000..92a6071 --- /dev/null +++ b/cli/printer/structured.go @@ -0,0 +1,45 @@ +package printer + +import ( + "encoding/json" + "fmt" + + "gopkg.in/yaml.v3" +) + +// YAML prints the given data in YAML format. +func YAML(data interface{}) error { + return File(data, "yaml") +} + +// JSON prints the given data in JSON format. +func JSON(data interface{}) error { + return File(data, "json") +} + +// PrettyJSON prints the given data in pretty-printed JSON format. +func PrettyJSON(data interface{}) error { + return File(data, "prettyjson") +} + +// File marshals and prints the given data in the specified format. +func File(data interface{}, format string) (err error) { + var output []byte + switch format { + case "yaml": + output, err = yaml.Marshal(data) + case "json": + output, err = json.Marshal(data) + case "prettyjson": + output, err = json.MarshalIndent(data, "", "\t") + default: + return fmt.Errorf("unknown format: %v", format) + } + + if err != nil { + return err + } + + fmt.Println(string(output)) + return nil +} diff --git a/cli/printer/text.go b/cli/printer/text.go index d371de6..49a0c58 100644 --- a/cli/printer/text.go +++ b/cli/printer/text.go @@ -2,99 +2,116 @@ package printer import ( "fmt" + + "github.com/muesli/termenv" ) // Success prints the given message(s) in green to indicate success. func Success(t ...string) { - fmt.Print(Green(t...)) + printWithColor(Green, t...) } // Successln prints the given message(s) in green with a newline. func Successln(t ...string) { - fmt.Println(Green(t...)) + printWithColorln(Green, t...) } // Successf formats and prints the success message in green. func Successf(t string, args ...interface{}) { - fmt.Print(Greenf(t, args...)) + printWithColorf(Greenf, t, args...) } // Warning prints the given message(s) in yellow to indicate a warning. func Warning(t ...string) { - fmt.Print(Yellow(t...)) + printWithColor(Yellow, t...) } // Warningln prints the given message(s) in yellow with a newline. func Warningln(t ...string) { - fmt.Println(Yellow(t...)) + printWithColorln(Yellow, t...) } // Warningf formats and prints the warning message in yellow. func Warningf(t string, args ...interface{}) { - fmt.Print(Yellowf(t, args...)) + printWithColorf(Yellowf, t, args...) } // Error prints the given message(s) in red to indicate an error. func Error(t ...string) { - fmt.Print(Red(t...)) + printWithColor(Red, t...) } // Errorln prints the given message(s) in red with a newline. func Errorln(t ...string) { - fmt.Println(Red(t...)) + printWithColorln(Red, t...) } // Errorf formats and prints the error message in red. func Errorf(t string, args ...interface{}) { - fmt.Print(Redf(t, args...)) + printWithColorf(Redf, t, args...) } // Info prints the given message(s) in cyan to indicate informational messages. func Info(t ...string) { - fmt.Print(Cyan(t...)) + printWithColor(Cyan, t...) } // Infoln prints the given message(s) in cyan with a newline. func Infoln(t ...string) { - fmt.Println(Cyan(t...)) + printWithColorln(Cyan, t...) } // Infof formats and prints the informational message in cyan. func Infof(t string, args ...interface{}) { - fmt.Print(Cyanf(t, args...)) + printWithColorf(Cyanf, t, args...) } // Bold prints the given message(s) in bold style. -func Bold(t ...string) { - fmt.Print(bold(t...)) +func Bold(t ...string) string { + return termenv.String(t...).Bold().String() } // Boldln prints the given message(s) in bold style with a newline. func Boldln(t ...string) { - fmt.Println(bold(t...)) + fmt.Println(Bold(t...)) } // Boldf formats and prints the message in bold style. -func Boldf(t string, args ...interface{}) { - fmt.Print(boldf(t, args...)) +func Boldf(t string, args ...interface{}) string { + return Bold(fmt.Sprintf(t, args...)) } // Italic prints the given message(s) in italic style. -func Italic(t ...string) { - fmt.Print(italic(t...)) +func Italic(t ...string) string { + return termenv.String(t...).Italic().String() } // Italicln prints the given message(s) in italic style with a newline. func Italicln(t ...string) { - fmt.Println(italic(t...)) + 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 Italicf(t string, args ...interface{}) string { + return Italic(fmt.Sprintf(t, args...)) } // Space prints a single space to the output. func Space() { fmt.Print(" ") } + +// printWithColor prints the given message(s) with the specified color function. +func printWithColor(colorFunc func(...string) string, t ...string) { + fmt.Print(colorFunc(t...)) +} + +// printWithColorln prints the given message(s) with the specified color function and a newline. +func printWithColorln(colorFunc func(...string) string, t ...string) { + fmt.Println(colorFunc(t...)) +} + +// printWithColorf formats and prints the message with the specified color function. +func printWithColorf(colorFunc func(string, ...interface{}) string, t string, args ...interface{}) { + fmt.Print(colorFunc(t, args...)) +} diff --git a/cli/prompt/README.md b/cli/prompt/README.md deleted file mode 100644 index da5e71a..0000000 --- a/cli/prompt/README.md +++ /dev/null @@ -1,67 +0,0 @@ -# Prompt Package - -The `prompt` package simplifies interactive CLI input using the `survey` library. It provides a consistent interface for user prompts such as single and multi-selection, text input, and confirmation. - -## Features - -- **Select**: Prompt users to select one option from a list. -- **MultiSelect**: Prompt users to select multiple options. -- **Input**: Prompt users to provide text input. -- **Confirm**: Prompt users for a yes/no confirmation. - -## Installation - -Add the package to your Go project: - -```bash -go get github.com/raystack/salt/cli/prompt -``` - -## Usage - -Here’s an example of how to use the `prompt` package: - -```go -package main - -import ( - "fmt" - "github.com/raystack/salt/cli/prompt" -) - -func main() { - p := prompt.New() - - // Single selection - index, err := p.Select("Choose an option:", "Option 1", []string{"Option 1", "Option 2", "Option 3"}) - if err != nil { - fmt.Println("Error:", err) - return - } - fmt.Println("Selected option index:", index) - - // Multi-selection - indices, err := p.MultiSelect("Choose multiple options:", nil, []string{"Option A", "Option B", "Option C"}) - if err != nil { - fmt.Println("Error:", err) - return - } - fmt.Println("Selected option indices:", indices) - - // Text input - input, err := p.Input("Enter your name:", "John Doe") - if err != nil { - fmt.Println("Error:", err) - return - } - fmt.Println("Input:", input) - - // Confirmation - confirm, err := p.Confirm("Do you want to proceed?", true) - if err != nil { - fmt.Println("Error:", err) - return - } - fmt.Println("Confirmation:", confirm) -} -``` \ No newline at end of file diff --git a/cli/prompt/prompt.go b/cli/prompter/prompt.go similarity index 99% rename from cli/prompt/prompt.go rename to cli/prompter/prompt.go index f69b8c6..5e17c6c 100644 --- a/cli/prompt/prompt.go +++ b/cli/prompter/prompt.go @@ -1,4 +1,4 @@ -package prompt +package prompter import ( "fmt" diff --git a/cli/release/README.md b/cli/release/README.md deleted file mode 100644 index 3e33cf1..0000000 --- a/cli/release/README.md +++ /dev/null @@ -1,72 +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/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_test.go b/cli/release/release_test.go deleted file mode 100644 index d542d9b..0000000 --- a/cli/release/release_test.go +++ /dev/null @@ -1,89 +0,0 @@ -package release - -import ( - "net/http" - "net/http/httptest" - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestReleaseInfo_Success(t *testing.T) { - // Mock a successful GitHub API response - mockResponse := `{ - "tag_name": "v1.2.3", - "tarball_url": "https://example.com/tarball/v1.2.3" - }` - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - w.Write([]byte(mockResponse)) - })) - defer server.Close() - - 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) -} - -func TestReleaseInfo_Failure(t *testing.T) { - // Mock a failed GitHub API response with a non-OK status code - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusInternalServerError) - })) - defer server.Close() - - info, err := FetchInfo(server.URL) - assert.Error(t, err) - assert.Nil(t, info) -} - -func TestReleaseInfo_InvalidJSON(t *testing.T) { - // Mock a response with invalid JSON - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - w.Write([]byte(`invalid json`)) - })) - defer server.Close() - - info, err := FetchInfo(server.URL) - assert.Error(t, err) - assert.Nil(t, info) -} - -func TestIsCurrentLatest(t *testing.T) { - // Test cases for release comparison - tests := []struct { - currVersion string - latestVersion string - expected bool - shouldError bool - }{ - {"1.2.3", "1.2.2", true, false}, - {"1.2.3", "1.2.3", true, false}, - {"1.2.2", "1.2.3", false, false}, - {"invalid", "1.2.3", false, true}, - {"1.2.3", "invalid", false, true}, - } - - for _, test := range tests { - result, err := CompareVersions(test.currVersion, test.latestVersion) - if test.shouldError { - assert.Error(t, err) - } else { - assert.NoError(t, err) - assert.Equal(t, test.expected, result) - } - } -} - -func TestUpdateNotice_ErrorHandling(t *testing.T) { - // Mock a server that returns an error - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusInternalServerError) - })) - defer server.Close() - - notice := CheckForUpdate("1.0.0", server.URL) - assert.Equal(t, "", notice) -} diff --git a/cli/release/release.go b/cli/releaser/release.go similarity index 99% rename from cli/release/release.go rename to cli/releaser/release.go index f2ef914..a821fe1 100644 --- a/cli/release/release.go +++ b/cli/releaser/release.go @@ -1,4 +1,4 @@ -package release +package releaser import ( "encoding/json" diff --git a/cli/terminal/README.md b/cli/terminal/README.md deleted file mode 100644 index dc9f617..0000000 --- a/cli/terminal/README.md +++ /dev/null @@ -1,141 +0,0 @@ -# Terminal - -The `terminal` package provides a collection of utilities to manage terminal interactions, including pager handling, TTY detection, and environment configuration for better command-line application support. - -## Features - -- **Pager Management**: Easily manage pagers like `less` or `more` to display output in a paginated format. -- **TTY Detection**: Check if the program is running in a terminal environment. -- **Color Management**: Determine if color output is disabled based on environment variables. -- **CI Detection**: Identify if the program is running in a Continuous Integration (CI) environment. -- **Homebrew Utilities**: Check for Homebrew installation and verify binary paths. -- **Browser Launching**: Open URLs in the default web browser, with cross-platform support. - -## Installation - -To include this package in your Go project, use: - -```bash -go get github.com/raystack/salt/ -``` - -## Usage - -### 1. Creating and Using a Pager - -The `Pager` struct manages a pager process for displaying output in a paginated format. - -```go -package main - -import ( - "fmt" - "github.com/raystack/salt/cli/terminal" -) - -func main() { - // Create a new Pager instance - pager := terminal.NewPager() - - // Optionally, set a custom pager command - pager.Set("less -R") - - // Start the pager - err := pager.Start() - if err != nil { - fmt.Println("Error starting pager:", err) - return - } - defer pager.Stop() - - // Output text to the pager - fmt.Fprintln(pager.Out, "This is a sample text output to the pager.") -} -``` - -### 2. Checking if the Terminal is a TTY - -Use `IsTTY` to check if the output is a TTY (teletypewriter). - -```go -if terminal.IsTTY() { - fmt.Println("Running in a terminal!") -} else { - fmt.Println("Not running in a terminal.") -} -``` - -### 3. Checking if Color Output is Disabled - -Use `IsColorDisabled` to determine if color output should be suppressed. - -```go -if terminal.IsColorDisabled() { - fmt.Println("Color output is disabled.") -} else { - fmt.Println("Color output is enabled.") -} -``` - -### 4. Checking if Running in a CI Environment - -Use `IsCI` to check if the program is running in a CI environment. - -```go -if terminal.IsCI() { - fmt.Println("Running in a Continuous Integration environment.") -} else { - fmt.Println("Not running in a CI environment.") -} -``` - - -### 4. Checking if Running in a CI Environment - -Use `IsCI` to check if the program is running in a CI environment. - -```go -if terminal.IsCI() { - fmt.Println("Running in a Continuous Integration environment.") -} else { - fmt.Println("Not running in a CI environment.") -} -``` - -### 5. Checking for Homebrew Installation - -Use `HasHomebrew` to check if Homebrew is installed on the system. - -```go -if terminal.HasHomebrew() { - fmt.Println("Homebrew is installed!") -} else { - fmt.Println("Homebrew is not installed.") -} -``` - -### 6. Checking if a Binary is Under Homebrew Path - -Use `IsUnderHomebrew` to determine if a binary is managed by Homebrew. - -```go -binaryPath := "/usr/local/bin/somebinary" -if terminal.IsUnderHomebrew(binaryPath) { - fmt.Println("The binary is under the Homebrew path.") -} else { - fmt.Println("The binary is not under the Homebrew path.") -} -``` - -### 7. Opening a URL in the Default Web Browser - -Use `OpenBrowser` to launch the default web browser with a specified URL. - -```go -goos := "darwin" // Use runtime.GOOS to get the current OS in a real scenario -url := "https://www.example.com" -cmd := terminal.OpenBrowser(goos, url) -if err := cmd.Run(); err != nil { - fmt.Println("Failed to open browser:", err) -} -``` \ No newline at end of file diff --git a/cli/terminal/brew.go b/cli/terminator/brew.go similarity index 79% rename from cli/terminal/brew.go rename to cli/terminator/brew.go index 2e20f80..fe926d2 100644 --- a/cli/terminal/brew.go +++ b/cli/terminator/brew.go @@ -1,4 +1,4 @@ -package terminal +package terminator import ( "os/exec" @@ -7,15 +7,8 @@ import ( ) // IsUnderHomebrew checks if a given binary path is managed under the Homebrew path. -// // This function is useful to verify if a binary is installed via Homebrew // by comparing its location to the Homebrew binary directory. -// -// Parameters: -// - path: The path of the binary to check. -// -// Returns: -// - A boolean value indicating whether the binary is located under the Homebrew path. func IsUnderHomebrew(path string) bool { if path == "" { return false @@ -36,13 +29,9 @@ func IsUnderHomebrew(path string) bool { } // HasHomebrew checks if Homebrew is installed on the user's system. -// // This function determines the presence of Homebrew by looking for the "brew" // executable in the system's PATH. It is useful to ensure Homebrew dependencies // can be managed before executing related commands. -// -// Returns: -// - A boolean value indicating whether Homebrew is installed. func HasHomebrew() bool { _, err := exec.LookPath("brew") return err == nil diff --git a/cli/terminal/browser.go b/cli/terminator/browser.go similarity index 98% rename from cli/terminal/browser.go rename to cli/terminator/browser.go index 687b03f..c8127d5 100644 --- a/cli/terminal/browser.go +++ b/cli/terminator/browser.go @@ -1,4 +1,4 @@ -package terminal +package terminator import ( "os" diff --git a/cli/terminal/pager.go b/cli/terminator/pager.go similarity index 99% rename from cli/terminal/pager.go rename to cli/terminator/pager.go index 3d1986f..7071e08 100644 --- a/cli/terminal/pager.go +++ b/cli/terminator/pager.go @@ -1,4 +1,4 @@ -package terminal +package terminator import ( "errors" diff --git a/cli/terminal/term.go b/cli/terminator/term.go similarity index 79% rename from cli/terminal/term.go rename to cli/terminator/term.go index 175fda4..23b66d8 100644 --- a/cli/terminal/term.go +++ b/cli/terminator/term.go @@ -1,4 +1,4 @@ -package terminal +package terminator import ( "os" @@ -8,34 +8,22 @@ import ( ) // IsTTY checks if the current output is a TTY (teletypewriter) or a Cygwin terminal. -// // This function is useful for determining if the program is running in a terminal // environment, which is important for features like colored output or interactive prompts. -// -// Returns: -// - A boolean value indicating whether the current output is a TTY or Cygwin terminal. func IsTTY() bool { return isatty.IsTerminal(os.Stdout.Fd()) || isatty.IsCygwinTerminal(os.Stdout.Fd()) } // IsColorDisabled checks if color output is disabled based on the environment settings. -// // This function uses the `termenv` library to determine if the NO_COLOR environment // variable is set, which is a common way to disable colored output. -// -// Returns: -// - A boolean value indicating whether color output is disabled. func IsColorDisabled() bool { return termenv.EnvNoColor() } // IsCI checks if the code is running in a Continuous Integration (CI) environment. -// // This function checks for common environment variables used by popular CI systems // like GitHub Actions, Travis CI, CircleCI, Jenkins, TeamCity, and others. -// -// Returns: -// - A boolean value indicating whether the code is running in a CI environment. func IsCI() bool { return os.Getenv("CI") != "" || // GitHub Actions, Travis CI, CircleCI, Cirrus CI, GitLab CI, AppVeyor, CodeShip, dsari os.Getenv("BUILD_NUMBER") != "" || // Jenkins, TeamCity diff --git a/config/config.go b/config/config.go index 51b1588..c82ae54 100644 --- a/config/config.go +++ b/config/config.go @@ -5,13 +5,12 @@ import ( "errors" "fmt" "os" + "path/filepath" "reflect" "strings" "github.com/go-playground/validator" - "github.com/jeremywohl/flatten" "github.com/mcuadros/go-defaults" - "github.com/mitchellh/mapstructure" "github.com/spf13/pflag" "github.com/spf13/viper" "gopkg.in/yaml.v3" @@ -71,6 +70,17 @@ func WithFlags(flags *pflag.FlagSet) Option { } } +// WithAppConfig sets up application-specific configuration file handling. +func WithAppConfig(app string) Option { + return func(l *Loader) { + filePath, err := getConfigFilePath(app) + if err != nil { + panic(fmt.Errorf("failed to determine config file path: %w", err)) + } + l.v.SetConfigFile(filePath) + } +} + // Load reads the configuration from the file, environment variables, and command-line flags, // and merges them into the provided configuration struct. It validates the configuration // using struct tags. @@ -127,6 +137,30 @@ func (l *Loader) Load(config interface{}) error { return nil } +// Init initializes the configuration file with default values. +func (l *Loader) Init(config interface{}) error { + defaults.SetDefaults(config) + + path := l.v.ConfigFileUsed() + if fileExists(path) { + return errors.New("configuration file already exists") + } + + data, err := yaml.Marshal(config) + if err != nil { + return fmt.Errorf("failed to marshal configuration: %w", err) + } + + if err := ensureDir(filepath.Dir(path)); err != nil { + return fmt.Errorf("failed to create directory: %w", err) + } + + if err := os.WriteFile(path, data, 0644); err != nil { + return fmt.Errorf("failed to write configuration file: %w", err) + } + return nil +} + // Get retrieves a configuration value by key. func (l *Loader) Get(key string) interface{} { return l.v.Get(key) @@ -165,60 +199,3 @@ func (l *Loader) View() (string, error) { } return string(data), nil } - -// validateStructPtr ensures the provided value is a pointer to a struct. -func validateStructPtr(value interface{}) error { - val := reflect.ValueOf(value) - if val.Kind() != reflect.Ptr || val.Elem().Kind() != reflect.Struct { - return errors.New("load requires a pointer to a struct") - } - return nil -} - -// extractFlattenedKeys retrieves all keys from the struct in a flattened format. -func extractFlattenedKeys(config interface{}) ([]string, error) { - var structMap map[string]interface{} - if err := mapstructure.Decode(config, &structMap); err != nil { - return nil, err - } - flatMap, err := flatten.Flatten(structMap, "", flatten.DotStyle) - if err != nil { - return nil, err - } - keys := make([]string, 0, len(flatMap)) - for k := range flatMap { - keys = append(keys, k) - } - return keys, nil -} - -// bindFlags dynamically binds flags to configuration fields based on `cmdx` tags. -func bindFlags(v *viper.Viper, flagSet *pflag.FlagSet, structType reflect.Type, parentKey string) error { - for i := 0; i < structType.NumField(); i++ { - field := structType.Field(i) - tag := field.Tag.Get("cmdx") - if tag == "" { - continue - } - - if parentKey != "" { - tag = parentKey + "." + tag - } - - if field.Type.Kind() == reflect.Struct { - // Recurse into nested structs - if err := bindFlags(v, flagSet, field.Type, tag); err != nil { - return err - } - } else { - flag := flagSet.Lookup(tag) - if flag == nil { - return fmt.Errorf("missing flag for tag: %s", tag) - } - if err := v.BindPFlag(tag, flag); err != nil { - return fmt.Errorf("failed to bind flag for tag: %s, error: %w", tag, err) - } - } - } - return nil -} diff --git a/config/config_test.go b/config/config_test.go index 2fa0bc5..ab925bd 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -301,3 +301,168 @@ func TestFlagsOnly(t *testing.T) { t.Errorf("Expected LogLevel to be 'warn', got %s", cfg.LogLevel) } } + +func TestLoader_SetAndGetSingleKey(t *testing.T) { + loader := config.NewLoader() + + // Set a value + loader.Set("test.key", "testValue") + loader.Set("test.nested.key", 42) + + value1 := loader.Get("test.key") + if value1 != "testValue" { + t.Errorf("Expected value for 'test.key' to be 'testValue', got %v", value1) + } + + value2 := loader.Get("test.nested.key") + // Assert the value + if value2 != 42 { + t.Errorf("Expected value for 'test.nested.key' to be 42, got %v", value2) + } +} + +func TestLoader_GetKeyFromConfigFile(t *testing.T) { + // Create a temporary configuration file + configFileContent := ` +server: + port: 8080 + host: "example.com" +log_level: "info" +` + configFilePath := "./test_config.yaml" + if err := os.WriteFile(configFilePath, []byte(configFileContent), 0644); err != nil { + t.Fatalf("Failed to create test config file: %v", err) + } + defer os.Remove(configFilePath) + + // Initialize Loader with the config file + loader := config.NewLoader(config.WithFile(configFilePath)) + + // Load the configuration + cfg := &Config{} + if err := loader.Load(cfg); err != nil { + t.Fatalf("Failed to load configuration: %v", err) + } + + // Test retrieving keys using Get + if loader.Get("server.port") != 8080 { + t.Errorf("Expected 'server.port' to be 8080, got %v", loader.Get("server.port")) + } + + if loader.Get("server.host") != "example.com" { + t.Errorf("Expected 'server.host' to be 'example.com', got %v", loader.Get("server.host")) + } + + if loader.Get("log_level") != "info" { + t.Errorf("Expected 'log_level' to be 'info', got %v", loader.Get("log_level")) + } + + // Test retrieving a key that doesn't exist + if loader.Get("nonexistent.key") != nil { + t.Errorf("Expected 'nonexistent.key' to be nil, got %v", loader.Get("nonexistent.key")) + } +} + +func TestLoader_SetDoesNotPersistToFile(t *testing.T) { + // Create a temporary configuration file + configFileContent := ` +server: + port: 8080 + host: "example.com" +log_level: "info" +` + configFilePath := "./test_config.yaml" + if err := os.WriteFile(configFilePath, []byte(configFileContent), 0644); err != nil { + t.Fatalf("Failed to create test config file: %v", err) + } + defer os.Remove(configFilePath) + + // Initialize Loader with the config file + loader := config.NewLoader(config.WithFile(configFilePath)) + + // Load the configuration + cfg := &Config{} + if err := loader.Load(cfg); err != nil { + t.Fatalf("Failed to load configuration: %v", err) + } + + // Set a new value for a key + loader.Set("server.port", 9090) + + // Verify the in-memory value has been updated + if loader.Get("server.port") != 9090 { + t.Errorf("Expected 'server.port' to be 9090 in memory, got %v", loader.Get("server.port")) + } + + // Reload the configuration from the file + newCfg := &Config{} + newLoader := config.NewLoader(config.WithFile(configFilePath)) + if err := newLoader.Load(newCfg); err != nil { + t.Fatalf("Failed to reload configuration: %v", err) + } + + // Verify the original file values are unchanged + if newLoader.Get("server.port") != 8080 { + t.Errorf("Expected 'server.port' to remain 8080 in file, got %v", newLoader.Get("server.port")) + } + if newLoader.Get("server.host") != "example.com" { + t.Errorf("Expected 'server.host' to remain 'example.com', got %v", newLoader.Get("server.host")) + } + if newLoader.Get("log_level") != "info" { + t.Errorf("Expected 'log_level' to remain 'info', got %v", newLoader.Get("log_level")) + } +} + +func TestLoader_SetAndSavePersistToFile(t *testing.T) { + // Create a temporary configuration file + initialConfig := ` +server: + port: 8080 + host: "example.com" +log_level: "info" +` + configFilePath := "./test_config.yaml" + if err := os.WriteFile(configFilePath, []byte(initialConfig), 0644); err != nil { + t.Fatalf("Failed to create test config file: %v", err) + } + defer os.Remove(configFilePath) + + // Initialize Loader with the config file + loader := config.NewLoader(config.WithFile(configFilePath)) + + // Load the configuration + cfg := &Config{} + if err := loader.Load(cfg); err != nil { + t.Fatalf("Failed to load configuration: %v", err) + } + + // Set new values for configuration keys + loader.Set("server.port", 9090) + loader.Set("server.host", "new-host.com") + loader.Set("log_level", "debug") + + // Save the updated configuration to the file + if err := loader.Save(); err != nil { + t.Fatalf("Failed to save configuration: %v", err) + } + + // Reload the configuration from the file into a new Loader + newLoader := config.NewLoader(config.WithFile(configFilePath)) + newCfg := &Config{} + if err := newLoader.Load(newCfg); err != nil { + t.Fatalf("Failed to reload configuration: %v", err) + } + + // Verify that the changes were persisted + if newLoader.Get("server.port") != 9090 { + t.Errorf("Expected 'server.port' to be 9090 in file, got %v", newLoader.Get("server.port")) + } + + if newLoader.Get("server.host") != "new-host.com" { + t.Errorf("Expected 'server.host' to be 'new-host.com' in file, got %v", newLoader.Get("server.host")) + } + + if newLoader.Get("log_level") != "debug" { + t.Errorf("Expected 'log_level' to be 'debug' in file, got %v", newLoader.Get("log_level")) + } +} diff --git a/config/helpers.go b/config/helpers.go new file mode 100644 index 0000000..402b74e --- /dev/null +++ b/config/helpers.go @@ -0,0 +1,111 @@ +package config + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "reflect" + "runtime" + + "github.com/jeremywohl/flatten" + "github.com/mitchellh/mapstructure" + "github.com/spf13/pflag" + "github.com/spf13/viper" +) + +// bindFlags dynamically binds flags to configuration fields based on `cmdx` tags. +func bindFlags(v *viper.Viper, flagSet *pflag.FlagSet, structType reflect.Type, parentKey string) error { + for i := 0; i < structType.NumField(); i++ { + field := structType.Field(i) + tag := field.Tag.Get("cmdx") + if tag == "" { + continue + } + + if parentKey != "" { + tag = parentKey + "." + tag + } + + if field.Type.Kind() == reflect.Struct { + // Recurse into nested structs + if err := bindFlags(v, flagSet, field.Type, tag); err != nil { + return err + } + } else { + flag := flagSet.Lookup(tag) + if flag == nil { + return fmt.Errorf("missing flag for tag: %s", tag) + } + if err := v.BindPFlag(tag, flag); err != nil { + return fmt.Errorf("failed to bind flag for tag: %s, error: %w", tag, err) + } + } + } + return nil +} + +// validateStructPtr ensures the provided value is a pointer to a struct. +func validateStructPtr(value interface{}) error { + val := reflect.ValueOf(value) + if val.Kind() != reflect.Ptr || val.Elem().Kind() != reflect.Struct { + return errors.New("load requires a pointer to a struct") + } + return nil +} + +// extractFlattenedKeys retrieves all keys from the struct in a flattened format. +func extractFlattenedKeys(config interface{}) ([]string, error) { + var structMap map[string]interface{} + if err := mapstructure.Decode(config, &structMap); err != nil { + return nil, err + } + flatMap, err := flatten.Flatten(structMap, "", flatten.DotStyle) + if err != nil { + return nil, err + } + keys := make([]string, 0, len(flatMap)) + for k := range flatMap { + keys = append(keys, k) + } + return keys, nil +} + +// Utilities for app-specific configuration paths +func getConfigFilePath(app string) (string, error) { + dirPath := getConfigDir("raystack") + if err := ensureDir(dirPath); err != nil { + return "", err + } + return filepath.Join(dirPath, app+".yml"), nil +} + +func getConfigDir(root string) string { + switch { + case envSet("RAYSTACK_CONFIG_DIR"): + return filepath.Join(os.Getenv("RAYSTACK_CONFIG_DIR"), root) + case envSet("XDG_CONFIG_HOME"): + return filepath.Join(os.Getenv("XDG_CONFIG_HOME"), root) + case runtime.GOOS == "windows" && envSet("APPDATA"): + return filepath.Join(os.Getenv("APPDATA"), root) + default: + home, _ := os.UserHomeDir() + return filepath.Join(home, ".config", root) + } +} + +func ensureDir(dir string) error { + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("failed to create directory: %w", err) + } + return nil +} + +func fileExists(filename string) bool { + _, err := os.Stat(filename) + return err == nil +} + +func envSet(key string) bool { + return os.Getenv(key) != "" +} diff --git a/server/spa/README.md b/server/spa/README.md deleted file mode 100644 index 7d8c131..0000000 --- a/server/spa/README.md +++ /dev/null @@ -1,96 +0,0 @@ -# SPA Server Package - -The `spa` package provides a simple HTTP handler to serve Single Page Applications (SPAs) from an embedded file system, with optional gzip compression. This is particularly useful for applications that need to serve static assets and handle client-side routing, where all paths should fall back to an `index.html` file. - -## Features - -- **Serve Embedded Static Files**: Serve files directly from an embedded filesystem (`embed.FS`), making deployments easier. -- **SPA Support with Client-Side Routing**: Automatically serves `index.html` when a requested file is not found, allowing for client-side routing. -- **Optional Gzip Compression**: Optionally compresses responses with gzip for clients that support it. - -## Installation - -Add the package to your Go project by running: - -```bash -go get github.com/raystack/spa -``` - -## Usage - -Here’s an example of using `spa` to serve a Single Page Application from an embedded file system. - -### Embed Your Static Files - -Embed your static files (like `index.html`, JavaScript, CSS, etc.) using Go’s `embed` package: - -```go -//go:embed all:build -var content embed.FS -``` - -### Setting Up the Server - -Use the `Handler` function to create an HTTP handler that serves your SPA with optional gzip compression. - -```go -package main - -import ( - "embed" - "log" - "net/http" - - "github.com/raystack/spa" -) - -//go:embed all:build -var content embed.FS - -func main() { - handler, err := spa.Handler(content, "build", "index.html", true) - if err != nil { - log.Fatalf("failed to initialize SPA handler: %v", err) - } - - http.Handle("/", handler) - log.Println("Starting server on :8080") - if err := http.ListenAndServe(":8080", nil); err != nil { - log.Fatalf("server failed: %v", err) - } -} -``` - -In this example: -- `content`: Embedded filesystem containing the build directory. -- `"build"`: The directory within the embedded filesystem where the static files are located. -- `"index.html"`: The fallback file to serve when a requested file isn’t found, typically used for client-side routing. -- `true`: Enables gzip compression for supported clients. - -## API Reference - -### `Handler` - -```go -func Handler(build embed.FS, dir string, index string, gzip bool) (http.Handler, error) -``` - -Creates an HTTP handler to serve an SPA with optional gzip compression. - -- **Parameters**: - - `build`: The embedded file system containing the static files. - - `dir`: The subdirectory within `build` where static files are located. - - `index`: The fallback file (usually "index.html") to serve when a requested file isn’t found. - - `gzip`: If `true`, responses will be compressed with gzip for clients that support it. - -- **Returns**: An `http.Handler` for serving the SPA, or an error if initialization fails. - -### `router` - -The `router` struct is an HTTP file system wrapper that prevents directory traversal and supports client-side routing by serving `index.html` for unknown paths. - -## Example Scenarios - -- **Deploying a Go-Based SPA**: Use `spa` to embed and serve your frontend from within your Go binary. -- **Supporting Client-Side Routing**: Serve a fallback `index.html` page for any route that doesn't match an existing file, supporting SPAs with dynamic URLs. -- **Optional Compression**: Enable gzip for production deployments to reduce bandwidth usage. diff --git a/server/spa/doc.go b/server/spa/doc.go new file mode 100644 index 0000000..47e1780 --- /dev/null +++ b/server/spa/doc.go @@ -0,0 +1,45 @@ +/* +Package spa provides a simple and efficient HTTP handler for serving +Single Page Applications (SPAs). + +The handler serves static files from an embedded file system and falls +back to serving an index file for client-side routing. Optionally, it +supports gzip compression for optimizing responses. + +Features: + - Serves static assets from an embedded file system. + - Fallback to an index file for client-side routing. + - Optional gzip compression for supported clients. + +Usage: + +To use this package, embed your SPA's build assets into your binary using +the `embed` package. Then, create an SPA handler using the `Handler` function +and register it with an HTTP server. + +Example: + + package main + + import ( + "embed" + "log" + "net/http" + + "yourmodule/spa" + ) + + //go:embed build/* + var build embed.FS + + func main() { + handler, err := spa.Handler(build, "build", "index.html", true) + if err != nil { + log.Fatalf("Failed to initialize SPA handler: %v", err) + } + + log.Println("Serving SPA on http://localhost:8080") + http.ListenAndServe(":8080", handler) + } +*/ +package spa diff --git a/server/spa/spa.go b/server/spa/handler.go similarity index 100% rename from server/spa/spa.go rename to server/spa/handler.go