diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 58b17b7a139..efc41e5790c 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -5027,6 +5027,46 @@ def _web_ui_build_needed(web_dir: Path) -> bool: return False +def _run_npm_install_deterministic( + npm: str, + cwd: Path, + *, + extra_args: tuple[str, ...] = (), + capture_output: bool = True, +) -> subprocess.CompletedProcess: + """Run a deterministic npm install that does not mutate ``package-lock.json``. + + Prefers ``npm ci`` (strict, lockfile-preserving) when a lockfile is present; + falls back to ``npm install`` only if ``npm ci`` fails (e.g. lockfile out of + sync on a WIP checkout). Without this, ``npm install`` on npm ≥ 10 silently + rewrites committed lockfiles (stripping ``"peer": true`` etc.), which leaves + the working tree dirty and causes the next ``hermes update`` to stash the + lockfile — repeatedly. + """ + lockfile = cwd / "package-lock.json" + if lockfile.exists(): + ci_cmd = [npm, "ci", *extra_args] + ci_result = subprocess.run( + ci_cmd, + cwd=cwd, + capture_output=capture_output, + text=True, + check=False, + ) + if ci_result.returncode == 0: + return ci_result + # Fall through to `npm install` — lockfile may be out of sync on a + # WIP fork/branch, or `npm ci` may not be available on very old npm. + install_cmd = [npm, "install", *extra_args] + return subprocess.run( + install_cmd, + cwd=cwd, + capture_output=capture_output, + text=True, + check=False, + ) + + def _build_web_ui(web_dir: Path, *, fatal: bool = False) -> bool: """Build the web UI frontend if npm is available. @@ -5050,7 +5090,7 @@ def _build_web_ui(web_dir: Path, *, fatal: bool = False) -> bool: print("Install Node.js, then run: cd web && npm install && npm run build") return not fatal print("→ Building web UI...") - r1 = subprocess.run([npm, "install", "--silent"], cwd=web_dir, capture_output=True) + r1 = _run_npm_install_deterministic(npm, web_dir, extra_args=("--silent",)) if r1.returncode != 0: print( f" {'✗' if fatal else '⚠'} Web UI npm install failed" @@ -5761,12 +5801,10 @@ def _update_node_dependencies() -> None: if not (path / "package.json").exists(): continue - result = subprocess.run( - [npm, "install", "--silent", "--no-fund", "--no-audit", "--progress=false"], - cwd=path, - capture_output=True, - text=True, - check=False, + result = _run_npm_install_deterministic( + npm, + path, + extra_args=("--silent", "--no-fund", "--no-audit", "--progress=false"), ) if result.returncode == 0: print(f" ✓ {label}")