sagex-cli 1.0.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.
- sagex/__init__.py +3 -0
- sagex/__main__.py +2907 -0
- sagex/_env.py +7 -0
- sagex/api/__init__.py +3 -0
- sagex/api/client.py +195 -0
- sagex/api/resources.py +763 -0
- sagex/api/store.py +52 -0
- sagex/app.py +378 -0
- sagex/app.tcss +63 -0
- sagex/config.py +88 -0
- sagex/copier.py +277 -0
- sagex/formatting.py +126 -0
- sagex/pusher.py +350 -0
- sagex/render.py +1046 -0
- sagex/runner.py +198 -0
- sagex/runview.py +247 -0
- sagex/shell.py +231 -0
- sagex/widgets/__init__.py +1 -0
- sagex/widgets/activity.py +56 -0
- sagex/widgets/message.py +78 -0
- sagex/widgets/prompt_input.py +54 -0
- sagex/widgets/run_graph.py +58 -0
- sagex_cli-1.0.0.dist-info/METADATA +210 -0
- sagex_cli-1.0.0.dist-info/RECORD +28 -0
- sagex_cli-1.0.0.dist-info/WHEEL +5 -0
- sagex_cli-1.0.0.dist-info/entry_points.txt +2 -0
- sagex_cli-1.0.0.dist-info/licenses/LICENSE +21 -0
- sagex_cli-1.0.0.dist-info/top_level.txt +1 -0
sagex/render.py
ADDED
|
@@ -0,0 +1,1046 @@
|
|
|
1
|
+
"""Terminal rendering for the `sagex show` commands (Rich console output).
|
|
2
|
+
|
|
3
|
+
This is the CLI-side presentation layer: it takes the plain data returned by
|
|
4
|
+
`sagex.api.resources` and prints it nicely to stdout. The TUI will render the
|
|
5
|
+
same data as widgets later; only this file is CLI-specific.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import difflib
|
|
9
|
+
import sys
|
|
10
|
+
import threading
|
|
11
|
+
import time
|
|
12
|
+
from contextlib import contextmanager
|
|
13
|
+
from datetime import datetime, timezone
|
|
14
|
+
|
|
15
|
+
from rich.cells import cell_len
|
|
16
|
+
from rich.console import Console, Group
|
|
17
|
+
from rich.live import Live
|
|
18
|
+
from rich.panel import Panel
|
|
19
|
+
from rich.syntax import Syntax
|
|
20
|
+
from rich.table import Table
|
|
21
|
+
from rich.text import Text
|
|
22
|
+
|
|
23
|
+
from sagex.formatting import STATUS_ICON, RunActivity, relative_time
|
|
24
|
+
|
|
25
|
+
console = Console()
|
|
26
|
+
|
|
27
|
+
# Live-run chrome (header, script stderr, notes, summary) goes to stderr so that
|
|
28
|
+
# `sagex run ... > out.txt` captures only the script's own stdout.
|
|
29
|
+
err_console = Console(stderr=True, highlight=False)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def note(text: str) -> None:
|
|
33
|
+
"""A dim one-line note (e.g. logs unavailable / hint)."""
|
|
34
|
+
console.print(Text(text, style="bright_black"))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# --- live runs (`sagex run`) -----------------------------------------------
|
|
38
|
+
|
|
39
|
+
def run_header(script_name: str, server_name: str, details: list[str],
|
|
40
|
+
inputs: dict, secret_names: set) -> None:
|
|
41
|
+
"""'▶ deploy.sh on web-1 · host · vault · key' plus the inputs (secrets masked)."""
|
|
42
|
+
line = Text()
|
|
43
|
+
line.append("▶ ", style="cyan")
|
|
44
|
+
line.append(script_name, style="bold")
|
|
45
|
+
line.append(" on ")
|
|
46
|
+
line.append(server_name, style="bold")
|
|
47
|
+
for detail in details:
|
|
48
|
+
if detail:
|
|
49
|
+
line.append(f" · {detail}", style="bright_black")
|
|
50
|
+
err_console.print(line, soft_wrap=True)
|
|
51
|
+
if inputs:
|
|
52
|
+
shown = Text(" ")
|
|
53
|
+
for i, (name, value) in enumerate(inputs.items()):
|
|
54
|
+
if i:
|
|
55
|
+
shown.append(" ")
|
|
56
|
+
shown.append(f"{name}=", style="bright_black")
|
|
57
|
+
shown.append("*****" if name in secret_names else (value or '""'))
|
|
58
|
+
err_console.print(shown, soft_wrap=True)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def workflow_run_header(name: str, node_count: int, inputs: dict, via: str | None = None) -> None:
|
|
62
|
+
"""'▶ Deploy Production · 4 nodes' (+ how it was started) and the parameter values
|
|
63
|
+
(secrets pre-masked)."""
|
|
64
|
+
line = Text()
|
|
65
|
+
line.append("▶ ", style="cyan")
|
|
66
|
+
line.append(name, style="bold")
|
|
67
|
+
line.append(f" · {node_count} node{'s' if node_count != 1 else ''}", style="bright_black")
|
|
68
|
+
if via:
|
|
69
|
+
line.append(f" · {via}", style="bright_black")
|
|
70
|
+
err_console.print(line, soft_wrap=True)
|
|
71
|
+
if inputs:
|
|
72
|
+
shown = Text(" ")
|
|
73
|
+
for i, (label, value) in enumerate(inputs.items()):
|
|
74
|
+
if i:
|
|
75
|
+
shown.append(" ")
|
|
76
|
+
shown.append(f"{label}=", style="bright_black")
|
|
77
|
+
shown.append(value or '""')
|
|
78
|
+
err_console.print(shown, soft_wrap=True)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def workflow_run_graph(view) -> None:
|
|
82
|
+
"""Print a run's final graph (its bordered panel) once, after a blank line."""
|
|
83
|
+
err_console.print()
|
|
84
|
+
err_console.print(view)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def workflow_node_start(label: str, kind: str) -> None:
|
|
88
|
+
"""'› Check service · script' — printed when a node begins, after a blank line
|
|
89
|
+
so each node's output reads as its own section."""
|
|
90
|
+
err_console.print()
|
|
91
|
+
line = Text()
|
|
92
|
+
line.append("› ", style="cyan")
|
|
93
|
+
line.append(label, style="bold")
|
|
94
|
+
if kind:
|
|
95
|
+
line.append(f" · {kind}", style="bright_black")
|
|
96
|
+
err_console.print(line, soft_wrap=True)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def workflow_node_done(label: str, status: str | None, exit_code=None, duration=None,
|
|
100
|
+
branch: str | None = None) -> None:
|
|
101
|
+
"""'✓ Check service · exit 0 · 2.3s' — printed when a node finishes."""
|
|
102
|
+
icon, color = STATUS_ICON.get(status or "", ("•", "white"))
|
|
103
|
+
parts = []
|
|
104
|
+
if status and status not in ("success", "failed"):
|
|
105
|
+
parts.append(status)
|
|
106
|
+
if branch:
|
|
107
|
+
parts.append(f"{branch} branch")
|
|
108
|
+
if exit_code is not None and exit_code != -1:
|
|
109
|
+
parts.append(f"exit {exit_code}")
|
|
110
|
+
if duration and status != "skipped":
|
|
111
|
+
parts.append(_elapsed(float(duration)))
|
|
112
|
+
line = Text()
|
|
113
|
+
line.append(f"{icon} ", style=color)
|
|
114
|
+
line.append(label, style=color if status == "failed" else "")
|
|
115
|
+
if parts:
|
|
116
|
+
line.append(" · " + " · ".join(parts), style="bright_black")
|
|
117
|
+
err_console.print(line, soft_wrap=True)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def run_stdout(text: str) -> None:
|
|
121
|
+
"""A line of the script's stdout, written raw (no styling) so it pipes cleanly.
|
|
122
|
+
|
|
123
|
+
While the activity line is showing on the same terminal, it goes through the
|
|
124
|
+
live console instead, which prints it above the activity line.
|
|
125
|
+
"""
|
|
126
|
+
if _stdout_through_live:
|
|
127
|
+
err_console.print(Text.from_ansi(text), soft_wrap=True)
|
|
128
|
+
else:
|
|
129
|
+
print(text, flush=True)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
_stdout_through_live = False
|
|
133
|
+
# One live display at a time: an explicit run_activity() suppresses the per-request
|
|
134
|
+
# waiting() line (e.g. the parallel lookups that `run script` makes under its own).
|
|
135
|
+
_live_lock = threading.Lock()
|
|
136
|
+
_explicit_active = False
|
|
137
|
+
_wait_depth = 0
|
|
138
|
+
_wait_live: Live | None = None
|
|
139
|
+
_wait_activity: RunActivity | None = None
|
|
140
|
+
_wait_ended = 0.0
|
|
141
|
+
|
|
142
|
+
_WAIT_VERBS = {"GET": "Loading", "DELETE": "Deleting"}
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _activity_live(renderable) -> Live:
|
|
146
|
+
# Live's default stdout redirect would push piped output into stderr; output is
|
|
147
|
+
# routed explicitly instead (see run_stdout()).
|
|
148
|
+
return Live(renderable, console=err_console, refresh_per_second=12, transient=True,
|
|
149
|
+
redirect_stdout=False, redirect_stderr=False)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class _Pinned:
|
|
153
|
+
"""A block (e.g. the live workflow graph) pinned above the activity line."""
|
|
154
|
+
|
|
155
|
+
def __init__(self, above, activity: RunActivity) -> None:
|
|
156
|
+
self.above, self.activity = above, activity
|
|
157
|
+
|
|
158
|
+
def __rich__(self):
|
|
159
|
+
# A blank line sets the pinned block off from the log scrolling above it.
|
|
160
|
+
return Group(Text(""), self.above, self.activity)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@contextmanager
|
|
164
|
+
def run_activity(activity: RunActivity, above=None):
|
|
165
|
+
"""Show `activity` on stderr while the block runs (no-op when stderr isn't a
|
|
166
|
+
terminal), with `above` (re-rendered every frame) pinned over it. Output printed
|
|
167
|
+
meanwhile scrolls above the live region, which is cleared on exit."""
|
|
168
|
+
global _stdout_through_live, _explicit_active
|
|
169
|
+
if not err_console.is_terminal:
|
|
170
|
+
yield
|
|
171
|
+
return
|
|
172
|
+
with _live_lock:
|
|
173
|
+
_explicit_active = True
|
|
174
|
+
_stdout_through_live = sys.stdout.isatty()
|
|
175
|
+
try:
|
|
176
|
+
with _activity_live(_Pinned(above, activity) if above is not None else activity):
|
|
177
|
+
yield
|
|
178
|
+
finally:
|
|
179
|
+
_stdout_through_live = False
|
|
180
|
+
with _live_lock:
|
|
181
|
+
_explicit_active = False
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@contextmanager
|
|
185
|
+
def waiting(method: str):
|
|
186
|
+
"""Show the activity line while one API request is in flight.
|
|
187
|
+
|
|
188
|
+
Installed as the API client's wait indicator for CLI subcommands. The line is
|
|
189
|
+
cleared as soon as the request returns, so it never overlaps the command's own
|
|
190
|
+
output or prompts. Back-to-back requests keep one running timer; a pause longer
|
|
191
|
+
than a second (e.g. a confirmation prompt) starts a fresh one.
|
|
192
|
+
"""
|
|
193
|
+
global _wait_depth, _wait_live, _wait_activity, _wait_ended
|
|
194
|
+
with _live_lock:
|
|
195
|
+
if not _explicit_active and err_console.is_terminal:
|
|
196
|
+
if _wait_depth == 0:
|
|
197
|
+
verb = _WAIT_VERBS.get(method.upper(), "Saving")
|
|
198
|
+
if _wait_activity is None or time.monotonic() - _wait_ended > 1.0:
|
|
199
|
+
_wait_activity = RunActivity(verb, "")
|
|
200
|
+
else:
|
|
201
|
+
_wait_activity.set(verb)
|
|
202
|
+
_wait_live = _activity_live(_wait_activity)
|
|
203
|
+
_wait_live.start()
|
|
204
|
+
_wait_depth += 1
|
|
205
|
+
counted = True
|
|
206
|
+
else:
|
|
207
|
+
counted = False
|
|
208
|
+
try:
|
|
209
|
+
yield
|
|
210
|
+
finally:
|
|
211
|
+
if counted:
|
|
212
|
+
with _live_lock:
|
|
213
|
+
_wait_depth -= 1
|
|
214
|
+
if _wait_depth == 0 and _wait_live is not None:
|
|
215
|
+
_wait_live.stop()
|
|
216
|
+
_wait_live = None
|
|
217
|
+
_wait_ended = time.monotonic()
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def run_stderr(text: str) -> None:
|
|
221
|
+
"""A line of the script's stderr (or a run error), in red on stderr."""
|
|
222
|
+
err_console.print(Text(text, style="red"), soft_wrap=True)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def run_note(text: str) -> None:
|
|
226
|
+
"""A dim status line on stderr (worker log lines, stop/detach notices)."""
|
|
227
|
+
err_console.print(Text(text, style="bright_black"), soft_wrap=True)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _elapsed(seconds: float) -> str:
|
|
231
|
+
if seconds < 60:
|
|
232
|
+
return f"{seconds:.1f}s"
|
|
233
|
+
return f"{int(seconds // 60)}m {int(seconds % 60)}s"
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def run_summary(status: str | None, exit_code, seconds: float, execution_id: str | None,
|
|
237
|
+
noun: str = "execution") -> None:
|
|
238
|
+
"""'✓ completed · exit 0 · 3.4s · execution <id>' (noun 'run' for workflows)."""
|
|
239
|
+
icon, color = STATUS_ICON.get(status or "", ("•", "white"))
|
|
240
|
+
parts = []
|
|
241
|
+
if exit_code is not None and exit_code != -1:
|
|
242
|
+
parts.append(f"exit {exit_code}")
|
|
243
|
+
parts.append(_elapsed(seconds))
|
|
244
|
+
if execution_id:
|
|
245
|
+
parts.append(f"{noun} {execution_id}")
|
|
246
|
+
line = Text()
|
|
247
|
+
line.append(f"{icon} {status or 'unknown'}", style=f"bold {color}")
|
|
248
|
+
line.append(" · " + " · ".join(parts), style="bright_black")
|
|
249
|
+
err_console.print(line, soft_wrap=True)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
# --- shared helpers --------------------------------------------------------
|
|
253
|
+
|
|
254
|
+
def print_json(data) -> None:
|
|
255
|
+
"""Dump a record as pretty JSON (for the --json flag / piping)."""
|
|
256
|
+
console.print_json(data=data)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def ambiguous(exc) -> None:
|
|
260
|
+
"""Print the matches for an ambiguous name and how to disambiguate."""
|
|
261
|
+
console.print(
|
|
262
|
+
f"[yellow]Multiple {exc.kind}s match [bold]{exc.ref}[/bold]. "
|
|
263
|
+
f"Re-run with an id:[/yellow]"
|
|
264
|
+
)
|
|
265
|
+
table = Table(show_header=True, header_style="bold", box=None, pad_edge=False)
|
|
266
|
+
table.add_column("id", style="cyan", no_wrap=True)
|
|
267
|
+
table.add_column("name")
|
|
268
|
+
table.add_column("", style="bright_black") # hint column
|
|
269
|
+
for m in exc.matches:
|
|
270
|
+
table.add_row(str(m.get("id")), m.get("name") or "(unnamed)", m.get("hint") or "")
|
|
271
|
+
console.print(table)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _kv(label: str, value) -> Text:
|
|
275
|
+
"""One 'Label: value' line with a dim label."""
|
|
276
|
+
line = Text()
|
|
277
|
+
line.append(f"{label}: ", style="bright_black")
|
|
278
|
+
line.append("" if value is None else str(value))
|
|
279
|
+
return line
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
# --- workflow --------------------------------------------------------------
|
|
283
|
+
|
|
284
|
+
def workflow(wf: dict) -> None:
|
|
285
|
+
"""Show a workflow: header panel + a table of its nodes."""
|
|
286
|
+
nodes = wf.get("nodes") or []
|
|
287
|
+
edges = wf.get("edges") or []
|
|
288
|
+
|
|
289
|
+
header = Text()
|
|
290
|
+
header.append(_kv("id", wf.get("id"))); header.append("\n")
|
|
291
|
+
header.append(_kv("description", wf.get("description") or "—")); header.append("\n")
|
|
292
|
+
header.append(_kv("nodes", f"{len(nodes)} edges: {len(edges)}")); header.append("\n")
|
|
293
|
+
header.append(_kv("created", wf.get("created_at"))); header.append("\n")
|
|
294
|
+
header.append(_kv("modified", wf.get("modified_at")))
|
|
295
|
+
console.print(Panel(header, title=wf.get("name") or "(unnamed workflow)", title_align="left"))
|
|
296
|
+
|
|
297
|
+
if nodes:
|
|
298
|
+
table = Table(title="Nodes", show_header=True, header_style="bold", title_justify="left")
|
|
299
|
+
table.add_column("id", style="cyan", no_wrap=True)
|
|
300
|
+
table.add_column("type")
|
|
301
|
+
table.add_column("label")
|
|
302
|
+
for n in nodes:
|
|
303
|
+
data = n.get("data") if isinstance(n.get("data"), dict) else {}
|
|
304
|
+
label = data.get("label") or n.get("label") or ""
|
|
305
|
+
table.add_row(str(n.get("id") or ""), str(n.get("type") or ""), str(label))
|
|
306
|
+
console.print(table)
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
# --- workflow graph (text visualization) -----------------------------------
|
|
310
|
+
|
|
311
|
+
# icon + colour + short kind label, keyed off node type (and action's data.type).
|
|
312
|
+
def _node_style(node: dict) -> tuple[str, str, str]:
|
|
313
|
+
ntype = node.get("type")
|
|
314
|
+
data = node.get("data") if isinstance(node.get("data"), dict) else {}
|
|
315
|
+
dtype = data.get("type")
|
|
316
|
+
if ntype == "trigger":
|
|
317
|
+
return "▸", "cyan", f"trigger:{dtype or 'manual'}"
|
|
318
|
+
if ntype == "decision":
|
|
319
|
+
return "◆", "yellow", "decision"
|
|
320
|
+
if ntype == "action":
|
|
321
|
+
if dtype == "email":
|
|
322
|
+
return "■", "blue", "action:email"
|
|
323
|
+
if dtype == "script":
|
|
324
|
+
return "●", "green", "action:script"
|
|
325
|
+
return "•", "white", f"action:{dtype or '?'}"
|
|
326
|
+
return "•", "white", str(ntype or "?")
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _node_box(node: dict | None, node_id) -> tuple[list[Text], int]:
|
|
330
|
+
"""Draw a node as a rounded rectangle. Returns (lines, box_width_in_cells).
|
|
331
|
+
|
|
332
|
+
The icon glyphs (⚡⚙✉◆) are counted as one cell each — Rich's cell_len calls
|
|
333
|
+
some of them width-2 (emoji), but terminals render them width-1, so we measure
|
|
334
|
+
the head line as icon(1) + space + label to keep the right border aligned.
|
|
335
|
+
"""
|
|
336
|
+
if node is None:
|
|
337
|
+
icon, color, kind = "•", "red", "missing node"
|
|
338
|
+
label = str(node_id)
|
|
339
|
+
else:
|
|
340
|
+
icon, color, kind = _node_style(node)
|
|
341
|
+
data = node.get("data") if isinstance(node.get("data"), dict) else {}
|
|
342
|
+
label = str(data.get("label") or node.get("label") or node_id)
|
|
343
|
+
if len(label) > 42:
|
|
344
|
+
label = label[:41] + "…"
|
|
345
|
+
|
|
346
|
+
meta = f"{kind} {node_id}" if str(node_id) != label else kind
|
|
347
|
+
head_w = 2 + cell_len(label) # icon(1) + space(1) + label
|
|
348
|
+
inner = max(head_w, cell_len(meta))
|
|
349
|
+
|
|
350
|
+
def _row(prefix_runs, width, close=True):
|
|
351
|
+
row = Text("│ ", style=color)
|
|
352
|
+
for text, style in prefix_runs:
|
|
353
|
+
row.append(text, style=style)
|
|
354
|
+
row.append(" " * (inner - width) + " ", style=color)
|
|
355
|
+
row.append("│", style=color)
|
|
356
|
+
return row
|
|
357
|
+
|
|
358
|
+
return (
|
|
359
|
+
[
|
|
360
|
+
Text("╭" + "─" * (inner + 2) + "╮", style=color),
|
|
361
|
+
_row([(f"{icon} ", color), (label, color)], head_w),
|
|
362
|
+
_row([(meta, "bright_black")], cell_len(meta)),
|
|
363
|
+
Text("╰" + "─" * (inner + 2) + "╯", style=color),
|
|
364
|
+
],
|
|
365
|
+
inner + 4,
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _prefix(lines: list[Text], first: str, rest: str, style: str = "bright_black") -> list[Text]:
|
|
370
|
+
"""Left-attach a connector to a block: `first` on line 0, `rest` on the others."""
|
|
371
|
+
out = []
|
|
372
|
+
for i, line in enumerate(lines):
|
|
373
|
+
row = Text(first if i == 0 else rest, style=style)
|
|
374
|
+
row.append(line)
|
|
375
|
+
out.append(row)
|
|
376
|
+
return out
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def workflow_graph(wf: dict) -> None:
|
|
380
|
+
"""Render a workflow as boxed nodes joined by flow edges.
|
|
381
|
+
|
|
382
|
+
Linear steps stack vertically under a centered spine (│▼); a decision fans out
|
|
383
|
+
to labelled elbow connectors (├─ true ▶ / └─ false ▶). A node reached by two
|
|
384
|
+
paths is drawn once and shown later as a back-reference (↑), keeping it finite.
|
|
385
|
+
"""
|
|
386
|
+
nodes = wf.get("nodes") or []
|
|
387
|
+
edges = wf.get("edges") or []
|
|
388
|
+
if not nodes:
|
|
389
|
+
note("(workflow has no nodes)")
|
|
390
|
+
return
|
|
391
|
+
|
|
392
|
+
by_id = {n.get("id"): n for n in nodes}
|
|
393
|
+
children: dict = {}
|
|
394
|
+
indeg = {n.get("id"): 0 for n in nodes}
|
|
395
|
+
for e in edges:
|
|
396
|
+
children.setdefault(e.get("source"), []).append(e)
|
|
397
|
+
if e.get("target") in indeg:
|
|
398
|
+
indeg[e.get("target")] += 1
|
|
399
|
+
|
|
400
|
+
roots = [n.get("id") for n in nodes if n.get("type") == "trigger"]
|
|
401
|
+
if not roots:
|
|
402
|
+
roots = [nid for nid, d in indeg.items() if d == 0] or [nodes[0].get("id")]
|
|
403
|
+
|
|
404
|
+
visited: set = set()
|
|
405
|
+
|
|
406
|
+
def block(node_id) -> list[Text]:
|
|
407
|
+
"""The subtree rooted at node_id, box top-left anchored at column 0."""
|
|
408
|
+
lines, width = _node_box(by_id.get(node_id), node_id)
|
|
409
|
+
visited.add(node_id)
|
|
410
|
+
kids = children.get(node_id, [])
|
|
411
|
+
|
|
412
|
+
if len(kids) == 1 and (by_id.get(node_id) or {}).get("type") != "decision":
|
|
413
|
+
spine = width // 2
|
|
414
|
+
lines.append(Text(" " * spine + "│", style="bright_black"))
|
|
415
|
+
lines.append(Text(" " * spine + "▼", style="bright_black"))
|
|
416
|
+
lines += _child(kids[0])
|
|
417
|
+
return lines
|
|
418
|
+
|
|
419
|
+
for i, e in enumerate(kids):
|
|
420
|
+
last = i == len(kids) - 1
|
|
421
|
+
handle = e.get("sourceHandle")
|
|
422
|
+
tag = f"{handle} " if handle else ""
|
|
423
|
+
first = ("└─ " if last else "├─ ") + f"{tag}▶ "
|
|
424
|
+
rest = (" " if last else "│") + " " * (len(first) - 1)
|
|
425
|
+
lines += _prefix(_child(e), first, rest)
|
|
426
|
+
return lines
|
|
427
|
+
|
|
428
|
+
def _child(edge) -> list[Text]:
|
|
429
|
+
target = edge.get("target")
|
|
430
|
+
if target in visited:
|
|
431
|
+
label = (by_id.get(target) or {}).get("data", {})
|
|
432
|
+
name = label.get("label") if isinstance(label, dict) else None
|
|
433
|
+
return [Text(f"↑ {name or target} (shown above)", style="bright_black")]
|
|
434
|
+
return block(target)
|
|
435
|
+
|
|
436
|
+
title = Text(wf.get("name") or "(workflow)", style="bold")
|
|
437
|
+
title.append(f" {len(nodes)} nodes · {len(edges)} edges", style="bright_black")
|
|
438
|
+
console.print(title)
|
|
439
|
+
|
|
440
|
+
for r in roots:
|
|
441
|
+
for line in block(r):
|
|
442
|
+
console.print(line)
|
|
443
|
+
|
|
444
|
+
leftover = [n.get("id") for n in nodes if n.get("id") not in visited]
|
|
445
|
+
if leftover:
|
|
446
|
+
console.print(Text("(not reachable from a trigger)", style="bright_black"))
|
|
447
|
+
for nid in leftover:
|
|
448
|
+
if nid not in visited:
|
|
449
|
+
for line in block(nid):
|
|
450
|
+
console.print(line)
|
|
451
|
+
|
|
452
|
+
console.print(Text("▸ trigger ● script ■ email ◆ decision", style="bright_black"))
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
# --- script ----------------------------------------------------------------
|
|
456
|
+
|
|
457
|
+
_EXT_LEXER = {
|
|
458
|
+
"py": "python", "js": "javascript", "ts": "typescript", "sh": "bash",
|
|
459
|
+
"bash": "bash", "ps1": "powershell", "rb": "ruby", "go": "go",
|
|
460
|
+
"json": "json", "yml": "yaml", "yaml": "yaml", "sql": "sql",
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def _lexer_for(name: str, content_type: str) -> str:
|
|
465
|
+
"""Best-effort syntax lexer from the file extension, then the MIME type."""
|
|
466
|
+
if name and "." in name:
|
|
467
|
+
ext = name.rsplit(".", 1)[-1].lower()
|
|
468
|
+
if ext in _EXT_LEXER:
|
|
469
|
+
return _EXT_LEXER[ext]
|
|
470
|
+
ct = (content_type or "").lower()
|
|
471
|
+
if "python" in ct:
|
|
472
|
+
return "python"
|
|
473
|
+
if "javascript" in ct:
|
|
474
|
+
return "javascript"
|
|
475
|
+
if "powershell" in ct:
|
|
476
|
+
return "powershell"
|
|
477
|
+
if "sh" in ct or "bash" in ct:
|
|
478
|
+
return "bash"
|
|
479
|
+
return "text"
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def script(meta: dict, content: dict | None) -> None:
|
|
483
|
+
"""Show a script: metadata panel + syntax-highlighted code (if fetched)."""
|
|
484
|
+
header = Text()
|
|
485
|
+
header.append(_kv("id", meta.get("id"))); header.append("\n")
|
|
486
|
+
header.append(_kv("pathname", meta.get("pathname") or "—")); header.append("\n")
|
|
487
|
+
header.append(_kv("type", meta.get("content_type"))); header.append("\n")
|
|
488
|
+
header.append(_kv("size", f"{meta.get('file_size')} bytes · v{meta.get('version')}")); header.append("\n")
|
|
489
|
+
header.append(_kv("updated", meta.get("updated_at")))
|
|
490
|
+
console.print(Panel(header, title=meta.get("name") or "(unnamed script)", title_align="left"))
|
|
491
|
+
|
|
492
|
+
if content is not None:
|
|
493
|
+
code = content.get("content") or ""
|
|
494
|
+
lexer = _lexer_for(content.get("name") or meta.get("name") or "",
|
|
495
|
+
content.get("content_type") or meta.get("content_type") or "")
|
|
496
|
+
console.print(Syntax(code, lexer, theme="ansi_dark", line_numbers=True, word_wrap=True))
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def script_diff(name: str, server_code: str, local_code: str) -> bool:
|
|
500
|
+
"""Print a unified diff of server→local script code. Returns True if they differ.
|
|
501
|
+
|
|
502
|
+
`-` lines are the current server version (about to be overwritten), `+` lines are
|
|
503
|
+
the local file. Used by `push script` to preview an update before it bumps the
|
|
504
|
+
version. Line-based; a difference in trailing newline alone is treated as no change.
|
|
505
|
+
"""
|
|
506
|
+
diff = list(difflib.unified_diff(
|
|
507
|
+
server_code.splitlines(), local_code.splitlines(),
|
|
508
|
+
fromfile=f"server:{name}", tofile=f"local:{name}", lineterm="",
|
|
509
|
+
))
|
|
510
|
+
if not diff:
|
|
511
|
+
return False
|
|
512
|
+
for line in diff:
|
|
513
|
+
if line.startswith(("+++", "---")):
|
|
514
|
+
console.print(Text(line, style="bold"))
|
|
515
|
+
elif line.startswith("@@"):
|
|
516
|
+
console.print(Text(line, style="cyan"))
|
|
517
|
+
elif line.startswith("+"):
|
|
518
|
+
console.print(Text(line, style="green"))
|
|
519
|
+
elif line.startswith("-"):
|
|
520
|
+
console.print(Text(line, style="red"))
|
|
521
|
+
else:
|
|
522
|
+
console.print(Text(line, style="bright_black"))
|
|
523
|
+
return True
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
# --- run -------------------------------------------------------------------
|
|
527
|
+
|
|
528
|
+
def _duration(started, finished) -> str | None:
|
|
529
|
+
"""Human 'finished - started' duration, or None if not computable."""
|
|
530
|
+
if not started or not finished:
|
|
531
|
+
return None
|
|
532
|
+
try:
|
|
533
|
+
secs = (datetime.fromisoformat(finished) - datetime.fromisoformat(started)).total_seconds()
|
|
534
|
+
except ValueError:
|
|
535
|
+
return None
|
|
536
|
+
if secs < 0:
|
|
537
|
+
return None
|
|
538
|
+
if secs < 60:
|
|
539
|
+
return f"{secs:.1f}s"
|
|
540
|
+
minutes, sec = divmod(int(secs), 60)
|
|
541
|
+
if minutes < 60:
|
|
542
|
+
return f"{minutes}m {sec}s"
|
|
543
|
+
hours, minutes = divmod(minutes, 60)
|
|
544
|
+
return f"{hours}h {minutes}m"
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def run(r: dict, nodes: list[dict]) -> None:
|
|
548
|
+
"""Show a run: header panel + a per-node results table."""
|
|
549
|
+
status = r.get("status") or "unknown"
|
|
550
|
+
icon, color = STATUS_ICON.get(status, ("•", "white"))
|
|
551
|
+
|
|
552
|
+
header = Text()
|
|
553
|
+
header.append(_kv("id", r.get("id"))); header.append("\n")
|
|
554
|
+
st = Text(); st.append("status: ", style="bright_black"); st.append(f"{icon} {status}", style=color)
|
|
555
|
+
header.append(st); header.append("\n")
|
|
556
|
+
header.append(_kv("created", r.get("created_at"))); header.append("\n")
|
|
557
|
+
header.append(_kv("started", r.get("started_at") or "—")); header.append("\n")
|
|
558
|
+
header.append(_kv("finished", r.get("finished_at") or "—")); header.append("\n")
|
|
559
|
+
header.append(_kv("duration", _duration(r.get("started_at"), r.get("finished_at")) or "—"))
|
|
560
|
+
if r.get("inputs"):
|
|
561
|
+
header.append("\n"); header.append(_kv("inputs", r.get("inputs")))
|
|
562
|
+
if r.get("error_message"):
|
|
563
|
+
header.append("\n")
|
|
564
|
+
err = Text(); err.append("error: ", style="bright_black"); err.append(str(r.get("error_message")), style="red")
|
|
565
|
+
header.append(err)
|
|
566
|
+
console.print(Panel(header, title=r.get("workflow_name") or "(run)", title_align="left"))
|
|
567
|
+
|
|
568
|
+
if nodes:
|
|
569
|
+
ordered = sorted(nodes, key=lambda n: n.get("execution_order") or 0)
|
|
570
|
+
table = Table(title="Nodes", show_header=True, header_style="bold", title_justify="left")
|
|
571
|
+
table.add_column("#", style="bright_black", no_wrap=True)
|
|
572
|
+
table.add_column("node")
|
|
573
|
+
table.add_column("status")
|
|
574
|
+
table.add_column("exit", justify="right")
|
|
575
|
+
table.add_column("error")
|
|
576
|
+
for n in ordered:
|
|
577
|
+
nstatus = n.get("status") or ""
|
|
578
|
+
_, ncolor = STATUS_ICON.get(nstatus, ("•", "white"))
|
|
579
|
+
code = n.get("exit_code")
|
|
580
|
+
table.add_row(
|
|
581
|
+
str(n.get("execution_order") if n.get("execution_order") is not None else ""),
|
|
582
|
+
str(n.get("node_label") or n.get("node_id") or ""),
|
|
583
|
+
Text(nstatus, style=ncolor),
|
|
584
|
+
"" if code is None else str(code),
|
|
585
|
+
str(n.get("error_message") or ""),
|
|
586
|
+
)
|
|
587
|
+
console.print(table)
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
def run_logs(entries: list[tuple[str, str, str]]) -> None:
|
|
591
|
+
"""Dump collected log bodies: entries are (node_label, stream, text)."""
|
|
592
|
+
if not entries:
|
|
593
|
+
note("(no log output)")
|
|
594
|
+
return
|
|
595
|
+
for label, stream, text in entries:
|
|
596
|
+
body = Text(text.rstrip() or "(empty)")
|
|
597
|
+
console.print(Panel(body, title=f"{label} · {stream}", title_align="left", border_style="bright_black"))
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
# --- trigger ---------------------------------------------------------------
|
|
601
|
+
|
|
602
|
+
def trigger(kind: str, t: dict) -> None:
|
|
603
|
+
"""Show a trigger's detail. Only ever displays the last 4 of a secret."""
|
|
604
|
+
header = Text()
|
|
605
|
+
header.append(_kv("kind", kind)); header.append("\n")
|
|
606
|
+
header.append(_kv("node", t.get("node_id"))); header.append("\n")
|
|
607
|
+
st = Text(); st.append("status: ", style="bright_black")
|
|
608
|
+
if t.get("is_active"):
|
|
609
|
+
st.append("● enabled", style="green")
|
|
610
|
+
else:
|
|
611
|
+
st.append("○ disabled", style="bright_black")
|
|
612
|
+
header.append(st); header.append("\n")
|
|
613
|
+
header.append(_kv("created", t.get("created_at"))); header.append("\n")
|
|
614
|
+
header.append(_kv("last triggered", t.get("last_triggered_at") or "never"))
|
|
615
|
+
|
|
616
|
+
if kind == "http":
|
|
617
|
+
header.append("\n"); header.append(_kv("url", t.get("trigger_url")))
|
|
618
|
+
last4 = t.get("secret_last4")
|
|
619
|
+
header.append("\n"); header.append(_kv("secret", f"••••{last4}" if last4 else "—"))
|
|
620
|
+
if t.get("rotated_at"):
|
|
621
|
+
header.append("\n"); header.append(_kv("rotated", t.get("rotated_at")))
|
|
622
|
+
else:
|
|
623
|
+
header.append("\n"); header.append(_schedule_block(t))
|
|
624
|
+
header.append("\n"); header.append(_kv("timezone", t.get("timezone")))
|
|
625
|
+
if t.get("last_run_id"):
|
|
626
|
+
header.append("\n")
|
|
627
|
+
lr = Text(); lr.append("last run: ", style="bright_black")
|
|
628
|
+
status = t.get("last_run_status")
|
|
629
|
+
if status:
|
|
630
|
+
icon, color = STATUS_ICON.get(status, ("•", "white"))
|
|
631
|
+
lr.append(f"{icon} {status} ", style=color)
|
|
632
|
+
lr.append(str(t.get("last_run_id")))
|
|
633
|
+
header.append(lr)
|
|
634
|
+
if t.get("last_error"):
|
|
635
|
+
header.append("\n")
|
|
636
|
+
err = Text(); err.append("last error: ", style="bright_black"); err.append(str(t.get("last_error")), style="red")
|
|
637
|
+
header.append(err)
|
|
638
|
+
|
|
639
|
+
console.print(Panel(header, title=t.get("workflow_name") or "(trigger)", title_align="left"))
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
# --- trigger writes (create / regenerate / invoke) -------------------------
|
|
643
|
+
|
|
644
|
+
def _http_invocation(url: str, secret_display: str) -> None:
|
|
645
|
+
"""Print a copy-paste 'how to call this HTTP trigger' cheat-sheet."""
|
|
646
|
+
body = '{"inputs": {}}'
|
|
647
|
+
block = Text()
|
|
648
|
+
block.append("POST ", style="bold"); block.append(str(url)); block.append("\n")
|
|
649
|
+
block.append("Headers:\n", style="bright_black")
|
|
650
|
+
block.append(" X-Trigger-Secret: ", style="bright_black"); block.append(secret_display); block.append("\n")
|
|
651
|
+
block.append(" Idempotency-Key: ", style="bright_black"); block.append("<unique per request — required>"); block.append("\n")
|
|
652
|
+
block.append(" Content-Type: ", style="bright_black"); block.append("application/json"); block.append("\n")
|
|
653
|
+
block.append("Body:\n", style="bright_black")
|
|
654
|
+
block.append(f" {body}")
|
|
655
|
+
console.print(Panel(block, title="Call this HTTP trigger", title_align="left", border_style="cyan"))
|
|
656
|
+
|
|
657
|
+
curl = (
|
|
658
|
+
f'curl -X POST "{url}" \\\n'
|
|
659
|
+
f' -H "X-Trigger-Secret: {secret_display}" \\\n'
|
|
660
|
+
f' -H "Idempotency-Key: $(uuidgen)" \\\n'
|
|
661
|
+
f' -H "Content-Type: application/json" \\\n'
|
|
662
|
+
f' -d \'{body}\''
|
|
663
|
+
)
|
|
664
|
+
# soft_wrap so long URLs aren't hard-broken mid-token — keeps the curl copy-pasteable.
|
|
665
|
+
console.print(Text(curl, style="bright_black"), soft_wrap=True)
|
|
666
|
+
note("Response 202 → { workflow_run_id, status, polling_url }")
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
def http_trigger_result(data: dict) -> None:
|
|
670
|
+
"""After create/regenerate: show the plaintext secret ONCE + the invocation cheat-sheet."""
|
|
671
|
+
secret = data.get("secret")
|
|
672
|
+
console.print(Text("✓ HTTP trigger ready.", style="green"))
|
|
673
|
+
if secret:
|
|
674
|
+
console.print(Text("Secret — shown once, copy it now (it can't be retrieved again):", style="bold red"))
|
|
675
|
+
console.print(Text(f" {secret}", style="bold"))
|
|
676
|
+
_http_invocation(data.get("trigger_url"), secret or f"<your secret, ends ••••{data.get('secret_last4')}>")
|
|
677
|
+
note(f"Try it from here: sagex trigger fire {_quoted(data.get('workflow_name'))}")
|
|
678
|
+
|
|
679
|
+
|
|
680
|
+
def http_trigger_invocation(data: dict) -> None:
|
|
681
|
+
"""Reprint how to call an existing HTTP trigger (secret not shown — only last4)."""
|
|
682
|
+
if not data.get("is_active", True):
|
|
683
|
+
note("This trigger is disabled — enable it before calling: sagex trigger enable …")
|
|
684
|
+
_http_invocation(data.get("trigger_url"), f"<your secret, ends ••••{data.get('secret_last4')}>")
|
|
685
|
+
note("The secret is stored hashed and can't be shown. Lost it? Run: sagex trigger regenerate …")
|
|
686
|
+
note(f"Try it from here: sagex trigger fire {_quoted(data.get('workflow_name'))}")
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
def _quoted(name: str | None) -> str:
|
|
690
|
+
return f'"{name}"' if name else "<workflow>"
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
def describe_cron(expr: str) -> str | None:
|
|
694
|
+
"""Human-readable description of a cron expression (e.g. 'At 09:00, on day 1 …').
|
|
695
|
+
|
|
696
|
+
Autosage's scheduler (Celery Beat) needs BOTH day-of-month and day-of-week to
|
|
697
|
+
match when both are set — unlike classic cron's either/or, which is what
|
|
698
|
+
cron_descriptor assumes — so that case is worded explicitly. Best-effort:
|
|
699
|
+
returns None if the expression is empty or unparseable.
|
|
700
|
+
"""
|
|
701
|
+
if not expr:
|
|
702
|
+
return None
|
|
703
|
+
try:
|
|
704
|
+
from cron_descriptor import ExpressionDescriptor, get_description
|
|
705
|
+
|
|
706
|
+
fields = expr.split()
|
|
707
|
+
if len(fields) == 5 and fields[2] not in ("*", "?") and fields[4] not in ("*", "?"):
|
|
708
|
+
d = ExpressionDescriptor(expr)
|
|
709
|
+
dow = d.get_day_of_week_description().lstrip(", ")
|
|
710
|
+
for prefix in ("or on ", "only on "):
|
|
711
|
+
dow = dow.removeprefix(prefix)
|
|
712
|
+
return (f"{d.get_time_of_day_description()}{d.get_day_of_month_description()}"
|
|
713
|
+
f"{d.get_month_description()}, only when it falls on {dow}")
|
|
714
|
+
return get_description(expr)
|
|
715
|
+
except Exception:
|
|
716
|
+
return None
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
def time_until(seconds: float) -> str:
|
|
720
|
+
"""'in 12m', 'in 8h 12m', 'in 3d 4h' (or 'now')."""
|
|
721
|
+
s = int(seconds)
|
|
722
|
+
if s < 60:
|
|
723
|
+
return "now" if s <= 0 else "in <1m"
|
|
724
|
+
if s < 3600:
|
|
725
|
+
return f"in {s // 60}m"
|
|
726
|
+
if s < 86400:
|
|
727
|
+
return f"in {s // 3600}h {s % 3600 // 60:02d}m"
|
|
728
|
+
return f"in {s // 86400}d {s % 86400 // 3600}h"
|
|
729
|
+
|
|
730
|
+
|
|
731
|
+
def schedule_time(iso: str) -> Text:
|
|
732
|
+
"""'Mon 13 Oct 09:00 UTC · 14:30 local · in 8h 12m' for one upcoming run."""
|
|
733
|
+
try:
|
|
734
|
+
dt = datetime.fromisoformat(iso)
|
|
735
|
+
except (TypeError, ValueError):
|
|
736
|
+
return Text(str(iso))
|
|
737
|
+
if dt.tzinfo is None:
|
|
738
|
+
dt = dt.replace(tzinfo=timezone.utc)
|
|
739
|
+
utc, local = dt.astimezone(timezone.utc), dt.astimezone()
|
|
740
|
+
line = Text(utc.strftime("%a %d %b %H:%M UTC"))
|
|
741
|
+
if local.utcoffset() != utc.utcoffset():
|
|
742
|
+
fmt = "%H:%M" if local.date() == utc.date() else "%a %H:%M"
|
|
743
|
+
line.append(f" · {local.strftime(fmt)} local", style="bright_black")
|
|
744
|
+
line.append(f" · {time_until((utc - datetime.now(timezone.utc)).total_seconds())}", style="bright_black")
|
|
745
|
+
return line
|
|
746
|
+
|
|
747
|
+
|
|
748
|
+
def _schedule_block(data: dict) -> Text:
|
|
749
|
+
"""cron / when / next runs — shared by the create result and `show trigger`."""
|
|
750
|
+
body = Text()
|
|
751
|
+
body.append(_kv("cron", data.get("cron_expression")))
|
|
752
|
+
desc = describe_cron(data.get("cron_expression"))
|
|
753
|
+
if desc:
|
|
754
|
+
body.append("\n"); body.append(_kv("when", f"{desc} (UTC)"))
|
|
755
|
+
upcoming = data.get("next_runs") or []
|
|
756
|
+
if upcoming:
|
|
757
|
+
body.append("\n"); body.append(Text("next runs:", style="bright_black"))
|
|
758
|
+
for iso in upcoming:
|
|
759
|
+
body.append("\n "); body.append(schedule_time(iso))
|
|
760
|
+
elif data.get("is_active") is False:
|
|
761
|
+
body.append("\n"); body.append(_kv("next runs", "none — the schedule is disabled"))
|
|
762
|
+
return body
|
|
763
|
+
|
|
764
|
+
|
|
765
|
+
def schedule_trigger_result(data: dict, workflow_name: str | None = None) -> None:
|
|
766
|
+
"""Summary after creating/updating a schedule trigger."""
|
|
767
|
+
console.print(Text("✓ Schedule trigger ready.", style="green"))
|
|
768
|
+
body = Text()
|
|
769
|
+
body.append(_kv("node", data.get("node_id"))); body.append("\n")
|
|
770
|
+
body.append(_schedule_block(data)); body.append("\n")
|
|
771
|
+
active = data.get("is_active")
|
|
772
|
+
st = Text(); st.append("active: ", style="bright_black")
|
|
773
|
+
st.append("● yes", style="green") if active else st.append("○ no", style="bright_black")
|
|
774
|
+
body.append(st)
|
|
775
|
+
console.print(body)
|
|
776
|
+
note("Cron is 5 fields (min hour day-of-month month day-of-week), evaluated in UTC.")
|
|
777
|
+
name = _quoted(workflow_name)
|
|
778
|
+
note(f"Fire it now: sagex trigger fire {name}")
|
|
779
|
+
note(f"Watch the next run: sagex trigger watch {name}")
|
|
780
|
+
|
|
781
|
+
|
|
782
|
+
# --- credential (key) ------------------------------------------------------
|
|
783
|
+
|
|
784
|
+
_SECRET_FIELDS = ["username", "password", "ssh_key", "key_passphrase", "cert_pem"]
|
|
785
|
+
|
|
786
|
+
|
|
787
|
+
def credential(c: dict, secrets: dict | None) -> None:
|
|
788
|
+
"""Show a credential. Secrets are shown ONLY when `secrets` is provided."""
|
|
789
|
+
header = Text()
|
|
790
|
+
header.append(_kv("id", c.get("id"))); header.append("\n")
|
|
791
|
+
header.append(_kv("type", c.get("credential_type"))); header.append("\n")
|
|
792
|
+
header.append(_kv("vault", c.get("vault_name") or c.get("vault"))); header.append("\n")
|
|
793
|
+
header.append(_kv("created", c.get("created_at"))); header.append("\n")
|
|
794
|
+
header.append(_kv("modified", c.get("modified_at")))
|
|
795
|
+
console.print(Panel(header, title=c.get("name") or "(credential)", title_align="left"))
|
|
796
|
+
|
|
797
|
+
if secrets is None:
|
|
798
|
+
note("Secrets hidden — pass --reveal to display them (asks first).")
|
|
799
|
+
return
|
|
800
|
+
|
|
801
|
+
console.print(Text("⚠ Revealed secrets — now visible in your terminal/scrollback:", style="bold red"))
|
|
802
|
+
table = Table(show_header=False, box=None, pad_edge=False)
|
|
803
|
+
table.add_column("field", style="bright_black", no_wrap=True)
|
|
804
|
+
table.add_column("value")
|
|
805
|
+
shown = False
|
|
806
|
+
for field in _SECRET_FIELDS:
|
|
807
|
+
value = secrets.get(field)
|
|
808
|
+
if value:
|
|
809
|
+
table.add_row(field, Text(str(value)))
|
|
810
|
+
shown = True
|
|
811
|
+
console.print(table) if shown else note("(no secret values set)")
|
|
812
|
+
|
|
813
|
+
|
|
814
|
+
# --- server ----------------------------------------------------------------
|
|
815
|
+
|
|
816
|
+
def server(s: dict) -> None:
|
|
817
|
+
"""Show a vault server, incl. its linked credential name and vault name."""
|
|
818
|
+
host = s.get("host") or "—"
|
|
819
|
+
port = s.get("port")
|
|
820
|
+
cred = s.get("credential_details") if isinstance(s.get("credential_details"), dict) else {}
|
|
821
|
+
|
|
822
|
+
header = Text()
|
|
823
|
+
header.append(_kv("id", s.get("id"))); header.append("\n")
|
|
824
|
+
header.append(_kv("host", f"{host}:{port}" if port else host)); header.append("\n")
|
|
825
|
+
header.append(_kv("method", s.get("connection_method"))); header.append("\n")
|
|
826
|
+
header.append(_kv("credential", cred.get("name") or "—")); header.append("\n")
|
|
827
|
+
header.append(_kv("vault", s.get("vault_name") or s.get("vault")));header.append("\n")
|
|
828
|
+
header.append(_kv("created", s.get("created_at"))); header.append("\n")
|
|
829
|
+
header.append(_kv("modified", s.get("modified_at")))
|
|
830
|
+
console.print(Panel(header, title=s.get("name") or "(server)", title_align="left"))
|
|
831
|
+
|
|
832
|
+
|
|
833
|
+
# --- vault -----------------------------------------------------------------
|
|
834
|
+
|
|
835
|
+
def vault(v: dict) -> None:
|
|
836
|
+
"""Show a vault: header panel + tables of its credentials and servers."""
|
|
837
|
+
creds = v.get("credentials") or []
|
|
838
|
+
servers = v.get("servers") or []
|
|
839
|
+
|
|
840
|
+
header = Text()
|
|
841
|
+
header.append(_kv("id", v.get("id"))); header.append("\n")
|
|
842
|
+
header.append(_kv("description", v.get("description") or "—")); header.append("\n")
|
|
843
|
+
header.append(_kv("credentials", len(creds))); header.append("\n")
|
|
844
|
+
header.append(_kv("servers", len(servers))); header.append("\n")
|
|
845
|
+
header.append(_kv("created", v.get("created_at"))); header.append("\n")
|
|
846
|
+
header.append(_kv("modified", v.get("modified_at")))
|
|
847
|
+
console.print(Panel(header, title=v.get("name") or "(vault)", title_align="left"))
|
|
848
|
+
|
|
849
|
+
if creds:
|
|
850
|
+
table = Table(title="Credentials", show_header=True, header_style="bold", title_justify="left")
|
|
851
|
+
table.add_column("name")
|
|
852
|
+
table.add_column("type")
|
|
853
|
+
for c in creds:
|
|
854
|
+
table.add_row(str(c.get("name") or ""), str(c.get("credential_type") or ""))
|
|
855
|
+
console.print(table)
|
|
856
|
+
|
|
857
|
+
if servers:
|
|
858
|
+
table = Table(title="Servers", show_header=True, header_style="bold", title_justify="left")
|
|
859
|
+
table.add_column("name")
|
|
860
|
+
table.add_column("host")
|
|
861
|
+
table.add_column("method")
|
|
862
|
+
for s in servers:
|
|
863
|
+
host = s.get("host") or ""
|
|
864
|
+
port = s.get("port")
|
|
865
|
+
table.add_row(str(s.get("name") or ""), f"{host}:{port}" if port else str(host),
|
|
866
|
+
str(s.get("connection_method") or ""))
|
|
867
|
+
console.print(table)
|
|
868
|
+
|
|
869
|
+
|
|
870
|
+
# ---------------------------------------------------------------------------
|
|
871
|
+
# JSON slimming — strip internal/noisy fields before printing --json output.
|
|
872
|
+
# ---------------------------------------------------------------------------
|
|
873
|
+
|
|
874
|
+
_SLIM: dict[str, list[str]] = {
|
|
875
|
+
"workflow": ["id", "name", "description", "modified_at", "created_at"],
|
|
876
|
+
"script": ["id", "name", "content_type", "version", "file_size", "updated_at"],
|
|
877
|
+
"run": ["id", "workflow_name", "status", "created_at", "started_at", "finished_at", "error_message"],
|
|
878
|
+
"trigger": ["workflow_name", "kind", "is_active", "node_id", "workflow_id",
|
|
879
|
+
"cron_expression", "timezone", "trigger_url", "secret_last4"],
|
|
880
|
+
"credential": ["id", "name", "credential_type", "vault_name", "created_at", "modified_at"],
|
|
881
|
+
"server": ["id", "name", "host", "port", "connection_method", "vault_name", "created_at", "modified_at"],
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
|
|
885
|
+
def slim_items(items: list[dict], kind: str) -> list[dict]:
|
|
886
|
+
"""Return a copy of `items` with only the display-relevant fields for `kind`."""
|
|
887
|
+
fields = _SLIM[kind]
|
|
888
|
+
return [{k: it[k] for k in fields if k in it} for it in items]
|
|
889
|
+
|
|
890
|
+
|
|
891
|
+
def slim_vaults(items: list[dict]) -> list[dict]:
|
|
892
|
+
"""Vault-specific slim: top-level fields + compact nested cred/server lists."""
|
|
893
|
+
out = []
|
|
894
|
+
for v in items:
|
|
895
|
+
d = {k: v[k] for k in ["id", "name", "description", "created_at", "modified_at"] if k in v}
|
|
896
|
+
d["credentials"] = [
|
|
897
|
+
{"id": c.get("id"), "name": c.get("name"), "type": c.get("credential_type")}
|
|
898
|
+
for c in (v.get("credentials") or [])
|
|
899
|
+
]
|
|
900
|
+
d["servers"] = [
|
|
901
|
+
{"id": s.get("id"), "name": s.get("name"), "host": s.get("host"), "method": s.get("connection_method")}
|
|
902
|
+
for s in (v.get("servers") or [])
|
|
903
|
+
]
|
|
904
|
+
out.append(d)
|
|
905
|
+
return out
|
|
906
|
+
|
|
907
|
+
|
|
908
|
+
# ---------------------------------------------------------------------------
|
|
909
|
+
# List renderers — one per resource type, for the `sagex list` commands.
|
|
910
|
+
# ---------------------------------------------------------------------------
|
|
911
|
+
|
|
912
|
+
|
|
913
|
+
def list_workflows(items: list[dict]) -> None:
|
|
914
|
+
if not items:
|
|
915
|
+
note("No workflows found.")
|
|
916
|
+
return
|
|
917
|
+
table = Table(show_header=True, header_style="bold")
|
|
918
|
+
table.add_column("name")
|
|
919
|
+
table.add_column("id", style="cyan", no_wrap=True)
|
|
920
|
+
table.add_column("modified", style="bright_black")
|
|
921
|
+
for w in items:
|
|
922
|
+
table.add_row(
|
|
923
|
+
w.get("name") or "(unnamed)",
|
|
924
|
+
str(w.get("id") or ""),
|
|
925
|
+
relative_time(w.get("modified_at")),
|
|
926
|
+
)
|
|
927
|
+
console.print(table)
|
|
928
|
+
|
|
929
|
+
|
|
930
|
+
def list_scripts(items: list[dict]) -> None:
|
|
931
|
+
if not items:
|
|
932
|
+
note("No scripts found.")
|
|
933
|
+
return
|
|
934
|
+
table = Table(show_header=True, header_style="bold")
|
|
935
|
+
table.add_column("name")
|
|
936
|
+
table.add_column("id", style="cyan", no_wrap=True)
|
|
937
|
+
table.add_column("type", style="bright_black")
|
|
938
|
+
table.add_column("ver", style="bright_black")
|
|
939
|
+
for s in items:
|
|
940
|
+
ver = s.get("version")
|
|
941
|
+
table.add_row(
|
|
942
|
+
s.get("name") or "(unnamed)",
|
|
943
|
+
str(s.get("id") or ""),
|
|
944
|
+
s.get("content_type") or "—",
|
|
945
|
+
f"v{ver}" if ver is not None else "—",
|
|
946
|
+
)
|
|
947
|
+
console.print(table)
|
|
948
|
+
|
|
949
|
+
|
|
950
|
+
def list_runs(items: list[dict]) -> None:
|
|
951
|
+
if not items:
|
|
952
|
+
note("No runs found.")
|
|
953
|
+
return
|
|
954
|
+
table = Table(show_header=True, header_style="bold")
|
|
955
|
+
table.add_column("id", style="cyan", no_wrap=True)
|
|
956
|
+
table.add_column("workflow")
|
|
957
|
+
table.add_column("status")
|
|
958
|
+
table.add_column("created", style="bright_black")
|
|
959
|
+
for r in items:
|
|
960
|
+
status = r.get("status") or "unknown"
|
|
961
|
+
icon, color = STATUS_ICON.get(status, ("•", "white"))
|
|
962
|
+
table.add_row(
|
|
963
|
+
str(r.get("id") or "")[:8],
|
|
964
|
+
r.get("workflow_name") or "—",
|
|
965
|
+
Text(f"{icon} {status}", style=color),
|
|
966
|
+
relative_time(r.get("created_at")),
|
|
967
|
+
)
|
|
968
|
+
console.print(table)
|
|
969
|
+
|
|
970
|
+
|
|
971
|
+
def list_triggers(items: list[dict]) -> None:
|
|
972
|
+
if not items:
|
|
973
|
+
note("No triggers found.")
|
|
974
|
+
return
|
|
975
|
+
table = Table(show_header=True, header_style="bold")
|
|
976
|
+
table.add_column("workflow")
|
|
977
|
+
table.add_column("kind")
|
|
978
|
+
table.add_column("active")
|
|
979
|
+
for t in items:
|
|
980
|
+
is_active = t.get("is_active")
|
|
981
|
+
active_text = Text("● yes", style="green") if is_active else Text("○ no", style="bright_black")
|
|
982
|
+
table.add_row(
|
|
983
|
+
t.get("workflow_name") or "—",
|
|
984
|
+
t.get("kind") or "—",
|
|
985
|
+
active_text,
|
|
986
|
+
)
|
|
987
|
+
console.print(table)
|
|
988
|
+
|
|
989
|
+
|
|
990
|
+
def list_credentials(items: list[dict]) -> None:
|
|
991
|
+
if not items:
|
|
992
|
+
note("No credentials found.")
|
|
993
|
+
return
|
|
994
|
+
table = Table(show_header=True, header_style="bold")
|
|
995
|
+
table.add_column("name")
|
|
996
|
+
table.add_column("id", style="cyan", no_wrap=True)
|
|
997
|
+
table.add_column("type", style="bright_black")
|
|
998
|
+
table.add_column("vault", style="bright_black")
|
|
999
|
+
for c in items:
|
|
1000
|
+
table.add_row(
|
|
1001
|
+
c.get("name") or "(unnamed)",
|
|
1002
|
+
str(c.get("id") or ""),
|
|
1003
|
+
c.get("credential_type") or "—",
|
|
1004
|
+
c.get("vault_name") or str(c.get("vault") or "—"),
|
|
1005
|
+
)
|
|
1006
|
+
console.print(table)
|
|
1007
|
+
|
|
1008
|
+
|
|
1009
|
+
def list_servers(items: list[dict]) -> None:
|
|
1010
|
+
if not items:
|
|
1011
|
+
note("No servers found.")
|
|
1012
|
+
return
|
|
1013
|
+
table = Table(show_header=True, header_style="bold")
|
|
1014
|
+
table.add_column("name")
|
|
1015
|
+
table.add_column("host")
|
|
1016
|
+
table.add_column("method", style="bright_black")
|
|
1017
|
+
for s in items:
|
|
1018
|
+
host = s.get("host") or "—"
|
|
1019
|
+
port = s.get("port")
|
|
1020
|
+
table.add_row(
|
|
1021
|
+
s.get("name") or "(unnamed)",
|
|
1022
|
+
f"{host}:{port}" if port else host,
|
|
1023
|
+
s.get("connection_method") or "—",
|
|
1024
|
+
)
|
|
1025
|
+
console.print(table)
|
|
1026
|
+
|
|
1027
|
+
|
|
1028
|
+
def list_vaults(items: list[dict]) -> None:
|
|
1029
|
+
if not items:
|
|
1030
|
+
note("No vaults found.")
|
|
1031
|
+
return
|
|
1032
|
+
table = Table(show_header=True, header_style="bold")
|
|
1033
|
+
table.add_column("name")
|
|
1034
|
+
table.add_column("id", style="cyan", no_wrap=True)
|
|
1035
|
+
table.add_column("#creds", justify="right", style="bright_black")
|
|
1036
|
+
table.add_column("#servers", justify="right", style="bright_black")
|
|
1037
|
+
for v in items:
|
|
1038
|
+
creds = v.get("credentials") or []
|
|
1039
|
+
svrs = v.get("servers") or []
|
|
1040
|
+
table.add_row(
|
|
1041
|
+
v.get("name") or "(unnamed)",
|
|
1042
|
+
str(v.get("id") or ""),
|
|
1043
|
+
str(len(creds)) if isinstance(creds, list) else "—",
|
|
1044
|
+
str(len(svrs)) if isinstance(svrs, list) else "—",
|
|
1045
|
+
)
|
|
1046
|
+
console.print(table)
|