diff --git a/hermes_cli/main.py b/hermes_cli/main.py index ddde81698f..a3371edf20 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -2189,6 +2189,15 @@ def _ensure_tui_workspace(tui_dir: Path) -> None: sys.exit(1) +def _npm_install_env(env: dict[str, str] | None = None) -> dict[str, str]: + """Build a clean environment for installing the pinned UI toolchain.""" + run_env = {**os.environ, **(env or {}), "CI": "1"} + # esbuild treats this as an executable override. If a shell points it at a + # different release, the pinned package's postinstall rejects that binary. + run_env.pop("ESBUILD_BINARY_PATH", None) + return run_env + + def _make_tui_argv(tui_dir: Path, tui_dev: bool) -> tuple[list[str], Path]: """TUI: --dev → tsx src; else node dist (HERMES_TUI_DIR prebuilt or esbuild).""" _ensure_tui_node() @@ -2319,7 +2328,7 @@ def _make_tui_argv(tui_dir: Path, tui_dev: bool) -> tuple[list[str], Path]: text=True, encoding="utf-8", errors="replace", - env={**with_hermes_node_path(), "CI": "1"}, + env=_npm_install_env(with_hermes_node_path()), ) result = _run_tui_install() @@ -5942,7 +5951,7 @@ def _run_npm_install_deterministic( # unicode-animations' postinstall animates to /dev/tty (bypasses # --silent/capture_output). It no-ops when CI is set — same as the TUI # install path and nix/lib.nix npm ci hooks. - run_env = {**os.environ, **(env or {}), "CI": "1"} + run_env = _npm_install_env(env) def _run(cmd: list[str]) -> subprocess.CompletedProcess: return _run_npm_watching_for_engine_failure( diff --git a/tests/hermes_cli/test_tui_npm_install.py b/tests/hermes_cli/test_tui_npm_install.py index 7fcfd2a345..90e85bb6cd 100644 --- a/tests/hermes_cli/test_tui_npm_install.py +++ b/tests/hermes_cli/test_tui_npm_install.py @@ -137,14 +137,15 @@ def test_no_stray_lockfiles_in_workspace_subdirs(main_mod) -> None: ) -def test_make_tui_argv_omits_workspace_when_tui_has_own_lockfile( +def test_make_tui_argv_omits_workspace_and_scrubs_esbuild_override( tmp_path: Path, main_mod, monkeypatch ) -> None: """When ui-tui/ has its own package-lock.json, _workspace_root returns tui_dir itself. npm install --workspace ui-tui would fail in that case because npm cannot find a workspace named "ui-tui" inside ui-tui/. The fix omits --workspace and runs plain npm install from tui_dir. - See #42973. + See #42973. The npm child must also ignore an inherited esbuild binary + override: a version mismatch makes esbuild's postinstall abort (#87405). """ tui_dir = tmp_path / "ui-tui" tui_dir.mkdir() @@ -156,6 +157,7 @@ def test_make_tui_argv_omits_workspace_when_tui_has_own_lockfile( monkeypatch.delenv("TERMUX_VERSION", raising=False) monkeypatch.setenv("PREFIX", "/usr") + monkeypatch.setenv("ESBUILD_BINARY_PATH", "/opt/esbuild-0.28.2") monkeypatch.setattr(main_mod, "_tui_need_npm_install", lambda _root: True) monkeypatch.setattr(main_mod.shutil, "which", lambda name: f"/bin/{name}") calls = [] @@ -173,6 +175,8 @@ def test_make_tui_argv_omits_workspace_when_tui_has_own_lockfile( assert "--workspace" not in install_cmd, ( f"npm install should omit --workspace when tui_dir has its own lockfile, got: {install_cmd}" ) - assert install_cmd[:2] == ["/bin/npm", "install"] + assert Path(install_cmd[0]).name in {"npm", "npm.cmd"} + assert install_cmd[1] == "install" # cwd must be tui_dir (standalone), not parent assert calls[0][1]["cwd"] == str(tui_dir) + assert "ESBUILD_BINARY_PATH" not in calls[0][1]["env"] diff --git a/tests/hermes_cli/test_web_ui_build.py b/tests/hermes_cli/test_web_ui_build.py index e5871e8d62..9850712e55 100644 --- a/tests/hermes_cli/test_web_ui_build.py +++ b/tests/hermes_cli/test_web_ui_build.py @@ -123,7 +123,7 @@ class TestBuildWebUISkipsWhenFresh: - def test_web_install_omits_workspace_when_web_has_own_lockfile( + def test_web_install_omits_workspace_and_scrubs_esbuild_override( self, tmp_path, monkeypatch ): """web/ with its own lockfile => _workspace_root returns web_dir, so @@ -132,13 +132,16 @@ class TestBuildWebUISkipsWhenFresh: Symmetric to the TUI fix in test_tui_npm_install.py. See #42973. With web's own lockfile present at cwd, _run_npm_install_deterministic - uses ``npm ci`` (not ``npm install``). + uses ``npm ci`` (not ``npm install``). The shared installer must also + remove an inherited esbuild binary override so package/binary versions + cannot diverge (#87405). """ web_dir, _ = _make_web_dir(tmp_path) (web_dir / "package-lock.json").write_text("{}", encoding="utf-8") (tmp_path / "package-lock.json").write_text("{}", encoding="utf-8") monkeypatch.delenv("TERMUX_VERSION", raising=False) monkeypatch.setenv("PREFIX", "/usr") + monkeypatch.setenv("ESBUILD_BINARY_PATH", "/opt/esbuild-0.28.2") install_cp = __import__("subprocess").CompletedProcess([], 0, stdout="", stderr="") build_cp = __import__("subprocess").CompletedProcess([], 0, stdout="", stderr="") @@ -150,8 +153,10 @@ class TestBuildWebUISkipsWhenFresh: assert result is True args, kwargs = mock_run.call_args assert "--workspace" not in args[0] - assert args[0] == ["/usr/bin/npm", "ci", "--include=dev", "--silent", "--prefer-offline"] + assert Path(args[0][0]).name in {"npm", "npm.cmd"} + assert args[0][1:] == ["ci", "--include=dev", "--silent", "--prefer-offline"] assert kwargs["cwd"] == web_dir + assert "ESBUILD_BINARY_PATH" not in kwargs["env"] def test_workspace_root_install_names_update_closure(self, tmp_path, monkeypatch): """From the workspace root, _build_web_ui must install the SAME