This commit is contained in:
Raven Scott
2026-04-02 22:35:09 -04:00
parent efeee0088a
commit 2e26b14250
56 changed files with 1838 additions and 75 deletions
+27 -14
View File
@@ -4,10 +4,11 @@ import b4a from 'b4a'
import safetyCatch from 'safety-catch'
import Hyperdrive from 'hyperdrive'
import Corestore from 'corestore'
import path from 'path'
import { topicKey, parseMbr } from 'bare-os-protocol'
import { SwarmDisk } from './lib/swarm-disk.js'
import { runKernelFromSource, runBinCommand } from './lib/kernel-runner.js'
import { runKernelFromSource } from './lib/kernel-runner.js'
import { createVfs } from './lib/vfs.js'
import { execShellLine } from './lib/shell.js'
import {
packageRootDir,
defaultBootCorestorePath,
@@ -28,8 +29,7 @@ function bootStorePath() {
/** @returns {Promise<{ readLine: (p: string) => Promise<string | null>, interactiveAvailable: boolean, skipInteractive: boolean }>} */
async function createReadLine() {
const skipInteractive =
globalThis.process?.env?.BARE_OS_SKIP_REPL === '1'
const skipInteractive = globalThis.process?.env?.BARE_OS_SKIP_REPL === '1'
if (skipInteractive) {
return {
readLine: async () => null,
@@ -87,10 +87,7 @@ async function createReadLine() {
/* bare-readline failed to load */
}
if (
looksLikeInteractiveStdin(stdin) &&
typeof stdout.write === 'function'
) {
if (looksLikeInteractiveStdin(stdin) && typeof stdout.write === 'function') {
return {
readLine: createStreamLineReader(stdin, stdout),
interactiveAvailable: true,
@@ -115,8 +112,11 @@ async function createReadLine() {
* @param {Uint8Array} initSource
*/
async function executeKernel(disk, store, swarm, initSource) {
const { readLine: rawReadLine, interactiveAvailable, skipInteractive } =
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) => {
@@ -130,18 +130,28 @@ async function executeKernel(disk, store, swarm, initSource) {
return line
}
const shellEnv = {
HOME: '/home/user',
PATH: '/bin',
USER: 'user',
PWD: '/home/user',
SHELL: 'bare-sh',
0: 'bare-os'
}
const vfs = createVfs(disk.drive, disk.personalDrive, shellEnv)
const ctx = {
disk,
drive: disk.drive,
personalDrive: disk.personalDrive,
vfs,
env: shellEnv,
console,
b4a,
topic: topicKey(),
readLine,
async execLine(line) {
const parts = line.trim().split(/\s+/)
if (!parts[0]) return
await runBinCommand(disk.drive, parts, ctx)
return await execShellLine(ctx, line)
}
}
@@ -166,7 +176,10 @@ async function bootFromPeers(disk, store, swarm) {
let initSource = null
for (const driveKey of keys) {
try {
console.log('Mounting drive', b4a.toString(driveKey, 'hex').slice(0, 16) + '...')
console.log(
'Mounting drive',
b4a.toString(driveKey, 'hex').slice(0, 16) + '...'
)
disk.drive = new Hyperdrive(store, driveKey)
await disk.drive.ready()
+3 -4
View File
@@ -10,8 +10,7 @@
*/
export async function createBareReadlineQuestion(stdin, stdout) {
const mod = await import('bare-readline')
const createInterface =
mod.createInterface ?? mod.default?.createInterface
const createInterface = mod.createInterface ?? mod.default?.createInterface
if (typeof createInterface !== 'function') {
throw new Error('bare-readline: createInterface missing')
}
@@ -90,7 +89,7 @@ export function createStreamLineReader(stdin, stdout) {
export function looksLikeInteractiveStdin(stdin) {
return Boolean(
stdin &&
typeof stdin.on === 'function' &&
typeof stdin.resume === 'function'
typeof stdin.on === 'function' &&
typeof stdin.resume === 'function'
)
}
+42 -12
View File
@@ -1,4 +1,5 @@
import b4a from 'b4a'
import unixPathResolve from 'unix-path-resolve'
const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor
@@ -16,23 +17,52 @@ export async function runKernelFromSource(source, ctx) {
}
/**
* Run /bin/<cmd> script with `async function run(ctx, argv)`.
* @param {import('hyperdrive').default} drive
* @param {string[]} argv
* @param {Record<string, unknown>} ctx
* @param {string} src
* @param {string[]} argv
* @param {string} [label]
*/
export async function runBinCommand(drive, argv, ctx) {
const cmd = argv[0]
const buf = await drive.get('/bin/' + cmd)
if (!buf) {
ctx.console.log('unknown command: ' + cmd)
return
}
const src = b4a.toString(buf)
async function runScriptFromSource(ctx, src, argv, label = argv[0]) {
const fn = new AsyncFunction(
'ctx',
'argv',
`${src}\nif (typeof run !== 'function') throw new Error('missing run() in /bin/${cmd}')\nreturn run(ctx, argv)\n`
`${src}\nif (typeof run !== 'function') throw new Error('missing run() in ${label}')\nreturn run(ctx, argv)\n`
)
return fn(ctx, argv)
}
/**
* Run a command: PATH on system drive, or path script on routed drive.
* @param {Record<string, unknown>} ctx
* @param {string[]} argv
*/
export async function runBinCommand(ctx, argv) {
const cmd = argv[0]
const systemDrive = ctx.drive
const vfs = ctx.vfs
const pathEnv = (vfs && vfs.env && vfs.env.PATH) || '/bin'
if (cmd.includes('/')) {
const abs = vfs.resolveLogical(cmd)
const { drive, path } = vfs.route(abs)
const buf = await drive.get(path, { follow: true })
if (!buf) {
ctx.console.log('not found: ' + cmd)
return
}
const source = b4a.toString(buf)
return runScriptFromSource(ctx, source, argv, cmd)
}
const dirs = pathEnv.split(':').filter(Boolean)
for (const dir of dirs) {
const p = unixPathResolve(dir, cmd)
const buf = await systemDrive.get(p, { follow: true })
if (buf) {
const source = b4a.toString(buf)
return runScriptFromSource(ctx, source, argv, p)
}
}
ctx.console.log('unknown command: ' + cmd)
}
+318
View File
@@ -0,0 +1,318 @@
import { runBinCommand } from './kernel-runner.js'
/**
* @typedef {{ type: 'word', value: string } | { type: 'op', value: string }} Token
*/
/** @param {string} line */
export function tokenize(line) {
/** @type {Token[]} */
const tokens = []
let i = 0
const skipWs = () => {
while (i < line.length && /\s/.test(line[i])) i++
}
while (i < line.length) {
skipWs()
if (i >= line.length) break
const c = line[i]
if (c === '|') {
tokens.push({ type: 'op', value: '|' })
i++
continue
}
if (c === '>') {
if (line[i + 1] === '>') {
tokens.push({ type: 'op', value: '>>' })
i += 2
} else {
tokens.push({ type: 'op', value: '>' })
i++
}
continue
}
if (c === '<') {
tokens.push({ type: 'op', value: '<' })
i++
continue
}
let word = ''
while (i < line.length) {
const ch = line[i]
if (ch === '\\') {
i++
if (i < line.length) word += line[i++]
continue
}
if (ch === "'") {
i++
while (i < line.length && line[i] !== "'") {
word += line[i++]
}
if (i < line.length) i++
continue
}
if (ch === '"') {
i++
while (i < line.length && line[i] !== '"') {
if (line[i] === '\\' && i + 1 < line.length) {
i++
word += line[i++]
continue
}
word += line[i++]
}
if (i < line.length) i++
continue
}
if (/\s/.test(ch) || ch === '|' || ch === '>' || ch === '<') break
word += ch
i++
}
if (word.length) tokens.push({ type: 'word', value: word })
}
return tokens
}
/**
* @param {string} s
* @param {Record<string, string>} env
*/
export function expandWord(s, env) {
let out = ''
let j = 0
while (j < s.length) {
if (s[j] === '$') {
if (s[j + 1] === '{') {
const end = s.indexOf('}', j + 2)
if (end === -1) {
out += s.slice(j)
break
}
const name = s.slice(j + 2, end)
out += env[name] ?? ''
j = end + 1
continue
}
if (/[0-9]/.test(s[j + 1] ?? '')) {
out += env[s[j + 1]] ?? ''
j += 2
continue
}
let k = j + 1
while (k < s.length && /[A-Za-z0-9_]/.test(s[k])) k++
const name = s.slice(j + 1, k)
if (name) {
out += env[name] ?? ''
j = k
} else {
out += '$'
j++
}
continue
}
out += s[j++]
}
return out
}
/**
* @typedef {{ argv: string[], assign: Record<string, string>, redirIn: string | null, redirOut: string | null, redirAppend: boolean }} SimpleCmd
*/
/**
* @param {Token[]} tokens
* @returns {SimpleCmd[][]}
*/
export function parsePipeline(tokens) {
/** @type {Token[][]} */
const pipes = [[]]
for (const t of tokens) {
if (t.type === 'op' && t.value === '|') {
pipes.push([])
} else {
pipes[pipes.length - 1].push(t)
}
}
return pipes.map((seg) => parseSimpleCommand(seg))
}
/** @param {Token[]} seg */
function parseSimpleCommand(seg) {
/** @type {Record<string, string>} */
const assign = {}
let redirIn = null
let redirOut = null
let redirAppend = false
/** @type {string[]} */
const argvWords = []
let seenCommand = false
let w = 0
while (w < seg.length) {
const t = seg[w]
if (t.type === 'op') {
if (t.value === '>' || t.value === '>>') {
const n = seg[w + 1]
if (n && n.type === 'word') {
redirAppend = t.value === '>>'
redirOut = n.value
w += 2
continue
}
}
if (t.value === '<') {
const n = seg[w + 1]
if (n && n.type === 'word') {
redirIn = n.value
w += 2
continue
}
}
w++
continue
}
const v = t.value
if (!seenCommand) {
const eq = v.indexOf('=')
if (eq > 0 && /^[A-Za-z_][A-Za-z0-9_]*$/.test(v.slice(0, eq))) {
assign[v.slice(0, eq)] = v.slice(eq + 1)
w++
continue
}
}
seenCommand = true
argvWords.push(v)
w++
}
let i = 0
while (i < argvWords.length) {
if (argvWords[i] === '>') {
redirOut = argvWords[i + 1] ?? ''
redirAppend = false
argvWords.splice(i, 2)
continue
}
if (argvWords[i] === '>>') {
redirOut = argvWords[i + 1] ?? ''
redirAppend = true
argvWords.splice(i, 2)
continue
}
if (argvWords[i] === '<') {
redirIn = argvWords[i + 1] ?? ''
argvWords.splice(i, 2)
continue
}
i++
}
return { argv: argvWords, assign, redirIn, redirOut, redirAppend }
}
/**
* @param {Record<string, unknown>} ctx
* @param {string} line
* @returns {Promise<'exit' | 'ok'>}
*/
export async function execShellLine(ctx, line) {
const raw = line.trim()
if (!raw) return 'ok'
const tokens = tokenize(raw)
if (!tokens.length) return 'ok'
const pipeline = parsePipeline(tokens)
const vfs = ctx.vfs
const env = vfs.env
let stdinText = typeof ctx.shellStdin === 'string' ? ctx.shellStdin : null
for (let pi = 0; pi < pipeline.length; pi++) {
const cmd = pipeline[pi]
const isLast = pi === pipeline.length - 1
for (const [k, val] of Object.entries(cmd.assign)) {
env[k] = expandWord(val, env)
}
if (!cmd.argv.length) continue
const argv = cmd.argv.map((w) => expandWord(w, env))
const name = argv[0]
if (cmd.redirIn) {
const p = expandWord(cmd.redirIn, env)
const buf = await vfs.readFile(p)
stdinText = buf ? ctx.b4a.toString(buf) : ''
}
const outChunks = []
const origLog = ctx.console.log
const origErr = ctx.console.error
if (!isLast || cmd.redirOut) {
ctx.console.log = (...args) => {
outChunks.push(args.map(String).join(' ') + '\n')
}
ctx.console.error = ctx.console.log
}
let code = 'ok'
try {
if (name === 'cd') {
try {
await vfs.chdir(argv[1] || vfs.home)
} catch (e) {
origErr.call(ctx.console, (e && e.message) || String(e))
}
} else if (name === 'export') {
for (const a of argv.slice(1)) {
const eq = a.indexOf('=')
if (eq > 0) env[a.slice(0, eq)] = expandWord(a.slice(eq + 1), env)
}
} else if (name === 'exit') {
code = 'exit'
} else {
const childCtx =
stdinText != null
? Object.assign({}, ctx, { shellStdin: stdinText, env })
: Object.assign({}, ctx, { env })
await runBinCommand(childCtx, argv)
}
} finally {
if (!isLast || cmd.redirOut) {
ctx.console.log = origLog
ctx.console.error = origErr
}
}
if (code === 'exit') return 'exit'
let pipeOut = outChunks.join('')
if (cmd.redirOut) {
const path = expandWord(cmd.redirOut, env)
const data = ctx.b4a.from(pipeOut)
if (cmd.redirAppend) {
const prev = await vfs.readFile(path)
const merged = prev ? ctx.b4a.concat([prev, data]) : data
await vfs.writeFile(path, merged)
} else {
await vfs.writeFile(path, data)
}
pipeOut = ''
}
stdinText = isLast ? null : pipeOut
ctx.shellStdin = stdinText ?? undefined
}
return 'ok'
}
+10 -3
View File
@@ -151,7 +151,10 @@ export class SwarmDisk {
c.array(c.string).encode(state, m.matches)
},
decode(state) {
return { id: c.uint32.decode(state), matches: c.array(c.string).decode(state) }
return {
id: c.uint32.decode(state),
matches: c.array(c.string).decode(state)
}
}
},
onmessage: (m, ch) => ch.userData.onsearchres(m)
@@ -242,14 +245,18 @@ export class SwarmDisk {
this.peers.delete(peer)
})
if (this.drive) this.drive.replicate(mux.stream, { live: true, download: true })
if (this.drive)
this.drive.replicate(mux.stream, { live: true, download: true })
if (this.personalDrive) this.personalDrive.replicate(mux.stream)
}
async read(index) {
if (this.localRAM.has(index)) return this.localRAM.get(index)
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => reject(new Error('MBR read timeout')), 10000)
const timeout = setTimeout(
() => reject(new Error('MBR read timeout')),
10000
)
this.pendingReads.set(index, (data) => {
clearTimeout(timeout)
resolve(data)
+144
View File
@@ -0,0 +1,144 @@
import unixPathResolve from 'unix-path-resolve'
/**
* Unified path view: system Hyperdrive for OS paths, personal Hyperdrive under $HOME.
* @param {import('hyperdrive').default} systemDrive
* @param {import('hyperdrive').default} personalDrive
* @param {Record<string, string>} env
*/
export function createVfs(systemDrive, personalDrive, env) {
const HOME = () => env.HOME || '/home/user'
let cwd = env.PWD || HOME()
function normalizeHome() {
const h = HOME()
return h.length > 1 && h.endsWith('/') ? h.slice(0, -1) : h
}
/** Logical absolute path from cwd + user path */
function resolveLogical(userPath) {
return unixPathResolve(cwd, userPath)
}
/** Map logical absolute path to { drive, path } for Hyperdrive ops */
function route(absPath) {
const h = normalizeHome()
if (absPath === h || absPath.startsWith(h + '/')) {
const sub =
absPath === h
? '/'
: '/' + absPath.slice(h.length + 1).replace(/^\//, '')
const p = unixPathResolve('/', sub)
return { drive: personalDrive, path: p }
}
return { drive: systemDrive, path: absPath }
}
async function entryOn(drive, p, opts) {
return drive.entry(p, opts)
}
async function isRegularFile(absPath) {
const { drive, path: p } = route(absPath)
const e = await entryOn(drive, p, { follow: true })
return !!(e && e.value && e.value.blob)
}
return {
get home() {
return normalizeHome()
},
getcwd() {
return cwd
},
resolveLogical,
route,
env,
async chdir(userPath) {
const abs = resolveLogical(userPath)
if (await isRegularFile(abs)) {
throw new Error('Not a directory: ' + userPath)
}
cwd = abs
env.PWD = cwd
},
async readFile(userPath) {
const abs = resolveLogical(userPath)
const { drive, path: p } = route(abs)
return drive.get(p, { follow: true })
},
async writeFile(userPath, buf, opts = {}) {
const abs = resolveLogical(userPath)
const { drive, path: p } = route(abs)
if (drive !== personalDrive) {
throw new Error('Read-only path (not under $HOME): ' + userPath)
}
return drive.put(p, buf, opts)
},
async unlink(userPath) {
const abs = resolveLogical(userPath)
const { drive, path: p } = route(abs)
if (drive !== personalDrive) {
throw new Error('Read-only path (not under $HOME): ' + userPath)
}
return drive.del(p)
},
async exists(userPath) {
const abs = resolveLogical(userPath)
const { drive, path: p } = route(abs)
return drive.exists(p)
},
/** @returns {Promise<string[]>} */
async readdir(userPath) {
const abs = resolveLogical(userPath)
const { drive, path: p } = route(abs)
const folder = p === '/' ? '/' : p
const names = []
const stream = drive.readdir(folder)
for await (const name of stream) {
names.push(name)
}
return names.sort()
},
async stat(userPath) {
const abs = resolveLogical(userPath)
const { drive, path: p } = route(abs)
const e = await entryOn(drive, p, { follow: true })
if (e && e.value && e.value.blob) {
const bl = e.value.blob
const len =
typeof bl.byteLength === 'number'
? bl.byteLength
: (bl.blockLength ?? 0)
return { type: 'file', size: len, path: abs }
}
if (e && e.value && e.value.linkname) {
return { type: 'symlink', path: abs }
}
const names = await (async () => {
const out = []
try {
const stream = drive.readdir(p === '/' ? '/' : p)
for await (const n of stream) out.push(n)
} catch {
/* treat as missing */
}
return out
})()
if (names.length) return { type: 'directory', path: abs }
if (e) return { type: 'directory', path: abs }
return null
}
}
}
+2 -1
View File
@@ -20,7 +20,8 @@
"hyperdrive": "^13.3.2",
"hyperswarm": "^4.16.0",
"protomux": "^3.10.1",
"safety-catch": "^1.0.2"
"safety-catch": "^1.0.2",
"unix-path-resolve": "^1.0.2"
},
"devDependencies": {
"brittle": "^3.1.0"
+118 -2
View File
@@ -8,15 +8,40 @@ 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'
import { createVfs } from './lib/vfs.js'
import { tokenize, expandWord, execShellLine } from './lib/shell.js'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
function testCorestoreDir(name) {
const dir = path.join(__dirname, '.test-data', name + '-' + process.pid + '-' + Math.random().toString(36).slice(2))
const dir = path.join(
__dirname,
'.test-data',
name + '-' + process.pid + '-' + Math.random().toString(36).slice(2)
)
mkdirSync(path.dirname(dir), { recursive: true })
return dir
}
function testCtx(drive, personal, env) {
const shellEnv = {
HOME: '/home/user',
PATH: '/bin',
USER: 'user',
PWD: '/home/user',
...env
}
const vfs = createVfs(drive, personal, shellEnv)
return {
drive,
personalDrive: personal,
vfs,
env: shellEnv,
console,
b4a
}
}
test('runKernelFromSource invokes start(ctx)', async (t) => {
const calls = []
const source = `
@@ -32,7 +57,9 @@ test('runBinCommand runs /bin helper', async (t) => {
const dir = testCorestoreDir('bin')
const store = new Corestore(dir)
const drive = new Hyperdrive(store)
const personal = new Hyperdrive(store.namespace('p'))
await drive.ready()
await personal.ready()
await drive.put(
'/bin/hello',
b4a.from(`
@@ -42,7 +69,9 @@ test('runBinCommand runs /bin helper', async (t) => {
`)
)
const out = []
await runBinCommand(drive, ['hello', 'a', 'b'], { out, console })
const ctx = testCtx(drive, personal)
ctx.out = out
await runBinCommand(ctx, ['hello', 'a', 'b'])
t.is(out[0], 'hello a b')
await store.close()
rmSync(dir, { recursive: true, force: true })
@@ -76,3 +105,90 @@ test('Hyperdrive roundtrips /boot/init.js on Corestore', async (t) => {
await drive.close()
rmSync(dir, { recursive: true, force: true })
})
test('vfs routes HOME to personal drive', async (t) => {
const dir = testCorestoreDir('vfs')
const store = new Corestore(dir)
const sys = new Hyperdrive(store)
const personal = new Hyperdrive(store.namespace('pv'))
await sys.ready()
await personal.ready()
const env = { HOME: '/home/user', PWD: '/home/user', PATH: '/bin' }
const vfs = createVfs(sys, personal, env)
await vfs.writeFile('f.txt', b4a.from('hi'))
const buf = await personal.get('/f.txt')
t.ok(buf)
t.is(b4a.toString(buf), 'hi')
const sysTry = await sys.get('/home/user/f.txt')
t.is(sysTry, null)
await store.close()
rmSync(dir, { recursive: true, force: true })
})
test('tokenize handles quotes and ops', async (t) => {
const tok = tokenize('ls -la | cat > out')
t.ok(tok.some((x) => x.type === 'op' && x.value === '|'))
t.ok(tok.some((x) => x.type === 'op' && x.value === '>'))
const w = tokenize("echo 'a b'")
const words = w.filter((x) => x.type === 'word').map((x) => x.value)
t.is(words.join(','), 'echo,a b')
})
test('expandWord reads env', async (t) => {
t.is(expandWord('x${HOME}y', { HOME: '/h' }), 'x/hy')
})
test('execShellLine runs cd and external', async (t) => {
const dir = testCorestoreDir('sh')
const store = new Corestore(dir)
const drive = new Hyperdrive(store)
const personal = new Hyperdrive(store.namespace('psh'))
await drive.ready()
await personal.ready()
await drive.put(
'/bin/xy',
b4a.from(`
async function run(ctx, argv) {
ctx.got.push(argv.join(' '))
}
`)
)
const got = []
const ctx = testCtx(drive, personal)
ctx.got = got
await execShellLine(ctx, 'cd /bin')
t.is(ctx.vfs.getcwd(), '/bin')
await execShellLine(ctx, 'xy one two')
t.is(got[0], 'xy one two')
await store.close()
rmSync(dir, { recursive: true, force: true })
})
test('tier-1 cat from system drive', async (t) => {
const dir = testCorestoreDir('cat')
const store = new Corestore(dir)
const drive = new Hyperdrive(store)
const personal = new Hyperdrive(store.namespace('pc'))
await drive.ready()
await personal.ready()
await drive.put('/bin/cat', b4a.from(await readBuiltBin('cat')))
await drive.put('/etc/x', b4a.from('hello'))
const lines = []
const ctx = testCtx(drive, personal)
ctx.console = {
log(s) {
lines.push(String(s))
},
error() {}
}
await runBinCommand(ctx, ['cat', '/etc/x'])
t.is(lines.join('\n'), 'hello')
await store.close()
rmSync(dir, { recursive: true, force: true })
})
async function readBuiltBin(name) {
const fs = await import('node:fs/promises')
const p = path.join(__dirname, '../../kernel/bin', name)
return fs.readFile(p, 'utf8')
}