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:
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
97
tests/pm/test_node_sidecar.py
Normal file
97
tests/pm/test_node_sidecar.py
Normal 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
|
||||
Reference in New Issue
Block a user