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

## [Unreleased]

### Added

- Added a NuGet credential provider. Run `cloudsmith credential-helper install nuget --workspace <workspace>` once. Then `dotnet restore`, MSBuild and Visual Studio authenticate to Cloudsmith feeds with your CLI credentials. NuGet finds the provider on PATH. This needs .NET SDK 9.0.200 or later. The provider also covers the NuGet custom domains of the Workspace. To name feed hosts yourself, use `--domain`. To skip the domain lookup, use `--no-discover`. No token is stored in `nuget.config`.

### Changed

- `cloudsmith domains list` and the credential helpers now get the domains from `GET /v2/workspaces/{workspace}/domains/` on the configured API host. This list includes the Cloudsmith-owned hosts, so the CLI no longer contains a list of Cloudsmith hostnames. The CLI keeps a separate domain cache for each API host.
Expand Down
5 changes: 5 additions & 0 deletions cloudsmith_cli/cli/commands/credential_helper/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from .docker import docker as docker_cmd
from .generic import generic as generic_cmd
from .manage import install_cmd, list_cmd, uninstall_cmd
from .nuget import nuget as nuget_cmd
from .pnpm import pnpm as pnpm_cmd
from .terraform import terraform as terraform_cmd

Expand All @@ -37,6 +38,9 @@ def credential_helper():
\b
# Install cargo credential helper
$ cloudsmith credential-helper install cargo
\b
# Install NuGet credential provider
$ cloudsmith credential-helper install nuget
\b
# Test Docker credential helper directly
$ echo "docker.cloudsmith.io" | cloudsmith credential-helper docker
Expand All @@ -54,5 +58,6 @@ def credential_helper():
credential_helper.add_command(list_cmd, name="list")
credential_helper.add_command(cargo_cmd, name="cargo")
credential_helper.add_command(terraform_cmd, name="terraform")
credential_helper.add_command(nuget_cmd, name="nuget")

main.add_command(credential_helper, name="credential-helper")
71 changes: 71 additions & 0 deletions cloudsmith_cli/cli/commands/credential_helper/manage.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,16 @@
from pathlib import Path

import click
from click.core import ParameterSource

from cloudsmith_cli.credential_helpers.cargo.installer import CargoInstaller
from cloudsmith_cli.credential_helpers.generic import PartialInstallError
from cloudsmith_cli.credential_helpers.launchers import (
ENV_ONLY_SETTINGS,
UnsafeLauncherValue,
install_time_settings,
)
from cloudsmith_cli.credential_helpers.nuget.installer import NuGetInstaller
from cloudsmith_cli.credential_helpers.pnpm.installer import PNPMInstaller
from cloudsmith_cli.credential_helpers.terraform.installer import (
TerraformHelperExeNotFound,
Expand Down Expand Up @@ -44,6 +51,7 @@
"pnpm": PNPMInstaller,
"cargo": CargoInstaller,
"terraform": TerraformInstaller,
"nuget": NuGetInstaller,
}


Expand Down Expand Up @@ -118,6 +126,61 @@ def _terraform_helper_args(ctx, opts, repo: str | None = None) -> tuple[str, ...
return tuple(args)


def _env_only_setting_options(f):
"""Add hidden options that read the settings in ``ENV_ONLY_SETTINGS``.

These settings have no flag, but ``install`` reads them through Click like
every other setting that a launcher keeps.
"""
for env_var in reversed(ENV_ONLY_SETTINGS):
flag = env_var.removeprefix("CLOUDSMITH_").lower().replace("_", "-")
option = click.option(
f"--{flag}-env", env_var.lower(), envvar=env_var, hidden=True
)
f = option(f)
return f


def _launcher_settings_from_cli(ctx, opts, env_only_settings) -> dict[str, str]:
"""Return the ``CLOUDSMITH_*`` settings that a launcher keeps.

Only values from a flag or an environment variable are kept, because the
helper reads the config files itself. Secrets such as the API key are
never kept. The proxy comes only from config, because a proxy URL can
hold credentials and other users can read the launcher.
"""
supplied = (ParameterSource.COMMANDLINE, ParameterSource.ENVIRONMENT)

def from_flag_or_env(param: str, value):
return value if ctx.get_parameter_source(param) in supplied else None

return install_time_settings(
{
"CLOUDSMITH_WORKSPACE": from_flag_or_env("org", opts.org),
"CLOUDSMITH_PROFILE": ctx.meta.get("profile"),
"CLOUDSMITH_CONFIG_FILE": ctx.meta.get("config_file"),
"CLOUDSMITH_CREDENTIALS_FILE": ctx.meta.get("creds_file"),
"CLOUDSMITH_API_HOST": from_flag_or_env("api_host", opts.api_host),
"CLOUDSMITH_WITHOUT_API_SSL_VERIFY": from_flag_or_env(
"without_api_ssl_verify",
"false" if opts.api_ssl_verify else "true",
),
"CLOUDSMITH_OIDC_AUDIENCE": from_flag_or_env(
"oidc_audience", opts.oidc_audience
),
"CLOUDSMITH_SERVICE_SLUG": from_flag_or_env(
"oidc_service_slug", opts.oidc_service_slug
),
**{
env_var: from_flag_or_env(
env_var.lower(), env_only_settings[env_var.lower()]
)
for env_var in ENV_ONLY_SETTINGS
},
}
)


_REPO_FLAGS = ("-r", "--repo", "--repository")


Expand Down Expand Up @@ -200,6 +263,7 @@ def _terraform_next_steps(helper_args: tuple[str, ...]) -> list[str]:
"list (as `-r <repo>`) so `terraform init` needs no CLOUDSMITH_REPO env "
"var. Terraform does not pass the repository to a credentials helper.",
)
@_env_only_setting_options
@common_cli_config_options
@common_cli_output_options
@common_api_auth_options
Expand All @@ -215,6 +279,7 @@ def install_cmd(
no_discover: bool,
refresh: bool,
repo: str | None,
**env_only_settings: str | None,
) -> None:
"""Install a credential helper launcher and configure the package manager.

Expand Down Expand Up @@ -269,12 +334,18 @@ def install_cmd(
helper_args = _terraform_helper_args(ctx, opts, repo)
install_kwargs["helper_args"] = helper_args

if getattr(installer, "KEEPS_INSTALL_TIME_SETTINGS", False):
install_kwargs["launcher_env"] = _launcher_settings_from_cli(
ctx, opts, env_only_settings
)

try:
actions = installer.install(**install_kwargs)
except (
TerraformrcConflictError,
TerraformHelperExeNotFound,
TerraformPluginDirError,
UnsafeLauncherValue,
) as exc:
raise click.ClickException(str(exc))
except OSError as exc:
Expand Down
94 changes: 94 additions & 0 deletions cloudsmith_cli/cli/commands/credential_helper/nuget.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
# Copyright 2026 Cloudsmith Ltd
"""
NuGet credential provider command.

Implements the NuGet cross-platform authentication plugin protocol for
Cloudsmith feeds.

See:
https://learn.microsoft.com/en-us/nuget/reference/extensibility/nuget-cross-platform-plugins
"""

import sys

import click

from ....core.credentials.chain import CredentialProviderChain
from ....credential_helpers.nuget import execute
from ...decorators import common_api_auth_options, initialise_api


def _utf8(stream, **kwargs):
"""Switch a text stream to UTF-8, which the NuGet plugin protocol requires."""
try:
stream.reconfigure(encoding="utf-8", **kwargs)
except (AttributeError, ValueError, OSError):
pass
return stream


@click.command(
context_settings={"ignore_unknown_options": True, "allow_interspersed_args": False}
)
@click.option(
"--domain",
"domains",
multiple=True,
help="A feed host to authenticate, as well as the Workspace's NuGet domains.",
)
@click.option(
"--no-discover",
is_flag=True,
help="Do not look up the Workspace's NuGet domains.",
)
@click.argument("nuget_args", nargs=-1, type=click.UNPROCESSED)
@common_api_auth_options
@initialise_api
def nuget(opts, domains, no_discover, nuget_args):
"""
NuGet credential provider for Cloudsmith feeds.

NuGet (dotnet, MSBuild, Visual Studio) runs this command through the
``nuget-plugin-cloudsmith`` launcher that ``cloudsmith credential-helper
install nuget`` puts on PATH, passing ``-Plugin`` after ``--``. It then
speaks the NuGet cross-platform plugin protocol on stdin/stdout.

Provides credentials for all Cloudsmith NuGet feeds: ``*.cloudsmith.io``,
``*.cloudsmith.com``, and any NuGet custom domains configured for the
Workspace (requires a Workspace - ``--workspace``, CLOUDSMITH_WORKSPACE,
or ``workspace`` in ``config.ini``; legacy aliases are also accepted - and
a valid API key/token). Any other feed is declined so NuGet falls back to
its other credential sources.

\b
Examples:

\b
# Called by NuGet via the launcher
$ nuget-plugin-cloudsmith -Plugin

\b
Environment variables:
CLOUDSMITH_API_KEY: API key for authentication (optional)
CLOUDSMITH_WORKSPACE: Workspace slug (CLOUDSMITH_ORG is also accepted)
"""
protocol_stdout = _utf8(sys.stdout, newline="\n")
# Other output, such as the HTTP trace of --debug, must not reach NuGet.
sys.stdout = sys.stderr
exit_code, stderr = execute(
nuget_args,
_utf8(sys.stdin, errors="replace"),
protocol_stdout,
credential=opts.credential,
api_host=opts.api_host,
workspace=opts.org,
refresh_credential=lambda: CredentialProviderChain().resolve(
opts.credential_context
),
domains=domains,
discover=not no_discover,
)

if stderr is not None:
click.echo(stderr, err=True)
sys.exit(exit_code)
1 change: 1 addition & 0 deletions cloudsmith_cli/cli/decorators.py
Original file line number Diff line number Diff line change
Expand Up @@ -586,6 +586,7 @@ def wrapper(ctx, *args, **kwargs):
click.secho(message, fg="yellow", err=True)

opts.credential = credential
opts.credential_context = context

kwargs["opts"] = opts
return ctx.invoke(f, *args, **kwargs)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,12 @@
from ....core.credentials.models import CredentialResult
from ....credential_helpers.docker.installer import DockerInstaller
from ....credential_helpers.launchers import (
UnsafeLauncherValue,
_launcher_content,
_launcher_filename,
_user_bin_dir,
install_time_settings,
is_on_path,
launcher_filename,
remove_launcher,
resolve_bin_dir,
write_launcher,
Expand Down Expand Up @@ -87,7 +89,7 @@ def test_launcher_filename_and_content(
``NotImplementedError`` on Python < 3.12).
"""
assert (
_launcher_filename(f"{format}-credential-cloudsmith", windows=windows)
launcher_filename(f"{format}-credential-cloudsmith", windows=windows)
== expected_name
)
assert (
Expand All @@ -96,6 +98,62 @@ def test_launcher_filename_and_content(
)


@pytest.mark.parametrize(
"profile,value,expected",
[
("C:\\Users\\jo", "C:\\Users\\jo\\a b&c!", "C:\\Users\\jo\\a b&c!"),
(
"C:\\Users\\José",
"c:\\users\\josé\\cs\\config.ini",
"%USERPROFILE%\\cs\\config.ini",
),
],
)
def test_windows_launcher_settings_are_ascii(monkeypatch, profile, value, expected):
"""cmd.exe reads a batch file in the console code page, not UTF-8.

Only a non-ASCII path under the user profile is written as %USERPROFILE%,
because another account that runs NuGet has a different profile.
"""
monkeypatch.setenv("USERPROFILE", profile)
content = _launcher_content("cmd", windows=True, env={"CLOUDSMITH_X": value})
assert content == (
"@echo off\r\nsetlocal DisableDelayedExpansion\r\n"
f'set "CLOUDSMITH_X={expected}"\r\ncmd %*\r\n'
)


@pytest.mark.parametrize("value", ["%PATH%", "D:\\Données"])
def test_windows_launcher_refuses_a_value_a_batch_file_cannot_hold(monkeypatch, value):
monkeypatch.setenv("USERPROFILE", "C:\\Users\\José")
with pytest.raises(UnsafeLauncherValue, match="CLOUDSMITH_PROFILE"):
_launcher_content("cmd", windows=True, env={"CLOUDSMITH_PROFILE": value})


def test_install_time_settings_resolve_paths_from_the_given_values(
monkeypatch, tmp_path
):
"""Only the given values count; the process environment is not read."""
monkeypatch.chdir(tmp_path)
monkeypatch.setenv("KEYRING_ROOT", "/var/keyring")
monkeypatch.setenv("CLOUDSMITH_KEYRING_BACKEND", "not.from.the.environment")

settings = install_time_settings(
{
"CLOUDSMITH_PROFILE": None,
"CLOUDSMITH_NO_KEYRING": "1",
"CLOUDSMITH_KEYRING_DIR": "$KEYRING_ROOT/store",
"CLOUDSMITH_KEYRING_FILE_PATH": "keyring.cfg",
}
)

assert settings == {
"CLOUDSMITH_NO_KEYRING": "1",
"CLOUDSMITH_KEYRING_DIR": "/var/keyring/store",
"CLOUDSMITH_KEYRING_FILE_PATH": str(tmp_path / "keyring.cfg"),
}


# ---------------------------------------------------------------------------
# 2. write_launcher / remove_launcher — end-to-end on the host platform
# ---------------------------------------------------------------------------
Expand Down
Loading
Loading