fix(arduino-cli): library search/install failures become one actionable sentence

arduino-cli reports errors as plain text or, with --format json, as a JSON
object on stderr. We forwarded that blob verbatim, so on a fresh install
with no library index yet (and no way through the firewall/proxy to
downloads.arduino.cc) the in-app agent told the user "the library server
returns a JSON error" ten times in a row.

humanize_cli_error() maps the known shapes — index download failed,
library zip download failed, library not found — to a single sentence
that says what to check, and keeps the raw text under raw_error. Applied
to search_libraries (rc != 0 and non-JSON stdout) and install_library.
Tests use the real arduino-cli 1.0.4 output captured offline.
This commit is contained in:
David Montero 2026-08-18 00:28:07 +02:00
parent f09489f498
commit 8abed79ad9
2 changed files with 192 additions and 4 deletions

View File

@ -23,6 +23,85 @@ def _looks_like_missing_header(stderr: str | None) -> bool:
return bool(stderr and _MISSING_HEADER_RE.search(stderr))
# ── arduino-cli error humanizer ──────────────────────────────────────────────
# arduino-cli reports failures in two shapes: plain text on stderr, or (when
# invoked with --format json) a JSON object {"error": "...", "warnings": [...]}
# on stderr. We used to hand that raw blob straight to the caller, so a fresh
# desktop install with no library index yet — and no way to reach
# downloads.arduino.cc through the user's firewall / proxy — surfaced to the
# in-app agent as "the library server returns a JSON error" (2026-08-17,
# a Maker cancelled 16 minutes after subscribing). Turn the known shapes into
# one actionable sentence and keep the raw text under a separate key.
_INDEX_DOWNLOAD_MARKERS = (
"error downloading index",
"library_index.tar.bz2",
"library_index.json",
"package_index.json",
"error updating library index",
"error initializing instance",
)
_NETWORK_MARKERS = (
"downloads.arduino.cc",
"dial tcp",
"dial udp",
"network is unreachable",
"no such host",
"connection refused",
"tls handshake",
"i/o timeout",
"context deadline exceeded",
"proxy",
)
def humanize_cli_error(raw: str | None, *, action: str = "run arduino-cli") -> str:
"""One readable sentence for an arduino-cli failure.
`raw` is whatever arduino-cli wrote (stderr, or stdout+stderr). `action`
names what we were doing ("search libraries", "install Adafruit GFX") so
the message reads naturally in the UI and in the agent's tool result.
"""
text = (raw or "").strip()
# JSON-shaped error (--format json): {"error": "...", "warnings": [...]}
if text.startswith("{"):
try:
import json as _json
obj = _json.loads(text)
if isinstance(obj, dict):
parts = [str(obj.get("error") or "")]
warns = obj.get("warnings")
if isinstance(warns, list):
parts.extend(str(w) for w in warns)
text = "\n".join(p for p in parts if p).strip()
except ValueError:
pass
low = text.lower()
if any(m in low for m in _INDEX_DOWNLOAD_MARKERS) or (
"index" in low and any(m in low for m in _NETWORK_MARKERS)
):
return (
f"Could not {action}: the Arduino library index is not available yet "
"and arduino-cli could not download it from downloads.arduino.cc. "
"Check your internet connection, firewall or proxy and try again."
)
if "can't download library" in low or (
"download" in low and any(m in low for m in _NETWORK_MARKERS)
):
return (
f"Could not {action}: the download from downloads.arduino.cc failed. "
"Check your internet connection, firewall or proxy and try again."
)
if "not found" in low or "no library found" in low:
first = next((ln.strip() for ln in text.splitlines() if ln.strip()), text)
return f"Could not {action}: {first[:300]}"
if not text:
return f"Could not {action}: arduino-cli returned no output."
first = next((ln.strip() for ln in text.splitlines() if ln.strip()), text)
return f"Could not {action}: {first[:300]}"
class ArduinoCLIService:
# Board manager URLs for cores that aren't built-in
CORE_URLS: dict[str, str] = {
@ -658,7 +737,11 @@ class ArduinoCLIService:
if result.returncode != 0:
print(f"Error searching libraries: {stderr}")
return {"success": False, "error": stderr}
return {
"success": False,
"error": humanize_cli_error(stderr or stdout, action="search libraries"),
"raw_error": stderr,
}
import json
try:
@ -686,7 +769,13 @@ class ArduinoCLIService:
return {"success": True, "libraries": libraries}
except json.JSONDecodeError:
return {"success": False, "error": "Invalid output format from arduino-cli"}
# rc was 0 but stdout is not JSON: arduino-cli printed a
# progress/warning line ahead of (or instead of) the payload.
return {
"success": False,
"error": humanize_cli_error(stderr or stdout, action="search libraries"),
"raw_error": (stderr or stdout)[:2000],
}
except Exception as e:
print(f"Exception searching libraries: {e}")
@ -763,7 +852,15 @@ class ArduinoCLIService:
"requested_version": version,
}
print(f"Failed to install {library_name}: {result.stderr}")
return {"success": False, "error": result.stderr, "stdout": result.stdout}
return {
"success": False,
"error": humanize_cli_error(
(result.stderr or "") + "\n" + (result.stdout or ""),
action=f"install {library_name}",
),
"raw_error": result.stderr,
"stdout": result.stdout,
}
except Exception as e:
print(f"Exception installing library: {e}")

View File

@ -0,0 +1,91 @@
"""arduino-cli failures reach the UI / agent as one actionable sentence.
Regression for 2026-08-17: a fresh desktop install has no library index
yet; when arduino-cli could not download it (firewall / proxy) `lib search`
returned a JSON blob on stderr and we forwarded it verbatim, so the in-app
agent told the user "the library server returns a JSON error" ten times in
a row. The strings below are the real arduino-cli 1.0.4 output captured
offline (`unshare -n`) against an empty data dir.
"""
from __future__ import annotations
import sys
from pathlib import Path
_REPO = Path(__file__).resolve().parents[3]
sys.path.insert(0, str(_REPO / "backend"))
from app.services.arduino_cli import humanize_cli_error # noqa: E402
SEARCH_JSON_STDERR = (
'{\n "error": "Error updating library index: Error downloading index '
"'https://downloads.arduino.cc/libraries/library_index.tar.bz2': Get "
'\\"https://downloads.arduino.cc/libraries/library_index.tar.bz2\\": dial tcp: '
'lookup downloads.arduino.cc on 127.0.0.53:53: dial udp 127.0.0.53:53: connect: '
'network is unreachable",\n "warnings": [\n "Error initializing instance: '
"Error downloading index 'https://downloads.arduino.cc/libraries/library_index.tar.bz2'\"\n ]\n}"
)
INSTALL_NO_INDEX_STDERR = (
"Error initializing instance: Error downloading index "
"'https://downloads.arduino.cc/libraries/library_index.tar.bz2': Get "
'"https://downloads.arduino.cc/libraries/library_index.tar.bz2": dial tcp: lookup '
"downloads.arduino.cc on 127.0.0.53:53: dial udp 127.0.0.53:53: connect: network is unreachable\n"
"Error initializing instance: Loading index file: reading library_index.json: open "
"/x/data/library_index.json: no such file or directory\n"
)
INSTALL_NO_NET_WITH_INDEX = (
"Downloading Adafruit GFX Library@1.12.6...\n"
'Adafruit GFX Library@1.12.6 Get "https://downloads.arduino.cc/libraries/github.com/adafruit/'
'Adafruit_GFX_Library-1.12.6.zip?query=install": dial tcp: lookup downloads.arduino.cc on '
"127.0.0.53:53: dial udp 127.0.0.53:53: connect: network is unreachable\n"
"Error installing Adafruit GFX Library: Can't download library: Get "
'"https://downloads.arduino.cc/libraries/github.com/adafruit/Adafruit_GFX_Library-1.12.6.zip'
'?query=install": dial tcp: lookup downloads.arduino.cc on 127.0.0.53:53: dial udp '
"127.0.0.53:53: connect: network is unreachable\n"
)
def test_search_json_error_becomes_index_download_sentence():
msg = humanize_cli_error(SEARCH_JSON_STDERR, action="search libraries")
assert msg.startswith("Could not search libraries:")
assert "library index" in msg
assert "downloads.arduino.cc" in msg
assert "firewall or proxy" in msg
# No JSON leaks through to the user / agent.
assert "{" not in msg and "warnings" not in msg
def test_install_without_index_is_the_same_story():
msg = humanize_cli_error(INSTALL_NO_INDEX_STDERR, action="install Adafruit GFX Library")
assert msg.startswith("Could not install Adafruit GFX Library:")
assert "library index" in msg
assert "downloads.arduino.cc" in msg
def test_install_with_index_but_no_network_names_the_download():
msg = humanize_cli_error(INSTALL_NO_NET_WITH_INDEX, action="install Adafruit GFX Library")
assert msg.startswith("Could not install Adafruit GFX Library:")
assert "download from downloads.arduino.cc failed" in msg
assert "firewall or proxy" in msg
def test_unknown_library_passes_the_cli_line_through():
msg = humanize_cli_error("Error installing NoSuchLib: Library 'NoSuchLib' not found", action="install NoSuchLib")
assert msg == "Could not install NoSuchLib: Error installing NoSuchLib: Library 'NoSuchLib' not found"
def test_empty_and_unknown_shapes_still_return_a_sentence():
assert humanize_cli_error("", action="search libraries") == (
"Could not search libraries: arduino-cli returned no output."
)
assert humanize_cli_error(None, action="search libraries").startswith("Could not search libraries:")
long = "x" * 1000
msg = humanize_cli_error(long, action="search libraries")
assert msg.startswith("Could not search libraries: ") and len(msg) < 400
def test_multiline_unknown_error_uses_first_line_only():
msg = humanize_cli_error("\n\nfirst line here\nsecond line\n", action="search libraries")
assert msg == "Could not search libraries: first line here"