updates
This commit is contained in:
@@ -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()
|
||||
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user