Files
bare-operating-system/packages/bare-os-booter/lib/shell/shell.js
T
2026-08-18 18:11:34 -04:00

2037 lines
65 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Shell implementation (tokenizer slice in {@link ./shell-tokenizer.js}; planner/executor here).
*/
import { runBinCommand, resolveBinInPath } from '../boot/kernel-runner.js'
import { tokenizeBareShellLineForDiagnostics } from './shell-tokenizer.js'
import { tokenizeBareShellLineDetailed } from './shell-tokenizer.js'
export { tokenizeBareShellLineForDiagnostics }
export { tokenizeBareShellLineDetailed }
import { bareOsKernelMetricSet } from '../boot/bare-os-kernel-metrics.js'
import { BARE_OS_SHELL_NOUNSET_ERROR } from './shell-nounset.js'
import { bareOsEvalArithmeticExpr } from './shell-arithmetic.js'
import {
isExecLineBuiltinDenied,
shellCommandDeniedByPolicy,
shellUnsafeRedirectPath
} from './shell-policy.js'
import {
collapseShellLineContinuations,
splitTopLevelStatements,
splitTopLevelByAmpersand,
findThenIndex,
findElseOrFiAfterThen,
findFiAfterElse,
findWhileDoSplit,
isValidShellIdentifier,
parseShellFunctionDeclaration,
casePatternMatches
} from './shell-syntax.js'
import { tokenize } from './shell-token.js'
import {
defaultShellAliases,
expandArgvAliases,
applyAliasDefinition,
runUnaliasBuiltin,
loadBarerc
} from './shell-alias.js'
import {
parsePipeline,
splitTokensBySemicolon,
splitTokensByAndOr,
segmentHasCommand,
buildShellExecutionGraph
} from './shell-parse.js'
import {
isShellBuiltin,
runShellReadBuiltin
} from './shell-builtins.js'
import {
syncBareOsExitStatusEnv,
getBareOsPipelineLimits,
waitWhileShellJobStopped,
appendShellAuditEvent,
mergePipelineChildCtx
} from './shell-runtime.js'
import { expandWord, expandShellWordTokens } from './shell-expand.js'
import {
bareOsPipelineChildCtx,
bareOsPipelineRawChunkToText,
runWithShellPipelineStageTimeout
} from './shell-pipeline.js'
import {
execShellLocalBuiltin,
tryExecShellDeclareBuiltin,
tryReportMisplacedReservedStatementStart,
execDoubleBracketLimited,
assignShellFunctionPositionalEnv,
casePatternList,
consumeInteractiveHeredoc
} from './shell-stmt.js'
export { BARE_OS_SHELL_NOUNSET_ERROR }
export {
bareOsShellReadBuiltinEnabled,
listBareOsShellBuiltins
} from './shell-builtins.js'
export {
BARE_OS_EXIT_STATUS_ENV,
syncBareOsExitStatusEnv,
DEFAULT_PIPELINE_MAX_STAGES,
DEFAULT_PIPELINE_MAX_CAPTURE_BYTES,
DEFAULT_PIPELINE_MAX_CAPTURE_LINES,
getBareOsPipelineLimits
} from './shell-runtime.js'
export { expandWord } from './shell-expand.js'
export { tokenize, shellWordText } from './shell-token.js'
export {
defaultShellAliases,
expandArgvAliases,
applyAliasDefinition,
runUnaliasBuiltin,
BARERC_SKELETON,
loadBarerc
} from './shell-alias.js'
export {
parsePipeline,
bareOsShellError,
bareOsShellAstSnapshot,
splitTokensBySemicolon,
splitTokensByAndOr,
planShellRedirections,
buildShellExecutionGraph
} from './shell-parse.js'
export { bareOsResetShellIdentityState } from './shell-stmt.js'
/** Shell planner/executor continues below. */
/**
* @param {string} signal
*/
function normalizeShellSignalName(signal) {
return String(signal || '')
.trim()
.replace(/^SIG/i, '')
.toUpperCase()
}
/**
* Execute a registered trap handler for a signal, when present.
* @param {Record<string, unknown>} ctx
* @param {string} signal
* @returns {Promise<boolean>} true when a trap ran
*/
export async function dispatchShellTrapSignal(ctx, signal) {
const sig = normalizeShellSignalName(signal)
if (!sig) return false
const handlers =
ctx.shellTrapHandlers && typeof ctx.shellTrapHandlers === 'object'
? /** @type {Record<string, string>} */ (ctx.shellTrapHandlers)
: null
if (!handlers || !handlers[sig]) return false
const cmd = String(handlers[sig] || '').trim()
if (!cmd) return false
await execShellLine(ctx, cmd)
return true
}
/**
* @param {Record<string, unknown>} ctx
* @param {SimpleCmd[]} pipeline
* @returns {Promise<'exit' | 'ok'>}
*/
async function execParsedPipeline(ctx, pipeline) {
const vfs = ctx.vfs
const env = vfs.env
const lim = getBareOsPipelineLimits(env)
if (pipeline.length > lim.maxStages) {
ctx.console.error(
`shell: pipeline exceeds BARE_OS_PIPELINE_MAX_STAGES (${lim.maxStages})`
)
ctx.exitCode = 1
return 'ok'
}
try {
bareOsKernelMetricSet('shell.pipeline_last_stages', pipeline.length)
} catch {
/* ignore */
}
let stdinText = typeof ctx.shellStdin === 'string' ? ctx.shellStdin : null
/** Last completed pipeline stage exit (POSIX default: status of last stage; optional pipefail). */
let pipelineLastExit = 0
/** True when **`BARE_OS_SHELL_PIPEFAIL`** is already set or set by a stage prefix in this pipeline. */
let pipelineWantsPipefail =
env.BARE_OS_SHELL_PIPEFAIL === '1' ||
env.BARE_OS_SHELL_PIPEFAIL === 'true'
/** First non-zero stage exit when pipefail is active (handbook: first failing stage wins). */
let pipefailFirstNonZero = 0
/** Stage exit codes for optional `BARE_OS_PIPESTATUS` (space-separated). */
const pipelineStageExits = []
const recordStageExit = (code) => {
const c = Number(code) || 0
pipelineStageExits.push(c)
pipelineLastExit = c
if (pipelineWantsPipefail && pipefailFirstNonZero === 0 && c !== 0) {
pipefailFirstNonZero = c
}
}
try {
for (let pi = 0; pi < pipeline.length; pi++) {
const cmd = pipeline[pi]
const isLast = pi === pipeline.length - 1
const origLog = ctx.console.log
const origErr = ctx.console.error
if (!cmd.argv.length && !Object.keys(cmd.assign).length) continue
if (!ctx.shellAliases) ctx.shellAliases = { ...defaultShellAliases() }
/** @type {string[]} */
let argv = []
for (let wi = 0; wi < cmd.argv.length; wi++) {
const wTok = cmd.argv[wi]
const exprMulCompat =
wi > 0 &&
argv[0] === 'expr' &&
wTok.value === '*' &&
Array.isArray(wTok.parts) &&
wTok.parts.length === 1 &&
wTok.parts[0].q === 'u' &&
wTok.parts[0].t === '*'
const xs = await expandShellWordTokens(ctx, wTok, env, {
disablePathnameExpansion: exprMulCompat
})
if (
xs.length === 0 &&
(env.BARE_OS_STRICT_POSIX === '1' ||
env.BARE_OS_STRICT_POSIX === 'true')
) {
origErr.call(
ctx.console,
'shell: pathname expansion produced no matches (strict POSIX)'
)
ctx.exitCode = 1
argv = []
break
}
for (const x of xs) argv.push(x)
}
if (argv.length === 0 && cmd.argv.length) {
continue
}
try {
argv = expandArgvAliases(argv, ctx.shellAliases)
} catch (e) {
ctx.console.error((e && e.message) || String(e))
ctx.exitCode = 1
continue
}
const name = argv[0]
appendShellAuditEvent(ctx, 'shell.command.start', {
command: name,
argv: argv.slice(0, 16),
stage: pi
})
if (shellCommandDeniedByPolicy(name, env)) {
origErr.call(ctx.console, 'shell: command denied by policy: ' + name)
ctx.exitCode = 126
recordStageExit(ctx.exitCode)
appendShellAuditEvent(ctx, 'shell.command.error', {
command: name,
stage: pi,
reason: 'policy_deny'
})
continue
}
const sandboxOn =
env.BARE_OS_SHELL_SANDBOX === '1' || env.BARE_OS_SHELL_SANDBOX === 'true'
if (sandboxOn && !isShellBuiltin(name, env) && name !== 'command' && name !== 'type') {
origErr.call(ctx.console, 'shell: sandbox blocks external command: ' + name)
ctx.exitCode = 126
recordStageExit(ctx.exitCode)
appendShellAuditEvent(ctx, 'shell.command.error', {
command: name,
stage: pi,
reason: 'sandbox_block'
})
continue
}
/** @type {string | null} */
let resolvedRedirOut = null
/** @type {string | null} */
let resolvedRedirErr = null
if (cmd.redirOut) {
const ps = await expandShellWordTokens(ctx, cmd.redirOut, env, {
redirect: true
})
if (ps.length === 0) {
origErr.call(ctx.console, 'shell: stdout redirect: no match')
ctx.exitCode = 1
continue
}
if (ps.length > 1) {
origErr.call(ctx.console, 'shell: stdout redirect: ambiguous')
ctx.exitCode = 1
continue
}
resolvedRedirOut = ps[0]
if (shellUnsafeRedirectPath(resolvedRedirOut, env)) {
origErr.call(ctx.console, 'shell: unsafe stdout redirect path denied')
ctx.exitCode = 1
recordStageExit(ctx.exitCode)
appendShellAuditEvent(ctx, 'shell.command.error', {
command: name,
stage: pi,
reason: 'unsafe_redirect_stdout',
path: resolvedRedirOut
})
continue
}
}
if (cmd.redirErr && !cmd.mergeStderrToStdout) {
const ps = await expandShellWordTokens(ctx, cmd.redirErr, env, {
redirect: true
})
if (ps.length === 0) {
origErr.call(ctx.console, 'shell: stderr redirect: no match')
ctx.exitCode = 1
continue
}
if (ps.length > 1) {
origErr.call(ctx.console, 'shell: stderr redirect: ambiguous')
ctx.exitCode = 1
continue
}
resolvedRedirErr = ps[0]
if (shellUnsafeRedirectPath(resolvedRedirErr, env)) {
origErr.call(ctx.console, 'shell: unsafe stderr redirect path denied')
ctx.exitCode = 1
recordStageExit(ctx.exitCode)
appendShellAuditEvent(ctx, 'shell.command.error', {
command: name,
stage: pi,
reason: 'unsafe_redirect_stderr',
path: resolvedRedirErr
})
continue
}
}
if (cmd.redirHereDoc) {
stdinText = expandWord(cmd.redirHereDoc, env)
} else if (cmd.redirIn) {
const paths = await expandShellWordTokens(ctx, cmd.redirIn, env, {
redirect: true
})
if (paths.length === 0) {
origErr.call(ctx.console, 'shell: stdin redirect: no match')
ctx.exitCode = 1
continue
}
if (paths.length > 1) {
origErr.call(ctx.console, 'shell: stdin redirect: ambiguous')
ctx.exitCode = 1
continue
}
if (shellUnsafeRedirectPath(paths[0], env)) {
origErr.call(ctx.console, 'shell: unsafe stdin redirect path denied')
ctx.exitCode = 1
recordStageExit(ctx.exitCode)
appendShellAuditEvent(ctx, 'shell.command.error', {
command: name,
stage: pi,
reason: 'unsafe_redirect_stdin',
path: paths[0]
})
continue
}
const buf = await vfs.readFile(paths[0])
stdinText = buf ? ctx.b4a.toString(buf) : ''
} else if (pi === 0 && typeof ctx.shellHeredocOnce === 'string') {
stdinText = ctx.shellHeredocOnce
delete ctx.shellHeredocOnce
}
const outChunks = []
const errChunks = []
const capOut = !isLast || cmd.redirOut != null
const mergeErr = cmd.mergeStderrToStdout
const capErrSeparate = cmd.redirErr != null && !mergeErr
const checkCaptureSize = (chunks) => {
const joined = chunks.join('')
if (joined.length > lim.maxBytes) {
throw new Error(
`shell: pipeline output exceeds BARE_OS_PIPELINE_MAX_BYTES (${lim.maxBytes})`
)
}
const lineCount = joined.split('\n').length - 1
if (lineCount > lim.maxLines) {
throw new Error(
`shell: pipeline output exceeds BARE_OS_PIPELINE_MAX_LINES (${lim.maxLines})`
)
}
}
const pushOut = (...args) => {
const line = args.map(String).join(' ') + '\n'
outChunks.push(line)
const st = ctx.bareOsSessionStats
if (st && typeof st.pipelineBytesTotal === 'number') {
st.pipelineBytesTotal += line.length
}
checkCaptureSize(outChunks)
}
/** @param {string | Uint8Array} chunk */
const pushOutRaw = (chunk) => {
const text = bareOsPipelineRawChunkToText(chunk)
outChunks.push(text)
const st = ctx.bareOsSessionStats
if (st && typeof st.pipelineBytesTotal === 'number') {
st.pipelineBytesTotal += text.length
}
checkCaptureSize(outChunks)
}
const pushErr = (...args) => {
const line = args.map(String).join(' ') + '\n'
errChunks.push(line)
const st = ctx.bareOsSessionStats
if (st && typeof st.pipelineBytesTotal === 'number') {
st.pipelineBytesTotal += line.length
}
checkCaptureSize(errChunks)
}
for (const [k, val] of Object.entries(cmd.assign)) {
if (
ctx.shellReadonlyVars instanceof Set &&
ctx.shellReadonlyVars.has(k)
) {
origErr.call(ctx.console, k + ': readonly variable')
ctx.exitCode = 1
continue
}
env[k] = expandWord(val, env)
}
if (
env.BARE_OS_SHELL_PIPEFAIL === '1' ||
env.BARE_OS_SHELL_PIPEFAIL === 'true'
) {
pipelineWantsPipefail = true
}
if (!cmd.argv.length) {
ctx.exitCode = 0
continue
}
ctx.exitCode = 0
if (capOut) {
ctx.console.log = pushOut
if (mergeErr) ctx.console.error = pushOut
else if (capErrSeparate) ctx.console.error = pushErr
else ctx.console.error = origErr
} else {
ctx.console.log = origLog
if (capErrSeparate) ctx.console.error = pushErr
else if (mergeErr) ctx.console.error = origLog
else ctx.console.error = origErr
}
let code = 'ok'
try {
if (
ctx.shellFunctions &&
typeof ctx.shellFunctions === 'object' &&
Object.prototype.hasOwnProperty.call(ctx.shellFunctions, name)
) {
const fn = ctx.shellFunctions[name]
const maxDepth = Number.parseInt(
String(env.BARE_OS_SHELL_FUNCTION_MAX_DEPTH || '32'),
10
)
const cap =
Number.isFinite(maxDepth) && maxDepth > 0 ? Math.min(maxDepth, 128) : 32
const curDepth =
typeof ctx.shellFunctionDepth === 'number' ? ctx.shellFunctionDepth : 0
if (curDepth >= cap) {
origErr.call(
ctx.console,
`shell: function recursion too deep (max ${cap})`
)
ctx.exitCode = 1
continue
}
const prevEnv = vfs.env
const fnEnv = env
const prevPositional = {}
for (const k of ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '#']) {
prevPositional[k] = fnEnv[k]
}
assignShellFunctionPositionalEnv(fnEnv, argv)
vfs.env = fnEnv
ctx.env = fnEnv
ctx.shellFunctionDepth = curDepth + 1
try {
const fnResult = await runWithShellPipelineStageTimeout(
env,
() => execSemicolonLists(ctx, fn.body),
`function ${name}`
)
if (fnResult === 'exit') code = 'exit'
} finally {
for (const [k, v] of Object.entries(prevPositional)) {
if (v == null) delete fnEnv[k]
else fnEnv[k] = v
}
ctx.shellFunctionDepth = curDepth
vfs.env = prevEnv
ctx.env = prevEnv
}
continue
}
if (isShellBuiltin(name, env) && isExecLineBuiltinDenied(name, env)) {
origErr.call(
ctx.console,
'shell: builtin denied by boot policy: ' + name
)
ctx.exitCode = 126
continue
}
if (name === 'alias') {
if (argv.length === 1) {
const al = ctx.shellAliases || {}
for (const k of Object.keys(al).sort()) {
origLog.call(ctx.console, `${k}='${al[k]}'`)
}
} else {
let okCount = 0
for (const part of argv.slice(1)) {
if (applyAliasDefinition(ctx, part)) okCount++
}
if (okCount === 0) {
origErr.call(
ctx.console,
'alias: usage: alias name=value [name=value ...]'
)
ctx.exitCode = 1
}
}
} else if (name === 'unalias') {
runUnaliasBuiltin(ctx, argv, (m) => {
origErr.call(ctx.console, m)
ctx.exitCode = 1
})
} else if (name === 'barerc') {
const sub = argv[1]
if (sub === 'reload') {
await loadBarerc(ctx, { createSkeletonIfMissing: false })
} else {
origErr.call(ctx.console, 'barerc: usage: barerc reload')
ctx.exitCode = 1
}
} else if (name === 'cd') {
try {
await vfs.chdir(argv[1] || vfs.home)
} catch (e) {
origErr.call(ctx.console, (e && e.message) || String(e))
ctx.exitCode = 1
}
} else if (name === 'export') {
const args = argv.slice(1)
if (args.length === 1 && args[0] === '-p') {
const keys = Object.keys(env).sort((a, b) => a.localeCompare(b))
for (const k of keys) {
const v = String(env[k] ?? '')
const q = "'" + v.replace(/'/g, "'\\''") + "'"
origLog.call(ctx.console, 'export ' + k + '=' + q)
}
} else {
let sawErr = false
for (const a of args) {
if (a === '-p') {
origErr.call(
ctx.console,
'export: -p must be the only argument'
)
ctx.exitCode = 2
sawErr = true
break
}
if (a.startsWith('-')) {
origErr.call(ctx.console, 'export: unsupported option: ' + a)
ctx.exitCode = 2
sawErr = true
break
}
const eq = a.indexOf('=')
if (eq > 0) {
const k = a.slice(0, eq)
if (!isValidShellIdentifier(k)) {
origErr.call(ctx.console, `export: not an identifier: ${k}`)
ctx.exitCode = 1
sawErr = true
continue
}
if (
ctx.shellReadonlyVars instanceof Set &&
ctx.shellReadonlyVars.has(k)
) {
origErr.call(ctx.console, k + ': readonly variable')
ctx.exitCode = 1
sawErr = true
continue
}
env[k] = expandWord(a.slice(eq + 1), env)
} else {
if (!isValidShellIdentifier(a)) {
origErr.call(ctx.console, `export: not an identifier: ${a}`)
ctx.exitCode = 1
sawErr = true
continue
}
if (!Object.prototype.hasOwnProperty.call(env, a)) env[a] = ''
}
}
if (!sawErr) ctx.exitCode = 0
}
} else if (name === 'unset') {
if (!ctx.shellReadonlyVars) ctx.shellReadonlyVars = new Set()
for (const a of argv.slice(1)) {
if (a.startsWith('-')) continue
if (ctx.shellReadonlyVars.has(a)) {
origErr.call(
ctx.console,
'unset: ' + a + ': cannot unset: readonly variable'
)
ctx.exitCode = 1
continue
}
delete env[a]
}
} else if (name === 'readonly') {
if (!ctx.shellReadonlyVars) ctx.shellReadonlyVars = new Set()
const args = argv.slice(1)
if (args.length === 1 && args[0] === '-p') {
const keys = [...ctx.shellReadonlyVars].sort((a, b) =>
a.localeCompare(b)
)
for (const k of keys) {
const v = String(env[k] ?? '')
const q = "'" + v.replace(/'/g, "'\\''") + "'"
origLog.call(ctx.console, 'readonly ' + k + '=' + q)
}
} else {
for (const a of args) {
if (a === '-p') {
origErr.call(
ctx.console,
'readonly: -p must be the only argument'
)
ctx.exitCode = 2
break
}
if (a.startsWith('-')) {
origErr.call(ctx.console, 'readonly: unsupported option: ' + a)
ctx.exitCode = 2
break
}
const eq = a.indexOf('=')
if (eq > 0) {
const k = a.slice(0, eq)
env[k] = expandWord(a.slice(eq + 1), env)
ctx.shellReadonlyVars.add(k)
} else ctx.shellReadonlyVars.add(a)
}
}
} else if (name === 'umask') {
if (argv[1] != null) {
const oct = argv[1]
if (!/^[0-7]{1,4}$/.test(oct)) {
origErr.call(ctx.console, 'umask: invalid octal mask')
ctx.exitCode = 1
} else {
env.UMASK = oct
}
} else {
const u = env.UMASK || '022'
origLog.call(ctx.console, String(u).padStart(4, '0'))
}
} else if (name === 'set') {
const args = argv.slice(1)
if (
args.length === 1 &&
(args[0] === '-o' || args[0] === '+o')
) {
const on = (v) =>
v === '1' || v === 'true' ? 'on' : 'off'
origLog.call(
ctx.console,
`errexit ${on(env.BARE_OS_SHELL_ERREXIT)}`
)
origLog.call(
ctx.console,
`nounset ${on(env.BARE_OS_SHELL_NOUNSET)}`
)
origLog.call(
ctx.console,
`pipefail ${on(env.BARE_OS_SHELL_PIPEFAIL)}`
)
origLog.call(
ctx.console,
`noglob ${on(env.BARE_OS_SHELL_NOGLOB)}`
)
} else if (args.length === 1 && args[0] === '-f') {
env.BARE_OS_SHELL_NOGLOB = '1'
} else if (args.length === 1 && args[0] === '+f') {
delete env.BARE_OS_SHELL_NOGLOB
} else if (
args.length === 2 &&
args[0] === '-o' &&
args[1] === 'errexit'
) {
env.BARE_OS_SHELL_ERREXIT = '1'
} else if (
args.length === 2 &&
args[0] === '+o' &&
args[1] === 'errexit'
) {
delete env.BARE_OS_SHELL_ERREXIT
} else if (args.length === 1 && args[0] === '-e') {
env.BARE_OS_SHELL_ERREXIT = '1'
} else if (args.length === 1 && args[0] === '+e') {
delete env.BARE_OS_SHELL_ERREXIT
} else if (
args.length === 2 &&
args[0] === '-o' &&
args[1] === 'nounset'
) {
env.BARE_OS_SHELL_NOUNSET = '1'
} else if (
args.length === 2 &&
args[0] === '+o' &&
args[1] === 'nounset'
) {
delete env.BARE_OS_SHELL_NOUNSET
} else if (args.length === 1 && args[0] === '-u') {
env.BARE_OS_SHELL_NOUNSET = '1'
} else if (args.length === 1 && args[0] === '+u') {
delete env.BARE_OS_SHELL_NOUNSET
} else if (
args.length === 2 &&
args[0] === '-o' &&
args[1] === 'pipefail'
) {
env.BARE_OS_SHELL_PIPEFAIL = '1'
} else if (
args.length === 2 &&
args[0] === '+o' &&
args[1] === 'pipefail'
) {
delete env.BARE_OS_SHELL_PIPEFAIL
} else {
origErr.call(
ctx.console,
'set: unsupported arguments (only -f / +f / -e / +e / -u / +u / -o errexit|nounset|pipefail / +o errexit|nounset|pipefail)'
)
ctx.exitCode = 1
}
} else if (name === ':') {
/* no-op */
} else if (name === 'command') {
const cargs = argv.slice(1)
if (cargs.length === 0) {
origErr.call(ctx.console, 'command: missing operand')
ctx.exitCode = 1
} else if (cargs[0] === '-v' || cargs[0] === '-V') {
const cmdn = cargs[1]
if (!cmdn) {
origErr.call(ctx.console, 'command: missing operand')
ctx.exitCode = 1
} else if (isShellBuiltin(cmdn, env)) {
origLog.call(ctx.console, cmdn)
} else {
const p = await resolveBinInPath(ctx, cmdn)
if (p) origLog.call(ctx.console, p)
else {
origErr.call(ctx.console, 'command: ' + cmdn + ': not found')
ctx.exitCode = 1
}
}
} else {
const childCtx = bareOsPipelineChildCtx(ctx, env, stdinText, capOut)
if (capOut) childCtx.bareOsBinWrite = pushOutRaw
await runWithShellPipelineStageTimeout(
env,
() => runBinCommand(childCtx, cargs),
cargs[0] || 'command'
)
mergePipelineChildCtx(ctx, childCtx)
}
} else if (name === 'type') {
const cmdn = argv[1]
if (!cmdn) {
origErr.call(ctx.console, 'type: missing operand')
ctx.exitCode = 1
} else if (isShellBuiltin(cmdn, env)) {
origLog.call(ctx.console, cmdn + ' is a shell builtin')
} else {
const p = await resolveBinInPath(ctx, cmdn)
if (p) origLog.call(ctx.console, cmdn + ' is ' + p)
else {
origErr.call(ctx.console, 'type: ' + cmdn + ': not found')
ctx.exitCode = 1
}
}
} else if (name === 'logout') {
const save = argv.includes('--save')
if (typeof ctx.applyLogout === 'function') {
try {
await ctx.applyLogout({ save })
} catch (e) {
origErr.call(ctx.console, (e && e.message) || String(e))
ctx.exitCode = 1
}
} else {
origErr.call(
ctx.console,
'logout: not supported in this environment'
)
ctx.exitCode = 1
}
} else if (name === 'jobs') {
const list = ctx.shellBackgroundJobs?.list || []
const ja = argv.slice(1)
let showPgidOnly = false
let longFmt = false
for (const a of ja) {
if (a === '-p') showPgidOnly = true
else if (a === '-l') longFmt = true
}
if (!list.length) {
origLog.call(ctx.console, '')
} else if (showPgidOnly) {
for (const j of list) {
if (typeof j.pgid === 'number')
origLog.call(ctx.console, String(j.pgid))
}
} else {
for (const j of list) {
let st = 'Running'
if (j.done) st = 'Done'
else if (j.stopped) st = 'Stopped'
const pg =
typeof j.pgid === 'number' && typeof j.sid === 'number'
? ` sid=${j.sid} pgid=${j.pgid}`
: ''
const syn =
longFmt && typeof j.id === 'number'
? ` pid=${4100 + j.id}`
: ''
origLog.call(
ctx.console,
`[${j.id}]+ ${st}${pg}${syn} ${j.label}`
)
}
}
} else if (name === 'fg') {
const list = ctx.shellBackgroundJobs?.list || []
let candidates = list.filter((j) => !j.done)
const arg = argv[1]
if (arg) {
const raw = arg.startsWith('%') ? arg.slice(1) : arg
const id = Number.parseInt(raw, 10)
if (Number.isFinite(id)) {
candidates = list.filter((j) => j.id === id)
}
}
const j = candidates.length ? candidates[candidates.length - 1] : null
if (!j) {
origErr.call(ctx.console, 'fg: no such job')
ctx.exitCode = 1
} else {
if (ctx.shellSessionState && typeof j.pgid === 'number') {
ctx.shellSessionState.foregroundPgid = j.pgid
}
if (j.stopped) j.stopped = false
try {
await j.promise
} catch {
/* background errors already logged */
}
if (ctx.shellSessionState) {
ctx.shellSessionState.foregroundPgid =
ctx.shellSessionState.sid ?? 1
}
ctx.exitCode = 0
}
} else if (name === 'bg') {
const list = ctx.shellBackgroundJobs?.list || []
const stopped = list.filter((j) => j && !j.done && j.stopped)
if (!stopped.length) {
origErr.call(ctx.console, 'bg: no stopped jobs')
ctx.exitCode = 1
} else {
let targets = stopped
const arg = argv[1]
if (arg) {
const raw = arg.startsWith('%') ? arg.slice(1) : arg
const jid = Number.parseInt(raw, 10)
if (Number.isFinite(jid)) {
targets = stopped.filter((j) => j.id === jid)
}
}
if (!targets.length) {
origErr.call(ctx.console, 'bg: no stopped jobs')
ctx.exitCode = 1
} else {
for (const j of targets) j.stopped = false
ctx.exitCode = 0
}
}
} else if (name === 'suspend-job') {
const list = ctx.shellBackgroundJobs?.list || []
let candidates = list.filter((j) => j && !j.done && !j.stopped)
const arg = argv[1]
if (arg) {
const raw = arg.startsWith('%') ? arg.slice(1) : arg
const jid = Number.parseInt(raw, 10)
if (Number.isFinite(jid)) {
candidates = list.filter((j) => j && !j.done && j.id === jid)
}
}
const j = candidates.length ? candidates[candidates.length - 1] : null
if (!j) {
origErr.call(ctx.console, 'suspend-job: no such job')
ctx.exitCode = 1
} else {
j.stopped = true
ctx.exitCode = 0
}
} else if (name === 'disown') {
if (!ctx.shellBackgroundJobs || !Array.isArray(ctx.shellBackgroundJobs.list)) {
origErr.call(ctx.console, 'disown: no job control')
ctx.exitCode = 1
} else {
const list = ctx.shellBackgroundJobs.list
const arg = argv[1]
/** @type {typeof list[number][]} */
let targets = []
if (!arg) {
const running = list.filter((j) => j && !j.done)
const j = running.length ? running[running.length - 1] : null
if (j) targets = [j]
} else {
const raw = arg.startsWith('%') ? arg.slice(1) : arg
const jid = Number.parseInt(raw, 10)
if (Number.isFinite(jid)) {
targets = list.filter((x) => x && x.id === jid)
}
}
if (!targets.length) {
origErr.call(ctx.console, 'disown: no such job')
ctx.exitCode = 1
} else {
for (const t of targets) {
const idx = list.indexOf(t)
if (idx >= 0) list.splice(idx, 1)
}
ctx.exitCode = 0
}
}
} else if (name === 'trap') {
if (argv[1] === '-l' || argv[1] === '--list') {
ctx.console.log('HUP INT KILL TERM PIPE CHLD USR1 USR2 EXIT')
} else if (argv[1] === '-p') {
const h =
ctx.shellTrapHandlers && typeof ctx.shellTrapHandlers === 'object'
? ctx.shellTrapHandlers
: {}
for (const k of Object.keys(h)) {
ctx.console.log(
`trap -- '${String(
/** @type {Record<string, string>} */ (h)[k]
).replace(/'/g, `'\\''`)}' ${k}`
)
}
} else if (argv.length < 3) {
origErr.call(ctx.console, 'trap: usage: trap COMMAND SIGNAL')
ctx.exitCode = 1
} else {
if (!ctx.shellTrapHandlers || typeof ctx.shellTrapHandlers !== 'object')
ctx.shellTrapHandlers = Object.create(null)
const cmd = argv[1]
const sig = normalizeShellSignalName(argv[2] || '')
if (cmd === '-' || cmd === '') {
delete /** @type {Record<string, string>} */ (ctx.shellTrapHandlers)[
sig
]
} else {
/** @type {Record<string, string>} */ (ctx.shellTrapHandlers)[sig] =
cmd
}
}
} else if (name === 'wait') {
const list = ctx.shellBackgroundJobs?.list || []
const shEnv = ctx.env || {}
const posixShellMode =
shEnv.BARE_OS_SHELL_POSIX_MODE === '1' ||
shEnv.BARE_OS_SHELL_POSIX_MODE === 'true'
let jobArg = argv[1]
let waitAny = false
if (posixShellMode && jobArg === '-n') {
waitAny = true
jobArg = argv[2]
}
const syntheticPidToJobId = (pidText) => {
const n = Number.parseInt(String(pidText || ''), 10)
if (!Number.isFinite(n)) return null
return n >= 4101 ? n - 4100 : null
}
/** @type {{ id: number, promise: Promise<string>, done?: boolean }[]} */
let target = list.filter((j) => j && !j.done)
if (waitAny && !jobArg) {
if (!target.length) {
ctx.exitCode = 0
} else {
try {
const doneJob = await Promise.race(
target.map((j) => j.promise.then(() => j))
)
const ec =
doneJob &&
typeof doneJob === 'object' &&
typeof doneJob.lastExitCode === 'number'
? doneJob.lastExitCode
: 0
ctx.exitCode = ec
} catch {
ctx.exitCode = 1
}
}
} else {
if (jobArg) {
const argText = String(jobArg)
if (argText === 'all') {
target = list.filter((j) => j)
} else {
const raw = argText.startsWith('%') ? argText.slice(1) : argText
const id = Number.parseInt(raw, 10)
const pidMapped = syntheticPidToJobId(argText)
const wantId =
pidMapped != null
? pidMapped
: Number.isFinite(id)
? id
: null
if (Number.isFinite(wantId)) {
target = list.filter((j) => j && j.id === wantId)
}
}
}
if (!target.length) {
origErr.call(ctx.console, 'wait: no such job')
ctx.exitCode = 1
} else {
try {
await Promise.all(target.map((j) => j.promise))
let ec = 0
for (const j of target) {
const c =
j && typeof j.lastExitCode === 'number' ? j.lastExitCode : 0
if (c !== 0) ec = c
}
ctx.exitCode = ec
} catch {
ctx.exitCode = 1
}
}
}
} else if (name === 'test' || name === '[') {
/** @type {string[]} */
let testArgv
if (name === '[') {
if (argv.length < 2) {
origErr.call(ctx.console, '[: missing ]')
ctx.exitCode = 2
} else if (argv[argv.length - 1] !== ']') {
origErr.call(ctx.console, '[: expected ]')
ctx.exitCode = 2
} else {
testArgv = ['test', ...argv.slice(1, -1)]
}
} else {
testArgv = argv
}
if (testArgv) {
const childCtx = bareOsPipelineChildCtx(ctx, env, stdinText, capOut)
if (capOut) childCtx.bareOsBinWrite = pushOutRaw
try {
await runWithShellPipelineStageTimeout(
env,
() => runBinCommand(childCtx, testArgv),
testArgv[0] || 'test'
)
mergePipelineChildCtx(ctx, childCtx)
if (childCtx.bareOsScriptCompletedWithCatch) {
ctx.bareOsPipelineStageError = true
delete childCtx.bareOsScriptCompletedWithCatch
}
} catch (e) {
origErr.call(ctx.console, (e && e.message) || String(e))
ctx.exitCode = 1
ctx.bareOsPipelineStageError = true
}
}
} else if (name === 'read') {
await runShellReadBuiltin(ctx, argv, env, origErr)
} else if (name === 'exit') {
code = 'exit'
let ec = 0
if (argv[1] !== undefined) {
const n = Number.parseInt(argv[1], 10)
ec = Number.isFinite(n) ? n : 0
}
await dispatchShellTrapSignal(ctx, 'EXIT')
ctx.exitCode = ec
if (typeof ctx.requestBooterExit === 'function') {
ctx.requestBooterExit(ec)
}
} else {
const childCtx = bareOsPipelineChildCtx(ctx, env, stdinText, capOut)
if (capOut) childCtx.bareOsBinWrite = pushOutRaw
try {
await runWithShellPipelineStageTimeout(
env,
() => runBinCommand(childCtx, argv),
argv[0] || 'bin'
)
mergePipelineChildCtx(ctx, childCtx)
if (childCtx.bareOsScriptCompletedWithCatch) {
ctx.bareOsPipelineStageError = true
delete childCtx.bareOsScriptCompletedWithCatch
}
} catch (e) {
origErr.call(ctx.console, (e && e.message) || String(e))
ctx.exitCode = 1
ctx.bareOsPipelineStageError = true
}
if (name === '/bin/exit' || name.endsWith('/exit')) {
code = 'exit'
}
}
appendShellAuditEvent(ctx, 'shell.command.finish', {
command: name,
stage: pi,
exitCode: Number(ctx.exitCode) || 0
})
} finally {
if (capOut || capErrSeparate || mergeErr) {
ctx.console.log = origLog
ctx.console.error = origErr
}
}
if (code === 'exit') return 'exit'
if (resolvedRedirErr) {
const epath = resolvedRedirErr
const edata = ctx.b4a.from(errChunks.join(''))
if (cmd.redirErrAppend) {
const prev = await vfs.readFile(epath)
const merged = prev ? ctx.b4a.concat([prev, edata]) : edata
await vfs.writeFile(epath, merged)
} else {
await vfs.writeFile(epath, edata)
}
}
let pipeOut = outChunks.join('')
if (resolvedRedirOut) {
const path = resolvedRedirOut
const data = ctx.b4a.from(pipeOut)
if (cmd.redirAppend) {
const prev = await vfs.readFile(path)
const merged = prev ? ctx.b4a.concat([prev, data]) : data
await vfs.writeFile(path, merged)
} else {
await vfs.writeFile(path, data)
}
pipeOut = ''
}
pipelineLastExit = Number(ctx.exitCode) || 0
pipelineStageExits.push(pipelineLastExit)
if (
pipelineWantsPipefail &&
pipefailFirstNonZero === 0 &&
pipelineLastExit !== 0
) {
pipefailFirstNonZero = pipelineLastExit
}
if (ctx.bareOsPipelineStageError) {
delete ctx.bareOsPipelineStageError
return 'ok'
}
stdinText = isLast ? null : pipeOut
ctx.shellStdin = stdinText ?? undefined
}
ctx.exitCode = pipelineWantsPipefail
? pipefailFirstNonZero !== 0
? pipefailFirstNonZero
: pipelineLastExit
: pipelineLastExit
if (
env.BARE_OS_SHELL_PIPESTATUS === '1' ||
env.BARE_OS_SHELL_PIPESTATUS === 'true'
) {
env.BARE_OS_PIPESTATUS = pipelineStageExits.join(' ')
}
return 'ok'
} catch (e) {
ctx.console.error((e && e.message) || String(e))
ctx.exitCode = 1
return 'ok'
}
}
/**
* @param {Record<string, unknown>} ctx
* @param {Token[]} toks
*/
function scheduleBackgroundShell(ctx, toks) {
if (!ctx.shellBackgroundJobs) {
ctx.shellBackgroundJobs = { nextId: 1, list: [] }
}
if (!ctx.shellSessionState) {
ctx.shellSessionState = {
sid: 1,
nextPgid: 300,
foregroundPgid: 1,
controllingTty: '/dev/console'
}
} else if (ctx.shellSessionState.foregroundPgid == null) {
ctx.shellSessionState.foregroundPgid = ctx.shellSessionState.sid
}
if (!ctx.shellSessionState.controllingTty) {
const fifo =
(ctx.env && ctx.env.BARE_OS_SESSION_FIFO) ||
(ctx.env && ctx.env.BARE_OS_IPC_SESSION_FIFO) ||
''
ctx.shellSessionState.controllingTty = fifo
? `ipc:${String(fifo).trim()}`
: '/dev/console'
}
const id = ctx.shellBackgroundJobs.nextId++
const pgid = ctx.shellSessionState.nextPgid++
const sid = ctx.shellSessionState.sid
const controlIpc = `job-${sid}-${pgid}-ctl`
const dataIpc = `job-${sid}-${pgid}-data`
const label = toks
.filter((t) => t.type === 'word')
.map((t) => t.value)
.slice(0, 6)
.join(' ')
const childCtx = Object.assign({}, ctx)
if (ctx.env && typeof ctx.env === 'object') {
childCtx.env = { ...ctx.env }
}
if (ctx.vfs && typeof ctx.vfs === 'object' && ctx.vfs.env && typeof ctx.vfs.env === 'object') {
childCtx.vfs = Object.assign(
Object.create(Object.getPrototypeOf(ctx.vfs)),
ctx.vfs,
{ env: childCtx.env && typeof childCtx.env === 'object' ? childCtx.env : { ...ctx.vfs.env } }
)
}
childCtx.shellBackgroundJobs = { nextId: 1, list: [] }
if (ctx.shellSessionState && typeof ctx.shellSessionState === 'object') {
childCtx.shellSessionState = { ...ctx.shellSessionState }
}
/** @type {{ id: number, label: string, promise: Promise<string>, done: boolean, stopped: boolean, pgid: number, sid: number, jobControlModel: string, controlIpc: string, dataIpc: string, terminate?: () => Promise<void>, _terminatePromise?: Promise<void>, lastExitCode?: number }} */
const entry = {
id,
label,
promise: /** @type {Promise<string>} */ (Promise.resolve('pending')),
done: false,
stopped: false,
pgid,
sid,
jobControlModel: 'logical_no_fork',
controlIpc,
dataIpc,
lastExitCode: 0
}
entry.promise = (async () => {
const statements = splitTopLevelStatements(toks)
for (const stmt of statements) {
await waitWhileShellJobStopped(entry)
if (!stmt.length) continue
const r = await dispatchShellStatement(childCtx, stmt)
if (r === 'exit') {
entry.lastExitCode =
typeof childCtx.exitCode === 'number' ? childCtx.exitCode : 0
return r
}
}
syncBareOsExitStatusEnv(childCtx)
entry.lastExitCode =
typeof childCtx.exitCode === 'number' ? childCtx.exitCode : 0
return 'ok'
})()
ctx.shellBackgroundJobs.list.push(entry)
entry.terminate = async () => {
if (entry._terminatePromise) return entry._terminatePromise
entry._terminatePromise = (async () => {
entry.stopped = true
entry.done = true
})()
return entry._terminatePromise
}
entry.promise.finally(() => {
entry.done = true
})
ctx.console.error(`[${id}] (sid=${sid} pgid=${pgid}) ${label || '(job)'} &`)
}
/**
* `if` COMPOUND `then` COMPOUND [ `else` COMPOUND ] `fi`
* Condition / branch bodies use the same `&&` / `||` / `|` rules as a normal line.
* @param {Record<string, unknown>} ctx
* @param {Token[]} tokens
* @returns {Promise<'exit' | 'ok'>}
*/
/**
* Run `;`-separated lists (same as outside `if`); last command sets exit status.
* @param {Record<string, unknown>} ctx
* @param {Token[]} toks
*/
/**
* @param {Record<string, unknown>} ctx
* @param {Token[]} toks
* @param {{ suppressErrexit?: boolean }} [opts]
*/
async function execSemicolonLists(ctx, toks, opts) {
const lists = splitTokensBySemicolon(toks)
const shE = ctx.vfs?.env
const suppressErrexit = opts && opts.suppressErrexit === true
const errexitOn =
!suppressErrexit &&
shE &&
(shE.BARE_OS_SHELL_ERREXIT === '1' || shE.BARE_OS_SHELL_ERREXIT === 'true')
for (const list of lists) {
if (!list.length) continue
const r = await execAndOrList(ctx, list)
if (r === 'exit') return 'exit'
if (errexitOn && (Number(ctx.exitCode) || 0) !== 0) return 'ok'
}
return 'ok'
}
/**
* @param {Record<string, unknown>} ctx
* @param {Token[]} bodyToks
* @returns {Promise<'ok' | 'exit' | 'break' | 'continue'>}
*/
async function execLoopBody(ctx, bodyToks) {
const lists = splitTokensBySemicolon(bodyToks)
for (const list of lists) {
if (!list.length) continue
const first = list[0]
if (first?.type === 'word' && first.value === 'break') {
ctx.exitCode = 0
return 'break'
}
if (first?.type === 'word' && first.value === 'continue') {
ctx.exitCode = 0
return 'continue'
}
const r = await dispatchShellStatement(ctx, list)
if (r === 'exit') return 'exit'
}
return 'ok'
}
/** @param {string} name */
/**
* @param {Record<string, unknown>} ctx
* @param {Token[]} tokens
* @returns {Promise<'exit' | 'ok'>}
*/
async function execWhileConstruct(ctx, tokens) {
const split = findWhileDoSplit(tokens)
if (split < 0) {
ctx.console.error('shell: while: expected "; do …; done"')
ctx.exitCode = 2
return 'ok'
}
const last = tokens[tokens.length - 1]
if (last.type !== 'word' || last.value !== 'done') {
ctx.console.error('shell: while: missing done')
ctx.exitCode = 2
return 'ok'
}
const condToks = tokens.slice(1, split)
const bodyToks = tokens.slice(split + 2, tokens.length - 1)
const maxIter = Number.parseInt(
ctx.vfs?.env?.BARE_OS_SHELL_LOOP_MAX || '10000',
10
)
const cap = Number.isFinite(maxIter) && maxIter > 0 ? maxIter : 10000
for (let i = 0; i < cap; i++) {
const r0 = await execSemicolonLists(ctx, condToks, { suppressErrexit: true })
if (r0 === 'exit') return 'exit'
if ((Number(ctx.exitCode) || 0) !== 0) break
const r1 = await execLoopBody(ctx, bodyToks)
if (r1 === 'exit') return 'exit'
if (r1 === 'break') break
if (r1 === 'continue') continue
}
return 'ok'
}
/**
* `until test-commands; do consequent-commands; done` — opposite exit test vs while (Issue 7style).
* Gated by **`BARE_OS_SHELL_UNTIL=1`** or **`true`**.
* @param {Record<string, unknown>} ctx
* @param {Token[]} tokens
* @returns {Promise<'exit' | 'ok'>}
*/
async function execUntilConstruct(ctx, tokens) {
const split = findWhileDoSplit(tokens)
if (split < 0) {
ctx.console.error('shell: until: expected "; do …; done"')
ctx.exitCode = 2
return 'ok'
}
const last = tokens[tokens.length - 1]
if (last.type !== 'word' || last.value !== 'done') {
ctx.console.error('shell: until: missing done')
ctx.exitCode = 2
return 'ok'
}
const condToks = tokens.slice(1, split)
const bodyToks = tokens.slice(split + 2, tokens.length - 1)
const maxIter = Number.parseInt(
ctx.vfs?.env?.BARE_OS_SHELL_LOOP_MAX || '10000',
10
)
const cap = Number.isFinite(maxIter) && maxIter > 0 ? maxIter : 10000
for (let i = 0; i < cap; i++) {
const r0 = await execSemicolonLists(ctx, condToks, { suppressErrexit: true })
if (r0 === 'exit') return 'exit'
if ((Number(ctx.exitCode) || 0) === 0) break
const r1 = await execLoopBody(ctx, bodyToks)
if (r1 === 'exit') return 'exit'
if (r1 === 'break') break
if (r1 === 'continue') continue
}
return 'ok'
}
/**
* @param {Record<string, unknown>} ctx
* @param {Token[]} tokens
* @returns {Promise<'exit' | 'ok'>}
*/
async function execForConstruct(ctx, tokens) {
const last = tokens[tokens.length - 1]
if (last.type !== 'word' || last.value !== 'done') {
ctx.console.error('shell: for: missing done')
ctx.exitCode = 2
return 'ok'
}
if (tokens.length < 7 || tokens[1].type !== 'word') {
ctx.console.error('shell: for: invalid syntax')
ctx.exitCode = 2
return 'ok'
}
if (tokens[2].type !== 'word' || tokens[2].value !== 'in') {
ctx.console.error('shell: for: expected `in`')
ctx.exitCode = 2
return 'ok'
}
/** @type {Token[]} */
const inToks = []
let semi = -1
for (let j = 3; j < tokens.length; j++) {
const t = tokens[j]
if (t.type === 'op' && t.value === ';') {
semi = j
break
}
inToks.push(t)
}
if (semi < 0) {
ctx.console.error('shell: for: expected `;` before do')
ctx.exitCode = 2
return 'ok'
}
if (tokens[semi + 1]?.type !== 'word' || tokens[semi + 1].value !== 'do') {
ctx.console.error('shell: for: expected `do` after `;`')
ctx.exitCode = 2
return 'ok'
}
const varName = tokens[1].value
const bodyToks = tokens.slice(semi + 2, tokens.length - 1)
const env = ctx.vfs.env
/** @type {string[]} */
const words = []
for (const t of inToks) {
if (t.type !== 'word') continue
const xs = await expandShellWordTokens(
ctx,
/** @type {Extract<Token, { type: 'word' }>} */ (t),
env,
{}
)
if (
xs.length === 0 &&
(env.BARE_OS_STRICT_POSIX === '1' ||
env.BARE_OS_STRICT_POSIX === 'true')
) {
ctx.console.error('shell: for `in`: pathname expansion produced no matches')
ctx.exitCode = 1
return 'ok'
}
for (const x of xs) words.push(x)
}
const maxIter = Number.parseInt(
ctx.vfs?.env?.BARE_OS_SHELL_LOOP_MAX || '10000',
10
)
const cap = Number.isFinite(maxIter) && maxIter > 0 ? maxIter : 10000
let total = 0
for (const w of words) {
env[varName] = w
if (++total > cap) {
ctx.console.error('shell: for: exceeded BARE_OS_SHELL_LOOP_MAX')
ctx.exitCode = 1
return 'ok'
}
const r = await execLoopBody(ctx, bodyToks)
if (r === 'exit') return 'exit'
if (r === 'break') break
if (r === 'continue') continue
}
return 'ok'
}
/**
* `case WORD in pattern) list ;; … esac` — bounded branches; patterns support `|` alternation and `*`.
* @param {Record<string, unknown>} ctx
* @param {Token[]} tokens
* @returns {Promise<'exit' | 'ok'>}
*/
async function execCaseConstruct(ctx, tokens) {
const last = tokens[tokens.length - 1]
if (last.type !== 'word' || last.value !== 'esac') {
ctx.console.error('shell: case: missing esac')
ctx.exitCode = 2
return 'ok'
}
if (
tokens.length < 5 ||
tokens[1].type !== 'word' ||
tokens[2].type !== 'word' ||
tokens[2].value !== 'in'
) {
ctx.console.error('shell: case: expected `case WORD in`')
ctx.exitCode = 2
return 'ok'
}
const env = ctx.vfs.env
const subj = expandWord(tokens[1].value, env)
const maxBranches = Number.parseInt(
ctx.vfs?.env?.BARE_OS_SHELL_CASE_MAX_BRANCHES || '32',
10
)
const cap = Number.isFinite(maxBranches) && maxBranches > 0 ? maxBranches : 32
let i = 3
let branches = 0
while (i < tokens.length - 1) {
if (++branches > cap) {
ctx.console.error(
'shell: case: too many branches (see BARE_OS_SHELL_CASE_MAX_BRANCHES)'
)
ctx.exitCode = 2
return 'ok'
}
let paren = -1
for (let k = i; k < tokens.length - 1; k++) {
const t = tokens[k]
if (t.type === 'op' && t.value === ')') {
paren = k
break
}
}
if (paren < 0) {
ctx.console.error('shell: case: expected )')
ctx.exitCode = 2
return 'ok'
}
const patToks = tokens.slice(i, paren)
let dsemi = -1
let doubleSemiLen = 2
for (let k = paren + 1; k < tokens.length - 1; k++) {
const t = tokens[k]
const n = tokens[k + 1]
if (t.type === 'op' && t.value === ';;') {
dsemi = k
doubleSemiLen = 1
break
}
if (
t.type === 'op' &&
t.value === ';' &&
n &&
n.type === 'op' &&
n.value === ';'
) {
dsemi = k
doubleSemiLen = 2
break
}
}
if (dsemi < 0) {
ctx.console.error('shell: case: expected ;;')
ctx.exitCode = 2
return 'ok'
}
const bodyToks = tokens.slice(paren + 1, dsemi)
const pats = casePatternList(patToks, env)
const matched = pats.some((p) => casePatternMatches(subj, p))
if (matched) {
const r = await execSemicolonLists(ctx, bodyToks)
if (r === 'exit') return 'exit'
return 'ok'
}
i = dsemi + doubleSemiLen
}
ctx.exitCode = 0
return 'ok'
}
async function dispatchShellStatement(ctx, stmt) {
/** Normalize inline brace-expression tokens like `{1..5}` into a word token so
* they use normal shell word expansion instead of statement/group operators. */
const normalized = []
for (let i = 0; i < stmt.length; i++) {
const a = stmt[i]
const b = stmt[i + 1]
const c = stmt[i + 2]
if (
a?.type === 'op' &&
a.value === '{' &&
b?.type === 'word' &&
c?.type === 'op' &&
c.value === '}' &&
(/^-?\d+\.\.-?\d+$/.test(b.value) || b.value.includes(','))
) {
normalized.push({
type: 'word',
value: '{' + b.value + '}',
parts: [{ q: /** @type {'u'} */ ('u'), t: '{' + b.value + '}' }]
})
i += 2
continue
}
normalized.push(a)
}
stmt = normalized
const head = stmt[0]
const shEnv = ctx.vfs?.env
const posixMode =
shEnv &&
(shEnv.BARE_OS_SHELL_POSIX_MODE === '1' ||
shEnv.BARE_OS_SHELL_POSIX_MODE === 'true')
const groupingMode =
shEnv &&
(shEnv.BARE_OS_SHELL_GROUPING === '1' ||
shEnv.BARE_OS_SHELL_GROUPING === 'true')
const arithmeticCommandMode =
shEnv &&
(shEnv.BARE_OS_SH_EXTENDED_PROFILE === '1' ||
shEnv.BARE_OS_SH_EXTENDED_PROFILE === 'true' ||
shEnv.BARE_OS_SHELL_POSIX_MODE === '1' ||
shEnv.BARE_OS_SHELL_POSIX_MODE === 'true')
if (
arithmeticCommandMode &&
((stmt.length >= 4 &&
stmt[0]?.type === 'op' &&
stmt[0].value === '(' &&
stmt[1]?.type === 'op' &&
stmt[1].value === '(' &&
stmt[stmt.length - 2]?.type === 'op' &&
stmt[stmt.length - 2].value === ')' &&
stmt[stmt.length - 1]?.type === 'op' &&
stmt[stmt.length - 1].value === ')') ||
(stmt.length >= 3 &&
stmt[0]?.type === 'word' &&
stmt[0].value === '((' &&
stmt[stmt.length - 1]?.type === 'word' &&
stmt[stmt.length - 1].value === '))'))
) {
const body =
stmt[0]?.type === 'word' && stmt[0].value === '((' ? stmt.slice(1, -1) : stmt.slice(2, -2)
const expr = body
.filter((t) => t.type !== 'op' || (t.value !== ';' && t.value !== '|'))
.map((t) => t.value)
.join(' ')
.trim()
if (!expr) {
ctx.console.error('shell: arithmetic: empty expression')
ctx.exitCode = 2
return 'ok'
}
try {
const n = bareOsEvalArithmeticExpr(expr, shEnv || {})
ctx.exitCode = n === 0 ? 1 : 0
} catch (e) {
ctx.console.error((e && e.message) || String(e))
ctx.exitCode = 2
}
return 'ok'
}
if ((posixMode || groupingMode) && head?.type === 'op' && head.value === '(') {
let depth = 0
let close = -1
for (let j = 0; j < stmt.length; j++) {
const t = stmt[j]
if (t.type === 'op' && t.value === '(') depth++
else if (t.type === 'op' && t.value === ')') {
depth--
if (depth === 0) {
close = j
break
}
}
}
if (close < 0) {
ctx.console.error('shell: grouped list: unmatched (')
ctx.exitCode = 2
return 'ok'
}
if (close !== stmt.length - 1) {
ctx.console.error(
'shell: grouped list must span the full statement (no trailing tokens after closing )'
)
ctx.exitCode = 2
return 'ok'
}
return execSemicolonLists(ctx, stmt.slice(1, close))
}
const localOn =
shEnv &&
(shEnv.BARE_OS_SHELL_LOCAL_DECLARE === '1' ||
shEnv.BARE_OS_SHELL_LOCAL_DECLARE === 'true')
if (localOn && head?.type === 'word' && head.value === 'local') {
return execShellLocalBuiltin(ctx, stmt.slice(1))
}
if (localOn && head?.type === 'word' && head.value === 'declare') {
const dr = tryExecShellDeclareBuiltin(ctx, stmt.slice(1))
if (dr != null) return dr
}
if (head?.type === 'word' && head.value === 'select') {
ctx.console.error('shell: select is unsupported')
ctx.exitCode = 2
return 'ok'
}
const doubleBracketOn =
shEnv &&
(shEnv.BARE_OS_SHELL_DOUBLE_BRACKET === '1' ||
shEnv.BARE_OS_SHELL_DOUBLE_BRACKET === 'true')
const legacyDoubleBracketOpen =
stmt[0]?.type === 'word' &&
stmt[0].value === '[' &&
stmt[1]?.type === 'word' &&
stmt[1].value === '['
const modernDoubleBracketOpen =
stmt[0]?.type === 'word' && stmt[0].value === '[['
if (legacyDoubleBracketOpen || modernDoubleBracketOpen) {
if (!doubleBracketOn) {
ctx.console.error(
'shell: [[ … ]] is not supported (use /bin/test or [ … ]; set BARE_OS_SHELL_DOUBLE_BRACKET=1 for limited ==/!=)'
)
ctx.exitCode = 2
return 'ok'
}
return execDoubleBracketLimited(ctx, stmt)
}
const fnDecl = parseShellFunctionDeclaration(stmt)
if (fnDecl) {
if (!ctx.shellFunctions || typeof ctx.shellFunctions !== 'object') {
ctx.shellFunctions = Object.create(null)
}
ctx.shellFunctions[fnDecl.name] = { body: fnDecl.body }
ctx.exitCode = 0
return 'ok'
}
if (head?.type === 'word' && head.value === 'if')
return execIfConstruct(ctx, stmt)
if (head?.type === 'word' && head.value === 'until')
return execUntilConstruct(ctx, stmt)
if (head?.type === 'word' && head.value === 'while')
return execWhileConstruct(ctx, stmt)
if (head?.type === 'word' && head.value === 'for')
return execForConstruct(ctx, stmt)
if (head?.type === 'word' && head.value === 'case')
return execCaseConstruct(ctx, stmt)
if (tryReportMisplacedReservedStatementStart(ctx, stmt)) return 'ok'
return execAndOrList(ctx, stmt)
}
async function execIfConstruct(ctx, tokens) {
const thenIdx = findThenIndex(tokens, 1)
if (thenIdx < 0) {
ctx.console.error('shell: syntax error: if without matching then')
ctx.exitCode = 2
return 'ok'
}
const cond = tokens.slice(1, thenIdx)
const tail = findElseOrFiAfterThen(tokens, thenIdx + 1)
if (!tail) {
ctx.console.error('shell: syntax error: if without fi')
ctx.exitCode = 2
return 'ok'
}
let closingFiIdx
if (tail.kind === 'fi') {
closingFiIdx = tail.idx
} else {
closingFiIdx = findFiAfterElse(tokens, tail.idx + 1)
if (closingFiIdx < 0) {
ctx.console.error('shell: syntax error: else without fi')
ctx.exitCode = 2
return 'ok'
}
}
if (closingFiIdx !== tokens.length - 1) {
ctx.console.error('shell: syntax error: unexpected tokens after fi')
ctx.exitCode = 2
return 'ok'
}
const condLists = splitTokensBySemicolon(cond).filter((c) => c.length)
if (!condLists.length) {
ctx.console.error('shell: invalid null command')
ctx.exitCode = 2
return 'ok'
}
for (const c of condLists) {
const r = await execAndOrList(ctx, c)
if (r === 'exit') return 'exit'
}
const condOk = (Number(ctx.exitCode) || 0) === 0
if (tail.kind === 'fi') {
const thenToks = tokens.slice(thenIdx + 1, tail.idx)
if (condOk) {
const r = await execSemicolonLists(ctx, thenToks)
if (r === 'exit') return 'exit'
} else {
ctx.exitCode = 0
}
return 'ok'
}
const elseIdx = tail.idx
const thenToks = tokens.slice(thenIdx + 1, elseIdx)
const elseToks = tokens.slice(elseIdx + 1, closingFiIdx)
if (condOk) {
const r = await execSemicolonLists(ctx, thenToks)
if (r === 'exit') return 'exit'
} else {
const r = await execSemicolonLists(ctx, elseToks)
if (r === 'exit') return 'exit'
}
return 'ok'
}
/**
* @param {Record<string, unknown>} ctx
* @param {Token[]} tokens
* @returns {Promise<'exit' | 'ok'>}
*/
async function execAndOrList(ctx, tokens) {
const { segments, ops } = splitTokensByAndOr(tokens)
for (let s = 0; s < segments.length; s++) {
if (!segmentHasCommand(segments[s])) {
ctx.console.error('shell: invalid null command')
ctx.exitCode = 2
return 'ok'
}
}
let lastStatus = 0
for (let i = 0; i < segments.length; i++) {
if (i > 0) {
const op = ops[i - 1]
if (op === '&&' && lastStatus !== 0) continue
if (op === '||' && lastStatus === 0) continue
}
let pipeline
try {
pipeline = parsePipeline(segments[i])
} catch (e) {
const code = e && /** @type {{ code?: string }} */ (e).code
if (code === 'BARE_OS_SHELL_ERROR') {
ctx.console.error((e && /** @type {Error} */ (e).message) || String(e))
ctx.exitCode = 2
syncBareOsExitStatusEnv(ctx)
return 'ok'
}
throw e
}
const r = await execParsedPipeline(ctx, pipeline)
if (r === 'exit') return 'exit'
lastStatus = Number(ctx.exitCode) || 0
}
return 'ok'
}
/**
* @param {Record<string, unknown>} ctx
* @param {string} line
* @returns {Promise<'exit' | 'ok'>}
*/
export async function execShellLine(ctx, line) {
const raw = line.trim()
if (!raw) return 'ok'
const vfs = ctx.vfs
const shellLocalOn =
vfs?.env &&
(vfs.env.BARE_OS_SHELL_LOCAL_DECLARE === '1' ||
vfs.env.BARE_OS_SHELL_LOCAL_DECLARE === 'true')
/** @type {Record<string, string> | null} */
let savedEnv = null
if (shellLocalOn && vfs?.env && typeof vfs.env === 'object') {
savedEnv = vfs.env
vfs.env = { ...savedEnv }
}
try {
return await execShellLineInner(ctx, raw)
} catch (e) {
const code = e && /** @type {{ code?: string }} */ (e).code
if (code === BARE_OS_SHELL_NOUNSET_ERROR) {
ctx.console.error('shell: ' + ((e && e.message) || 'unbound variable'))
ctx.exitCode = 1
syncBareOsExitStatusEnv(ctx)
return 'ok'
}
throw e
} finally {
if (savedEnv != null && vfs) vfs.env = savedEnv
}
}
/**
* @param {Record<string, unknown>} ctx
* @param {string} rawTrimmed
*/
async function execShellLineInner(ctx, rawTrimmed) {
if (typeof ctx.execLine !== 'function') {
ctx.execLine = async (ln) => execShellLine(ctx, ln)
}
let execLine = collapseShellLineContinuations(rawTrimmed.trim())
const arithCmd = /^\(\((.*)\)\)$/.exec(execLine)
if (arithCmd) {
const envArith = ctx.vfs?.env && typeof ctx.vfs.env === 'object' ? ctx.vfs.env : {}
try {
const n = bareOsEvalArithmeticExpr(String(arithCmd[1] || '').trim(), envArith)
ctx.exitCode = n === 0 ? 1 : 0
} catch (e) {
ctx.console.error((e && e.message) || String(e))
ctx.exitCode = 2
}
syncBareOsExitStatusEnv(ctx)
return 'ok'
}
const env0 = ctx.vfs?.env && typeof ctx.vfs.env === 'object' ? ctx.vfs.env : {}
if (
env0.BARE_OS_SHELL_EXEC_GRAPH_DUMP === '1' ||
env0.BARE_OS_SHELL_EXEC_GRAPH_DUMP === 'true'
) {
try {
ctx.shellLastExecGraph = buildShellExecutionGraph(
collapseShellLineContinuations(rawTrimmed.trim())
)
} catch {
/* best-effort diagnostic only */
}
}
if (!ctx.shellSessionState) {
ctx.shellSessionState = {
sid: 1,
nextPgid: 300,
foregroundPgid: 1,
controllingTty: '/dev/console'
}
} else {
if (ctx.shellSessionState.foregroundPgid == null) {
ctx.shellSessionState.foregroundPgid = ctx.shellSessionState.sid
}
if (!ctx.shellSessionState.controllingTty) {
const fifo =
(ctx.env && ctx.env.BARE_OS_SESSION_FIFO) ||
(ctx.env && ctx.env.BARE_OS_IPC_SESSION_FIFO) ||
''
ctx.shellSessionState.controllingTty = fifo
? `ipc:${String(fifo).trim()}`
: '/dev/console'
}
}
ctx.shellSessionState.foregroundPgid = ctx.shellSessionState.sid
{
const heredoc = await consumeInteractiveHeredoc(
ctx,
execLine,
syncBareOsExitStatusEnv
)
if (!heredoc.ok) return 'ok'
execLine = heredoc.execLine
}
const tokens = tokenize(execLine)
if (!tokens.length) return 'ok'
const statements = splitTopLevelStatements(tokens)
for (const stmt of statements) {
if (!stmt.length) continue
const ampParts = splitTopLevelByAmpersand(stmt)
for (let ai = 0; ai < ampParts.length; ai++) {
const part = ampParts[ai]
if (!part.length) continue
const isBg = ai < ampParts.length - 1
if (isBg) {
scheduleBackgroundShell(ctx, part)
continue
}
const r = await dispatchShellStatement(ctx, part)
if (r === 'exit') {
syncBareOsExitStatusEnv(ctx)
return 'exit'
}
syncBareOsExitStatusEnv(ctx)
const shE = ctx.vfs?.env
if (
shE &&
(shE.BARE_OS_SHELL_ERREXIT === '1' ||
shE.BARE_OS_SHELL_ERREXIT === 'true') &&
(Number(ctx.exitCode) || 0) !== 0
) {
syncBareOsExitStatusEnv(ctx)
return 'ok'
}
}
}
syncBareOsExitStatusEnv(ctx)
return 'ok'
}