From f3cba4fd1b8755ddfe2ad244193e6d34b9249d05 Mon Sep 17 00:00:00 2001 From: edavidaja Date: Mon, 13 Jul 2026 14:52:50 -0400 Subject: [PATCH 1/6] feat: add backwards-compatible `rsconnect server` command group --- rsconnect/main.py | 16 ++++++++++++++++ tests/test_main.py | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/rsconnect/main.py b/rsconnect/main.py index 733477422..0f0529d9e 100644 --- a/rsconnect/main.py +++ b/rsconnect/main.py @@ -950,6 +950,22 @@ def remove( click.echo("Note: the removed server was the default. Use `rsconnect add --set-default` to set a new one.") +@cli.group("server", no_args_is_help=True) +def server_group(): + """ + Manage saved Posit Connect and shinyapps.io server nicknames. + + These subcommands are aliases for the top-level add, list, remove, and + details commands, grouped under a single namespace. + """ + + +server_group.add_command(add) +server_group.add_command(list_servers) +server_group.add_command(remove) +server_group.add_command(details) + + def _resolve_identity_token(identity_token: Optional[str], identity_token_file: Optional[str]) -> Optional[str]: """Resolve the identity token from the flag/env var or a file. diff --git a/tests/test_main.py b/tests/test_main.py index 817a7833d..5ec9c42f4 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1774,6 +1774,43 @@ def test_add_set_default(self, tmp_path): os.environ["CONNECT_SERVER"] = original_server_value +class TestServerGroup: + def test_server_group_help_lists_subcommands(self): + runner = CliRunner() + result = runner.invoke(cli, ["server", "--help"]) + assert result.exit_code == 0, result.output + for sub in ("add", "list", "remove", "details"): + assert sub in result.output + + def test_server_group_no_args_shows_help(self): + runner = CliRunner() + result = runner.invoke(cli, ["server"]) + assert result.exit_code == 0, result.output + assert "Usage:" in result.output + + def test_server_list_alias(self, tmp_path): + from rsconnect.metadata import ServerStore + + store = ServerStore(base_dir=str(tmp_path)) + store.set("s1", "http://s1.local", api_key="key1") + runner = CliRunner() + with mock.patch("rsconnect.main.server_store", store): + result = runner.invoke(cli, ["server", "list"]) + assert result.exit_code == 0, result.output + assert "s1" in result.output + + def test_top_level_list_still_works(self, tmp_path): + from rsconnect.metadata import ServerStore + + store = ServerStore(base_dir=str(tmp_path)) + store.set("s1", "http://s1.local", api_key="key1") + runner = CliRunner() + with mock.patch("rsconnect.main.server_store", store): + result = runner.invoke(cli, ["list"]) + assert result.exit_code == 0, result.output + assert "s1" in result.output + + class TestDeployGit(TestCase): """Tests for deploy git CLI command.""" From e131c0bc362d7a75f6e6131582af8f9e88b0d59d Mon Sep 17 00:00:00 2001 From: edavidaja Date: Mon, 13 Jul 2026 14:56:34 -0400 Subject: [PATCH 2/6] feat: add `rsconnect server set-default` command --- rsconnect/main.py | 44 ++++++++++++++++++- tests/test_main.py | 106 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 149 insertions(+), 1 deletion(-) diff --git a/rsconnect/main.py b/rsconnect/main.py index 0f0529d9e..2bcdcc978 100644 --- a/rsconnect/main.py +++ b/rsconnect/main.py @@ -947,7 +947,48 @@ def remove( if message: click.echo(message) if removed_was_default: - click.echo("Note: the removed server was the default. Use `rsconnect add --set-default` to set a new one.") + click.echo("Note: the removed server was the default. Use `rsconnect server set-default` to set a new one.") + + +@click.command( + "set-default", + short_help="Set an already-saved server as the default.", + help=( + "Mark an already-saved server as the default. The default is used when " + "neither -n/--name nor -s/--server is given to other commands. This only " + "updates local metadata; the server is not contacted. Prefer -n/--name; " + "-s/--server must match the stored URL exactly." + ), + no_args_is_help=True, +) +@click.option("--name", "-n", help="The nickname of the server to set as the default.") +@click.option("--server", "-s", help="The URL of the server to set as the default.") +@click.option("--verbose", "-v", count=True, help="Enable verbose output. Use -vv for very verbose (debug) output.") +@click.pass_context +def set_default( + ctx: click.Context, + name: Optional[str], + server: Optional[str], + verbose: int, +): + set_verbosity(verbose) + output_params(ctx, locals().items()) + + with cli_feedback("Checking arguments"): + if name and server: + raise RSConnectException("You must specify only one of -n/--name or -s/--server.") + if not name and not server: + raise RSConnectException("You must specify one of -n/--name or -s/--server.") + + if server: + entry = server_store.get_by_url(server) + if entry is None: + raise RSConnectException('URL "%s" was not found.' % server) + name = entry["name"] + + server_store.set_default(name) + + click.echo('Server "%s" is now the default.' % name) @cli.group("server", no_args_is_help=True) @@ -964,6 +1005,7 @@ def server_group(): server_group.add_command(list_servers) server_group.add_command(remove) server_group.add_command(details) +server_group.add_command(set_default) def _resolve_identity_token(identity_token: Optional[str], identity_token_file: Optional[str]) -> Optional[str]: diff --git a/tests/test_main.py b/tests/test_main.py index 5ec9c42f4..482c5923e 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1773,6 +1773,112 @@ def test_add_set_default(self, tmp_path): if original_server_value: os.environ["CONNECT_SERVER"] = original_server_value + def test_server_set_default_by_name(self, tmp_path): + from rsconnect.metadata import ServerStore + + store = ServerStore(base_dir=str(tmp_path)) + store.set("s1", "http://s1.local", api_key="key1", set_as_default=True) + store.set("s2", "http://s2.local", api_key="key2") + runner = CliRunner() + with mock.patch("rsconnect.main.server_store", store): + result = runner.invoke(cli, ["server", "set-default", "-n", "s2"]) + assert result.exit_code == 0, result.output + assert 'Server "s2" is now the default.' in result.output + assert store.get_default()["name"] == "s2" + + def test_server_set_default_by_url_reports_nickname(self, tmp_path): + from rsconnect.metadata import ServerStore + + store = ServerStore(base_dir=str(tmp_path)) + store.set("s1", "http://s1.local", api_key="key1") + runner = CliRunner() + with mock.patch("rsconnect.main.server_store", store): + result = runner.invoke(cli, ["server", "set-default", "-s", "http://s1.local"]) + assert result.exit_code == 0, result.output + assert 'Server "s1" is now the default.' in result.output + assert store.get_default()["name"] == "s1" + + def test_server_set_default_unknown_name(self, tmp_path): + from rsconnect.metadata import ServerStore + + store = ServerStore(base_dir=str(tmp_path)) + store.set("s1", "http://s1.local", api_key="key1") + runner = CliRunner() + with mock.patch("rsconnect.main.server_store", store): + result = runner.invoke(cli, ["server", "set-default", "-n", "nope"]) + assert result.exit_code != 0 + assert "does not exist" in result.output + + def test_server_set_default_unknown_url(self, tmp_path): + from rsconnect.metadata import ServerStore + + store = ServerStore(base_dir=str(tmp_path)) + store.set("s1", "http://s1.local", api_key="key1") + runner = CliRunner() + with mock.patch("rsconnect.main.server_store", store): + result = runner.invoke(cli, ["server", "set-default", "-s", "http://absent.local"]) + assert result.exit_code != 0 + assert "was not found" in result.output + + def test_server_set_default_both_options_error(self, tmp_path): + from rsconnect.metadata import ServerStore + + store = ServerStore(base_dir=str(tmp_path)) + store.set("s1", "http://s1.local", api_key="key1") + runner = CliRunner() + with mock.patch("rsconnect.main.server_store", store): + result = runner.invoke(cli, ["server", "set-default", "-n", "s1", "-s", "http://s1.local"]) + assert result.exit_code != 0 + assert "only one of" in result.output + + def test_server_set_default_no_args_shows_help(self): + runner = CliRunner() + result = runner.invoke(cli, ["server", "set-default"]) + assert result.exit_code == 0, result.output + assert "Usage:" in result.output + + def test_server_set_default_idempotent(self, tmp_path): + from rsconnect.metadata import ServerStore + + store = ServerStore(base_dir=str(tmp_path)) + store.set("s1", "http://s1.local", api_key="key1", set_as_default=True) + runner = CliRunner() + with mock.patch("rsconnect.main.server_store", store): + result = runner.invoke(cli, ["server", "set-default", "-n", "s1"]) + assert result.exit_code == 0, result.output + assert store.get_default()["name"] == "s1" + + def test_server_set_default_shinyapps_entry(self, tmp_path): + from rsconnect.metadata import ServerStore + + store = ServerStore(base_dir=str(tmp_path)) + store.set("shiny", "https://api.shinyapps.io", account_name="acct", token="tok", secret="sec") + runner = CliRunner() + with mock.patch("rsconnect.main.server_store", store): + result = runner.invoke(cli, ["server", "set-default", "-n", "shiny"]) + assert result.exit_code == 0, result.output + assert store.get_default()["name"] == "shiny" + + def test_no_top_level_set_default(self): + runner = CliRunner() + result = runner.invoke(cli, ["set-default", "-n", "s1"]) + assert result.exit_code != 0 + assert "No such command" in result.output or "Usage:" in result.output + + def test_set_default_moves_list_marker(self, tmp_path): + from rsconnect.metadata import ServerStore + + store = ServerStore(base_dir=str(tmp_path)) + store.set("s1", "http://s1.local", api_key="key1", set_as_default=True) + store.set("s2", "http://s2.local", api_key="key2") + runner = CliRunner() + with mock.patch("rsconnect.main.server_store", store): + runner.invoke(cli, ["server", "set-default", "-n", "s2"]) + result = runner.invoke(cli, ["list"]) + assert result.exit_code == 0, result.output + s2_line = next(line for line in result.output.splitlines() if '"s2"' in line) + assert "[default]" in s2_line + class TestServerGroup: def test_server_group_help_lists_subcommands(self): From ce5c92c093ba344dac90148b6e852ab0205df7a1 Mon Sep 17 00:00:00 2001 From: edavidaja Date: Mon, 13 Jul 2026 15:01:38 -0400 Subject: [PATCH 3/6] docs: document `rsconnect server set-default` and the server group --- docs/CHANGELOG.md | 5 ++++- docs/commands/set-default.md | 4 ++++ mkdocs.yml | 1 + 3 files changed, 9 insertions(+), 1 deletion(-) create mode 100644 docs/commands/set-default.md diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index a82b7a443..0808f7261 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -48,7 +48,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Servers can now be marked as the default with `rsconnect add --set-default`. When neither `-n/--name` nor `-s/--server` is provided, the default server is used automatically. `rsconnect login` sets the server as default unless - `--no-set-default` is passed. `CONNECT_SERVER` still takes precedence. + `--no-set-default` is passed. `CONNECT_SERVER` still takes precedence. An + already-saved server can be made the default without re-adding it using + `rsconnect server set-default -n `. The new `rsconnect server` command + group also aliases `add`, `list`, `remove`, and `details`. - New `environment` subcommand for managing execution environments on Connect. - New `integration` subcommand for managing OAuth integrations on Connect. diff --git a/docs/commands/set-default.md b/docs/commands/set-default.md new file mode 100644 index 000000000..1293caa17 --- /dev/null +++ b/docs/commands/set-default.md @@ -0,0 +1,4 @@ +::: mkdocs-click + :module: rsconnect.main + :command: set_default + :prog_name: rsconnect server set-default diff --git a/mkdocs.yml b/mkdocs.yml index a92bcba56..c72e834f7 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -52,6 +52,7 @@ nav: - logout: commands/logout.md - quickstart: commands/quickstart.md - remove: commands/remove.md + - set-default: commands/set-default.md - system: commands/system.md - version: commands/version.md - write-manifest: commands/write-manifest.md From edef70ee48b965d710a0f8ebdad13c4ee7aad7b7 Mon Sep 17 00:00:00 2001 From: edavidaja Date: Mon, 13 Jul 2026 15:08:53 -0400 Subject: [PATCH 4/6] test: assert set-default in group help and idempotent message --- tests/test_main.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_main.py b/tests/test_main.py index 482c5923e..a1b5c8dca 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1846,6 +1846,7 @@ def test_server_set_default_idempotent(self, tmp_path): with mock.patch("rsconnect.main.server_store", store): result = runner.invoke(cli, ["server", "set-default", "-n", "s1"]) assert result.exit_code == 0, result.output + assert 'Server "s1" is now the default.' in result.output assert store.get_default()["name"] == "s1" def test_server_set_default_shinyapps_entry(self, tmp_path): @@ -1885,7 +1886,7 @@ def test_server_group_help_lists_subcommands(self): runner = CliRunner() result = runner.invoke(cli, ["server", "--help"]) assert result.exit_code == 0, result.output - for sub in ("add", "list", "remove", "details"): + for sub in ("add", "list", "remove", "details", "set-default"): assert sub in result.output def test_server_group_no_args_shows_help(self): From b169f69dc0f9b9a2cbcd65a282e2805b29c729cc Mon Sep 17 00:00:00 2001 From: edavidaja Date: Mon, 13 Jul 2026 15:29:55 -0400 Subject: [PATCH 5/6] feat: alias bootstrap under the `server` command group --- docs/CHANGELOG.md | 2 +- rsconnect/main.py | 4 +--- tests/test_main.py | 13 ++++++++++++- 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 0808f7261..526b1643a 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -51,7 +51,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `--no-set-default` is passed. `CONNECT_SERVER` still takes precedence. An already-saved server can be made the default without re-adding it using `rsconnect server set-default -n `. The new `rsconnect server` command - group also aliases `add`, `list`, `remove`, and `details`. + group also aliases `add`, `list`, `remove`, `details`, and `bootstrap`. - New `environment` subcommand for managing execution environments on Connect. - New `integration` subcommand for managing OAuth integrations on Connect. diff --git a/rsconnect/main.py b/rsconnect/main.py index 2bcdcc978..f083a1a64 100644 --- a/rsconnect/main.py +++ b/rsconnect/main.py @@ -995,9 +995,6 @@ def set_default( def server_group(): """ Manage saved Posit Connect and shinyapps.io server nicknames. - - These subcommands are aliases for the top-level add, list, remove, and - details commands, grouped under a single namespace. """ @@ -1006,6 +1003,7 @@ def server_group(): server_group.add_command(remove) server_group.add_command(details) server_group.add_command(set_default) +server_group.add_command(bootstrap) def _resolve_identity_token(identity_token: Optional[str], identity_token_file: Optional[str]) -> Optional[str]: diff --git a/tests/test_main.py b/tests/test_main.py index a1b5c8dca..3afe0784a 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1886,9 +1886,20 @@ def test_server_group_help_lists_subcommands(self): runner = CliRunner() result = runner.invoke(cli, ["server", "--help"]) assert result.exit_code == 0, result.output - for sub in ("add", "list", "remove", "details", "set-default"): + for sub in ("add", "list", "remove", "details", "set-default", "bootstrap"): assert sub in result.output + def test_server_bootstrap_alias_help(self): + runner = CliRunner() + result = runner.invoke(cli, ["server", "bootstrap", "--help"]) + assert result.exit_code == 0, result.output + assert "bootstrap" in result.output + + def test_top_level_bootstrap_still_works(self): + runner = CliRunner() + result = runner.invoke(cli, ["bootstrap", "--help"]) + assert result.exit_code == 0, result.output + def test_server_group_no_args_shows_help(self): runner = CliRunner() result = runner.invoke(cli, ["server"]) From f4d4f0c4265defd1425f0a417ac30a61a788b4f8 Mon Sep 17 00:00:00 2001 From: "E. David Aja" Date: Tue, 14 Jul 2026 09:53:08 -0400 Subject: [PATCH 6/6] Apply suggestion from @edavidaja --- rsconnect/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rsconnect/main.py b/rsconnect/main.py index f083a1a64..fc4efe9c7 100644 --- a/rsconnect/main.py +++ b/rsconnect/main.py @@ -994,7 +994,7 @@ def set_default( @cli.group("server", no_args_is_help=True) def server_group(): """ - Manage saved Posit Connect and shinyapps.io server nicknames. + Manage connections with Posit Connect and shinyapps.io servers. """