mtr-cli 0.2.0__tar.gz → 2.0.0__tar.gz

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.
Files changed (38) hide show
  1. {mtr_cli-0.2.0 → mtr_cli-2.0.0}/PKG-INFO +37 -25
  2. {mtr_cli-0.2.0 → mtr_cli-2.0.0}/README.md +35 -23
  3. mtr_cli-2.0.0/mtr/__init__.py +3 -0
  4. {mtr_cli-0.2.0 → mtr_cli-2.0.0}/mtr/cli.py +153 -110
  5. {mtr_cli-0.2.0 → mtr_cli-2.0.0}/mtr/config.py +14 -0
  6. mtr_cli-2.0.0/mtr/ssh.py +132 -0
  7. mtr_cli-2.0.0/mtr/sync.py +230 -0
  8. mtr_cli-2.0.0/mtr/updater.py +130 -0
  9. {mtr_cli-0.2.0 → mtr_cli-2.0.0}/pyproject.toml +2 -2
  10. {mtr_cli-0.2.0 → mtr_cli-2.0.0}/tests/integration/test_cli_flow.py +24 -29
  11. mtr_cli-2.0.0/tests/integration/test_cli_phase1.py +67 -0
  12. {mtr_cli-0.2.0 → mtr_cli-2.0.0}/tests/unit/test_config.py +56 -0
  13. mtr_cli-2.0.0/tests/unit/test_ssh.py +25 -0
  14. mtr_cli-2.0.0/tests/unit/test_sync_rsync.py +368 -0
  15. mtr_cli-2.0.0/tests/unit/test_updater.py +282 -0
  16. mtr_cli-2.0.0/uv.lock +424 -0
  17. mtr_cli-0.2.0/mtr/ssh.py +0 -199
  18. mtr_cli-0.2.0/mtr/sync.py +0 -338
  19. mtr_cli-0.2.0/tests/integration/test_cli_phase1.py +0 -75
  20. mtr_cli-0.2.0/tests/unit/__init__.py +0 -0
  21. mtr_cli-0.2.0/tests/unit/test_ssh.py +0 -60
  22. mtr_cli-0.2.0/tests/unit/test_ssh_interactive.py +0 -299
  23. mtr_cli-0.2.0/tests/unit/test_ssh_pre_cmd.py +0 -43
  24. mtr_cli-0.2.0/tests/unit/test_sync_rsync.py +0 -142
  25. mtr_cli-0.2.0/tests/unit/test_sync_sftp.py +0 -178
  26. mtr_cli-0.2.0/uv.lock +0 -709
  27. {mtr_cli-0.2.0 → mtr_cli-2.0.0}/.gitignore +0 -0
  28. {mtr_cli-0.2.0 → mtr_cli-2.0.0}/.pre-commit-config.yaml +0 -0
  29. {mtr_cli-0.2.0 → mtr_cli-2.0.0}/.python-version +0 -0
  30. {mtr_cli-0.2.0 → mtr_cli-2.0.0}/AGENTS.md +0 -0
  31. {mtr_cli-0.2.0 → mtr_cli-2.0.0}/LICENSE +0 -0
  32. {mtr_cli-0.2.0 → mtr_cli-2.0.0}/examples/config.yaml +0 -0
  33. {mtr_cli-0.2.0 → mtr_cli-2.0.0}/mtr/logger.py +0 -0
  34. {mtr_cli-0.2.0/mtr → mtr_cli-2.0.0/tests}/__init__.py +0 -0
  35. {mtr_cli-0.2.0 → mtr_cli-2.0.0}/tests/conftest.py +0 -0
  36. {mtr_cli-0.2.0/tests → mtr_cli-2.0.0/tests/integration}/__init__.py +0 -0
  37. {mtr_cli-0.2.0/tests/integration → mtr_cli-2.0.0/tests/unit}/__init__.py +0 -0
  38. {mtr_cli-0.2.0 → mtr_cli-2.0.0}/tests/unit/test_logger.py +0 -0
@@ -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
@@ -6,8 +6,7 @@ MTRemote 是一个专为 AI Infra 和 Python/C++ 混合开发设计的命令行
6
6
 
7
7
  * **多服务器管理**:通过配置文件管理多个 GPU 节点,支持默认服务器 (Implicit/Explicit)。
8
8
  * **智能同步引擎**:
9
- * **Rsync (推荐)**:调用系统 `rsync`,支持增量同步,速度极快。支持 `sshpass` 自动处理密码认证。
10
- * **SFTP (兼容)**:纯 Python 实现,适用于无 `rsync` 的环境,配置简单。
9
+ * **Rsync**:调用系统 `rsync`,支持增量同步,速度极快。支持 `sshpass` 自动处理密码认证。
11
10
  * **双向同步**:支持从远端下载文件/文件夹到本地(`--get` 参数)。
12
11
  * **双模式交互 (Dual-Mode Interaction)**:
13
12
  * **交互模式 (Interactive)**:自动检测 TTY,支持 PTY 分配、Raw Mode、Rich UI 动画。完美支持 `vim`, `ipython`, `pdb`, `htop`。
@@ -30,11 +29,21 @@ pip install mtr-cli
30
29
 
31
30
  MTRemote 需要以下系统命令:
32
31
 
33
- | 命令 | 用途 | 安装方式 |
34
- |------|------|----------|
35
- | `ssh` | 交互式 Shell (TTY) | macOS/Linux 自带,或 `brew install openssh` |
36
- | `rsync` | 快速文件同步 (推荐) | macOS/Linux 自带 |
37
- | `sshpass` | 密码认证 (可选) | `brew install hudochenkov/sshpass/sshpass` (macOS) / `apt install sshpass` (Ubuntu) |
32
+ | 命令 | 用途 | 安装方式 | 版本要求 |
33
+ |------|------|----------|----------|
34
+ | `ssh` | 交互式 Shell (TTY) | macOS/Linux 自带,或 `brew install openssh` | - |
35
+ | `rsync` | 快速文件同步 (推荐) | macOS/Linux 自带 | **≥ 3.1.0** (TTY 进度显示需要) |
36
+ | `sshpass` | 密码认证 (可选) | `brew install hudochenkov/sshpass/sshpass` (macOS) / `apt install sshpass` (Ubuntu) | - |
37
+
38
+ **注意**:macOS 自带的 rsync 版本较旧(2.6.9),不支持 TTY 模式下的进度显示。建议通过 Homebrew 安装新版:
39
+
40
+ ```bash
41
+ # macOS 用户建议升级 rsync
42
+ brew install rsync
43
+
44
+ # 验证版本
45
+ rsync --version # 应显示 3.1.0 或更高版本
46
+ ```
38
47
 
39
48
  **注意**:交互式 Shell 功能(如 `mtr bash`, `mtr ipython`)**必须**安装 `ssh`。密码认证**必须**安装 `sshpass`。
40
49
 
@@ -56,7 +65,7 @@ mtr --init
56
65
 
57
66
  ```yaml
58
67
  defaults:
59
- sync: "rsync" # 或 "sftp"
68
+ sync: "rsync"
60
69
  exclude: [".git/", "__pycache__/"]
61
70
  download_dir: "./downloads" # 默认下载位置(可选)
62
71
 
@@ -88,6 +97,21 @@ mtr ipython
88
97
  mtr -s prod-node python train.py
89
98
  ```
90
99
 
100
+ ### ⚠️ 参数传递注意事项
101
+
102
+ 当执行的命令包含以 `-` 开头的参数时(如 `python -c`, `gcc -O2`),建议使用 `--` 作为分隔符,避免被误认为是 `mtr` 的选项:
103
+
104
+ ```bash
105
+ # ❌ 错误:-s 会被当作 --server 的短选项
106
+ mtr python3 -s
107
+
108
+ # ✅ 正确:使用 -- 分隔符
109
+ mtr -- python3 -s
110
+
111
+ # ✅ 指定服务器时也使用 --
112
+ mtr --server prod-node -- python3 -c "print('hello')"
113
+ ```
114
+
91
115
  ## 📖 命令行选项
92
116
 
93
117
  ```bash
@@ -132,21 +156,9 @@ mtr --enable-log --log-file ./debug.log python train.py
132
156
  mtr --no-tty python train.py > log.txt
133
157
  ```
134
158
 
135
- ### 2. 使用 SFTP 模式
136
- 如果本地或远程无法使用 rsync,可以在配置中指定 `sync: sftp`:
137
-
138
- ```yaml
139
- servers:
140
- win-server:
141
- host: "10.0.0.9"
142
- sync: "sftp"
143
- password: "secret_password"
144
- ```
145
-
146
- ### 3. 密码认证
159
+ ### 2. 密码认证
147
160
  支持 SSH 密码认证,但推荐使用 SSH Key。
148
161
  * **交互式 Shell**: 使用 `sshpass` 包装 `ssh -t` 命令。
149
- * **SFTP**: 原生支持密码。
150
162
  * **Rsync**: 需要本地安装 `sshpass` 工具才能使用密码认证。
151
163
 
152
164
  **密码认证依赖**: 使用密码认证时,必须安装 `sshpass`:
@@ -161,7 +173,7 @@ sudo apt-get install sshpass
161
173
  sudo yum install sshpass
162
174
  ```
163
175
 
164
- ### 4. 从远端下载文件 (--get)
176
+ ### 3. 从远端下载文件 (--get)
165
177
  使用 `--get` 参数可以从远端服务器下载文件或文件夹到本地:
166
178
 
167
179
  ```bash
@@ -204,7 +216,7 @@ servers:
204
216
  3. 默认配置中的 `download_dir`
205
217
  4. 当前工作目录
206
218
 
207
- ### 5. 调试日志 (--enable-log)
219
+ ### 4. 调试日志 (--enable-log)
208
220
  当遇到问题需要排查时,可以启用文件日志:
209
221
 
210
222
  ```bash
@@ -0,0 +1,3 @@
1
+ """MTRemote - A CLI tool for seamless local development and remote execution."""
2
+
3
+ __version__ = "2.0.0"
@@ -4,17 +4,22 @@ from datetime import datetime
4
4
 
5
5
  import click
6
6
 
7
+ from mtr import __version__
7
8
  from mtr.config import ConfigError, ConfigLoader
8
9
  from mtr.logger import LogLevel, get_logger, setup_logging
9
- from mtr.ssh import SSHClientWrapper, SSHError
10
- from mtr.sync import RsyncSyncer, SftpSyncer, SyncError
10
+ from mtr.ssh import SSHError, run_ssh_command
11
+ from mtr.sync import RsyncSyncer, SyncError
12
+ from mtr.updater import UpdateChecker
11
13
 
12
14
  DEFAULT_CONFIG_TEMPLATE = """# MTRemote Configuration
13
15
  defaults:
14
- # 默认同步引擎
15
- # 选项: "rsync" (推荐), "sftp"
16
+ # 默认同步引擎 (仅支持 rsync)
16
17
  sync: "rsync"
17
18
 
19
+ # 是否尊重 .gitignore 文件
20
+ # 设置为 true 时,rsync 会自动读取项目根目录的 .gitignore 并排除匹配的文件
21
+ respect_gitignore: true
22
+
18
23
  exclude:
19
24
  - ".git/"
20
25
  - "__pycache__/"
@@ -34,12 +39,9 @@ servers:
34
39
  # 预设命令 (可选)
35
40
  # pre_cmd: "source ~/.bashrc && conda activate myenv"
36
41
 
37
- # 密码认证 (可选)
42
+ # 密码认证 (可选,需要安装 sshpass)
38
43
  # password: "secret"
39
44
 
40
- # 强制同步引擎 (可选)
41
- # sync: "sftp"
42
-
43
45
  # 该服务器的下载位置(可选,覆盖 defaults)
44
46
  # download_dir: "./backups/dev-node"
45
47
  """
@@ -66,6 +68,7 @@ def _init_config():
66
68
 
67
69
 
68
70
  @click.command(context_settings=dict(ignore_unknown_options=True, allow_extra_args=True))
71
+ @click.version_option(version=__version__, prog_name="mtr-cli", help="Show version and exit.")
69
72
  @click.option("-s", "--server", help="Target server alias")
70
73
  @click.option("--sync/--no-sync", default=True, help="Enable/Disable code sync")
71
74
  @click.option("--dry-run", is_flag=True, help="Print commands without executing")
@@ -76,10 +79,36 @@ def _init_config():
76
79
  @click.option("--log-file", help="Path to log file (default: ./.mtr/logs/mtr_YYYYMMDD_HHMMSS.log)")
77
80
  @click.option("--get", "remote_get_path", help="Remote path to download from")
78
81
  @click.option("--to", "local_dest_path", help="Local destination path for download (optional)")
82
+ @click.option("--no-check-update", is_flag=True, help="Disable update check")
79
83
  @click.argument("command", nargs=-1, type=click.UNPROCESSED)
80
- def cli(server, sync, dry_run, tty, init, enable_log, log_level, log_file, remote_get_path, local_dest_path, command):
84
+ def cli(
85
+ server,
86
+ sync,
87
+ dry_run,
88
+ tty,
89
+ init,
90
+ enable_log,
91
+ log_level,
92
+ log_file,
93
+ remote_get_path,
94
+ local_dest_path,
95
+ no_check_update,
96
+ command,
97
+ ):
81
98
  """MTRemote: Sync and Execute code on remote server."""
82
99
 
100
+ # Check for updates (async, non-blocking)
101
+ update_message = None
102
+ if not no_check_update and not init:
103
+ checker = UpdateChecker()
104
+ # Try to get cached update message first (from previous check)
105
+ update_message = checker.get_cached_update_message()
106
+ # Trigger background check for next time
107
+ try:
108
+ checker.check()
109
+ except Exception:
110
+ pass # Silently fail update check
111
+
83
112
  # Get logger instance (will be no-op if not setup)
84
113
  logger = get_logger()
85
114
 
@@ -186,55 +215,67 @@ def cli(server, sync, dry_run, tty, init, enable_log, log_level, log_file, remot
186
215
  # Resolve exclude
187
216
  exclude = config.global_defaults.get("exclude", []) + server_conf.get("exclude", [])
188
217
 
218
+ # Get respect_gitignore setting
219
+ respect_gitignore = config.get_respect_gitignore()
220
+
189
221
  # Determine engine
190
222
  engine = server_conf.get("sync", config.global_defaults.get("sync", "rsync"))
191
223
 
192
- if engine == "rsync":
193
- syncer = RsyncSyncer(
194
- local_dir=local_dir,
195
- remote_dir=remote_dir,
196
- host=host,
197
- user=user,
198
- key_filename=key_filename,
199
- password=password,
200
- port=port,
201
- exclude=exclude,
202
- )
203
- elif engine == "sftp":
204
- syncer = SftpSyncer(
205
- local_dir=local_dir,
206
- remote_dir=remote_dir,
207
- host=host,
208
- user=user,
209
- key_filename=key_filename,
210
- password=password,
211
- port=port,
212
- exclude=exclude,
213
- )
214
- else:
224
+ # Check if SFTP is configured
225
+ if engine == "sftp":
226
+ logger.error("SFTP mode is no longer supported. Please use rsync.", module="mtr.cli")
215
227
  click.secho(
216
- f"Warning: Sync engine '{engine}' not supported yet. Fallback/Skipping.",
217
- fg="yellow",
228
+ "Error: SFTP mode has been removed. Please update your config to use 'sync: rsync'.",
229
+ fg="red",
230
+ err=True,
218
231
  )
219
- syncer = None
232
+ sys.exit(1)
233
+
234
+ syncer = RsyncSyncer(
235
+ local_dir=local_dir,
236
+ remote_dir=remote_dir,
237
+ host=host,
238
+ user=user,
239
+ key_filename=key_filename,
240
+ password=password,
241
+ port=port,
242
+ exclude=exclude,
243
+ respect_gitignore=respect_gitignore,
244
+ )
245
+
246
+ try:
247
+ if dry_run:
248
+ click.echo(f"[DryRun] Would sync {local_dir} -> {remote_dir}")
249
+ logger.info(f"[DryRun] Would sync {local_dir} -> {remote_dir}", module="mtr.sync")
250
+ else:
251
+ if is_interactive and console:
252
+ # TTY mode: single line real-time update using Rich Live
253
+ from rich.live import Live
254
+ from rich.text import Text
255
+
256
+ with Live(Text("Starting sync...", style="blue"), refresh_per_second=10) as live:
220
257
 
221
- if syncer:
222
- try:
223
- if dry_run:
224
- click.echo(f"[DryRun] Would sync {local_dir} -> {remote_dir}")
225
- logger.info(f"[DryRun] Would sync {local_dir} -> {remote_dir}", module="mtr.sync")
258
+ def show_sync_progress(filename):
259
+ # Get relative path for cleaner display
260
+ rel_path = os.path.relpath(filename, local_dir)
261
+ live.update(Text(f"Syncing: {rel_path}", style="blue"))
262
+
263
+ syncer.sync(show_progress=True, progress_callback=show_sync_progress)
264
+ live.update(Text("Sync completed!", style="green"))
226
265
  else:
227
- if is_interactive and console:
228
- with console.status("[bold blue]Syncing code...", spinner="dots"):
229
- syncer.sync()
230
- else:
231
- click.secho("Syncing code...", fg="blue")
232
- syncer.sync()
233
- logger.info(f"Sync completed: {local_dir} -> {remote_dir}", module="mtr.sync")
234
- except SyncError as e:
235
- logger.error(f"Sync failed: {e}", module="mtr.sync")
236
- click.secho(f"Sync Failed: {e}", fg="red", err=True)
237
- sys.exit(1)
266
+ # no_tty mode: print each file on new line
267
+ def show_sync_progress(filename):
268
+ rel_path = os.path.relpath(filename, local_dir)
269
+ click.echo(f"Syncing: {rel_path}")
270
+
271
+ click.secho("Syncing code...", fg="blue")
272
+ syncer.sync(show_progress=True, progress_callback=show_sync_progress)
273
+ click.secho("Sync completed!", fg="green")
274
+ logger.info(f"Sync completed: {local_dir} -> {remote_dir}", module="mtr.sync")
275
+ except SyncError as e:
276
+ logger.error(f"Sync failed: {e}", module="mtr.sync")
277
+ click.secho(f"Sync Failed: {e}", fg="red", err=True)
278
+ sys.exit(1)
238
279
 
239
280
  # 3. Download from remote (if --get is specified)
240
281
  if remote_get_path:
@@ -260,50 +301,61 @@ def cli(server, sync, dry_run, tty, init, enable_log, log_level, log_file, remot
260
301
  # Resolve exclude
261
302
  exclude = config.global_defaults.get("exclude", []) + server_conf.get("exclude", [])
262
303
 
304
+ # Get respect_gitignore setting
305
+ respect_gitignore = config.get_respect_gitignore()
306
+
263
307
  # Determine engine
264
308
  engine = server_conf.get("sync", config.global_defaults.get("sync", "rsync"))
265
309
 
266
- if engine == "rsync":
267
- syncer = RsyncSyncer(
268
- local_dir=".", # Not used for download
269
- remote_dir=".", # Not used for download
270
- host=host,
271
- user=user,
272
- key_filename=key_filename,
273
- password=password,
274
- port=port,
275
- exclude=exclude,
276
- )
277
- elif engine == "sftp":
278
- syncer = SftpSyncer(
279
- local_dir=".", # Not used for download
280
- remote_dir=".", # Not used for download
281
- host=host,
282
- user=user,
283
- key_filename=key_filename,
284
- password=password,
285
- port=port,
286
- exclude=exclude,
287
- )
288
- else:
310
+ # Check if SFTP is configured
311
+ if engine == "sftp":
312
+ logger.error("SFTP mode is no longer supported. Please use rsync.", module="mtr.cli")
289
313
  click.secho(
290
- f"Warning: Sync engine '{engine}' not supported yet.",
291
- fg="yellow",
314
+ "Error: SFTP mode has been removed. Please update your config to use 'sync: rsync'.",
315
+ fg="red",
316
+ err=True,
292
317
  )
293
318
  sys.exit(1)
294
319
 
320
+ syncer = RsyncSyncer(
321
+ local_dir=".", # Not used for download
322
+ remote_dir=".", # Not used for download
323
+ host=host,
324
+ user=user,
325
+ key_filename=key_filename,
326
+ password=password,
327
+ port=port,
328
+ exclude=exclude,
329
+ respect_gitignore=respect_gitignore,
330
+ )
331
+
295
332
  try:
296
333
  if dry_run:
297
334
  click.echo(f"[DryRun] Would download {remote_get_path} -> {local_dest}")
298
335
  logger.info(f"[DryRun] Would download {remote_get_path} -> {local_dest}", module="mtr.sync")
299
336
  else:
300
337
  if is_interactive and console:
301
- with console.status(f"[bold blue]Downloading {remote_get_path}...", spinner="dots"):
302
- syncer.download(remote_get_path, local_dest)
338
+ # TTY mode: single line real-time update using Rich Live
339
+ from rich.live import Live
340
+ from rich.text import Text
341
+
342
+ with Live(Text("Starting download...", style="blue"), refresh_per_second=10) as live:
343
+
344
+ def show_download_progress(filename):
345
+ live.update(Text(f"Downloading: {filename}", style="blue"))
346
+
347
+ syncer.download(
348
+ remote_get_path, local_dest, show_progress=True, progress_callback=show_download_progress
349
+ )
350
+ live.update(Text("Download completed!", style="green"))
303
351
  console.print(f"✅ [green]Downloaded:[/green] {remote_get_path} -> {local_dest}")
304
352
  else:
353
+ # no_tty mode: print each file on new line
354
+ def show_download_progress(filename):
355
+ click.echo(f"Downloading: {filename}")
356
+
305
357
  click.secho(f"Downloading {remote_get_path}...", fg="blue")
306
- syncer.download(remote_get_path, local_dest)
358
+ syncer.download(remote_get_path, local_dest, show_progress=True, progress_callback=show_download_progress)
307
359
  click.secho(f"Download completed: {local_dest}", fg="green")
308
360
  logger.info(f"Download completed: {remote_get_path} -> {local_dest}", module="mtr.sync")
309
361
  except SyncError as e:
@@ -322,43 +374,34 @@ def cli(server, sync, dry_run, tty, init, enable_log, log_level, log_file, remot
322
374
  click.echo(f"[DryRun] Would run on {host}: {remote_cmd} (workdir={remote_dir})")
323
375
  return
324
376
 
325
- ssh = SSHClientWrapper(host, user, port=port, key_filename=key_filename, password=password)
326
377
  try:
327
- ssh.connect()
328
- logger.info(f"SSH connection established to {host}", module="mtr.ssh")
329
-
330
- if is_interactive:
331
- # Run interactive shell (full TTY support)
332
- logger.info(f"Executing interactive command: {remote_cmd}", module="mtr.cli")
333
- exit_code = ssh.run_interactive_shell(remote_cmd, workdir=remote_dir, pre_cmd=pre_cmd)
334
- logger.info(f"Command completed with exit code: {exit_code}", module="mtr.cli")
335
- sys.exit(exit_code)
336
- else:
337
- # Run stream mode (for scripts/pipes)
338
- # pty=False ensures clean output for parsing (separates stdout/stderr if we implemented that,
339
- # but currently streams merged or just stdout. Let's keep pty=False to avoid control chars)
340
- logger.info(f"Executing command: {remote_cmd}", module="mtr.cli")
341
- stream = ssh.exec_command_stream(remote_cmd, workdir=remote_dir, pre_cmd=pre_cmd, pty=False)
342
-
343
- # Consume generator and print
344
- exit_code = 0
345
- try:
346
- while True:
347
- line = next(stream)
348
- click.echo(line, nl=False)
349
- except StopIteration as e:
350
- exit_code = e.value
351
-
352
- logger.info(f"Command completed with exit code: {exit_code}", module="mtr.cli")
353
- sys.exit(exit_code)
378
+ # Execute command via SSH
379
+ logger.info(f"Executing command: {remote_cmd}", module="mtr.cli")
380
+ exit_code = run_ssh_command(
381
+ host=host,
382
+ user=user,
383
+ command=remote_cmd,
384
+ port=port,
385
+ key_filename=key_filename,
386
+ password=password,
387
+ workdir=remote_dir,
388
+ pre_cmd=pre_cmd,
389
+ tty=is_interactive,
390
+ )
391
+ logger.info(f"Command completed with exit code: {exit_code}", module="mtr.cli")
392
+
393
+ # Show update message if available
394
+ if update_message:
395
+ click.echo(update_message, err=True)
396
+ sys.exit(exit_code)
354
397
 
355
398
  except SSHError as e:
356
399
  logger.error(f"SSH error: {e}", module="mtr.ssh")
357
400
  click.secho(f"SSH Error: {e}", fg="red", err=True)
401
+ # Show update message if available even on error
402
+ if update_message:
403
+ click.echo(update_message, err=True)
358
404
  sys.exit(1)
359
- finally:
360
- logger.info("Closing SSH connection", module="mtr.ssh")
361
- ssh.close()
362
405
 
363
406
 
364
407
  if __name__ == "__main__":
@@ -19,6 +19,20 @@ class Config:
19
19
  server_config: Dict[str, Any]
20
20
  global_defaults: Dict[str, Any]
21
21
 
22
+ def get_respect_gitignore(self) -> bool:
23
+ """Get respect_gitignore setting, default True.
24
+
25
+ Priority: server config > global defaults > True (default)
26
+ """
27
+ # Check server config first
28
+ if "respect_gitignore" in self.server_config:
29
+ return self.server_config["respect_gitignore"]
30
+ # Then check global defaults
31
+ if "respect_gitignore" in self.global_defaults:
32
+ return self.global_defaults["respect_gitignore"]
33
+ # Default to True
34
+ return True
35
+
22
36
 
23
37
  class ConfigLoader:
24
38
  def __init__(self, config_path: Optional[str] = None):