0ef5fcb1c5
Security / Dependency audit (pip-audit) (push) Has been cancelled
Security / CodeQL (javascript-typescript) (push) Has been cancelled
Security / CodeQL (python) (push) Has been cancelled
Security / Secret scan (gitleaks) (push) Has been cancelled
rust / test (ubuntu) (push) Has been cancelled
rust / simulator e2e (macos-latest) (push) Has been cancelled
rust / simulator e2e (ubuntu-latest) (push) Has been cancelled
rust / simulator e2e (windows-latest) (push) Has been cancelled
rust / wheels (aarch64-apple-darwin) (push) Has been cancelled
rust / wheels (x86_64-unknown-linux-gnu) (push) Has been cancelled
rust / wheels (x86_64-apple-darwin) (push) Has been cancelled
rust / audit (push) Has been cancelled
rust / parity (nightly, allowed to fail during Phase 0) (push) Has been cancelled
CI / commitlint (push) Has been skipped
Dev Containers / validate (.devcontainer/devcontainer.json, default) (push) Failing after 0s
Dev Containers / validate (.devcontainer/memory-stack/devcontainer.json, memory-stack) (push) Failing after 0s
Dev Containers / validate-worktree (push) Failing after 0s
CI / changes (push) Failing after 4s
Deploy Documentation / validate (push) Has been skipped
Deploy Documentation / deploy (push) Failing after 1s
Init Native E2E / init-native (ubuntu-latest, claude) (push) Failing after 1s
Init Native E2E / init-native (ubuntu-latest, codex) (push) Failing after 1s
Install Native E2E / install-native (ubuntu-latest) (push) Failing after 1s
OpenCode Plugin / typecheck + build + test (push) Failing after 1s
Init Native E2E / init-native (ubuntu-latest, copilot) (push) Failing after 1s
Release Please / release-please (push) Failing after 1s
Wrap E2E / docker-wrap-e2e (push) Failing after 1s
Wrap Native E2E / wrap-native (ubuntu-latest) (push) Failing after 1s
Init E2E / docker-init-e2e (push) Failing after 4s
Merge Conflicts / merge-conflicts (push) Failing after 4s
CI / lint (push) Has been cancelled
CI / build-wheel (push) Has been cancelled
CI / build-wheel-windows (push) Has been cancelled
CI / prefetch-model (push) Has been cancelled
CI / test-dashboard-ui (push) Has been cancelled
CI / test (1) (push) Has been cancelled
CI / test (2) (push) Has been cancelled
CI / test (3) (push) Has been cancelled
CI / test (4) (push) Has been cancelled
CI / test-extras (push) Has been cancelled
CI / test-agno (push) Has been cancelled
CI / build (push) Has been cancelled
CI / workflow-validation (push) Has been cancelled
CI / docker-native-e2e (push) Has been cancelled
CI / windows-native-wrapper (push) Has been cancelled
CI / macos-native-wrapper (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code-nonroot name:code-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code-slim name:code-slim]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code-slim-nonroot name:code-slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-nonroot name:nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-slim name:slim]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-slim-nonroot name:slim-nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime name:]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code name:code]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code-nonroot name:code-nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code-slim name:code-slim]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code-slim-nonroot name:code-slim-nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-nonroot name:nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-slim name:slim]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-slim-nonroot name:slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime name:]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code name:code]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code-nonroot name:code-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code-slim name:code-slim]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code-slim-nonroot name:code-slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-nonroot name:nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-slim name:slim]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-slim-nonroot name:slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime name:]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code name:code]) (push) Has been cancelled
Docker / promote-latest (push) Has been cancelled
Init Native E2E / init-native (macos-latest, claude) (push) Has been cancelled
Init Native E2E / init-native (macos-latest, codex) (push) Has been cancelled
Init Native E2E / init-native (macos-latest, copilot) (push) Has been cancelled
Install Native E2E / install-native (macos-latest) (push) Has been cancelled
Wrap Native E2E / wrap-native (macos-latest) (push) Has been cancelled
558 lines
19 KiB
Python
558 lines
19 KiB
Python
"""Fetcher for bundled CLI tool binaries.
|
|
|
|
`pip install headroom-ai` pulls `ast-grep-cli` as a proper PyPI binary wheel
|
|
(core dependency), so ast-grep is always on PATH. The other two high-value
|
|
tools — `difft` (difftastic) and `scc` — are fetched from pinned upstream
|
|
GitHub releases at proxy startup, verified, cached per-user, and exec'd.
|
|
|
|
Supported platforms: linux (glibc + musl) x86_64/aarch64, macOS x86_64/arm64,
|
|
Windows x86_64. Unsupported platforms raise PlatformNotSupported; callers in
|
|
the compression pipeline should fall back to their non-accelerated path.
|
|
|
|
Env vars:
|
|
HEADROOM_BINARIES_MIRROR base URL that replaces https://github.com
|
|
HEADROOM_BINARIES_CACHE override cache dir
|
|
HEADROOM_BINARIES_OFFLINE if set, never reach the network
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import functools
|
|
import glob
|
|
import hashlib
|
|
import json
|
|
import logging
|
|
import os
|
|
import platform
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tarfile
|
|
import tempfile
|
|
import urllib.error
|
|
import urllib.parse
|
|
import urllib.request
|
|
import zipfile
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from headroom._subprocess import run
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
__all__ = [
|
|
"BinaryError",
|
|
"BinaryFetchError",
|
|
"PlatformNotSupported",
|
|
"Sha256Mismatch",
|
|
"OfflineError",
|
|
"PlatformKey",
|
|
"detect_platform",
|
|
"cache_dir",
|
|
"resolve",
|
|
"which",
|
|
"status",
|
|
"ensure_tools",
|
|
]
|
|
|
|
|
|
# ---------- Exceptions ---------------------------------------------------- #
|
|
|
|
|
|
class BinaryError(Exception):
|
|
"""Base exception for the binaries module."""
|
|
|
|
|
|
class BinaryFetchError(BinaryError):
|
|
"""Raised when a download fails or an archive cannot be extracted."""
|
|
|
|
|
|
class PlatformNotSupported(BinaryError):
|
|
"""Raised when the current OS/arch is not covered by a tool's registry."""
|
|
|
|
|
|
class Sha256Mismatch(BinaryError):
|
|
"""Raised when a downloaded asset's SHA256 does not match the pin."""
|
|
|
|
|
|
class OfflineError(BinaryError):
|
|
"""Raised when a network fetch is required but HEADROOM_BINARIES_OFFLINE is set."""
|
|
|
|
|
|
# ---------- Platform detection -------------------------------------------- #
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class PlatformKey:
|
|
os: str # "linux" | "darwin" | "windows"
|
|
arch: str # "x86_64" | "aarch64"
|
|
libc: str # "gnu" | "musl" | "n/a"
|
|
|
|
def key(self) -> str:
|
|
# Compact form used as registry lookup key and cache subdirectory.
|
|
if self.os == "linux":
|
|
return f"{self.os}-{self.arch}-{self.libc}"
|
|
return f"{self.os}-{self.arch}"
|
|
|
|
|
|
def _machine_to_arch(machine: str) -> str:
|
|
m = machine.lower()
|
|
if m in ("x86_64", "amd64"):
|
|
return "x86_64"
|
|
if m in ("aarch64", "arm64"):
|
|
return "aarch64"
|
|
return m # return as-is; lookup will fail cleanly with PlatformNotSupported
|
|
|
|
|
|
def _is_musl() -> bool:
|
|
"""Best-effort musl detection on Linux. Never raises.
|
|
|
|
First: ask `ldd --version` (returns 'musl' on Alpine/Void).
|
|
Fallback: check for the musl dynamic loader at /lib/ld-musl-*.so.1,
|
|
which is present on Alpine even when `ldd` is absent.
|
|
"""
|
|
try:
|
|
out = run(
|
|
["ldd", "--version"],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=2,
|
|
check=False,
|
|
)
|
|
if "musl" in (out.stdout + out.stderr).lower():
|
|
return True
|
|
except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
|
|
pass
|
|
# Fallback: musl ships a distinctive dynamic loader.
|
|
if glob.glob("/lib/ld-musl-*.so.1") or glob.glob("/lib64/ld-musl-*.so.1"):
|
|
return True
|
|
return False
|
|
|
|
|
|
@functools.lru_cache(maxsize=1)
|
|
def detect_platform() -> PlatformKey:
|
|
arch = _machine_to_arch(platform.machine())
|
|
if sys.platform.startswith("linux"):
|
|
return PlatformKey("linux", arch, "musl" if _is_musl() else "gnu")
|
|
if sys.platform == "darwin":
|
|
return PlatformKey("darwin", arch, "n/a")
|
|
if sys.platform.startswith("win"):
|
|
return PlatformKey("windows", arch, "n/a")
|
|
return PlatformKey(sys.platform, arch, "n/a")
|
|
|
|
|
|
# ---------- Cache dir ----------------------------------------------------- #
|
|
|
|
|
|
def _is_writable_dir(path: Path) -> bool:
|
|
try:
|
|
mode = path.stat().st_mode
|
|
except OSError:
|
|
return False
|
|
return path.is_dir() and bool(mode & 0o222)
|
|
|
|
|
|
def _has_writable_existing_parent(path: Path) -> bool:
|
|
current = path
|
|
while not current.exists():
|
|
parent = current.parent
|
|
if parent == current:
|
|
return False
|
|
current = parent
|
|
return _is_writable_dir(current)
|
|
|
|
|
|
def cache_dir() -> Path:
|
|
override = os.environ.get("HEADROOM_BINARIES_CACHE")
|
|
if override:
|
|
return Path(override).expanduser().resolve()
|
|
if sys.platform.startswith("win"):
|
|
base = os.environ.get("LOCALAPPDATA") or str(Path.home() / "AppData" / "Local")
|
|
return Path(base) / "headroom" / "bin"
|
|
if sys.platform == "darwin":
|
|
return Path.home() / "Library" / "Caches" / "headroom" / "bin"
|
|
xdg = os.environ.get("XDG_CACHE_HOME")
|
|
base = Path(xdg) if xdg else Path.home() / ".cache"
|
|
return base / "headroom" / "bin"
|
|
|
|
|
|
# ---------- Registry ------------------------------------------------------ #
|
|
|
|
|
|
_REGISTRY_PATH = Path(__file__).parent / "tools.json"
|
|
|
|
|
|
@functools.lru_cache(maxsize=1)
|
|
def _registry() -> dict[str, Any]:
|
|
with _REGISTRY_PATH.open("r", encoding="utf-8") as f:
|
|
data: dict[str, Any] = json.load(f)
|
|
return data
|
|
|
|
|
|
def _tool_entry(tool: str) -> dict[str, Any]:
|
|
reg = _registry()
|
|
tools: dict[str, Any] = reg.get("tools", {})
|
|
if tool not in tools:
|
|
raise KeyError(f"unknown tool {tool!r}; known: {sorted(tools)}")
|
|
entry: dict[str, Any] = tools[tool]
|
|
return entry
|
|
|
|
|
|
def _is_pypi_tool(tool: str) -> bool:
|
|
entry = _tool_entry(tool)
|
|
return entry.get("version") == "pypi" or not entry.get("assets")
|
|
|
|
|
|
def _asset_for_platform(tool: str, plat: PlatformKey) -> dict[str, Any]:
|
|
entry = _tool_entry(tool)
|
|
if _is_pypi_tool(tool):
|
|
raise PlatformNotSupported(
|
|
f"{tool}: distributed via PyPI only; `pip install headroom-ai` "
|
|
f"should have placed `{entry.get('binary', tool)}` on PATH."
|
|
)
|
|
assets: dict[str, Any] = entry.get("assets", {})
|
|
asset: dict[str, Any] | None = assets.get(plat.key())
|
|
if asset is None:
|
|
supported = sorted(assets.keys())
|
|
raise PlatformNotSupported(
|
|
f"{tool}: no prebuilt binary for {plat.key()}; supported: {supported}"
|
|
)
|
|
return asset
|
|
|
|
|
|
def _mirror_url(url: str) -> str:
|
|
mirror = os.environ.get("HEADROOM_BINARIES_MIRROR")
|
|
if not mirror:
|
|
return url
|
|
# Only substitute the github.com host so that paths remain intact.
|
|
for prefix in ("https://github.com", "https://objects.githubusercontent.com"):
|
|
if url.startswith(prefix):
|
|
return mirror.rstrip("/") + url[len(prefix) :]
|
|
return url
|
|
|
|
|
|
# ---------- Download + verify --------------------------------------------- #
|
|
|
|
|
|
def _download(url: str, dest: Path, *, progress: bool = True) -> None:
|
|
if os.environ.get("HEADROOM_BINARIES_OFFLINE"):
|
|
raise OfflineError(f"offline mode (HEADROOM_BINARIES_OFFLINE=1) but fetch required: {url}")
|
|
if not _has_writable_existing_parent(dest.parent):
|
|
raise OSError(f"binary cache directory parent is not writable: {dest.parent}")
|
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
if not _is_writable_dir(dest.parent):
|
|
raise OSError(f"binary cache directory is not writable: {dest.parent}")
|
|
final_url = _mirror_url(url)
|
|
req = urllib.request.Request(final_url, headers={"User-Agent": "headroom-binaries/1"})
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=60) as resp: # noqa: S310 (https)
|
|
total = int(resp.headers.get("Content-Length") or 0)
|
|
_stream_to(resp, dest, total, label=dest.name, show_progress=progress)
|
|
except urllib.error.URLError as e:
|
|
raise BinaryFetchError(f"failed to download {final_url}: {e}") from e
|
|
|
|
|
|
def _stream_to(src: Any, dest: Path, total: int, *, label: str, show_progress: bool) -> None:
|
|
# Rich progress if available and stderr is a tty; otherwise silent chunked copy.
|
|
try:
|
|
if show_progress and sys.stderr.isatty():
|
|
from rich.progress import (
|
|
BarColumn,
|
|
DownloadColumn,
|
|
Progress,
|
|
TextColumn,
|
|
TimeRemainingColumn,
|
|
TransferSpeedColumn,
|
|
)
|
|
|
|
with Progress(
|
|
TextColumn("[bold blue]{task.description}"),
|
|
BarColumn(),
|
|
DownloadColumn(),
|
|
TransferSpeedColumn(),
|
|
TimeRemainingColumn(),
|
|
) as prog:
|
|
task = prog.add_task(label, total=total or None)
|
|
with dest.open("wb") as out:
|
|
while chunk := src.read(1024 * 64):
|
|
out.write(chunk)
|
|
prog.update(task, advance=len(chunk))
|
|
return
|
|
except ImportError:
|
|
pass
|
|
with dest.open("wb") as out:
|
|
shutil.copyfileobj(src, out)
|
|
|
|
|
|
def _sha256_file(path: Path) -> str:
|
|
h = hashlib.sha256()
|
|
with path.open("rb") as f:
|
|
for chunk in iter(lambda: f.read(1024 * 64), b""):
|
|
h.update(chunk)
|
|
return h.hexdigest()
|
|
|
|
|
|
def _verify_sha256(path: Path, expected: str | None) -> None:
|
|
if not expected:
|
|
# Upstream release not SHA-pinned in registry. HTTPS + the GitHub CDN
|
|
# is the only integrity check. Log at INFO so verbose runs can see
|
|
# this state; `doctor` surfaces the same fact via `sha_pinned=False`.
|
|
logger.info("binary %s downloaded without sha256 pin (HTTPS trust only)", path.name)
|
|
return
|
|
got = _sha256_file(path)
|
|
if got.lower() != expected.lower():
|
|
path.unlink(missing_ok=True)
|
|
raise Sha256Mismatch(f"sha256 mismatch for {path.name}: expected {expected}, got {got}")
|
|
|
|
|
|
# ---------- Archive extraction ------------------------------------------- #
|
|
|
|
|
|
def _extract(archive: Path, member: str, dest: Path) -> None:
|
|
"""Extract `member` from archive into `dest` (single-file binary)."""
|
|
if not _has_writable_existing_parent(dest.parent):
|
|
raise OSError(f"binary cache directory parent is not writable: {dest.parent}")
|
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
if not _is_writable_dir(dest.parent):
|
|
raise OSError(f"binary cache directory is not writable: {dest.parent}")
|
|
name = archive.name.lower()
|
|
try:
|
|
if name.endswith(".tar.gz") or name.endswith(".tgz"):
|
|
with tarfile.open(archive, "r:gz") as tf:
|
|
_extract_member_from_tar(tf, member, dest)
|
|
elif name.endswith(".zip"):
|
|
with zipfile.ZipFile(archive) as zf:
|
|
_extract_member_from_zip(zf, member, dest)
|
|
elif name.endswith(".gz") and not (name.endswith(".tar.gz") or name.endswith(".tgz")):
|
|
# bare .gz of a single binary (e.g. `scc-linux-x86_64.gz`)
|
|
import gzip
|
|
|
|
with gzip.open(archive, "rb") as gz, dest.open("wb") as out:
|
|
shutil.copyfileobj(gz, out)
|
|
else:
|
|
# Not an archive — treat the downloaded file itself as the binary.
|
|
shutil.copy2(archive, dest)
|
|
except (tarfile.TarError, zipfile.BadZipFile, OSError) as e:
|
|
raise BinaryFetchError(f"failed to extract {archive.name}: {e}") from e
|
|
|
|
|
|
def _extract_member_from_tar(tf: tarfile.TarFile, member: str, dest: Path) -> None:
|
|
# Match by basename so that registries can specify "difft" even though the
|
|
# upstream tar may include a leading directory like "difft-0.64.0/difft".
|
|
wanted = member.lower()
|
|
for m in tf.getmembers():
|
|
base = m.name.rsplit("/", 1)[-1].lower()
|
|
if base == wanted and m.isfile():
|
|
extracted = tf.extractfile(m)
|
|
if extracted is None:
|
|
continue
|
|
with dest.open("wb") as out:
|
|
shutil.copyfileobj(extracted, out)
|
|
return
|
|
raise BinaryFetchError(f"archive did not contain expected member {member!r}")
|
|
|
|
|
|
def _extract_member_from_zip(zf: zipfile.ZipFile, member: str, dest: Path) -> None:
|
|
wanted = member.lower()
|
|
for info in zf.infolist():
|
|
base = info.filename.rsplit("/", 1)[-1].lower()
|
|
if base == wanted and not info.is_dir():
|
|
with zf.open(info) as src, dest.open("wb") as out:
|
|
shutil.copyfileobj(src, out)
|
|
return
|
|
raise BinaryFetchError(f"archive did not contain expected member {member!r}")
|
|
|
|
|
|
# ---------- Public API ---------------------------------------------------- #
|
|
|
|
|
|
def _binary_name(tool: str, plat: PlatformKey) -> str:
|
|
entry = _tool_entry(tool)
|
|
base = entry.get("binary", tool)
|
|
return f"{base}.exe" if plat.os == "windows" else base
|
|
|
|
|
|
def _cached_path(tool: str, version: str, plat: PlatformKey) -> Path:
|
|
return cache_dir() / f"{tool}-{version}-{plat.key()}" / _binary_name(tool, plat)
|
|
|
|
|
|
def _in_registry(tool: str) -> bool:
|
|
return tool in _registry().get("tools", {})
|
|
|
|
|
|
def _path_lookup(tool: str) -> Path | None:
|
|
"""Find `tool` on PATH or in this interpreter's Scripts/bin directory.
|
|
|
|
PyPI binary wheels (e.g. ast-grep-cli) install their console scripts into
|
|
sys.prefix/bin (or sys.prefix/Scripts on Windows). That directory is on
|
|
PATH when the venv is activated, but subprocesses started by a non-active
|
|
interpreter can miss it, so we check it explicitly as a fallback.
|
|
"""
|
|
candidates = [tool]
|
|
if _in_registry(tool):
|
|
alias = _tool_entry(tool).get("binary")
|
|
if alias and alias != tool:
|
|
candidates.append(alias)
|
|
|
|
for name in candidates:
|
|
found = shutil.which(name)
|
|
if found:
|
|
return Path(found)
|
|
|
|
scripts_dir = Path(sys.prefix) / ("Scripts" if sys.platform.startswith("win") else "bin")
|
|
for name in candidates:
|
|
exe = scripts_dir / (name + (".exe" if sys.platform.startswith("win") else ""))
|
|
if exe.exists():
|
|
return exe
|
|
return None
|
|
|
|
|
|
def which(tool: str) -> Path | None:
|
|
"""Return a path to `tool` if it is on PATH or already cached, else None.
|
|
|
|
Never triggers a network fetch. Callers that want the tool to be installed
|
|
on demand should use `resolve()` instead.
|
|
"""
|
|
on_path = _path_lookup(tool)
|
|
if on_path:
|
|
return on_path
|
|
if not _in_registry(tool):
|
|
return None
|
|
try:
|
|
plat = detect_platform()
|
|
_asset_for_platform(tool, plat) # raises if unsupported
|
|
except PlatformNotSupported:
|
|
return None
|
|
path = _cached_path(tool, _tool_entry(tool)["version"], plat)
|
|
return path if path.exists() else None
|
|
|
|
|
|
def resolve(tool: str) -> Path:
|
|
"""Return a path to the tool binary, fetching it on first use.
|
|
|
|
Raises PlatformNotSupported if the tool is unavailable on this platform,
|
|
OfflineError if a fetch is required but HEADROOM_BINARIES_OFFLINE is set,
|
|
Sha256Mismatch if verification fails, BinaryFetchError on other IO errors.
|
|
"""
|
|
on_path = _path_lookup(tool)
|
|
if on_path:
|
|
return on_path
|
|
if not _in_registry(tool):
|
|
raise KeyError(f"unknown tool {tool!r}")
|
|
|
|
plat = detect_platform()
|
|
entry = _tool_entry(tool)
|
|
asset = _asset_for_platform(tool, plat)
|
|
version = entry["version"]
|
|
binary_path = _cached_path(tool, version, plat)
|
|
if binary_path.exists():
|
|
return binary_path
|
|
|
|
# Not cached — fetch, verify, extract.
|
|
url = asset["url"]
|
|
sha256 = asset.get("sha256")
|
|
member = asset.get("member", _binary_name(tool, plat))
|
|
|
|
with tempfile.TemporaryDirectory(prefix="headroom-fetch-") as tmp:
|
|
tmp_dir = Path(tmp)
|
|
# Strip query params so mirror URLs like `.../difft.tar.gz?token=...`
|
|
# don't produce filenames that break archive-type detection.
|
|
url_path = urllib.parse.urlparse(url).path
|
|
download_path = tmp_dir / (Path(url_path).name or "download")
|
|
_download(url, download_path)
|
|
_verify_sha256(download_path, sha256)
|
|
staging = tmp_dir / "out"
|
|
_extract(download_path, member, staging)
|
|
binary_path.parent.mkdir(parents=True, exist_ok=True)
|
|
# Atomic move with a PID-scoped partial name so two concurrent
|
|
# `headroom proxy` starts don't race on the same .partial path.
|
|
tmp_final = binary_path.with_name(f"{binary_path.name}.{os.getpid()}.partial")
|
|
shutil.move(str(staging), tmp_final)
|
|
try:
|
|
tmp_final.chmod(0o755)
|
|
except OSError as e:
|
|
if not sys.platform.startswith("win"):
|
|
logger.warning("chmod +x failed for %s: %s", tmp_final, e)
|
|
# On Windows the .exe is already executable; elsewhere we logged.
|
|
os.replace(tmp_final, binary_path)
|
|
return binary_path
|
|
|
|
|
|
def ensure_tools(quiet: bool = False) -> dict[str, Path | None]:
|
|
if not _has_writable_existing_parent(cache_dir()):
|
|
return {name: which(name) for name in _registry().get("tools", {})}
|
|
"""Install every tool in the registry if missing. Safe to call repeatedly.
|
|
|
|
Called at proxy startup and on first `headroom` CLI invocation so that no
|
|
tool fetch ever happens inside a live request. Skips tools that are on
|
|
PATH, already cached, or distributed via PyPI-only (ast-grep).
|
|
|
|
Returns a map of tool_name -> resolved Path (or None if unsupported).
|
|
Never raises; unsupported platforms or offline errors are logged via
|
|
stderr and the tool is skipped.
|
|
"""
|
|
out: dict[str, Path | None] = {}
|
|
for name in _registry().get("tools", {}):
|
|
try:
|
|
if _is_pypi_tool(name):
|
|
# ast-grep ships via pip; just record whether it's on PATH.
|
|
out[name] = _path_lookup(name)
|
|
continue
|
|
out[name] = resolve(name)
|
|
except (
|
|
PlatformNotSupported,
|
|
OfflineError,
|
|
BinaryFetchError,
|
|
Sha256Mismatch,
|
|
# Catch readonly / sandboxed filesystems (e.g. containerized
|
|
# home dirs) so proxy startup never fails because the cache dir
|
|
# can't be created. The interceptor fall back to no-op.
|
|
OSError,
|
|
) as e:
|
|
out[name] = None
|
|
if not quiet:
|
|
print(f"headroom: skipping {name}: {e}", file=sys.stderr)
|
|
return out
|
|
|
|
|
|
def status() -> list[dict[str, Any]]:
|
|
"""Return a list of status dicts for every tool in the registry.
|
|
|
|
Used by `headroom tools doctor`. Never fetches — only inspects.
|
|
"""
|
|
out: list[dict[str, Any]] = []
|
|
plat = detect_platform()
|
|
for name, entry in _registry().get("tools", {}).items():
|
|
row: dict[str, Any] = {
|
|
"tool": name,
|
|
"version": entry.get("version"),
|
|
"platform": plat.key(),
|
|
"source": entry.get("source", "fetched"),
|
|
"path": None,
|
|
"state": "missing",
|
|
}
|
|
# Honor PATH.
|
|
on_path = shutil.which(name) or (
|
|
shutil.which(entry["binary"]) if entry.get("binary") else None
|
|
)
|
|
if on_path:
|
|
row["path"] = on_path
|
|
row["state"] = "on-path"
|
|
out.append(row)
|
|
continue
|
|
try:
|
|
asset = _asset_for_platform(name, plat)
|
|
except PlatformNotSupported as e:
|
|
row["state"] = "unsupported-platform"
|
|
row["detail"] = str(e)
|
|
out.append(row)
|
|
continue
|
|
row["sha_pinned"] = bool(asset.get("sha256"))
|
|
cached = _cached_path(name, entry["version"], plat)
|
|
if cached.exists():
|
|
row["path"] = str(cached)
|
|
row["state"] = "cached"
|
|
out.append(row)
|
|
return out
|