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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.

### Added

- `CLOUDSMITH_KEYRING_FILE_PATH` is now accepted as an alias for `KEYRING_PROPERTY_FILE_PATH`, setting the exact file the bundled `keyrings.cryptfile`/`keyrings.alt` file backends store tokens in. If both are set, `KEYRING_PROPERTY_FILE_PATH` takes precedence. `~` and environment variables in the value are expanded.
- `CLOUDSMITH_KEYRING_DIR` points the bundled file-based keyring backends at a directory; each backend keeps its default filename. Previously the only way to relocate these files was `XDG_DATA_HOME` (or the platform equivalent), which moves data for every XDG-aware application. A file path variable (`CLOUDSMITH_KEYRING_FILE_PATH` or `KEYRING_PROPERTY_FILE_PATH`) takes precedence over the directory. `~` and environment variables in the value are expanded.
- 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`.

## [1.24.0] - 2026-08-18
Expand Down
39 changes: 38 additions & 1 deletion cloudsmith_cli/core/keyring.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,40 @@ def _sync_keyring_property_env():
os.environ["KEYRING_PROPERTY_KEYRING_KEY"] = alias_value


def _expand_path(value):
return os.path.expanduser(os.path.expandvars(value))


def _sync_keyring_file_path_env():
"""Allow CLOUDSMITH_KEYRING_FILE_PATH to alias KEYRING_PROPERTY_FILE_PATH."""
alias_value = os.environ.get("CLOUDSMITH_KEYRING_FILE_PATH")
if alias_value and "KEYRING_PROPERTY_FILE_PATH" not in os.environ:
os.environ["KEYRING_PROPERTY_FILE_PATH"] = _expand_path(alias_value)


def _apply_keyring_file_location(backend):
"""Set the storage file location before other properties apply.

The cryptfile keyring_key setter opens the storage file at
assignment time. Set the file location first, so the backend opens
the correct file. A file path env var takes precedence over
CLOUDSMITH_KEYRING_DIR. With the directory, the backend keeps its
default filename. The function ignores backends without a storage
file.
"""
file_path = os.environ.get("KEYRING_PROPERTY_FILE_PATH")
if file_path:
backend.file_path = file_path
return
dir_value = os.environ.get("CLOUDSMITH_KEYRING_DIR")
if not dir_value:
return
filename = getattr(backend, "filename", None)
if not filename:
return
backend.file_path = os.path.join(_expand_path(dir_value), filename)


def _prepare_keyring_backend():
"""Resolve env var aliases and apply them to the keyring backend.

Expand All @@ -49,7 +83,10 @@ def _prepare_keyring_backend():
"""
_sync_keyring_backend_env()
_sync_keyring_property_env()
keyring.get_keyring().set_properties_from_env()
_sync_keyring_file_path_env()
backend = keyring.get_keyring()
_apply_keyring_file_location(backend)
backend.set_properties_from_env()


def _get_value(key):
Expand Down
224 changes: 224 additions & 0 deletions cloudsmith_cli/core/tests/test_keyring.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import pytest
from freezegun import freeze_time
from keyring.errors import KeyringError
from keyrings.cryptfile.cryptfile import CryptFileKeyring

from ..keyring import (
delete_sso_tokens,
Expand Down Expand Up @@ -348,6 +349,229 @@ def test_applies_properties_to_resolved_backend(
mock_get_keyring.return_value.set_properties_from_env.assert_called_once()


class TestKeyringFilePathAlias:
"""Tests for CLOUDSMITH_KEYRING_FILE_PATH aliasing KEYRING_PROPERTY_FILE_PATH."""

api_host = "https://example.com"

def test_sets_keyring_property_when_unset(self, mock_get_user, mock_get_password):
env = os.environ.copy()
env.pop("KEYRING_PROPERTY_FILE_PATH", None)
env["CLOUDSMITH_KEYRING_FILE_PATH"] = "/secure/path/keyring.cfg"
with patch.dict(os.environ, env, clear=True):
get_access_token(self.api_host)
assert (
os.environ["KEYRING_PROPERTY_FILE_PATH"] == "/secure/path/keyring.cfg"
)

def test_does_not_override_existing_keyring_property(
self, mock_get_user, mock_get_password
):
env = os.environ.copy()
env["KEYRING_PROPERTY_FILE_PATH"] = "/native/path/keyring.cfg"
env["CLOUDSMITH_KEYRING_FILE_PATH"] = "/alias/path/keyring.cfg"
with patch.dict(os.environ, env, clear=True):
get_access_token(self.api_host)
assert (
os.environ["KEYRING_PROPERTY_FILE_PATH"] == "/native/path/keyring.cfg"
)

def test_no_op_when_alias_not_set(self, mock_get_user, mock_get_password):
env = os.environ.copy()
env.pop("KEYRING_PROPERTY_FILE_PATH", None)
env.pop("CLOUDSMITH_KEYRING_FILE_PATH", None)
with patch.dict(os.environ, env, clear=True):
get_access_token(self.api_host)
assert "KEYRING_PROPERTY_FILE_PATH" not in os.environ

def test_expands_user_and_env_vars(self, mock_get_user, mock_get_password):
env = os.environ.copy()
env.pop("KEYRING_PROPERTY_FILE_PATH", None)
env["KEYRING_STORE_ROOT"] = "/secure"
env["CLOUDSMITH_KEYRING_FILE_PATH"] = "$KEYRING_STORE_ROOT/keyring.cfg"
with patch.dict(os.environ, env, clear=True):
get_access_token(self.api_host)
assert os.environ["KEYRING_PROPERTY_FILE_PATH"] == "/secure/keyring.cfg"

def test_expands_home_directory(self, mock_get_user, mock_get_password):
env = os.environ.copy()
env.pop("KEYRING_PROPERTY_FILE_PATH", None)
env["CLOUDSMITH_KEYRING_FILE_PATH"] = "~/keyring.cfg"
with patch.dict(os.environ, env, clear=True):
get_access_token(self.api_host)
assert os.environ["KEYRING_PROPERTY_FILE_PATH"] == os.path.expanduser(
"~/keyring.cfg"
)

def test_expands_home_inside_env_var_value(self, mock_get_user, mock_get_password):
env = os.environ.copy()
env.pop("KEYRING_PROPERTY_FILE_PATH", None)
env["KEYRING_STORE_ROOT"] = "~"
env["CLOUDSMITH_KEYRING_FILE_PATH"] = "$KEYRING_STORE_ROOT/keyring.cfg"
with patch.dict(os.environ, env, clear=True):
get_access_token(self.api_host)
assert os.environ["KEYRING_PROPERTY_FILE_PATH"] == os.path.expanduser(
"~/keyring.cfg"
)


class TestKeyringDirEnv:
"""Tests for CLOUDSMITH_KEYRING_DIR redirecting file-backed backends."""

api_host = "https://example.com"

def test_sets_backend_file_path(
self, mock_get_user, mock_get_password, mock_get_keyring
):
backend = mock_get_keyring.return_value
backend.filename = "cryptfile_pass.cfg"
env = os.environ.copy()
env.pop("KEYRING_PROPERTY_FILE_PATH", None)
env["CLOUDSMITH_KEYRING_DIR"] = "/secure/dir"
with patch.dict(os.environ, env, clear=True):
get_access_token(self.api_host)
assert backend.file_path == os.path.join(
"/secure/dir", "cryptfile_pass.cfg"
)

def test_native_file_path_wins_over_dir(
self, mock_get_user, mock_get_password, mock_get_keyring
):
backend = mock_get_keyring.return_value
backend.filename = "cryptfile_pass.cfg"
env = os.environ.copy()
env["KEYRING_PROPERTY_FILE_PATH"] = "/native/path/keyring.cfg"
env["CLOUDSMITH_KEYRING_DIR"] = "/secure/dir"
with patch.dict(os.environ, env, clear=True):
get_access_token(self.api_host)
assert backend.file_path == "/native/path/keyring.cfg"

def test_alias_file_path_wins_over_dir(
self, mock_get_user, mock_get_password, mock_get_keyring
):
backend = mock_get_keyring.return_value
backend.filename = "cryptfile_pass.cfg"
env = os.environ.copy()
env.pop("KEYRING_PROPERTY_FILE_PATH", None)
env["CLOUDSMITH_KEYRING_FILE_PATH"] = "/alias/path/keyring.cfg"
env["CLOUDSMITH_KEYRING_DIR"] = "/secure/dir"
with patch.dict(os.environ, env, clear=True):
get_access_token(self.api_host)
assert backend.file_path == "/alias/path/keyring.cfg"

def test_no_op_for_backends_without_filename(
self, mock_get_user, mock_get_password, mock_get_keyring
):
backend = mock_get_keyring.return_value
del backend.filename
Comment thread
cloudsmith-iduffy marked this conversation as resolved.
backend.file_path = "untouched"
env = os.environ.copy()
env.pop("KEYRING_PROPERTY_FILE_PATH", None)
env["CLOUDSMITH_KEYRING_DIR"] = "/secure/dir"
with patch.dict(os.environ, env, clear=True):
get_access_token(self.api_host)
assert backend.file_path == "untouched"

def test_no_op_when_dir_not_set(
self, mock_get_user, mock_get_password, mock_get_keyring
):
backend = mock_get_keyring.return_value
backend.filename = "cryptfile_pass.cfg"
backend.file_path = "untouched"
env = os.environ.copy()
env.pop("KEYRING_PROPERTY_FILE_PATH", None)
env.pop("CLOUDSMITH_KEYRING_DIR", None)
with patch.dict(os.environ, env, clear=True):
get_access_token(self.api_host)
assert backend.file_path == "untouched"

def test_expands_user_and_env_vars(
self, mock_get_user, mock_get_password, mock_get_keyring
):
backend = mock_get_keyring.return_value
backend.filename = "keyring_pass.cfg"
env = os.environ.copy()
env.pop("KEYRING_PROPERTY_FILE_PATH", None)
env["KEYRING_STORE_ROOT"] = "/secure"
env["CLOUDSMITH_KEYRING_DIR"] = "$KEYRING_STORE_ROOT/store"
with patch.dict(os.environ, env, clear=True):
get_access_token(self.api_host)
assert backend.file_path == os.path.join(
"/secure/store", "keyring_pass.cfg"
)

def test_expands_home_directory(
self, mock_get_user, mock_get_password, mock_get_keyring
):
backend = mock_get_keyring.return_value
backend.filename = "keyring_pass.cfg"
env = os.environ.copy()
env.pop("KEYRING_PROPERTY_FILE_PATH", None)
env["CLOUDSMITH_KEYRING_DIR"] = "~/store"
with patch.dict(os.environ, env, clear=True):
get_access_token(self.api_host)
assert backend.file_path == os.path.join(
os.path.expanduser("~/store"), "keyring_pass.cfg"
)


class TestKeyringFileRelocation:
"""Tests for file relocation with a real cryptfile backend."""

api_host = "https://example.com"

@staticmethod
def _relocation_env(tmp_path):
env = os.environ.copy()
for var in (
"KEYRING_PROPERTY_FILE_PATH",
"KEYRING_PROPERTY_KEYRING_KEY",
"CLOUDSMITH_KEYRING_FILE_PATH",
"CLOUDSMITH_KEYRING_DIR",
"CLOUDSMITH_KEYRING_KEY",
):
env.pop(var, None)
env["XDG_DATA_HOME"] = str(tmp_path / "default")
env["CLOUDSMITH_KEYRING_KEY"] = "test-password"
return env

def _roundtrip_token(self, backend, env):
with (
patch.dict(os.environ, env, clear=True),
patch.object(keyring, "set_password", side_effect=backend.set_password),
patch.object(keyring, "get_password", side_effect=backend.get_password),
):
store_access_token(self.api_host, "token-value")
assert get_access_token(self.api_host) == "token-value"

def test_dir_relocates_storage_before_unlock(
self, tmp_path, mock_get_user, mock_get_keyring
):
backend = CryptFileKeyring()
mock_get_keyring.return_value = backend
env = self._relocation_env(tmp_path)
env["CLOUDSMITH_KEYRING_DIR"] = str(tmp_path / "secure")

self._roundtrip_token(backend, env)

assert (tmp_path / "secure" / backend.filename).is_file()
assert not (tmp_path / "default").exists()

def test_file_path_alias_relocates_storage_before_unlock(
self, tmp_path, mock_get_user, mock_get_keyring
):
backend = CryptFileKeyring()
mock_get_keyring.return_value = backend
target = tmp_path / "secure" / "tokens.cfg"
env = self._relocation_env(tmp_path)
env["CLOUDSMITH_KEYRING_FILE_PATH"] = str(target)

self._roundtrip_token(backend, env)

assert target.is_file()
assert not (tmp_path / "default").exists()


class TestDeleteSsoTokens:
"""Tests for the delete_sso_tokens and has_sso_tokens functions."""

Expand Down
Loading