From 4e449385516c03b6b279ced004ff0ada493d56ef Mon Sep 17 00:00:00 2001 From: ShitK <80999801+ShitK@users.noreply.github.com> Date: Wed, 29 Jul 2026 08:08:33 +0800 Subject: [PATCH] fix: align pnpm consumers with Corepack fallback (#4405) * fix: align pnpm consumers with Corepack fallback * fix: run pnpm helper from frontend workspace * fix: preserve Corepack resolution hint --- AGENTS.md | 2 + Makefile | 4 +- README.md | 2 + backend/tests/test_check_script.py | 101 ++++++++++++++++--- backend/tests/test_doctor.py | 44 +++++++++ backend/tests/test_pnpm_script.py | 142 +++++++++++++++++++++++++++ backend/tests/test_support_bundle.py | 22 +++++ frontend/Makefile | 24 +++-- scripts/check.py | 76 ++++++++------ scripts/doctor.py | 49 ++++++--- scripts/pnpm.py | 76 ++++++++++++++ scripts/serve.sh | 19 ++-- scripts/support_bundle.py | 10 +- 13 files changed, 500 insertions(+), 71 deletions(-) create mode 100644 backend/tests/test_pnpm_script.py create mode 100644 scripts/pnpm.py diff --git a/AGENTS.md b/AGENTS.md index 9f1567c63..8907ac0b0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -113,6 +113,8 @@ cd frontend && pnpm test # Unit tests Rule of thumb: **root `make` = the full application**; **`backend/Makefile` and `frontend/` (`pnpm`) = per-module work.** +Host-side pnpm consumers, including the root/frontend Makefiles and local diagnostic scripts, must run through `scripts/pnpm.py`. The runner preserves direct `pnpm`/`pnpm.cmd` priority, falls back to `corepack pnpm`, and is invoked from `frontend/` so Corepack honors the package-manager version pinned by that project. + ## Where to Go Next - Backend work → **[backend/AGENTS.md](backend/AGENTS.md)** diff --git a/Makefile b/Makefile index 20e1a572e..d8d69544b 100644 --- a/Makefile +++ b/Makefile @@ -16,6 +16,8 @@ else RUN_WITH_GIT_BASH = endif +FRONTEND_PNPM = $(PYTHON) ../scripts/pnpm.py + help: @echo "DeerFlow Development Commands:" @echo " make setup - Interactive setup wizard (recommended for new users)" @@ -80,7 +82,7 @@ install: @echo "Installing backend dependencies..." @cd backend && uv sync @echo "Installing frontend dependencies..." - @cd frontend && pnpm install + @cd frontend && $(FRONTEND_PNPM) install @echo "Installing pre-commit hooks..." @uv tool install pre-commit @pre-commit install --overwrite diff --git a/README.md b/README.md index 46a3b7ce8..79f20cb13 100644 --- a/README.md +++ b/README.md @@ -306,6 +306,8 @@ On Windows, run the local development flow from Git Bash. Native `cmd.exe` and P make check # Verifies Node.js 22+, pnpm, uv, nginx ``` + The local `make check`, `make install`, `make dev`, and `make start` entry points use a direct `pnpm`/`pnpm.cmd` executable when available and otherwise fall back to `corepack pnpm`. Corepack runs from `frontend/`, so it honors the `packageManager` version pinned in `frontend/package.json`; enabling a global pnpm shim is not required. + 2. **Install dependencies**: ```bash make install # Install backend + frontend dependencies + pre-commit hooks diff --git a/backend/tests/test_check_script.py b/backend/tests/test_check_script.py index c8e96e47f..342ec5f7a 100644 --- a/backend/tests/test_check_script.py +++ b/backend/tests/test_check_script.py @@ -1,20 +1,27 @@ from __future__ import annotations import importlib.util +import subprocess from pathlib import Path REPO_ROOT = Path(__file__).resolve().parents[2] CHECK_SCRIPT_PATH = REPO_ROOT / "scripts" / "check.py" +PNPM_SCRIPT_PATH = REPO_ROOT / "scripts" / "pnpm.py" -spec = importlib.util.spec_from_file_location("deerflow_check_script", CHECK_SCRIPT_PATH) -assert spec is not None -assert spec.loader is not None -check_script = importlib.util.module_from_spec(spec) -spec.loader.exec_module(check_script) +def _load_script(path: Path, name: str): + assert path.exists(), f"{path} must exist" + spec = importlib.util.spec_from_file_location(name, path) + assert spec is not None + assert spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module def test_find_pnpm_command_prefers_resolved_executable(monkeypatch): + pnpm_script = _load_script(PNPM_SCRIPT_PATH, "deerflow_pnpm_script_direct") + def fake_which(name: str) -> str | None: if name == "pnpm": return r"C:\Users\tester\AppData\Roaming\npm\pnpm.CMD" @@ -22,26 +29,30 @@ def test_find_pnpm_command_prefers_resolved_executable(monkeypatch): return r"C:\Users\tester\AppData\Roaming\npm\pnpm.cmd" return None - monkeypatch.setattr(check_script.shutil, "which", fake_which) + monkeypatch.setattr(pnpm_script.shutil, "which", fake_which) - assert check_script.find_pnpm_command() == [r"C:\Users\tester\AppData\Roaming\npm\pnpm.CMD"] + assert pnpm_script.find_pnpm_command() == [r"C:\Users\tester\AppData\Roaming\npm\pnpm.CMD"] def test_find_pnpm_command_falls_back_to_corepack(monkeypatch): + pnpm_script = _load_script(PNPM_SCRIPT_PATH, "deerflow_pnpm_script_corepack") + def fake_which(name: str) -> str | None: if name == "corepack": return r"C:\Program Files\nodejs\corepack.exe" return None - monkeypatch.setattr(check_script.shutil, "which", fake_which) + monkeypatch.setattr(pnpm_script.shutil, "which", fake_which) - assert check_script.find_pnpm_command() == [ + assert pnpm_script.find_pnpm_command() == [ r"C:\Program Files\nodejs\corepack.exe", "pnpm", ] def test_find_pnpm_command_falls_back_to_corepack_cmd(monkeypatch): + pnpm_script = _load_script(PNPM_SCRIPT_PATH, "deerflow_pnpm_script_corepack_cmd") + def fake_which(name: str) -> str | None: if name == "corepack": return None @@ -49,9 +60,77 @@ def test_find_pnpm_command_falls_back_to_corepack_cmd(monkeypatch): return r"C:\Program Files\nodejs\corepack.cmd" return None - monkeypatch.setattr(check_script.shutil, "which", fake_which) + monkeypatch.setattr(pnpm_script.shutil, "which", fake_which) - assert check_script.find_pnpm_command() == [ + assert pnpm_script.find_pnpm_command() == [ r"C:\Program Files\nodejs\corepack.cmd", "pnpm", ] + + +def test_check_script_uses_shared_pnpm_runner(): + check_script = CHECK_SCRIPT_PATH.read_text(encoding="utf-8") + + assert 'Path(__file__).with_name("pnpm.py")' in check_script + + +def test_check_script_preserves_runner_failure_diagnostics(monkeypatch): + check_script = _load_script(CHECK_SCRIPT_PATH, "deerflow_check_script_failure") + call_kwargs = {} + + def fake_run(*args, **kwargs): + call_kwargs.update(kwargs) + return subprocess.CompletedProcess( + args=["python", "pnpm.py", "-v"], + returncode=42, + stdout="partial pnpm output\n", + stderr="Error: pnpm command failed with exit status 42.\n", + ) + + monkeypatch.setattr(check_script.subprocess, "run", fake_run) + + assert check_script.run_pnpm_version() == ( + None, + False, + "Error: pnpm command failed with exit status 42.\npartial pnpm output", + ) + assert call_kwargs["cwd"] == REPO_ROOT / "frontend" + + +def test_check_script_preserves_corepack_resolution_hint(monkeypatch): + check_script = _load_script(CHECK_SCRIPT_PATH, "deerflow_check_script_corepack") + + def fake_run(*args, **kwargs): + return subprocess.CompletedProcess( + args=["python", "pnpm.py", "-v"], + returncode=0, + stdout="10.26.2\n", + stderr="Using pnpm via Corepack.\n", + ) + + monkeypatch.setattr(check_script.subprocess, "run", fake_run) + + assert check_script.run_pnpm_version() == ("10.26.2", True, None) + + +def test_check_status_labels_corepack_fallback(monkeypatch, capsys): + check_script = _load_script(CHECK_SCRIPT_PATH, "deerflow_check_script_status") + + monkeypatch.setattr(check_script.shutil, "which", lambda name: f"/fake/{name}") + monkeypatch.setattr( + check_script, + "run_command", + lambda command: { + "node": "v22.0.0", + "uv": "uv 0.11.31", + "nginx": "nginx/1.31.3", + }[command[0]], + ) + monkeypatch.setattr( + check_script, + "run_pnpm_version", + lambda: ("10.26.2", True, None), + ) + + assert check_script.main() == 0 + assert "OK pnpm 10.26.2 (via Corepack)" in capsys.readouterr().out diff --git a/backend/tests/test_doctor.py b/backend/tests/test_doctor.py index e00e51e0f..9e7617cc0 100644 --- a/backend/tests/test_doctor.py +++ b/backend/tests/test_doctor.py @@ -22,6 +22,50 @@ class TestCheckPython: assert result.status == "ok" +# --------------------------------------------------------------------------- +# check_pnpm +# --------------------------------------------------------------------------- + + +class TestCheckPnpm: + def test_uses_shared_runner_from_frontend(self, monkeypatch): + captured = {} + + def fake_run(cmd, **kwargs): + captured["cmd"] = cmd + captured["kwargs"] = kwargs + return doctor.subprocess.CompletedProcess(cmd, 0, stdout="10.26.2\n", stderr="") + + monkeypatch.setattr(doctor.subprocess, "run", fake_run) + + result = doctor.check_pnpm() + + expected_runner = doctor.Path(doctor.__file__).with_name("pnpm.py") + assert result.status == "ok" + assert result.detail == "10.26.2" + assert captured["cmd"] == [sys.executable, str(expected_runner), "-v"] + assert captured["kwargs"]["cwd"] == expected_runner.parent.parent / "frontend" + assert captured["kwargs"]["shell"] is False + assert captured["kwargs"]["check"] is False + + def test_runner_failure_is_reported_as_failure(self, monkeypatch): + def fake_run(cmd, **kwargs): + return doctor.subprocess.CompletedProcess( + cmd, + 42, + stdout="", + stderr="Error: pnpm command failed with exit status 42.\n", + ) + + monkeypatch.setattr(doctor.subprocess, "run", fake_run) + + result = doctor.check_pnpm() + + assert result.status == "fail" + assert "exit status 42" in result.detail + assert result.fix is not None + + # --------------------------------------------------------------------------- # check_config_exists # --------------------------------------------------------------------------- diff --git a/backend/tests/test_pnpm_script.py b/backend/tests/test_pnpm_script.py new file mode 100644 index 000000000..261138b35 --- /dev/null +++ b/backend/tests/test_pnpm_script.py @@ -0,0 +1,142 @@ +from __future__ import annotations + +import json +import os +import subprocess +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[2] +PNPM_SCRIPT = REPO_ROOT / "scripts" / "pnpm.py" +FRONTEND_DIR = REPO_ROOT / "frontend" + + +def _write_fake_command(bin_dir: Path, name: str, label: str, exit_code: int = 0) -> Path: + if os.name == "nt": + path = bin_dir / f"{name}.cmd" + path.write_text( + f"@echo off\r\necho {label}^|%CD%^|%*\r\nexit /b {exit_code}\r\n", + encoding="utf-8", + ) + else: + path = bin_dir / name + path.write_text( + f"#!/bin/sh\nprintf '%s|%s|%s\\n' '{label}' \"$PWD\" \"$*\"\nexit {exit_code}\n", + encoding="utf-8", + ) + path.chmod(0o755) + return path + + +def _run_pnpm( + path: Path, + *args: str, + cwd: Path = FRONTEND_DIR, +) -> subprocess.CompletedProcess[str]: + env = os.environ.copy() + env["PATH"] = str(path) + return subprocess.run( + [sys.executable, str(PNPM_SCRIPT), *args], + cwd=cwd, + env=env, + capture_output=True, + text=True, + check=False, + shell=False, + ) + + +def test_runner_prefers_direct_pnpm_and_forwards_arguments(tmp_path: Path): + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + _write_fake_command(bin_dir, "pnpm", "direct") + _write_fake_command(bin_dir, "corepack", "corepack") + + result = _run_pnpm(bin_dir, "run", "dev", "--host", "127.0.0.1") + + assert result.returncode == 0 + assert result.stdout.strip() == f"direct|{FRONTEND_DIR}|run dev --host 127.0.0.1" + assert "via Corepack" not in result.stderr + + +def test_runner_uses_corepack_pnpm_from_frontend_directory(tmp_path: Path): + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + _write_fake_command(bin_dir, "corepack", "corepack") + + result = _run_pnpm(bin_dir, "--version") + + assert result.returncode == 0 + assert result.stdout.strip() == f"corepack|{FRONTEND_DIR}|pnpm --version" + assert result.stderr.strip() == "Using pnpm via Corepack." + + package_json = json.loads((FRONTEND_DIR / "package.json").read_text(encoding="utf-8")) + assert package_json["packageManager"] == "pnpm@10.26.2" + + +def test_runner_uses_frontend_directory_when_called_from_repo_root(tmp_path: Path): + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + _write_fake_command(bin_dir, "corepack", "corepack") + + result = _run_pnpm(bin_dir, "--version", cwd=REPO_ROOT) + + assert result.returncode == 0 + assert result.stdout.strip() == f"corepack|{FRONTEND_DIR}|pnpm --version" + + +def test_runner_reports_actionable_error_when_pnpm_and_corepack_are_missing(tmp_path: Path): + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + + result = _run_pnpm(bin_dir, "--version") + + assert result.returncode == 127 + assert "Neither pnpm nor Corepack is available" in result.stderr + assert "ensure 'corepack' is on PATH" in result.stderr + + +def test_runner_propagates_selected_pnpm_failure(tmp_path: Path): + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + _write_fake_command(bin_dir, "pnpm", "broken-direct", exit_code=42) + _write_fake_command(bin_dir, "corepack", "unused-corepack") + + result = _run_pnpm(bin_dir, "install", "--frozen-lockfile") + + assert result.returncode == 42 + assert result.stdout.strip() == f"broken-direct|{FRONTEND_DIR}|install --frozen-lockfile" + assert "pnpm command failed with exit status 42" in result.stderr + + +def test_official_entrypoints_route_pnpm_through_shared_runner(): + root_makefile = (REPO_ROOT / "Makefile").read_text(encoding="utf-8") + frontend_makefile = (FRONTEND_DIR / "Makefile").read_text(encoding="utf-8") + serve_script = (REPO_ROOT / "scripts" / "serve.sh").read_text(encoding="utf-8") + doctor_script = (REPO_ROOT / "scripts" / "doctor.py").read_text(encoding="utf-8") + support_bundle_script = (REPO_ROOT / "scripts" / "support_bundle.py").read_text(encoding="utf-8") + + assert "cd frontend && $(FRONTEND_PNPM) install" in root_makefile + assert "PNPM = $(PYTHON) ../scripts/pnpm.py" in frontend_makefile + assert '"$DEERFLOW_PNPM_PYTHON" "$DEERFLOW_PNPM_RUNNER" install --silent' in serve_script + assert 'DEERFLOW_PNPM_RUNNER="$REPO_ROOT/scripts/pnpm.py"' in serve_script + assert 'FRONTEND_CMD=\'"$DEERFLOW_PNPM_PYTHON" "$DEERFLOW_PNPM_RUNNER" run dev\'' in serve_script + assert '"\\$DEERFLOW_PNPM_RUNNER\\" run preview"' in serve_script + assert 'Path(__file__).with_name("pnpm.py")' in doctor_script + assert 'project_root / "scripts" / "pnpm.py"' in support_bundle_script + + +def test_make_install_dry_run_does_not_invoke_bare_pnpm(): + result = subprocess.run( + ["make", "-n", "install"], + cwd=REPO_ROOT, + capture_output=True, + text=True, + check=False, + shell=False, + ) + + assert result.returncode == 0 + assert "cd frontend && " in result.stdout + assert "../scripts/pnpm.py install" in result.stdout + assert "cd frontend && pnpm install" not in result.stdout diff --git a/backend/tests/test_support_bundle.py b/backend/tests/test_support_bundle.py index d66433417..1e08dd4e3 100644 --- a/backend/tests/test_support_bundle.py +++ b/backend/tests/test_support_bundle.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +import sys import zipfile import pytest @@ -14,6 +15,27 @@ def _zip_text(zip_path, name: str) -> str: return zf.read(name).decode("utf-8") +def test_collect_environment_routes_pnpm_through_shared_runner(tmp_path, monkeypatch): + calls = [] + + def fake_version_command(name, args, cwd): + calls.append((name, args, cwd)) + return {"name": name, "ok": True, "stdout": "version", "stderr": ""} + + monkeypatch.setattr(support_bundle, "_version_command", fake_version_command) + + support_bundle.collect_environment(tmp_path) + + pnpm_calls = [call for call in calls if call[0] == "pnpm"] + assert pnpm_calls == [ + ( + "pnpm", + [sys.executable, str(tmp_path / "scripts" / "pnpm.py"), "--version"], + tmp_path / "frontend", + ) + ] + + def test_redact_data_recursively_masks_secret_like_keys(): data = { "models": [ diff --git a/frontend/Makefile b/frontend/Makefile index bf6c351e2..862806bd5 100644 --- a/frontend/Makefile +++ b/frontend/Makefile @@ -1,24 +1,32 @@ +ifeq ($(OS),Windows_NT) + PYTHON ?= python +else + PYTHON ?= python3 +endif + +PNPM = $(PYTHON) ../scripts/pnpm.py + install: - pnpm install + $(PNPM) install build: - pnpm build + $(PNPM) build dev: - pnpm dev + $(PNPM) dev test: - pnpm test + $(PNPM) test test-e2e: - pnpm test:e2e + $(PNPM) test:e2e lint: - pnpm lint + $(PNPM) lint format: - pnpm format:write + $(PNPM) format:write build-static: - NEXT_CONFIG_BUILD_OUTPUT=standalone SKIP_ENV_VALIDATION=1 NEXT_PUBLIC_STATIC_WEBSITE_ONLY=true pnpm build + NEXT_CONFIG_BUILD_OUTPUT=standalone SKIP_ENV_VALIDATION=1 NEXT_PUBLIC_STATIC_WEBSITE_ONLY=true $(PNPM) build @if [ -d .next/static ]; then mkdir -p .next/standalone/.next && cp -R .next/static .next/standalone/.next/static; fi diff --git a/scripts/check.py b/scripts/check.py index b633b1015..1248a87df 100644 --- a/scripts/check.py +++ b/scripts/check.py @@ -8,6 +8,10 @@ import subprocess import sys from pathlib import Path +PNPM_SCRIPT_PATH = Path(__file__).with_name("pnpm.py") +FRONTEND_DIR = PNPM_SCRIPT_PATH.parent.parent / "frontend" +COREPACK_NOTICE = "Using pnpm via Corepack." + def configure_stdio() -> None: """Prefer UTF-8 output so Unicode status markers render on Windows.""" @@ -23,28 +27,43 @@ def configure_stdio() -> None: def run_command(command: list[str]) -> str | None: """Run a command and return trimmed stdout, or None on failure.""" try: - result = subprocess.run(command, capture_output=True, text=True, check=True, shell=False) + result = subprocess.run( + command, capture_output=True, text=True, check=True, shell=False + ) except (OSError, subprocess.CalledProcessError): return None return result.stdout.strip() or result.stderr.strip() -def find_pnpm_command() -> list[str] | None: - """Return a pnpm-compatible command that exists on this machine.""" - pnpm_path = shutil.which("pnpm") - if pnpm_path: - return [str(Path(pnpm_path))] +def run_pnpm_version() -> tuple[str | None, bool, str | None]: + """Return the pnpm version, resolution source, and failure message.""" + try: + result = subprocess.run( + [sys.executable, str(PNPM_SCRIPT_PATH), "-v"], + capture_output=True, + text=True, + check=False, + shell=False, + cwd=FRONTEND_DIR, + ) + except OSError as exc: + return None, False, f"Unable to launch the pnpm runner: {exc}" - pnpm_cmd_path = shutil.which("pnpm.cmd") - if pnpm_cmd_path: - return [str(Path(pnpm_cmd_path))] + stdout = result.stdout.strip() + stderr_lines = result.stderr.splitlines() + via_corepack = COREPACK_NOTICE in stderr_lines + stderr = "\n".join(line for line in stderr_lines if line != COREPACK_NOTICE).strip() + if result.returncode == 0 and (stdout or stderr): + return stdout or stderr, via_corepack, None - corepack_path = shutil.which("corepack") - if not corepack_path: - corepack_path = shutil.which("corepack.cmd") - if corepack_path: - return [str(Path(corepack_path)), "pnpm"] - return None + diagnostics = "\n".join(part for part in (stderr, stdout) if part) + if diagnostics: + return None, via_corepack, diagnostics + return ( + None, + via_corepack, + f"The pnpm runner exited with status {result.returncode} without output.", + ) def parse_node_major(version_text: str) -> int | None: @@ -91,22 +110,15 @@ def main() -> int: print() print("Checking pnpm...") - pnpm_command = find_pnpm_command() - if pnpm_command: - pnpm_version = run_command([*pnpm_command, "-v"]) - if pnpm_version: - if Path(pnpm_command[0]).stem.lower() == "corepack": - print(f" OK pnpm {pnpm_version} (via Corepack)") - else: - print(f" OK pnpm {pnpm_version}") - else: - print(" INFO Unable to determine pnpm version") - failed = True + pnpm_version, pnpm_via_corepack, pnpm_error = run_pnpm_version() + if pnpm_version: + resolution_hint = " (via Corepack)" if pnpm_via_corepack else "" + print(f" OK pnpm {pnpm_version}{resolution_hint}") else: - print(" FAIL pnpm not found") - print(" Install: npm install -g pnpm") - print(" Or enable Corepack: corepack enable") - print(" Or visit: https://pnpm.io/installation") + print(" FAIL pnpm is unavailable or failed to run") + if pnpm_error: + for line in pnpm_error.splitlines(): + print(f" {line}") failed = True print() @@ -115,7 +127,9 @@ def main() -> int: uv_version_text = run_command(["uv", "--version"]) if uv_version_text: uv_version_parts = uv_version_text.split() - uv_version = uv_version_parts[1] if len(uv_version_parts) > 1 else uv_version_text + uv_version = ( + uv_version_parts[1] if len(uv_version_parts) > 1 else uv_version_text + ) print(f" OK uv {uv_version}") else: print(" INFO Unable to determine uv version") diff --git a/scripts/doctor.py b/scripts/doctor.py index 33dd0a911..06009e89b 100644 --- a/scripts/doctor.py +++ b/scripts/doctor.py @@ -24,6 +24,8 @@ from typing import Literal # --------------------------------------------------------------------------- Status = Literal["ok", "warn", "fail", "skip"] +PNPM_SCRIPT_PATH = Path(__file__).with_name("pnpm.py") +FRONTEND_DIR = PNPM_SCRIPT_PATH.parent.parent / "frontend" def _supports_color() -> bool: @@ -165,18 +167,41 @@ def check_node() -> CheckResult: def check_pnpm() -> CheckResult: - candidates = [["pnpm"], ["pnpm.cmd"]] - if shutil.which("corepack"): - candidates.append(["corepack", "pnpm"]) - for cmd in candidates: - if shutil.which(cmd[0]): - out = _run([*cmd, "-v"]) or "" - return CheckResult("pnpm", "ok", out) - return CheckResult( - "pnpm", - "fail", - fix="npm install -g pnpm (or: corepack enable)", - ) + try: + result = subprocess.run( + [sys.executable, str(PNPM_SCRIPT_PATH), "-v"], + cwd=FRONTEND_DIR, + capture_output=True, + text=True, + check=False, + shell=False, + ) + except OSError as exc: + return CheckResult( + "pnpm", + "fail", + f"Unable to run pnpm resolver: {exc}", + fix="Install pnpm, or install Corepack and ensure it is on PATH", + ) + + stdout = (result.stdout or "").strip() + stderr = (result.stderr or "").strip() + if result.returncode != 0: + detail = "\n".join(part for part in (stderr, stdout) if part) + return CheckResult( + "pnpm", + "fail", + detail or f"pnpm resolver exited with status {result.returncode}", + fix="Install pnpm, or install Corepack and ensure it is on PATH", + ) + if not stdout: + return CheckResult( + "pnpm", + "fail", + stderr or "pnpm resolver returned no version", + fix="Install pnpm, or install Corepack and ensure it is on PATH", + ) + return CheckResult("pnpm", "ok", stdout) def check_uv() -> CheckResult: diff --git a/scripts/pnpm.py b/scripts/pnpm.py new file mode 100644 index 000000000..da7956f88 --- /dev/null +++ b/scripts/pnpm.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +"""Run pnpm directly when available, otherwise run it through Corepack.""" + +from __future__ import annotations + +import shutil +import subprocess +import sys +from collections.abc import Sequence +from pathlib import Path + +FRONTEND_DIR = Path(__file__).resolve().parent.parent / "frontend" +COREPACK_NOTICE = "Using pnpm via Corepack." + + +def find_pnpm_command() -> list[str] | None: + """Return the preferred pnpm-compatible command for this machine.""" + pnpm_path = shutil.which("pnpm") + if pnpm_path: + return [str(Path(pnpm_path))] + + pnpm_cmd_path = shutil.which("pnpm.cmd") + if pnpm_cmd_path: + return [str(Path(pnpm_cmd_path))] + + corepack_path = shutil.which("corepack") + if not corepack_path: + corepack_path = shutil.which("corepack.cmd") + if corepack_path: + return [str(Path(corepack_path)), "pnpm"] + return None + + +def run_pnpm(arguments: Sequence[str]) -> int: + """Run pnpm with the supplied arguments and propagate its exit status.""" + command = find_pnpm_command() + if command is None: + print( + "Error: Neither pnpm nor Corepack is available on PATH.", + file=sys.stderr, + ) + print( + "Install pnpm, or install Corepack and ensure 'corepack' is on PATH.", + file=sys.stderr, + ) + return 127 + + if Path(command[0]).stem.lower() == "corepack": + print(COREPACK_NOTICE, file=sys.stderr) + + try: + result = subprocess.run( + [*command, *arguments], + check=False, + shell=False, + cwd=FRONTEND_DIR, + ) + except OSError as exc: + print(f"Error: Failed to run pnpm via {command[0]}: {exc}", file=sys.stderr) + return 126 + + exit_code = 128 - result.returncode if result.returncode < 0 else result.returncode + if exit_code != 0: + print( + f"Error: pnpm command failed with exit status {exit_code}.", + file=sys.stderr, + ) + return exit_code + + +def main(argv: Sequence[str] | None = None) -> int: + return run_pnpm(sys.argv[1:] if argv is None else argv) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/serve.sh b/scripts/serve.sh index ad49deba1..bbd86274a 100755 --- a/scripts/serve.sh +++ b/scripts/serve.sh @@ -293,15 +293,20 @@ if $DAEMON_MODE; then MODE_LABEL="$MODE_LABEL [daemon]" fi +# Resolve pnpm through the same runner used by make check/install. Exporting +# these values keeps paths with spaces intact when run_service invokes sh -c. +if ! DEERFLOW_PNPM_PYTHON="$(_pick_python)"; then + echo "Python 3 is required to run pnpm." + exit 1 +fi +DEERFLOW_PNPM_RUNNER="$REPO_ROOT/scripts/pnpm.py" +export DEERFLOW_PNPM_PYTHON DEERFLOW_PNPM_RUNNER + # Frontend command if $DEV_MODE; then - FRONTEND_CMD="pnpm run dev" + FRONTEND_CMD='"$DEERFLOW_PNPM_PYTHON" "$DEERFLOW_PNPM_RUNNER" run dev' else - if ! PYTHON_BIN="$(_pick_python)"; then - echo "Python is required to generate BETTER_AUTH_SECRET." - exit 1 - fi - FRONTEND_CMD="env BETTER_AUTH_SECRET=$($PYTHON_BIN -c 'import secrets; print(secrets.token_hex(16))') pnpm run preview" + FRONTEND_CMD="env BETTER_AUTH_SECRET=$($DEERFLOW_PNPM_PYTHON -c 'import secrets; print(secrets.token_hex(16))') \"\$DEERFLOW_PNPM_PYTHON\" \"\$DEERFLOW_PNPM_RUNNER\" run preview" fi # Runtime path defaults. Local `make dev` launches Gateway from `backend/`, @@ -386,7 +391,7 @@ if ! $SKIP_INSTALL; then # in particular). Required for postgres extras — see PR #2584. # Intentionally unquoted to splat multiple `--extra X` pairs. (cd backend && uv sync --quiet --all-packages $UV_EXTRAS_FLAGS) || { echo "✗ Backend dependency install failed"; exit 1; } - (cd frontend && pnpm install --silent) || { echo "✗ Frontend dependency install failed"; exit 1; } + (cd frontend && "$DEERFLOW_PNPM_PYTHON" "$DEERFLOW_PNPM_RUNNER" install --silent) || { echo "✗ Frontend dependency install failed"; exit 1; } echo "✓ Dependencies synced" else echo "⏩ Skipping dependency install (--skip-install)" diff --git a/scripts/support_bundle.py b/scripts/support_bundle.py index 2eb0247b0..fbc29954e 100644 --- a/scripts/support_bundle.py +++ b/scripts/support_bundle.py @@ -210,7 +210,15 @@ def collect_environment(project_root: Path) -> dict[str, Any]: }, "commands": [ _version_command("node", ["node", "--version"], project_root), - _version_command("pnpm", ["pnpm", "--version"], project_root), + _version_command( + "pnpm", + [ + sys.executable, + str(project_root / "scripts" / "pnpm.py"), + "--version", + ], + project_root / "frontend", + ), _version_command("uv", ["uv", "--version"], project_root), _version_command("nginx", ["nginx", "-v"], project_root), _version_command("docker", ["docker", "--version"], project_root),