rrun-cli 0.1.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.
rrun_cli-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 waqiju
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,134 @@
1
+ Metadata-Version: 2.4
2
+ Name: rrun-cli
3
+ Version: 0.1.0
4
+ Summary: Run local scripts on remote machines over SSH via stdin pipes — no escaping/encoding hell. Supports python/powershell/bash, with a unified remote Python 3.12 venv provisioner.
5
+ Author: waqiju
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/waqiju/rrun
8
+ Project-URL: Repository, https://github.com/waqiju/rrun
9
+ Project-URL: Issues, https://github.com/waqiju/rrun/issues
10
+ Keywords: ssh,remote-exec,powershell,windows,ops,automation
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Intended Audience :: System Administrators
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Operating System :: POSIX :: Linux
17
+ Classifier: Operating System :: MacOS
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Topic :: System :: Systems Administration
20
+ Requires-Python: >=3.10
21
+ Description-Content-Type: text/markdown
22
+ License-File: LICENSE
23
+ Dynamic: license-file
24
+
25
+ # rrun
26
+
27
+ Run local scripts on remote machines over SSH — **via stdin pipes, never via command-line arguments** — so quoting, escaping, and CJK/UTF-8 encoding survive the `bash → ssh → cmd/powershell` journey intact.
28
+
29
+ ```bash
30
+ rrun exec my-win-box ./deploy.ps1 # powershell (inferred from .ps1)
31
+ rrun exec my-mac ./build.sh # bash
32
+ rrun exec my-win-box ./report.py # python (remote 3.12, -X utf8)
33
+ rrun exec my-win-box -c "Get-Date" # inline content (archived to ~/.rrun/drops/)
34
+ rrun exec my-mac ./etl.py -- arg1 arg2 # ASCII args pass-through
35
+ ```
36
+
37
+ Remote stdout → your stdout, remote stderr → your stderr, and the **exit code is passed through** unchanged (255 = ssh transport failure, 124 = local `--timeout`). Pipes, `&&`, and CI integration just work.
38
+
39
+ ## Why
40
+
41
+ Running commands on remote Windows machines from a POSIX shell is a minefield: sshd lands you in `cmd` with a GBK codepage, quotes and `$` get eaten by one of three shells along the way, and any non-ASCII argument gets mojibake'd. rrun's rules:
42
+
43
+ - Script content (UTF-8, Chinese welcome) is always piped through **stdin** — never the command line.
44
+ - For PowerShell, the payload is base64-wrapped into a **single-line pure-ASCII wrapper** (`powershell -Command -` executes stdin line-by-line, multi-line fails silently), decoded and invoked as a ScriptBlock remotely.
45
+ - Command-line arguments are restricted to ASCII and passed through safely (`sys.argv` / `$@` / `$args`). Put anything fancier in the script or a JSON file.
46
+
47
+ See [docs/remote-exec-conventions.md](docs/remote-exec-conventions.md) (中文) for the full set of hard-won conventions.
48
+
49
+ ## Install
50
+
51
+ ```bash
52
+ pipx install rrun-cli # recommended: isolated global CLI (provides the `rrun` command)
53
+ # or: pip install rrun-cli
54
+ ```
55
+
56
+ > The PyPI distribution is named `rrun-cli` (plain `rrun` is on PyPI's prohibited-name list
57
+ > as it's confusable with `run`), but the installed command is `rrun` — plus the legacy alias
58
+ > `remote-machine`.
59
+
60
+ Both `rrun` and the legacy alias `remote-machine` are installed.
61
+
62
+ **Control machine requirements:** `ssh` + `sshpass` (`sudo apt install sshpass`), Python ≥ 3.10. Zero third-party Python dependencies.
63
+ **Remote machines:** OpenSSH server. Windows remotes execute via PowerShell; Mac/Linux via bash.
64
+
65
+ ## Configuration: machines.json
66
+
67
+ Credentials live in a local `machines.json` (never committed — it's a local file, `chmod 600` recommended). See [machines.template.json](machines.template.json):
68
+
69
+ ```json
70
+ {
71
+ "defaults": { "windows": { "os": "Windows" } },
72
+ "machines": [
73
+ { "name": "my-win-box", "ip": "192.168.1.10", "os": "Windows",
74
+ "user": "admin", "password": "secret" }
75
+ ]
76
+ }
77
+ ```
78
+
79
+ Sources are merged by machine name, highest priority first (all optional, failures skipped silently):
80
+
81
+ 1. `$RRUN_CONFIG` (os.pathsep-separated, multiple files allowed)
82
+ 2. `$REMOTE_MACHINE_CONFIG` (legacy name, still honored)
83
+ 3. `./machines.json` (current working directory)
84
+ 4. `~/.rrun/machines.json`
85
+ 5. `~/.rrun/machines.d/*.json` (sorted by filename — point each inventory at its own file/symlink)
86
+ 6. Legacy: `~/.remote-machine/machines.json` (POSIX) / `C:\tools\remote-machine\machines.json` (Windows)
87
+
88
+ Per-file `defaults` sections (keyed by `os`, lowercased) are applied before merging. Inspect the chain with `rrun config`; list machines (redacted) with `rrun machines`.
89
+
90
+ ## Subcommands
91
+
92
+ | Command | Purpose |
93
+ |---|---|
94
+ | `rrun exec <host> <script\|-c ...>` | Execute a local script / inline content remotely |
95
+ | `rrun machines [--json]` | List merged machines (redacted, with source) |
96
+ | `rrun config` | Diagnose the machines.json source chain |
97
+ | `rrun setup <host\|--all> [--force]` | Provision the unified remote Python 3.12 venv (idempotent) |
98
+ | `rrun pip <host> -- list` | Run pip inside the remote unified venv |
99
+ | `rrun close [<host>\|--all]` | Close ssh ControlMaster multiplexed connections |
100
+
101
+ Useful `exec` flags: `--lang bash|powershell|python`, `--workdir`, `--env K=V`, `--timeout`, `--python <path>` (skip detection), `--no-mux`, `-q`.
102
+
103
+ ## Remote Python: unified 3.12 environment
104
+
105
+ For `python` scripts, rrun requires a **3.12.x** interpreter on the remote, probed in order:
106
+
107
+ 1. Unified venv: `C:\tools\remote-machine\venv\Scripts\python.exe` / `~/.remote-machine/venv/bin/python`
108
+ 2. Standalone base: `...\python312\python.exe` / `~/.remote-machine/python312/bin/python3`
109
+ 3. Existing installs: `C:\Python\Python312\python.exe` / `python3.12` on PATH
110
+
111
+ If none match, run `rrun setup <host>`. It is idempotent and non-destructive: if the machine lacks Python 3.12, a [python-build-standalone](https://github.com/astral-sh/python-build-standalone) tarball is downloaded to the local cache (`~/.cache/rrun/`) and streamed over ssh stdin — no registry, no PATH changes, no admin rights, and the remote never touches GitHub. The venv gets an Alibaba Cloud pip mirror in its own `pip.ini`/`pip.conf` (global config untouched) plus the standard packages from `remote-requirements.txt` (override with `~/.rrun/remote-requirements.txt`).
112
+
113
+ ## Auditing & state
114
+
115
+ - Every execution appends a JSON line to `~/.rrun/log/remote-exec.jsonl` (host, lang, script sha1, exit code, duration — never passwords; env vars recorded as keys only).
116
+ - Inline `-c` payloads are archived under `~/.rrun/drops/` for replay.
117
+ - Root directory overridable via `$RRUN_HOME`.
118
+
119
+ ---
120
+
121
+ ## 中文简介
122
+
123
+ rrun 解决「从 POSIX shell 在远程机器(尤其 Windows)执行脚本」的转码/转义地狱:
124
+ 脚本内容一律走 **stdin 管道**(UTF-8,中文随便用),命令行参数只放行 ASCII;
125
+ PowerShell 载荷编码为单行纯 ASCII wrapper,退出码原样透传。
126
+
127
+ - 安装:`pipx install rrun-cli`(控制端需 `ssh` + `sshpass`;装好后命令是 `rrun`)
128
+ - 机器清单 `machines.json` 来源链:`$RRUN_CONFIG` → `./machines.json` → `~/.rrun/machines.json` → `~/.rrun/machines.d/*.json` → 旧位置兼容
129
+ - 远程 python 基线锁 3.12,`rrun setup <host>` 一键幂等初始化统一 venv(standalone 基座经 ssh 推流安装,远端无需访问 GitHub,pip 走阿里云镜像)
130
+ - 更多踩坑约定见 [docs/remote-exec-conventions.md](docs/remote-exec-conventions.md)
131
+
132
+ ## License
133
+
134
+ MIT
@@ -0,0 +1,110 @@
1
+ # rrun
2
+
3
+ Run local scripts on remote machines over SSH — **via stdin pipes, never via command-line arguments** — so quoting, escaping, and CJK/UTF-8 encoding survive the `bash → ssh → cmd/powershell` journey intact.
4
+
5
+ ```bash
6
+ rrun exec my-win-box ./deploy.ps1 # powershell (inferred from .ps1)
7
+ rrun exec my-mac ./build.sh # bash
8
+ rrun exec my-win-box ./report.py # python (remote 3.12, -X utf8)
9
+ rrun exec my-win-box -c "Get-Date" # inline content (archived to ~/.rrun/drops/)
10
+ rrun exec my-mac ./etl.py -- arg1 arg2 # ASCII args pass-through
11
+ ```
12
+
13
+ Remote stdout → your stdout, remote stderr → your stderr, and the **exit code is passed through** unchanged (255 = ssh transport failure, 124 = local `--timeout`). Pipes, `&&`, and CI integration just work.
14
+
15
+ ## Why
16
+
17
+ Running commands on remote Windows machines from a POSIX shell is a minefield: sshd lands you in `cmd` with a GBK codepage, quotes and `$` get eaten by one of three shells along the way, and any non-ASCII argument gets mojibake'd. rrun's rules:
18
+
19
+ - Script content (UTF-8, Chinese welcome) is always piped through **stdin** — never the command line.
20
+ - For PowerShell, the payload is base64-wrapped into a **single-line pure-ASCII wrapper** (`powershell -Command -` executes stdin line-by-line, multi-line fails silently), decoded and invoked as a ScriptBlock remotely.
21
+ - Command-line arguments are restricted to ASCII and passed through safely (`sys.argv` / `$@` / `$args`). Put anything fancier in the script or a JSON file.
22
+
23
+ See [docs/remote-exec-conventions.md](docs/remote-exec-conventions.md) (中文) for the full set of hard-won conventions.
24
+
25
+ ## Install
26
+
27
+ ```bash
28
+ pipx install rrun-cli # recommended: isolated global CLI (provides the `rrun` command)
29
+ # or: pip install rrun-cli
30
+ ```
31
+
32
+ > The PyPI distribution is named `rrun-cli` (plain `rrun` is on PyPI's prohibited-name list
33
+ > as it's confusable with `run`), but the installed command is `rrun` — plus the legacy alias
34
+ > `remote-machine`.
35
+
36
+ Both `rrun` and the legacy alias `remote-machine` are installed.
37
+
38
+ **Control machine requirements:** `ssh` + `sshpass` (`sudo apt install sshpass`), Python ≥ 3.10. Zero third-party Python dependencies.
39
+ **Remote machines:** OpenSSH server. Windows remotes execute via PowerShell; Mac/Linux via bash.
40
+
41
+ ## Configuration: machines.json
42
+
43
+ Credentials live in a local `machines.json` (never committed — it's a local file, `chmod 600` recommended). See [machines.template.json](machines.template.json):
44
+
45
+ ```json
46
+ {
47
+ "defaults": { "windows": { "os": "Windows" } },
48
+ "machines": [
49
+ { "name": "my-win-box", "ip": "192.168.1.10", "os": "Windows",
50
+ "user": "admin", "password": "secret" }
51
+ ]
52
+ }
53
+ ```
54
+
55
+ Sources are merged by machine name, highest priority first (all optional, failures skipped silently):
56
+
57
+ 1. `$RRUN_CONFIG` (os.pathsep-separated, multiple files allowed)
58
+ 2. `$REMOTE_MACHINE_CONFIG` (legacy name, still honored)
59
+ 3. `./machines.json` (current working directory)
60
+ 4. `~/.rrun/machines.json`
61
+ 5. `~/.rrun/machines.d/*.json` (sorted by filename — point each inventory at its own file/symlink)
62
+ 6. Legacy: `~/.remote-machine/machines.json` (POSIX) / `C:\tools\remote-machine\machines.json` (Windows)
63
+
64
+ Per-file `defaults` sections (keyed by `os`, lowercased) are applied before merging. Inspect the chain with `rrun config`; list machines (redacted) with `rrun machines`.
65
+
66
+ ## Subcommands
67
+
68
+ | Command | Purpose |
69
+ |---|---|
70
+ | `rrun exec <host> <script\|-c ...>` | Execute a local script / inline content remotely |
71
+ | `rrun machines [--json]` | List merged machines (redacted, with source) |
72
+ | `rrun config` | Diagnose the machines.json source chain |
73
+ | `rrun setup <host\|--all> [--force]` | Provision the unified remote Python 3.12 venv (idempotent) |
74
+ | `rrun pip <host> -- list` | Run pip inside the remote unified venv |
75
+ | `rrun close [<host>\|--all]` | Close ssh ControlMaster multiplexed connections |
76
+
77
+ Useful `exec` flags: `--lang bash|powershell|python`, `--workdir`, `--env K=V`, `--timeout`, `--python <path>` (skip detection), `--no-mux`, `-q`.
78
+
79
+ ## Remote Python: unified 3.12 environment
80
+
81
+ For `python` scripts, rrun requires a **3.12.x** interpreter on the remote, probed in order:
82
+
83
+ 1. Unified venv: `C:\tools\remote-machine\venv\Scripts\python.exe` / `~/.remote-machine/venv/bin/python`
84
+ 2. Standalone base: `...\python312\python.exe` / `~/.remote-machine/python312/bin/python3`
85
+ 3. Existing installs: `C:\Python\Python312\python.exe` / `python3.12` on PATH
86
+
87
+ If none match, run `rrun setup <host>`. It is idempotent and non-destructive: if the machine lacks Python 3.12, a [python-build-standalone](https://github.com/astral-sh/python-build-standalone) tarball is downloaded to the local cache (`~/.cache/rrun/`) and streamed over ssh stdin — no registry, no PATH changes, no admin rights, and the remote never touches GitHub. The venv gets an Alibaba Cloud pip mirror in its own `pip.ini`/`pip.conf` (global config untouched) plus the standard packages from `remote-requirements.txt` (override with `~/.rrun/remote-requirements.txt`).
88
+
89
+ ## Auditing & state
90
+
91
+ - Every execution appends a JSON line to `~/.rrun/log/remote-exec.jsonl` (host, lang, script sha1, exit code, duration — never passwords; env vars recorded as keys only).
92
+ - Inline `-c` payloads are archived under `~/.rrun/drops/` for replay.
93
+ - Root directory overridable via `$RRUN_HOME`.
94
+
95
+ ---
96
+
97
+ ## 中文简介
98
+
99
+ rrun 解决「从 POSIX shell 在远程机器(尤其 Windows)执行脚本」的转码/转义地狱:
100
+ 脚本内容一律走 **stdin 管道**(UTF-8,中文随便用),命令行参数只放行 ASCII;
101
+ PowerShell 载荷编码为单行纯 ASCII wrapper,退出码原样透传。
102
+
103
+ - 安装:`pipx install rrun-cli`(控制端需 `ssh` + `sshpass`;装好后命令是 `rrun`)
104
+ - 机器清单 `machines.json` 来源链:`$RRUN_CONFIG` → `./machines.json` → `~/.rrun/machines.json` → `~/.rrun/machines.d/*.json` → 旧位置兼容
105
+ - 远程 python 基线锁 3.12,`rrun setup <host>` 一键幂等初始化统一 venv(standalone 基座经 ssh 推流安装,远端无需访问 GitHub,pip 走阿里云镜像)
106
+ - 更多踩坑约定见 [docs/remote-exec-conventions.md](docs/remote-exec-conventions.md)
107
+
108
+ ## License
109
+
110
+ MIT
@@ -0,0 +1,44 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "rrun-cli"
7
+ version = "0.1.0"
8
+ description = "Run local scripts on remote machines over SSH via stdin pipes — no escaping/encoding hell. Supports python/powershell/bash, with a unified remote Python 3.12 venv provisioner."
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ requires-python = ">=3.10"
12
+ authors = [{ name = "waqiju" }]
13
+ keywords = ["ssh", "remote-exec", "powershell", "windows", "ops", "automation"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Environment :: Console",
17
+ "Intended Audience :: Developers",
18
+ "Intended Audience :: System Administrators",
19
+ "License :: OSI Approved :: MIT License",
20
+ "Operating System :: POSIX :: Linux",
21
+ "Operating System :: MacOS",
22
+ "Programming Language :: Python :: 3",
23
+ "Topic :: System :: Systems Administration",
24
+ ]
25
+ # 纯标准库实现,零第三方依赖。控制端仅需本机有 ssh + sshpass。
26
+ dependencies = []
27
+
28
+ [project.urls]
29
+ Homepage = "https://github.com/waqiju/rrun"
30
+ Repository = "https://github.com/waqiju/rrun"
31
+ Issues = "https://github.com/waqiju/rrun/issues"
32
+
33
+ [project.scripts]
34
+ rrun = "rrun.__main__:main"
35
+ remote-machine = "rrun.__main__:main" # 旧名别名,兼容期保留
36
+
37
+ [tool.setuptools]
38
+ package-dir = { "" = "src" }
39
+
40
+ [tool.setuptools.packages.find]
41
+ where = ["src"]
42
+
43
+ [tool.setuptools.package-data]
44
+ rrun = ["remote-requirements.txt"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,36 @@
1
+ # -*- coding: utf-8 -*-
2
+ """rrun — 远程机器执行模块(Run Remote)。
3
+
4
+ 约定:read/write/edit 等文件操作始终发生在本地;任何远程执行都是
5
+ 「本地写脚本 → stdin 管道送远端解释器执行 → 收回 stdout/stderr/退出码」。
6
+
7
+ 支持语言:bash(Mac/Linux)、powershell(Windows)、python(跨平台)。
8
+ """
9
+
10
+ try:
11
+ from importlib.metadata import version as _pkg_version
12
+
13
+ __version__ = _pkg_version("rrun-cli")
14
+ except Exception: # noqa: BLE001 - 未安装(源码直跑)时回退
15
+ __version__ = "0.1.0"
16
+
17
+ from .executor import (
18
+ AUDIT_LOG,
19
+ EXIT_TIMEOUT,
20
+ EXIT_TRANSPORT_ERROR,
21
+ RRUN_HOME,
22
+ ExecResult,
23
+ build_ps_wrapper,
24
+ close_mux,
25
+ detect_remote_python,
26
+ run,
27
+ )
28
+ from .registry import Machine, SourceInfo, candidate_sources, load_machines, resolve_machine, scan_sources
29
+ from .setup import SetupResult, load_requirements, setup_machine
30
+
31
+ __all__ = [
32
+ "AUDIT_LOG", "EXIT_TIMEOUT", "EXIT_TRANSPORT_ERROR", "RRUN_HOME",
33
+ "ExecResult", "Machine", "SetupResult", "SourceInfo",
34
+ "build_ps_wrapper", "candidate_sources", "close_mux", "detect_remote_python",
35
+ "load_machines", "load_requirements", "resolve_machine", "run", "scan_sources", "setup_machine",
36
+ ]
@@ -0,0 +1,301 @@
1
+ # -*- coding: utf-8 -*-
2
+ """rrun — 远程机器执行器(Run Remote)。
3
+
4
+ 核心约定:脚本内容(可含中文,UTF-8)经 **stdin 管道**送远端解释器执行,
5
+ 全程不走命令行参数,规避 bash->ssh->cmd 多层转码/转义问题。
6
+
7
+ 用法:
8
+ rrun exec <host> <script.py|.ps1|.sh> [ascii_args...]
9
+ rrun exec <host> -c "Write-Output 中文" # lang 按 OS 推断
10
+ rrun exec pc_build temp/x.py --timeout 60
11
+ rrun exec mac_mini --lang python temp/x.py
12
+ rrun setup <host|--all> [--force] # 初始化统一 python 环境(3.12 venv + 阿里源)
13
+ rrun pip <host> -- list # 在统一 venv 中执行 pip
14
+ rrun machines # 列出可用机器(脱敏,含来源)
15
+ rrun config # 查看 machines.json 来源链解析
16
+ rrun close [<host>|--all] # 关闭 ssh 复用连接
17
+
18
+ 约定:
19
+ - lang 推断:文件扩展名(.py/.ps1/.sh)优先,否则按机器 OS(Windows=powershell,其他=bash)。
20
+ - 命令行参数只允许 ASCII,透传远端(python=sys.argv;bash=$@;powershell=$args);
21
+ 中文/特殊字符一律写进脚本内容或 JSON 文件。
22
+ - python 基线锁 3.12:探测命中统一 venv(优先)/standalone 基座/存量 3.12,
23
+ 并校验版本号;全灭则报错提示先跑 setup(exec 热路径不做隐式安装)。
24
+ - 退出码:远端脚本退出码原样透传;255=ssh 传输层错误;124=本地超时。
25
+ - 内联 -c 内容自动落盘 ~/.rrun/drops/ 留档,可复跑(RRUN_HOME 可改根目录)。
26
+ - 审计:每次执行追加 ~/.rrun/log/remote-exec.jsonl。
27
+
28
+ 退出码即远端退出码,可直接管道使用:远端 stdout→本机 stdout,stderr→stderr。
29
+ """
30
+
31
+ import argparse
32
+ import json
33
+ import sys
34
+ import time
35
+ from pathlib import Path
36
+
37
+ from .executor import RRUN_HOME, _check_ascii, _ssh_run, close_mux, run
38
+ from .registry import load_machines, resolve_machine, scan_sources
39
+ from .setup import setup_machine, venv_python_or_die
40
+
41
+ INLINE_DROP_DIR = RRUN_HOME / "drops"
42
+
43
+
44
+ def _parse_env(pairs) -> dict:
45
+ env = {}
46
+ for p in pairs or []:
47
+ if "=" not in p:
48
+ raise SystemExit(f"[remote-exec] --env 格式应为 KEY=VAL: {p!r}")
49
+ k, v = p.split("=", 1)
50
+ env[k] = v
51
+ return env
52
+
53
+
54
+ def _drop_inline(host: str, lang: str, content: str):
55
+ """内联内容落盘 ~/.rrun/drops/ 留档(可复跑/可 edit 后重跑)。"""
56
+ ext = {"python": ".py", "powershell": ".ps1", "bash": ".sh"}.get(lang, ".txt")
57
+ INLINE_DROP_DIR.mkdir(parents=True, exist_ok=True)
58
+ path = INLINE_DROP_DIR / f"{time.strftime('%Y%m%d-%H%M%S')}_{host}{ext}"
59
+ path.write_text(content, encoding="utf-8")
60
+ return path
61
+
62
+
63
+ def cmd_exec(ns) -> int:
64
+ try:
65
+ machine = resolve_machine(ns.host)
66
+ except KeyError as e:
67
+ raise SystemExit(f"[remote-exec] {e}")
68
+
69
+ content = ns.content
70
+ file = ns.script or ""
71
+ lang = ns.lang or ""
72
+ if content and file:
73
+ raise SystemExit("[remote-exec] 脚本文件与 -c/--content 只能二选一")
74
+ if not content and not file:
75
+ raise SystemExit("[remote-exec] 缺少脚本:给文件路径或 -c/--content")
76
+
77
+ # content 模式先确定 lang 再落盘(扩展名需要 lang)
78
+ script_for_log = file
79
+ if content:
80
+ if not lang:
81
+ lang = machine.default_lang
82
+ dropped = _drop_inline(machine.name, lang, content)
83
+ script_for_log = str(dropped)
84
+ print(f"[remote-exec] 内联内容已落盘: {dropped}", file=sys.stderr)
85
+
86
+ args = ns.args
87
+
88
+ try:
89
+ result = run(
90
+ ns.host, lang=lang, file=file, content=content, args=args,
91
+ workdir=ns.workdir or "", env=_parse_env(ns.env),
92
+ timeout=ns.timeout, remote_python=(ns.python or ""), utf8=not ns.no_utf8,
93
+ mux=not ns.no_mux,
94
+ )
95
+ except (ValueError, RuntimeError) as e:
96
+ raise SystemExit(f"[remote-exec] {e}")
97
+
98
+ if not ns.quiet:
99
+ via = script_for_log or "<stdin>"
100
+ extra = f" python={result.remote_python}({result.remote_python_version})" if result.lang == "python" else ""
101
+ print(f"[remote-exec] host={result.host} ({result.ip}) lang={result.lang}{extra} "
102
+ f"script={via} sha1={result.content_sha1}", file=sys.stderr)
103
+ sys.stdout.buffer.write(result.stdout)
104
+ sys.stdout.buffer.flush()
105
+ sys.stderr.buffer.write(result.stderr)
106
+ sys.stderr.buffer.flush()
107
+
108
+ if result.timed_out:
109
+ print(f"[remote-exec] 超时({ns.timeout}s),本地已终止", file=sys.stderr)
110
+ elif result.transport_error:
111
+ print(f"[remote-exec] ssh 传输层错误(连接失败/认证失败/掉线),exit=255", file=sys.stderr)
112
+ if not ns.quiet:
113
+ print(f"[remote-exec] exit={result.exit_code} 耗时 {result.duration:.1f}s", file=sys.stderr)
114
+ return result.exit_code
115
+
116
+
117
+ def _short_source(path_str: str) -> str:
118
+ """来源路径缩短显示:home → ~。"""
119
+ if not path_str:
120
+ return "-"
121
+ s = str(path_str)
122
+ home = str(Path.home())
123
+ if s.startswith(home):
124
+ return "~" + s[len(home):]
125
+ return s
126
+
127
+
128
+ def cmd_machines(ns) -> int:
129
+ machines = load_machines()
130
+ if ns.json:
131
+ print(json.dumps([m.public_dict() for m in machines], ensure_ascii=False, indent=2))
132
+ return 0
133
+ for m in machines:
134
+ desc = f" # {m.description}" if m.description else ""
135
+ print(f"{m.name:<24} {m.ip:<16} {m.os:<8} {m.user:<12} {m.default_lang:<11} {_short_source(m.source)}{desc}")
136
+ return 0
137
+
138
+
139
+ def cmd_config(ns) -> int:
140
+ infos = scan_sources()
141
+ print("machines.json 来源链(高 → 低优先级,同名机器高优先级覆盖):")
142
+ raw_total = 0
143
+ for i, info in enumerate(infos, 1):
144
+ if not info.exists:
145
+ state = "- 不存在"
146
+ elif info.error:
147
+ state = f"✗ 读取失败: {info.error}"
148
+ else:
149
+ state = f"✓ {info.machine_count} 台"
150
+ raw_total += info.machine_count
151
+ print(f" [{i}] {info.label:<22} {info.path} {state}")
152
+ merged = load_machines()
153
+ print(f"合计 {len(merged)} 台(各来源原始共 {raw_total} 台,同名覆盖后 {len(merged)} 台)")
154
+ return 0
155
+
156
+
157
+ def cmd_setup(ns) -> int:
158
+ if ns.all:
159
+ hosts = [m.name for m in load_machines()]
160
+ elif ns.host:
161
+ hosts = [ns.host]
162
+ else:
163
+ raise SystemExit("[setup] 需要指定 host 或 --all")
164
+ results = []
165
+ if len(hosts) == 1:
166
+ print(f"[setup] {hosts[0]} 初始化中...", file=sys.stderr)
167
+ results.append(setup_machine(hosts[0], force=ns.force))
168
+ else:
169
+ from concurrent.futures import ThreadPoolExecutor, as_completed
170
+ with ThreadPoolExecutor(max_workers=ns.jobs) as pool:
171
+ futs = {pool.submit(setup_machine, h, force=ns.force): h for h in hosts}
172
+ for f in as_completed(futs):
173
+ r = f.result()
174
+ results.append(r)
175
+ print(f"[setup] {r.host}: {'ok' if r.ok else 'FAIL'} ({r.duration:.0f}s)", file=sys.stderr)
176
+ print(f"\n{'host':<24} {'结果':<6} {'python':<10} {'venv':<46} 备注")
177
+ for r in sorted(results, key=lambda x: x.host):
178
+ if r.ok:
179
+ note = []
180
+ if r.installed_standalone:
181
+ note.append("新装standalone")
182
+ if r.created_venv:
183
+ note.append("新建venv")
184
+ if not note:
185
+ note.append("已存在,仅校验/补装依赖")
186
+ print(f"{r.host:<24} {'ok':<6} {r.version:<10} {r.venv_python:<46} {','.join(note)}")
187
+ else:
188
+ print(f"{r.host:<24} {'FAIL':<6} {'':<10} {'':<46} {r.message[:80]}")
189
+ return 0 if all(r.ok for r in results) else 1
190
+
191
+
192
+ def cmd_pip(ns) -> int:
193
+ try:
194
+ machine = resolve_machine(ns.host)
195
+ except KeyError as e:
196
+ raise SystemExit(f"[pip] {e}")
197
+ args = list(ns.pargs or []) + list(getattr(ns, "args", []) or [])
198
+ if not args:
199
+ raise SystemExit("[pip] 缺少 pip 参数,例:rrun pip <host> -- list")
200
+ try:
201
+ _check_ascii(args, "pip 参数")
202
+ py = venv_python_or_die(ns.host)
203
+ except (ValueError, RuntimeError) as e:
204
+ raise SystemExit(f"[pip] {e}")
205
+ joined = " ".join(args)
206
+ if machine.is_windows:
207
+ remote_cmd = f'"{py}" -m pip {joined}'
208
+ else:
209
+ remote_cmd = f"{py} -m pip {joined}"
210
+ t0 = time.time()
211
+ rc, out, err, timed_out = _ssh_run(machine, remote_cmd, b"", ns.timeout, True)
212
+ sys.stdout.buffer.write(out)
213
+ sys.stdout.buffer.flush()
214
+ sys.stderr.buffer.write(err)
215
+ sys.stderr.buffer.flush()
216
+ print(f"[pip] {machine.name} exit={rc} 耗时 {time.time() - t0:.1f}s", file=sys.stderr)
217
+ return rc
218
+
219
+
220
+ def cmd_close(ns) -> int:
221
+ if ns.all:
222
+ hosts = [m.name for m in load_machines()]
223
+ elif ns.host:
224
+ hosts = [ns.host]
225
+ else:
226
+ raise SystemExit("[remote-exec] close 需要指定 host 或 --all")
227
+ rc = 0
228
+ for h in hosts:
229
+ try:
230
+ code, msg = close_mux(h)
231
+ except Exception as e: # noqa: BLE001 - close 尽量遍历完
232
+ code, msg = 1, str(e)
233
+ print(f"[remote-exec] close {h}: {'ok' if code == 0 else msg}")
234
+ rc = rc or code
235
+ return rc
236
+
237
+
238
+ def main() -> None:
239
+ ap = argparse.ArgumentParser(
240
+ prog="rrun",
241
+ description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
242
+ sub = ap.add_subparsers(dest="subcmd", required=True)
243
+
244
+ ep = sub.add_parser("exec", help="在远端机器执行本地脚本/内联内容")
245
+ ep.add_argument("host", help="机器 name/ip/hostname(见 machines 子命令)")
246
+ ep.add_argument("script", nargs="?", help="本地脚本路径(.py/.ps1/.sh,UTF-8)")
247
+ ep.add_argument("--lang", choices=["bash", "powershell", "python"],
248
+ help="远端解释语言(缺省:按扩展名,否则按机器 OS)")
249
+ ep.add_argument("-c", "--content", help="内联脚本内容(自动落盘 ~/.rrun/drops/ 留档)")
250
+ ep.add_argument("args", nargs="*",
251
+ help="脚本参数(仅 ASCII;建议用 -- 与选项分隔,选项可放任意位置)")
252
+ ep.add_argument("--workdir", help="远端工作目录(bash/powershell 支持)")
253
+ ep.add_argument("--env", action="append", metavar="KEY=VAL",
254
+ help="远端环境变量(可多次;仅 ASCII)")
255
+ ep.add_argument("--timeout", type=float, help="本地超时秒数(超时 exit=124)")
256
+ ep.add_argument("--python", dest="python", help="远端 python 路径(跳过自动探测)")
257
+ ep.add_argument("--no-utf8", action="store_true", help="远端 python 不加 -X utf8")
258
+ ep.add_argument("--no-mux", action="store_true", help="禁用 ssh ControlMaster 复用")
259
+ ep.add_argument("-q", "--quiet", action="store_true", help="不打印 [remote-exec] 信息行")
260
+ ep.set_defaults(func=cmd_exec)
261
+
262
+ mp = sub.add_parser("machines", help="列出全部来源合并后的机器(脱敏,含来源)")
263
+ mp.add_argument("--json", action="store_true")
264
+ mp.set_defaults(func=cmd_machines)
265
+
266
+ cf = sub.add_parser("config", help="查看 machines.json 来源链解析(哪些文件生效、各贡献几台)")
267
+ cf.set_defaults(func=cmd_config)
268
+
269
+ sp = sub.add_parser("setup", help="初始化远端统一 python 环境(3.12 venv + 阿里云源)")
270
+ sp.add_argument("host", nargs="?", help="机器 name/ip;--all 表示全部")
271
+ sp.add_argument("--all", action="store_true", help="对 machines.json 所有机器执行")
272
+ sp.add_argument("--force", action="store_true", help="重建 venv(不动已装的 python 本体)")
273
+ sp.add_argument("--jobs", type=int, default=6, help="--all 时的并发数(默认 6)")
274
+ sp.set_defaults(func=cmd_setup)
275
+
276
+ pp = sub.add_parser("pip", help="在远端统一 venv 中执行 pip(ad-hoc 装包)")
277
+ pp.add_argument("host", help="机器 name/ip")
278
+ pp.add_argument("pargs", nargs="*", help="pip 参数;含 - 开头选项时放 -- 之后")
279
+ pp.add_argument("--timeout", type=float, default=300.0, help="本地超时秒数(默认 300)")
280
+ pp.set_defaults(func=cmd_pip)
281
+
282
+ cp = sub.add_parser("close", help="关闭 ssh ControlMaster 复用连接")
283
+ cp.add_argument("host", nargs="?", help="机器 name/ip;省略时需 --all")
284
+ cp.add_argument("--all", action="store_true", help="关闭所有机器的复用连接")
285
+ cp.set_defaults(func=cmd_close)
286
+
287
+ # argparse 对 -- 的处理与子解析器/位置参数组合有 quirk,手动切分更可靠:
288
+ # 第一个 -- 之后的全部内容原样作为脚本参数
289
+ argv = sys.argv[1:]
290
+ passthrough: "list[str] | None" = None
291
+ if "--" in argv:
292
+ i = argv.index("--")
293
+ argv, passthrough = argv[:i], argv[i + 1:]
294
+ ns = ap.parse_args(argv)
295
+ if passthrough is not None:
296
+ ns.args = list(getattr(ns, "args", []) or []) + passthrough
297
+ sys.exit(ns.func(ns))
298
+
299
+
300
+ if __name__ == "__main__":
301
+ main()