diff --git a/CHANGELOG.md b/CHANGELOG.md index 536562f..3dcc599 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, 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 9232051..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. -Pass `--no-color`, or set the `NO_COLOR` environment variable, to turn off the colored output. +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 @@ -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.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 b913e21..63aea4c 100644 --- a/internal/output/format_test.go +++ b/internal/output/format_test.go @@ -78,6 +78,37 @@ 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} + + 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