255 lines
7.1 KiB
JavaScript
255 lines
7.1 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'
|
||
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<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'
|
||
)
|
||
}
|
||
|
||
const REPL_HISTORY_CAP = 1000
|
||
|
||
/**
|
||
* Same path as Fish REPL (`fish-readline.js`) so fallback and Fish share one file.
|
||
* @param {Record<string, unknown>} 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_<USER>` when Fish-style REPL is off.
|
||
* @param {Record<string, unknown>} 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<string | null>} innerReadLine
|
||
* @param {Record<string, unknown>} ctx
|
||
* @returns {(prompt: string) => Promise<string | null>}
|
||
*/
|
||
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
|
||
}
|
||
}
|