fix(setup): honor pinned pnpm version in host runner (#8183)

* fix(setup): honor pinned pnpm version in host runner

* fix(setup): harden pnpm version probing

* docs: drop changelog edit from pnpm PR
This commit is contained in:
杰尼龟 2026-11-14 17:09:46 +08:00 committed by GitHub
parent f086cce86e
commit a68dc2a1a0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 135 additions and 7 deletions

View File

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

View File

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

View File

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

View File

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