node: NODE_COMPAT env var — project-wide augmentation opt-out (= --node, tree-wide)#34
Conversation
…de, tree-wide) A truthy NODE_COMPAT (1/true/yes, case-insensitive; empty/0/false/unset = off) forces compat mode — zero runtime augmentation, identical to the --node flag — across every runtime entrypoint. Because env vars inherit to descendants, setting it once covers the whole process tree: the persistent form of --node. Version resolution/provisioning stays on (compat = no augmentation, not no-pinning). Mechanism: a node_compat_env() helper OR-ed into the compat bit at each entrypoint that decides compat — run_file_in_dir (nub <file> + the node-hijack), run_script (nub run), run_exec_with_dlx (nub exec / nubx), and run_workspace_target (-r). So NODE_COMPAT=1 node foo.ts / nub foo.ts / nub run x all run vanilla. Composes with --node (either forces compat); no --no-node re-enable (deferred). Brand-clean: NODE_ prefix (Node doesn't claim the name), not NUB_/AUBE_. Reconciles the prior NODE_COMPAT=1 dismissal in AGENTS.md (that was about a file-run compat var, superseded by --node) and the stale "no NODE_COMPAT env var" line in site/public/skill.md. Claude-Session: https://claude.ai/code/session_01YRvztkcr4fzfg9rD5edUwa
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
…eft uncovered) `nub watch` was the one runtime entrypoint whose Node spawn never flowed through the NODE_COMPAT OR — it has no --node flag and always injected nub's flags + preload + eager .env*. Wire the ambient opt-out in: when NODE_COMPAT is truthy, run the pinned Node with `--watch` and only the user's argv (no flag injection, no preload, no .env), matching the zero-augmentation contract everywhere else. Version provisioning stays on. Self-review audit of every compat-deciding / Node-spawn path in cli.rs confirms no remaining bypass: the four OR-ed funnels (run_file_in_dir, run_script, run_exec_with_dlx, run_workspace_target) plus run_watch now cover every entry — `nub <file>`, the node-hijack, run/exec/nubx, -r workspace runs, stdin/-flag short-circuits, and the launch_bin native-bin + node-bin branches (the latter routes back through run_file_in_dir; the former gates apply_exec_augmentation on the already-OR-ed compat bit). The only direct `node` spawns are the two run_watch branches (both handled) and run_file_in_dir's spawn_node (post-OR). Test node_compat_env_forces_vanilla_under_watch: spawns `NODE_COMPAT=1 nub watch`, the probe writes its augmentation snapshot to a sentinel file (the watch loop never self-exits), asserts no preload, empty NODE_OPTIONS, no injected execArgv flag (only --watch/--watch-preserve-output), and no eager .env. Claude-Session: https://claude.ai/code/session_01YRvztkcr4fzfg9rD5edUwa
There was a problem hiding this comment.
Pull request overview
Note
Copilot couldn't run its full agentic review because no GitHub Actions runner was available. Make sure your repository has a runner available to run Copilot's review, or add a copilot-setup-steps.yml file specifying one with the runs-on attribute. See the docs for more details.
Adds a project/tree-wide “compat mode” opt-out via a truthy NODE_COMPAT environment variable, making runtime augmentation consistently disable-able without repeating --node across process trees.
Changes:
- Introduces
node_compat_env()and ORs it into compat decisions across all runtime entrypoints. - Adds an integration test ensuring
NODE_COMPATforces vanilla behavior for both directnub <file>and thenodePATH-hijack path (unix). - Updates documentation to reflect the new
NODE_COMPATbehavior and removes prior “no env var” statements.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 4 comments.
| File | Description |
|---|---|
crates/nub-cli/src/cli.rs |
Implements NODE_COMPAT parsing and applies it across runtime entrypoints. |
crates/nub-cli/tests/integration.rs |
Adds integration coverage asserting vanilla vs augmented behavior under NODE_COMPAT. |
AGENTS.md |
Updates architectural/decision docs to mark NODE_COMPAT as live and clarify semantics. |
site/public/skill.md |
Updates user-facing docs to describe using NODE_COMPAT as persistent --node. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| let run = |bin: &std::path::Path, args: &[&str], compat: Option<&str>| { | ||
| let mut cmd = Command::new(bin); | ||
| cmd.args(args) | ||
| .current_dir(&proj) | ||
| .env("XDG_CACHE_HOME", unique_test_cache()) | ||
| .env_remove("NODE_COMPAT"); | ||
| if let Some(v) = compat { | ||
| cmd.env("NODE_COMPAT", v); | ||
| } | ||
| cmd.output().expect("spawn") | ||
| }; |
| assert!( | ||
| !s.contains("preload"), | ||
| "{ctx} must run vanilla (no preload): {s}" | ||
| ); | ||
| assert!( | ||
| s.contains("NODE_OPTIONS=\n") || s.contains("NODE_OPTIONS=$"), | ||
| "{ctx} must not inject NODE_OPTIONS: {s:?}" | ||
| ); | ||
| assert!( | ||
| s.contains("execArgv=\n"), | ||
| "{ctx} must have empty execArgv: {s:?}" | ||
| ); | ||
| assert!( | ||
| s.contains("COMPAT_ENV=\n") || s.contains("COMPAT_ENV=$"), | ||
| "{ctx} must NOT load `.env` (compat mode): {s:?}" | ||
| ); |
| #[cfg(unix)] | ||
| #[test] | ||
| fn node_compat_env_forces_vanilla_tree_wide() { |
| fn node_compat_env() -> bool { | ||
| match env::var("NODE_COMPAT") { | ||
| Ok(v) => matches!(v.trim().to_ascii_lowercase().as_str(), "1" | "true" | "yes"), | ||
| Err(_) => false, | ||
| } | ||
| } |
The clippy fix in 894e6ef left `&& s.contains(...) {` on one line, which fails `cargo fmt --check` (CI's fmt job). Run rustfmt: brace on its own line. Claude-Session: https://claude.ai/code/session_01YRvztkcr4fzfg9rD5edUwa
What
A truthy
NODE_COMPATenv var forces compat mode (zero runtime augmentation — identical to the--nodeflag) across every nub runtime entrypoint, project/tree-wide. Because env vars inherit to descendants, setting it once (shell,.envrc, CI) covers the whole process tree — the persistent form of--node. Version resolution/provisioning stays ON (compat = no augmentation, not no-pinning).Semantics
1/true/yes(case-insensitive, trimmed). Empty /0/false/ unset = off.--node: either one forces compat. No--no-nodere-enable (deferred per the design recommendation).NODE_prefix (Node doesn't claim the name), notNUB_*/AUBE_*.Call sites
A single
node_compat_env()helper is OR-ed into thecompat/compat_modebit at each entrypoint where compat is decided:run_file_in_dirnub <file>and thenode-PATH-hijack (run_as_node→run_file→ here)run_scriptnub run <script>run_exec_with_dlxnub exec/nubx(native-bin path)run_workspace_target-r/--filterworkspace runsSo
NODE_COMPAT=1 node foo.ts,NODE_COMPAT=1 nub foo.ts, andNODE_COMPAT=1 nub run xall run vanilla.Test
node_compat_env_forces_vanilla_tree_wide(unix-only) asserts via the.env-eager-load discriminator:.envloads);NODE_COMPAT=1via the hijack → emptyNODE_OPTIONS/execArgv, no.env;NODE_COMPAT=1on a directnub <file>run → vanilla too;TRUE) forces compat; falsy (0) stays augmented.Docs
Reconciles the prior
NODE_COMPAT=1dismissal inAGENTS.md(that was a file-run compat var, superseded by--node; this is a different thing — the tree-wide augmentation opt-out) and the stale "noNODE_COMPAT=1env var" line insite/public/skill.md.Verification
cargo build/clippy/fmtclean; the new test + existingnode_hijack_node_flag_opts_out_of_augmentationpass. (The 33 augmentation-transpile test failures in the local run are pre-existing — they require theruntime/asset dir adjacent to the binary, which a bare test build lacks; confirmed identical failures on the base branch.)https://claude.ai/code/session_01YRvztkcr4fzfg9rD5edUwa