Update
This commit is contained in:
+131
-168
@@ -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()
|
||||
})
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -3,22 +3,51 @@
|
||||
* on fds 0–2 (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 }
|
||||
}
|
||||
|
||||
@@ -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,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)
|
||||
|
||||
@@ -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())
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
@@ -15,6 +15,7 @@ const commands = [
|
||||
'dirname',
|
||||
'echo',
|
||||
'env',
|
||||
'exit',
|
||||
'false',
|
||||
'head',
|
||||
'help',
|
||||
|
||||
@@ -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,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'
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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'
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user