Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
11 changes: 9 additions & 2 deletions rsconnect/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -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:
Expand Down
53 changes: 50 additions & 3 deletions rsconnect/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -740,6 +746,7 @@ def add(
account: Optional[str],
token: Optional[str],
secret: Optional[str],
set_default: bool,
verbose: int,
):
set_verbosity(verbose)
Expand Down Expand Up @@ -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))
Expand All @@ -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:
Expand All @@ -818,13 +832,17 @@ 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:
click.echo('Updated Connect server "%s" with URL %s' % (name, real_server_rsc.url))
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",
Expand All @@ -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")
Expand Down Expand Up @@ -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:
Expand All @@ -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(
Expand Down Expand Up @@ -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(
Expand All @@ -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)
Expand Down Expand Up @@ -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,
Expand All @@ -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).",
Expand Down
43 changes: 37 additions & 6 deletions rsconnect/metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -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,
Expand All @@ -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):
"""
Expand Down Expand Up @@ -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"),
Expand Down
5 changes: 3 additions & 2 deletions rsconnect/validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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."
)

Expand Down
Loading
Loading