From 1d0196bec68688152b76d5ae5bc4b233dd36505c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tighearn=C3=A1n=20Carroll?= <13870403+tigh-latte@users.noreply.github.com> Date: Wed, 19 Aug 2026 19:18:05 +0100 Subject: [PATCH 01/21] npm credential helper --- .../commands/credential_helper/__init__.py | 2 + .../cli/commands/credential_helper/manage.py | 3 + .../cli/commands/credential_helper/npm.py | 48 ++ .../test_credential_helper_install.py | 457 ++++++++++++++++-- .../credential_helpers/docker/installer.py | 2 +- .../credential_helpers/launchers.py | 12 +- .../credential_helpers/npm/__init__.py | 4 + .../credential_helpers/npm/installer.py | 274 +++++++++++ cloudsmith_cli/credential_helpers/npm/rc.py | 158 ++++++ .../credential_helpers/npm/runtime.py | 67 +++ 10 files changed, 982 insertions(+), 45 deletions(-) create mode 100644 cloudsmith_cli/cli/commands/credential_helper/npm.py create mode 100644 cloudsmith_cli/credential_helpers/npm/__init__.py create mode 100644 cloudsmith_cli/credential_helpers/npm/installer.py create mode 100644 cloudsmith_cli/credential_helpers/npm/rc.py create mode 100644 cloudsmith_cli/credential_helpers/npm/runtime.py diff --git a/cloudsmith_cli/cli/commands/credential_helper/__init__.py b/cloudsmith_cli/cli/commands/credential_helper/__init__.py index 91d12bb9..ea835dc4 100644 --- a/cloudsmith_cli/cli/commands/credential_helper/__init__.py +++ b/cloudsmith_cli/cli/commands/credential_helper/__init__.py @@ -12,6 +12,7 @@ from .docker import docker as docker_cmd from .generic import generic as generic_cmd from .manage import install_cmd, list_cmd, uninstall_cmd +from .npm import npm as npm_cmd @click.group() @@ -32,6 +33,7 @@ def credential_helper(): """ +credential_helper.add_command(npm_cmd, name="npm") credential_helper.add_command(docker_cmd, name="docker") credential_helper.add_command(generic_cmd, name="generic") credential_helper.add_command(install_cmd, name="install") diff --git a/cloudsmith_cli/cli/commands/credential_helper/manage.py b/cloudsmith_cli/cli/commands/credential_helper/manage.py index 35cdc825..1c609ba3 100644 --- a/cloudsmith_cli/cli/commands/credential_helper/manage.py +++ b/cloudsmith_cli/cli/commands/credential_helper/manage.py @@ -12,6 +12,8 @@ import click +from cloudsmith_cli.credential_helpers.npm.installer import NPMInstaller + from ....credential_helpers.docker.installer import DockerInstaller from ... import utils from ...decorators import ( @@ -27,6 +29,7 @@ _INSTALLERS: dict[str, type] = { "docker": DockerInstaller, + "npm": NPMInstaller, } diff --git a/cloudsmith_cli/cli/commands/credential_helper/npm.py b/cloudsmith_cli/cli/commands/credential_helper/npm.py new file mode 100644 index 00000000..34fad5d9 --- /dev/null +++ b/cloudsmith_cli/cli/commands/credential_helper/npm.py @@ -0,0 +1,48 @@ +""" +Npm credential helper command. + +Implements the NPM credential helper protocol for Cloudsmith registries. +""" + +import sys + +import click + +from ....credential_helpers.npm import * +from ...decorators import common_api_auth_options, resolve_credentials + + +@click.command() +@click.argument("repo", required=False, default="npm.cloudsmith.io") +@common_api_auth_options +@resolve_credentials +def npm(opts, repo): + """ + Input (stdin): + Server URL as plain text (e.g. "npm.cloudsmith.io") + + Output (stdout): + Text: + + Exit codes: + 0: Success + 1: Error (no credentials available, not a Cloudsmith registry, etc.) + + Environment variables: + CLOUDSMITH_API_KEY: API key for authentication (optional) + CLOUDSMITH_ORG: Organisation slug (required for custom domain support) + """ + + exit_code, stdout, stderr = execute( + repo, + credential=opts.credential, + api_host=opts.api_host, + org=opts.org, + ) + + if stdout is not None: + click.echo(stdout, nl=False) + if stderr is not None: + click.echo(stderr, err=True) + + sys.exit(exit_code) 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 62ffc723..449afaf5 100644 --- a/cloudsmith_cli/cli/tests/commands/test_credential_helper_install.py +++ b/cloudsmith_cli/cli/tests/commands/test_credential_helper_install.py @@ -12,6 +12,9 @@ import click.testing import pytest +from _pytest.monkeypatch import MonkeyPatch + +from cloudsmith_cli.credential_helpers.npm.installer import NPMInstaller from ....core.credentials.models import CredentialResult from ....credential_helpers.default_domains import DomainType @@ -43,21 +46,37 @@ def runner(): @pytest.mark.parametrize( - "windows,expected_name,expected_content", + "windows,format,expected_name,expected_content", [ ( False, + "docker", "docker-credential-cloudsmith", '#!/bin/sh\nexec cloudsmith credential-helper docker "$@"\n', ), ( True, + "docker", "docker-credential-cloudsmith.cmd", "@echo off\r\ncloudsmith credential-helper docker %*\r\n", ), + ( + False, + "npm", + "npm-credential-cloudsmith", + '#!/bin/sh\nexec cloudsmith credential-helper npm "$@"\n', + ), + ( + True, + "npm", + "npm-credential-cloudsmith.cmd", + "@echo off\r\ncloudsmith credential-helper npm %*\r\n", + ), ], ) -def test_launcher_filename_and_content(windows, expected_name, expected_content): +def test_launcher_filename_and_content( + windows, format, expected_name, expected_content +): """Per-platform launcher name + body — guards the exact Windows .cmd bytes. Parameterised on ``windows`` rather than patching ``os.name`` so no @@ -65,11 +84,11 @@ def test_launcher_filename_and_content(windows, expected_name, expected_content) ``NotImplementedError`` on Python < 3.12). """ assert ( - _launcher_filename("docker-credential-cloudsmith", windows=windows) + _launcher_filename(f"{format}-credential-cloudsmith", windows=windows) == expected_name ) assert ( - _launcher_content("cloudsmith credential-helper docker", windows=windows) + _launcher_content(f"cloudsmith credential-helper {format}", windows=windows) == expected_content ) @@ -79,30 +98,46 @@ def test_launcher_filename_and_content(windows, expected_name, expected_content) # --------------------------------------------------------------------------- -def test_write_launcher_writes_executable_script(tmp_path): +@pytest.mark.parametrize( + "format", + [ + "docker", + "npm", + ], +) +def test_write_launcher_writes_executable_script(format, tmp_path): """write_launcher writes the shim with content + 0o755 on the host platform.""" dest = write_launcher( tmp_path, - "docker-credential-cloudsmith", - "cloudsmith credential-helper docker", + f"{format}-credential-cloudsmith", + f"cloudsmith credential-helper {format}", ) - expected = '#!/bin/sh\nexec cloudsmith credential-helper docker "$@"\n' + expected = f'#!/bin/sh\nexec cloudsmith credential-helper {format} "$@"\n' assert dest.read_text(encoding="utf-8") == expected assert stat.S_IMODE(dest.stat().st_mode) == 0o755 -def test_remove_launcher(tmp_path): +@pytest.mark.parametrize( + "format", + [ + "docker", + "npm", + ], +) +def test_remove_launcher(format, tmp_path): """remove_launcher returns True + file gone when present, False when absent.""" write_launcher( tmp_path, - "docker-credential-cloudsmith", - "cloudsmith credential-helper docker", + f"{format}-credential-cloudsmith", + f"cloudsmith credential-helper {format}", ) - assert remove_launcher(tmp_path, "docker-credential-cloudsmith") is True - assert not (tmp_path / "docker-credential-cloudsmith").exists() + launcher = f"{format}-credential-cloudsmith" + assert (tmp_path / launcher).exists() + assert remove_launcher(tmp_path, launcher) is True + assert not (tmp_path / launcher).exists() # Second call: file is gone now - assert remove_launcher(tmp_path, "docker-credential-cloudsmith") is False + assert remove_launcher(tmp_path, launcher) is False # --------------------------------------------------------------------------- @@ -190,6 +225,28 @@ def test_docker_installer_install(tmp_path, monkeypatch): assert (bin_dir / "docker-credential-cloudsmith").exists() +def test_npm_installer_install(tmp_path: Path, monkeypatch: MonkeyPatch): + """install sets default+extra domains, preserves foreign entries, writes the launcher.""" + npm_path = tmp_path / ".npmrc" + monkeypatch.setenv("NPM_CONFIG_USERCONFIG", str(npm_path)) + bin_dir = tmp_path / "bin" + monkeypatch.setenv("PATH", str(bin_dir)) + + # Seed a config with foreign data that must be preserved + npm_path.write_text("//registry.npmjs.org/:_authToken=abc123") + + installer = NPMInstaller() + installer.install(bin_dir=str(bin_dir), domains=("my.registry.example.com",)) + + assert ( + npm_path.read_text() == "//registry.npmjs.org/:_authToken=abc123\n" + f"//npm.cloudsmith.io/:tokenHelper={bin_dir}/npm-credential-cloudsmith\n" + f"//my.registry.example.com/:tokenHelper={bin_dir}/npm-credential-cloudsmith" + ) + # Launcher written + assert (bin_dir / "npm-credential-cloudsmith").exists() + + # --------------------------------------------------------------------------- # 6. install --dry-run # --------------------------------------------------------------------------- @@ -210,6 +267,21 @@ def test_docker_installer_dry_run(tmp_path, monkeypatch): assert any("docker.cloudsmith.io" in a for a in actions) +def test_npm_installer_dry_run(tmp_path, monkeypatch): + """dry_run=True: no launcher written, config.json absent, returns planned strings.""" + npm_path = tmp_path / ".npmrc" + monkeypatch.setenv("NPM_CONFIG_USERCONFIG", str(npm_path)) + bin_dir = tmp_path / "bin" + + installer = NPMInstaller() + actions = installer.install(bin_dir=str(bin_dir), dry_run=True) + + assert not (bin_dir / "npm-credential-cloudsmith").exists() + assert not npm_path.exists() + assert any("would write launcher" in a for a in actions) + assert any("npm.cloudsmith.io" in a for a in actions) + + # --------------------------------------------------------------------------- # 7. install idempotent # --------------------------------------------------------------------------- @@ -232,6 +304,23 @@ def test_docker_installer_idempotent(tmp_path, monkeypatch): assert any("already up to date" in a for a in actions) +def test_npm_installer_idempotent(tmp_path, monkeypatch): + """Second install run reports no change (config mtime unchanged, 'already up to date').""" + npm_path = tmp_path / ".npmrc" + monkeypatch.setenv("NPM_CONFIG_USERCONFIG", str(npm_path)) + bin_dir = tmp_path / "bin" + + installer = NPMInstaller() + installer.install(bin_dir=str(bin_dir)) + + mtime_before = npm_path.stat().st_mtime + actions = installer.install(bin_dir=str(bin_dir)) + mtime_after = npm_path.stat().st_mtime + + assert mtime_before == mtime_after + assert any("already up to date" in a for a in actions) + + # --------------------------------------------------------------------------- # 8. uninstall # --------------------------------------------------------------------------- @@ -277,6 +366,34 @@ def test_docker_installer_uninstall(tmp_path, monkeypatch): assert not launcher.exists() +def test_npm_installer_uninstall(tmp_path: Path, monkeypatch): + """uninstall removes only cloudsmith entries (foreign kept) + removes launcher. + + Also verifies --bin-dir: install to custom dir, uninstall with same --bin-dir removes it. + """ + npm_path = tmp_path / ".npmrc" + monkeypatch.setenv("NPM_CONFIG_USERCONFIG", str(npm_path)) + custom_bin_dir = tmp_path / "custom_bin" + + # Install to a custom bin dir + installer = NPMInstaller() + npm_path.write_text( + "//registry.npmjs.org/:_authToken=abc123\n" + f"//npm.cloudsmith.io/:tokenHelper={custom_bin_dir}/npm-credential-cloudsmith\n" + f"//my.custom.domain/:tokenHelper={custom_bin_dir}/npm-credential-cloudsmith" + ) + # Install launcher to custom_bin_dir + installer.install(bin_dir=str(custom_bin_dir)) + launcher = custom_bin_dir / "npm-credential-cloudsmith" + assert launcher.exists(), "Precondition: launcher must exist after install" + + # Uninstall — removes cloudsmith keys and launcher + installer.uninstall(bin_dir=str(custom_bin_dir)) + + assert npm_path.read_text() == "//registry.npmjs.org/:_authToken=abc123" + assert not launcher.exists() + + # --------------------------------------------------------------------------- # 9. DockerInstaller.status — str-not-Path guard # --------------------------------------------------------------------------- @@ -320,6 +437,44 @@ def test_docker_installer_status_type_contract(tmp_path, monkeypatch): assert not isinstance(launcher, Path) +def test_npm_installer_status_type_contract(tmp_path: Path, monkeypatch: MonkeyPatch): + """status()['launcher'] is str when installed and None when not — never a Path. + + Retained guard: the -F json Path-serialization regression. + """ + npm_path = tmp_path / ".npm" + monkeypatch.setenv("DOCKER_CONFIG", str(npm_path)) + bin_dir = tmp_path / "bin" + + installer = NPMInstaller() + + # Before install: launcher is None + with patch( + "cloudsmith_cli.credential_helpers.npm.installer.resolve_bin_dir", + return_value=bin_dir, + ): + result_before = installer.status() + + assert result_before["launcher"] is None + assert not isinstance(result_before["launcher"], Path) + + # After install: launcher is a non-None str + installer.install(bin_dir=str(bin_dir)) + with patch( + "cloudsmith_cli.credential_helpers.npm.installer.resolve_bin_dir", + return_value=bin_dir, + ): + result_after = installer.status() + + launcher = result_after["launcher"] + assert launcher is not None + assert isinstance(launcher, str), ( + f"status()['launcher'] must be str, got {type(launcher).__name__!r}" + ) + assert launcher.endswith("npm-credential-cloudsmith") + assert not isinstance(launcher, Path) + + # --------------------------------------------------------------------------- # 10. autodiscovery # --------------------------------------------------------------------------- @@ -549,34 +704,50 @@ def test_manage_cli_unknown_helper_exits_nonzero(runner): # --------------------------------------------------------------------------- -def test_manage_cli_dry_run_exits_0(runner, tmp_path, monkeypatch): +@pytest.mark.parametrize( + "helper", + [ + "docker", + "npm", + ], +) +def test_manage_cli_dry_run_exits_0(helper, runner, tmp_path, monkeypatch): """install docker --no-discover --dry-run exits 0.""" monkeypatch.setenv("DOCKER_CONFIG", str(tmp_path / ".docker")) + monkeypatch.setenv("NPM_CONFIG_USERCONFIG", str(tmp_path / ".npmrc")) from ....cli.commands.credential_helper.manage import install_cmd result = runner.invoke( install_cmd, - ["docker", "--no-discover", "--dry-run", "--bin-dir", str(tmp_path / "bin")], + [helper, "--no-discover", "--dry-run", "--bin-dir", str(tmp_path / "bin")], ) assert result.exit_code == 0, result.output assert "would" in result.output.lower() or "dry run" in result.output.lower() +@pytest.mark.parametrize( + "format,installer", + [ + ("docker", DockerInstaller), + ("npm", NPMInstaller), + ], +) def test_manage_cli_passes_resolved_credential_to_installer( - runner, tmp_path, monkeypatch + format, installer, runner, tmp_path, monkeypatch ): """install hands the resolved CredentialResult to the installer intact.""" monkeypatch.setenv("DOCKER_CONFIG", str(tmp_path / ".docker")) + monkeypatch.setenv("NPM_CONFIG_USERCONFIG", str(tmp_path / ".npmrc")) from ....cli.commands.credential_helper.manage import install_cmd - with patch.object(DockerInstaller, "install", return_value=[]) as mock_install: + with patch.object(installer, "install", return_value=[]) as mock_install: result = runner.invoke( install_cmd, [ - "docker", + format, "--no-discover", "--bin-dir", str(tmp_path / "bin"), @@ -596,14 +767,21 @@ def test_manage_cli_passes_resolved_credential_to_installer( # --------------------------------------------------------------------------- -def test_path_warning_when_bin_dir_not_on_path(tmp_path, monkeypatch): +@pytest.mark.parametrize( + "installer", + [ + DockerInstaller, + NPMInstaller, + ], +) +def test_path_warning_when_bin_dir_not_on_path(installer, tmp_path, monkeypatch): """install returns a WARNING action when target bin_dir is not on PATH.""" - docker_dir = tmp_path / ".docker" - monkeypatch.setenv("DOCKER_CONFIG", str(docker_dir)) + monkeypatch.setenv("DOCKER_CONFIG", str(tmp_path / ".docker")) + monkeypatch.setenv("NPM_CONFIG_USERCONFIG", str(tmp_path / ".npmrc")) bin_dir = tmp_path / "bin" monkeypatch.setenv("PATH", "/usr/bin:/usr/local/bin") - installer = DockerInstaller() + installer = installer() actions = installer.install(bin_dir=str(bin_dir)) warning_actions = [a for a in actions if a.startswith("WARNING")] @@ -620,9 +798,19 @@ def test_path_warning_when_bin_dir_not_on_path(tmp_path, monkeypatch): os.name != "posix" or (hasattr(os, "geteuid") and os.geteuid() == 0), reason="permission test only meaningful on POSIX as non-root", ) -def test_unwritable_bin_dir_gives_click_exception(runner, tmp_path, monkeypatch): +@pytest.mark.parametrize( + "format", + [ + "docker", + "npm", + ], +) +def test_unwritable_bin_dir_gives_click_exception( + format, runner, tmp_path, monkeypatch +): """install with an unwritable --bin-dir exits non-zero as ClickException/SystemExit, not raw OSError.""" monkeypatch.setenv("DOCKER_CONFIG", str(tmp_path / ".docker")) + monkeypatch.setenv("NPM_CONFIG_USERCONFIG", str(tmp_path / ".npmrc")) from ....cli.commands.credential_helper.manage import install_cmd @@ -631,7 +819,7 @@ def test_unwritable_bin_dir_gives_click_exception(runner, tmp_path, monkeypatch) ro_dir.chmod(0o500) try: - result = runner.invoke(install_cmd, ["docker", "--bin-dir", str(ro_dir)]) + result = runner.invoke(install_cmd, [format, "--bin-dir", str(ro_dir)]) finally: ro_dir.chmod(0o700) @@ -646,16 +834,6 @@ def test_unwritable_bin_dir_gives_click_exception(runner, tmp_path, monkeypatch) # --------------------------------------------------------------------------- -_STUB_STATUS = { - "launcher": "/some/bin/docker-credential-cloudsmith", - "hosts": ["docker.cloudsmith.io"], -} - - -def _stub_status_fn(_self): - return _STUB_STATUS - - @pytest.mark.parametrize( "cmd_name,cli_args,expected_helper,expect_dry_run_key", [ @@ -674,6 +852,20 @@ def _stub_status_fn(_self): "docker", True, ), + ( + "install_cmd", + [ + "npm", + "--dry-run", + "--no-discover", + "--bin-dir", + "{bin_dir}", + "-F", + "json", + ], + "npm", + True, + ), # uninstall dry-run with -F json ( "uninstall_cmd", @@ -681,6 +873,12 @@ def _stub_status_fn(_self): "docker", True, ), + ( + "uninstall_cmd", + ["npm", "--dry-run", "-F", "json"], + "npm", + True, + ), # list with -F json ( "list_cmd", @@ -688,6 +886,12 @@ def _stub_status_fn(_self): "docker", False, ), + ( + "list_cmd", + ["-F", "json"], + "npm", + False, + ), ], ) def test_output_format_json( @@ -703,8 +907,23 @@ def test_output_format_json( Retained guard: list -F json serialises a launcher path (str), not a Path object. """ + + def _docker_stub_status_fn(_self): + return { + "launcher": "/some/bin/docker-credential-cloudsmith", + "hosts": ["docker.cloudsmith.io"], + } + + def _npm_stub_status_fn(_self): + return { + "launcher": "/some/bin/npm-credential-cloudsmith", + "hosts": ["npm.cloudsmith.io"], + } + monkeypatch.setenv("DOCKER_CONFIG", str(tmp_path / ".docker")) - monkeypatch.setattr(DockerInstaller, "status", _stub_status_fn) + monkeypatch.setenv("NPM_CONFIG_USERCONFIG", str(tmp_path / ".npmrc")) + monkeypatch.setattr(DockerInstaller, "status", _docker_stub_status_fn) + monkeypatch.setattr(NPMInstaller, "status", _npm_stub_status_fn) from ....cli.commands.credential_helper import manage as manage_mod @@ -727,7 +946,7 @@ def test_output_format_json( assert isinstance(data, list) entry = next(e for e in data if e["helper"] == expected_helper) assert "launcher" in entry - assert entry["launcher"] == "/some/bin/docker-credential-cloudsmith" + assert entry["launcher"] == f"/some/bin/{expected_helper}-credential-cloudsmith" assert "hosts" in entry else: assert data["helper"] == expected_helper @@ -737,15 +956,17 @@ def test_output_format_json( assert data["dry_run"] is True -def test_output_format_default_shows_human_text(runner, tmp_path, monkeypatch): +@pytest.mark.parametrize("helper", ["docker", "npm"]) +def test_output_format_default_shows_human_text(helper, runner, tmp_path, monkeypatch): """Default (no -F) install dry-run shows human-readable text, not raw JSON.""" monkeypatch.setenv("DOCKER_CONFIG", str(tmp_path / ".docker")) + monkeypatch.setenv("NPM_CONFIG_USERCONFIG", str(tmp_path / ".npmrc")) from ....cli.commands.credential_helper.manage import install_cmd result = runner.invoke( install_cmd, - ["docker", "--dry-run", "--no-discover", "--bin-dir", str(tmp_path / "bin")], + [helper, "--dry-run", "--no-discover", "--bin-dir", str(tmp_path / "bin")], catch_exceptions=False, ) @@ -758,7 +979,7 @@ def test_output_format_default_shows_human_text(runner, tmp_path, monkeypatch): # --------------------------------------------------------------------------- -def test_install_coerces_malformed_cred_helpers(tmp_path, monkeypatch): +def test_docker_install_coerces_malformed_cred_helpers(tmp_path, monkeypatch): """install coerces a non-dict credHelpers (list) rather than raising TypeError.""" docker_dir = tmp_path / ".docker" docker_dir.mkdir(parents=True) @@ -780,7 +1001,30 @@ def test_install_coerces_malformed_cred_helpers(tmp_path, monkeypatch): assert cfg["credHelpers"]["docker.cloudsmith.io"] == "cloudsmith" -def test_uninstall_tolerates_malformed_cred_helpers(tmp_path, monkeypatch): +def test_npm_install_coerces_malformed_cred_helpers(tmp_path: Path, monkeypatch): + """install coerces a non-dict credHelpers (list) rather than raising TypeError.""" + npm_path = tmp_path / ".npmrc" + monkeypatch.setenv("NPM_CONFIG_USERCONFIG", str(npm_path)) + bin_dir = tmp_path / "bin" + + # Seed config with a malformed credHelpers value (list instead of dict) + npm_path.write_text( + "badtext\n//registry.npmjs.org/:_authToken=abc123\nmorebadtext\n" + ) + + installer = NPMInstaller() + # Must not raise + installer.install(bin_dir=str(bin_dir), discover=False) + + assert ( + npm_path.read_text() == "badtext\n" + "//registry.npmjs.org/:_authToken=abc123\n" + "morebadtext\n" + f"//npm.cloudsmith.io/:tokenHelper={bin_dir}/npm-credential-cloudsmith" + ) + + +def test_docker_uninstall_tolerates_malformed_cred_helpers(tmp_path, monkeypatch): """uninstall treats a non-dict credHelpers (string) as a no-op rather than raising.""" docker_dir = tmp_path / ".docker" docker_dir.mkdir(parents=True) @@ -798,6 +1042,137 @@ def test_uninstall_tolerates_malformed_cred_helpers(tmp_path, monkeypatch): installer.uninstall(bin_dir=str(bin_dir)) +def test_npm_uninstall_tolerates_malformed_cred_helpers(tmp_path: Path, monkeypatch): + """uninstall treats a non-dict credHelpers (string) as a no-op rather than raising.""" + npm_path = tmp_path / ".npmrc" + monkeypatch.setenv("NPM_CONFIG_USERCONFIG", str(npm_path)) + bin_dir = tmp_path / "bin" + + # Seed config with a malformed credHelpers value (string instead of dict) + npm_path.write_text( + "badtext\n" + "//registry.npmjs.org/:_authToken=abc123\n" + "morebadtext\n" + f"//npm.cloudsmith.io/:tokenHelper={bin_dir}/npm-credential-cloudsmith" + ) + + installer = NPMInstaller() + # Must not raise + installer.uninstall(bin_dir=str(bin_dir)) + + assert ( + npm_path.read_text() + == "badtext\n//registry.npmjs.org/:_authToken=abc123\nmorebadtext" + ) + + +@pytest.mark.parametrize("char", [";", "#"]) +def test_npm_uninstall_tolerates_comments(char, tmp_path: Path, monkeypatch): + """uninstall treats a non-dict credHelpers (string) as a no-op rather than raising.""" + npm_path = tmp_path / ".npmrc" + monkeypatch.setenv("NPM_CONFIG_USERCONFIG", str(npm_path)) + bin_dir = tmp_path / "bin" + + # Seed config with a malformed credHelpers value (string instead of dict) + npm_path.write_text( + "//registry.npmjs.org/:_authToken=abc123\n" + f"//npm.cloudsmith.io/:tokenHelper={bin_dir}/npm-credential-cloudsmith{char}wowcommented" + ) + + installer = NPMInstaller() + # Must not raise + installer.uninstall(bin_dir=str(bin_dir)) + + assert npm_path.read_text() == "//registry.npmjs.org/:_authToken=abc123" + + +def test_npm_uninstall_tolerates_leading_whitespace(tmp_path: Path, monkeypatch): + npm_path = tmp_path / ".npmrc" + monkeypatch.setenv("NPM_CONFIG_USERCONFIG", str(npm_path)) + bin_dir = tmp_path / "bin" + + # Seed config with a malformed credHelpers value (string instead of dict) + npm_path.write_text( + "//registry.npmjs.org/:_authToken=abc123\n" + f" //npm.cloudsmith.io/:tokenHelper={bin_dir}/npm-credential-cloudsmith\n" + "//unrelated.registry/:_authToken=abc123" + ) + + installer = NPMInstaller() + # Must not raise + installer.uninstall(bin_dir=str(bin_dir)) + + assert ( + npm_path.read_text() + == "//registry.npmjs.org/:_authToken=abc123\n//unrelated.registry/:_authToken=abc123" + ) + + +def test_npm_uninstall_preserves_leading_whitespace(tmp_path: Path, monkeypatch): + npm_path = tmp_path / ".npmrc" + monkeypatch.setenv("NPM_CONFIG_USERCONFIG", str(npm_path)) + bin_dir = tmp_path / "bin" + + # Seed config with a malformed credHelpers value (string instead of dict) + npm_path.write_text( + " //registry.npmjs.org/:_authToken=abc123\n" + f"//npm.cloudsmith.io/:tokenHelper={bin_dir}/npm-credential-cloudsmith\n" + "//unrelated.registry/:_authToken=abc123" + ) + + installer = NPMInstaller() + # Must not raise + installer.uninstall(bin_dir=str(bin_dir)) + + assert ( + npm_path.read_text() + == " //registry.npmjs.org/:_authToken=abc123\n//unrelated.registry/:_authToken=abc123" + ) + + +@pytest.mark.parametrize("kind", ["_auth", "_authToken", "_password"]) +def test_npm_install_warn_on_auth_configured(kind, tmp_path: Path, monkeypatch): + npm_path = tmp_path / ".npmrc" + monkeypatch.setenv("NPM_CONFIG_USERCONFIG", str(npm_path)) + bin_dir = tmp_path / "bin" + + npm_path.write_text(f"//npm.cloudsmith.io/:{kind}=token") + + installer = NPMInstaller() + actions = installer.install(bin_dir=str(bin_dir), discover=False) + + # file was untouched + assert npm_path.read_text() == f"//npm.cloudsmith.io/:{kind}=token" + + warning_actions = [a for a in actions if a.startswith("WARNING")] + assert warning_actions, f"Expected a WARNING action, got: {actions}" + + +@pytest.mark.parametrize("kind", ["_auth", "_authToken", "_password"]) +def test_npm_install_warn_on_auth_configured_partial_write( + kind, tmp_path: Path, monkeypatch +): + npm_path = tmp_path / ".npmrc" + monkeypatch.setenv("NPM_CONFIG_USERCONFIG", str(npm_path)) + bin_dir = tmp_path / "bin" + + npm_path.write_text(f"//npm.cloudsmith.io/:{kind}=token") + + installer = NPMInstaller() + actions = installer.install( + bin_dir=str(bin_dir), discover=False, domains=("my.registry.example.com",) + ) + + # file was partially written to + assert ( + npm_path.read_text() == f"//npm.cloudsmith.io/:{kind}=token\n" + f"//my.registry.example.com/:tokenHelper={bin_dir}/npm-credential-cloudsmith" + ) + + warning_actions = [a for a in actions if a.startswith("WARNING")] + assert warning_actions, f"Expected a WARNING action, got: {actions}" + + # --------------------------------------------------------------------------- # 18. frozen-binary launcher target (PyInstaller standalone) # --------------------------------------------------------------------------- diff --git a/cloudsmith_cli/credential_helpers/docker/installer.py b/cloudsmith_cli/credential_helpers/docker/installer.py index 818812d8..c9958f78 100644 --- a/cloudsmith_cli/credential_helpers/docker/installer.py +++ b/cloudsmith_cli/credential_helpers/docker/installer.py @@ -224,7 +224,7 @@ def mutate(config: dict) -> None: if not is_on_path(target_dir): actions.append( f"WARNING: {target_dir} is not on PATH — " - "add it to your PATH so Docker can find docker-credential-cloudsmith" + "add it to your PATH so pnpm can find npm-credential-cloudsmith" ) return actions diff --git a/cloudsmith_cli/credential_helpers/launchers.py b/cloudsmith_cli/credential_helpers/launchers.py index acf978ef..69b2698b 100644 --- a/cloudsmith_cli/credential_helpers/launchers.py +++ b/cloudsmith_cli/credential_helpers/launchers.py @@ -54,7 +54,7 @@ def _user_bin_dir(windows: bool) -> Path: return Path.home() / ".local" / "bin" -def write_launcher(bin_dir: Path, name: str, target_cmd: str) -> Path: +def write_launcher(bin_dir: Path, name: str, target_cmd: str, dry_run=False) -> Path: """Write a launcher script for *name* in *bin_dir* that execs *target_cmd*. Parameters @@ -73,6 +73,11 @@ def write_launcher(bin_dir: Path, name: str, target_cmd: str) -> Path: The path of the written file. """ windows = _is_windows() + if dry_run: + if windows: + return bin_dir / f"{name}.cmd" + else: + return bin_dir / name bin_dir = Path(bin_dir) bin_dir.mkdir(parents=True, exist_ok=True) @@ -86,7 +91,7 @@ def write_launcher(bin_dir: Path, name: str, target_cmd: str) -> Path: return dest -def remove_launcher(bin_dir: Path, name: str) -> bool: +def remove_launcher(bin_dir: Path, name: str, dry_run=False) -> bool: """Remove a launcher previously created by :func:`write_launcher`. Parameters @@ -104,7 +109,8 @@ def remove_launcher(bin_dir: Path, name: str) -> bool: target = Path(bin_dir) / _launcher_filename(name, windows=_is_windows()) if target.exists(): - target.unlink() + if not dry_run: + target.unlink() return True return False diff --git a/cloudsmith_cli/credential_helpers/npm/__init__.py b/cloudsmith_cli/credential_helpers/npm/__init__.py new file mode 100644 index 00000000..76414023 --- /dev/null +++ b/cloudsmith_cli/credential_helpers/npm/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2026 Cloudsmith Ltd +from .runtime import execute, get_credentials + +__all__ = ["execute", "get_credentials"] diff --git a/cloudsmith_cli/credential_helpers/npm/installer.py b/cloudsmith_cli/credential_helpers/npm/installer.py new file mode 100644 index 00000000..0c767d74 --- /dev/null +++ b/cloudsmith_cli/credential_helpers/npm/installer.py @@ -0,0 +1,274 @@ +import logging +import os +import sys +from pathlib import Path + +from cloudsmith_cli.credential_helpers.backends import BackendKind +from cloudsmith_cli.credential_helpers.custom_domains import get_format_domains +from cloudsmith_cli.credential_helpers.launchers import ( + is_on_path, + remove_launcher, + resolve_bin_dir, + write_launcher, +) +from cloudsmith_cli.credential_helpers.npm.rc import NPMRC, AuthKeyConflictError + +from ...core.credentials.models import CredentialResult + +logger = logging.getLogger(__name__) + + +def _config_path() -> Path: + npm_user_config = os.environ.get("NPM_CONFIG_USERCONFIG") + + if npm_user_config: + return Path(npm_user_config) + + return Path.home() / ".npmrc" + + +class NPMInstaller: + LAUNCHER_NAME = "npm-credential-cloudsmith" + TARGET_CMD = "cloudsmith credential-helper npm" + HELPER_VALUE = "cloudsmith" + DEFAULT_HOST = "npm.cloudsmith.io" + + name = "npm" + summary = "NPM credential helper for Cloudsmith registries" + + @classmethod + def _resolve_target_cmd(cls) -> str: + """Return the command the launcher forwards to. + + A pip/source install resolves the bare ``cloudsmith`` command via + ``PATH``. A frozen standalone binary (PyInstaller) is not guaranteed + to be on ``PATH`` under that name, so point the launcher at the + absolute executable instead — mirroring the frozen handling in + :func:`cloudsmith_cli.cli.commands.mcp._get_server_config`. The path + is quoted so a directory containing spaces still execs correctly. + """ + if getattr(sys, "frozen", False): + return f'"{sys.executable}" credential-helper npm' + return cls.TARGET_CMD + + def install( + self, + *, + bin_dir: str | None = None, + domains: tuple[str, ...] = (), + discover: bool = True, + refresh: bool = False, + org: str | None = None, + credential: CredentialResult | None = None, + api_host: str | None = None, + dry_run: bool = False, + ) -> list[str]: + """Install the NPM crednetial helper. + + Writes the launcher binary and registers Cloudsmith registry hosts in + ``${NPM_CONFIG_USERCONFIG:-~/.npmrc}``. + + Parameters + ---------- + bin_dir: + Override for the directory to install the launcher. Defaults to + :func:`resolve_bin_dir` auto-detection. + domains: + Additional registry hostnames to configure (in addition to the + default ``npm.cloudsmith.io``). + discover: + When ``True`` (default), attempt to auto-discover NPM custom + domains via the Cloudsmith API. Discovery is best-effort and never + prevents the defaults from being registered. + refresh: + When ``True``, bypass the domain cache and fetch fresh data from + the API. Only meaningful when *discover* is also ``True``. + org: + Cloudsmith organisation slug used for custom-domain discovery. + credential: + Resolved credential used for custom-domain discovery. + api_host: + Cloudsmith API host URL override. + dry_run: + When ``True``, compute and return planned actions without writing + any files. + + Returns + ------- + list[str] + Human-readable descriptions of actions taken (or planned, when + *dry_run* is ``True``). + """ + target_dir = resolve_bin_dir(bin_dir) + config_path = _config_path() + + actions: list[str] = [] + + # Start with the default host plus any explicitly requested domains. + hosts: list[str] = [self.DEFAULT_HOST, *domains] + + if discover: + if dry_run: + actions.append("skipped custom-domain auto-discovery (dry run)") + elif org and credential and credential.api_key: + try: + discovered = get_format_domains( + org, + BackendKind.NPM, + credential=credential, + api_host=api_host, + refresh=refresh, + ) + except Exception as exc: # pylint: disable=broad-except + # Discovery is best-effort: never let it abort the install of + # the defaults. (Network/SDK errors degrade to a warning; + # ApiException is already handled inside.) + actions.append( + f"WARNING: custom-domain auto-discovery failed: {exc}" + ) + discovered = [] + new_hosts = [h for h in discovered if h not in hosts] + hosts.extend(discovered) + actions.append(f"discovered {len(new_hosts)} new NPM custom domain(s)") + else: + logger.debug( + "skipped auto-discovery" + " (no organization/credentials; pass --no-discover to silence)" + ) + + # De-duplicate while preserving order + seen: set[str] = set() + deduped: list[str] = [] + for h in hosts: + if h not in seen: + seen.add(h) + deduped.append(h) + hosts = deduped + + # Real install + launcher_path = write_launcher( + target_dir, self.LAUNCHER_NAME, self._resolve_target_cmd(), dry_run=dry_run + ) + if dry_run: + actions.append(f"would write launcher {launcher_path}") + else: + actions.append(f"wrote launcher {launcher_path}") + + with NPMRC(config_path, modifiable=not dry_run) as rc: + failures = 0 + for host in hosts: + entry = NPMRC.URLEntry.from_values( + host, "tokenHelper", str(launcher_path) + ) + try: + added = rc.add(entry) + except AuthKeyConflictError as e: + failures += 1 + if dry_run: + actions.append( + f"WARNING would not set {entry} in {config_path} as {e} already set" + ) + else: + actions.append( + f"WARNING did not set {entry} in {config_path} as {e} already set" + ) + continue + + if dry_run: + if added: + actions.append(f"would set {entry} in {config_path}") + else: + actions.append( + f"{entry} already set in {config_path} (no change)" + ) + + if failures == len(hosts): + pass + elif failures > 0: + # warn with error + pass + elif not rc.modified: + actions.append(f"npmrc already up to date ({config_path})") + + if not is_on_path(target_dir): + actions.append( + f"WARNING: {target_dir} is not on PATH — " + "add it to your PATH so Docker can find docker-credential-cloudsmith" + ) + return actions + + def uninstall( + self, *, bin_dir: str | None = None, dry_run: bool = False + ) -> list[str]: + config_path = _config_path() + actions: list[str] = [] + + target_dir = resolve_bin_dir(bin_dir) + + if not config_path.exists(): + actions.append(".npmrc file doesn't exist, nothing to do") + return actions + + if os.name == "nt": + launcher_path = target_dir / f"{self.LAUNCHER_NAME}.cmd" + else: + launcher_path = target_dir / self.LAUNCHER_NAME + + with NPMRC(config_path, modifiable=not dry_run) as rc: + hosts: list[str] = rc.helped_hosts(str(launcher_path)) + for host in hosts: + removed = rc.remove(NPMRC.URLEntry.from_values(host, "tokenHelper")) + if dry_run: + if removed: + actions.append( + f"would remove //{host}/:tokenHelper from {config_path}" + ) + else: + actions.append(f"domain {host} not installed in {config_path}") + + removed = remove_launcher(target_dir, self.LAUNCHER_NAME, dry_run=dry_run) + if removed: + if dry_run: + actions.append(f"would remove launcher {launcher_path}") + else: + actions.append(f"removed launcher {launcher_path}") + else: + actions.append(f"launcher not found at {launcher_path} (nothing to remove)") + + return actions + + def status(self) -> dict: + """Return current installation status. + + Returns + ------- + dict + A dict with keys: + + ``"launcher"`` + The :class:`~pathlib.Path` of the launcher if it exists, + else ``None``. + ``"hosts"`` + List of hostnames in ``config.json``'s ``credHelpers`` block + whose value equals ``"cloudsmith"``. + """ + target_dir = resolve_bin_dir() + if os.name == "nt": + launcher_path: Path | None = target_dir / f"{self.LAUNCHER_NAME}.cmd" + else: + launcher_path = target_dir / self.LAUNCHER_NAME + + if launcher_path is not None and not launcher_path.exists(): + launcher_path = None + + config_path = _config_path() + + hosts: list[str] = [] + if config_path.exists(): + with NPMRC(config_path) as rc: + hosts: list[str] = rc.helped_hosts(str(launcher_path)) + + return { + "launcher": str(launcher_path) if launcher_path is not None else None, + "hosts": hosts, + } diff --git a/cloudsmith_cli/credential_helpers/npm/rc.py b/cloudsmith_cli/credential_helpers/npm/rc.py new file mode 100644 index 00000000..3c1853fe --- /dev/null +++ b/cloudsmith_cli/credential_helpers/npm/rc.py @@ -0,0 +1,158 @@ +from pathlib import Path + +from typing_extensions import Self + + +class AuthKeyConflictError(KeyError): + """Raised when adding tokenHelper but auth is already set""" + + +class NPMRC: + class URLEntry(str): + _key: str + _value: str | None + + @classmethod + def from_values(cls, domain: str, key: str, value: str | None = None): + return cls(f"//{domain}/:{key}={value}") + + @property + def id(self) -> str: + return f"{self._domain}::{self._key}" + + def __new__(cls, value): + obj = super().__new__(cls, value) + return obj + + def __init__(self, entry: str): + self._raw: str = entry + + ## if line contains trailing comment, ignore + for i, c in enumerate(entry): + if c in ";#": + entry = entry[:i] + break + + # remove any starting whitespace + stripped_entry = entry.lstrip() + + # track the starting whitespace + self._leading = entry[: len(stripped_entry) - len(entry)] + if not stripped_entry.startswith("/"): + raise ValueError("invalid url, should start with ``//``") + + stripped_entry = stripped_entry.lstrip("/") + + domain, kv = stripped_entry.split(":", 1) + self._domain = domain.rstrip("/") + if not self._domain: + raise ValueError(f"entry {self._raw} is missing domain") + + self._key, self._value = kv.split("=", 1) + if not self._key: + raise ValueError(f"entry {self._raw} is missing key") + + def __str__(self) -> str: + return f"{self._leading}//{self._domain}/:{self._key}={self._value}" + + @property + def modified(self): + return self._modified + + def __init__(self, path: Path, modifiable=False) -> None: + self._modifiable = modifiable + self._modified = False + self._path: Path = path + self._lines: list[str | NPMRC.URLEntry] = [] + self._mapping: dict[str, str | None] = {} + + def __enter__(self) -> Self: + if not self._path.exists(): + if self._modifiable: + self._path.touch() + else: + return self + + self.parse() + + return self + + def __exit__(self, *_): + self.write() + return False + + def __contains__(self, item: str | URLEntry) -> bool: + if isinstance(item, NPMRC.URLEntry): + return item.id in self._mapping and ( + item._value is not None or self._mapping[item.id] == item._value + ) + + return any(item in line for line in self._lines) + + def create_if_not_exists(self): + if not self._path.exists(): + self._path.touch() + + def parse(self): + with open(self._path) as f: + for line in f: + if line.lstrip().startswith("//"): + entry = NPMRC.URLEntry(line.rstrip("\n")) + self._lines.append(entry) + self._mapping[entry.id] = entry._value + else: + self._lines.append(line.rstrip("\n")) + + def add(self, entry: URLEntry) -> bool: + if entry.id in self._mapping: + return False + + if NPMRC.URLEntry.from_values(entry._domain, "_authToken") in self: + raise AuthKeyConflictError("_authToken") + + if NPMRC.URLEntry.from_values(entry._domain, "_auth") in self: + raise AuthKeyConflictError("_auth") + + if NPMRC.URLEntry.from_values(entry._domain, "_password") in self: + raise AuthKeyConflictError("_password") + + self._mapping[entry.id] = entry._value + self._lines.append(entry) + self._modified = True + return True + + def remove(self, entry: URLEntry) -> bool: + if entry.id not in self._mapping: + return False + + for i, line in enumerate(self._lines): + if ( + isinstance(line, NPMRC.URLEntry) + and line._domain == entry._domain + and line._key == entry._key + ): + self._lines.pop(i) + del self._mapping[line.id] + self._modified = True + return True + + return False + + def helped_hosts(self, v: str) -> list[str]: + return [ + line._domain + for line in self._lines + if isinstance(line, NPMRC.URLEntry) + and line._key == "tokenHelper" + and line._value == v + ] + + def write(self): + if not self._modifiable: + return + + if not self.modified: + return + + with open(self._path, "w") as f: + f.write("\n".join(self._lines)) diff --git a/cloudsmith_cli/credential_helpers/npm/runtime.py b/cloudsmith_cli/credential_helpers/npm/runtime.py new file mode 100644 index 00000000..2c893fe8 --- /dev/null +++ b/cloudsmith_cli/credential_helpers/npm/runtime.py @@ -0,0 +1,67 @@ +import logging + +from ..backends import BackendKind +from ..common import is_cloudsmith_domain + +logger = logging.getLogger(__name__) + +_REFUSAL_MESSAGE = ( + "Error: Unable to retrieve credentials. " + "Provide credentials via the CLOUDSMITH_API_KEY environment variable, " + "credentials.ini, the system keyring, or an OIDC service. " + "Verify current authentication with `cloudsmith whoami --verbose`." +) + + +def get_credentials(server_url, credential=None, api_host=None, org=None): + """ + Get credentials for a Cloudsmith NPM registry. + + Verifies the URL is a Cloudsmith registry (including custom domains) + and returns credentials if available. + + Args: + server_url: The Docker registry server URL + credential: Pre-resolved CredentialResult from the provider chain + api_host: Cloudsmith API host URL + org: Organisation slug whose custom domains to match against + + Returns: + dict: Credentials with 'Username' and 'Secret' keys, or None + """ + if not credential or not credential.api_key: + return None + + if not is_cloudsmith_domain( + server_url, + credential=credential, + api_host=api_host, + backend_kind=BackendKind.NPM, + org=org, + ): + return None + + return credential.api_key + + +def execute( + repo, credential=None, api_host=None, org=None +) -> tuple[int, str | None, str | None]: + return _get_execute(repo, credential=credential, api_host=api_host, org=org) + + +def _get_execute( + server_url: str, credential=None, api_host=None, org=None +) -> tuple[int, str | None, str | None]: + try: + cred = get_credentials( + server_url, credential=credential, api_host=api_host, org=org + ) + if not cred: + return (1, None, _REFUSAL_MESSAGE) + + return (0, cred, None) + except Exception as exc: + logger.debug("npm credential-helper get failed: %s", exc, exc_info=True) + + return 1, None, None From 8631b1191361d53518d8307adcf08d0200e49e3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tighearn=C3=A1n=20Carroll?= <13870403+tigh-latte@users.noreply.github.com> Date: Thu, 20 Aug 2026 09:58:05 +0100 Subject: [PATCH 02/21] exit code on any failure. update tests to cover this case --- .../cli/commands/credential_helper/manage.py | 6 ++++++ .../commands/test_credential_helper_install.py | 16 ++++++++++++---- cloudsmith_cli/credential_helpers/generic.py | 16 ++++++++++++++++ .../credential_helpers/npm/installer.py | 10 +++------- cloudsmith_cli/credential_helpers/npm/rc.py | 8 ++++++++ 5 files changed, 45 insertions(+), 11 deletions(-) diff --git a/cloudsmith_cli/cli/commands/credential_helper/manage.py b/cloudsmith_cli/cli/commands/credential_helper/manage.py index 1c609ba3..f0604556 100644 --- a/cloudsmith_cli/cli/commands/credential_helper/manage.py +++ b/cloudsmith_cli/cli/commands/credential_helper/manage.py @@ -12,6 +12,7 @@ import click +from cloudsmith_cli.credential_helpers.generic import InstallError from cloudsmith_cli.credential_helpers.npm.installer import NPMInstaller from ....credential_helpers.docker.installer import DockerInstaller @@ -133,6 +134,7 @@ def install_cmd( # Disable automatic custom-domain discovery $ cloudsmith credential-helper install docker --no-discover """ + ec = 0 installer = _get_installer(helper) try: actions = installer.install( @@ -149,6 +151,9 @@ def install_cmd( raise click.ClickException( f"Failed to install {helper!r} credential helper: {exc}" ) + except InstallError as exc: + actions = exc.actions + ec = exc.exit_code use_stderr = utils.should_use_stderr(opts) warnings = [a for a in actions if a.startswith("WARNING")] @@ -168,6 +173,7 @@ def install_cmd( click.echo(f" {action}" if dry_run else action, err=use_stderr) for warning in warnings: click.secho(f" {warning}" if dry_run else warning, err=True, fg="yellow") + sys.exit(ec) # --------------------------------------------------------------------------- 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 449afaf5..5edb04a7 100644 --- a/cloudsmith_cli/cli/tests/commands/test_credential_helper_install.py +++ b/cloudsmith_cli/cli/tests/commands/test_credential_helper_install.py @@ -2,6 +2,7 @@ """Tests for credential-helper install/uninstall/list commands and launchers.""" from __future__ import annotations +from cloudsmith_cli.credential_helpers.generic import InstallError import json import os @@ -1139,7 +1140,10 @@ def test_npm_install_warn_on_auth_configured(kind, tmp_path: Path, monkeypatch): npm_path.write_text(f"//npm.cloudsmith.io/:{kind}=token") installer = NPMInstaller() - actions = installer.install(bin_dir=str(bin_dir), discover=False) + with pytest.raises(InstallError) as e: + installer.install(bin_dir=str(bin_dir), discover=False) + assert e.value.exit_code == 1 + actions = e.value.actions # file was untouched assert npm_path.read_text() == f"//npm.cloudsmith.io/:{kind}=token" @@ -1159,9 +1163,13 @@ def test_npm_install_warn_on_auth_configured_partial_write( npm_path.write_text(f"//npm.cloudsmith.io/:{kind}=token") installer = NPMInstaller() - actions = installer.install( - bin_dir=str(bin_dir), discover=False, domains=("my.registry.example.com",) - ) + with pytest.raises(InstallError) as e: + installer.install( + bin_dir=str(bin_dir), discover=False, domains=("my.registry.example.com",) + ) + + assert e.value.exit_code == 1 + actions = e.value.actions # file was partially written to assert ( diff --git a/cloudsmith_cli/credential_helpers/generic.py b/cloudsmith_cli/credential_helpers/generic.py index cbb2de05..11b1c663 100644 --- a/cloudsmith_cli/credential_helpers/generic.py +++ b/cloudsmith_cli/credential_helpers/generic.py @@ -20,6 +20,22 @@ ) +class InstallError(Exception): + """Raised when the installation process fails, but scheduled post-install reporting actions still need to be executed.""" + + def __init__(self, actions, exit_code=1): + self.__actions = actions + self.__exit_code = exit_code + + @property + def actions(self): + return self.__actions + + @property + def exit_code(self): + return self.__exit_code + + def build_response(credential): """ Build the versioned credential document. diff --git a/cloudsmith_cli/credential_helpers/npm/installer.py b/cloudsmith_cli/credential_helpers/npm/installer.py index 0c767d74..f97697b9 100644 --- a/cloudsmith_cli/credential_helpers/npm/installer.py +++ b/cloudsmith_cli/credential_helpers/npm/installer.py @@ -5,6 +5,7 @@ from cloudsmith_cli.credential_helpers.backends import BackendKind from cloudsmith_cli.credential_helpers.custom_domains import get_format_domains +from cloudsmith_cli.credential_helpers.generic import InstallError from cloudsmith_cli.credential_helpers.launchers import ( is_on_path, remove_launcher, @@ -155,7 +156,6 @@ def install( actions.append(f"wrote launcher {launcher_path}") with NPMRC(config_path, modifiable=not dry_run) as rc: - failures = 0 for host in hosts: entry = NPMRC.URLEntry.from_values( host, "tokenHelper", str(launcher_path) @@ -163,7 +163,6 @@ def install( try: added = rc.add(entry) except AuthKeyConflictError as e: - failures += 1 if dry_run: actions.append( f"WARNING would not set {entry} in {config_path} as {e} already set" @@ -182,11 +181,8 @@ def install( f"{entry} already set in {config_path} (no change)" ) - if failures == len(hosts): - pass - elif failures > 0: - # warn with error - pass + if rc.failures > 0: + raise InstallError(actions) elif not rc.modified: actions.append(f"npmrc already up to date ({config_path})") diff --git a/cloudsmith_cli/credential_helpers/npm/rc.py b/cloudsmith_cli/credential_helpers/npm/rc.py index 3c1853fe..0e16042d 100644 --- a/cloudsmith_cli/credential_helpers/npm/rc.py +++ b/cloudsmith_cli/credential_helpers/npm/rc.py @@ -59,7 +59,12 @@ def __str__(self) -> str: def modified(self): return self._modified + @property + def failures(self): + return self._failures + def __init__(self, path: Path, modifiable=False) -> None: + self._failures = 0 self._modifiable = modifiable self._modified = False self._path: Path = path @@ -108,12 +113,15 @@ def add(self, entry: URLEntry) -> bool: return False if NPMRC.URLEntry.from_values(entry._domain, "_authToken") in self: + self._failures += 1 raise AuthKeyConflictError("_authToken") if NPMRC.URLEntry.from_values(entry._domain, "_auth") in self: + self._failures += 1 raise AuthKeyConflictError("_auth") if NPMRC.URLEntry.from_values(entry._domain, "_password") in self: + self._failures += 1 raise AuthKeyConflictError("_password") self._mapping[entry.id] = entry._value From 6dfa7a2d8e7bbdc9008eeeca146e63b7450e3f43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tighearn=C3=A1n=20Carroll?= <13870403+tigh-latte@users.noreply.github.com> Date: Thu, 20 Aug 2026 10:00:13 +0100 Subject: [PATCH 03/21] remove a few dockers to npms --- cloudsmith_cli/cli/commands/credential_helper/manage.py | 3 ++- cloudsmith_cli/credential_helpers/npm/installer.py | 2 +- cloudsmith_cli/credential_helpers/npm/runtime.py | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/cloudsmith_cli/cli/commands/credential_helper/manage.py b/cloudsmith_cli/cli/commands/credential_helper/manage.py index f0604556..d7bf0b4b 100644 --- a/cloudsmith_cli/cli/commands/credential_helper/manage.py +++ b/cloudsmith_cli/cli/commands/credential_helper/manage.py @@ -134,7 +134,6 @@ def install_cmd( # Disable automatic custom-domain discovery $ cloudsmith credential-helper install docker --no-discover """ - ec = 0 installer = _get_installer(helper) try: actions = installer.install( @@ -154,6 +153,8 @@ def install_cmd( except InstallError as exc: actions = exc.actions ec = exc.exit_code + else: + ec = 0 use_stderr = utils.should_use_stderr(opts) warnings = [a for a in actions if a.startswith("WARNING")] diff --git a/cloudsmith_cli/credential_helpers/npm/installer.py b/cloudsmith_cli/credential_helpers/npm/installer.py index f97697b9..8e0488dc 100644 --- a/cloudsmith_cli/credential_helpers/npm/installer.py +++ b/cloudsmith_cli/credential_helpers/npm/installer.py @@ -189,7 +189,7 @@ def install( if not is_on_path(target_dir): actions.append( f"WARNING: {target_dir} is not on PATH — " - "add it to your PATH so Docker can find docker-credential-cloudsmith" + "add it to your PATH so pnpm can find npm-credential-cloudsmith" ) return actions diff --git a/cloudsmith_cli/credential_helpers/npm/runtime.py b/cloudsmith_cli/credential_helpers/npm/runtime.py index 2c893fe8..176e355c 100644 --- a/cloudsmith_cli/credential_helpers/npm/runtime.py +++ b/cloudsmith_cli/credential_helpers/npm/runtime.py @@ -21,7 +21,7 @@ def get_credentials(server_url, credential=None, api_host=None, org=None): and returns credentials if available. Args: - server_url: The Docker registry server URL + server_url: The NPM registry server URL credential: Pre-resolved CredentialResult from the provider chain api_host: Cloudsmith API host URL org: Organisation slug whose custom domains to match against From 50a4535e6dfd4f99b903fab4b0aa2ee9421cc0b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tighearn=C3=A1n=20Carroll?= <13870403+tigh-latte@users.noreply.github.com> Date: Thu, 20 Aug 2026 10:01:51 +0100 Subject: [PATCH 04/21] Rename `InstallError` to `PartialInstallError` --- .../cli/commands/credential_helper/manage.py | 4 ++-- .../tests/commands/test_credential_helper_install.py | 6 +++--- cloudsmith_cli/credential_helpers/generic.py | 12 ++++++------ cloudsmith_cli/credential_helpers/npm/installer.py | 4 ++-- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/cloudsmith_cli/cli/commands/credential_helper/manage.py b/cloudsmith_cli/cli/commands/credential_helper/manage.py index d7bf0b4b..627f0ada 100644 --- a/cloudsmith_cli/cli/commands/credential_helper/manage.py +++ b/cloudsmith_cli/cli/commands/credential_helper/manage.py @@ -12,7 +12,7 @@ import click -from cloudsmith_cli.credential_helpers.generic import InstallError +from cloudsmith_cli.credential_helpers.generic import PartialInstallError from cloudsmith_cli.credential_helpers.npm.installer import NPMInstaller from ....credential_helpers.docker.installer import DockerInstaller @@ -150,7 +150,7 @@ def install_cmd( raise click.ClickException( f"Failed to install {helper!r} credential helper: {exc}" ) - except InstallError as exc: + except PartialInstallError as exc: actions = exc.actions ec = exc.exit_code else: 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 5edb04a7..c41d7ec2 100644 --- a/cloudsmith_cli/cli/tests/commands/test_credential_helper_install.py +++ b/cloudsmith_cli/cli/tests/commands/test_credential_helper_install.py @@ -2,7 +2,7 @@ """Tests for credential-helper install/uninstall/list commands and launchers.""" from __future__ import annotations -from cloudsmith_cli.credential_helpers.generic import InstallError +from cloudsmith_cli.credential_helpers.generic import PartialInstallError import json import os @@ -1140,7 +1140,7 @@ def test_npm_install_warn_on_auth_configured(kind, tmp_path: Path, monkeypatch): npm_path.write_text(f"//npm.cloudsmith.io/:{kind}=token") installer = NPMInstaller() - with pytest.raises(InstallError) as e: + with pytest.raises(PartialInstallError) as e: installer.install(bin_dir=str(bin_dir), discover=False) assert e.value.exit_code == 1 actions = e.value.actions @@ -1163,7 +1163,7 @@ def test_npm_install_warn_on_auth_configured_partial_write( npm_path.write_text(f"//npm.cloudsmith.io/:{kind}=token") installer = NPMInstaller() - with pytest.raises(InstallError) as e: + with pytest.raises(PartialInstallError) as e: installer.install( bin_dir=str(bin_dir), discover=False, domains=("my.registry.example.com",) ) diff --git a/cloudsmith_cli/credential_helpers/generic.py b/cloudsmith_cli/credential_helpers/generic.py index 11b1c663..4bf2b676 100644 --- a/cloudsmith_cli/credential_helpers/generic.py +++ b/cloudsmith_cli/credential_helpers/generic.py @@ -20,20 +20,20 @@ ) -class InstallError(Exception): - """Raised when the installation process fails, but scheduled post-install reporting actions still need to be executed.""" +class PartialInstallError(Exception): + """Raised when the installation partially fail, but scheduled post-install reporting actions still need to be executed.""" def __init__(self, actions, exit_code=1): - self.__actions = actions - self.__exit_code = exit_code + self._actions = actions + self._exit_code = exit_code @property def actions(self): - return self.__actions + return self._actions @property def exit_code(self): - return self.__exit_code + return self._exit_code def build_response(credential): diff --git a/cloudsmith_cli/credential_helpers/npm/installer.py b/cloudsmith_cli/credential_helpers/npm/installer.py index 8e0488dc..7d407439 100644 --- a/cloudsmith_cli/credential_helpers/npm/installer.py +++ b/cloudsmith_cli/credential_helpers/npm/installer.py @@ -5,7 +5,7 @@ from cloudsmith_cli.credential_helpers.backends import BackendKind from cloudsmith_cli.credential_helpers.custom_domains import get_format_domains -from cloudsmith_cli.credential_helpers.generic import InstallError +from cloudsmith_cli.credential_helpers.generic import PartialInstallError from cloudsmith_cli.credential_helpers.launchers import ( is_on_path, remove_launcher, @@ -182,7 +182,7 @@ def install( ) if rc.failures > 0: - raise InstallError(actions) + raise PartialInstallError(actions) elif not rc.modified: actions.append(f"npmrc already up to date ({config_path})") From 4d80cdaabf3939c19f46b7a46e6a7e3e77d5fc1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tighearn=C3=A1n=20Carroll?= <13870403+tigh-latte@users.noreply.github.com> Date: Thu, 20 Aug 2026 10:10:12 +0100 Subject: [PATCH 05/21] more generic docs for credential helper --- .../cli/commands/credential_helper/manage.py | 12 ++++++------ cloudsmith_cli/credential_helpers/npm/runtime.py | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/cloudsmith_cli/cli/commands/credential_helper/manage.py b/cloudsmith_cli/cli/commands/credential_helper/manage.py index 627f0ada..6ce99ca4 100644 --- a/cloudsmith_cli/cli/commands/credential_helper/manage.py +++ b/cloudsmith_cli/cli/commands/credential_helper/manage.py @@ -114,25 +114,25 @@ def install_cmd( ) -> None: """Install a credential helper launcher and configure the package manager. - HELPER is the name of the credential helper to install (e.g. ``docker``). + HELPER is the name of the credential helper to install (e.g. ``docker``, ``npm``). Examples: \b - # Install Docker credential helper - $ cloudsmith credential-helper install docker + # Install credential helper + $ cloudsmith credential-helper install HELPER \b # Install with a custom domain - $ cloudsmith credential-helper install docker --domain my.registry.example.com + $ cloudsmith credential-helper install HELPER --domain my.registry.example.com \b # Preview without making changes - $ cloudsmith credential-helper install docker --dry-run + $ cloudsmith credential-helper install HELPER --dry-run \b # Disable automatic custom-domain discovery - $ cloudsmith credential-helper install docker --no-discover + $ cloudsmith credential-helper install HELPER --no-discover """ installer = _get_installer(helper) try: diff --git a/cloudsmith_cli/credential_helpers/npm/runtime.py b/cloudsmith_cli/credential_helpers/npm/runtime.py index 176e355c..4855868e 100644 --- a/cloudsmith_cli/credential_helpers/npm/runtime.py +++ b/cloudsmith_cli/credential_helpers/npm/runtime.py @@ -27,7 +27,7 @@ def get_credentials(server_url, credential=None, api_host=None, org=None): org: Organisation slug whose custom domains to match against Returns: - dict: Credentials with 'Username' and 'Secret' keys, or None + str: the token in plain text, with no newline at the end """ if not credential or not credential.api_key: return None From 9de97f334b69e391b07f50e632b2d8fd3d745f09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tighearn=C3=A1n=20Carroll?= <13870403+tigh-latte@users.noreply.github.com> Date: Thu, 20 Aug 2026 10:12:30 +0100 Subject: [PATCH 06/21] better help text formatting --- cloudsmith_cli/cli/commands/credential_helper/npm.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/cloudsmith_cli/cli/commands/credential_helper/npm.py b/cloudsmith_cli/cli/commands/credential_helper/npm.py index 34fad5d9..b4ff67b3 100644 --- a/cloudsmith_cli/cli/commands/credential_helper/npm.py +++ b/cloudsmith_cli/cli/commands/credential_helper/npm.py @@ -18,16 +18,18 @@ @resolve_credentials def npm(opts, repo): """ - Input (stdin): + Input (arg, optional): Server URL as plain text (e.g. "npm.cloudsmith.io") Output (stdout): Text: + \b Exit codes: 0: Success 1: Error (no credentials available, not a Cloudsmith registry, etc.) + \b Environment variables: CLOUDSMITH_API_KEY: API key for authentication (optional) CLOUDSMITH_ORG: Organisation slug (required for custom domain support) From 39fab64a8f69edd0493dae05b8bbe95aca66bfba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tighearn=C3=A1n=20Carroll?= <13870403+tigh-latte@users.noreply.github.com> Date: Thu, 20 Aug 2026 10:35:15 +0100 Subject: [PATCH 07/21] copilot feedback --- cloudsmith_cli/cli/commands/credential_helper/npm.py | 1 + cloudsmith_cli/credential_helpers/docker/installer.py | 2 +- cloudsmith_cli/credential_helpers/generic.py | 2 +- cloudsmith_cli/credential_helpers/npm/installer.py | 1 + cloudsmith_cli/credential_helpers/npm/rc.py | 10 ++++++++-- cloudsmith_cli/credential_helpers/npm/runtime.py | 3 ++- 6 files changed, 14 insertions(+), 5 deletions(-) diff --git a/cloudsmith_cli/cli/commands/credential_helper/npm.py b/cloudsmith_cli/cli/commands/credential_helper/npm.py index b4ff67b3..8db1085c 100644 --- a/cloudsmith_cli/cli/commands/credential_helper/npm.py +++ b/cloudsmith_cli/cli/commands/credential_helper/npm.py @@ -1,3 +1,4 @@ +# Copyright 2026 Cloudsmith Ltd """ Npm credential helper command. diff --git a/cloudsmith_cli/credential_helpers/docker/installer.py b/cloudsmith_cli/credential_helpers/docker/installer.py index c9958f78..818812d8 100644 --- a/cloudsmith_cli/credential_helpers/docker/installer.py +++ b/cloudsmith_cli/credential_helpers/docker/installer.py @@ -224,7 +224,7 @@ def mutate(config: dict) -> None: if not is_on_path(target_dir): actions.append( f"WARNING: {target_dir} is not on PATH — " - "add it to your PATH so pnpm can find npm-credential-cloudsmith" + "add it to your PATH so Docker can find docker-credential-cloudsmith" ) return actions diff --git a/cloudsmith_cli/credential_helpers/generic.py b/cloudsmith_cli/credential_helpers/generic.py index 4bf2b676..98e6465c 100644 --- a/cloudsmith_cli/credential_helpers/generic.py +++ b/cloudsmith_cli/credential_helpers/generic.py @@ -21,7 +21,7 @@ class PartialInstallError(Exception): - """Raised when the installation partially fail, but scheduled post-install reporting actions still need to be executed.""" + """Raised when the installation partially fails, but scheduled post-install reporting actions still need to be executed.""" def __init__(self, actions, exit_code=1): self._actions = actions diff --git a/cloudsmith_cli/credential_helpers/npm/installer.py b/cloudsmith_cli/credential_helpers/npm/installer.py index 7d407439..c74c723e 100644 --- a/cloudsmith_cli/credential_helpers/npm/installer.py +++ b/cloudsmith_cli/credential_helpers/npm/installer.py @@ -1,3 +1,4 @@ +# Copyright 2026 Cloudsmith Ltd import logging import os import sys diff --git a/cloudsmith_cli/credential_helpers/npm/rc.py b/cloudsmith_cli/credential_helpers/npm/rc.py index 0e16042d..cf034f74 100644 --- a/cloudsmith_cli/credential_helpers/npm/rc.py +++ b/cloudsmith_cli/credential_helpers/npm/rc.py @@ -1,3 +1,4 @@ +# Copyright 2026 Cloudsmith Ltd from pathlib import Path from typing_extensions import Self @@ -14,7 +15,12 @@ class URLEntry(str): @classmethod def from_values(cls, domain: str, key: str, value: str | None = None): - return cls(f"//{domain}/:{key}={value}") + obj = cls( + f"//{domain}/:{key}=" if value is None else f"//{domain}/:{key}={value}" + ) + if value is None: + obj._value = None + return obj @property def id(self) -> str: @@ -89,7 +95,7 @@ def __exit__(self, *_): def __contains__(self, item: str | URLEntry) -> bool: if isinstance(item, NPMRC.URLEntry): return item.id in self._mapping and ( - item._value is not None or self._mapping[item.id] == item._value + item._value is None or self._mapping[item.id] == item._value ) return any(item in line for line in self._lines) diff --git a/cloudsmith_cli/credential_helpers/npm/runtime.py b/cloudsmith_cli/credential_helpers/npm/runtime.py index 4855868e..7a69a26e 100644 --- a/cloudsmith_cli/credential_helpers/npm/runtime.py +++ b/cloudsmith_cli/credential_helpers/npm/runtime.py @@ -1,3 +1,4 @@ +# Copyright 2026 Cloudsmith Ltd import logging from ..backends import BackendKind @@ -64,4 +65,4 @@ def _get_execute( except Exception as exc: logger.debug("npm credential-helper get failed: %s", exc, exc_info=True) - return 1, None, None + return 1, None, _REFUSAL_MESSAGE From 357a9f78481e760ea16531c1cc180a5aa1e0ff10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tighearn=C3=A1n=20Carroll?= <13870403+tigh-latte@users.noreply.github.com> Date: Thu, 20 Aug 2026 11:32:58 +0100 Subject: [PATCH 08/21] better doc strings --- cloudsmith_cli/cli/commands/credential_helper/manage.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cloudsmith_cli/cli/commands/credential_helper/manage.py b/cloudsmith_cli/cli/commands/credential_helper/manage.py index 6ce99ca4..7c010eba 100644 --- a/cloudsmith_cli/cli/commands/credential_helper/manage.py +++ b/cloudsmith_cli/cli/commands/credential_helper/manage.py @@ -44,7 +44,7 @@ def _get_installer(name: str): Returns ------- - DockerInstaller + BaseInstaller An instance of the appropriate installer class. Raises @@ -89,7 +89,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", @@ -166,7 +166,7 @@ def install_cmd( "warnings": warnings, } if utils.maybe_print_as_json(opts, data): - return + sys.exit(ec) if dry_run: click.echo("Dry run — no changes will be made:", err=use_stderr) From a8198eeb2d7c646bb05caff93f812ecf3b8df5c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tighearn=C3=A1n=20Carroll?= <13870403+tigh-latte@users.noreply.github.com> Date: Thu, 20 Aug 2026 11:33:26 +0100 Subject: [PATCH 09/21] tests for the npmrc object --- .../credential_helpers/npm/tests/__init__.py | 1 + .../credential_helpers/npm/tests/test_rc.py | 467 ++++++++++++++++++ 2 files changed, 468 insertions(+) create mode 100644 cloudsmith_cli/credential_helpers/npm/tests/__init__.py create mode 100644 cloudsmith_cli/credential_helpers/npm/tests/test_rc.py diff --git a/cloudsmith_cli/credential_helpers/npm/tests/__init__.py b/cloudsmith_cli/credential_helpers/npm/tests/__init__.py new file mode 100644 index 00000000..c4541398 --- /dev/null +++ b/cloudsmith_cli/credential_helpers/npm/tests/__init__.py @@ -0,0 +1 @@ +# Copyright 2026 Cloudsmith Ltd diff --git a/cloudsmith_cli/credential_helpers/npm/tests/test_rc.py b/cloudsmith_cli/credential_helpers/npm/tests/test_rc.py new file mode 100644 index 00000000..91da6424 --- /dev/null +++ b/cloudsmith_cli/credential_helpers/npm/tests/test_rc.py @@ -0,0 +1,467 @@ +# Copyright 2026 Cloudsmith Ltd +"""Tests for NPMRC (NPM configuration file) management.""" + +from __future__ import annotations + +from pathlib import Path +from tempfile import TemporaryDirectory + +import pytest + +from ..rc import NPMRC, AuthKeyConflictError + + +class TestURLEntry: + """Tests for NPMRC.URLEntry class.""" + + def test_urlentry_from_values_with_value(self): + """Create a URLEntry with a value from factory method.""" + entry = NPMRC.URLEntry.from_values( + "registry.example.com", "tokenHelper", "my-helper" + ) + assert str(entry) == "//registry.example.com/:tokenHelper=my-helper" + assert entry._domain == "registry.example.com" + assert entry._key == "tokenHelper" + assert entry._value == "my-helper" + + def test_urlentry_from_values_without_value(self): + """Create a URLEntry without a value from factory method.""" + entry = NPMRC.URLEntry.from_values("registry.example.com", "tokenHelper", None) + # The from_values method sets _value to None, but __new__ creates the string + # with an empty value, which then gets parsed back during __init__ + assert entry._domain == "registry.example.com" + assert entry._key == "tokenHelper" + assert entry._value is None + + def test_urlentry_id_property(self): + """The id property combines domain and key.""" + entry = NPMRC.URLEntry.from_values( + "registry.example.com", "tokenHelper", "helper" + ) + assert entry.id == "registry.example.com::tokenHelper" + + def test_urlentry_parse_basic(self): + """Parse a basic .npmrc line.""" + entry = NPMRC.URLEntry("//registry.example.com/:tokenHelper=my-helper") + assert entry._domain == "registry.example.com" + assert entry._key == "tokenHelper" + assert entry._value == "my-helper" + + def test_urlentry_parse_with_leading_whitespace(self): + """Parse entry with leading whitespace.""" + entry = NPMRC.URLEntry(" //registry.example.com/:tokenHelper=value") + # Note: there's a bug in the original code at line 46 that calculates _leading + # incorrectly. It computes entry[:len(stripped_entry)-len(entry)] which gives + # the entire entry string instead of just the whitespace. + # This test documents current behavior, not ideal behavior. + assert entry._domain == "registry.example.com" + assert entry._key == "tokenHelper" + assert entry._value == "value" + + def test_urlentry_parse_with_comment_semicolon(self): + """Parse entry with trailing semicolon comment.""" + entry = NPMRC.URLEntry("//registry.example.com/:tokenHelper=value;comment here") + assert entry._value == "value" + + def test_urlentry_parse_with_comment_hash(self): + """Parse entry with trailing hash comment.""" + entry = NPMRC.URLEntry("//registry.example.com/:tokenHelper=value#comment here") + assert entry._value == "value" + + def test_urlentry_parse_invalid_no_slashes(self): + """Invalid entry must start with //.""" + with pytest.raises(ValueError, match="should start with"): + NPMRC.URLEntry("registry.example.com/:tokenHelper=value") + + def test_urlentry_parse_invalid_missing_domain(self): + """Invalid entry without domain.""" + with pytest.raises(ValueError, match="missing domain"): + NPMRC.URLEntry("//:tokenHelper=value") + + def test_urlentry_parse_invalid_missing_key(self): + """Invalid entry without key.""" + with pytest.raises(ValueError, match="missing key"): + NPMRC.URLEntry("//registry.example.com/:=value") + + def test_urlentry_domain_with_trailing_slash(self): + """Parse and normalize domain with trailing slash.""" + entry = NPMRC.URLEntry("//registry.example.com//:tokenHelper=value") + assert entry._domain == "registry.example.com" + + def test_urlentry_contains_logic(self): + """Test URLEntry containment for different values.""" + entry1 = NPMRC.URLEntry("//registry.example.com/:tokenHelper=helper1") + entry2 = NPMRC.URLEntry("//registry.example.com/:tokenHelper=helper2") + entry_none = NPMRC.URLEntry("//registry.example.com/:tokenHelper=") + + # Same entry is contained + assert entry1 in [entry1] + + # Different value is not contained + assert entry1 not in [entry2] + + # None value in entry matches any token + assert entry_none not in [entry1] # entry_none has no value + + +class TestNPMRC: + """Tests for NPMRC class.""" + + def test_npmrc_create_new_file(self): + """Create a new NPMRC instance with non-existent file.""" + with TemporaryDirectory() as tmpdir: + rc_path = Path(tmpdir) / ".npmrc" + npmrc = NPMRC(rc_path, modifiable=True) + assert npmrc.modified is False + assert npmrc.failures == 0 + + def test_npmrc_context_manager_creates_file(self): + """Using NPMRC as context manager creates file if modifiable.""" + with TemporaryDirectory() as tmpdir: + rc_path = Path(tmpdir) / ".npmrc" + with NPMRC(rc_path, modifiable=True): + assert rc_path.exists() + + def test_npmrc_context_manager_nonexistent_nonmodifiable(self): + """Non-modifiable NPMRC doesn't create non-existent file.""" + with TemporaryDirectory() as tmpdir: + rc_path = Path(tmpdir) / ".npmrc" + with NPMRC(rc_path, modifiable=False): + assert not rc_path.exists() + + def test_npmrc_parse_simple_file(self): + """Parse a simple .npmrc file with multiple entries.""" + with TemporaryDirectory() as tmpdir: + rc_path = Path(tmpdir) / ".npmrc" + rc_path.write_text( + "//registry.npmjs.org/:tokenHelper=npm\n" + "//npm.example.com/:tokenHelper=custom\n" + "some_other_setting=value\n" + ) + with NPMRC(rc_path, modifiable=False) as npmrc: + assert len(npmrc._lines) == 3 + assert npmrc._mapping["registry.npmjs.org::tokenHelper"] == "npm" + assert npmrc._mapping["npm.example.com::tokenHelper"] == "custom" + + def test_npmrc_parse_preserves_non_url_entries(self): + """Parsing preserves non-URL entries as strings.""" + with TemporaryDirectory() as tmpdir: + rc_path = Path(tmpdir) / ".npmrc" + rc_path.write_text( + "registry=https://registry.npmjs.org/\n" + "//registry.npmjs.org/:tokenHelper=npm\n" + ) + with NPMRC(rc_path, modifiable=False) as npmrc: + assert npmrc._lines[0] == "registry=https://registry.npmjs.org/" + assert isinstance(npmrc._lines[1], NPMRC.URLEntry) + + def test_npmrc_add_entry(self): + """Add a new entry to NPMRC.""" + with TemporaryDirectory() as tmpdir: + rc_path = Path(tmpdir) / ".npmrc" + with NPMRC(rc_path, modifiable=True) as npmrc: + entry = NPMRC.URLEntry.from_values( + "registry.example.com", "tokenHelper", "my-helper" + ) + result = npmrc.add(entry) + assert result is True + assert npmrc.modified is True + assert entry.id in npmrc._mapping + + def test_npmrc_add_duplicate_entry_fails(self): + """Adding a duplicate entry returns False.""" + with TemporaryDirectory() as tmpdir: + rc_path = Path(tmpdir) / ".npmrc" + with NPMRC(rc_path, modifiable=True) as npmrc: + entry = NPMRC.URLEntry.from_values( + "registry.example.com", "tokenHelper", "my-helper" + ) + assert npmrc.add(entry) is True + assert npmrc.add(entry) is False + assert npmrc.failures == 0 + + def test_npmrc_add_conflict_with_authToken(self): + """Adding tokenHelper when _authToken exists raises conflict error.""" + with TemporaryDirectory() as tmpdir: + rc_path = Path(tmpdir) / ".npmrc" + rc_path.write_text("//registry.example.com/:_authToken=secret\n") + with NPMRC(rc_path, modifiable=True) as npmrc: + entry = NPMRC.URLEntry.from_values( + "registry.example.com", "tokenHelper", "my-helper" + ) + with pytest.raises(AuthKeyConflictError, match="_authToken"): + npmrc.add(entry) + assert npmrc.failures == 1 + + def test_npmrc_add_conflict_with_auth(self): + """Adding tokenHelper when _auth exists raises conflict error.""" + with TemporaryDirectory() as tmpdir: + rc_path = Path(tmpdir) / ".npmrc" + rc_path.write_text("//registry.example.com/:_auth=secret\n") + with NPMRC(rc_path, modifiable=True) as npmrc: + entry = NPMRC.URLEntry.from_values( + "registry.example.com", "tokenHelper", "my-helper" + ) + with pytest.raises(AuthKeyConflictError, match="_auth"): + npmrc.add(entry) + assert npmrc.failures == 1 + + def test_npmrc_add_conflict_with_password(self): + """Adding tokenHelper when _password exists raises conflict error.""" + with TemporaryDirectory() as tmpdir: + rc_path = Path(tmpdir) / ".npmrc" + rc_path.write_text("//registry.example.com/:_password=secret\n") + with NPMRC(rc_path, modifiable=True) as npmrc: + entry = NPMRC.URLEntry.from_values( + "registry.example.com", "tokenHelper", "my-helper" + ) + with pytest.raises(AuthKeyConflictError, match="_password"): + npmrc.add(entry) + assert npmrc.failures == 1 + + def test_npmrc_add_no_conflict_different_domain(self): + """Adding tokenHelper to one domain doesn't conflict with _authToken on another.""" + with TemporaryDirectory() as tmpdir: + rc_path = Path(tmpdir) / ".npmrc" + rc_path.write_text("//other.example.com/:_authToken=secret\n") + with NPMRC(rc_path, modifiable=True) as npmrc: + entry = NPMRC.URLEntry.from_values( + "registry.example.com", "tokenHelper", "my-helper" + ) + assert npmrc.add(entry) is True + assert npmrc.failures == 0 + + def test_npmrc_remove_entry(self): + """Remove an entry from NPMRC.""" + with TemporaryDirectory() as tmpdir: + rc_path = Path(tmpdir) / ".npmrc" + rc_path.write_text("//registry.example.com/:tokenHelper=my-helper\n") + with NPMRC(rc_path, modifiable=True) as npmrc: + entry = NPMRC.URLEntry.from_values( + "registry.example.com", "tokenHelper", "my-helper" + ) + result = npmrc.remove(entry) + assert result is True + assert npmrc.modified is True + assert entry.id not in npmrc._mapping + + def test_npmrc_remove_nonexistent_entry(self): + """Removing non-existent entry returns False.""" + with TemporaryDirectory() as tmpdir: + rc_path = Path(tmpdir) / ".npmrc" + with NPMRC(rc_path, modifiable=True) as npmrc: + entry = NPMRC.URLEntry.from_values( + "registry.example.com", "tokenHelper", "my-helper" + ) + result = npmrc.remove(entry) + assert result is False + assert npmrc.modified is False + + def test_npmrc_contains_urlentry(self): + """Check if URLEntry is contained in NPMRC.""" + with TemporaryDirectory() as tmpdir: + rc_path = Path(tmpdir) / ".npmrc" + rc_path.write_text("//registry.example.com/:tokenHelper=my-helper\n") + with NPMRC(rc_path, modifiable=False) as npmrc: + entry = NPMRC.URLEntry.from_values( + "registry.example.com", "tokenHelper", "my-helper" + ) + assert entry in npmrc + + def test_npmrc_contains_string(self): + """Check if string is contained in NPMRC.""" + with TemporaryDirectory() as tmpdir: + rc_path = Path(tmpdir) / ".npmrc" + rc_path.write_text("registry=https://registry.npmjs.org/\n") + with NPMRC(rc_path, modifiable=False) as npmrc: + assert "registry=https://registry.npmjs.org/" in npmrc + + def test_npmrc_write_modified_file(self): + """Write modified NPMRC back to file.""" + with TemporaryDirectory() as tmpdir: + rc_path = Path(tmpdir) / ".npmrc" + with NPMRC(rc_path, modifiable=True) as npmrc: + entry = NPMRC.URLEntry.from_values( + "registry.example.com", "tokenHelper", "my-helper" + ) + npmrc.add(entry) + + # Verify written content + content = rc_path.read_text() + assert "//registry.example.com/:tokenHelper=my-helper" in content + + def test_npmrc_write_not_modified_no_write(self): + """Unmodified NPMRC is not written to file.""" + with TemporaryDirectory() as tmpdir: + rc_path = Path(tmpdir) / ".npmrc" + rc_path.write_text("//registry.example.com/:tokenHelper=existing\n") + original_mtime = rc_path.stat().st_mtime + + # Read without modifying + with NPMRC(rc_path, modifiable=True) as npmrc: + pass + + # File should not be modified + assert rc_path.stat().st_mtime == original_mtime + assert not npmrc.modified + + def test_npmrc_write_nonmodifiable_no_write(self): + """Non-modifiable NPMRC does not write.""" + with TemporaryDirectory() as tmpdir: + rc_path = Path(tmpdir) / ".npmrc" + with NPMRC(rc_path, modifiable=True) as npmrc: + entry = NPMRC.URLEntry.from_values( + "registry.example.com", "tokenHelper", "helper" + ) + npmrc.add(entry) + + # Clear file + rc_path.unlink() + + # Try to modify with non-modifiable instance + with NPMRC(rc_path, modifiable=False) as npmrc: + npmrc._modified = True # Force modified flag + + # File should not exist + assert not rc_path.exists() + + def test_npmrc_helped_hosts_single(self): + """Get list of hosts with tokenHelper.""" + with TemporaryDirectory() as tmpdir: + rc_path = Path(tmpdir) / ".npmrc" + rc_path.write_text( + "//registry.npmjs.org/:tokenHelper=npm\n" + "//npm.example.com/:tokenHelper=custom\n" + ) + with NPMRC(rc_path, modifiable=False) as npmrc: + hosts = npmrc.helped_hosts("npm") + assert hosts == ["registry.npmjs.org"] + + def test_npmrc_helped_hosts_multiple(self): + """Get list of hosts with same tokenHelper value.""" + with TemporaryDirectory() as tmpdir: + rc_path = Path(tmpdir) / ".npmrc" + rc_path.write_text( + "//registry1.example.com/:tokenHelper=my-helper\n" + "//registry2.example.com/:tokenHelper=my-helper\n" + "//registry3.example.com/:tokenHelper=other\n" + ) + with NPMRC(rc_path, modifiable=False) as npmrc: + hosts = npmrc.helped_hosts("my-helper") + assert sorted(hosts) == [ + "registry1.example.com", + "registry2.example.com", + ] + + def test_npmrc_helped_hosts_empty(self): + """Get empty list when no hosts use tokenHelper.""" + with TemporaryDirectory() as tmpdir: + rc_path = Path(tmpdir) / ".npmrc" + rc_path.write_text("//registry.example.com/:tokenHelper=other\n") + with NPMRC(rc_path, modifiable=False) as npmrc: + hosts = npmrc.helped_hosts("nonexistent") + assert hosts == [] + + def test_npmrc_roundtrip_preserves_format(self): + """Writing and reading preserves file format.""" + with TemporaryDirectory() as tmpdir: + rc_path = Path(tmpdir) / ".npmrc" + original_content = ( + "registry=https://registry.npmjs.org/\n" + "//registry.example.com/:tokenHelper=my-helper\n" + " //custom.example.com/:_authToken=secret\n" + ) + rc_path.write_text(original_content) + + # Read, add entry, write + with NPMRC(rc_path, modifiable=True) as npmrc: + entry = NPMRC.URLEntry.from_values( + "new.example.com", "tokenHelper", "new" + ) + npmrc.add(entry) + + # Read again + content = rc_path.read_text() + assert "registry=https://registry.npmjs.org/" in content + assert "//custom.example.com/:_authToken=secret" in content + assert "//new.example.com/:tokenHelper=new" in content + + def test_npmrc_create_if_not_exists(self): + """Explicitly create NPMRC file if it doesn't exist.""" + with TemporaryDirectory() as tmpdir: + rc_path = Path(tmpdir) / ".npmrc" + npmrc = NPMRC(rc_path, modifiable=True) + assert not rc_path.exists() + npmrc.create_if_not_exists() + assert rc_path.exists() + + def test_npmrc_create_if_not_exists_already_exists(self): + """Create if not exists doesn't fail if file already exists.""" + with TemporaryDirectory() as tmpdir: + rc_path = Path(tmpdir) / ".npmrc" + rc_path.write_text("existing content\n") + npmrc = NPMRC(rc_path, modifiable=True) + npmrc.create_if_not_exists() + assert rc_path.read_text() == "existing content\n" + + def test_npmrc_multiple_operations(self): + """Test multiple add/remove operations in sequence.""" + with TemporaryDirectory() as tmpdir: + rc_path = Path(tmpdir) / ".npmrc" + with NPMRC(rc_path, modifiable=True) as npmrc: + # Add entries + e1 = NPMRC.URLEntry.from_values("reg1.example.com", "tokenHelper", "h1") + e2 = NPMRC.URLEntry.from_values("reg2.example.com", "tokenHelper", "h2") + e3 = NPMRC.URLEntry.from_values("reg3.example.com", "tokenHelper", "h3") + + assert npmrc.add(e1) is True + assert npmrc.add(e2) is True + assert npmrc.add(e3) is True + assert npmrc.modified is True + + # Remove middle entry + assert npmrc.remove(e2) is True + + # Verify state + assert e1.id in npmrc._mapping + assert e2.id not in npmrc._mapping + assert e3.id in npmrc._mapping + + def test_npmrc_failure_counter(self): + """Failure counter increments on conflicts.""" + with TemporaryDirectory() as tmpdir: + rc_path = Path(tmpdir) / ".npmrc" + rc_path.write_text( + "//reg1.example.com/:_authToken=secret\n" + "//reg2.example.com/:_password=pwd\n" + ) + with NPMRC(rc_path, modifiable=True) as npmrc: + assert npmrc.failures == 0 + + e1 = NPMRC.URLEntry.from_values("reg1.example.com", "tokenHelper", "h1") + try: + npmrc.add(e1) + except AuthKeyConflictError: + pass + assert npmrc.failures == 1 + + e2 = NPMRC.URLEntry.from_values("reg2.example.com", "tokenHelper", "h2") + try: + npmrc.add(e2) + except AuthKeyConflictError: + pass + assert npmrc.failures == 2 + + +class TestAuthKeyConflictError: + """Tests for AuthKeyConflictError exception.""" + + def test_authkeyconflict_is_keyerror(self): + """AuthKeyConflictError is a subclass of KeyError.""" + exc = AuthKeyConflictError("_authToken") + assert isinstance(exc, KeyError) + + def test_authkeyconflict_message(self): + """AuthKeyConflictError preserves message.""" + exc = AuthKeyConflictError("_auth") + assert exc.args[0] == "_auth" From 5d8c1dcb8b01da7f668bcbc3820c7694f0e2a493 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tighearn=C3=A1n=20Carroll?= <13870403+tigh-latte@users.noreply.github.com> Date: Thu, 20 Aug 2026 11:35:15 +0100 Subject: [PATCH 10/21] changelog entry --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1cb31916..6d3adf75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ## [Unreleased] +### Added + +- Added an NPM credential helper for Cloudsmith registries. `cloudsmith credential-helper install npm` installs an `npm-credential-cloudsmith` launcher binary and registers it in `~/.npmrc`, so npm authenticates to Cloudsmith registries automatically using your existing CLI credentials — no manual `npm login` required. Custom Cloudsmith registry domains are discovered via the API and cached locally; add extra hostnames with `--domain` (repeatable), disable discovery with `--no-discover`, or preview changes with `--dry-run`. Manage installed helpers with `cloudsmith credential-helper uninstall npm` and `cloudsmith credential-helper list`. + ## [1.24.0] - 2026-08-18 ### Added From ad13e3c9502a5f3416d41947d4d087545b1085b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tighearn=C3=A1n=20Carroll?= <13870403+tigh-latte@users.noreply.github.com> Date: Thu, 20 Aug 2026 11:46:20 +0100 Subject: [PATCH 11/21] fix leading whitespace bug, and doc clarification around pnpm --- cloudsmith_cli/cli/commands/credential_helper/manage.py | 5 +++++ cloudsmith_cli/credential_helpers/npm/rc.py | 2 +- cloudsmith_cli/credential_helpers/npm/tests/test_rc.py | 6 ++---- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/cloudsmith_cli/cli/commands/credential_helper/manage.py b/cloudsmith_cli/cli/commands/credential_helper/manage.py index 7c010eba..675eba56 100644 --- a/cloudsmith_cli/cli/commands/credential_helper/manage.py +++ b/cloudsmith_cli/cli/commands/credential_helper/manage.py @@ -116,6 +116,11 @@ def install_cmd( HELPER is the name of the credential helper to install (e.g. ``docker``, ``npm``). + Important for NPM/pnpm: The tokenHelper directive is only honored in the + user-level ~/.npmrc file, not in a project-level .npmrc. This is a pnpm/npm + security restriction. The absolute path to the launcher is automatically + calculated and configured. + Examples: \b diff --git a/cloudsmith_cli/credential_helpers/npm/rc.py b/cloudsmith_cli/credential_helpers/npm/rc.py index cf034f74..b7d47278 100644 --- a/cloudsmith_cli/credential_helpers/npm/rc.py +++ b/cloudsmith_cli/credential_helpers/npm/rc.py @@ -43,7 +43,7 @@ def __init__(self, entry: str): stripped_entry = entry.lstrip() # track the starting whitespace - self._leading = entry[: len(stripped_entry) - len(entry)] + self._leading = entry[: len(entry) - len(stripped_entry)] if not stripped_entry.startswith("/"): raise ValueError("invalid url, should start with ``//``") diff --git a/cloudsmith_cli/credential_helpers/npm/tests/test_rc.py b/cloudsmith_cli/credential_helpers/npm/tests/test_rc.py index 91da6424..a57afad6 100644 --- a/cloudsmith_cli/credential_helpers/npm/tests/test_rc.py +++ b/cloudsmith_cli/credential_helpers/npm/tests/test_rc.py @@ -50,13 +50,11 @@ def test_urlentry_parse_basic(self): def test_urlentry_parse_with_leading_whitespace(self): """Parse entry with leading whitespace.""" entry = NPMRC.URLEntry(" //registry.example.com/:tokenHelper=value") - # Note: there's a bug in the original code at line 46 that calculates _leading - # incorrectly. It computes entry[:len(stripped_entry)-len(entry)] which gives - # the entire entry string instead of just the whitespace. - # This test documents current behavior, not ideal behavior. + assert entry._leading == " " assert entry._domain == "registry.example.com" assert entry._key == "tokenHelper" assert entry._value == "value" + assert str(entry) == " //registry.example.com/:tokenHelper=value" def test_urlentry_parse_with_comment_semicolon(self): """Parse entry with trailing semicolon comment.""" From c04340734846578f4d73b19fc845a15aee20d414 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tighearn=C3=A1n=20Carroll?= <13870403+tigh-latte@users.noreply.github.com> Date: Thu, 20 Aug 2026 11:49:01 +0100 Subject: [PATCH 12/21] Exclude nested test packages from wheel builds Add pattern to exclude test packages at deeper nesting levels (e.g. cloudsmith_cli.credential_helpers.npm.tests) from the built wheel distribution. This prevents duplicate __init__.py files in the wheel and ensures test files are not packaged with the CLI. --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index c040aa4f..95548fe6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -102,7 +102,7 @@ include-package-data = false [tool.setuptools.packages.find] include = ["cloudsmith_cli*"] -exclude = ["cloudsmith_cli.tests*", "cloudsmith_cli.*.tests*"] +exclude = ["cloudsmith_cli.tests*", "cloudsmith_cli.*.tests*", "cloudsmith_cli.*.*.tests*"] namespaces = false [tool.setuptools.package-data] From 522cc24be67ceb96bc36ea7b9ccfb029ae8c89e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tighearn=C3=A1n=20Carroll?= <13870403+tigh-latte@users.noreply.github.com> Date: Thu, 20 Aug 2026 11:54:57 +0100 Subject: [PATCH 13/21] fix npm test --- .../cli/tests/commands/test_credential_helper_install.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 c41d7ec2..4f0f46c3 100644 --- a/cloudsmith_cli/cli/tests/commands/test_credential_helper_install.py +++ b/cloudsmith_cli/cli/tests/commands/test_credential_helper_install.py @@ -444,7 +444,7 @@ def test_npm_installer_status_type_contract(tmp_path: Path, monkeypatch: MonkeyP Retained guard: the -F json Path-serialization regression. """ npm_path = tmp_path / ".npm" - monkeypatch.setenv("DOCKER_CONFIG", str(npm_path)) + monkeypatch.setenv("NPM_CONFIG_USERCONFIG", str(npm_path)) bin_dir = tmp_path / "bin" installer = NPMInstaller() From 632fc7546ec84bd8c4f609b871a1de1283cf6203 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tighearn=C3=A1n=20Carroll?= <13870403+tigh-latte@users.noreply.github.com> Date: Thu, 20 Aug 2026 12:06:40 +0100 Subject: [PATCH 14/21] more specific function name for npm cred fetcher --- cloudsmith_cli/credential_helpers/npm/__init__.py | 4 ++-- cloudsmith_cli/credential_helpers/npm/runtime.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/cloudsmith_cli/credential_helpers/npm/__init__.py b/cloudsmith_cli/credential_helpers/npm/__init__.py index 76414023..4a0eef35 100644 --- a/cloudsmith_cli/credential_helpers/npm/__init__.py +++ b/cloudsmith_cli/credential_helpers/npm/__init__.py @@ -1,4 +1,4 @@ # Copyright 2026 Cloudsmith Ltd -from .runtime import execute, get_credentials +from .runtime import execute, get_npm_credentials -__all__ = ["execute", "get_credentials"] +__all__ = ["execute", "get_npm_credentials"] diff --git a/cloudsmith_cli/credential_helpers/npm/runtime.py b/cloudsmith_cli/credential_helpers/npm/runtime.py index 7a69a26e..03668a59 100644 --- a/cloudsmith_cli/credential_helpers/npm/runtime.py +++ b/cloudsmith_cli/credential_helpers/npm/runtime.py @@ -14,7 +14,7 @@ ) -def get_credentials(server_url, credential=None, api_host=None, org=None): +def get_npm_credentials(server_url, credential=None, api_host=None, org=None): """ Get credentials for a Cloudsmith NPM registry. @@ -55,7 +55,7 @@ def _get_execute( server_url: str, credential=None, api_host=None, org=None ) -> tuple[int, str | None, str | None]: try: - cred = get_credentials( + cred = get_npm_credentials( server_url, credential=credential, api_host=api_host, org=org ) if not cred: From e24c11655fb69e3883315afc65fd7e1ca1a08246 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tighearn=C3=A1n=20Carroll?= <13870403+tigh-latte@users.noreply.github.com> Date: Thu, 20 Aug 2026 12:14:03 +0100 Subject: [PATCH 15/21] fix code ql check --- .../cli/tests/commands/test_credential_helper_install.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 4f0f46c3..691e483d 100644 --- a/cloudsmith_cli/cli/tests/commands/test_credential_helper_install.py +++ b/cloudsmith_cli/cli/tests/commands/test_credential_helper_install.py @@ -280,7 +280,7 @@ def test_npm_installer_dry_run(tmp_path, monkeypatch): assert not (bin_dir / "npm-credential-cloudsmith").exists() assert not npm_path.exists() assert any("would write launcher" in a for a in actions) - assert any("npm.cloudsmith.io" in a for a in actions) + assert any(a.startswith("would set //npm.cloudsmith.io/") for a in actions) # --------------------------------------------------------------------------- From c5ffd8bf0cc8d851412137e185380c2c3be3a7e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tighearn=C3=A1n=20Carroll?= <13870403+tigh-latte@users.noreply.github.com> Date: Thu, 20 Aug 2026 12:32:42 +0100 Subject: [PATCH 16/21] remove not in PATH warning for npm credential helper install, as pnpm expects an absolute path and therefore doesn't need to be in PATH --- .../test_credential_helper_install.py | 25 +++++++++++-------- .../credential_helpers/npm/installer.py | 6 ----- 2 files changed, 15 insertions(+), 16 deletions(-) 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 691e483d..22a1d5e4 100644 --- a/cloudsmith_cli/cli/tests/commands/test_credential_helper_install.py +++ b/cloudsmith_cli/cli/tests/commands/test_credential_helper_install.py @@ -768,21 +768,13 @@ def test_manage_cli_passes_resolved_credential_to_installer( # --------------------------------------------------------------------------- -@pytest.mark.parametrize( - "installer", - [ - DockerInstaller, - NPMInstaller, - ], -) -def test_path_warning_when_bin_dir_not_on_path(installer, tmp_path, monkeypatch): +def test_docker_path_warning_when_bin_dir_not_on_path(tmp_path, monkeypatch): """install returns a WARNING action when target bin_dir is not on PATH.""" monkeypatch.setenv("DOCKER_CONFIG", str(tmp_path / ".docker")) - monkeypatch.setenv("NPM_CONFIG_USERCONFIG", str(tmp_path / ".npmrc")) bin_dir = tmp_path / "bin" monkeypatch.setenv("PATH", "/usr/bin:/usr/local/bin") - installer = installer() + installer = DockerInstaller() actions = installer.install(bin_dir=str(bin_dir)) warning_actions = [a for a in actions if a.startswith("WARNING")] @@ -790,6 +782,19 @@ def test_path_warning_when_bin_dir_not_on_path(installer, tmp_path, monkeypatch) assert any("PATH" in a for a in warning_actions) +def test_npm_no_path_warning_when_bin_dir_not_on_path(tmp_path, monkeypatch): + """install returns a WARNING action when target bin_dir is not on PATH.""" + monkeypatch.setenv("NPM_CONFIG_USERCONFIG", str(tmp_path / ".npmrc")) + bin_dir = tmp_path / "bin" + monkeypatch.setenv("PATH", "/usr/bin:/usr/local/bin") + + installer = NPMInstaller() + actions = installer.install(bin_dir=str(bin_dir)) + + warning_actions = [a for a in actions if a.startswith("WARNING")] + assert not warning_actions, f"Expected no WARNING action, got: {actions}" + + # --------------------------------------------------------------------------- # 15. Unwritable dir → clean ClickException (no raw traceback) # --------------------------------------------------------------------------- diff --git a/cloudsmith_cli/credential_helpers/npm/installer.py b/cloudsmith_cli/credential_helpers/npm/installer.py index c74c723e..aab8a860 100644 --- a/cloudsmith_cli/credential_helpers/npm/installer.py +++ b/cloudsmith_cli/credential_helpers/npm/installer.py @@ -8,7 +8,6 @@ from cloudsmith_cli.credential_helpers.custom_domains import get_format_domains from cloudsmith_cli.credential_helpers.generic import PartialInstallError from cloudsmith_cli.credential_helpers.launchers import ( - is_on_path, remove_launcher, resolve_bin_dir, write_launcher, @@ -187,11 +186,6 @@ def install( elif not rc.modified: actions.append(f"npmrc already up to date ({config_path})") - if not is_on_path(target_dir): - actions.append( - f"WARNING: {target_dir} is not on PATH — " - "add it to your PATH so pnpm can find npm-credential-cloudsmith" - ) return actions def uninstall( From b1ccd0199dc0581cb3885f1f8eca54e59355b81e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tighearn=C3=A1n=20Carroll?= <13870403+tigh-latte@users.noreply.github.com> Date: Thu, 20 Aug 2026 12:40:50 +0100 Subject: [PATCH 17/21] more extensive tests around npmrc file handling --- .../credential_helpers/npm/tests/test_rc.py | 327 ++++++++++++++++++ 1 file changed, 327 insertions(+) diff --git a/cloudsmith_cli/credential_helpers/npm/tests/test_rc.py b/cloudsmith_cli/credential_helpers/npm/tests/test_rc.py index a57afad6..832f6d0e 100644 --- a/cloudsmith_cli/credential_helpers/npm/tests/test_rc.py +++ b/cloudsmith_cli/credential_helpers/npm/tests/test_rc.py @@ -463,3 +463,330 @@ def test_authkeyconflict_message(self): """AuthKeyConflictError preserves message.""" exc = AuthKeyConflictError("_auth") assert exc.args[0] == "_auth" + + +class TestInstallingAndUninstallingWithRealWorldConfigs: + """Integration tests with realistic pre-configured .npmrc files.""" + + def test_install_appends_line_to_minimal_npmrc(self): + """Installing appends tokenHelper line to minimal .npmrc.""" + with TemporaryDirectory() as tmpdir: + rc_path = Path(tmpdir) / ".npmrc" + rc_path.write_text("registry=https://registry.npmjs.org/\n") + + with NPMRC(rc_path, modifiable=True) as npmrc: + entry = NPMRC.URLEntry.from_values( + "npm.cloudsmith.io", + "tokenHelper", + "/usr/local/bin/npm-credentials-cloudsmith", + ) + assert npmrc.add(entry) is True + + # Verify file content - check exact lines + content = rc_path.read_text() + lines = [line for line in content.split("\n") if line] # exclude empty + assert len(lines) == 2 + assert lines[0] == "registry=https://registry.npmjs.org/" + assert ( + lines[1] + == "//npm.cloudsmith.io/:tokenHelper=/usr/local/bin/npm-credentials-cloudsmith" + ) + + def test_install_preserves_all_other_lines(self): + """Installing preserves all non-related lines in .npmrc.""" + with TemporaryDirectory() as tmpdir: + rc_path = Path(tmpdir) / ".npmrc" + original_lines = [ + "registry=https://registry.npmjs.org/", + "legacy-peer-deps=true", + "@myorg:registry=https://custom.example.com/", + "//custom.example.com/:always-auth=true", + ] + rc_path.write_text("\n".join(original_lines) + "\n") + + with NPMRC(rc_path, modifiable=True) as npmrc: + entry = NPMRC.URLEntry.from_values( + "npm.cloudsmith.io", + "tokenHelper", + "/usr/local/bin/npm-credentials-cloudsmith", + ) + assert npmrc.add(entry) is True + + # Verify all original lines are still there (exact match) + content = rc_path.read_text() + lines = [line for line in content.split("\n") if line] + for original_line in original_lines: + assert original_line in lines + assert ( + "//npm.cloudsmith.io/:tokenHelper=/usr/local/bin/npm-credentials-cloudsmith" + in lines + ) + + def test_uninstall_removes_only_target_line(self): + """Uninstalling removes only the target tokenHelper line.""" + with TemporaryDirectory() as tmpdir: + rc_path = Path(tmpdir) / ".npmrc" + original_lines = [ + "registry=https://registry.npmjs.org/", + "//npm.cloudsmith.io/:tokenHelper=/usr/local/bin/npm-credentials-cloudsmith", + "legacy-peer-deps=true", + ] + rc_path.write_text("\n".join(original_lines) + "\n") + + with NPMRC(rc_path, modifiable=True) as npmrc: + entry = NPMRC.URLEntry.from_values( + "npm.cloudsmith.io", + "tokenHelper", + "/usr/local/bin/npm-credentials-cloudsmith", + ) + assert npmrc.remove(entry) is True + + # Verify only the target line was removed + content = rc_path.read_text() + lines = [line for line in content.split("\n") if line] + assert "registry=https://registry.npmjs.org/" in lines + assert "legacy-peer-deps=true" in lines + assert not any("//npm.cloudsmith.io/:tokenHelper" in line for line in lines) + + def test_install_multiple_registries_preserves_order(self): + """Installing multiple registries preserves existing order.""" + with TemporaryDirectory() as tmpdir: + rc_path = Path(tmpdir) / ".npmrc" + original_lines = [ + "registry=https://registry.npmjs.org/", + "//registry1.example.com/:always-auth=true", + "//registry2.example.com/:always-auth=true", + ] + rc_path.write_text("\n".join(original_lines) + "\n") + + with NPMRC(rc_path, modifiable=True) as npmrc: + entry1 = NPMRC.URLEntry.from_values( + "cloudsmith1.io", "tokenHelper", "/path/to/helper1" + ) + entry2 = NPMRC.URLEntry.from_values( + "cloudsmith2.io", "tokenHelper", "/path/to/helper2" + ) + assert npmrc.add(entry1) is True + assert npmrc.add(entry2) is True + + # Verify original lines are in original order, new lines at end + lines = [line for line in rc_path.read_text().split("\n") if line] + assert lines[0] == "registry=https://registry.npmjs.org/" + assert lines[1] == "//registry1.example.com/:always-auth=true" + assert lines[2] == "//registry2.example.com/:always-auth=true" + assert lines[3] == "//cloudsmith1.io/:tokenHelper=/path/to/helper1" + assert lines[4] == "//cloudsmith2.io/:tokenHelper=/path/to/helper2" + + def test_install_then_uninstall_leaves_clean_state(self): + """Installing then uninstalling returns file to original state.""" + with TemporaryDirectory() as tmpdir: + rc_path = Path(tmpdir) / ".npmrc" + original_lines = [ + "registry=https://registry.npmjs.org/", + "//registry.npmjs.org/:_authToken=secret", + ] + rc_path.write_text("\n".join(original_lines) + "\n") + + # Install + with NPMRC(rc_path, modifiable=True) as npmrc: + entry = NPMRC.URLEntry.from_values( + "cloudsmith.io", "tokenHelper", "/path/helper" + ) + assert npmrc.add(entry) is True + + content_after_install = rc_path.read_text() + lines_after_install = [ + line for line in content_after_install.split("\n") if line + ] + assert "//cloudsmith.io/:tokenHelper=/path/helper" in lines_after_install + + # Uninstall + with NPMRC(rc_path, modifiable=True) as npmrc: + entry = NPMRC.URLEntry.from_values( + "cloudsmith.io", "tokenHelper", "/path/helper" + ) + assert npmrc.remove(entry) is True + + content_after_uninstall = rc_path.read_text() + lines_after_uninstall = [ + line for line in content_after_uninstall.split("\n") if line + ] + # Verify all original lines are still present (exact match) + for original_line in original_lines: + assert original_line in lines_after_uninstall + # Verify cloudsmith entry was removed + assert not any( + "//cloudsmith.io/:tokenHelper" in line for line in lines_after_uninstall + ) + + def test_install_with_scoped_packages(self): + """Installing works alongside scoped package registries.""" + with TemporaryDirectory() as tmpdir: + rc_path = Path(tmpdir) / ".npmrc" + original_lines = [ + "registry=https://registry.npmjs.org/", + "@myorg:registry=https://custom.example.com/", + "@another:registry=https://another.example.com/", + ] + rc_path.write_text("\n".join(original_lines) + "\n") + + with NPMRC(rc_path, modifiable=True) as npmrc: + entry = NPMRC.URLEntry.from_values( + "cloudsmith.io", "tokenHelper", "/path/helper" + ) + assert npmrc.add(entry) is True + + content = rc_path.read_text() + lines = [line for line in content.split("\n") if line] + # All scoped registries should still be there + assert "@myorg:registry=https://custom.example.com/" in lines + assert "@another:registry=https://another.example.com/" in lines + assert "//cloudsmith.io/:tokenHelper=/path/helper" in lines + + def test_install_with_commented_lines(self): + """Installing preserves commented-out lines.""" + with TemporaryDirectory() as tmpdir: + rc_path = Path(tmpdir) / ".npmrc" + original_lines = [ + "registry=https://registry.npmjs.org/", + "# //old.registry.com/:_authToken=disabled", + "legacy-peer-deps=true", + ] + rc_path.write_text("\n".join(original_lines) + "\n") + + with NPMRC(rc_path, modifiable=True) as npmrc: + entry = NPMRC.URLEntry.from_values( + "cloudsmith.io", "tokenHelper", "/path/helper" + ) + assert npmrc.add(entry) is True + + content = rc_path.read_text() + lines = [line for line in content.split("\n") if line] + # Commented line should be preserved (exact match) + assert "# //old.registry.com/:_authToken=disabled" in lines + assert "//cloudsmith.io/:tokenHelper=/path/helper" in lines + + def test_uninstall_idempotent(self): + """Uninstalling same entry twice is safe (idempotent).""" + with TemporaryDirectory() as tmpdir: + rc_path = Path(tmpdir) / ".npmrc" + original_lines = [ + "registry=https://registry.npmjs.org/", + "//cloudsmith.io/:tokenHelper=/path/helper", + ] + rc_path.write_text("\n".join(original_lines) + "\n") + + with NPMRC(rc_path, modifiable=True) as npmrc: + entry = NPMRC.URLEntry.from_values( + "cloudsmith.io", "tokenHelper", "/path/helper" + ) + # First removal succeeds + assert npmrc.remove(entry) is True + # Second removal returns False (already removed) + assert npmrc.remove(entry) is False + + def test_install_with_complex_registry_config(self): + """Installing to complex real-world registry configuration.""" + with TemporaryDirectory() as tmpdir: + rc_path = Path(tmpdir) / ".npmrc" + original_lines = [ + "registry=https://registry.npmjs.org/", + "npm_config_loglevel=warn", + "@babel:registry=https://registry.npmjs.org/", + "@types:registry=https://registry.npmjs.org/", + "//registry.npmjs.org/:_authToken=npm_secret_token_here", + "always-auth=false", + ] + rc_path.write_text("\n".join(original_lines) + "\n") + + with NPMRC(rc_path, modifiable=True) as npmrc: + cloudsmith_entry = NPMRC.URLEntry.from_values( + "npm.cloudsmith.io", + "tokenHelper", + "/usr/local/bin/npm-credentials-cloudsmith", + ) + assert npmrc.add(cloudsmith_entry) is True + + # Verify after context manager exit + content = rc_path.read_text() + lines = [line for line in content.split("\n") if line] + # Original auth still there + assert "//registry.npmjs.org/:_authToken=npm_secret_token_here" in lines + # New entry added + assert ( + "//npm.cloudsmith.io/:tokenHelper=/usr/local/bin/npm-credentials-cloudsmith" + in lines + ) + # All original lines preserved + for original_line in original_lines: + assert original_line in lines + + def test_install_different_helpers_same_domain(self): + """Installing different credential helpers to same domain.""" + with TemporaryDirectory() as tmpdir: + rc_path = Path(tmpdir) / ".npmrc" + rc_path.write_text("registry=https://registry.npmjs.org/\n") + + with NPMRC(rc_path, modifiable=True) as npmrc: + # Add tokenHelper for cloudsmith + entry1 = NPMRC.URLEntry.from_values( + "cloudsmith.io", "tokenHelper", "/path/to/helper1" + ) + assert npmrc.add(entry1) is True + + # Add custom setting for same domain + entry2 = NPMRC.URLEntry.from_values( + "cloudsmith.io", + "registryUrl", + "https://cloudsmith.io/npm/myorg/myrepo/", + ) + assert npmrc.add(entry2) is True + + content = rc_path.read_text() + lines = [line for line in content.split("\n") if line] + assert "//cloudsmith.io/:tokenHelper=/path/to/helper1" in lines + assert ( + "//cloudsmith.io/:registryUrl=https://cloudsmith.io/npm/myorg/myrepo/" + in lines + ) + + def test_roundtrip_with_leading_whitespace(self): + """Roundtrip preserves leading whitespace on entries.""" + with TemporaryDirectory() as tmpdir: + rc_path = Path(tmpdir) / ".npmrc" + original_lines = [ + "registry=https://registry.npmjs.org/", + " //indented.example.com/:_authToken=secret", + ] + rc_path.write_text("\n".join(original_lines) + "\n") + + # Read and re-write without changes + with NPMRC(rc_path, modifiable=True): + # Don't modify, just read + pass + + content = rc_path.read_text() + lines = content.split("\n") + # Indentation should be preserved (exact match) + assert " //indented.example.com/:_authToken=secret" in lines + + def test_install_with_empty_lines(self): + """Installing handles .npmrc with empty lines gracefully.""" + with TemporaryDirectory() as tmpdir: + rc_path = Path(tmpdir) / ".npmrc" + rc_path.write_text( + "registry=https://registry.npmjs.org/\n\nlegacy-peer-deps=true\n\n" + ) + + with NPMRC(rc_path, modifiable=True) as npmrc: + entry = NPMRC.URLEntry.from_values( + "cloudsmith.io", "tokenHelper", "/path/helper" + ) + assert npmrc.add(entry) is True + + content = rc_path.read_text() + lines = [line for line in content.split("\n") if line] + # All non-empty content preserved + assert "registry=https://registry.npmjs.org/" in lines + assert "legacy-peer-deps=true" in lines + assert "//cloudsmith.io/:tokenHelper=/path/helper" in lines From b2db9f265d615a10b2ccab5d381717f49c5cd0ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tighearn=C3=A1n=20Carroll?= <13870403+tigh-latte@users.noreply.github.com> Date: Thu, 20 Aug 2026 13:42:21 +0100 Subject: [PATCH 18/21] fix: pr feedback * beginning on npm -> pnpm rename * `resolve_bin_dir` will now resolve dirs as absolute instead of relative * silently fail handling malformed url entries in npmrc, testing them instead as a normal line * fix typo * tests where apply for the above --- .../commands/credential_helper/__init__.py | 10 +- .../credential_helper/{npm.py => pnpm.py} | 2 +- .../test_credential_helper_install.py | 63 +++++- .../credential_helpers/launchers.py | 16 +- .../credential_helpers/npm/installer.py | 3 +- cloudsmith_cli/credential_helpers/npm/rc.py | 9 +- .../credential_helpers/npm/tests/test_rc.py | 180 ++++++++++++++++++ 7 files changed, 265 insertions(+), 18 deletions(-) rename cloudsmith_cli/cli/commands/credential_helper/{npm.py => pnpm.py} (98%) diff --git a/cloudsmith_cli/cli/commands/credential_helper/__init__.py b/cloudsmith_cli/cli/commands/credential_helper/__init__.py index ea835dc4..c1bf2db5 100644 --- a/cloudsmith_cli/cli/commands/credential_helper/__init__.py +++ b/cloudsmith_cli/cli/commands/credential_helper/__init__.py @@ -12,7 +12,7 @@ from .docker import docker as docker_cmd from .generic import generic as generic_cmd from .manage import install_cmd, list_cmd, uninstall_cmd -from .npm import npm as npm_cmd +from .pnpm import pnpm as pnpm_cmd @click.group() @@ -28,12 +28,18 @@ def credential_helper(): # Install Docker credential helper $ cloudsmith credential-helper install docker + # Install pnpm credential helper + $ cloudsmith credential-helper install pnpm + # Test Docker credential helper directly $ echo "docker.cloudsmith.io" | cloudsmith credential-helper docker + + # Test pnpm credential helper directly + $ cloudsmith credential-helper pnpm npm.cloudsmith.io """ -credential_helper.add_command(npm_cmd, name="npm") +credential_helper.add_command(pnpm_cmd, name="pnpm") credential_helper.add_command(docker_cmd, name="docker") credential_helper.add_command(generic_cmd, name="generic") credential_helper.add_command(install_cmd, name="install") diff --git a/cloudsmith_cli/cli/commands/credential_helper/npm.py b/cloudsmith_cli/cli/commands/credential_helper/pnpm.py similarity index 98% rename from cloudsmith_cli/cli/commands/credential_helper/npm.py rename to cloudsmith_cli/cli/commands/credential_helper/pnpm.py index 8db1085c..fa16e2ab 100644 --- a/cloudsmith_cli/cli/commands/credential_helper/npm.py +++ b/cloudsmith_cli/cli/commands/credential_helper/pnpm.py @@ -17,7 +17,7 @@ @click.argument("repo", required=False, default="npm.cloudsmith.io") @common_api_auth_options @resolve_credentials -def npm(opts, repo): +def pnpm(opts, repo): """ Input (arg, optional): Server URL as plain text (e.g. "npm.cloudsmith.io") 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 22a1d5e4..62644d6b 100644 --- a/cloudsmith_cli/cli/tests/commands/test_credential_helper_install.py +++ b/cloudsmith_cli/cli/tests/commands/test_credential_helper_install.py @@ -2,7 +2,6 @@ """Tests for credential-helper install/uninstall/list commands and launchers.""" from __future__ import annotations -from cloudsmith_cli.credential_helpers.generic import PartialInstallError import json import os @@ -15,6 +14,7 @@ import pytest from _pytest.monkeypatch import MonkeyPatch +from cloudsmith_cli.credential_helpers.generic import PartialInstallError from cloudsmith_cli.credential_helpers.npm.installer import NPMInstaller from ....core.credentials.models import CredentialResult @@ -146,9 +146,64 @@ def test_remove_launcher(format, tmp_path): # --------------------------------------------------------------------------- -def test_resolve_bin_dir_override(tmp_path): - """An explicit override is returned verbatim.""" - assert resolve_bin_dir(str(tmp_path)) == tmp_path +def test_resolve_bin_dir_override_absolute(tmp_path): + """An absolute path override is returned as an absolute path.""" + absolute_path = tmp_path.resolve() + assert resolve_bin_dir(str(absolute_path)) == absolute_path + + +def test_resolve_bin_dir_override_relative_simple(tmp_path, monkeypatch): + """A relative path override is resolved to an absolute path. + + If we're in /path/from/root/to and give 'launcher/bin', it returns + /path/from/root/to/launcher/bin. + """ + monkeypatch.chdir(tmp_path) + relative_override = "launcher/bin" + result = resolve_bin_dir(relative_override) + + # Result should be absolute + assert result.is_absolute() + + # Result should be correct: cwd / relative_override + expected = tmp_path / relative_override + assert result == expected + + +def test_resolve_bin_dir_override_relative_parent(tmp_path, monkeypatch): + """A relative path with parent directory references is resolved correctly. + + If we're in /path/from/root/to/randomdir and give '../launcher/bin', + it returns /path/from/root/to/launcher/bin. + """ + # Create subdirectory structure + random_dir = tmp_path / "randomdir" + random_dir.mkdir() + + monkeypatch.chdir(random_dir) + relative_override = "../launcher/bin" + result = resolve_bin_dir(relative_override) + + # Result should be absolute + assert result.is_absolute() + + # Result should be correct: (cwd / parent / launcher / bin) + expected = tmp_path / "launcher" / "bin" + assert result == expected + + +def test_resolve_bin_dir_override_relative_current_dir(tmp_path, monkeypatch): + """A relative path starting with './' is resolved correctly.""" + monkeypatch.chdir(tmp_path) + relative_override = "./locally-scoped-dir" + result = resolve_bin_dir(relative_override) + + # Result should be absolute + assert result.is_absolute() + + # Result should be correct + expected = tmp_path / "locally-scoped-dir" + assert result == expected @pytest.mark.parametrize( diff --git a/cloudsmith_cli/credential_helpers/launchers.py b/cloudsmith_cli/credential_helpers/launchers.py index 69b2698b..2886879e 100644 --- a/cloudsmith_cli/credential_helpers/launchers.py +++ b/cloudsmith_cli/credential_helpers/launchers.py @@ -120,9 +120,10 @@ def resolve_bin_dir(override: str | None = None) -> Path: Resolution order ---------------- - 1. *override* → ``Path(override)``. + 1. *override* → resolved to an absolute path (relative paths are resolved + against the current working directory). 2. The directory of the running ``cloudsmith`` executable — if that - directory is writable. + directory is writable. 3. The user-local bin directory (see :func:`_user_bin_dir`). The chosen directory is **not** created here; that happens when the @@ -132,14 +133,17 @@ def resolve_bin_dir(override: str | None = None) -> Path: ---------- override: Explicit path supplied by the caller (e.g. ``--bin-dir`` CLI option). + Both relative and absolute paths are supported; relative paths are + resolved against the current working directory, ensuring the returned + path is always absolute. Returns ------- Path - The resolved directory. + The resolved directory as an absolute path. """ if override is not None: - return Path(override) + return Path(override).resolve() # Option 2: beside the running cloudsmith binary (if writable) cloudsmith_path = shutil.which("cloudsmith") @@ -149,10 +153,10 @@ def resolve_bin_dir(override: str | None = None) -> Path: candidate = Path(os.path.dirname(os.path.realpath(sys.argv[0]))) if os.access(candidate, os.W_OK | os.X_OK): - return candidate + return candidate.resolve() # Option 3: user-local bin - return _user_bin_dir(_is_windows()) + return _user_bin_dir(_is_windows()).resolve() def is_on_path(directory: Path) -> bool: diff --git a/cloudsmith_cli/credential_helpers/npm/installer.py b/cloudsmith_cli/credential_helpers/npm/installer.py index aab8a860..3bfafac2 100644 --- a/cloudsmith_cli/credential_helpers/npm/installer.py +++ b/cloudsmith_cli/credential_helpers/npm/installer.py @@ -31,7 +31,6 @@ def _config_path() -> Path: class NPMInstaller: LAUNCHER_NAME = "npm-credential-cloudsmith" TARGET_CMD = "cloudsmith credential-helper npm" - HELPER_VALUE = "cloudsmith" DEFAULT_HOST = "npm.cloudsmith.io" name = "npm" @@ -64,7 +63,7 @@ def install( api_host: str | None = None, dry_run: bool = False, ) -> list[str]: - """Install the NPM crednetial helper. + """Install the NPM credential helper. Writes the launcher binary and registers Cloudsmith registry hosts in ``${NPM_CONFIG_USERCONFIG:-~/.npmrc}``. diff --git a/cloudsmith_cli/credential_helpers/npm/rc.py b/cloudsmith_cli/credential_helpers/npm/rc.py index b7d47278..678b5ed0 100644 --- a/cloudsmith_cli/credential_helpers/npm/rc.py +++ b/cloudsmith_cli/credential_helpers/npm/rc.py @@ -108,9 +108,12 @@ def parse(self): with open(self._path) as f: for line in f: if line.lstrip().startswith("//"): - entry = NPMRC.URLEntry(line.rstrip("\n")) - self._lines.append(entry) - self._mapping[entry.id] = entry._value + try: + entry = NPMRC.URLEntry(line.rstrip("\n")) + self._lines.append(entry) + self._mapping[entry.id] = entry._value + except Exception: + self._lines.append(line.rstrip("\n")) else: self._lines.append(line.rstrip("\n")) diff --git a/cloudsmith_cli/credential_helpers/npm/tests/test_rc.py b/cloudsmith_cli/credential_helpers/npm/tests/test_rc.py index 832f6d0e..ec6682de 100644 --- a/cloudsmith_cli/credential_helpers/npm/tests/test_rc.py +++ b/cloudsmith_cli/credential_helpers/npm/tests/test_rc.py @@ -153,6 +153,74 @@ def test_npmrc_parse_preserves_non_url_entries(self): assert npmrc._lines[0] == "registry=https://registry.npmjs.org/" assert isinstance(npmrc._lines[1], NPMRC.URLEntry) + def test_npmrc_parse_ignores_invalid_lines(self): + """Parsing gracefully skips invalid URL lines. + + Invalid lines that start with // but don't parse correctly are stored + as raw strings and not added to the mapping. This prevents crashes + on malformed entries while preserving the original content. + """ + with TemporaryDirectory() as tmpdir: + rc_path = Path(tmpdir) / ".npmrc" + rc_path.write_text( + "//registry.npmjs.org/:tokenHelper=npm\n" + "// malformed comment line\n" + "//missing-domain/\n" + "//example.com/:validKey=validValue\n" + "//domain.com/noKeyValuePair\n" + ) + # Should parse without raising an exception + with NPMRC(rc_path, modifiable=False) as npmrc: + # Should have 5 lines total + assert len(npmrc._lines) == 5 + + # Valid entries should be URLEntry objects + assert isinstance(npmrc._lines[0], NPMRC.URLEntry) + assert isinstance(npmrc._lines[3], NPMRC.URLEntry) + + # Invalid entries should be stored as strings + assert isinstance(npmrc._lines[1], str) + assert isinstance(npmrc._lines[2], str) + assert isinstance(npmrc._lines[4], str) + assert npmrc._lines[1] == "// malformed comment line" + assert npmrc._lines[2] == "//missing-domain/" + assert npmrc._lines[4] == "//domain.com/noKeyValuePair" + + # Only valid entries should be in mapping + assert len(npmrc._mapping) == 2 + assert "registry.npmjs.org::tokenHelper" in npmrc._mapping + assert "example.com::validKey" in npmrc._mapping + + def test_npmrc_parse_user_comment_line(self): + """Parsing handles user-entered comment-like lines gracefully. + + This is the specific case from the PR comment where a user mistakenly + thought // was for comments (like in other formats) and created a + malformed entry "// oh I thought // was for code comments". + """ + with TemporaryDirectory() as tmpdir: + rc_path = Path(tmpdir) / ".npmrc" + rc_path.write_text( + "// oh I thought // was for code comments\n" + "//npm.cloudsmith.io/:tokenHelper=/usr/local/bin/npm-cred\n" + ) + # Should not raise ValueError during parsing + with NPMRC(rc_path, modifiable=False) as npmrc: + assert len(npmrc._lines) == 2 + + # First line (malformed) should be stored as string + assert isinstance(npmrc._lines[0], str) + assert npmrc._lines[0] == "// oh I thought // was for code comments" + + # Second line (valid) should be parsed + assert isinstance(npmrc._lines[1], NPMRC.URLEntry) + assert npmrc._lines[1]._domain == "npm.cloudsmith.io" + assert npmrc._lines[1]._key == "tokenHelper" + + # Only the valid entry should be in mapping + assert len(npmrc._mapping) == 1 + assert "npm.cloudsmith.io::tokenHelper" in npmrc._mapping + def test_npmrc_add_entry(self): """Add a new entry to NPMRC.""" with TemporaryDirectory() as tmpdir: @@ -229,6 +297,118 @@ def test_npmrc_add_no_conflict_different_domain(self): assert npmrc.add(entry) is True assert npmrc.failures == 0 + def test_npmrc_add_entry_preserves_invalid_lines(self): + """Adding an entry preserves invalid lines in the file. + + When adding a new entry to a file with invalid lines, those invalid + lines should be preserved in their original position and written back + to the file unchanged. + """ + with TemporaryDirectory() as tmpdir: + rc_path = Path(tmpdir) / ".npmrc" + rc_path.write_text( + "// invalid comment line\n" + "//registry.npmjs.org/:tokenHelper=existing\n" + "//malformed/missing/content\n" + ) + with NPMRC(rc_path, modifiable=True) as npmrc: + # Add a new valid entry + new_entry = NPMRC.URLEntry.from_values( + "registry.example.com", "tokenHelper", "new-helper" + ) + assert npmrc.add(new_entry) is True + + # Verify the lines are in order: invalid, valid, invalid, new valid + assert len(npmrc._lines) == 4 + assert isinstance(npmrc._lines[0], str) + assert npmrc._lines[0] == "// invalid comment line" + assert isinstance(npmrc._lines[1], NPMRC.URLEntry) + assert isinstance(npmrc._lines[2], str) + assert npmrc._lines[2] == "//malformed/missing/content" + assert isinstance(npmrc._lines[3], NPMRC.URLEntry) + + # Verify the file content preserves all lines including invalid ones + content = rc_path.read_text() + assert "// invalid comment line" in content + assert "//registry.npmjs.org/:tokenHelper=existing" in content + assert "//malformed/missing/content" in content + assert "//registry.example.com/:tokenHelper=new-helper" in content + + def test_npmrc_remove_entry_preserves_invalid_lines(self): + """Removing an entry preserves invalid lines in the file. + + When removing a valid entry from a file with invalid lines, those + invalid lines should remain in place and be written back unchanged. + """ + with TemporaryDirectory() as tmpdir: + rc_path = Path(tmpdir) / ".npmrc" + rc_path.write_text( + "// this is not a valid entry\n" + "//registry.example.com/:tokenHelper=helper1\n" + "//registry.other.com/:tokenHelper=helper2\n" + "//another invalid line without proper format\n" + ) + with NPMRC(rc_path, modifiable=True) as npmrc: + # Remove the first valid entry + entry_to_remove = NPMRC.URLEntry.from_values( + "registry.example.com", "tokenHelper", "helper1" + ) + assert npmrc.remove(entry_to_remove) is True + + # Verify structure: invalid, removed, valid, invalid + assert len(npmrc._lines) == 3 # removed one valid entry + assert isinstance(npmrc._lines[0], str) + assert npmrc._lines[0] == "// this is not a valid entry" + assert isinstance(npmrc._lines[1], NPMRC.URLEntry) + assert npmrc._lines[1]._domain == "registry.other.com" + assert isinstance(npmrc._lines[2], str) + assert npmrc._lines[2] == "//another invalid line without proper format" + + # Verify the file content preserves invalid lines + content = rc_path.read_text() + assert "// this is not a valid entry" in content + assert ( + "//registry.example.com/:tokenHelper=helper1" not in content + ) # removed + assert "//registry.other.com/:tokenHelper=helper2" in content + assert "//another invalid line without proper format" in content + + def test_npmrc_add_and_remove_preserves_invalid_lines_roundtrip(self): + """Adding and removing entries in sequence preserves invalid lines. + + This test validates that the file can be modified multiple times + while preserving invalid entries throughout the process. + """ + with TemporaryDirectory() as tmpdir: + rc_path = Path(tmpdir) / ".npmrc" + rc_path.write_text( + "// user comment\n//registry.a.com/:tokenHelper=a\n//malformed\n" + ) + + # First pass: add and remove + with NPMRC(rc_path, modifiable=True) as npmrc: + new_entry = NPMRC.URLEntry.from_values( + "registry.b.com", "tokenHelper", "b" + ) + npmrc.add(new_entry) + + # Verify file has invalid lines preserved + content = rc_path.read_text() + assert "// user comment" in content + assert "//malformed" in content + + # Second pass: read again and verify structure + with NPMRC(rc_path, modifiable=False) as npmrc: + assert len(npmrc._lines) == 4 # 2 invalid + 2 valid + invalid_count = sum( + 1 for line in npmrc._lines if not isinstance(line, NPMRC.URLEntry) + ) + valid_count = sum( + 1 for line in npmrc._lines if isinstance(line, NPMRC.URLEntry) + ) + assert invalid_count == 2 + assert valid_count == 2 + def test_npmrc_remove_entry(self): """Remove an entry from NPMRC.""" with TemporaryDirectory() as tmpdir: From f17b63ca0438e39b1dc0c52eaacf548fa0b53bb4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tighearn=C3=A1n=20Carroll?= <13870403+tigh-latte@users.noreply.github.com> Date: Thu, 20 Aug 2026 14:16:08 +0100 Subject: [PATCH 19/21] fix: update tokenHelper executable path if a new bin-dir is provided also, add tests around this, and make sure that the dry run reports that a value will be written --- .../test_credential_helper_install.py | 90 ++++++++++++++ cloudsmith_cli/credential_helpers/npm/rc.py | 12 +- .../credential_helpers/npm/tests/test_rc.py | 117 ++++++++++++++++++ 3 files changed, 218 insertions(+), 1 deletion(-) 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 62644d6b..ac1f9b39 100644 --- a/cloudsmith_cli/cli/tests/commands/test_credential_helper_install.py +++ b/cloudsmith_cli/cli/tests/commands/test_credential_helper_install.py @@ -338,6 +338,43 @@ def test_npm_installer_dry_run(tmp_path, monkeypatch): assert any(a.startswith("would set //npm.cloudsmith.io/") for a in actions) +def test_npm_installer_dry_run_updates_existing_tokenhelper(tmp_path, monkeypatch): + """dry_run with existing tokenHelper reports update same as adding new entry. + + When re-installing with a different bin_dir, the dry-run should report + the update using the same "would set" format as if adding a new entry. + """ + npm_path = tmp_path / ".npmrc" + monkeypatch.setenv("NPM_CONFIG_USERCONFIG", str(npm_path)) + old_bin_dir = tmp_path / "old_bin" + new_bin_dir = tmp_path / "new_bin" + + # Seed config with existing tokenHelper entry + npm_path.write_text( + f"//npm.cloudsmith.io/:tokenHelper={old_bin_dir}/npm-credential-cloudsmith\n" + ) + + # Re-install with different bin_dir in dry-run mode + installer = NPMInstaller() + actions = installer.install( + bin_dir=str(new_bin_dir), domains=("npm.cloudsmith.io",), dry_run=True + ) + + # Verify no files were created/modified + assert not (new_bin_dir / "npm-credential-cloudsmith").exists() + assert npm_path.read_text().startswith( + f"//npm.cloudsmith.io/:tokenHelper={old_bin_dir}" + ) + + # Verify dry-run reported the update as a "would set" action + # Same format as if it were being added for the first time + assert any("would write launcher" in a for a in actions) + assert any(a.startswith("would set //npm.cloudsmith.io/") for a in actions) + assert any(str(new_bin_dir) in a for a in actions), ( + "Actions should mention the new bin_dir path" + ) + + # --------------------------------------------------------------------------- # 7. install idempotent # --------------------------------------------------------------------------- @@ -377,6 +414,59 @@ def test_npm_installer_idempotent(tmp_path, monkeypatch): assert any("already up to date" in a for a in actions) +def test_npm_installer_updates_tokenhelper_with_different_bin_dir( + tmp_path: Path, monkeypatch: MonkeyPatch +): + """Re-installing with a different bin_dir updates the tokenHelper path. + + When an entry with tokenHelper already exists for a domain, calling install + again with a different bin_dir should overwrite the old launcher path with + the new one. Verifies that only the matching domain entries are updated. + """ + npm_path = tmp_path / ".npmrc" + monkeypatch.setenv("NPM_CONFIG_USERCONFIG", str(npm_path)) + old_bin_dir = tmp_path / "old_bin" + new_bin_dir = tmp_path / "new_bin" + + # Seed config with existing tokenHelper entries for domains that will be + # re-installed, plus one unrelated entry + npm_path.write_text( + f"//npm.cloudsmith.io/:tokenHelper={old_bin_dir}/npm-credential-cloudsmith\n" + f"//my.custom.domain/:tokenHelper={old_bin_dir}/npm-credential-cloudsmith\n" + "//registry.npmjs.org/:_authToken=abc123" + ) + + # Re-install with new bin_dir for the same domains that already exist + installer = NPMInstaller() + installer.install(bin_dir=str(new_bin_dir), domains=("my.custom.domain",)) + + content = npm_path.read_text() + + # Verify old paths are replaced with new ones for managed domains + assert ( + f"//npm.cloudsmith.io/:tokenHelper={new_bin_dir}/npm-credential-cloudsmith" + in content + ), "npm.cloudsmith.io should be updated to new_bin_dir" + + assert ( + f"//my.custom.domain/:tokenHelper={new_bin_dir}/npm-credential-cloudsmith" + in content + ), "my.custom.domain should be updated to new_bin_dir" + + # Verify old bin_dir paths are gone + assert f"{old_bin_dir}" not in content, ( + "All old bin_dir paths should be replaced with new_bin_dir" + ) + + # Verify non-cloudsmith entries are preserved + assert "//registry.npmjs.org/:_authToken=abc123" in content, ( + "Non-cloudsmith entries should be preserved" + ) + + # Verify launcher exists in new location + assert (new_bin_dir / "npm-credential-cloudsmith").exists() + + # --------------------------------------------------------------------------- # 8. uninstall # --------------------------------------------------------------------------- diff --git a/cloudsmith_cli/credential_helpers/npm/rc.py b/cloudsmith_cli/credential_helpers/npm/rc.py index 678b5ed0..39a36996 100644 --- a/cloudsmith_cli/credential_helpers/npm/rc.py +++ b/cloudsmith_cli/credential_helpers/npm/rc.py @@ -118,9 +118,19 @@ def parse(self): self._lines.append(line.rstrip("\n")) def add(self, entry: URLEntry) -> bool: - if entry.id in self._mapping: + if entry in self: return False + if NPMRC.URLEntry.from_values(entry._domain, entry._key) in self: + for i, line in enumerate(self._lines): + if isinstance(line, NPMRC.URLEntry) and line.id == entry.id: + self._lines[i] = entry + break + + self._modified = True + self._mapping[entry.id] = entry._value + return True + if NPMRC.URLEntry.from_values(entry._domain, "_authToken") in self: self._failures += 1 raise AuthKeyConflictError("_authToken") diff --git a/cloudsmith_cli/credential_helpers/npm/tests/test_rc.py b/cloudsmith_cli/credential_helpers/npm/tests/test_rc.py index ec6682de..53466bca 100644 --- a/cloudsmith_cli/credential_helpers/npm/tests/test_rc.py +++ b/cloudsmith_cli/credential_helpers/npm/tests/test_rc.py @@ -409,6 +409,123 @@ def test_npmrc_add_and_remove_preserves_invalid_lines_roundtrip(self): assert invalid_count == 2 assert valid_count == 2 + def test_npmrc_add_update_existing_entry_modifies_lines(self): + """Updating an existing entry modifies the _lines list in place. + + When adding an entry with the same domain and key but different value, + the entry in _lines should be replaced with the new entry object. + This ensures that __str__() returns the updated value. + """ + with TemporaryDirectory() as tmpdir: + rc_path = Path(tmpdir) / ".npmrc" + rc_path.write_text("//registry.example.com/:tokenHelper=/old/path/helper\n") + + with NPMRC(rc_path, modifiable=True) as npmrc: + # Verify initial state + assert len(npmrc._lines) == 1 + old_entry = npmrc._lines[0] + assert isinstance(old_entry, NPMRC.URLEntry) + assert old_entry._value == "/old/path/helper" + + # Add a new entry with same domain/key but different value + new_entry = NPMRC.URLEntry.from_values( + "registry.example.com", "tokenHelper", "/new/path/helper" + ) + result = npmrc.add(new_entry) + + # Verify operation returned True + assert result is True + + # Verify _lines was updated with the new entry object + assert len(npmrc._lines) == 1 + updated_entry = npmrc._lines[0] + assert isinstance(updated_entry, NPMRC.URLEntry) + assert updated_entry is new_entry, ( + "_lines should contain the new entry object, not the old one" + ) + assert updated_entry._value == "/new/path/helper" + + def test_npmrc_add_update_sets_modified_flag(self): + """Updating an existing entry sets the _modified flag to True.""" + with TemporaryDirectory() as tmpdir: + rc_path = Path(tmpdir) / ".npmrc" + rc_path.write_text("//registry.example.com/:tokenHelper=/old/path/helper\n") + + with NPMRC(rc_path, modifiable=True) as npmrc: + # Verify initial state + assert npmrc.modified is False + + # Update the entry + new_entry = NPMRC.URLEntry.from_values( + "registry.example.com", "tokenHelper", "/new/path/helper" + ) + npmrc.add(new_entry) + + # Verify _modified flag was set + assert npmrc.modified is True + + def test_npmrc_add_update_syncs_mapping(self): + """Updating an entry keeps _mapping in sync with _lines. + + When an entry is updated, the mapping value should reflect the new value. + """ + with TemporaryDirectory() as tmpdir: + rc_path = Path(tmpdir) / ".npmrc" + rc_path.write_text("//registry.example.com/:tokenHelper=/old/path/helper\n") + + with NPMRC(rc_path, modifiable=True) as npmrc: + # Verify initial mapping + entry_id = "registry.example.com::tokenHelper" + assert npmrc._mapping[entry_id] == "/old/path/helper" + + # Update the entry + new_entry = NPMRC.URLEntry.from_values( + "registry.example.com", "tokenHelper", "/new/path/helper" + ) + npmrc.add(new_entry) + + # Verify mapping was updated + assert npmrc._mapping[entry_id] == "/new/path/helper" + + def test_npmrc_add_update_multiple_entries(self): + """Updating one entry doesn't affect others in _lines. + + Verifies that when multiple tokenHelper entries exist and one is updated, + only the matching entry is replaced while others are preserved. + """ + with TemporaryDirectory() as tmpdir: + rc_path = Path(tmpdir) / ".npmrc" + rc_path.write_text( + "//registry.a.com/:tokenHelper=/old/path/helper\n" + "//registry.b.com/:tokenHelper=/other/path/helper\n" + ) + + with NPMRC(rc_path, modifiable=True) as npmrc: + # Verify initial state + assert len(npmrc._lines) == 2 + entry_a = npmrc._lines[0] + assert isinstance(entry_a, NPMRC.URLEntry) + entry_b = npmrc._lines[1] + assert isinstance(entry_b, NPMRC.URLEntry) + assert entry_a._domain == "registry.a.com" + assert entry_b._domain == "registry.b.com" + + # Update only registry.a.com + new_entry_a = NPMRC.URLEntry.from_values( + "registry.a.com", "tokenHelper", "/new/path/helper" + ) + result = npmrc.add(new_entry_a) + + # Verify update was successful + assert result is True + + # Verify _lines structure: updated a, unchanged b + assert len(npmrc._lines) == 2 + assert npmrc._lines[0] is new_entry_a + assert npmrc._lines[0]._value == "/new/path/helper" + assert npmrc._lines[1] is entry_b # Unchanged + assert npmrc._lines[1]._value == "/other/path/helper" + def test_npmrc_remove_entry(self): """Remove an entry from NPMRC.""" with TemporaryDirectory() as tmpdir: From 99bd6f285c5caff511e7adcc5c8eec345bfea50b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tighearn=C3=A1n=20Carroll?= <13870403+tigh-latte@users.noreply.github.com> Date: Thu, 20 Aug 2026 14:40:30 +0100 Subject: [PATCH 20/21] fix: update all references of npm to pnpm given that tokenHelper is a pnpm config item. --- .../cli/commands/credential_helper/manage.py | 10 +- .../cli/commands/credential_helper/pnpm.py | 6 +- .../test_credential_helper_install.py | 148 +++++++++--------- .../credential_helpers/npm/__init__.py | 4 - .../credential_helpers/pnpm/__init__.py | 4 + .../{npm => pnpm}/installer.py | 20 +-- .../credential_helpers/{npm => pnpm}/rc.py | 0 .../{npm => pnpm}/runtime.py | 10 +- .../{npm => pnpm}/tests/__init__.py | 0 .../{npm => pnpm}/tests/test_rc.py | 0 10 files changed, 101 insertions(+), 101 deletions(-) delete mode 100644 cloudsmith_cli/credential_helpers/npm/__init__.py create mode 100644 cloudsmith_cli/credential_helpers/pnpm/__init__.py rename cloudsmith_cli/credential_helpers/{npm => pnpm}/installer.py (94%) rename cloudsmith_cli/credential_helpers/{npm => pnpm}/rc.py (100%) rename cloudsmith_cli/credential_helpers/{npm => pnpm}/runtime.py (85%) rename cloudsmith_cli/credential_helpers/{npm => pnpm}/tests/__init__.py (100%) rename cloudsmith_cli/credential_helpers/{npm => pnpm}/tests/test_rc.py (100%) diff --git a/cloudsmith_cli/cli/commands/credential_helper/manage.py b/cloudsmith_cli/cli/commands/credential_helper/manage.py index 675eba56..2eaa94ef 100644 --- a/cloudsmith_cli/cli/commands/credential_helper/manage.py +++ b/cloudsmith_cli/cli/commands/credential_helper/manage.py @@ -13,7 +13,7 @@ import click from cloudsmith_cli.credential_helpers.generic import PartialInstallError -from cloudsmith_cli.credential_helpers.npm.installer import NPMInstaller +from cloudsmith_cli.credential_helpers.pnpm.installer import PNPMInstaller from ....credential_helpers.docker.installer import DockerInstaller from ... import utils @@ -30,7 +30,7 @@ _INSTALLERS: dict[str, type] = { "docker": DockerInstaller, - "npm": NPMInstaller, + "pnpm": PNPMInstaller, } @@ -114,10 +114,10 @@ def install_cmd( ) -> None: """Install a credential helper launcher and configure the package manager. - HELPER is the name of the credential helper to install (e.g. ``docker``, ``npm``). + HELPER is the name of the credential helper to install (e.g. ``docker``, ``pnpm``). - Important for NPM/pnpm: The tokenHelper directive is only honored in the - user-level ~/.npmrc file, not in a project-level .npmrc. This is a pnpm/npm + Important for pnpm: The tokenHelper directive is only honored in the + user-level ~/.npmrc file, not in a project-level .npmrc. This is a pnpm security restriction. The absolute path to the launcher is automatically calculated and configured. diff --git a/cloudsmith_cli/cli/commands/credential_helper/pnpm.py b/cloudsmith_cli/cli/commands/credential_helper/pnpm.py index fa16e2ab..55654c7a 100644 --- a/cloudsmith_cli/cli/commands/credential_helper/pnpm.py +++ b/cloudsmith_cli/cli/commands/credential_helper/pnpm.py @@ -1,15 +1,15 @@ # Copyright 2026 Cloudsmith Ltd """ -Npm credential helper command. +pnpm credential helper command. -Implements the NPM credential helper protocol for Cloudsmith registries. +Implements the pnpm credential helper protocol for Cloudsmith registries. """ import sys import click -from ....credential_helpers.npm import * +from ....credential_helpers.pnpm import execute from ...decorators import common_api_auth_options, resolve_credentials 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 ac1f9b39..bc14fabd 100644 --- a/cloudsmith_cli/cli/tests/commands/test_credential_helper_install.py +++ b/cloudsmith_cli/cli/tests/commands/test_credential_helper_install.py @@ -15,7 +15,7 @@ from _pytest.monkeypatch import MonkeyPatch from cloudsmith_cli.credential_helpers.generic import PartialInstallError -from cloudsmith_cli.credential_helpers.npm.installer import NPMInstaller +from cloudsmith_cli.credential_helpers.pnpm.installer import PNPMInstaller from ....core.credentials.models import CredentialResult from ....credential_helpers.default_domains import DomainType @@ -63,15 +63,15 @@ def runner(): ), ( False, - "npm", - "npm-credential-cloudsmith", - '#!/bin/sh\nexec cloudsmith credential-helper npm "$@"\n', + "pnpm", + "pnpm-credential-cloudsmith", + '#!/bin/sh\nexec cloudsmith credential-helper pnpm "$@"\n', ), ( True, - "npm", - "npm-credential-cloudsmith.cmd", - "@echo off\r\ncloudsmith credential-helper npm %*\r\n", + "pnpm", + "pnpm-credential-cloudsmith.cmd", + "@echo off\r\ncloudsmith credential-helper pnpm %*\r\n", ), ], ) @@ -103,7 +103,7 @@ def test_launcher_filename_and_content( "format", [ "docker", - "npm", + "pnpm", ], ) def test_write_launcher_writes_executable_script(format, tmp_path): @@ -122,7 +122,7 @@ def test_write_launcher_writes_executable_script(format, tmp_path): "format", [ "docker", - "npm", + "pnpm", ], ) def test_remove_launcher(format, tmp_path): @@ -281,7 +281,7 @@ def test_docker_installer_install(tmp_path, monkeypatch): assert (bin_dir / "docker-credential-cloudsmith").exists() -def test_npm_installer_install(tmp_path: Path, monkeypatch: MonkeyPatch): +def test_pnpm_installer_install(tmp_path: Path, monkeypatch: MonkeyPatch): """install sets default+extra domains, preserves foreign entries, writes the launcher.""" npm_path = tmp_path / ".npmrc" monkeypatch.setenv("NPM_CONFIG_USERCONFIG", str(npm_path)) @@ -291,16 +291,16 @@ def test_npm_installer_install(tmp_path: Path, monkeypatch: MonkeyPatch): # Seed a config with foreign data that must be preserved npm_path.write_text("//registry.npmjs.org/:_authToken=abc123") - installer = NPMInstaller() + installer = PNPMInstaller() installer.install(bin_dir=str(bin_dir), domains=("my.registry.example.com",)) assert ( npm_path.read_text() == "//registry.npmjs.org/:_authToken=abc123\n" - f"//npm.cloudsmith.io/:tokenHelper={bin_dir}/npm-credential-cloudsmith\n" - f"//my.registry.example.com/:tokenHelper={bin_dir}/npm-credential-cloudsmith" + f"//npm.cloudsmith.io/:tokenHelper={bin_dir}/pnpm-credential-cloudsmith\n" + f"//my.registry.example.com/:tokenHelper={bin_dir}/pnpm-credential-cloudsmith" ) # Launcher written - assert (bin_dir / "npm-credential-cloudsmith").exists() + assert (bin_dir / "pnpm-credential-cloudsmith").exists() # --------------------------------------------------------------------------- @@ -323,22 +323,22 @@ def test_docker_installer_dry_run(tmp_path, monkeypatch): assert any("docker.cloudsmith.io" in a for a in actions) -def test_npm_installer_dry_run(tmp_path, monkeypatch): +def test_pnpm_installer_dry_run(tmp_path, monkeypatch): """dry_run=True: no launcher written, config.json absent, returns planned strings.""" npm_path = tmp_path / ".npmrc" monkeypatch.setenv("NPM_CONFIG_USERCONFIG", str(npm_path)) bin_dir = tmp_path / "bin" - installer = NPMInstaller() + installer = PNPMInstaller() actions = installer.install(bin_dir=str(bin_dir), dry_run=True) - assert not (bin_dir / "npm-credential-cloudsmith").exists() + assert not (bin_dir / "pnpm-credential-cloudsmith").exists() assert not npm_path.exists() assert any("would write launcher" in a for a in actions) assert any(a.startswith("would set //npm.cloudsmith.io/") for a in actions) -def test_npm_installer_dry_run_updates_existing_tokenhelper(tmp_path, monkeypatch): +def test_pnpm_installer_dry_run_updates_existing_tokenhelper(tmp_path, monkeypatch): """dry_run with existing tokenHelper reports update same as adding new entry. When re-installing with a different bin_dir, the dry-run should report @@ -351,17 +351,17 @@ def test_npm_installer_dry_run_updates_existing_tokenhelper(tmp_path, monkeypatc # Seed config with existing tokenHelper entry npm_path.write_text( - f"//npm.cloudsmith.io/:tokenHelper={old_bin_dir}/npm-credential-cloudsmith\n" + f"//npm.cloudsmith.io/:tokenHelper={old_bin_dir}/pnpm-credential-cloudsmith\n" ) # Re-install with different bin_dir in dry-run mode - installer = NPMInstaller() + installer = PNPMInstaller() actions = installer.install( bin_dir=str(new_bin_dir), domains=("npm.cloudsmith.io",), dry_run=True ) # Verify no files were created/modified - assert not (new_bin_dir / "npm-credential-cloudsmith").exists() + assert not (new_bin_dir / "pnpm-credential-cloudsmith").exists() assert npm_path.read_text().startswith( f"//npm.cloudsmith.io/:tokenHelper={old_bin_dir}" ) @@ -397,13 +397,13 @@ def test_docker_installer_idempotent(tmp_path, monkeypatch): assert any("already up to date" in a for a in actions) -def test_npm_installer_idempotent(tmp_path, monkeypatch): +def test_pnpm_installer_idempotent(tmp_path, monkeypatch): """Second install run reports no change (config mtime unchanged, 'already up to date').""" npm_path = tmp_path / ".npmrc" monkeypatch.setenv("NPM_CONFIG_USERCONFIG", str(npm_path)) bin_dir = tmp_path / "bin" - installer = NPMInstaller() + installer = PNPMInstaller() installer.install(bin_dir=str(bin_dir)) mtime_before = npm_path.stat().st_mtime @@ -414,7 +414,7 @@ def test_npm_installer_idempotent(tmp_path, monkeypatch): assert any("already up to date" in a for a in actions) -def test_npm_installer_updates_tokenhelper_with_different_bin_dir( +def test_pnpm_installer_updates_tokenhelper_with_different_bin_dir( tmp_path: Path, monkeypatch: MonkeyPatch ): """Re-installing with a different bin_dir updates the tokenHelper path. @@ -431,25 +431,25 @@ def test_npm_installer_updates_tokenhelper_with_different_bin_dir( # Seed config with existing tokenHelper entries for domains that will be # re-installed, plus one unrelated entry npm_path.write_text( - f"//npm.cloudsmith.io/:tokenHelper={old_bin_dir}/npm-credential-cloudsmith\n" - f"//my.custom.domain/:tokenHelper={old_bin_dir}/npm-credential-cloudsmith\n" + f"//npm.cloudsmith.io/:tokenHelper={old_bin_dir}/pnpm-credential-cloudsmith\n" + f"//my.custom.domain/:tokenHelper={old_bin_dir}/pnpm-credential-cloudsmith\n" "//registry.npmjs.org/:_authToken=abc123" ) # Re-install with new bin_dir for the same domains that already exist - installer = NPMInstaller() + installer = PNPMInstaller() installer.install(bin_dir=str(new_bin_dir), domains=("my.custom.domain",)) content = npm_path.read_text() # Verify old paths are replaced with new ones for managed domains assert ( - f"//npm.cloudsmith.io/:tokenHelper={new_bin_dir}/npm-credential-cloudsmith" + f"//npm.cloudsmith.io/:tokenHelper={new_bin_dir}/pnpm-credential-cloudsmith" in content ), "npm.cloudsmith.io should be updated to new_bin_dir" assert ( - f"//my.custom.domain/:tokenHelper={new_bin_dir}/npm-credential-cloudsmith" + f"//my.custom.domain/:tokenHelper={new_bin_dir}/pnpm-credential-cloudsmith" in content ), "my.custom.domain should be updated to new_bin_dir" @@ -464,7 +464,7 @@ def test_npm_installer_updates_tokenhelper_with_different_bin_dir( ) # Verify launcher exists in new location - assert (new_bin_dir / "npm-credential-cloudsmith").exists() + assert (new_bin_dir / "pnpm-credential-cloudsmith").exists() # --------------------------------------------------------------------------- @@ -512,7 +512,7 @@ def test_docker_installer_uninstall(tmp_path, monkeypatch): assert not launcher.exists() -def test_npm_installer_uninstall(tmp_path: Path, monkeypatch): +def test_pnpm_installer_uninstall(tmp_path: Path, monkeypatch): """uninstall removes only cloudsmith entries (foreign kept) + removes launcher. Also verifies --bin-dir: install to custom dir, uninstall with same --bin-dir removes it. @@ -522,15 +522,15 @@ def test_npm_installer_uninstall(tmp_path: Path, monkeypatch): custom_bin_dir = tmp_path / "custom_bin" # Install to a custom bin dir - installer = NPMInstaller() + installer = PNPMInstaller() npm_path.write_text( "//registry.npmjs.org/:_authToken=abc123\n" - f"//npm.cloudsmith.io/:tokenHelper={custom_bin_dir}/npm-credential-cloudsmith\n" - f"//my.custom.domain/:tokenHelper={custom_bin_dir}/npm-credential-cloudsmith" + f"//npm.cloudsmith.io/:tokenHelper={custom_bin_dir}/pnpm-credential-cloudsmith\n" + f"//my.custom.domain/:tokenHelper={custom_bin_dir}/pnpm-credential-cloudsmith" ) # Install launcher to custom_bin_dir installer.install(bin_dir=str(custom_bin_dir)) - launcher = custom_bin_dir / "npm-credential-cloudsmith" + launcher = custom_bin_dir / "pnpm-credential-cloudsmith" assert launcher.exists(), "Precondition: launcher must exist after install" # Uninstall — removes cloudsmith keys and launcher @@ -583,7 +583,7 @@ def test_docker_installer_status_type_contract(tmp_path, monkeypatch): assert not isinstance(launcher, Path) -def test_npm_installer_status_type_contract(tmp_path: Path, monkeypatch: MonkeyPatch): +def test_pnpm_installer_status_type_contract(tmp_path: Path, monkeypatch: MonkeyPatch): """status()['launcher'] is str when installed and None when not — never a Path. Retained guard: the -F json Path-serialization regression. @@ -592,11 +592,11 @@ def test_npm_installer_status_type_contract(tmp_path: Path, monkeypatch: MonkeyP monkeypatch.setenv("NPM_CONFIG_USERCONFIG", str(npm_path)) bin_dir = tmp_path / "bin" - installer = NPMInstaller() + installer = PNPMInstaller() # Before install: launcher is None with patch( - "cloudsmith_cli.credential_helpers.npm.installer.resolve_bin_dir", + "cloudsmith_cli.credential_helpers.pnpm.installer.resolve_bin_dir", return_value=bin_dir, ): result_before = installer.status() @@ -607,7 +607,7 @@ def test_npm_installer_status_type_contract(tmp_path: Path, monkeypatch: MonkeyP # After install: launcher is a non-None str installer.install(bin_dir=str(bin_dir)) with patch( - "cloudsmith_cli.credential_helpers.npm.installer.resolve_bin_dir", + "cloudsmith_cli.credential_helpers.pnpm.installer.resolve_bin_dir", return_value=bin_dir, ): result_after = installer.status() @@ -617,7 +617,7 @@ def test_npm_installer_status_type_contract(tmp_path: Path, monkeypatch: MonkeyP assert isinstance(launcher, str), ( f"status()['launcher'] must be str, got {type(launcher).__name__!r}" ) - assert launcher.endswith("npm-credential-cloudsmith") + assert launcher.endswith("pnpm-credential-cloudsmith") assert not isinstance(launcher, Path) @@ -854,7 +854,7 @@ def test_manage_cli_unknown_helper_exits_nonzero(runner): "helper", [ "docker", - "npm", + "pnpm", ], ) def test_manage_cli_dry_run_exits_0(helper, runner, tmp_path, monkeypatch): @@ -877,7 +877,7 @@ def test_manage_cli_dry_run_exits_0(helper, runner, tmp_path, monkeypatch): "format,installer", [ ("docker", DockerInstaller), - ("npm", NPMInstaller), + ("pnpm", PNPMInstaller), ], ) def test_manage_cli_passes_resolved_credential_to_installer( @@ -927,13 +927,13 @@ def test_docker_path_warning_when_bin_dir_not_on_path(tmp_path, monkeypatch): assert any("PATH" in a for a in warning_actions) -def test_npm_no_path_warning_when_bin_dir_not_on_path(tmp_path, monkeypatch): +def test_pnpm_no_path_warning_when_bin_dir_not_on_path(tmp_path, monkeypatch): """install returns a WARNING action when target bin_dir is not on PATH.""" monkeypatch.setenv("NPM_CONFIG_USERCONFIG", str(tmp_path / ".npmrc")) bin_dir = tmp_path / "bin" monkeypatch.setenv("PATH", "/usr/bin:/usr/local/bin") - installer = NPMInstaller() + installer = PNPMInstaller() actions = installer.install(bin_dir=str(bin_dir)) warning_actions = [a for a in actions if a.startswith("WARNING")] @@ -953,7 +953,7 @@ def test_npm_no_path_warning_when_bin_dir_not_on_path(tmp_path, monkeypatch): "format", [ "docker", - "npm", + "pnpm", ], ) def test_unwritable_bin_dir_gives_click_exception( @@ -1006,7 +1006,7 @@ def test_unwritable_bin_dir_gives_click_exception( ( "install_cmd", [ - "npm", + "pnpm", "--dry-run", "--no-discover", "--bin-dir", @@ -1014,7 +1014,7 @@ def test_unwritable_bin_dir_gives_click_exception( "-F", "json", ], - "npm", + "pnpm", True, ), # uninstall dry-run with -F json @@ -1026,8 +1026,8 @@ def test_unwritable_bin_dir_gives_click_exception( ), ( "uninstall_cmd", - ["npm", "--dry-run", "-F", "json"], - "npm", + ["pnpm", "--dry-run", "-F", "json"], + "pnpm", True, ), # list with -F json @@ -1040,7 +1040,7 @@ def test_unwritable_bin_dir_gives_click_exception( ( "list_cmd", ["-F", "json"], - "npm", + "pnpm", False, ), ], @@ -1065,16 +1065,16 @@ def _docker_stub_status_fn(_self): "hosts": ["docker.cloudsmith.io"], } - def _npm_stub_status_fn(_self): + def _pnpm_stub_status_fn(_self): return { - "launcher": "/some/bin/npm-credential-cloudsmith", + "launcher": "/some/bin/pnpm-credential-cloudsmith", "hosts": ["npm.cloudsmith.io"], } monkeypatch.setenv("DOCKER_CONFIG", str(tmp_path / ".docker")) monkeypatch.setenv("NPM_CONFIG_USERCONFIG", str(tmp_path / ".npmrc")) monkeypatch.setattr(DockerInstaller, "status", _docker_stub_status_fn) - monkeypatch.setattr(NPMInstaller, "status", _npm_stub_status_fn) + monkeypatch.setattr(PNPMInstaller, "status", _pnpm_stub_status_fn) from ....cli.commands.credential_helper import manage as manage_mod @@ -1107,7 +1107,7 @@ def _npm_stub_status_fn(_self): assert data["dry_run"] is True -@pytest.mark.parametrize("helper", ["docker", "npm"]) +@pytest.mark.parametrize("helper", ["docker", "pnpm"]) def test_output_format_default_shows_human_text(helper, runner, tmp_path, monkeypatch): """Default (no -F) install dry-run shows human-readable text, not raw JSON.""" monkeypatch.setenv("DOCKER_CONFIG", str(tmp_path / ".docker")) @@ -1152,7 +1152,7 @@ def test_docker_install_coerces_malformed_cred_helpers(tmp_path, monkeypatch): assert cfg["credHelpers"]["docker.cloudsmith.io"] == "cloudsmith" -def test_npm_install_coerces_malformed_cred_helpers(tmp_path: Path, monkeypatch): +def test_pnpm_install_coerces_malformed_cred_helpers(tmp_path: Path, monkeypatch): """install coerces a non-dict credHelpers (list) rather than raising TypeError.""" npm_path = tmp_path / ".npmrc" monkeypatch.setenv("NPM_CONFIG_USERCONFIG", str(npm_path)) @@ -1163,7 +1163,7 @@ def test_npm_install_coerces_malformed_cred_helpers(tmp_path: Path, monkeypatch) "badtext\n//registry.npmjs.org/:_authToken=abc123\nmorebadtext\n" ) - installer = NPMInstaller() + installer = PNPMInstaller() # Must not raise installer.install(bin_dir=str(bin_dir), discover=False) @@ -1171,7 +1171,7 @@ def test_npm_install_coerces_malformed_cred_helpers(tmp_path: Path, monkeypatch) npm_path.read_text() == "badtext\n" "//registry.npmjs.org/:_authToken=abc123\n" "morebadtext\n" - f"//npm.cloudsmith.io/:tokenHelper={bin_dir}/npm-credential-cloudsmith" + f"//npm.cloudsmith.io/:tokenHelper={bin_dir}/pnpm-credential-cloudsmith" ) @@ -1193,7 +1193,7 @@ def test_docker_uninstall_tolerates_malformed_cred_helpers(tmp_path, monkeypatch installer.uninstall(bin_dir=str(bin_dir)) -def test_npm_uninstall_tolerates_malformed_cred_helpers(tmp_path: Path, monkeypatch): +def test_pnpm_uninstall_tolerates_malformed_cred_helpers(tmp_path: Path, monkeypatch): """uninstall treats a non-dict credHelpers (string) as a no-op rather than raising.""" npm_path = tmp_path / ".npmrc" monkeypatch.setenv("NPM_CONFIG_USERCONFIG", str(npm_path)) @@ -1204,10 +1204,10 @@ def test_npm_uninstall_tolerates_malformed_cred_helpers(tmp_path: Path, monkeypa "badtext\n" "//registry.npmjs.org/:_authToken=abc123\n" "morebadtext\n" - f"//npm.cloudsmith.io/:tokenHelper={bin_dir}/npm-credential-cloudsmith" + f"//npm.cloudsmith.io/:tokenHelper={bin_dir}/pnpm-credential-cloudsmith" ) - installer = NPMInstaller() + installer = PNPMInstaller() # Must not raise installer.uninstall(bin_dir=str(bin_dir)) @@ -1218,7 +1218,7 @@ def test_npm_uninstall_tolerates_malformed_cred_helpers(tmp_path: Path, monkeypa @pytest.mark.parametrize("char", [";", "#"]) -def test_npm_uninstall_tolerates_comments(char, tmp_path: Path, monkeypatch): +def test_pnpm_uninstall_tolerates_comments(char, tmp_path: Path, monkeypatch): """uninstall treats a non-dict credHelpers (string) as a no-op rather than raising.""" npm_path = tmp_path / ".npmrc" monkeypatch.setenv("NPM_CONFIG_USERCONFIG", str(npm_path)) @@ -1227,17 +1227,17 @@ def test_npm_uninstall_tolerates_comments(char, tmp_path: Path, monkeypatch): # Seed config with a malformed credHelpers value (string instead of dict) npm_path.write_text( "//registry.npmjs.org/:_authToken=abc123\n" - f"//npm.cloudsmith.io/:tokenHelper={bin_dir}/npm-credential-cloudsmith{char}wowcommented" + f"//npm.cloudsmith.io/:tokenHelper={bin_dir}/pnpm-credential-cloudsmith{char}wowcommented" ) - installer = NPMInstaller() + installer = PNPMInstaller() # Must not raise installer.uninstall(bin_dir=str(bin_dir)) assert npm_path.read_text() == "//registry.npmjs.org/:_authToken=abc123" -def test_npm_uninstall_tolerates_leading_whitespace(tmp_path: Path, monkeypatch): +def test_pnpm_uninstall_tolerates_leading_whitespace(tmp_path: Path, monkeypatch): npm_path = tmp_path / ".npmrc" monkeypatch.setenv("NPM_CONFIG_USERCONFIG", str(npm_path)) bin_dir = tmp_path / "bin" @@ -1245,11 +1245,11 @@ def test_npm_uninstall_tolerates_leading_whitespace(tmp_path: Path, monkeypatch) # Seed config with a malformed credHelpers value (string instead of dict) npm_path.write_text( "//registry.npmjs.org/:_authToken=abc123\n" - f" //npm.cloudsmith.io/:tokenHelper={bin_dir}/npm-credential-cloudsmith\n" + f" //npm.cloudsmith.io/:tokenHelper={bin_dir}/pnpm-credential-cloudsmith\n" "//unrelated.registry/:_authToken=abc123" ) - installer = NPMInstaller() + installer = PNPMInstaller() # Must not raise installer.uninstall(bin_dir=str(bin_dir)) @@ -1259,7 +1259,7 @@ def test_npm_uninstall_tolerates_leading_whitespace(tmp_path: Path, monkeypatch) ) -def test_npm_uninstall_preserves_leading_whitespace(tmp_path: Path, monkeypatch): +def test_pnpm_uninstall_preserves_leading_whitespace(tmp_path: Path, monkeypatch): npm_path = tmp_path / ".npmrc" monkeypatch.setenv("NPM_CONFIG_USERCONFIG", str(npm_path)) bin_dir = tmp_path / "bin" @@ -1267,11 +1267,11 @@ def test_npm_uninstall_preserves_leading_whitespace(tmp_path: Path, monkeypatch) # Seed config with a malformed credHelpers value (string instead of dict) npm_path.write_text( " //registry.npmjs.org/:_authToken=abc123\n" - f"//npm.cloudsmith.io/:tokenHelper={bin_dir}/npm-credential-cloudsmith\n" + f"//npm.cloudsmith.io/:tokenHelper={bin_dir}/pnpm-credential-cloudsmith\n" "//unrelated.registry/:_authToken=abc123" ) - installer = NPMInstaller() + installer = PNPMInstaller() # Must not raise installer.uninstall(bin_dir=str(bin_dir)) @@ -1282,14 +1282,14 @@ def test_npm_uninstall_preserves_leading_whitespace(tmp_path: Path, monkeypatch) @pytest.mark.parametrize("kind", ["_auth", "_authToken", "_password"]) -def test_npm_install_warn_on_auth_configured(kind, tmp_path: Path, monkeypatch): +def test_pnpm_install_warn_on_auth_configured(kind, tmp_path: Path, monkeypatch): npm_path = tmp_path / ".npmrc" monkeypatch.setenv("NPM_CONFIG_USERCONFIG", str(npm_path)) bin_dir = tmp_path / "bin" npm_path.write_text(f"//npm.cloudsmith.io/:{kind}=token") - installer = NPMInstaller() + installer = PNPMInstaller() with pytest.raises(PartialInstallError) as e: installer.install(bin_dir=str(bin_dir), discover=False) assert e.value.exit_code == 1 @@ -1303,7 +1303,7 @@ def test_npm_install_warn_on_auth_configured(kind, tmp_path: Path, monkeypatch): @pytest.mark.parametrize("kind", ["_auth", "_authToken", "_password"]) -def test_npm_install_warn_on_auth_configured_partial_write( +def test_pnpm_install_warn_on_auth_configured_partial_write( kind, tmp_path: Path, monkeypatch ): npm_path = tmp_path / ".npmrc" @@ -1312,7 +1312,7 @@ def test_npm_install_warn_on_auth_configured_partial_write( npm_path.write_text(f"//npm.cloudsmith.io/:{kind}=token") - installer = NPMInstaller() + installer = PNPMInstaller() with pytest.raises(PartialInstallError) as e: installer.install( bin_dir=str(bin_dir), discover=False, domains=("my.registry.example.com",) @@ -1324,7 +1324,7 @@ def test_npm_install_warn_on_auth_configured_partial_write( # file was partially written to assert ( npm_path.read_text() == f"//npm.cloudsmith.io/:{kind}=token\n" - f"//my.registry.example.com/:tokenHelper={bin_dir}/npm-credential-cloudsmith" + f"//my.registry.example.com/:tokenHelper={bin_dir}/pnpm-credential-cloudsmith" ) warning_actions = [a for a in actions if a.startswith("WARNING")] diff --git a/cloudsmith_cli/credential_helpers/npm/__init__.py b/cloudsmith_cli/credential_helpers/npm/__init__.py deleted file mode 100644 index 4a0eef35..00000000 --- a/cloudsmith_cli/credential_helpers/npm/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -# Copyright 2026 Cloudsmith Ltd -from .runtime import execute, get_npm_credentials - -__all__ = ["execute", "get_npm_credentials"] diff --git a/cloudsmith_cli/credential_helpers/pnpm/__init__.py b/cloudsmith_cli/credential_helpers/pnpm/__init__.py new file mode 100644 index 00000000..cbf2f40d --- /dev/null +++ b/cloudsmith_cli/credential_helpers/pnpm/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2026 Cloudsmith Ltd +from .runtime import execute, get_pnpm_credentials + +__all__ = ["execute", "get_pnpm_credentials"] diff --git a/cloudsmith_cli/credential_helpers/npm/installer.py b/cloudsmith_cli/credential_helpers/pnpm/installer.py similarity index 94% rename from cloudsmith_cli/credential_helpers/npm/installer.py rename to cloudsmith_cli/credential_helpers/pnpm/installer.py index 3bfafac2..f45cea8d 100644 --- a/cloudsmith_cli/credential_helpers/npm/installer.py +++ b/cloudsmith_cli/credential_helpers/pnpm/installer.py @@ -12,7 +12,7 @@ resolve_bin_dir, write_launcher, ) -from cloudsmith_cli.credential_helpers.npm.rc import NPMRC, AuthKeyConflictError +from cloudsmith_cli.credential_helpers.pnpm.rc import NPMRC, AuthKeyConflictError from ...core.credentials.models import CredentialResult @@ -28,13 +28,13 @@ def _config_path() -> Path: return Path.home() / ".npmrc" -class NPMInstaller: - LAUNCHER_NAME = "npm-credential-cloudsmith" - TARGET_CMD = "cloudsmith credential-helper npm" +class PNPMInstaller: + LAUNCHER_NAME = "pnpm-credential-cloudsmith" + TARGET_CMD = "cloudsmith credential-helper pnpm" DEFAULT_HOST = "npm.cloudsmith.io" - name = "npm" - summary = "NPM credential helper for Cloudsmith registries" + name = "pnpm" + summary = "pnpm credential helper for Cloudsmith registries" @classmethod def _resolve_target_cmd(cls) -> str: @@ -48,7 +48,7 @@ def _resolve_target_cmd(cls) -> str: is quoted so a directory containing spaces still execs correctly. """ if getattr(sys, "frozen", False): - return f'"{sys.executable}" credential-helper npm' + return f'"{sys.executable}" credential-helper pnpm' return cls.TARGET_CMD def install( @@ -63,7 +63,7 @@ def install( api_host: str | None = None, dry_run: bool = False, ) -> list[str]: - """Install the NPM credential helper. + """Install the pnpm credential helper. Writes the launcher binary and registers Cloudsmith registry hosts in ``${NPM_CONFIG_USERCONFIG:-~/.npmrc}``. @@ -77,7 +77,7 @@ def install( Additional registry hostnames to configure (in addition to the default ``npm.cloudsmith.io``). discover: - When ``True`` (default), attempt to auto-discover NPM custom + When ``True`` (default), attempt to auto-discover pnpm custom domains via the Cloudsmith API. Discovery is best-effort and never prevents the defaults from being registered. refresh: @@ -129,7 +129,7 @@ def install( discovered = [] new_hosts = [h for h in discovered if h not in hosts] hosts.extend(discovered) - actions.append(f"discovered {len(new_hosts)} new NPM custom domain(s)") + actions.append(f"discovered {len(new_hosts)} new pnpm custom domain(s)") else: logger.debug( "skipped auto-discovery" diff --git a/cloudsmith_cli/credential_helpers/npm/rc.py b/cloudsmith_cli/credential_helpers/pnpm/rc.py similarity index 100% rename from cloudsmith_cli/credential_helpers/npm/rc.py rename to cloudsmith_cli/credential_helpers/pnpm/rc.py diff --git a/cloudsmith_cli/credential_helpers/npm/runtime.py b/cloudsmith_cli/credential_helpers/pnpm/runtime.py similarity index 85% rename from cloudsmith_cli/credential_helpers/npm/runtime.py rename to cloudsmith_cli/credential_helpers/pnpm/runtime.py index 03668a59..87fde0cc 100644 --- a/cloudsmith_cli/credential_helpers/npm/runtime.py +++ b/cloudsmith_cli/credential_helpers/pnpm/runtime.py @@ -14,15 +14,15 @@ ) -def get_npm_credentials(server_url, credential=None, api_host=None, org=None): +def get_pnpm_credentials(server_url, credential=None, api_host=None, org=None): """ - Get credentials for a Cloudsmith NPM registry. + Get credentials for a Cloudsmith pnpm registry. Verifies the URL is a Cloudsmith registry (including custom domains) and returns credentials if available. Args: - server_url: The NPM registry server URL + server_url: The pnpm registry server URL credential: Pre-resolved CredentialResult from the provider chain api_host: Cloudsmith API host URL org: Organisation slug whose custom domains to match against @@ -55,7 +55,7 @@ def _get_execute( server_url: str, credential=None, api_host=None, org=None ) -> tuple[int, str | None, str | None]: try: - cred = get_npm_credentials( + cred = get_pnpm_credentials( server_url, credential=credential, api_host=api_host, org=org ) if not cred: @@ -63,6 +63,6 @@ def _get_execute( return (0, cred, None) except Exception as exc: - logger.debug("npm credential-helper get failed: %s", exc, exc_info=True) + logger.debug("pnpm credential-helper get failed: %s", exc, exc_info=True) return 1, None, _REFUSAL_MESSAGE diff --git a/cloudsmith_cli/credential_helpers/npm/tests/__init__.py b/cloudsmith_cli/credential_helpers/pnpm/tests/__init__.py similarity index 100% rename from cloudsmith_cli/credential_helpers/npm/tests/__init__.py rename to cloudsmith_cli/credential_helpers/pnpm/tests/__init__.py diff --git a/cloudsmith_cli/credential_helpers/npm/tests/test_rc.py b/cloudsmith_cli/credential_helpers/pnpm/tests/test_rc.py similarity index 100% rename from cloudsmith_cli/credential_helpers/npm/tests/test_rc.py rename to cloudsmith_cli/credential_helpers/pnpm/tests/test_rc.py From 5bfa3b1e8b4001e3840714f768ceb4888657bd5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tighearn=C3=A1n=20Carroll?= <13870403+tigh-latte@users.noreply.github.com> Date: Thu, 20 Aug 2026 15:02:23 +0100 Subject: [PATCH 21/21] update changelog to reflect pnpm naming instead of npm --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d3adf75..4b8299bd 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 an NPM credential helper for Cloudsmith registries. `cloudsmith credential-helper install npm` installs an `npm-credential-cloudsmith` launcher binary and registers it in `~/.npmrc`, so npm authenticates to Cloudsmith registries automatically using your existing CLI credentials — no manual `npm login` required. Custom Cloudsmith registry domains are discovered via the API and cached locally; add extra hostnames with `--domain` (repeatable), disable discovery with `--no-discover`, or preview changes with `--dry-run`. Manage installed helpers with `cloudsmith credential-helper uninstall npm` and `cloudsmith credential-helper list`. +- Added an PNPM credential helper for Cloudsmith registries. `cloudsmith credential-helper install pnpm` installs an `pnpm-credential-cloudsmith` launcher binary and registers it in `~/.npmrc`, so npm authenticates to Cloudsmith registries automatically using your existing CLI credentials — no manual `npm login` required. Custom Cloudsmith registry domains are discovered via the API and cached locally; add extra hostnames with `--domain` (repeatable), disable discovery with `--no-discover`, or preview changes with `--dry-run`. Manage installed helpers with `cloudsmith credential-helper uninstall pnpm` and `cloudsmith credential-helper list`. ## [1.24.0] - 2026-08-18