// Terminal colour for the operator CLI. Colour here is chrome, never // content: every line still says exactly what it said before, and a pipe, // a log file or a CI run sees byte-identical output to what it saw before // this file existed. That rule is what makes it safe to sprinkle — the // e2e tests exec the binary with its stdout on a pipe, so none of them can // be broken by a palette change. // // The decision is per WRITER, not global, because the CLI's print seam // (cli.go's `stdout` var) is swapped for a buffer by in-process tests: a // package-level "are we on a terminal" flag read from os.Stdout would leak // escape codes into those buffers on a developer's laptop while staying // clean in CI. A buffer is not an *os.File, so it never gets colour. package main import ( "bytes" "fmt" "io" "os" "strings" "sync" "text/tabwriter" ) // SGR codes. Deliberately the 8-colour set plus dim/bold: those render // sanely against both light and dark terminal themes, which 256-colour // picks do not. const ( ansiReset = "\x1b[0m" ansiBold = "\x1b[1m" ansiDim = "\x1b[2m" ansiRed = "\x1b[31m" ansiGreen = "\x1b[32m" ansiYellow = "\x1b[33m" ansiCyan = "\x1b[36m" ) // colors is a palette bound to one writer. The zero value is the no-colour // palette, so anything that forgets to ask still prints plain text. type colors struct{ on bool } // colorFor decides whether the writer wants colour. Anything that is not a // real file — a test buffer, a bytes.Buffer behind the `stdout` seam — gets // the plain palette without consulting the environment at all. func colorFor(w io.Writer) colors { f, ok := w.(*os.File) if !ok { return colors{} } return colors{on: colorEnabled(f)} } // colorEnabled is the precedence chain, most explicit first: // // CARLOS_COLOR=always|never an operator's own override, wins over all // NO_COLOR= the cross-tool convention (no-color.org) // TERM unset or "dumb" the terminal has told us it cannot // otherwise colour iff this writer is a terminal func colorEnabled(f *os.File) bool { switch strings.ToLower(strings.TrimSpace(os.Getenv("CARLOS_COLOR"))) { case "always", "force", "1", "true", "yes": return true case "never", "0", "false", "no": return false } if os.Getenv("NO_COLOR") != "" { return false } if t := os.Getenv("TERM"); t == "" || t == "dumb" { return false } return isTerminal(f) } var ( stdoutIsTTY = sync.OnceValue(func() bool { return charDevice(os.Stdout) }) stderrIsTTY = sync.OnceValue(func() bool { return charDevice(os.Stderr) }) ) // isTerminal answers for the two files every CLI verb writes to without a // syscall per line; anything else is stat'd on the spot (only tests do // that, and only a handful of times). func isTerminal(f *os.File) bool { switch f { case os.Stdout: return stdoutIsTTY() case os.Stderr: return stderrIsTTY() } return charDevice(f) } // charDevice is the dependency-free isatty: a pipe, a regular file and a // socket all fail it, which is exactly the set we must not colour. (The // repo has no isatty dependency and does not need one for this.) func charDevice(f *os.File) bool { if f == nil { return false } fi, err := f.Stat() if err != nil { return false } return fi.Mode()&os.ModeCharDevice != 0 } // The vocabulary is semantic, not chromatic: call sites say what a span of // text MEANS and this file decides what that looks like. // // muted context the operator did not ask for — banners, "nothing to do" // ok a write actually happened // warn a dry run, a skip, a bypassed rail // bad a refusal or an error // head a heading or a table header row // link a URL or a code to type func (c colors) muted(s string) string { return c.wrap(ansiDim, s) } func (c colors) ok(s string) string { return c.wrap(ansiGreen, s) } func (c colors) warn(s string) string { return c.wrap(ansiYellow, s) } func (c colors) bad(s string) string { return c.wrap(ansiRed, s) } func (c colors) head(s string) string { return c.wrap(ansiBold, s) } func (c colors) link(s string) string { return c.wrap(ansiCyan, s) } func (c colors) wrap(code, s string) string { if !c.on || s == "" { return s } return code + s + ansiReset } // mutedf and warnf print one whole line in one style. They take the same // format string the call site had before (trailing newline and all) so the // only thing that changes at a call site is which function it calls. Whole // lines earn a style only when the whole line is one thought: a "nothing to // do" verdict the operator can skim past (muted), or a refusal to act // (warn). Anywhere the line mixes a verb with real content, style the verb // and leave the content plain — a paragraph of green is noise. func mutedf(w io.Writer, format string, a ...any) { fmt.Fprintln(w, colorFor(w).muted(strings.TrimSuffix(fmt.Sprintf(format, a...), "\n"))) } func warnf(w io.Writer, format string, a ...any) { fmt.Fprintln(w, colorFor(w).warn(strings.TrimSuffix(fmt.Sprintf(format, a...), "\n"))) } // printUsing prints the "which console, as whom" banner nearly every // API-mode verb opens with. Dimmed, because it is context for the lines // under it rather than the answer anybody typed the command to get. func printUsing(w io.Writer, sess *apiSession) { c := colorFor(w) line := fmt.Sprintf("using %s as %s", sess.console, sess.address) if d := configDestination(sess.console); d != "" { line += " (destination " + d + ")" } fmt.Fprintln(w, c.muted(line)) } // printProposed is the one line every guarded write (promote, rollback, a // `carlos pipeline` edit) prints when the console answers 202 instead of // landing the change (release pipelines v2 §4, Task 10 controller ruling 1): // a bearer token can never carry a passkey quorum, so the change is // recorded rather than refused outright, and the operator's next step is the // console URL, not a retry of this same command. func printProposed(w io.Writer, e *apiProposedError) { c := colorFor(w) fmt.Fprintf(w, "%s — needs %d confirmation(s); finish at %s\n pending id %s\n", c.warn("proposed"), e.Needed, e.ConsoleURL, e.PendingID) } // errorWriter paints whole lines with the "something failed" style. Every // verb in this binary reports failure through log.Fatal, so wrapping the // standard logger's output once (main) colours all of them without touching // a single call site. It is a no-op wherever stderr is not a terminal, which // includes every daemon this binary also runs: `carlos edge` and `carlos // agent` log into journald, and journald must not receive escape codes. type errorWriter struct { w io.Writer pal colors } func newErrorWriter(w io.Writer) io.Writer { pal := colorFor(w) if !pal.on { return w } return errorWriter{w: w, pal: pal} } func (e errorWriter) Write(p []byte) (int, error) { line := strings.TrimSuffix(string(p), "\n") if _, err := io.WriteString(e.w, e.pal.bad(line)+"\n"); err != nil { return 0, err } // The caller is told its whole write landed: it did, styling aside. return len(p), nil } // table is the CLI's tabwriter with a bold header row. It buffers because // tabwriter measures a cell as its bytes: styling the header before it goes // through the writer would count the eight bytes of escape codes as column // content and pad every row below it that much too wide. So cells go in // plain, the layout is computed plain, and only the finished header LINE is // styled on its way out. type table struct { *tabwriter.Writer buf *bytes.Buffer out io.Writer pal colors } func newTable(out io.Writer) *table { buf := &bytes.Buffer{} return &table{ Writer: tabwriter.NewWriter(buf, 0, 4, 2, ' ', 0), buf: buf, out: out, pal: colorFor(out), } } func (t *table) Flush() error { if err := t.Writer.Flush(); err != nil { return err } s := t.buf.String() t.buf.Reset() if t.pal.on { if i := strings.IndexByte(s, '\n'); i > 0 { // Style the header text but not the padding that follows it: // a bold run of trailing spaces is invisible on most terminals // and an underline-ish smear on a few. line := s[:i] text := strings.TrimRight(line, " ") s = t.pal.head(text) + line[len(text):] + s[i:] } } _, err := io.WriteString(t.out, s) return err }