• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

pantsbuild / pants / 18812500213

26 Oct 2025 03:42AM UTC coverage: 80.284% (+0.005%) from 80.279%
18812500213

Pull #22804

github

web-flow
Merge 2a56fdb46 into 4834308dc
Pull Request #22804: test_shell_command: use correct default cache scope for a test's environment

29 of 31 new or added lines in 2 files covered. (93.55%)

1314 existing lines in 64 files now uncovered.

77900 of 97030 relevant lines covered (80.28%)

3.35 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

77.64
/src/python/pants/backend/javascript/subsystems/nodejs.py
1
# Copyright 2022 Pants project contributors (see CONTRIBUTORS.md).
2
# Licensed under the Apache License, Version 2.0 (see LICENSE).
3

4
from __future__ import annotations
11✔
5

6
import itertools
11✔
7
import logging
11✔
8
import os.path
11✔
9
from collections.abc import Collection, Iterable, Mapping, Sequence
11✔
10
from dataclasses import dataclass, field
11✔
11
from itertools import groupby
11✔
12
from typing import ClassVar
11✔
13

14
from nodesemver import min_satisfying
11✔
15

16
from pants.core.environments.target_types import EnvironmentTarget
11✔
17
from pants.core.util_rules import asdf, search_paths, system_binaries
11✔
18
from pants.core.util_rules.asdf import AsdfPathString, AsdfToolPathsResult
11✔
19
from pants.core.util_rules.env_vars import environment_vars_subset
11✔
20
from pants.core.util_rules.env_vars import environment_vars_subset as environment_vars_subset_get
11✔
21
from pants.core.util_rules.external_tool import (
11✔
22
    DownloadedExternalTool,
23
    ExternalToolRequest,
24
    ExternalToolVersion,
25
    TemplatedExternalToolOptionsMixin,
26
    download_external_tool,
27
)
28
from pants.core.util_rules.external_tool import rules as external_tool_rules
11✔
29
from pants.core.util_rules.search_paths import (
11✔
30
    ExecutableSearchPathsOptionMixin,
31
    ValidateSearchPathsRequest,
32
    VersionManagerSearchPathsRequest,
33
    get_un_cachable_version_manager_paths,
34
    validate_search_paths,
35
)
36
from pants.core.util_rules.system_binaries import (
11✔
37
    BinaryNotFoundError,
38
    BinaryPath,
39
    BinaryPathRequest,
40
    BinaryPathTest,
41
    BinaryShims,
42
    BinaryShimsRequest,
43
    create_binary_shims,
44
    find_binary,
45
)
46
from pants.engine.env_vars import EXTRA_ENV_VARS_USAGE_HELP, EnvironmentVars, EnvironmentVarsRequest
11✔
47
from pants.engine.fs import EMPTY_DIGEST, CreateDigest, Digest, Directory, DownloadFile
11✔
48
from pants.engine.internals.native_engine import FileDigest, MergeDigests
11✔
49
from pants.engine.internals.platform_rules import environment_path_variable
11✔
50
from pants.engine.internals.selectors import concurrently
11✔
51
from pants.engine.intrinsics import create_digest, merge_digests
11✔
52
from pants.engine.platform import Platform
11✔
53
from pants.engine.process import Process, fallible_to_exec_result_or_raise
11✔
54
from pants.engine.rules import Rule, collect_rules, implicitly, rule
11✔
55
from pants.engine.unions import UnionRule
11✔
56
from pants.option.option_types import DictOption, ShellStrListOption, StrListOption, StrOption
11✔
57
from pants.option.subsystem import Subsystem
11✔
58
from pants.util.docutil import bin_name
11✔
59
from pants.util.frozendict import FrozenDict
11✔
60
from pants.util.logging import LogLevel
11✔
61
from pants.util.ordered_set import FrozenOrderedSet
11✔
62
from pants.util.strutil import help_text, softwrap
11✔
63

64
_logger = logging.getLogger(__name__)
11✔
65

66

67
class NodeJS(Subsystem, TemplatedExternalToolOptionsMixin):
11✔
68
    options_scope = "nodejs"
11✔
69
    help = "The Node.js Javascript runtime (including Corepack)."
11✔
70

71
    default_version = "v22.14.0"
11✔
72
    default_known_versions = [
11✔
73
        "v22.14.0|macos_arm64|e9404633bc02a5162c5c573b1e2490f5fb44648345d64a958b17e325729a5e42|47035396",
74
        "v22.14.0|macos_x86_64|6698587713ab565a94a360e091df9f6d91c8fadda6d00f0cf6526e9b40bed250|48656392",
75
        "v22.14.0|linux_arm64|08bfbf538bad0e8cbb0269f0173cca28d705874a67a22f60b57d99dc99e30050|28636440",
76
        "v22.14.0|linux_x86_64|69b09dba5c8dcb05c4e4273a4340db1005abeafe3927efda2bc5b249e80437ec|29893360",
77
    ]
78

79
    default_url_template = "https://nodejs.org/dist/{version}/node-{version}-{platform}.tar"
11✔
80
    default_url_platform_mapping = {
11✔
81
        "macos_arm64": "darwin-arm64",
82
        "macos_x86_64": "darwin-x64",
83
        "linux_arm64": "linux-arm64",
84
        "linux_x86_64": "linux-x64",
85
    }
86

87
    resolves = DictOption[str](
11✔
88
        default={},
89
        help=softwrap(
90
            f"""
91
            A mapping of names to lockfile paths used in your project.
92

93
            Specifying a resolve name is optional. If unspecified,
94
            the default resolve name is calculated by taking the path
95
            from the source root to the directory containing the lockfile
96
            and replacing '{os.path.sep}' with '.' in that path.
97

98
            Example:
99
            An npm lockfile located at `src/js/package/package-lock.json`
100
            will result in a resolve named `js.package`, assuming src/
101
            is a source root.
102

103
            Run `{bin_name()} generate-lockfiles` to
104
            generate the lockfile(s).
105
            """
106
        ),
107
        advanced=True,
108
    )
109

110
    def generate_url(self, version: str, plat: Platform) -> str:
11✔
111
        """NodeJS binaries are compressed as .gz for Mac, .xz for Linux."""
112
        platform = self.url_platform_mapping.get(plat.value, "")
×
113
        url = self.url_template.format(version=version, platform=platform)
×
114
        extension = "gz" if plat.is_macos else "xz"
×
115
        return f"{url}.{extension}"
×
116

117
    def generate_exe(self, version: str, plat: Platform) -> str:
11✔
118
        assert self.default_url_platform_mapping is not None
×
119
        plat_str = self.default_url_platform_mapping[plat.value]
×
120
        return f"./node-{version}-{plat_str}/bin/node"
×
121

122
    async def download_known_version(
11✔
123
        self, known_version: ExternalToolVersion, platform: Platform
124
    ) -> DownloadedExternalTool:
125
        exe = self.generate_exe(known_version.version, platform)
×
126
        url = self.generate_url(known_version.version, platform)
×
127
        download_file = DownloadFile(url, FileDigest(known_version.sha256, known_version.filesize))
×
128
        return await download_external_tool(ExternalToolRequest(download_file, exe))
×
129

130
    package_manager = StrOption(
11✔
131
        default="npm",
132
        help=softwrap(
133
            """
134
            Default Node.js package manager to use.
135

136
            You can either rely on this default together with the [nodejs].package_managers
137
            option, or specify the `package.json#packageManager` tool and version
138
            in the package.json of your project.
139

140
            Specifying conflicting package manager versions within a multi-package
141
            workspace is an error.
142
            """
143
        ),
144
    )
145

146
    package_managers = DictOption[str](
11✔
147
        default={"npm": "10.9.2", "yarn": "1.22.22", "pnpm": "9.15.6"},
148
        help=help_text(
149
            """
150
            A mapping of package manager versions to semver releases.
151

152
            Many organizations only need a single version of a package manager, which is
153
            a good default and often the simplest thing to do.
154

155
            The version download is managed by Corepack. This mapping corresponds to
156
            the https://github.com/nodejs/corepack#known-good-releases setting, using
157
            the `--activate` flag.
158
            """
159
        ),
160
    )
161

162
    extra_env_vars = StrListOption(
11✔
163
        help=softwrap(
164
            f"""
165
            Environment variables to set during package manager operations.
166

167
            {EXTRA_ENV_VARS_USAGE_HELP}
168
            """
169
        ),
170
        advanced=True,
171
    )
172

173
    @property
11✔
174
    def default_package_manager(self) -> str | None:
11✔
175
        if self.package_manager in self.package_managers:
×
176
            return f"{self.package_manager}@{self.package_managers[self.package_manager]}"
×
177
        return self.package_manager
×
178

179
    _tools = StrListOption(
11✔
180
        default=[],
181
        help=softwrap(
182
            """
183
            List any additional executable tools required for node processes to work. The paths to
184
            these tools will be included in the PATH used in the execution sandbox, so that
185
            they may be used by nodejs processes execution.
186
            """
187
        ),
188
        advanced=True,
189
    )
190

191
    _optional_tools = StrListOption(
11✔
192
        default=[],
193
        help=softwrap(
194
            """
195
            List any additional executable which are not mandatory for node processes to work, but
196
            which should be included if available. The paths to these tools will be included in the
197
            PATH used in the execution sandbox, so that they may be used by nodejs processes execution.
198
            """
199
        ),
200
        advanced=True,
201
    )
202

203
    @property
11✔
204
    def tools(self) -> tuple[str, ...]:
11✔
UNCOV
205
        return tuple(sorted(set(self._tools)))
1✔
206

207
    @property
11✔
208
    def optional_tools(self) -> tuple[str, ...]:
11✔
UNCOV
209
        return tuple(sorted(set(self._optional_tools)))
1✔
210

211
    class EnvironmentAware(ExecutableSearchPathsOptionMixin, Subsystem.EnvironmentAware):
11✔
212
        search_path = StrListOption(
11✔
213
            default=["<PATH>"],
214
            help=lambda cls: help_text(
215
                f"""
216
                A list of paths to search for Node.js distributions.
217

218
                This option is only used if a templated url download
219
                specified via [{cls.subsystem.options_scope}].known_versions
220
                does not contain a version matching the configured
221
                [{cls.subsystem.options_scope}].version range.
222

223
                You can specify absolute paths to binaries
224
                and/or to directories containing binaries. The order of entries does
225
                not matter.
226

227
                The following special strings are supported:
228

229
                For all runtime environment types:
230

231
                * `<PATH>`, the contents of the PATH env var
232

233
                When the environment is a `local_environment` target:
234

235
                * `{AsdfPathString.STANDARD}`, {AsdfPathString.STANDARD.description("Node.js")}
236
                * `{AsdfPathString.LOCAL}`, {AsdfPathString.LOCAL.description("binaries")}
237
                * `<NVM>`, all NodeJS versions under $NVM_DIR/versions/node
238
                * `<NVM_LOCAL>`, the nvm installation with the version in BUILD_ROOT/.nvmrc
239
                Note that the version in the .nvmrc file has to be on the form "vX.Y.Z".
240
                """
241
            ),
242
            advanced=True,
243
            metavar="<binary-paths>",
244
        )
245

246
        executable_search_paths_help = softwrap(
11✔
247
            """
248
            The PATH value that will be used to find any tools required to run nodejs processes.
249
            """
250
        )
251

252
        _corepack_env_vars = ShellStrListOption(
11✔
253
            help=softwrap(
254
                """
255
                Environment variables to set for `corepack` invocations.
256

257
                Entries are either strings in the form `ENV_VAR=value` to set an explicit value;
258
                or just `ENV_VAR` to copy the value from Pants's own environment.
259

260
                Review https://github.com/nodejs/corepack#environment-variables
261
                for available variables.
262
                """
263
            ),
264
            advanced=True,
265
        )
266

267
        @property
11✔
268
        def corepack_env_vars(self) -> tuple[str, ...]:
11✔
269
            return tuple(sorted(set(self._corepack_env_vars)))
×
270

271

272
@dataclass(frozen=True)
11✔
273
class NodeJSToolProcess:
11✔
274
    """A request for a tool installed with NodeJS."""
275

276
    tool: str
11✔
277
    tool_version: str | None
11✔
278
    args: tuple[str, ...]
11✔
279
    description: str
11✔
280
    level: LogLevel = LogLevel.INFO
11✔
281
    input_digest: Digest = EMPTY_DIGEST
11✔
282
    output_files: tuple[str, ...] = ()
11✔
283
    output_directories: tuple[str, ...] = ()
11✔
284
    working_directory: str | None = None
11✔
285
    append_only_caches: FrozenDict[str, str] = field(default_factory=FrozenDict)
11✔
286
    timeout_seconds: int | None = None
11✔
287
    extra_env: Mapping[str, str] = field(default_factory=FrozenDict)
11✔
288
    project_digest: Digest | None = None
11✔
289

290
    @classmethod
11✔
291
    def npm(
11✔
292
        cls,
293
        args: Iterable[str],
294
        description: str,
295
        level: LogLevel = LogLevel.INFO,
296
        input_digest: Digest = EMPTY_DIGEST,
297
        output_files: tuple[str, ...] = (),
298
        output_directories: tuple[str, ...] = (),
299
        working_directory: str | None = None,
300
        append_only_caches: FrozenDict[str, str] | None = None,
301
        timeout_seconds: int | None = None,
302
        extra_env: Mapping[str, str] | None = None,
303
        tool_version: str | None = None,
304
        project_digest: Digest | None = None,
305
    ) -> NodeJSToolProcess:
UNCOV
306
        return cls(
1✔
307
            tool="npm",
308
            tool_version=tool_version,
309
            args=tuple(args),
310
            description=description,
311
            level=level,
312
            input_digest=input_digest,
313
            output_files=output_files,
314
            output_directories=output_directories,
315
            working_directory=working_directory,
316
            append_only_caches=append_only_caches or FrozenDict(),
317
            timeout_seconds=timeout_seconds,
318
            extra_env=extra_env or FrozenDict(),
319
            project_digest=project_digest,
320
        )
321

322

323
@dataclass(frozen=True)
11✔
324
class NodeJSBinaries:
11✔
325
    binary_dir: str
11✔
326
    digest: Digest | None = None
11✔
327

328

329
@dataclass(frozen=True)
11✔
330
class NodeJSProcessEnvironment:
11✔
331
    binaries: NodeJSBinaries
11✔
332
    npm_config_cache: str
11✔
333
    tool_binaries: BinaryShims
11✔
334
    corepack_home: str
11✔
335
    corepack_shims: str
11✔
336
    corepack_env_vars: EnvironmentVars
11✔
337

338
    base_bin_dir: ClassVar[str] = "__node"
11✔
339

340
    def to_env_dict(self, extras: Mapping[str, str] | None = None) -> dict[str, str]:
11✔
UNCOV
341
        extras = extras or {}
1✔
UNCOV
342
        extra_path = extras.get("PATH", "")
1✔
UNCOV
343
        path = [self.tool_binaries.path_component, self.corepack_shims, self.binary_directory]
1✔
UNCOV
344
        if extra_path:
1✔
UNCOV
345
            path.append(extra_path)
1✔
346

UNCOV
347
        return {
1✔
348
            **extras,
349
            "PATH": os.pathsep.join(path),
350
            "npm_config_cache": self.npm_config_cache,  # Normally stored at ~/.npm,
351
            "COREPACK_HOME": os.path.join("{chroot}", self.corepack_home),
352
            **self.corepack_env_vars,
353
        }
354

355
    @property
11✔
356
    def append_only_caches(self) -> Mapping[str, str]:
11✔
357
        return {"npm": self.npm_config_cache}
×
358

359
    @property
11✔
360
    def binary_directory(self) -> str:
11✔
UNCOV
361
        return self.binaries.binary_dir
1✔
362

363
    def immutable_digest(self) -> dict[str, Digest]:
11✔
364
        return (
×
365
            {self.base_bin_dir: self.binaries.digest, **self.tool_binaries.immutable_input_digests}
366
            if self.binaries.digest
367
            else {**self.tool_binaries.immutable_input_digests}
368
        )
369

370

371
async def add_corepack_shims_to_digest(
11✔
372
    binaries: NodeJSBinaries, tool_shims: BinaryShims, corepack_env_vars: EnvironmentVars
373
) -> Digest:
UNCOV
374
    directory_digest = await create_digest(CreateDigest([Directory("._corepack")]))
1✔
UNCOV
375
    binary_digest = binaries.digest if binaries.digest else EMPTY_DIGEST
1✔
UNCOV
376
    input_digest = await merge_digests(MergeDigests((directory_digest, binary_digest)))
1✔
377

UNCOV
378
    none_immutable_binary_path = binaries.binary_dir.replace(
1✔
379
        f"/{NodeJSProcessEnvironment.base_bin_dir}", ""
380
    )
UNCOV
381
    enable_corepack_result = await fallible_to_exec_result_or_raise(
1✔
382
        **implicitly(
383
            Process(
384
                argv=(
385
                    "corepack",
386
                    "enable",
387
                    "npm",
388
                    "pnpm",
389
                    "yarn",
390
                    "--install-directory",
391
                    "._corepack",
392
                ),
393
                input_digest=input_digest,
394
                immutable_input_digests={**tool_shims.immutable_input_digests},
395
                output_directories=["._corepack"],
396
                description="Enabling corepack shims",
397
                level=LogLevel.DEBUG,
398
                env={
399
                    "PATH": f"{tool_shims.path_component}:{none_immutable_binary_path}",
400
                    "COREPACK_HOME": "._corepack_home",
401
                    **corepack_env_vars,
402
                },
403
            )
404
        )
405
    )
UNCOV
406
    return await merge_digests(MergeDigests((binary_digest, enable_corepack_result.output_digest)))
1✔
407

408

409
async def get_nodejs_process_tools_shims(
11✔
410
    *,
411
    tools: Sequence[str],
412
    optional_tools: Sequence[str],
413
    search_path: Sequence[str],
414
    rationale: str,
415
) -> BinaryShims:
UNCOV
416
    requests = [
1✔
417
        BinaryPathRequest(binary_name=binary_name, search_path=search_path)
418
        for binary_name in (*tools, *optional_tools)
419
    ]
UNCOV
420
    paths = await concurrently(find_binary(request, **implicitly()) for request in requests)
1✔
UNCOV
421
    required_tools_paths = [
1✔
422
        path.first_path_or_raise(request, rationale=rationale)
423
        for request, path in zip(requests, paths)
424
        if request.binary_name in tools
425
    ]
UNCOV
426
    optional_tools_paths = [
1✔
427
        path.first_path
428
        for request, path in zip(requests, paths)
429
        if request.binary_name in optional_tools and path.first_path
430
    ]
431

UNCOV
432
    tools_shims = await create_binary_shims(
1✔
433
        BinaryShimsRequest.for_paths(
434
            *required_tools_paths,
435
            *optional_tools_paths,
436
            rationale=rationale,
437
        ),
438
        **implicitly(),
439
    )
440

UNCOV
441
    return tools_shims
1✔
442

443

444
@rule(level=LogLevel.DEBUG)
11✔
445
async def node_process_environment(
11✔
446
    binaries: NodeJSBinaries,
447
    nodejs: NodeJS,
448
    nodejs_environment: NodeJS.EnvironmentAware,
449
) -> NodeJSProcessEnvironment:
UNCOV
450
    default_required_tools = ["sh", "bash"]
1✔
UNCOV
451
    tools_used_by_setup_scripts = ["mkdir", "rm", "touch", "which"]
1✔
UNCOV
452
    pnpm_shim_tools = ["sed", "dirname", "uname"]
1✔
453

UNCOV
454
    binary_shims = await get_nodejs_process_tools_shims(
1✔
455
        tools=[
456
            *default_required_tools,
457
            *tools_used_by_setup_scripts,
458
            *pnpm_shim_tools,
459
            *nodejs.tools,
460
        ],
461
        optional_tools=nodejs.optional_tools,
462
        search_path=nodejs_environment.executable_search_path,
463
        rationale="execute a nodejs process",
464
    )
UNCOV
465
    corepack_env_vars = await environment_vars_subset_get(
1✔
466
        EnvironmentVarsRequest(nodejs_environment.corepack_env_vars), **implicitly()
467
    )
UNCOV
468
    binary_digest_with_shims = await add_corepack_shims_to_digest(
1✔
469
        binaries, binary_shims, corepack_env_vars
470
    )
UNCOV
471
    binaries = NodeJSBinaries(binaries.binary_dir, binary_digest_with_shims)
1✔
472

UNCOV
473
    return NodeJSProcessEnvironment(
1✔
474
        binaries=binaries,
475
        npm_config_cache="._npm",
476
        tool_binaries=binary_shims,
477
        corepack_home="._corepack_home",
478
        corepack_shims=os.path.join(
479
            "{chroot}", NodeJSProcessEnvironment.base_bin_dir, "._corepack"
480
        ),
481
        corepack_env_vars=corepack_env_vars,
482
    )
483

484

485
@dataclass(frozen=True)
11✔
486
class NodeJSBootstrap:
11✔
487
    nodejs_search_paths: tuple[str, ...]
11✔
488

489

490
async def _get_nvm_root() -> str | None:
11✔
491
    """See https://github.com/nvm-sh/nvm#installing-and-updating."""
492

UNCOV
493
    env = await environment_vars_subset(
1✔
494
        EnvironmentVarsRequest(("NVM_DIR", "XDG_CONFIG_HOME", "HOME")), **implicitly()
495
    )
UNCOV
496
    nvm_dir = env.get("NVM_DIR")
1✔
UNCOV
497
    default_dir = env.get("XDG_CONFIG_HOME", env.get("HOME"))
1✔
UNCOV
498
    if nvm_dir:
1✔
UNCOV
499
        return nvm_dir
1✔
UNCOV
500
    elif default_dir:
1✔
UNCOV
501
        return os.path.join(default_dir, ".nvm")
1✔
UNCOV
502
    return None
1✔
503

504

505
async def _nodejs_search_paths(
11✔
506
    env_tgt: EnvironmentTarget, paths: Collection[str]
507
) -> tuple[str, ...]:
508
    asdf_result = await AsdfToolPathsResult.get_un_cachable_search_paths(
×
509
        paths,
510
        env_tgt=env_tgt,
511
        tool_name="nodejs",
512
        tool_description="Node.js distribution",
513
        paths_option_name=f"[{NodeJS.options_scope}].search_path",
514
    )
515
    asdf_standard_tool_paths = asdf_result.standard_tool_paths
×
516
    asdf_local_tool_paths = asdf_result.local_tool_paths
×
517
    special_strings: dict[str, Iterable[str]] = {
×
518
        AsdfPathString.STANDARD: asdf_standard_tool_paths,
519
        AsdfPathString.LOCAL: asdf_local_tool_paths,
520
    }
521
    nvm_dir = await _get_nvm_root()
×
522
    expanded: list[str] = []
×
523
    nvm_path_results = await concurrently(
×
524
        get_un_cachable_version_manager_paths(
525
            VersionManagerSearchPathsRequest(
526
                env_tgt,
527
                nvm_dir,
528
                "versions/node",
529
                f"[{NodeJS.options_scope}].search_path",
530
                (".nvmrc",),
531
                s if s == "<NVM_LOCAL>" else None,
532
            ),
533
        )
534
        for s in paths
535
        if s == "<NVM>" or s == "<NVM_LOCAL>"
536
    )
537
    for nvm_path in FrozenOrderedSet(itertools.chain.from_iterable(nvm_path_results)):
×
538
        expanded.append(nvm_path)
×
539
    for s in paths:
×
540
        if s == "<PATH>":
×
541
            expanded.extend(await environment_path_variable(**implicitly()))  # noqa: PNT30: Linear search
×
542
        elif s in special_strings:
×
543
            expanded.extend(special_strings[s])
×
544
        elif s == "<NVM>" or s == "<NVM_LOCAL>":
×
545
            continue
×
546
        else:
547
            expanded.append(s)
×
548
    return tuple(expanded)
×
549

550

551
@rule
11✔
552
async def nodejs_bootstrap(nodejs_env_aware: NodeJS.EnvironmentAware) -> NodeJSBootstrap:
11✔
553
    search_paths = await validate_search_paths(
×
554
        ValidateSearchPathsRequest(
555
            env_tgt=nodejs_env_aware.env_tgt,
556
            search_paths=tuple(nodejs_env_aware.search_path),
557
            option_origin=f"[{NodeJS.options_scope}].search_path",
558
            environment_key="nodejs_search_path",
559
            is_default=nodejs_env_aware._is_default("search_path"),
560
            local_only=FrozenOrderedSet(
561
                (AsdfPathString.STANDARD, AsdfPathString.LOCAL, "<NVM>", "<NVM_LOCAL>")
562
            ),
563
        )
564
    )
565

566
    expanded_paths = await _nodejs_search_paths(nodejs_env_aware.env_tgt, search_paths)
×
567

568
    return NodeJSBootstrap(nodejs_search_paths=expanded_paths)
×
569

570

571
class _BinaryPathsPerVersion(FrozenDict[str, Sequence[BinaryPath]]):
11✔
572
    pass
11✔
573

574

575
@rule(level=LogLevel.DEBUG, desc="Testing for Node.js binaries.")
11✔
576
async def get_valid_nodejs_paths_by_version(bootstrap: NodeJSBootstrap) -> _BinaryPathsPerVersion:
11✔
577
    paths = await find_binary(
×
578
        BinaryPathRequest(
579
            search_path=bootstrap.nodejs_search_paths,
580
            binary_name="node",
581
            test=BinaryPathTest(
582
                ["--version"], fingerprint_stdout=False
583
            ),  # Hack to retain version info
584
        ),
585
        **implicitly(),
586
    )
587

588
    group_by_version = groupby((path for path in paths.paths), key=lambda path: path.fingerprint)
×
589
    return _BinaryPathsPerVersion({version: tuple(paths) for version, paths in group_by_version})
×
590

591

592
@rule(level=LogLevel.DEBUG, desc="Finding Node.js distribution binaries.")
11✔
593
async def determine_nodejs_binaries(
11✔
594
    nodejs: NodeJS, platform: Platform, paths_per_version: _BinaryPathsPerVersion
595
) -> NodeJSBinaries:
UNCOV
596
    decoded_versions = groupby(
1✔
597
        (ExternalToolVersion.decode(unparsed) for unparsed in nodejs.known_versions),
598
        lambda v: v.version,
599
    )
600

UNCOV
601
    decoded_per_version = {
1✔
602
        version: tuple(
603
            known_version
604
            for known_version in known_versions
605
            if known_version.platform == platform.value
606
        )
607
        for version, known_versions in decoded_versions
608
    }
609

UNCOV
610
    satisfying_version = min_satisfying(decoded_per_version.keys(), nodejs.version)
1✔
UNCOV
611
    if satisfying_version:
1✔
UNCOV
612
        known_version = decoded_per_version[satisfying_version][0]
1✔
UNCOV
613
        downloaded = await nodejs.download_known_version(known_version, platform)
1✔
UNCOV
614
        nodejs_bin_dir = os.path.join(
1✔
615
            "{chroot}",
616
            NodeJSProcessEnvironment.base_bin_dir,
617
            os.path.dirname(downloaded.exe),
618
        )
619

UNCOV
620
        return NodeJSBinaries(nodejs_bin_dir, downloaded.digest)
1✔
621

UNCOV
622
    satisfying_version = min_satisfying(paths_per_version.keys(), nodejs.version)
1✔
UNCOV
623
    if not satisfying_version:
1✔
UNCOV
624
        raise BinaryNotFoundError(
1✔
625
            softwrap(
626
                f"""
627
                Cannot find any `node` binaries satisfying the range '{nodejs.version}'.
628

629
                To fix, either list a `[{NodeJS.options_scope}].known_versions` version that satisfies the range,
630
                or ensure `[{NodeJS.options_scope}].search_path` contains a path to binaries that satisfy the range.
631
                """
632
            )
633
        )
UNCOV
634
    return NodeJSBinaries(os.path.dirname(paths_per_version[satisfying_version][0].path))
1✔
635

636

637
@dataclass(frozen=True)
11✔
638
class CorepackToolRequest:
11✔
639
    tool: str
11✔
640
    version: str | None = None
11✔
641

642

643
@dataclass(frozen=True)
11✔
644
class CorepackToolDigest:
11✔
645
    digest: Digest
11✔
646

647

648
@rule(desc="Preparing Corepack managed tool.")
11✔
649
async def prepare_corepack_tool(
11✔
650
    request: CorepackToolRequest, environment: NodeJSProcessEnvironment, nodejs: NodeJS
651
) -> CorepackToolDigest:
652
    version = request.version or nodejs.package_managers.get(request.tool)
×
653
    tool_spec = f"{request.tool}@{version}" if version else request.tool
×
654
    tool_description = tool_spec if version else f"default {tool_spec} version"
×
655
    result = await fallible_to_exec_result_or_raise(
×
656
        **implicitly(
657
            Process(
658
                argv=filter(
659
                    None, ("corepack", "prepare", tool_spec if version else "--all", "--activate")
660
                ),
661
                description=f"Preparing configured {tool_description}.",
662
                immutable_input_digests=environment.immutable_digest(),
663
                level=LogLevel.DEBUG,
664
                env=environment.to_env_dict(),
665
                append_only_caches={**environment.append_only_caches},
666
                output_directories=[environment.corepack_home],
667
            )
668
        )
669
    )
670
    return CorepackToolDigest(result.output_digest)
×
671

672

673
@rule(level=LogLevel.DEBUG)
11✔
674
async def setup_node_tool_process(
11✔
675
    request: NodeJSToolProcess, environment: NodeJSProcessEnvironment
676
) -> Process:
677
    if request.tool in ("npm", "npx", "pnpm", "yarn"):
×
678
        tool_name = request.tool.replace("npx", "npm")
×
679
        corepack_tool = await prepare_corepack_tool(
×
680
            CorepackToolRequest(tool_name, request.tool_version), **implicitly()
681
        )
682
        input_digest = await merge_digests(
×
683
            MergeDigests([request.input_digest, corepack_tool.digest])
684
        )
685
    else:
686
        input_digest = request.input_digest
×
687
    return Process(
×
688
        argv=list(filter(None, (request.tool, *request.args))),
689
        input_digest=input_digest,
690
        output_files=request.output_files,
691
        immutable_input_digests=environment.immutable_digest(),
692
        output_directories=request.output_directories,
693
        description=request.description,
694
        level=request.level,
695
        env=environment.to_env_dict(request.extra_env),
696
        working_directory=request.working_directory,
697
        append_only_caches={**request.append_only_caches, **environment.append_only_caches},
698
        timeout_seconds=request.timeout_seconds,
699
    )
700

701

702
class UserChosenNodeJSResolveAliases(FrozenDict[str, str]):
11✔
703
    pass
11✔
704

705

706
@rule(level=LogLevel.DEBUG)
11✔
707
async def user_chosen_resolve_aliases(nodejs: NodeJS) -> UserChosenNodeJSResolveAliases:
11✔
708
    return UserChosenNodeJSResolveAliases((value, key) for key, value in nodejs.resolves.items())
×
709

710

711
def rules() -> Iterable[Rule | UnionRule]:
11✔
712
    return (
11✔
713
        *collect_rules(),
714
        *external_tool_rules(),
715
        *asdf.rules(),
716
        *system_binaries.rules(),
717
        *search_paths.rules(),
718
    )
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc