Files
bare-operating-system/packages/bare-os-booter/lib/cli-readline.js
T
2026-04-25 02:59:32 -04:00

255 lines
7.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 constructors 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
}
}