// A bash prompt on the app's own VFS. `node`, `npm` and `npx` run real processes in // the same runtime as the app, through bashkit (`@everruns/bashkit-wasm`). This module // owns the session: the line editor while idle, the pty while a foreground process has // the keys, and bash itself while a line runs. It must not import xterm — `term` is // whatever `terminal.js` `create()` returns. import { initBashkit, Bash } from "@everruns/bashkit-wasm"; import { hostFs } from "./shell-fs.js"; import { LAUNCHERS, isEvalFile } from "./launchers.js"; import { initialLine, step } from "./line-editor.js"; const enc = new TextEncoder(); const ENV = Object.freeze({ HOME: "/app", PATH: "/app/node_modules/.bin:/usr/local/bin:/usr/bin:/bin", TERM: "xterm-256color", COLORTERM: "truecolor", USER: "user", LANG: "C.UTF-8", }); // A `\n` byte becomes `\r\n` unless a `\r` already precedes it — pty output already // carries its own CR, so this is a no-op on it. Byte-level (not text) so binary output // from a piped command passes through untouched; `lastCR` carries the boundary byte // across chunks. function crlfTransform() { let lastCR = false; return (bytes) => { const out = []; for (let i = 0; i < bytes.length; i++) { const b = bytes[i]; if (b === 0x0a && !(i === 0 ? lastCR : bytes[i - 1] === 0x0d)) out.push(0x0d); out.push(b); lastCR = b === 0x0d; } return new Uint8Array(out); }; } // bash.analyze THROWS for input bash itself can tell is incomplete (open quote, // `if`/`for`/a function body with no closing keyword, a trailing `$(`). It ACCEPTS — // does not throw for — four other incomplete forms, which this shell measures itself: // a trailing unescaped `\`, a trailing `&&`/`||`, and an open heredoc. function analyzeSaysIncomplete(message) { return /unexpected EOF|unexpected end of file|unterminated/i.test(String(message ?? "")); } function trailingBackslash(text) { let n = 0; for (let i = text.length - 1; i >= 0 && text[i] === "\\"; i--) n++; return n % 2 === 1; } function trailingAndOr(text) { const t = text.trimEnd(); return t.endsWith("&&") || t.endsWith("||"); } const HEREDOC_RE = /<<-?\s*(['"]?)([A-Za-z_][A-Za-z0-9_]*)\1/g; function openHeredoc(text) { const lines = text.split("\n"); HEREDOC_RE.lastIndex = 0; let m; while ((m = HEREDOC_RE.exec(text))) { const dashed = text.slice(m.index, m.index + 3) === "<<-"; const startLine = text.slice(0, m.index).split("\n").length - 1; const closed = lines.slice(startLine + 1).some((l) => (dashed ? l.replace(/^\t+/, "") : l) === m[2]); if (!closed) return true; } return false; } function selfDetectedIncomplete(text) { return trailingBackslash(text) || trailingAndOr(text) || openHeredoc(text); } // Joining a continuation line onto `pending`: a trailing single backslash is bash's own // line-continuation marker, so it (and the newline it would otherwise introduce) is // dropped; anything else (an open heredoc, a trailing `&&`) keeps its line break. function joinContinuation(pending, line) { return trailingBackslash(pending) ? pending.slice(0, -1) + line : `${pending}\n${line}`; } /** @returns {Promise<{ dispose(): void }>} */ export async function createShell({ runtime, term, cwd = "/app" }) { await initBashkit(new URL("./bashkit.wasm", import.meta.url)); const counter = { n: 0, next() { return ++this.n; } }; const live = new Set(); let curCwd = cwd; let pending = null; // One tagged state for the session: "idle" the line editor owns keys, "running" a // line is executing (keys queue as typeahead; ^C interrupts), "foreground" a pty process owns them. let state = { tag: "idle" }; let foregroundThisLine = false; async function run(ctx, launch) { if (foregroundThisLine) { const proc = runtime.exec(launch.entry, { argv: launch.argv, cwd: ctx.cwd, env: ctx.env, pty: term.fit() }); state = { tag: "foreground", proc }; live.add(proc); try { const reader = proc.stdout.getReader(); for (;;) { const { value, done } = await reader.read(); if (done) break; await ctx.stdout.write(value); } return await proc.exited; } finally { live.delete(proc); state = { tag: "running" }; } } const proc = runtime.exec(launch.entry, { argv: launch.argv, cwd: ctx.cwd, env: ctx.env }); live.add(proc); const pumpIn = (async () => { const w = proc.stdin.getWriter(); for await (const chunk of ctx.stdin) await w.write(chunk); await w.close().catch(() => {}); })(); const pump = async (readable, sink) => { const r = readable.getReader(); for (;;) { const { value, done } = await r.read(); if (done) break; await sink.write(value); } }; try { await Promise.all([pump(proc.stdout, ctx.stdout), pump(proc.stderr, ctx.stderr)]); return await proc.exited; } finally { pumpIn.catch(() => {}); live.delete(proc); } } async function launch(name, ctx) { const resolved = await LAUNCHERS[name](runtime.fs, ctx.cwd, ctx.argv, counter); if ("error" in resolved) { await ctx.stderr.write(enc.encode(`${resolved.error}\n`)); return resolved.code; } try { return await run(ctx, resolved); } finally { if (isEvalFile(resolved.entry)) await runtime.fs.unlink(resolved.entry).catch(() => {}); } } const bash = new Bash({ fs: hostFs(runtime), cwd, env: { ...ENV }, commands: { node: (ctx) => launch("node", ctx), npm: (ctx) => launch("npm", ctx), npx: (ctx) => launch("npx", ctx), }, }); function isForegroundLine(text) { let a; try { a = bash.analyze(text); } catch { return false; } if (a.commands.length !== 1 || a.redirects.length !== 0 || a.hasCommandSubstitution) return false; const name = a.commands[0]?.name; return name === "node" || name === "npm" || name === "npx"; } async function readCwd() { const r = await bash.execute('printf %s "$PWD"', { capture: 1 << 16 }); return r.stdout; } // Each `bash.execute()` call is its own fresh script as far as `$?` goes — it starts // at 0, not at what the previous call ended with. A real shell's lines don't work // that way, so this session tracks the last line's status itself and seeds it back // in as a leading no-op, the way a login shell's `$?` already reads 0 on its first // line and whatever the prior line left on every one after. let lastExit = 0; async function execLine(text) { bash.clearCancel(); foregroundThisLine = isForegroundLine(text); const outCrlf = crlfTransform(); const errCrlf = crlfTransform(); try { const r = await bash.execute(`(exit ${lastExit}); ${text}`, { stdout: (b) => term.write(outCrlf(b)), stderr: (b) => term.write(errCrlf(b)), capture: 0, }); lastExit = r.exitCode; } catch (e) { term.write(errCrlf(enc.encode(`bash: ${e.message}\n`))); lastExit = 2; } curCwd = await readCwd(); } function promptText() { const display = curCwd === "/app" ? "~" : curCwd.replace(/^\/app(?=\/|$)/, "~"); return `\x1b[2m${display}\x1b[0m \x1b[32m$\x1b[0m `; } function writePrompt() { term.write(pending !== null ? "> " : promptText()); } // Redraws per line-editor.js's contract: the full prompt + buffer, `\x1b[K` to erase // whatever a shorter buffer left behind, then the cursor walked back to its offset. function redraw(echo, cursor) { term.write(`\r${pending !== null ? "> " : promptText()}${echo}\x1b[K`); const back = echo.length - cursor; if (back > 0) term.write(`\x1b[${back}D`); } function onIdleSubmit(line) { const candidate = pending === null ? line : joinContinuation(pending, line); let incomplete = false; let syntaxError = null; try { bash.analyze(candidate); } catch (e) { if (analyzeSaysIncomplete(e.message)) incomplete = true; else syntaxError = e.message; } if (!incomplete && syntaxError === null && selfDetectedIncomplete(candidate)) incomplete = true; if (incomplete) { pending = candidate; writePrompt(); return; } pending = null; // Reported from the line as typed: `execLine` prefixes `(exit n);`, which would // shift every column bash names on line 1. if (syntaxError !== null) { term.write(`bash: ${syntaxError}\r\n`); lastExit = 2; writePrompt(); return; } state = { tag: "running" }; execLine(candidate).then(() => { state = { tag: "idle" }; redraw(editorState.buffer, editorState.cursor); const keys = typeahead; typeahead = ""; if (keys) edit(keys); }); } // Keys typed while a line runs wait for the next prompt, as a tty's input queue holds // them for a shell that is not reading; ^C flushes them, as ISIG does. let typeahead = ""; let editorState = initialLine(); term.onData((data) => { if (state.tag === "foreground") { state.proc.pty.write(data); return; } if (state.tag === "running") { if (data.includes("\x03")) { typeahead = ""; for (const p of live) p.kill("SIGINT"); bash.cancel(); } else { typeahead += data; } return; } edit(data); }); function edit(data) { const result = step(editorState, data); editorState = result.state; if (result.clear) { term.write("\x1b[2J\x1b[H"); redraw(result.echo, result.state.cursor); return; } if (result.interrupt) { term.write("^C\r\n"); pending = null; writePrompt(); return; } if (result.submit !== undefined) { // A paste or replayed typeahead submits text that was never drawn key by key. const [first, ...more] = result.submit.split("\n"); redraw(first, first.length); for (const line of more) term.write(`\r\n> ${line}`); term.write("\r\n"); onIdleSubmit(result.submit); return; } redraw(result.echo, result.state.cursor); } term.onResize(({ cols, rows }) => { if (state.tag === "foreground") state.proc.pty.resize(cols, rows); }); globalThis.__slopboxShell = { readBuffer: () => term.readBuffer(), state: () => state.tag }; writePrompt(); return { dispose() { for (const p of live) p.kill("SIGKILL"); }, }; }