From 4e851d12d6544475d71694fd12709bb2d585fce0 Mon Sep 17 00:00:00 2001 From: Bartosz Blizniak Date: Fri, 16 Oct 2026 15:51:07 +0100 Subject: [PATCH 1/9] feat(ENG-15089): deprecate the cloudsmith login username/password flow `cloudsmith login` and its `cloudsmith token` alias now print a deprecation warning on stderr before they prompt for credentials. The commands keep working until username and password login is disabled. The warning points users at getting an API key from the web app and at `cloudsmith auth`. The 401 hint for users with no credentials no longer recommends `cloudsmith token`, and the failed-login hint now covers the `login` name as well as `token`. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 8 ++ README.md | 28 ++---- cloudsmith_cli/cli/commands/login.py | 35 ++++++- cloudsmith_cli/cli/exceptions.py | 9 +- .../cli/tests/commands/test_login.py | 98 +++++++++++++++++++ cloudsmith_cli/cli/tests/test_exceptions.py | 13 ++- 6 files changed, 161 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 89add60d..9dbbdddb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ## [Unreleased] +### Deprecated + +- `cloudsmith login` and its `cloudsmith token` alias are deprecated and will be removed in a future release, because Cloudsmith is removing username and password login. Both commands still work, but they now print a deprecation warning to stderr before they prompt for credentials. Get your API key from the Cloudsmith web app (your user menu, then **Personal API Keys**) and set `CLOUDSMITH_API_KEY`, or run `cloudsmith auth` to authenticate with SAML SSO. + +### Changed + +- The hint shown for a `401 - Unauthorized` error when no credentials are set no longer suggests `cloudsmith token`. It now suggests `$CLOUDSMITH_API_KEY` or `cloudsmith auth`. + ## [1.29.0] - 2026-10-15 ### Added diff --git a/README.md b/README.md index 4f2f2cd2..f07d4858 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ The CLI currently supports the following commands (and sub-commands): - `entitlements`|`ents`: List entitlements for a repository. - `packages`: List packages for a repository. (Aliases `repos list`) - `repos`: List repositories for a namespace (owner). -- `login`|`token`: Retrieve your API authentication token/key via login. +- `login`|`token`: [DEPRECATED] Retrieve your API authentication token/key via login. - `logout`: Clear stored authentication credentials and SSO tokens (Keyring, API key from credential file and emit warning when `$CLOUDSMITH_API_KEY` is still set). - `metadata`: Manage arbitrary JSON metadata (SBOM, BuildInfo, custom) attached to a package. - `add`: Attach a new metadata entry to a package. @@ -363,29 +363,15 @@ cloudsmith auth --owner example --no-browser #### Getting Your API Key -You can retrieve your API key using the `cloudsmith login` command: +Get your API key from the Cloudsmith web app: click your user icon in the top right corner, then click **Personal API Keys**. See [API Key](https://docs.cloudsmith.com/accounts-and-teams/api-key) for more information. If you authenticated with `cloudsmith auth`, you can also use `cloudsmith tokens create` to create an API key. -``` -cloudsmith login -Login: you@example.com -Password: -Repeat for confirmation: -``` - -*Note:* Please ensure you use your email for the 'Login' prompt and not your user slug/identifier. - -The resulting output looks something like: - -``` -Retrieving API token for 'you@example.com' ... OK -Your API token is: 1234567890abcdef1234567890abcdef -``` - -Once you have your API key you can then put this into your `credentials.ini`, use it as an environment variable `export CLOUDSMITH_API_KEY=your_key_here` or pass it to the CLI using the `-k your_key_here` flag. +Give your API key to the CLI in one of these ways: -For convenience the CLI will ask you if you want to install the default configuration files, complete with your API key, if they don't already exist. Say 'y' or 'yes' to create the configuration files. +- Set the `CLOUDSMITH_API_KEY` environment variable: `export CLOUDSMITH_API_KEY=your_key_here`. +- Put the key into your `credentials.ini` file. +- Pass the key to the CLI with the `-k your_key_here` flag. -If the configuration files already exist, you'll have to manually put the API key into the configuration files, but the CLI will print out their locations. +*Note:* The `cloudsmith login` command and its `cloudsmith token` alias are deprecated and will be removed in a future release. They retrieve your API key with your email address and password, and Cloudsmith is removing username and password login. ## Uploading Packages diff --git a/cloudsmith_cli/cli/commands/login.py b/cloudsmith_cli/cli/commands/login.py index a2f73399..8b9038c5 100644 --- a/cloudsmith_cli/cli/commands/login.py +++ b/cloudsmith_cli/cli/commands/login.py @@ -21,7 +21,33 @@ def validate_login(ctx, param, value): return value -@main.command(aliases=["token"]) +DEPRECATION_WARNING = ( + "DEPRECATION WARNING: 'cloudsmith {name}' is deprecated and will be removed in " + "a future release. Username and password login is going away.\n" + "To authenticate instead, do one of these:\n" + " - Get your API key from the Cloudsmith web app (your user menu, then " + "Personal API Keys) and set the CLOUDSMITH_API_KEY environment variable or " + "add it to credentials.ini.\n" + " - Run 'cloudsmith auth' to authenticate with SAML SSO.\n" + "See https://docs.cloudsmith.com/accounts-and-teams/api-key for details." +) + + +class DeprecatedLoginCommand(click.Command): + """Command that prints the deprecation warning before any prompt.""" + + def parse_args(self, ctx, args): + # Click prompts for --login and --password while parsing, so the + # warning must be printed here rather than in the command body. + # Shell completion also parses (resiliently) and must stay quiet. + if not ctx.resilient_parsing: + click.secho( + DEPRECATION_WARNING.format(name=ctx.info_name), fg="yellow", err=True + ) + return super().parse_args(ctx, args) + + +@main.command(cls=DeprecatedLoginCommand, aliases=["token"]) @click.option( "-l", "--login", @@ -36,7 +62,12 @@ def validate_login(ctx, param, value): @decorators.initialise_api @click.pass_context def login(ctx, opts, login, password): # pylint: disable=redefined-outer-name - """Retrieve your API authentication token/key via login.""" + """[DEPRECATED] Retrieve your API authentication token/key via login. + + Username and password login will be removed in a future release. Get your + API key from the Cloudsmith web app instead, or run 'cloudsmith auth' to + authenticate with SAML SSO. + """ use_stderr = utils.should_use_stderr(opts) click.echo( f"Retrieving API token for {click.style(login, bold=True)} ... ", diff --git a/cloudsmith_cli/cli/exceptions.py b/cloudsmith_cli/cli/exceptions.py index de7f01f4..c170540c 100644 --- a/cloudsmith_cli/cli/exceptions.py +++ b/cloudsmith_cli/cli/exceptions.py @@ -210,8 +210,8 @@ def get_401_error_hint(ctx, opts, exc): "try again." ) - if ctx.info_name == "token": - # This is already the token command + if ctx.info_name in ("login", "token"): + # This is already the (deprecated) login command return ( "The login failed - Either your email address and/or " "your password was incorrect. Please check them and " @@ -220,8 +220,9 @@ def get_401_error_hint(ctx, opts, exc): return ( "You don't have an API key or access token set, but it seems this action " - "requires authentication - Try getting your API key via " - "'cloudsmith token', or access token via 'cloudsmith auth', then try again." + "requires authentication - Try setting your API key via " + "$CLOUDSMITH_API_KEY, or getting an access token via 'cloudsmith auth', " + "then try again." ) diff --git a/cloudsmith_cli/cli/tests/commands/test_login.py b/cloudsmith_cli/cli/tests/commands/test_login.py index 724fa002..c815e91d 100644 --- a/cloudsmith_cli/cli/tests/commands/test_login.py +++ b/cloudsmith_cli/cli/tests/commands/test_login.py @@ -1,6 +1,10 @@ +import json +from unittest.mock import patch + import pytest from ...commands.login import login +from ...commands.main import main @pytest.mark.usefixtures("set_api_host_env_var") @@ -29,3 +33,97 @@ def test_login_via_args(self, runner, username, password, api_key): assert not result.exception assert result.exit_code == 0 assert expected in result.stdout + + +def invoke_main_login(runner, config_dir, name, *extra_args, user_input=None): + """Run `cloudsmith ` through the registered command tree. + + The config and credentials paths point at an empty directory so a real + config.ini or credentials.ini on the machine cannot change the result. + """ + args = [ + name, + "--config-file", + str(config_dir), + "--credentials-file", + str(config_dir), + "--api-host", + "https://api.example.invalid", + *extra_args, + ] + with ( + patch( + "cloudsmith_cli.cli.commands.login.get_user_token", + return_value="fake-api-key", + ), + patch( + "cloudsmith_cli.cli.commands.login.create_config_files", + return_value=(False, False), + ), + patch("cloudsmith_cli.cli.commands.login.new_config_messaging"), + ): + return runner.invoke(main, args, input=user_input) + + +@pytest.mark.parametrize("name", ["login", "token"]) +class TestLoginDeprecation: + """Login still works, but every invocation warns that it is going away.""" + + def test_warns_on_stderr_and_still_returns_the_key(self, runner, tmp_path, name): + result = invoke_main_login( + runner, tmp_path, name, "-l", "you@example.com", "-p", "secret" + ) + + assert result.exit_code == 0 + assert f"DEPRECATION WARNING: 'cloudsmith {name}' is deprecated" in ( + result.stderr + ) + assert "Personal API Keys" in result.stderr + assert "cloudsmith auth" in result.stderr + assert "DEPRECATION WARNING" not in result.stdout + assert "Your API key/token is: fake-api-key" in result.stdout + + def test_warns_before_prompting_for_credentials(self, runner, tmp_path, name): + result = invoke_main_login( + runner, + tmp_path, + name, + user_input="you@example.com\nsecret\nsecret\n", + ) + + assert result.exit_code == 0 + assert result.output.index("DEPRECATION WARNING") < result.output.index( + "Login:" + ) + + def test_json_output_stays_parseable(self, runner, tmp_path, name): + result = invoke_main_login( + runner, + tmp_path, + name, + "-l", + "you@example.com", + "-p", + "secret", + "--output-format", + "json", + ) + + assert result.exit_code == 0 + assert "DEPRECATION WARNING" in result.stderr + assert json.loads(result.stdout)["data"]["token"] == "fake-api-key" + + def test_help_marks_the_command_deprecated(self, runner, tmp_path, name): + result = invoke_main_login(runner, tmp_path, name, "--help") + + assert result.exit_code == 0 + assert "[DEPRECATED]" in result.stdout + + +def test_shell_completion_parsing_does_not_warn(capsys): + """Tab completion parses resiliently and must not print the warning.""" + login.make_context("login", ["-l", "you@example.com"], resilient_parsing=True) + + captured = capsys.readouterr() + assert "DEPRECATION WARNING" not in captured.err + assert "DEPRECATION WARNING" not in captured.out diff --git a/cloudsmith_cli/cli/tests/test_exceptions.py b/cloudsmith_cli/cli/tests/test_exceptions.py index 67f143d9..7e538991 100644 --- a/cloudsmith_cli/cli/tests/test_exceptions.py +++ b/cloudsmith_cli/cli/tests/test_exceptions.py @@ -3,6 +3,8 @@ import json from unittest.mock import Mock, patch +import pytest + from cloudsmith_cli.cli.commands.main import main from cloudsmith_cli.cli.exceptions import get_401_error_hint, handle_api_exceptions from cloudsmith_cli.core.api.exceptions import ApiException @@ -45,10 +47,15 @@ def test_api_key_credential_does_not_assert_a_permissions_problem(self): assert hint_for(credential) == API_KEY_HINT def test_no_credential_suggests_authenticating(self): - assert "cloudsmith token" in hint_for(None) + hint = hint_for(None) + + assert "CLOUDSMITH_API_KEY" in hint + assert "cloudsmith auth" in hint + assert "cloudsmith token" not in hint - def test_no_credential_on_token_command_reports_a_failed_login(self): - assert "login failed" in hint_for(None, info_name="token") + @pytest.mark.parametrize("info_name", ["login", "token"]) + def test_no_credential_on_login_command_reports_a_failed_login(self, info_name): + assert "login failed" in hint_for(None, info_name=info_name) def invoke_credentialed_401(runner, config_dir, output_format="pretty"): From 0a1a6949a31069b9477095464ad633ca0615f1d0 Mon Sep 17 00:00:00 2001 From: Bartosz Blizniak Date: Fri, 16 Oct 2026 15:51:08 +0100 Subject: [PATCH 2/9] feat(ENG-15089): report disabled password login and keep JSON output clean When the API returns 410 (or 404 once the route is removed), `cloudsmith login` and `cloudsmith token` explain that username and password login is disabled and how to get an API key, instead of the raw error. Other errors, such as outages or wrong credentials, keep the standard rendering. Config file prompts and messages from `create_config_files` and `new_config_messaging` now go to stderr for JSON output, so `login -F json` and `--save-config` with JSON output produce parseable stdout. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 6 +- README.md | 2 +- cloudsmith_cli/cli/commands/login.py | 34 ++++- cloudsmith_cli/cli/exceptions.py | 4 + .../cli/tests/commands/test_login.py | 138 ++++++++++++++++-- cloudsmith_cli/core/config.py | 54 ++++--- cloudsmith_cli/core/tests/test_config.py | 42 +++++- 7 files changed, 243 insertions(+), 37 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9dbbdddb..37345ba2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,12 +9,16 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ### Deprecated -- `cloudsmith login` and its `cloudsmith token` alias are deprecated and will be removed in a future release, because Cloudsmith is removing username and password login. Both commands still work, but they now print a deprecation warning to stderr before they prompt for credentials. Get your API key from the Cloudsmith web app (your user menu, then **Personal API Keys**) and set `CLOUDSMITH_API_KEY`, or run `cloudsmith auth` to authenticate with SAML SSO. +- `cloudsmith login` and its `cloudsmith token` alias are deprecated and will be removed in a future release, because Cloudsmith is removing username and password login. Both commands still work while the server allows password login, but they now print a deprecation warning to stderr before they prompt for credentials. Once the server switches password login off (`410 Gone`, or `404` after the endpoint is removed), they fail with a message that explains how to get an API key instead. Other errors, such as outages or wrong credentials, are reported as before. Get your API key from the Cloudsmith web app (your user menu, then **Personal API Keys**) and set `CLOUDSMITH_API_KEY`, or run `cloudsmith auth` to authenticate with SAML SSO. ### Changed - The hint shown for a `401 - Unauthorized` error when no credentials are set no longer suggests `cloudsmith token`. It now suggests `$CLOUDSMITH_API_KEY` or `cloudsmith auth`. +### Fixed + +- `cloudsmith login`, and `cloudsmith auth` and `cloudsmith tokens` with `--save-config`, no longer write config file prompts and messages to stdout when `--output-format json` or `pretty_json` is set. They now go to stderr, so stdout holds only the JSON. + ## [1.29.0] - 2026-10-15 ### Added diff --git a/README.md b/README.md index f07d4858..1af73237 100644 --- a/README.md +++ b/README.md @@ -371,7 +371,7 @@ Give your API key to the CLI in one of these ways: - Put the key into your `credentials.ini` file. - Pass the key to the CLI with the `-k your_key_here` flag. -*Note:* The `cloudsmith login` command and its `cloudsmith token` alias are deprecated and will be removed in a future release. They retrieve your API key with your email address and password, and Cloudsmith is removing username and password login. +*Note:* The `cloudsmith login` command and its `cloudsmith token` alias are deprecated and will be removed in a future release. They retrieve your API key with your email address and password, and Cloudsmith is removing username and password login. Once it is switched off, both commands fail with a message that explains how to get an API key instead. ## Uploading Packages diff --git a/cloudsmith_cli/cli/commands/login.py b/cloudsmith_cli/cli/commands/login.py index 8b9038c5..0139a297 100644 --- a/cloudsmith_cli/cli/commands/login.py +++ b/cloudsmith_cli/cli/commands/login.py @@ -33,6 +33,26 @@ def validate_login(ctx, param, value): ) +REMOVED_MESSAGE = ( + "Username and password login has been removed, so this command no longer " + "works. Get your API key from the Cloudsmith web app (your user menu, then " + "Personal API Keys) and set the CLOUDSMITH_API_KEY environment variable, or " + "run 'cloudsmith auth' to authenticate with SAML SSO." +) + +#: Statuses the token endpoint returns once the server has switched password +#: login off (410) or removed the route entirely (404). +REMOVED_STATUSES = (404, 410) + + +def summarise_removed_login(exc, detail, fields): + """Replace the raw error with a removal notice once the endpoint is off.""" + # pylint: disable=unused-argument + if exc.status in REMOVED_STATUSES: + return REMOVED_MESSAGE + return None + + class DeprecatedLoginCommand(click.Command): """Command that prints the deprecation warning before any prompt.""" @@ -78,7 +98,12 @@ def login(ctx, opts, login, password): # pylint: disable=redefined-outer-name context_msg = "Failed to retrieve the API token!" try: with ( - handle_api_exceptions(ctx, opts=opts, context_msg=context_msg), + handle_api_exceptions( + ctx, + opts=opts, + context_msg=context_msg, + summarise_error=summarise_removed_login, + ), maybe_spinner(opts), ): api_key = get_user_token(login=login, password=password) @@ -97,7 +122,12 @@ def login(ctx, opts, login, password): # pylint: disable=redefined-outer-name try: with ( - handle_api_exceptions(ctx, opts=opts, context_msg=context_msg), + handle_api_exceptions( + ctx, + opts=opts, + context_msg=context_msg, + summarise_error=summarise_removed_login, + ), maybe_spinner(opts), ): api_key = get_user_token( diff --git a/cloudsmith_cli/cli/exceptions.py b/cloudsmith_cli/cli/exceptions.py index c170540c..bb41a16e 100644 --- a/cloudsmith_cli/cli/exceptions.py +++ b/cloudsmith_cli/cli/exceptions.py @@ -229,6 +229,10 @@ def get_401_error_hint(ctx, opts, exc): def get_404_error_hint(ctx, opts, exc): """Get the hint for a 404/NotFound error.""" # pylint: disable=unused-argument + if ctx.info_name in ("login", "token"): + # The token endpoint itself is gone, not a user/org + return None + # pylint: disable=fixme # TODO(ls): Expand this to be contextual (we could look at the # arguments for the command). diff --git a/cloudsmith_cli/cli/tests/commands/test_login.py b/cloudsmith_cli/cli/tests/commands/test_login.py index c815e91d..76e47097 100644 --- a/cloudsmith_cli/cli/tests/commands/test_login.py +++ b/cloudsmith_cli/cli/tests/commands/test_login.py @@ -1,9 +1,11 @@ import json -from unittest.mock import patch +from contextlib import ExitStack +from unittest.mock import Mock, patch import pytest -from ...commands.login import login +from ....core.api.exceptions import ApiException, TwoFactorRequiredException +from ...commands.login import REMOVED_MESSAGE, login from ...commands.main import main @@ -35,11 +37,20 @@ def test_login_via_args(self, runner, username, password, api_key): assert expected in result.stdout -def invoke_main_login(runner, config_dir, name, *extra_args, user_input=None): +def invoke_main_login( + runner, + config_dir, + name, + *extra_args, + user_input=None, + get_user_token=None, + mock_config_files=True, +): """Run `cloudsmith ` through the registered command tree. The config and credentials paths point at an empty directory so a real config.ini or credentials.ini on the machine cannot change the result. + ``get_user_token`` replaces the API call (default: return a fake key). """ args = [ name, @@ -51,17 +62,23 @@ def invoke_main_login(runner, config_dir, name, *extra_args, user_input=None): "https://api.example.invalid", *extra_args, ] - with ( - patch( - "cloudsmith_cli.cli.commands.login.get_user_token", - return_value="fake-api-key", - ), - patch( - "cloudsmith_cli.cli.commands.login.create_config_files", - return_value=(False, False), - ), - patch("cloudsmith_cli.cli.commands.login.new_config_messaging"), - ): + with ExitStack() as stack: + stack.enter_context( + patch( + "cloudsmith_cli.cli.commands.login.get_user_token", + get_user_token or Mock(return_value="fake-api-key"), + ) + ) + if mock_config_files: + stack.enter_context( + patch( + "cloudsmith_cli.cli.commands.login.create_config_files", + return_value=(False, False), + ) + ) + stack.enter_context( + patch("cloudsmith_cli.cli.commands.login.new_config_messaging") + ) return runner.invoke(main, args, input=user_input) @@ -97,6 +114,7 @@ def test_warns_before_prompting_for_credentials(self, runner, tmp_path, name): ) def test_json_output_stays_parseable(self, runner, tmp_path, name): + """Config-file prompts and messages must not leak into JSON stdout.""" result = invoke_main_login( runner, tmp_path, @@ -107,10 +125,13 @@ def test_json_output_stays_parseable(self, runner, tmp_path, name): "secret", "--output-format", "json", + user_input="N\n", + mock_config_files=False, ) assert result.exit_code == 0 assert "DEPRECATION WARNING" in result.stderr + assert "do you want to create them?" in result.stderr assert json.loads(result.stdout)["data"]["token"] == "fake-api-key" def test_help_marks_the_command_deprecated(self, runner, tmp_path, name): @@ -127,3 +148,92 @@ def test_shell_completion_parsing_does_not_warn(capsys): captured = capsys.readouterr() assert "DEPRECATION WARNING" not in captured.err assert "DEPRECATION WARNING" not in captured.out + + +@pytest.mark.parametrize("name", ["login", "token"]) +@pytest.mark.parametrize("status", [404, 410]) +class TestLoginSwitchedOff: + """Once the server switches the endpoint off, say so instead of a raw error.""" + + @staticmethod + def switched_off(status): + return Mock(side_effect=ApiException(status, detail="Gone.")) + + def test_reports_removal(self, runner, tmp_path, name, status): + result = invoke_main_login( + runner, + tmp_path, + name, + "-l", + "you@example.com", + "-p", + "secret", + get_user_token=self.switched_off(status), + ) + + assert result.return_value == status + assert REMOVED_MESSAGE in result.output + assert f"status: {status}" not in result.output + assert "usually means the user/org is wrong" not in result.output + + def test_json_reports_removal(self, runner, tmp_path, name, status): + result = invoke_main_login( + runner, + tmp_path, + name, + "-l", + "you@example.com", + "-p", + "secret", + "--output-format", + "json", + get_user_token=self.switched_off(status), + ) + + error = json.loads(result.stdout) + assert result.return_value == status + assert error["detail"] == REMOVED_MESSAGE + assert error["meta"]["code"] == status + assert error["help"]["hint"] is None + + def test_reports_removal_after_two_factor(self, runner, tmp_path, name, status): + get_user_token = Mock( + side_effect=[ + TwoFactorRequiredException("2fa-token"), + ApiException(status, detail="Gone."), + ] + ) + + result = invoke_main_login( + runner, + tmp_path, + name, + "-l", + "you@example.com", + "-p", + "secret", + user_input="123456\n", + get_user_token=get_user_token, + ) + + assert result.return_value == status + assert REMOVED_MESSAGE in result.output + + +@pytest.mark.parametrize("status", [401, 500, 503]) +def test_other_errors_keep_the_standard_rendering(runner, tmp_path, status): + """An outage or bad credentials must not be reported as a removal.""" + result = invoke_main_login( + runner, + tmp_path, + "login", + "-l", + "you@example.com", + "-p", + "secret", + get_user_token=Mock(side_effect=ApiException(status, detail="Nope.")), + ) + + assert result.return_value == status + assert REMOVED_MESSAGE not in result.output + assert f"status: {status}" in result.output diff --git a/cloudsmith_cli/core/config.py b/cloudsmith_cli/core/config.py index 7c4b232a..547eaa7d 100644 --- a/cloudsmith_cli/core/config.py +++ b/cloudsmith_cli/core/config.py @@ -10,9 +10,15 @@ ) +def _use_stderr(opts): + """Keep stdout clean for machine-readable output.""" + return getattr(opts, "output", None) in ("json", "pretty_json") + + def create_config_files(ctx, opts, api_key, force=False): """Create default config files.""" # pylint: disable=unused-argument + err = _use_stderr(opts) config_reader = opts.get_config_reader() creds_reader = opts.get_creds_reader() has_config = config_reader.has_default_file() @@ -21,22 +27,27 @@ def create_config_files(ctx, opts, api_key, force=False): if has_config and has_creds: create = False else: - click.echo() + click.echo(err=err) if not force: create = click.confirm( - "No default config file(s) found, do you want to create them?" + "No default config file(s) found, do you want to create them?", + err=err, ) else: create = "y" - click.echo() + click.echo(err=err) if not create: click.secho( - "For reference here are your default config file locations:", fg="yellow" + "For reference here are your default config file locations:", + fg="yellow", + err=err, ) else: click.secho( - "Great! Let me just create your default configs for you now ...", fg="green" + "Great! Let me just create your default configs for you now ...", + fg="green", + err=err, ) configs = ( @@ -59,6 +70,7 @@ def create_config_files(ctx, opts, api_key, force=False): ), ), nl=False, + err=err, ) if not config.present and create: @@ -72,14 +84,15 @@ def create_config_files(ctx, opts, api_key, force=False): has_errors = True if ok: - click.secho("CREATED", fg="green") + click.secho("CREATED", fg="green", err=err) else: - click.secho("ERROR", fg="red") + click.secho("ERROR", fg="red", err=err) click.secho( "The following error occurred while trying to " "create the file: {message}".format( message=click.style(error_message, fg="red") - ) + ), + err=err, ) continue @@ -94,48 +107,53 @@ def create_config_files(ctx, opts, api_key, force=False): config.reader.get_default_filepath(), config.data["api_key"], ) - click.secho("UPDATED", fg="green") + click.secho("UPDATED", fg="green", err=err) except OSError as exc: has_errors = True - click.secho("ERROR", fg="red") + click.secho("ERROR", fg="red", err=err) click.secho( "The following error occurred while trying to " "update the file: {message}".format( message=click.style(exc.strerror, fg="red") - ) + ), + err=err, ) continue - click.secho("EXISTS" if config.present else "NOT CREATED", fg="yellow") + click.secho("EXISTS" if config.present else "NOT CREATED", fg="yellow", err=err) return create, has_errors def new_config_messaging(has_errors, opts, create, api_key): """Provide messaging to user after generating new configs""" + err = _use_stderr(opts) if has_errors: - click.echo() - click.secho("Oops, please fix the errors and try again!", fg="red") + click.echo(err=err) + click.secho("Oops, please fix the errors and try again!", fg="red", err=err) return if opts.api_key_from_file != api_key: - click.echo() + click.echo(err=err) if opts.api_key_from_file: click.secho( "Note: The above API key doesn't match what you have in " "your default credentials config file.", fg="yellow", + err=err, ) elif not create: click.secho( "Note: Don't forget to put your API key in a config file, " "export it on the environment, or set it via -k.", fg="yellow", + err=err, ) click.secho( "If you need more help please see the documentation: " - f"{click.style(get_help_website(), bold=True)}" + f"{click.style(get_help_website(), bold=True)}", + err=err, ) - click.echo() + click.echo(err=err) - click.secho("You're ready to rock, let's start automating!", fg="green") + click.secho("You're ready to rock, let's start automating!", fg="green", err=err) diff --git a/cloudsmith_cli/core/tests/test_config.py b/cloudsmith_cli/core/tests/test_config.py index 714ae756..d3a7e6fc 100644 --- a/cloudsmith_cli/core/tests/test_config.py +++ b/cloudsmith_cli/core/tests/test_config.py @@ -1,8 +1,11 @@ """Tests for config file messaging.""" +import io from unittest.mock import Mock, patch -from ..config import new_config_messaging +import pytest + +from ..config import create_config_files, new_config_messaging def capture_messages(api_key_from_file, new_api_key, create=False): @@ -41,3 +44,40 @@ def test_prompts_to_store_the_key_when_the_file_has_none(self): messages = capture_messages(api_key_from_file=None, new_api_key="csa_new") assert "Don't forget to put your API key in a config file" in messages + + +def json_opts(output, has_default_file): + """Opts for a JSON-output command whose config readers report the given state.""" + + def reader(name): + return Mock( + config_name=name, + has_default_file=Mock(return_value=has_default_file), + get_default_filepath=Mock(return_value=f"/nowhere/{name}.ini"), + spec=["config_name", "has_default_file", "get_default_filepath"], + ) + + return Mock( + output=output, + api_key_from_file=None, + get_config_reader=Mock(return_value=reader("standard")), + get_creds_reader=Mock(return_value=reader("credentials")), + ) + + +@pytest.mark.parametrize("output", ["json", "pretty_json"]) +@pytest.mark.parametrize("has_default_file", [True, False]) +def test_json_output_keeps_config_messages_off_stdout( + capsys, monkeypatch, output, has_default_file +): + """Callers print JSON on stdout, so prompts and messages go to stderr.""" + monkeypatch.setattr("sys.stdin", io.StringIO("N\n")) + opts = json_opts(output, has_default_file) + + create, has_errors = create_config_files(None, opts, api_key="csa_new") + new_config_messaging(has_errors, opts, create, api_key="csa_new") + + captured = capsys.readouterr() + assert captured.out == "" + assert "config file locations" in captured.err + assert "ready to rock" in captured.err From 707bde62111132c812ebb7f3a6271652398ea89c Mon Sep 17 00:00:00 2001 From: Bartosz Blizniak Date: Fri, 16 Oct 2026 15:51:09 +0100 Subject: [PATCH 3/9] docs(ENG-15089): point the login deprecation at the changelog for the date The warning, help text, CHANGELOG and README say the commands stop working when username and password login is disabled, and that the date will be announced in the public changelog. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- README.md | 2 +- cloudsmith_cli/cli/commands/login.py | 13 +++++++------ cloudsmith_cli/cli/tests/commands/test_login.py | 1 + 4 files changed, 10 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 37345ba2..a4868082 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ### Deprecated -- `cloudsmith login` and its `cloudsmith token` alias are deprecated and will be removed in a future release, because Cloudsmith is removing username and password login. Both commands still work while the server allows password login, but they now print a deprecation warning to stderr before they prompt for credentials. Once the server switches password login off (`410 Gone`, or `404` after the endpoint is removed), they fail with a message that explains how to get an API key instead. Other errors, such as outages or wrong credentials, are reported as before. Get your API key from the Cloudsmith web app (your user menu, then **Personal API Keys**) and set `CLOUDSMITH_API_KEY`, or run `cloudsmith auth` to authenticate with SAML SSO. +- `cloudsmith login` and its `cloudsmith token` alias are deprecated, because Cloudsmith is switching off username and password login. The date they stop working will be announced in the [Cloudsmith changelog](https://cloudsmith.com/changelog). Both commands still work until then, but they now print a deprecation warning to stderr before they prompt for credentials. Once the server switches password login off (`410 Gone`, or `404` after the endpoint is removed), they fail with a message that explains how to get an API key instead. Other errors, such as outages or wrong credentials, are reported as before. Get your API key from the Cloudsmith web app (your user menu, then **Personal API Keys**) and set `CLOUDSMITH_API_KEY`, or run `cloudsmith auth` to authenticate with SAML SSO. ### Changed diff --git a/README.md b/README.md index 1af73237..c29cc976 100644 --- a/README.md +++ b/README.md @@ -371,7 +371,7 @@ Give your API key to the CLI in one of these ways: - Put the key into your `credentials.ini` file. - Pass the key to the CLI with the `-k your_key_here` flag. -*Note:* The `cloudsmith login` command and its `cloudsmith token` alias are deprecated and will be removed in a future release. They retrieve your API key with your email address and password, and Cloudsmith is removing username and password login. Once it is switched off, both commands fail with a message that explains how to get an API key instead. +*Note:* The `cloudsmith login` command and its `cloudsmith token` alias are deprecated. They retrieve your API key with your email address and password, and Cloudsmith is switching off username and password login. The date will be announced in the [Cloudsmith changelog](https://cloudsmith.com/changelog). After that, both commands fail with a message that explains how to get an API key instead. ## Uploading Packages diff --git a/cloudsmith_cli/cli/commands/login.py b/cloudsmith_cli/cli/commands/login.py index 0139a297..16d507a5 100644 --- a/cloudsmith_cli/cli/commands/login.py +++ b/cloudsmith_cli/cli/commands/login.py @@ -22,14 +22,15 @@ def validate_login(ctx, param, value): DEPRECATION_WARNING = ( - "DEPRECATION WARNING: 'cloudsmith {name}' is deprecated and will be removed in " - "a future release. Username and password login is going away.\n" + "DEPRECATION WARNING: 'cloudsmith {name}' is deprecated, because Cloudsmith is " + "switching off username and password login.\n" "To authenticate instead, do one of these:\n" " - Get your API key from the Cloudsmith web app (your user menu, then " "Personal API Keys) and set the CLOUDSMITH_API_KEY environment variable or " "add it to credentials.ini.\n" " - Run 'cloudsmith auth' to authenticate with SAML SSO.\n" - "See https://docs.cloudsmith.com/accounts-and-teams/api-key for details." + "The date this command stops working will be announced in the Cloudsmith " + "changelog: https://cloudsmith.com/changelog" ) @@ -84,9 +85,9 @@ def parse_args(self, ctx, args): def login(ctx, opts, login, password): # pylint: disable=redefined-outer-name """[DEPRECATED] Retrieve your API authentication token/key via login. - Username and password login will be removed in a future release. Get your - API key from the Cloudsmith web app instead, or run 'cloudsmith auth' to - authenticate with SAML SSO. + Cloudsmith is switching off username and password login. The date will be + announced in the Cloudsmith changelog. Get your API key from the Cloudsmith + web app instead, or run 'cloudsmith auth' to authenticate with SAML SSO. """ use_stderr = utils.should_use_stderr(opts) click.echo( diff --git a/cloudsmith_cli/cli/tests/commands/test_login.py b/cloudsmith_cli/cli/tests/commands/test_login.py index 76e47097..c735a782 100644 --- a/cloudsmith_cli/cli/tests/commands/test_login.py +++ b/cloudsmith_cli/cli/tests/commands/test_login.py @@ -97,6 +97,7 @@ def test_warns_on_stderr_and_still_returns_the_key(self, runner, tmp_path, name) ) assert "Personal API Keys" in result.stderr assert "cloudsmith auth" in result.stderr + assert "https://cloudsmith.com/changelog" in result.stderr assert "DEPRECATION WARNING" not in result.stdout assert "Your API key/token is: fake-api-key" in result.stdout From c4bb01487cba04b65424fd4dd599ae85249be2f8 Mon Sep 17 00:00:00 2001 From: Bartosz Blizniak Date: Fri, 16 Oct 2026 15:51:10 +0100 Subject: [PATCH 4/9] docs(ENG-15089): tidy the wording of the login messages - The deprecation warning uses a `Warning:` prefix and names the replacement. - The disabled-login error states what failed and adds a `Fix:` line with the exact command. - Use "API key" consistently, and stop telling users to "try again" in the 401 hints. The success output (`Your API key/token is: ...`) is unchanged so that scripts parsing it keep working. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 +- README.md | 2 +- cloudsmith_cli/cli/commands/login.py | 44 +++++++++---------- cloudsmith_cli/cli/exceptions.py | 12 +++-- .../cli/tests/commands/test_login.py | 16 +++---- cloudsmith_cli/cli/tests/test_exceptions.py | 2 +- 6 files changed, 37 insertions(+), 43 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a4868082..c8f2b81d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,11 +9,11 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ### Deprecated -- `cloudsmith login` and its `cloudsmith token` alias are deprecated, because Cloudsmith is switching off username and password login. The date they stop working will be announced in the [Cloudsmith changelog](https://cloudsmith.com/changelog). Both commands still work until then, but they now print a deprecation warning to stderr before they prompt for credentials. Once the server switches password login off (`410 Gone`, or `404` after the endpoint is removed), they fail with a message that explains how to get an API key instead. Other errors, such as outages or wrong credentials, are reported as before. Get your API key from the Cloudsmith web app (your user menu, then **Personal API Keys**) and set `CLOUDSMITH_API_KEY`, or run `cloudsmith auth` to authenticate with SAML SSO. +- `cloudsmith login` and its `cloudsmith token` alias are deprecated, and will stop working when Cloudsmith disables username and password login. The date will be announced in the [Cloudsmith changelog](https://cloudsmith.com/changelog). Until then, both commands work as before, but they print a deprecation warning to stderr before they prompt for credentials. Once the server disables password login (`410 Gone`, or `404` after the endpoint is deleted), they fail with a message that explains how to get an API key instead. Other errors, such as outages or wrong credentials, are reported as before. To replace them, set `CLOUDSMITH_API_KEY` to an API key from the Cloudsmith web app (your user menu, then **Personal API Keys**), or run `cloudsmith auth` to log in with SAML SSO. ### Changed -- The hint shown for a `401 - Unauthorized` error when no credentials are set no longer suggests `cloudsmith token`. It now suggests `$CLOUDSMITH_API_KEY` or `cloudsmith auth`. +- The hint shown for a `401 - Unauthorized` error when no API key or SSO session is set no longer suggests `cloudsmith token`. It now suggests `CLOUDSMITH_API_KEY` or `cloudsmith auth`. ### Fixed diff --git a/README.md b/README.md index c29cc976..19cdfa1f 100644 --- a/README.md +++ b/README.md @@ -371,7 +371,7 @@ Give your API key to the CLI in one of these ways: - Put the key into your `credentials.ini` file. - Pass the key to the CLI with the `-k your_key_here` flag. -*Note:* The `cloudsmith login` command and its `cloudsmith token` alias are deprecated. They retrieve your API key with your email address and password, and Cloudsmith is switching off username and password login. The date will be announced in the [Cloudsmith changelog](https://cloudsmith.com/changelog). After that, both commands fail with a message that explains how to get an API key instead. +*Note:* The `cloudsmith login` command and its `cloudsmith token` alias are deprecated. They retrieve your API key with your email address and password, and they will stop working when Cloudsmith disables username and password login. The date will be announced in the [Cloudsmith changelog](https://cloudsmith.com/changelog). After that, both commands fail with a message that explains how to get an API key instead. ## Uploading Packages diff --git a/cloudsmith_cli/cli/commands/login.py b/cloudsmith_cli/cli/commands/login.py index 16d507a5..e7d0efaf 100644 --- a/cloudsmith_cli/cli/commands/login.py +++ b/cloudsmith_cli/cli/commands/login.py @@ -22,32 +22,29 @@ def validate_login(ctx, param, value): DEPRECATION_WARNING = ( - "DEPRECATION WARNING: 'cloudsmith {name}' is deprecated, because Cloudsmith is " - "switching off username and password login.\n" - "To authenticate instead, do one of these:\n" - " - Get your API key from the Cloudsmith web app (your user menu, then " - "Personal API Keys) and set the CLOUDSMITH_API_KEY environment variable or " - "add it to credentials.ini.\n" - " - Run 'cloudsmith auth' to authenticate with SAML SSO.\n" - "The date this command stops working will be announced in the Cloudsmith " - "changelog: https://cloudsmith.com/changelog" + "Warning: `cloudsmith {name}` is deprecated and will stop working when " + "Cloudsmith disables username and password login. The date will be announced " + "at https://cloudsmith.com/changelog\n" + "Use an API key or SSO instead: set `CLOUDSMITH_API_KEY` to an API key from " + "the Cloudsmith web app (your user menu, then Personal API Keys), or run " + "`cloudsmith auth` to log in with SAML SSO." ) REMOVED_MESSAGE = ( - "Username and password login has been removed, so this command no longer " - "works. Get your API key from the Cloudsmith web app (your user menu, then " - "Personal API Keys) and set the CLOUDSMITH_API_KEY environment variable, or " - "run 'cloudsmith auth' to authenticate with SAML SSO." + "Could not retrieve API key: username and password login is disabled.\n" + "Fix: set `CLOUDSMITH_API_KEY` to an API key from the Cloudsmith web app " + "(your user menu, then Personal API Keys), or run `cloudsmith auth` to log in " + "with SAML SSO." ) -#: Statuses the token endpoint returns once the server has switched password -#: login off (410) or removed the route entirely (404). +#: Statuses the token endpoint returns once the server has disabled password +#: login (410) or deleted the route entirely (404). REMOVED_STATUSES = (404, 410) def summarise_removed_login(exc, detail, fields): - """Replace the raw error with a removal notice once the endpoint is off.""" + """Replace the raw error with a clear notice once the endpoint is disabled.""" # pylint: disable=unused-argument if exc.status in REMOVED_STATUSES: return REMOVED_MESSAGE @@ -83,20 +80,23 @@ def parse_args(self, ctx, args): @decorators.initialise_api @click.pass_context def login(ctx, opts, login, password): # pylint: disable=redefined-outer-name - """[DEPRECATED] Retrieve your API authentication token/key via login. + """[DEPRECATED] Retrieve your API key with your email address and password. - Cloudsmith is switching off username and password login. The date will be - announced in the Cloudsmith changelog. Get your API key from the Cloudsmith - web app instead, or run 'cloudsmith auth' to authenticate with SAML SSO. + This command will stop working when Cloudsmith disables username and + password login. The date will be announced at + https://cloudsmith.com/changelog + + Set `CLOUDSMITH_API_KEY` to an API key from the Cloudsmith web app instead, + or run `cloudsmith auth` to log in with SAML SSO. """ use_stderr = utils.should_use_stderr(opts) click.echo( - f"Retrieving API token for {click.style(login, bold=True)} ... ", + f"Retrieving API key for {click.style(login, bold=True)} ... ", nl=False, err=use_stderr, ) - context_msg = "Failed to retrieve the API token!" + context_msg = "Could not retrieve API key" try: with ( handle_api_exceptions( diff --git a/cloudsmith_cli/cli/exceptions.py b/cloudsmith_cli/cli/exceptions.py index bb41a16e..59ccc497 100644 --- a/cloudsmith_cli/cli/exceptions.py +++ b/cloudsmith_cli/cli/exceptions.py @@ -213,16 +213,14 @@ def get_401_error_hint(ctx, opts, exc): if ctx.info_name in ("login", "token"): # This is already the (deprecated) login command return ( - "The login failed - Either your email address and/or " - "your password was incorrect. Please check them and " - "try again!" + "Could not log in with this email address and password. Check both " + "and run the command again." ) return ( - "You don't have an API key or access token set, but it seems this action " - "requires authentication - Try setting your API key via " - "$CLOUDSMITH_API_KEY, or getting an access token via 'cloudsmith auth', " - "then try again." + "This command requires authentication, and no API key or SSO session was " + "found. Set `CLOUDSMITH_API_KEY` to your API key, or run `cloudsmith auth` " + "to log in with SAML SSO." ) diff --git a/cloudsmith_cli/cli/tests/commands/test_login.py b/cloudsmith_cli/cli/tests/commands/test_login.py index c735a782..c6136fd5 100644 --- a/cloudsmith_cli/cli/tests/commands/test_login.py +++ b/cloudsmith_cli/cli/tests/commands/test_login.py @@ -92,13 +92,11 @@ def test_warns_on_stderr_and_still_returns_the_key(self, runner, tmp_path, name) ) assert result.exit_code == 0 - assert f"DEPRECATION WARNING: 'cloudsmith {name}' is deprecated" in ( - result.stderr - ) + assert f"Warning: `cloudsmith {name}` is deprecated" in result.stderr assert "Personal API Keys" in result.stderr assert "cloudsmith auth" in result.stderr assert "https://cloudsmith.com/changelog" in result.stderr - assert "DEPRECATION WARNING" not in result.stdout + assert "is deprecated" not in result.stdout assert "Your API key/token is: fake-api-key" in result.stdout def test_warns_before_prompting_for_credentials(self, runner, tmp_path, name): @@ -110,9 +108,7 @@ def test_warns_before_prompting_for_credentials(self, runner, tmp_path, name): ) assert result.exit_code == 0 - assert result.output.index("DEPRECATION WARNING") < result.output.index( - "Login:" - ) + assert result.output.index("is deprecated") < result.output.index("Login:") def test_json_output_stays_parseable(self, runner, tmp_path, name): """Config-file prompts and messages must not leak into JSON stdout.""" @@ -131,7 +127,7 @@ def test_json_output_stays_parseable(self, runner, tmp_path, name): ) assert result.exit_code == 0 - assert "DEPRECATION WARNING" in result.stderr + assert "is deprecated" in result.stderr assert "do you want to create them?" in result.stderr assert json.loads(result.stdout)["data"]["token"] == "fake-api-key" @@ -147,8 +143,8 @@ def test_shell_completion_parsing_does_not_warn(capsys): login.make_context("login", ["-l", "you@example.com"], resilient_parsing=True) captured = capsys.readouterr() - assert "DEPRECATION WARNING" not in captured.err - assert "DEPRECATION WARNING" not in captured.out + assert "is deprecated" not in captured.err + assert "is deprecated" not in captured.out @pytest.mark.parametrize("name", ["login", "token"]) diff --git a/cloudsmith_cli/cli/tests/test_exceptions.py b/cloudsmith_cli/cli/tests/test_exceptions.py index 7e538991..12cda5e3 100644 --- a/cloudsmith_cli/cli/tests/test_exceptions.py +++ b/cloudsmith_cli/cli/tests/test_exceptions.py @@ -55,7 +55,7 @@ def test_no_credential_suggests_authenticating(self): @pytest.mark.parametrize("info_name", ["login", "token"]) def test_no_credential_on_login_command_reports_a_failed_login(self, info_name): - assert "login failed" in hint_for(None, info_name=info_name) + assert "Could not log in" in hint_for(None, info_name=info_name) def invoke_credentialed_401(runner, config_dir, output_format="pretty"): From 2c8e02bf5ce3234f68846f76760e8356cd43484c Mon Sep 17 00:00:00 2001 From: Bartosz Blizniak Date: Fri, 16 Oct 2026 15:51:11 +0100 Subject: [PATCH 5/9] docs(ENG-15089): link the login messages to the API key docs The deprecation warning, the disabled-login error and `--help` now end with a `More:` line linking to the CLI docs section on getting an API key. The SSO fix names the exact command, `cloudsmith auth --workspace `. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- cloudsmith_cli/cli/commands/login.py | 17 +++++++++++++---- cloudsmith_cli/cli/exceptions.py | 4 ++-- cloudsmith_cli/cli/tests/commands/test_login.py | 5 ++++- 4 files changed, 20 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c8f2b81d..69c7d6ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ### Deprecated -- `cloudsmith login` and its `cloudsmith token` alias are deprecated, and will stop working when Cloudsmith disables username and password login. The date will be announced in the [Cloudsmith changelog](https://cloudsmith.com/changelog). Until then, both commands work as before, but they print a deprecation warning to stderr before they prompt for credentials. Once the server disables password login (`410 Gone`, or `404` after the endpoint is deleted), they fail with a message that explains how to get an API key instead. Other errors, such as outages or wrong credentials, are reported as before. To replace them, set `CLOUDSMITH_API_KEY` to an API key from the Cloudsmith web app (your user menu, then **Personal API Keys**), or run `cloudsmith auth` to log in with SAML SSO. +- `cloudsmith login` and its `cloudsmith token` alias are deprecated, and will stop working when Cloudsmith disables username and password login. The date will be announced in the [Cloudsmith changelog](https://cloudsmith.com/changelog). Until then, both commands work as before, but they print a deprecation warning to stderr before they prompt for credentials. Once the server disables password login (`410 Gone`, or `404` after the endpoint is deleted), they fail with a message that explains how to get an API key instead. Other errors, such as outages or wrong credentials, are reported as before. To replace them, set `CLOUDSMITH_API_KEY` to an API key from the Cloudsmith web app (your user menu, then **Personal API Keys**), or run `cloudsmith auth --workspace ` to log in with SAML SSO. See [Getting your API key](https://docs.cloudsmith.com/developer-tools/cli#getting-your-api-key). ### Changed diff --git a/cloudsmith_cli/cli/commands/login.py b/cloudsmith_cli/cli/commands/login.py index e7d0efaf..d7a9e502 100644 --- a/cloudsmith_cli/cli/commands/login.py +++ b/cloudsmith_cli/cli/commands/login.py @@ -21,21 +21,28 @@ def validate_login(ctx, param, value): return value +#: Docs section on getting an API key and logging in with SAML SSO instead. +API_KEY_DOCS_URL = ( + "https://docs.cloudsmith.com/developer-tools/cli#getting-your-api-key" +) + DEPRECATION_WARNING = ( "Warning: `cloudsmith {name}` is deprecated and will stop working when " "Cloudsmith disables username and password login. The date will be announced " "at https://cloudsmith.com/changelog\n" "Use an API key or SSO instead: set `CLOUDSMITH_API_KEY` to an API key from " "the Cloudsmith web app (your user menu, then Personal API Keys), or run " - "`cloudsmith auth` to log in with SAML SSO." + "`cloudsmith auth --workspace ` to log in with SAML SSO.\n" + f"More: {API_KEY_DOCS_URL}" ) REMOVED_MESSAGE = ( "Could not retrieve API key: username and password login is disabled.\n" "Fix: set `CLOUDSMITH_API_KEY` to an API key from the Cloudsmith web app " - "(your user menu, then Personal API Keys), or run `cloudsmith auth` to log in " - "with SAML SSO." + "(your user menu, then Personal API Keys), or run " + "`cloudsmith auth --workspace ` to log in with SAML SSO.\n" + f"More: {API_KEY_DOCS_URL}" ) #: Statuses the token endpoint returns once the server has disabled password @@ -87,7 +94,9 @@ def login(ctx, opts, login, password): # pylint: disable=redefined-outer-name https://cloudsmith.com/changelog Set `CLOUDSMITH_API_KEY` to an API key from the Cloudsmith web app instead, - or run `cloudsmith auth` to log in with SAML SSO. + or run `cloudsmith auth --workspace ` to log in with SAML SSO. + + More: https://docs.cloudsmith.com/developer-tools/cli#getting-your-api-key """ use_stderr = utils.should_use_stderr(opts) click.echo( diff --git a/cloudsmith_cli/cli/exceptions.py b/cloudsmith_cli/cli/exceptions.py index 59ccc497..b7bc6e05 100644 --- a/cloudsmith_cli/cli/exceptions.py +++ b/cloudsmith_cli/cli/exceptions.py @@ -219,8 +219,8 @@ def get_401_error_hint(ctx, opts, exc): return ( "This command requires authentication, and no API key or SSO session was " - "found. Set `CLOUDSMITH_API_KEY` to your API key, or run `cloudsmith auth` " - "to log in with SAML SSO." + "found. Set `CLOUDSMITH_API_KEY` to your API key, or run " + "`cloudsmith auth --workspace ` to log in with SAML SSO." ) diff --git a/cloudsmith_cli/cli/tests/commands/test_login.py b/cloudsmith_cli/cli/tests/commands/test_login.py index c6136fd5..812549f1 100644 --- a/cloudsmith_cli/cli/tests/commands/test_login.py +++ b/cloudsmith_cli/cli/tests/commands/test_login.py @@ -5,7 +5,7 @@ import pytest from ....core.api.exceptions import ApiException, TwoFactorRequiredException -from ...commands.login import REMOVED_MESSAGE, login +from ...commands.login import API_KEY_DOCS_URL, REMOVED_MESSAGE, login from ...commands.main import main @@ -96,6 +96,7 @@ def test_warns_on_stderr_and_still_returns_the_key(self, runner, tmp_path, name) assert "Personal API Keys" in result.stderr assert "cloudsmith auth" in result.stderr assert "https://cloudsmith.com/changelog" in result.stderr + assert f"More: {API_KEY_DOCS_URL}" in result.stderr assert "is deprecated" not in result.stdout assert "Your API key/token is: fake-api-key" in result.stdout @@ -136,6 +137,7 @@ def test_help_marks_the_command_deprecated(self, runner, tmp_path, name): assert result.exit_code == 0 assert "[DEPRECATED]" in result.stdout + assert API_KEY_DOCS_URL in result.stdout def test_shell_completion_parsing_does_not_warn(capsys): @@ -170,6 +172,7 @@ def test_reports_removal(self, runner, tmp_path, name, status): assert result.return_value == status assert REMOVED_MESSAGE in result.output + assert f"More: {API_KEY_DOCS_URL}" in result.output assert f"status: {status}" not in result.output assert "usually means the user/org is wrong" not in result.output From 49ac4268ed317238f30a8f3226e424d70f048a28 Mon Sep 17 00:00:00 2001 From: Bartosz Blizniak Date: Fri, 16 Oct 2026 15:59:48 +0100 Subject: [PATCH 6/9] fix(ENG-15089): leave config file output unchanged Keep this change to the login deprecation. Routing config file prompts and messages to stderr for JSON output is a separate fix, so restore `core/config.py` and its tests, and drop the CHANGELOG entry. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 -- .../cli/tests/commands/test_login.py | 22 +++----- cloudsmith_cli/core/config.py | 54 +++++++------------ cloudsmith_cli/core/tests/test_config.py | 42 +-------------- 4 files changed, 27 insertions(+), 95 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 69c7d6ba..ff0aca14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,10 +15,6 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - The hint shown for a `401 - Unauthorized` error when no API key or SSO session is set no longer suggests `cloudsmith token`. It now suggests `CLOUDSMITH_API_KEY` or `cloudsmith auth`. -### Fixed - -- `cloudsmith login`, and `cloudsmith auth` and `cloudsmith tokens` with `--save-config`, no longer write config file prompts and messages to stdout when `--output-format json` or `pretty_json` is set. They now go to stderr, so stdout holds only the JSON. - ## [1.29.0] - 2026-10-15 ### Added diff --git a/cloudsmith_cli/cli/tests/commands/test_login.py b/cloudsmith_cli/cli/tests/commands/test_login.py index 812549f1..d699f24a 100644 --- a/cloudsmith_cli/cli/tests/commands/test_login.py +++ b/cloudsmith_cli/cli/tests/commands/test_login.py @@ -44,7 +44,6 @@ def invoke_main_login( *extra_args, user_input=None, get_user_token=None, - mock_config_files=True, ): """Run `cloudsmith ` through the registered command tree. @@ -69,16 +68,15 @@ def invoke_main_login( get_user_token or Mock(return_value="fake-api-key"), ) ) - if mock_config_files: - stack.enter_context( - patch( - "cloudsmith_cli.cli.commands.login.create_config_files", - return_value=(False, False), - ) - ) - stack.enter_context( - patch("cloudsmith_cli.cli.commands.login.new_config_messaging") + stack.enter_context( + patch( + "cloudsmith_cli.cli.commands.login.create_config_files", + return_value=(False, False), ) + ) + stack.enter_context( + patch("cloudsmith_cli.cli.commands.login.new_config_messaging") + ) return runner.invoke(main, args, input=user_input) @@ -112,7 +110,6 @@ def test_warns_before_prompting_for_credentials(self, runner, tmp_path, name): assert result.output.index("is deprecated") < result.output.index("Login:") def test_json_output_stays_parseable(self, runner, tmp_path, name): - """Config-file prompts and messages must not leak into JSON stdout.""" result = invoke_main_login( runner, tmp_path, @@ -123,13 +120,10 @@ def test_json_output_stays_parseable(self, runner, tmp_path, name): "secret", "--output-format", "json", - user_input="N\n", - mock_config_files=False, ) assert result.exit_code == 0 assert "is deprecated" in result.stderr - assert "do you want to create them?" in result.stderr assert json.loads(result.stdout)["data"]["token"] == "fake-api-key" def test_help_marks_the_command_deprecated(self, runner, tmp_path, name): diff --git a/cloudsmith_cli/core/config.py b/cloudsmith_cli/core/config.py index 547eaa7d..7c4b232a 100644 --- a/cloudsmith_cli/core/config.py +++ b/cloudsmith_cli/core/config.py @@ -10,15 +10,9 @@ ) -def _use_stderr(opts): - """Keep stdout clean for machine-readable output.""" - return getattr(opts, "output", None) in ("json", "pretty_json") - - def create_config_files(ctx, opts, api_key, force=False): """Create default config files.""" # pylint: disable=unused-argument - err = _use_stderr(opts) config_reader = opts.get_config_reader() creds_reader = opts.get_creds_reader() has_config = config_reader.has_default_file() @@ -27,27 +21,22 @@ def create_config_files(ctx, opts, api_key, force=False): if has_config and has_creds: create = False else: - click.echo(err=err) + click.echo() if not force: create = click.confirm( - "No default config file(s) found, do you want to create them?", - err=err, + "No default config file(s) found, do you want to create them?" ) else: create = "y" - click.echo(err=err) + click.echo() if not create: click.secho( - "For reference here are your default config file locations:", - fg="yellow", - err=err, + "For reference here are your default config file locations:", fg="yellow" ) else: click.secho( - "Great! Let me just create your default configs for you now ...", - fg="green", - err=err, + "Great! Let me just create your default configs for you now ...", fg="green" ) configs = ( @@ -70,7 +59,6 @@ def create_config_files(ctx, opts, api_key, force=False): ), ), nl=False, - err=err, ) if not config.present and create: @@ -84,15 +72,14 @@ def create_config_files(ctx, opts, api_key, force=False): has_errors = True if ok: - click.secho("CREATED", fg="green", err=err) + click.secho("CREATED", fg="green") else: - click.secho("ERROR", fg="red", err=err) + click.secho("ERROR", fg="red") click.secho( "The following error occurred while trying to " "create the file: {message}".format( message=click.style(error_message, fg="red") - ), - err=err, + ) ) continue @@ -107,53 +94,48 @@ def create_config_files(ctx, opts, api_key, force=False): config.reader.get_default_filepath(), config.data["api_key"], ) - click.secho("UPDATED", fg="green", err=err) + click.secho("UPDATED", fg="green") except OSError as exc: has_errors = True - click.secho("ERROR", fg="red", err=err) + click.secho("ERROR", fg="red") click.secho( "The following error occurred while trying to " "update the file: {message}".format( message=click.style(exc.strerror, fg="red") - ), - err=err, + ) ) continue - click.secho("EXISTS" if config.present else "NOT CREATED", fg="yellow", err=err) + click.secho("EXISTS" if config.present else "NOT CREATED", fg="yellow") return create, has_errors def new_config_messaging(has_errors, opts, create, api_key): """Provide messaging to user after generating new configs""" - err = _use_stderr(opts) if has_errors: - click.echo(err=err) - click.secho("Oops, please fix the errors and try again!", fg="red", err=err) + click.echo() + click.secho("Oops, please fix the errors and try again!", fg="red") return if opts.api_key_from_file != api_key: - click.echo(err=err) + click.echo() if opts.api_key_from_file: click.secho( "Note: The above API key doesn't match what you have in " "your default credentials config file.", fg="yellow", - err=err, ) elif not create: click.secho( "Note: Don't forget to put your API key in a config file, " "export it on the environment, or set it via -k.", fg="yellow", - err=err, ) click.secho( "If you need more help please see the documentation: " - f"{click.style(get_help_website(), bold=True)}", - err=err, + f"{click.style(get_help_website(), bold=True)}" ) - click.echo(err=err) + click.echo() - click.secho("You're ready to rock, let's start automating!", fg="green", err=err) + click.secho("You're ready to rock, let's start automating!", fg="green") diff --git a/cloudsmith_cli/core/tests/test_config.py b/cloudsmith_cli/core/tests/test_config.py index d3a7e6fc..714ae756 100644 --- a/cloudsmith_cli/core/tests/test_config.py +++ b/cloudsmith_cli/core/tests/test_config.py @@ -1,11 +1,8 @@ """Tests for config file messaging.""" -import io from unittest.mock import Mock, patch -import pytest - -from ..config import create_config_files, new_config_messaging +from ..config import new_config_messaging def capture_messages(api_key_from_file, new_api_key, create=False): @@ -44,40 +41,3 @@ def test_prompts_to_store_the_key_when_the_file_has_none(self): messages = capture_messages(api_key_from_file=None, new_api_key="csa_new") assert "Don't forget to put your API key in a config file" in messages - - -def json_opts(output, has_default_file): - """Opts for a JSON-output command whose config readers report the given state.""" - - def reader(name): - return Mock( - config_name=name, - has_default_file=Mock(return_value=has_default_file), - get_default_filepath=Mock(return_value=f"/nowhere/{name}.ini"), - spec=["config_name", "has_default_file", "get_default_filepath"], - ) - - return Mock( - output=output, - api_key_from_file=None, - get_config_reader=Mock(return_value=reader("standard")), - get_creds_reader=Mock(return_value=reader("credentials")), - ) - - -@pytest.mark.parametrize("output", ["json", "pretty_json"]) -@pytest.mark.parametrize("has_default_file", [True, False]) -def test_json_output_keeps_config_messages_off_stdout( - capsys, monkeypatch, output, has_default_file -): - """Callers print JSON on stdout, so prompts and messages go to stderr.""" - monkeypatch.setattr("sys.stdin", io.StringIO("N\n")) - opts = json_opts(output, has_default_file) - - create, has_errors = create_config_files(None, opts, api_key="csa_new") - new_config_messaging(has_errors, opts, create, api_key="csa_new") - - captured = capsys.readouterr() - assert captured.out == "" - assert "config file locations" in captured.err - assert "ready to rock" in captured.err From 15eee572f8a79a237381dca54018a6b6c366de75 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 19 Oct 2026 08:30:17 +0000 Subject: [PATCH 7/9] fix: standardize login authentication messages on SAML Co-authored-by: cloudsmith-iduffy <178375997+cloudsmith-iduffy@users.noreply.github.com> --- cloudsmith_cli/cli/commands/login.py | 10 +++++----- cloudsmith_cli/cli/exceptions.py | 6 +++--- cloudsmith_cli/cli/tests/commands/test_login.py | 7 +++++++ cloudsmith_cli/cli/tests/test_exceptions.py | 11 +++++++++-- 4 files changed, 24 insertions(+), 10 deletions(-) diff --git a/cloudsmith_cli/cli/commands/login.py b/cloudsmith_cli/cli/commands/login.py index d7a9e502..97388356 100644 --- a/cloudsmith_cli/cli/commands/login.py +++ b/cloudsmith_cli/cli/commands/login.py @@ -21,7 +21,7 @@ def validate_login(ctx, param, value): return value -#: Docs section on getting an API key and logging in with SAML SSO instead. +#: Docs section on getting an API key and logging in with SAML instead. API_KEY_DOCS_URL = ( "https://docs.cloudsmith.com/developer-tools/cli#getting-your-api-key" ) @@ -30,9 +30,9 @@ def validate_login(ctx, param, value): "Warning: `cloudsmith {name}` is deprecated and will stop working when " "Cloudsmith disables username and password login. The date will be announced " "at https://cloudsmith.com/changelog\n" - "Use an API key or SSO instead: set `CLOUDSMITH_API_KEY` to an API key from " + "Use an API key or SAML instead: set `CLOUDSMITH_API_KEY` to an API key from " "the Cloudsmith web app (your user menu, then Personal API Keys), or run " - "`cloudsmith auth --workspace ` to log in with SAML SSO.\n" + "`cloudsmith auth --workspace ` to log in with SAML.\n" f"More: {API_KEY_DOCS_URL}" ) @@ -41,7 +41,7 @@ def validate_login(ctx, param, value): "Could not retrieve API key: username and password login is disabled.\n" "Fix: set `CLOUDSMITH_API_KEY` to an API key from the Cloudsmith web app " "(your user menu, then Personal API Keys), or run " - "`cloudsmith auth --workspace ` to log in with SAML SSO.\n" + "`cloudsmith auth --workspace ` to log in with SAML.\n" f"More: {API_KEY_DOCS_URL}" ) @@ -94,7 +94,7 @@ def login(ctx, opts, login, password): # pylint: disable=redefined-outer-name https://cloudsmith.com/changelog Set `CLOUDSMITH_API_KEY` to an API key from the Cloudsmith web app instead, - or run `cloudsmith auth --workspace ` to log in with SAML SSO. + or run `cloudsmith auth --workspace ` to log in with SAML. More: https://docs.cloudsmith.com/developer-tools/cli#getting-your-api-key """ diff --git a/cloudsmith_cli/cli/exceptions.py b/cloudsmith_cli/cli/exceptions.py index b7bc6e05..38bd42db 100644 --- a/cloudsmith_cli/cli/exceptions.py +++ b/cloudsmith_cli/cli/exceptions.py @@ -201,7 +201,7 @@ def get_401_error_hint(ctx, opts, exc): credential = getattr(opts, "credential", None) if credential and credential.auth_type == "bearer": - return "Since you have an SSO access token set, this probably means that it has expired. Try getting a new token with 'cloudsmith auth', then try again." + return "Since you have a SAML access token set, this probably means that it has expired. Try getting a new token with 'cloudsmith auth', then try again." if credential: return ( @@ -218,9 +218,9 @@ def get_401_error_hint(ctx, opts, exc): ) return ( - "This command requires authentication, and no API key or SSO session was " + "This command requires authentication, and no API key or SAML session was " "found. Set `CLOUDSMITH_API_KEY` to your API key, or run " - "`cloudsmith auth --workspace ` to log in with SAML SSO." + "`cloudsmith auth --workspace ` to log in with SAML." ) diff --git a/cloudsmith_cli/cli/tests/commands/test_login.py b/cloudsmith_cli/cli/tests/commands/test_login.py index d699f24a..21e73fad 100644 --- a/cloudsmith_cli/cli/tests/commands/test_login.py +++ b/cloudsmith_cli/cli/tests/commands/test_login.py @@ -93,6 +93,9 @@ def test_warns_on_stderr_and_still_returns_the_key(self, runner, tmp_path, name) assert f"Warning: `cloudsmith {name}` is deprecated" in result.stderr assert "Personal API Keys" in result.stderr assert "cloudsmith auth" in result.stderr + assert "Use an API key or SAML instead" in result.stderr + assert "log in with SAML." in result.stderr + assert "sso" not in result.stderr.lower() assert "https://cloudsmith.com/changelog" in result.stderr assert f"More: {API_KEY_DOCS_URL}" in result.stderr assert "is deprecated" not in result.stdout @@ -132,6 +135,8 @@ def test_help_marks_the_command_deprecated(self, runner, tmp_path, name): assert result.exit_code == 0 assert "[DEPRECATED]" in result.stdout assert API_KEY_DOCS_URL in result.stdout + assert "log in with SAML." in result.stdout + assert "sso" not in result.stdout.lower() def test_shell_completion_parsing_does_not_warn(capsys): @@ -166,6 +171,8 @@ def test_reports_removal(self, runner, tmp_path, name, status): assert result.return_value == status assert REMOVED_MESSAGE in result.output + assert "log in with SAML." in result.output + assert "sso" not in result.output.lower() assert f"More: {API_KEY_DOCS_URL}" in result.output assert f"status: {status}" not in result.output assert "usually means the user/org is wrong" not in result.output diff --git a/cloudsmith_cli/cli/tests/test_exceptions.py b/cloudsmith_cli/cli/tests/test_exceptions.py index 12cda5e3..729cc018 100644 --- a/cloudsmith_cli/cli/tests/test_exceptions.py +++ b/cloudsmith_cli/cli/tests/test_exceptions.py @@ -35,10 +35,14 @@ class TestGet401ErrorHint: def test_bearer_credential_suggests_reauthenticating(self): credential = CredentialResult( - api_key="sso-token", source_name="keyring", auth_type="bearer" + api_key="saml-token", source_name="keyring", auth_type="bearer" ) - assert "cloudsmith auth" in hint_for(credential) + hint = hint_for(credential) + + assert "cloudsmith auth" in hint + assert "SAML access token" in hint + assert "sso" not in hint.lower() def test_api_key_credential_does_not_assert_a_permissions_problem(self): """A 401 alone cannot establish a specific permissions problem.""" @@ -52,6 +56,9 @@ def test_no_credential_suggests_authenticating(self): assert "CLOUDSMITH_API_KEY" in hint assert "cloudsmith auth" in hint assert "cloudsmith token" not in hint + assert "SAML session" in hint + assert "log in with SAML." in hint + assert "sso" not in hint.lower() @pytest.mark.parametrize("info_name", ["login", "token"]) def test_no_credential_on_login_command_reports_a_failed_login(self, info_name): From 460e8d83d84216c78513c0716908372de45e99ea Mon Sep 17 00:00:00 2001 From: Bartosz Blizniak Date: Mon, 19 Oct 2026 09:32:21 +0100 Subject: [PATCH 8/9] fix(ENG-15089): keep the failed-login hint text, use SAML in CHANGELOG Restore the original wording of the failed-login 401 hint; only the `login` name is added so it no longer suggests the command that just failed. Say "SAML" instead of "SSO" in the CHANGELOG entries. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 ++-- cloudsmith_cli/cli/exceptions.py | 5 +++-- cloudsmith_cli/cli/tests/test_exceptions.py | 2 +- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ff0aca14..301dce34 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,11 +9,11 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ### Deprecated -- `cloudsmith login` and its `cloudsmith token` alias are deprecated, and will stop working when Cloudsmith disables username and password login. The date will be announced in the [Cloudsmith changelog](https://cloudsmith.com/changelog). Until then, both commands work as before, but they print a deprecation warning to stderr before they prompt for credentials. Once the server disables password login (`410 Gone`, or `404` after the endpoint is deleted), they fail with a message that explains how to get an API key instead. Other errors, such as outages or wrong credentials, are reported as before. To replace them, set `CLOUDSMITH_API_KEY` to an API key from the Cloudsmith web app (your user menu, then **Personal API Keys**), or run `cloudsmith auth --workspace ` to log in with SAML SSO. See [Getting your API key](https://docs.cloudsmith.com/developer-tools/cli#getting-your-api-key). +- `cloudsmith login` and its `cloudsmith token` alias are deprecated, and will stop working when Cloudsmith disables username and password login. The date will be announced in the [Cloudsmith changelog](https://cloudsmith.com/changelog). Until then, both commands work as before, but they print a deprecation warning to stderr before they prompt for credentials. Once the server disables password login (`410 Gone`, or `404` after the endpoint is deleted), they fail with a message that explains how to get an API key instead. Other errors, such as outages or wrong credentials, are reported as before. To replace them, set `CLOUDSMITH_API_KEY` to an API key from the Cloudsmith web app (your user menu, then **Personal API Keys**), or run `cloudsmith auth --workspace ` to log in with SAML. See [Getting your API key](https://docs.cloudsmith.com/developer-tools/cli#getting-your-api-key). ### Changed -- The hint shown for a `401 - Unauthorized` error when no API key or SSO session is set no longer suggests `cloudsmith token`. It now suggests `CLOUDSMITH_API_KEY` or `cloudsmith auth`. +- The hint shown for a `401 - Unauthorized` error when no API key or SAML session is set no longer suggests `cloudsmith token`. It now suggests `CLOUDSMITH_API_KEY` or `cloudsmith auth`. ## [1.29.0] - 2026-10-15 diff --git a/cloudsmith_cli/cli/exceptions.py b/cloudsmith_cli/cli/exceptions.py index 38bd42db..a3519b21 100644 --- a/cloudsmith_cli/cli/exceptions.py +++ b/cloudsmith_cli/cli/exceptions.py @@ -213,8 +213,9 @@ def get_401_error_hint(ctx, opts, exc): if ctx.info_name in ("login", "token"): # This is already the (deprecated) login command return ( - "Could not log in with this email address and password. Check both " - "and run the command again." + "The login failed - Either your email address and/or " + "your password was incorrect. Please check them and " + "try again!" ) return ( diff --git a/cloudsmith_cli/cli/tests/test_exceptions.py b/cloudsmith_cli/cli/tests/test_exceptions.py index 729cc018..da3153b1 100644 --- a/cloudsmith_cli/cli/tests/test_exceptions.py +++ b/cloudsmith_cli/cli/tests/test_exceptions.py @@ -62,7 +62,7 @@ def test_no_credential_suggests_authenticating(self): @pytest.mark.parametrize("info_name", ["login", "token"]) def test_no_credential_on_login_command_reports_a_failed_login(self, info_name): - assert "Could not log in" in hint_for(None, info_name=info_name) + assert "login failed" in hint_for(None, info_name=info_name) def invoke_credentialed_401(runner, config_dir, output_format="pretty"): From 430fc6d1000f476330fce61da08ab25a2140e550 Mon Sep 17 00:00:00 2001 From: Bartosz Blizniak Date: Tue, 20 Oct 2026 09:53:25 +0100 Subject: [PATCH 9/9] docs(ENG-15089): announce the November 20, 2026 login removal date The deprecation warning, `--help`, CHANGELOG and README now say `cloudsmith login` and `cloudsmith token` stop working on November 20, 2026, when Cloudsmith disables username and password login. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- README.md | 2 +- cloudsmith_cli/cli/commands/login.py | 11 +++++------ cloudsmith_cli/cli/tests/commands/test_login.py | 1 + 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e38b9738..8e8f6988 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,7 +17,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ### Deprecated -- `cloudsmith login` and its `cloudsmith token` alias are deprecated, and will stop working when Cloudsmith disables username and password login. The date will be announced in the [Cloudsmith changelog](https://cloudsmith.com/changelog). Until then, both commands work as before, but they print a deprecation warning to stderr before they prompt for credentials. Once the server disables password login (`410 Gone`, or `404` after the endpoint is deleted), they fail with a message that explains how to get an API key instead. Other errors, such as outages or wrong credentials, are reported as before. To replace them, set `CLOUDSMITH_API_KEY` to an API key from the Cloudsmith web app (your user menu, then **Personal API Keys**), or run `cloudsmith auth --workspace ` to log in with SAML. See [Getting your API key](https://docs.cloudsmith.com/developer-tools/cli#getting-your-api-key). +- `cloudsmith login` and its `cloudsmith token` alias are deprecated, and will stop working on November 20, 2026, when Cloudsmith disables username and password login. See the [Cloudsmith changelog](https://cloudsmith.com/changelog). Until then, both commands work as before, but they print a deprecation warning to stderr before they prompt for credentials. Once the server disables password login (`410 Gone`, or `404` after the endpoint is deleted), they fail with a message that explains how to get an API key instead. Other errors, such as outages or wrong credentials, are reported as before. To replace them, set `CLOUDSMITH_API_KEY` to an API key from the Cloudsmith web app (your user menu, then **Personal API Keys**), or run `cloudsmith auth --workspace ` to log in with SAML. See [Getting your API key](https://docs.cloudsmith.com/developer-tools/cli#getting-your-api-key). ### Removed diff --git a/README.md b/README.md index 19cdfa1f..6fd7a2bc 100644 --- a/README.md +++ b/README.md @@ -371,7 +371,7 @@ Give your API key to the CLI in one of these ways: - Put the key into your `credentials.ini` file. - Pass the key to the CLI with the `-k your_key_here` flag. -*Note:* The `cloudsmith login` command and its `cloudsmith token` alias are deprecated. They retrieve your API key with your email address and password, and they will stop working when Cloudsmith disables username and password login. The date will be announced in the [Cloudsmith changelog](https://cloudsmith.com/changelog). After that, both commands fail with a message that explains how to get an API key instead. +*Note:* The `cloudsmith login` command and its `cloudsmith token` alias are deprecated. They retrieve your API key with your email address and password, and they will stop working on November 20, 2026, when Cloudsmith disables username and password login. See the [Cloudsmith changelog](https://cloudsmith.com/changelog). After that, both commands fail with a message that explains how to get an API key instead. ## Uploading Packages diff --git a/cloudsmith_cli/cli/commands/login.py b/cloudsmith_cli/cli/commands/login.py index 97388356..245f47a4 100644 --- a/cloudsmith_cli/cli/commands/login.py +++ b/cloudsmith_cli/cli/commands/login.py @@ -27,9 +27,9 @@ def validate_login(ctx, param, value): ) DEPRECATION_WARNING = ( - "Warning: `cloudsmith {name}` is deprecated and will stop working when " - "Cloudsmith disables username and password login. The date will be announced " - "at https://cloudsmith.com/changelog\n" + "Warning: `cloudsmith {name}` is deprecated and will stop working on " + "November 20, 2026, when Cloudsmith disables username and password login. " + "See https://cloudsmith.com/changelog\n" "Use an API key or SAML instead: set `CLOUDSMITH_API_KEY` to an API key from " "the Cloudsmith web app (your user menu, then Personal API Keys), or run " "`cloudsmith auth --workspace ` to log in with SAML.\n" @@ -89,9 +89,8 @@ def parse_args(self, ctx, args): def login(ctx, opts, login, password): # pylint: disable=redefined-outer-name """[DEPRECATED] Retrieve your API key with your email address and password. - This command will stop working when Cloudsmith disables username and - password login. The date will be announced at - https://cloudsmith.com/changelog + This command will stop working on November 20, 2026, when Cloudsmith + disables username and password login. See https://cloudsmith.com/changelog Set `CLOUDSMITH_API_KEY` to an API key from the Cloudsmith web app instead, or run `cloudsmith auth --workspace ` to log in with SAML. diff --git a/cloudsmith_cli/cli/tests/commands/test_login.py b/cloudsmith_cli/cli/tests/commands/test_login.py index 21e73fad..b117e438 100644 --- a/cloudsmith_cli/cli/tests/commands/test_login.py +++ b/cloudsmith_cli/cli/tests/commands/test_login.py @@ -96,6 +96,7 @@ def test_warns_on_stderr_and_still_returns_the_key(self, runner, tmp_path, name) assert "Use an API key or SAML instead" in result.stderr assert "log in with SAML." in result.stderr assert "sso" not in result.stderr.lower() + assert "will stop working on November 20, 2026" in result.stderr assert "https://cloudsmith.com/changelog" in result.stderr assert f"More: {API_KEY_DOCS_URL}" in result.stderr assert "is deprecated" not in result.stdout