mando-cli v0.4.0 — mando up failures invisible when output is piped/redirected (2026-06-25)
A two-bug fix discovered while validating the --mando=pull workaround from mando-cli-v0.4.0-mando-bess-binary-missing-2026-06-25. When mando up runs with its stdout/stderr piped or redirected (which the remote tester does: mando … 2>&1 | tee log), all streamed docker compose output and the actual failure reason vanish — the user sees only a bare docker compose up … failed (exit 1) with an empty .mando/compose-up.log. Two distinct, independent bugs conspire; both are fixed and validated on macOS.
For Agents
This is a diagnostics/observability bug, not a logic bug in
upitself — the stack still fails for the same underlying reason (the binary-missing design gap, here exercised via its--mando=pullworkaround hitting a registry auth wall). The pathology is that the failure detail never reaches the captured stream when stderr is not a TTY. Two root causes stack: (A)docker composewrites progress + errors to/dev/tty, bypassing the pipe mando-cli set up; (B)indicatif::MultiProgress::printlnis a silent no-op on non-TTY, so even the lines mando-cli does capture get dropped on the replay path. Fixing only one is insufficient — both are required. Both fixes are implemented + built + validated on macOS, NOT committed; a linux/amd64 build for the tester is pending.
Status
Both fixes implemented, built, and validated locally on macOS. NOT yet committed. linux/amd64 build for the remote tester (gabi, WSL) still pending. No CI/regression test added yet.
How it surfaced
Validating the [[mando-cli-v0.4.0-mando-bess-binary-missing-2026-06-25|mando up --mando=pull workaround]]. The remote tester runs everything through tee:
mando up --mando=pull 2>&1 | tee logThe redirect makes stderr a pipe, not a TTY — which is what trips both bugs. Interactively (real TTY) the output looked fine, so this only reproduces under pipe/redirect/CI/tee. Symptom: the captured log ended after ~35 lines with nothing but:
docker compose up … failed (exit 1)
and .mando/compose-up.log was empty — no pull progress, no error, no tail. The real cause (a registry pull access denied) was completely swallowed.
Bug A — docker compose progress bypasses the pipe (writes to /dev/tty)
mando-cli’s streaming compose runner (src/runtime/compose.rs, the fn that runs docker compose … up via run_streaming) does pipe child stdout/stderr. But docker compose writes its pull/up progress AND the final error to /dev/tty directly, bypassing the piped fds. So:
- The captured
.mando/compose-up.logwas empty. - mando-cli’s own replay had nothing to replay.
- The user saw only the bare
failed (exit 1).
Fix A — force plain output with --progress plain
Add the top-level --progress plain flag to the compose invocation so progress is emitted as plain newline-delimited text to the pipe instead of the TTY (compose.rs ~line 231):
Cmd::new("docker")
.arg("compose")
.arg("--progress").arg("plain") // <-- forces plain newline output into the pipe
.arg("-p")
// … -f <files> … up …Fix A alone is INSUFFICIENT
With only Fix A, the lines now reach mando-cli’s pipe reader — but mando-cli then forwards them through
indicatif, which drops them again on non-TTY (Bug B). You need both.--ansi neveris an equivalent alternative to--progress plainfor the same effect.
Bug B — indicatif::MultiProgress::println silently drops non-TTY output
CliBuffer::add_log_line (src/opts.rs:57) forwarded every streamed line via indicatif::MultiProgress::println. On non-TTY stderr that call is a silent no-op — it returns Ok(()) and writes nothing. So every line mando-cli read from the (now-plain, post-Fix-A) compose stream was dropped, as was the failure-tail replay block that prints the last N lines on error (compose.rs:263-272). Net under pipe/redirect: total silence except the final bare failed (exit 1).
Fix B — fall back to eprintln! when stderr is not a terminal
pub fn add_log_line(&self, line: &str) {
use std::io::IsTerminal;
if std::io::stderr().is_terminal() {
if self.multi.println(line).is_err() { eprintln!("{line}"); }
} else {
eprintln!("{line}");
}
}When stderr is a TTY, keep the indicatif path (progress bars stay clean) but still fall back to eprintln! if println errors. When stderr is not a TTY, bypass indicatif entirely and write straight to stderr.
Validation (proven on macOS)
Re-running up --mando=pull redirected (non-TTY) with both fixes:
| Before | After | |
|---|---|---|
| Captured lines | ~35 | 138 |
| What you see | bare failed (exit 1) | real pull access denied for mando … error |
| Failure tail | absent | --- last 40 log lines --- block present |
| Log pointer | absent | --- full log: .mando/compose-up.log |
.mando/compose-up.log | empty | populated |
The now-visible underlying failure is a registry auth error (pull access denied for mando …, … 'docker login') — i.e. the --mando=pull path reaches the registry but isn’t authenticated to pull the image.
Reusable gotchas
Two CLI/observability gotchas — applicable beyond mando-cli
1.
docker composeprogress targets/dev/tty. Any pipe-captureddocker composeinvocation must pass--progress plain(or--ansi never), or its progress output and its errors write straight to the terminal device and vanish from your captured stream. Piping the child’s stdout/stderr is not enough — compose ignores those fds for progress.2.
indicatif::MultiProgress::printlnis a silent no-op on non-TTY. It returnsOk(())and writes nothing when stderr isn’t a terminal. Any CLI using it for log passthrough must fall back toeprintln!whenstd::io::stderr().is_terminal()isfalse, or all piped / CI /tee’d output silently disappears. (Progress bars being suppressed on non-TTY is fine; log lines routed through the same API getting suppressed is the trap.)General principle: branch on
IsTerminalfor anything that carries failure detail — never let a pretty-printer be the sole sink for diagnostics.
Tester’s actual (now-visible) failure — and a misleading auth distinction
With the fixes, the real error gabi was hitting becomes visible:
pull access denied for mando …
… 'docker login'
He needs docker login registry.gitlab.com using a GitLab Personal Access Token with the read_registry scope.
"registry auth" in mando-cli ≠ docker login
mando-cli’s own “registry auth” line refers to GitLab API credentials (the git /
glabcredential helper, used to clone/resolve, e.g. the token-URL clone pattern in be-1595-arrow-consumer-lock-script-2026-06-24). It is NOT the same asdocker loginto the container registry. Being authenticated for the GitLab API does not authenticate the Docker daemon to pull images — those are separate credential stores. This distinction is easy to miss and was the source of confusion here.
Recommended follow-ups
| # | Action | File(s) | Gate |
|---|---|---|---|
| 1 | Commit both fixes (A --progress plain, B IsTerminal fallback) | src/runtime/compose.rs ~L231, src/opts.rs:57 | now |
| 2 | Build linux/amd64 binary for gabi (WSL) | per mando-cli-wsl-linux-build | after #1 |
| 3 | Tell gabi: docker login registry.gitlab.com with a GitLab PAT (read_registry scope) — distinct from mando-cli’s GitLab-API auth | — | now |
| 4 | Consider a regression guard: assert non-TTY up failure populates .mando/compose-up.log + emits the tail | tests | nice-to-have |
Related
- mando-cli-v0.4.0-mando-bess-binary-missing-2026-06-25 — the parent note. This visibility fix was found while validating that note’s
mando up --mando=pullworkaround; once output was visible, the workaround’s own wall (registrypull access denied) became diagnosable. That note covers the underlying design gap (default profile buildsmandofrom a thin Dockerfile that COPYs a never-compiled binary). - mando-cli-v0.4.0-compose-bugs-triage-2026-05-26 — the compose-runtime triage two failures earlier on this same
uppath (commit2233959); documents theprofiles: ["{run_tag}"]invariant and the fresh-checkout coverage hole this whole chain stems from. - mando-cli-v2 — current architecture (AppContext, CommandDelegate, runtime pipeline) the compose runner and
CliBufferlive in. - mando-cli-simulator-env-contract-2026-06-02 — sibling lesson about deriving UI strings from the real invocation args (don’t let display text drift from behaviour); same “make the CLI’s surface honest” theme.
- be-1595-arrow-consumer-lock-script-2026-06-24 — the GitLab token-URL clone / credential-helper auth that the “registry auth” line refers to (the GitLab-API auth that is not
docker login). - mando-cli-wsl-linux-build — how to produce the pending linux/amd64 build for the WSL tester.
- Source:
/Volumes/bandi/coding/poc/mando-cli/src/runtime/compose.rs(compose runner, ~L231 invocation, L263-272 failure tail),/Volumes/bandi/coding/poc/mando-cli/src/opts.rs:57(CliBuffer::add_log_line).