/** * 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 | 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 | 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 | 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} ctx * @param {number} maxBytes * @param {(m: string) => void} errFn * @returns {Promise} 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} ctx * @param {string[]} argv * @param {Record} 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 }