556 lines
15 KiB
Bash
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
|
|
}
|