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/__main__.py ADDED
@@ -0,0 +1,2907 @@
1
+ """sagex entry point.
2
+
3
+ sagex -> launch the terminal app (TUI)
4
+ sagex <group> <verb> -> run one command and exit, e.g. `sagex list workflow`
5
+
6
+ Typer parses the command line: with no subcommand, the callback launches the TUI;
7
+ otherwise the matching command runs as a plain CLI action. `sagex --help` lists
8
+ every group.
9
+ """
10
+
11
+ import json
12
+ import os
13
+ import sys
14
+ import time
15
+ import uuid
16
+ from contextlib import contextmanager
17
+ from datetime import datetime, timezone
18
+ from pathlib import Path
19
+
20
+ import typer
21
+
22
+ # Windows terminals often default to a legacy code page (cp1252) that can't encode
23
+ # characters like ✓/✗, which would crash typer.echo / Rich mid-output. Force UTF-8
24
+ # so output is safe regardless of the console's codepage.
25
+ for _stream in (sys.stdout, sys.stderr):
26
+ try:
27
+ _stream.reconfigure(encoding="utf-8")
28
+ except (AttributeError, ValueError):
29
+ pass
30
+
31
+ from sagex import __version__, config, copier, pusher, render, runner, runview
32
+ from sagex.api import ApiClient, ApiError, build_client, resources
33
+ from sagex.api import store
34
+ from sagex.api.client import set_wait_indicator
35
+ from sagex.app import SagexApp
36
+
37
+ app = typer.Typer(
38
+ help="sagex — Autosage in your terminal. Run with no command to open the terminal app.",
39
+ add_completion=False,
40
+ no_args_is_help=False, # no args -> run the callback (launch the TUI)
41
+ # Typer's crash report prints every frame's local variables by default — which
42
+ # would include the API key (client.api_key) and any secret being prompted for.
43
+ pretty_exceptions_show_locals=False,
44
+ )
45
+ auth_app = typer.Typer(help="Manage authentication (API key).")
46
+ app.add_typer(auth_app, name="auth")
47
+
48
+ workspace_app = typer.Typer(help="Manage the local workspace folder.")
49
+ app.add_typer(workspace_app, name="workspace")
50
+
51
+ show_app = typer.Typer(help="Show a single resource's details in the terminal.")
52
+ app.add_typer(show_app, name="show")
53
+
54
+ render_app = typer.Typer(help="Render a workflow as a flow tree in the terminal.")
55
+ app.add_typer(render_app, name="render")
56
+
57
+ list_app = typer.Typer(help="List resources in a compact table.")
58
+ app.add_typer(list_app, name="list")
59
+
60
+ copy_app = typer.Typer(help="Copy resources to your local workspace.")
61
+ app.add_typer(copy_app, name="copy")
62
+
63
+ push_app = typer.Typer(help="Push local resource files to the server (create/update).")
64
+ app.add_typer(push_app, name="push")
65
+
66
+ new_app = typer.Typer(help="Scaffold new local resource files ready to edit and push.")
67
+ app.add_typer(new_app, name="new")
68
+
69
+ create_app = typer.Typer(help="Create server-side resources (vaults, servers, keys).")
70
+ app.add_typer(create_app, name="create")
71
+
72
+ update_app = typer.Typer(help="Update server-side resources (vaults, servers, keys).")
73
+ app.add_typer(update_app, name="update")
74
+
75
+ delete_app = typer.Typer(help="Delete server-side resources (asks for confirmation).")
76
+ app.add_typer(delete_app, name="delete")
77
+
78
+ trigger_app = typer.Typer(help="Configure workflow triggers (HTTP webhooks and schedules).")
79
+ app.add_typer(trigger_app, name="trigger")
80
+
81
+ rename_app = typer.Typer(help="Rename a resource (workflow, script, vault, server, key).")
82
+ app.add_typer(rename_app, name="rename")
83
+
84
+ run_app = typer.Typer(help="Run a script or workflow now and watch it live.")
85
+ app.add_typer(run_app, name="run")
86
+
87
+ # Lightweight authenticated endpoint used to verify a key.
88
+ _VERIFY_PATH = "/api/users/profile/"
89
+
90
+ # ---------------------------------------------------------------------------
91
+ # Workflow scaffold template — embedded in files created by `sagex new workflow`.
92
+ # _doc is stripped by workflow_payload() so it's never sent to the server.
93
+ # ---------------------------------------------------------------------------
94
+ _WORKFLOW_TEMPLATE: dict = {
95
+ "_doc": [
96
+ "Workflow file created by `sagex new workflow`.",
97
+ "Edit it, then run: sagex push workflow <this-file>",
98
+ "Get a full copy-paste snippet for any node/edge with: sagex syntax <kind>",
99
+ "",
100
+ "REQUIRED TOP-LEVEL FIELDS",
101
+ " name (string) display name shown in the web app",
102
+ " description (string) optional free text",
103
+ " nodes (list) each node needs: id, type, position {x, y}, data",
104
+ " edges (list) each edge needs: id, source, target",
105
+ " (a new file has no 'id' -- push creates the workflow and writes its id back here)",
106
+ "",
107
+ "NODE TYPES",
108
+ " 'trigger' -- entry point; every workflow needs exactly one.",
109
+ " data.type: 'manual' | 'schedule' | 'http'",
110
+ "",
111
+ " 'action' + data.type 'script' -- runs a script on a server.",
112
+ " data.selectedScript.scriptId (required) id (string) from `sagex list script`",
113
+ " data.selectedScript.type script kind label, e.g. 'Shell Script'",
114
+ " data.vaultDetails.vaultId (required) id from `sagex list vault`",
115
+ " data.vaultDetails.serverId (required) id from `sagex list server`",
116
+ " data.vaultDetails.credentialId (required) id from `sagex list key`",
117
+ " data.executionMode 'remote' (run on the vault's server)",
118
+ " data.outputFormat 'json' | 'text'",
119
+ " data.parameters[] {id, name, type, value, sourceType, description}",
120
+ " sourceType 'manual' = literal; 'output' = {{node-id.output.key}}",
121
+ " type: string | number | boolean | password",
122
+ " data.jsonSchema[] {name, type} -- declares this script's output fields",
123
+ "",
124
+ " 'action' + data.type 'email' -- sends an email.",
125
+ " data.smtpConfig.{host, port, vaultId, credentialId} (required; credential = username_password)",
126
+ " data.smtpConfig.secure true to use TLS",
127
+ " data.to (list) and data.subject (string) (required)",
128
+ " data.from / data.cc / data.bcc / data.body (optional)",
129
+ "",
130
+ " 'decision' -- conditional branch; needs exactly 2 outgoing edges.",
131
+ " data.conditions[] {id, field, operator, value, fieldSource, valueSource}",
132
+ " operator e.g. '==' / '!='; *Source: 'output' ({{...}}) or 'manual'",
133
+ " data.trueLabel list of node ids taken when conditions pass",
134
+ " data.falseLabel list of node ids taken otherwise",
135
+ " Its two edges must carry sourceHandle: 'true' and 'false'.",
136
+ "",
137
+ "EDGES",
138
+ " {id, source, target} connect nodes; type 'smoothstep' + style are UI hints.",
139
+ " A decision node's two edges also need sourceHandle 'true' / 'false'.",
140
+ "",
141
+ "CREATE vs UPDATE",
142
+ " No 'id' field -> push creates a new workflow and writes the id back here.",
143
+ " Has 'id' field -> push updates that workflow (asks for confirmation).",
144
+ " Wrong/stale id -> server returns 404; re-run push with --new to force-create.",
145
+ ],
146
+ "name": "",
147
+ "description": "",
148
+ "nodes": [
149
+ {
150
+ "id": "trigger-1",
151
+ "type": "trigger",
152
+ "position": {"x": 0, "y": 200},
153
+ "data": {"type": "manual", "label": "Manual Trigger", "description": ""},
154
+ "measured": {"width": 160, "height": 160},
155
+ },
156
+ {
157
+ "id": "action-1",
158
+ "type": "action",
159
+ "position": {"x": 250, "y": 200},
160
+ "data": {
161
+ "type": "script",
162
+ "label": "Run Script",
163
+ "description": "",
164
+ "executionMode": "remote",
165
+ "outputFormat": "json",
166
+ "selectedScript": {"type": "Shell Script", "scriptId": "REPLACE_WITH_SCRIPT_ID"},
167
+ "vaultDetails": {
168
+ "vaultId": "REPLACE_WITH_VAULT_ID",
169
+ "serverId": "REPLACE_WITH_SERVER_ID",
170
+ "credentialId": "REPLACE_WITH_CREDENTIAL_ID",
171
+ },
172
+ "parameters": [],
173
+ "jsonSchema": [],
174
+ },
175
+ "measured": {"width": 275, "height": 102},
176
+ },
177
+ ],
178
+ "edges": [
179
+ {
180
+ "id": "xy-edge__trigger-1-action-1",
181
+ "type": "smoothstep",
182
+ "source": "trigger-1",
183
+ "target": "action-1",
184
+ "style": {"stroke": "#9CA3AF", "strokeWidth": 2},
185
+ }
186
+ ],
187
+ }
188
+
189
+
190
+ # Snippets mirror the web app's export shape (see a real export for reference).
191
+ # ids/values are placeholders; REPLACE_WITH_* markers are the ones you must fill in.
192
+ _SYNTAX_SNIPPETS: dict[str, object] = {
193
+ "trigger": {
194
+ "id": "trigger-1",
195
+ "type": "trigger",
196
+ "position": {"x": 0, "y": 200},
197
+ "data": {"type": "manual", "label": "Manual Trigger", "description": ""},
198
+ "measured": {"width": 160, "height": 160},
199
+ },
200
+ "script": {
201
+ "id": "action-1",
202
+ "type": "action",
203
+ "position": {"x": 250, "y": 200},
204
+ "data": {
205
+ "type": "script",
206
+ "label": "Run Script",
207
+ "description": "",
208
+ "executionMode": "remote",
209
+ "outputFormat": "json",
210
+ "selectedScript": {"type": "Shell Script", "scriptId": "REPLACE_WITH_SCRIPT_ID"},
211
+ "vaultDetails": {
212
+ "vaultId": "REPLACE_WITH_VAULT_ID",
213
+ "serverId": "REPLACE_WITH_SERVER_ID",
214
+ "credentialId": "REPLACE_WITH_CREDENTIAL_ID",
215
+ },
216
+ "parameters": [
217
+ {
218
+ "id": "param-1",
219
+ "name": "SERVICE_NAME",
220
+ "type": "string",
221
+ "value": "nginx",
222
+ "sourceType": "manual",
223
+ "description": "",
224
+ }
225
+ ],
226
+ "jsonSchema": [
227
+ {"name": "service_name", "type": "string"},
228
+ {"name": "status", "type": "string"},
229
+ ],
230
+ },
231
+ "measured": {"width": 275, "height": 102},
232
+ },
233
+ "email": {
234
+ "id": "action-1",
235
+ "type": "action",
236
+ "position": {"x": 250, "y": 200},
237
+ "data": {
238
+ "type": "email",
239
+ "label": "Send Email",
240
+ "description": "",
241
+ "from": "sender@example.com",
242
+ "to": ["recipient@example.com"],
243
+ "cc": [],
244
+ "bcc": [],
245
+ "subject": "Subject here",
246
+ "body": "Email body here.",
247
+ "smtpConfig": {
248
+ "host": "smtp.gmail.com",
249
+ "port": 587,
250
+ "secure": False,
251
+ "vaultId": "REPLACE_WITH_VAULT_ID",
252
+ "credentialId": "REPLACE_WITH_CREDENTIAL_ID",
253
+ },
254
+ },
255
+ "measured": {"width": 185, "height": 102},
256
+ },
257
+ "decision": {
258
+ "id": "decision-1",
259
+ "type": "decision",
260
+ "position": {"x": 500, "y": 200},
261
+ "data": {
262
+ "label": "Condition Check",
263
+ "description": "",
264
+ "conditions": [
265
+ {
266
+ "id": "cond-1",
267
+ "field": "{{action-1.output.exists}}",
268
+ "value": "true",
269
+ "operator": "==",
270
+ "fieldSource": "output",
271
+ "valueSource": "manual",
272
+ }
273
+ ],
274
+ "trueLabel": ["action-on-true"],
275
+ "falseLabel": ["action-on-false"],
276
+ },
277
+ "measured": {"width": 160, "height": 160},
278
+ },
279
+ "edge": {
280
+ "id": "xy-edge__source-target",
281
+ "type": "smoothstep",
282
+ "source": "source-node-id",
283
+ "target": "target-node-id",
284
+ "style": {"stroke": "#9CA3AF", "strokeWidth": 2},
285
+ },
286
+ "decision-edge": [
287
+ {
288
+ "id": "decision-1-action-on-true-true",
289
+ "type": "smoothstep",
290
+ "label": "True",
291
+ "source": "decision-1",
292
+ "target": "action-on-true",
293
+ "sourceHandle": "true",
294
+ "style": {"stroke": "#10b981", "strokeWidth": 2},
295
+ },
296
+ {
297
+ "id": "decision-1-action-on-false-false",
298
+ "type": "smoothstep",
299
+ "label": "False",
300
+ "source": "decision-1",
301
+ "target": "action-on-false",
302
+ "sourceHandle": "false",
303
+ "style": {"stroke": "#ef4444", "strokeWidth": 2},
304
+ },
305
+ ],
306
+ }
307
+
308
+ _SYNTAX_NOTES: dict[str, list[str]] = {
309
+ "trigger": [
310
+ "Entry point — exactly one per workflow. Required: id, type, position, data.type, data.label.",
311
+ "data.type: 'manual' | 'schedule' | 'http' (schedule/http need their trigger settings from the web app).",
312
+ ],
313
+ "script": [
314
+ "Action that runs a script on a server.",
315
+ "Required: selectedScript.scriptId, vaultDetails.{vaultId, serverId, credentialId}.",
316
+ "parameters[].sourceType: 'manual' (literal value) or 'output' (value is a {{node-id.output.key}} reference).",
317
+ "parameters[].type: string | number | boolean | password. outputFormat: 'json' | 'text'.",
318
+ "jsonSchema declares this script's outputs so later nodes can read {{this-id.output.<name>}}.",
319
+ ],
320
+ "email": [
321
+ "Action that sends an email.",
322
+ "Required: smtpConfig.{host, port, vaultId, credentialId}, subject, to[].",
323
+ "credentialId must be a username_password credential. from / cc / bcc / body are optional.",
324
+ ],
325
+ "decision": [
326
+ "Branch node — needs exactly 2 outgoing edges (see: sagex syntax decision-edge).",
327
+ "conditions[].operator is a comparison such as '==' or '!=' (see the web condition editor for the full set).",
328
+ "conditions[].fieldSource / valueSource: 'output' (a {{...}} reference) or 'manual' (a literal).",
329
+ "trueLabel / falseLabel list the target node ids taken on each branch.",
330
+ ],
331
+ "edge": [
332
+ "Connection between two nodes. Required: id, source, target.",
333
+ "type ('smoothstep') and style are UI hints — safe to keep as-is.",
334
+ ],
335
+ "decision-edge": [
336
+ "The two required edges out of a decision node.",
337
+ "sourceHandle 'true'/'false' is mandatory; label and coloured style match the web app.",
338
+ ],
339
+ }
340
+
341
+ _SYNTAX_KINDS = " | ".join(_SYNTAX_SNIPPETS)
342
+
343
+
344
+ def _version_callback(value: bool) -> None:
345
+ """Print the version and exit (an 'eager' option, handled before anything else)."""
346
+ if value:
347
+ typer.echo(f"sagex {__version__}")
348
+ raise typer.Exit()
349
+
350
+
351
+ @app.callback(invoke_without_command=True)
352
+ def _default(
353
+ ctx: typer.Context,
354
+ version: bool = typer.Option(
355
+ False, "--version", callback=_version_callback, is_eager=True,
356
+ help="Show the version and exit.",
357
+ ),
358
+ ) -> None:
359
+ """Launch the terminal app when no subcommand is given."""
360
+ if ctx.invoked_subcommand is None:
361
+ SagexApp().run() # the TUI shows its own loaders, never the CLI line
362
+ else:
363
+ set_wait_indicator(render.waiting)
364
+
365
+
366
+ @app.command("syntax")
367
+ def syntax_cmd(
368
+ kind: str = typer.Argument(..., help=f"Snippet to print: {_SYNTAX_KINDS}"),
369
+ ) -> None:
370
+ """Print a ready-to-copy JSON snippet for a workflow node or edge.
371
+
372
+ Paste the output into your workflow file's 'nodes' or 'edges' array,
373
+ then replace the TODO placeholders with real ids and values.
374
+
375
+ Run `sagex list script` / `sagex list vault` to find the ids you need.
376
+ """
377
+ snippet = _SYNTAX_SNIPPETS.get(kind)
378
+ if snippet is None:
379
+ typer.echo(f"✗ Unknown kind '{kind}'. Choose one of: {_SYNTAX_KINDS}")
380
+ raise typer.Exit(code=1)
381
+ for line in _SYNTAX_NOTES.get(kind, []):
382
+ render.note(f"# {line}")
383
+ typer.echo(json.dumps(snippet, indent=2))
384
+
385
+
386
+ @auth_app.command("login")
387
+ def auth_login() -> None:
388
+ """Log in with your sagex API key (paste it when prompted)."""
389
+ render.note(f"sagex is in early access — API keys are issued on request: {config.WEB_URL}/contact")
390
+ key = typer.prompt("Paste your sagex API key", hide_input=True).strip()
391
+ if not key:
392
+ typer.echo("No key entered.")
393
+ raise typer.Exit(code=1)
394
+ if not key.startswith("sgx_"):
395
+ typer.echo("✗ That doesn't look like a sagex API key (they start with sgx_).")
396
+ raise typer.Exit(code=1)
397
+
398
+ # Verify BEFORE storing, so a mistyped or revoked key never lands in the keychain.
399
+ # Only a 401/403 proves the key is bad; if the server is down we can't tell, so
400
+ # save it anyway and say it's unverified.
401
+ problem = None
402
+ try:
403
+ ApiClient(config.api_url(), key).get(_VERIFY_PATH)
404
+ except ApiError as exc:
405
+ if exc.status in (401, 403):
406
+ typer.echo("✗ That key wasn't accepted — check you pasted all of it, or request a new one.")
407
+ raise typer.Exit(code=1)
408
+ problem = exc.message
409
+
410
+ try:
411
+ store.set_key(key)
412
+ except store.KeyStoreError:
413
+ typer.echo("✗ No OS keychain is available on this machine to store the key.")
414
+ typer.echo(f" Set it in your environment instead: {store.ENV_VAR}=<your key>")
415
+ raise typer.Exit(code=1)
416
+
417
+ if problem:
418
+ typer.echo("Key saved to your OS keychain, but it couldn't be verified right now:")
419
+ typer.echo(f" {problem}")
420
+ else:
421
+ typer.echo("✓ Logged in — key saved to your OS keychain.")
422
+ if store.key_from_env():
423
+ render.note(f"{store.ENV_VAR} is set in your environment and takes precedence over the saved key.")
424
+
425
+
426
+ @auth_app.command("status")
427
+ def auth_status() -> None:
428
+ """Show whether you're logged in, and check the key still works."""
429
+ key = store.get_key()
430
+ if not key:
431
+ typer.echo("Not logged in. Run: sagex auth login")
432
+ raise typer.Exit(code=1)
433
+
434
+ client = build_client()
435
+ try:
436
+ client.get(_VERIFY_PATH)
437
+ except ApiError as exc:
438
+ typer.echo(f"✗ {exc.message}")
439
+ raise typer.Exit(code=1)
440
+
441
+ # Name the server only when it isn't the hosted one (i.e. a SAGEX_API_URL override).
442
+ where = "" if client.base_url == config.DEFAULT_API_URL else f" at {client.base_url}"
443
+ source = f" via {store.ENV_VAR}" if store.key_from_env() else ""
444
+ typer.echo(f"✓ Logged in to Autosage{where} · key sgx_…{key[-4:]}{source}")
445
+
446
+
447
+ @auth_app.command("logout")
448
+ def auth_logout() -> None:
449
+ """Remove the stored API key from your OS keychain."""
450
+ store.delete_key()
451
+ typer.echo("Logged out — API key removed from your OS keychain.")
452
+ if store.key_from_env():
453
+ render.note(f"{store.ENV_VAR} is still set in your environment, so sagex will keep using it.")
454
+
455
+
456
+ @workspace_app.command("set")
457
+ def workspace_set(path: str) -> None:
458
+ """Set the local workspace folder (relative or absolute path)."""
459
+ try:
460
+ resolved = config.resolve_workspace(path)
461
+ except ValueError as exc:
462
+ typer.echo(str(exc))
463
+ raise typer.Exit(code=1)
464
+ settings = config.load()
465
+ settings["workspace"] = resolved
466
+ config.save(settings)
467
+ typer.echo(f"Workspace set to: {resolved}")
468
+
469
+
470
+ @workspace_app.command("show")
471
+ def workspace_show() -> None:
472
+ """Show the current workspace folder."""
473
+ ws = config.load().get("workspace")
474
+ typer.echo(ws or "(not set — defaults to the current directory)")
475
+
476
+
477
+ def _client_or_exit() -> "build_client":
478
+ """Return an API client, or exit early with a friendly message if no key."""
479
+ if not store.get_key():
480
+ typer.echo("Not logged in. Run: sagex auth login")
481
+ raise typer.Exit(code=1)
482
+ return build_client()
483
+
484
+
485
+ def _resolve_or_exit(resolve, client, ref: str, kind: str):
486
+ """Run a resolver, turning its expected errors into clean CLI exits."""
487
+ try:
488
+ return resolve(client, ref)
489
+ except resources.AmbiguousResource as exc:
490
+ render.ambiguous(exc)
491
+ raise typer.Exit(code=1)
492
+ except resources.ResourceNotFound as exc:
493
+ typer.echo(str(exc))
494
+ raise typer.Exit(code=1)
495
+ except ApiError as exc:
496
+ typer.echo(f"✗ {exc.message}")
497
+ raise typer.Exit(code=1)
498
+
499
+
500
+ # ---------------------------------------------------------------------------
501
+ # Credential (key) input helpers. Secrets are entered interactively (hidden) or
502
+ # read from an existing file; they are NEVER accepted as flag values (which would
503
+ # leak into shell history / ps) and are NEVER written to disk by us.
504
+ # ---------------------------------------------------------------------------
505
+
506
+ _KEY_TYPES = {
507
+ "username_password": "username_password", "up": "username_password",
508
+ "user": "username_password", "password": "username_password",
509
+ "ssh_key": "ssh_key", "ssh": "ssh_key", "key": "ssh_key",
510
+ "certificate": "certificate", "cert": "certificate",
511
+ }
512
+ _KEY_TYPE_CHOICES = "username_password | ssh_key | certificate"
513
+
514
+
515
+ def _normalize_key_type(value: str) -> str:
516
+ """Map a user-supplied --type (incl. aliases like 'ssh'/'cert') to a server value."""
517
+ ctype = _KEY_TYPES.get(value.strip().lower())
518
+ if not ctype:
519
+ typer.echo(f"✗ --type must be one of: {_KEY_TYPE_CHOICES}.")
520
+ raise typer.Exit(code=1)
521
+ return ctype
522
+
523
+
524
+ def _prompt_secret(label: str, *, confirm: bool) -> str:
525
+ """Prompt for a single-line secret with hidden input (optionally confirmed)."""
526
+ return typer.prompt(label, hide_input=True, confirmation_prompt=confirm)
527
+
528
+
529
+ def _prompt_optional_secret(label: str) -> str:
530
+ """Prompt for an optional single-line secret (hidden); '' if left blank."""
531
+ return typer.prompt(label, hide_input=True, default="", show_default=False)
532
+
533
+
534
+ def _read_secret_file(path_str: str) -> str:
535
+ """Read a multi-line secret (key/cert) from an existing file. Never written back."""
536
+ p = Path(path_str).expanduser()
537
+ if not p.is_file():
538
+ typer.echo(f"✗ No such file: {p}")
539
+ raise typer.Exit(code=1)
540
+ try:
541
+ return p.read_text(encoding="utf-8")
542
+ except OSError as exc:
543
+ typer.echo(f"✗ Couldn't read {p}: {exc}")
544
+ raise typer.Exit(code=1)
545
+
546
+
547
+ def _paste_secret(label: str) -> str:
548
+ """Read a multi-line secret pasted into the terminal, ended with EOF."""
549
+ typer.echo(f" Paste the {label}, then press Ctrl-D (Ctrl-Z then Enter on Windows) on a blank line:")
550
+ return sys.stdin.read()
551
+
552
+
553
+ def _acquire_multiline(label: str, file_opt: str | None, *, paste_when_none: bool) -> str | None:
554
+ """Get a multi-line secret from a file path, from '-' (paste), or by paste.
555
+
556
+ file_opt: a path, or '-' to paste, or None. When None: paste if paste_when_none
557
+ (the create flow), else return None (the update flow — leave the field unchanged).
558
+ """
559
+ if file_opt == "-":
560
+ content = _paste_secret(label)
561
+ elif file_opt:
562
+ content = _read_secret_file(file_opt)
563
+ elif paste_when_none:
564
+ content = _paste_secret(label)
565
+ else:
566
+ return None
567
+ if not content.strip():
568
+ typer.echo(f"✗ {label} is empty.")
569
+ raise typer.Exit(code=1)
570
+ return content
571
+
572
+
573
+ @show_app.command("workflow")
574
+ def show_workflow_cmd(
575
+ ref: str = typer.Argument(..., help="Workflow name or id."),
576
+ json_out: bool = typer.Option(False, "--json", help="Print the raw record as JSON."),
577
+ ) -> None:
578
+ """Show a workflow's details (including its nodes and edges). Use `sagex render` for the flow tree."""
579
+ client = _client_or_exit()
580
+ wf = _resolve_or_exit(resources.resolve_workflow, client, ref, "workflow")
581
+ render.print_json(wf) if json_out else render.workflow(wf)
582
+
583
+
584
+ @render_app.command("workflow")
585
+ def render_workflow_cmd(
586
+ ref: str = typer.Argument(None, help="Workflow name or id (omit when using --file)."),
587
+ file: str = typer.Option(None, "--file", "-f", help="Render a local file: a path, or a name under <workspace>/workflows/."),
588
+ ) -> None:
589
+ """Render a workflow's flow as boxed nodes + edges. Works on a local file too (--file)."""
590
+ if file:
591
+ workspace = Path(config.workspace_dir())
592
+ try:
593
+ path = pusher.resolve_workflow_file(workspace, file)
594
+ except pusher.WorkflowFileNotFound as exc:
595
+ typer.echo(f"No workflow file for '{exc.ref}'. Looked at:")
596
+ for p in exc.looked:
597
+ typer.echo(f" {p}")
598
+ raise typer.Exit(code=1)
599
+ try:
600
+ wf = pusher.load_doc(path)
601
+ except ValueError as exc:
602
+ typer.echo(f"✗ {exc}")
603
+ raise typer.Exit(code=1)
604
+ else:
605
+ if not ref:
606
+ typer.echo("Give a workflow name/id, or pass --file <path-or-name>.")
607
+ raise typer.Exit(code=1)
608
+ client = _client_or_exit()
609
+ wf = _resolve_or_exit(resources.resolve_workflow, client, ref, "workflow")
610
+ render.workflow_graph(wf)
611
+
612
+
613
+ @show_app.command("script")
614
+ def show_script_cmd(
615
+ ref: str = typer.Argument(..., help="Script name or id."),
616
+ json_out: bool = typer.Option(False, "--json", help="Print the raw record as JSON."),
617
+ no_content: bool = typer.Option(False, "--no-content", help="Skip the code body."),
618
+ ) -> None:
619
+ """Show a script's metadata and (by default) its code, syntax-highlighted."""
620
+ client = _client_or_exit()
621
+ meta = _resolve_or_exit(resources.resolve_script, client, ref, "script")
622
+ if json_out:
623
+ render.print_json(meta)
624
+ return
625
+ content = None
626
+ if not no_content:
627
+ try:
628
+ content = resources.get_script_content(client, meta["id"])
629
+ except ApiError as exc:
630
+ typer.echo(f"(couldn't load code body: {exc.message})") # still show metadata
631
+ render.script(meta, content)
632
+
633
+
634
+ @show_app.command("run")
635
+ def show_run_cmd(
636
+ ref: str = typer.Argument(..., help="Run id (or an id prefix)."),
637
+ json_out: bool = typer.Option(False, "--json", help="Print the raw record as JSON."),
638
+ logs: bool = typer.Option(False, "--logs", help="Also download and print each node's stdout/stderr."),
639
+ ) -> None:
640
+ """Show a run's status and per-node results (and optionally its logs)."""
641
+ client = _client_or_exit()
642
+ run = _resolve_or_exit(resources.resolve_run, client, ref, "run")
643
+ if json_out:
644
+ render.print_json(run)
645
+ return
646
+
647
+ nodes: list = []
648
+ try:
649
+ nodes = resources.get_run_nodes(client, run["id"])
650
+ except ApiError as exc:
651
+ typer.echo(f"(couldn't load node results: {exc.message})")
652
+
653
+ render.run(run, nodes)
654
+
655
+ # Logs live in GCS behind signed URLs and are swept after ~90 days; the server
656
+ # tells us via `logs_expired` on any node (it's keyed off the run's age).
657
+ if any(n.get("logs_expired") for n in nodes):
658
+ render.note("Logs are not available for this run — we keep logs for 90 days.")
659
+ elif logs:
660
+ entries = []
661
+ for n in nodes:
662
+ label = n.get("node_label") or n.get("node_id") or "node"
663
+ for stream in ("stdout", "stderr"):
664
+ url = n.get(f"{stream}_signed_url")
665
+ if url:
666
+ try:
667
+ text = resources.fetch_log_text(url)
668
+ except ApiError as exc:
669
+ text = f"({exc.message})"
670
+ entries.append((label, stream, text))
671
+ render.run_logs(entries)
672
+ elif any(n.get("stdout_signed_url") or n.get("stderr_signed_url") for n in nodes):
673
+ render.note("Logs available — pass --logs to print them.")
674
+
675
+
676
+ @show_app.command("trigger")
677
+ def show_trigger_cmd(
678
+ ref: str = typer.Argument(..., help="Trigger id or workflow name."),
679
+ json_out: bool = typer.Option(False, "--json", help="Print the raw record as JSON."),
680
+ ) -> None:
681
+ """Show a trigger's detail (schedule or HTTP). Never prints the full secret."""
682
+ client = _client_or_exit()
683
+ kind, detail = _resolve_or_exit(resources.resolve_trigger, client, ref, "trigger")
684
+ render.print_json(detail) if json_out else render.trigger(kind, detail)
685
+
686
+
687
+ @show_app.command("key")
688
+ def show_key_cmd(
689
+ ref: str = typer.Argument(..., help="Credential (key) id or name."),
690
+ reveal: bool = typer.Option(False, "--reveal", help="Fetch and display the secret values (asks first)."),
691
+ ) -> None:
692
+ """Show a vault credential. Secrets stay hidden unless you pass --reveal."""
693
+ client = _client_or_exit()
694
+ cred = _resolve_or_exit(resources.resolve_credential, client, ref, "key")
695
+
696
+ secrets = None
697
+ if reveal:
698
+ name = cred.get("name") or cred.get("id")
699
+ if not typer.confirm(f"Reveal plaintext secrets for '{name}'? They will be printed here."):
700
+ typer.echo("Aborted — secrets not fetched.")
701
+ raise typer.Exit(code=1)
702
+ try:
703
+ secrets = resources.reveal_credential(client, cred["id"])
704
+ except ApiError as exc:
705
+ typer.echo(f"✗ {exc.message}")
706
+ raise typer.Exit(code=1)
707
+ render.credential(cred, secrets)
708
+
709
+
710
+ @show_app.command("server")
711
+ def show_server_cmd(
712
+ ref: str = typer.Argument(..., help="Server id or name."),
713
+ json_out: bool = typer.Option(False, "--json", help="Print the raw record as JSON."),
714
+ ) -> None:
715
+ """Show a vault server (host, connection method, linked credential, vault)."""
716
+ client = _client_or_exit()
717
+ server = _resolve_or_exit(resources.resolve_server, client, ref, "server")
718
+ render.print_json(server) if json_out else render.server(server)
719
+
720
+
721
+ @show_app.command("vault")
722
+ def show_vault_cmd(
723
+ ref: str = typer.Argument(..., help="Vault id or name."),
724
+ json_out: bool = typer.Option(False, "--json", help="Print the raw record as JSON."),
725
+ ) -> None:
726
+ """Show a vault and the credentials and servers it contains."""
727
+ client = _client_or_exit()
728
+ vault = _resolve_or_exit(resources.resolve_vault, client, ref, "vault")
729
+ render.print_json(vault) if json_out else render.vault(vault)
730
+
731
+
732
+ def _list_or_exit(fn, client, **kwargs):
733
+ """Call a list_*_full function and return items, or exit on ApiError."""
734
+ try:
735
+ return fn(client, **kwargs)
736
+ except ApiError as exc:
737
+ typer.echo(f"✗ {exc.message}")
738
+ raise typer.Exit(code=1)
739
+
740
+
741
+ @list_app.command("workflow")
742
+ def list_workflow_cmd(
743
+ json_out: bool = typer.Option(False, "--json", help="Print raw JSON."),
744
+ ) -> None:
745
+ """List all workflows."""
746
+ client = _client_or_exit()
747
+ items = _list_or_exit(resources.list_workflows_full, client)
748
+ render.print_json(render.slim_items(items, "workflow")) if json_out else render.list_workflows(items)
749
+
750
+
751
+ @list_app.command("script")
752
+ def list_script_cmd(
753
+ json_out: bool = typer.Option(False, "--json", help="Print raw JSON."),
754
+ ) -> None:
755
+ """List all scripts."""
756
+ client = _client_or_exit()
757
+ items = _list_or_exit(resources.list_scripts_full, client)
758
+ render.print_json(render.slim_items(items, "script")) if json_out else render.list_scripts(items)
759
+
760
+
761
+ @list_app.command("run")
762
+ def list_run_cmd(
763
+ limit: int = typer.Option(20, "--limit", help="Max runs to show (newest first)."),
764
+ json_out: bool = typer.Option(False, "--json", help="Print raw JSON."),
765
+ ) -> None:
766
+ """List recent runs, newest first."""
767
+ client = _client_or_exit()
768
+ items = _list_or_exit(resources.list_runs_full, client, limit=limit)
769
+ render.print_json(render.slim_items(items, "run")) if json_out else render.list_runs(items)
770
+
771
+
772
+ @list_app.command("trigger")
773
+ def list_trigger_cmd(
774
+ json_out: bool = typer.Option(False, "--json", help="Print raw JSON."),
775
+ ) -> None:
776
+ """List all configured triggers."""
777
+ client = _client_or_exit()
778
+ items = _list_or_exit(resources.list_triggers_full, client)
779
+ render.print_json(render.slim_items(items, "trigger")) if json_out else render.list_triggers(items)
780
+
781
+
782
+ @list_app.command("key")
783
+ def list_key_cmd(
784
+ json_out: bool = typer.Option(False, "--json", help="Print raw JSON."),
785
+ ) -> None:
786
+ """List all vault credentials (no secrets shown)."""
787
+ client = _client_or_exit()
788
+ items = _list_or_exit(resources.list_credentials_full, client)
789
+ render.print_json(render.slim_items(items, "credential")) if json_out else render.list_credentials(items)
790
+
791
+
792
+ @list_app.command("server")
793
+ def list_server_cmd(
794
+ json_out: bool = typer.Option(False, "--json", help="Print raw JSON."),
795
+ ) -> None:
796
+ """List all vault servers."""
797
+ client = _client_or_exit()
798
+ items = _list_or_exit(resources.list_servers_full, client)
799
+ render.print_json(render.slim_items(items, "server")) if json_out else render.list_servers(items)
800
+
801
+
802
+ @list_app.command("vault")
803
+ def list_vault_cmd(
804
+ json_out: bool = typer.Option(False, "--json", help="Print raw JSON."),
805
+ ) -> None:
806
+ """List all vaults."""
807
+ client = _client_or_exit()
808
+ items = _list_or_exit(resources.list_vaults_full, client)
809
+ render.print_json(render.slim_vaults(items)) if json_out else render.list_vaults(items)
810
+
811
+
812
+ def _wrote(paths) -> None:
813
+ """Report the files a copy command wrote (one dim line each)."""
814
+ for p in paths:
815
+ render.note(f" wrote {p}")
816
+
817
+
818
+ @copy_app.command("workflow")
819
+ def copy_workflow_cmd(
820
+ ref: str = typer.Argument(None, help="Workflow name or id (omit with --all)."),
821
+ all_: bool = typer.Option(False, "--all", help="Copy every workflow."),
822
+ ) -> None:
823
+ """Copy a workflow (or all) as JSON to <workspace>/workflows/."""
824
+ client = _client_or_exit()
825
+ workspace = Path(config.workspace_dir())
826
+ if all_:
827
+ try:
828
+ paths = copier.copy_all_workflows(client, workspace)
829
+ except ApiError as exc:
830
+ typer.echo(f"✗ {exc.message}"); raise typer.Exit(code=1)
831
+ _wrote(paths)
832
+ render.note(f"Copied {len(paths)} workflow(s) to {workspace / 'workflows'}")
833
+ return
834
+ if not ref:
835
+ typer.echo("Give a workflow name/id, or pass --all."); raise typer.Exit(code=1)
836
+ wf = _resolve_or_exit(resources.resolve_workflow, client, ref, "workflow")
837
+ _wrote([copier.copy_workflow(workspace, wf)])
838
+
839
+
840
+ @copy_app.command("script")
841
+ def copy_script_cmd(
842
+ ref: str = typer.Argument(None, help="Script name or id (omit with --all)."),
843
+ all_: bool = typer.Option(False, "--all", help="Copy every script."),
844
+ ) -> None:
845
+ """Copy a script's code (or all) to <workspace>/scripts/."""
846
+ client = _client_or_exit()
847
+ workspace = Path(config.workspace_dir())
848
+ if all_:
849
+ try:
850
+ paths = copier.copy_all_scripts(client, workspace)
851
+ except ApiError as exc:
852
+ typer.echo(f"✗ {exc.message}"); raise typer.Exit(code=1)
853
+ _wrote(paths)
854
+ render.note(f"Copied {len(paths)} script(s) to {workspace / 'scripts'}")
855
+ return
856
+ if not ref:
857
+ typer.echo("Give a script name/id, or pass --all."); raise typer.Exit(code=1)
858
+ meta = _resolve_or_exit(resources.resolve_script, client, ref, "script")
859
+ try:
860
+ content = resources.get_script_content(client, meta["id"])
861
+ except ApiError as exc:
862
+ typer.echo(f"✗ {exc.message}"); raise typer.Exit(code=1)
863
+ _wrote([copier.copy_script(workspace, meta, content)])
864
+
865
+
866
+ @copy_app.command("run")
867
+ def copy_run_cmd(
868
+ ref: str = typer.Argument(None, help="Run id/prefix (omit when using --since)."),
869
+ since: str = typer.Option(
870
+ None, "--since",
871
+ help="Copy all runs in a window instead of one: 1day | 7days | 1month | all.",
872
+ ),
873
+ ) -> None:
874
+ """Copy a run's metadata + logs to <workspace>/runs/ (one folder per run)."""
875
+ client = _client_or_exit()
876
+ workspace = Path(config.workspace_dir())
877
+
878
+ if since is not None:
879
+ if since != "all" and since not in copier.WINDOWS:
880
+ typer.echo("--since must be one of: 1day, 7days, 1month, all."); raise typer.Exit(code=1)
881
+ try:
882
+ runs = copier.runs_in_window(resources.list_runs_full(client, limit=None), since)
883
+ except ApiError as exc:
884
+ typer.echo(f"✗ {exc.message}"); raise typer.Exit(code=1)
885
+ if not runs:
886
+ render.note(f"No runs in the last {since}."); return
887
+ render.note(f"Copying {len(runs)} run(s)…")
888
+ total = 0
889
+ for r in runs:
890
+ try:
891
+ paths = copier.copy_run(client, workspace, r)
892
+ except ApiError as exc:
893
+ typer.echo(f"✗ {r.get('id')}: {exc.message}"); continue
894
+ _wrote(paths); total += len(paths)
895
+ render.note(f"Copied {len(runs)} run(s) to {workspace / 'runs'}")
896
+ return
897
+
898
+ if not ref:
899
+ typer.echo("Give a run id/prefix, or pass --since <window>."); raise typer.Exit(code=1)
900
+ run = _resolve_or_exit(resources.resolve_run, client, ref, "run")
901
+ try:
902
+ _wrote(copier.copy_run(client, workspace, run))
903
+ except ApiError as exc:
904
+ typer.echo(f"✗ {exc.message}"); raise typer.Exit(code=1)
905
+
906
+
907
+ @copy_app.command("key")
908
+ def copy_key_cmd(
909
+ ref: str = typer.Argument(None, help="(unused)"),
910
+ ) -> None:
911
+ """Keys/credentials cannot be copied — secrets never touch local disk."""
912
+ typer.echo("Keys can't be copied — secrets are never written to disk.")
913
+ typer.echo("To view a secret value interactively, use: sagex show key <ref> --reveal")
914
+ raise typer.Exit(code=1)
915
+
916
+
917
+ @new_app.command("workflow")
918
+ def new_workflow_cmd(
919
+ name: str = typer.Argument(..., help="Workflow display name."),
920
+ output: str = typer.Option(None, "--output", "-o", help="Output path (default: <workspace>/workflows/<name>.json)."),
921
+ force: bool = typer.Option(False, "--force", "-f", help="Overwrite if the file already exists."),
922
+ ) -> None:
923
+ """Create a template workflow JSON file in your workspace, ready to edit and push."""
924
+ from sagex.copier import safe_name
925
+
926
+ workspace = Path(config.workspace_dir())
927
+ if output:
928
+ dest = Path(output)
929
+ else:
930
+ stem = safe_name(name, "workflow")
931
+ dest = workspace / "workflows" / f"{stem}.json"
932
+
933
+ if dest.exists() and not force:
934
+ typer.echo(f"✗ {dest} already exists. Pass --force to overwrite, or choose a different name.")
935
+ raise typer.Exit(code=1)
936
+
937
+ doc = dict(_WORKFLOW_TEMPLATE)
938
+ doc["name"] = name
939
+
940
+ dest.parent.mkdir(parents=True, exist_ok=True)
941
+ dest.write_text(json.dumps(doc, indent=2), encoding="utf-8")
942
+
943
+ typer.echo(f"✓ Created {dest}")
944
+ render.note(" Edit the file (fill in node ids, scriptId, vaultDetails, etc.)")
945
+ render.note(f" Push when ready: sagex push workflow \"{dest}\"")
946
+ render.note(" See the '_doc' field in the file for a full field reference.")
947
+
948
+
949
+ @new_app.command("script")
950
+ def new_script_cmd(
951
+ name: str = typer.Argument(..., help="Script name (letters, numbers, _ and - only)."),
952
+ language: str = typer.Option(None, "--language", "--lang", help="Language, e.g. shell, python, powershell (inferred from --output's extension if omitted)."),
953
+ output: str = typer.Option(None, "--output", "-o", help="Output path (default: <workspace>/scripts/<name>.<ext>)."),
954
+ force: bool = typer.Option(False, "--force", "-f", help="Overwrite if the file already exists."),
955
+ ) -> None:
956
+ """Create a starter script file with a header explaining how params & secrets reach it."""
957
+ workspace = Path(config.workspace_dir())
958
+
959
+ lang = language or (pusher.infer_script_language(Path(output)) if output else None)
960
+ if not lang:
961
+ typer.echo("Give a --language (e.g. shell, python, powershell), or an --output path with a known extension.")
962
+ raise typer.Exit(code=1)
963
+
964
+ try:
965
+ filename, content = pusher.script_scaffold(name, lang)
966
+ except ValueError as exc:
967
+ typer.echo(f"✗ {exc}")
968
+ raise typer.Exit(code=1)
969
+
970
+ dest = Path(output) if output else workspace / "scripts" / filename
971
+ if dest.exists() and not force:
972
+ typer.echo(f"✗ {dest} already exists. Pass --force to overwrite, or choose a different name.")
973
+ raise typer.Exit(code=1)
974
+
975
+ dest.parent.mkdir(parents=True, exist_ok=True)
976
+ dest.write_text(content, encoding="utf-8")
977
+
978
+ typer.echo(f"✓ Created {dest}")
979
+ render.note(" Read the header — it explains how parameters and secrets reach the script.")
980
+ render.note(f" Push when ready: sagex push script \"{dest}\"")
981
+
982
+
983
+ @create_app.command("vault")
984
+ def create_vault_cmd(
985
+ name: str = typer.Argument(..., help="Vault name (must be unique among your vaults)."),
986
+ description: str = typer.Option("", "--description", "-d", help="Optional description."),
987
+ ) -> None:
988
+ """Create a new vault — a container for servers and credentials."""
989
+ client = _client_or_exit()
990
+
991
+ # Name is unique per owner; check first (the server's duplicate error shape varies).
992
+ try:
993
+ existing = resources.find_vault_by_name(client, name)
994
+ except ApiError as exc:
995
+ typer.echo(f"✗ {exc.message}")
996
+ raise typer.Exit(code=1)
997
+ if existing:
998
+ typer.echo(f"✗ You already have a vault named '{name}' (id {existing.get('id')}).")
999
+ typer.echo(f" To change it, run: sagex update vault \"{name}\" --description \"…\"")
1000
+ raise typer.Exit(code=1)
1001
+
1002
+ try:
1003
+ created = resources.create_vault(client, {"name": name, "description": description})
1004
+ except ApiError as exc:
1005
+ typer.echo(f"✗ {exc.message}")
1006
+ raise typer.Exit(code=1)
1007
+ typer.echo(f"✓ Created vault '{created.get('name')}' (id {created.get('id')})")
1008
+
1009
+
1010
+ @update_app.command("vault")
1011
+ def update_vault_cmd(
1012
+ ref: str = typer.Argument(..., help="Vault name or id."),
1013
+ name: str = typer.Option(None, "--name", help="New name for the vault."),
1014
+ description: str = typer.Option(None, "--description", "-d", help="New description."),
1015
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip the confirmation prompt."),
1016
+ ) -> None:
1017
+ """Update a vault's name and/or description."""
1018
+ if name is None and description is None:
1019
+ typer.echo("Nothing to update — pass --name and/or --description.")
1020
+ raise typer.Exit(code=1)
1021
+
1022
+ client = _client_or_exit()
1023
+ vault = _resolve_or_exit(resources.resolve_vault, client, ref, "vault")
1024
+
1025
+ payload: dict = {}
1026
+ if name is not None:
1027
+ payload["name"] = name
1028
+ if description is not None:
1029
+ payload["description"] = description
1030
+
1031
+ # Guard a rename against colliding with another of your vaults.
1032
+ if name is not None and name.strip().lower() != str(vault.get("name") or "").lower():
1033
+ clash = resources.find_vault_by_name(client, name)
1034
+ if clash and str(clash.get("id")) != str(vault.get("id")):
1035
+ typer.echo(f"✗ You already have another vault named '{name}' (id {clash.get('id')}).")
1036
+ raise typer.Exit(code=1)
1037
+
1038
+ typer.echo(f"Updating vault '{vault.get('name')}' (id {str(vault.get('id'))[:8]}…)")
1039
+ if not yes and not typer.confirm(" Apply these changes?"):
1040
+ typer.echo("Aborted — nothing changed.")
1041
+ raise typer.Exit(code=1)
1042
+
1043
+ try:
1044
+ updated = resources.update_vault(client, vault["id"], payload)
1045
+ except ApiError as exc:
1046
+ typer.echo(f"✗ {exc.message}")
1047
+ raise typer.Exit(code=1)
1048
+ typer.echo(f"✓ Updated vault '{updated.get('name')}' (id {updated.get('id')})")
1049
+
1050
+
1051
+ @create_app.command("server")
1052
+ def create_server_cmd(
1053
+ name: str = typer.Argument(..., help="Server name (unique within the vault)."),
1054
+ vault: str = typer.Option(..., "--vault", help="Vault name or id to create the server in (required)."),
1055
+ host: str = typer.Option(..., "--host", help="Hostname or IP address (required)."),
1056
+ method: str = typer.Option(..., "--method", "-m", help="Connection method: ssh or winrm (required)."),
1057
+ port: int = typer.Option(None, "--port", "-p", help="Port (default: 22 for ssh, 5985 for winrm)."),
1058
+ key: str = typer.Option(None, "--key", "-k", help="Credential (key) name or id to attach — must be in the same vault."),
1059
+ ) -> None:
1060
+ """Create a vault server (SSH or WinRM). Optionally attach a credential with --key."""
1061
+ method = method.lower()
1062
+ if method not in ("ssh", "winrm"):
1063
+ typer.echo("✗ --method must be 'ssh' or 'winrm'.")
1064
+ raise typer.Exit(code=1)
1065
+ if port is None:
1066
+ port = 22 if method == "ssh" else 5985
1067
+ elif not 1 <= port <= 65535:
1068
+ typer.echo("✗ --port must be between 1 and 65535.")
1069
+ raise typer.Exit(code=1)
1070
+
1071
+ client = _client_or_exit()
1072
+ v = _resolve_or_exit(resources.resolve_vault, client, vault, "vault")
1073
+
1074
+ # Server name is unique within its vault — check the vault's existing servers.
1075
+ for s in (v.get("servers") or []):
1076
+ if str(s.get("name") or "").lower() == name.strip().lower():
1077
+ typer.echo(f"✗ Vault '{v.get('name')}' already has a server named '{name}' (id {s.get('id')}).")
1078
+ raise typer.Exit(code=1)
1079
+
1080
+ payload = {
1081
+ "vault": v["id"],
1082
+ "name": name,
1083
+ "host": host,
1084
+ "connection_method": method,
1085
+ "port": port,
1086
+ }
1087
+
1088
+ cred = None
1089
+ if key:
1090
+ cred = resources.find_credential_in_vault(v, key)
1091
+ if not cred:
1092
+ typer.echo(f"✗ No credential '{key}' in vault '{v.get('name')}'.")
1093
+ typer.echo(f" A server's key must live in the same vault. See: sagex show vault \"{v.get('name')}\"")
1094
+ raise typer.Exit(code=1)
1095
+ payload["credential"] = cred["id"]
1096
+
1097
+ try:
1098
+ created = resources.create_server(client, payload)
1099
+ except ApiError as exc:
1100
+ typer.echo(f"✗ {exc.message}")
1101
+ raise typer.Exit(code=1)
1102
+
1103
+ typer.echo(f"✓ Created server '{created.get('name')}' (id {created.get('id')}) in vault '{v.get('name')}'")
1104
+ attached = f" · key: {cred.get('name')}" if cred else " · no key attached"
1105
+ render.note(f" {method} · {host}:{port}{attached}")
1106
+
1107
+
1108
+ @create_app.command("key")
1109
+ def create_key_cmd(
1110
+ name: str = typer.Argument(..., help="Credential name (unique within the vault)."),
1111
+ vault: str = typer.Option(..., "--vault", help="Vault name or id to create the key in (required)."),
1112
+ type_: str = typer.Option(..., "--type", "-t", help=f"Credential type: {_KEY_TYPE_CHOICES} (aliases: ssh, cert)."),
1113
+ username: str = typer.Option(None, "--username", "-u", help="Username (username_password type; prompted if omitted)."),
1114
+ key_file: str = typer.Option(None, "--key-file", help="SSH private key file path, or - to paste (ssh_key type)."),
1115
+ cert_file: str = typer.Option(None, "--cert-file", help="Certificate PEM file path, or - to paste (certificate type)."),
1116
+ ) -> None:
1117
+ """Create a vault credential (key). Secrets are entered securely — never via flags."""
1118
+ ctype = _normalize_key_type(type_)
1119
+
1120
+ # Reject flags that don't match the chosen type (fast, before any network/prompt).
1121
+ if username is not None and ctype != "username_password":
1122
+ typer.echo("✗ --username only applies to a username_password key.")
1123
+ raise typer.Exit(code=1)
1124
+ if key_file is not None and ctype != "ssh_key":
1125
+ typer.echo("✗ --key-file only applies to an ssh_key key.")
1126
+ raise typer.Exit(code=1)
1127
+ if cert_file is not None and ctype != "certificate":
1128
+ typer.echo("✗ --cert-file only applies to a certificate key.")
1129
+ raise typer.Exit(code=1)
1130
+
1131
+ client = _client_or_exit()
1132
+ v = _resolve_or_exit(resources.resolve_vault, client, vault, "vault")
1133
+
1134
+ # Name is unique within the vault — check BEFORE prompting for any secret.
1135
+ if resources.find_credential_in_vault(v, name):
1136
+ typer.echo(f"✗ Vault '{v.get('name')}' already has a key named '{name}'.")
1137
+ raise typer.Exit(code=1)
1138
+
1139
+ payload = {"vault": v["id"], "name": name, "credential_type": ctype}
1140
+ if ctype == "username_password":
1141
+ payload["username"] = username if username is not None else typer.prompt("Username")
1142
+ payload["password"] = _prompt_secret("Password", confirm=True)
1143
+ elif ctype == "ssh_key":
1144
+ payload["ssh_key"] = _acquire_multiline("SSH private key", key_file, paste_when_none=True)
1145
+ passphrase = _prompt_optional_secret("Key passphrase (leave blank if none)")
1146
+ if passphrase:
1147
+ payload["key_passphrase"] = passphrase
1148
+ else: # certificate
1149
+ payload["cert_pem"] = _acquire_multiline("certificate PEM", cert_file, paste_when_none=True)
1150
+
1151
+ try:
1152
+ created = resources.create_credential(client, payload)
1153
+ except ApiError as exc:
1154
+ typer.echo(f"✗ {exc.message}")
1155
+ raise typer.Exit(code=1)
1156
+ typer.echo(f"✓ Created key '{created.get('name')}' (id {created.get('id')}, type {ctype}) in vault '{v.get('name')}'")
1157
+
1158
+
1159
+ @update_app.command("server")
1160
+ def update_server_cmd(
1161
+ ref: str = typer.Argument(..., help="Server name or id."),
1162
+ name: str = typer.Option(None, "--name", help="New server name (unique within its vault)."),
1163
+ host: str = typer.Option(None, "--host", help="New hostname or IP address."),
1164
+ method: str = typer.Option(None, "--method", "-m", help="New connection method: ssh or winrm."),
1165
+ port: int = typer.Option(None, "--port", "-p", help="New port (1-65535)."),
1166
+ key: str = typer.Option(None, "--key", "-k", help="Attach a credential (name or id) from the same vault."),
1167
+ unlink_key: bool = typer.Option(False, "--unlink-key", help="Detach the currently attached credential."),
1168
+ vault: str = typer.Option(None, "--vault", help="Scope the lookup to this vault (name or id) — use when the same server name exists in multiple vaults."),
1169
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip the confirmation prompt."),
1170
+ ) -> None:
1171
+ """Update a vault server's fields and/or its attached credential."""
1172
+ if key and unlink_key:
1173
+ typer.echo("✗ Use either --key or --unlink-key, not both.")
1174
+ raise typer.Exit(code=1)
1175
+ if name is None and host is None and method is None and port is None and not key and not unlink_key:
1176
+ typer.echo("Nothing to update — pass --name/--host/--method/--port/--key/--unlink-key.")
1177
+ raise typer.Exit(code=1)
1178
+
1179
+ if method is not None:
1180
+ method = method.lower()
1181
+ if method not in ("ssh", "winrm"):
1182
+ typer.echo("✗ --method must be 'ssh' or 'winrm'.")
1183
+ raise typer.Exit(code=1)
1184
+ if port is not None and not 1 <= port <= 65535:
1185
+ typer.echo("✗ --port must be between 1 and 65535.")
1186
+ raise typer.Exit(code=1)
1187
+
1188
+ client = _client_or_exit()
1189
+
1190
+ # --vault scopes the lookup to one vault (names are unique there); otherwise
1191
+ # resolve globally, which reports an ambiguity if the name spans vaults.
1192
+ vault_detail = None
1193
+ if vault:
1194
+ vault_detail = _resolve_or_exit(resources.resolve_vault, client, vault, "vault")
1195
+ server = resources.find_server_in_vault(vault_detail, ref)
1196
+ if not server:
1197
+ typer.echo(f"✗ No server '{ref}' in vault '{vault_detail.get('name')}'.")
1198
+ raise typer.Exit(code=1)
1199
+ else:
1200
+ server = _resolve_or_exit(resources.resolve_server, client, ref, "server")
1201
+
1202
+ payload: dict = {}
1203
+ if name is not None:
1204
+ payload["name"] = name
1205
+ if host is not None:
1206
+ payload["host"] = host
1207
+ if method is not None:
1208
+ payload["connection_method"] = method
1209
+ if port is not None:
1210
+ payload["port"] = port
1211
+
1212
+ # A rename or a key change both need the vault detail (its servers + credentials
1213
+ # lists). Reuse the one fetched for --vault, else fetch by the server's vault id.
1214
+ if (name is not None or key) and vault_detail is None:
1215
+ vault_detail = _resolve_or_exit(resources.resolve_vault, client, server.get("vault"), "vault")
1216
+
1217
+ if name is not None:
1218
+ for s in (vault_detail.get("servers") or []):
1219
+ if str(s.get("id")) != str(server.get("id")) and str(s.get("name") or "").lower() == name.strip().lower():
1220
+ typer.echo(f"✗ Vault '{vault_detail.get('name')}' already has another server named '{name}'.")
1221
+ raise typer.Exit(code=1)
1222
+
1223
+ cred = None
1224
+ if key:
1225
+ cred = resources.find_credential_in_vault(vault_detail, key)
1226
+ if not cred:
1227
+ typer.echo(f"✗ No credential '{key}' in this server's vault '{vault_detail.get('name')}'.")
1228
+ typer.echo(f" A server's key must live in the same vault. See: sagex show vault \"{vault_detail.get('name')}\"")
1229
+ raise typer.Exit(code=1)
1230
+ payload["credential"] = cred["id"]
1231
+ elif unlink_key:
1232
+ payload["credential"] = None
1233
+
1234
+ typer.echo(f"Updating server '{server.get('name')}' (id {str(server.get('id'))[:8]}…)")
1235
+ if not yes and not typer.confirm(" Apply these changes?"):
1236
+ typer.echo("Aborted — nothing changed.")
1237
+ raise typer.Exit(code=1)
1238
+
1239
+ try:
1240
+ updated = resources.update_server(client, server["id"], payload)
1241
+ except ApiError as exc:
1242
+ typer.echo(f"✗ {exc.message}")
1243
+ raise typer.Exit(code=1)
1244
+
1245
+ typer.echo(f"✓ Updated server '{updated.get('name')}' (id {updated.get('id')})")
1246
+
1247
+
1248
+ @update_app.command("key")
1249
+ def update_key_cmd(
1250
+ ref: str = typer.Argument(..., help="Credential (key) name or id."),
1251
+ vault: str = typer.Option(None, "--vault", help="Scope the lookup to this vault (name or id) — for a name shared across vaults."),
1252
+ name: str = typer.Option(None, "--name", help="New name for the key."),
1253
+ username: str = typer.Option(None, "--username", "-u", help="New username (username_password keys)."),
1254
+ set_password: bool = typer.Option(False, "--set-password", help="Prompt for a new password (hidden)."),
1255
+ key_file: str = typer.Option(None, "--key-file", help="Replace the SSH key from this file path, or - to paste."),
1256
+ set_passphrase: bool = typer.Option(False, "--set-passphrase", help="Prompt for a new key passphrase (blank clears it)."),
1257
+ cert_file: str = typer.Option(None, "--cert-file", help="Replace the certificate PEM from this file path, or - to paste."),
1258
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip the confirmation prompt."),
1259
+ ) -> None:
1260
+ """Update a vault credential — rename or rotate a secret. Secrets entered securely."""
1261
+ if not (name is not None or username is not None or set_password
1262
+ or set_passphrase or key_file is not None or cert_file is not None):
1263
+ typer.echo("Nothing to update — pass --name/--username/--set-password/--key-file/--set-passphrase/--cert-file.")
1264
+ raise typer.Exit(code=1)
1265
+
1266
+ client = _client_or_exit()
1267
+
1268
+ # --vault scopes the lookup (names are unique per vault); else resolve globally.
1269
+ if vault:
1270
+ vd = _resolve_or_exit(resources.resolve_vault, client, vault, "vault")
1271
+ cred = resources.find_credential_in_vault(vd, ref)
1272
+ if not cred:
1273
+ typer.echo(f"✗ No key '{ref}' in vault '{vd.get('name')}'.")
1274
+ raise typer.Exit(code=1)
1275
+ else:
1276
+ cred = _resolve_or_exit(resources.resolve_credential, client, ref, "key")
1277
+ vd = None
1278
+
1279
+ ctype = cred.get("credential_type")
1280
+
1281
+ # Only allow changes that match the key's type (update can't change the type).
1282
+ if (username is not None or set_password) and ctype != "username_password":
1283
+ typer.echo(f"✗ This key is type '{ctype}'; --username/--set-password only apply to username_password.")
1284
+ raise typer.Exit(code=1)
1285
+ if (key_file is not None or set_passphrase) and ctype != "ssh_key":
1286
+ typer.echo(f"✗ This key is type '{ctype}'; --key-file/--set-passphrase only apply to ssh_key.")
1287
+ raise typer.Exit(code=1)
1288
+ if cert_file is not None and ctype != "certificate":
1289
+ typer.echo(f"✗ This key is type '{ctype}'; --cert-file only applies to certificate.")
1290
+ raise typer.Exit(code=1)
1291
+
1292
+ # Rename clash guard within the same vault.
1293
+ if name is not None:
1294
+ if vd is None:
1295
+ vd = _resolve_or_exit(resources.resolve_vault, client, cred.get("vault"), "vault")
1296
+ clash = resources.find_credential_in_vault(vd, name)
1297
+ if clash and str(clash.get("id")) != str(cred.get("id")):
1298
+ typer.echo(f"✗ Vault '{vd.get('name')}' already has another key named '{name}'.")
1299
+ raise typer.Exit(code=1)
1300
+
1301
+ # Confirm the intended changes BEFORE entering any secret value.
1302
+ changes = []
1303
+ if name is not None: changes.append(f"rename to '{name}'")
1304
+ if username is not None: changes.append("set username")
1305
+ if set_password: changes.append("new password")
1306
+ if key_file is not None: changes.append("replace SSH key")
1307
+ if set_passphrase: changes.append("new passphrase")
1308
+ if cert_file is not None: changes.append("replace certificate")
1309
+ typer.echo(f"Updating key '{cred.get('name')}' (id {str(cred.get('id'))[:8]}…): {', '.join(changes)}")
1310
+ if not yes and not typer.confirm(" Proceed?"):
1311
+ typer.echo("Aborted — nothing changed.")
1312
+ raise typer.Exit(code=1)
1313
+
1314
+ # Collect values (prompts / files) only after confirmation.
1315
+ payload: dict = {}
1316
+ if name is not None:
1317
+ payload["name"] = name
1318
+ if username is not None:
1319
+ payload["username"] = username
1320
+ if set_password:
1321
+ payload["password"] = _prompt_secret("New password", confirm=True)
1322
+ if key_file is not None:
1323
+ payload["ssh_key"] = _acquire_multiline("SSH private key", key_file, paste_when_none=False)
1324
+ if set_passphrase:
1325
+ payload["key_passphrase"] = _prompt_optional_secret("New key passphrase (blank to clear)")
1326
+ if cert_file is not None:
1327
+ payload["cert_pem"] = _acquire_multiline("certificate PEM", cert_file, paste_when_none=False)
1328
+
1329
+ try:
1330
+ updated = resources.update_credential(client, cred["id"], payload)
1331
+ except ApiError as exc:
1332
+ typer.echo(f"✗ {exc.message}")
1333
+ raise typer.Exit(code=1)
1334
+ typer.echo(f"✓ Updated key '{updated.get('name')}' (id {updated.get('id')})")
1335
+
1336
+
1337
+ # ---------------------------------------------------------------------------
1338
+ # Delete commands. Resolution reuses the resolve_* helpers, so an ambiguous name
1339
+ # prints the matching records (with ids) and aborts. Deletes are irreversible.
1340
+ # ---------------------------------------------------------------------------
1341
+
1342
+
1343
+ def _confirm_delete_or_abort(kind: str, name, ident, yes: bool) -> None:
1344
+ """Standard delete confirmation (skipped when --yes). Aborts via typer.Exit."""
1345
+ if yes:
1346
+ return
1347
+ typer.echo(f"About to delete {kind} '{name}' (id {ident}). This cannot be undone.")
1348
+ if not typer.confirm(" Delete it?"):
1349
+ typer.echo("Aborted — nothing deleted.")
1350
+ raise typer.Exit(code=1)
1351
+
1352
+
1353
+ @delete_app.command("workflow")
1354
+ def delete_workflow_cmd(
1355
+ ref: str = typer.Argument(..., help="Workflow name or id."),
1356
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip the confirmation prompt."),
1357
+ ) -> None:
1358
+ """Delete a workflow."""
1359
+ client = _client_or_exit()
1360
+ wf = _resolve_or_exit(resources.resolve_workflow, client, ref, "workflow")
1361
+ _confirm_delete_or_abort("workflow", wf.get("name"), wf.get("id"), yes)
1362
+ try:
1363
+ resources.delete_workflow(client, wf["id"])
1364
+ except ApiError as exc:
1365
+ typer.echo(f"✗ {exc.message}")
1366
+ raise typer.Exit(code=1)
1367
+ typer.echo(f"✓ Deleted workflow '{wf.get('name')}' (id {wf.get('id')})")
1368
+
1369
+
1370
+ @delete_app.command("script")
1371
+ def delete_script_cmd(
1372
+ ref: str = typer.Argument(..., help="Script name or id."),
1373
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip the confirmation prompt."),
1374
+ ) -> None:
1375
+ """Delete a script."""
1376
+ client = _client_or_exit()
1377
+ meta = _resolve_or_exit(resources.resolve_script, client, ref, "script")
1378
+ _confirm_delete_or_abort("script", meta.get("name"), meta.get("id"), yes)
1379
+ try:
1380
+ resources.delete_script(client, meta["id"])
1381
+ except ApiError as exc:
1382
+ typer.echo(f"✗ {exc.message}")
1383
+ raise typer.Exit(code=1)
1384
+ typer.echo(f"✓ Deleted script '{meta.get('name')}' (id {meta.get('id')})")
1385
+
1386
+
1387
+ @delete_app.command("vault")
1388
+ def delete_vault_cmd(
1389
+ ref: str = typer.Argument(..., help="Vault name or id."),
1390
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip the confirmation (and the cascade name check)."),
1391
+ ) -> None:
1392
+ """Delete a vault. This CASCADES — every server and credential inside it is deleted too."""
1393
+ client = _client_or_exit()
1394
+ v = _resolve_or_exit(resources.resolve_vault, client, ref, "vault")
1395
+ name = v.get("name")
1396
+ servers = v.get("servers") or []
1397
+ creds = v.get("credentials") or []
1398
+
1399
+ if not yes:
1400
+ typer.echo(f"⚠ Deleting vault '{name}' (id {v.get('id')}) will ALSO delete everything inside it:")
1401
+ typer.echo(f" {len(servers)} server(s) and {len(creds)} credential(s).")
1402
+ typer.echo(" This cannot be undone.")
1403
+ typed = typer.prompt(" Type the vault name to confirm")
1404
+ if typed.strip() != name:
1405
+ typer.echo("Name did not match — aborted.")
1406
+ raise typer.Exit(code=1)
1407
+
1408
+ try:
1409
+ resources.delete_vault(client, v["id"])
1410
+ except ApiError as exc:
1411
+ typer.echo(f"✗ {exc.message}")
1412
+ raise typer.Exit(code=1)
1413
+ typer.echo(f"✓ Deleted vault '{name}' (and {len(servers)} server(s), {len(creds)} credential(s))")
1414
+
1415
+
1416
+ @delete_app.command("server")
1417
+ def delete_server_cmd(
1418
+ ref: str = typer.Argument(..., help="Server name or id."),
1419
+ vault: str = typer.Option(None, "--vault", help="Scope the lookup to this vault (name or id) — for a name shared across vaults."),
1420
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip the confirmation prompt."),
1421
+ ) -> None:
1422
+ """Delete a vault server."""
1423
+ client = _client_or_exit()
1424
+ if vault:
1425
+ vd = _resolve_or_exit(resources.resolve_vault, client, vault, "vault")
1426
+ server = resources.find_server_in_vault(vd, ref)
1427
+ if not server:
1428
+ typer.echo(f"✗ No server '{ref}' in vault '{vd.get('name')}'.")
1429
+ raise typer.Exit(code=1)
1430
+ else:
1431
+ server = _resolve_or_exit(resources.resolve_server, client, ref, "server")
1432
+
1433
+ _confirm_delete_or_abort("server", server.get("name"), server.get("id"), yes)
1434
+ try:
1435
+ resources.delete_server(client, server["id"])
1436
+ except ApiError as exc:
1437
+ typer.echo(f"✗ {exc.message}")
1438
+ raise typer.Exit(code=1)
1439
+ typer.echo(f"✓ Deleted server '{server.get('name')}' (id {server.get('id')})")
1440
+
1441
+
1442
+ @delete_app.command("key")
1443
+ def delete_key_cmd(
1444
+ ref: str = typer.Argument(..., help="Credential (key) name or id."),
1445
+ vault: str = typer.Option(None, "--vault", help="Scope the lookup to this vault (name or id) — for a name shared across vaults."),
1446
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip the confirmation prompt."),
1447
+ ) -> None:
1448
+ """Delete a vault credential (key)."""
1449
+ client = _client_or_exit()
1450
+ if vault:
1451
+ vd = _resolve_or_exit(resources.resolve_vault, client, vault, "vault")
1452
+ cred = resources.find_credential_in_vault(vd, ref)
1453
+ if not cred:
1454
+ typer.echo(f"✗ No key '{ref}' in vault '{vd.get('name')}'.")
1455
+ raise typer.Exit(code=1)
1456
+ else:
1457
+ cred = _resolve_or_exit(resources.resolve_credential, client, ref, "key")
1458
+
1459
+ if not yes:
1460
+ typer.echo(f"About to delete key '{cred.get('name')}' (id {cred.get('id')}). This cannot be undone.")
1461
+ typer.echo(" Any server using this key will have its credential unset.")
1462
+ if not typer.confirm(" Delete it?"):
1463
+ typer.echo("Aborted — nothing deleted.")
1464
+ raise typer.Exit(code=1)
1465
+
1466
+ try:
1467
+ resources.delete_credential(client, cred["id"])
1468
+ except ApiError as exc:
1469
+ typer.echo(f"✗ {exc.message}")
1470
+ raise typer.Exit(code=1)
1471
+ typer.echo(f"✓ Deleted key '{cred.get('name')}' (id {cred.get('id')})")
1472
+
1473
+
1474
+ @delete_app.command("trigger")
1475
+ def delete_trigger_cmd(
1476
+ ref: str = typer.Argument(..., help="Workflow name, or trigger id."),
1477
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip the confirmation prompt."),
1478
+ ) -> None:
1479
+ """Delete a trigger (HTTP or schedule)."""
1480
+ client = _client_or_exit()
1481
+ kind, t = _resolve_or_exit(resources.resolve_trigger, client, ref, "trigger")
1482
+ _confirm_delete_or_abort("trigger", f"{t.get('workflow_name')} ({kind})", t.get("id"), yes)
1483
+ try:
1484
+ resources.delete_trigger(client, t.get("workflow_id"), kind, t.get("node_id"))
1485
+ except ApiError as exc:
1486
+ typer.echo(f"✗ {exc.message}")
1487
+ raise typer.Exit(code=1)
1488
+ typer.echo(f"✓ Deleted {kind} trigger for '{t.get('workflow_name')}'")
1489
+
1490
+
1491
+ # ---------------------------------------------------------------------------
1492
+ # Trigger configuration (dedicated `trigger` sub-app). create/regenerate target a
1493
+ # workflow's trigger NODE; enable/disable/invoke act on an existing trigger.
1494
+ # ---------------------------------------------------------------------------
1495
+
1496
+
1497
+ def _pick_trigger_node(wf: dict, dtype: str, node_opt: str | None) -> str:
1498
+ """Return the id of the workflow's <dtype> trigger node, or exit with guidance."""
1499
+ if node_opt:
1500
+ ids = {n.get("id") for n in (wf.get("nodes") or [])}
1501
+ if node_opt not in ids:
1502
+ typer.echo(f"✗ Node '{node_opt}' not found in workflow '{wf.get('name')}'.")
1503
+ raise typer.Exit(code=1)
1504
+ return node_opt
1505
+ nodes = resources.find_trigger_nodes(wf, dtype)
1506
+ if not nodes:
1507
+ typer.echo(f"✗ Workflow '{wf.get('name')}' has no {dtype} trigger node.")
1508
+ typer.echo(f" Add a trigger node with data.type '{dtype}' (see: sagex syntax trigger), or pass --node <id>.")
1509
+ raise typer.Exit(code=1)
1510
+ if len(nodes) > 1:
1511
+ typer.echo(f"Workflow '{wf.get('name')}' has multiple {dtype} trigger nodes — pass --node <id>:")
1512
+ for n in nodes:
1513
+ typer.echo(f" {n.get('id')} {(n.get('data') or {}).get('label') or ''}")
1514
+ raise typer.Exit(code=1)
1515
+ return nodes[0].get("id")
1516
+
1517
+
1518
+ _TRIGGER_LABELS = {"http": "HTTP Trigger", "schedule": "Schedule Trigger"}
1519
+
1520
+
1521
+ def _offer_trigger_switch(client, wf: dict, dtype: str, yes: bool) -> dict:
1522
+ """A workflow has exactly one trigger node. If it's a manual one, offer to make it
1523
+ a <dtype> trigger (on-demand runs keep working); returns the updated workflow."""
1524
+ triggers = [n for n in wf.get("nodes") or [] if n.get("type") == "trigger"]
1525
+ if len(triggers) != 1:
1526
+ return wf # _pick_trigger_node explains the problem
1527
+ node = triggers[0]
1528
+ data = node.get("data") if isinstance(node.get("data"), dict) else {}
1529
+ current = data.get("type") or "manual"
1530
+ if current != "manual":
1531
+ typer.echo(f"✗ Workflow '{wf.get('name')}' uses a {current} trigger node, and a workflow has only one.")
1532
+ typer.echo(f" To switch, remove that trigger (sagex delete trigger \"{wf.get('name')}\"), then set the")
1533
+ typer.echo(f" node's data.type to '{dtype}' in the workflow file and push it.")
1534
+ raise typer.Exit(code=1)
1535
+
1536
+ kind = "an HTTP webhook" if dtype == "http" else "a schedule"
1537
+ typer.echo(f"Workflow '{wf.get('name')}' has a manual trigger. It can be switched to {kind};")
1538
+ typer.echo(" on-demand runs (sagex run workflow) keep working either way.")
1539
+ if not yes and not typer.confirm(" Switch the trigger node?"):
1540
+ typer.echo("Aborted — nothing changed.")
1541
+ raise typer.Exit(code=1)
1542
+
1543
+ new_data = {**data, "type": dtype}
1544
+ if data.get("label") in (None, "", "Trigger", "Manual Trigger"):
1545
+ new_data["label"] = _TRIGGER_LABELS[dtype]
1546
+ nodes = [{**n, "data": new_data} if n is node else n for n in wf["nodes"]]
1547
+ try:
1548
+ resources.update_workflow_nodes(client, wf["id"], nodes)
1549
+ except ApiError as exc:
1550
+ typer.echo(f"✗ {exc.message}")
1551
+ raise typer.Exit(code=1)
1552
+ typer.echo(f"✓ Switched the trigger node to {dtype}.")
1553
+ return {**wf, "nodes": nodes}
1554
+
1555
+
1556
+ @trigger_app.command("create")
1557
+ def trigger_create_cmd(
1558
+ workflow: str = typer.Argument(..., help="Workflow name or id."),
1559
+ type_: str = typer.Option(..., "--type", "-t", help="Trigger type: http or schedule."),
1560
+ cron: str = typer.Option(None, "--cron", help="Cron expression (5 fields, UTC) — required for schedule."),
1561
+ node: str = typer.Option(None, "--node", help="Trigger node id (auto-detected if the workflow has exactly one)."),
1562
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmations (switching a manual trigger node, rotating an HTTP secret)."),
1563
+ ) -> None:
1564
+ """Create or update a workflow trigger. HTTP prints its secret once + how to call it.
1565
+
1566
+ If the workflow's trigger node is manual, this offers to switch it to the requested type first.
1567
+ """
1568
+ ttype = type_.lower()
1569
+ if ttype not in ("http", "schedule"):
1570
+ typer.echo("✗ --type must be 'http' or 'schedule'.")
1571
+ raise typer.Exit(code=1)
1572
+ if ttype == "schedule":
1573
+ if not cron:
1574
+ typer.echo("✗ A schedule trigger needs --cron \"<min> <hour> <day-of-month> <month> <day-of-week>\".")
1575
+ raise typer.Exit(code=1)
1576
+ if len(cron.split()) != 5:
1577
+ typer.echo("✗ --cron must have exactly 5 whitespace-separated fields.")
1578
+ raise typer.Exit(code=1)
1579
+ elif cron:
1580
+ typer.echo("✗ --cron only applies to a schedule trigger.")
1581
+ raise typer.Exit(code=1)
1582
+
1583
+ client = _client_or_exit()
1584
+ wf = _resolve_or_exit(resources.resolve_workflow, client, workflow, "workflow")
1585
+ if not node and not resources.find_trigger_nodes(wf, ttype):
1586
+ wf = _offer_trigger_switch(client, wf, ttype, yes)
1587
+ node_id = _pick_trigger_node(wf, ttype, node)
1588
+
1589
+ if ttype == "http":
1590
+ exists = False
1591
+ try:
1592
+ resources.get_http_trigger(client, wf["id"], node_id)
1593
+ exists = True
1594
+ except ApiError as exc:
1595
+ if exc.status != 404:
1596
+ typer.echo(f"✗ {exc.message}")
1597
+ raise typer.Exit(code=1)
1598
+ if exists and not yes:
1599
+ typer.echo("⚠ An HTTP trigger already exists for this node.")
1600
+ typer.echo(" Recreating ROTATES the secret and invalidates the current one.")
1601
+ if not typer.confirm(" Rotate it now?"):
1602
+ typer.echo("Aborted — trigger unchanged.")
1603
+ raise typer.Exit(code=1)
1604
+ try:
1605
+ data = resources.create_http_trigger(client, wf["id"], node_id)
1606
+ except ApiError as exc:
1607
+ typer.echo(f"✗ {exc.message}")
1608
+ raise typer.Exit(code=1)
1609
+ render.http_trigger_result(data)
1610
+ else:
1611
+ try:
1612
+ data = resources.upsert_schedule_trigger(client, wf["id"], node_id, cron)
1613
+ except ApiError as exc:
1614
+ detail = (exc.errors or {}).get("cron_expression") if isinstance(exc.errors, dict) else None
1615
+ typer.echo(f"✗ {detail[0] if isinstance(detail, list) and detail else exc.message}")
1616
+ raise typer.Exit(code=1)
1617
+ render.schedule_trigger_result(data, wf.get("name"))
1618
+ # Scheduled runs are started with no inputs, so a parameter without a saved
1619
+ # value runs empty every time.
1620
+ empty = [s["name"] for s in runner.workflow_params(wf.get("nodes"))
1621
+ if s["source"] == "manual" and not s["default"]]
1622
+ if empty:
1623
+ typer.echo(f"⚠ Scheduled runs pass no parameters, so these will run empty: {', '.join(empty)}.")
1624
+ typer.echo(" Give them a saved value in the workflow (sagex copy workflow … → edit → push).")
1625
+
1626
+
1627
+ @trigger_app.command("regenerate")
1628
+ def trigger_regenerate_cmd(
1629
+ workflow: str = typer.Argument(..., help="Workflow name or id."),
1630
+ node: str = typer.Option(None, "--node", help="Trigger node id (auto-detected if one)."),
1631
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip the confirmation prompt."),
1632
+ ) -> None:
1633
+ """Rotate an HTTP trigger's secret (same URL). The current secret stops working."""
1634
+ client = _client_or_exit()
1635
+ wf = _resolve_or_exit(resources.resolve_workflow, client, workflow, "workflow")
1636
+ node_id = _pick_trigger_node(wf, "http", node)
1637
+ if not yes and not typer.confirm("Rotate the secret? The current one will stop working."):
1638
+ typer.echo("Aborted — secret unchanged.")
1639
+ raise typer.Exit(code=1)
1640
+ try:
1641
+ data = resources.regenerate_http_trigger(client, wf["id"], node_id)
1642
+ except ApiError as exc:
1643
+ typer.echo(f"✗ {exc.message}")
1644
+ raise typer.Exit(code=1)
1645
+ render.http_trigger_result(data)
1646
+
1647
+
1648
+ def _set_trigger_active(ref: str, active: bool) -> None:
1649
+ """Enable/disable an existing trigger via the global PATCH (keyed by its uuid)."""
1650
+ client = _client_or_exit()
1651
+ kind, t = _resolve_or_exit(resources.resolve_trigger, client, ref, "trigger")
1652
+ try:
1653
+ resources.set_trigger_active(client, kind, t.get("id"), active)
1654
+ except ApiError as exc:
1655
+ typer.echo(f"✗ {exc.message}")
1656
+ raise typer.Exit(code=1)
1657
+ typer.echo(f"✓ {'Enabled' if active else 'Disabled'} {kind} trigger for '{t.get('workflow_name')}'")
1658
+
1659
+
1660
+ @trigger_app.command("enable")
1661
+ def trigger_enable_cmd(
1662
+ ref: str = typer.Argument(..., help="Workflow name, or trigger id."),
1663
+ ) -> None:
1664
+ """Enable a trigger (is_active = true)."""
1665
+ _set_trigger_active(ref, True)
1666
+
1667
+
1668
+ @trigger_app.command("disable")
1669
+ def trigger_disable_cmd(
1670
+ ref: str = typer.Argument(..., help="Workflow name, or trigger id."),
1671
+ ) -> None:
1672
+ """Disable a trigger without deleting it (is_active = false)."""
1673
+ _set_trigger_active(ref, False)
1674
+
1675
+
1676
+ @trigger_app.command("invoke")
1677
+ def trigger_invoke_cmd(
1678
+ ref: str = typer.Argument(..., help="Workflow name, or trigger id."),
1679
+ ) -> None:
1680
+ """Show how to call an HTTP trigger (URL, headers, body, curl). Secret is not shown."""
1681
+ client = _client_or_exit()
1682
+ kind, t = _resolve_or_exit(resources.resolve_trigger, client, ref, "trigger")
1683
+ if kind != "http":
1684
+ typer.echo("✗ Only HTTP triggers are called via URL — this is a schedule trigger.")
1685
+ raise typer.Exit(code=1)
1686
+ render.http_trigger_invocation(t)
1687
+
1688
+
1689
+ # ---------------------------------------------------------------------------
1690
+ # Rename commands. Low-risk and reversible, so they run without a confirmation
1691
+ # prompt (they're meant to double as Autobot tools). Name-uniqueness is guarded
1692
+ # where the server enforces it (vault per-owner; server/key per-vault).
1693
+ # ---------------------------------------------------------------------------
1694
+
1695
+
1696
+ @rename_app.command("workflow")
1697
+ def rename_workflow_cmd(
1698
+ ref: str = typer.Argument(..., help="Workflow name or id."),
1699
+ new_name: str = typer.Argument(..., help="New workflow name."),
1700
+ ) -> None:
1701
+ """Rename a workflow (leaves its nodes and edges untouched)."""
1702
+ client = _client_or_exit()
1703
+ wf = _resolve_or_exit(resources.resolve_workflow, client, ref, "workflow")
1704
+ try:
1705
+ updated = resources.rename_workflow(client, wf["id"], new_name)
1706
+ except ApiError as exc:
1707
+ typer.echo(f"✗ {exc.message}")
1708
+ raise typer.Exit(code=1)
1709
+ typer.echo(f"✓ Renamed workflow to '{updated.get('name') or new_name}' (id {wf.get('id')})")
1710
+
1711
+
1712
+ @rename_app.command("script")
1713
+ def rename_script_cmd(
1714
+ ref: str = typer.Argument(..., help="Script name or id."),
1715
+ new_name: str = typer.Argument(..., help="New script name (no extension — it's kept automatically)."),
1716
+ ) -> None:
1717
+ """Rename a script. Give the bare name; the file extension is preserved."""
1718
+ if not pusher.is_valid_script_name(new_name):
1719
+ typer.echo("✗ A script name can only contain letters, numbers, '_' and '-' "
1720
+ "(no extension or dot — the extension is kept automatically).")
1721
+ raise typer.Exit(code=1)
1722
+ client = _client_or_exit()
1723
+ meta = _resolve_or_exit(resources.resolve_script, client, ref, "script")
1724
+ try:
1725
+ updated = resources.rename_script(client, meta["id"], new_name)
1726
+ except ApiError as exc:
1727
+ typer.echo(f"✗ {exc.message}")
1728
+ raise typer.Exit(code=1)
1729
+ typer.echo(f"✓ Renamed script to '{updated.get('name') or new_name}' (id {meta.get('id')})")
1730
+
1731
+
1732
+ @rename_app.command("vault")
1733
+ def rename_vault_cmd(
1734
+ ref: str = typer.Argument(..., help="Vault name or id."),
1735
+ new_name: str = typer.Argument(..., help="New vault name (unique among your vaults)."),
1736
+ ) -> None:
1737
+ """Rename a vault."""
1738
+ client = _client_or_exit()
1739
+ v = _resolve_or_exit(resources.resolve_vault, client, ref, "vault")
1740
+ clash = resources.find_vault_by_name(client, new_name)
1741
+ if clash and str(clash.get("id")) != str(v.get("id")):
1742
+ typer.echo(f"✗ You already have another vault named '{new_name}' (id {clash.get('id')}).")
1743
+ raise typer.Exit(code=1)
1744
+ try:
1745
+ updated = resources.update_vault(client, v["id"], {"name": new_name})
1746
+ except ApiError as exc:
1747
+ typer.echo(f"✗ {exc.message}")
1748
+ raise typer.Exit(code=1)
1749
+ typer.echo(f"✓ Renamed vault to '{updated.get('name') or new_name}' (id {v.get('id')})")
1750
+
1751
+
1752
+ @rename_app.command("server")
1753
+ def rename_server_cmd(
1754
+ ref: str = typer.Argument(..., help="Server name or id."),
1755
+ new_name: str = typer.Argument(..., help="New server name (unique within its vault)."),
1756
+ vault: str = typer.Option(None, "--vault", help="Scope the lookup to this vault — for a name shared across vaults."),
1757
+ ) -> None:
1758
+ """Rename a vault server."""
1759
+ client = _client_or_exit()
1760
+ if vault:
1761
+ vd = _resolve_or_exit(resources.resolve_vault, client, vault, "vault")
1762
+ server = resources.find_server_in_vault(vd, ref)
1763
+ if not server:
1764
+ typer.echo(f"✗ No server '{ref}' in vault '{vd.get('name')}'.")
1765
+ raise typer.Exit(code=1)
1766
+ else:
1767
+ server = _resolve_or_exit(resources.resolve_server, client, ref, "server")
1768
+ vd = _resolve_or_exit(resources.resolve_vault, client, server.get("vault"), "vault")
1769
+
1770
+ clash = resources.find_server_in_vault(vd, new_name)
1771
+ if clash and str(clash.get("id")) != str(server.get("id")):
1772
+ typer.echo(f"✗ Vault '{vd.get('name')}' already has another server named '{new_name}'.")
1773
+ raise typer.Exit(code=1)
1774
+ try:
1775
+ updated = resources.update_server(client, server["id"], {"name": new_name})
1776
+ except ApiError as exc:
1777
+ typer.echo(f"✗ {exc.message}")
1778
+ raise typer.Exit(code=1)
1779
+ typer.echo(f"✓ Renamed server to '{updated.get('name') or new_name}' (id {server.get('id')})")
1780
+
1781
+
1782
+ @rename_app.command("key")
1783
+ def rename_key_cmd(
1784
+ ref: str = typer.Argument(..., help="Credential (key) name or id."),
1785
+ new_name: str = typer.Argument(..., help="New key name (unique within its vault)."),
1786
+ vault: str = typer.Option(None, "--vault", help="Scope the lookup to this vault — for a name shared across vaults."),
1787
+ ) -> None:
1788
+ """Rename a vault credential (key)."""
1789
+ client = _client_or_exit()
1790
+ if vault:
1791
+ vd = _resolve_or_exit(resources.resolve_vault, client, vault, "vault")
1792
+ cred = resources.find_credential_in_vault(vd, ref)
1793
+ if not cred:
1794
+ typer.echo(f"✗ No key '{ref}' in vault '{vd.get('name')}'.")
1795
+ raise typer.Exit(code=1)
1796
+ else:
1797
+ cred = _resolve_or_exit(resources.resolve_credential, client, ref, "key")
1798
+ vd = _resolve_or_exit(resources.resolve_vault, client, cred.get("vault"), "vault")
1799
+
1800
+ clash = resources.find_credential_in_vault(vd, new_name)
1801
+ if clash and str(clash.get("id")) != str(cred.get("id")):
1802
+ typer.echo(f"✗ Vault '{vd.get('name')}' already has another key named '{new_name}'.")
1803
+ raise typer.Exit(code=1)
1804
+ try:
1805
+ updated = resources.update_credential(client, cred["id"], {"name": new_name})
1806
+ except ApiError as exc:
1807
+ typer.echo(f"✗ {exc.message}")
1808
+ raise typer.Exit(code=1)
1809
+ typer.echo(f"✓ Renamed key to '{updated.get('name') or new_name}' (id {cred.get('id')})")
1810
+
1811
+
1812
+ @push_app.command("workflow")
1813
+ def push_workflow_cmd(
1814
+ ref: str = typer.Argument(..., help="Workflow file path, or a name under <workspace>/workflows/."),
1815
+ new: bool = typer.Option(False, "--new", help="Force-create a new workflow even if the file has an id."),
1816
+ name: str = typer.Option(None, "--name", help="Override the workflow name (useful with --new)."),
1817
+ yes: bool = typer.Option(False, "--yes", help="Skip the update confirmation prompt."),
1818
+ ) -> None:
1819
+ """Push a workflow file to the server — creates if new, updates if it carries an id."""
1820
+ client = _client_or_exit()
1821
+ workspace = Path(config.workspace_dir())
1822
+
1823
+ try:
1824
+ path = pusher.resolve_workflow_file(workspace, ref)
1825
+ except pusher.WorkflowFileNotFound as exc:
1826
+ typer.echo(f"No workflow file for '{exc.ref}'. Looked at:")
1827
+ for p in exc.looked:
1828
+ typer.echo(f" {p}")
1829
+ raise typer.Exit(code=1)
1830
+
1831
+ try:
1832
+ doc = pusher.load_doc(path)
1833
+ except ValueError as exc:
1834
+ typer.echo(f"✗ {exc}")
1835
+ raise typer.Exit(code=1)
1836
+
1837
+ problems = pusher.validate_workflow_doc(doc)
1838
+ if problems:
1839
+ typer.echo(f"✗ {path} is not a valid workflow:")
1840
+ for p in problems:
1841
+ typer.echo(f" - {p}")
1842
+ raise typer.Exit(code=1)
1843
+
1844
+ payload = pusher.workflow_payload(doc, name)
1845
+ wid = None if new else doc.get("id")
1846
+ label = payload["name"]
1847
+
1848
+ if wid:
1849
+ # UPDATE — fetch the server copy first (confirms it's ours + stale-write guard).
1850
+ try:
1851
+ server = resources.get_workflow_detail(client, wid)
1852
+ except ApiError as exc:
1853
+ if exc.status == 404:
1854
+ typer.echo(f"No workflow {wid} on the server (deleted, or not yours).")
1855
+ typer.echo("To create a fresh copy instead, re-run with --new.")
1856
+ else:
1857
+ typer.echo(f"✗ {exc.message}")
1858
+ raise typer.Exit(code=1)
1859
+
1860
+ typer.echo(f"Updating '{label}' (id {str(wid)[:8]}…)")
1861
+ if pusher.server_is_newer(server.get("modified_at"), doc.get("modified_at")):
1862
+ typer.echo(f" ⚠ server copy is newer (server {server.get('modified_at')} "
1863
+ f"vs local {doc.get('modified_at')}) — pushing overwrites it.")
1864
+ if not yes and not typer.confirm(" Overwrite the server copy?"):
1865
+ typer.echo("Aborted — nothing pushed.")
1866
+ raise typer.Exit(code=1)
1867
+
1868
+ try:
1869
+ updated = resources.update_workflow(client, wid, payload)
1870
+ except ApiError as exc:
1871
+ typer.echo(f"✗ {exc.message}")
1872
+ raise typer.Exit(code=1)
1873
+ pusher.sync_meta_back(path, doc, updated)
1874
+ typer.echo(f"✓ Updated '{updated.get('name')}' (id {updated.get('id')})")
1875
+ return
1876
+
1877
+ # CREATE
1878
+ typer.echo(f"Creating '{label}'…")
1879
+ try:
1880
+ created = resources.create_workflow(client, payload)
1881
+ except ApiError as exc:
1882
+ typer.echo(f"✗ {exc.message}")
1883
+ raise typer.Exit(code=1)
1884
+ pusher.sync_meta_back(path, doc, created)
1885
+ typer.echo(f"✓ Created '{created.get('name')}' (id {created.get('id')})")
1886
+ render.note(f" wrote id back to {path}")
1887
+
1888
+
1889
+ @push_app.command("script")
1890
+ def push_script_cmd(
1891
+ ref: str = typer.Argument(..., help="Script file path, or a name under <workspace>/scripts/."),
1892
+ name: str = typer.Option(None, "--name", help="Override the script name (letters, numbers, _ and - only)."),
1893
+ language: str = typer.Option(None, "--language", "--lang", help="Override the language (else inferred from the file extension)."),
1894
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip the update confirmation prompt."),
1895
+ ) -> None:
1896
+ """Push a script file — creates it, or updates the existing one after confirming.
1897
+
1898
+ A script's code can't carry a server id, so this matches by filename: it looks
1899
+ for one of yours with the same name and, if found, offers to update it (bumping
1900
+ its version); otherwise it creates a new script.
1901
+ """
1902
+ client = _client_or_exit()
1903
+ workspace = Path(config.workspace_dir())
1904
+
1905
+ try:
1906
+ path = pusher.resolve_script_file(workspace, ref)
1907
+ except pusher.ScriptFileNotFound as exc:
1908
+ if exc.matches:
1909
+ typer.echo(f"'{exc.ref}' matches more than one file — pass the full name:")
1910
+ for p in exc.matches:
1911
+ typer.echo(f" {p.name}")
1912
+ else:
1913
+ typer.echo(f"No script file for '{exc.ref}'. Looked at:")
1914
+ for p in exc.looked:
1915
+ typer.echo(f" {p}")
1916
+ raise typer.Exit(code=1)
1917
+
1918
+ try:
1919
+ spec = pusher.script_push_spec(path, name, language)
1920
+ except ValueError as exc:
1921
+ typer.echo(f"✗ {exc}")
1922
+ raise typer.Exit(code=1)
1923
+
1924
+ # Check-then-confirm: is there already a script of ours with this filename?
1925
+ try:
1926
+ existing = resources.find_script_by_name(client, spec["server_name"])
1927
+ except ApiError as exc:
1928
+ typer.echo(f"✗ {exc.message}")
1929
+ raise typer.Exit(code=1)
1930
+
1931
+ if existing:
1932
+ sid = existing.get("id")
1933
+ typer.echo(f"'{spec['server_name']}' already exists (id {sid}, v{existing.get('version')}, "
1934
+ f"updated {existing.get('updated_at')}).")
1935
+
1936
+ # Preview what the update would overwrite (best-effort; don't block on a fetch error).
1937
+ try:
1938
+ server_code = (resources.get_script_content(client, sid) or {}).get("content") or ""
1939
+ except ApiError as exc:
1940
+ server_code = None
1941
+ render.note(f" (couldn't fetch the current version to diff: {exc.message})")
1942
+
1943
+ if server_code is not None and not render.script_diff(spec["server_name"], server_code, spec["content"]):
1944
+ typer.echo("Local file matches the server copy — nothing to update.")
1945
+ return
1946
+
1947
+ if not yes and not typer.confirm(" Update it? This overwrites the code and bumps the version."):
1948
+ typer.echo("Aborted — nothing pushed.")
1949
+ raise typer.Exit(code=1)
1950
+ try:
1951
+ updated = resources.update_script(client, sid, spec["content"])
1952
+ except ApiError as exc:
1953
+ typer.echo(f"✗ {exc.message}")
1954
+ raise typer.Exit(code=1)
1955
+ typer.echo(f"✓ Updated '{updated.get('name')}' (id {updated.get('id')}, now v{updated.get('version')})")
1956
+ return
1957
+
1958
+ # CREATE — no existing script to clobber (server rejects a duplicate pathname).
1959
+ typer.echo(f"Creating '{spec['server_name']}' (language: {spec['language']})…")
1960
+ try:
1961
+ created = resources.create_script(client, {
1962
+ "name": spec["name"], "language": spec["language"], "content": spec["content"],
1963
+ })
1964
+ except ApiError as exc:
1965
+ typer.echo(f"✗ {exc.message}")
1966
+ raise typer.Exit(code=1)
1967
+ typer.echo(f"✓ Created '{created.get('name')}' (id {created.get('id')})")
1968
+
1969
+
1970
+ # ---------------------------------------------------------------------------
1971
+ # Run commands. A script run streams live from the server. Ctrl+C asks the server
1972
+ # to stop it while the stream stays open to report the final status; a second
1973
+ # Ctrl+C detaches. Secret inputs are only ever typed at a hidden prompt.
1974
+ # ---------------------------------------------------------------------------
1975
+
1976
+
1977
+ class _RunSetupError(Exception):
1978
+ """A run can't start; carries the lines to print once the activity line is gone."""
1979
+
1980
+ def __init__(self, *lines: str) -> None:
1981
+ super().__init__(lines[0])
1982
+ self.lines = lines
1983
+
1984
+
1985
+ @contextmanager
1986
+ def _setup_errors():
1987
+ """Turn failures from a run's setup step (which runs under the activity line and
1988
+ prints nothing) into clean messages + exits once that line has cleared."""
1989
+ try:
1990
+ yield
1991
+ except KeyboardInterrupt:
1992
+ typer.echo("Cancelled — nothing was run.", err=True)
1993
+ raise typer.Exit(code=130)
1994
+ except resources.AmbiguousResource as exc:
1995
+ render.ambiguous(exc)
1996
+ raise typer.Exit(code=1)
1997
+ except resources.ResourceNotFound as exc:
1998
+ typer.echo(str(exc))
1999
+ raise typer.Exit(code=1)
2000
+ except ApiError as exc:
2001
+ typer.echo(f"✗ {exc.message}")
2002
+ raise typer.Exit(code=1)
2003
+ except _RunSetupError as exc:
2004
+ for line in exc.lines:
2005
+ typer.echo(line)
2006
+ raise typer.Exit(code=1)
2007
+
2008
+
2009
+ def _prepare_script_run(client, ref: str, server_ref: str, vault_ref: str | None,
2010
+ key_ref: str | None, activity: "render.RunActivity"):
2011
+ """Look up everything a script run needs and return
2012
+ (script, server, credential, vault_name, content).
2013
+
2014
+ The script, server and vault lists are fetched in parallel; their items already
2015
+ carry what a run needs (params, vault/key links, nested keys), so no detail
2016
+ calls follow. Prints nothing: it runs under the activity line, so failures are
2017
+ raised for the caller to report.
2018
+ """
2019
+ scripts, servers, vaults = runner.fetch_parallel(
2020
+ lambda: resources.list_scripts_full(client),
2021
+ lambda: resources.list_servers_full(client),
2022
+ lambda: resources.list_vaults_full(client),
2023
+ )
2024
+ vaults_by_id = {str(v.get("id")): v for v in vaults}
2025
+ script = resources.pick_script(scripts, ref)
2026
+
2027
+ if vault_ref:
2028
+ vault = resources.pick_vault(vaults, vault_ref)
2029
+ server = resources.find_server_in_vault(vault, server_ref)
2030
+ if not server:
2031
+ raise _RunSetupError(f"✗ No server '{server_ref}' in vault '{vault.get('name')}'.")
2032
+ else:
2033
+ names = {vid: v.get("name") for vid, v in vaults_by_id.items()}
2034
+ server = resources.pick_server(servers, server_ref, names)
2035
+ vault = vaults_by_id.get(str(server.get("vault"))) or {}
2036
+
2037
+ if key_ref:
2038
+ cred = resources.find_credential_in_vault(vault, key_ref)
2039
+ if not cred:
2040
+ raise _RunSetupError(
2041
+ f"✗ No key '{key_ref}' in the server's vault '{vault.get('name')}'.",
2042
+ f" A server's key must live in the same vault. See: sagex show vault \"{vault.get('name')}\"",
2043
+ )
2044
+ elif server.get("credential"):
2045
+ cred = server.get("credential_details") or {"id": server.get("credential")}
2046
+ else:
2047
+ raise _RunSetupError(
2048
+ f"✗ Server '{server.get('name')}' has no key attached.",
2049
+ f" Pass --key <name>, or attach one: sagex update server \"{server.get('name')}\" --key <name>",
2050
+ )
2051
+
2052
+ activity.set("Reading the script")
2053
+ (body,) = runner.fetch_parallel(lambda: resources.get_script_content(client, script["id"]))
2054
+ return script, server, cred, vault.get("name"), (body or {}).get("content") or ""
2055
+
2056
+
2057
+ def _prompt_input(spec: dict) -> str:
2058
+ """Ask for one script input until it fits its type (hidden for secrets).
2059
+ Prompts go to stderr so redirected stdout holds only the script's output."""
2060
+ if spec["description"]:
2061
+ render.run_note(f" {spec['name']}: {spec['description']}")
2062
+ suffix = " (secret)" if spec["secret"] else (f" ({spec['type']})" if spec["type"] != "string" else "")
2063
+ while True:
2064
+ value = typer.prompt(f"{spec['name']}{suffix}", hide_input=spec["secret"],
2065
+ default="", show_default=False, err=True)
2066
+ problem = runner.check_value(spec["type"], value)
2067
+ if not problem:
2068
+ return value
2069
+ typer.echo(f" ✗ {problem}", err=True)
2070
+
2071
+
2072
+ def _collect_inputs(specs: list[dict], raw: list[str]) -> dict:
2073
+ """A value for every script input: --param, else the saved default, else a
2074
+ prompt. Secrets are refused as flags (shell history) and always prompted for."""
2075
+ by_name = {s["name"].lower(): s for s in specs}
2076
+ given: dict[str, str] = {}
2077
+ for item in raw:
2078
+ name, sep, value = item.partition("=")
2079
+ name = name.strip()
2080
+ if not sep or not name:
2081
+ typer.echo(f"✗ --param must look like NAME=VALUE (got '{item}').")
2082
+ raise typer.Exit(code=1)
2083
+ spec = by_name.get(name.lower())
2084
+ if spec is None:
2085
+ known = ", ".join(s["name"] for s in specs) or "none"
2086
+ typer.echo(f"✗ '{name}' isn't an input of this script. Its {{{{NAME}}}} inputs: {known}.")
2087
+ raise typer.Exit(code=1)
2088
+ if spec["secret"]:
2089
+ typer.echo(f"✗ '{spec['name']}' is a secret — leave it off the command line; you'll be asked for it (hidden).")
2090
+ raise typer.Exit(code=1)
2091
+ problem = runner.check_value(spec["type"], value)
2092
+ if problem:
2093
+ typer.echo(f"✗ --param {spec['name']}: {problem} (got '{value}').")
2094
+ raise typer.Exit(code=1)
2095
+ given[spec["name"]] = value
2096
+
2097
+ # Prompts are written to stderr, so both ends must be a terminal. stdin alone
2098
+ # isn't enough: Windows reports a null-device stdin (as the TUI uses) as a TTY.
2099
+ interactive = sys.stdin.isatty() and sys.stderr.isatty()
2100
+ inputs: dict[str, str] = {}
2101
+ for spec in specs:
2102
+ name = spec["name"]
2103
+ if name in given:
2104
+ inputs[name] = given[name]
2105
+ elif spec["default"]:
2106
+ inputs[name] = spec["default"]
2107
+ elif interactive:
2108
+ inputs[name] = _prompt_input(spec)
2109
+ elif spec["secret"]:
2110
+ typer.echo(f"✗ '{name}' is a secret and can only be typed at a hidden prompt — run this in a terminal.")
2111
+ raise typer.Exit(code=1)
2112
+ else:
2113
+ typer.echo(f"✗ No value for '{name}'. Pass --param {name}=VALUE.")
2114
+ raise typer.Exit(code=1)
2115
+ return inputs
2116
+
2117
+
2118
+ def _show_run_frame(frame, state: dict, log: "copier.ScriptRunLog | None") -> bool:
2119
+ """Print one live-run frame, copy it to the local log, and track the run's
2120
+ status; False once the run is done."""
2121
+ event, data = frame
2122
+ if not isinstance(data, dict):
2123
+ data = {"data": data}
2124
+ text = None
2125
+ if event == "stdout":
2126
+ text = str(data.get("data", ""))
2127
+ render.run_stdout(text)
2128
+ elif event == "stderr":
2129
+ text = str(data.get("data", ""))
2130
+ render.run_stderr(text)
2131
+ elif event == "status":
2132
+ state["execution_id"] = data.get("execution_id") or state["execution_id"]
2133
+ state["status"] = data.get("status") or state["status"]
2134
+ if log and state["execution_id"]:
2135
+ log.start(state["execution_id"])
2136
+ elif event == "exit_code":
2137
+ state["exit_code"] = data.get("exit_code")
2138
+ elif event == "error":
2139
+ text = str(data.get("message") or data.get("data") or "Run failed.")
2140
+ render.run_stderr(text)
2141
+ elif event == "done":
2142
+ return False
2143
+ else: # 'log' and anything newer: worker info lines
2144
+ text = str(data.get("data", ""))
2145
+ render.run_note(text)
2146
+ if log and text is not None:
2147
+ log.line(event, text)
2148
+ return True
2149
+
2150
+
2151
+ def _follow_script_run(client, payload: dict, log: "copier.ScriptRunLog | None") -> int:
2152
+ """Stream a script run to the terminal (and the local log); return the CLI exit code."""
2153
+ stream = runner.BackgroundStream(resources.run_script_stream(client, payload))
2154
+ state = {"execution_id": None, "status": None, "exit_code": None}
2155
+ activity = render.RunActivity("Starting the run")
2156
+ stopping = detached = False
2157
+ started = time.monotonic()
2158
+ with render.run_activity(activity):
2159
+ while True:
2160
+ try:
2161
+ frame = stream.get()
2162
+ if frame is None or not _show_run_frame(frame, state, log):
2163
+ break
2164
+ if state["status"] == "running" and not stopping and activity.verb != "Running":
2165
+ activity.set("Running")
2166
+ except KeyboardInterrupt:
2167
+ if stopping or not state["execution_id"]:
2168
+ detached = True
2169
+ break
2170
+ stopping = True
2171
+ activity.set("Stopping", "ctrl+c again to detach")
2172
+ render.run_note("Stop requested — waiting for the run to end.")
2173
+ try:
2174
+ resources.stop_script_execution(client, state["execution_id"])
2175
+ except ApiError as exc:
2176
+ render.run_note(f"Couldn't stop it: {exc.message}")
2177
+ except ApiError as exc:
2178
+ render.run_stderr(f"✗ {exc.message}")
2179
+ if log:
2180
+ log.line("error", exc.message)
2181
+ break
2182
+
2183
+ seconds = time.monotonic() - started
2184
+ status, exit_code, execution_id = state["status"], state["exit_code"], state["execution_id"]
2185
+ if log:
2186
+ log.finish(execution_id=execution_id, status=status, exit_code=exit_code,
2187
+ seconds=seconds, detached=detached)
2188
+ if detached:
2189
+ hint = f" (execution {execution_id})" if execution_id else ""
2190
+ render.run_note(f"Detached — the run may still be going{hint}.")
2191
+ elif execution_id:
2192
+ render.run_summary(status, exit_code, seconds, execution_id)
2193
+ if log and log.folder:
2194
+ render.run_note(f" logs: {_display_path(log.folder)}")
2195
+ elif log and log.error:
2196
+ render.run_note(f" couldn't save logs locally: {log.error}")
2197
+
2198
+ if detached or status == "cancelled":
2199
+ return 130
2200
+ if status == "completed":
2201
+ return 0
2202
+ return exit_code if isinstance(exit_code, int) and exit_code > 0 else 1
2203
+
2204
+
2205
+ def _display_path(path: Path) -> str:
2206
+ """`path` relative to the current directory when it's inside it, else absolute."""
2207
+ try:
2208
+ return str(path.resolve().relative_to(Path.cwd().resolve()))
2209
+ except ValueError:
2210
+ return str(path)
2211
+
2212
+
2213
+ @run_app.command("script")
2214
+ def run_script_cmd(
2215
+ ref: str = typer.Argument(..., help="Script name or id."),
2216
+ server_ref: str = typer.Option(..., "--server", "-s", help="Server (name or id) to run it on."),
2217
+ key: str = typer.Option(None, "--key", "-k", help="Key (name or id) from the server's vault. Defaults to the server's attached key."),
2218
+ vault: str = typer.Option(None, "--vault", help="Scope the server lookup to this vault (name or id) — for a server name shared across vaults."),
2219
+ params: list[str] = typer.Option(None, "--param", "-p", help="A script input as NAME=VALUE (repeatable). Secrets are asked for at a hidden prompt instead."),
2220
+ no_log: bool = typer.Option(False, "--no-log", help="Don't save a copy of the logs under <workspace>/logs/script/<execution_id>/."),
2221
+ ) -> None:
2222
+ """Run a script on a server and stream its output live.
2223
+
2224
+ The script's inputs are its {{NAME}} markers. Each value comes from --param, else the script's saved default, else a prompt. Ctrl+C stops the run (press it again to detach). A copy of the logs is saved under <workspace>/logs/script/<execution_id>/. Exits with the script's exit code.
2225
+ """
2226
+ client = _client_or_exit()
2227
+ activity = render.RunActivity("Looking up the script and server", "ctrl+c to cancel")
2228
+ with _setup_errors(), render.run_activity(activity):
2229
+ script, server, cred, vault_name, content = _prepare_script_run(
2230
+ client, ref, server_ref, vault, key, activity)
2231
+
2232
+ specs = runner.script_params(content, script.get("parameters"))
2233
+ inputs = _collect_inputs(specs, params or [])
2234
+ secret_names = {s["name"] for s in specs if s["secret"]}
2235
+
2236
+ details = [server.get("host"),
2237
+ vault_name and f"vault {vault_name}",
2238
+ cred.get("name") and f"key {cred.get('name')}"]
2239
+ render.run_header(script.get("name") or ref, server.get("name") or server_ref,
2240
+ details, inputs, secret_names)
2241
+
2242
+ log = None
2243
+ if not no_log:
2244
+ log = copier.ScriptRunLog(Path(config.workspace_dir()), {
2245
+ "script": {"id": script.get("id"), "name": script.get("name")},
2246
+ "server": {"id": server.get("id"), "name": server.get("name"), "host": server.get("host")},
2247
+ "vault": vault_name,
2248
+ "key": cred.get("name"),
2249
+ "inputs": {k: ("*****" if k in secret_names else v) for k, v in inputs.items()},
2250
+ })
2251
+ payload = {
2252
+ "script_details": {
2253
+ "script_id": int(script["id"]),
2254
+ "script_name": script.get("name") or "",
2255
+ "pathname": script.get("pathname") or "",
2256
+ },
2257
+ "vault_details": {
2258
+ "vault_id": str(server.get("vault")),
2259
+ "server_id": str(server.get("id")),
2260
+ "credential_id": str(cred.get("id")),
2261
+ },
2262
+ "inputs": inputs,
2263
+ }
2264
+ raise typer.Exit(code=_follow_script_run(client, payload, log))
2265
+
2266
+
2267
+ _RUN_FINISHED = ("success", "failed", "cancelled")
2268
+
2269
+
2270
+ def _emit_run_event(view: "runview.WorkflowRunView", event: dict) -> None:
2271
+ """Apply a run update to the live graph; under the TUI (SAGEX_EVENTS=1) also print
2272
+ it as an event line so the TUI can draw the same graph."""
2273
+ view.apply(event)
2274
+ if os.environ.get("SAGEX_EVENTS") == "1":
2275
+ print(runview.event_line(event), file=sys.stderr, flush=True)
2276
+
2277
+
2278
+ def _collect_workflow_inputs(specs: list[dict], raw: list[str]) -> tuple[dict, dict]:
2279
+ """Return (inputs to send keyed by parameter id, values to show keyed by label).
2280
+
2281
+ --param takes a parameter name, or its id when a name repeats across nodes.
2282
+ Parameters not set keep the value saved in the workflow (and aren't sent);
2283
+ ones with no saved value are prompted for. Secrets are refused as flags, and
2284
+ parameters fed by another node's output can't be set.
2285
+ """
2286
+ manual = [s for s in specs if s["source"] == "manual"]
2287
+ by_id = {s["id"]: s for s in specs}
2288
+ name_count: dict[str, int] = {}
2289
+ for s in manual:
2290
+ name_count[s["name"].lower()] = name_count.get(s["name"].lower(), 0) + 1
2291
+
2292
+ def ref_for(s): # how to address a parameter on the command line
2293
+ return s["name"] if name_count[s["name"].lower()] == 1 else s["id"]
2294
+
2295
+ given: dict[str, str] = {}
2296
+ for item in raw:
2297
+ key, sep, value = item.partition("=")
2298
+ key = key.strip()
2299
+ if not sep or not key:
2300
+ typer.echo(f"✗ --param must look like NAME=VALUE (got '{item}').")
2301
+ raise typer.Exit(code=1)
2302
+ spec = by_id.get(key)
2303
+ if spec is None:
2304
+ hits = [s for s in specs if s["name"].lower() == key.lower()]
2305
+ if not hits:
2306
+ known = ", ".join(sorted({s["name"] for s in manual})) or "none"
2307
+ typer.echo(f"✗ '{key}' isn't a parameter of this workflow. Its parameters: {known}.")
2308
+ raise typer.Exit(code=1)
2309
+ if len(hits) > 1:
2310
+ typer.echo(f"✗ '{key}' names {len(hits)} different parameters — pass its id instead:")
2311
+ for s in hits:
2312
+ typer.echo(f" --param {s['id']}=VALUE ({', '.join(s['nodes'])})")
2313
+ raise typer.Exit(code=1)
2314
+ spec = hits[0]
2315
+ if spec["source"] != "manual":
2316
+ typer.echo(f"✗ '{spec['name']}' takes its value from another node's output; it can't be set here.")
2317
+ raise typer.Exit(code=1)
2318
+ if spec["secret"]:
2319
+ typer.echo(f"✗ '{spec['name']}' is a secret — leave it off the command line; you'll be asked for it (hidden).")
2320
+ raise typer.Exit(code=1)
2321
+ problem = runner.check_value(spec["type"], value)
2322
+ if problem:
2323
+ typer.echo(f"✗ --param {spec['name']}: {problem} (got '{value}').")
2324
+ raise typer.Exit(code=1)
2325
+ given[spec["id"]] = value
2326
+
2327
+ interactive = sys.stdin.isatty() and sys.stderr.isatty()
2328
+ inputs: dict[str, str] = {}
2329
+ shown: dict[str, str] = {}
2330
+ for s in manual:
2331
+ if s["id"] in given:
2332
+ value = inputs[s["id"]] = given[s["id"]]
2333
+ elif s["default"]:
2334
+ value = s["default"] # saved in the workflow; not sent
2335
+ elif interactive:
2336
+ value = inputs[s["id"]] = _prompt_input(s)
2337
+ elif s["secret"]:
2338
+ typer.echo(f"✗ '{s['name']}' is a secret and can only be typed at a hidden prompt — run this in a terminal.")
2339
+ raise typer.Exit(code=1)
2340
+ else:
2341
+ typer.echo(f"✗ No value for '{s['name']}'. Pass --param {ref_for(s)}=VALUE.")
2342
+ raise typer.Exit(code=1)
2343
+ label = s["name"] if name_count[s["name"].lower()] == 1 else f"{s['name']} ({s['nodes'][0]})"
2344
+ shown[label] = "*****" if s["secret"] else value
2345
+ return inputs, shown
2346
+
2347
+
2348
+ def _show_workflow_frame(frame, state: dict, view: "runview.WorkflowRunView") -> bool:
2349
+ """Apply one live-run frame to the graph, print its output to the scrolling log,
2350
+ and track the run; False once it reports 'done'."""
2351
+ event, data = frame
2352
+ if not isinstance(data, dict):
2353
+ data = {"data": data}
2354
+ if event == "status":
2355
+ state["status"] = data.get("status") or state["status"]
2356
+ if data.get("error_message"):
2357
+ state["error"] = data["error_message"]
2358
+ _emit_run_event(view, {"t": "run", "status": state["status"]})
2359
+ elif event == "node_start":
2360
+ node_id = data.get("node_id")
2361
+ label = data.get("node_label") or view.label(node_id)
2362
+ state["current"] = label
2363
+ if state["status"] == "queued":
2364
+ state["status"] = "running" # the 'running' status may have streamed early
2365
+ _emit_run_event(view, {"t": "node", "id": node_id, "status": "running"})
2366
+ # The trigger finishes instantly and usually streams before we connect, so
2367
+ # it gets no log lines either way (the graph still shows it done).
2368
+ if view.kind(node_id) != "trigger":
2369
+ render.workflow_node_start(label, view.kind(node_id))
2370
+ elif event == "node_complete":
2371
+ node_id = data.get("node_id")
2372
+ branch = data.get("taken_branch") if "decision_result" in data else None
2373
+ _emit_run_event(view, {"t": "node", "id": node_id, "status": data.get("status"),
2374
+ "exit_code": data.get("exit_code"), "duration": data.get("duration"),
2375
+ "branch": branch})
2376
+ if view.kind(node_id) != "trigger":
2377
+ render.workflow_node_done(data.get("node_label") or view.label(node_id), data.get("status"),
2378
+ data.get("exit_code"), data.get("duration"), branch)
2379
+ elif event == "log":
2380
+ kind, text = runner.classify_log_line(str(data.get("stdout") or data.get("data") or ""))
2381
+ {"stdout": render.run_stdout, "stderr": render.run_stderr, "note": render.run_note}[kind](text)
2382
+ elif event == "stdout":
2383
+ render.run_stdout(str(data.get("data", "")))
2384
+ elif event == "stderr":
2385
+ render.run_stderr(str(data.get("data", "")))
2386
+ elif event == "error":
2387
+ render.run_stderr(str(data.get("message") or data.get("data") or "Stream error."))
2388
+ elif event == "done":
2389
+ if data.get("status") in _RUN_FINISHED:
2390
+ state["status"] = data["status"]
2391
+ _emit_run_event(view, {"t": "run", "status": state["status"]})
2392
+ return False
2393
+ return True
2394
+
2395
+
2396
+ def _pump_workflow_stream(client, run_id: str, state: dict, view: "runview.WorkflowRunView",
2397
+ activity: "render.RunActivity") -> bool:
2398
+ """Show one stream connection's frames; True if it ended with the run finished.
2399
+
2400
+ The connection lives in state["stream"] so a Ctrl+C (whose cancel may fail)
2401
+ resumes the same stream instead of opening a second one and losing frames.
2402
+ """
2403
+ if state.get("stream") is None:
2404
+ state["stream"] = runner.BackgroundStream(resources.stream_workflow_run(client, run_id))
2405
+ while True:
2406
+ try:
2407
+ frame = state["stream"].get()
2408
+ except ApiError:
2409
+ state["stream"] = None
2410
+ raise
2411
+ if frame is None:
2412
+ state["stream"] = None
2413
+ return False # ended early (dropped / 30-min cap)
2414
+ state["errors"] = 0
2415
+ if not _show_workflow_frame(frame, state, view):
2416
+ state["stream"] = None
2417
+ return state["status"] in _RUN_FINISHED
2418
+ if state["status"] == "running":
2419
+ activity.set(f"Running {state['current']}" if state["current"] else "Running")
2420
+
2421
+
2422
+ def _iso_seconds(start: str | None, end: str | None) -> float | None:
2423
+ try:
2424
+ return (datetime.fromisoformat(end) - datetime.fromisoformat(start)).total_seconds()
2425
+ except (TypeError, ValueError):
2426
+ return None
2427
+
2428
+
2429
+ def _follow_workflow_run(client, run_id: str, view: "runview.WorkflowRunView",
2430
+ workspace: Path | None) -> int:
2431
+ """Follow a workflow run live until it ends; return the CLI exit code.
2432
+
2433
+ On a terminal, the run's graph stays pinned at the bottom and updates as nodes
2434
+ start and finish, while their output scrolls above it. The stream is live-only,
2435
+ so it reconnects if it ends early, and the run's node records are applied at the
2436
+ end (covering anything that streamed before we connected). Ctrl+C cancels the
2437
+ run; a second Ctrl+C detaches.
2438
+ """
2439
+ state = {"status": "queued", "error": "", "current": "", "errors": 0, "stream": None}
2440
+ activity = render.RunActivity("Queued — waiting for a worker", "ctrl+c to cancel")
2441
+ started = time.monotonic()
2442
+ cancel_tried = detached = lost = False
2443
+ with render.run_activity(activity, above=view):
2444
+ while True:
2445
+ try:
2446
+ if _pump_workflow_stream(client, run_id, state, view, activity):
2447
+ break
2448
+ run = resources.get_workflow_run(client, run_id) # did it finish meanwhile?
2449
+ if run.get("status") in _RUN_FINISHED:
2450
+ state["status"], state["error"] = run["status"], run.get("error_message") or ""
2451
+ break
2452
+ render.run_note("Reconnecting to the live stream…")
2453
+ time.sleep(1)
2454
+ except KeyboardInterrupt:
2455
+ if cancel_tried:
2456
+ detached = True
2457
+ break
2458
+ cancel_tried = True
2459
+ activity.set("Cancelling", "ctrl+c again to detach")
2460
+ try:
2461
+ resources.cancel_workflow_run(client, run_id)
2462
+ state["status"] = "cancelled"
2463
+ break
2464
+ except KeyboardInterrupt:
2465
+ detached = True
2466
+ break
2467
+ except ApiError as exc:
2468
+ render.run_note(f"Couldn't cancel: {exc.message}")
2469
+ activity.set("Running", "ctrl+c again to detach")
2470
+ except ApiError as exc:
2471
+ state["errors"] += 1
2472
+ render.run_stderr(f"✗ {exc.message}")
2473
+ if state["errors"] >= 3:
2474
+ lost = True
2475
+ break
2476
+
2477
+ seconds = time.monotonic() - started
2478
+ if detached or lost:
2479
+ why = "Detached — the run is still going" if detached else "Lost the live stream"
2480
+ render.run_note(f"{why} (run {run_id}). Check it later: sagex show run {run_id}")
2481
+ return 130 if detached else 1
2482
+
2483
+ # Apply the run's node records (the authoritative final state), show the final
2484
+ # graph, then save the logs from the server's copies.
2485
+ run, nodes = {"id": run_id, "status": state["status"]}, None
2486
+ try:
2487
+ with render.run_activity(render.RunActivity("Collecting the results", "")):
2488
+ run, nodes = runner.fetch_parallel(
2489
+ lambda: resources.get_workflow_run(client, run_id),
2490
+ lambda: resources.get_run_nodes(client, run_id),
2491
+ )
2492
+ except (ApiError, KeyboardInterrupt) as exc:
2493
+ render.run_note(f"Couldn't fetch the run's final results: {getattr(exc, 'message', 'interrupted')}")
2494
+
2495
+ for n in nodes or []:
2496
+ if n.get("status"):
2497
+ _emit_run_event(view, {"t": "node", "id": n.get("node_id"), "status": n["status"],
2498
+ "exit_code": n.get("exit_code"),
2499
+ "duration": _iso_seconds(n.get("started_at"), n.get("finished_at"))})
2500
+ status = run.get("status") or state["status"]
2501
+ _emit_run_event(view, {"t": "run", "status": status})
2502
+ if os.environ.get("SAGEX_EVENTS") != "1": # the TUI draws its own final graph
2503
+ render.workflow_run_graph(view)
2504
+
2505
+ if status == "failed" and (run.get("error_message") or state["error"]):
2506
+ render.run_stderr(run.get("error_message") or state["error"])
2507
+ total = _iso_seconds(run.get("started_at"), run.get("finished_at")) or seconds
2508
+ render.run_summary(status, None, total, run_id, noun="run")
2509
+
2510
+ if workspace is not None and nodes is not None:
2511
+ try:
2512
+ with render.run_activity(render.RunActivity("Saving the logs", "")):
2513
+ folder = copier.save_workflow_run(client, workspace, run, nodes)
2514
+ render.run_note(f" logs: {_display_path(folder)}")
2515
+ except (OSError, ApiError, KeyboardInterrupt) as exc:
2516
+ render.run_note(f" couldn't save logs locally: {getattr(exc, 'message', exc)}")
2517
+
2518
+ if status == "success":
2519
+ return 0
2520
+ return 130 if status == "cancelled" else 1
2521
+
2522
+
2523
+ @run_app.command("workflow")
2524
+ def run_workflow_cmd(
2525
+ ref: str = typer.Argument(..., help="Workflow name or id."),
2526
+ params: list[str] = typer.Option(None, "--param", "-p", help="Set a parameter as NAME=VALUE (or PARAM_ID=VALUE when a name repeats across nodes). Repeatable; secrets are asked for at a hidden prompt."),
2527
+ notify: str = typer.Option(None, "--notify", help="Email this address when the run finishes."),
2528
+ no_log: bool = typer.Option(False, "--no-log", help="Don't save the run's logs under <workspace>/logs/workflow/<run_id>/."),
2529
+ ) -> None:
2530
+ """Run a workflow now (manual trigger) and follow it live, node by node.
2531
+
2532
+ Parameters keep the values saved in the workflow unless set with --param; any without a value are asked for. Ctrl+C cancels the run (press it again to detach). The logs are saved under <workspace>/logs/workflow/<run_id>/ when it ends.
2533
+ """
2534
+ if notify is not None and "@" not in notify:
2535
+ typer.echo("✗ --notify needs an email address.")
2536
+ raise typer.Exit(code=1)
2537
+
2538
+ client = _client_or_exit()
2539
+ with _setup_errors(), render.run_activity(render.RunActivity("Looking up the workflow", "ctrl+c to cancel")):
2540
+ wf = resources.resolve_workflow(client, ref)
2541
+
2542
+ specs = runner.workflow_params(wf.get("nodes"))
2543
+ inputs, shown = _collect_workflow_inputs(specs, params or [])
2544
+ payload = {"inputs": inputs, "send_email": bool(notify), "user_email": notify or ""}
2545
+
2546
+ try:
2547
+ with render.run_activity(render.RunActivity("Starting the run", "")):
2548
+ queued = resources.trigger_workflow_run(client, wf["id"], payload) or {}
2549
+ except KeyboardInterrupt:
2550
+ typer.echo("Interrupted — the run may already be queued. Check: sagex list run", err=True)
2551
+ raise typer.Exit(code=130)
2552
+ except ApiError as exc:
2553
+ typer.echo(f"✗ {exc.message}")
2554
+ raise typer.Exit(code=1)
2555
+
2556
+ run_id = str(queued.get("workflow_run_id") or "")
2557
+ if not run_id:
2558
+ typer.echo("✗ The server didn't return a run id.")
2559
+ raise typer.Exit(code=1)
2560
+
2561
+ raise typer.Exit(code=_watch_new_run(client, wf, run_id, shown, no_log))
2562
+
2563
+
2564
+ def _watch_new_run(client, wf: dict, run_id: str, shown: dict, no_log: bool,
2565
+ via: str | None = None) -> int:
2566
+ """Print the run header, then follow the run live; returns the CLI exit code."""
2567
+ view = runview.WorkflowRunView.from_workflow(wf)
2568
+ render.workflow_run_header(wf.get("name") or "(workflow)", len(view.order), shown, via)
2569
+ if os.environ.get("SAGEX_EVENTS") == "1":
2570
+ print(runview.event_line({"t": "start", "run_id": run_id, "view": view.snapshot()}),
2571
+ file=sys.stderr, flush=True)
2572
+ workspace = None if no_log else Path(config.workspace_dir())
2573
+ return _follow_workflow_run(client, run_id, view, workspace)
2574
+
2575
+
2576
+ def _parse_iso(iso: str | None) -> datetime | None:
2577
+ try:
2578
+ dt = datetime.fromisoformat(iso)
2579
+ except (TypeError, ValueError):
2580
+ return None
2581
+ return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
2582
+
2583
+
2584
+ @trigger_app.command("watch")
2585
+ def trigger_watch_cmd(
2586
+ ref: str = typer.Argument(..., help="Workflow name, or schedule trigger id."),
2587
+ no_log: bool = typer.Option(False, "--no-log", help="Don't save the run's logs under <workspace>/logs/workflow/<run_id>/."),
2588
+ ) -> None:
2589
+ """Wait for a schedule's next run, then follow it live.
2590
+
2591
+ Counts down to the next firing (times come from the server, using the scheduler's own rules), then follows the run with the live graph. If a firing passes without a run (e.g. the previous run was still going) it says why and keeps waiting. Ctrl+C stops watching.
2592
+ """
2593
+ client = _client_or_exit()
2594
+ with _setup_errors(), render.run_activity(render.RunActivity("Looking up the schedule", "ctrl+c to cancel")):
2595
+ kind, trig = resources.resolve_trigger(client, ref)
2596
+ if kind != "schedule":
2597
+ raise _RunSetupError(
2598
+ "✗ That's an HTTP trigger — it runs when it's called, not on a schedule.",
2599
+ f" Call it now: sagex trigger fire \"{trig.get('workflow_name')}\"",
2600
+ )
2601
+ wf = resources.get_workflow_detail(client, trig.get("workflow_id"))
2602
+
2603
+ name = wf.get("name") or trig.get("workflow_name")
2604
+ if not trig.get("is_active", True):
2605
+ typer.echo(f"✗ This schedule is disabled. Enable it first: sagex trigger enable \"{name}\"")
2606
+ raise typer.Exit(code=1)
2607
+
2608
+ baseline = trig.get("last_run_id")
2609
+ upcoming = [t for t in map(_parse_iso, trig.get("next_runs") or []) if t]
2610
+ next_at = upcoming[0] if upcoming else None # None: an older server without next_runs
2611
+ activity = render.RunActivity("Waiting for the next run", "ctrl+c to stop watching")
2612
+ run_id, errors, last_poll = None, 0, time.monotonic()
2613
+ try:
2614
+ with render.run_activity(activity):
2615
+ while run_id is None:
2616
+ now = datetime.now(timezone.utc)
2617
+ remaining = (next_at - now).total_seconds() if next_at else None
2618
+ if next_at:
2619
+ clock = next_at.astimezone().strftime("%H:%M")
2620
+ activity.set(f"Next run {render.time_until(remaining)} (at {clock})" if remaining > 0
2621
+ else "Waiting for the scheduled run to start")
2622
+ # Poll gently while it's far off, briskly around the firing time.
2623
+ interval = 3 if remaining is None or remaining <= 10 else 15
2624
+ if time.monotonic() - last_poll >= interval:
2625
+ last_poll = time.monotonic()
2626
+ try:
2627
+ detail = resources.get_schedule_trigger(client, trig.get("workflow_id"), trig.get("node_id"))
2628
+ errors = 0
2629
+ except ApiError as exc:
2630
+ detail = None
2631
+ errors += 1
2632
+ render.run_stderr(f"✗ {exc.message}")
2633
+ if errors >= 3:
2634
+ raise typer.Exit(code=1)
2635
+ if detail and detail.get("last_run_id") and detail.get("last_run_id") != baseline:
2636
+ run_id = str(detail["last_run_id"])
2637
+ break
2638
+ fresh = [t for t in map(_parse_iso, (detail or {}).get("next_runs") or []) if t]
2639
+ # A firing that passed (with a minute's grace) without starting a run.
2640
+ if next_at and remaining < -60 and fresh and fresh[0] > next_at:
2641
+ reason = detail.get("last_error") or "no run started — is the scheduler (Celery Beat) running?"
2642
+ render.run_note(f"The {next_at.astimezone().strftime('%H:%M')} run didn't start: {reason}")
2643
+ next_at = fresh[0]
2644
+ elif next_at is None and fresh:
2645
+ next_at = fresh[0]
2646
+ time.sleep(1)
2647
+ except KeyboardInterrupt:
2648
+ render.run_note("Stopped watching.")
2649
+ raise typer.Exit(code=130)
2650
+
2651
+ raise typer.Exit(code=_watch_new_run(client, wf, run_id, {}, no_log, via="via schedule"))
2652
+
2653
+
2654
+ def _fire_schedule_now(client, trig: dict, wf: dict, no_follow: bool, no_log: bool) -> int:
2655
+ """Fire a schedule now (scheduler path) and follow the run; returns the exit code."""
2656
+ name = wf.get("name") or trig.get("workflow_name")
2657
+ if not trig.get("is_active", True):
2658
+ typer.echo(f"✗ This schedule is disabled. Enable it first: sagex trigger enable \"{name}\"")
2659
+ return 1
2660
+ try:
2661
+ with render.run_activity(render.RunActivity("Firing the schedule", "")):
2662
+ queued = resources.fire_schedule_now(client, trig.get("workflow_id"), trig.get("node_id")) or {}
2663
+ except KeyboardInterrupt:
2664
+ typer.echo("Interrupted — the run may already be queued. Check: sagex list run", err=True)
2665
+ return 130
2666
+ except ApiError as exc:
2667
+ typer.echo(f"✗ {exc.message}")
2668
+ if exc.status == 404 and exc.message.startswith("Request failed"):
2669
+ typer.echo(" Your server may predate firing schedules on demand — update it.")
2670
+ return 1
2671
+
2672
+ run_id = str(queued.get("workflow_run_id") or "")
2673
+ if not run_id:
2674
+ typer.echo("✗ The server didn't return a run id.")
2675
+ return 1
2676
+ if no_follow:
2677
+ typer.echo(f"✓ Queued scheduled run {run_id}.")
2678
+ typer.echo(f" Check it: sagex show run {run_id}")
2679
+ return 0
2680
+ return _watch_new_run(client, wf, run_id, {}, no_log, via="via schedule · fired now")
2681
+
2682
+
2683
+ def _read_trigger_secret(secret_file: str | None) -> str:
2684
+ """The HTTP trigger secret: from --secret-file, else a hidden prompt. Never a flag
2685
+ (shell history / process list) and never stored."""
2686
+ if secret_file:
2687
+ secret = _read_secret_file(secret_file).strip()
2688
+ elif sys.stdin.isatty() and sys.stderr.isatty():
2689
+ secret = typer.prompt("Trigger secret", hide_input=True, err=True).strip()
2690
+ else:
2691
+ typer.echo("✗ The trigger secret is typed at a hidden prompt — run this in a terminal, or pass --secret-file <path>.")
2692
+ raise typer.Exit(code=1)
2693
+ if not secret:
2694
+ typer.echo("✗ The trigger secret is empty.")
2695
+ raise typer.Exit(code=1)
2696
+ return secret
2697
+
2698
+
2699
+ @trigger_app.command("fire")
2700
+ def trigger_fire_cmd(
2701
+ ref: str = typer.Argument(..., help="Workflow name, or trigger id."),
2702
+ params: list[str] = typer.Option(None, "--param", "-p", help="Set a parameter as NAME=VALUE (or PARAM_ID=VALUE when a name repeats). Repeatable; secrets are asked for at a hidden prompt."),
2703
+ secret_file: str = typer.Option(None, "--secret-file", help="Read the trigger secret from this file instead of a hidden prompt."),
2704
+ idempotency_key: str = typer.Option(None, "--idempotency-key", help="Send this Idempotency-Key (default: a new random one). Reusing a key returns the run it started."),
2705
+ no_follow: bool = typer.Option(False, "--no-follow", help="Just queue the run and print its id and polling URL."),
2706
+ no_log: bool = typer.Option(False, "--no-log", help="Don't save the run's logs under <workspace>/logs/workflow/<run_id>/."),
2707
+ ) -> None:
2708
+ """Fire a trigger now and follow the run live: an HTTP trigger is called exactly as a webhook caller would; a schedule is fired through the scheduler's own path.
2709
+
2710
+ HTTP: sends only the trigger secret and an Idempotency-Key (no API key), so it tests the real webhook path. The secret is typed at a hidden prompt or read from --secret-file, never passed as a flag. Parameters work like `sagex run workflow`. Schedule: runs it now exactly as its cron would (no parameters; skipped if its previous run is still going). Ctrl+C cancels the run (press it again to detach).
2711
+ """
2712
+ client = _client_or_exit()
2713
+ with _setup_errors(), render.run_activity(render.RunActivity("Looking up the trigger", "ctrl+c to cancel")):
2714
+ kind, trig = resources.resolve_trigger(client, ref)
2715
+ wf = resources.get_workflow_detail(client, trig.get("workflow_id"))
2716
+
2717
+ if kind == "schedule":
2718
+ if params or secret_file or idempotency_key:
2719
+ typer.echo("✗ A schedule fires with no parameters, secret or Idempotency-Key — drop those options.")
2720
+ raise typer.Exit(code=1)
2721
+ raise typer.Exit(code=_fire_schedule_now(client, trig, wf, no_follow, no_log))
2722
+
2723
+ if not trig.get("is_active", True):
2724
+ typer.echo(f"✗ This HTTP trigger is disabled. Enable it first: sagex trigger enable \"{wf.get('name')}\"")
2725
+ raise typer.Exit(code=1)
2726
+ path = resources.http_trigger_path(trig)
2727
+ if not path:
2728
+ typer.echo("✗ Couldn't work out this trigger's URL. See: sagex trigger invoke …")
2729
+ raise typer.Exit(code=1)
2730
+
2731
+ specs = runner.workflow_params(wf.get("nodes"))
2732
+ inputs, shown = _collect_workflow_inputs(specs, params or [])
2733
+ secret = _read_trigger_secret(secret_file)
2734
+ key = idempotency_key or str(uuid.uuid4())
2735
+
2736
+ try:
2737
+ with render.run_activity(render.RunActivity("Calling the webhook", "")):
2738
+ queued = resources.fire_http_trigger(client, path, secret, key, inputs) or {}
2739
+ except KeyboardInterrupt:
2740
+ typer.echo(f"Interrupted — the run may already be queued (Idempotency-Key {key}). Check: sagex list run", err=True)
2741
+ raise typer.Exit(code=130)
2742
+ except ApiError as exc:
2743
+ typer.echo(f"✗ {exc.message}")
2744
+ if exc.status == 401:
2745
+ typer.echo(f" The secret ends ••••{trig.get('secret_last4')}. Lost it? Rotate it: sagex trigger regenerate \"{wf.get('name')}\"")
2746
+ raise typer.Exit(code=1)
2747
+
2748
+ run_id = str(queued.get("workflow_run_id") or "")
2749
+ if not run_id:
2750
+ typer.echo("✗ The webhook didn't return a run id.")
2751
+ raise typer.Exit(code=1)
2752
+ if queued.get("idempotent"):
2753
+ render.note("That Idempotency-Key was already used, so no new run was queued — this is the run it started.")
2754
+ if no_follow:
2755
+ what = "Run" if queued.get("idempotent") else "Queued run"
2756
+ typer.echo(f"✓ {what} {run_id} via the HTTP trigger (Idempotency-Key {key}).")
2757
+ if queued.get("polling_url"):
2758
+ typer.echo(f" Polling URL (GET with X-Trigger-Secret): {queued['polling_url']}")
2759
+ typer.echo(f" Poll it from here: sagex trigger poll {run_id} --watch")
2760
+ return
2761
+ raise typer.Exit(code=_watch_new_run(client, wf, run_id, shown, no_log,
2762
+ via=f"via HTTP trigger · Idempotency-Key {key}"))
2763
+
2764
+
2765
+ def _graph_for_run(client, run_id: str) -> dict | None:
2766
+ """The graph shape of a webhook run's workflow (best effort; None if unavailable)."""
2767
+ try:
2768
+ return resources.resolve_http_trigger_run(client, run_id).get("workflow")
2769
+ except (ApiError, resources.AmbiguousResource, AttributeError):
2770
+ return None
2771
+
2772
+
2773
+ def _find_trigger_run(client, target: str, run_ref: str | None) -> dict:
2774
+ """One server lookup for `trigger poll`: the trigger run named by TARGET (+ RUN).
2775
+ Exits with a hint when it can't be found."""
2776
+ try:
2777
+ with render.run_activity(render.RunActivity("Finding the run", "ctrl+c to cancel")):
2778
+ return resources.resolve_http_trigger_run(client, target, run_ref)
2779
+ except KeyboardInterrupt:
2780
+ raise typer.Exit(code=130)
2781
+ except resources.AmbiguousResource as exc:
2782
+ render.ambiguous(exc)
2783
+ raise typer.Exit(code=1)
2784
+ except ApiError as exc:
2785
+ typer.echo(f"✗ {exc.message}")
2786
+ code = exc.errors.get("code") if isinstance(exc.errors, dict) else None
2787
+ hint = {
2788
+ "not_webhook_run": f" See it with: sagex show run {target}",
2789
+ "no_runs_yet": f" Fire it: sagex trigger fire \"{target}\"",
2790
+ "run_not_found": f" Only runs this webhook started can be polled. See it with: sagex show run {run_ref}",
2791
+ "trigger_not_found": " See your triggers: sagex list trigger",
2792
+ "not_found": " See your triggers: sagex list trigger",
2793
+ }.get(code)
2794
+ if hint is None and exc.status == 404:
2795
+ hint = " Your server may predate this lookup — update it, or pass the full polling URL."
2796
+ if hint:
2797
+ typer.echo(hint)
2798
+ raise typer.Exit(code=1)
2799
+
2800
+
2801
+ def _apply_poll(view: "runview.WorkflowRunView", data: dict) -> None:
2802
+ """Apply a polling response (node records + run status) to the graph."""
2803
+ now = datetime.now(timezone.utc).isoformat()
2804
+ for n in data.get("nodes") or []:
2805
+ if n.get("status"):
2806
+ event = {"t": "node", "id": n.get("node_id"), "status": n["status"],
2807
+ "exit_code": n.get("exit_code"),
2808
+ "duration": _iso_seconds(n.get("started_at"), n.get("finished_at"))}
2809
+ if n["status"] == "running":
2810
+ event["elapsed"] = _iso_seconds(n.get("started_at"), now)
2811
+ _emit_run_event(view, event)
2812
+ _emit_run_event(view, {"t": "run", "status": data.get("status")})
2813
+
2814
+
2815
+ @trigger_app.command("poll")
2816
+ def trigger_poll_cmd(
2817
+ target: str = typer.Argument(..., help="A run id (or its first few characters), a trigger id / workflow name (= its latest run), or a polling URL."),
2818
+ run_ref: str = typer.Argument(None, metavar="[RUN]", help="A run id, when TARGET is a trigger or workflow."),
2819
+ secret_file: str = typer.Option(None, "--secret-file", help="Read the trigger secret from this file instead of a hidden prompt."),
2820
+ watch: bool = typer.Option(False, "--watch", "-w", help="Keep polling until the run finishes."),
2821
+ interval: float = typer.Option(3.0, "--interval", help="Seconds between polls with --watch (minimum 2)."),
2822
+ ) -> None:
2823
+ """Check a run through its HTTP trigger's polling URL — exactly what a webhook caller sees.
2824
+
2825
+ Pass a run id (as `trigger fire` prints), a trigger id or workflow name for its latest webhook run, or the full polling URL. Polls with only the trigger secret (no API key), typed at a hidden prompt or read from --secret-file. Shows the run as a graph, once, or live with --watch. Ctrl+C stops watching; the run carries on.
2826
+ """
2827
+ client = _client_or_exit()
2828
+ parsed = resources.http_trigger_poll_path(target)
2829
+ if parsed and run_ref:
2830
+ typer.echo("✗ The polling URL already names the run — drop the extra run id.")
2831
+ raise typer.Exit(code=1)
2832
+ found = None if parsed else _find_trigger_run(client, target, run_ref) # one lookup
2833
+ path, run_id = parsed if parsed else (found["polling_path"], str(found["run"]["id"]))
2834
+
2835
+ secret = _read_trigger_secret(secret_file)
2836
+ try:
2837
+ with render.run_activity(render.RunActivity("Checking the run", "")):
2838
+ if found:
2839
+ (data,) = runner.fetch_parallel(lambda: resources.poll_http_trigger_run(client, path, secret))
2840
+ wf = found.get("workflow")
2841
+ else: # from a URL: fetch the graph shape alongside the poll
2842
+ data, wf = runner.fetch_parallel(
2843
+ lambda: resources.poll_http_trigger_run(client, path, secret),
2844
+ lambda: _graph_for_run(client, run_id),
2845
+ )
2846
+ except KeyboardInterrupt:
2847
+ raise typer.Exit(code=130)
2848
+ except ApiError as exc:
2849
+ typer.echo(f"✗ {exc.message}")
2850
+ if exc.status == 404:
2851
+ typer.echo(f" Only runs this webhook started can be polled. See it with: sagex show run {run_id}")
2852
+ raise typer.Exit(code=1)
2853
+
2854
+ data = data or {}
2855
+ if wf:
2856
+ view = runview.WorkflowRunView.from_workflow(wf)
2857
+ else: # no access to the workflow's shape: show the polled nodes as a flat list
2858
+ records = sorted(data.get("nodes") or [], key=lambda n: n.get("execution_order") or 0)
2859
+ view = runview.WorkflowRunView(f"Run {run_id}", [
2860
+ {"id": n.get("node_id"), "label": n.get("node_label") or n.get("node_id"), "kind": ""}
2861
+ for n in records], [])
2862
+ if os.environ.get("SAGEX_EVENTS") == "1":
2863
+ print(runview.event_line({"t": "start", "run_id": run_id, "view": view.snapshot()}),
2864
+ file=sys.stderr, flush=True)
2865
+ _apply_poll(view, data)
2866
+
2867
+ stopped = False
2868
+ if watch and not view.finished:
2869
+ interval = max(interval, 2.0)
2870
+ activity = render.RunActivity("Polling", f"every {interval:g}s · ctrl+c to stop watching")
2871
+ errors = 0
2872
+ with render.run_activity(activity, above=view):
2873
+ while not view.finished:
2874
+ try:
2875
+ time.sleep(interval)
2876
+ data = resources.poll_http_trigger_run(client, path, secret) or {}
2877
+ errors = 0
2878
+ _apply_poll(view, data)
2879
+ except KeyboardInterrupt:
2880
+ stopped = True
2881
+ break
2882
+ except ApiError as exc:
2883
+ errors += 1
2884
+ render.run_stderr(f"✗ {exc.message}")
2885
+ if errors >= 3:
2886
+ break
2887
+
2888
+ if os.environ.get("SAGEX_EVENTS") != "1": # the TUI draws its own graph
2889
+ render.workflow_run_graph(view)
2890
+ if view.finished:
2891
+ if view.status == "failed" and data.get("error_message"):
2892
+ render.run_stderr(data["error_message"])
2893
+ total = _iso_seconds(data.get("started_at"), data.get("finished_at")) or 0.0
2894
+ render.run_summary(view.status, None, total, run_id, noun="run")
2895
+ else:
2896
+ why = "Stopped watching — the run carries on" if stopped else f"Still {view.status}"
2897
+ hint = "" if watch and not stopped else f" · watch it: sagex trigger poll {run_id} --watch"
2898
+ render.run_note(f"{why} (run {run_id}).{hint}")
2899
+
2900
+ if stopped:
2901
+ raise typer.Exit(code=130)
2902
+ if watch:
2903
+ raise typer.Exit(code=0 if view.status == "success" else 1)
2904
+
2905
+
2906
+ if __name__ == "__main__":
2907
+ app()