/** * The interpreter: it walks the parsed command line and runs the command table * against the VFS. Structure (`;` `&` `|` `|&` `&&` `||`, subshells, groups, * redirections, prefix assignments) is honored here; what a command *does* * belongs to its program in `./programs/`. * * Output is text, not streams: every program is a JavaScript function that * returns before the next one runs, so a pipeline hands a string along instead * of plumbing byte streams a browser worker has no way to schedule between. * @module @deepseek-ai/dsh-experimental-webworker-runtime/src/shell/interpret */ import { parseShell } from '@yarnpkg/parsers' import type { Command, CommandChain, CommandLine, RedirectArgument, ShellLine, ValueArgument } from './ast.ts' import { expandArgument, isGlobPattern } from './expand.ts' import type { ExpansionContext } from './expand.ts' import { describeFailure, hostFileSystem, resolveIn } from './fs-access.ts' import { standardPrograms } from './programs/index.ts' import type { ShellFileSystem, ShellIo, ShellProgram, ShellRunOutcome, ShellState } from './types.ts' /** Status a command line reports once the caller's abort signal has fired. */ const ABORTED_STATUS = 130 /** Status of a command name the table does not hold, as POSIX shells report it. */ const NOT_FOUND_STATUS = 127 /** Nesting limit for `$( … )`; a deeper line is a runaway, not a command. */ const MAX_SUBSTITUTION_DEPTH = 16 /** Everything one `bash -c` invocation needs. */ export interface ShellRunOptions { /** Working directory the line starts in. */ cwd: string /** Environment the line starts with. */ env: Record /** Standard input contents; absent means empty. */ stdin?: string | undefined /** Cancellation: an aborted line stops before its next command. */ signal?: AbortSignal | undefined /** * The filesystem this run acts on; defaults to the VFS mounted in this * thread. A run inside a process worker passes the message-backed one. */ fs?: ShellFileSystem | undefined /** * Called with each write as it happens, before the run settles. The returned * outcome still carries the complete text; this only lets a caller that * reports progress (a background job's incremental reads) see output while * the line is still running. */ onOutput?: ((stream: 'stdout' | 'stderr', text: string) => void) | undefined } /** Accumulates one output stream. */ interface Sink { write: (text: string) => void } /** A sink over a string buffer, for pipelines and command substitution. */ function buffer(): Sink & { text(): string } { const chunks: string[] = [] return { write: (text: string) => { chunks.push(text) }, text: () => chunks.join(''), } } /** * Run one shell command line to completion. * @param source - the command source, exactly as `bash -c` would receive it. * @param options - starting directory, environment, standard input, cancellation, filesystem, output callback. * @returns the exit status and the complete standard output and standard error. */ export async function runShellCommand(source: string, options: ShellRunOptions): Promise { const run = startRun(options) let line: ShellLine try { line = parseShell(source, { isGlobPattern }) } catch (error) { run.io.err(`bash: syntax error: ${error instanceof Error ? error.message.split('\n')[0] : String(error)}\n`) return run.settle(2) } const interpreter = new Interpreter(standardPrograms(), options.fs ?? hostFileSystem(), options.signal) return run.settle(await interpreter.line(line, run.state, run.io)) } /** * Run one program directly, without a command line to parse. * * This is the path for an argv the caller already has in pieces — a spawn that * names a program instead of handing `bash` a script — so nothing re-quotes * words that were never quoted in the first place. * @param argv - the program name at index 0, then its arguments. * @param options - starting directory, environment, standard input, cancellation, filesystem, output callback. * @returns the exit status and the complete standard output and standard error. */ export async function runShellProgram(argv: readonly string[], options: ShellRunOptions): Promise { const run = startRun(options) const name = argv[0] const program = name === undefined ? undefined : standardPrograms().get(name) if (name === undefined || program === undefined) { run.io.err(`bash: ${name ?? ''}: command not found\n`) return run.settle(NOT_FOUND_STATUS) } if (options.signal?.aborted === true) return run.settle(ABORTED_STATUS) try { return run.settle(await program(argv, run.io, run.state, options.fs ?? hostFileSystem())) } catch (error) { run.io.err(`bash: ${name}: ${error instanceof Error ? error.message : String(error)}\n`) return run.settle(1) } } /** Build the state, the sinks, and the settlement one run reports through. */ function startRun(options: ShellRunOptions): { state: ShellState io: ShellIo settle: (exitCode: number) => ShellRunOutcome } { const stdout = buffer() const stderr = buffer() const report = options.onOutput return { state: { cwd: options.cwd, environment: { ...options.env }, variables: {}, lastStatus: 0, exitRequested: undefined, signal: options.signal, }, io: { stdin: options.stdin ?? '', out: (text: string) => { stdout.write(text) report?.('stdout', text) }, err: (text: string) => { stderr.write(text) report?.('stderr', text) }, }, settle: (exitCode: number) => ({ exitCode, stdout: stdout.text(), stderr: stderr.text() }), } } /** One interpretation pass; holds what every nested command shares. */ class Interpreter { constructor( private readonly programs: ReadonlyMap, private readonly fs: ShellFileSystem, private readonly signal: AbortSignal | undefined, private readonly depth = 0, ) {} /** * Run every command of one line, left to right. * @param line - the parsed line. * @param state - shell state the line reads and mutates. * @param io - standard input and the output sinks. * @returns the status of the last command that ran. */ async line(line: ShellLine, state: ShellState, io: ShellIo): Promise { let status = state.lastStatus for (const entry of line) { if (this.signal?.aborted === true) return ABORTED_STATUS // `&` starts no background job here: the worker has no scheduler that // could run one, so a backgrounded command runs to completion in place. status = await this.commandLine(entry.command, state, io) state.lastStatus = status if (state.exitRequested !== undefined) return state.exitRequested } return status } /** * Run one `&&` / `||` chain. * * The grammar nests these to the right, while a shell evaluates them left to * right: `false && a || b` runs `b`. Flattening first is what makes the * skipped `&&` hand its status to the following `||` instead of taking the * whole remainder of the line with it. */ private async commandLine(commandLine: CommandLine, state: ShellState, io: ShellIo): Promise { const links: { type: '&&' | '||'; chain: CommandChain }[] = [] for (let current = commandLine.then; current !== undefined; current = current.line.then) { links.push({ type: current.type, chain: current.line.chain }) } let status = await this.pipeline(commandLine.chain, state, io) state.lastStatus = status for (const link of links) { if (state.exitRequested !== undefined) return status if (link.type === '&&' ? status !== 0 : status === 0) continue status = await this.pipeline(link.chain, state, io) state.lastStatus = status } return status } /** Run one `|` / `|&` pipeline; its status is the last stage's. */ private async pipeline(chain: CommandChain, state: ShellState, io: ShellIo): Promise { const stages: { command: CommandChain; mergesStderr: boolean }[] = [] for (let current: CommandChain | undefined = chain; current !== undefined;) { const link: CommandChain['then'] = current.then stages.push({ command: current, mergesStderr: link?.type === '|&' }) current = link?.chain } let input = io.stdin let status = 0 for (const [index, stage] of stages.entries()) { if (this.signal?.aborted === true) return ABORTED_STATUS const last = index === stages.length - 1 const piped = buffer() const stageIo: ShellIo = last ? { stdin: input, out: io.out, err: io.err } : { stdin: input, out: piped.write, err: stage.mergesStderr ? piped.write : io.err } status = await this.command(stage.command, state, stageIo) if (!last) input = piped.text() if (state.exitRequested !== undefined) return status } return status } /** Run one command node: a program call, a subshell, a group, or bare assignments. */ private async command(command: Command, state: ShellState, io: ShellIo): Promise { switch (command.type) { case 'envs': for (const env of command.envs) assign(state, env.name, await this.assignedValue(env.args[0], state)) return 0 case 'subshell': { // A subshell sees a copy: its `cd` and its assignments die with it. const nested = { ...state, environment: { ...state.environment }, variables: { ...state.variables } } return await this.redirected(command.args, state, io, async inner => await this.line(command.subshell, nested, inner)) } case 'group': return await this.redirected(command.args, state, io, async inner => await this.line(command.group, state, inner)) case 'command': return await this.program(command, state, io) } } /** Expand a command's words and run the program they name. */ private async program(command: Extract, state: ShellState, io: ShellIo): Promise { const argv: string[] = [] const redirections: RedirectArgument[] = [] for (const argument of command.args) { if (argument.type === 'redirection') { redirections.push(argument) continue } argv.push(...await expandArgument(argument, this.context(state))) } const prefix: Record = {} for (const env of command.envs) prefix[env.name] = await this.assignedValue(env.args[0], state) if (argv.length === 0) { for (const [name, value] of Object.entries(prefix)) assign(state, name, value) return 0 } // A prefixed command sees the assignments as environment for its run only, // which also means it cannot change the caller's directory. const scope = Object.keys(prefix).length === 0 ? state : { ...state, environment: { ...state.environment, ...prefix } } const name = argv[0] as string const program = this.programs.get(name) if (program === undefined) { io.err(`bash: ${name}: command not found\n`) return NOT_FOUND_STATUS } return await this.redirected(redirections, state, io, async (inner) => { try { return await program(argv, inner, scope, this.fs) } catch (error) { // A program's own defect must not take the whole worker down with it. inner.err(`bash: ${name}: ${error instanceof Error ? error.message : String(error)}\n`) return 1 } }) } /** * Apply redirections around one body, then restore nothing: every sink is a * value, so the caller's own `io` is untouched by construction. */ private async redirected( redirections: readonly RedirectArgument[], state: ShellState, io: ShellIo, body: (io: ShellIo) => Promise, ): Promise { let stdin = io.stdin let out = io.out let err = io.err // Every file write this redirection set started, awaited before the // command's status is reported: a `> file` must be complete on return. const writes: Promise[] = [] for (const redirection of redirections) { const targets: string[] = [] for (const argument of redirection.args) targets.push(...await expandArgument(argument, this.context(state))) const target = targets[0] if (target === undefined || targets.length > 1) { io.err('bash: ambiguous redirect\n') return 1 } try { switch (redirection.subtype) { case '<': stdin = await this.fs.readText(resolveIn(state.cwd, target)) break case '<<<': stdin = `${target}\n` break case '>': case '>>': { const path = resolveIn(state.cwd, target) // Truncation happens at redirect time, so `> file` empties it even // when the command writes nothing. if (redirection.subtype === '>') await this.fs.writeText(path, '') // Appends are ordered by the queue below: a sink is synchronous to // its caller, so writes are chained rather than raced. let pending: Promise = Promise.resolve() const sink = (text: string): void => { pending = pending.then(async () => { await this.fs.writeText(path, text, true) }) writes.push(pending) } if (redirection.fd === 2) err = sink else out = sink break } case '>&': { // Only descriptor duplication between stdout and stderr is // meaningful here: those are the only two the shell owns. if (redirection.fd === 2 && target === '1') err = out else if ((redirection.fd === null || redirection.fd === 1) && target === '2') out = err else { io.err(`bash: ${String(redirection.fd ?? 1)}>&${target}: unsupported descriptor redirection\n`) return 1 } break } case '<&': io.err(`bash: <&${target}: unsupported descriptor redirection\n`) return 1 } } catch (error) { io.err(`${describeFailure('bash', resolveIn(state.cwd, target), error)}\n`) return 1 } } const status = await body({ stdin, out, err }) await Promise.all(writes) return status } /** The expansion hook: `$( … )` runs on a nested interpreter of the same table. */ private context(state: ShellState): ExpansionContext { return { state, fs: this.fs, substitute: async (shell: ShellLine): Promise => { if (this.depth >= MAX_SUBSTITUTION_DEPTH) { throw new Error(`command substitution nested deeper than ${String(MAX_SUBSTITUTION_DEPTH)} levels`) } const captured = buffer() const nested = { ...state, environment: { ...state.environment }, variables: { ...state.variables } } const inner = new Interpreter(this.programs, this.fs, this.signal, this.depth + 1) await inner.line(shell, nested, { stdin: '', out: captured.write, err: () => {} }) return captured.text().replace(/\n+$/, '') }, } } /** Expand the right-hand side of one `NAME=value` assignment. */ private async assignedValue(argument: ValueArgument | undefined, state: ShellState): Promise { if (argument === undefined) return '' return (await expandArgument(argument, this.context(state))).join(' ') } } /** * Record one assignment. An exported name keeps its export (the environment * copy is what programs read); anything else stays a shell variable. */ function assign(state: ShellState, name: string, value: string): void { if (name in state.environment) state.environment[name] = value else state.variables[name] = value }