carlos / platform Public

CLI: colour the output, but only ever on a terminal (#93)

Colour here is chrome, never content. A write that happened reads green
(added/shipped/promoted/restored), a dry run or a bypassed rail yellow, a
refusal red, and everything the operator did not ask for is dimmed — the
"using <console> as <you>" banner most of all, since it opened nearly every
API-mode verb at full brightness above the line anyone actually ran the
command to read.

The rule that makes it safe to sprinkle: the decision is per WRITER, not
global. colorFor() answers only for an *os.File that is a char device, so a
pipe, a redirect, a CI run and the bytes.Buffer behind cli.go's `stdout`
seam all get exactly the output they got before this commit. That is why the
whole suite passes untouched. Precedence is CARLOS_COLOR=always|never, then
NO_COLOR, then TERM unset/dumb, then isatty; no new dependency (isatty here
is Stat().Mode()&os.ModeCharDevice).

Three things worth naming:

- newTable() replaces the seven raw tabwriter.NewWriter sites and buffers on
  purpose. tabwriter measures a cell by its bytes, so styling a header
  before it goes through would count eight bytes of escape codes as column
  content and pad every row below it that much too wide. Cells go in plain,
  the layout is computed plain, and only the finished header LINE is bolded
  on the way out. A test strips the codes back out and asserts the columns
  did not move.
- log.SetOutput(newErrorWriter(os.Stderr)) in main colours every log.Fatal
  in the binary in one place instead of at a few hundred call sites — and is
  a no-op for `carlos edge` and `carlos agent`, whose stderr is journald.
- The 23 copies of the banner line collapse into one printUsing().

Verbs get colour on the verb, not the sentence: a paragraph of green is
noise. Whole lines are styled only where the whole line is one thought — a
"nothing to do" verdict to skim past, or a refusal.

Verified with the built binary under a real pty (script -qec) as well as
piped: colours under the pty, plain bytes through a pipe, plain under
NO_COLOR=1, coloured under CARLOS_COLOR=always through a pipe.

go build/vet/test ./... clean, all 20 packages; gofmt clean.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
James Adam 084b86fff72b546bfb5001f117d2beaa300224e1 parent 535038f
10 files changed, +514 −137
  • README.md +8 −0
  • accounts.go +37 −30
  • appconfig_cli.go +24 −25
  • apps.go +15 −13
  • auth.go +17 −14
  • cli.go +45 −43
  • color.go +222 −0
  • color_test.go +128 −0
  • instances_cli.go +13 −12
  • main.go +5 −0
diff --git a/README.md b/README.md
index f9c453f57..a775ae81f 100644
--- a/README.md
+++ b/README.md
@@ -84,6 +84,14 @@ defaulting to `ops`:
carlos ship -account kass -app blog ...
carlos add -account kass -app blog blog.example.com
+**Colour.** The CLI colours its output when — and only when — it is writing
+to a terminal: a write that happened reads green, a dry run or a bypassed
+rail yellow, a refusal red, and context you did not ask for (the `using
+<console> as <you>` banner, "nothing to do" verdicts, follow-up hints) is
+dimmed. Redirect it, pipe it, or run it in CI and every byte is what it
+always was, so parsing scripts and test assertions are unaffected. Override
+with `CARLOS_COLOR=always|never`; `NO_COLOR=1` and `TERM=dumb` are honoured.
+
**Who the CLI is.** `carlos auth login` gives the command line an identity,
and that identity owns the accounts it creates. It authenticates against the
**deployment's own console** — already a registered Keymail OAuth client and
diff --git a/accounts.go b/accounts.go
index a703033c4..b398cc583 100644
--- a/accounts.go
+++ b/accounts.go
@@ -21,7 +21,6 @@ import (
"os"
"slices"
"strings"
- "text/tabwriter"
"time"
"github.com/carlosframework/platform/internal/domains"
@@ -149,8 +148,9 @@ func createAccountInBucket(regPath, name, owner string) {
if err := r.PutAccount(id, sqid, name); err != nil {
log.Fatalf("accounts create: materialize: %v", err)
}
- fmt.Printf("created account %q\n id: %d\n sqid: %s\n owner: %s\n", name, id, sqid, owner)
- fmt.Printf("use it with: carlos ship -account %s -app <app> ...\n", sqid)
+ fmt.Printf("%s %q\n id: %d\n sqid: %s\n owner: %s\n",
+ colorFor(os.Stdout).ok("created account"), name, id, sqid, owner)
+ mutedf(os.Stdout, "use it with: carlos ship -account %s -app <app> ...\n", sqid)
fmt.Printf("%s becomes its owner in the console the next time the console materializes\n"+
"accounts from the bucket (on start, or on any `console member` command).\n", owner)
}
@@ -165,7 +165,7 @@ func createAccountInBucket(regPath, name, owner string) {
// gap that produced accounts nobody could reach.
func createAccountViaConsole(cred *credential, regPath, name string) {
if warn := credentialPermWarning(); warn != "" {
- fmt.Fprintln(os.Stderr, warn)
+ warnf(os.Stderr, "%s", warn)
}
c := newConsoleClient(cred.Console, cred.Token)
var out struct {
@@ -179,23 +179,25 @@ func createAccountViaConsole(cred *credential, regPath, name string) {
map[string]string{"name": name}, &out); err != nil {
log.Fatalf("accounts create: %s: %v", cred.Console, err)
}
- fmt.Printf("created account %q\n id: %d\n sqid: %s\n owner: %s (you)\n", out.Name, out.ID, out.Sqid, out.Owner)
- fmt.Printf(" open: %s\n", out.URL)
- fmt.Printf("use it with: carlos ship -account %s -app <app> ...\n", out.Sqid)
+ pal := colorFor(os.Stdout)
+ fmt.Printf("%s %q\n id: %d\n sqid: %s\n owner: %s (you)\n",
+ pal.ok("created account"), out.Name, out.ID, out.Sqid, out.Owner)
+ fmt.Printf(" open: %s\n", pal.link(out.URL))
+ mutedf(os.Stdout, "use it with: carlos ship -account %s -app <app> ...\n", out.Sqid)
// The registry row is this box's local materialization of what the bucket
// already says. Best-effort, unlike the bucket-mint path: the account
// exists and is owned no matter what happens here, and a build machine
// with no registry at all is a perfectly normal place to run this.
r, err := registry.Open(regPath)
if err != nil {
- fmt.Printf("\n(no registry at %s, so nothing was materialized locally: %v —\n"+
- "harmless: the bucket is the control plane and every box reconciles from it.)\n", regPath, err)
+ mutedf(os.Stdout, "\n(no registry at %s, so nothing was materialized locally: %v —\n"+
+ "harmless: the bucket is the control plane and every box reconciles from it.)", regPath, err)
return
}
defer r.Close()
if err := r.PutAccount(out.ID, out.Sqid, out.Name); err != nil {
- fmt.Printf("\n(could not materialize the registry row here: %v — harmless: the account\n"+
- "exists in the bucket and every box reconciles from it.)\n", err)
+ mutedf(os.Stdout, "\n(could not materialize the registry row here: %v — harmless: the account\n"+
+ "exists in the bucket and every box reconciles from it.)", err)
}
}
@@ -229,7 +231,7 @@ func runAccountsList(args []string) {
}
apps[acct][rt.App] = true
}
- w := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
+ w := newTable(os.Stdout)
fmt.Fprintln(w, "SQID\tNAME\tAPPS\tID")
for _, a := range accts {
fmt.Fprintf(w, "%s\t%s\t%d\t%d\n", a.Sqid, a.Name, len(apps[a.Sqid]), a.ID)
@@ -277,7 +279,7 @@ func runAccountsMigrate(args []string) {
// assuming a prefix.
plan, srcAcct, hadProduction := migrationPlan(ctx, store, *app, *from, *to, dst)
if len(plan) == 0 {
- fmt.Printf("nothing under %s — nothing to copy\n", release.AppPrefix(srcAcct, *app))
+ mutedf(os.Stdout, "nothing under %s — nothing to copy\n", release.AppPrefix(srcAcct, *app))
}
// Pointers the legacy-source path refused to overwrite because a newer
// qualified pointer already exists; verification must not compare these
@@ -285,7 +287,7 @@ func runAccountsMigrate(args []string) {
var keptPointers []string
for _, c := range plan {
if *dryRun {
- fmt.Printf("would copy %s -> %s\n", c.from, c.to)
+ warnf(os.Stdout, "would copy %s -> %s\n", c.from, c.to)
continue
}
body, _, err := store.Get(ctx, c.from)
@@ -318,8 +320,9 @@ func runAccountsMigrate(args []string) {
// pointer was NOT taken rather than left to find out from adopt.
if srcAcct == release.LegacySource {
if cur, _, err := store.Get(ctx, c.to); err == nil && string(cur) != string(body) {
- fmt.Printf("KEPT %s — a qualified pointer already exists and differs from the legacy\n"+
- "copy; the qualified one is live state and wins. The legacy pointer was not taken.\n", c.to)
+ fmt.Printf("%s %s — a qualified pointer already exists and differs from the legacy\n"+
+ "copy; the qualified one is live state and wins. The legacy pointer was not taken.\n",
+ colorFor(os.Stdout).warn("KEPT"), c.to)
keptPointers = append(keptPointers, c.to)
continue
}
@@ -328,9 +331,10 @@ func runAccountsMigrate(args []string) {
case err != nil:
log.Fatalf("accounts migrate: write %s: %v", c.to, err)
case changed:
- fmt.Printf("converged %s -> %s (the destination pointer disagreed)\n", c.from, c.to)
+ fmt.Printf("%s %s -> %s (the destination pointer disagreed)\n",
+ colorFor(os.Stdout).ok("converged"), c.from, c.to)
default:
- fmt.Printf("already copied %s -> %s\n", c.from, c.to)
+ mutedf(os.Stdout, "already copied %s -> %s\n", c.from, c.to)
}
continue
}
@@ -344,16 +348,16 @@ func runAccountsMigrate(args []string) {
"overwrite an immutable release object. Nothing has been deleted; work out which\n"+
"of the two is the real %s before going further.", c.to, *app)
}
- fmt.Printf("already copied %s -> %s\n", c.from, c.to)
+ mutedf(os.Stdout, "already copied %s -> %s\n", c.from, c.to)
continue
}
- fmt.Printf("copied %s -> %s\n", c.from, c.to)
+ fmt.Printf("%s %s -> %s\n", colorFor(os.Stdout).ok("copied"), c.from, c.to)
}
// The one object that does not travel. Loud, because nothing downstream
// will notice: a destination with no flag simply defaults to "not
// production", so the app stops baking silently rather than failing.
if hadProduction {
- fmt.Println("\nNOT COPIED:")
+ fmt.Printf("\n%s\n", colorFor(os.Stdout).warn("NOT COPIED:"))
fmt.Printf(" %s: carried a production declaration — re-declare it at the destination "+
"(the flag is signed per-account and cannot be copied)\n", *app)
}
@@ -371,13 +375,13 @@ func runAccountsMigrate(args []string) {
continue
}
if *dryRun {
- fmt.Printf("would stamp account %s on route %s (was %q)\n", *to, rt.Host, rt.Account)
+ warnf(os.Stdout, "would stamp account %s on route %s (was %q)\n", *to, rt.Host, rt.Account)
continue
}
if err := r.SetAccount(rt.Host, *to); err != nil {
log.Fatalf("accounts migrate: stamp %s: %v", rt.Host, err)
}
- fmt.Printf("stamped account %s on route %s\n", *to, rt.Host)
+ fmt.Printf("%s account %s on route %s\n", colorFor(os.Stdout).ok("stamped"), *to, rt.Host)
}
// The console keeps its OWN app→account row, and this binary deliberately
@@ -387,7 +391,7 @@ func runAccountsMigrate(args []string) {
// output rather than in a runbook somebody has to find.
followUp := fmt.Sprintf("console migrate-app -app %s -to %s", *app, *to)
if *dryRun {
- fmt.Println("dry run: nothing was written")
+ warnf(os.Stdout, "dry run: nothing was written")
fmt.Printf("\nafterwards, on the console's box: %s\n", followUp)
return
}
@@ -496,7 +500,7 @@ func migrationPlan(ctx context.Context, store release.Store, app, from, to, dst
if len(keys) == 0 {
log.Fatalf("accounts migrate: nothing under %s — %s has no legacy objects to move", src, app)
}
- fmt.Printf("source: %s (the legacy, account-less keys)\n", src)
+ mutedf(os.Stdout, "source: %s (the legacy, account-less keys)\n", src)
var out []copyOp
hadProduction := false
for _, k := range keys {
@@ -548,9 +552,9 @@ func migrationPlan(ctx context.Context, store release.Store, app, from, to, dst
fmt.Printf("exists in both (that is what the runtime reads), and the legacy copy of the\n")
fmt.Printf("rest, so no rollback target is lost.\n")
} else if len(opsKeys) > 0 {
- fmt.Printf("source: %s\n", opsPrefix)
+ mutedf(os.Stdout, "source: %s\n", opsPrefix)
} else if len(legacyKeys) > 0 {
- fmt.Printf("source: %s\n", legacyPrefix)
+ mutedf(os.Stdout, "source: %s\n", legacyPrefix)
}
// Qualified first, then legacy for anything it did not cover — the
@@ -601,7 +605,8 @@ func verifyMigratedPointers(ctx context.Context, store release.Store, app, srcAc
// construction, and the exemption was already printed.
if slices.Contains(kept, key) {
channel := strings.TrimSuffix(strings.TrimPrefix(key, prefix), ".json")
- fmt.Printf("verified %s: kept the existing qualified pointer (legacy copy was stale)\n", channel)
+ fmt.Printf("%s %s: kept the existing qualified pointer (legacy copy was stale)\n",
+ colorFor(os.Stdout).ok("verified"), channel)
continue
}
channel := strings.TrimSuffix(strings.TrimPrefix(key, prefix), ".json")
@@ -653,10 +658,12 @@ func verifyMigratedPointers(ctx context.Context, store release.Store, app, srcAc
if err != nil {
log.Fatalf("accounts migrate: verify: %s/%s does not resolve under %s: %v", app, channel, dstAcct, err)
}
- fmt.Printf("verified %s: %s serves %s under %s\n", channel, app, p.Version, dstAcct)
+ fmt.Printf("%s %s: %s serves %s under %s\n",
+ colorFor(os.Stdout).ok("verified"), channel, app, p.Version, dstAcct)
continue
}
- fmt.Printf("verified %s: byte-identical to what %s serves (set CARLOS_RELEASE_PUBKEY to see versions)\n", channel, srcAcct)
+ fmt.Printf("%s %s: byte-identical to what %s serves (set CARLOS_RELEASE_PUBKEY to see versions)\n",
+ colorFor(os.Stdout).ok("verified"), channel, srcAcct)
}
}
diff --git a/appconfig_cli.go b/appconfig_cli.go
index 977c5513f..59b10801a 100644
--- a/appconfig_cli.go
+++ b/appconfig_cli.go
@@ -12,7 +12,6 @@ import (
"os"
"sort"
"strings"
- "text/tabwriter"
"github.com/carlosframework/platform/internal/appconfig"
"github.com/carlosframework/platform/internal/release"
@@ -211,7 +210,7 @@ func runEnvSet(args []string) {
log.Fatal(err)
}
if sess != nil {
- fmt.Fprintf(os.Stdout, "using %s as %s\n", sess.console, sess.address)
+ printUsing(os.Stdout, sess)
set := make(map[string]appconfig.Var, len(kv))
for k, v := range kv {
set[k] = appconfig.Var{Kind: "env", Value: v}
@@ -220,14 +219,14 @@ func runEnvSet(args []string) {
apiConfigApply{Environment: *environment, Set: set}); err != nil {
log.Fatal(err)
}
- fmt.Printf("set %d env var(s) for %s (%s)\n", len(kv), *app, bundleLabel(*environment))
+ fmt.Printf("%s %d env var(s) for %s (%s)\n", colorFor(os.Stdout).ok("set"), len(kv), *app, bundleLabel(*environment))
return
}
if err := saveEnvVars(context.Background(), openStore(), acctOf(), *app, *environment, kv); err != nil {
log.Fatal(err)
}
- fmt.Printf("set %d env var(s) for %s (%s)\n", len(kv), *app, bundleLabel(*environment))
+ fmt.Printf("%s %d env var(s) for %s (%s)\n", colorFor(os.Stdout).ok("set"), len(kv), *app, bundleLabel(*environment))
}
func runEnvUnset(args []string) {
@@ -247,13 +246,13 @@ func runEnvUnset(args []string) {
log.Fatal(err)
}
if sess != nil {
- fmt.Fprintf(os.Stdout, "using %s as %s\n", sess.console, sess.address)
+ printUsing(os.Stdout, sess)
removed, err := sess.configApply(context.Background(), apiAccount(acctRaw, acctIDRaw), *app,
apiConfigApply{Environment: *environment, Unset: fs.Args()})
if err != nil {
log.Fatal(err)
}
- fmt.Printf("unset %d of %d var(s) for %s (%s)\n", removed, fs.NArg(), *app, bundleLabel(*environment))
+ fmt.Printf("%s %d of %d var(s) for %s (%s)\n", colorFor(os.Stdout).ok("unset"), removed, fs.NArg(), *app, bundleLabel(*environment))
return
}
@@ -261,7 +260,7 @@ func runEnvUnset(args []string) {
if err != nil {
log.Fatal(err)
}
- fmt.Printf("unset %d of %d var(s) for %s (%s)\n", removed, fs.NArg(), *app, bundleLabel(*environment))
+ fmt.Printf("%s %d of %d var(s) for %s (%s)\n", colorFor(os.Stdout).ok("unset"), removed, fs.NArg(), *app, bundleLabel(*environment))
}
func runEnvList(args []string) {
@@ -281,7 +280,7 @@ func runEnvList(args []string) {
log.Fatal(err)
}
if sess != nil {
- fmt.Fprintf(os.Stdout, "using %s as %s\n", sess.console, sess.address)
+ printUsing(os.Stdout, sess)
cfg, err := sess.configGet(context.Background(), apiAccount(acctRaw, acctIDRaw), *app, *environment)
if err != nil {
log.Fatal(err)
@@ -323,7 +322,7 @@ func envVarsFromAPI(vars []apiConfigVar) map[string]string {
// byte-identical between the direct and API branches of runEnvList, which
// differ only in HOW they fetch execDelivery/vars.
func printEnvList(app, environment, execDelivery string, vars map[string]string) {
- fmt.Printf("%s (%s):\n", app, bundleLabel(environment))
+ fmt.Printf("%s\n", colorFor(os.Stdout).head(fmt.Sprintf("%s (%s):", app, bundleLabel(environment))))
// exec_delivery is app-level only and never printed for a named
// environment's listing, matching where it can ever actually be SET
// (runEnvExecDelivery refuses -environment before it reaches appconfig
@@ -336,7 +335,7 @@ func printEnvList(app, environment, execDelivery string, vars map[string]string)
keys = append(keys, k)
}
sort.Strings(keys)
- w := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
+ w := newTable(os.Stdout)
fmt.Fprintln(w, "KEY\tVALUE")
for _, k := range keys {
fmt.Fprintf(w, "%s\t%s\n", k, vars[k])
@@ -389,7 +388,7 @@ func runEnvExecDelivery(args []string) {
log.Fatal(err)
}
if sess != nil {
- fmt.Fprintf(os.Stdout, "using %s as %s\n", sess.console, sess.address)
+ printUsing(os.Stdout, sess)
// The wire vocabulary is "file"/"off", never "" — value is "" for
// the local SetExecDelivery convention (clearing the field), but
// the console's PUT decodes ExecDelivery as a pointer where nil
@@ -406,9 +405,9 @@ func runEnvExecDelivery(args []string) {
log.Fatal(err)
}
if value == "" {
- fmt.Printf("exec-delivery off for %s\n", *app)
+ fmt.Printf("%s off for %s\n", colorFor(os.Stdout).ok("exec-delivery"), *app)
} else {
- fmt.Printf("exec-delivery %s for %s\n", value, *app)
+ fmt.Printf("%s %s for %s\n", colorFor(os.Stdout).ok("exec-delivery"), value, *app)
}
}
@@ -427,7 +426,7 @@ func runEnvEnvironments(args []string) {
log.Fatal(err)
}
if sess != nil {
- fmt.Fprintf(os.Stdout, "using %s as %s\n", sess.console, sess.address)
+ printUsing(os.Stdout, sess)
cfg, err := sess.configGet(context.Background(), apiAccount(acctRaw, acctIDRaw), *app, "")
if err != nil {
log.Fatal(err)
@@ -477,16 +476,16 @@ func runEnvSync(args []string) {
log.Fatal(err)
}
if sess != nil {
- fmt.Fprintf(os.Stdout, "using %s as %s\n", sess.console, sess.address)
+ printUsing(os.Stdout, sess)
if err := sess.configSync(context.Background(), apiAccount(acctRaw, acctIDRaw), *app); err != nil {
log.Fatal(err)
}
} else if err := appconfig.TouchGeneration(context.Background(), openStore(), acctOf(), *app); err != nil {
log.Fatal(err)
}
- fmt.Printf("config sync requested for %s — exec-backed instances converge within seconds; "+
+ fmt.Printf("%s for %s — exec-backed instances converge within seconds; "+
"unit-backed routes within seconds where the box's carlos-adopt.path trigger is armed, "+
- "else on the next hourly root pass\n", *app)
+ "else on the next hourly root pass\n", colorFor(os.Stdout).ok("config sync requested"), *app)
}
const secretsUsage = `usage: carlos secrets <command>
@@ -608,7 +607,7 @@ func runSecretsSet(args []string) {
log.Fatal(err)
}
if sess != nil {
- fmt.Fprintf(os.Stdout, "using %s as %s\n", sess.console, sess.address)
+ printUsing(os.Stdout, sess)
pub := secretsPubKeyAPI(sess, apiAccount(acctRaw, acctIDRaw), *app)
set := make(map[string]appconfig.Var, len(kv))
for k, v := range kv {
@@ -622,7 +621,7 @@ func runSecretsSet(args []string) {
apiConfigApply{Environment: *environment, Set: set}); err != nil {
log.Fatal(err)
}
- fmt.Printf("set %d secret(s) for %s (%s)\n", len(kv), *app, bundleLabel(*environment))
+ fmt.Printf("%s %d secret(s) for %s (%s)\n", colorFor(os.Stdout).ok("set"), len(kv), *app, bundleLabel(*environment))
return
}
@@ -638,7 +637,7 @@ func runSecretsSet(args []string) {
if err := saveSecrets(context.Background(), openStore(), acctOf(), *app, *environment, sealed); err != nil {
log.Fatal(err)
}
- fmt.Printf("set %d secret(s) for %s (%s)\n", len(kv), *app, bundleLabel(*environment))
+ fmt.Printf("%s %d secret(s) for %s (%s)\n", colorFor(os.Stdout).ok("set"), len(kv), *app, bundleLabel(*environment))
}
func runSecretsUnset(args []string) {
@@ -658,13 +657,13 @@ func runSecretsUnset(args []string) {
log.Fatal(err)
}
if sess != nil {
- fmt.Fprintf(os.Stdout, "using %s as %s\n", sess.console, sess.address)
+ printUsing(os.Stdout, sess)
removed, err := sess.configApply(context.Background(), apiAccount(acctRaw, acctIDRaw), *app,
apiConfigApply{Environment: *environment, Unset: fs.Args()})
if err != nil {
log.Fatal(err)
}
- fmt.Printf("unset %d of %d secret(s) for %s (%s)\n", removed, fs.NArg(), *app, bundleLabel(*environment))
+ fmt.Printf("%s %d of %d secret(s) for %s (%s)\n", colorFor(os.Stdout).ok("unset"), removed, fs.NArg(), *app, bundleLabel(*environment))
return
}
@@ -672,7 +671,7 @@ func runSecretsUnset(args []string) {
if err != nil {
log.Fatal(err)
}
- fmt.Printf("unset %d of %d secret(s) for %s (%s)\n", removed, fs.NArg(), *app, bundleLabel(*environment))
+ fmt.Printf("%s %d of %d secret(s) for %s (%s)\n", colorFor(os.Stdout).ok("unset"), removed, fs.NArg(), *app, bundleLabel(*environment))
}
// printSecretsList renders `secrets list`'s output — header line, then
@@ -680,7 +679,7 @@ func runSecretsUnset(args []string) {
// and API branches of runSecretsList, which differ only in HOW they fetch
// the key set.
func printSecretsList(app, environment string, keys []string) {
- fmt.Printf("%s (%s):\n", app, bundleLabel(environment))
+ fmt.Printf("%s\n", colorFor(os.Stdout).head(fmt.Sprintf("%s (%s):", app, bundleLabel(environment))))
sort.Strings(keys)
for _, k := range keys {
fmt.Println(k)
@@ -704,7 +703,7 @@ func runSecretsList(args []string) {
log.Fatal(err)
}
if sess != nil {
- fmt.Fprintf(os.Stdout, "using %s as %s\n", sess.console, sess.address)
+ printUsing(os.Stdout, sess)
cfg, err := sess.configGet(context.Background(), apiAccount(acctRaw, acctIDRaw), *app, *environment)
if err != nil {
log.Fatal(err)
diff --git a/apps.go b/apps.go
index d255eab49..9f5587589 100644
--- a/apps.go
+++ b/apps.go
@@ -179,7 +179,7 @@ func runAppsDelete(args []string) {
if mode == "signed-in" {
acct := apiAccount(acctRaw, acctIDRaw)
- fmt.Fprintf(stdout, "using %s as %s\n", sess.console, sess.address)
+ printUsing(stdout, sess)
res, err := sess.trashApp(context.Background(), acct, app)
if err != nil {
log.Fatal(err)
@@ -268,9 +268,10 @@ func headlessTrash(ctx context.Context, store release.Store, pub ed25519.PublicK
// back releases and channels but no routes — repeated at delete time as well
// as restore time because nothing else in the terminal will say it twice.
func printTrashed(app, restorableUntil string) {
- fmt.Fprintf(stdout, "trashed %s — restorable until %s (carlos apps restore %s)\n", app, restorableUntil, app)
- fmt.Fprintf(stdout, "restoring brings back its releases and channels, but NO routes — "+
- "carlos add <host> to serve it again\n")
+ fmt.Fprintf(stdout, "%s %s — restorable until %s (carlos apps restore %s)\n",
+ colorFor(stdout).ok("trashed"), app, restorableUntil, app)
+ mutedf(stdout, "restoring brings back its releases and channels, but NO routes — "+
+ "carlos add <host> to serve it again")
}
func runAppsRestore(args []string) {
@@ -298,19 +299,19 @@ func runAppsRestore(args []string) {
if mode == "signed-in" {
acct := apiAccount(acctRaw, acctIDRaw)
- fmt.Fprintf(stdout, "using %s as %s\n", sess.console, sess.address)
+ printUsing(stdout, sess)
res, err := sess.restoreApp(context.Background(), acct, app)
if err != nil {
log.Fatal(err)
}
- fmt.Fprintf(stdout, "restored %s\n", app)
+ fmt.Fprintf(stdout, "%s %s\n", colorFor(stdout).ok("restored"), app)
if res.Note != "" {
// The server's own wording (handleAPIRestoreApp) — printed
// verbatim rather than reworded here, so the two modes cannot
// drift apart on what they tell someone about routing.
fmt.Fprintln(stdout, res.Note)
} else {
- fmt.Fprintf(stdout, "%s has NO routes — carlos add <host> to serve it again\n", app)
+ mutedf(stdout, "%s has NO routes — carlos add <host> to serve it again\n", app)
}
return
}
@@ -325,9 +326,9 @@ func runAppsRestore(args []string) {
if err := release.RestoreApp(context.Background(), store, acctID, app); err != nil {
log.Fatalf("apps restore: %v", err)
}
- fmt.Fprintf(stdout, "restored %s\n", app)
- fmt.Fprintf(stdout, "%s is restored with its releases and channels intact, and with NO routes — "+
- "carlos add <host> to serve it again\n", app)
+ fmt.Fprintf(stdout, "%s %s\n", colorFor(stdout).ok("restored"), app)
+ mutedf(stdout, "%s is restored with its releases and channels intact, and with NO routes — "+
+ "carlos add <host> to serve it again", app)
}
func runAppsCreate(args []string) {
@@ -365,12 +366,13 @@ func runAppsCreate(args []string) {
"unset the store env and run: carlos auth login -console <url>")
}
acct := apiAccount(acctRaw, acctIDRaw)
- fmt.Fprintf(stdout, "using %s as %s\n", sess.console, sess.address)
+ printUsing(stdout, sess)
res, err := sess.createApp(context.Background(), acct, strings.TrimSpace(*app))
if err != nil {
log.Fatalf("apps create: %v", err)
}
- fmt.Fprintf(stdout, "created app %s in account %s — it's yours\n", res.App, res.Account)
- fmt.Fprintf(stdout, " open: %s\n", res.URL)
+ c := colorFor(stdout)
+ fmt.Fprintf(stdout, "%s %s in account %s — it's yours\n", c.ok("created app"), res.App, res.Account)
+ fmt.Fprintf(stdout, " open: %s\n", c.link(res.URL))
fmt.Fprintf(stdout, " ship: carlos ship -account %s -app %s ...\n", res.Account, res.App)
}
diff --git a/auth.go b/auth.go
index e8053f778..ade8dfe54 100644
--- a/auth.go
+++ b/auth.go
@@ -99,7 +99,7 @@ func RunAuth(args []string) {
os.Exit(1)
}
if err != nil {
- fmt.Fprintf(os.Stderr, "carlos auth %s: %v\n", args[0], err)
+ fmt.Fprintf(os.Stderr, "%s %v\n", colorFor(os.Stderr).bad("carlos auth "+args[0]+":"), err)
os.Exit(1)
}
}
@@ -631,9 +631,12 @@ func authLogin(out io.Writer, args []string) error {
}
// Printed, never opened: this frequently runs over SSH, where opening a
// browser would open it on the wrong machine.
- fmt.Fprintf(out, "\nOpen this in a browser signed in to %s:\n\n %s\n\n", base, link)
- fmt.Fprintf(out, "and type this code:\n\n %s\n\n", start.UserCode)
- fmt.Fprintf(out, "Waiting for approval (this code expires in %d minutes)…\n", start.ExpiresIn/60)
+ pal := colorFor(out)
+ fmt.Fprintf(out, "\nOpen this in a browser signed in to %s:\n\n %s\n\n", base, pal.link(link))
+ // The code is the one thing here a person has to read off the screen and
+ // type somewhere else, so it is the one thing given weight.
+ fmt.Fprintf(out, "and type this code:\n\n %s\n\n", pal.head(start.UserCode))
+ mutedf(out, "Waiting for approval (this code expires in %d minutes)…", start.ExpiresIn/60)
deadline := loginDeadline(time.Now(), start.ExpiresIn)
wait := pollWait(start.Interval)
@@ -682,12 +685,12 @@ func authLogin(out io.Writer, args []string) error {
// token first exists on this machine, and a pre-existing file with
// loose permissions is exactly when to say so.
if warn := credentialPermWarning(); warn != "" {
- fmt.Fprintln(out, warn)
+ warnf(out, "%s", warn)
}
- fmt.Fprintf(out, "\nLogged in to %s as %s.\n", base, poll.Address)
- fmt.Fprintf(out, "Token stored in %s (mode 0600); it expires %s.\n",
+ fmt.Fprintf(out, "\n%s to %s as %s.\n", pal.ok("Logged in"), base, poll.Address)
+ mutedf(out, "Token stored in %s (mode 0600); it expires %s.",
credentialsPath(), expires.Format("2006-01-02"))
- fmt.Fprintf(out, "Revoke it any time at %s/auth/cli/tokens\n", base)
+ mutedf(out, "Revoke it any time at %s/auth/cli/tokens", base)
return nil
default:
return fmt.Errorf("the console answered with an unknown status %q", poll.Status)
@@ -712,7 +715,7 @@ func authWhoami(out io.Writer, args []string) error {
return err
}
if warn := credentialPermWarning(); warn != "" {
- fmt.Fprintln(out, warn)
+ warnf(out, "%s", warn)
}
var who whoamiResponse
if _, err := newConsoleClient(c.Console, c.Token).
@@ -724,10 +727,10 @@ func authWhoami(out io.Writer, args []string) error {
}
fmt.Fprintf(out, "%s at %s\n", who.Address, c.Console)
if who.Label != "" {
- fmt.Fprintf(out, " terminal: %s\n", who.Label)
+ mutedf(out, " terminal: %s", who.Label)
}
if who.ExpiresAt != "" {
- fmt.Fprintf(out, " expires: %s\n", who.ExpiresAt)
+ mutedf(out, " expires: %s", who.ExpiresAt)
}
return nil
}
@@ -762,11 +765,11 @@ func authLogout(out io.Writer, args []string) error {
return err
}
if revokeErr != nil {
- fmt.Fprintf(out, "Forgot the credential for %s locally, but could not revoke it there: %v\n", origin, revokeErr)
- fmt.Fprintf(out, "It stays live until it expires — revoke it at %s/auth/cli/tokens\n", origin)
+ warnf(out, "Forgot the credential for %s locally, but could not revoke it there: %v", origin, revokeErr)
+ warnf(out, "It stays live until it expires — revoke it at %s/auth/cli/tokens", origin)
return nil
}
- fmt.Fprintf(out, "Logged out of %s — the token is revoked.\n", origin)
+ fmt.Fprintf(out, "%s of %s — the token is revoked.\n", colorFor(out).ok("Logged out"), origin)
return nil
}
diff --git a/cli.go b/cli.go
index 226a8e54d..78682b327 100644
--- a/cli.go
+++ b/cli.go
@@ -21,7 +21,6 @@ import (
"regexp"
"sort"
"strings"
- "text/tabwriter"
"time"
"github.com/carlosframework/platform/internal/appconfig"
@@ -159,7 +158,7 @@ func runAdd(args []string) {
}); err != nil {
log.Fatal(err)
}
- fmt.Printf("added %s\n", host)
+ fmt.Printf("%s %s\n", colorFor(os.Stdout).ok("added"), host)
}
func runRemove(args []string) {
@@ -177,7 +176,7 @@ func runRemove(args []string) {
if err := r.Delete(fs.Arg(0)); err != nil {
log.Fatal(err)
}
- fmt.Printf("removed %s\n", fs.Arg(0))
+ fmt.Printf("%s %s\n", colorFor(os.Stdout).ok("removed"), fs.Arg(0))
}
func runInstances(args []string) {
@@ -213,7 +212,7 @@ func runInstances(args []string) {
if err != nil {
log.Fatal(err)
}
- w := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
+ w := newTable(os.Stdout)
fmt.Fprintln(w, "HOST\tAPP\tKIND\tCHANNEL\tSTATUS\tVERSION\tHIB\tBACKING")
for _, t := range routes {
hib := ""
@@ -570,7 +569,7 @@ func runRoute(args []string) {
// applied structurally below for every candidate, not just
// here — so this exclusion is belt-and-braces for those, but
// load-bearing for a plain Platform apex.)
- fmt.Printf("%s: skipped — platform-minted route; use -host %s to change it explicitly\n", rt.Host, rt.Host)
+ warnf(os.Stdout, "%s: skipped — platform-minted route; use -host %s to change it explicitly\n", rt.Host, rt.Host)
continue
}
candidates = append(candidates, rt)
@@ -823,7 +822,8 @@ func runRoute(args []string) {
for _, msg := range refusals {
fmt.Fprintln(os.Stderr, msg)
}
- fmt.Fprintf(os.Stderr, "route: %d of %d route(s) refused — nothing written\n", len(refusals), len(candidates))
+ fmt.Fprintf(os.Stderr, "%s %d of %d route(s) refused — nothing written\n",
+ colorFor(os.Stderr).bad("route:"), len(refusals), len(candidates))
os.Exit(1)
}
@@ -868,7 +868,7 @@ func runRoute(args []string) {
prefix := ""
if *dryRun {
- prefix = "(dry run) "
+ prefix = colorFor(os.Stdout).warn("(dry run)") + " "
}
envLabel := func(e string) string {
if e == "" {
@@ -886,7 +886,7 @@ func runRoute(args []string) {
if chanVerdicts != nil {
v := chanVerdicts[i]
if v.kind == vNoop {
- fmt.Printf("%s: already following %s — nothing to do\n", v.rt.Host, *channel)
+ mutedf(os.Stdout, "%s: already following %s — nothing to do\n", v.rt.Host, *channel)
} else {
fmt.Printf("%s%s: %s -> %s (%s)\n", prefix, v.rt.Host, v.from, *channel, v.note)
}
@@ -894,14 +894,14 @@ func runRoute(args []string) {
if envVerdicts != nil {
v := envVerdicts[i]
if v.kind == eNoop {
- fmt.Printf("%s: environment already %s — nothing to do\n", v.rt.Host, envLabel(resolvedEnv))
+ mutedf(os.Stdout, "%s: environment already %s — nothing to do\n", v.rt.Host, envLabel(resolvedEnv))
} else {
fmt.Printf("%s%s: environment %s -> %s\n", prefix, v.rt.Host, envLabel(v.from), envLabel(resolvedEnv))
}
}
if *tunnel != "" && i == 0 {
if tunnelNoop {
- fmt.Printf("%s: tunnel already %s — nothing to do\n", candidates[0].Host, tunnelLabel(resolvedTunnel))
+ mutedf(os.Stdout, "%s: tunnel already %s — nothing to do\n", candidates[0].Host, tunnelLabel(resolvedTunnel))
} else {
fmt.Printf("%s%s: tunnel %s -> %s\n", prefix, candidates[0].Host, tunnelLabel(tunnelFrom), tunnelLabel(resolvedTunnel))
}
@@ -954,7 +954,7 @@ func runRouteGrant(r *registry.Registry, rt registry.Route, grantTok, revokeTok
prefix := ""
if dryRun {
- prefix = "(dry run) "
+ prefix = colorFor(os.Stdout).warn("(dry run)") + " "
}
var next string
if granting {
@@ -969,7 +969,7 @@ func runRouteGrant(r *registry.Registry, rt registry.Route, grantTok, revokeTok
log.Fatalf("route: %s: %v", rt.Host, err)
}
if cur.Has(tok) {
- fmt.Printf("%s: %s already granted — nothing to do\n", rt.Host, tok)
+ mutedf(os.Stdout, "%s: %s already granted — nothing to do\n", rt.Host, tok)
return
}
} else {
@@ -979,7 +979,7 @@ func runRouteGrant(r *registry.Registry, rt registry.Route, grantTok, revokeTok
if !cur.Has(tok) {
// A quiet success: the caller's goal is "not granted", not "I
// removed something".
- fmt.Printf("%s: %s was not granted — nothing to revoke\n", rt.Host, tok)
+ mutedf(os.Stdout, "%s: %s was not granted — nothing to revoke\n", rt.Host, tok)
return
}
}
@@ -1102,10 +1102,10 @@ func runRouteHibernate(r *registry.Registry, rt registry.Route, on, dryRun bool)
}
prefix := ""
if dryRun {
- prefix = "(dry run) "
+ prefix = colorFor(os.Stdout).warn("(dry run)") + " "
}
if rt.Hibernate == on {
- fmt.Printf("%s: hibernate already %v — nothing to do\n", rt.Host, on)
+ mutedf(os.Stdout, "%s: hibernate already %v — nothing to do\n", rt.Host, on)
return
}
if !dryRun {
@@ -1147,10 +1147,10 @@ func runRouteBacking(r *registry.Registry, rt registry.Route, backing string, dr
}
prefix := ""
if dryRun {
- prefix = "(dry run) "
+ prefix = colorFor(os.Stdout).warn("(dry run)") + " "
}
if rt.Backing == backing {
- fmt.Printf("%s: backing already %s — nothing to do\n", rt.Host, backingWord(backing))
+ mutedf(os.Stdout, "%s: backing already %s — nothing to do\n", rt.Host, backingWord(backing))
return
}
if !dryRun {
@@ -1524,7 +1524,7 @@ func runShip(args []string) {
if err := release.Ship(context.Background(), openStore(), acctOf(), m, files); err != nil {
log.Fatal(err)
}
- fmt.Fprintf(stdout, "shipped %s %s (%d artifacts)\n", *app, *ver, len(files))
+ fmt.Fprintf(stdout, "%s %s %s (%d artifacts)\n", colorFor(stdout).ok("shipped"), *app, *ver, len(files))
}
// readCapabilities is the ship-time declaration surface: a file holding
@@ -1644,15 +1644,15 @@ func runShipAPI(sess *apiSession, acct apiAcct, fs *flag.FlagSet, app, ver, kind
}
}
- fmt.Fprintf(stdout, "using %s as %s\n", sess.console, sess.address)
+ printUsing(stdout, sess)
res, err := sess.ship(context.Background(), acct, app, ver, kind, files, capsSet)
if err != nil {
return err
}
if res.NoOp {
- fmt.Fprintf(stdout, "already shipped %s %s — identical content, nothing to do\n", app, ver)
+ mutedf(stdout, "already shipped %s %s — identical content, nothing to do\n", app, ver)
} else {
- fmt.Fprintf(stdout, "shipped %s %s\n", app, ver)
+ fmt.Fprintf(stdout, "%s %s %s\n", colorFor(stdout).ok("shipped"), app, ver)
}
return nil
}
@@ -1689,15 +1689,15 @@ func runPromote(args []string) {
log.Fatal("hotfix is an operator act — use the direct path (CARLOS_DEPLOYMENT_BUCKET) or the console")
}
acct := apiAccount(acctRaw, acctIDRaw)
- fmt.Fprintf(stdout, "using %s as %s\n", sess.console, sess.address)
+ printUsing(stdout, sess)
res, err := sess.promote(context.Background(), acct, *app, version, channel)
if err != nil {
log.Fatal(err)
}
if res.NoOp {
- fmt.Fprintf(stdout, "%s %s is already on %s — nothing to do\n", *app, version, channel)
+ mutedf(stdout, "%s %s is already on %s — nothing to do\n", *app, version, channel)
} else {
- fmt.Fprintf(stdout, "promoted %s %s -> %s\n", *app, version, channel)
+ fmt.Fprintf(stdout, "%s %s %s -> %s\n", colorFor(stdout).ok("promoted"), *app, version, channel)
}
return
}
@@ -1710,11 +1710,13 @@ func runPromote(args []string) {
}
switch {
case res.NoOp:
- fmt.Fprintf(stdout, "%s %s is already on %s — nothing to do\n", *app, version, channel)
+ mutedf(stdout, "%s %s is already on %s — nothing to do\n", *app, version, channel)
case res.Hotfix:
- fmt.Fprintf(stdout, "promoted %s %s -> %s (HOTFIX: ladder bypassed, recorded)\n", *app, version, channel)
+ c := colorFor(stdout)
+ fmt.Fprintf(stdout, "%s %s %s -> %s %s\n", c.ok("promoted"), *app, version, channel,
+ c.warn("(HOTFIX: ladder bypassed, recorded)"))
default:
- fmt.Fprintf(stdout, "promoted %s %s -> %s\n", *app, version, channel)
+ fmt.Fprintf(stdout, "%s %s %s -> %s\n", colorFor(stdout).ok("promoted"), *app, version, channel)
}
}
@@ -1747,12 +1749,12 @@ func runChannels(args []string) {
}
if sess != nil {
acct := apiAccount(acctRaw, acctIDRaw)
- fmt.Fprintf(stdout, "using %s as %s\n", sess.console, sess.address)
+ printUsing(stdout, sess)
chans, err := sess.channels(context.Background(), acct, *app)
if err != nil {
log.Fatal(err)
}
- w := tabwriter.NewWriter(stdout, 0, 4, 2, ' ', 0)
+ w := newTable(stdout)
fmt.Fprintln(w, "CHANNEL\tVERSION\tPROMOTED")
for _, ch := range chans {
if ch.Err != "" {
@@ -1773,7 +1775,7 @@ func runChannels(args []string) {
log.Fatal(err)
}
pub := publicKey()
- w := tabwriter.NewWriter(stdout, 0, 4, 2, ' ', 0)
+ w := newTable(stdout)
fmt.Fprintln(w, "CHANNEL\tVERSION\tPROMOTED")
for _, k := range keys {
name := strings.TrimSuffix(strings.TrimPrefix(k, prefix), ".json")
@@ -1803,18 +1805,18 @@ func runRollback(args []string) {
}
if sess != nil {
acct := apiAccount(acctRaw, acctIDRaw)
- fmt.Fprintf(stdout, "using %s as %s\n", sess.console, sess.address)
+ printUsing(stdout, sess)
if err := sess.rollback(context.Background(), acct, *app, fs.Arg(0)); err != nil {
log.Fatal(err)
}
- fmt.Fprintf(stdout, "rolled back %s %s\n", *app, fs.Arg(0))
+ fmt.Fprintf(stdout, "%s %s %s\n", colorFor(stdout).ok("rolled back"), *app, fs.Arg(0))
return
}
if err := releaseSigner().Rollback(context.Background(), openStore(), acctOf(), *app, fs.Arg(0)); err != nil {
log.Fatal(err)
}
- fmt.Fprintf(stdout, "rolled back %s %s\n", *app, fs.Arg(0))
+ fmt.Fprintf(stdout, "%s %s %s\n", colorFor(stdout).ok("rolled back"), *app, fs.Arg(0))
}
// ledgerUsage lists every `carlos ledger` sub-verb — printed for a bare
@@ -1959,12 +1961,12 @@ func runLedgerAppend(args []string) {
}
sess := ledgerAPISession(*consoleFlag)
- fmt.Fprintf(stdout, "using %s as %s\n", sess.console, sess.address)
+ printUsing(stdout, sess)
a, err := sess.ledgerAppend(context.Background(), apiAccount(acctRaw, acctIDRaw), *app, name, json.RawMessage(raw))
if err != nil {
log.Fatal(err)
}
- fmt.Fprintf(stdout, "appended to %s: entry #%d (sha %s)\n", name, a.Count, a.SHA256[:12])
+ fmt.Fprintf(stdout, "%s %s: entry #%d (sha %s)\n", colorFor(stdout).ok("appended to"), name, a.Count, a.SHA256[:12])
}
// runLedgerHead prints HEAD.json verbatim — no "using ... as ..." banner
@@ -2008,7 +2010,7 @@ func runLedgerList(args []string) {
}
sess := ledgerAPISession(*consoleFlag)
- fmt.Fprintf(stdout, "using %s as %s\n", sess.console, sess.address)
+ printUsing(stdout, sess)
body, err := sess.ledgerList(context.Background(), apiAccount(acctRaw, acctIDRaw), *app)
if err != nil {
log.Fatal(err)
@@ -2023,7 +2025,7 @@ func runLedgerList(args []string) {
switch {
case res.Manifest == nil:
- fmt.Fprintln(stdout, "not published — nothing is public for this app yet")
+ mutedf(stdout, "not published — nothing is public for this app yet")
case res.Manifest.Public:
title := res.Manifest.Title
if title == "" {
@@ -2031,13 +2033,13 @@ func runLedgerList(args []string) {
}
fmt.Fprintf(stdout, "published %q: %s\n", title, strings.Join(res.Manifest.Ledgers, ", "))
default:
- fmt.Fprintln(stdout, "unpublished (public=false)")
+ mutedf(stdout, "unpublished (public=false)")
}
if len(res.Ledgers) == 0 {
- fmt.Fprintln(stdout, "no ledgers yet")
+ mutedf(stdout, "no ledgers yet")
return
}
- w := tabwriter.NewWriter(stdout, 0, 4, 2, ' ', 0)
+ w := newTable(stdout)
fmt.Fprintln(w, "LEDGER")
for _, n := range res.Ledgers {
fmt.Fprintln(w, n)
@@ -2064,7 +2066,7 @@ func runLedgerPublish(args []string) {
}
sess := ledgerAPISession(*consoleFlag)
- fmt.Fprintf(stdout, "using %s as %s\n", sess.console, sess.address)
+ printUsing(stdout, sess)
m := ledger.Manifest{Public: *public, Ledgers: names, Title: *title}
got, err := sess.ledgerPublish(context.Background(), apiAccount(acctRaw, acctIDRaw), *app, m)
if err != nil {
@@ -2103,7 +2105,7 @@ func runLedgerBlob(args []string) {
files := fs.Args()[1:]
sess := ledgerAPISession(*consoleFlag)
- fmt.Fprintf(stdout, "using %s as %s\n", sess.console, sess.address)
+ printUsing(stdout, sess)
for _, path := range files {
data, err := os.ReadFile(path)
if err != nil {
@@ -2147,7 +2149,7 @@ func runLedgerVerify(args []string) {
if err != nil {
log.Fatal(err)
}
- fmt.Fprintf(stdout, "verified: %d entries, head %s\n", res.Count, res.HeadSHA256[:12])
+ fmt.Fprintf(stdout, "%s %d entries, head %s\n", colorFor(stdout).ok("verified:"), res.Count, res.HeadSHA256[:12])
}
// defaultGrantTTL is how long a scoped key lives when the operator does not
diff --git a/color.go b/color.go
new file mode 100644
index 000000000..703055f83
--- /dev/null
+++ b/color.go
@@ -0,0 +1,222 @@
+// 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=<anything> 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)
+ fmt.Fprintln(w, c.muted(fmt.Sprintf("using %s as %s", sess.console, sess.address)))
+}
+
+// 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
+}
diff --git a/color_test.go b/color_test.go
new file mode 100644
index 000000000..a6d0a8ba4
--- /dev/null
+++ b/color_test.go
@@ -0,0 +1,128 @@
+package main
+
+import (
+ "bytes"
+ "os"
+ "strings"
+ "testing"
+)
+
+// pipeFile is a real *os.File that is NOT a terminal — the shape every CI
+// run, every `| less` and every e2e test's exec'd binary sees.
+func pipeFile(t *testing.T) *os.File {
+ t.Helper()
+ r, w, err := os.Pipe()
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { r.Close(); w.Close() })
+ return w
+}
+
+func TestColorEnabledPrecedence(t *testing.T) {
+ f := pipeFile(t)
+ cases := []struct {
+ name string
+ env map[string]string
+ want bool
+ }{
+ {"pipe with a terminal set", map[string]string{"TERM": "xterm-256color"}, false},
+ {"CARLOS_COLOR=always beats the pipe", map[string]string{"TERM": "xterm", "CARLOS_COLOR": "always"}, true},
+ {"CARLOS_COLOR=always beats NO_COLOR", map[string]string{"TERM": "xterm", "CARLOS_COLOR": "always", "NO_COLOR": "1"}, true},
+ {"CARLOS_COLOR=never", map[string]string{"TERM": "xterm", "CARLOS_COLOR": "never"}, false},
+ {"NO_COLOR", map[string]string{"TERM": "xterm", "NO_COLOR": "1"}, false},
+ {"TERM=dumb", map[string]string{"TERM": "dumb", "CARLOS_COLOR": ""}, false},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ for _, k := range []string{"TERM", "NO_COLOR", "CARLOS_COLOR"} {
+ t.Setenv(k, tc.env[k])
+ }
+ if got := colorEnabled(f); got != tc.want {
+ t.Fatalf("colorEnabled = %v, want %v", got, tc.want)
+ }
+ })
+ }
+}
+
+// A buffer is what the CLI's print seam holds under test. It must never be
+// coloured, whatever the environment says — including CARLOS_COLOR=always,
+// because an operator with that set in their shell would otherwise see every
+// in-process test fail on escape codes.
+func TestColorForNonFileIsAlwaysPlain(t *testing.T) {
+ t.Setenv("CARLOS_COLOR", "always")
+ t.Setenv("TERM", "xterm-256color")
+ var buf bytes.Buffer
+ if c := colorFor(&buf); c.on {
+ t.Fatal("a bytes.Buffer was given a colour palette")
+ }
+ mutedf(&buf, "using %s as %s", "https://console.example", "vicky@keymail.dev")
+ if got, want := buf.String(), "using https://console.example as vicky@keymail.dev\n"; got != want {
+ t.Fatalf("mutedf wrote %q, want %q", got, want)
+ }
+}
+
+func TestPaletteWrapsOnlyWhenOn(t *testing.T) {
+ on := colors{on: true}
+ if got, want := on.ok("shipped"), ansiGreen+"shipped"+ansiReset; got != want {
+ t.Fatalf("ok = %q, want %q", got, want)
+ }
+ if got := on.muted(""); got != "" {
+ t.Fatalf("an empty string got wrapped: %q", got)
+ }
+ off := colors{}
+ for name, got := range map[string]string{
+ "muted": off.muted("x"), "ok": off.ok("x"), "warn": off.warn("x"),
+ "bad": off.bad("x"), "head": off.head("x"), "link": off.link("x"),
+ } {
+ if got != "x" {
+ t.Fatalf("%s with colour off = %q, want %q", name, got, "x")
+ }
+ }
+}
+
+// The point of table's buffering: escape codes on the header must not be
+// counted as column content. If they were, every row below would be padded
+// to the header's inflated width.
+func TestTableHeaderStylingDoesNotMoveColumns(t *testing.T) {
+ rows := func(w *table) {
+ w.Write([]byte("CHANNEL\tVERSION\tPROMOTED\n"))
+ w.Write([]byte("canary/keymail\t1.2.3\t2026-08-07\n"))
+ w.Write([]byte("stable\t1.0.0\t2026-08-01\n"))
+ }
+
+ var plain bytes.Buffer
+ pt := newTable(&plain)
+ rows(pt)
+ if err := pt.Flush(); err != nil {
+ t.Fatal(err)
+ }
+
+ var styled bytes.Buffer
+ st := newTable(&styled)
+ st.pal = colors{on: true} // a buffer never gets colour on its own
+ rows(st)
+ if err := st.Flush(); err != nil {
+ t.Fatal(err)
+ }
+
+ got := styled.String()
+ if !strings.HasPrefix(got, ansiBold+"CHANNEL") {
+ t.Fatalf("header not styled: %q", got[:40])
+ }
+ // Strip the codes back out and the two renderings must be identical:
+ // the columns did not move.
+ stripped := strings.NewReplacer(ansiBold, "", ansiReset, "").Replace(got)
+ if stripped != plain.String() {
+ t.Fatalf("styling changed the layout:\n plain: %q\nstyled: %q", plain.String(), stripped)
+ }
+}
+
+// Colour off, the banner is byte-for-byte the line the API-mode tests pin.
+func TestPrintUsingIsUnchangedWithoutColor(t *testing.T) {
+ var buf bytes.Buffer
+ printUsing(&buf, &apiSession{console: "https://console.example", address: "vicky@keymail.dev"})
+ if got, want := buf.String(), "using https://console.example as vicky@keymail.dev\n"; got != want {
+ t.Fatalf("printUsing wrote %q, want %q", got, want)
+ }
+}
diff --git a/instances_cli.go b/instances_cli.go
index 79e3f3bac..0ee781a66 100644
--- a/instances_cli.go
+++ b/instances_cli.go
@@ -21,8 +21,8 @@ import (
"flag"
"fmt"
"log"
+ "os"
"strings"
- "text/tabwriter"
"github.com/carlosframework/platform/internal/provision"
"github.com/carlosframework/platform/internal/release"
@@ -119,13 +119,13 @@ func runInstancesEnable(args []string) {
seedReq = &argv
}
acct := apiAccount(acctRaw, acctIDRaw)
- fmt.Fprintf(stdout, "using %s as %s\n", sess.console, sess.address)
+ printUsing(stdout, sess)
m, err := sess.instancesEnable(context.Background(), acct, *app, channelReq, seedReq)
if err != nil {
log.Fatalf("instances enable: %v", err)
}
- fmt.Fprintf(stdout, "enabled instances for %s under %s\n", m.App, m.Domain)
- fmt.Fprintf(stdout, " first instance: carlos instances create -app %s -host <label>.%s\n", m.App, m.Domain)
+ fmt.Fprintf(stdout, "%s for %s under %s\n", colorFor(stdout).ok("enabled instances"), m.App, m.Domain)
+ mutedf(stdout, " first instance: carlos instances create -app %s -host <label>.%s\n", m.App, m.Domain)
return
}
@@ -176,7 +176,7 @@ func runInstancesEnable(args []string) {
if err := instancesEnable(context.Background(), openStore(), acctOf(), *app, m); err != nil {
log.Fatal(err)
}
- fmt.Printf("enabled instances for %s under %s\n", *app, *domain)
+ fmt.Printf("%s for %s under %s\n", colorFor(os.Stdout).ok("enabled instances"), *app, *domain)
}
// parseSeed splits -seed's raw value on whitespace (strings.Fields) into the
@@ -242,14 +242,14 @@ func runInstancesCreate(args []string) {
log.Fatal(instancesRecordsUsage)
}
sess := requireAPISession("create", *consoleFlag)
- fmt.Fprintf(stdout, "using %s as %s\n", sess.console, sess.address)
+ printUsing(stdout, sess)
rec, err := sess.instanceCreate(context.Background(), apiAccount(acctRaw, acctIDRaw), *app,
strings.TrimSpace(*host), strings.TrimSpace(*region), strings.TrimSpace(*channel))
if err != nil {
log.Fatalf("instances create: %v", err)
}
- fmt.Fprintf(stdout, "created instance %s (channel %s)\n", rec.Host, rec.Channel)
- fmt.Fprintf(stdout, " the host comes live on the next reconcile pass on its box\n")
+ fmt.Fprintf(stdout, "%s %s (channel %s)\n", colorFor(stdout).ok("created instance"), rec.Host, rec.Channel)
+ mutedf(stdout, " the host comes live on the next reconcile pass on its box")
}
func runInstancesList(args []string) {
@@ -267,14 +267,14 @@ func runInstancesList(args []string) {
if err != nil {
log.Fatalf("instances list: %v", err)
}
- w := tabwriter.NewWriter(stdout, 0, 4, 2, ' ', 0)
+ w := newTable(stdout)
fmt.Fprintln(w, "HOST\tREGION\tCHANNEL\tPOOL\tCREATED\tBY")
for _, r := range recs {
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n", r.Host, r.Region, r.Channel, r.Pool, r.CreatedAt, r.CreatedBy)
}
w.Flush()
if truncated {
- fmt.Fprintln(stdout, "(truncated at 500 records)")
+ mutedf(stdout, "(truncated at 500 records)")
}
}
@@ -290,9 +290,10 @@ func runInstancesDelete(args []string) {
log.Fatal(instancesRecordsUsage)
}
sess := requireAPISession("delete", *consoleFlag)
- fmt.Fprintf(stdout, "using %s as %s\n", sess.console, sess.address)
+ printUsing(stdout, sess)
if err := sess.instanceDelete(context.Background(), apiAccount(acctRaw, acctIDRaw), *app, strings.TrimSpace(*host)); err != nil {
log.Fatalf("instances delete: %v", err)
}
- fmt.Fprintf(stdout, "deleted instance %s — its route retires on the next reconcile pass; parked data stays\n", *host)
+ fmt.Fprintf(stdout, "%s %s — its route retires on the next reconcile pass; parked data stays\n",
+ colorFor(stdout).ok("deleted instance"), *host)
}
diff --git a/main.go b/main.go
index a2abeffe4..a068aa33a 100644
--- a/main.go
+++ b/main.go
@@ -16,6 +16,11 @@ import (
)
func main() {
+ // Failure is reported through log.Fatal everywhere in this binary, so
+ // the logger is where the "this went wrong" colour goes — once, rather
+ // than at a few hundred call sites. Off unless stderr is a terminal, so
+ // journald and CI logs see exactly what they saw before.
+ log.SetOutput(newErrorWriter(os.Stderr))
if len(os.Args) < 2 {
usage(os.Stderr)
os.Exit(2)