Skip to content

Commit 03c506f

Browse files
authored
feat: Allow setting a default server (#777)
With more than one entry in the `servers.json` file, the `-s` or `-n` would previously have become mandatory. This commit allows a server to be marked as the default, in which case it is assumed to be the target. Specifically, `rsconnect add` now has a `--set-default` flag to mark a server as the default. For backwards compatibility we do not do this automatically. For `rsconnect login`, on the other hand, we don't need to be backwards compatible -- it hasn't been released yet -- and so that command will make the server the default automatically (unless `--no-set-default` is passed). Some care has been taken to keep precedence of flags and environment variables intact and ensure that only one server is the default at a time. Unit tests are included. Closes #214. Signed-off-by: Aaron Jacobs <aaron.jacobs@posit.co>
1 parent de2f656 commit 03c506f

7 files changed

Lines changed: 263 additions & 13 deletions

File tree

docs/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1111
write-manifest.
1212
- Perform case insensitive matching of the configured Snowflake connection authenticator.
1313
- New `login` and `logout` subcommands for authenticating to Connect via OAuth.
14+
- Servers can now be marked as the default with `rsconnect add --set-default`.
15+
When neither `-n/--name` nor `-s/--server` is provided, the default server is
16+
used automatically. `rsconnect login` sets the server as default unless
17+
`--no-set-default` is passed. `CONNECT_SERVER` still takes precedence.
1418

1519
## [1.29.0] - 2026-04-29
1620

rsconnect/api.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@
6565
create_multipart_form_data,
6666
)
6767
from .log import cls_logged, connect_logger, console_logger, logger
68-
from .metadata import AppStore, ServerStore
68+
from .metadata import AppStore, ServerData, ServerStore
6969
from .models import (
7070
AppMode,
7171
AppModes,
@@ -1039,6 +1039,7 @@ def setup_remote_server(
10391039
token: Optional[str] = None,
10401040
secret: Optional[str] = None,
10411041
):
1042+
store = ServerStore()
10421043
validation.validate_connection_options(
10431044
ctx=ctx,
10441045
url=url,
@@ -1050,6 +1051,7 @@ def setup_remote_server(
10501051
token=token,
10511052
secret=secret,
10521053
name=name,
1054+
has_default_server=store.get_default() is not None,
10531055
)
10541056
# The validation.validate_connection_options() function ensures that certain
10551057
# combinations of arguments are present; the cast() calls inside of the
@@ -1059,7 +1061,12 @@ def setup_remote_server(
10591061
if cacert and not ca_data:
10601062
ca_data = read_certificate_file(cacert)
10611063

1062-
server_data = ServerStore().resolve(name, url)
1064+
# Skip default-server resolution when shinyapps credentials are explicitly
1065+
# provided — the user is targeting shinyapps.io, not a stored Connect server.
1066+
if token and secret and account_name and not name and not url:
1067+
server_data = ServerData(None, None, False)
1068+
else:
1069+
server_data = store.resolve(name, url)
10631070
if server_data.from_store:
10641071
url = server_data.url
10651072
if self.logger:

rsconnect/main.py

Lines changed: 50 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -728,6 +728,12 @@ def bootstrap(
728728
@server_args
729729
@spcs_args
730730
@cloud_shinyapps_args
731+
@click.option(
732+
"--set-default",
733+
is_flag=True,
734+
default=False,
735+
help="Mark this server as the default (used when -n/--name and -s/--server are not specified).",
736+
)
731737
@click.pass_context
732738
def add(
733739
ctx: click.Context,
@@ -740,6 +746,7 @@ def add(
740746
account: Optional[str],
741747
token: Optional[str],
742748
secret: Optional[str],
749+
set_default: bool,
743750
verbose: int,
744751
):
745752
set_verbosity(verbose)
@@ -782,6 +789,7 @@ def add(
782789
account_name=real_server.account_name,
783790
token=real_server.token,
784791
secret=real_server.secret,
792+
set_as_default=set_default,
785793
)
786794
if old_server:
787795
click.echo('Updated {} credential "{}".'.format(real_server.remote_name, name))
@@ -798,7 +806,13 @@ def add(
798806

799807
_test_spcs_creds(real_server_spcs)
800808

801-
server_store.set(name, server, api_key=api_key, snowflake_connection_name=snowflake_connection_name)
809+
server_store.set(
810+
name,
811+
server,
812+
api_key=api_key,
813+
snowflake_connection_name=snowflake_connection_name,
814+
set_as_default=set_default,
815+
)
802816
if old_server:
803817
click.echo('Updated {} credential "{}".'.format(real_server_spcs.remote_name, name))
804818
else:
@@ -818,13 +832,17 @@ def add(
818832
api_key=real_server_rsc.api_key,
819833
insecure=real_server_rsc.insecure,
820834
ca_data=real_server_rsc.ca_data,
835+
set_as_default=set_default,
821836
)
822837

823838
if old_server:
824839
click.echo('Updated Connect server "%s" with URL %s' % (name, real_server_rsc.url))
825840
else:
826841
click.echo('Added Connect server "%s" with URL %s' % (name, real_server_rsc.url))
827842

843+
if set_default:
844+
click.echo('Server "%s" is now the default.' % name)
845+
828846

829847
@cli.command(
830848
"list",
@@ -844,7 +862,8 @@ def list_servers(verbose: int):
844862
else:
845863
click.echo()
846864
for server in servers:
847-
click.echo('Nickname: "%s"' % server["name"])
865+
default_marker = " [default]" if server.get("default") else ""
866+
click.echo('Nickname: "%s"%s' % (server["name"], default_marker))
848867
click.echo(" URL: %s" % server["url"])
849868
if server.get("api_key"):
850869
click.echo(" API key is saved")
@@ -948,12 +967,19 @@ def remove(
948967
if name and server:
949968
raise RSConnectException("You must specify only one of -n/--name or -s/--server.")
950969

970+
removed_was_default = False
951971
if name:
972+
entry = server_store.get_by_name(name)
973+
if entry:
974+
removed_was_default = bool(entry.get("default"))
952975
if server_store.remove_by_name(name):
953976
message = 'Removed nickname "%s".' % name
954977
else:
955978
raise RSConnectException('Nickname "%s" was not found.' % name)
956979
elif server:
980+
entry = server_store.get_by_url(server)
981+
if entry:
982+
removed_was_default = bool(entry.get("default"))
957983
if server_store.remove_by_url(server):
958984
message = 'Removed URL "%s".' % server
959985
else:
@@ -963,6 +989,8 @@ def remove(
963989

964990
if message:
965991
click.echo(message)
992+
if removed_was_default:
993+
click.echo("Note: the removed server was the default. Use `rsconnect add --set-default` to set a new one.")
966994

967995

968996
@cli.command(
@@ -991,6 +1019,12 @@ def remove(
9911019
help="Use device code flow for headless/non-interactive environments.",
9921020
)
9931021
@click.option("--client-id", default=None, help="OAuth client ID (skips Dynamic Client Registration).")
1022+
@click.option(
1023+
"--no-set-default",
1024+
is_flag=True,
1025+
default=False,
1026+
help="Do not mark this server as the default after login.",
1027+
)
9941028
@click.option("--verbose", "-v", count=True, help="Enable verbose output. Use -vv for very verbose (debug) output.")
9951029
@cli_exception_handler
9961030
def login(
@@ -1000,6 +1034,7 @@ def login(
10001034
cacert: Optional[str],
10011035
use_device_code: bool,
10021036
client_id: Optional[str],
1037+
no_set_default: bool,
10031038
verbose: int,
10041039
):
10051040
set_verbosity(verbose)
@@ -1062,8 +1097,17 @@ def _do_login(cid: str) -> dict[str, Any]:
10621097

10631098
ca_data_str = ca_data.decode("utf-8") if isinstance(ca_data, bytes) else ca_data
10641099

1100+
set_as_default = not no_set_default
1101+
10651102
if stored_in_keyring:
1066-
server_store.set(name, server, oauth_client_id=client_id, insecure=insecure, ca_data=ca_data_str)
1103+
server_store.set(
1104+
name,
1105+
server,
1106+
oauth_client_id=client_id,
1107+
insecure=insecure,
1108+
ca_data=ca_data_str,
1109+
set_as_default=set_as_default,
1110+
)
10671111
else:
10681112
server_store.set(
10691113
name,
@@ -1074,9 +1118,12 @@ def _do_login(cid: str) -> dict[str, Any]:
10741118
oauth_access_token=access_token,
10751119
oauth_refresh_token=refresh_token,
10761120
oauth_token_expiry=expiry,
1121+
set_as_default=set_as_default,
10771122
)
10781123

10791124
click.echo('Logged in to "%s" (%s)' % (name, server))
1125+
if set_as_default:
1126+
click.echo('Server "%s" is now the default.' % name)
10801127
if not stored_in_keyring:
10811128
click.secho(
10821129
"Note: keyring not available; credentials stored in local file (chmod 600).",

rsconnect/metadata.py

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,7 @@ class ServerDataDict(TypedDict):
263263
oauth_access_token: NotRequired[str]
264264
oauth_refresh_token: NotRequired[str]
265265
oauth_token_expiry: NotRequired[float]
266+
default: NotRequired[bool]
266267

267268

268269
class ServerData:
@@ -339,6 +340,27 @@ def get_all_servers(self):
339340
"""
340341
return self._get_sorted_values(lambda s: s.get("name") or "")
341342

343+
def get_default(self) -> Optional[ServerDataDict]:
344+
"""Return the entry marked as default, or None."""
345+
for entry in self._data.values():
346+
if entry.get("default"):
347+
return entry
348+
return None
349+
350+
def clear_default(self) -> None:
351+
"""Remove the default flag from all entries. Does not save."""
352+
for entry in self._data.values():
353+
entry.pop("default", None) # type: ignore[misc]
354+
355+
def set_default(self, name: str) -> None:
356+
"""Mark the named server as the default, clearing any prior default."""
357+
entry = self._get_by_key(name)
358+
if entry is None:
359+
raise RSConnectException('The nickname, "%s", does not exist.' % name)
360+
self.clear_default()
361+
entry["default"] = True # type: ignore[typeddict-unknown-key]
362+
self.save()
363+
342364
def set(
343365
self,
344366
name: str,
@@ -354,6 +376,7 @@ def set(
354376
oauth_access_token: Optional[str] = None,
355377
oauth_refresh_token: Optional[str] = None,
356378
oauth_token_expiry: Optional[float] = None,
379+
set_as_default: bool = False,
357380
):
358381
"""
359382
Add (or update) information about a Connect server
@@ -371,7 +394,14 @@ def set(
371394
:param oauth_access_token: OAuth access token (fallback when keyring unavailable).
372395
:param oauth_refresh_token: OAuth refresh token (fallback when keyring unavailable).
373396
:param oauth_token_expiry: OAuth token expiry as unix timestamp.
397+
:param set_as_default: mark this server as the default.
374398
"""
399+
existing = self._get_by_key(name)
400+
was_default = bool(existing.get("default")) if existing else False
401+
402+
if set_as_default:
403+
self.clear_default()
404+
375405
common_data: ServerDataDict = {
376406
"name": name,
377407
"url": url,
@@ -393,7 +423,10 @@ def set(
393423
else:
394424
target_data = dict(token=token, secret=secret)
395425

396-
self._set(name, {**common_data, **target_data}) # type: ignore
426+
entry = {**common_data, **target_data}
427+
if set_as_default or was_default:
428+
entry["default"] = True
429+
self._set(name, entry) # type: ignore
397430

398431
def remove_by_name(self, name: str):
399432
"""
@@ -461,15 +494,13 @@ def resolve(self, name: Optional[str], url: Optional[str]) -> ServerData:
461494
elif url:
462495
entry = self.get_by_url(url)
463496
else:
464-
# if there is a single server, default to it
465-
if self.count() == 1:
497+
entry = self.get_default()
498+
if entry is None and self.count() == 1:
466499
entry = self._get_first_value()
467-
else:
468-
entry = None
469500

470501
if entry:
471502
return ServerData(
472-
name,
503+
name or entry["name"],
473504
entry["url"],
474505
True,
475506
insecure=entry.get("insecure"),

rsconnect/validation.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ def validate_connection_options(
4646
secret: Optional[str],
4747
name: Optional[str] = None,
4848
snowflake_connection_name: Optional[str] = None,
49+
has_default_server: bool = False,
4950
):
5051
"""
5152
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(
9899
{', '.join(present_options_mutually_exclusive_with_name)}. See command help for further details."
99100
)
100101

101-
if not name and not url and not shinyapps_options:
102+
if not name and not url and not any(shinyapps_options.values()) and not has_default_server:
102103
raise RSConnectException(
103-
"You must specify one of -n/--name OR -s/--server OR T/--token, -S/--secret, \
104+
"You must specify one of -n/--name OR -s/--server OR -T/--token, -S/--secret, \
104105
either via command options or environment variables. See command help for further details."
105106
)
106107

0 commit comments

Comments
 (0)