From 4617630d9e23ac45b80842e7e2515a447555e008 Mon Sep 17 00:00:00 2001 From: Pulak Kanti Bhowmick Date: Fri, 8 Nov 2024 02:23:14 +0600 Subject: [PATCH 1/3] handle invalid command error (#766) Signed-off-by: Pulak Kanti Bhowmick --- cmd/cmd_utils.go | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/cmd/cmd_utils.go b/cmd/cmd_utils.go index 2477679de..2bdbdb8fb 100644 --- a/cmd/cmd_utils.go +++ b/cmd/cmd_utils.go @@ -167,6 +167,15 @@ func preCustomCommand( ) { var sb strings.Builder if len(args) != len(commandConfig.Arguments) { + if len(commandConfig.Arguments) == 0 { + u.LogError(schema.CliConfiguration{}, errors.New("invalid command")) + sb.WriteString("Available command(s):\n") + for i, c := range commandConfig.Commands { + sb.WriteString(fmt.Sprintf("%d. %s %s %s\n", i+1, parentCommand.Use, commandConfig.Name, c.Name)) + } + u.LogInfo(schema.CliConfiguration{}, sb.String()) + os.Exit(1) + } sb.WriteString(fmt.Sprintf("Command requires %d argument(s):\n", len(commandConfig.Arguments))) for i, arg := range commandConfig.Arguments { if arg.Name == "" { From 3342e8e0ad3ece209f7547e7dcb0d848f3d85d72 Mon Sep 17 00:00:00 2001 From: Pulak Kanti Bhowmick Date: Fri, 8 Nov 2024 19:41:50 +0600 Subject: [PATCH 2/3] Skip component if `metadata.enabled` is set to `false` (#756) * skip components if not enabled Signed-off-by: Pulak Kanti Bhowmick * by default make component is enabled Signed-off-by: Pulak Kanti Bhowmick * refactor code Signed-off-by: Pulak Kanti Bhowmick * use disable instead of enable to use default value Signed-off-by: Pulak Kanti Bhowmick * rename to metadata.enabled Signed-off-by: Pulak Kanti Bhowmick * make by default enabled value is true Signed-off-by: Pulak Kanti Bhowmick * fix bug Signed-off-by: Pulak Kanti Bhowmick * add doc Signed-off-by: Pulak Kanti Bhowmick * update doc Signed-off-by: Pulak Kanti Bhowmick * fix typo Signed-off-by: Pulak Kanti Bhowmick * fix indentation Signed-off-by: Pulak Kanti Bhowmick * Update website/docs/core-concepts/stacks/define-components.mdx Co-authored-by: Erik Osterman (CEO @ Cloud Posse) * Update website/docs/core-concepts/stacks/define-components.mdx Co-authored-by: Erik Osterman (CEO @ Cloud Posse) * Update website/docs/core-concepts/stacks/define-components.mdx Co-authored-by: Erik Osterman (CEO @ Cloud Posse) * Update website/docs/core-concepts/stacks/define-components.mdx Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * fix note Signed-off-by: Pulak Kanti Bhowmick --------- Signed-off-by: Pulak Kanti Bhowmick Co-authored-by: Erik Osterman (CEO @ Cloud Posse) Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- internal/exec/helmfile.go | 5 +++++ internal/exec/stack_utils.go | 12 +++++++--- internal/exec/terraform.go | 5 +++++ internal/exec/utils.go | 8 ++++--- pkg/schema/schema.go | 1 + pkg/spacelift/spacelift_stack_processor.go | 4 ++-- .../stacks/define-components.mdx | 22 +++++++++++++++++++ 7 files changed, 49 insertions(+), 8 deletions(-) diff --git a/internal/exec/helmfile.go b/internal/exec/helmfile.go index 4b2c8d303..dd422a380 100644 --- a/internal/exec/helmfile.go +++ b/internal/exec/helmfile.go @@ -63,6 +63,11 @@ func ExecuteHelmfile(info schema.ConfigAndStacksInfo) error { return errors.New("stack must be specified") } + if !info.ComponentIsEnabled { + u.LogInfo(cliConfig, fmt.Sprintf("component '%s' is not enabled and skipped", info.ComponentFromArg)) + return nil + } + err = checkHelmfileConfig(cliConfig) if err != nil { return err diff --git a/internal/exec/stack_utils.go b/internal/exec/stack_utils.go index eeba73308..573be3573 100644 --- a/internal/exec/stack_utils.go +++ b/internal/exec/stack_utils.go @@ -54,13 +54,14 @@ func BuildTerraformWorkspace(cliConfig schema.CliConfiguration, configAndStacksI return strings.Replace(workspace, "/", "-", -1), nil } -// ProcessComponentMetadata processes component metadata and returns a base component (if any) and whether the component is real or abstract +// ProcessComponentMetadata processes component metadata and returns a base component (if any) and whether the component is real or abstract and whether the component is disabled or not func ProcessComponentMetadata( component string, componentSection map[string]any, -) (map[string]any, string, bool) { +) (map[string]any, string, bool, bool) { baseComponentName := "" componentIsAbstract := false + componentIsEnabled := true var componentMetadata map[string]any // Find base component in the `component` attribute @@ -75,6 +76,11 @@ func ProcessComponentMetadata( componentIsAbstract = true } } + if enabledValue, exists := componentMetadata["enabled"]; exists { + if enabled, ok := enabledValue.(bool); ok && !enabled { + componentIsEnabled = false + } + } // Find base component in the `metadata.component` attribute // `metadata.component` overrides `component` if componentMetadataComponent, componentMetadataComponentExists := componentMetadata[cfg.ComponentSectionName].(string); componentMetadataComponentExists { @@ -87,7 +93,7 @@ func ProcessComponentMetadata( baseComponentName = "" } - return componentMetadata, baseComponentName, componentIsAbstract + return componentMetadata, baseComponentName, componentIsAbstract, componentIsEnabled } // BuildDependentStackNameFromDependsOnLegacy builds the dependent stack name from "settings.spacelift.depends_on" config diff --git a/internal/exec/terraform.go b/internal/exec/terraform.go index 7b3153fd4..cffe26734 100644 --- a/internal/exec/terraform.go +++ b/internal/exec/terraform.go @@ -70,6 +70,11 @@ func ExecuteTerraform(info schema.ConfigAndStacksInfo) error { return errors.New("stack must be specified") } + if !info.ComponentIsEnabled { + u.LogInfo(cliConfig, fmt.Sprintf("component '%s' is not enabled and skipped", info.ComponentFromArg)) + return nil + } + err = checkTerraformConfig(cliConfig) if err != nil { return err diff --git a/internal/exec/utils.go b/internal/exec/utils.go index 8eea02068..b2ac1342f 100644 --- a/internal/exec/utils.go +++ b/internal/exec/utils.go @@ -129,7 +129,8 @@ func ProcessComponentConfig( } // Process component metadata and find a base component (if any) and whether the component is real or abstract - componentMetadata, baseComponentName, componentIsAbstract := ProcessComponentMetadata(component, componentSection) + componentMetadata, baseComponentName, componentIsAbstract, componentIsEnabled := ProcessComponentMetadata(component, componentSection) + configAndStacksInfo.ComponentIsEnabled = componentIsEnabled // Remove the ENV vars that are set to `null` in the `env` section. // Setting an ENV var to `null` in stack config has the effect of unsetting it @@ -391,7 +392,7 @@ func ProcessStacks( } } - if foundStackCount == 0 { + if foundStackCount == 0 && configAndStacksInfo.ComponentIsEnabled { cliConfigYaml := "" if cliConfig.Logs.Level == u.LogLevelTrace { @@ -573,8 +574,9 @@ func ProcessStacks( configAndStacksInfo.ComponentEnvList = u.ConvertEnvVars(configAndStacksInfo.ComponentEnvSection) // Process component metadata - _, baseComponentName, _ := ProcessComponentMetadata(configAndStacksInfo.ComponentFromArg, configAndStacksInfo.ComponentSection) + _, baseComponentName, _, componentIsEnabled := ProcessComponentMetadata(configAndStacksInfo.ComponentFromArg, configAndStacksInfo.ComponentSection) configAndStacksInfo.BaseComponentPath = baseComponentName + configAndStacksInfo.ComponentIsEnabled = componentIsEnabled // Process component path and name configAndStacksInfo.ComponentFolderPrefix = "" diff --git a/pkg/schema/schema.go b/pkg/schema/schema.go index 89e90b061..41b677970 100644 --- a/pkg/schema/schema.go +++ b/pkg/schema/schema.go @@ -196,6 +196,7 @@ type ConfigAndStacksInfo struct { ComponentImportsSection []string NeedHelp bool ComponentIsAbstract bool + ComponentIsEnabled bool ComponentMetadataSection AtmosSectionMapType TerraformWorkspace string JsonSchemaDir string diff --git a/pkg/spacelift/spacelift_stack_processor.go b/pkg/spacelift/spacelift_stack_processor.go index 0401298a2..353b0166b 100644 --- a/pkg/spacelift/spacelift_stack_processor.go +++ b/pkg/spacelift/spacelift_stack_processor.go @@ -172,9 +172,9 @@ func TransformStackConfigToSpaceliftStacks( } // Process component metadata and find a base component (if any) and whether the component is real or abstract - componentMetadata, baseComponentName, componentIsAbstract := e.ProcessComponentMetadata(component, componentMap) + componentMetadata, baseComponentName, componentIsAbstract, componentIsEnabled := e.ProcessComponentMetadata(component, componentMap) - if componentIsAbstract { + if componentIsAbstract || !componentIsEnabled { continue } diff --git a/website/docs/core-concepts/stacks/define-components.mdx b/website/docs/core-concepts/stacks/define-components.mdx index e7406c9eb..9ec3c0389 100644 --- a/website/docs/core-concepts/stacks/define-components.mdx +++ b/website/docs/core-concepts/stacks/define-components.mdx @@ -212,3 +212,25 @@ There are two types of components:
`abstract`
An `abstract` component is more like a blueprint. It can’t be deployed on its own. Instead, it’s a base configuration that needs to be extended or inherited by other components. This is similar to an ["abstract base classes"](https://en.wikipedia.org/wiki/Abstract_type) in programming—it defines reusable configurations, but it’s not complete enough to be deployed directly.
+ +### Disabling Components with `metadata.enabled` + +The `metadata.enabled` parameter controls whether a component is included in deployment. By default, components are enabled. Setting `metadata.enabled` to `false` skips the component entirely—no workspace is created, and no Terraform commands are executed. Disabling a component does not cause deletion. It just signals that it's no longer managed by Atmos. + +:::info Note +This should not be confused with [Cloud Posse's conventions and best practices](/best-practices/terraform/) of having modules and components define a [Terraform input named `enabled`](/best-practices/terraform/#use-feature-flags-list-or-map-inputs-for-optional-functionality). This is a general convention and `vars.enabled` is not a special variable. Atmos does not treat it differently from any other variable. +::: + +**Example**: +```yaml +# Disable a component in a specific environment +components: + terraform: + vpc: + metadata: + type: real + enabled: false + vars: + name: primary-vpc +``` +Using the `metadata.enabled` flag makes it easy to ensure that only the intended components are active in each environment. From ffb64eb4bd03b7637dbde21ecc0d85ecb9005699 Mon Sep 17 00:00:00 2001 From: "Vinicius C." Date: Sun, 10 Nov 2024 19:57:43 +0000 Subject: [PATCH 3/3] Wrapper for long lines in help (#770) * main terminal wrapper for long lines * preserving the original length writer * set widths to be dynamic * chore: improve error handling * improvements * clean up flags * general fixes and styles for terminal width feature * align columns * dont make columns equal width * clean code and column fixes help printer --- cmd/root.go | 7 ++ go.mod | 6 +- go.sum | 4 + internal/tui/templates/help_printer.go | 122 +++++++++++++++++++++ internal/tui/templates/templater.go | 73 ++++++++++++ internal/tui/templates/term/term_writer.go | 79 +++++++++++++ 6 files changed, 288 insertions(+), 3 deletions(-) create mode 100644 internal/tui/templates/help_printer.go create mode 100644 internal/tui/templates/templater.go create mode 100644 internal/tui/templates/term/term_writer.go diff --git a/cmd/root.go b/cmd/root.go index 22c383571..d02cacf5d 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -11,6 +11,7 @@ import ( "github.com/spf13/pflag" e "github.com/cloudposse/atmos/internal/exec" + "github.com/cloudposse/atmos/internal/tui/templates" tuiUtils "github.com/cloudposse/atmos/internal/tui/utils" cfg "github.com/cloudposse/atmos/pkg/config" "github.com/cloudposse/atmos/pkg/schema" @@ -107,12 +108,17 @@ func Execute() error { } func init() { + // Add template function for wrapped flag usages + cobra.AddTemplateFunc("wrappedFlagUsages", templates.WrappedFlagUsages) + RootCmd.PersistentFlags().String("redirect-stderr", "", "File descriptor to redirect 'stderr' to. "+ "Errors can be redirected to any file or any standard file descriptor (including '/dev/null'): atmos --redirect-stderr /dev/stdout") RootCmd.PersistentFlags().String("logs-level", "Info", "Logs level. Supported log levels are Trace, Debug, Info, Warning, Off. If the log level is set to Off, Atmos will not log any messages") RootCmd.PersistentFlags().String("logs-file", "/dev/stdout", "The file to write Atmos logs to. Logs can be written to any file or any standard file descriptor, including '/dev/stdout', '/dev/stderr' and '/dev/null'") + // Set custom usage template + templates.SetCustomUsageFunc(RootCmd) cobra.OnInitialize(initConfig) } @@ -131,6 +137,7 @@ func initConfig() { } b.HelpFunc(command, strings) + command.Usage() }) } diff --git a/go.mod b/go.mod index 445b0e8a0..a0ba87eee 100644 --- a/go.mod +++ b/go.mod @@ -30,6 +30,7 @@ require ( github.com/kubescape/go-git-url v0.0.30 github.com/lrstanley/bubblezone v0.0.0-20240914071701-b48c55a5e78e github.com/mitchellh/go-homedir v1.1.0 + github.com/mitchellh/go-wordwrap v1.0.0 github.com/mitchellh/mapstructure v1.5.0 github.com/open-policy-agent/opa v0.70.0 github.com/otiai10/copy v1.14.0 @@ -41,6 +42,7 @@ require ( github.com/spf13/viper v1.19.0 github.com/stretchr/testify v1.9.0 github.com/zclconf/go-cty v1.15.0 + golang.org/x/term v0.26.0 gopkg.in/yaml.v3 v3.0.1 mvdan.cc/sh/v3 v3.10.0 ) @@ -174,7 +176,6 @@ require ( github.com/microcosm-cc/bluemonday v1.0.27 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/go-testing-interface v1.14.1 // indirect - github.com/mitchellh/go-wordwrap v1.0.0 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/moby/locker v1.0.1 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect @@ -241,8 +242,7 @@ require ( golang.org/x/net v0.30.0 // indirect golang.org/x/oauth2 v0.22.0 // indirect golang.org/x/sync v0.8.0 // indirect - golang.org/x/sys v0.26.0 // indirect - golang.org/x/term v0.25.0 // indirect + golang.org/x/sys v0.27.0 // indirect golang.org/x/text v0.19.0 // indirect golang.org/x/time v0.7.0 // indirect golang.org/x/tools v0.22.0 // indirect diff --git a/go.sum b/go.sum index cc52dfecc..79a99a901 100644 --- a/go.sum +++ b/go.sum @@ -1533,6 +1533,8 @@ golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s= +golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= @@ -1540,6 +1542,8 @@ golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuX golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.25.0 h1:WtHI/ltw4NvSUig5KARz9h521QvRC8RmF/cuYqifU24= golang.org/x/term v0.25.0/go.mod h1:RPyXicDX+6vLxogjjRxjgD2TKtmAO6NZBsBRfrOLu7M= +golang.org/x/term v0.26.0 h1:WEQa6V3Gja/BhNxg540hBip/kkaYtRg3cxg4oXSw4AU= +golang.org/x/term v0.26.0/go.mod h1:Si5m1o57C5nBNQo5z1iq+XDijt21BDBDp2bK0QI8e3E= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= diff --git a/internal/tui/templates/help_printer.go b/internal/tui/templates/help_printer.go new file mode 100644 index 000000000..4c78e0de8 --- /dev/null +++ b/internal/tui/templates/help_printer.go @@ -0,0 +1,122 @@ +package templates + +import ( + "fmt" + "io" + "strings" + + "github.com/mitchellh/go-wordwrap" + "github.com/spf13/pflag" +) + +const ( + defaultOffset = 10 + minWidth = 80 + flagIndent = " " + nameIndentWidth = 4 + minDescWidth = 20 +) + +type HelpFlagPrinter struct { + wrapLimit uint + out io.Writer + maxFlagLen int +} + +func NewHelpFlagPrinter(out io.Writer, wrapLimit uint, flags *pflag.FlagSet) *HelpFlagPrinter { + if out == nil { + panic("output writer cannot be nil") + } + if flags == nil { + panic("flag set cannot be nil") + } + if wrapLimit < minWidth { + wrapLimit = minWidth + } + + return &HelpFlagPrinter{ + wrapLimit: wrapLimit, + out: out, + maxFlagLen: calculateMaxFlagLength(flags), + } +} + +func calculateMaxFlagLength(flags *pflag.FlagSet) int { + maxLen := 0 + flags.VisitAll(func(flag *pflag.Flag) { + length := len(flagIndent) + + if len(flag.Shorthand) > 0 { + if flag.Value.Type() != "bool" { + length += len(fmt.Sprintf("-%s, --%s %s", flag.Shorthand, flag.Name, flag.Value.Type())) + } else { + length += len(fmt.Sprintf("-%s, --%s", flag.Shorthand, flag.Name)) + } + } else { + if flag.Value.Type() != "bool" { + length += len(fmt.Sprintf(" --%s %s", flag.Name, flag.Value.Type())) + } else { + length += len(fmt.Sprintf(" --%s", flag.Name)) + } + } + + if length > maxLen { + maxLen = length + } + }) + return maxLen +} + +func (p *HelpFlagPrinter) PrintHelpFlag(flag *pflag.Flag) { + nameIndent := nameIndentWidth + + flagName := "" + if flag.Shorthand != "" { + if flag.Value.Type() != "bool" { + flagName = fmt.Sprintf("%s-%s, --%s %s", strings.Repeat(" ", nameIndent), + flag.Shorthand, flag.Name, flag.Value.Type()) + } else { + flagName = fmt.Sprintf("%s-%s, --%s", strings.Repeat(" ", nameIndent), + flag.Shorthand, flag.Name) + } + } else { + if flag.Value.Type() != "bool" { + flagName = fmt.Sprintf("%s --%s %s", strings.Repeat(" ", nameIndent), + flag.Name, flag.Value.Type()) + } else { + flagName = fmt.Sprintf("%s --%s", strings.Repeat(" ", nameIndent), + flag.Name) + } + } + + flagSection := fmt.Sprintf("%-*s", p.maxFlagLen, flagName) + descIndent := p.maxFlagLen + 4 + + description := flag.Usage + if flag.DefValue != "" { + description = fmt.Sprintf("%s (default %q)", description, flag.DefValue) + } + + descWidth := int(p.wrapLimit) - descIndent + if descWidth < minDescWidth { + descWidth = minDescWidth + } + + wrapped := wordwrap.WrapString(description, uint(descWidth)) + lines := strings.Split(wrapped, "\n") + + if _, err := fmt.Fprintf(p.out, "%-*s%s\n", descIndent, flagSection, lines[0]); err != nil { + return + } + + // Print remaining lines with proper indentation + for _, line := range lines[1:] { + if _, err := fmt.Fprintf(p.out, "%s%s\n", strings.Repeat(" ", descIndent), line); err != nil { + return + } + } + + if _, err := fmt.Fprintln(p.out); err != nil { + return + } +} diff --git a/internal/tui/templates/templater.go b/internal/tui/templates/templater.go new file mode 100644 index 000000000..a9f3c2bea --- /dev/null +++ b/internal/tui/templates/templater.go @@ -0,0 +1,73 @@ +package templates + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +// Templater handles the generation and management of command usage templates. +type Templater struct { + UsageTemplate string +} + +// SetCustomUsageFunc configures a custom usage template for the provided cobra command. +// It returns an error if the command is nil. +func SetCustomUsageFunc(cmd *cobra.Command) error { + if cmd == nil { + return fmt.Errorf("command cannot be nil") + } + t := &Templater{ + UsageTemplate: MainUsageTemplate(), + } + + cmd.SetUsageTemplate(t.UsageTemplate) + return nil +} + +// MainUsageTemplate returns the usage template for the root command and wrap cobra flag usages to the terminal width +func MainUsageTemplate() string { + return `Usage:{{if .Runnable}} + {{.UseLine}}{{end}}{{if .HasAvailableSubCommands}} + {{.CommandPath}} [command]{{end}}{{if gt (len .Aliases) 0}} + +Aliases: + {{.NameAndAliases}}{{end}}{{if .HasExample}} + +Examples: +{{.Example}}{{end}}{{if .HasAvailableSubCommands}} + +Available Commands:{{range .Commands}}{{if (or .IsAvailableCommand (eq .Name "help"))}} + {{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{if .HasAvailableLocalFlags}} + +Flags: +{{wrappedFlagUsages .LocalFlags | trimTrailingWhitespaces}}{{end}}{{if .HasAvailableInheritedFlags}} + +Global Flags: +{{wrappedFlagUsages .InheritedFlags | trimTrailingWhitespaces}}{{end}}{{if .HasHelpSubCommands}} + +Additional help topics:{{range .Commands}}{{if .IsAdditionalHelpTopicCommand}} + {{rpad .CommandPath .CommandPathPadding}} {{.Short}}{{end}}{{end}}{{end}}{{if .HasAvailableSubCommands}} + +Use "{{.CommandPath}} [command] --help" for more information about a command.{{end}} +` +} + +// Default terminal width if actual width cannot be determined +const maxWidth = 80 + +// WrappedFlagUsages formats the flag usage string to fit within the terminal width +func WrappedFlagUsages(f *pflag.FlagSet) string { + var builder strings.Builder + printer := NewHelpFlagPrinter(&builder, maxWidth, f) + + printer.maxFlagLen = calculateMaxFlagLength(f) + + f.VisitAll(func(flag *pflag.Flag) { + printer.PrintHelpFlag(flag) + }) + + return builder.String() +} diff --git a/internal/tui/templates/term/term_writer.go b/internal/tui/templates/term/term_writer.go new file mode 100644 index 000000000..2af038112 --- /dev/null +++ b/internal/tui/templates/term/term_writer.go @@ -0,0 +1,79 @@ +package term + +import ( + "io" + "os" + + "github.com/mitchellh/go-wordwrap" + "golang.org/x/term" +) + +// TerminalWriter wraps an io.Writer and provides automatic line wrapping based on terminal width +// It ensures that output text is formatted to fit within the terminal's dimensions. +type TerminalWriter struct { + width uint + writer io.Writer +} + +const ( + maxWidth = 120 + mediumWidth = 100 + minWidth = 80 +) + +// NewResponsiveWriter creates a terminal-aware writer that automatically wraps text +// based on the terminal width. If the provided writer is not a terminal or if width +// detection fails, it will return the original writer unchanged. +func NewResponsiveWriter(w io.Writer) io.Writer { + file, ok := w.(*os.File) + if !ok { + return w + } + + if !term.IsTerminal(int(file.Fd())) { + return w + } + + width, _, err := term.GetSize(int(file.Fd())) + if err != nil { + return w + } + + // Use optimal width based on terminal size + var limit uint + switch { + case width >= maxWidth: + limit = maxWidth + case width >= mediumWidth: + limit = mediumWidth + case width >= minWidth: + limit = minWidth + default: + limit = uint(width) + } + + return &TerminalWriter{ + width: limit, + writer: w, + } +} + +func (w *TerminalWriter) Write(p []byte) (int, error) { + if w.width == 0 { + return w.writer.Write(p) + } + + // Preserving the original length for correct return value + originalLen := len(p) + wrapped := wordwrap.WrapString(string(p), w.width) + n, err := w.writer.Write([]byte(wrapped)) + if err != nil { + return n, err + } + // return the original length as per io.Writer contract + return originalLen, nil +} + +func (w *TerminalWriter) GetWidth() uint { + return w.width +}