cloudsmith-cli 1.20.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. cloudsmith_cli/__init__.py +10 -0
  2. cloudsmith_cli/__main__.py +8 -0
  3. cloudsmith_cli/cli/__init__.py +1 -0
  4. cloudsmith_cli/cli/command.py +160 -0
  5. cloudsmith_cli/cli/commands/__init__.py +33 -0
  6. cloudsmith_cli/cli/commands/auth.py +173 -0
  7. cloudsmith_cli/cli/commands/check.py +129 -0
  8. cloudsmith_cli/cli/commands/copy.py +98 -0
  9. cloudsmith_cli/cli/commands/credential_helper/__init__.py +39 -0
  10. cloudsmith_cli/cli/commands/credential_helper/docker.py +66 -0
  11. cloudsmith_cli/cli/commands/credential_helper/manage.py +299 -0
  12. cloudsmith_cli/cli/commands/delete.py +67 -0
  13. cloudsmith_cli/cli/commands/dependencies.py +108 -0
  14. cloudsmith_cli/cli/commands/docs.py +16 -0
  15. cloudsmith_cli/cli/commands/download.py +620 -0
  16. cloudsmith_cli/cli/commands/entitlements.py +819 -0
  17. cloudsmith_cli/cli/commands/help_.py +12 -0
  18. cloudsmith_cli/cli/commands/list_.py +317 -0
  19. cloudsmith_cli/cli/commands/login.py +99 -0
  20. cloudsmith_cli/cli/commands/logout.py +151 -0
  21. cloudsmith_cli/cli/commands/main.py +73 -0
  22. cloudsmith_cli/cli/commands/mcp.py +523 -0
  23. cloudsmith_cli/cli/commands/metadata.py +503 -0
  24. cloudsmith_cli/cli/commands/metrics/__init__.py +2 -0
  25. cloudsmith_cli/cli/commands/metrics/command.py +20 -0
  26. cloudsmith_cli/cli/commands/metrics/entitlements.py +148 -0
  27. cloudsmith_cli/cli/commands/metrics/packages.py +134 -0
  28. cloudsmith_cli/cli/commands/move.py +111 -0
  29. cloudsmith_cli/cli/commands/policy/__init__.py +3 -0
  30. cloudsmith_cli/cli/commands/policy/command.py +20 -0
  31. cloudsmith_cli/cli/commands/policy/deny.py +248 -0
  32. cloudsmith_cli/cli/commands/policy/license.py +335 -0
  33. cloudsmith_cli/cli/commands/policy/vulnerability.py +322 -0
  34. cloudsmith_cli/cli/commands/push.py +1323 -0
  35. cloudsmith_cli/cli/commands/quarantine.py +148 -0
  36. cloudsmith_cli/cli/commands/quota/__init__.py +2 -0
  37. cloudsmith_cli/cli/commands/quota/command.py +20 -0
  38. cloudsmith_cli/cli/commands/quota/history.py +122 -0
  39. cloudsmith_cli/cli/commands/quota/quota.py +107 -0
  40. cloudsmith_cli/cli/commands/repos.py +330 -0
  41. cloudsmith_cli/cli/commands/resync.py +90 -0
  42. cloudsmith_cli/cli/commands/status.py +92 -0
  43. cloudsmith_cli/cli/commands/tags.py +375 -0
  44. cloudsmith_cli/cli/commands/tokens.py +318 -0
  45. cloudsmith_cli/cli/commands/upstream.py +479 -0
  46. cloudsmith_cli/cli/commands/vulnerabilities.py +141 -0
  47. cloudsmith_cli/cli/commands/whoami.py +188 -0
  48. cloudsmith_cli/cli/config.py +635 -0
  49. cloudsmith_cli/cli/decorators.py +624 -0
  50. cloudsmith_cli/cli/exceptions.py +215 -0
  51. cloudsmith_cli/cli/metadata_common.py +146 -0
  52. cloudsmith_cli/cli/saml.py +109 -0
  53. cloudsmith_cli/cli/table.py +59 -0
  54. cloudsmith_cli/cli/types.py +14 -0
  55. cloudsmith_cli/cli/utils.py +267 -0
  56. cloudsmith_cli/cli/validators.py +378 -0
  57. cloudsmith_cli/cli/webserver.py +263 -0
  58. cloudsmith_cli/core/__init__.py +1 -0
  59. cloudsmith_cli/core/api/__init__.py +1 -0
  60. cloudsmith_cli/core/api/distros.py +31 -0
  61. cloudsmith_cli/core/api/entitlements.py +130 -0
  62. cloudsmith_cli/core/api/exceptions.py +57 -0
  63. cloudsmith_cli/core/api/files.py +131 -0
  64. cloudsmith_cli/core/api/init.py +109 -0
  65. cloudsmith_cli/core/api/metadata.py +217 -0
  66. cloudsmith_cli/core/api/metrics.py +78 -0
  67. cloudsmith_cli/core/api/orgs.py +201 -0
  68. cloudsmith_cli/core/api/packages.py +309 -0
  69. cloudsmith_cli/core/api/quota.py +64 -0
  70. cloudsmith_cli/core/api/rates.py +28 -0
  71. cloudsmith_cli/core/api/repos.py +81 -0
  72. cloudsmith_cli/core/api/status.py +27 -0
  73. cloudsmith_cli/core/api/upstreams.py +72 -0
  74. cloudsmith_cli/core/api/user.py +109 -0
  75. cloudsmith_cli/core/api/version.py +15 -0
  76. cloudsmith_cli/core/api/vulnerabilities.py +230 -0
  77. cloudsmith_cli/core/cache_utils.py +160 -0
  78. cloudsmith_cli/core/config.py +140 -0
  79. cloudsmith_cli/core/credentials/__init__.py +0 -0
  80. cloudsmith_cli/core/credentials/chain.py +69 -0
  81. cloudsmith_cli/core/credentials/models.py +44 -0
  82. cloudsmith_cli/core/credentials/oidc/__init__.py +6 -0
  83. cloudsmith_cli/core/credentials/oidc/cache.py +220 -0
  84. cloudsmith_cli/core/credentials/oidc/detectors/__init__.py +122 -0
  85. cloudsmith_cli/core/credentials/oidc/detectors/aws.py +85 -0
  86. cloudsmith_cli/core/credentials/oidc/detectors/azure_devops.py +70 -0
  87. cloudsmith_cli/core/credentials/oidc/detectors/base.py +26 -0
  88. cloudsmith_cli/core/credentials/oidc/detectors/bitbucket_pipelines.py +35 -0
  89. cloudsmith_cli/core/credentials/oidc/detectors/circleci.py +40 -0
  90. cloudsmith_cli/core/credentials/oidc/detectors/generic.py +42 -0
  91. cloudsmith_cli/core/credentials/oidc/detectors/github_actions.py +64 -0
  92. cloudsmith_cli/core/credentials/oidc/detectors/gitlab_ci.py +49 -0
  93. cloudsmith_cli/core/credentials/oidc/exchange.py +87 -0
  94. cloudsmith_cli/core/credentials/provider.py +17 -0
  95. cloudsmith_cli/core/credentials/providers/__init__.py +15 -0
  96. cloudsmith_cli/core/credentials/providers/cli_flag.py +24 -0
  97. cloudsmith_cli/core/credentials/providers/credentials_file.py +24 -0
  98. cloudsmith_cli/core/credentials/providers/env_var.py +24 -0
  99. cloudsmith_cli/core/credentials/providers/keyring_provider.py +59 -0
  100. cloudsmith_cli/core/credentials/providers/oidc_provider.py +115 -0
  101. cloudsmith_cli/core/download.py +594 -0
  102. cloudsmith_cli/core/keyring.py +171 -0
  103. cloudsmith_cli/core/mcp/__init__.py +0 -0
  104. cloudsmith_cli/core/mcp/data.py +17 -0
  105. cloudsmith_cli/core/mcp/server.py +786 -0
  106. cloudsmith_cli/core/pagination.py +131 -0
  107. cloudsmith_cli/core/ratelimits.py +88 -0
  108. cloudsmith_cli/core/rest.py +255 -0
  109. cloudsmith_cli/core/utils.py +95 -0
  110. cloudsmith_cli/core/version.py +20 -0
  111. cloudsmith_cli/credential_helpers/__init__.py +7 -0
  112. cloudsmith_cli/credential_helpers/backends.py +41 -0
  113. cloudsmith_cli/credential_helpers/common.py +111 -0
  114. cloudsmith_cli/credential_helpers/custom_domains.py +281 -0
  115. cloudsmith_cli/credential_helpers/docker/__init__.py +4 -0
  116. cloudsmith_cli/credential_helpers/docker/installer.py +349 -0
  117. cloudsmith_cli/credential_helpers/docker/runtime.py +117 -0
  118. cloudsmith_cli/credential_helpers/launchers.py +175 -0
  119. cloudsmith_cli/data/VERSION +1 -0
  120. cloudsmith_cli/data/config.ini +23 -0
  121. cloudsmith_cli/data/credentials.ini +14 -0
  122. cloudsmith_cli/templates/__init__.py +3 -0
  123. cloudsmith_cli/templates/auth_error.html +45 -0
  124. cloudsmith_cli/templates/auth_success.html +37 -0
  125. cloudsmith_cli-1.20.0.dist-info/METADATA +610 -0
  126. cloudsmith_cli-1.20.0.dist-info/RECORD +130 -0
  127. cloudsmith_cli-1.20.0.dist-info/WHEEL +5 -0
  128. cloudsmith_cli-1.20.0.dist-info/entry_points.txt +2 -0
  129. cloudsmith_cli-1.20.0.dist-info/licenses/LICENSE +201 -0
  130. cloudsmith_cli-1.20.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,349 @@
1
+ # Copyright 2026 Cloudsmith Ltd
2
+ """Installer for the Docker credential helper.
3
+
4
+ Manages writing/removing the ``docker-credential-cloudsmith`` launcher and
5
+ patching ``~/.docker/config.json`` to enable the helper for Cloudsmith
6
+ registry hosts.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import logging
13
+ import os
14
+ import sys
15
+ from pathlib import Path
16
+
17
+ from ...core.cache_utils import merge_json_file
18
+ from ..backends import BackendKind
19
+ from ..custom_domains import get_format_domains
20
+ from ..launchers import is_on_path, remove_launcher, resolve_bin_dir, write_launcher
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ def _docker_config_path() -> Path:
26
+ """Return the path to the Docker client configuration file.
27
+
28
+ Respects the ``DOCKER_CONFIG`` environment variable; otherwise returns
29
+ the platform default ``~/.docker/config.json``.
30
+ """
31
+ docker_config_env = os.environ.get("DOCKER_CONFIG")
32
+ if docker_config_env:
33
+ return Path(docker_config_env) / "config.json"
34
+ return Path.home() / ".docker" / "config.json"
35
+
36
+
37
+ class DockerInstaller:
38
+ """Manages installation of the Docker credential helper for Cloudsmith.
39
+
40
+ This installer writes a ``docker-credential-cloudsmith`` launcher binary
41
+ and patches ``~/.docker/config.json`` to route the configured registry
42
+ hosts through the Cloudsmith credential helper.
43
+
44
+ Usage::
45
+
46
+ installer = DockerInstaller()
47
+ actions = installer.install(domains=["my-registry.example.com"])
48
+ for action in actions:
49
+ print(action)
50
+ """
51
+
52
+ LAUNCHER_NAME = "docker-credential-cloudsmith"
53
+ TARGET_CMD = "cloudsmith credential-helper docker"
54
+ HELPER_VALUE = "cloudsmith"
55
+ DEFAULT_HOST = "docker.cloudsmith.io"
56
+
57
+ name = "docker"
58
+ summary = "Docker credential helper for Cloudsmith registries"
59
+
60
+ @classmethod
61
+ def _resolve_target_cmd(cls) -> str:
62
+ """Return the command the launcher forwards to.
63
+
64
+ A pip/source install resolves the bare ``cloudsmith`` command via
65
+ ``PATH``. A frozen standalone binary (PyInstaller) is not guaranteed
66
+ to be on ``PATH`` under that name, so point the launcher at the
67
+ absolute executable instead — mirroring the frozen handling in
68
+ :func:`cloudsmith_cli.cli.commands.mcp._get_server_config`. The path
69
+ is quoted so a directory containing spaces still execs correctly.
70
+ """
71
+ if getattr(sys, "frozen", False):
72
+ return f'"{sys.executable}" credential-helper docker'
73
+ return cls.TARGET_CMD
74
+
75
+ def install(
76
+ self,
77
+ *,
78
+ bin_dir: str | None = None,
79
+ domains: tuple[str, ...] = (),
80
+ discover: bool = True,
81
+ refresh: bool = False,
82
+ org: str | None = None,
83
+ api_key: str | None = None,
84
+ auth_type: str = "api_key",
85
+ api_host: str | None = None,
86
+ dry_run: bool = False,
87
+ ) -> list[str]:
88
+ """Install the Docker credential helper.
89
+
90
+ Writes the launcher binary and registers Cloudsmith registry hosts in
91
+ ``~/.docker/config.json``.
92
+
93
+ Parameters
94
+ ----------
95
+ bin_dir:
96
+ Override for the directory to install the launcher. Defaults to
97
+ :func:`resolve_bin_dir` auto-detection.
98
+ domains:
99
+ Additional registry hostnames to configure (in addition to the
100
+ default ``docker.cloudsmith.io``).
101
+ discover:
102
+ When ``True`` (default), attempt to auto-discover Docker custom
103
+ domains via the Cloudsmith API. Discovery is best-effort and never
104
+ prevents the defaults from being registered.
105
+ refresh:
106
+ When ``True``, bypass the domain cache and fetch fresh data from
107
+ the API. Only meaningful when *discover* is also ``True``.
108
+ org:
109
+ Cloudsmith organisation slug used for custom-domain discovery.
110
+ api_key:
111
+ API key used for custom-domain discovery.
112
+ auth_type:
113
+ Credential type: ``"api_key"`` (default) or ``"bearer"``.
114
+ api_host:
115
+ Cloudsmith API host URL override.
116
+ dry_run:
117
+ When ``True``, compute and return planned actions without writing
118
+ any files.
119
+
120
+ Returns
121
+ -------
122
+ list[str]
123
+ Human-readable descriptions of actions taken (or planned, when
124
+ *dry_run* is ``True``).
125
+ """
126
+ target_dir = resolve_bin_dir(bin_dir)
127
+ config_path = _docker_config_path()
128
+
129
+ actions: list[str] = []
130
+
131
+ # Start with the default host plus any explicitly requested domains.
132
+ hosts: list[str] = [self.DEFAULT_HOST, *domains]
133
+
134
+ # --- Custom-domain auto-discovery (best-effort) ---
135
+ if discover:
136
+ if org and api_key:
137
+ # Discovery boundary: network/SDK errors must never abort the
138
+ # default install. ApiException is already handled inside
139
+ # get_format_domains; this broad catch is the deliberate outer
140
+ # boundary (consistent with "boundary catches, library stays clean").
141
+ # Note: BaseException subclasses (KeyboardInterrupt/SystemExit)
142
+ # intentionally propagate — they are not caught by `except Exception`.
143
+ try:
144
+ discovered = get_format_domains(
145
+ org,
146
+ BackendKind.DOCKER,
147
+ api_key=api_key,
148
+ auth_type=auth_type,
149
+ api_host=api_host,
150
+ refresh=refresh,
151
+ )
152
+ except Exception as exc: # pylint: disable=broad-except
153
+ # Discovery is best-effort: never let it abort the install of
154
+ # the defaults. (Network/SDK errors degrade to a warning;
155
+ # ApiException is already handled inside.)
156
+ actions.append(
157
+ f"WARNING: custom-domain auto-discovery failed: {exc}"
158
+ )
159
+ discovered = []
160
+ new_hosts = [h for h in discovered if h not in hosts]
161
+ hosts.extend(discovered)
162
+ actions.append(
163
+ f"discovered {len(new_hosts)} new Docker custom domain(s)"
164
+ )
165
+ else:
166
+ logger.debug(
167
+ "skipped auto-discovery"
168
+ " (no organization/credentials; pass --no-discover to silence)"
169
+ )
170
+
171
+ # De-duplicate while preserving order
172
+ seen: set[str] = set()
173
+ deduped: list[str] = []
174
+ for h in hosts:
175
+ if h not in seen:
176
+ seen.add(h)
177
+ deduped.append(h)
178
+ hosts = deduped
179
+
180
+ def mutate(config: dict) -> None:
181
+ helpers = config.get("credHelpers")
182
+ if not isinstance(helpers, dict):
183
+ helpers = config["credHelpers"] = {}
184
+ for host in hosts:
185
+ helpers[host] = self.HELPER_VALUE
186
+
187
+ if dry_run:
188
+ if os.name == "nt":
189
+ launcher_path = target_dir / f"{self.LAUNCHER_NAME}.cmd"
190
+ else:
191
+ launcher_path = target_dir / self.LAUNCHER_NAME
192
+ actions.append(f"would write launcher {launcher_path}")
193
+
194
+ would_change = merge_json_file(config_path, mutate, dry_run=True)
195
+ for host in hosts:
196
+ if would_change:
197
+ actions.append(
198
+ f"would set credHelpers[{host!r}]={self.HELPER_VALUE!r}"
199
+ f" in {config_path}"
200
+ )
201
+ else:
202
+ actions.append(
203
+ f"credHelpers[{host!r}] already set"
204
+ f" in {config_path} (no change)"
205
+ )
206
+ return actions
207
+
208
+ # Real install
209
+ launcher_path = write_launcher(
210
+ target_dir, self.LAUNCHER_NAME, self._resolve_target_cmd()
211
+ )
212
+ actions.append(f"wrote launcher {launcher_path}")
213
+
214
+ changed = merge_json_file(config_path, mutate)
215
+ if changed:
216
+ for host in hosts:
217
+ actions.append(
218
+ f"set credHelpers[{host!r}]={self.HELPER_VALUE!r}"
219
+ f" in {config_path}"
220
+ )
221
+ else:
222
+ actions.append(f"config.json already up to date ({config_path})")
223
+
224
+ if not is_on_path(target_dir):
225
+ actions.append(
226
+ f"WARNING: {target_dir} is not on PATH — "
227
+ "add it to your PATH so Docker can find docker-credential-cloudsmith"
228
+ )
229
+
230
+ return actions
231
+
232
+ def uninstall(
233
+ self, *, bin_dir: str | None = None, dry_run: bool = False
234
+ ) -> list[str]:
235
+ """Uninstall the Docker credential helper.
236
+
237
+ Removes the launcher binary and strips Cloudsmith-managed entries from
238
+ ``~/.docker/config.json``.
239
+
240
+ Parameters
241
+ ----------
242
+ bin_dir:
243
+ Override for the directory where the launcher was installed.
244
+ Defaults to :func:`resolve_bin_dir` auto-detection. Pass the same
245
+ value that was given to :meth:`install` so the correct launcher file
246
+ is found and removed.
247
+ dry_run:
248
+ When ``True``, return planned actions without writing any files.
249
+
250
+ Returns
251
+ -------
252
+ list[str]
253
+ Human-readable descriptions of actions taken (or planned).
254
+ """
255
+ target_dir = resolve_bin_dir(bin_dir)
256
+ config_path = _docker_config_path()
257
+
258
+ def mutate(config: dict) -> None:
259
+ helpers = config.get("credHelpers")
260
+ if not isinstance(helpers, dict):
261
+ return
262
+ removed = [k for k, v in helpers.items() if v == self.HELPER_VALUE]
263
+ for key in removed:
264
+ del helpers[key]
265
+ if removed and not helpers:
266
+ del config["credHelpers"]
267
+
268
+ actions: list[str] = []
269
+
270
+ if os.name == "nt":
271
+ launcher_path = target_dir / f"{self.LAUNCHER_NAME}.cmd"
272
+ else:
273
+ launcher_path = target_dir / self.LAUNCHER_NAME
274
+
275
+ if dry_run:
276
+ if launcher_path.exists():
277
+ actions.append(f"would remove launcher {launcher_path}")
278
+ else:
279
+ actions.append(
280
+ f"launcher not found at {launcher_path} (nothing to remove)"
281
+ )
282
+
283
+ would_change = merge_json_file(config_path, mutate, dry_run=True)
284
+ if would_change:
285
+ actions.append(
286
+ f"would remove credHelpers entries with value"
287
+ f" {self.HELPER_VALUE!r} from {config_path}"
288
+ )
289
+ else:
290
+ actions.append(f"no credHelpers entries to remove from {config_path}")
291
+ return actions
292
+
293
+ # Real uninstall
294
+ removed = remove_launcher(target_dir, self.LAUNCHER_NAME)
295
+ if removed:
296
+ actions.append(f"removed launcher {launcher_path}")
297
+ else:
298
+ actions.append(f"launcher not found at {launcher_path} (nothing to remove)")
299
+
300
+ changed = merge_json_file(config_path, mutate)
301
+ if changed:
302
+ actions.append(
303
+ f"removed credHelpers entries with value"
304
+ f" {self.HELPER_VALUE!r} from {config_path}"
305
+ )
306
+ else:
307
+ actions.append(f"no credHelpers entries to remove from {config_path}")
308
+
309
+ return actions
310
+
311
+ def status(self) -> dict:
312
+ """Return current installation status.
313
+
314
+ Returns
315
+ -------
316
+ dict
317
+ A dict with keys:
318
+
319
+ ``"launcher"``
320
+ The :class:`~pathlib.Path` of the launcher if it exists,
321
+ else ``None``.
322
+ ``"hosts"``
323
+ List of hostnames in ``config.json``'s ``credHelpers`` block
324
+ whose value equals ``"cloudsmith"``.
325
+ """
326
+ target_dir = resolve_bin_dir()
327
+ if os.name == "nt":
328
+ launcher_path: Path | None = target_dir / f"{self.LAUNCHER_NAME}.cmd"
329
+ else:
330
+ launcher_path = target_dir / self.LAUNCHER_NAME
331
+
332
+ if launcher_path is not None and not launcher_path.exists():
333
+ launcher_path = None
334
+
335
+ config_path = _docker_config_path()
336
+ hosts: list[str] = []
337
+ if config_path.exists():
338
+ try:
339
+ data = json.loads(config_path.read_text(encoding="utf-8"))
340
+ if isinstance(data, dict):
341
+ helpers = data.get("credHelpers", {})
342
+ hosts = [k for k, v in helpers.items() if v == self.HELPER_VALUE]
343
+ except (json.JSONDecodeError, OSError):
344
+ pass
345
+
346
+ return {
347
+ "launcher": str(launcher_path) if launcher_path is not None else None,
348
+ "hosts": hosts,
349
+ }
@@ -0,0 +1,117 @@
1
+ # Copyright 2026 Cloudsmith Ltd
2
+ """
3
+ Docker credential helper runtime.
4
+
5
+ Transport-light protocol logic for the Docker credential helper protocol.
6
+ This module is intentionally free of Click/sys imports so it can be unit-tested
7
+ without invoking the CLI machinery.
8
+
9
+ See: https://github.com/docker/docker-credential-helpers
10
+ """
11
+
12
+ import json
13
+ import logging
14
+
15
+ from ..backends import BackendKind
16
+ from ..common import is_cloudsmith_domain
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ _REFUSAL_MESSAGE = (
21
+ "Error: Unable to retrieve credentials. "
22
+ "Provide credentials via the CLOUDSMITH_API_KEY environment variable, "
23
+ "credentials.ini, the system keyring, or an OIDC service. "
24
+ "Verify current authentication with `cloudsmith whoami --verbose`."
25
+ )
26
+
27
+
28
+ def get_credentials(server_url, credential=None, api_host=None):
29
+ """
30
+ Get credentials for a Cloudsmith Docker registry.
31
+
32
+ Verifies the URL is a Cloudsmith registry (including custom domains)
33
+ and returns credentials if available.
34
+
35
+ Args:
36
+ server_url: The Docker registry server URL
37
+ credential: Pre-resolved CredentialResult from the provider chain
38
+ api_host: Cloudsmith API host URL
39
+
40
+ Returns:
41
+ dict: Credentials with 'Username' and 'Secret' keys, or None
42
+ """
43
+ if not credential or not credential.api_key:
44
+ return None
45
+
46
+ if not is_cloudsmith_domain(
47
+ server_url,
48
+ api_key=credential.api_key,
49
+ auth_type=getattr(credential, "auth_type", "api_key"),
50
+ api_host=api_host,
51
+ backend_kind=BackendKind.DOCKER,
52
+ ):
53
+ return None
54
+
55
+ return {"Username": "token", "Secret": credential.api_key}
56
+
57
+
58
+ def _execute_get(stdin, credential, api_host) -> tuple[int, str | None, str | None]:
59
+ """Handle the 'get' operation of the Docker credential helper protocol."""
60
+ try:
61
+ server_url = stdin.read().strip()
62
+ if not server_url:
63
+ return (1, None, "Error: No server URL provided on stdin")
64
+
65
+ creds = get_credentials(server_url, credential=credential, api_host=api_host)
66
+ if creds is None:
67
+ return (1, None, _REFUSAL_MESSAGE)
68
+
69
+ return (0, json.dumps(creds), None)
70
+ except Exception as exc: # pylint: disable=broad-except
71
+ # Protocol boundary: a credential helper must never crash `docker pull`/`push`.
72
+ # Covers: broken-pipe OSError from stdin.read(), network/SDK errors from
73
+ # get_credentials, and TypeError from json.dumps — all degrade to a clean
74
+ # refusal (exit 1), not a traceback.
75
+ # This is the ONLY intentional broad except in this feature.
76
+ # (Exception does not catch KeyboardInterrupt/SystemExit, which is correct.)
77
+ logger.debug("docker credential-helper get failed: %s", exc, exc_info=True)
78
+ return (1, None, _REFUSAL_MESSAGE)
79
+
80
+
81
+ def execute(
82
+ operation, stdin, credential=None, api_host=None
83
+ ) -> tuple[int, str | None, str | None]:
84
+ """
85
+ Execute a Docker credential helper protocol operation.
86
+
87
+ Args:
88
+ operation: One of 'get', 'store', 'erase', 'list'
89
+ stdin: A file-like object to read the server URL from (for 'get')
90
+ credential: Pre-resolved CredentialResult from the provider chain
91
+ api_host: Cloudsmith API host URL
92
+
93
+ Returns:
94
+ A (exit_code, stdout_text, stderr_text) tuple. Either text value may
95
+ be None if there is nothing to write to that stream.
96
+ """
97
+ if operation in ("store", "erase"):
98
+ # Drain stdin to keep Docker happy; guard against tty/pipe errors.
99
+ try:
100
+ if not stdin.isatty():
101
+ stdin.read()
102
+ except (OSError, ValueError, AttributeError):
103
+ pass
104
+ return (0, None, None)
105
+
106
+ if operation == "list":
107
+ return (0, "{}", None)
108
+
109
+ if operation == "get":
110
+ return _execute_get(stdin, credential, api_host)
111
+
112
+ return (
113
+ 1,
114
+ None,
115
+ f"Error: Unknown operation '{operation}'. "
116
+ "Valid operations: get, store, erase, list",
117
+ )
@@ -0,0 +1,175 @@
1
+ # Copyright 2026 Cloudsmith Ltd
2
+ """Launcher writer/remover for credential-helper on-PATH binaries.
3
+
4
+ Creates a thin shell script (Unix) or .cmd batch file (Windows) named
5
+ ``docker-credential-cloudsmith`` (or similar) that forwards every call to the
6
+ single ``cloudsmith`` binary already installed on the user's PATH.
7
+
8
+ The platform-specific bits (file name, script body, user bin directory) live in
9
+ small pure helpers parameterised on ``windows`` so they can be unit-tested
10
+ without monkeypatching ``os.name`` — faking ``os.name`` on a posix host makes
11
+ ``pathlib.Path`` raise ``NotImplementedError`` on Python < 3.12.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import os
17
+ import shutil
18
+ import sys
19
+ from pathlib import Path
20
+
21
+
22
+ def _is_windows() -> bool:
23
+ """Return True when running on Windows."""
24
+ return os.name == "nt"
25
+
26
+
27
+ def _launcher_filename(name: str, *, windows: bool) -> str:
28
+ """Return the launcher file name for the platform (``.cmd`` on Windows)."""
29
+ return f"{name}.cmd" if windows else name
30
+
31
+
32
+ def _launcher_content(target_cmd: str, *, windows: bool) -> str:
33
+ """Return the launcher script body for the platform.
34
+
35
+ Windows uses a ``.cmd`` batch file (``@echo off`` keeps stdout clean for
36
+ Docker's credential JSON); Unix uses a ``#!/bin/sh`` script that ``exec``s
37
+ the target so signals and the exit code pass straight through.
38
+ """
39
+ if windows:
40
+ return f"@echo off\r\n{target_cmd} %*\r\n"
41
+ return f'#!/bin/sh\nexec {target_cmd} "$@"\n'
42
+
43
+
44
+ def _user_bin_dir(windows: bool) -> Path:
45
+ """Return the user-local bin directory for the platform.
46
+
47
+ Unix → ``~/.local/bin``; Windows → ``%LOCALAPPDATA%\\Cloudsmith\\bin``
48
+ (falling back to the home directory when ``LOCALAPPDATA`` is unset).
49
+ """
50
+ if windows:
51
+ localappdata = os.environ.get("LOCALAPPDATA")
52
+ base = Path(localappdata) if localappdata else Path.home()
53
+ return base / "Cloudsmith" / "bin"
54
+ return Path.home() / ".local" / "bin"
55
+
56
+
57
+ def write_launcher(bin_dir: Path, name: str, target_cmd: str) -> Path:
58
+ """Write a launcher script for *name* in *bin_dir* that execs *target_cmd*.
59
+
60
+ Parameters
61
+ ----------
62
+ bin_dir:
63
+ Directory in which to create the launcher. Created if absent.
64
+ name:
65
+ Base name of the helper binary (e.g. ``docker-credential-cloudsmith``).
66
+ target_cmd:
67
+ The command the launcher forwards to (e.g.
68
+ ``cloudsmith credential-helper docker``).
69
+
70
+ Returns
71
+ -------
72
+ Path
73
+ The path of the written file.
74
+ """
75
+ windows = _is_windows()
76
+ bin_dir = Path(bin_dir)
77
+ bin_dir.mkdir(parents=True, exist_ok=True)
78
+
79
+ dest = bin_dir / _launcher_filename(name, windows=windows)
80
+ dest.write_text(
81
+ _launcher_content(target_cmd, windows=windows), encoding="utf-8", newline=""
82
+ )
83
+ if not windows:
84
+ dest.chmod(0o755)
85
+
86
+ return dest
87
+
88
+
89
+ def remove_launcher(bin_dir: Path, name: str) -> bool:
90
+ """Remove a launcher previously created by :func:`write_launcher`.
91
+
92
+ Parameters
93
+ ----------
94
+ bin_dir:
95
+ Directory that contains (or contained) the launcher.
96
+ name:
97
+ Base name of the helper binary (without extension).
98
+
99
+ Returns
100
+ -------
101
+ bool
102
+ ``True`` if a file was removed, ``False`` if no file was found.
103
+ """
104
+ target = Path(bin_dir) / _launcher_filename(name, windows=_is_windows())
105
+
106
+ if target.exists():
107
+ target.unlink()
108
+ return True
109
+ return False
110
+
111
+
112
+ def resolve_bin_dir(override: str | None = None) -> Path:
113
+ """Determine the best directory in which to place a launcher.
114
+
115
+ Resolution order
116
+ ----------------
117
+ 1. *override* → ``Path(override)``.
118
+ 2. The directory of the running ``cloudsmith`` executable — if that
119
+ directory is writable.
120
+ 3. The user-local bin directory (see :func:`_user_bin_dir`).
121
+
122
+ The chosen directory is **not** created here; that happens when the
123
+ launcher is written via :func:`write_launcher`.
124
+
125
+ Parameters
126
+ ----------
127
+ override:
128
+ Explicit path supplied by the caller (e.g. ``--bin-dir`` CLI option).
129
+
130
+ Returns
131
+ -------
132
+ Path
133
+ The resolved directory.
134
+ """
135
+ if override is not None:
136
+ return Path(override)
137
+
138
+ # Option 2: beside the running cloudsmith binary (if writable)
139
+ cloudsmith_path = shutil.which("cloudsmith")
140
+ if cloudsmith_path:
141
+ candidate = Path(os.path.dirname(os.path.realpath(cloudsmith_path)))
142
+ else:
143
+ candidate = Path(os.path.dirname(os.path.realpath(sys.argv[0])))
144
+
145
+ if os.access(candidate, os.W_OK | os.X_OK):
146
+ return candidate
147
+
148
+ # Option 3: user-local bin
149
+ return _user_bin_dir(_is_windows())
150
+
151
+
152
+ def is_on_path(directory: Path) -> bool:
153
+ """Return True if *directory* is an entry in the current ``$PATH``.
154
+
155
+ Comparison is case-insensitive on Windows (``os.path.normcase``) and
156
+ normalised via ``os.path.normpath`` on all platforms.
157
+
158
+ Parameters
159
+ ----------
160
+ directory:
161
+ The directory to check.
162
+
163
+ Returns
164
+ -------
165
+ bool
166
+ ``True`` if *directory* appears in ``$PATH``.
167
+ """
168
+ needle = os.path.normcase(os.path.normpath(str(directory)))
169
+ path_env = os.environ.get("PATH", "")
170
+ for entry in path_env.split(os.pathsep):
171
+ if not entry:
172
+ continue
173
+ if os.path.normcase(os.path.normpath(entry)) == needle:
174
+ return True
175
+ return False
@@ -0,0 +1 @@
1
+ 1.20.0
@@ -0,0 +1,23 @@
1
+ # Default configuration
2
+ [default]
3
+ # The API host to connect to (default: api.cloudsmith.io).
4
+ api_host=
5
+
6
+ # The API proxy to connect through (default: None).
7
+ api_proxy=
8
+
9
+ # Whether to verify SSL connection to the API (default: True)
10
+ api_ssl_verify=true
11
+
12
+ # The user agent to use for requests (default: calculated).
13
+ api_user_agent=
14
+
15
+
16
+ # Profile-based configuration
17
+ # You can set as many additional profiles as you need to provide
18
+ # for different configuration environments (e.g. prod vs staging).
19
+ # Add your overrides in the sections and then specify one of:
20
+ # * -P your-profile-name (as an argument)
21
+ # * --profile your-profile-name (an an argument)
22
+ # * CLOUDSMITH_PROFILE=your-profile-name (as an env variable)
23
+ [profile:your-profile-name]
@@ -0,0 +1,14 @@
1
+ # Default configuration
2
+ [default]
3
+ # The API key for authenticating with the API.
4
+ api_key=
5
+
6
+
7
+ # Profile-based configuration
8
+ # You can set as many additional profiles as you need to provide
9
+ # for different configuration environments (e.g. prod vs staging).
10
+ # Add your overrides in the sections and then specify one of:
11
+ # * -P your-profile-name (as an argument)
12
+ # * --profile your-profile-name (an an argument)
13
+ # * CLOUDSMITH_PROFILE=your-profile-name (as an env variable)
14
+ [profile:your-profile-name]
@@ -0,0 +1,3 @@
1
+ """
2
+ HTML templates for Cloudsmith CLI interface.
3
+ """