rrun-cli 0.2.0__tar.gz → 0.2.2__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rrun-cli
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
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
5
  Author: waqiju
6
6
  License-Expression: MIT
@@ -106,7 +106,7 @@ The full set of hard-won conventions and internals: [docs/remote-exec-convention
106
106
  | `rrun doctor <host\|--all>` | Health-check ssh + auth + remote python (`--all` opens real connections to every machine) |
107
107
  | `rrun close [<host>\|--all]` | Close ssh ControlMaster multiplexed connections |
108
108
 
109
- Useful `exec` flags: `--lang bash|powershell|python`, `--workdir`, `--env K=V`, `--timeout`, `--python <path>` (skip detection), `--no-mux`, `-q`.
109
+ Useful `exec` flags: `--lang bash|powershell|python`, `--workdir`/`--env K=V` (not for Windows+python), `--timeout`, `--python <path>` (skip detection), `--no-mux`, `-q`.
110
110
 
111
111
  ### Exit codes
112
112
 
@@ -153,7 +153,7 @@ For `python` scripts, rrun requires a **3.12.x** interpreter on the remote, prob
153
153
  2. Standalone base: `...\python312\python.exe` / `~/.remote-machine/python312/bin/python3`
154
154
  3. Existing installs: `C:\Python\Python312\python.exe` / `python3.12` on PATH
155
155
 
156
- 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 its own `pip.ini`/`pip.conf` (global pip config untouched) plus the standard packages from the bundled `remote-requirements.txt` (override with `~/.rrun/remote-requirements.txt`).
156
+ If none match, run `rrun setup <host>`. It is idempotent and non-destructive: if the machine lacks a **venv-capable** Python 3.12 — none installed, or the interpreter cannot create venvs (Debian/Ubuntu split `ensurepip` into the separate `python3.12-venv` package) — a [python-build-standalone](https://github.com/astral-sh/python-build-standalone) tarball (Windows/macOS/Linux) 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. A half-created venv left by an interrupted earlier setup (python runs but pip is missing) is detected and recreated automatically. `rrun doctor <host>` reports all of these states before you provision. The venv gets its own `pip.ini`/`pip.conf` (global pip config untouched) plus the standard packages from the bundled `remote-requirements.txt` (override with `~/.rrun/remote-requirements.txt`).
157
157
 
158
158
  ## Security
159
159
 
@@ -78,7 +78,7 @@ The full set of hard-won conventions and internals: [docs/remote-exec-convention
78
78
  | `rrun doctor <host\|--all>` | Health-check ssh + auth + remote python (`--all` opens real connections to every machine) |
79
79
  | `rrun close [<host>\|--all]` | Close ssh ControlMaster multiplexed connections |
80
80
 
81
- Useful `exec` flags: `--lang bash|powershell|python`, `--workdir`, `--env K=V`, `--timeout`, `--python <path>` (skip detection), `--no-mux`, `-q`.
81
+ Useful `exec` flags: `--lang bash|powershell|python`, `--workdir`/`--env K=V` (not for Windows+python), `--timeout`, `--python <path>` (skip detection), `--no-mux`, `-q`.
82
82
 
83
83
  ### Exit codes
84
84
 
@@ -125,7 +125,7 @@ For `python` scripts, rrun requires a **3.12.x** interpreter on the remote, prob
125
125
  2. Standalone base: `...\python312\python.exe` / `~/.remote-machine/python312/bin/python3`
126
126
  3. Existing installs: `C:\Python\Python312\python.exe` / `python3.12` on PATH
127
127
 
128
- 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 its own `pip.ini`/`pip.conf` (global pip config untouched) plus the standard packages from the bundled `remote-requirements.txt` (override with `~/.rrun/remote-requirements.txt`).
128
+ If none match, run `rrun setup <host>`. It is idempotent and non-destructive: if the machine lacks a **venv-capable** Python 3.12 — none installed, or the interpreter cannot create venvs (Debian/Ubuntu split `ensurepip` into the separate `python3.12-venv` package) — a [python-build-standalone](https://github.com/astral-sh/python-build-standalone) tarball (Windows/macOS/Linux) 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. A half-created venv left by an interrupted earlier setup (python runs but pip is missing) is detected and recreated automatically. `rrun doctor <host>` reports all of these states before you provision. The venv gets its own `pip.ini`/`pip.conf` (global pip config untouched) plus the standard packages from the bundled `remote-requirements.txt` (override with `~/.rrun/remote-requirements.txt`).
129
129
 
130
130
  ## Security
131
131
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "rrun-cli"
7
- version = "0.2.0"
7
+ version = "0.2.2"
8
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
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -11,7 +11,7 @@ try:
11
11
 
12
12
  __version__ = _pkg_version("rrun-cli")
13
13
  except Exception: # noqa: BLE001 - 未安装(源码直跑)时回退
14
- __version__ = "0.2.0"
14
+ __version__ = "0.2.2"
15
15
 
16
16
  from .doctor import DoctorResult, check_machine
17
17
  from .executor import (
@@ -41,6 +41,7 @@ import sys
41
41
  import time
42
42
  from pathlib import Path
43
43
 
44
+ from . import __version__
44
45
  from .doctor import check_machine
45
46
  from .executor import RRUN_HOME, _check_ascii, _ssh_run, close_mux, run
46
47
  from .registry import load_machines, resolve_machine, scan_sources
@@ -277,6 +278,7 @@ def main() -> None:
277
278
  ap = argparse.ArgumentParser(
278
279
  prog="rrun",
279
280
  description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
281
+ ap.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
280
282
  sub = ap.add_subparsers(dest="subcmd", required=True)
281
283
 
282
284
  ep = sub.add_parser("exec", help="execute a local script / inline content on a remote machine")
@@ -287,9 +289,9 @@ def main() -> None:
287
289
  ep.add_argument("-c", "--content", help="inline script content (archived to ~/.rrun/drops/)")
288
290
  ep.add_argument("args", nargs="*",
289
291
  help="script arguments (ASCII only; use -- to separate from options)")
290
- ep.add_argument("--workdir", help="remote working directory (bash/powershell only)")
292
+ ep.add_argument("--workdir", help="remote working directory (not for Windows+python)")
291
293
  ep.add_argument("--env", action="append", metavar="KEY=VAL",
292
- help="remote environment variable (repeatable; ASCII only)")
294
+ help="remote environment variable (repeatable; ASCII only; not for Windows+python)")
293
295
  ep.add_argument("--timeout", type=float, help="local timeout in seconds (exit=124 on expiry)")
294
296
  ep.add_argument("--python", dest="python", help="remote python path (skips auto-detection)")
295
297
  ep.add_argument("--no-utf8", action="store_true", help="do not pass -X utf8 to remote python")
@@ -8,16 +8,19 @@ import time
8
8
  from dataclasses import dataclass, field
9
9
 
10
10
  from .executor import (
11
+ POSIX_BASE_CANDIDATES,
11
12
  POSIX_PYTHON_CANDIDATES,
12
13
  PS_REMOTE_CMD,
13
14
  VENV_PY_POSIX,
14
15
  VENV_PY_WIN,
16
+ WINDOWS_BASE_CANDIDATES,
15
17
  WINDOWS_PYTHON_CANDIDATES,
16
18
  _ssh_run,
17
19
  build_ps_wrapper,
20
+ check_remote_pip,
18
21
  probe_python,
19
22
  )
20
- from .registry import resolve_machine
23
+ from .registry import Machine, resolve_machine
21
24
 
22
25
 
23
26
  @dataclass
@@ -33,6 +36,20 @@ class DoctorResult:
33
36
  message: str = "" # 失败原因 / 建议
34
37
 
35
38
 
39
+ def _venv_missing_guidance(machine: Machine, found_version: str, mux: bool) -> str:
40
+ """venv 缺失时的指引:审查基座资格(ensurepip),说明 setup 将会走哪条路。"""
41
+ base_cands = WINDOWS_BASE_CANDIDATES if machine.is_windows else POSIX_BASE_CANDIDATES
42
+ base = probe_python(machine, base_cands, mux, require_ensurepip=True)
43
+ if base:
44
+ head = "unified venv missing" if found_version else "no python 3.12 found"
45
+ return f"{head}; run: rrun setup {machine.name}"
46
+ hint = " (Debian/Ubuntu: sudo apt install python3.12-venv)" if not machine.is_windows else ""
47
+ if found_version:
48
+ return (f"unified venv missing; base python {found_version} lacks ensurepip{hint}; "
49
+ f"rrun setup {machine.name} will install a standalone python")
50
+ return f"no python 3.12 found; rrun setup {machine.name} will install a standalone python"
51
+
52
+
36
53
  def check_machine(host: str, mux: bool = True, timeout: float = 30.0) -> DoctorResult:
37
54
  """检查单台机器:ssh 回显 → 远端 python 探测。不抛异常,结果全部汇入 DoctorResult。"""
38
55
  try:
@@ -80,9 +97,13 @@ def check_machine(host: str, mux: bool = True, timeout: float = 30.0) -> DoctorR
80
97
  "/.remote-machine/venv/bin/python")
81
98
  tag = "unified venv" if r.is_unified_venv else "fallback (not the unified venv)"
82
99
  r.checks.append(f"python {r.python_version} at {r.python_path} [{tag}]")
83
- if not r.is_unified_venv:
84
- r.message = f"unified venv missing; run: rrun setup {machine.name}"
100
+ if r.is_unified_venv:
101
+ if not check_remote_pip(machine, r.python_path, mux):
102
+ r.message = f"unified venv is broken (pip missing); repair: rrun setup {machine.name}"
103
+ return r
104
+ else:
105
+ r.message = _venv_missing_guidance(machine, r.python_version, mux)
85
106
  else:
86
- r.message = f"no python 3.12 found; run: rrun setup {machine.name}"
107
+ r.message = _venv_missing_guidance(machine, "", mux)
87
108
  r.ok = r.ssh_ok and bool(hit) and r.is_unified_venv
88
109
  return r
@@ -205,27 +205,33 @@ _PY_VER_PRINT_CODE = "import sys;print(*sys.version_info[:3],sep=chr(46))"
205
205
  _PY_VER_CHECK_CODE = (
206
206
  _PY_VER_PRINT_CODE + f";sys.exit(0 if sys.version_info[:2]=={REQUIRED_PY_MAJOR_MINOR} else 1)"
207
207
  )
208
+ # 基座资格加码:ensurepip 必须可 import(Debian/Ubuntu 把 ensurepip 整体拆进 python3.x-venv
209
+ # 包,缺失时系统 python 版本对却建不了 venv——不算合格基座)。import 失败即非零退出,候选跳过。
210
+ _PY_BASE_CHECK_CODE = "import ensurepip;" + _PY_VER_CHECK_CODE
208
211
 
209
212
 
210
- def _ps_probe_script(candidates) -> str:
211
- """Windows 探测脚本:逐候选校验版本==3.12.x,命中输出 path|x.y.z,全灭 exit 1。"""
213
+ def _ps_probe_script(candidates, require_ensurepip: bool = False) -> str:
214
+ """Windows 探测脚本:逐候选校验(版本==3.12.x,require_ensurepip 时加 ensurepip 资格),
215
+ 命中输出 path|x.y.z,全灭 exit 1。"""
216
+ check = _PY_BASE_CHECK_CODE if require_ensurepip else _PY_VER_CHECK_CODE
212
217
  arr = ",".join(_ps_quote(c) for c in candidates)
213
218
  return (
214
219
  f"foreach($c in @({arr})){{"
215
220
  "if(Test-Path $c){"
216
- f"$v=& $c -X utf8 -c \"{_PY_VER_CHECK_CODE}\" 2>$null;"
221
+ f"$v=& $c -X utf8 -c \"{check}\" 2>$null;"
217
222
  "if($LASTEXITCODE -eq 0){Write-Output ($c+'|'+($v -join ''));exit 0}"
218
223
  "}}"
219
224
  )
220
225
 
221
226
 
222
- def _bash_probe_script(candidates) -> str:
227
+ def _bash_probe_script(candidates, require_ensurepip: bool = False) -> str:
223
228
  """posix 探测脚本:同 Windows 语义。候选为可信常量($HOME 需保留展开,不可 shlex.quote)。"""
229
+ check = _PY_BASE_CHECK_CODE if require_ensurepip else _PY_VER_CHECK_CODE
224
230
  items = " ".join(f'"{c}"' for c in candidates)
225
231
  return (
226
232
  f"for p in {items}; do\n"
227
233
  ' command -v "$p" >/dev/null 2>&1 || continue\n'
228
- f' v=$("$p" -X utf8 -c \'{_PY_VER_CHECK_CODE}\' 2>/dev/null) || continue\n'
234
+ f' v=$("$p" -X utf8 -c \'{check}\' 2>/dev/null) || continue\n'
229
235
  ' echo "$p|$v"\n'
230
236
  " exit 0\n"
231
237
  "done\n"
@@ -233,14 +239,19 @@ def _bash_probe_script(candidates) -> str:
233
239
  )
234
240
 
235
241
 
236
- def probe_python(machine: Machine, candidates, mux: bool = True) -> "tuple[str, str] | None":
237
- """在候选中找版本==3.12.x 的远端 python,单次往返;返回 (路径, 版本),全灭返回 None"""
242
+ def probe_python(machine: Machine, candidates, mux: bool = True,
243
+ require_ensurepip: bool = False) -> "tuple[str, str] | None":
244
+ """在候选中找版本==3.12.x 的远端 python,单次往返;返回 (路径, 版本),全灭返回 None。
245
+
246
+ require_ensurepip=True 用于 setup/doctor 的基座资格审查:无 ensurepip 的 python
247
+ 建不了 venv(Debian/Ubuntu 拆包 python3.x-venv 的典型场景),跳过。
248
+ """
238
249
  if machine.is_windows:
239
250
  rc, out, _, _ = _ssh_run(machine, PS_REMOTE_CMD,
240
- build_ps_wrapper(_ps_probe_script(candidates)), 60, mux)
251
+ build_ps_wrapper(_ps_probe_script(candidates, require_ensurepip)), 60, mux)
241
252
  else:
242
253
  rc, out, _, _ = _ssh_run(machine, "bash -s",
243
- _bash_probe_script(candidates).encode("utf-8"), 60, mux)
254
+ _bash_probe_script(candidates, require_ensurepip).encode("utf-8"), 60, mux)
244
255
  if rc != 0 or not out.strip():
245
256
  return None
246
257
  line = out.decode("utf-8", "replace").strip().splitlines()[-1].strip()
@@ -262,6 +273,18 @@ def probe_python_version(machine: Machine, python_path: str, mux: bool = True) -
262
273
  return out.decode("utf-8", "replace").strip().splitlines()[-1].strip()
263
274
 
264
275
 
276
+ def check_remote_pip(machine: Machine, python_path: str, mux: bool = True) -> bool:
277
+ """检查指定远端 python 是否带 pip(半拉子 venv 检测:venv python 能跑,
278
+ 但若创建时 ensurepip 失败过,venv 里就没有 pip)。"""
279
+ if machine.is_windows:
280
+ payload = build_ps_wrapper(f"& {_ps_quote(python_path)} -m pip --version *>$null")
281
+ rc, _, _, _ = _ssh_run(machine, PS_REMOTE_CMD, payload, 30, mux)
282
+ else:
283
+ rc, _, _, _ = _ssh_run(machine, "bash -s",
284
+ f'"{python_path}" -m pip --version >/dev/null 2>&1\n'.encode(), 30, mux)
285
+ return rc == 0
286
+
287
+
265
288
  def detect_remote_python(machine: Machine, override: str = "", mux: bool = True) -> "tuple[str, str]":
266
289
  """解析远端 python,返回 (路径, 版本)。基线锁 3.12;machines.json "python" 字段/参数可覆盖(跳过校验)。"""
267
290
  key = (machine.ip, override or machine.python)
@@ -329,7 +352,7 @@ def run(host: str, lang: str = "", file: str = "", content: str = "", args=(),
329
352
  py_cmd += " " + " ".join(shlex.quote(a) for a in args)
330
353
  if env or workdir:
331
354
  if machine.is_windows:
332
- raise ValueError("workdir/env are not supported for Windows+python yet; use os.chdir/os.environ inside the script")
355
+ raise ValueError("workdir/env are not supported for Windows+python (by design); use os.chdir/os.environ inside the script")
333
356
  remote_cmd = _posix_prefix(workdir, env) + py_cmd
334
357
  else:
335
358
  remote_cmd = py_cmd
@@ -38,6 +38,7 @@ from .executor import (
38
38
  _check_ascii,
39
39
  _ssh_run,
40
40
  build_ps_wrapper,
41
+ check_remote_pip,
41
42
  probe_python,
42
43
  probe_python_version,
43
44
  )
@@ -54,6 +55,8 @@ PBS_ASSETS = {
54
55
  ("Windows", "x86_64"): f"cpython-{PBS_VER}+{PBS_TAG}-x86_64-pc-windows-msvc-install_only.tar.gz",
55
56
  ("Mac", "arm64"): f"cpython-{PBS_VER}+{PBS_TAG}-aarch64-apple-darwin-install_only.tar.gz",
56
57
  ("Mac", "x86_64"): f"cpython-{PBS_VER}+{PBS_TAG}-x86_64-apple-darwin-install_only.tar.gz",
58
+ ("Linux", "x86_64"): f"cpython-{PBS_VER}+{PBS_TAG}-x86_64-unknown-linux-gnu-install_only.tar.gz",
59
+ ("Linux", "aarch64"): f"cpython-{PBS_VER}+{PBS_TAG}-aarch64-unknown-linux-gnu-install_only.tar.gz",
57
60
  }
58
61
  PBS_DOWNLOAD_BASE = f"https://github.com/astral-sh/python-build-standalone/releases/download/{PBS_TAG}/"
59
62
  CACHE_DIR = Path(os.environ.get("XDG_CACHE_HOME", "") or Path.home() / ".cache") / "rrun"
@@ -109,6 +112,13 @@ def _ensure_local_tarball(os_name: str, arch: str) -> Path:
109
112
  return path
110
113
 
111
114
 
115
+ def _pbs_platform(uname_sm: str) -> "tuple[str, str]":
116
+ """`uname -sm` 输出 -> PBS_ASSETS 键('Linux x86_64'->('Linux','x86_64'),'Darwin arm64'->('Mac','arm64'))。"""
117
+ parts = uname_sm.split()
118
+ sys_name, arch = (parts + ["", ""])[:2]
119
+ return {"Darwin": "Mac", "Linux": "Linux"}.get(sys_name, sys_name), arch
120
+
121
+
112
122
  def _install_standalone(machine: Machine, mux: bool, timeout: float) -> str:
113
123
  """推送 standalone python 到远端并解压,返回基座 python 路径。"""
114
124
  if machine.is_windows:
@@ -123,11 +133,10 @@ def _install_standalone(machine: Machine, mux: bool, timeout: float) -> str:
123
133
  )
124
134
  base_py = BASE_PY_WIN
125
135
  else:
126
- rc, out, _, _ = _ssh_run(machine, "uname -m", b"", 30, mux)
127
- arch = out.decode("utf-8", "replace").strip()
128
- os_name = "Mac"
136
+ rc, out, _, _ = _ssh_run(machine, "uname -sm", b"", 30, mux)
137
+ os_name, arch = _pbs_platform(out.decode("utf-8", "replace"))
129
138
  if (os_name, arch) not in PBS_ASSETS:
130
- raise RuntimeError(f"[{machine.name}] unsupported architecture: {arch}")
139
+ raise RuntimeError(f"[{machine.name}] no standalone python build for platform: {os_name} {arch}")
131
140
  extract_cmd = (
132
141
  'tools="$HOME/.remote-machine"; mkdir -p "$tools" '
133
142
  '&& rm -rf "$tools/python312" '
@@ -149,7 +158,11 @@ def _install_standalone(machine: Machine, mux: bool, timeout: float) -> str:
149
158
 
150
159
 
151
160
  def _build_ps_setup(base_py: str, pkgs: "list[str]", force: bool) -> str:
152
- """Windows 一键脚本:建 venv(缺/force 时)→ pip.ini → 装依赖 → 自检。全 ASCII。"""
161
+ """Windows 一键脚本:建 venv(缺/坏/force 时)→ pip.ini → 装依赖 → 自检。全 ASCII。
162
+
163
+ “坏” = bin/python 在但 pip 不在:上次 venv 创建中途失败(如 ensurepip 缺失)
164
+ 留下的半拉子 venv,直接重建(幂等自愈)。
165
+ """
153
166
  pkg_args = " ".join(pkgs)
154
167
  imports = ";".join(f"import {_req_import_name(p)}" for p in pkgs) or "pass"
155
168
  force_lit = "$true" if force else "$false"
@@ -159,6 +172,10 @@ $venv="$tools\\venv"
159
172
  $venvPy="$venv\\Scripts\\python.exe"
160
173
  New-Item -ItemType Directory -Force $tools | Out-Null
161
174
  if({force_lit} -and (Test-Path $venv)){{Remove-Item -Recurse -Force $venv}}
175
+ if(Test-Path $venvPy){{
176
+ & $venvPy -m pip --version *>$null
177
+ if($LASTEXITCODE -ne 0){{Remove-Item -Recurse -Force $venv}}
178
+ }}
162
179
  if(-not (Test-Path $venvPy)){{
163
180
  if('{base_py}' -eq ''){{Write-Error "no base python";exit 1}}
164
181
  & '{base_py}' -m venv $venv
@@ -184,6 +201,9 @@ tools="$HOME/.remote-machine"
184
201
  venv="$tools/venv"
185
202
  mkdir -p "$tools"
186
203
  if [ "{'1' if force else '0'}" = "1" ]; then rm -rf "$venv"; fi
204
+ if [ -x "$venv/bin/python" ] && ! "$venv/bin/python" -m pip --version >/dev/null 2>&1; then
205
+ rm -rf "$venv"
206
+ fi
187
207
  if [ ! -x "$venv/bin/python" ]; then
188
208
  "{base_py}" -m venv "$venv"
189
209
  fi
@@ -209,13 +229,20 @@ def setup_machine(host: str, force: bool = False, mux: bool = True,
209
229
  base_cands = WINDOWS_BASE_CANDIDATES if machine.is_windows else POSIX_BASE_CANDIDATES
210
230
 
211
231
  hit = probe_python(machine, [venv_py], mux)
212
- need_create = force or not hit
232
+ # 半拉子 venv(python 能跑但 pip 不在——上次创建中途失败的残留)也视为待重建;
233
+ # 否则远端脚本删掉坏 venv 后会拿空 base_py 去重建,必然失败
234
+ venv_broken = bool(hit) and not check_remote_pip(machine, hit[0], mux)
235
+ need_create = force or not hit or venv_broken
213
236
  base_py = ""
214
237
  if need_create:
215
- base_hit = probe_python(machine, base_cands, mux)
238
+ # 基座资格:版本 3.12 且带 ensurepip(缺 ensurepip 的系统 python 建不了 venv,
239
+ # Debian/Ubuntu 未装 python3.x-venv 的典型场景)——不合格则装 standalone 基座
240
+ base_hit = probe_python(machine, base_cands, mux, require_ensurepip=True)
216
241
  if base_hit:
217
242
  base_py = base_hit[0]
218
243
  else:
244
+ print(f"[setup] [{machine.name}] no venv-capable python 3.12 base found; "
245
+ f"installing standalone python ...", file=sys.stderr)
219
246
  base_py = _install_standalone(machine, mux, timeout)
220
247
  result.installed_standalone = True
221
248
  result.created_venv = True
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rrun-cli
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
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
5
  Author: waqiju
6
6
  License-Expression: MIT
@@ -106,7 +106,7 @@ The full set of hard-won conventions and internals: [docs/remote-exec-convention
106
106
  | `rrun doctor <host\|--all>` | Health-check ssh + auth + remote python (`--all` opens real connections to every machine) |
107
107
  | `rrun close [<host>\|--all]` | Close ssh ControlMaster multiplexed connections |
108
108
 
109
- Useful `exec` flags: `--lang bash|powershell|python`, `--workdir`, `--env K=V`, `--timeout`, `--python <path>` (skip detection), `--no-mux`, `-q`.
109
+ Useful `exec` flags: `--lang bash|powershell|python`, `--workdir`/`--env K=V` (not for Windows+python), `--timeout`, `--python <path>` (skip detection), `--no-mux`, `-q`.
110
110
 
111
111
  ### Exit codes
112
112
 
@@ -153,7 +153,7 @@ For `python` scripts, rrun requires a **3.12.x** interpreter on the remote, prob
153
153
  2. Standalone base: `...\python312\python.exe` / `~/.remote-machine/python312/bin/python3`
154
154
  3. Existing installs: `C:\Python\Python312\python.exe` / `python3.12` on PATH
155
155
 
156
- 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 its own `pip.ini`/`pip.conf` (global pip config untouched) plus the standard packages from the bundled `remote-requirements.txt` (override with `~/.rrun/remote-requirements.txt`).
156
+ If none match, run `rrun setup <host>`. It is idempotent and non-destructive: if the machine lacks a **venv-capable** Python 3.12 — none installed, or the interpreter cannot create venvs (Debian/Ubuntu split `ensurepip` into the separate `python3.12-venv` package) — a [python-build-standalone](https://github.com/astral-sh/python-build-standalone) tarball (Windows/macOS/Linux) 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. A half-created venv left by an interrupted earlier setup (python runs but pip is missing) is detected and recreated automatically. `rrun doctor <host>` reports all of these states before you provision. The venv gets its own `pip.ini`/`pip.conf` (global pip config untouched) plus the standard packages from the bundled `remote-requirements.txt` (override with `~/.rrun/remote-requirements.txt`).
157
157
 
158
158
  ## Security
159
159
 
@@ -1,7 +1,10 @@
1
- """CLI 辅助函数测试:--env 解析、inline 落盘。"""
1
+ """CLI 辅助函数测试:--env 解析、inline 落盘、--version。"""
2
+
3
+ import sys
2
4
 
3
5
  import pytest
4
6
 
7
+ import rrun
5
8
  import rrun.__main__ as cli
6
9
 
7
10
 
@@ -27,3 +30,13 @@ class TestDropInline:
27
30
  def test_unknown_lang_txt(self, tmp_path, monkeypatch):
28
31
  monkeypatch.setattr(cli, "INLINE_DROP_DIR", tmp_path / "drops")
29
32
  assert cli._drop_inline("h", "weird", "x").suffix == ".txt"
33
+
34
+
35
+ class TestVersion:
36
+ def test_version_flag(self, monkeypatch, capsys):
37
+ # argparse version action 打印到 stdout 并以 0 退出
38
+ monkeypatch.setattr(sys, "argv", ["rrun", "--version"])
39
+ with pytest.raises(SystemExit) as e:
40
+ cli.main()
41
+ assert e.value.code == 0
42
+ assert capsys.readouterr().out.strip() == f"rrun {rrun.__version__}"
@@ -5,8 +5,12 @@ import base64
5
5
  import pytest
6
6
 
7
7
  from rrun.executor import (
8
+ _PY_BASE_CHECK_CODE,
9
+ _PY_VER_CHECK_CODE,
10
+ _bash_probe_script,
8
11
  _check_ascii,
9
12
  _posix_prefix,
13
+ _ps_probe_script,
10
14
  _ps_quote,
11
15
  _ssh_args,
12
16
  build_bash_command,
@@ -74,6 +78,29 @@ class TestBashCommand:
74
78
  build_bash_command(args=["中文"])
75
79
 
76
80
 
81
+ class TestProbeScripts:
82
+ def test_bash_probe_default_is_version_only(self):
83
+ s = _bash_probe_script(["python3.12"])
84
+ assert "sys.version_info" in s
85
+ assert "ensurepip" not in s # exec 热路径不做基座资格加码
86
+
87
+ def test_bash_probe_require_ensurepip(self):
88
+ s = _bash_probe_script(["python3.12", "/usr/local/bin/python3.12"], require_ensurepip=True)
89
+ assert "import ensurepip" in s
90
+
91
+ def test_ps_probe_require_ensurepip(self):
92
+ s = _ps_probe_script([r"C:\Python\Python312\python.exe"], require_ensurepip=True)
93
+ assert "import ensurepip" in s
94
+
95
+ def test_ps_probe_default_is_version_only(self):
96
+ assert "ensurepip" not in _ps_probe_script([r"C:\py\python.exe"])
97
+
98
+ def test_check_codes_contain_no_quotes(self):
99
+ # bash 探测用单引号包裹检查代码,代码内出现引号会破坏组装
100
+ for code in (_PY_VER_CHECK_CODE, _PY_BASE_CHECK_CODE):
101
+ assert "'" not in code and '"' not in code
102
+
103
+
77
104
  class TestPosixPrefix:
78
105
  def test_empty(self):
79
106
  assert _posix_prefix() == ""
@@ -30,6 +30,26 @@ class TestLoadRequirements:
30
30
  assert load_requirements() == ["foo>=1", "bar"]
31
31
 
32
32
 
33
+ class TestPbsAssets:
34
+ def test_linux_assets_present(self):
35
+ assert ("Linux", "x86_64") in st.PBS_ASSETS
36
+ assert ("Linux", "aarch64") in st.PBS_ASSETS
37
+
38
+ def test_pbs_platform_mapping(self):
39
+ assert st._pbs_platform("Linux x86_64") == ("Linux", "x86_64")
40
+ assert st._pbs_platform("Linux aarch64") == ("Linux", "aarch64")
41
+ assert st._pbs_platform("Darwin arm64") == ("Mac", "arm64")
42
+ assert st._pbs_platform("Darwin x86_64") == ("Mac", "x86_64")
43
+
44
+ def test_pbs_platform_edge(self):
45
+ assert st._pbs_platform("") == ("", "")
46
+ assert st._pbs_platform("FreeBSD") == ("FreeBSD", "")
47
+
48
+ def test_all_mapped_posix_platforms_have_assets(self):
49
+ for uname in ("Linux x86_64", "Linux aarch64", "Darwin arm64", "Darwin x86_64"):
50
+ assert st._pbs_platform(uname) in st.PBS_ASSETS
51
+
52
+
33
53
  class TestSetupScripts:
34
54
  def test_bash_setup(self):
35
55
  s = _build_bash_setup("/usr/bin/python3.12", ["requests>=2", "foo"], force=False)
@@ -41,6 +61,12 @@ class TestSetupScripts:
41
61
  def test_bash_setup_force(self):
42
62
  assert "[ \"1\" = \"1\" ]" in _build_bash_setup("p", ["requests"], force=True)
43
63
 
64
+ def test_bash_setup_heals_half_broken_venv(self):
65
+ # 上次 venv 创建中途失败(ensurepip 缺失等)留下的半拉子 venv:检测无 pip 并重建
66
+ s = _build_bash_setup("/usr/bin/python3.12", ["requests"], force=False)
67
+ assert '"$venv/bin/python" -m pip --version' in s
68
+ assert 'rm -rf "$venv"' in s
69
+
44
70
  def test_ps_setup_ascii_and_content(self):
45
71
  s = _build_ps_setup("C:\\py\\python.exe", ["requests"], force=False)
46
72
  assert "$true" not in s and "$false" in s
@@ -48,3 +74,6 @@ class TestSetupScripts:
48
74
 
49
75
  def test_ps_setup_force(self):
50
76
  assert "if($true" in _build_ps_setup("C:\\py\\python.exe", ["requests"], force=True)
77
+
78
+ def test_ps_setup_heals_half_broken_venv(self):
79
+ assert "-m pip --version" in _build_ps_setup("C:\\py\\python.exe", ["requests"], force=False)
File without changes
File without changes
File without changes