Добавлена возможность устанавливать различные пути для нод

main
ilyukhin 2 months ago
parent dbeb762178
commit e2b20b0cd7

576
dsync

@ -1,15 +1,18 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
VERSION = "0.1.0" VERSION = "0.2.0"
import re
import sys import sys
import json import json
import subprocess import subprocess
from pathlib import Path from pathlib import Path
import concurrent.futures import concurrent.futures
from typing import Any, Dict, List, Optional, Tuple
# ANSI color codes
class Colors: class Colors:
"""ANSI color escape codes for simple CLI highlighting."""
RED = '\033[31m' RED = '\033[31m'
GREEN = '\033[32m' GREEN = '\033[32m'
ORANGE = '\033[33m' ORANGE = '\033[33m'
@ -21,11 +24,33 @@ class Colors:
RESET = '\033[0m' RESET = '\033[0m'
BOLD = '\033[1m' 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}" return f"{color}{text}{Colors.RESET}"
def find_file_up(filename: str, start_dir: Path = Path.cwd()) -> Path: 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 current_dir = start_dir
while current_dir != current_dir.parent: while current_dir != current_dir.parent:
candidate = current_dir / filename 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)) print(colored(f"Error: {filename} not found in this directory or any parent.", Colors.RED))
sys.exit(1) 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") config_path = find_file_up(".dsyncconfig")
with open(config_path) as f: 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<name>[^:]+):(?P<dir>/.*)$")
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): def check_node_status(node_name: str, project_dir: Path, ignore_file: Path, remote_dir: str) -> Dict[str, Any]:
"""Check status for a single node""" """Compute rsync dry-run diff for a single node.
remote = f"{node}:{master_dir}"
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 = [ cmd = [
"rsync", "-avz", "--dry-run", "--itemize-changes", "--delete", "rsync", "-avz", "--dry-run", "--itemize-changes", "--delete",
f"--exclude-from={ignore_file}", f"--exclude-from={ignore_file}",
"-e", "ssh -o StrictHostKeyChecking=no -o ConnectTimeout=5", "-e", "ssh -o StrictHostKeyChecking=no -o ConnectTimeout=5",
f"{project_dir}/", remote f"{project_dir}/", remote
] ]
try: try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30) result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
if result.returncode == 0: if result.returncode == 0:
# Parse rsync output lines = result.stdout.strip().split('\n') if result.stdout.strip() else []
lines = result.stdout.strip().split('\n') changes: List[str] = []
changes = [] # Filter only meaningful change lines
for line in lines: for line in lines:
if line.startswith('>f') or line.startswith('>d') or line.startswith('*deleting'): if line.startswith(('>f', '>d', '*deleting', '<f', '<d', 'cd', 'cf')):
changes.append(line)
elif line.startswith('<f') or line.startswith('<d'):
changes.append(line) changes.append(line)
elif line.startswith('cd') or line.startswith('cf'):
changes.append(line)
return { return {
'node': node, 'node': node_name,
'remote_dir': remote_dir,
'status': 'ok', 'status': 'ok',
'changes': changes, 'changes': changes,
'total_changes': len(changes) 'total_changes': len(changes)
} }
else: return {
return { 'node': node_name,
'node': node, 'remote_dir': remote_dir,
'status': 'error', 'status': 'error',
'error': result.stderr.strip(), 'error': result.stderr.strip(),
'changes': [], 'changes': [],
'total_changes': 0 'total_changes': 0
} }
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
return { return {
'node': node, 'node': node_name,
'remote_dir': remote_dir,
'status': 'timeout', 'status': 'timeout',
'error': 'Connection timeout', 'error': 'Connection timeout',
'changes': [], 'changes': [],
@ -91,77 +201,75 @@ def check_node_status(node, project_dir, ignore_file, master_dir):
} }
except Exception as e: except Exception as e:
return { return {
'node': node, 'node': node_name,
'remote_dir': remote_dir,
'status': 'error', 'status': 'error',
'error': str(e), 'error': str(e),
'changes': [], 'changes': [],
'total_changes': 0 'total_changes': 0
} }
def status():
"""Show sync status for all nodes""" def status() -> None:
"""Show synchronization status for all nodes (including per-node directories)."""
config, project_dir = load_config() config, project_dir = load_config()
ignore_file = project_dir / config.get("ignore_file", ".dsyncignore") ignore_file = project_dir / config.get("ignore_file", ".dsyncignore")
master_dir = config.get("master_dir", str(project_dir)) 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(): if not ignore_file.exists():
ignore_file.write_text("# Add patterns to ignore during sync\n") ignore_file.write_text("# Add patterns to ignore during sync\n")
nodes = config.get("nodes", []) nodes = _normalize_nodes(config)
if not nodes: if not nodes:
print(colored("No nodes configured", Colors.YELLOW)) print(colored("No nodes configured", Colors.YELLOW))
return return
print(f"Checking status for {len(nodes)} node(s)...") print(f"Checking status for {len(nodes)} node(s)...")
print("-" * 50) 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: with concurrent.futures.ThreadPoolExecutor(max_workers=min(len(nodes), 10)) as executor:
futures = { futures = {
executor.submit(check_node_status, node, project_dir, ignore_file, master_dir): node executor.submit(
for node in nodes check_node_status,
n["name"], project_dir, ignore_file,
n["remote_dir"] or master_dir
): n["name"] for n in nodes
} }
results: List[Dict[str, Any]] = []
results = []
for future in concurrent.futures.as_completed(futures): for future in concurrent.futures.as_completed(futures):
result = future.result() results.append(future.result())
results.append(result)
# Sort results by node name for consistent output
results.sort(key=lambda x: x['node']) results.sort(key=lambda x: x['node'])
# Display results
total_changes = 0 total_changes = 0
for result in results: for r in results:
node = result['node'] node = r['node']
status = result['status'] rdir = r.get('remote_dir') or master_dir
changes = result['changes'] st = r['status']
change_count = result['total_changes'] changes = r['changes']
cnt = r['total_changes']
if status == 'ok':
if change_count == 0: header = f"{node} ({rdir})"
print(colored(f"[OK] {node}: Up to date", Colors.GREEN)) if st == 'ok':
if cnt == 0:
print(colored(f"[OK] {header}: Up to date", Colors.GREEN))
else: else:
print(colored(f"[!] {node}: {change_count} change(s) pending", Colors.YELLOW)) print(colored(f"[!] {header}: {cnt} change(s) pending", Colors.YELLOW))
total_changes += change_count total_changes += cnt
# Show a small preview of the change list
# Show first few changes for ch in changes[:5]:
for i, change in enumerate(changes[:5]): print(f" {parse_rsync_action(ch)}")
action = parse_rsync_action(change)
print(f" {action}")
if len(changes) > 5: if len(changes) > 5:
print(f" ... and {len(changes) - 5} more change(s)") print(f" ... and {len(changes) - 5} more change(s)")
else: 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() print()
# Summary
active_nodes = sum(1 for r in results if r['status'] == 'ok') active_nodes = sum(1 for r in results if r['status'] == 'ok')
error_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) print("-" * 50)
if total_changes > 0: if total_changes > 0:
print(colored(f"Summary: {active_nodes} active, {error_nodes} error(s), {total_changes} total changes", Colors.YELLOW)) 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: if error_nodes == 0:
print(colored("All nodes are up to date!", Colors.GREEN)) 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'): if line.startswith('*deleting'):
filename = line.split(' ', 1)[1].strip() filename = line.split(' ', 1)[1].strip()
return f"DELETE: {filename}" return f"DELETE: {filename}"
if len(line) < 11: if len(line) < 11:
return line.strip() return line.strip()
# Parse rsync itemize format: YXcstpoguax filename # rsync itemize format: YXcstpoguax filename
changes = line[0:11] changes = line[0:11]
filename = line[11:].strip() filename = line[11:].strip()
if not filename: if not filename:
return line.strip() return line.strip()
# Check file type and direction # Direction
file_type = changes[0] file_type = changes[0]
if file_type == '<': if file_type == '<':
direction = "SEND" direction = "SEND" # local -> remote
elif file_type == '>': elif file_type == '>':
direction = "RECV" direction = "RECV" # remote -> local (rare in our usage)
elif file_type == 'c': elif file_type == 'c':
direction = "CREATE" direction = "CREATE"
elif file_type == 'h': elif file_type == 'h':
@ -201,8 +316,8 @@ def parse_rsync_action(line):
direction = "UPDATE" direction = "UPDATE"
else: else:
direction = "CHANGE" direction = "CHANGE"
# Parse item type # Item kind
change_type = changes[1] change_type = changes[1]
if change_type == 'f': if change_type == 'f':
item_type = "file" item_type = "file"
@ -216,16 +331,15 @@ def parse_rsync_action(line):
item_type = "special" item_type = "special"
else: else:
item_type = "item" item_type = "item"
# Parse specific changes # Specific attributes
change_details = changes[2:11] change_details = changes[2:11]
details = [] details: List[str] = []
if change_details[0] == '+': if change_details[0] == '+':
return f"NEW {item_type.upper()}: {filename}" return f"NEW {item_type.upper()}: {filename}"
elif change_details[0] == 'c': elif change_details[0] == 'c':
details.append("content") details.append("content")
if change_details[1] == 's': if change_details[1] == 's':
details.append("size") details.append("size")
if change_details[2] == 't': if change_details[2] == 't':
@ -242,70 +356,81 @@ def parse_rsync_action(line):
details.append("ACL") details.append("ACL")
if change_details[8] == 'x': if change_details[8] == 'x':
details.append("extended attributes") 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() config, project_dir = load_config()
ignore_file = project_dir / config.get("ignore_file", ".dsyncignore") ignore_file = project_dir / config.get("ignore_file", ".dsyncignore")
master_dir = config.get("master_dir", str(project_dir)) master_dir = config.get("master_dir", str(project_dir))
# Ensure ignore file exists
if not ignore_file.exists(): if not ignore_file.exists():
ignore_file.write_text("# Add patterns to ignore during sync\n") 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 success_count = 0
failed_nodes = [] failed_nodes: List[str] = []
for node in config["nodes"]: for n in nodes:
remote = f"{node}:{master_dir}" node = n["name"]
rdir = n["remote_dir"] or master_dir
remote = f"{node}:{rdir}"
print(f"Syncing to {remote}...") print(f"Syncing to {remote}...")
cmd = [ cmd = [
"rsync", "-avz", "--delete", "rsync", "-avz", "--delete",
f"--exclude-from={ignore_file}", f"--exclude-from={ignore_file}",
"-e", "ssh -o StrictHostKeyChecking=no -o ConnectTimeout=10", "-e", "ssh -o StrictHostKeyChecking=no -o ConnectTimeout=10",
f"{project_dir}/", remote f"{project_dir}/", remote
] ]
try: try:
subprocess.run(cmd, check=True, capture_output=True, text=True) 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 success_count += 1
except subprocess.CalledProcessError as e: except subprocess.CalledProcessError as e:
print(colored(f"[X] Failed to sync to {node}: {e}", Colors.RED)) print(colored(f"[X] Failed to sync to {node} ({rdir}): {e}", Colors.RED))
failed_nodes.append(node) failed_nodes.append(f"{node} ({rdir})")
print() print()
if success_count == len(config['nodes']): if success_count == len(nodes):
print(colored(f"Sync completed: {success_count}/{len(config['nodes'])} nodes successful", Colors.GREEN)) print(colored(f"Sync completed: {success_count}/{len(nodes)} nodes successful", Colors.GREEN))
else: 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: if failed_nodes:
print(colored(f"Failed nodes: {', '.join(failed_nodes)}", Colors.RED)) 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" config_file = master_dir / ".dsyncconfig"
ignore_file = master_dir / ".dsyncignore" ignore_file = master_dir / ".dsyncignore"
if config_file.exists(): if config_file.exists():
print(colored(f"dsync already initialized in {master_dir}", Colors.YELLOW)) print(colored(f"dsync already initialized in {master_dir}", Colors.YELLOW))
return return
config = { config = {
"master_dir": str(master_dir), "master_dir": str(master_dir),
"nodes": [], "nodes": [], # list of {"name": "...", "remote_dir": "/path"|None}
"ignore_file": ".dsyncignore" "ignore_file": ".dsyncignore"
} }
config_file.write_text(json.dumps(config, indent=2)) 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(): if not ignore_file.exists():
ignore_content = """# dsync ignore patterns ignore_content = """# dsync ignore patterns
# Add patterns to ignore during sync # Add patterns to ignore during sync
@ -349,114 +474,203 @@ node_modules/
.cache/ .cache/
""" """
ignore_file.write_text(ignore_content) ignore_file.write_text(ignore_content)
print(colored(f"Initialized dsync config in {master_dir}", Colors.GREEN)) 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)) print(colored("No nodes provided", Colors.YELLOW))
return return
config, project_dir = load_config() config, project_dir = load_config()
added_nodes = [] nodes = _normalize_nodes(config)
master_dir = config.get("master_dir", str(project_dir))
for node in nodes:
if node not in config["nodes"]: # Special alt-form: exactly two args "user@host /remote/dir"
config["nodes"].append(node) if len(args) == 2 and args[1].startswith(("/", ".", "~")):
added_nodes.append(node) 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: else:
print(colored(f"Node {node} already exists", Colors.YELLOW)) nodes.append({"name": name, "remote_dir": rdir})
print(colored(f"Added node: {name} ({rdir})", Colors.GREEN))
if added_nodes: config["nodes"] = nodes
(project_dir / ".dsyncconfig").write_text(json.dumps(config, indent=2)) save_config(config, project_dir)
print(colored(f"Added nodes: {', '.join(added_nodes)}", Colors.GREEN)) 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: else:
print(colored("No new nodes added", Colors.YELLOW)) 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)) print(colored("No nodes provided", Colors.YELLOW))
return return
config, project_dir = load_config() config, project_dir = load_config()
removed_nodes = [] nodes = _normalize_nodes(config)
for node in nodes: removed: List[str] = []
if node in config["nodes"]: left: List[Dict[str, Optional[str]]] = []
config["nodes"].remove(node) to_remove = set(nodes_to_remove)
removed_nodes.append(node)
for n in nodes:
if n["name"] in to_remove:
removed.append(n["name"])
else: else:
print(colored(f"Node {node} not found", Colors.YELLOW)) left.append(n)
if removed_nodes: if removed:
(project_dir / ".dsyncconfig").write_text(json.dumps(config, indent=2)) config["nodes"] = left
print(colored(f"Removed nodes: {', '.join(removed_nodes)}", Colors.GREEN)) save_config(config, project_dir)
print(colored(f"Removed nodes: {', '.join(removed)}", Colors.GREEN))
else: else:
print(colored("No nodes removed", Colors.YELLOW)) print(colored("No nodes removed", Colors.YELLOW))
def list_nodes():
config, _ = load_config() def set_node_dir(node_name: str, remote_dir: str) -> None:
nodes = config.get("nodes", []) """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: if nodes:
print(colored("Configured nodes:", Colors.CYAN)) print(colored("Configured nodes:", Colors.CYAN))
for i, node in enumerate(nodes, 1): for i, n in enumerate(sorted(nodes, key=lambda x: x["name"]), 1):
print(f" {i}. {node}") rdir = n["remote_dir"] or master_dir
print(f" {i}. {n['name']} -> {rdir}")
else: else:
print(colored("No nodes configured", Colors.YELLOW)) print(colored("No nodes configured", Colors.YELLOW))
def show_help():
def show_help() -> None:
"""Print CLI usage."""
help_text = """ help_text = """
dsync - Directory Synchronization Tool dsync - Directory Synchronization Tool
Usage: Usage:
dsync init Initialize dsync in current directory
dsync add-node <node1> [node2...] Add one or more nodes
dsync del-node <node1> [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 init
dsync add-node user@server1.com user@server2.com dsync add-node <node1> [node2 ...] Add nodes. Each node may be 'user@host' or 'user@host:/remote/dir'
dsync del-node user@server1.com dsync add-node <node> <remote_dir> Add single node with explicit dir (alt form)
dsync status dsync del-node <node1> [node2 ...] Remove nodes
dsync sync dsync set-node-dir <node> <remote_dir> Set/Change remote dir for node
dsync clear-node-dir <node> 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: Notes:
- Status command shows pending changes without syncing - If a node has no specific remote_dir, master_dir is used.
- Sync uses rsync with --delete flag to handle file deletions - 'add-node user@host:/path' works for multiple nodes in one command.
- SSH keys should be configured for passwordless access - SSH keys should be configured for passwordless access.
- Edit .dsyncignore to exclude files from sync - Edit .dsyncignore to exclude files from sync.
""" """
print(help_text) print(help_text)
def show_version():
def show_version() -> None:
"""Print dsync version.""" """Print dsync version."""
print(f"dsync {VERSION}") print(f"dsync {VERSION}")
if __name__ == "__main__": if __name__ == "__main__":
if len(sys.argv) < 2: if len(sys.argv) < 2:
show_help() show_help()
sys.exit(1) sys.exit(1)
cmd = sys.argv[1] cmd = sys.argv[1]
if cmd == "init": if cmd == "init":
init() init()
elif cmd in ("-an", "--add-node", "add-node"): elif cmd in ("-an", "--add-node", "add-node"):
if len(sys.argv) < 3: if len(sys.argv) < 3:
print(colored("Usage: dsync.py add-node <node1> [node2...]", Colors.RED)) print(colored("Usage: dsync add-node <node> [remote_dir] | add-node <node1> [node2...]", Colors.RED))
sys.exit(1) sys.exit(1)
add_nodes(*sys.argv[2:]) add_nodes(*sys.argv[2:])
elif cmd in ("-dn", "--del-node", "del-node"): elif cmd in ("-dn", "--del-node", "del-node"):
if len(sys.argv) < 3: if len(sys.argv) < 3:
print(colored("Usage: dsync.py del-node <node1> [node2...]", Colors.RED)) print(colored("Usage: dsync del-node <node1> [node2...]", Colors.RED))
sys.exit(1) sys.exit(1)
del_nodes(*sys.argv[2:]) 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 <node> <remote_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 <node>", Colors.RED))
sys.exit(1)
clear_node_dir(sys.argv[2])
elif cmd in ("-ln", "--list-nodes", "list-nodes"): elif cmd in ("-ln", "--list-nodes", "list-nodes"):
list_nodes() list_nodes()
elif cmd == "status": elif cmd == "status":
@ -471,4 +685,4 @@ if __name__ == "__main__":
else: else:
print(colored(f"Unknown command: {cmd}", Colors.RED)) print(colored(f"Unknown command: {cmd}", Colors.RED))
show_help() show_help()
sys.exit(1) sys.exit(1)

Loading…
Cancel
Save