diff --git a/pkg/cli/environment.go b/pkg/cli/environment.go index 3f2dc00b2..113eef243 100644 --- a/pkg/cli/environment.go +++ b/pkg/cli/environment.go @@ -89,6 +89,8 @@ type EnvSettings struct { BurstLimit int // QPS is queries per second which may be used to avoid throttling. QPS float32 + // NoColor disables colorized output + NoColor bool } func New() *EnvSettings { @@ -109,6 +111,7 @@ func New() *EnvSettings { RepositoryCache: envOr("HELM_REPOSITORY_CACHE", helmpath.CachePath("repository")), BurstLimit: envIntOr("HELM_BURST_LIMIT", defaultBurstLimit), QPS: envFloat32Or("HELM_QPS", defaultQPS), + NoColor: envBoolOr("NO_COLOR", false), } env.Debug, _ = strconv.ParseBool(os.Getenv("HELM_DEBUG")) @@ -160,6 +163,7 @@ func (s *EnvSettings) AddFlags(fs *pflag.FlagSet) { fs.StringVar(&s.RepositoryCache, "repository-cache", s.RepositoryCache, "path to the directory containing cached repository indexes") fs.IntVar(&s.BurstLimit, "burst-limit", s.BurstLimit, "client-side default throttling limit") fs.Float32Var(&s.QPS, "qps", s.QPS, "queries per second used when communicating with the Kubernetes API, not including bursting") + fs.BoolVar(&s.NoColor, "no-color", s.NoColor, "disable colorized output") } func envOr(name, def string) string { diff --git a/pkg/cli/output/color.go b/pkg/cli/output/color.go new file mode 100644 index 000000000..9d20f770d --- /dev/null +++ b/pkg/cli/output/color.go @@ -0,0 +1,70 @@ +/* +Copyright The Helm Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package output + +import ( + "os" + + "github.com/fatih/color" + "golang.org/x/term" + + release "helm.sh/helm/v4/pkg/release/v1" +) + +// ColorizeStatus returns a colorized version of the status string based on the status value +func ColorizeStatus(status release.Status, noColor bool) string { + // Disable color if requested or if not in a terminal + if noColor || os.Getenv("NO_COLOR") != "" || !term.IsTerminal(int(os.Stdout.Fd())) { + return status.String() + } + + switch status { + case release.StatusDeployed: + return color.GreenString(status.String()) + case release.StatusFailed: + return color.RedString(status.String()) + case release.StatusPendingInstall, release.StatusPendingUpgrade, release.StatusPendingRollback, release.StatusUninstalling: + return color.YellowString(status.String()) + case release.StatusUnknown: + return color.RedString(status.String()) + default: + // For uninstalled, superseded, and any other status + return status.String() + } +} + +// ColorizeHeader returns a colorized version of a header string +func ColorizeHeader(header string, noColor bool) string { + // Disable color if requested or if not in a terminal + if noColor || os.Getenv("NO_COLOR") != "" || !term.IsTerminal(int(os.Stdout.Fd())) { + return header + } + + // Use bold for headers + return color.New(color.Bold).Sprint(header) +} + +// ColorizeNamespace returns a colorized version of a namespace string +func ColorizeNamespace(namespace string, noColor bool) string { + // Disable color if requested or if not in a terminal + if noColor || os.Getenv("NO_COLOR") != "" || !term.IsTerminal(int(os.Stdout.Fd())) { + return namespace + } + + // Use cyan for namespaces + return color.CyanString(namespace) +} diff --git a/pkg/cli/output/color_test.go b/pkg/cli/output/color_test.go new file mode 100644 index 000000000..7e8ddddf0 --- /dev/null +++ b/pkg/cli/output/color_test.go @@ -0,0 +1,219 @@ +/* +Copyright The Helm Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package output + +import ( + "os" + "strings" + "testing" + + release "helm.sh/helm/v4/pkg/release/v1" +) + +func TestColorizeStatus(t *testing.T) { + // Save original NO_COLOR env var + originalNoColor := os.Getenv("NO_COLOR") + defer func() { + if err := os.Setenv("NO_COLOR", originalNoColor); err != nil { + t.Errorf("Failed to restore NO_COLOR env var: %v", err) + } + }() + + tests := []struct { + name string + status release.Status + noColor bool + envNoColor string + wantColor bool // whether we expect color codes in output + }{ + { + name: "deployed status with color", + status: release.StatusDeployed, + noColor: false, + envNoColor: "", + wantColor: true, + }, + { + name: "deployed status without color flag", + status: release.StatusDeployed, + noColor: true, + envNoColor: "", + wantColor: false, + }, + { + name: "deployed status with NO_COLOR env", + status: release.StatusDeployed, + noColor: false, + envNoColor: "1", + wantColor: false, + }, + { + name: "failed status with color", + status: release.StatusFailed, + noColor: false, + envNoColor: "", + wantColor: true, + }, + { + name: "pending install status with color", + status: release.StatusPendingInstall, + noColor: false, + envNoColor: "", + wantColor: true, + }, + { + name: "unknown status with color", + status: release.StatusUnknown, + noColor: false, + envNoColor: "", + wantColor: true, + }, + { + name: "superseded status with color", + status: release.StatusSuperseded, + noColor: false, + envNoColor: "", + wantColor: false, // superseded doesn't get colored + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := os.Setenv("NO_COLOR", tt.envNoColor); err != nil { + t.Fatalf("Failed to set NO_COLOR env var: %v", err) + } + + result := ColorizeStatus(tt.status, tt.noColor) + + // Check if result contains ANSI escape codes + hasColor := strings.Contains(result, "\033[") + + // In test environment, term.IsTerminal will be false, so we won't get color + // unless we're testing the logic without terminal detection + if hasColor && !tt.wantColor { + t.Errorf("ColorizeStatus() returned color when none expected: %q", result) + } + + // Always check the status text is present + if !strings.Contains(result, tt.status.String()) { + t.Errorf("ColorizeStatus() = %q, want to contain %q", result, tt.status.String()) + } + }) + } +} + +func TestColorizeHeader(t *testing.T) { + // Save original NO_COLOR env var + originalNoColor := os.Getenv("NO_COLOR") + defer func() { + if err := os.Setenv("NO_COLOR", originalNoColor); err != nil { + t.Errorf("Failed to restore NO_COLOR env var: %v", err) + } + }() + + tests := []struct { + name string + header string + noColor bool + envNoColor string + }{ + { + name: "header with color", + header: "NAME", + noColor: false, + envNoColor: "", + }, + { + name: "header without color flag", + header: "NAME", + noColor: true, + envNoColor: "", + }, + { + name: "header with NO_COLOR env", + header: "NAME", + noColor: false, + envNoColor: "1", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := os.Setenv("NO_COLOR", tt.envNoColor); err != nil { + t.Fatalf("Failed to set NO_COLOR env var: %v", err) + } + + result := ColorizeHeader(tt.header, tt.noColor) + + // Always check the header text is present + if !strings.Contains(result, tt.header) { + t.Errorf("ColorizeHeader() = %q, want to contain %q", result, tt.header) + } + }) + } +} + +func TestColorizeNamespace(t *testing.T) { + // Save original NO_COLOR env var + originalNoColor := os.Getenv("NO_COLOR") + defer func() { + if err := os.Setenv("NO_COLOR", originalNoColor); err != nil { + t.Errorf("Failed to restore NO_COLOR env var: %v", err) + } + }() + + tests := []struct { + name string + namespace string + noColor bool + envNoColor string + }{ + { + name: "namespace with color", + namespace: "default", + noColor: false, + envNoColor: "", + }, + { + name: "namespace without color flag", + namespace: "default", + noColor: true, + envNoColor: "", + }, + { + name: "namespace with NO_COLOR env", + namespace: "default", + noColor: false, + envNoColor: "1", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := os.Setenv("NO_COLOR", tt.envNoColor); err != nil { + t.Fatalf("Failed to set NO_COLOR env var: %v", err) + } + + result := ColorizeNamespace(tt.namespace, tt.noColor) + + // Always check the namespace text is present + if !strings.Contains(result, tt.namespace) { + t.Errorf("ColorizeNamespace() = %q, want to contain %q", result, tt.namespace) + } + }) + } +} diff --git a/pkg/cmd/get_all.go b/pkg/cmd/get_all.go index aee92df51..9ada32318 100644 --- a/pkg/cmd/get_all.go +++ b/pkg/cmd/get_all.go @@ -63,6 +63,7 @@ func newGetAllCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { debug: true, showMetadata: true, hideNotes: false, + noColor: settings.NoColor, }) }, } diff --git a/pkg/cmd/install.go b/pkg/cmd/install.go index 3496a4bbd..78f62aa2e 100644 --- a/pkg/cmd/install.go +++ b/pkg/cmd/install.go @@ -168,6 +168,7 @@ func newInstallCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { debug: settings.Debug, showMetadata: false, hideNotes: client.HideNotes, + noColor: settings.NoColor, }) }, } @@ -237,13 +238,13 @@ func runInstall(args []string, client *action.Install, valueOpts *values.Options client.Version = ">0.0.0-0" } - name, chart, err := client.NameAndChart(args) + name, chartRef, err := client.NameAndChart(args) if err != nil { return nil, err } client.ReleaseName = name - cp, err := client.LocateChart(chart, settings) + cp, err := client.LocateChart(chartRef, settings) if err != nil { return nil, err } diff --git a/pkg/cmd/list.go b/pkg/cmd/list.go index 5af43adad..a1f31459f 100644 --- a/pkg/cmd/list.go +++ b/pkg/cmd/list.go @@ -106,7 +106,7 @@ func newListCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { } } - return outfmt.Write(out, newReleaseListWriter(results, client.TimeFormat, client.NoHeaders)) + return outfmt.Write(out, newReleaseListWriter(results, client.TimeFormat, client.NoHeaders, settings.NoColor)) }, } @@ -146,9 +146,10 @@ type releaseElement struct { type releaseListWriter struct { releases []releaseElement noHeaders bool + noColor bool } -func newReleaseListWriter(releases []*release.Release, timeFormat string, noHeaders bool) *releaseListWriter { +func newReleaseListWriter(releases []*release.Release, timeFormat string, noHeaders bool, noColor bool) *releaseListWriter { // Initialize the array so no results returns an empty array instead of null elements := make([]releaseElement, 0, len(releases)) for _, r := range releases { @@ -173,26 +174,58 @@ func newReleaseListWriter(releases []*release.Release, timeFormat string, noHead elements = append(elements, element) } - return &releaseListWriter{elements, noHeaders} + return &releaseListWriter{elements, noHeaders, noColor} } -func (r *releaseListWriter) WriteTable(out io.Writer) error { +func (w *releaseListWriter) WriteTable(out io.Writer) error { table := uitable.New() - if !r.noHeaders { - table.AddRow("NAME", "NAMESPACE", "REVISION", "UPDATED", "STATUS", "CHART", "APP VERSION") + if !w.noHeaders { + table.AddRow( + output.ColorizeHeader("NAME", w.noColor), + output.ColorizeHeader("NAMESPACE", w.noColor), + output.ColorizeHeader("REVISION", w.noColor), + output.ColorizeHeader("UPDATED", w.noColor), + output.ColorizeHeader("STATUS", w.noColor), + output.ColorizeHeader("CHART", w.noColor), + output.ColorizeHeader("APP VERSION", w.noColor), + ) } - for _, r := range r.releases { - table.AddRow(r.Name, r.Namespace, r.Revision, r.Updated, r.Status, r.Chart, r.AppVersion) + for _, r := range w.releases { + // Parse the status string back to a release.Status to use color + var status release.Status + switch r.Status { + case "deployed": + status = release.StatusDeployed + case "failed": + status = release.StatusFailed + case "pending-install": + status = release.StatusPendingInstall + case "pending-upgrade": + status = release.StatusPendingUpgrade + case "pending-rollback": + status = release.StatusPendingRollback + case "uninstalling": + status = release.StatusUninstalling + case "uninstalled": + status = release.StatusUninstalled + case "superseded": + status = release.StatusSuperseded + case "unknown": + status = release.StatusUnknown + default: + status = release.Status(r.Status) + } + table.AddRow(r.Name, output.ColorizeNamespace(r.Namespace, w.noColor), r.Revision, r.Updated, output.ColorizeStatus(status, w.noColor), r.Chart, r.AppVersion) } return output.EncodeTable(out, table) } -func (r *releaseListWriter) WriteJSON(out io.Writer) error { - return output.EncodeJSON(out, r.releases) +func (w *releaseListWriter) WriteJSON(out io.Writer) error { + return output.EncodeJSON(out, w.releases) } -func (r *releaseListWriter) WriteYAML(out io.Writer) error { - return output.EncodeYAML(out, r.releases) +func (w *releaseListWriter) WriteYAML(out io.Writer) error { + return output.EncodeYAML(out, w.releases) } // Returns all releases from 'releases', except those with names matching 'ignoredReleases' diff --git a/pkg/cmd/release_testing.go b/pkg/cmd/release_testing.go index 1dac28534..e43c58145 100644 --- a/pkg/cmd/release_testing.go +++ b/pkg/cmd/release_testing.go @@ -78,6 +78,7 @@ func newReleaseTestCmd(cfg *action.Configuration, out io.Writer) *cobra.Command debug: settings.Debug, showMetadata: false, hideNotes: client.HideNotes, + noColor: settings.NoColor, }); err != nil { return err } diff --git a/pkg/cmd/status.go b/pkg/cmd/status.go index 2b1138786..c2960f823 100644 --- a/pkg/cmd/status.go +++ b/pkg/cmd/status.go @@ -84,6 +84,7 @@ func newStatusCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { debug: false, showMetadata: false, hideNotes: false, + noColor: settings.NoColor, }) }, } @@ -112,6 +113,7 @@ type statusPrinter struct { debug bool showMetadata bool hideNotes bool + noColor bool } func (s statusPrinter) WriteJSON(out io.Writer) error { @@ -130,8 +132,8 @@ func (s statusPrinter) WriteTable(out io.Writer) error { if !s.release.Info.LastDeployed.IsZero() { _, _ = fmt.Fprintf(out, "LAST DEPLOYED: %s\n", s.release.Info.LastDeployed.Format(time.ANSIC)) } - _, _ = fmt.Fprintf(out, "NAMESPACE: %s\n", s.release.Namespace) - _, _ = fmt.Fprintf(out, "STATUS: %s\n", s.release.Info.Status.String()) + _, _ = fmt.Fprintf(out, "NAMESPACE: %s\n", output.ColorizeNamespace(s.release.Namespace, s.noColor)) + _, _ = fmt.Fprintf(out, "STATUS: %s\n", output.ColorizeStatus(s.release.Info.Status, s.noColor)) _, _ = fmt.Fprintf(out, "REVISION: %d\n", s.release.Version) if s.showMetadata { _, _ = fmt.Fprintf(out, "CHART: %s\n", s.release.Chart.Metadata.Name) @@ -218,7 +220,7 @@ func (s statusPrinter) WriteTable(out io.Writer) error { // Hide notes from output - option in install and upgrades if !s.hideNotes && len(s.release.Info.Notes) > 0 { - fmt.Fprintf(out, "NOTES:\n%s\n", strings.TrimSpace(s.release.Info.Notes)) + _, _ = fmt.Fprintf(out, "NOTES:\n%s\n", strings.TrimSpace(s.release.Info.Notes)) } return nil } diff --git a/pkg/cmd/upgrade.go b/pkg/cmd/upgrade.go index d4e7b4852..32d4f230b 100644 --- a/pkg/cmd/upgrade.go +++ b/pkg/cmd/upgrade.go @@ -166,6 +166,7 @@ func newUpgradeCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { debug: settings.Debug, showMetadata: false, hideNotes: instClient.HideNotes, + noColor: settings.NoColor, }) } else if err != nil { return err @@ -257,6 +258,7 @@ func newUpgradeCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { debug: settings.Debug, showMetadata: false, hideNotes: client.HideNotes, + noColor: settings.NoColor, }) }, }