diff --git a/libs/cli/langgraph_cli/deploy.py b/libs/cli/langgraph_cli/deploy.py index 7507482c1..f83946e32 100644 --- a/libs/cli/langgraph_cli/deploy.py +++ b/libs/cli/langgraph_cli/deploy.py @@ -2536,6 +2536,8 @@ def deploy_listeners() -> None: def deploy_listeners_list( api_key: str | None, host_url: str | None, json_output: bool ) -> None: + global _no_input + _no_input = json_output client = _create_host_backend_client(host_url, api_key) listeners = _call_host_backend_with_optional_tenant( client, lambda c: c.list_listeners() diff --git a/libs/cli/tests/unit_tests/cli/test_cli.py b/libs/cli/tests/unit_tests/cli/test_cli.py index 01838dec2..5b621b8ad 100644 --- a/libs/cli/tests/unit_tests/cli/test_cli.py +++ b/libs/cli/tests/unit_tests/cli/test_cli.py @@ -14,6 +14,7 @@ import langgraph_cli.deploy as deploy_module from langgraph_cli.cli import cli, prepare_args_and_stdin from langgraph_cli.config import Config, _get_pip_cleanup_lines, validate_config from langgraph_cli.docker import DEFAULT_POSTGRES_URI, DockerCapabilities, Version +from langgraph_cli.host_backend import HostBackendError from langgraph_cli.util import clean_empty_lines FORMATTED_CLEANUP_LINES = _get_pip_cleanup_lines( @@ -536,6 +537,9 @@ def test_deploy_listeners_list_command_no_results(monkeypatch) -> None: def test_deploy_listeners_list_command_json(monkeypatch) -> None: + # --json sets the module-level _no_input global as a side effect; pin + # the "before" value so monkeypatch restores it after this test. + monkeypatch.setattr(deploy_module, "_no_input", False) runner = CliRunner() listeners = [ @@ -579,6 +583,9 @@ def test_deploy_listeners_list_command_json(monkeypatch) -> None: def test_deploy_listeners_list_command_json_no_results(monkeypatch) -> None: + # --json sets the module-level _no_input global as a side effect; pin + # the "before" value so monkeypatch restores it after this test. + monkeypatch.setattr(deploy_module, "_no_input", False) runner = CliRunner() class FakeClient: @@ -608,6 +615,52 @@ def test_deploy_listeners_list_command_json_no_results(monkeypatch) -> None: assert json.loads(result.output) == [] +def test_deploy_listeners_list_command_json_org_scoped_403(monkeypatch) -> None: + """With --json, an org-scoped 403 must raise cleanly instead of + prompting for a workspace ID, which would write to stdout and hang + waiting for input that a scripted invocation never provides.""" + # deploy_listeners_list sets the module-level _no_input global as a + # side effect; pin its "before" value so monkeypatch restores it after + # this test instead of leaking True into whichever test runs next. + monkeypatch.setattr(deploy_module, "_no_input", False) + runner = CliRunner() + + class FakeClient: + def __init__(self, host_url: str, api_key: str, tenant_id: str | None = None): + pass + + def list_listeners(self): + raise HostBackendError("requires workspace specification", status_code=403) + + def set_tenant(self, tenant_id: str) -> None: + raise AssertionError("must not retry with a prompted tenant with --json") + + monkeypatch.setattr(deploy_module, "HostBackendClient", FakeClient) + + prompt_calls = [] + monkeypatch.setattr( + click, "prompt", lambda *a, **k: prompt_calls.append((a, k)) or "unreachable" + ) + + result = runner.invoke( + cli, + [ + "deploy", + "listeners", + "list", + "--json", + "--api-key", + "test-key", + "--host-url", + "https://api.example.com", + ], + ) + + assert prompt_calls == [], "must not prompt interactively with --json" + assert result.exit_code != 0 + assert "LANGSMITH_TENANT_ID" in result.output + + def test_deploy_revisions_list_command(monkeypatch) -> None: runner = CliRunner() captured: dict[str, str] = {}