shellflow 0.1.0__py3-none-any.whl
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.
- shellflow-0.1.0.dist-info/METADATA +253 -0
- shellflow-0.1.0.dist-info/RECORD +5 -0
- shellflow-0.1.0.dist-info/WHEEL +4 -0
- shellflow-0.1.0.dist-info/entry_points.txt +2 -0
- shellflow.py +802 -0
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: shellflow
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A minimal shell script orchestrator with SSH support
|
|
5
|
+
Project-URL: Homepage, https://github.com/longcipher/shellflow
|
|
6
|
+
Project-URL: Repository, https://github.com/longcipher/shellflow
|
|
7
|
+
Project-URL: Issues, https://github.com/longcipher/shellflow/issues
|
|
8
|
+
Author: Bob Liu
|
|
9
|
+
License-Expression: Apache-2.0
|
|
10
|
+
Keywords: automation,orchestration,shell,ssh
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Topic :: System :: Systems Administration
|
|
19
|
+
Requires-Python: >=3.12
|
|
20
|
+
Requires-Dist: paramiko>=3.0.0
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# ShellFlow
|
|
24
|
+
|
|
25
|
+
[](https://deepwiki.com/longcipher/shellflow)
|
|
26
|
+
[](https://context7.com/longcipher/shellflow)
|
|
27
|
+
[](https://www.python.org/downloads/)
|
|
28
|
+
[](LICENSE)
|
|
29
|
+
[](https://pypi.org/project/shellflow/)
|
|
30
|
+
|
|
31
|
+

|
|
32
|
+
|
|
33
|
+
ShellFlow is a minimal shell script orchestrator for mixed local and remote execution. You write one shell script, mark execution boundaries with comments, and ShellFlow runs each block in order while resolving remote targets from your SSH configuration.
|
|
34
|
+
|
|
35
|
+

|
|
36
|
+
|
|
37
|
+
## What It Does
|
|
38
|
+
|
|
39
|
+
- Split a shell script into `@LOCAL` and `@REMOTE` execution blocks.
|
|
40
|
+
- Run each block fail-fast, in order.
|
|
41
|
+
- Reuse the shared prelude before the first marker for every block.
|
|
42
|
+
- Pass the previous block output forward as `SHELLFLOW_LAST_OUTPUT`.
|
|
43
|
+
- Resolve remote targets from `~/.ssh/config` or a custom SSH config path.
|
|
44
|
+
|
|
45
|
+
## Quick Start
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
git clone https://github.com/longcipher/shellflow.git
|
|
49
|
+
cd shellflow
|
|
50
|
+
uv sync --all-groups # uv sync --refresh --reinstall --no-cache
|
|
51
|
+
uv tool install --force --reinstall --refresh --no-cache .
|
|
52
|
+
|
|
53
|
+
shellflow run playbooks/hello.sh
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
If you do not want a global tool install, use `uv run shellflow run playbooks/hello.sh`.
|
|
57
|
+
|
|
58
|
+
## Installation
|
|
59
|
+
|
|
60
|
+
### Development checkout
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
git clone https://github.com/longcipher/shellflow.git
|
|
64
|
+
cd shellflow
|
|
65
|
+
uv sync --all-groups # uv sync --refresh --reinstall --no-cache
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### Install as a local tool
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
uv tool install --force .
|
|
72
|
+
shellflow --version
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### Install into the active environment
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
uv pip install -e .
|
|
79
|
+
shellflow --version
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Script Format
|
|
83
|
+
|
|
84
|
+
Shellflow recognizes two markers:
|
|
85
|
+
|
|
86
|
+
- `# @LOCAL`
|
|
87
|
+
- `# @REMOTE <ssh-host>`
|
|
88
|
+
|
|
89
|
+
`<ssh-host>` must match a `Host` entry in your SSH config. Shellflow then connects using that SSH host definition, which means the actual machine can be resolved through the configured `HostName`, `User`, `Port`, and `IdentityFile` values.
|
|
90
|
+
|
|
91
|
+
Example:
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
#!/bin/bash
|
|
95
|
+
set -euo pipefail
|
|
96
|
+
|
|
97
|
+
# @LOCAL
|
|
98
|
+
echo "runs locally"
|
|
99
|
+
|
|
100
|
+
# @REMOTE sui
|
|
101
|
+
uname -a
|
|
102
|
+
|
|
103
|
+
# @LOCAL
|
|
104
|
+
echo "remote output: $SHELLFLOW_LAST_OUTPUT"
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## SSH Configuration
|
|
108
|
+
|
|
109
|
+
Example `~/.ssh/config` entry:
|
|
110
|
+
|
|
111
|
+
```sshconfig
|
|
112
|
+
Host sui
|
|
113
|
+
HostName 192.168.1.100
|
|
114
|
+
User deploy
|
|
115
|
+
Port 22
|
|
116
|
+
IdentityFile ~/.ssh/id_ed25519
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
With that config, this block is valid:
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
# @REMOTE sui
|
|
123
|
+
hostname
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
This is intentional:
|
|
127
|
+
|
|
128
|
+
- Shellflow accepts configured SSH host names, not arbitrary free-form targets.
|
|
129
|
+
- Unknown remote targets fail early with a clear error before spawning `ssh`.
|
|
130
|
+
- You can override the default config path with `--ssh-config`.
|
|
131
|
+
|
|
132
|
+
## Execution Model
|
|
133
|
+
|
|
134
|
+
Each block runs in a fresh shell.
|
|
135
|
+
|
|
136
|
+
- Shell options from the prelude are copied into every block.
|
|
137
|
+
- Shell state like `cd`, shell variables, aliases, and `export` commands does not persist across blocks.
|
|
138
|
+
- Explicit context values are passed forward through environment variables.
|
|
139
|
+
|
|
140
|
+
Example:
|
|
141
|
+
|
|
142
|
+
```bash
|
|
143
|
+
# @LOCAL
|
|
144
|
+
echo "build-123"
|
|
145
|
+
|
|
146
|
+
# @LOCAL
|
|
147
|
+
echo "last output = $SHELLFLOW_LAST_OUTPUT"
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Lines before the first marker are treated as a shared prelude and prepended to every executable block:
|
|
151
|
+
|
|
152
|
+
```bash
|
|
153
|
+
#!/bin/bash
|
|
154
|
+
set -euo pipefail
|
|
155
|
+
|
|
156
|
+
# @LOCAL
|
|
157
|
+
echo "prelude is active"
|
|
158
|
+
|
|
159
|
+
# @REMOTE sui
|
|
160
|
+
echo "prelude is also active here"
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
## CLI
|
|
164
|
+
|
|
165
|
+
```text
|
|
166
|
+
shellflow run <script>
|
|
167
|
+
shellflow run <script> --verbose
|
|
168
|
+
shellflow run <script> --ssh-config ./ssh_config
|
|
169
|
+
shellflow --version
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
Examples:
|
|
173
|
+
|
|
174
|
+
```bash
|
|
175
|
+
shellflow run playbooks/hello.sh
|
|
176
|
+
shellflow run playbooks/hello.sh -v
|
|
177
|
+
shellflow run playbooks/hello.sh --ssh-config ~/.ssh/config.work
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
## Development
|
|
181
|
+
|
|
182
|
+
Useful commands:
|
|
183
|
+
|
|
184
|
+
```bash
|
|
185
|
+
just sync
|
|
186
|
+
just test
|
|
187
|
+
just bdd
|
|
188
|
+
just test-all
|
|
189
|
+
just typecheck
|
|
190
|
+
just build
|
|
191
|
+
just publish
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
Direct verification commands:
|
|
195
|
+
|
|
196
|
+
```bash
|
|
197
|
+
uv run pytest -q
|
|
198
|
+
uv run behave features
|
|
199
|
+
uv run ruff check .
|
|
200
|
+
uv run ty check src tests
|
|
201
|
+
uv build
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
## Release Process
|
|
205
|
+
|
|
206
|
+
Shellflow supports both local publishing and GitHub Actions release publishing.
|
|
207
|
+
|
|
208
|
+
### Local publish
|
|
209
|
+
|
|
210
|
+
```bash
|
|
211
|
+
just publish
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
`uv publish` uses standard `uv` authentication mechanisms such as `UV_PUBLISH_TOKEN`, or PyPI trusted publishing when supported by the environment.
|
|
215
|
+
|
|
216
|
+
### GitHub Actions publish on tag push
|
|
217
|
+
|
|
218
|
+
The repository includes:
|
|
219
|
+
|
|
220
|
+
- `.github/workflows/ci.yml` for lint, type-check, test, and build verification.
|
|
221
|
+
- `.github/workflows/release.yml` for publishing to PyPI when a tag like `v0.1.0` is pushed.
|
|
222
|
+
|
|
223
|
+
Recommended release flow:
|
|
224
|
+
|
|
225
|
+
```bash
|
|
226
|
+
git tag v0.1.0
|
|
227
|
+
git push origin v0.1.0
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
To use trusted publishing with PyPI:
|
|
231
|
+
|
|
232
|
+
1. Create a `pypi` environment in GitHub repository settings.
|
|
233
|
+
2. Add this repository as a trusted publisher in the PyPI project settings.
|
|
234
|
+
3. Push a `v*` tag.
|
|
235
|
+
|
|
236
|
+
The release workflow then runs verification, builds distributions with `uv build`, and uploads them with `uv publish`.
|
|
237
|
+
|
|
238
|
+
## Project Layout
|
|
239
|
+
|
|
240
|
+
```text
|
|
241
|
+
shellflow/
|
|
242
|
+
├── src/shellflow.py
|
|
243
|
+
├── tests/
|
|
244
|
+
├── features/
|
|
245
|
+
├── playbooks/
|
|
246
|
+
├── pyproject.toml
|
|
247
|
+
├── Justfile
|
|
248
|
+
└── README.md
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
## License
|
|
252
|
+
|
|
253
|
+
Apache-2.0
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
shellflow.py,sha256=3bzWFrcnPvgcdVoGhjokOYsb2bQ-x8Z4dCnmlcGGVRc,23870
|
|
2
|
+
shellflow-0.1.0.dist-info/METADATA,sha256=G3GWnFw0g9BctFL6MPRXZS1NmKIEHRoJcbIpdKwGoWQ,6233
|
|
3
|
+
shellflow-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
4
|
+
shellflow-0.1.0.dist-info/entry_points.txt,sha256=HFG1Lrtah5P4566pMBOnCSX971OA1NVn1U_4vheYezg,45
|
|
5
|
+
shellflow-0.1.0.dist-info/RECORD,,
|
shellflow.py
ADDED
|
@@ -0,0 +1,802 @@
|
|
|
1
|
+
"""Shellflow - A minimal shell script orchestrator with SSH support.
|
|
2
|
+
|
|
3
|
+
This module provides a single-file implementation for parsing and executing
|
|
4
|
+
shell scripts with local and remote execution blocks. Scripts use comment
|
|
5
|
+
markers to define execution blocks:
|
|
6
|
+
|
|
7
|
+
# @LOCAL
|
|
8
|
+
echo "Running locally"
|
|
9
|
+
|
|
10
|
+
# @REMOTE server1
|
|
11
|
+
echo "Running on server1"
|
|
12
|
+
|
|
13
|
+
The module supports SSH configuration from ~/.ssh/config and provides
|
|
14
|
+
fail-fast execution with clear error reporting.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import argparse
|
|
20
|
+
import fnmatch
|
|
21
|
+
import os
|
|
22
|
+
import re
|
|
23
|
+
import subprocess
|
|
24
|
+
import sys
|
|
25
|
+
from dataclasses import dataclass, field
|
|
26
|
+
from importlib.metadata import PackageNotFoundError
|
|
27
|
+
from importlib.metadata import version as distribution_version
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import Any
|
|
30
|
+
|
|
31
|
+
# =============================================================================
|
|
32
|
+
# Data Classes
|
|
33
|
+
# =============================================================================
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class Block:
|
|
38
|
+
"""Represents a block of commands to execute."""
|
|
39
|
+
|
|
40
|
+
target: str # "LOCAL" or "REMOTE:<host>"
|
|
41
|
+
commands: list[str] = field(default_factory=list)
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def is_local(self) -> bool:
|
|
45
|
+
"""Check if this block runs locally."""
|
|
46
|
+
return self.target == "LOCAL"
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def is_remote(self) -> bool:
|
|
50
|
+
"""Check if this block runs remotely."""
|
|
51
|
+
return self.target.startswith("REMOTE:")
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def host(self) -> str | None:
|
|
55
|
+
"""Get the remote host if this is a remote block."""
|
|
56
|
+
if self.is_remote:
|
|
57
|
+
return self.target.split(":", 1)[1]
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass
|
|
62
|
+
class ExecutionContext:
|
|
63
|
+
"""Context passed between block executions."""
|
|
64
|
+
|
|
65
|
+
env: dict[str, str] = field(default_factory=dict)
|
|
66
|
+
last_output: str = ""
|
|
67
|
+
success: bool = True
|
|
68
|
+
|
|
69
|
+
def to_shell_env(self) -> dict[str, str]:
|
|
70
|
+
"""Convert context to environment variables for shell execution."""
|
|
71
|
+
shell_env = os.environ.copy()
|
|
72
|
+
shell_env.update(self.env)
|
|
73
|
+
shell_env["SHELLFLOW_LAST_OUTPUT"] = self.last_output
|
|
74
|
+
return shell_env
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass
|
|
78
|
+
class ExecutionResult:
|
|
79
|
+
"""Result of executing a single block."""
|
|
80
|
+
|
|
81
|
+
success: bool
|
|
82
|
+
output: str
|
|
83
|
+
exit_code: int = 0
|
|
84
|
+
error_message: str = ""
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass
|
|
88
|
+
class RunResult:
|
|
89
|
+
"""Result of running a complete script."""
|
|
90
|
+
|
|
91
|
+
success: bool
|
|
92
|
+
blocks_executed: int = 0
|
|
93
|
+
error_message: str = ""
|
|
94
|
+
block_results: list[ExecutionResult] = field(default_factory=list)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass
|
|
98
|
+
class SSHConfig:
|
|
99
|
+
"""SSH configuration for a remote host."""
|
|
100
|
+
|
|
101
|
+
host: str
|
|
102
|
+
hostname: str | None = None
|
|
103
|
+
user: str | None = None
|
|
104
|
+
port: int = 22
|
|
105
|
+
identity_file: str | None = None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# =============================================================================
|
|
109
|
+
# Exceptions
|
|
110
|
+
# =============================================================================
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class ShellflowError(Exception):
|
|
114
|
+
"""Base exception for Shellflow errors."""
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class ParseError(ShellflowError):
|
|
118
|
+
"""Exception raised when parsing fails."""
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class ExecutionError(ShellflowError):
|
|
122
|
+
"""Exception raised when execution fails."""
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
PACKAGE_NAME = "shellflow"
|
|
126
|
+
DEFAULT_VERSION = "0.1.0"
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
# =============================================================================
|
|
130
|
+
# SSH Config Parser
|
|
131
|
+
# =============================================================================
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def read_ssh_config(host: str) -> SSHConfig | None:
|
|
135
|
+
"""Read SSH configuration for a host from ~/.ssh/config.
|
|
136
|
+
|
|
137
|
+
Uses paramiko.SSHConfig if available, otherwise falls back to basic parsing.
|
|
138
|
+
|
|
139
|
+
Args:
|
|
140
|
+
host: The host alias to look up.
|
|
141
|
+
|
|
142
|
+
Returns:
|
|
143
|
+
SSHConfig object if found, None otherwise.
|
|
144
|
+
"""
|
|
145
|
+
ssh_config_path = _get_ssh_config_path()
|
|
146
|
+
if not ssh_config_path.exists():
|
|
147
|
+
return None
|
|
148
|
+
|
|
149
|
+
try:
|
|
150
|
+
# Try to use paramiko if available
|
|
151
|
+
import paramiko
|
|
152
|
+
|
|
153
|
+
ssh_config = paramiko.SSHConfig()
|
|
154
|
+
with ssh_config_path.open() as handle:
|
|
155
|
+
ssh_config.parse(handle)
|
|
156
|
+
|
|
157
|
+
if not _ssh_config_matches_host(ssh_config, host):
|
|
158
|
+
return None
|
|
159
|
+
|
|
160
|
+
lookup = ssh_config.lookup(host)
|
|
161
|
+
if not lookup:
|
|
162
|
+
return None
|
|
163
|
+
|
|
164
|
+
return SSHConfig(
|
|
165
|
+
host=host,
|
|
166
|
+
hostname=lookup.get("hostname"),
|
|
167
|
+
user=lookup.get("user"),
|
|
168
|
+
port=int(lookup.get("port", 22)),
|
|
169
|
+
identity_file=lookup.get("identityfile", [None])[0]
|
|
170
|
+
if isinstance(lookup.get("identityfile"), list)
|
|
171
|
+
else lookup.get("identityfile"),
|
|
172
|
+
)
|
|
173
|
+
except (AttributeError, ImportError):
|
|
174
|
+
# Fall back to basic parsing
|
|
175
|
+
return _parse_ssh_config_basic(ssh_config_path, host)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _ssh_config_matches_host(ssh_config: Any, host: str) -> bool:
|
|
179
|
+
"""Check whether a host matches any explicit Host rule in the SSH config."""
|
|
180
|
+
get_hostnames = getattr(ssh_config, "get_hostnames", None)
|
|
181
|
+
if not callable(get_hostnames):
|
|
182
|
+
return True
|
|
183
|
+
|
|
184
|
+
patterns = {pattern for pattern in get_hostnames() if pattern}
|
|
185
|
+
return any(fnmatch.fnmatch(host, pattern) for pattern in patterns)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _get_ssh_config_path() -> Path:
|
|
189
|
+
"""Resolve the SSH config path, allowing environment override."""
|
|
190
|
+
configured_path = os.environ.get("SHELLFLOW_SSH_CONFIG")
|
|
191
|
+
if configured_path:
|
|
192
|
+
return Path(configured_path).expanduser()
|
|
193
|
+
return Path.home() / ".ssh" / "config"
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _parse_ssh_config_basic(config_path: Path, host: str) -> SSHConfig | None:
|
|
197
|
+
"""Basic SSH config parser without paramiko.
|
|
198
|
+
|
|
199
|
+
Args:
|
|
200
|
+
config_path: Path to the SSH config file.
|
|
201
|
+
host: The host alias to look up.
|
|
202
|
+
|
|
203
|
+
Returns:
|
|
204
|
+
SSHConfig object if found, None otherwise.
|
|
205
|
+
"""
|
|
206
|
+
sections: list[tuple[list[str], dict[str, Any]]] = []
|
|
207
|
+
current_patterns: list[str] = []
|
|
208
|
+
current_options: dict[str, Any] = {}
|
|
209
|
+
|
|
210
|
+
with config_path.open() as handle:
|
|
211
|
+
for raw_line in handle:
|
|
212
|
+
line = raw_line.strip()
|
|
213
|
+
if not line or line.startswith("#"):
|
|
214
|
+
continue
|
|
215
|
+
|
|
216
|
+
parts = line.split(maxsplit=1)
|
|
217
|
+
if len(parts) < 2:
|
|
218
|
+
continue
|
|
219
|
+
|
|
220
|
+
keyword, value = parts[0].lower(), parts[1]
|
|
221
|
+
|
|
222
|
+
if keyword == "host":
|
|
223
|
+
if current_patterns:
|
|
224
|
+
sections.append((current_patterns, current_options))
|
|
225
|
+
current_patterns = value.split()
|
|
226
|
+
current_options = {}
|
|
227
|
+
continue
|
|
228
|
+
|
|
229
|
+
if not current_patterns:
|
|
230
|
+
continue
|
|
231
|
+
|
|
232
|
+
if keyword == "hostname":
|
|
233
|
+
current_options["hostname"] = value
|
|
234
|
+
elif keyword == "user":
|
|
235
|
+
current_options["user"] = value
|
|
236
|
+
elif keyword == "port":
|
|
237
|
+
current_options["port"] = int(value)
|
|
238
|
+
elif keyword == "identityfile":
|
|
239
|
+
current_options["identityfile"] = value
|
|
240
|
+
|
|
241
|
+
if current_patterns:
|
|
242
|
+
sections.append((current_patterns, current_options))
|
|
243
|
+
|
|
244
|
+
config: dict[str, Any] = {"host": host}
|
|
245
|
+
matched = False
|
|
246
|
+
|
|
247
|
+
for patterns, options in sections:
|
|
248
|
+
if any(fnmatch.fnmatch(host, pattern) for pattern in patterns):
|
|
249
|
+
matched = True
|
|
250
|
+
config.update(options)
|
|
251
|
+
|
|
252
|
+
if not matched:
|
|
253
|
+
return None
|
|
254
|
+
|
|
255
|
+
return SSHConfig(
|
|
256
|
+
host=config.get("host", host),
|
|
257
|
+
hostname=config.get("hostname"),
|
|
258
|
+
user=config.get("user"),
|
|
259
|
+
port=config.get("port", 22),
|
|
260
|
+
identity_file=config.get("identityfile"),
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
# =============================================================================
|
|
265
|
+
# Script Parser
|
|
266
|
+
# =============================================================================
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
BLOCK_MARKER_RE = re.compile(r"^\s*#\s*@(?P<marker>[A-Z]+)(?:\s+(?P<argument>\S+))?\s*$")
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _parse_block_marker(line: str) -> tuple[str, str | None] | None:
|
|
273
|
+
"""Parse a line as a shellflow marker if it matches exactly."""
|
|
274
|
+
match = BLOCK_MARKER_RE.match(line)
|
|
275
|
+
if not match:
|
|
276
|
+
return None
|
|
277
|
+
return match.group("marker"), match.group("argument")
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _build_block_commands(prelude: list[str], body: list[str]) -> list[str]:
|
|
281
|
+
"""Combine shared prelude with block-specific commands."""
|
|
282
|
+
cleaned_body = _clean_commands(body)
|
|
283
|
+
if not cleaned_body:
|
|
284
|
+
return []
|
|
285
|
+
return [*prelude, *cleaned_body]
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def parse_script(content: str) -> list[Block]:
|
|
289
|
+
"""Parse a shell script into execution blocks.
|
|
290
|
+
|
|
291
|
+
Parses scripts with comment markers:
|
|
292
|
+
# @LOCAL - Start a local execution block
|
|
293
|
+
# @REMOTE <host> - Start a remote execution block
|
|
294
|
+
|
|
295
|
+
Args:
|
|
296
|
+
content: The script content to parse.
|
|
297
|
+
|
|
298
|
+
Returns:
|
|
299
|
+
List of Block objects.
|
|
300
|
+
|
|
301
|
+
Raises:
|
|
302
|
+
ParseError: If the script cannot be parsed.
|
|
303
|
+
"""
|
|
304
|
+
blocks: list[Block] = []
|
|
305
|
+
current_block: Block | None = None
|
|
306
|
+
accumulated_lines: list[str] = []
|
|
307
|
+
prelude_lines: list[str] = []
|
|
308
|
+
|
|
309
|
+
for line_no, line in enumerate(content.splitlines(), 1):
|
|
310
|
+
marker = _parse_block_marker(line)
|
|
311
|
+
if marker:
|
|
312
|
+
marker_name, marker_argument = marker
|
|
313
|
+
if marker_name not in {"LOCAL", "REMOTE"}:
|
|
314
|
+
raise ParseError(f"Line {line_no}: Unknown marker @{marker_name}")
|
|
315
|
+
|
|
316
|
+
if current_block is None:
|
|
317
|
+
prelude_lines = _clean_commands(accumulated_lines)
|
|
318
|
+
else:
|
|
319
|
+
current_block.commands = _build_block_commands(prelude_lines, accumulated_lines)
|
|
320
|
+
if current_block.commands:
|
|
321
|
+
blocks.append(current_block)
|
|
322
|
+
|
|
323
|
+
accumulated_lines = []
|
|
324
|
+
|
|
325
|
+
if marker_name == "LOCAL":
|
|
326
|
+
current_block = Block(target="LOCAL")
|
|
327
|
+
else:
|
|
328
|
+
if not marker_argument:
|
|
329
|
+
raise ParseError(f"Line {line_no}: @REMOTE marker missing host")
|
|
330
|
+
current_block = Block(target=f"REMOTE:{marker_argument}")
|
|
331
|
+
continue
|
|
332
|
+
|
|
333
|
+
accumulated_lines.append(line)
|
|
334
|
+
|
|
335
|
+
# Don't forget the last block
|
|
336
|
+
if current_block:
|
|
337
|
+
current_block.commands = _build_block_commands(prelude_lines, accumulated_lines)
|
|
338
|
+
if current_block.commands:
|
|
339
|
+
blocks.append(current_block)
|
|
340
|
+
|
|
341
|
+
return blocks
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _clean_commands(lines: list[str]) -> list[str]:
|
|
345
|
+
"""Clean accumulated lines into executable commands.
|
|
346
|
+
|
|
347
|
+
Removes leading empty lines and common leading whitespace while
|
|
348
|
+
preserving the relative indentation of the commands.
|
|
349
|
+
|
|
350
|
+
Args:
|
|
351
|
+
lines: Raw lines accumulated from the script.
|
|
352
|
+
|
|
353
|
+
Returns:
|
|
354
|
+
List of cleaned command lines.
|
|
355
|
+
"""
|
|
356
|
+
# Remove empty lines from start and end
|
|
357
|
+
while lines and not lines[0].strip():
|
|
358
|
+
lines = lines[1:]
|
|
359
|
+
while lines and not lines[-1].strip():
|
|
360
|
+
lines = lines[:-1]
|
|
361
|
+
|
|
362
|
+
if not lines:
|
|
363
|
+
return []
|
|
364
|
+
|
|
365
|
+
# Find common leading whitespace (excluding empty lines)
|
|
366
|
+
non_empty_lines = [line for line in lines if line.strip()]
|
|
367
|
+
if not non_empty_lines:
|
|
368
|
+
return []
|
|
369
|
+
|
|
370
|
+
common_indent = min(len(line) - len(line.lstrip()) for line in non_empty_lines)
|
|
371
|
+
|
|
372
|
+
# Remove common leading whitespace
|
|
373
|
+
return [line[common_indent:] for line in lines]
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
# =============================================================================
|
|
377
|
+
# Execution
|
|
378
|
+
# =============================================================================
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _build_executable_script(
|
|
382
|
+
commands: list[str],
|
|
383
|
+
context: ExecutionContext,
|
|
384
|
+
*,
|
|
385
|
+
include_context_exports: bool,
|
|
386
|
+
) -> str:
|
|
387
|
+
"""Build a shell script payload for local or remote execution."""
|
|
388
|
+
script_lines = ["set -e"]
|
|
389
|
+
if include_context_exports:
|
|
390
|
+
script_lines.extend(_build_context_exports(context))
|
|
391
|
+
script_lines.extend(commands)
|
|
392
|
+
return "\n".join(script_lines)
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _build_context_exports(context: ExecutionContext) -> list[str]:
|
|
396
|
+
"""Build export statements for explicit shellflow context values only."""
|
|
397
|
+
exports = [f"export SHELLFLOW_LAST_OUTPUT={_quote_shell_value(context.last_output)}"]
|
|
398
|
+
for key, value in context.env.items():
|
|
399
|
+
if _is_valid_env_name(key):
|
|
400
|
+
exports.append(f"export {key}={_quote_shell_value(value)}")
|
|
401
|
+
return exports
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def _is_valid_env_name(name: str) -> bool:
|
|
405
|
+
"""Check whether a string is a valid shell environment variable name."""
|
|
406
|
+
return bool(re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", name))
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _quote_shell_value(value: str) -> str:
|
|
410
|
+
"""Quote a value for use in a shell export statement."""
|
|
411
|
+
escaped = value.replace("\\", "\\\\").replace('"', '\\"').replace("$", "\\$").replace("`", "\\`")
|
|
412
|
+
return f'"{escaped}"'
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def _combine_output(stdout: str, stderr: str) -> str:
|
|
416
|
+
"""Combine stdout and stderr into a single trimmed output string."""
|
|
417
|
+
output = stdout.strip()
|
|
418
|
+
error_output = stderr.strip()
|
|
419
|
+
if output and error_output:
|
|
420
|
+
return f"{output}\n{error_output}"
|
|
421
|
+
return output or error_output
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def _iter_display_commands(commands: list[str]) -> list[str]:
|
|
425
|
+
"""Return non-empty, non-comment commands suitable for verbose display."""
|
|
426
|
+
return [command for command in commands if command.strip() and not command.lstrip().startswith("#")]
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def _format_env_value(value: str) -> str:
|
|
430
|
+
"""Format an environment variable value for readable verbose output."""
|
|
431
|
+
escaped = (
|
|
432
|
+
value.replace("\\", "\\\\").replace("\n", "\\n").replace("\r", "\\r").replace("\t", "\\t").replace('"', '\\"')
|
|
433
|
+
)
|
|
434
|
+
return f'"{escaped}"'
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _iter_display_context(context: ExecutionContext) -> list[str]:
|
|
438
|
+
"""Return explicit shellflow context values suitable for verbose display."""
|
|
439
|
+
lines: list[str] = []
|
|
440
|
+
if context.last_output:
|
|
441
|
+
lines.append(f"SHELLFLOW_LAST_OUTPUT={_format_env_value(context.last_output)}")
|
|
442
|
+
for key, value in context.env.items():
|
|
443
|
+
if _is_valid_env_name(key):
|
|
444
|
+
lines.append(f"{key}={_format_env_value(value)}")
|
|
445
|
+
return lines
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def execute_local(
|
|
449
|
+
block: Block,
|
|
450
|
+
context: ExecutionContext,
|
|
451
|
+
) -> ExecutionResult:
|
|
452
|
+
"""Execute a local block.
|
|
453
|
+
|
|
454
|
+
Runs the block's commands in a local subprocess with the given context.
|
|
455
|
+
|
|
456
|
+
Args:
|
|
457
|
+
block: The block to execute.
|
|
458
|
+
context: The execution context with environment and state.
|
|
459
|
+
|
|
460
|
+
Returns:
|
|
461
|
+
ExecutionResult with success status and output.
|
|
462
|
+
"""
|
|
463
|
+
if not block.commands:
|
|
464
|
+
return ExecutionResult(success=True, output="")
|
|
465
|
+
|
|
466
|
+
script = _build_executable_script(
|
|
467
|
+
block.commands,
|
|
468
|
+
context,
|
|
469
|
+
include_context_exports=False,
|
|
470
|
+
)
|
|
471
|
+
env = context.to_shell_env()
|
|
472
|
+
|
|
473
|
+
try:
|
|
474
|
+
result = subprocess.run(
|
|
475
|
+
["/bin/bash", "-se"],
|
|
476
|
+
input=script,
|
|
477
|
+
capture_output=True,
|
|
478
|
+
text=True,
|
|
479
|
+
env=env,
|
|
480
|
+
)
|
|
481
|
+
|
|
482
|
+
return ExecutionResult(
|
|
483
|
+
success=result.returncode == 0,
|
|
484
|
+
output=_combine_output(result.stdout, result.stderr),
|
|
485
|
+
exit_code=result.returncode,
|
|
486
|
+
error_message="" if result.returncode == 0 else f"Exit code: {result.returncode}",
|
|
487
|
+
)
|
|
488
|
+
except (OSError, subprocess.SubprocessError) as e:
|
|
489
|
+
return ExecutionResult(
|
|
490
|
+
success=False,
|
|
491
|
+
output="",
|
|
492
|
+
exit_code=-1,
|
|
493
|
+
error_message=str(e),
|
|
494
|
+
)
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def execute_remote(
|
|
498
|
+
block: Block,
|
|
499
|
+
context: ExecutionContext,
|
|
500
|
+
ssh_config: SSHConfig | None,
|
|
501
|
+
) -> ExecutionResult:
|
|
502
|
+
"""Execute a remote block via SSH.
|
|
503
|
+
|
|
504
|
+
Builds an SSH command and executes the block's commands on a remote host.
|
|
505
|
+
|
|
506
|
+
Args:
|
|
507
|
+
block: The block to execute.
|
|
508
|
+
context: The execution context with environment and state.
|
|
509
|
+
ssh_config: Optional SSH configuration for the remote host.
|
|
510
|
+
|
|
511
|
+
Returns:
|
|
512
|
+
ExecutionResult with success status and output.
|
|
513
|
+
"""
|
|
514
|
+
if not block.commands:
|
|
515
|
+
return ExecutionResult(success=True, output="")
|
|
516
|
+
|
|
517
|
+
host = block.host
|
|
518
|
+
if not host:
|
|
519
|
+
return ExecutionResult(
|
|
520
|
+
success=False,
|
|
521
|
+
output="",
|
|
522
|
+
exit_code=-1,
|
|
523
|
+
error_message="No host specified for remote block",
|
|
524
|
+
)
|
|
525
|
+
|
|
526
|
+
if ssh_config is None:
|
|
527
|
+
ssh_config = read_ssh_config(host)
|
|
528
|
+
|
|
529
|
+
if ssh_config is None:
|
|
530
|
+
ssh_config_path = _get_ssh_config_path()
|
|
531
|
+
return ExecutionResult(
|
|
532
|
+
success=False,
|
|
533
|
+
output="",
|
|
534
|
+
exit_code=-1,
|
|
535
|
+
error_message=(f"Remote host '{host}' was not found in SSH config: {ssh_config_path}"),
|
|
536
|
+
)
|
|
537
|
+
|
|
538
|
+
ssh_args = ["ssh"]
|
|
539
|
+
|
|
540
|
+
if ssh_config.port and ssh_config.port != 22:
|
|
541
|
+
ssh_args.extend(["-p", str(ssh_config.port)])
|
|
542
|
+
if ssh_config.user:
|
|
543
|
+
ssh_args.extend(["-l", ssh_config.user])
|
|
544
|
+
if ssh_config.identity_file:
|
|
545
|
+
ssh_args.extend(["-i", str(Path(ssh_config.identity_file).expanduser())])
|
|
546
|
+
|
|
547
|
+
ssh_config_path = _get_ssh_config_path()
|
|
548
|
+
if ssh_config_path.exists():
|
|
549
|
+
ssh_args.extend(["-F", str(ssh_config_path)])
|
|
550
|
+
|
|
551
|
+
ssh_args.extend(["-o", "BatchMode=yes", host, "bash", "-se"])
|
|
552
|
+
remote_script = _build_executable_script(
|
|
553
|
+
block.commands,
|
|
554
|
+
context,
|
|
555
|
+
include_context_exports=True,
|
|
556
|
+
)
|
|
557
|
+
|
|
558
|
+
try:
|
|
559
|
+
result = subprocess.run(
|
|
560
|
+
ssh_args,
|
|
561
|
+
input=remote_script,
|
|
562
|
+
capture_output=True,
|
|
563
|
+
text=True,
|
|
564
|
+
)
|
|
565
|
+
|
|
566
|
+
return ExecutionResult(
|
|
567
|
+
success=result.returncode == 0,
|
|
568
|
+
output=_combine_output(result.stdout, result.stderr),
|
|
569
|
+
exit_code=result.returncode,
|
|
570
|
+
error_message="" if result.returncode == 0 else f"SSH exit code: {result.returncode}",
|
|
571
|
+
)
|
|
572
|
+
except (OSError, subprocess.SubprocessError) as e:
|
|
573
|
+
return ExecutionResult(
|
|
574
|
+
success=False,
|
|
575
|
+
output="",
|
|
576
|
+
exit_code=-1,
|
|
577
|
+
error_message=str(e),
|
|
578
|
+
)
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
# =============================================================================
|
|
582
|
+
# Script Runner
|
|
583
|
+
# =============================================================================
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
def run_script(blocks: list[Block], verbose: bool = False) -> RunResult:
|
|
587
|
+
"""Run a list of blocks sequentially.
|
|
588
|
+
|
|
589
|
+
Executes each block in order, updating the execution context between
|
|
590
|
+
blocks. Fails fast on any error.
|
|
591
|
+
|
|
592
|
+
Args:
|
|
593
|
+
blocks: List of blocks to execute.
|
|
594
|
+
verbose: Whether to print progress information.
|
|
595
|
+
|
|
596
|
+
Returns:
|
|
597
|
+
RunResult with success status and execution info.
|
|
598
|
+
"""
|
|
599
|
+
context = ExecutionContext()
|
|
600
|
+
blocks_executed = 0
|
|
601
|
+
block_results: list[ExecutionResult] = []
|
|
602
|
+
|
|
603
|
+
# ANSI color codes for verbose output
|
|
604
|
+
GREEN = "\033[92m"
|
|
605
|
+
RED = "\033[91m"
|
|
606
|
+
BLUE = "\033[94m"
|
|
607
|
+
YELLOW = "\033[93m"
|
|
608
|
+
DIM = "\033[90m"
|
|
609
|
+
RESET = "\033[0m"
|
|
610
|
+
|
|
611
|
+
for i, block in enumerate(blocks, 1):
|
|
612
|
+
# Print block info if verbose
|
|
613
|
+
if verbose:
|
|
614
|
+
if block.is_local:
|
|
615
|
+
print(f"{BLUE}[{i}/{len(blocks)}] LOCAL{RESET}")
|
|
616
|
+
else:
|
|
617
|
+
host = block.host or "unknown"
|
|
618
|
+
print(f"{YELLOW}[{i}/{len(blocks)}] REMOTE: {host}{RESET}")
|
|
619
|
+
for env_line in _iter_display_context(context):
|
|
620
|
+
print(f"{DIM}@env {env_line}{RESET}")
|
|
621
|
+
for command in _iter_display_commands(block.commands):
|
|
622
|
+
print(f"{DIM}$ {command}{RESET}")
|
|
623
|
+
|
|
624
|
+
# Execute the block
|
|
625
|
+
if block.is_local:
|
|
626
|
+
result = execute_local(block, context)
|
|
627
|
+
else:
|
|
628
|
+
host = block.host
|
|
629
|
+
if not host:
|
|
630
|
+
return RunResult(
|
|
631
|
+
success=False,
|
|
632
|
+
blocks_executed=blocks_executed,
|
|
633
|
+
error_message=f"Block {i}: No host specified for remote block",
|
|
634
|
+
)
|
|
635
|
+
ssh_config = read_ssh_config(host)
|
|
636
|
+
result = execute_remote(block, context, ssh_config)
|
|
637
|
+
|
|
638
|
+
# Update context
|
|
639
|
+
context.last_output = result.output
|
|
640
|
+
context.success = result.success
|
|
641
|
+
block_results.append(result)
|
|
642
|
+
|
|
643
|
+
if verbose:
|
|
644
|
+
if result.output:
|
|
645
|
+
print(result.output)
|
|
646
|
+
if result.success:
|
|
647
|
+
print(f"{GREEN}✓ Success{RESET}\n")
|
|
648
|
+
else:
|
|
649
|
+
print(f"{RED}✗ Failed: {result.error_message}{RESET}\n")
|
|
650
|
+
|
|
651
|
+
blocks_executed += 1
|
|
652
|
+
|
|
653
|
+
# Fail fast on error
|
|
654
|
+
if not result.success:
|
|
655
|
+
return RunResult(
|
|
656
|
+
success=False,
|
|
657
|
+
blocks_executed=blocks_executed,
|
|
658
|
+
error_message=f"Block {i} failed: {result.error_message}",
|
|
659
|
+
block_results=block_results,
|
|
660
|
+
)
|
|
661
|
+
|
|
662
|
+
return RunResult(
|
|
663
|
+
success=True,
|
|
664
|
+
blocks_executed=blocks_executed,
|
|
665
|
+
block_results=block_results,
|
|
666
|
+
)
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
# =============================================================================
|
|
670
|
+
# CLI
|
|
671
|
+
# =============================================================================
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
def create_parser() -> argparse.ArgumentParser:
|
|
675
|
+
"""Create the argument parser for the CLI.
|
|
676
|
+
|
|
677
|
+
Returns:
|
|
678
|
+
Configured ArgumentParser instance.
|
|
679
|
+
"""
|
|
680
|
+
parser = argparse.ArgumentParser(
|
|
681
|
+
prog="shellflow",
|
|
682
|
+
description="A minimal shell script orchestrator with SSH support",
|
|
683
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
684
|
+
epilog="""
|
|
685
|
+
Examples:
|
|
686
|
+
shellflow run script.sh # Run a script
|
|
687
|
+
shellflow run script.sh --verbose # Run with verbose output
|
|
688
|
+
shellflow --version # Show version
|
|
689
|
+
""",
|
|
690
|
+
)
|
|
691
|
+
|
|
692
|
+
parser.add_argument(
|
|
693
|
+
"--version",
|
|
694
|
+
action="version",
|
|
695
|
+
version=f"%(prog)s {_get_version()}",
|
|
696
|
+
)
|
|
697
|
+
|
|
698
|
+
subparsers = parser.add_subparsers(dest="command", help="Available commands")
|
|
699
|
+
|
|
700
|
+
# Run command
|
|
701
|
+
run_parser = subparsers.add_parser(
|
|
702
|
+
"run",
|
|
703
|
+
help="Run a shellflow script",
|
|
704
|
+
description="Parse and execute a shellflow script.",
|
|
705
|
+
)
|
|
706
|
+
run_parser.add_argument(
|
|
707
|
+
"script",
|
|
708
|
+
help="Path to the shell script to execute",
|
|
709
|
+
)
|
|
710
|
+
run_parser.add_argument(
|
|
711
|
+
"--verbose",
|
|
712
|
+
"-v",
|
|
713
|
+
action="store_true",
|
|
714
|
+
help="Enable verbose output with colored progress",
|
|
715
|
+
)
|
|
716
|
+
run_parser.add_argument(
|
|
717
|
+
"--ssh-config",
|
|
718
|
+
help="Path to an SSH config file to use instead of ~/.ssh/config",
|
|
719
|
+
)
|
|
720
|
+
|
|
721
|
+
return parser
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
def main(args: list[str] | None = None) -> int:
|
|
725
|
+
"""Main entry point for the CLI.
|
|
726
|
+
|
|
727
|
+
Args:
|
|
728
|
+
args: Command-line arguments (defaults to sys.argv[1:]).
|
|
729
|
+
|
|
730
|
+
Returns:
|
|
731
|
+
Exit code (0 for success, non-zero for failure).
|
|
732
|
+
"""
|
|
733
|
+
parser = create_parser()
|
|
734
|
+
parsed_args = parser.parse_args(args)
|
|
735
|
+
|
|
736
|
+
if not parsed_args.command:
|
|
737
|
+
parser.print_help()
|
|
738
|
+
return 1
|
|
739
|
+
|
|
740
|
+
if parsed_args.command == "run":
|
|
741
|
+
return cmd_run(parsed_args)
|
|
742
|
+
|
|
743
|
+
return 0
|
|
744
|
+
|
|
745
|
+
|
|
746
|
+
def cmd_run(args: argparse.Namespace) -> int:
|
|
747
|
+
"""Execute the run command.
|
|
748
|
+
|
|
749
|
+
Args:
|
|
750
|
+
args: Parsed arguments for the run command.
|
|
751
|
+
|
|
752
|
+
Returns:
|
|
753
|
+
Exit code (0 for success, non-zero for failure).
|
|
754
|
+
"""
|
|
755
|
+
script_path = Path(args.script)
|
|
756
|
+
|
|
757
|
+
if args.ssh_config:
|
|
758
|
+
os.environ["SHELLFLOW_SSH_CONFIG"] = str(Path(args.ssh_config).expanduser())
|
|
759
|
+
|
|
760
|
+
if not script_path.exists():
|
|
761
|
+
sys.stderr.write(f"Error: Script not found: {script_path}\n")
|
|
762
|
+
return 1
|
|
763
|
+
|
|
764
|
+
try:
|
|
765
|
+
content = script_path.read_text()
|
|
766
|
+
except OSError as e:
|
|
767
|
+
sys.stderr.write(f"Error: Cannot read script: {e}\n")
|
|
768
|
+
return 1
|
|
769
|
+
|
|
770
|
+
try:
|
|
771
|
+
blocks = parse_script(content)
|
|
772
|
+
except ParseError as e:
|
|
773
|
+
sys.stderr.write(f"Parse error: {e}\n")
|
|
774
|
+
return 1
|
|
775
|
+
|
|
776
|
+
if not blocks:
|
|
777
|
+
if args.verbose:
|
|
778
|
+
print("No executable blocks found in script.")
|
|
779
|
+
return 0
|
|
780
|
+
|
|
781
|
+
result = run_script(blocks, verbose=args.verbose)
|
|
782
|
+
|
|
783
|
+
if not result.success:
|
|
784
|
+
sys.stderr.write(f"Execution failed: {result.error_message}\n")
|
|
785
|
+
return 1
|
|
786
|
+
|
|
787
|
+
if args.verbose:
|
|
788
|
+
print(f"\nCompleted: {result.blocks_executed} block(s) executed successfully.")
|
|
789
|
+
|
|
790
|
+
return 0
|
|
791
|
+
|
|
792
|
+
|
|
793
|
+
def _get_version() -> str:
|
|
794
|
+
"""Resolve the installed package version, falling back to the source default."""
|
|
795
|
+
try:
|
|
796
|
+
return distribution_version(PACKAGE_NAME)
|
|
797
|
+
except PackageNotFoundError:
|
|
798
|
+
return DEFAULT_VERSION
|
|
799
|
+
|
|
800
|
+
|
|
801
|
+
if __name__ == "__main__":
|
|
802
|
+
sys.exit(main())
|