mtr-cli 0.3.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 (37) hide show
  1. {mtr_cli-0.3.0 → mtr_cli-2.0.0}/PKG-INFO +22 -20
  2. {mtr_cli-0.3.0 → mtr_cli-2.0.0}/README.md +20 -18
  3. mtr_cli-2.0.0/mtr/__init__.py +3 -0
  4. {mtr_cli-0.3.0 → mtr_cli-2.0.0}/mtr/cli.py +123 -127
  5. mtr_cli-2.0.0/mtr/ssh.py +132 -0
  6. {mtr_cli-0.3.0 → mtr_cli-2.0.0}/mtr/sync.py +0 -240
  7. mtr_cli-2.0.0/mtr/updater.py +130 -0
  8. {mtr_cli-0.3.0 → mtr_cli-2.0.0}/pyproject.toml +2 -2
  9. {mtr_cli-0.3.0 → mtr_cli-2.0.0}/tests/integration/test_cli_flow.py +24 -29
  10. mtr_cli-2.0.0/tests/integration/test_cli_phase1.py +67 -0
  11. mtr_cli-2.0.0/tests/unit/test_ssh.py +25 -0
  12. mtr_cli-2.0.0/tests/unit/test_updater.py +282 -0
  13. mtr_cli-2.0.0/uv.lock +424 -0
  14. mtr_cli-0.3.0/docs/intro-to-mtr.md +0 -180
  15. mtr_cli-0.3.0/mtr/ssh.py +0 -199
  16. mtr_cli-0.3.0/tests/integration/test_cli_phase1.py +0 -75
  17. mtr_cli-0.3.0/tests/unit/__init__.py +0 -0
  18. mtr_cli-0.3.0/tests/unit/test_ssh.py +0 -60
  19. mtr_cli-0.3.0/tests/unit/test_ssh_interactive.py +0 -299
  20. mtr_cli-0.3.0/tests/unit/test_ssh_pre_cmd.py +0 -43
  21. mtr_cli-0.3.0/tests/unit/test_sync_sftp.py +0 -206
  22. mtr_cli-0.3.0/uv.lock +0 -709
  23. {mtr_cli-0.3.0 → mtr_cli-2.0.0}/.gitignore +0 -0
  24. {mtr_cli-0.3.0 → mtr_cli-2.0.0}/.pre-commit-config.yaml +0 -0
  25. {mtr_cli-0.3.0 → mtr_cli-2.0.0}/.python-version +0 -0
  26. {mtr_cli-0.3.0 → mtr_cli-2.0.0}/AGENTS.md +0 -0
  27. {mtr_cli-0.3.0 → mtr_cli-2.0.0}/LICENSE +0 -0
  28. {mtr_cli-0.3.0 → mtr_cli-2.0.0}/examples/config.yaml +0 -0
  29. {mtr_cli-0.3.0 → mtr_cli-2.0.0}/mtr/config.py +0 -0
  30. {mtr_cli-0.3.0 → mtr_cli-2.0.0}/mtr/logger.py +0 -0
  31. {mtr_cli-0.3.0/mtr → mtr_cli-2.0.0/tests}/__init__.py +0 -0
  32. {mtr_cli-0.3.0 → mtr_cli-2.0.0}/tests/conftest.py +0 -0
  33. {mtr_cli-0.3.0/tests → mtr_cli-2.0.0/tests/integration}/__init__.py +0 -0
  34. {mtr_cli-0.3.0/tests/integration → mtr_cli-2.0.0/tests/unit}/__init__.py +0 -0
  35. {mtr_cli-0.3.0 → mtr_cli-2.0.0}/tests/unit/test_config.py +0 -0
  36. {mtr_cli-0.3.0 → mtr_cli-2.0.0}/tests/unit/test_logger.py +0 -0
  37. {mtr_cli-0.3.0 → mtr_cli-2.0.0}/tests/unit/test_sync_rsync.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mtr-cli
3
- Version: 0.3.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`。
@@ -93,7 +92,7 @@ mtr --init
93
92
 
94
93
  ```yaml
95
94
  defaults:
96
- sync: "rsync" # 或 "sftp"
95
+ sync: "rsync"
97
96
  exclude: [".git/", "__pycache__/"]
98
97
  download_dir: "./downloads" # 默认下载位置(可选)
99
98
 
@@ -125,6 +124,21 @@ mtr ipython
125
124
  mtr -s prod-node python train.py
126
125
  ```
127
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
+
128
142
  ## 📖 命令行选项
129
143
 
130
144
  ```bash
@@ -169,21 +183,9 @@ mtr --enable-log --log-file ./debug.log python train.py
169
183
  mtr --no-tty python train.py > log.txt
170
184
  ```
171
185
 
172
- ### 2. 使用 SFTP 模式
173
- 如果本地或远程无法使用 rsync,可以在配置中指定 `sync: sftp`:
174
-
175
- ```yaml
176
- servers:
177
- win-server:
178
- host: "10.0.0.9"
179
- sync: "sftp"
180
- password: "secret_password"
181
- ```
182
-
183
- ### 3. 密码认证
186
+ ### 2. 密码认证
184
187
  支持 SSH 密码认证,但推荐使用 SSH Key。
185
188
  * **交互式 Shell**: 使用 `sshpass` 包装 `ssh -t` 命令。
186
- * **SFTP**: 原生支持密码。
187
189
  * **Rsync**: 需要本地安装 `sshpass` 工具才能使用密码认证。
188
190
 
189
191
  **密码认证依赖**: 使用密码认证时,必须安装 `sshpass`:
@@ -198,7 +200,7 @@ sudo apt-get install sshpass
198
200
  sudo yum install sshpass
199
201
  ```
200
202
 
201
- ### 4. 从远端下载文件 (--get)
203
+ ### 3. 从远端下载文件 (--get)
202
204
  使用 `--get` 参数可以从远端服务器下载文件或文件夹到本地:
203
205
 
204
206
  ```bash
@@ -241,7 +243,7 @@ servers:
241
243
  3. 默认配置中的 `download_dir`
242
244
  4. 当前工作目录
243
245
 
244
- ### 5. 调试日志 (--enable-log)
246
+ ### 4. 调试日志 (--enable-log)
245
247
  当遇到问题需要排查时,可以启用文件日志:
246
248
 
247
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`。
@@ -66,7 +65,7 @@ mtr --init
66
65
 
67
66
  ```yaml
68
67
  defaults:
69
- sync: "rsync" # 或 "sftp"
68
+ sync: "rsync"
70
69
  exclude: [".git/", "__pycache__/"]
71
70
  download_dir: "./downloads" # 默认下载位置(可选)
72
71
 
@@ -98,6 +97,21 @@ mtr ipython
98
97
  mtr -s prod-node python train.py
99
98
  ```
100
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
+
101
115
  ## 📖 命令行选项
102
116
 
103
117
  ```bash
@@ -142,21 +156,9 @@ mtr --enable-log --log-file ./debug.log python train.py
142
156
  mtr --no-tty python train.py > log.txt
143
157
  ```
144
158
 
145
- ### 2. 使用 SFTP 模式
146
- 如果本地或远程无法使用 rsync,可以在配置中指定 `sync: sftp`:
147
-
148
- ```yaml
149
- servers:
150
- win-server:
151
- host: "10.0.0.9"
152
- sync: "sftp"
153
- password: "secret_password"
154
- ```
155
-
156
- ### 3. 密码认证
159
+ ### 2. 密码认证
157
160
  支持 SSH 密码认证,但推荐使用 SSH Key。
158
161
  * **交互式 Shell**: 使用 `sshpass` 包装 `ssh -t` 命令。
159
- * **SFTP**: 原生支持密码。
160
162
  * **Rsync**: 需要本地安装 `sshpass` 工具才能使用密码认证。
161
163
 
162
164
  **密码认证依赖**: 使用密码认证时,必须安装 `sshpass`:
@@ -171,7 +173,7 @@ sudo apt-get install sshpass
171
173
  sudo yum install sshpass
172
174
  ```
173
175
 
174
- ### 4. 从远端下载文件 (--get)
176
+ ### 3. 从远端下载文件 (--get)
175
177
  使用 `--get` 参数可以从远端服务器下载文件或文件夹到本地:
176
178
 
177
179
  ```bash
@@ -214,7 +216,7 @@ servers:
214
216
  3. 默认配置中的 `download_dir`
215
217
  4. 当前工作目录
216
218
 
217
- ### 5. 调试日志 (--enable-log)
219
+ ### 4. 调试日志 (--enable-log)
218
220
  当遇到问题需要排查时,可以启用文件日志:
219
221
 
220
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,20 +4,20 @@ 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
 
18
- # 是否尊重 .gitignore 文件(仅 rsync 模式支持)
19
+ # 是否尊重 .gitignore 文件
19
20
  # 设置为 true 时,rsync 会自动读取项目根目录的 .gitignore 并排除匹配的文件
20
- # SFTP 模式不支持此选项,如启用会报错
21
21
  respect_gitignore: true
22
22
 
23
23
  exclude:
@@ -39,12 +39,9 @@ servers:
39
39
  # 预设命令 (可选)
40
40
  # pre_cmd: "source ~/.bashrc && conda activate myenv"
41
41
 
42
- # 密码认证 (可选)
42
+ # 密码认证 (可选,需要安装 sshpass)
43
43
  # password: "secret"
44
44
 
45
- # 强制同步引擎 (可选)
46
- # sync: "sftp"
47
-
48
45
  # 该服务器的下载位置(可选,覆盖 defaults)
49
46
  # download_dir: "./backups/dev-node"
50
47
  """
@@ -71,6 +68,7 @@ def _init_config():
71
68
 
72
69
 
73
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.")
74
72
  @click.option("-s", "--server", help="Target server alias")
75
73
  @click.option("--sync/--no-sync", default=True, help="Enable/Disable code sync")
76
74
  @click.option("--dry-run", is_flag=True, help="Print commands without executing")
@@ -81,10 +79,36 @@ def _init_config():
81
79
  @click.option("--log-file", help="Path to log file (default: ./.mtr/logs/mtr_YYYYMMDD_HHMMSS.log)")
82
80
  @click.option("--get", "remote_get_path", help="Remote path to download from")
83
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")
84
83
  @click.argument("command", nargs=-1, type=click.UNPROCESSED)
85
- 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
+ ):
86
98
  """MTRemote: Sync and Execute code on remote server."""
87
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
+
88
112
  # Get logger instance (will be no-op if not setup)
89
113
  logger = get_logger()
90
114
 
@@ -197,71 +221,61 @@ def cli(server, sync, dry_run, tty, init, enable_log, log_level, log_file, remot
197
221
  # Determine engine
198
222
  engine = server_conf.get("sync", config.global_defaults.get("sync", "rsync"))
199
223
 
200
- if engine == "rsync":
201
- syncer = RsyncSyncer(
202
- local_dir=local_dir,
203
- remote_dir=remote_dir,
204
- host=host,
205
- user=user,
206
- key_filename=key_filename,
207
- password=password,
208
- port=port,
209
- exclude=exclude,
210
- respect_gitignore=respect_gitignore,
211
- )
212
- elif engine == "sftp":
213
- syncer = SftpSyncer(
214
- local_dir=local_dir,
215
- remote_dir=remote_dir,
216
- host=host,
217
- user=user,
218
- key_filename=key_filename,
219
- password=password,
220
- port=port,
221
- exclude=exclude,
222
- respect_gitignore=respect_gitignore,
223
- )
224
- 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")
225
227
  click.secho(
226
- f"Warning: Sync engine '{engine}' not supported yet. Fallback/Skipping.",
227
- fg="yellow",
228
+ "Error: SFTP mode has been removed. Please update your config to use 'sync: rsync'.",
229
+ fg="red",
230
+ err=True,
228
231
  )
229
- 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:
230
257
 
231
- if syncer:
232
- try:
233
- if dry_run:
234
- click.echo(f"[DryRun] Would sync {local_dir} -> {remote_dir}")
235
- logger.info(f"[DryRun] Would sync {local_dir} -> {remote_dir}", module="mtr.sync")
236
- else:
237
- if is_interactive and console:
238
- # TTY mode: single line real-time update using Rich Live
239
- from rich.live import Live
240
- from rich.text import Text
241
-
242
- with Live(Text("Starting sync...", style="blue"), refresh_per_second=10) as live:
243
-
244
- def show_sync_progress(filename):
245
- # Get relative path for cleaner display
246
- rel_path = os.path.relpath(filename, local_dir)
247
- live.update(Text(f"Syncing: {rel_path}", style="blue"))
248
-
249
- syncer.sync(show_progress=True, progress_callback=show_sync_progress)
250
- live.update(Text("Sync completed!", style="green"))
251
- else:
252
- # no_tty mode: print each file on new line
253
258
  def show_sync_progress(filename):
259
+ # Get relative path for cleaner display
254
260
  rel_path = os.path.relpath(filename, local_dir)
255
- click.echo(f"Syncing: {rel_path}")
261
+ live.update(Text(f"Syncing: {rel_path}", style="blue"))
256
262
 
257
- click.secho("Syncing code...", fg="blue")
258
263
  syncer.sync(show_progress=True, progress_callback=show_sync_progress)
259
- click.secho("Sync completed!", fg="green")
260
- logger.info(f"Sync completed: {local_dir} -> {remote_dir}", module="mtr.sync")
261
- except SyncError as e:
262
- logger.error(f"Sync failed: {e}", module="mtr.sync")
263
- click.secho(f"Sync Failed: {e}", fg="red", err=True)
264
- sys.exit(1)
264
+ live.update(Text("Sync completed!", style="green"))
265
+ else:
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)
265
279
 
266
280
  # 3. Download from remote (if --get is specified)
267
281
  if remote_get_path:
@@ -293,37 +307,28 @@ def cli(server, sync, dry_run, tty, init, enable_log, log_level, log_file, remot
293
307
  # Determine engine
294
308
  engine = server_conf.get("sync", config.global_defaults.get("sync", "rsync"))
295
309
 
296
- if engine == "rsync":
297
- syncer = RsyncSyncer(
298
- local_dir=".", # Not used for download
299
- remote_dir=".", # Not used for download
300
- host=host,
301
- user=user,
302
- key_filename=key_filename,
303
- password=password,
304
- port=port,
305
- exclude=exclude,
306
- respect_gitignore=respect_gitignore,
307
- )
308
- elif engine == "sftp":
309
- syncer = SftpSyncer(
310
- local_dir=".", # Not used for download
311
- remote_dir=".", # Not used for download
312
- host=host,
313
- user=user,
314
- key_filename=key_filename,
315
- password=password,
316
- port=port,
317
- exclude=exclude,
318
- respect_gitignore=respect_gitignore,
319
- )
320
- 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")
321
313
  click.secho(
322
- f"Warning: Sync engine '{engine}' not supported yet.",
323
- fg="yellow",
314
+ "Error: SFTP mode has been removed. Please update your config to use 'sync: rsync'.",
315
+ fg="red",
316
+ err=True,
324
317
  )
325
318
  sys.exit(1)
326
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
+
327
332
  try:
328
333
  if dry_run:
329
334
  click.echo(f"[DryRun] Would download {remote_get_path} -> {local_dest}")
@@ -369,43 +374,34 @@ def cli(server, sync, dry_run, tty, init, enable_log, log_level, log_file, remot
369
374
  click.echo(f"[DryRun] Would run on {host}: {remote_cmd} (workdir={remote_dir})")
370
375
  return
371
376
 
372
- ssh = SSHClientWrapper(host, user, port=port, key_filename=key_filename, password=password)
373
377
  try:
374
- ssh.connect()
375
- logger.info(f"SSH connection established to {host}", module="mtr.ssh")
376
-
377
- if is_interactive:
378
- # Run interactive shell (full TTY support)
379
- logger.info(f"Executing interactive command: {remote_cmd}", module="mtr.cli")
380
- exit_code = ssh.run_interactive_shell(remote_cmd, workdir=remote_dir, pre_cmd=pre_cmd)
381
- logger.info(f"Command completed with exit code: {exit_code}", module="mtr.cli")
382
- sys.exit(exit_code)
383
- else:
384
- # Run stream mode (for scripts/pipes)
385
- # pty=False ensures clean output for parsing (separates stdout/stderr if we implemented that,
386
- # but currently streams merged or just stdout. Let's keep pty=False to avoid control chars)
387
- logger.info(f"Executing command: {remote_cmd}", module="mtr.cli")
388
- stream = ssh.exec_command_stream(remote_cmd, workdir=remote_dir, pre_cmd=pre_cmd, pty=False)
389
-
390
- # Consume generator and print
391
- exit_code = 0
392
- try:
393
- while True:
394
- line = next(stream)
395
- click.echo(line, nl=False)
396
- except StopIteration as e:
397
- exit_code = e.value
398
-
399
- logger.info(f"Command completed with exit code: {exit_code}", module="mtr.cli")
400
- 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)
401
397
 
402
398
  except SSHError as e:
403
399
  logger.error(f"SSH error: {e}", module="mtr.ssh")
404
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)
405
404
  sys.exit(1)
406
- finally:
407
- logger.info("Closing SSH connection", module="mtr.ssh")
408
- ssh.close()
409
405
 
410
406
 
411
407
  if __name__ == "__main__":
@@ -0,0 +1,132 @@
1
+ """SSH utilities for MTRemote."""
2
+
3
+ import os
4
+ import shutil
5
+ import subprocess
6
+ from typing import Optional
7
+
8
+ from mtr.logger import get_logger
9
+
10
+
11
+ class SSHError(Exception):
12
+ """SSH-related errors."""
13
+
14
+ pass
15
+
16
+
17
+ def _check_ssh_availability():
18
+ """Check if ssh command is available."""
19
+ if shutil.which("ssh") is None:
20
+ raise SSHError(
21
+ "SSH command not found. Please install OpenSSH client.\n"
22
+ " macOS: brew install openssh\n"
23
+ " Ubuntu/Debian: sudo apt-get install openssh-client\n"
24
+ " CentOS/RHEL: sudo yum install openssh-clients"
25
+ )
26
+
27
+
28
+ def _check_sshpass_availability():
29
+ """Check if sshpass command is available."""
30
+ if shutil.which("sshpass") is None:
31
+ raise SSHError(
32
+ "sshpass command not found. Please install sshpass for password authentication.\n"
33
+ " macOS: brew install hudochenkov/sshpass/sshpass\n"
34
+ " Ubuntu/Debian: sudo apt-get install sshpass\n"
35
+ " CentOS/RHEL: sudo yum install sshpass"
36
+ )
37
+
38
+
39
+ def _build_command(command: str, workdir: Optional[str] = None, pre_cmd: Optional[str] = None) -> str:
40
+ """Build the full command string with workdir and pre_cmd."""
41
+ parts = []
42
+ if workdir:
43
+ parts.append(f"cd {workdir}")
44
+ if pre_cmd:
45
+ parts.append(pre_cmd)
46
+ parts.append(command)
47
+ return " && ".join(parts)
48
+
49
+
50
+ def run_ssh_command(
51
+ host: str,
52
+ user: str,
53
+ command: str,
54
+ port: int = 22,
55
+ key_filename: Optional[str] = None,
56
+ password: Optional[str] = None,
57
+ workdir: Optional[str] = None,
58
+ pre_cmd: Optional[str] = None,
59
+ tty: bool = True,
60
+ ) -> int:
61
+ """
62
+ Run a command on remote host via system SSH.
63
+
64
+ Args:
65
+ host: Remote host address
66
+ user: SSH username
67
+ command: Command to execute
68
+ port: SSH port (default: 22)
69
+ key_filename: Path to SSH private key
70
+ password: SSH password (requires sshpass)
71
+ workdir: Working directory on remote host
72
+ pre_cmd: Command to run before main command
73
+ tty: If True, use ssh -t for TTY allocation
74
+
75
+ Returns:
76
+ Exit code from the remote command
77
+
78
+ Raises:
79
+ SSHError: If SSH command fails or is not available
80
+ """
81
+ logger = get_logger()
82
+ mode_str = "interactive" if tty else "batch"
83
+ logger.info(f"Executing {mode_str} command via SSH: {command}", module="mtr.ssh")
84
+ logger.debug(f"Host: {host}, User: {user}, Port: {port}, TTY: {tty}", module="mtr.ssh")
85
+
86
+ # Check SSH availability
87
+ _check_ssh_availability()
88
+
89
+ # Check sshpass availability if password is used
90
+ if password and not key_filename:
91
+ _check_sshpass_availability()
92
+
93
+ # Build the full command
94
+ full_command = _build_command(command, workdir, pre_cmd)
95
+ logger.debug(f"Full command: {full_command}", module="mtr.ssh")
96
+
97
+ # Build SSH command
98
+ ssh_cmd = ["ssh"]
99
+
100
+ # Add -t flag for TTY mode
101
+ if tty:
102
+ ssh_cmd.append("-t")
103
+
104
+ # Port
105
+ if port != 22:
106
+ ssh_cmd.extend(["-p", str(port)])
107
+
108
+ # Key authentication
109
+ if key_filename:
110
+ ssh_cmd.extend(["-i", os.path.expanduser(key_filename)])
111
+
112
+ # Target host and command
113
+ target = f"{user}@{host}"
114
+ ssh_cmd.extend([target, full_command])
115
+
116
+ # Wrap with sshpass if password is provided
117
+ if password and not key_filename:
118
+ ssh_cmd = ["sshpass", "-p", password] + ssh_cmd
119
+
120
+ logger.debug(f"Executing: {' '.join(ssh_cmd)}", module="mtr.ssh")
121
+
122
+ # Run command
123
+ try:
124
+ result = subprocess.run(ssh_cmd)
125
+ logger.info(f"Command exited with code: {result.returncode}", module="mtr.ssh")
126
+ return result.returncode
127
+ except FileNotFoundError as e:
128
+ logger.error(f"Command not found: {e}", module="mtr.ssh")
129
+ raise SSHError(f"SSH command execution failed: {e}")
130
+ except Exception as e:
131
+ logger.error(f"SSH command failed: {e}", module="mtr.ssh")
132
+ raise SSHError(f"SSH command failed: {e}")