# Copyright Kevin Deldycke <[email protected]> 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
import json
import re
from functools import cached_property
from extra_platforms import ALL_PLATFORMS
from ..capabilities import search_capabilities, version_not_implemented
from ..execution import CLIError
from ..manager import PackageManager
TYPE_CHECKING = False
if TYPE_CHECKING:
from collections.abc import Iterator
from ..package import Package
NOT_FOUND_STATUSES = frozenset({400, 401, 404})
"""Registry answers that Yarn Classic reads as "no such package".
Its request manager resolves a request answered with one of these codes to an
empty result instead of an error. See
[`request-manager.js`](https://github.com/yarnpkg/yarn/blob/740c38c3a962c30ddb344a919bbfb7065620714b/src/util/request-manager.js#L454-L456).
"""
def _json_records(stream: str) -> Iterator[dict]:
"""Yield the JSON objects of a `--json` stream, skipping every other line.
Node writes its own warnings to the same streams, as plain text.
"""
for line in stream.splitlines():
try:
record = json.loads(line)
except ValueError:
continue
if isinstance(record, dict):
yield record
[docs]
class Yarn(PackageManager):
"""Virtual base shared by Yarn Classic and Yarn Berry.
The two Yarn lines grew incompatible CLIs, so mpm models them as separate
managers, {class}`YarnClassic` and {class}`YarnBerry`, both invoking the
same `yarn` binary. The reported version decides which one is active: Classic
claims the `1.x` range, Berry `2.x` and later. Only the shared cache-cleanup
operation lives on this base.
Command equivalences with the sibling JS managers are listed in
[antfu-collective/ni](https://github.com/antfu-collective/ni?tab=readme-ov-file#ni).
"""
logo = "yarn"
platforms = ALL_PLATFORMS
virtual = True
[docs]
def cleanup_cache(self) -> None:
"""Removes things we don't need anymore.
See [`yarn cache clean`](https://yarnpkg.com/cli/cache/clean).
```{code-block} shell-session
$ yarn cache clean --all
yarn cache v1.22.19
success Cleared cache.
✨ Done in 0.35s.
```
"""
self.run_cli("cache", "clean", "--all")
[docs]
class YarnClassic(Yarn):
"""Yarn Classic, the `1.x` line.
mpm claims this class for any `yarn` binary reporting a `1.x` version and
drives it through the `yarn global` command family, so installs, upgrades and
removals target the global prefix. Its `--json` output is a stream of one JSON
object per line, not a single document, so every query is parsed line by line.
```{note}
Yarn has [no dedicated search command](https://github.com/yarnpkg/yarn/issues/778#issuecomment-253146299) by
maintainer decision, so `search` is simulated with `yarn info` and only
resolves exact package names.
```
"""
maintenance_note = (
"Yarn Classic (`1.x`) is [frozen](https://github.com/yarnpkg/yarn), taking "
"only security fixes; the actively developed "
"[Yarn Berry](https://github.com/yarnpkg/berry) line has a different CLI and "
"is wrapped separately as the `yarn-berry` manager."
)
id = "yarn"
name = "Yarn Classic"
homepage_url = "https://yarnpkg.com"
documentation_url = "https://classic.yarnpkg.com/en/docs/cli/"
repository_url = "https://github.com/yarnpkg/yarn"
wikipedia_url = "https://en.wikipedia.org/wiki/Yarn_(package_manager)"
requirement = ">=1.20.0,<2.0.0"
cli_names = ("yarn",)
pre_args = ("--silent",)
# Yarn prints its routine warnings on <stderr> even under `--silent`, like
# `package.json: No license field` for the global folder, and Node adds its
# own runtime warnings there. `outdated` exits 1 whenever an update exists,
# so none of these lines may read as a failure.
stderr_noise = re.compile(
r'^(?:\{"type":"warning",|warning |\(node:\d+\) |\(Use `node --trace-)'
)
_INSTALLED_REGEXP = re.compile(
r"^.+\"data\":\"\\\"(?P<package_id>\S+)"
r"@(?P<version>\S+)\\\" has binaries:\"\}$",
re.MULTILINE,
)
"""
```{code-block} shell-session
$ yarn --version
1.22.11
```
"""
_REQUEST_STATUS_REGEXP = re.compile(
r"finished with status code (?P<status>\d+)\.$",
)
@property
def installed(self) -> Iterator[Package]:
"""Fetch installed packages.
```{code-block} shell-session
$ yarn --silent global --json list --depth 0
{"type":"activityStart","data":{"id":0}}
{"type":"activityTick","data":{"id":0,"name":"awesome-lint@^0.18.0"}}
{"type":"activityTick","data":{"id":0,"name":"arrify@^2.0.1"}}
{"type":"activityTick","data":{"id":0,"name":"case@^1.6.3"}}
{"type":"activityTick","data":{"id":0,"name":"emoji-regex@^9.2.0"}}
{"type":"activityEnd","data":{"id":0}}
{"type":"progressStart","data":{"id":0,"total":327}}
{"type":"progressTick","data":{"id":0,"current":1}}
{"type":"progressTick","data":{"id":0,"current":2}}
{"type":"progressTick","data":{"id":0,"current":3}}
{"type":"progressTick","data":{"id":0,"current":4}}
{"type":"progressTick","data":{"id":0,"current":5}}
{"type":"progressFinish","data":{"id":0}}
{"type":"info","data":"\"[email protected]\" has binaries:"}
{"type":"list","data":{"type":"bins-awesome-lint","items":["awesome-lint"]}}
```
```{code-block} console
$ yarn global list --depth 0
yarn global v1.22.19
info "[email protected]" has binaries:
- awesome-lint
✨ Done in 0.13s.
```
"""
output = self.run_cli(
"global", "--json", "list", "--depth", "0", must_succeed=True
)
for package_id, version in self._INSTALLED_REGEXP.findall(output):
yield self.package(id=package_id, installed_version=version)
[docs]
@cached_property
def global_dir(self) -> str:
"""Locate the global directory.
```{code-block} shell-session
$ yarn global dir
~/.config/yarn/global
```
"""
return self.run_cli("global", "dir", force_exec=True).rstrip()
@property
def outdated(self) -> Iterator[Package]:
"""Fetch outdated packages.
Yarn emits its `--json` output as a stream of one JSON object per line
(the sample below elides the `info` color-legend object; the
human-readable transcript further down shows it):
```{code-block} shell-session
$ yarn --silent --json outdated --cwd ~/.config/yarn/global
{"type":"warning","data":"package.json: No license field"}
{"type":"table","data":{"head":["Package","Current","Wanted","Latest","Package Type","URL"],"body":[["markdown","0.4.0","0.4.0","0.5.0","dependencies","git://github.com/evilstreak/markdown-js.git"]]}}
```
```{code-block} console
$ yarn outdated --cwd ~/.config/yarn/global
yarn outdated v1.22.19
warning package.json: No license field
info Color legend :
"<red>" : Major Update backward-incompatible updates
"<yellow>" : Minor Update backward-compatible features
"<green>" : Patch Update backward-compatible bug fixes
Package Current Wanted Latest Package Type URL
markdown 0.4.0 0.4.0 0.5.0 dependencies git://github.com/.../md-js.git
✨ Done in 0.95s.
```
```{note}
`yarn outdated` exits `1` whenever an update exists, with its warnings on
`<stderr>`. Those warnings are
{attr}`~meta_package_manager.execution.CLIExecutor.stderr_noise`, so the
exit reads as a status. A failed registry request prints an `error`
record there instead, and still fails the run.
```
"""
output = self.run_cli(
"--json", "outdated", "--cwd", self.global_dir, must_succeed=True
)
if output:
for line in output.splitlines():
if not line:
continue
obj = json.loads(line)
if obj["type"] == "table":
for package in obj["data"]["body"]:
if package[2] == "linked":
continue
yield self.package(
id=package[0],
installed_version=package[1],
latest_version=package[3],
)
[docs]
@search_capabilities(extended_support=False, exact_support=False)
def search(self, query: str, extended: bool, exact: bool) -> Iterator[Package]:
r"""Fetch matching packages.
```{warning}
Yarn maintainers have [decided to not implement a dedicated search command](https://github.com/yarnpkg/yarn/issues/778#issuecomment-253146299).
Search is simulated by a direct call to `yarn info`, and as a result only
works for exact match.
```
```{note}
`yarn info` prints the same `Received invalid response from npm.` error
record, and exits `0`, both for a package that does not exist and for a
registry request that failed. `--verbose` adds the status code each
request finished with, which tells them apart: a
{data}`~meta_package_manager.managers.yarn.NOT_FOUND_STATUSES` answer is
a package that does not exist, while any other answer, or none at all,
raises a {class}`~meta_package_manager.execution.CLIError` naming it.
```
```{code-block} shell-session
$ yarn --silent --json --verbose info @bouzuya/borage
{"type":"verbose","data":"Checking for configuration file \"~/.npmrc\"."}
{"type":"verbose","data":"Found configuration file \"~/.npmrc\"."}
{"type":"verbose","data":"Checking for configuration file \"~/.npmrc\"."}
{"type":"verbose","data":"Found configuration file \"~/.npmrc\"."}
{"type":"verbose","data":"Checking for configuration file \"/opt/homebrew/Cellar/node/26.9.0/etc/npmrc\"."}
{"type":"verbose","data":"Checking for configuration file \"~/.npmrc\"."}
{"type":"verbose","data":"Found configuration file \"~/.npmrc\"."}
{"type":"verbose","data":"Checking for configuration file \"/Users/.npmrc\"."}
{"type":"verbose","data":"Checking for configuration file \"~/.yarnrc\"."}
{"type":"verbose","data":"Checking for configuration file \"~/.yarnrc\"."}
{"type":"verbose","data":"Checking for configuration file \"/opt/homebrew/Cellar/node/26.9.0/etc/yarnrc\"."}
{"type":"verbose","data":"Checking for configuration file \"~/.yarnrc\"."}
{"type":"verbose","data":"Checking for configuration file \"/Users/.yarnrc\"."}
{"type":"verbose","data":"current time: 2026-09-19T18:18:56.700Z"}
{"type":"verbose","data":"Performing \"GET\" request to \"https://registry.yarnpkg.com/@bouzuya%2fborage\"."}
{"type":"verbose","data":"Request \"https://registry.yarnpkg.com/@bouzuya%2fborage\" finished with status code 200."}
{"type":"inspect","data":{"name":"@bouzuya/borage","description":"A GitHub Pages deployer for bouzuya/blog.bouzuya.net","dist-tags":{"latest":"3.1.2"},"versions":["3.1.2"],"maintainers":[{"name":"bouzuya","email":"[email protected]"}],"time":{"modified":"2022-06-12T15:26:55.277Z","created":"2017-05-21T23:27:30.332Z","3.1.2":"2017-05-21T23:27:30.332Z"},"homepage":"https://github.com/bouzuya/borage","keywords":["bouzuya","bbn"],"repository":{"type":"git","url":"git+https://github.com/bouzuya/borage.git"},"author":{"name":"bouzuya","email":"[email protected]","url":"http://bouzuya.net"},"bugs":{"url":"https://github.com/bouzuya/borage/issues"},"license":"MIT","readmeFilename":"README.md","version":"3.1.2","dependencies":{"aws-sdk":"^2.1.30","es6-promise":"^2.1.1","glob":"^5.0.9","mime":"^1.3.4"},"devDependencies":{"coffee-script":"^1.9.2","del":"^1.1.1","gulp":"^3.8.11","gulp-coffee":"^2.3.1","gulp-concat":"^2.5.2","gulp-espower":"^0.10.1","gulp-mocha":"^2.0.1","gulp-sourcemaps":"^1.5.2","gulp-uglify":"^1.2.0","gulp-util":"^3.0.4","gulp-watch":"^4.2.4","power-assert":"^0.11.0","run-sequence":"^1.1.0","sinon":"^1.14.1"},"main":"index.js","scripts":{"build":"gulp build","clean":"gulp clean","start":"gulp","test":"gulp test","watch":"gulp watch"},"gitHead":"0f45887778f89ccf9a17e0a097067ae085c515e5","dist":{"shasum":"9be1578d9d2859833cca435d3501d439f6f27489","tarball":"https://registry.npmjs.org/@bouzuya/borage/-/borage-3.1.2.tgz","integrity":"sha512-gBp5eS2+5VSQqqgHs7fBwesw+ZhbfwewPoiyaINPIchhiIkAjnJ8gPN7RsBqzUftZNXF04x5HuHZG9xe/PHptw==","signatures":[{"keyid":"SHA256:jl3bwswu80PjjokCgh0o2w5c2U4LhQAE57gj9cz1kzA","sig":"MEQCIBDqfLdL4pGDRKMfJ3HvGGE6tmWoM2uKV8jZWAVshLaKAiAhfqB0YTsKYVz2SUSa1Jz/ZiBPlkF3jAPGqV7UN+lpBw=="}]}}}
```
"""
previous = self._last_run
output = self.run_cli("--json", "--verbose", "info", query, must_succeed=True)
# Only the run this call produced says anything about this query.
if self._last_run is not previous:
self._raise_hidden_failure(self._last_run)
for record in _json_records(output):
if record.get("type") == "inspect":
package = record["data"]
yield self.package(
id=package["name"],
description=package["description"],
latest_version=package["version"],
)
def _raise_hidden_failure(self, run: tuple[int, str, str] | None) -> None:
"""Raise the registry failure a `yarn info` run reported on a zero exit.
The failure is relayed at `WARNING` and recorded in
{attr}`~meta_package_manager.execution.CLIExecutor.cli_errors`, as the
executor's own failure gate does for a non-zero exit.
"""
if run is None:
return
code, output, error = run
messages = [
str(record.get("data"))
for record in _json_records(error)
if record.get("type") == "error"
]
if not messages:
return
statuses = []
for record in _json_records(output):
if record.get("type") != "verbose":
continue
match = self._REQUEST_STATUS_REGEXP.search(str(record.get("data")))
if match:
statuses.append(int(match.group("status")))
if statuses and statuses[-1] in NOT_FOUND_STATUSES:
return
if statuses:
answer = f"The registry answered HTTP {statuses[-1]}."
else:
answer = "The registry did not answer."
exception = CLIError(code, output, " ".join((*messages, answer)))
self._relay_failure(exception, is_escalation=False)
self.cli_errors.append(exception)
raise exception
[docs]
@version_not_implemented
def install(self, package_id: str, version: str | None = None) -> str:
"""Install one package.
```{code-block} shell-session
$ yarn --silent global add awesome-lint
yarn global v1.22.19
[1/4] 🔍 Resolving packages...
[2/4] 🚚 Fetching packages...
[3/4] 🔗 Linking dependencies...
[4/4] 🔨 Building fresh packages...
success Installed "[email protected]" with binaries:
- awesome-lint
✨ Done in 16.15s.
```
"""
return self.run_cli("global", "add", package_id)
[docs]
@version_not_implemented
def upgrade_one_cli(
self,
package_id: str,
version: str | None = None,
) -> tuple[str, ...]:
"""Generates the CLI to upgrade the provided package.
```{code-block} shell-session
$ yarn --silent global upgrade markdown --latest
yarn global v1.22.19
[1/4] 🔍 Resolving packages...
[2/4] 🚚 Fetching packages...
[3/4] 🔗 Linking dependencies...
[4/4] 🔨 Rebuilding all packages...
success Saved lockfile.
success Saved 2 new dependencies.
info Direct dependencies
└─ [email protected]
info All dependencies
├─ [email protected]
└─ [email protected]
✨ Done in 1.77s.
```
"""
return self.build_cli("global", "upgrade", package_id, "--latest")
[docs]
def remove(self, package_id: str) -> str:
"""Remove one package.
```{code-block} shell-session
$ yarn --silent global remove awesome-lint
yarn global v1.22.19
[1/2] 🗑 Removing module awesome-lint...
[2/2] 🔨 Regenerating lockfile and installing missing dependencies...
success Uninstalled packages.
✨ Done in 0.21s.
```
"""
return self.run_cli("global", "remove", package_id)
[docs]
class YarnBerry(Yarn):
"""Yarn Berry, the `2.x` and later line.
mpm claims this class for any `yarn` binary reporting a `2.x` or newer
version.
```{warning}
Yarn Berry removed the `yarn global` command family entirely: it has no
notion of globally installed packages. Only `search` is available, while
`installed`, `outdated`, `install`, `upgrade` and `remove` are all
unsupported.
```
```{note}
`search` is simulated with `yarn npm info` and only resolves exact
package names.
```
"""
id = "yarn-berry"
name = "Yarn Berry"
homepage_url = "https://yarnpkg.com"
documentation_url = "https://yarnpkg.com/cli"
repository_url = "https://github.com/yarnpkg/berry"
wikipedia_url = "https://en.wikipedia.org/wiki/Yarn_(package_manager)"
requirement = ">=2.0.0"
cli_names = ("yarn",)
[docs]
@search_capabilities(extended_support=False, exact_support=False)
def search(self, query: str, extended: bool, exact: bool) -> Iterator[Package]:
"""Fetch matching packages.
```{warning}
Search is simulated by a direct call to `yarn npm info`, and as a
result only works for exact match.
```
```{code-block} shell-session
$ yarn npm info @bouzuya/borage --json
{"name":"@bouzuya/borage","description":"A GitHub Pages deployer for bouzuya/blog.bouzuya.net","dist-tags":{"latest":"3.1.2"},"versions":["3.1.2"],"readme":"\n\nborage is a deployer for [blog.bouzuya.net](http://blog.bouzuya.net).\n\nSee: [bouzuya/blog.bouzuya.net][]\n\n## Installation\n\n $ npm install https://github.com/bouzuya/borage/archive/master.tar.gz\n\nor\n\n $ npm install https://github.com/bouzuya/borage/archive/{VERSION}.tar.gz\n\n## License\n\n[MIT](LICENSE)\n\n## Author\n\n[bouzuya][user] <[[email protected]][email]> ([http://bouzuya.net][url])\n\n[user]: https://github.com/bouzuya\n[email]: mailto:[email protected]\n[url]: http://bouzuya.net\n[bouzuya/blog.bouzuya.net]: https://github.com/bouzuya/blog.bouzuya.net\n[bouzuya/grunt-tentacles]: https://github.com/bouzuya/grunt-tentacles\n[bouzuya/kraken]: https://github.com/bouzuya/kraken\n","maintainers":[{"name":"bouzuya","email":"[email protected]"}],"time":{"modified":"2022-06-12T15:26:55.277Z","created":"2017-05-21T23:27:30.332Z","3.1.2":"2017-05-21T23:27:30.332Z"},"homepage":"https://github.com/bouzuya/borage","keywords":["bouzuya","bbn"],"repository":{"type":"git","url":"git+https://github.com/bouzuya/borage.git"},"author":{"name":"bouzuya","email":"[email protected]","url":"http://bouzuya.net"},"bugs":{"url":"https://github.com/bouzuya/borage/issues"},"license":"MIT","readmeFilename":"README.md","version":"3.1.2","dependencies":{"aws-sdk":"^2.1.30","es6-promise":"^2.1.1","glob":"^5.0.9","mime":"^1.3.4"},"devDependencies":{"coffee-script":"^1.9.2","del":"^1.1.1","gulp":"^3.8.11","gulp-coffee":"^2.3.1","gulp-concat":"^2.5.2","gulp-espower":"^0.10.1","gulp-mocha":"^2.0.1","gulp-sourcemaps":"^1.5.2","gulp-uglify":"^1.2.0","gulp-util":"^3.0.4","gulp-watch":"^4.2.4","power-assert":"^0.11.0","run-sequence":"^1.1.0","sinon":"^1.14.1"},"main":"index.js","scripts":{"build":"gulp build","clean":"gulp clean","start":"gulp","test":"gulp test","watch":"gulp watch"},"gitHead":"0f45887778f89ccf9a17e0a097067ae085c515e5","dist":{"shasum":"9be1578d9d2859833cca435d3501d439f6f27489","tarball":"https://registry.npmjs.org/@bouzuya/borage/-/borage-3.1.2.tgz","integrity":"sha512-gBp5eS2+5VSQqqgHs7fBwesw+ZhbfwewPoiyaINPIchhiIkAjnJ8gPN7RsBqzUftZNXF04x5HuHZG9xe/PHptw==","signatures":[{"keyid":"SHA256:jl3bwswu80PjjokCgh0o2w5c2U4LhQAE57gj9cz1kzA","sig":"MEQCIBDqfLdL4pGDRKMfJ3HvGGE6tmWoM2uKV8jZWAVshLaKAiAhfqB0YTsKYVz2SUSa1Jz/ZiBPlkF3jAPGqV7UN+lpBw=="}]}}
```
"""
output = self.run_cli("npm", "info", query, "--json")
package = self.parse_json(output)
if not isinstance(package, dict):
return
package_id = package.get("name")
if package_id:
yield self.package(
id=package_id,
description=package.get("description"),
latest_version=package.get("dist-tags", {}).get("latest")
or package.get("version"),
)