diff --git a/README.md b/README.md index 04fbc3e302..6e0075c77d 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,10 @@ make install make dev ``` +The local commands use the pnpm version pinned in `frontend/package.json`. If a +different global pnpm is on `PATH`, they use Corepack when available; otherwise +they report the version mismatch before installing frontend dependencies. + Run `make doctor` after setting model credentials. It reports obvious placeholder API keys as incomplete configuration; replace them with real keys before testing model calls. diff --git a/backend/tests/test_pnpm_script.py b/backend/tests/test_pnpm_script.py index 55c103cca3..b0833217ac 100644 --- a/backend/tests/test_pnpm_script.py +++ b/backend/tests/test_pnpm_script.py @@ -65,6 +65,85 @@ def test_runner_prefers_direct_pnpm_and_forwards_arguments(tmp_path: Path): assert "via Corepack" not in result.stderr +@pytest.mark.parametrize("direct_version", ["11.25.0", "10.26.2-rc.1"]) +def test_runner_uses_corepack_when_direct_pnpm_conflicts_with_package_manager(tmp_path: Path, direct_version: str): + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + direct = _write_fake_command(bin_dir, "pnpm", "direct") + if os.name == "nt": + direct.write_text( + f'@echo off\r\nif "%1"=="--version" (echo {direct_version} & exit /b 0)\r\necho direct^|%CD%^|%*\r\n', + encoding="utf-8", + ) + else: + direct.write_text( + f'#!/bin/sh\nif [ "$1" = "--version" ]; then echo {direct_version}; exit 0; fi\nprintf "direct|%s|%s\\n" "$PWD" "$*"\n', + encoding="utf-8", + ) + _write_fake_command(bin_dir, "corepack", "corepack") + + result = _run_pnpm(bin_dir, "install", "--frozen-lockfile") + + assert result.returncode == 0 + assert result.stdout.strip() == f"corepack|{FRONTEND_DIR}|pnpm install --frozen-lockfile" + assert "via Corepack" in result.stderr + + +def test_runner_rejects_mismatched_pnpm_without_corepack(tmp_path: Path): + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + direct = _write_fake_command(bin_dir, "pnpm", "direct") + if os.name == "nt": + direct.write_text("@echo off\r\necho 11.25.0\r\n", encoding="utf-8") + else: + direct.write_text("#!/bin/sh\necho 11.25.0\n", encoding="utf-8") + + result = _run_pnpm(bin_dir, "install") + + assert result.returncode == 127 + assert "pnpm 11.25.0 is on PATH" in result.stderr + assert "pins 10.26.2" in result.stderr + + +@pytest.mark.parametrize("package_content", [None, "{", "{}"]) +def test_runner_reports_unreadable_or_unpinned_package_manifest(tmp_path: Path, monkeypatch, capsys, package_content: str | None): + package_json = tmp_path / "package.json" + if package_content is not None: + package_json.write_text(package_content, encoding="utf-8") + run_pnpm = PNPM_MODULE["run_pnpm"] + monkeypatch.setitem(run_pnpm.__globals__, "PACKAGE_JSON_PATH", package_json) + monkeypatch.setitem(run_pnpm.__globals__, "find_pnpm_command", lambda: ["pnpm"]) + + assert run_pnpm(["install"]) != 0 + assert "frontend/package.json" in capsys.readouterr().err + + +def test_runner_warns_when_direct_pnpm_version_is_unparseable(tmp_path: Path): + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + _write_fake_command(bin_dir, "pnpm", "direct") + + result = _run_pnpm(bin_dir, "install") + + assert result.returncode == 0 + assert "could not determine pnpm version" in result.stderr.lower() + + +def test_version_probe_does_not_read_stdin(monkeypatch): + run_pnpm = PNPM_MODULE["run_pnpm"] + calls = [] + + def fake_run(*args, **kwargs): + calls.append(kwargs) + return SimpleNamespace(returncode=0, stdout="10.26.2\n") + + monkeypatch.setitem(run_pnpm.__globals__, "find_pnpm_command", lambda: ["pnpm"]) + monkeypatch.setitem(run_pnpm.__globals__, "subprocess", SimpleNamespace(run=fake_run, DEVNULL=-3)) + + assert run_pnpm(["install"]) == 0 + assert calls[0]["stdin"] == -3 + + def test_runner_prefers_cmd_shim_on_windows(monkeypatch): paths = { "pnpm": r"C:\tools\pnpm.exe", diff --git a/scripts/AGENTS.md b/scripts/AGENTS.md index e007b04ee9..a5c7632b5f 100644 --- a/scripts/AGENTS.md +++ b/scripts/AGENTS.md @@ -23,7 +23,9 @@ Cases marked for real Compose run only when a `docker` CLI is installed. `core.fileMode=false` or a non-POSIX filesystem does not keep executable bits. - **Host pnpm calls go through `scripts/pnpm.py`.** It chooses the launcher by Python's `os.name`, not by the invoking shell, so native Windows, MSYS and POSIX - each get the right one. + each get the right one. A direct pnpm with a version different from the + `frontend/package.json` pin falls back to Corepack; without Corepack, the + runner stops before running the mismatched pnpm. - **Shell filters stay compatible with GNU and BSD sed**, because macOS ships the BSD tools. `sandbox-mode.sh` uses Bash and awk only. - **Lookups that run before the environment exists use the standard library diff --git a/scripts/pnpm.py b/scripts/pnpm.py index a122ea2a53..fd39f468a0 100644 --- a/scripts/pnpm.py +++ b/scripts/pnpm.py @@ -1,9 +1,11 @@ #!/usr/bin/env python3 -"""Run pnpm directly when available, otherwise run it through Corepack.""" +"""Run the frontend's pinned pnpm version directly or through Corepack.""" from __future__ import annotations +import json import os +import re import shutil import subprocess import sys @@ -12,6 +14,15 @@ from pathlib import Path FRONTEND_DIR = Path(__file__).resolve().parent.parent / "frontend" COREPACK_NOTICE = "Using pnpm via Corepack." +PACKAGE_JSON_PATH = FRONTEND_DIR / "package.json" + + +def find_corepack_command() -> list[str] | None: + corepack_names = ("corepack.cmd", "corepack") if os.name == "nt" else ("corepack", "corepack.cmd") + for name in corepack_names: + if corepack_path := shutil.which(name): + return [str(Path(corepack_path)), "pnpm"] + return None def find_pnpm_command() -> list[str] | None: @@ -21,11 +32,7 @@ def find_pnpm_command() -> list[str] | None: if pnpm_path := shutil.which(name): return [str(Path(pnpm_path))] - corepack_names = ("corepack.cmd", "corepack") if os.name == "nt" else ("corepack", "corepack.cmd") - for name in corepack_names: - if corepack_path := shutil.which(name): - return [str(Path(corepack_path)), "pnpm"] - return None + return find_corepack_command() def run_pnpm(arguments: Sequence[str]) -> int: @@ -42,6 +49,42 @@ def run_pnpm(arguments: Sequence[str]) -> int: ) return 127 + if Path(command[0]).stem.lower() == "pnpm": + try: + package_manager = json.loads(PACKAGE_JSON_PATH.read_text(encoding="utf-8"))["packageManager"] + if not isinstance(package_manager, str) or not package_manager.startswith("pnpm@"): + raise ValueError("packageManager must name a pinned pnpm version") + pinned_version = package_manager.removeprefix("pnpm@") + except (OSError, json.JSONDecodeError, KeyError, TypeError, ValueError) as exc: + print(f"Error: Cannot read pinned pnpm version from frontend/package.json: {exc}", file=sys.stderr) + return 127 + try: + probe = subprocess.run( + [command[0], "--version"], + capture_output=True, + text=True, + stdin=subprocess.DEVNULL, + check=False, + shell=False, + cwd=FRONTEND_DIR, + ) + except OSError as exc: + print(f"Error: Failed to check pnpm version via {command[0]}: {exc}", file=sys.stderr) + return 126 + installed_version = probe.stdout.strip() + if probe.returncode == 0 and re.fullmatch(r"\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?", installed_version): + if installed_version != pinned_version: + corepack_command = find_corepack_command() + if corepack_command is None: + print( + f"Error: pnpm {installed_version} is on PATH, but frontend/package.json pins {pinned_version}. Install that version or make Corepack available.", + file=sys.stderr, + ) + return 127 + command = corepack_command + else: + print("Warning: could not determine pnpm version; proceeding without pin check.", file=sys.stderr) + if Path(command[0]).stem.lower() == "corepack": print(COREPACK_NOTICE, file=sys.stderr)