hdms
Added ls alias to list.
Made delegate failures explicit and nonzero in packages/bare-os-coreutils/src/hdms.js.
Added nonzero error exit in packages/bare-os-booter/lib/hdms-manager.js.
git-pear
Implemented clone subcommand routing to git clone in packages/bare-os-coreutils/src/git-pear.js.
trustctl
Added ls alias to status/policy output in packages/bare-os-coreutils/src/trustctl.js.
oidc-publish
Added explicit unknown-subcommand handling and publish subcommand compatibility in packages/bare-os-coreutils/src/oidc-publish.js.
ssh-keygen
Wrapped delegate invocation with explicit error propagation in packages/bare-os-coreutils/src/ssh-keygen.js.
Added success output on generated keypair in packages/bare-os-booter/lib/ssh-keygen-cli.js.
sshd
Added -t config test mode and explicit exit semantics in packages/bare-os-booter/lib/bare-openssh.js.
Ensured wrapper sets exit code consistently in packages/bare-os-openssh/src/sshd.js.
telnet
Changed connector preference to use net.createConnection first when available, then syscall bridge fallback, in packages/bare-os-coreutils/src/telnet.js.
crontab -e flow
Implemented edit flow with VISUAL/EDITOR fallback, unlocked-state checks, temp file handling, install, and cleanup in packages/bare-os-coreutils/src/crontab.js.
Shell/runtime hardcore semantics
Added numeric brace range expansion {1..5} in packages/bare-os-booter/lib/shell-glob.js.
Enabled brace expansion by default unless explicitly disabled.
Added normalization for inline brace-expression tokens in packages/bare-os-booter/lib/shell.js.
Added arithmetic command-form handling for (( ... )) in packages/bare-os-booter/lib/shell.js.
Extended shell signal trap dispatch support for USR1/USR2 (in addition to INT/TERM) in packages/bare-os-booter/index.js.
Hardened kill command delivery validation in packages/bare-os-coreutils/src/kill.js.
Regression tests
Added new: packages/bare-os-coreutils/test/hardcore-bugs.test.mjs.
Extended shell tests in packages/bare-os-booter/test.js for:
default cmdsub behavior,
brace range expansion,
arithmetic command form.
Existing regression files still pass after updates.
4256 lines
127 KiB
JavaScript
4256 lines
127 KiB
JavaScript
/**
|
||
* Shell implementation (tokenizer slice in {@link ./shell-tokenizer.js}; planner/executor here).
|
||
*/
|
||
import { runBinCommand, resolveBinInPath } from './kernel-runner.js'
|
||
import { tokenizeBareShellLineForDiagnostics } from './shell-tokenizer.js'
|
||
import { tokenizeBareShellLineDetailed } from './shell-tokenizer.js'
|
||
import { findArithmeticClose, lexShellLine } from './shell-lex.js'
|
||
|
||
export { tokenizeBareShellLineForDiagnostics }
|
||
export { tokenizeBareShellLineDetailed }
|
||
import {
|
||
applyBareOsThemeFromEnv,
|
||
bareOsGetThemePreset,
|
||
bareOsListThemeNames
|
||
} from './bare-os-theme-presets.js'
|
||
import { pathnameExpandShellWord, bareOsFnmatch } from './shell-glob.js'
|
||
import { bareOsKernelMetricSet } from './bare-os-kernel-metrics.js'
|
||
|
||
/** @see execShellLine — nounset violations surface as exit status 1 */
|
||
export const BARE_OS_SHELL_NOUNSET_ERROR = 'BARE_OS_SHELL_NOUNSET'
|
||
|
||
/**
|
||
* @param {Record<string, string> | null | undefined} env
|
||
*/
|
||
function shellNounsetEnvOn(env) {
|
||
return (
|
||
env &&
|
||
(env.BARE_OS_SHELL_NOUNSET === '1' || env.BARE_OS_SHELL_NOUNSET === 'true')
|
||
)
|
||
}
|
||
|
||
/**
|
||
* @param {string} name
|
||
* @param {Record<string, string>} env
|
||
*/
|
||
function shellCheckUnboundParam(name, env) {
|
||
if (!shellNounsetEnvOn(env) || !name) return
|
||
const n = name.trim()
|
||
if (n === '?' || /^\d+$/.test(n)) return
|
||
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(n)) return
|
||
if (!Object.prototype.hasOwnProperty.call(env, n)) {
|
||
const e = new Error(`unbound variable: ${n}`)
|
||
e.code = BARE_OS_SHELL_NOUNSET_ERROR
|
||
throw e
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Expand `$VAR`, `${VAR}`, and `$N` inside arithmetic before identifier substitution.
|
||
* @param {string} inner
|
||
* @param {Record<string, string>} env
|
||
*/
|
||
function bareOsArithmeticExpandDollarForms(inner, env) {
|
||
const s = String(inner || '')
|
||
if (s.length > 4096) throw new Error('shell: arithmetic: expression too long')
|
||
/** @type {string[]} */
|
||
const parts = []
|
||
let i = 0
|
||
let replacements = 0
|
||
const maxRep = 256
|
||
while (i < s.length) {
|
||
if (s[i] !== '$') {
|
||
parts.push(s[i++])
|
||
continue
|
||
}
|
||
if (++replacements > maxRep)
|
||
throw new Error('shell: arithmetic: expansion limit exceeded')
|
||
if (i + 1 >= s.length) {
|
||
parts.push('$')
|
||
i++
|
||
continue
|
||
}
|
||
if (s[i + 1] === '{') {
|
||
const end = s.indexOf('}', i + 2)
|
||
if (end === -1) {
|
||
parts.push('$')
|
||
i++
|
||
continue
|
||
}
|
||
const name = s.slice(i + 2, end).trim()
|
||
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name))
|
||
throw new Error('shell: arithmetic: invalid ${} in expression')
|
||
shellCheckUnboundParam(name, env)
|
||
const raw = env[name]
|
||
const n = Number.parseInt(String(raw ?? '0').trim(), 10)
|
||
parts.push(String(Number.isFinite(n) ? n : 0))
|
||
i = end + 1
|
||
continue
|
||
}
|
||
const c1 = s[i + 1]
|
||
if (/[0-9]/.test(c1)) {
|
||
shellCheckUnboundParam(c1, env)
|
||
const raw = env[c1]
|
||
const n = Number.parseInt(String(raw ?? '0').trim(), 10)
|
||
parts.push(String(Number.isFinite(n) ? n : 0))
|
||
i += 2
|
||
continue
|
||
}
|
||
if (!/[A-Za-z_]/.test(c1)) {
|
||
parts.push('$')
|
||
i++
|
||
continue
|
||
}
|
||
let k = i + 2
|
||
while (k < s.length && /[A-Za-z0-9_]/.test(s[k])) k++
|
||
const name = s.slice(i + 1, k)
|
||
shellCheckUnboundParam(name, env)
|
||
const raw = env[name]
|
||
const n = Number.parseInt(String(raw ?? '0').trim(), 10)
|
||
parts.push(String(Number.isFinite(n) ? n : 0))
|
||
i = k
|
||
}
|
||
return parts.join('')
|
||
}
|
||
|
||
/**
|
||
* @param {string} expr
|
||
* @param {Record<string, string>} env
|
||
*/
|
||
function bareOsEvalArithmeticExpr(expr, env) {
|
||
const pre = bareOsArithmeticExpandDollarForms(String(expr || ''), env)
|
||
const src = pre.trim()
|
||
if (!src) return '0'
|
||
if (!/^[A-Za-z0-9_+\-*/%()\s]+$/.test(src)) {
|
||
throw new Error('shell: arithmetic: invalid token')
|
||
}
|
||
const replaced = src.replace(/\b[A-Za-z_][A-Za-z0-9_]*\b/g, (name) => {
|
||
shellCheckUnboundParam(name, env)
|
||
const raw = env[name]
|
||
const n = Number.parseInt(String(raw ?? '0').trim(), 10)
|
||
return String(Number.isFinite(n) ? n : 0)
|
||
})
|
||
let out = 0
|
||
try {
|
||
out = Function('"use strict"; return (' + replaced + ');')()
|
||
} catch {
|
||
throw new Error('shell: arithmetic: invalid expression')
|
||
}
|
||
const num = Number(out)
|
||
if (!Number.isFinite(num)) throw new Error('shell: arithmetic: non-finite result')
|
||
return String(Math.trunc(num))
|
||
}
|
||
|
||
const SHELL_BUILTINS = new Set([
|
||
'alias',
|
||
'unalias',
|
||
'barerc',
|
||
'cd',
|
||
'export',
|
||
'unset',
|
||
'readonly',
|
||
'umask',
|
||
'set',
|
||
':',
|
||
'command',
|
||
'type',
|
||
'logout',
|
||
'exit',
|
||
'jobs',
|
||
'fg',
|
||
'bg',
|
||
'wait',
|
||
'suspend-job',
|
||
'disown',
|
||
'trap',
|
||
'test',
|
||
'['
|
||
])
|
||
|
||
/**
|
||
* Optional POSIX-style **`read`** builtin (bounded line, IFS split). Off by default.
|
||
* @param {Record<string, string | undefined> | null | undefined} env
|
||
*/
|
||
export function bareOsShellReadBuiltinEnabled(env) {
|
||
const o = env && typeof env === 'object' ? env : {}
|
||
return o.BARE_OS_SHELL_READ_BUILTIN === '1' || o.BARE_OS_SHELL_READ_BUILTIN === 'true'
|
||
}
|
||
|
||
/**
|
||
* Sorted list of shell builtin command names for completion / UX.
|
||
* Includes **`read`** only when {@link bareOsShellReadBuiltinEnabled} is true.
|
||
* @param {Record<string, string | undefined> | null | undefined} env
|
||
* @returns {string[]}
|
||
*/
|
||
export function listBareOsShellBuiltins(env) {
|
||
const out = [...SHELL_BUILTINS]
|
||
if (bareOsShellReadBuiltinEnabled(env)) out.push('read')
|
||
out.sort()
|
||
return out
|
||
}
|
||
|
||
/**
|
||
* @param {string} cmd
|
||
* @param {Record<string, string | undefined> | null | undefined} env
|
||
*/
|
||
function isShellBuiltin(cmd, env) {
|
||
if (SHELL_BUILTINS.has(cmd)) return true
|
||
if (cmd === 'read' && bareOsShellReadBuiltinEnabled(env)) return true
|
||
return false
|
||
}
|
||
|
||
/**
|
||
* @param {string} line
|
||
* @param {string} ifs
|
||
* @param {number} nNames
|
||
*/
|
||
function bareOsShellReadSplitFields(line, ifs, nNames) {
|
||
const sep = ifs.length ? ifs[0] : ' '
|
||
if (nNames <= 1) return [line]
|
||
const out = []
|
||
let rest = line
|
||
for (let i = 0; i < nNames - 1; i++) {
|
||
const idx = rest.indexOf(sep)
|
||
if (idx === -1) {
|
||
out.push(rest)
|
||
rest = ''
|
||
break
|
||
}
|
||
out.push(rest.slice(0, idx))
|
||
rest = rest.slice(idx + sep.length)
|
||
}
|
||
while (out.length < nNames - 1) out.push('')
|
||
out.push(rest)
|
||
return out
|
||
}
|
||
|
||
/**
|
||
* @param {Record<string, unknown>} ctx
|
||
* @param {number} maxBytes
|
||
* @param {(m: string) => void} errFn
|
||
* @returns {Promise<string | null>} null = EOF / error
|
||
*/
|
||
async function bareOsShellReadOneLine(ctx, maxBytes, errFn, opts = {}) {
|
||
const delimiter = typeof opts.delimiter === 'string' ? opts.delimiter : '\n'
|
||
const timeoutMs = Number.isFinite(opts.timeoutMs) ? Number(opts.timeoutMs) : 0
|
||
if (typeof ctx.shellStdin === 'string') {
|
||
const raw = ctx.shellStdin
|
||
const idx = delimiter ? raw.indexOf(delimiter) : -1
|
||
const line = idx === -1 ? raw : raw.slice(0, idx)
|
||
ctx.shellStdin = idx === -1 ? '' : raw.slice(idx + delimiter.length)
|
||
if (line.length > maxBytes) {
|
||
errFn(`read: line exceeds BARE_OS_SHELL_READ_MAX_BYTES (${maxBytes})`)
|
||
return null
|
||
}
|
||
return line
|
||
}
|
||
const rl = ctx.readLine
|
||
if (typeof rl === 'function') {
|
||
const readP = rl('')
|
||
const ln =
|
||
timeoutMs > 0
|
||
? await Promise.race([
|
||
readP,
|
||
new Promise((resolve) => setTimeout(() => resolve(null), timeoutMs))
|
||
])
|
||
: await readP
|
||
if (ln == null) return null
|
||
if (ln.length > maxBytes) {
|
||
errFn(`read: line exceeds BARE_OS_SHELL_READ_MAX_BYTES (${maxBytes})`)
|
||
return null
|
||
}
|
||
return ln
|
||
}
|
||
errFn(
|
||
'read: no input (redirect stdin, use a pipeline, or interactive readLine)'
|
||
)
|
||
return null
|
||
}
|
||
|
||
/**
|
||
* @param {Record<string, unknown>} ctx
|
||
* @param {string[]} argv
|
||
* @param {Record<string, string>} env
|
||
* @param {(m: string) => void} origErr
|
||
*/
|
||
async function runShellReadBuiltin(ctx, argv, env, origErr) {
|
||
let i = 1
|
||
let rawMode = false
|
||
let delimiter = '\n'
|
||
let timeoutMs = 0
|
||
while (i < argv.length && argv[i].startsWith('-')) {
|
||
const a = argv[i]
|
||
if (a === '-r') {
|
||
rawMode = true
|
||
}
|
||
else if (a === '-d') {
|
||
const d = argv[i + 1]
|
||
if (d == null) {
|
||
origErr.call(ctx.console, 'read: option requires an argument -- d')
|
||
ctx.exitCode = 2
|
||
return
|
||
}
|
||
delimiter = String(d).slice(0, 1)
|
||
i++
|
||
}
|
||
else if (a === '-t') {
|
||
const v = argv[i + 1]
|
||
if (v == null) {
|
||
origErr.call(ctx.console, 'read: option requires an argument -- t')
|
||
ctx.exitCode = 2
|
||
return
|
||
}
|
||
const n = Number.parseFloat(String(v))
|
||
if (!Number.isFinite(n) || n < 0) {
|
||
origErr.call(ctx.console, 'read: invalid timeout: ' + String(v))
|
||
ctx.exitCode = 2
|
||
return
|
||
}
|
||
timeoutMs = Math.min(Math.floor(n * 1000), 120000)
|
||
i++
|
||
}
|
||
else if (a === '--') {
|
||
i++
|
||
break
|
||
} else {
|
||
origErr.call(ctx.console, 'read: unsupported option: ' + a)
|
||
ctx.exitCode = 2
|
||
return
|
||
}
|
||
i++
|
||
}
|
||
const names = argv.slice(i).filter(Boolean)
|
||
if (!names.length) names.push('REPLY')
|
||
const maxRaw = env.BARE_OS_SHELL_READ_MAX_BYTES
|
||
const maxParsed =
|
||
maxRaw != null && String(maxRaw).trim() !== ''
|
||
? Number.parseInt(String(maxRaw), 10)
|
||
: 65536
|
||
const maxBytes =
|
||
Number.isFinite(maxParsed) && maxParsed > 0
|
||
? Math.min(maxParsed, 2_000_000)
|
||
: 65536
|
||
let line = await bareOsShellReadOneLine(
|
||
ctx,
|
||
maxBytes,
|
||
(m) => origErr.call(ctx.console, m),
|
||
{ delimiter, timeoutMs }
|
||
)
|
||
if (line === null) {
|
||
for (const n of names) env[n] = ''
|
||
ctx.exitCode = 1
|
||
return
|
||
}
|
||
if (!rawMode) {
|
||
line = line.replace(/\\(.)/g, '$1')
|
||
}
|
||
const ifs =
|
||
env.IFS !== undefined && env.IFS !== null ? String(env.IFS) : ' \t\n'
|
||
const fields = bareOsShellReadSplitFields(line, ifs, names.length)
|
||
for (let j = 0; j < names.length; j++) {
|
||
const k = names[j]
|
||
if (
|
||
ctx.shellReadonlyVars instanceof Set &&
|
||
ctx.shellReadonlyVars.has(k)
|
||
) {
|
||
origErr.call(ctx.console, k + ': readonly variable')
|
||
ctx.exitCode = 1
|
||
return
|
||
}
|
||
env[k] = fields[j] ?? ''
|
||
}
|
||
ctx.exitCode = 0
|
||
}
|
||
|
||
/**
|
||
* When `suspend-job` sets `stopped` on a background entry, yield between statements
|
||
* until `fg` / `bg` clears it (cooperative logical job control; no host SIGTSTP).
|
||
* @param {{ stopped?: boolean }} entry
|
||
*/
|
||
function waitWhileShellJobStopped(entry) {
|
||
if (!entry || !entry.stopped) return Promise.resolve()
|
||
return new Promise((resolve) => {
|
||
const id = setInterval(() => {
|
||
if (!entry.stopped) {
|
||
clearInterval(id)
|
||
resolve(undefined)
|
||
}
|
||
}, 10)
|
||
})
|
||
}
|
||
|
||
/**
|
||
* Boot policy v4: comma list in `BARE_OS_BOOT_POLICY_DENY_EXEC_LINE_BUILTINS`.
|
||
* @param {string} name
|
||
* @param {Record<string, string | undefined> | null | undefined} env
|
||
*/
|
||
function isExecLineBuiltinDenied(name, env) {
|
||
const raw = env && env.BARE_OS_BOOT_POLICY_DENY_EXEC_LINE_BUILTINS
|
||
if (raw == null || String(raw).trim() === '') return false
|
||
const set = new Set(
|
||
String(raw)
|
||
.split(',')
|
||
.map((s) => s.trim())
|
||
.filter(Boolean)
|
||
)
|
||
return set.has(name)
|
||
}
|
||
|
||
/**
|
||
* @param {string} key
|
||
* @param {Record<string, string | undefined> | null | undefined} env
|
||
*/
|
||
function shellCsvSetFromEnv(key, env) {
|
||
const raw = env && env[key]
|
||
if (raw == null || String(raw).trim() === '') return new Set()
|
||
return new Set(
|
||
String(raw)
|
||
.split(',')
|
||
.map((s) => s.trim())
|
||
.filter(Boolean)
|
||
)
|
||
}
|
||
|
||
/**
|
||
* @param {Record<string, unknown>} ctx
|
||
* @param {string} event
|
||
* @param {Record<string, unknown>} payload
|
||
*/
|
||
function appendShellAuditEvent(ctx, event, payload = {}) {
|
||
if (!Array.isArray(ctx.shellAuditEvents)) ctx.shellAuditEvents = []
|
||
ctx.shellAuditEvents.push({
|
||
schema: 1,
|
||
ts: Date.now(),
|
||
event,
|
||
...payload
|
||
})
|
||
}
|
||
|
||
/**
|
||
* @param {string} name
|
||
* @param {Record<string, string | undefined> | null | undefined} env
|
||
*/
|
||
function shellCommandDeniedByPolicy(name, env) {
|
||
const cmd = String(name || '').trim()
|
||
if (!cmd) return false
|
||
const deny = shellCsvSetFromEnv('BARE_OS_SHELL_DENY_COMMANDS', env)
|
||
if (deny.has(cmd)) return true
|
||
const allow = shellCsvSetFromEnv('BARE_OS_SHELL_ALLOW_COMMANDS', env)
|
||
if (allow.size > 0 && !allow.has(cmd)) return true
|
||
return false
|
||
}
|
||
|
||
/**
|
||
* @param {string} path
|
||
* @param {Record<string, string | undefined> | null | undefined} env
|
||
*/
|
||
function shellUnsafeRedirectPath(path, env) {
|
||
const guardOn =
|
||
env &&
|
||
(env.BARE_OS_SHELL_REDIRECT_GUARD === '1' ||
|
||
env.BARE_OS_SHELL_REDIRECT_GUARD === 'true')
|
||
if (!guardOn) return false
|
||
const p = String(path || '').trim()
|
||
if (!p) return false
|
||
if (p.includes('/../') || p.startsWith('../') || p.endsWith('/..')) return true
|
||
if (p.startsWith('/proc/') || p.startsWith('/sys/') || p.startsWith('/dev/')) return true
|
||
return false
|
||
}
|
||
|
||
/**
|
||
* @param {Record<string, unknown>} ctx
|
||
* @param {Record<string, unknown>} childCtx
|
||
*/
|
||
function mergeChildExitCode(ctx, childCtx) {
|
||
if (childCtx.exitCode !== undefined && childCtx.exitCode !== null) {
|
||
ctx.exitCode = childCtx.exitCode
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @param {unknown} identity
|
||
* @returns {identity is { state: unknown, publicKey: unknown, secretKey: unknown }}
|
||
*/
|
||
function isMergeableIdentitySession(identity) {
|
||
if (!identity || typeof identity !== 'object') return false
|
||
return (
|
||
Object.prototype.hasOwnProperty.call(identity, 'state') &&
|
||
Object.prototype.hasOwnProperty.call(identity, 'publicKey') &&
|
||
Object.prototype.hasOwnProperty.call(identity, 'secretKey')
|
||
)
|
||
}
|
||
|
||
/**
|
||
* Merge shell child-command side effects we intentionally allow to flow back.
|
||
* Today this includes exit status and identity session state.
|
||
* @param {Record<string, unknown>} ctx
|
||
* @param {Record<string, unknown>} childCtx
|
||
*/
|
||
function mergePipelineChildCtx(ctx, childCtx) {
|
||
mergeChildExitCode(ctx, childCtx)
|
||
if (isMergeableIdentitySession(childCtx.identity)) {
|
||
ctx.identity = /** @type {Record<string, unknown>} */ (childCtx.identity)
|
||
}
|
||
}
|
||
|
||
/** Env key mirroring last command exit status (POSIX `$?` parity). */
|
||
export const BARE_OS_EXIT_STATUS_ENV = 'BARE_OS_EXIT_STATUS'
|
||
|
||
/**
|
||
* Mirror `ctx.exitCode` into `ctx.vfs.env` so kernels and `echo $?` see last status.
|
||
* @param {Record<string, unknown>} ctx
|
||
*/
|
||
export function syncBareOsExitStatusEnv(ctx) {
|
||
const env = ctx.vfs?.env
|
||
if (!env || typeof env !== 'object') return
|
||
const n = Number(ctx.exitCode)
|
||
env[BARE_OS_EXIT_STATUS_ENV] = String(Number.isFinite(n) ? n : 0)
|
||
}
|
||
|
||
/** Max alias indirections (prevents cycles). */
|
||
const MAX_ALIAS_DEPTH = 16
|
||
|
||
/** Default caps for simulated pipeline capture (`console.log` between stages). */
|
||
export const DEFAULT_PIPELINE_MAX_STAGES = 32
|
||
export const DEFAULT_PIPELINE_MAX_CAPTURE_BYTES = 2 * 1024 * 1024
|
||
export const DEFAULT_PIPELINE_MAX_CAPTURE_LINES = 50000
|
||
|
||
/**
|
||
* Resolved simulated pipeline limits for the current `vfs.env` / session env.
|
||
* @param {Record<string, string> | null | undefined} env
|
||
*/
|
||
export function getBareOsPipelineLimits(env) {
|
||
const o = env && typeof env === 'object' ? env : {}
|
||
const parse = (key, def) => {
|
||
const v = o[key]
|
||
if (v == null || v === '') return def
|
||
const n = Number.parseInt(String(v), 10)
|
||
return Number.isFinite(n) && n > 0 ? n : def
|
||
}
|
||
/** Upper bounds on simulated capture (after streaming multiplier); tunable for high-RAM hosts. */
|
||
const absMaxBytes = parse(
|
||
'BARE_OS_PIPELINE_ABS_MAX_BYTES',
|
||
512 * 1024 * 1024
|
||
)
|
||
const absMaxLines = parse('BARE_OS_PIPELINE_ABS_MAX_LINES', 2_000_000)
|
||
const streamOn =
|
||
o.BARE_OS_SHELL_STREAMING === '1' || o.BARE_OS_SHELL_STREAMING === 'true'
|
||
const multRaw = Number.parseFloat(
|
||
String(o.BARE_OS_SHELL_STREAMING_MULT || '4')
|
||
)
|
||
const mult =
|
||
streamOn && Number.isFinite(multRaw) && multRaw > 1
|
||
? Math.min(multRaw, 16)
|
||
: 1
|
||
const baseBytes = parse(
|
||
'BARE_OS_PIPELINE_MAX_BYTES',
|
||
DEFAULT_PIPELINE_MAX_CAPTURE_BYTES
|
||
)
|
||
const baseLines = parse(
|
||
'BARE_OS_PIPELINE_MAX_LINES',
|
||
DEFAULT_PIPELINE_MAX_CAPTURE_LINES
|
||
)
|
||
const effectiveBytes = Math.floor(baseBytes * mult)
|
||
const effectiveLines = Math.floor(baseLines * mult)
|
||
return {
|
||
maxStages: parse(
|
||
'BARE_OS_PIPELINE_MAX_STAGES',
|
||
DEFAULT_PIPELINE_MAX_STAGES
|
||
),
|
||
maxBytes: Math.min(effectiveBytes, absMaxBytes),
|
||
maxLines: Math.min(effectiveLines, absMaxLines),
|
||
streamingMultiplier: mult,
|
||
/** True when `BARE_OS_SHELL_STREAMING` relaxes caps via multiplier. */
|
||
streamingEnabled: streamOn,
|
||
/** Parsed `BARE_OS_PIPELINE_MAX_*` before multiplier (for operator snapshots). */
|
||
baseMaxBytes: baseBytes,
|
||
baseMaxLines: baseLines,
|
||
/** Hard ceilings after multiplier (`BARE_OS_PIPELINE_ABS_MAX_*`; defaults 512 MiB / 2 M lines). */
|
||
absCapBytes: absMaxBytes,
|
||
absCapLines: absMaxLines
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Baseline aliases; `~/.barerc` and `unalias -a` merge/reset from this table.
|
||
* @returns {Record<string, string>}
|
||
*/
|
||
export function defaultShellAliases() {
|
||
return {
|
||
nano: 'edit',
|
||
top: 'baretop',
|
||
btop: 'baretop',
|
||
ll: 'ls -la',
|
||
la: 'ls -A',
|
||
l: 'ls',
|
||
'..': 'cd ..',
|
||
'...': 'cd ../..'
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Expand first argv[0] through alias chain; append original argv.slice(1).
|
||
* @param {string[]} argv
|
||
* @param {Record<string, string> | null | undefined} aliases
|
||
* @returns {string[]}
|
||
*/
|
||
export function expandArgvAliases(argv, aliases) {
|
||
if (!argv.length) return argv
|
||
const map = aliases && typeof aliases === 'object' ? aliases : {}
|
||
const out = [...argv]
|
||
let depth = 0
|
||
while (depth < MAX_ALIAS_DEPTH) {
|
||
const first = out[0]
|
||
const repl = map[first]
|
||
if (repl == null || repl === '') break
|
||
const words = tokenize(repl)
|
||
.filter((t) => t.type === 'word')
|
||
.map((t) => t.value)
|
||
if (!words.length) break
|
||
out.splice(0, 1, ...words)
|
||
depth++
|
||
}
|
||
if (depth >= MAX_ALIAS_DEPTH && map[out[0]]) {
|
||
throw new Error('alias: expansion nested too deeply')
|
||
}
|
||
return out
|
||
}
|
||
|
||
/**
|
||
* Strip one layer of matching single/double quotes from alias value.
|
||
* @param {string} val
|
||
*/
|
||
function stripAliasQuotes(val) {
|
||
const v = val.trim()
|
||
if (v.length >= 2) {
|
||
const q = v[0]
|
||
if ((q === "'" || q === '"') && v[v.length - 1] === q) {
|
||
return v.slice(1, -1)
|
||
}
|
||
}
|
||
return v
|
||
}
|
||
|
||
/**
|
||
* @param {Record<string, unknown>} ctx
|
||
* @param {string} rest content after `alias ` (name=value...)
|
||
*/
|
||
export function applyAliasDefinition(ctx, rest) {
|
||
const eq = rest.indexOf('=')
|
||
if (eq <= 0) return false
|
||
const aname = rest.slice(0, eq).trim()
|
||
if (!aname) return false
|
||
let val = rest.slice(eq + 1).trim()
|
||
val = stripAliasQuotes(val)
|
||
if (!ctx.shellAliases) ctx.shellAliases = {}
|
||
ctx.shellAliases[aname] = val
|
||
return true
|
||
}
|
||
|
||
/**
|
||
* @param {Record<string, unknown>} ctx
|
||
* @param {string[]} argv argv for unalias builtin (includes 'unalias')
|
||
*/
|
||
export function runUnaliasBuiltin(ctx, argv, logError) {
|
||
if (!ctx.shellAliases) ctx.shellAliases = { ...defaultShellAliases() }
|
||
const args = argv.slice(1)
|
||
if (args.length === 0) {
|
||
logError('unalias: missing name')
|
||
return
|
||
}
|
||
if (args.includes('-a')) {
|
||
ctx.shellAliases = { ...defaultShellAliases() }
|
||
return
|
||
}
|
||
for (const name of args) {
|
||
if (name === '-a') continue
|
||
delete ctx.shellAliases[name]
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Parse one line from ~/.barerc (without leading `unalias`).
|
||
*/
|
||
function applyBarercUnalias(ctx, rest) {
|
||
if (!ctx.shellAliases) ctx.shellAliases = { ...defaultShellAliases() }
|
||
const parts = rest.split(/\s+/).filter(Boolean)
|
||
if (parts.length === 1 && parts[0] === '-a') {
|
||
ctx.shellAliases = { ...defaultShellAliases() }
|
||
return
|
||
}
|
||
for (const name of parts) {
|
||
if (name === '-a') ctx.shellAliases = { ...defaultShellAliases() }
|
||
else delete ctx.shellAliases[name]
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Comment-only template written on first login when `~/.barerc` is absent
|
||
* (`loadBarerc(ctx, { createSkeletonIfMissing: true })`).
|
||
*/
|
||
export const BARERC_SKELETON = `# Bare OS — ~/.barerc (not full sh; only export, alias, unalias, theme, # comments).
|
||
#
|
||
# export MY_VAR=value
|
||
# theme default
|
||
# export BARE_OS_COLOR_DEPTH=truecolor
|
||
# export BARE_OS_DIRCOLORS=~/.dir_colors
|
||
# export BARE_OS_LS_COLORS_LOCKED=1
|
||
# alias gst='git status'
|
||
# unalias ll
|
||
`
|
||
|
||
/**
|
||
* Load `~/.barerc`: only `export`, `alias`, `unalias`, `theme`, comments, blank lines.
|
||
* Resets aliases to defaults first, then applies file.
|
||
* @param {Record<string, unknown>} ctx
|
||
* @param {{ createSkeletonIfMissing?: boolean }} [opts] If true and the file is missing, write {@link BARERC_SKELETON} (login / unlock only).
|
||
*/
|
||
export async function loadBarerc(ctx, opts = {}) {
|
||
const { createSkeletonIfMissing = false } = opts
|
||
const strict = globalThis.process?.env?.BARE_OS_STRICT_BARC === '1'
|
||
ctx.shellAliases = { ...defaultShellAliases() }
|
||
const vfs = ctx.vfs
|
||
const env = vfs.env
|
||
let buf = null
|
||
try {
|
||
buf = await vfs.readFile('~/.barerc')
|
||
} catch {
|
||
buf = null
|
||
}
|
||
|
||
let text = null
|
||
if (!buf && createSkeletonIfMissing) {
|
||
try {
|
||
await vfs.writeFile('~/.barerc', ctx.b4a.from(BARERC_SKELETON))
|
||
text = BARERC_SKELETON
|
||
} catch (e) {
|
||
ctx.console?.error?.(
|
||
'[bare-os] could not create ~/.barerc: ' + ((e && e.message) || e)
|
||
)
|
||
}
|
||
}
|
||
|
||
if (!text) {
|
||
if (!buf) {
|
||
await applyBareOsThemeFromEnv(ctx)
|
||
return
|
||
}
|
||
text = ctx.b4a.toString(buf)
|
||
}
|
||
for (const line of text.split(/\r?\n/)) {
|
||
const t = line.trim()
|
||
if (!t || t.startsWith('#')) continue
|
||
if (t.startsWith('export ')) {
|
||
const rest = t.slice(7).trim()
|
||
const eq = rest.indexOf('=')
|
||
if (eq > 0 && /^[A-Za-z_][A-Za-z0-9_]*$/.test(rest.slice(0, eq))) {
|
||
env[rest.slice(0, eq)] = expandWord(rest.slice(eq + 1), env)
|
||
} else if (strict) {
|
||
ctx.console?.error?.('barerc: ignored: ' + t)
|
||
}
|
||
continue
|
||
}
|
||
if (t.startsWith('theme ') || t === 'theme') {
|
||
const name = t === 'theme' ? '' : t.slice(6).trim()
|
||
if (!name) {
|
||
if (strict) ctx.console?.error?.('barerc: theme requires a name')
|
||
continue
|
||
}
|
||
if (!/^[a-zA-Z0-9_.-]+$/.test(name)) {
|
||
if (strict) ctx.console?.error?.('barerc: invalid theme name: ' + name)
|
||
continue
|
||
}
|
||
const norm = name.toLowerCase().replace(/\s+/g, '_')
|
||
if (!bareOsGetThemePreset(norm)) {
|
||
ctx.console?.error?.('barerc: unknown theme: ' + name)
|
||
continue
|
||
}
|
||
env.BARE_OS_THEME = norm
|
||
continue
|
||
}
|
||
if (t.startsWith('alias ')) {
|
||
const ok = applyAliasDefinition(ctx, t.slice(6).trim())
|
||
if (!ok && strict) ctx.console?.error?.('barerc: ignored: ' + t)
|
||
continue
|
||
}
|
||
if (t.startsWith('unalias ')) {
|
||
applyBarercUnalias(ctx, t.slice(8).trim())
|
||
continue
|
||
}
|
||
if (strict) ctx.console?.error?.('barerc: ignored: ' + t)
|
||
}
|
||
await applyBareOsThemeFromEnv(ctx)
|
||
}
|
||
|
||
/**
|
||
* @typedef {{ q: 'u' | 's' | 'd', t: string }} ShellWordPart
|
||
*/
|
||
|
||
/**
|
||
* @typedef {{ type: 'word', value: string, parts: ShellWordPart[] } | { type: 'op', value: string }} Token
|
||
*/
|
||
|
||
/** @param {Token} t */
|
||
export function shellWordText(t) {
|
||
return t && t.type === 'word' ? t.value : ''
|
||
}
|
||
|
||
/** @param {string} line */
|
||
export function tokenize(line) {
|
||
return /** @type {Token[]} */ (lexShellLine(line))
|
||
}
|
||
|
||
function collapseShellLineContinuations(s) {
|
||
return String(s || '').replace(/\\\r?\n/g, '')
|
||
}
|
||
|
||
/**
|
||
* @param {string} inner
|
||
* @param {Record<string, string>} env
|
||
*/
|
||
function expandParamBracedInner(inner, env, depth = 0) {
|
||
const expandNested = (s) => {
|
||
const txt = String(s ?? '')
|
||
if (!txt.includes('$')) return txt
|
||
return expandWord(txt, env, depth + 1)
|
||
}
|
||
const tr = inner.trim()
|
||
const lenParam = /^#([A-Za-z_][A-Za-z0-9_]*)$/.exec(tr)
|
||
if (lenParam) {
|
||
shellCheckUnboundParam(lenParam[1], env)
|
||
return String(String(env[lenParam[1]] ?? '').length)
|
||
}
|
||
const indirectOn =
|
||
env &&
|
||
(env.BARE_OS_SHELL_INDIRECT_EXPANSION === '1' ||
|
||
env.BARE_OS_SHELL_INDIRECT_EXPANSION === 'true')
|
||
const indirectName = /^!([A-Za-z_][A-Za-z0-9_]*)$/.exec(tr)
|
||
if (indirectOn && indirectName) {
|
||
const ref = String(env[indirectName[1]] ?? '')
|
||
shellCheckUnboundParam(ref, env)
|
||
return String(env[ref] ?? '')
|
||
}
|
||
const paramV2 =
|
||
env &&
|
||
(env.BARE_OS_SHELL_PARAM_EXPANSION_V2 === '1' ||
|
||
env.BARE_OS_SHELL_PARAM_EXPANSION_V2 === 'true')
|
||
const paramV3 =
|
||
env &&
|
||
(env.BARE_OS_SHELL_PARAM_EXPANSION_V3 === '1' ||
|
||
env.BARE_OS_SHELL_PARAM_EXPANSION_V3 === 'true')
|
||
|
||
const errIdx = inner.indexOf(':?')
|
||
if (paramV3 && errIdx > 0) {
|
||
const name = inner.slice(0, errIdx).trim()
|
||
const msg = inner.slice(errIdx + 2)
|
||
if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
|
||
const v = env[name]
|
||
if (v == null || String(v) === '') {
|
||
throw new Error(expandNested(msg) || 'parameter null or unset')
|
||
}
|
||
return String(v)
|
||
}
|
||
}
|
||
|
||
const assignIdx = inner.indexOf(':=')
|
||
if (paramV2 && assignIdx > 0) {
|
||
const name = inner.slice(0, assignIdx).trim()
|
||
const alt = inner.slice(assignIdx + 2)
|
||
if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
|
||
let v = env[name]
|
||
if (v == null || String(v) === '') {
|
||
const ex = expandNested(alt)
|
||
env[name] = ex
|
||
v = ex
|
||
}
|
||
return String(v ?? '')
|
||
}
|
||
}
|
||
|
||
const posixUnsetOnly =
|
||
env &&
|
||
(env.BARE_OS_SHELL_POSIX_UNSET_ONLY_DEFAULT === '1' ||
|
||
env.BARE_OS_SHELL_POSIX_UNSET_ONLY_DEFAULT === 'true')
|
||
if (posixUnsetOnly) {
|
||
const hy = inner.indexOf('-')
|
||
if (
|
||
hy > 0 &&
|
||
inner.slice(hy - 1, hy + 1) !== ':-' &&
|
||
!inner.includes(':')
|
||
) {
|
||
const m = /^([A-Za-z_][A-Za-z0-9_]*)-(.+)$/.exec(inner)
|
||
if (m && m[1] && m[2] != null) {
|
||
const name = m[1]
|
||
const alt = m[2]
|
||
if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
|
||
if (!Object.prototype.hasOwnProperty.call(env, name))
|
||
return expandNested(alt)
|
||
return String(env[name] ?? '')
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
const idx = inner.indexOf(':-')
|
||
if (idx > 0) {
|
||
const name = inner.slice(0, idx).trim()
|
||
if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
|
||
const alt = inner.slice(idx + 2)
|
||
const v = env[name]
|
||
if (v != null && String(v) !== '') return String(v)
|
||
return expandNested(alt)
|
||
}
|
||
}
|
||
|
||
const plusIdx = inner.indexOf(':+')
|
||
if (paramV3 && plusIdx > 0) {
|
||
const name = inner.slice(0, plusIdx).trim()
|
||
const alt = inner.slice(plusIdx + 2)
|
||
if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
|
||
const v = env[name]
|
||
if (v != null && String(v) !== '') return expandNested(alt)
|
||
return ''
|
||
}
|
||
}
|
||
|
||
if (paramV3) {
|
||
const sliceRe = /^([A-Za-z_][A-Za-z0-9_]*):(\d+)(?::(\d+))?$/.exec(tr)
|
||
if (sliceRe) {
|
||
const name = sliceRe[1]
|
||
const off = Number.parseInt(sliceRe[2], 10)
|
||
const ln =
|
||
sliceRe[3] != null ? Number.parseInt(sliceRe[3], 10) : undefined
|
||
const v = String(env[name] ?? '')
|
||
let out = Number.isFinite(off) ? v.slice(off) : v
|
||
if (ln != null && Number.isFinite(ln)) out = out.slice(0, ln)
|
||
return out
|
||
}
|
||
const globalRepl = /^([A-Za-z_][A-Za-z0-9_]*)\/\/(.*)\/(.*)$/.exec(tr)
|
||
if (globalRepl && globalRepl[2].length <= 256 && globalRepl[3].length <= 512) {
|
||
const name = globalRepl[1]
|
||
let v = String(env[name] ?? '')
|
||
const pat = globalRepl[2]
|
||
const rep = expandNested(globalRepl[3])
|
||
try {
|
||
const re = new RegExp(pat, 'g')
|
||
v = v.replace(re, rep)
|
||
} catch {
|
||
/* invalid regex — leave value */
|
||
}
|
||
return v
|
||
}
|
||
}
|
||
|
||
if (paramV2) {
|
||
const longPref = /^([A-Za-z_][A-Za-z0-9_]*)##(.+)$/.exec(inner)
|
||
if (longPref && longPref[2].length > 0 && longPref[2].length <= 128) {
|
||
const v = String(env[longPref[1]] ?? '')
|
||
const pat = longPref[2]
|
||
if (pat === '*/') {
|
||
const i = v.lastIndexOf('/')
|
||
return i >= 0 ? v.slice(i + 1) : v
|
||
}
|
||
let end = -1
|
||
for (let i = 0; i <= v.length - pat.length; i++) {
|
||
if (v.slice(i, i + pat.length) === pat) end = i + pat.length
|
||
}
|
||
return end >= 0 ? v.slice(end) : v
|
||
}
|
||
const shortPref = /^([A-Za-z_][A-Za-z0-9_]*)#(.+)$/.exec(inner)
|
||
if (shortPref && shortPref[2].length > 0 && shortPref[2].length <= 128) {
|
||
const v = String(env[shortPref[1]] ?? '')
|
||
const pat = shortPref[2]
|
||
if (pat === '*/') {
|
||
const i = v.indexOf('/')
|
||
return i >= 0 ? v.slice(i + 1) : v
|
||
}
|
||
const i = v.indexOf(pat)
|
||
return i >= 0 ? v.slice(i + pat.length) : v
|
||
}
|
||
const longSuf = /^([A-Za-z_][A-Za-z0-9_]*)%%(.+)$/.exec(inner)
|
||
if (longSuf && longSuf[2].length > 0 && longSuf[2].length <= 128) {
|
||
const v = String(env[longSuf[1]] ?? '')
|
||
const pat = longSuf[2]
|
||
if (!/[?*[]/.test(pat) && v.endsWith(pat))
|
||
return v.slice(0, v.length - pat.length)
|
||
if (pat.includes('*') && !pat.includes('[') && !pat.includes('?')) {
|
||
const parts = pat.split('*')
|
||
if (parts.length === 2) {
|
||
const a = parts[0]
|
||
const b = parts[1]
|
||
let best = -1
|
||
for (let len = 1; len <= v.length; len++) {
|
||
const suf = v.slice(v.length - len)
|
||
if (
|
||
suf.startsWith(a) &&
|
||
suf.endsWith(b) &&
|
||
suf.length >= a.length + b.length
|
||
) {
|
||
if (best < 0 || len > best) best = len
|
||
}
|
||
}
|
||
if (best > 0) return v.slice(0, v.length - best)
|
||
}
|
||
}
|
||
return v
|
||
}
|
||
const shortSuf = /^([A-Za-z_][A-Za-z0-9_]*)%(.+)$/.exec(inner)
|
||
if (shortSuf && shortSuf[2].length > 0 && shortSuf[2].length <= 128) {
|
||
const v = String(env[shortSuf[1]] ?? '')
|
||
const pat = shortSuf[2]
|
||
if (!/[?*[]/.test(pat) && v.endsWith(pat))
|
||
return v.slice(0, v.length - pat.length)
|
||
if (pat.includes('*') && !pat.includes('[') && !pat.includes('?')) {
|
||
const parts = pat.split('*')
|
||
if (parts.length === 2) {
|
||
const a = parts[0]
|
||
const b = parts[1]
|
||
let best = -1
|
||
for (let len = 1; len <= v.length; len++) {
|
||
const suf = v.slice(v.length - len)
|
||
if (
|
||
suf.startsWith(a) &&
|
||
suf.endsWith(b) &&
|
||
suf.length >= a.length + b.length
|
||
) {
|
||
if (best < 0 || len < best) best = len
|
||
}
|
||
}
|
||
if (best > 0) return v.slice(0, v.length - best)
|
||
}
|
||
}
|
||
return v
|
||
}
|
||
}
|
||
|
||
const hash = inner.indexOf('#')
|
||
if (hash > 0) {
|
||
const name = inner.slice(0, hash).trim()
|
||
const pref = inner.slice(hash + 1)
|
||
if (
|
||
/^[A-Za-z_][A-Za-z0-9_]*$/.test(name) &&
|
||
pref.length > 0 &&
|
||
pref.length <= 128
|
||
) {
|
||
const v = String(env[name] ?? '')
|
||
return v.startsWith(pref) ? v.slice(pref.length) : v
|
||
}
|
||
}
|
||
const keySimple = inner.trim()
|
||
shellCheckUnboundParam(keySimple, env)
|
||
return env[keySimple] ?? ''
|
||
}
|
||
|
||
/**
|
||
* @param {string} s
|
||
* @param {Record<string, string>} env
|
||
*/
|
||
export function expandWord(s, env, depth = 0) {
|
||
const maxDepthRaw = Number.parseInt(
|
||
String(env?.BARE_OS_SHELL_EXPANSION_MAX_DEPTH || '32'),
|
||
10
|
||
)
|
||
const maxDepth =
|
||
Number.isFinite(maxDepthRaw) && maxDepthRaw > 0 ? Math.min(maxDepthRaw, 256) : 32
|
||
if (depth > maxDepth) {
|
||
throw new Error(`shell: expansion recursion too deep (max ${maxDepth})`)
|
||
}
|
||
const paramExpOn =
|
||
env &&
|
||
(env.BARE_OS_SHELL_PARAM_EXPANSION === '1' ||
|
||
env.BARE_OS_SHELL_PARAM_EXPANSION === 'true')
|
||
const paramV2 =
|
||
env &&
|
||
(env.BARE_OS_SHELL_PARAM_EXPANSION_V2 === '1' ||
|
||
env.BARE_OS_SHELL_PARAM_EXPANSION_V2 === 'true')
|
||
const paramV3 =
|
||
env &&
|
||
(env.BARE_OS_SHELL_PARAM_EXPANSION_V3 === '1' ||
|
||
env.BARE_OS_SHELL_PARAM_EXPANSION_V3 === 'true')
|
||
let out = ''
|
||
let j = 0
|
||
while (j < s.length) {
|
||
if (s[j] === '$') {
|
||
if (s[j + 1] === '(' && s[j + 2] === '(') {
|
||
const close = findArithmeticClose(s, j + 3)
|
||
if (close < 0) {
|
||
out += s.slice(j)
|
||
break
|
||
}
|
||
const inner = s.slice(j + 3, close)
|
||
try {
|
||
out += bareOsEvalArithmeticExpr(inner, env)
|
||
} catch (e) {
|
||
if (
|
||
env?.BARE_OS_SHELL_POSIX_MODE === '1' ||
|
||
env?.BARE_OS_SHELL_POSIX_MODE === 'true'
|
||
) {
|
||
throw new Error(
|
||
'shell: arithmetic: invalid token (POSIX mode strict arithmetic)'
|
||
)
|
||
}
|
||
throw e
|
||
}
|
||
j = close + 2
|
||
continue
|
||
}
|
||
if (s[j + 1] === '{') {
|
||
const end = s.indexOf('}', j + 2)
|
||
if (end === -1) {
|
||
out += s.slice(j)
|
||
break
|
||
}
|
||
const inner = s.slice(j + 2, end)
|
||
const tr0 = inner.trim()
|
||
const indirectOnBr =
|
||
env?.BARE_OS_SHELL_INDIRECT_EXPANSION === '1' ||
|
||
env?.BARE_OS_SHELL_INDIRECT_EXPANSION === 'true'
|
||
if (inner === '?') {
|
||
out += env[BARE_OS_EXIT_STATUS_ENV] ?? '0'
|
||
} else if (
|
||
/^#[A-Za-z_][A-Za-z0-9_]*$/.test(tr0) ||
|
||
(indirectOnBr && /^![A-Za-z_][A-Za-z0-9_]*$/.test(tr0)) ||
|
||
(paramExpOn &&
|
||
(inner.includes(':-') ||
|
||
(paramV3 && (inner.includes(':+') || inner.includes(':?'))) ||
|
||
(paramV3 &&
|
||
(/^[A-Za-z_][A-Za-z0-9_]*:\d/.test(tr0) ||
|
||
/^[A-Za-z_][A-Za-z0-9_]*\/\//.test(inner))) ||
|
||
(paramV2 &&
|
||
(inner.includes(':=') ||
|
||
/^[A-Za-z_][A-Za-z0-9_]*##/.test(inner) ||
|
||
/^[A-Za-z_][A-Za-z0-9_]*%%/.test(inner) ||
|
||
/^[A-Za-z_][A-Za-z0-9_]*%[^%]/.test(inner) ||
|
||
/^[A-Za-z_][A-Za-z0-9_]*#[^#]/.test(inner))) ||
|
||
(/^[A-Za-z_][A-Za-z0-9_]*#/.test(inner) && inner.includes('#'))))
|
||
) {
|
||
out += expandParamBracedInner(inner, env, depth + 1)
|
||
} else {
|
||
const ik = inner.trim()
|
||
shellCheckUnboundParam(ik, env)
|
||
out += env[ik] ?? ''
|
||
}
|
||
j = end + 1
|
||
continue
|
||
}
|
||
if (s[j + 1] === '?') {
|
||
out += env[BARE_OS_EXIT_STATUS_ENV] ?? '0'
|
||
j += 2
|
||
continue
|
||
}
|
||
if (/[0-9]/.test(s[j + 1] ?? '')) {
|
||
const pn = s[j + 1]
|
||
shellCheckUnboundParam(pn, env)
|
||
out += env[pn] ?? ''
|
||
j += 2
|
||
continue
|
||
}
|
||
let k = j + 1
|
||
while (k < s.length && /[A-Za-z0-9_]/.test(s[k])) k++
|
||
const name = s.slice(j + 1, k)
|
||
if (name) {
|
||
shellCheckUnboundParam(name, env)
|
||
out += env[name] ?? ''
|
||
j = k
|
||
} else {
|
||
out += '$'
|
||
j++
|
||
}
|
||
continue
|
||
}
|
||
out += s[j++]
|
||
}
|
||
return out
|
||
}
|
||
|
||
/**
|
||
* @typedef {{
|
||
* argv: Extract<Token, { type: 'word' }>[],
|
||
* assign: Record<string, string>,
|
||
* redirIn: Extract<Token, { type: 'word' }> | null,
|
||
* redirOut: Extract<Token, { type: 'word' }> | null,
|
||
* redirAppend: boolean,
|
||
* redirErr: Extract<Token, { type: 'word' }> | null,
|
||
* redirErrAppend: boolean,
|
||
* mergeStderrToStdout: boolean,
|
||
* redirHereDoc: string | null
|
||
* }} SimpleCmd
|
||
*/
|
||
|
||
/**
|
||
* @param {Token[]} tokens
|
||
* @returns {SimpleCmd[][]}
|
||
*/
|
||
export function parsePipeline(tokens) {
|
||
/** @type {Token[][]} */
|
||
const pipes = [[]]
|
||
for (const t of tokens) {
|
||
if (t.type === 'op' && t.value === '|') {
|
||
pipes.push([])
|
||
} else {
|
||
pipes[pipes.length - 1].push(t)
|
||
}
|
||
}
|
||
|
||
return pipes.map((seg) => parseSimpleCommand(seg))
|
||
}
|
||
|
||
/**
|
||
* Structured syntax error that callers can route differently from expansion/runtime failures.
|
||
* @param {string} message
|
||
* @param {{ index?: number, phase?: 'tokenize' | 'parse' | 'expand' | 'runtime' }} [meta]
|
||
*/
|
||
export function bareOsShellError(message, meta = {}) {
|
||
const e = new Error(String(message || 'shell error'))
|
||
e.code = 'BARE_OS_SHELL_ERROR'
|
||
e.shellPhase = meta.phase || 'runtime'
|
||
if (Number.isFinite(meta.index)) e.shellIndex = Number(meta.index)
|
||
return e
|
||
}
|
||
|
||
/**
|
||
* Snapshot-friendly parse artifact for deterministic grammar tests.
|
||
* @param {string} line
|
||
* @returns {{
|
||
* schema: 1,
|
||
* line: string,
|
||
* diagnosticTokens: ReturnType<typeof tokenizeBareShellLineDetailed>,
|
||
* tokens: ReturnType<typeof tokenize>,
|
||
* pipeline: ReturnType<typeof parsePipeline>
|
||
* }}
|
||
*/
|
||
export function bareOsShellAstSnapshot(line) {
|
||
const src = String(line || '')
|
||
return {
|
||
schema: 1,
|
||
line: src,
|
||
diagnosticTokens: tokenizeBareShellLineDetailed(src),
|
||
tokens: tokenize(src),
|
||
pipeline: parsePipeline(tokenize(src))
|
||
}
|
||
}
|
||
|
||
/** @param {Token[]} seg */
|
||
function parseSimpleCommand(seg) {
|
||
/** @type {Record<string, string>} */
|
||
const assign = {}
|
||
/** @type {Extract<Token, { type: 'word' }> | null} */
|
||
let redirIn = null
|
||
/** @type {Extract<Token, { type: 'word' }> | null} */
|
||
let redirOut = null
|
||
let redirAppend = false
|
||
/** @type {Extract<Token, { type: 'word' }> | null} */
|
||
let redirErr = null
|
||
let redirErrAppend = false
|
||
let mergeStderrToStdout = false
|
||
/** @type {string | null} */
|
||
let redirHereDoc = null
|
||
/** @type {Extract<Token, { type: 'word' }>[]} */
|
||
const argvWords = []
|
||
let seenCommand = false
|
||
|
||
let w = 0
|
||
while (w < seg.length) {
|
||
const t = seg[w]
|
||
if (t.type === 'op') {
|
||
if (t.value === '2>' || t.value === '2>>') {
|
||
const n = seg[w + 1]
|
||
if (n && n.type === 'word') {
|
||
redirErrAppend = t.value === '2>>'
|
||
redirErr = n
|
||
w += 2
|
||
continue
|
||
}
|
||
}
|
||
if (t.value === '2>&1') {
|
||
mergeStderrToStdout = true
|
||
w++
|
||
continue
|
||
}
|
||
if (t.value === '>' || t.value === '>>') {
|
||
const n = seg[w + 1]
|
||
if (n && n.type === 'op' && n.value === '(') {
|
||
throw bareOsShellError(
|
||
'shell: process substitution >(…) is unsupported',
|
||
{ phase: 'parse' }
|
||
)
|
||
}
|
||
if (n && n.type === 'word') {
|
||
redirAppend = t.value === '>>'
|
||
redirOut = n
|
||
w += 2
|
||
continue
|
||
}
|
||
}
|
||
if (t.value === '<') {
|
||
const n = seg[w + 1]
|
||
if (n && n.type === 'op' && n.value === '(') {
|
||
throw bareOsShellError(
|
||
'shell: process substitution <(…) is unsupported',
|
||
{ phase: 'parse' }
|
||
)
|
||
}
|
||
if (n && n.type === 'word') {
|
||
redirIn = n
|
||
w += 2
|
||
continue
|
||
}
|
||
}
|
||
if (t.value === '<<' || t.value === '<<-') {
|
||
const n = seg[w + 1]
|
||
if (n && n.type === 'word') {
|
||
w += 2
|
||
continue
|
||
}
|
||
}
|
||
if (t.value === '<<<') {
|
||
const n = seg[w + 1]
|
||
if (n && n.type === 'word') {
|
||
redirHereDoc = n.value
|
||
w += 2
|
||
continue
|
||
}
|
||
}
|
||
w++
|
||
continue
|
||
}
|
||
|
||
const v = t.value
|
||
if (!seenCommand) {
|
||
const eq = v.indexOf('=')
|
||
if (eq > 0 && /^[A-Za-z_][A-Za-z0-9_]*$/.test(v.slice(0, eq))) {
|
||
assign[v.slice(0, eq)] = v.slice(eq + 1)
|
||
w++
|
||
continue
|
||
}
|
||
}
|
||
seenCommand = true
|
||
argvWords.push(/** @type {Extract<Token, { type: 'word' }>} */ (t))
|
||
w++
|
||
}
|
||
|
||
let i = 0
|
||
while (i < argvWords.length) {
|
||
const wt = (k) => shellWordText(argvWords[i + k])
|
||
if (
|
||
wt(0) === '2' &&
|
||
wt(1) === '>' &&
|
||
wt(2) === '&' &&
|
||
wt(3) === '1'
|
||
) {
|
||
mergeStderrToStdout = true
|
||
argvWords.splice(i, 4)
|
||
continue
|
||
}
|
||
if (wt(0) === '2' && wt(1) === '>') {
|
||
redirErr = argvWords[i + 2] ?? null
|
||
redirErrAppend = false
|
||
argvWords.splice(i, 3)
|
||
continue
|
||
}
|
||
if (wt(0) === '2' && wt(1) === '>>') {
|
||
redirErr = argvWords[i + 2] ?? null
|
||
redirErrAppend = true
|
||
argvWords.splice(i, 3)
|
||
continue
|
||
}
|
||
if (wt(0) === '>') {
|
||
redirOut = argvWords[i + 1] ?? null
|
||
redirAppend = false
|
||
argvWords.splice(i, 2)
|
||
continue
|
||
}
|
||
if (wt(0) === '>>') {
|
||
redirOut = argvWords[i + 1] ?? null
|
||
redirAppend = true
|
||
argvWords.splice(i, 2)
|
||
continue
|
||
}
|
||
if (wt(0) === '<') {
|
||
redirIn = argvWords[i + 1] ?? null
|
||
argvWords.splice(i, 2)
|
||
continue
|
||
}
|
||
if (wt(0) === '<<<') {
|
||
redirHereDoc = wt(1) || ''
|
||
argvWords.splice(i, 2)
|
||
continue
|
||
}
|
||
if (wt(0) === '<<' || wt(0) === '<<-') {
|
||
argvWords.splice(i, 2)
|
||
continue
|
||
}
|
||
i++
|
||
}
|
||
|
||
return {
|
||
argv: argvWords,
|
||
assign,
|
||
redirIn,
|
||
redirOut,
|
||
redirAppend,
|
||
redirErr,
|
||
redirErrAppend,
|
||
mergeStderrToStdout,
|
||
redirHereDoc
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Split token list on `;` into separate commands (AND-OR lists).
|
||
* @param {Token[]} tokens
|
||
* @returns {Token[][]}
|
||
*/
|
||
export function splitTokensBySemicolon(tokens) {
|
||
/** @type {Token[][]} */
|
||
const lists = []
|
||
/** @type {Token[]} */
|
||
let cur = []
|
||
let kwDepth = 0
|
||
let parenDepth = 0
|
||
let braceDepth = 0
|
||
for (const t of tokens) {
|
||
if (t.type === 'word') {
|
||
if (
|
||
t.value === 'if' ||
|
||
t.value === 'while' ||
|
||
t.value === 'until' ||
|
||
t.value === 'for' ||
|
||
t.value === 'select' ||
|
||
t.value === 'case'
|
||
)
|
||
kwDepth++
|
||
else if (
|
||
t.value === 'fi' ||
|
||
t.value === 'done' ||
|
||
t.value === 'esac'
|
||
)
|
||
kwDepth = Math.max(0, kwDepth - 1)
|
||
} else if (t.type === 'op') {
|
||
if (t.value === '(') parenDepth++
|
||
else if (t.value === ')') parenDepth = Math.max(0, parenDepth - 1)
|
||
else if (t.value === '{') braceDepth++
|
||
else if (t.value === '}') braceDepth = Math.max(0, braceDepth - 1)
|
||
}
|
||
if (
|
||
t.type === 'op' &&
|
||
t.value === ';' &&
|
||
kwDepth === 0 &&
|
||
parenDepth === 0 &&
|
||
braceDepth === 0
|
||
) {
|
||
lists.push(cur)
|
||
cur = []
|
||
} else {
|
||
cur.push(t)
|
||
}
|
||
}
|
||
lists.push(cur)
|
||
return lists
|
||
}
|
||
|
||
/**
|
||
* Split one semicolon-separated list on `&&` / `||` (left-associative chain).
|
||
* @param {Token[]} tokens
|
||
* @returns {{ segments: Token[][], ops: string[] }}
|
||
*/
|
||
export function splitTokensByAndOr(tokens) {
|
||
/** @type {Token[][]} */
|
||
const segments = []
|
||
/** @type {string[]} */
|
||
const ops = []
|
||
/** @type {Token[]} */
|
||
let cur = []
|
||
let kwDepth = 0
|
||
let parenDepth = 0
|
||
let braceDepth = 0
|
||
for (const t of tokens) {
|
||
if (t.type === 'word') {
|
||
if (
|
||
t.value === 'if' ||
|
||
t.value === 'while' ||
|
||
t.value === 'until' ||
|
||
t.value === 'for' ||
|
||
t.value === 'select' ||
|
||
t.value === 'case'
|
||
)
|
||
kwDepth++
|
||
else if (
|
||
t.value === 'fi' ||
|
||
t.value === 'done' ||
|
||
t.value === 'esac'
|
||
)
|
||
kwDepth = Math.max(0, kwDepth - 1)
|
||
} else if (t.type === 'op') {
|
||
if (t.value === '(') parenDepth++
|
||
else if (t.value === ')') parenDepth = Math.max(0, parenDepth - 1)
|
||
else if (t.value === '{') braceDepth++
|
||
else if (t.value === '}') braceDepth = Math.max(0, braceDepth - 1)
|
||
}
|
||
if (
|
||
t.type === 'op' &&
|
||
(t.value === '&&' || t.value === '||') &&
|
||
kwDepth === 0 &&
|
||
parenDepth === 0 &&
|
||
braceDepth === 0
|
||
) {
|
||
segments.push(cur)
|
||
ops.push(t.value)
|
||
cur = []
|
||
} else {
|
||
cur.push(t)
|
||
}
|
||
}
|
||
segments.push(cur)
|
||
return { segments, ops }
|
||
}
|
||
|
||
/** @param {Token[]} seg */
|
||
function segmentHasCommand(seg) {
|
||
if (!seg.length) return false
|
||
try {
|
||
const cmd = parseSimpleCommand(seg)
|
||
return cmd.argv.length > 0 || Object.keys(cmd.assign).length > 0
|
||
} catch (e) {
|
||
if (e && /** @type {{ code?: string }} */ (e).code === 'BARE_OS_SHELL_ERROR')
|
||
return true
|
||
throw e
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @param {string} s
|
||
* @param {number} dollarIdx index of `$` in a `$(…)` command substitution
|
||
*/
|
||
function findCmdSubstCloseParen(s, dollarIdx) {
|
||
if (s[dollarIdx] !== '$' || s[dollarIdx + 1] !== '(') return -1
|
||
let depth = 1
|
||
for (let j = dollarIdx + 2; j < s.length; j++) {
|
||
if (s[j] === '(') depth++
|
||
else if (s[j] === ')') {
|
||
depth--
|
||
if (depth === 0) return j
|
||
}
|
||
}
|
||
return -1
|
||
}
|
||
|
||
/**
|
||
* Next `$(` that is command substitution (skip `$((` arithmetic regions).
|
||
* @param {string} s
|
||
* @param {number} from
|
||
*/
|
||
function findNextCmdSubstParen(s, from) {
|
||
let i = from
|
||
while (i < s.length) {
|
||
const j = s.indexOf('$(', i)
|
||
if (j < 0) return -1
|
||
if (j + 2 < s.length && s[j + 2] === '(') {
|
||
const ac = findArithmeticClose(s, j + 3)
|
||
if (ac < 0) return -1
|
||
i = ac + 2
|
||
continue
|
||
}
|
||
return j
|
||
}
|
||
return -1
|
||
}
|
||
|
||
/**
|
||
* @param {string} s
|
||
* @param {number} from scan from here (first char inside / after opener)
|
||
*/
|
||
function findBacktickClose(s, from) {
|
||
return s.indexOf('`', from)
|
||
}
|
||
|
||
/**
|
||
* @param {Record<string, unknown>} ctx
|
||
* @param {string} inner
|
||
* @param {Record<string, string>} env
|
||
*/
|
||
async function expandCmdsubstEmbedded(ctx, inner, env) {
|
||
const ex = typeof ctx.execLine === 'function' ? ctx.execLine : null
|
||
if (!ex) throw new Error('shell: cmdsubst: execLine unavailable')
|
||
const maxLen =
|
||
Number.parseInt(env.BARE_OS_SHELL_CMDSUBST_MAX_BYTES || '8192', 10) || 8192
|
||
const lines = []
|
||
const prev = ctx.console.log
|
||
ctx.console.log = (...a) => {
|
||
lines.push(a.map(String).join(' '))
|
||
}
|
||
try {
|
||
await ex(inner.trim())
|
||
} finally {
|
||
ctx.console.log = prev
|
||
}
|
||
let out = lines.join('\n').replace(/\n+$/, '')
|
||
if (out.length > maxLen) out = out.slice(0, maxLen)
|
||
const totalBudgetRaw = Number.parseInt(
|
||
String(env.BARE_OS_SHELL_EXPANSION_MAX_BYTES || '262144'),
|
||
10
|
||
)
|
||
const totalBudget =
|
||
Number.isFinite(totalBudgetRaw) && totalBudgetRaw > 0
|
||
? Math.min(totalBudgetRaw, 8 * 1024 * 1024)
|
||
: 262144
|
||
if (out.length > totalBudget) {
|
||
throw new Error(
|
||
`shell: expansion exceeds BARE_OS_SHELL_EXPANSION_MAX_BYTES (${totalBudget})`
|
||
)
|
||
}
|
||
return out
|
||
}
|
||
|
||
/**
|
||
* Optional trace hook for expansion ordering.
|
||
* Order is: parameter/command/arithmetic, then split/glob.
|
||
* @param {Record<string, unknown>} ctx
|
||
* @param {Record<string, string>} env
|
||
* @param {Record<string, unknown>} row
|
||
*/
|
||
function maybeTraceShellExpansion(ctx, env, row) {
|
||
const on =
|
||
env.BARE_OS_SHELL_EXPANSION_TRACE === '1' ||
|
||
env.BARE_OS_SHELL_EXPANSION_TRACE === 'true'
|
||
if (!on) return
|
||
if (!Array.isArray(ctx.shellExpansionTrace)) ctx.shellExpansionTrace = []
|
||
ctx.shellExpansionTrace.push({
|
||
ts: Date.now(),
|
||
...row
|
||
})
|
||
}
|
||
|
||
/**
|
||
* @param {Record<string, unknown>} ctx
|
||
* @param {string} s
|
||
* @param {Record<string, string>} env
|
||
* @param {number} [depth]
|
||
*/
|
||
async function expandWordWithCmdSubst(ctx, s, env, depth = 0) {
|
||
const maxDepth = 2
|
||
if (depth > maxDepth) throw new Error('shell: cmdsubst: nesting too deep')
|
||
const rawCmdOn = String(env.BARE_OS_SHELL_CMDSUBST || '').trim().toLowerCase()
|
||
const cmdOn = !(rawCmdOn === '0' || rawCmdOn === 'false' || rawCmdOn === 'off')
|
||
if (!cmdOn) return expandWord(s, env)
|
||
|
||
const tick = findBacktickClose(s, 0)
|
||
const dol = findNextCmdSubstParen(s, 0)
|
||
|
||
/** @type {'tick'|'dol'|null} */
|
||
let kind = null
|
||
let pos = -1
|
||
if (tick >= 0 && (dol < 0 || tick < dol)) {
|
||
kind = 'tick'
|
||
pos = tick
|
||
} else if (dol >= 0) {
|
||
kind = 'dol'
|
||
pos = dol
|
||
} else {
|
||
return expandWord(s, env)
|
||
}
|
||
|
||
if (kind === 'dol') {
|
||
const closeParen = findCmdSubstCloseParen(s, pos)
|
||
if (closeParen < 0) return expandWord(s, env)
|
||
const inner = s.slice(pos + 2, closeParen)
|
||
const pre = s.slice(0, pos)
|
||
const post = s.slice(closeParen + 1)
|
||
const mid = await expandCmdsubstEmbedded(ctx, inner, env)
|
||
const merged = pre + mid + post
|
||
return expandWordWithCmdSubst(ctx, merged, env, depth + 1)
|
||
}
|
||
|
||
const closeTick = findBacktickClose(s, pos + 1)
|
||
if (closeTick < 0) return expandWord(s, env)
|
||
const inner = s.slice(pos + 1, closeTick)
|
||
const pre = s.slice(0, pos)
|
||
const post = s.slice(closeTick + 1)
|
||
const mid = await expandCmdsubstEmbedded(ctx, inner, env)
|
||
const merged = pre + mid + post
|
||
return expandWordWithCmdSubst(ctx, merged, env, depth + 1)
|
||
}
|
||
|
||
/**
|
||
* Param / command-substitution expansion per quote segment, then pathname expansion.
|
||
* Expansion ordering (declared profile): parameter/command/arithmetic -> word split -> glob.
|
||
* @param {Record<string, unknown>} ctx
|
||
* @param {Extract<Token, { type: 'word' }>} wordTok
|
||
* @param {Record<string, string>} env
|
||
* @param {{ redirect?: boolean, disablePathnameExpansion?: boolean }} [globOpts]
|
||
* @returns {Promise<string[]>}
|
||
*/
|
||
async function expandShellWordTokens(ctx, wordTok, env, globOpts) {
|
||
const parts =
|
||
wordTok.parts && wordTok.parts.length
|
||
? wordTok.parts
|
||
: [{ q: /** @type {'u'} */ ('u'), t: wordTok.value }]
|
||
/** @type {ShellWordPart[]} */
|
||
const ep = []
|
||
for (const p of parts) {
|
||
if (p.q === 's') ep.push(p)
|
||
else {
|
||
maybeTraceShellExpansion(ctx, env, {
|
||
stage: 'expand-pre',
|
||
quote: p.q,
|
||
input: p.t
|
||
})
|
||
const s = await expandWordWithCmdSubst(ctx, p.t, env, 0)
|
||
maybeTraceShellExpansion(ctx, env, {
|
||
stage: 'expand-post',
|
||
quote: p.q,
|
||
output: s
|
||
})
|
||
ep.push({ q: p.q, t: s })
|
||
}
|
||
}
|
||
const out = await pathnameExpandShellWord(ctx, ep, env, globOpts || {})
|
||
maybeTraceShellExpansion(ctx, env, {
|
||
stage: 'split-glob',
|
||
outputCount: out.length,
|
||
output: out.slice(0, 8)
|
||
})
|
||
const budgetRaw = Number.parseInt(
|
||
String(env.BARE_OS_SHELL_EXPANSION_MAX_BYTES || '262144'),
|
||
10
|
||
)
|
||
const budget =
|
||
Number.isFinite(budgetRaw) && budgetRaw > 0
|
||
? Math.min(budgetRaw, 8 * 1024 * 1024)
|
||
: 262144
|
||
const bytes = out.reduce((n, s) => n + String(s).length, 0)
|
||
if (bytes > budget) {
|
||
throw new Error(
|
||
`shell: expansion exceeds BARE_OS_SHELL_EXPANSION_MAX_BYTES (${budget})`
|
||
)
|
||
}
|
||
return out
|
||
}
|
||
|
||
/**
|
||
* Normalized redirection plan independent from execution side-effects.
|
||
* @param {SimpleCmd} cmd
|
||
*/
|
||
export function planShellRedirections(cmd) {
|
||
return {
|
||
stdin: cmd.redirHereDoc != null ? 'heredoc' : cmd.redirIn ? 'file' : 'inherit',
|
||
stdout: cmd.redirOut ? (cmd.redirAppend ? 'append' : 'truncate') : 'inherit',
|
||
stderr: cmd.mergeStderrToStdout
|
||
? 'stdout'
|
||
: cmd.redirErr
|
||
? cmd.redirErrAppend
|
||
? 'append'
|
||
: 'truncate'
|
||
: 'inherit'
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Execution graph (lists -> and/or -> pipelines) for debugging and tests.
|
||
* @param {string} line
|
||
*/
|
||
export function buildShellExecutionGraph(line) {
|
||
const tokens = tokenize(String(line || ''))
|
||
const lists = splitTokensBySemicolon(tokens)
|
||
const graph = {
|
||
schema: 1,
|
||
line: String(line || ''),
|
||
listCount: lists.length,
|
||
lists: []
|
||
}
|
||
for (const list of lists) {
|
||
const { segments, ops } = splitTokensByAndOr(list)
|
||
const entry = {
|
||
andOrOps: ops.slice(),
|
||
segments: []
|
||
}
|
||
for (const seg of segments) {
|
||
let pipe
|
||
try {
|
||
pipe = parsePipeline(seg)
|
||
} catch (e) {
|
||
const code = e && /** @type {{ code?: string }} */ (e).code
|
||
entry.segments.push({
|
||
parseError:
|
||
code === 'BARE_OS_SHELL_ERROR'
|
||
? (e && /** @type {Error} */ (e).message) || String(e)
|
||
: String((e && /** @type {Error} */ (e).message) || e)
|
||
})
|
||
continue
|
||
}
|
||
entry.segments.push({
|
||
pipelineLength: pipe.length,
|
||
commands: pipe.map((cmd) => ({
|
||
argv: cmd.argv.map((w) => shellWordText(w)),
|
||
redirections: planShellRedirections(cmd)
|
||
}))
|
||
})
|
||
}
|
||
graph.lists.push(entry)
|
||
}
|
||
return graph
|
||
}
|
||
|
||
/**
|
||
* @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
|
||
}
|
||
|
||
/**
|
||
* Shallow clone for pipeline **`/bin`** execution: session **`env`**, optional **`shellStdin`**, and
|
||
* **`bareOsStdoutCaptured`** when stdout is captured (pipe to next stage or **`>`** redirect) so
|
||
* utilities (e.g. **`ls`**) can use one-record-per-line output.
|
||
* @param {Record<string, unknown>} ctx
|
||
* @param {Record<string, string>} env
|
||
* @param {string | null} stdinText
|
||
* @param {boolean} bareOsStdoutCaptured
|
||
*/
|
||
function bareOsPipelineChildCtx(ctx, env, stdinText, bareOsStdoutCaptured) {
|
||
const o = Object.assign({}, ctx, {
|
||
env: env && typeof env === 'object' ? { ...env } : env,
|
||
bareOsStdoutCaptured
|
||
})
|
||
if (stdinText != null) o.shellStdin = stdinText
|
||
return o
|
||
}
|
||
|
||
/**
|
||
* Convert raw binary chunks into shell pipeline capture text.
|
||
* Pipeline transport is text-based, so bytes are mapped 1:1 via char codes.
|
||
* @param {string | Uint8Array} chunk
|
||
*/
|
||
function bareOsPipelineRawChunkToText(chunk) {
|
||
if (typeof chunk === 'string') return chunk
|
||
if (!(chunk instanceof Uint8Array)) return String(chunk)
|
||
let s = ''
|
||
for (let i = 0; i < chunk.length; i++) s += String.fromCharCode(chunk[i])
|
||
return s
|
||
}
|
||
|
||
/**
|
||
* Optional timeout guard for potentially stalled pipeline stages.
|
||
* @param {Record<string, string>} env
|
||
* @param {() => Promise<unknown>} run
|
||
* @param {string} label
|
||
*/
|
||
async function runWithShellPipelineStageTimeout(env, run, label) {
|
||
const raw = Number.parseInt(String(env.BARE_OS_SHELL_PIPELINE_STAGE_TIMEOUT_MS || ''), 10)
|
||
const timeoutMs = Number.isFinite(raw) && raw > 0 ? Math.min(raw, 120000) : 0
|
||
if (!timeoutMs) return await run()
|
||
/** @type {ReturnType<typeof setTimeout> | null} */
|
||
let timer = null
|
||
try {
|
||
return await Promise.race([
|
||
run(),
|
||
new Promise((_, reject) => {
|
||
timer = setTimeout(() => {
|
||
reject(
|
||
new Error(
|
||
`shell: pipeline stage timeout (${timeoutMs}ms): ${label}`
|
||
)
|
||
)
|
||
}, timeoutMs)
|
||
})
|
||
])
|
||
} finally {
|
||
if (timer) clearTimeout(timer)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @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'
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Split on `;` only at depth 0 (`if` / `fi` nesting).
|
||
* @param {Token[]} tokens
|
||
* @returns {Token[][]}
|
||
*/
|
||
function splitTopLevelStatements(tokens) {
|
||
/** @type {Token[][]} */
|
||
const out = []
|
||
/** @type {Token[]} */
|
||
let cur = []
|
||
let depth = 0
|
||
let parenDepth = 0
|
||
let braceDepth = 0
|
||
for (const t of tokens) {
|
||
if (t.type === 'word') {
|
||
if (t.value === 'if') depth++
|
||
else if (t.value === 'fi') depth = Math.max(0, depth - 1)
|
||
else if (
|
||
t.value === 'while' ||
|
||
t.value === 'until' ||
|
||
t.value === 'for' ||
|
||
t.value === 'select'
|
||
)
|
||
depth++
|
||
else if (t.value === 'done') depth = Math.max(0, depth - 1)
|
||
else if (t.value === 'case') depth++
|
||
else if (t.value === 'esac') depth = Math.max(0, depth - 1)
|
||
} else if (t.type === 'op') {
|
||
if (t.value === '(') parenDepth++
|
||
else if (t.value === ')') parenDepth = Math.max(0, parenDepth - 1)
|
||
else if (t.value === '{') braceDepth++
|
||
else if (t.value === '}') braceDepth = Math.max(0, braceDepth - 1)
|
||
}
|
||
if (
|
||
t.type === 'op' &&
|
||
t.value === ';' &&
|
||
depth === 0 &&
|
||
parenDepth === 0 &&
|
||
braceDepth === 0
|
||
) {
|
||
if (cur.length) out.push(cur)
|
||
cur = []
|
||
} else {
|
||
cur.push(t)
|
||
}
|
||
}
|
||
if (cur.length) out.push(cur)
|
||
return out
|
||
}
|
||
|
||
/**
|
||
* Split on `&` at depth 0 (outside `if`/`fi`). Each segment except the last runs in the background.
|
||
* @param {Token[]} tokens
|
||
* @returns {Token[][]}
|
||
*/
|
||
function splitTopLevelByAmpersand(tokens) {
|
||
/** @type {Token[][]} */
|
||
const out = []
|
||
/** @type {Token[]} */
|
||
let cur = []
|
||
let depth = 0
|
||
let parenDepth = 0
|
||
let braceDepth = 0
|
||
for (const t of tokens) {
|
||
if (t.type === 'word') {
|
||
if (t.value === 'if') depth++
|
||
else if (t.value === 'fi') depth = Math.max(0, depth - 1)
|
||
else if (
|
||
t.value === 'while' ||
|
||
t.value === 'until' ||
|
||
t.value === 'for' ||
|
||
t.value === 'select'
|
||
)
|
||
depth++
|
||
else if (t.value === 'done') depth = Math.max(0, depth - 1)
|
||
else if (t.value === 'case') depth++
|
||
else if (t.value === 'esac') depth = Math.max(0, depth - 1)
|
||
} else if (t.type === 'op') {
|
||
if (t.value === '(') parenDepth++
|
||
else if (t.value === ')') parenDepth = Math.max(0, parenDepth - 1)
|
||
else if (t.value === '{') braceDepth++
|
||
else if (t.value === '}') braceDepth = Math.max(0, braceDepth - 1)
|
||
}
|
||
if (
|
||
t.type === 'op' &&
|
||
t.value === '&' &&
|
||
depth === 0 &&
|
||
parenDepth === 0 &&
|
||
braceDepth === 0
|
||
) {
|
||
out.push(cur)
|
||
cur = []
|
||
} else {
|
||
cur.push(t)
|
||
}
|
||
}
|
||
out.push(cur)
|
||
return out
|
||
}
|
||
|
||
/**
|
||
* @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)'} &`)
|
||
}
|
||
|
||
/**
|
||
* @param {Token[]} tokens
|
||
* @param {number} start index after `if`
|
||
*/
|
||
function findThenIndex(tokens, start) {
|
||
let d = 1
|
||
for (let j = start; j < tokens.length; j++) {
|
||
const t = tokens[j]
|
||
if (t.type !== 'word') continue
|
||
if (t.value === 'if') d++
|
||
else if (t.value === 'fi') d--
|
||
else if (t.value === 'then' && d === 1) return j
|
||
}
|
||
return -1
|
||
}
|
||
|
||
/**
|
||
* After `then`, find `else` at depth 1 or closing `fi` at depth 0.
|
||
* @param {Token[]} tokens
|
||
* @param {number} start index after `then` token
|
||
* @returns {{ kind: 'else' | 'fi', idx: number } | null}
|
||
*/
|
||
function findElseOrFiAfterThen(tokens, start) {
|
||
let d = 1
|
||
for (let j = start; j < tokens.length; j++) {
|
||
const t = tokens[j]
|
||
if (t.type !== 'word') continue
|
||
if (t.value === 'if') d++
|
||
else if (t.value === 'fi') {
|
||
d--
|
||
if (d === 0) return { kind: 'fi', idx: j }
|
||
} else if (t.value === 'else' && d === 1) return { kind: 'else', idx: j }
|
||
}
|
||
return null
|
||
}
|
||
|
||
/**
|
||
* @param {Token[]} tokens
|
||
* @param {number} start index after `else`
|
||
*/
|
||
function findFiAfterElse(tokens, start) {
|
||
let d = 1
|
||
for (let j = start; j < tokens.length; j++) {
|
||
const t = tokens[j]
|
||
if (t.type !== 'word') continue
|
||
if (t.value === 'if') d++
|
||
else if (t.value === 'fi') {
|
||
d--
|
||
if (d === 0) return j
|
||
}
|
||
}
|
||
return -1
|
||
}
|
||
|
||
/**
|
||
* `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 */
|
||
function isValidShellIdentifier(name) {
|
||
return /^[A-Za-z_][A-Za-z0-9_]*$/.test(name)
|
||
}
|
||
|
||
/**
|
||
* @param {Token[]} stmt
|
||
* @returns {{ name: string, body: Token[] } | null}
|
||
*/
|
||
function parseShellFunctionDeclaration(stmt) {
|
||
const n = stmt.length
|
||
if (n < 5) return null
|
||
let name = ''
|
||
let bodyStart = -1
|
||
if (
|
||
stmt[0]?.type === 'word' &&
|
||
stmt[0].value === 'function' &&
|
||
stmt[1]?.type === 'word'
|
||
) {
|
||
name = stmt[1].value
|
||
if (
|
||
stmt[2]?.type === 'op' &&
|
||
stmt[2].value === '(' &&
|
||
stmt[3]?.type === 'op' &&
|
||
stmt[3].value === ')'
|
||
) {
|
||
if (stmt[4]?.type === 'op' && stmt[4].value === '{') bodyStart = 5
|
||
} else if (stmt[2]?.type === 'op' && stmt[2].value === '{') {
|
||
bodyStart = 3
|
||
}
|
||
} else if (
|
||
stmt[0]?.type === 'word' &&
|
||
stmt[1]?.type === 'op' &&
|
||
stmt[1].value === '(' &&
|
||
stmt[2]?.type === 'op' &&
|
||
stmt[2].value === ')' &&
|
||
stmt[3]?.type === 'op' &&
|
||
stmt[3].value === '{'
|
||
) {
|
||
name = stmt[0].value
|
||
bodyStart = 4
|
||
}
|
||
if (!name || bodyStart < 0 || !isValidShellIdentifier(name)) return null
|
||
let depth = 1
|
||
let close = -1
|
||
for (let i = bodyStart; i < stmt.length; i++) {
|
||
const t = stmt[i]
|
||
if (t.type === 'op' && t.value === '{') depth++
|
||
else if (t.type === 'op' && t.value === '}') {
|
||
depth--
|
||
if (depth === 0) {
|
||
close = i
|
||
break
|
||
}
|
||
}
|
||
}
|
||
if (close < 0 || close !== stmt.length - 1) return null
|
||
const body = stmt.slice(bodyStart, close)
|
||
if (body.length && body[body.length - 1]?.type === 'op' && body[body.length - 1].value === ';') {
|
||
body.pop()
|
||
}
|
||
return { name, body }
|
||
}
|
||
|
||
/**
|
||
* @param {Token[]} tokens
|
||
* @returns {number}
|
||
*/
|
||
function findWhileDoSplit(tokens) {
|
||
for (let j = 1; j < tokens.length - 2; j++) {
|
||
const t = tokens[j]
|
||
if (t.type === 'op' && t.value === ';') {
|
||
const n = tokens[j + 1]
|
||
if (n && n.type === 'word' && n.value === 'do') return j
|
||
}
|
||
}
|
||
return -1
|
||
}
|
||
|
||
/**
|
||
* @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'
|
||
}
|
||
|
||
/**
|
||
* @param {Token[]} toks
|
||
* @param {Record<string, string>} env
|
||
* @returns {string[]}
|
||
*/
|
||
function casePatternList(toks, env) {
|
||
/** @type {string[]} */
|
||
const out = []
|
||
/** @type {Token[]} */
|
||
let cur = []
|
||
for (const t of toks) {
|
||
if (t.type === 'op' && t.value === '|') {
|
||
if (cur.length) {
|
||
const s = cur.map((w) => w.value).join(' ')
|
||
out.push(expandWord(s.trim(), env))
|
||
cur = []
|
||
}
|
||
} else if (t.type === 'word') {
|
||
cur.push(t)
|
||
}
|
||
}
|
||
if (cur.length) {
|
||
const s = cur.map((w) => w.value).join(' ')
|
||
out.push(expandWord(s.trim(), env))
|
||
}
|
||
return out.filter(Boolean)
|
||
}
|
||
|
||
/**
|
||
* @param {string} subject
|
||
* @param {string} pat
|
||
*/
|
||
function casePatternMatches(subject, pat) {
|
||
if (pat.includes('|')) {
|
||
return pat.split('|').some((p) => casePatternMatches(subject, p.trim()))
|
||
}
|
||
if (pat === '*') return true
|
||
if (!/[*?\[]/.test(pat)) return subject === pat
|
||
return bareOsFnmatch(subject, pat)
|
||
}
|
||
|
||
/**
|
||
* `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'
|
||
}
|
||
|
||
/**
|
||
* @param {Record<string, unknown>} ctx
|
||
* @param {Token[]} stmt
|
||
* @returns {Promise<'exit' | 'ok'>}
|
||
*/
|
||
/**
|
||
* @param {Record<string, unknown>} ctx
|
||
* @param {{ type: string, value: string }[]} rest
|
||
*/
|
||
async function execShellLocalBuiltin(ctx, rest) {
|
||
const vfs = ctx.vfs
|
||
const env = vfs?.env
|
||
if (!env || typeof env !== 'object') {
|
||
ctx.exitCode = 0
|
||
return 'ok'
|
||
}
|
||
let n = 0
|
||
for (const t of rest) {
|
||
if (++n > 48) break
|
||
if (t.type !== 'word') continue
|
||
const eq = t.value.indexOf('=')
|
||
if (eq <= 0) continue
|
||
const name = t.value.slice(0, eq).trim()
|
||
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) continue
|
||
env[name] = expandWord(t.value.slice(eq + 1), env)
|
||
}
|
||
ctx.exitCode = 0
|
||
return 'ok'
|
||
}
|
||
|
||
/**
|
||
* @param {Record<string, unknown>} ctx
|
||
* @param {{ type: string, value: string }[]} rest
|
||
* @returns {'ok' | null}
|
||
*/
|
||
function tryExecShellDeclareBuiltin(ctx, rest) {
|
||
const vfs = ctx.vfs
|
||
const env = vfs?.env
|
||
if (!env || typeof env !== 'object') return 'ok'
|
||
if (rest[0]?.type !== 'word' || rest[0].value !== '-r') return null
|
||
let n = 0
|
||
for (let i = 1; i < rest.length; i++) {
|
||
if (++n > 32) break
|
||
const t = rest[i]
|
||
if (t.type !== 'word') continue
|
||
const eq = t.value.indexOf('=')
|
||
if (eq <= 0) continue
|
||
const name = t.value.slice(0, eq).trim()
|
||
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) continue
|
||
env[name] = expandWord(t.value.slice(eq + 1), env)
|
||
}
|
||
ctx.exitCode = 0
|
||
return 'ok'
|
||
}
|
||
|
||
/** Reserved words that cannot begin a simple or compound statement (POSIX-style). */
|
||
const BARE_OS_SHELL_MISPLACED_STATEMENT_START = new Set([
|
||
'then',
|
||
'else',
|
||
'elif',
|
||
'fi',
|
||
'do',
|
||
'done',
|
||
'esac',
|
||
'in'
|
||
])
|
||
|
||
/**
|
||
* @param {Record<string, unknown>} ctx
|
||
* @param {Token[]} stmt
|
||
* @returns {boolean} true when an error was reported (caller should return)
|
||
*/
|
||
function tryReportMisplacedReservedStatementStart(ctx, stmt) {
|
||
const h = stmt[0]
|
||
if (!h || h.type !== 'word') return false
|
||
const w = h.value
|
||
if (!BARE_OS_SHELL_MISPLACED_STATEMENT_START.has(w)) return false
|
||
ctx.console.error(
|
||
`shell: syntax error: reserved word '${w}' cannot start a statement`
|
||
)
|
||
ctx.exitCode = 2
|
||
return true
|
||
}
|
||
|
||
/**
|
||
* Minimal gated **`[[ … ]]`** — only **`[[ WORD == WORD ]]`** and **`[[ WORD != WORD ]]`**.
|
||
* @param {Record<string, unknown>} ctx
|
||
* @param {Token[]} stmt
|
||
* @returns {Promise<'exit' | 'ok'>}
|
||
*/
|
||
async function execDoubleBracketLimited(ctx, stmt) {
|
||
const vfs = ctx.vfs
|
||
const env = vfs?.env && typeof vfs.env === 'object' ? vfs.env : {}
|
||
const last = stmt[stmt.length - 1]
|
||
if (!last || last.type !== 'word' || last.value !== ']]') {
|
||
ctx.console.error('shell: [[: missing closing ]]')
|
||
ctx.exitCode = 2
|
||
return 'ok'
|
||
}
|
||
const bodyStart = stmt[0]?.value === '[[' ? 1 : 2
|
||
const inner = stmt.slice(bodyStart, -1)
|
||
if (
|
||
inner.length === 3 &&
|
||
inner[0].type === 'word' &&
|
||
inner[1].type === 'word' &&
|
||
inner[2].type === 'word'
|
||
) {
|
||
const op = inner[1].value
|
||
if (op === '==' || op === '!=') {
|
||
const a = expandWord(inner[0].value, env)
|
||
const b = expandWord(inner[2].value, env)
|
||
ctx.exitCode = op === '==' ? (a === b ? 0 : 1) : a !== b ? 0 : 1
|
||
return 'ok'
|
||
}
|
||
}
|
||
ctx.console.error(
|
||
'shell: [[: only `[[ WORD == WORD ]]` and `[[ WORD != WORD ]]` are supported'
|
||
)
|
||
ctx.exitCode = 2
|
||
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, string>} env
|
||
* @param {string[]} argv
|
||
*/
|
||
function assignShellFunctionPositionalEnv(env, argv) {
|
||
env['0'] = argv[0] || ''
|
||
const fnArgs = argv.slice(1)
|
||
for (let i = 1; i <= 9; i++) env[String(i)] = fnArgs[i - 1] ?? ''
|
||
env['#'] = String(fnArgs.length)
|
||
}
|
||
|
||
/**
|
||
* @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 readL = ctx.readLine
|
||
if (typeof readL === 'function') {
|
||
const hm = execLine.match(/^(.*?)<<-?\s*(?:'([^']+)'|"([^"]+)"|(\S+))\s*$/)
|
||
if (hm) {
|
||
const prefix = hm[1].trimEnd()
|
||
if (!prefix) {
|
||
ctx.console.error(
|
||
'shell: here-document requires a command before << on the same line'
|
||
)
|
||
ctx.exitCode = 2
|
||
syncBareOsExitStatusEnv(ctx)
|
||
return 'ok'
|
||
}
|
||
const delim = hm[2] ?? hm[3] ?? hm[4]
|
||
const singleQuoted = hm[2] != null
|
||
const posixHeredocCap = (() => {
|
||
if (
|
||
ctx.env?.BARE_OS_SHELL_POSIX_MODE !== '1' &&
|
||
ctx.env?.BARE_OS_SHELL_POSIX_MODE !== 'true'
|
||
) {
|
||
return null
|
||
}
|
||
const raw = String(ctx.env.BARE_OS_SHELL_HEREDOC_MAX_BYTES || '').trim()
|
||
const n = parseInt(raw, 10)
|
||
if (Number.isFinite(n) && n > 0) return Math.min(2_000_000, n)
|
||
return 262144
|
||
})()
|
||
/** @type {string[]} */
|
||
const bodyLines = []
|
||
let heredocAcc = 0
|
||
for (;;) {
|
||
const ln = await readL('> ')
|
||
if (ln == null) break
|
||
if (ln === delim) break
|
||
if (posixHeredocCap != null) {
|
||
heredocAcc += ln.length + 1
|
||
if (heredocAcc > posixHeredocCap) {
|
||
ctx.console.error(
|
||
'shell: here-document exceeds BARE_OS_SHELL_HEREDOC_MAX_BYTES cap (POSIX mode)'
|
||
)
|
||
ctx.exitCode = 2
|
||
syncBareOsExitStatusEnv(ctx)
|
||
return 'ok'
|
||
}
|
||
}
|
||
bodyLines.push(ln)
|
||
}
|
||
const vfs = ctx.vfs
|
||
const env = vfs?.env && typeof vfs.env === 'object' ? vfs.env : {}
|
||
let body = bodyLines.join('\n')
|
||
if (!singleQuoted) {
|
||
body = bodyLines.map((l) => expandWord(l, env)).join('\n')
|
||
}
|
||
ctx.shellHeredocOnce = body
|
||
if (body.length > 0 && !body.endsWith('\n')) {
|
||
ctx.shellHeredocOnce += '\n'
|
||
}
|
||
execLine = prefix
|
||
}
|
||
}
|
||
|
||
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'
|
||
}
|
||
|
||
/**
|
||
* Clear simulated background jobs on guest ↔ unlocked transitions (POSIX session model).
|
||
* @param {Record<string, unknown>} ctx
|
||
*/
|
||
export function bareOsResetShellIdentityState(ctx) {
|
||
if (
|
||
ctx.shellBackgroundJobs &&
|
||
typeof ctx.shellBackgroundJobs === 'object' &&
|
||
Array.isArray(ctx.shellBackgroundJobs.list)
|
||
) {
|
||
ctx.shellBackgroundJobs.list.length = 0
|
||
ctx.shellBackgroundJobs.nextId = 1
|
||
}
|
||
}
|