From cbb6c35149cc4d82b250989b6fb1d8fde06d77d6 Mon Sep 17 00:00:00 2001 From: Ian Duffy Date: Sat, 13 Jun 2026 01:09:32 +0100 Subject: [PATCH] feat: add Maven shell-plugin credential helper Maven has no native credential-helper protocol, so add a shell-plugin approach: a `mvn` shim (placed first on PATH via `credential-helper shell-init`) transparently forwards to the generic top-level `cloudsmith exec`, which provisions an ephemeral settings.xml from the resolved credential (API key or OIDC), runs the real `mvn`, and cleans up. - `cloudsmith exec -- `: runs a package-manager command authenticated against Cloudsmith. The plugin is inferred from the command's binary name; unmatched commands run unchanged. Resolves the real binary while excluding the shims dir to avoid recursion, and cleans up temp dirs (even on failure, with provisioning errors surfaced as a clean message, never a traceback). - `credential-helper install maven --org --repo [--registry-id]` and `credential-helper shell-init`, wired into the existing installer registry. - One injected authenticates both dependency resolution (download CDN) and distributionManagement (native Maven upload); Maven matches it by id. Install prints the distributionManagement snippet for opt-in deploy. - Custom Cloudsmith domains are org-scoped via a reusable common.repo_path_segment (drop for custom domains, keep it for *.cloudsmith.io); download custom domains carry backend_kind=None, upload custom domains carry BackendKind.MAVEN. - settings.xml / distributionManagement live in cloudsmith_cli/templates/*.tmpl rendered via a shared templates.render() (also adopted by webserver.py). - Shell-plugin state is stored as [package-manager:] sections in package-managers.ini in the CLI config dir, via configparser. - The `--registry-id` flag and the shared PluginEntry field use a package-manager-neutral name (Maven id, NuGet source key, etc.). Co-Authored-By: Claude Opus 4.8 (1M context) --- cloudsmith_cli/cli/commands/__init__.py | 1 + .../commands/credential_helper/__init__.py | 2 + .../cli/commands/credential_helper/manage.py | 45 +- .../cli/commands/credential_helper/shell.py | 36 + cloudsmith_cli/cli/commands/exec_.py | 41 + .../commands/test_credential_helper_maven.py | 841 ++++++++++++++++++ cloudsmith_cli/cli/webserver.py | 28 +- cloudsmith_cli/credential_helpers/common.py | 32 +- .../credential_helpers/maven/__init__.py | 2 + .../credential_helpers/maven/installer.py | 236 +++++ .../shellplugin/__init__.py | 2 + .../credential_helpers/shellplugin/config.py | 113 +++ .../credential_helpers/shellplugin/maven.py | 90 ++ .../credential_helpers/shellplugin/plugin.py | 70 ++ .../credential_helpers/shellplugin/runner.py | 122 +++ .../shellplugin/shellinit.py | 49 + .../credential_helpers/shellplugin/shims.py | 30 + cloudsmith_cli/templates/__init__.py | 28 +- .../maven_distribution_management.xml.tmpl | 10 + .../templates/maven_settings.xml.tmpl | 29 + 20 files changed, 1770 insertions(+), 37 deletions(-) create mode 100644 cloudsmith_cli/cli/commands/credential_helper/shell.py create mode 100644 cloudsmith_cli/cli/commands/exec_.py create mode 100644 cloudsmith_cli/cli/tests/commands/test_credential_helper_maven.py create mode 100644 cloudsmith_cli/credential_helpers/maven/__init__.py create mode 100644 cloudsmith_cli/credential_helpers/maven/installer.py create mode 100644 cloudsmith_cli/credential_helpers/shellplugin/__init__.py create mode 100644 cloudsmith_cli/credential_helpers/shellplugin/config.py create mode 100644 cloudsmith_cli/credential_helpers/shellplugin/maven.py create mode 100644 cloudsmith_cli/credential_helpers/shellplugin/plugin.py create mode 100644 cloudsmith_cli/credential_helpers/shellplugin/runner.py create mode 100644 cloudsmith_cli/credential_helpers/shellplugin/shellinit.py create mode 100644 cloudsmith_cli/credential_helpers/shellplugin/shims.py create mode 100644 cloudsmith_cli/templates/maven_distribution_management.xml.tmpl create mode 100644 cloudsmith_cli/templates/maven_settings.xml.tmpl diff --git a/cloudsmith_cli/cli/commands/__init__.py b/cloudsmith_cli/cli/commands/__init__.py index 634add8f..42ed166a 100644 --- a/cloudsmith_cli/cli/commands/__init__.py +++ b/cloudsmith_cli/cli/commands/__init__.py @@ -10,6 +10,7 @@ docs, download, entitlements, + exec_, help_, list_, login, diff --git a/cloudsmith_cli/cli/commands/credential_helper/__init__.py b/cloudsmith_cli/cli/commands/credential_helper/__init__.py index 93e5feae..a7065158 100644 --- a/cloudsmith_cli/cli/commands/credential_helper/__init__.py +++ b/cloudsmith_cli/cli/commands/credential_helper/__init__.py @@ -11,6 +11,7 @@ from ..main import main from .docker import docker as docker_cmd from .manage import install_cmd, list_cmd, uninstall_cmd +from .shell import shell_init @click.group() @@ -32,6 +33,7 @@ def credential_helper(): credential_helper.add_command(docker_cmd, name="docker") +credential_helper.add_command(shell_init, name="shell-init") credential_helper.add_command(install_cmd, name="install") credential_helper.add_command(uninstall_cmd, name="uninstall") credential_helper.add_command(list_cmd, name="list") diff --git a/cloudsmith_cli/cli/commands/credential_helper/manage.py b/cloudsmith_cli/cli/commands/credential_helper/manage.py index 54d3a3ae..989dc24f 100644 --- a/cloudsmith_cli/cli/commands/credential_helper/manage.py +++ b/cloudsmith_cli/cli/commands/credential_helper/manage.py @@ -8,12 +8,13 @@ from __future__ import annotations -import os import sys import click from ....credential_helpers.docker.installer import DockerInstaller +from ....credential_helpers.maven.installer import MavenInstaller +from ....credential_helpers.shellplugin.config import DEFAULT_REGISTRY_ID from ... import utils from ...decorators import ( common_api_auth_options, @@ -28,6 +29,7 @@ _INSTALLERS: dict[str, type] = { "docker": DockerInstaller, + "maven": MavenInstaller, } @@ -86,7 +88,7 @@ def _get_installer(name: str): "--no-discover", is_flag=True, default=False, - help="Disable automatic discovery of custom Docker domains.", + help="Disable automatic discovery of custom domains.", ) @click.option( "--refresh", @@ -97,7 +99,21 @@ def _get_installer(name: str): @click.option( "--org", default=None, - help="Cloudsmith organisation slug for custom-domain discovery.", + envvar="CLOUDSMITH_ORG", + help="Cloudsmith organisation slug.", +) +@click.option( + "--repo", + default=None, + envvar="CLOUDSMITH_REPO", + help="Cloudsmith repository slug.", +) +@click.option( + "--registry-id", + default=DEFAULT_REGISTRY_ID, + show_default=True, + help="Id the credentials are registered under in your project config " + "(e.g. the Maven settings.xml/pom.xml id).", ) @common_cli_config_options @common_cli_output_options @@ -114,6 +130,8 @@ def install_cmd( no_discover: bool, refresh: bool, org: str | None, + repo: str | None, + registry_id: str, ) -> None: """Install a credential helper launcher and configure the package manager. @@ -138,16 +156,29 @@ def install_cmd( $ cloudsmith credential-helper install docker --no-discover """ installer = _get_installer(helper) - org = org or os.environ.get("CLOUDSMITH_ORG", "").strip() or None api_key = opts.credential.api_key if opts.credential else None auth_type = ( getattr(opts.credential, "auth_type", "api_key") if opts.credential else "api_key" ) + + per_repo = getattr(installer, "requires_repo", False) + if per_repo and not repo: + click.echo( + f"Error: helper {helper!r} requires --repo (and --org).", + err=True, + ) + sys.exit(1) + + # Shell plugins (per-repo) keep their shims in a fixed dir; --bin-dir only + # applies to launcher-based helpers like Docker. + if per_repo: + extra: dict = {"repo": repo, "registry_id": registry_id} + else: + extra = {"bin_dir": bin_dir} try: actions = installer.install( - bin_dir=bin_dir, domains=domains, dry_run=dry_run, discover=not no_discover, @@ -156,6 +187,7 @@ def install_cmd( api_key=api_key, auth_type=auth_type, api_host=opts.api_host, + **extra, ) except OSError as exc: raise click.ClickException( @@ -217,8 +249,9 @@ def uninstall_cmd(ctx, opts, helper: str, bin_dir: str | None, dry_run: bool) -> $ cloudsmith credential-helper uninstall docker --dry-run """ installer = _get_installer(helper) + extra = {} if getattr(installer, "requires_repo", False) else {"bin_dir": bin_dir} try: - actions = installer.uninstall(bin_dir=bin_dir, dry_run=dry_run) + actions = installer.uninstall(dry_run=dry_run, **extra) except OSError as exc: raise click.ClickException( f"Failed to uninstall {helper!r} credential helper: {exc}" diff --git a/cloudsmith_cli/cli/commands/credential_helper/shell.py b/cloudsmith_cli/cli/commands/credential_helper/shell.py new file mode 100644 index 00000000..4e9fdacf --- /dev/null +++ b/cloudsmith_cli/cli/commands/credential_helper/shell.py @@ -0,0 +1,36 @@ +# Copyright 2026 Cloudsmith Ltd +"""``cloudsmith credential-helper shell-init`` — print shell init for shims. + +Add ``eval "$(cloudsmith credential-helper shell-init)"`` to your shell rc file +to put the Cloudsmith shims directory ahead of the real package-manager +binaries on ``$PATH``. +""" + +import click + +from ....credential_helpers.shellplugin.shellinit import detect_shell, generate_init + + +@click.command(name="shell-init") +@click.option( + "--shell", + "shell_name", + type=click.Choice(["bash", "zsh", "fish"]), + default=None, + help="Target shell. Auto-detected from $SHELL when omitted.", +) +def shell_init(shell_name): + """Print shell init that puts the Cloudsmith shims dir first on PATH. + + Examples: + + \b + # bash / zsh + $ eval "$(cloudsmith credential-helper shell-init)" + + \b + # fish + $ cloudsmith credential-helper shell-init --shell fish | source + """ + shell = shell_name or detect_shell() + click.echo(generate_init(shell), nl=False) diff --git a/cloudsmith_cli/cli/commands/exec_.py b/cloudsmith_cli/cli/commands/exec_.py new file mode 100644 index 00000000..7d1d806a --- /dev/null +++ b/cloudsmith_cli/cli/commands/exec_.py @@ -0,0 +1,41 @@ +# Copyright 2026 Cloudsmith Ltd +"""CLI/Commands - Run a command with Cloudsmith credentials provisioned.""" + +import sys + +import click + +from ...credential_helpers.shellplugin import runner +from ..decorators import common_api_auth_options, resolve_credentials +from .main import main + + +@main.command(name="exec", context_settings={"ignore_unknown_options": True}) +@click.option( + "--org", default=None, envvar="CLOUDSMITH_ORG", help="Cloudsmith organisation slug." +) +@click.option( + "--repo", default=None, envvar="CLOUDSMITH_REPO", help="Cloudsmith repository slug." +) +@click.argument("command", nargs=-1, type=click.UNPROCESSED, required=True) +@common_api_auth_options +@resolve_credentials +def exec_(opts, org, repo, command): + """Run a package-manager command authenticated against Cloudsmith. + + Wraps the command so it resolves dependencies from (and publishes to) your + Cloudsmith repository, with credentials injected for that single run and + cleaned up afterwards. The package manager is detected automatically from + the command, so just put it after ``--``: + + \b + $ cloudsmith exec -- mvn clean deploy + """ + exit_code = runner.run( + list(command), + credential=opts.credential, + owner=org, + repo=repo, + api_host=opts.api_host, + ) + sys.exit(exit_code) diff --git a/cloudsmith_cli/cli/tests/commands/test_credential_helper_maven.py b/cloudsmith_cli/cli/tests/commands/test_credential_helper_maven.py new file mode 100644 index 00000000..f050e04d --- /dev/null +++ b/cloudsmith_cli/cli/tests/commands/test_credential_helper_maven.py @@ -0,0 +1,841 @@ +# Copyright 2026 Cloudsmith Ltd +"""Tests for the Maven shell-plugin credential helper (config, settings.xml, +shims, shell-init, runner, installer, and CLI wiring).""" + +from __future__ import annotations + +import os +from pathlib import Path + +import pytest + +from ....credential_helpers.shellplugin import config as plugin_config + +# --------------------------------------------------------------------------- +# 1. config — PluginEntry + plugins.json +# --------------------------------------------------------------------------- + + +@pytest.fixture() +def _home(tmp_path, monkeypatch): + """Point the CLI config dir at a tmp dir so plugins.json/shims land under it.""" + monkeypatch.setattr( + "cloudsmith_cli.credential_helpers.shellplugin.config.get_default_config_path", + lambda: str(tmp_path), + ) + return tmp_path + + +def test_config_path_and_shims_dir_in_config_dir(_home): + """config_path() and shims_dir() live in the CLI config directory.""" + assert plugin_config.config_path() == _home / "package-managers.ini" + assert plugin_config.shims_dir() == _home / "shims" + + +def test_load_plugins_missing_file_returns_empty(_home): + """load_plugins() returns {} when plugins.json does not exist.""" + assert plugin_config.load_plugins() == {} + + +def test_set_then_get_plugin_roundtrip(_home): + """set_plugin persists every field; get_plugin reads it back.""" + entry = plugin_config.PluginEntry( + owner="acme", + repo="prod", + api_host="https://api.cloudsmith.io", + cdn_host="dl.cloudsmith.io", + upload_host="maven.cloudsmith.io", + registry_id="cloudsmith", + ) + plugin_config.set_plugin("maven", entry) + + loaded = plugin_config.get_plugin("maven") + assert loaded == entry + # Persisted on disk as an INI section in the CLI config dir. + text = plugin_config.config_path().read_text(encoding="utf-8") + assert "[package-manager:maven]" in text + assert "owner = acme" in text + assert "cdn_host = dl.cloudsmith.io" in text + + +def test_get_plugin_absent_returns_none(_home): + """get_plugin returns None for a format with no entry.""" + assert plugin_config.get_plugin("maven") is None + + +def test_remove_plugin(_home): + """remove_plugin deletes the entry (True), and is a no-op (False) afterwards.""" + plugin_config.set_plugin( + "maven", + plugin_config.PluginEntry( + owner="acme", + repo="prod", + api_host="https://api.cloudsmith.io", + cdn_host="dl.cloudsmith.io", + upload_host="maven.cloudsmith.io", + registry_id="cloudsmith", + ), + ) + assert plugin_config.remove_plugin("maven") is True + assert plugin_config.get_plugin("maven") is None + assert plugin_config.remove_plugin("maven") is False + + +def test_plugin_entry_from_dict_tolerates_missing_optionals(_home): + """PluginEntry.from_dict fills defaults for absent host/registry_id keys.""" + entry = plugin_config.PluginEntry.from_dict({"owner": "acme", "repo": "prod"}) + assert entry.owner == "acme" + assert entry.repo == "prod" + assert entry.cdn_host == "dl.cloudsmith.io" + assert entry.upload_host == "maven.cloudsmith.io" + assert entry.registry_id == "cloudsmith" + + +# --------------------------------------------------------------------------- +# 2. maven.build_settings_xml + MavenPlugin +# --------------------------------------------------------------------------- + + +def test_build_settings_xml_server_and_active_profile(): + """settings.xml carries one + an active download .""" + from ....credential_helpers.shellplugin.maven import build_settings_xml + + xml = build_settings_xml( + owner="acme", + repo="prod", + token="k_abc", + cdn_host="dl.cloudsmith.io", + server_id="cloudsmith", + ) + + assert "cloudsmith" in xml + assert "token" in xml + assert "k_abc" in xml + # Download repository + plugin repository point at the CDN basic endpoint. + assert "https://dl.cloudsmith.io/basic/acme/prod/maven/" in xml + assert "" in xml + assert "" in xml + # Profile is active by default. + assert "cloudsmith" in xml + + +def test_build_settings_xml_custom_cdn_host_is_org_scoped(): + """A custom download domain is org-scoped: the segment is dropped.""" + from ....credential_helpers.shellplugin.maven import build_settings_xml + + xml = build_settings_xml( + owner="acme", + repo="prod", + token="k_abc", + cdn_host="dl.acme.example.com", + server_id="my-cs", + ) + # Custom domain → /basic//maven/ (no ); default keeps the org. + assert "https://dl.acme.example.com/basic/prod/maven/" in xml + assert "/basic/acme/prod/maven/" not in xml + assert "my-cs" in xml + assert "my-cs" in xml + + +def test_repo_path_segment_org_scoping(): + """Standard hosts keep /; custom domains drop the org.""" + from ....credential_helpers.common import ( + is_standard_cloudsmith_host, + repo_path_segment, + ) + + assert is_standard_cloudsmith_host("dl.cloudsmith.io") is True + assert is_standard_cloudsmith_host("maven.cloudsmith.com") is True + assert is_standard_cloudsmith_host("dl-prod.iduffy.cloudsmith.sh") is False + + assert repo_path_segment("acme", "prod", "dl.cloudsmith.io") == "acme/prod" + assert repo_path_segment("acme", "prod", "dl-prod.iduffy.example.sh") == "prod" + + +def test_maven_download_url_default_keeps_org_custom_drops_it(): + """download_url keeps for dl.cloudsmith.io, drops it for custom domains.""" + from ....credential_helpers.shellplugin.maven import download_url + + assert ( + download_url("acme", "prod", "dl.cloudsmith.io") + == "https://dl.cloudsmith.io/basic/acme/prod/maven/" + ) + assert ( + download_url("acme", "prod", "dl.acme.example.com") + == "https://dl.acme.example.com/basic/prod/maven/" + ) + + +def test_maven_upload_url_default_keeps_org_custom_drops_it(): + """upload_url keeps for maven.cloudsmith.io, drops it for custom domains.""" + from ....credential_helpers.shellplugin.maven import upload_url + + assert ( + upload_url("acme", "prod", "maven.cloudsmith.io") + == "https://maven.cloudsmith.io/acme/prod/" + ) + assert ( + upload_url("acme", "prod", "maven.acme.example.com") + == "https://maven.acme.example.com/prod/" + ) + + +def test_build_settings_xml_escapes_token(): + """A token with XML metacharacters is escaped in the password element.""" + from ....credential_helpers.shellplugin.maven import build_settings_xml + + xml = build_settings_xml( + owner="acme", + repo="prod", + token="aa<b&c" in xml + assert "a Path: + directory.mkdir(parents=True, exist_ok=True) + path = directory / name + path.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") + path.chmod(0o755) + return path + + +def test_resolve_real_binary_skips_excluded_dir(tmp_path, monkeypatch): + """resolve_real_binary skips the shims dir and finds the real binary.""" + from ....credential_helpers.shellplugin.runner import resolve_real_binary + + shims = tmp_path / "shims" + real = tmp_path / "real" + _make_executable(shims, "mvn") + real_mvn = _make_executable(real, "mvn") + monkeypatch.setenv("PATH", os.pathsep.join([str(shims), str(real)])) + + resolved = resolve_real_binary("mvn", exclude_dirs=[str(shims)]) + assert resolved == str(real_mvn) + + +def test_resolve_real_binary_none_when_only_excluded(tmp_path, monkeypatch): + """resolve_real_binary returns None when the only match is excluded.""" + from ....credential_helpers.shellplugin.runner import resolve_real_binary + + shims = tmp_path / "shims" + _make_executable(shims, "mvn") + monkeypatch.setenv("PATH", str(shims)) + + assert resolve_real_binary("mvn", exclude_dirs=[str(shims)]) is None + + +def test_run_skip_auth_passes_through_without_provisioning(_home, monkeypatch): + """`mvn --version` execs the real binary directly, no settings.xml.""" + from ....core.credentials.models import CredentialResult + from ....credential_helpers.shellplugin import runner + + monkeypatch.setattr(runner, "resolve_real_binary", lambda *_a, **_k: "/usr/bin/mvn") + captured = {} + + def _fake_run_process(path, args, env): + captured["path"] = path + captured["args"] = args + return 0 + + monkeypatch.setattr(runner, "_run_process", _fake_run_process) + + code = runner.run( + ["mvn", "--version"], + credential=CredentialResult(api_key="k_abc", source_name="test"), + owner="acme", + repo="prod", + ) + + assert code == 0 + assert captured["path"] == "/usr/bin/mvn" + assert captured["args"] == ["--version"] + # No temp settings dir left behind. + assert not list(_home.glob("**/settings.xml")) + + +def test_run_provisions_prepends_s_and_cleans_up(_home, monkeypatch): + """Auth path: provisions settings.xml, prepends -s, cleans up temp dir.""" + from ....core.credentials.models import CredentialResult + from ....credential_helpers.shellplugin import config, runner + + config.set_plugin( + "maven", + config.PluginEntry( + owner="acme", + repo="prod", + api_host="https://api.cloudsmith.io", + cdn_host="dl.cloudsmith.io", + upload_host="maven.cloudsmith.io", + registry_id="cloudsmith", + ), + ) + monkeypatch.setattr(runner, "resolve_real_binary", lambda *_a, **_k: "/usr/bin/mvn") + + captured = {} + + def _fake_run_process(path, args, env): + captured["path"] = path + captured["args"] = args + # Settings file must still exist while the child runs. + captured["settings_exists"] = Path(args[1]).exists() + return 7 + + monkeypatch.setattr(runner, "_run_process", _fake_run_process) + + code = runner.run( + ["mvn", "install"], + credential=CredentialResult(api_key="k_abc", source_name="test"), + owner="acme", + repo="prod", + ) + + assert code == 7 + assert captured["args"][0] == "-s" + assert captured["args"][-1] == "install" + assert captured["settings_exists"] is True + # Temp dir cleaned up after the child exits. + assert not Path(captured["args"][1]).exists() + + +def test_run_empty_command_returns_nonzero(_home): + """exec with no command is a usage error.""" + from ....credential_helpers.shellplugin import runner + + assert runner.run([], credential=None) != 0 + + +def test_run_unmatched_command_runs_generically(_home, monkeypatch): + """A command with no matching plugin runs as-is, with no provisioning.""" + from ....credential_helpers.shellplugin import runner + + monkeypatch.setattr( + runner, "resolve_real_binary", lambda *_a, **_k: "/usr/bin/whoami" + ) + captured = {} + + def _fake_run_process(path, args, env): + captured["path"] = path + captured["args"] = args + return 0 + + monkeypatch.setattr(runner, "_run_process", _fake_run_process) + + code = runner.run(["whoami"], credential=None) + assert code == 0 + assert captured["path"] == "/usr/bin/whoami" + assert captured["args"] == [] + assert not list(_home.glob("**/settings.xml")) + + +def test_run_provision_failure_is_clean_no_traceback(_home, monkeypatch): + """A provisioning error returns a non-zero code, not a traceback.""" + from ....core.credentials.models import CredentialResult + from ....credential_helpers.shellplugin import config, plugin, runner + + config.set_plugin("maven", config.PluginEntry(owner="acme", repo="prod")) + + class _BoomPlugin: + name = "maven" + binary_name = "mvn" + + def skip_auth_args(self): + return [] + + def provision(self, *_a, **_k): + raise OSError("disk full") + + monkeypatch.setattr(plugin, "get_by_binary", lambda _b: _BoomPlugin()) + monkeypatch.setattr(runner, "resolve_real_binary", lambda *_a, **_k: "/usr/bin/mvn") + + code = runner.run( + ["mvn", "install"], + credential=CredentialResult(api_key="k", source_name="t"), + ) + assert code != 0 + + +def test_maven_provision_cleans_temp_dir_on_failure(monkeypatch, tmp_path): + """If writing settings.xml fails, provision removes its temp dir and re-raises.""" + from ....credential_helpers.shellplugin import config, maven + + leak = tmp_path / "leak-dir" + + def _fake_mkdtemp(*_a, **_k): + leak.mkdir() + return str(leak) + + def _boom(**_k): + raise OSError("boom") + + monkeypatch.setattr(maven.tempfile, "mkdtemp", _fake_mkdtemp) + monkeypatch.setattr(maven, "build_settings_xml", _boom) + + with pytest.raises(OSError): + maven.MavenPlugin().provision( + config.PluginEntry(owner="acme", repo="prod"), "tok", [] + ) + assert not leak.exists() + + +def test_run_no_token_warns_but_proceeds(_home, monkeypatch): + """No credential on an auth path still runs (public repos), with a warning.""" + from ....credential_helpers.shellplugin import config, runner + + config.set_plugin("maven", config.PluginEntry(owner="acme", repo="prod")) + monkeypatch.setattr(runner, "resolve_real_binary", lambda *_a, **_k: "/usr/bin/mvn") + captured = {} + + def _fake_run_process(path, args, env): + captured["args"] = args + return 0 + + monkeypatch.setattr(runner, "_run_process", _fake_run_process) + + code = runner.run(["mvn", "install"], credential=None) + assert code == 0 + assert captured["args"][0] == "-s" + + +def test_run_binary_not_found_returns_nonzero(_home, monkeypatch): + """When the real binary cannot be resolved, run returns non-zero.""" + from ....core.credentials.models import CredentialResult + from ....credential_helpers.shellplugin import runner + + monkeypatch.setattr(runner, "resolve_real_binary", lambda *_a, **_k: None) + code = runner.run( + ["mvn", "install"], + credential=CredentialResult(api_key="k", source_name="t"), + owner="acme", + repo="prod", + ) + assert code != 0 + + +# --------------------------------------------------------------------------- +# 6. MavenInstaller +# --------------------------------------------------------------------------- + +_INSTALLER_GET_CUSTOM_DOMAINS = ( + "cloudsmith_cli.credential_helpers.maven.installer.get_custom_domains" +) + + +def _fake_discovery(monkeypatch, *, cdn_host=None, upload_host=None, raises=False): + """Mock get_custom_domains: CDN domains carry backend_kind None; Maven == MAVEN.""" + from ....credential_helpers.backends import BackendKind + from ....credential_helpers.custom_domains import CustomDomain + + domains = [] + if cdn_host: + domains.append( + CustomDomain(host=cdn_host, backend_kind=None, enabled=True, validated=True) + ) + if upload_host: + domains.append( + CustomDomain( + host=upload_host, + backend_kind=int(BackendKind.MAVEN), + enabled=True, + validated=True, + ) + ) + + def _fake(org, **_kw): + if raises: + raise RuntimeError("network down") + return domains + + monkeypatch.setattr(_INSTALLER_GET_CUSTOM_DOMAINS, _fake) + + +def test_maven_install_discovers_both_hosts_and_persists(_home, monkeypatch): + """install writes the shim and persists discovered CDN + upload hosts.""" + from ....credential_helpers.maven.installer import MavenInstaller + from ....credential_helpers.shellplugin import config + + monkeypatch.setenv("PATH", str(config.shims_dir())) + _fake_discovery(monkeypatch, cdn_host="dl.acme.com", upload_host="maven.acme.com") + + MavenInstaller().install(org="acme", repo="prod", api_key="k", discover=True) + + assert (config.shims_dir() / "mvn").exists() + entry = config.get_plugin("maven") + assert entry.owner == "acme" + assert entry.repo == "prod" + assert entry.cdn_host == "dl.acme.com" + assert entry.upload_host == "maven.acme.com" + assert entry.registry_id == "cloudsmith" + + +def test_maven_install_defaults_when_no_discovery(_home, monkeypatch): + """discover=False keeps the *.cloudsmith.io default hosts.""" + from ....credential_helpers.maven.installer import MavenInstaller + from ....credential_helpers.shellplugin import config + + monkeypatch.setenv("PATH", str(config.shims_dir())) + MavenInstaller().install(org="acme", repo="prod", discover=False) + + entry = config.get_plugin("maven") + assert entry.cdn_host == "dl.cloudsmith.io" + assert entry.upload_host == "maven.cloudsmith.io" + + +def test_maven_install_domain_override(_home, monkeypatch): + """--domain overrides the discovered/default download CDN host.""" + from ....credential_helpers.maven.installer import MavenInstaller + from ....credential_helpers.shellplugin import config + + monkeypatch.setenv("PATH", str(config.shims_dir())) + MavenInstaller().install( + org="acme", repo="prod", domains=("my.cdn.example.com",), discover=False + ) + assert config.get_plugin("maven").cdn_host == "my.cdn.example.com" + + +def test_maven_install_custom_registry_id(_home, monkeypatch): + """--registry-id is persisted for use in settings.xml + pom snippet.""" + from ....credential_helpers.maven.installer import MavenInstaller + from ....credential_helpers.shellplugin import config + + monkeypatch.setenv("PATH", str(config.shims_dir())) + MavenInstaller().install( + org="acme", repo="prod", discover=False, registry_id="my-cs" + ) + assert config.get_plugin("maven").registry_id == "my-cs" + + +def test_maven_install_prints_distribution_management_snippet(_home, monkeypatch): + """install surfaces a distributionManagement snippet for opt-in deploy.""" + from ....credential_helpers.maven.installer import MavenInstaller + from ....credential_helpers.shellplugin import config + + monkeypatch.setenv("PATH", str(config.shims_dir())) + actions = MavenInstaller().install(org="acme", repo="prod", discover=False) + + joined = "\n".join(actions) + assert "distributionManagement" in joined + assert "https://maven.cloudsmith.io/acme/prod/" in joined + assert "cloudsmith" in joined + + +def test_maven_install_snippet_custom_upload_domain_drops_org(_home, monkeypatch): + """With a custom upload domain, the deploy snippet URL is org-scoped.""" + from ....credential_helpers.maven.installer import MavenInstaller + from ....credential_helpers.shellplugin import config + + monkeypatch.setenv("PATH", str(config.shims_dir())) + _fake_discovery(monkeypatch, upload_host="maven.acme.example.com") + + actions = MavenInstaller().install( + org="acme", repo="prod", api_key="k", discover=True + ) + joined = "\n".join(actions) + assert "https://maven.acme.example.com/prod/" in joined + assert "maven.acme.example.com/acme/prod/" not in joined + + +def test_maven_install_dry_run_writes_nothing(_home, monkeypatch): + """dry_run: no shim, no plugins.json entry, returns 'would' actions.""" + from ....credential_helpers.maven.installer import MavenInstaller + from ....credential_helpers.shellplugin import config + + actions = MavenInstaller().install( + org="acme", repo="prod", discover=False, dry_run=True + ) + assert not (config.shims_dir() / "mvn").exists() + assert config.get_plugin("maven") is None + assert any("would" in a.lower() for a in actions) + + +def test_maven_install_path_warning(_home, monkeypatch): + """A WARNING is returned when the shims dir is not on PATH.""" + from ....credential_helpers.maven.installer import MavenInstaller + + monkeypatch.setenv("PATH", "/usr/bin:/usr/local/bin") + actions = MavenInstaller().install(org="acme", repo="prod", discover=False) + warnings = [a for a in actions if a.startswith("WARNING")] + assert warnings + assert any("PATH" in a for a in warnings) + + +def test_maven_install_discovery_failure_is_graceful(_home, monkeypatch): + """Discovery errors degrade to a WARNING; defaults still install.""" + from ....credential_helpers.maven.installer import MavenInstaller + from ....credential_helpers.shellplugin import config + + monkeypatch.setenv("PATH", str(config.shims_dir())) + _fake_discovery(monkeypatch, raises=True) + + actions = MavenInstaller().install( + org="acme", repo="prod", api_key="k", discover=True + ) + assert (config.shims_dir() / "mvn").exists() + assert config.get_plugin("maven").cdn_host == "dl.cloudsmith.io" + assert any(a.startswith("WARNING") for a in actions) + + +def test_maven_uninstall_removes_shim_and_entry(_home, monkeypatch): + """uninstall removes the shim and drops the plugins.json entry.""" + from ....credential_helpers.maven.installer import MavenInstaller + from ....credential_helpers.shellplugin import config + + monkeypatch.setenv("PATH", str(config.shims_dir())) + installer = MavenInstaller() + installer.install(org="acme", repo="prod", discover=False) + assert (config.shims_dir() / "mvn").exists() + + installer.uninstall() + assert not (config.shims_dir() / "mvn").exists() + assert config.get_plugin("maven") is None + + +def test_maven_status_launcher_is_str_or_none(_home, monkeypatch): + """status()['launcher'] is None before install and a str afterwards.""" + from ....credential_helpers.maven.installer import MavenInstaller + from ....credential_helpers.shellplugin import config + + monkeypatch.setenv("PATH", str(config.shims_dir())) + installer = MavenInstaller() + + assert installer.status()["launcher"] is None + + installer.install(org="acme", repo="prod", discover=False) + launcher = installer.status()["launcher"] + assert isinstance(launcher, str) + assert launcher.endswith("mvn") + + +# --------------------------------------------------------------------------- +# 7. CLI wiring — exec + shell-init + manage +# --------------------------------------------------------------------------- + + +@pytest.fixture() +def cli_runner(): + import click.testing + + return click.testing.CliRunner() + + +def test_exec_cmd_passes_command_through_and_propagates_exit_code( + cli_runner, monkeypatch +): + """`cloudsmith exec -- mvn install` calls runner.run([...]) and returns its code.""" + from ....cli.commands.exec_ import exec_ + from ....credential_helpers.shellplugin import runner + + captured = {} + + def _fake_run(command, **kwargs): + captured["command"] = command + return 7 + + monkeypatch.setattr(runner, "run", _fake_run) + + result = cli_runner.invoke(exec_, ["--", "mvn", "install", "-DskipTests"]) + assert result.exit_code == 7 + assert captured["command"] == ["mvn", "install", "-DskipTests"] + + +def test_shell_init_cmd_explicit_shell(cli_runner, _home): + """`shell-init --shell bash` prints the PATH prepend for the shims dir.""" + from ....cli.commands.credential_helper.shell import shell_init + from ....credential_helpers.shellplugin import config + + result = cli_runner.invoke(shell_init, ["--shell", "bash"]) + assert result.exit_code == 0 + assert f'export PATH="{config.shims_dir()}:$PATH"' in result.output + + +def test_shell_init_cmd_detects_fish(cli_runner, monkeypatch): + """`shell-init` with $SHELL=fish emits fish syntax.""" + from ....cli.commands.credential_helper.shell import shell_init + + monkeypatch.setenv("SHELL", "/usr/bin/fish") + result = cli_runner.invoke(shell_init, []) + assert result.exit_code == 0 + assert "fish_add_path" in result.output + + +def test_manage_list_includes_maven(cli_runner): + """`credential-helper list` shows the maven helper.""" + from ....cli.commands.credential_helper.manage import list_cmd + + result = cli_runner.invoke(list_cmd, []) + assert result.exit_code == 0, result.output + assert "maven" in result.output + + +def test_manage_install_maven_dry_run(cli_runner, _home): + """`install maven --org --repo --no-discover --dry-run` exits 0 with a plan.""" + from ....cli.commands.credential_helper.manage import install_cmd + + result = cli_runner.invoke( + install_cmd, + ["maven", "--org", "acme", "--repo", "prod", "--no-discover", "--dry-run"], + ) + assert result.exit_code == 0, result.output + assert "would" in result.output.lower() or "dry run" in result.output.lower() + + +def test_manage_install_maven_ignores_bin_dir(cli_runner, _home, tmp_path): + """--bin-dir does not apply to shell plugins: the shim lands in the shims dir.""" + from ....cli.commands.credential_helper.manage import install_cmd + from ....credential_helpers.shellplugin import config + + other = tmp_path / "other-bin" + result = cli_runner.invoke( + install_cmd, + [ + "maven", + "--org", + "acme", + "--repo", + "prod", + "--no-discover", + "--bin-dir", + str(other), + ], + ) + assert result.exit_code == 0, result.output + assert (config.shims_dir() / "mvn").exists() + assert not (other / "mvn").exists() + + +def test_manage_install_maven_requires_repo(cli_runner, _home): + """Installing maven without --repo fails clearly.""" + from ....cli.commands.credential_helper.manage import install_cmd + + result = cli_runner.invoke(install_cmd, ["maven", "--org", "acme", "--no-discover"]) + assert result.exit_code != 0 diff --git a/cloudsmith_cli/cli/webserver.py b/cloudsmith_cli/cli/webserver.py index 060d2ff7..8359c58c 100644 --- a/cloudsmith_cli/cli/webserver.py +++ b/cloudsmith_cli/cli/webserver.py @@ -1,5 +1,4 @@ import html -import os import socket from functools import cached_property from http.server import BaseHTTPRequestHandler, HTTPServer @@ -7,6 +6,7 @@ import click +from .. import templates from ..core.api.exceptions import ApiException from ..core.api.init import initialise_api from ..core.credentials.models import CredentialResult @@ -16,32 +16,12 @@ def get_template_path(template_name): """Get the absolute path to a template file.""" - base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) - return os.path.join(base_dir, "templates", template_name) + return templates.template_path(template_name) def render_template(template_name, **context): - """ - Render a template with the given context. - - Args: - template_name: Name of the template file - context: Dictionary of variables to replace in the template - - Returns: - Rendered HTML content - """ - template_path = get_template_path(template_name) - - with open(template_path, encoding="utf-8") as file: - content = file.read() - - # Replace placeholders with context values - for key, value in context.items(): - placeholder = f"" - content = content.replace(placeholder, value if value else "") - - return content + """Render a template with the given context (see :func:`templates.render`).""" + return templates.render(template_name, **context) class AuthenticationWebServer(HTTPServer): diff --git a/cloudsmith_cli/credential_helpers/common.py b/cloudsmith_cli/credential_helpers/common.py index f7bcf85b..79c51148 100644 --- a/cloudsmith_cli/credential_helpers/common.py +++ b/cloudsmith_cli/credential_helpers/common.py @@ -48,6 +48,32 @@ def extract_hostname(url): return hostname +def is_standard_cloudsmith_host(url): + """Return True if *url*'s host is a standard Cloudsmith host. + + Standard hosts are ``cloudsmith.io``/``cloudsmith.com`` and their + subdomains. Anything else is treated as a custom domain. + """ + hostname = extract_hostname(url) + return ( + hostname in ("cloudsmith.io", "cloudsmith.com") + or hostname.endswith(".cloudsmith.io") + or hostname.endswith(".cloudsmith.com") + ) + + +def repo_path_segment(owner, repo, host): + """Return the org-qualified path segment for a Cloudsmith URL. + + Standard hosts include the org (``/``); a custom domain is + bound to a single org, so the org is omitted (````). This rule is + Cloudsmith-wide, not format-specific. + """ + if is_standard_cloudsmith_host(host): + return f"{owner}/{repo}" + return repo + + def is_cloudsmith_domain( url, api_key=None, auth_type="api_key", api_host=None, backend_kind=None ): @@ -74,11 +100,7 @@ def is_cloudsmith_domain( return False # Standard Cloudsmith domains — no auth needed, always match regardless of backend_kind - if ( - hostname in ("cloudsmith.io", "cloudsmith.com") - or hostname.endswith(".cloudsmith.io") - or hostname.endswith(".cloudsmith.com") - ): + if is_standard_cloudsmith_host(hostname): return True # Custom domains require org + auth diff --git a/cloudsmith_cli/credential_helpers/maven/__init__.py b/cloudsmith_cli/credential_helpers/maven/__init__.py new file mode 100644 index 00000000..d444388a --- /dev/null +++ b/cloudsmith_cli/credential_helpers/maven/__init__.py @@ -0,0 +1,2 @@ +# Copyright 2026 Cloudsmith Ltd +"""Maven credential support (shell-plugin installer).""" diff --git a/cloudsmith_cli/credential_helpers/maven/installer.py b/cloudsmith_cli/credential_helpers/maven/installer.py new file mode 100644 index 00000000..e3af98a2 --- /dev/null +++ b/cloudsmith_cli/credential_helpers/maven/installer.py @@ -0,0 +1,236 @@ +# Copyright 2026 Cloudsmith Ltd +"""Installer for the Maven shell-plugin credential helper. + +Writes an ``mvn`` shim into the Cloudsmith shims dir, records the repo binding +and resolved hosts in the CLI config, and surfaces the ``distributionManagement`` +snippet needed for ``mvn deploy``. Download resolution works transparently once +the shims dir is on PATH (via ``credential-helper shell-init``); upload is opt-in. + +Maven uses two distinct endpoints, so two custom-domain kinds matter: +- **download** (dependency resolution) goes via the download CDN; its custom + domains carry ``backend_kind is None`` (the generic download domain). +- **upload** (``distributionManagement``) goes via the native Maven endpoint; + its custom domains carry ``BackendKind.MAVEN``. +""" + +from __future__ import annotations + +import logging +from xml.sax.saxutils import escape + +from ...templates import render +from ..backends import BackendKind +from ..custom_domains import CustomDomain, get_custom_domains +from ..launchers import is_on_path +from ..shellplugin import config +from ..shellplugin.maven import upload_url +from ..shellplugin.shims import remove_shim, write_shim + +logger = logging.getLogger(__name__) + +_DISTRIBUTION_MANAGEMENT_TEMPLATE = "maven_distribution_management.xml.tmpl" + + +def _deploy_snippet(owner: str, repo: str, upload_host: str, registry_id: str) -> str: + """Return the pom.xml distributionManagement snippet for opt-in deploy.""" + snippet = render( + _DISTRIBUTION_MANAGEMENT_TEMPLATE, + server_id=escape(registry_id), + url=escape(upload_url(owner, repo, upload_host)), + ) + return ( + "To publish with `mvn deploy`, add this to your pom.xml " + "(the id must match the server id):\n" + snippet + ) + + +def _first_host(domains: list[CustomDomain], backend_kind: int | None) -> str | None: + """Return the first enabled+validated host matching *backend_kind*.""" + for domain in domains: + if domain.backend_kind == backend_kind and domain.enabled and domain.validated: + return domain.host + return None + + +class MavenInstaller: + """Installs the Maven shell plugin (shim + config entry).""" + + BINARY_NAME = "mvn" + name = "maven" + summary = "Maven shell plugin for Cloudsmith repositories" + requires_repo = True + + def _discover_domains( + self, + *, + org: str | None, + api_key: str | None, + auth_type: str, + api_host: str | None, + refresh: bool, + actions: list[str], + ) -> list[CustomDomain]: + """Fetch the org's custom domains (best-effort; failure → WARNING + []).""" + if not (org and api_key): + return [] + try: + return get_custom_domains( + org, + api_key=api_key, + auth_type=auth_type, + api_host=api_host, + refresh=refresh, + ) + except Exception as exc: # pylint: disable=broad-except + # Discovery boundary: never let a lookup failure abort the install. + actions.append(f"WARNING: custom-domain discovery failed: {exc}") + return [] + + def _resolve_hosts( + self, + *, + domains_override: tuple[str, ...], + discover: bool, + org: str | None, + api_key: str | None, + auth_type: str, + api_host: str | None, + refresh: bool, + actions: list[str], + ) -> tuple[str, str]: + """Resolve (cdn_host, upload_host) from overrides, discovery, defaults.""" + discovered: list[CustomDomain] = [] + if discover: + discovered = self._discover_domains( + org=org, + api_key=api_key, + auth_type=auth_type, + api_host=api_host, + refresh=refresh, + actions=actions, + ) + + if domains_override: + cdn_host = domains_override[0] + else: + cdn_host = _first_host(discovered, None) or config.DEFAULT_CDN_HOST + upload_host = ( + _first_host(discovered, BackendKind.MAVEN) or config.DEFAULT_UPLOAD_HOST + ) + return cdn_host, upload_host + + def install( + self, + *, + domains: tuple[str, ...] = (), + discover: bool = True, + refresh: bool = False, + org: str | None = None, + repo: str | None = None, + registry_id: str = config.DEFAULT_REGISTRY_ID, + api_key: str | None = None, + auth_type: str = "api_key", + api_host: str | None = None, + dry_run: bool = False, + ) -> list[str]: + """Install the Maven shell plugin; return human-readable actions.""" + owner = org + actions: list[str] = [] + + cdn_host, upload_host = self._resolve_hosts( + domains_override=domains, + discover=discover, + org=owner, + api_key=api_key, + auth_type=auth_type, + api_host=api_host, + refresh=refresh, + actions=actions, + ) + + shims_dir = config.shims_dir() + shim_path = shims_dir / self.BINARY_NAME + + if dry_run: + actions.append(f"would write shim {shim_path}") + actions.append( + f"would configure maven for {owner}/{repo} " + f"(download {cdn_host}, upload {upload_host})" + ) + actions.append( + _deploy_snippet(owner or "", repo or "", upload_host, registry_id) + ) + return actions + + write_shim(shims_dir, self.BINARY_NAME) + actions.append(f"wrote shim {shim_path}") + + config.set_plugin( + self.name, + config.PluginEntry( + owner=owner or "", + repo=repo or "", + api_host=api_host or config.DEFAULT_API_HOST, + cdn_host=cdn_host, + upload_host=upload_host, + registry_id=registry_id, + ), + ) + actions.append( + f"configured maven for {owner}/{repo} " + f"(download {cdn_host}, upload {upload_host})" + ) + + if not is_on_path(shims_dir): + actions.append( + f"WARNING: {shims_dir} is not on PATH — add it with " + '`eval "$(cloudsmith credential-helper shell-init)"`' + ) + + actions.append( + _deploy_snippet(owner or "", repo or "", upload_host, registry_id) + ) + return actions + + def uninstall(self, *, dry_run: bool = False) -> list[str]: + """Remove the Maven shim and drop its config entry.""" + shims_dir = config.shims_dir() + shim_path = shims_dir / self.BINARY_NAME + actions: list[str] = [] + + if dry_run: + if shim_path.exists(): + actions.append(f"would remove shim {shim_path}") + else: + actions.append(f"shim not found at {shim_path} (nothing to remove)") + if config.get_plugin(self.name) is not None: + actions.append("would remove maven from the package-manager config") + else: + actions.append("maven not configured (nothing to remove)") + return actions + + if remove_shim(shims_dir, self.BINARY_NAME): + actions.append(f"removed shim {shim_path}") + else: + actions.append(f"shim not found at {shim_path} (nothing to remove)") + + if config.remove_plugin(self.name): + actions.append("removed maven from the package-manager config") + else: + actions.append("maven not configured (nothing to remove)") + return actions + + def status(self) -> dict: + """Return shim path (str|None) and configured hosts for `list`.""" + shim_path = config.shims_dir() / self.BINARY_NAME + launcher = str(shim_path) if shim_path.exists() else None + + entry = config.get_plugin(self.name) + hosts: list[str] = [] + if entry is not None: + hosts = [ + f"{entry.owner}/{entry.repo}", + f"download:{entry.cdn_host}", + f"upload:{entry.upload_host}", + ] + return {"launcher": launcher, "hosts": hosts} diff --git a/cloudsmith_cli/credential_helpers/shellplugin/__init__.py b/cloudsmith_cli/credential_helpers/shellplugin/__init__.py new file mode 100644 index 00000000..b19edfa3 --- /dev/null +++ b/cloudsmith_cli/credential_helpers/shellplugin/__init__.py @@ -0,0 +1,2 @@ +# Copyright 2026 Cloudsmith Ltd +"""Shell-plugin credential support (shim + exec) for package managers like Maven.""" diff --git a/cloudsmith_cli/credential_helpers/shellplugin/config.py b/cloudsmith_cli/credential_helpers/shellplugin/config.py new file mode 100644 index 00000000..2cb345e4 --- /dev/null +++ b/cloudsmith_cli/credential_helpers/shellplugin/config.py @@ -0,0 +1,113 @@ +# Copyright 2026 Cloudsmith Ltd +"""Persistent state for shell-plugin credential helpers. + +Records which package-manager formats are enabled and the org/repo + resolved +hosts each is bound to, as ``[package-manager:]`` sections in +``package-managers.ini`` inside the CLI config directory (alongside +``config.ini`` / ``credentials.ini``). +""" + +from __future__ import annotations + +import configparser +from dataclasses import asdict, dataclass +from pathlib import Path + +from ...cli.config import get_default_config_path + +DEFAULT_CDN_HOST = "dl.cloudsmith.io" +DEFAULT_UPLOAD_HOST = "maven.cloudsmith.io" +DEFAULT_REGISTRY_ID = "cloudsmith" +DEFAULT_API_HOST = "https://api.cloudsmith.io" + +_SECTION_PREFIX = "package-manager:" + + +@dataclass(frozen=True) +class PluginEntry: + """A single enabled shell-plugin's binding (org/repo + resolved hosts).""" + + owner: str + repo: str + api_host: str = DEFAULT_API_HOST + cdn_host: str = DEFAULT_CDN_HOST + upload_host: str = DEFAULT_UPLOAD_HOST + registry_id: str = DEFAULT_REGISTRY_ID + + @classmethod + def from_dict(cls, data) -> PluginEntry: + """Build an entry from a config section, filling defaults.""" + return cls( + owner=data.get("owner", ""), + repo=data.get("repo", ""), + api_host=data.get("api_host") or DEFAULT_API_HOST, + cdn_host=data.get("cdn_host") or DEFAULT_CDN_HOST, + upload_host=data.get("upload_host") or DEFAULT_UPLOAD_HOST, + registry_id=data.get("registry_id") or DEFAULT_REGISTRY_ID, + ) + + def to_dict(self) -> dict: + """Return the entry as a flat string mapping for the INI section.""" + return asdict(self) + + +def config_path() -> Path: + """Return the path to ``package-managers.ini`` in the CLI config dir.""" + return Path(get_default_config_path()) / "package-managers.ini" + + +def shims_dir() -> Path: + """Return the directory that holds package-manager shims on PATH.""" + return Path(get_default_config_path()) / "shims" + + +def _read() -> configparser.ConfigParser: + parser = configparser.ConfigParser(interpolation=None) + path = config_path() + if path.exists(): + parser.read(path, encoding="utf-8") + return parser + + +def _write(parser: configparser.ConfigParser) -> None: + path = config_path() + path.parent.mkdir(parents=True, exist_ok=True) + with open(path, "w", encoding="utf-8") as handle: + parser.write(handle) + + +def load_plugins() -> dict[str, PluginEntry]: + """Return the enabled plugins keyed by format name (``{}`` when none).""" + parser = _read() + return { + section[len(_SECTION_PREFIX) :]: PluginEntry.from_dict(parser[section]) + for section in parser.sections() + if section.startswith(_SECTION_PREFIX) + } + + +def get_plugin(name: str) -> PluginEntry | None: + """Return the stored entry for *name*, or ``None`` when not enabled.""" + parser = _read() + section = _SECTION_PREFIX + name + if not parser.has_section(section): + return None + return PluginEntry.from_dict(parser[section]) + + +def set_plugin(name: str, entry: PluginEntry) -> None: + """Record (or replace) the entry for *name* and persist.""" + parser = _read() + parser[_SECTION_PREFIX + name] = entry.to_dict() + _write(parser) + + +def remove_plugin(name: str) -> bool: + """Drop the entry for *name*; return True if one was removed.""" + parser = _read() + section = _SECTION_PREFIX + name + if not parser.has_section(section): + return False + parser.remove_section(section) + _write(parser) + return True diff --git a/cloudsmith_cli/credential_helpers/shellplugin/maven.py b/cloudsmith_cli/credential_helpers/shellplugin/maven.py new file mode 100644 index 00000000..835e36dd --- /dev/null +++ b/cloudsmith_cli/credential_helpers/shellplugin/maven.py @@ -0,0 +1,90 @@ +# Copyright 2026 Cloudsmith Ltd +"""Maven shell plugin. + +Maven has no credential helper, so we inject an ephemeral ``settings.xml`` via +``mvn -s`` for the duration of a single invocation. The file carries a +```` (matched by id to both our injected download profile and the +user's ``distributionManagement``) plus an active profile whose repositories +point at the Cloudsmith download CDN — so dependency resolution works with no +``pom.xml`` edits. Upload (deploy) reuses the same ```` credentials; +its URL comes from the user's ``distributionManagement``. +""" + +from __future__ import annotations + +import os +import shutil +import tempfile +from xml.sax.saxutils import escape + +from ...templates import render +from ..common import repo_path_segment +from .config import PluginEntry +from .plugin import ProvisionResult + +_SKIP_AUTH_ARGS = ["--help", "--version", "-v", "help"] +_SETTINGS_TEMPLATE = "maven_settings.xml.tmpl" + + +def download_url(owner: str, repo: str, cdn_host: str) -> str: + """Return the Maven download (dependency-resolution) repository URL.""" + return f"https://{cdn_host}/basic/{repo_path_segment(owner, repo, cdn_host)}/maven/" + + +def upload_url(owner: str, repo: str, upload_host: str) -> str: + """Return the native Maven upload (distributionManagement) URL.""" + return f"https://{upload_host}/{repo_path_segment(owner, repo, upload_host)}/" + + +def build_settings_xml( + owner: str, repo: str, token: str, cdn_host: str, server_id: str +) -> str: + """Return a Maven ``settings.xml`` body for the given repo + credentials.""" + return render( + _SETTINGS_TEMPLATE, + server_id=escape(server_id), + token=escape(token), + url=escape(download_url(owner, repo, cdn_host)), + ) + + +class MavenPlugin: + """Provisions an ephemeral ``settings.xml`` and runs ``mvn`` with ``-s``.""" + + name = "maven" + binary_name = "mvn" + + def skip_auth_args(self) -> list[str]: + """Args for which Maven needs no Cloudsmith credentials.""" + return list(_SKIP_AUTH_ARGS) + + def provision( + self, entry: PluginEntry, token: str, args: list[str] + ) -> ProvisionResult: + """Write a 0600 ``settings.xml`` and return ``-s `` to prepend. + + Atomic: if writing fails the temp dir is removed before re-raising, so + a partial provisioning never leaks a directory. + """ + temp_dir = tempfile.mkdtemp(prefix="cloudsmith-maven-") + written = False + try: + settings_path = os.path.join(temp_dir, "settings.xml") + content = build_settings_xml( + owner=entry.owner, + repo=entry.repo, + token=token, + cdn_host=entry.cdn_host, + server_id=entry.registry_id, + ) + with open(settings_path, "w", encoding="utf-8") as handle: + handle.write(content) + os.chmod(settings_path, 0o600) + written = True + return ProvisionResult( + prepend_args=["-s", settings_path], + temp_dirs=[temp_dir], + ) + finally: + if not written: + shutil.rmtree(temp_dir, ignore_errors=True) diff --git a/cloudsmith_cli/credential_helpers/shellplugin/plugin.py b/cloudsmith_cli/credential_helpers/shellplugin/plugin.py new file mode 100644 index 00000000..4e04099a --- /dev/null +++ b/cloudsmith_cli/credential_helpers/shellplugin/plugin.py @@ -0,0 +1,70 @@ +# Copyright 2026 Cloudsmith Ltd +"""Plugin protocol and registry for shell-plugin credential helpers. + +A *plugin* knows how to provision ephemeral credentials for one package +manager that lacks a native credential helper (e.g. Maven). The runner looks +a plugin up by the command being run (its binary name) and asks it to +provision per-invocation config. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Protocol + +if TYPE_CHECKING: + from .config import PluginEntry + + +@dataclass +class ProvisionResult: + """The per-invocation provisioning a plugin produces for one command. + + ``env`` are extra environment variables to set on the child process, + ``prepend_args`` are inserted before the user's arguments, and + ``temp_dirs`` are removed after the child exits. + """ + + env: dict[str, str] = field(default_factory=dict) + prepend_args: list[str] = field(default_factory=list) + temp_dirs: list[str] = field(default_factory=list) + + +class Plugin(Protocol): + """A package-manager shell plugin.""" + + name: str + binary_name: str + + def skip_auth_args(self) -> list[str]: + """Args that mean 'no credentials needed' (e.g. ``--version``).""" + + def provision( + self, entry: PluginEntry, token: str, args: list[str] + ) -> ProvisionResult: + """Write ephemeral credentials and return how to run the binary.""" + + +def _registry() -> dict[str, Plugin]: + """Build the plugin registry (imported lazily to avoid import cycles).""" + from .maven import MavenPlugin + + return {"maven": MavenPlugin()} + + +def get(name: str) -> Plugin | None: + """Return the registered plugin for *name*, or ``None``.""" + return _registry().get(name) + + +def get_by_binary(binary_name: str) -> Plugin | None: + """Return the plugin whose ``binary_name`` matches, or ``None``.""" + for plugin in _registry().values(): + if plugin.binary_name == binary_name: + return plugin + return None + + +def names() -> list[str]: + """Return the sorted names of registered plugins.""" + return sorted(_registry()) diff --git a/cloudsmith_cli/credential_helpers/shellplugin/runner.py b/cloudsmith_cli/credential_helpers/shellplugin/runner.py new file mode 100644 index 00000000..3dcd79a7 --- /dev/null +++ b/cloudsmith_cli/credential_helpers/shellplugin/runner.py @@ -0,0 +1,122 @@ +# Copyright 2026 Cloudsmith Ltd +"""Run a command with Cloudsmith credentials provisioned for it. + +``cloudsmith exec -- `` (or a shim that forwards to it) lands here. +The plugin is inferred from the command's binary name; when one matches we +provision ephemeral credentials, run the *real* binary (resolved with the +shims dir excluded to avoid recursion), and clean up. Commands with no +matching plugin run unchanged. +""" + +from __future__ import annotations + +import logging +import os +import shutil +import subprocess +import sys + +from . import config, plugin + +logger = logging.getLogger(__name__) + + +def resolve_real_binary(binary_name: str, exclude_dirs: list[str]) -> str | None: + """Return the first ``$PATH`` match for *binary_name* outside *exclude_dirs*. + + Excluding the shims dir prevents a shim from re-invoking itself. + """ + excluded = {os.path.normcase(os.path.normpath(d)) for d in exclude_dirs} + for entry in os.environ.get("PATH", "").split(os.pathsep): + if not entry: + continue + if os.path.normcase(os.path.normpath(entry)) in excluded: + continue + candidate = shutil.which(binary_name, path=entry) + if candidate: + return candidate + return None + + +def _run_process(path: str, args: list[str], env: dict[str, str]) -> int: + """Run *path* with *args* and *env*, returning its exit code.""" + completed = subprocess.run([path, *args], env=env, check=False) + return completed.returncode + + +def _resolve_entry( + format_name: str, owner: str | None, repo: str | None, api_host: str | None +) -> config.PluginEntry: + """Load the stored entry for *format_name*, else build one from inputs.""" + entry = config.get_plugin(format_name) + if entry is not None: + return entry + return config.PluginEntry.from_dict( + { + "owner": owner or "", + "repo": repo or "", + "api_host": api_host or config.DEFAULT_API_HOST, + } + ) + + +def _needs_auth(args: list[str], skip_auth_args: list[str]) -> bool: + """Return False when any arg signals an auth-free command (help/version). + + Matching anywhere in the command is intentional: these flags (e.g. Maven's + ``--version``/``help``) short-circuit the tool regardless of position, so + there is nothing to authenticate. + """ + skip = set(skip_auth_args) + return not any(arg in skip for arg in args) + + +def run( + command: list[str], + credential=None, + owner: str | None = None, + repo: str | None = None, + api_host: str | None = None, +) -> int: + """Run *command* with credentials provisioned for its package manager. + + Returns the child process exit code, or non-zero on a setup error. + """ + if not command: + print("cloudsmith: exec requires a command to run", file=sys.stderr) + return 2 + + binary_name, args = command[0], command[1:] + real_binary = resolve_real_binary( + binary_name, exclude_dirs=[str(config.shims_dir())] + ) + if real_binary is None: + print(f"cloudsmith: command not found: {binary_name}", file=sys.stderr) + return 127 + + impl = plugin.get_by_binary(binary_name) + if impl is None or not _needs_auth(args, impl.skip_auth_args()): + return _run_process(real_binary, args, dict(os.environ)) + + token = getattr(credential, "api_key", None) if credential else None + if not token: + print( + "cloudsmith: warning: no credential resolved — set CLOUDSMITH_API_KEY " + "or configure OIDC", + file=sys.stderr, + ) + + entry = _resolve_entry(impl.name, owner, repo, api_host) + try: + result = impl.provision(entry, token or "", args) + except OSError as exc: + # Boundary: a failed provisioning must not crash the wrapped tool with + # a traceback. provision() cleans up its own temp dir before raising. + print(f"cloudsmith: failed to provision credentials: {exc}", file=sys.stderr) + return 1 + try: + env = {**os.environ, **result.env} + return _run_process(real_binary, [*result.prepend_args, *args], env) + finally: + for temp_dir in result.temp_dirs: + shutil.rmtree(temp_dir, ignore_errors=True) diff --git a/cloudsmith_cli/credential_helpers/shellplugin/shellinit.py b/cloudsmith_cli/credential_helpers/shellplugin/shellinit.py new file mode 100644 index 00000000..c2c5402b --- /dev/null +++ b/cloudsmith_cli/credential_helpers/shellplugin/shellinit.py @@ -0,0 +1,49 @@ +# Copyright 2026 Cloudsmith Ltd +"""Shell initialisation for the package-manager shims. + +Prints a snippet for ``eval "$(cloudsmith credential-helper shell-init)"`` that +prepends the Cloudsmith shims directory to ``$PATH`` so the shims shadow the +real binaries. A single PATH prepend covers every enabled format (the shims +dir holds them all). +""" + +from __future__ import annotations + +import os + +from .config import shims_dir + + +def _posix_init(path: str) -> str: + return ( + "# Put Cloudsmith package-manager shims ahead of the real binaries\n" + f'export PATH="{path}:$PATH"\n' + ) + + +def _fish_init(path: str) -> str: + return ( + "# Put Cloudsmith package-manager shims ahead of the real binaries\n" + f'fish_add_path "{path}"\n' + ) + + +_BUILDERS = {"bash": _posix_init, "zsh": _posix_init, "fish": _fish_init} + + +def generate_init(shell: str) -> str: + """Return the shell-init snippet for *shell* (bash/zsh/fish).""" + try: + builder = _BUILDERS[shell] + except KeyError: + supported = ", ".join(sorted(_BUILDERS)) + raise ValueError( + f"unsupported shell {shell!r}; choose one of: {supported}" + ) from None + return builder(str(shims_dir())) + + +def detect_shell() -> str: + """Best-effort shell detection from ``$SHELL``, defaulting to bash.""" + name = os.path.basename(os.environ.get("SHELL", "")) + return name if name in _BUILDERS else "bash" diff --git a/cloudsmith_cli/credential_helpers/shellplugin/shims.py b/cloudsmith_cli/credential_helpers/shellplugin/shims.py new file mode 100644 index 00000000..eabee97f --- /dev/null +++ b/cloudsmith_cli/credential_helpers/shellplugin/shims.py @@ -0,0 +1,30 @@ +# Copyright 2026 Cloudsmith Ltd +"""Shim writer for shell-plugin package managers. + +A shim is a tiny launcher (reusing the credential-helper launcher body) named +after the real binary (e.g. ``mvn``) that re-execs ``cloudsmith exec -- +"$@"``. Placed first on PATH (via ``credential-helper shell-init``), it +shadows the real binary so every invocation is wrapped with Cloudsmith +credentials; ``exec`` infers the right plugin from the binary name. +""" + +from __future__ import annotations + +from pathlib import Path + +from ..launchers import remove_launcher, write_launcher + + +def shim_target_cmd(binary_name: str) -> str: + """Return the command a shim for *binary_name* forwards to.""" + return f"cloudsmith exec -- {binary_name}" + + +def write_shim(shims_dir: Path, binary_name: str) -> Path: + """Write a shim named *binary_name* that wraps it via ``cloudsmith exec``.""" + return write_launcher(shims_dir, binary_name, shim_target_cmd(binary_name)) + + +def remove_shim(shims_dir: Path, binary_name: str) -> bool: + """Remove a shim previously created by :func:`write_shim`.""" + return remove_launcher(shims_dir, binary_name) diff --git a/cloudsmith_cli/templates/__init__.py b/cloudsmith_cli/templates/__init__.py index f9e8facc..bb2b2d7b 100644 --- a/cloudsmith_cli/templates/__init__.py +++ b/cloudsmith_cli/templates/__init__.py @@ -1,3 +1,27 @@ +"""Templates for the Cloudsmith CLI (HTML pages, config-file templates). + +Templates use ```` markers so the files stay valid +HTML/XML and get normal editor validation; :func:`render` substitutes them. """ -HTML templates for Cloudsmith CLI interface. -""" + +import os + + +def template_path(name): + """Return the absolute path to the template *name* in this package.""" + return os.path.join(os.path.dirname(os.path.abspath(__file__)), name) + + +def render(name, **context): + """Render template *name*, replacing ```` markers. + + Each ``context`` key ``foo`` replaces ```` with its + value (empty string when falsy). Callers are responsible for escaping + values for the target format (e.g. XML-escaping). + """ + with open(template_path(name), encoding="utf-8") as handle: + content = handle.read() + for key, value in context.items(): + placeholder = f"" + content = content.replace(placeholder, value if value else "") + return content diff --git a/cloudsmith_cli/templates/maven_distribution_management.xml.tmpl b/cloudsmith_cli/templates/maven_distribution_management.xml.tmpl new file mode 100644 index 00000000..bd9813e4 --- /dev/null +++ b/cloudsmith_cli/templates/maven_distribution_management.xml.tmpl @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/cloudsmith_cli/templates/maven_settings.xml.tmpl b/cloudsmith_cli/templates/maven_settings.xml.tmpl new file mode 100644 index 00000000..4a883cde --- /dev/null +++ b/cloudsmith_cli/templates/maven_settings.xml.tmpl @@ -0,0 +1,29 @@ + + + + + token + + + + + + + + + + + + + + + + + + + + + + + +