# 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
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
_ANSI_REGEXP = re.compile(r"\x1b\[[0-9;]*m")
"""Style sequences to strip before parsing the update report.
bin colors its two streams through separate libraries, and only one of them
honors `NO_COLOR`. That one is `fatih/color`, governing the version strings; the
`caarlos0/log` lines carrying them are styled independently and keep their color
whenever `CI` is non-empty, which every GitHub Actions run sets. The escape lands
on the bullet the pattern anchors on, so it is removed rather than matched
around.
"""
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 `list` reports and what `remove` and `update` accept. That choice 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.
```
```{note}
That asymmetry is also why `install` is not implemented: no identifier `list`
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.
No `search`: bin has no registry to search, only sources the user names. No
`sync`: there is no index to refresh. `bin prune` is left alone too, since it
drops configuration entries whose file has vanished rather than cleaning up
packages.
```
```{caution}
A bin that has never been configured prompts for its download directory on
*every* command, the listing included, and cannot be driven until someone
answers once interactively. `mpm` sees that as a failed version probe and
treats the manager as unavailable, which is the right outcome: an
uninitialised bin has no inventory to report, and reporting zero packages
would be a lie.
```
No escalation: bin installs into a directory it picked from `$PATH` for being
writable, and never needs root.
Documentation: [bin](https://github.com/marcosnils/bin).
"""
name = "bin"
homepage_url = "https://github.com/marcosnils/bin"
platforms = LINUX_LIKE, MACOS, WINDOWS
requirement = ">=0.27.0"
"""The release whose listing layout {meth}`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. This keeps
# the stdout table clean; the stderr report is stripped separately, that
# stream's styling being beyond this lever's reach.
"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,
_ANSI_REGEXP.sub("", 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 genuinely 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)