bare-readline treats empty prompt as falsy and defaults to ">"; each SSH readline recycle painted that before setPrompt. Pass initialPrompt from the real PS1, defer first attach until after profile/barerc, and skip the extra \\r\\n after recycled bare-readline.
165 lines
4.4 KiB
JavaScript
165 lines
4.4 KiB
JavaScript
/**
|
||
* Line-oriented stdin for Bare/Pear (no node:readline) and Node fallback.
|
||
* Pear-terminal uses bare-readline + bare-stdio; we mirror that after Node readline.
|
||
*/
|
||
|
||
import { createInterface as bareReadlineCreateInterface } from 'bare-readline'
|
||
|
||
/**
|
||
* Strip C0 controls; apply BS (0x08) and DEL (0x7F) for raw SSH line fallback.
|
||
* @param {string} s
|
||
*/
|
||
export function sanitizeInteractiveShellLine(s) {
|
||
let out = ''
|
||
for (let i = 0; i < s.length; i++) {
|
||
const c = s.charCodeAt(i)
|
||
if (c === 8 || c === 127) {
|
||
out = out.slice(0, -1)
|
||
continue
|
||
}
|
||
if (c < 32 && c !== 9) continue
|
||
out += s[i]
|
||
}
|
||
return out
|
||
}
|
||
|
||
/**
|
||
* Interactive line editor (cursor, echo, history) — same engine as the Pear console.
|
||
* Caller should call **`close()`** when the session ends to detach input listeners.
|
||
*
|
||
* bare-readline treats **`prompt: ''` as falsy** and falls back to `'> '`, which flashes
|
||
* after each `new Readline` (e.g. SSH recycle). Use a non-empty placeholder or pass
|
||
* **`initialPrompt`** so the constructor’s first `prompt()` matches the real PS1.
|
||
*
|
||
* @param {import('stream').Readable} stdin
|
||
* @param {import('stream').Writable} stdout
|
||
* @param {{ initialPrompt?: string }} [opts]
|
||
* @returns {Promise<{ readLine: (prompt: string) => Promise<string | null>, close: () => void }>}
|
||
*/
|
||
export async function createBareReadlineSession(stdin, stdout, opts = {}) {
|
||
if (typeof bareReadlineCreateInterface !== 'function') {
|
||
throw new Error('bare-readline: createInterface missing')
|
||
}
|
||
const initial =
|
||
typeof opts.initialPrompt === 'string' && opts.initialPrompt.length > 0
|
||
? opts.initialPrompt
|
||
: ' '
|
||
const rl = bareReadlineCreateInterface({
|
||
input: stdin,
|
||
output: stdout,
|
||
prompt: initial
|
||
})
|
||
return {
|
||
readLine(prompt) {
|
||
return new Promise((resolve) => {
|
||
rl.setPrompt(prompt)
|
||
rl.prompt()
|
||
rl.once('line', (line) => {
|
||
resolve(line)
|
||
})
|
||
})
|
||
},
|
||
close() {
|
||
try {
|
||
rl.close()
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @param {import('stream').Readable} stdin
|
||
* @param {import('stream').Writable} stdout
|
||
* @returns {(prompt: string) => Promise<string>}
|
||
*/
|
||
export async function createBareReadlineQuestion(stdin, stdout) {
|
||
const { readLine } = await createBareReadlineSession(stdin, stdout)
|
||
return readLine
|
||
}
|
||
|
||
/**
|
||
* @param {import('stream').Readable} stdin
|
||
* @param {import('stream').Writable} stdout
|
||
* @returns {(prompt: string) => Promise<string | null>}
|
||
*/
|
||
export function createStreamLineReader(stdin, stdout) {
|
||
let buf = ''
|
||
/** @type {string[]} */
|
||
const queue = []
|
||
/** @type {((line: string | null) => void)[]} */
|
||
const waiters = []
|
||
|
||
function deliver(line) {
|
||
if (waiters.length) waiters.shift()(line)
|
||
else queue.push(line)
|
||
}
|
||
|
||
function onData(chunk) {
|
||
const s =
|
||
typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8')
|
||
buf += s
|
||
while (buf.length) {
|
||
const n = buf.indexOf('\n')
|
||
const r = buf.indexOf('\r')
|
||
if (n === -1 && r === -1) break
|
||
let cut
|
||
let ateCr = false
|
||
if (n === -1) {
|
||
cut = r
|
||
ateCr = true
|
||
} else if (r === -1) {
|
||
cut = n
|
||
} else if (r < n) {
|
||
cut = r
|
||
ateCr = true
|
||
} else {
|
||
cut = n
|
||
}
|
||
const raw = buf.slice(0, cut)
|
||
buf = buf.slice(cut + 1)
|
||
if (ateCr && buf.length && buf[0] === '\n') buf = buf.slice(1)
|
||
deliver(String(raw).replace(/\r/g, ''))
|
||
}
|
||
}
|
||
|
||
function flushRemainder() {
|
||
if (buf.length) {
|
||
deliver(buf.replace(/\r$/, ''))
|
||
buf = ''
|
||
}
|
||
}
|
||
|
||
function onEnd() {
|
||
flushRemainder()
|
||
while (waiters.length) waiters.shift()(null)
|
||
}
|
||
|
||
if (typeof stdin.setEncoding === 'function') stdin.setEncoding('utf8')
|
||
stdin.on('data', onData)
|
||
stdin.on('end', onEnd)
|
||
stdin.on('error', onEnd)
|
||
if (typeof stdin.resume === 'function') stdin.resume()
|
||
|
||
return function readLine(prompt) {
|
||
if (typeof stdout.write === 'function') {
|
||
// DECTCEM: show cursor (boot splash / curses often hide it; dumb fallback has no redraw)
|
||
stdout.write('\x1b[?25h\x1b[0m' + prompt)
|
||
}
|
||
if (queue.length) return Promise.resolve(queue.shift() ?? null)
|
||
return new Promise((resolve) => waiters.push(resolve))
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @returns {boolean}
|
||
*/
|
||
export function looksLikeInteractiveStdin(stdin) {
|
||
return Boolean(
|
||
stdin &&
|
||
typeof stdin.on === 'function' &&
|
||
typeof stdin.resume === 'function'
|
||
)
|
||
}
|