diff --git a/pm/cli.py b/pm/cli.py index 6c013b9206..ef2c72aacb 100644 --- a/pm/cli.py +++ b/pm/cli.py @@ -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 diff --git a/pm/packages.py b/pm/packages.py index ee736ef5cb..2e26a69d05 100644 --- a/pm/packages.py +++ b/pm/packages.py @@ -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( diff --git a/tests/pm/test_update_dependency_legs.py b/tests/pm/test_update_dependency_legs.py new file mode 100644 index 0000000000..4200cdb4f6 --- /dev/null +++ b/tests/pm/test_update_dependency_legs.py @@ -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 diff --git a/tests/pm/test_uv_python.py b/tests/pm/test_uv_python.py index d3dbcf209c..077983aecf 100644 --- a/tests/pm/test_uv_python.py +++ b/tests/pm/test_uv_python.py @@ -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())