From c93aa14750ddfa23328c2853554d66b5faa1c136 Mon Sep 17 00:00:00 2001 From: Ian Duffy Date: Fri, 31 Jul 2026 23:19:49 +0100 Subject: [PATCH 1/2] feat(ENG-13681): add credential-helper generic command Emits a resolved credential as a versioned JSON document on stdout, so tools that cannot import the CLI can still authenticate through the full provider chain (API key, credentials.ini, system keyring, OIDC). The command takes no arguments because a Cloudsmith token is organisation-wide, so the host where it will be used does not change which credential resolves. The document is serialised in one step and a single broad exception guard protects the protocol boundary, so subprocess consumers never receive a partial document or traceback. Refusals exit non-zero with a human-readable line on stderr and nothing on stdout. The CLI wiring tests resolve from --api-key, so they never read a developer's real credentials.ini or keyring. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 6 + .../commands/credential_helper/__init__.py | 2 + .../cli/commands/credential_helper/generic.py | 62 +++++++++ .../test_credential_helper_generic.py | 118 ++++++++++++++++++ cloudsmith_cli/credential_helpers/generic.py | 63 ++++++++++ 5 files changed, 251 insertions(+) create mode 100644 cloudsmith_cli/cli/commands/credential_helper/generic.py create mode 100644 cloudsmith_cli/cli/tests/commands/test_credential_helper_generic.py create mode 100644 cloudsmith_cli/credential_helpers/generic.py diff --git a/CHANGELOG.md b/CHANGELOG.md index c19a90ce..f7eb550c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ## [Unreleased] +## [1.21.0] - 2026-08-03 + +### Added + +- `cloudsmith credential-helper generic` resolves a credential through the full provider chain (API key, `credentials.ini`, system keyring, OIDC) and emits it as a versioned JSON document — `{"version": 1, "username": "token", "password": ""}` — for tools that shell out to the CLI rather than importing it. It takes no arguments: a Cloudsmith token is organisation-wide, so the host it will be used against does not change which credential resolves. Errors exit non-zero with a message on stderr and never emit a partial document. + ## [1.20.2] - 2026-07-31 ### Fixed diff --git a/cloudsmith_cli/cli/commands/credential_helper/__init__.py b/cloudsmith_cli/cli/commands/credential_helper/__init__.py index 93e5feae..91d12bb9 100644 --- a/cloudsmith_cli/cli/commands/credential_helper/__init__.py +++ b/cloudsmith_cli/cli/commands/credential_helper/__init__.py @@ -10,6 +10,7 @@ from ..main import main from .docker import docker as docker_cmd +from .generic import generic as generic_cmd from .manage import install_cmd, list_cmd, uninstall_cmd @@ -32,6 +33,7 @@ def credential_helper(): credential_helper.add_command(docker_cmd, name="docker") +credential_helper.add_command(generic_cmd, name="generic") credential_helper.add_command(install_cmd, name="install") credential_helper.add_command(uninstall_cmd, name="uninstall") credential_helper.add_command(list_cmd, name="list") diff --git a/cloudsmith_cli/cli/commands/credential_helper/generic.py b/cloudsmith_cli/cli/commands/credential_helper/generic.py new file mode 100644 index 00000000..e5f1356f --- /dev/null +++ b/cloudsmith_cli/cli/commands/credential_helper/generic.py @@ -0,0 +1,62 @@ +# Copyright 2026 Cloudsmith Ltd +""" +Generic credential helper command. + +Emits a versioned JSON credential document. +""" + +import sys + +import click + +from ....credential_helpers.generic import execute +from ...decorators import ( + common_api_auth_options, + common_cli_config_options, + resolve_credentials, +) + + +@click.command() +@common_cli_config_options +@common_api_auth_options +@resolve_credentials +def generic(opts): + """ + Emit a Cloudsmith credential as JSON. + + Resolves a credential through the full provider chain and writes a + versioned JSON document to stdout. Takes no arguments: a Cloudsmith token + is organisation-wide, so the host it will be used against does not change + which credential resolves. + + \b + Output (stdout): + JSON: {"version": 1, "username": "token", "password": ""} + + \b + Exit codes: + 0: Success + 1: No credential could be resolved + + Examples: + + \b + # Resolve a credential + $ cloudsmith credential-helper generic + + \b + # Extract just the token + $ cloudsmith credential-helper generic | jq -r .password + + \b + Environment variables: + CLOUDSMITH_API_KEY: API key for authentication (optional) + """ + exit_code, stdout, stderr = execute(credential=opts.credential) + + if stdout is not None: + click.echo(stdout) + if stderr is not None: + click.echo(stderr, err=True) + sys.exit(exit_code) diff --git a/cloudsmith_cli/cli/tests/commands/test_credential_helper_generic.py b/cloudsmith_cli/cli/tests/commands/test_credential_helper_generic.py new file mode 100644 index 00000000..2fd06a5f --- /dev/null +++ b/cloudsmith_cli/cli/tests/commands/test_credential_helper_generic.py @@ -0,0 +1,118 @@ +# Copyright 2026 Cloudsmith Ltd +"""Tests for the `cloudsmith credential-helper generic` command.""" + +import json +from unittest.mock import patch + +from ....cli.commands.credential_helper.generic import generic +from ....core.credentials.models import CredentialResult +from ....credential_helpers.generic import _REFUSAL_MESSAGE, PROTOCOL_VERSION, execute + +TOKEN = "k_secret_token_value" + +HERMETIC_ARGS = ["--api-key", "fake-api-key"] + + +def test_execute_success_returns_versioned_document(): + """A resolved credential produces the exact version-1 contract.""" + credential = CredentialResult(api_key=TOKEN, source_name="env") + + code, stdout, stderr = execute(credential=credential) + + assert code == 0 + assert stderr is None + assert json.loads(stdout) == { + "version": PROTOCOL_VERSION, + "username": "token", + "password": TOKEN, + } + + +def test_execute_document_has_no_extra_keys(): + """Consumers pin on the contract - no undeclared keys may leak in.""" + credential = CredentialResult(api_key=TOKEN, source_name="env") + + _, stdout, _ = execute(credential=credential) + + assert set(json.loads(stdout)) == {"version", "username", "password"} + + +def test_execute_no_credential_refuses(): + """No credential -> exit 1, message on stderr, nothing on stdout.""" + code, stdout, stderr = execute(credential=None) + + assert code == 1 + assert stdout is None + assert "Unable to retrieve credentials" in stderr + + +def test_execute_empty_api_key_refuses(): + """An empty api_key is not a usable credential.""" + credential = CredentialResult(api_key="", source_name="env") + + code, stdout, stderr = execute(credential=credential) + + assert code == 1 + assert stdout is None + assert stderr == _REFUSAL_MESSAGE + + +def test_execute_degrades_on_unexpected_exception(): + """A raising credential degrades to a clean refusal, never a traceback.""" + + class ExplodingCredential: + """Stands in for any object whose attribute access misbehaves.""" + + @property + def api_key(self): + raise RuntimeError("boom") + + code, stdout, stderr = execute(credential=ExplodingCredential()) + + assert code == 1 + assert stdout is None + assert stderr == _REFUSAL_MESSAGE + + +def test_token_only_ever_appears_on_stdout(): + """The secret must never be written to stderr.""" + credential = CredentialResult(api_key=TOKEN, source_name="env") + + _, stdout, stderr = execute(credential=credential) + + assert TOKEN in stdout + assert stderr is None + + +def test_cli_emits_bare_contract_on_stdout(runner): + """The command echoes execute()'s stdout verbatim, with nothing on stderr.""" + document = json.dumps( + {"version": PROTOCOL_VERSION, "username": "token", "password": TOKEN} + ) + + with patch( + "cloudsmith_cli.cli.commands.credential_helper.generic.execute", + return_value=(0, document, None), + ): + result = runner.invoke(generic, args=HERMETIC_ARGS, catch_exceptions=False) + + assert result.exit_code == 0 + assert json.loads(result.stdout) == { + "version": PROTOCOL_VERSION, + "username": "token", + "password": TOKEN, + } + assert result.stderr == "" + + +def test_cli_refusal_exits_1_with_empty_stdout(runner): + """A refusal must not emit a partial document on stdout.""" + with patch( + "cloudsmith_cli.cli.commands.credential_helper.generic.execute", + return_value=(1, None, _REFUSAL_MESSAGE), + ): + result = runner.invoke(generic, args=HERMETIC_ARGS, catch_exceptions=False) + + assert result.exit_code == 1 + assert result.stdout == "" + assert "Unable to retrieve credentials" in result.stderr diff --git a/cloudsmith_cli/credential_helpers/generic.py b/cloudsmith_cli/credential_helpers/generic.py new file mode 100644 index 00000000..cbb2de05 --- /dev/null +++ b/cloudsmith_cli/credential_helpers/generic.py @@ -0,0 +1,63 @@ +# Copyright 2026 Cloudsmith Ltd +""" +Generic credential helper runtime. + +Emits a versioned JSON credential document. +""" + +import json +import logging + +logger = logging.getLogger(__name__) + +PROTOCOL_VERSION = 1 + +_REFUSAL_MESSAGE = ( + "Error: Unable to retrieve credentials. " + "Provide credentials via the CLOUDSMITH_API_KEY environment variable, " + "credentials.ini, the system keyring, or an OIDC service. " + "Verify current authentication with `cloudsmith whoami --verbose`." +) + + +def build_response(credential): + """ + Build the versioned credential document. + + Args: + credential: Pre-resolved CredentialResult from the provider chain + + Returns: + dict: The credential document, or None when no credential is available + """ + if not credential or not credential.api_key: + return None + + return { + "version": PROTOCOL_VERSION, + "username": "token", + "password": credential.api_key, + } + + +def execute(credential=None) -> tuple[int, str | None, str | None]: + """ + Resolve a credential into a versioned JSON document. + + Args: + credential: Pre-resolved CredentialResult from the provider chain + + Returns: + A (exit_code, stdout_text, stderr_text) tuple. Either text value may + be None if there is nothing to write to that stream. The document is + serialised in one step, so a partial document can never be emitted. + """ + try: + response = build_response(credential) + if response is None: + return (1, None, _REFUSAL_MESSAGE) + + return (0, json.dumps(response), None) + except Exception as exc: # pylint: disable=broad-except + logger.debug("generic credential-helper failed: %s", exc, exc_info=True) + return (1, None, _REFUSAL_MESSAGE) From 1af8b0b33f9fea0f60b27f2c9369afa483d810f8 Mon Sep 17 00:00:00 2001 From: Bartosz Blizniak Date: Mon, 3 Aug 2026 13:12:51 +0100 Subject: [PATCH 2/2] =?UTF-8?q?Bump=20version:=201.20.2=20=E2=86=92=201.21?= =?UTF-8?q?.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .bumpversion.cfg | 2 +- cloudsmith_cli/data/VERSION | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index d519f16a..61607b93 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 1.20.2 +current_version = 1.21.0 commit = True tag = True parse = (?P\d+)\.(?P\d+)\.(?P\d+) diff --git a/cloudsmith_cli/data/VERSION b/cloudsmith_cli/data/VERSION index 769e37e1..3500250a 100644 --- a/cloudsmith_cli/data/VERSION +++ b/cloudsmith_cli/data/VERSION @@ -1 +1 @@ -1.20.2 +1.21.0