diff --git a/dsync b/dsync new file mode 100644 index 0000000..e77c21c --- /dev/null +++ b/dsync @@ -0,0 +1,463 @@ +#!/usr/bin/env python3 + +import sys +import json +import subprocess +from pathlib import Path +import concurrent.futures + +# ANSI color codes +class Colors: + RED = '\033[31m' + GREEN = '\033[32m' + ORANGE = '\033[33m' + YELLOW = '\033[1;33m' + BLUE = '\033[34m' + MAGENTA = '\033[35m' + CYAN = '\033[36m' + WHITE = '\033[37m' + RESET = '\033[0m' + BOLD = '\033[1m' + +def colored(text, color): + """Apply color to text""" + return f"{color}{text}{Colors.RESET}" + +def find_file_up(filename: str, start_dir: Path = Path.cwd()) -> Path: + current_dir = start_dir + while current_dir != current_dir.parent: + candidate = current_dir / filename + if candidate.exists(): + return candidate + current_dir = current_dir.parent + print(colored(f"Error: {filename} not found in this directory or any parent.", Colors.RED)) + sys.exit(1) + +def load_config(): + config_path = find_file_up(".dsyncconfig") + with open(config_path) as f: + return json.load(f), config_path.parent + +def check_node_status(node, project_dir, ignore_file, master_dir): + """Check status for a single node""" + remote = f"{node}:{master_dir}" + + cmd = [ + "rsync", "-avz", "--dry-run", "--itemize-changes", "--delete", + f"--exclude-from={ignore_file}", + "-e", "ssh -o StrictHostKeyChecking=no -o ConnectTimeout=5", + f"{project_dir}/", remote + ] + + try: + result = subprocess.run(cmd, capture_output=True, text=True, timeout=30) + + if result.returncode == 0: + # Parse rsync output + lines = result.stdout.strip().split('\n') + changes = [] + + for line in lines: + if line.startswith('>f') or line.startswith('>d') or line.startswith('*deleting'): + changes.append(line) + elif line.startswith(' 5: + print(f" ... and {len(changes) - 5} more change(s)") + else: + print(colored(f"[X] {node}: {result.get('error', 'Unknown error')}", Colors.RED)) + + print() + + # Summary + active_nodes = sum(1 for r in results if r['status'] == 'ok') + error_nodes = sum(1 for r in results if r['status'] != 'ok') + + print("-" * 50) + if total_changes > 0: + print(colored(f"Summary: {active_nodes} active, {error_nodes} error(s), {total_changes} total changes", Colors.YELLOW)) + print(colored("Run 'dsync sync' to apply changes", Colors.YELLOW)) + else: + print(colored(f"Summary: {active_nodes} active, {error_nodes} error(s), {total_changes} total changes", Colors.GREEN)) + if error_nodes == 0: + print(colored("All nodes are up to date!", Colors.GREEN)) + +def parse_rsync_action(line): + """Parse rsync itemize-changes output into human-readable format""" + if line.startswith('*deleting'): + filename = line.split(' ', 1)[1].strip() + return f"DELETE: {filename}" + + if len(line) < 11: + return line.strip() + + # Parse rsync itemize format: YXcstpoguax filename + changes = line[0:11] + filename = line[11:].strip() + + if not filename: + return line.strip() + + # Check file type and direction + file_type = changes[0] + if file_type == '<': + direction = "SEND" + elif file_type == '>': + direction = "RECV" + elif file_type == 'c': + direction = "CREATE" + elif file_type == 'h': + direction = "HARDLINK" + elif file_type == '.': + direction = "UPDATE" + else: + direction = "CHANGE" + + # Parse item type + change_type = changes[1] + if change_type == 'f': + item_type = "file" + elif change_type == 'd': + item_type = "directory" + elif change_type == 'L': + item_type = "symlink" + elif change_type == 'D': + item_type = "device" + elif change_type == 'S': + item_type = "special" + else: + item_type = "item" + + # Parse specific changes + change_details = changes[2:11] + details = [] + + if change_details[0] == '+': + return f"NEW {item_type.upper()}: {filename}" + elif change_details[0] == 'c': + details.append("content") + + if change_details[1] == 's': + details.append("size") + if change_details[2] == 't': + details.append("timestamp") + if change_details[3] == 'p': + details.append("permissions") + if change_details[4] == 'o': + details.append("owner") + if change_details[5] == 'g': + details.append("group") + if change_details[6] == 'u': + details.append("unused") + if change_details[7] == 'a': + details.append("ACL") + if change_details[8] == 'x': + details.append("extended attributes") + + # Build result with direction and item type + if details: + detail_str = ", ".join(details) + return f"{direction} {item_type.upper()}: {filename} ({detail_str})" + else: + return f"{direction} {item_type.upper()}: {filename}" + +def sync(): + config, project_dir = load_config() + ignore_file = project_dir / config.get("ignore_file", ".dsyncignore") + master_dir = config.get("master_dir", str(project_dir)) + + # Ensure ignore file exists + if not ignore_file.exists(): + ignore_file.write_text("# Add patterns to ignore during sync\n") + + success_count = 0 + failed_nodes = [] + + for node in config["nodes"]: + remote = f"{node}:{master_dir}" + print(f"Syncing to {remote}...") + + cmd = [ + "rsync", "-avz", "--delete", + f"--exclude-from={ignore_file}", + "-e", "ssh -o StrictHostKeyChecking=no -o ConnectTimeout=10", + f"{project_dir}/", remote + ] + + try: + subprocess.run(cmd, check=True, capture_output=True, text=True) + print(colored(f"[OK] Successfully synced to {node}", Colors.GREEN)) + success_count += 1 + except subprocess.CalledProcessError as e: + print(colored(f"[X] Failed to sync to {node}: {e}", Colors.RED)) + failed_nodes.append(node) + + print() + if success_count == len(config['nodes']): + print(colored(f"Sync completed: {success_count}/{len(config['nodes'])} nodes successful", Colors.GREEN)) + else: + print(colored(f"Sync completed: {success_count}/{len(config['nodes'])} nodes successful", Colors.YELLOW)) + + if failed_nodes: + print(colored(f"Failed nodes: {', '.join(failed_nodes)}", Colors.RED)) + +def init(master_dir: Path = Path.cwd()): + config_file = master_dir / ".dsyncconfig" + ignore_file = master_dir / ".dsyncignore" + + if config_file.exists(): + print(colored(f"dsync already initialized in {master_dir}", Colors.YELLOW)) + return + + config = { + "master_dir": str(master_dir), + "nodes": [], + "ignore_file": ".dsyncignore" + } + + config_file.write_text(json.dumps(config, indent=2)) + + if not ignore_file.exists(): + ignore_content = """# dsync ignore patterns +# Add patterns to ignore during sync +.git/ +.gitignore +.dsyncconfig +.dsyncignore +__pycache__/ +*.pyc +*.pyo +*.pyd +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ +.vscode/ +.idea/ +*.log +node_modules/ +.npm +.cache/ +""" + ignore_file.write_text(ignore_content) + + print(colored(f"Initialized dsync config in {master_dir}", Colors.GREEN)) + +def add_nodes(*nodes): + if not nodes: + print(colored("No nodes provided", Colors.YELLOW)) + return + + config, project_dir = load_config() + added_nodes = [] + + for node in nodes: + if node not in config["nodes"]: + config["nodes"].append(node) + added_nodes.append(node) + else: + print(colored(f"Node {node} already exists", Colors.YELLOW)) + + if added_nodes: + (project_dir / ".dsyncconfig").write_text(json.dumps(config, indent=2)) + print(colored(f"Added nodes: {', '.join(added_nodes)}", Colors.GREEN)) + else: + print(colored("No new nodes added", Colors.YELLOW)) + +def del_nodes(*nodes): + if not nodes: + print(colored("No nodes provided", Colors.YELLOW)) + return + + config, project_dir = load_config() + removed_nodes = [] + + for node in nodes: + if node in config["nodes"]: + config["nodes"].remove(node) + removed_nodes.append(node) + else: + print(colored(f"Node {node} not found", Colors.YELLOW)) + + if removed_nodes: + (project_dir / ".dsyncconfig").write_text(json.dumps(config, indent=2)) + print(colored(f"Removed nodes: {', '.join(removed_nodes)}", Colors.GREEN)) + else: + print(colored("No nodes removed", Colors.YELLOW)) + +def list_nodes(): + config, _ = load_config() + nodes = config.get("nodes", []) + if nodes: + print(colored("Configured nodes:", Colors.CYAN)) + for i, node in enumerate(nodes, 1): + print(f" {i}. {node}") + else: + print(colored("No nodes configured", Colors.YELLOW)) + +def show_help(): + help_text = """ +dsync.py - Directory Synchronization Tool + +Usage: + dsync.py init Initialize dsync in current directory + dsync.py add-node [node2...] Add one or more nodes + dsync.py del-node [node2...] Remove one or more nodes + dsync.py list-nodes List all configured nodes + dsync.py status Show sync status for all nodes + dsync.py sync Sync to all configured nodes + dsync.py help Show this help message + +Examples: + dsync.py init + dsync.py add-node user@server1.com user@server2.com + dsync.py del-node user@server1.com + dsync.py status + dsync.py sync + +Notes: + - Status command shows pending changes without syncing + - Sync uses rsync with --delete flag to handle file deletions + - SSH keys should be configured for passwordless access + - Edit .dsyncignore to exclude files from sync +""" + print(help_text) + +if __name__ == "__main__": + if len(sys.argv) < 2: + show_help() + sys.exit(1) + + cmd = sys.argv[1] + + if cmd == "init": + init() + elif cmd == "add-node": + if len(sys.argv) < 3: + print(colored("Usage: dsync.py add-node [node2...]", Colors.RED)) + sys.exit(1) + add_nodes(*sys.argv[2:]) + elif cmd == "del-node": + if len(sys.argv) < 3: + print(colored("Usage: dsync.py del-node [node2...]", Colors.RED)) + sys.exit(1) + del_nodes(*sys.argv[2:]) + elif cmd == "list-nodes": + list_nodes() + elif cmd == "status": + status() + elif cmd == "sync": + sync() + elif cmd == "help": + show_help() + else: + print(colored(f"Unknown command: {cmd}", Colors.RED)) + show_help() + sys.exit(1) \ No newline at end of file