mtr-cli 0.2.0__py3-none-any.whl → 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
mtr/sync.py CHANGED
@@ -1,4 +1,3 @@
1
- import fnmatch
2
1
  import os
3
2
  import shlex
4
3
  import shutil
@@ -6,243 +5,23 @@ import subprocess
6
5
  from abc import ABC, abstractmethod
7
6
  from typing import List, Optional
8
7
 
9
- import paramiko
10
-
11
8
 
12
9
  class SyncError(Exception):
13
10
  pass
14
11
 
15
12
 
16
13
  class BaseSyncer(ABC):
17
- def __init__(self, local_dir: str, remote_dir: str, exclude: List[str]):
14
+ def __init__(self, local_dir: str, remote_dir: str, exclude: List[str], respect_gitignore: bool = True):
18
15
  self.local_dir = local_dir
19
16
  self.remote_dir = remote_dir
20
17
  self.exclude = exclude
18
+ self.respect_gitignore = respect_gitignore
21
19
 
22
20
  @abstractmethod
23
- def sync(self):
21
+ def sync(self, show_progress: bool = False, progress_callback=None):
24
22
  pass
25
23
 
26
24
 
27
- class SftpSyncer(BaseSyncer):
28
- def __init__(
29
- self,
30
- local_dir: str,
31
- remote_dir: str,
32
- host: str,
33
- user: str,
34
- key_filename: Optional[str] = None,
35
- password: Optional[str] = None,
36
- port: int = 22,
37
- exclude: List[str] = None,
38
- ):
39
- super().__init__(local_dir, remote_dir, exclude or [])
40
- self.host = host
41
- self.user = user
42
- self.key_filename = key_filename
43
- self.password = password
44
- self.port = port
45
- self.transport = None
46
- self.sftp = None
47
-
48
- def _should_ignore(self, filename: str) -> bool:
49
- for pattern in self.exclude:
50
- # Handle directory exclusion (basic)
51
- if pattern.endswith("/") and filename.startswith(pattern.rstrip("/")):
52
- return True
53
- if fnmatch.fnmatch(filename, pattern):
54
- return True
55
- return False
56
-
57
- def _connect(self):
58
- try:
59
- self.transport = paramiko.Transport((self.host, self.port))
60
- connect_kwargs = {"username": self.user}
61
-
62
- if self.key_filename:
63
- key_path = os.path.expanduser(self.key_filename)
64
- # Try different key types? For now assuming RSA or standard loading
65
- # Or just use connect method of SSHClient? No, Sftp is lower level usually,
66
- # but we can use SSHClient to get sftp
67
-
68
- # Simpler approach: Use SSHClientWrapper logic or just Paramiko SSHClient
69
- client = paramiko.SSHClient()
70
- client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
71
-
72
- k_kwargs = {
73
- "hostname": self.host,
74
- "username": self.user,
75
- "port": self.port,
76
- "key_filename": key_path,
77
- }
78
- if self.password:
79
- k_kwargs["password"] = self.password
80
-
81
- client.connect(**k_kwargs)
82
- self.sftp = client.open_sftp()
83
- return
84
-
85
- if self.password:
86
- connect_kwargs["password"] = self.password
87
- self.transport.connect(**connect_kwargs)
88
- self.sftp = paramiko.SFTPClient.from_transport(self.transport)
89
- else:
90
- raise SyncError("No auth method provided (need key or password)")
91
-
92
- except Exception as e:
93
- raise SyncError(f"SFTP Connection failed: {e}")
94
-
95
- def _ensure_remote_dir(self, remote_path: str):
96
- """Recursively create remote directory."""
97
- # This is a bit expensive, optimization: assume parent exists or try/except
98
- # Simple implementation:
99
- dirs = remote_path.split("/")
100
- current = ""
101
- for d in dirs:
102
- if not d:
103
- continue
104
- current += f"/{d}"
105
- try:
106
- self.sftp.stat(current)
107
- except FileNotFoundError:
108
- try:
109
- self.sftp.mkdir(current)
110
- except OSError:
111
- pass # Already exists maybe
112
-
113
- def sync(self):
114
- if not self.sftp:
115
- self._connect()
116
-
117
- # Ensure base remote dir exists
118
- try:
119
- self.sftp.stat(self.remote_dir)
120
- except FileNotFoundError:
121
- self._ensure_remote_dir(self.remote_dir)
122
-
123
- # Walk local tree
124
- for root, dirs, files in os.walk(self.local_dir):
125
- # Filtering dirs in place to prevent recursion
126
- dirs[:] = [d for d in dirs if not self._should_ignore(d)]
127
-
128
- rel_path = os.path.relpath(root, self.local_dir)
129
- if rel_path == ".":
130
- remote_root = self.remote_dir
131
- else:
132
- remote_root = os.path.join(self.remote_dir, rel_path)
133
-
134
- # Check/Create remote dir
135
- try:
136
- self.sftp.stat(remote_root)
137
- except FileNotFoundError:
138
- self.sftp.mkdir(remote_root)
139
-
140
- for file in files:
141
- if self._should_ignore(file):
142
- continue
143
-
144
- local_file = os.path.join(root, file)
145
- remote_file = os.path.join(remote_root, file)
146
-
147
- # Check sync necessity (Size & Mtime)
148
- should_upload = True
149
- try:
150
- remote_stat = self.sftp.stat(remote_file)
151
- local_stat = os.stat(local_file)
152
-
153
- if remote_stat.st_size == local_stat.st_size and int(remote_stat.st_mtime) >= int(local_stat.st_mtime):
154
- should_upload = False
155
- except FileNotFoundError:
156
- pass # Does not exist, must upload
157
-
158
- if should_upload:
159
- # print(f"Uploading {local_file} -> {remote_file}")
160
- self.sftp.put(local_file, remote_file)
161
- # Preserve permissions
162
- mode = os.stat(local_file).st_mode
163
- self.sftp.chmod(remote_file, mode)
164
-
165
- if self.sftp:
166
- self.sftp.close()
167
- if self.transport:
168
- self.transport.close()
169
-
170
- def download(self, remote_path: str, local_path: str):
171
- """Download file or directory from remote to local."""
172
- if not self.sftp:
173
- self._connect()
174
-
175
- try:
176
- # Ensure local directory exists
177
- local_dir = os.path.dirname(local_path)
178
- if local_dir and not os.path.exists(local_dir):
179
- os.makedirs(local_dir, exist_ok=True)
180
-
181
- try:
182
- import stat
183
-
184
- remote_stat = self.sftp.stat(remote_path)
185
- is_dir = stat.S_ISDIR(remote_stat.st_mode)
186
-
187
- if is_dir:
188
- self._download_dir(remote_path, local_path)
189
- else:
190
- self._download_file(remote_path, local_path)
191
- except FileNotFoundError:
192
- raise SyncError(f"Remote path not found: {remote_path}")
193
- except Exception as e:
194
- raise SyncError(f"Download failed: {e}")
195
- finally:
196
- if self.sftp:
197
- self.sftp.close()
198
- if self.transport:
199
- self.transport.close()
200
-
201
- def _should_download_file(self, remote_file: str, local_file: str) -> bool:
202
- """Check if file should be downloaded based on size and mtime."""
203
- try:
204
- remote_stat = self.sftp.stat(remote_file)
205
- local_stat = os.stat(local_file)
206
-
207
- # Size different, need download
208
- if remote_stat.st_size != local_stat.st_size:
209
- return True
210
-
211
- # Remote file is newer than local file
212
- return int(remote_stat.st_mtime) > int(local_stat.st_mtime)
213
- except FileNotFoundError:
214
- return True # Local file doesn't exist, must download
215
-
216
- def _download_file(self, remote_file: str, local_file: str):
217
- """Download a single file with incremental check."""
218
- if not self._should_download_file(remote_file, local_file):
219
- return # No need to download
220
-
221
- self.sftp.get(remote_file, local_file)
222
- # Preserve permissions
223
- remote_stat = self.sftp.stat(remote_file)
224
- os.chmod(local_file, remote_stat.st_mode)
225
-
226
- def _download_dir(self, remote_dir: str, local_dir: str):
227
- """Recursively download a directory."""
228
- if not os.path.exists(local_dir):
229
- os.makedirs(local_dir, exist_ok=True)
230
-
231
- for entry in self.sftp.listdir_attr(remote_dir):
232
- remote_path = f"{remote_dir}/{entry.filename}"
233
- local_path = os.path.join(local_dir, entry.filename)
234
-
235
- if self._should_ignore(entry.filename):
236
- continue
237
-
238
- import stat
239
-
240
- if stat.S_ISDIR(entry.st_mode):
241
- self._download_dir(remote_path, local_path)
242
- else:
243
- self._download_file(remote_path, local_path)
244
-
245
-
246
25
  class RsyncSyncer(BaseSyncer):
247
26
  def __init__(
248
27
  self,
@@ -254,8 +33,9 @@ class RsyncSyncer(BaseSyncer):
254
33
  password: Optional[str] = None,
255
34
  port: int = 22,
256
35
  exclude: List[str] = None,
36
+ respect_gitignore: bool = True,
257
37
  ):
258
- super().__init__(local_dir, remote_dir, exclude or [])
38
+ super().__init__(local_dir, remote_dir, exclude or [], respect_gitignore)
259
39
  self.host = host
260
40
  self.user = user
261
41
  self.key_filename = key_filename
@@ -269,9 +49,20 @@ class RsyncSyncer(BaseSyncer):
269
49
  opts += f" -i {self.key_filename}"
270
50
  return opts
271
51
 
272
- def _build_rsync_base(self) -> List[str]:
52
+ def _build_rsync_base(self, show_progress: bool = False) -> List[str]:
273
53
  """Build rsync base command with common options."""
274
- cmd = ["rsync", "-azq"]
54
+ if show_progress:
55
+ # In progress mode, use -av --info=NAME to show filenames only
56
+ cmd = ["rsync", "-av", "--info=NAME"]
57
+ else:
58
+ # Silent mode
59
+ cmd = ["rsync", "-azq"]
60
+
61
+ # Add gitignore filter if enabled
62
+ if self.respect_gitignore:
63
+ gitignore_path = os.path.join(self.local_dir, ".gitignore")
64
+ if os.path.exists(gitignore_path):
65
+ cmd.append("--filter=:- .gitignore")
275
66
 
276
67
  # Add excludes
277
68
  for item in self.exclude:
@@ -294,35 +85,115 @@ class RsyncSyncer(BaseSyncer):
294
85
  if not shutil.which("sshpass"):
295
86
  raise SyncError("Rsync with password requires 'sshpass'. Please install it or use SSH Key.")
296
87
 
297
- def _build_rsync_command(self) -> List[str]:
88
+ def _check_rsync_version(self) -> tuple:
89
+ """Check local rsync version and return (major, minor, patch) tuple.
90
+
91
+ Returns:
92
+ Tuple of (major, minor, patch) version numbers
93
+ Raises:
94
+ SyncError: If rsync is not installed or version cannot be parsed
95
+ """
96
+ try:
97
+ result = subprocess.run(["rsync", "--version"], capture_output=True, text=True, timeout=5)
98
+ if result.returncode != 0:
99
+ raise SyncError("Failed to check rsync version. Is rsync installed?")
100
+
101
+ # Parse version from first line, e.g., "rsync version 3.2.5 protocol version 31"
102
+ first_line = result.stdout.split("\n")[0]
103
+ import re
104
+
105
+ match = re.search(r"version\s+(\d+)\.(\d+)\.(\d+)", first_line)
106
+ if not match:
107
+ # Try alternative format: "rsync version 2.6.9 compatible"
108
+ match = re.search(r"version\s+(\d+)\.(\d+)\.(\d+)", first_line)
109
+ if not match:
110
+ raise SyncError(f"Cannot parse rsync version from: {first_line}")
111
+
112
+ major, minor, patch = int(match.group(1)), int(match.group(2)), int(match.group(3))
113
+ return (major, minor, patch)
114
+ except FileNotFoundError:
115
+ raise SyncError("rsync not found. Please install rsync.")
116
+ except subprocess.TimeoutExpired:
117
+ raise SyncError("Timeout while checking rsync version.")
118
+ except Exception as e:
119
+ raise SyncError(f"Failed to check rsync version: {e}")
120
+
121
+ def _is_rsync_version_supported(self, min_version: tuple = (3, 1, 0)) -> bool:
122
+ """Check if local rsync version meets minimum requirement.
123
+
124
+ Args:
125
+ min_version: Minimum required version as (major, minor, patch) tuple
126
+ Returns:
127
+ True if version is supported, False otherwise
128
+ """
129
+ try:
130
+ current_version = self._check_rsync_version()
131
+ return current_version >= min_version
132
+ except SyncError:
133
+ return False
134
+
135
+ def _build_rsync_command(self, show_progress: bool = False) -> List[str]:
298
136
  """Build rsync command for uploading (local -> remote)."""
299
137
  # Ensure local dir ends with / to sync contents, not the dir itself
300
138
  src = self.local_dir if self.local_dir.endswith("/") else f"{self.local_dir}/"
301
139
  dest = f"{self.user}@{self.host}:{shlex.quote(self.remote_dir)}"
302
140
 
303
- cmd = self._build_rsync_base()
141
+ cmd = self._build_rsync_base(show_progress=show_progress)
304
142
  cmd.extend([src, dest])
305
143
 
306
144
  return self._wrap_with_sshpass(cmd)
307
145
 
308
- def _build_rsync_download_command(self, remote_path: str, local_path: str) -> List[str]:
146
+ def _build_rsync_download_command(self, remote_path: str, local_path: str, show_progress: bool = False) -> List[str]:
309
147
  """Build rsync command for downloading (remote -> local)."""
310
148
  src = f"{self.user}@{self.host}:{shlex.quote(remote_path)}"
311
149
 
312
- cmd = self._build_rsync_base()
150
+ cmd = self._build_rsync_base(show_progress=show_progress)
313
151
  cmd.extend([src, local_path])
314
152
 
315
153
  return self._wrap_with_sshpass(cmd)
316
154
 
317
- def sync(self):
155
+ def sync(self, show_progress: bool = False, progress_callback=None):
318
156
  self._check_sshpass()
319
- cmd = self._build_rsync_command()
157
+
158
+ # Check rsync version if progress mode is requested
159
+ if show_progress and progress_callback:
160
+ if not self._is_rsync_version_supported():
161
+ version = self._check_rsync_version()
162
+ raise SyncError(
163
+ f"rsync version {version[0]}.{version[1]}.{version[2]} is too old. "
164
+ f"Progress display requires rsync >= 3.1.0. "
165
+ f"Please upgrade rsync or use --no-tty mode."
166
+ )
167
+
168
+ cmd = self._build_rsync_command(show_progress=show_progress)
169
+
320
170
  try:
321
- subprocess.run(cmd, check=True)
171
+ if show_progress and progress_callback:
172
+ # Run with real-time output parsing for progress display
173
+ process = subprocess.Popen(
174
+ cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1, universal_newlines=True
175
+ )
176
+
177
+ # Parse rsync output line by line
178
+ for line in process.stdout:
179
+ line = line.strip()
180
+ # Skip empty lines and summary lines
181
+ if line and not line.startswith("sent") and not line.startswith("total"):
182
+ # Extract filename from rsync output
183
+ # Rsync --info=NAME outputs filenames directly
184
+ if not line.startswith("receiving") and not line.startswith("building"):
185
+ progress_callback(line)
186
+
187
+ process.wait()
188
+ if process.returncode != 0:
189
+ raise SyncError(f"Rsync failed with exit code {process.returncode}")
190
+ else:
191
+ # Silent mode - use subprocess.run
192
+ subprocess.run(cmd, check=True)
322
193
  except subprocess.CalledProcessError as e:
323
194
  raise SyncError(f"Rsync failed with exit code {e.returncode}")
324
195
 
325
- def download(self, remote_path: str, local_path: str):
196
+ def download(self, remote_path: str, local_path: str, show_progress: bool = False, progress_callback=None):
326
197
  """Download file or directory from remote to local."""
327
198
  self._check_sshpass()
328
199
 
@@ -331,8 +202,29 @@ class RsyncSyncer(BaseSyncer):
331
202
  if local_dir and not os.path.exists(local_dir):
332
203
  os.makedirs(local_dir, exist_ok=True)
333
204
 
334
- cmd = self._build_rsync_download_command(remote_path, local_path)
205
+ cmd = self._build_rsync_download_command(remote_path, local_path, show_progress=show_progress)
335
206
  try:
336
- subprocess.run(cmd, check=True)
207
+ if show_progress and progress_callback:
208
+ # Run with real-time output parsing for progress display
209
+ process = subprocess.Popen(
210
+ cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1, universal_newlines=True
211
+ )
212
+
213
+ # Parse rsync output line by line
214
+ for line in process.stdout:
215
+ line = line.strip()
216
+ # Skip empty lines and summary lines
217
+ if line and not line.startswith("sent") and not line.startswith("total"):
218
+ # Extract filename from rsync output
219
+ # Rsync --info=NAME outputs filenames directly
220
+ if not line.startswith("receiving") and not line.startswith("building"):
221
+ progress_callback(line)
222
+
223
+ process.wait()
224
+ if process.returncode != 0:
225
+ raise SyncError(f"Rsync download failed with exit code {process.returncode}")
226
+ else:
227
+ # Silent mode - use subprocess.run
228
+ subprocess.run(cmd, check=True)
337
229
  except subprocess.CalledProcessError as e:
338
230
  raise SyncError(f"Rsync download failed with exit code {e.returncode}")
mtr/updater.py ADDED
@@ -0,0 +1,130 @@
1
+ """Update checker for mtr-cli."""
2
+
3
+ import json
4
+ import os
5
+ import urllib.request
6
+ from datetime import datetime, timedelta
7
+ from pathlib import Path
8
+ from typing import Optional
9
+
10
+ from packaging import version
11
+
12
+ from mtr import __version__
13
+
14
+ PYPI_API_URL = "https://pypi.org/pypi/mtr-cli/json"
15
+ CACHE_DIR = Path.home() / ".cache" / "mtr"
16
+ CACHE_FILE = CACHE_DIR / "update_cache.json"
17
+ CHECK_INTERVAL_HOURS = 24
18
+
19
+
20
+ class UpdateChecker:
21
+ """Check for updates from PyPI."""
22
+
23
+ def __init__(self, current_version: str = __version__):
24
+ self.current_version = version.parse(current_version)
25
+ self.cache_file = CACHE_FILE
26
+
27
+ def _ensure_cache_dir(self) -> None:
28
+ """Ensure cache directory exists."""
29
+ self.cache_file.parent.mkdir(parents=True, exist_ok=True)
30
+
31
+ def _load_cache(self) -> dict:
32
+ """Load cache from file."""
33
+ if not self.cache_file.exists():
34
+ return {}
35
+ try:
36
+ with open(self.cache_file, "r") as f:
37
+ return json.load(f)
38
+ except (json.JSONDecodeError, IOError):
39
+ return {}
40
+
41
+ def _save_cache(self, data: dict) -> None:
42
+ """Save cache to file."""
43
+ self._ensure_cache_dir()
44
+ try:
45
+ with open(self.cache_file, "w") as f:
46
+ json.dump(data, f)
47
+ except IOError:
48
+ pass # Silently fail if we can't write cache
49
+
50
+ def should_check(self) -> bool:
51
+ """Check if we should perform an update check."""
52
+ # Check if disabled via environment variable
53
+ if os.environ.get("MTR_DISABLE_UPDATE_CHECK", "").lower() in ("1", "true", "yes"):
54
+ return False
55
+
56
+ cache = self._load_cache()
57
+ last_check = cache.get("last_check_time")
58
+
59
+ if not last_check:
60
+ return True
61
+
62
+ try:
63
+ last_check_time = datetime.fromisoformat(last_check)
64
+ next_check_time = last_check_time + timedelta(hours=CHECK_INTERVAL_HOURS)
65
+ return datetime.now() >= next_check_time
66
+ except ValueError:
67
+ return True
68
+
69
+ def get_latest_version(self) -> Optional[str]:
70
+ """Fetch latest version from PyPI."""
71
+ try:
72
+ with urllib.request.urlopen(PYPI_API_URL, timeout=5) as response:
73
+ data = json.loads(response.read().decode("utf-8"))
74
+ return data["info"]["version"]
75
+ except Exception:
76
+ return None
77
+
78
+ def check(self) -> Optional[str]:
79
+ """Perform update check and return update message if available.
80
+
81
+ Returns:
82
+ Update message string if a new version is available, None otherwise.
83
+ """
84
+ if not self.should_check():
85
+ return None
86
+
87
+ latest_version_str = self.get_latest_version()
88
+
89
+ # Save check result regardless of success (to avoid hammering PyPI on failures)
90
+ cache_data = {
91
+ "last_check_time": datetime.now().isoformat(),
92
+ "current_version": str(self.current_version),
93
+ }
94
+
95
+ if latest_version_str:
96
+ cache_data["latest_version"] = latest_version_str
97
+ self._save_cache(cache_data)
98
+
99
+ latest = version.parse(latest_version_str)
100
+ if latest > self.current_version:
101
+ return self._format_update_message(latest_version_str)
102
+ else:
103
+ self._save_cache(cache_data)
104
+
105
+ return None
106
+
107
+ def _format_update_message(self, latest_version: str) -> str:
108
+ """Format update message."""
109
+ return (
110
+ f"\n"
111
+ f"⚠️ Update available: {self.current_version} → {latest_version}\n"
112
+ f" Run: uv tool upgrade mtr-cli\n"
113
+ f" Or: pip install -U mtr-cli\n"
114
+ )
115
+
116
+ def get_cached_update_message(self) -> Optional[str]:
117
+ """Get update message from cache without making network request.
118
+
119
+ Returns:
120
+ Update message string if a new version was previously detected, None otherwise.
121
+ """
122
+ cache = self._load_cache()
123
+ latest_version_str = cache.get("latest_version")
124
+
125
+ if latest_version_str:
126
+ latest = version.parse(latest_version_str)
127
+ if latest > self.current_version:
128
+ return self._format_update_message(latest_version_str)
129
+
130
+ return None
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mtr-cli
3
- Version: 0.2.0
3
+ Version: 2.0.0
4
4
  Summary: A CLI tool for seamless local development and remote execution on GPU servers.
5
5
  Project-URL: Homepage, https://github.com/lecoan/mtremote
6
6
  Project-URL: Repository, https://github.com/lecoan/mtremote
@@ -20,7 +20,7 @@ Classifier: Topic :: Software Development :: Build Tools
20
20
  Classifier: Topic :: System :: Systems Administration
21
21
  Requires-Python: >=3.10
22
22
  Requires-Dist: click>=8.0.0
23
- Requires-Dist: paramiko>=2.11.0
23
+ Requires-Dist: packaging>=21.0
24
24
  Requires-Dist: pyyaml>=6.0
25
25
  Requires-Dist: rich>=12.0.0
26
26
  Description-Content-Type: text/markdown
@@ -33,8 +33,7 @@ MTRemote 是一个专为 AI Infra 和 Python/C++ 混合开发设计的命令行
33
33
 
34
34
  * **多服务器管理**:通过配置文件管理多个 GPU 节点,支持默认服务器 (Implicit/Explicit)。
35
35
  * **智能同步引擎**:
36
- * **Rsync (推荐)**:调用系统 `rsync`,支持增量同步,速度极快。支持 `sshpass` 自动处理密码认证。
37
- * **SFTP (兼容)**:纯 Python 实现,适用于无 `rsync` 的环境,配置简单。
36
+ * **Rsync**:调用系统 `rsync`,支持增量同步,速度极快。支持 `sshpass` 自动处理密码认证。
38
37
  * **双向同步**:支持从远端下载文件/文件夹到本地(`--get` 参数)。
39
38
  * **双模式交互 (Dual-Mode Interaction)**:
40
39
  * **交互模式 (Interactive)**:自动检测 TTY,支持 PTY 分配、Raw Mode、Rich UI 动画。完美支持 `vim`, `ipython`, `pdb`, `htop`。
@@ -57,11 +56,21 @@ pip install mtr-cli
57
56
 
58
57
  MTRemote 需要以下系统命令:
59
58
 
60
- | 命令 | 用途 | 安装方式 |
61
- |------|------|----------|
62
- | `ssh` | 交互式 Shell (TTY) | macOS/Linux 自带,或 `brew install openssh` |
63
- | `rsync` | 快速文件同步 (推荐) | macOS/Linux 自带 |
64
- | `sshpass` | 密码认证 (可选) | `brew install hudochenkov/sshpass/sshpass` (macOS) / `apt install sshpass` (Ubuntu) |
59
+ | 命令 | 用途 | 安装方式 | 版本要求 |
60
+ |------|------|----------|----------|
61
+ | `ssh` | 交互式 Shell (TTY) | macOS/Linux 自带,或 `brew install openssh` | - |
62
+ | `rsync` | 快速文件同步 (推荐) | macOS/Linux 自带 | **≥ 3.1.0** (TTY 进度显示需要) |
63
+ | `sshpass` | 密码认证 (可选) | `brew install hudochenkov/sshpass/sshpass` (macOS) / `apt install sshpass` (Ubuntu) | - |
64
+
65
+ **注意**:macOS 自带的 rsync 版本较旧(2.6.9),不支持 TTY 模式下的进度显示。建议通过 Homebrew 安装新版:
66
+
67
+ ```bash
68
+ # macOS 用户建议升级 rsync
69
+ brew install rsync
70
+
71
+ # 验证版本
72
+ rsync --version # 应显示 3.1.0 或更高版本
73
+ ```
65
74
 
66
75
  **注意**:交互式 Shell 功能(如 `mtr bash`, `mtr ipython`)**必须**安装 `ssh`。密码认证**必须**安装 `sshpass`。
67
76
 
@@ -83,7 +92,7 @@ mtr --init
83
92
 
84
93
  ```yaml
85
94
  defaults:
86
- sync: "rsync" # 或 "sftp"
95
+ sync: "rsync"
87
96
  exclude: [".git/", "__pycache__/"]
88
97
  download_dir: "./downloads" # 默认下载位置(可选)
89
98
 
@@ -115,6 +124,21 @@ mtr ipython
115
124
  mtr -s prod-node python train.py
116
125
  ```
117
126
 
127
+ ### ⚠️ 参数传递注意事项
128
+
129
+ 当执行的命令包含以 `-` 开头的参数时(如 `python -c`, `gcc -O2`),建议使用 `--` 作为分隔符,避免被误认为是 `mtr` 的选项:
130
+
131
+ ```bash
132
+ # ❌ 错误:-s 会被当作 --server 的短选项
133
+ mtr python3 -s
134
+
135
+ # ✅ 正确:使用 -- 分隔符
136
+ mtr -- python3 -s
137
+
138
+ # ✅ 指定服务器时也使用 --
139
+ mtr --server prod-node -- python3 -c "print('hello')"
140
+ ```
141
+
118
142
  ## 📖 命令行选项
119
143
 
120
144
  ```bash
@@ -159,21 +183,9 @@ mtr --enable-log --log-file ./debug.log python train.py
159
183
  mtr --no-tty python train.py > log.txt
160
184
  ```
161
185
 
162
- ### 2. 使用 SFTP 模式
163
- 如果本地或远程无法使用 rsync,可以在配置中指定 `sync: sftp`:
164
-
165
- ```yaml
166
- servers:
167
- win-server:
168
- host: "10.0.0.9"
169
- sync: "sftp"
170
- password: "secret_password"
171
- ```
172
-
173
- ### 3. 密码认证
186
+ ### 2. 密码认证
174
187
  支持 SSH 密码认证,但推荐使用 SSH Key。
175
188
  * **交互式 Shell**: 使用 `sshpass` 包装 `ssh -t` 命令。
176
- * **SFTP**: 原生支持密码。
177
189
  * **Rsync**: 需要本地安装 `sshpass` 工具才能使用密码认证。
178
190
 
179
191
  **密码认证依赖**: 使用密码认证时,必须安装 `sshpass`:
@@ -188,7 +200,7 @@ sudo apt-get install sshpass
188
200
  sudo yum install sshpass
189
201
  ```
190
202
 
191
- ### 4. 从远端下载文件 (--get)
203
+ ### 3. 从远端下载文件 (--get)
192
204
  使用 `--get` 参数可以从远端服务器下载文件或文件夹到本地:
193
205
 
194
206
  ```bash
@@ -231,7 +243,7 @@ servers:
231
243
  3. 默认配置中的 `download_dir`
232
244
  4. 当前工作目录
233
245
 
234
- ### 5. 调试日志 (--enable-log)
246
+ ### 4. 调试日志 (--enable-log)
235
247
  当遇到问题需要排查时,可以启用文件日志:
236
248
 
237
249
  ```bash