feat(pm): npm ci executor for plugin package.json sidecars — wired

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.
This commit is contained in:
ethernet
2026-09-03 14:03:58 -04:00
parent 1fc1d054af
commit 2d7d43c9be
3 changed files with 193 additions and 2 deletions

View File

@@ -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(

View File

@@ -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,

View File

@@ -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