scc-cli 1.5.3__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 +311 -0
- scc_cli/cli_common.py +190 -0
- scc_cli/cli_helpers.py +244 -0
- scc_cli/commands/__init__.py +20 -0
- scc_cli/commands/admin.py +708 -0
- scc_cli/commands/audit.py +246 -0
- scc_cli/commands/config.py +528 -0
- scc_cli/commands/exceptions.py +696 -0
- scc_cli/commands/init.py +272 -0
- scc_cli/commands/launch/__init__.py +73 -0
- scc_cli/commands/launch/app.py +1247 -0
- scc_cli/commands/launch/render.py +309 -0
- scc_cli/commands/launch/sandbox.py +135 -0
- scc_cli/commands/launch/workspace.py +339 -0
- scc_cli/commands/org/__init__.py +49 -0
- scc_cli/commands/org/_builders.py +264 -0
- scc_cli/commands/org/app.py +41 -0
- scc_cli/commands/org/import_cmd.py +267 -0
- scc_cli/commands/org/init_cmd.py +269 -0
- scc_cli/commands/org/schema_cmd.py +76 -0
- scc_cli/commands/org/status_cmd.py +157 -0
- scc_cli/commands/org/update_cmd.py +330 -0
- scc_cli/commands/org/validate_cmd.py +138 -0
- scc_cli/commands/support.py +323 -0
- scc_cli/commands/team.py +910 -0
- scc_cli/commands/worktree/__init__.py +72 -0
- scc_cli/commands/worktree/_helpers.py +57 -0
- scc_cli/commands/worktree/app.py +170 -0
- scc_cli/commands/worktree/container_commands.py +385 -0
- scc_cli/commands/worktree/context_commands.py +61 -0
- scc_cli/commands/worktree/session_commands.py +128 -0
- scc_cli/commands/worktree/worktree_commands.py +734 -0
- scc_cli/config.py +647 -0
- scc_cli/confirm.py +20 -0
- scc_cli/console.py +562 -0
- scc_cli/contexts.py +394 -0
- scc_cli/core/__init__.py +68 -0
- scc_cli/core/constants.py +101 -0
- scc_cli/core/errors.py +297 -0
- scc_cli/core/exit_codes.py +91 -0
- scc_cli/core/workspace.py +57 -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 +467 -0
- scc_cli/docker/credentials.py +726 -0
- scc_cli/docker/launch.py +595 -0
- scc_cli/doctor/__init__.py +105 -0
- scc_cli/doctor/checks/__init__.py +166 -0
- scc_cli/doctor/checks/cache.py +314 -0
- scc_cli/doctor/checks/config.py +107 -0
- scc_cli/doctor/checks/environment.py +182 -0
- scc_cli/doctor/checks/json_helpers.py +157 -0
- scc_cli/doctor/checks/organization.py +264 -0
- scc_cli/doctor/checks/worktree.py +278 -0
- scc_cli/doctor/render.py +365 -0
- scc_cli/doctor/types.py +66 -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/git.py +84 -0
- scc_cli/json_command.py +166 -0
- scc_cli/json_output.py +159 -0
- scc_cli/kinds.py +65 -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 +846 -0
- scc_cli/marketplace/normalize.py +548 -0
- scc_cli/marketplace/render.py +281 -0
- scc_cli/marketplace/resolve.py +459 -0
- scc_cli/marketplace/schema.py +506 -0
- scc_cli/marketplace/sync.py +279 -0
- scc_cli/marketplace/team_cache.py +195 -0
- scc_cli/marketplace/team_fetch.py +689 -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/services/__init__.py +1 -0
- scc_cli/services/git/__init__.py +79 -0
- scc_cli/services/git/branch.py +151 -0
- scc_cli/services/git/core.py +216 -0
- scc_cli/services/git/hooks.py +108 -0
- scc_cli/services/git/worktree.py +444 -0
- scc_cli/services/workspace/__init__.py +36 -0
- scc_cli/services/workspace/resolver.py +223 -0
- scc_cli/services/workspace/suspicious.py +200 -0
- scc_cli/sessions.py +425 -0
- scc_cli/setup.py +589 -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 +383 -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 +154 -0
- scc_cli/ui/branding.py +68 -0
- scc_cli/ui/chrome.py +401 -0
- scc_cli/ui/dashboard/__init__.py +62 -0
- scc_cli/ui/dashboard/_dashboard.py +794 -0
- scc_cli/ui/dashboard/loaders.py +452 -0
- scc_cli/ui/dashboard/models.py +185 -0
- scc_cli/ui/dashboard/orchestrator.py +735 -0
- scc_cli/ui/formatters.py +444 -0
- scc_cli/ui/gate.py +350 -0
- scc_cli/ui/git_interactive.py +869 -0
- scc_cli/ui/git_render.py +176 -0
- scc_cli/ui/help.py +157 -0
- scc_cli/ui/keys.py +615 -0
- scc_cli/ui/list_screen.py +437 -0
- scc_cli/ui/picker.py +763 -0
- scc_cli/ui/prompts.py +201 -0
- scc_cli/ui/quick_resume.py +116 -0
- scc_cli/ui/wizard.py +576 -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 +114 -0
- scc_cli/utils/ttl.py +376 -0
- scc_cli/validate.py +455 -0
- scc_cli-1.5.3.dist-info/METADATA +401 -0
- scc_cli-1.5.3.dist-info/RECORD +153 -0
- scc_cli-1.5.3.dist-info/WHEEL +4 -0
- scc_cli-1.5.3.dist-info/entry_points.txt +2 -0
- scc_cli-1.5.3.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Source resolver for organization config imports.
|
|
3
|
+
|
|
4
|
+
Resolves human-friendly source strings to fetchable URLs or file paths.
|
|
5
|
+
Supports GitHub/GitLab shorthands while keeping the runtime fetch-only (no git clone).
|
|
6
|
+
|
|
7
|
+
Resolution precedence (order matters to avoid collisions):
|
|
8
|
+
1. Local file: exists on disk OR starts with ./ ../ / ~ OR matches Windows drive
|
|
9
|
+
2. URL: starts with http:// or https://
|
|
10
|
+
3. Shorthand: github: / gitlab: / <host>: patterns
|
|
11
|
+
4. Error: unknown format with examples
|
|
12
|
+
|
|
13
|
+
Examples:
|
|
14
|
+
# Direct HTTPS
|
|
15
|
+
https://example.com/org-config.json
|
|
16
|
+
|
|
17
|
+
# Local file
|
|
18
|
+
./org-config.json
|
|
19
|
+
file:./org-config.json
|
|
20
|
+
|
|
21
|
+
# GitHub shorthand
|
|
22
|
+
github:sundsvall/scc-org:org.json # default branch (floating)
|
|
23
|
+
github:sundsvall/scc-org@v1.2.0:org.json # tag (pinned)
|
|
24
|
+
github:sundsvall/scc-org@abc1234:org.json # SHA (pinned)
|
|
25
|
+
|
|
26
|
+
# GitLab shorthand
|
|
27
|
+
gitlab:myco/platform/scc@v1.0:org.json
|
|
28
|
+
|
|
29
|
+
# Self-hosted
|
|
30
|
+
gitlab.mycompany.com:team/config@main:org.json
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import os
|
|
36
|
+
import re
|
|
37
|
+
from dataclasses import dataclass
|
|
38
|
+
from pathlib import Path
|
|
39
|
+
from typing import Literal
|
|
40
|
+
|
|
41
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
42
|
+
# Data Types
|
|
43
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
44
|
+
|
|
45
|
+
SourceProvider = Literal["file", "https", "github", "gitlab", "custom"]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True)
|
|
49
|
+
class ResolvedSource:
|
|
50
|
+
"""Result of resolving a source string.
|
|
51
|
+
|
|
52
|
+
Attributes:
|
|
53
|
+
original: The original source string as provided by user.
|
|
54
|
+
resolved_url: The resolved HTTPS URL or file path for fetching.
|
|
55
|
+
provider: The detected provider type.
|
|
56
|
+
host: The host for custom providers (e.g., gitlab.mycompany.com).
|
|
57
|
+
owner: Repository owner/org (for shorthand sources).
|
|
58
|
+
repo: Repository name (for shorthand sources).
|
|
59
|
+
ref: Git reference (tag, SHA, branch) if specified.
|
|
60
|
+
path: Path within the repository.
|
|
61
|
+
is_pinned: True if ref is a tag or SHA (not a branch or default).
|
|
62
|
+
is_file: True if this is a local file path.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
original: str
|
|
66
|
+
resolved_url: str
|
|
67
|
+
provider: SourceProvider
|
|
68
|
+
host: str | None = None
|
|
69
|
+
owner: str | None = None
|
|
70
|
+
repo: str | None = None
|
|
71
|
+
ref: str | None = None
|
|
72
|
+
path: str | None = None
|
|
73
|
+
is_pinned: bool = False
|
|
74
|
+
is_file: bool = False
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass(frozen=True)
|
|
78
|
+
class ResolveError:
|
|
79
|
+
"""Error during source resolution.
|
|
80
|
+
|
|
81
|
+
Attributes:
|
|
82
|
+
message: Human-readable error message.
|
|
83
|
+
source: The source string that failed.
|
|
84
|
+
suggestion: Suggested fix or examples.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
message: str
|
|
88
|
+
source: str
|
|
89
|
+
suggestion: str | None = None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
93
|
+
# Resolution Patterns
|
|
94
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
95
|
+
|
|
96
|
+
# Windows drive pattern: C:\ or D:/
|
|
97
|
+
WINDOWS_DRIVE_PATTERN = re.compile(r"^[A-Za-z]:[/\\]")
|
|
98
|
+
|
|
99
|
+
# GitHub shorthand: github:owner/repo@ref:path or github:owner/repo:path
|
|
100
|
+
GITHUB_PATTERN = re.compile(
|
|
101
|
+
r"^github:(?P<owner>[^/@:]+)/(?P<repo>[^/@:]+)(?:@(?P<ref>[^:]+))?:(?P<path>.+)$"
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
# GitLab shorthand: gitlab:owner/repo@ref:path (supports nested groups)
|
|
105
|
+
GITLAB_PATTERN = re.compile(r"^gitlab:(?P<owner_repo>[^@:]+)(?:@(?P<ref>[^:]+))?:(?P<path>.+)$")
|
|
106
|
+
|
|
107
|
+
# Custom host shorthand: host.com:owner/repo@ref:path
|
|
108
|
+
# Must have at least one dot in host to distinguish from Windows paths
|
|
109
|
+
CUSTOM_HOST_PATTERN = re.compile(
|
|
110
|
+
r"^(?P<host>[a-zA-Z0-9][-a-zA-Z0-9]*\.[a-zA-Z0-9][-a-zA-Z0-9.]*)"
|
|
111
|
+
r":(?P<owner_repo>[^@:]+)(?:@(?P<ref>[^:]+))?:(?P<path>.+)$"
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
# Pinned ref detection: starts with v followed by number, or is hex SHA
|
|
115
|
+
TAG_PATTERN = re.compile(r"^v?\d+")
|
|
116
|
+
SHA_PATTERN = re.compile(r"^[0-9a-f]{7,40}$", re.IGNORECASE)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
120
|
+
# Helper Functions
|
|
121
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _is_pinned_ref(ref: str | None) -> bool:
|
|
125
|
+
"""Determine if a git ref is pinned (tag or SHA) vs floating (branch).
|
|
126
|
+
|
|
127
|
+
Pinned refs are:
|
|
128
|
+
- Tags: v1.0.0, v2, 1.0.0, etc.
|
|
129
|
+
- SHAs: 7+ character hex strings
|
|
130
|
+
|
|
131
|
+
Everything else (main, develop, feature/x) is considered floating.
|
|
132
|
+
"""
|
|
133
|
+
if not ref:
|
|
134
|
+
return False
|
|
135
|
+
|
|
136
|
+
# Check if it's a SHA (7-40 hex characters)
|
|
137
|
+
if SHA_PATTERN.match(ref):
|
|
138
|
+
return True
|
|
139
|
+
|
|
140
|
+
# Check if it looks like a version tag
|
|
141
|
+
if TAG_PATTERN.match(ref):
|
|
142
|
+
return True
|
|
143
|
+
|
|
144
|
+
return False
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _is_local_file_path(source: str) -> bool:
|
|
148
|
+
r"""Check if source looks like a local file path.
|
|
149
|
+
|
|
150
|
+
Checks (in order):
|
|
151
|
+
1. Starts with explicit file: prefix
|
|
152
|
+
2. Starts with ./ or ../
|
|
153
|
+
3. Starts with / (Unix absolute)
|
|
154
|
+
4. Starts with ~ (home directory)
|
|
155
|
+
5. Matches Windows drive pattern (C:\, D:/, etc.)
|
|
156
|
+
6. Actually exists on disk
|
|
157
|
+
"""
|
|
158
|
+
# Explicit file: prefix
|
|
159
|
+
if source.startswith("file:"):
|
|
160
|
+
return True
|
|
161
|
+
|
|
162
|
+
# Relative paths
|
|
163
|
+
if source.startswith("./") or source.startswith("../"):
|
|
164
|
+
return True
|
|
165
|
+
|
|
166
|
+
# Unix absolute path
|
|
167
|
+
if source.startswith("/"):
|
|
168
|
+
return True
|
|
169
|
+
|
|
170
|
+
# Home directory
|
|
171
|
+
if source.startswith("~"):
|
|
172
|
+
return True
|
|
173
|
+
|
|
174
|
+
# Windows drive pattern
|
|
175
|
+
if WINDOWS_DRIVE_PATTERN.match(source):
|
|
176
|
+
return True
|
|
177
|
+
|
|
178
|
+
# Check if file actually exists (catches bare filenames like "org.json")
|
|
179
|
+
try:
|
|
180
|
+
path = Path(source).expanduser()
|
|
181
|
+
if path.exists():
|
|
182
|
+
return True
|
|
183
|
+
except (OSError, ValueError):
|
|
184
|
+
pass
|
|
185
|
+
|
|
186
|
+
return False
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _resolve_file_source(source: str) -> ResolvedSource:
|
|
190
|
+
"""Resolve a local file source."""
|
|
191
|
+
# Strip file: prefix if present
|
|
192
|
+
path_str = source[5:] if source.startswith("file:") else source
|
|
193
|
+
|
|
194
|
+
# Expand and resolve path
|
|
195
|
+
path = Path(path_str).expanduser().resolve()
|
|
196
|
+
|
|
197
|
+
return ResolvedSource(
|
|
198
|
+
original=source,
|
|
199
|
+
resolved_url=str(path),
|
|
200
|
+
provider="file",
|
|
201
|
+
path=str(path),
|
|
202
|
+
is_pinned=True, # Local files are considered "pinned" (deterministic)
|
|
203
|
+
is_file=True,
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _resolve_github_source(source: str) -> ResolvedSource | ResolveError:
|
|
208
|
+
"""Resolve a GitHub shorthand source."""
|
|
209
|
+
match = GITHUB_PATTERN.match(source)
|
|
210
|
+
if not match:
|
|
211
|
+
return ResolveError(
|
|
212
|
+
message="Invalid GitHub source format",
|
|
213
|
+
source=source,
|
|
214
|
+
suggestion="Use: github:owner/repo@ref:path (e.g., github:acme/config@v1.0:org.json)",
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
owner = match.group("owner")
|
|
218
|
+
repo = match.group("repo")
|
|
219
|
+
ref = match.group("ref") or "HEAD" # Default to HEAD (main/master)
|
|
220
|
+
path = match.group("path")
|
|
221
|
+
|
|
222
|
+
# Build raw.githubusercontent.com URL
|
|
223
|
+
resolved_url = f"https://raw.githubusercontent.com/{owner}/{repo}/{ref}/{path}"
|
|
224
|
+
|
|
225
|
+
return ResolvedSource(
|
|
226
|
+
original=source,
|
|
227
|
+
resolved_url=resolved_url,
|
|
228
|
+
provider="github",
|
|
229
|
+
host="github.com",
|
|
230
|
+
owner=owner,
|
|
231
|
+
repo=repo,
|
|
232
|
+
ref=ref if match.group("ref") else None,
|
|
233
|
+
path=path,
|
|
234
|
+
is_pinned=_is_pinned_ref(match.group("ref")),
|
|
235
|
+
is_file=False,
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _resolve_gitlab_source(source: str) -> ResolvedSource | ResolveError:
|
|
240
|
+
"""Resolve a GitLab shorthand source."""
|
|
241
|
+
match = GITLAB_PATTERN.match(source)
|
|
242
|
+
if not match:
|
|
243
|
+
return ResolveError(
|
|
244
|
+
message="Invalid GitLab source format",
|
|
245
|
+
source=source,
|
|
246
|
+
suggestion="Use: gitlab:owner/repo@ref:path (e.g., gitlab:acme/config@v1.0:org.json)",
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
owner_repo = match.group("owner_repo") # Can include nested groups
|
|
250
|
+
ref = match.group("ref") or "main"
|
|
251
|
+
path = match.group("path")
|
|
252
|
+
|
|
253
|
+
# Split owner/repo (last segment is repo, rest is owner/group)
|
|
254
|
+
parts = owner_repo.split("/")
|
|
255
|
+
if len(parts) < 2:
|
|
256
|
+
return ResolveError(
|
|
257
|
+
message="GitLab source must include owner/repo",
|
|
258
|
+
source=source,
|
|
259
|
+
suggestion="Use: gitlab:owner/repo@ref:path",
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
owner = "/".join(parts[:-1])
|
|
263
|
+
repo = parts[-1]
|
|
264
|
+
|
|
265
|
+
# Build gitlab.com raw URL
|
|
266
|
+
resolved_url = f"https://gitlab.com/{owner_repo}/-/raw/{ref}/{path}"
|
|
267
|
+
|
|
268
|
+
return ResolvedSource(
|
|
269
|
+
original=source,
|
|
270
|
+
resolved_url=resolved_url,
|
|
271
|
+
provider="gitlab",
|
|
272
|
+
host="gitlab.com",
|
|
273
|
+
owner=owner,
|
|
274
|
+
repo=repo,
|
|
275
|
+
ref=ref if match.group("ref") else None,
|
|
276
|
+
path=path,
|
|
277
|
+
is_pinned=_is_pinned_ref(match.group("ref")),
|
|
278
|
+
is_file=False,
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _resolve_custom_host_source(source: str) -> ResolvedSource | ResolveError:
|
|
283
|
+
"""Resolve a custom-hosted GitLab-style source."""
|
|
284
|
+
match = CUSTOM_HOST_PATTERN.match(source)
|
|
285
|
+
if not match:
|
|
286
|
+
return ResolveError(
|
|
287
|
+
message="Invalid custom host source format",
|
|
288
|
+
source=source,
|
|
289
|
+
suggestion="Use: host.com:owner/repo@ref:path",
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
host = match.group("host")
|
|
293
|
+
owner_repo = match.group("owner_repo")
|
|
294
|
+
ref = match.group("ref") or "main"
|
|
295
|
+
path = match.group("path")
|
|
296
|
+
|
|
297
|
+
# Split owner/repo
|
|
298
|
+
parts = owner_repo.split("/")
|
|
299
|
+
if len(parts) < 2:
|
|
300
|
+
return ResolveError(
|
|
301
|
+
message="Custom host source must include owner/repo",
|
|
302
|
+
source=source,
|
|
303
|
+
suggestion=f"Use: {host}:owner/repo@ref:path",
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
owner = "/".join(parts[:-1])
|
|
307
|
+
repo = parts[-1]
|
|
308
|
+
|
|
309
|
+
# Assume GitLab-style raw URL for custom hosts
|
|
310
|
+
resolved_url = f"https://{host}/{owner_repo}/-/raw/{ref}/{path}"
|
|
311
|
+
|
|
312
|
+
return ResolvedSource(
|
|
313
|
+
original=source,
|
|
314
|
+
resolved_url=resolved_url,
|
|
315
|
+
provider="custom",
|
|
316
|
+
host=host,
|
|
317
|
+
owner=owner,
|
|
318
|
+
repo=repo,
|
|
319
|
+
ref=ref if match.group("ref") else None,
|
|
320
|
+
path=path,
|
|
321
|
+
is_pinned=_is_pinned_ref(match.group("ref")),
|
|
322
|
+
is_file=False,
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
327
|
+
# Main Resolution Function
|
|
328
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def resolve_source(source: str) -> ResolvedSource | ResolveError:
|
|
332
|
+
"""Resolve a source string to a fetchable URL or file path.
|
|
333
|
+
|
|
334
|
+
Resolution precedence (order matters to avoid collisions):
|
|
335
|
+
1. Local file: exists on disk OR starts with ./ ../ / ~ OR Windows drive
|
|
336
|
+
2. URL: starts with http:// or https://
|
|
337
|
+
3. Shorthand: github: / gitlab: / <host>: patterns
|
|
338
|
+
4. Error: unknown format with examples
|
|
339
|
+
|
|
340
|
+
Args:
|
|
341
|
+
source: The source string to resolve.
|
|
342
|
+
|
|
343
|
+
Returns:
|
|
344
|
+
ResolvedSource on success, ResolveError on failure.
|
|
345
|
+
|
|
346
|
+
Examples:
|
|
347
|
+
>>> resolve_source("./org.json")
|
|
348
|
+
ResolvedSource(provider="file", is_file=True, ...)
|
|
349
|
+
|
|
350
|
+
>>> resolve_source("github:acme/config@v1.0:org.json")
|
|
351
|
+
ResolvedSource(provider="github", is_pinned=True, ...)
|
|
352
|
+
|
|
353
|
+
>>> resolve_source("https://example.com/org.json")
|
|
354
|
+
ResolvedSource(provider="https", ...)
|
|
355
|
+
"""
|
|
356
|
+
source = source.strip()
|
|
357
|
+
|
|
358
|
+
if not source:
|
|
359
|
+
return ResolveError(
|
|
360
|
+
message="Empty source string",
|
|
361
|
+
source=source,
|
|
362
|
+
suggestion="Provide a URL, file path, or shorthand (github:owner/repo@ref:path)",
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
# 1. Check for local file path FIRST (prevents collisions)
|
|
366
|
+
if _is_local_file_path(source):
|
|
367
|
+
return _resolve_file_source(source)
|
|
368
|
+
|
|
369
|
+
# 2. Check for HTTPS URL
|
|
370
|
+
if source.startswith("https://"):
|
|
371
|
+
return ResolvedSource(
|
|
372
|
+
original=source,
|
|
373
|
+
resolved_url=source,
|
|
374
|
+
provider="https",
|
|
375
|
+
is_pinned=False, # Can't determine pinning for raw URLs
|
|
376
|
+
is_file=False,
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
# 2b. Reject HTTP (security)
|
|
380
|
+
if source.startswith("http://"):
|
|
381
|
+
return ResolveError(
|
|
382
|
+
message="HTTP not allowed (security risk)",
|
|
383
|
+
source=source,
|
|
384
|
+
suggestion="Use HTTPS: " + source.replace("http://", "https://"),
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
# 3. Check for shorthands
|
|
388
|
+
if source.startswith("github:"):
|
|
389
|
+
return _resolve_github_source(source)
|
|
390
|
+
|
|
391
|
+
if source.startswith("gitlab:"):
|
|
392
|
+
return _resolve_gitlab_source(source)
|
|
393
|
+
|
|
394
|
+
# 3b. Check for custom host shorthand (must have dot in host)
|
|
395
|
+
if ":" in source and "." in source.split(":")[0]:
|
|
396
|
+
result = _resolve_custom_host_source(source)
|
|
397
|
+
if not isinstance(result, ResolveError):
|
|
398
|
+
return result
|
|
399
|
+
# Fall through to unknown format error with better examples
|
|
400
|
+
|
|
401
|
+
# 4. Unknown format
|
|
402
|
+
return ResolveError(
|
|
403
|
+
message="Unknown source format",
|
|
404
|
+
source=source,
|
|
405
|
+
suggestion="""Valid formats:
|
|
406
|
+
• Local file: ./org.json, ~/config/org.json
|
|
407
|
+
• HTTPS URL: https://example.com/org.json
|
|
408
|
+
• GitHub: github:owner/repo@tag:path.json
|
|
409
|
+
• GitLab: gitlab:owner/repo@tag:path.json
|
|
410
|
+
• Custom host: gitlab.company.com:owner/repo@tag:path.json""",
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
415
|
+
# Auth Detection
|
|
416
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def detect_auth_env_var(resolved: ResolvedSource) -> str | None:
|
|
420
|
+
"""Detect appropriate auth environment variable for a resolved source.
|
|
421
|
+
|
|
422
|
+
Priority:
|
|
423
|
+
1. SCC_ORG_TOKEN (SCC-specific, always checked first)
|
|
424
|
+
2. GITHUB_TOKEN (for GitHub sources)
|
|
425
|
+
3. GITLAB_TOKEN (for GitLab sources)
|
|
426
|
+
|
|
427
|
+
Args:
|
|
428
|
+
resolved: The resolved source to detect auth for.
|
|
429
|
+
|
|
430
|
+
Returns:
|
|
431
|
+
Environment variable name if found and set, None otherwise.
|
|
432
|
+
"""
|
|
433
|
+
# SCC-specific token takes priority
|
|
434
|
+
if os.environ.get("SCC_ORG_TOKEN"):
|
|
435
|
+
return "SCC_ORG_TOKEN"
|
|
436
|
+
|
|
437
|
+
# Provider-specific tokens
|
|
438
|
+
if resolved.provider == "github" and os.environ.get("GITHUB_TOKEN"):
|
|
439
|
+
return "GITHUB_TOKEN"
|
|
440
|
+
|
|
441
|
+
if resolved.provider in ("gitlab", "custom") and os.environ.get("GITLAB_TOKEN"):
|
|
442
|
+
return "GITLAB_TOKEN"
|
|
443
|
+
|
|
444
|
+
return None
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def build_auth_spec(resolved: ResolvedSource, explicit_auth: str | None = None) -> str | None:
|
|
448
|
+
"""Build auth specification for a resolved source.
|
|
449
|
+
|
|
450
|
+
Args:
|
|
451
|
+
resolved: The resolved source.
|
|
452
|
+
explicit_auth: Explicitly provided auth spec (e.g., "env:MY_TOKEN").
|
|
453
|
+
|
|
454
|
+
Returns:
|
|
455
|
+
Auth spec string (e.g., "env:GITHUB_TOKEN") or None if no auth.
|
|
456
|
+
"""
|
|
457
|
+
# Explicit auth takes priority
|
|
458
|
+
if explicit_auth:
|
|
459
|
+
return explicit_auth
|
|
460
|
+
|
|
461
|
+
# Local files don't need auth
|
|
462
|
+
if resolved.is_file:
|
|
463
|
+
return None
|
|
464
|
+
|
|
465
|
+
# Detect from environment
|
|
466
|
+
env_var = detect_auth_env_var(resolved)
|
|
467
|
+
if env_var:
|
|
468
|
+
return f"env:{env_var}"
|
|
469
|
+
|
|
470
|
+
return None
|