"""Plugin-local npm installs retain PM's paired Node and consent policy.""" import json import os from pathlib import Path import shutil import shlex import pytest import pm from pm.package import InstallError, Runner, compose_env from pm.workspace import install_node_sidecar _REAL_HERMES_HOME = Path.home() / ".hermes" # Captured before per-test HOME isolation. def test_no_package_json_never_acquires_npm(tmp_path, monkeypatch): def unexpected(*args, **kwargs): pytest.fail("a dependency-free plugin must not acquire npm") monkeypatch.setattr(pm, "ensure", unexpected) assert install_node_sidecar(tmp_path) is None @pytest.mark.parametrize("explicit", [False, True]) def test_acquisition_receives_consent_and_reports_refusal(tmp_path, monkeypatch, explicit): import importlib monkeypatch.setattr(importlib.import_module("pm.install"), "lazy_installs_allowed", lambda: True) (tmp_path / "package.json").write_text('{}') calls = [] def refused(name, **kwargs): calls.append((name, kwargs)) raise InstallError(name, "acquisition refused") monkeypatch.setattr(pm, "ensure", refused) reason = install_node_sidecar(tmp_path, explicit=explicit) assert "acquisition refused" in reason assert calls == [("npm", {"explicit": explicit})] @pytest.mark.platforms("posix") def test_real_npm_uses_paired_node_with_empty_ambient_path(tmp_path, monkeypatch): npm, node = shutil.which("npm"), shutil.which("node") if not npm or not node: pytest.skip("npm and node are required") if any(Path(executable).absolute().is_relative_to(_REAL_HERMES_HOME) for executable in (npm, node)): pytest.skip("requires npm and Node outside the real Hermes home") # npm's real JS entrypoint uses /usr/bin/env node. Its paired Node lives # in a different PATH entry, just as the two PM packages do. npm_cli = next(iter(Path(npm).resolve().parent.parent.glob("lib/npm*/bin/npm-cli.js")), Path(npm).resolve()) npm_bin, node_bin = tmp_path / "npm/bin", tmp_path / "node/bin" npm_bin.mkdir(parents=True) node_bin.mkdir(parents=True) wrapper = npm_bin / "npm" wrapper.write_text(f'#!/bin/sh\nexec node {shlex.quote(str(npm_cli))} "$@"\n') wrapper.chmod(0o755) (node_bin / "node").symlink_to(node) monkeypatch.setenv("HOME", str(tmp_path)) monkeypatch.setattr(Path, "home", lambda: tmp_path) monkeypatch.setenv("HERMES_HOME", str(tmp_path / "home")) monkeypatch.setenv("PATH", "") monkeypatch.setenv("HERMES_DISABLE_LAZY_INSTALLS", "1") context = Runner("npm", compose_env([ {"PATH": [str(node_bin)]}, {"PATH": [str(npm_bin)]}, ])) calls = [] def acquire(name, **kwargs): calls.append((name, kwargs)) return context monkeypatch.setattr(pm, "ensure", acquire) dependency, plugin = tmp_path / "side-dep", tmp_path / "plugin" dependency.mkdir() plugin.mkdir() (dependency / "package.json").write_text(json.dumps({"name": "side-dep", "version": "1.0.0", "main": "index.js"})) (dependency / "index.js").write_text("module.exports = 'isolated';") manifest = {"name": "plugin", "version": "1.0.0", "dependencies": {"side-dep": "file:../side-dep"}} (plugin / "package.json").write_text(json.dumps(manifest)) ambient = dict(os.environ) assert install_node_sidecar(plugin, explicit=True) is None lock = (plugin / "package-lock.json").read_bytes() # ci must remove an extraneous directory; another `install` would not # reliably prove that the lockfile selected the clean-install path. stale = plugin / "node_modules/stale-file" stale.write_text("remove me") assert install_node_sidecar(plugin, explicit=True) is None assert not stale.exists() assert (plugin / "package-lock.json").read_bytes() == lock result = context.run(["node", "-e", "console.log(require('side-dep'))"], cwd=plugin, capture_output=True, text=True, timeout=30) assert result.returncode == 0, result.stderr assert result.stdout.strip() == "isolated" assert not (tmp_path / "node_modules").exists() assert dict(os.environ) == ambient assert calls == [("npm", {"explicit": True})] * 2 (plugin / "package.json").write_text("{malformed") reason = install_node_sidecar(plugin, explicit=True) assert "exited" in reason and "JSON" in reason def test_on_demand_sidecar_install_respects_lazy_refusal(tmp_path, monkeypatch): (tmp_path / "package.json").write_text('{}') monkeypatch.setenv("HERMES_DISABLE_LAZY_INSTALLS", "1") binary = tmp_path / ("npm.cmd" if os.name == "nt" else "npm") binary.write_text("process boundary fixture") binary.chmod(0o755) class InstalledRunner: env = {"PATH": str(tmp_path)} def run(self, *args, **kwargs): pytest.fail("installed npm must not mutate sidecars when on-demand installs are disabled") monkeypatch.setattr(pm, "ensure", lambda *args, **kwargs: InstalledRunner()) reason = install_node_sidecar(tmp_path) assert reason and "disabled" in reason