diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 375561ad6d..b2b181ac68 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -830,7 +830,7 @@ def _print_tui_exit_summary(session_id: Optional[str], active_session_file: Opti def _tui_need_npm_install(root: Path) -> bool: - """True when @hermes/ink is missing or node_modules is behind package-lock.json (post-pull).""" + """True when @hermes/ink is missing or node_modules is behind package-lock.json.""" ink = root / "node_modules" / "@hermes" / "ink" / "package.json" if not ink.is_file(): return True @@ -840,7 +840,32 @@ def _tui_need_npm_install(root: Path) -> bool: marker = root / "node_modules" / ".package-lock.json" if not marker.is_file(): return True - return lock.stat().st_mtime > marker.stat().st_mtime + + try: + wanted = json.loads(lock.read_text(encoding="utf-8")).get("packages") or {} + installed = json.loads(marker.read_text(encoding="utf-8")).get("packages") or {} + except (OSError, json.JSONDecodeError): + return lock.stat().st_mtime > marker.stat().st_mtime + + ignored = {"ideallyInert"} + + def comparable(pkg: dict) -> dict: + return {k: v for k, v in pkg.items() if k not in ignored} + + for name, pkg in wanted.items(): + if name == "": + continue + + if name not in installed: + if isinstance(pkg, dict) and (pkg.get("optional") or pkg.get("peer")): + continue + return True + + if isinstance(pkg, dict) and isinstance(installed[name], dict): + if comparable(pkg) != comparable(installed[name]): + return True + + return False def _find_bundled_tui(tui_dir: Path) -> Optional[Path]: diff --git a/tests/hermes_cli/test_tui_npm_install.py b/tests/hermes_cli/test_tui_npm_install.py index bceaf9de0b..e56196e07e 100644 --- a/tests/hermes_cli/test_tui_npm_install.py +++ b/tests/hermes_cli/test_tui_npm_install.py @@ -1,4 +1,4 @@ -"""_tui_need_npm_install: auto npm when lockfile ahead of node_modules.""" +"""_tui_need_npm_install: auto npm when node_modules is behind the lockfile.""" import os from pathlib import Path @@ -36,15 +36,39 @@ def test_need_install_when_ink_missing(tmp_path: Path, main_mod) -> None: assert main_mod._tui_need_npm_install(tmp_path) is True -def test_need_install_when_lock_newer_than_marker(tmp_path: Path, main_mod) -> None: +def test_no_install_when_lock_newer_but_hidden_lock_matches(tmp_path: Path, main_mod) -> None: _touch_ink(tmp_path) - (tmp_path / "package-lock.json").write_text("{}") - (tmp_path / "node_modules" / ".package-lock.json").write_text("{}") + (tmp_path / "package-lock.json").write_text('{"packages":{"node_modules/foo":{"version":"1.0.0"}}}') + (tmp_path / "node_modules" / ".package-lock.json").write_text( + '{"packages":{"node_modules/foo":{"version":"1.0.0","ideallyInert":true}}}' + ) os.utime(tmp_path / "package-lock.json", (200, 200)) os.utime(tmp_path / "node_modules" / ".package-lock.json", (100, 100)) + assert main_mod._tui_need_npm_install(tmp_path) is False + + +def test_need_install_when_required_package_missing_from_hidden_lock(tmp_path: Path, main_mod) -> None: + _touch_ink(tmp_path) + (tmp_path / "package-lock.json").write_text( + '{"packages":{"node_modules/foo":{"version":"1.0.0"},"node_modules/bar":{"version":"1.0.0"}}}' + ) + (tmp_path / "node_modules" / ".package-lock.json").write_text( + '{"packages":{"node_modules/foo":{"version":"1.0.0"}}}' + ) assert main_mod._tui_need_npm_install(tmp_path) is True +def test_no_install_when_only_optional_peer_package_missing_from_hidden_lock(tmp_path: Path, main_mod) -> None: + _touch_ink(tmp_path) + (tmp_path / "package-lock.json").write_text( + '{"packages":{"node_modules/foo":{"version":"1.0.0"},"node_modules/optional":{"version":"1.0.0","optional":true,"peer":true}}}' + ) + (tmp_path / "node_modules" / ".package-lock.json").write_text( + '{"packages":{"node_modules/foo":{"version":"1.0.0"}}}' + ) + assert main_mod._tui_need_npm_install(tmp_path) is False + + def test_no_install_when_lock_older_than_marker(tmp_path: Path, main_mod) -> None: _touch_ink(tmp_path) (tmp_path / "package-lock.json").write_text("{}")