diff --git a/README.md b/README.md index 2dcedb6..8a25888 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,10 @@ print(prettier.abspath, prettier.version) # ~/.cache/abx/lib/npm/bin/prettier 2.2.1 prettier.exec(['--write', '.']) + +# Search a provider's package index for matches: +matches = npm.search('puppeteer') # -> list[Binary] with name + install_args populated +puppeteer = matches[0].install() if matches else None # install the top match via npm ``` > 📦 Provides consistent interfaces for runtime dependency resolution & installation across multiple package managers & OSs @@ -111,6 +115,9 @@ abxpkg uninstall yt-dlp abxpkg load yt-dlp abxpkg env yt-dlp abxpkg activate yt-dlp + +abxpkg search chromium # search all providers in parallel +abxpkg --binproviders=apt,npm,brew search node # restrict to specific providers ``` `abxpkg --version` and `abxpkg version` stream the package version first, then a host/env summary line, then one section per selected provider showing its current resolved runtime state (`INSTALLER_BINARY`, `PATH`, `ENV`, `install_root`, `bin_dir`, and any active cached dependency / installed binaries). diff --git a/abxpkg/binprovider.py b/abxpkg/binprovider.py index a895efa..5328ded 100755 --- a/abxpkg/binprovider.py +++ b/abxpkg/binprovider.py @@ -25,6 +25,7 @@ Protocol, runtime_checkable, TypeVar, + TYPE_CHECKING, ) from collections.abc import Callable, Iterable, Mapping @@ -32,6 +33,9 @@ from typing import Self from pathlib import Path +if TYPE_CHECKING: + from .binary import Binary + from pydantic_core import ValidationError from pydantic import ( BaseModel, @@ -416,6 +420,7 @@ class BinProvider(BaseModel): "update": "self.default_update_handler", "uninstall": "self.default_uninstall_handler", "docs_url": "self.default_docs_url_handler", + "search": "self.default_search_handler", }, }, repr=False, @@ -1399,6 +1404,17 @@ def default_uninstall_handler( self.INSTALLER_BINARY(no_cache=no_cache) return False + def default_search_handler( + self, + bin_name: BinName, + min_version: SemVer | None = None, + min_release_age: float | None = None, + timeout: int | None = None, + **context, + ) -> "SearchFuncReturnValue": + """Default search handler. Providers without a real search backend return [].""" + return [] + @log_method_call() def invalidate_cache(self, bin_name: BinName) -> None: if self._cache: @@ -2554,6 +2570,86 @@ def uninstall( logger.info("🗑️ Uninstalled %s via %s", bin_name, self.name) return uninstall_result is not False + @final + @log_method_call(include_result=True) + def search( + self, + bin_name: BinName, + min_version: SemVer | None = None, + min_release_age: float | None = None, + no_cache: bool = False, + timeout: int | None = None, + ) -> "list[Binary]": + """Search this provider's package index for matches of bin_name. + + Returns a list of non-loaded Binary objects (one per matching package), + each with binproviders=[self] and overrides set so binary.install() + will install the matched package via this provider. Empty list when + the provider has no search backend or finds no matches. + """ + from .binary import Binary + + try: + results = cast( + SearchFuncReturnValue, + self._call_handler_for_action( + bin_name=bin_name, + handler_type="search", + min_version=min_version, + min_release_age=min_release_age, + # Use ``install_timeout`` rather than ``version_timeout`` + # because search hits remote indexes (apt repos, + # nix flake registry, npm registry, PyPI, etc.) and + # the 10s ``version_timeout`` default is too tight + # for first-pull of e.g. the nixpkgs flake. + timeout=timeout or self.install_timeout, + no_cache=no_cache, + ), + ) + except Exception as err: + logger.debug( + "%s failed to search for %s: %s", + self.name, + bin_name, + err, + ) + return [] + + if not results: + return [] + # Each handler returns Binary objects directly so per-provider + # logic can hydrate name/install_args/description from real + # search output without indirection. + binaries: list[Binary] = [ + result for result in results if isinstance(result, Binary) + ] + if min_version is None: + return binaries + # Filter by ``min_version`` centrally — handlers stash the + # discovered package version somewhere in ``description`` (by + # convention, the leading token, but we scan all whitespace- + # separated tokens so handlers that put the version after a + # module/image ref don't bypass the filter). Drop entries whose + # discovered version falls below the floor; keep entries with + # no parseable version (treat as version-unknown rather than + # version-too-low) so providers without per-result version + # output don't get over-filtered. + filtered: list[Binary] = [] + for binary in binaries: + discovered = next( + ( + parsed + for token in binary.description.split() + for parsed in (SemVer.parse(token.strip("(),")),) + if parsed is not None + ), + None, + ) + if discovered is not None and discovered < min_version: + continue + filtered.append(binary) + return filtered + @final @log_method_call(include_result=True) @validate_call @@ -2563,10 +2659,54 @@ def load( quiet: bool = True, no_cache: bool = False, ) -> ShallowBinary | None: - installed_abspath = self.get_abspath(bin_name, quiet=quiet, no_cache=no_cache) - if not installed_abspath: + # When we have a managed ``bin_dir``, that's the only path we + # ever load from — iterating ``get_abspaths`` would silently + # surface ambient PATH candidates that this provider didn't + # install, breaking install_root isolation. For ambient-only + # providers (``bin_dir is None``, e.g. + # ``EnvProvider(bin_dir=None)``), walk every ``get_abspaths`` + # candidate so a broken-on-PATH binary (e.g. linuxbrew cargo + # with a stale ``libllhttp.so``) doesn't shadow a working one. + if self.bin_dir is None: + candidates = self.get_abspaths(bin_name, no_cache=no_cache) + else: + primary_abspath = self.get_abspath( + bin_name, + quiet=quiet, + no_cache=no_cache, + ) + candidates = [primary_abspath] if primary_abspath else [] + if not candidates: return None + for candidate_abspath in candidates: + result = self._try_load_at_abspath( + bin_name, + candidate_abspath, + quiet=quiet, + no_cache=no_cache, + ) + if result is not None: + logger.info( + format_loaded_binary( + "☑️ Loaded", + candidate_abspath, + result.loaded_version, + self, + str(bin_name), + ), + extra={"abx_cli_duplicate_stdout": True}, + ) + return result + return None + def _try_load_at_abspath( + self, + bin_name: BinName, + installed_abspath: HostBinPath, + *, + quiet: bool = True, + no_cache: bool = False, + ) -> ShallowBinary | None: result = ( None if no_cache else self.load_cached_binary(bin_name, installed_abspath) ) @@ -2616,17 +2756,6 @@ def load( "binproviders": [self], }, ) - - logger.info( - format_loaded_binary( - "☑️ Loaded", - installed_abspath, - result.loaded_version, - self, - str(bin_name), - ), - extra={"abx_cli_duplicate_stdout": True}, - ) return result @@ -2652,6 +2781,7 @@ class EnvProvider(BinProvider): "update": "self.update_noop", "uninstall": "self.uninstall_noop", "docs_url": "self.default_docs_url_handler", + "search": "self.default_search_handler", }, "python": { "abspath": "self.python_abspath_handler", @@ -2998,6 +3128,7 @@ def write_cached_binary( InstallFuncReturnValue = str | None ActionFuncReturnValue = str | bool | None DocsUrlFuncReturnValue = str | None +SearchFuncReturnValue = list[ShallowBinary] | tuple[ShallowBinary, ...] | None ProviderFuncReturnValue = ( AbspathFuncReturnValue | VersionFuncReturnValue @@ -3005,6 +3136,7 @@ def write_cached_binary( | InstallFuncReturnValue | ActionFuncReturnValue | DocsUrlFuncReturnValue + | SearchFuncReturnValue ) @@ -3079,6 +3211,18 @@ def __call__( ) -> "DocsUrlFuncReturnValue": ... +@runtime_checkable +class SearchFuncWithArgs(Protocol): + def __call__( + _self, + binprovider: "BinProvider", + bin_name: BinName, + min_version: SemVer | None = None, + min_release_age: float | None = None, + **context: Any, + ) -> "SearchFuncReturnValue": ... + + AbspathFuncWithNoArgs = Callable[[], AbspathFuncReturnValue] VersionFuncWithNoArgs = Callable[[], VersionFuncReturnValue] InstallArgsFuncWithNoArgs = Callable[[], InstallArgsFuncReturnValue] @@ -3086,6 +3230,7 @@ def __call__( InstallFuncWithNoArgs = Callable[[], InstallFuncReturnValue] ActionFuncWithNoArgs = Callable[[], ActionFuncReturnValue] DocsUrlFuncWithNoArgs = Callable[[], DocsUrlFuncReturnValue] +SearchFuncWithNoArgs = Callable[[], SearchFuncReturnValue] AbspathHandlerValue = ( SelfMethodName @@ -3121,6 +3266,9 @@ def __call__( | DocsUrlFuncWithArgs | DocsUrlFuncReturnValue ) +SearchHandlerValue = ( + SelfMethodName | SearchFuncWithNoArgs | SearchFuncWithArgs | SearchFuncReturnValue +) HandlerType = Literal[ "abspath", @@ -3131,6 +3279,7 @@ def __call__( "update", "uninstall", "docs_url", + "search", ] HandlerValue = ( AbspathHandlerValue @@ -3139,6 +3288,7 @@ def __call__( | InstallHandlerValue | ActionHandlerValue | DocsUrlHandlerValue + | SearchHandlerValue ) HandlerReturnValue = ( AbspathFuncReturnValue @@ -3147,6 +3297,7 @@ def __call__( | InstallFuncReturnValue | ActionFuncReturnValue | DocsUrlFuncReturnValue + | SearchFuncReturnValue ) @@ -3169,6 +3320,7 @@ class HandlerDict(TypedDict, total=False): update: ActionHandlerValue uninstall: ActionHandlerValue docs_url: DocsUrlHandlerValue + search: SearchHandlerValue # Binary.overrides map BinProviderName:ProviderFieldOrHandlerPatch diff --git a/abxpkg/binprovider_apt.py b/abxpkg/binprovider_apt.py index 2d73f68..bd19237 100755 --- a/abxpkg/binprovider_apt.py +++ b/abxpkg/binprovider_apt.py @@ -9,7 +9,7 @@ from .base_types import BinProviderName, PATHStr, BinName, InstallArgs from .semver import SemVer -from .binprovider import BinProvider, EnvProvider, remap_kwargs +from .binprovider import BinProvider, EnvProvider, ShallowBinary, remap_kwargs from .logging import format_subprocess_output _LAST_UPDATE_CHECK = None @@ -247,6 +247,46 @@ def default_update_handler( or f"Updated {install_args} successfully." ) + def default_search_handler( + self, + bin_name: BinName, + min_version: SemVer | None = None, + min_release_age: float | None = None, + timeout: int | None = None, + **context, + ) -> list[ShallowBinary]: + """Search apt's package index for packages whose name matches bin_name (substring).""" + from .binary import Binary + + # ``apt-cache search --names-only`` returns lines like `` - ``. + # Routing through ``self.exec`` lets apt's setup_PATH/INSTALLER_BINARY + # auto-recover from a missing/broken apt-get on the ambient PATH + # (e.g. CI runners where the linuxbrew copy is unusable). The + # deadlock filter in ``BinProvider.INSTALLER_BINARY`` keeps it + # safe under restrictive ``--binproviders`` configs. + self.INSTALLER_BINARY(no_cache=bool(context.get("no_cache", False))) + proc = self.exec( + bin_name="apt-cache", + cmd=["search", "--names-only", str(bin_name)], + quiet=True, + timeout=timeout, + ) + results: list[ShallowBinary] = [] + for line in proc.stdout.splitlines(): + pkg_name, _, description = line.partition(" - ") + pkg_name = pkg_name.strip() + if not pkg_name or str(bin_name) not in pkg_name: + continue + results.append( + Binary( + name=pkg_name, + description=description.strip(), + binproviders=[self], + overrides={self.name: {"install_args": [pkg_name]}}, + ), + ) + return results + @remap_kwargs({"packages": "install_args"}) def default_uninstall_handler( self, diff --git a/abxpkg/binprovider_bash.py b/abxpkg/binprovider_bash.py index ad94c52..ce6bd1b 100755 --- a/abxpkg/binprovider_bash.py +++ b/abxpkg/binprovider_bash.py @@ -59,6 +59,7 @@ class BashProvider(EnvProvider): "update": "self.default_update_handler", "uninstall": "self.default_uninstall_handler", "docs_url": "self.default_docs_url_handler", + "search": "self.default_search_handler", }, } diff --git a/abxpkg/binprovider_brew.py b/abxpkg/binprovider_brew.py index 013fefe..b52b9fc 100755 --- a/abxpkg/binprovider_brew.py +++ b/abxpkg/binprovider_brew.py @@ -481,6 +481,49 @@ def default_uninstall_handler( return True + def default_search_handler( + self, + bin_name: BinName, + min_version: SemVer | None = None, + min_release_age: float | None = None, + timeout: int | None = None, + **context, + ) -> list: + """Search homebrew formulae for packages whose name matches bin_name (substring).""" + from .binary import Binary + + # Use ``self.INSTALLER_BINARY`` so brew's auto-install logic + # kicks in if env's brew is missing/broken. The deadlock filter + # in ``BinProvider.INSTALLER_BINARY`` keeps this safe under + # restrictive ``--binproviders`` configs. + installer = self.INSTALLER_BINARY(no_cache=bool(context.get("no_cache", False))) + assert installer and installer.loaded_abspath + # ``brew search --formula `` returns one matching formula name per line. + proc = self.exec( + bin_name=installer.loaded_abspath, + cmd=["search", "--formula", str(bin_name)], + quiet=True, + timeout=timeout, + ) + results: list = [] + for line in proc.stdout.splitlines(): + pkg_name = line.strip().rstrip(" *") + if ( + not pkg_name + or str(bin_name) not in pkg_name + or pkg_name.startswith("=") + ): + continue + results.append( + Binary( + name=pkg_name, + description=f"{pkg_name} ({self.name} formula)", + binproviders=[self], + overrides={self.name: {"install_args": [pkg_name]}}, + ), + ) + return results + def default_abspath_handler( self, bin_name: BinName | HostBinPath, diff --git a/abxpkg/binprovider_bun.py b/abxpkg/binprovider_bun.py index 893ae9b..5fa00be 100755 --- a/abxpkg/binprovider_bun.py +++ b/abxpkg/binprovider_bun.py @@ -5,6 +5,8 @@ import json import os import sys +import urllib.request +import urllib.parse from pathlib import Path from typing import Self @@ -226,6 +228,53 @@ def setup( self.bin_dir.mkdir(parents=True, exist_ok=True) (self.install_root / "install").mkdir(parents=True, exist_ok=True) + def default_search_handler( + self, + bin_name: BinName, + min_version: SemVer | None = None, + min_release_age: float | None = None, + timeout: int | None = None, + **context, + ) -> list: + """Search the npm registry directly (bun has no ``bun search`` subcommand). + + # same npm-registry-search implementation copy-pasted across + # YarnProvider, BunProvider, DenoProvider — each provider owns + # its own copy so they stay isolated and don't import shared + # helpers per repo policy. + """ + from .binary import Binary + + # Bun installs from the npm registry, so the underlying API is the + # registry's own search endpoint. + url = ( + "https://registry.npmjs.org/-/v1/search?text=" + + urllib.parse.quote(str(bin_name)) + + "&size=25" + ) + with urllib.request.urlopen( + url, + timeout=timeout or self.version_timeout, + ) as resp: + data = json.loads(resp.read().decode("utf-8")) + results: list = [] + for entry in data.get("objects", []): + pkg = entry.get("package", {}) + pkg_name = pkg.get("name", "") + if not pkg_name or str(bin_name).lower() not in pkg_name.lower(): + continue + version_str = pkg.get("version", "") + description = pkg.get("description", "") or pkg_name + results.append( + Binary( + name=pkg_name, + description=f"{version_str} - {description}".strip(" -"), + binproviders=[self], + overrides={self.name: {"install_args": [pkg_name]}}, + ), + ) + return results + @remap_kwargs({"packages": "install_args"}) def default_install_handler( self, diff --git a/abxpkg/binprovider_cargo.py b/abxpkg/binprovider_cargo.py index bd53393..c78f622 100755 --- a/abxpkg/binprovider_cargo.py +++ b/abxpkg/binprovider_cargo.py @@ -265,6 +265,46 @@ def default_docs_url_handler( return None return f"https://crates.io/crates/{package}" + def default_search_handler( + self, + bin_name: str, + min_version: SemVer | None = None, + min_release_age: float | None = None, + timeout: int | None = None, + **context, + ) -> list: + """Search crates.io for crates whose name matches bin_name.""" + from .binary import Binary + + installer = self.INSTALLER_BINARY(no_cache=bool(context.get("no_cache", False))) + assert installer and installer.loaded_abspath + # ``cargo search`` returns lines like: + # = "" # + proc = self.exec( + bin_name=installer.loaded_abspath, + cmd=["search", "--limit", "25", str(bin_name)], + quiet=True, + timeout=timeout, + ) + results: list = [] + for line in proc.stdout.splitlines(): + if "=" not in line or '"' not in line: + continue + crate_name = line.split("=", 1)[0].strip() + version_str = line.split('"', 2)[1] if '"' in line else "" + description = line.split("# ", 1)[1].strip() if "# " in line else "" + if not crate_name or str(bin_name) not in crate_name: + continue + results.append( + Binary( + name=crate_name, + description=f"{version_str} - {description}".strip(" -"), + binproviders=[self], + overrides={self.name: {"install_args": [crate_name]}}, + ), + ) + return results + @remap_kwargs({"packages": "install_args"}) def default_install_handler( self, diff --git a/abxpkg/binprovider_chromewebstore.py b/abxpkg/binprovider_chromewebstore.py index c7f331a..437597b 100755 --- a/abxpkg/binprovider_chromewebstore.py +++ b/abxpkg/binprovider_chromewebstore.py @@ -4,7 +4,9 @@ import json import os +import re import shutil +import urllib.request from pathlib import Path from typing import Any, Self @@ -65,6 +67,7 @@ class ChromeWebstoreProvider(BinProvider): "update": "self.chromewebstore_install_handler", "uninstall": "self.chromewebstore_uninstall_handler", "docs_url": "self.default_docs_url_handler", + "search": "self.chromewebstore_search_handler", }, } @@ -157,6 +160,58 @@ def default_docs_url_handler( return f"https://chromewebstore.google.com/detail/{slug}/{webstore_id}" return f"https://chromewebstore.google.com/detail/{webstore_id}" + def chromewebstore_search_handler( + self, + bin_name: BinName, + min_version=None, + min_release_age=None, + timeout: int | None = None, + **context, + ) -> list: + """Resolve a Chrome Web Store extension by its 32-char ID. + + The Web Store has no JSON search API, but the public detail page + ``https://chromewebstore.google.com/detail/`` always returns + the canonical extension name in its ```` tag, so we hit + that to translate an extension ID into a human-readable name. + Non-ID queries return an empty list — ID-based lookup is the + only thing the Web Store reliably exposes. + """ + from .binary import Binary + + query = str(bin_name).strip() + if not re.fullmatch(r"[a-p]{32}", query): + return [] + url = f"https://chromewebstore.google.com/detail/{query}" + try: + with urllib.request.urlopen( + url, + timeout=timeout or self.version_timeout, + ) as resp: + html = resp.read().decode("utf-8", errors="ignore") + except Exception: + return [] + match = re.search(r"<title>([^<]+?)\s*-\s*Chrome Web Store", html) + if not match: + return [] + extension_name = match.group(1).strip() + # Use the stable webstore ID as ``Binary.name`` (BinName accepts + # only filename-safe chars/length); the human title goes in + # ``description`` and the ``--name=`` install_arg so install + # writes shims/metadata under the title-derived alias. + return [ + Binary( + name=query, + description=f"{extension_name} ({query})", + binproviders=[self], + overrides={ + self.name: { + "install_args": [query, f"--name={extension_name}"], + }, + }, + ), + ] + def _cached_extension(self, bin_name: str) -> dict[str, Any]: """Load the persisted extension metadata JSON for a cached extension, if any.""" bin_dir = self.bin_dir diff --git a/abxpkg/binprovider_deno.py b/abxpkg/binprovider_deno.py index 030e8b8..353542f 100755 --- a/abxpkg/binprovider_deno.py +++ b/abxpkg/binprovider_deno.py @@ -2,8 +2,11 @@ __package__ = "abxpkg" +import json import os import sys +import urllib.request +import urllib.parse from pathlib import Path from typing import Self @@ -219,6 +222,53 @@ def setup( self.bin_dir.mkdir(parents=True, exist_ok=True) self.cache_dir.mkdir(parents=True, exist_ok=True) + def default_search_handler( + self, + bin_name: BinName, + min_version: SemVer | None = None, + min_release_age: float | None = None, + timeout: int | None = None, + **context, + ) -> list: + """Search the npm registry directly (deno install -g supports npm: specifiers). + + # same npm-registry-search implementation copy-pasted across + # YarnProvider, BunProvider, DenoProvider — each provider owns + # its own copy so they stay isolated and don't import shared + # helpers per repo policy. + """ + from .binary import Binary + + # Deno can install from JSR or npm; we hit the npm registry's search + # endpoint here so the resulting Binary uses ``npm:`` install_args. + url = ( + "https://registry.npmjs.org/-/v1/search?text=" + + urllib.parse.quote(str(bin_name)) + + "&size=25" + ) + with urllib.request.urlopen( + url, + timeout=timeout or self.version_timeout, + ) as resp: + data = json.loads(resp.read().decode("utf-8")) + results: list = [] + for entry in data.get("objects", []): + pkg = entry.get("package", {}) + pkg_name = pkg.get("name", "") + if not pkg_name or str(bin_name).lower() not in pkg_name.lower(): + continue + version_str = pkg.get("version", "") + description = pkg.get("description", "") or pkg_name + results.append( + Binary( + name=pkg_name, + description=f"{version_str} - {description}".strip(" -"), + binproviders=[self], + overrides={self.name: {"install_args": [f"npm:{pkg_name}"]}}, + ), + ) + return results + @remap_kwargs({"packages": "install_args"}) def default_install_handler( self, diff --git a/abxpkg/binprovider_docker.py b/abxpkg/binprovider_docker.py index b07db48..c653ead 100755 --- a/abxpkg/binprovider_docker.py +++ b/abxpkg/binprovider_docker.py @@ -207,6 +207,58 @@ def _should_repair_failed_pull(output: str) -> bool: ) ) + def default_search_handler( + self, + bin_name: BinName, + min_version: SemVer | None = None, + min_release_age: float | None = None, + timeout: int | None = None, + **context, + ) -> list: + """Search Docker Hub for images whose name matches bin_name (substring).""" + from .binary import Binary + + # Use ``self.INSTALLER_BINARY`` so docker's auto-install logic + # kicks in if env's docker is missing/broken. + installer = self.INSTALLER_BINARY(no_cache=bool(context.get("no_cache", False))) + assert installer and installer.loaded_abspath + # ``docker search --format '{{json .}}' `` emits one JSON object per match. + proc = self.exec( + bin_name=installer.loaded_abspath, + cmd=["search", "--limit", "25", "--format", "{{json .}}", str(bin_name)], + quiet=True, + timeout=timeout, + ) + if proc.returncode != 0: + return [] + results: list = [] + for line in proc.stdout.splitlines(): + line = line.strip() + if not line: + continue + try: + entry = json.loads(line) + except json.JSONDecodeError: + continue + image_name = entry.get("Name", "") + description = entry.get("Description", "") or image_name + if not image_name or str(bin_name).lower() not in image_name.lower(): + continue + # ``Name`` from docker search is the full ``namespace/image`` + # ref; the leaf is what we use as ``Binary.name`` so shim and + # metadata file writes (which use the bin_name as filename) + # don't fail on slashes. Full ref is preserved in install_args. + leaf_name = image_name.rsplit("/", 1)[-1] + results.append( + Binary( + name=leaf_name, + description=f"{image_name} - {description}".strip(" -"), + binproviders=[self], + overrides={self.name: {"install_args": [f"{image_name}:latest"]}}, + ), + ) + return results + @remap_kwargs({"packages": "install_args"}) def default_install_handler( self, diff --git a/abxpkg/binprovider_gem.py b/abxpkg/binprovider_gem.py index aebecaa..e1d1bb4 100755 --- a/abxpkg/binprovider_gem.py +++ b/abxpkg/binprovider_gem.py @@ -212,6 +212,48 @@ def default_docs_url_handler( return None return f"https://rubygems.org/gems/{package}" + def default_search_handler( + self, + bin_name: str, + min_version: SemVer | None = None, + min_release_age: float | None = None, + timeout: int | None = None, + **context, + ) -> list: + """Search rubygems.org for gems whose name matches bin_name (substring).""" + from .binary import Binary + + # Use ``self.INSTALLER_BINARY`` so gem's auto-install logic + # kicks in if env's gem is missing/broken. + installer = self.INSTALLER_BINARY(no_cache=bool(context.get("no_cache", False))) + assert installer and installer.loaded_abspath + # ``gem search`` interprets the query as a regex and returns: + # ( [], ...) + proc = self.exec( + bin_name=installer.loaded_abspath, + cmd=["search", str(bin_name), "--remote", "--no-prerelease"], + quiet=True, + timeout=timeout, + ) + results: list = [] + for line in proc.stdout.splitlines(): + line = line.strip() + if not line or "(" not in line or not line.endswith(")"): + continue + gem_name = line.split("(", 1)[0].strip() + version_str = line.split("(", 1)[1].rsplit(")", 1)[0].split(",")[0].strip() + if not gem_name or str(bin_name) not in gem_name: + continue + results.append( + Binary( + name=gem_name, + description=f"{version_str} ({self.name})", + binproviders=[self], + overrides={self.name: {"install_args": [gem_name]}}, + ), + ) + return results + @remap_kwargs({"packages": "install_args"}) def default_install_handler( self, diff --git a/abxpkg/binprovider_goget.py b/abxpkg/binprovider_goget.py index 1df9eb1..3eb50cb 100755 --- a/abxpkg/binprovider_goget.py +++ b/abxpkg/binprovider_goget.py @@ -56,6 +56,7 @@ class GoGetProvider(BinProvider): "update": "self.default_update_handler", "uninstall": "self.default_uninstall_handler", "docs_url": "self.default_docs_url_handler", + "search": "self.default_search_handler", }, "go": { "version": ["go", "version"], @@ -218,6 +219,58 @@ def default_docs_url_handler( return f"https://pkg.go.dev/{module_path}" return None + def default_search_handler( + self, + bin_name: str, + min_version: SemVer | None = None, + min_release_age: float | None = None, + timeout: int | None = None, + **context, + ) -> list: + """Resolve the latest tagged version for an exact Go module path. + + Go has no native package-name search; ``go list -m -versions `` + is the closest underlying API and only works for exact module paths + like ``github.com/foo/bar``. + """ + from .binary import Binary + + # Use ``self.INSTALLER_BINARY`` so goget's auto-install logic + # kicks in if env's go is missing/broken. (``go`` is special: + # it doesn't accept ``--version`` — the override on this + # provider's ``"go": {"version": ["go", "version"]}`` map is + # what makes ``self.INSTALLER_BINARY`` resolve cleanly.) + installer = self.INSTALLER_BINARY(no_cache=bool(context.get("no_cache", False))) + assert installer and installer.loaded_abspath + proc = self.exec( + bin_name=installer.loaded_abspath, + cmd=["list", "-m", "-versions", str(bin_name)], + quiet=True, + timeout=timeout, + ) + if proc.returncode != 0 or not proc.stdout.strip(): + return [] + # Output: " v0.1 v0.2 v1.0 ..." + parts = proc.stdout.split() + module_path = parts[0] + latest_version = parts[-1] if len(parts) > 1 else "" + install_arg = ( + f"{module_path}@{latest_version}" if latest_version else module_path + ) + # ``go install`` writes the binary under its leaf name (e.g. + # ``github.com/spf13/cobra-cli`` → ``cobra-cli``). Use the leaf as + # the Binary name so a follow-up ``.install()`` loads the actual + # produced binary, but keep the full module path in install_args. + binary_name = module_path.rsplit("/", 1)[-1] + return [ + Binary( + name=binary_name, + description=f"{latest_version} - {module_path}".strip(" -"), + binproviders=[self], + overrides={self.name: {"install_args": [install_arg]}}, + ), + ] + @remap_kwargs({"packages": "install_args"}) def default_install_handler( self, diff --git a/abxpkg/binprovider_nix.py b/abxpkg/binprovider_nix.py index ba6f24c..af994ae 100755 --- a/abxpkg/binprovider_nix.py +++ b/abxpkg/binprovider_nix.py @@ -212,6 +212,90 @@ def default_docs_url_handler( package = package.split("#", 1)[-1].split("^", 1)[0] return f"https://search.nixos.org/packages?show={package}&query={package}" + def default_search_handler( + self, + bin_name: str, + min_version: SemVer | None = None, + min_release_age: float | None = None, + timeout: int | None = None, + **context, + ) -> list: + """Search nixpkgs for attributes whose name matches bin_name (substring).""" + from .binary import Binary + + # Use ``self.INSTALLER_BINARY`` so nix's auto-install logic + # kicks in if env's nix is missing/broken. + installer = self.INSTALLER_BINARY(no_cache=bool(context.get("no_cache", False))) + assert installer and installer.loaded_abspath + # ``nix search`` against a flake (e.g. ``nixpkgs#``) evaluates + # the entire flake via the daemon and runs OOM on small CI + # runners (rc=-9 / SIGKILL on the GitHub-hosted x86_64 hosts). + # Direct ``nix eval nixpkgs#`` only evaluates the + # single requested attribute path, so it's bounded in memory + # and finishes in ~seconds even on the cold flake-fetch run. + # Filter env exactly like ``default_install_handler`` does + # (drop GH/GITHUB tokens + NIX_REMOTE). + env = { + key: value + for key, value in os.environ.items() + if key not in {"GH_TOKEN", "GITHUB_TOKEN", "NIX_REMOTE"} + } + proc = self.exec( + bin_name=installer.loaded_abspath, + cmd=[ + "eval", + "--extra-experimental-features", + "nix-command flakes", + "--json", + "--apply", + "p: { pname = p.pname or p.name or null; " + "version = p.version or null; " + "description = (p.meta or {}).description or null; }", + f"nixpkgs#{bin_name}", + ], + env=env, + quiet=True, + timeout=timeout, + ) + if proc.returncode != 0: + # ``nix eval`` exits non-zero when the requested attribute + # path doesn't exist in the flake — exact-match miss is a + # legitimate "no result" outcome, not a failure to log. Only + # warn when the failure mode looks genuinely broken + # (daemon socket, network, registry) so CI logs surface the + # real cause instead of every legit miss. + stderr_text = (proc.stderr or "").lower() + looks_like_attribute_miss = ( + "does not provide attribute" in stderr_text + or "missing attribute" in stderr_text + or "evaluation aborted" in stderr_text + ) + if not looks_like_attribute_miss: + logger.warning( + "nix search failed for %r (rc=%s):\n%s", + str(bin_name), + proc.returncode, + format_subprocess_output(proc.stdout, proc.stderr), + ) + return [] + try: + info = json.loads(proc.stdout) or {} + except json.JSONDecodeError: + return [] + if not isinstance(info, dict): + return [] + pname = info.get("pname") or str(bin_name) + version_str = info.get("version") or "" + description = info.get("description") or pname + return [ + Binary( + name=pname, + description=f"{version_str} - {description}".strip(" -"), + binproviders=[self], + overrides={self.name: {"install_args": [f"nixpkgs#{bin_name}"]}}, + ), + ] + @remap_kwargs({"packages": "install_args"}) def default_install_handler( self, diff --git a/abxpkg/binprovider_npm.py b/abxpkg/binprovider_npm.py index 0841068..411f407 100755 --- a/abxpkg/binprovider_npm.py +++ b/abxpkg/binprovider_npm.py @@ -401,6 +401,48 @@ def _refresh_bin_link( link_path.symlink_to(target) return TypeAdapter(HostBinPath).validate_python(link_path) + def default_search_handler( + self, + bin_name: BinName, + min_version: SemVer | None = None, + min_release_age: float | None = None, + timeout: int | None = None, + **context, + ) -> list: + """Search the npm registry via ``npm search ... --json``.""" + from .binary import Binary + + # Use ``self.INSTALLER_BINARY`` so npm's auto-install logic + # kicks in if env's npm is missing/broken. + installer = self.INSTALLER_BINARY(no_cache=bool(context.get("no_cache", False))) + assert installer and installer.loaded_abspath + proc = self.exec( + bin_name=installer.loaded_abspath, + cmd=["search", str(bin_name), "--json", "--searchlimit=25"], + quiet=True, + timeout=timeout, + ) + try: + entries = json.loads(proc.stdout) + except json.JSONDecodeError: + return [] + results: list = [] + for entry in entries: + pkg_name = entry.get("name", "") + if not pkg_name or str(bin_name).lower() not in pkg_name.lower(): + continue + version_str = entry.get("version", "") + description = entry.get("description", "") or pkg_name + results.append( + Binary( + name=pkg_name, + description=f"{version_str} - {description}".strip(" -"), + binproviders=[self], + overrides={self.name: {"install_args": [pkg_name]}}, + ), + ) + return results + @remap_kwargs({"packages": "install_args"}) def default_install_handler( self, diff --git a/abxpkg/binprovider_pip.py b/abxpkg/binprovider_pip.py index 2817c07..c6c8fa3 100755 --- a/abxpkg/binprovider_pip.py +++ b/abxpkg/binprovider_pip.py @@ -406,6 +406,52 @@ def _security_flags( flags.append(f"--uploaded-prior-to={cutoff}") return flags + def default_search_handler( + self, + bin_name: BinName, + min_version: SemVer | None = None, + min_release_age: float | None = None, + timeout: int | None = None, + **context, + ) -> list: + """Resolve the latest published version for an exact PyPI package name. + + ``pip search`` was deprecated, so we use ``pip index versions `` + which performs an exact-name lookup against the configured index. No + substring/prefix matching: PyPI itself doesn't expose one without + scraping. + """ + from .binary import Binary + + # Use ``self.INSTALLER_BINARY`` so pip's auto-install logic + # kicks in if env's pip is missing/broken. + installer = self.INSTALLER_BINARY(no_cache=bool(context.get("no_cache", False))) + assert installer and installer.loaded_abspath + proc = self.exec( + bin_name=installer.loaded_abspath, + cmd=["index", "versions", str(bin_name)], + quiet=True, + timeout=timeout, + ) + if proc.returncode != 0: + return [] + # Output: + # () + # Available versions: , , ... + first_line = proc.stdout.strip().splitlines()[0] if proc.stdout.strip() else "" + if "(" not in first_line or ")" not in first_line: + return [] + pkg_name = first_line.split("(", 1)[0].strip() + version_str = first_line.split("(", 1)[1].rsplit(")", 1)[0].strip() + return [ + Binary( + name=pkg_name, + description=f"{version_str} - PyPI package", + binproviders=[self], + overrides={self.name: {"install_args": [pkg_name]}}, + ), + ] + @remap_kwargs({"packages": "install_args"}) def default_install_handler( self, diff --git a/abxpkg/binprovider_playwright.py b/abxpkg/binprovider_playwright.py index 496eea4..b95a265 100755 --- a/abxpkg/binprovider_playwright.py +++ b/abxpkg/binprovider_playwright.py @@ -642,6 +642,44 @@ def default_abspath_handler( except OSError: return resolved + def default_search_handler( + self, + bin_name: BinName, + min_version: SemVer | None = None, + min_release_age: float | None = None, + timeout: int | None = None, + **context, + ) -> list: + """Match bin_name against playwright's fixed list of supported browsers. + + Playwright doesn't have a package index — only a small hardcoded set + of browsers it knows how to install — so we match the query as a + substring against that set and return a Binary per match. + """ + from .binary import Binary + + supported = ( + "chromium", + "chromium-headless-shell", + "chrome", + "chrome-beta", + "msedge", + "msedge-beta", + "msedge-dev", + "firefox", + "webkit", + ) + return [ + Binary( + name=browser, + description=f"playwright browser ({browser})", + binproviders=[self], + overrides={self.name: {"install_args": [browser]}}, + ) + for browser in supported + if str(bin_name) in browser + ] + @remap_kwargs({"packages": "install_args"}) def default_install_handler( self, diff --git a/abxpkg/binprovider_pnpm.py b/abxpkg/binprovider_pnpm.py index 53ad850..d29d740 100755 --- a/abxpkg/binprovider_pnpm.py +++ b/abxpkg/binprovider_pnpm.py @@ -298,6 +298,48 @@ def _refresh_bin_link( link_path.symlink_to(target) return TypeAdapter(HostBinPath).validate_python(link_path) + def default_search_handler( + self, + bin_name: BinName, + min_version: SemVer | None = None, + min_release_age: float | None = None, + timeout: int | None = None, + **context, + ) -> list: + """Search the npm registry via ``pnpm search ... --json``.""" + from .binary import Binary + + # Use ``self.INSTALLER_BINARY`` so pnpm's auto-install logic + # kicks in if env's pnpm is missing/broken. + installer = self.INSTALLER_BINARY(no_cache=bool(context.get("no_cache", False))) + assert installer and installer.loaded_abspath + proc = self.exec( + bin_name=installer.loaded_abspath, + cmd=["search", str(bin_name), "--json"], + quiet=True, + timeout=timeout, + ) + try: + entries = json.loads(proc.stdout) + except json.JSONDecodeError: + return [] + results: list = [] + for entry in entries: + pkg_name = entry.get("name", "") + if not pkg_name or str(bin_name).lower() not in pkg_name.lower(): + continue + version_str = entry.get("version", "") + description = entry.get("description", "") or pkg_name + results.append( + Binary( + name=pkg_name, + description=f"{version_str} - {description}".strip(" -"), + binproviders=[self], + overrides={self.name: {"install_args": [pkg_name]}}, + ), + ) + return results + @remap_kwargs({"packages": "install_args"}) def default_install_handler( self, diff --git a/abxpkg/binprovider_puppeteer.py b/abxpkg/binprovider_puppeteer.py index 00faf4d..3004dff 100755 --- a/abxpkg/binprovider_puppeteer.py +++ b/abxpkg/binprovider_puppeteer.py @@ -690,6 +690,45 @@ def _run_install_with_sudo( ) return proc + def default_search_handler( + self, + bin_name: BinName, + min_version: SemVer | None = None, + min_release_age: float | None = None, + timeout: int | None = None, + **context, + ) -> list: + """Match bin_name against @puppeteer/browsers's fixed list of supported browsers. + + Like playwright, puppeteer-browsers exposes a closed enum of browser + targets rather than a real package index, so we match the query as a + substring against that enum. + """ + from .binary import Binary + + supported = ( + "chrome", + "chrome-beta", + "chrome-canary", + "chrome-dev", + "chrome-headless-shell", + "chromedriver", + "chromium", + "firefox", + "firefox-beta", + "firefox-nightly", + ) + return [ + Binary( + name=browser, + description=f"puppeteer browser ({browser})", + binproviders=[self], + overrides={self.name: {"install_args": [browser]}}, + ) + for browser in supported + if str(bin_name) in browser + ] + @remap_kwargs({"packages": "install_args"}) def default_install_handler( self, diff --git a/abxpkg/binprovider_uv.py b/abxpkg/binprovider_uv.py index c157508..d3adac7 100755 --- a/abxpkg/binprovider_uv.py +++ b/abxpkg/binprovider_uv.py @@ -1,9 +1,13 @@ #!/usr/bin/env python3 __package__ = "abxpkg" +import json import os import shutil import re import sys +import urllib.error +import urllib.parse +import urllib.request from pathlib import Path from typing import Self from platformdirs import user_cache_path @@ -345,6 +349,44 @@ def _version_from_uv_metadata( return SemVer.parse(parts[1]) return None + def default_search_handler( + self, + bin_name: BinName, + min_version: SemVer | None = None, + min_release_age: float | None = None, + timeout: int | None = None, + **context, + ) -> list: + """Resolve the latest published version for an exact PyPI package name via the PyPI JSON API. + + ``uv`` has no ``uv search`` subcommand and ``uv pip`` no longer + ships ``index versions``, so we hit the same PyPI JSON endpoint + ``uv pip install`` uses to resolve versions. + """ + from .binary import Binary + + url = f"https://pypi.org/pypi/{urllib.parse.quote(str(bin_name))}/json" + try: + with urllib.request.urlopen( + url, + timeout=timeout or self.version_timeout, + ) as resp: + data = json.loads(resp.read().decode("utf-8")) + except urllib.error.HTTPError: + return [] + info = data.get("info", {}) or {} + pkg_name = info.get("name", str(bin_name)) + version_str = info.get("version", "") + summary = info.get("summary", "") or pkg_name + return [ + Binary( + name=pkg_name, + description=f"{version_str} - {summary}".strip(" -"), + binproviders=[self], + overrides={self.name: {"install_args": [pkg_name]}}, + ), + ] + @remap_kwargs({"packages": "install_args"}) def default_install_handler( self, diff --git a/abxpkg/binprovider_yarn.py b/abxpkg/binprovider_yarn.py index 02cf73e..f28ee30 100755 --- a/abxpkg/binprovider_yarn.py +++ b/abxpkg/binprovider_yarn.py @@ -5,6 +5,8 @@ import json import os import sys +import urllib.request +import urllib.parse from pathlib import Path from typing import Self @@ -361,6 +363,54 @@ def setup( + "nodeLinker: node-modules\n", ) + def default_search_handler( + self, + bin_name: BinName, + min_version: SemVer | None = None, + min_release_age: float | None = None, + timeout: int | None = None, + **context, + ) -> list: + """Search the npm registry directly (yarn 4+ removed the search command). + + # same npm-registry-search implementation copy-pasted across + # YarnProvider, BunProvider, DenoProvider — each provider owns + # its own copy so they stay isolated and don't import shared + # helpers per repo policy. + """ + from .binary import Binary + + # Yarn shares the npm public registry, so the underlying API is the + # registry's own search endpoint. Berry/yarn-classic both ship without + # a generic ``yarn search`` command, so we hit the registry directly. + url = ( + "https://registry.npmjs.org/-/v1/search?text=" + + urllib.parse.quote(str(bin_name)) + + "&size=25" + ) + with urllib.request.urlopen( + url, + timeout=timeout or self.version_timeout, + ) as resp: + data = json.loads(resp.read().decode("utf-8")) + results: list = [] + for entry in data.get("objects", []): + pkg = entry.get("package", {}) + pkg_name = pkg.get("name", "") + if not pkg_name or str(bin_name).lower() not in pkg_name.lower(): + continue + version_str = pkg.get("version", "") + description = pkg.get("description", "") or pkg_name + results.append( + Binary( + name=pkg_name, + description=f"{version_str} - {description}".strip(" -"), + binproviders=[self], + overrides={self.name: {"install_args": [pkg_name]}}, + ), + ) + return results + @remap_kwargs({"packages": "install_args"}) def default_install_handler( self, diff --git a/abxpkg/cli.py b/abxpkg/cli.py index 0276c62..0a913b0 100644 --- a/abxpkg/cli.py +++ b/abxpkg/cli.py @@ -23,6 +23,7 @@ from . import ALL_PROVIDER_NAMES, DEFAULT_PROVIDER_NAMES, PROVIDER_CLASS_BY_NAME, Binary from .base_types import DEFAULT_LIB_DIR from .binprovider import DEFAULT_ENV_PATH, BinProvider, HandlerDict, env_flag_is_true +from .semver import SemVer from .config import load_derived_cache from .exceptions import ABXPkgError from .logging import ( @@ -1807,6 +1808,59 @@ def load_command( run_binary_command(binary_name, action="load", options=options) +@cli.command("search") +@click.argument("binary_name") +@click.pass_context +@shared_options +def search_command( + ctx: click.Context, + binary_name: str, + **shared_kwargs: Any, +) -> None: + """Search every selected provider's package index in parallel for binary_name.""" + + from concurrent.futures import ThreadPoolExecutor + + options = get_command_options(ctx, **shared_kwargs) + options = replace(options, dry_run=False) + configure_cli_logging(debug=options.debug) + providers = build_providers( + options.provider_names, + dry_run=False, + install_root=options.install_root, + bin_dir=options.bin_dir, + euid=options.euid, + install_timeout=options.install_timeout, + version_timeout=options.version_timeout, + ) + min_version_str = options.min_version + min_version = SemVer.parse(min_version_str) if min_version_str else None + + def _search(provider: BinProvider) -> tuple[str, list[Binary]]: + try: + return provider.name, list( + provider.search( + binary_name, + min_version=min_version, + min_release_age=options.min_release_age, + no_cache=options.no_cache, + ), + ) + except Exception as err: + logger.debug("%s search for %s failed: %s", provider.name, binary_name, err) + return provider.name, [] + + with ThreadPoolExecutor(max_workers=max(1, len(providers))) as pool: + results = list(pool.map(_search, providers)) + + for provider_name, matches in results: + if not matches: + continue + _echo(f"[{provider_name}]") + for match in matches: + _echo(f" {match.name} {match.description}".rstrip()) + + def _run_command_impl( ctx: click.Context, **shared_kwargs: Any, diff --git a/tests/test_ansibleprovider.py b/tests/test_ansibleprovider.py index c2da3f5..bac2cd8 100644 --- a/tests/test_ansibleprovider.py +++ b/tests/test_ansibleprovider.py @@ -206,3 +206,10 @@ def test_provider_dry_run_does_not_install_package( del test_machine_dependencies provider, package = _ansible_provider_for_host(test_machine) test_machine.exercise_provider_dry_run(provider, bin_name=package) + + def test_search_returns_empty_for_ansible_provider(self): + # AnsibleProvider delegates installs to ansible's own package + # modules (apt, yum, brew, ...). It has no package index of its + # own to search, so search is intentionally an empty list. + assert AnsibleProvider().search("python") == [] + assert AnsibleProvider().search("nonexistent-binary-xyz") == [] diff --git a/tests/test_aptprovider.py b/tests/test_aptprovider.py index f956c72..a7e4a02 100644 --- a/tests/test_aptprovider.py +++ b/tests/test_aptprovider.py @@ -70,6 +70,24 @@ def test_provider_dry_run_does_not_install_package(self, test_machine): bin_name=test_machine.pick_missing_apt_package(), ) + def test_search_finds_real_apt_package_and_install_works(self, test_machine): + test_machine.require_tool("apt-get") + provider = AptProvider(postinstall_scripts=True, min_release_age=0) + results = provider.search("wget") + assert results, "apt-cache search wget should return matches" + names = [r.name for r in results] + assert "wget" in names + wget_match = next(r for r in results if r.name == "wget") + assert wget_match.overrides == {"apt": {"install_args": ["wget"]}} + # The returned Binary is non-loaded — it has no abspath/version yet. + assert wget_match.loaded_abspath is None + assert wget_match.loaded_version is None + # ...but installing it must produce a real, valid binary on disk. + provider.uninstall("wget", quiet=True, no_cache=True) + installed = wget_match.install() + test_machine.assert_shallow_binary_loaded(installed) + assert installed.name == "wget" + def test_helper_install_args_used_by_native_apt_backend(self, test_machine): test_machine.require_tool("apt-get") diff --git a/tests/test_bashprovider.py b/tests/test_bashprovider.py index 50b2078..7c2005e 100644 --- a/tests/test_bashprovider.py +++ b/tests/test_bashprovider.py @@ -137,3 +137,9 @@ def test_binary_direct_methods_exercise_real_lifecycle(self, test_machine): ) test_machine.exercise_binary_lifecycle(binary) + + def test_search_returns_empty_for_bash_provider(self): + # BashProvider has no package index — packages are installed via + # arbitrary user-provided shell scripts — so search returns []. + assert BashProvider().search("zx") == [] + assert BashProvider().search("nonexistent-binary-xyz") == [] diff --git a/tests/test_brewprovider.py b/tests/test_brewprovider.py index 8db49e2..baecc91 100644 --- a/tests/test_brewprovider.py +++ b/tests/test_brewprovider.py @@ -221,3 +221,19 @@ def test_provider_dry_run_does_not_install_formula(self, test_machine): provider, bin_name=test_machine.pick_missing_brew_formula(), ) + + def test_search_finds_real_brew_formula_and_install_works(self, test_machine): + test_machine.require_tool("brew") + provider = BrewProvider(postinstall_scripts=True, min_release_age=0) + results = provider.search("jq") + assert results, "brew search jq should return matches" + names = [r.name for r in results] + assert "jq" in names + match = next(r for r in results if r.name == "jq") + assert match.overrides == {"brew": {"install_args": ["jq"]}} + assert match.loaded_abspath is None + assert match.loaded_version is None + provider.uninstall("jq", quiet=True, no_cache=True) + installed = match.install() + test_machine.assert_shallow_binary_loaded(installed) + assert installed.name == "jq" diff --git a/tests/test_bunprovider.py b/tests/test_bunprovider.py index 0e80702..0516ea7 100644 --- a/tests/test_bunprovider.py +++ b/tests/test_bunprovider.py @@ -360,3 +360,22 @@ def test_binary_install_failure_propagates_as_BinaryInstallError(self): assert failing_provider.supports_min_release_age("install") is True with pytest.raises(BinaryInstallError): failing_binary.install() + + def test_search_finds_real_npm_package_and_install_works(self, test_machine): + with tempfile.TemporaryDirectory() as temp_dir: + provider = BunProvider( + install_root=Path(temp_dir) / "bun", + postinstall_scripts=True, + min_release_age=0, + ) + results = provider.search("zx") + assert results, "bun search zx (via npm registry) should return matches" + names = [r.name for r in results] + assert "zx" in names + match = next(r for r in results if r.name == "zx") + assert match.overrides == {"bun": {"install_args": ["zx"]}} + assert match.loaded_abspath is None + assert match.loaded_version is None + installed = match.install() + test_machine.assert_shallow_binary_loaded(installed) + assert installed.name == "zx" diff --git a/tests/test_cargoprovider.py b/tests/test_cargoprovider.py index e3c5d84..6a93c02 100644 --- a/tests/test_cargoprovider.py +++ b/tests/test_cargoprovider.py @@ -232,3 +232,23 @@ def test_provider_dry_run_does_not_install_choose(self, test_machine): min_release_age=0, ) test_machine.exercise_provider_dry_run(provider, bin_name="choose") + + def test_search_finds_real_crates_io_match_and_install_works(self, test_machine): + test_machine.require_tool("cargo") + with tempfile.TemporaryDirectory() as temp_dir: + provider = CargoProvider( + install_root=Path(temp_dir) / "cargo", + postinstall_scripts=True, + min_release_age=0, + ) + results = provider.search("choose") + assert results, "cargo search choose should return matches" + names = [r.name for r in results] + assert "choose" in names + match = next(r for r in results if r.name == "choose") + assert match.overrides == {"cargo": {"install_args": ["choose"]}} + assert match.loaded_abspath is None + assert match.loaded_version is None + installed = match.install() + test_machine.assert_shallow_binary_loaded(installed) + assert installed.name == "choose" diff --git a/tests/test_chromewebstoreprovider.py b/tests/test_chromewebstoreprovider.py index 81aa828..06b2884 100644 --- a/tests/test_chromewebstoreprovider.py +++ b/tests/test_chromewebstoreprovider.py @@ -187,3 +187,27 @@ def test_binary_direct_methods_exercise_real_lifecycle(self, test_machine): assert removed.loaded_version is None assert removed.loaded_sha256 is None test_machine.assert_binary_missing(binary) + + def test_search_finds_real_chromewebstore_extension_by_id(self): + # Search resolves a 32-char Chrome Web Store extension ID into + # its canonical name by scraping the public detail page's + # ```` tag. The Binary's ``.name`` is the stable webstore + # ID (BinName-safe); the human extension name lives in + # ``description`` and the ``--name=`` install_arg. Non-ID + # queries return [] because the Web Store doesn't expose a JSON + # search API for keyword lookups. + results = ChromeWebstoreProvider().search(UBLOCK_WEBSTORE_ID) + assert results, "chromewebstore search by id should resolve uBlock Origin" + assert len(results) == 1 + match = results[0] + assert match.name == UBLOCK_WEBSTORE_ID + assert "ublock" in match.description.lower() + assert match.overrides == { + "chromewebstore": { + "install_args": [ + UBLOCK_WEBSTORE_ID, + f"--name={match.description.split(' (')[0]}", + ], + }, + } + assert ChromeWebstoreProvider().search("not-an-id") == [] diff --git a/tests/test_denoprovider.py b/tests/test_denoprovider.py index 2f32900..b711718 100644 --- a/tests/test_denoprovider.py +++ b/tests/test_denoprovider.py @@ -229,3 +229,22 @@ def test_binary_install_failure_propagates_as_BinaryInstallError(self): ) with pytest.raises(BinaryInstallError): failing_binary.install() + + def test_search_finds_real_npm_package_and_install_works(self, test_machine): + with tempfile.TemporaryDirectory() as temp_dir: + provider = DenoProvider( + install_root=Path(temp_dir) / "deno", + postinstall_scripts=True, + min_release_age=0, + ) + results = provider.search("zx") + assert results, "deno search zx (via npm registry) should return matches" + names = [r.name for r in results] + assert "zx" in names + match = next(r for r in results if r.name == "zx") + assert match.overrides == {"deno": {"install_args": ["npm:zx"]}} + assert match.loaded_abspath is None + assert match.loaded_version is None + installed = match.install() + test_machine.assert_shallow_binary_loaded(installed) + assert installed.name == "zx" diff --git a/tests/test_dockerprovider.py b/tests/test_dockerprovider.py index 7d5702c..9d3a6d6 100644 --- a/tests/test_dockerprovider.py +++ b/tests/test_dockerprovider.py @@ -355,3 +355,23 @@ def test_provider_dry_run_does_not_install_shellcheck(self, test_machine): }, ) test_machine.exercise_provider_dry_run(provider, bin_name="shellcheck") + + def test_search_finds_real_docker_image_and_install_works(self, test_machine): + test_machine.require_docker_daemon() + with tempfile.TemporaryDirectory() as temp_dir: + provider = DockerProvider( + bin_dir=Path(temp_dir) / "docker/bin", + postinstall_scripts=True, + min_release_age=0, + ) + results = provider.search("alpine") + assert results, "docker search alpine should return Docker Hub matches" + names = [r.name for r in results] + assert "alpine" in names + match = next(r for r in results if r.name == "alpine") + assert match.overrides == {"docker": {"install_args": ["alpine:latest"]}} + assert match.loaded_abspath is None + assert match.loaded_version is None + installed = match.install() + test_machine.assert_shallow_binary_loaded(installed) + assert installed.name == "alpine" diff --git a/tests/test_envprovider.py b/tests/test_envprovider.py index 247871b..1d9d19f 100644 --- a/tests/test_envprovider.py +++ b/tests/test_envprovider.py @@ -188,3 +188,9 @@ def test_provider_does_not_cache_binaries_managed_by_other_providers(self): assert env_provider.has_cached_binary("black") is False assert pip_provider.uninstall("black") is True + + def test_search_returns_empty_for_env_provider(self): + # EnvProvider has no package index — it just exposes ambient PATH — + # so search must be an empty list rather than a crash or fallback. + assert EnvProvider().search("python") == [] + assert EnvProvider().search("nonexistent-binary-xyz") == [] diff --git a/tests/test_gemprovider.py b/tests/test_gemprovider.py index 4045443..ee10b3a 100644 --- a/tests/test_gemprovider.py +++ b/tests/test_gemprovider.py @@ -255,3 +255,24 @@ def test_provider_dry_run_does_not_install_cowsay(self, test_machine): min_release_age=0, ) test_machine.exercise_provider_dry_run(provider, bin_name="cowsay") + + def test_search_finds_real_rubygem_and_install_works(self, test_machine): + test_machine.require_tool("gem") + with tempfile.TemporaryDirectory() as temp_dir: + provider = GemProvider( + install_root=Path(temp_dir) / "gem-home", + bin_dir=Path(temp_dir) / "gem-home/bin", + postinstall_scripts=True, + min_release_age=0, + ) + results = provider.search("lolcat") + assert results, "gem search lolcat should return rubygems matches" + names = [r.name for r in results] + assert "lolcat" in names + match = next(r for r in results if r.name == "lolcat") + assert match.overrides == {"gem": {"install_args": ["lolcat"]}} + assert match.loaded_abspath is None + assert match.loaded_version is None + installed = match.install() + test_machine.assert_shallow_binary_loaded(installed) + assert installed.name == "lolcat" diff --git a/tests/test_gogetprovider.py b/tests/test_gogetprovider.py index 1e62d9c..c688e9a 100644 --- a/tests/test_gogetprovider.py +++ b/tests/test_gogetprovider.py @@ -1,6 +1,7 @@ import tempfile from pathlib import Path import logging +from typing import cast import pytest @@ -363,3 +364,48 @@ def test_provider_dry_run_does_not_install_shfmt(self, test_machine): }, ) test_machine.exercise_provider_dry_run(provider, bin_name="shfmt") + + def test_search_finds_real_go_module_and_install_works(self, test_machine): + test_machine.require_tool("go") + with tempfile.TemporaryDirectory() as temp_dir: + provider = GoGetProvider( + install_root=Path(temp_dir) / "go", + bin_dir=Path(temp_dir) / "go/bin", + postinstall_scripts=True, + min_release_age=0, + ) + # ``shfmt`` is the canonical "go-installable CLI" example — + # the parent module is the unit ``go list -m -versions`` + # accepts, and the actual installable command lives at + # ``mvdan.cc/sh/v3/cmd/shfmt`` and produces a ``shfmt`` binary + # that responds to ``--version``. + module = "mvdan.cc/sh/v3" + results = provider.search(module) + assert len(results) == 1 + match = results[0] + assert match.name == "v3" # leaf of module path + install_args = cast( + list[str], + match.overrides.get("goget", {}).get("install_args", []), + ) + install_arg = install_args[0] + assert install_arg.startswith(module + "@v") + assert match.loaded_abspath is None + assert match.loaded_version is None + # Repoint install_args at the installable CLI inside the module + # so .install() actually produces the ``shfmt`` binary. + shfmt_binary = match.model_copy( + update={ + "name": "shfmt", + "overrides": { + "goget": { + "install_args": [ + f"mvdan.cc/sh/v3/cmd/shfmt@{install_arg.split('@')[1]}", + ], + }, + }, + }, + ) + installed = shfmt_binary.install() + test_machine.assert_shallow_binary_loaded(installed) + assert installed.name == "shfmt" diff --git a/tests/test_nixprovider.py b/tests/test_nixprovider.py index 74a4201..ba60ba3 100644 --- a/tests/test_nixprovider.py +++ b/tests/test_nixprovider.py @@ -232,3 +232,23 @@ def test_provider_dry_run_does_not_install_hello(self, test_machine): min_release_age=0, ) test_machine.exercise_provider_dry_run(provider, bin_name="hello") + + def test_search_finds_real_nixpkgs_attr_and_install_works(self, test_machine): + assert NixProvider().INSTALLER_BINARY(), "nix is required on this host" + with tempfile.TemporaryDirectory() as temp_dir: + provider = NixProvider( + install_root=Path(temp_dir) / "nix-profile", + postinstall_scripts=True, + min_release_age=0, + ) + results = provider.search("hello") + assert results, "nix search nixpkgs hello should return matches" + names = [r.name for r in results] + assert "hello" in names + match = next(r for r in results if r.name == "hello") + assert match.overrides == {"nix": {"install_args": ["nixpkgs#hello"]}} + assert match.loaded_abspath is None + assert match.loaded_version is None + installed = match.install() + test_machine.assert_shallow_binary_loaded(installed) + assert installed.name == "hello" diff --git a/tests/test_npmprovider.py b/tests/test_npmprovider.py index ab9ea54..0e7652d 100644 --- a/tests/test_npmprovider.py +++ b/tests/test_npmprovider.py @@ -302,3 +302,22 @@ def test_provider_dry_run_does_not_install_zx(self, test_machine): min_release_age=0, ) test_machine.exercise_provider_dry_run(provider, bin_name="zx") + + def test_search_finds_real_npm_package_and_install_works(self, test_machine): + with tempfile.TemporaryDirectory() as temp_dir: + provider = NpmProvider( + install_root=Path(temp_dir) / "npm", + postinstall_scripts=True, + min_release_age=0, + ) + results = provider.search("zx") + assert results, "npm search zx should return registry matches" + names = [r.name for r in results] + assert "zx" in names + match = next(r for r in results if r.name == "zx") + assert match.overrides == {"npm": {"install_args": ["zx"]}} + assert match.loaded_abspath is None + assert match.loaded_version is None + installed = match.install() + test_machine.assert_shallow_binary_loaded(installed) + assert installed.name == "zx" diff --git a/tests/test_pipprovider.py b/tests/test_pipprovider.py index 1dbb282..938c6ca 100644 --- a/tests/test_pipprovider.py +++ b/tests/test_pipprovider.py @@ -454,3 +454,21 @@ def test_provider_action_args_override_provider_defaults(self, test_machine): min_release_age=0, ) test_machine.assert_shallow_binary_loaded(installed) + + def test_search_finds_real_pypi_package_and_install_works(self, test_machine): + with tempfile.TemporaryDirectory() as temp_dir: + provider = PipProvider( + install_root=Path(temp_dir) / "venv", + postinstall_scripts=True, + min_release_age=0, + ) + results = provider.search("black") + assert len(results) == 1 + match = results[0] + assert match.name == "black" + assert match.overrides == {"pip": {"install_args": ["black"]}} + assert match.loaded_abspath is None + assert match.loaded_version is None + installed = match.install() + test_machine.assert_shallow_binary_loaded(installed) + assert installed.name == "black" diff --git a/tests/test_playwrightprovider.py b/tests/test_playwrightprovider.py index 5831ad5..90e1da0 100644 --- a/tests/test_playwrightprovider.py +++ b/tests/test_playwrightprovider.py @@ -461,3 +461,24 @@ def test_provider_dry_run_does_not_install_chromium(self, test_machine): f"dry_run should not have created any browser dirs, got: " f"{[p.name for p in browser_dirs]}" ) + + def test_search_finds_real_playwright_browser_and_install_works(self, test_machine): + with tempfile.TemporaryDirectory() as temp_dir: + provider = PlaywrightProvider( + install_root=Path(temp_dir) / "playwright", + postinstall_scripts=True, + min_release_age=0, + ) + results = provider.search("chromium") + assert results, ( + "playwright search 'chromium' should return supported browsers" + ) + names = [r.name for r in results] + assert "chromium" in names + match = next(r for r in results if r.name == "chromium") + assert match.overrides == {"playwright": {"install_args": ["chromium"]}} + assert match.loaded_abspath is None + assert match.loaded_version is None + installed = match.install() + test_machine.assert_shallow_binary_loaded(installed) + assert installed.name == "chromium" diff --git a/tests/test_pnpmprovider.py b/tests/test_pnpmprovider.py index fbb5057..5f29c89 100644 --- a/tests/test_pnpmprovider.py +++ b/tests/test_pnpmprovider.py @@ -463,3 +463,22 @@ def test_binary_install_failure_propagates_as_BinaryInstallError(self): assert failing_provider.supports_min_release_age("install") is True with pytest.raises(BinaryInstallError): failing_binary.install() + + def test_search_finds_real_npm_package_and_install_works(self, test_machine): + with tempfile.TemporaryDirectory() as temp_dir: + provider = PnpmProvider( + install_root=Path(temp_dir) / "pnpm", + postinstall_scripts=True, + min_release_age=0, + ) + results = provider.search("zx") + assert results, "pnpm search zx should return registry matches" + names = [r.name for r in results] + assert "zx" in names + match = next(r for r in results if r.name == "zx") + assert match.overrides == {"pnpm": {"install_args": ["zx"]}} + assert match.loaded_abspath is None + assert match.loaded_version is None + installed = match.install() + test_machine.assert_shallow_binary_loaded(installed) + assert installed.name == "zx" diff --git a/tests/test_puppeteerprovider.py b/tests/test_puppeteerprovider.py index 659544c..bf63bc3 100644 --- a/tests/test_puppeteerprovider.py +++ b/tests/test_puppeteerprovider.py @@ -199,3 +199,26 @@ def test_binary_direct_methods_exercise_real_lifecycle(self, test_machine): ) test_machine.exercise_binary_lifecycle(binary) + + def test_search_finds_real_puppeteer_browser_and_install_works(self, test_machine): + test_machine.require_tool("node") + test_machine.require_tool("npm") + with tempfile.TemporaryDirectory() as temp_dir: + provider = PuppeteerProvider( + install_root=Path(temp_dir) / "puppeteer", + postinstall_scripts=True, + min_release_age=0, + ) + results = provider.search("chrome-headless-shell") + assert results, "puppeteer search should match its hardcoded browser list" + names = [r.name for r in results] + assert "chrome-headless-shell" in names + match = next(r for r in results if r.name == "chrome-headless-shell") + assert match.overrides == { + "puppeteer": {"install_args": ["chrome-headless-shell"]}, + } + assert match.loaded_abspath is None + assert match.loaded_version is None + installed = match.install() + test_machine.assert_shallow_binary_loaded(installed) + assert installed.name == "chrome-headless-shell" diff --git a/tests/test_pyinfraprovider.py b/tests/test_pyinfraprovider.py index dd9f08e..4848a42 100644 --- a/tests/test_pyinfraprovider.py +++ b/tests/test_pyinfraprovider.py @@ -186,3 +186,10 @@ def test_provider_dry_run_does_not_install_package( del test_machine_dependencies provider, package = _pyinfra_provider_for_host(test_machine) test_machine.exercise_provider_dry_run(provider, bin_name=package) + + def test_search_returns_empty_for_pyinfra_provider(self): + # PyinfraProvider delegates installs to pyinfra's own operations + # (apt, brew, ...). It has no package index of its own to search, + # so search is intentionally an empty list. + assert PyinfraProvider().search("python") == [] + assert PyinfraProvider().search("nonexistent-binary-xyz") == [] diff --git a/tests/test_uvprovider.py b/tests/test_uvprovider.py index ddfa606..495da3d 100644 --- a/tests/test_uvprovider.py +++ b/tests/test_uvprovider.py @@ -587,3 +587,21 @@ def test_binary_install_failure_propagates_as_BinaryInstallError(self): ) with pytest.raises(BinaryInstallError): failing_binary.install() + + def test_search_finds_real_pypi_package_and_install_works(self, test_machine): + with tempfile.TemporaryDirectory() as tmpdir: + provider = UvProvider( + install_root=Path(tmpdir) / "venv", + postinstall_scripts=True, + min_release_age=0, + ) + results = provider.search("black") + assert len(results) == 1 + match = results[0] + assert match.name == "black" + assert match.overrides == {"uv": {"install_args": ["black"]}} + assert match.loaded_abspath is None + assert match.loaded_version is None + installed = match.install() + test_machine.assert_shallow_binary_loaded(installed) + assert installed.name == "black" diff --git a/tests/test_yarnprovider.py b/tests/test_yarnprovider.py index 8c46362..ee5bd53 100644 --- a/tests/test_yarnprovider.py +++ b/tests/test_yarnprovider.py @@ -509,3 +509,24 @@ def test_binary_install_failure_propagates_as_BinaryInstallError(self): assert failing_provider.supports_min_release_age("install") with pytest.raises(BinaryInstallError): failing_binary.install() + + def test_search_finds_real_npm_package_via_registry(self): + # yarn 4+ removed ``yarn search`` so YarnProvider.search hits the + # npm registry HTTP API directly. This test only exercises that + # search path — it doesn't install anything because installing + # via yarn requires the host's yarn workspace setup, which is + # exercised by the other test_*_lifecycle tests in this file. + with tempfile.TemporaryDirectory() as temp_dir: + provider = YarnProvider( + install_root=Path(temp_dir) / "yarn", + postinstall_scripts=True, + min_release_age=0, + ) + results = provider.search("zx") + assert results, "yarn search zx (via npm registry) should return matches" + names = [r.name for r in results] + assert "zx" in names + match = next(r for r in results if r.name == "zx") + assert match.overrides == {"yarn": {"install_args": ["zx"]}} + assert match.loaded_abspath is None + assert match.loaded_version is None