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 @@ -13,6 +13,9 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.

- `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.
- `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 a random id generated fresh for that invocation 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. The stable server id you put in `distributionManagement` is supplied only when the invocation is actually a deploy (`deploy`, `deploy:*`, `release:*` since `release:perform` runs a deploy internally, or a goal given by its fully-qualified plugin coordinates like `org.apache.maven.plugins:maven-deploy-plugin:3.1.1:deploy`), since that is the only case that needs 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.20.2] - 2026-07-31

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 @@ -10,6 +10,7 @@
docs,
download,
entitlements,
exec_,
help_,
list_,
login,
Expand Down
27 changes: 22 additions & 5 deletions cloudsmith_cli/cli/commands/credential_helper/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,29 +13,46 @@
from .domains import domains_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`` and ``domains`` emit machine-readable JSON for tools that
shell out to the CLI instead of importing it.

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(domains_cmd, name="domains")
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
67 changes: 59 additions & 8 deletions cloudsmith_cli/cli/commands/credential_helper/manage.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,13 @@

from __future__ import annotations

import os
import sys

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 @@ -28,6 +29,7 @@

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


Expand Down Expand Up @@ -74,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 @@ -86,7 +89,7 @@ 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",
Expand All @@ -97,7 +100,21 @@ def _get_installer(name: str):
@click.option(
"--org",
default=None,
help="Cloudsmith organisation slug for custom-domain discovery.",
envvar="CLOUDSMITH_ORG",
help="Cloudsmith organisation slug.",
)
@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
Expand All @@ -114,17 +131,25 @@ def install_cmd(
no_discover: bool,
refresh: bool,
org: str | None,
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 @@ -138,17 +163,42 @@ def install_cmd(
$ cloudsmith credential-helper install docker --no-discover
"""
installer = _get_installer(helper)
org = org or os.environ.get("CLOUDSMITH_ORG", "").strip() or None

# click passes an env var through verbatim, so a padded CLOUDSMITH_ORG /
# CLOUDSMITH_REPO would otherwise be written into the binding as a slug.
org = org.strip() or None if org else None
repo = repo.strip() or None if repo else None

per_repo = getattr(installer, "requires_repo", False)
if per_repo and not (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=org,
credential=opts.credential,
api_host=opts.api_host,
**extra,
)
except OSError as exc:
raise click.ClickException(
Expand Down Expand Up @@ -210,8 +260,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)
58 changes: 58 additions & 0 deletions cloudsmith_cli/cli/commands/exec_.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# 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 ..decorators import common_api_auth_options, resolve_credentials
from .main import main


@main.command(name="exec", context_settings={"ignore_unknown_options": True})
@click.option(
"--org",
default=None,
envvar="CLOUDSMITH_ORG",
help="Cloudsmith organisation slug. As a flag this overrides the stored "
"binding; from the environment it applies only when nothing is stored.",
)
@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, org, 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
"""

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

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