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

### Added

- Added `-w` and `--workspace` as the shared Workspace option used by authentication, OIDC, and custom-domain discovery. Set `CLOUDSMITH_WORKSPACE` or `workspace` in `config.ini` to configure it once for every command.
- Added `cloudsmith repos gpg` for managing the GPG key a repository signs its package indexes with. `get` shows the active key and its armored public block, `upload` installs a key you supply, and `regenerate` replaces the current key with a freshly generated Cloudsmith one. Key material and passphrases are only ever read from a file, stdin, or a hidden prompt, never from a command-line value, and `--debug` is refused on `upload` so the request body can't be logged. Both mutating subcommands accept `-n/--dry-run`, which checks the inputs and the key currently in place - naming the fingerprint that would be replaced - then stops before the request, so a mistyped repository or a stale credential fails there rather than on the real attempt. `regenerate` asks you to type `regenerate` to confirm - with no terminal attached it fails instead of blocking, so pass `-y/--yes` for unattended runs. There's no `delete` subcommand because the API has no way to remove a repository's key.
- Added `cloudsmith repos privileges` for managing explicit repository access from the terminal. `list` shows the teams, users and service accounts that were granted access explicitly; `set` grants access to any number of them and leaves everyone else untouched, asking first if it would lower access someone already has; `revoke` takes access away from the ones named, skipping any that had none; and `replace` makes a JSON file (or stdin) the complete truth for the repository. `revoke` and `replace` ask for confirmation first unless `-y` is passed.

### Changed

- `cloudsmith copy` now prints `Copied: owner/repo/slug (slug_perm)` after a successful copy and includes `slug_perm` in `-F json` output, matching `push`.
- `cloudsmith domains list` now includes the Workspace slug in a `workspace` field.

## [1.25.0] - 2026-08-24

Expand Down
23 changes: 8 additions & 15 deletions cloudsmith_cli/cli/commands/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ def _perform_saml_authentication(
idp_url = get_idp_url(api_host, owner, session=session)

click.echo(
f"Your organization's SAML IDP URL is: {click.style(idp_url, bold=True)}",
f"Your Workspace's SAML IDP URL is: {click.style(idp_url, bold=True)}",
err=use_stderr,
)
click.echo(err=use_stderr)
Expand Down Expand Up @@ -75,15 +75,6 @@ def _perform_saml_authentication(


@main.command(aliases=["auth"])
@click.option(
"-o",
"--owner",
metavar="OWNER",
required=True,
callback=validators.validate_owner,
prompt=True,
help="The name of the Cloudsmith organization to authenticate with.",
)
@click.option(
"-t",
"--token",
Expand Down Expand Up @@ -132,15 +123,14 @@ def _perform_saml_authentication(
def authenticate(
ctx,
opts,
owner,
token,
force,
save_config,
json,
request_api_key_flag,
no_browser,
):
"""Authenticate to Cloudsmith using the org's SAML setup."""
"""Authenticate to Cloudsmith using the Workspace's SAML setup."""
# Validate mutual exclusivity
if request_api_key_flag and (token or force):
raise click.UsageError(
Expand Down Expand Up @@ -175,10 +165,13 @@ def authenticate(
err=True,
)

owner = owner[0].strip("[]'")
workspace = opts.org or click.prompt("Workspace", err=use_stderr)
workspace = validators.validate_owner(ctx, None, workspace)[0]
opts.org = workspace

click.echo(
f"Beginning authentication for the {click.style(owner, bold=True)} org ... ",
"Beginning authentication for the "
f"{click.style(workspace, bold=True)} Workspace ... ",
err=use_stderr,
)

Expand All @@ -189,7 +182,7 @@ def authenticate(
with handle_api_exceptions(ctx, opts=opts, context_msg=context_message):
_perform_saml_authentication(
opts,
owner,
workspace,
enable_token_creation=enable_token_creation,
use_stderr=use_stderr,
no_browser=no_browser,
Expand Down
9 changes: 5 additions & 4 deletions cloudsmith_cli/cli/commands/credential_helper/cargo.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,10 @@ def cargo(opts, cargo_plugin, provider_args): # pylint: disable=unused-argument
the supported protocol versions, then one response per request.

Provides credentials for all Cloudsmith Cargo registries: ``*.cloudsmith.io``,
``*.cloudsmith.com``, and any custom domains configured for the organisation
(requires an organisation - ``--org``, CLOUDSMITH_ORG or ``org`` in
``config.ini`` - and a valid API key/token).
``*.cloudsmith.com``, and any 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).

A registry that is not a Cloudsmith one is answered with
``url-not-supported`` so Cargo falls through to the next configured
Expand Down Expand Up @@ -74,7 +75,7 @@ def cargo(opts, cargo_plugin, provider_args): # pylint: disable=unused-argument
\b
Environment variables:
CLOUDSMITH_API_KEY: API key for authentication (optional)
CLOUDSMITH_ORG: Organisation slug (required for custom domain support)
CLOUDSMITH_WORKSPACE: Workspace slug (CLOUDSMITH_ORG is also accepted)
"""
# `provider_args` collects the extra arguments Cargo appends from the
# credential-provider config entry. This provider takes no configuration
Expand Down
9 changes: 5 additions & 4 deletions cloudsmith_cli/cli/commands/credential_helper/docker.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,10 @@ def docker(opts, operation):
(get/store/erase/list).

Provides credentials for all Cloudsmith Docker registries: ``*.cloudsmith.io``,
``*.cloudsmith.com``, and any custom domains configured for the organisation
(requires an organisation - ``--org``, CLOUDSMITH_ORG or ``org`` in
``config.ini`` - and a valid API key/token).
``*.cloudsmith.com``, and any 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).

Input (stdin):
Server URL as plain text (e.g. "docker.cloudsmith.io")
Expand All @@ -51,7 +52,7 @@ def docker(opts, operation):

Environment variables:
CLOUDSMITH_API_KEY: API key for authentication (optional)
CLOUDSMITH_ORG: Organisation slug (required for custom domain support)
CLOUDSMITH_WORKSPACE: Workspace slug (CLOUDSMITH_ORG is also accepted)
"""
exit_code, stdout, stderr = execute(
operation,
Expand Down
2 changes: 1 addition & 1 deletion cloudsmith_cli/cli/commands/credential_helper/generic.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ def generic(opts):

Resolves a credential through the full provider chain and writes a
versioned JSON document to stdout. Takes no arguments: a Cloudsmith token
is organisation-wide, so the host it will be used against does not change
is Workspace-wide, so the host it will be used against does not change
which credential resolves.

\b
Expand Down
2 changes: 1 addition & 1 deletion cloudsmith_cli/cli/commands/credential_helper/pnpm.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ def pnpm(opts, repo):
\b
Environment variables:
CLOUDSMITH_API_KEY: API key for authentication (optional)
CLOUDSMITH_ORG: Organisation slug (required for custom domain support)
CLOUDSMITH_WORKSPACE: Workspace slug (CLOUDSMITH_ORG is also accepted)
"""

exit_code, stdout, stderr = execute(
Expand Down
32 changes: 17 additions & 15 deletions cloudsmith_cli/cli/commands/domains.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ def _custom_entries(records) -> list[dict]:
"format": format_for_backend_kind(record.backend_kind),
"type": "custom",
"domain_type": record.domain_type.value,
"workspace": record.org,
"org": record.org,
"repository": record.repository,
"primary": record.primary,
Expand Down Expand Up @@ -133,19 +134,19 @@ def list_domains( # pylint: disable=too-many-arguments
``*.cloudsmith.io`` service hosts alongside custom domains, with ``type``
distinguishing ``default`` from ``custom`` and ``domain_type`` saying what
each host is for: ``download``, ``upload``, or ``native_api`` for a host
speaking one package format's own protocol. An organisation's own custom
speaking one package format's own protocol. A Workspace's own custom
domains are listed ahead of the built-in hosts, which serve as the
fallback. Only usable hosts are listed: a custom domain that is disabled or
not yet validated serves nothing, so it is left out rather than offered as
somewhere to publish to. Check it in the Cloudsmith UI if one you expect is
missing here.

Built-in hosts are always listed and need no organisation or authentication.
An organisation from ``--org``, CLOUDSMITH_ORG or ``org`` in ``config.ini``
adds its custom domains, and a failed lookup exits non-zero rather than
rendering as "no domains". With no organisation the command lists whatever
earlier runs cached and makes no API call; ``--refresh`` bypasses that cache
for a configured organisation.
Built-in hosts are always listed and need no Workspace or authentication.
A Workspace from ``--workspace``, CLOUDSMITH_WORKSPACE, or ``workspace`` in
``config.ini`` adds its custom domains, and a failed lookup exits non-zero
rather than rendering as "no domains". With no Workspace the command lists
whatever earlier runs cached and makes no API call; ``--refresh`` bypasses
that cache for a configured Workspace.

Where two custom domains could serve the same request Cloudsmith picks the
one bound to the repository in hand, then ``primary`` over secondary, then
Expand All @@ -163,7 +164,7 @@ def list_domains( # pylint: disable=too-many-arguments

Output (stdout):
JSON: {"version": 1, "domains": [{"host": ..., "format": ...,
"type": ..., "domain_type": ..., "org": ..., "repository": ...,
"type": ..., "domain_type": ..., "workspace": ..., "repository": ...,
"primary": ..., "created_at": ...}], "meta": {"pagination": {...}}}

"meta" is only present when the result is paginated (i.e. not
Expand All @@ -177,16 +178,16 @@ def list_domains( # pylint: disable=too-many-arguments
$ cloudsmith domains list

\b
# List built-in hosts plus an organisation's custom domains
$ cloudsmith domains list --org my-org
# List built-in hosts plus a Workspace's custom domains
$ cloudsmith domains list --workspace my-workspace

\b
# The hosts usable for one repository, most-preferred first
$ cloudsmith domains list --org my-org --repo my-repo --format maven
$ cloudsmith domains list --workspace my-workspace --repo my-repo --format maven

\b
# Where to upload to
$ cloudsmith domains list --org my-org --domain-type upload
$ cloudsmith domains list --workspace my-workspace --domain-type upload
"""
if not page_all:
explicit_sources = {
Expand Down Expand Up @@ -229,6 +230,7 @@ def list_domains( # pylint: disable=too-many-arguments
"format": domain.format_label,
"type": "default",
"domain_type": domain.domain_type.value,
"workspace": None,
"org": None,
"repository": None,
"primary": True,
Expand Down Expand Up @@ -257,9 +259,9 @@ def list_domains( # pylint: disable=too-many-arguments
else:
if refresh:
click.secho(
"Warning: --refresh needs an organisation to fetch from, so the "
"cached custom domains below are unchanged. Set --org, "
"CLOUDSMITH_ORG or org in config.ini.",
"Warning: --refresh needs a Workspace to fetch from, so the "
"cached custom domains below are unchanged. Set --workspace, "
"CLOUDSMITH_WORKSPACE, or workspace in config.ini.",
fg="yellow",
err=True,
)
Expand Down
22 changes: 22 additions & 0 deletions cloudsmith_cli/cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ class Default(SectionSchema):
mcp_allowed_tools = ConfigParam(name="mcp_allowed_tools", type=str)
mcp_allowed_tool_groups = ConfigParam(name="mcp_allowed_tool_groups", type=str)
oidc_audience = ConfigParam(name="oidc_audience", type=str)
workspace = ConfigParam(name="workspace", type=str)
org = ConfigParam(name="org", type=str)
organization = ConfigParam(name="organization", type=str)
oidc_org = ConfigParam(name="oidc_org", type=str)
Expand Down Expand Up @@ -202,7 +203,18 @@ def load_config(cls, opts, path=None, profile=None):

@staticmethod
def _load_values_into_opts(opts, values):
preferred_workspace = values.get("workspace")
if isinstance(preferred_workspace, str):
preferred_workspace = preferred_workspace.strip()
if preferred_workspace.startswith(('"', "'")):
preferred_workspace = preferred_workspace[1:]
if preferred_workspace.endswith(('"', "'")):
preferred_workspace = preferred_workspace[:-1]
preferred_workspace = preferred_workspace.strip()
has_preferred_workspace = bool(preferred_workspace)
for k, v in values.items():
if has_preferred_workspace and k in ("org", "organization", "oidc_org"):
continue
if v is None:
continue
if isinstance(v, str):
Expand Down Expand Up @@ -500,6 +512,16 @@ def org(self, value):
value = value.strip() or None
self._set_option("org", value)

@property
def workspace(self):
"""Get the Workspace slug using current terminology."""
return self.org

@workspace.setter
def workspace(self, value):
"""Set the Workspace slug using current terminology."""
self.org = value

@property
def organization(self):
"""Get the organisation slug, spelled in full."""
Expand Down
8 changes: 6 additions & 2 deletions cloudsmith_cli/cli/decorators.py
Original file line number Diff line number Diff line change
Expand Up @@ -436,12 +436,16 @@ def resolve_credentials(f):
help="The OIDC audience for token requests.",
)
@click.option(
"-w",
"--workspace",
"--org",
"--organization",
"--oidc-org",
"-o",
"--owner",
"org",
envvar="CLOUDSMITH_ORG",
help="The Cloudsmith organisation slug.",
envvar=["CLOUDSMITH_WORKSPACE", "CLOUDSMITH_ORG"],
help="The Cloudsmith Workspace slug.",
)
@click.option(
"--oidc-service-slug",
Expand Down
58 changes: 55 additions & 3 deletions cloudsmith_cli/cli/tests/commands/test_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,58 @@ def mock_auth_server():
class TestAuthenticateCommand:
"""Tests for the authenticate command."""

@pytest.mark.parametrize(
"option",
[
"--workspace",
"-w",
"--org",
"--organization",
"--oidc-org",
"--owner",
"-o",
],
)
def test_workspace_option_aliases(
self,
option,
runner,
mock_saml_session,
mock_get_idp_url,
mock_webbrowser,
mock_auth_server,
):
"""Current and legacy option spellings identify the same Workspace."""
result = runner.invoke(
authenticate,
[option, "test-workspace", "--no-browser"],
catch_exceptions=False,
)

assert result.exit_code == 0, result.output
assert mock_auth_server.call_args.kwargs["owner"] == "test-workspace"

@pytest.mark.parametrize("envvar", ["CLOUDSMITH_WORKSPACE", "CLOUDSMITH_ORG"])
def test_workspace_environment_aliases(
self,
envvar,
runner,
mock_saml_session,
mock_get_idp_url,
mock_webbrowser,
mock_auth_server,
):
"""Authentication inherits either Workspace environment variable."""
result = runner.invoke(
authenticate,
["--no-browser"],
env={envvar: "test-workspace"},
catch_exceptions=False,
)

assert result.exit_code == 0, result.output
assert mock_auth_server.call_args.kwargs["owner"] == "test-workspace"

def test_auth_command_invokes_webserver(
self,
runner,
Expand Down Expand Up @@ -204,8 +256,8 @@ def test_no_browser_skips_webbrowser_open(
assert result.exit_code == 0
mock_webbrowser.open.assert_not_called()
assert "Skipping automatic browser launch" in result.output
assert "Opening your organization's SAML IDP URL" not in result.output
assert "Your organization's SAML IDP URL is:" in result.output
assert "Opening your Workspace's SAML IDP URL" not in result.output
assert "Your Workspace's SAML IDP URL is:" in result.output
mock_auth_server.assert_called_once()
mock_auth_server.return_value.handle_request.assert_called_once()

Expand Down Expand Up @@ -414,5 +466,5 @@ def test_json_mode_writes_error_json_to_stdout(
payload = json.loads(result.stdout)
assert payload["detail"] == "Invalid input."
assert payload["meta"]["code"] == 422
assert "Beginning authentication for the testorg org" in result.stderr
assert "Beginning authentication for the testorg Workspace" in result.stderr
assert "Beginning authentication for the testorg org" not in result.stdout
2 changes: 2 additions & 0 deletions cloudsmith_cli/cli/tests/commands/test_domains.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ def test_document_has_one_schema_for_both_kinds_of_host(runner, monkeypatch):
"format": "python",
"type": "custom",
"domain_type": "native_api",
"workspace": "acme",
"org": "acme",
"repository": None,
"primary": True,
Expand All @@ -132,6 +133,7 @@ def test_document_has_one_schema_for_both_kinds_of_host(runner, monkeypatch):
"format": "python",
"type": "default",
"domain_type": "native_api",
"workspace": None,
"org": None,
"repository": None,
"primary": True,
Expand Down
Loading
Loading