This commit is contained in:
Raven Scott
2026-04-02 22:20:04 -04:00
parent 9efede0dc8
commit efeee0088a
8 changed files with 272 additions and 40 deletions
+95 -24
View File
@@ -13,6 +13,12 @@ import {
defaultBootCorestorePath,
defaultLocalSeedCorestorePath
} from './lib/paths.js'
import {
createBareReadlineQuestion,
createStreamLineReader,
looksLikeInteractiveStdin
} from './lib/cli-readline.js'
import { resolveStdio } from './lib/resolve-stdio.js'
const _pkg = packageRootDir(import.meta.url)
@@ -20,33 +26,85 @@ function bootStorePath() {
return defaultBootCorestorePath(_pkg, import.meta.url)
}
/** @returns {Promise<{ readLine: (p: string) => Promise<string | null>, interactiveAvailable: boolean, skipInteractive: boolean }>} */
async function createReadLine() {
if (globalThis.process?.env?.BARE_OS_SKIP_REPL === '1') {
return async () => null
}
try {
const { createInterface } = await import('node:readline')
const stdin = globalThis.process?.stdin
const stdout = globalThis.process?.stdout
if (!stdin || !stdout) {
return async () => null
const skipInteractive =
globalThis.process?.env?.BARE_OS_SKIP_REPL === '1'
if (skipInteractive) {
return {
readLine: async () => null,
interactiveAvailable: false,
skipInteractive: true
}
return (prompt) =>
new Promise((resolve) => {
const rl = createInterface({
input: stdin,
output: stdout
})
rl.question(prompt, (line) => {
rl.close()
resolve(line)
})
})
} catch {
}
const { stdin, stdout } = await resolveStdio()
if (!stdin || !stdout) {
console.warn(
'[bare-os-booter] No node:readline; exiting REPL. Set BARE_OS_SKIP_REPL=1 for non-interactive kernel or run under Node.'
'[bare-os-booter] No stdin/stdout (neither process nor bare-stdio). Cannot run interactive shell.'
)
return async () => null
return {
readLine: async () => null,
interactiveAvailable: false,
skipInteractive: false
}
}
let nodeReadline = null
try {
nodeReadline = await import('node:readline')
} catch {
/* Bare / Pear */
}
if (nodeReadline?.createInterface) {
const { createInterface } = nodeReadline
return {
readLine: (prompt) =>
new Promise((resolve) => {
const rl = createInterface({
input: stdin,
output: stdout
})
rl.question(prompt, (line) => {
rl.close()
resolve(line)
})
}),
interactiveAvailable: true,
skipInteractive: false
}
}
try {
const readLine = await createBareReadlineQuestion(stdin, stdout)
return {
readLine,
interactiveAvailable: true,
skipInteractive: false
}
} catch {
/* bare-readline failed to load */
}
if (
looksLikeInteractiveStdin(stdin) &&
typeof stdout.write === 'function'
) {
return {
readLine: createStreamLineReader(stdin, stdout),
interactiveAvailable: true,
skipInteractive: false
}
}
console.warn(
'[bare-os-booter] No line input. Set BARE_OS_SKIP_REPL=1 to exit after boot.'
)
return {
readLine: async () => null,
interactiveAvailable: false,
skipInteractive: false
}
}
@@ -57,7 +115,20 @@ async function createReadLine() {
* @param {Uint8Array} initSource
*/
async function executeKernel(disk, store, swarm, initSource) {
const readLine = await createReadLine()
const { readLine: rawReadLine, interactiveAvailable, skipInteractive } =
await createReadLine()
/** 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 rawReadLine(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
}
const ctx = {
disk,
@@ -0,0 +1,96 @@
/**
* 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.
*/
/**
* @param {import('stream').Readable} stdin
* @param {import('stream').Writable} stdout
* @returns {(prompt: string) => Promise<string>}
*/
export async function createBareReadlineQuestion(stdin, stdout) {
const mod = await import('bare-readline')
const createInterface =
mod.createInterface ?? mod.default?.createInterface
if (typeof createInterface !== 'function') {
throw new Error('bare-readline: createInterface missing')
}
const rl = createInterface({
input: stdin,
output: stdout,
prompt: ''
})
return (prompt) =>
new Promise((resolve) => {
rl.setPrompt(prompt)
rl.prompt()
rl.once('line', (line) => {
resolve(line)
})
})
}
/**
* @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
let i
while ((i = buf.indexOf('\n')) !== -1) {
const raw = buf.slice(0, i)
buf = buf.slice(i + 1)
deliver(raw.replace(/\r$/, ''))
}
}
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') stdout.write(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'
)
}
@@ -0,0 +1,24 @@
/**
* Pear often runs without `process.stdin` / `process.stdout`; Holepunch uses `bare-stdio`
* on fds 02 (same idea as pear-terminal's stdio singleton).
*/
/**
* @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) {
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) {
return { stdin: io.in, stdout: io.out }
}
} catch (_) {
/* optional under Node-only installs */
}
return { stdin: null, stdout: null }
}
+2
View File
@@ -10,6 +10,8 @@
"test": "brittle-node test.js"
},
"dependencies": {
"bare-readline": "^1.3.1",
"bare-stdio": "^1.0.2",
"bare-os": "^3.8.7",
"bare-os-protocol": "*",
"b4a": "^1.6.7",
+18
View File
@@ -5,7 +5,9 @@ import Corestore from 'corestore'
import { mkdirSync, rmSync } from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { PassThrough } from 'node:stream'
import { runKernelFromSource, runBinCommand } from './lib/kernel-runner.js'
import { createStreamLineReader } from './lib/cli-readline.js'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
@@ -46,6 +48,22 @@ test('runBinCommand runs /bin helper', async (t) => {
rmSync(dir, { recursive: true, force: true })
})
test('createStreamLineReader yields lines after newline', async (t) => {
const stdin = new PassThrough()
const chunks = []
const stdout = {
write(s) {
chunks.push(String(s))
}
}
const readLine = createStreamLineReader(stdin, stdout)
const p = readLine('> ')
stdin.write('help\n')
t.is(await p, 'help')
t.ok(chunks.join('').includes('> '))
stdin.end()
})
test('Hyperdrive roundtrips /boot/init.js on Corestore', async (t) => {
const dir = testCorestoreDir('seed')
const store = new Corestore(dir)