New tooling
Entry: scripts/run-bin.mjs Preload shim (must load first): scripts/run-bin-prelude.mjs Harness: scripts/lib/run-bin-harness.mjs Usage: node scripts/run-bin.mjs ls -la /bin node scripts/run-bin.mjs cat /etc/passwd node scripts/run-bin.mjs --cmd sed -n '1,5p' /home/user/note.txt node scripts/run-bin.mjs --json echo "hello" printf 'a\nb\n' | node scripts/run-bin.mjs cat npm run test:coreutils -- --suite basic npm run test:coreutils -- --build --watch -- echo test # rebuild + watch src/<cmd>.js (use `--buil
This commit is contained in:
@@ -0,0 +1,516 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Fast isolated /bin runner: temp Hyperdrives + createVfs + runBinCommand (no Pear / swarm).
|
||||
* Usage: node scripts/run-bin.mjs [--json] [--build] [--cmd NAME] [--suite NAME] [--interactive] [--watch] [--no-vfs-snapshot] -- [args…]
|
||||
*/
|
||||
|
||||
import './run-bin-prelude.mjs'
|
||||
|
||||
import { spawn } from 'node:child_process'
|
||||
import { watch } from 'node:fs'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import process from 'node:process'
|
||||
import readline from 'node:readline/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import {
|
||||
stdin as stdinStream,
|
||||
stdout as stdoutStream,
|
||||
stderr as stderrStream
|
||||
} from 'node:process'
|
||||
import { createRunBinHarness } from './lib/run-bin-harness.mjs'
|
||||
|
||||
/** Same mapping as bare-os-coreutils/build.mjs `commandSourceFile`. */
|
||||
function commandSourceFile(binName) {
|
||||
if (binName === 'nano') return 'edit'
|
||||
if (binName === 'btop') return 'baretop'
|
||||
if (binName === 'say') return 'baresay'
|
||||
return binName
|
||||
}
|
||||
|
||||
const G = '\x1b[32m'
|
||||
const Y = '\x1b[33m'
|
||||
const Dim = '\x1b[2m'
|
||||
const R = '\x1b[31m'
|
||||
const Reset = '\x1b[0m'
|
||||
|
||||
const repoRoot = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
'..'
|
||||
)
|
||||
|
||||
function printHelp() {
|
||||
stdoutStream.write(`Bare OS run-bin — execute built kernel/bin utilities in an isolated VFS (no Pear).
|
||||
|
||||
${Dim}Usage:${Reset}
|
||||
node scripts/run-bin.mjs [options] <command> [args…]
|
||||
node scripts/run-bin.mjs [options] --cmd <name> [args…]
|
||||
printf 'a\\nb' | node scripts/run-bin.mjs cat
|
||||
|
||||
${Dim}Options:${Reset}
|
||||
--json Machine-readable JSON on stdout (for agents / Cursor)
|
||||
--build Run npm run build -w bare-os-coreutils first
|
||||
--cmd <name> Command name when argv would be ambiguous
|
||||
--suite <name> Run test/fixtures/coreutils/<name>.suite.json
|
||||
--interactive REPL (reuse one harness)
|
||||
--watch Watch coreutils src/<cmd>.js and rebuild + rerun last invocation
|
||||
--no-vfs-snapshot Omit vfsSnapshot from --json (faster)
|
||||
-h, --help This help
|
||||
|
||||
${Dim}Examples:${Reset}
|
||||
node scripts/run-bin.mjs ls -la /bin
|
||||
node scripts/run-bin.mjs --json echo hello
|
||||
node scripts/run-bin.mjs --cmd sed -n '1,2p' /home/user/note.txt
|
||||
npm run test:coreutils -- --suite basic
|
||||
`)
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
/** @type {{ json: boolean, build: boolean, interactive: boolean, watch: boolean, vfsSnapshot: boolean, suite: string | null, cmdExplicit: string | null, help: boolean, positional: string[] }} */
|
||||
const out = {
|
||||
json: false,
|
||||
build: false,
|
||||
interactive: false,
|
||||
watch: false,
|
||||
vfsSnapshot: true,
|
||||
suite: null,
|
||||
cmdExplicit: null,
|
||||
help: false,
|
||||
positional: []
|
||||
}
|
||||
let i = 0
|
||||
while (i < argv.length) {
|
||||
const a = argv[i]
|
||||
if (a === '--') {
|
||||
out.positional.push(...argv.slice(i + 1))
|
||||
break
|
||||
}
|
||||
if (a === '-h' || a === '--help') {
|
||||
out.help = true
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if (a === '--json') {
|
||||
out.json = true
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if (a === '--build') {
|
||||
out.build = true
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if (a === '--interactive') {
|
||||
out.interactive = true
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if (a === '--watch') {
|
||||
out.watch = true
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if (a === '--no-vfs-snapshot') {
|
||||
out.vfsSnapshot = false
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if (a === '--suite') {
|
||||
out.suite = argv[i + 1] || ''
|
||||
i += 2
|
||||
continue
|
||||
}
|
||||
if (a.startsWith('--suite=')) {
|
||||
out.suite = a.slice('--suite='.length)
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if (a === '--cmd') {
|
||||
out.cmdExplicit = argv[i + 1] || ''
|
||||
i += 2
|
||||
out.positional.push(...argv.slice(i))
|
||||
break
|
||||
}
|
||||
if (a.startsWith('-')) {
|
||||
stderrStream.write(`${R}run-bin:${Reset} unknown flag ${a}\n`)
|
||||
process.exitCode = 2
|
||||
return null
|
||||
}
|
||||
out.positional.push(...argv.slice(i))
|
||||
break
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function runNpmBuild() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const p = spawn('npm', ['run', 'build', '-w', 'bare-os-coreutils'], {
|
||||
cwd: repoRoot,
|
||||
stdio: 'inherit',
|
||||
env: process.env
|
||||
})
|
||||
p.on('error', reject)
|
||||
p.on('close', (code) => {
|
||||
if (code === 0) resolve()
|
||||
else
|
||||
reject(new Error(`npm run build -w bare-os-coreutils exited ${code}`))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function readStdinIfPiped() {
|
||||
if (stdinStream.isTTY) return ''
|
||||
const chunks = []
|
||||
return await new Promise((resolve, reject) => {
|
||||
let settled = false
|
||||
let gotData = false
|
||||
function finish() {
|
||||
if (settled) return
|
||||
settled = true
|
||||
resolve(Buffer.concat(chunks).toString('utf8'))
|
||||
}
|
||||
stdinStream.on('data', (c) => {
|
||||
gotData = true
|
||||
chunks.push(c)
|
||||
})
|
||||
stdinStream.once('end', finish)
|
||||
stdinStream.once('error', reject)
|
||||
stdinStream.resume()
|
||||
setTimeout(() => {
|
||||
if (!gotData) finish()
|
||||
}, 100)
|
||||
})
|
||||
}
|
||||
|
||||
async function executeOnce(harness, positional, opts) {
|
||||
let argv
|
||||
if (opts.cmdExplicit) {
|
||||
argv = [opts.cmdExplicit, ...positional]
|
||||
} else if (positional.length === 0) {
|
||||
throw new Error(
|
||||
'run-bin: missing command (pass a utility name or --suite / --interactive)'
|
||||
)
|
||||
} else {
|
||||
argv = positional
|
||||
}
|
||||
|
||||
const shellStdin = opts.stdinOverride ?? (await readStdinIfPiped())
|
||||
const r = await harness.run(argv, {
|
||||
shellStdin,
|
||||
timeoutMs: harness.timeoutMs
|
||||
})
|
||||
|
||||
const snap = await harness.vfsSnapshot({
|
||||
skip: opts.vfsSnapshot === false
|
||||
})
|
||||
|
||||
let errStr = null
|
||||
if (r.thrown) errStr = r.thrown.stack || r.thrown.message
|
||||
|
||||
const command = argv[0] || ''
|
||||
|
||||
const payload = {
|
||||
command,
|
||||
argv,
|
||||
exitCode: r.exitCode,
|
||||
stdout: r.stdout,
|
||||
stderr: r.stderr,
|
||||
durationMs: Math.round(r.durationMs * 1000) / 1000,
|
||||
error: errStr,
|
||||
vfsSnapshot: snap ?? undefined
|
||||
}
|
||||
|
||||
if (opts.json) {
|
||||
stdoutStream.write(JSON.stringify(payload) + '\n')
|
||||
} else {
|
||||
if (r.stdout)
|
||||
stdoutStream.write(r.stdout.endsWith('\n') ? r.stdout : r.stdout + '\n')
|
||||
if (r.stderr)
|
||||
stderrStream.write(
|
||||
Y + r.stderr + Reset + (r.stderr.endsWith('\n') ? '' : '\n')
|
||||
)
|
||||
if (r.thrown) {
|
||||
stderrStream.write(
|
||||
R + (r.thrown.stack || r.thrown.message) + Reset + '\n'
|
||||
)
|
||||
}
|
||||
const ec = r.exitCode
|
||||
const ecColor = ec === 0 ? G : R
|
||||
stderrStream.write(
|
||||
`${Dim}(${ecColor}exit ${ec}${Dim} · ${Math.round(r.durationMs)}ms${Dim})${Reset}\n`
|
||||
)
|
||||
}
|
||||
|
||||
process.exitCode =
|
||||
r.exitCode === 0 && !r.thrown ? 0 : r.thrown ? 1 : r.exitCode
|
||||
return payload
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Awaited<ReturnType<typeof createRunBinHarness>} harness
|
||||
* @param {*} parsed
|
||||
*/
|
||||
async function runInteractive(harness, parsed) {
|
||||
const rl = readline.createInterface({
|
||||
input: stdinStream,
|
||||
output: stdoutStream,
|
||||
terminal: stdinStream.isTTY
|
||||
})
|
||||
stderrStream.write(
|
||||
`${G}run-bin REPL${Reset} — type a command line (empty to exit). cwd PWD=/\n`
|
||||
)
|
||||
try {
|
||||
while (true) {
|
||||
const line = await rl.question(`${Y}run-bin>${Reset} `)
|
||||
const t = line.trim()
|
||||
if (!t) break
|
||||
const parts = tokenizeLine(t)
|
||||
if (parts.length === 0) continue
|
||||
parsed.cmdExplicit = null
|
||||
parsed.positional = parts
|
||||
parsed.stdinOverride = ''
|
||||
await executeOnce(harness, parts, {
|
||||
json: parsed.json,
|
||||
vfsSnapshot: parsed.vfsSnapshot,
|
||||
cmdExplicit: null,
|
||||
stdinOverride: ''
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
rl.close()
|
||||
}
|
||||
}
|
||||
|
||||
/** Minimal quoting: split on spaces, respect single-quoted segments */
|
||||
function tokenizeLine(line) {
|
||||
const out = []
|
||||
let cur = ''
|
||||
let quote = null
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const c = line[i]
|
||||
if (quote === "'") {
|
||||
if (c === "'") quote = null
|
||||
else cur += c
|
||||
continue
|
||||
}
|
||||
if (c === "'") {
|
||||
quote = "'"
|
||||
continue
|
||||
}
|
||||
if (/\s/.test(c)) {
|
||||
if (cur.length) {
|
||||
out.push(cur)
|
||||
cur = ''
|
||||
}
|
||||
continue
|
||||
}
|
||||
cur += c
|
||||
}
|
||||
if (cur.length) out.push(cur)
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Awaited<ReturnType<typeof createRunBinHarness>} harness
|
||||
*/
|
||||
async function runSuiteFile(harness, suiteName, parsed) {
|
||||
const suitePath = path.join(
|
||||
repoRoot,
|
||||
'test/fixtures/coreutils',
|
||||
`${suiteName}.suite.json`
|
||||
)
|
||||
let raw
|
||||
try {
|
||||
raw = JSON.parse(await readFile(suitePath, 'utf8'))
|
||||
} catch (e) {
|
||||
throw new Error(`run-bin: cannot read suite ${suitePath}: ${e}`)
|
||||
}
|
||||
const cases = raw.cases
|
||||
if (!Array.isArray(cases)) {
|
||||
throw new Error(`run-bin: suite ${suitePath} missing cases[]`)
|
||||
}
|
||||
|
||||
/** @type {unknown[]} */
|
||||
const results = []
|
||||
let failed = 0
|
||||
|
||||
for (const c of cases) {
|
||||
const id = String(c.id || '?')
|
||||
const argv = c.argv
|
||||
if (!Array.isArray(argv) || argv.length === 0) {
|
||||
stderrStream.write(`${R}SKIP ${id}: invalid argv${Reset}\n`)
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
if (c.fixture) await harness.applyFixture(String(c.fixture))
|
||||
harness.ctx.shellStdin = typeof c.stdin === 'string' ? c.stdin : ''
|
||||
harness.resetBuffers()
|
||||
harness.ctx.exitCode = 0
|
||||
const r = await harness.run(argv, { shellStdin: harness.ctx.shellStdin })
|
||||
const exp = c.expect || {}
|
||||
const wantEc = exp.exitCode
|
||||
let ok = true
|
||||
/** @type {string[]} */
|
||||
const reasons = []
|
||||
if (r.thrown) {
|
||||
ok = false
|
||||
reasons.push(String(r.thrown.message || r.thrown))
|
||||
}
|
||||
if (wantEc !== undefined && r.exitCode !== wantEc) {
|
||||
ok = false
|
||||
reasons.push(`exitCode want ${wantEc} got ${r.exitCode}`)
|
||||
}
|
||||
if (typeof exp.stdoutIncludes === 'string') {
|
||||
if (!String(r.stdout).includes(exp.stdoutIncludes)) {
|
||||
ok = false
|
||||
reasons.push(`stdout missing substring`)
|
||||
}
|
||||
}
|
||||
if (typeof exp.stderrIncludes === 'string') {
|
||||
if (!String(r.stderr).includes(exp.stderrIncludes)) {
|
||||
ok = false
|
||||
reasons.push(`stderr missing substring`)
|
||||
}
|
||||
}
|
||||
const row = {
|
||||
id,
|
||||
argv,
|
||||
ok,
|
||||
exitCode: r.exitCode,
|
||||
stdout: r.stdout,
|
||||
stderr: r.stderr,
|
||||
durationMs: Math.round(r.durationMs * 1000) / 1000,
|
||||
reasons: ok ? undefined : reasons,
|
||||
error: r.thrown ? r.thrown.message : undefined
|
||||
}
|
||||
results.push(row)
|
||||
if (!ok) failed++
|
||||
if (parsed.json) continue
|
||||
const tag = ok ? `${G}OK${Reset}` : `${R}FAIL${Reset}`
|
||||
stderrStream.write(`${tag} ${id} (${Math.round(r.durationMs)}ms)\n`)
|
||||
if (!ok) {
|
||||
stderrStream.write(` ${reasons.join('; ')}\n`)
|
||||
stderrStream.write(` stdout: ${JSON.stringify(r.stdout)}\n`)
|
||||
}
|
||||
}
|
||||
|
||||
if (parsed.json) {
|
||||
stdoutStream.write(
|
||||
JSON.stringify({ suite: suiteName, failed, cases: results }, null, 2) +
|
||||
'\n'
|
||||
)
|
||||
} else if (failed === 0) {
|
||||
stderrStream.write(`${G}All ${cases.length} case(s) passed.${Reset}\n`)
|
||||
} else {
|
||||
stderrStream.write(`${R}${failed} case(s) failed.${Reset}\n`)
|
||||
}
|
||||
|
||||
process.exitCode = failed > 0 ? 1 : 0
|
||||
}
|
||||
|
||||
async function prepareHarness() {
|
||||
const harness = await createRunBinHarness(repoRoot)
|
||||
try {
|
||||
await harness.seedKernelBins()
|
||||
} catch (e) {
|
||||
await harness.cleanup()
|
||||
throw new Error(
|
||||
`run-bin: kernel/bin missing or unreadable — run: npm run build -w bare-os-coreutils (${e})`
|
||||
)
|
||||
}
|
||||
await harness.seedLayout()
|
||||
return harness
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const parsed = parseArgs(process.argv.slice(2))
|
||||
if (!parsed) return
|
||||
if (parsed.help) {
|
||||
printHelp()
|
||||
return
|
||||
}
|
||||
|
||||
if (parsed.build) await runNpmBuild()
|
||||
|
||||
if (parsed.suite) {
|
||||
const harness = await prepareHarness()
|
||||
try {
|
||||
await runSuiteFile(harness, parsed.suite, parsed)
|
||||
} finally {
|
||||
await harness.cleanup()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const harness = await prepareHarness()
|
||||
|
||||
try {
|
||||
if (parsed.interactive) {
|
||||
await runInteractive(harness, parsed)
|
||||
return
|
||||
}
|
||||
|
||||
const positional = parsed.positional
|
||||
|
||||
if (positional.length === 0 && !parsed.cmdExplicit && !parsed.suite) {
|
||||
stderrStream.write(
|
||||
`${Y}run-bin:${Reset} missing arguments (try --help)\n`
|
||||
)
|
||||
process.exitCode = 2
|
||||
return
|
||||
}
|
||||
|
||||
await executeOnce(harness, positional, {
|
||||
json: parsed.json,
|
||||
vfsSnapshot: parsed.vfsSnapshot,
|
||||
cmdExplicit: parsed.cmdExplicit
|
||||
})
|
||||
|
||||
if (parsed.watch && !parsed.json) {
|
||||
const buildOnWatch = parsed.build
|
||||
const cmd = parsed.cmdExplicit || positional[0] || ''
|
||||
const srcBase = commandSourceFile(cmd)
|
||||
const srcFile = path.join(
|
||||
repoRoot,
|
||||
'packages/bare-os-coreutils/src',
|
||||
`${srcBase}.js`
|
||||
)
|
||||
stderrStream.write(
|
||||
`${Dim}Watching ${srcFile} · re-run uses ${buildOnWatch ? '`npm run build -w bare-os-coreutils`' : 'seed from existing kernel/bin only'}${Reset}\n`
|
||||
)
|
||||
let timer = null
|
||||
watch(srcFile, { persistent: true }, () => {
|
||||
if (timer) clearTimeout(timer)
|
||||
timer = setTimeout(async () => {
|
||||
stderrStream.write(`${Dim}Change detected…${Reset}\n`)
|
||||
try {
|
||||
if (buildOnWatch) {
|
||||
stderrStream.write(
|
||||
`${Dim}npm run build -w bare-os-coreutils…${Reset}\n`
|
||||
)
|
||||
await runNpmBuild()
|
||||
}
|
||||
await harness.seedKernelBins()
|
||||
stderrStream.write(`${Dim}Re-seeded /bin — re-running…${Reset}\n`)
|
||||
await executeOnce(harness, positional, {
|
||||
json: false,
|
||||
vfsSnapshot: parsed.vfsSnapshot,
|
||||
cmdExplicit: parsed.cmdExplicit,
|
||||
stdinOverride: ''
|
||||
})
|
||||
} catch (e) {
|
||||
stderrStream.write(`${R}${e}${Reset}\n`)
|
||||
}
|
||||
}, 180)
|
||||
})
|
||||
await new Promise(() => {})
|
||||
}
|
||||
} finally {
|
||||
if (!parsed.watch) await harness.cleanup()
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
stderrStream.write(`${R}${err.stack || err.message}${Reset}\n`)
|
||||
process.exitCode = 1
|
||||
})
|
||||
Reference in New Issue
Block a user