rrun-cli 0.2.1__tar.gz → 0.2.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (25) hide show
  1. {rrun_cli-0.2.1 → rrun_cli-0.2.3}/PKG-INFO +10 -16
  2. {rrun_cli-0.2.1 → rrun_cli-0.2.3}/README.md +9 -15
  3. {rrun_cli-0.2.1 → rrun_cli-0.2.3}/pyproject.toml +2 -2
  4. {rrun_cli-0.2.1 → rrun_cli-0.2.3}/src/rrun/__init__.py +1 -1
  5. {rrun_cli-0.2.1 → rrun_cli-0.2.3}/src/rrun/__main__.py +41 -28
  6. rrun_cli-0.2.3/src/rrun/config_cmd.py +272 -0
  7. {rrun_cli-0.2.1 → rrun_cli-0.2.3}/src/rrun/executor.py +1 -1
  8. rrun_cli-0.2.3/src/rrun/machines.template.json +42 -0
  9. {rrun_cli-0.2.1 → rrun_cli-0.2.3}/src/rrun/registry.py +8 -1
  10. {rrun_cli-0.2.1 → rrun_cli-0.2.3}/src/rrun_cli.egg-info/PKG-INFO +10 -16
  11. {rrun_cli-0.2.1 → rrun_cli-0.2.3}/src/rrun_cli.egg-info/SOURCES.txt +3 -0
  12. {rrun_cli-0.2.1 → rrun_cli-0.2.3}/tests/test_cli.py +14 -1
  13. rrun_cli-0.2.3/tests/test_config_cmd.py +247 -0
  14. {rrun_cli-0.2.1 → rrun_cli-0.2.3}/LICENSE +0 -0
  15. {rrun_cli-0.2.1 → rrun_cli-0.2.3}/setup.cfg +0 -0
  16. {rrun_cli-0.2.1 → rrun_cli-0.2.3}/src/rrun/doctor.py +0 -0
  17. {rrun_cli-0.2.1 → rrun_cli-0.2.3}/src/rrun/remote-requirements.txt +0 -0
  18. {rrun_cli-0.2.1 → rrun_cli-0.2.3}/src/rrun/setup.py +0 -0
  19. {rrun_cli-0.2.1 → rrun_cli-0.2.3}/src/rrun_cli.egg-info/dependency_links.txt +0 -0
  20. {rrun_cli-0.2.1 → rrun_cli-0.2.3}/src/rrun_cli.egg-info/entry_points.txt +0 -0
  21. {rrun_cli-0.2.1 → rrun_cli-0.2.3}/src/rrun_cli.egg-info/requires.txt +0 -0
  22. {rrun_cli-0.2.1 → rrun_cli-0.2.3}/src/rrun_cli.egg-info/top_level.txt +0 -0
  23. {rrun_cli-0.2.1 → rrun_cli-0.2.3}/tests/test_executor.py +0 -0
  24. {rrun_cli-0.2.1 → rrun_cli-0.2.3}/tests/test_registry.py +0 -0
  25. {rrun_cli-0.2.1 → rrun_cli-0.2.3}/tests/test_setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rrun-cli
3
- Version: 0.2.1
3
+ Version: 0.2.3
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
@@ -64,19 +64,13 @@ brew install hudochenkov/sshpass/sshpass # macOS (sshpass is not in homeb
64
64
 
65
65
  ## Quickstart
66
66
 
67
- Create `~/.rrun/machines.json` (full format in [Configuration](#configuration-machinesjson)):
68
-
69
- ```json
70
- {
71
- "machines": [
72
- { "name": "my-win-box", "ip": "192.168.1.10", "os": "Windows", "user": "admin", "password": "secret" }
73
- ]
74
- }
75
- ```
76
-
77
- Then:
67
+ Scaffold the machine inventory, fill in your machines, verify:
78
68
 
79
69
  ```bash
70
+ rrun config init # create ~/.rrun/machines.json from the bundled template (mode 600)
71
+ rrun config edit # open it in $EDITOR — replace the my-* example entries
72
+ # or: rrun config add # interactive wizard that appends one machine
73
+
80
74
  rrun machines # verify the inventory is picked up (redacted)
81
75
  printf 'Write-Output "hello 中文"\n' > demo.ps1
82
76
  rrun exec my-win-box demo.ps1 # powershell, inferred from .ps1
@@ -100,13 +94,13 @@ The full set of hard-won conventions and internals: [docs/remote-exec-convention
100
94
  |---|---|
101
95
  | `rrun exec <host> <script\|-c ...>` | Execute a local script / inline content remotely |
102
96
  | `rrun machines [--json]` | List merged machines (redacted, with source) |
103
- | `rrun config` | Diagnose the machines.json source chain |
97
+ | `rrun config [init\|add\|edit]` | Manage the user inventory (`init` scaffolds from the bundled template, `add` appends via wizard/flags, `edit` opens `$EDITOR`); bare `config` diagnoses the source chain |
104
98
  | `rrun setup <host\|--all> [--force]` | Provision the unified remote Python 3.12 venv (idempotent) |
105
99
  | `rrun pip <host> -- list` | Run pip inside the remote unified venv |
106
100
  | `rrun doctor <host\|--all>` | Health-check ssh + auth + remote python (`--all` opens real connections to every machine) |
107
101
  | `rrun close [<host>\|--all]` | Close ssh ControlMaster multiplexed connections |
108
102
 
109
- Useful `exec` flags: `--lang bash|powershell|python`, `--workdir`, `--env K=V`, `--timeout`, `--python <path>` (skip detection), `--no-mux`, `-q`.
103
+ Useful `exec` flags: `--lang bash|powershell|python`, `--workdir`/`--env K=V` (not for Windows+python), `--timeout`, `--python <path>` (skip detection), `--no-mux`, `-q`.
110
104
 
111
105
  ### Exit codes
112
106
 
@@ -118,7 +112,7 @@ Useful `exec` flags: `--lang bash|powershell|python`, `--workdir`, `--env K=V`,
118
112
 
119
113
  ## Configuration: machines.json
120
114
 
121
- Credentials live in local `machines.json` files see [machines.template.json](machines.template.json):
115
+ Credentials live in local `machines.json` files. `rrun config init` writes the bundled template to `~/.rrun/machines.json` (also browsable at [src/rrun/machines.template.json](src/rrun/machines.template.json)):
122
116
 
123
117
  ```json
124
118
  {
@@ -157,7 +151,7 @@ If none match, run `rrun setup <host>`. It is idempotent and non-destructive: if
157
151
 
158
152
  ## Security
159
153
 
160
- - `machines.json` stores **plaintext passwords**. Keep it local, `chmod 600`, never commit it — or leave `password` empty and use key-based auth instead.
154
+ - `machines.json` stores **plaintext passwords**. Keep it local and never commit it — `rrun config init`/`add` already write the user inventory with mode `600` — or leave `password` empty and use key-based auth instead.
161
155
  - Key-based auth runs ssh with `BatchMode=yes` (no interactive prompts; a missing/unauthorized key fails fast instead of eating the script from stdin).
162
156
  - The audit log never records passwords, and `--env` values are logged as keys only.
163
157
 
@@ -36,19 +36,13 @@ brew install hudochenkov/sshpass/sshpass # macOS (sshpass is not in homeb
36
36
 
37
37
  ## Quickstart
38
38
 
39
- Create `~/.rrun/machines.json` (full format in [Configuration](#configuration-machinesjson)):
40
-
41
- ```json
42
- {
43
- "machines": [
44
- { "name": "my-win-box", "ip": "192.168.1.10", "os": "Windows", "user": "admin", "password": "secret" }
45
- ]
46
- }
47
- ```
48
-
49
- Then:
39
+ Scaffold the machine inventory, fill in your machines, verify:
50
40
 
51
41
  ```bash
42
+ rrun config init # create ~/.rrun/machines.json from the bundled template (mode 600)
43
+ rrun config edit # open it in $EDITOR — replace the my-* example entries
44
+ # or: rrun config add # interactive wizard that appends one machine
45
+
52
46
  rrun machines # verify the inventory is picked up (redacted)
53
47
  printf 'Write-Output "hello 中文"\n' > demo.ps1
54
48
  rrun exec my-win-box demo.ps1 # powershell, inferred from .ps1
@@ -72,13 +66,13 @@ The full set of hard-won conventions and internals: [docs/remote-exec-convention
72
66
  |---|---|
73
67
  | `rrun exec <host> <script\|-c ...>` | Execute a local script / inline content remotely |
74
68
  | `rrun machines [--json]` | List merged machines (redacted, with source) |
75
- | `rrun config` | Diagnose the machines.json source chain |
69
+ | `rrun config [init\|add\|edit]` | Manage the user inventory (`init` scaffolds from the bundled template, `add` appends via wizard/flags, `edit` opens `$EDITOR`); bare `config` diagnoses the source chain |
76
70
  | `rrun setup <host\|--all> [--force]` | Provision the unified remote Python 3.12 venv (idempotent) |
77
71
  | `rrun pip <host> -- list` | Run pip inside the remote unified venv |
78
72
  | `rrun doctor <host\|--all>` | Health-check ssh + auth + remote python (`--all` opens real connections to every machine) |
79
73
  | `rrun close [<host>\|--all]` | Close ssh ControlMaster multiplexed connections |
80
74
 
81
- Useful `exec` flags: `--lang bash|powershell|python`, `--workdir`, `--env K=V`, `--timeout`, `--python <path>` (skip detection), `--no-mux`, `-q`.
75
+ Useful `exec` flags: `--lang bash|powershell|python`, `--workdir`/`--env K=V` (not for Windows+python), `--timeout`, `--python <path>` (skip detection), `--no-mux`, `-q`.
82
76
 
83
77
  ### Exit codes
84
78
 
@@ -90,7 +84,7 @@ Useful `exec` flags: `--lang bash|powershell|python`, `--workdir`, `--env K=V`,
90
84
 
91
85
  ## Configuration: machines.json
92
86
 
93
- Credentials live in local `machines.json` files see [machines.template.json](machines.template.json):
87
+ Credentials live in local `machines.json` files. `rrun config init` writes the bundled template to `~/.rrun/machines.json` (also browsable at [src/rrun/machines.template.json](src/rrun/machines.template.json)):
94
88
 
95
89
  ```json
96
90
  {
@@ -129,7 +123,7 @@ If none match, run `rrun setup <host>`. It is idempotent and non-destructive: if
129
123
 
130
124
  ## Security
131
125
 
132
- - `machines.json` stores **plaintext passwords**. Keep it local, `chmod 600`, never commit it — or leave `password` empty and use key-based auth instead.
126
+ - `machines.json` stores **plaintext passwords**. Keep it local and never commit it — `rrun config init`/`add` already write the user inventory with mode `600` — or leave `password` empty and use key-based auth instead.
133
127
  - Key-based auth runs ssh with `BatchMode=yes` (no interactive prompts; a missing/unauthorized key fails fast instead of eating the script from stdin).
134
128
  - The audit log never records passwords, and `--env` values are logged as keys only.
135
129
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "rrun-cli"
7
- version = "0.2.1"
7
+ version = "0.2.3"
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"
@@ -45,7 +45,7 @@ package-dir = { "" = "src" }
45
45
  where = ["src"]
46
46
 
47
47
  [tool.setuptools.package-data]
48
- rrun = ["remote-requirements.txt"]
48
+ rrun = ["remote-requirements.txt", "machines.template.json"]
49
49
 
50
50
  [tool.pytest.ini_options]
51
51
  testpaths = ["tests"]
@@ -11,7 +11,7 @@ try:
11
11
 
12
12
  __version__ = _pkg_version("rrun-cli")
13
13
  except Exception: # noqa: BLE001 - 未安装(源码直跑)时回退
14
- __version__ = "0.2.1"
14
+ __version__ = "0.2.3"
15
15
 
16
16
  from .doctor import DoctorResult, check_machine
17
17
  from .executor import (
@@ -14,6 +14,7 @@ Usage:
14
14
  rrun doctor <host|--all> # health-check ssh + auth + remote python
15
15
  rrun machines # list machines (redacted, with source)
16
16
  rrun config # diagnose the machines.json source chain
17
+ rrun config init|add|edit # scaffold / append / edit ~/.rrun/machines.json
17
18
  rrun close [<host>|--all] # close ssh multiplexed connections
18
19
 
19
20
  Conventions:
@@ -41,9 +42,11 @@ import sys
41
42
  import time
42
43
  from pathlib import Path
43
44
 
45
+ from . import __version__
46
+ from .config_cmd import cmd_config_add, cmd_config_chain, cmd_config_edit, cmd_config_init
44
47
  from .doctor import check_machine
45
48
  from .executor import RRUN_HOME, _check_ascii, _ssh_run, close_mux, run
46
- from .registry import load_machines, resolve_machine, scan_sources
49
+ from .registry import EMPTY_INVENTORY_HINT, load_machines, resolve_machine
47
50
  from .setup import setup_machine, venv_python_or_die
48
51
 
49
52
  INLINE_DROP_DIR = RRUN_HOME / "drops"
@@ -137,29 +140,12 @@ def cmd_machines(ns) -> int:
137
140
  machines = load_machines()
138
141
  if ns.json:
139
142
  print(json.dumps([m.public_dict() for m in machines], ensure_ascii=False, indent=2))
140
- return 0
141
- for m in machines:
142
- desc = f" # {m.description}" if m.description else ""
143
- print(f"{m.name:<24} {m.ip:<16} {m.os:<8} {m.user:<12} {m.default_lang:<11} {_short_source(m.source)}{desc}")
144
- return 0
145
-
146
-
147
- def cmd_config(ns) -> int:
148
- infos = scan_sources()
149
- print("machines.json source chain (high -> low priority; same-name machines are")
150
- print("overridden by higher-priority sources):")
151
- raw_total = 0
152
- for i, info in enumerate(infos, 1):
153
- if not info.exists:
154
- state = "- missing"
155
- elif info.error:
156
- state = f"x read failed: {info.error}"
157
- else:
158
- state = f"ok, {info.machine_count} machines"
159
- raw_total += info.machine_count
160
- print(f" [{i}] {info.label:<22} {info.path} {state}")
161
- merged = load_machines()
162
- print(f"{len(merged)} machines total ({raw_total} across all sources before same-name overrides)")
143
+ else:
144
+ for m in machines:
145
+ desc = f" # {m.description}" if m.description else ""
146
+ print(f"{m.name:<24} {m.ip:<16} {m.os:<8} {m.user:<12} {m.default_lang:<11} {_short_source(m.source)}{desc}")
147
+ if not machines:
148
+ print(f"[machines] {EMPTY_INVENTORY_HINT}", file=sys.stderr)
163
149
  return 0
164
150
 
165
151
 
@@ -277,6 +263,7 @@ def main() -> None:
277
263
  ap = argparse.ArgumentParser(
278
264
  prog="rrun",
279
265
  description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
266
+ ap.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
280
267
  sub = ap.add_subparsers(dest="subcmd", required=True)
281
268
 
282
269
  ep = sub.add_parser("exec", help="execute a local script / inline content on a remote machine")
@@ -287,9 +274,9 @@ def main() -> None:
287
274
  ep.add_argument("-c", "--content", help="inline script content (archived to ~/.rrun/drops/)")
288
275
  ep.add_argument("args", nargs="*",
289
276
  help="script arguments (ASCII only; use -- to separate from options)")
290
- ep.add_argument("--workdir", help="remote working directory (bash/powershell only)")
277
+ ep.add_argument("--workdir", help="remote working directory (not for Windows+python)")
291
278
  ep.add_argument("--env", action="append", metavar="KEY=VAL",
292
- help="remote environment variable (repeatable; ASCII only)")
279
+ help="remote environment variable (repeatable; ASCII only; not for Windows+python)")
293
280
  ep.add_argument("--timeout", type=float, help="local timeout in seconds (exit=124 on expiry)")
294
281
  ep.add_argument("--python", dest="python", help="remote python path (skips auto-detection)")
295
282
  ep.add_argument("--no-utf8", action="store_true", help="do not pass -X utf8 to remote python")
@@ -301,8 +288,34 @@ def main() -> None:
301
288
  mp.add_argument("--json", action="store_true")
302
289
  mp.set_defaults(func=cmd_machines)
303
290
 
304
- cf = sub.add_parser("config", help="show the machines.json source chain (which files apply, how many machines each)")
305
- cf.set_defaults(func=cmd_config)
291
+ cf = sub.add_parser("config", help="manage the machine inventory (init/add/edit); "
292
+ "bare 'config' diagnoses the machines.json source chain")
293
+ cf.set_defaults(func=cmd_config_chain)
294
+ cfsub = cf.add_subparsers(dest="config_cmd")
295
+
296
+ ci = cfsub.add_parser("init", help="create ~/.rrun/machines.json from the bundled template (mode 600)")
297
+ ci.add_argument("--force", action="store_true", help="overwrite an existing inventory file")
298
+ ci.set_defaults(func=cmd_config_init)
299
+
300
+ ca = cfsub.add_parser("add", help="append a machine to ~/.rrun/machines.json "
301
+ "(interactive wizard when neither --name nor --ip is given)")
302
+ ca.add_argument("--name", help="unique machine name (no spaces)")
303
+ ca.add_argument("--ip", help="ip address of the machine")
304
+ ca.add_argument("--hostname", help="optional hostname (also resolvable)")
305
+ ca.add_argument("--os", choices=["Windows", "Mac", "Linux"], help="remote OS (default: Windows)")
306
+ ca.add_argument("--user", help="ssh login user")
307
+ ca.add_argument("--password", help="plaintext password (visible in shell history — prefer the wizard); "
308
+ "omit for key-based auth")
309
+ ca.add_argument("--identity-file", dest="identity_file", help="ssh private key path (key auth only)")
310
+ ca.add_argument("--port", type=int, help="ssh port (default: 22)")
311
+ ca.add_argument("--python", dest="python", help="explicit remote python path (skips auto-detection)")
312
+ ca.add_argument("--description", help="free-form description")
313
+ ca.add_argument("-i", "--interactive", action="store_true", help="force the interactive wizard")
314
+ ca.set_defaults(func=cmd_config_add)
315
+
316
+ ce = cfsub.add_parser("edit", help="open ~/.rrun/machines.json in $EDITOR "
317
+ "(creates it from the template first if missing)")
318
+ ce.set_defaults(func=cmd_config_edit)
306
319
 
307
320
  sp = sub.add_parser("setup", help="provision the unified remote python environment (3.12 venv)")
308
321
  sp.add_argument("host", nargs="?", help="machine name/ip; use --all for every machine")
@@ -0,0 +1,272 @@
1
+ """`rrun config` 子命令组:机器清单的初始化 / 追加 / 编辑 + 来源链诊断。
2
+
3
+ 设计要点:
4
+ - init/add/edit 只操作「用户级主清单」(registry 来源链中的 user 位),路径从
5
+ candidate_sources() 派生而非写死 ~/.rrun,保证写入位置就是加载位置;
6
+ 其他来源(env/cwd/machines.d/legacy)只读不动。
7
+ - 模板内嵌为 package data(src/rrun/machines.template.json),离线可用、随发行版本
8
+ 锁定;写盘即收紧权限(目录 700 / 文件 600,明文密码场景,Windows chmod 语义有限跳过)。
9
+ - add 双模式:不给 --name/--ip → 交互向导(getpass 隐藏密码,旗帜值作预填默认);
10
+ 给了 → 纯旗帜模式(可脚本化);-i 强制向导。
11
+ - edit 走 $VISUAL/$EDITOR(Windows 回退 notepad,POSIX 回退 vi),退出后校验 JSON;
12
+ 文件缺失时先按模板创建(crontab -e 风格)。
13
+ - 无子命令时(bare `rrun config`)保持 v0.2.x 行为:诊断来源链;空清单附 init 引导。
14
+ """
15
+
16
+ import getpass
17
+ import json
18
+ import os
19
+ import shlex
20
+ import subprocess
21
+ import sys
22
+ from pathlib import Path
23
+
24
+ from .registry import (
25
+ EMPTY_INVENTORY_HINT,
26
+ _load_file,
27
+ candidate_sources,
28
+ load_machines,
29
+ resolve_machine,
30
+ scan_sources,
31
+ )
32
+
33
+ TEMPLATE_FILE = Path(__file__).resolve().with_name("machines.template.json")
34
+
35
+ _OS_ALIASES = {
36
+ "w": "Windows", "windows": "Windows",
37
+ "m": "Mac", "mac": "Mac", "macos": "Mac", "osx": "Mac", "darwin": "Mac",
38
+ "l": "Linux", "linux": "Linux",
39
+ }
40
+
41
+
42
+ def user_inventory_path() -> Path:
43
+ """用户级主清单路径(从来源链派生,不写死 ~/.rrun)。"""
44
+ for label, p in candidate_sources():
45
+ if label == "user":
46
+ return p
47
+ raise RuntimeError("unreachable: candidate_sources() always includes the user source") # pragma: no cover
48
+
49
+
50
+ def _shorten(path: Path) -> str:
51
+ """显示用:home 前缀折叠为 ~。"""
52
+ s = str(path)
53
+ home = str(Path.home())
54
+ return "~" + s[len(home):] if s.startswith(home) else s
55
+
56
+
57
+ def _write_secure(path: Path, text: str) -> None:
58
+ """写清单并收紧权限(目录 700 / 文件 600;Windows 上跳过 chmod)。"""
59
+ path.parent.mkdir(parents=True, exist_ok=True)
60
+ path.write_text(text, encoding="utf-8")
61
+ if os.name != "nt":
62
+ os.chmod(path.parent, 0o700)
63
+ os.chmod(path, 0o600)
64
+
65
+
66
+ def _build_entry(name: str, ip: str, os_name: str, user: str, *,
67
+ password: str = "", hostname: str = "", identity_file: str = "",
68
+ port: int = 22, python: str = "", description: str = "") -> dict:
69
+ """组装机器条目并校验必填(空可选字段不落盘,保持清单干净)。"""
70
+ if not name or any(c.isspace() for c in name):
71
+ raise SystemExit("[config] machine name must be non-empty and contain no whitespace (--name)")
72
+ if not ip:
73
+ raise SystemExit("[config] ip is required (--ip, or run with no flags for the interactive wizard)")
74
+ if not user:
75
+ raise SystemExit("[config] ssh user is required (--user, or run with no flags for the interactive wizard)")
76
+ if not 1 <= port <= 65535:
77
+ raise SystemExit(f"[config] port must be 1-65535, got {port}")
78
+ entry = {"name": name, "ip": ip, "os": os_name, "user": user}
79
+ if password:
80
+ entry["password"] = password
81
+ if identity_file:
82
+ entry["identity_file"] = identity_file
83
+ if port != 22:
84
+ entry["port"] = port
85
+ if hostname:
86
+ entry["hostname"] = hostname
87
+ if python:
88
+ entry["python"] = python
89
+ if description:
90
+ entry["description"] = description
91
+ return entry
92
+
93
+
94
+ def _append_machine(path: Path, entry: dict) -> None:
95
+ """追加机器到清单文件;文件已存在但损坏时拒绝写入(绝不覆盖用户数据)。"""
96
+ if path.exists():
97
+ try:
98
+ data = json.loads(path.read_text(encoding="utf-8"))
99
+ except (OSError, ValueError) as e:
100
+ raise SystemExit(f"[config] {path} is not valid JSON ({e}); fix it first with 'rrun config edit'") from None
101
+ if not isinstance(data, dict):
102
+ raise SystemExit(f"[config] {path} must contain a JSON object; fix it first with 'rrun config edit'")
103
+ else:
104
+ data = {}
105
+ machines = data.setdefault("machines", [])
106
+ if not isinstance(machines, list):
107
+ raise SystemExit(f"[config] {path} has a non-list 'machines' field; fix it first with 'rrun config edit'")
108
+ if any(isinstance(m, dict) and m.get("name") == entry["name"] for m in machines):
109
+ raise SystemExit(f"[config] machine [{entry['name']}] already exists in {_shorten(path)}"
110
+ " — edit it with 'rrun config edit'")
111
+ machines.append(entry)
112
+ _write_secure(path, json.dumps(data, ensure_ascii=False, indent=2) + "\n")
113
+
114
+
115
+ def _report_added(path: Path, name: str) -> None:
116
+ """成功提示 + 高优先级来源遮蔽预警。"""
117
+ print(f"[config] added [{name}] to {_shorten(path)}")
118
+ try:
119
+ m = resolve_machine(name)
120
+ except KeyError:
121
+ return
122
+ if m.source != str(path):
123
+ print(f"[config] warning: higher-priority source {m.source} also defines [{name}]"
124
+ " and shadows this entry", file=sys.stderr)
125
+ print(f"[config] next: rrun doctor {name} # connectivity + python check")
126
+
127
+
128
+ def cmd_config_init(ns) -> int:
129
+ """按内嵌模板创建用户级主清单;已存在时拒绝覆盖(--force 除外)。"""
130
+ path = user_inventory_path()
131
+ if path.exists() and not ns.force:
132
+ print(f"[config] {_shorten(path)} already exists (use --force to overwrite)", file=sys.stderr)
133
+ return 1
134
+ _write_secure(path, TEMPLATE_FILE.read_text(encoding="utf-8"))
135
+ perms = "" if os.name == "nt" else ", mode 600"
136
+ print(f"[config] created {_shorten(path)} from the bundled template{perms}")
137
+ print("[config] next: replace the my-* example entries with your machines"
138
+ " — 'rrun config edit' or 'rrun config add'")
139
+ print("[config] then: rrun machines # verify the inventory is picked up (redacted)")
140
+ return 0
141
+
142
+
143
+ def _ask(label: str, default: str = "", secret: bool = False) -> str:
144
+ suffix = f" [{default}]" if default else ""
145
+ prompt = f" {label}{suffix}: "
146
+ value = getpass.getpass(prompt) if secret else input(prompt)
147
+ return value.strip() or default
148
+
149
+
150
+ def _ask_required(label: str, default: str = "") -> str:
151
+ while True:
152
+ value = _ask(label, default)
153
+ if value:
154
+ return value
155
+ print(" (required)")
156
+
157
+
158
+ def _ask_name(default: str = "") -> str:
159
+ while True:
160
+ value = _ask_required("name (unique, no spaces)", default)
161
+ if not any(c.isspace() for c in value):
162
+ return value
163
+ print(" name must not contain whitespace")
164
+
165
+
166
+ def _ask_os(default: str = "Windows") -> str:
167
+ while True:
168
+ value = _ask("os (Windows/Mac/Linux)", default).lower()
169
+ if value in _OS_ALIASES:
170
+ return _OS_ALIASES[value]
171
+ print(" please answer Windows, Mac or Linux (w/m/l)")
172
+
173
+
174
+ def _ask_port(default: int = 22) -> int:
175
+ while True:
176
+ try:
177
+ port = int(_ask("ssh port", str(default)))
178
+ except ValueError:
179
+ print(" port must be a number")
180
+ continue
181
+ if 1 <= port <= 65535:
182
+ return port
183
+ print(" port must be 1-65535")
184
+
185
+
186
+ def _wizard(ns, path: Path) -> "dict | None":
187
+ """交互问答收集机器信息(旗帜值作预填默认);用户放弃时返回 None。"""
188
+ if not sys.stdin.isatty():
189
+ raise SystemExit("[config] interactive add needs a TTY; use flags instead: "
190
+ "rrun config add --name N --ip A --user U [--os Windows] [--password ...]")
191
+ print(f"Add a machine to {_shorten(path)} (Ctrl-C to abort):")
192
+ name = _ask_name(ns.name or "")
193
+ ip = _ask_required("ip", ns.ip or "")
194
+ hostname = _ask("hostname (optional, also resolvable)", ns.hostname or "")
195
+ os_name = _ask_os(ns.os or "Windows")
196
+ user = _ask_required("ssh user", ns.user or "")
197
+ password = _ask("password (empty = key auth)", ns.password or "", secret=True)
198
+ identity_file = ""
199
+ if not password:
200
+ identity_file = _ask("identity_file (optional; empty = default key chain)", ns.identity_file or "")
201
+ port = ns.port if ns.port is not None else _ask_port()
202
+ python = _ask("remote python path (optional, skips auto-detection)", ns.python or "")
203
+ description = _ask("description (optional)", ns.description or "")
204
+ entry = _build_entry(name, ip, os_name, user,
205
+ password=password, hostname=hostname, identity_file=identity_file,
206
+ port=port, python=python, description=description)
207
+ auth = "password" if password else "key"
208
+ print(f"\n {name}: {user}@{ip}:{port} os={os_name} auth={auth}")
209
+ if _ask(f"append to {_shorten(path)}?", "Y").lower() not in ("y", "yes"):
210
+ print("[config] aborted, nothing written")
211
+ return None
212
+ return entry
213
+
214
+
215
+ def cmd_config_add(ns) -> int:
216
+ """追加一台机器:无 --name/--ip 时走交互向导,否则纯旗帜模式。"""
217
+ path = user_inventory_path()
218
+ if ns.interactive or (not ns.name and not ns.ip):
219
+ entry = _wizard(ns, path)
220
+ if entry is None:
221
+ return 1
222
+ else:
223
+ entry = _build_entry(
224
+ ns.name or "", ns.ip or "", ns.os or "Windows", ns.user or "",
225
+ password=ns.password or "", hostname=ns.hostname or "",
226
+ identity_file=ns.identity_file or "", port=ns.port if ns.port is not None else 22,
227
+ python=ns.python or "", description=ns.description or "",
228
+ )
229
+ _append_machine(path, entry)
230
+ _report_added(path, entry["name"])
231
+ return 0
232
+
233
+
234
+ def cmd_config_edit(ns) -> int:
235
+ """用 $VISUAL/$EDITOR 打开用户级主清单(缺失时先按模板创建),退出后校验 JSON。"""
236
+ path = user_inventory_path()
237
+ if not path.exists():
238
+ _write_secure(path, TEMPLATE_FILE.read_text(encoding="utf-8"))
239
+ print(f"[config] created {_shorten(path)} from the bundled template")
240
+ editor = os.environ.get("VISUAL") or os.environ.get("EDITOR") or ("notepad" if os.name == "nt" else "vi")
241
+ rc = subprocess.call(shlex.split(editor) + [str(path)])
242
+ if rc:
243
+ raise SystemExit(f"[config] editor exited with code {rc}")
244
+ try:
245
+ machines = _load_file(path)
246
+ except Exception as e: # noqa: BLE001 - 校验场景需要把原始解析错误带给用户
247
+ print(f"[config] warning: {_shorten(path)} does not parse: {e}", file=sys.stderr)
248
+ return 1
249
+ print(f"[config] {_shorten(path)} ok — {len(machines)} machines")
250
+ return 0
251
+
252
+
253
+ def cmd_config_chain(ns) -> int:
254
+ """无子命令时的默认行为:诊断来源链(即 v0.2.x 的 bare `rrun config`)。"""
255
+ infos = scan_sources()
256
+ print("machines.json source chain (high -> low priority; same-name machines are")
257
+ print("overridden by higher-priority sources):")
258
+ raw_total = 0
259
+ for i, info in enumerate(infos, 1):
260
+ if not info.exists:
261
+ state = "- missing"
262
+ elif info.error:
263
+ state = f"x read failed: {info.error}"
264
+ else:
265
+ state = f"ok, {info.machine_count} machines"
266
+ raw_total += info.machine_count
267
+ print(f" [{i}] {info.label:<22} {info.path} {state}")
268
+ merged = load_machines()
269
+ print(f"{len(merged)} machines total ({raw_total} across all sources before same-name overrides)")
270
+ if not merged:
271
+ print(f"hint: {EMPTY_INVENTORY_HINT}")
272
+ return 0
@@ -352,7 +352,7 @@ def run(host: str, lang: str = "", file: str = "", content: str = "", args=(),
352
352
  py_cmd += " " + " ".join(shlex.quote(a) for a in args)
353
353
  if env or workdir:
354
354
  if machine.is_windows:
355
- 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")
356
356
  remote_cmd = _posix_prefix(workdir, env) + py_cmd
357
357
  else:
358
358
  remote_cmd = py_cmd
@@ -0,0 +1,42 @@
1
+ {
2
+ "defaults": {
3
+ "windows": { "os": "Windows" },
4
+ "mac": { "os": "Mac" },
5
+ "linux": { "os": "Linux" }
6
+ },
7
+ "machines": [
8
+ {
9
+ "name": "my-win-box",
10
+ "ip": "192.168.1.10",
11
+ "os": "Windows",
12
+ "user": "admin",
13
+ "password": "secret",
14
+ "description": "Windows build box"
15
+ },
16
+ {
17
+ "name": "my-mac-mini",
18
+ "hostname": "mac-mini.local",
19
+ "ip": "192.168.1.20",
20
+ "os": "Mac",
21
+ "user": "admin",
22
+ "password": "secret"
23
+ },
24
+ {
25
+ "name": "my-linux",
26
+ "ip": "192.168.1.30",
27
+ "os": "Linux",
28
+ "user": "ubuntu",
29
+ "password": "secret",
30
+ "python": "/usr/bin/python3.12"
31
+ },
32
+ {
33
+ "name": "cloud-vm-key-auth",
34
+ "ip": "1.2.3.4",
35
+ "port": 2222,
36
+ "os": "Linux",
37
+ "user": "root",
38
+ "identity_file": "~/.ssh/id_ed25519",
39
+ "description": "password omitted -> key auth (BatchMode); port overrides 22"
40
+ }
41
+ ]
42
+ }
@@ -23,6 +23,10 @@ from pathlib import Path
23
23
  ENV_MACHINES_JSON = "RRUN_CONFIG"
24
24
  ENV_MACHINES_JSON_LEGACY = "REMOTE_MACHINE_CONFIG"
25
25
 
26
+ # 空清单引导:首次安装(无任何来源)时指给用户的下一步。CLI 多处复用。
27
+ EMPTY_INVENTORY_HINT = ("no machines configured yet — run 'rrun config init' "
28
+ "to create ~/.rrun/machines.json from a bundled template")
29
+
26
30
  _KNOWN_KEYS = {"name", "ip", "user", "password", "os", "hostname", "description", "python", "port", "identity_file"}
27
31
 
28
32
 
@@ -164,4 +168,7 @@ def resolve_machine(host: str, path: "str | os.PathLike | None" = None) -> Machi
164
168
  return m
165
169
  available = ", ".join(f"{m.name}({m.ip})" for m in machines) or "<none>"
166
170
  sources = ", ".join(str(p) for _lbl, p in candidate_sources() if p.is_file()) or "<no sources available>"
167
- raise KeyError(f"machine [{host}] not found. Scanned sources: {sources}. Available: {available}")
171
+ msg = f"machine [{host}] not found. Scanned sources: {sources}. Available: {available}"
172
+ if not machines:
173
+ msg += f" Hint: {EMPTY_INVENTORY_HINT}"
174
+ raise KeyError(msg)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rrun-cli
3
- Version: 0.2.1
3
+ Version: 0.2.3
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
@@ -64,19 +64,13 @@ brew install hudochenkov/sshpass/sshpass # macOS (sshpass is not in homeb
64
64
 
65
65
  ## Quickstart
66
66
 
67
- Create `~/.rrun/machines.json` (full format in [Configuration](#configuration-machinesjson)):
68
-
69
- ```json
70
- {
71
- "machines": [
72
- { "name": "my-win-box", "ip": "192.168.1.10", "os": "Windows", "user": "admin", "password": "secret" }
73
- ]
74
- }
75
- ```
76
-
77
- Then:
67
+ Scaffold the machine inventory, fill in your machines, verify:
78
68
 
79
69
  ```bash
70
+ rrun config init # create ~/.rrun/machines.json from the bundled template (mode 600)
71
+ rrun config edit # open it in $EDITOR — replace the my-* example entries
72
+ # or: rrun config add # interactive wizard that appends one machine
73
+
80
74
  rrun machines # verify the inventory is picked up (redacted)
81
75
  printf 'Write-Output "hello 中文"\n' > demo.ps1
82
76
  rrun exec my-win-box demo.ps1 # powershell, inferred from .ps1
@@ -100,13 +94,13 @@ The full set of hard-won conventions and internals: [docs/remote-exec-convention
100
94
  |---|---|
101
95
  | `rrun exec <host> <script\|-c ...>` | Execute a local script / inline content remotely |
102
96
  | `rrun machines [--json]` | List merged machines (redacted, with source) |
103
- | `rrun config` | Diagnose the machines.json source chain |
97
+ | `rrun config [init\|add\|edit]` | Manage the user inventory (`init` scaffolds from the bundled template, `add` appends via wizard/flags, `edit` opens `$EDITOR`); bare `config` diagnoses the source chain |
104
98
  | `rrun setup <host\|--all> [--force]` | Provision the unified remote Python 3.12 venv (idempotent) |
105
99
  | `rrun pip <host> -- list` | Run pip inside the remote unified venv |
106
100
  | `rrun doctor <host\|--all>` | Health-check ssh + auth + remote python (`--all` opens real connections to every machine) |
107
101
  | `rrun close [<host>\|--all]` | Close ssh ControlMaster multiplexed connections |
108
102
 
109
- Useful `exec` flags: `--lang bash|powershell|python`, `--workdir`, `--env K=V`, `--timeout`, `--python <path>` (skip detection), `--no-mux`, `-q`.
103
+ Useful `exec` flags: `--lang bash|powershell|python`, `--workdir`/`--env K=V` (not for Windows+python), `--timeout`, `--python <path>` (skip detection), `--no-mux`, `-q`.
110
104
 
111
105
  ### Exit codes
112
106
 
@@ -118,7 +112,7 @@ Useful `exec` flags: `--lang bash|powershell|python`, `--workdir`, `--env K=V`,
118
112
 
119
113
  ## Configuration: machines.json
120
114
 
121
- Credentials live in local `machines.json` files see [machines.template.json](machines.template.json):
115
+ Credentials live in local `machines.json` files. `rrun config init` writes the bundled template to `~/.rrun/machines.json` (also browsable at [src/rrun/machines.template.json](src/rrun/machines.template.json)):
122
116
 
123
117
  ```json
124
118
  {
@@ -157,7 +151,7 @@ If none match, run `rrun setup <host>`. It is idempotent and non-destructive: if
157
151
 
158
152
  ## Security
159
153
 
160
- - `machines.json` stores **plaintext passwords**. Keep it local, `chmod 600`, never commit it — or leave `password` empty and use key-based auth instead.
154
+ - `machines.json` stores **plaintext passwords**. Keep it local and never commit it — `rrun config init`/`add` already write the user inventory with mode `600` — or leave `password` empty and use key-based auth instead.
161
155
  - Key-based auth runs ssh with `BatchMode=yes` (no interactive prompts; a missing/unauthorized key fails fast instead of eating the script from stdin).
162
156
  - The audit log never records passwords, and `--env` values are logged as keys only.
163
157
 
@@ -3,8 +3,10 @@ README.md
3
3
  pyproject.toml
4
4
  src/rrun/__init__.py
5
5
  src/rrun/__main__.py
6
+ src/rrun/config_cmd.py
6
7
  src/rrun/doctor.py
7
8
  src/rrun/executor.py
9
+ src/rrun/machines.template.json
8
10
  src/rrun/registry.py
9
11
  src/rrun/remote-requirements.txt
10
12
  src/rrun/setup.py
@@ -15,6 +17,7 @@ src/rrun_cli.egg-info/entry_points.txt
15
17
  src/rrun_cli.egg-info/requires.txt
16
18
  src/rrun_cli.egg-info/top_level.txt
17
19
  tests/test_cli.py
20
+ tests/test_config_cmd.py
18
21
  tests/test_executor.py
19
22
  tests/test_registry.py
20
23
  tests/test_setup.py
@@ -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__}"
@@ -0,0 +1,247 @@
1
+ """config 子命令测试:init 模板落盘/权限、add 旗帜与向导模式、edit、空态引导。"""
2
+
3
+ import argparse
4
+ import getpass
5
+ import json
6
+ import os
7
+ import stat
8
+ from types import SimpleNamespace
9
+
10
+ import pytest
11
+
12
+ from rrun import config_cmd
13
+ from rrun.config_cmd import (
14
+ cmd_config_add,
15
+ cmd_config_chain,
16
+ cmd_config_edit,
17
+ cmd_config_init,
18
+ user_inventory_path,
19
+ )
20
+ from rrun.registry import load_machines, resolve_machine
21
+
22
+
23
+ @pytest.fixture()
24
+ def isolated(monkeypatch, tmp_path):
25
+ """隔离全部来源:HOME 指向临时目录、cwd 切换、两个 env var 清空。"""
26
+ monkeypatch.setenv("RRUN_CONFIG", "")
27
+ monkeypatch.setenv("REMOTE_MACHINE_CONFIG", "")
28
+ home = tmp_path / "home"
29
+ home.mkdir()
30
+ monkeypatch.setenv("HOME", str(home)) # POSIX 下 Path.home() 读 HOME
31
+ cwd = tmp_path / "cwd"
32
+ cwd.mkdir()
33
+ monkeypatch.chdir(cwd)
34
+ return tmp_path
35
+
36
+
37
+ def _ns(**kw):
38
+ return argparse.Namespace(**kw)
39
+
40
+
41
+ def _flag_ns(**kw):
42
+ base = dict(name=None, ip=None, hostname=None, os=None, user=None, password=None,
43
+ identity_file=None, port=None, python=None, description=None, interactive=False)
44
+ base.update(kw)
45
+ return _ns(**base)
46
+
47
+
48
+ def _read_inventory():
49
+ return json.loads(user_inventory_path().read_text(encoding="utf-8"))
50
+
51
+
52
+ class TestInit:
53
+ def test_creates_template_with_secure_perms(self, isolated, capsys):
54
+ assert cmd_config_init(_ns(force=False)) == 0
55
+ path = user_inventory_path()
56
+ assert path == isolated / "home" / ".rrun" / "machines.json"
57
+ data = _read_inventory()
58
+ assert any(m["name"] == "my-win-box" for m in data["machines"])
59
+ if os.name != "nt":
60
+ assert stat.S_IMODE(path.stat().st_mode) == 0o600
61
+ assert stat.S_IMODE(path.parent.stat().st_mode) == 0o700
62
+ assert "config edit" in capsys.readouterr().out
63
+
64
+ def test_refuses_overwrite_without_force(self, isolated, capsys):
65
+ cmd_config_init(_ns(force=False))
66
+ assert cmd_config_init(_ns(force=False)) == 1
67
+ assert "--force" in capsys.readouterr().err
68
+
69
+ def test_force_overwrites(self, isolated):
70
+ cmd_config_init(_ns(force=False))
71
+ user_inventory_path().write_text('{"machines": []}', encoding="utf-8")
72
+ assert cmd_config_init(_ns(force=True)) == 0
73
+ assert _read_inventory()["machines"] # 模板非空
74
+
75
+ def test_template_machines_visible_to_loader(self, isolated):
76
+ cmd_config_init(_ns(force=False))
77
+ assert "my-win-box" in [m.name for m in load_machines()]
78
+
79
+
80
+ class TestAddFlags:
81
+ def test_append_creates_skeleton_file(self, isolated):
82
+ assert cmd_config_add(_flag_ns(name="box", ip="1.2.3.4", user="admin", password="pw")) == 0
83
+ (m,) = _read_inventory()["machines"]
84
+ assert m == {"name": "box", "ip": "1.2.3.4", "os": "Windows", "user": "admin", "password": "pw"}
85
+ if os.name != "nt":
86
+ assert stat.S_IMODE(user_inventory_path().stat().st_mode) == 0o600
87
+
88
+ def test_requires_name_ip_user(self, isolated):
89
+ with pytest.raises(SystemExit, match="--user"):
90
+ cmd_config_add(_flag_ns(name="box", ip="1.2.3.4"))
91
+ with pytest.raises(SystemExit, match="--ip"):
92
+ cmd_config_add(_flag_ns(name="box", user="u"))
93
+
94
+ def test_whitespace_name_rejected(self, isolated):
95
+ with pytest.raises(SystemExit, match="whitespace"):
96
+ cmd_config_add(_flag_ns(name="my box", ip="1.2.3.4", user="u"))
97
+
98
+ def test_bad_port_rejected(self, isolated):
99
+ with pytest.raises(SystemExit, match="1-65535"):
100
+ cmd_config_add(_flag_ns(name="x", ip="1.1.1.1", user="u", port=70000))
101
+
102
+ def test_duplicate_name_rejected(self, isolated):
103
+ cmd_config_add(_flag_ns(name="box", ip="1.2.3.4", user="u"))
104
+ with pytest.raises(SystemExit, match="already exists"):
105
+ cmd_config_add(_flag_ns(name="box", ip="9.9.9.9", user="u"))
106
+
107
+ def test_appends_to_template_preserving_entries(self, isolated):
108
+ cmd_config_init(_ns(force=False))
109
+ cmd_config_add(_flag_ns(name="real", ip="1.2.3.4", user="root", os="Linux",
110
+ port=2222, identity_file="~/.ssh/id_ed25519", description="vm"))
111
+ data = _read_inventory()
112
+ names = [m["name"] for m in data["machines"]]
113
+ assert "my-win-box" in names and "real" in names
114
+ assert data["defaults"] # 模板原有段落保留
115
+ m = next(x for x in data["machines"] if x["name"] == "real")
116
+ assert m == {"name": "real", "ip": "1.2.3.4", "os": "Linux", "user": "root",
117
+ "identity_file": "~/.ssh/id_ed25519", "port": 2222, "description": "vm"}
118
+ # 注册表视角:key 认证 + 端口生效
119
+ resolved = resolve_machine("real")
120
+ assert resolved.port == 2222 and resolved.public_dict()["auth"] == "key"
121
+
122
+ def test_port_22_not_written(self, isolated):
123
+ cmd_config_add(_flag_ns(name="x", ip="1.1.1.1", user="u", port=22))
124
+ assert "port" not in _read_inventory()["machines"][0]
125
+
126
+ def test_broken_target_file_refused(self, isolated):
127
+ path = user_inventory_path()
128
+ path.parent.mkdir(parents=True)
129
+ path.write_text("{broken", encoding="utf-8")
130
+ with pytest.raises(SystemExit, match="not valid JSON"):
131
+ cmd_config_add(_flag_ns(name="x", ip="1.1.1.1", user="u"))
132
+ assert path.read_text(encoding="utf-8") == "{broken" # 未被覆盖
133
+
134
+ def test_shadow_warning_when_cwd_defines_same_name(self, isolated, capsys):
135
+ (isolated / "cwd" / "machines.json").write_text(
136
+ json.dumps({"machines": [{"name": "box", "ip": "9.9.9.9", "user": "cwd"}]}), encoding="utf-8")
137
+ assert cmd_config_add(_flag_ns(name="box", ip="1.2.3.4", user="u")) == 0
138
+ assert "shadows" in capsys.readouterr().err
139
+
140
+
141
+ class TestWizard:
142
+ def _feed(self, monkeypatch, inputs, password=""):
143
+ it = iter(inputs)
144
+ monkeypatch.setattr("builtins.input", lambda prompt="": next(it))
145
+ monkeypatch.setattr(getpass, "getpass", lambda prompt="": password)
146
+ monkeypatch.setattr(config_cmd.sys, "stdin", SimpleNamespace(isatty=lambda: True))
147
+
148
+ def test_full_flow_password_auth(self, isolated, monkeypatch):
149
+ self._feed(monkeypatch, [
150
+ "mybox", # name
151
+ "10.0.0.1", # ip
152
+ "", # hostname
153
+ "l", # os -> Linux
154
+ "ubuntu", # user
155
+ # password 走 getpass mock
156
+ "", # port -> 22
157
+ "", # python
158
+ "test box", # description
159
+ "Y", # confirm
160
+ ], password="sekret")
161
+ assert cmd_config_add(_flag_ns()) == 0
162
+ m = _read_inventory()["machines"][0]
163
+ assert m == {"name": "mybox", "ip": "10.0.0.1", "os": "Linux", "user": "ubuntu",
164
+ "password": "sekret", "description": "test box"}
165
+
166
+ def test_key_auth_prompts_identity_file(self, isolated, monkeypatch):
167
+ self._feed(monkeypatch, [
168
+ "vm", "1.2.3.4", "", "w", "admin",
169
+ "~/.ssh/id_ed25519", # 密码为空才问这一项
170
+ "2222", "", "", "y",
171
+ ], password="")
172
+ assert cmd_config_add(_flag_ns()) == 0
173
+ m = _read_inventory()["machines"][0]
174
+ assert m["identity_file"] == "~/.ssh/id_ed25519" and m["port"] == 2222
175
+ assert "password" not in m
176
+
177
+ def test_flags_prefill_wizard_defaults(self, isolated, monkeypatch):
178
+ # name/ip/hostname/os/user 由旗帜预填或回车,port 先输非法值触发重问再回车
179
+ self._feed(monkeypatch, ["", "", "", "", "", "s3cr3t-port", "", "", "", "y"], password="pw")
180
+ ns = _flag_ns(name="prefilled", ip="2.2.2.2", os="Mac", user="macuser")
181
+ assert cmd_config_add(ns) == 0
182
+ m = _read_inventory()["machines"][0]
183
+ assert m["name"] == "prefilled" and m["os"] == "Mac" and m["user"] == "macuser"
184
+ # port 交互输入非法值 -> 重问?"s3cr3t-port" 非数字会触发重问,下一个输入 "" -> 22
185
+ assert "port" not in m
186
+
187
+ def test_abort_writes_nothing(self, isolated, monkeypatch):
188
+ self._feed(monkeypatch, ["x", "1.1.1.1", "", "w", "u", "", "", "", "n"], password="pw")
189
+ assert cmd_config_add(_flag_ns()) == 1
190
+ assert not user_inventory_path().exists()
191
+
192
+ def test_non_tty_requires_flags(self, isolated, monkeypatch):
193
+ monkeypatch.setattr(config_cmd.sys, "stdin", SimpleNamespace(isatty=lambda: False))
194
+ with pytest.raises(SystemExit, match="TTY"):
195
+ cmd_config_add(_flag_ns())
196
+
197
+ def test_os_reprompts_on_garbage(self, isolated, monkeypatch):
198
+ self._feed(monkeypatch, ["x", "1.1.1.1", "", "solaris", "m", "u", "", "", "", "", "y"], password="")
199
+ assert cmd_config_add(_flag_ns()) == 0
200
+ assert _read_inventory()["machines"][0]["os"] == "Mac"
201
+
202
+
203
+ class TestEdit:
204
+ def test_missing_file_bootstraps_template(self, isolated, monkeypatch, capsys):
205
+ calls = []
206
+ monkeypatch.setattr(config_cmd.subprocess, "call", lambda cmd: calls.append(cmd) or 0)
207
+ monkeypatch.delenv("VISUAL", raising=False)
208
+ monkeypatch.setenv("EDITOR", "my-editor --wait")
209
+ assert cmd_config_edit(_ns()) == 0
210
+ path = user_inventory_path()
211
+ assert path.exists() and calls == [["my-editor", "--wait", str(path)]]
212
+ assert "created" in capsys.readouterr().out
213
+
214
+ def test_invalid_json_after_edit_warns(self, isolated, monkeypatch, capsys):
215
+ path = user_inventory_path()
216
+
217
+ def fake_editor(cmd):
218
+ path.write_text("{oops", encoding="utf-8")
219
+ return 0
220
+
221
+ monkeypatch.setattr(config_cmd.subprocess, "call", fake_editor)
222
+ monkeypatch.delenv("VISUAL", raising=False)
223
+ monkeypatch.setenv("EDITOR", "true")
224
+ assert cmd_config_edit(_ns()) == 1
225
+ assert "does not parse" in capsys.readouterr().err
226
+
227
+ def test_editor_nonzero_exit(self, isolated, monkeypatch):
228
+ monkeypatch.setattr(config_cmd.subprocess, "call", lambda cmd: 130)
229
+ monkeypatch.delenv("VISUAL", raising=False)
230
+ monkeypatch.setenv("EDITOR", "true")
231
+ with pytest.raises(SystemExit, match="130"):
232
+ cmd_config_edit(_ns())
233
+
234
+
235
+ class TestEmptyStateHints:
236
+ def test_chain_diagnose_hint(self, isolated, capsys):
237
+ assert cmd_config_chain(_ns()) == 0
238
+ assert "rrun config init" in capsys.readouterr().out
239
+
240
+ def test_machines_hint(self, isolated, capsys):
241
+ import rrun.__main__ as cli
242
+ assert cli.cmd_machines(_ns(json=False)) == 0
243
+ assert "rrun config init" in capsys.readouterr().err
244
+
245
+ def test_resolve_machine_hint(self, isolated):
246
+ with pytest.raises(KeyError, match="rrun config init"):
247
+ resolve_machine("nope")
File without changes
File without changes
File without changes
File without changes
File without changes