diff --git a/app/cli/cmd/color.go b/app/cli/cmd/color.go new file mode 100644 index 000000000..94db3dc31 --- /dev/null +++ b/app/cli/cmd/color.go @@ -0,0 +1,91 @@ +// +// Copyright 2026 The Chainloop 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 cmd + +import ( + "os" + + "golang.org/x/term" +) + +// colorCapableCISignals are environment variables set by CI systems whose logs +// render ANSI escape codes, so colored output is desirable there even though +// the process is not attached to an interactive terminal. The list mirrors the +// one used by the widely adopted supports-color/chalk detection. +// +// Jenkins is intentionally absent: its console does not interpret ANSI escape +// codes unless the AnsiColor plugin is installed, so leaving it out makes the +// CLI fall back to the non-terminal default (no color) and avoids leaking raw +// escape sequences into the build log. +var colorCapableCISignals = []string{ + "GITHUB_ACTIONS", + "GITLAB_CI", + "CIRCLECI", + "TRAVIS", + "BUILDKITE", + "DRONE", + "APPVEYOR", +} + +// LogColorDisabled reports whether colorized log output should be disabled. It +// follows the same approach as the supports-color/chalk family: honor the +// NO_COLOR / FORCE_COLOR conventions, then enable color on an interactive +// terminal or in a CI system known to render ANSI escape codes. +func LogColorDisabled() bool { + return !colorSupported(os.LookupEnv, term.IsTerminal(int(os.Stderr.Fd()))) +} + +// colorSupported decides whether ANSI color should be emitted, given a way to +// look up environment variables and whether stderr is an interactive terminal. +// It is kept pure so the decision logic can be unit tested. +// +// Precedence: +// 1. NO_COLOR set, any value -> no color (https://no-color.org/). Universal +// opt-out, wins over everything. +// 2. CLICOLOR_FORCE / FORCE_COLOR set to a non-empty, non-"0" value -> color. +// Universal opt-in (https://bixense.com/clicolors/ and FORCE_COLOR). +// 3. TERM=dumb -> no color. +// 4. Interactive terminal -> color. +// 5. Non-terminal (CI, pipe, file) -> color only for CI systems known to +// render ANSI (see colorCapableCISignals); otherwise no color. This keeps +// piped output and ANSI-incapable consoles (e.g. Jenkins) clean. +func colorSupported(lookupEnv func(string) (string, bool), isTerminal bool) bool { + if _, ok := lookupEnv("NO_COLOR"); ok { + return false + } + + for _, key := range []string{"CLICOLOR_FORCE", "FORCE_COLOR"} { + if v, ok := lookupEnv(key); ok && v != "" && v != "0" { + return true + } + } + + if v, ok := lookupEnv("TERM"); ok && v == "dumb" { + return false + } + + if isTerminal { + return true + } + + for _, key := range colorCapableCISignals { + if _, ok := lookupEnv(key); ok { + return true + } + } + + return false +} diff --git a/app/cli/cmd/color_test.go b/app/cli/cmd/color_test.go new file mode 100644 index 000000000..52e01b7a6 --- /dev/null +++ b/app/cli/cmd/color_test.go @@ -0,0 +1,127 @@ +// +// Copyright 2026 The Chainloop 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 cmd + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestColorSupported(t *testing.T) { + testCases := []struct { + name string + env map[string]string + isTerminal bool + want bool + }{ + { + name: "interactive terminal gets color", + isTerminal: true, + want: true, + }, + { + name: "plain non-terminal (pipe/file) gets no color", + isTerminal: false, + want: false, + }, + { + name: "NO_COLOR disables color even on a terminal", + env: map[string]string{"NO_COLOR": "1"}, + isTerminal: true, + want: false, + }, + { + name: "NO_COLOR with empty value still disables (presence wins)", + env: map[string]string{"NO_COLOR": ""}, + isTerminal: true, + want: false, + }, + { + name: "CLICOLOR_FORCE forces color on a non-terminal", + env: map[string]string{"CLICOLOR_FORCE": "1"}, + isTerminal: false, + want: true, + }, + { + name: "FORCE_COLOR forces color on a non-terminal", + env: map[string]string{"FORCE_COLOR": "1"}, + isTerminal: false, + want: true, + }, + { + name: "CLICOLOR_FORCE=0 does not force color", + env: map[string]string{"CLICOLOR_FORCE": "0"}, + isTerminal: false, + want: false, + }, + { + name: "NO_COLOR takes precedence over FORCE_COLOR", + env: map[string]string{"NO_COLOR": "1", "FORCE_COLOR": "1"}, + isTerminal: true, + want: false, + }, + { + name: "TERM=dumb disables color on a terminal", + env: map[string]string{"TERM": "dumb"}, + isTerminal: true, + want: false, + }, + // CI systems that render ANSI keep color despite no terminal. + { + name: "GitHub Actions gets color", + env: map[string]string{"GITHUB_ACTIONS": "true", "CI": "true"}, + isTerminal: false, + want: true, + }, + { + name: "GitLab CI gets color", + env: map[string]string{"GITLAB_CI": "true", "CI": "true"}, + isTerminal: false, + want: true, + }, + // Jenkins is not in the color-capable list, so a non-terminal Jenkins + // console gets no color and avoids leaking raw escape codes. + { + name: "Jenkins gets no color", + env: map[string]string{"JENKINS_URL": "http://jenkins.local/", "BUILD_NUMBER": "10"}, + isTerminal: false, + want: false, + }, + { + name: "Jenkins with FORCE_COLOR opts back in", + env: map[string]string{"JENKINS_URL": "http://jenkins.local/", "FORCE_COLOR": "1"}, + isTerminal: false, + want: true, + }, + { + name: "generic CI not known to render ANSI gets no color", + env: map[string]string{"CI": "true"}, + isTerminal: false, + want: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + lookup := func(key string) (string, bool) { + v, ok := tc.env[key] + return v, ok + } + assert.Equal(t, tc.want, colorSupported(lookup, tc.isTerminal)) + }) + } +} diff --git a/app/cli/main.go b/app/cli/main.go index 42d56ec01..ba566a15f 100644 --- a/app/cli/main.go +++ b/app/cli/main.go @@ -33,8 +33,16 @@ import ( ) func main() { - // Couldn't find an easier way to disable the timestamp - logger := zerolog.New(zerolog.ConsoleWriter{Out: os.Stderr, FormatTimestamp: func(interface{}) string { return "" }}) + // Couldn't find an easier way to disable the timestamp. + // Enable color on interactive terminals and CI systems that render ANSI, + // and disable it for ANSI-incapable consumers (e.g. a Jenkins console + // without the AnsiColor plugin, piped output) to avoid leaking raw escape + // codes. See cmd.LogColorDisabled. + logger := zerolog.New(zerolog.ConsoleWriter{ + Out: os.Stderr, + NoColor: cmd.LogColorDisabled(), + FormatTimestamp: func(interface{}) string { return "" }, + }) rootCmd := cmd.NewRootCmd(logger) if err := cmd.Execute(rootCmd); err != nil { msg, exitCode := errorInfo(err, logger)