diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index dab05a4e..9669c760 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 write-manifest. - Perform case insensitive matching of the configured Snowflake connection authenticator. - New `login` and `logout` subcommands for authenticating to Connect via OAuth. +- 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. ## [1.29.0] - 2026-04-29 diff --git a/rsconnect/api.py b/rsconnect/api.py index 1312b340..436217e8 100644 --- a/rsconnect/api.py +++ b/rsconnect/api.py @@ -65,7 +65,7 @@ create_multipart_form_data, ) from .log import cls_logged, connect_logger, console_logger, logger -from .metadata import AppStore, ServerStore +from .metadata import AppStore, ServerData, ServerStore from .models import ( AppMode, AppModes, @@ -1039,6 +1039,7 @@ def setup_remote_server( token: Optional[str] = None, secret: Optional[str] = None, ): + store = ServerStore() validation.validate_connection_options( ctx=ctx, url=url, @@ -1050,6 +1051,7 @@ def setup_remote_server( token=token, secret=secret, name=name, + has_default_server=store.get_default() is not None, ) # The validation.validate_connection_options() function ensures that certain # combinations of arguments are present; the cast() calls inside of the @@ -1059,7 +1061,12 @@ def setup_remote_server( if cacert and not ca_data: ca_data = read_certificate_file(cacert) - server_data = ServerStore().resolve(name, url) + # Skip default-server resolution when shinyapps credentials are explicitly + # provided — the user is targeting shinyapps.io, not a stored Connect server. + if token and secret and account_name and not name and not url: + server_data = ServerData(None, None, False) + else: + server_data = store.resolve(name, url) if server_data.from_store: url = server_data.url if self.logger: diff --git a/rsconnect/main.py b/rsconnect/main.py index e4284811..2362f916 100644 --- a/rsconnect/main.py +++ b/rsconnect/main.py @@ -728,6 +728,12 @@ def bootstrap( @server_args @spcs_args @cloud_shinyapps_args +@click.option( + "--set-default", + is_flag=True, + default=False, + help="Mark this server as the default (used when -n/--name and -s/--server are not specified).", +) @click.pass_context def add( ctx: click.Context, @@ -740,6 +746,7 @@ def add( account: Optional[str], token: Optional[str], secret: Optional[str], + set_default: bool, verbose: int, ): set_verbosity(verbose) @@ -782,6 +789,7 @@ def add( account_name=real_server.account_name, token=real_server.token, secret=real_server.secret, + set_as_default=set_default, ) if old_server: click.echo('Updated {} credential "{}".'.format(real_server.remote_name, name)) @@ -798,7 +806,13 @@ def add( _test_spcs_creds(real_server_spcs) - server_store.set(name, server, api_key=api_key, snowflake_connection_name=snowflake_connection_name) + server_store.set( + name, + server, + api_key=api_key, + snowflake_connection_name=snowflake_connection_name, + set_as_default=set_default, + ) if old_server: click.echo('Updated {} credential "{}".'.format(real_server_spcs.remote_name, name)) else: @@ -818,6 +832,7 @@ def add( api_key=real_server_rsc.api_key, insecure=real_server_rsc.insecure, ca_data=real_server_rsc.ca_data, + set_as_default=set_default, ) if old_server: @@ -825,6 +840,9 @@ def add( else: click.echo('Added Connect server "%s" with URL %s' % (name, real_server_rsc.url)) + if set_default: + click.echo('Server "%s" is now the default.' % name) + @cli.command( "list", @@ -844,7 +862,8 @@ def list_servers(verbose: int): else: click.echo() for server in servers: - click.echo('Nickname: "%s"' % server["name"]) + default_marker = " [default]" if server.get("default") else "" + click.echo('Nickname: "%s"%s' % (server["name"], default_marker)) click.echo(" URL: %s" % server["url"]) if server.get("api_key"): click.echo(" API key is saved") @@ -948,12 +967,19 @@ def remove( if name and server: raise RSConnectException("You must specify only one of -n/--name or -s/--server.") + removed_was_default = False if name: + entry = server_store.get_by_name(name) + if entry: + removed_was_default = bool(entry.get("default")) if server_store.remove_by_name(name): message = 'Removed nickname "%s".' % name else: raise RSConnectException('Nickname "%s" was not found.' % name) elif server: + entry = server_store.get_by_url(server) + if entry: + removed_was_default = bool(entry.get("default")) if server_store.remove_by_url(server): message = 'Removed URL "%s".' % server else: @@ -963,6 +989,8 @@ 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.") @cli.command( @@ -991,6 +1019,12 @@ def remove( help="Use device code flow for headless/non-interactive environments.", ) @click.option("--client-id", default=None, help="OAuth client ID (skips Dynamic Client Registration).") +@click.option( + "--no-set-default", + is_flag=True, + default=False, + help="Do not mark this server as the default after login.", +) @click.option("--verbose", "-v", count=True, help="Enable verbose output. Use -vv for very verbose (debug) output.") @cli_exception_handler def login( @@ -1000,6 +1034,7 @@ def login( cacert: Optional[str], use_device_code: bool, client_id: Optional[str], + no_set_default: bool, verbose: int, ): set_verbosity(verbose) @@ -1062,8 +1097,17 @@ def _do_login(cid: str) -> dict[str, Any]: ca_data_str = ca_data.decode("utf-8") if isinstance(ca_data, bytes) else ca_data + set_as_default = not no_set_default + if stored_in_keyring: - server_store.set(name, server, oauth_client_id=client_id, insecure=insecure, ca_data=ca_data_str) + server_store.set( + name, + server, + oauth_client_id=client_id, + insecure=insecure, + ca_data=ca_data_str, + set_as_default=set_as_default, + ) else: server_store.set( name, @@ -1074,9 +1118,12 @@ def _do_login(cid: str) -> dict[str, Any]: oauth_access_token=access_token, oauth_refresh_token=refresh_token, oauth_token_expiry=expiry, + set_as_default=set_as_default, ) click.echo('Logged in to "%s" (%s)' % (name, server)) + if set_as_default: + click.echo('Server "%s" is now the default.' % name) if not stored_in_keyring: click.secho( "Note: keyring not available; credentials stored in local file (chmod 600).", diff --git a/rsconnect/metadata.py b/rsconnect/metadata.py index 409aaf75..c3b67ec6 100644 --- a/rsconnect/metadata.py +++ b/rsconnect/metadata.py @@ -263,6 +263,7 @@ class ServerDataDict(TypedDict): oauth_access_token: NotRequired[str] oauth_refresh_token: NotRequired[str] oauth_token_expiry: NotRequired[float] + default: NotRequired[bool] class ServerData: @@ -339,6 +340,27 @@ def get_all_servers(self): """ return self._get_sorted_values(lambda s: s.get("name") or "") + def get_default(self) -> Optional[ServerDataDict]: + """Return the entry marked as default, or None.""" + for entry in self._data.values(): + if entry.get("default"): + return entry + return None + + def clear_default(self) -> None: + """Remove the default flag from all entries. Does not save.""" + for entry in self._data.values(): + entry.pop("default", None) # type: ignore[misc] + + def set_default(self, name: str) -> None: + """Mark the named server as the default, clearing any prior default.""" + entry = self._get_by_key(name) + if entry is None: + raise RSConnectException('The nickname, "%s", does not exist.' % name) + self.clear_default() + entry["default"] = True # type: ignore[typeddict-unknown-key] + self.save() + def set( self, name: str, @@ -354,6 +376,7 @@ def set( oauth_access_token: Optional[str] = None, oauth_refresh_token: Optional[str] = None, oauth_token_expiry: Optional[float] = None, + set_as_default: bool = False, ): """ Add (or update) information about a Connect server @@ -371,7 +394,14 @@ def set( :param oauth_access_token: OAuth access token (fallback when keyring unavailable). :param oauth_refresh_token: OAuth refresh token (fallback when keyring unavailable). :param oauth_token_expiry: OAuth token expiry as unix timestamp. + :param set_as_default: mark this server as the default. """ + existing = self._get_by_key(name) + was_default = bool(existing.get("default")) if existing else False + + if set_as_default: + self.clear_default() + common_data: ServerDataDict = { "name": name, "url": url, @@ -393,7 +423,10 @@ def set( else: target_data = dict(token=token, secret=secret) - self._set(name, {**common_data, **target_data}) # type: ignore + entry = {**common_data, **target_data} + if set_as_default or was_default: + entry["default"] = True + self._set(name, entry) # type: ignore def remove_by_name(self, name: str): """ @@ -461,15 +494,13 @@ def resolve(self, name: Optional[str], url: Optional[str]) -> ServerData: elif url: entry = self.get_by_url(url) else: - # if there is a single server, default to it - if self.count() == 1: + entry = self.get_default() + if entry is None and self.count() == 1: entry = self._get_first_value() - else: - entry = None if entry: return ServerData( - name, + name or entry["name"], entry["url"], True, insecure=entry.get("insecure"), diff --git a/rsconnect/validation.py b/rsconnect/validation.py index e1f4c992..12d03275 100644 --- a/rsconnect/validation.py +++ b/rsconnect/validation.py @@ -46,6 +46,7 @@ def validate_connection_options( secret: Optional[str], name: Optional[str] = None, snowflake_connection_name: Optional[str] = None, + has_default_server: bool = False, ): """ Validates provided Connect or shinyapps.io connection options and returns which target to use given the provided @@ -98,9 +99,9 @@ def validate_connection_options( {', '.join(present_options_mutually_exclusive_with_name)}. See command help for further details." ) - if not name and not url and not shinyapps_options: + if not name and not url and not any(shinyapps_options.values()) and not has_default_server: raise RSConnectException( - "You must specify one of -n/--name OR -s/--server OR T/--token, -S/--secret, \ + "You must specify one of -n/--name OR -s/--server OR -T/--token, -S/--secret, \ either via command options or environment variables. See command help for further details." ) diff --git a/tests/test_main.py b/tests/test_main.py index e841f5a2..7445ff49 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1144,3 +1144,97 @@ def test_no_package_json(self, tmp_path): result = runner.invoke(cli, ["write-manifest", "nodejs", str(tmp_path)]) assert result.exit_code == 1 assert "package.json" in result.output + + +class TestDefaultServer: + def test_list_shows_default_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): + result = runner.invoke(cli, ["list"]) + assert result.exit_code == 0, result.output + assert '[default]' in result.output + assert 's1' in result.output + + def test_list_no_default_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") + + runner = CliRunner() + with mock.patch("rsconnect.main.server_store", store): + result = runner.invoke(cli, ["list"]) + assert result.exit_code == 0, result.output + assert '[default]' not in result.output + + def test_remove_default_shows_note(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, ["remove", "--name", "s1"]) + assert result.exit_code == 0, result.output + assert "was the default" in result.output + + def test_remove_non_default_no_note(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, ["remove", "--name", "s2"]) + assert result.exit_code == 0, result.output + assert "was the default" not in result.output + + @httpretty.activate(verbose=True, allow_net_connect=False) + def test_add_set_default(self, tmp_path): + from rsconnect.metadata import ServerStore + + httpretty.register_uri( + httpretty.GET, + "http://connect.local/__api__/server_settings", + body='{"version": "2024.01.0"}', + adding_headers={"Content-Type": "application/json"}, + status=200, + ) + httpretty.register_uri( + httpretty.GET, + "http://connect.local/__api__/v1/user", + body='{"username": "admin"}', + adding_headers={"Content-Type": "application/json"}, + status=200, + ) + + store = ServerStore(base_dir=str(tmp_path)) + original_api_key_value = os.environ.pop("CONNECT_API_KEY", None) + original_server_value = os.environ.pop("CONNECT_SERVER", None) + try: + runner = CliRunner() + with mock.patch("rsconnect.main.server_store", store): + result = runner.invoke( + cli, + ["add", "--name", "myserver", "--server", "http://connect.local", + "--api-key", "fake-key", "--set-default"], + ) + assert result.exit_code == 0, result.output + assert "is now the default" in result.output + assert store.get_default() is not None + assert store.get_default()["name"] == "myserver" + finally: + if original_api_key_value: + os.environ["CONNECT_API_KEY"] = original_api_key_value + if original_server_value: + os.environ["CONNECT_SERVER"] = original_server_value diff --git a/tests/test_metadata.py b/tests/test_metadata.py index 68406007..92037947 100644 --- a/tests/test_metadata.py +++ b/tests/test_metadata.py @@ -179,6 +179,72 @@ def test_save_and_load(self): def test_get_path(self): self.assertIn("servers.json", self.server_store.get_path()) + def test_get_default_none(self): + self.assertIsNone(self.server_store.get_default()) + + def test_set_default(self): + self.server_store.set_default("foo") + default = self.server_store.get_default() + self.assertIsNotNone(default) + self.assertEqual(default["name"], "foo") + + def test_set_default_clears_previous(self): + self.server_store.set_default("foo") + self.server_store.set_default("bar") + default = self.server_store.get_default() + self.assertEqual(default["name"], "bar") + foo = self.server_store.get_by_name("foo") + self.assertNotIn("default", foo) + + def test_set_default_not_found(self): + from rsconnect.exception import RSConnectException + + with self.assertRaises(RSConnectException): + self.server_store.set_default("nonexistent") + + def test_set_as_default_on_add(self): + self.server_store.set("new_server", "http://new.local", api_key="key1", set_as_default=True) + default = self.server_store.get_default() + self.assertEqual(default["name"], "new_server") + + def test_set_as_default_clears_previous_on_add(self): + self.server_store.set("s1", "http://s1.local", api_key="key1", set_as_default=True) + self.server_store.set("s2", "http://s2.local", api_key="key2", set_as_default=True) + default = self.server_store.get_default() + self.assertEqual(default["name"], "s2") + s1 = self.server_store.get_by_name("s1") + self.assertFalse(s1.get("default", False)) + + def test_re_add_preserves_default(self): + self.server_store.set("foo", "http://connect.local", api_key="newKey", set_as_default=True) + self.assertTrue(self.server_store.get_by_name("foo").get("default")) + # Re-add without set_as_default — default should be preserved + self.server_store.set("foo", "http://connect.local", api_key="anotherKey") + self.assertTrue(self.server_store.get_by_name("foo").get("default")) + + def test_resolve_uses_default_server(self): + self.server_store.set_default("foo") + server_data = self.server_store.resolve(None, None) + self.assertTrue(server_data.from_store) + self.assertEqual(server_data.url, "http://connect.local") + self.assertEqual(server_data.api_key, "notReallyAnApiKey") + self.assertEqual(server_data.name, "foo") + + def test_resolve_default_takes_priority_over_single_server_fallback(self): + # Remove all but two servers, mark one as default + self.server_store.remove_by_name("baz") + self.server_store.remove_by_name("qux") + self.server_store.remove_by_name("None") + # Now have foo and bar; mark bar as default + self.server_store.set_default("bar") + server_data = self.server_store.resolve(None, None) + self.assertEqual(server_data.url, "http://connect.remote") + + def test_remove_default_clears_it(self): + self.server_store.set_default("foo") + self.server_store.remove_by_name("foo") + self.assertIsNone(self.server_store.get_default()) + class TestAppMetadata(TestCase): def setUp(self):