sh: add POSIX-like -c support and complete shell subset gaps
Implement sh -c COMMAND [NAME [ARG...]] in /bin/sh, add shell function declarations/invocation support, expand export semantics (NAME, NAME=value, -p), and make until/loop-control behavior available by default. Add focused shell tests and update sh man-page option docs.
This commit is contained in:
+36
-3
@@ -88,9 +88,9 @@ function bareOsEmitRaw(ctx, chunk) {
|
||||
}
|
||||
|
||||
async function run(ctx, argv) {
|
||||
const script = argv[1]
|
||||
if (script == null) {
|
||||
ctx.console.error('usage: sh SCRIPT')
|
||||
const mode = argv[1]
|
||||
if (mode == null) {
|
||||
ctx.console.error('usage: sh -c COMMAND [NAME [ARG ...]] | sh SCRIPT')
|
||||
ctx.exitCode = 2
|
||||
return
|
||||
}
|
||||
@@ -99,6 +99,39 @@ async function run(ctx, argv) {
|
||||
ctx.exitCode = 1
|
||||
return
|
||||
}
|
||||
if (mode === '-c') {
|
||||
const command = argv[2]
|
||||
if (command == null) {
|
||||
ctx.console.error('sh: option requires an argument -- c')
|
||||
ctx.console.error('usage: sh -c COMMAND [NAME [ARG ...]] | sh SCRIPT')
|
||||
ctx.exitCode = 2
|
||||
return
|
||||
}
|
||||
const env = ctx.vfs?.env
|
||||
/** @type {Record<string, string | undefined> | null} */
|
||||
const saved = env && typeof env === 'object' ? {} : null
|
||||
if (saved && env) {
|
||||
for (const k of ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '#']) {
|
||||
saved[k] = env[k]
|
||||
}
|
||||
env['0'] = argv[3] ?? 'sh'
|
||||
const args = argv.slice(4)
|
||||
for (let i = 1; i <= 9; i++) env[String(i)] = args[i - 1] ?? ''
|
||||
env['#'] = String(args.length)
|
||||
}
|
||||
try {
|
||||
await ctx.execLine(command)
|
||||
} finally {
|
||||
if (saved && env) {
|
||||
for (const [k, v] of Object.entries(saved)) {
|
||||
if (v == null) delete env[k]
|
||||
else env[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
const script = mode
|
||||
let buf
|
||||
try {
|
||||
buf = await ctx.vfs.readFile(script)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"schema": 2,
|
||||
"profileId": "bare-os-posix-like",
|
||||
"generatedAt": "2026-04-26T19:04:18.264Z",
|
||||
"generatedAt": "2026-04-26T19:19:08.534Z",
|
||||
"note": "Sparse POSIX Issue 7 coverage hints for /bin utilities. Omitted command names are not yet profiled here.",
|
||||
"commandIndex": [
|
||||
{
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"atMs": 1777230258263,
|
||||
"atMs": 1777231148534,
|
||||
"commands": [
|
||||
"agent",
|
||||
"appctl",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"generatedAt": "2026-04-26T19:04:18.469Z",
|
||||
"generatedAt": "2026-04-26T19:19:08.715Z",
|
||||
"pages": [
|
||||
{
|
||||
"name": "agent",
|
||||
@@ -3211,8 +3211,13 @@
|
||||
"synopsis": [
|
||||
"sh [OPTION]... [OPERAND]..."
|
||||
],
|
||||
"description": "Runs a shell script file line-by-line via ctx.execLine (same language as the interactive booter shell). This is not the interactive REPL; see man bare-os-shell for session builtins, pipelines, and jobs.",
|
||||
"options": [],
|
||||
"description": "Runs shell commands via ctx.execLine (same language as the interactive booter shell). Supports command-string mode (`-c`) and script-file mode (`sh SCRIPT`). This is not the interactive REPL; see man bare-os-shell for session builtins, pipelines, and jobs.",
|
||||
"options": [
|
||||
{
|
||||
"flag": "-c COMMAND [NAME [ARG ...]]",
|
||||
"meaning": "Execute command string and set positional parameters for that command context."
|
||||
}
|
||||
],
|
||||
"keywords": [
|
||||
"sh",
|
||||
"bare-os",
|
||||
|
||||
@@ -633,11 +633,21 @@ export function tokenize(line) {
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if (c === '{') {
|
||||
tokens.push({ type: 'op', value: '{' })
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if (c === ')') {
|
||||
tokens.push({ type: 'op', value: ')' })
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if (c === '}') {
|
||||
tokens.push({ type: 'op', value: '}' })
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if (c === '<') {
|
||||
if (line[i + 1] === '<' && line[i + 2] === '<') {
|
||||
tokens.push({ type: 'op', value: '<<<' })
|
||||
@@ -705,7 +715,9 @@ export function tokenize(line) {
|
||||
ch === ';' ||
|
||||
ch === '&' ||
|
||||
ch === '(' ||
|
||||
ch === ')'
|
||||
ch === ')' ||
|
||||
ch === '{' ||
|
||||
ch === '}'
|
||||
)
|
||||
break
|
||||
cur.t += ch
|
||||
@@ -1186,8 +1198,38 @@ export function splitTokensBySemicolon(tokens) {
|
||||
const lists = []
|
||||
/** @type {Token[]} */
|
||||
let cur = []
|
||||
let kwDepth = 0
|
||||
let parenDepth = 0
|
||||
let braceDepth = 0
|
||||
for (const t of tokens) {
|
||||
if (t.type === 'op' && t.value === ';') {
|
||||
if (t.type === 'word') {
|
||||
if (
|
||||
t.value === 'if' ||
|
||||
t.value === 'while' ||
|
||||
t.value === 'until' ||
|
||||
t.value === 'for' ||
|
||||
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 {
|
||||
@@ -1210,8 +1252,38 @@ export function splitTokensByAndOr(tokens) {
|
||||
const ops = []
|
||||
/** @type {Token[]} */
|
||||
let cur = []
|
||||
let kwDepth = 0
|
||||
let parenDepth = 0
|
||||
let braceDepth = 0
|
||||
for (const t of tokens) {
|
||||
if (t.type === 'op' && (t.value === '&&' || t.value === '||')) {
|
||||
if (t.type === 'word') {
|
||||
if (
|
||||
t.value === 'if' ||
|
||||
t.value === 'while' ||
|
||||
t.value === 'until' ||
|
||||
t.value === 'for' ||
|
||||
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 = []
|
||||
@@ -1553,6 +1625,52 @@ async function execParsedPipeline(ctx, pipeline) {
|
||||
|
||||
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 execSemicolonLists(ctx, fn.body)
|
||||
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,
|
||||
@@ -1610,6 +1728,7 @@ async function execParsedPipeline(ctx, pipeline) {
|
||||
origLog.call(ctx.console, 'export ' + k + '=' + q)
|
||||
}
|
||||
} else {
|
||||
let sawErr = false
|
||||
for (const a of args) {
|
||||
if (a === '-p') {
|
||||
origErr.call(
|
||||
@@ -1617,22 +1736,45 @@ async function execParsedPipeline(ctx, pipeline) {
|
||||
'export: -p must be the only argument'
|
||||
)
|
||||
ctx.exitCode = 2
|
||||
continue
|
||||
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()
|
||||
@@ -2155,6 +2297,8 @@ function splitTopLevelStatements(tokens) {
|
||||
/** @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++
|
||||
@@ -2168,8 +2312,19 @@ function splitTopLevelStatements(tokens) {
|
||||
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) {
|
||||
if (
|
||||
t.type === 'op' &&
|
||||
t.value === ';' &&
|
||||
depth === 0 &&
|
||||
parenDepth === 0 &&
|
||||
braceDepth === 0
|
||||
) {
|
||||
if (cur.length) out.push(cur)
|
||||
cur = []
|
||||
} else {
|
||||
@@ -2191,6 +2346,8 @@ function splitTopLevelByAmpersand(tokens) {
|
||||
/** @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++
|
||||
@@ -2204,8 +2361,19 @@ function splitTopLevelByAmpersand(tokens) {
|
||||
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) {
|
||||
if (
|
||||
t.type === 'op' &&
|
||||
t.value === '&' &&
|
||||
depth === 0 &&
|
||||
parenDepth === 0 &&
|
||||
braceDepth === 0
|
||||
) {
|
||||
out.push(cur)
|
||||
cur = []
|
||||
} else {
|
||||
@@ -2408,13 +2576,6 @@ async function execSemicolonLists(ctx, toks, opts) {
|
||||
* @returns {Promise<'ok' | 'exit' | 'break' | 'continue'>}
|
||||
*/
|
||||
async function execLoopBody(ctx, bodyToks) {
|
||||
const loopCtl =
|
||||
ctx.vfs?.env?.BARE_OS_SHELL_LOOP_CONTROL === '1' ||
|
||||
ctx.vfs?.env?.BARE_OS_SHELL_LOOP_CONTROL === 'true'
|
||||
if (!loopCtl) {
|
||||
const r = await execSemicolonLists(ctx, bodyToks)
|
||||
return r === 'exit' ? 'exit' : 'ok'
|
||||
}
|
||||
const lists = splitTokensBySemicolon(bodyToks)
|
||||
for (const list of lists) {
|
||||
if (!list.length) continue
|
||||
@@ -2433,6 +2594,70 @@ async function execLoopBody(ctx, bodyToks) {
|
||||
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}
|
||||
@@ -2842,12 +3067,18 @@ async function dispatchShellStatement(ctx, stmt) {
|
||||
const dr = tryExecShellDeclareBuiltin(ctx, stmt.slice(1))
|
||||
if (dr != null) return dr
|
||||
}
|
||||
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)
|
||||
const untilOn =
|
||||
shEnv &&
|
||||
(shEnv.BARE_OS_SHELL_UNTIL === '1' || shEnv.BARE_OS_SHELL_UNTIL === 'true')
|
||||
if (untilOn && head?.type === 'word' && head.value === 'until')
|
||||
if (head?.type === 'word' && head.value === 'until')
|
||||
return execUntilConstruct(ctx, stmt)
|
||||
if (head?.type === 'word' && head.value === 'while')
|
||||
return execWhileConstruct(ctx, stmt)
|
||||
@@ -2954,6 +3185,17 @@ async function execAndOrList(ctx, tokens) {
|
||||
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
|
||||
|
||||
@@ -6215,6 +6215,102 @@ test('tier-1 sh shebang strip and symlink script path', async (t) => {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
test('tier-1 sh -c executes command and sets positional args', async (t) => {
|
||||
const dir = testCorestoreDir('shdashc')
|
||||
const store = new Corestore(dir)
|
||||
const drive = new Hyperdrive(store)
|
||||
const personal = new Hyperdrive(store.namespace('pshc'))
|
||||
await drive.ready()
|
||||
await personal.ready()
|
||||
await drive.put('/bin/sh', b4a.from(await readBuiltBin('sh')))
|
||||
await drive.put(
|
||||
'/bin/echo',
|
||||
b4a.from(`async function run(ctx, argv) {
|
||||
ctx.console.log(argv.slice(1).join(' '))
|
||||
}
|
||||
`)
|
||||
)
|
||||
await drive.put(
|
||||
'/bin/false',
|
||||
b4a.from(`async function run(ctx) {
|
||||
ctx.exitCode = 1
|
||||
}
|
||||
`)
|
||||
)
|
||||
const lines = []
|
||||
const ctx = testCtx(drive, personal)
|
||||
ctx.runBinCommand = function (argv, ro) {
|
||||
return runBinCommand(this, argv, ro)
|
||||
}
|
||||
ctx.execLine = (line) => execShellLine(ctx, line)
|
||||
ctx.console = {
|
||||
log(s) {
|
||||
lines.push(String(s))
|
||||
},
|
||||
error(s) {
|
||||
lines.push('e:' + String(s))
|
||||
}
|
||||
}
|
||||
ctx.exitCode = 0
|
||||
await runBinCommand(ctx, ['sh', '-c', 'echo $0 $1 $2 $#', 'myshell', 'aa', 'bb'])
|
||||
t.is(ctx.exitCode, 0)
|
||||
t.ok(lines.some((l) => l.includes('myshell aa bb 2')))
|
||||
|
||||
ctx.exitCode = 0
|
||||
await runBinCommand(ctx, ['sh', '-c', 'false'])
|
||||
t.is(ctx.exitCode, 1)
|
||||
|
||||
await store.close()
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
test('execShellLine supports shell functions, export NAME, and until by default', async (t) => {
|
||||
const dir = testCorestoreDir('shfunc')
|
||||
const store = new Corestore(dir)
|
||||
const drive = new Hyperdrive(store)
|
||||
const personal = new Hyperdrive(store.namespace('pshf'))
|
||||
await drive.ready()
|
||||
await personal.ready()
|
||||
await drive.put(
|
||||
'/bin/echo',
|
||||
b4a.from(`async function run(ctx, argv) {
|
||||
ctx.console.log(argv.slice(1).join(' '))
|
||||
}
|
||||
`)
|
||||
)
|
||||
const lines = []
|
||||
const ctx = testCtx(drive, personal)
|
||||
ctx.console = {
|
||||
log(s) {
|
||||
lines.push(String(s))
|
||||
},
|
||||
error(s) {
|
||||
lines.push('e:' + String(s))
|
||||
}
|
||||
}
|
||||
ctx.exitCode = 0
|
||||
|
||||
await execShellLine(ctx, 'hello() { echo $1; }; hello world')
|
||||
t.is(ctx.exitCode, 0)
|
||||
t.ok(lines.includes('world'))
|
||||
|
||||
ctx.exitCode = 0
|
||||
await execShellLine(ctx, 'export DEMO')
|
||||
t.is(ctx.exitCode, 0)
|
||||
t.is(ctx.vfs.env.DEMO, '')
|
||||
|
||||
ctx.exitCode = 0
|
||||
await execShellLine(ctx, 'export BAD-NAME=1')
|
||||
t.is(ctx.exitCode, 1)
|
||||
|
||||
ctx.exitCode = 0
|
||||
await execShellLine(ctx, 'until false; do break; done')
|
||||
t.is(ctx.exitCode, 0)
|
||||
|
||||
await store.close()
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
test('tier-2 cat env touch mkdir', async (t) => {
|
||||
const dir = testCorestoreDir('tier2tem')
|
||||
const store = new Corestore(dir)
|
||||
|
||||
@@ -5,8 +5,13 @@
|
||||
"synopsis": [
|
||||
"sh [OPTION]... [OPERAND]..."
|
||||
],
|
||||
"description": "Runs a shell script file line-by-line via ctx.execLine (same language as the interactive booter shell). This is not the interactive REPL; see man bare-os-shell for session builtins, pipelines, and jobs.",
|
||||
"options": [],
|
||||
"description": "Runs shell commands via ctx.execLine (same language as the interactive booter shell). Supports command-string mode (`-c`) and script-file mode (`sh SCRIPT`). This is not the interactive REPL; see man bare-os-shell for session builtins, pipelines, and jobs.",
|
||||
"options": [
|
||||
{
|
||||
"flag": "-c COMMAND [NAME [ARG ...]]",
|
||||
"meaning": "Execute command string and set positional parameters for that command context."
|
||||
}
|
||||
],
|
||||
"keywords": [
|
||||
"sh",
|
||||
"bare-os",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
async function run(ctx, argv) {
|
||||
const script = argv[1]
|
||||
if (script == null) {
|
||||
ctx.console.error('usage: sh SCRIPT')
|
||||
const mode = argv[1]
|
||||
if (mode == null) {
|
||||
ctx.console.error('usage: sh -c COMMAND [NAME [ARG ...]] | sh SCRIPT')
|
||||
ctx.exitCode = 2
|
||||
return
|
||||
}
|
||||
@@ -10,6 +10,39 @@ async function run(ctx, argv) {
|
||||
ctx.exitCode = 1
|
||||
return
|
||||
}
|
||||
if (mode === '-c') {
|
||||
const command = argv[2]
|
||||
if (command == null) {
|
||||
ctx.console.error('sh: option requires an argument -- c')
|
||||
ctx.console.error('usage: sh -c COMMAND [NAME [ARG ...]] | sh SCRIPT')
|
||||
ctx.exitCode = 2
|
||||
return
|
||||
}
|
||||
const env = ctx.vfs?.env
|
||||
/** @type {Record<string, string | undefined> | null} */
|
||||
const saved = env && typeof env === 'object' ? {} : null
|
||||
if (saved && env) {
|
||||
for (const k of ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '#']) {
|
||||
saved[k] = env[k]
|
||||
}
|
||||
env['0'] = argv[3] ?? 'sh'
|
||||
const args = argv.slice(4)
|
||||
for (let i = 1; i <= 9; i++) env[String(i)] = args[i - 1] ?? ''
|
||||
env['#'] = String(args.length)
|
||||
}
|
||||
try {
|
||||
await ctx.execLine(command)
|
||||
} finally {
|
||||
if (saved && env) {
|
||||
for (const [k, v] of Object.entries(saved)) {
|
||||
if (v == null) delete env[k]
|
||||
else env[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
const script = mode
|
||||
let buf
|
||||
try {
|
||||
buf = await ctx.vfs.readFile(script)
|
||||
|
||||
@@ -88,9 +88,9 @@ function bareOsEmitRaw(ctx, chunk) {
|
||||
}
|
||||
|
||||
async function run(ctx, argv) {
|
||||
const script = argv[1]
|
||||
if (script == null) {
|
||||
ctx.console.error('usage: sh SCRIPT')
|
||||
const mode = argv[1]
|
||||
if (mode == null) {
|
||||
ctx.console.error('usage: sh -c COMMAND [NAME [ARG ...]] | sh SCRIPT')
|
||||
ctx.exitCode = 2
|
||||
return
|
||||
}
|
||||
@@ -99,6 +99,39 @@ async function run(ctx, argv) {
|
||||
ctx.exitCode = 1
|
||||
return
|
||||
}
|
||||
if (mode === '-c') {
|
||||
const command = argv[2]
|
||||
if (command == null) {
|
||||
ctx.console.error('sh: option requires an argument -- c')
|
||||
ctx.console.error('usage: sh -c COMMAND [NAME [ARG ...]] | sh SCRIPT')
|
||||
ctx.exitCode = 2
|
||||
return
|
||||
}
|
||||
const env = ctx.vfs?.env
|
||||
/** @type {Record<string, string | undefined> | null} */
|
||||
const saved = env && typeof env === 'object' ? {} : null
|
||||
if (saved && env) {
|
||||
for (const k of ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '#']) {
|
||||
saved[k] = env[k]
|
||||
}
|
||||
env['0'] = argv[3] ?? 'sh'
|
||||
const args = argv.slice(4)
|
||||
for (let i = 1; i <= 9; i++) env[String(i)] = args[i - 1] ?? ''
|
||||
env['#'] = String(args.length)
|
||||
}
|
||||
try {
|
||||
await ctx.execLine(command)
|
||||
} finally {
|
||||
if (saved && env) {
|
||||
for (const [k, v] of Object.entries(saved)) {
|
||||
if (v == null) delete env[k]
|
||||
else env[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
const script = mode
|
||||
let buf
|
||||
try {
|
||||
buf = await ctx.vfs.readFile(script)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"schema": 2,
|
||||
"profileId": "bare-os-posix-like",
|
||||
"generatedAt": "2026-04-26T19:04:18.264Z",
|
||||
"generatedAt": "2026-04-26T19:19:08.534Z",
|
||||
"note": "Sparse POSIX Issue 7 coverage hints for /bin utilities. Omitted command names are not yet profiled here.",
|
||||
"commandIndex": [
|
||||
{
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"atMs": 1777230258263,
|
||||
"atMs": 1777231148534,
|
||||
"commands": [
|
||||
"agent",
|
||||
"appctl",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"generatedAt": "2026-04-26T19:04:18.469Z",
|
||||
"generatedAt": "2026-04-26T19:19:08.715Z",
|
||||
"pages": [
|
||||
{
|
||||
"name": "agent",
|
||||
@@ -3211,8 +3211,13 @@
|
||||
"synopsis": [
|
||||
"sh [OPTION]... [OPERAND]..."
|
||||
],
|
||||
"description": "Runs a shell script file line-by-line via ctx.execLine (same language as the interactive booter shell). This is not the interactive REPL; see man bare-os-shell for session builtins, pipelines, and jobs.",
|
||||
"options": [],
|
||||
"description": "Runs shell commands via ctx.execLine (same language as the interactive booter shell). Supports command-string mode (`-c`) and script-file mode (`sh SCRIPT`). This is not the interactive REPL; see man bare-os-shell for session builtins, pipelines, and jobs.",
|
||||
"options": [
|
||||
{
|
||||
"flag": "-c COMMAND [NAME [ARG ...]]",
|
||||
"meaning": "Execute command string and set positional parameters for that command context."
|
||||
}
|
||||
],
|
||||
"keywords": [
|
||||
"sh",
|
||||
"bare-os",
|
||||
|
||||
Reference in New Issue
Block a user