From a349261f6c7d5a02e3e2b5c5276c435c2620aefb Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 3 May 2026 03:31:55 +0000 Subject: [PATCH] Add BinProvider.search() interface Adds a uniform .search(query) method on BinProvider that delegates to per-provider native search commands (apt-cache search, npm search, brew search, nix search, pip index versions, etc.) and surfaces results as Binary objects ready to install. Also wires up an 'abxpkg search' CLI command that runs all providers in parallel. --- README.md | 7 ++ abxpkg/binprovider.py | 178 +++++++++++++++++++++++++-- abxpkg/binprovider_apt.py | 42 ++++++- abxpkg/binprovider_bash.py | 1 + abxpkg/binprovider_brew.py | 43 +++++++ abxpkg/binprovider_bun.py | 49 ++++++++ abxpkg/binprovider_cargo.py | 40 ++++++ abxpkg/binprovider_chromewebstore.py | 55 +++++++++ abxpkg/binprovider_deno.py | 50 ++++++++ abxpkg/binprovider_docker.py | 52 ++++++++ abxpkg/binprovider_gem.py | 42 +++++++ abxpkg/binprovider_goget.py | 53 ++++++++ abxpkg/binprovider_nix.py | 84 +++++++++++++ abxpkg/binprovider_npm.py | 42 +++++++ abxpkg/binprovider_pip.py | 46 +++++++ abxpkg/binprovider_playwright.py | 38 ++++++ abxpkg/binprovider_pnpm.py | 42 +++++++ abxpkg/binprovider_puppeteer.py | 39 ++++++ abxpkg/binprovider_uv.py | 42 +++++++ abxpkg/binprovider_yarn.py | 50 ++++++++ abxpkg/cli.py | 54 ++++++++ tests/test_ansibleprovider.py | 7 ++ tests/test_aptprovider.py | 18 +++ tests/test_bashprovider.py | 6 + tests/test_brewprovider.py | 16 +++ tests/test_bunprovider.py | 19 +++ tests/test_cargoprovider.py | 20 +++ tests/test_chromewebstoreprovider.py | 24 ++++ tests/test_denoprovider.py | 19 +++ tests/test_dockerprovider.py | 20 +++ tests/test_envprovider.py | 6 + tests/test_gemprovider.py | 21 ++++ tests/test_gogetprovider.py | 46 +++++++ tests/test_nixprovider.py | 20 +++ tests/test_npmprovider.py | 19 +++ tests/test_pipprovider.py | 18 +++ tests/test_playwrightprovider.py | 21 ++++ tests/test_pnpmprovider.py | 19 +++ tests/test_puppeteerprovider.py | 23 ++++ tests/test_pyinfraprovider.py | 7 ++ tests/test_uvprovider.py | 18 +++ tests/test_yarnprovider.py | 21 ++++ 42 files changed, 1423 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 2dcedb63..8a25888c 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 a895efa0..5328ded0 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 2d73f684..bd192379 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 ad94c52e..ce6bd1b1 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 013fefe1..b52b9fcd 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 893ae9b6..5fa00be4 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 bd533937..c78f6220 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 c7f331a5..437597b0 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 030e8b8f..353542fc 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 b07db48b..c653eadb 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 aebecaab..e1d1bb4e 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 1df9eb15..3eb50cbb 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 ba6f24c2..af994aea 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 08410680..411f4079 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 2817c070..c6c8fa38 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 496eea42..b95a265b 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 53ad8508..d29d7401 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 00faf4dc..3004dffe 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 c157508e..d3adac78 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 02cf73ee..f28ee305 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 0276c625..0a913b0f 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 c2da3f52..bac2cd85 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 f956c72d..a7e4a02c 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 50b2078a..7c2005e8 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 8db49e28..baecc912 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 0e807029..0516ea7a 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 e3c5d841..6a93c02e 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 81aa8281..06b28845 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 2f32900c..b711718a 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 7d5702c8..9d3a6d69 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 247871bb..1d9d19f5 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 40454434..ee10b3ad 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 1e62d9c5..c688e9a8 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 74a42013..ba60ba3f 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 ab9ea54a..0e7652de 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 1dbb2826..938c6cab 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 5831ad53..90e1da02 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 fbb50575..5f29c89e 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 659544c3..bf63bc36 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 dd9f08e7..4848a42b 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 ddfa6069..495da3da 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 8c463627..ee5bd53d 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