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
This commit is contained in:
ShitK 2026-07-29 08:08:33 +08:00 committed by GitHub
parent 9bb8225079
commit 4e44938551
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 500 additions and 71 deletions

View File

@ -113,6 +113,8 @@ cd frontend && pnpm test # Unit tests
Rule of thumb: **root `make` = the full application**; **`backend/Makefile` and `frontend/` Rule of thumb: **root `make` = the full application**; **`backend/Makefile` and `frontend/`
(`pnpm`) = per-module work.** (`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 ## Where to Go Next
- Backend work → **[backend/AGENTS.md](backend/AGENTS.md)** - Backend work → **[backend/AGENTS.md](backend/AGENTS.md)**

View File

@ -16,6 +16,8 @@ else
RUN_WITH_GIT_BASH = RUN_WITH_GIT_BASH =
endif endif
FRONTEND_PNPM = $(PYTHON) ../scripts/pnpm.py
help: help:
@echo "DeerFlow Development Commands:" @echo "DeerFlow Development Commands:"
@echo " make setup - Interactive setup wizard (recommended for new users)" @echo " make setup - Interactive setup wizard (recommended for new users)"
@ -80,7 +82,7 @@ install:
@echo "Installing backend dependencies..." @echo "Installing backend dependencies..."
@cd backend && uv sync @cd backend && uv sync
@echo "Installing frontend dependencies..." @echo "Installing frontend dependencies..."
@cd frontend && pnpm install @cd frontend && $(FRONTEND_PNPM) install
@echo "Installing pre-commit hooks..." @echo "Installing pre-commit hooks..."
@uv tool install pre-commit @uv tool install pre-commit
@pre-commit install --overwrite @pre-commit install --overwrite

View File

@ -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 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**: 2. **Install dependencies**:
```bash ```bash
make install # Install backend + frontend dependencies + pre-commit hooks make install # Install backend + frontend dependencies + pre-commit hooks

View File

@ -1,20 +1,27 @@
from __future__ import annotations from __future__ import annotations
import importlib.util import importlib.util
import subprocess
from pathlib import Path from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[2] REPO_ROOT = Path(__file__).resolve().parents[2]
CHECK_SCRIPT_PATH = REPO_ROOT / "scripts" / "check.py" 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) def _load_script(path: Path, name: str):
assert spec is not None assert path.exists(), f"{path} must exist"
assert spec.loader is not None spec = importlib.util.spec_from_file_location(name, path)
check_script = importlib.util.module_from_spec(spec) assert spec is not None
spec.loader.exec_module(check_script) 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): 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: def fake_which(name: str) -> str | None:
if name == "pnpm": if name == "pnpm":
return r"C:\Users\tester\AppData\Roaming\npm\pnpm.CMD" 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 r"C:\Users\tester\AppData\Roaming\npm\pnpm.cmd"
return None 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): 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: def fake_which(name: str) -> str | None:
if name == "corepack": if name == "corepack":
return r"C:\Program Files\nodejs\corepack.exe" return r"C:\Program Files\nodejs\corepack.exe"
return None 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", r"C:\Program Files\nodejs\corepack.exe",
"pnpm", "pnpm",
] ]
def test_find_pnpm_command_falls_back_to_corepack_cmd(monkeypatch): 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: def fake_which(name: str) -> str | None:
if name == "corepack": if name == "corepack":
return None 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 r"C:\Program Files\nodejs\corepack.cmd"
return None 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", r"C:\Program Files\nodejs\corepack.cmd",
"pnpm", "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

View File

@ -22,6 +22,50 @@ class TestCheckPython:
assert result.status == "ok" 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 # check_config_exists
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

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

View File

@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
import json import json
import sys
import zipfile import zipfile
import pytest import pytest
@ -14,6 +15,27 @@ def _zip_text(zip_path, name: str) -> str:
return zf.read(name).decode("utf-8") 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(): def test_redact_data_recursively_masks_secret_like_keys():
data = { data = {
"models": [ "models": [

View File

@ -1,24 +1,32 @@
ifeq ($(OS),Windows_NT)
PYTHON ?= python
else
PYTHON ?= python3
endif
PNPM = $(PYTHON) ../scripts/pnpm.py
install: install:
pnpm install $(PNPM) install
build: build:
pnpm build $(PNPM) build
dev: dev:
pnpm dev $(PNPM) dev
test: test:
pnpm test $(PNPM) test
test-e2e: test-e2e:
pnpm test:e2e $(PNPM) test:e2e
lint: lint:
pnpm lint $(PNPM) lint
format: format:
pnpm format:write $(PNPM) format:write
build-static: 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 @if [ -d .next/static ]; then mkdir -p .next/standalone/.next && cp -R .next/static .next/standalone/.next/static; fi

View File

@ -8,6 +8,10 @@ import subprocess
import sys import sys
from pathlib import Path 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: def configure_stdio() -> None:
"""Prefer UTF-8 output so Unicode status markers render on Windows.""" """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: def run_command(command: list[str]) -> str | None:
"""Run a command and return trimmed stdout, or None on failure.""" """Run a command and return trimmed stdout, or None on failure."""
try: 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): except (OSError, subprocess.CalledProcessError):
return None return None
return result.stdout.strip() or result.stderr.strip() return result.stdout.strip() or result.stderr.strip()
def find_pnpm_command() -> list[str] | None: def run_pnpm_version() -> tuple[str | None, bool, str | None]:
"""Return a pnpm-compatible command that exists on this machine.""" """Return the pnpm version, resolution source, and failure message."""
pnpm_path = shutil.which("pnpm") try:
if pnpm_path: result = subprocess.run(
return [str(Path(pnpm_path))] [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") stdout = result.stdout.strip()
if pnpm_cmd_path: stderr_lines = result.stderr.splitlines()
return [str(Path(pnpm_cmd_path))] 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") diagnostics = "\n".join(part for part in (stderr, stdout) if part)
if not corepack_path: if diagnostics:
corepack_path = shutil.which("corepack.cmd") return None, via_corepack, diagnostics
if corepack_path: return (
return [str(Path(corepack_path)), "pnpm"] None,
return None via_corepack,
f"The pnpm runner exited with status {result.returncode} without output.",
)
def parse_node_major(version_text: str) -> int | None: def parse_node_major(version_text: str) -> int | None:
@ -91,22 +110,15 @@ def main() -> int:
print() print()
print("Checking pnpm...") print("Checking pnpm...")
pnpm_command = find_pnpm_command() pnpm_version, pnpm_via_corepack, pnpm_error = run_pnpm_version()
if pnpm_command: if pnpm_version:
pnpm_version = run_command([*pnpm_command, "-v"]) resolution_hint = " (via Corepack)" if pnpm_via_corepack else ""
if pnpm_version: print(f" OK pnpm {pnpm_version}{resolution_hint}")
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
else: else:
print(" FAIL pnpm not found") print(" FAIL pnpm is unavailable or failed to run")
print(" Install: npm install -g pnpm") if pnpm_error:
print(" Or enable Corepack: corepack enable") for line in pnpm_error.splitlines():
print(" Or visit: https://pnpm.io/installation") print(f" {line}")
failed = True failed = True
print() print()
@ -115,7 +127,9 @@ def main() -> int:
uv_version_text = run_command(["uv", "--version"]) uv_version_text = run_command(["uv", "--version"])
if uv_version_text: if uv_version_text:
uv_version_parts = uv_version_text.split() 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}") print(f" OK uv {uv_version}")
else: else:
print(" INFO Unable to determine uv version") print(" INFO Unable to determine uv version")

View File

@ -24,6 +24,8 @@ from typing import Literal
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
Status = Literal["ok", "warn", "fail", "skip"] 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: def _supports_color() -> bool:
@ -165,18 +167,41 @@ def check_node() -> CheckResult:
def check_pnpm() -> CheckResult: def check_pnpm() -> CheckResult:
candidates = [["pnpm"], ["pnpm.cmd"]] try:
if shutil.which("corepack"): result = subprocess.run(
candidates.append(["corepack", "pnpm"]) [sys.executable, str(PNPM_SCRIPT_PATH), "-v"],
for cmd in candidates: cwd=FRONTEND_DIR,
if shutil.which(cmd[0]): capture_output=True,
out = _run([*cmd, "-v"]) or "" text=True,
return CheckResult("pnpm", "ok", out) check=False,
return CheckResult( shell=False,
"pnpm", )
"fail", except OSError as exc:
fix="npm install -g pnpm (or: corepack enable)", 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: def check_uv() -> CheckResult:

76
scripts/pnpm.py Normal file
View File

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

View File

@ -293,15 +293,20 @@ if $DAEMON_MODE; then
MODE_LABEL="$MODE_LABEL [daemon]" MODE_LABEL="$MODE_LABEL [daemon]"
fi 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 # Frontend command
if $DEV_MODE; then if $DEV_MODE; then
FRONTEND_CMD="pnpm run dev" FRONTEND_CMD='"$DEERFLOW_PNPM_PYTHON" "$DEERFLOW_PNPM_RUNNER" run dev'
else else
if ! PYTHON_BIN="$(_pick_python)"; then 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"
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"
fi fi
# Runtime path defaults. Local `make dev` launches Gateway from `backend/`, # 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. # in particular). Required for postgres extras — see PR #2584.
# Intentionally unquoted to splat multiple `--extra X` pairs. # 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 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" echo "✓ Dependencies synced"
else else
echo "⏩ Skipping dependency install (--skip-install)" echo "⏩ Skipping dependency install (--skip-install)"

View File

@ -210,7 +210,15 @@ def collect_environment(project_root: Path) -> dict[str, Any]:
}, },
"commands": [ "commands": [
_version_command("node", ["node", "--version"], project_root), _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("uv", ["uv", "--version"], project_root),
_version_command("nginx", ["nginx", "-v"], project_root), _version_command("nginx", ["nginx", "-v"], project_root),
_version_command("docker", ["docker", "--version"], project_root), _version_command("docker", ["docker", "--version"], project_root),