| 1 | // Terminal colour for the operator CLI. Colour here is chrome, never |
| 2 | // content: every line still says exactly what it said before, and a pipe, |
| 3 | // a log file or a CI run sees byte-identical output to what it saw before |
| 4 | // this file existed. That rule is what makes it safe to sprinkle — the |
| 5 | // e2e tests exec the binary with its stdout on a pipe, so none of them can |
| 6 | // be broken by a palette change. |
| 7 | // |
| 8 | // The decision is per WRITER, not global, because the CLI's print seam |
| 9 | // (cli.go's `stdout` var) is swapped for a buffer by in-process tests: a |
| 10 | // package-level "are we on a terminal" flag read from os.Stdout would leak |
| 11 | // escape codes into those buffers on a developer's laptop while staying |
| 12 | // clean in CI. A buffer is not an *os.File, so it never gets colour. |
| 13 | package main |
| 14 | |
| 15 | import ( |
| 16 | "bytes" |
| 17 | "fmt" |
| 18 | "io" |
| 19 | "os" |
| 20 | "strings" |
| 21 | "sync" |
| 22 | "text/tabwriter" |
| 23 | ) |
| 24 | |
| 25 | // SGR codes. Deliberately the 8-colour set plus dim/bold: those render |
| 26 | // sanely against both light and dark terminal themes, which 256-colour |
| 27 | // picks do not. |
| 28 | const ( |
| 29 | ansiReset = "\x1b[0m" |
| 30 | ansiBold = "\x1b[1m" |
| 31 | ansiDim = "\x1b[2m" |
| 32 | ansiRed = "\x1b[31m" |
| 33 | ansiGreen = "\x1b[32m" |
| 34 | ansiYellow = "\x1b[33m" |
| 35 | ansiCyan = "\x1b[36m" |
| 36 | ) |
| 37 | |
| 38 | // colors is a palette bound to one writer. The zero value is the no-colour |
| 39 | // palette, so anything that forgets to ask still prints plain text. |
| 40 | type colors struct{ on bool } |
| 41 | |
| 42 | // colorFor decides whether the writer wants colour. Anything that is not a |
| 43 | // real file — a test buffer, a bytes.Buffer behind the `stdout` seam — gets |
| 44 | // the plain palette without consulting the environment at all. |
| 45 | func colorFor(w io.Writer) colors { |
| 46 | f, ok := w.(*os.File) |
| 47 | if !ok { |
| 48 | return colors{} |
| 49 | } |
| 50 | return colors{on: colorEnabled(f)} |
| 51 | } |
| 52 | |
| 53 | // colorEnabled is the precedence chain, most explicit first: |
| 54 | // |
| 55 | // CARLOS_COLOR=always|never an operator's own override, wins over all |
| 56 | // NO_COLOR=<anything> the cross-tool convention (no-color.org) |
| 57 | // TERM unset or "dumb" the terminal has told us it cannot |
| 58 | // otherwise colour iff this writer is a terminal |
| 59 | func colorEnabled(f *os.File) bool { |
| 60 | switch strings.ToLower(strings.TrimSpace(os.Getenv("CARLOS_COLOR"))) { |
| 61 | case "always", "force", "1", "true", "yes": |
| 62 | return true |
| 63 | case "never", "0", "false", "no": |
| 64 | return false |
| 65 | } |
| 66 | if os.Getenv("NO_COLOR") != "" { |
| 67 | return false |
| 68 | } |
| 69 | if t := os.Getenv("TERM"); t == "" || t == "dumb" { |
| 70 | return false |
| 71 | } |
| 72 | return isTerminal(f) |
| 73 | } |
| 74 | |
| 75 | var ( |
| 76 | stdoutIsTTY = sync.OnceValue(func() bool { return charDevice(os.Stdout) }) |
| 77 | stderrIsTTY = sync.OnceValue(func() bool { return charDevice(os.Stderr) }) |
| 78 | ) |
| 79 | |
| 80 | // isTerminal answers for the two files every CLI verb writes to without a |
| 81 | // syscall per line; anything else is stat'd on the spot (only tests do |
| 82 | // that, and only a handful of times). |
| 83 | func isTerminal(f *os.File) bool { |
| 84 | switch f { |
| 85 | case os.Stdout: |
| 86 | return stdoutIsTTY() |
| 87 | case os.Stderr: |
| 88 | return stderrIsTTY() |
| 89 | } |
| 90 | return charDevice(f) |
| 91 | } |
| 92 | |
| 93 | // charDevice is the dependency-free isatty: a pipe, a regular file and a |
| 94 | // socket all fail it, which is exactly the set we must not colour. (The |
| 95 | // repo has no isatty dependency and does not need one for this.) |
| 96 | func charDevice(f *os.File) bool { |
| 97 | if f == nil { |
| 98 | return false |
| 99 | } |
| 100 | fi, err := f.Stat() |
| 101 | if err != nil { |
| 102 | return false |
| 103 | } |
| 104 | return fi.Mode()&os.ModeCharDevice != 0 |
| 105 | } |
| 106 | |
| 107 | // The vocabulary is semantic, not chromatic: call sites say what a span of |
| 108 | // text MEANS and this file decides what that looks like. |
| 109 | // |
| 110 | // muted context the operator did not ask for — banners, "nothing to do" |
| 111 | // ok a write actually happened |
| 112 | // warn a dry run, a skip, a bypassed rail |
| 113 | // bad a refusal or an error |
| 114 | // head a heading or a table header row |
| 115 | // link a URL or a code to type |
| 116 | func (c colors) muted(s string) string { return c.wrap(ansiDim, s) } |
| 117 | func (c colors) ok(s string) string { return c.wrap(ansiGreen, s) } |
| 118 | func (c colors) warn(s string) string { return c.wrap(ansiYellow, s) } |
| 119 | func (c colors) bad(s string) string { return c.wrap(ansiRed, s) } |
| 120 | func (c colors) head(s string) string { return c.wrap(ansiBold, s) } |
| 121 | func (c colors) link(s string) string { return c.wrap(ansiCyan, s) } |
| 122 | |
| 123 | func (c colors) wrap(code, s string) string { |
| 124 | if !c.on || s == "" { |
| 125 | return s |
| 126 | } |
| 127 | return code + s + ansiReset |
| 128 | } |
| 129 | |
| 130 | // mutedf and warnf print one whole line in one style. They take the same |
| 131 | // format string the call site had before (trailing newline and all) so the |
| 132 | // only thing that changes at a call site is which function it calls. Whole |
| 133 | // lines earn a style only when the whole line is one thought: a "nothing to |
| 134 | // do" verdict the operator can skim past (muted), or a refusal to act |
| 135 | // (warn). Anywhere the line mixes a verb with real content, style the verb |
| 136 | // and leave the content plain — a paragraph of green is noise. |
| 137 | func mutedf(w io.Writer, format string, a ...any) { |
| 138 | fmt.Fprintln(w, colorFor(w).muted(strings.TrimSuffix(fmt.Sprintf(format, a...), "\n"))) |
| 139 | } |
| 140 | |
| 141 | func warnf(w io.Writer, format string, a ...any) { |
| 142 | fmt.Fprintln(w, colorFor(w).warn(strings.TrimSuffix(fmt.Sprintf(format, a...), "\n"))) |
| 143 | } |
| 144 | |
| 145 | // printUsing prints the "which console, as whom" banner nearly every |
| 146 | // API-mode verb opens with. Dimmed, because it is context for the lines |
| 147 | // under it rather than the answer anybody typed the command to get. |
| 148 | func printUsing(w io.Writer, sess *apiSession) { |
| 149 | c := colorFor(w) |
| 150 | line := fmt.Sprintf("using %s as %s", sess.console, sess.address) |
| 151 | if d := configDestination(sess.console); d != "" { |
| 152 | line += " (destination " + d + ")" |
| 153 | } |
| 154 | fmt.Fprintln(w, c.muted(line)) |
| 155 | } |
| 156 | |
| 157 | // printProposed is the one line every guarded write (promote, rollback, a |
| 158 | // `carlos pipeline` edit) prints when the console answers 202 instead of |
| 159 | // landing the change (release pipelines v2 §4, Task 10 controller ruling 1): |
| 160 | // a bearer token can never carry a passkey quorum, so the change is |
| 161 | // recorded rather than refused outright, and the operator's next step is the |
| 162 | // console URL, not a retry of this same command. |
| 163 | func printProposed(w io.Writer, e *apiProposedError) { |
| 164 | c := colorFor(w) |
| 165 | fmt.Fprintf(w, "%s — needs %d confirmation(s); finish at %s\n pending id %s\n", |
| 166 | c.warn("proposed"), e.Needed, e.ConsoleURL, e.PendingID) |
| 167 | } |
| 168 | |
| 169 | // errorWriter paints whole lines with the "something failed" style. Every |
| 170 | // verb in this binary reports failure through log.Fatal, so wrapping the |
| 171 | // standard logger's output once (main) colours all of them without touching |
| 172 | // a single call site. It is a no-op wherever stderr is not a terminal, which |
| 173 | // includes every daemon this binary also runs: `carlos edge` and `carlos |
| 174 | // agent` log into journald, and journald must not receive escape codes. |
| 175 | type errorWriter struct { |
| 176 | w io.Writer |
| 177 | pal colors |
| 178 | } |
| 179 | |
| 180 | func newErrorWriter(w io.Writer) io.Writer { |
| 181 | pal := colorFor(w) |
| 182 | if !pal.on { |
| 183 | return w |
| 184 | } |
| 185 | return errorWriter{w: w, pal: pal} |
| 186 | } |
| 187 | |
| 188 | func (e errorWriter) Write(p []byte) (int, error) { |
| 189 | line := strings.TrimSuffix(string(p), "\n") |
| 190 | if _, err := io.WriteString(e.w, e.pal.bad(line)+"\n"); err != nil { |
| 191 | return 0, err |
| 192 | } |
| 193 | // The caller is told its whole write landed: it did, styling aside. |
| 194 | return len(p), nil |
| 195 | } |
| 196 | |
| 197 | // table is the CLI's tabwriter with a bold header row. It buffers because |
| 198 | // tabwriter measures a cell as its bytes: styling the header before it goes |
| 199 | // through the writer would count the eight bytes of escape codes as column |
| 200 | // content and pad every row below it that much too wide. So cells go in |
| 201 | // plain, the layout is computed plain, and only the finished header LINE is |
| 202 | // styled on its way out. |
| 203 | type table struct { |
| 204 | *tabwriter.Writer |
| 205 | buf *bytes.Buffer |
| 206 | out io.Writer |
| 207 | pal colors |
| 208 | } |
| 209 | |
| 210 | func newTable(out io.Writer) *table { |
| 211 | buf := &bytes.Buffer{} |
| 212 | return &table{ |
| 213 | Writer: tabwriter.NewWriter(buf, 0, 4, 2, ' ', 0), |
| 214 | buf: buf, |
| 215 | out: out, |
| 216 | pal: colorFor(out), |
| 217 | } |
| 218 | } |
| 219 | |
| 220 | func (t *table) Flush() error { |
| 221 | if err := t.Writer.Flush(); err != nil { |
| 222 | return err |
| 223 | } |
| 224 | s := t.buf.String() |
| 225 | t.buf.Reset() |
| 226 | if t.pal.on { |
| 227 | if i := strings.IndexByte(s, '\n'); i > 0 { |
| 228 | // Style the header text but not the padding that follows it: |
| 229 | // a bold run of trailing spaces is invisible on most terminals |
| 230 | // and an underline-ish smear on a few. |
| 231 | line := s[:i] |
| 232 | text := strings.TrimRight(line, " ") |
| 233 | s = t.pal.head(text) + line[len(text):] + s[i:] |
| 234 | } |
| 235 | } |
| 236 | _, err := io.WriteString(t.out, s) |
| 237 | return err |
| 238 | } |
| 239 | |