Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 27 additions & 11 deletions nerve/proxy/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,18 @@

GITHUB_RELEASES_API = "https://api.github.com/repos/router-for-me/CLIProxyAPI/releases/latest"

# Map platform.machine() → GitHub release asset suffix.
# Map platform.machine() → GitHub release asset suffix, for Linux only; darwin
# is resolved directly below. Windows has no entry: the downloader only opens
# .tar.gz and the project ships Windows builds as .zip.
#
# CLIProxyAPI names its 64-bit ARM builds "aarch64", not "arm64". Asking for
# "arm64" matched no asset on an Apple Silicon Mac or an ARM VPS, and the error
# ("No CLIProxyAPI asset found for darwin_arm64") reads as a missing build
# rather than a wrong name.
_ARCH_MAP: dict[str, str] = {
"x86_64": "linux_amd64",
"aarch64": "linux_arm64",
"arm64": "linux_arm64", # macOS-style
"AMD64": "windows_amd64", # Windows
"aarch64": "linux_aarch64",
"arm64": "linux_aarch64", # macOS-style spelling of the same arch
}


Expand All @@ -45,14 +51,29 @@ def _detect_asset_suffix() -> str:
machine = platform.machine()

if system == "darwin":
return "darwin_arm64" if machine in ("arm64", "aarch64") else "darwin_amd64"
return "darwin_aarch64" if machine in ("arm64", "aarch64") else "darwin_amd64"
if system == "linux":
mapped = _ARCH_MAP.get(machine)
if mapped:
return mapped
raise RuntimeError(f"Unsupported platform: {system}/{machine}")


def _select_asset_url(assets: list[dict[str, Any]], suffix: str) -> str | None:
"""Return the download URL of the full build for ``suffix``, if published.

A release carries both ``CLIProxyAPI_<ver>_linux_aarch64.tar.gz`` and
``CLIProxyAPI_<ver>_linux_aarch64_no-plugin.tar.gz``, so the suffix is a
substring of two assets and a substring match picks whichever GitHub lists
first — lately the stripped no-plugin build. Anchor on the exact tail.
"""
wanted = f"_{suffix}.tar.gz"
for asset in assets:
if asset["name"].endswith(wanted):
return asset["browser_download_url"]
return None


class ProxyService:
"""Manages the CLIProxyAPI subprocess lifecycle."""

Expand Down Expand Up @@ -91,12 +112,7 @@ async def _download_binary(self, dest: Path) -> None:
logger.info("Latest CLIProxyAPI release: %s", tag)

# Find matching asset.
asset_url: str | None = None
for asset in release.get("assets", []):
name: str = asset["name"]
if suffix in name and name.endswith(".tar.gz"):
asset_url = asset["browser_download_url"]
break
asset_url = _select_asset_url(release.get("assets", []), suffix)

if not asset_url:
raise RuntimeError(
Expand Down
101 changes: 101 additions & 0 deletions tests/test_proxy_asset_suffix.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
"""The release asset the proxy downloader picks out of a CLIProxyAPI release.

Two ways this went wrong:

*Wrong name.* CLIProxyAPI publishes 64-bit ARM builds as ``aarch64``; the
downloader asked for ``arm64`` and so found nothing on exactly the two platforms
Nerve is most likely to run on — an Apple Silicon Mac and an ARM VPS. The
failure surfaced during ``nerve init`` as "No CLIProxyAPI asset found for
darwin_arm64", which reads like the build is missing rather than misnamed, and
pushed the operator toward buying a separate API key.

*Wrong build.* Each platform ships twice — a full build and a stripped
``_no-plugin`` one — and the suffix is a substring of both names. The match had
no tiebreak, so the winner was whichever GitHub listed first: the no-plugin
build, in every one of the last five releases.

Names verified against release v7.2.131.
"""

from __future__ import annotations

import pytest

from nerve.proxy.service import _detect_asset_suffix, _select_asset_url

# The tar.gz builds the downloader can actually install. Windows assets ship as
# .zip, which the downloader cannot open — and _detect_asset_suffix never
# returns a windows suffix — so they are deliberately absent.
PUBLISHED = {
"darwin_aarch64", "darwin_amd64",
"linux_aarch64", "linux_amd64",
"freebsd_amd64",
}

VERSION = "7.2.131"


def _asset(name: str) -> dict[str, str]:
return {
"name": name,
"browser_download_url": f"https://example.invalid/{name}",
}


@pytest.mark.parametrize(
"system,machine,expected",
[
("Darwin", "arm64", "darwin_aarch64"),
("Darwin", "aarch64", "darwin_aarch64"),
("Darwin", "x86_64", "darwin_amd64"),
("Linux", "aarch64", "linux_aarch64"),
("Linux", "arm64", "linux_aarch64"),
("Linux", "x86_64", "linux_amd64"),
],
)
def test_suffix_matches_a_published_asset(monkeypatch, system, machine, expected):
monkeypatch.setattr("platform.system", lambda: system)
monkeypatch.setattr("platform.machine", lambda: machine)

suffix = _detect_asset_suffix()
assert suffix == expected
assert suffix in PUBLISHED, f"{suffix} is not a name the project publishes"


def test_unsupported_platform_still_raises(monkeypatch):
monkeypatch.setattr("platform.system", lambda: "SunOS")
monkeypatch.setattr("platform.machine", lambda: "sparc")
with pytest.raises(RuntimeError):
_detect_asset_suffix()


def test_windows_is_unsupported_not_silently_mismatched(monkeypatch):
"""Windows ships .zip only, so it must fail loudly rather than half-work."""
monkeypatch.setattr("platform.system", lambda: "Windows")
monkeypatch.setattr("platform.machine", lambda: "AMD64")
with pytest.raises(RuntimeError):
_detect_asset_suffix()


@pytest.mark.parametrize("suffix", ["linux_aarch64", "linux_amd64", "darwin_aarch64"])
@pytest.mark.parametrize("no_plugin_first", [True, False])
def test_full_build_wins_over_no_plugin_in_either_order(suffix, no_plugin_first):
full = _asset(f"CLIProxyAPI_{VERSION}_{suffix}.tar.gz")
stripped = _asset(f"CLIProxyAPI_{VERSION}_{suffix}_no-plugin.tar.gz")
pair = [stripped, full] if no_plugin_first else [full, stripped]

# Padded with the other platforms so the match cannot succeed by luck.
assets = [
_asset(f"CLIProxyAPI_{VERSION}_{other}.tar.gz")
for other in sorted(PUBLISHED - {suffix})
]
assets += pair
assets.append(_asset(f"CLIProxyAPI_{VERSION}_windows_amd64.zip"))
assets.append(_asset("checksums.txt"))

assert _select_asset_url(assets, suffix) == full["browser_download_url"]


def test_missing_asset_returns_none():
assets = [_asset(f"CLIProxyAPI_{VERSION}_linux_amd64.tar.gz")]
assert _select_asset_url(assets, "linux_aarch64") is None