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
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

- After a SAML login, `cloudsmith auth` saves the Workspace to `config.ini`. It writes to the `[profile:NAME]` section of the selected profile, or to `[default]` when you do not select a profile. The CLI writes to the file or directory that `--config-file` gives, or else to your user-level `config.ini`. If the `config.ini` does not exist, the CLI creates it.
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")
2 changes: 2 additions & 0 deletions cloudsmith_cli/cli/commands/credential_helper/manage.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from cloudsmith_cli.credential_helpers.cargo.installer import CargoInstaller
from cloudsmith_cli.credential_helpers.generic import PartialInstallError
from cloudsmith_cli.credential_helpers.launchers import UnsafeLauncherValue
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 @@ -47,6 +48,7 @@
"pnpm": PNPMInstaller,
"cargo": CargoInstaller,
"terraform": TerraformInstaller,
"nuget": NuGetInstaller,
}


Expand Down
104 changes: 104 additions & 0 deletions cloudsmith_cli/cli/commands/credential_helper/nuget.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
# 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 import keyring
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


def _resolve_credential(opts, non_interactive):
"""Resolve the credential and keep it on *opts*, where telemetry redacts it."""
if non_interactive:
keyring.forbid_password_prompts()
opts.credential = CredentialProviderChain().resolve(opts.credential_context)
return opts.credential


@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
# NuGet gives the handshake a deadline, so a slow credential, such as an OIDC
# exchange, resolves at the first credentials request.
@initialise_api(defer_credentials=True)
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,
api_host=opts.api_host,
workspace=opts.org,
refresh_credential=lambda non_interactive: _resolve_credential(
opts, non_interactive
),
domains=domains,
discover=not no_discover,
)

if stderr is not None:
click.echo(stderr, err=True)
sys.exit(exit_code)
18 changes: 13 additions & 5 deletions cloudsmith_cli/cli/decorators.py
Original file line number Diff line number Diff line change
Expand Up @@ -633,8 +633,13 @@ def _warn_on_oidc_detector_controls(order, disabled):
)


def resolve_credentials(f):
"""Resolve credentials via the provider chain. Depends on initialise_session."""
def resolve_credentials(f=None, *, defer=False):
"""Resolve credentials via the provider chain. Depends on initialise_session.

With ``defer=True``, the command resolves them from ``opts.credential_context``.
"""
if f is None:
return functools.partial(resolve_credentials, defer=defer)

@click.option(
"--oidc-audience",
Expand Down Expand Up @@ -722,7 +727,7 @@ def wrapper(ctx, *args, **kwargs):
)

chain = CredentialProviderChain()
credential = chain.resolve(context)
credential = None if defer else chain.resolve(context)

if context.keyring_refresh_failed and not is_auth_command:
if credential:
Expand Down Expand Up @@ -750,15 +755,18 @@ 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)

return initialise_session(wrapper)


def initialise_api(f):
def initialise_api(f=None, *, defer_credentials=False):
"""Initialise the Cloudsmith API for use. Depends on resolve_credentials."""
if f is None:
return functools.partial(initialise_api, defer_credentials=defer_credentials)

@click.option(
"-R",
Expand Down Expand Up @@ -835,7 +843,7 @@ def call_print_rate_limit_info_with_opts(rate_info):
kwargs["opts"] = opts
return ctx.invoke(f, *args, **kwargs)

return resolve_credentials(wrapper)
return resolve_credentials(wrapper, defer=defer_credentials)


def initialise_mcp(f):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@
from ....credential_helpers.launchers import (
UnsafeLauncherValue,
_launcher_content,
_launcher_filename,
is_on_path,
launcher_filename,
remove_launcher,
resolve_bin_dir,
write_launcher,
Expand Down Expand Up @@ -103,7 +103,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 Down
Loading
Loading