/** * 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, 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} */ 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} */ 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' ) }