Skip to content

Commit 21efae7

Browse files
feat(node,bun): interactive REPLs, and the runtime gaps they exposed
`node` with no script said "missing script" and `bun repl` refused with a message about a limitation that no longer held. Both now open a REPL on a shared kit: line editing, history, multi-line, Ctrl+C/Ctrl+D, and TypeScript in the Bun one. Building it surfaced six things worth fixing on their own: - stack frames named the transpiled text, not the user's file and line - `readline` was a stub, so interactive scaffolders hung - the argv parser silently ignored flags it claimed to accept - a pipe on fd 0 reported itself as a terminal - `Bun.exit` was missing, and bare `process.exit()` ignored `exitCode` - `--watch`/`--hot` did not exist Deliberately not done: `.load`/`.save`, tab completion, REPL history on disk. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent eb11c07 commit 21efae7

40 files changed

Lines changed: 4848 additions & 1062 deletions

.github/workflows/ci.yml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ jobs:
102102
# but missing here runs nowhere at all: skipped in toolchain-gate for want of
103103
# the crates, and never selected in the only job that has them.
104104
- name: offline spikes that need the Wasm VFS
105-
run: node scripts/run-spikes.mjs --offline dep-cache bun http-binary-body http-response-bytes cookie-session fs-cp fs-errors worker-pool node-cli child-stdin fs-metadata crypto-jwk zlib-brotli port-liveness constants diag-liveness fatal-errors net-close-order net-blocklist
105+
run: node scripts/run-spikes.mjs --offline dep-cache bun http-binary-body http-response-bytes cookie-session fs-cp fs-errors worker-pool node-cli repl stack-traces readline watch child-stdin fs-metadata crypto-jwk zlib-brotli port-liveness constants diag-liveness fatal-errors net-close-order net-blocklist
106106
- run: npm run verify
107107

108108
# --- package-manager gate: the North Star, gated on its own ----------------
@@ -197,6 +197,11 @@ jobs:
197197
run: npm run build:vfs:node && npm run build:codec:node && npm run build:crypto:node
198198
- name: framework templates must start their dev server
199199
run: node scripts/run-spikes.mjs --net preact lit solid vue svelte qwik
200+
# Its own step rather than an extra filter above, because it proves something
201+
# different: that an interactive scaffolder can be ANSWERED, not that a template
202+
# serves. Needs the vendored npm, hence the net tier.
203+
- name: an interactive scaffolder must be answerable
204+
run: node scripts/run-spikes.mjs --net scaffolder
200205

201206
# --- network tier: real template installs from the live registry -----------
202207
# Manual (workflow_dispatch) or scheduled: slow and depends on

packages/kernel-host/coreutils.js

Lines changed: 252 additions & 10 deletions
Large diffs are not rendered by default.

packages/kernel-host/kernel.js

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -563,7 +563,13 @@ export class Kernel {
563563
sab,
564564
debugSab,
565565
debugLang,
566-
spec: { ...spec, pid, ppid: parentPid ?? 0 },
566+
// `capture` goes to the guest too, as the answer to "am I attached to a
567+
// terminal?". The kernel already treats it as that on the blocking read path
568+
// below (handleReadStdin: "a captured process has no way to be typed at"),
569+
// and the flowing path needs the same fact — process.stdin.isTTY was
570+
// hardcoded true, so `node` with no script could not tell an interactive
571+
// shell from a spawnSync and would sit at a prompt nobody could reach.
572+
spec: { ...spec, pid, ppid: parentPid ?? 0, capture },
567573
// #16 stage 2b: a spawned thread gets its creator's MessageChannel end as a
568574
// transferable, delivered to the worker as parentPort at init.
569575
threadPort,
@@ -995,14 +1001,37 @@ export class Kernel {
9951001
const cwd = opts.cwd || "/";
9961002
const programPath = this.resolveProgram(command, cwd, opts.env || {});
9971003
if (!programPath) return -1;
998-
return this.createProcess(
1004+
const pid = this.createProcess(
9991005
// Carry `command` so downstream logic keyed on it works — notably the
10001006
// breakpoint debugger's skip-list (`sh`/`npm`/…): without it a debug-mode
10011007
// shell has command=undefined and is wrongly treated as a debug target, so
10021008
// auto-attach lands on the shell instead of the `node` the user runs.
10031009
{ command, programPath, args, cwd, env: opts.env || {} },
10041010
{ capture: !!opts.capture },
10051011
);
1012+
// Captured here too. `start` grew this first, and leaving `launch` without it gave
1013+
// the kernel two spawn-captured paths that disagreed about fd 0: a captured bare
1014+
// `node` resolved through start() and hung forever through launch(). Whether a
1015+
// process has a terminal is a property of the process, not of which function made
1016+
// it. (The blocking stdin path in handleReadStdin already treated capture as EOF
1017+
// for both, which is why only the flowing side drifted.)
1018+
this.closeCapturedStdin(pid, opts);
1019+
return pid;
1020+
}
1021+
1022+
/**
1023+
* A captured process has no terminal, so nothing will EVER be typed at it: fd 0 is an
1024+
* empty pipe, and it has to reach EOF or a program that reads stdin waits for a
1025+
* writer that cannot exist. `node` with no script is exactly that program now that it
1026+
* reads its script from a non-terminal stdin (see the tty note in boot.js).
1027+
*
1028+
* handleSpawn does the same thing by hand rather than through here, because it has to
1029+
* order its own `input` before the EOF.
1030+
*/
1031+
closeCapturedStdin(pid, opts) {
1032+
if (!opts || !opts.capture || pid < 0) return;
1033+
if (opts.input != null) this.sendStdin(pid, opts.input);
1034+
this.sendStdin(pid, null);
10061035
}
10071036

10081037
/** Stop a running process: terminate its worker + release its ports. */
@@ -1025,6 +1054,7 @@ export class Kernel {
10251054
);
10261055
const proc = this.procs.get(pid);
10271056
proc.onExit = resolve;
1057+
this.closeCapturedStdin(pid, opts);
10281058
// A worker fault is not an exit status, so reject rather than hand back a
10291059
// fabricated code the caller would read as the program's own. The result is
10301060
// attached so stdout/stderr collected before the fault aren't lost. This
@@ -1567,7 +1597,16 @@ export class Kernel {
15671597
}
15681598
const parentPid = parent.pid;
15691599
const childPid = this.createProcess(
1570-
{ command: spec.command, programPath, args: spec.args || [], cwd, env: spec.env || {} },
1600+
{
1601+
command: spec.command,
1602+
programPath,
1603+
args: spec.args || [],
1604+
cwd,
1605+
env: spec.env || {},
1606+
// Whether fd 0 is a pipe, when the spawner said so. Undefined leaves the
1607+
// capture-derived default in place — see the tty note in boot.js.
1608+
tty: spec.stdinIsPipe === undefined ? undefined : !spec.stdinIsPipe,
1609+
},
15711610
{ parentPid, stream: true },
15721611
);
15731612
this.procs.get(childPid).onExit = (res) => {

packages/kernel-host/programs/bun.js

Lines changed: 159 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,15 @@
5050
// import the runtime's BUN_VERSION; it carries the fallback literal below instead,
5151
// and scripts/spike-bun-offline.mjs asserts the two never drift.
5252

53+
// The line editor + read/eval/print loop behind `bun repl`, shared with `node`'s.
54+
// APPENDED to the program source below rather than prepended, so this program's
55+
// own 'use strict' stays first in the file; `createRepl` is a hoisted function
56+
// declaration, so the position does not otherwise matter. The kit is a separate
57+
// template string with its own escaping — the no-backslash rule above is about
58+
// THIS string, not that one. See programs/repl-kit.js.
59+
import { REPL_KIT_SRC } from "./repl-kit.js";
60+
import { WATCH_KIT_SRC } from "./watch-kit.js";
61+
5362
// The version the CLI reports when the Vivari runtime is not present to install
5463
// the Bun global (a plain `node` host, e.g. the offline spike). MUST equal
5564
// BUN_VERSION in packages/runtime/builtins/bun.js — that is the real definition,
@@ -158,6 +167,102 @@ function runFile(target, rest) {
158167
}
159168
}
160169
170+
// ---- TypeScript at the prompt ----------------------------------------------
171+
// Bun accepts TS/TSX everywhere it accepts JS, with no config and no build step,
172+
// so 'bun repl' and 'bun -e' have to as well. Bun.Transpiler is the same engine
173+
// the loader runs over a .ts file (packages/runtime/typescript-transform.js), so
174+
// what the prompt accepts and what a file accepts cannot drift.
175+
//
176+
// The TSX loader is Bun.Transpiler's own default and matches what real bun does
177+
// with -e. It carries TSX's one ambiguity with it: in a .tsx source an arrow
178+
// generic, 'const f = <T>(x: T) => x', parses as JSX. That is the binary's
179+
// behaviour in .tsx too; write 'const f = <T,>(x: T) => x' as you would there.
180+
//
181+
// A host with no Vivari runtime has no Bun global to ask, so the source passes
182+
// through untouched rather than the program dying at startup.
183+
function bunTranspile(code, what) {
184+
const g = globalThis.Bun;
185+
if (!g || typeof g.Transpiler !== 'function') return code;
186+
try {
187+
return new g.Transpiler({ loader: 'tsx' }).transformSync(code);
188+
} catch (e) {
189+
// Say WHICH input failed. A bare 'Unexpected token' out of a REPL that also
190+
// evaluates is ambiguous between the transform and the evaluator.
191+
const wrapped = new Error('bun: could not parse ' + (what || 'input') + ': ' + ((e && e.message) || e));
192+
wrapped.stack = wrapped.message;
193+
throw wrapped;
194+
}
195+
}
196+
197+
// ---- bun repl ---------------------------------------------------------------
198+
// An interactive prompt, on the shared kit in programs/repl-kit.js (appended to
199+
// this source at the bottom of the file). This used to refuse, on the grounds
200+
// that "the REPL wants a tty and the sandbox has pipes rather than a terminal
201+
// device" — which was simply not true by the time it was written: the runtime
202+
// gives a process a flowing TTY stdin with isTTY set, and the interactive shell
203+
// forwards raw keystrokes to its foreground child. Nothing was missing except
204+
// the loop itself.
205+
//
206+
// What is Bun's rather than the kit's is all in this config: TypeScript at the
207+
// prompt, the Bun global in scope, let/const hoisted to var, 'import' rewritten
208+
// to a dynamic import, and the _ / _error bindings. Those are the REPL semantics Bun
209+
// documents (https://bun.com/docs/runtime/repl).
210+
function doRepl(rest) {
211+
const unknown = (rest || []).filter((a) => a.charAt(0) === '-');
212+
if (unknown.length) {
213+
err('bun repl: unrecognised flag ' + unknown[0]);
214+
process.exit(1);
215+
return;
216+
}
217+
installBun(true);
218+
const ident = bunIdent();
219+
220+
// The prompt's own scope. require() resolves from the CWD, not from /bin, so
221+
// require('./thing') at the prompt means what the user typing it means.
222+
const moduleBuiltin = require('module');
223+
const cwdRequire = typeof moduleBuiltin.createRequire === 'function'
224+
? moduleBuiltin.createRequire(path.resolve(cwd, '[repl]'))
225+
: require;
226+
globalThis.require = (m) => cwdRequire(m.charAt(0) === '.' || m.charAt(0) === '/' ? path.resolve(cwd, m) : m);
227+
228+
createRepl({
229+
banner: 'Welcome to Bun v' + ident.version + ' (Vivari).' + NL + 'Type ".help" for more information.',
230+
prompt: '> ',
231+
contPrompt: '... ',
232+
historyFile: (process.env.HOME || '') + '/.bun_repl_history',
233+
// Before the transform, and it has to be: the transform below THROWS on
234+
// 'function f() {', so a continuation decided from its error would never get
235+
// one. See the contract note in programs/repl-kit.js.
236+
isIncomplete: replIncomplete,
237+
transform: (src) => bunTranspile(src, 'input'),
238+
rewrite: (src) => {
239+
// Imports first: the rewrite turns a statement-level import (illegal in
240+
// an eval) into an awaited dynamic import, and the declaration hoist then
241+
// treats what is left like any other line.
242+
const imported = replRewriteImports(src);
243+
const hoisted = replHoistDeclarations(imported.code);
244+
const names = imported.names.slice();
245+
for (let i = 0; i < hoisted.names.length; i++) {
246+
if (names.indexOf(hoisted.names[i]) < 0) names.push(hoisted.names[i]);
247+
}
248+
return { code: hoisted.code, names: names, statement: hoisted.statement || imported.statement };
249+
},
250+
errorVar: '_error',
251+
commands: {
252+
'.copy': {
253+
help: 'NOT AVAILABLE here: copy the last result to the clipboard',
254+
run: (arg, api) => {
255+
// Refused by name, the way bun publish and bun patch are, rather
256+
// than silently doing nothing: a .copy that prints "copied" and copies
257+
// nothing is worse than one that says it cannot.
258+
api.write('.copy is not implemented in the Vivari shim: it writes to the SYSTEM clipboard, and a Web Worker cannot reach navigator.clipboard at all — that API needs a document and a user gesture, and a worker has neither.' + NL);
259+
api.write('Select the text in the terminal and copy it with your browser instead.' + NL);
260+
},
261+
},
262+
},
263+
});
264+
}
265+
161266
// ---- run a package.json script (with pre/post), via the shell --------------
162267
function runScriptCmd(command, rest) {
163268
return new Promise((resolve) => {
@@ -538,6 +643,7 @@ function helpText() {
538643
' bun <file.ts> run a TS/JS/TSX file (Bun global + zero-config TS)',
539644
' bun run <script|file> run a package.json script or a file',
540645
' bun install|add|remove manage dependencies (delegates to npm; writes bun.lock)',
646+
' bun repl interactive prompt (TypeScript, Bun global, history)',
541647
' bun x <pkg> run a package binary (bunx)',
542648
' bun build <entry> bundle an entry point and its imports',
543649
' bun test [filters] run bun:test suites (-t, --bail, --timeout, -u, --reporter)',
@@ -853,7 +959,53 @@ function doCreate(args) {
853959
return doExec([pkgName].concat(rest));
854960
}
855961
962+
// --watch / --hot, which bun takes anywhere before the target: 'bun --watch x.ts',
963+
// 'bun run --watch dev'. Peeled off wherever they appear, so the argv the supervisor
964+
// re-spawns is the same command MINUS the watch request — re-passing it would make
965+
// every restart supervise a supervisor.
966+
//
967+
// --hot is the one honest compromise here. In real bun it is a SOFT reload: the module
968+
// graph is swapped inside the running process, so globals survive and an open server
969+
// keeps its socket. Doing that needs the loader to invalidate and re-evaluate a
970+
// subgraph while everything else stays live, which is its own change. What runs here
971+
// is --watch, and the one-line notice below says exactly that, once, rather than
972+
// letting a program silently lose the state its author expected to keep.
973+
function peelWatchFlags(list) {
974+
const kept = [];
975+
let watch = false, hot = false;
976+
for (const a of list) {
977+
if (a === '--watch') { watch = true; continue; }
978+
if (a === '--hot') { hot = true; continue; }
979+
// Bun clears the screen on rerun and this flag turns that off. We never clear —
980+
// the terminal is the user's whole session record — so it is already the default
981+
// and is dropped rather than reported as unknown.
982+
if (a === '--no-clear-screen') continue;
983+
kept.push(a);
984+
}
985+
return { kept: kept, watch: watch || hot, hot: hot };
986+
}
987+
856988
async function main() {
989+
const w = peelWatchFlags(argv);
990+
if (w.watch) {
991+
const target = w.kept[0] === 'run' ? w.kept[1] : w.kept[0];
992+
if (!target) { err('bun --watch needs a file or a script to run'); process.exit(1); }
993+
if (w.hot) {
994+
err('note: --hot runs as --watch in the Vivari shim (the process is RESTARTED, not soft-reloaded), so globals and open sockets do not survive a reload.');
995+
}
996+
createWatcher({
997+
argv: ['bun'].concat(w.kept),
998+
label: target,
999+
paths: [path.resolve(cwd, target)],
1000+
// Bun prints no banner of its own around a rerun, so neither do we. The banners
1001+
// node prints are node's, and copying them here would describe bun wrongly.
1002+
onRunEnd: null,
1003+
onRestart: null,
1004+
clear: false,
1005+
});
1006+
return;
1007+
}
1008+
8571009
const first = argv[0];
8581010
if (!first || first === '--help' || first === '-h' || first === 'help') { out(helpText()); process.exit(0); }
8591011
if (first === '--version' || first === '-v') { out(bunIdent().version); process.exit(0); }
@@ -862,7 +1014,11 @@ async function main() {
8621014
installBun(true);
8631015
const code = argv[1] || '';
8641016
const fn = new Function('code', 'return eval(code)');
865-
fn(code);
1017+
// Transpiled, because real bun -e takes TypeScript: bun has no JS-only mode,
1018+
// and every one of its docs' one-liners is written in TS. This handed the
1019+
// source straight to eval, so 'bun -e' was the one place in the shim where
1020+
// 'const n: number = 1' was a SyntaxError.
1021+
fn(bunTranspile(code, '-e'));
8661022
process.exit(0);
8671023
}
8681024
@@ -907,11 +1063,7 @@ async function main() {
9071063
err('Edit the file in node_modules directly, or vendor the package into your source tree.');
9081064
process.exit(1);
9091065
return;
910-
case 'repl':
911-
err('bun repl is not implemented in the Vivari shim: the REPL wants a tty for line editing and history, and the sandbox has pipes rather than a terminal device.');
912-
err('Use "bun run <file>" or "bun -e <code>" instead.');
913-
process.exit(1);
914-
return;
1066+
case 'repl': return doRepl(rest);
9151067
default:
9161068
// Bare bun <file> / bun <script> -> run it. Anything else is a subcommand we
9171069
// do not have; say so instead of failing later as a missing file.
@@ -935,7 +1087,7 @@ main().catch((e) => {
9351087
process.stderr.write('bun: ' + ((e && e.stack) || e) + NL);
9361088
process.exit(1);
9371089
});
938-
`;
1090+
` + REPL_KIT_SRC + WATCH_KIT_SRC;
9391091

9401092
// `bunx` — Bun's package runner. Same behaviour as `bun x`; delegates to npx.
9411093
export const BUNX_PROGRAM = `

0 commit comments

Comments
 (0)