Files
bare-operating-system/packages/bare-os-seeder/kernel/bin/man
T
Raven Scott 21325e18d3
Release rolling / release (push) Successful in 9m45s
Harden Coreutils Bump to 0.1.1
2026-08-12 21:47:16 -04:00

852 lines
23 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
}
/** 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<string, unknown>} 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<string, unknown>} 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<string, unknown>} 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<string, unknown>} 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<string, unknown>} 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<string, unknown> | 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<string, string | undefined> | null | undefined} env
* @param {Record<string, unknown> | 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<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
}