From cae18b6e9102f9507209e23c4edc31f310069bd3 Mon Sep 17 00:00:00 2001 From: Simone Carletti Date: Mon, 24 Aug 2026 12:09:41 +0200 Subject: [PATCH 1/2] Bold table headers and widen the update check skip The --no-color flag was plumbed through to output.Printer but nothing read it. Table output now prints the header row in bold, and the new output.ColorEnabled helper decides colour from the flag, the NO_COLOR environment variable, and whether the target writer is a terminal. The update notice uses the same helper. tabwriter sizes a column by the byte count of its cells, so the table is laid out first and the escape sequences wrap the finished header line. Wrapping each header cell instead would shift every column. The update check also skips on BUILD_NUMBER and RUN_ID, and now requires both the standard output stream and the standard error stream to be a terminal. Mark fatih/color as a direct dependency. It was recorded as indirect because the module was added before the first import. --- CHANGELOG.md | 2 ++ README.md | 4 ++-- go.mod | 2 +- internal/cli/root.go | 6 +++--- internal/output/color.go | 19 +++++++++++++++++++ internal/output/color_test.go | 26 ++++++++++++++++++++++++++ internal/output/format_test.go | 15 +++++++++++++++ internal/output/table.go | 27 +++++++++++++++++++++++++-- internal/update/notify.go | 17 +++++++++++++---- internal/update/notify_test.go | 14 ++++++++++---- 10 files changed, 116 insertions(+), 16 deletions(-) create mode 100644 internal/output/color.go create mode 100644 internal/output/color_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 536562f..55492c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ This project uses [Semantic Versioning 2.0.0](http://semver.org/), the format is - Release artifacts are published only to `dnsimple/cli`. The `dnsimple/homebrew-tap` release mirror is removed, and the install scripts and the Homebrew formula now download from `dnsimple/cli`. - The new release notice now prints the release page URL for every installation method, colors the version numbers, and separates itself from the command output with a blank line above and below. `--no-color` and the `NO_COLOR` environment variable turn the color off. The `DNSIMPLE_NO_UPDATE_CHECK` environment variable, which turns the check off, is now documented in the README. +- Table output puts the header row in bold when the standard output stream is a terminal. `--no-color` and the `NO_COLOR` environment variable turn the bold off, and redirected output stays plain. +- The update check no longer runs when the `BUILD_NUMBER` or the `RUN_ID` environment variable is set, and it now requires both the standard output stream and the standard error stream to be a terminal. ## 0.10.0 - 2026-06-15 diff --git a/README.md b/README.md index 9232051..c6f2cbd 100644 --- a/README.md +++ b/README.md @@ -185,7 +185,7 @@ dnsimple domains list --format '{{range .}}{{.Name}}{{printf "\n"}}{{end}}' To discover available template fields, inspect the corresponding `--json` output and use the fields inside `data` as the underlying resource model. -Pass `--no-color`, or set the `NO_COLOR` environment variable, to turn off the colored output. +Table output puts the header row in bold when the standard output stream is a terminal. Pass `--no-color`, or set the `NO_COLOR` environment variable, to turn the colored output off. Redirected output is always plain. ### Sandbox Environment @@ -209,7 +209,7 @@ You will need a token created in the sandbox environment. Production tokens will The CLI checks for a new release at most once every 24 hours and caches the result. When a new release exists, the CLI writes a short notice to the standard error stream after the command output. The notice names the current version, the new version, the command to upgrade your installation, and the release page. -The check does not run when the standard error stream is not a terminal, when the `CI` environment variable is set, or when you run `--version`, `--help`, or `completion`. +The check does not run when the standard output stream or the standard error stream is not a terminal, when one of the `CI`, `BUILD_NUMBER`, and `RUN_ID` environment variables is set, or when you run `--version`, `--help`, or `completion`. To turn the check off, set `DNSIMPLE_NO_UPDATE_CHECK`: diff --git a/go.mod b/go.mod index ee6dced..a3f4109 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.25.4 require ( github.com/cli/browser v1.3.0 github.com/dnsimple/dnsimple-go/v9 v9.1.0 + github.com/fatih/color v1.19.0 github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 github.com/spf13/viper v1.21.0 @@ -14,7 +15,6 @@ require ( ) require ( - github.com/fatih/color v1.19.0 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/google/go-querystring v1.2.0 // indirect diff --git a/internal/cli/root.go b/internal/cli/root.go index 8b17ec4..cb66c60 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -7,6 +7,7 @@ import ( "time" "github.com/dnsimple/cli/internal/cmdutil" + "github.com/dnsimple/cli/internal/output" "github.com/dnsimple/cli/internal/update" "github.com/spf13/cobra" "golang.org/x/term" @@ -71,7 +72,7 @@ func Execute(version string, args []string) int { var updateCh <-chan *update.CheckResult if update.ShouldCheck(update.Opts{ CurrentVersion: version, - IsTerminal: term.IsTerminal(int(os.Stderr.Fd())), + IsTerminal: term.IsTerminal(int(os.Stdout.Fd())) && term.IsTerminal(int(os.Stderr.Fd())), Debug: debug, Args: args, }) { @@ -88,8 +89,7 @@ func Execute(version string, args []string) int { if updateCh != nil { select { case result := <-updateCh: - useColor := !f.Flags.NoColor && os.Getenv("NO_COLOR") == "" - update.PrintNotice(os.Stderr, result, useColor) + update.PrintNotice(os.Stderr, result, output.ColorEnabled(os.Stderr, f.Flags.NoColor)) case <-time.After(2 * time.Second): } } diff --git a/internal/output/color.go b/internal/output/color.go new file mode 100644 index 0000000..e4097be --- /dev/null +++ b/internal/output/color.go @@ -0,0 +1,19 @@ +package output + +import ( + "io" + "os" + + "golang.org/x/term" +) + +// ColorEnabled reports whether w accepts colored output. The noColor argument +// carries the --no-color flag. A writer that is not a terminal never gets color, +// so redirected output and captured test output stay plain. +func ColorEnabled(w io.Writer, noColor bool) bool { + if noColor || os.Getenv("NO_COLOR") != "" { + return false + } + f, ok := w.(*os.File) + return ok && term.IsTerminal(int(f.Fd())) +} diff --git a/internal/output/color_test.go b/internal/output/color_test.go new file mode 100644 index 0000000..068f169 --- /dev/null +++ b/internal/output/color_test.go @@ -0,0 +1,26 @@ +package output + +import ( + "bytes" + "os" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestColorEnabled(t *testing.T) { + t.Run("non-terminal writer", func(t *testing.T) { + t.Setenv("NO_COLOR", "") + assert.False(t, ColorEnabled(&bytes.Buffer{}, false)) + }) + + t.Run("no-color flag", func(t *testing.T) { + t.Setenv("NO_COLOR", "") + assert.False(t, ColorEnabled(os.Stdout, true)) + }) + + t.Run("NO_COLOR env var", func(t *testing.T) { + t.Setenv("NO_COLOR", "1") + assert.False(t, ColorEnabled(os.Stdout, false)) + }) +} diff --git a/internal/output/format_test.go b/internal/output/format_test.go index b913e21..86e46b3 100644 --- a/internal/output/format_test.go +++ b/internal/output/format_test.go @@ -78,6 +78,21 @@ func TestPrinterPrintTable(t *testing.T) { } } +func TestPrinterPrintTableStaysPlainWithoutATerminal(t *testing.T) { + var buf bytes.Buffer + p := &Printer{Writer: &buf, Format: FormatTable} + + err := p.Print(&stubFormattable{ + headers: []string{"ID", "NAME"}, + rows: [][]string{{"1", "example.com"}}, + }) + if !assert.NoError(t, err) { + return + } + + assert.Equal(t, "ID NAME\n1 example.com\n", buf.String()) +} + func TestPrinterPrintTableEmptyHeaders(t *testing.T) { var buf bytes.Buffer p := &Printer{Writer: &buf, Format: FormatTable} diff --git a/internal/output/table.go b/internal/output/table.go index 3ddcdc3..10b073f 100644 --- a/internal/output/table.go +++ b/internal/output/table.go @@ -1,9 +1,13 @@ package output import ( + "bytes" "fmt" + "io" "strings" "text/tabwriter" + + "github.com/fatih/color" ) func (p *Printer) printTable(data Formattable) error { @@ -14,7 +18,8 @@ func (p *Printer) printTable(data Formattable) error { return nil } - w := tabwriter.NewWriter(p.Writer, 0, 0, 2, ' ', 0) + var buf bytes.Buffer + w := tabwriter.NewWriter(&buf, 0, 0, 2, ' ', 0) // Print header fmt.Fprintln(w, strings.Join(headers, "\t")) @@ -24,5 +29,23 @@ func (p *Printer) printTable(data Formattable) error { fmt.Fprintln(w, strings.Join(row, "\t")) } - return w.Flush() + if err := w.Flush(); err != nil { + return err + } + + if !ColorEnabled(p.Writer, p.NoColor) { + _, err := p.Writer.Write(buf.Bytes()) + return err + } + + // tabwriter sizes a column by the byte count of its cells, so the escape + // sequences go around the laid out header line instead of each header cell. + header, body, _ := strings.Cut(buf.String(), "\n") + bold := color.New(color.Bold) + bold.EnableColor() + if _, err := fmt.Fprintln(p.Writer, bold.Sprint(header)); err != nil { + return err + } + _, err := io.WriteString(p.Writer, body) + return err } diff --git a/internal/update/notify.go b/internal/update/notify.go index 6b4d33a..8035d23 100644 --- a/internal/update/notify.go +++ b/internal/update/notify.go @@ -15,9 +15,11 @@ import ( // Opts configures the update check behavior. type Opts struct { CurrentVersion string - IsTerminal bool - Debug bool - Args []string + // IsTerminal reports whether both the standard output stream and the + // standard error stream are terminals. + IsTerminal bool + Debug bool + Args []string } // ShouldCheck evaluates whether the update check should run. @@ -31,7 +33,7 @@ func ShouldCheck(opts Opts) bool { if !opts.IsTerminal { return false } - if os.Getenv("CI") != "" { + if isCI() { return false } for _, arg := range opts.Args { @@ -48,6 +50,13 @@ func ShouldCheck(opts Opts) bool { return true } +// isCI reports whether the CLI runs on a continuous integration service. +func isCI() bool { + return os.Getenv("CI") != "" || + os.Getenv("BUILD_NUMBER") != "" || + os.Getenv("RUN_ID") != "" +} + // CheckAsync launches a background version check and returns a channel // that will receive the result (or nil on error/no update). func CheckAsync(ctx context.Context, currentVersion string, debug bool) <-chan *CheckResult { diff --git a/internal/update/notify_test.go b/internal/update/notify_test.go index 4979628..5564195 100644 --- a/internal/update/notify_test.go +++ b/internal/update/notify_test.go @@ -37,10 +37,16 @@ func TestShouldCheck(t *testing.T) { assert.False(t, ShouldCheck(opts)) }) - t.Run("CI env var", func(t *testing.T) { - t.Setenv("CI", "true") - assert.False(t, ShouldCheck(base)) - }) + ciVars := []string{"CI", "BUILD_NUMBER", "RUN_ID"} + for _, name := range ciVars { + t.Run("CI env var "+name, func(t *testing.T) { + for _, other := range ciVars { + t.Setenv(other, "") + } + t.Setenv(name, "1") + assert.False(t, ShouldCheck(base)) + }) + } t.Run("version flag", func(t *testing.T) { opts := base From bc68254562b3dd67b6119bc31d598dbc4e7a85f8 Mon Sep 17 00:00:00 2001 From: Simone Carletti Date: Mon, 24 Aug 2026 12:13:43 +0200 Subject: [PATCH 2/2] Dim the pagination hints The hints are advice about the table rather than part of it, so they render faint and let the table itself stay at full intensity. They go to the error stream, so the colour decision reads that writer. --- CHANGELOG.md | 2 +- README.md | 2 +- internal/output/format.go | 16 +++++++++++++--- internal/output/format_test.go | 16 ++++++++++++++++ 4 files changed, 31 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 55492c5..3dcc599 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ This project uses [Semantic Versioning 2.0.0](http://semver.org/), the format is - Release artifacts are published only to `dnsimple/cli`. The `dnsimple/homebrew-tap` release mirror is removed, and the install scripts and the Homebrew formula now download from `dnsimple/cli`. - The new release notice now prints the release page URL for every installation method, colors the version numbers, and separates itself from the command output with a blank line above and below. `--no-color` and the `NO_COLOR` environment variable turn the color off. The `DNSIMPLE_NO_UPDATE_CHECK` environment variable, which turns the check off, is now documented in the README. -- Table output puts the header row in bold when the standard output stream is a terminal. `--no-color` and the `NO_COLOR` environment variable turn the bold off, and redirected output stays plain. +- Table output puts the header row in bold, and the pagination hints that go with a multi-page list are faint. `--no-color` and the `NO_COLOR` environment variable turn the color off, and redirected output stays plain. - The update check no longer runs when the `BUILD_NUMBER` or the `RUN_ID` environment variable is set, and it now requires both the standard output stream and the standard error stream to be a terminal. ## 0.10.0 - 2026-06-15 diff --git a/README.md b/README.md index c6f2cbd..bc8fb39 100644 --- a/README.md +++ b/README.md @@ -185,7 +185,7 @@ dnsimple domains list --format '{{range .}}{{.Name}}{{printf "\n"}}{{end}}' To discover available template fields, inspect the corresponding `--json` output and use the fields inside `data` as the underlying resource model. -Table output puts the header row in bold when the standard output stream is a terminal. Pass `--no-color`, or set the `NO_COLOR` environment variable, to turn the colored output off. Redirected output is always plain. +Table output puts the header row in bold, and the pagination hints that go with a multi-page list are faint. Pass `--no-color`, or set the `NO_COLOR` environment variable, to turn the colored output off. Redirected output is always plain. ### Sandbox Environment diff --git a/internal/output/format.go b/internal/output/format.go index 1db9a93..65236b7 100644 --- a/internal/output/format.go +++ b/internal/output/format.go @@ -5,6 +5,8 @@ import ( "fmt" "io" "text/template" + + "github.com/fatih/color" ) // Format represents the output format type. @@ -74,10 +76,18 @@ func (p *Printer) Print(data Formattable) error { // template output are left untouched: JSON already embeds the pagination object. func (p *Printer) PrintList(data Formattable, info *PageInfo) error { hint := p.Format == FormatTable && info != nil && info.TotalPages > 1 && p.ErrWriter != nil + + // The hints are advice about the table, so they are faint and the table is not. + faint := color.New(color.Faint) + faint.DisableColor() + if hint && ColorEnabled(p.ErrWriter, p.NoColor) { + faint.EnableColor() + } + if hint { // Summary above the table so it stays visible before a long list scrolls past. - fmt.Fprintf(p.ErrWriter, "Showing %d of %d %s (page %d of %d).\n\n", - info.Shown, info.TotalEntries, info.Noun, info.CurrentPage, info.TotalPages) + fmt.Fprintf(p.ErrWriter, "%s\n\n", faint.Sprintf("Showing %d of %d %s (page %d of %d).", + info.Shown, info.TotalEntries, info.Noun, info.CurrentPage, info.TotalPages)) } if err := p.Print(data); err != nil { return err @@ -85,7 +95,7 @@ func (p *Printer) PrintList(data Formattable, info *PageInfo) error { if hint { // Navigation advice below the table, where it lands next to the prompt. if nav := info.navHint(); nav != "" { - fmt.Fprintf(p.ErrWriter, "\n%s\n", nav) + fmt.Fprintf(p.ErrWriter, "\n%s\n", faint.Sprint(nav)) } } return nil diff --git a/internal/output/format_test.go b/internal/output/format_test.go index 86e46b3..63aea4c 100644 --- a/internal/output/format_test.go +++ b/internal/output/format_test.go @@ -78,6 +78,22 @@ func TestPrinterPrintTable(t *testing.T) { } } +func TestPrinterPrintListHintsStayPlainWithoutATerminal(t *testing.T) { + var out, errOut bytes.Buffer + p := &Printer{Writer: &out, ErrWriter: &errOut, Format: FormatTable} + + err := p.PrintList(listData(), &PageInfo{ + Noun: "records", Shown: 2, CurrentPage: 1, TotalPages: 3, TotalEntries: 6, CanFetchAll: true, + }) + if !assert.NoError(t, err) { + return + } + + assert.NotContains(t, errOut.String(), "\x1b[") + assert.Contains(t, errOut.String(), "Showing 2 of 6 records (page 1 of 3).") + assert.Contains(t, errOut.String(), "Pass --all to fetch every page") +} + func TestPrinterPrintTableStaysPlainWithoutATerminal(t *testing.T) { var buf bytes.Buffer p := &Printer{Writer: &buf, Format: FormatTable}