Files
bare-operating-system/packages/bare-os-booter/lib/shell.js
T
Raven Scott 27292f2ba6 Network/auth/delegate hardening
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.
2026-04-27 08:54:50 -04:00

4256 lines
127 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Shell implementation (tokenizer slice in {@link ./shell-tokenizer.js}; planner/executor here).
*/
import { runBinCommand, resolveBinInPath } from './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 512MiB / 2M 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 7style).
* Gated by **`BARE_OS_SHELL_UNTIL=1`** or **`true`**.
* @param {Record<string, unknown>} ctx
* @param {Token[]} tokens
* @returns {Promise<'exit' | 'ok'>}
*/
async function execUntilConstruct(ctx, tokens) {
const split = findWhileDoSplit(tokens)
if (split < 0) {
ctx.console.error('shell: until: expected "; do …; done"')
ctx.exitCode = 2
return 'ok'
}
const last = tokens[tokens.length - 1]
if (last.type !== 'word' || last.value !== 'done') {
ctx.console.error('shell: until: missing done')
ctx.exitCode = 2
return 'ok'
}
const condToks = tokens.slice(1, split)
const bodyToks = tokens.slice(split + 2, tokens.length - 1)
const maxIter = Number.parseInt(
ctx.vfs?.env?.BARE_OS_SHELL_LOOP_MAX || '10000',
10
)
const cap = Number.isFinite(maxIter) && maxIter > 0 ? maxIter : 10000
for (let i = 0; i < cap; i++) {
const r0 = await execSemicolonLists(ctx, condToks, { suppressErrexit: true })
if (r0 === 'exit') return 'exit'
if ((Number(ctx.exitCode) || 0) === 0) break
const r1 = await execLoopBody(ctx, bodyToks)
if (r1 === 'exit') return 'exit'
if (r1 === 'break') break
if (r1 === 'continue') continue
}
return 'ok'
}
/**
* @param {Record<string, unknown>} ctx
* @param {Token[]} tokens
* @returns {Promise<'exit' | 'ok'>}
*/
async function execForConstruct(ctx, tokens) {
const last = tokens[tokens.length - 1]
if (last.type !== 'word' || last.value !== 'done') {
ctx.console.error('shell: for: missing done')
ctx.exitCode = 2
return 'ok'
}
if (tokens.length < 7 || tokens[1].type !== 'word') {
ctx.console.error('shell: for: invalid syntax')
ctx.exitCode = 2
return 'ok'
}
if (tokens[2].type !== 'word' || tokens[2].value !== 'in') {
ctx.console.error('shell: for: expected `in`')
ctx.exitCode = 2
return 'ok'
}
/** @type {Token[]} */
const inToks = []
let semi = -1
for (let j = 3; j < tokens.length; j++) {
const t = tokens[j]
if (t.type === 'op' && t.value === ';') {
semi = j
break
}
inToks.push(t)
}
if (semi < 0) {
ctx.console.error('shell: for: expected `;` before do')
ctx.exitCode = 2
return 'ok'
}
if (tokens[semi + 1]?.type !== 'word' || tokens[semi + 1].value !== 'do') {
ctx.console.error('shell: for: expected `do` after `;`')
ctx.exitCode = 2
return 'ok'
}
const varName = tokens[1].value
const bodyToks = tokens.slice(semi + 2, tokens.length - 1)
const env = ctx.vfs.env
/** @type {string[]} */
const words = []
for (const t of inToks) {
if (t.type !== 'word') continue
const xs = await expandShellWordTokens(
ctx,
/** @type {Extract<Token, { type: 'word' }>} */ (t),
env,
{}
)
if (
xs.length === 0 &&
(env.BARE_OS_STRICT_POSIX === '1' ||
env.BARE_OS_STRICT_POSIX === 'true')
) {
ctx.console.error('shell: for `in`: pathname expansion produced no matches')
ctx.exitCode = 1
return 'ok'
}
for (const x of xs) words.push(x)
}
const maxIter = Number.parseInt(
ctx.vfs?.env?.BARE_OS_SHELL_LOOP_MAX || '10000',
10
)
const cap = Number.isFinite(maxIter) && maxIter > 0 ? maxIter : 10000
let total = 0
for (const w of words) {
env[varName] = w
if (++total > cap) {
ctx.console.error('shell: for: exceeded BARE_OS_SHELL_LOOP_MAX')
ctx.exitCode = 1
return 'ok'
}
const r = await execLoopBody(ctx, bodyToks)
if (r === 'exit') return 'exit'
if (r === 'break') break
if (r === 'continue') continue
}
return 'ok'
}
/**
* @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
}
}