feat(run): cross-platform script shell via deno_task_shell#448
feat(run): cross-platform script shell via deno_task_shell#448colinhacks wants to merge 3 commits into
Conversation
Implement pnpm's `shellEmulator` setting for real via the `deno_task_shell` crate (MIT), a bundled POSIX-subset shell. `nub run` script bodies now execute through it by default on every platform, so a script behaves the same on Windows as on Unix without a system `sh` — `rm -rf`, `&&`, `$VAR`, `cp`, and the other common POSIX-isms `cmd.exe` can't run work everywhere. Default-on is a deliberate divergence from pnpm (whose `shellEmulator` defaults off): one engine, identical behavior across platforms. Opt back out to the native `sh`/`cmd` with `shell-emulator=false` in `.npmrc`; `--script-shell <path>` still overrides to a real shell binary and wins over the emulator. The augmentation environment (PATH shim, `NODE_OPTIONS`, `$NODE`, `npm_*`) is assembled once and handed to both the native `Command` path and the emulator, so a `node`/`tsc` spawned inside a script stays transpiled either way. Signal forwarding wires deno's `KillSignal` so SIGTERM/SIGINT reach emulated children. Scope is `nub run` scripts only (nub-cli); install/lifecycle scripts are unchanged and deferred. This is a behavior change and warrants a minor release. Closes #399 Claude-Session: https://claude.ai/code/session_01FP9cPvndrGxL7giLJvi2PS
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
There was a problem hiding this comment.
ℹ️ No critical issues — two minor observations inline.
Reviewed changes — the initial review of routing nub run package scripts through a bundled deno_task_shell POSIX-subset shell by default on every platform.
- Add the
shell_emulatormodule —run_inherit/run_prefixeddrivedeno_task_shellon a current-thread tokio runtime +LocalSet(deno's executor is!Send; trailing-&usesspawn_local), withbuild_env_mapseeding the full parent env then layering nub's overrides because deno spawns children withenv_clear. - Refactor
build_script_commandto aScriptInvocationenum —Native(Command)vsEmulated { body, env, cwd }, with the augmentation environment assembled into a single orderedenv_pairslist that BOTH paths consume, so a script child sees an identical environment either way. - Add
shell_emulator(project_root)config resolver — default-on,npm_config_shell_emulatorenv over.npmrc, bothshell-emulatorandshellEmulatorspellings, falsey =false/0/no/off. - Wire signal forwarding for the emulated path — SIGTERM/SIGHUP/SIGQUIT always forwarded to deno's
KillSignal; SIGINT forwarded only when stdin is not a TTY (avoids double-SIGINT); Windows relies on the Job Object plus Ctrl-C forwarding. - Add e2e tests + docs — POSIX-ism green by default, augmentation env reaches the script, POSIX arg escaping, exit-code propagation, trailing-
&(proves theLocalSet),shell-emulator=falseopt-out; new "Cross-platform script shell" docs section.
Claude Opus | 𝕏
| nub run clean # identical on Windows, macOS, and Linux | ||
| ``` | ||
|
|
||
| It is a subset, not a full shell. Covered: the built-ins (`rm`, `cp`, `mv`, `mkdir`, `cat`, `echo`, `test`, `xargs`, `pwd`, `sleep`, …), pipes, `&&` / `||`, `;`, `$VAR` and `$(…)` expansion, `~`, brace expansion, redirection, and background `&` jobs. Not covered: shell control flow (`for`, `while`, `if`, `case`), functions, and arithmetic `$((…))`. A command that isn't a built-in is resolved on `PATH` and run as a real process, so `node`, `tsc`, and your `node_modules/.bin` tools work as usual. The subset is not byte-identical to `bash`, `sh`, or pnpm's `@yarnpkg/shell`. |
There was a problem hiding this comment.
ℹ️ test is listed as a covered built-in, but deno_task_shell has no test builtin — its built-ins are cp, mv, rm, mkdir, pwd, sleep, echo, cat, exit, head, export, unset, xargs, and :. A non-builtin resolves on PATH, and Windows has no test.exe, so test fails there — the exact cross-platform gap this section claims to close. Consider dropping test from the list (or swapping in a real builtin like head).
Technical details
# Docs list a non-existent `test` builtin
## Affected sites
- `site/content/docs/runner/run.mdx:220` — "Covered: the built-ins (`rm`, `cp`, `mv`, `mkdir`, `cat`, `echo`, `test`, `xargs`, `pwd`, `sleep`, …)"
## Required outcome
- The listed built-ins are all actually provided by `deno_task_shell` so the doc's cross-platform promise holds on Windows.
## Suggested approach
- Remove `test` from the list. Optionally add `head` (a real builtin) if a replacement is wanted. Source of truth: https://docs.deno.com/runtime/reference/cli/task/#built-in-commands| let err_handle = std::thread::Builder::new() | ||
| .name("nub-emul-err".into()) | ||
| .spawn(move || err_policy.run(into_pipe_reader(err_reader))) | ||
| .context("spawn shell-emulator stderr drain thread")?; |
There was a problem hiding this comment.
ℹ️ These drains use Builder::spawn + ?, so under OS thread-create pressure run_prefixed returns an error. The native prefixed path (PipeReaders) instead falls back to an inline poll(2) drain in exactly that case (the nub ci exit-101 family noted in spawn_script_prefixed). This degrades gracefully (an error, not a panic/abort), but it's a behavioral divergence between the two prefixed paths worth a note or a follow-up.
Technical details
# Emulated prefixed path lacks the native path's thread-exhaustion fallback
## Affected sites
- `crates/nub-cli/src/shell_emulator.rs:69-76` — `run_prefixed` spawns stdout/stderr drains with `Builder::spawn(...)?`
- `crates/nub-cli/src/cli.rs` (`PipeReaders::drain`) — native path interleaves both pipes on one thread via `poll(2)` when `thread::spawn` would EAGAIN
## Required outcome
- Under thread-create pressure, a workspace `-r` / `--stream` run through the emulator behaves no worse than the native path (ideally still drains rather than erroring the single package's script).
## Open questions for the human
- Is matching the native path's inline fallback worth the complexity here, or is erroring out acceptable for the emulated path? (`block_on` already occupies the current thread, so a single-thread interleave would need `poll(2)` on the two `PipeReader`s the same way `PipeReaders` does.)Address review on the default-on shell emulator: - Parse errors (arithmetic `$((…))`, unbalanced quotes) now carry a hint naming the escape hatch (`shell-emulator=false`), in both the inherit and prefixed paths via a shared `parse_body` helper. Control-flow keywords (`for`/`if`/…) don't parse-error — deno reads them as unknown commands (exit 127) — so the docs call that out and point at the opt-out. - Absolutize the cwd before handing it to deno: `ShellState::new` asserts `cwd.is_absolute()`, which would hard-abort under `panic = "abort"`; the native `Command` path tolerates a relative root, so match it (clean error on failure instead of a panic). - Tighten the opt-out test to also assert the emulated `for` exits nonzero, documenting the accepted regression rather than hiding it. Claude-Session: https://claude.ai/code/session_01FP9cPvndrGxL7giLJvi2PS
There was a problem hiding this comment.
ℹ️ No new issues in the delta — one minor doc observation inline.
Reviewed changes — the delta since the prior pullfrog review (6ea1309): parse-error UX, a cwd-absolutization fix, and a signal/doc/test hardening pass.
- Point parse errors at the opt-out —
parse_bodywraps everyparser::parsefailure withEMULATOR_OPT_OUT_HINT, so a user hitting a construct outside the subset (arithmetic$((…)), unbalanced quotes) is directed toshell-emulator=falseinstead of a bare parse error. - Absolutize a relative project root before deno —
absolute_cwdresolves a relative cwd viastd::path::absoluteon both therun_inheritandrun_prefixedpaths. deno'sShellState::newrunsassert!(cwd.is_absolute()), which underpanic = "abort"would hard-abort; the nativeCommandpath tolerated a relative root viacurrent_dir, so this makes the emulator match instead of panicking. Verified against upstreamdeno_task_shellmain. - Document SIGHUP's non-aborting mapping — a comment clarifies that
SignalKind::Other(1)is delivered to the in-flight child but doesn't trigger deno's abort path. Confirmed accurate:Other(_).causes_abort()isfalseupstream. - Harden the opt-out test — the
for-loop differential now also asserts the default (emulated) run exits nonzero, documenting the accepted control-flow regression rather than only checking thatabcisn't printed. - Reword the docs subset section — adds that control-flow keywords aren't a clean parse error (they read as an unknown command, e.g.
for: command not found).
The delta is a clean robustness + UX pass and introduces no new issues. The two prior informational threads (the run_prefixed thread-exhaustion divergence, and the test non-builtin in the docs) are both still open — the second is re-surfaced inline below because this commit re-touched that exact line.
Claude Opus | 𝕏
| nub run clean # identical on Windows, macOS, and Linux | ||
| ``` | ||
|
|
||
| It is a subset, not a full shell. Covered: the built-ins (`rm`, `cp`, `mv`, `mkdir`, `cat`, `echo`, `test`, `xargs`, `pwd`, `sleep`, …), pipes, `&&` / `||`, `;`, `$VAR` and `$(…)` expansion, `~`, brace expansion, redirection, and background `&` jobs. Not covered: shell control flow (`for`, `while`, `if`, `case`), functions, and arithmetic `$((…))` — a script that needs those must opt out (below). Control-flow keywords aren't a clean parse error; they read as an unknown command, so a `for` loop fails with `for: command not found`. A command that isn't a built-in is resolved on `PATH` and run as a real process, so `node`, `tsc`, and your `node_modules/.bin` tools work as usual. The subset is not byte-identical to `bash`, `sh`, or pnpm's `@yarnpkg/shell`. |
There was a problem hiding this comment.
ℹ️ This reword re-touches the covered-builtins line but still lists test, which deno_task_shell does not provide as a builtin — its builtins are cp, mv, rm, mkdir, pwd, sleep, echo, cat, exit, head, export, unset, xargs, and :. A non-builtin resolves on PATH, and Windows has no test.exe, so test fails there — the exact cross-platform gap this section claims to close. Drop test from the list (optionally swap in a real builtin like head). Source: https://docs.deno.com/runtime/reference/cli/task/#built-in-commands
`single_package_run_echoes_command_to_stderr_unless_silent` branched on `cfg!(windows)` to expect `cmd.exe` caret-escaping in the `$ …` preamble and output. With the shell emulator default-on, Windows scripts now run through the POSIX-subset shell like every other platform, so the echo and the execution share one `sh`-escaped string (`'brave new world'`) — the Windows branch encoded pre-emulator native-cmd behavior and no longer holds. Drop the conditional; the sh form is asserted on every platform, which also serves as the host-agnostic guard that the emulated display uses the sh escaper (the display and exec are the same `full_cmd`; there was no display/exec mismatch to fix in the code). Also assert the sh-escaped preamble in the shell_emulator arg-forwarding test so the display-escaper invariant is covered without a Windows runner. Claude-Session: https://claude.ai/code/session_01FP9cPvndrGxL7giLJvi2PS

Implements pnpm's
shellEmulatorviadeno_task_shell(MIT):nub runscript bodies run through a bundled POSIX-subset shell by default on every platform, so POSIX-isms (rm -rf,&&,$VAR) work on Windows without a systemsh.shell-emulator=false→ nativesh/cmd;--script-shell <path>overrides to a real shell.NODE_OPTIONS,npm_*) reaches both paths, sonode/tscstays transpiled. deno'sKillSignalhandles SIGTERM/SIGINT.nub runonly.Behavior change; warrants a minor release.
Closes #399