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 up itself — the stack still fails for the same underlying reason (the binary-missing design gap, here exercised via its --mando=pull workaround 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 compose writes progress + errors to /dev/tty, bypassing the pipe mando-cli set up; (B) indicatif::MultiProgress::println is 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 log

The 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.log was 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 never is an equivalent alternative to --progress plain for 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:

BeforeAfter
Captured lines~35138
What you seebare failed (exit 1)real pull access denied for mando … error
Failure tailabsent--- last 40 log lines --- block present
Log pointerabsent--- full log: .mando/compose-up.log
.mando/compose-up.logemptypopulated

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 compose progress targets /dev/tty. Any pipe-captured docker compose invocation 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::println is a silent no-op on non-TTY. It returns Ok(()) and writes nothing when stderr isn’t a terminal. Any CLI using it for log passthrough must fall back to eprintln! when std::io::stderr().is_terminal() is false, 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 IsTerminal for 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 / glab credential 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 as docker login to 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.

#ActionFile(s)Gate
1Commit both fixes (A --progress plain, B IsTerminal fallback)src/runtime/compose.rs ~L231, src/opts.rs:57now
2Build linux/amd64 binary for gabi (WSL)per mando-cli-wsl-linux-buildafter #1
3Tell gabi: docker login registry.gitlab.com with a GitLab PAT (read_registry scope) — distinct from mando-cli’s GitLab-API authnow
4Consider a regression guard: assert non-TTY up failure populates .mando/compose-up.log + emits the tailtestsnice-to-have
  • mando-cli-v0.4.0-mando-bess-binary-missing-2026-06-25the parent note. This visibility fix was found while validating that note’s mando up --mando=pull workaround; once output was visible, the workaround’s own wall (registry pull access denied) became diagnosable. That note covers the underlying design gap (default profile builds mando from 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 up path (commit 2233959); documents the profiles: ["{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 CliBuffer live 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).