Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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`:

Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
6 changes: 3 additions & 3 deletions internal/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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,
}) {
Expand All @@ -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):
}
}
Expand Down
19 changes: 19 additions & 0 deletions internal/output/color.go
Original file line number Diff line number Diff line change
@@ -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()))
}
26 changes: 26 additions & 0 deletions internal/output/color_test.go
Original file line number Diff line number Diff line change
@@ -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))
})
}
16 changes: 13 additions & 3 deletions internal/output/format.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import (
"fmt"
"io"
"text/template"

"github.com/fatih/color"
)

// Format represents the output format type.
Expand Down Expand Up @@ -74,18 +76,26 @@ 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
}
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
Expand Down
31 changes: 31 additions & 0 deletions internal/output/format_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
27 changes: 25 additions & 2 deletions internal/output/table.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
package output

import (
"bytes"
"fmt"
"io"
"strings"
"text/tabwriter"

"github.com/fatih/color"
)

func (p *Printer) printTable(data Formattable) error {
Expand All @@ -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"))
Expand All @@ -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
}
17 changes: 13 additions & 4 deletions internal/update/notify.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 {
Expand All @@ -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 {
Expand Down
14 changes: 10 additions & 4 deletions internal/update/notify_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down