Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
## {{ 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)
* Improved exec and tooling commands to forward host terminal environment (`TERM`, `LANG`, `TZ`, etc.) into containers
* Improved color output handling so containers receive `NO_COLOR=1` when Lando itself is running without color

## v3.26.3 - [April 14, 2026](https://github.com/lando/core/releases/tag/v3.26.3)

* Fixed crash when `config.yml` is empty instead of containing `{}` [#439](https://github.com/lando/core/issues/439)
Expand Down
42 changes: 24 additions & 18 deletions lib/compose.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

// Modules
const _ = require('lodash');
const buildEnvironment = require('../utils/build-exec-environment');
const describeContext = require('../utils/describe-context');
const extractDetach = require('../utils/extract-detach');

// Helper object for flags
const composeFlags = {
Expand All @@ -26,7 +29,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},
kill: {},
logs: {follow: false, timestamps: false},
ps: {q: true},
Expand Down Expand Up @@ -122,27 +125,30 @@ exports.remove = (compose, project, opts = {}) => {
* Run docker compose run
*/
exports.run = (compose, project, opts = {}) => {
// add some deep handling for detaching
// @TODO: should we let and explicit set of opts.detach override this?
// thinking probably not because & is just not going to work the way you want without detach?
// that said we can skip this if detach is already set to true
if (opts.detach !== true) {
if (opts.cmd[0] === '/etc/lando/exec.sh' && opts.cmd[opts.cmd.length - 1] === '&') {
opts.cmd.pop();
opts.detach = true;
} else if (opts.cmd[0].endsWith('sh') && opts.cmd[1] === '-c' && opts.cmd[2].endsWith('&')) {
opts.cmd[2] = opts.cmd[2].slice(0, -1).trim();
opts.detach = true;
} else if (opts.cmd[0].endsWith('bash') && opts.cmd[1] === '-c' && opts.cmd[2].endsWith('&')) {
opts.cmd[2] = opts.cmd[2].slice(0, -1).trim();
opts.detach = true;
} else if (opts.cmd[opts.cmd.length - 1] === '&') {
opts.cmd.pop();
// Extract detach intent from trailing '&' in the command
if (opts.detach !== true && opts.cmd) {
const result = extractDetach(opts.cmd);
if (result.detach) {
opts.cmd = result.cmd;
opts.detach = true;
}
}

// and return
const context = describeContext();

// Detached execs should never allocate a TTY. Otherwise compute TTY
// at call time so the decision reflects the current terminal state.
if (opts.detach === true) {
opts.noTTY = true;
} else if (opts.noTTY === undefined) {
opts.noTTY = !(context.stdin.isTTY && context.stdout.isTTY);
}

// Build the environment using the same shared utility as the docker
// exec path so both code paths forward identical host hints (TERM,
// LANG, COLUMNS/LINES, NO_COLOR, etc.). Caller-provided vars always win.
opts.environment = buildEnvironment(context, opts.environment || {});

return buildShell('exec', project, compose, opts);
};

Expand Down
31 changes: 31 additions & 0 deletions lib/compose.types.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
'use strict';

/**
* @file Type definitions for {@link module:lib/compose}.
*/

/**
* @typedef {object} ComposeRunOpts
* @property {boolean} [detach] — run in detached mode
* @property {string[]} [cmd] — command array to exec in the container
* @property {boolean} [noTTY] — disable PTY allocation (computed from
* terminal state when not set explicitly)
* @property {Record<string, string>} [environment] — env vars to inject
* @property {string[]} [services] — target service name(s)
* @property {string} [user] — user to run as inside the container
* @property {string} [workdir] — working directory inside the container
* @property {string[]} [entrypoint] — override container entrypoint
* @property {string} [cstdio] — child stdio mode
* @property {boolean} [silent] — suppress output
*/

/**
* @typedef {object} ShellSpec
* @property {string[]} cmd — the assembled docker-compose argv
* @property {object} opts — options passed to shell.sh
* @property {string} opts.mode — shell execution mode
* @property {string} [opts.cstdio] — child stdio mode
* @property {boolean} [opts.silent] — suppress output
*/

module.exports = {};
1 change: 1 addition & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

180 changes: 180 additions & 0 deletions test/build-exec-environment.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
/**
* Tests for build-exec-environment.js
* @file build-exec-environment.spec.js
*/

'use strict';

const chai = require('chai');
const expect = chai.expect;
chai.should();

const buildEnvironment = require('../utils/build-exec-environment');

// Helper — build a context with a controlled env so tests never
// touch process.env and don't need save/restore boilerplate.
const makeCtx = (overrides = {}) => {
const {env, stdout, stderr, ...rest} = overrides;
return {
stdout: {isTTY: true, columns: 80, rows: 24, ...stdout},
stderr: {isTTY: true, ...stderr},
env: env || {},
landoColorLevel: 3,
...rest,
};
};

describe('build-exec-environment', () => {
describe('inherited vars', () => {
it('should forward TERM when set', () => {
const ctx = makeCtx({env: {TERM: 'xterm-256color'}});
const env = buildEnvironment(ctx);
expect(env.TERM).to.equal('xterm-256color');
});

it('should not include TERM when unset', () => {
const ctx = makeCtx();
const env = buildEnvironment(ctx);
expect(env).to.not.have.property('TERM');
});

it('should forward COLORTERM when set', () => {
const ctx = makeCtx({env: {COLORTERM: 'truecolor'}});
const env = buildEnvironment(ctx);
expect(env.COLORTERM).to.equal('truecolor');
});

it('should forward TERM_PROGRAM when set', () => {
const ctx = makeCtx({env: {TERM_PROGRAM: 'iTerm.app'}});
const env = buildEnvironment(ctx);
expect(env.TERM_PROGRAM).to.equal('iTerm.app');
});

it('should forward TZ when set', () => {
const ctx = makeCtx({env: {TZ: 'America/New_York'}});
const env = buildEnvironment(ctx);
expect(env.TZ).to.equal('America/New_York');
});

it('should forward locale vars when set', () => {
const ctx = makeCtx({env: {LANG: 'en_US.UTF-8', LC_ALL: 'C', LC_CTYPE: 'UTF-8', LC_MESSAGES: 'en_US'}});
const env = buildEnvironment(ctx);
expect(env.LANG).to.equal('en_US.UTF-8');
expect(env.LC_ALL).to.equal('C');
expect(env.LC_CTYPE).to.equal('UTF-8');
expect(env.LC_MESSAGES).to.equal('en_US');
});

it('should ignore env vars not in forwardKeys', () => {
const ctx = makeCtx({env: {TERM: 'xterm', SECRET_TOKEN: 'abc123'}});
const env = buildEnvironment(ctx);
expect(env.TERM).to.equal('xterm');
expect(env).to.not.have.property('SECRET_TOKEN');
});

it('should not forward CI env vars', () => {
const ctx = makeCtx({env: {CI: 'true', GITHUB_ACTIONS: 'true', GITLAB_CI: 'true'}});
const env = buildEnvironment(ctx);
expect(env).to.not.have.property('CI');
expect(env).to.not.have.property('GITHUB_ACTIONS');
expect(env).to.not.have.property('GITLAB_CI');
});

it('should not forward DEBUG or VERBOSE', () => {
const ctx = makeCtx({env: {DEBUG: '*', VERBOSE: '1'}});
const env = buildEnvironment(ctx);
expect(env).to.not.have.property('DEBUG');
expect(env).to.not.have.property('VERBOSE');
});

it('should not forward color env vars from the host', () => {
const ctx = makeCtx({env: {FORCE_COLOR: '3', NO_COLOR: '1', CLICOLOR: '1', CLICOLOR_FORCE: '1'}});
const env = buildEnvironment(ctx);
// Color state is derived from Lando's own chalk level, not host vars
expect(env).to.not.have.property('FORCE_COLOR');
expect(env).to.not.have.property('CLICOLOR');
expect(env).to.not.have.property('CLICOLOR_FORCE');
});
});

describe('color suppression from Lando state', () => {
it('should set NO_COLOR=1 when Lando is not producing color', () => {
const ctx = makeCtx({landoColorLevel: 0});
const env = buildEnvironment(ctx);
expect(env.NO_COLOR).to.equal('1');
});

it('should not set NO_COLOR when Lando is producing color', () => {
const ctx = makeCtx({landoColorLevel: 3});
const env = buildEnvironment(ctx);
expect(env).to.not.have.property('NO_COLOR');
});

it('should not set NO_COLOR when Lando has basic color support', () => {
const ctx = makeCtx({landoColorLevel: 1});
const env = buildEnvironment(ctx);
expect(env).to.not.have.property('NO_COLOR');
});
});

describe('synthetic vars', () => {
it('should set COLUMNS and LINES when stdout is not a TTY', () => {
const ctx = makeCtx({stdout: {isTTY: false, columns: 120, rows: 40}});
const env = buildEnvironment(ctx);
expect(env.COLUMNS).to.equal('120');
expect(env.LINES).to.equal('40');
});

it('should not set COLUMNS and LINES when stdout is a TTY', () => {
const ctx = makeCtx({stdout: {isTTY: true, columns: 120, rows: 40}});
const env = buildEnvironment(ctx);
expect(env).to.not.have.property('COLUMNS');
expect(env).to.not.have.property('LINES');
});
});

describe('user overrides', () => {
it('should let user env override inherited vars', () => {
const ctx = makeCtx({env: {TERM: 'xterm'}});
const env = buildEnvironment(ctx, {TERM: 'dumb'});
expect(env.TERM).to.equal('dumb');
});

it('should let user env override synthetic vars', () => {
const ctx = makeCtx({stdout: {isTTY: false, columns: 80, rows: 24}});
const env = buildEnvironment(ctx, {COLUMNS: '200'});
expect(env.COLUMNS).to.equal('200');
});

it('should let user env override NO_COLOR suppression', () => {
const ctx = makeCtx({landoColorLevel: 0});
const env = buildEnvironment(ctx, {NO_COLOR: ''});
// User explicitly clearing NO_COLOR should win
expect(env.NO_COLOR).to.equal('');
});

it('should let user env force color even when Lando has no color', () => {
const ctx = makeCtx({landoColorLevel: 0});
const env = buildEnvironment(ctx, {FORCE_COLOR: '3'});
// Synthetic NO_COLOR is set, but user FORCE_COLOR is also present
expect(env.FORCE_COLOR).to.equal('3');
});

it('should pass through arbitrary user vars', () => {
const ctx = makeCtx();
const env = buildEnvironment(ctx, {MY_APP_VAR: 'hello'});
expect(env.MY_APP_VAR).to.equal('hello');
});
});

describe('precedence', () => {
it('should apply inherited < synthetic < user', () => {
const env = buildEnvironment(
makeCtx({stdout: {isTTY: false, columns: 80, rows: 24}}),
{COLUMNS: '999'},
);
// User wins over synthetic
expect(env.COLUMNS).to.equal('999');
});
});
});
Loading
Loading