mtr-cli 0.3.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 +3 -0
- mtr/cli.py +123 -127
- mtr/ssh.py +99 -166
- mtr/sync.py +0 -240
- mtr/updater.py +130 -0
- {mtr_cli-0.3.0.dist-info → mtr_cli-2.0.0.dist-info}/METADATA +22 -20
- mtr_cli-2.0.0.dist-info/RECORD +12 -0
- mtr_cli-0.3.0.dist-info/RECORD +0 -11
- {mtr_cli-0.3.0.dist-info → mtr_cli-2.0.0.dist-info}/WHEEL +0 -0
- {mtr_cli-0.3.0.dist-info → mtr_cli-2.0.0.dist-info}/entry_points.txt +0 -0
- {mtr_cli-0.3.0.dist-info → mtr_cli-2.0.0.dist-info}/licenses/LICENSE +0 -0
mtr/__init__.py
CHANGED
mtr/cli.py
CHANGED
|
@@ -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
|
|
10
|
-
from mtr.sync import RsyncSyncer,
|
|
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
|
|
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(
|
|
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
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
|
|
227
|
-
fg="
|
|
228
|
+
"Error: SFTP mode has been removed. Please update your config to use 'sync: rsync'.",
|
|
229
|
+
fg="red",
|
|
230
|
+
err=True,
|
|
228
231
|
)
|
|
229
|
-
|
|
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
|
-
|
|
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
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
|
297
|
-
|
|
298
|
-
|
|
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
|
-
|
|
323
|
-
fg="
|
|
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
|
-
|
|
375
|
-
logger.info(f"
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
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__":
|
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
|
|
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
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
)
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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}")
|
mtr/sync.py
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import fnmatch
|
|
2
1
|
import os
|
|
3
2
|
import shlex
|
|
4
3
|
import shutil
|
|
@@ -6,8 +5,6 @@ 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
|
|
@@ -25,243 +22,6 @@ class BaseSyncer(ABC):
|
|
|
25
22
|
pass
|
|
26
23
|
|
|
27
24
|
|
|
28
|
-
class SftpSyncer(BaseSyncer):
|
|
29
|
-
def __init__(
|
|
30
|
-
self,
|
|
31
|
-
local_dir: str,
|
|
32
|
-
remote_dir: str,
|
|
33
|
-
host: str,
|
|
34
|
-
user: str,
|
|
35
|
-
key_filename: Optional[str] = None,
|
|
36
|
-
password: Optional[str] = None,
|
|
37
|
-
port: int = 22,
|
|
38
|
-
exclude: List[str] = None,
|
|
39
|
-
respect_gitignore: bool = True,
|
|
40
|
-
):
|
|
41
|
-
super().__init__(local_dir, remote_dir, exclude or [], respect_gitignore)
|
|
42
|
-
self.host = host
|
|
43
|
-
self.user = user
|
|
44
|
-
self.key_filename = key_filename
|
|
45
|
-
self.password = password
|
|
46
|
-
self.port = port
|
|
47
|
-
self.transport = None
|
|
48
|
-
self.sftp = None
|
|
49
|
-
|
|
50
|
-
# SFTP mode does not support respect_gitignore
|
|
51
|
-
if respect_gitignore:
|
|
52
|
-
raise SyncError(
|
|
53
|
-
"respect_gitignore is not supported in SFTP mode. "
|
|
54
|
-
"Please set respect_gitignore to false in your config or use rsync mode."
|
|
55
|
-
)
|
|
56
|
-
|
|
57
|
-
def _should_ignore(self, filename: str) -> bool:
|
|
58
|
-
for pattern in self.exclude:
|
|
59
|
-
# Handle directory exclusion (basic)
|
|
60
|
-
if pattern.endswith("/") and filename.startswith(pattern.rstrip("/")):
|
|
61
|
-
return True
|
|
62
|
-
if fnmatch.fnmatch(filename, pattern):
|
|
63
|
-
return True
|
|
64
|
-
return False
|
|
65
|
-
|
|
66
|
-
def _connect(self):
|
|
67
|
-
try:
|
|
68
|
-
self.transport = paramiko.Transport((self.host, self.port))
|
|
69
|
-
connect_kwargs = {"username": self.user}
|
|
70
|
-
|
|
71
|
-
if self.key_filename:
|
|
72
|
-
key_path = os.path.expanduser(self.key_filename)
|
|
73
|
-
# Try different key types? For now assuming RSA or standard loading
|
|
74
|
-
# Or just use connect method of SSHClient? No, Sftp is lower level usually,
|
|
75
|
-
# but we can use SSHClient to get sftp
|
|
76
|
-
|
|
77
|
-
# Simpler approach: Use SSHClientWrapper logic or just Paramiko SSHClient
|
|
78
|
-
client = paramiko.SSHClient()
|
|
79
|
-
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
|
80
|
-
|
|
81
|
-
k_kwargs = {
|
|
82
|
-
"hostname": self.host,
|
|
83
|
-
"username": self.user,
|
|
84
|
-
"port": self.port,
|
|
85
|
-
"key_filename": key_path,
|
|
86
|
-
}
|
|
87
|
-
if self.password:
|
|
88
|
-
k_kwargs["password"] = self.password
|
|
89
|
-
|
|
90
|
-
client.connect(**k_kwargs)
|
|
91
|
-
self.sftp = client.open_sftp()
|
|
92
|
-
return
|
|
93
|
-
|
|
94
|
-
if self.password:
|
|
95
|
-
connect_kwargs["password"] = self.password
|
|
96
|
-
self.transport.connect(**connect_kwargs)
|
|
97
|
-
self.sftp = paramiko.SFTPClient.from_transport(self.transport)
|
|
98
|
-
else:
|
|
99
|
-
raise SyncError("No auth method provided (need key or password)")
|
|
100
|
-
|
|
101
|
-
except Exception as e:
|
|
102
|
-
raise SyncError(f"SFTP Connection failed: {e}")
|
|
103
|
-
|
|
104
|
-
def _ensure_remote_dir(self, remote_path: str):
|
|
105
|
-
"""Recursively create remote directory."""
|
|
106
|
-
# This is a bit expensive, optimization: assume parent exists or try/except
|
|
107
|
-
# Simple implementation:
|
|
108
|
-
dirs = remote_path.split("/")
|
|
109
|
-
current = ""
|
|
110
|
-
for d in dirs:
|
|
111
|
-
if not d:
|
|
112
|
-
continue
|
|
113
|
-
current += f"/{d}"
|
|
114
|
-
try:
|
|
115
|
-
self.sftp.stat(current)
|
|
116
|
-
except FileNotFoundError:
|
|
117
|
-
try:
|
|
118
|
-
self.sftp.mkdir(current)
|
|
119
|
-
except OSError:
|
|
120
|
-
pass # Already exists maybe
|
|
121
|
-
|
|
122
|
-
def sync(self, show_progress: bool = False, progress_callback=None):
|
|
123
|
-
if not self.sftp:
|
|
124
|
-
self._connect()
|
|
125
|
-
|
|
126
|
-
# Ensure base remote dir exists
|
|
127
|
-
try:
|
|
128
|
-
self.sftp.stat(self.remote_dir)
|
|
129
|
-
except FileNotFoundError:
|
|
130
|
-
self._ensure_remote_dir(self.remote_dir)
|
|
131
|
-
|
|
132
|
-
# Walk local tree
|
|
133
|
-
for root, dirs, files in os.walk(self.local_dir):
|
|
134
|
-
# Filtering dirs in place to prevent recursion
|
|
135
|
-
dirs[:] = [d for d in dirs if not self._should_ignore(d)]
|
|
136
|
-
|
|
137
|
-
rel_path = os.path.relpath(root, self.local_dir)
|
|
138
|
-
if rel_path == ".":
|
|
139
|
-
remote_root = self.remote_dir
|
|
140
|
-
else:
|
|
141
|
-
remote_root = os.path.join(self.remote_dir, rel_path)
|
|
142
|
-
|
|
143
|
-
# Check/Create remote dir
|
|
144
|
-
try:
|
|
145
|
-
self.sftp.stat(remote_root)
|
|
146
|
-
except FileNotFoundError:
|
|
147
|
-
self.sftp.mkdir(remote_root)
|
|
148
|
-
|
|
149
|
-
for file in files:
|
|
150
|
-
if self._should_ignore(file):
|
|
151
|
-
continue
|
|
152
|
-
|
|
153
|
-
local_file = os.path.join(root, file)
|
|
154
|
-
remote_file = os.path.join(remote_root, file)
|
|
155
|
-
|
|
156
|
-
# Check sync necessity (Size & Mtime)
|
|
157
|
-
should_upload = True
|
|
158
|
-
try:
|
|
159
|
-
remote_stat = self.sftp.stat(remote_file)
|
|
160
|
-
local_stat = os.stat(local_file)
|
|
161
|
-
|
|
162
|
-
if remote_stat.st_size == local_stat.st_size and int(remote_stat.st_mtime) >= int(local_stat.st_mtime):
|
|
163
|
-
should_upload = False
|
|
164
|
-
except FileNotFoundError:
|
|
165
|
-
pass # Does not exist, must upload
|
|
166
|
-
|
|
167
|
-
if should_upload:
|
|
168
|
-
# Call progress callback if provided
|
|
169
|
-
if show_progress and progress_callback:
|
|
170
|
-
progress_callback(local_file)
|
|
171
|
-
self.sftp.put(local_file, remote_file)
|
|
172
|
-
# Preserve permissions
|
|
173
|
-
mode = os.stat(local_file).st_mode
|
|
174
|
-
self.sftp.chmod(remote_file, mode)
|
|
175
|
-
|
|
176
|
-
if self.sftp:
|
|
177
|
-
self.sftp.close()
|
|
178
|
-
if self.transport:
|
|
179
|
-
self.transport.close()
|
|
180
|
-
|
|
181
|
-
def download(self, remote_path: str, local_path: str, show_progress: bool = False, progress_callback=None):
|
|
182
|
-
"""Download file or directory from remote to local."""
|
|
183
|
-
if not self.sftp:
|
|
184
|
-
self._connect()
|
|
185
|
-
|
|
186
|
-
try:
|
|
187
|
-
# Ensure local directory exists
|
|
188
|
-
local_dir = os.path.dirname(local_path)
|
|
189
|
-
if local_dir and not os.path.exists(local_dir):
|
|
190
|
-
os.makedirs(local_dir, exist_ok=True)
|
|
191
|
-
|
|
192
|
-
try:
|
|
193
|
-
import stat
|
|
194
|
-
|
|
195
|
-
remote_stat = self.sftp.stat(remote_path)
|
|
196
|
-
is_dir = stat.S_ISDIR(remote_stat.st_mode)
|
|
197
|
-
|
|
198
|
-
if is_dir:
|
|
199
|
-
self._download_dir(
|
|
200
|
-
remote_path, local_path, show_progress=show_progress, progress_callback=progress_callback
|
|
201
|
-
)
|
|
202
|
-
else:
|
|
203
|
-
self._download_file(
|
|
204
|
-
remote_path, local_path, show_progress=show_progress, progress_callback=progress_callback
|
|
205
|
-
)
|
|
206
|
-
except FileNotFoundError:
|
|
207
|
-
raise SyncError(f"Remote path not found: {remote_path}")
|
|
208
|
-
except Exception as e:
|
|
209
|
-
raise SyncError(f"Download failed: {e}")
|
|
210
|
-
finally:
|
|
211
|
-
if self.sftp:
|
|
212
|
-
self.sftp.close()
|
|
213
|
-
if self.transport:
|
|
214
|
-
self.transport.close()
|
|
215
|
-
|
|
216
|
-
def _should_download_file(self, remote_file: str, local_file: str) -> bool:
|
|
217
|
-
"""Check if file should be downloaded based on size and mtime."""
|
|
218
|
-
try:
|
|
219
|
-
remote_stat = self.sftp.stat(remote_file)
|
|
220
|
-
local_stat = os.stat(local_file)
|
|
221
|
-
|
|
222
|
-
# Size different, need download
|
|
223
|
-
if remote_stat.st_size != local_stat.st_size:
|
|
224
|
-
return True
|
|
225
|
-
|
|
226
|
-
# Remote file is newer than local file
|
|
227
|
-
return int(remote_stat.st_mtime) > int(local_stat.st_mtime)
|
|
228
|
-
except FileNotFoundError:
|
|
229
|
-
return True # Local file doesn't exist, must download
|
|
230
|
-
|
|
231
|
-
def _download_file(self, remote_file: str, local_file: str, show_progress: bool = False, progress_callback=None):
|
|
232
|
-
"""Download a single file with incremental check."""
|
|
233
|
-
if not self._should_download_file(remote_file, local_file):
|
|
234
|
-
return # No need to download
|
|
235
|
-
|
|
236
|
-
# Call progress callback if provided
|
|
237
|
-
if show_progress and progress_callback:
|
|
238
|
-
progress_callback(remote_file)
|
|
239
|
-
|
|
240
|
-
self.sftp.get(remote_file, local_file)
|
|
241
|
-
# Preserve permissions
|
|
242
|
-
remote_stat = self.sftp.stat(remote_file)
|
|
243
|
-
os.chmod(local_file, remote_stat.st_mode)
|
|
244
|
-
|
|
245
|
-
def _download_dir(self, remote_dir: str, local_dir: str, show_progress: bool = False, progress_callback=None):
|
|
246
|
-
"""Recursively download a directory."""
|
|
247
|
-
if not os.path.exists(local_dir):
|
|
248
|
-
os.makedirs(local_dir, exist_ok=True)
|
|
249
|
-
|
|
250
|
-
for entry in self.sftp.listdir_attr(remote_dir):
|
|
251
|
-
remote_path = f"{remote_dir}/{entry.filename}"
|
|
252
|
-
local_path = os.path.join(local_dir, entry.filename)
|
|
253
|
-
|
|
254
|
-
if self._should_ignore(entry.filename):
|
|
255
|
-
continue
|
|
256
|
-
|
|
257
|
-
import stat
|
|
258
|
-
|
|
259
|
-
if stat.S_ISDIR(entry.st_mode):
|
|
260
|
-
self._download_dir(remote_path, local_path, show_progress=show_progress, progress_callback=progress_callback)
|
|
261
|
-
else:
|
|
262
|
-
self._download_file(remote_path, local_path, show_progress=show_progress, progress_callback=progress_callback)
|
|
263
|
-
|
|
264
|
-
|
|
265
25
|
class RsyncSyncer(BaseSyncer):
|
|
266
26
|
def __init__(
|
|
267
27
|
self,
|
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.
|
|
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:
|
|
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
|
|
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"
|
|
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.
|
|
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
|
-
###
|
|
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
|
-
###
|
|
246
|
+
### 4. 调试日志 (--enable-log)
|
|
245
247
|
当遇到问题需要排查时,可以启用文件日志:
|
|
246
248
|
|
|
247
249
|
```bash
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
mtr/__init__.py,sha256=8cyDYQIUDSlgJOLnC8PLiPDXLQa-H8yH9pp8WAgZ_-0,104
|
|
2
|
+
mtr/cli.py,sha256=KxrWYOe360nhvFz05Ffk5kP6Qn72B5TU-uehnaimLAQ,15480
|
|
3
|
+
mtr/config.py,sha256=HA_pZ3rTFPYa6JhgFnEQlf4ukdFovx8obv58UILloWQ,3568
|
|
4
|
+
mtr/logger.py,sha256=9DPKTTzYsNMF7vXnvJ0bJditNYgzhZWHwLKUErtwCBY,3669
|
|
5
|
+
mtr/ssh.py,sha256=z2MZf5sI2TIve62aUOOGHnYMC_zU6p8SXwyoLiCF1Sg,3962
|
|
6
|
+
mtr/sync.py,sha256=UFpqW_QIdmYVfWtflQsADkvoizFhfdG_owJCAvYIwmE,9529
|
|
7
|
+
mtr/updater.py,sha256=2R6d2ohsTDXY1Oor0NZy4wRfF-Iy6Un9g_2hkqbUlmA,4263
|
|
8
|
+
mtr_cli-2.0.0.dist-info/METADATA,sha256=jz604P5fjikJAo69oIFYB7yRGtcKtmwoz-EZ0Kg_PHw,9860
|
|
9
|
+
mtr_cli-2.0.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
10
|
+
mtr_cli-2.0.0.dist-info/entry_points.txt,sha256=8BRK0VoSAWGzovrOdzWqpwwNj-dmjVY1iQcz5MQseV4,36
|
|
11
|
+
mtr_cli-2.0.0.dist-info/licenses/LICENSE,sha256=PkuO1VHNDkFylFSOtMADb93mjExarK6DBTjtCB3kBeU,1067
|
|
12
|
+
mtr_cli-2.0.0.dist-info/RECORD,,
|
mtr_cli-0.3.0.dist-info/RECORD
DELETED
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
mtr/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
-
mtr/cli.py,sha256=LXoXCdajKQe_oVXlf9FPGgHt7N3P0zQxE-mh9xPwnII,16363
|
|
3
|
-
mtr/config.py,sha256=HA_pZ3rTFPYa6JhgFnEQlf4ukdFovx8obv58UILloWQ,3568
|
|
4
|
-
mtr/logger.py,sha256=9DPKTTzYsNMF7vXnvJ0bJditNYgzhZWHwLKUErtwCBY,3669
|
|
5
|
-
mtr/ssh.py,sha256=fCEXxfEK6ao8YrCU9FBJ_e5zrK93AKz6bgjXWkORQkk,7170
|
|
6
|
-
mtr/sync.py,sha256=xrljWPt8keigyiri7oc1RNvxJxBNBHjJjhgGtXP0F_g,18668
|
|
7
|
-
mtr_cli-0.3.0.dist-info/METADATA,sha256=XFZfTrcxMzhCbvzwY6LQrJWq_r2lnboCqL8SbDSuYnQ,9806
|
|
8
|
-
mtr_cli-0.3.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
9
|
-
mtr_cli-0.3.0.dist-info/entry_points.txt,sha256=8BRK0VoSAWGzovrOdzWqpwwNj-dmjVY1iQcz5MQseV4,36
|
|
10
|
-
mtr_cli-0.3.0.dist-info/licenses/LICENSE,sha256=PkuO1VHNDkFylFSOtMADb93mjExarK6DBTjtCB3kBeU,1067
|
|
11
|
-
mtr_cli-0.3.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|