537 lines
19 KiB
Python
537 lines
19 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Version Detector - Detect version differences between marketplace and project plugins
|
|
|
|
This module provides version parsing, comparison, and mismatch detection to improve
|
|
the marketplace update UX by informing users when updates are available.
|
|
|
|
Features:
|
|
- Parse semantic versions from plugin.json files
|
|
- Compare marketplace vs project versions
|
|
- Detect upgrade/downgrade scenarios
|
|
- Handle pre-release versions
|
|
- Security: Path validation via security_utils
|
|
- Clear error messages for version issues
|
|
|
|
Security:
|
|
- All file paths validated via security_utils.validate_path()
|
|
- Prevents path traversal (CWE-22)
|
|
- Rejects symlink attacks (CWE-59)
|
|
- Audit logging for security events
|
|
|
|
Usage:
|
|
from version_detector import VersionDetector, detect_version_mismatch
|
|
|
|
# Detect version mismatch
|
|
result = detect_version_mismatch("/path/to/project")
|
|
if result.is_upgrade_available:
|
|
print(f"Update available: {result.marketplace_version}")
|
|
|
|
# Low-level API
|
|
detector = VersionDetector(project_root)
|
|
project_ver = detector.parse_project_version()
|
|
marketplace_ver = detector.parse_marketplace_version("autonomous-dev")
|
|
comparison = detector.compare_versions(project_ver, marketplace_ver)
|
|
|
|
Date: 2025-11-08
|
|
Issue: GitHub #50 - Fix Marketplace Update UX
|
|
Agent: implementer
|
|
|
|
|
|
Design Patterns:
|
|
See library-design-patterns skill for standardized design patterns.
|
|
"""
|
|
|
|
import json
|
|
import re
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
# Import with fallback for both dev (plugins/) and installed (.claude/lib/) environments
|
|
try:
|
|
from plugins.autonomous_dev.lib.security_utils import validate_path, audit_log
|
|
except ImportError:
|
|
from security_utils import validate_path, audit_log
|
|
|
|
|
|
@dataclass
|
|
class Version:
|
|
"""Semantic version representation.
|
|
|
|
See error-handling-patterns skill for exception hierarchy and error handling best practices.
|
|
|
|
Attributes:
|
|
major: Major version number (breaking changes)
|
|
minor: Minor version number (new features)
|
|
patch: Patch version number (bug fixes)
|
|
prerelease: Pre-release tag (e.g., "beta.1", "rc.2") or None
|
|
"""
|
|
|
|
major: int
|
|
minor: int
|
|
patch: int
|
|
prerelease: Optional[str] = None
|
|
|
|
def __str__(self) -> str:
|
|
"""Return string representation of version."""
|
|
base = f"{self.major}.{self.minor}.{self.patch}"
|
|
if self.prerelease:
|
|
return f"{base}-{self.prerelease}"
|
|
return base
|
|
|
|
def __lt__(self, other: "Version") -> bool:
|
|
"""Compare versions for less-than."""
|
|
if not isinstance(other, Version):
|
|
return NotImplemented
|
|
|
|
# Compare major.minor.patch first
|
|
if (self.major, self.minor, self.patch) != (other.major, other.minor, other.patch):
|
|
return (self.major, self.minor, self.patch) < (other.major, other.minor, other.patch)
|
|
|
|
# If base versions equal, compare prerelease
|
|
# No prerelease > has prerelease (3.7.0 > 3.7.0-beta.1)
|
|
if self.prerelease is None and other.prerelease is None:
|
|
return False
|
|
if self.prerelease is None:
|
|
return False # 3.7.0 > 3.7.0-beta.1
|
|
if other.prerelease is None:
|
|
return True # 3.7.0-beta.1 < 3.7.0
|
|
|
|
# Both have prerelease, compare alphabetically
|
|
return self.prerelease < other.prerelease
|
|
|
|
def __eq__(self, other: object) -> bool:
|
|
"""Compare versions for equality."""
|
|
if not isinstance(other, Version):
|
|
return NotImplemented
|
|
return (
|
|
self.major == other.major
|
|
and self.minor == other.minor
|
|
and self.patch == other.patch
|
|
and self.prerelease == other.prerelease
|
|
)
|
|
|
|
def __le__(self, other: "Version") -> bool:
|
|
"""Compare versions for less-than-or-equal."""
|
|
return self == other or self < other
|
|
|
|
def __gt__(self, other: "Version") -> bool:
|
|
"""Compare versions for greater-than."""
|
|
return not self <= other
|
|
|
|
def __ge__(self, other: "Version") -> bool:
|
|
"""Compare versions for greater-than-or-equal."""
|
|
return not self < other
|
|
|
|
|
|
@dataclass
|
|
class VersionComparison:
|
|
"""Result of version comparison.
|
|
|
|
Attributes:
|
|
project_version: Project plugin version string (or None if not found)
|
|
marketplace_version: Marketplace plugin version string (or None if not found)
|
|
status: Comparison status constant
|
|
message: Human-readable comparison message (auto-generated if not provided)
|
|
is_upgrade: Quick check if upgrade is available
|
|
is_downgrade: Quick check if downgrade would occur
|
|
"""
|
|
|
|
# Status constants
|
|
UPGRADE_AVAILABLE = "upgrade_available"
|
|
DOWNGRADE_RISK = "downgrade_risk"
|
|
UP_TO_DATE = "up_to_date" # Versions equal
|
|
EQUAL = UP_TO_DATE # Alias for backwards compatibility
|
|
MARKETPLACE_NOT_INSTALLED = "marketplace_not_installed"
|
|
PROJECT_NOT_SYNCED = "project_not_synced"
|
|
UNKNOWN = "unknown"
|
|
|
|
project_version: Optional[str] = None
|
|
marketplace_version: Optional[str] = None
|
|
status: str = UNKNOWN
|
|
message: str = ""
|
|
is_upgrade: bool = False
|
|
is_downgrade: bool = False
|
|
|
|
def __post_init__(self):
|
|
"""Set convenience flags and auto-generate message if needed."""
|
|
self.is_upgrade = self.status == self.UPGRADE_AVAILABLE
|
|
self.is_downgrade = self.status == self.DOWNGRADE_RISK
|
|
|
|
# Auto-generate message if not provided
|
|
if not self.message:
|
|
if self.status == self.UPGRADE_AVAILABLE:
|
|
self.message = f"Upgrade available: {self.project_version} -> {self.marketplace_version}"
|
|
elif self.status == self.DOWNGRADE_RISK:
|
|
self.message = f"Warning: Project version {self.project_version} is newer than marketplace {self.marketplace_version}"
|
|
elif self.status == self.UP_TO_DATE:
|
|
self.message = f"Versions in sync: {self.project_version}"
|
|
else:
|
|
self.message = "No version information available"
|
|
|
|
|
|
# Exception hierarchy pattern from error-handling-patterns skill:
|
|
# BaseException -> Exception -> AutonomousDevError -> DomainError(BaseException) -> SpecificError
|
|
class VersionParseError(Exception):
|
|
"""Exception raised when version string cannot be parsed."""
|
|
|
|
pass
|
|
|
|
|
|
class VersionDetector:
|
|
"""Detector for version mismatches between marketplace and project plugins.
|
|
|
|
Attributes:
|
|
project_root: Validated project root path
|
|
marketplace_plugins_file: Path to installed_plugins.json (default: ~/.claude/plugins/installed_plugins.json)
|
|
"""
|
|
|
|
# Semantic version regex: MAJOR.MINOR.PATCH[-PRERELEASE]
|
|
VERSION_PATTERN = re.compile(
|
|
r'^(\d+)\.(\d+)\.(\d+)(?:-([a-zA-Z0-9.]+))?$'
|
|
)
|
|
|
|
def __init__(
|
|
self,
|
|
project_root: Path,
|
|
marketplace_plugins_file: Optional[Path] = None,
|
|
):
|
|
"""Initialize version detector.
|
|
|
|
Args:
|
|
project_root: Path to project root directory
|
|
marketplace_plugins_file: Optional path to marketplace installed_plugins.json
|
|
|
|
Raises:
|
|
ValueError: If path fails security validation
|
|
"""
|
|
# Validate project root
|
|
try:
|
|
validated_root = validate_path(project_root, "project root")
|
|
self.project_root = Path(validated_root).resolve()
|
|
except ValueError as e:
|
|
audit_log(
|
|
"version_detection",
|
|
"failure",
|
|
{
|
|
"operation": "init",
|
|
"project_root": str(project_root),
|
|
"error": str(e),
|
|
},
|
|
)
|
|
raise
|
|
|
|
# Set marketplace plugins file (default or custom)
|
|
if marketplace_plugins_file:
|
|
self.marketplace_plugins_file = marketplace_plugins_file
|
|
else:
|
|
self.marketplace_plugins_file = (
|
|
Path.home() / ".claude" / "plugins" / "installed_plugins.json"
|
|
)
|
|
|
|
def _parse_version_string(self, version_string: str) -> Version:
|
|
"""Parse semantic version string into Version object (private method).
|
|
|
|
Args:
|
|
version_string: Version string (e.g., "3.7.0", "3.8.0-beta.1")
|
|
|
|
Returns:
|
|
Version object with parsed components
|
|
|
|
Raises:
|
|
VersionParseError: If version string is invalid
|
|
|
|
Note:
|
|
This is the internal parsing method used by other methods.
|
|
Public API should use parse_project_version() or parse_marketplace_version().
|
|
"""
|
|
match = self.VERSION_PATTERN.match(version_string)
|
|
if not match:
|
|
raise VersionParseError(
|
|
f"Invalid version string: '{version_string}'\n"
|
|
f"Expected format: MAJOR.MINOR.PATCH (e.g., 3.7.0)\n"
|
|
f"Optional pre-release: MAJOR.MINOR.PATCH-PRERELEASE (e.g., 3.8.0-beta.1)"
|
|
)
|
|
|
|
major, minor, patch, prerelease = match.groups()
|
|
return Version(
|
|
major=int(major),
|
|
minor=int(minor),
|
|
patch=int(patch),
|
|
prerelease=prerelease,
|
|
)
|
|
|
|
def parse_version(self, version_string: str) -> Version:
|
|
"""Parse semantic version string into Version object (public API).
|
|
|
|
Args:
|
|
version_string: Version string (e.g., "3.7.0", "3.8.0-beta.1")
|
|
|
|
Returns:
|
|
Version object with parsed components
|
|
|
|
Raises:
|
|
VersionParseError: If version string is invalid
|
|
"""
|
|
return self._parse_version_string(version_string)
|
|
|
|
def _read_json_file(self, file_path: Path) -> dict:
|
|
"""Read and parse JSON file with security validation.
|
|
|
|
Args:
|
|
file_path: Path to JSON file to read
|
|
|
|
Returns:
|
|
Parsed JSON data as dictionary
|
|
|
|
Raises:
|
|
ValueError: If path fails security validation
|
|
FileNotFoundError: If file doesn't exist
|
|
PermissionError: If file is not readable
|
|
VersionParseError: If JSON is corrupted
|
|
|
|
Note:
|
|
This is an internal method that validates paths before reading.
|
|
All file reads should go through this method for security.
|
|
"""
|
|
# Validate path before reading
|
|
try:
|
|
validated_path = validate_path(file_path, "JSON file")
|
|
except ValueError as e:
|
|
audit_log(
|
|
"version_detection",
|
|
"security_violation",
|
|
{
|
|
"operation": "_read_json_file",
|
|
"path": str(file_path),
|
|
"error": str(e),
|
|
},
|
|
)
|
|
raise
|
|
|
|
# Check file exists
|
|
if not Path(validated_path).exists():
|
|
raise FileNotFoundError(f"File not found: {validated_path}")
|
|
|
|
# Parse JSON
|
|
try:
|
|
with open(validated_path, "r") as f:
|
|
return json.load(f)
|
|
except json.JSONDecodeError as e:
|
|
raise VersionParseError(
|
|
f"Corrupted JSON file: {validated_path}\n"
|
|
f"JSON parse error: {e}\n"
|
|
f"Expected: Valid JSON file"
|
|
)
|
|
except PermissionError:
|
|
raise
|
|
|
|
def parse_project_version(self) -> Optional[Version]:
|
|
"""Parse project plugin version from plugin.json.
|
|
|
|
Returns:
|
|
Version object or None if plugin.json not found
|
|
|
|
Raises:
|
|
VersionParseError: If plugin.json is corrupted or version is invalid
|
|
"""
|
|
plugin_json = (
|
|
self.project_root
|
|
/ ".claude"
|
|
/ "plugins"
|
|
/ "autonomous-dev"
|
|
/ "plugin.json"
|
|
)
|
|
|
|
# Return None if file doesn't exist (not an error)
|
|
if not plugin_json.exists():
|
|
return None
|
|
|
|
# Validate path before reading (let ValueError bubble up for security violations)
|
|
try:
|
|
validated_path = validate_path(plugin_json, "project plugin.json")
|
|
except ValueError as e:
|
|
audit_log(
|
|
"version_detection",
|
|
"security_violation",
|
|
{
|
|
"operation": "parse_project_version",
|
|
"path": str(plugin_json),
|
|
"error": str(e),
|
|
},
|
|
)
|
|
# Re-raise ValueError for security violations (expected by tests)
|
|
raise
|
|
|
|
# Parse JSON
|
|
try:
|
|
with open(validated_path, "r") as f:
|
|
data = json.load(f)
|
|
except json.JSONDecodeError as e:
|
|
raise VersionParseError(
|
|
f"Corrupted plugin.json: {plugin_json}\n"
|
|
f"JSON parse error: {e}\n"
|
|
f"Expected: Valid JSON file"
|
|
)
|
|
|
|
# Extract version field
|
|
if "version" not in data:
|
|
raise VersionParseError(
|
|
f"Missing 'version' field in {plugin_json}\n"
|
|
f"Expected: plugin.json with 'version' field\n"
|
|
f"Example: {{'name': 'autonomous-dev', 'version': '3.7.0'}}"
|
|
)
|
|
|
|
version_string = data["version"]
|
|
return self.parse_version(version_string)
|
|
|
|
def parse_marketplace_version(self, plugin_name: str) -> Optional[Version]:
|
|
"""Parse marketplace plugin version from installed_plugins.json.
|
|
|
|
Args:
|
|
plugin_name: Plugin name (e.g., "autonomous-dev")
|
|
|
|
Returns:
|
|
Version object or None if plugin not found in marketplace
|
|
|
|
Raises:
|
|
VersionParseError: If installed_plugins.json is corrupted or version is invalid
|
|
"""
|
|
# Return None if file doesn't exist
|
|
if not self.marketplace_plugins_file.exists():
|
|
return None
|
|
|
|
# Parse JSON
|
|
try:
|
|
with open(self.marketplace_plugins_file, "r") as f:
|
|
data = json.load(f)
|
|
except json.JSONDecodeError as e:
|
|
raise VersionParseError(
|
|
f"Corrupted installed_plugins.json: {self.marketplace_plugins_file}\n"
|
|
f"JSON parse error: {e}\n"
|
|
f"Expected: Valid JSON file"
|
|
)
|
|
|
|
# Extract plugin entry
|
|
if plugin_name not in data:
|
|
return None
|
|
|
|
plugin_data = data[plugin_name]
|
|
if "version" not in plugin_data:
|
|
raise VersionParseError(
|
|
f"Missing 'version' field for plugin '{plugin_name}' in {self.marketplace_plugins_file}\n"
|
|
f"Expected: Plugin entry with 'version' field"
|
|
)
|
|
|
|
version_string = plugin_data["version"]
|
|
return self.parse_version(version_string)
|
|
|
|
def compare_versions(
|
|
self,
|
|
project_version: Optional[Version],
|
|
marketplace_version: Optional[Version],
|
|
) -> VersionComparison:
|
|
"""Compare project and marketplace versions.
|
|
|
|
Args:
|
|
project_version: Project plugin version (or None if not installed)
|
|
marketplace_version: Marketplace plugin version (or None if not found)
|
|
|
|
Returns:
|
|
VersionComparison with status and message (versions as strings)
|
|
"""
|
|
# Convert Version objects to strings for comparison result
|
|
project_str = str(project_version) if project_version else None
|
|
marketplace_str = str(marketplace_version) if marketplace_version else None
|
|
|
|
# Case 1: Both versions unknown
|
|
if project_version is None and marketplace_version is None:
|
|
return VersionComparison(
|
|
project_version=None,
|
|
marketplace_version=None,
|
|
status=VersionComparison.UNKNOWN,
|
|
message="No version information available",
|
|
)
|
|
|
|
# Case 2: Marketplace not installed
|
|
if marketplace_version is None:
|
|
return VersionComparison(
|
|
project_version=project_str,
|
|
marketplace_version=None,
|
|
status=VersionComparison.MARKETPLACE_NOT_INSTALLED,
|
|
message=f"Project version: {project_version}, Marketplace: not installed",
|
|
)
|
|
|
|
# Case 3: Project not synced
|
|
if project_version is None:
|
|
return VersionComparison(
|
|
project_version=None,
|
|
marketplace_version=marketplace_str,
|
|
status=VersionComparison.PROJECT_NOT_SYNCED,
|
|
message=f"Marketplace version: {marketplace_version}, Project: not synced",
|
|
)
|
|
|
|
# Case 4: Marketplace newer (upgrade available)
|
|
if marketplace_version > project_version:
|
|
return VersionComparison(
|
|
project_version=project_str,
|
|
marketplace_version=marketplace_str,
|
|
status=VersionComparison.UPGRADE_AVAILABLE,
|
|
message=f"Upgrade available: {project_version} -> {marketplace_version}",
|
|
is_upgrade=True,
|
|
)
|
|
|
|
# Case 5: Project newer (downgrade risk)
|
|
if project_version > marketplace_version:
|
|
return VersionComparison(
|
|
project_version=project_str,
|
|
marketplace_version=marketplace_str,
|
|
status=VersionComparison.DOWNGRADE_RISK,
|
|
message=f"Warning: Project version {project_version} is newer than marketplace {marketplace_version}",
|
|
is_downgrade=True,
|
|
)
|
|
|
|
# Case 6: Versions equal
|
|
return VersionComparison(
|
|
project_version=project_str,
|
|
marketplace_version=marketplace_str,
|
|
status=VersionComparison.UP_TO_DATE,
|
|
message=f"Versions in sync: {project_version}",
|
|
)
|
|
|
|
|
|
def detect_version_mismatch(
|
|
project_root: str,
|
|
plugin_name: str = "autonomous-dev",
|
|
marketplace_plugins_file: Optional[str] = None,
|
|
) -> VersionComparison:
|
|
"""Detect version mismatch between marketplace and project plugin.
|
|
|
|
This is the high-level convenience function for version detection.
|
|
|
|
Args:
|
|
project_root: Path to project root directory
|
|
plugin_name: Plugin name (default: "autonomous-dev")
|
|
marketplace_plugins_file: Optional path to installed_plugins.json
|
|
|
|
Returns:
|
|
VersionComparison with detailed comparison results
|
|
|
|
Raises:
|
|
ValueError: If path fails security validation
|
|
VersionParseError: If version parsing fails
|
|
|
|
Example:
|
|
>>> result = detect_version_mismatch("/path/to/project")
|
|
>>> if result.is_upgrade_available:
|
|
... print(f"Update available: {result.message}")
|
|
"""
|
|
marketplace_file = Path(marketplace_plugins_file) if marketplace_plugins_file else None
|
|
detector = VersionDetector(Path(project_root), marketplace_file)
|
|
|
|
project_version = detector.parse_project_version()
|
|
marketplace_version = detector.parse_marketplace_version(plugin_name)
|
|
|
|
return detector.compare_versions(project_version, marketplace_version)
|