# Copyright Kevin Deldycke <kevin@deldycke.com> and contributors.
#
# This program is Free Software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
from __future__ import annotations
from extra_platforms import ALL_PLATFORMS
from ..capabilities import search_capabilities, version_not_implemented
from ..manager import PackageManager
TYPE_CHECKING = False
if TYPE_CHECKING:
from collections.abc import Iterator
from ..package import Package
[docs]
class PNPM(PackageManager):
"""A Node.js package manager with a content-addressable global store.
Like {class}`meta_package_manager.managers.npm.NPM`, mpm drives pnpm in
global mode (`--global` on every operation) and parses its `--json` output.
Command equivalences with the sibling JS managers are listed in
[antfu-collective/ni](https://github.com/antfu-collective/ni?tab=readme-ov-file#ni).
```{note}
pnpm enforces a supply-chain cooldown through its `minimumReleaseAge`
setting (counted in minutes), refusing to install any release published
more recently than the configured age. The version floor is set by
`search`, which first shipped in `11.0.0`; that release also clears the
earlier `minimumReleaseAge` floor, so one requirement guards every
advertised operation.
```
```{caution}
`pnpm outdated` exits `1` when it finds outdated packages, printing the
report to `<stdout>` with an empty `<stderr>`. The query passes
`must_succeed` so this benign non-zero exit is tolerated instead of
raising.
```
"""
name = "Node pnpm"
homepage_url = "https://pnpm.io"
logo = "pnpm"
platforms = ALL_PLATFORMS
requirement = ">=11.0.0"
"""[11.0.0](https://github.com/pnpm/pnpm/releases/tag/v11.0.0) is the first
version to ship the `search` subcommand. It also clears the `10.16.0` floor of
`minimumReleaseAge`, the release-age gate mpm drives for the supply-chain
cooldown (see {attr}`cooldown_env_var`), so a single floor covers every
advertised operation. Older pnpm releases either lack `search` or silently
ignore the cooldown setting.
"""
cooldown_env_var = "pnpm_config_minimum_release_age"
"""pnpm honors a release-age cooldown through its `minimumReleaseAge` setting.
pnpm reads any setting from an environment variable built by snake-casing the
setting name behind a `pnpm_config_` prefix (the docs render `pmOnFail` as
`pnpm_config_pm_on_fail`), so `pnpm_config_minimum_release_age` sets
`minimumReleaseAge` without touching `pnpm-workspace.yaml`. Once set, pnpm
refuses to install any version published more recently than the configured age,
across direct and transitive dependencies.
`minimumReleaseAge` is expressed in minutes, so {meth}`cooldown_env_value`
is overridden to emit a minute count.
See pnpm's [`minimumReleaseAge` setting](https://pnpm.io/settings#minimumreleaseage).
"""
[docs]
def cooldown_env_value(self) -> str:
"""Render {attr}`meta_package_manager.execution.CLIExecutor.cooldown` as an
integer minute count for pnpm's `minimumReleaseAge`.
Sub-minute cooldowns round up so the gate over-protects rather than silently
collapsing to `0` (the "no cooldown" sentinel).
"""
return self.cooldown_rounded_up(60)
@property
def installed(self) -> Iterator[Package]:
"""Fetch installed packages.
`pnpm list --json` returns an array of project objects; the global scope
resolves to a single one whose `dependencies` map holds the installed
packages.
```{code-block} shell-session
$ pnpm list --global --json --depth 0
[
{
"name": "global",
"dependencies": {
"eslint": {
"from": "eslint",
"version": "9.15.0"
},
"typescript": {
"from": "typescript",
"version": "5.6.3"
}
}
}
]
```
"""
output = self.run_cli(
"list", "--global", "--json", "--depth", "0", must_succeed=True
)
data = self.parse_json(output)
if data:
for project in data:
for pkg_id, pkg_infos in project.get("dependencies", {}).items():
yield self.package(
id=pkg_id,
installed_version=pkg_infos["version"],
)
@property
def outdated(self) -> Iterator[Package]:
"""Fetch outdated packages.
`pnpm outdated` exits with code `1` when it finds outdated packages, but
writes the report to `<stdout>` and leaves `<stderr>` empty. Passing
`must_succeed` keeps the lenient failure gate that tolerates a non-zero
exit with an empty `<stderr>` as a benign status code, so the call does
not raise (see {meth}`meta_package_manager.execution.CLIExecutor.run`).
```{code-block} shell-session
$ pnpm outdated --global --json
{
"eslint": {
"current": "9.10.0",
"latest": "9.15.0",
"wanted": "9.15.0",
"isDeprecated": false,
"dependencyType": "dependencies"
}
}
```
"""
output = self.run_cli("outdated", "--global", "--json", must_succeed=True)
data = self.parse_json(output)
if data:
for pkg_id, pkg_infos in data.items():
yield self.package(
id=pkg_id,
installed_version=pkg_infos.get("current"),
latest_version=pkg_infos["latest"],
)
[docs]
@search_capabilities(exact_support=False)
def search(self, query: str, extended: bool, exact: bool) -> Iterator[Package]:
"""Fetch matching packages.
pnpm queries the registry's `/-/v1/search` endpoint and, with `--json`,
emits an array of the matched packages (an empty `[]` when none match).
```{caution}
Search does not support exact matching: the registry endpoint matches on
names, descriptions and keywords, so the framework refilters the raw
results for exact queries.
```
```{code-block} shell-session
$ pnpm search --json is-positive
[
{
"name": "is-positive",
"version": "3.1.0",
"description": "Check if something is a positive number",
"date": "2017-10-24T15:24:08.180Z",
"maintainers": [
{
"username": "sindresorhus"
}
]
}
]
```
"""
output = self.run_cli("search", "--json", query, must_succeed=True)
data = self.parse_json(output)
if data:
for pkg_infos in data:
yield self.package(
id=pkg_infos["name"],
description=pkg_infos.get("description"),
latest_version=pkg_infos["version"],
)
[docs]
@version_not_implemented
def install(self, package_id: str, version: str | None = None) -> str:
"""Install one package.
```{code-block} shell-session
$ pnpm add --global markdown
```
"""
return self.run_cli("add", "--global", package_id)
[docs]
def upgrade_all_cli(self) -> tuple[str, ...]:
"""Generates the CLI to upgrade all packages.
```{code-block} shell-session
$ pnpm update --global --latest
```
"""
return self.build_cli("update", "--global", "--latest")
[docs]
@version_not_implemented
def upgrade_one_cli(
self,
package_id: str,
version: str | None = None,
) -> tuple[str, ...]:
"""Generates the CLI to upgrade the package provided as parameter.
```{code-block} shell-session
$ pnpm update --global --latest markdown
```
"""
return self.build_cli("update", "--global", "--latest", package_id)
[docs]
def remove(self, package_id: str) -> str:
"""Remove one package.
```{code-block} shell-session
$ pnpm remove --global markdown
```
"""
return self.run_cli("remove", "--global", package_id)
[docs]
def cleanup_cache(self) -> None:
"""Remove orphan packages from the global content-addressable store.
```{code-block} shell-session
$ pnpm store prune
```
"""
self.run_cli("store", "prune")