rrun-cli 0.1.0__tar.gz → 0.1.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- rrun_cli-0.1.2/PKG-INFO +175 -0
- rrun_cli-0.1.2/README.md +150 -0
- {rrun_cli-0.1.0 → rrun_cli-0.1.2}/pyproject.toml +5 -4
- {rrun_cli-0.1.0 → rrun_cli-0.1.2}/src/rrun/__init__.py +1 -1
- {rrun_cli-0.1.0 → rrun_cli-0.1.2}/src/rrun/__main__.py +78 -72
- {rrun_cli-0.1.0 → rrun_cli-0.1.2}/src/rrun/executor.py +12 -12
- {rrun_cli-0.1.0 → rrun_cli-0.1.2}/src/rrun/registry.py +3 -3
- {rrun_cli-0.1.0 → rrun_cli-0.1.2}/src/rrun/setup.py +8 -8
- rrun_cli-0.1.2/src/rrun_cli.egg-info/PKG-INFO +175 -0
- rrun_cli-0.1.0/PKG-INFO +0 -134
- rrun_cli-0.1.0/README.md +0 -110
- rrun_cli-0.1.0/src/rrun_cli.egg-info/PKG-INFO +0 -134
- {rrun_cli-0.1.0 → rrun_cli-0.1.2}/LICENSE +0 -0
- {rrun_cli-0.1.0 → rrun_cli-0.1.2}/setup.cfg +0 -0
- {rrun_cli-0.1.0 → rrun_cli-0.1.2}/src/rrun/remote-requirements.txt +0 -0
- {rrun_cli-0.1.0 → rrun_cli-0.1.2}/src/rrun_cli.egg-info/SOURCES.txt +0 -0
- {rrun_cli-0.1.0 → rrun_cli-0.1.2}/src/rrun_cli.egg-info/dependency_links.txt +0 -0
- {rrun_cli-0.1.0 → rrun_cli-0.1.2}/src/rrun_cli.egg-info/entry_points.txt +0 -0
- {rrun_cli-0.1.0 → rrun_cli-0.1.2}/src/rrun_cli.egg-info/top_level.txt +0 -0
rrun_cli-0.1.2/PKG-INFO
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: rrun-cli
|
|
3
|
+
Version: 0.1.2
|
|
4
|
+
Summary: Run local scripts on remote machines over SSH via stdin pipes — no escaping/encoding hell. Supports python/powershell/bash, with a unified remote Python 3.12 venv provisioner.
|
|
5
|
+
Author: waqiju
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/waqiju/rrun
|
|
8
|
+
Project-URL: Repository, https://github.com/waqiju/rrun
|
|
9
|
+
Project-URL: Issues, https://github.com/waqiju/rrun/issues
|
|
10
|
+
Project-URL: Changelog, https://github.com/waqiju/rrun/blob/main/CHANGELOG.md
|
|
11
|
+
Project-URL: Documentation, https://github.com/waqiju/rrun/blob/main/docs/remote-exec-conventions.md
|
|
12
|
+
Keywords: ssh,remote-exec,powershell,windows,ops,automation
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Environment :: Console
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: Intended Audience :: System Administrators
|
|
17
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
18
|
+
Classifier: Operating System :: MacOS
|
|
19
|
+
Classifier: Programming Language :: Python :: 3
|
|
20
|
+
Classifier: Topic :: System :: Systems Administration
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
License-File: LICENSE
|
|
24
|
+
Dynamic: license-file
|
|
25
|
+
|
|
26
|
+
# rrun
|
|
27
|
+
|
|
28
|
+
[](https://pypi.org/project/rrun-cli/)
|
|
29
|
+
[](https://pypi.org/project/rrun-cli/)
|
|
30
|
+
[](https://github.com/waqiju/rrun/blob/main/LICENSE)
|
|
31
|
+
[](https://github.com/waqiju/rrun/actions/workflows/publish.yml)
|
|
32
|
+
|
|
33
|
+
**[中文文档](README.zh-CN.md)**
|
|
34
|
+
|
|
35
|
+
Run local scripts on remote machines over SSH — **via stdin pipes, never via command-line arguments** — so quoting, escaping, and CJK/UTF-8 encoding survive the `bash → ssh → cmd/powershell` journey intact.
|
|
36
|
+
|
|
37
|
+
- python / powershell / bash, inferred from the file extension or the remote OS
|
|
38
|
+
- Remote stdout/stderr stream back verbatim; the **exit code passes through**, so pipes and CI just work
|
|
39
|
+
- ssh ControlMaster multiplexing: ~0.5s on first contact, ~0.02s afterwards
|
|
40
|
+
- One-command provisioning of a unified **remote Python 3.12 venv** (`rrun setup`) — the remote never touches the internet
|
|
41
|
+
- Zero third-party Python dependencies
|
|
42
|
+
|
|
43
|
+
## Install
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
pipx install rrun-cli # provides the `rrun` command (plus a `remote-machine` alias)
|
|
47
|
+
# or: pip install rrun-cli
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
> The PyPI distribution is named `rrun-cli` (`rrun` sits on PyPI's prohibited-name list as it
|
|
51
|
+
> is confusable with `run`); the installed command is plain `rrun`.
|
|
52
|
+
|
|
53
|
+
**Control machine:** Linux or macOS (Windows works via WSL), Python ≥ 3.10, plus `ssh` and `sshpass`:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
sudo apt install sshpass # Debian/Ubuntu
|
|
57
|
+
brew install hudochenkov/sshpass/sshpass # macOS (sshpass is not in homebrew-core)
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
**Remote machines:** an OpenSSH server with password auth enabled. Windows remotes execute via PowerShell, Mac/Linux remotes via bash.
|
|
61
|
+
|
|
62
|
+
## Quickstart
|
|
63
|
+
|
|
64
|
+
Create `~/.rrun/machines.json` (full format in [Configuration](#configuration-machinesjson)):
|
|
65
|
+
|
|
66
|
+
```json
|
|
67
|
+
{
|
|
68
|
+
"machines": [
|
|
69
|
+
{ "name": "my-win-box", "ip": "192.168.1.10", "os": "Windows", "user": "admin", "password": "secret" }
|
|
70
|
+
]
|
|
71
|
+
}
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Then:
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
rrun machines # verify the inventory is picked up (redacted)
|
|
78
|
+
printf 'Write-Output "hello 中文"\n' > demo.ps1
|
|
79
|
+
rrun exec my-win-box demo.ps1 # powershell, inferred from .ps1
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
That's the whole loop: write a script locally → it runs remotely → you get its output and exit code.
|
|
83
|
+
|
|
84
|
+
## Why
|
|
85
|
+
|
|
86
|
+
Running commands on remote Windows machines from a POSIX shell is a minefield: sshd lands you in `cmd` with a GBK codepage, quotes and `$` get eaten by one of the three shells along the way, and any non-ASCII argument gets mojibake'd. rrun's rules:
|
|
87
|
+
|
|
88
|
+
- Script content (UTF-8, CJK welcome) always travels through **stdin** — never the command line.
|
|
89
|
+
- PowerShell payloads are base64-wrapped into a **single-line pure-ASCII wrapper** (`powershell -Command -` executes stdin line-by-line; multi-line input fails silently), then decoded and invoked as a ScriptBlock remotely.
|
|
90
|
+
- Command-line arguments are restricted to ASCII and passed through safely (`sys.argv` / `$@` / `$args`). Put anything fancier in the script itself or in a JSON file.
|
|
91
|
+
|
|
92
|
+
The full set of hard-won conventions and internals: [docs/remote-exec-conventions.md](docs/remote-exec-conventions.md) ([中文](docs/remote-exec-conventions.zh-CN.md)).
|
|
93
|
+
|
|
94
|
+
## Subcommands
|
|
95
|
+
|
|
96
|
+
| Command | Purpose |
|
|
97
|
+
|---|---|
|
|
98
|
+
| `rrun exec <host> <script\|-c ...>` | Execute a local script / inline content remotely |
|
|
99
|
+
| `rrun machines [--json]` | List merged machines (redacted, with source) |
|
|
100
|
+
| `rrun config` | Diagnose the machines.json source chain |
|
|
101
|
+
| `rrun setup <host\|--all> [--force]` | Provision the unified remote Python 3.12 venv (idempotent) |
|
|
102
|
+
| `rrun pip <host> -- list` | Run pip inside the remote unified venv |
|
|
103
|
+
| `rrun close [<host>\|--all]` | Close ssh ControlMaster multiplexed connections |
|
|
104
|
+
|
|
105
|
+
Useful `exec` flags: `--lang bash|powershell|python`, `--workdir`, `--env K=V`, `--timeout`, `--python <path>` (skip detection), `--no-mux`, `-q`.
|
|
106
|
+
|
|
107
|
+
### Exit codes
|
|
108
|
+
|
|
109
|
+
| Code | Meaning |
|
|
110
|
+
|---|---|
|
|
111
|
+
| `0`–`254` | The remote script's own exit code, passed through unchanged |
|
|
112
|
+
| `255` | ssh transport failure (unreachable / auth failure / connection dropped) |
|
|
113
|
+
| `124` | local `--timeout` expired; the local ssh client was killed |
|
|
114
|
+
|
|
115
|
+
## Configuration: machines.json
|
|
116
|
+
|
|
117
|
+
Credentials live in local `machines.json` files — see [machines.template.json](machines.template.json):
|
|
118
|
+
|
|
119
|
+
```json
|
|
120
|
+
{
|
|
121
|
+
"defaults": { "windows": { "os": "Windows" } },
|
|
122
|
+
"machines": [
|
|
123
|
+
{ "name": "my-win-box", "ip": "192.168.1.10", "os": "Windows",
|
|
124
|
+
"user": "admin", "password": "secret" }
|
|
125
|
+
]
|
|
126
|
+
}
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Sources are merged by machine name, highest priority first (all optional, failures skipped silently):
|
|
130
|
+
|
|
131
|
+
1. `$RRUN_CONFIG` (os.pathsep-separated, multiple files allowed)
|
|
132
|
+
2. `$REMOTE_MACHINE_CONFIG` (legacy name, still honored)
|
|
133
|
+
3. `./machines.json` (current working directory)
|
|
134
|
+
4. `~/.rrun/machines.json`
|
|
135
|
+
5. `~/.rrun/machines.d/*.json` (sorted by filename — point each inventory at its own file/symlink)
|
|
136
|
+
6. Legacy: `~/.remote-machine/machines.json` (POSIX) / `C:\tools\remote-machine\machines.json` (Windows)
|
|
137
|
+
|
|
138
|
+
Per-file `defaults` sections (keyed by `os`, lowercased) are applied before merging. Inspect the chain with `rrun config`; list machines (redacted) with `rrun machines`.
|
|
139
|
+
|
|
140
|
+
## Remote Python: unified 3.12 environment
|
|
141
|
+
|
|
142
|
+
For `python` scripts, rrun requires a **3.12.x** interpreter on the remote, probed in order:
|
|
143
|
+
|
|
144
|
+
1. Unified venv: `C:\tools\remote-machine\venv\Scripts\python.exe` / `~/.remote-machine/venv/bin/python`
|
|
145
|
+
2. Standalone base: `...\python312\python.exe` / `~/.remote-machine/python312/bin/python3`
|
|
146
|
+
3. Existing installs: `C:\Python\Python312\python.exe` / `python3.12` on PATH
|
|
147
|
+
|
|
148
|
+
If none match, run `rrun setup <host>`. It is idempotent and non-destructive: if the machine lacks Python 3.12, a [python-build-standalone](https://github.com/astral-sh/python-build-standalone) tarball is downloaded to the local cache (`~/.cache/rrun/`) and streamed over ssh stdin — no registry, no PATH changes, no admin rights, and the remote never touches GitHub. The venv gets its own `pip.ini`/`pip.conf` (global pip config untouched) plus the standard packages from the bundled `remote-requirements.txt` (override with `~/.rrun/remote-requirements.txt`).
|
|
149
|
+
|
|
150
|
+
## Security
|
|
151
|
+
|
|
152
|
+
- `machines.json` stores **plaintext passwords**. Keep it local, `chmod 600`, never commit it.
|
|
153
|
+
- rrun authenticates with passwords via `sshpass`; **key-based authentication is not supported yet** (on the roadmap).
|
|
154
|
+
- The audit log never records passwords, and `--env` values are logged as keys only.
|
|
155
|
+
|
|
156
|
+
## Auditing & state
|
|
157
|
+
|
|
158
|
+
- Every execution appends a JSON line to `~/.rrun/log/remote-exec.jsonl` (host, lang, script sha1, exit code, duration).
|
|
159
|
+
- Inline `-c` payloads are archived under `~/.rrun/drops/` for replay.
|
|
160
|
+
- The state root defaults to `~/.rrun/` and is overridable via `$RRUN_HOME`.
|
|
161
|
+
|
|
162
|
+
## Upgrading / uninstalling
|
|
163
|
+
|
|
164
|
+
```bash
|
|
165
|
+
pipx upgrade rrun-cli
|
|
166
|
+
pipx uninstall rrun-cli
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
## Contributing
|
|
170
|
+
|
|
171
|
+
Issues and PRs are welcome. Development setup: clone → `python3.12 -m venv .venv && .venv/bin/pip install -e .` → hack → smoke-test with `rrun machines`. Releases are cut by pushing a `vX.Y.Z` tag; CI builds and publishes to PyPI via trusted publishing. See [CHANGELOG.md](CHANGELOG.md).
|
|
172
|
+
|
|
173
|
+
## License
|
|
174
|
+
|
|
175
|
+
[MIT](LICENSE)
|
rrun_cli-0.1.2/README.md
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# rrun
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/rrun-cli/)
|
|
4
|
+
[](https://pypi.org/project/rrun-cli/)
|
|
5
|
+
[](https://github.com/waqiju/rrun/blob/main/LICENSE)
|
|
6
|
+
[](https://github.com/waqiju/rrun/actions/workflows/publish.yml)
|
|
7
|
+
|
|
8
|
+
**[中文文档](README.zh-CN.md)**
|
|
9
|
+
|
|
10
|
+
Run local scripts on remote machines over SSH — **via stdin pipes, never via command-line arguments** — so quoting, escaping, and CJK/UTF-8 encoding survive the `bash → ssh → cmd/powershell` journey intact.
|
|
11
|
+
|
|
12
|
+
- python / powershell / bash, inferred from the file extension or the remote OS
|
|
13
|
+
- Remote stdout/stderr stream back verbatim; the **exit code passes through**, so pipes and CI just work
|
|
14
|
+
- ssh ControlMaster multiplexing: ~0.5s on first contact, ~0.02s afterwards
|
|
15
|
+
- One-command provisioning of a unified **remote Python 3.12 venv** (`rrun setup`) — the remote never touches the internet
|
|
16
|
+
- Zero third-party Python dependencies
|
|
17
|
+
|
|
18
|
+
## Install
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
pipx install rrun-cli # provides the `rrun` command (plus a `remote-machine` alias)
|
|
22
|
+
# or: pip install rrun-cli
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
> The PyPI distribution is named `rrun-cli` (`rrun` sits on PyPI's prohibited-name list as it
|
|
26
|
+
> is confusable with `run`); the installed command is plain `rrun`.
|
|
27
|
+
|
|
28
|
+
**Control machine:** Linux or macOS (Windows works via WSL), Python ≥ 3.10, plus `ssh` and `sshpass`:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
sudo apt install sshpass # Debian/Ubuntu
|
|
32
|
+
brew install hudochenkov/sshpass/sshpass # macOS (sshpass is not in homebrew-core)
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
**Remote machines:** an OpenSSH server with password auth enabled. Windows remotes execute via PowerShell, Mac/Linux remotes via bash.
|
|
36
|
+
|
|
37
|
+
## Quickstart
|
|
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:
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
rrun machines # verify the inventory is picked up (redacted)
|
|
53
|
+
printf 'Write-Output "hello 中文"\n' > demo.ps1
|
|
54
|
+
rrun exec my-win-box demo.ps1 # powershell, inferred from .ps1
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
That's the whole loop: write a script locally → it runs remotely → you get its output and exit code.
|
|
58
|
+
|
|
59
|
+
## Why
|
|
60
|
+
|
|
61
|
+
Running commands on remote Windows machines from a POSIX shell is a minefield: sshd lands you in `cmd` with a GBK codepage, quotes and `$` get eaten by one of the three shells along the way, and any non-ASCII argument gets mojibake'd. rrun's rules:
|
|
62
|
+
|
|
63
|
+
- Script content (UTF-8, CJK welcome) always travels through **stdin** — never the command line.
|
|
64
|
+
- PowerShell payloads are base64-wrapped into a **single-line pure-ASCII wrapper** (`powershell -Command -` executes stdin line-by-line; multi-line input fails silently), then decoded and invoked as a ScriptBlock remotely.
|
|
65
|
+
- Command-line arguments are restricted to ASCII and passed through safely (`sys.argv` / `$@` / `$args`). Put anything fancier in the script itself or in a JSON file.
|
|
66
|
+
|
|
67
|
+
The full set of hard-won conventions and internals: [docs/remote-exec-conventions.md](docs/remote-exec-conventions.md) ([中文](docs/remote-exec-conventions.zh-CN.md)).
|
|
68
|
+
|
|
69
|
+
## Subcommands
|
|
70
|
+
|
|
71
|
+
| Command | Purpose |
|
|
72
|
+
|---|---|
|
|
73
|
+
| `rrun exec <host> <script\|-c ...>` | Execute a local script / inline content remotely |
|
|
74
|
+
| `rrun machines [--json]` | List merged machines (redacted, with source) |
|
|
75
|
+
| `rrun config` | Diagnose the machines.json source chain |
|
|
76
|
+
| `rrun setup <host\|--all> [--force]` | Provision the unified remote Python 3.12 venv (idempotent) |
|
|
77
|
+
| `rrun pip <host> -- list` | Run pip inside the remote unified venv |
|
|
78
|
+
| `rrun close [<host>\|--all]` | Close ssh ControlMaster multiplexed connections |
|
|
79
|
+
|
|
80
|
+
Useful `exec` flags: `--lang bash|powershell|python`, `--workdir`, `--env K=V`, `--timeout`, `--python <path>` (skip detection), `--no-mux`, `-q`.
|
|
81
|
+
|
|
82
|
+
### Exit codes
|
|
83
|
+
|
|
84
|
+
| Code | Meaning |
|
|
85
|
+
|---|---|
|
|
86
|
+
| `0`–`254` | The remote script's own exit code, passed through unchanged |
|
|
87
|
+
| `255` | ssh transport failure (unreachable / auth failure / connection dropped) |
|
|
88
|
+
| `124` | local `--timeout` expired; the local ssh client was killed |
|
|
89
|
+
|
|
90
|
+
## Configuration: machines.json
|
|
91
|
+
|
|
92
|
+
Credentials live in local `machines.json` files — see [machines.template.json](machines.template.json):
|
|
93
|
+
|
|
94
|
+
```json
|
|
95
|
+
{
|
|
96
|
+
"defaults": { "windows": { "os": "Windows" } },
|
|
97
|
+
"machines": [
|
|
98
|
+
{ "name": "my-win-box", "ip": "192.168.1.10", "os": "Windows",
|
|
99
|
+
"user": "admin", "password": "secret" }
|
|
100
|
+
]
|
|
101
|
+
}
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Sources are merged by machine name, highest priority first (all optional, failures skipped silently):
|
|
105
|
+
|
|
106
|
+
1. `$RRUN_CONFIG` (os.pathsep-separated, multiple files allowed)
|
|
107
|
+
2. `$REMOTE_MACHINE_CONFIG` (legacy name, still honored)
|
|
108
|
+
3. `./machines.json` (current working directory)
|
|
109
|
+
4. `~/.rrun/machines.json`
|
|
110
|
+
5. `~/.rrun/machines.d/*.json` (sorted by filename — point each inventory at its own file/symlink)
|
|
111
|
+
6. Legacy: `~/.remote-machine/machines.json` (POSIX) / `C:\tools\remote-machine\machines.json` (Windows)
|
|
112
|
+
|
|
113
|
+
Per-file `defaults` sections (keyed by `os`, lowercased) are applied before merging. Inspect the chain with `rrun config`; list machines (redacted) with `rrun machines`.
|
|
114
|
+
|
|
115
|
+
## Remote Python: unified 3.12 environment
|
|
116
|
+
|
|
117
|
+
For `python` scripts, rrun requires a **3.12.x** interpreter on the remote, probed in order:
|
|
118
|
+
|
|
119
|
+
1. Unified venv: `C:\tools\remote-machine\venv\Scripts\python.exe` / `~/.remote-machine/venv/bin/python`
|
|
120
|
+
2. Standalone base: `...\python312\python.exe` / `~/.remote-machine/python312/bin/python3`
|
|
121
|
+
3. Existing installs: `C:\Python\Python312\python.exe` / `python3.12` on PATH
|
|
122
|
+
|
|
123
|
+
If none match, run `rrun setup <host>`. It is idempotent and non-destructive: if the machine lacks Python 3.12, a [python-build-standalone](https://github.com/astral-sh/python-build-standalone) tarball is downloaded to the local cache (`~/.cache/rrun/`) and streamed over ssh stdin — no registry, no PATH changes, no admin rights, and the remote never touches GitHub. The venv gets its own `pip.ini`/`pip.conf` (global pip config untouched) plus the standard packages from the bundled `remote-requirements.txt` (override with `~/.rrun/remote-requirements.txt`).
|
|
124
|
+
|
|
125
|
+
## Security
|
|
126
|
+
|
|
127
|
+
- `machines.json` stores **plaintext passwords**. Keep it local, `chmod 600`, never commit it.
|
|
128
|
+
- rrun authenticates with passwords via `sshpass`; **key-based authentication is not supported yet** (on the roadmap).
|
|
129
|
+
- The audit log never records passwords, and `--env` values are logged as keys only.
|
|
130
|
+
|
|
131
|
+
## Auditing & state
|
|
132
|
+
|
|
133
|
+
- Every execution appends a JSON line to `~/.rrun/log/remote-exec.jsonl` (host, lang, script sha1, exit code, duration).
|
|
134
|
+
- Inline `-c` payloads are archived under `~/.rrun/drops/` for replay.
|
|
135
|
+
- The state root defaults to `~/.rrun/` and is overridable via `$RRUN_HOME`.
|
|
136
|
+
|
|
137
|
+
## Upgrading / uninstalling
|
|
138
|
+
|
|
139
|
+
```bash
|
|
140
|
+
pipx upgrade rrun-cli
|
|
141
|
+
pipx uninstall rrun-cli
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
## Contributing
|
|
145
|
+
|
|
146
|
+
Issues and PRs are welcome. Development setup: clone → `python3.12 -m venv .venv && .venv/bin/pip install -e .` → hack → smoke-test with `rrun machines`. Releases are cut by pushing a `vX.Y.Z` tag; CI builds and publishes to PyPI via trusted publishing. See [CHANGELOG.md](CHANGELOG.md).
|
|
147
|
+
|
|
148
|
+
## License
|
|
149
|
+
|
|
150
|
+
[MIT](LICENSE)
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
[build-system]
|
|
2
|
-
requires = ["setuptools>=
|
|
2
|
+
requires = ["setuptools>=77"]
|
|
3
3
|
build-backend = "setuptools.build_meta"
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "rrun-cli"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.2"
|
|
8
8
|
description = "Run local scripts on remote machines over SSH via stdin pipes — no escaping/encoding hell. Supports python/powershell/bash, with a unified remote Python 3.12 venv provisioner."
|
|
9
9
|
readme = "README.md"
|
|
10
|
-
license =
|
|
10
|
+
license = "MIT"
|
|
11
11
|
requires-python = ">=3.10"
|
|
12
12
|
authors = [{ name = "waqiju" }]
|
|
13
13
|
keywords = ["ssh", "remote-exec", "powershell", "windows", "ops", "automation"]
|
|
@@ -16,7 +16,6 @@ classifiers = [
|
|
|
16
16
|
"Environment :: Console",
|
|
17
17
|
"Intended Audience :: Developers",
|
|
18
18
|
"Intended Audience :: System Administrators",
|
|
19
|
-
"License :: OSI Approved :: MIT License",
|
|
20
19
|
"Operating System :: POSIX :: Linux",
|
|
21
20
|
"Operating System :: MacOS",
|
|
22
21
|
"Programming Language :: Python :: 3",
|
|
@@ -29,6 +28,8 @@ dependencies = []
|
|
|
29
28
|
Homepage = "https://github.com/waqiju/rrun"
|
|
30
29
|
Repository = "https://github.com/waqiju/rrun"
|
|
31
30
|
Issues = "https://github.com/waqiju/rrun/issues"
|
|
31
|
+
Changelog = "https://github.com/waqiju/rrun/blob/main/CHANGELOG.md"
|
|
32
|
+
Documentation = "https://github.com/waqiju/rrun/blob/main/docs/remote-exec-conventions.md"
|
|
32
33
|
|
|
33
34
|
[project.scripts]
|
|
34
35
|
rrun = "rrun.__main__:main"
|
|
@@ -1,31 +1,36 @@
|
|
|
1
1
|
# -*- coding: utf-8 -*-
|
|
2
|
-
"""rrun —
|
|
2
|
+
"""rrun CLI — run local scripts on remote machines over SSH stdin pipes.
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
4
|
+
Core contract: script content (UTF-8, CJK welcome) is piped to the remote
|
|
5
|
+
interpreter via stdin, never via command-line arguments — sidestepping the
|
|
6
|
+
bash -> ssh -> cmd quoting/encoding gauntlet.
|
|
6
7
|
|
|
7
|
-
|
|
8
|
+
Usage:
|
|
8
9
|
rrun exec <host> <script.py|.ps1|.sh> [ascii_args...]
|
|
9
|
-
rrun exec <host> -c "Write-Output
|
|
10
|
+
rrun exec <host> -c "Write-Output hello" # lang inferred from remote OS
|
|
10
11
|
rrun exec pc_build temp/x.py --timeout 60
|
|
11
12
|
rrun exec mac_mini --lang python temp/x.py
|
|
12
|
-
rrun setup <host|--all> [--force] #
|
|
13
|
-
rrun pip <host> -- list #
|
|
14
|
-
rrun machines #
|
|
15
|
-
rrun config #
|
|
16
|
-
rrun close [<host>|--all] #
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
13
|
+
rrun setup <host|--all> [--force] # provision the unified python env (3.12 venv)
|
|
14
|
+
rrun pip <host> -- list # run pip in the unified venv
|
|
15
|
+
rrun machines # list machines (redacted, with source)
|
|
16
|
+
rrun config # diagnose the machines.json source chain
|
|
17
|
+
rrun close [<host>|--all] # close ssh multiplexed connections
|
|
18
|
+
|
|
19
|
+
Conventions:
|
|
20
|
+
- Language inference: file extension (.py/.ps1/.sh) first, then remote OS
|
|
21
|
+
(Windows=powershell, others=bash).
|
|
22
|
+
- Command-line args are ASCII-only, passed through remotely
|
|
23
|
+
(python=sys.argv; bash=$@; powershell=$args); put CJK/special characters
|
|
24
|
+
in the script content or a JSON file instead.
|
|
25
|
+
- Remote python is pinned to 3.12: probes the unified venv (preferred),
|
|
26
|
+
the standalone base, then existing installs; all missing -> run `rrun setup`
|
|
27
|
+
first (exec never installs implicitly on the hot path).
|
|
28
|
+
- Exit codes: the remote exit code passes through unchanged;
|
|
29
|
+
255=ssh transport error; 124=local timeout.
|
|
30
|
+
- Inline -c content is archived under ~/.rrun/drops/ for replay.
|
|
31
|
+
- Audit: every execution appends to ~/.rrun/log/remote-exec.jsonl.
|
|
32
|
+
|
|
33
|
+
Remote stdout -> local stdout, stderr -> stderr; the exit code is the remote one.
|
|
29
34
|
"""
|
|
30
35
|
|
|
31
36
|
import argparse
|
|
@@ -45,7 +50,7 @@ def _parse_env(pairs) -> dict:
|
|
|
45
50
|
env = {}
|
|
46
51
|
for p in pairs or []:
|
|
47
52
|
if "=" not in p:
|
|
48
|
-
raise SystemExit(f"[remote-exec] --env
|
|
53
|
+
raise SystemExit(f"[remote-exec] --env expects KEY=VAL, got: {p!r}")
|
|
49
54
|
k, v = p.split("=", 1)
|
|
50
55
|
env[k] = v
|
|
51
56
|
return env
|
|
@@ -70,9 +75,9 @@ def cmd_exec(ns) -> int:
|
|
|
70
75
|
file = ns.script or ""
|
|
71
76
|
lang = ns.lang or ""
|
|
72
77
|
if content and file:
|
|
73
|
-
raise SystemExit("[remote-exec]
|
|
78
|
+
raise SystemExit("[remote-exec] give either a script file or -c/--content, not both")
|
|
74
79
|
if not content and not file:
|
|
75
|
-
raise SystemExit("[remote-exec]
|
|
80
|
+
raise SystemExit("[remote-exec] nothing to run: pass a script file or -c/--content")
|
|
76
81
|
|
|
77
82
|
# content 模式先确定 lang 再落盘(扩展名需要 lang)
|
|
78
83
|
script_for_log = file
|
|
@@ -81,7 +86,7 @@ def cmd_exec(ns) -> int:
|
|
|
81
86
|
lang = machine.default_lang
|
|
82
87
|
dropped = _drop_inline(machine.name, lang, content)
|
|
83
88
|
script_for_log = str(dropped)
|
|
84
|
-
print(f"[remote-exec]
|
|
89
|
+
print(f"[remote-exec] inline content archived to {dropped}", file=sys.stderr)
|
|
85
90
|
|
|
86
91
|
args = ns.args
|
|
87
92
|
|
|
@@ -106,11 +111,11 @@ def cmd_exec(ns) -> int:
|
|
|
106
111
|
sys.stderr.buffer.flush()
|
|
107
112
|
|
|
108
113
|
if result.timed_out:
|
|
109
|
-
print(f"[remote-exec]
|
|
114
|
+
print(f"[remote-exec] local timeout ({ns.timeout}s), ssh client killed", file=sys.stderr)
|
|
110
115
|
elif result.transport_error:
|
|
111
|
-
print(f"[remote-exec] ssh
|
|
116
|
+
print(f"[remote-exec] ssh transport error (unreachable / auth failure / dropped), exit=255", file=sys.stderr)
|
|
112
117
|
if not ns.quiet:
|
|
113
|
-
print(f"[remote-exec] exit={result.exit_code}
|
|
118
|
+
print(f"[remote-exec] exit={result.exit_code} took {result.duration:.1f}s", file=sys.stderr)
|
|
114
119
|
return result.exit_code
|
|
115
120
|
|
|
116
121
|
|
|
@@ -138,19 +143,20 @@ def cmd_machines(ns) -> int:
|
|
|
138
143
|
|
|
139
144
|
def cmd_config(ns) -> int:
|
|
140
145
|
infos = scan_sources()
|
|
141
|
-
print("machines.json
|
|
146
|
+
print("machines.json source chain (high -> low priority; same-name machines are")
|
|
147
|
+
print("overridden by higher-priority sources):")
|
|
142
148
|
raw_total = 0
|
|
143
149
|
for i, info in enumerate(infos, 1):
|
|
144
150
|
if not info.exists:
|
|
145
|
-
state = "-
|
|
151
|
+
state = "- missing"
|
|
146
152
|
elif info.error:
|
|
147
|
-
state = f"
|
|
153
|
+
state = f"x read failed: {info.error}"
|
|
148
154
|
else:
|
|
149
|
-
state = f"
|
|
155
|
+
state = f"ok, {info.machine_count} machines"
|
|
150
156
|
raw_total += info.machine_count
|
|
151
157
|
print(f" [{i}] {info.label:<22} {info.path} {state}")
|
|
152
158
|
merged = load_machines()
|
|
153
|
-
print(f"
|
|
159
|
+
print(f"{len(merged)} machines total ({raw_total} across all sources before same-name overrides)")
|
|
154
160
|
return 0
|
|
155
161
|
|
|
156
162
|
|
|
@@ -160,10 +166,10 @@ def cmd_setup(ns) -> int:
|
|
|
160
166
|
elif ns.host:
|
|
161
167
|
hosts = [ns.host]
|
|
162
168
|
else:
|
|
163
|
-
raise SystemExit("[setup]
|
|
169
|
+
raise SystemExit("[setup] specify a host or --all")
|
|
164
170
|
results = []
|
|
165
171
|
if len(hosts) == 1:
|
|
166
|
-
print(f"[setup] {hosts[0]}
|
|
172
|
+
print(f"[setup] provisioning {hosts[0]} ...", file=sys.stderr)
|
|
167
173
|
results.append(setup_machine(hosts[0], force=ns.force))
|
|
168
174
|
else:
|
|
169
175
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
@@ -173,17 +179,17 @@ def cmd_setup(ns) -> int:
|
|
|
173
179
|
r = f.result()
|
|
174
180
|
results.append(r)
|
|
175
181
|
print(f"[setup] {r.host}: {'ok' if r.ok else 'FAIL'} ({r.duration:.0f}s)", file=sys.stderr)
|
|
176
|
-
print(f"\n{'host':<24} {'
|
|
182
|
+
print(f"\n{'host':<24} {'result':<6} {'python':<10} {'venv':<46} note")
|
|
177
183
|
for r in sorted(results, key=lambda x: x.host):
|
|
178
184
|
if r.ok:
|
|
179
185
|
note = []
|
|
180
186
|
if r.installed_standalone:
|
|
181
|
-
note.append("
|
|
187
|
+
note.append("standalone installed")
|
|
182
188
|
if r.created_venv:
|
|
183
|
-
note.append("
|
|
189
|
+
note.append("venv created")
|
|
184
190
|
if not note:
|
|
185
|
-
note.append("
|
|
186
|
-
print(f"{r.host:<24} {'ok':<6} {r.version:<10} {r.venv_python:<46} {'
|
|
191
|
+
note.append("already present, verified/topped-up deps")
|
|
192
|
+
print(f"{r.host:<24} {'ok':<6} {r.version:<10} {r.venv_python:<46} {', '.join(note)}")
|
|
187
193
|
else:
|
|
188
194
|
print(f"{r.host:<24} {'FAIL':<6} {'':<10} {'':<46} {r.message[:80]}")
|
|
189
195
|
return 0 if all(r.ok for r in results) else 1
|
|
@@ -196,9 +202,9 @@ def cmd_pip(ns) -> int:
|
|
|
196
202
|
raise SystemExit(f"[pip] {e}")
|
|
197
203
|
args = list(ns.pargs or []) + list(getattr(ns, "args", []) or [])
|
|
198
204
|
if not args:
|
|
199
|
-
raise SystemExit("[pip]
|
|
205
|
+
raise SystemExit("[pip] missing pip args, e.g.: rrun pip <host> -- list")
|
|
200
206
|
try:
|
|
201
|
-
_check_ascii(args, "pip
|
|
207
|
+
_check_ascii(args, "pip args")
|
|
202
208
|
py = venv_python_or_die(ns.host)
|
|
203
209
|
except (ValueError, RuntimeError) as e:
|
|
204
210
|
raise SystemExit(f"[pip] {e}")
|
|
@@ -213,7 +219,7 @@ def cmd_pip(ns) -> int:
|
|
|
213
219
|
sys.stdout.buffer.flush()
|
|
214
220
|
sys.stderr.buffer.write(err)
|
|
215
221
|
sys.stderr.buffer.flush()
|
|
216
|
-
print(f"[pip] {machine.name} exit={rc}
|
|
222
|
+
print(f"[pip] {machine.name} exit={rc} took {time.time() - t0:.1f}s", file=sys.stderr)
|
|
217
223
|
return rc
|
|
218
224
|
|
|
219
225
|
|
|
@@ -223,7 +229,7 @@ def cmd_close(ns) -> int:
|
|
|
223
229
|
elif ns.host:
|
|
224
230
|
hosts = [ns.host]
|
|
225
231
|
else:
|
|
226
|
-
raise SystemExit("[remote-exec] close
|
|
232
|
+
raise SystemExit("[remote-exec] close needs a host or --all")
|
|
227
233
|
rc = 0
|
|
228
234
|
for h in hosts:
|
|
229
235
|
try:
|
|
@@ -241,47 +247,47 @@ def main() -> None:
|
|
|
241
247
|
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
242
248
|
sub = ap.add_subparsers(dest="subcmd", required=True)
|
|
243
249
|
|
|
244
|
-
ep = sub.add_parser("exec", help="
|
|
245
|
-
ep.add_argument("host", help="
|
|
246
|
-
ep.add_argument("script", nargs="?", help="
|
|
250
|
+
ep = sub.add_parser("exec", help="execute a local script / inline content on a remote machine")
|
|
251
|
+
ep.add_argument("host", help="machine name/ip/hostname (see the machines subcommand)")
|
|
252
|
+
ep.add_argument("script", nargs="?", help="local script path (.py/.ps1/.sh, UTF-8)")
|
|
247
253
|
ep.add_argument("--lang", choices=["bash", "powershell", "python"],
|
|
248
|
-
help="
|
|
249
|
-
ep.add_argument("-c", "--content", help="
|
|
254
|
+
help="remote interpreter language (default: by extension, else by remote OS)")
|
|
255
|
+
ep.add_argument("-c", "--content", help="inline script content (archived to ~/.rrun/drops/)")
|
|
250
256
|
ep.add_argument("args", nargs="*",
|
|
251
|
-
help="
|
|
252
|
-
ep.add_argument("--workdir", help="
|
|
257
|
+
help="script arguments (ASCII only; use -- to separate from options)")
|
|
258
|
+
ep.add_argument("--workdir", help="remote working directory (bash/powershell only)")
|
|
253
259
|
ep.add_argument("--env", action="append", metavar="KEY=VAL",
|
|
254
|
-
help="
|
|
255
|
-
ep.add_argument("--timeout", type=float, help="
|
|
256
|
-
ep.add_argument("--python", dest="python", help="
|
|
257
|
-
ep.add_argument("--no-utf8", action="store_true", help="
|
|
258
|
-
ep.add_argument("--no-mux", action="store_true", help="
|
|
259
|
-
ep.add_argument("-q", "--quiet", action="store_true", help="
|
|
260
|
+
help="remote environment variable (repeatable; ASCII only)")
|
|
261
|
+
ep.add_argument("--timeout", type=float, help="local timeout in seconds (exit=124 on expiry)")
|
|
262
|
+
ep.add_argument("--python", dest="python", help="remote python path (skips auto-detection)")
|
|
263
|
+
ep.add_argument("--no-utf8", action="store_true", help="do not pass -X utf8 to remote python")
|
|
264
|
+
ep.add_argument("--no-mux", action="store_true", help="disable ssh ControlMaster multiplexing")
|
|
265
|
+
ep.add_argument("-q", "--quiet", action="store_true", help="suppress [remote-exec] info lines")
|
|
260
266
|
ep.set_defaults(func=cmd_exec)
|
|
261
267
|
|
|
262
|
-
mp = sub.add_parser("machines", help="
|
|
268
|
+
mp = sub.add_parser("machines", help="list machines merged from all sources (redacted, with source)")
|
|
263
269
|
mp.add_argument("--json", action="store_true")
|
|
264
270
|
mp.set_defaults(func=cmd_machines)
|
|
265
271
|
|
|
266
|
-
cf = sub.add_parser("config", help="
|
|
272
|
+
cf = sub.add_parser("config", help="show the machines.json source chain (which files apply, how many machines each)")
|
|
267
273
|
cf.set_defaults(func=cmd_config)
|
|
268
274
|
|
|
269
|
-
sp = sub.add_parser("setup", help="
|
|
270
|
-
sp.add_argument("host", nargs="?", help="
|
|
271
|
-
sp.add_argument("--all", action="store_true", help="
|
|
272
|
-
sp.add_argument("--force", action="store_true", help="
|
|
273
|
-
sp.add_argument("--jobs", type=int, default=6, help="--all
|
|
275
|
+
sp = sub.add_parser("setup", help="provision the unified remote python environment (3.12 venv)")
|
|
276
|
+
sp.add_argument("host", nargs="?", help="machine name/ip; use --all for every machine")
|
|
277
|
+
sp.add_argument("--all", action="store_true", help="run against all machines in machines.json")
|
|
278
|
+
sp.add_argument("--force", action="store_true", help="recreate the venv (keeps the python base)")
|
|
279
|
+
sp.add_argument("--jobs", type=int, default=6, help="concurrency for --all (default 6)")
|
|
274
280
|
sp.set_defaults(func=cmd_setup)
|
|
275
281
|
|
|
276
|
-
pp = sub.add_parser("pip", help="
|
|
277
|
-
pp.add_argument("host", help="
|
|
278
|
-
pp.add_argument("pargs", nargs="*", help="pip
|
|
279
|
-
pp.add_argument("--timeout", type=float, default=300.0, help="
|
|
282
|
+
pp = sub.add_parser("pip", help="run pip inside the remote unified venv (ad-hoc installs)")
|
|
283
|
+
pp.add_argument("host", help="machine name/ip")
|
|
284
|
+
pp.add_argument("pargs", nargs="*", help="pip arguments; put options starting with - after --")
|
|
285
|
+
pp.add_argument("--timeout", type=float, default=300.0, help="local timeout in seconds (default 300)")
|
|
280
286
|
pp.set_defaults(func=cmd_pip)
|
|
281
287
|
|
|
282
|
-
cp = sub.add_parser("close", help="
|
|
283
|
-
cp.add_argument("host", nargs="?", help="
|
|
284
|
-
cp.add_argument("--all", action="store_true", help="
|
|
288
|
+
cp = sub.add_parser("close", help="close ssh ControlMaster multiplexed connections")
|
|
289
|
+
cp.add_argument("host", nargs="?", help="machine name/ip; omit with --all")
|
|
290
|
+
cp.add_argument("--all", action="store_true", help="close connections for all machines")
|
|
285
291
|
cp.set_defaults(func=cmd_close)
|
|
286
292
|
|
|
287
293
|
# argparse 对 -- 的处理与子解析器/位置参数组合有 quirk,手动切分更可靠:
|