redo-cli 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.
- main.py +301 -0
- modules/__init__.py +1 -0
- modules/placeholders.py +134 -0
- modules/runner.py +278 -0
- modules/storage.py +515 -0
- modules/ui.py +433 -0
- redo_cli-0.1.0.dist-info/METADATA +231 -0
- redo_cli-0.1.0.dist-info/RECORD +11 -0
- redo_cli-0.1.0.dist-info/WHEEL +5 -0
- redo_cli-0.1.0.dist-info/entry_points.txt +2 -0
- redo_cli-0.1.0.dist-info/top_level.txt +2 -0
modules/runner.py
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import re
|
|
2
|
+
import subprocess
|
|
3
|
+
|
|
4
|
+
from rich import box
|
|
5
|
+
from rich.console import Console, Group
|
|
6
|
+
from rich.live import Live
|
|
7
|
+
from rich.panel import Panel
|
|
8
|
+
from rich.prompt import Confirm
|
|
9
|
+
from rich.spinner import Spinner
|
|
10
|
+
from rich.table import Table
|
|
11
|
+
from rich.text import Text
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
console = Console(highlight=False)
|
|
15
|
+
|
|
16
|
+
DANGEROUS_PATTERNS = [
|
|
17
|
+
r"\bdel\s+/(?:s|f)\b.*\s+/(?:s|f)\b",
|
|
18
|
+
r"\brd\s+/s\b",
|
|
19
|
+
r"\brmdir\s+/s\b",
|
|
20
|
+
r"\bremove-item\b(?=.*-recurse\b)(?=.*-force\b)",
|
|
21
|
+
r"\bformat\b",
|
|
22
|
+
r"\bmkfs(?:\.[a-z0-9]+)?\b",
|
|
23
|
+
r"\bdd\s+.*\bof=",
|
|
24
|
+
r"\bsudo\b",
|
|
25
|
+
r"\bgit\s+reset\s+--hard\b",
|
|
26
|
+
]
|
|
27
|
+
COMMAND_TIMEOUT_SECONDS = 1800
|
|
28
|
+
STATUS_PENDING = "Pending"
|
|
29
|
+
STATUS_RUNNING = "Running"
|
|
30
|
+
STATUS_DONE = "Done"
|
|
31
|
+
STATUS_FAILED = "Failed"
|
|
32
|
+
STATUS_SKIPPED = "Skipped"
|
|
33
|
+
SPINNER_NAME = "line"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _result(code, status, message, data=None):
|
|
37
|
+
result = {
|
|
38
|
+
"code": code,
|
|
39
|
+
"status": status,
|
|
40
|
+
"message": message,
|
|
41
|
+
}
|
|
42
|
+
if data is not None:
|
|
43
|
+
result["data"] = data
|
|
44
|
+
return result
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def is_dangerous_command(command):
|
|
48
|
+
normalized = " ".join(command.strip().lower().split())
|
|
49
|
+
return _is_dangerous_rm_command(normalized) or any(
|
|
50
|
+
re.search(pattern, normalized, flags=re.IGNORECASE) for pattern in DANGEROUS_PATTERNS
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _is_dangerous_rm_command(command):
|
|
55
|
+
segments = re.split(r"\s*(?:&&|\|\||[;|])\s*", command)
|
|
56
|
+
return any(_is_dangerous_rm_segment(segment) for segment in segments)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _is_dangerous_rm_segment(command):
|
|
60
|
+
parts = command.strip().split()
|
|
61
|
+
if not parts or parts[0] != "rm":
|
|
62
|
+
return False
|
|
63
|
+
|
|
64
|
+
has_recursive = False
|
|
65
|
+
has_force = False
|
|
66
|
+
for part in parts[1:]:
|
|
67
|
+
if part in {"--recursive", "--dir"}:
|
|
68
|
+
has_recursive = True
|
|
69
|
+
elif part == "--force":
|
|
70
|
+
has_force = True
|
|
71
|
+
elif part.startswith("-") and not part.startswith("--"):
|
|
72
|
+
flags = part[1:]
|
|
73
|
+
has_recursive = has_recursive or "r" in flags
|
|
74
|
+
has_force = has_force or "f" in flags
|
|
75
|
+
|
|
76
|
+
return has_recursive and has_force
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _status_style(status):
|
|
80
|
+
if status == STATUS_DONE:
|
|
81
|
+
return "bold green"
|
|
82
|
+
if status == STATUS_RUNNING:
|
|
83
|
+
return "bold steel_blue"
|
|
84
|
+
if status == STATUS_FAILED:
|
|
85
|
+
return "bold red"
|
|
86
|
+
if status == STATUS_SKIPPED:
|
|
87
|
+
return "yellow"
|
|
88
|
+
return "dim"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _workflow_table(commands, statuses):
|
|
92
|
+
table = Table(
|
|
93
|
+
box=box.ROUNDED,
|
|
94
|
+
border_style="grey46",
|
|
95
|
+
header_style="bold steel_blue",
|
|
96
|
+
expand=True,
|
|
97
|
+
)
|
|
98
|
+
table.add_column("#", justify="right", style="dim", no_wrap=True)
|
|
99
|
+
table.add_column("Command", overflow="fold")
|
|
100
|
+
table.add_column("Status", justify="right", no_wrap=True)
|
|
101
|
+
|
|
102
|
+
for index, command in enumerate(commands, start=1):
|
|
103
|
+
status = statuses[index - 1]
|
|
104
|
+
table.add_row(str(index), command, Text(status, style=_status_style(status)))
|
|
105
|
+
|
|
106
|
+
return table
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _workflow_view(commands, statuses):
|
|
110
|
+
loader = Spinner(
|
|
111
|
+
SPINNER_NAME,
|
|
112
|
+
text=Text("Your workflow is running, check the status.", style="bold steel_blue"),
|
|
113
|
+
style="steel_blue",
|
|
114
|
+
)
|
|
115
|
+
return Panel(
|
|
116
|
+
Group(loader, "", _workflow_table(commands, statuses)),
|
|
117
|
+
title="Workflow status",
|
|
118
|
+
border_style="grey46",
|
|
119
|
+
box=box.ROUNDED,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _trim_output(output, max_lines=18):
|
|
124
|
+
output = _stringify_output(output)
|
|
125
|
+
lines = output.strip().splitlines()
|
|
126
|
+
if len(lines) <= max_lines:
|
|
127
|
+
return "\n".join(lines)
|
|
128
|
+
return "\n".join(["..."] + lines[-max_lines:])
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _stringify_output(output):
|
|
132
|
+
if output is None:
|
|
133
|
+
return ""
|
|
134
|
+
if isinstance(output, bytes):
|
|
135
|
+
return output.decode(errors="replace")
|
|
136
|
+
return str(output)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _extract_git_upstream_hint(command, stderr):
|
|
140
|
+
if command.strip() != "git push":
|
|
141
|
+
return None
|
|
142
|
+
if "has no upstream branch" not in stderr:
|
|
143
|
+
return None
|
|
144
|
+
|
|
145
|
+
match = re.search(r"git push --set-upstream origin [^\s]+", stderr)
|
|
146
|
+
if not match:
|
|
147
|
+
return None
|
|
148
|
+
|
|
149
|
+
return f"Set the upstream once with: {match.group(0)}"
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _failure_message(command, returncode, stderr):
|
|
153
|
+
hint = _extract_git_upstream_hint(command, stderr)
|
|
154
|
+
if hint:
|
|
155
|
+
return f"command failed with exit code {returncode}. {hint}"
|
|
156
|
+
return f"command failed with exit code {returncode}"
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _show_failure_details(result):
|
|
160
|
+
data = result.get("data", {})
|
|
161
|
+
stderr = data.get("stderr", "").strip()
|
|
162
|
+
stdout = data.get("stdout", "").strip()
|
|
163
|
+
output = stderr or stdout or "No output captured."
|
|
164
|
+
|
|
165
|
+
body = Group(
|
|
166
|
+
Text(f"Command: {data.get('command', '-')}", style="bold"),
|
|
167
|
+
Text(f"Exit code: {data.get('returncode', '-')}", style="red"),
|
|
168
|
+
"",
|
|
169
|
+
Text(_trim_output(output)),
|
|
170
|
+
)
|
|
171
|
+
console.print(
|
|
172
|
+
Panel(
|
|
173
|
+
body,
|
|
174
|
+
title="Command failed",
|
|
175
|
+
border_style="red",
|
|
176
|
+
box=box.ROUNDED,
|
|
177
|
+
)
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def run_command(command):
|
|
182
|
+
try:
|
|
183
|
+
completed = subprocess.run(
|
|
184
|
+
command,
|
|
185
|
+
shell=True,
|
|
186
|
+
capture_output=True,
|
|
187
|
+
text=True,
|
|
188
|
+
timeout=COMMAND_TIMEOUT_SECONDS,
|
|
189
|
+
)
|
|
190
|
+
except subprocess.TimeoutExpired as error:
|
|
191
|
+
data = {
|
|
192
|
+
"command": command,
|
|
193
|
+
"returncode": None,
|
|
194
|
+
"stdout": _stringify_output(error.stdout),
|
|
195
|
+
"stderr": _stringify_output(error.stderr),
|
|
196
|
+
"timeout": COMMAND_TIMEOUT_SECONDS,
|
|
197
|
+
}
|
|
198
|
+
return _result(1, "error", f"command timed out after {COMMAND_TIMEOUT_SECONDS} seconds", data)
|
|
199
|
+
except OSError as error:
|
|
200
|
+
data = {
|
|
201
|
+
"command": command,
|
|
202
|
+
"returncode": None,
|
|
203
|
+
"stdout": "",
|
|
204
|
+
"stderr": str(error),
|
|
205
|
+
}
|
|
206
|
+
return _result(1, "error", f"could not run command: {error}", data)
|
|
207
|
+
|
|
208
|
+
data = {
|
|
209
|
+
"command": command,
|
|
210
|
+
"returncode": completed.returncode,
|
|
211
|
+
"stdout": completed.stdout or "",
|
|
212
|
+
"stderr": completed.stderr or "",
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if completed.returncode != 0:
|
|
216
|
+
return _result(
|
|
217
|
+
1,
|
|
218
|
+
"error",
|
|
219
|
+
_failure_message(command, completed.returncode, data["stderr"]),
|
|
220
|
+
data,
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
return _result(0, "success", "command completed successfully", data)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def run_workflow_commands(commands, dry_run=False):
|
|
227
|
+
if dry_run:
|
|
228
|
+
return _result(
|
|
229
|
+
0,
|
|
230
|
+
"success",
|
|
231
|
+
"dry run completed",
|
|
232
|
+
{"dry_run": True, "commands": commands},
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
dangerous_commands = [command for command in commands if is_dangerous_command(command)]
|
|
236
|
+
if dangerous_commands:
|
|
237
|
+
console.print("[bold yellow]Dangerous command detected:[/bold yellow]")
|
|
238
|
+
for command in dangerous_commands:
|
|
239
|
+
console.print(f" {command}")
|
|
240
|
+
|
|
241
|
+
if not Confirm.ask("Continue anyway?", default=False):
|
|
242
|
+
return _result(2, "warning", "workflow cancelled by user")
|
|
243
|
+
|
|
244
|
+
statuses = [STATUS_PENDING for _ in commands]
|
|
245
|
+
failed_result = None
|
|
246
|
+
|
|
247
|
+
with Live(
|
|
248
|
+
_workflow_view(commands, statuses),
|
|
249
|
+
console=console,
|
|
250
|
+
refresh_per_second=8,
|
|
251
|
+
) as live:
|
|
252
|
+
for index, command in enumerate(commands):
|
|
253
|
+
statuses[index] = STATUS_RUNNING
|
|
254
|
+
live.update(_workflow_view(commands, statuses))
|
|
255
|
+
|
|
256
|
+
result = run_command(command)
|
|
257
|
+
if result["code"] != 0:
|
|
258
|
+
statuses[index] = STATUS_FAILED
|
|
259
|
+
for remaining_index in range(index + 1, len(statuses)):
|
|
260
|
+
statuses[remaining_index] = STATUS_SKIPPED
|
|
261
|
+
live.update(_workflow_view(commands, statuses))
|
|
262
|
+
failed_result = result
|
|
263
|
+
break
|
|
264
|
+
|
|
265
|
+
statuses[index] = STATUS_DONE
|
|
266
|
+
live.update(_workflow_view(commands, statuses))
|
|
267
|
+
|
|
268
|
+
if failed_result is not None:
|
|
269
|
+
console.print()
|
|
270
|
+
_show_failure_details(failed_result)
|
|
271
|
+
return failed_result
|
|
272
|
+
|
|
273
|
+
return _result(
|
|
274
|
+
0,
|
|
275
|
+
"success",
|
|
276
|
+
"workflow completed successfully",
|
|
277
|
+
{"dry_run": False, "commands": commands},
|
|
278
|
+
)
|