Skip to content
Draft
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 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": "<token>"}` — 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.

- `cloudsmith credential-helper domains` emits a versioned JSON document — `{"version": 1, "domains": [{"host": ..., "format": ..., "enabled": ..., "validated": ..., "type": ..., "domain_type": ..., "org": ..., "repository": ...}]}` — listing the hosts Cloudsmith can authenticate: the built-in service domains plus any custom domains configured for an organisation, along with the package format each one serves (`format` is `null` for hosts that serve no single format, like the download CDN). Each entry's `domain_type` says what the host is for — `download` for the CDN, `upload` for the generic upload endpoint, or `native_api` for a host that speaks a single package format's own protocol — so a consumer can pick the right host without pattern-matching on hostnames. `org` and `repository` say which organisation a custom domain belongs to and, when it is scoped to one, which repository — built-in entries carry `null` for both. Disabled and unvalidated custom domains are included too, with their state intact, so a misconfigured domain is visible rather than silently missing. A failed custom-domain lookup (typo'd organisation, missing permission, unreachable API) exits non-zero with a message on stderr instead of silently listing built-in hosts only. The command takes no arguments: the organisation is resolved from `CLOUDSMITH_ORG` or `oidc_org` in `config.ini`. With an organisation configured, its custom domains are looked up (and a failed lookup still exits non-zero); with none configured, the command instead lists whatever custom domains an earlier run already cached, at no API cost, rather than querying every organisation the caller belongs to. Custom-domain lookups are cached on disk for seven days, since custom domains change rarely; pass `--refresh` to bypass the cache and fetch fresh data, for example right after adding or validating a domain. Like `credential-helper generic`, this command is for programmatic consumers only and has no human-readable output mode.
- The built-in domain list can be replaced via a `[domains]` section in `config.ini`, mapping hostname to package format, for dedicated and on-premise deployments. For safety this is honoured only from trusted configuration — your user-level config directory, `~/.cloudsmith`, or an explicit `--config-file`. A `[domains]` section in a directory-relative `config.ini` is ignored with a warning, because that file can be committed to a repository and this list decides which hosts may receive a credential.

Expand Down
2 changes: 2 additions & 0 deletions cloudsmith_cli/cli/commands/credential_helper/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from ..main import main
from .docker import docker as docker_cmd
from .domains import domains_cmd
from .generic import generic as generic_cmd
from .manage import install_cmd, list_cmd, uninstall_cmd


Expand All @@ -34,6 +35,7 @@ def credential_helper():

credential_helper.add_command(docker_cmd, name="docker")
credential_helper.add_command(domains_cmd, name="domains")
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")
Expand Down
63 changes: 63 additions & 0 deletions cloudsmith_cli/cli/commands/credential_helper/generic.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# 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. Use ``cloudsmith credential-helper domains`` to
discover which hosts Cloudsmith can authenticate.

\b
Output (stdout):
JSON: {"version": 1, "username": "token", "password": "<token>"}

\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)
118 changes: 118 additions & 0 deletions cloudsmith_cli/cli/tests/commands/test_credential_helper_generic.py
Original file line number Diff line number Diff line change
@@ -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
63 changes: 63 additions & 0 deletions cloudsmith_cli/credential_helpers/generic.py
Original file line number Diff line number Diff line change
@@ -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)