scc-cli 1.4.1__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.
Potentially problematic release.
This version of scc-cli might be problematic. Click here for more details.
- scc_cli/__init__.py +15 -0
- scc_cli/audit/__init__.py +37 -0
- scc_cli/audit/parser.py +191 -0
- scc_cli/audit/reader.py +180 -0
- scc_cli/auth.py +145 -0
- scc_cli/claude_adapter.py +485 -0
- scc_cli/cli.py +259 -0
- scc_cli/cli_admin.py +706 -0
- scc_cli/cli_audit.py +245 -0
- scc_cli/cli_common.py +166 -0
- scc_cli/cli_config.py +527 -0
- scc_cli/cli_exceptions.py +705 -0
- scc_cli/cli_helpers.py +244 -0
- scc_cli/cli_init.py +272 -0
- scc_cli/cli_launch.py +1454 -0
- scc_cli/cli_org.py +1428 -0
- scc_cli/cli_support.py +322 -0
- scc_cli/cli_team.py +892 -0
- scc_cli/cli_worktree.py +865 -0
- scc_cli/config.py +583 -0
- scc_cli/console.py +562 -0
- scc_cli/constants.py +79 -0
- scc_cli/contexts.py +377 -0
- scc_cli/deprecation.py +54 -0
- scc_cli/deps.py +189 -0
- scc_cli/docker/__init__.py +127 -0
- scc_cli/docker/core.py +466 -0
- scc_cli/docker/credentials.py +726 -0
- scc_cli/docker/launch.py +604 -0
- scc_cli/doctor/__init__.py +99 -0
- scc_cli/doctor/checks.py +1074 -0
- scc_cli/doctor/render.py +346 -0
- scc_cli/doctor/types.py +66 -0
- scc_cli/errors.py +288 -0
- scc_cli/evaluation/__init__.py +27 -0
- scc_cli/evaluation/apply_exceptions.py +207 -0
- scc_cli/evaluation/evaluate.py +97 -0
- scc_cli/evaluation/models.py +80 -0
- scc_cli/exit_codes.py +55 -0
- scc_cli/git.py +1521 -0
- scc_cli/json_command.py +166 -0
- scc_cli/json_output.py +96 -0
- scc_cli/kinds.py +62 -0
- scc_cli/marketplace/__init__.py +123 -0
- scc_cli/marketplace/adapter.py +74 -0
- scc_cli/marketplace/compute.py +377 -0
- scc_cli/marketplace/constants.py +87 -0
- scc_cli/marketplace/managed.py +135 -0
- scc_cli/marketplace/materialize.py +723 -0
- scc_cli/marketplace/normalize.py +548 -0
- scc_cli/marketplace/render.py +257 -0
- scc_cli/marketplace/resolve.py +459 -0
- scc_cli/marketplace/schema.py +506 -0
- scc_cli/marketplace/sync.py +260 -0
- scc_cli/marketplace/team_cache.py +195 -0
- scc_cli/marketplace/team_fetch.py +688 -0
- scc_cli/marketplace/trust.py +244 -0
- scc_cli/models/__init__.py +41 -0
- scc_cli/models/exceptions.py +273 -0
- scc_cli/models/plugin_audit.py +434 -0
- scc_cli/org_templates.py +269 -0
- scc_cli/output_mode.py +167 -0
- scc_cli/panels.py +113 -0
- scc_cli/platform.py +350 -0
- scc_cli/profiles.py +960 -0
- scc_cli/remote.py +443 -0
- scc_cli/schemas/__init__.py +1 -0
- scc_cli/schemas/org-v1.schema.json +456 -0
- scc_cli/schemas/team-config.v1.schema.json +163 -0
- scc_cli/sessions.py +425 -0
- scc_cli/setup.py +588 -0
- scc_cli/source_resolver.py +470 -0
- scc_cli/stats.py +378 -0
- scc_cli/stores/__init__.py +13 -0
- scc_cli/stores/exception_store.py +251 -0
- scc_cli/subprocess_utils.py +88 -0
- scc_cli/teams.py +382 -0
- scc_cli/templates/__init__.py +2 -0
- scc_cli/templates/org/__init__.py +0 -0
- scc_cli/templates/org/minimal.json +19 -0
- scc_cli/templates/org/reference.json +74 -0
- scc_cli/templates/org/strict.json +38 -0
- scc_cli/templates/org/teams.json +42 -0
- scc_cli/templates/statusline.sh +75 -0
- scc_cli/theme.py +348 -0
- scc_cli/ui/__init__.py +124 -0
- scc_cli/ui/branding.py +68 -0
- scc_cli/ui/chrome.py +395 -0
- scc_cli/ui/dashboard/__init__.py +62 -0
- scc_cli/ui/dashboard/_dashboard.py +677 -0
- scc_cli/ui/dashboard/loaders.py +395 -0
- scc_cli/ui/dashboard/models.py +184 -0
- scc_cli/ui/dashboard/orchestrator.py +390 -0
- scc_cli/ui/formatters.py +443 -0
- scc_cli/ui/gate.py +350 -0
- scc_cli/ui/help.py +157 -0
- scc_cli/ui/keys.py +538 -0
- scc_cli/ui/list_screen.py +431 -0
- scc_cli/ui/picker.py +700 -0
- scc_cli/ui/prompts.py +200 -0
- scc_cli/ui/wizard.py +675 -0
- scc_cli/update.py +680 -0
- scc_cli/utils/__init__.py +39 -0
- scc_cli/utils/fixit.py +264 -0
- scc_cli/utils/fuzzy.py +124 -0
- scc_cli/utils/locks.py +101 -0
- scc_cli/utils/ttl.py +376 -0
- scc_cli/validate.py +455 -0
- scc_cli-1.4.1.dist-info/METADATA +369 -0
- scc_cli-1.4.1.dist-info/RECORD +113 -0
- scc_cli-1.4.1.dist-info/WHEEL +4 -0
- scc_cli-1.4.1.dist-info/entry_points.txt +2 -0
- scc_cli-1.4.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,548 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Plugin reference normalization and pattern matching utilities.
|
|
3
|
+
|
|
4
|
+
This module provides:
|
|
5
|
+
- normalize_plugin(): Convert plugin references to canonical form
|
|
6
|
+
- matches_pattern(): Glob pattern matching for plugin filtering
|
|
7
|
+
|
|
8
|
+
Plugin Reference Formats:
|
|
9
|
+
- `name@marketplace`: Standard format (canonical)
|
|
10
|
+
- `@marketplace/name`: npm-style format (normalized to standard)
|
|
11
|
+
- `name`: Auto-resolved based on org marketplaces
|
|
12
|
+
|
|
13
|
+
Auto-Resolution Rules:
|
|
14
|
+
- 0 org marketplaces → resolves to `claude-plugins-official`
|
|
15
|
+
- 1 org marketplace → auto-resolves to that marketplace
|
|
16
|
+
- 2+ marketplaces → explicit `@marketplace` required (raises AmbiguousMarketplaceError)
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import fnmatch
|
|
22
|
+
from typing import TYPE_CHECKING, Any
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
from scc_cli.marketplace.schema import MarketplaceSource
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
29
|
+
# Exceptions
|
|
30
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class InvalidPluginRefError(ValueError):
|
|
34
|
+
"""Raised when a plugin reference is malformed."""
|
|
35
|
+
|
|
36
|
+
def __init__(self, ref: str, reason: str) -> None:
|
|
37
|
+
self.ref = ref
|
|
38
|
+
self.reason = reason
|
|
39
|
+
super().__init__(f"Invalid plugin reference '{ref}': {reason}")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class AmbiguousMarketplaceError(ValueError):
|
|
43
|
+
"""Raised when plugin ref needs explicit marketplace qualifier."""
|
|
44
|
+
|
|
45
|
+
def __init__(self, plugin_name: str, available_marketplaces: list[str]) -> None:
|
|
46
|
+
self.plugin_name = plugin_name
|
|
47
|
+
self.available_marketplaces = available_marketplaces
|
|
48
|
+
marketplaces_str = ", ".join(sorted(available_marketplaces))
|
|
49
|
+
super().__init__(
|
|
50
|
+
f"Ambiguous plugin reference '{plugin_name}': "
|
|
51
|
+
f"specify marketplace explicitly (available: {marketplaces_str}). "
|
|
52
|
+
f"Use '{plugin_name}@<marketplace>' or '@<marketplace>/{plugin_name}'."
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
57
|
+
# Normalization
|
|
58
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def normalize_plugin(
|
|
62
|
+
ref: str,
|
|
63
|
+
org_marketplaces: dict[str, Any],
|
|
64
|
+
) -> str:
|
|
65
|
+
"""Normalize a plugin reference to canonical 'name@marketplace' format.
|
|
66
|
+
|
|
67
|
+
Supports three input formats:
|
|
68
|
+
- `name@marketplace`: Already canonical, returned as-is
|
|
69
|
+
- `@marketplace/name`: npm-style, converted to canonical
|
|
70
|
+
- `name`: Auto-resolved based on org marketplace count
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
ref: Plugin reference in any supported format
|
|
74
|
+
org_marketplaces: Dict of org-defined marketplaces (keys are marketplace names)
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
Canonical plugin reference in 'name@marketplace' format
|
|
78
|
+
|
|
79
|
+
Raises:
|
|
80
|
+
InvalidPluginRefError: If reference is malformed
|
|
81
|
+
AmbiguousMarketplaceError: If 2+ org marketplaces and no explicit qualifier
|
|
82
|
+
|
|
83
|
+
Examples:
|
|
84
|
+
>>> normalize_plugin("code-review@internal", {})
|
|
85
|
+
'code-review@internal'
|
|
86
|
+
|
|
87
|
+
>>> normalize_plugin("@internal/code-review", {})
|
|
88
|
+
'code-review@internal'
|
|
89
|
+
|
|
90
|
+
>>> normalize_plugin("tool", {"internal": {...}})
|
|
91
|
+
'tool@internal'
|
|
92
|
+
|
|
93
|
+
>>> normalize_plugin("tool", {}) # No org marketplaces
|
|
94
|
+
'tool@claude-plugins-official'
|
|
95
|
+
"""
|
|
96
|
+
# Strip whitespace
|
|
97
|
+
ref = ref.strip()
|
|
98
|
+
|
|
99
|
+
# Validate not empty
|
|
100
|
+
if not ref:
|
|
101
|
+
raise InvalidPluginRefError(ref, "plugin reference cannot be empty")
|
|
102
|
+
|
|
103
|
+
# Check for double @@ which is always invalid
|
|
104
|
+
if "@@" in ref:
|
|
105
|
+
raise InvalidPluginRefError(ref, "invalid double '@' in reference")
|
|
106
|
+
|
|
107
|
+
# Parse the reference format
|
|
108
|
+
if ref.startswith("@"):
|
|
109
|
+
# npm-style: @marketplace/name
|
|
110
|
+
return _parse_npm_style(ref)
|
|
111
|
+
elif "@" in ref:
|
|
112
|
+
# Standard format: name@marketplace
|
|
113
|
+
return _parse_standard_format(ref)
|
|
114
|
+
else:
|
|
115
|
+
# Bare name: auto-resolve marketplace
|
|
116
|
+
return _auto_resolve_marketplace(ref, org_marketplaces)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _parse_npm_style(ref: str) -> str:
|
|
120
|
+
"""Parse @marketplace/name format to canonical form.
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
ref: Plugin reference starting with @
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
Canonical 'name@marketplace' format
|
|
127
|
+
|
|
128
|
+
Raises:
|
|
129
|
+
InvalidPluginRefError: If format is invalid
|
|
130
|
+
"""
|
|
131
|
+
# Remove leading @
|
|
132
|
+
without_at = ref[1:]
|
|
133
|
+
|
|
134
|
+
# Split on first /
|
|
135
|
+
if "/" not in without_at:
|
|
136
|
+
raise InvalidPluginRefError(ref, "npm-style format requires '/' separator")
|
|
137
|
+
|
|
138
|
+
parts = without_at.split("/", 1)
|
|
139
|
+
marketplace = parts[0]
|
|
140
|
+
name = parts[1]
|
|
141
|
+
|
|
142
|
+
# Validate parts
|
|
143
|
+
if not marketplace:
|
|
144
|
+
raise InvalidPluginRefError(ref, "marketplace name cannot be empty")
|
|
145
|
+
if not name:
|
|
146
|
+
raise InvalidPluginRefError(ref, "plugin name cannot be empty")
|
|
147
|
+
|
|
148
|
+
return f"{name}@{marketplace}"
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _parse_standard_format(ref: str) -> str:
|
|
152
|
+
"""Parse name@marketplace format, validating structure.
|
|
153
|
+
|
|
154
|
+
Args:
|
|
155
|
+
ref: Plugin reference containing @
|
|
156
|
+
|
|
157
|
+
Returns:
|
|
158
|
+
Validated canonical format (same as input if valid)
|
|
159
|
+
|
|
160
|
+
Raises:
|
|
161
|
+
InvalidPluginRefError: If format is invalid
|
|
162
|
+
"""
|
|
163
|
+
# Split on first @ only
|
|
164
|
+
parts = ref.split("@", 1)
|
|
165
|
+
name = parts[0]
|
|
166
|
+
marketplace = parts[1]
|
|
167
|
+
|
|
168
|
+
# Validate parts
|
|
169
|
+
if not name:
|
|
170
|
+
raise InvalidPluginRefError(ref, "plugin name cannot be empty")
|
|
171
|
+
if not marketplace:
|
|
172
|
+
raise InvalidPluginRefError(ref, "marketplace name cannot be empty")
|
|
173
|
+
|
|
174
|
+
return f"{name}@{marketplace}"
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _auto_resolve_marketplace(
|
|
178
|
+
plugin_name: str,
|
|
179
|
+
org_marketplaces: dict[str, Any],
|
|
180
|
+
) -> str:
|
|
181
|
+
"""Auto-resolve marketplace for bare plugin name.
|
|
182
|
+
|
|
183
|
+
Resolution rules:
|
|
184
|
+
- 0 org marketplaces → claude-plugins-official
|
|
185
|
+
- 1 org marketplace → that marketplace
|
|
186
|
+
- 2+ marketplaces → raise AmbiguousMarketplaceError
|
|
187
|
+
|
|
188
|
+
Args:
|
|
189
|
+
plugin_name: Bare plugin name without marketplace
|
|
190
|
+
org_marketplaces: Dict of org-defined marketplaces
|
|
191
|
+
|
|
192
|
+
Returns:
|
|
193
|
+
Canonical plugin reference with resolved marketplace
|
|
194
|
+
|
|
195
|
+
Raises:
|
|
196
|
+
AmbiguousMarketplaceError: If 2+ org marketplaces defined
|
|
197
|
+
"""
|
|
198
|
+
# Validate plugin name
|
|
199
|
+
if not plugin_name:
|
|
200
|
+
raise InvalidPluginRefError(plugin_name, "plugin name cannot be empty")
|
|
201
|
+
|
|
202
|
+
# Count org marketplaces (implicit marketplaces don't count)
|
|
203
|
+
marketplace_count = len(org_marketplaces)
|
|
204
|
+
|
|
205
|
+
if marketplace_count == 0:
|
|
206
|
+
# No org marketplaces → use implicit official
|
|
207
|
+
return f"{plugin_name}@claude-plugins-official"
|
|
208
|
+
elif marketplace_count == 1:
|
|
209
|
+
# Exactly one org marketplace → auto-resolve to it
|
|
210
|
+
marketplace_name = next(iter(org_marketplaces.keys()))
|
|
211
|
+
return f"{plugin_name}@{marketplace_name}"
|
|
212
|
+
else:
|
|
213
|
+
# 2+ org marketplaces → ambiguous, require explicit
|
|
214
|
+
raise AmbiguousMarketplaceError(
|
|
215
|
+
plugin_name,
|
|
216
|
+
list(org_marketplaces.keys()),
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
221
|
+
# Pattern Matching
|
|
222
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def matches_pattern(plugin_ref: str, pattern: str) -> bool:
|
|
226
|
+
"""Check if a plugin reference matches a glob pattern.
|
|
227
|
+
|
|
228
|
+
Uses fnmatch for glob-style pattern matching:
|
|
229
|
+
- `*` matches any sequence of characters
|
|
230
|
+
- `?` matches any single character
|
|
231
|
+
- `[seq]` matches any character in seq
|
|
232
|
+
- `[!seq]` matches any character not in seq
|
|
233
|
+
|
|
234
|
+
Args:
|
|
235
|
+
plugin_ref: Canonical plugin reference (name@marketplace)
|
|
236
|
+
pattern: Glob pattern to match against
|
|
237
|
+
|
|
238
|
+
Returns:
|
|
239
|
+
True if plugin matches pattern, False otherwise
|
|
240
|
+
|
|
241
|
+
Examples:
|
|
242
|
+
>>> matches_pattern("code-review@internal", "*@internal")
|
|
243
|
+
True
|
|
244
|
+
|
|
245
|
+
>>> matches_pattern("tool-v1@org", "tool-v?@*")
|
|
246
|
+
True
|
|
247
|
+
|
|
248
|
+
>>> matches_pattern("other@external", "*@internal")
|
|
249
|
+
False
|
|
250
|
+
"""
|
|
251
|
+
# Handle empty cases
|
|
252
|
+
if not plugin_ref or not pattern:
|
|
253
|
+
return False
|
|
254
|
+
|
|
255
|
+
# Use fnmatch for glob-style matching with case-insensitive comparison
|
|
256
|
+
# Documentation requires Unicode-aware casefolding for security patterns
|
|
257
|
+
# to prevent bypass attempts using case variations (e.g., "MALICIOUS-*" vs "malicious-*")
|
|
258
|
+
return fnmatch.fnmatch(plugin_ref.casefold(), pattern.casefold())
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def matches_any_pattern(plugin_ref: str, patterns: list[str]) -> str | None:
|
|
262
|
+
"""Check if a plugin reference matches any pattern in a list.
|
|
263
|
+
|
|
264
|
+
Args:
|
|
265
|
+
plugin_ref: Canonical plugin reference (name@marketplace)
|
|
266
|
+
patterns: List of glob patterns to match against
|
|
267
|
+
|
|
268
|
+
Returns:
|
|
269
|
+
First matching pattern, or None if no match
|
|
270
|
+
|
|
271
|
+
Examples:
|
|
272
|
+
>>> matches_any_pattern("tool@internal", ["*@external", "*@internal"])
|
|
273
|
+
'*@internal'
|
|
274
|
+
|
|
275
|
+
>>> matches_any_pattern("tool@other", ["*@internal"])
|
|
276
|
+
None
|
|
277
|
+
"""
|
|
278
|
+
for pattern in patterns:
|
|
279
|
+
if matches_pattern(plugin_ref, pattern):
|
|
280
|
+
return pattern
|
|
281
|
+
return None
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
285
|
+
# URL Pattern Matching (Phase 2: Federated Team Configs)
|
|
286
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def normalize_url_for_matching(url: str) -> str:
|
|
290
|
+
"""Normalize a URL for pattern matching.
|
|
291
|
+
|
|
292
|
+
Normalization steps:
|
|
293
|
+
1. Remove protocol (https://, http://)
|
|
294
|
+
2. Convert git@host:path format to host/path
|
|
295
|
+
3. Lowercase the host portion (path case preserved)
|
|
296
|
+
4. Remove trailing .git suffix
|
|
297
|
+
|
|
298
|
+
Args:
|
|
299
|
+
url: URL in any format (HTTPS, HTTP, SSH git@)
|
|
300
|
+
|
|
301
|
+
Returns:
|
|
302
|
+
Normalized URL string for pattern matching
|
|
303
|
+
|
|
304
|
+
Examples:
|
|
305
|
+
>>> normalize_url_for_matching("https://github.com/sundsvall/plugins")
|
|
306
|
+
'github.com/sundsvall/plugins'
|
|
307
|
+
|
|
308
|
+
>>> normalize_url_for_matching("git@github.com:sundsvall/plugins.git")
|
|
309
|
+
'github.com/sundsvall/plugins'
|
|
310
|
+
"""
|
|
311
|
+
if not url:
|
|
312
|
+
return ""
|
|
313
|
+
|
|
314
|
+
normalized = url
|
|
315
|
+
|
|
316
|
+
# Remove protocol prefix
|
|
317
|
+
if normalized.startswith("https://"):
|
|
318
|
+
normalized = normalized[8:]
|
|
319
|
+
elif normalized.startswith("http://"):
|
|
320
|
+
normalized = normalized[7:]
|
|
321
|
+
|
|
322
|
+
# Convert git@host:path to host/path
|
|
323
|
+
if normalized.startswith("git@"):
|
|
324
|
+
normalized = normalized[4:]
|
|
325
|
+
# Replace first : with / (the colon separates host from path in SSH URLs)
|
|
326
|
+
if ":" in normalized:
|
|
327
|
+
normalized = normalized.replace(":", "/", 1)
|
|
328
|
+
|
|
329
|
+
# Remove trailing .git
|
|
330
|
+
if normalized.endswith(".git"):
|
|
331
|
+
normalized = normalized[:-4]
|
|
332
|
+
|
|
333
|
+
# Lowercase the host portion only (preserve path case)
|
|
334
|
+
if "/" in normalized:
|
|
335
|
+
parts = normalized.split("/", 1)
|
|
336
|
+
host = parts[0].lower()
|
|
337
|
+
path = parts[1]
|
|
338
|
+
normalized = f"{host}/{path}"
|
|
339
|
+
else:
|
|
340
|
+
normalized = normalized.lower()
|
|
341
|
+
|
|
342
|
+
return normalized
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def fnmatch_with_globstar(text: str, pattern: str) -> bool:
|
|
346
|
+
"""Extended fnmatch supporting globstar (**) for recursive matching.
|
|
347
|
+
|
|
348
|
+
Globstar rules:
|
|
349
|
+
- ** matches zero or more path segments (including /)
|
|
350
|
+
- * matches within a single segment (no /)
|
|
351
|
+
- Other fnmatch patterns work normally
|
|
352
|
+
|
|
353
|
+
Args:
|
|
354
|
+
text: Text to match against
|
|
355
|
+
pattern: Glob pattern with optional ** support
|
|
356
|
+
|
|
357
|
+
Returns:
|
|
358
|
+
True if text matches pattern
|
|
359
|
+
|
|
360
|
+
Examples:
|
|
361
|
+
>>> fnmatch_with_globstar("github.com/a/b/c/repo", "github.com/**/repo")
|
|
362
|
+
True
|
|
363
|
+
|
|
364
|
+
>>> fnmatch_with_globstar("github.com/org/repo", "github.com/*/repo")
|
|
365
|
+
True
|
|
366
|
+
"""
|
|
367
|
+
if not text or not pattern:
|
|
368
|
+
return False
|
|
369
|
+
|
|
370
|
+
import re
|
|
371
|
+
|
|
372
|
+
# Apply Unicode-aware casefolding for case-insensitive matching
|
|
373
|
+
# This prevents bypass attempts using case variations in URL patterns
|
|
374
|
+
text = text.casefold()
|
|
375
|
+
pattern = pattern.casefold()
|
|
376
|
+
|
|
377
|
+
# Convert glob pattern to regex
|
|
378
|
+
# Key insight: * must NOT cross / boundaries, ** CAN cross them
|
|
379
|
+
regex = ""
|
|
380
|
+
i = 0
|
|
381
|
+
|
|
382
|
+
while i < len(pattern):
|
|
383
|
+
# Check for ** (globstar)
|
|
384
|
+
if pattern[i : i + 2] == "**":
|
|
385
|
+
# Lookahead/behind to determine context
|
|
386
|
+
has_slash_before = i > 0 and pattern[i - 1] == "/"
|
|
387
|
+
has_slash_after = i + 2 < len(pattern) and pattern[i + 2] == "/"
|
|
388
|
+
|
|
389
|
+
if has_slash_before and has_slash_after:
|
|
390
|
+
# /**/ - matches zero or more segments
|
|
391
|
+
# The preceding / is already in regex, consume the following /
|
|
392
|
+
# Pattern: (?:[^/]+/)* matches x/ or x/y/ or empty
|
|
393
|
+
regex += "(?:[^/]+/)*"
|
|
394
|
+
i += 3 # Skip ** and the following /
|
|
395
|
+
elif has_slash_before:
|
|
396
|
+
# /** at end - matches zero or more segments after /
|
|
397
|
+
regex += "(?:[^/]+(?:/[^/]+)*)?$"
|
|
398
|
+
i += 2
|
|
399
|
+
elif has_slash_after:
|
|
400
|
+
# **/ at start - matches zero or more segments before /
|
|
401
|
+
regex += "(?:[^/]+/)*"
|
|
402
|
+
i += 3 # Skip ** and the following /
|
|
403
|
+
else:
|
|
404
|
+
# Standalone ** - matches anything
|
|
405
|
+
regex += ".*"
|
|
406
|
+
i += 2
|
|
407
|
+
|
|
408
|
+
elif pattern[i] == "*":
|
|
409
|
+
# Single * - matches within segment (no /)
|
|
410
|
+
regex += "[^/]*"
|
|
411
|
+
i += 1
|
|
412
|
+
|
|
413
|
+
elif pattern[i] == "?":
|
|
414
|
+
# ? - matches single char except /
|
|
415
|
+
regex += "[^/]"
|
|
416
|
+
i += 1
|
|
417
|
+
|
|
418
|
+
elif pattern[i] == "[":
|
|
419
|
+
# Character class - find matching ]
|
|
420
|
+
end = pattern.find("]", i + 1)
|
|
421
|
+
if end != -1:
|
|
422
|
+
regex += pattern[i : end + 1]
|
|
423
|
+
i = end + 1
|
|
424
|
+
else:
|
|
425
|
+
regex += re.escape(pattern[i])
|
|
426
|
+
i += 1
|
|
427
|
+
|
|
428
|
+
else:
|
|
429
|
+
# Literal character
|
|
430
|
+
regex += re.escape(pattern[i])
|
|
431
|
+
i += 1
|
|
432
|
+
|
|
433
|
+
try:
|
|
434
|
+
return bool(re.match(f"^{regex}$", text))
|
|
435
|
+
except re.error:
|
|
436
|
+
return False
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def matches_url_pattern(url: str, pattern: str) -> bool:
|
|
440
|
+
"""Check if a URL matches a pattern with globstar support.
|
|
441
|
+
|
|
442
|
+
Combines URL normalization with globstar matching:
|
|
443
|
+
1. Normalizes the URL (removes protocol, handles SSH format)
|
|
444
|
+
2. Uses fnmatch_with_globstar for pattern matching
|
|
445
|
+
|
|
446
|
+
Args:
|
|
447
|
+
url: URL to match (HTTPS, HTTP, or SSH format)
|
|
448
|
+
pattern: Glob pattern (host/path format, supports ** and *)
|
|
449
|
+
|
|
450
|
+
Returns:
|
|
451
|
+
True if normalized URL matches pattern
|
|
452
|
+
|
|
453
|
+
Examples:
|
|
454
|
+
>>> matches_url_pattern("https://github.com/sundsvall/plugins", "github.com/sundsvall/**")
|
|
455
|
+
True
|
|
456
|
+
|
|
457
|
+
>>> matches_url_pattern("git@github.com:org/repo.git", "github.com/org/**")
|
|
458
|
+
True
|
|
459
|
+
"""
|
|
460
|
+
if not url or not pattern:
|
|
461
|
+
return False
|
|
462
|
+
|
|
463
|
+
normalized = normalize_url_for_matching(url)
|
|
464
|
+
return fnmatch_with_globstar(normalized, pattern)
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def matches_any_url_pattern(url: str, patterns: list[str]) -> str | None:
|
|
468
|
+
"""Check if a URL matches any pattern in a list.
|
|
469
|
+
|
|
470
|
+
Args:
|
|
471
|
+
url: URL to match
|
|
472
|
+
patterns: List of glob patterns
|
|
473
|
+
|
|
474
|
+
Returns:
|
|
475
|
+
First matching pattern, or None if no match
|
|
476
|
+
|
|
477
|
+
Examples:
|
|
478
|
+
>>> matches_any_url_pattern("https://github.com/org/repo", ["github.com/**"])
|
|
479
|
+
'github.com/**'
|
|
480
|
+
|
|
481
|
+
>>> matches_any_url_pattern("https://other.com/repo", ["github.com/**"])
|
|
482
|
+
None
|
|
483
|
+
"""
|
|
484
|
+
for pattern in patterns:
|
|
485
|
+
if matches_url_pattern(url, pattern):
|
|
486
|
+
return pattern
|
|
487
|
+
return None
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
491
|
+
# Source URL Extraction (Phase 2: Federated Team Configs)
|
|
492
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def get_source_url(source: MarketplaceSource) -> str:
|
|
496
|
+
"""Extract URL from any MarketplaceSource type for pattern matching.
|
|
497
|
+
|
|
498
|
+
Converts the various source types to a normalized URL format suitable
|
|
499
|
+
for matching against trust grant patterns.
|
|
500
|
+
|
|
501
|
+
Args:
|
|
502
|
+
source: MarketplaceSource of any type (GitHub, Git, URL, Directory)
|
|
503
|
+
|
|
504
|
+
Returns:
|
|
505
|
+
URL string for pattern matching:
|
|
506
|
+
- GitHub: github.com/{owner}/{repo}
|
|
507
|
+
- Git: the url field directly
|
|
508
|
+
- URL: the url field directly
|
|
509
|
+
- Directory: the path field (for local sources)
|
|
510
|
+
|
|
511
|
+
Examples:
|
|
512
|
+
>>> from scc_cli.marketplace.schema import MarketplaceSourceGitHub
|
|
513
|
+
>>> source = MarketplaceSourceGitHub(source="github", owner="org", repo="plugins")
|
|
514
|
+
>>> get_source_url(source)
|
|
515
|
+
'https://github.com/org/plugins'
|
|
516
|
+
|
|
517
|
+
>>> from scc_cli.marketplace.schema import MarketplaceSourceGit
|
|
518
|
+
>>> source = MarketplaceSourceGit(source="git", url="https://gitlab.example.se/ai/plugins.git")
|
|
519
|
+
>>> get_source_url(source)
|
|
520
|
+
'https://gitlab.example.se/ai/plugins.git'
|
|
521
|
+
"""
|
|
522
|
+
# Import here to avoid circular imports
|
|
523
|
+
from scc_cli.marketplace.schema import (
|
|
524
|
+
MarketplaceSourceDirectory,
|
|
525
|
+
MarketplaceSourceGit,
|
|
526
|
+
MarketplaceSourceGitHub,
|
|
527
|
+
MarketplaceSourceURL,
|
|
528
|
+
)
|
|
529
|
+
|
|
530
|
+
if isinstance(source, MarketplaceSourceGitHub):
|
|
531
|
+
# Build GitHub URL from owner/repo
|
|
532
|
+
return f"https://github.com/{source.owner}/{source.repo}"
|
|
533
|
+
|
|
534
|
+
elif isinstance(source, MarketplaceSourceGit):
|
|
535
|
+
# Use the Git URL directly
|
|
536
|
+
return source.url
|
|
537
|
+
|
|
538
|
+
elif isinstance(source, MarketplaceSourceURL):
|
|
539
|
+
# Use the URL directly
|
|
540
|
+
return source.url
|
|
541
|
+
|
|
542
|
+
elif isinstance(source, MarketplaceSourceDirectory):
|
|
543
|
+
# Use the path for local directories
|
|
544
|
+
return source.path
|
|
545
|
+
|
|
546
|
+
else:
|
|
547
|
+
# Fallback for unknown types - should not happen
|
|
548
|
+
return ""
|