#!/usr/bin/env python3 """ CODITECT Version Check Script
Checks for available updates by comparing local version with remote. Uses caching to avoid excessive network requests.
Usage: python3 check-version.py # Check for updates (uses cache) python3 check-version.py --force # Force fresh check (bypass cache) python3 check-version.py --json # Output JSON format python3 check-version.py --quiet # Only show if update available """
import argparse import json import os import sys import urllib.request from datetime import datetime from pathlib import Path
============================================================================
Configuration
============================================================================
ADR-114: ~/.coditect is the framework installation symlink
Version cache stays in framework dir (small, regenerable)
CODITECT_DIR = Path.home() / ".coditect" VERSION_FILE = CODITECT_DIR / "VERSION" CACHE_FILE = CODITECT_DIR / "context-storage" / "version-check.json" GCS_VERSION_URL = "https://storage.googleapis.com/coditect-dist/version.json" CACHE_HOURS = 24
============================================================================
Colors
============================================================================
Shared Colors module (consolidates 36 duplicate definitions)
_script_dir = Path(file).parent sys.path.insert(0, str(_script_dir / "core")) from colors import Colors
def supports_color(): """Check if terminal supports color.""" return hasattr(sys.stdout, 'isatty') and sys.stdout.isatty()
def colored(text, color): """Apply color if terminal supports it.""" if supports_color(): return f"{color}{text}{Colors.RESET}" return text
============================================================================
Version Functions
============================================================================
def get_local_version() -> str: """Get the locally installed version.""" if VERSION_FILE.exists(): return VERSION_FILE.read_text().strip() return "unknown"
def fetch_remote_version() -> dict: """Fetch the latest version info from GCS.""" try: with urllib.request.urlopen(GCS_VERSION_URL, timeout=5) as response: return json.loads(response.read().decode()) except Exception as e: return {"error": str(e)}
def get_cached_check() -> dict: """Get cached version check if still valid.""" if not CACHE_FILE.exists(): return None
try:
cache = json.loads(CACHE_FILE.read_text())
checked_at = datetime.fromisoformat(cache.get("checked_at", ""))
age_hours = (datetime.now() - checked_at).total_seconds() / 3600
if age_hours < CACHE_HOURS:
cache["from_cache"] = True
cache["cache_age_hours"] = round(age_hours, 1)
return cache
except Exception:
pass
return None
def save_cache(current: str, latest: str, update_available: bool, remote_info: dict): """Save version check result to cache.""" CACHE_FILE.parent.mkdir(parents=True, exist_ok=True) cache = { "checked_at": datetime.now().isoformat(), "current": current, "latest": latest, "update_available": update_available, "changelog_url": remote_info.get("changelog", ""), "update_message": remote_info.get("update_message", ""), "min_supported": remote_info.get("min_supported", "") } CACHE_FILE.write_text(json.dumps(cache, indent=2))
def compare_versions(current: str, latest: str) -> int: """Compare two semver versions. Returns -1 if current < latest, 0 if equal, 1 if current > latest.""" def parse_version(v): try: parts = v.replace("v", "").split(".") return tuple(int(p) for p in parts[:3]) except: return (0, 0, 0)
c = parse_version(current)
l = parse_version(latest)
if c < l:
return -1
elif c > l:
return 1
return 0
def check_version(force: bool = False) -> dict: """Check for available updates.""" current = get_local_version()
# Try cache first (unless forced)
if not force:
cached = get_cached_check()
if cached:
# Update current version in case it changed
cached["current"] = current
cached["update_available"] = compare_versions(current, cached.get("latest", current)) < 0
return cached
# Fetch from remote
remote = fetch_remote_version()
if "error" in remote:
return {
"current": current,
"latest": "unknown",
"update_available": False,
"error": remote["error"],
"from_cache": False
}
latest = remote.get("latest", current)
update_available = compare_versions(current, latest) < 0
# Cache the result
save_cache(current, latest, update_available, remote)
return {
"current": current,
"latest": latest,
"update_available": update_available,
"changelog_url": remote.get("changelog", ""),
"update_message": remote.get("update_message", ""),
"min_supported": remote.get("min_supported", ""),
"from_cache": False
}
============================================================================
Output Functions
============================================================================
def print_human_readable(result: dict, quiet: bool = False): """Print human-readable version check output.""" current = result.get("current", "unknown") latest = result.get("latest", "unknown") update_available = result.get("update_available", False)
if quiet and not update_available:
return # Silent if no update and quiet mode
if "error" in result:
print(colored(f" Version check failed: {result['error']}", Colors.YELLOW))
print(f" Current version: {colored(current, Colors.CYAN)}")
return
if update_available:
print(colored(" Update available!", Colors.YELLOW))
print(f" Current: {colored(current, Colors.CYAN)}")
print(f" Latest: {colored(latest, Colors.GREEN)}")
if result.get("update_message"):
print(f" {result['update_message']}")
print(f" Run {colored('/update', Colors.CYAN)} to update")
else:
if not quiet:
print(f" {colored('Up to date', Colors.GREEN)} (v{current})")
if result.get("from_cache") and not quiet:
print(f" (cached {result.get('cache_age_hours', 0)}h ago)")
def print_json(result: dict): """Print JSON output.""" print(json.dumps(result, indent=2))
def print_orient_banner(result: dict): """Print banner for /orient command integration.""" update_available = result.get("update_available", False)
if update_available:
current = result.get("current", "unknown")
latest = result.get("latest", "unknown")
# Print warning banner
print()
print(colored("=" * 60, Colors.YELLOW))
print(colored(f" UPDATE AVAILABLE: v{current} -> v{latest}", Colors.YELLOW + Colors.BOLD))
if result.get("update_message"):
print(colored(f" {result['update_message']}", Colors.YELLOW))
print(colored(f" Run /update to get the latest version", Colors.YELLOW))
print(colored("=" * 60, Colors.YELLOW))
print()
============================================================================
Main
============================================================================
def main(): parser = argparse.ArgumentParser(description="Check for CODITECT updates") parser.add_argument("--force", "-f", action="store_true", help="Force fresh check (bypass cache)") parser.add_argument("--json", "-j", action="store_true", help="Output JSON format") parser.add_argument("--quiet", "-q", action="store_true", help="Only show output if update available") parser.add_argument("--banner", "-b", action="store_true", help="Show update banner for /orient integration")
args = parser.parse_args()
result = check_version(force=args.force)
if args.json:
print_json(result)
elif args.banner:
print_orient_banner(result)
else:
print_human_readable(result, quiet=args.quiet)
# Exit code: 0 if no update, 1 if update available, 2 if error
if "error" in result:
sys.exit(2)
elif result.get("update_available"):
sys.exit(1)
else:
sys.exit(0)
if name == "main": main()