From bbf47f1bb3170771bef56413971e5684c62b770a Mon Sep 17 00:00:00 2001 From: Ian Duffy Date: Sat, 24 Oct 2026 18:53:01 +0100 Subject: [PATCH] fix(no-ticket): write launcher settings as the text each option accepts The install command kept the parsed value of each CLOUDSMITH_* option. An enum value such as the --color choice went to shlex.quote and the install stopped with a TypeError. Now each value becomes the text that its option accepts. A list of values is not kept. The launcher writer refuses a value that is not a string, with a clear message. Co-Authored-By: Claude Opus 5.5 --- .../cli/commands/credential_helper/manage.py | 25 ++++++++++--- .../test_credential_helper_install.py | 35 +++++++++++++++++++ .../credential_helpers/launchers.py | 6 ++++ 3 files changed, 61 insertions(+), 5 deletions(-) diff --git a/cloudsmith_cli/cli/commands/credential_helper/manage.py b/cloudsmith_cli/cli/commands/credential_helper/manage.py index c570b6ad..f01ac898 100644 --- a/cloudsmith_cli/cli/commands/credential_helper/manage.py +++ b/cloudsmith_cli/cli/commands/credential_helper/manage.py @@ -147,7 +147,23 @@ def _main_envvar(option) -> str: return names if isinstance(names, str) else names[0] -def _launcher_settings_from_cli(ctx) -> dict[str, str]: +def _launcher_value( + option: click.Parameter, ctx: click.Context, value: object +) -> str | None: + """Return *value* as the text that *option* accepts, or None to skip it.""" + if isinstance(value, bool): + return str(value).lower() + # A launcher variable holds one value, so it cannot keep a list of values. + if isinstance(value, tuple): + return None + if isinstance(option.type, click.Choice): + return option.type.normalize_choice(value, ctx) + if isinstance(value, (str, int, float)): + return str(value) + return None + + +def _launcher_settings_from_cli(ctx: click.Context) -> dict[str, str]: """Return the ``CLOUDSMITH_*`` settings that a launcher keeps. An option with a ``CLOUDSMITH_*`` environment variable is kept when its @@ -169,10 +185,9 @@ def _launcher_settings_from_cli(ctx) -> dict[str, str]: envvar = _main_envvar(option) source = context.get_parameter_source(option.name) if envvar.startswith("CLOUDSMITH_") and source in supplied: - value = context.params[option.name] - settings[envvar] = ( - str(value).lower() if isinstance(value, bool) else value - ) + value = _launcher_value(option, context, context.params[option.name]) + if value is not None: + settings[envvar] = value return settings diff --git a/cloudsmith_cli/cli/tests/commands/test_credential_helper_install.py b/cloudsmith_cli/cli/tests/commands/test_credential_helper_install.py index 914dd7b9..296f5b37 100644 --- a/cloudsmith_cli/cli/tests/commands/test_credential_helper_install.py +++ b/cloudsmith_cli/cli/tests/commands/test_credential_helper_install.py @@ -17,7 +17,9 @@ from cloudsmith_cli.credential_helpers.generic import PartialInstallError from cloudsmith_cli.credential_helpers.pnpm.installer import PNPMInstaller +from ....cli.commands.credential_helper.manage import _launcher_settings_from_cli from ....core.credentials.models import CredentialResult +from ....core.utils import ColorMode from ....credential_helpers.docker.installer import DockerInstaller from ....credential_helpers.launchers import ( UnsafeLauncherValue, @@ -136,6 +138,39 @@ def test_windows_launcher_refuses_a_value_a_batch_file_cannot_hold(monkeypatch, _launcher_content("cmd", windows=True, env={"CLOUDSMITH_PROFILE": value}) +def test_launcher_refuses_a_value_that_is_not_a_string(): + with pytest.raises(TypeError, match="CLOUDSMITH_COLOR"): + _launcher_content( + "cmd", windows=False, env={"CLOUDSMITH_COLOR": ColorMode.NEVER} + ) + + +@pytest.mark.parametrize( + "option_kwargs,value,kept", + [ + ({"type": click.Choice(ColorMode, case_sensitive=False)}, "NEVER", "never"), + ({"type": int}, "3", "3"), + ({"type": click.Choice(["a", "b"]), "multiple": True}, "a", None), + ], +) +def test_launcher_keeps_a_setting_as_the_text_its_option_accepts( + runner, option_kwargs, value, kept +): + """A launcher sets one text value per variable, so it skips a list of values.""" + + @click.command() + @click.option("--setting", envvar="CLOUDSMITH_SETTING", **option_kwargs) + @click.pass_context + def command(ctx, setting): + click.echo(json.dumps(_launcher_settings_from_cli(ctx))) + + result = runner.invoke(command, ["--setting", value], catch_exceptions=False) + + assert json.loads(result.stdout) == ( + {} if kept is None else {"CLOUDSMITH_SETTING": kept} + ) + + def test_install_passes_flag_and_environment_settings_to_the_launcher( runner, monkeypatch, tmp_path ): diff --git a/cloudsmith_cli/credential_helpers/launchers.py b/cloudsmith_cli/credential_helpers/launchers.py index ceabd26d..eebc7425 100644 --- a/cloudsmith_cli/credential_helpers/launchers.py +++ b/cloudsmith_cli/credential_helpers/launchers.py @@ -99,6 +99,12 @@ def _launcher_content( same result as the install. """ env = env or {} + for name, value in env.items(): + if not isinstance(value, str): + raise TypeError( + f"{name} {value!r} cannot go into the launcher: the value must " + f"be a string, not {type(value).__name__}." + ) if windows: settings = "".join( f'set "{name}={batch_value(name, value)}"\r\n'