Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 4 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -322,16 +322,16 @@ def main(context: dict[str, str]) -> int:
cfg["node"][f"{constants.SYSTEM_MACHINE}_sha256"],
reporoot,
)
node.install_yarn(cfg["node"]["yarn_version"], reporoot)

node.install_pnpm(reporoot)

print("installing node dependencies...")
proc.run(
(
".devenv/bin/yarn",
f"{reporoot}/.devenv/bin/pnpm",
"install",
"--frozen-lockfile",
"--no-progress",
"--non-interactive",
"--reporter=append-only",
),
)

Expand All @@ -353,7 +353,6 @@ linux_x86_64 = https://storage.googleapis.com/sentry-dev-infra-assets/node/node-
linux_x86_64_sha256 = efc0f295dd878e510ab12ea36bbadc3db03c687ab30c07e86c7cdba7eed879a9
# used for autoupdate
version = v20.13.1
yarn_version = 1.22.22
```

### brew
Expand Down
103 changes: 50 additions & 53 deletions devenv/lib/node.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

import json
import os
import shutil
import tempfile
Expand All @@ -9,7 +10,7 @@
from devenv.lib import proc


_shims = ("node", "npm", "npx", "yarn", "pnpm")
_shims = ("node", "npm", "npx")


def _install(url: str, sha256: str, into: str) -> None:
Expand All @@ -32,7 +33,7 @@ def _install(url: str, sha256: str, into: str) -> None:
def uninstall(binroot: str) -> None:
shutil.rmtree(f"{binroot}/node-env", ignore_errors=True)

for shim in (*_shims, "yarn"):
for shim in (*_shims, "pnpm"):
fp = f"{binroot}/{shim}"
try:
os.remove(fp)
Expand Down Expand Up @@ -71,6 +72,51 @@ def installed(version: str, binroot: str) -> bool:
return False


def installed_pnpm(version: str, binroot: str) -> bool:
if shutil.which(
"pnpm", path=binroot
) != f"{binroot}/pnpm" or not os.path.exists(
f"{binroot}/node-env/bin/pnpm"
):
return False

stdout = proc.run((f"{binroot}/pnpm", "--version"), stdout=True)
installed_version = stdout.strip()
return version == installed_version


def install_pnpm(reporoot: str) -> None:
binroot = fs.ensure_binroot(reporoot)

with open(f"{reporoot}/package.json") as f:
package_json = json.load(f)
pnpm = package_json["packageManager"]
Comment on lines +91 to +93
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Direct access to package_json["packageManager"] without existence check can cause KeyError.
Severity: CRITICAL | Confidence: 1.00

🔍 Detailed Analysis

If install_pnpm() is called on a repository without a packageManager field in its package.json, the code will raise an unhandled KeyError when attempting to access package_json["packageManager"]. This occurs because the field is optional according to npm documentation, and the current implementation directly accesses it without checking for its existence. This will crash the setup process.

💡 Suggested Fix

Add a check for the existence of the packageManager key in package_json before accessing it. Provide a default value or raise a more informative error if missing.

🤖 Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.

Location: devenv/lib/node.py#L91-L93

Potential issue: If `install_pnpm()` is called on a repository without a
`packageManager` field in its `package.json`, the code will raise an unhandled
`KeyError` when attempting to access `package_json["packageManager"]`. This occurs
because the field is optional according to npm documentation, and the current
implementation directly accesses it without checking for its existence. This will crash
the setup process.

Did we get this right? 👍 / 👎 to inform future reviews.

pnpm_version = pnpm.split("@")[-1]

if installed_pnpm(pnpm_version, binroot):
return

print(f"installing pnpm {pnpm_version}...")

# {binroot}/npm is a devenv-managed shim, so
# this install -g ends up putting pnpm into
# .devenv/bin/node-env/bin/pnpm which is pointed
# to by the {binroot}/pnpm shim
proc.run(
(f"{binroot}/npm", "install", "-g", f"pnpm@{pnpm_version}"), stdout=True
)

fs.write_script(
f"{binroot}/pnpm",
"""#!/bin/sh
export PATH={binroot}/node-env/bin:"${{PATH}}"
export NPM_CONFIG_PREFIX={binroot}/node-env
exec {binroot}/node-env/bin/pnpm "$@"
""",
shell_escape={"binroot": binroot},
)


def install(version: str, url: str, sha256: str, reporoot: str) -> None:
binroot = fs.ensure_binroot(reporoot)

Expand All @@ -81,9 +127,8 @@ def install(version: str, url: str, sha256: str, reporoot: str) -> None:
uninstall(binroot)
_install(url, sha256, binroot)

# NPM_CONFIG_PREFIX is needed to ensure npm install -g yarn
# puts yarn into our node-env.

# NPM_CONFIG_PREFIX is needed to ensure npm install -g pnpm
# puts pnpm into our node-env.
for shim in _shims:
fs.write_script(
f"{binroot}/{shim}",
Expand All @@ -97,51 +142,3 @@ def install(version: str, url: str, sha256: str, reporoot: str) -> None:

if not installed(version, binroot):
raise SystemExit(f"failed to install node {version}!")


def installed_yarn(version: str, binroot: str) -> bool:
if shutil.which(
"yarn", path=binroot
) != f"{binroot}/yarn" or not os.path.exists(
f"{binroot}/node-env/bin/yarn"
):
return False

with open(f"{binroot}/yarn", "r") as f:
sample = f.read(64)
if "VOLTA_HOME" in sample:
print("volta-based yarn detected")
return False

stdout = proc.run((f"{binroot}/yarn", "--version"), stdout=True)
installed_version = stdout.strip()
if version == installed_version:
return True

print(f"installed yarn {installed_version} is unexpected!")
return False


def install_yarn(version: str, reporoot: str) -> None:
binroot = fs.ensure_binroot(reporoot)

if installed_yarn(version, binroot):
return

# not explicitly uninstalling here, following steps
# will pave over it
print(f"installing yarn {version}...")

proc.run((f"{binroot}/npm", "install", "-g", f"yarn@{version}"))

fs.write_script(
f"{binroot}/yarn",
"""#!/bin/sh
export PATH={binroot}/node-env/bin:"${{PATH}}"
exec {binroot}/node-env/bin/yarn "$@"
""",
shell_escape={"binroot": binroot},
)

if not installed_yarn(version, binroot):
raise SystemExit(f"failed to install yarn {version}!")
37 changes: 37 additions & 0 deletions tests/lib/test_node.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
from __future__ import annotations

import os
import pathlib
from unittest.mock import patch

from devenv.lib import node
from devenv.lib.repository import Repository


def test_install_pnpm(tmp_path: pathlib.Path) -> None:
repo = Repository(f"{tmp_path}/test")

binroot = f"{repo.path}/.devenv/bin"
os.makedirs(binroot)
open(f"{binroot}/node", "w").close()

with patch("devenv.lib.archive.download"), patch(
"devenv.lib.archive.unpack",
side_effect=lambda archive_file, tmpd, perform_strip1, strip1_new_prefix: os.makedirs(
f"{tmpd}/{strip1_new_prefix}"
),
), patch("devenv.lib.node.os.path.exists"), patch(
"devenv.lib.direnv.proc.run", side_effect=["0.0.0"] # node --version
):
node.install("0.0.0", "bar", "baz", repo.path)

with open(f"{binroot}/npm", "r") as f:
text = f.read()
assert (
text
== f"""#!/bin/sh
export PATH={binroot}/node-env/bin:"${{PATH}}"
export NPM_CONFIG_PREFIX={binroot}/node-env
exec {binroot}/node-env/bin/npm "$@"
"""
)
Loading