rrun-cli 0.2.2__tar.gz → 0.2.4__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.2.2 → rrun_cli-0.2.4}/PKG-INFO +10 -16
- {rrun_cli-0.2.2 → rrun_cli-0.2.4}/README.md +9 -15
- {rrun_cli-0.2.2 → rrun_cli-0.2.4}/pyproject.toml +2 -2
- {rrun_cli-0.2.2 → rrun_cli-0.2.4}/src/rrun/__init__.py +1 -1
- {rrun_cli-0.2.2 → rrun_cli-0.2.4}/src/rrun/__main__.py +37 -26
- rrun_cli-0.2.4/src/rrun/config_cmd.py +283 -0
- rrun_cli-0.2.4/src/rrun/machines.d.README.md +16 -0
- rrun_cli-0.2.4/src/rrun/machines.template.json +43 -0
- {rrun_cli-0.2.2 → rrun_cli-0.2.4}/src/rrun/registry.py +27 -7
- {rrun_cli-0.2.2 → rrun_cli-0.2.4}/src/rrun_cli.egg-info/PKG-INFO +10 -16
- {rrun_cli-0.2.2 → rrun_cli-0.2.4}/src/rrun_cli.egg-info/SOURCES.txt +4 -0
- rrun_cli-0.2.4/tests/test_config_cmd.py +316 -0
- {rrun_cli-0.2.2 → rrun_cli-0.2.4}/LICENSE +0 -0
- {rrun_cli-0.2.2 → rrun_cli-0.2.4}/setup.cfg +0 -0
- {rrun_cli-0.2.2 → rrun_cli-0.2.4}/src/rrun/doctor.py +0 -0
- {rrun_cli-0.2.2 → rrun_cli-0.2.4}/src/rrun/executor.py +0 -0
- {rrun_cli-0.2.2 → rrun_cli-0.2.4}/src/rrun/remote-requirements.txt +0 -0
- {rrun_cli-0.2.2 → rrun_cli-0.2.4}/src/rrun/setup.py +0 -0
- {rrun_cli-0.2.2 → rrun_cli-0.2.4}/src/rrun_cli.egg-info/dependency_links.txt +0 -0
- {rrun_cli-0.2.2 → rrun_cli-0.2.4}/src/rrun_cli.egg-info/entry_points.txt +0 -0
- {rrun_cli-0.2.2 → rrun_cli-0.2.4}/src/rrun_cli.egg-info/requires.txt +0 -0
- {rrun_cli-0.2.2 → rrun_cli-0.2.4}/src/rrun_cli.egg-info/top_level.txt +0 -0
- {rrun_cli-0.2.2 → rrun_cli-0.2.4}/tests/test_cli.py +0 -0
- {rrun_cli-0.2.2 → rrun_cli-0.2.4}/tests/test_executor.py +0 -0
- {rrun_cli-0.2.2 → rrun_cli-0.2.4}/tests/test_registry.py +0 -0
- {rrun_cli-0.2.2 → rrun_cli-0.2.4}/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.
|
|
3
|
+
Version: 0.2.4
|
|
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
|
-
|
|
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,7 +94,7 @@ 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` |
|
|
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) |
|
|
@@ -118,7 +112,7 @@ Useful `exec` flags: `--lang bash|powershell|python`, `--workdir`/`--env K=V` (n
|
|
|
118
112
|
|
|
119
113
|
## Configuration: machines.json
|
|
120
114
|
|
|
121
|
-
Credentials live in local `machines.json` files
|
|
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
|
{
|
|
@@ -140,7 +134,7 @@ Sources are merged by machine name, highest priority first (all optional, failur
|
|
|
140
134
|
2. `$REMOTE_MACHINE_CONFIG` (legacy name, still honored)
|
|
141
135
|
3. `./machines.json` (current working directory)
|
|
142
136
|
4. `~/.rrun/machines.json`
|
|
143
|
-
5. `~/.rrun/machines.d/*.json` (sorted by filename — point each inventory at its own file/symlink)
|
|
137
|
+
5. `~/.rrun/machines.d/*.json` (sorted by filename — point each inventory at its own file/symlink; `rrun config init` creates this directory with a README inside)
|
|
144
138
|
6. Legacy: `~/.remote-machine/machines.json` (POSIX) / `C:\tools\remote-machine\machines.json` (Windows)
|
|
145
139
|
|
|
146
140
|
Per-file `defaults` sections (keyed by `os`, lowercased) are applied before merging. Inspect the chain with `rrun config`; list machines (redacted) with `rrun machines`.
|
|
@@ -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
|
|
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
|
-
|
|
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,7 +66,7 @@ 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` |
|
|
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) |
|
|
@@ -90,7 +84,7 @@ Useful `exec` flags: `--lang bash|powershell|python`, `--workdir`/`--env K=V` (n
|
|
|
90
84
|
|
|
91
85
|
## Configuration: machines.json
|
|
92
86
|
|
|
93
|
-
Credentials live in local `machines.json` files
|
|
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
|
{
|
|
@@ -112,7 +106,7 @@ Sources are merged by machine name, highest priority first (all optional, failur
|
|
|
112
106
|
2. `$REMOTE_MACHINE_CONFIG` (legacy name, still honored)
|
|
113
107
|
3. `./machines.json` (current working directory)
|
|
114
108
|
4. `~/.rrun/machines.json`
|
|
115
|
-
5. `~/.rrun/machines.d/*.json` (sorted by filename — point each inventory at its own file/symlink)
|
|
109
|
+
5. `~/.rrun/machines.d/*.json` (sorted by filename — point each inventory at its own file/symlink; `rrun config init` creates this directory with a README inside)
|
|
116
110
|
6. Legacy: `~/.remote-machine/machines.json` (POSIX) / `C:\tools\remote-machine\machines.json` (Windows)
|
|
117
111
|
|
|
118
112
|
Per-file `defaults` sections (keyed by `os`, lowercased) are applied before merging. Inspect the chain with `rrun config`; list machines (redacted) with `rrun machines`.
|
|
@@ -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
|
|
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.
|
|
7
|
+
version = "0.2.4"
|
|
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", "machines.d.README.md"]
|
|
49
49
|
|
|
50
50
|
[tool.pytest.ini_options]
|
|
51
51
|
testpaths = ["tests"]
|
|
@@ -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:
|
|
@@ -42,9 +43,10 @@ import time
|
|
|
42
43
|
from pathlib import Path
|
|
43
44
|
|
|
44
45
|
from . import __version__
|
|
46
|
+
from .config_cmd import cmd_config_add, cmd_config_chain, cmd_config_edit, cmd_config_init
|
|
45
47
|
from .doctor import check_machine
|
|
46
48
|
from .executor import RRUN_HOME, _check_ascii, _ssh_run, close_mux, run
|
|
47
|
-
from .registry import load_machines, resolve_machine
|
|
49
|
+
from .registry import EMPTY_INVENTORY_HINT, load_machines, resolve_machine
|
|
48
50
|
from .setup import setup_machine, venv_python_or_die
|
|
49
51
|
|
|
50
52
|
INLINE_DROP_DIR = RRUN_HOME / "drops"
|
|
@@ -138,29 +140,12 @@ def cmd_machines(ns) -> int:
|
|
|
138
140
|
machines = load_machines()
|
|
139
141
|
if ns.json:
|
|
140
142
|
print(json.dumps([m.public_dict() for m in machines], ensure_ascii=False, indent=2))
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
def cmd_config(ns) -> int:
|
|
149
|
-
infos = scan_sources()
|
|
150
|
-
print("machines.json source chain (high -> low priority; same-name machines are")
|
|
151
|
-
print("overridden by higher-priority sources):")
|
|
152
|
-
raw_total = 0
|
|
153
|
-
for i, info in enumerate(infos, 1):
|
|
154
|
-
if not info.exists:
|
|
155
|
-
state = "- missing"
|
|
156
|
-
elif info.error:
|
|
157
|
-
state = f"x read failed: {info.error}"
|
|
158
|
-
else:
|
|
159
|
-
state = f"ok, {info.machine_count} machines"
|
|
160
|
-
raw_total += info.machine_count
|
|
161
|
-
print(f" [{i}] {info.label:<22} {info.path} {state}")
|
|
162
|
-
merged = load_machines()
|
|
163
|
-
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)
|
|
164
149
|
return 0
|
|
165
150
|
|
|
166
151
|
|
|
@@ -303,8 +288,34 @@ def main() -> None:
|
|
|
303
288
|
mp.add_argument("--json", action="store_true")
|
|
304
289
|
mp.set_defaults(func=cmd_machines)
|
|
305
290
|
|
|
306
|
-
cf = sub.add_parser("config", help="
|
|
307
|
-
|
|
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)
|
|
308
319
|
|
|
309
320
|
sp = sub.add_parser("setup", help="provision the unified remote python environment (3.12 venv)")
|
|
310
321
|
sp.add_argument("host", nargs="?", help="machine name/ip; use --all for every machine")
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
"""`rrun config` 子命令组:机器清单的初始化 / 追加 / 编辑 + 来源链诊断。
|
|
2
|
+
|
|
3
|
+
设计要点:
|
|
4
|
+
- init/add/edit 只操作「用户级主清单」,路径取 registry.user_inventory_path()(单一
|
|
5
|
+
真相源);不得在来源链上按 label 反查——cwd/env 来源与之同路径时去重会丢弃
|
|
6
|
+
user 条目。其他来源(env/cwd/machines.d/legacy)只读不动。
|
|
7
|
+
- init 顺带创建 machines.d/ 并内置 README.md(.d 模式的可发现性传统:目录自带说明)。
|
|
8
|
+
- 模板与 README 内嵌为 package data,离线可用、随发行版本锁定;写盘即收紧权限
|
|
9
|
+
(目录 700 / 文件 600,明文密码场景,Windows chmod 语义有限跳过)。
|
|
10
|
+
- add 双模式:不给 --name/--ip → 交互向导(getpass 隐藏密码,旗帜值作预填默认);
|
|
11
|
+
给了 → 纯旗帜模式(可脚本化);-i 强制向导。
|
|
12
|
+
- edit 走 $VISUAL/$EDITOR(Windows 回退 notepad,POSIX 回退 vi),退出后校验 JSON;
|
|
13
|
+
文件缺失时先按模板创建(crontab -e 风格)。
|
|
14
|
+
- 无子命令时(bare `rrun config`)保持 v0.2.x 行为:诊断来源链;空清单附 init 引导。
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import getpass
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import shlex
|
|
21
|
+
import subprocess
|
|
22
|
+
import sys
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
from .registry import (
|
|
26
|
+
EMPTY_INVENTORY_HINT,
|
|
27
|
+
_load_file,
|
|
28
|
+
load_machines,
|
|
29
|
+
resolve_machine,
|
|
30
|
+
scan_sources,
|
|
31
|
+
user_inventory_path,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
TEMPLATE_FILE = Path(__file__).resolve().with_name("machines.template.json")
|
|
35
|
+
MACHINES_D_README = Path(__file__).resolve().with_name("machines.d.README.md")
|
|
36
|
+
|
|
37
|
+
_OS_ALIASES = {
|
|
38
|
+
"w": "Windows", "windows": "Windows",
|
|
39
|
+
"m": "Mac", "mac": "Mac", "macos": "Mac", "osx": "Mac", "darwin": "Mac",
|
|
40
|
+
"l": "Linux", "linux": "Linux",
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _shorten(path: Path) -> str:
|
|
45
|
+
"""显示用:home 前缀折叠为 ~。"""
|
|
46
|
+
s = str(path)
|
|
47
|
+
home = str(Path.home())
|
|
48
|
+
return "~" + s[len(home):] if s.startswith(home) else s
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _write_secure(path: Path, text: str) -> None:
|
|
52
|
+
"""写清单并收紧权限(目录 700 / 文件 600;Windows 上跳过 chmod)。"""
|
|
53
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
54
|
+
path.write_text(text, encoding="utf-8")
|
|
55
|
+
if os.name != "nt":
|
|
56
|
+
os.chmod(path.parent, 0o700)
|
|
57
|
+
os.chmod(path, 0o600)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _build_entry(name: str, ip: str, os_name: str, user: str, *,
|
|
61
|
+
password: str = "", hostname: str = "", identity_file: str = "",
|
|
62
|
+
port: int = 22, python: str = "", description: str = "") -> dict:
|
|
63
|
+
"""组装机器条目并校验必填(空可选字段不落盘,保持清单干净)。"""
|
|
64
|
+
if not name or any(c.isspace() for c in name):
|
|
65
|
+
raise SystemExit("[config] machine name must be non-empty and contain no whitespace (--name)")
|
|
66
|
+
if not ip:
|
|
67
|
+
raise SystemExit("[config] ip is required (--ip, or run with no flags for the interactive wizard)")
|
|
68
|
+
if not user:
|
|
69
|
+
raise SystemExit("[config] ssh user is required (--user, or run with no flags for the interactive wizard)")
|
|
70
|
+
if not 1 <= port <= 65535:
|
|
71
|
+
raise SystemExit(f"[config] port must be 1-65535, got {port}")
|
|
72
|
+
entry = {"name": name, "ip": ip, "os": os_name, "user": user}
|
|
73
|
+
if password:
|
|
74
|
+
entry["password"] = password
|
|
75
|
+
if identity_file:
|
|
76
|
+
entry["identity_file"] = identity_file
|
|
77
|
+
if port != 22:
|
|
78
|
+
entry["port"] = port
|
|
79
|
+
if hostname:
|
|
80
|
+
entry["hostname"] = hostname
|
|
81
|
+
if python:
|
|
82
|
+
entry["python"] = python
|
|
83
|
+
if description:
|
|
84
|
+
entry["description"] = description
|
|
85
|
+
return entry
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _append_machine(path: Path, entry: dict) -> None:
|
|
89
|
+
"""追加机器到清单文件;文件已存在但损坏时拒绝写入(绝不覆盖用户数据)。"""
|
|
90
|
+
if path.exists():
|
|
91
|
+
try:
|
|
92
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
93
|
+
except (OSError, ValueError) as e:
|
|
94
|
+
raise SystemExit(f"[config] {path} is not valid JSON ({e}); fix it first with 'rrun config edit'") from None
|
|
95
|
+
if not isinstance(data, dict):
|
|
96
|
+
raise SystemExit(f"[config] {path} must contain a JSON object; fix it first with 'rrun config edit'")
|
|
97
|
+
else:
|
|
98
|
+
data = {}
|
|
99
|
+
machines = data.setdefault("machines", [])
|
|
100
|
+
if not isinstance(machines, list):
|
|
101
|
+
raise SystemExit(f"[config] {path} has a non-list 'machines' field; fix it first with 'rrun config edit'")
|
|
102
|
+
if any(isinstance(m, dict) and m.get("name") == entry["name"] for m in machines):
|
|
103
|
+
raise SystemExit(f"[config] machine [{entry['name']}] already exists in {_shorten(path)}"
|
|
104
|
+
" — edit it with 'rrun config edit'")
|
|
105
|
+
machines.append(entry)
|
|
106
|
+
_write_secure(path, json.dumps(data, ensure_ascii=False, indent=2) + "\n")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _report_added(path: Path, name: str) -> None:
|
|
110
|
+
"""成功提示 + 高优先级来源遮蔽预警。"""
|
|
111
|
+
print(f"[config] added [{name}] to {_shorten(path)}")
|
|
112
|
+
try:
|
|
113
|
+
m = resolve_machine(name)
|
|
114
|
+
except KeyError:
|
|
115
|
+
return
|
|
116
|
+
if m.source != str(path):
|
|
117
|
+
print(f"[config] warning: higher-priority source {m.source} also defines [{name}]"
|
|
118
|
+
" and shadows this entry", file=sys.stderr)
|
|
119
|
+
print(f"[config] next: rrun doctor {name} # connectivity + python check")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _ensure_machines_d() -> Path:
|
|
123
|
+
"""创建 machines.d/ 并内置 README(幂等;README 已存在不覆盖——用户可能改过)。"""
|
|
124
|
+
d = user_inventory_path().parent / "machines.d"
|
|
125
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
126
|
+
if os.name != "nt":
|
|
127
|
+
os.chmod(d, 0o700)
|
|
128
|
+
readme = d / "README.md"
|
|
129
|
+
if not readme.exists():
|
|
130
|
+
_write_secure(readme, MACHINES_D_README.read_text(encoding="utf-8"))
|
|
131
|
+
return d
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def cmd_config_init(ns) -> int:
|
|
135
|
+
"""按内嵌模板创建用户级主清单 + machines.d/;已存在时拒绝覆盖(--force 除外)。"""
|
|
136
|
+
path = user_inventory_path()
|
|
137
|
+
if path.exists() and not ns.force:
|
|
138
|
+
print(f"[config] {_shorten(path)} already exists (use --force to overwrite)", file=sys.stderr)
|
|
139
|
+
return 1
|
|
140
|
+
_write_secure(path, TEMPLATE_FILE.read_text(encoding="utf-8"))
|
|
141
|
+
d = _ensure_machines_d()
|
|
142
|
+
perms = "" if os.name == "nt" else ", mode 600"
|
|
143
|
+
print(f"[config] created {_shorten(path)} from the bundled template{perms}")
|
|
144
|
+
print(f"[config] machines.d ready at {_shorten(d)}/ — drop extra *.json inventories there"
|
|
145
|
+
" (see its README.md)")
|
|
146
|
+
print("[config] next: replace the my-* example entries with your machines"
|
|
147
|
+
" — 'rrun config edit' or 'rrun config add'")
|
|
148
|
+
print("[config] then: rrun machines # verify the inventory is picked up (redacted)")
|
|
149
|
+
return 0
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _ask(label: str, default: str = "", secret: bool = False) -> str:
|
|
153
|
+
suffix = f" [{default}]" if default else ""
|
|
154
|
+
prompt = f" {label}{suffix}: "
|
|
155
|
+
value = getpass.getpass(prompt) if secret else input(prompt)
|
|
156
|
+
return value.strip() or default
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _ask_required(label: str, default: str = "") -> str:
|
|
160
|
+
while True:
|
|
161
|
+
value = _ask(label, default)
|
|
162
|
+
if value:
|
|
163
|
+
return value
|
|
164
|
+
print(" (required)")
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _ask_name(default: str = "") -> str:
|
|
168
|
+
while True:
|
|
169
|
+
value = _ask_required("name (unique, no spaces)", default)
|
|
170
|
+
if not any(c.isspace() for c in value):
|
|
171
|
+
return value
|
|
172
|
+
print(" name must not contain whitespace")
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _ask_os(default: str = "Windows") -> str:
|
|
176
|
+
while True:
|
|
177
|
+
value = _ask("os (Windows/Mac/Linux)", default).lower()
|
|
178
|
+
if value in _OS_ALIASES:
|
|
179
|
+
return _OS_ALIASES[value]
|
|
180
|
+
print(" please answer Windows, Mac or Linux (w/m/l)")
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _ask_port(default: int = 22) -> int:
|
|
184
|
+
while True:
|
|
185
|
+
try:
|
|
186
|
+
port = int(_ask("ssh port", str(default)))
|
|
187
|
+
except ValueError:
|
|
188
|
+
print(" port must be a number")
|
|
189
|
+
continue
|
|
190
|
+
if 1 <= port <= 65535:
|
|
191
|
+
return port
|
|
192
|
+
print(" port must be 1-65535")
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _wizard(ns, path: Path) -> "dict | None":
|
|
196
|
+
"""交互问答收集机器信息(旗帜值作预填默认);用户放弃时返回 None。"""
|
|
197
|
+
if not sys.stdin.isatty():
|
|
198
|
+
raise SystemExit("[config] interactive add needs a TTY; use flags instead: "
|
|
199
|
+
"rrun config add --name N --ip A --user U [--os Windows] [--password ...]")
|
|
200
|
+
print(f"Add a machine to {_shorten(path)} (Ctrl-C to abort):")
|
|
201
|
+
name = _ask_name(ns.name or "")
|
|
202
|
+
ip = _ask_required("ip", ns.ip or "")
|
|
203
|
+
hostname = _ask("hostname (optional, also resolvable)", ns.hostname or "")
|
|
204
|
+
os_name = _ask_os(ns.os or "Windows")
|
|
205
|
+
user = _ask_required("ssh user", ns.user or "")
|
|
206
|
+
password = _ask("password (empty = key auth)", ns.password or "", secret=True)
|
|
207
|
+
identity_file = ""
|
|
208
|
+
if not password:
|
|
209
|
+
identity_file = _ask("identity_file (optional; empty = default key chain)", ns.identity_file or "")
|
|
210
|
+
port = ns.port if ns.port is not None else _ask_port()
|
|
211
|
+
python = _ask("remote python path (optional, skips auto-detection)", ns.python or "")
|
|
212
|
+
description = _ask("description (optional)", ns.description or "")
|
|
213
|
+
entry = _build_entry(name, ip, os_name, user,
|
|
214
|
+
password=password, hostname=hostname, identity_file=identity_file,
|
|
215
|
+
port=port, python=python, description=description)
|
|
216
|
+
auth = "password" if password else "key"
|
|
217
|
+
print(f"\n {name}: {user}@{ip}:{port} os={os_name} auth={auth}")
|
|
218
|
+
if _ask(f"append to {_shorten(path)}?", "Y").lower() not in ("y", "yes"):
|
|
219
|
+
print("[config] aborted, nothing written")
|
|
220
|
+
return None
|
|
221
|
+
return entry
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def cmd_config_add(ns) -> int:
|
|
225
|
+
"""追加一台机器:无 --name/--ip 时走交互向导,否则纯旗帜模式。"""
|
|
226
|
+
path = user_inventory_path()
|
|
227
|
+
if ns.interactive or (not ns.name and not ns.ip):
|
|
228
|
+
entry = _wizard(ns, path)
|
|
229
|
+
if entry is None:
|
|
230
|
+
return 1
|
|
231
|
+
else:
|
|
232
|
+
entry = _build_entry(
|
|
233
|
+
ns.name or "", ns.ip or "", ns.os or "Windows", ns.user or "",
|
|
234
|
+
password=ns.password or "", hostname=ns.hostname or "",
|
|
235
|
+
identity_file=ns.identity_file or "", port=ns.port if ns.port is not None else 22,
|
|
236
|
+
python=ns.python or "", description=ns.description or "",
|
|
237
|
+
)
|
|
238
|
+
_append_machine(path, entry)
|
|
239
|
+
_report_added(path, entry["name"])
|
|
240
|
+
return 0
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def cmd_config_edit(ns) -> int:
|
|
244
|
+
"""用 $VISUAL/$EDITOR 打开用户级主清单(缺失时先按模板创建),退出后校验 JSON。"""
|
|
245
|
+
path = user_inventory_path()
|
|
246
|
+
if not path.exists():
|
|
247
|
+
_write_secure(path, TEMPLATE_FILE.read_text(encoding="utf-8"))
|
|
248
|
+
print(f"[config] created {_shorten(path)} from the bundled template")
|
|
249
|
+
editor = os.environ.get("VISUAL") or os.environ.get("EDITOR") or ("notepad" if os.name == "nt" else "vi")
|
|
250
|
+
rc = subprocess.call(shlex.split(editor) + [str(path)])
|
|
251
|
+
if rc:
|
|
252
|
+
raise SystemExit(f"[config] editor exited with code {rc}")
|
|
253
|
+
try:
|
|
254
|
+
machines = _load_file(path)
|
|
255
|
+
except Exception as e: # noqa: BLE001 - 校验场景需要把原始解析错误带给用户
|
|
256
|
+
print(f"[config] warning: {_shorten(path)} does not parse: {e}", file=sys.stderr)
|
|
257
|
+
return 1
|
|
258
|
+
print(f"[config] {_shorten(path)} ok — {len(machines)} machines")
|
|
259
|
+
return 0
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def cmd_config_chain(ns) -> int:
|
|
263
|
+
"""无子命令时的默认行为:诊断来源链(即 v0.2.x 的 bare `rrun config`)。"""
|
|
264
|
+
infos = scan_sources()
|
|
265
|
+
print("machines.json source chain (high -> low priority; same-name machines are")
|
|
266
|
+
print("overridden by higher-priority sources):")
|
|
267
|
+
raw_total = 0
|
|
268
|
+
for i, info in enumerate(infos, 1):
|
|
269
|
+
if info.path.is_dir():
|
|
270
|
+
state = "dir exists, no *.json files"
|
|
271
|
+
elif not info.exists:
|
|
272
|
+
state = "- missing"
|
|
273
|
+
elif info.error:
|
|
274
|
+
state = f"x read failed: {info.error}"
|
|
275
|
+
else:
|
|
276
|
+
state = f"ok, {info.machine_count} machines"
|
|
277
|
+
raw_total += info.machine_count
|
|
278
|
+
print(f" [{i}] {info.label:<22} {info.path} {state}")
|
|
279
|
+
merged = load_machines()
|
|
280
|
+
print(f"{len(merged)} machines total ({raw_total} across all sources before same-name overrides)")
|
|
281
|
+
if not merged:
|
|
282
|
+
print(f"hint: {EMPTY_INVENTORY_HINT}")
|
|
283
|
+
return 0
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# machines.d — additional machine inventories
|
|
2
|
+
|
|
3
|
+
Drop extra inventories here as `*.json` files (same format as
|
|
4
|
+
`~/.rrun/machines.json`). They are merged with the main inventory
|
|
5
|
+
by machine name:
|
|
6
|
+
|
|
7
|
+
- `~/.rrun/machines.json` wins on name conflicts (higher priority).
|
|
8
|
+
- Among `machines.d` files, filename order decides
|
|
9
|
+
(`10-*.json` beats `90-*.json`).
|
|
10
|
+
|
|
11
|
+
Symlinks are welcome — one inventory per file, e.g.:
|
|
12
|
+
|
|
13
|
+
ln -s ~/src/bot_home/config/machines.json ~/.rrun/machines.d/bot_home.json
|
|
14
|
+
|
|
15
|
+
Only `*.json` files are read; this README is ignored.
|
|
16
|
+
Full format: https://github.com/waqiju/rrun#configuration-machinesjson
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"defaults": {
|
|
3
|
+
"windows": { "os": "Windows" },
|
|
4
|
+
"mac": { "os": "Mac" },
|
|
5
|
+
"linux": { "os": "Linux" }
|
|
6
|
+
},
|
|
7
|
+
"_hint": "more inventories: drop *.json into ~/.rrun/machines.d/ (see its README.md); docs: https://github.com/waqiju/rrun",
|
|
8
|
+
"machines": [
|
|
9
|
+
{
|
|
10
|
+
"name": "my-win-box",
|
|
11
|
+
"ip": "192.168.1.10",
|
|
12
|
+
"os": "Windows",
|
|
13
|
+
"user": "admin",
|
|
14
|
+
"password": "secret",
|
|
15
|
+
"description": "Windows build box"
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"name": "my-mac-mini",
|
|
19
|
+
"hostname": "mac-mini.local",
|
|
20
|
+
"ip": "192.168.1.20",
|
|
21
|
+
"os": "Mac",
|
|
22
|
+
"user": "admin",
|
|
23
|
+
"password": "secret"
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
"name": "my-linux",
|
|
27
|
+
"ip": "192.168.1.30",
|
|
28
|
+
"os": "Linux",
|
|
29
|
+
"user": "ubuntu",
|
|
30
|
+
"password": "secret",
|
|
31
|
+
"python": "/usr/bin/python3.12"
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
"name": "cloud-vm-key-auth",
|
|
35
|
+
"ip": "1.2.3.4",
|
|
36
|
+
"port": 2222,
|
|
37
|
+
"os": "Linux",
|
|
38
|
+
"user": "root",
|
|
39
|
+
"identity_file": "~/.ssh/id_ed25519",
|
|
40
|
+
"description": "password omitted -> key auth (BatchMode); port overrides 22"
|
|
41
|
+
}
|
|
42
|
+
]
|
|
43
|
+
}
|
|
@@ -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
|
|
|
@@ -72,6 +76,15 @@ class SourceInfo:
|
|
|
72
76
|
error: str = ""
|
|
73
77
|
|
|
74
78
|
|
|
79
|
+
def user_inventory_path() -> Path:
|
|
80
|
+
"""用户级主清单路径(单一真相源:candidate_sources 的 user 条目、config init/add/edit 共用)。
|
|
81
|
+
|
|
82
|
+
注意:不要从 candidate_sources() 的结果里按 label 反查这个路径——当 cwd 恰好是
|
|
83
|
+
~/.rrun 或 $RRUN_CONFIG 指向同一文件时,去重会丢弃 user 条目(v0.2.3 的教训)。
|
|
84
|
+
"""
|
|
85
|
+
return Path.home() / ".rrun" / "machines.json"
|
|
86
|
+
|
|
87
|
+
|
|
75
88
|
def candidate_sources() -> "list[tuple[str, Path]]":
|
|
76
89
|
"""返回 (标签, 路径) 有序来源链(高→低优先级),按 resolved path 去重。"""
|
|
77
90
|
cands: list[tuple[str, Path]] = []
|
|
@@ -82,12 +95,14 @@ def candidate_sources() -> "list[tuple[str, Path]]":
|
|
|
82
95
|
label = f"${env_name}" if i == 0 else f"${env_name}[{i}]"
|
|
83
96
|
cands.append((label, Path(p).expanduser()))
|
|
84
97
|
cands.append(("cwd", Path.cwd() / "machines.json"))
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
machines_d = rrun_home / "machines.d"
|
|
98
|
+
cands.append(("user", user_inventory_path()))
|
|
99
|
+
machines_d = user_inventory_path().parent / "machines.d"
|
|
88
100
|
if machines_d.is_dir():
|
|
89
|
-
|
|
90
|
-
|
|
101
|
+
files = sorted(machines_d.glob("*.json"))
|
|
102
|
+
if files:
|
|
103
|
+
cands.extend(("user.d", p) for p in files)
|
|
104
|
+
else:
|
|
105
|
+
cands.append(("user.d", machines_d)) # 空目录也入链:让诊断能看到它存在(merge 时 is_file 跳过)
|
|
91
106
|
if os.name == "nt":
|
|
92
107
|
cands.append(("legacy", Path(r"C:\tools\remote-machine\machines.json")))
|
|
93
108
|
else:
|
|
@@ -146,7 +161,9 @@ def scan_sources() -> "list[SourceInfo]":
|
|
|
146
161
|
infos = []
|
|
147
162
|
for label, p in candidate_sources():
|
|
148
163
|
info = SourceInfo(label=label, path=p)
|
|
149
|
-
if p.
|
|
164
|
+
if p.is_dir():
|
|
165
|
+
info.exists = True # 目录型来源(空 machines.d):无可读机器,仅示意存在
|
|
166
|
+
elif p.is_file():
|
|
150
167
|
info.exists = True
|
|
151
168
|
try:
|
|
152
169
|
info.machine_count = len(_load_file(p))
|
|
@@ -164,4 +181,7 @@ def resolve_machine(host: str, path: "str | os.PathLike | None" = None) -> Machi
|
|
|
164
181
|
return m
|
|
165
182
|
available = ", ".join(f"{m.name}({m.ip})" for m in machines) or "<none>"
|
|
166
183
|
sources = ", ".join(str(p) for _lbl, p in candidate_sources() if p.is_file()) or "<no sources available>"
|
|
167
|
-
|
|
184
|
+
msg = f"machine [{host}] not found. Scanned sources: {sources}. Available: {available}"
|
|
185
|
+
if not machines:
|
|
186
|
+
msg += f" Hint: {EMPTY_INVENTORY_HINT}"
|
|
187
|
+
raise KeyError(msg)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: rrun-cli
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.4
|
|
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
|
-
|
|
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,7 +94,7 @@ 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` |
|
|
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) |
|
|
@@ -118,7 +112,7 @@ Useful `exec` flags: `--lang bash|powershell|python`, `--workdir`/`--env K=V` (n
|
|
|
118
112
|
|
|
119
113
|
## Configuration: machines.json
|
|
120
114
|
|
|
121
|
-
Credentials live in local `machines.json` files
|
|
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
|
{
|
|
@@ -140,7 +134,7 @@ Sources are merged by machine name, highest priority first (all optional, failur
|
|
|
140
134
|
2. `$REMOTE_MACHINE_CONFIG` (legacy name, still honored)
|
|
141
135
|
3. `./machines.json` (current working directory)
|
|
142
136
|
4. `~/.rrun/machines.json`
|
|
143
|
-
5. `~/.rrun/machines.d/*.json` (sorted by filename — point each inventory at its own file/symlink)
|
|
137
|
+
5. `~/.rrun/machines.d/*.json` (sorted by filename — point each inventory at its own file/symlink; `rrun config init` creates this directory with a README inside)
|
|
144
138
|
6. Legacy: `~/.remote-machine/machines.json` (POSIX) / `C:\tools\remote-machine\machines.json` (Windows)
|
|
145
139
|
|
|
146
140
|
Per-file `defaults` sections (keyed by `os`, lowercased) are applied before merging. Inspect the chain with `rrun config`; list machines (redacted) with `rrun machines`.
|
|
@@ -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
|
|
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,11 @@ 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.d.README.md
|
|
10
|
+
src/rrun/machines.template.json
|
|
8
11
|
src/rrun/registry.py
|
|
9
12
|
src/rrun/remote-requirements.txt
|
|
10
13
|
src/rrun/setup.py
|
|
@@ -15,6 +18,7 @@ src/rrun_cli.egg-info/entry_points.txt
|
|
|
15
18
|
src/rrun_cli.egg-info/requires.txt
|
|
16
19
|
src/rrun_cli.egg-info/top_level.txt
|
|
17
20
|
tests/test_cli.py
|
|
21
|
+
tests/test_config_cmd.py
|
|
18
22
|
tests/test_executor.py
|
|
19
23
|
tests/test_registry.py
|
|
20
24
|
tests/test_setup.py
|
|
@@ -0,0 +1,316 @@
|
|
|
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")
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
class TestInventoryPathCollision:
|
|
251
|
+
"""v0.2.3 回归:cwd == ~/.rrun 或 $RRUN_CONFIG 指向用户清单时,来源链去重会丢弃
|
|
252
|
+
user 标签——user_inventory_path() 不得受此影响。"""
|
|
253
|
+
|
|
254
|
+
def test_cwd_inside_rrun_home(self, isolated, monkeypatch):
|
|
255
|
+
cmd_config_init(_ns(force=False))
|
|
256
|
+
monkeypatch.chdir(isolated / "home" / ".rrun")
|
|
257
|
+
assert cmd_config_add(_flag_ns(name="x", ip="1.1.1.1", user="u")) == 0
|
|
258
|
+
assert resolve_machine("x").ip == "1.1.1.1"
|
|
259
|
+
assert cmd_config_init(_ns(force=False)) == 1 # 仍能正确识别已存在
|
|
260
|
+
|
|
261
|
+
def test_env_points_at_user_file(self, isolated, monkeypatch):
|
|
262
|
+
cmd_config_init(_ns(force=False))
|
|
263
|
+
monkeypatch.setenv("RRUN_CONFIG", str(user_inventory_path()))
|
|
264
|
+
assert cmd_config_add(_flag_ns(name="x", ip="1.1.1.1", user="u")) == 0
|
|
265
|
+
assert resolve_machine("x").ip == "1.1.1.1"
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
class TestMachinesD:
|
|
269
|
+
def test_init_creates_dir_and_readme(self, isolated):
|
|
270
|
+
cmd_config_init(_ns(force=False))
|
|
271
|
+
d = user_inventory_path().parent / "machines.d"
|
|
272
|
+
readme = d / "README.md"
|
|
273
|
+
assert readme.is_file() and "ln -s" in readme.read_text(encoding="utf-8")
|
|
274
|
+
if os.name != "nt":
|
|
275
|
+
assert stat.S_IMODE(d.stat().st_mode) == 0o700
|
|
276
|
+
|
|
277
|
+
def test_readme_not_treated_as_inventory(self, isolated):
|
|
278
|
+
cmd_config_init(_ns(force=False))
|
|
279
|
+
assert all("README" not in m.name for m in load_machines())
|
|
280
|
+
|
|
281
|
+
def test_ensure_machines_d_idempotent(self, isolated):
|
|
282
|
+
cmd_config_init(_ns(force=False))
|
|
283
|
+
readme = user_inventory_path().parent / "machines.d" / "README.md"
|
|
284
|
+
readme.write_text("user customized", encoding="utf-8")
|
|
285
|
+
cmd_config_init(_ns(force=True))
|
|
286
|
+
assert readme.read_text(encoding="utf-8") == "user customized" # 不覆盖用户改动
|
|
287
|
+
|
|
288
|
+
def test_chain_shows_empty_dir(self, isolated, capsys):
|
|
289
|
+
cmd_config_init(_ns(force=False))
|
|
290
|
+
capsys.readouterr()
|
|
291
|
+
cmd_config_chain(_ns())
|
|
292
|
+
assert "no *.json files" in capsys.readouterr().out
|
|
293
|
+
|
|
294
|
+
def test_chain_lists_json_files_when_present(self, isolated, capsys):
|
|
295
|
+
cmd_config_init(_ns(force=False))
|
|
296
|
+
d = user_inventory_path().parent / "machines.d"
|
|
297
|
+
(d / "10-x.json").write_text(
|
|
298
|
+
json.dumps({"machines": [{"name": "deep", "ip": "1.1.1.1", "user": "u"}]}),
|
|
299
|
+
encoding="utf-8")
|
|
300
|
+
capsys.readouterr()
|
|
301
|
+
cmd_config_chain(_ns())
|
|
302
|
+
out = capsys.readouterr().out
|
|
303
|
+
assert "10-x.json" in out and "no *.json" not in out
|
|
304
|
+
assert "deep" in [m.name for m in load_machines()]
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
class TestTemplateHint:
|
|
308
|
+
def test_hint_present_and_ignored_by_loader(self, isolated):
|
|
309
|
+
cmd_config_init(_ns(force=False))
|
|
310
|
+
assert "_hint" in _read_inventory()
|
|
311
|
+
assert load_machines() # _hint 不影响加载
|
|
312
|
+
|
|
313
|
+
def test_hint_preserved_after_add(self, isolated):
|
|
314
|
+
cmd_config_init(_ns(force=False))
|
|
315
|
+
cmd_config_add(_flag_ns(name="z", ip="1.1.1.1", user="u"))
|
|
316
|
+
assert "_hint" in _read_inventory()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|