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

## [Unreleased]

### Added

- `cloudsmith credential-helper install maven --org <org> --repo <repo>` sets up transparent Maven authentication. Maven has no credential-helper protocol, so the CLI installs an `mvn` shim that wraps every invocation in `cloudsmith exec`, activated by putting the shims directory first on `PATH`. Wrapped runs resolve dependencies from the bound repository through a mode-0600 `settings.xml` injected via `mvn -s` and deleted when the run ends, never written to `~/.m2` — and `~/.m2/settings.xml` is not consulted, so mirrors and proxies declared there do not apply. Passing your own `-s/--settings` runs Maven unwrapped, with a warning. Publishing is opt-in: `install` prints the `distributionManagement` snippet to add to `pom.xml`. Custom download and upload domains are discovered from the organisation as for the Docker helper.
- `cloudsmith exec -- <command>` runs a package-manager command with Cloudsmith credentials provisioned for that single run and cleaned up afterwards — the same machinery the `mvn` shim uses, callable directly in CI without touching `PATH`. The package manager is detected from the command name, and help and version invocations pass straight through unwrapped.
- `cloudsmith credential-helper shell-init` prints the shell initialisation (bash, zsh and fish) that puts the Cloudsmith shims directory first on `PATH`, for `eval "$(cloudsmith credential-helper shell-init)"` in a shell rc file.

### Security

- The `<server>` id in the generated Maven `settings.xml` is `cloudsmith` by default, matching the `distributionManagement` snippet `install` prints so a team can share one `pom.xml`. Maven matches a server's credentials to a repository by id alone, with no host check, so a `pom.xml` declaring a repository under that id receives the token — the same exposure as the `~/.m2/settings.xml` a Maven user would otherwise keep. Pass `--server-id` at install time to bind the credential to an id a third-party `pom.xml` cannot guess.

## [1.23.0] - 2026-08-14

### Added
Expand Down
1 change: 1 addition & 0 deletions cloudsmith_cli/cli/commands/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
domains,
download,
entitlements,
exec_,
help_,
list_,
login,
Expand Down
20 changes: 15 additions & 5 deletions cloudsmith_cli/cli/commands/credential_helper/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,28 +12,38 @@
from .docker import docker as docker_cmd
from .generic import generic as generic_cmd
from .manage import install_cmd, list_cmd, uninstall_cmd
from .shell import shell_init


@click.group()
def credential_helper():
"""
Credential helpers for package managers.

These commands provide credentials for package managers like Docker.
Use ``install`` to set up the on-PATH launcher and configure the package
manager automatically, or run the runtime command directly for debugging.
Use ``install`` to set up a helper and configure the package manager
automatically. Docker uses a native credential-helper launcher; Maven has
no such protocol, so it uses an ``mvn`` shim plus ``cloudsmith exec`` —
activate the shims directory with ``credential-helper shell-init``.

Examples:
# Install Docker credential helper

\b
# Install the Docker credential helper
$ cloudsmith credential-helper install docker

# Test Docker credential helper directly
\b
# Install the Maven helper for one repository
$ cloudsmith credential-helper install maven --org my-org --repo my-repo

\b
# Test the Docker credential helper directly
$ echo "docker.cloudsmith.io" | cloudsmith credential-helper docker
"""


credential_helper.add_command(docker_cmd, name="docker")
credential_helper.add_command(generic_cmd, name="generic")
credential_helper.add_command(shell_init, name="shell-init")
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
58 changes: 51 additions & 7 deletions cloudsmith_cli/cli/commands/credential_helper/manage.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
import click

from ....credential_helpers.docker.installer import DockerInstaller
from ....credential_helpers.maven.config import DEFAULT_SERVER_ID
from ....credential_helpers.maven.installer import MavenInstaller
from ... import utils
from ...decorators import (
common_api_auth_options,
Expand All @@ -27,6 +29,7 @@

_INSTALLERS: dict[str, type] = {
"docker": DockerInstaller,
"maven": MavenInstaller,
}


Expand Down Expand Up @@ -73,7 +76,7 @@ def _get_installer(name: str):
"--domain",
"domains",
multiple=True,
help="Additional registry hostname to configure (repeatable).",
help="Additional registry hostname to configure (repeatable). Docker only.",
)
@click.option(
"--dry-run",
Expand All @@ -85,14 +88,27 @@ def _get_installer(name: str):
"--no-discover",
is_flag=True,
default=False,
help="Disable automatic discovery of custom Docker domains.",
help="Disable automatic discovery of custom domains.",
)
@click.option(
"--refresh",
is_flag=True,
default=False,
help="Bypass the custom-domain cache and fetch fresh data from the API.",
)
@click.option(
"--repo",
default=None,
envvar="CLOUDSMITH_REPO",
help="Maven only: the Cloudsmith repository slug to bind.",
)
@click.option(
"--server-id",
default=DEFAULT_SERVER_ID,
show_default=True,
help="Maven only: the <server> id the credentials are registered under in "
"the generated settings.xml, matching your pom.xml.",
)
@common_cli_config_options
@common_cli_output_options
@common_api_auth_options
Expand All @@ -107,17 +123,25 @@ def install_cmd(
dry_run: bool,
no_discover: bool,
refresh: bool,
repo: str | None,
server_id: str,
) -> None:
"""Install a credential helper launcher and configure the package manager.

HELPER is the name of the credential helper to install (e.g. ``docker``).
HELPER is the name of the credential helper to install (``docker`` or
``maven``). The maven helper binds one repository, so it requires
``--org`` and ``--repo``.

Examples:

\b
# Install Docker credential helper
$ cloudsmith credential-helper install docker

\b
# Install the Maven helper for one repository
$ cloudsmith credential-helper install maven --org my-org --repo my-repo

\b
# Install with a custom domain
$ cloudsmith credential-helper install docker --domain my.registry.example.com
Expand All @@ -131,18 +155,37 @@ def install_cmd(
$ cloudsmith credential-helper install docker --no-discover
"""
installer = _get_installer(helper)

# click passes CLOUDSMITH_REPO through verbatim, and a padded slug would
# be interpolated into a URL and 404. opts.org is already normalised on its
# way through the options object.
repo = (repo or "").strip() or None

# The Maven helper binds a single repository and keeps its shim in a fixed
# shims directory, so --bin-dir and --domain do not apply to it.
if installer.requires_repo:
if not (opts.org and repo):
raise click.ClickException(f"helper {helper!r} requires --org and --repo.")
for name, value in (("--bin-dir", bin_dir), ("--domain", domains)):
if value:
click.echo(f"Warning: {name} is ignored for {helper!r}.", err=True)
extra: dict = {"repo": repo, "server_id": server_id}
else:
extra = {"bin_dir": bin_dir, "domains": domains}

try:
actions = installer.install(
bin_dir=bin_dir,
domains=domains,
dry_run=dry_run,
discover=not no_discover,
refresh=refresh,
org=opts.org,
credential=opts.credential,
api_host=opts.api_host,
**extra,
)
except OSError as exc:
except (OSError, ValueError) as exc:
# ValueError: a trusted [domains] table that declares no host for this
# helper's format, which has no default to fall back on.
raise click.ClickException(
f"Failed to install {helper!r} credential helper: {exc}"
)
Expand Down Expand Up @@ -202,8 +245,9 @@ def uninstall_cmd(ctx, opts, helper: str, bin_dir: str | None, dry_run: bool) ->
$ cloudsmith credential-helper uninstall docker --dry-run
"""
installer = _get_installer(helper)
extra = {} if installer.requires_repo else {"bin_dir": bin_dir}
try:
actions = installer.uninstall(bin_dir=bin_dir, dry_run=dry_run)
actions = installer.uninstall(dry_run=dry_run, **extra)
except OSError as exc:
raise click.ClickException(
f"Failed to uninstall {helper!r} credential helper: {exc}"
Expand Down
55 changes: 55 additions & 0 deletions cloudsmith_cli/cli/commands/credential_helper/shell.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# Copyright 2026 Cloudsmith Ltd
"""``cloudsmith credential-helper shell-init`` — print shell init for shims.

Add ``eval "$(cloudsmith credential-helper shell-init)"`` to your shell rc file
to put the Cloudsmith shims directory ahead of the real package-manager
binaries on ``$PATH``.
"""

import os

import click

from ....credential_helpers.maven.config import shims_dir

_COMMENT = "# Put Cloudsmith package-manager shims ahead of the real binaries"

_POSIX_STATEMENT = 'export PATH="{path}:$PATH"'

_STATEMENTS = {
"bash": _POSIX_STATEMENT,
"zsh": _POSIX_STATEMENT,
"fish": 'fish_add_path "{path}"',
}


def detect_shell():
"""Best-effort shell detection from ``$SHELL``, defaulting to bash."""
name = os.path.basename(os.environ.get("SHELL", ""))
return name if name in _STATEMENTS else "bash"


@click.command(name="shell-init")
@click.option(
"--shell",
"shell_name",
type=click.Choice(sorted(_STATEMENTS)),
default=None,
help="Target shell. Auto-detected from $SHELL when omitted.",
)
def shell_init(shell_name):
"""Print shell init that puts the Cloudsmith shims dir first on PATH.

Examples:

\b
# bash / zsh
$ eval "$(cloudsmith credential-helper shell-init)"

\b
# fish
$ cloudsmith credential-helper shell-init --shell fish | source
"""
statement = _STATEMENTS[shell_name or detect_shell()]
click.echo(_COMMENT)
click.echo(statement.format(path=shims_dir()))
34 changes: 34 additions & 0 deletions cloudsmith_cli/cli/commands/exec_.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Copyright 2026 Cloudsmith Ltd
"""CLI/Commands - Run a command with Cloudsmith credentials provisioned."""

import sys

import click

from ...credential_helpers.maven import runner
from ..decorators import common_api_auth_options, resolve_credentials
from .main import main


@main.command(name="exec", context_settings={"ignore_unknown_options": True})
@click.argument("command", nargs=-1, type=click.UNPROCESSED, required=True)
@common_api_auth_options
@resolve_credentials
@click.pass_context
def exec_(ctx, opts, command):
"""Run a package-manager command authenticated against Cloudsmith.

Wraps the command so it resolves dependencies from your Cloudsmith
repository, with credentials injected for that run and removed afterwards.
This is the machinery the ``mvn`` shim uses, callable directly in CI
without touching ``PATH``.

Maven runs use a generated ``settings.xml``; your ``~/.m2/settings.xml``
is not consulted. The repository comes from ``credential-helper install
maven``. The package manager is detected from the command, so just put it
after ``--``:

\b
$ cloudsmith exec -- mvn clean install
"""
sys.exit(runner.run(list(command), credential=opts.credential))
29 changes: 29 additions & 0 deletions cloudsmith_cli/cli/tests/commands/conftest.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,34 @@
import pytest

from ....cli import config as cli_config
from ....core.credentials.models import CredentialResult


@pytest.fixture()
def cli_config_dir(tmp_path, monkeypatch):
"""Point the CLI config dir at a tmp dir, and return it.

Maven helper state (``package-managers.ini`` and the shims dir) hangs off
it. The trusted config search path is pinned at an empty directory, so
the developer's own ``config.ini`` cannot change the default domains
these tests observe.
"""
monkeypatch.setattr(
"cloudsmith_cli.credential_helpers.maven.config.get_default_config_path",
lambda: str(tmp_path),
)
empty = tmp_path / "empty-trusted-config"
empty.mkdir()
monkeypatch.setattr(cli_config.ConfigReader, "config_files", ["config.ini"])
monkeypatch.setattr(cli_config.ConfigReader, "config_searchpath", [str(empty)])
return tmp_path


@pytest.fixture()
def credential():
"""A resolved credential, as the provider chain would hand one back."""
return CredentialResult(api_key="k_abc", source_name="test")


class MockToken:
"""Mock Token object with the properties needed for testing."""
Expand Down
Loading