update
CI / test (push) Has been cancelled

This commit is contained in:
Raven Scott
2026-04-03 03:07:51 -04:00
parent 342b6040fe
commit d794c87b32
19 changed files with 823 additions and 18 deletions
+79
View File
@@ -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
View File
@@ -5,7 +5,7 @@ function bareStdin(ctx) {
async function run(ctx, argv) { async function run(ctx, argv) {
ctx.console.log( 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( ctx.console.log(
'Identity: login [--new] <passphrase> | logout [--save] | savevault (encrypt copy of personal drive under /.bare/vault/)' 'Identity: login [--new] <passphrase> | logout [--save] | savevault (encrypt copy of personal drive under /.bare/vault/)'
+2 -1
View File
@@ -36,7 +36,8 @@ async function run(ctx, argv) {
ctx.console.error('ls: cannot access ' + t + ': ' + (e.message || e)) ctx.console.error('ls: cannot access ' + t + ': ' + (e.message || e))
continue 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) { if (!longFmt) {
ctx.console.log(names.join(' ')) ctx.console.log(names.join(' '))
} else { } else {
+7 -2
View File
@@ -7,14 +7,19 @@ async function start(ctx) {
const rel = await drive.get('/etc/os-release') const rel = await drive.get('/etc/os-release')
if (rel) console.log(b4a.toString(rel)) if (rel) console.log(b4a.toString(rel))
console.log( 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) { while (true) {
const line = await readLine('') const line = await readLine('')
if (line == null) break if (line == null) break
const t = line.trim() const t = line.trim()
if (t === '') continue 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 if (status === 'exit') break
} }
} }
+4
View File
@@ -28,6 +28,8 @@ import {
applyLoginKeys applyLoginKeys
} from './lib/identity-session.js' } from './lib/identity-session.js'
import { HdmsController, runHdmsCli } from './lib/hdms-manager.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) const _pkg = packageRootDir(import.meta.url)
@@ -336,6 +338,8 @@ async function executeKernel(disk, store, swarm, initSource) {
ctx.writeScreen = session.writeScreen ctx.writeScreen = session.writeScreen
ctx.console = session.console ctx.console = session.console
await startBareInitd(ctx)
disk.os = { disk.os = {
async searchLocal() { async searchLocal() {
return [] return []
+250
View File
@@ -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
})
+114
View File
@@ -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()
+36 -3
View File
@@ -3,6 +3,13 @@ import unixPathResolve from 'unix-path-resolve'
const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor 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)`. * Execute kernel source from Hyperdrive (trusted). Expects top-level `async function start(ctx)`.
* @param {string} source * @param {string} source
@@ -23,16 +30,31 @@ export async function runKernelFromSource(source, ctx) {
* @param {string} [label] * @param {string} [label]
*/ */
async function runScriptFromSource(ctx, src, argv, label = argv[0]) { async function runScriptFromSource(ctx, src, argv, label = argv[0]) {
try {
const body = stripShebang(src)
const fn = new AsyncFunction( const fn = new AsyncFunction(
'ctx', 'ctx',
'argv', '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 {Record<string, unknown>} ctx
* @param {string[]} argv * @param {string[]} argv
*/ */
@@ -54,6 +76,17 @@ export async function runBinCommand(ctx, argv) {
return runScriptFromSource(ctx, source, argv, cmd) 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) const dirs = pathEnv.split(':').filter(Boolean)
for (const dir of dirs) { for (const dir of dirs) {
const p = unixPathResolve(dir, cmd) const p = unixPathResolve(dir, cmd)
@@ -9,6 +9,7 @@ import {
replDbg, replDbg,
unbindReplDebugStream unbindReplDebugStream
} from './debug-repl.js' } 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. * 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() { function cleanup() {
if (isReplDebug()) replDbg('repl', 'cleanup', fishRead ? 'fish teardown' : 'noop') if (isReplDebug()) replDbg('repl', 'cleanup', fishRead ? 'fish teardown' : 'noop')
stopBareInitd()
if (fishRead && stdin) { if (fishRead && stdin) {
disableFishRawMode(stdin) disableFishRawMode(stdin)
releaseFishStdin(stdin) releaseFishStdin(stdin)
+4
View File
@@ -327,7 +327,11 @@ export async function execShellLine(ctx, line) {
stdinText != null stdinText != null
? Object.assign({}, ctx, { shellStdin: stdinText, env }) ? Object.assign({}, ctx, { shellStdin: stdinText, env })
: Object.assign({}, ctx, { env }) : Object.assign({}, ctx, { env })
try {
await runBinCommand(childCtx, argv) await runBinCommand(childCtx, argv)
} catch (e) {
origErr.call(ctx.console, (e && e.message) || String(e))
}
if (name === '/bin/exit' || name.endsWith('/exit')) { if (name === '/bin/exit' || name.endsWith('/exit')) {
code = 'exit' code = 'exit'
} }
+152
View File
@@ -3,6 +3,7 @@ import b4a from 'b4a'
import Hyperdrive from 'hyperdrive' import Hyperdrive from 'hyperdrive'
import Corestore from 'corestore' import Corestore from 'corestore'
import { mkdirSync, rmSync } from 'node:fs' import { mkdirSync, rmSync } from 'node:fs'
import { readFile } from 'node:fs/promises'
import path from 'node:path' import path from 'node:path'
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
import { PassThrough } from 'node:stream' import { PassThrough } from 'node:stream'
@@ -18,6 +19,16 @@ import {
dedupeConsecutiveHistory, dedupeConsecutiveHistory,
searchHistoryEntries searchHistoryEntries
} from './lib/fish-readline.js' } 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)) const __dirname = path.dirname(fileURLToPath(import.meta.url))
function testCorestoreDir(name) { function testCorestoreDir(name) {
@@ -84,6 +95,108 @@ test('runBinCommand runs /bin helper', async (t) => {
rmSync(dir, { recursive: true, force: true }) 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) => { test('createStreamLineReader yields lines after newline', async (t) => {
const stdin = new PassThrough() const stdin = new PassThrough()
const chunks = [] const chunks = []
@@ -260,6 +373,45 @@ async function run(ctx, argv) {
rmSync(dir, { recursive: true, force: true }) 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) => { test('fish-readline stripAnsi and fuzzyMatch', async (t) => {
t.is(stripAnsi('\x1b[32mhi\x1b[0m'), 'hi') t.is(stripAnsi('\x1b[32mhi\x1b[0m'), 'hi')
t.ok(fuzzyMatch('hello', 'hlo')) t.ok(fuzzyMatch('hello', 'hlo'))
+1
View File
@@ -11,6 +11,7 @@ const commands = [
'basename', 'basename',
'cat', 'cat',
'clear', 'clear',
'crontab',
'date', 'date',
'dirname', 'dirname',
'echo', 'echo',
+74
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
async function run(ctx, argv) { async function run(ctx, argv) {
ctx.console.log( 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( ctx.console.log(
'Identity: login [--new] <passphrase> | logout [--save] | savevault (encrypt copy of personal drive under /.bare/vault/)' 'Identity: login [--new] <passphrase> | logout [--save] | savevault (encrypt copy of personal drive under /.bare/vault/)'
+2 -1
View File
@@ -31,7 +31,8 @@ async function run(ctx, argv) {
ctx.console.error('ls: cannot access ' + t + ': ' + (e.message || e)) ctx.console.error('ls: cannot access ' + t + ': ' + (e.message || e))
continue 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) { if (!longFmt) {
ctx.console.log(names.join(' ')) ctx.console.log(names.join(' '))
} else { } 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
}
}
+1 -1
View File
@@ -5,7 +5,7 @@ function bareStdin(ctx) {
async function run(ctx, argv) { async function run(ctx, argv) {
ctx.console.log( 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( ctx.console.log(
'Identity: login [--new] <passphrase> | logout [--save] | savevault (encrypt copy of personal drive under /.bare/vault/)' 'Identity: login [--new] <passphrase> | logout [--save] | savevault (encrypt copy of personal drive under /.bare/vault/)'
+2 -1
View File
@@ -36,7 +36,8 @@ async function run(ctx, argv) {
ctx.console.error('ls: cannot access ' + t + ': ' + (e.message || e)) ctx.console.error('ls: cannot access ' + t + ': ' + (e.message || e))
continue 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) { if (!longFmt) {
ctx.console.log(names.join(' ')) ctx.console.log(names.join(' '))
} else { } else {
+7 -2
View File
@@ -7,14 +7,19 @@ async function start(ctx) {
const rel = await drive.get('/etc/os-release') const rel = await drive.get('/etc/os-release')
if (rel) console.log(b4a.toString(rel)) if (rel) console.log(b4a.toString(rel))
console.log( 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) { while (true) {
const line = await readLine('') const line = await readLine('')
if (line == null) break if (line == null) break
const t = line.trim() const t = line.trim()
if (t === '') continue 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 if (status === 'exit') break
} }
} }