Skip to content
Merged
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 @@ -28,6 +28,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
- `--api-headers` with a blocked header, such as `user-agent=x`, or with a value that has no `=`, shows a usage error. Before, the CLI showed a traceback.
- `cloudsmith list dependencies --help` now shows the help for the dependencies list. Before, it showed the help for the entitlements list.
- `cloudsmith credential-helper install terraform` now escapes the `terraformrc` args. Previously, a Windows path such as `C:\Users\jo\config.ini` in `--config-file` or `--credentials-file` made Terraform reject the full CLI config file. A `"` in an arg also made Terraform reject the file. A `${` in an arg could change the value that Terraform read. A `}` in an arg made the next install write an incorrect file. To fix a file that an earlier version wrote, run the install command again.
- `cloudsmith logout` no longer damages `credentials.ini` when a line follows an empty `api_key`. Previously, the CLI removed the next line that is not blank, for example on the second `logout`. When that line was a section header, such as `[profile:NAME]`, and that section also had a key, each later command failed with `DuplicateOptionError`. `cloudsmith logout` now also clears an `api_key` that uses `:` instead of `=`. The same fix applies when `cloudsmith login`, `cloudsmith tokens create --save-config`, `cloudsmith tokens refresh --save-config`, or `cloudsmith auth --request-api-key --save-config` write the API key.
- When `credentials.ini` has no `api_key` in the `[default]` section, `cloudsmith login` and the `--save-config` options now add the API key to that section. If the section does not exist, the CLI creates it. Previously, the CLI showed `UPDATED` but did not save the key.
- `cloudsmith credential-helper list` now shows the pnpm hosts in `.npmrc` when the pnpm launcher is missing. Previously, the command showed no hosts, and pnpm still tried to run the missing launcher for those hosts.
- `list` also shows the pnpm hosts that you installed with `--bin-dir`. `cloudsmith credential-helper uninstall pnpm` now removes these `.npmrc` entries, as the Docker and Cargo uninstall commands do.
- `cloudsmith credential-helper list` now shows a helper as not configured when its config file is not valid, and it still lists the other helpers. This applies to a Docker `config.json` with a `credHelpers` value that is not an object, and to a Docker or Terraform config file that is not UTF-8. It also applies to a pnpm `.npmrc` that the CLI cannot read or decode. Previously, the command stopped with an error such as `AttributeError`.
Expand Down
17 changes: 12 additions & 5 deletions cloudsmith_cli/cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -363,15 +363,22 @@ def find_existing_files(cls):
@classmethod
def _set_api_key(cls, path: str | os.PathLike[str], api_key: str = "") -> None:
"""Write api_key value in a credentials file, preserving structure."""
# Text mode reads CRLF as LF, so no "\r" reaches the pattern.
with open(path) as f:
content = f.read()
replacement = rf"\1 = {api_key}" if api_key else r"\1 ="
line = f"api_key = {api_key}" if api_key else "api_key ="
# "[ \t]" and not "\s": "\s" also matches a newline, so an empty
# key would consume the next lines and the next section header.
content = re.sub(
r"^(api_key)\s*=\s*.*$",
replacement,
r"^api_key[ \t]*[=:].*$",
lambda _: line,
content,
flags=re.MULTILINE,
)
if api_key:
# The CLI reads [default] when no profile is selected, so a new
# key must be saved there even when the file has no api_key line.
content = _set_section_value(content, "default", "api_key", api_key)
# "r+" keeps a read-only file unchanged. The mode changes through the open
# file, so it applies to the file that gets the key.
with open(path, "r+") as f:
Expand All @@ -388,12 +395,12 @@ def _set_api_key(cls, path: str | os.PathLike[str], api_key: str = "") -> None:
f.truncate()

@classmethod
def clear_api_key(cls, path):
def clear_api_key(cls, path: str | os.PathLike[str]) -> None:
"""Clear api_key values in a credentials file, preserving structure."""
cls._set_api_key(path)

@classmethod
def update_api_key(cls, path, api_key):
def update_api_key(cls, path: str | os.PathLike[str], api_key: str) -> None:
"""Update api_key value in an existing credentials file, preserving structure."""
cls._set_api_key(path, api_key)

Expand Down
50 changes: 50 additions & 0 deletions cloudsmith_cli/cli/tests/test_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# Copyright 2026 Cloudsmith Ltd
"""Tests for the credentials file edits in cli/config.py."""

import os

import pytest

from ..config import CredentialsReader


def _expected_bytes(text):
return text.replace("\n", os.linesep).encode()


@pytest.mark.parametrize(
"newline", ["\n", pytest.param("\r\n", marks=pytest.mark.windows)]
)
def test_clear_api_key_twice_keeps_profile_sections(tmp_path, newline):
path = tmp_path / "credentials.ini"
content = (
"[default]\napi_key = defaultkey\n\n[profile:staging]\napi_key: stagingkey\n"
)
path.write_bytes(content.replace("\n", newline).encode())

CredentialsReader.clear_api_key(path)
CredentialsReader.clear_api_key(path)

assert path.read_bytes() == _expected_bytes(
"[default]\napi_key =\n\n[profile:staging]\napi_key =\n"
)


def test_update_api_key_after_empty_key_keeps_profile_sections(tmp_path):
path = tmp_path / "credentials.ini"
path.write_text("[default]\napi_key =\n\n[profile:staging]\napi_key = stagingkey\n")

CredentialsReader.update_api_key(path, "newkey")

assert path.read_bytes() == _expected_bytes(
"[default]\napi_key = newkey\n\n[profile:staging]\napi_key = newkey\n"
)


def test_update_api_key_adds_missing_key_to_default_section(tmp_path):
path = tmp_path / "credentials.ini"
path.write_text("[default]\n")

CredentialsReader.update_api_key(path, "newkey")

assert path.read_bytes() == _expected_bytes("[default]\napi_key = newkey\n")
Loading