952 lines
27 KiB
Plaintext
952 lines
27 KiB
Plaintext
/* 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
|
|
}
|
|
|
|
/** ANSI helpers for /bin/edit (preamble; no import in src). */
|
|
|
|
const EDIT_ANSI_RESET = '\x1b[0m'
|
|
|
|
/**
|
|
* @param {Record<string, unknown>} [ctx]
|
|
* @returns {import('stream').Writable | undefined}
|
|
*/
|
|
function bareEditResolveStdout(ctx) {
|
|
if (!ctx || typeof ctx !== 'object') return globalThis.process?.stdout
|
|
const c = /** @type {{ replStdout?: unknown, stdout?: unknown }} */ (ctx)
|
|
const out = c.replStdout || c.stdout || globalThis.process?.stdout
|
|
return /** @type {import('stream').Writable | undefined} */ (out)
|
|
}
|
|
|
|
/**
|
|
* @param {Record<string, unknown>} [ctx]
|
|
*/
|
|
function bareEditUseColor(ctx) {
|
|
const env =
|
|
ctx && typeof ctx === 'object' && ctx.env && typeof ctx.env === 'object'
|
|
? /** @type {Record<string, string>} */ (ctx.env)
|
|
: {}
|
|
if (env.NO_COLOR != null && String(env.NO_COLOR) !== '') return false
|
|
const out = bareEditResolveStdout(ctx)
|
|
return Boolean(out && /** @type {{ isTTY?: boolean }} */ (out).isTTY)
|
|
}
|
|
|
|
/**
|
|
* @param {'keyword'|'string'|'comment'|'number'|'status'|'inverse'|'dim'} cls
|
|
* @param {boolean} on
|
|
*/
|
|
function bareEditSgr(cls, on) {
|
|
if (!on) return ''
|
|
switch (cls) {
|
|
case 'keyword':
|
|
return '\x1b[36m'
|
|
case 'string':
|
|
return '\x1b[32m'
|
|
case 'comment':
|
|
return '\x1b[90m'
|
|
case 'number':
|
|
return '\x1b[33m'
|
|
case 'status':
|
|
return '\x1b[44m\x1b[97m'
|
|
case 'inverse':
|
|
return '\x1b[7m'
|
|
case 'dim':
|
|
return '\x1b[2m'
|
|
default:
|
|
return ''
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {string} fullLine
|
|
* @param {Array<{ start: number, end: number, cls: string }>} spans offsets into fullLine
|
|
* @param {number} visStart first column (0-based)
|
|
* @param {number} maxLen max code units to show
|
|
* @param {boolean} useColor
|
|
*/
|
|
function bareEditPaintLineWindow(fullLine, spans, visStart, maxLen, useColor) {
|
|
const slice = fullLine.slice(visStart, visStart + maxLen)
|
|
if (!useColor) return slice
|
|
const n = slice.length
|
|
const relSpans = spans
|
|
.map((s) => ({
|
|
start: Math.max(0, s.start - visStart),
|
|
end: Math.min(n, s.end - visStart),
|
|
cls: s.cls
|
|
}))
|
|
.filter((s) => s.end > 0 && s.start < n)
|
|
.sort((a, b) => a.start - b.start)
|
|
|
|
let out = ''
|
|
let pos = 0
|
|
for (const sp of relSpans) {
|
|
if (sp.start > pos) out += slice.slice(pos, sp.start)
|
|
out +=
|
|
bareEditSgr(/** @type {'keyword'} */ (sp.cls), true) +
|
|
slice.slice(sp.start, sp.end) +
|
|
EDIT_ANSI_RESET
|
|
pos = sp.end
|
|
}
|
|
if (pos < n) out += slice.slice(pos)
|
|
return out
|
|
}
|
|
|
|
/**
|
|
* Move cursor (1-based row/col, DEC origin). Clamp to sane bounds for escape parsing.
|
|
* @param {number} row1
|
|
* @param {number} col1
|
|
*/
|
|
function bareEditCup(row1, col1) {
|
|
const r = Math.max(1, Math.min(Math.floor(row1), 9999))
|
|
const c = Math.max(1, Math.min(Math.floor(col1), 9999))
|
|
return '\x1b[' + r + ';' + c + 'H'
|
|
}
|
|
|
|
/**
|
|
* @param {Record<string, unknown>} ctx
|
|
* @param {import('stream').Writable} stdout
|
|
* @param {string} s
|
|
*/
|
|
function bareEditWrite(ctx, stdout, s) {
|
|
if (!stdout || typeof stdout.write !== 'function') return
|
|
try {
|
|
stdout.write(s)
|
|
} catch {
|
|
try {
|
|
ctx.console?.error?.('edit: stdout write failed')
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
}
|
|
|
|
/** TTY key parsing for /bin/edit: consume one logical key from a mutable byte queue. */
|
|
|
|
/**
|
|
* @param {number} b1
|
|
*/
|
|
function bareEditUtf8TrailCount(b1) {
|
|
if (b1 >= 0xc0 && b1 < 0xe0) return 1
|
|
if (b1 >= 0xe0 && b1 < 0xf0) return 2
|
|
if (b1 >= 0xf0) return 3
|
|
return 0
|
|
}
|
|
|
|
/**
|
|
* @param {number[]} bytes
|
|
*/
|
|
function bareEditUtf8DecodeKey(bytes) {
|
|
try {
|
|
const u = new Uint8Array(bytes)
|
|
if (typeof TextDecoder !== 'undefined') {
|
|
return new TextDecoder('utf-8', { fatal: false }).decode(u)
|
|
}
|
|
} catch {
|
|
/* fall through */
|
|
}
|
|
let s = ''
|
|
for (const b of bytes) s += String.fromCharCode(b)
|
|
return s
|
|
}
|
|
|
|
/**
|
|
* @param {string} seq CSI payload after ESC [, including final byte (e.g. "A", "1;5A", "3~")
|
|
*/
|
|
function bareEditCsiToEvent(seq) {
|
|
if (seq === '3~') return { type: 'ctrl', code: 'delete' }
|
|
if (seq === '5~') return { type: 'nav', key: 'pageup' }
|
|
if (seq === '6~') return { type: 'nav', key: 'pagedown' }
|
|
const last = seq.charAt(seq.length - 1)
|
|
if (last === '~') {
|
|
if (seq === '1~' || seq === '7~') return { type: 'nav', key: 'home' }
|
|
if (seq === '4~' || seq === '8~') return { type: 'nav', key: 'end' }
|
|
const tildeNum = /^(\d+)~$/.exec(seq)
|
|
if (tildeNum) {
|
|
const n = parseInt(tildeNum[1], 10)
|
|
const fnMap = {
|
|
11: 1,
|
|
12: 2,
|
|
13: 3,
|
|
14: 4,
|
|
15: 5,
|
|
17: 6,
|
|
18: 7,
|
|
19: 8,
|
|
20: 9,
|
|
21: 10,
|
|
23: 11,
|
|
24: 12
|
|
}
|
|
if (fnMap[n] != null) return { type: 'fn', n: fnMap[n] }
|
|
}
|
|
return { type: 'unknown' }
|
|
}
|
|
if (last === 'Z') return { type: 'nav', key: 'stab' }
|
|
if (last === 'A' || last === 'B' || last === 'C' || last === 'D') {
|
|
const map = { A: 'up', B: 'down', C: 'right', D: 'left' }
|
|
return { type: 'nav', key: map[last] }
|
|
}
|
|
if (last === 'H') return { type: 'nav', key: 'home' }
|
|
if (last === 'F') return { type: 'nav', key: 'end' }
|
|
return { type: 'unknown' }
|
|
}
|
|
|
|
/**
|
|
* @param {number} b3 byte after ESC O
|
|
*/
|
|
function bareEditSs3ToEvent(b3) {
|
|
if (b3 === 72) return { type: 'nav', key: 'home' }
|
|
if (b3 === 70) return { type: 'nav', key: 'end' }
|
|
if (b3 === 65) return { type: 'nav', key: 'up' }
|
|
if (b3 === 66) return { type: 'nav', key: 'down' }
|
|
if (b3 === 67) return { type: 'nav', key: 'right' }
|
|
if (b3 === 68) return { type: 'nav', key: 'left' }
|
|
if (b3 === 80) return { type: 'fn', n: 1 }
|
|
if (b3 === 81) return { type: 'fn', n: 2 }
|
|
if (b3 === 82) return { type: 'fn', n: 3 }
|
|
if (b3 === 83) return { type: 'fn', n: 4 }
|
|
return { type: 'unknown' }
|
|
}
|
|
|
|
/** ESC [ 200 ~ */
|
|
const BARE_EDIT_BRACKET_PASTE_START = [27, 91, 50, 48, 48, 126]
|
|
/** ESC [ 201 ~ */
|
|
const BARE_EDIT_BRACKET_PASTE_END = [27, 91, 50, 48, 49, 126]
|
|
|
|
/**
|
|
* @param {number[]} q
|
|
* @param {number[]} prefix
|
|
*/
|
|
function bareEditStartsWithBytes(q, prefix) {
|
|
if (q.length < prefix.length) return false
|
|
for (let i = 0; i < prefix.length; i++) {
|
|
if (q[i] !== prefix[i]) return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
/**
|
|
* True if q could still become BARE_EDIT_BRACKET_PASTE_START with more bytes.
|
|
* @param {number[]} q
|
|
*/
|
|
function bareEditCouldBeBracketPastePrefix(q) {
|
|
const pre = BARE_EDIT_BRACKET_PASTE_START
|
|
const n = Math.min(q.length, pre.length)
|
|
for (let i = 0; i < n; i++) {
|
|
if (q[i] !== pre[i]) return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
/**
|
|
* @param {number[]} bytes
|
|
*/
|
|
function bareEditDecodePasteInner(bytes) {
|
|
try {
|
|
if (typeof TextDecoder !== 'undefined') {
|
|
return new TextDecoder('utf-8', { fatal: false }).decode(
|
|
new Uint8Array(bytes)
|
|
)
|
|
}
|
|
} catch {
|
|
/* fall through */
|
|
}
|
|
let s = ''
|
|
for (const b of bytes) s += String.fromCharCode(b)
|
|
return s
|
|
}
|
|
|
|
/**
|
|
* If queue begins with ESC [ 200 ~ but no closing ESC [ 201 ~ (stdin closed), return inner bytes as text.
|
|
* @param {number[]} q
|
|
* @returns {string|undefined}
|
|
*/
|
|
function bareEditFinalizeBracketedPasteOnEof(q) {
|
|
const SL = BARE_EDIT_BRACKET_PASTE_START.length
|
|
if (q.length >= SL && bareEditStartsWithBytes(q, BARE_EDIT_BRACKET_PASTE_START)) {
|
|
const inner = q.slice(SL)
|
|
q.length = 0
|
|
return bareEditDecodePasteInner(inner)
|
|
}
|
|
return undefined
|
|
}
|
|
|
|
/**
|
|
* xterm bracketed paste: ESC [ 200 ~ … ESC [ 201 ~
|
|
* @param {number[]} q mutable queue
|
|
* @returns {string|null|undefined} string if consumed; null if incomplete; undefined if not bracketed paste at front
|
|
*/
|
|
function bareEditTryConsumeBracketedPaste(q) {
|
|
const SL = BARE_EDIT_BRACKET_PASTE_START.length
|
|
const EL = BARE_EDIT_BRACKET_PASTE_END.length
|
|
if (!q.length) return undefined
|
|
if (q.length < SL) {
|
|
return bareEditCouldBeBracketPastePrefix(q) ? null : undefined
|
|
}
|
|
if (!bareEditStartsWithBytes(q, BARE_EDIT_BRACKET_PASTE_START)) {
|
|
return undefined
|
|
}
|
|
for (let i = SL; i <= q.length - EL; i++) {
|
|
let ok = true
|
|
for (let j = 0; j < EL; j++) {
|
|
if (q[i + j] !== BARE_EDIT_BRACKET_PASTE_END[j]) {
|
|
ok = false
|
|
break
|
|
}
|
|
}
|
|
if (ok) {
|
|
const inner = q.slice(SL, i)
|
|
q.splice(0, i + EL)
|
|
return bareEditDecodePasteInner(inner)
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* @param {number[]} q mutable queue (front = index 0)
|
|
* @returns {Record<string, unknown> | null} null if more bytes needed
|
|
*/
|
|
function bareEditTryConsumeKey(q) {
|
|
if (!q.length) return null
|
|
const b1 = q[0]
|
|
if (b1 === 3) {
|
|
q.shift()
|
|
return { type: 'ctrl', code: 'interrupt' }
|
|
}
|
|
if (b1 === 8 || b1 === 127) {
|
|
q.shift()
|
|
return { type: 'ctrl', code: 'backspace' }
|
|
}
|
|
if (b1 === 13 || b1 === 10) {
|
|
q.shift()
|
|
return { type: 'key', ch: '\n' }
|
|
}
|
|
if (b1 === 9) {
|
|
q.shift()
|
|
return { type: 'key', ch: '\t' }
|
|
}
|
|
if (b1 === 27) {
|
|
if (q.length < 2) return null
|
|
const b2 = q[1]
|
|
if (b2 === 91) {
|
|
let i = 2
|
|
while (i < q.length) {
|
|
const b = q[i]
|
|
if (b >= 0x40 && b <= 0x7e) {
|
|
const seq = String.fromCharCode.apply(null, q.slice(2, i + 1))
|
|
q.splice(0, i + 1)
|
|
return bareEditCsiToEvent(seq)
|
|
}
|
|
i++
|
|
}
|
|
return null
|
|
}
|
|
if (b2 === 79) {
|
|
if (q.length < 3) return null
|
|
const b3 = q[2]
|
|
q.splice(0, 3)
|
|
return bareEditSs3ToEvent(b3)
|
|
}
|
|
q.splice(0, 2)
|
|
return { type: 'key', ch: String.fromCharCode(b2) }
|
|
}
|
|
if (b1 < 0x20) {
|
|
q.shift()
|
|
return { type: 'ctrl', code: b1 }
|
|
}
|
|
const need = bareEditUtf8TrailCount(b1)
|
|
if (q.length < 1 + need) return null
|
|
const chunk = q.splice(0, 1 + need)
|
|
return { type: 'key', ch: bareEditUtf8DecodeKey(chunk) }
|
|
}
|
|
|
|
/** Stdin chunk queue + async key reader for edit/chat TUIs (requires edit-key-parse.js first). */
|
|
|
|
/**
|
|
* @param {unknown} chunk
|
|
* @returns {number[]}
|
|
*/
|
|
function bareEditChunkBytes(chunk) {
|
|
if (chunk == null) return []
|
|
if (typeof chunk === 'string') {
|
|
const out = []
|
|
for (let i = 0; i < chunk.length; i++) out.push(chunk.charCodeAt(i) & 0xff)
|
|
return out
|
|
}
|
|
const len = /** @type {{ length: number, [k: number]: number }} */ (chunk).length
|
|
const out = []
|
|
for (let i = 0; i < len; i++) out.push(Number(chunk[i]) & 0xff)
|
|
return out
|
|
}
|
|
|
|
/**
|
|
* @param {{ nextByte: () => Promise<number|undefined>, dispose?: () => void, _keyq?: number[] }} reader
|
|
*/
|
|
async function bareEditReadKey(reader) {
|
|
reader._keyq = reader._keyq || []
|
|
const q = reader._keyq
|
|
for (;;) {
|
|
const pasteText = bareEditTryConsumeBracketedPaste(q)
|
|
if (pasteText !== undefined && pasteText !== null) {
|
|
return { type: 'paste', text: pasteText }
|
|
}
|
|
if (pasteText === null) {
|
|
const b = await reader.nextByte()
|
|
if (b === undefined) {
|
|
const eofPaste = bareEditFinalizeBracketedPasteOnEof(q)
|
|
if (eofPaste !== undefined) {
|
|
return { type: 'paste', text: eofPaste }
|
|
}
|
|
if (!q.length) return { type: 'eof' }
|
|
if (q.length === 1 && q[0] === 27) {
|
|
q.length = 0
|
|
return { type: 'key', ch: '\x1b' }
|
|
}
|
|
const lone = q.shift()
|
|
if (lone !== undefined && lone < 0x20) {
|
|
return { type: 'ctrl', code: lone }
|
|
}
|
|
if (lone !== undefined) {
|
|
return { type: 'key', ch: String.fromCharCode(lone) }
|
|
}
|
|
return { type: 'eof' }
|
|
}
|
|
q.push(b)
|
|
continue
|
|
}
|
|
const ev = bareEditTryConsumeKey(q)
|
|
if (ev) return ev
|
|
const b = await reader.nextByte()
|
|
if (b === undefined) {
|
|
const eofPaste = bareEditFinalizeBracketedPasteOnEof(q)
|
|
if (eofPaste !== undefined) {
|
|
return { type: 'paste', text: eofPaste }
|
|
}
|
|
if (!q.length) return { type: 'eof' }
|
|
if (q.length === 1 && q[0] === 27) {
|
|
q.length = 0
|
|
return { type: 'key', ch: '\x1b' }
|
|
}
|
|
const lone = q.shift()
|
|
if (lone !== undefined && lone < 0x20) {
|
|
return { type: 'ctrl', code: lone }
|
|
}
|
|
if (lone !== undefined) {
|
|
return { type: 'key', ch: String.fromCharCode(lone) }
|
|
}
|
|
return { type: 'eof' }
|
|
}
|
|
q.push(b)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {import('stream').Readable} stdin
|
|
*/
|
|
function bareEditCreateStdinReader(stdin) {
|
|
/** @type {number[]} */
|
|
const bytes = []
|
|
/** @type {(() => void)[]} */
|
|
const waiters = []
|
|
function drain() {
|
|
while (waiters.length && bytes.length) {
|
|
const w = waiters.shift()
|
|
if (w) w()
|
|
}
|
|
}
|
|
/** @param {unknown} chunk */
|
|
function onData(chunk) {
|
|
bytes.push(...bareEditChunkBytes(chunk))
|
|
drain()
|
|
}
|
|
stdin.on('data', onData)
|
|
return {
|
|
nextByte() {
|
|
if (bytes.length) return Promise.resolve(bytes.shift())
|
|
return new Promise((resolve) => {
|
|
waiters.push(() => resolve(bytes.shift()))
|
|
})
|
|
},
|
|
dispose() {
|
|
stdin.removeListener('data', onData)
|
|
}
|
|
}
|
|
}
|
|
|
|
const BARE_P2P_SUITE_PREFIX = '[bare-p2p-v1]'
|
|
const BARE_P2P_SUITE_MAX_HISTORY = 2000
|
|
|
|
function bareP2pNowMs() {
|
|
return Date.now()
|
|
}
|
|
|
|
function bareP2pId(prefix) {
|
|
const rnd = Math.floor(Math.random() * 0x7fffffff)
|
|
return prefix + '-' + bareP2pNowMs().toString(36) + '-' + rnd.toString(36)
|
|
}
|
|
|
|
function bareP2pHome(ctx) {
|
|
const env = ctx.env && typeof ctx.env === 'object' ? ctx.env : {}
|
|
const home = typeof env.HOME === 'string' && env.HOME ? env.HOME : '/home/guest'
|
|
return home
|
|
}
|
|
|
|
function bareP2pJoinPath(a, b) {
|
|
if (!a.endsWith('/')) return a + '/' + b
|
|
return a + b
|
|
}
|
|
|
|
function bareP2pDataDir(ctx) {
|
|
return bareP2pJoinPath(bareP2pHome(ctx), '.bare/p2p-suite')
|
|
}
|
|
|
|
function bareP2pDataFile(ctx, app) {
|
|
return bareP2pJoinPath(bareP2pDataDir(ctx), app + '.json')
|
|
}
|
|
|
|
async function bareP2pReadJson(ctx, path) {
|
|
try {
|
|
if (!ctx.vfs || typeof ctx.vfs.readFile !== 'function') return null
|
|
const b = await ctx.vfs.readFile(path)
|
|
if (!b) return null
|
|
const t = ctx.b4a.toString(b).trim()
|
|
if (!t) return null
|
|
return JSON.parse(t)
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
async function bareP2pWriteJson(ctx, path, obj) {
|
|
if (!ctx.vfs || typeof ctx.vfs.writeFile !== 'function') return false
|
|
const body = JSON.stringify(obj, null, 2) + '\n'
|
|
const b4 = ctx.b4a
|
|
const buf =
|
|
b4 && typeof b4.from === 'function'
|
|
? b4.from(body)
|
|
: new TextEncoder().encode(body)
|
|
try {
|
|
await ctx.vfs.writeFile(path, buf)
|
|
return true
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
function bareP2pDecodeEnvelope(line) {
|
|
const s = String(line || '')
|
|
if (!s.startsWith(BARE_P2P_SUITE_PREFIX)) return null
|
|
const payload = s.slice(BARE_P2P_SUITE_PREFIX.length).trim()
|
|
if (!payload) return null
|
|
try {
|
|
const obj = JSON.parse(payload)
|
|
if (!obj || typeof obj !== 'object') return null
|
|
return obj
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
function bareP2pEncodeEnvelope(app, kind, payload) {
|
|
return (
|
|
BARE_P2P_SUITE_PREFIX +
|
|
' ' +
|
|
JSON.stringify({
|
|
schema: 1,
|
|
suite: 'bare-p2p',
|
|
version: '1',
|
|
app,
|
|
kind,
|
|
tsMs: bareP2pNowMs(),
|
|
payload: payload || {}
|
|
})
|
|
)
|
|
}
|
|
|
|
function bareP2pSend(ctx, app, kind, payload) {
|
|
if (app === 'meshdrop' && typeof ctx.bareOsMeshdropSend === 'function') {
|
|
return ctx.bareOsMeshdropSend({
|
|
app,
|
|
kind,
|
|
payload: payload || {},
|
|
tsMs: bareP2pNowMs()
|
|
})
|
|
}
|
|
if (typeof ctx.bareOsChatSend !== 'function') {
|
|
return { ok: false, reason: 'bareOsChatSend unavailable' }
|
|
}
|
|
return ctx.bareOsChatSend(bareP2pEncodeEnvelope(app, kind, payload))
|
|
}
|
|
|
|
function bareP2pCollectFromHistory(ctx, app, limit) {
|
|
if (app === 'meshdrop' && typeof ctx.bareOsMeshdropHistory === 'function') {
|
|
let hist = []
|
|
try {
|
|
hist = ctx.bareOsMeshdropHistory(
|
|
Math.max(1, Math.min(BARE_P2P_SUITE_MAX_HISTORY, limit || 512))
|
|
)
|
|
} catch {
|
|
hist = []
|
|
}
|
|
if (!Array.isArray(hist)) return []
|
|
const out = []
|
|
for (const ev of hist) {
|
|
if (!ev || typeof ev !== 'object') continue
|
|
if (ev.app !== app) continue
|
|
out.push({
|
|
fromPeerKey: typeof ev.fromPeerKey === 'string' ? ev.fromPeerKey : '',
|
|
displayName: typeof ev.sender === 'string' ? ev.sender : '',
|
|
local: Boolean(ev.local),
|
|
receivedAtMs:
|
|
typeof ev.receivedAtMs === 'number' ? ev.receivedAtMs : bareP2pNowMs(),
|
|
packet: {
|
|
app,
|
|
kind: typeof ev.kind === 'string' ? ev.kind : 'unknown',
|
|
payload: ev.payload && typeof ev.payload === 'object' ? ev.payload : {}
|
|
}
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
if (typeof ctx.bareOsChatHistory !== 'function') return []
|
|
let hist = []
|
|
try {
|
|
hist = ctx.bareOsChatHistory(
|
|
Math.max(1, Math.min(BARE_P2P_SUITE_MAX_HISTORY, limit || 512))
|
|
)
|
|
} catch {
|
|
hist = []
|
|
}
|
|
if (!Array.isArray(hist)) return []
|
|
const out = []
|
|
for (const ev of hist) {
|
|
if (!ev || typeof ev !== 'object') continue
|
|
const body = String(ev.body || '')
|
|
const decoded = bareP2pDecodeEnvelope(body)
|
|
if (!decoded || decoded.app !== app) continue
|
|
out.push({
|
|
fromPeerKey: typeof ev.fromPeerKey === 'string' ? ev.fromPeerKey : '',
|
|
displayName: typeof ev.displayName === 'string' ? ev.displayName : '',
|
|
local: Boolean(ev.local),
|
|
receivedAtMs:
|
|
typeof ev.receivedAtMs === 'number' ? ev.receivedAtMs : bareP2pNowMs(),
|
|
packet: decoded
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
function bareP2pSubscribe(ctx, app, fn) {
|
|
if (app === 'meshdrop' && typeof ctx.bareOsMeshdropSubscribe === 'function') {
|
|
return ctx.bareOsMeshdropSubscribe((ev) => {
|
|
if (!ev || typeof ev !== 'object' || ev.app !== app) return
|
|
fn({
|
|
fromPeerKey: typeof ev.fromPeerKey === 'string' ? ev.fromPeerKey : '',
|
|
displayName: typeof ev.sender === 'string' ? ev.sender : '',
|
|
local: Boolean(ev.local),
|
|
receivedAtMs:
|
|
typeof ev.receivedAtMs === 'number' ? ev.receivedAtMs : bareP2pNowMs(),
|
|
packet: {
|
|
app,
|
|
kind: typeof ev.kind === 'string' ? ev.kind : 'unknown',
|
|
payload: ev.payload && typeof ev.payload === 'object' ? ev.payload : {}
|
|
}
|
|
})
|
|
})
|
|
}
|
|
if (typeof ctx.bareOsChatSubscribe !== 'function') return () => {}
|
|
return ctx.bareOsChatSubscribe((ev) => {
|
|
if (!ev || typeof ev !== 'object') return
|
|
const body = String(ev.body || '')
|
|
const decoded = bareP2pDecodeEnvelope(body)
|
|
if (!decoded || decoded.app !== app) return
|
|
fn({
|
|
fromPeerKey: typeof ev.fromPeerKey === 'string' ? ev.fromPeerKey : '',
|
|
displayName: typeof ev.displayName === 'string' ? ev.displayName : '',
|
|
local: Boolean(ev.local),
|
|
receivedAtMs:
|
|
typeof ev.receivedAtMs === 'number' ? ev.receivedAtMs : bareP2pNowMs(),
|
|
packet: decoded
|
|
})
|
|
})
|
|
}
|
|
|
|
async function bareP2pReadSwarmSnapshot(ctx) {
|
|
const out = {
|
|
atMs: bareP2pNowMs(),
|
|
peerCount: null,
|
|
topicCount: null,
|
|
peers: []
|
|
}
|
|
const snap = await bareP2pReadJson(ctx, '/proc/bare_os/swarm')
|
|
if (!snap || typeof snap !== 'object') return out
|
|
if (typeof snap.peerCount === 'number') out.peerCount = snap.peerCount
|
|
if (typeof snap.topicCount === 'number') out.topicCount = snap.topicCount
|
|
const peers = Array.isArray(snap.peers) ? snap.peers : []
|
|
const detailsRaw = await bareP2pReadJson(ctx, '/proc/bare_os/peer_details.json')
|
|
const details =
|
|
detailsRaw && typeof detailsRaw === 'object' && Array.isArray(detailsRaw.peers)
|
|
? detailsRaw.peers
|
|
: []
|
|
/** @type {Map<string, Record<string, unknown>>} */
|
|
const byKey = new Map()
|
|
for (const d of details) {
|
|
if (!d || typeof d !== 'object') continue
|
|
const rk = typeof d.remotePublicKey === 'string' ? d.remotePublicKey : ''
|
|
const pid = typeof d.peerId === 'string' ? d.peerId : ''
|
|
if (rk) byKey.set('k:' + rk, d)
|
|
if (pid) byKey.set('p:' + pid, d)
|
|
}
|
|
const merged = []
|
|
for (const p of peers) {
|
|
if (!p || typeof p !== 'object') continue
|
|
const rk = typeof p.remotePublicKey === 'string' ? p.remotePublicKey : ''
|
|
const pid = typeof p.peerId === 'string' ? p.peerId : ''
|
|
const d = (rk && byKey.get('k:' + rk)) || (pid && byKey.get('p:' + pid)) || null
|
|
merged.push(d && typeof d === 'object' ? { ...p, ...d } : p)
|
|
}
|
|
out.peers = merged.slice(0, 128)
|
|
return out
|
|
}
|
|
|
|
async function bareP2pReadProcJson(ctx, path) {
|
|
const v = await bareP2pReadJson(ctx, path)
|
|
return v && typeof v === 'object' ? v : {}
|
|
}
|
|
|
|
function bareP2pFmtClock(ms) {
|
|
const d = new Date(typeof ms === 'number' ? ms : Date.now())
|
|
const z = (n) => (n < 10 ? '0' : '') + n
|
|
return z(d.getHours()) + ':' + z(d.getMinutes()) + ':' + z(d.getSeconds())
|
|
}
|
|
|
|
function bareP2pTruncate(s, cols) {
|
|
const t = String(s || '')
|
|
if (t.length <= cols) return t
|
|
return t.slice(0, Math.max(0, cols - 1)) + '\u2026'
|
|
}
|
|
|
|
/**
|
|
* Read-only TUI shell: q quits, r refreshes.
|
|
* @param {Record<string, unknown>} ctx
|
|
* @param {{ title: string, subtitle?: string, renderLines: () => string[] | Promise<string[]>, onRefresh?: () => void | Promise<void> }} opts
|
|
*/
|
|
async function bareP2pRunSimpleTui(ctx, opts) {
|
|
const stdin = ctx.replStdin
|
|
const stdout = bareEditResolveStdout(ctx)
|
|
if (!stdin || !stdout) {
|
|
ctx.console.error('p2p tui: missing stdin/stdout')
|
|
ctx.exitCode = 1
|
|
return
|
|
}
|
|
const useColor = bareEditUseColor(ctx)
|
|
const envEarly =
|
|
ctx.env && typeof ctx.env === 'object'
|
|
? /** @type {Record<string, string>} */ (ctx.env)
|
|
: {}
|
|
const cols0 = parseInt(envEarly.COLUMNS || '80', 10) || 80
|
|
const rows0 = parseInt(envEarly.LINES || '24', 10) || 24
|
|
const dims = () => ({
|
|
cols:
|
|
Math.max(48, /** @type {{ columns?: number }} */ (stdout).columns || cols0),
|
|
rows: Math.max(12, /** @type {{ rows?: number }} */ (stdout).rows || rows0)
|
|
})
|
|
|
|
const reader = bareEditCreateStdinReader(stdin)
|
|
let suspended = false
|
|
let alt = false
|
|
let loop = true
|
|
|
|
const draw = async () => {
|
|
const { cols, rows } = dims()
|
|
const lines = await opts.renderLines()
|
|
let out = '\x1b[?25l\x1b[2J\x1b[H'
|
|
const top =
|
|
bareEditSgr('status', useColor) +
|
|
bareP2pTruncate(' ' + opts.title + ' ', cols) +
|
|
EDIT_ANSI_RESET
|
|
out += bareEditCup(1, 1) + '\x1b[K' + top
|
|
const sub = bareP2pTruncate(
|
|
(opts.subtitle || 'q quit · r refresh') + ' · ' + bareP2pFmtClock(Date.now()),
|
|
cols
|
|
)
|
|
out +=
|
|
bareEditCup(2, 1) +
|
|
'\x1b[K' +
|
|
bareEditSgr('dim', useColor) +
|
|
sub +
|
|
EDIT_ANSI_RESET
|
|
out += bareEditCup(3, 1) + '\x1b[K'
|
|
const maxBody = Math.max(1, rows - 3)
|
|
for (let i = 0; i < maxBody; i++) {
|
|
const raw = i < lines.length ? String(lines[i]) : ''
|
|
out += bareEditCup(4 + i, 1) + '\x1b[K' + bareP2pTruncate(raw, cols)
|
|
}
|
|
bareEditWrite(ctx, stdout, out + '\x1b[?25h')
|
|
}
|
|
|
|
try {
|
|
if (typeof ctx.suspendReplForSubprocess === 'function') {
|
|
ctx.suspendReplForSubprocess()
|
|
suspended = true
|
|
}
|
|
if (typeof stdin.setRawMode === 'function') stdin.setRawMode(true)
|
|
if (typeof stdin.resume === 'function') stdin.resume()
|
|
bareEditWrite(ctx, stdout, '\x1b[?1049h')
|
|
alt = true
|
|
await draw()
|
|
while (loop) {
|
|
const ev = await bareEditReadKey(reader)
|
|
if (ev.type === 'eof') break
|
|
if (ev.type === 'ctrl') {
|
|
const code = typeof ev.code === 'number' ? ev.code : 0
|
|
if (ev.code === 'interrupt' || code === 3 || code === 17 || code === 24) {
|
|
break
|
|
}
|
|
}
|
|
if (ev.type === 'key' && ev.ch) {
|
|
if (ev.ch === 'q' || ev.ch === 'Q') break
|
|
if (ev.ch === 'r' || ev.ch === 'R') {
|
|
if (typeof opts.onRefresh === 'function') await opts.onRefresh()
|
|
await draw()
|
|
continue
|
|
}
|
|
}
|
|
await draw()
|
|
}
|
|
} finally {
|
|
try {
|
|
if (alt) bareEditWrite(ctx, stdout, '\x1b[?1049l')
|
|
bareEditWrite(ctx, stdout, '\x1b[?25h\x1b[0m')
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
reader.dispose()
|
|
try {
|
|
if (typeof stdin.setRawMode === 'function') stdin.setRawMode(false)
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
if (suspended && typeof ctx.resumeReplAfterSubprocess === 'function') {
|
|
ctx.resumeReplAfterSubprocess()
|
|
}
|
|
}
|
|
}
|
|
|
|
async function run(ctx, argv) {
|
|
const argv0 = argv[0] || 'routeview'
|
|
const args = argv.slice(1)
|
|
if (args.includes('-h') || args.includes('--help')) {
|
|
ctx.console.log(
|
|
'usage: ' + argv0 + '\n' + 'Route and relay visibility TUI.\n' + 'See man routeview.'
|
|
)
|
|
return
|
|
}
|
|
const stdin = ctx.replStdin
|
|
const isTTY = Boolean(stdin && /** @type {{ isTTY?: boolean }} */ (stdin).isTTY)
|
|
if (!isTTY) {
|
|
const r = await bareP2pReadProcJson(ctx, '/proc/bare_os/route_summary.json')
|
|
ctx.console.log(JSON.stringify(r, null, 2))
|
|
return
|
|
}
|
|
await bareP2pRunSimpleTui(ctx, {
|
|
title: 'routeview',
|
|
subtitle: 'direct vs relay summary · q quit · r refresh',
|
|
renderLines: async () => {
|
|
const r = await bareP2pReadProcJson(ctx, '/proc/bare_os/route_summary.json')
|
|
return [
|
|
'peers: ' + String(r.peers ?? '?'),
|
|
'directLikely: ' + String(r.directLikely ?? '?'),
|
|
'relayLikely: ' + String(r.relayLikely ?? '?'),
|
|
'',
|
|
'note: ' + String(r.note || '')
|
|
]
|
|
}
|
|
})
|
|
}
|