From e2b20b0cd77cddfbb3c3a9f67b83268813b559ea Mon Sep 17 00:00:00 2001 From: ilyukhin Date: Wed, 17 Sep 2025 02:01:15 +0000 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=B0=20=D0=B2=D0=BE=D0=B7=D0=BC=D0=BE=D0=B6=D0=BD=D0=BE?= =?UTF-8?q?=D1=81=D1=82=D1=8C=20=D1=83=D1=81=D1=82=D0=B0=D0=BD=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B8=D0=B2=D0=B0=D1=82=D1=8C=20=D1=80=D0=B0=D0=B7=D0=BB?= =?UTF-8?q?=D0=B8=D1=87=D0=BD=D1=8B=D0=B5=20=D0=BF=D1=83=D1=82=D0=B8=20?= =?UTF-8?q?=D0=B4=D0=BB=D1=8F=20=D0=BD=D0=BE=D0=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- dsync | 576 ++++++++++++++++++++++++++++++++++++++++------------------ 1 file changed, 395 insertions(+), 181 deletions(-) diff --git a/dsync b/dsync index 16749c7..186cca0 100644 --- a/dsync +++ b/dsync @@ -1,15 +1,18 @@ #!/usr/bin/env python3 -VERSION = "0.1.0" +VERSION = "0.2.0" +import re import sys import json import subprocess from pathlib import Path import concurrent.futures +from typing import Any, Dict, List, Optional, Tuple + -# ANSI color codes class Colors: + """ANSI color escape codes for simple CLI highlighting.""" RED = '\033[31m' GREEN = '\033[32m' ORANGE = '\033[33m' @@ -21,11 +24,33 @@ class Colors: RESET = '\033[0m' BOLD = '\033[1m' -def colored(text, color): - """Apply color to text""" + +def colored(text: str, color: str) -> str: + """Wrap text with an ANSI color. + + Args: + text: String to colorize. + color: One of the `Colors` constants. + + Returns: + Colorized string with a trailing reset sequence. + """ return f"{color}{text}{Colors.RESET}" + def find_file_up(filename: str, start_dir: Path = Path.cwd()) -> Path: + """Search for a file by walking up parent directories. + + Args: + filename: File to find (e.g., ".dsyncconfig"). + start_dir: Directory to start the upward search from. + + Returns: + Absolute path to the first matching file found. + + Raises: + SystemExit: If the file cannot be found up to filesystem root. + """ current_dir = start_dir while current_dir != current_dir.parent: candidate = current_dir / filename @@ -35,55 +60,140 @@ def find_file_up(filename: str, start_dir: Path = Path.cwd()) -> Path: print(colored(f"Error: {filename} not found in this directory or any parent.", Colors.RED)) sys.exit(1) -def load_config(): + +def load_config() -> Tuple[Dict[str, Any], Path]: + """Load configuration from `.dsyncconfig`. + + Returns: + (config dict, project root path where the config lives). + """ config_path = find_file_up(".dsyncconfig") with open(config_path) as f: - return json.load(f), config_path.parent + cfg = json.load(f) + return cfg, config_path.parent + + +def save_config(config: Dict[str, Any], project_dir: Path) -> None: + """Persist configuration back to `.dsyncconfig`. + + Args: + config: Configuration object to save. + project_dir: Directory containing `.dsyncconfig`. + """ + (project_dir / ".dsyncconfig").write_text(json.dumps(config, indent=2)) + + +def _normalize_nodes(config: Dict[str, Any]) -> List[Dict[str, Optional[str]]]: + """Normalize nodes list to dict objects with optional remote_dir. + + Backward-compatibility: + - Old configs might have `nodes` as a list of strings ["user@host", ...]. + - New format is: [{"name": "user@host", "remote_dir": "/path" | None}, ...] + + Args: + config: Loaded dsync configuration. + + Returns: + List of node dicts with keys: "name", "remote_dir". + """ + norm: List[Dict[str, Optional[str]]] = [] + for item in config.get("nodes", []): + if isinstance(item, str): + norm.append({"name": item, "remote_dir": None}) + elif isinstance(item, dict) and "name" in item: + norm.append({"name": item["name"], "remote_dir": item.get("remote_dir")}) + return norm + + +def _index_by_name(nodes: List[Dict[str, Optional[str]]], name: str) -> int: + """Find node index by name. + + Args: + nodes: Normalized node list. + name: Node identifier, e.g. "user@host". + + Returns: + Index in list, or -1 if not found. + """ + for i, n in enumerate(nodes): + if n.get("name") == name: + return i + return -1 + + +# Accepts either "user@host" or "user@host:/absolute/dir" +_NODE_ARG_RE = re.compile(r"^(?P[^:]+):(?P/.*)$") + + +def _parse_node_token(token: str) -> Tuple[str, Optional[str]]: + """Parse a node token into (name, remote_dir). + + Supported forms: + "user@host" -> ("user@host", None) + "user@host:/path/to/dir" -> ("user@host", "/path/to/dir") + + Args: + token: CLI argument representing a node. + + Returns: + Tuple of (node_name, remote_dir_or_None). + """ + m = _NODE_ARG_RE.match(token) + if m: + return m.group("name"), m.group("dir") + return token, None + -def check_node_status(node, project_dir, ignore_file, master_dir): - """Check status for a single node""" - remote = f"{node}:{master_dir}" - +def check_node_status(node_name: str, project_dir: Path, ignore_file: Path, remote_dir: str) -> Dict[str, Any]: + """Compute rsync dry-run diff for a single node. + + Args: + node_name: SSH target in form "user@host". + project_dir: Local project directory to sync from. + ignore_file: Path to rsync exclude file. + remote_dir: Remote directory for this node. + + Returns: + Dict with fields: node, remote_dir, status ("ok"|"error"|"timeout"), + changes (list[str]), total_changes (int), error (optional). + """ + remote = f"{node_name}:{remote_dir}" + + # --itemize-changes yields machine-parsable change indicators we can explain. 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 = [] - + lines = result.stdout.strip().split('\n') if result.stdout.strip() else [] + changes: List[str] = [] + # Filter only meaningful change lines for line in lines: - if line.startswith('>f') or line.startswith('>d') or line.startswith('*deleting'): - changes.append(line) - elif line.startswith('f', '>d', '*deleting', ' None: + """Show synchronization status for all nodes (including per-node directories).""" 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 + + # Ensure exclude file exists (empty is fine) if not ignore_file.exists(): ignore_file.write_text("# Add patterns to ignore during sync\n") - - nodes = config.get("nodes", []) + + nodes = _normalize_nodes(config) if not nodes: print(colored("No nodes configured", Colors.YELLOW)) return - + print(f"Checking status for {len(nodes)} node(s)...") print("-" * 50) - - # Check all nodes in parallel for speed + + # Run rsync dry-runs in parallel for speed. with concurrent.futures.ThreadPoolExecutor(max_workers=min(len(nodes), 10)) as executor: futures = { - executor.submit(check_node_status, node, project_dir, ignore_file, master_dir): node - for node in nodes + executor.submit( + check_node_status, + n["name"], project_dir, ignore_file, + n["remote_dir"] or master_dir + ): n["name"] for n in nodes } - - results = [] + results: List[Dict[str, Any]] = [] for future in concurrent.futures.as_completed(futures): - result = future.result() - results.append(result) - - # Sort results by node name for consistent output + results.append(future.result()) + results.sort(key=lambda x: x['node']) - - # Display results + total_changes = 0 - for result in results: - node = result['node'] - status = result['status'] - changes = result['changes'] - change_count = result['total_changes'] - - if status == 'ok': - if change_count == 0: - print(colored(f"[OK] {node}: Up to date", Colors.GREEN)) + for r in results: + node = r['node'] + rdir = r.get('remote_dir') or master_dir + st = r['status'] + changes = r['changes'] + cnt = r['total_changes'] + + header = f"{node} ({rdir})" + if st == 'ok': + if cnt == 0: + print(colored(f"[OK] {header}: Up to date", Colors.GREEN)) else: - print(colored(f"[!] {node}: {change_count} change(s) pending", Colors.YELLOW)) - total_changes += change_count - - # Show first few changes - for i, change in enumerate(changes[:5]): - action = parse_rsync_action(change) - print(f" {action}") - + print(colored(f"[!] {header}: {cnt} change(s) pending", Colors.YELLOW)) + total_changes += cnt + # Show a small preview of the change list + for ch in changes[:5]: + print(f" {parse_rsync_action(ch)}") if len(changes) > 5: print(f" ... and {len(changes) - 5} more change(s)") else: - print(colored(f"[X] {node}: {result.get('error', 'Unknown error')}", Colors.RED)) - + print(colored(f"[X] {header}: {r.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)) @@ -171,28 +279,35 @@ def status(): 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""" + +def parse_rsync_action(line: str) -> str: + """Translate `rsync --itemize-changes` markers into readable messages. + + Args: + line: Single rsync itemize line. + + Returns: + Human-readable description of the change. + """ 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 + + # rsync itemize format: YXcstpoguax filename changes = line[0:11] filename = line[11:].strip() - if not filename: return line.strip() - - # Check file type and direction + + # Direction file_type = changes[0] if file_type == '<': - direction = "SEND" + direction = "SEND" # local -> remote elif file_type == '>': - direction = "RECV" + direction = "RECV" # remote -> local (rare in our usage) elif file_type == 'c': direction = "CREATE" elif file_type == 'h': @@ -201,8 +316,8 @@ def parse_rsync_action(line): direction = "UPDATE" else: direction = "CHANGE" - - # Parse item type + + # Item kind change_type = changes[1] if change_type == 'f': item_type = "file" @@ -216,16 +331,15 @@ def parse_rsync_action(line): item_type = "special" else: item_type = "item" - - # Parse specific changes + + # Specific attributes change_details = changes[2:11] - details = [] - + details: List[str] = [] + 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': @@ -242,70 +356,81 @@ def parse_rsync_action(line): 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(): + return f"{direction} {item_type.upper()}: {filename}" if not details \ + else f"{direction} {item_type.upper()}: {filename} ({', '.join(details)})" + + +def sync() -> None: + """Synchronize local project directory to all nodes, honoring per-node dirs. + + Uses rsync with `--delete` so remote deletions track local deletions. + """ 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") - + + nodes = _normalize_nodes(config) + if not nodes: + print(colored("No nodes configured", Colors.YELLOW)) + return + success_count = 0 - failed_nodes = [] - - for node in config["nodes"]: - remote = f"{node}:{master_dir}" + failed_nodes: List[str] = [] + + for n in nodes: + node = n["name"] + rdir = n["remote_dir"] or master_dir + remote = f"{node}:{rdir}" 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)) + print(colored(f"[OK] Successfully synced to {node} ({rdir})", 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(colored(f"[X] Failed to sync to {node} ({rdir}): {e}", Colors.RED)) + failed_nodes.append(f"{node} ({rdir})") + print() - if success_count == len(config['nodes']): - print(colored(f"Sync completed: {success_count}/{len(config['nodes'])} nodes successful", Colors.GREEN)) + if success_count == len(nodes): + print(colored(f"Sync completed: {success_count}/{len(nodes)} nodes successful", Colors.GREEN)) else: - print(colored(f"Sync completed: {success_count}/{len(config['nodes'])} nodes successful", Colors.YELLOW)) - + print(colored(f"Sync completed: {success_count}/{len(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()): + +def init(master_dir: Path = Path.cwd()) -> None: + """Initialize dsync in the given directory (creates config & ignore). + + Args: + master_dir: Directory to treat as project root (default: 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": [], + "nodes": [], # list of {"name": "...", "remote_dir": "/path"|None} "ignore_file": ".dsyncignore" } - config_file.write_text(json.dumps(config, indent=2)) - + + # Seed a reasonable ignore file for common Python/JS build clutter. if not ignore_file.exists(): ignore_content = """# dsync ignore patterns # Add patterns to ignore during sync @@ -349,114 +474,203 @@ node_modules/ .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: + +def add_nodes(*args: str) -> None: + """Add one or multiple nodes. + + Supported forms: + dsync add-node user@host + dsync add-node user@host:/remote/dir + dsync add-node user@host /remote/dir # alt form for a single node + dsync add-node user@h1 user@h2:/dir user@h3 ... + + Args: + *args: Node tokens, see forms above. + """ + if not args: 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) + nodes = _normalize_nodes(config) + master_dir = config.get("master_dir", str(project_dir)) + + # Special alt-form: exactly two args "user@host /remote/dir" + if len(args) == 2 and args[1].startswith(("/", ".", "~")): + name, rdir = args[0], args[1] + idx = _index_by_name(nodes, name) + if idx >= 0: + print(colored(f"Node {name} already exists", Colors.YELLOW)) 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)) + nodes.append({"name": name, "remote_dir": rdir}) + print(colored(f"Added node: {name} ({rdir})", Colors.GREEN)) + config["nodes"] = nodes + save_config(config, project_dir) + return + + added_any = False + for tok in args: + name, rdir = _parse_node_token(tok) + idx = _index_by_name(nodes, name) + if idx >= 0: + print(colored(f"Node {name} already exists", Colors.YELLOW)) + continue + nodes.append({"name": name, "remote_dir": rdir}) + print(colored(f"Added node: {name} ({rdir or master_dir})", Colors.GREEN)) + added_any = True + + if added_any: + config["nodes"] = nodes + save_config(config, project_dir) else: print(colored("No new nodes added", Colors.YELLOW)) -def del_nodes(*nodes): - if not nodes: + +def del_nodes(*nodes_to_remove: str) -> None: + """Remove one or multiple nodes by name. + + Args: + *nodes_to_remove: Node names to delete (e.g., "user@host"). + """ + if not nodes_to_remove: 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) + nodes = _normalize_nodes(config) + + removed: List[str] = [] + left: List[Dict[str, Optional[str]]] = [] + to_remove = set(nodes_to_remove) + + for n in nodes: + if n["name"] in to_remove: + removed.append(n["name"]) 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)) + left.append(n) + + if removed: + config["nodes"] = left + save_config(config, project_dir) + print(colored(f"Removed nodes: {', '.join(removed)}", Colors.GREEN)) else: print(colored("No nodes removed", Colors.YELLOW)) -def list_nodes(): - config, _ = load_config() - nodes = config.get("nodes", []) + +def set_node_dir(node_name: str, remote_dir: str) -> None: + """Set or change the remote project directory for a node. + + Args: + node_name: Node name "user@host". + remote_dir: Absolute path on the remote host. + """ + config, project_dir = load_config() + nodes = _normalize_nodes(config) + idx = _index_by_name(nodes, node_name) + if idx < 0: + print(colored(f"Node {node_name} not found", Colors.RED)) + return + nodes[idx]["remote_dir"] = remote_dir + config["nodes"] = nodes + save_config(config, project_dir) + print(colored(f"Updated node {node_name} dir -> {remote_dir}", Colors.GREEN)) + + +def clear_node_dir(node_name: str) -> None: + """Clear per-node remote directory (fallback to master_dir on use). + + Args: + node_name: Node name "user@host". + """ + config, project_dir = load_config() + nodes = _normalize_nodes(config) + idx = _index_by_name(nodes, node_name) + if idx < 0: + print(colored(f"Node {node_name} not found", Colors.RED)) + return + nodes[idx]["remote_dir"] = None + config["nodes"] = nodes + save_config(config, project_dir) + print(colored(f"Cleared node {node_name} dir (will use master_dir)", Colors.GREEN)) + + +def list_nodes() -> None: + """List configured nodes with their effective remote directories.""" + config, project_dir = load_config() + master_dir = config.get("master_dir", str(project_dir)) + nodes = _normalize_nodes(config) if nodes: print(colored("Configured nodes:", Colors.CYAN)) - for i, node in enumerate(nodes, 1): - print(f" {i}. {node}") + for i, n in enumerate(sorted(nodes, key=lambda x: x["name"]), 1): + rdir = n["remote_dir"] or master_dir + print(f" {i}. {n['name']} -> {rdir}") else: print(colored("No nodes configured", Colors.YELLOW)) -def show_help(): + +def show_help() -> None: + """Print CLI usage.""" help_text = """ dsync - Directory Synchronization Tool Usage: - dsync init Initialize dsync in current directory - dsync add-node [node2...] Add one or more nodes - dsync del-node [node2...] Remove one or more nodes - dsync list-nodes List all configured nodes - dsync status Show sync status for all nodes - dsync sync Sync to all configured nodes - dsync help Show this help message - dsync version Print dsync version - -Examples: dsync init - dsync add-node user@server1.com user@server2.com - dsync del-node user@server1.com - dsync status - dsync sync + dsync add-node [node2 ...] Add nodes. Each node may be 'user@host' or 'user@host:/remote/dir' + dsync add-node Add single node with explicit dir (alt form) + dsync del-node [node2 ...] Remove nodes + dsync set-node-dir Set/Change remote dir for node + dsync clear-node-dir Clear node dir (fallback to master_dir) + dsync list-nodes List nodes with their remote dirs + dsync status Show sync status for all nodes (with dirs) + dsync sync Sync to all configured nodes + dsync help Show this help message + dsync version Print dsync version 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 + - If a node has no specific remote_dir, master_dir is used. + - 'add-node user@host:/path' works for multiple nodes in one command. + - SSH keys should be configured for passwordless access. + - Edit .dsyncignore to exclude files from sync. """ print(help_text) -def show_version(): + +def show_version() -> None: """Print dsync version.""" print(f"dsync {VERSION}") - if __name__ == "__main__": if len(sys.argv) < 2: show_help() sys.exit(1) cmd = sys.argv[1] - + if cmd == "init": init() elif cmd in ("-an", "--add-node", "add-node"): if len(sys.argv) < 3: - print(colored("Usage: dsync.py add-node [node2...]", Colors.RED)) + print(colored("Usage: dsync add-node [remote_dir] | add-node [node2...]", Colors.RED)) sys.exit(1) add_nodes(*sys.argv[2:]) elif cmd in ("-dn", "--del-node", "del-node"): if len(sys.argv) < 3: - print(colored("Usage: dsync.py del-node [node2...]", Colors.RED)) + print(colored("Usage: dsync del-node [node2...]", Colors.RED)) sys.exit(1) del_nodes(*sys.argv[2:]) + elif cmd in ("-sd", "--set-node-dir", "--set-dir", "set-node-dir", "set-dir"): + if len(sys.argv) != 4: + print(colored("Usage: dsync set-node-dir ", Colors.RED)) + sys.exit(1) + set_node_dir(sys.argv[2], sys.argv[3]) + elif cmd in ("-cn", "--clear-node-dir", "--clear-dir", "clear-node-dir", "clear-dir"): + if len(sys.argv) != 3: + print(colored("Usage: dsync clear-node-dir ", Colors.RED)) + sys.exit(1) + clear_node_dir(sys.argv[2]) elif cmd in ("-ln", "--list-nodes", "list-nodes"): list_nodes() elif cmd == "status": @@ -471,4 +685,4 @@ if __name__ == "__main__": else: print(colored(f"Unknown command: {cmd}", Colors.RED)) show_help() - sys.exit(1) \ No newline at end of file + sys.exit(1)