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
scc_cli/remote.py
ADDED
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Remote config fetching with auth and caching.
|
|
3
|
+
|
|
4
|
+
Handle all HTTP concerns for fetching org config:
|
|
5
|
+
- URL validation (HTTPS only)
|
|
6
|
+
- Auth resolution (env:VAR, command:CMD)
|
|
7
|
+
- ETag-based conditional fetching
|
|
8
|
+
- Local cache with TTL
|
|
9
|
+
|
|
10
|
+
Module Separation: This module does HTTP only, no business logic.
|
|
11
|
+
Business logic is in profiles.py, format knowledge is in claude_adapter.py.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import hashlib
|
|
17
|
+
import json
|
|
18
|
+
import stat
|
|
19
|
+
import sys
|
|
20
|
+
from datetime import datetime, timedelta, timezone
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import TYPE_CHECKING, Any
|
|
23
|
+
from urllib.parse import urlparse
|
|
24
|
+
|
|
25
|
+
import requests
|
|
26
|
+
|
|
27
|
+
from scc_cli.auth import is_remote_command_allowed
|
|
28
|
+
from scc_cli.auth import resolve_auth as _resolve_auth_impl
|
|
29
|
+
from scc_cli.output_mode import print_human
|
|
30
|
+
from scc_cli.utils.locks import file_lock, lock_path
|
|
31
|
+
|
|
32
|
+
if TYPE_CHECKING:
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
37
|
+
# XDG Base Directory Paths
|
|
38
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
39
|
+
|
|
40
|
+
# Cache directory: ~/.cache/scc/ (regenerable, safe to delete)
|
|
41
|
+
CACHE_DIR = Path.home() / ".cache" / "scc"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
45
|
+
# Exceptions
|
|
46
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class CacheNotFoundError(Exception):
|
|
50
|
+
"""Raised when cache is required but not available."""
|
|
51
|
+
|
|
52
|
+
pass
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class ConfigValidationError(Exception):
|
|
56
|
+
"""Raised when org config fails validation.
|
|
57
|
+
|
|
58
|
+
This is raised when either:
|
|
59
|
+
- Structural validation fails (JSON Schema errors)
|
|
60
|
+
- Semantic validation fails (governance invariant violations)
|
|
61
|
+
|
|
62
|
+
Invalid configs are never cached to prevent polluting the cache.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
pass
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
69
|
+
# URL Validation
|
|
70
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def validate_org_config_url(url: str) -> str:
|
|
74
|
+
"""Validate and normalize org config URL. HTTPS only.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
url: URL to validate
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
Validated and normalized URL
|
|
81
|
+
|
|
82
|
+
Raises:
|
|
83
|
+
ValueError for:
|
|
84
|
+
- http:// URLs (security risk)
|
|
85
|
+
- git@ or ssh:// URLs (not supported)
|
|
86
|
+
- Non-URL formats
|
|
87
|
+
"""
|
|
88
|
+
url = url.strip()
|
|
89
|
+
|
|
90
|
+
# Reject SSH URLs early
|
|
91
|
+
if url.startswith("git@") or url.startswith("ssh://"):
|
|
92
|
+
raise ValueError(f"SSH URL not supported for org config: {url}")
|
|
93
|
+
|
|
94
|
+
parsed = urlparse(url)
|
|
95
|
+
|
|
96
|
+
# HTTPS only - reject http:// for security
|
|
97
|
+
if parsed.scheme == "http":
|
|
98
|
+
raise ValueError(f"HTTP not allowed (use HTTPS): {url}")
|
|
99
|
+
|
|
100
|
+
if parsed.scheme != "https":
|
|
101
|
+
raise ValueError(f"Invalid URL scheme (HTTPS required): {url}")
|
|
102
|
+
|
|
103
|
+
return url
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
107
|
+
# Auth Resolution
|
|
108
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def resolve_auth(auth_spec: str | None, *, from_remote: bool = False) -> str | None:
|
|
112
|
+
"""Resolve auth from 'env:VAR' or 'command:CMD' syntax.
|
|
113
|
+
|
|
114
|
+
SECURITY: Uses auth.py module with shell=False to prevent shell injection.
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
auth_spec: Auth specification string or None
|
|
118
|
+
from_remote: If True, applies trust model restrictions for remote org config.
|
|
119
|
+
command: auth requires SCC_ALLOW_REMOTE_COMMANDS=1 when from_remote=True.
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
Token string or None if not available/configured
|
|
123
|
+
|
|
124
|
+
Raises:
|
|
125
|
+
ValueError: If command: auth is used from remote config without opt-in
|
|
126
|
+
"""
|
|
127
|
+
if not auth_spec:
|
|
128
|
+
return None
|
|
129
|
+
|
|
130
|
+
# Determine if command: auth is allowed based on source
|
|
131
|
+
# User config (from_remote=False): Always allow command: auth
|
|
132
|
+
# Remote org config (from_remote=True): Require explicit opt-in
|
|
133
|
+
allow_command = not from_remote or is_remote_command_allowed()
|
|
134
|
+
|
|
135
|
+
try:
|
|
136
|
+
result = _resolve_auth_impl(auth_spec, allow_command=allow_command)
|
|
137
|
+
return result.token if result else None
|
|
138
|
+
except RuntimeError:
|
|
139
|
+
# Command execution failed - return None for backward compatibility
|
|
140
|
+
# (old behavior: failed commands returned None)
|
|
141
|
+
return None
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
145
|
+
# HTTP Fetching
|
|
146
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def fetch_org_config(
|
|
150
|
+
url: str, auth: str | None, etag: str | None = None
|
|
151
|
+
) -> tuple[dict[str, Any] | None, str | None, int]:
|
|
152
|
+
"""Fetch org config from URL with ETag support.
|
|
153
|
+
|
|
154
|
+
Args:
|
|
155
|
+
url: HTTPS URL to fetch from (validated)
|
|
156
|
+
auth: Auth token for Authorization header
|
|
157
|
+
etag: Previous ETag for conditional request
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
Tuple of (config_dict, new_etag, status_code)
|
|
161
|
+
- 200: new config returned
|
|
162
|
+
- 304: not modified, config is None (use cache)
|
|
163
|
+
- 401/403: auth error, config is None
|
|
164
|
+
- Other errors: config is None
|
|
165
|
+
"""
|
|
166
|
+
# Validate URL (HTTPS enforcement)
|
|
167
|
+
url = validate_org_config_url(url)
|
|
168
|
+
|
|
169
|
+
headers = {}
|
|
170
|
+
|
|
171
|
+
# Add Authorization header if auth provided
|
|
172
|
+
if auth:
|
|
173
|
+
headers["Authorization"] = f"Bearer {auth}"
|
|
174
|
+
|
|
175
|
+
# Add If-None-Match header for conditional request
|
|
176
|
+
if etag:
|
|
177
|
+
headers["If-None-Match"] = etag
|
|
178
|
+
|
|
179
|
+
try:
|
|
180
|
+
response = requests.get(url, headers=headers, timeout=30)
|
|
181
|
+
status = response.status_code
|
|
182
|
+
|
|
183
|
+
# 304 Not Modified - use cached version
|
|
184
|
+
if status == 304:
|
|
185
|
+
return (None, etag, 304)
|
|
186
|
+
|
|
187
|
+
# Error responses
|
|
188
|
+
if status != 200:
|
|
189
|
+
return (None, None, status)
|
|
190
|
+
|
|
191
|
+
# Parse JSON response
|
|
192
|
+
try:
|
|
193
|
+
config = response.json()
|
|
194
|
+
except json.JSONDecodeError:
|
|
195
|
+
return (None, None, -1) # Invalid JSON
|
|
196
|
+
|
|
197
|
+
# Extract new ETag
|
|
198
|
+
new_etag = response.headers.get("ETag")
|
|
199
|
+
|
|
200
|
+
return (config, new_etag, 200)
|
|
201
|
+
|
|
202
|
+
except requests.RequestException:
|
|
203
|
+
return (None, None, -2) # Network error
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
207
|
+
# Cache Operations
|
|
208
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def save_to_cache(
|
|
212
|
+
org_config: dict[str, Any], source_url: str, etag: str | None, ttl_hours: int
|
|
213
|
+
) -> None:
|
|
214
|
+
"""Save org config to cache with metadata.
|
|
215
|
+
|
|
216
|
+
Args:
|
|
217
|
+
org_config: Organization config dict to cache
|
|
218
|
+
source_url: URL the config was fetched from
|
|
219
|
+
etag: ETag from server response
|
|
220
|
+
ttl_hours: Cache time-to-live in hours
|
|
221
|
+
"""
|
|
222
|
+
lock_file = lock_path("org-config-cache")
|
|
223
|
+
with file_lock(lock_file):
|
|
224
|
+
# Ensure cache directory exists
|
|
225
|
+
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
|
226
|
+
|
|
227
|
+
# Save org config with restrictive permissions (owner read/write only)
|
|
228
|
+
config_file = CACHE_DIR / "org_config.json"
|
|
229
|
+
config_content = json.dumps(org_config, indent=2)
|
|
230
|
+
config_file.write_text(config_content)
|
|
231
|
+
config_file.chmod(stat.S_IRUSR | stat.S_IWUSR) # 0o600 - owner read/write only
|
|
232
|
+
|
|
233
|
+
# Calculate fingerprint (SHA256 of cached bytes)
|
|
234
|
+
fingerprint = hashlib.sha256(config_file.read_bytes()).hexdigest()
|
|
235
|
+
|
|
236
|
+
# Calculate expiry time
|
|
237
|
+
now = datetime.now(timezone.utc)
|
|
238
|
+
expires_at = now + timedelta(hours=ttl_hours)
|
|
239
|
+
|
|
240
|
+
# Save metadata
|
|
241
|
+
meta = {
|
|
242
|
+
"org_config": {
|
|
243
|
+
"source_url": source_url,
|
|
244
|
+
"fetched_at": now.isoformat(),
|
|
245
|
+
"expires_at": expires_at.isoformat(),
|
|
246
|
+
"etag": etag,
|
|
247
|
+
"fingerprint": fingerprint,
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
meta_file = CACHE_DIR / "cache_meta.json"
|
|
251
|
+
meta_file.write_text(json.dumps(meta, indent=2))
|
|
252
|
+
meta_file.chmod(stat.S_IRUSR | stat.S_IWUSR) # 0o600 - owner read/write only
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def load_from_cache() -> tuple[dict[str, Any] | None, dict[str, Any] | None]:
|
|
256
|
+
"""Load cached org config and metadata.
|
|
257
|
+
|
|
258
|
+
Returns:
|
|
259
|
+
Tuple of (config_dict, metadata_dict)
|
|
260
|
+
Both are None if cache doesn't exist or is corrupted
|
|
261
|
+
"""
|
|
262
|
+
config_file = CACHE_DIR / "org_config.json"
|
|
263
|
+
meta_file = CACHE_DIR / "cache_meta.json"
|
|
264
|
+
|
|
265
|
+
if not config_file.exists() or not meta_file.exists():
|
|
266
|
+
return (None, None)
|
|
267
|
+
|
|
268
|
+
lock_file = lock_path("org-config-cache")
|
|
269
|
+
with file_lock(lock_file):
|
|
270
|
+
try:
|
|
271
|
+
config = json.loads(config_file.read_text())
|
|
272
|
+
meta = json.loads(meta_file.read_text())
|
|
273
|
+
return (config, meta)
|
|
274
|
+
except (json.JSONDecodeError, OSError):
|
|
275
|
+
return (None, None)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def is_cache_valid(meta: dict[str, Any] | None) -> bool:
|
|
279
|
+
"""Check if cache is within TTL.
|
|
280
|
+
|
|
281
|
+
Args:
|
|
282
|
+
meta: Cache metadata dict
|
|
283
|
+
|
|
284
|
+
Returns:
|
|
285
|
+
True if cache is valid and within TTL
|
|
286
|
+
"""
|
|
287
|
+
if not meta:
|
|
288
|
+
return False
|
|
289
|
+
|
|
290
|
+
org_config_meta = meta.get("org_config", {})
|
|
291
|
+
expires_at_str = org_config_meta.get("expires_at")
|
|
292
|
+
|
|
293
|
+
if not expires_at_str:
|
|
294
|
+
return False
|
|
295
|
+
|
|
296
|
+
try:
|
|
297
|
+
expires_at = datetime.fromisoformat(expires_at_str)
|
|
298
|
+
if expires_at.tzinfo is None:
|
|
299
|
+
expires_at = expires_at.replace(tzinfo=timezone.utc)
|
|
300
|
+
now = datetime.now(timezone.utc)
|
|
301
|
+
return now < expires_at
|
|
302
|
+
except (ValueError, TypeError):
|
|
303
|
+
return False
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
307
|
+
# Validation Gate
|
|
308
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _validate_org_config(config: dict[str, Any]) -> None:
|
|
312
|
+
"""Validate org config structurally and semantically.
|
|
313
|
+
|
|
314
|
+
This is the Validation Gate pattern - called BEFORE caching to ensure
|
|
315
|
+
invalid configs never pollute the cache.
|
|
316
|
+
|
|
317
|
+
Two-step validation:
|
|
318
|
+
1. Structural: JSON Schema validation (required fields, types, patterns)
|
|
319
|
+
2. Semantic: Governance invariants (enabled ⊆ allowed, enabled ∩ blocked = ∅)
|
|
320
|
+
|
|
321
|
+
Args:
|
|
322
|
+
config: Organization config dict to validate
|
|
323
|
+
|
|
324
|
+
Raises:
|
|
325
|
+
ConfigValidationError: If either validation step fails
|
|
326
|
+
"""
|
|
327
|
+
# Import here to avoid circular dependencies at module load time
|
|
328
|
+
from scc_cli.validate import InvariantViolation, validate_config_invariants
|
|
329
|
+
from scc_cli.validate import validate_org_config as validate_schema
|
|
330
|
+
|
|
331
|
+
# Step 1: Structural validation (JSON Schema)
|
|
332
|
+
schema_errors = validate_schema(config)
|
|
333
|
+
if schema_errors:
|
|
334
|
+
# Format errors for user-friendly message
|
|
335
|
+
error_summary = "; ".join(schema_errors[:3]) # Show first 3 errors
|
|
336
|
+
if len(schema_errors) > 3:
|
|
337
|
+
error_summary += f" (+{len(schema_errors) - 3} more)"
|
|
338
|
+
raise ConfigValidationError(
|
|
339
|
+
f"Organization config failed schema validation: {error_summary}"
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
# Step 2: Semantic validation (governance invariants)
|
|
343
|
+
violations: list[InvariantViolation] = validate_config_invariants(config)
|
|
344
|
+
errors = [v for v in violations if v.severity == "error"]
|
|
345
|
+
if errors:
|
|
346
|
+
# Format violations for user-friendly message
|
|
347
|
+
error_messages = [v.message for v in errors[:3]] # Show first 3
|
|
348
|
+
error_summary = "; ".join(error_messages)
|
|
349
|
+
if len(errors) > 3:
|
|
350
|
+
error_summary += f" (+{len(errors) - 3} more)"
|
|
351
|
+
raise ConfigValidationError(
|
|
352
|
+
f"Organization config failed invariant validation: {error_summary}"
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
357
|
+
# Main Entry Point
|
|
358
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def load_org_config(
|
|
362
|
+
user_config: dict[str, Any], force_refresh: bool = False, offline: bool = False
|
|
363
|
+
) -> dict[str, Any] | None:
|
|
364
|
+
"""Load organization config from cache or remote.
|
|
365
|
+
|
|
366
|
+
This is the main entry point for getting org config.
|
|
367
|
+
|
|
368
|
+
Args:
|
|
369
|
+
user_config: User config dict with organization_source
|
|
370
|
+
force_refresh: Bypass TTL check and always fetch from remote
|
|
371
|
+
offline: Use cache only, error if no cache available
|
|
372
|
+
|
|
373
|
+
Returns:
|
|
374
|
+
Organization config dict, or None for standalone mode
|
|
375
|
+
|
|
376
|
+
Raises:
|
|
377
|
+
CacheNotFoundError: In offline mode when no cache is available
|
|
378
|
+
"""
|
|
379
|
+
# Standalone mode - no org config
|
|
380
|
+
if user_config.get("standalone"):
|
|
381
|
+
return None
|
|
382
|
+
|
|
383
|
+
# No organization source configured
|
|
384
|
+
org_source = user_config.get("organization_source")
|
|
385
|
+
if not org_source:
|
|
386
|
+
return None
|
|
387
|
+
|
|
388
|
+
url = org_source.get("url")
|
|
389
|
+
if not url:
|
|
390
|
+
return None
|
|
391
|
+
|
|
392
|
+
auth_spec = org_source.get("auth")
|
|
393
|
+
|
|
394
|
+
# Try to load from cache
|
|
395
|
+
cached_config, meta = load_from_cache()
|
|
396
|
+
|
|
397
|
+
# Offline mode: cache only
|
|
398
|
+
if offline:
|
|
399
|
+
if cached_config is not None:
|
|
400
|
+
return cached_config
|
|
401
|
+
raise CacheNotFoundError(f"No cached config available for offline mode. URL: {url}")
|
|
402
|
+
|
|
403
|
+
# Check if cache is valid and we don't need to refresh
|
|
404
|
+
if not force_refresh and cached_config is not None and is_cache_valid(meta):
|
|
405
|
+
return cached_config
|
|
406
|
+
|
|
407
|
+
# Need to fetch from remote
|
|
408
|
+
auth = resolve_auth(auth_spec)
|
|
409
|
+
etag = meta.get("org_config", {}).get("etag") if meta else None
|
|
410
|
+
|
|
411
|
+
config, new_etag, status = fetch_org_config(url, auth=auth, etag=etag)
|
|
412
|
+
|
|
413
|
+
# 304 Not Modified - use cached version
|
|
414
|
+
if status == 304 and cached_config is not None:
|
|
415
|
+
return cached_config
|
|
416
|
+
|
|
417
|
+
# Success - validate BEFORE caching (Validation Gate pattern)
|
|
418
|
+
if status == 200 and config is not None:
|
|
419
|
+
# Validate config - raises ConfigValidationError if invalid
|
|
420
|
+
# This prevents invalid configs from polluting the cache
|
|
421
|
+
_validate_org_config(config)
|
|
422
|
+
from scc_cli.validate import check_version_compatibility
|
|
423
|
+
|
|
424
|
+
compatibility = check_version_compatibility(config)
|
|
425
|
+
if not compatibility.compatible:
|
|
426
|
+
raise ConfigValidationError(compatibility.blocking_error or "Config incompatible")
|
|
427
|
+
|
|
428
|
+
# Only cache after validation passes
|
|
429
|
+
ttl_hours = config.get("defaults", {}).get("cache_ttl_hours", 24)
|
|
430
|
+
save_to_cache(config, url, new_etag, ttl_hours)
|
|
431
|
+
return config
|
|
432
|
+
|
|
433
|
+
# Fetch failed - return stale cache if available (with warning)
|
|
434
|
+
if cached_config is not None:
|
|
435
|
+
print_human(
|
|
436
|
+
"[yellow]Warning:[/yellow] Failed to refresh org config; using cached config.",
|
|
437
|
+
file=sys.stderr,
|
|
438
|
+
highlight=False,
|
|
439
|
+
)
|
|
440
|
+
return cached_config
|
|
441
|
+
|
|
442
|
+
# No cache and fetch failed - return None
|
|
443
|
+
return None
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Bundled JSON schemas for offline validation."""
|