This commit is contained in:
Raven Scott
2026-04-03 01:37:34 -04:00
parent 3b6cb97997
commit eca209b663
24 changed files with 1259 additions and 927 deletions
+131 -168
View File
@@ -20,38 +20,38 @@ import {
looksLikeInteractiveStdin
} from './lib/cli-readline.js'
import { resolveStdio } from './lib/resolve-stdio.js'
import { createFishReadLine, disableFishRawMode } from './lib/fish-readline.js'
import {
BOOT_TIMEOUT_MESSAGE,
BOOT_TIMEOUT_MS,
sleepReject,
startBootSplash
} from './lib/boot-splash.js'
import { createKernelReplSession } from './lib/repl-session.js'
const _pkg = packageRootDir(import.meta.url)
/**
* On Pear/Bare, `process.exit` is often missing or ineffective; use `Bare.exit`
* (see holepunch `pear-prerelease`, `pear-terminal`, `bare-process`).
* @param {number} code
*/
function exitHostProcess(code) {
const Bare = globalThis.Bare
if (Bare && typeof Bare.exit === 'function') {
Bare.exit(code)
return
}
const p = globalThis.process
if (p && typeof p.exit === 'function') p.exit(code)
}
function bootStorePath() {
return defaultBootCorestorePath(_pkg, import.meta.url)
}
/** Kernel / replication phase budget (after peer wait). */
function parseLoadTimeoutMs() {
const raw = globalThis.process?.env?.BARE_OS_BOOT_TIMEOUT_MS
const n = Number(raw)
if (Number.isFinite(n) && n > 0) return Math.min(Math.trunc(n), 600_000)
return BOOT_TIMEOUT_MS
}
/** Max ms to wait for first peer before falling back to local seed (default 1 min). */
function parsePeerWaitMs() {
const raw = globalThis.process?.env?.BARE_OS_PEER_WAIT_MS
if (raw === undefined || raw === '') return BOOT_TIMEOUT_MS
const n = Number(raw)
if (!Number.isFinite(n) || n < 0) return BOOT_TIMEOUT_MS
return Math.min(Math.trunc(n), 600_000)
}
/** @returns {Promise<{ readLine: (p: string) => Promise<string | null>, interactiveAvailable: boolean, skipInteractive: boolean, stdout: import('stream').Writable | null, stdin: import('stream').Readable | null }>} */
/**
* @returns {Promise<{
* readLine: (p: string) => Promise<string | null>,
* interactiveAvailable: boolean,
* skipInteractive: boolean,
* stdout: import('stream').Writable | null,
* stdin: import('stream').Readable | null
* }>}
*/
async function createReadLine() {
const { stdin, stdout } = await resolveStdio()
@@ -79,6 +79,38 @@ async function createReadLine() {
}
}
const ttyRawCapable =
Boolean(stdin.isTTY) &&
typeof stdin.setRawMode === 'function' &&
typeof stdout.write === 'function'
/** Prefer fish-style raw editor on TTY — do not stack node:readline on top. */
if (ttyRawCapable) {
/** Lazy: avoid bare-readline attaching to stdin before createFishReadLine runs. */
let ttyFallback = undefined
const lazyRawReadLine = async (prompt) => {
if (ttyFallback === undefined) {
try {
ttyFallback = await createBareReadlineQuestion(stdin, stdout)
} catch {
ttyFallback =
looksLikeInteractiveStdin(stdin) && typeof stdout.write === 'function'
? createStreamLineReader(stdin, stdout)
: false
}
}
if (ttyFallback === false) return null
return ttyFallback(prompt)
}
return {
readLine: lazyRawReadLine,
interactiveAvailable: true,
skipInteractive: false,
stdout,
stdin
}
}
let nodeReadline = null
try {
nodeReadline = await import('node:readline')
@@ -157,29 +189,6 @@ async function executeKernel(disk, store, swarm, initSource) {
stdin: sessionStdin
} = await createReadLine()
/** Same Writable as the REPL (bare-stdio or process.stdout) for ANSI / clear. */
function writeScreen(chunk) {
const s = typeof chunk === 'string' ? chunk : String(chunk)
if (sessionStdout && typeof sessionStdout.write === 'function') {
sessionStdout.write(s)
} else if (
globalThis.process?.stdout &&
typeof globalThis.process.stdout.write === 'function'
) {
globalThis.process.stdout.write(s)
}
globalThis.console.clear?.()
}
if (
interactiveAvailable &&
!skipInteractive &&
sessionStdout &&
typeof sessionStdout.write === 'function'
) {
writeScreen('\x1b[H\x1b[2J\x1b[3J')
}
const shellEnv = {
HOME: '/home/user',
PATH: '/bin',
@@ -194,6 +203,8 @@ async function executeKernel(disk, store, swarm, initSource) {
}
const vfs = createVfs(disk.drive, disk.personalDrive, shellEnv)
let sessionExitCode = 0
let forceSessionEnd = false
/** @type {Record<string, unknown>} */
const ctx = {
disk,
@@ -204,46 +215,48 @@ async function executeKernel(disk, store, swarm, initSource) {
console,
b4a,
topic: topicKey(),
writeScreen,
async execLine(line) {
return await execShellLine(ctx, line)
}
readLine: async () => null,
writeScreen: () => {},
/**
* Builtin `exit`, `/bin/exit`, or session end: set exit code and stop readLine (kernel loop).
* @param {number} [code=0]
*/
requestBooterExit(code = 0) {
const n = typeof code === 'number' ? code : Number.parseInt(String(code), 10)
sessionExitCode = Number.isFinite(n) ? n : 0
forceSessionEnd = true
},
/** Replaced after createKernelReplSession (see readLine shim). */
execLine: async () => 'ok'
}
const fishOff = globalThis.process?.env?.BARE_OS_FISH === '0'
let fishRead = null
if (
!skipInteractive &&
interactiveAvailable &&
!fishOff &&
sessionStdin &&
sessionStdout
) {
fishRead = await createFishReadLine(ctx, {
stdin: sessionStdin,
stdout: sessionStdout,
writeScreen
})
if (fishRead) {
console.log('[bare-os-booter] Fish-style TTY line editor active')
const session = await createKernelReplSession({
stdin: sessionStdin,
stdout: sessionStdout,
rawReadLine,
interactiveAvailable,
skipInteractive,
ctx
})
const sessionReadLine = session.readLine
ctx.execLine = async (line) => {
const t = line.trim()
const exitMatch = t.match(/^exit(?:\s+(-?\d+))?$/)
if (exitMatch) {
const ec =
exitMatch[1] != null ? Number.parseInt(exitMatch[1], 10) : 0
ctx.requestBooterExit(Number.isFinite(ec) ? ec : 0)
return 'exit'
}
return await execShellLine(ctx, line)
}
const readLineImpl = fishRead || rawReadLine
/** Under Pear there is often no readline; never return null or the kernel exits and tears down the swarm. */
const readLine = async (prompt) => {
const line = await readLineImpl(prompt)
if (line == null && !skipInteractive && !interactiveAvailable) {
console.log(
'(No interactive stdin — leaving booter and replication running. Close the app to quit.)'
)
await new Promise(() => {})
}
return line
ctx.readLine = async (prompt) => {
if (forceSessionEnd) return null
return sessionReadLine(prompt)
}
ctx.readLine = readLine
ctx.writeScreen = session.writeScreen
ctx.console = session.console
disk.os = {
async searchLocal() {
@@ -257,30 +270,21 @@ async function executeKernel(disk, store, swarm, initSource) {
try {
await runKernelFromSource(b4a.toString(initSource), ctx)
} finally {
if (fishRead) disableFishRawMode(sessionStdin)
session.cleanup()
}
return sessionExitCode
}
/**
* @param {SwarmDisk} disk
* @param {import('corestore').default} store
* @param {import('hyperswarm').default} swarm
* @param {{ silent?: boolean }} [opts]
* @returns {Promise<Uint8Array>}
*/
async function bootFromPeers(disk, store, swarm, opts = {}) {
const { silent = false } = opts
const log = silent ? () => {} : console.log.bind(console)
async function bootFromPeers(disk, store, swarm) {
const topic = topicKey()
log('Loading MBR from peers...')
console.log('Loading MBR from peers...')
const mbr = await disk.read(0)
const { keys } = parseMbr(mbr)
let initSource = null
for (const driveKey of keys) {
try {
log(
console.log(
'Mounting drive',
b4a.toString(driveKey, 'hex').slice(0, 16) + '...'
)
@@ -301,32 +305,22 @@ async function bootFromPeers(disk, store, swarm, opts = {}) {
}
if (initSource) break
log('Kernel not ready on this drive key, trying next...')
console.log('Kernel not ready on this drive key, trying next...')
} catch (err) {
log('Drive error:', err.message)
console.log('Drive error:', err.message)
}
}
if (!initSource) throw new Error('Kernel not found after replication')
await disk.initPersonalDrive(store, swarm, Hyperdrive)
log('Starting kernel...')
return initSource
console.log('Starting kernel...')
return await executeKernel(disk, store, swarm, initSource)
}
/**
* @param {SwarmDisk} disk
* @param {import('corestore').default} store
* @param {import('hyperswarm').default} swarm
* @param {{ silent?: boolean }} [opts]
* @returns {Promise<Uint8Array>}
*/
async function bootLocal(disk, store, swarm, opts = {}) {
const { silent = false } = opts
const log = silent ? () => {} : console.log.bind(console)
async function bootLocal(disk, store, swarm) {
const seedPath = defaultLocalSeedCorestorePath(_pkg, import.meta.url)
log('Local boot from', seedPath)
console.log('Local boot from', seedPath)
const seedStore = new Corestore(seedPath)
disk.drive = new Hyperdrive(seedStore)
await disk.drive.ready()
@@ -340,30 +334,17 @@ async function bootLocal(disk, store, swarm, opts = {}) {
if (!initSource) throw new Error('Kernel missing in local seed store')
await disk.initPersonalDrive(store, swarm, Hyperdrive)
return initSource
return await executeKernel(disk, store, swarm, initSource)
}
async function main() {
console.log('--- bare-os-booter ---')
const { stdout } = await resolveStdio()
const useSplash = Boolean(stdout && typeof stdout.write === 'function')
const silentBoot = useSplash && stdout.isTTY === true
const store = new Corestore(bootStorePath())
const swarm = new Hyperswarm()
const disk = new SwarmDisk()
const topic = topicKey()
/** @type {string} */
let bootStatus = 'joining swarm'
const stopSplash = useSplash
? startBootSplash(stdout, {
peerCount: () => disk.peers.size,
status: () => bootStatus
})
: () => {}
swarm.on('connection', (socket) => {
const mux = new Protomux(socket)
disk.addPeer(mux, socket)
@@ -371,50 +352,29 @@ async function main() {
swarm.join(topic)
const loadTimeoutMs = parseLoadTimeoutMs()
const peerWaitMs = parsePeerWaitMs()
/** Room for full peer wait plus replication/local load without racing too early. */
const totalBootRaceMs = Math.min(peerWaitMs + loadTimeoutMs, 900_000)
const maxWait = Number(globalThis.process?.env?.BARE_OS_PEER_WAIT_MS || 8000)
let waited = 0
while (disk.peers.size === 0 && waited < maxWait) {
await new Promise((r) => setTimeout(r, 500))
waited += 500
}
console.log('Peers:', disk.peers.size)
let exitCode = 0
try {
const initSource = await Promise.race([
(async () => {
let waited = 0
bootStatus = 'waiting for peers'
while (disk.peers.size === 0 && waited < peerWaitMs) {
await new Promise((r) => setTimeout(r, 500))
waited += 500
}
if (!silentBoot) {
console.log('Peers:', disk.peers.size)
}
bootStatus =
disk.peers.size > 0 ? 'replicating kernel' : 'loading local seed'
if (disk.peers.size > 0) {
return await bootFromPeers(disk, store, swarm, {
silent: silentBoot
})
}
return await bootLocal(disk, store, swarm, { silent: silentBoot })
})(),
sleepReject(totalBootRaceMs, () => {
stopSplash()
})
])
stopSplash()
await executeKernel(disk, store, swarm, initSource)
} catch (err) {
stopSplash()
if (err && err.message === BOOT_TIMEOUT_MESSAGE) {
console.error('bare-os-booter:', err.message)
if (typeof globalThis.process?.exit === 'function') {
globalThis.process.exitCode = 1
}
return
const kernelExit =
disk.peers.size > 0
? await bootFromPeers(disk, store, swarm)
: await bootLocal(disk, store, swarm)
if (typeof kernelExit === 'number' && Number.isFinite(kernelExit)) {
exitCode = kernelExit
}
throw err
} catch (err) {
console.error('bare-os-booter failed:', err?.message ?? err)
if (err?.stack) console.error(err.stack)
safetyCatch(err)
exitCode = 1
} finally {
try {
if (disk.personalDrive) await disk.personalDrive.close()
@@ -429,10 +389,13 @@ async function main() {
await store.close()
} catch (_) {}
}
exitHostProcess(exitCode)
}
main().catch((err) => {
console.error('bare-os-booter failed:', err?.message ?? err)
if (err?.stack) console.error(err.stack)
safetyCatch(err)
exitHostProcess(1)
})
@@ -1,79 +0,0 @@
/** Default boot attempt timeout (ms). */
export const BOOT_TIMEOUT_MS = 60_000
export const BOOT_TIMEOUT_MESSAGE = 'Boot timed out'
const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
/**
* @param {import('stream').Writable | null | undefined} stdout
* @param {{ peerCount?: () => number, status?: () => string }} opts
* @returns {() => void} stop — clears the line and restores cursor visibility
*/
export function startBootSplash(stdout, opts = {}) {
const start = Date.now()
let frame = 0
let stopped = false
function tick() {
if (stopped || !stdout?.write) return
const elapsedMs = Date.now() - start
const totalSec = Math.floor(elapsedMs / 1000)
const m = Math.floor(totalSec / 60)
const s = totalSec % 60
const clock = `${m}:${s.toString().padStart(2, '0')}`
const spin = SPINNER[frame++ % SPINNER.length]
let extra = ''
try {
const n = opts.peerCount?.()
if (typeof n === 'number') extra += ` peers ${n}`
} catch {
/* ignore */
}
try {
const st = opts.status?.()
if (st) extra += ` · ${st}`
} catch {
/* ignore */
}
stdout.write(
`\r\x1b[K\x1b[36m${spin}\x1b[0m \x1b[1mbare-os\x1b[0m booting \x1b[33m${clock}\x1b[0m${extra}`
)
}
if (stdout?.write) stdout.write('\x1b[?25l')
tick()
const id = setInterval(tick, 100)
return function stopBootSplash() {
if (stopped) return
stopped = true
clearInterval(id)
if (stdout?.write) {
stdout.write('\r\x1b[K')
stdout.write('\x1b[?25h')
}
}
}
export function bootTimedOutError() {
return new Error(BOOT_TIMEOUT_MESSAGE)
}
/**
* @param {number} ms
* @param {() => void} [onTimeout]
*/
export function sleepReject(ms, onTimeout) {
return new Promise((_, reject) => {
const t = setTimeout(() => {
try {
onTimeout?.()
} catch {
/* ignore */
}
reject(bootTimedOutError())
}, ms)
if (t.unref) t.unref()
})
}
+118
View File
@@ -0,0 +1,118 @@
/**
* Verbose REPL/fish diagnostics when `DEBUG=1`.
* Writes to the **session TTY** when bound (see `bindReplDebugStream`) so Pear/bare-stdio
* logs show in the same terminal pane as the shell. Falls back to `process.stdout`, then
* `stderr`, then `console.log`. Lines are prefixed with reset + newline so they stay
* readable if the prompt left SGR/cursor state; expect the screen to scroll while debugging.
*/
/** @type {import('stream').Writable | null} */
let _debugStream = null
/** Bind REPL/fish stdout (e.g. bare-stdio `out`) so DEBUG lines appear in the visible terminal. */
export function bindReplDebugStream(stream) {
_debugStream =
stream && typeof stream.write === 'function' ? stream : null
}
export function unbindReplDebugStream() {
_debugStream = null
}
/** @returns {boolean} */
export function isReplDebug() {
const p = globalThis.process?.env
return p?.DEBUG === '1'
}
function ts() {
if (typeof globalThis.performance?.now === 'function') {
return `${globalThis.performance.now().toFixed(2)}ms`
}
return `${Date.now()}`
}
function serialize(part) {
if (typeof part === 'string') return part
if (part === undefined) return 'undefined'
if (part === null) return 'null'
if (typeof part === 'number' || typeof part === 'boolean') return String(part)
try {
return JSON.stringify(part)
} catch {
return String(part)
}
}
/**
* @param {string} scope Short tag (e.g. fish-render, repl, booter)
* @param {...unknown} parts Message pieces
*/
function writeDebugLine(text) {
const payload = `\x1b[0m\n${text}\n`
try {
if (_debugStream && typeof _debugStream.write === 'function') {
_debugStream.write(payload)
return
}
const p = globalThis.process
if (p?.stdout && typeof p.stdout.write === 'function') {
p.stdout.write(payload)
return
}
if (p?.stderr && typeof p.stderr.write === 'function') {
p.stderr.write(payload)
return
}
globalThis.console?.log?.(text)
} catch {
/* ignore */
}
}
export function replDbg(scope, ...parts) {
if (!isReplDebug()) return
const line = `[${ts()}] [bare-os DEBUG:${scope}] ${parts.map(serialize).join(' ')}`
writeDebugLine(line)
}
/**
* Human-readable key / escape sequence for logs.
* @param {string} key
*/
export function replDbgKeyRepr(key) {
if (key === '\n') return '\\n'
if (key === '\r') return '\\r'
if (key === '\t') return '\\t'
if (key === '\u007f') return 'DEL'
if (key === '\u0003') return '^C'
if (key === '\u0004') return '^D'
if (key === '\u000c') return '^L'
if (key === '\u0012' || (key.length === 1 && key.charCodeAt(0) === 18)) return '^R'
if (key.startsWith('\u001b')) {
return `CSI(${key.length}ch)=${JSON.stringify(key)}`
}
if (key.length === 1) {
const c = key.charCodeAt(0)
if (c < 32) return `^${String.fromCharCode(c + 64)}(u${c})`
return key
}
return JSON.stringify(key.length > 120 ? key.slice(0, 120) + '…' : key)
}
/**
* @param {string} label
* @param {string} chunk
* @param {number} [maxLen]
*/
export function replDbgChunk(label, chunk, maxLen = 256) {
if (!isReplDebug()) return
const s = typeof chunk === 'string' ? chunk : String(chunk)
const t = s.length > maxLen ? s.slice(0, maxLen) + `…(+${s.length - maxLen})` : s
const codes = [...t].slice(0, 80).map((ch) => {
const u = ch.charCodeAt(0)
if (u < 32 || u === 127) return `\\x${u.toString(16).padStart(2, '0')}`
return ch
})
replDbg('fish-stdin', label, `len=${s.length}`, `head=${JSON.stringify(codes.join(''))}`)
}
@@ -1,71 +0,0 @@
/**
* Hyper-os compatible history lines: #timestamp:command per line.
* @typedef {{ timestamp: number, command: string }} HistoryEntry
*/
/** @param {string} data */
export function parseHistoryFile(data) {
const lines = data.split('\n').filter((l) => l.length > 0)
/** @type {HistoryEntry[]} */
const out = []
for (const line of lines) {
if (line.startsWith('#')) {
const m = line.match(/^#(\d+):(.+)$/)
if (m) {
out.push({ timestamp: parseInt(m[1], 10), command: m[2] })
continue
}
}
out.push({ timestamp: Date.now(), command: line })
}
dedupeHistory(out)
return out
}
/** @param {HistoryEntry[]} history in-place */
export function dedupeHistory(history) {
const deduped = []
let last = null
for (const e of history) {
if (e.command !== last) {
deduped.push(e)
last = e.command
}
}
history.length = 0
history.push(...deduped)
}
/** @param {HistoryEntry[]} history */
export function serializeHistory(history) {
return history.map((e) => `#${e.timestamp}:${e.command}`).join('\n')
}
/**
* Ghost: most recent history entry whose command starts with prefix.
* @param {HistoryEntry[]} history chronological (oldest first)
* @param {string} prefix
*/
export function ghostFromHistory(history, prefix) {
if (!prefix.length) return ''
for (let i = history.length - 1; i >= 0; i--) {
const cmd = history[i].command
if (cmd.startsWith(prefix)) return cmd
}
return ''
}
/**
* Reverse search: commands containing query, most recent first.
* @param {HistoryEntry[]} history
* @param {string} query
*/
export function searchHistorySubstring(history, query) {
if (!query) return []
const results = []
for (let i = history.length - 1; i >= 0; i--) {
const cmd = history[i].command
if (cmd.includes(query)) results.push(cmd)
}
return results
}
File diff suppressed because it is too large Load Diff
+180
View File
@@ -0,0 +1,180 @@
import {
createFishReadLine,
disableFishRawMode,
releaseFishStdin
} from './fish-readline.js'
import {
bindReplDebugStream,
isReplDebug,
replDbg,
unbindReplDebugStream
} from './debug-repl.js'
/**
* Kernel `console` must write to the same stream as the line editor so cursor stays in sync.
* @param {import('stream').Writable} stream
* @returns {Promise<import('console').Console | Record<string, (...args: unknown[]) => void>>}
*/
async function createFishSyncedConsole(stream) {
try {
const { Console } = await import('node:console')
return new Console(stream, stream)
} catch {
const writeLn = (...args) => {
stream.write(
args.map((a) => (typeof a === 'string' ? a : String(a))).join(' ') + '\n'
)
}
return {
log: writeLn,
info: writeLn,
debug: writeLn,
error: writeLn,
warn: writeLn
}
}
}
/**
* One TTY session for the kernel: `readLine`, `console`, and `writeScreen` share `stdout`.
* When the TTY supports raw mode, fish-style editing is the primary implementation (not a
* second layer on top of bare-readline output).
*
* @param {{
* stdin: import('stream').Readable | null,
* stdout: import('stream').Writable | null,
* rawReadLine: (p: string) => Promise<string | null>,
* interactiveAvailable: boolean,
* skipInteractive: boolean,
* ctx: Record<string, unknown>
* }} args
*/
export async function createKernelReplSession({
stdin,
stdout,
rawReadLine,
interactiveAvailable,
skipInteractive,
ctx
}) {
const baseConsole = globalThis.console
if (isReplDebug() && stdout) bindReplDebugStream(stdout)
function writeScreen(chunk) {
const s = typeof chunk === 'string' ? chunk : String(chunk)
if (isReplDebug()) {
replDbg(
'repl-writeScreen',
'write',
`len=${s.length}`,
`dest=${stdout && typeof stdout.write === 'function' ? 'sessionStdout' : 'process.stdout'}`,
JSON.stringify(s.length > 64 ? s.slice(0, 64) + '…' : s)
)
}
if (stdout && typeof stdout.write === 'function') {
stdout.write(s)
} else if (
globalThis.process?.stdout &&
typeof globalThis.process.stdout.write === 'function'
) {
globalThis.process.stdout.write(s)
}
}
const fishOff = globalThis.process?.env?.BARE_OS_FISH === '0'
let fishRead = null
if (isReplDebug()) {
replDbg(
'repl',
'createKernelReplSession',
JSON.stringify({
skipInteractive,
interactiveAvailable,
fishOff,
hasStdin: Boolean(stdin),
hasStdout: Boolean(stdout)
})
)
}
if (
!skipInteractive &&
interactiveAvailable &&
!fishOff &&
stdin &&
stdout
) {
fishRead = await createFishReadLine(ctx, {
stdin,
stdout,
writeScreen
})
if (isReplDebug()) {
replDbg('repl', 'createFishReadLine result', fishRead ? 'attached' : 'null (fallback rawReadLine)')
}
if (fishRead && stdout && typeof stdout.write === 'function') {
stdout.write(
'\x1b[0m[bare-os-booter] Fish-style TTY line editor active\n'
)
}
}
let outConsole = baseConsole
if (fishRead && stdout) {
outConsole = await createFishSyncedConsole(stdout)
}
if (isReplDebug()) {
replDbg(
'repl',
'console binding',
fishRead ? 'createFishSyncedConsole(session stdout)' : 'process global console'
)
}
const readLine = async (prompt) => {
if (isReplDebug()) {
replDbg(
'repl-readLine',
'invoke',
`impl=${fishRead ? 'fish' : 'raw'}`,
JSON.stringify(typeof prompt === 'string' ? prompt.slice(0, 100) : prompt)
)
}
const line = await (fishRead || rawReadLine)(prompt)
if (isReplDebug()) {
replDbg(
'repl-readLine',
'resolved',
line === null ? 'null' : `string len=${line.length}`,
line != null ? JSON.stringify(line.slice(0, 120)) : ''
)
}
if (line == null && !skipInteractive && !interactiveAvailable) {
baseConsole.log(
'(No interactive stdin — leaving booter and replication running. Close the app to quit.)'
)
await new Promise(() => {})
}
return line
}
function cleanup() {
if (isReplDebug()) replDbg('repl', 'cleanup', fishRead ? 'fish teardown' : 'noop')
if (fishRead && stdin) {
disableFishRawMode(stdin)
releaseFishStdin(stdin)
}
unbindReplDebugStream()
}
return {
writeScreen,
readLine,
console: outConsole,
cleanup,
fishActive: Boolean(fishRead)
}
}
+31 -2
View File
@@ -3,22 +3,51 @@
* on fds 02 (same idea as pear-terminal's stdio singleton).
*/
import { bindReplDebugStream, isReplDebug, replDbg } from './debug-repl.js'
/**
* @returns {Promise<{ stdin: import('stream').Readable | null, stdout: import('stream').Writable | null }>}
*/
export async function resolveStdio() {
const p = globalThis.process
if (p?.stdin && p?.stdout) {
if (isReplDebug()) {
bindReplDebugStream(p.stdout)
replDbg(
'resolve-stdio',
'process stdin/stdout',
JSON.stringify({
stdinIsTTY: p.stdin.isTTY,
stdoutColumns: p.stdout.columns,
hasCursorTo: typeof p.stdout.cursorTo,
hasClearLine: typeof p.stdout.clearLine
})
)
}
return { stdin: p.stdin, stdout: p.stdout }
}
try {
const mod = await import('bare-stdio')
const io = mod.default?.in ? mod.default : mod
if (io?.in && io?.out) {
if (isReplDebug()) {
bindReplDebugStream(io.out)
replDbg(
'resolve-stdio',
'bare-stdio',
JSON.stringify({
stdinIsTTY: io.in.isTTY,
stdoutColumns: io.out.columns,
hasCursorTo: typeof io.out.cursorTo,
hasClearLine: typeof io.out.clearLine
})
)
}
return { stdin: io.in, stdout: io.out }
}
} catch (_) {
/* optional under Node-only installs */
} catch (e) {
if (isReplDebug()) replDbg('resolve-stdio', 'bare-stdio unavailable', String(e))
}
if (isReplDebug()) replDbg('resolve-stdio', 'no stdin/stdout')
return { stdin: null, stdout: null }
}
+12 -1
View File
@@ -278,14 +278,25 @@ export async function execShellLine(ctx, line) {
const eq = a.indexOf('=')
if (eq > 0) env[a.slice(0, eq)] = expandWord(a.slice(eq + 1), env)
}
} else if (name === 'exit' || name === 'quit') {
} else if (name === 'exit') {
code = 'exit'
let ec = 0
if (argv[1] !== undefined) {
const n = Number.parseInt(argv[1], 10)
ec = Number.isFinite(n) ? n : 0
}
if (typeof ctx.requestBooterExit === 'function') {
ctx.requestBooterExit(ec)
}
} else {
const childCtx =
stdinText != null
? Object.assign({}, ctx, { shellStdin: stdinText, env })
: Object.assign({}, ctx, { env })
await runBinCommand(childCtx, argv)
if (name === '/bin/exit' || name.endsWith('/exit')) {
code = 'exit'
}
}
} finally {
if (!isLast || cmd.redirOut) {
+1 -9
View File
@@ -1,14 +1,6 @@
import b4a from 'b4a'
import c from 'compact-encoding'
import { PROTOCOL_NAME } from 'bare-os-protocol/constants.js'
import { BOOT_TIMEOUT_MS } from './boot-splash.js'
/** Default matches boot race; override with BARE_OS_MBR_READ_TIMEOUT_MS (was hardcoded 10s). */
function mbrReadTimeoutMs() {
const n = Number(globalThis.process?.env?.BARE_OS_MBR_READ_TIMEOUT_MS)
if (Number.isFinite(n) && n > 0) return Math.min(Math.trunc(n), 300_000)
return BOOT_TIMEOUT_MS
}
export class SwarmDisk {
constructor() {
@@ -263,7 +255,7 @@ export class SwarmDisk {
return new Promise((resolve, reject) => {
const timeout = setTimeout(
() => reject(new Error('MBR read timeout')),
mbrReadTimeoutMs()
10000
)
this.pendingReads.set(index, (data) => {
clearTimeout(timeout)
-70
View File
@@ -1,70 +0,0 @@
/**
* Minimal CSI helpers for TTY output (no node:readline).
* Semantics match Node readline: x/y are 0-based; CHA/CUP use 1-based parameters.
*/
/** @param {unknown} n */
function clampNonNegInt(n) {
if (!Number.isFinite(n)) return 0
const i = Math.trunc(n)
return i < 0 ? 0 : i
}
/**
* @param {import('stream').Writable | null | undefined} stream
* @param {number} x 0-based column
* @param {number} [y] 0-based row; if omitted, CHA only
*/
export function cursorTo(stream, x, y) {
if (stream == null || typeof stream.write !== 'function') return
const cx = clampNonNegInt(x)
if (typeof y === 'number' && Number.isFinite(y)) {
const cy = clampNonNegInt(y)
stream.write(`\x1b[${cy + 1};${cx + 1}H`)
} else {
stream.write(`\x1b[${cx + 1}G`)
}
}
/**
* @param {import('stream').Writable | null | undefined} stream
* @param {number} dx
* @param {number} dy
*/
export function moveCursor(stream, dx, dy) {
if (stream == null || typeof stream.write !== 'function') return
const ddx = Number.isFinite(dx) ? Math.trunc(dx) : 0
const ddy = Number.isFinite(dy) ? Math.trunc(dy) : 0
if (ddy < 0) stream.write(`\x1b[${-ddy}A`)
else if (ddy > 0) stream.write(`\x1b[${ddy}B`)
if (ddx < 0) stream.write(`\x1b[${-ddx}D`)
else if (ddx > 0) stream.write(`\x1b[${ddx}C`)
}
/**
* Erase from cursor to end of display (ED default).
* @param {import('stream').Writable | null | undefined} stream
*/
export function clearScreenDown(stream) {
if (stream == null || typeof stream.write !== 'function') return
stream.write('\x1b[J')
}
/**
* Carriage return + erase entire line (EL 2K).
* @param {import('stream').Writable | null | undefined} stream
*/
export function clearEntireLine(stream) {
if (stream == null || typeof stream.write !== 'function') return
stream.write('\r\x1b[2K')
}
/**
* Column 0 then EL entire line (same effect as clearEntireLine on most terminals).
* @param {import('stream').Writable | null | undefined} stream
*/
export function clearCurrentLine(stream) {
if (stream == null || typeof stream.write !== 'function') return
cursorTo(stream, 0)
stream.write('\x1b[2K')
}
@@ -0,0 +1,154 @@
#!/usr/bin/env node
/**
* Standalone TTY repro for fish-style readline (lone `>`, missing `[user@host:path]`).
*
* Run in a real terminal (not piped):
* cd packages/bare-os-booter && node scripts/fish-tty-repro.js
* # or: npm run repro:fish-tty -w bare-os-booter
*
* Automated PTY (drains stdout so Node does not block; checks for green prompt):
* python3 scripts/run-fish-repro-pty.py
* # or: npm run repro:fish-tty:pty -w bare-os-booter
*
* Env:
* BARE_OS_REPRO_SKIP_STDERR=1 — omit stderr preamble (isolates stdout/stderr ordering).
* BARE_OS_REPRO_SKIP_PREAMBLE=1 — skip fake kernel lines; only fish prompt.
* BARE_OS_FISH_RESYNC_LINE=0 — disable leading newline before each prompt (default is on).
* DEBUG=1 — verbose fish/TTY logs on the same terminal as the shell (debug-repl.js).
*
* Type a few commands; use `exit` or Ctrl+D to quit. Compare first prompt vs after ^C.
*/
import b4a from 'b4a'
import { bindReplDebugStream, unbindReplDebugStream } from '../lib/debug-repl.js'
import {
createFishReadLine,
disableFishRawMode,
releaseFishStdin
} from '../lib/fish-readline.js'
function mockCtx() {
return {
vfs: {
getcwd: () => '/home/user',
resolveLogical: (p) => (p === '' ? '/' : p),
async readdir() {
return []
}
},
drive: {
readdir() {
async function* empty() {
/* no /bin entries */
}
return empty()
}
},
personalDrive: {
async get() {
return null
},
async put() {}
},
b4a,
env: {
HOME: '/home/user',
USER: 'user',
HOSTNAME: 'bare-os',
PATH: '/bin',
PWD: '/home/user'
}
}
}
/** Same idea as executeKernel writeScreen (TTY clear hook). */
function makeWriteScreen(stdout) {
return (chunk) => {
const s = typeof chunk === 'string' ? chunk : String(chunk)
stdout.write(s)
}
}
function kernelPreambleToStdout(stdout) {
const lines = [
'NAME="BareOS"',
'VERSION="0.1.0"',
'VARIANT="hyperdrive-only"',
'',
'Bare operating system — POSIX-ish shell: cd, export, exit, quit | try: help, ls /bin, pwd'
]
for (const line of lines) stdout.write(line + '\n')
}
async function main() {
const stdin = process.stdin
const stdout = process.stdout
if (!stdin.isTTY || typeof stdin.setRawMode !== 'function') {
process.stderr.write(
'fish-tty-repro: need a TTY with setRawMode (run in a terminal, not a pipe).\n'
)
process.exit(1)
}
const skipStderr = process.env.BARE_OS_REPRO_SKIP_STDERR === '1'
const skipPreamble = process.env.BARE_OS_REPRO_SKIP_PREAMBLE === '1'
if (!skipStderr) {
process.stderr.write(
'\x1b[0m[bare-os-booter] Fish-style TTY line editor active\n'
)
}
const ctx = mockCtx()
const writeScreen = makeWriteScreen(stdout)
if (process.env.DEBUG === '1') bindReplDebugStream(stdout)
const fishRead = await createFishReadLine(ctx, {
stdin,
stdout,
writeScreen
})
if (!fishRead) {
process.stderr.write('createFishReadLine returned null.\n')
process.exit(1)
}
if (!skipPreamble) {
kernelPreambleToStdout(stdout)
}
const readLine = (prompt) => fishRead(prompt)
process.stderr.write(
'\n--- repro ready: first prompts should show full [user@bare-os:~] > if healthy ---\n'
)
try {
while (true) {
const line = await readLine('repro> ')
if (line == null) {
stdout.write('\n')
break
}
const t = line.trim()
if (t === 'exit' || t === 'quit') break
if (t === 'cls' || t === 'clear') {
writeScreen('\x1b[H\x1b[2J\x1b[3J')
continue
}
if (t) stdout.write(`(cmd) ${t}\n`)
}
} finally {
disableFishRawMode(stdin)
releaseFishStdin(stdin)
unbindReplDebugStream()
}
}
main().catch((err) => {
process.stderr.write(String(err?.stack || err) + '\n')
process.exit(1)
})
@@ -0,0 +1,92 @@
#!/usr/bin/env python3
"""
Run fish-tty-repro.js under a pseudo-TTY and drain output so Node never blocks on write.
Usage (from anywhere):
python3 packages/bare-os-booter/scripts/run-fish-repro-pty.py
BARE_OS_REPRO_SKIP_STDERR=1 python3 packages/bare-os-booter/scripts/run-fish-repro-pty.py
BARE_OS_REPRO_PTY_ASSERT=1 ... # exit 2 if green prompt not detected (CI guard)
Requires: Unix (pty). Not for Windows CI.
"""
from __future__ import annotations
import os
import pty
import select
import sys
import threading
import time
HERE = os.path.dirname(os.path.abspath(__file__))
BOOTER = os.path.dirname(HERE)
def main() -> int:
chunks: list[bytes] = []
def reader(master_fd: int, done: threading.Event) -> None:
while not done.is_set():
try:
r, _, _ = select.select([master_fd], [], [], 0.2)
except (OSError, ValueError):
break
if done.is_set():
break
if not r:
continue
try:
data = os.read(master_fd, 65536)
except OSError:
break
if not data:
break
chunks.append(data)
pid, master_fd = pty.fork()
if pid == 0:
os.chdir(BOOTER)
os.execlp("node", "node", os.path.join(HERE, "fish-tty-repro.js"))
return 0 # unreachable
done = threading.Event()
t = threading.Thread(target=reader, args=(master_fd, done))
t.start()
time.sleep(0.6)
os.write(master_fd, b"hello\n")
time.sleep(0.35)
os.write(master_fd, b"exit\n")
# Wait for Node to exit before closing the PTY master (avoids exit code 1 / hang).
_, status = os.waitpid(pid, 0)
done.set()
t.join(timeout=5.0)
try:
os.close(master_fd)
except OSError:
pass
raw = b"".join(chunks)
try:
sys.stdout.buffer.write(raw)
except BrokenPipeError:
pass
text = raw.decode("utf-8", errors="replace")
ok_green = "\x1b[32m[user@bare-os" in text or "[user@bare-os" in text
sys.stderr.write(
f"\n--- pty harness: exit status {status}, bytes {len(raw)}, "
f"saw_full_prompt_hint={ok_green} ---\n"
)
if os.environ.get("BARE_OS_REPRO_PTY_ASSERT") == "1" and not ok_green:
sys.stderr.write("BARE_OS_REPRO_PTY_ASSERT: expected green [user@bare-os] prompt\n")
return 2
if os.WIFEXITED(status):
return os.WEXITSTATUS(status)
return 1
if __name__ == "__main__":
sys.exit(main())
+31 -88
View File
@@ -11,19 +11,13 @@ import { createStreamLineReader } from './lib/cli-readline.js'
import { createVfs } from './lib/vfs.js'
import { tokenize, expandWord, execShellLine } from './lib/shell.js'
import {
fuzzyMatch,
stripAnsi,
parseHistoryFile,
serializeHistory,
ghostFromHistory,
searchHistorySubstring
} from './lib/fish-history.js'
import { fuzzyMatch, SHELL_BUILTINS } from './lib/fish-readline.js'
import {
clearCurrentLine,
clearEntireLine,
clearScreenDown,
cursorTo,
moveCursor
} from './lib/tty-ansi.js'
formatHistoryFile,
dedupeConsecutiveHistory,
searchHistoryEntries
} from './lib/fish-readline.js'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
@@ -193,12 +187,35 @@ async function run(ctx, argv) {
t.is(ctx.vfs.getcwd(), '/bin')
await execShellLine(ctx, 'xy one two')
t.is(got[0], 'xy one two')
t.is(await execShellLine(ctx, 'exit'), 'exit')
t.is(await execShellLine(ctx, 'quit'), 'exit')
await store.close()
rmSync(dir, { recursive: true, force: true })
})
test('fish-readline stripAnsi and fuzzyMatch', async (t) => {
t.is(stripAnsi('\x1b[32mhi\x1b[0m'), 'hi')
t.ok(fuzzyMatch('hello', 'hlo'))
t.ok(!fuzzyMatch('hello', 'hxo'))
})
test('fish-readline history parse format dedupe search', async (t) => {
const parsed = parseHistoryFile('#1:a\n#2:b\n')
t.is(parsed.length, 2)
t.is(parsed[0].command, 'a')
const round = parseHistoryFile(formatHistoryFile(parsed))
t.is(round[1].command, 'b')
const deduped = dedupeConsecutiveHistory([
{ timestamp: 1, command: 'x' },
{ timestamp: 2, command: 'x' },
{ timestamp: 3, command: 'y' }
])
t.is(deduped.length, 2)
t.is(deduped[1].command, 'y')
const hist = [{ command: 'aa' }, { command: 'ba' }]
const found = searchHistoryEntries(hist, 'a')
t.ok(found.includes('ba'))
t.ok(found.includes('aa'))
})
test('tier-1 cat from system drive', async (t) => {
const dir = testCorestoreDir('cat')
const store = new Corestore(dir)
@@ -227,77 +244,3 @@ async function readBuiltBin(name) {
const p = path.join(__dirname, '../../kernel/bin', name)
return fs.readFile(p, 'utf8')
}
test('fish history parse serialize roundtrip', async (t) => {
const raw = '#100:ls\n#200:cd /\n'
const h = parseHistoryFile(raw)
t.is(h.length, 2)
t.is(h[0].command, 'ls')
const again = parseHistoryFile(serializeHistory(h))
t.is(again.length, 2)
})
test('ghostFromHistory uses most recent prefix match', async (t) => {
const h = [
{ timestamp: 1, command: 'ls /bin' },
{ timestamp: 2, command: 'ls /etc' },
{ timestamp: 3, command: 'pwd' }
]
t.is(ghostFromHistory(h, 'ls'), 'ls /etc')
t.is(ghostFromHistory(h, 'p'), 'pwd')
})
test('searchHistorySubstring reverse order', async (t) => {
const h = [
{ timestamp: 1, command: 'aa' },
{ timestamp: 2, command: 'ab' }
]
const r = searchHistorySubstring(h, 'a')
t.is(r[0], 'ab')
})
test('fuzzyMatch and SHELL_BUILTINS', async (t) => {
t.ok(fuzzyMatch('basename', 'bse'))
t.ok(SHELL_BUILTINS.includes('cd'))
})
test('tty-ansi emits CSI for cursor and clear', async (t) => {
/** @type {string[]} */
const chunks = []
const stream = {
write(s) {
chunks.push(typeof s === 'string' ? s : String(s))
}
}
cursorTo(stream, 0)
t.is(chunks.join(''), '\x1b[1G')
chunks.length = 0
cursorTo(stream, 4)
t.is(chunks.join(''), '\x1b[5G')
chunks.length = 0
cursorTo(stream, 2, 1)
t.is(chunks.join(''), '\x1b[2;3H')
chunks.length = 0
moveCursor(stream, 0, -2)
t.is(chunks.join(''), '\x1b[2A')
chunks.length = 0
moveCursor(stream, 3, 1)
t.is(chunks.join(''), '\x1b[1B\x1b[3C')
chunks.length = 0
clearScreenDown(stream)
t.is(chunks.join(''), '\x1b[J')
chunks.length = 0
clearEntireLine(stream)
t.is(chunks.join(''), '\r\x1b[2K')
chunks.length = 0
clearCurrentLine(stream)
t.is(chunks.join(''), '\x1b[1G\x1b[2K')
})
+1
View File
@@ -15,6 +15,7 @@ const commands = [
'dirname',
'echo',
'env',
'exit',
'false',
'head',
'help',
+12
View File
@@ -0,0 +1,12 @@
/** Drive-resident exit: ends the booter session (ctx.requestBooterExit from bare-os-booter). */
async function run(ctx, argv) {
let ec = 0
if (argv[1] !== undefined) {
const n = Number.parseInt(argv[1], 10)
ec = Number.isFinite(n) ? n : 0
}
if (typeof ctx.requestBooterExit === 'function') {
ctx.requestBooterExit(ec)
}
ctx.exitCode = ec
}
+1 -1
View File
@@ -1,5 +1,5 @@
async function run(ctx, argv) {
ctx.console.log(
'Bare OS — builtins: cd, export, exit, quit | /bin: basename cat clear date dirname echo env false head help hostname id ls nl pathchk printenv pwd rm seq sleep sort tail test touch true tty uname wc which whoami'
'Bare OS — builtins: cd, export, exit | /bin: basename cat clear date dirname echo env exit false head help hostname id ls nl pathchk printenv pwd rm seq sleep sort tail test touch true tty uname wc which whoami'
)
}
+17
View File
@@ -0,0 +1,17 @@
/** Shared helpers for drive-resident /bin scripts (prepended before each command). */
function bareStdin(ctx) {
return typeof ctx.shellStdin === 'string' ? ctx.shellStdin : ''
}
/** Drive-resident exit: ends the booter session (ctx.requestBooterExit from bare-os-booter). */
async function run(ctx, argv) {
let ec = 0
if (argv[1] !== undefined) {
const n = Number.parseInt(argv[1], 10)
ec = Number.isFinite(n) ? n : 0
}
if (typeof ctx.requestBooterExit === 'function') {
ctx.requestBooterExit(ec)
}
ctx.exitCode = ec
}
+1 -1
View File
@@ -5,6 +5,6 @@ function bareStdin(ctx) {
async function run(ctx, argv) {
ctx.console.log(
'Bare OS — builtins: cd, export, exit, quit | /bin: basename cat clear date dirname echo env false head help hostname id ls nl pathchk printenv pwd rm seq sleep sort tail test touch true tty uname wc which whoami'
'Bare OS — builtins: cd, export, exit | /bin: basename cat clear date dirname echo env exit false head help hostname id ls nl pathchk printenv pwd rm seq sleep sort tail test touch true tty uname wc which whoami'
)
}
+2 -2
View File
@@ -7,10 +7,10 @@ async function start(ctx) {
const rel = await drive.get('/etc/os-release')
if (rel) console.log(b4a.toString(rel))
console.log(
'Bare operating system — POSIX-ish shell: cd, export, exit, quit | try: help, ls /bin, pwd'
'Bare operating system — POSIX-ish shell: cd, export, exit | try: help, ls /bin, pwd'
)
while (true) {
const line = await readLine('bare-os> ')
const line = await readLine('')
if (line == null) break
const t = line.trim()
if (t === '') continue