diff --git a/README.md b/README.md index b92fd2a..34a5e65 100644 --- a/README.md +++ b/README.md @@ -114,10 +114,27 @@ $ posit connect api v1/users -X GET -f page_size=5 # or force GET $ posit connect api v1/content -f name=my-app # POST body (creates content) ``` +## `posit connect version` + +Print the Connect server version using your saved credentials: + +```console +$ posit connect version +$ posit connect version --name production +$ posit connect version --server https://connect.example.com --api-key "$CONNECT_API_KEY" +``` + +The command supports `-n`/`--name`, `-s`/`--server`, `-k`/`--api-key`, +`--no-tls-verify`, and `-c`/`--cacert`, with the same connection environment +variables as `posit connect api`. It prints an empty line if the server hides +its version. Rsconnect's `CONNECT_SERVER_VERSION` environment variable overrides +the reported version. + ## `posit connect *` `posit` wraps [`rsconnect-python`](https://github.com/posit-dev/rsconnect-python): it re-exposes the full `rsconnect` command set under `posit connect`, in addition to the `api` utility. +The `version` command is overridden to report the Connect server version. ## Using `posit` with Claude Code diff --git a/src/posit_cli/connect/__init__.py b/src/posit_cli/connect/__init__.py index a7cafad..6a5e662 100644 --- a/src/posit_cli/connect/__init__.py +++ b/src/posit_cli/connect/__init__.py @@ -9,6 +9,7 @@ from rsconnect.main import cli as rsconnect_cli from .api import api as api_cmd +from .version import version as version_cmd _epilog = ( @@ -29,3 +30,5 @@ def connect() -> None: connect.add_command(_cmd, name=_name) connect.add_command(api_cmd, name="api") +# The mounted command reports the rsconnect package version. +connect.add_command(version_cmd, name="version") diff --git a/src/posit_cli/connect/version.py b/src/posit_cli/connect/version.py new file mode 100644 index 0000000..61f3360 --- /dev/null +++ b/src/posit_cli/connect/version.py @@ -0,0 +1,68 @@ +"""Print the selected Connect server's version.""" + +from typing import Optional + +import click +from rsconnect.api import RSConnectException, RSConnectExecutor + + +@click.command( + context_settings={"help_option_names": ["-h", "--help"]}, + short_help="Show the Connect server version.", +) +@click.option("--name", "-n", default=None, help="Nickname of a saved server.") +@click.option( + "--server", + "-s", + default=None, + envvar="CONNECT_SERVER", + help="Connect server URL [env: CONNECT_SERVER].", +) +@click.option( + "--api-key", + "-k", + default=None, + envvar="CONNECT_API_KEY", + help="Connect API key [env: CONNECT_API_KEY].", +) +@click.option( + "--no-tls-verify", + "insecure", + is_flag=True, + default=False, + envvar="CONNECT_INSECURE", + help="Skip TLS certificate verification (still uses TLS) [env: CONNECT_INSECURE].", +) +@click.option( + "--cacert", + "-c", + type=click.Path(exists=True, dir_okay=False), + default=None, + envvar="CONNECT_CA_CERTIFICATE", + help="Path to trusted TLS CA certificate.", +) +def version( + name: Optional[str], + server: Optional[str], + api_key: Optional[str], + insecure: bool, + cacert: Optional[str], +) -> None: + """Print the Connect server version using saved credentials by default. + + CONNECT_SERVER_VERSION overrides the reported version. If the server hides + its version, print an empty line. + """ + try: + executor = RSConnectExecutor( + ctx=None, + name=name, + url=server, + api_key=api_key, + insecure=insecure, + cacert=cacert, + ) + server_version = executor.client.server_version() + except RSConnectException as exc: + raise click.ClickException(str(exc)) from exc + click.echo(server_version) diff --git a/tests/test_rsconnect_contract.py b/tests/test_rsconnect_contract.py index bbb0881..2cb4c6e 100644 --- a/tests/test_rsconnect_contract.py +++ b/tests/test_rsconnect_contract.py @@ -17,6 +17,7 @@ def test_executor_surface(): assert expected in params, f"RSConnectExecutor lost __init__ param {expected!r}" assert hasattr(RSConnectClient, "request") + assert callable(getattr(RSConnectClient, "server_version", None)) req_params = inspect.signature(RSConnectClient.request).parameters for expected in ("method", "path", "query_params", "body", "headers"): assert expected in req_params, f"RSConnectClient.request lost {expected!r}" diff --git a/tests/test_version.py b/tests/test_version.py new file mode 100644 index 0000000..e0c1b56 --- /dev/null +++ b/tests/test_version.py @@ -0,0 +1,97 @@ +"""Tests for Connect server version output and connection selection.""" + +from unittest.mock import patch + +import pytest +from click.testing import CliRunner +from rsconnect.api import RSConnectClient, RSConnectException, RSConnectServer + +from posit_cli.__main__ import cli + + +@pytest.mark.parametrize( + "settings, output", [({"version": "2026.10.0"}, "2026.10.0\n"), ({}, "\n")] +) +def test_server_version(settings, output, monkeypatch): + monkeypatch.delenv("CONNECT_SERVER_VERSION", raising=False) + client = RSConnectClient(RSConnectServer("https://connect.example.com", api_key="key")) + with patch("posit_cli.connect.version.RSConnectExecutor") as executor: + with patch.object(client, "get", return_value=settings) as get: + executor.return_value.client = client + result = CliRunner().invoke(cli, ["connect", "version"]) + assert result.exit_code == 0, result.output + assert result.output == output + get.assert_called_once_with("server_settings") + executor.assert_called_once_with( + ctx=None, name=None, url=None, api_key=None, insecure=False, cacert=None + ) + + +def test_server_version_override(monkeypatch): + monkeypatch.setenv("CONNECT_SERVER_VERSION", "2026.09.0") + client = RSConnectClient(RSConnectServer("https://connect.example.com", api_key="key")) + with patch("posit_cli.connect.version.RSConnectExecutor") as executor: + with patch.object(client, "get") as get: + executor.return_value.client = client + result = CliRunner().invoke(cli, ["connect", "version"]) + assert result.exit_code == 0, result.output + assert result.output == "2026.09.0\n" + get.assert_not_called() + + +@pytest.mark.parametrize("use_env", [False, True]) +def test_connection_options(tmp_path, use_env): + cacert = tmp_path / "ca.pem" + cacert.write_text("certificate") + args = ["connect", "version", "--name", "production"] + env = {} + if use_env: + env = { + "CONNECT_SERVER": "https://connect.example.com", + "CONNECT_API_KEY": "key", + "CONNECT_INSECURE": "true", + "CONNECT_CA_CERTIFICATE": str(cacert), + } + else: + args += [ + "-s", + "https://connect.example.com", + "-k", + "key", + "--no-tls-verify", + "-c", + str(cacert), + ] + with patch("posit_cli.connect.version.RSConnectExecutor") as executor: + executor.return_value.client.server_version.return_value = "2026.10.0" + result = CliRunner().invoke(cli, args, env=env) + assert result.exit_code == 0, result.output + executor.assert_called_once_with( + ctx=None, + name="production", + url="https://connect.example.com", + api_key="key", + insecure=True, + cacert=str(cacert), + ) + + +@pytest.mark.parametrize("stage", ["selection", "request"]) +def test_errors(stage): + with patch("posit_cli.connect.version.RSConnectExecutor") as executor: + error = RSConnectException("unable to retrieve server version") + if stage == "selection": + executor.side_effect = error + else: + executor.return_value.client.server_version.side_effect = error + result = CliRunner().invoke(cli, ["connect", "version"]) + assert result.exit_code == 1 + assert result.output == "Error: unable to retrieve server version\n" + assert isinstance(result.exception, SystemExit) + + +def test_help(): + result = CliRunner().invoke(cli, ["connect", "version", "-h"]) + assert result.exit_code == 0 + assert "Connect server version" in result.output + assert "--server" in result.output