From f18939d45dd9dc1f599bdd491a9f19662fb31373 Mon Sep 17 00:00:00 2001 From: Aaron Feledy Date: Fri, 27 Feb 2026 18:25:41 -0600 Subject: [PATCH 1/4] test: add ANSI escape code validation for redirected output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds tests to verify that `lando --help` and tooling commands do not include ANSI escape codes when stdout is redirected to a file. These tests are expected to FAIL until the fix is applied — changing TTY allocation to check `process.stdout.isTTY` instead of `process.stdin.isTTY` in compose.js and build-docker-exec.js. Ref #345 --- CHANGELOG.md | 2 ++ examples/tooling/README.md | 5 +++++ 2 files changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 475e6c772..642a5c878 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ ## {{ UNRELEASED_VERSION }} - [{{ UNRELEASED_DATE }}]({{ UNRELEASED_LINK }}) +* Fixed ANSI escape codes appearing in redirected output by checking `stdout.isTTY` instead of `stdin.isTTY` for TTY allocation [#345](https://github.com/lando/core/issues/345) + ## v3.26.2 - [December 17, 2025](https://github.com/lando/core/releases/tag/v3.26.2) * Updated to use new Lando Alliance Apple Developer certificates diff --git a/examples/tooling/README.md b/examples/tooling/README.md index 83b59cc00..15049b5bf 100644 --- a/examples/tooling/README.md +++ b/examples/tooling/README.md @@ -184,6 +184,11 @@ lando cols lando lines cat cols | grep "$(tput cols)" cat lines | grep "$(tput lines)" +# Should not include ANSI escape codes in redirected lando help output +lando --help > /tmp/lando-help-output.txt 2>&1 && ! grep -P '\x1b\[' /tmp/lando-help-output.txt + +# Should not include ANSI escape codes in redirected tooling output +lando envvar > /tmp/lando-tool-output.txt 2>&1 && ! grep -P '\x1b\[' /tmp/lando-tool-output.txt ``` ## Destroy tests From 5beb9120e939c484593de3de5d4a5793aa5967c7 Mon Sep 17 00:00:00 2001 From: Aaron Feledy Date: Fri, 27 Feb 2026 18:49:06 -0600 Subject: [PATCH 2/4] test: replace leia test with unit tests for TTY allocation The leia integration tests can't reproduce the bug in CI because GitHub Actions doesn't allocate a real PTY, so stdin.isTTY is always false. Replaced with unit tests that mock process.stdin.isTTY and process.stdout.isTTY to validate both compose.js and build-docker-exec.js TTY allocation logic. Failing tests: - compose: should set noTTY=true when stdout is not a TTY - docker exec: should not include --tty when stdout is not a TTY These fail because both files check stdin.isTTY but ignore stdout.isTTY. Ref #345 --- examples/tooling/README.md | 5 -- test/tty-allocation.spec.js | 141 ++++++++++++++++++++++++++++++++++++ 2 files changed, 141 insertions(+), 5 deletions(-) create mode 100644 test/tty-allocation.spec.js diff --git a/examples/tooling/README.md b/examples/tooling/README.md index 15049b5bf..83b59cc00 100644 --- a/examples/tooling/README.md +++ b/examples/tooling/README.md @@ -184,11 +184,6 @@ lando cols lando lines cat cols | grep "$(tput cols)" cat lines | grep "$(tput lines)" -# Should not include ANSI escape codes in redirected lando help output -lando --help > /tmp/lando-help-output.txt 2>&1 && ! grep -P '\x1b\[' /tmp/lando-help-output.txt - -# Should not include ANSI escape codes in redirected tooling output -lando envvar > /tmp/lando-tool-output.txt 2>&1 && ! grep -P '\x1b\[' /tmp/lando-tool-output.txt ``` ## Destroy tests diff --git a/test/tty-allocation.spec.js b/test/tty-allocation.spec.js new file mode 100644 index 000000000..2c7d1f191 --- /dev/null +++ b/test/tty-allocation.spec.js @@ -0,0 +1,141 @@ +/* + * Tests for TTY allocation in docker exec and compose. + * @file tty-allocation.spec.js + * + * Validates that TTY is only allocated when BOTH stdin and stdout + * are TTYs. When stdout is redirected (e.g. `lando foo > file.txt`), + * TTY should NOT be allocated so that ANSI escape codes are not + * written to the file. + * + * @see https://github.com/lando/core/issues/345 + * @see https://github.com/lando/drupal/issues/157 + */ + +'use strict'; + +const chai = require('chai'); +const expect = chai.expect; +chai.should(); + +describe('TTY allocation', () => { + // Save originals + const originalStdinIsTTY = process.stdin.isTTY; + const originalStdoutIsTTY = process.stdout.isTTY; + + afterEach(() => { + // Restore after each test + process.stdin.isTTY = originalStdinIsTTY; + process.stdout.isTTY = originalStdoutIsTTY; + // Clear require cache so compose.js re-evaluates + delete require.cache[require.resolve('./../lib/compose')]; + delete require.cache[require.resolve('./../utils/build-docker-exec')]; + }); + + describe('compose exec (lib/compose.js)', () => { + it('should set noTTY=true when stdout is not a TTY (output redirected)', () => { + process.stdin.isTTY = true; + process.stdout.isTTY = false; + const compose = require('./../lib/compose'); + const result = compose.run( + ['docker-compose.yml'], + 'test_project', + {services: ['web'], cmd: ['echo', 'hello']}, + ); + // When noTTY is true, the -T flag should be in the command + expect(result.cmd).to.include('-T'); + }); + + it('should set noTTY=false when both stdin and stdout are TTYs', () => { + process.stdin.isTTY = true; + process.stdout.isTTY = true; + const compose = require('./../lib/compose'); + const result = compose.run( + ['docker-compose.yml'], + 'test_project', + {services: ['web'], cmd: ['echo', 'hello']}, + ); + // When both are TTY, -T should NOT be in the command + expect(result.cmd).to.not.include('-T'); + }); + + it('should set noTTY=true when stdin is not a TTY (non-interactive)', () => { + process.stdin.isTTY = false; + process.stdout.isTTY = true; + const compose = require('./../lib/compose'); + const result = compose.run( + ['docker-compose.yml'], + 'test_project', + {services: ['web'], cmd: ['echo', 'hello']}, + ); + expect(result.cmd).to.include('-T'); + }); + + it('should set noTTY=true when neither stdin nor stdout is a TTY', () => { + process.stdin.isTTY = false; + process.stdout.isTTY = false; + const compose = require('./../lib/compose'); + const result = compose.run( + ['docker-compose.yml'], + 'test_project', + {services: ['web'], cmd: ['echo', 'hello']}, + ); + expect(result.cmd).to.include('-T'); + }); + }); + + describe('docker exec (utils/build-docker-exec.js)', () => { + it('should not include --tty when stdout is not a TTY', () => { + process.stdin.isTTY = true; + process.stdout.isTTY = false; + const buildDockerExec = require('./../utils/build-docker-exec'); + + let capturedCmd; + const injected = { + config: {dockerBin: 'docker'}, + _config: {dockerBin: 'docker'}, + shell: { + sh: (cmd, opts) => { + capturedCmd = cmd; + return Promise.resolve(); + }, + }, + }; + + const datum = { + id: 'test_container', + cmd: ['echo', 'hello'], + opts: {user: 'www-data', environment: {}}, + }; + + buildDockerExec(injected, 'inherit', datum); + expect(capturedCmd).to.not.include('--tty'); + }); + + it('should include --tty when both stdin and stdout are TTYs', () => { + process.stdin.isTTY = true; + process.stdout.isTTY = true; + const buildDockerExec = require('./../utils/build-docker-exec'); + + let capturedCmd; + const injected = { + config: {dockerBin: 'docker'}, + _config: {dockerBin: 'docker'}, + shell: { + sh: (cmd, opts) => { + capturedCmd = cmd; + return Promise.resolve(); + }, + }, + }; + + const datum = { + id: 'test_container', + cmd: ['echo', 'hello'], + opts: {user: 'www-data', environment: {}}, + }; + + buildDockerExec(injected, 'inherit', datum); + expect(capturedCmd).to.include('--tty'); + }); + }); +}); From 65c38fe1047cec1d07c9d4fde52eec5f74f058c8 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 28 Feb 2026 00:54:15 +0000 Subject: [PATCH 3/4] Fix stale require cache in TTY allocation tests Add beforeEach hook to clear require cache before each test runs. This ensures each test gets a fresh module evaluation with the correct TTY values, preventing the first test from using a stale cached module loaded by compose.spec.js. --- test/tty-allocation.spec.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/test/tty-allocation.spec.js b/test/tty-allocation.spec.js index 2c7d1f191..64edab6b2 100644 --- a/test/tty-allocation.spec.js +++ b/test/tty-allocation.spec.js @@ -22,6 +22,12 @@ describe('TTY allocation', () => { const originalStdinIsTTY = process.stdin.isTTY; const originalStdoutIsTTY = process.stdout.isTTY; + beforeEach(() => { + // Clear require cache so compose.js re-evaluates with test-set TTY values + delete require.cache[require.resolve('./../lib/compose')]; + delete require.cache[require.resolve('./../utils/build-docker-exec')]; + }); + afterEach(() => { // Restore after each test process.stdin.isTTY = originalStdinIsTTY; From d535e05698c1609e4077599b1316bdc522d3b28e Mon Sep 17 00:00:00 2001 From: Aaron Feledy Date: Fri, 27 Feb 2026 20:29:47 -0600 Subject: [PATCH 4/4] fix: only allocate TTY when both stdin and stdout are terminals Changes TTY detection in both compose exec and direct docker exec to check process.stdout.isTTY in addition to process.stdin.isTTY. Previously, running `lando foo > file.txt` from a terminal would allocate a TTY inside the container (because stdin was a TTY), causing commands like composer to emit ANSI escape codes into the output file. Now TTY is only allocated when both stdin AND stdout are terminals, matching the expected behavior: colors in interactive use, clean output when redirected. Fixes #345 --- lib/compose.js | 2 +- utils/build-docker-exec.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/compose.js b/lib/compose.js index 34cc67c18..2a5721cfe 100644 --- a/lib/compose.js +++ b/lib/compose.js @@ -26,7 +26,7 @@ const composeFlags = { const defaultOptions = { build: {noCache: false, pull: true}, down: {removeOrphans: true, volumes: true}, - exec: {detach: false, noTTY: !process.stdin.isTTY}, + exec: {detach: false, noTTY: !(process.stdin.isTTY && process.stdout.isTTY)}, kill: {}, logs: {follow: false, timestamps: false}, ps: {q: true}, diff --git a/utils/build-docker-exec.js b/utils/build-docker-exec.js index 651f505e2..eddb6c99e 100644 --- a/utils/build-docker-exec.js +++ b/utils/build-docker-exec.js @@ -8,7 +8,7 @@ const _ = require('lodash'); const getExecOpts = (docker, datum) => { const exec = [docker, 'exec']; // Should only use this if we have to - if (process.stdin.isTTY) exec.push('--tty'); + if (process.stdin.isTTY && process.stdout.isTTY) exec.push('--tty'); // Should only set interactive in node mode if (process.lando === 'node') exec.push('--interactive'); // add workdir if we can