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

pantsbuild / pants / 19068377358

04 Nov 2025 12:18PM UTC coverage: 92.46% (+12.2%) from 80.3%
19068377358

Pull #22816

github

web-flow
Merge a242f1805 into 89462b7ef
Pull Request #22816: Update Pants internal Python to 3.14

13 of 14 new or added lines in 12 files covered. (92.86%)

244 existing lines in 13 files now uncovered.

89544 of 96846 relevant lines covered (92.46%)

3.72 hits per line

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

97.89
/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
10✔
5

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

14
from nodesemver import min_satisfying
10✔
15

16
from pants.core.environments.target_types import EnvironmentTarget
10✔
17
from pants.core.util_rules import asdf, search_paths, system_binaries
10✔
18
from pants.core.util_rules.asdf import AsdfPathString, AsdfToolPathsResult
10✔
19
from pants.core.util_rules.env_vars import environment_vars_subset
10✔
20
from pants.core.util_rules.env_vars import environment_vars_subset as environment_vars_subset_get
10✔
21
from pants.core.util_rules.external_tool import (
10✔
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
10✔
29
from pants.core.util_rules.search_paths import (
10✔
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 (
10✔
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
10✔
47
from pants.engine.fs import EMPTY_DIGEST, CreateDigest, Digest, Directory, DownloadFile
10✔
48
from pants.engine.internals.native_engine import FileDigest, MergeDigests
10✔
49
from pants.engine.internals.platform_rules import environment_path_variable
10✔
50
from pants.engine.internals.selectors import concurrently
10✔
51
from pants.engine.intrinsics import create_digest, merge_digests
10✔
52
from pants.engine.platform import Platform
10✔
53
from pants.engine.process import Process, fallible_to_exec_result_or_raise
10✔
54
from pants.engine.rules import Rule, collect_rules, implicitly, rule
10✔
55
from pants.engine.unions import UnionRule
10✔
56
from pants.option.option_types import DictOption, ShellStrListOption, StrListOption, StrOption
10✔
57
from pants.option.subsystem import Subsystem
10✔
58
from pants.util.docutil import bin_name
10✔
59
from pants.util.frozendict import FrozenDict
10✔
60
from pants.util.logging import LogLevel
10✔
61
from pants.util.ordered_set import FrozenOrderedSet
10✔
62
from pants.util.strutil import help_text, softwrap
10✔
63

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

66

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

71
    default_version = "v24.10.0"
10✔
72
    default_known_versions = [
10✔
73
        "v24.10.0|macos_arm64|fbc3d6e1e1d962450d058e918214373872cc4c46e08673f31c35932afac4a8c5|51169103",
74
        "v24.10.0|macos_x86_64|627b884f66db0dd35f4b46fb9e994774ce560a7fb60798ba1ab81e867a73687d|52347633",
75
        "v24.10.0|linux_arm64|07f0558316ebb8977dd6fb29b4de8d369a639d3d8cef544293852a6f5eea6af8|31020300",
76
        "v24.10.0|linux_x86_64|2642f4428869aca32443660fd71b3918e2be1277a899bdcaeb64c93b54b5af17|32102040",
77
    ]
78

79
    default_url_template = "https://nodejs.org/dist/{version}/node-{version}-{platform}.tar"
10✔
80
    default_url_platform_mapping = {
10✔
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](
10✔
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:
10✔
111
        """NodeJS binaries are compressed as .gz for Mac, .xz for Linux."""
112
        platform = self.url_platform_mapping.get(plat.value, "")
10✔
113
        url = self.url_template.format(version=version, platform=platform)
10✔
114
        extension = "gz" if plat.is_macos else "xz"
10✔
115
        return f"{url}.{extension}"
10✔
116

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

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

130
    package_manager = StrOption(
10✔
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](
10✔
147
        default={"npm": "11.6.2", "yarn": "1.22.22", "pnpm": "10.19.0"},
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(
10✔
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
10✔
174
    def default_package_manager(self) -> str | None:
10✔
175
        if self.package_manager in self.package_managers:
8✔
176
            return f"{self.package_manager}@{self.package_managers[self.package_manager]}"
8✔
177
        return self.package_manager
1✔
178

179
    _tools = StrListOption(
10✔
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(
10✔
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
10✔
204
    def tools(self) -> tuple[str, ...]:
10✔
205
        return tuple(sorted(set(self._tools)))
10✔
206

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

211
    class EnvironmentAware(ExecutableSearchPathsOptionMixin, Subsystem.EnvironmentAware):
10✔
212
        search_path = StrListOption(
10✔
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(
10✔
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(
10✔
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
10✔
268
        def corepack_env_vars(self) -> tuple[str, ...]:
10✔
269
            return tuple(sorted(set(self._corepack_env_vars)))
10✔
270

271

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

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

290
    @classmethod
10✔
291
    def npm(
10✔
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:
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)
10✔
324
class NodeJSBinaries:
10✔
325
    binary_dir: str
10✔
326
    digest: Digest | None = None
10✔
327

328

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

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

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

347
        return {
10✔
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
10✔
356
    def append_only_caches(self) -> Mapping[str, str]:
10✔
357
        return {"npm": self.npm_config_cache}
10✔
358

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

363
    def immutable_digest(self) -> dict[str, Digest]:
10✔
364
        return (
10✔
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(
10✔
372
    binaries: NodeJSBinaries, tool_shims: BinaryShims, corepack_env_vars: EnvironmentVars
373
) -> Digest:
374
    directory_digest = await create_digest(CreateDigest([Directory("._corepack")]))
10✔
375
    binary_digest = binaries.digest if binaries.digest else EMPTY_DIGEST
10✔
376
    input_digest = await merge_digests(MergeDigests((directory_digest, binary_digest)))
10✔
377

378
    none_immutable_binary_path = binaries.binary_dir.replace(
10✔
379
        f"/{NodeJSProcessEnvironment.base_bin_dir}", ""
380
    )
381
    enable_corepack_result = await fallible_to_exec_result_or_raise(
10✔
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
    )
406
    return await merge_digests(MergeDigests((binary_digest, enable_corepack_result.output_digest)))
10✔
407

408

409
async def get_nodejs_process_tools_shims(
10✔
410
    *,
411
    tools: Sequence[str],
412
    optional_tools: Sequence[str],
413
    search_path: Sequence[str],
414
    rationale: str,
415
) -> BinaryShims:
416
    requests = [
10✔
417
        BinaryPathRequest(binary_name=binary_name, search_path=search_path)
418
        for binary_name in (*tools, *optional_tools)
419
    ]
420
    paths = await concurrently(find_binary(request, **implicitly()) for request in requests)
10✔
421
    required_tools_paths = [
10✔
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
    ]
426
    optional_tools_paths = [
10✔
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

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

441
    return tools_shims
10✔
442

443

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

454
    binary_shims = await get_nodejs_process_tools_shims(
10✔
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
    )
465
    corepack_env_vars = await environment_vars_subset_get(
10✔
466
        EnvironmentVarsRequest(nodejs_environment.corepack_env_vars), **implicitly()
467
    )
468
    binary_digest_with_shims = await add_corepack_shims_to_digest(
10✔
469
        binaries, binary_shims, corepack_env_vars
470
    )
471
    binaries = NodeJSBinaries(binaries.binary_dir, binary_digest_with_shims)
10✔
472

473
    return NodeJSProcessEnvironment(
10✔
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)
10✔
486
class NodeJSBootstrap:
10✔
487
    nodejs_search_paths: tuple[str, ...]
10✔
488

489

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

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

504

505
async def _nodejs_search_paths(
10✔
506
    env_tgt: EnvironmentTarget, paths: Collection[str]
507
) -> tuple[str, ...]:
508
    asdf_result = await AsdfToolPathsResult.get_un_cachable_search_paths(
10✔
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
10✔
516
    asdf_local_tool_paths = asdf_result.local_tool_paths
10✔
517
    special_strings: dict[str, Iterable[str]] = {
10✔
518
        AsdfPathString.STANDARD: asdf_standard_tool_paths,
519
        AsdfPathString.LOCAL: asdf_local_tool_paths,
520
    }
521
    nvm_dir = await _get_nvm_root()
10✔
522
    expanded: list[str] = []
10✔
523
    nvm_path_results = await concurrently(
10✔
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)):
10✔
538
        expanded.append(nvm_path)
×
539
    for s in paths:
10✔
540
        if s == "<PATH>":
10✔
541
            expanded.extend(await environment_path_variable(**implicitly()))  # noqa: PNT30: Linear search
10✔
542
        elif s in special_strings:
1✔
543
            expanded.extend(special_strings[s])
×
544
        elif s == "<NVM>" or s == "<NVM_LOCAL>":
1✔
545
            continue
×
546
        else:
547
            expanded.append(s)
1✔
548
    return tuple(expanded)
10✔
549

550

551
@rule
10✔
552
async def nodejs_bootstrap(nodejs_env_aware: NodeJS.EnvironmentAware) -> NodeJSBootstrap:
10✔
553
    search_paths = await validate_search_paths(
10✔
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)
10✔
567

568
    return NodeJSBootstrap(nodejs_search_paths=expanded_paths)
10✔
569

570

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

574

575
@rule(level=LogLevel.DEBUG, desc="Testing for Node.js binaries.")
10✔
576
async def get_valid_nodejs_paths_by_version(bootstrap: NodeJSBootstrap) -> _BinaryPathsPerVersion:
10✔
577
    paths = await find_binary(
10✔
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)
10✔
589
    return _BinaryPathsPerVersion({version: tuple(paths) for version, paths in group_by_version})
10✔
590

591

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

601
    decoded_per_version = {
10✔
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

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

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

622
    satisfying_version = min_satisfying(paths_per_version.keys(), nodejs.version)
1✔
623
    if not satisfying_version:
1✔
UNCOV
624
        raise BinaryNotFoundError(
×
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
        )
634
    return NodeJSBinaries(os.path.dirname(paths_per_version[satisfying_version][0].path))
1✔
635

636

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

642

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

647

648
@rule(desc="Preparing Corepack managed tool.")
10✔
649
async def prepare_corepack_tool(
10✔
650
    request: CorepackToolRequest, environment: NodeJSProcessEnvironment, nodejs: NodeJS
651
) -> CorepackToolDigest:
652
    version = request.version or nodejs.package_managers.get(request.tool)
10✔
653
    tool_spec = f"{request.tool}@{version}" if version else request.tool
10✔
654
    tool_description = tool_spec if version else f"default {tool_spec} version"
10✔
655
    result = await fallible_to_exec_result_or_raise(
10✔
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)
10✔
671

672

673
@rule(level=LogLevel.DEBUG)
10✔
674
async def setup_node_tool_process(
10✔
675
    request: NodeJSToolProcess, environment: NodeJSProcessEnvironment
676
) -> Process:
677
    if request.tool in ("npm", "npx", "pnpm", "yarn"):
10✔
678
        tool_name = request.tool.replace("npx", "npm")
10✔
679
        corepack_tool = await prepare_corepack_tool(
10✔
680
            CorepackToolRequest(tool_name, request.tool_version), **implicitly()
681
        )
682
        input_digest = await merge_digests(
10✔
683
            MergeDigests([request.input_digest, corepack_tool.digest])
684
        )
685
    else:
686
        input_digest = request.input_digest
×
687
    return Process(
10✔
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]):
10✔
703
    pass
10✔
704

705

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

710

711
def rules() -> Iterable[Rule | UnionRule]:
10✔
712
    return (
10✔
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