Skip to main content

#!/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()