mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 14:25:08 +00:00
refactor(plugins): split PluginStoreManager into mixins (#659)
* refactor(plugins): split PluginStoreManager into mixins src/plugin_system/store_manager.py (2,977 lines) keeps the class, its shared state, locks, the uninstall registry, directory lookup and uninstall; its methods are split by area into: - store_registry.py (_RegistryMixin): registry, GitHub metadata, search, manifest validation - store_install.py (_InstallMixin): install paths and dependencies - store_update.py (_UpdateMixin): updates, rollback, local git state Pure move: all 56 members are byte-identical (checked with ast) and the assembled class has exactly the same attributes as before (checked at runtime). PluginStoreManager is imported from store_manager.py as before. Tests that patched shared modules (subprocess, requests, tempfile, shutil) through store_manager now reach them through the module whose code they exercise; a source-text contract test reads all store_*.py modules. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore: annotate findings the split moved into new store modules subprocess imports and a list-form git clone (no shell), and the config template's placeholder token string -- existing code that Codacy reported as new because it moved. Annotated with the repo's nosec/nosemgrep style. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore: annotate the default-branch git clone the split moved Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
File diff suppressed because it is too large
Load Diff
+17
-2517
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,852 @@
|
||||
"""Plugin store: the plugin registry, GitHub metadata, search and manifest
|
||||
validation.
|
||||
|
||||
Part of PluginStoreManager (store_manager.py), which mixes this class in;
|
||||
methods reach shared state and helpers through ``self``.
|
||||
"""
|
||||
|
||||
import json
|
||||
import requests
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Optional, Any
|
||||
from jsonschema import Draft7Validator, ValidationError
|
||||
from src.plugin_system.repo_urls import (
|
||||
github_api_headers, github_owner_repo, normalize_repo_url,
|
||||
)
|
||||
|
||||
|
||||
class _RegistryMixin:
|
||||
"""PluginStoreManager methods: see the module docstring."""
|
||||
|
||||
def _load_github_token(self) -> Optional[str]:
|
||||
"""
|
||||
Load GitHub API token from config_secrets.json if available.
|
||||
|
||||
Returns:
|
||||
GitHub token or None if not configured
|
||||
"""
|
||||
try:
|
||||
config_path = Path(__file__).parent.parent.parent / "config" / "config_secrets.json"
|
||||
if config_path.exists():
|
||||
with open(config_path, 'r', encoding='utf-8') as f:
|
||||
config = json.load(f)
|
||||
token = config.get('github', {}).get('api_token', '').strip()
|
||||
# The config template's placeholder, not a credential.
|
||||
if token and token != "YOUR_GITHUB_PERSONAL_ACCESS_TOKEN": # nosec B105 # nosemgrep
|
||||
return token
|
||||
except Exception as e:
|
||||
self.logger.debug(f"Could not load GitHub token: {e}")
|
||||
return None
|
||||
|
||||
def _validate_github_token(self, token: str) -> tuple[bool, Optional[str]]:
|
||||
"""
|
||||
Validate a GitHub token by making a lightweight API call.
|
||||
|
||||
Args:
|
||||
token: GitHub personal access token to validate
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, error_message)
|
||||
- is_valid: True if token is valid, False otherwise
|
||||
- error_message: None if valid, error description if invalid
|
||||
"""
|
||||
if not token:
|
||||
return (False, "No token provided")
|
||||
|
||||
# Check cache first
|
||||
cache_key = token[:10] # Use first 10 chars as cache key for privacy
|
||||
if cache_key in self._token_validation_cache:
|
||||
cached_valid, cached_time, cached_error = self._token_validation_cache[cache_key]
|
||||
if time.time() - cached_time < self._token_validation_cache_timeout:
|
||||
return (cached_valid, cached_error)
|
||||
|
||||
# Validate token by making a lightweight API call to /user endpoint
|
||||
try:
|
||||
api_url = "https://api.github.com/user"
|
||||
response = requests.get(api_url, headers=github_api_headers(token), timeout=5)
|
||||
|
||||
if response.status_code == 200:
|
||||
# Token is valid
|
||||
result = (True, None)
|
||||
self._token_validation_cache[cache_key] = (True, time.time(), None)
|
||||
return result
|
||||
elif response.status_code == 401:
|
||||
# Token is invalid or expired
|
||||
error_msg = "Token is invalid or expired"
|
||||
result = (False, error_msg)
|
||||
self._token_validation_cache[cache_key] = (False, time.time(), error_msg)
|
||||
return result
|
||||
elif response.status_code == 403:
|
||||
# Rate limit or forbidden (but token might be valid)
|
||||
# Check if it's a rate limit issue
|
||||
if 'rate limit' in response.text.lower():
|
||||
# Rate limit: return error but don't cache (rate limits are temporary)
|
||||
error_msg = "Rate limit exceeded"
|
||||
result = (False, error_msg)
|
||||
return result
|
||||
else:
|
||||
# Token lacks permissions: cache the result (permissions don't change)
|
||||
error_msg = "Token lacks required permissions"
|
||||
result = (False, error_msg)
|
||||
self._token_validation_cache[cache_key] = (False, time.time(), error_msg)
|
||||
return result
|
||||
else:
|
||||
# Other error
|
||||
error_msg = f"GitHub API error: {response.status_code}"
|
||||
result = (False, error_msg)
|
||||
self._token_validation_cache[cache_key] = (False, time.time(), error_msg)
|
||||
return result
|
||||
|
||||
except requests.exceptions.Timeout:
|
||||
error_msg = "GitHub API request timed out"
|
||||
result = (False, error_msg)
|
||||
# Don't cache timeout errors
|
||||
return result
|
||||
except requests.exceptions.RequestException as e:
|
||||
error_msg = f"Network error: {str(e)}"
|
||||
result = (False, error_msg)
|
||||
# Don't cache network errors
|
||||
return result
|
||||
except Exception as e:
|
||||
error_msg = f"Unexpected error: {str(e)}"
|
||||
result = (False, error_msg)
|
||||
# Don't cache unexpected errors
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _iso_to_date(iso_timestamp: str) -> str:
|
||||
"""Convert an ISO timestamp to YYYY-MM-DD string."""
|
||||
if not iso_timestamp:
|
||||
return ""
|
||||
|
||||
try:
|
||||
dt = datetime.fromisoformat(iso_timestamp.replace('Z', '+00:00'))
|
||||
return dt.strftime('%Y-%m-%d')
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _distinct_sequence(values: List[str]) -> List[str]:
|
||||
"""Return list preserving order while removing duplicates and falsey entries."""
|
||||
seen = set()
|
||||
ordered = []
|
||||
for value in values:
|
||||
if not value:
|
||||
continue
|
||||
if value in seen:
|
||||
continue
|
||||
seen.add(value)
|
||||
ordered.append(value)
|
||||
return ordered
|
||||
|
||||
def _validate_manifest_version_fields(self, manifest: Dict[str, Any]) -> List[str]:
|
||||
"""
|
||||
Validate version-related fields in manifest for consistency.
|
||||
|
||||
Checks:
|
||||
- compatible_versions is present and is an array
|
||||
- Standardized field names are used (min_ledmatrix_version, max_ledmatrix_version)
|
||||
- Deprecated fields are not used (ledmatrix_version)
|
||||
- versions array entries use ledmatrix_min_version instead of ledmatrix_min
|
||||
|
||||
Args:
|
||||
manifest: Manifest dictionary to validate
|
||||
|
||||
Returns:
|
||||
List of validation error/warning messages (empty if valid)
|
||||
"""
|
||||
errors = []
|
||||
|
||||
# Check compatible_versions is an array
|
||||
if 'compatible_versions' in manifest:
|
||||
if not isinstance(manifest['compatible_versions'], list):
|
||||
errors.append("compatible_versions must be an array")
|
||||
elif len(manifest['compatible_versions']) == 0:
|
||||
errors.append("compatible_versions array cannot be empty")
|
||||
|
||||
# Warn about deprecated ledmatrix_version field
|
||||
if 'ledmatrix_version' in manifest:
|
||||
errors.append("ledmatrix_version is deprecated, use compatible_versions instead")
|
||||
|
||||
# Check versions array entries use standardized field names
|
||||
if 'versions' in manifest and isinstance(manifest['versions'], list):
|
||||
for i, version_entry in enumerate(manifest['versions']):
|
||||
if not isinstance(version_entry, dict):
|
||||
continue
|
||||
|
||||
# Check for old ledmatrix_min field
|
||||
if 'ledmatrix_min' in version_entry and 'ledmatrix_min_version' not in version_entry:
|
||||
errors.append(f"versions[{i}] uses deprecated 'ledmatrix_min', should use 'ledmatrix_min_version'")
|
||||
|
||||
return errors
|
||||
|
||||
def _validate_manifest_schema(self, manifest: Dict[str, Any], plugin_id: str) -> List[str]:
|
||||
"""
|
||||
Validate manifest against JSON schema if available.
|
||||
|
||||
Args:
|
||||
manifest: Manifest dictionary to validate
|
||||
plugin_id: Plugin ID for error messages
|
||||
|
||||
Returns:
|
||||
List of validation error messages (empty if valid or schema unavailable)
|
||||
"""
|
||||
try:
|
||||
# Load manifest schema
|
||||
schema_path = Path(__file__).parent.parent.parent / "schema" / "manifest_schema.json"
|
||||
if not schema_path.exists():
|
||||
return [] # Schema not available, skip validation
|
||||
|
||||
with open(schema_path, 'r', encoding='utf-8') as f:
|
||||
schema = json.load(f)
|
||||
|
||||
# Validate schema itself
|
||||
Draft7Validator.check_schema(schema)
|
||||
|
||||
# Validate manifest against schema
|
||||
validator = Draft7Validator(schema)
|
||||
errors = []
|
||||
for error in validator.iter_errors(manifest):
|
||||
error_path = '.'.join(str(p) for p in error.path)
|
||||
errors.append(f"{error_path}: {error.message}")
|
||||
|
||||
return errors
|
||||
except json.JSONDecodeError as e:
|
||||
self.logger.warning(f"Could not parse manifest schema: {e}")
|
||||
return []
|
||||
except ValidationError as e:
|
||||
self.logger.warning(f"Manifest schema is invalid: {e}")
|
||||
return []
|
||||
except Exception as e:
|
||||
self.logger.debug(f"Error validating manifest schema for {plugin_id}: {e}")
|
||||
return []
|
||||
|
||||
_EMPTY_REPO_INFO: Dict[str, Any] = {
|
||||
'stars': 0,
|
||||
'forks': 0,
|
||||
'open_issues': 0,
|
||||
'updated_at_iso': '',
|
||||
'last_commit_iso': '',
|
||||
'last_commit_date': '',
|
||||
'language': '',
|
||||
'license': '',
|
||||
'default_branch': 'main',
|
||||
}
|
||||
|
||||
def _get_github_repo_info(self, repo_url: str) -> Dict[str, Any]:
|
||||
"""GitHub metadata for a repository (stars, default branch, last push).
|
||||
|
||||
Returns zeroed defaults (``_EMPTY_REPO_INFO``) for a non-GitHub URL or
|
||||
when GitHub cannot be asked and nothing is cached.
|
||||
"""
|
||||
try:
|
||||
owner_repo = github_owner_repo(repo_url)
|
||||
if owner_repo is None:
|
||||
return dict(self._EMPTY_REPO_INFO)
|
||||
owner, repo = owner_repo
|
||||
cache_key = f"{owner}/{repo}"
|
||||
|
||||
if cache_key in self.github_cache:
|
||||
cached_time, cached_data = self.github_cache[cache_key]
|
||||
if time.time() - cached_time < self.cache_timeout:
|
||||
return cached_data
|
||||
|
||||
api_url = f"https://api.github.com/repos/{owner}/{repo}"
|
||||
try:
|
||||
response = requests.get(
|
||||
api_url, headers=github_api_headers(self.github_token), timeout=10)
|
||||
except requests.RequestException as req_err:
|
||||
# Network error: prefer a stale cache hit over an empty
|
||||
# default so the UI keeps working on a flaky Pi WiFi link.
|
||||
# Bump the cached entry's timestamp into a short backoff
|
||||
# window so subsequent requests serve the stale payload
|
||||
# cheaply instead of re-hitting the network on every request.
|
||||
if cache_key in self.github_cache:
|
||||
_, stale = self.github_cache[cache_key]
|
||||
self._record_cache_backoff(self.github_cache, cache_key, self.cache_timeout, stale)
|
||||
self.logger.warning(
|
||||
"GitHub repo info fetch failed for %s (%s); serving stale cache.",
|
||||
cache_key, req_err,
|
||||
)
|
||||
return stale
|
||||
raise
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
pushed_at = data.get('pushed_at', '') or data.get('updated_at', '')
|
||||
repo_info = {
|
||||
'stars': data.get('stargazers_count', 0),
|
||||
'forks': data.get('forks_count', 0),
|
||||
'open_issues': data.get('open_issues_count', 0),
|
||||
'updated_at_iso': data.get('updated_at', ''),
|
||||
'last_commit_iso': pushed_at,
|
||||
'last_commit_date': self._iso_to_date(pushed_at),
|
||||
'language': data.get('language', ''),
|
||||
'license': data.get('license', {}).get('name', '') if data.get('license') else '',
|
||||
'default_branch': data.get('default_branch', 'main')
|
||||
}
|
||||
self.github_cache[cache_key] = (time.time(), repo_info)
|
||||
return repo_info
|
||||
|
||||
if response.status_code == 403:
|
||||
# Rate limit or authentication issue. A stale star count is
|
||||
# better than a reset to zero, and the backoff bump stops the
|
||||
# store hammering the API while rate-limited.
|
||||
if cache_key in self.github_cache:
|
||||
_, stale = self.github_cache[cache_key]
|
||||
self._record_cache_backoff(self.github_cache, cache_key, self.cache_timeout, stale)
|
||||
self.logger.warning(
|
||||
"GitHub API 403 for %s; serving stale cache.", cache_key,
|
||||
)
|
||||
return stale
|
||||
if not self.github_token:
|
||||
self.logger.warning(
|
||||
"GitHub API rate limit likely exceeded (403). "
|
||||
"Add a GitHub personal access token to config/config_secrets.json "
|
||||
"under 'github.api_token' to increase rate limits from 60 to 5000/hour."
|
||||
)
|
||||
else:
|
||||
self.logger.warning(
|
||||
f"GitHub API request failed: 403 for {api_url}. "
|
||||
f"Your token may have insufficient permissions or rate limit exceeded."
|
||||
)
|
||||
else:
|
||||
self.logger.warning(f"GitHub API request failed: {response.status_code} for {api_url}")
|
||||
if cache_key in self.github_cache:
|
||||
_, stale = self.github_cache[cache_key]
|
||||
self._record_cache_backoff(self.github_cache, cache_key, self.cache_timeout, stale)
|
||||
return stale
|
||||
|
||||
return dict(self._EMPTY_REPO_INFO)
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
# Offline, DNS or a timeout reaching GitHub: the listing still
|
||||
# works without the extra repo info, so this is not an error.
|
||||
self.logger.warning("GitHub repo info unavailable for %s: %s", repo_url, e)
|
||||
return dict(self._EMPTY_REPO_INFO)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error fetching GitHub repo info for {repo_url}: {e}")
|
||||
return dict(self._EMPTY_REPO_INFO)
|
||||
|
||||
def _http_get_with_retries(self, url: str, *, timeout: int = 10, stream: bool = False, headers: Dict[str, str] = None, max_retries: int = 3, backoff_sec: float = 0.75):
|
||||
"""
|
||||
HTTP GET with simple retry strategy and exponential backoff.
|
||||
|
||||
Returns a requests.Response or raises the last exception.
|
||||
"""
|
||||
last_exc = None
|
||||
for attempt in range(1, max_retries + 1):
|
||||
try:
|
||||
resp = requests.get(url, timeout=timeout, stream=stream, headers=headers)
|
||||
return resp
|
||||
except requests.RequestException as e:
|
||||
last_exc = e
|
||||
self.logger.warning(f"HTTP GET failed (attempt {attempt}/{max_retries}) for {url}: {e}")
|
||||
if attempt < max_retries:
|
||||
time.sleep(backoff_sec * attempt)
|
||||
# Exhausted retries
|
||||
raise last_exc
|
||||
|
||||
def fetch_registry_from_url(self, repo_url: str) -> Optional[Dict]:
|
||||
"""
|
||||
Fetch a registry-style plugins.json from a custom GitHub repository URL.
|
||||
|
||||
This allows users to point to a registry-style monorepo (like the official
|
||||
ledmatrix-plugins repo) and browse/install plugins from it.
|
||||
|
||||
Args:
|
||||
repo_url: GitHub repository URL (e.g., https://github.com/user/ledmatrix-plugins)
|
||||
|
||||
Returns:
|
||||
Registry dict with plugins list, or None if not found/invalid
|
||||
"""
|
||||
try:
|
||||
repo_url = normalize_repo_url(repo_url)
|
||||
|
||||
# plugins.json or registry.json at the root of main, then master.
|
||||
registry_urls = []
|
||||
owner_repo = github_owner_repo(repo_url)
|
||||
if owner_repo is not None:
|
||||
owner, repo = owner_repo
|
||||
for branch in ['main', 'master']:
|
||||
registry_urls.append(f"https://raw.githubusercontent.com/{owner}/{repo}/{branch}/plugins.json")
|
||||
registry_urls.append(f"https://raw.githubusercontent.com/{owner}/{repo}/{branch}/registry.json")
|
||||
|
||||
for url in registry_urls:
|
||||
try:
|
||||
response = self._http_get_with_retries(url, timeout=10)
|
||||
if response.status_code == 200:
|
||||
registry = response.json()
|
||||
# Validate it looks like a registry
|
||||
if isinstance(registry, dict) and 'plugins' in registry:
|
||||
self.logger.info(f"Successfully fetched registry from {url}")
|
||||
return registry
|
||||
except Exception as e:
|
||||
self.logger.debug(f"Failed to fetch from {url}: {e}")
|
||||
continue
|
||||
|
||||
self.logger.warning(f"No valid registry found at {repo_url}")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error fetching registry from URL: {e}", exc_info=True)
|
||||
return None
|
||||
|
||||
def fetch_registry(self, force_refresh: bool = False, raise_on_failure: bool = False) -> Dict:
|
||||
"""
|
||||
Fetch the plugin registry from GitHub.
|
||||
|
||||
Args:
|
||||
force_refresh: Force refresh even if cached
|
||||
raise_on_failure: If True, re-raise network / JSON errors instead
|
||||
of silently falling back to stale cache / empty dict. UI
|
||||
callers prefer the stale-fallback default so the plugin
|
||||
list keeps working on flaky WiFi; the state reconciler
|
||||
needs the explicit failure signal so it can distinguish
|
||||
"plugin genuinely not in registry" from "I couldn't reach
|
||||
the registry at all" and not mark everything unrecoverable.
|
||||
|
||||
Returns:
|
||||
Registry data with list of available plugins
|
||||
|
||||
Raises:
|
||||
requests.RequestException / json.JSONDecodeError when
|
||||
``raise_on_failure`` is True and the fetch fails.
|
||||
"""
|
||||
# Check if cache is still valid (within timeout)
|
||||
current_time = time.time()
|
||||
if (self.registry_cache and self.registry_cache_time and
|
||||
not force_refresh and
|
||||
(current_time - self.registry_cache_time) < self.registry_cache_timeout):
|
||||
return self.registry_cache
|
||||
|
||||
with self._registry_fetch_lock:
|
||||
# Re-check inside the lock — a concurrent caller that was waiting
|
||||
# may have already populated the cache while we blocked.
|
||||
current_time = time.time()
|
||||
if (self.registry_cache and self.registry_cache_time and
|
||||
not force_refresh and
|
||||
(current_time - self.registry_cache_time) < self.registry_cache_timeout):
|
||||
return self.registry_cache
|
||||
|
||||
try:
|
||||
self.logger.info(f"Fetching plugin registry from {self.REGISTRY_URL}")
|
||||
response = self._http_get_with_retries(self.REGISTRY_URL, timeout=10)
|
||||
response.raise_for_status()
|
||||
self.registry_cache = response.json()
|
||||
self.registry_cache_time = current_time
|
||||
self.logger.info(f"Fetched registry with {len(self.registry_cache.get('plugins', []))} plugins")
|
||||
return self.registry_cache
|
||||
except requests.RequestException as e:
|
||||
self.logger.error(f"Error fetching registry: {e}")
|
||||
if raise_on_failure:
|
||||
raise
|
||||
# Prefer stale cache over an empty list so the plugin list UI
|
||||
# keeps working on a flaky connection (e.g. Pi on WiFi). Bump
|
||||
# registry_cache_time into a short backoff window so the next
|
||||
# request serves the stale payload cheaply instead of
|
||||
# re-hitting the network on every request (matches the
|
||||
# pattern used by github_cache / commit_info_cache).
|
||||
if self.registry_cache:
|
||||
self.logger.warning("Falling back to stale registry cache")
|
||||
self.registry_cache_time = (
|
||||
time.time() + self._failure_backoff_seconds - self.registry_cache_timeout
|
||||
)
|
||||
return self.registry_cache
|
||||
return {"plugins": []}
|
||||
except json.JSONDecodeError as e:
|
||||
self.logger.error(f"Error parsing registry JSON: {e}")
|
||||
if raise_on_failure:
|
||||
raise
|
||||
if self.registry_cache:
|
||||
self.registry_cache_time = (
|
||||
time.time() + self._failure_backoff_seconds - self.registry_cache_timeout
|
||||
)
|
||||
return self.registry_cache
|
||||
return {"plugins": []}
|
||||
|
||||
def search_plugins(self, query: str = "", category: str = "", tags: List[str] = None, fetch_commit_info: bool = True, include_saved_repos: bool = True, saved_repositories_manager = None) -> List[Dict]:
|
||||
"""
|
||||
Search for plugins in the registry with enhanced metadata.
|
||||
|
||||
GitHub supplies live metadata such as stars and last commit
|
||||
timestamps; the registry supplies descriptive information (name,
|
||||
description, repo URL, etc.).
|
||||
|
||||
Args:
|
||||
query: Search query string (searches name, description, id, author)
|
||||
category: Filter by category (e.g., 'sports', 'weather', 'time')
|
||||
tags: Filter by tags (matches any tag in list)
|
||||
fetch_commit_info: If True (default), fetch commit metadata from GitHub.
|
||||
include_saved_repos: If True (default), also search the
|
||||
registry-style repositories the user saved.
|
||||
saved_repositories_manager: The SavedRepositoriesManager holding
|
||||
those repositories; without it only the official registry is
|
||||
searched.
|
||||
|
||||
Returns:
|
||||
List of matching plugin metadata enriched with GitHub information
|
||||
"""
|
||||
if tags is None:
|
||||
tags = []
|
||||
|
||||
# Fetch from official registry
|
||||
registry = self.fetch_registry()
|
||||
plugins = registry.get('plugins', []) or []
|
||||
|
||||
# Also fetch from saved repositories if enabled
|
||||
if include_saved_repos and saved_repositories_manager:
|
||||
saved_repos = saved_repositories_manager.get_registry_repositories()
|
||||
for repo_info in saved_repos:
|
||||
repo_url = repo_info.get('url')
|
||||
if repo_url:
|
||||
try:
|
||||
custom_registry = self.fetch_registry_from_url(repo_url)
|
||||
if custom_registry:
|
||||
custom_plugins = custom_registry.get('plugins', []) or []
|
||||
# Mark these as from custom repository
|
||||
for plugin in custom_plugins:
|
||||
plugin['_source'] = 'custom_repository'
|
||||
plugin['_repository_url'] = repo_url
|
||||
plugin['_repository_name'] = repo_info.get('name', repo_url)
|
||||
plugins.extend(custom_plugins)
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Failed to fetch plugins from saved repository {repo_url}: {e}")
|
||||
|
||||
# First pass: apply cheap filters (category/tags/query) so we only
|
||||
# fetch GitHub metadata for plugins that will actually be returned.
|
||||
filtered: List[Dict] = []
|
||||
for plugin in plugins:
|
||||
if category and plugin.get('category') != category:
|
||||
continue
|
||||
if tags and not any(tag in plugin.get('tags', []) for tag in tags):
|
||||
continue
|
||||
if query:
|
||||
query_lower = query.lower()
|
||||
searchable_text = ' '.join([
|
||||
plugin.get('name', ''),
|
||||
plugin.get('description', ''),
|
||||
plugin.get('id', ''),
|
||||
plugin.get('author', ''),
|
||||
]).lower()
|
||||
if query_lower not in searchable_text:
|
||||
continue
|
||||
filtered.append(plugin)
|
||||
|
||||
def _enrich(plugin: Dict) -> Dict:
|
||||
"""Enrich a single plugin with GitHub metadata.
|
||||
|
||||
Called concurrently from a ThreadPoolExecutor. Both HTTP helpers
|
||||
(``_get_github_repo_info`` / ``_get_latest_commit_info``) are
|
||||
thread-safe -- they use ``requests`` and write their own cache
|
||||
keys on Python dicts, which is atomic under the GIL for
|
||||
single-key assignments.
|
||||
"""
|
||||
enhanced_plugin = plugin.copy()
|
||||
repo_url = plugin.get('repo', '')
|
||||
if not repo_url:
|
||||
return enhanced_plugin
|
||||
|
||||
github_info = self._get_github_repo_info(repo_url)
|
||||
enhanced_plugin['stars'] = github_info.get('stars', plugin.get('stars', 0))
|
||||
enhanced_plugin['default_branch'] = github_info.get('default_branch', plugin.get('branch', 'main'))
|
||||
enhanced_plugin['last_updated_iso'] = github_info.get('last_commit_iso')
|
||||
enhanced_plugin['last_updated'] = github_info.get('last_commit_date')
|
||||
|
||||
if fetch_commit_info:
|
||||
branch = plugin.get('branch') or github_info.get('default_branch', 'main')
|
||||
|
||||
commit_info = self._get_latest_commit_info(repo_url, branch)
|
||||
if commit_info:
|
||||
enhanced_plugin['last_commit'] = commit_info.get('short_sha')
|
||||
enhanced_plugin['last_commit_sha'] = commit_info.get('sha')
|
||||
enhanced_plugin['last_updated'] = commit_info.get('date') or enhanced_plugin.get('last_updated')
|
||||
enhanced_plugin['last_updated_iso'] = commit_info.get('date_iso') or enhanced_plugin.get('last_updated_iso')
|
||||
enhanced_plugin['last_commit_message'] = commit_info.get('message')
|
||||
enhanced_plugin['last_commit_author'] = commit_info.get('author')
|
||||
enhanced_plugin['branch'] = commit_info.get('branch', branch)
|
||||
enhanced_plugin['last_commit_branch'] = commit_info.get('branch')
|
||||
|
||||
# Intentionally NO per-plugin manifest.json fetch here.
|
||||
# The registry's plugins.json already carries ``description``
|
||||
# (it is generated from each plugin's manifest by
|
||||
# ``update_registry.py``), and ``last_updated`` is filled in
|
||||
# from the commit info above. Fetching manifest.json per
|
||||
# plugin costs one extra HTTPS round trip per result; on a Pi4
|
||||
# with a flaky WiFi link the tail retries of that one call
|
||||
# (_http_get_with_retries does 3 attempts with exponential
|
||||
# backoff) dominate wall time even with the thread pool.
|
||||
|
||||
return enhanced_plugin
|
||||
|
||||
# Fan out the per-plugin GitHub enrichment. Serially, a Pi4 with ~15
|
||||
# plugins and a cold cache makes 30+ HTTP requests in strict sequence
|
||||
# (the "connecting to display" hang users reported). With a thread
|
||||
# pool, latency is dominated by the slowest request rather than
|
||||
# their sum. Workers capped at 10 to stay well under the
|
||||
# unauthenticated GitHub rate limit burst and avoid overwhelming a
|
||||
# Pi's WiFi link.
|
||||
if not filtered:
|
||||
return []
|
||||
|
||||
# Not worth the pool overhead for tiny workloads. Parenthesized to
|
||||
# make Python's default ``and`` > ``or`` precedence explicit: a
|
||||
# single plugin, OR a small batch where we don't need commit info.
|
||||
if (len(filtered) == 1) or ((not fetch_commit_info) and (len(filtered) < 4)):
|
||||
return [_enrich(p) for p in filtered]
|
||||
|
||||
max_workers = min(10, len(filtered))
|
||||
with ThreadPoolExecutor(max_workers=max_workers, thread_name_prefix='plugin-search') as executor:
|
||||
# executor.map preserves input order, which the UI relies on.
|
||||
return list(executor.map(_enrich, filtered))
|
||||
|
||||
def _fetch_manifest_from_github(self, repo_url: str, branch: str = "master", manifest_path: str = "manifest.json", force_refresh: bool = False) -> Optional[Dict]:
|
||||
"""
|
||||
Fetch manifest.json directly from a GitHub repository.
|
||||
|
||||
Args:
|
||||
repo_url: GitHub repository URL
|
||||
branch: Branch name (default: master)
|
||||
manifest_path: Path to manifest within the repo (default: manifest.json).
|
||||
For monorepo plugins this will be e.g. "plugins/football-scoreboard/manifest.json".
|
||||
force_refresh: If True, bypass the cache.
|
||||
|
||||
Returns:
|
||||
Manifest data or None if not found
|
||||
"""
|
||||
try:
|
||||
owner_repo = github_owner_repo(repo_url)
|
||||
if owner_repo is None:
|
||||
return None
|
||||
owner, repo = owner_repo
|
||||
|
||||
cache_key = f"{owner}/{repo}:{branch}:{manifest_path}"
|
||||
if not force_refresh and cache_key in self.manifest_cache:
|
||||
cached_time, cached_data = self.manifest_cache[cache_key]
|
||||
if time.time() - cached_time < self.manifest_cache_timeout:
|
||||
return cached_data
|
||||
|
||||
raw_url = f"https://raw.githubusercontent.com/{owner}/{repo}/{branch}/{manifest_path}"
|
||||
response = self._http_get_with_retries(raw_url, timeout=10)
|
||||
if response.status_code == 200:
|
||||
result = response.json()
|
||||
self.manifest_cache[cache_key] = (time.time(), result)
|
||||
return result
|
||||
if response.status_code == 404 and branch != "main":
|
||||
raw_url = f"https://raw.githubusercontent.com/{owner}/{repo}/main/{manifest_path}"
|
||||
response = self._http_get_with_retries(raw_url, timeout=10)
|
||||
if response.status_code == 200:
|
||||
result = response.json()
|
||||
self.manifest_cache[cache_key] = (time.time(), result)
|
||||
return result
|
||||
|
||||
# Cache the miss too, so a plugin without a manifest at this path
|
||||
# is not re-fetched on every browse.
|
||||
self.manifest_cache[cache_key] = (time.time(), None)
|
||||
except Exception as e:
|
||||
self.logger.debug(f"Could not fetch manifest from GitHub for {repo_url}: {e}")
|
||||
|
||||
return None
|
||||
|
||||
def _get_latest_commit_info(self, repo_url: str, branch: str = "main", force_refresh: bool = False) -> Optional[Dict[str, Any]]:
|
||||
"""Return metadata about the latest commit on the given branch."""
|
||||
try:
|
||||
owner_repo = github_owner_repo(repo_url)
|
||||
if owner_repo is None:
|
||||
return None
|
||||
owner, repo = owner_repo
|
||||
|
||||
cache_key = f"{owner}/{repo}:{branch}"
|
||||
if not force_refresh and cache_key in self.commit_info_cache:
|
||||
cached_time, cached_data = self.commit_info_cache[cache_key]
|
||||
if time.time() - cached_time < self.commit_cache_timeout:
|
||||
return cached_data
|
||||
|
||||
branches_to_try = self._distinct_sequence([branch, 'main', 'master'])
|
||||
headers = github_api_headers(self.github_token)
|
||||
|
||||
last_error = None
|
||||
for branch_name in branches_to_try:
|
||||
api_url = f"https://api.github.com/repos/{owner}/{repo}/commits/{branch_name}"
|
||||
try:
|
||||
response = requests.get(api_url, headers=headers, timeout=10)
|
||||
except requests.RequestException as req_err:
|
||||
# Network failure: fall back to a stale cache hit if
|
||||
# available so the plugin store UI keeps populating
|
||||
# commit info on a flaky WiFi link. Bump the cached
|
||||
# timestamp into the backoff window so we don't
|
||||
# re-retry on every request.
|
||||
if cache_key in self.commit_info_cache:
|
||||
_, stale = self.commit_info_cache[cache_key]
|
||||
if stale is not None:
|
||||
self._record_cache_backoff(
|
||||
self.commit_info_cache, cache_key,
|
||||
self.commit_cache_timeout, stale,
|
||||
)
|
||||
self.logger.warning(
|
||||
"GitHub commit fetch failed for %s (%s); serving stale cache.",
|
||||
cache_key, req_err,
|
||||
)
|
||||
return stale
|
||||
last_error = str(req_err)
|
||||
continue
|
||||
if response.status_code == 200:
|
||||
commit_data = response.json()
|
||||
commit_sha_full = commit_data.get('sha', '')
|
||||
commit_sha_short = commit_sha_full[:7] if commit_sha_full else ''
|
||||
commit_meta = commit_data.get('commit', {})
|
||||
commit_author = commit_meta.get('author', {})
|
||||
commit_date_iso = commit_author.get('date', '')
|
||||
|
||||
result = {
|
||||
'branch': branch_name,
|
||||
'sha': commit_sha_full,
|
||||
'short_sha': commit_sha_short,
|
||||
'date_iso': commit_date_iso,
|
||||
'date': self._iso_to_date(commit_date_iso),
|
||||
'author': commit_author.get('name', ''),
|
||||
'message': commit_meta.get('message', ''),
|
||||
}
|
||||
self.commit_info_cache[cache_key] = (time.time(), result)
|
||||
return result
|
||||
|
||||
if response.status_code == 403 and not self.github_token:
|
||||
self.logger.debug("GitHub commit API rate limited (403). Consider adding a token.")
|
||||
last_error = response.text
|
||||
else:
|
||||
last_error = response.text
|
||||
|
||||
if last_error:
|
||||
self.logger.debug(f"Unable to fetch commit info for {repo_url}: {last_error}")
|
||||
|
||||
# All branches returned a non-200 response (e.g. 404 on every
|
||||
# candidate, or a transient 5xx). If we already had a good
|
||||
# cached value, prefer serving that — overwriting it with
|
||||
# None here would wipe out commit info the UI just showed
|
||||
# on the previous request. Bump the timestamp into the
|
||||
# backoff window so subsequent lookups hit the cache.
|
||||
if cache_key in self.commit_info_cache:
|
||||
_, prior = self.commit_info_cache[cache_key]
|
||||
if prior is not None:
|
||||
self._record_cache_backoff(
|
||||
self.commit_info_cache, cache_key,
|
||||
self.commit_cache_timeout, prior,
|
||||
)
|
||||
return prior
|
||||
|
||||
# No prior good value — cache the negative result so we don't
|
||||
# hammer a plugin that genuinely has no reachable commits.
|
||||
self.commit_info_cache[cache_key] = (time.time(), None)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.debug(f"Error fetching latest commit metadata for {repo_url}: {e}")
|
||||
|
||||
return None
|
||||
|
||||
def get_plugin_info(self, plugin_id: str, fetch_latest_from_github: bool = True, force_refresh: bool = False) -> Optional[Dict]:
|
||||
"""
|
||||
Get detailed information about a plugin from the registry.
|
||||
|
||||
GitHub provides authoritative metadata such as stars and the latest
|
||||
commit. The registry supplies descriptive information (name, id, repo URL).
|
||||
|
||||
Args:
|
||||
plugin_id: Plugin identifier
|
||||
fetch_latest_from_github: If True (default), augment with GitHub commit metadata.
|
||||
force_refresh: If True, bypass caches for commit/manifest data.
|
||||
|
||||
Returns:
|
||||
Plugin metadata or None if not found
|
||||
"""
|
||||
registry = self.fetch_registry()
|
||||
plugins = registry.get('plugins', []) or []
|
||||
plugin_info = self._match_registry_entry(plugins, plugin_id)
|
||||
|
||||
if not plugin_info:
|
||||
return None
|
||||
|
||||
if fetch_latest_from_github:
|
||||
repo_url = plugin_info.get('repo')
|
||||
if repo_url:
|
||||
plugin_info = plugin_info.copy()
|
||||
|
||||
github_info = self._get_github_repo_info(repo_url)
|
||||
branch = plugin_info.get('branch') or github_info.get('default_branch', 'main')
|
||||
|
||||
plugin_info['default_branch'] = github_info.get('default_branch', branch)
|
||||
plugin_info['stars'] = github_info.get('stars', plugin_info.get('stars', 0))
|
||||
plugin_info['last_updated'] = github_info.get('last_commit_date', plugin_info.get('last_updated'))
|
||||
plugin_info['last_updated_iso'] = github_info.get('last_commit_iso', plugin_info.get('last_updated_iso'))
|
||||
|
||||
commit_info = self._get_latest_commit_info(repo_url, branch, force_refresh=force_refresh)
|
||||
if commit_info:
|
||||
plugin_info['last_commit'] = commit_info.get('short_sha')
|
||||
plugin_info['last_commit_sha'] = commit_info.get('sha')
|
||||
plugin_info['last_commit_message'] = commit_info.get('message')
|
||||
plugin_info['last_commit_author'] = commit_info.get('author')
|
||||
plugin_info['last_updated'] = commit_info.get('date') or plugin_info.get('last_updated')
|
||||
plugin_info['last_updated_iso'] = commit_info.get('date_iso') or plugin_info.get('last_updated_iso')
|
||||
plugin_info['branch'] = commit_info.get('branch', branch)
|
||||
plugin_info['last_commit_branch'] = commit_info.get('branch')
|
||||
|
||||
plugin_subpath = plugin_info.get('plugin_path', '')
|
||||
manifest_rel = f"{plugin_subpath}/manifest.json" if plugin_subpath else "manifest.json"
|
||||
github_manifest = self._fetch_manifest_from_github(repo_url, branch, manifest_rel, force_refresh=force_refresh)
|
||||
if github_manifest:
|
||||
if 'last_updated' in github_manifest and not plugin_info.get('last_updated'):
|
||||
plugin_info['last_updated'] = github_manifest['last_updated']
|
||||
if 'description' in github_manifest:
|
||||
plugin_info['description'] = github_manifest['description']
|
||||
|
||||
return plugin_info
|
||||
|
||||
@staticmethod
|
||||
def _match_registry_entry(plugins: List[Dict], plugin_id: str) -> Optional[Dict]:
|
||||
"""Find a registry entry by its id, or by the directory it installs to.
|
||||
|
||||
Four shipped plugins have a registry ``id`` that differs from the ``id``
|
||||
in their own manifest: ``weather`` installs to ``plugins/ledmatrix-weather``,
|
||||
and likewise stocks, music and leaderboard. Installation already prefers
|
||||
the manifest id for the directory name, so on disk, in ``config.json``
|
||||
and in a backup manifest those plugins are called ``ledmatrix-weather``.
|
||||
|
||||
Only the registry calls them ``weather``, and nothing resolved that in
|
||||
reverse: restoring a backup asked the store for ``ledmatrix-weather``
|
||||
and got "Plugin not found in registry", silently dropping four enabled
|
||||
plugins from a restored device.
|
||||
|
||||
Matching ``plugin_path`` fixes it without renaming any published id,
|
||||
which would orphan ``plugin_state.json`` entries keyed on the old ones.
|
||||
Exact id always wins, so an entry whose *path* happens to collide with
|
||||
another entry's id cannot shadow it.
|
||||
"""
|
||||
if not plugin_id:
|
||||
return None
|
||||
exact = next((p for p in plugins if p.get('id') == plugin_id), None)
|
||||
if exact is not None:
|
||||
return exact
|
||||
for entry in plugins:
|
||||
path = (entry.get('plugin_path') or '').rstrip('/')
|
||||
if path and path.rsplit('/', 1)[-1] == plugin_id:
|
||||
return entry
|
||||
return None
|
||||
|
||||
def get_registry_info(self, plugin_id: str) -> Optional[Dict]:
|
||||
"""
|
||||
Get plugin information from the registry cache only (no GitHub API calls).
|
||||
|
||||
Use this for lightweight lookups where only registry fields are needed
|
||||
(e.g., verified status, latest_version).
|
||||
|
||||
Args:
|
||||
plugin_id: Plugin identifier
|
||||
|
||||
Returns:
|
||||
Plugin metadata from registry or None if not found
|
||||
"""
|
||||
registry = self.fetch_registry()
|
||||
plugins = registry.get('plugins', []) or []
|
||||
return self._match_registry_entry(plugins, plugin_id)
|
||||
@@ -0,0 +1,732 @@
|
||||
"""Plugin store: updating installed plugins, with rollback, and reading their
|
||||
local git state.
|
||||
|
||||
Part of PluginStoreManager (store_manager.py), which mixes this class in;
|
||||
methods reach shared state and helpers through ``self``.
|
||||
"""
|
||||
|
||||
import json
|
||||
import subprocess # nosec B404 - list-form argv only, no shell # nosemgrep
|
||||
from pathlib import Path
|
||||
from typing import Dict, Optional, Tuple
|
||||
from src.plugin_system.plugin_dirs import BACKUP_MARKER
|
||||
from src.plugin_system.repo_urls import same_repo
|
||||
|
||||
|
||||
class _UpdateMixin:
|
||||
"""PluginStoreManager methods: see the module docstring."""
|
||||
|
||||
def _git_cache_signature(self, git_dir: Path) -> Optional[Tuple]:
|
||||
"""Build a cache signature that invalidates on the kind of updates
|
||||
a plugin user actually cares about.
|
||||
|
||||
Caching on ``.git/HEAD`` mtime alone is not enough: a ``git pull``
|
||||
that fast-forwards the current branch updates
|
||||
``.git/refs/heads/<branch>`` (or ``.git/packed-refs``) but leaves
|
||||
HEAD's contents and mtime untouched. And the cached ``result``
|
||||
dict includes ``remote_url`` — a value read from ``.git/config`` —
|
||||
so a config-only change (e.g. a monorepo-migration re-pointing
|
||||
``remote.origin.url``) must also invalidate the cache.
|
||||
|
||||
Signature components:
|
||||
- HEAD contents (catches detach / branch switch)
|
||||
- HEAD mtime
|
||||
- if HEAD points at a ref, that ref file's mtime (catches
|
||||
fast-forward / reset on the current branch)
|
||||
- packed-refs mtime as a coarse fallback for repos using packed refs
|
||||
- .git/config contents + mtime (catches remote URL changes and
|
||||
any other config-only edit that affects what the cached
|
||||
``remote_url`` field should contain)
|
||||
|
||||
Returns ``None`` if HEAD cannot be read at all (caller will skip
|
||||
the cache and take the slow path).
|
||||
"""
|
||||
head_file = git_dir / 'HEAD'
|
||||
try:
|
||||
head_mtime = head_file.stat().st_mtime
|
||||
head_contents = head_file.read_text(encoding='utf-8', errors='replace').strip()
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
ref_mtime = None
|
||||
if head_contents.startswith('ref: '):
|
||||
ref_path = head_contents[len('ref: '):].strip()
|
||||
# ``ref_path`` looks like ``refs/heads/main``. It lives either
|
||||
# as a loose file under .git/ or inside .git/packed-refs.
|
||||
loose_ref = git_dir / ref_path
|
||||
try:
|
||||
ref_mtime = loose_ref.stat().st_mtime
|
||||
except OSError:
|
||||
ref_mtime = None
|
||||
|
||||
packed_refs_mtime = None
|
||||
if ref_mtime is None:
|
||||
try:
|
||||
packed_refs_mtime = (git_dir / 'packed-refs').stat().st_mtime
|
||||
except OSError:
|
||||
packed_refs_mtime = None
|
||||
|
||||
config_mtime = None
|
||||
config_contents = None
|
||||
config_file = git_dir / 'config'
|
||||
try:
|
||||
config_mtime = config_file.stat().st_mtime
|
||||
config_contents = config_file.read_text(encoding='utf-8', errors='replace').strip()
|
||||
except OSError:
|
||||
config_mtime = None
|
||||
config_contents = None
|
||||
|
||||
return (
|
||||
head_contents, head_mtime,
|
||||
ref_mtime, packed_refs_mtime,
|
||||
config_contents, config_mtime,
|
||||
)
|
||||
|
||||
def _get_local_git_info(self, plugin_path: Path) -> Optional[Dict[str, str]]:
|
||||
"""Return local git branch, commit hash, and commit date if the plugin is a git checkout.
|
||||
|
||||
Results are cached keyed on a signature that includes HEAD
|
||||
contents plus the mtime of HEAD AND the resolved ref (or
|
||||
packed-refs). Repeated calls skip the ``git log`` subprocess when
|
||||
nothing has changed, and a ``git pull`` that fast-forwards the
|
||||
branch correctly invalidates the cache.
|
||||
"""
|
||||
git_dir = plugin_path / '.git'
|
||||
if not git_dir.exists():
|
||||
return None
|
||||
|
||||
cache_key = str(plugin_path)
|
||||
signature = self._git_cache_signature(git_dir)
|
||||
|
||||
if signature is not None:
|
||||
cached = self._git_info_cache.get(cache_key)
|
||||
if cached is not None and cached[0] == signature:
|
||||
return cached[1]
|
||||
|
||||
try:
|
||||
# .git may be a file (worktree / submodule) containing "gitdir: <path>".
|
||||
# Resolve it to the actual git directory before reading any files.
|
||||
try:
|
||||
if git_dir.is_file():
|
||||
pointer = git_dir.read_text(encoding='utf-8', errors='replace').strip()
|
||||
if pointer.startswith('gitdir:'):
|
||||
resolved = (plugin_path / pointer[len('gitdir:'):].strip()).resolve()
|
||||
if resolved.is_dir():
|
||||
git_dir = resolved
|
||||
else:
|
||||
return None
|
||||
else:
|
||||
return None
|
||||
except (OSError, NotADirectoryError):
|
||||
return None
|
||||
|
||||
# Read branch directly from .git/HEAD (no subprocess).
|
||||
branch = ''
|
||||
try:
|
||||
head_text = (git_dir / 'HEAD').read_text(encoding='utf-8', errors='replace').strip()
|
||||
if head_text.startswith('ref: refs/heads/'):
|
||||
branch = head_text[len('ref: refs/heads/'):]
|
||||
elif head_text.startswith('ref: '):
|
||||
branch = head_text[len('ref: '):]
|
||||
# else: detached HEAD — branch stays ''
|
||||
except (OSError, NotADirectoryError):
|
||||
pass
|
||||
|
||||
# Remote URL from .git/config — parse [remote "origin"] url line.
|
||||
remote_url = None
|
||||
try:
|
||||
config_text = (git_dir / 'config').read_text(encoding='utf-8', errors='replace')
|
||||
in_origin = False
|
||||
for line in config_text.splitlines():
|
||||
stripped = line.strip()
|
||||
if stripped == '[remote "origin"]':
|
||||
in_origin = True
|
||||
elif stripped.startswith('['):
|
||||
in_origin = False
|
||||
elif in_origin and stripped.startswith('url') and '=' in stripped:
|
||||
remote_url = stripped.split('=', 1)[1].strip()
|
||||
break
|
||||
except (OSError, NotADirectoryError):
|
||||
pass
|
||||
|
||||
# Single subprocess: SHA + commit date in one call.
|
||||
log_result = subprocess.run(
|
||||
['git', '-C', str(plugin_path), 'log', '-1', '--format=%H%n%cI', 'HEAD'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
check=True
|
||||
)
|
||||
lines = log_result.stdout.strip().splitlines()
|
||||
sha = lines[0] if lines else ''
|
||||
commit_date_iso = lines[1] if len(lines) > 1 else ''
|
||||
|
||||
result = {
|
||||
'sha': sha,
|
||||
'short_sha': sha[:7] if sha else '',
|
||||
'branch': branch,
|
||||
}
|
||||
|
||||
if remote_url:
|
||||
result['remote_url'] = remote_url
|
||||
|
||||
if commit_date_iso:
|
||||
result['date_iso'] = commit_date_iso
|
||||
result['date'] = self._iso_to_date(commit_date_iso)
|
||||
|
||||
if signature is not None:
|
||||
self._git_info_cache[cache_key] = (signature, result)
|
||||
return result
|
||||
except subprocess.CalledProcessError as err:
|
||||
self.logger.debug(f"Failed to read git info for {plugin_path.name}: {err}")
|
||||
except subprocess.TimeoutExpired:
|
||||
self.logger.debug(f"Timed out reading git info for {plugin_path.name}")
|
||||
|
||||
return None
|
||||
|
||||
def _gate_pulled_commit(self, plugin_id: str, plugin_path: Path,
|
||||
previous_sha: Optional[str]) -> bool:
|
||||
"""Apply the compatibility gate to a commit that arrived via git pull.
|
||||
|
||||
Every other route into an installed plugin goes through
|
||||
``install_plugin``, which gates in ``_install_plugin_impl``. This one
|
||||
did not: a ``git pull`` could deliver a manifest flooring above this
|
||||
core and nothing would notice until the plugin failed to load, which
|
||||
surfaces as one line in the journal and a scoreboard that silently
|
||||
stopped appearing.
|
||||
|
||||
Checked after the pull rather than before it, for the same reason
|
||||
``_install_plugin_impl`` checks after the download: the registry
|
||||
carries no compatibility field, so the incoming floor is only knowable
|
||||
once the new commit is on disk.
|
||||
|
||||
Undone with ``git reset --hard`` rather than by removing the directory.
|
||||
This is a live checkout, the previous commit is still in the object
|
||||
store, and the reset leaves the user on the exact version they were
|
||||
already running -- the same promise ``_reinstall_with_rollback`` makes,
|
||||
reached by the means this path actually has. It is also the gentler
|
||||
option: no window in which the plugin directory does not exist, and no
|
||||
``.standalone-backup-`` debris if the process dies mid-way.
|
||||
|
||||
A manifest that cannot be read is not evidence of incompatibility, so
|
||||
it allows. ``compatibility.check`` refuses only on evidence for the
|
||||
same reason: a wrong refusal breaks a working install, while a wrong
|
||||
allowance degrades to exactly the behaviour this path had before the
|
||||
gate existed.
|
||||
"""
|
||||
manifest_path = plugin_path / "manifest.json"
|
||||
try:
|
||||
with open(manifest_path, 'r', encoding='utf-8') as mf:
|
||||
manifest = json.load(mf)
|
||||
except (OSError, ValueError) as e:
|
||||
self.logger.warning(
|
||||
"Could not read %s after updating %s (%s); allowing the "
|
||||
"update, as an unreadable manifest declares no floor",
|
||||
manifest_path, plugin_id, e)
|
||||
return True
|
||||
|
||||
from src.plugin_system import compatibility
|
||||
core_version = compatibility.current_core_version()
|
||||
|
||||
compatible, reason = compatibility.check(manifest, core_version)
|
||||
if compatible:
|
||||
return True
|
||||
|
||||
self.logger.error("Refusing the update to %s: %s", plugin_id, reason)
|
||||
|
||||
if not previous_sha:
|
||||
self.logger.error(
|
||||
"Cannot roll %s back: the commit it was on before the pull is "
|
||||
"unknown. It is now on a version this core cannot run — "
|
||||
"reinstall it from the plugin store.", plugin_id)
|
||||
return False
|
||||
|
||||
# Safe by construction: update_plugin returns before pulling unless the
|
||||
# tree was clean or successfully stashed, so there are no uncommitted
|
||||
# tracked edits for --hard to discard. The stash is not popped on the
|
||||
# success path either, so the reset leaves the working tree exactly
|
||||
# where a successful pull would have. Say "commit", not "changes".
|
||||
reset = subprocess.run(
|
||||
['git', '-C', str(plugin_path), 'reset', '--hard', previous_sha],
|
||||
capture_output=True, text=True, timeout=60, check=False)
|
||||
if reset.returncode != 0:
|
||||
self.logger.error(
|
||||
"CRITICAL: could not roll %s back to commit %s: %s. It is left "
|
||||
"on a version this core cannot run; "
|
||||
"`git -C %s reset --hard %s` restores it.",
|
||||
plugin_id, previous_sha[:7],
|
||||
(reset.stderr or reset.stdout or '').strip(),
|
||||
plugin_path, previous_sha)
|
||||
else:
|
||||
self.logger.info(
|
||||
"Rolled %s back to commit %s; it stays on the version it was "
|
||||
"already running.", plugin_id, previous_sha[:7])
|
||||
return False
|
||||
|
||||
def _reinstall_with_rollback(self, plugin_id: str, plugin_path: Path) -> bool:
|
||||
"""Replace an installed plugin with a fresh install, atomically.
|
||||
|
||||
The old install is renamed aside (not deleted) until the new install
|
||||
succeeds, then removed; on ANY install failure the old directory is
|
||||
restored. Deleting first turns a failed download into a destroyed
|
||||
plugin: during the monorepo migration a Pi with broken DNS lost every
|
||||
old-remote plugin that way, with none able to be re-downloaded.
|
||||
|
||||
The aside name embeds BACKUP_MARKER ('.standalone-backup-') so every
|
||||
plugin directory lookup (src/plugin_system/plugin_dirs.py) ignores it
|
||||
even though it still contains a manifest.json.
|
||||
|
||||
Held for the whole operation under a per-plugin_id lock: two
|
||||
overlapping requests for the same plugin (double-click, two
|
||||
browser tabs — the web UI runs Flask with threaded=True) must not
|
||||
interleave their renames, or the second could steal the first's
|
||||
rollback safety net mid-install. Other plugin_ids are unaffected.
|
||||
"""
|
||||
with self._get_reinstall_lock(plugin_id):
|
||||
backup_path = plugin_path.with_name(
|
||||
f"{plugin_path.name}{BACKUP_MARKER}migrating")
|
||||
problem = self._set_aside(plugin_path, backup_path)
|
||||
if problem:
|
||||
self.logger.error(
|
||||
"Not updating %s: %s; the installed version is left in place",
|
||||
plugin_id, problem)
|
||||
return False
|
||||
|
||||
try:
|
||||
installed = self.install_plugin(plugin_id)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Reinstall of {plugin_id} raised: {e}")
|
||||
installed = False
|
||||
|
||||
if installed:
|
||||
self._discard_backup(plugin_id, backup_path, "update")
|
||||
return True
|
||||
|
||||
# Bad network, registry error...: the user keeps a working plugin.
|
||||
self._restore_backup(plugin_id, plugin_path, backup_path, "Reinstall")
|
||||
return False
|
||||
|
||||
def update_plugin(self, plugin_id: str) -> bool:
|
||||
"""
|
||||
Update a plugin to the latest commit on its upstream branch.
|
||||
"""
|
||||
plugin_path = self._find_plugin_path(plugin_id)
|
||||
|
||||
if plugin_path is None or not plugin_path.exists():
|
||||
self.logger.error(f"Plugin not installed: {plugin_id}")
|
||||
return False
|
||||
|
||||
try:
|
||||
self.logger.info(f"Checking for updates to plugin {plugin_id}")
|
||||
|
||||
# Check if this is a bundled/unmanaged plugin (no registry entry, no git remote)
|
||||
# These are plugins shipped with LEDMatrix itself and updated via LEDMatrix updates.
|
||||
metadata_path = plugin_path / ".plugin_metadata.json"
|
||||
if metadata_path.exists():
|
||||
try:
|
||||
with open(metadata_path, 'r', encoding='utf-8') as f:
|
||||
metadata = json.load(f)
|
||||
if metadata.get('install_type') == 'bundled':
|
||||
self.logger.info(f"Plugin {plugin_id} is a bundled plugin; updates are delivered via LEDMatrix itself")
|
||||
return True
|
||||
except (OSError, ValueError) as e:
|
||||
self.logger.debug(f"[PluginStore] Could not read metadata for {plugin_id} at {metadata_path}: {e}")
|
||||
|
||||
# First check if it's a git repository - if so, we can update directly
|
||||
git_info = self._get_local_git_info(plugin_path)
|
||||
|
||||
if git_info:
|
||||
# Plugin is a git repository - try to update via git
|
||||
local_branch = git_info.get('branch') or 'main'
|
||||
local_sha = git_info.get('sha')
|
||||
|
||||
# Try to get remote info from registry (optional)
|
||||
self.fetch_registry(force_refresh=True)
|
||||
plugin_info_remote = self.get_plugin_info(plugin_id, fetch_latest_from_github=True, force_refresh=True)
|
||||
# Try without 'ledmatrix-' prefix (monorepo migration)
|
||||
resolved_id = plugin_id
|
||||
if not plugin_info_remote and plugin_id.startswith('ledmatrix-'):
|
||||
alt_id = plugin_id[len('ledmatrix-'):]
|
||||
plugin_info_remote = self.get_plugin_info(alt_id, fetch_latest_from_github=True, force_refresh=True)
|
||||
if plugin_info_remote:
|
||||
resolved_id = alt_id
|
||||
self.logger.info(f"Plugin {plugin_id} found in registry as {resolved_id}")
|
||||
remote_branch = None
|
||||
remote_sha = None
|
||||
|
||||
if plugin_info_remote:
|
||||
remote_branch = plugin_info_remote.get('branch') or plugin_info_remote.get('default_branch')
|
||||
remote_sha = plugin_info_remote.get('last_commit_sha')
|
||||
|
||||
# Check if the local git remote still matches the registry repo URL.
|
||||
# After monorepo migration, old clones point to archived individual repos
|
||||
# while the registry now points to the monorepo. Detect this and reinstall.
|
||||
registry_repo = plugin_info_remote.get('repo', '')
|
||||
local_remote = git_info.get('remote_url', '')
|
||||
if local_remote and registry_repo and not same_repo(local_remote, registry_repo):
|
||||
self.logger.info(
|
||||
f"Plugin {resolved_id} git remote ({local_remote}) differs from registry ({registry_repo}). "
|
||||
f"Reinstalling from registry to migrate to new source."
|
||||
)
|
||||
return self._reinstall_with_rollback(resolved_id, plugin_path)
|
||||
|
||||
# Check if already up to date
|
||||
if remote_sha and local_sha and remote_sha.startswith(local_sha):
|
||||
self.logger.info(f"Plugin {plugin_id} already matches remote commit {remote_sha[:7]}")
|
||||
return True
|
||||
|
||||
# Update via git pull
|
||||
self.logger.info(f"Updating {plugin_id} via git pull (local branch: {local_branch})...")
|
||||
try:
|
||||
# Fetch latest changes first to get all remote branch info
|
||||
# If fetch fails, we'll still try to pull (might work with existing remote refs)
|
||||
fetch_result = subprocess.run(
|
||||
['git', '-C', str(plugin_path), 'fetch', 'origin'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
check=False
|
||||
)
|
||||
if fetch_result.returncode != 0:
|
||||
self.logger.warning(f"Git fetch failed for {plugin_id}: {fetch_result.stderr or fetch_result.stdout}. Will still attempt pull.")
|
||||
else:
|
||||
self.logger.debug(f"Successfully fetched remote changes for {plugin_id}")
|
||||
|
||||
# Determine which remote branch to pull from
|
||||
# Strategy: Use what the local branch is tracking, or find the best match
|
||||
remote_pull_branch = None
|
||||
|
||||
# First, check what the local branch is tracking
|
||||
tracking_result = subprocess.run(
|
||||
['git', '-C', str(plugin_path), 'rev-parse', '--abbrev-ref', '--symbolic-full-name', f'{local_branch}@{{upstream}}'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
check=False
|
||||
)
|
||||
|
||||
if tracking_result.returncode == 0 and tracking_result.stdout.strip():
|
||||
# Local branch is tracking a remote branch
|
||||
tracking_ref = tracking_result.stdout.strip()
|
||||
# Extract branch name from refs/remotes/origin/branch-name or origin/branch-name
|
||||
if tracking_ref.startswith('refs/remotes/origin/'):
|
||||
remote_pull_branch = tracking_ref.replace('refs/remotes/origin/', '')
|
||||
self.logger.info(f"Local branch {local_branch} is tracking origin/{remote_pull_branch}")
|
||||
elif tracking_ref.startswith('origin/'):
|
||||
remote_pull_branch = tracking_ref.replace('origin/', '')
|
||||
self.logger.info(f"Local branch {local_branch} is tracking origin/{remote_pull_branch}")
|
||||
|
||||
# If not tracking anything, try to find the best remote branch match
|
||||
if not remote_pull_branch:
|
||||
# Check if remote branch from registry exists
|
||||
if remote_branch:
|
||||
remote_check = subprocess.run(
|
||||
['git', '-C', str(plugin_path), 'ls-remote', '--heads', 'origin', remote_branch],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
check=False
|
||||
)
|
||||
if remote_check.returncode == 0 and remote_check.stdout.strip():
|
||||
remote_pull_branch = remote_branch
|
||||
self.logger.info(f"Using remote branch {remote_branch} from registry")
|
||||
|
||||
# If registry branch doesn't exist, check if local branch name exists on remote
|
||||
if not remote_pull_branch:
|
||||
local_as_remote_check = subprocess.run(
|
||||
['git', '-C', str(plugin_path), 'ls-remote', '--heads', 'origin', local_branch],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
check=False
|
||||
)
|
||||
if local_as_remote_check.returncode == 0 and local_as_remote_check.stdout.strip():
|
||||
remote_pull_branch = local_branch
|
||||
self.logger.info(f"Using local branch name {local_branch} as remote branch")
|
||||
|
||||
# Last resort: try to get remote's default branch
|
||||
if not remote_pull_branch:
|
||||
default_branch_result = subprocess.run(
|
||||
['git', '-C', str(plugin_path), 'symbolic-ref', 'refs/remotes/origin/HEAD'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
check=False
|
||||
)
|
||||
if default_branch_result.returncode == 0:
|
||||
default_ref = default_branch_result.stdout.strip()
|
||||
if default_ref.startswith('refs/remotes/origin/'):
|
||||
remote_pull_branch = default_ref.replace('refs/remotes/origin/', '')
|
||||
self.logger.info(f"Using remote default branch {remote_pull_branch}")
|
||||
|
||||
# If we still don't have a remote branch, use local branch name (git will handle it)
|
||||
if not remote_pull_branch:
|
||||
remote_pull_branch = local_branch
|
||||
self.logger.info(f"Falling back to local branch name {local_branch} for pull")
|
||||
|
||||
# Ensure we're on the local branch
|
||||
checkout_result = subprocess.run(
|
||||
['git', '-C', str(plugin_path), 'checkout', local_branch],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
check=False
|
||||
)
|
||||
if checkout_result.returncode != 0:
|
||||
self.logger.warning(f"Git checkout to {local_branch} failed for {plugin_id}: {checkout_result.stderr or checkout_result.stdout}. Will still attempt pull.")
|
||||
|
||||
# Check for local changes and untracked files that might conflict
|
||||
# First, check for untracked files that would be overwritten
|
||||
try:
|
||||
# Check for untracked files
|
||||
untracked_result = subprocess.run(
|
||||
['git', '-C', str(plugin_path), 'status', '--porcelain', '--untracked-files=all'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
check=False
|
||||
)
|
||||
untracked_files = []
|
||||
if untracked_result.returncode == 0:
|
||||
for line in untracked_result.stdout.strip().split('\n'):
|
||||
if line.startswith('??'):
|
||||
# Untracked file
|
||||
file_path = line[3:].strip()
|
||||
untracked_files.append(file_path)
|
||||
|
||||
# Check for tracked file changes
|
||||
status_result = subprocess.run(
|
||||
['git', '-C', str(plugin_path), 'status', '--porcelain', '--untracked-files=no'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
check=False
|
||||
)
|
||||
has_changes = bool(status_result.stdout.strip())
|
||||
|
||||
# If there are untracked files, stash them
|
||||
if untracked_files:
|
||||
self.logger.info(f"Found {len(untracked_files)} untracked files in {plugin_id}, will stash them")
|
||||
has_changes = True
|
||||
except subprocess.TimeoutExpired:
|
||||
# If status check times out, assume there might be changes and proceed
|
||||
self.logger.warning(f"Git status check timed out for {plugin_id}, proceeding with update")
|
||||
has_changes = True
|
||||
|
||||
stash_info = ""
|
||||
# Whether the pull can be undone without destroying work.
|
||||
tree_is_recoverable = not has_changes
|
||||
if has_changes:
|
||||
self.logger.info(f"Stashing local changes in {plugin_id} before update")
|
||||
try:
|
||||
# Use -u to include untracked files in stash
|
||||
stash_result = subprocess.run(
|
||||
['git', '-C', str(plugin_path), 'stash', 'push', '-u', '-m', f'LEDMatrix auto-stash before update {plugin_id}'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
check=False
|
||||
)
|
||||
if stash_result.returncode == 0:
|
||||
stash_info = " (local changes were stashed)"
|
||||
tree_is_recoverable = True
|
||||
self.logger.info(f"Stashed local changes (including untracked files) for {plugin_id}")
|
||||
else:
|
||||
self.logger.warning(f"Failed to stash local changes for {plugin_id}: {stash_result.stderr}")
|
||||
except subprocess.TimeoutExpired:
|
||||
self.logger.warning(f"Stash operation timed out for {plugin_id}, proceeding with pull")
|
||||
|
||||
# Do not pull what cannot be un-pulled.
|
||||
#
|
||||
# The compatibility gate below can refuse the commit this
|
||||
# pull brings down, and its only way back is `git reset
|
||||
# --hard`, which discards uncommitted tracked edits. Those
|
||||
# edits are exactly what the stash above exists to protect,
|
||||
# so a stash that failed or timed out leaves the rollback
|
||||
# unable to run without destroying them.
|
||||
#
|
||||
# A pull does not necessarily refuse on a dirty tree -- git
|
||||
# merges happily as long as the incoming commit touches
|
||||
# different files -- so without this the update would
|
||||
# succeed, the gate would refuse, and the reset would take
|
||||
# the user's work with it. Refusing here costs an update in
|
||||
# a case that already went wrong; the alternative costs
|
||||
# data.
|
||||
if not tree_is_recoverable:
|
||||
self.logger.error(
|
||||
"Refusing to update %s: it has local changes that could "
|
||||
"not be stashed, and an incompatible update could then "
|
||||
"only be rolled back by discarding them. Commit or stash "
|
||||
"them by hand, then update.", plugin_id)
|
||||
return False
|
||||
|
||||
# Pull from the determined remote branch
|
||||
self.logger.info(f"Pulling from origin/{remote_pull_branch} for {plugin_id}...")
|
||||
pull_result = subprocess.run(
|
||||
['git', '-C', str(plugin_path), 'pull', 'origin', remote_pull_branch],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120,
|
||||
check=True
|
||||
)
|
||||
|
||||
pull_message = pull_result.stdout.strip() or f"Pulled latest changes for {plugin_id}"
|
||||
if stash_info:
|
||||
pull_message += stash_info
|
||||
self.logger.info(pull_message)
|
||||
|
||||
updated_git_info = self._get_local_git_info(plugin_path) or {}
|
||||
updated_sha = updated_git_info.get('sha', '')
|
||||
if remote_sha and updated_sha and remote_sha.startswith(updated_sha):
|
||||
self.logger.info(f"Plugin {plugin_id} now at remote commit {remote_sha[:7]}{stash_info}")
|
||||
elif updated_sha:
|
||||
self.logger.info(f"Plugin {plugin_id} updated to commit {updated_sha[:7]}{stash_info}")
|
||||
|
||||
# The install gate, at the only point on this path where
|
||||
# it can be answered. Every other route in goes through
|
||||
# install_plugin, which gates in _install_plugin_impl; this
|
||||
# one did not, so a pull could deliver a manifest flooring
|
||||
# above this core and nothing would notice.
|
||||
if not self._gate_pulled_commit(plugin_id, plugin_path, local_sha):
|
||||
return False
|
||||
|
||||
self._install_dependencies(plugin_path)
|
||||
return True
|
||||
|
||||
except subprocess.CalledProcessError as git_error:
|
||||
error_output = git_error.stderr or git_error.stdout or "Unknown error"
|
||||
cmd_str = ' '.join(git_error.cmd)
|
||||
self.logger.error(f"Git update failed for {plugin_id}")
|
||||
self.logger.error(f"Command: {cmd_str}")
|
||||
self.logger.error(f"Return code: {git_error.returncode}")
|
||||
self.logger.error(f"Error output: {error_output}")
|
||||
|
||||
# Check for specific error conditions
|
||||
error_lower = error_output.lower()
|
||||
if "would be overwritten" in error_output or "local changes" in error_lower:
|
||||
self.logger.warning(f"Plugin {plugin_id} has local changes that prevent update. Consider committing or stashing changes manually.")
|
||||
elif "refusing to merge unrelated histories" in error_lower:
|
||||
self.logger.error(f"Plugin {plugin_id} has unrelated git histories. Plugin may need to be reinstalled.")
|
||||
elif "authentication" in error_lower or "permission denied" in error_lower:
|
||||
self.logger.error(f"Authentication failed for {plugin_id}. Check git credentials or repository permissions.")
|
||||
elif "not found" in error_lower or "does not exist" in error_lower:
|
||||
self.logger.error(f"Remote branch or repository not found for {plugin_id}. Check repository URL and branch name.")
|
||||
elif "conflict" in error_lower:
|
||||
self.logger.error(f"Merge conflict detected for {plugin_id}. Resolve conflicts manually or reinstall plugin.")
|
||||
|
||||
return False
|
||||
except subprocess.TimeoutExpired:
|
||||
self.logger.warning(f"Git update timed out for {plugin_id}")
|
||||
return False
|
||||
|
||||
# A plugin with its own .git that _get_local_git_info could not
|
||||
# read (e.g. no commits yet) may still name a remote to reinstall
|
||||
# from. Without its own .git, `git -C <plugin>` walks up and finds
|
||||
# the enclosing LEDMatrix checkout when plugins live in
|
||||
# plugin-repos/ -- `--local` does not prevent that -- and the
|
||||
# "plugin's" remote would be LEDMatrix itself.
|
||||
repo_url = None
|
||||
if (plugin_path / '.git').exists():
|
||||
try:
|
||||
remote_url_result = subprocess.run(
|
||||
['git', '-C', str(plugin_path), 'config', '--local', '--get', 'remote.origin.url'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
check=False
|
||||
)
|
||||
if remote_url_result.returncode == 0:
|
||||
repo_url = remote_url_result.stdout.strip() or None
|
||||
if repo_url:
|
||||
self.logger.info(f"Found git remote URL for {plugin_id}: {repo_url}")
|
||||
except (OSError, subprocess.SubprocessError) as e:
|
||||
self.logger.debug(f"Could not get git remote URL: {e}")
|
||||
|
||||
# Try registry-based update
|
||||
self.logger.info(f"Plugin {plugin_id} is not a git repository, checking registry...")
|
||||
self.fetch_registry(force_refresh=True)
|
||||
plugin_info_remote = self.get_plugin_info(plugin_id, fetch_latest_from_github=True, force_refresh=True)
|
||||
|
||||
# If not found, try without 'ledmatrix-' prefix (monorepo migration)
|
||||
registry_id = plugin_id
|
||||
if not plugin_info_remote and plugin_id.startswith('ledmatrix-'):
|
||||
alt_id = plugin_id[len('ledmatrix-'):]
|
||||
plugin_info_remote = self.get_plugin_info(alt_id, fetch_latest_from_github=True, force_refresh=True)
|
||||
if plugin_info_remote:
|
||||
registry_id = alt_id
|
||||
self.logger.info(f"Plugin {plugin_id} found in registry as {alt_id}")
|
||||
|
||||
# If not in registry but we have a repo URL, try reinstalling from that URL
|
||||
if not plugin_info_remote and repo_url:
|
||||
self.logger.info(f"Plugin {plugin_id} not in registry but has git remote URL. Reinstalling from {repo_url} to enable updates...")
|
||||
try:
|
||||
# Get current branch if possible
|
||||
branch_result = subprocess.run(
|
||||
['git', '-C', str(plugin_path), 'rev-parse', '--abbrev-ref', 'HEAD'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
check=False
|
||||
)
|
||||
branch = branch_result.stdout.strip() if branch_result.returncode == 0 else None
|
||||
if branch == 'HEAD' or not branch:
|
||||
branch = 'main'
|
||||
|
||||
# Reinstall from URL
|
||||
result = self.install_from_url(repo_url, plugin_id=plugin_id, branch=branch)
|
||||
if result.get('success'):
|
||||
self.logger.info(f"Successfully reinstalled {plugin_id} from {repo_url} as git repository")
|
||||
return True
|
||||
else:
|
||||
self.logger.warning(f"Failed to reinstall {plugin_id} from {repo_url}: {result.get('error')}")
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error reinstalling {plugin_id} from URL: {e}")
|
||||
|
||||
if not plugin_info_remote:
|
||||
self.logger.warning(f"Plugin {plugin_id} not found in registry and not a git repository; cannot update automatically")
|
||||
if not repo_url:
|
||||
self.logger.warning("Plugin may have been installed via ZIP download. Try reinstalling from GitHub URL to enable updates.")
|
||||
return False
|
||||
|
||||
repo_url = plugin_info_remote.get('repo')
|
||||
remote_sha = plugin_info_remote.get('last_commit_sha')
|
||||
remote_branch = plugin_info_remote.get('branch') or plugin_info_remote.get('default_branch')
|
||||
|
||||
# Compare local manifest version against registry latest_version
|
||||
# to avoid unnecessary reinstalls for monorepo plugins. Uses the
|
||||
# same semantic comparator as the web UI's update badge, so
|
||||
# equivalent spellings ("v1.2.0" vs "1.2.0") never trigger a
|
||||
# reinstall and a locally-ahead version is never downgraded.
|
||||
try:
|
||||
local_manifest_path = plugin_path / "manifest.json"
|
||||
if local_manifest_path.exists():
|
||||
with open(local_manifest_path, 'r', encoding='utf-8') as f:
|
||||
local_manifest = json.load(f)
|
||||
local_version = local_manifest.get('version', '')
|
||||
remote_version = plugin_info_remote.get('latest_version', '')
|
||||
from src.plugin_system.compatibility import is_update_available
|
||||
# No truthiness gate: the shared comparator already treats
|
||||
# a missing version on either side as "no update", and the
|
||||
# store must agree with the UI badge in that case too. A
|
||||
# missing manifest (not just a missing version field)
|
||||
# still falls through to the reinstall recovery path.
|
||||
if not is_update_available(local_version, remote_version):
|
||||
self.logger.info(
|
||||
f"Plugin {plugin_id} already at latest version "
|
||||
f"(installed {local_version}, registry {remote_version})")
|
||||
return True
|
||||
except Exception as e:
|
||||
self.logger.debug(f"Could not compare versions for {plugin_id}: {e}")
|
||||
|
||||
# Plugin is not a git repo but is in registry and has a newer version - reinstall
|
||||
self.logger.info(f"Plugin {plugin_id} not installed via git; re-installing latest archive (registry id: {registry_id})")
|
||||
|
||||
# Reinstall with the old version kept aside until the new
|
||||
# download succeeds — this is the path every routine store
|
||||
# update takes, and a mid-update network failure must not
|
||||
# destroy the user's plugin.
|
||||
return self._reinstall_with_rollback(registry_id, plugin_path)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error updating plugin {plugin_id}: {e}", exc_info=True)
|
||||
return False
|
||||
Reference in New Issue
Block a user