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,15 @@
1
+ """API version utilities."""
2
+
3
+ import importlib.metadata
4
+
5
+ import semver
6
+
7
+
8
+ def get_version():
9
+ """Get the raw/unparsed version of the API as a string."""
10
+ return importlib.metadata.version("cloudsmith_api")
11
+
12
+
13
+ def get_version_info():
14
+ """Get the API version as VersionInfo object."""
15
+ return semver.parse_version_info(get_version())
@@ -0,0 +1,230 @@
1
+ """API - Vulnerabilities endpoints."""
2
+
3
+ import click
4
+ import cloudsmith_api
5
+
6
+ from ...cli import utils
7
+ from .. import ratelimits
8
+ from .exceptions import catch_raise_api_exception
9
+ from .init import get_api_client
10
+
11
+
12
+ def get_vulnerabilities_api():
13
+ """Get the vulnerabilities API client."""
14
+ return get_api_client(cloudsmith_api.VulnerabilitiesApi)
15
+
16
+
17
+ def _print_vulnerabilities_summary_table(data, severity_filter, total_filtered_vulns):
18
+ """Print vulnerabilities as a table."""
19
+
20
+ severity_keys = {
21
+ "Critical": "critical",
22
+ "High": "high",
23
+ "Medium": "medium",
24
+ "Low": "low",
25
+ "Unknown": "unknown",
26
+ }
27
+
28
+ if severity_filter:
29
+ allowed = [s.strip().lower() for s in severity_filter.split(",")]
30
+ severity_keys = {k: v for k, v in severity_keys.items() if v in allowed}
31
+
32
+ headers = [{"header": "Package", "justify": "left", "style": "cyan"}]
33
+ for key in severity_keys.keys():
34
+ headers.append({"header": key, "justify": "center", "style": "white"})
35
+
36
+ # Get package name and version for the target label
37
+ pkg_data = getattr(data, "package", None)
38
+ pkg_name = getattr(pkg_data, "name", "Unknown")
39
+ pkg_version = getattr(pkg_data, "version", "Unknown")
40
+ target_label = f"{pkg_name}:{pkg_version}"
41
+
42
+ # Initialize aggregate counts
43
+ counts = {v: 0 for v in severity_keys.values()}
44
+
45
+ # Parse the scans and aggregate results
46
+ scans = getattr(data, "scans", [])
47
+ for scan in scans:
48
+ results = getattr(scan, "results", [])
49
+ for result in results:
50
+ severity = getattr(result, "severity", "unknown").lower()
51
+ if severity in counts:
52
+ counts[severity] += 1
53
+ elif "unknown" in counts:
54
+ counts["unknown"] += 1
55
+
56
+ # Create the single summary row
57
+ row = [target_label]
58
+ for _header, key in severity_keys.items():
59
+ row.append(str(counts[key]))
60
+
61
+ rows = [row]
62
+
63
+ click.echo()
64
+ click.echo()
65
+
66
+ utils.rich_print_table(headers=headers, rows=rows, title="Vulnerabilities Summary")
67
+
68
+ if severity_filter:
69
+ filters = severity_filter.upper()
70
+ click.echo(
71
+ f"\nTotal Vulnerabilities: {getattr(data, 'num_vulnerabilities', 0)}"
72
+ )
73
+ click.echo(f"\nTotal {filters} Vulnerabilities: {total_filtered_vulns}")
74
+ else:
75
+ click.echo(
76
+ f"\nTotal Vulnerabilities: {getattr(data, 'num_vulnerabilities', 0)}"
77
+ )
78
+ click.echo()
79
+
80
+
81
+ def _print_vulnerabilities_assessment_table(data, severity_filter=None):
82
+ """Print vulnerabilities assessment as a table."""
83
+
84
+ # Group vulnerabilities by package
85
+ grouped_vulns = {}
86
+
87
+ allowed_severities = None
88
+ if severity_filter:
89
+ allowed_severities = [s.strip().lower() for s in severity_filter.split(",")]
90
+
91
+ # Get top level package info as fallback
92
+ pkg_data = getattr(data, "package", None)
93
+ top_pkg_name = getattr(pkg_data, "name", "Unknown")
94
+
95
+ # Get scan data
96
+ scans = getattr(data, "scans", [])
97
+ for scan in scans:
98
+ results = getattr(scan, "results", [])
99
+ for result in results:
100
+ # Filter by severity if requested
101
+ if allowed_severities:
102
+ severity = getattr(result, "severity", "unknown").lower()
103
+ if severity not in allowed_severities:
104
+ continue
105
+
106
+ pkg_name = getattr(result, "package_name", top_pkg_name)
107
+ if pkg_name not in grouped_vulns:
108
+ grouped_vulns[pkg_name] = []
109
+ grouped_vulns[pkg_name].append(result)
110
+
111
+ if not grouped_vulns:
112
+ click.echo("\nNo vulnerabilities found matching criteria.")
113
+ return
114
+
115
+ # Severity mapping for sorting
116
+ sev_map = {"critical": 0, "high": 1, "medium": 2, "low": 3, "unknown": 4}
117
+
118
+ # Iterate through sorted packages
119
+ for pkg_name in sorted(grouped_vulns.keys()):
120
+ vulns = grouped_vulns[pkg_name]
121
+
122
+ # Sort vulns by severity (Critical first)
123
+ vulns.sort(
124
+ key=lambda r: sev_map.get(getattr(r, "severity", "unknown").lower(), 99)
125
+ )
126
+
127
+ rows = []
128
+ for result in vulns:
129
+ # Severity
130
+ severity = getattr(result, "severity", "Unknown").title()
131
+ severity_style = "white"
132
+ s = severity.lower()
133
+ if s == "critical":
134
+ severity_style = "red bold"
135
+ elif s == "high":
136
+ severity_style = "red"
137
+ elif s == "medium":
138
+ severity_style = "yellow"
139
+ elif s == "low":
140
+ severity_style = "blue"
141
+
142
+ # ID
143
+ vuln_id = getattr(
144
+ result, "vulnerability_id", getattr(result, "identifier", "Unknown")
145
+ )
146
+
147
+ # Affected Version
148
+ affected_raw = getattr(
149
+ result, "affected_version", getattr(result, "affected_version", None)
150
+ )
151
+ if hasattr(affected_raw, "version"):
152
+ aff_version = affected_raw.version
153
+ affected_operator = affected_raw.operator
154
+ affected_version = f"{affected_operator} {aff_version}"
155
+ else:
156
+ affected_version = str(affected_raw) if affected_raw else "-"
157
+
158
+ # Fixed Version
159
+ fixed_raw = getattr(
160
+ result, "fix_version", getattr(result, "fixed_version", None)
161
+ )
162
+ if hasattr(fixed_raw, "version"):
163
+ fix_version = fixed_raw.version
164
+ fixed_operator = fixed_raw.operator
165
+ fixed_version = f"{fixed_operator} {fix_version}"
166
+ else:
167
+ fixed_version = str(fixed_raw) if fixed_raw else "-"
168
+
169
+ # Title / Description
170
+ title = getattr(result, "title", "")
171
+
172
+ rows.append(
173
+ [
174
+ f"[{severity_style}]{severity}[/{severity_style}]",
175
+ vuln_id,
176
+ affected_version,
177
+ fixed_version,
178
+ title,
179
+ ]
180
+ )
181
+
182
+ click.echo()
183
+ utils.rich_print_table(
184
+ headers=[
185
+ "Severity",
186
+ "Vulnerability",
187
+ "Affected Version",
188
+ "Fixed Version",
189
+ "Title",
190
+ ],
191
+ rows=rows,
192
+ title=f"Package: {pkg_name}",
193
+ show_lines=True,
194
+ )
195
+ click.echo()
196
+
197
+
198
+ def get_package_scan_identifier(owner, repo, package):
199
+ """Get the scan identifier using the package identifier"""
200
+ client = get_vulnerabilities_api()
201
+
202
+ with catch_raise_api_exception():
203
+ data, _, headers = client.vulnerabilities_package_list_with_http_info(
204
+ owner=owner, repo=repo, package=package
205
+ )
206
+
207
+ ratelimits.maybe_rate_limit(client, headers)
208
+
209
+ return data[0].identifier
210
+
211
+
212
+ def get_package_scan_result(
213
+ opts, owner, repo, package, show_assessment, fixable, severity_filter
214
+ ):
215
+ """Get the package vulnerability scan result."""
216
+ client = get_vulnerabilities_api()
217
+
218
+ with catch_raise_api_exception():
219
+ scan_identifier = get_package_scan_identifier(
220
+ owner=owner, repo=repo, package=package
221
+ )
222
+
223
+ with catch_raise_api_exception():
224
+ data, _, headers = client.vulnerabilities_read_with_http_info(
225
+ owner=owner, repo=repo, package=package, identifier=scan_identifier
226
+ )
227
+
228
+ ratelimits.maybe_rate_limit(client, headers)
229
+
230
+ return data
@@ -0,0 +1,160 @@
1
+ # Copyright 2026 Cloudsmith Ltd
2
+ """Shared utilities for on-disk credential and cache storage."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ import os
8
+ import tempfile
9
+ from collections.abc import Callable
10
+ from typing import Any
11
+
12
+
13
+ def _atomic_write_text(dest: str, text: str, *, mode: int = 0o600) -> None:
14
+ """Atomically write *text* to *dest* using a sibling temp file.
15
+
16
+ Caller is responsible for ensuring the parent directory exists.
17
+ """
18
+ parent = os.path.dirname(dest) or "."
19
+ tmp_fd, tmp_path = tempfile.mkstemp(dir=parent, prefix=".tmp_", suffix=".json")
20
+ try:
21
+ with os.fdopen(tmp_fd, "w", encoding="utf-8") as f:
22
+ f.write(text)
23
+ f.flush()
24
+ os.fsync(f.fileno())
25
+ os.chmod(tmp_path, mode)
26
+ os.replace(tmp_path, dest)
27
+ except (OSError, TypeError, ValueError):
28
+ try:
29
+ os.unlink(tmp_path)
30
+ except OSError:
31
+ pass
32
+ raise
33
+
34
+
35
+ def atomic_write_json(path: str | os.PathLike, data: Any, *, mode: int = 0o600) -> None:
36
+ """Atomically write JSON to a file with restrictive permissions.
37
+
38
+ Writes to a sibling temp file, fsyncs, sets mode, then renames over the
39
+ destination. Concurrent readers never see a partial file. Temp file is
40
+ removed on error. Caller is responsible for ensuring the parent directory
41
+ exists.
42
+ """
43
+ dest = os.fspath(path)
44
+ _atomic_write_text(dest, json.dumps(data), mode=mode)
45
+
46
+
47
+ def merge_json_file(
48
+ path: str | os.PathLike,
49
+ mutate: Callable[[dict], None],
50
+ *,
51
+ backup: bool = True,
52
+ dry_run: bool = False,
53
+ mode: int = 0o600,
54
+ ) -> bool:
55
+ """Read a JSON object file, apply *mutate* in place, and atomically write it back.
56
+
57
+ Parameters
58
+ ----------
59
+ path:
60
+ Path to the JSON file (e.g. ``~/.docker/config.json``).
61
+ mutate:
62
+ Callable that receives the loaded ``dict`` and modifies it in place.
63
+ It must not return a value; changes are applied to the dict directly.
64
+ backup:
65
+ When ``True`` (default) and the file already existed and content will
66
+ change, copy the prior file to ``{path}.bak`` before writing.
67
+ dry_run:
68
+ When ``True``, perform the read + mutate + change-detection but make
69
+ **no** writes (no temp file, no ``.bak``, no replace).
70
+ mode:
71
+ File-permission bits for the written file (default ``0o600``).
72
+
73
+ Returns
74
+ -------
75
+ bool
76
+ ``True`` if the file content changed (or would change under
77
+ ``dry_run``), ``False`` otherwise.
78
+
79
+ Notes
80
+ -----
81
+ * If the file is missing, empty, or does not parse as a JSON object
82
+ (``dict``), the starting value is ``{}``.
83
+ * Key order is preserved — ``sort_keys`` is **not** used.
84
+ * The on-disk form is ``json.dumps(data, indent=2, ensure_ascii=False) + "\\n"``.
85
+ * Parent directory is created (mode ``0o700``) if absent.
86
+
87
+ Concurrency
88
+ -----------
89
+ This is a single-writer, install-time helper (used by
90
+ ``credential-helper install/uninstall``). The read-modify-write is
91
+ last-writer-wins and is **NOT** safe against concurrent writers mutating
92
+ the same file; do not use it on a hot path. The atomic replace guarantees
93
+ the file is never left partially written, but concurrent merges can drop
94
+ each other's changes.
95
+ """
96
+ dest = os.fspath(path)
97
+
98
+ # ------------------------------------------------------------------
99
+ # 1. Read existing content
100
+ # ------------------------------------------------------------------
101
+ existing_text: str | None = None
102
+ try:
103
+ with open(dest, encoding="utf-8") as f:
104
+ existing_text = f.read()
105
+ except FileNotFoundError:
106
+ existing_text = None
107
+
108
+ # ------------------------------------------------------------------
109
+ # 2. Parse → dict (treat missing/empty/non-dict/malformed as {})
110
+ # ------------------------------------------------------------------
111
+ data: dict = {}
112
+ if existing_text:
113
+ try:
114
+ parsed = json.loads(existing_text)
115
+ if isinstance(parsed, dict):
116
+ data = parsed
117
+ except (json.JSONDecodeError, ValueError):
118
+ pass
119
+
120
+ # ------------------------------------------------------------------
121
+ # 3. Mutate in place
122
+ # ------------------------------------------------------------------
123
+ mutate(data)
124
+
125
+ # ------------------------------------------------------------------
126
+ # 4. Stable serialisation + change detection
127
+ # ------------------------------------------------------------------
128
+ new_text = json.dumps(data, indent=2, ensure_ascii=False) + "\n"
129
+
130
+ if existing_text is not None:
131
+ # Normalise existing content for comparison: if the file already has
132
+ # the exact canonical form we produce, treat as no-change.
133
+ no_change = new_text == existing_text
134
+ else:
135
+ no_change = False # file didn't exist → always a change
136
+
137
+ if no_change:
138
+ return False
139
+
140
+ if dry_run:
141
+ return True
142
+
143
+ # ------------------------------------------------------------------
144
+ # 5. Ensure parent directory exists
145
+ # ------------------------------------------------------------------
146
+ parent = os.path.dirname(dest) or "."
147
+ os.makedirs(parent, mode=0o700, exist_ok=True)
148
+
149
+ # ------------------------------------------------------------------
150
+ # 6. Backup (only when file existed and content changes)
151
+ # ------------------------------------------------------------------
152
+ if backup and existing_text is not None:
153
+ _atomic_write_text(dest + ".bak", existing_text, mode=0o600)
154
+
155
+ # ------------------------------------------------------------------
156
+ # 7. Atomic write
157
+ # ------------------------------------------------------------------
158
+ _atomic_write_text(dest, new_text, mode=mode)
159
+
160
+ return True
@@ -0,0 +1,140 @@
1
+ import collections
2
+ import stat
3
+
4
+ import click
5
+
6
+ from .utils import get_help_website
7
+
8
+ ConfigValues = collections.namedtuple(
9
+ "ConfigValues", ["reader", "present", "mode", "data"]
10
+ )
11
+
12
+
13
+ def create_config_files(ctx, opts, api_key, force=False):
14
+ """Create default config files."""
15
+ # pylint: disable=unused-argument
16
+ config_reader = opts.get_config_reader()
17
+ creds_reader = opts.get_creds_reader()
18
+ has_config = config_reader.has_default_file()
19
+ has_creds = creds_reader.has_default_file()
20
+
21
+ if has_config and has_creds:
22
+ create = False
23
+ else:
24
+ click.echo()
25
+ if not force:
26
+ create = click.confirm(
27
+ "No default config file(s) found, do you want to create them?"
28
+ )
29
+ else:
30
+ create = "y"
31
+
32
+ click.echo()
33
+ if not create:
34
+ click.secho(
35
+ "For reference here are your default config file locations:", fg="yellow"
36
+ )
37
+ else:
38
+ click.secho(
39
+ "Great! Let me just create your default configs for you now ...", fg="green"
40
+ )
41
+
42
+ configs = (
43
+ ConfigValues(reader=config_reader, present=has_config, mode=None, data={}),
44
+ ConfigValues(
45
+ reader=creds_reader,
46
+ present=has_creds,
47
+ mode=stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP | stat.S_IWGRP,
48
+ data={"api_key": api_key},
49
+ ),
50
+ )
51
+
52
+ has_errors = False
53
+ for config in configs:
54
+ click.echo(
55
+ "%(name)s config file: %(filepath)s ... "
56
+ % {
57
+ "name": click.style(config.reader.config_name.capitalize(), bold=True),
58
+ "filepath": click.style(
59
+ config.reader.get_default_filepath(), fg="magenta"
60
+ ),
61
+ },
62
+ nl=False,
63
+ )
64
+
65
+ if not config.present and create:
66
+ try:
67
+ ok = config.reader.create_default_file(
68
+ data=config.data, mode=config.mode
69
+ )
70
+ except OSError as exc:
71
+ ok = False
72
+ error_message = exc.strerror
73
+ has_errors = True
74
+
75
+ if ok:
76
+ click.secho("CREATED", fg="green")
77
+ else:
78
+ click.secho("ERROR", fg="red")
79
+ click.secho(
80
+ "The following error occurred while trying to "
81
+ "create the file: %(message)s"
82
+ % {"message": click.style(error_message, fg="red")}
83
+ )
84
+ continue
85
+
86
+ # Update existing credentials file with new API key if provided
87
+ if (
88
+ config.present
89
+ and config.data.get("api_key")
90
+ and hasattr(config.reader, "update_api_key")
91
+ ):
92
+ try:
93
+ config.reader.update_api_key(
94
+ config.reader.get_default_filepath(),
95
+ config.data["api_key"],
96
+ )
97
+ click.secho("UPDATED", fg="green")
98
+ except OSError as exc:
99
+ has_errors = True
100
+ click.secho("ERROR", fg="red")
101
+ click.secho(
102
+ "The following error occurred while trying to "
103
+ "update the file: %(message)s"
104
+ % {"message": click.style(exc.strerror, fg="red")}
105
+ )
106
+ continue
107
+
108
+ click.secho("EXISTS" if config.present else "NOT CREATED", fg="yellow")
109
+
110
+ return create, has_errors
111
+
112
+
113
+ def new_config_messaging(has_errors, opts, create, api_key):
114
+ """Provide messaging to user after generating new configs"""
115
+ if has_errors:
116
+ click.echo()
117
+ click.secho("Oops, please fix the errors and try again!", fg="red")
118
+ return
119
+
120
+ if opts.api_key != api_key:
121
+ click.echo()
122
+ if opts.api_key:
123
+ click.secho(
124
+ "Note: The above API key doesn't match what you have in "
125
+ "your default credentials config file.",
126
+ fg="yellow",
127
+ )
128
+ elif not create:
129
+ click.secho(
130
+ "Note: Don't forget to put your API key in a config file, "
131
+ "export it on the environment, or set it via -k.",
132
+ fg="yellow",
133
+ )
134
+ click.secho(
135
+ "If you need more help please see the documentation: "
136
+ "%(website)s" % {"website": click.style(get_help_website(), bold=True)}
137
+ )
138
+ click.echo()
139
+
140
+ click.secho("You're ready to rock, let's start automating!", fg="green")
File without changes
@@ -0,0 +1,69 @@
1
+ """Credential provider chain for the Cloudsmith CLI.
2
+
3
+ Implements an AWS SDK-style credential resolution chain that evaluates
4
+ credential sources sequentially and returns the first valid result.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+
11
+ from .models import CredentialContext, CredentialResult
12
+ from .provider import CredentialProvider
13
+ from .providers import (
14
+ CLIFlagProvider,
15
+ CredentialsFileProvider,
16
+ EnvVarProvider,
17
+ KeyringProvider,
18
+ OidcProvider,
19
+ )
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ class CredentialProviderChain:
25
+ """Evaluates credential providers in order, returning the first valid result.
26
+
27
+ If no providers are given, uses the default chain:
28
+ CLIFlag → EnvVar → CredentialsFile → Keyring → OIDC.
29
+ """
30
+
31
+ def __init__(self, providers: list[CredentialProvider] | None = None):
32
+ if providers is not None:
33
+ self.providers = providers
34
+ else:
35
+ self.providers = [
36
+ CLIFlagProvider(),
37
+ EnvVarProvider(),
38
+ CredentialsFileProvider(),
39
+ KeyringProvider(),
40
+ OidcProvider(),
41
+ ]
42
+
43
+ def resolve(self, context: CredentialContext) -> CredentialResult | None:
44
+ """Evaluate each provider in order. Return the first successful result."""
45
+ for provider in self.providers:
46
+ try:
47
+ result = provider.resolve(context)
48
+ if result is not None:
49
+ if context.debug:
50
+ logger.debug(
51
+ "Credentials resolved by %s: %s",
52
+ provider.name,
53
+ result.source_detail or result.source_name,
54
+ )
55
+ return result
56
+ if context.debug:
57
+ logger.debug(
58
+ "Provider %s did not resolve credentials, trying next",
59
+ provider.name,
60
+ )
61
+ except Exception: # pylint: disable=broad-exception-caught
62
+ # Intentionally broad - one provider failing shouldn't stop others
63
+ logger.debug(
64
+ "Provider %s raised an exception, skipping",
65
+ provider.name,
66
+ exc_info=True,
67
+ )
68
+ continue
69
+ return None
@@ -0,0 +1,44 @@
1
+ """Credential data models for the Cloudsmith CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Literal
7
+
8
+ import requests
9
+
10
+
11
+ @dataclass
12
+ class CredentialContext:
13
+ """Context passed to credential providers during resolution.
14
+
15
+ Separate per-source fields allow the chain to evaluate sources in priority
16
+ order without conflating them. Populated from Click options in
17
+ ``resolve_credentials``.
18
+ """
19
+
20
+ session: requests.Session | None = None
21
+ api_key_from_flag: str | None = None
22
+ api_key_from_env: str | None = None
23
+ api_key_from_file: str | None = None
24
+ api_host: str = "https://api.cloudsmith.io"
25
+ creds_file_path: str | None = None
26
+ profile: str | None = None
27
+ debug: bool = False
28
+ keyring_refresh_failed: bool = False
29
+ oidc_audience: str | None = None
30
+ oidc_org: str | None = None
31
+ oidc_service_slug: str | None = None
32
+ oidc_discovery_disabled: bool = False
33
+ oidc_detector_order: str | None = None
34
+ oidc_disabled_detectors: frozenset[str] = frozenset()
35
+
36
+
37
+ @dataclass
38
+ class CredentialResult:
39
+ """Result from a successful credential resolution."""
40
+
41
+ api_key: str
42
+ source_name: str
43
+ source_detail: str | None = None
44
+ auth_type: Literal["api_key", "bearer"] = "api_key"
@@ -0,0 +1,6 @@
1
+ """OIDC support for the Cloudsmith CLI credential chain.
2
+
3
+ References:
4
+ https://help.cloudsmith.io/docs/openid-connect
5
+ https://cloudsmith.com/blog/securely-connect-cloudsmith-to-your-cicd-using-oidc-authentication
6
+ """