From 2f98f471c962d5eaf2139e6669a0158433e31523 Mon Sep 17 00:00:00 2001 From: Anna Henningsen Date: Wed, 15 Jan 2025 16:12:22 +0100 Subject: [PATCH 1/5] feat(cli-repl): add support for bracketed paste in REPL MONGOSH-1909 Bracketed paste allows us to receive a copy-pasted piece of mongosh as a single block, rather than interpreting it line-by-line. For now, this requires some monkey-patching of Node.js internals, so a follow-up ticket will include work to upstream support for this into Node.js core. --- package-lock.json | 2 +- packages/cli-repl/package.json | 2 +- packages/cli-repl/src/async-repl.spec.ts | 39 ++++++++ packages/cli-repl/src/async-repl.ts | 21 ++++- packages/cli-repl/src/cli-repl.spec.ts | 5 +- packages/cli-repl/src/line-by-line-input.ts | 7 +- packages/cli-repl/src/mongosh-repl.ts | 10 +- .../cli-repl/src/repl-paste-support.spec.ts | 91 +++++++++++++++++++ packages/cli-repl/src/repl-paste-support.ts | 67 ++++++++++++++ packages/e2e-tests/test/e2e.spec.ts | 13 +++ 10 files changed, 246 insertions(+), 11 deletions(-) create mode 100644 packages/cli-repl/src/repl-paste-support.spec.ts create mode 100644 packages/cli-repl/src/repl-paste-support.ts diff --git a/package-lock.json b/package-lock.json index 9ca643c703..5531ca50a3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29335,7 +29335,7 @@ "webpack-merge": "^5.8.0" }, "engines": { - "node": ">=16.15.0" + "node": ">=18.19.0" }, "optionalDependencies": { "get-console-process-list": "^1.0.5", diff --git a/packages/cli-repl/package.json b/packages/cli-repl/package.json index 93d3bba513..307d6ab206 100644 --- a/packages/cli-repl/package.json +++ b/packages/cli-repl/package.json @@ -43,7 +43,7 @@ "access": "public" }, "engines": { - "node": ">=16.15.0" + "node": ">=18.19.0" }, "mongosh": { "ciRequiredOptionalDependencies": { diff --git a/packages/cli-repl/src/async-repl.spec.ts b/packages/cli-repl/src/async-repl.spec.ts index 04fa7aed5c..08c2a53b3e 100644 --- a/packages/cli-repl/src/async-repl.spec.ts +++ b/packages/cli-repl/src/async-repl.spec.ts @@ -313,4 +313,43 @@ describe('AsyncRepl', function () { }); }); }); + + it('does not run pasted text immediately', async function () { + const { input, output } = createDefaultAsyncRepl({ + terminal: true, + useColors: false, + }); + + output.read(); // Read prompt so it doesn't mess with further output + input.write('\x1b[200~1234\n*5678\n\x1b[201~'); + await tick(); + // ESC[nG is horizontal cursor movement, ESC[nJ is cursor display reset + expect(output.read()).to.equal( + '1234\r\n\x1B[1G\x1B[0J... \x1B[5G*5678\r\n\x1B[1G\x1B[0J... \x1B[5G' + ); + input.write('\n'); + await tick(); + // Contains the expected result after hitting newline + expect(output.read()).to.equal('\r\n7006652\n\x1B[1G\x1B[0J> \x1B[3G'); + }); + + it('allows using ctrl+c to avoid running pasted text', async function () { + const { input, output } = createDefaultAsyncRepl({ + terminal: true, + useColors: false, + }); + + output.read(); // Read prompt so it doesn't mess with further output + input.write('\x1b[200~1234\n*5678\n\x1b[201~'); + await tick(); + expect(output.read()).to.equal( + '1234\r\n\x1B[1G\x1B[0J... \x1B[5G*5678\r\n\x1B[1G\x1B[0J... \x1B[5G' + ); + input.write('\x03'); // Ctrl+C + await tick(); + expect(output.read()).to.equal('\r\n\x1b[1G\x1b[0J> \x1b[3G'); + input.write('"foo";\n'); // Write something else + await tick(); + expect(output.read()).to.equal(`"foo";\r\n'foo'\n\x1B[1G\x1B[0J> \x1B[3G`); + }); }); diff --git a/packages/cli-repl/src/async-repl.ts b/packages/cli-repl/src/async-repl.ts index c05ee3d7ca..b9ee863afa 100644 --- a/packages/cli-repl/src/async-repl.ts +++ b/packages/cli-repl/src/async-repl.ts @@ -5,6 +5,7 @@ import type { ReadLineOptions } from 'readline'; import type { ReplOptions, REPLServer } from 'repl'; import type { start as originalStart } from 'repl'; import { promisify } from 'util'; +import type { KeypressKey } from './repl-paste-support'; // Utility, inverse of Readonly type Mutable = { @@ -75,7 +76,9 @@ function getPrompt(repl: any): string { export function start(opts: AsyncREPLOptions): REPLServer { // 'repl' is not supported in startup snapshots yet. // eslint-disable-next-line @typescript-eslint/no-var-requires - const { Recoverable, start: originalStart } = require('repl'); + const { Recoverable, start: originalStart } = + // eslint-disable-next-line @typescript-eslint/consistent-type-imports + require('repl') as typeof import('repl'); const { asyncEval, wrapCallbackError = (err) => err, onAsyncSigint } = opts; if (onAsyncSigint) { (opts as ReplOptions).breakEvalOnSigint = true; @@ -96,12 +99,28 @@ export function start(opts: AsyncREPLOptions): REPLServer { return wasInRawMode; }; + // TODO(MONGOSH-1911): Upstream this feature into Node.js core. + let isPasting = false; + repl.input.on('keypress', (s: string, key: KeypressKey) => { + if (key.name === 'paste-start') { + isPasting = true; + } else if (key.name === 'paste-end') { + isPasting = false; + } + }); + (repl as Mutable).eval = ( input: string, context: any, filename: string, callback: (err: Error | null, result?: any) => void ): void => { + if (isPasting) { + return callback( + new Recoverable(new Error('recoverable because pasting in progress')) + ); + } + async function _eval() { let previouslyInRawMode; diff --git a/packages/cli-repl/src/cli-repl.spec.ts b/packages/cli-repl/src/cli-repl.spec.ts index a62c3cb324..3f915bfbd6 100644 --- a/packages/cli-repl/src/cli-repl.spec.ts +++ b/packages/cli-repl/src/cli-repl.spec.ts @@ -2612,8 +2612,7 @@ describe('CliRepl', function () { for (const { version, deprecated } of [ { version: 'v20.5.1', deprecated: false }, { version: '20.0.0', deprecated: false }, - { version: '18.0.0', deprecated: true }, - { version: '16.20.3', deprecated: true }, + { version: '18.19.0', deprecated: true }, ]) { delete (process as any).version; (process as any).version = version; @@ -2639,7 +2638,7 @@ describe('CliRepl', function () { it('does not print any deprecation warning when CLI is ran with --quiet flag', async function () { // Setting all the possible situation for a deprecation warning - process.version = '16.20.3'; + process.version = '18.20.0'; process.versions.openssl = '1.1.11'; cliRepl.getGlibcVersion = () => '1.27'; diff --git a/packages/cli-repl/src/line-by-line-input.ts b/packages/cli-repl/src/line-by-line-input.ts index 5508eb03a2..0d88bbcc2f 100644 --- a/packages/cli-repl/src/line-by-line-input.ts +++ b/packages/cli-repl/src/line-by-line-input.ts @@ -1,5 +1,6 @@ import { Readable } from 'stream'; import { StringDecoder } from 'string_decoder'; +import type { ReadStream } from 'tty'; const LINE_ENDING_RE = /\r?\n|\r(?!\n)/; const CTRL_C = '\u0003'; @@ -22,14 +23,14 @@ const CTRL_D = '\u0004'; * the proxied `tty.ReadStream`, forwarding all the characters. */ export class LineByLineInput extends Readable { - private _originalInput: NodeJS.ReadStream; + private _originalInput: Readable & Partial; private _forwarding: boolean; private _blockOnNewLineEnabled: boolean; private _charQueue: (string | null)[]; private _decoder: StringDecoder; private _insidePushCalls: number; - constructor(readable: NodeJS.ReadStream) { + constructor(readable: Readable & Partial) { super(); this._originalInput = readable; this._forwarding = true; @@ -64,7 +65,7 @@ export class LineByLineInput extends Readable { ); const proxy = new Proxy(readable, { - get: (target: NodeJS.ReadStream, property: string): any => { + get: (target: typeof readable, property: string): any => { if ( typeof property === 'string' && !property.startsWith('_') && diff --git a/packages/cli-repl/src/mongosh-repl.ts b/packages/cli-repl/src/mongosh-repl.ts index 7e2d0a729d..2d917c2bb8 100644 --- a/packages/cli-repl/src/mongosh-repl.ts +++ b/packages/cli-repl/src/mongosh-repl.ts @@ -42,6 +42,7 @@ import type { FormatOptions } from './format-output'; import { markTime } from './startup-timing'; import type { Context } from 'vm'; import { Script, createContext, runInContext } from 'vm'; +import { installPasteSupport } from './repl-paste-support'; declare const __non_webpack_require__: any; @@ -134,6 +135,7 @@ class MongoshNodeRepl implements EvaluationListener { input: Readable; lineByLineInput: LineByLineInput; output: Writable; + outputFinishString = ''; // Can add ANSI escape codes to reset state from previously written ones bus: MongoshBus; nodeReplOptions: Partial; shellCliOptions: Partial; @@ -250,7 +252,7 @@ class MongoshNodeRepl implements EvaluationListener { // 'repl' is not supported in startup snapshots yet. // eslint-disable-next-line @typescript-eslint/no-var-requires start: require('pretty-repl').start, - input: this.lineByLineInput as unknown as Readable, + input: this.lineByLineInput, output: this.output, prompt: '', writer: this.writer.bind(this), @@ -386,6 +388,8 @@ class MongoshNodeRepl implements EvaluationListener { const { repl, instanceState } = this.runtimeState(); if (!repl) return; + this.outputFinishString += installPasteSupport(repl); + const origReplCompleter = promisify(repl.completer.bind(repl)); // repl.completer is callback-style const mongoshCompleter = completer.bind( null, @@ -1075,7 +1079,9 @@ class MongoshNodeRepl implements EvaluationListener { await once(rs.repl, 'exit'); } await rs.instanceState.close(true); - await new Promise((resolve) => this.output.write('', resolve)); + await new Promise((resolve) => + this.output.write(this.outputFinishString, resolve) + ); } } diff --git a/packages/cli-repl/src/repl-paste-support.spec.ts b/packages/cli-repl/src/repl-paste-support.spec.ts new file mode 100644 index 0000000000..c19a84e2f1 --- /dev/null +++ b/packages/cli-repl/src/repl-paste-support.spec.ts @@ -0,0 +1,91 @@ +import type { ReplOptions, REPLServer } from 'repl'; +import { start } from 'repl'; +import type { Readable, Writable } from 'stream'; +import { PassThrough } from 'stream'; +import { tick } from '../test/repl-helpers'; +import { installPasteSupport } from './repl-paste-support'; +import { expect } from 'chai'; + +function createTerminalRepl(extraOpts: Partial = {}): { + input: Writable; + output: Readable; + repl: REPLServer; +} { + const input = new PassThrough(); + const output = new PassThrough({ encoding: 'utf8' }); + + const repl = start({ + input: input, + output: output, + prompt: '> ', + terminal: true, + useColors: false, + ...extraOpts, + }); + return { input, output, repl }; +} + +describe('installPasteSupport', function () { + it('does nothing for non-terminal REPL instances', async function () { + const { repl, output } = createTerminalRepl({ terminal: false }); + const onFinish = installPasteSupport(repl); + await tick(); + expect(output.read()).to.equal('> '); + expect(onFinish).to.equal(''); + }); + + it('prints a control character sequence that indicates support for bracketed paste', async function () { + const { repl, output } = createTerminalRepl(); + const onFinish = installPasteSupport(repl); + await tick(); + expect(output.read()).to.include('\x1B[?2004h'); + expect(onFinish).to.include('\x1B[?2004l'); + }); + + it('echoes back control characters in the input by default', async function () { + const { repl, input, output } = createTerminalRepl(); + installPasteSupport(repl); + await tick(); + output.read(); // Ignore prompt etc. + input.write('foo\x1b[Dbar'); // ESC[D = 1 character to the left + await tick(); + expect(output.read()).to.equal( + 'foo\x1B[1D\x1B[1G\x1B[0J> fobo\x1B[6G\x1B[1G\x1B[0J> fobao\x1B[7G\x1B[1G\x1B[0J> fobaro\x1B[8G' + ); + }); + + it('ignores control characters in the input while pasting', async function () { + const { repl, input, output } = createTerminalRepl(); + installPasteSupport(repl); + await tick(); + output.read(); // Ignore prompt etc. + input.write('\x1b[200~foo\x1b[Dbar\x1b[201~'); // ESC[D = 1 character to the left + await tick(); + expect(output.read()).to.equal('foobar'); + }); + + it('resets to accepting control characters in the input after pasting', async function () { + const { repl, input, output } = createTerminalRepl(); + installPasteSupport(repl); + await tick(); + output.read(); + input.write('\x1b[200~foo\x1b[Dbar\x1b[201~'); // ESC[D = 1 character to the left + await tick(); + output.read(); + input.write('foo\x1b[Dbar'); + await tick(); + expect(output.read()).to.equal( + 'foo\x1B[1D\x1B[1G\x1B[0J> foobarfobo\x1B[12G\x1B[1G\x1B[0J> foobarfobao\x1B[13G\x1B[1G\x1B[0J> foobarfobaro\x1B[14G' + ); + }); + + it('allows a few special characters while pasting', async function () { + const { repl, input, output } = createTerminalRepl(); + installPasteSupport(repl); + await tick(); + output.read(); + input.write('\x1b[200~12*34\n_*_\n\x1b[201~'); + await tick(); + expect(output.read()).to.include((12 * 34) ** 2); + }); +}); diff --git a/packages/cli-repl/src/repl-paste-support.ts b/packages/cli-repl/src/repl-paste-support.ts new file mode 100644 index 0000000000..8cfa484102 --- /dev/null +++ b/packages/cli-repl/src/repl-paste-support.ts @@ -0,0 +1,67 @@ +import type { REPLServer } from 'repl'; + +// https://github.com/nodejs/node/blob/d9786109b2a0982677135f0c146f6b591a0e4961/lib/internal/readline/utils.js#L90 +// https://nodejs.org/api/readline.html#readlineemitkeypresseventsstream-interface +export type KeypressKey = { + sequence: string | null; + name: string | undefined; + ctrl: boolean; + meta: boolean; + shift: boolean; + code?: string; +}; + +function* prototypeChain(obj: unknown): Iterable { + if (!obj) return; + yield obj; + yield* prototypeChain(Object.getPrototypeOf(obj)); +} + +export function installPasteSupport(repl: REPLServer): string { + if (!repl.terminal) return ''; // No paste needed in non-terminal environments + + // TODO(MONGOSH-1911): Upstream as much of this into Node.js core as possible, + // both because of the value to the wider community but also because this is + // messing with Node.js REPL internals to a very unfortunate degree. + repl.output.write('\x1b[?2004h'); // Indicate support for paste mote + const onEnd = '\x1b[?2004l'; // End of support for paste mode + // Find the symbol used for the (internal) _ttyWrite method of readline.Interface + // https://github.com/nodejs/node/blob/d9786109b2a0982677135f0c146f6b591a0e4961/lib/internal/readline/interface.js#L1056 + const ttyWriteKey = [...prototypeChain(repl)] + .flatMap((proto) => Object.getOwnPropertySymbols(proto)) + .find((s) => String(s).includes('(_ttyWrite)')); + if (!ttyWriteKey) + throw new Error('Could not find _ttyWrite key on readline instance'); + repl.input.on('keypress', (s: string, key: KeypressKey) => { + if (key.name === 'paste-start') { + if (Object.prototype.hasOwnProperty.call(repl, ttyWriteKey)) + throw new Error( + 'Unexpected existing own _ttyWrite key on readline instance' + ); + const origTtyWrite = (repl as any)[ttyWriteKey]; + Object.defineProperty(repl as any, ttyWriteKey, { + value: function (s: string, key: KeypressKey) { + if (key.ctrl || key.meta || key.code) { + // Special character or escape code sequence, ignore while pasting + return; + } + if ( + key.name && + key.name !== key.sequence?.toLowerCase() && + !['tab', 'return', 'enter', 'space'].includes(key.name) + ) { + // Special character or escape code sequence, ignore while pasting + return; + } + return origTtyWrite.call(this, s, key); + }, + enumerable: false, + writable: true, + configurable: true, + }); + } else if (key.name === 'paste-end') { + delete (repl as any)[ttyWriteKey]; + } + }); + return onEnd; +} diff --git a/packages/e2e-tests/test/e2e.spec.ts b/packages/e2e-tests/test/e2e.spec.ts index c9b2b18c74..084812b563 100644 --- a/packages/e2e-tests/test/e2e.spec.ts +++ b/packages/e2e-tests/test/e2e.spec.ts @@ -298,6 +298,19 @@ describe('e2e', function () { await shell.waitForSuccessfulExit(); shell.assertContainsOutput('3628800'); }); + it('ignores control characters in TTY input', async function () { + shell = this.startTestShell({ + args: ['--nodb'], + forceTerminal: true, + }); + await shell.waitForPrompt(); + shell.assertNoErrors(); + + expect(await shell.executeLine('24\x08 * 3\n')).to.include('\n6\n'); // \x08 is backspace + expect( + await shell.executeLine('\x1b[200~24\x08 * 3\x1b[201~\n') + ).to.include('\n72\n'); + }); }); describe('set db', function () { From eb4ceb01f9aa597e19e7a9ed3b62da36d5713929 Mon Sep 17 00:00:00 2001 From: Anna Henningsen Date: Fri, 24 Jan 2025 14:31:55 +0100 Subject: [PATCH 2/5] fixup: unset TERM=dumb on s390x --- .evergreen/setup-env.sh | 7 +++++++ packages/e2e-tests/test/e2e.spec.ts | 3 --- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/.evergreen/setup-env.sh b/.evergreen/setup-env.sh index 3d286a2139..fd555dba72 100755 --- a/.evergreen/setup-env.sh +++ b/.evergreen/setup-env.sh @@ -17,6 +17,13 @@ export MONGOSH_TEST_ONLY_MAX_LOG_FILE_COUNT=100000 export IS_MONGOSH_EVERGREEN_CI=1 export DEBUG="mongodb*,$DEBUG" +# This is, weirdly enough, specifically set on s390x hosts, but messes +# with our e2e tests. +if [ x"$TERM" = x"dumb" ]; then + unset TERM +fi +echo "TERM variable is set to '${TERM:-}'" + if [ "$OS" != "Windows_NT" ]; then if which realpath; then # No realpath on macOS, but also not needed there export HOME="$(realpath "$HOME")" # Needed to de-confuse nvm when /home is a symlink diff --git a/packages/e2e-tests/test/e2e.spec.ts b/packages/e2e-tests/test/e2e.spec.ts index 084812b563..56f7d14cb6 100644 --- a/packages/e2e-tests/test/e2e.spec.ts +++ b/packages/e2e-tests/test/e2e.spec.ts @@ -1523,9 +1523,6 @@ describe('e2e', function () { describe('history file', function () { it('persists between sessions', async function () { - if (process.arch === 's390x') { - return this.skip(); // https://jira.mongodb.org/browse/MONGOSH-746 - } await shell.executeLine('a = 42'); shell.writeInput('.exit\n'); await shell.waitForSuccessfulExit(); From b83a81b940dbda2b1c4e9dd376680dabd5128785 Mon Sep 17 00:00:00 2001 From: Anna Henningsen Date: Fri, 24 Jan 2025 15:00:36 +0100 Subject: [PATCH 3/5] fixup: add test for .editor mode --- packages/e2e-tests/test/e2e.spec.ts | 21 +++++++++++++++++++-- packages/e2e-tests/test/test-shell.ts | 4 ++-- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/packages/e2e-tests/test/e2e.spec.ts b/packages/e2e-tests/test/e2e.spec.ts index 56f7d14cb6..e1fd118b7e 100644 --- a/packages/e2e-tests/test/e2e.spec.ts +++ b/packages/e2e-tests/test/e2e.spec.ts @@ -306,9 +306,26 @@ describe('e2e', function () { await shell.waitForPrompt(); shell.assertNoErrors(); - expect(await shell.executeLine('24\x08 * 3\n')).to.include('\n6\n'); // \x08 is backspace + expect(await shell.executeLine('24\b * 3\n')).to.include('\n6\n'); // \b is backspace expect( - await shell.executeLine('\x1b[200~24\x08 * 3\x1b[201~\n') + await shell.executeLine('\x1b[200~24\b * 3\x1b[201~\n') + ).to.include('\n72\n'); + }); + it('ignores control characters in TTY input inside of .editor', async function () { + shell = this.startTestShell({ + args: ['--nodb'], + forceTerminal: true, + }); + await shell.waitForPrompt(); + shell.assertNoErrors(); + + const start = shell.output.length; + shell.writeInputLine('.editor'); + await shell.waitForPrompt(start, { + promptPattern: /\/\/ Entering editor mode/, + }); + expect( + await shell.executeLine('\x1b[200~24\b * 3\x1b[201~\x04') // \x04 is Ctrl+D to finish code ).to.include('\n72\n'); }); }); diff --git a/packages/e2e-tests/test/test-shell.ts b/packages/e2e-tests/test/test-shell.ts index aa8744eb9d..3a5d870b82 100644 --- a/packages/e2e-tests/test/test-shell.ts +++ b/packages/e2e-tests/test/test-shell.ts @@ -201,14 +201,14 @@ export class TestShell { async waitForPrompt( start = 0, - opts: { timeout?: number } = {} + opts: { timeout?: number; promptPattern?: RegExp } = {} ): Promise { await eventually( () => { const output = this._output.slice(start); const lines = output.split('\n'); const found = !!lines - .filter((l) => PROMPT_PATTERN.exec(l)) // a line that is the prompt must at least match the pattern + .filter((l) => (opts.promptPattern ?? PROMPT_PATTERN).test(l)) // a line that is the prompt must at least match the pattern .find((l) => { // in some situations the prompt occurs multiple times in the line (but only in tests!) const prompts = l From 3822b3cf3b6e6a81ea62b0cb585565d82f2b8e44 Mon Sep 17 00:00:00 2001 From: Anna Henningsen Date: Fri, 24 Jan 2025 15:06:08 +0100 Subject: [PATCH 4/5] fixup: no need to account for ccs in input in TERM=dumb --- packages/cli-repl/src/repl-paste-support.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli-repl/src/repl-paste-support.ts b/packages/cli-repl/src/repl-paste-support.ts index 8cfa484102..b3337e7369 100644 --- a/packages/cli-repl/src/repl-paste-support.ts +++ b/packages/cli-repl/src/repl-paste-support.ts @@ -18,7 +18,7 @@ function* prototypeChain(obj: unknown): Iterable { } export function installPasteSupport(repl: REPLServer): string { - if (!repl.terminal) return ''; // No paste needed in non-terminal environments + if (!repl.terminal || process.env.TERM === 'dumb') return ''; // No paste needed in non-terminal environments // TODO(MONGOSH-1911): Upstream as much of this into Node.js core as possible, // both because of the value to the wider community but also because this is From 01f660ec68c8965c08f226e3fe36339dacac666d Mon Sep 17 00:00:00 2001 From: Anna Henningsen Date: Tue, 28 Jan 2025 13:29:42 +0100 Subject: [PATCH 5/5] fixup: typo --- packages/cli-repl/src/repl-paste-support.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli-repl/src/repl-paste-support.ts b/packages/cli-repl/src/repl-paste-support.ts index b3337e7369..e7d6ebd0b2 100644 --- a/packages/cli-repl/src/repl-paste-support.ts +++ b/packages/cli-repl/src/repl-paste-support.ts @@ -23,7 +23,7 @@ export function installPasteSupport(repl: REPLServer): string { // TODO(MONGOSH-1911): Upstream as much of this into Node.js core as possible, // both because of the value to the wider community but also because this is // messing with Node.js REPL internals to a very unfortunate degree. - repl.output.write('\x1b[?2004h'); // Indicate support for paste mote + repl.output.write('\x1b[?2004h'); // Indicate support for paste mode const onEnd = '\x1b[?2004l'; // End of support for paste mode // Find the symbol used for the (internal) _ttyWrite method of readline.Interface // https://github.com/nodejs/node/blob/d9786109b2a0982677135f0c146f6b591a0e4961/lib/internal/readline/interface.js#L1056