Files
bare-operating-system/packages/bare-os-seeder/kernel/bin/sed
T
2026-04-03 23:42:47 -04:00

991 lines
25 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
}
/**
* POSIX-oriented sed engine for Bare OS /bin/sed (no import; concatenated before src/sed.js).
* Covers: -n -e -f -E, addresses (#,$,/re/,n,m,n~s), s///[ngp0-9], y///, d D p P n N,
* h H g G x, b t :, q, r w, =, l, a i c (backslash forms), comments, hold space, line continuations.
*/
/**
* @param {string} delim
* @param {string} script
* @param {number} start
* @returns {{ raw: string, end: number } | null}
*/
function bareSedReadDelimited(delim, script, start) {
if (delim === '\n' || delim === '') return null
let i = start
let out = ''
while (i < script.length) {
const c = script[i]
if (c === '\\' && i + 1 < script.length) {
out += script[i + 1]
i += 2
continue
}
if (c === delim) return { raw: out, end: i + 1 }
out += c
i++
}
return null
}
/**
* @param {string} reStr
* @param {boolean} extended
*/
function bareSedCompileRegex(reStr, extended) {
let flags = extended ? 'u' : 'u'
let body = reStr
if (extended) {
body = body
.replace(/\(\?#[^)]*\)/g, '')
.replace(/\(\?:/g, '(')
.replace(/\+/g, '{1,}')
.replace(/\?/g, '{0,1}')
}
try {
return new RegExp(body, flags)
} catch {
return /$^/
}
}
/**
* @param {string} repl
* @param {string[]} caps
* @param {string} match
* @param {string} line
* @param {number} off
*/
function bareSedApplyReplacement(repl, caps, match, line, off) {
let o = ''
for (let i = 0; i < repl.length; i++) {
const c = repl[i]
if (c === '&') {
o += match
continue
}
if (c === '\\' && i + 1 < repl.length) {
const n = repl[i + 1]
if (n >= '1' && n <= '9') {
o += caps[Number(n)] || ''
i++
continue
}
if (n === '&') {
o += '&'
i++
continue
}
if (n === '\\') {
o += '\\'
i++
continue
}
o += n
i++
continue
}
o += c
}
return o
}
function bareSedListLine(s) {
let o = ''
for (let i = 0; i < s.length; i++) {
const code = s.charCodeAt(i)
if (code === 10) o += '\\n'
else if (code === 9) o += '\\t'
else if (code === 92) o += '\\\\'
else if (code < 32 || code > 126)
o += '\\' + code.toString(8).padStart(3, '0')
else o += s[i]
}
return o + '$'
}
/**
* @typedef {{ type: string, [k: string]: unknown }} BareSedCmd
*/
/**
* @param {string} script
* @param {boolean} extended
* @returns {BareSedCmd[]}
*/
function bareSedParseScript(script, extended) {
/** @type {BareSedCmd[]} */
const cmds = []
let i = 0
const len = script.length
function skipWs() {
while (i < len && /[ \t\r]/.test(script[i])) i++
}
function skipCommentLine() {
while (i < len && script[i] !== '\n') i++
if (i < len && script[i] === '\n') i++
}
function readAddr() {
skipWs()
if (i >= len) return null
const c = script[i]
if (c === '#') {
skipCommentLine()
return 'skip'
}
if (c === '0' && script[i + 1] >= '1' && script[i + 1] <= '9') {
/* fall through to number */
} else if (c >= '1' && c <= '9') {
let n = 0
while (i < len && script[i] >= '0' && script[i] <= '9') {
n = n * 10 + (script[i].charCodeAt(0) - 48)
i++
}
return { kind: 'num', n }
}
if (c === '$') {
i++
return { kind: 'last' }
}
if (c === '/' || c === '\\') {
let delim = c
let start = i + 1
if (c === '\\') {
delim = script[i + 1] || '/'
start = i + 2
}
const got = bareSedReadDelimited(delim, script, start)
if (!got) throw new Error('sed: unterminated address regex')
i = got.end
return { kind: 're', rx: bareSedCompileRegex(got.raw, extended) }
}
return null
}
function readAddrPair() {
const a = readAddr()
if (a === 'skip' || a === null) return a
skipWs()
if (i < len && script[i] === ',') {
i++
const b = readAddr()
if (b === 'skip' || b === null) throw new Error('sed: invalid address')
return { kind: 'range', a, b }
}
if (i < len && script[i] === '~') {
i++
let step = 0
while (i < len && script[i] >= '0' && script[i] <= '9') {
step = step * 10 + (script[i].charCodeAt(0) - 48)
i++
}
if (step < 1) step = 1
return { kind: 'step', a, step }
}
return a
}
while (i < len) {
skipWs()
if (i >= len) break
if (script[i] === '#' || (script[i] === '\n' && (i++, false))) {
if (script[i - 1] === '#') skipCommentLine()
else continue
continue
}
if (script[i] === ';') {
i++
continue
}
if (script[i] === '\n') {
i++
continue
}
let neg = false
if (script[i] === '!') {
neg = true
i++
skipWs()
}
const addr1 = readAddrPair()
if (addr1 === 'skip') continue
skipWs()
if (i >= len) break
const ch = script[i]
if (ch === '#') {
skipCommentLine()
continue
}
if (ch === ':') {
i++
let lab = ''
while (i < len && /[A-Za-z0-9_]/.test(script[i])) lab += script[i++]
cmds.push({ type: 'label', name: lab, neg, addr: addr1 })
continue
}
if (ch === 'b' || ch === 't') {
const ty = ch
i++
skipWs()
let lab = ''
while (i < len && /[A-Za-z0-9_]/.test(script[i])) lab += script[i++]
cmds.push({ type: ty, label: lab, neg, addr: addr1 })
continue
}
if (ch === 'r' || ch === 'w') {
const ty = ch
i++
skipWs()
let path = ''
while (i < len && script[i] !== '\n' && script[i] !== ';')
path += script[i++]
path = path.replace(/[ \t]+$/, '')
cmds.push({
type: ty === 'r' ? 'readFile' : 'writeFile',
path,
neg,
addr: addr1
})
continue
}
if (ch === 'a' || ch === 'i' || ch === 'c') {
const ty = ch
i++
skipWs()
if (i < len && script[i] === '\\') i++
let text = ''
while (i < len && script[i] !== '\n') text += script[i++]
if (i < len && script[i] === '\n') i++
while (i < len && script[i] === '\\') {
i++
let cont = ''
while (i < len && script[i] !== '\n') cont += script[i++]
text += '\n' + cont
if (i < len && script[i] === '\n') i++
}
cmds.push({
type: ty === 'a' ? 'append' : ty === 'i' ? 'insert' : 'change',
text,
neg,
addr: addr1
})
continue
}
if (ch === 's') {
i++
const delim = script[i++]
const pat = bareSedReadDelimited(delim, script, i)
if (!pat) throw new Error('sed: unterminated s command')
i = pat.end
const rep = bareSedReadDelimited(delim, script, i)
if (!rep) throw new Error('sed: unterminated s replacement')
i = rep.end
/** @type {{ g?: boolean, p?: boolean, n?: number }} */
const fl = {}
while (i < len && /[gpn0-9]/.test(script[i])) {
const f = script[i++]
if (f === 'g') fl.g = true
else if (f === 'p') fl.p = true
else if (f >= '1' && f <= '9') fl.n = Number(f)
}
cmds.push({
type: 'subst',
rx: bareSedCompileRegex(pat.raw, extended),
rep: rep.raw,
flags: fl,
neg,
addr: addr1
})
continue
}
if (ch === 'y') {
i++
const delim = script[i++]
const from = bareSedReadDelimited(delim, script, i)
if (!from) throw new Error('sed: unterminated y')
i = from.end
const to = bareSedReadDelimited(delim, script, i)
if (!to) throw new Error('sed: unterminated y')
i = to.end
if (from.raw.length !== to.raw.length)
throw new Error('sed: y strings must be same length')
cmds.push({ type: 'y', from: from.raw, to: to.raw, neg, addr: addr1 })
continue
}
const map = {
d: 'del',
D: 'delFirst',
p: 'print',
P: 'printFirst',
n: 'nextLine',
N: 'appendNext',
h: 'hold',
H: 'holdAppend',
g: 'get',
G: 'getAppend',
x: 'swap',
q: 'quit',
l: 'list',
'=': 'lineNum'
}
const ty = map[ch]
if (ty) {
i++
let count = 1
if (ty === 'quit' && i < len && script[i] >= '0' && script[i] <= '9') {
count = 0
while (i < len && script[i] >= '0' && script[i] <= '9') {
count = count * 10 + (script[i].charCodeAt(0) - 48)
i++
}
}
cmds.push({ type: ty, neg, addr: addr1, quitCode: count })
continue
}
throw new Error('sed: unknown command: ' + ch)
}
return cmds
}
/**
* @param {unknown} addr
* @param {number} lineNo
* @param {number} lastLine
* @param {string} ps
*/
function bareSedAddrSimple(addr, lineNo, lastLine, ps) {
if (addr == null) return true
if (typeof addr === 'object' && addr.kind === 'num') return lineNo === addr.n
if (typeof addr === 'object' && addr.kind === 'last')
return lineNo === lastLine
if (typeof addr === 'object' && addr.kind === 're') {
addr.rx.lastIndex = 0
return addr.rx.test(ps)
}
if (typeof addr === 'object' && addr.kind === 'step') {
const an = addr.a
if (typeof an === 'object' && an.kind === 'num') {
return lineNo >= an.n && (lineNo - an.n) % addr.step === 0
}
return false
}
return false
}
/**
* @param {unknown} addr
* @param {number} lineNo
* @param {number} lastLine
* @param {string} ps
* @param {Map<number, { active: boolean }>} rangeStates
* @param {number} cmdIndex
*/
function bareSedAddrMatchFull(
addr,
lineNo,
lastLine,
ps,
rangeStates,
cmdIndex
) {
if (addr == null) return true
if (typeof addr === 'object' && addr.kind === 'range') {
const a = addr.a
const b = addr.b
if (
typeof a === 'object' &&
a.kind === 'num' &&
typeof b === 'object' &&
b.kind === 'num'
) {
return lineNo >= a.n && lineNo <= b.n
}
if (
typeof a === 'object' &&
a.kind === 'num' &&
typeof b === 'object' &&
b.kind === 'last'
) {
return lineNo >= a.n && lineNo <= lastLine
}
if (
typeof a === 'object' &&
a.kind === 'last' &&
typeof b === 'object' &&
b.kind === 'num'
) {
return lineNo >= lastLine && lineNo <= b.n
}
if (
typeof a === 'object' &&
a.kind === 're' &&
typeof b === 'object' &&
b.kind === 're'
) {
let st = rangeStates.get(cmdIndex)
if (!st) {
st = { active: false }
rangeStates.set(cmdIndex, st)
}
a.rx.lastIndex = 0
b.rx.lastIndex = 0
const hitA = a.rx.test(ps)
const hitB = b.rx.test(ps)
if (!st.active && hitA) st.active = true
const inRange = st.active
if (st.active && hitB) st.active = false
return inRange
}
if (
typeof a === 'object' &&
a.kind === 'num' &&
typeof b === 'object' &&
b.kind === 're'
) {
let st = rangeStates.get(cmdIndex)
if (!st) {
st = { active: false }
rangeStates.set(cmdIndex, st)
}
if (lineNo === a.n) st.active = true
b.rx.lastIndex = 0
const hitB = b.rx.test(ps)
const inRange = st.active
if (st.active && hitB) st.active = false
return inRange
}
if (
typeof a === 'object' &&
a.kind === 're' &&
typeof b === 'object' &&
b.kind === 'num'
) {
let st = rangeStates.get(cmdIndex)
if (!st) {
st = { active: false }
rangeStates.set(cmdIndex, st)
}
a.rx.lastIndex = 0
if (!st.active && a.rx.test(ps)) st.active = true
const inRange = st.active
if (st.active && lineNo >= b.n) st.active = false
return inRange
}
return false
}
return bareSedAddrSimple(addr, lineNo, lastLine, ps)
}
function bareSedMatchAddr(
addr,
neg,
lineNo,
lastLine,
ps,
rangeStates,
cmdIndex
) {
const m = bareSedAddrMatchFull(
addr,
lineNo,
lastLine,
ps,
rangeStates,
cmdIndex
)
return neg ? !m : m
}
/**
* @param {string[]} lines
* @param {string[]} scripts
* @param {{ silent?: boolean, extended?: boolean, nullData?: boolean, readFile?: (p: string) => string | null, writeFile?: (p: string, chunk: string) => void, lastLineHint?: number }} opts
* @returns {string}
*/
function bareSedRun(lines, scripts, opts) {
const silent = !!opts.silent
const extended = !!opts.extended
const eol = opts.nullData ? '\0' : '\n'
const readF = opts.readFile || (() => null)
const writeF = opts.writeFile || (() => {})
const fullScript = scripts.join('\n')
const cmds = bareSedParseScript(fullScript.replace(/\\\n/g, ''), extended)
/** @type {Record<string, number>} */
const labels = {}
for (let ci = 0; ci < cmds.length; ci++) {
if (cmds[ci].type === 'label')
labels[/** @type {string} */ (cmds[ci].name)] = ci
}
const lastLine = opts.lastLineHint != null ? opts.lastLineHint : lines.length
/** @type {string[]} */
const out = []
let hold = ''
let quit = 0
let lastSubst = false
/** @type {Map<number, { active: boolean }>} */
const rangeStates = new Map()
function emit(s) {
out.push(s)
}
let lineIdx = 0
while (lineIdx < lines.length && quit === 0) {
let ps = lines[lineIdx]
const lineNo = lineIdx + 1
let autoPrint = !silent
let delLine = false
let nextRead = false
let ci = 0
while (ci < cmds.length && quit === 0) {
const cmd = cmds[ci]
if (cmd.type === 'label') {
ci++
continue
}
const addr = cmd.addr
if (
!bareSedMatchAddr(
addr,
!!cmd.neg,
lineNo,
lastLine,
ps,
rangeStates,
ci
)
) {
ci++
continue
}
switch (cmd.type) {
case 'subst': {
lastSubst = false
const rx = /** @type {RegExp} */ (cmd.rx)
const rep = /** @type {string} */ (cmd.rep)
const fl = /** @type {{ g?: boolean, p?: boolean, n?: number }} */ (
cmd.flags
)
let count = 0
let res = ''
let pos = 0
const g = !!fl.g
const wantN = fl.n != null ? fl.n : g ? Infinity : 1
let replCount = 0
rx.lastIndex = 0
let m
const str = ps
while ((m = rx.exec(str)) && replCount < wantN) {
res += str.slice(pos, m.index)
const caps = m.map((x) => (x == null ? '' : String(x)))
res += bareSedApplyReplacement(rep, caps, m[0], str, m.index)
pos = m.index + m[0].length
count++
replCount++
lastSubst = true
if (!g) break
if (m[0].length === 0) {
rx.lastIndex++
if (rx.lastIndex > str.length) break
}
}
if (count) {
ps = res + str.slice(pos)
if (fl.p) emit(ps + eol)
}
break
}
case 'y': {
const from = /** @type {string} */ (cmd.from)
const to = /** @type {string} */ (cmd.to)
const map = {}
for (let j = 0; j < from.length; j++) map[from[j]] = to[j]
let ns = ''
for (let j = 0; j < ps.length; j++)
ns += map[ps[j]] != null ? map[ps[j]] : ps[j]
ps = ns
break
}
case 'del':
delLine = true
autoPrint = false
break
case 'delFirst': {
const nl = ps.indexOf('\n')
if (nl === -1) {
delLine = true
autoPrint = false
} else ps = ps.slice(nl + 1)
ci = -1
break
}
case 'print':
emit(ps + eol)
break
case 'printFirst': {
const nl = ps.indexOf('\n')
emit((nl === -1 ? ps : ps.slice(0, nl)) + eol)
break
}
case 'nextLine':
if (autoPrint && !silent) emit(ps + eol)
lineIdx++
nextRead = true
ci = cmds.length
break
case 'appendNext':
lineIdx++
if (lineIdx < lines.length) ps += '\n' + lines[lineIdx]
else delLine = true
break
case 'hold':
hold = ps
break
case 'holdAppend':
hold += (hold ? '\n' : '') + ps
break
case 'get':
ps = hold
break
case 'getAppend':
ps += '\n' + hold
break
case 'swap': {
const t = ps
ps = hold
hold = t
break
}
case 'quit':
if (autoPrint && !silent) emit(ps + eol)
quit = /** @type {number} */ (cmd.quitCode) || 0
break
case 'list':
emit(bareSedListLine(ps) + eol)
break
case 'lineNum':
emit(String(lineNo) + eol)
break
case 'readFile': {
const text = readF(/** @type {string} */ (cmd.path))
if (text) emit(text.endsWith(eol) ? text : text + eol)
break
}
case 'writeFile':
writeF(/** @type {string} */ (cmd.path), ps + '\n')
break
case 'append':
emit(/** @type {string} */ (cmd.text) + eol)
break
case 'insert':
/* handled as emit before line — approximated by prepending to output before autoPrint */
out.push(/** @type {string} */ (cmd.text) + '\n')
break
case 'change':
autoPrint = false
emit(/** @type {string} */ (cmd.text) + eol)
delLine = true
break
case 'b': {
const lab = /** @type {string} */ (cmd.label)
if (lab && labels[lab] != null) ci = labels[lab]
break
}
case 't': {
if (lastSubst) {
const lab = /** @type {string} */ (cmd.label)
if (lab && labels[lab] != null) ci = labels[lab]
lastSubst = false
}
break
}
default:
break
}
ci++
if (delLine) break
if (nextRead) break
}
if (quit) break
if (nextRead) continue
if (!delLine && autoPrint) emit(ps + eol)
lineIdx++
}
return out.join('')
}
async function run(ctx, argv) {
let silent = false
let extended = false
let nullData = false
/** @type {string[]} */
const scripts = []
/** @type {string[]} */
const files = []
for (let i = 1; i < argv.length; i++) {
const a = argv[i]
if (a === '-n' || a === '--quiet' || a === '--silent') {
silent = true
continue
}
if (a === '-z' || a === '--null-data') {
nullData = true
continue
}
if (a === '-E' || a === '-r') {
extended = true
continue
}
if (a === '-e') {
scripts.push(argv[++i] || '')
continue
}
if (a === '-f') {
const path = argv[++i]
if (!path) {
ctx.console.error('sed: -f requires a file')
ctx.exitCode = 1
return
}
const buf = await ctx.vfs.readFile(path)
if (!buf) {
ctx.console.error('sed: cannot read ' + path)
ctx.exitCode = 1
return
}
scripts.push(ctx.b4a.toString(buf))
continue
}
if (a === '--') {
files.push(...argv.slice(i + 1))
break
}
if (a.startsWith('-') && a.length > 1) {
ctx.console.error('sed: unsupported option ' + a)
ctx.exitCode = 1
return
}
if (!scripts.length) {
scripts.push(a)
files.push(...argv.slice(i + 1))
} else {
files.push(...argv.slice(i))
}
break
}
if (!scripts.length) {
ctx.console.error('usage: sed [-n] [-E] {-e script | -f file} [file...]')
ctx.exitCode = 1
return
}
function bareSedCollectRPaths(scList) {
const set = new Set()
const s = scList.join('\n')
const m0 = /^\s*r\s+([^\n;]+)/.exec(s)
if (m0) set.add(m0[1].trim())
let i = 0
while (i < s.length) {
const j = s.indexOf('\nr', i)
const k = s.indexOf(';r', i)
let hit = -1
if (j >= 0 && (k < 0 || j <= k)) hit = j + 1
else if (k >= 0) hit = k + 1
if (hit < 0) break
let p = hit + 1
while (p < s.length && /[ \t]/.test(s[p])) p++
let end = p
while (end < s.length && s[end] !== '\n' && s[end] !== ';') end++
const path = s.slice(p, end).trim()
if (path) set.add(path)
i = end
}
return [...set]
}
/** @type {Record<string, string>} */
const readCache = Object.create(null)
for (const rp of bareSedCollectRPaths(scripts)) {
const buf = await ctx.vfs.readFile(rp)
readCache[rp] = buf ? ctx.b4a.toString(buf) : ''
}
const maxNull =
Number.parseInt(
String(ctx.vfs?.env?.BARE_OS_SED_NULL_MAX_RECORDS || '100000'),
10
) || 100000
/** @type {string[]} */
const lines = []
async function pushFile(path) {
const buf = await ctx.vfs.readFile(path)
if (!buf) {
ctx.console.error('sed: ' + path + ': No such file')
ctx.exitCode = 1
return false
}
const t = ctx.b4a.toString(buf)
if (nullData) {
const rec = t.split('\0')
const room = maxNull - lines.length
lines.push(...rec.slice(0, Math.max(0, room)))
} else {
const ls = t.split(/\r?\n/)
if (ls.length && ls[ls.length - 1] === '') ls.pop()
lines.push(...ls)
}
return true
}
if (!files.length) {
const s = bareStdin(ctx)
const ls = nullData ? s.split('\0').slice(0, maxNull) : s.split(/\r?\n/)
if (!nullData && ls.length && ls[ls.length - 1] === '') ls.pop()
lines.push(...ls)
} else {
for (const f of files) {
if (!(await pushFile(f))) return
}
}
/** @type {Record<string, string>} */
const wAccum = Object.create(null)
const out = bareSedRun(lines, scripts, {
silent,
extended,
nullData,
readFile: (p) => readCache[p] ?? null,
writeFile: (p, chunk) => {
wAccum[p] = (wAccum[p] || '') + chunk
},
lastLineHint: lines.length
})
for (const [p, data] of Object.entries(wAccum)) {
try {
const prev = await ctx.vfs.readFile(p)
const merged = prev
? ctx.b4a.concat([prev, ctx.b4a.from(data)])
: ctx.b4a.from(data)
await ctx.vfs.writeFile(p, merged)
} catch (e) {
ctx.console.error('sed: ' + p + ': ' + ((e && e.message) || e))
ctx.exitCode = 1
}
}
const trail = nullData ? /\0$/ : /\n$/
const t = out.replace(trail, '')
ctx.console.log(t)
}