2037 lines
65 KiB
JavaScript
2037 lines
65 KiB
JavaScript
/**
|
||
* 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 7–style).
|
||
* 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'
|
||
}
|