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/__init__.py CHANGED
@@ -0,0 +1,3 @@
1
+ """MTRemote - A CLI tool for seamless local development and remote execution."""
2
+
3
+ __version__ = "2.0.0"
mtr/cli.py CHANGED
@@ -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__":
mtr/config.py CHANGED
@@ -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):
mtr/ssh.py CHANGED
@@ -1,19 +1,17 @@
1
+ """SSH utilities for MTRemote."""
2
+
1
3
  import os
2
4
  import shutil
3
5
  import subprocess
4
- from typing import Generator, Optional
5
-
6
- import paramiko
6
+ from typing import Optional
7
7
 
8
8
  from mtr.logger import get_logger
9
9
 
10
10
 
11
11
  class SSHError(Exception):
12
- pass
12
+ """SSH-related errors."""
13
13
 
14
-
15
- # Constants for batch mode
16
- BUFFER_SIZE = 32768 # 32KB for better performance
14
+ pass
17
15
 
18
16
 
19
17
  def _check_ssh_availability():
@@ -38,162 +36,97 @@ def _check_sshpass_availability():
38
36
  )
39
37
 
40
38
 
41
- class SSHClientWrapper:
42
- def __init__(
43
- self,
44
- host: str,
45
- user: str,
46
- port: int = 22,
47
- key_filename: Optional[str] = None,
48
- password: Optional[str] = None,
49
- ):
50
- self.host = host
51
- self.user = user
52
- self.port = port
53
- self.key_filename = key_filename
54
- self.password = password
55
- self.client: Optional[paramiko.SSHClient] = None
56
-
57
- def connect(self):
58
- logger = get_logger()
59
- logger.info(f"Connecting to {self.host}:{self.port} as {self.user}", module="mtr.ssh")
60
- self.client = paramiko.SSHClient()
61
- self.client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
62
-
63
- connect_kwargs = {
64
- "hostname": self.host,
65
- "username": self.user,
66
- "port": self.port,
67
- "timeout": 10,
68
- }
69
-
70
- if self.key_filename:
71
- expanded_key = os.path.expanduser(self.key_filename)
72
- connect_kwargs["key_filename"] = expanded_key
73
- logger.info("Using key-based authentication", module="mtr.ssh")
74
- elif self.password:
75
- connect_kwargs["password"] = self.password
76
- logger.info("Using password authentication", module="mtr.ssh")
77
- else:
78
- logger.info("No authentication method specified, using default", module="mtr.ssh")
79
-
80
- try:
81
- self.client.connect(**connect_kwargs)
82
- logger.info(f"SSH connection established to {self.host}", module="mtr.ssh")
83
- except paramiko.SSHException as e:
84
- logger.error(f"Failed to connect to {self.host}: {e}", module="mtr.ssh")
85
- raise SSHError(f"Failed to connect to {self.host}: {e}")
86
-
87
- def _build_command(self, command: str, workdir: Optional[str] = None, pre_cmd: Optional[str] = None) -> str:
88
- parts = []
89
- if workdir:
90
- parts.append(f"cd {workdir}")
91
- if pre_cmd:
92
- parts.append(pre_cmd)
93
- parts.append(command)
94
- return " && ".join(parts)
95
-
96
- def exec_command_stream(
97
- self,
98
- command: str,
99
- workdir: Optional[str] = None,
100
- pre_cmd: Optional[str] = None,
101
- pty: bool = True,
102
- ) -> Generator[str, None, int]:
103
- """
104
- Executes command and yields output lines.
105
- Returns the exit code.
106
- Suitable for batch mode or when interactivity is not required.
107
- """
108
- logger = get_logger()
109
- logger.info(f"Executing command (stream mode): {command}", module="mtr.ssh")
110
- logger.debug(f"Workdir: {workdir}, Pre-cmd: {pre_cmd}, PTY: {pty}", module="mtr.ssh")
111
-
112
- if not self.client:
113
- raise SSHError("Client not connected")
114
-
115
- full_command = self._build_command(command, workdir, pre_cmd)
116
- logger.debug(f"Full command: {full_command}", module="mtr.ssh")
117
-
118
- try:
119
- stdin, stdout, stderr = self.client.exec_command(full_command, get_pty=pty)
120
- stdin.close()
121
-
122
- logger.debug("Command executed, starting output stream", module="mtr.ssh")
123
-
124
- line_count = 0
125
- for line in stdout:
126
- line_count += 1
127
- if line_count % 100 == 0:
128
- logger.debug(f"Streamed {line_count} lines so far", module="mtr.ssh")
129
- yield line
130
-
131
- logger.debug(f"Output stream ended, total lines: {line_count}", module="mtr.ssh")
132
-
133
- exit_code = stdout.channel.recv_exit_status()
134
- logger.info(f"Command exited with code: {exit_code}", module="mtr.ssh")
135
- return exit_code
136
-
137
- except paramiko.SSHException as e:
138
- logger.error(f"Command execution failed: {e}", module="mtr.ssh")
139
- raise SSHError(f"Command execution failed: {e}")
140
-
141
- def run_interactive_shell(self, command: str, workdir: Optional[str] = None, pre_cmd: Optional[str] = None) -> int:
142
- """
143
- Runs an interactive shell using system ssh -t command.
144
- This provides full TTY support with proper signal handling.
145
- Returns exit code.
146
- """
147
- logger = get_logger()
148
- logger.info(f"Starting interactive shell via ssh -t: {command}", module="mtr.ssh")
149
- logger.debug(f"Workdir: {workdir}, Pre-cmd: {pre_cmd}", module="mtr.ssh")
150
-
151
- # Check SSH availability
152
- _check_ssh_availability()
153
-
154
- # Check sshpass availability if password is used
155
- if self.password:
156
- _check_sshpass_availability()
157
-
158
- full_command = self._build_command(command, workdir, pre_cmd)
159
-
160
- # Build ssh command
161
- ssh_cmd = ["ssh", "-t"]
162
-
163
- # Port
164
- if self.port != 22:
165
- ssh_cmd.extend(["-p", str(self.port)])
166
-
167
- # Key authentication
168
- if self.key_filename:
169
- ssh_cmd.extend(["-i", os.path.expanduser(self.key_filename)])
170
-
171
- # Target host and command
172
- target = f"{self.user}@{self.host}"
173
- ssh_cmd.extend([target, full_command])
174
-
175
- # Wrap with sshpass if password is provided
176
- if self.password:
177
- ssh_cmd = ["sshpass", "-p", self.password] + ssh_cmd
178
-
179
- logger.debug(f"Executing: {' '.join(ssh_cmd)}", module="mtr.ssh")
180
-
181
- # Run command with direct stdin/stdout/stderr forwarding
182
- try:
183
- result = subprocess.run(ssh_cmd)
184
- logger.info(f"Interactive shell exited with code: {result.returncode}", module="mtr.ssh")
185
- return result.returncode
186
- except FileNotFoundError as e:
187
- # This shouldn't happen if _check_ssh_availability passed, but just in case
188
- logger.error(f"Command not found: {e}", module="mtr.ssh")
189
- raise SSHError(f"SSH command execution failed: {e}")
190
- except Exception as e:
191
- logger.error(f"Interactive shell failed: {e}", module="mtr.ssh")
192
- raise SSHError(f"Interactive shell failed: {e}")
193
-
194
- def close(self):
195
- if self.client:
196
- logger = get_logger()
197
- logger.debug("Closing SSH connection", module="mtr.ssh")
198
- self.client.close()
199
- logger.debug("SSH connection closed", module="mtr.ssh")
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}")