441 lines
12 KiB
Bash
441 lines
12 KiB
Bash
/** 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
|
|
}
|
|
|
|
/** 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] [[section] name]\n' +
|
|
' Section 1: /bin utilities; section 7: Bare OS handbook (man 7 bare-os-handbook).\n' +
|
|
' Data: /share/man/man.json on the system drive.'
|
|
)
|
|
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.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 rows = db.pages
|
|
.map((p) => ({ n: p.name, s: p.section }))
|
|
.sort((a, b) => (a.n !== b.n ? (a.n < b.n ? -1 : 1) : a.s - b.s))
|
|
for (const r of rows) 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
|
|
}
|
|
ctx.console.log(bareManRenderPage(page, ctx, width).replace(/\n$/, ''))
|
|
}
|