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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](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. 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

### Added
Expand Down
9 changes: 6 additions & 3 deletions cloudsmith_cli/cli/commands/logout.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)

Expand Down
2 changes: 1 addition & 1 deletion cloudsmith_cli/cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
4 changes: 3 additions & 1 deletion cloudsmith_cli/cli/decorators.py
Original file line number Diff line number Diff line change
Expand Up @@ -312,7 +312,9 @@ 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",
)
@click.option(
"--api-proxy",
Expand Down
8 changes: 8 additions & 0 deletions cloudsmith_cli/cli/tests/commands/test_logout.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
72 changes: 72 additions & 0 deletions cloudsmith_cli/cli/tests/test_api_host_normalization.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# 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

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):
_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, (), ())
23 changes: 23 additions & 0 deletions cloudsmith_cli/cli/validators.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""CLI - Validators."""

import base64
import re
from datetime import datetime, timezone
from urllib.parse import urlsplit

Expand All @@ -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):
Expand Down Expand Up @@ -86,6 +89,26 @@ 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. 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 None

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.

Expand Down
Loading