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/update.py
ADDED
|
@@ -0,0 +1,680 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Check for updates to scc-cli CLI and organization config.
|
|
3
|
+
|
|
4
|
+
Two independent update mechanisms:
|
|
5
|
+
1. CLI version: Check PyPI (public, always accessible), throttle to once/24h
|
|
6
|
+
2. Org config: Check remote URL with ETag, throttle to TTL (1-6h typically)
|
|
7
|
+
|
|
8
|
+
Design principles:
|
|
9
|
+
- Non-blocking: Update checks do not delay CLI startup
|
|
10
|
+
- Graceful degradation: Offline = use cache silently
|
|
11
|
+
- Cache-first: Always prefer cached data over network errors
|
|
12
|
+
- UX-friendly: Clear, non-intrusive update notifications
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import re
|
|
18
|
+
import shutil
|
|
19
|
+
import sys
|
|
20
|
+
import urllib.error
|
|
21
|
+
import urllib.request
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from datetime import datetime, timedelta, timezone
|
|
24
|
+
from importlib.metadata import PackageNotFoundError
|
|
25
|
+
from importlib.metadata import version as get_installed_version
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import TYPE_CHECKING, Any, cast
|
|
28
|
+
|
|
29
|
+
from rich.console import Console
|
|
30
|
+
from rich.panel import Panel
|
|
31
|
+
|
|
32
|
+
if TYPE_CHECKING:
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
# Package name on PyPI
|
|
36
|
+
PACKAGE_NAME = "scc-cli"
|
|
37
|
+
|
|
38
|
+
# PyPI JSON API endpoint
|
|
39
|
+
PYPI_URL = f"https://pypi.org/pypi/{PACKAGE_NAME}/json"
|
|
40
|
+
|
|
41
|
+
# Timeout for PyPI requests (kept short to avoid hanging CLI)
|
|
42
|
+
REQUEST_TIMEOUT = 3
|
|
43
|
+
|
|
44
|
+
# Throttling: Don't check CLI version more than once per day
|
|
45
|
+
CLI_CHECK_INTERVAL_HOURS = 24
|
|
46
|
+
|
|
47
|
+
# Throttling: Org config check interval (1 hour minimum between checks)
|
|
48
|
+
# Note: This is separate from cache TTL. TTL controls staleness,
|
|
49
|
+
# this controls how often we even attempt to check.
|
|
50
|
+
ORG_CONFIG_CHECK_INTERVAL_HOURS = 1
|
|
51
|
+
|
|
52
|
+
# Cache directory for update check timestamps
|
|
53
|
+
UPDATE_CHECK_CACHE_DIR = Path.home() / ".cache" / "scc"
|
|
54
|
+
UPDATE_CHECK_META_FILE = UPDATE_CHECK_CACHE_DIR / "update_check_meta.json"
|
|
55
|
+
|
|
56
|
+
# Pre-release tag ordering (lower = earlier in release cycle)
|
|
57
|
+
_PRERELEASE_ORDER = {"dev": 0, "a": 1, "alpha": 1, "b": 2, "beta": 2, "rc": 3, "c": 3}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass
|
|
61
|
+
class UpdateInfo:
|
|
62
|
+
"""Information about available CLI updates."""
|
|
63
|
+
|
|
64
|
+
current: str
|
|
65
|
+
latest: str | None
|
|
66
|
+
update_available: bool
|
|
67
|
+
install_method: str # 'pip', 'pipx', 'uv', 'editable'
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass
|
|
71
|
+
class OrgConfigUpdateResult:
|
|
72
|
+
"""Result of org config update check."""
|
|
73
|
+
|
|
74
|
+
status: str # 'updated', 'unchanged', 'offline', 'auth_failed', 'no_cache', 'standalone'
|
|
75
|
+
message: str | None = None
|
|
76
|
+
cached_age_hours: float | None = None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass
|
|
80
|
+
class UpdateCheckResult:
|
|
81
|
+
"""Combined result of all update checks."""
|
|
82
|
+
|
|
83
|
+
cli_update: UpdateInfo | None = None
|
|
84
|
+
org_config: OrgConfigUpdateResult | None = None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def check_for_updates() -> UpdateInfo:
|
|
88
|
+
"""
|
|
89
|
+
Check PyPI for updates using stdlib urllib and return update info.
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
UpdateInfo with current version, latest version, and update status
|
|
93
|
+
"""
|
|
94
|
+
current = _get_current_version()
|
|
95
|
+
latest = _fetch_latest_from_pypi()
|
|
96
|
+
method = _detect_install_method()
|
|
97
|
+
|
|
98
|
+
update_available = False
|
|
99
|
+
if latest:
|
|
100
|
+
update_available = _compare_versions(current, latest) < 0
|
|
101
|
+
|
|
102
|
+
return UpdateInfo(
|
|
103
|
+
current=current,
|
|
104
|
+
latest=latest,
|
|
105
|
+
update_available=update_available,
|
|
106
|
+
install_method=method,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _get_current_version() -> str:
|
|
111
|
+
"""Return the currently installed version."""
|
|
112
|
+
try:
|
|
113
|
+
return get_installed_version(PACKAGE_NAME)
|
|
114
|
+
except PackageNotFoundError:
|
|
115
|
+
return "0.0.0"
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _fetch_latest_from_pypi() -> str | None:
|
|
119
|
+
"""Fetch and return the latest version from the PyPI JSON API."""
|
|
120
|
+
try:
|
|
121
|
+
with urllib.request.urlopen(PYPI_URL, timeout=REQUEST_TIMEOUT) as response:
|
|
122
|
+
data = json.loads(response.read().decode("utf-8"))
|
|
123
|
+
return cast(str, data["info"]["version"])
|
|
124
|
+
except (urllib.error.URLError, json.JSONDecodeError, TimeoutError, OSError, KeyError):
|
|
125
|
+
# Network errors, invalid JSON, timeouts, or malformed response
|
|
126
|
+
return None
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _parse_version(v: str) -> tuple[tuple[int, ...], tuple[int, int] | None]:
|
|
130
|
+
"""
|
|
131
|
+
Parse a version string into (numeric_parts, prerelease_info).
|
|
132
|
+
|
|
133
|
+
Examples:
|
|
134
|
+
"1.0.0" -> ((1, 0, 0), None)
|
|
135
|
+
"1.0.0rc1" -> ((1, 0, 0), (3, 1)) # rc=3 in order, number=1
|
|
136
|
+
"1.0.0a2" -> ((1, 0, 0), (1, 2)) # a=1 in order, number=2
|
|
137
|
+
"1.0.0.dev1" -> ((1, 0, 0), (0, 1)) # dev=0 in order, number=1
|
|
138
|
+
"""
|
|
139
|
+
# Normalize: replace common separators
|
|
140
|
+
v = v.lower().replace("-", ".").replace("_", ".")
|
|
141
|
+
|
|
142
|
+
# Extract numeric parts and any pre-release suffix
|
|
143
|
+
# Pattern: digits optionally followed by prerelease tag
|
|
144
|
+
parts: list[int] = []
|
|
145
|
+
prerelease: tuple[int, int] | None = None
|
|
146
|
+
|
|
147
|
+
# Split by dots and process each segment
|
|
148
|
+
segments = v.split(".")[:4] # Limit to 4 segments
|
|
149
|
+
|
|
150
|
+
for segment in segments:
|
|
151
|
+
# Check for pre-release tag embedded in segment (e.g., "0rc1")
|
|
152
|
+
match = re.match(r"^(\d+)([a-z]+)(\d*)$", segment)
|
|
153
|
+
if match:
|
|
154
|
+
num, tag, tag_num = match.groups()
|
|
155
|
+
parts.append(int(num))
|
|
156
|
+
if tag in _PRERELEASE_ORDER:
|
|
157
|
+
prerelease = (_PRERELEASE_ORDER[tag], int(tag_num) if tag_num else 0)
|
|
158
|
+
break
|
|
159
|
+
elif segment.isdigit():
|
|
160
|
+
parts.append(int(segment))
|
|
161
|
+
elif segment in _PRERELEASE_ORDER:
|
|
162
|
+
# Standalone tag like ".dev1" after split
|
|
163
|
+
prerelease = (_PRERELEASE_ORDER[segment], 0)
|
|
164
|
+
break
|
|
165
|
+
elif re.match(r"^([a-z]+)(\d*)$", segment):
|
|
166
|
+
# Tag with optional number like "dev1"
|
|
167
|
+
m = re.match(r"^([a-z]+)(\d*)$", segment)
|
|
168
|
+
if m:
|
|
169
|
+
tag, tag_num = m.groups()
|
|
170
|
+
if tag in _PRERELEASE_ORDER:
|
|
171
|
+
prerelease = (_PRERELEASE_ORDER[tag], int(tag_num) if tag_num else 0)
|
|
172
|
+
break
|
|
173
|
+
else:
|
|
174
|
+
# Unknown format, try to extract leading digits
|
|
175
|
+
num_str = ""
|
|
176
|
+
for char in segment:
|
|
177
|
+
if char.isdigit():
|
|
178
|
+
num_str += char
|
|
179
|
+
else:
|
|
180
|
+
break
|
|
181
|
+
if num_str:
|
|
182
|
+
parts.append(int(num_str))
|
|
183
|
+
|
|
184
|
+
# Ensure at least 3 parts for comparison
|
|
185
|
+
while len(parts) < 3:
|
|
186
|
+
parts.append(0)
|
|
187
|
+
|
|
188
|
+
return (tuple(parts), prerelease)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _compare_versions(v1: str, v2: str) -> int:
|
|
192
|
+
"""
|
|
193
|
+
Compare two versions with proper pre-release handling.
|
|
194
|
+
|
|
195
|
+
Pre-release versions (dev, alpha, beta, rc) are LESS than the final release.
|
|
196
|
+
Example: 1.0.0rc1 < 1.0.0 < 1.0.1
|
|
197
|
+
|
|
198
|
+
Returns:
|
|
199
|
+
-1 if v1 < v2
|
|
200
|
+
0 if v1 == v2
|
|
201
|
+
1 if v1 > v2
|
|
202
|
+
"""
|
|
203
|
+
parts1, pre1 = _parse_version(v1)
|
|
204
|
+
parts2, pre2 = _parse_version(v2)
|
|
205
|
+
|
|
206
|
+
# Compare numeric parts first
|
|
207
|
+
if parts1 != parts2:
|
|
208
|
+
return (parts1 > parts2) - (parts1 < parts2)
|
|
209
|
+
|
|
210
|
+
# Same numeric version - check pre-release status
|
|
211
|
+
# Final release (no prerelease) > any prerelease
|
|
212
|
+
if pre1 is None and pre2 is None:
|
|
213
|
+
return 0
|
|
214
|
+
if pre1 is None:
|
|
215
|
+
return 1 # v1 is final release, v2 is prerelease -> v1 > v2
|
|
216
|
+
if pre2 is None:
|
|
217
|
+
return -1 # v1 is prerelease, v2 is final release -> v1 < v2
|
|
218
|
+
|
|
219
|
+
# Both are prereleases - compare them
|
|
220
|
+
return (pre1 > pre2) - (pre1 < pre2)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _detect_install_method() -> str:
|
|
224
|
+
"""
|
|
225
|
+
Detect how the package was installed by checking the environment context.
|
|
226
|
+
|
|
227
|
+
Use sys.prefix, environment variables, and path patterns to determine
|
|
228
|
+
the actual install method, not just which tools exist on the system.
|
|
229
|
+
|
|
230
|
+
Returns one of: 'pipx', 'uv', 'pip', 'editable'
|
|
231
|
+
"""
|
|
232
|
+
# Check for editable install first (development mode)
|
|
233
|
+
try:
|
|
234
|
+
from importlib.metadata import distribution
|
|
235
|
+
|
|
236
|
+
dist = distribution(PACKAGE_NAME)
|
|
237
|
+
# PEP 610: Check for editable install via direct_url.json
|
|
238
|
+
direct_url_text = dist.read_text("direct_url.json")
|
|
239
|
+
if direct_url_text:
|
|
240
|
+
import json as json_mod
|
|
241
|
+
|
|
242
|
+
direct_url = json_mod.loads(direct_url_text)
|
|
243
|
+
if direct_url.get("dir_info", {}).get("editable", False):
|
|
244
|
+
return "editable"
|
|
245
|
+
except Exception:
|
|
246
|
+
pass
|
|
247
|
+
|
|
248
|
+
# Get the prefix path where this Python is installed
|
|
249
|
+
prefix = sys.prefix.lower()
|
|
250
|
+
|
|
251
|
+
# Check for pipx environment (pipx creates venvs in specific locations)
|
|
252
|
+
# Common patterns: ~/.local/pipx/venvs/, ~/.local/share/pipx/venvs/
|
|
253
|
+
pipx_indicators = [
|
|
254
|
+
"pipx/venvs",
|
|
255
|
+
"pipx\\venvs", # Windows
|
|
256
|
+
os.environ.get("PIPX_HOME", ""),
|
|
257
|
+
os.environ.get("PIPX_LOCAL_VENVS", ""),
|
|
258
|
+
]
|
|
259
|
+
if any(ind and ind.lower() in prefix for ind in pipx_indicators if ind):
|
|
260
|
+
return "pipx"
|
|
261
|
+
|
|
262
|
+
# Check for uv tool install (CLI tools installed via `uv tool install`)
|
|
263
|
+
# These are in ~/.local/share/uv/tools/ or $UV_TOOL_DIR
|
|
264
|
+
uv_tool_indicators = [
|
|
265
|
+
"uv/tools",
|
|
266
|
+
"uv\\tools", # Windows
|
|
267
|
+
os.environ.get("UV_TOOL_DIR", ""),
|
|
268
|
+
]
|
|
269
|
+
if any(ind and ind.lower() in prefix for ind in uv_tool_indicators if ind):
|
|
270
|
+
return "uv_tool"
|
|
271
|
+
|
|
272
|
+
# Check for uv environment (regular uv pip install in venv)
|
|
273
|
+
# uv uses UV_PYTHON_INSTALL_DIR and creates venvs differently
|
|
274
|
+
uv_indicators = [
|
|
275
|
+
os.environ.get("UV_PYTHON_INSTALL_DIR", ""),
|
|
276
|
+
os.environ.get("UV_CACHE_DIR", ""),
|
|
277
|
+
]
|
|
278
|
+
# uv environments often have .uv in the path or UV env vars set
|
|
279
|
+
if ".uv" in prefix or any(ind for ind in uv_indicators if ind):
|
|
280
|
+
return "uv"
|
|
281
|
+
|
|
282
|
+
# Check if uv is available and likely the preferred tool
|
|
283
|
+
# (only if we can't detect pipx context)
|
|
284
|
+
if shutil.which("uv"):
|
|
285
|
+
return "uv"
|
|
286
|
+
|
|
287
|
+
# Check if pipx is available as fallback
|
|
288
|
+
if shutil.which("pipx"):
|
|
289
|
+
return "pipx"
|
|
290
|
+
|
|
291
|
+
# Default to pip
|
|
292
|
+
return "pip"
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def get_update_command(method: str) -> str:
|
|
296
|
+
"""
|
|
297
|
+
Return the appropriate update command for the given install method.
|
|
298
|
+
|
|
299
|
+
Args:
|
|
300
|
+
method: One of 'pipx', 'uv_tool', 'uv', 'pip', 'editable'
|
|
301
|
+
|
|
302
|
+
Returns:
|
|
303
|
+
Shell command to run for updating
|
|
304
|
+
"""
|
|
305
|
+
if method == "pipx":
|
|
306
|
+
return f"pipx upgrade {PACKAGE_NAME}"
|
|
307
|
+
elif method == "uv_tool":
|
|
308
|
+
return f"uv tool upgrade {PACKAGE_NAME}"
|
|
309
|
+
elif method == "uv":
|
|
310
|
+
return f"uv pip install --upgrade {PACKAGE_NAME}"
|
|
311
|
+
else:
|
|
312
|
+
return f"pip install --upgrade {PACKAGE_NAME}"
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
316
|
+
# Update Check Throttling
|
|
317
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _load_update_check_meta() -> dict[Any, Any]:
|
|
321
|
+
"""Load and return update check metadata (timestamps for throttling)."""
|
|
322
|
+
if not UPDATE_CHECK_META_FILE.exists():
|
|
323
|
+
return {}
|
|
324
|
+
try:
|
|
325
|
+
return cast(dict[Any, Any], json.loads(UPDATE_CHECK_META_FILE.read_text()))
|
|
326
|
+
except (json.JSONDecodeError, OSError):
|
|
327
|
+
return {}
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _save_update_check_meta(meta: dict[str, Any]) -> None:
|
|
331
|
+
"""Save update check metadata to disk."""
|
|
332
|
+
UPDATE_CHECK_CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
|
333
|
+
UPDATE_CHECK_META_FILE.write_text(json.dumps(meta, indent=2))
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _should_check_cli_updates() -> bool:
|
|
337
|
+
"""Return True if enough time has passed since last CLI update check."""
|
|
338
|
+
meta = _load_update_check_meta()
|
|
339
|
+
last_check_str = meta.get("cli_last_check")
|
|
340
|
+
|
|
341
|
+
if not last_check_str:
|
|
342
|
+
return True
|
|
343
|
+
|
|
344
|
+
try:
|
|
345
|
+
last_check = datetime.fromisoformat(last_check_str)
|
|
346
|
+
now = datetime.now(timezone.utc)
|
|
347
|
+
elapsed = now - last_check
|
|
348
|
+
return elapsed > timedelta(hours=CLI_CHECK_INTERVAL_HOURS)
|
|
349
|
+
except (ValueError, TypeError):
|
|
350
|
+
return True
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _mark_cli_check_done() -> None:
|
|
354
|
+
"""Update the timestamp for CLI update check."""
|
|
355
|
+
meta = _load_update_check_meta()
|
|
356
|
+
meta["cli_last_check"] = datetime.now(timezone.utc).isoformat()
|
|
357
|
+
_save_update_check_meta(meta)
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _should_check_org_config() -> bool:
|
|
361
|
+
"""Return True if enough time has passed since last org config check."""
|
|
362
|
+
meta = _load_update_check_meta()
|
|
363
|
+
last_check_str = meta.get("org_config_last_check")
|
|
364
|
+
|
|
365
|
+
if not last_check_str:
|
|
366
|
+
return True
|
|
367
|
+
|
|
368
|
+
try:
|
|
369
|
+
last_check = datetime.fromisoformat(last_check_str)
|
|
370
|
+
now = datetime.now(timezone.utc)
|
|
371
|
+
elapsed = now - last_check
|
|
372
|
+
return elapsed > timedelta(hours=ORG_CONFIG_CHECK_INTERVAL_HOURS)
|
|
373
|
+
except (ValueError, TypeError):
|
|
374
|
+
return True
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _mark_org_config_check_done() -> None:
|
|
378
|
+
"""Update the timestamp for org config check."""
|
|
379
|
+
meta = _load_update_check_meta()
|
|
380
|
+
meta["org_config_last_check"] = datetime.now(timezone.utc).isoformat()
|
|
381
|
+
_save_update_check_meta(meta)
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
385
|
+
# Org Config Update Checking
|
|
386
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def check_org_config_update(
|
|
390
|
+
user_config: dict[str, Any], force: bool = False
|
|
391
|
+
) -> OrgConfigUpdateResult:
|
|
392
|
+
"""
|
|
393
|
+
Check for org config updates using ETag conditional fetch.
|
|
394
|
+
|
|
395
|
+
Handle these scenarios:
|
|
396
|
+
- On corporate network: Fetch org config with auth token, update cache
|
|
397
|
+
- Off VPN (offline): Use cached config, skip update check silently
|
|
398
|
+
- Auth token expired/invalid: Use cached config, show warning
|
|
399
|
+
- Never fetched + offline: Return 'no_cache' status
|
|
400
|
+
|
|
401
|
+
Args:
|
|
402
|
+
user_config: User config dict with organization_source
|
|
403
|
+
force: Force check even if throttle interval hasn't elapsed
|
|
404
|
+
|
|
405
|
+
Returns:
|
|
406
|
+
OrgConfigUpdateResult with status and optional message
|
|
407
|
+
"""
|
|
408
|
+
# Import here to avoid circular imports
|
|
409
|
+
from scc_cli import remote
|
|
410
|
+
|
|
411
|
+
# Standalone mode - no org config to update
|
|
412
|
+
if user_config.get("standalone"):
|
|
413
|
+
return OrgConfigUpdateResult(status="standalone")
|
|
414
|
+
|
|
415
|
+
# No organization source configured
|
|
416
|
+
org_source = user_config.get("organization_source")
|
|
417
|
+
if not org_source:
|
|
418
|
+
return OrgConfigUpdateResult(status="standalone")
|
|
419
|
+
|
|
420
|
+
url = org_source.get("url")
|
|
421
|
+
if not url:
|
|
422
|
+
return OrgConfigUpdateResult(status="standalone")
|
|
423
|
+
|
|
424
|
+
auth_spec = org_source.get("auth")
|
|
425
|
+
|
|
426
|
+
# Check throttle (unless forced)
|
|
427
|
+
if not force and not _should_check_org_config():
|
|
428
|
+
# Return early - too soon to check
|
|
429
|
+
return OrgConfigUpdateResult(status="throttled")
|
|
430
|
+
|
|
431
|
+
# Try to load existing cache
|
|
432
|
+
cached_config, meta = remote.load_from_cache()
|
|
433
|
+
|
|
434
|
+
# Calculate cache age if available
|
|
435
|
+
cached_age_hours = None
|
|
436
|
+
if meta and meta.get("org_config", {}).get("fetched_at"):
|
|
437
|
+
try:
|
|
438
|
+
fetched_at = datetime.fromisoformat(meta["org_config"]["fetched_at"])
|
|
439
|
+
now = datetime.now(timezone.utc)
|
|
440
|
+
cached_age_hours = (now - fetched_at).total_seconds() / 3600
|
|
441
|
+
except (ValueError, TypeError):
|
|
442
|
+
pass
|
|
443
|
+
|
|
444
|
+
# Resolve auth
|
|
445
|
+
auth = remote.resolve_auth(auth_spec) if auth_spec else None
|
|
446
|
+
|
|
447
|
+
# Get cached ETag for conditional request
|
|
448
|
+
etag = meta.get("org_config", {}).get("etag") if meta else None
|
|
449
|
+
|
|
450
|
+
# Attempt to fetch with ETag
|
|
451
|
+
try:
|
|
452
|
+
config, new_etag, status = remote.fetch_org_config(url, auth=auth, etag=etag)
|
|
453
|
+
except Exception:
|
|
454
|
+
# Network error - use cache silently
|
|
455
|
+
_mark_org_config_check_done()
|
|
456
|
+
if cached_config:
|
|
457
|
+
return OrgConfigUpdateResult(
|
|
458
|
+
status="offline",
|
|
459
|
+
cached_age_hours=cached_age_hours,
|
|
460
|
+
)
|
|
461
|
+
return OrgConfigUpdateResult(status="no_cache")
|
|
462
|
+
|
|
463
|
+
# Mark check as done
|
|
464
|
+
_mark_org_config_check_done()
|
|
465
|
+
|
|
466
|
+
# 304 Not Modified - cache is current
|
|
467
|
+
if status == 304:
|
|
468
|
+
return OrgConfigUpdateResult(
|
|
469
|
+
status="unchanged",
|
|
470
|
+
cached_age_hours=cached_age_hours,
|
|
471
|
+
)
|
|
472
|
+
|
|
473
|
+
# 200 OK - new config available
|
|
474
|
+
if status == 200 and config is not None:
|
|
475
|
+
# Save to cache
|
|
476
|
+
ttl_hours = config.get("defaults", {}).get("cache_ttl_hours", 24)
|
|
477
|
+
remote.save_to_cache(config, url, new_etag, ttl_hours)
|
|
478
|
+
return OrgConfigUpdateResult(
|
|
479
|
+
status="updated",
|
|
480
|
+
message="Organization config updated from remote",
|
|
481
|
+
)
|
|
482
|
+
|
|
483
|
+
# 401/403 - auth failed
|
|
484
|
+
if status in (401, 403):
|
|
485
|
+
if cached_config:
|
|
486
|
+
return OrgConfigUpdateResult(
|
|
487
|
+
status="auth_failed",
|
|
488
|
+
message="Auth failed for org config, using cached version",
|
|
489
|
+
cached_age_hours=cached_age_hours,
|
|
490
|
+
)
|
|
491
|
+
return OrgConfigUpdateResult(
|
|
492
|
+
status="auth_failed",
|
|
493
|
+
message="Auth failed and no cached config available",
|
|
494
|
+
)
|
|
495
|
+
|
|
496
|
+
# Other errors - use cache if available
|
|
497
|
+
if cached_config:
|
|
498
|
+
return OrgConfigUpdateResult(
|
|
499
|
+
status="offline",
|
|
500
|
+
cached_age_hours=cached_age_hours,
|
|
501
|
+
)
|
|
502
|
+
|
|
503
|
+
return OrgConfigUpdateResult(status="no_cache")
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
507
|
+
# Combined Update Check
|
|
508
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def check_all_updates(user_config: dict[str, Any], force: bool = False) -> UpdateCheckResult:
|
|
512
|
+
"""
|
|
513
|
+
Check for all available updates (CLI and org config).
|
|
514
|
+
|
|
515
|
+
Use this as the main entry point for update checking.
|
|
516
|
+
|
|
517
|
+
Args:
|
|
518
|
+
user_config: User config dict
|
|
519
|
+
force: Force checks even if throttle intervals haven't elapsed
|
|
520
|
+
|
|
521
|
+
Returns:
|
|
522
|
+
UpdateCheckResult with CLI and org config update info
|
|
523
|
+
"""
|
|
524
|
+
result = UpdateCheckResult()
|
|
525
|
+
|
|
526
|
+
# Check CLI updates (throttled)
|
|
527
|
+
if force or _should_check_cli_updates():
|
|
528
|
+
result.cli_update = check_for_updates()
|
|
529
|
+
_mark_cli_check_done()
|
|
530
|
+
|
|
531
|
+
# Check org config updates (throttled)
|
|
532
|
+
result.org_config = check_org_config_update(user_config, force=force)
|
|
533
|
+
|
|
534
|
+
return result
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
538
|
+
# UX-Friendly Console Output
|
|
539
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def render_update_notification(console: Console, result: UpdateCheckResult) -> None:
|
|
543
|
+
"""
|
|
544
|
+
Render update notifications in a UX-friendly way.
|
|
545
|
+
|
|
546
|
+
Design principles:
|
|
547
|
+
- Non-intrusive: Use a single line for most cases
|
|
548
|
+
- Actionable: Show exact command to run
|
|
549
|
+
- Quiet on success: Produce no noise when everything is current
|
|
550
|
+
|
|
551
|
+
Args:
|
|
552
|
+
console: Rich Console instance
|
|
553
|
+
result: UpdateCheckResult from check_all_updates()
|
|
554
|
+
"""
|
|
555
|
+
# CLI update notification
|
|
556
|
+
if result.cli_update and result.cli_update.update_available:
|
|
557
|
+
cli = result.cli_update
|
|
558
|
+
update_cmd = get_update_command(cli.install_method)
|
|
559
|
+
console.print(
|
|
560
|
+
f"[cyan]⬆ Update available:[/cyan] "
|
|
561
|
+
f"scc-cli [dim]{cli.current}[/dim] → [green]{cli.latest}[/green] "
|
|
562
|
+
f"[dim]Run: {update_cmd}[/dim]"
|
|
563
|
+
)
|
|
564
|
+
|
|
565
|
+
# Org config notifications (only show warnings/errors)
|
|
566
|
+
if result.org_config:
|
|
567
|
+
org = result.org_config
|
|
568
|
+
|
|
569
|
+
if org.status == "updated":
|
|
570
|
+
console.print("[green]✓[/green] Organization config updated")
|
|
571
|
+
|
|
572
|
+
elif org.status == "auth_failed" and org.cached_age_hours is not None:
|
|
573
|
+
age_str = _format_age(org.cached_age_hours)
|
|
574
|
+
console.print(
|
|
575
|
+
f"[yellow]⚠ Auth failed for org config, using cached version ({age_str} old)[/yellow]"
|
|
576
|
+
)
|
|
577
|
+
|
|
578
|
+
elif org.status == "auth_failed":
|
|
579
|
+
console.print("[red]✗ Auth failed and no cached config available. Run: scc setup[/red]")
|
|
580
|
+
|
|
581
|
+
elif org.status == "no_cache":
|
|
582
|
+
console.print(
|
|
583
|
+
"[yellow]⚠ No organization config cached. Run: scc setup when on network[/yellow]"
|
|
584
|
+
)
|
|
585
|
+
|
|
586
|
+
# Don't show anything for 'unchanged', 'offline', 'standalone', 'throttled'
|
|
587
|
+
# - These are normal states that don't need user attention
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
def render_update_status_panel(console: Console, result: UpdateCheckResult) -> None:
|
|
591
|
+
"""
|
|
592
|
+
Render a detailed update status panel for the `scc update` command.
|
|
593
|
+
|
|
594
|
+
Args:
|
|
595
|
+
console: Rich Console instance
|
|
596
|
+
result: UpdateCheckResult from check_all_updates()
|
|
597
|
+
"""
|
|
598
|
+
lines = []
|
|
599
|
+
|
|
600
|
+
# CLI Version section
|
|
601
|
+
lines.append("[bold]CLI Version[/bold]")
|
|
602
|
+
if result.cli_update:
|
|
603
|
+
cli = result.cli_update
|
|
604
|
+
if cli.update_available:
|
|
605
|
+
lines.append(f" Current: {cli.current}")
|
|
606
|
+
lines.append(f" Latest: [green]{cli.latest}[/green] [cyan](update available)[/cyan]")
|
|
607
|
+
lines.append(f" Update: [dim]{get_update_command(cli.install_method)}[/dim]")
|
|
608
|
+
else:
|
|
609
|
+
lines.append(f" [green]✓[/green] {cli.current} (up to date)")
|
|
610
|
+
else:
|
|
611
|
+
lines.append(" [dim]Not checked (throttled)[/dim]")
|
|
612
|
+
|
|
613
|
+
lines.append("")
|
|
614
|
+
|
|
615
|
+
# Org Config section
|
|
616
|
+
lines.append("[bold]Organization Config[/bold]")
|
|
617
|
+
if result.org_config:
|
|
618
|
+
org = result.org_config
|
|
619
|
+
|
|
620
|
+
if org.status == "standalone":
|
|
621
|
+
lines.append(" [dim]Standalone mode (no org config)[/dim]")
|
|
622
|
+
|
|
623
|
+
elif org.status == "updated":
|
|
624
|
+
lines.append(" [green]✓[/green] Updated from remote")
|
|
625
|
+
|
|
626
|
+
elif org.status == "unchanged":
|
|
627
|
+
if org.cached_age_hours is not None:
|
|
628
|
+
age_str = _format_age(org.cached_age_hours)
|
|
629
|
+
lines.append(f" [green]✓[/green] Current (cached {age_str} ago)")
|
|
630
|
+
else:
|
|
631
|
+
lines.append(" [green]✓[/green] Current (unchanged)")
|
|
632
|
+
|
|
633
|
+
elif org.status == "offline":
|
|
634
|
+
if org.cached_age_hours is not None:
|
|
635
|
+
age_str = _format_age(org.cached_age_hours)
|
|
636
|
+
lines.append(f" [yellow]⚠[/yellow] Using cached config ({age_str} old)")
|
|
637
|
+
lines.append(" [dim]Remote check failed (offline?)[/dim]")
|
|
638
|
+
else:
|
|
639
|
+
lines.append(" [yellow]⚠[/yellow] Offline, using cached config")
|
|
640
|
+
|
|
641
|
+
elif org.status == "auth_failed":
|
|
642
|
+
if org.cached_age_hours is not None:
|
|
643
|
+
age_str = _format_age(org.cached_age_hours)
|
|
644
|
+
lines.append(f" [yellow]⚠[/yellow] Auth failed, using cached ({age_str} old)")
|
|
645
|
+
else:
|
|
646
|
+
lines.append(" [red]✗[/red] Auth failed, no cache available")
|
|
647
|
+
lines.append(" [dim]Check your auth token or run: scc setup[/dim]")
|
|
648
|
+
|
|
649
|
+
elif org.status == "no_cache":
|
|
650
|
+
lines.append(" [red]✗[/red] No cached config available")
|
|
651
|
+
lines.append(" [dim]Run: scc setup when on network[/dim]")
|
|
652
|
+
|
|
653
|
+
elif org.status == "throttled":
|
|
654
|
+
lines.append(" [dim]Not checked (throttled)[/dim]")
|
|
655
|
+
else:
|
|
656
|
+
lines.append(" [dim]Not checked[/dim]")
|
|
657
|
+
|
|
658
|
+
panel = Panel(
|
|
659
|
+
"\n".join(lines),
|
|
660
|
+
title="[bold]Update Status[/bold]",
|
|
661
|
+
border_style="blue",
|
|
662
|
+
padding=(0, 1),
|
|
663
|
+
)
|
|
664
|
+
|
|
665
|
+
console.print()
|
|
666
|
+
console.print(panel)
|
|
667
|
+
console.print()
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
def _format_age(hours: float) -> str:
|
|
671
|
+
"""Format an age in hours as a human-readable string."""
|
|
672
|
+
if hours < 1:
|
|
673
|
+
minutes = int(hours * 60)
|
|
674
|
+
return f"{minutes} minute{'s' if minutes != 1 else ''}"
|
|
675
|
+
elif hours < 24:
|
|
676
|
+
h = int(hours)
|
|
677
|
+
return f"{h} hour{'s' if h != 1 else ''}"
|
|
678
|
+
else:
|
|
679
|
+
days = int(hours / 24)
|
|
680
|
+
return f"{days} day{'s' if days != 1 else ''}"
|