Skip to content

feat(run): cross-platform script shell via deno_task_shell#448

Open
colinhacks wants to merge 3 commits into
mainfrom
deno-shell-emulator
Open

feat(run): cross-platform script shell via deno_task_shell#448
colinhacks wants to merge 3 commits into
mainfrom
deno-shell-emulator

Conversation

@colinhacks

Copy link
Copy Markdown
Contributor

Implements pnpm's shellEmulator via deno_task_shell (MIT): nub run script bodies run through a bundled POSIX-subset shell by default on every platform, so POSIX-isms (rm -rf, &&, $VAR) work on Windows without a system sh.

  • Default-on is a deliberate divergence from pnpm (defaults off).
  • Opt out: shell-emulator=false → native sh/cmd; --script-shell <path> overrides to a real shell.
  • The augmentation env (PATH shim, NODE_OPTIONS, npm_*) reaches both paths, so node/tsc stays transpiled. deno's KillSignal handles SIGTERM/SIGINT.
  • Scope: nub run only.

Behavior change; warrants a minor release.

Closes #399

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
Copilot AI review requested due to automatic review settings July 12, 2026 18:49

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@vercel

vercel Bot commented Jul 12, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
nub Ready Ready Preview, Comment Jul 12, 2026 7:36pm

Request Review

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ 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_emulator modulerun_inherit / run_prefixed drive deno_task_shell on a current-thread tokio runtime + LocalSet (deno's executor is !Send; trailing-& uses spawn_local), with build_env_map seeding the full parent env then layering nub's overrides because deno spawns children with env_clear.
  • Refactor build_script_command to a ScriptInvocation enumNative(Command) vs Emulated { body, env, cwd }, with the augmentation environment assembled into a single ordered env_pairs list 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_emulator env over .npmrc, both shell-emulator and shellEmulator spellings, 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 the LocalSet), shell-emulator=false opt-out; new "Cross-platform script shell" docs section.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using Claude Opus𝕏

Comment thread site/content/docs/runner/run.mdx Outdated
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`.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ 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")?;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ 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

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ 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-outparse_body wraps every parser::parse failure with EMULATOR_OPT_OUT_HINT, so a user hitting a construct outside the subset (arithmetic $((…)), unbalanced quotes) is directed to shell-emulator=false instead of a bare parse error.
  • Absolutize a relative project root before denoabsolute_cwd resolves a relative cwd via std::path::absolute on both the run_inherit and run_prefixed paths. deno's ShellState::new runs assert!(cwd.is_absolute()), which under panic = "abort" would hard-abort; the native Command path tolerated a relative root via current_dir, so this makes the emulator match instead of panicking. Verified against upstream deno_task_shell main.
  • 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() is false upstream.
  • 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 that abc isn'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.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using 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`.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ 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
@colinhacks colinhacks added the breaking Introduces a breaking behavior change (warrants a minor release + release-note callout) label Jul 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

breaking Introduces a breaking behavior change (warrants a minor release + release-note callout)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Cross platform shell for task runner

2 participants