From b3941773b808d106b599120ec84a50da3468e063 Mon Sep 17 00:00:00 2001 From: Ian Duffy Date: Wed, 26 Aug 2026 21:57:15 +0100 Subject: [PATCH 1/3] feat(cli): add Workspace option aliases Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 1 + .../cli/commands/credential_helper/cargo.py | 6 ++--- .../cli/commands/credential_helper/docker.py | 6 ++--- cloudsmith_cli/cli/commands/domains.py | 22 +++++++++---------- cloudsmith_cli/cli/decorators.py | 4 +++- cloudsmith_cli/cli/tests/test_org_option.py | 15 +++++++------ 6 files changed, 29 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b87e660..80058d07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ### Added +- Added `-w` and `--workspace` as aliases for the shared Workspace option used by OIDC and custom-domain discovery. The existing `--org`, `--organization`, and `--oidc-org` spellings remain available for compatibility. - 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. diff --git a/cloudsmith_cli/cli/commands/credential_helper/cargo.py b/cloudsmith_cli/cli/commands/credential_helper/cargo.py index f40dd2d4..7a334739 100644 --- a/cloudsmith_cli/cli/commands/credential_helper/cargo.py +++ b/cloudsmith_cli/cli/commands/credential_helper/cargo.py @@ -35,8 +35,8 @@ 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 + ``*.cloudsmith.com``, and any custom domains configured for the Workspace + (requires a Workspace - ``--workspace``, CLOUDSMITH_ORG or ``org`` in ``config.ini`` - and a valid API key/token). A registry that is not a Cloudsmith one is answered with @@ -74,7 +74,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_ORG: Workspace slug (required for custom domain support) """ # `provider_args` collects the extra arguments Cargo appends from the # credential-provider config entry. This provider takes no configuration diff --git a/cloudsmith_cli/cli/commands/credential_helper/docker.py b/cloudsmith_cli/cli/commands/credential_helper/docker.py index 7c34f8a7..6364ddc5 100644 --- a/cloudsmith_cli/cli/commands/credential_helper/docker.py +++ b/cloudsmith_cli/cli/commands/credential_helper/docker.py @@ -28,8 +28,8 @@ 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 + ``*.cloudsmith.com``, and any custom domains configured for the Workspace + (requires a Workspace - ``--workspace``, CLOUDSMITH_ORG or ``org`` in ``config.ini`` - and a valid API key/token). Input (stdin): @@ -51,7 +51,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_ORG: Workspace slug (required for custom domain support) """ exit_code, stdout, stderr = execute( operation, diff --git a/cloudsmith_cli/cli/commands/domains.py b/cloudsmith_cli/cli/commands/domains.py index f6647255..0896e464 100644 --- a/cloudsmith_cli/cli/commands/domains.py +++ b/cloudsmith_cli/cli/commands/domains.py @@ -133,19 +133,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`` + Built-in hosts are always listed and need no Workspace or authentication. + A Workspace from ``--workspace``, 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 + 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 organisation. + 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 @@ -177,16 +177,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 = { @@ -257,8 +257,8 @@ 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, " + "Warning: --refresh needs a Workspace to fetch from, so the " + "cached custom domains below are unchanged. Set --workspace, " "CLOUDSMITH_ORG or org in config.ini.", fg="yellow", err=True, diff --git a/cloudsmith_cli/cli/decorators.py b/cloudsmith_cli/cli/decorators.py index 9012ab1d..bd897be4 100644 --- a/cloudsmith_cli/cli/decorators.py +++ b/cloudsmith_cli/cli/decorators.py @@ -436,12 +436,14 @@ def resolve_credentials(f): help="The OIDC audience for token requests.", ) @click.option( + "-w", + "--workspace", "--org", "--organization", "--oidc-org", "org", envvar="CLOUDSMITH_ORG", - help="The Cloudsmith organisation slug.", + help="The Cloudsmith Workspace slug.", ) @click.option( "--oidc-service-slug", diff --git a/cloudsmith_cli/cli/tests/test_org_option.py b/cloudsmith_cli/cli/tests/test_org_option.py index e44c7957..8cc4417b 100644 --- a/cloudsmith_cli/cli/tests/test_org_option.py +++ b/cloudsmith_cli/cli/tests/test_org_option.py @@ -1,10 +1,9 @@ # Copyright 2026 Cloudsmith Ltd -"""Tests for the organisation option and its accepted spellings. +"""Tests for the Workspace option and its accepted spellings. -The organisation is named three ways for historical reasons: ``--org`` is the -name to use, ``--organization`` reads naturally in scripts, and ``--oidc-org`` -is what the option was called when only OIDC token exchange consumed it. All -three are one option, so a consumer never has to know which era it came from. +``--workspace`` and ``-w`` use current Cloudsmith terminology. ``--org``, +``--organization``, and ``--oidc-org`` remain available for compatibility. +All five flags are one option. """ import click @@ -59,9 +58,11 @@ def test_every_config_spelling_sets_the_one_organisation(config_file, key): assert opts.oidc_org == "acme" -@pytest.mark.parametrize("flag", ["--org", "--organization", "--oidc-org"]) +@pytest.mark.parametrize( + "flag", ["--workspace", "-w", "--org", "--organization", "--oidc-org"] +) def test_every_flag_sets_the_organisation(org_reporting_command, flag): - """All three flags are one option, so any of them reaches ``opts.org``.""" + """Every flag reaches the existing internal ``opts.org`` value.""" result = click.testing.CliRunner().invoke(org_reporting_command, [flag, "acme"]) assert result.exit_code == 0, result.output From 57ac82a3efb3b39dcb797a08943e9328db445fc5 Mon Sep 17 00:00:00 2001 From: Ian Duffy Date: Thu, 27 Aug 2026 06:27:25 +0100 Subject: [PATCH 2/3] feat(cli): prefer Workspace configuration Unify auth with the shared Workspace option, retain all legacy aliases, and prefer CLOUDSMITH_WORKSPACE and the workspace config key. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 2 +- cloudsmith_cli/cli/commands/auth.py | 23 +++---- .../cli/commands/credential_helper/cargo.py | 7 +- .../cli/commands/credential_helper/docker.py | 7 +- .../cli/commands/credential_helper/generic.py | 2 +- .../cli/commands/credential_helper/pnpm.py | 2 +- cloudsmith_cli/cli/commands/domains.py | 13 ++-- cloudsmith_cli/cli/config.py | 22 ++++++ cloudsmith_cli/cli/decorators.py | 4 +- .../cli/tests/commands/test_auth.py | 58 +++++++++++++++- cloudsmith_cli/cli/tests/test_org_option.py | 69 +++++++++++++++++-- .../credentials/providers/oidc_provider.py | 9 +-- cloudsmith_cli/credential_helpers/common.py | 5 +- cloudsmith_cli/data/config.ini | 3 + 14 files changed, 179 insertions(+), 47 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 80058d07..2db60d7f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ### Added -- Added `-w` and `--workspace` as aliases for the shared Workspace option used by OIDC and custom-domain discovery. The existing `--org`, `--organization`, and `--oidc-org` spellings remain available for compatibility. +- Added `-w` and `--workspace` as aliases for the shared Workspace option used by authentication, OIDC, and custom-domain discovery. `CLOUDSMITH_WORKSPACE` and `workspace` in `config.ini` are now the preferred environment variable and configuration key, with `CLOUDSMITH_ORG` and `org` retained as fallbacks. The existing `-o`, `--owner`, `--org`, `--organization`, and `--oidc-org` option spellings remain available for compatibility. - 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. diff --git a/cloudsmith_cli/cli/commands/auth.py b/cloudsmith_cli/cli/commands/auth.py index 0955b4c7..1bfbd599 100644 --- a/cloudsmith_cli/cli/commands/auth.py +++ b/cloudsmith_cli/cli/commands/auth.py @@ -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) @@ -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", @@ -132,7 +123,6 @@ def _perform_saml_authentication( def authenticate( ctx, opts, - owner, token, force, save_config, @@ -140,7 +130,7 @@ def authenticate( 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( @@ -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, ) @@ -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, diff --git a/cloudsmith_cli/cli/commands/credential_helper/cargo.py b/cloudsmith_cli/cli/commands/credential_helper/cargo.py index 7a334739..b78e0838 100644 --- a/cloudsmith_cli/cli/commands/credential_helper/cargo.py +++ b/cloudsmith_cli/cli/commands/credential_helper/cargo.py @@ -36,8 +36,9 @@ def cargo(opts, cargo_plugin, provider_args): # pylint: disable=unused-argument Provides credentials for all Cloudsmith Cargo registries: ``*.cloudsmith.io``, ``*.cloudsmith.com``, and any custom domains configured for the Workspace - (requires a Workspace - ``--workspace``, CLOUDSMITH_ORG or ``org`` in - ``config.ini`` - and a valid API key/token). + (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 @@ -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: Workspace 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 diff --git a/cloudsmith_cli/cli/commands/credential_helper/docker.py b/cloudsmith_cli/cli/commands/credential_helper/docker.py index 6364ddc5..25d3d81f 100644 --- a/cloudsmith_cli/cli/commands/credential_helper/docker.py +++ b/cloudsmith_cli/cli/commands/credential_helper/docker.py @@ -29,8 +29,9 @@ def docker(opts, operation): Provides credentials for all Cloudsmith Docker registries: ``*.cloudsmith.io``, ``*.cloudsmith.com``, and any custom domains configured for the Workspace - (requires a Workspace - ``--workspace``, CLOUDSMITH_ORG or ``org`` in - ``config.ini`` - and a valid API key/token). + (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") @@ -51,7 +52,7 @@ def docker(opts, operation): Environment variables: CLOUDSMITH_API_KEY: API key for authentication (optional) - CLOUDSMITH_ORG: Workspace slug (required for custom domain support) + CLOUDSMITH_WORKSPACE: Workspace slug (CLOUDSMITH_ORG is also accepted) """ exit_code, stdout, stderr = execute( operation, diff --git a/cloudsmith_cli/cli/commands/credential_helper/generic.py b/cloudsmith_cli/cli/commands/credential_helper/generic.py index e5f1356f..db98b17d 100644 --- a/cloudsmith_cli/cli/commands/credential_helper/generic.py +++ b/cloudsmith_cli/cli/commands/credential_helper/generic.py @@ -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 diff --git a/cloudsmith_cli/cli/commands/credential_helper/pnpm.py b/cloudsmith_cli/cli/commands/credential_helper/pnpm.py index 55654c7a..bfe9866a 100644 --- a/cloudsmith_cli/cli/commands/credential_helper/pnpm.py +++ b/cloudsmith_cli/cli/commands/credential_helper/pnpm.py @@ -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( diff --git a/cloudsmith_cli/cli/commands/domains.py b/cloudsmith_cli/cli/commands/domains.py index 0896e464..44cf8b4e 100644 --- a/cloudsmith_cli/cli/commands/domains.py +++ b/cloudsmith_cli/cli/commands/domains.py @@ -141,11 +141,12 @@ def list_domains( # pylint: disable=too-many-arguments missing here. Built-in hosts are always listed and need no Workspace or authentication. - A Workspace from ``--workspace``, 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 Workspace the command lists whatever - earlier runs cached and makes no API call; ``--refresh`` bypasses that cache - for a configured Workspace. + A Workspace from ``--workspace``, CLOUDSMITH_WORKSPACE, CLOUDSMITH_ORG, + ``workspace`` in ``config.ini`` or a legacy config alias 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 @@ -259,7 +260,7 @@ def list_domains( # pylint: disable=too-many-arguments click.secho( "Warning: --refresh needs a Workspace to fetch from, so the " "cached custom domains below are unchanged. Set --workspace, " - "CLOUDSMITH_ORG or org in config.ini.", + "CLOUDSMITH_WORKSPACE, or workspace in config.ini.", fg="yellow", err=True, ) diff --git a/cloudsmith_cli/cli/config.py b/cloudsmith_cli/cli/config.py index 7281121e..271da3e9 100644 --- a/cloudsmith_cli/cli/config.py +++ b/cloudsmith_cli/cli/config.py @@ -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) @@ -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): @@ -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.""" diff --git a/cloudsmith_cli/cli/decorators.py b/cloudsmith_cli/cli/decorators.py index bd897be4..a76305e8 100644 --- a/cloudsmith_cli/cli/decorators.py +++ b/cloudsmith_cli/cli/decorators.py @@ -441,8 +441,10 @@ def resolve_credentials(f): "--org", "--organization", "--oidc-org", + "-o", + "--owner", "org", - envvar="CLOUDSMITH_ORG", + envvar=["CLOUDSMITH_WORKSPACE", "CLOUDSMITH_ORG"], help="The Cloudsmith Workspace slug.", ) @click.option( diff --git a/cloudsmith_cli/cli/tests/commands/test_auth.py b/cloudsmith_cli/cli/tests/commands/test_auth.py index 68bb7616..79546de5 100644 --- a/cloudsmith_cli/cli/tests/commands/test_auth.py +++ b/cloudsmith_cli/cli/tests/commands/test_auth.py @@ -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, @@ -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() @@ -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 diff --git a/cloudsmith_cli/cli/tests/test_org_option.py b/cloudsmith_cli/cli/tests/test_org_option.py index 8cc4417b..518f4a5f 100644 --- a/cloudsmith_cli/cli/tests/test_org_option.py +++ b/cloudsmith_cli/cli/tests/test_org_option.py @@ -2,8 +2,8 @@ """Tests for the Workspace option and its accepted spellings. ``--workspace`` and ``-w`` use current Cloudsmith terminology. ``--org``, -``--organization``, and ``--oidc-org`` remain available for compatibility. -All five flags are one option. +``--organization``, ``--oidc-org``, ``--owner``, and ``-o`` remain available +for compatibility. All seven flags are one option. """ import click @@ -35,6 +35,7 @@ def org_reporting_command(monkeypatch): The Options object is a process-wide thread-local, so it is cleared per test to stop one invocation's organisation leaking into the next. """ + monkeypatch.delenv("CLOUDSMITH_WORKSPACE", raising=False) monkeypatch.delenv("CLOUDSMITH_ORG", raising=False) monkeypatch.delattr(OPTIONS, "value", raising=False) @@ -47,19 +48,61 @@ def report(ctx, opts): # pylint: disable=unused-argument return report -@pytest.mark.parametrize("key", ["org", "organization", "oidc_org"]) +@pytest.mark.parametrize("key", ["workspace", "org", "organization", "oidc_org"]) def test_every_config_spelling_sets_the_one_organisation(config_file, key): """The aliases are one value in config.ini, not three independent settings.""" opts = Options() opts.load_config_file(config_file(f"[default]\n{key} = acme\n")) assert opts.org == "acme" + assert opts.workspace == "acme" assert opts.organization == "acme" assert opts.oidc_org == "acme" +def test_workspace_config_takes_precedence_over_legacy_aliases(config_file): + """The current config key wins regardless of the order used in the file.""" + opts = Options() + opts.load_config_file( + config_file( + "[default]\n" + "workspace = preferred-workspace\n" + "org = legacy-org\n" + "organization = legacy-organization\n" + "oidc_org = legacy-oidc-org\n" + ) + ) + + assert opts.workspace == "preferred-workspace" + assert opts.org == "preferred-workspace" + + +@pytest.mark.parametrize("empty_workspace", ["", '""', "''", '" "']) +def test_empty_workspace_config_does_not_mask_legacy_alias( + config_file, empty_workspace +): + """An empty preferred key allows a populated compatibility key to apply.""" + opts = Options() + opts.load_config_file( + config_file( + f"[default]\nworkspace = {empty_workspace}\norg = legacy-workspace\n" + ) + ) + + assert opts.workspace == "legacy-workspace" + + @pytest.mark.parametrize( - "flag", ["--workspace", "-w", "--org", "--organization", "--oidc-org"] + "flag", + [ + "--workspace", + "-w", + "--org", + "--organization", + "--oidc-org", + "--owner", + "-o", + ], ) def test_every_flag_sets_the_organisation(org_reporting_command, flag): """Every flag reaches the existing internal ``opts.org`` value.""" @@ -69,11 +112,23 @@ def test_every_flag_sets_the_organisation(org_reporting_command, flag): assert "org=acme" in result.output -def test_environment_sets_the_organisation(org_reporting_command, monkeypatch): - """CLOUDSMITH_ORG is unchanged by the rename, and is still honoured.""" - monkeypatch.setenv("CLOUDSMITH_ORG", "acme-from-env") +@pytest.mark.parametrize("envvar", ["CLOUDSMITH_WORKSPACE", "CLOUDSMITH_ORG"]) +def test_environment_sets_the_organisation(org_reporting_command, monkeypatch, envvar): + """Both environment-variable spellings set the internal organisation value.""" + monkeypatch.setenv(envvar, "acme-from-env") result = click.testing.CliRunner().invoke(org_reporting_command, []) assert result.exit_code == 0, result.output assert "org=acme-from-env" in result.output + + +def test_workspace_environment_takes_precedence(org_reporting_command, monkeypatch): + """Current Workspace terminology wins when both environment aliases are set.""" + monkeypatch.setenv("CLOUDSMITH_ORG", "legacy-org") + monkeypatch.setenv("CLOUDSMITH_WORKSPACE", "preferred-workspace") + + result = click.testing.CliRunner().invoke(org_reporting_command, []) + + assert result.exit_code == 0, result.output + assert "org=preferred-workspace" in result.output diff --git a/cloudsmith_cli/core/credentials/providers/oidc_provider.py b/cloudsmith_cli/core/credentials/providers/oidc_provider.py index 680571b6..62d81d8e 100644 --- a/cloudsmith_cli/core/credentials/providers/oidc_provider.py +++ b/cloudsmith_cli/core/credentials/providers/oidc_provider.py @@ -13,9 +13,9 @@ class OidcProvider(CredentialProvider): """Resolves credentials via OIDC auto-discovery. - Requires CLOUDSMITH_ORG and CLOUDSMITH_SERVICE_SLUG to be set (via env - vars or click options). Auto-detects the environment, fetches the vendor - OIDC JWT, and exchanges it for a short-lived Cloudsmith API token. + Requires a Workspace and CLOUDSMITH_SERVICE_SLUG to be set (via environment + variables or Click options). Auto-detects the environment, fetches the + vendor OIDC JWT, and exchanges it for a short-lived Cloudsmith API token. """ name = "oidc" @@ -37,7 +37,8 @@ def resolve( # pylint: disable=too-many-return-statements if not org or not service_slug: if context.debug: logger.debug( - "OidcProvider: CLOUDSMITH_ORG and/or CLOUDSMITH_SERVICE_SLUG " + "OidcProvider: CLOUDSMITH_WORKSPACE (or CLOUDSMITH_ORG) " + "and/or CLOUDSMITH_SERVICE_SLUG " "not set, skipping OIDC auto-discovery" ) return None diff --git a/cloudsmith_cli/credential_helpers/common.py b/cloudsmith_cli/credential_helpers/common.py index 17db3dca..e036799a 100644 --- a/cloudsmith_cli/credential_helpers/common.py +++ b/cloudsmith_cli/credential_helpers/common.py @@ -62,8 +62,9 @@ def is_cloudsmith_domain( backend_kind: If given, custom domains only match when their backend_kind equals it (standard *.cloudsmith.io domains always match regardless). When None (default), any enabled+validated custom domain matches. - org: Organisation slug whose custom domains to match against, as the - CLI resolved it from --org, CLOUDSMITH_ORG or config.ini + org: Workspace slug whose custom domains to match against, as the CLI + resolved it from --workspace, CLOUDSMITH_WORKSPACE, + CLOUDSMITH_ORG, workspace in config.ini, or a legacy config alias Returns: bool: True if this is a Cloudsmith domain diff --git a/cloudsmith_cli/data/config.ini b/cloudsmith_cli/data/config.ini index e62071b9..b00ad540 100644 --- a/cloudsmith_cli/data/config.ini +++ b/cloudsmith_cli/data/config.ini @@ -12,6 +12,9 @@ api_ssl_verify=true # The user agent to use for requests (default: calculated). api_user_agent= +# The Cloudsmith Workspace slug (default: None). +workspace= + # Profile-based configuration # You can set as many additional profiles as you need to provide From f968b0cbfcdeb43539a2cb93819b8f7f27003604 Mon Sep 17 00:00:00 2001 From: Ian Duffy Date: Thu, 27 Aug 2026 06:50:24 +0100 Subject: [PATCH 3/3] Expose Workspace in domains output Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 3 ++- cloudsmith_cli/cli/commands/domains.py | 15 ++++++++------- cloudsmith_cli/cli/tests/commands/test_domains.py | 2 ++ 3 files changed, 12 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2db60d7f..18e4243b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,13 +9,14 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ### Added -- Added `-w` and `--workspace` as aliases for the shared Workspace option used by authentication, OIDC, and custom-domain discovery. `CLOUDSMITH_WORKSPACE` and `workspace` in `config.ini` are now the preferred environment variable and configuration key, with `CLOUDSMITH_ORG` and `org` retained as fallbacks. The existing `-o`, `--owner`, `--org`, `--organization`, and `--oidc-org` option spellings remain available for compatibility. +- 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 diff --git a/cloudsmith_cli/cli/commands/domains.py b/cloudsmith_cli/cli/commands/domains.py index 44cf8b4e..c4f42ae6 100644 --- a/cloudsmith_cli/cli/commands/domains.py +++ b/cloudsmith_cli/cli/commands/domains.py @@ -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, @@ -141,12 +142,11 @@ def list_domains( # pylint: disable=too-many-arguments missing here. Built-in hosts are always listed and need no Workspace or authentication. - A Workspace from ``--workspace``, CLOUDSMITH_WORKSPACE, CLOUDSMITH_ORG, - ``workspace`` in ``config.ini`` or a legacy config alias 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. + 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 @@ -164,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 @@ -230,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, diff --git a/cloudsmith_cli/cli/tests/commands/test_domains.py b/cloudsmith_cli/cli/tests/commands/test_domains.py index 19440fc3..bc877130 100644 --- a/cloudsmith_cli/cli/tests/commands/test_domains.py +++ b/cloudsmith_cli/cli/tests/commands/test_domains.py @@ -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, @@ -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,