Files
bare-operating-system/packages/bare-os-seeder/kernel/bin/man
T
2026-04-03 22:34:08 -04:00

556 lines
15 KiB
Bash

/* BARE_OS_BIN_API 1.0.0 — bump when staged /bin script semantics change (see developer guide). */
/** Shared helpers for drive-resident /bin scripts (prepended before each command). */
function bareStdin(ctx) {
return typeof ctx.shellStdin === 'string' ? ctx.shellStdin : ''
}
/** @param {number} mode @param {'file' | 'directory' | 'symlink'} type */
function bareFormatModeString(mode, type) {
const typeChar = type === 'directory' ? 'd' : type === 'symlink' ? 'l' : '-'
const perm = mode & 0o777
const r = (bit) => (perm & bit ? 'r' : '-')
const w = (bit) => (perm & bit ? 'w' : '-')
const x = (bit) => (perm & bit ? 'x' : '-')
return (
typeChar +
r(0o400) +
w(0o200) +
x(0o100) +
r(0o040) +
w(0o020) +
x(0o010) +
r(0o004) +
w(0o002) +
x(0o001)
)
}
/** @param {number} mtimeMs @param {number} [nowMs] */
function bareFormatLsMtime(mtimeMs, nowMs) {
const now = nowMs != null ? nowMs : Date.now()
const d = new Date(mtimeMs)
const months = [
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec'
]
const mon = months[d.getMonth()]
const day = String(d.getDate()).padStart(2, ' ')
const sixMo = 180 * 24 * 3600 * 1000
if (Math.abs(now - mtimeMs) > sixMo) {
const yr = String(d.getFullYear()).padStart(4, ' ')
return mon + ' ' + day + ' ' + yr
}
const hh = String(d.getHours()).padStart(2, '0')
const mm = String(d.getMinutes()).padStart(2, '0')
return mon + ' ' + day + ' ' + hh + ':' + mm
}
/** @param {number} size */
function barePosixBlocks(size) {
return Math.ceil(Number(size) / 512) || 0
}
/**
* Raw stdout for NUL/binary when **`process.stdout.write`** is missing.
* If **`ctx.bareOsBinWrite(Uint8Array|string)`** is set (tests / host), use it.
* @param {Record<string, unknown>} ctx
* @param {string | Uint8Array} chunk
* @returns {boolean}
*/
function bareOsEmitRaw(ctx, chunk) {
if (typeof ctx.bareOsBinWrite === 'function') {
const b4 = ctx.b4a
const u8 =
typeof chunk === 'string'
? b4 && typeof b4.from === 'function'
? b4.from(chunk)
: new TextEncoder().encode(chunk)
: chunk
ctx.bareOsBinWrite(u8 instanceof Uint8Array ? u8 : new Uint8Array(u8))
return true
}
const w = globalThis.process?.stdout?.write
if (typeof w === 'function') {
w.call(globalThis.process.stdout, chunk)
return true
}
return false
}
/** Plain-text manual formatter (prepended before src/man.js; no import in /bin/man). */
function bareManParseWidth(env) {
const raw = env && env.MANWIDTH != null ? String(env.MANWIDTH).trim() : ''
const n = raw ? Number.parseInt(raw, 10) : 72
if (!Number.isFinite(n)) return 72
return Math.max(40, Math.min(n, 200))
}
function bareManUseAnsi(ctx) {
const env = ctx.env || {}
if (env.NO_COLOR != null && String(env.NO_COLOR) !== '') return false
const out = ctx.stdout
return Boolean(out && out.isTTY)
}
function bareManBold(s, on) {
if (!on) return s
return '\x1b[1m' + s + '\x1b[0m'
}
function bareManWrap(text, width) {
const words = String(text).replace(/\s+/g, ' ').trim().split(' ')
const lines = []
let cur = ''
for (const w of words) {
const next = cur ? cur + ' ' + w : w
if (next.length <= width) cur = next
else {
if (cur) lines.push(cur)
cur = w.length > width ? w.slice(0, width) : w
while (cur.length > width) {
lines.push(cur.slice(0, width))
cur = cur.slice(width)
}
}
}
if (cur) lines.push(cur)
return lines
}
/** Indent fixed-width command lines; hard-wrap only when longer than width. */
function bareManRenderExampleCode(code, width) {
const indent = ' '
const max = Math.max(20, width - indent.length)
const out = []
for (const line of String(code).split('\n')) {
if (line.length <= max) {
out.push(indent + line)
continue
}
let rest = line
while (rest.length > max) {
out.push(indent + rest.slice(0, max))
rest = rest.slice(max)
}
if (rest) out.push(indent + rest)
}
return out.join('\n')
}
/** Keep newlines; hard-wrap long lines only (for handbook / preformatted text). */
function bareManRenderPreserve(text, width) {
const indent = ' '
const max = Math.max(20, width - indent.length)
const out = []
for (const line of String(text).split('\n')) {
if (line === '') {
out.push('')
continue
}
let rest = line
while (rest.length > max) {
out.push(indent + rest.slice(0, max))
rest = rest.slice(max)
}
out.push(indent + rest)
}
return out.join('\n') + '\n'
}
function bareManFlushBlock(lines, width, prefixFirst, prefixRest) {
const out = []
let first = true
for (const line of lines) {
const wrapped = bareManWrap(
line,
width - (first ? prefixFirst.length : prefixRest.length)
)
for (let i = 0; i < wrapped.length; i++) {
const p = i === 0 && first ? prefixFirst : prefixRest
out.push(p + wrapped[i])
first = false
}
}
return out.join('\n') + '\n'
}
function bareManRenderPage(page, ctx, width) {
const ansi = bareManUseAnsi(ctx)
const H = (s) => bareManBold(s, ansi) + '\n'
let s = ''
s += H('NAME')
s += page.name + '(' + page.section + ') - ' + page.title + '\n\n'
s += H('SYNOPSIS')
for (const line of page.synopsis) {
s += ' ' + line + '\n'
}
s += '\n'
s += H('DESCRIPTION')
if (page.descriptionMode === 'preserve') {
s += bareManRenderPreserve(page.description, width)
} else {
s += bareManFlushBlock([page.description], width, '', ' ')
}
if (page.options && page.options.length) {
s += '\n' + H('OPTIONS')
for (const o of page.options) {
const head = o.flag + '\t'
const rest = o.meaning
s += bareManFlushBlock([rest], width, ' ' + head, ' ')
}
}
if (page.examples && page.examples.length) {
s += '\n' + H('EXAMPLES')
s +=
bareManFlushBlock(
[
'tl;dr-style snippets (like cheat.sh). Copy, adapt paths; pipelines are shell-simulated on Bare OS.'
],
width,
'',
' '
) + '\n'
for (const ex of page.examples) {
if (ex.caption) {
s += bareManFlushBlock(['# ' + ex.caption], width, '', ' ') + '\n'
}
s += bareManRenderExampleCode(ex.code, width) + '\n\n'
}
}
if (page.environment && page.environment.length) {
s += '\n' + H('ENVIRONMENT')
for (const e of page.environment) s += ' ' + e + '\n'
}
if (page.files && page.files.length) {
s += '\n' + H('FILES')
for (const f of page.files) s += ' ' + f + '\n'
}
if (page.exitStatus && page.exitStatus.length) {
s += '\n' + H('EXIT STATUS')
for (const e of page.exitStatus) s += ' ' + e + '\n'
}
if (page.diagnostics && page.diagnostics.length) {
s += '\n' + H('DIAGNOSTICS')
for (const d of page.diagnostics) s += ' ' + d + '\n'
}
if (page.builtins && page.builtins.length) {
s += '\n' + H('SHELL BUILTINS')
for (const b of page.builtins) {
s += '\n' + bareManBold(b.name, ansi) + '\n'
if (b.synopsis && b.synopsis.length) {
for (const line of b.synopsis) s += ' ' + line + '\n'
}
s += bareManFlushBlock([b.description], width, ' ', ' ')
if (b.options && b.options.length) {
for (const o of b.options) {
const head = o.flag + '\t'
s += bareManFlushBlock(
[o.meaning],
width,
' ' + head,
' '
)
}
}
if (b.examples && b.examples.length) {
s += '\n' + bareManBold(' Examples', ansi) + '\n'
for (const ex of b.examples) {
if (ex.caption) {
s +=
bareManFlushBlock(
['# ' + ex.caption],
width,
' ',
' '
) + '\n'
}
s += bareManRenderExampleCode(ex.code, width) + '\n\n'
}
}
}
}
if (page.bareOsNotes) {
s += '\n' + H('BARE OS NOTES')
s += bareManFlushBlock([page.bareOsNotes], width, '', ' ')
}
if (page.seeAlso && page.seeAlso.length) {
s += '\n' + H('SEE ALSO')
const parts = page.seeAlso.map((r) => r.name + '(' + r.section + ')')
s += ' ' + parts.join(', ') + '\n'
}
if (page.stub) {
s += '\n' + H('STATUS')
s += ' This command is a stub or intentionally limited on Bare OS.\n'
}
return s
}
async function run(ctx, argv) {
const env = ctx.env || {}
const width = bareManParseWidth(env)
const args = argv.slice(1)
function usage() {
ctx.console.error(
'usage: man [-k keyword] [-f name] [-l] [-w] [[section] name]\n' +
' Section 1: /bin; section 7: handbook + developer guide (man handbook, man devguide).\n' +
' Data: /share/man/man.json on the system drive.\n' +
' Long pages: set PAGER=bare-slice and optional MAN_SLICE=N (default 24) for section breaks.'
)
ctx.exitCode = 2
}
if (args.length === 0) {
usage()
return
}
let mode = 'page'
let kWord = ''
let fName = ''
const rest = []
for (let i = 0; i < args.length; i++) {
const a = args[i]
if (a === '--') {
rest.push(...args.slice(i + 1))
break
}
if (a === '-k' || a === '--apropos') {
mode = 'apropos'
kWord = args[++i] || ''
if (!kWord) {
usage()
return
}
continue
}
if (a === '-f' || a === '--whatis') {
mode = 'whatis'
fName = args[++i] || ''
if (!fName) {
usage()
return
}
continue
}
if (a === '-l' || a === '--list') {
mode = 'list'
continue
}
if (a === '-w' || a === '--where' || a === '--path') {
ctx.console.log('/share/man/man.json')
return
}
if (a.startsWith('-')) {
ctx.console.error('man: unknown option: ' + a)
ctx.exitCode = 2
return
}
rest.push(a)
}
const drive = ctx.drive
if (!drive || typeof drive.get !== 'function') {
ctx.console.error('man: no system drive in context')
ctx.exitCode = 1
return
}
let buf
try {
buf = await drive.get('/share/man/man.json', { follow: true })
} catch {
buf = null
}
if (!buf) {
ctx.console.error('man: manual database not found (/share/man/man.json)')
ctx.exitCode = 1
return
}
let db
try {
db = JSON.parse(ctx.b4a.toString(buf))
} catch (e) {
ctx.console.error('man: invalid manual database: ' + (e.message || e))
ctx.exitCode = 1
return
}
if (!db.pages || !db.index) {
ctx.console.error('man: malformed manual database')
ctx.exitCode = 1
return
}
if (mode === 'list') {
const CAT_ORDER = ['coreutils', 'extra', 'handbook', 'devguide']
const CAT_HEADING = {
coreutils: 'Section 1 — /bin utilities',
extra: 'Section 1 — Git and shell',
handbook: 'Section 7 — Handbook',
devguide: 'Section 7 — Developer guide'
}
function listCategoryOf(p) {
const c = p.listCategory
if (
c === 'coreutils' ||
c === 'extra' ||
c === 'handbook' ||
c === 'devguide'
)
return c
if (p.section === 7) {
const n = p.name
if (n.startsWith('devguide-') || n === 'bare-os-developer-guide')
return 'devguide'
return 'handbook'
}
if (p.name === 'git' || p.name === 'bare-os-shell') return 'extra'
return 'coreutils'
}
const rows = db.pages.map((p) => ({
n: p.name,
s: p.section,
cat: listCategoryOf(p)
}))
rows.sort((a, b) => {
const ia = CAT_ORDER.indexOf(a.cat)
const ib = CAT_ORDER.indexOf(b.cat)
const ca = ia === -1 ? 99 : ia
const cb = ib === -1 ? 99 : ib
if (ca !== cb) return ca - cb
if (a.n !== b.n) return a.n < b.n ? -1 : 1
return a.s - b.s
})
let prevCat = ''
for (const r of rows) {
if (r.cat !== prevCat) {
if (prevCat !== '') ctx.console.log('')
ctx.console.log(CAT_HEADING[r.cat] || r.cat)
prevCat = r.cat
}
ctx.console.log(' ' + r.n + '(' + r.s + ')')
}
return
}
if (mode === 'apropos') {
const needle = kWord.toLowerCase()
const seen = new Set()
const hits = []
if (db.apropos && Array.isArray(db.apropos)) {
for (const row of db.apropos) {
if (typeof row.kw !== 'string') continue
if (!row.kw.includes(needle)) continue
const idx = row.pageRef
if (typeof idx !== 'number' || !db.pages[idx]) continue
if (seen.has(idx)) continue
seen.add(idx)
const p = db.pages[idx]
hits.push(p.name + '(' + p.section + ') - ' + p.title)
}
}
hits.sort()
for (const line of hits) ctx.console.log(line)
return
}
if (mode === 'whatis') {
const key = fName.toLowerCase()
const idx = db.index[key]
if (idx === undefined || !db.pages[idx]) {
ctx.console.error('man: nothing appropriate for ' + fName)
ctx.exitCode = 1
return
}
const p = db.pages[idx]
ctx.console.log(p.name + '(' + p.section + ') - ' + p.title)
return
}
if (rest.length === 0) {
usage()
return
}
/** @type {number | null} */
let sectionExplicit = null
let name = rest[0]
if (rest.length >= 2 && /^[0-9]+$/.test(rest[0])) {
sectionExplicit = Number.parseInt(rest[0], 10)
name = rest[1]
}
if (
sectionExplicit !== null &&
(sectionExplicit < 1 || sectionExplicit > 8)
) {
ctx.console.error('man: section must be between 1 and 8')
ctx.exitCode = 2
return
}
const idx = db.index[String(name).toLowerCase()]
if (idx === undefined || !db.pages[idx]) {
ctx.console.error('man: no manual entry for ' + name)
ctx.exitCode = 1
return
}
const page = db.pages[idx]
if (sectionExplicit !== null && page.section !== sectionExplicit) {
ctx.console.error(
'man: no entry for ' +
name +
' in section ' +
sectionExplicit +
' (see man -l)'
)
ctx.exitCode = 1
return
}
const rendered = bareManRenderPage(page, ctx, width).replace(/\n$/, '')
if (bareManSlicePage(rendered, env, ctx.console)) return
ctx.console.log(rendered)
}
/**
* @param {string} text
* @param {Record<string, string>} env
* @param {{ log: (s: string) => void }} cons
*/
function bareManSlicePage(text, env, cons) {
const pager = env.PAGER || ''
if (pager !== 'bare-slice' && pager !== 'bare_slice') return false
const raw = env.MAN_SLICE || '24'
const n = Number.parseInt(String(raw), 10)
const sliceLines = Number.isFinite(n) && n > 0 ? n : 24
const lines = text.split('\n')
const total = lines.length
for (let i = 0; i < total; i += sliceLines) {
const chunk = lines.slice(i, i + sliceLines).join('\n')
cons.log(chunk)
if (i + sliceLines < total) {
const hi = Math.min(i + sliceLines, total)
cons.log('')
cons.log(
`--- man: lines ${i + 1}-${hi} of ${total} (PAGER=bare-slice) ---`
)
cons.log('')
}
}
return true
}