Something went wrong. Try again.
Monorepo for the nibling.run platform
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320// 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"); }, };}