Skip to main content

#!/usr/bin/env python3 """ CODITECT Update Script

Updates the CODITECT installation to the latest version from the protected location. Only updates ~/.coditect (the protected install), never submodules.

Usage: python3 update-coditect.py # Update to latest python3 update-coditect.py --check # Check for updates only python3 update-coditect.py --yes # Update without confirmation python3 update-coditect.py --version # Show current version """

import argparse import json import os import subprocess import sys import urllib.request from datetime import datetime from pathlib import Path

============================================================================

Configuration

============================================================================

ADR-114: ~/.coditect is the framework installation symlink

Version info stays in framework dir (small, regenerable cache)

CODITECT_DIR = Path.home() / ".coditect" VERSION_FILE = CODITECT_DIR / "VERSION" VERSION_CHECK_CACHE = 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 print_success(msg): print(f" {Colors.GREEN}✓{Colors.RESET} {msg}") def print_warning(msg): print(f" {Colors.YELLOW}⚠{Colors.RESET} {msg}") def print_error(msg): print(f" {Colors.RED}✗{Colors.RESET} {msg}") def print_info(msg): print(f" {Colors.CYAN}ℹ{Colors.RESET} {msg}")

============================================================================

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=10) as response: return json.loads(response.read().decode()) except Exception as e: print_warning(f"Could not fetch version info: {e}") return None

def get_cached_version_check() -> dict: """Get cached version check if still valid.""" if not VERSION_CHECK_CACHE.exists(): return None

try:
cache = json.loads(VERSION_CHECK_CACHE.read_text())
checked_at = datetime.fromisoformat(cache.get("checked_at", ""))
age_hours = (datetime.now() - checked_at).total_seconds() / 3600

if age_hours < CACHE_HOURS:
return cache
except Exception:
pass

return None

def save_version_check_cache(current: str, latest: str, update_available: bool): """Save version check result to cache.""" VERSION_CHECK_CACHE.parent.mkdir(parents=True, exist_ok=True) cache = { "checked_at": datetime.now().isoformat(), "current": current, "latest": latest, "update_available": update_available } VERSION_CHECK_CACHE.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

============================================================================

Git Functions

============================================================================

def is_git_repo(path: Path) -> bool: """Check if path is a git repository.""" return (path / ".git").exists() or (path / ".git").is_file()

def is_submodule(path: Path) -> bool: """Check if the repository is a git submodule.""" git_path = path / ".git" if git_path.is_file(): # .git is a file pointing to the parent repo's modules dir return True return False

def get_git_status(path: Path) -> dict: """Get git status for the repository.""" result = subprocess.run( ["git", "status", "--porcelain"], cwd=path, capture_output=True, text=True )

has_changes = bool(result.stdout.strip())

# Check if we're behind remote
subprocess.run(["git", "fetch", "origin", "main", "--quiet"], cwd=path, capture_output=True)

result = subprocess.run(
["git", "rev-list", "--count", "HEAD..origin/main"],
cwd=path,
capture_output=True,
text=True
)

commits_behind = int(result.stdout.strip()) if result.returncode == 0 else 0

return {
"has_changes": has_changes,
"commits_behind": commits_behind
}

def get_changelog(path: Path, from_version: str) -> list: """Get commit messages since a version tag.""" # Try to find the tag for the current version tag = f"v{from_version}"

result = subprocess.run(
["git", "log", f"{tag}..origin/main", "--oneline", "--no-decorate"],
cwd=path,
capture_output=True,
text=True
)

if result.returncode != 0:
# Tag might not exist, just show recent commits
result = subprocess.run(
["git", "log", "HEAD..origin/main", "--oneline", "--no-decorate", "-20"],
cwd=path,
capture_output=True,
text=True
)

if result.stdout.strip():
return result.stdout.strip().split("\n")[:10] # Limit to 10
return []

def git_pull(path: Path) -> bool: """Pull latest changes with rebase.""" result = subprocess.run( ["git", "pull", "--rebase", "origin", "main"], cwd=path, capture_output=True, text=True )

if result.returncode != 0:
print_error(f"Git pull failed: {result.stderr}")
return False

return True

============================================================================

Update Functions

============================================================================

def update_dependencies(path: Path) -> bool: """Update Python dependencies if requirements.txt changed.""" requirements = path / "requirements.txt" venv_pip = path / ".venv" / "bin" / "pip"

if not requirements.exists():
return True

if not venv_pip.exists():
print_info("No virtual environment found, skipping dependency update")
return True

result = subprocess.run(
[str(venv_pip), "install", "-r", str(requirements), "-q"],
cwd=path,
capture_output=True,
text=True
)

if result.returncode != 0:
print_warning(f"Dependency update had issues: {result.stderr}")
return False

return True

def update_version_file(path: Path, version: str): """Update the local VERSION file.""" version_file = path / "VERSION" version_file.write_text(f"{version}\n")

============================================================================

Main Functions

============================================================================

def check_for_updates(use_cache: bool = True) -> dict: """Check for available updates.""" current = get_local_version()

# Try cache first
if use_cache:
cached = get_cached_version_check()
if cached:
return {
"current": current,
"latest": cached.get("latest", current),
"update_available": cached.get("update_available", False),
"from_cache": True
}

# Fetch from GCS
remote = fetch_remote_version()
if not remote:
return {
"current": current,
"latest": "unknown",
"update_available": False,
"error": "Could not fetch version info"
}

latest = remote.get("latest", current)
update_available = compare_versions(current, latest) < 0

# Cache the result
save_version_check_cache(current, latest, update_available)

return {
"current": current,
"latest": latest,
"update_available": update_available,
"update_message": remote.get("update_message", ""),
"changelog_url": remote.get("changelog", ""),
"from_cache": False
}

def do_update(skip_confirm: bool = False) -> bool: """Perform the update.""" print(f"\n{Colors.BOLD}CODITECT Update{Colors.RESET}") print("=" * 50)

# Verify we're updating the protected location
if not CODITECT_DIR.exists():
print_error("CODITECT not installed. Run the installer first.")
return False

if not is_git_repo(CODITECT_DIR):
print_error(f"{CODITECT_DIR} is not a git repository")
return False

# Check if it's a submodule (developer safety)
if is_submodule(CODITECT_DIR):
print_error("This appears to be a development submodule.")
print_info("Use 'git pull' directly instead of /update.")
return False

# Check for updates
print()
info = check_for_updates(use_cache=False)

print(f" Current version: {Colors.CYAN}{info['current']}{Colors.RESET}")
print(f" Latest version: {Colors.GREEN}{info['latest']}{Colors.RESET}")

if not info["update_available"]:
print()
print_success("Already up to date!")
return True

# Show what's new
print()
changelog = get_changelog(CODITECT_DIR, info["current"])
if changelog:
print(f" {Colors.BOLD}Changes:{Colors.RESET}")
for commit in changelog[:5]:
print(f" - {commit}")
if len(changelog) > 5:
print(f" ... and {len(changelog) - 5} more")

if info.get("update_message"):
print()
print(f" {Colors.CYAN}{info['update_message']}{Colors.RESET}")

# Confirm update
if not skip_confirm:
print()
response = input(f" Update to v{info['latest']}? [Y/n]: ").strip().lower()
if response and response != 'y':
print_info("Update cancelled")
return False

# Check for local changes
status = get_git_status(CODITECT_DIR)
if status["has_changes"]:
print()
print_warning("You have local changes that may be overwritten")
if not skip_confirm:
response = input(" Continue anyway? [y/N]: ").strip().lower()
if response != 'y':
print_info("Update cancelled")
return False

# Perform update
print()
print(" Updating...")

if not git_pull(CODITECT_DIR):
print_error("Update failed")
print_info(f"To rollback: cd {CODITECT_DIR} && git reset --hard HEAD@{{1}}")
return False

print_success("Applied updates (git pull --rebase)")

# Update dependencies
if update_dependencies(CODITECT_DIR):
print_success("Dependencies up to date")

# Read new version from repo (it may have changed)
new_version_file = CODITECT_DIR / "VERSION"
if new_version_file.exists():
new_version = new_version_file.read_text().strip()
else:
new_version = info["latest"]

print()
print(f" {Colors.GREEN}✓ Updated to v{new_version}{Colors.RESET}")

# Clear cache
if VERSION_CHECK_CACHE.exists():
VERSION_CHECK_CACHE.unlink()

return True

def main(): parser = argparse.ArgumentParser(description="Update CODITECT installation") parser.add_argument("--check", action="store_true", help="Check for updates only") parser.add_argument("--yes", "-y", action="store_true", help="Update without confirmation") parser.add_argument("--version", "-v", action="store_true", help="Show current version") args = parser.parse_args()

if args.version:
version = get_local_version()
print(f"CODITECT v{version}")
return

if args.check:
info = check_for_updates(use_cache=False)
print(f"\n{Colors.BOLD}CODITECT Version Check{Colors.RESET}")
print("=" * 50)
print(f" Current: v{info['current']}")
print(f" Latest: v{info['latest']}")

if info["update_available"]:
print()
print(f" {Colors.YELLOW}⚠ Update available!{Colors.RESET}")
print(f" Run {Colors.CYAN}/update{Colors.RESET} to update.")
else:
print()
print_success("You're up to date!")
return

# Do the update
success = do_update(skip_confirm=args.yes)
sys.exit(0 if success else 1)

if name == "main": main()