carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

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.
13package main
14
15import (
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.
28const (
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.
40type 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.
45func 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
59func 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
75var (
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).
83func 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.)
96func 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
116func (c colors) muted(s string) string { return c.wrap(ansiDim, s) }
117func (c colors) ok(s string) string { return c.wrap(ansiGreen, s) }
118func (c colors) warn(s string) string { return c.wrap(ansiYellow, s) }
119func (c colors) bad(s string) string { return c.wrap(ansiRed, s) }
120func (c colors) head(s string) string { return c.wrap(ansiBold, s) }
121func (c colors) link(s string) string { return c.wrap(ansiCyan, s) }
122
123func (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.
137func mutedf(w io.Writer, format string, a ...any) {
138 fmt.Fprintln(w, colorFor(w).muted(strings.TrimSuffix(fmt.Sprintf(format, a...), "\n")))
139}
140
141func 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.
148func 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.
163func 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.
175type errorWriter struct {
176 w io.Writer
177 pal colors
178}
179
180func 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
188func (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.
203type table struct {
204 *tabwriter.Writer
205 buf *bytes.Buffer
206 out io.Writer
207 pal colors
208}
209
210func 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
220func (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