/* 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} 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 } /** Session env map (`vfs.env`, then `ctx.env`). Never throws. */ function bareOsEnv(ctx) { const v = ctx && ctx.vfs && ctx.vfs.env if (v && typeof v === 'object') return v const e = ctx && ctx.env if (e && typeof e === 'object') return e return {} } /** * Strict POSIX-ish decimal integer (no octal, no exponent, no empty). * @param {unknown} s * @returns {number} */ function bareOsParseDecInt(s) { const t = String(s == null ? '' : s).trim() if (!/^[+-]?(?:0|[1-9][0-9]*)$/.test(t)) return NaN const n = Number.parseInt(t, 10) return Number.isSafeInteger(n) ? n : NaN } /** @param {unknown} s */ function bareOsParseNonNegInt(s) { const n = bareOsParseDecInt(s) return n >= 0 ? n : NaN } /** * @param {Record} ctx * @param {string} name * @param {number} fallback * @param {number} [min] * @param {number} [max] */ function bareOsEnvInt(ctx, name, fallback, min, max) { const raw = bareOsEnv(ctx)[name] if (raw == null || raw === '') return fallback const n = Number.parseInt(String(raw), 10) if (!Number.isFinite(n)) return fallback let v = n if (min != null && v < min) v = min if (max != null && v > max) v = max return v } /** * @param {Record} ctx * @param {string} msg * @param {number} [code] */ function bareOsFail(ctx, msg, code) { if (msg) ctx.console.error(msg) ctx.exitCode = code == null ? 1 : code } /** @param {unknown} e */ function bareOsIsNotFoundErr(e) { const code = e && typeof e === 'object' ? e.code : '' if (code === 'ENOENT') return true const msg = String((e && e.message) || e || '') return /ENOENT|No such file|not found/i.test(msg) } /** * @param {Record} ctx * @param {unknown} buf * @returns {Uint8Array} */ function bareOsToU8(ctx, buf) { if (!buf) return new Uint8Array(0) if (buf instanceof Uint8Array) return buf if (ctx && ctx.b4a && typeof ctx.b4a.from === 'function') return ctx.b4a.from(buf) return new Uint8Array(buf) } /** @param {string} dir @param {string} name */ function bareOsJoinPath(dir, name) { const d = String(dir || '').replace(/\/+$/, '') const n = String(name || '').replace(/^\/+/, '') if (!d || d === '/') return '/' + n return d + '/' + n } /** @param {string} p */ function bareOsBaseName(p) { const t = String(p || '').replace(/\/+$/, '') if (!t || t === '/') return t === '/' ? '/' : '' const i = t.lastIndexOf('/') return i < 0 ? t : t.slice(i + 1) || t } /** @param {string} p */ function bareOsParentDir(p) { const t = String(p || '').replace(/\/+$/, '') || '/' if (t === '/') return '/' const i = t.lastIndexOf('/') return i <= 0 ? '/' : t.slice(0, i) || '/' } /** @param {string} p */ function bareOsNormPath(p) { return String(p || '').replace(/\/+$/, '') || '/' } /** * @param {Record} ctx * @param {string} p */ function bareOsResolvePath(ctx, p) { if (ctx && ctx.vfs && typeof ctx.vfs.resolveLogical === 'function') { try { return String(ctx.vfs.resolveLogical(p) || p) } catch { /* fall through */ } } return String(p || '') } /** * True when dest is src or lives under src (self-copy / self-move). * @param {Record} ctx * @param {string} src * @param {string} dest */ function bareOsDestInsideSrc(ctx, src, dest) { const s = bareOsNormPath(bareOsResolvePath(ctx, src)) const d = bareOsNormPath(bareOsResolvePath(ctx, dest)) if (s === d) return true if (s === '/') return d !== '/' return d === s || d.startsWith(s + '/') } const BARE_OS_B64_ALPH = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/' /** @param {Uint8Array} u8 */ function bareOsB64Encode(u8) { let out = '' let i = 0 for (; i + 2 < u8.length; i += 3) { const n = (u8[i] << 16) | (u8[i + 1] << 8) | u8[i + 2] out += BARE_OS_B64_ALPH[(n >> 18) & 63] + BARE_OS_B64_ALPH[(n >> 12) & 63] + BARE_OS_B64_ALPH[(n >> 6) & 63] + BARE_OS_B64_ALPH[n & 63] } const rest = u8.length - i if (rest === 1) { const n = u8[i] << 16 out += BARE_OS_B64_ALPH[(n >> 18) & 63] + BARE_OS_B64_ALPH[(n >> 12) & 63] + '==' } else if (rest === 2) { const n = (u8[i] << 16) | (u8[i + 1] << 8) out += BARE_OS_B64_ALPH[(n >> 18) & 63] + BARE_OS_B64_ALPH[(n >> 12) & 63] + BARE_OS_B64_ALPH[(n >> 6) & 63] + '=' } return out } /** * RFC 4648 Base64 decode (also accepts URL-safe alphabet). Rejects junk. * @param {string} s * @returns {Uint8Array} */ function bareOsB64Decode(s) { const t = String(s).replace(/\s+/g, '') if (!t) return new Uint8Array(0) if (t.length % 4 === 1) throw new Error('invalid base64 length') let pad = 0 if (t.endsWith('==')) pad = 2 else if (t.endsWith('=')) pad = 1 const body = pad ? t.slice(0, t.length - pad) : t const bytes = [] let buf = 0 let bits = 0 for (let i = 0; i < body.length; i++) { const c = body[i] let v = BARE_OS_B64_ALPH.indexOf(c) if (v < 0) { if (c === '-') v = 62 else if (c === '_') v = 63 else throw new Error('invalid base64 character') } buf = (buf << 6) | v bits += 6 if (bits >= 8) { bits -= 8 bytes.push((buf >> bits) & 255) } } if (pad) { const want = Math.floor((body.length * 6) / 8) if (bytes.length > want) bytes.length = want } return new Uint8Array(bytes) } /** * @param {string} s * @returns {Uint8Array} */ function bareOsHexDecode(s) { const t = String(s).replace(/\s+/g, '') if (t.length % 2 !== 0) throw new Error('odd hex length') const out = new Uint8Array(t.length / 2) for (let i = 0; i < out.length; i++) { const pair = t.slice(i * 2, i * 2 + 2) if (!/^[0-9a-fA-F]{2}$/.test(pair)) throw new Error('invalid hex') out[i] = Number.parseInt(pair, 16) } return out } /** @param {Uint8Array} u8 */ function bareOsHexEncode(u8) { let s = '' for (let i = 0; i < u8.length; i++) s += u8[i].toString(16).padStart(2, '0') return s } /** Plain-text manual formatter (prepended before src/man.js; no import in /bin/man). */ const BARE_MAN_WIDTH_MIN = 40 const BARE_MAN_WIDTH_MAX = 200 const BARE_MAN_WIDTH_DEFAULT = 72 /** * @param {number} n * @returns {number} */ function bareManClampWidth(n) { return Math.max(BARE_MAN_WIDTH_MIN, Math.min(n, BARE_MAN_WIDTH_MAX)) } /** * @param {Record | null | undefined} ctx * @returns {number | null} usable column count, or null if unknown */ function bareManTryTerminalColumns(ctx) { if (!ctx || typeof ctx !== 'object') return null if (ctx.bareOsStdoutCaptured) return null const streams = [] if (ctx.stdout) streams.push(ctx.stdout) if (ctx.replStdout && ctx.replStdout !== ctx.stdout) streams.push(ctx.replStdout) for (const s of streams) { if ( s && s.isTTY && typeof s.columns === 'number' && Number.isFinite(s.columns) && s.columns > 0 ) { return bareManClampWidth(s.columns) } } return null } /** * @param {Record | null | undefined} env * @param {Record | null | undefined} ctx */ function bareManParseWidth(env, ctx) { const raw = env && env.MANWIDTH != null ? String(env.MANWIDTH).trim() : '' if (raw) { const n = Number.parseInt(raw, 10) if (!Number.isFinite(n)) return BARE_MAN_WIDTH_DEFAULT return bareManClampWidth(n) } const fromTTY = bareManTryTerminalColumns(ctx) if (fromTTY != null) return fromTTY const colRaw = env && env.COLUMNS != null ? String(env.COLUMNS).trim() : '' if (colRaw) { const c = Number.parseInt(colRaw, 10) if (Number.isFinite(c) && c > 0) return bareManClampWidth(c) } return BARE_MAN_WIDTH_DEFAULT } 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 intentionally bounded on Bare OS relative to Issue 7; see BARE OS NOTES and handbook ch.9.\n' } return s } async function run(ctx, argv) { const env = Object.assign( {}, ctx.vfs && ctx.vfs.env && typeof ctx.vfs.env === 'object' ? ctx.vfs.env : {}, ctx.env && typeof ctx.env === 'object' ? ctx.env : {} ) const width = bareManParseWidth(env, ctx) 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 + user manual + developer guide + docs/ (man handbook, man users-manual, man devguide, man docs).\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', 'usersmanual', 'devguide', 'docs' ] const CAT_HEADING = { coreutils: 'Section 1 — /bin utilities', extra: 'Section 1 — Git and shell', handbook: 'Section 7 — Handbook', usersmanual: 'Section 7 — User manual', devguide: 'Section 7 — Developer guide', docs: 'Section 7 — Documentation (docs/)' } function listCategoryOf(p) { const c = p.listCategory if ( c === 'coreutils' || c === 'extra' || c === 'handbook' || c === 'usersmanual' || c === 'devguide' || c === 'docs' ) return c if (p.section === 7) { const n = p.name if (n.startsWith('devguide-') || n === 'bare-os-developer-guide') return 'devguide' if (n.startsWith('docs-') || n === 'bare-os-docs') return 'docs' if ( n.startsWith('users-manual-') || n === 'bare-os-users-manual' ) return 'usersmanual' 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} 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 }