@@ -0,0 +1,79 @@
|
||||
/** Shared helpers for drive-resident /bin scripts (prepended before each command). */
|
||||
function bareStdin(ctx) {
|
||||
return typeof ctx.shellStdin === 'string' ? ctx.shellStdin : ''
|
||||
}
|
||||
|
||||
async function run(ctx, argv) {
|
||||
const vfs = ctx.vfs
|
||||
const home = vfs.env?.HOME || '/home/guest'
|
||||
const h = String(home).replace(/\/$/, '')
|
||||
const tabPath = `${h}/.crontab`
|
||||
const rest = argv.slice(1)
|
||||
|
||||
if (rest.length === 0) {
|
||||
ctx.console.error('crontab: usage: crontab -l | crontab -r | crontab <file>')
|
||||
ctx.exitCode = 1
|
||||
return
|
||||
}
|
||||
|
||||
if (rest[0] === '-l') {
|
||||
try {
|
||||
const b = await vfs.readFile(tabPath)
|
||||
if (!b || !b.length) {
|
||||
ctx.console.log('no crontab for user')
|
||||
return
|
||||
}
|
||||
const s = ctx.b4a.toString(b)
|
||||
ctx.console.log(s.endsWith('\n') ? s : s + '\n')
|
||||
} catch {
|
||||
ctx.console.log('no crontab for user')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (rest[0] === '-r') {
|
||||
if (ctx.identity?.state !== 'unlocked') {
|
||||
ctx.console.error('crontab: log in to modify crontab')
|
||||
ctx.exitCode = 1
|
||||
return
|
||||
}
|
||||
try {
|
||||
await vfs.unlink(tabPath)
|
||||
} catch (e) {
|
||||
const msg = String(e?.message || e)
|
||||
if (/not found|ENOENT|no entry|missing/i.test(msg)) {
|
||||
ctx.console.error('crontab: no crontab for user')
|
||||
} else {
|
||||
ctx.console.error('crontab: ' + msg)
|
||||
}
|
||||
ctx.exitCode = 1
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (rest[0].startsWith('-')) {
|
||||
ctx.console.error('crontab: unknown option ' + rest[0])
|
||||
ctx.exitCode = 1
|
||||
return
|
||||
}
|
||||
|
||||
if (ctx.identity?.state !== 'unlocked') {
|
||||
ctx.console.error('crontab: log in to install crontab')
|
||||
ctx.exitCode = 1
|
||||
return
|
||||
}
|
||||
|
||||
const file = rest[0]
|
||||
try {
|
||||
const b = await vfs.readFile(file)
|
||||
if (b == null) {
|
||||
ctx.console.error('crontab: ' + file + ': cannot read')
|
||||
ctx.exitCode = 1
|
||||
return
|
||||
}
|
||||
await vfs.writeFile(tabPath, b)
|
||||
} catch (e) {
|
||||
ctx.console.error('crontab: ' + (e.message || e))
|
||||
ctx.exitCode = 1
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -5,7 +5,7 @@ function bareStdin(ctx) {
|
||||
|
||||
async function run(ctx, argv) {
|
||||
ctx.console.log(
|
||||
'Bare OS — default user: guest | builtins: cd, export, exit, login, logout | /bin: basename cat clear date dirname echo env exit false head hdms help hostname id login logout ls nl pathchk printenv pwd rm savevault seq sleep sort tail test touch true tty uname wc which whoami'
|
||||
'Bare OS — default user: guest | builtins: cd, export, exit, login, logout | /bin: basename cat clear crontab date dirname echo env exit false head hdms help hostname id login logout ls nl pathchk printenv pwd rm savevault seq sleep sort tail test touch true tty uname wc which whoami'
|
||||
)
|
||||
ctx.console.log(
|
||||
'Identity: login [--new] <passphrase> | logout [--save] | savevault (encrypt copy of personal drive under /.bare/vault/)'
|
||||
|
||||
+2
-1
@@ -36,7 +36,8 @@ async function run(ctx, argv) {
|
||||
ctx.console.error('ls: cannot access ' + t + ': ' + (e.message || e))
|
||||
continue
|
||||
}
|
||||
if (!showAll) names = names.filter((n) => n !== '.' && n !== '..')
|
||||
// POSIX: hide dotfiles unless -a (. and .. are dot-prefixed too).
|
||||
if (!showAll) names = names.filter((n) => !n.startsWith('.'))
|
||||
if (!longFmt) {
|
||||
ctx.console.log(names.join(' '))
|
||||
} else {
|
||||
|
||||
+7
-2
@@ -7,14 +7,19 @@ async function start(ctx) {
|
||||
const rel = await drive.get('/etc/os-release')
|
||||
if (rel) console.log(b4a.toString(rel))
|
||||
console.log(
|
||||
'Bare operating system — session: guest (login [--new] <passphrase> to unlock identity) | shell: cd, export, exit, login, logout | try: help, ls /bin, pwd'
|
||||
'Bare operating system — session: guest (login [--new] <passphrase> to unlock identity) | shell: cd, export, exit, login, logout | try: help, ls /bin, pwd, crontab -l'
|
||||
)
|
||||
while (true) {
|
||||
const line = await readLine('')
|
||||
if (line == null) break
|
||||
const t = line.trim()
|
||||
if (t === '') continue
|
||||
const status = await execLine(t)
|
||||
let status = 'ok'
|
||||
try {
|
||||
status = await execLine(t)
|
||||
} catch (e) {
|
||||
console.error((e && e.message) || String(e))
|
||||
}
|
||||
if (status === 'exit') break
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,8 @@ import {
|
||||
applyLoginKeys
|
||||
} from './lib/identity-session.js'
|
||||
import { HdmsController, runHdmsCli } from './lib/hdms-manager.js'
|
||||
import { startBareInitd } from './lib/bare-initd.js'
|
||||
import './lib/bare-cron.js'
|
||||
|
||||
const _pkg = packageRootDir(import.meta.url)
|
||||
|
||||
@@ -336,6 +338,8 @@ async function executeKernel(disk, store, swarm, initSource) {
|
||||
ctx.writeScreen = session.writeScreen
|
||||
ctx.console = session.console
|
||||
|
||||
await startBareInitd(ctx)
|
||||
|
||||
disk.os = {
|
||||
async searchLocal() {
|
||||
return []
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
/**
|
||||
* User crontab at ~/.crontab — five fields (minute hour dom month dow) + command.
|
||||
* Registered as bare-initd service `bare-cron`.
|
||||
*/
|
||||
|
||||
import { registerBareInitdDisposer, registerBareService } from './bare-initd.js'
|
||||
|
||||
/** @param {Date} d */
|
||||
function msToNextMinuteBoundary(d = new Date()) {
|
||||
return 60000 - (d.getSeconds() * 1000 + d.getMilliseconds())
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} spec
|
||||
* @param {number} value
|
||||
* @param {number} min
|
||||
* @param {number} max
|
||||
*/
|
||||
export function fieldMatches(spec, value, min, max) {
|
||||
const s = spec.trim()
|
||||
if (!s) return false
|
||||
for (const part of s.split(',')) {
|
||||
const p = part.trim()
|
||||
if (!p) continue
|
||||
if (partMatches(p, value, min, max)) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} p
|
||||
* @param {number} value
|
||||
* @param {number} min
|
||||
* @param {number} max
|
||||
*/
|
||||
function partMatches(p, value, min, max) {
|
||||
if (p === '*') return true
|
||||
const slash = p.indexOf('/')
|
||||
if (slash !== -1) {
|
||||
const range = p.slice(0, slash)
|
||||
const step = Number.parseInt(p.slice(slash + 1), 10)
|
||||
if (!Number.isFinite(step) || step < 1) return false
|
||||
let lo
|
||||
let hi
|
||||
if (range === '*') {
|
||||
lo = min
|
||||
hi = max
|
||||
} else if (range.includes('-')) {
|
||||
const [a, b] = range.split('-').map((x) => Number.parseInt(x.trim(), 10))
|
||||
lo = Math.min(a, b)
|
||||
hi = Math.max(a, b)
|
||||
} else {
|
||||
lo = hi = Number.parseInt(range, 10)
|
||||
}
|
||||
if (!Number.isFinite(lo) || !Number.isFinite(hi)) return false
|
||||
return value >= lo && value <= hi && (value - lo) % step === 0
|
||||
}
|
||||
if (p.includes('-')) {
|
||||
const [a, b] = p.split('-').map((x) => Number.parseInt(x.trim(), 10))
|
||||
const lo = Math.min(a, b)
|
||||
const hi = Math.max(a, b)
|
||||
return value >= lo && value <= hi
|
||||
}
|
||||
const n = Number.parseInt(p, 10)
|
||||
return Number.isFinite(n) && value === n
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} spec
|
||||
* @param {number} jsDow 0=Sun … 6=Sat (Date#getDay)
|
||||
*/
|
||||
export function dowFieldMatches(spec, jsDow) {
|
||||
const s = spec.trim()
|
||||
if (!s) return false
|
||||
for (const part of s.split(',')) {
|
||||
const p = part.trim()
|
||||
if (!p) continue
|
||||
if (dowPartMatches(p, jsDow)) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} p
|
||||
* @param {number} jsDow
|
||||
*/
|
||||
function dowPartMatches(p, jsDow) {
|
||||
if (p === '*') return true
|
||||
const slash = p.indexOf('/')
|
||||
if (slash !== -1) {
|
||||
const range = p.slice(0, slash)
|
||||
const step = Number.parseInt(p.slice(slash + 1), 10)
|
||||
if (!Number.isFinite(step) || step < 1) return false
|
||||
if (range === '*') {
|
||||
for (let js = 0; js <= 6; js += step) {
|
||||
if (jsDow === js) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
if (range.includes('-')) {
|
||||
const [a, b] = range.split('-').map((x) => Number.parseInt(x.trim(), 10))
|
||||
const lo = Math.min(a, b)
|
||||
const hi = Math.max(a, b)
|
||||
for (let k = lo; k <= hi; k++) {
|
||||
if ((k - lo) % step !== 0) continue
|
||||
const js = k === 7 ? 0 : k
|
||||
if (js >= 0 && js <= 6 && jsDow === js) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
const k = Number.parseInt(range.trim(), 10)
|
||||
if (!Number.isFinite(k)) return false
|
||||
const js = k === 7 ? 0 : k
|
||||
return js >= 0 && js <= 6 && jsDow === js
|
||||
}
|
||||
if (p.includes('-')) {
|
||||
const [a, b] = p.split('-').map((x) => Number.parseInt(x.trim(), 10))
|
||||
const lo = Math.min(a, b)
|
||||
const hi = Math.max(a, b)
|
||||
for (let k = lo; k <= hi; k++) {
|
||||
const js = k === 7 ? 0 : k
|
||||
if (js === jsDow) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
const n = Number.parseInt(p, 10)
|
||||
if (!Number.isFinite(n)) return false
|
||||
if (n === 7) return jsDow === 0
|
||||
return jsDow === n
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} line
|
||||
* @returns {{ minute: string, hour: string, dom: string, month: string, dow: string, command: string } | null}
|
||||
*/
|
||||
export function parseCronLine(line) {
|
||||
const t = line.trim()
|
||||
if (!t || t.startsWith('#')) return null
|
||||
const parts = t.split(/\s+/)
|
||||
if (parts.length < 6) return null
|
||||
const [minute, hour, dom, month, dow, ...rest] = parts
|
||||
const command = rest.join(' ')
|
||||
if (!command) return null
|
||||
return { minute, hour, dom, month, dow, command }
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ minute: string, hour: string, dom: string, month: string, dow: string }} job
|
||||
* @param {Date} date
|
||||
*/
|
||||
export function jobMatchesDate(job, date) {
|
||||
return (
|
||||
fieldMatches(job.minute, date.getMinutes(), 0, 59) &&
|
||||
fieldMatches(job.hour, date.getHours(), 0, 23) &&
|
||||
fieldMatches(job.dom, date.getDate(), 1, 31) &&
|
||||
fieldMatches(job.month, date.getMonth() + 1, 1, 12) &&
|
||||
dowFieldMatches(job.dow, date.getDay())
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown>} ctx
|
||||
*/
|
||||
async function readUserCrontabText(ctx) {
|
||||
const vfs = ctx.vfs
|
||||
if (!vfs || typeof vfs.readFile !== 'function') return ''
|
||||
const home = vfs.env?.HOME || '/home/guest'
|
||||
const h = String(home).replace(/\/$/, '')
|
||||
const path = `${h}/.crontab`
|
||||
try {
|
||||
const b = await vfs.readFile(path)
|
||||
if (!b) return ''
|
||||
return ctx.b4a.toString(b)
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown>} ctx
|
||||
*/
|
||||
function startBareCron(ctx) {
|
||||
/** @type {Set<number>} */
|
||||
const running = new Set()
|
||||
let timeoutId = null
|
||||
let intervalId = null
|
||||
|
||||
async function tick() {
|
||||
let text
|
||||
try {
|
||||
text = await readUserCrontabText(ctx)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
if (!text.trim()) return
|
||||
|
||||
const lines = text.split(/\r?\n/)
|
||||
/** @type {{ lineIndex: number, minute: string, hour: string, dom: string, month: string, dow: string, command: string }[]} */
|
||||
const jobs = []
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const job = parseCronLine(lines[i])
|
||||
if (job) jobs.push({ lineIndex: i, ...job })
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
for (const job of jobs) {
|
||||
if (!jobMatchesDate(job, now)) continue
|
||||
const key = job.lineIndex
|
||||
if (running.has(key)) continue
|
||||
running.add(key)
|
||||
void (async () => {
|
||||
try {
|
||||
const execLine = ctx.execLine
|
||||
if (typeof execLine !== 'function') return
|
||||
await execLine(job.command)
|
||||
} catch (e) {
|
||||
const msg = e?.message || String(e)
|
||||
try {
|
||||
ctx.console?.error?.(`[bare-cron] ${msg}`)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
} finally {
|
||||
running.delete(key)
|
||||
}
|
||||
})()
|
||||
}
|
||||
}
|
||||
|
||||
registerBareInitdDisposer(() => {
|
||||
if (timeoutId != null) clearTimeout(timeoutId)
|
||||
if (intervalId != null) clearInterval(intervalId)
|
||||
timeoutId = null
|
||||
intervalId = null
|
||||
})
|
||||
|
||||
const delay = msToNextMinuteBoundary()
|
||||
timeoutId = setTimeout(() => {
|
||||
timeoutId = null
|
||||
void tick()
|
||||
intervalId = setInterval(() => {
|
||||
void tick()
|
||||
}, 60000)
|
||||
}, delay)
|
||||
}
|
||||
|
||||
registerBareService({
|
||||
name: 'bare-cron',
|
||||
start: startBareCron
|
||||
})
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* Bare init daemon — lightweight service supervisor (systemd-like registration).
|
||||
* Services start after the session console exists; failures are logged, not fatal.
|
||||
*/
|
||||
|
||||
/** @typedef {{ name: string, start: (ctx: Record<string, unknown>) => void | Promise<void> }} BareService */
|
||||
|
||||
/** @type {BareService[]} */
|
||||
const registry = []
|
||||
|
||||
/** @type {(() => void)[]} */
|
||||
const disposers = []
|
||||
|
||||
/**
|
||||
* Register cleanup (e.g. clearInterval) for when the kernel session ends.
|
||||
* @param {() => void} fn
|
||||
*/
|
||||
export function registerBareInitdDisposer(fn) {
|
||||
if (typeof fn === 'function') disposers.push(fn)
|
||||
}
|
||||
|
||||
export function stopBareInitd() {
|
||||
while (disposers.length) {
|
||||
const fn = disposers.pop()
|
||||
try {
|
||||
fn()
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {BareService} service
|
||||
*/
|
||||
export function registerBareService(service) {
|
||||
if (!service?.name || typeof service.start !== 'function') return
|
||||
registry.push(service)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown>} ctx
|
||||
*/
|
||||
export async function startBareInitd(ctx) {
|
||||
for (const s of registry) {
|
||||
try {
|
||||
await s.start(ctx)
|
||||
} catch (e) {
|
||||
const msg = e?.message || String(e)
|
||||
try {
|
||||
ctx.console?.error?.(`[bare-initd] ${s.name}: ${msg}`)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const KERNEL_LOG_REL = '.kernel/kernel.log'
|
||||
|
||||
/**
|
||||
* Append one UTF-8 line to ~/.kernel/kernel.log (personal drive). Best-effort; never throws.
|
||||
* @param {Record<string, unknown>} ctx
|
||||
* @param {string} kind
|
||||
* @param {string} line
|
||||
*/
|
||||
async function appendKernelLog(ctx, kind, line) {
|
||||
try {
|
||||
const vfs = ctx.vfs
|
||||
if (!vfs || typeof vfs.readFile !== 'function') return
|
||||
const home = vfs.env?.HOME || '/home/guest'
|
||||
const h = home.replace(/\/$/, '')
|
||||
const keepPath = `${h}/.kernel/.keep`
|
||||
const logPath = `${h}/${KERNEL_LOG_REL}`
|
||||
try {
|
||||
await vfs.writeFile(keepPath, ctx.b4a.from(''))
|
||||
} catch {
|
||||
/* exists */
|
||||
}
|
||||
const prev = await vfs.readFile(logPath)
|
||||
const ts = new Date().toISOString()
|
||||
const chunk = ctx.b4a.from(`[${ts}] [${kind}] ${line}\n`)
|
||||
const merged = prev ? ctx.b4a.concat([prev, chunk]) : chunk
|
||||
await vfs.writeFile(logPath, merged)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
function registerKernelLoggerService() {
|
||||
registerBareService({
|
||||
name: 'kernel-logger',
|
||||
start(ctx) {
|
||||
const c = ctx.console
|
||||
if (!c || typeof c.log !== 'function') return
|
||||
|
||||
const origLog = c.log.bind(c)
|
||||
const origErr = typeof c.error === 'function' ? c.error.bind(c) : origLog
|
||||
|
||||
c.log = (...args) => {
|
||||
const text = args.map(String).join(' ')
|
||||
void appendKernelLog(ctx, 'log', text)
|
||||
return origLog(...args)
|
||||
}
|
||||
c.error = (...args) => {
|
||||
const text = args.map(String).join(' ')
|
||||
void appendKernelLog(ctx, 'error', text)
|
||||
return origErr(...args)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
registerKernelLoggerService()
|
||||
@@ -3,6 +3,13 @@ import unixPathResolve from 'unix-path-resolve'
|
||||
|
||||
const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor
|
||||
|
||||
/** Strip one leading Unix shebang so AsyncFunction does not see `#!` as invalid syntax. */
|
||||
function stripShebang(source) {
|
||||
if (typeof source !== 'string' || !source.startsWith('#!')) return source
|
||||
const m = source.match(/^#![^\n]*\n/)
|
||||
return m ? source.slice(m[0].length) : source
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute kernel source from Hyperdrive (trusted). Expects top-level `async function start(ctx)`.
|
||||
* @param {string} source
|
||||
@@ -23,16 +30,31 @@ export async function runKernelFromSource(source, ctx) {
|
||||
* @param {string} [label]
|
||||
*/
|
||||
async function runScriptFromSource(ctx, src, argv, label = argv[0]) {
|
||||
try {
|
||||
const body = stripShebang(src)
|
||||
const fn = new AsyncFunction(
|
||||
'ctx',
|
||||
'argv',
|
||||
`${src}\nif (typeof run !== 'function') throw new Error('missing run() in ${label}')\nreturn run(ctx, argv)\n`
|
||||
`${body}\nif (typeof run !== 'function') throw new Error('missing run() in ${label}')\nreturn run(ctx, argv)\n`
|
||||
)
|
||||
return fn(ctx, argv)
|
||||
return await fn(ctx, argv)
|
||||
} catch (e) {
|
||||
const msg = e?.message || String(e)
|
||||
const stack = e?.stack
|
||||
if (typeof ctx.console?.error === 'function') {
|
||||
ctx.console.error(msg)
|
||||
if (stack && typeof stack === 'string' && stack !== msg) {
|
||||
const lines = stack.split('\n').slice(1, 4)
|
||||
for (const line of lines) {
|
||||
if (line && line.trim()) ctx.console.error(line.trim())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a command: PATH on system drive, or path script on routed drive.
|
||||
* Run a command: bare `*.js` in cwd (before PATH), path script on any routed drive, then PATH on system.
|
||||
* @param {Record<string, unknown>} ctx
|
||||
* @param {string[]} argv
|
||||
*/
|
||||
@@ -54,6 +76,17 @@ export async function runBinCommand(ctx, argv) {
|
||||
return runScriptFromSource(ctx, source, argv, cmd)
|
||||
}
|
||||
|
||||
// `script.js` in $PWD before PATH (same VFS routing as `./script.js`).
|
||||
if (cmd.endsWith('.js')) {
|
||||
const abs = vfs.resolveLogical(cmd)
|
||||
const { drive, path } = vfs.route(abs)
|
||||
const buf = await drive.get(path, { follow: true })
|
||||
if (buf) {
|
||||
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)
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
replDbg,
|
||||
unbindReplDebugStream
|
||||
} from './debug-repl.js'
|
||||
import { stopBareInitd } from './bare-initd.js'
|
||||
|
||||
/**
|
||||
* Kernel `console` must write to the same stream as the line editor so cursor stays in sync.
|
||||
@@ -163,6 +164,7 @@ export async function createKernelReplSession({
|
||||
|
||||
function cleanup() {
|
||||
if (isReplDebug()) replDbg('repl', 'cleanup', fishRead ? 'fish teardown' : 'noop')
|
||||
stopBareInitd()
|
||||
if (fishRead && stdin) {
|
||||
disableFishRawMode(stdin)
|
||||
releaseFishStdin(stdin)
|
||||
|
||||
@@ -327,7 +327,11 @@ export async function execShellLine(ctx, line) {
|
||||
stdinText != null
|
||||
? Object.assign({}, ctx, { shellStdin: stdinText, env })
|
||||
: Object.assign({}, ctx, { env })
|
||||
try {
|
||||
await runBinCommand(childCtx, argv)
|
||||
} catch (e) {
|
||||
origErr.call(ctx.console, (e && e.message) || String(e))
|
||||
}
|
||||
if (name === '/bin/exit' || name.endsWith('/exit')) {
|
||||
code = 'exit'
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import b4a from 'b4a'
|
||||
import Hyperdrive from 'hyperdrive'
|
||||
import Corestore from 'corestore'
|
||||
import { mkdirSync, rmSync } from 'node:fs'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { PassThrough } from 'node:stream'
|
||||
@@ -18,6 +19,16 @@ import {
|
||||
dedupeConsecutiveHistory,
|
||||
searchHistoryEntries
|
||||
} from './lib/fish-readline.js'
|
||||
import {
|
||||
fieldMatches,
|
||||
dowFieldMatches,
|
||||
parseCronLine,
|
||||
jobMatchesDate
|
||||
} from './lib/bare-cron.js'
|
||||
import {
|
||||
registerBareInitdDisposer,
|
||||
stopBareInitd
|
||||
} from './lib/bare-initd.js'
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
function testCorestoreDir(name) {
|
||||
@@ -84,6 +95,108 @@ test('runBinCommand runs /bin helper', async (t) => {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
test('runBinCommand runs bare hello.js from cwd on personal drive', async (t) => {
|
||||
const dir = testCorestoreDir('barejs')
|
||||
const store = new Corestore(dir)
|
||||
const drive = new Hyperdrive(store)
|
||||
const personal = new Hyperdrive(store.namespace('pj'))
|
||||
await drive.ready()
|
||||
await personal.ready()
|
||||
await personal.put(
|
||||
'/hello.js',
|
||||
b4a.from(`
|
||||
async function run(ctx, argv) {
|
||||
ctx.out.push(argv.join(' '))
|
||||
}
|
||||
`)
|
||||
)
|
||||
const out = []
|
||||
const ctx = testCtx(drive, personal)
|
||||
ctx.out = out
|
||||
await runBinCommand(ctx, ['hello.js', 'x', 'y'])
|
||||
t.is(out[0], 'hello.js x y')
|
||||
await store.close()
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
test('runBinCommand strips shebang from user script', async (t) => {
|
||||
const dir = testCorestoreDir('shebang')
|
||||
const store = new Corestore(dir)
|
||||
const drive = new Hyperdrive(store)
|
||||
const personal = new Hyperdrive(store.namespace('psh'))
|
||||
await drive.ready()
|
||||
await personal.ready()
|
||||
await personal.put(
|
||||
'/x.js',
|
||||
b4a.from(`#!/usr/bin/env bare
|
||||
async function run(ctx) { ctx.out.push('ok') }
|
||||
`)
|
||||
)
|
||||
const out = []
|
||||
const ctx = testCtx(drive, personal)
|
||||
ctx.out = out
|
||||
await runBinCommand(ctx, ['./x.js'])
|
||||
t.is(out[0], 'ok')
|
||||
await store.close()
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
test('runBinCommand user script error is caught and logged', async (t) => {
|
||||
const dir = testCorestoreDir('throwjs')
|
||||
const store = new Corestore(dir)
|
||||
const drive = new Hyperdrive(store)
|
||||
const personal = new Hyperdrive(store.namespace('ptj'))
|
||||
await drive.ready()
|
||||
await personal.ready()
|
||||
await personal.put(
|
||||
'/bad.js',
|
||||
b4a.from(`async function run() { test() }`)
|
||||
)
|
||||
const errs = []
|
||||
const ctx = testCtx(drive, personal)
|
||||
ctx.console = {
|
||||
log: () => {},
|
||||
error: (...a) => {
|
||||
errs.push(a.join(' '))
|
||||
}
|
||||
}
|
||||
await runBinCommand(ctx, ['./bad.js'])
|
||||
t.ok(errs.length > 0)
|
||||
t.ok(errs.some((e) => /test|not defined|ReferenceError/i.test(e)))
|
||||
await store.close()
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
test('ls hides dotfiles unless -a', async (t) => {
|
||||
const lsPath = path.join(__dirname, '../../kernel/bin/ls')
|
||||
const lsSrc = await readFile(lsPath, 'utf8')
|
||||
const dir = testCorestoreDir('lsdot')
|
||||
const store = new Corestore(dir)
|
||||
const drive = new Hyperdrive(store)
|
||||
const personal = new Hyperdrive(store.namespace('pls'))
|
||||
await drive.ready()
|
||||
await personal.ready()
|
||||
await drive.put('/bin/ls', b4a.from(lsSrc))
|
||||
await personal.put('/shown.txt', b4a.from(''))
|
||||
await personal.put('/.hidden', b4a.from(''))
|
||||
const lines = []
|
||||
const ctx = testCtx(drive, personal)
|
||||
ctx.console = {
|
||||
log: (s) => lines.push(String(s)),
|
||||
error: (...a) => lines.push(a.join(' '))
|
||||
}
|
||||
await runBinCommand(ctx, ['ls'])
|
||||
const flat = lines.join('\n')
|
||||
t.ok(flat.includes('shown.txt'))
|
||||
t.ok(!flat.includes('hidden'))
|
||||
lines.length = 0
|
||||
await runBinCommand(ctx, ['ls', '-a'])
|
||||
const flatA = lines.join('\n')
|
||||
t.ok(flatA.includes('hidden'))
|
||||
await store.close()
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
test('createStreamLineReader yields lines after newline', async (t) => {
|
||||
const stdin = new PassThrough()
|
||||
const chunks = []
|
||||
@@ -260,6 +373,45 @@ async function run(ctx, argv) {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
test('cron fieldMatches and dowFieldMatches', async (t) => {
|
||||
t.ok(fieldMatches('*', 0, 0, 59))
|
||||
t.ok(fieldMatches('*/5', 10, 0, 59))
|
||||
t.ok(!fieldMatches('*/5', 11, 0, 59))
|
||||
t.ok(fieldMatches('1-3', 2, 0, 59))
|
||||
t.ok(!fieldMatches('1-3', 4, 0, 59))
|
||||
t.ok(fieldMatches('1,4', 1, 0, 59))
|
||||
t.ok(fieldMatches('1,4', 4, 0, 59))
|
||||
t.ok(fieldMatches('1-10/2', 3, 0, 59))
|
||||
t.ok(!fieldMatches('1-10/2', 4, 0, 59))
|
||||
t.ok(dowFieldMatches('7', 0))
|
||||
t.ok(!dowFieldMatches('7', 1))
|
||||
t.ok(dowFieldMatches('0', 0))
|
||||
t.ok(dowFieldMatches('1-5', 3))
|
||||
})
|
||||
|
||||
test('cron parseCronLine and jobMatchesDate', async (t) => {
|
||||
t.absent(parseCronLine(''))
|
||||
t.absent(parseCronLine('# comment'))
|
||||
t.absent(parseCronLine('0 0 * *'))
|
||||
const j = parseCronLine('30 14 15 6 * echo hello world')
|
||||
t.ok(j)
|
||||
t.is(j.command, 'echo hello world')
|
||||
const when = new Date(2020, 5, 15, 14, 30, 0)
|
||||
t.ok(jobMatchesDate(j, when))
|
||||
t.ok(!jobMatchesDate(j, new Date(2020, 5, 15, 14, 31, 0)))
|
||||
})
|
||||
|
||||
test('stopBareInitd runs registered disposers', async (t) => {
|
||||
let n = 0
|
||||
registerBareInitdDisposer(() => {
|
||||
n++
|
||||
})
|
||||
stopBareInitd()
|
||||
t.is(n, 1)
|
||||
stopBareInitd()
|
||||
t.is(n, 1)
|
||||
})
|
||||
|
||||
test('fish-readline stripAnsi and fuzzyMatch', async (t) => {
|
||||
t.is(stripAnsi('\x1b[32mhi\x1b[0m'), 'hi')
|
||||
t.ok(fuzzyMatch('hello', 'hlo'))
|
||||
|
||||
@@ -11,6 +11,7 @@ const commands = [
|
||||
'basename',
|
||||
'cat',
|
||||
'clear',
|
||||
'crontab',
|
||||
'date',
|
||||
'dirname',
|
||||
'echo',
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
async function run(ctx, argv) {
|
||||
const vfs = ctx.vfs
|
||||
const home = vfs.env?.HOME || '/home/guest'
|
||||
const h = String(home).replace(/\/$/, '')
|
||||
const tabPath = `${h}/.crontab`
|
||||
const rest = argv.slice(1)
|
||||
|
||||
if (rest.length === 0) {
|
||||
ctx.console.error('crontab: usage: crontab -l | crontab -r | crontab <file>')
|
||||
ctx.exitCode = 1
|
||||
return
|
||||
}
|
||||
|
||||
if (rest[0] === '-l') {
|
||||
try {
|
||||
const b = await vfs.readFile(tabPath)
|
||||
if (!b || !b.length) {
|
||||
ctx.console.log('no crontab for user')
|
||||
return
|
||||
}
|
||||
const s = ctx.b4a.toString(b)
|
||||
ctx.console.log(s.endsWith('\n') ? s : s + '\n')
|
||||
} catch {
|
||||
ctx.console.log('no crontab for user')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (rest[0] === '-r') {
|
||||
if (ctx.identity?.state !== 'unlocked') {
|
||||
ctx.console.error('crontab: log in to modify crontab')
|
||||
ctx.exitCode = 1
|
||||
return
|
||||
}
|
||||
try {
|
||||
await vfs.unlink(tabPath)
|
||||
} catch (e) {
|
||||
const msg = String(e?.message || e)
|
||||
if (/not found|ENOENT|no entry|missing/i.test(msg)) {
|
||||
ctx.console.error('crontab: no crontab for user')
|
||||
} else {
|
||||
ctx.console.error('crontab: ' + msg)
|
||||
}
|
||||
ctx.exitCode = 1
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (rest[0].startsWith('-')) {
|
||||
ctx.console.error('crontab: unknown option ' + rest[0])
|
||||
ctx.exitCode = 1
|
||||
return
|
||||
}
|
||||
|
||||
if (ctx.identity?.state !== 'unlocked') {
|
||||
ctx.console.error('crontab: log in to install crontab')
|
||||
ctx.exitCode = 1
|
||||
return
|
||||
}
|
||||
|
||||
const file = rest[0]
|
||||
try {
|
||||
const b = await vfs.readFile(file)
|
||||
if (b == null) {
|
||||
ctx.console.error('crontab: ' + file + ': cannot read')
|
||||
ctx.exitCode = 1
|
||||
return
|
||||
}
|
||||
await vfs.writeFile(tabPath, b)
|
||||
} catch (e) {
|
||||
ctx.console.error('crontab: ' + (e.message || e))
|
||||
ctx.exitCode = 1
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
async function run(ctx, argv) {
|
||||
ctx.console.log(
|
||||
'Bare OS — default user: guest | builtins: cd, export, exit, login, logout | /bin: basename cat clear date dirname echo env exit false head hdms help hostname id login logout ls nl pathchk printenv pwd rm savevault seq sleep sort tail test touch true tty uname wc which whoami'
|
||||
'Bare OS — default user: guest | builtins: cd, export, exit, login, logout | /bin: basename cat clear crontab date dirname echo env exit false head hdms help hostname id login logout ls nl pathchk printenv pwd rm savevault seq sleep sort tail test touch true tty uname wc which whoami'
|
||||
)
|
||||
ctx.console.log(
|
||||
'Identity: login [--new] <passphrase> | logout [--save] | savevault (encrypt copy of personal drive under /.bare/vault/)'
|
||||
|
||||
@@ -31,7 +31,8 @@ async function run(ctx, argv) {
|
||||
ctx.console.error('ls: cannot access ' + t + ': ' + (e.message || e))
|
||||
continue
|
||||
}
|
||||
if (!showAll) names = names.filter((n) => n !== '.' && n !== '..')
|
||||
// POSIX: hide dotfiles unless -a (. and .. are dot-prefixed too).
|
||||
if (!showAll) names = names.filter((n) => !n.startsWith('.'))
|
||||
if (!longFmt) {
|
||||
ctx.console.log(names.join(' '))
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
/** Shared helpers for drive-resident /bin scripts (prepended before each command). */
|
||||
function bareStdin(ctx) {
|
||||
return typeof ctx.shellStdin === 'string' ? ctx.shellStdin : ''
|
||||
}
|
||||
|
||||
async function run(ctx, argv) {
|
||||
const vfs = ctx.vfs
|
||||
const home = vfs.env?.HOME || '/home/guest'
|
||||
const h = String(home).replace(/\/$/, '')
|
||||
const tabPath = `${h}/.crontab`
|
||||
const rest = argv.slice(1)
|
||||
|
||||
if (rest.length === 0) {
|
||||
ctx.console.error('crontab: usage: crontab -l | crontab -r | crontab <file>')
|
||||
ctx.exitCode = 1
|
||||
return
|
||||
}
|
||||
|
||||
if (rest[0] === '-l') {
|
||||
try {
|
||||
const b = await vfs.readFile(tabPath)
|
||||
if (!b || !b.length) {
|
||||
ctx.console.log('no crontab for user')
|
||||
return
|
||||
}
|
||||
const s = ctx.b4a.toString(b)
|
||||
ctx.console.log(s.endsWith('\n') ? s : s + '\n')
|
||||
} catch {
|
||||
ctx.console.log('no crontab for user')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (rest[0] === '-r') {
|
||||
if (ctx.identity?.state !== 'unlocked') {
|
||||
ctx.console.error('crontab: log in to modify crontab')
|
||||
ctx.exitCode = 1
|
||||
return
|
||||
}
|
||||
try {
|
||||
await vfs.unlink(tabPath)
|
||||
} catch (e) {
|
||||
const msg = String(e?.message || e)
|
||||
if (/not found|ENOENT|no entry|missing/i.test(msg)) {
|
||||
ctx.console.error('crontab: no crontab for user')
|
||||
} else {
|
||||
ctx.console.error('crontab: ' + msg)
|
||||
}
|
||||
ctx.exitCode = 1
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (rest[0].startsWith('-')) {
|
||||
ctx.console.error('crontab: unknown option ' + rest[0])
|
||||
ctx.exitCode = 1
|
||||
return
|
||||
}
|
||||
|
||||
if (ctx.identity?.state !== 'unlocked') {
|
||||
ctx.console.error('crontab: log in to install crontab')
|
||||
ctx.exitCode = 1
|
||||
return
|
||||
}
|
||||
|
||||
const file = rest[0]
|
||||
try {
|
||||
const b = await vfs.readFile(file)
|
||||
if (b == null) {
|
||||
ctx.console.error('crontab: ' + file + ': cannot read')
|
||||
ctx.exitCode = 1
|
||||
return
|
||||
}
|
||||
await vfs.writeFile(tabPath, b)
|
||||
} catch (e) {
|
||||
ctx.console.error('crontab: ' + (e.message || e))
|
||||
ctx.exitCode = 1
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ function bareStdin(ctx) {
|
||||
|
||||
async function run(ctx, argv) {
|
||||
ctx.console.log(
|
||||
'Bare OS — default user: guest | builtins: cd, export, exit, login, logout | /bin: basename cat clear date dirname echo env exit false head hdms help hostname id login logout ls nl pathchk printenv pwd rm savevault seq sleep sort tail test touch true tty uname wc which whoami'
|
||||
'Bare OS — default user: guest | builtins: cd, export, exit, login, logout | /bin: basename cat clear crontab date dirname echo env exit false head hdms help hostname id login logout ls nl pathchk printenv pwd rm savevault seq sleep sort tail test touch true tty uname wc which whoami'
|
||||
)
|
||||
ctx.console.log(
|
||||
'Identity: login [--new] <passphrase> | logout [--save] | savevault (encrypt copy of personal drive under /.bare/vault/)'
|
||||
|
||||
@@ -36,7 +36,8 @@ async function run(ctx, argv) {
|
||||
ctx.console.error('ls: cannot access ' + t + ': ' + (e.message || e))
|
||||
continue
|
||||
}
|
||||
if (!showAll) names = names.filter((n) => n !== '.' && n !== '..')
|
||||
// POSIX: hide dotfiles unless -a (. and .. are dot-prefixed too).
|
||||
if (!showAll) names = names.filter((n) => !n.startsWith('.'))
|
||||
if (!longFmt) {
|
||||
ctx.console.log(names.join(' '))
|
||||
} else {
|
||||
|
||||
@@ -7,14 +7,19 @@ async function start(ctx) {
|
||||
const rel = await drive.get('/etc/os-release')
|
||||
if (rel) console.log(b4a.toString(rel))
|
||||
console.log(
|
||||
'Bare operating system — session: guest (login [--new] <passphrase> to unlock identity) | shell: cd, export, exit, login, logout | try: help, ls /bin, pwd'
|
||||
'Bare operating system — session: guest (login [--new] <passphrase> to unlock identity) | shell: cd, export, exit, login, logout | try: help, ls /bin, pwd, crontab -l'
|
||||
)
|
||||
while (true) {
|
||||
const line = await readLine('')
|
||||
if (line == null) break
|
||||
const t = line.trim()
|
||||
if (t === '') continue
|
||||
const status = await execLine(t)
|
||||
let status = 'ok'
|
||||
try {
|
||||
status = await execLine(t)
|
||||
} catch (e) {
|
||||
console.error((e && e.message) || String(e))
|
||||
}
|
||||
if (status === 'exit') break
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user