This commit is contained in:
2026-08-18 18:11:28 -04:00
parent 0e9a650fa2
commit bbaf47028f
259 changed files with 0 additions and 0 deletions
@@ -0,0 +1,224 @@
/**
* Shell builtin name table and optional POSIX `read` builtin.
*/
export const SHELL_BUILTINS = new Set([
'alias',
'unalias',
'barerc',
'cd',
'export',
'unset',
'readonly',
'umask',
'set',
':',
'command',
'type',
'logout',
'exit',
'jobs',
'fg',
'bg',
'wait',
'suspend-job',
'disown',
'trap',
'test',
'['
])
/**
* Optional POSIX-style **`read`** builtin (bounded line, IFS split). Off by default.
* @param {Record<string, string | undefined> | null | undefined} env
*/
export function bareOsShellReadBuiltinEnabled(env) {
const o = env && typeof env === 'object' ? env : {}
return o.BARE_OS_SHELL_READ_BUILTIN === '1' || o.BARE_OS_SHELL_READ_BUILTIN === 'true'
}
/**
* Sorted list of shell builtin command names for completion / UX.
* Includes **`read`** only when {@link bareOsShellReadBuiltinEnabled} is true.
* @param {Record<string, string | undefined> | null | undefined} env
* @returns {string[]}
*/
export function listBareOsShellBuiltins(env) {
const out = [...SHELL_BUILTINS]
if (bareOsShellReadBuiltinEnabled(env)) out.push('read')
out.sort()
return out
}
/**
* @param {string} cmd
* @param {Record<string, string | undefined> | null | undefined} env
*/
export function isShellBuiltin(cmd, env) {
if (SHELL_BUILTINS.has(cmd)) return true
if (cmd === 'read' && bareOsShellReadBuiltinEnabled(env)) return true
return false
}
/**
* @param {string} line
* @param {string} ifs
* @param {number} nNames
*/
export function bareOsShellReadSplitFields(line, ifs, nNames) {
const sep = ifs.length ? ifs[0] : ' '
if (nNames <= 1) return [line]
const out = []
let rest = line
for (let i = 0; i < nNames - 1; i++) {
const idx = rest.indexOf(sep)
if (idx === -1) {
out.push(rest)
rest = ''
break
}
out.push(rest.slice(0, idx))
rest = rest.slice(idx + sep.length)
}
while (out.length < nNames - 1) out.push('')
out.push(rest)
return out
}
/**
* @param {Record<string, unknown>} ctx
* @param {number} maxBytes
* @param {(m: string) => void} errFn
* @returns {Promise<string | null>} null = EOF / error
*/
export async function bareOsShellReadOneLine(ctx, maxBytes, errFn, opts = {}) {
const delimiter = typeof opts.delimiter === 'string' ? opts.delimiter : '\n'
const timeoutMs = Number.isFinite(opts.timeoutMs) ? Number(opts.timeoutMs) : 0
if (typeof ctx.shellStdin === 'string') {
const raw = ctx.shellStdin
const idx = delimiter ? raw.indexOf(delimiter) : -1
const line = idx === -1 ? raw : raw.slice(0, idx)
ctx.shellStdin = idx === -1 ? '' : raw.slice(idx + delimiter.length)
if (line.length > maxBytes) {
errFn(`read: line exceeds BARE_OS_SHELL_READ_MAX_BYTES (${maxBytes})`)
return null
}
return line
}
const rl = ctx.readLine
if (typeof rl === 'function') {
const readP = rl('')
const ln =
timeoutMs > 0
? await Promise.race([
readP,
new Promise((resolve) => setTimeout(() => resolve(null), timeoutMs))
])
: await readP
if (ln == null) return null
if (ln.length > maxBytes) {
errFn(`read: line exceeds BARE_OS_SHELL_READ_MAX_BYTES (${maxBytes})`)
return null
}
return ln
}
errFn(
'read: no input (redirect stdin, use a pipeline, or interactive readLine)'
)
return null
}
/**
* @param {Record<string, unknown>} ctx
* @param {string[]} argv
* @param {Record<string, string>} env
* @param {(m: string) => void} origErr
*/
export async function runShellReadBuiltin(ctx, argv, env, origErr) {
let i = 1
let rawMode = false
let delimiter = '\n'
let timeoutMs = 0
while (i < argv.length && argv[i].startsWith('-')) {
const a = argv[i]
if (a === '-r') {
rawMode = true
}
else if (a === '-d') {
const d = argv[i + 1]
if (d == null) {
origErr.call(ctx.console, 'read: option requires an argument -- d')
ctx.exitCode = 2
return
}
delimiter = String(d).slice(0, 1)
i++
}
else if (a === '-t') {
const v = argv[i + 1]
if (v == null) {
origErr.call(ctx.console, 'read: option requires an argument -- t')
ctx.exitCode = 2
return
}
const n = Number.parseFloat(String(v))
if (!Number.isFinite(n) || n < 0) {
origErr.call(ctx.console, 'read: invalid timeout: ' + String(v))
ctx.exitCode = 2
return
}
timeoutMs = Math.min(Math.floor(n * 1000), 120000)
i++
}
else if (a === '--') {
i++
break
} else {
origErr.call(ctx.console, 'read: unsupported option: ' + a)
ctx.exitCode = 2
return
}
i++
}
const names = argv.slice(i).filter(Boolean)
if (!names.length) names.push('REPLY')
const maxRaw = env.BARE_OS_SHELL_READ_MAX_BYTES
const maxParsed =
maxRaw != null && String(maxRaw).trim() !== ''
? Number.parseInt(String(maxRaw), 10)
: 65536
const maxBytes =
Number.isFinite(maxParsed) && maxParsed > 0
? Math.min(maxParsed, 2_000_000)
: 65536
let line = await bareOsShellReadOneLine(
ctx,
maxBytes,
(m) => origErr.call(ctx.console, m),
{ delimiter, timeoutMs }
)
if (line === null) {
for (const n of names) env[n] = ''
ctx.exitCode = 1
return
}
if (!rawMode) {
line = line.replace(/\\(.)/g, '$1')
}
const ifs =
env.IFS !== undefined && env.IFS !== null ? String(env.IFS) : ' \t\n'
const fields = bareOsShellReadSplitFields(line, ifs, names.length)
for (let j = 0; j < names.length; j++) {
const k = names[j]
if (
ctx.shellReadonlyVars instanceof Set &&
ctx.shellReadonlyVars.has(k)
) {
origErr.call(ctx.console, k + ': readonly variable')
ctx.exitCode = 1
return
}
env[k] = fields[j] ?? ''
}
ctx.exitCode = 0
}