# 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 re
from typing import ClassVar
from extra_platforms import LINUX_LIKE, MACOS, WINDOWS
from ..capabilities import version_not_implemented
from ..manager import PackageManager
TYPE_CHECKING = False
if TYPE_CHECKING:
from collections.abc import Iterator
from ..package import Package
DRY_RUN_UPDATES_FOUND = 3
"""Exit code `bin update --dry-run` uses to report that updates exist.
A dry run finding nothing exits `0`, and finding something exits this instead,
so neither is a failure. Anything else is.
"""
[docs]
class Bin(PackageManager):
"""Installer of binaries straight from GitHub releases and similar sources.
```{caution}
The package identifier is the absolute path of the installed binary, which
is what the listing reports and what `remove` and `update` accept. A
source spec (`github.com/junegunn/fzf`, a release-tag URL, `goinstall://…`)
does not resolve outside the install command itself.
```
```{caution}
A bin that has never been configured prompts for its download directory on
*every* command and cannot be driven until someone answers once
interactively. Until then `mpm` treats the manager as unavailable rather
than reporting zero packages.
```
No escalation is ever needed: bin installs into a directory it picked from
`$PATH` for being writable.
"""
# The path identifier is forced rather than preferred: bin names a
# package differently depending on the verb. Installing takes a source
# spec (`github.com/junegunn/fzf`, a release-tag URL, `goinstall://…`,
# `docker://…`, a vendor host), while everything afterwards is keyed on
# the installed path. Handing a source spec back to `remove` does not
# resolve, and a bare basename resolves through `$PATH` first, so a
# managed binary shadowed by another copy on `$PATH` fails outright. The
# absolute path is the only identifier every non-installing operation
# accepts.
#
# That asymmetry is also why `install` is not implemented: no identifier
# the listing reports can be handed to it, so `mpm` could never install
# what it had just listed. Installing through bin stays a
# `bin install <spec>` the user runs themselves, and `mpm` reports and
# maintains the result.
#
# `bin prune` is left alone too, since it drops configuration entries
# whose file has vanished rather than cleaning up packages.
operation_notes: ClassVar = {
"install": (
"No identifier the listing reports can be handed back to the "
"install command, which takes a source spec."
),
"search": ("There is no registry to search, only sources the user names."),
"sync": "There is no index to refresh.",
}
name = "bin"
repository_url = "https://github.com/marcosnils/bin"
platforms = LINUX_LIKE, MACOS, WINDOWS
requirement = ">=0.27.0"
"""The release whose listing layout {meth}`~meta_package_manager.manager.PackageManager.installed` reproduces: it reserves
a leading column of the version field for the pin marker. The parser tolerates
the older unreserved layout too, but the floor tracks what is verified.
"""
extra_env: ClassVar = {
# bin colors on its own initiative whenever `CI` is non-empty, which
# every GitHub Actions run sets. This keeps the stdout table clean, but
# only `fatih/color`, which styles the version strings, honors it: the
# `caarlos0/log` lines of the stderr report keep their color, and
# `CLIExecutor.run` strips them like every capture.
"NO_COLOR": "1",
}
version_regexes = (r"^bin version (?P<version>\S+)",)
r"""Search the version on the first line.
```{code-block} shell-session
$ bin --version
bin version 0.29.1
commit: c24db4aced89c855062fe8e2907ae0deb3fb9f53
built at: 2026-08-02T13:37:10Z
built by: goreleaser
```
Four lines are printed and the version is the third word of the first, bare
and without a `v`. There is no `version` subcommand.
"""
_INSTALLED_REGEXP = re.compile(
r"^(?P<package_id>\S.*?)\s{2,}\*?(?P<installed_version>\S+)\s{2,}\S+\s{2,}"
r"(?:OK|missing .*)$",
)
"""One row of the installed table.
A four-column table padded with at least two spaces between cells, each column
as wide as its widest value, so no column sits at a fixed offset and the
separator is the only thing worth anchoring on. Three details drive the
pattern. The first column is a path and may itself contain a single space, so
it is matched lazily up to the first run of two. The version is preceded by a
reserved column carrying `*` when the binary is pinned, which the optional
marker consumes so it never lands in the version. And the status column is
either `OK` or `missing <path>`, the latter carrying spaces of its own, so the
pattern is anchored on it at the end rather than counting fields. A row whose
version or URL is empty collapses to three columns and correctly fails to
match, rather than silently shifting a URL into the version field.
"""
_OUTDATED_REGEXP = re.compile(
r"^\s*•\s+(?P<package_id>.+?)\s+(?P<installed_version>\S+)\s+->\s+"
r"(?P<latest_version>\S+)\s+\(\S+\)$",
)
"""One upgradable binary of the dry-run report.
Bulleted rather than tabulated, since this is a log stream rather than a
table. The path is matched lazily and the versions are anchored on the arrow
between them, so a path carrying a space is read correctly instead of shifting
a fragment into the installed version. The run's closing `command failed` line
carries no arrow and is skipped by not matching.
"""
@property
def installed(self) -> Iterator[Package]:
"""Fetch installed packages.
```{code-block} shell-session
$ bin list
/home/user/.local/bin/fzf v0.74.2 github.com/junegunn/fzf OK
/home/user/.local/bin/rg 14.1.1 https://github.com/BurntSushi/ripgrep/releases/tag/14.1.1 OK
/home/user/.local/bin/terraform *1.5.7 releases.hashicorp.com/terraform OK
```
"""
output = self.run_cli("list")
yield from self.parse_regex_lines(self._INSTALLED_REGEXP, output)
@property
def outdated(self) -> Iterator[Package]:
"""Fetch outdated packages.
This is the operation that keeps bin a class rather than a definition, on
two counts a fixed argument list cannot express.
```{important}
The report is written to `stderr` while `stdout` stays empty, and the run
exits `3` when it finds anything to update. Both are deliberate on bin's
side: `0` means everything is current, so a zero exit is the *empty*
answer here and only a code that is neither means the check itself
failed. The entries the failing exit records are discarded, since this one
is a result rather than an error.
```
```{code-block} shell-session
$ bin update --dry-run
```
```{code-block} console
• /home/user/.local/bin/fzf v0.40.0 -> v0.74.2 (https://github.com/junegunn/fzf/releases/tag/v0.74.2)
• /home/user/.local/bin/gh v2.40.0 -> v2.97.0 (https://github.com/cli/cli/releases/tag/v2.97.0)
⨯ command failed error=Updates found, exit (dry-run mode).
```
```{note}
A pinned binary is reported as pinned and skipped before its version is
ever checked, so it never appears here. That matches the listing, which
marks it with a `*`.
```
"""
before = len(self.cli_errors)
self.run_cli("update", "--dry-run")
last = self._last_run
if last is None:
return
code, _stdout, stderr = last
if code not in (0, DRY_RUN_UPDATES_FOUND):
return
# The dry run reports through its exit code, so the entry a non-zero one
# recorded is not a failure to carry into mpm's own error tally.
del self.cli_errors[before:]
yield from self.parse_regex_lines(self._OUTDATED_REGEXP, stderr)
[docs]
def upgrade_all_cli(self) -> tuple[str, ...]:
"""Generates the CLI to upgrade all packages.
`--yes` answers the single batch confirmation `update` asks before
applying, and `--continue-on-error` keeps one failing binary from
abandoning the rest of the run.
```{code-block} shell-session
$ bin update --yes --continue-on-error
```
"""
return self.build_cli("update", "--yes", "--continue-on-error")
[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.
Unlike some whole-system upgraders, naming a binary restricts the run
to it.
```{code-block} shell-session
$ bin update --yes --continue-on-error /home/user/.local/bin/fzf
```
"""
return self.build_cli("update", "--yes", "--continue-on-error", package_id)
[docs]
def remove(self, package_id: str) -> str:
"""Removes a package.
```{warning}
`remove` reports a name it could not resolve on `stderr` and still exits
`0`, so a zero exit here means the command ran, not that anything was
removed. Confirm a removal by listing again.
```
```{code-block} shell-session
$ bin remove /home/user/.local/bin/fzf
```
"""
return self.run_cli("remove", package_id)