Merge pull request #105 from AIOSAI/feat/backup-fixbackup-archive-obsolete-files-timesta
feat(backup): fix(backup): archive obsolete files, timestamps consolidation, plugin cleanup
This commit is contained in:
@@ -23,13 +23,13 @@ from aipass.cli.apps.modules import console, error
|
||||
from aipass.prax import logger
|
||||
|
||||
# Explicit module imports (replaces dynamic discover_modules)
|
||||
from aipass.backup.apps.modules import backup_core, google_drive_sync, integrations, reauth_drive
|
||||
from aipass.backup.apps.modules import backup_core, google_drive_sync
|
||||
|
||||
|
||||
def get_modules():
|
||||
"""Return list of modules with handle_command() interface"""
|
||||
modules = []
|
||||
for mod in [backup_core, google_drive_sync, integrations, reauth_drive]:
|
||||
for mod in [backup_core, google_drive_sync]:
|
||||
if hasattr(mod, 'handle_command'):
|
||||
modules.append(mod)
|
||||
return modules
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
# Extensions package - Drop-in extensions for branch functionality
|
||||
@@ -1,140 +0,0 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: version_manager.py
|
||||
# Description: Version discovery and management
|
||||
# Version: 2.0.0
|
||||
# Created: 2025-11-16
|
||||
# Modified: 2026-03-09
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
Version Manager Handler
|
||||
|
||||
Discovers and manages versioned backup files using the file-organized structure.
|
||||
Supports version listing and filtering.
|
||||
"""
|
||||
|
||||
# =============================================
|
||||
# IMPORTS
|
||||
# =============================================
|
||||
|
||||
from aipass.prax import logger
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
# logger imported from aipass.prax
|
||||
|
||||
# Import from handlers
|
||||
from aipass.backup.apps.handlers.utils.system_utils import safe_print
|
||||
from aipass.backup.apps.handlers.json import json_handler
|
||||
|
||||
# =============================================
|
||||
# VERSION FILE DISCOVERY
|
||||
# =============================================
|
||||
|
||||
def get_versioned_files(backup_path: Path, file_path: str | None = None) -> Dict[str, List[str]]:
|
||||
"""Get all versioned files using new file-organized structure.
|
||||
|
||||
Args:
|
||||
backup_path: Path to backup root directory
|
||||
file_path: Optional filter to search for specific file
|
||||
|
||||
Returns:
|
||||
Dictionary mapping file paths to lists of version timestamps
|
||||
"""
|
||||
versioned_files = {}
|
||||
|
||||
json_handler.log_operation("version_discovery")
|
||||
if not backup_path.exists():
|
||||
return versioned_files
|
||||
|
||||
# Normalize search path for cross-platform compatibility
|
||||
normalized_search_path = None
|
||||
if file_path:
|
||||
normalized_search_path = str(Path(file_path)).replace('\\', '/').lower()
|
||||
|
||||
# NEW STRUCTURE: Look for *_diffs folders
|
||||
for diff_folder in backup_path.rglob('*_diffs'):
|
||||
if not diff_folder.is_dir():
|
||||
continue
|
||||
|
||||
# Extract base filename from diff folder name (remove _diffs suffix)
|
||||
if not diff_folder.name.endswith('_diffs'):
|
||||
continue
|
||||
|
||||
base_filename = diff_folder.name[:-6] # Remove '_diffs'
|
||||
|
||||
# Get relative path from backup root to the file
|
||||
# diff_folder structure: .../filename/filename_diffs/
|
||||
file_folder = diff_folder.parent
|
||||
if file_folder.name != base_filename:
|
||||
continue # Skip if structure doesn't match expected pattern
|
||||
|
||||
# Calculate base path for this file
|
||||
rel_path_to_file_folder = file_folder.relative_to(backup_path)
|
||||
base_path = str(rel_path_to_file_folder / base_filename)
|
||||
|
||||
# Filter by search path if provided
|
||||
if file_path:
|
||||
normalized_base_path = str(base_path).replace('\\', '/').lower()
|
||||
if normalized_search_path is not None and normalized_search_path not in normalized_base_path:
|
||||
continue
|
||||
|
||||
# Find all diff files in this folder
|
||||
versions = []
|
||||
for diff_file in diff_folder.glob('*_v*.diff'):
|
||||
filename = diff_file.name
|
||||
if '_v' in filename and filename.endswith('.diff'):
|
||||
# Extract version from filename: basename_v<timestamp>.diff
|
||||
version_part = filename.split('_v')[1].replace('.diff', '')
|
||||
versions.append(version_part)
|
||||
|
||||
if versions:
|
||||
versioned_files[base_path] = sorted(versions, reverse=True) # Newest first
|
||||
|
||||
return versioned_files
|
||||
|
||||
|
||||
def list_versioned_files(backup_path: Path) -> bool:
|
||||
"""List all versioned files with their version counts.
|
||||
|
||||
Args:
|
||||
backup_path: Path to backup root directory
|
||||
|
||||
Returns:
|
||||
True if files were found and listed, False otherwise
|
||||
"""
|
||||
try:
|
||||
versioned_files = get_versioned_files(backup_path)
|
||||
|
||||
if not versioned_files:
|
||||
safe_print("No versioned files found")
|
||||
return False
|
||||
|
||||
safe_print(f"\n{'='*70}")
|
||||
safe_print("VERSIONED FILES HISTORY")
|
||||
safe_print('='*70)
|
||||
|
||||
for file_path, versions in versioned_files.items():
|
||||
version_count = len(versions)
|
||||
latest_version = versions[0] if versions else "unknown"
|
||||
|
||||
safe_print(f" {file_path}")
|
||||
safe_print(f" Versions: {version_count} | Latest: {latest_version}")
|
||||
|
||||
# Show first 3 versions
|
||||
for i, version in enumerate(versions[:3]):
|
||||
marker = ">" if i == 0 else " "
|
||||
safe_print(f" {marker} v{version}")
|
||||
|
||||
if len(versions) > 3:
|
||||
safe_print(f" ... and {len(versions) - 3} more versions")
|
||||
safe_print("")
|
||||
|
||||
safe_print('='*70)
|
||||
safe_print(f"Use: python backup.py diff --file <path> to see changes")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
safe_print(f"Error listing versioned files: {e}")
|
||||
logger.error(f"[version_manager] Failed to list versioned files: {e}")
|
||||
return False
|
||||
@@ -1,223 +0,0 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: vscode_integration.py
|
||||
# Description: VS Code diff viewer integration
|
||||
# Version: 2.0.0
|
||||
# Created: 2025-11-16
|
||||
# Modified: 2026-03-09
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
VS Code Integration Handler
|
||||
|
||||
Opens file diffs in VS Code editor for visual comparison.
|
||||
Supports baseline vs current and version-to-version comparisons.
|
||||
"""
|
||||
|
||||
# =============================================
|
||||
# IMPORTS
|
||||
# =============================================
|
||||
|
||||
import sys
|
||||
import os
|
||||
import subprocess
|
||||
from aipass.prax import logger
|
||||
from pathlib import Path
|
||||
|
||||
# logger imported from aipass.prax
|
||||
|
||||
# Import from handlers
|
||||
from aipass.backup.apps.handlers.utils.system_utils import safe_print
|
||||
from aipass.backup.apps.handlers.diff.version_manager import get_versioned_files
|
||||
from aipass.backup.apps.handlers.json import json_handler
|
||||
|
||||
# =============================================
|
||||
# VS CODE INTEGRATION
|
||||
# =============================================
|
||||
|
||||
def show_file_diff(backup_path: Path, source_dir: Path, file_path: str, version1: str | None = None, version2: str | None = None) -> bool:
|
||||
"""Show diff between two versions of a file in VS Code.
|
||||
|
||||
Args:
|
||||
backup_path: Path to backup root directory
|
||||
source_dir: Path to source directory (for current file comparison)
|
||||
file_path: File path to show diff for
|
||||
version1: First version timestamp (optional)
|
||||
version2: Second version timestamp (optional)
|
||||
|
||||
Returns:
|
||||
True if diff was opened successfully, False otherwise
|
||||
"""
|
||||
try:
|
||||
json_handler.log_operation("vscode_diff_opened")
|
||||
versioned_files = get_versioned_files(backup_path, file_path)
|
||||
|
||||
if not versioned_files:
|
||||
safe_print(f"No versioned files found for: {file_path}")
|
||||
return False
|
||||
|
||||
# Find the matching file with normalized path comparison
|
||||
matching_file = None
|
||||
normalized_search_path = str(Path(file_path)).replace('\\', '/').lower()
|
||||
for base_path in versioned_files:
|
||||
normalized_base_path = str(base_path).replace('\\', '/').lower()
|
||||
if normalized_search_path in normalized_base_path:
|
||||
matching_file = base_path
|
||||
break
|
||||
|
||||
if not matching_file:
|
||||
safe_print(f"File not found: {file_path}")
|
||||
return False
|
||||
|
||||
versions = versioned_files[matching_file]
|
||||
|
||||
if len(versions) < 1:
|
||||
safe_print(f"Only one version found for: {file_path}")
|
||||
return False
|
||||
|
||||
# Determine which versions to compare
|
||||
if version1 and version2:
|
||||
if version1 not in versions or version2 not in versions:
|
||||
safe_print(f"Version not found. Available: {', '.join(versions)}")
|
||||
return False
|
||||
else:
|
||||
# Compare latest version with current file, or two most recent versions
|
||||
current_file = backup_path / matching_file
|
||||
if current_file.exists() and len(versions) >= 1:
|
||||
version1 = "current"
|
||||
version2 = versions[0] # Most recent version
|
||||
elif len(versions) >= 2:
|
||||
version1 = versions[1] # Second most recent
|
||||
version2 = versions[0] # Most recent
|
||||
else:
|
||||
safe_print(f"Need at least 2 versions to compare")
|
||||
return False
|
||||
|
||||
# Determine file paths for comparison using NEW STRUCTURE
|
||||
# For VS Code diff, we need actual files, not diff patches
|
||||
try:
|
||||
# NEW STRUCTURE: matching_file is like "root/CLAUDE.local.md/CLAUDE.local.md"
|
||||
# We need to find the file folder and look for baseline + current file
|
||||
|
||||
# Extract the base filename for pattern matching
|
||||
base_filename = Path(matching_file).name
|
||||
file_folder_path = backup_path / Path(matching_file).parent
|
||||
|
||||
# Look for baseline file in the file folder
|
||||
# Handle files with extensions like CLAUDE.local.md -> CLAUDE.local-baseline-*
|
||||
name_without_ext = base_filename.rsplit('.', 1)[0] if '.' in base_filename else base_filename
|
||||
baseline_pattern = f"{name_without_ext}-baseline-*"
|
||||
baseline_files = list(file_folder_path.glob(baseline_pattern))
|
||||
|
||||
# Current backup file path (in the organized structure)
|
||||
current_backup_file = file_folder_path / base_filename
|
||||
|
||||
# Source file path (live file in source directory)
|
||||
# Convert backup path back to source path
|
||||
matching_file_normalized = str(Path(matching_file)).replace('\\', '/')
|
||||
if matching_file_normalized.startswith("root/"):
|
||||
# Root level file: root/filename/filename -> filename
|
||||
source_relative_path = base_filename
|
||||
else:
|
||||
# Nested file: folder/filename/filename -> folder/filename
|
||||
source_relative_path = str(Path(matching_file).parent.parent / base_filename)
|
||||
|
||||
source_file_path = source_dir / source_relative_path
|
||||
|
||||
if baseline_files:
|
||||
# ALWAYS prefer baseline comparison when available
|
||||
file1_path = baseline_files[0] # Use first baseline found
|
||||
file2_path = source_file_path # Current source
|
||||
label1 = f"{base_filename} (baseline)"
|
||||
label2 = f"{base_filename} (current)"
|
||||
elif current_backup_file.exists():
|
||||
# No baseline, compare backup against current source
|
||||
file1_path = current_backup_file # Backup version
|
||||
file2_path = source_file_path # Live source
|
||||
label1 = f"{base_filename} (backup)"
|
||||
label2 = f"{base_filename} (current)"
|
||||
|
||||
# Check if they're the same
|
||||
if file1_path.exists() and file2_path.exists():
|
||||
if file1_path.stat().st_mtime == file2_path.stat().st_mtime:
|
||||
safe_print(f"Note: Backup is identical to current file (no changes)")
|
||||
safe_print(f" This happens when Google Drive sync updates the backup")
|
||||
else:
|
||||
safe_print(f"Could not find backup file in new structure: {matching_file}")
|
||||
return False
|
||||
|
||||
# Verify files exist
|
||||
if not file1_path.exists():
|
||||
safe_print(f"File not found: {file1_path}")
|
||||
return False
|
||||
if not file2_path.exists():
|
||||
safe_print(f"File not found: {file2_path}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
safe_print(f"Error locating files: {e}")
|
||||
logger.error(f"[vscode_integration] Error locating files for diff: {e}")
|
||||
return False
|
||||
|
||||
# Open diff in VS Code
|
||||
safe_print(f"\n{'='*70}")
|
||||
safe_print(f"Opening diff in VS Code: {matching_file}")
|
||||
safe_print(f"Comparing: {label2} -> {label1}")
|
||||
safe_print('='*70)
|
||||
|
||||
# Launch VS Code with diff view
|
||||
try:
|
||||
# Try to find VS Code command
|
||||
code_cmd = 'code'
|
||||
if sys.platform == 'win32':
|
||||
# On Windows, try common VS Code paths if 'code' not in PATH
|
||||
import shutil
|
||||
if not shutil.which('code'):
|
||||
# Platform-specific VS Code detection
|
||||
if sys.platform == 'win32':
|
||||
# Windows VS Code path
|
||||
vscode_path = Path(r"C:\Users") / os.environ.get('USERNAME', 'input') / r"AppData\Local\Programs\Microsoft VS Code\bin\code.cmd"
|
||||
if vscode_path.exists():
|
||||
code_cmd = str(vscode_path)
|
||||
else:
|
||||
code_cmd = 'code.cmd'
|
||||
else:
|
||||
# Linux - VS Code is usually in PATH or use snap
|
||||
if Path('/snap/bin/code').exists():
|
||||
code_cmd = '/snap/bin/code'
|
||||
elif Path('/usr/bin/code').exists():
|
||||
code_cmd = '/usr/bin/code'
|
||||
else:
|
||||
code_cmd = 'code'
|
||||
|
||||
# Use subprocess for better error handling
|
||||
# Swap order so baseline (older) is on left, current (newer) is on right
|
||||
result = subprocess.run(
|
||||
[code_cmd, '--diff', str(file1_path), str(file2_path)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
shell=True if sys.platform == 'win32' else False
|
||||
)
|
||||
if result.returncode == 0:
|
||||
safe_print(f"Diff opened in VS Code")
|
||||
else:
|
||||
safe_print(f"VS Code returned code {result.returncode}")
|
||||
if result.stderr:
|
||||
safe_print(f"Error: {result.stderr}")
|
||||
except FileNotFoundError:
|
||||
safe_print(f"VS Code command not found. Make sure 'code' is in your PATH")
|
||||
safe_print(f"You can manually compare:")
|
||||
safe_print(f" File 1: {file2_path}")
|
||||
safe_print(f" File 2: {file1_path}")
|
||||
return False
|
||||
except Exception as e:
|
||||
safe_print(f"Error launching VS Code: {e}")
|
||||
logger.error(f"[vscode_integration] Error launching VS Code: {e}")
|
||||
return False
|
||||
|
||||
safe_print('='*70)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
safe_print(f"Error generating diff: {e}")
|
||||
logger.error(f"[vscode_integration] Error in show_file_diff: {e}")
|
||||
return False
|
||||
@@ -1,111 +0,0 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: integration_ops.py
|
||||
# Description: Backup Integration Operations Handler
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-02-20
|
||||
# Modified: 2026-03-09
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
Backup Integration Operations Handler
|
||||
|
||||
Implementation logic for external integrations:
|
||||
- Google Drive sync operations
|
||||
- Backup directory read-only protection
|
||||
|
||||
Called by the integrations module orchestrator.
|
||||
"""
|
||||
|
||||
import os
|
||||
import stat
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
from aipass.backup.apps.handlers.json import json_handler
|
||||
|
||||
|
||||
def sync_to_drive(
|
||||
backup_path: Path,
|
||||
source_dir: Path,
|
||||
mode: str,
|
||||
backup_note: str,
|
||||
drive_sync_module: Any,
|
||||
drive_sync_available: bool,
|
||||
) -> bool:
|
||||
"""Sync versioned backups to Google Drive.
|
||||
|
||||
Args:
|
||||
backup_path: Path to backup directory to sync
|
||||
source_dir: Source directory being backed up (for project name)
|
||||
mode: Backup mode ('snapshot' or 'versioned')
|
||||
backup_note: Optional note describing the backup
|
||||
drive_sync_module: The GoogleDriveSync class (injected)
|
||||
drive_sync_available: Whether google_drive_sync is importable
|
||||
|
||||
Returns:
|
||||
bool: True if sync succeeded, False otherwise
|
||||
"""
|
||||
json_handler.log_operation("drive_sync_initiated")
|
||||
|
||||
if not drive_sync_available or drive_sync_module is None:
|
||||
return False
|
||||
|
||||
if mode != 'versioned':
|
||||
return False
|
||||
|
||||
try:
|
||||
project_name = "AIPass"
|
||||
drive_sync = drive_sync_module()
|
||||
|
||||
if not drive_sync.authenticate():
|
||||
return False
|
||||
|
||||
result = drive_sync.sync_backup_files(
|
||||
backup_dir=backup_path,
|
||||
project_name=project_name,
|
||||
note=backup_note
|
||||
)
|
||||
return result["success"] if isinstance(result, dict) else result
|
||||
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def set_backup_readonly(backup_path: Path) -> tuple[bool, str]:
|
||||
"""Set backup directory to read-only for protection.
|
||||
|
||||
Applies read-only permissions recursively to all directories and files.
|
||||
|
||||
Args:
|
||||
backup_path: Path to backup directory to protect
|
||||
|
||||
Returns:
|
||||
Tuple of (success: bool, message: str)
|
||||
"""
|
||||
if not backup_path.exists():
|
||||
return False, f"Backup path does not exist: {backup_path}"
|
||||
|
||||
try:
|
||||
protected_dirs = 0
|
||||
protected_files = 0
|
||||
|
||||
for root, dirs, files in os.walk(str(backup_path)):
|
||||
try:
|
||||
os.chmod(root, stat.S_IRUSR | stat.S_IRGRP | stat.S_IROTH |
|
||||
stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
||||
protected_dirs += 1
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
for file in files:
|
||||
file_path = os.path.join(root, file)
|
||||
try:
|
||||
os.chmod(file_path, stat.S_IRUSR | stat.S_IRGRP | stat.S_IROTH)
|
||||
protected_files += 1
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return True, f"Protected {protected_dirs} dirs, {protected_files} files"
|
||||
|
||||
except Exception as e:
|
||||
return False, f"Could not set read-only protection: {e}"
|
||||
@@ -20,7 +20,7 @@ from datetime import datetime
|
||||
from aipass.backup.apps.handlers.json import json_handler
|
||||
|
||||
_BACKUP_ROOT = Path(__file__).resolve().parents[3] # src/aipass/backup/
|
||||
TIMESTAMPS_FILE = _BACKUP_ROOT / "backup_data" / "backup_timestamps.json"
|
||||
TIMESTAMPS_FILE = _BACKUP_ROOT / "backup_json" / "backup_timestamps.json"
|
||||
|
||||
MODES = ["snapshot", "versioned", "drive_sync"]
|
||||
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: reauth_handler.py
|
||||
# Description: Google Drive Re-Authentication Handler
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-02-20
|
||||
# Modified: 2026-03-09
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
Google Drive Re-Authentication Handler
|
||||
|
||||
Implementation logic for re-authenticating with Google Drive via console OAuth flow.
|
||||
Called by the reauth_drive module orchestrator.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from aipass.backup.apps.handlers.json import json_handler
|
||||
|
||||
SCOPES = ['https://www.googleapis.com/auth/drive.file']
|
||||
CREDS_PATH = Path.home() / '.aipass' / 'drive_creds.json'
|
||||
|
||||
|
||||
def reauth(client_secrets_path: Path) -> bool:
|
||||
"""Perform Google Drive re-authentication via console OAuth flow.
|
||||
|
||||
Args:
|
||||
client_secrets_path: Path to the OAuth client secrets JSON file
|
||||
|
||||
Returns:
|
||||
bool: True if authentication succeeded, False otherwise
|
||||
"""
|
||||
try:
|
||||
from google_auth_oauthlib.flow import InstalledAppFlow # type: ignore[import-unresolved]
|
||||
from google.oauth2.credentials import Credentials # type: ignore[import-unresolved]
|
||||
from google.auth.transport.requests import Request # type: ignore[import-unresolved]
|
||||
from googleapiclient.discovery import build # type: ignore[import-unresolved]
|
||||
except ImportError as e:
|
||||
return False
|
||||
|
||||
json_handler.log_operation("reauth_initiated")
|
||||
|
||||
# Step 1: Try refreshing existing token first
|
||||
if CREDS_PATH.exists():
|
||||
try:
|
||||
creds = Credentials.from_authorized_user_file(str(CREDS_PATH), SCOPES)
|
||||
if creds and creds.expired and creds.refresh_token:
|
||||
creds.refresh(Request())
|
||||
with open(CREDS_PATH, 'w', encoding='utf-8') as f:
|
||||
f.write(creds.to_json())
|
||||
# Test connection
|
||||
service = build('drive', 'v3', credentials=creds)
|
||||
about = service.about().get(fields="user").execute()
|
||||
return True
|
||||
except Exception:
|
||||
pass # Proceed with full re-authentication
|
||||
|
||||
# Step 2: Full OAuth flow via console
|
||||
if not client_secrets_path.exists():
|
||||
return False
|
||||
|
||||
flow = InstalledAppFlow.from_client_secrets_file(str(client_secrets_path), SCOPES)
|
||||
|
||||
try:
|
||||
creds = flow.run_local_server(port=8085, open_browser=False)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
# Save new credentials
|
||||
CREDS_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(CREDS_PATH, 'w', encoding='utf-8') as f:
|
||||
f.write(creds.to_json())
|
||||
|
||||
# Test connection
|
||||
try:
|
||||
service = build('drive', 'v3', credentials=creds)
|
||||
about = service.about().get(fields="user,storageQuota").execute()
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
@@ -106,7 +106,7 @@ def print_introspection():
|
||||
console.print(f" [cyan]handlers/{domain}/[/cyan]")
|
||||
|
||||
console.print()
|
||||
console.print("[dim]Run 'python3 backup_core.py --help' for usage[/dim]")
|
||||
console.print("[dim]Run 'drone @backup --help' for usage[/dim]")
|
||||
console.print()
|
||||
|
||||
|
||||
@@ -123,8 +123,8 @@ def print_help():
|
||||
console.print("Main backup orchestration module")
|
||||
console.print()
|
||||
console.print("[yellow]Usage:[/yellow]")
|
||||
console.print(" python3 backup_core.py # Show module info")
|
||||
console.print(" python3 backup_core.py --help # Show this help")
|
||||
console.print(" drone @backup # Show module info")
|
||||
console.print(" drone @backup --help # Show this help")
|
||||
console.print()
|
||||
console.print("[yellow]Commands:[/yellow]")
|
||||
console.print(" snapshot Full copy backup (overwrites destination)")
|
||||
@@ -153,6 +153,10 @@ def handle_command(args) -> bool:
|
||||
if not hasattr(args, 'command'):
|
||||
return False
|
||||
|
||||
if args.command in ['--help', '-h', 'help']:
|
||||
print_help()
|
||||
return True
|
||||
|
||||
# Check for backup commands ('all' handled by entry point, listed here for drone discovery)
|
||||
if args.command not in ['snapshot', 'versioned', 'all']:
|
||||
return False
|
||||
|
||||
@@ -1,291 +0,0 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: integrations.py
|
||||
# Description: External integrations and backup protection
|
||||
# Version: 2.1.0
|
||||
# Created: 2025-11-29
|
||||
# Modified: 2026-03-09
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
Backup System Integrations Module
|
||||
|
||||
Handles external integrations for the backup system following seed architecture standards.
|
||||
Provides CLI command routing and integration orchestration.
|
||||
|
||||
This module manages optional features that integrate with external services
|
||||
or provide additional protection mechanisms for backups:
|
||||
- Google Drive cloud synchronization (optional)
|
||||
- Backup directory protection (read-only permissions)
|
||||
|
||||
Architecture Pattern:
|
||||
- handle_command(args) - CLI entry point for integration commands
|
||||
- Integration functions with proper error handling and logging
|
||||
- Graceful degradation for optional dependencies (Google Drive)
|
||||
"""
|
||||
|
||||
# =============================================
|
||||
# IMPORTS
|
||||
# =============================================
|
||||
|
||||
import sys
|
||||
import os
|
||||
import stat
|
||||
from pathlib import Path
|
||||
|
||||
from aipass.cli.apps.modules import console
|
||||
from aipass.prax import logger
|
||||
from aipass.backup.apps.handlers.json import json_handler
|
||||
|
||||
|
||||
def _header(text):
|
||||
console.print(f"\n[bold cyan]{'='*70}[/bold cyan]")
|
||||
console.print(f"[bold cyan] {text}[/bold cyan]")
|
||||
console.print(f"[bold cyan]{'='*70}[/bold cyan]")
|
||||
|
||||
|
||||
# Handler imports - relative imports
|
||||
from aipass.backup.apps.handlers.utils.system_utils import safe_print
|
||||
from aipass.backup.apps.handlers.operations.integration_ops import (
|
||||
sync_to_drive as _sync_to_drive_handler,
|
||||
set_backup_readonly as _set_backup_readonly_handler,
|
||||
)
|
||||
|
||||
# Google Drive sync integration (optional - graceful degradation if not available)
|
||||
try:
|
||||
from aipass.backup.apps.modules.google_drive_sync import GoogleDriveSync
|
||||
DRIVE_SYNC_AVAILABLE = True
|
||||
except ImportError:
|
||||
GoogleDriveSync = None # type: ignore
|
||||
DRIVE_SYNC_AVAILABLE = False
|
||||
logger.info("[integrations] Google Drive sync module not available - sync features disabled")
|
||||
|
||||
# =============================================
|
||||
# MODULE-LEVEL COMMAND HANDLER
|
||||
# =============================================
|
||||
|
||||
|
||||
def handle_command(args) -> bool | None:
|
||||
"""Route integration commands to appropriate handler.
|
||||
|
||||
This is the module-level entry point for CLI commands related to integrations.
|
||||
Routes integration-specific commands like sync-to-drive and set-readonly.
|
||||
|
||||
Args:
|
||||
args: Command-line arguments from CLI parser
|
||||
|
||||
Returns:
|
||||
bool: True if command succeeded, False otherwise, None if command not matched
|
||||
|
||||
Example:
|
||||
result = handle_command(args)
|
||||
if result:
|
||||
console.print("Integration command completed successfully")
|
||||
|
||||
Supported commands:
|
||||
- sync-to-drive: Sync backup to Google Drive
|
||||
- set-readonly: Protect backup with read-only permissions
|
||||
"""
|
||||
if not args:
|
||||
print_introspection()
|
||||
return True
|
||||
|
||||
if not hasattr(args, 'integration_command'):
|
||||
return None
|
||||
|
||||
json_handler.log_operation("integration_command")
|
||||
|
||||
if args.integration_command == 'sync-to-drive':
|
||||
backup_path = Path(args.backup_path) if hasattr(args, 'backup_path') else None
|
||||
source_dir = Path(args.source_dir) if hasattr(args, 'source_dir') else None
|
||||
mode = getattr(args, 'mode', 'versioned')
|
||||
backup_note = getattr(args, 'backup_note', '')
|
||||
|
||||
if not backup_path or not source_dir:
|
||||
logger.warning("[integrations] Missing required arguments for sync-to-drive")
|
||||
return False
|
||||
|
||||
return _sync_to_drive(backup_path, source_dir, mode, backup_note)
|
||||
|
||||
elif args.integration_command == 'set-readonly':
|
||||
backup_path = Path(args.backup_path) if hasattr(args, 'backup_path') else None
|
||||
if not backup_path:
|
||||
logger.warning("[integrations] Missing backup_path argument for set-readonly")
|
||||
return False
|
||||
|
||||
return _set_backup_readonly(backup_path)
|
||||
|
||||
return None
|
||||
|
||||
# =============================================
|
||||
# GOOGLE DRIVE INTEGRATION
|
||||
# =============================================
|
||||
|
||||
|
||||
def _sync_to_drive(backup_path: Path, source_dir: Path, mode: str, backup_note: str = "") -> bool:
|
||||
"""Orchestrate sync to Google Drive using integration handler.
|
||||
|
||||
Args:
|
||||
backup_path: Path to backup directory to sync
|
||||
source_dir: Source directory being backed up
|
||||
mode: Backup mode ('snapshot' or 'versioned')
|
||||
backup_note: Optional note describing the backup
|
||||
|
||||
Returns:
|
||||
bool: True if sync succeeded, False otherwise
|
||||
"""
|
||||
if not DRIVE_SYNC_AVAILABLE:
|
||||
safe_print("\033[93m[WARNING] Google Drive sync module not available - check backup_system installation\033[0m")
|
||||
logger.warning("[integrations] Drive sync requested but google_drive_sync module not found")
|
||||
return False
|
||||
|
||||
if mode != 'versioned':
|
||||
safe_print("[INFO] Drive sync only available for versioned backups")
|
||||
logger.info(f"[integrations] Drive sync skipped - mode {mode} not supported (versioned only)")
|
||||
return False
|
||||
|
||||
try:
|
||||
# Display sync header
|
||||
safe_print("\n" + "="*70)
|
||||
safe_print("\033[96m GOOGLE DRIVE SYNC\033[0m")
|
||||
safe_print("="*70)
|
||||
|
||||
project_name = "AIPass"
|
||||
safe_print(f"\033[92m✓\033[0m Google Drive folder name: \033[1m{project_name}\033[0m")
|
||||
safe_print(f"\033[92m✓\033[0m Destination: Google Drive (versioned backups)")
|
||||
safe_print("-"*70)
|
||||
|
||||
logger.info("[integrations] Starting Google Drive sync")
|
||||
safe_print("\033[94m[AUTH]\033[0m Verifying Google Drive credentials...")
|
||||
|
||||
success = _sync_to_drive_handler(
|
||||
backup_path=backup_path,
|
||||
source_dir=source_dir,
|
||||
mode=mode,
|
||||
backup_note=backup_note,
|
||||
drive_sync_module=GoogleDriveSync,
|
||||
drive_sync_available=DRIVE_SYNC_AVAILABLE,
|
||||
)
|
||||
|
||||
if success:
|
||||
safe_print("\033[92m✓ Sync completed successfully\033[0m")
|
||||
logger.info(f"[integrations] Drive sync completed successfully for {project_name}")
|
||||
else:
|
||||
logger.error("[integrations] Drive sync completed with errors")
|
||||
return success
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Drive sync failed: {e}"
|
||||
safe_print(f"[ERROR] {error_msg}")
|
||||
logger.error(f"[integrations] {error_msg}")
|
||||
return False
|
||||
|
||||
|
||||
|
||||
# =============================================
|
||||
# FILE PROTECTION
|
||||
# =============================================
|
||||
|
||||
|
||||
def _set_backup_readonly(backup_path: Path) -> bool:
|
||||
"""Orchestrate backup directory read-only protection using handler.
|
||||
|
||||
Args:
|
||||
backup_path: Path to backup directory to protect
|
||||
|
||||
Returns:
|
||||
bool: True if protection applied successfully, False otherwise
|
||||
"""
|
||||
try:
|
||||
if not backup_path.exists():
|
||||
warning_msg = f"Backup path does not exist: {backup_path}"
|
||||
safe_print(f"[WARNING] {warning_msg}")
|
||||
logger.warning(f"[integrations] {warning_msg}")
|
||||
return False
|
||||
|
||||
success, message = _set_backup_readonly_handler(backup_path)
|
||||
|
||||
if success:
|
||||
safe_print(f"[PROTECTION] Backup directory set to read-only: {backup_path}")
|
||||
logger.info(f"[integrations] Read-only set: {backup_path} ({message})")
|
||||
else:
|
||||
safe_print(f"[WARNING] {message}")
|
||||
logger.warning(f"[integrations] {message}")
|
||||
return success
|
||||
|
||||
except Exception as e:
|
||||
warning_msg = f"Could not set read-only protection: {e}"
|
||||
safe_print(f"[WARNING] {warning_msg}")
|
||||
logger.warning(f"[integrations] {warning_msg}")
|
||||
return False
|
||||
|
||||
|
||||
|
||||
# =============================================
|
||||
# MODULE INITIALIZATION
|
||||
# =============================================
|
||||
|
||||
# Log module initialization
|
||||
logger.info("[integrations] Module loaded - external integration support ready")
|
||||
if DRIVE_SYNC_AVAILABLE:
|
||||
logger.info("[integrations] Google Drive sync available")
|
||||
else:
|
||||
logger.info("[integrations] Google Drive sync unavailable - install google_drive_sync.py to enable")
|
||||
|
||||
# =============================================
|
||||
# MAIN ENTRY POINT
|
||||
# =============================================
|
||||
|
||||
def print_help():
|
||||
"""Display help information for the integrations module."""
|
||||
_header("BACKUP SYSTEM - INTEGRATIONS MODULE")
|
||||
safe_print("")
|
||||
safe_print("\033[1mPURPOSE:\033[0m")
|
||||
safe_print(" External integrations for the backup system")
|
||||
safe_print("")
|
||||
safe_print("\033[1mAVAILABLE SUBCOMMANDS:\033[0m")
|
||||
safe_print("")
|
||||
safe_print(" \033[92msync-to-drive\033[0m")
|
||||
safe_print(" Sync versioned backups to Google Drive cloud storage")
|
||||
safe_print(" Requires: backup_path, source_dir, mode, backup_note")
|
||||
safe_print(" Status: " + ("\033[92mAvailable\033[0m" if DRIVE_SYNC_AVAILABLE else "\033[93mUnavailable (google_drive_sync module not found)\033[0m"))
|
||||
safe_print("")
|
||||
safe_print(" \033[92mset-readonly\033[0m")
|
||||
safe_print(" Protect backup directory with read-only permissions")
|
||||
safe_print(" Requires: backup_path")
|
||||
safe_print(" Status: \033[92mAvailable\033[0m")
|
||||
safe_print("")
|
||||
safe_print("\033[1mUSAGE:\033[0m")
|
||||
safe_print(" This module is called via the backup system CLI:")
|
||||
safe_print(" \033[90m$ backup integration sync-to-drive --backup-path <path> ...\033[0m")
|
||||
safe_print(" \033[90m$ backup integration set-readonly --backup-path <path>\033[0m")
|
||||
safe_print("")
|
||||
safe_print("\033[1mOR\033[0m import functions directly in Python:")
|
||||
safe_print(" \033[90mfrom aipass.backup.apps.modules.integrations import handle_command\033[0m")
|
||||
safe_print("")
|
||||
safe_print("-"*70)
|
||||
safe_print("\033[1mCommands:\033[0m sync-to-drive, set-readonly")
|
||||
safe_print("="*70)
|
||||
safe_print("")
|
||||
|
||||
def print_introspection():
|
||||
"""Display module introspection info."""
|
||||
console.print()
|
||||
console.print("integrations Module")
|
||||
console.print("External integrations and backup protection (Drive sync, read-only)")
|
||||
console.print()
|
||||
console.print("Connected Handlers:")
|
||||
console.print(" handlers/utils/")
|
||||
console.print(" - system_utils.py (safe_print — terminal-safe output wrapper)")
|
||||
console.print(" handlers/operations/")
|
||||
console.print(" - integration_ops.py (sync_to_drive — Drive upload orchestration)")
|
||||
console.print(" - integration_ops.py (set_backup_readonly — read-only permission setter)")
|
||||
console.print()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
"""Display help when module is run directly."""
|
||||
import sys as _sys
|
||||
if len(_sys.argv) > 1 and _sys.argv[1] in ['--help', '-h', 'help']:
|
||||
print_help()
|
||||
else:
|
||||
print_help()
|
||||
@@ -1,152 +0,0 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: reauth_drive.py
|
||||
# Description: Google Drive Re-Authentication Module
|
||||
# Version: 1.2.0
|
||||
# Created: 2026-02-21
|
||||
# Modified: 2026-03-09
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
reauth_drive - Google Drive Re-Authentication Module
|
||||
|
||||
Standalone Google Drive re-authentication module.
|
||||
Uses console-based OAuth flow (no browser needed).
|
||||
Delegates implementation to reauth_handler.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from aipass.cli.apps.modules import console
|
||||
from aipass.cli.apps.modules.display import error
|
||||
from aipass.prax import logger
|
||||
from aipass.backup.apps.handlers.json import json_handler
|
||||
|
||||
# Handler imports
|
||||
from aipass.backup.apps.handlers.utils.reauth_handler import reauth as _run_reauth
|
||||
|
||||
CLIENT_SECRETS = Path(__file__).resolve().parents[1] / 'credentials.json'
|
||||
CREDS_PATH = Path.home() / '.aipass' / 'drive_creds.json'
|
||||
|
||||
|
||||
def handle_command(args) -> bool:
|
||||
"""Handle reauth commands routed from the backup_system orchestrator.
|
||||
|
||||
Args:
|
||||
args: Command-line arguments from CLI parser
|
||||
|
||||
Returns:
|
||||
bool: True if command handled, False otherwise
|
||||
"""
|
||||
if not args:
|
||||
print_introspection()
|
||||
return True
|
||||
|
||||
if not hasattr(args, 'command'):
|
||||
return False
|
||||
|
||||
command = args.command
|
||||
if command in ['--help', '-h', 'help']:
|
||||
print_help()
|
||||
return True
|
||||
|
||||
if command == 'reauth':
|
||||
json_handler.log_operation("reauth_command")
|
||||
return _execute_reauth()
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def print_help():
|
||||
"""Display help for the reauth_drive module."""
|
||||
console.print()
|
||||
console.print("[bold cyan]reauth_drive - Google Drive Re-Authentication[/bold cyan]")
|
||||
console.print()
|
||||
console.print("Re-authenticates with Google Drive using console OAuth flow.")
|
||||
console.print()
|
||||
console.print("[yellow]Usage:[/yellow]")
|
||||
console.print(" python3 reauth_drive.py # Run re-authentication")
|
||||
console.print(" python3 reauth_drive.py --help # Show this help")
|
||||
console.print()
|
||||
|
||||
|
||||
def _execute_reauth() -> bool:
|
||||
"""Orchestrate the re-authentication flow with user feedback."""
|
||||
try:
|
||||
from google.oauth2.credentials import Credentials
|
||||
from google.auth.transport.requests import Request # noqa: F811
|
||||
from googleapiclient.discovery import build
|
||||
except ImportError as e:
|
||||
error(f"Missing package: {e}")
|
||||
console.print(f"Install: {sys.executable} -m pip install google-api-python-client google-auth-httplib2 google-auth-oauthlib")
|
||||
return False
|
||||
|
||||
if not CLIENT_SECRETS.exists():
|
||||
error(f"Client secrets not found at: {CLIENT_SECRETS}")
|
||||
logger.warning(f"OAuth client secrets not found at: {CLIENT_SECRETS}")
|
||||
return False
|
||||
|
||||
console.print()
|
||||
console.print("[bold cyan]" + "="*60 + "[/bold cyan]")
|
||||
console.print("[bold cyan]GOOGLE DRIVE RE-AUTHENTICATION[/bold cyan]")
|
||||
console.print("[bold cyan]" + "="*60 + "[/bold cyan]")
|
||||
|
||||
# Check for token refresh first
|
||||
if CREDS_PATH.exists():
|
||||
try:
|
||||
creds = Credentials.from_authorized_user_file(str(CREDS_PATH), ['https://www.googleapis.com/auth/drive.file'])
|
||||
if creds and creds.expired and creds.refresh_token:
|
||||
console.print("Attempting token refresh...")
|
||||
logger.info("Attempting Drive token refresh")
|
||||
except Exception as e:
|
||||
logger.warning(f"Token refresh check failed: {e}")
|
||||
|
||||
console.print()
|
||||
console.print("Starting local auth server...")
|
||||
console.print("A URL will be printed below - open it in your browser.")
|
||||
console.print("After authorizing, the browser will redirect to localhost.")
|
||||
console.print()
|
||||
|
||||
success = _run_reauth(CLIENT_SECRETS)
|
||||
|
||||
if success:
|
||||
console.print("[green]Re-authentication SUCCESSFUL![/green]")
|
||||
logger.info("Drive re-authentication successful")
|
||||
# Show account info
|
||||
try:
|
||||
creds = Credentials.from_authorized_user_file(str(CREDS_PATH), ['https://www.googleapis.com/auth/drive.file'])
|
||||
service = build('drive', 'v3', credentials=creds)
|
||||
about = service.about().get(fields="user,storageQuota").execute()
|
||||
email = about['user']['emailAddress']
|
||||
quota = about.get('storageQuota', {})
|
||||
usage_gb = int(quota.get('usage', 0)) / (1024**3)
|
||||
limit_gb = int(quota.get('limit', 0)) / (1024**3)
|
||||
console.print(f"Authenticated as: {email}")
|
||||
console.print(f"Storage: {usage_gb:.2f} GB / {limit_gb:.2f} GB")
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
error("Re-authentication FAILED")
|
||||
logger.error("Drive re-authentication failed")
|
||||
|
||||
return success
|
||||
|
||||
|
||||
def print_introspection():
|
||||
"""Display module introspection info."""
|
||||
console.print()
|
||||
console.print("reauth_drive Module")
|
||||
console.print("Google Drive re-authentication via console OAuth flow")
|
||||
console.print()
|
||||
console.print("Connected Handlers:")
|
||||
console.print(" handlers/utils/")
|
||||
console.print(" - reauth_handler.py (reauth — OAuth credential refresh and re-auth)")
|
||||
console.print()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) > 1 and sys.argv[1] in ['--help', '-h', 'help']:
|
||||
print_help()
|
||||
sys.exit(0)
|
||||
success = _execute_reauth()
|
||||
sys.exit(0 if success else 1)
|
||||
@@ -1 +0,0 @@
|
||||
# Plugins package - Pluggable components for branch capabilities
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
# Placeholder
|
||||
@@ -1,9 +0,0 @@
|
||||
# TPLAN-0001: Test TPLAN auto-discovery
|
||||
|
||||
Tag:
|
||||
|
||||
> Test template for auto-discovery verification
|
||||
|
||||
---
|
||||
|
||||
*Created: 2026-03-18*
|
||||
@@ -1,13 +0,0 @@
|
||||
# TPLAN-0001: Test TPLAN via registry
|
||||
|
||||
Tag:
|
||||
|
||||
> Testing template — auto-discovered from filesystem
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
---
|
||||
*Created: 2026-03-18*
|
||||
*Updated: 2026-03-18*
|
||||
Reference in New Issue
Block a user