| |
| """ |
| Antigravity Manager β Supabase Sync + App Launcher |
| =================================================== |
| This single file handles everything: |
| 1. Downloads backup from Supabase on startup (if exists) |
| 2. Starts the Antigravity Manager app |
| 3. Watches for changes every 60s and backs up to Supabase |
| 4. On shutdown, does a final backup |
| |
| SETUP: Either edit the keys below OR set them as HF Space secrets. |
| HF secrets (env vars) override the values below. |
| """ |
|
|
| import os |
| import sys |
| import time |
| import signal |
| import hashlib |
| import tarfile |
| import tempfile |
| import logging |
| import subprocess |
| import threading |
|
|
| |
| |
| |
| |
| SUPABASE_URL = "" |
| SUPABASE_KEY = "" |
| SUPABASE_BUCKET = "antigravity-data" |
| |
| |
|
|
| |
| SUPABASE_URL = os.environ.get("SUPABASE_URL", SUPABASE_URL) |
| SUPABASE_KEY = os.environ.get("SUPABASE_KEY", SUPABASE_KEY) |
| SUPABASE_BUCKET = os.environ.get("SUPABASE_BUCKET", SUPABASE_BUCKET) |
|
|
| |
| DATA_DIR = "/root/.antigravity_tools" |
| BACKUP_FILENAME = "antigravity_backup.tar.gz" |
| SYNC_INTERVAL = 60 |
| APP_BINARY = "/app/antigravity-tools" |
|
|
| |
| logging.basicConfig( |
| level=logging.INFO, |
| format="[%(asctime)s] [SYNC] %(message)s", |
| datefmt="%H:%M:%S", |
| ) |
| log = logging.getLogger("sync") |
|
|
| |
| _last_hash = "" |
| _app_process = None |
|
|
| |
| |
| |
|
|
| try: |
| import requests as http |
| except ImportError: |
| log.error("'requests' module not found. Install it: pip install requests") |
| log.error("Sync will be DISABLED. The app will still run.") |
| http = None |
|
|
|
|
| def _supabase_ok(): |
| """Check if Supabase is configured.""" |
| if not SUPABASE_URL or not SUPABASE_KEY: |
| return False |
| if http is None: |
| return False |
| return True |
|
|
|
|
| def _headers(): |
| return { |
| "Authorization": f"Bearer {SUPABASE_KEY}", |
| "apikey": SUPABASE_KEY, |
| } |
|
|
|
|
| def _obj_url(path): |
| return f"{SUPABASE_URL.rstrip('/')}/storage/v1/object/{path}" |
|
|
|
|
| |
| |
| |
|
|
| def dir_hash(): |
| """Hash all file paths + sizes + mtimes to detect changes.""" |
| if not os.path.exists(DATA_DIR): |
| return "" |
| h = hashlib.sha256() |
| for root, dirs, files in os.walk(DATA_DIR): |
| dirs.sort() |
| for f in sorted(files): |
| fp = os.path.join(root, f) |
| rel = os.path.relpath(fp, DATA_DIR) |
| try: |
| st = os.stat(fp) |
| h.update(f"{rel}:{st.st_size}:{st.st_mtime_ns}".encode()) |
| except OSError: |
| pass |
| return h.hexdigest() |
|
|
|
|
| def sync_down(): |
| """Download backup from Supabase and extract to DATA_DIR.""" |
| if not _supabase_ok(): |
| log.info("Supabase not configured. Skipping restore.") |
| return False |
|
|
| log.info(f"Downloading backup from Supabase '{SUPABASE_BUCKET}'...") |
| try: |
| r = http.get( |
| _obj_url(f"{SUPABASE_BUCKET}/{BACKUP_FILENAME}"), |
| headers=_headers(), |
| timeout=30, |
| ) |
| except Exception as e: |
| log.error(f"Download failed: {e}") |
| return False |
|
|
| if r.status_code in (400, 404): |
| log.info("No backup found. Starting fresh.") |
| return False |
|
|
| if r.status_code != 200: |
| log.error(f"Download error: HTTP {r.status_code}") |
| return False |
|
|
| |
| tmp = tempfile.mktemp(suffix=".tar.gz") |
| try: |
| with open(tmp, "wb") as f: |
| f.write(r.content) |
| os.makedirs(DATA_DIR, exist_ok=True) |
| with tarfile.open(tmp, "r:gz") as tar: |
| tar.extractall(path=DATA_DIR) |
| count = sum(1 for _ in _walk_files()) |
| log.info(f"β
Restored {count} files from backup.") |
| return True |
| except Exception as e: |
| log.error(f"Extract failed: {e}") |
| return False |
| finally: |
| if os.path.exists(tmp): |
| os.unlink(tmp) |
|
|
|
|
| def sync_up(): |
| """Tar DATA_DIR and upload to Supabase.""" |
| if not _supabase_ok(): |
| return False |
|
|
| if not os.path.exists(DATA_DIR): |
| return False |
|
|
| files = list(_walk_files()) |
| if not files: |
| return False |
|
|
| log.info(f"Uploading backup ({len(files)} files)...") |
| tmp = tempfile.mktemp(suffix=".tar.gz") |
| try: |
| |
| with tarfile.open(tmp, "w:gz") as tar: |
| for fp in files: |
| arcname = os.path.relpath(fp, DATA_DIR) |
| tar.add(fp, arcname=arcname) |
| size_kb = os.path.getsize(tmp) / 1024 |
| log.info(f"Tarball: {size_kb:.1f} KB") |
|
|
| |
| url = _obj_url(f"{SUPABASE_BUCKET}/{BACKUP_FILENAME}") |
| headers = _headers() |
| headers["Content-Type"] = "application/gzip" |
| headers["x-upsert"] = "true" |
|
|
| with open(tmp, "rb") as f: |
| r = http.post(url, headers=headers, data=f, timeout=60) |
|
|
| |
| if r.status_code == 400: |
| with open(tmp, "rb") as f: |
| r = http.put(url, headers=headers, data=f, timeout=60) |
|
|
| if r.status_code in (200, 201): |
| log.info("β
Backup uploaded.") |
| return True |
| else: |
| log.error(f"Upload failed: HTTP {r.status_code} β {r.text[:200]}") |
| return False |
| except Exception as e: |
| log.error(f"Upload error: {e}") |
| return False |
| finally: |
| if os.path.exists(tmp): |
| os.unlink(tmp) |
|
|
|
|
| def _walk_files(): |
| """Yield all file paths in DATA_DIR.""" |
| if not os.path.exists(DATA_DIR): |
| return |
| for root, _, files in os.walk(DATA_DIR): |
| for f in files: |
| yield os.path.join(root, f) |
|
|
|
|
| |
| |
| |
|
|
| def watcher_loop(stop_event): |
| """Every SYNC_INTERVAL seconds, check for changes and sync.""" |
| global _last_hash |
|
|
| |
| waited = 0 |
| while not os.path.exists(DATA_DIR) and waited < 120: |
| time.sleep(5) |
| waited += 5 |
|
|
| |
| time.sleep(10) |
| sync_up() |
| _last_hash = dir_hash() |
|
|
| while not stop_event.is_set(): |
| stop_event.wait(SYNC_INTERVAL) |
| if stop_event.is_set(): |
| break |
|
|
| current = dir_hash() |
| if current != _last_hash: |
| log.info("Changes detected. Syncing...") |
| if sync_up(): |
| _last_hash = current |
|
|
|
|
| |
| |
| |
|
|
| def main(): |
| global _app_process |
|
|
| print("=" * 50) |
| print(" Antigravity Manager + Supabase Sync") |
| print("=" * 50) |
|
|
| |
| if _supabase_ok(): |
| print(f" Supabase: β
Configured ({SUPABASE_URL[:40]}...)") |
| print(f" Bucket: {SUPABASE_BUCKET}") |
| else: |
| print(" Supabase: β Not configured") |
| if not SUPABASE_URL: |
| print(" β SUPABASE_URL is empty") |
| if not SUPABASE_KEY: |
| print(" β SUPABASE_KEY is empty") |
| if http is None: |
| print(" β 'requests' module not installed") |
| print(" β οΈ Data will NOT persist across restarts!") |
| print("=" * 50) |
| print() |
|
|
| |
| sync_down() |
|
|
| |
| stop_event = threading.Event() |
| if _supabase_ok(): |
| watcher = threading.Thread(target=watcher_loop, args=(stop_event,), daemon=True) |
| watcher.start() |
| log.info(f"Watcher started (every {SYNC_INTERVAL}s)") |
|
|
| |
| log.info("Starting Antigravity Manager...") |
| _app_process = subprocess.Popen( |
| [APP_BINARY, "--headless"], |
| stdout=sys.stdout, |
| stderr=sys.stderr, |
| ) |
|
|
| |
| def shutdown(signum, frame): |
| log.info("Shutting down...") |
| stop_event.set() |
|
|
| |
| if _supabase_ok(): |
| log.info("Running final backup...") |
| sync_up() |
| log.info("Final backup done.") |
|
|
| |
| if _app_process and _app_process.poll() is None: |
| _app_process.terminate() |
| try: |
| _app_process.wait(timeout=10) |
| except subprocess.TimeoutExpired: |
| _app_process.kill() |
|
|
| sys.exit(0) |
|
|
| signal.signal(signal.SIGTERM, shutdown) |
| signal.signal(signal.SIGINT, shutdown) |
|
|
| |
| try: |
| _app_process.wait() |
| except KeyboardInterrupt: |
| shutdown(None, None) |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|