diff --git a/CHANGELOG.md b/CHANGELOG.md index 128960f40..cd0f34861 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/lib/compose.js b/lib/compose.js index 34cc67c18..83374b6ff 100644 --- a/lib/compose.js +++ b/lib/compose.js @@ -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 = { @@ -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}, @@ -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); }; diff --git a/lib/compose.types.js b/lib/compose.types.js new file mode 100644 index 000000000..26c2d5cfe --- /dev/null +++ b/lib/compose.types.js @@ -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} [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 = {}; diff --git a/package-lock.json b/package-lock.json index 032b0ca7e..7dc2f07b5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,7 @@ "": { "name": "@lando/core", "version": "3.26.3", + "hasInstallScript": true, "license": "MIT", "dependencies": { "@lando/argv": "^1.2.0", diff --git a/test/build-exec-environment.spec.js b/test/build-exec-environment.spec.js new file mode 100644 index 000000000..76a5933e6 --- /dev/null +++ b/test/build-exec-environment.spec.js @@ -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'); + }); + }); +}); diff --git a/test/describe-context.spec.js b/test/describe-context.spec.js new file mode 100644 index 000000000..78be9a071 --- /dev/null +++ b/test/describe-context.spec.js @@ -0,0 +1,105 @@ +/** + * Tests for describe-context.js + * @file describe-context.spec.js + */ + +'use strict'; + +const chai = require('chai'); +const expect = chai.expect; +chai.should(); + +const describeContext = require('../utils/describe-context'); + +describe('describe-context', () => { + const originalStdinIsTTY = process.stdin.isTTY; + const originalStdoutIsTTY = process.stdout.isTTY; + const originalStderrIsTTY = process.stderr.isTTY; + const originalLando = process.lando; + const originalEnv = {...process.env}; + + afterEach(() => { + process.stdin.isTTY = originalStdinIsTTY; + process.stdout.isTTY = originalStdoutIsTTY; + process.stderr.isTTY = originalStderrIsTTY; + process.lando = originalLando; + // Restore env vars we may have changed + delete process.env.CI; + if (originalEnv.CI !== undefined) process.env.CI = originalEnv.CI; + }); + + it('should return an object with stdin, stdout, stderr, env, and flags', () => { + const ctx = describeContext(); + expect(ctx).to.have.property('stdin'); + expect(ctx).to.have.property('stdout'); + expect(ctx).to.have.property('stderr'); + expect(ctx).to.have.property('env'); + expect(ctx).to.have.property('isNodeMode'); + expect(ctx).to.have.property('ci'); + expect(ctx).to.have.property('landoColorLevel'); + }); + + it('should expose process.env as env', () => { + const ctx = describeContext(); + expect(ctx.env).to.equal(process.env); + }); + + it('should reflect stdin TTY state', () => { + process.stdin.isTTY = true; + expect(describeContext().stdin.isTTY).to.be.true; + + process.stdin.isTTY = undefined; + expect(describeContext().stdin.isTTY).to.be.false; + }); + + it('should reflect stdout TTY state', () => { + process.stdout.isTTY = true; + expect(describeContext().stdout.isTTY).to.be.true; + + process.stdout.isTTY = undefined; + expect(describeContext().stdout.isTTY).to.be.false; + }); + + it('should reflect stderr TTY state', () => { + process.stderr.isTTY = true; + expect(describeContext().stderr.isTTY).to.be.true; + + process.stderr.isTTY = undefined; + expect(describeContext().stderr.isTTY).to.be.false; + }); + + it('should default stdout columns and rows when not available', () => { + const ctx = describeContext(); + expect(ctx.stdout.columns).to.be.a('number'); + expect(ctx.stdout.rows).to.be.a('number'); + // Should have sensible defaults + expect(ctx.stdout.columns).to.be.at.least(1); + expect(ctx.stdout.rows).to.be.at.least(1); + }); + + it('should detect node mode from process.lando', () => { + process.lando = 'node'; + expect(describeContext().isNodeMode).to.be.true; + + process.lando = 'browser'; + expect(describeContext().isNodeMode).to.be.false; + + delete process.lando; + expect(describeContext().isNodeMode).to.be.false; + }); + + it('should detect CI from environment', () => { + process.env.CI = 'true'; + expect(describeContext().ci).to.be.true; + + delete process.env.CI; + expect(describeContext().ci).to.be.false; + }); + + it('should expose landoColorLevel as a number from chalk', () => { + const ctx = describeContext(); + expect(ctx.landoColorLevel).to.be.a('number'); + expect(ctx.landoColorLevel).to.be.at.least(0); + expect(ctx.landoColorLevel).to.be.at.most(3); + }); +}); diff --git a/test/extract-detach.spec.js b/test/extract-detach.spec.js new file mode 100644 index 000000000..9fa96d737 --- /dev/null +++ b/test/extract-detach.spec.js @@ -0,0 +1,87 @@ +/** + * Tests for extract-detach.js + * @file extract-detach.spec.js + */ + +'use strict'; + +const chai = require('chai'); +const expect = chai.expect; +chai.should(); + +const extractDetach = require('../utils/extract-detach'); + +describe('extract-detach', () => { + it('should detect bare trailing &', () => { + const result = extractDetach(['sleep', '100', '&']); + expect(result.detach).to.be.true; + expect(result.cmd).to.eql(['sleep', '100']); + }); + + it('should not treat literal trailing & in a plain argv argument as detach', () => { + const result = extractDetach(['curl', 'https://example.test/?a=1&']); + expect(result.detach).to.be.false; + expect(result.cmd).to.eql(['curl', 'https://example.test/?a=1&']); + }); + + it('should return detach=false when no trailing &', () => { + const result = extractDetach(['echo', 'hello']); + expect(result.detach).to.be.false; + expect(result.cmd).to.eql(['echo', 'hello']); + }); + + it('should handle empty command array', () => { + const result = extractDetach([]); + expect(result.detach).to.be.false; + expect(result.cmd).to.eql([]); + }); + + it('should work with exec.sh wrapper and bare &', () => { + const result = extractDetach(['/etc/lando/exec.sh', 'sleep', '100', '&']); + expect(result.detach).to.be.true; + expect(result.cmd).to.eql(['/etc/lando/exec.sh', 'sleep', '100']); + }); + + it('should work with exec.sh wrapper and appended &', () => { + const result = extractDetach(['/etc/lando/exec.sh', 'sleep', '100&']); + expect(result.detach).to.be.true; + expect(result.cmd).to.eql(['/etc/lando/exec.sh', 'sleep', '100']); + }); + + it('should work with sh -c wrapper', () => { + const result = extractDetach(['/bin/sh', '-c', 'sleep 100&']); + expect(result.detach).to.be.true; + expect(result.cmd).to.eql(['/bin/sh', '-c', 'sleep 100']); + }); + + it('should work with bash -c wrapper', () => { + const result = extractDetach(['/bin/bash', '-c', 'sleep 100&']); + expect(result.detach).to.be.true; + expect(result.cmd).to.eql(['/bin/bash', '-c', 'sleep 100']); + }); + + it('should not modify the original array', () => { + const original = ['sleep', '100', '&']; + const copy = [...original]; + extractDetach(original); + expect(original).to.eql(copy); + }); + + it('should ignore & in the middle of the command', () => { + const result = extractDetach(['echo', '&', 'hello']); + expect(result.detach).to.be.false; + expect(result.cmd).to.eql(['echo', '&', 'hello']); + }); + + it('should handle single-element command with &', () => { + const result = extractDetach(['&']); + expect(result.detach).to.be.true; + expect(result.cmd).to.eql([]); + }); + + it('should trim whitespace after removing & from shell wrappers', () => { + const result = extractDetach(['/bin/sh', '-c', 'sleep 100 &']); + expect(result.detach).to.be.true; + expect(result.cmd[2]).to.equal('sleep 100'); + }); +}); diff --git a/test/tty-allocation.spec.js b/test/tty-allocation.spec.js new file mode 100644 index 000000000..f05232711 --- /dev/null +++ b/test/tty-allocation.spec.js @@ -0,0 +1,324 @@ +/** + * Tests for TTY allocation in docker exec and compose exec. + * @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(); + +const {buildExecArgs} = require('../utils/build-docker-exec'); + +// Helper to build a minimal context object for testing +const makeContext = (overrides = {}) => { + const {stdin, stdout, stderr, env, ...rest} = overrides; + + return { + stdin: {isTTY: false, isClosed: false, ...stdin}, + stdout: {isTTY: false, columns: 80, rows: 24, ...stdout}, + stderr: {isTTY: false, ...stderr}, + env: env || {}, + isNodeMode: true, + ci: false, + landoColorLevel: 0, + ...rest, + }; +}; + +// Helper to build a minimal datum object for testing +const makeDatum = (overrides = {}) => ({ + id: 'test_container', + cmd: ['echo', 'hello'], + opts: {user: 'www-data', environment: {}, ...overrides.opts}, + ...overrides, +}); + +describe('TTY allocation', () => { + describe('docker exec (utils/build-docker-exec.js)', () => { + it('should include --tty when both stdin and stdout are TTYs', () => { + const ctx = makeContext({stdin: {isTTY: true}, stdout: {isTTY: true}}); + const args = buildExecArgs('docker', makeDatum(), ctx); + expect(args).to.include('--tty'); + }); + + it('should not include --tty when stdout is not a TTY (output redirected)', () => { + const ctx = makeContext({stdin: {isTTY: true}, stdout: {isTTY: false}}); + const args = buildExecArgs('docker', makeDatum(), ctx); + expect(args).to.not.include('--tty'); + }); + + it('should not include --tty when stdin is not a TTY', () => { + const ctx = makeContext({stdin: {isTTY: false}, stdout: {isTTY: true}}); + const args = buildExecArgs('docker', makeDatum(), ctx); + expect(args).to.not.include('--tty'); + }); + + it('should not include --tty when neither stdin nor stdout is a TTY', () => { + const ctx = makeContext({stdin: {isTTY: false}, stdout: {isTTY: false}}); + const args = buildExecArgs('docker', makeDatum(), ctx); + expect(args).to.not.include('--tty'); + }); + }); + + describe('interactive mode', () => { + it('should include --interactive in node mode', () => { + const ctx = makeContext({isNodeMode: true}); + const args = buildExecArgs('docker', makeDatum(), ctx); + expect(args).to.include('--interactive'); + }); + + it('should not include --interactive outside node mode', () => { + const ctx = makeContext({isNodeMode: false}); + const args = buildExecArgs('docker', makeDatum(), ctx); + expect(args).to.not.include('--interactive'); + }); + + it('should not include --interactive when stdin is closed', () => { + const ctx = makeContext({isNodeMode: true, stdin: {isTTY: true, isClosed: true}}); + const args = buildExecArgs('docker', makeDatum(), ctx); + expect(args).to.not.include('--interactive'); + }); + + it('should not include --interactive when detaching', () => { + const ctx = makeContext({isNodeMode: true}); + const datum = makeDatum({cmd: ['sleep', '100', '&']}); + const args = buildExecArgs('docker', datum, ctx); + expect(args).to.include('--detach'); + expect(args).to.not.include('--interactive'); + }); + }); + + describe('detach handling', () => { + it('should detect trailing & and add --detach', () => { + const ctx = makeContext(); + const datum = makeDatum({cmd: ['sleep', '100', '&']}); + const args = buildExecArgs('docker', datum, ctx); + expect(args).to.include('--detach'); + expect(args).to.not.include('&'); + }); + + it('should detect appended & in shell wrappers and add --detach', () => { + const ctx = makeContext(); + const datum = makeDatum({cmd: ['/bin/sh', '-c', 'sleep 100&']}); + const args = buildExecArgs('docker', datum, ctx); + expect(args).to.include('--detach'); + }); + + it('should not include --tty when detaching', () => { + const ctx = makeContext({stdin: {isTTY: true}, stdout: {isTTY: true}}); + const datum = makeDatum({cmd: ['sleep', '100', '&']}); + const args = buildExecArgs('docker', datum, ctx); + expect(args).to.include('--detach'); + expect(args).to.not.include('--tty'); + }); + }); + + describe('command assembly', () => { + it('should include workdir when set', () => { + const ctx = makeContext(); + const datum = makeDatum({opts: {user: 'root', environment: {}, workdir: '/app'}}); + const args = buildExecArgs('docker', datum, ctx); + const wdIdx = args.indexOf('--workdir'); + expect(wdIdx).to.be.greaterThan(-1); + expect(args[wdIdx + 1]).to.equal('/app'); + }); + + it('should include user', () => { + const ctx = makeContext(); + const datum = makeDatum({opts: {user: 'root', environment: {}}}); + const args = buildExecArgs('docker', datum, ctx); + const uIdx = args.indexOf('--user'); + expect(uIdx).to.be.greaterThan(-1); + expect(args[uIdx + 1]).to.equal('root'); + }); + + it('should include environment variables', () => { + const ctx = makeContext(); + const datum = makeDatum({opts: {user: 'root', environment: {FOO: 'bar'}}}); + const args = buildExecArgs('docker', datum, ctx); + expect(args).to.include('--env'); + expect(args).to.include('FOO=bar'); + }); + + it('should place container id before the command', () => { + const ctx = makeContext(); + const datum = makeDatum(); + const args = buildExecArgs('docker', datum, ctx); + const idIdx = args.indexOf('test_container'); + const cmdIdx = args.indexOf('echo'); + expect(idIdx).to.be.greaterThan(-1); + expect(cmdIdx).to.be.greaterThan(idIdx); + }); + + it('should use the specified docker binary', () => { + const ctx = makeContext(); + const args = buildExecArgs('/usr/local/bin/docker', makeDatum(), ctx); + expect(args[0]).to.equal('/usr/local/bin/docker'); + expect(args[1]).to.equal('exec'); + }); + }); + + describe('datum mutation (build-docker-exec.js caller contract)', () => { + it('should write cleaned cmd back to datum so compose fallback sees it', () => { + const ctx = makeContext(); + const datum = makeDatum({cmd: ['sleep', '100', '&']}); + // Simulate what the exported module function does + buildExecArgs('docker', datum, ctx); + // The internal buildExecArgs does NOT mutate, but the exported + // wrapper writes back. Test the extractDetach write-back that + // the outer function performs. + const extractDetach = require('../utils/extract-detach'); + datum.cmd = extractDetach(datum.cmd).cmd; + expect(datum.cmd).to.eql(['sleep', '100']); + expect(datum.cmd).to.not.include('&'); + }); + + it('should write cleaned cmd for shell wrapper detach', () => { + const datum = makeDatum({cmd: ['/bin/sh', '-c', 'sleep 100&']}); + const extractDetach = require('../utils/extract-detach'); + datum.cmd = extractDetach(datum.cmd).cmd; + expect(datum.cmd).to.eql(['/bin/sh', '-c', 'sleep 100']); + }); + }); + + describe('compose exec (lib/compose.js)', () => { + const originalStdinIsTTY = process.stdin.isTTY; + const originalStdoutIsTTY = process.stdout.isTTY; + + afterEach(() => { + process.stdin.isTTY = originalStdinIsTTY; + process.stdout.isTTY = originalStdoutIsTTY; + }); + + 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']}, + ); + 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']}, + ); + 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'); + }); + + it('should allow explicit noTTY override', () => { + 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'], noTTY: true}, + ); + expect(result.cmd).to.include('-T'); + }); + + it('should detect detach from trailing & in command and force noTTY', () => { + 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: ['sleep', '100', '&']}, + ); + expect(result.cmd).to.include('--detach'); + expect(result.cmd).to.include('-T'); + }); + + it('should inject COLUMNS and LINES when noTTY is set', () => { + 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'); + // COLUMNS and LINES should be injected via --env + expect(result.cmd).to.include('--env'); + const envPairs = result.cmd.filter((_, i, a) => i > 0 && a[i - 1] === '--env'); + const colEntry = envPairs.find(e => e.startsWith('COLUMNS=')); + const lineEntry = envPairs.find(e => e.startsWith('LINES=')); + expect(colEntry).to.be.a('string'); + expect(lineEntry).to.be.a('string'); + }); + + it('should not inject COLUMNS and LINES when TTY is allocated', () => { + 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']}, + ); + const envPairs = result.cmd.filter((_, i, a) => i > 0 && a[i - 1] === '--env'); + const colEntry = envPairs.find(e => e.startsWith('COLUMNS=')); + expect(colEntry).to.be.undefined; + }); + + it('should not clobber caller-provided environment vars', () => { + 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'], environment: {COLUMNS: '999', MY_VAR: 'keep'}}, + ); + const envPairs = result.cmd.filter((_, i, a) => i > 0 && a[i - 1] === '--env'); + // Caller COLUMNS should win over the injected default + expect(envPairs).to.include('COLUMNS=999'); + expect(envPairs).to.include('MY_VAR=keep'); + }); + }); +}); diff --git a/utils/build-docker-exec.js b/utils/build-docker-exec.js index 651f505e2..30f72e8da 100644 --- a/utils/build-docker-exec.js +++ b/utils/build-docker-exec.js @@ -1,59 +1,71 @@ 'use strict'; -const _ = require('lodash'); +const describeContext = require('./describe-context'); +const extractDetach = require('./extract-detach'); +const buildEnvironment = require('./build-exec-environment'); /* - * Build docker exec opts + * Builds the docker exec argument array. + * + * Each concern — TTY allocation, interactive mode, detach detection, + * environment propagation — reads from the context object rather than + * from process globals directly. This makes every decision testable + * with plain objects. */ -const getExecOpts = (docker, datum) => { - const exec = [docker, 'exec']; - // Should only use this if we have to - if (process.stdin.isTTY) exec.push('--tty'); - // Should only set interactive in node mode - if (process.lando === 'node') exec.push('--interactive'); - // add workdir if we can +const buildExecArgs = (docker, datum, context) => { + const args = [docker, 'exec']; + const {cmd, detach} = extractDetach(datum.cmd); + + if (detach) { + args.push('--detach'); + } else { + // Allocate a PTY only when both sides are real terminals and + // we're not detaching (detach + tty is nonsensical) + if (context.stdin.isTTY && context.stdout.isTTY) { + args.push('--tty'); + } + + // Keep stdin open when running in node mode and stdin isn't closed. + // Skip when detaching — there's no stdin to attach to. + if (context.isNodeMode && !context.stdin.isClosed) { + args.push('--interactive'); + } + } + if (datum.opts.workdir) { - exec.push('--workdir'); - exec.push(datum.opts.workdir); + args.push('--workdir', datum.opts.workdir); } - // Add user - exec.push('--user'); - exec.push(datum.opts.user); - // Add envvvars - _.forEach(datum.opts.environment, (value, key) => { - exec.push('--env'); - exec.push(`${key}=${value}`); - }); - - // Assess the intention to detach for execers - if (datum.cmd[0] === '/etc/lando/exec.sh' && datum.cmd[datum.cmd.length - 1] === '&') { - datum.cmd.pop(); - exec.push('--detach'); - } else if (datum.cmd[0] === '/etc/lando/exec.sh' && datum.cmd[datum.cmd.length - 1].endsWith('&')) { - datum.cmd[datum.cmd.length - 1] = datum.cmd[datum.cmd.length - 1].slice(0, -1).trim(); - exec.push('--detach'); - // Assess the intention to detach for shell wrappers - } else if (datum.cmd[0].endsWith('sh') && datum.cmd[1] === '-c' && datum.cmd[2].endsWith('&')) { - datum.cmd[2] = datum.cmd[2].slice(0, -1).trim(); - exec.push('--detach'); - } else if (datum.cmd[0].endsWith('bash') && datum.cmd[1] === '-c' && datum.cmd[2].endsWith('&')) { - datum.cmd[2] = datum.cmd[2].slice(0, -1).trim(); - exec.push('--detach'); - // Assess the intention to detach for everything else - } else if (datum.cmd[datum.cmd.length - 1] === '&') { - datum.cmd.pop(); - exec.push('--detach'); + + args.push('--user', datum.opts.user); + + const env = buildEnvironment(context, datum.opts.environment); + for (const [key, value] of Object.entries(env)) { + args.push('--env', `${key}=${value}`); } - // Add id - exec.push(datum.id); - return exec; + args.push(datum.id); + args.push(...cmd); + + return args; }; module.exports = (injected, stdio, datum = {}) => { - // Depending on whether injected is the app or lando const dockerBin = injected.config.dockerBin || injected._config.dockerBin; - const opts = {mode: 'attach', cstdio: stdio}; - // Run run run - return injected.shell.sh(getExecOpts(dockerBin, datum).concat(datum.cmd), opts); + const context = describeContext(); + + // Extract detach once and reuse the cleaned command for both + // the arg builder and the datum mutation contract. + const {cmd: cleanCmd} = extractDetach(datum.cmd); + const args = buildExecArgs(dockerBin, datum, context); + + // Write the cleaned command back to datum so callers that reuse the + // same object (e.g. build-tooling-task.js compose fallback) see it + // without the trailing '&'. This preserves the mutation contract + // the old getExecOpts() relied on. + datum.cmd = cleanCmd; + + return injected.shell.sh(args, {mode: 'attach', cstdio: stdio}); }; + +// Expose internals for testing +module.exports.buildExecArgs = buildExecArgs; diff --git a/utils/build-docker-exec.types.js b/utils/build-docker-exec.types.js new file mode 100644 index 000000000..360769909 --- /dev/null +++ b/utils/build-docker-exec.types.js @@ -0,0 +1,21 @@ +'use strict'; + +/** + * @file Type definitions for {@link module:utils/build-docker-exec}. + */ + +/** + * @typedef {object} ExecDatumOpts + * @property {string} [workdir] — working directory inside the container + * @property {string} user — user to run as inside the container + * @property {Record} [environment] — caller-provided env overrides + */ + +/** + * @typedef {object} ExecDatum + * @property {string[]} cmd — the command to execute + * @property {string} id — target container id + * @property {ExecDatumOpts} opts + */ + +module.exports = {}; diff --git a/utils/build-exec-environment.js b/utils/build-exec-environment.js new file mode 100644 index 000000000..3af0ff586 --- /dev/null +++ b/utils/build-exec-environment.js @@ -0,0 +1,49 @@ +'use strict'; + +// Host variables to forward when set. Terminal type and locale +// provide context so tools inside the container produce appropriate +// output; TZ keeps timestamps consistent with the host. +const forwardKeys = [ + 'TERM', 'COLORTERM', 'TERM_PROGRAM', + 'LANG', 'LC_ALL', 'LC_CTYPE', 'LC_MESSAGES', + 'TZ', +]; + +/* + * Builds the environment variables for a docker exec invocation. + * + * Three layers with explicit precedence: + * 1. inherited — host vars forwarded when set + * 2. synthetic — derived from context analysis (e.g. COLUMNS/LINES, + * color suppression when Lando itself is not producing color) + * 3. userEnv — explicit user overrides (always win) + */ +module.exports = (context, userEnv = {}) => { + const hostEnv = context.env || process.env; + const inherited = {}; + for (const key of forwardKeys) { + if (hostEnv[key] === undefined) continue; + inherited[key] = hostEnv[key]; + } + + const synthetic = {}; + + // When Lando itself isn't producing colorful output, tell containers + // not to either. context.landoColorLevel mirrors chalk.level which + // already accounts for NO_COLOR, FORCE_COLOR, TERM=dumb, TTY state, + // and every other signal the host uses to decide on color support. + if (context.landoColorLevel === 0) { + synthetic.NO_COLOR = '1'; + } + + if (!context.stdout.isTTY) { + // No PTY means no SIGWINCH, but a static hint is better than nothing + synthetic.COLUMNS = String(context.stdout.columns); + synthetic.LINES = String(context.stdout.rows); + } + + return {...inherited, ...synthetic, ...userEnv}; +}; + +// Expose for testing +module.exports.forwardKeys = forwardKeys; diff --git a/utils/describe-context.js b/utils/describe-context.js new file mode 100644 index 000000000..eaa22d083 --- /dev/null +++ b/utils/describe-context.js @@ -0,0 +1,34 @@ +'use strict'; + +const chalk = require('chalk'); + +/* + * Describes the current execution context as a plain object. + * + * Every decision downstream reads from this object instead of from + * `process` directly. That single change makes the whole exec builder + * deterministic and testable with plain objects. + */ +module.exports = () => ({ + stdin: { + isTTY: Boolean(process.stdin.isTTY), + isClosed: process.stdin.readableEnded || false, + }, + stdout: { + isTTY: Boolean(process.stdout.isTTY), + columns: process.stdout.columns || 80, + rows: process.stdout.rows || 24, + }, + stderr: { + isTTY: Boolean(process.stderr.isTTY), + }, + env: process.env, + isNodeMode: process.lando === 'node', + ci: Boolean(process.env.CI), + // chalk.level: 0=none, 1=basic, 2=256, 3=truecolor. + // Reflects whether Lando itself is producing colorful output. + // chalk already accounts for NO_COLOR, FORCE_COLOR, TERM=dumb, + // TTY state, etc. so downstream code can treat this as the single + // source of truth for color support. + landoColorLevel: chalk.level, +}); diff --git a/utils/describe-context.types.js b/utils/describe-context.types.js new file mode 100644 index 000000000..1055673e7 --- /dev/null +++ b/utils/describe-context.types.js @@ -0,0 +1,41 @@ +'use strict'; + +/** + * @file Type definitions for {@link module:utils/describe-context}. + */ + +/** + * @typedef {object} StdinContext + * @property {boolean} isTTY — whether stdin is connected to a terminal + * @property {boolean} isClosed — whether the readable side has already ended + */ + +/** + * @typedef {object} StdoutContext + * @property {boolean} isTTY — whether stdout is connected to a terminal + * @property {number} columns — terminal width (defaults to 80 when not a TTY) + * @property {number} rows — terminal height (defaults to 24 when not a TTY) + */ + +/** + * @typedef {object} StderrContext + * @property {boolean} isTTY — whether stderr is connected to a terminal + */ + +/** + * @typedef {object} ExecutionContext + * @property {StdinContext} stdin + * @property {StdoutContext} stdout + * @property {StderrContext} stderr + * @property {Record} env — host environment variables + * @property {boolean} isNodeMode — true when `process.lando === 'node'` + * @property {boolean} ci — true when the CI env var is set + * @property {0 | 1 | 2 | 3} landoColorLevel — chalk color level + * (0 = none, 1 = basic, 2 = 256-color, 3 = truecolor). + * Reflects whether Lando itself is producing colorful output. + * chalk already accounts for NO_COLOR, FORCE_COLOR, TERM=dumb, + * TTY state, etc. so downstream code can treat this as the single + * source of truth for color support. + */ + +module.exports = {}; diff --git a/utils/extract-detach.js b/utils/extract-detach.js new file mode 100644 index 000000000..6e4734d12 --- /dev/null +++ b/utils/extract-detach.js @@ -0,0 +1,39 @@ +'use strict'; + +/* + * Extracts detach intent from a command array. + * + * Detects and removes detach syntax from the command, returning a + * clean copy and a boolean indicating whether to detach. + * + * A bare trailing '&' is only meaningful as shell syntax, so we accept + * it for all command shapes. An appended '&' is only shell syntax for + * wrapper-style commands where the final command is itself a shell + * string (`sh -c`, `bash -c`, or `/etc/lando/exec.sh`). For plain argv + * commands, a trailing '&' can be literal data and must be preserved. + * + * The previous implementation had separate branches for each wrapper + * type, but in every case the '&' was either the last element or + * appended to the last element. This normalizes that in one place. + */ +module.exports = cmd => { + const parts = [...cmd]; + let detach = false; + + if (parts.length === 0) return {cmd: parts, detach}; + + // Trailing bare '&' + if (parts[parts.length - 1] === '&') { + parts.pop(); + detach = true; + // '&' appended to a shell string inside a wrapper command + } else if (parts[0] === '/etc/lando/exec.sh' && parts[parts.length - 1] && parts[parts.length - 1].endsWith('&')) { + parts[parts.length - 1] = parts[parts.length - 1].slice(0, -1).trim(); + detach = true; + } else if (parts[0] && parts[0].endsWith('sh') && parts[1] === '-c' && parts[2] && parts[2].endsWith('&')) { + parts[2] = parts[2].slice(0, -1).trim(); + detach = true; + } + + return {cmd: parts, detach}; +}; diff --git a/utils/extract-detach.types.js b/utils/extract-detach.types.js new file mode 100644 index 000000000..b7e2f5b25 --- /dev/null +++ b/utils/extract-detach.types.js @@ -0,0 +1,13 @@ +'use strict'; + +/** + * @file Type definitions for {@link module:utils/extract-detach}. + */ + +/** + * @typedef {object} DetachResult + * @property {string[]} cmd — cleaned command array with detach syntax removed + * @property {boolean} detach — whether detach intent was detected + */ + +module.exports = {};