/** * 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' import { dedupeConsecutiveHistory, formatHistoryFile, parseHistoryFile, scrubShellHistoryEntries, shouldPersistShellHistoryCommand } from './fish-readline.js' /** * 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 prompt string 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' ) } const REPL_HISTORY_CAP = 1000 /** * Same path as Fish REPL (`fish-readline.js`) so fallback and Fish share one file. * @param {Record} ctx */ function bareReplHistoryDrivePath(ctx) { const u = String( ctx?.vfs?.env?.USER ?? (ctx.env && ctx.env.USER) ?? 'guest' ).replace(/[^a-zA-Z0-9._-]/g, '_') return `/.bare/repl_history_${u}` } /** * Append one line to `/.bare/repl_history_` when Fish-style REPL is off. * @param {Record} ctx * @param {string} line */ async function appendBareReplHistory(ctx, line) { const trimmed = String(line).trim() if (!trimmed) return const personalDrive = ctx.personalDrive const b4a = ctx.b4a if ( !personalDrive || typeof personalDrive.get !== 'function' || typeof personalDrive.put !== 'function' || !b4a || typeof b4a.from !== 'function' ) { return } const historyPath = bareReplHistoryDrivePath(ctx) /** @type {Array<{ timestamp: number, command: string }>} */ let history = [] let scrubbedExisting = false try { const buf = await personalDrive.get(historyPath) if (buf) { const scrubbed = scrubShellHistoryEntries( dedupeConsecutiveHistory(parseHistoryFile(b4a.toString(buf))) ) history = scrubbed.entries scrubbedExisting = scrubbed.removed > 0 } } catch { history = [] } if (!shouldPersistShellHistoryCommand(trimmed)) { if (scrubbedExisting) { await personalDrive.put(historyPath, b4a.from(formatHistoryFile(history))) } return } history = history.filter((e) => e.command !== trimmed) history.push({ timestamp: Date.now(), command: trimmed }) while (history.length > REPL_HISTORY_CAP) history.shift() await personalDrive.put(historyPath, b4a.from(formatHistoryFile(history))) } /** * When **`BARE_OS_FISH=0`** or the TTY cannot use Fish, optionally persist lines to the * same history file Fish uses (**`BARE_OS_REPL_HISTORY=1`**). Does not add arrow-key * recall (use Fish REPL for full editing). * * @param {(prompt: string) => Promise} innerReadLine * @param {Record} ctx * @returns {(prompt: string) => Promise} */ export function wrapReadLineWithReplHistoryPersist(innerReadLine, ctx) { return async function readLine(prompt) { const ln = await innerReadLine(prompt) if (ln != null && String(ln).trim()) { try { await appendBareReplHistory(ctx, ln) } catch { /* ignore */ } } return ln } }