From 4bdcc9ac1cf77729a28bc67c11fb6b6373d863a2 Mon Sep 17 00:00:00 2001 From: Ian Duffy Date: Fri, 21 Aug 2026 21:09:56 +0100 Subject: [PATCH 1/5] feat(no-ticket): normalize the API host A loosely-written API host used to fail with an error that hid the cause. A value with no scheme aborted before the request. A trailing slash returned a 404 with a hint that blamed the user or org, when only the punctuation was wrong. Normalize the API host so these values work. The CLI adds the https scheme when the value gives none. It also removes surrounding whitespace and trailing slashes. `api.cloudsmith.io`, `//api.cloudsmith.io/` and ` https://api.cloudsmith.io/ ` all resolve to `https://api.cloudsmith.io`. This applies to `--api-host`, the `CLOUDSMITH_API_HOST` variable and the `api_host` config key. --- CHANGELOG.md | 4 ++ cloudsmith_cli/cli/commands/logout.py | 9 ++- cloudsmith_cli/cli/config.py | 2 +- cloudsmith_cli/cli/decorators.py | 5 +- .../cli/tests/commands/test_logout.py | 8 +++ .../cli/tests/test_api_host_normalization.py | 66 +++++++++++++++++++ cloudsmith_cli/cli/validators.py | 22 +++++++ 7 files changed, 111 insertions(+), 5 deletions(-) create mode 100644 cloudsmith_cli/cli/tests/test_api_host_normalization.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b8299bd..2bfa65de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - Added an PNPM credential helper for Cloudsmith registries. `cloudsmith credential-helper install pnpm` installs an `pnpm-credential-cloudsmith` launcher binary and registers it in `~/.npmrc`, so npm authenticates to Cloudsmith registries automatically using your existing CLI credentials — no manual `npm login` required. Custom Cloudsmith registry domains are discovered via the API and cached locally; add extra hostnames with `--domain` (repeatable), disable discovery with `--no-discover`, or preview changes with `--dry-run`. Manage installed helpers with `cloudsmith credential-helper uninstall pnpm` and `cloudsmith credential-helper list`. +### Changed + +- The API host is now normalised, so a loosely-written value works. The CLI removes surrounding whitespace and trailing slashes, and adds the `https` scheme when the value gives none. `api.cloudsmith.io`, `//api.cloudsmith.io/` and ` https://api.cloudsmith.io/ ` all resolve to `https://api.cloudsmith.io`. This applies to `--api-host`, `CLOUDSMITH_API_HOST` and the `api_host` config key, and it keeps the keyring token key stable between `cloudsmith auth` and `cloudsmith logout`. The allow-list check on `api_host` values from a directory-relative config file runs against the normalised value, so a scheme-less Cloudsmith host is no longer rejected. + ## [1.24.0] - 2026-08-18 ### Added diff --git a/cloudsmith_cli/cli/commands/logout.py b/cloudsmith_cli/cli/commands/logout.py index 53e9d184..9ac2199d 100644 --- a/cloudsmith_cli/cli/commands/logout.py +++ b/cloudsmith_cli/cli/commands/logout.py @@ -7,7 +7,7 @@ import cloudsmith_api from ...core import keyring -from .. import decorators, utils +from .. import decorators, utils, validators from ..config import CredentialsReader from .main import main @@ -117,8 +117,11 @@ def logout(ctx, opts, api_host, keyring_only, config_only, dry_run): "--keyring-only and --config-only are mutually exclusive." ) - if api_host is None: - api_host = opts.api_host or cloudsmith_api.Configuration().host + api_host = ( + validators.normalize_api_host(api_host) + or opts.api_host + or cloudsmith_api.Configuration().host + ) use_stderr = utils.should_use_stderr(opts) diff --git a/cloudsmith_cli/cli/config.py b/cloudsmith_cli/cli/config.py index e623e421..e8a94938 100644 --- a/cloudsmith_cli/cli/config.py +++ b/cloudsmith_cli/cli/config.py @@ -341,7 +341,7 @@ def api_host(self): @api_host.setter def api_host(self, value): """Set value for API host.""" - self._set_option("api_host", value) + self._set_option("api_host", validators.normalize_api_host(value)) @property def api_key(self): diff --git a/cloudsmith_cli/cli/decorators.py b/cloudsmith_cli/cli/decorators.py index d4d9e60e..d2ffbe23 100644 --- a/cloudsmith_cli/cli/decorators.py +++ b/cloudsmith_cli/cli/decorators.py @@ -312,7 +312,10 @@ def initialise_session(f): """Create a shared HTTP session with proxy/SSL/user-agent settings.""" @click.option( - "--api-host", envvar="CLOUDSMITH_API_HOST", help="The API host to connect to." + "--api-host", + envvar="CLOUDSMITH_API_HOST", + help="The API host to connect to. If you do not give a scheme, " + "the CLI uses https.", ) @click.option( "--api-proxy", diff --git a/cloudsmith_cli/cli/tests/commands/test_logout.py b/cloudsmith_cli/cli/tests/commands/test_logout.py index 87d2be45..7062248e 100644 --- a/cloudsmith_cli/cli/tests/commands/test_logout.py +++ b/cloudsmith_cli/cli/tests/commands/test_logout.py @@ -53,6 +53,14 @@ def test_full_logout(self, runner, mock_deps): assert "Removed credentials from:" in result.output assert "Removed SSO tokens from system keyring" in result.output + def test_misconfigured_api_host_is_normalized(self, runner, mock_deps): + _, mock_keyring = mock_deps + + result = runner.invoke(logout, ["--api-host", " api.example.com/ "]) + + assert result.exit_code == 0 + mock_keyring.delete_sso_tokens.assert_called_once_with(HOST) + def test_dry_run(self, runner, mock_deps): mock_creds, mock_keyring = mock_deps diff --git a/cloudsmith_cli/cli/tests/test_api_host_normalization.py b/cloudsmith_cli/cli/tests/test_api_host_normalization.py new file mode 100644 index 00000000..11885792 --- /dev/null +++ b/cloudsmith_cli/cli/tests/test_api_host_normalization.py @@ -0,0 +1,66 @@ +# Copyright 2026 Cloudsmith Ltd +"""Tests for api_host normalization.""" + +import click +import pytest + +from ..config import Options +from ..decorators import _guard_untrusted_endpoints +from ..validators import normalize_api_host +from .test_api_host_validation import _FakeContext, _write_cwd_config + + +class TestNormalizeApiHost: + @pytest.mark.parametrize( + ("value", "expected"), + [ + ("api.cloudsmith.io", "https://api.cloudsmith.io"), + ("api.cloudsmith.io/", "https://api.cloudsmith.io"), + ("api.cloudsmith.io:8080", "https://api.cloudsmith.io:8080"), + ("//api.cloudsmith.io", "https://api.cloudsmith.io"), + ("https://api.cloudsmith.io/", "https://api.cloudsmith.io"), + ("https://api.cloudsmith.io///", "https://api.cloudsmith.io"), + ("https://api.cloudsmith.io/v1/", "https://api.cloudsmith.io/v1"), + (" https://api.cloudsmith.io ", "https://api.cloudsmith.io"), + ("\thttps://api.cloudsmith.io\n", "https://api.cloudsmith.io"), + ("http://localhost:8000/", "http://localhost:8000"), + ("https://api.cloudsmith.io", "https://api.cloudsmith.io"), + ], + ) + def test_host_is_normalized(self, value, expected): + assert normalize_api_host(value) == expected + + @pytest.mark.parametrize("value", [None, "", " "]) + def test_empty_values_pass_through(self, value): + assert not normalize_api_host(value) + + +class TestOptionsApiHost: + def test_setter_normalizes(self): + opts = Options() + opts.api_host = " api.cloudsmith.io/ " + assert opts.api_host == "https://api.cloudsmith.io" + + def test_setter_accepts_none(self): + opts = Options() + opts.api_host = None + assert opts.api_host is None + + +class TestGuardWithNormalizedHost: + def test_untrusted_host_without_scheme_still_raises(self, tmp_path, monkeypatch): + _write_cwd_config( + tmp_path, monkeypatch, "[default]\napi_host = evil.example.com\n" + ) + opts = Options() + opts.api_host = "evil.example.com" + with pytest.raises(click.UsageError): + _guard_untrusted_endpoints(_FakeContext({}), opts, (), ()) + + def test_trusted_host_without_scheme_passes(self, tmp_path, monkeypatch): + _write_cwd_config( + tmp_path, monkeypatch, "[default]\napi_host = api.cloudsmith.io/\n" + ) + opts = Options() + opts.api_host = "api.cloudsmith.io/" + _guard_untrusted_endpoints(_FakeContext({}), opts, (), ()) diff --git a/cloudsmith_cli/cli/validators.py b/cloudsmith_cli/cli/validators.py index bb28fdf5..ccf6ceae 100644 --- a/cloudsmith_cli/cli/validators.py +++ b/cloudsmith_cli/cli/validators.py @@ -1,6 +1,7 @@ """CLI - Validators.""" import base64 +import re from datetime import datetime, timezone from urllib.parse import urlsplit @@ -13,6 +14,8 @@ BAD_API_HEADERS = ("user-agent", "host") API_HEADER_TRANSFORMS = {} PUBLIC_API_HOST_SUFFIXES = ("cloudsmith.io", "cloudsmith.com") +DEFAULT_API_HOST_SCHEME = "https" +API_HOST_SCHEME_RE = re.compile(r"^[a-zA-Z][a-zA-Z0-9+.\-]*://") class IntOrWildcard(click.ParamType): @@ -86,6 +89,25 @@ def validate_api_headers(param, value): return headers +def normalize_api_host(value): + """Normalise a user-supplied API host into a canonical URL. + + Removes surrounding whitespace and trailing slashes. Adds the https + scheme when the value does not give one. + """ + if not isinstance(value, str): + return value + + host = value.strip() + if not host: + return host + + if not API_HOST_SCHEME_RE.match(host): + host = f"{DEFAULT_API_HOST_SCHEME}://{host.lstrip('/')}" + + return host.rstrip("/") + + def host_matches_suffixes(url, suffixes): """True if url's hostname equals or is a subdomain of one of the suffixes. From 9d844a08e996ed254540023d68cb543f1d7333ab Mon Sep 17 00:00:00 2001 From: Ian Duffy Date: Fri, 21 Aug 2026 21:23:08 +0100 Subject: [PATCH 2/5] fix(no-ticket): keep the current API host when a value is blank A blank host replaced a host that was already set. A CI variable set to whitespace therefore discarded the `api_host` from `config.ini`, and the CLI fell back to the public API host without a word. A user on a dedicated instance sent their API key to the wrong host. Return None for a blank value, so the existing host survives. --- CHANGELOG.md | 2 +- cloudsmith_cli/cli/commands/logout.py | 3 ++- cloudsmith_cli/cli/tests/test_api_host_normalization.py | 6 ++++++ cloudsmith_cli/cli/validators.py | 5 +++-- 4 files changed, 12 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2bfa65de..35605dae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ### Changed -- The API host is now normalised, so a loosely-written value works. The CLI removes surrounding whitespace and trailing slashes, and adds the `https` scheme when the value gives none. `api.cloudsmith.io`, `//api.cloudsmith.io/` and ` https://api.cloudsmith.io/ ` all resolve to `https://api.cloudsmith.io`. This applies to `--api-host`, `CLOUDSMITH_API_HOST` and the `api_host` config key, and it keeps the keyring token key stable between `cloudsmith auth` and `cloudsmith logout`. The allow-list check on `api_host` values from a directory-relative config file runs against the normalised value, so a scheme-less Cloudsmith host is no longer rejected. +- The API host is now normalised, so a loosely-written value works. The CLI removes surrounding whitespace and trailing slashes, and adds the `https` scheme when the value gives none. `api.cloudsmith.io`, `//api.cloudsmith.io/` and ` https://api.cloudsmith.io/ ` all resolve to `https://api.cloudsmith.io`. This applies to `--api-host`, `CLOUDSMITH_API_HOST` and the `api_host` config key, and it keeps the keyring token key stable between `cloudsmith auth` and `cloudsmith logout`. The allow-list check on `api_host` values from a directory-relative config file runs against the normalised value, so a scheme-less Cloudsmith host is no longer rejected. If your `api_host` had a trailing slash, the keyring key changes and you must run `cloudsmith auth` again. ## [1.24.0] - 2026-08-18 diff --git a/cloudsmith_cli/cli/commands/logout.py b/cloudsmith_cli/cli/commands/logout.py index 9ac2199d..072d4d34 100644 --- a/cloudsmith_cli/cli/commands/logout.py +++ b/cloudsmith_cli/cli/commands/logout.py @@ -87,7 +87,8 @@ def _collect_warnings(keyring_only, config_only): "--api-host", envvar="CLOUDSMITH_API_HOST", default=None, - help="The API host to clear keyring tokens for.", + help="The API host to clear keyring tokens for. If you do not give a " + "scheme, the CLI uses https.", ) @click.option( "--keyring-only", diff --git a/cloudsmith_cli/cli/tests/test_api_host_normalization.py b/cloudsmith_cli/cli/tests/test_api_host_normalization.py index 11885792..4f223b3b 100644 --- a/cloudsmith_cli/cli/tests/test_api_host_normalization.py +++ b/cloudsmith_cli/cli/tests/test_api_host_normalization.py @@ -46,6 +46,12 @@ def test_setter_accepts_none(self): opts.api_host = None assert opts.api_host is None + def test_blank_value_keeps_the_current_host(self): + opts = Options() + opts.api_host = "https://api.internal.example" + opts.api_host = " " + assert opts.api_host == "https://api.internal.example" + class TestGuardWithNormalizedHost: def test_untrusted_host_without_scheme_still_raises(self, tmp_path, monkeypatch): diff --git a/cloudsmith_cli/cli/validators.py b/cloudsmith_cli/cli/validators.py index ccf6ceae..7ad34733 100644 --- a/cloudsmith_cli/cli/validators.py +++ b/cloudsmith_cli/cli/validators.py @@ -93,14 +93,15 @@ def normalize_api_host(value): """Normalise a user-supplied API host into a canonical URL. Removes surrounding whitespace and trailing slashes. Adds the https - scheme when the value does not give one. + scheme when the value does not give one. Returns None for a blank value, + so a blank value does not replace a host that is already set. """ if not isinstance(value, str): return value host = value.strip() if not host: - return host + return None if not API_HOST_SCHEME_RE.match(host): host = f"{DEFAULT_API_HOST_SCHEME}://{host.lstrip('/')}" From 2f3c4feaab3f69b8e734693afea7173826caed40 Mon Sep 17 00:00:00 2001 From: Ian Duffy Date: Fri, 21 Aug 2026 22:15:33 +0100 Subject: [PATCH 3/5] Update help text for --api-host option Simplified help text for the --api-host option. --- cloudsmith_cli/cli/commands/logout.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/cloudsmith_cli/cli/commands/logout.py b/cloudsmith_cli/cli/commands/logout.py index 072d4d34..2e2f64cc 100644 --- a/cloudsmith_cli/cli/commands/logout.py +++ b/cloudsmith_cli/cli/commands/logout.py @@ -87,8 +87,7 @@ def _collect_warnings(keyring_only, config_only): "--api-host", envvar="CLOUDSMITH_API_HOST", default=None, - help="The API host to clear keyring tokens for. If you do not give a " - "scheme, the CLI uses https.", + help="The API host to clear keyring tokens for." ) @click.option( "--keyring-only", From 97b8485299df85e917cdbb01e3ef3b690831ca90 Mon Sep 17 00:00:00 2001 From: Ian Duffy Date: Fri, 21 Aug 2026 22:15:55 +0100 Subject: [PATCH 4/5] Update logout.py --- cloudsmith_cli/cli/commands/logout.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cloudsmith_cli/cli/commands/logout.py b/cloudsmith_cli/cli/commands/logout.py index 2e2f64cc..9ac2199d 100644 --- a/cloudsmith_cli/cli/commands/logout.py +++ b/cloudsmith_cli/cli/commands/logout.py @@ -87,7 +87,7 @@ def _collect_warnings(keyring_only, config_only): "--api-host", envvar="CLOUDSMITH_API_HOST", default=None, - help="The API host to clear keyring tokens for." + help="The API host to clear keyring tokens for.", ) @click.option( "--keyring-only", From 13db57e76f922cd50435b930e23514b4f6e683e0 Mon Sep 17 00:00:00 2001 From: Ian Duffy Date: Fri, 21 Aug 2026 22:17:37 +0100 Subject: [PATCH 5/5] Refine help text for --api-host option Updated help text for --api-host option to be more concise. --- cloudsmith_cli/cli/decorators.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/cloudsmith_cli/cli/decorators.py b/cloudsmith_cli/cli/decorators.py index d2ffbe23..2bec978d 100644 --- a/cloudsmith_cli/cli/decorators.py +++ b/cloudsmith_cli/cli/decorators.py @@ -314,8 +314,7 @@ def initialise_session(f): @click.option( "--api-host", envvar="CLOUDSMITH_API_HOST", - help="The API host to connect to. If you do not give a scheme, " - "the CLI uses https.", + help="The API host to connect to", ) @click.option( "--api-proxy",