cli: maturity polish — color, error translation, tabwriter consistency

Adds three small but high-leverage presentation tweaks for v0.1:

1. internal/cli/style is a new ~70 LOC package with Pass/Fail/Warn/
   Dim/Bold helpers. Each is TTY-gated and obeys NO_COLOR. No
   external dep. Wired into the doctor PASS/FAIL/WARN status, the
   "banger:" error prefix on stderr, and the dim 'ready in <elapsed>'
   line.
2. internal/cli/errors translates rpc.ErrorResponse into user-facing
   text. operation_failed becomes invisible (the message wins);
   not_found, already_exists, bad_request, bad_version, unauthorized,
   unknown_method get short labels; unknown codes pass through. The
   daemon-attached op_id lands in dim parens — paste into
   journalctl --grep to find the daemon log line that produced the
   failure.
3. Tabwriter config converges on (0, 8, 2, ' ', 0) across every
   list/table command. The vm prune confirmation table picked up the
   right config; system install + system status switched from bare
   "key: value\n" lines to tabular form. printVMSpecLine drops its
   Unicode middle dot for an ASCII '|' so terminals without UTF-8
   render cleanly.

Tests cover translateRPCError for every code, style helpers no-op
on non-TTY and under NO_COLOR. Smoke status greps switch from
"key: value" to "key   value" to match the new format.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Thales Maciel 2026-04-26 22:27:07 -03:00
parent e47b8146dc
commit 71a332a6a1
No known key found for this signature in database
GPG key ID: 33112E6833C34679
11 changed files with 358 additions and 28 deletions

View file

@ -9,6 +9,7 @@ import (
"syscall"
"banger/internal/cli"
"banger/internal/cli/style"
)
func main() {
@ -21,7 +22,12 @@ func main() {
if errors.As(err, &exitErr) {
os.Exit(exitErr.Code)
}
fmt.Fprintf(os.Stderr, "banger: %v\n", err)
// Render the failure through the CLI's translator so RPC
// codes become friendly text, op_ids land in parens for
// journalctl grepping, and the "banger:" prefix turns red
// on a TTY.
prefix := style.Fail(os.Stderr, "banger:")
fmt.Fprintf(os.Stderr, "%s %s\n", prefix, cli.TranslateError(os.Stderr, err))
os.Exit(1)
}
}

View file

@ -1790,10 +1790,14 @@ func TestDaemonStatusIncludesLogPathWhenStopped(t *testing.T) {
}
output := stdout.String()
// Output is tabwriter-formatted (key TAB value, padded). Assert
// the key and value land on the same line rather than pinning a
// specific separator.
for _, want := range []string{
"service: bangerd.service",
"socket: /run/banger/bangerd.sock",
"log: journalctl -u bangerd.service",
"service",
"bangerd.service",
"/run/banger/bangerd.sock",
"journalctl -u bangerd.service",
} {
if !strings.Contains(output, want) {
t.Fatalf("output = %q, want %q", output, want)
@ -1825,13 +1829,14 @@ func TestDaemonStatusIncludesDaemonBuildInfoWhenRunning(t *testing.T) {
output := stdout.String()
for _, want := range []string{
"service: bangerd.service",
"socket: /run/banger/bangerd.sock",
"log: journalctl -u bangerd.service",
"pid: 42",
"version: v1.2.3",
"commit: abc123",
"built_at: 2026-03-22T12:00:00Z",
"service",
"bangerd.service",
"/run/banger/bangerd.sock",
"journalctl -u bangerd.service",
"42",
"v1.2.3",
"abc123",
"2026-03-22T12:00:00Z",
} {
if !strings.Contains(output, want) {
t.Fatalf("output = %q, want %q", output, want)

View file

@ -9,6 +9,7 @@ import (
"path/filepath"
"strconv"
"strings"
"text/tabwriter"
"banger/internal/buildinfo"
"banger/internal/installmeta"
@ -190,8 +191,16 @@ func (d *deps) runSystemInstall(ctx context.Context, out io.Writer, ownerFlag st
if err := d.waitForDaemonReady(ctx, installmeta.DefaultSocketPath); err != nil {
return err
}
_, err = fmt.Fprintf(out, "installed\nowner: %s\nsocket: %s\nhelper_socket: %s\nservice: %s\nhelper_service: %s\n", meta.OwnerUser, installmeta.DefaultSocketPath, installmeta.DefaultRootHelperSocketPath, installmeta.DefaultService, installmeta.DefaultRootHelperService)
return err
if _, err := fmt.Fprintln(out, "installed"); err != nil {
return err
}
w := tabwriter.NewWriter(out, 0, 8, 2, ' ', 0)
fmt.Fprintf(w, "owner\t%s\n", meta.OwnerUser)
fmt.Fprintf(w, "socket\t%s\n", installmeta.DefaultSocketPath)
fmt.Fprintf(w, "helper_socket\t%s\n", installmeta.DefaultRootHelperSocketPath)
fmt.Fprintf(w, "service\t%s\n", installmeta.DefaultService)
fmt.Fprintf(w, "helper_service\t%s\n", installmeta.DefaultRootHelperService)
return w.Flush()
}
func (d *deps) runSystemStatus(ctx context.Context, out io.Writer) error {
@ -212,17 +221,28 @@ func (d *deps) runSystemStatus(ctx context.Context, out io.Writer) error {
if helperEnabled == "" {
helperEnabled = "unknown"
}
fmt.Fprintf(out, "service: %s\nenabled: %s\nactive: %s\nhelper_service: %s\nhelper_enabled: %s\nhelper_active: %s\nsocket: %s\nhelper_socket: %s\nlog: journalctl -u %s -u %s\n",
installmeta.DefaultService, enabled, active,
installmeta.DefaultRootHelperService, helperEnabled, helperActive,
layout.SocketPath, installmeta.DefaultRootHelperSocketPath,
installmeta.DefaultService, installmeta.DefaultRootHelperService)
w := tabwriter.NewWriter(out, 0, 8, 2, ' ', 0)
fmt.Fprintf(w, "service\t%s\n", installmeta.DefaultService)
fmt.Fprintf(w, "enabled\t%s\n", enabled)
fmt.Fprintf(w, "active\t%s\n", active)
fmt.Fprintf(w, "helper_service\t%s\n", installmeta.DefaultRootHelperService)
fmt.Fprintf(w, "helper_enabled\t%s\n", helperEnabled)
fmt.Fprintf(w, "helper_active\t%s\n", helperActive)
fmt.Fprintf(w, "socket\t%s\n", layout.SocketPath)
fmt.Fprintf(w, "helper_socket\t%s\n", installmeta.DefaultRootHelperSocketPath)
fmt.Fprintf(w, "log\tjournalctl -u %s -u %s\n", installmeta.DefaultService, installmeta.DefaultRootHelperService)
if ping, err := d.daemonPing(ctx, layout.SocketPath); err == nil {
info := buildinfo.Normalize(ping.Version, ping.Commit, ping.BuiltAt)
_, err = fmt.Fprintf(out, "pid: %d\n%s", ping.PID, formatBuildInfoBlock(info))
return err
fmt.Fprintf(w, "pid\t%d\n", ping.PID)
fmt.Fprintf(w, "version\t%s\n", info.Version)
if info.Commit != "" {
fmt.Fprintf(w, "commit\t%s\n", info.Commit)
}
if info.BuiltAt != "" {
fmt.Fprintf(w, "built_at\t%s\n", info.BuiltAt)
}
}
return nil
return w.Flush()
}
func (d *deps) runSystemUninstall(ctx context.Context, out io.Writer, purge bool) error {

View file

@ -281,7 +281,7 @@ func (d *deps) runVMPrune(cmd *cobra.Command, socketPath string, force bool) err
}
fmt.Fprintf(stdout, "The following %d VM(s) will be deleted:\n", len(victims))
w := tabwriter.NewWriter(stdout, 0, 0, 2, ' ', 0)
w := tabwriter.NewWriter(stdout, 0, 8, 2, ' ', 0)
fmt.Fprintln(w, " ID\tNAME\tSTATE")
for _, vm := range victims {
fmt.Fprintf(w, " %s\t%s\t%s\n", shortID(vm.ID), vm.Name, vm.State)

90
internal/cli/errors.go Normal file
View file

@ -0,0 +1,90 @@
package cli
import (
"errors"
"strings"
"banger/internal/cli/style"
"banger/internal/rpc"
"io"
)
// TranslateError is the public entry point used by cmd/banger/main.go
// to render any error reaching the top of the cobra tree. Forwards
// to the package-internal helper so tests can reach it directly.
func TranslateError(w io.Writer, err error) string {
return translateRPCError(w, err)
}
// translateRPCError turns an error returned by rpc.Call into a
// user-facing string. Known codes get short, friendly prefixes;
// unknown codes pass through verbatim so debuggability is preserved.
// When the daemon attached an op_id the helper appends it in parens
// so an operator can paste it into journalctl --grep.
//
// Color is applied only when w is a TTY (and NO_COLOR is unset).
// The returned string never includes a trailing newline — caller
// chooses where it goes.
func translateRPCError(w io.Writer, err error) string {
if err == nil {
return ""
}
var rpcErr *rpc.ErrorResponse
if !errors.As(err, &rpcErr) || rpcErr == nil {
// Non-RPC failures (dialing the socket, decode errors,
// context cancellation, ...) come through as plain Go
// errors. Surface them verbatim — they already mention
// the underlying cause clearly enough.
return err.Error()
}
prefix := errorCodePrefix(rpcErr.Code)
body := rpcErr.Message
if prefix != "" {
body = prefix + ": " + rpcErr.Message
} else if rpcErr.Message == "" {
// Defensive: a server that returned a code with no
// message still has SOMETHING to report; default to the
// raw code so we never print an empty error.
body = rpcErr.Code
}
if rpcErr.OpID != "" {
body = body + " (" + style.Dim(w, rpcErr.OpID) + ")"
}
return body
}
// errorCodePrefix maps the small set of codes the daemon emits to
// short user-facing labels. Unknown codes return "" so the message
// alone is shown — keeps the door open for future codes the CLI
// hasn't been updated to recognise.
//
// "operation_failed" is the catch-all the generic dispatcher uses
// when a service returned an error; the message is already self-
// explanatory, so we strip the code entirely. Specialised codes
// (not_found, already_exists, ...) keep a label because the
// message body alone may not say what kind of failure it is.
func errorCodePrefix(code string) string {
switch strings.TrimSpace(code) {
case "", "operation_failed":
return ""
case "not_found":
return "not found"
case "not_running":
return "not running"
case "already_exists":
return "already exists"
case "bad_request", "bad_params":
return "bad request"
case "bad_version":
return "version mismatch"
case "unauthorized":
return "unauthorized"
case "unknown_method":
return "unknown method"
default:
// Surface the raw code so an operator filing a bug has
// something concrete to grep for. Strips the boilerplate
// "operation_failed" but keeps anything novel.
return code
}
}

View file

@ -0,0 +1,60 @@
package cli
import (
"bytes"
"errors"
"strings"
"testing"
"banger/internal/rpc"
)
// TestTranslateRPCError pins the user-facing error rendering for
// every code the daemon emits today plus the catch-all unknown-code
// path. Buffer is non-TTY so style helpers no-op and assertions
// stay readable.
func TestTranslateRPCError(t *testing.T) {
var buf bytes.Buffer
cases := []struct {
name string
code string
msg string
opID string
expect string
}{
{"operation_failed strips code", "operation_failed", "vm running", "", "vm running"},
{"empty code drops prefix", "", "raw boom", "", "raw boom"},
{"not_found", "not_found", `vm "x" not found`, "", `not found: vm "x" not found`},
{"not_running", "not_running", "vm is not running", "", "not running: vm is not running"},
{"already_exists", "already_exists", "image foo", "", "already exists: image foo"},
{"bad_request", "bad_request", "missing rootfs", "", "bad request: missing rootfs"},
{"bad_params", "bad_params", "invalid tap name", "", "bad request: invalid tap name"},
{"bad_version", "bad_version", "unsupported version 99", "", "version mismatch: unsupported version 99"},
{"unauthorized", "unauthorized", "uid 1000 not allowed", "", "unauthorized: uid 1000 not allowed"},
{"unknown_method", "unknown_method", "no.such.method", "", "unknown method: no.such.method"},
{"unknown code falls through", "weird_new_code", "boom", "", "weird_new_code: boom"},
{"op_id appended in parens", "operation_failed", "boom", "op-deadbeef00ff", "boom (op-deadbeef00ff)"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := &rpc.ErrorResponse{Code: tc.code, Message: tc.msg, OpID: tc.opID}
got := translateRPCError(&buf, err)
if got != tc.expect {
t.Errorf("got %q, want %q", got, tc.expect)
}
})
}
}
// TestTranslateRPCErrorPassesThroughNonRPCErrors covers the dial
// failure / decode failure paths where rpc.Call returns a plain Go
// error rather than *rpc.ErrorResponse. The translator must not
// hide the original message — that's the only signal an operator
// has when the daemon is down.
func TestTranslateRPCErrorPassesThroughNonRPCErrors(t *testing.T) {
var buf bytes.Buffer
got := translateRPCError(&buf, errors.New("dial unix /run/banger/bangerd.sock: connect: no such file or directory"))
if !strings.Contains(got, "no such file or directory") {
t.Fatalf("plain error lost: got %q", got)
}
}

View file

@ -3,12 +3,14 @@ package cli
import (
"encoding/json"
"fmt"
"io"
"os"
"sort"
"strings"
"text/tabwriter"
"banger/internal/api"
"banger/internal/cli/style"
"banger/internal/model"
"banger/internal/system"
)
@ -278,8 +280,19 @@ func printKernelCatalogTable(out anyWriter, entries []api.KernelCatalogEntry) er
// -- doctor printer -------------------------------------------------
func printDoctorReport(out anyWriter, report system.Report) error {
colorWriter, _ := out.(io.Writer)
for _, check := range report.Checks {
status := strings.ToUpper(string(check.Status))
if colorWriter != nil {
switch check.Status {
case system.CheckStatusPass:
status = style.Pass(colorWriter, status)
case system.CheckStatusFail:
status = style.Fail(colorWriter, status)
case system.CheckStatusWarn:
status = style.Warn(colorWriter, status)
}
}
if _, err := fmt.Fprintf(out, "%s\t%s\n", status, check.Name); err != nil {
return err
}

View file

@ -0,0 +1,70 @@
// Package style provides a tiny, conservative ANSI-color helper for
// banger's CLI. The contract:
//
// - Each helper takes the writer the styled string is going to and
// returns either the wrapped string or the plain one.
// - "Wrapped" only happens when the writer is a TTY AND the
// NO_COLOR environment variable is unset.
// - No 256-color or truecolor; no theme system; no external dep.
//
// Banger's CLI uses these for status (pass/fail/warn), error
// prefixes, and dim secondary text. Anything richer belongs in a
// dedicated TUI layer that this package isn't.
package style
import (
"io"
"os"
"strings"
)
// ANSI escape sequences. Kept private — callers compose meaning via
// the named helpers (Pass/Fail/Warn/...), not raw codes.
const (
ansiReset = "\x1b[0m"
ansiBold = "\x1b[1m"
ansiDim = "\x1b[2m"
ansiRed = "\x1b[31m"
ansiGreen = "\x1b[32m"
ansiYel = "\x1b[33m"
)
// Pass wraps s in green when w is a TTY and NO_COLOR is unset.
func Pass(w io.Writer, s string) string { return wrap(w, ansiGreen, s) }
// Fail wraps s in red.
func Fail(w io.Writer, s string) string { return wrap(w, ansiRed, s) }
// Warn wraps s in yellow.
func Warn(w io.Writer, s string) string { return wrap(w, ansiYel, s) }
// Dim wraps s in dim.
func Dim(w io.Writer, s string) string { return wrap(w, ansiDim, s) }
// Bold wraps s in bold.
func Bold(w io.Writer, s string) string { return wrap(w, ansiBold, s) }
// SupportsColor reports whether colored output should be emitted to
// w. Exposed so callers that build multi-segment strings can avoid
// duplicating the gate per call.
func SupportsColor(w io.Writer) bool {
if strings.TrimSpace(os.Getenv("NO_COLOR")) != "" {
return false
}
file, ok := w.(*os.File)
if !ok {
return false
}
info, err := file.Stat()
if err != nil {
return false
}
return info.Mode()&os.ModeCharDevice != 0
}
func wrap(w io.Writer, code, s string) string {
if !SupportsColor(w) {
return s
}
return code + s + ansiReset
}

View file

@ -0,0 +1,64 @@
package style
import (
"bytes"
"os"
"strings"
"testing"
)
// TestStyleNoOpsForNonTTYWriter pins that styled helpers don't emit
// ANSI escapes when the destination isn't a terminal. Buffers stand
// in for any non-TTY writer (CI, redirected stdout, log files).
func TestStyleNoOpsForNonTTYWriter(t *testing.T) {
var buf bytes.Buffer
cases := map[string]string{
"pass": Pass(&buf, "ok"),
"fail": Fail(&buf, "boom"),
"warn": Warn(&buf, "huh"),
"dim": Dim(&buf, "sub"),
"bold": Bold(&buf, "bold"),
}
for label, got := range cases {
if strings.Contains(got, "\x1b[") {
t.Errorf("%s: contains ANSI escape on non-TTY writer: %q", label, got)
}
}
}
// TestStyleSuppressedByNoColor pins https://no-color.org compliance:
// even on a "real" TTY, NO_COLOR forces plain output.
func TestStyleSuppressedByNoColor(t *testing.T) {
t.Setenv("NO_COLOR", "1")
r, w, err := os.Pipe()
if err != nil {
t.Fatalf("Pipe: %v", err)
}
defer r.Close()
defer w.Close()
// w is a pipe end, not a char device — NO_COLOR is the dominant
// gate but verifying the helper still suppresses guards against
// a future TTY-detection regression that would otherwise need a
// pty harness to surface.
if got := Pass(w, "ok"); strings.Contains(got, "\x1b[") {
t.Errorf("NO_COLOR set but Pass() emitted ANSI: %q", got)
}
if got := Fail(w, "boom"); strings.Contains(got, "\x1b[") {
t.Errorf("NO_COLOR set but Fail() emitted ANSI: %q", got)
}
}
// TestSupportsColorRespectsNoColor confirms the gate function used
// by the helpers. Required for callers that compose multi-segment
// strings and want to ask once.
func TestSupportsColorRespectsNoColor(t *testing.T) {
t.Setenv("NO_COLOR", "1")
tmp, err := os.CreateTemp(t.TempDir(), "style-*")
if err != nil {
t.Fatalf("CreateTemp: %v", err)
}
defer tmp.Close()
if SupportsColor(tmp) {
t.Fatal("SupportsColor returned true with NO_COLOR set")
}
}

View file

@ -10,6 +10,7 @@ import (
"time"
"banger/internal/api"
"banger/internal/cli/style"
"banger/internal/config"
"banger/internal/model"
"banger/internal/paths"
@ -52,7 +53,7 @@ func printVMSpecLine(out io.Writer, params api.VMCreateParams) {
diskBytes = parsed
}
}
_, _ = fmt.Fprintf(out, "spec: %d vcpu · %d MiB · %s disk\n",
_, _ = fmt.Fprintf(out, "spec: %d vcpu | %d MiB | %s disk\n",
vcpu, memory, model.FormatSizeBytes(diskBytes))
}
@ -75,7 +76,8 @@ func (d *deps) runVMCreate(ctx context.Context, socketPath string, stderr io.Wri
if op.Done {
renderer.render(op)
if op.Success && op.VM != nil {
_, _ = fmt.Fprintf(stderr, "[vm create] ready in %s\n", formatVMCreateElapsed(time.Since(start)))
elapsed := formatVMCreateElapsed(time.Since(start))
_, _ = fmt.Fprintf(stderr, "[vm create] ready in %s\n", style.Dim(stderr, elapsed))
return *op.VM, nil
}
if strings.TrimSpace(op.Error) == "" {

View file

@ -134,8 +134,8 @@ sudo env \
sudo touch "$smoke_marker"
status_out="$("$BANGER" system status)" || die 'system status failed after install'
grep -q 'active: active' <<<"$status_out" || die "owner daemon not active after install: $status_out"
grep -q 'helper_active: active' <<<"$status_out" || die "root helper not active after install: $status_out"
grep -qE '^active +active' <<<"$status_out" || die "owner daemon not active after install: $status_out"
grep -qE '^helper_active +active' <<<"$status_out" || die "root helper not active after install: $status_out"
log 'doctor: checking host readiness'
if ! "$BANGER" doctor; then
@ -145,8 +145,8 @@ fi
log 'system restart: services should come back cleanly'
sudo_banger "$BANGER" system restart >/dev/null || die 'system restart failed'
status_out="$("$BANGER" system status)" || die 'system status failed after restart'
grep -q 'active: active' <<<"$status_out" || die "owner daemon not active after restart: $status_out"
grep -q 'helper_active: active' <<<"$status_out" || die "root helper not active after restart: $status_out"
grep -qE '^active +active' <<<"$status_out" || die "owner daemon not active after restart: $status_out"
grep -qE '^helper_active +active' <<<"$status_out" || die "root helper not active after restart: $status_out"
# --- bare vm run ------------------------------------------------------
log "bare vm run: create + start + ssh + exec 'echo smoke-bare-ok' + --rm"