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

- `cloudsmith domains list` lists the hosts Cloudsmith can authenticate as a versioned JSON document: `{"version": 1, "domains": [{"host": ..., "format": ..., "type": ..., "domain_type": ..., "org": ..., "repository": ..., "primary": ..., "created_at": ...}]}`. The built-in list can be replaced by a `[domains]` section in a trusted `config.ini` — each entry maps a hostname to the format it serves, or to `download`/`upload` — for dedicated deployments. An organisation's own custom domains are listed ahead of the built-in hosts, and a custom domain that is disabled or not yet validated is left out entirely, since it serves nothing. `--format` and `--repo` narrow the list to the hosts usable for a package format or repository, most-preferred first, and `--domain-type` to those with one purpose: `download`, `upload`, `api` or `native_api`.
- The Cloudsmith organisation is now named by `--org`, with `--organization` and `--oidc-org` accepted as aliases for the same option, and `org`, `organization` or `oidc_org` accepted in `config.ini`. `--oidc-org` named the setting after the first feature that wanted it; it is read by custom-domain discovery as well as OIDC token exchange, so it is now named after what it is. The `CLOUDSMITH_ORG` environment variable is unchanged, and `credential-helper install` no longer has a separate `--org` of its own.
- `cloudsmith credential-helper install maven --org <org> --repo <repo>` sets up transparent Maven authentication. Maven has no credential-helper protocol, so the CLI installs a shell plugin instead: an `mvn` shim (activated by putting the shims directory first on `PATH` via `cloudsmith credential-helper shell-init`) that wraps every invocation in `cloudsmith exec`. Wrapped runs resolve dependencies from the bound Cloudsmith repository through an ephemeral, mode-0600 `settings.xml` that is injected via `mvn -s` and deleted when the run ends, so no token is ever written to durable configuration. Note that wrapped runs do not consult `~/.m2/settings.xml`; passing your own `-s/--settings` runs Maven unwrapped, with a warning. Publishing via `mvn deploy` is opt-in: `install` prints the `distributionManagement` snippet to add to `pom.xml`. Custom download/upload domains are discovered automatically from the organisation, as for the Docker helper, and a domain bound to the repository being installed is preferred over an organisation-wide one, while a domain bound to a *different* repository is never used. Which kind each host is — organisation-wide or bound to this repository — is recorded in `package-managers.ini` alongside it, so wrapped `mvn` runs need no further lookup. A repository-scoped custom domain identifies the repository on its own, so its URLs carry neither an organisation nor a repository path segment (`https://dl-prod.acme.com/basic/maven/` rather than `.../basic/<org>/<repo>/maven/`). Because such a host serves only the repository it was discovered for, `cloudsmith exec --repo <other-repo>` resets it back to the default host for that one invocation rather than reusing it for an unrelated repository. Maven matches a `<server>`'s credentials to a repository by id alone, with no host check, so a wrapped run binds the injected download credential to an unguessable id minted for the binding at install time rather than a fixed, guessable one — a `pom.xml` that declares a repository under a guessed server id can no longer obtain the token on an ordinary build. That id is stable across runs, because Maven's local repository records which repository each cached artifact came from and a new id every run would re-download everything and break offline builds. The stable server id you put in `distributionManagement` is supplied only when the invocation is actually a deploy (`deploy`, `deploy:*`, `release:perform`, or a goal given by its fully-qualified plugin coordinates like `org.apache.maven.plugins:maven-deploy-plugin:3.1.1:deploy-file`), since that is the only case that needs it — the rest of the release lifecycle, `release:prepare` included, publishes nothing and is not given it.
- `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 (for example in CI, without touching `PATH`). The package manager is detected from the command name; `--org`/`--repo` flags override the stored binding for one invocation, while `CLOUDSMITH_ORG`/`CLOUDSMITH_REPO` from the environment apply only when no binding is stored. Running a command whose helper is not configured is an error that names the `install` command to run, and help/version invocations pass through without credentials.
- `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.

## [1.21.0] - 2026-08-03

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
28 changes: 23 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,46 @@
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``.

``generic`` emits a machine-readable credential for tools that shell out to
the CLI instead of importing it; ``cloudsmith domains list`` names the hosts
it can be used against.

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

\b
# Emit a credential as JSON
$ cloudsmith credential-helper generic
"""


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
64 changes: 58 additions & 6 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.installer import MavenInstaller
from ....credential_helpers.shellplugin.config import DEFAULT_REGISTRY_ID
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,8 @@ def _get_installer(name: str):
"--domain",
"domains",
multiple=True,
help="Additional registry hostname to configure (repeatable).",
help="Registry hostname to configure (repeatable). Docker adds each host; "
"maven routes each to its download or upload endpoint.",
)
@click.option(
"--dry-run",
Expand All @@ -85,14 +89,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="Cloudsmith repository slug.",
)
@click.option(
"--registry-id",
default=DEFAULT_REGISTRY_ID,
show_default=True,
help="Id the credentials are registered under in your project config "
"(e.g. the Maven settings.xml/pom.xml <server> id).",
)
@common_cli_config_options
@common_cli_output_options
@common_api_auth_options
Expand All @@ -107,17 +124,25 @@ def install_cmd(
dry_run: bool,
no_discover: bool,
refresh: bool,
repo: str | None,
registry_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 +156,44 @@ def install_cmd(
$ cloudsmith credential-helper install docker --no-discover
"""
installer = _get_installer(helper)

# opts.org is already normalised on its way through the options object.
repo = utils.normalise_slug(repo)

per_repo = getattr(installer, "requires_repo", False)
if per_repo and not (opts.org and repo):
click.echo(
f"Error: helper {helper!r} requires --org and --repo.",
err=True,
)
sys.exit(1)

# Shell plugins (per-repo) keep their shims in a fixed dir; --bin-dir only
# applies to launcher-based helpers like Docker.
if per_repo:
if bin_dir:
click.echo(
f"Warning: --bin-dir is ignored for {helper!r}; its shim lives "
"in a fixed shims directory.",
err=True,
)
extra: dict = {"repo": repo, "registry_id": registry_id}
else:
extra = {"bin_dir": bin_dir}
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 +253,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 getattr(installer, "requires_repo", False) 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
36 changes: 36 additions & 0 deletions cloudsmith_cli/cli/commands/credential_helper/shell.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# 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 click

from ....credential_helpers.shellplugin.shellinit import detect_shell, generate_init


@click.command(name="shell-init")
@click.option(
"--shell",
"shell_name",
type=click.Choice(["bash", "zsh", "fish"]),
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
"""
shell = shell_name or detect_shell()
click.echo(generate_init(shell), nl=False)
59 changes: 59 additions & 0 deletions cloudsmith_cli/cli/commands/exec_.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# Copyright 2026 Cloudsmith Ltd
"""CLI/Commands - Run a command with Cloudsmith credentials provisioned."""

import sys

import click
from click.core import ParameterSource

from ...credential_helpers.shellplugin import runner
from .. import utils
from ..decorators import common_api_auth_options, resolve_credentials
from .main import main


@main.command(name="exec", context_settings={"ignore_unknown_options": True})
@click.option(
"--repo",
default=None,
envvar="CLOUDSMITH_REPO",
help="Cloudsmith repository slug. As a flag this overrides the stored "
"binding; from the environment it applies only when nothing is stored.",
)
@click.argument("command", nargs=-1, type=click.UNPROCESSED, required=True)
@common_api_auth_options
@resolve_credentials
@click.pass_context
def exec_(ctx, opts, repo, command):
"""Run a package-manager command authenticated against Cloudsmith.

Wraps the command so it resolves dependencies from (and publishes to) your
Cloudsmith repository, with credentials injected for that single run and
cleaned up afterwards. Maven runs use an ephemeral ``settings.xml``; your
``~/.m2/settings.xml`` is not consulted. The package manager is detected
automatically from the command, so just put it after ``--``:

\b
$ cloudsmith exec -- mvn clean deploy

``--org`` overrides the stored binding when passed as a flag; taken from
the environment or ``config.ini`` it applies only when nothing is stored,
the same rule ``--repo`` follows.
"""

def flag_value(name, value):
source = ctx.get_parameter_source(name)
return value if source == ParameterSource.COMMANDLINE else None

# opts.org is already normalised on its way through the options object.
repo = utils.normalise_slug(repo)

exit_code = runner.run(
list(command),
credential=opts.credential,
owner=flag_value("org", opts.org),
repo=flag_value("repo", repo),
default_owner=opts.org,
default_repo=repo,
)
sys.exit(exit_code)
Loading