fix(pm): refresh dependencies with the installed project tools

The uv step used a nonexistent command. Both dependency steps used the
caller directory instead of the PM repository. Run uv lock --upgrade
and the installed npm executable in the correct project. Require the
installed tool closure without installing a fallback.

Reuse npm environment sanitization for unpack and update. The separately
pinned npm keeps its Node dependency on PATH without changing the parent.
Missing tools and failed commands return failure before venv sync.

Real uv and npm commands ran in guarded temporary projects. The test
removes Node's bundled npm before update and preserves unrelated files.
The missing-Python test checks the actual refusal and unchanged facts.
All 87 focused tests pass. Lint passes. No project pins or locks changed.
This commit is contained in:
ethernet
2026-09-10 03:50:40 -04:00
parent 11c65c4f33
commit b2be572937
4 changed files with 210 additions and 13 deletions

View File

@@ -14,6 +14,7 @@ from pathlib import Path
from pm.ensure import _facts, _lockfile, _store, ensure, stage_only
from pm.ensure import uv as pm_uv
from pm.package import InstallError
from pm.paths import repo_root
from pm.registry import get_package
from pm.store import ALL_TARGETS, current_target, hash_url
from pm.update import Resolved, resolve_package
@@ -325,7 +326,7 @@ def cmd_update(args) -> int:
return 1
if args.check:
if args.uv:
print("uv deps: would run `uv update` + venv sync")
print("uv deps: would run `uv lock --upgrade` + venv sync")
if args.npm:
print("npm deps: would run `npm update`")
return 1 if changed else 0
@@ -352,13 +353,13 @@ def cmd_update(args) -> int:
print("pm update: nothing to update")
if args.uv:
uv_bin, env = pm_uv()
uv_bin, env = pm_uv(realize=False)
if uv_bin is None:
print("✗ uv: not installed")
return 1
code, tail = _run_live([uv_bin, "update"], cwd=".", env=env)
code, tail = _run_live([uv_bin, "lock", "--upgrade"], cwd=str(repo_root()), env=env)
if code != 0:
print(f"✗ uv update failed:\n{tail}")
print(f"✗ uv lock --upgrade failed:\n{tail}")
return 1
print("✓ uv.lock refreshed")
try:
@@ -369,7 +370,17 @@ def cmd_update(args) -> int:
print(f"✗ {e}")
return 1
if args.npm:
code, tail = _run_live(["npm", "update"], cwd=".", env=dict(os.environ))
from pm.ensure import env_for, installed_package
from pm.packages import npm_env
from pm.paths import writable_store_root
npm = installed_package("npm")
node = installed_package("node")
if npm is None or npm.binary is None or node is None or node.binary is None:
print("✗ npm or Node: not installed; run `hermes pm install`")
return 1
env = npm_env(writable_store_root() / ".npm-cache", env_for("npm"))
code, tail = _run_live([str(npm.binary), "update"], cwd=str(repo_root()), env=env)
if code != 0:
print(f"✗ npm update failed:\n{tail}")
return 1

View File

@@ -556,6 +556,18 @@ class TermuxDocker(Package):
return ""
def npm_env(cache_dir: Path, base_env: Optional[dict] = None) -> dict[str, str]:
"""Keep ambient Node and npm options out of PM's child process."""
env = {
key: value
for key, value in (os.environ if base_env is None else base_env).items()
if not key.lower().startswith("npm_config_")
and key.upper() not in ("NODE_OPTIONS", "NODE_PATH", "NODE_ENV")
}
env["npm_config_cache"] = str(cache_dir)
return env
@register
class Npm(BinaryPackage):
name = "npm"
@@ -616,13 +628,7 @@ class Npm(BinaryPackage):
if not bundled_cli.is_file():
raise InstallError(self.name, "node's entry is missing its bundled npm-cli.js")
env = {
key: value
for key, value in os.environ.items()
if not key.lower().startswith("npm_config_")
and key not in ("NODE_OPTIONS", "NODE_PATH", "NODE_ENV")
}
env["npm_config_cache"] = str(archive.parent / ".npm-cache")
env = npm_env(archive.parent / ".npm-cache")
staged.mkdir(parents=True, exist_ok=True)
proc = subprocess.run(

View File

@@ -0,0 +1,177 @@
"""Optional dependency refreshes run in PM's repository with its installed tools."""
import importlib
import json
import os
from pathlib import Path
import shutil
import sys
import tarfile
import tempfile
import pytest
from pm import cli, paths, registry
from pm.lock import Facts, Lockfile
from pm.package import Package
from pm.packages import Nodejs, Npm, Python, Uv
from pm.store import current_target
class ManualFixture(Package):
name = 'manual-fixture'
def project(tmp_path, monkeypatch):
repo = tmp_path / 'repo'
repo.mkdir()
(repo / 'pyproject.toml').write_text(
'[project]\nname="update-proof"\nversion="1"\nrequires-python=">=3.14"\n'
'[tool.uv]\npackage=false\n', encoding='utf-8')
caller = tmp_path / 'unrelated-project'
caller.mkdir()
(caller / 'pyproject.toml').write_text('not a project', encoding='utf-8')
lock = Lockfile(repo / 'pm/lock.json')
lock.set_pin('manual-fixture', '1', {})
lock.save()
monkeypatch.setitem(registry._packages, 'manual-fixture', ManualFixture())
monkeypatch.setattr(paths, 'repo_root', lambda: repo)
monkeypatch.setattr(cli, 'repo_root', lambda: repo)
monkeypatch.setattr(paths, 'lockfile_path', lambda: lock.path)
monkeypatch.setenv('HERMES_HOME', str(tmp_path / 'home'))
monkeypatch.setenv('HERMES_RUNTIME_DIR', str(tmp_path / 'store'))
monkeypatch.chdir(caller)
run_live = cli._run_live
def run_owned(cmd, *, cwd, env, **kwargs):
assert Path(cwd).resolve() == repo.resolve(), 'refuse a dependency command outside the temporary project'
assert Path(cmd[0]).resolve().is_relative_to(tmp_path.resolve()), 'refuse a tool outside the temporary store'
return run_live(cmd, cwd=cwd, env=env, **kwargs)
monkeypatch.setattr(cli, '_run_live', run_owned)
return repo, caller, lock
@pytest.mark.platforms('windows', 'posix')
def test_uv_refresh_uses_real_installed_tool_and_only_the_owned_project(tmp_path, monkeypatch, capsys):
repo, caller, lock = project(tmp_path, monkeypatch)
uv = Path(shutil.which('uv') or pytest.fail('canonical test environment requires uv'))
runtime = tmp_path / 'store'
managed = runtime / 'uv-fixture' / uv.name
managed.parent.mkdir(parents=True)
shutil.copy2(uv, managed)
python = Path(sys._base_executable).resolve()
python_root = python.parent if os.name == 'nt' else python.parents[1]
target = current_target()
facts = Facts(runtime / 'facts.json')
for name, package, entry in [('uv', Uv(), managed.parent), ('python', Python(), python_root)]:
monkeypatch.setitem(registry._packages, name, package)
lock.set_pin(name, 'fixture', {target: {'url': f'https://example.invalid/{name}', 'sha256': '1' * 64}})
facts.record(name, 'fixture', str(entry), package.env(entry, target), runtime,
target=target, artifacts=['1' * 64])
lock.save()
before_lock = lock.path.read_bytes()
parent_env = dict(os.environ)
syncs = []
ensure = importlib.import_module('pm.ensure')
monkeypatch.setattr(ensure, 'sync_venv', lambda **kw: syncs.append(kw))
assert cli.main(['update', 'manual-fixture', '--uv', '--check']) == 0
assert not (repo / 'uv.lock').exists()
assert not syncs
check_output = capsys.readouterr().out
assert cli.main(['update', 'manual-fixture', '--uv']) == 0
assert 'uv lock --upgrade' in check_output
assert (repo / 'uv.lock').is_file()
assert not (caller / 'uv.lock').exists()
assert syncs == [{'explicit': True}]
assert lock.path.read_bytes() == before_lock
assert dict(os.environ) == parent_env
original = (repo / 'uv.lock').read_bytes()
syncs.clear()
(repo / 'pyproject.toml').write_text('invalid project [', encoding='utf-8')
assert cli.main(['update', 'manual-fixture', '--uv']) == 1
assert (repo / 'uv.lock').read_bytes() == original and not syncs
assert 'uv lock --upgrade failed' in capsys.readouterr().out
managed.unlink()
assert cli.main(['update', 'manual-fixture', '--uv']) == 1
assert (repo / 'uv.lock').read_bytes() == original and not syncs
assert 'not installed' in capsys.readouterr().out
@pytest.mark.platforms('windows', 'posix')
def test_npm_refresh_uses_its_installed_entry_and_owned_project(monkeypatch, capsys):
node_source = Path(shutil.which('node') or pytest.fail('node is required'))
npm_source = Path(shutil.which('npm.cmd' if os.name == 'nt' else 'npm') or pytest.fail('npm is required')).resolve()
npm_source = npm_source.parent / 'node_modules/npm' if os.name == 'nt' else npm_source.parents[1]
assert (npm_source / 'bin/npm-cli.js').is_file()
# Keep the real tool's argv clear of the harness's hermes-update guard.
temp_root = Path(os.environ['LOCALAPPDATA']) / 'Temp' if os.name == 'nt' else Path('/tmp')
with tempfile.TemporaryDirectory(prefix='pm-deps-', dir=temp_root) as temporary, monkeypatch.context() as scoped:
monkeypatch = scoped
root = Path(temporary)
repo, caller, lock = project(root, monkeypatch)
user = root / 'user'
user.mkdir()
for key in ('HOME', 'USERPROFILE', 'APPDATA', 'LOCALAPPDATA'):
monkeypatch.setenv(key, str(user))
target = current_target()
runtime = root / 'store'
node_entry = runtime / 'node-fixture'
node_binary = node_entry / ('node.exe' if os.name == 'nt' else 'bin/node')
node_binary.parent.mkdir(parents=True)
shutil.copy2(node_source, node_binary)
bundled_npm = node_entry / ('node_modules/npm' if os.name == 'nt' else 'lib/node_modules/npm')
shutil.copytree(npm_source, bundled_npm)
facts = Facts(runtime / 'facts.json')
node, npm = Nodejs(), Npm()
monkeypatch.setitem(registry._packages, 'node', node)
monkeypatch.setitem(registry._packages, 'npm', npm)
lock.set_pin('node', 'fixture', {target: {'url': 'https://example.invalid/node', 'sha256': '1' * 64}})
facts.record('node', 'fixture', str(node_entry), node.env(node_entry, target), runtime,
target=target, artifacts=['1' * 64])
lock.save()
archive = root / 'npm.tgz'
with tarfile.open(archive, 'w:gz') as output:
output.add(npm_source, arcname='package')
npm_entry = runtime / 'npm-fixture'
npm.unpack(archive, npm_entry, target)
version = json.loads((npm_source / 'package.json').read_text(encoding='utf-8'))['version']
lock.set_pin('npm', version, {target: {'url': 'https://example.invalid/npm', 'sha256': '2' * 64}})
facts.record('npm', version, str(npm_entry), npm.env(npm_entry, target), runtime,
target=target, artifacts=['2' * 64])
lock.save()
# After install, Node's bundled npm is no longer available as a fallback.
shutil.rmtree(bundled_npm)
(repo / 'package.json').write_text(json.dumps({'name': 'pm-leg-proof', 'version': '1.0.0', 'private': True}), encoding='utf-8')
(repo / '.npmrc').write_text('offline=true\naudit=false\nfund=false\nignore-scripts=true\n', encoding='utf-8')
monkeypatch.setenv('PATH', str(Path(os.environ.get('SYSTEMROOT', '/')) / 'System32') if os.name == 'nt' else '/usr/bin:/bin')
monkeypatch.setenv('NODE_OPTIONS', '--invalid-option-to-be-scrubbed')
monkeypatch.setenv('npm_config_cache', str(root / 'ambient-cache'))
before = dict(os.environ)
before_lock = lock.path.read_bytes()
assert cli.main(['update', 'manual-fixture', '--npm', '--check']) == 0
assert not (repo / 'package-lock.json').exists()
assert cli.main(['update', 'manual-fixture', '--npm']) == 0, capsys.readouterr().out
assert json.loads((repo / 'package-lock.json').read_text(encoding='utf-8'))['name'] == 'pm-leg-proof'
assert not (caller / 'package-lock.json').exists()
assert dict(os.environ) == before
assert lock.path.read_bytes() == before_lock
assert not (root / 'ambient-cache').exists()
lock_bytes = (repo / 'package-lock.json').read_bytes()
(repo / 'package.json').write_text('not json', encoding='utf-8')
assert cli.main(['update', 'manual-fixture', '--npm']) == 1
assert (repo / 'package-lock.json').read_bytes() == lock_bytes
assert 'npm update failed' in capsys.readouterr().out
node_binary.rename(node_binary.with_suffix('.held'))
assert cli.main(['update', 'manual-fixture', '--npm']) == 1
assert (repo / 'package-lock.json').read_bytes() == lock_bytes
assert 'not installed' in capsys.readouterr().out
node_binary.with_suffix('.held').rename(node_binary)
npm.binary(npm_entry, target).unlink()
assert cli.main(['update', 'manual-fixture', '--npm']) == 1
assert (repo / 'package-lock.json').read_bytes() == lock_bytes
assert 'not installed' in capsys.readouterr().out
assert dict(os.environ) == before and lock.path.read_bytes() == before_lock

View File

@@ -120,6 +120,9 @@ def test_uv_refuses_discovery_when_pm_python_is_missing(installed_uv, monkeypatc
entry.mkdir()
facts.record("python", "test", entry.name, {}, entry.parent,
target=target, artifacts=[digest])
recorded = facts.path.read_bytes()
assert ensure.uv(realize=False)[0] is None
with pytest.raises(InstallError, match="binary is missing"):
with pytest.raises(InstallError, match="lazy installs are disabled: python"):
ensure.uv()
assert facts.path.read_bytes() == recorded
assert not list(entry.iterdir())