fix(remote): resolve service names against the remote server

Remote mode resolved names with the local alias table and built an empty stub for any unknown tag, so an unentitled service showed a blank help page. Resolve against the server's tags and aliases, and reject unknown names with the list the server does offer.
This commit is contained in:
imSp4rky
2026-08-28 10:54:21 -06:00
parent 3cb3613565
commit c49fccf3b5
3 changed files with 82 additions and 18 deletions
+3 -1
View File
@@ -101,7 +101,9 @@ In `--remote` mode unshackle turns the server's service list into synthetic CLI
commands that operate against it, falling back to the tags in that `services` sub-dict when commands that operate against it, falling back to the tags in that `services` sub-dict when
unshackle cannot fetch the list. Each synthetic command carries the server-side service's options and unshackle cannot fetch the list. Each synthetic command carries the server-side service's options and
documentation, so `unshackle dl --remote <TAG> -h` shows the same help text as it does on the documentation, so `unshackle dl --remote <TAG> -h` shows the same help text as it does on the
server. See [remote sessions](../../dev/rest-api/remote-sessions.md) for the server. Service names resolve against the server's tags and aliases, not your local services.
If the server does not offer a name to your API key, unshackle rejects the name and lists the
tags it does offer. See [remote sessions](../../dev/rest-api/remote-sessions.md) for the
full setup. full setup.
## `serve` ## `serve`
@@ -0,0 +1,51 @@
"""make_remote_command resolves names against the remote server's list and reports the failures."""
from __future__ import annotations
from unittest.mock import patch
import click
import pytest
from unshackle.core.services import Services
SERVED = [
{"tag": "AMZN", "url": "https://example.com", "help": "Amazon.", "cli_params": [], "aliases": []},
{"tag": "HMAX", "url": "https://example.com", "help": "Max.", "cli_params": [], "aliases": ["MAX", "hbomax"]},
]
def _make(name: str) -> click.Command:
return Services.make_remote_command(name, ctx=click.Context(click.Command("dl")))
def test_unknown_name_raises_with_typed_name_and_available_list() -> None:
with patch.object(Services, "fetch_remote_services", return_value=SERVED):
with pytest.raises(click.ClickException, match=r"does not offer a service named 'BBC'.*AMZN, HMAX"):
_make("BBC")
def test_served_tag_builds_full_command() -> None:
with patch.object(Services, "fetch_remote_services", return_value=SERVED):
cmd = _make("AMZN")
assert cmd.help == "Amazon."
def test_server_alias_resolves_to_server_tag() -> None:
with patch.object(Services, "fetch_remote_services", return_value=SERVED):
cmd = _make("max")
assert cmd.name == "HMAX"
assert cmd.help == "Max."
def test_empty_service_list_raises_no_services_message() -> None:
with patch.object(Services, "fetch_remote_services", return_value=[]):
with pytest.raises(click.ClickException, match=r"offers no services to your API key"):
_make("AMZN")
def test_fetch_failure_keeps_stub_instead_of_raising() -> None:
with patch.object(Services, "fetch_remote_services", return_value=None):
cmd = _make("AMZN")
assert cmd.name == "AMZN"
assert cmd.help is None
+28 -17
View File
@@ -300,7 +300,7 @@ class Services(click.Group):
remote = ctx.params.get("remote") or (ctx.parent and ctx.parent.params.get("remote")) remote = ctx.params.get("remote") or (ctx.parent and ctx.parent.params.get("remote"))
if remote: if remote:
return Services.make_remote_command(tag, ctx) return Services.make_remote_command(name, ctx)
try: try:
service = Services.load(tag) service = Services.load(tag)
@@ -355,9 +355,33 @@ class Services(click.Group):
return None return None
@staticmethod @staticmethod
def make_remote_command(tag: str, ctx: click.Context) -> click.Command: def make_remote_command(name: str, ctx: click.Context) -> click.Command:
"""Make a Click command for a remote service with server-provided options.""" """Make a Click command for a remote service, with the options the remote server gives."""
svc_info = Services.fetch_remote_service_info(tag, ctx) # A failed fetch (None) means the remote server was unreachable, not that the name is
# invalid - fall through to a bare stub so `-h` keeps working offline.
services = Services.fetch_remote_services(ctx)
if services is None:
svc_info = None
tag = Services.get_tag(name)
else:
# Resolve against the remote server's tags and aliases, not the local tables: the
# two drift apart as soon as either side's services change. Tags win over aliases,
# as in local get_tag.
lowered = name.lower()
svc_info = next((svc for svc in services if str(svc.get("tag", "")).lower() == lowered), None)
if svc_info is None:
svc_info = next(
(svc for svc in services if lowered in (str(a).lower() for a in svc.get("aliases") or [])), None
)
if svc_info is None:
if services:
available = ", ".join(sorted(svc["tag"] for svc in services))
raise click.ClickException(
f"The remote server does not offer a service named '{name}'. "
f"It offers these services: {available}"
)
raise click.ClickException("The remote server offers no services to your API key.")
tag = svc_info["tag"]
short_help = svc_info.get("url") if svc_info else None short_help = svc_info.get("url") if svc_info else None
help_text = svc_info.get("help") if svc_info else None help_text = svc_info.get("help") if svc_info else None
if help_text: if help_text:
@@ -410,19 +434,6 @@ class Services(click.Group):
return import_cli return import_cli
@staticmethod
def fetch_remote_service_info(tag: str, ctx: click.Context) -> dict | None:
"""Fetch service info for a specific service from the remote server."""
try:
services = Services.fetch_remote_services(ctx)
if services:
for svc in services:
if svc.get("tag") == tag:
return svc
except Exception as e:
log.debug(f"Remote service info lookup failed for {tag}: {e!r}")
return None
@staticmethod @staticmethod
def get_tags() -> list[str]: def get_tags() -> list[str]:
"""Returns a list of service tags from all available Services.""" """Returns a list of service tags from all available Services."""