diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 7527b4d48af..391d85f1b52 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -1264,6 +1264,32 @@ def _workspace_root(dir: Path) -> Path: return dir +def _termux_workspace_install_context( + dir: Path, *, include_child_workspaces: bool = False +) -> tuple[Path, tuple[str, ...]]: + """Return Termux-only ``(cwd, npm_args)`` for installing deps for *dir* only.""" + ws_root = _workspace_root(dir) + if ws_root == dir: + return dir, () + + try: + workspace = dir.relative_to(ws_root).as_posix() + except ValueError: + return ws_root, () + + workspace_args: list[str] = ["--workspace", workspace] + if include_child_workspaces: + packages_dir = dir / "packages" + if packages_dir.is_dir(): + for child in sorted(packages_dir.iterdir()): + if child.is_dir() and (child / "package.json").is_file(): + workspace_args.extend( + ["--workspace", child.relative_to(ws_root).as_posix()] + ) + workspace_args.append("--include-workspace-root=false") + return ws_root, tuple(workspace_args) + + def _tui_need_npm_install(root: Path) -> bool: """True when @hermes/ink is missing or node_modules is behind package-lock.json. @@ -1524,16 +1550,43 @@ def _make_tui_argv(tui_dir: Path, tui_dev: bool) -> tuple[list[str], Path]: # 2. Normal flow: npm install if needed, always esbuild, then node dist/entry.js. # --dev flow: npm install if needed, then tsx src/entry.tsx. - # npm install runs from the workspace root (where package-lock.json lives); - # npm workspaces resolves ui-tui deps automatically. + # Existing desktop behaviour runs npm from the workspace root. Termux + # scopes the install to ui-tui so launch does not pull desktop/web + # dependencies into the hot path. did_install = False - if _tui_need_npm_install(tui_dir): + termux_startup = _is_termux_startup_environment() + termux_need_rebuild = False + if termux_startup and not tui_dev: + termux_need_rebuild = _tui_need_rebuild(tui_dir) + + skip_install_for_fresh_termux_bundle = ( + termux_startup and not tui_dev and not termux_need_rebuild + ) + if ( + not skip_install_for_fresh_termux_bundle + and _tui_need_npm_install(tui_dir) + ): npm = _node_bin("npm") if not os.environ.get("HERMES_QUIET"): print("Installing TUI dependencies…") + npm_cwd = _workspace_root(tui_dir) + npm_workspace_args: tuple[str, ...] = () + if termux_startup: + npm_cwd, npm_workspace_args = _termux_workspace_install_context( + tui_dir, + include_child_workspaces=True, + ) result = subprocess.run( - [npm, "install", "--silent", "--no-fund", "--no-audit", "--progress=false"], - cwd=str(_workspace_root(tui_dir)), + [ + npm, + "install", + *npm_workspace_args, + "--silent", + "--no-fund", + "--no-audit", + "--progress=false", + ], + cwd=str(npm_cwd), stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, @@ -1579,8 +1632,8 @@ def _make_tui_argv(tui_dir: Path, tui_dev: bool) -> tuple[list[str], Path]: # Termux cold starts use the freshness check because esbuild startup is # expensive on old mobile CPUs. should_build = True - if _is_termux_startup_environment(): - should_build = did_install or _tui_need_rebuild(tui_dir) + if termux_startup: + should_build = did_install or termux_need_rebuild if should_build: npm = _node_bin("npm") @@ -7004,10 +7057,14 @@ def _build_web_ui(web_dir: Path, *, fatal: bool = False) -> bool: if text: _say(text) + npm_cwd = _workspace_root(web_dir) + npm_workspace_args: tuple[str, ...] = () + if _is_termux_startup_environment(): + npm_cwd, npm_workspace_args = _termux_workspace_install_context(web_dir) r1 = _run_npm_install_deterministic( npm, - _workspace_root(web_dir), - extra_args=("--silent",), + npm_cwd, + extra_args=(*npm_workspace_args, "--silent"), ) if r1.returncode != 0: _say( diff --git a/tests/hermes_cli/test_tui_npm_install.py b/tests/hermes_cli/test_tui_npm_install.py index 1ab5edcb06c..790ace09d2d 100644 --- a/tests/hermes_cli/test_tui_npm_install.py +++ b/tests/hermes_cli/test_tui_npm_install.py @@ -172,6 +172,97 @@ def test_make_tui_argv_skips_build_only_on_termux_when_fresh( assert cwd == tmp_path +def test_make_tui_argv_skips_install_on_termux_when_bundle_fresh( + tmp_path: Path, main_mod, monkeypatch +) -> None: + _touch_tui_entry(tmp_path) + monkeypatch.setenv("TERMUX_VERSION", "1") + monkeypatch.setattr(main_mod, "_tui_need_npm_install", lambda _root: True) + monkeypatch.setattr(main_mod, "_tui_need_rebuild", lambda _root: False) + monkeypatch.setattr(main_mod.shutil, "which", lambda name: f"/bin/{name}") + + def fail_run(*_args, **_kwargs): + raise AssertionError("fresh Termux TUI launch must not run npm") + + monkeypatch.setattr(main_mod.subprocess, "run", fail_run) + + argv, cwd = main_mod._make_tui_argv(tmp_path, tui_dev=False) + + assert argv == ["/bin/node", "--expose-gc", str(tmp_path / "dist" / "entry.js")] + assert cwd == tmp_path + + +def test_make_tui_argv_scopes_npm_install_on_termux_workspace( + tmp_path: Path, main_mod, monkeypatch +) -> None: + tui_dir = tmp_path / "ui-tui" + tui_dir.mkdir() + (tui_dir / "package.json").write_text("{}") + ink_dir = tui_dir / "packages" / "hermes-ink" + ink_dir.mkdir(parents=True) + (ink_dir / "package.json").write_text("{}") + (tmp_path / "package-lock.json").write_text("{}") + + monkeypatch.setenv("TERMUX_VERSION", "1") + monkeypatch.setattr(main_mod, "_tui_need_npm_install", lambda _root: True) + monkeypatch.setattr(main_mod, "_tui_need_rebuild", lambda _root: True) + monkeypatch.setattr(main_mod.shutil, "which", lambda name: f"/bin/{name}") + calls = [] + + def fake_run(*args, **kwargs): + calls.append((args, kwargs)) + return types.SimpleNamespace(returncode=0, stdout="", stderr="") + + monkeypatch.setattr(main_mod.subprocess, "run", fake_run) + + main_mod._make_tui_argv(tui_dir, tui_dev=False) + + install_cmd = calls[0][0][0] + assert install_cmd[:7] == [ + "/bin/npm", + "install", + "--workspace", + "ui-tui", + "--workspace", + "ui-tui/packages/hermes-ink", + "--include-workspace-root=false", + ] + assert calls[0][1]["cwd"] == str(tmp_path) + + +def test_make_tui_argv_keeps_desktop_workspace_install_behaviour( + tmp_path: Path, main_mod, monkeypatch +) -> None: + tui_dir = tmp_path / "ui-tui" + tui_dir.mkdir() + (tui_dir / "package.json").write_text("{}") + (tmp_path / "package-lock.json").write_text("{}") + + monkeypatch.delenv("TERMUX_VERSION", raising=False) + monkeypatch.setenv("PREFIX", "/usr") + monkeypatch.setattr(main_mod, "_tui_need_npm_install", lambda _root: True) + monkeypatch.setattr(main_mod.shutil, "which", lambda name: f"/bin/{name}") + calls = [] + + def fake_run(*args, **kwargs): + calls.append((args, kwargs)) + return types.SimpleNamespace(returncode=0, stdout="", stderr="") + + monkeypatch.setattr(main_mod.subprocess, "run", fake_run) + + main_mod._make_tui_argv(tui_dir, tui_dev=False) + + assert calls[0][0][0] == [ + "/bin/npm", + "install", + "--silent", + "--no-fund", + "--no-audit", + "--progress=false", + ] + assert calls[0][1]["cwd"] == str(tmp_path) + + def test_make_tui_argv_keeps_desktop_always_build_behaviour( tmp_path: Path, main_mod, monkeypatch ) -> None: diff --git a/tests/hermes_cli/test_web_ui_build.py b/tests/hermes_cli/test_web_ui_build.py index 666420a37ab..1f76e2a7cb5 100644 --- a/tests/hermes_cli/test_web_ui_build.py +++ b/tests/hermes_cli/test_web_ui_build.py @@ -164,6 +164,50 @@ class TestBuildWebUISkipsWhenFresh: assert args[0] == ["/usr/bin/npm", "run", "build"] assert kwargs["cwd"] == web_dir + def test_termux_web_install_is_workspace_scoped(self, tmp_path, monkeypatch): + web_dir, _ = _make_web_dir(tmp_path) + (tmp_path / "package-lock.json").write_text("{}", encoding="utf-8") + monkeypatch.setenv("TERMUX_VERSION", "1") + + install_cp = __import__("subprocess").CompletedProcess([], 0, stdout="", stderr="") + build_cp = __import__("subprocess").CompletedProcess([], 0, stdout="", stderr="") + with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \ + patch("hermes_cli.main.subprocess.run", return_value=install_cp) as mock_run, \ + patch("hermes_cli.main._run_with_idle_timeout", return_value=build_cp): + result = _build_web_ui(web_dir) + + assert result is True + args, kwargs = mock_run.call_args + assert args[0] == [ + "/usr/bin/npm", + "ci", + "--workspace", + "web", + "--include-workspace-root=false", + "--silent", + ] + assert kwargs["cwd"] == tmp_path + + def test_desktop_web_install_uses_existing_workspace_root( + self, tmp_path, monkeypatch + ): + web_dir, _ = _make_web_dir(tmp_path) + (tmp_path / "package-lock.json").write_text("{}", encoding="utf-8") + monkeypatch.delenv("TERMUX_VERSION", raising=False) + monkeypatch.setenv("PREFIX", "/usr") + + install_cp = __import__("subprocess").CompletedProcess([], 0, stdout="", stderr="") + build_cp = __import__("subprocess").CompletedProcess([], 0, stdout="", stderr="") + with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \ + patch("hermes_cli.main.subprocess.run", return_value=install_cp) as mock_run, \ + patch("hermes_cli.main._run_with_idle_timeout", return_value=build_cp): + result = _build_web_ui(web_dir) + + assert result is True + args, kwargs = mock_run.call_args + assert args[0] == ["/usr/bin/npm", "ci", "--silent"] + assert kwargs["cwd"] == tmp_path + class TestBuildWebUIRetryAndStaleFallback: """Coverage for the retry + stale-dist fallback added in #23824 / issue #23817."""