From b991e72c032b446526faa0d77dd36c35440d646c Mon Sep 17 00:00:00 2001 From: Arturo Peroni Date: Wed, 3 Jun 2026 15:03:11 +0200 Subject: [PATCH 1/3] feat(#26): add internal/ui installer-style terminal UX package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Printer renders colored step/✔/⚠/· output matching the tracebloc/client installer (scripts/lib/common.sh). TTY+NO_COLOR auto-detect with a WithColor override (functional-options); fatih/color forced per-instance so it's testable on a buffer. Helpers: Banner, Step, Successf, Warnf, Infof, Errorf, Hintf, PromptHeader. Co-Authored-By: Claude Opus 4.8 (1M context) --- go.mod | 5 +- go.sum | 11 ++- internal/ui/ui.go | 150 +++++++++++++++++++++++++++++++++++++++++ internal/ui/ui_test.go | 80 ++++++++++++++++++++++ 4 files changed, 243 insertions(+), 3 deletions(-) create mode 100644 internal/ui/ui.go create mode 100644 internal/ui/ui_test.go diff --git a/go.mod b/go.mod index 56b4e7b..1e50353 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ module github.com/tracebloc/cli go 1.26.0 require ( + github.com/fatih/color v1.19.0 github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 github.com/schollz/progressbar/v3 v3.19.0 github.com/spf13/cobra v1.8.1 @@ -35,6 +36,8 @@ require ( github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/mailru/easyjson v0.7.7 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect github.com/moby/spdystream v0.5.1 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect @@ -47,7 +50,7 @@ require ( go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/net v0.49.0 // indirect golang.org/x/oauth2 v0.34.0 // indirect - golang.org/x/sys v0.40.0 // indirect + golang.org/x/sys v0.42.0 // indirect golang.org/x/time v0.14.0 // indirect google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect diff --git a/go.sum b/go.sum index a49a7f6..13a149f 100644 --- a/go.sum +++ b/go.sum @@ -12,6 +12,8 @@ github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxK github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes= github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= +github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE= github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= @@ -48,6 +50,10 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db h1:62I3jR2EmQ4l5rM/4FEfDWcRD+abF5XlKShorW5LRoQ= @@ -100,8 +106,9 @@ golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= -golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= -golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= diff --git a/internal/ui/ui.go b/internal/ui/ui.go new file mode 100644 index 0000000..240f924 --- /dev/null +++ b/internal/ui/ui.go @@ -0,0 +1,150 @@ +// Package ui renders installer-style terminal output for the tracebloc +// CLI — colored step headers, ✔/⚠/· status lines, dim hints, and a +// branded banner — matching the look of the tracebloc/client one-line +// installer (scripts/lib/common.sh). +// +// Everything goes through a Printer, constructed with New. A Printer +// colorizes only when its writer is a real terminal and NO_COLOR is +// unset; pass WithColor to force the decision (e.g. a --plain flag). +// This mirrors internal/push.NewProgress, which likewise degrades on +// non-TTY output so piped/CI logs stay clean. +package ui + +import ( + "fmt" + "io" + "os" + + "github.com/fatih/color" + "golang.org/x/term" +) + +// Printer writes installer-style status output to w. Construct it with +// New — the zero value is unusable (w would be nil). Its methods are +// the Go counterparts of common.sh's step/success/warn/info/hint +// helpers. +// +// A Printer is read-only after construction, so it's safe to pass down +// a command's call tree; it is not safe for concurrent writes to the +// same underlying writer (neither is fmt.Fprintf). +type Printer struct { + w io.Writer + color bool +} + +// Option customizes a Printer at construction. This is the functional- +// options pattern: rather than a widening New(w, color, ...) signature +// or a separate Config struct, each knob is a small func that mutates +// the Printer. Callers pass only what they care about, and we can add +// options later without breaking New's signature. +type Option func(*Printer) + +// WithColor forces colorized output on or off, overriding New's auto- +// detection. Wire a --plain flag to WithColor(false); NO_COLOR is +// already honored by the default detection. +func WithColor(on bool) Option { + return func(p *Printer) { p.color = on } +} + +// New returns a Printer writing to w. By default it colorizes only when +// w is a real terminal and the NO_COLOR env var is unset +// (https://no-color.org). Options are applied after auto-detection, so +// WithColor wins over it. +func New(w io.Writer, opts ...Option) *Printer { + p := &Printer{w: w, color: autoColor(w)} + for _, opt := range opts { + opt(p) + } + return p +} + +// autoColor reports whether to colorize for w: only when NO_COLOR is +// unset AND w is an *os.File pointing at a terminal. A bytes.Buffer +// (tests), a pipe, or a redirect to a file all fail the *os.File + +// IsTerminal check and get plain output — the conservative default, +// same test internal/push.isTTY uses. +func autoColor(w io.Writer) bool { + if _, ok := os.LookupEnv("NO_COLOR"); ok { + return false + } + f, ok := w.(*os.File) + if !ok { + return false + } + return term.IsTerminal(int(f.Fd())) +} + +// paint wraps s in the given SGR attributes when this Printer is in +// color mode, otherwise returns s unchanged. We force-enable the +// fatih/color instance (EnableColor) so OUR p.color decision is +// authoritative — fatih/color's package-level auto-detect keys off +// os.Stdout, not our writer, which would be wrong under tests/pipes. +func (p *Printer) paint(s string, attrs ...color.Attribute) string { + if !p.color || len(attrs) == 0 { + return s + } + c := color.New(attrs...) + c.EnableColor() + return c.Sprint(s) +} + +// out is the single write path. The (n, err) result is discarded +// explicitly: a failed write to the terminal (closed pipe, /dev/full) +// can't be acted on mid-render and shouldn't crash the command — the +// exit code is the contract, same rationale as the Fprintf discards in +// internal/cli. errcheck (run by `make ci`) wants the discard explicit. +func (p *Printer) out(format string, a ...any) { + _, _ = fmt.Fprintf(p.w, format, a...) +} + +// Banner prints the branded intro block: a bold-cyan title, a dim rule, +// and an optional subtitle. Mirrors common.sh print_banner. +func (p *Printer) Banner(title, subtitle string) { + p.out("\n %s\n", p.paint(title, color.FgCyan, color.Bold)) + p.out(" %s\n", p.paint("────────────────────────────────────────", color.Faint)) + if subtitle != "" { + p.out(" %s\n", subtitle) + } + p.out("\n") +} + +// Step prints a major-step header: "Step n/total label" in bold cyan. +// Mirrors common.sh step(). +func (p *Printer) Step(n, total int, label string) { + head := p.paint(fmt.Sprintf("Step %d/%d", n, total), color.FgCyan, color.Bold) + p.out("\n%s %s\n", head, p.paint(label, color.Bold)) +} + +// Successf prints a completed-item line with a green ✔. The trailing +// `f` + (format, args) signature is Go's convention for "takes a format +// string" (cf. fmt.Printf vs fmt.Print). +func (p *Printer) Successf(format string, a ...any) { + p.out(" %s %s\n", p.paint("✔", color.FgGreen), fmt.Sprintf(format, a...)) +} + +// Warnf prints a non-blocking warning with a yellow ⚠. +func (p *Printer) Warnf(format string, a ...any) { + p.out(" %s %s\n", p.paint("⚠", color.FgYellow), fmt.Sprintf(format, a...)) +} + +// Infof prints supplementary detail with a dim · bullet. +func (p *Printer) Infof(format string, a ...any) { + p.out(" %s %s\n", p.paint("·", color.Faint), fmt.Sprintf(format, a...)) +} + +// Errorf prints a bold-red ✖ error line. Unlike common.sh's error(), +// it does NOT exit — surfacing the message is the UI's job; the command +// still returns an *exitError so main() owns the process exit code. +func (p *Printer) Errorf(format string, a ...any) { + p.out(" %s\n", p.paint("✖ "+fmt.Sprintf(format, a...), color.FgRed, color.Bold)) +} + +// Hintf prints dim contextual help (e.g. the line under a prompt). +func (p *Printer) Hintf(format string, a ...any) { + p.out(" %s\n", p.paint(fmt.Sprintf(format, a...), color.Faint)) +} + +// PromptHeader prints a bold-white label before a user-input prompt. +func (p *Printer) PromptHeader(label string) { + p.out("\n %s\n", p.paint(label, color.Bold, color.FgWhite)) +} diff --git a/internal/ui/ui_test.go b/internal/ui/ui_test.go new file mode 100644 index 0000000..a4b24b8 --- /dev/null +++ b/internal/ui/ui_test.go @@ -0,0 +1,80 @@ +package ui + +import ( + "bytes" + "strings" + "testing" +) + +// esc is the ANSI SGR prefix every color escape starts with. Its +// presence/absence in the output is how we assert color vs plain +// without pinning specific color codes (which could change). +const esc = "\x1b[" + +// TestNew_BufferDefaultsToPlain: a non-*os.File writer (a bytes.Buffer) +// can't be a terminal, so New auto-detects no-color. This is the path +// every test + CI run takes. +func TestNew_BufferDefaultsToPlain(t *testing.T) { + var buf bytes.Buffer + p := New(&buf) + p.Successf("done") + + if strings.Contains(buf.String(), esc) { + t.Errorf("default Printer on a buffer emitted ANSI codes: %q", buf.String()) + } + for _, want := range []string{"✔", "done"} { + if !strings.Contains(buf.String(), want) { + t.Errorf("plain output missing %q: %q", want, buf.String()) + } + } +} + +// TestWithColorFalse_OmitsANSI: forcing color off yields clean text +// across the helper set, while still emitting the structural content. +func TestWithColorFalse_OmitsANSI(t *testing.T) { + var buf bytes.Buffer + p := New(&buf, WithColor(false)) + p.Banner("tracebloc", "declarative ingestion") + p.Step(1, 3, "Discover cluster") + p.Warnf("PVC is %s", "ReadWriteOnce") + p.Hintf("pass --namespace to override") + + if strings.Contains(buf.String(), esc) { + t.Errorf("WithColor(false) still emitted ANSI: %q", buf.String()) + } + for _, want := range []string{"tracebloc", "Step 1/3", "Discover cluster", "ReadWriteOnce"} { + if !strings.Contains(buf.String(), want) { + t.Errorf("output missing %q: %q", want, buf.String()) + } + } +} + +// TestWithColorTrue_EmitsANSI: forcing color on wraps text in SGR codes +// even though the writer (a buffer) is not a real terminal — proving +// p.color is authoritative over fatih/color's package-level auto-detect. +func TestWithColorTrue_EmitsANSI(t *testing.T) { + var buf bytes.Buffer + p := New(&buf, WithColor(true)) + p.Successf("staged %d files", 5) + + out := buf.String() + if !strings.Contains(out, esc) { + t.Errorf("WithColor(true) emitted no ANSI codes: %q", out) + } + if !strings.Contains(out, "staged 5 files") { + t.Errorf("colored output missing message: %q", out) + } +} + +// TestNoColorEnv_DefaultsPlain exercises the NO_COLOR branch of +// autoColor: with it set, a freshly-constructed Printer stays plain. +// (t.Setenv restores the prior value when the test ends.) +func TestNoColorEnv_DefaultsPlain(t *testing.T) { + t.Setenv("NO_COLOR", "1") + var buf bytes.Buffer + New(&buf).Successf("ok") + + if strings.Contains(buf.String(), esc) { + t.Errorf("NO_COLOR set but ANSI emitted: %q", buf.String()) + } +} From c8c74ff610b0f1eb070d652804d9a0c79907ff36 Mon Sep 17 00:00:00 2001 From: Arturo Peroni Date: Wed, 3 Jun 2026 15:29:22 +0200 Subject: [PATCH 2/3] feat(#26): render dataset push pre-flight via internal/ui + add --plain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a persistent --plain root flag and a printerFor(cmd) helper, plus ui.Section/ui.Field. dataset push pre-flight now renders through the ui.Printer (colored Section/Field rows, ⚠ for the RWO warning) instead of ad-hoc fmt.Fprintf; behavior unchanged (the pinned RendersKeyFacts test still passes). Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/cli/coverage_test.go | 4 +- internal/cli/dataset.go | 74 +++++++++++++++++------------------ internal/cli/root.go | 20 ++++++++++ internal/ui/ui.go | 13 ++++++ internal/ui/ui_test.go | 20 ++++++++++ 5 files changed, 91 insertions(+), 40 deletions(-) diff --git a/internal/cli/coverage_test.go b/internal/cli/coverage_test.go index 0cbe474..2a60438 100644 --- a/internal/cli/coverage_test.go +++ b/internal/cli/coverage_test.go @@ -13,6 +13,7 @@ import ( "github.com/tracebloc/cli/internal/cluster" "github.com/tracebloc/cli/internal/push" + "github.com/tracebloc/cli/internal/ui" ) // TestPrintPushPreflight_RendersKeyFacts pins that the pre-flight @@ -46,7 +47,8 @@ func TestPrintPushPreflight_RendersKeyFacts(t *testing.T) { } var buf bytes.Buffer - printPushPreflight(&buf, layout, release, pvc, spec, false) + p := ui.New(&buf, ui.WithColor(false)) + printPushPreflight(p, layout, release, pvc, spec, false) out := buf.String() for _, want := range []string{ diff --git a/internal/cli/dataset.go b/internal/cli/dataset.go index 095dc1a..6da567f 100644 --- a/internal/cli/dataset.go +++ b/internal/cli/dataset.go @@ -15,6 +15,7 @@ import ( "github.com/tracebloc/cli/internal/push" "github.com/tracebloc/cli/internal/schema" "github.com/tracebloc/cli/internal/submit" + "github.com/tracebloc/cli/internal/ui" ) // newDatasetCmd wires the `tracebloc dataset` subtree. The dominant @@ -166,6 +167,7 @@ Exit codes: Detach: detach, IdempotencyKey: idempotencyKey, ImageDigest: imageDigest, + Printer: printerFor(cmd), }) }, } @@ -244,6 +246,10 @@ type runDatasetPushArgs struct { IngestorSAName string StagePodImage string + // Printer renders the pre-flight summary + status output. Built in + // the RunE from the persistent --plain flag (see printerFor). + Printer *ui.Printer + // Phase 4 (#152) fields. See the flag declarations for the // per-knob rationale; all three are optional. Detach bool @@ -471,7 +477,7 @@ func runDatasetPush(ctx context.Context, out, errOut io.Writer, a runDatasetPush // differs. Customers iterating on a bad layout see this // every attempt, so it's worth keeping skimmable: one fact // per line, aligned by column. - printPushPreflight(out, layout, release, pvc, spec, a.DryRun) + printPushPreflight(a.Printer, layout, release, pvc, spec, a.DryRun) // 8. Dry-run stop. Acknowledged success. if a.DryRun { @@ -622,76 +628,66 @@ func runDatasetPush(ctx context.Context, out, errOut io.Writer, a runDatasetPush // types) because the formatting is policy and lives with the CLI, // not the data. func printPushPreflight( - out io.Writer, + p *ui.Printer, layout *push.LocalLayout, release *cluster.ParentRelease, pvc *cluster.SharedPVC, spec map[string]any, dryRun bool, ) { - // Explicit-discard the writer errors throughout — same rationale - // as cli/cluster.go and cli/ingest.go: a pipe-write failure - // shouldn't convert success into failure. The exit code is - // the contract. cat, _ := spec["category"].(string) - _, _ = fmt.Fprintf(out, "Local dataset:\n") - _, _ = fmt.Fprintf(out, " root: %s\n", layout.Root) + p.Section("Local dataset") + p.Field("root", layout.Root) switch { case push.IsTabular(cat): - _, _ = fmt.Fprintf(out, " data CSV: %s\n", layout.LabelsCSV) + p.Field("data CSV", layout.LabelsCSV) if sch, ok := spec["schema"].(map[string]string); ok { - _, _ = fmt.Fprintf(out, " columns: %d\n", len(sch)) + p.Field("columns", fmt.Sprintf("%d", len(sch))) } case push.IsText(cat): dir := push.TextSidecarDir(cat) - _, _ = fmt.Fprintf(out, " labels.csv: %s\n", layout.LabelsCSV) - _, _ = fmt.Fprintf(out, " %-15s%d files\n", dir+":", len(layout.Sidecars[dir])) + p.Field("labels.csv", layout.LabelsCSV) + p.Field(dir, fmt.Sprintf("%d files", len(layout.Sidecars[dir]))) if _, ok := layout.ExtraFiles["tokenizer.json"]; ok { - _, _ = fmt.Fprintf(out, " %-15s%s\n", "tokenizer:", "tokenizer.json") + p.Field("tokenizer", "tokenizer.json") } default: - _, _ = fmt.Fprintf(out, " labels.csv: %s\n", layout.LabelsCSV) - _, _ = fmt.Fprintf(out, " images: %d files\n", len(layout.Images)) + p.Field("labels.csv", layout.LabelsCSV) + p.Field("images", fmt.Sprintf("%d files", len(layout.Images))) if anns := layout.Sidecars["annotations"]; len(anns) > 0 { - _, _ = fmt.Fprintf(out, " annotations: %d files\n", len(anns)) + p.Field("annotations", fmt.Sprintf("%d files", len(anns))) } } - _, _ = fmt.Fprintf(out, " total size: %s\n", push.HumanBytes(layout.TotalBytes)) - _, _ = fmt.Fprintln(out) + p.Field("total size", push.HumanBytes(layout.TotalBytes)) - _, _ = fmt.Fprintf(out, "Target cluster:\n") - _, _ = fmt.Fprintf(out, " release: %s (chart %s)\n", release.ReleaseName, release.ChartVersion) - _, _ = fmt.Fprintf(out, " jobs-manager: %s\n", release.JobsManagerService) - _, _ = fmt.Fprintf(out, " shared PVC: %s (%s)\n", pvc.ClaimName, pvc.Phase) + p.Section("Target cluster") + p.Field("release", fmt.Sprintf("%s (chart %s)", release.ReleaseName, release.ChartVersion)) + p.Field("jobs-manager", release.JobsManagerService) + p.Field("shared PVC", fmt.Sprintf("%s (%s)", pvc.ClaimName, pvc.Phase)) if !pvc.IsReadWriteMany() { - // Warn but don't block — RWO clusters still work, the - // scheduler will co-locate the stage Pod with the existing - // mounter. Phase 3 PR-b will surface the same warning at - // pod-create time too. - _, _ = fmt.Fprintf(out, " access: %v (warn: not ReadWriteMany — stage Pod will co-locate)\n", pvc.AccessModes) + // Warn but don't block — RWO clusters still work; the scheduler + // co-locates the stage Pod with the existing mounter. + p.Warnf("PVC is %v, not ReadWriteMany — the stage Pod will co-locate with the existing mounter", pvc.AccessModes) } - _, _ = fmt.Fprintln(out) - _, _ = fmt.Fprintf(out, "Synthesized ingest spec:\n") - _, _ = fmt.Fprintf(out, " table: %s\n", spec["table"]) - _, _ = fmt.Fprintf(out, " category: %s\n", spec["category"]) - _, _ = fmt.Fprintf(out, " intent: %s\n", spec["intent"]) + p.Section("Synthesized ingest spec") + p.Field("table", fmt.Sprintf("%v", spec["table"])) + p.Field("category", fmt.Sprintf("%v", spec["category"])) + p.Field("intent", fmt.Sprintf("%v", spec["intent"])) switch lbl := spec["label"].(type) { case string: - _, _ = fmt.Fprintf(out, " label column: %s\n", lbl) + p.Field("label column", lbl) case map[string]any: - _, _ = fmt.Fprintf(out, " label column: %v (policy: %v)\n", lbl["column"], lbl["policy"]) + p.Field("label column", fmt.Sprintf("%v (policy: %v)", lbl["column"], lbl["policy"])) } if tc, ok := spec["time_column"].(string); ok && tc != "" { - _, _ = fmt.Fprintf(out, " time column: %s\n", tc) + p.Field("time column", tc) } - _, _ = fmt.Fprintf(out, " destination: %s\n", push.FinalDestPrefix(spec["table"].(string))) - _, _ = fmt.Fprintln(out) + p.Field("destination", push.FinalDestPrefix(spec["table"].(string))) if !dryRun { - _, _ = fmt.Fprintf(out, "Next: stage %d files (%s) for table %q\n", + p.Infof("Next: stage %d files (%s) for table %q", layout.FileCount(), push.HumanBytes(layout.TotalBytes), spec["table"]) - _, _ = fmt.Fprintln(out) } } diff --git a/internal/cli/root.go b/internal/cli/root.go index d7d9b47..e2b8205 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -8,6 +8,8 @@ package cli import ( "github.com/spf13/cobra" + + "github.com/tracebloc/cli/internal/ui" ) // BuildInfo carries metadata that main.go pulls from -ldflags. We @@ -65,6 +67,13 @@ what's planned next.`, SilenceUsage: true, } + // Persistent flags apply to the root and every subcommand. --plain + // disables color + decorative output; ui.New also auto-plains on a + // non-TTY and honors $NO_COLOR, so this is the explicit opt-out for + // CI / log capture where stdout might still look like a terminal. + root.PersistentFlags().Bool("plain", false, + "disable color and decorative output (also honors $NO_COLOR)") + // Subcommands. New phases append here. root.AddCommand(newVersionCmd(info)) root.AddCommand(newIngestCmd()) @@ -73,3 +82,14 @@ what's planned next.`, return root } + +// printerFor builds a ui.Printer for a command's stdout, honoring the +// persistent --plain flag. Color / TTY / NO_COLOR auto-detection lives +// in ui.New; --plain just forces it off. Commands call this at the top +// of their RunE. +func printerFor(cmd *cobra.Command) *ui.Printer { + if plain, _ := cmd.Flags().GetBool("plain"); plain { + return ui.New(cmd.OutOrStdout(), ui.WithColor(false)) + } + return ui.New(cmd.OutOrStdout()) +} diff --git a/internal/ui/ui.go b/internal/ui/ui.go index 240f924..81082c9 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -148,3 +148,16 @@ func (p *Printer) Hintf(format string, a ...any) { func (p *Printer) PromptHeader(label string) { p.out("\n %s\n", p.paint(label, color.Bold, color.FgWhite)) } + +// Section prints a bold section header preceded by a blank line. Used +// to group related Field rows (e.g. "Target cluster"). +func (p *Printer) Section(title string) { + p.out("\n %s\n", p.paint(title, color.Bold)) +} + +// Field prints an aligned, dim-labelled key/value row beneath a +// Section: " label: value". The label is padded to a fixed +// width so values line up within a section. +func (p *Printer) Field(label, value string) { + p.out(" %s %s\n", p.paint(fmt.Sprintf("%-14s", label+":"), color.Faint), value) +} diff --git a/internal/ui/ui_test.go b/internal/ui/ui_test.go index a4b24b8..e605a45 100644 --- a/internal/ui/ui_test.go +++ b/internal/ui/ui_test.go @@ -66,6 +66,26 @@ func TestWithColorTrue_EmitsANSI(t *testing.T) { } } +// TestSectionAndField_Plain: the aligned key/value rendering used by +// cluster info + dataset push pre-flight stays clean (no ANSI) and +// surfaces both label and value when color is off. +func TestSectionAndField_Plain(t *testing.T) { + var buf bytes.Buffer + p := New(&buf, WithColor(false)) + p.Section("Target cluster") + p.Field("release", "ingdemo (chart 1.4.2)") + + out := buf.String() + if strings.Contains(out, esc) { + t.Errorf("plain Section/Field emitted ANSI: %q", out) + } + for _, want := range []string{"Target cluster", "release:", "ingdemo (chart 1.4.2)"} { + if !strings.Contains(out, want) { + t.Errorf("output missing %q: %q", want, out) + } + } +} + // TestNoColorEnv_DefaultsPlain exercises the NO_COLOR branch of // autoColor: with it set, a freshly-constructed Printer stays plain. // (t.Setenv restores the prior value when the test ends.) From b3fa8f11e1a2e8ca1ba1de8368c3c88ac1fa7616 Mon Sep 17 00:00:00 2001 From: Arturo Peroni Date: Wed, 3 Jun 2026 15:48:27 +0200 Subject: [PATCH 3/3] feat(#26): render cluster info via internal/ui MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cluster info now prints through the ui.Printer (Section/Field + a green ✔ ready line) instead of raw fmt.Fprintf, matching the dataset push pre-flight. Test updated for the *ui.Printer signature (still asserts exit-3 on a bad kubeconfig). Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/cli/cluster.go | 48 +++++++++++++++-------------------- internal/cli/coverage_test.go | 2 +- 2 files changed, 22 insertions(+), 28 deletions(-) diff --git a/internal/cli/cluster.go b/internal/cli/cluster.go index fd9da91..ca12976 100644 --- a/internal/cli/cluster.go +++ b/internal/cli/cluster.go @@ -10,6 +10,7 @@ import ( "github.com/spf13/cobra" "github.com/tracebloc/cli/internal/cluster" + "github.com/tracebloc/cli/internal/ui" ) // newClusterCmd wires the `tracebloc cluster` subtree. Today it has @@ -86,7 +87,7 @@ Exit codes: RunE: func(cmd *cobra.Command, _ []string) error { return runClusterInfo( cmd.Context(), - cmd.OutOrStdout(), + printerFor(cmd), kubeconfigPath, contextOverride, nsOverride, ingestorSAName, tokenExpiry, @@ -111,7 +112,7 @@ Exit codes: func runClusterInfo( ctx context.Context, - out interface{ Write([]byte) (int, error) }, + p *ui.Printer, kubeconfigPath, contextOverride, nsOverride string, ingestorSAOverride string, tokenExpiry int64, @@ -133,16 +134,10 @@ func runClusterInfo( return &exitError{code: 3, err: err} } - // Explicit-discard the writer errors: same rationale as - // internal/cli/ingest.go — the exit code is the contract, and a - // pipe-write failure shouldn't convert success into failure. If - // the downstream consumer disappears mid-write we still finish - // cleanly. errcheck wants this acknowledged. - _, _ = fmt.Fprintf(out, "Kubeconfig:\n") - _, _ = fmt.Fprintf(out, " context: %s\n", resolved.Context) - _, _ = fmt.Fprintf(out, " server: %s\n", resolved.ServerURL) - _, _ = fmt.Fprintf(out, " namespace: %s\n", resolved.Namespace) - _, _ = fmt.Fprintln(out) + p.Section("Kubeconfig") + p.Field("context", resolved.Context) + p.Field("server", resolved.ServerURL) + p.Field("namespace", resolved.Namespace) // Discover the parent release. release, err := cluster.DiscoverParentRelease(ctx, cs, resolved.Namespace) @@ -161,18 +156,17 @@ func runClusterInfo( release.IngestorSAName = ingestorSAOverride } - _, _ = fmt.Fprintf(out, "Parent release:\n") - _, _ = fmt.Fprintf(out, " name: %s\n", release.ReleaseName) - _, _ = fmt.Fprintf(out, " chart version: %s\n", release.ChartVersion) - _, _ = fmt.Fprintf(out, " app version: %s\n", release.AppVersion) - _, _ = fmt.Fprintf(out, " jobs-manager: %s\n", release.JobsManagerService) - _, _ = fmt.Fprintf(out, " ingestor SA: %s/%s\n", resolved.Namespace, release.IngestorSAName) + p.Section("Parent release") + p.Field("name", release.ReleaseName) + p.Field("chart version", release.ChartVersion) + p.Field("app version", release.AppVersion) + p.Field("jobs-manager", release.JobsManagerService) + p.Field("ingestor SA", fmt.Sprintf("%s/%s", resolved.Namespace, release.IngestorSAName)) digest := release.IngestorImageDigest if digest == "" { digest = "" } - _, _ = fmt.Fprintf(out, " ingestor img: %s\n", digest) - _, _ = fmt.Fprintln(out) + p.Field("ingestor img", digest) // Mint a token (or fall back). The audience is intentionally // nil today — jobs-manager's TokenReview accepts the default @@ -187,16 +181,16 @@ func runClusterInfo( } hash := sha256.Sum256([]byte(tok.Token)) - _, _ = fmt.Fprintf(out, "Ingestor SA token:\n") - _, _ = fmt.Fprintf(out, " source: %s\n", tok.Source) - _, _ = fmt.Fprintf(out, " sha256[:8]: %s\n", hex.EncodeToString(hash[:8])) + p.Section("Ingestor SA token") + p.Field("source", tok.Source.String()) + p.Field("sha256[:8]", hex.EncodeToString(hash[:8])) if tok.ExpirationSeconds > 0 { exp := time.Duration(tok.ExpirationSeconds) * time.Second - _, _ = fmt.Fprintf(out, " expires in: ~%s (server may cap shorter)\n", exp) + p.Field("expires in", fmt.Sprintf("~%s (server may cap shorter)", exp)) } else { - _, _ = fmt.Fprintf(out, " expires in: never (static-secret fallback)\n") + p.Field("expires in", "never (static-secret fallback)") } - _, _ = fmt.Fprintln(out) - _, _ = fmt.Fprintln(out, "Ready for `tracebloc dataset push`.") + + p.Successf("Ready for `tracebloc dataset push`.") return nil } diff --git a/internal/cli/coverage_test.go b/internal/cli/coverage_test.go index 2a60438..152beaf 100644 --- a/internal/cli/coverage_test.go +++ b/internal/cli/coverage_test.go @@ -94,7 +94,7 @@ func TestRunClusterInfo_BadKubeconfigExitsThree(t *testing.T) { } var buf bytes.Buffer - err := runClusterInfo(context.Background(), &buf, bad, "", "", "", 600) + err := runClusterInfo(context.Background(), ui.New(&buf), bad, "", "", "", 600) if err == nil { t.Fatal("runClusterInfo with a broken kubeconfig returned nil; want an exitError") }