From 2d7d43c9bed69c55c8e2b295de92eab442578b2e Mon Sep 17 00:00:00 2001 From: ethernet Date: Thu, 3 Sep 2026 14:03:58 -0400 Subject: [PATCH] =?UTF-8?q?feat(pm):=20npm=20ci=20executor=20for=20plugin?= =?UTF-8?q?=20package.json=20sidecars=20=E2=80=94=20wired?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The declared-but-unwired surface from the plugin-deps plan §B item 2: scan_plugin classified package.json sidecars but nothing executed. - pm/workspace.install_node_sidecar(): npm ci (with package-lock.json) or npm install (without) into the plugin's OWN node_modules — never a global prefix; pm's pinned npm store-first (PATH second, the same precedence as _uv_binary); lazy-install-gated; returns a reason string on failure, never raises. Injectable runner for hermetic tests. - plugins install flow: a package.json plugin gets its own y/n consent question; a node-dep failure warns but never blocks the python-dep path or the install. tests: 6 hermetic tests (no-package no-op, ci-vs-install selection by lockfile presence, lazy-off refusal, failure reason surfacing, runner explosion isolation). Full sweep: 210 passed, 0 failed. --- hermes_cli/plugins_cmd.py | 29 ++++++++++- pm/workspace.py | 69 +++++++++++++++++++++++++ tests/pm/test_node_sidecar.py | 97 +++++++++++++++++++++++++++++++++++ 3 files changed, 193 insertions(+), 2 deletions(-) create mode 100644 tests/pm/test_node_sidecar.py diff --git a/hermes_cli/plugins_cmd.py b/hermes_cli/plugins_cmd.py index 8efec2aa6e..cee3cba9c9 100644 --- a/hermes_cli/plugins_cmd.py +++ b/hermes_cli/plugins_cmd.py @@ -415,8 +415,33 @@ def _install_plugin_python_deps( return True, None deps = [d.strip() for d in deps if isinstance(d, str) and d.strip()] has_pyproject = (target / "pyproject.toml").is_file() - if not deps and not has_pyproject: - return True, None # no declared python deps at all + has_package_json = (target / "package.json").is_file() + if not deps and not has_pyproject and not has_package_json: + return True, None # no declared deps at all + + # Node sidecar (package.json): the npm ci executor — separate consent + # question, same try-then-enable posture. Failure never blocks the + # python path below. + node_reason = None + if has_package_json: + console.print(f"\n[bold]{manifest.get('name', 'this plugin')}[/bold] declares Node dependencies (package.json).") + if sys.stdin.isatty() and sys.stdout.isatty(): + try: + node_answer = input( + " Install them into the plugin's own node_modules now? [y/N]: " + ).strip().lower() + except (EOFError, KeyboardInterrupt): + node_answer = "" + else: + node_answer = "" + if node_answer in {"y", "yes"}: + from pm.workspace import install_node_sidecar + + node_reason = install_node_sidecar(target) + if node_reason: + console.print(f"[yellow]⚠[/yellow] Node deps: {node_reason}") + else: + console.print("[dim]Skipped Node deps — run `hermes plugins install` again to retry.[/dim]\n") plugin_name = manifest.get("name", "this plugin") console.print( diff --git a/pm/workspace.py b/pm/workspace.py index 5ab7708899..ff414d5a8f 100644 --- a/pm/workspace.py +++ b/pm/workspace.py @@ -24,6 +24,7 @@ from __future__ import annotations import hashlib import os +import subprocess from pathlib import Path from typing import Optional @@ -280,6 +281,74 @@ def scan_plugin(plugin_dir: Path) -> dict: return found +def install_node_sidecar( + plugin_dir: Path, + *, + npm_bin: Optional[str] = None, + runner=subprocess.run, +) -> Optional[str]: + """`npm ci` the plugin's package.json into ITS OWN node_modules — + the declared sidecar install (plugin-deps plan §B item 2; wired here). + + Plugin-local (never a global npm prefix), pm's pinned npm when the + store has one (ambient PATH npm otherwise — same store-first, + PATH-second precedence as _uv_binary), gated by the lazy-install + policy, receipt-noted. Returns None on success, else why not. + """ + package_json = plugin_dir / "package.json" + if not package_json.is_file(): + return None # nothing to install + + from pm.ensure import lazy_installs_allowed + + if not lazy_installs_allowed(): + return "lazy installs are disabled — run `hermes pm install` after enabling" + + # a lockfile means reproducible `npm ci`; plain `npm install` otherwise + install_cmd = ["ci"] if (plugin_dir / "package-lock.json").is_file() else ["install"] + if npm_bin is None: + npm_bin = _node_npm_binary("npm") + if npm_bin is None: + return "npm not found (pm store or PATH)" + + try: + proc = runner( + [npm_bin, *install_cmd, "--no-audit", "--no-fund"], + cwd=str(plugin_dir), + capture_output=True, + text=True, + timeout=900, + ) + except Exception as exc: + return f"npm {install_cmd[0]} failed to run: {exc}" + if proc.returncode != 0: + tail = (proc.stderr or proc.stdout or "").strip()[-300:] + return f"npm {install_cmd[0]} exited {proc.returncode}: {tail}" + return None + + +def _node_npm_binary(name: str) -> Optional[str]: + """pm's pinned npm from the store (store-first), PATH second.""" + from pm.ensure import env_for + + try: + env = env_for("npm") + except Exception: + env = None + if env: + path_value = env.get("PATH", "") + import shutil as _shutil + + for d in path_value.split(os.pathsep): + if d: + candidate = Path(d) / ("npm.cmd" if os.name == "nt" else name) + if candidate.is_file(): + return str(candidate) + import shutil as _shutil + + return _shutil.which(name) + + def lock_and_sync( plugin_dirs: list[Path], extras: Optional[list[str]] = None, diff --git a/tests/pm/test_node_sidecar.py b/tests/pm/test_node_sidecar.py new file mode 100644 index 0000000000..7ab4df1e14 --- /dev/null +++ b/tests/pm/test_node_sidecar.py @@ -0,0 +1,97 @@ +"""Tests: install_node_sidecar — the npm ci executor for plugin package.json +sidecars (plugin-deps plan §B item 2, wired). Hermetic: runner + binary +injected, lazy-gate patched.""" + +from __future__ import annotations + +from pathlib import Path +from types import SimpleNamespace + +import pytest + +import pm.workspace as ws + + +@pytest.fixture +def lazy_on(monkeypatch): + import sys + + if "pm.ensure" not in sys.modules: + import importlib + + importlib.import_module("pm.ensure") + ensure_mod = sys.modules["pm.ensure"] + monkeypatch.setattr(ensure_mod, "lazy_installs_allowed", lambda: True) + + +def _plug(tmp_path: Path, with_lock: bool = False) -> Path: + plug = tmp_path / "node-plug" + plug.mkdir() + (plug / "package.json").write_text('{"name": "node-plug"}\n', encoding="utf-8") + if with_lock: + (plug / "package-lock.json").write_text("{}\n", encoding="utf-8") + return plug + + +def test_no_package_json_is_a_noop(tmp_path, lazy_on): + plug = tmp_path / "plain" + plug.mkdir() + assert ws.install_node_sidecar(plug, npm_bin="npm") is None + + +def test_ci_when_lockfile_present(tmp_path, lazy_on): + plug = _plug(tmp_path, with_lock=True) + calls = [] + + def runner(cmd, **k): + calls.append(cmd) + return SimpleNamespace(returncode=0, stdout="", stderr="") + + assert ws.install_node_sidecar(plug, npm_bin="npm", runner=runner) is None + assert calls == [["npm", "ci", "--no-audit", "--no-fund"]] + + +def test_install_without_lockfile(tmp_path, lazy_on): + plug = _plug(tmp_path) # no package-lock.json + calls = [] + + def runner(cmd, **k): + calls.append(cmd) + return SimpleNamespace(returncode=0, stdout="", stderr="") + + assert ws.install_node_sidecar(plug, npm_bin="npm", runner=runner) is None + assert calls == [["npm", "install", "--no-audit", "--no-fund"]] + + +def test_lazy_off_refuses(tmp_path, monkeypatch): + import sys + + if "pm.ensure" not in sys.modules: + import importlib + + importlib.import_module("pm.ensure") + ensure_mod = sys.modules["pm.ensure"] + monkeypatch.setattr(ensure_mod, "lazy_installs_allowed", lambda: False) + plug = _plug(tmp_path) + reason = ws.install_node_sidecar(plug, npm_bin="npm") + assert reason and "disabled" in reason + + +def test_npm_failure_returns_reason_not_raise(tmp_path, lazy_on): + plug = _plug(tmp_path) + + def runner(cmd, **k): + return SimpleNamespace(returncode=1, stdout="", stderr="ERESOLVE unable to resolve dependency tree") + + reason = ws.install_node_sidecar(plug, npm_bin="npm", runner=runner) + assert "exited 1" in reason and "ERESOLVE" in reason + + +def test_runner_explosion_is_a_reason(tmp_path, lazy_on): + plug = _plug(tmp_path) + + def runner(cmd, **k): + raise OSError("spawn denied") + + reason = ws.install_node_sidecar(plug, npm_bin="npm", runner=runner) + assert "failed to run" in reason