hookstride-cli 0.2.0__py3-none-any.whl → 0.3.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.
- hookstride_cli/__init__.py +1 -1
- hookstride_cli/console.py +18 -14
- hookstride_cli/main.py +465 -118
- hookstride_cli-0.3.0.dist-info/METADATA +136 -0
- hookstride_cli-0.3.0.dist-info/RECORD +7 -0
- hookstride_cli-0.2.0.dist-info/METADATA +0 -179
- hookstride_cli-0.2.0.dist-info/RECORD +0 -7
- {hookstride_cli-0.2.0.dist-info → hookstride_cli-0.3.0.dist-info}/WHEEL +0 -0
- {hookstride_cli-0.2.0.dist-info → hookstride_cli-0.3.0.dist-info}/entry_points.txt +0 -0
hookstride_cli/__init__.py
CHANGED
hookstride_cli/console.py
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
"""Interactive
|
|
1
|
+
"""Interactive Hookstride console with allowlisted project and destination writes.
|
|
2
2
|
|
|
3
|
-
The console
|
|
4
|
-
|
|
5
|
-
and stores no history on disk.
|
|
3
|
+
The console never executes shell commands, retains tokens only in memory,
|
|
4
|
+
and stores no command history on disk.
|
|
6
5
|
"""
|
|
7
6
|
|
|
8
7
|
from __future__ import annotations
|
|
@@ -25,11 +24,11 @@ from typer.main import get_command
|
|
|
25
24
|
from . import main as cli
|
|
26
25
|
|
|
27
26
|
# Only these typed commands may execute through the console.
|
|
28
|
-
#
|
|
29
|
-
|
|
27
|
+
# Write actions are intentionally allowlisted, never automatically unlocked.
|
|
28
|
+
ALLOWED_COMMANDS: dict[str, set[str]] = {
|
|
30
29
|
"auth": {"status"},
|
|
31
|
-
"projects": {"list", "use"},
|
|
32
|
-
"destinations": {"list"},
|
|
30
|
+
"projects": {"list", "use", "create", "update"},
|
|
31
|
+
"destinations": {"list", "create", "update"},
|
|
33
32
|
"keys": {"list"},
|
|
34
33
|
"events": {"list", "show"},
|
|
35
34
|
"billing": {"status", "history"},
|
|
@@ -38,7 +37,7 @@ READ_COMMANDS: dict[str, set[str]] = {
|
|
|
38
37
|
}
|
|
39
38
|
ROOT_COMMANDS = {"status", "stats"}
|
|
40
39
|
|
|
41
|
-
# Aliases that feel like a familiar operator console
|
|
40
|
+
# Aliases that feel like a familiar operator console.
|
|
42
41
|
SHOW_TARGETS: dict[str, tuple[str, ...] | None] = {
|
|
43
42
|
"options": None,
|
|
44
43
|
"projects": ("projects", "list"),
|
|
@@ -72,6 +71,8 @@ COMMAND_COMPLETIONS = sorted(
|
|
|
72
71
|
*SAFE_HISTORY_COMMANDS,
|
|
73
72
|
"use ", "projects use ", "events show ", "events list --status ",
|
|
74
73
|
"events list --origin ", "events list --page ", "set project ",
|
|
74
|
+
"projects create --name ", "projects update ",
|
|
75
|
+
"destinations create --name ", "destinations update ",
|
|
75
76
|
"set env ", "json ", "show ", "auth ",
|
|
76
77
|
}
|
|
77
78
|
)
|
|
@@ -194,6 +195,8 @@ class HookstrideConsole:
|
|
|
194
195
|
"[bold]help[/bold] Show console help\n"
|
|
195
196
|
"[bold]show options[/bold] Display environment, project and authentication\n"
|
|
196
197
|
"[bold]show projects[/bold] List accessible projects\n"
|
|
198
|
+
"[bold]projects create --name N --account UUID[/bold] Create a project\n"
|
|
199
|
+
"[bold]projects update UUID --name N[/bold] Rename a project\n"
|
|
197
200
|
"[bold]use UUID[/bold] Select a project (also saves preference)\n"
|
|
198
201
|
"[bold]back[/bold] Leave the active project context\n"
|
|
199
202
|
"[bold]set env dev[/bold] Switch to development API\n"
|
|
@@ -202,6 +205,8 @@ class HookstrideConsole:
|
|
|
202
205
|
"[bold]auth logout[/bold] Clear the current environment's session token\n"
|
|
203
206
|
"[bold]auth status[/bold] Validate current token\n"
|
|
204
207
|
"[bold]show destinations[/bold] Inspect outgoing webhooks\n"
|
|
208
|
+
"[bold]destinations create --name N --url HTTPS_URL[/bold] Add receiver\n"
|
|
209
|
+
"[bold]destinations update UUID --pause[/bold] Pause a receiver\n"
|
|
205
210
|
"[bold]show events[/bold] View recent events\n"
|
|
206
211
|
"[bold]events list --status failed[/bold] Filter delivery history\n"
|
|
207
212
|
"[bold]events show UUID[/bold] Inspect a delivery and attempts\n"
|
|
@@ -223,7 +228,6 @@ class HookstrideConsole:
|
|
|
223
228
|
["API", self.session.origin],
|
|
224
229
|
["Project", project],
|
|
225
230
|
["Authenticated token", "configured" if self._current_token() else "missing"],
|
|
226
|
-
["Mode", "read-only"],
|
|
227
231
|
]
|
|
228
232
|
cli._table(_FakeContext(self.session), "Session options", ["Setting", "Value"], rows)
|
|
229
233
|
|
|
@@ -294,7 +298,7 @@ class HookstrideConsole:
|
|
|
294
298
|
self._write("[green]✓[/green] Signed out for this console environment.")
|
|
295
299
|
|
|
296
300
|
def _execute_cli(self, args: list[str], *, json_output: bool = False) -> None:
|
|
297
|
-
"""Invoke
|
|
301
|
+
"""Invoke explicitly allowed Click/Typer commands, never a shell."""
|
|
298
302
|
if not _allowed_cli(args):
|
|
299
303
|
raise cli.CLIError("Unknown or unavailable command. Type 'help'.")
|
|
300
304
|
group = args[0]
|
|
@@ -382,7 +386,7 @@ class HookstrideConsole:
|
|
|
382
386
|
|
|
383
387
|
def run(self) -> None:
|
|
384
388
|
self._write(
|
|
385
|
-
"\n[bold cyan]HOOKSTRIDE[/bold cyan] [dim]CONSOLE
|
|
389
|
+
f"\n[bold cyan]HOOKSTRIDE[/bold cyan] [dim]CONSOLE v{cli.VERSION}[/dim]\n"
|
|
386
390
|
"[dim]Type 'help' for commands, 'auth login' for a token, "
|
|
387
391
|
"or 'exit' to quit.[/dim]\n"
|
|
388
392
|
)
|
|
@@ -431,8 +435,8 @@ def _allowed_cli(parts: list[str]) -> bool:
|
|
|
431
435
|
return len(parts) == 1
|
|
432
436
|
return (
|
|
433
437
|
len(parts) >= 2
|
|
434
|
-
and parts[0] in
|
|
435
|
-
and parts[1] in
|
|
438
|
+
and parts[0] in ALLOWED_COMMANDS
|
|
439
|
+
and parts[1] in ALLOWED_COMMANDS[parts[0]]
|
|
436
440
|
)
|
|
437
441
|
|
|
438
442
|
|
hookstride_cli/main.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
"""Hookstride CLI:
|
|
1
|
+
"""Hookstride CLI: project-scoped Control API management.
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Commands use Control API tokens. This module never persists a token.
|
|
4
4
|
"""
|
|
5
5
|
|
|
6
6
|
from __future__ import annotations
|
|
@@ -24,20 +24,22 @@ from rich.markup import escape
|
|
|
24
24
|
from rich.panel import Panel
|
|
25
25
|
from rich.text import Text
|
|
26
26
|
|
|
27
|
-
|
|
27
|
+
from . import __version__
|
|
28
|
+
|
|
29
|
+
VERSION = __version__
|
|
28
30
|
ORIGINS = {
|
|
29
|
-
"dev": "https://api-dev.hookstride.
|
|
30
|
-
"production": "https://api.hookstride.
|
|
31
|
+
"dev": "https://api-dev.hookstride.com",
|
|
32
|
+
"production": "https://api.hookstride.com",
|
|
31
33
|
}
|
|
32
34
|
app = typer.Typer(
|
|
33
|
-
help="Hookstride ·
|
|
35
|
+
help="Hookstride · manage projects and webhook destinations.",
|
|
34
36
|
add_completion=False,
|
|
35
37
|
no_args_is_help=False,
|
|
36
38
|
pretty_exceptions_show_locals=False,
|
|
37
39
|
)
|
|
38
40
|
auth_app = typer.Typer(help="Check Control API authentication.")
|
|
39
|
-
projects_app = typer.Typer(help="List and select
|
|
40
|
-
destinations_app = typer.Typer(help="
|
|
41
|
+
projects_app = typer.Typer(help="List, create, update and select workspace projects.")
|
|
42
|
+
destinations_app = typer.Typer(help="List, create and update webhook destinations.")
|
|
41
43
|
events_app = typer.Typer(help="Inspect delivery history and attempts.")
|
|
42
44
|
billing_app = typer.Typer(help="Inspect project-isolated billing.")
|
|
43
45
|
incoming_app = typer.Typer(help="Inspect incoming webhook sources.")
|
|
@@ -59,8 +61,12 @@ class CLIError(Exception):
|
|
|
59
61
|
|
|
60
62
|
class Session:
|
|
61
63
|
def __init__(
|
|
62
|
-
self,
|
|
63
|
-
|
|
64
|
+
self,
|
|
65
|
+
environment: str,
|
|
66
|
+
api_url: str | None,
|
|
67
|
+
project: str | None,
|
|
68
|
+
json_output: bool,
|
|
69
|
+
no_color: bool,
|
|
64
70
|
) -> None:
|
|
65
71
|
if environment not in ORIGINS:
|
|
66
72
|
raise CLIError("Choose --env dev or --env production.")
|
|
@@ -69,7 +75,8 @@ class Session:
|
|
|
69
75
|
self.project = _uuid(project) if project else None
|
|
70
76
|
self.json_output = json_output
|
|
71
77
|
self.console = Console(
|
|
72
|
-
stderr=False,
|
|
78
|
+
stderr=False,
|
|
79
|
+
no_color=no_color or json_output,
|
|
73
80
|
force_terminal=False if json_output else None,
|
|
74
81
|
)
|
|
75
82
|
|
|
@@ -81,10 +88,17 @@ def _origin(value: str) -> str:
|
|
|
81
88
|
port = parts.port
|
|
82
89
|
hostname = parts.hostname
|
|
83
90
|
except ValueError:
|
|
84
|
-
raise CLIError(
|
|
91
|
+
raise CLIError(
|
|
92
|
+
"Invalid API origin. Specify a hostname and valid port."
|
|
93
|
+
) from None
|
|
85
94
|
if (
|
|
86
|
-
not hostname
|
|
87
|
-
or
|
|
95
|
+
not hostname
|
|
96
|
+
or port == 0
|
|
97
|
+
or parts.username
|
|
98
|
+
or parts.password
|
|
99
|
+
or parts.query
|
|
100
|
+
or parts.fragment
|
|
101
|
+
or parts.path not in ("", "/")
|
|
88
102
|
):
|
|
89
103
|
raise CLIError("API URL must be an origin, without path, credentials or query.")
|
|
90
104
|
if parts.scheme != "https" and not (
|
|
@@ -98,13 +112,15 @@ def _uuid(value: str) -> str:
|
|
|
98
112
|
try:
|
|
99
113
|
return str(uuid.UUID(value))
|
|
100
114
|
except (ValueError, AttributeError) as exc:
|
|
101
|
-
raise CLIError("Expected a valid
|
|
115
|
+
raise CLIError("Expected a valid UUID.") from exc
|
|
102
116
|
|
|
103
117
|
|
|
104
118
|
def _config_file() -> Path:
|
|
105
119
|
configured = os.environ.get("HOOKSTRIDE_CONFIG_FILE")
|
|
106
|
-
return
|
|
107
|
-
Path.
|
|
120
|
+
return (
|
|
121
|
+
Path(configured).expanduser()
|
|
122
|
+
if configured
|
|
123
|
+
else (Path.home() / ".config" / "hookstride" / "settings.json")
|
|
108
124
|
)
|
|
109
125
|
|
|
110
126
|
|
|
@@ -156,7 +172,9 @@ def _project(ctx: typer.Context) -> str:
|
|
|
156
172
|
saved = _read_config().get("projects", {})
|
|
157
173
|
selected = saved.get(session.environment) if isinstance(saved, dict) else None
|
|
158
174
|
if not selected:
|
|
159
|
-
raise CLIError(
|
|
175
|
+
raise CLIError(
|
|
176
|
+
"Select a project with 'hookstride projects use UUID' or --project UUID."
|
|
177
|
+
)
|
|
160
178
|
return _uuid(selected)
|
|
161
179
|
|
|
162
180
|
|
|
@@ -171,23 +189,39 @@ def _token() -> str:
|
|
|
171
189
|
|
|
172
190
|
|
|
173
191
|
def _request(
|
|
174
|
-
session: Session,
|
|
175
|
-
|
|
192
|
+
session: Session,
|
|
193
|
+
path: str,
|
|
194
|
+
*,
|
|
195
|
+
authenticate: bool = True,
|
|
196
|
+
params: dict[str, Any] | None = None,
|
|
197
|
+
method: str = "GET",
|
|
198
|
+
json_body: dict[str, Any] | None = None,
|
|
176
199
|
) -> Any:
|
|
200
|
+
if method not in {"GET", "POST", "PATCH"}:
|
|
201
|
+
raise CLIError("Unsupported API request method.")
|
|
177
202
|
headers = {"Accept": "application/json"}
|
|
178
203
|
if authenticate:
|
|
179
204
|
headers["Authorization"] = "Bearer " + _token()
|
|
180
205
|
try:
|
|
181
206
|
with httpx.Client(
|
|
182
|
-
base_url=session.origin,
|
|
207
|
+
base_url=session.origin,
|
|
208
|
+
headers=headers,
|
|
209
|
+
timeout=15,
|
|
183
210
|
follow_redirects=False,
|
|
184
211
|
) as client:
|
|
185
|
-
|
|
212
|
+
if method == "GET":
|
|
213
|
+
response = client.get(path, params=params)
|
|
214
|
+
else:
|
|
215
|
+
response = client.request(method, path, json=json_body)
|
|
186
216
|
except (httpx.HTTPError, httpx.InvalidURL, ValueError):
|
|
187
217
|
# Never print provider errors: transport exceptions may include headers.
|
|
188
|
-
raise CLIError(
|
|
218
|
+
raise CLIError(
|
|
219
|
+
"Unable to reach the Hookstride API. Check the URL and connection."
|
|
220
|
+
) from None
|
|
189
221
|
if response.status_code == 401:
|
|
190
|
-
raise CLIError(
|
|
222
|
+
raise CLIError(
|
|
223
|
+
"Authentication rejected. Check that your control token is valid."
|
|
224
|
+
)
|
|
191
225
|
if response.status_code == 403:
|
|
192
226
|
raise CLIError("Access denied for this account or project (HTTP 403).")
|
|
193
227
|
if response.status_code == 404:
|
|
@@ -244,16 +278,26 @@ def _escaped(value: Any) -> str:
|
|
|
244
278
|
def _colored_status(value: Any) -> Text:
|
|
245
279
|
label = _safe_terminal_text(value or "unknown")
|
|
246
280
|
style = {
|
|
247
|
-
"active": "green",
|
|
248
|
-
"
|
|
249
|
-
"
|
|
281
|
+
"active": "green",
|
|
282
|
+
"delivered": "green",
|
|
283
|
+
"succeeded": "green",
|
|
284
|
+
"success": "green",
|
|
285
|
+
"failed": "red",
|
|
286
|
+
"revoked": "red",
|
|
287
|
+
"abandoned": "yellow",
|
|
288
|
+
"pending": "yellow",
|
|
289
|
+
"delivering": "cyan",
|
|
250
290
|
}.get(label.lower(), "white")
|
|
251
291
|
return Text(label.upper(), style=style)
|
|
252
292
|
|
|
253
293
|
|
|
254
294
|
def _table(
|
|
255
|
-
ctx: typer.Context,
|
|
256
|
-
|
|
295
|
+
ctx: typer.Context,
|
|
296
|
+
title: str,
|
|
297
|
+
columns: list[str],
|
|
298
|
+
rows: list[list[Any]],
|
|
299
|
+
*,
|
|
300
|
+
status_column: int | None = None,
|
|
257
301
|
) -> None:
|
|
258
302
|
"""Render Metasploit-style columns without borders or wrapped identifiers."""
|
|
259
303
|
session = _session(ctx)
|
|
@@ -278,19 +322,18 @@ def _table(
|
|
|
278
322
|
# Keep names, dates, plans and statuses readable. On narrow terminals,
|
|
279
323
|
# trim identifiers/references first instead of wrapping them across lines.
|
|
280
324
|
priority = [
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
325
|
+
(
|
|
326
|
+
100
|
|
327
|
+
if ("id" in header.lower() or "reference" in header.lower())
|
|
328
|
+
else 50 if header.lower() in {"url", "error"} else 0
|
|
329
|
+
)
|
|
284
330
|
for header in columns
|
|
285
331
|
]
|
|
286
332
|
minimums = [
|
|
287
|
-
min(width, 8 if priority[i] == 100 else 4)
|
|
288
|
-
for i, width in enumerate(widths)
|
|
333
|
+
min(width, 8 if priority[i] == 100 else 4) for i, width in enumerate(widths)
|
|
289
334
|
]
|
|
290
335
|
while sum(widths) + gap * (len(widths) - 1) > available:
|
|
291
|
-
candidates = [
|
|
292
|
-
i for i in range(len(widths)) if widths[i] > minimums[i]
|
|
293
|
-
]
|
|
336
|
+
candidates = [i for i in range(len(widths)) if widths[i] > minimums[i]]
|
|
294
337
|
if not candidates:
|
|
295
338
|
break
|
|
296
339
|
index = max(candidates, key=lambda i: (priority[i], widths[i] - minimums[i]))
|
|
@@ -312,7 +355,9 @@ def _table(
|
|
|
312
355
|
return result
|
|
313
356
|
|
|
314
357
|
session.console.print(row_text([Text(col) for col in columns]), highlight=False)
|
|
315
|
-
session.console.print(
|
|
358
|
+
session.console.print(
|
|
359
|
+
row_text([Text("-" * size) for size in widths]), highlight=False
|
|
360
|
+
)
|
|
316
361
|
for values in styled_rows:
|
|
317
362
|
session.console.print(row_text(values), highlight=False)
|
|
318
363
|
|
|
@@ -329,8 +374,13 @@ def _entries(data: Any, label: str) -> list[dict[str, Any]]:
|
|
|
329
374
|
|
|
330
375
|
|
|
331
376
|
def _print_or_table(
|
|
332
|
-
ctx: typer.Context,
|
|
333
|
-
|
|
377
|
+
ctx: typer.Context,
|
|
378
|
+
data: Any,
|
|
379
|
+
title: str,
|
|
380
|
+
columns: list[str],
|
|
381
|
+
rows: list[list[Any]],
|
|
382
|
+
*,
|
|
383
|
+
status_column: int | None = None,
|
|
334
384
|
) -> None:
|
|
335
385
|
if _session(ctx).json_output:
|
|
336
386
|
_print(ctx, data)
|
|
@@ -340,7 +390,7 @@ def _print_or_table(
|
|
|
340
390
|
|
|
341
391
|
@app.command("console")
|
|
342
392
|
def console_entry(ctx: typer.Context) -> None:
|
|
343
|
-
"""Start the interactive
|
|
393
|
+
"""Start the interactive Hookstride operator console."""
|
|
344
394
|
from .console import run_console
|
|
345
395
|
|
|
346
396
|
run_console(_session(ctx))
|
|
@@ -349,12 +399,20 @@ def console_entry(ctx: typer.Context) -> None:
|
|
|
349
399
|
@app.callback(invoke_without_command=True)
|
|
350
400
|
def root(
|
|
351
401
|
ctx: typer.Context,
|
|
352
|
-
env: str = typer.Option("
|
|
353
|
-
api_url: str | None = typer.Option(
|
|
354
|
-
|
|
355
|
-
|
|
402
|
+
env: str = typer.Option("production", "--env", help="production (default) or dev."),
|
|
403
|
+
api_url: str | None = typer.Option(
|
|
404
|
+
None, "--api-url", help="Override the API origin."
|
|
405
|
+
),
|
|
406
|
+
project: str | None = typer.Option(
|
|
407
|
+
None, "--project", help="Project UUID override."
|
|
408
|
+
),
|
|
409
|
+
json_output: bool = typer.Option(
|
|
410
|
+
False, "--json", help="Machine-readable JSON, no styling."
|
|
411
|
+
),
|
|
356
412
|
no_color: bool = typer.Option(False, "--no-color", help="Disable ANSI colors."),
|
|
357
|
-
version: bool = typer.Option(
|
|
413
|
+
version: bool = typer.Option(
|
|
414
|
+
False, "--version", is_eager=True, help="Show CLI version."
|
|
415
|
+
),
|
|
358
416
|
) -> None:
|
|
359
417
|
if version:
|
|
360
418
|
typer.echo(f"hookstride {VERSION}")
|
|
@@ -410,7 +468,9 @@ def status(ctx: typer.Context) -> None:
|
|
|
410
468
|
color = "green" if online else "red"
|
|
411
469
|
label = "Online" if online else "Offline"
|
|
412
470
|
session.console.print(
|
|
413
|
-
Panel.fit(
|
|
471
|
+
Panel.fit(
|
|
472
|
+
f"[{color}]● {label}[/{color}]", title="Hookstride", border_style=color
|
|
473
|
+
)
|
|
414
474
|
)
|
|
415
475
|
if not online:
|
|
416
476
|
raise typer.Exit(code=1)
|
|
@@ -425,12 +485,15 @@ def auth_status(ctx: typer.Context) -> None:
|
|
|
425
485
|
_print(ctx, {"authenticated": True, "email": data.get("user", {}).get("email")})
|
|
426
486
|
return
|
|
427
487
|
user = data.get("user", {})
|
|
428
|
-
_session(ctx).console.print(
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
488
|
+
_session(ctx).console.print(
|
|
489
|
+
Panel.fit(
|
|
490
|
+
f"[green]✓ Authenticated[/green]\n"
|
|
491
|
+
f"Account: {_escaped(user.get('email'))}\n"
|
|
492
|
+
f"Workspace memberships: {len(data.get('memberships', []))}",
|
|
493
|
+
title="Control API",
|
|
494
|
+
border_style="green",
|
|
495
|
+
)
|
|
496
|
+
)
|
|
434
497
|
|
|
435
498
|
|
|
436
499
|
@projects_app.command("list")
|
|
@@ -439,15 +502,27 @@ def projects_list(ctx: typer.Context) -> None:
|
|
|
439
502
|
data = _request(_session(ctx), "/control/v1/projects/")
|
|
440
503
|
items = _entries(data, "projects")
|
|
441
504
|
_print_or_table(
|
|
442
|
-
ctx,
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
505
|
+
ctx,
|
|
506
|
+
data,
|
|
507
|
+
"Projects",
|
|
508
|
+
["Name", "Workspace", "Role", "Plan", "Project ID"],
|
|
509
|
+
[
|
|
510
|
+
[
|
|
511
|
+
p.get("name"),
|
|
512
|
+
p.get("account_name"),
|
|
513
|
+
p.get("role"),
|
|
514
|
+
(p.get("subscription") or {}).get("plan_code"),
|
|
515
|
+
p.get("id"),
|
|
516
|
+
]
|
|
517
|
+
for p in items
|
|
518
|
+
],
|
|
446
519
|
)
|
|
447
520
|
|
|
448
521
|
|
|
449
522
|
@projects_app.command("use")
|
|
450
|
-
def projects_use(
|
|
523
|
+
def projects_use(
|
|
524
|
+
ctx: typer.Context, project_id: str = typer.Argument(..., help="Project UUID.")
|
|
525
|
+
) -> None:
|
|
451
526
|
"""Choose a default project for this environment (only saves its UUID)."""
|
|
452
527
|
session = _session(ctx)
|
|
453
528
|
project_id = _uuid(project_id)
|
|
@@ -466,13 +541,170 @@ def projects_use(ctx: typer.Context, project_id: str = typer.Argument(..., help=
|
|
|
466
541
|
def destinations_list(ctx: typer.Context, page: int = typer.Option(1, min=1)) -> None:
|
|
467
542
|
"""List the selected project's outgoing destinations."""
|
|
468
543
|
project = _project(ctx)
|
|
469
|
-
data = _request(
|
|
544
|
+
data = _request(
|
|
545
|
+
_session(ctx),
|
|
546
|
+
f"/control/v1/projects/{project}/destinations/",
|
|
547
|
+
params={"page": page},
|
|
548
|
+
)
|
|
470
549
|
items = _entries(data, "destinations")
|
|
471
550
|
_print_or_table(
|
|
472
|
-
ctx,
|
|
473
|
-
|
|
474
|
-
|
|
551
|
+
ctx,
|
|
552
|
+
data,
|
|
553
|
+
"Destinations",
|
|
554
|
+
["Name", "URL", "Active", "Destination ID"],
|
|
555
|
+
[
|
|
556
|
+
[
|
|
557
|
+
x.get("name"),
|
|
558
|
+
x.get("url"),
|
|
559
|
+
"yes" if x.get("is_active") else "no",
|
|
560
|
+
x.get("id"),
|
|
561
|
+
]
|
|
562
|
+
for x in items
|
|
563
|
+
],
|
|
564
|
+
)
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def _name(value: str, label: str) -> str:
|
|
568
|
+
value = value.strip()
|
|
569
|
+
if not value or len(value) > 120:
|
|
570
|
+
raise CLIError(f"{label} must be between 1 and 120 characters.")
|
|
571
|
+
return value
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def _webhook_url(value: str) -> str:
|
|
575
|
+
"""Basic local validation; the server also enforces public-URL SSRF policy."""
|
|
576
|
+
if len(value) > 500:
|
|
577
|
+
raise CLIError("Destination URL must not exceed 500 characters.")
|
|
578
|
+
try:
|
|
579
|
+
url = urlsplit(value)
|
|
580
|
+
port = url.port
|
|
581
|
+
hostname = url.hostname
|
|
582
|
+
except ValueError:
|
|
583
|
+
raise CLIError("Invalid destination HTTPS URL.") from None
|
|
584
|
+
if (
|
|
585
|
+
url.scheme != "https"
|
|
586
|
+
or not hostname
|
|
587
|
+
or port == 0
|
|
588
|
+
or url.username
|
|
589
|
+
or url.password
|
|
590
|
+
or url.fragment
|
|
591
|
+
or any(ch.isspace() for ch in value)
|
|
592
|
+
):
|
|
593
|
+
raise CLIError("Destination must have a valid public HTTPS URL without credentials or fragment.")
|
|
594
|
+
return value
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
def _result(ctx: typer.Context, title: str, data: Any, *, secret: str | None = None) -> None:
|
|
598
|
+
if not isinstance(data, dict):
|
|
599
|
+
raise CLIError("Unexpected API response.")
|
|
600
|
+
if _session(ctx).json_output:
|
|
601
|
+
_print(ctx, data)
|
|
602
|
+
return
|
|
603
|
+
session = _session(ctx)
|
|
604
|
+
session.console.print(f"[green]✓[/green] {title}")
|
|
605
|
+
for key in ("id", "name", "slug", "url", "is_active", "account_name"):
|
|
606
|
+
if key in data:
|
|
607
|
+
session.console.print(f" {key.replace('_', ' ').title()}: {_escaped(data[key])}")
|
|
608
|
+
if secret is not None:
|
|
609
|
+
# Shown once and never saved to the CLI config or command history.
|
|
610
|
+
session.console.print("[yellow]Signing secret (save this now; it cannot be retrieved later):[/yellow]")
|
|
611
|
+
session.console.print(_plain(secret), highlight=False)
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
@projects_app.command("create")
|
|
615
|
+
def projects_create(
|
|
616
|
+
ctx: typer.Context,
|
|
617
|
+
name: str = typer.Option(..., "--name", help="New project name."),
|
|
618
|
+
account: str = typer.Option(..., "--account", help="Workspace account UUID from projects list."),
|
|
619
|
+
slug: str | None = typer.Option(None, "--slug", help="Optional project slug."),
|
|
620
|
+
) -> None:
|
|
621
|
+
"""Create a project in a workspace where you are an admin or owner."""
|
|
622
|
+
payload = {"account_id": _uuid(account), "name": _name(name, "Project name")}
|
|
623
|
+
if slug is not None:
|
|
624
|
+
if not slug.strip():
|
|
625
|
+
raise CLIError("Project slug cannot be blank.")
|
|
626
|
+
payload["slug"] = slug.strip()
|
|
627
|
+
data = _request(_session(ctx), "/control/v1/projects/", method="POST", json_body=payload)
|
|
628
|
+
_result(ctx, "Project created", data)
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
@projects_app.command("update")
|
|
632
|
+
def projects_update(
|
|
633
|
+
ctx: typer.Context,
|
|
634
|
+
project_id: str = typer.Argument(..., help="Project UUID."),
|
|
635
|
+
name: str | None = typer.Option(None, "--name", help="New project name."),
|
|
636
|
+
slug: str | None = typer.Option(None, "--slug", help="New project slug."),
|
|
637
|
+
) -> None:
|
|
638
|
+
"""Rename a project or update its slug (admin/owner only)."""
|
|
639
|
+
changes: dict[str, Any] = {}
|
|
640
|
+
if name is not None:
|
|
641
|
+
changes["name"] = _name(name, "Project name")
|
|
642
|
+
if slug is not None:
|
|
643
|
+
if not slug.strip():
|
|
644
|
+
raise CLIError("Project slug cannot be blank.")
|
|
645
|
+
changes["slug"] = slug.strip()
|
|
646
|
+
if not changes:
|
|
647
|
+
raise CLIError("Specify --name and/or --slug.")
|
|
648
|
+
project_id = _uuid(project_id)
|
|
649
|
+
data = _request(
|
|
650
|
+
_session(ctx), f"/control/v1/projects/{project_id}/",
|
|
651
|
+
method="PATCH", json_body=changes,
|
|
652
|
+
)
|
|
653
|
+
_result(ctx, "Project updated", data)
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
@destinations_app.command("create")
|
|
657
|
+
def destinations_create(
|
|
658
|
+
ctx: typer.Context,
|
|
659
|
+
name: str = typer.Option(..., "--name", help="Destination name."),
|
|
660
|
+
url: str = typer.Option(..., "--url", help="Public HTTPS webhook receiver."),
|
|
661
|
+
) -> None:
|
|
662
|
+
"""Create a destination and display its signing secret exactly once."""
|
|
663
|
+
project = _project(ctx)
|
|
664
|
+
data = _request(
|
|
665
|
+
_session(ctx), f"/control/v1/projects/{project}/destinations/",
|
|
666
|
+
method="POST", json_body={"name": _name(name, "Destination name"), "url": _webhook_url(url)},
|
|
667
|
+
)
|
|
668
|
+
if not isinstance(data, dict) or not isinstance(data.get("destination"), dict):
|
|
669
|
+
raise CLIError("Unexpected destination creation response.")
|
|
670
|
+
secret = data.get("signing_secret")
|
|
671
|
+
if not isinstance(secret, str) or not secret:
|
|
672
|
+
raise CLIError("Destination created but the signing secret was not returned. Rotate it in the dashboard.")
|
|
673
|
+
if _session(ctx).json_output:
|
|
674
|
+
_print(ctx, data)
|
|
675
|
+
else:
|
|
676
|
+
_result(ctx, "Destination created", data["destination"], secret=secret)
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
@destinations_app.command("update")
|
|
680
|
+
def destinations_update(
|
|
681
|
+
ctx: typer.Context,
|
|
682
|
+
destination_id: str = typer.Argument(..., help="Destination UUID."),
|
|
683
|
+
name: str | None = typer.Option(None, "--name", help="New destination name."),
|
|
684
|
+
url: str | None = typer.Option(None, "--url", help="New public HTTPS receiver."),
|
|
685
|
+
pause: bool = typer.Option(False, "--pause", help="Pause delivery to this destination."),
|
|
686
|
+
resume: bool = typer.Option(False, "--resume", help="Resume delivery to this destination."),
|
|
687
|
+
) -> None:
|
|
688
|
+
"""Update a destination in the selected project (developer role or above)."""
|
|
689
|
+
if pause and resume:
|
|
690
|
+
raise CLIError("Choose --pause or --resume, not both.")
|
|
691
|
+
changes: dict[str, Any] = {}
|
|
692
|
+
if name is not None:
|
|
693
|
+
changes["name"] = _name(name, "Destination name")
|
|
694
|
+
if url is not None:
|
|
695
|
+
changes["url"] = _webhook_url(url)
|
|
696
|
+
if pause or resume:
|
|
697
|
+
changes["is_active"] = bool(resume)
|
|
698
|
+
if not changes:
|
|
699
|
+
raise CLIError("Specify --name, --url, --pause, or --resume.")
|
|
700
|
+
destination_id = _uuid(destination_id)
|
|
701
|
+
project = _project(ctx)
|
|
702
|
+
data = _request(
|
|
703
|
+
_session(ctx),
|
|
704
|
+
f"/control/v1/projects/{project}/destinations/{destination_id}/",
|
|
705
|
+
method="PATCH", json_body=changes,
|
|
475
706
|
)
|
|
707
|
+
_result(ctx, "Destination updated", data)
|
|
476
708
|
|
|
477
709
|
|
|
478
710
|
@keys_app.command("list")
|
|
@@ -482,12 +714,20 @@ def keys_list(ctx: typer.Context) -> None:
|
|
|
482
714
|
data = _request(_session(ctx), f"/control/v1/projects/{project}/api-keys/")
|
|
483
715
|
items = _entries(data, "API keys")
|
|
484
716
|
_print_or_table(
|
|
485
|
-
ctx,
|
|
717
|
+
ctx,
|
|
718
|
+
data,
|
|
719
|
+
"API keys",
|
|
486
720
|
["Name", "Masked key", "Enabled", "Expires", "Key ID"],
|
|
487
|
-
[
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
721
|
+
[
|
|
722
|
+
[
|
|
723
|
+
key.get("name"),
|
|
724
|
+
key.get("masked_key"),
|
|
725
|
+
"yes" if key.get("is_active") else "no",
|
|
726
|
+
_date(key.get("expires_at")),
|
|
727
|
+
key.get("id"),
|
|
728
|
+
]
|
|
729
|
+
for key in items
|
|
730
|
+
],
|
|
491
731
|
)
|
|
492
732
|
|
|
493
733
|
|
|
@@ -495,9 +735,15 @@ def keys_list(ctx: typer.Context) -> None:
|
|
|
495
735
|
def events_list(
|
|
496
736
|
ctx: typer.Context,
|
|
497
737
|
page: int = typer.Option(1, min=1),
|
|
498
|
-
status_filter: str | None = typer.Option(
|
|
499
|
-
|
|
500
|
-
|
|
738
|
+
status_filter: str | None = typer.Option(
|
|
739
|
+
None, "--status", help="delivered, failed, pending..."
|
|
740
|
+
),
|
|
741
|
+
origin: str | None = typer.Option(
|
|
742
|
+
None, "--origin", help="api, incoming or replay."
|
|
743
|
+
),
|
|
744
|
+
search: str | None = typer.Option(
|
|
745
|
+
None, "--search", help="Event ID, type or destination."
|
|
746
|
+
),
|
|
501
747
|
) -> None:
|
|
502
748
|
"""List recent delivery activity with optional filters."""
|
|
503
749
|
project = _project(ctx)
|
|
@@ -508,12 +754,26 @@ def events_list(
|
|
|
508
754
|
params["origin"] = origin
|
|
509
755
|
if search:
|
|
510
756
|
params["q"] = search
|
|
511
|
-
data = _request(
|
|
757
|
+
data = _request(
|
|
758
|
+
_session(ctx), f"/control/v1/projects/{project}/events/", params=params
|
|
759
|
+
)
|
|
512
760
|
items = _entries(data, "events")
|
|
513
761
|
_print_or_table(
|
|
514
|
-
ctx,
|
|
515
|
-
|
|
516
|
-
|
|
762
|
+
ctx,
|
|
763
|
+
data,
|
|
764
|
+
"Activity",
|
|
765
|
+
["Created", "Event", "Destination", "Status", "Attempts", "Event ID"],
|
|
766
|
+
[
|
|
767
|
+
[
|
|
768
|
+
_date(x.get("created_at")),
|
|
769
|
+
x.get("event_type"),
|
|
770
|
+
x.get("destination_name"),
|
|
771
|
+
x.get("status"),
|
|
772
|
+
x.get("attempt_count"),
|
|
773
|
+
x.get("id"),
|
|
774
|
+
]
|
|
775
|
+
for x in items
|
|
776
|
+
],
|
|
517
777
|
status_column=3,
|
|
518
778
|
)
|
|
519
779
|
|
|
@@ -522,22 +782,37 @@ def events_list(
|
|
|
522
782
|
def events_show(ctx: typer.Context, event_id: str = typer.Argument(...)) -> None:
|
|
523
783
|
"""Inspect an event and its delivery attempts (redacted by the API)."""
|
|
524
784
|
project = _project(ctx)
|
|
525
|
-
data = _request(
|
|
785
|
+
data = _request(
|
|
786
|
+
_session(ctx), f"/control/v1/projects/{project}/events/{_uuid(event_id)}/"
|
|
787
|
+
)
|
|
526
788
|
if _session(ctx).json_output:
|
|
527
789
|
_print(ctx, data)
|
|
528
790
|
return
|
|
529
791
|
session = _session(ctx)
|
|
530
|
-
session.console.print(
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
792
|
+
session.console.print(
|
|
793
|
+
Panel.fit(
|
|
794
|
+
f"[bold]{_escaped(data.get('event_type'))}[/bold]\n"
|
|
795
|
+
f"Status: {_escaped(str(_colored_status(data.get('status'))))}\n"
|
|
796
|
+
f"Destination: {_escaped(data.get('destination_name'))}\n"
|
|
797
|
+
f"Event ID: {_escaped(data.get('id'))}",
|
|
798
|
+
title="Delivery",
|
|
799
|
+
border_style="cyan",
|
|
800
|
+
)
|
|
801
|
+
)
|
|
537
802
|
_table(
|
|
538
|
-
ctx,
|
|
539
|
-
|
|
540
|
-
|
|
803
|
+
ctx,
|
|
804
|
+
"Delivery attempts",
|
|
805
|
+
["Attempt", "HTTP", "Duration (ms)", "Time", "Error"],
|
|
806
|
+
[
|
|
807
|
+
[
|
|
808
|
+
x.get("attempt_number"),
|
|
809
|
+
x.get("status_code"),
|
|
810
|
+
x.get("duration_ms"),
|
|
811
|
+
_date(x.get("created_at")),
|
|
812
|
+
x.get("error"),
|
|
813
|
+
]
|
|
814
|
+
for x in data.get("attempts", [])
|
|
815
|
+
],
|
|
541
816
|
)
|
|
542
817
|
|
|
543
818
|
|
|
@@ -550,14 +825,17 @@ def billing_status(ctx: typer.Context) -> None:
|
|
|
550
825
|
_print(ctx, data)
|
|
551
826
|
return
|
|
552
827
|
plan = data.get("subscription") or {}
|
|
553
|
-
_session(ctx).console.print(
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
828
|
+
_session(ctx).console.print(
|
|
829
|
+
Panel.fit(
|
|
830
|
+
f"Plan: [bold]{_escaped(plan.get('plan_code'))}[/bold]\n"
|
|
831
|
+
f"Status: {_escaped(str(_colored_status(plan.get('status'))))}\n"
|
|
832
|
+
f"Flex credits: {_escaped(plan.get('flex_credits'))}\n"
|
|
833
|
+
f"Current period ends: {_escaped(_date(plan.get('current_period_end')))}\n"
|
|
834
|
+
f"Payment attempts: {len(data.get('records', []))}",
|
|
835
|
+
title="Project Billing",
|
|
836
|
+
border_style="cyan",
|
|
837
|
+
)
|
|
838
|
+
)
|
|
561
839
|
|
|
562
840
|
|
|
563
841
|
@billing_app.command("history")
|
|
@@ -567,11 +845,20 @@ def billing_history(ctx: typer.Context) -> None:
|
|
|
567
845
|
data = _request(_session(ctx), f"/control/v1/projects/{project}/billing/")
|
|
568
846
|
records = data.get("records", [])
|
|
569
847
|
_print_or_table(
|
|
570
|
-
ctx,
|
|
848
|
+
ctx,
|
|
849
|
+
records,
|
|
850
|
+
"Payment history",
|
|
571
851
|
["Date", "Plan", "Amount", "Status", "Reference"],
|
|
572
|
-
[
|
|
573
|
-
|
|
574
|
-
|
|
852
|
+
[
|
|
853
|
+
[
|
|
854
|
+
_date(r.get("created_at")),
|
|
855
|
+
r.get("plan_code"),
|
|
856
|
+
f"{r.get('currency', '')} {int(r.get('amount_minor', 0)) / 100:.2f}",
|
|
857
|
+
r.get("status"),
|
|
858
|
+
r.get("reference"),
|
|
859
|
+
]
|
|
860
|
+
for r in records
|
|
861
|
+
],
|
|
575
862
|
status_column=3,
|
|
576
863
|
)
|
|
577
864
|
|
|
@@ -583,9 +870,20 @@ def incoming_sources(ctx: typer.Context) -> None:
|
|
|
583
870
|
data = _request(_session(ctx), f"/control/v1/projects/{project}/incoming/sources/")
|
|
584
871
|
items = data.get("sources", [])
|
|
585
872
|
_print_or_table(
|
|
586
|
-
ctx,
|
|
587
|
-
|
|
588
|
-
|
|
873
|
+
ctx,
|
|
874
|
+
data,
|
|
875
|
+
"Incoming sources",
|
|
876
|
+
["Name", "Provider", "Routing", "Active", "Source ID"],
|
|
877
|
+
[
|
|
878
|
+
[
|
|
879
|
+
s.get("name"),
|
|
880
|
+
s.get("provider"),
|
|
881
|
+
s.get("routing"),
|
|
882
|
+
"yes" if s.get("is_active") else "no",
|
|
883
|
+
s.get("id"),
|
|
884
|
+
]
|
|
885
|
+
for s in items
|
|
886
|
+
],
|
|
589
887
|
)
|
|
590
888
|
|
|
591
889
|
|
|
@@ -596,11 +894,20 @@ def incoming_routes(ctx: typer.Context) -> None:
|
|
|
596
894
|
data = _request(_session(ctx), f"/control/v1/projects/{project}/incoming/routes/")
|
|
597
895
|
routes = data.get("routes", [])
|
|
598
896
|
_print_or_table(
|
|
599
|
-
ctx,
|
|
897
|
+
ctx,
|
|
898
|
+
data,
|
|
899
|
+
"Incoming routes",
|
|
600
900
|
["Source", "Destination", "Pattern", "Active", "Route ID"],
|
|
601
|
-
[
|
|
602
|
-
|
|
603
|
-
|
|
901
|
+
[
|
|
902
|
+
[
|
|
903
|
+
route.get("source"),
|
|
904
|
+
route.get("endpoint"),
|
|
905
|
+
route.get("event_pattern"),
|
|
906
|
+
"yes" if route.get("is_active") else "no",
|
|
907
|
+
route.get("id"),
|
|
908
|
+
]
|
|
909
|
+
for route in routes
|
|
910
|
+
],
|
|
604
911
|
)
|
|
605
912
|
|
|
606
913
|
|
|
@@ -611,10 +918,19 @@ def incoming_bindings(ctx: typer.Context) -> None:
|
|
|
611
918
|
data = _request(_session(ctx), f"/control/v1/projects/{project}/incoming/bindings/")
|
|
612
919
|
bindings = data.get("bindings", [])
|
|
613
920
|
_print_or_table(
|
|
614
|
-
ctx,
|
|
921
|
+
ctx,
|
|
922
|
+
data,
|
|
923
|
+
"Incoming bindings",
|
|
615
924
|
["Reference", "Source", "Destination", "Binding ID"],
|
|
616
|
-
[
|
|
617
|
-
|
|
925
|
+
[
|
|
926
|
+
[
|
|
927
|
+
item.get("reference"),
|
|
928
|
+
item.get("source"),
|
|
929
|
+
item.get("endpoint"),
|
|
930
|
+
item.get("id"),
|
|
931
|
+
]
|
|
932
|
+
for item in bindings
|
|
933
|
+
],
|
|
618
934
|
)
|
|
619
935
|
|
|
620
936
|
|
|
@@ -627,11 +943,20 @@ def incoming_registration_keys(ctx: typer.Context) -> None:
|
|
|
627
943
|
)
|
|
628
944
|
keys = data.get("keys", [])
|
|
629
945
|
_print_or_table(
|
|
630
|
-
ctx,
|
|
946
|
+
ctx,
|
|
947
|
+
data,
|
|
948
|
+
"Registration keys",
|
|
631
949
|
["Name", "Masked key", "Active", "Expiry", "Key ID"],
|
|
632
|
-
[
|
|
633
|
-
|
|
634
|
-
|
|
950
|
+
[
|
|
951
|
+
[
|
|
952
|
+
item.get("name"),
|
|
953
|
+
item.get("masked_key"),
|
|
954
|
+
"yes" if item.get("is_active") else "no",
|
|
955
|
+
_date(item.get("expires_at")),
|
|
956
|
+
item.get("id"),
|
|
957
|
+
]
|
|
958
|
+
for item in keys
|
|
959
|
+
],
|
|
635
960
|
)
|
|
636
961
|
|
|
637
962
|
|
|
@@ -645,9 +970,18 @@ def delivery_stats(ctx: typer.Context) -> None:
|
|
|
645
970
|
return
|
|
646
971
|
counts = data.get("events", {})
|
|
647
972
|
_table(
|
|
648
|
-
ctx,
|
|
649
|
-
|
|
650
|
-
|
|
973
|
+
ctx,
|
|
974
|
+
"Delivery overview",
|
|
975
|
+
["Total", "Delivered", "Failed", "Pending", "Success"],
|
|
976
|
+
[
|
|
977
|
+
[
|
|
978
|
+
counts.get("total"),
|
|
979
|
+
counts.get("delivered"),
|
|
980
|
+
counts.get("failed"),
|
|
981
|
+
counts.get("pending"),
|
|
982
|
+
f"{counts.get('success_rate', 0)}%",
|
|
983
|
+
]
|
|
984
|
+
],
|
|
651
985
|
)
|
|
652
986
|
|
|
653
987
|
|
|
@@ -656,18 +990,31 @@ def config_show(ctx: typer.Context) -> None:
|
|
|
656
990
|
"""Print the active API/profile settings, never the token value."""
|
|
657
991
|
session = _session(ctx)
|
|
658
992
|
saved = _read_config().get("projects", {})
|
|
659
|
-
selected = session.project or (
|
|
993
|
+
selected = session.project or (
|
|
994
|
+
saved.get(session.environment) if isinstance(saved, dict) else None
|
|
995
|
+
)
|
|
660
996
|
data = {
|
|
661
997
|
"environment": session.environment,
|
|
662
998
|
"api_url": session.origin,
|
|
663
999
|
"project": selected,
|
|
664
|
-
"token_configured": bool(
|
|
665
|
-
|
|
1000
|
+
"token_configured": bool(
|
|
1001
|
+
os.environ.get("HOOKSTRIDE_CONTROL_TOKEN", "").startswith("whrctl_")
|
|
1002
|
+
),
|
|
1003
|
+
"token_source": (
|
|
1004
|
+
"environment"
|
|
1005
|
+
if os.environ.get("HOOKSTRIDE_CONTROL_TOKEN", "").startswith("whrctl_")
|
|
1006
|
+
else None
|
|
1007
|
+
),
|
|
666
1008
|
}
|
|
667
1009
|
if session.json_output:
|
|
668
1010
|
_print(ctx, data)
|
|
669
1011
|
else:
|
|
670
|
-
_table(
|
|
1012
|
+
_table(
|
|
1013
|
+
ctx,
|
|
1014
|
+
"Configuration",
|
|
1015
|
+
["Setting", "Value"],
|
|
1016
|
+
[[k, v] for k, v in data.items()],
|
|
1017
|
+
)
|
|
671
1018
|
|
|
672
1019
|
|
|
673
1020
|
def main() -> None:
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: hookstride-cli
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: Manage Hookstride projects and destinations, inspect webhook activity and billing.
|
|
5
|
+
License: Proprietary
|
|
6
|
+
Requires-Python: >=3.11
|
|
7
|
+
Requires-Dist: click<9,>=8.1
|
|
8
|
+
Requires-Dist: httpx<1,>=0.28
|
|
9
|
+
Requires-Dist: prompt-toolkit<4,>=3.0.52
|
|
10
|
+
Requires-Dist: rich<15,>=14
|
|
11
|
+
Requires-Dist: typer<1,>=0.20
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
14
|
+
# Hookstride CLI
|
|
15
|
+
|
|
16
|
+
Hookstride CLI lets you manage projects and webhook destinations, inspect delivery activity, and view billing records. It includes an interactive console with the same selected-project commands.
|
|
17
|
+
|
|
18
|
+
Version 0.3.0 adds project creation and updates, plus destination creation, updates, and pausing/resuming. Billing and credential changes, delivery replay, and destructive operations remain unavailable.
|
|
19
|
+
|
|
20
|
+
## Installation
|
|
21
|
+
|
|
22
|
+
Requires Python 3.11 or later.
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
uv tool install hookstride-cli
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Alternatively, use `pipx install hookstride-cli`.
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
hookstride --version
|
|
32
|
+
hookstride status
|
|
33
|
+
hookstride console
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Upgrade an existing installation with `uv tool upgrade hookstride-cli`.
|
|
37
|
+
|
|
38
|
+
## Authentication
|
|
39
|
+
|
|
40
|
+
Authenticated commands require a **Control API token** (`whrctl_`), available from your Hookstride dashboard. Project delivery API keys (`whrk_`) do not grant CLI access.
|
|
41
|
+
|
|
42
|
+
In an interactive console, run `auth login` and enter the token at the hidden prompt:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
hookstride console
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
For one-off commands, set `HOOKSTRIDE_CONTROL_TOKEN` in your shell without entering it on the command line:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
read -rs "HOOKSTRIDE_CONTROL_TOKEN?Control token: "
|
|
52
|
+
echo
|
|
53
|
+
export HOOKSTRIDE_CONTROL_TOKEN
|
|
54
|
+
hookstride auth status
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
The `read` example above works in **zsh**. In Bash, use `read -rs -p "Control token: " HOOKSTRIDE_CONTROL_TOKEN` followed by `echo` and `export HOOKSTRIDE_CONTROL_TOKEN`.
|
|
58
|
+
|
|
59
|
+
Tokens entered through `auth login` are retained only for the interactive console session. The CLI does not write tokens to its settings file.
|
|
60
|
+
|
|
61
|
+
## Commands
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
hookstride status
|
|
65
|
+
hookstride auth status
|
|
66
|
+
hookstride projects list
|
|
67
|
+
hookstride projects use PROJECT_UUID
|
|
68
|
+
hookstride projects create --account ACCOUNT_UUID --name "Staging" --slug staging
|
|
69
|
+
hookstride projects update PROJECT_UUID --name "Production EU"
|
|
70
|
+
|
|
71
|
+
hookstride destinations list
|
|
72
|
+
hookstride destinations create --name "Webhook receiver" --url "https://app.example.com/webhooks/hookstride"
|
|
73
|
+
hookstride destinations update DESTINATION_UUID --url "https://api.example.com/webhooks/hookstride"
|
|
74
|
+
hookstride destinations update DESTINATION_UUID --pause
|
|
75
|
+
hookstride destinations update DESTINATION_UUID --resume
|
|
76
|
+
hookstride keys list
|
|
77
|
+
hookstride events list --status failed
|
|
78
|
+
hookstride events show EVENT_UUID
|
|
79
|
+
hookstride stats
|
|
80
|
+
|
|
81
|
+
hookstride incoming sources
|
|
82
|
+
hookstride incoming routes
|
|
83
|
+
hookstride incoming bindings
|
|
84
|
+
hookstride incoming registration-keys
|
|
85
|
+
|
|
86
|
+
hookstride billing status
|
|
87
|
+
hookstride billing history
|
|
88
|
+
hookstride config show
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Replace the example UUIDs with identifiers from your account. Find your workspace's `account_id` with `hookstride --json projects list`; that ID is required for project creation. Project creation and renaming require an admin/owner role. Destination changes require developer access or higher.
|
|
92
|
+
|
|
93
|
+
Commands that operate on a project use the selected project. You can override the selection for an individual command:
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
hookstride --project PROJECT_UUID events list
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
### Interactive console
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
hookstride console
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Use `help` to view commands, Tab for completion, and `exit` or Ctrl+D to leave. The console also supports `show projects`, `show events`, `show destinations`, `use PROJECT_UUID`, `back`, and `set env dev|production`. The same `projects create/update` and `destinations create/update` commands work inside the console. Commands with customer-provided names and URLs are not saved to console history.
|
|
106
|
+
|
|
107
|
+
### JSON output
|
|
108
|
+
|
|
109
|
+
Use `--json` before a command to retrieve structured data for scripts:
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
hookstride --json projects list
|
|
113
|
+
hookstride --project PROJECT_UUID --json events list --status failed
|
|
114
|
+
hookstride --env production --json billing history
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Errors go to standard error; unsuccessful requests return a nonzero exit code. When creating a destination, the server returns a signing secret **once**. Save it securely immediately. Both terminal output and `--json` deliberately include that one-time secret, so avoid piping creation output into logs or public CI artifacts.
|
|
118
|
+
|
|
119
|
+
## Environments and local settings
|
|
120
|
+
|
|
121
|
+
The CLI connects to the production API by default: `https://api.hookstride.com`.
|
|
122
|
+
A new release must be published to PyPI before existing installations receive this change.
|
|
123
|
+
|
|
124
|
+
To inspect the development API explicitly, select it:
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
hookstride --env dev console
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Development requests use `https://api-dev.hookstride.com`. Use a Control API token issued for the selected environment. A custom HTTPS API origin can be supplied with `--api-url`.
|
|
131
|
+
|
|
132
|
+
Project selections are stored separately by environment in `~/.config/hookstride/settings.json`. This file contains project identifiers, **not authentication tokens**. Console tokens and command history remain in memory and are discarded at exit.
|
|
133
|
+
|
|
134
|
+
## Documentation
|
|
135
|
+
|
|
136
|
+
Documentation: https://hookstride.com
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
hookstride_cli/__init__.py,sha256=nu4rJBpNjzDjU4Gx5MN1BdUiMVx6HbsoDoYnSfWzkok,55
|
|
2
|
+
hookstride_cli/console.py,sha256=kUARFRd5XkuXsJWOJ-enAfw_dY4j6ANelZX5B1vQSpM,19126
|
|
3
|
+
hookstride_cli/main.py,sha256=q_L6e_GBD78vLx_JURuMkV9V2Ao07_OW9rKH016zA0E,34582
|
|
4
|
+
hookstride_cli-0.3.0.dist-info/METADATA,sha256=iPHreSMhrsMKt27zUgU9kjFipo2wQdXcHTFQ7TZ7Vu4,5201
|
|
5
|
+
hookstride_cli-0.3.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
6
|
+
hookstride_cli-0.3.0.dist-info/entry_points.txt,sha256=E9bqvnCFVO-lYULID8x-HTp0gYnfrYprB2R8n-DKy6Q,56
|
|
7
|
+
hookstride_cli-0.3.0.dist-info/RECORD,,
|
|
@@ -1,179 +0,0 @@
|
|
|
1
|
-
Metadata-Version: 2.5
|
|
2
|
-
Name: hookstride-cli
|
|
3
|
-
Version: 0.2.0
|
|
4
|
-
Summary: Readable, project-scoped Hookstride Control API terminal client.
|
|
5
|
-
License: Proprietary
|
|
6
|
-
Requires-Python: >=3.11
|
|
7
|
-
Requires-Dist: click<9,>=8.1
|
|
8
|
-
Requires-Dist: httpx<1,>=0.28
|
|
9
|
-
Requires-Dist: prompt-toolkit<4,>=3.0.52
|
|
10
|
-
Requires-Dist: rich<15,>=14
|
|
11
|
-
Requires-Dist: typer<1,>=0.20
|
|
12
|
-
Description-Content-Type: text/markdown
|
|
13
|
-
|
|
14
|
-
# Hookstride CLI
|
|
15
|
-
|
|
16
|
-
A fast, colorful, read-only terminal for the Hookstride Control API. Inspired by the clarity of tools like Nmap, it keeps ordinary REST responses unchanged for apps and integrations.
|
|
17
|
-
|
|
18
|
-
## Install
|
|
19
|
-
|
|
20
|
-
Once the first package release is available on PyPI, install with:
|
|
21
|
-
|
|
22
|
-
```bash
|
|
23
|
-
uv tool install hookstride-cli
|
|
24
|
-
hookstride --version
|
|
25
|
-
hookstride console
|
|
26
|
-
```
|
|
27
|
-
|
|
28
|
-
Alternative: `pipx install hookstride-cli`. Update with `uv tool upgrade hookstride-cli`.
|
|
29
|
-
Requires Python 3.11 or newer. No checkout or Django backend installation is needed.
|
|
30
|
-
|
|
31
|
-
## Authentication
|
|
32
|
-
|
|
33
|
-
In the Hookstride dashboard, generate an MFA-verified **Control API / Management token**, beginning with `whrctl_`. Do **not** use a `whrk_` delivery API key, and never paste a token directly into commands or issue comments.
|
|
34
|
-
|
|
35
|
-
The CLI reads `HOOKSTRIDE_CONTROL_TOKEN` from your current shell; it **does not persist tokens** on disk. For a Bash/Zsh terminal, paste interactively without writing the token in your command history:
|
|
36
|
-
|
|
37
|
-
```bash
|
|
38
|
-
printf "Control token: "
|
|
39
|
-
read -rs HOOKSTRIDE_CONTROL_TOKEN
|
|
40
|
-
printf "\\n"
|
|
41
|
-
export HOOKSTRIDE_CONTROL_TOKEN
|
|
42
|
-
|
|
43
|
-
hookstride auth status
|
|
44
|
-
```
|
|
45
|
-
|
|
46
|
-
The token may remain in your shell environment until you run `unset HOOKSTRIDE_CONTROL_TOKEN`. Environment values may be inspectable by processes with the appropriate local privileges; use an ephemeral, limited-life management token. Avoid recording terminal sessions while entering secrets.
|
|
47
|
-
|
|
48
|
-
## Explore development
|
|
49
|
-
|
|
50
|
-
```bash
|
|
51
|
-
hookstride status
|
|
52
|
-
hookstride auth status
|
|
53
|
-
hookstride projects list
|
|
54
|
-
hookstride projects use 00000000-0000-0000-0000-000000000000
|
|
55
|
-
|
|
56
|
-
hookstride destinations list
|
|
57
|
-
hookstride keys list
|
|
58
|
-
hookstride incoming sources
|
|
59
|
-
hookstride incoming routes
|
|
60
|
-
hookstride incoming bindings
|
|
61
|
-
hookstride incoming registration-keys
|
|
62
|
-
hookstride events list --status failed
|
|
63
|
-
hookstride events show 00000000-0000-0000-0000-000000000000
|
|
64
|
-
hookstride stats
|
|
65
|
-
hookstride billing status
|
|
66
|
-
hookstride billing history
|
|
67
|
-
hookstride config show
|
|
68
|
-
```
|
|
69
|
-
|
|
70
|
-
Replace the sample UUIDs with your own project/event IDs.
|
|
71
|
-
|
|
72
|
-
The CLI starts on `https://api-dev.hookstride.voteeasy.net`. Use `--env production` (before the command) to target `https://api.hookstride.voteeasy.net`; configure each environment with its own corresponding Control token. The apex `https://hookstride.voteeasy.net` serves the frontend. You can also use `--api-url https://your-api-host` for a custom origin, or HTTP on loopback for local testing.
|
|
73
|
-
|
|
74
|
-
**Project selection is environment-specific.** The only persisted data is a project UUID in `~/.config/hookstride/settings.json`, owner-readable; not your credentials. Override temporarily with `--project PROJECT_UUID`.
|
|
75
|
-
|
|
76
|
-
## Development checks
|
|
77
|
-
|
|
78
|
-
When the GitHub Actions runner is unavailable, run the **same** CLI tests locally
|
|
79
|
-
without sending anything to the live Hookstride API:
|
|
80
|
-
|
|
81
|
-
```bash
|
|
82
|
-
bash cli/scripts/check.sh
|
|
83
|
-
```
|
|
84
|
-
|
|
85
|
-
This checks Python syntax, Ruff, isolated unit tests using mocked HTTP responses,
|
|
86
|
-
and basic commands. A successful local run is not a substitute for a live
|
|
87
|
-
read-only smoke test against the deployed development API.
|
|
88
|
-
|
|
89
|
-
Tables use compact, borderless columns. Identifiers and long URLs are shortened
|
|
90
|
-
only when necessary to fit the terminal; the actual API response is not changed.
|
|
91
|
-
Use `--json` to copy complete project IDs, payment references and destination URLs.
|
|
92
|
-
|
|
93
|
-
## Interactive operator console (v0.2)
|
|
94
|
-
|
|
95
|
-
Start a lightweight, Metasploit-inspired, **read-only** interactive console:
|
|
96
|
-
|
|
97
|
-
```bash
|
|
98
|
-
hookstride console
|
|
99
|
-
```
|
|
100
|
-
|
|
101
|
-
The shell starts on the **development API** by default. Enter `help` for
|
|
102
|
-
available commands, use Tab for completion, Ctrl+C to cancel the current line,
|
|
103
|
-
and Ctrl+D or `exit` to close the console.
|
|
104
|
-
|
|
105
|
-
```text
|
|
106
|
-
HOOKSTRIDE CONSOLE v0.2
|
|
107
|
-
hookstride [dev] > auth login
|
|
108
|
-
Control token (hidden):
|
|
109
|
-
✓ Authenticated as developer@example.com
|
|
110
|
-
hookstride [dev] > show projects
|
|
111
|
-
Projects
|
|
112
|
-
========
|
|
113
|
-
Name Workspace Role Plan Project ID
|
|
114
|
-
---------- ------------ ----- ------ ------------------------------------
|
|
115
|
-
Production Beta Testing owner flex ddc21845-d0f1-49af-8d35-2719dc641f69
|
|
116
|
-
|
|
117
|
-
hookstride [dev] > use ddc21845-d0f1-49af-8d35-2719dc641f69
|
|
118
|
-
hookstride [dev:ddc21845] > show payments
|
|
119
|
-
hookstride [dev:ddc21845] > events list --status failed
|
|
120
|
-
hookstride [dev:ddc21845] > show destinations
|
|
121
|
-
hookstride [dev:ddc21845] > json events list
|
|
122
|
-
hookstride [dev:ddc21845] > back
|
|
123
|
-
hookstride [dev] > exit
|
|
124
|
-
```
|
|
125
|
-
|
|
126
|
-
This console accepts the existing read-only CLI commands, plus convenient
|
|
127
|
-
`show` aliases, `set env dev|production`, `use UUID`,
|
|
128
|
-
`back`, `clear`, `history`, and `auth login|logout`.
|
|
129
|
-
`projects use UUID` and `use UUID` persist only the active project's UUID
|
|
130
|
-
in local settings. The console does not support a shell escape, external
|
|
131
|
-
processes, POST/DELETE operations, or purchases.
|
|
132
|
-
|
|
133
|
-
### Token handling
|
|
134
|
-
|
|
135
|
-
`auth login` securely prompts for a Control API token via hidden input. Tokens
|
|
136
|
-
are kept **in memory for this console process only**, are discarded at exit,
|
|
137
|
-
are never written to a config or history file, and are never echoed. The
|
|
138
|
-
console stores only a **safe subset** of commands in volatile arrow-key history;
|
|
139
|
-
it never records tokens or arbitrary query/search strings.
|
|
140
|
-
|
|
141
|
-
You may also provide an existing token through environment variables:
|
|
142
|
-
|
|
143
|
-
```bash
|
|
144
|
-
# Use a development token with the default development console
|
|
145
|
-
export HOOKSTRIDE_DEV_CONTROL_TOKEN="…" # do not put real secrets in shell history
|
|
146
|
-
hookstride console
|
|
147
|
-
|
|
148
|
-
# To start directly against production, explicitly select it
|
|
149
|
-
hookstride --env production console
|
|
150
|
-
```
|
|
151
|
-
|
|
152
|
-
For safest handling, prefer `auth login` over typing tokens in shell commands.
|
|
153
|
-
For interactive environment changes, the generic legacy
|
|
154
|
-
`HOOKSTRIDE_CONTROL_TOKEN` is **never forwarded to another environment**.
|
|
155
|
-
Use `auth login` separately after `set env production`, or provide a
|
|
156
|
-
matching `HOOKSTRIDE_PRODUCTION_CONTROL_TOKEN` token. This prevents a dev
|
|
157
|
-
token from accidentally being sent to the production API.
|
|
158
|
-
|
|
159
|
-
The console requires a real interactive terminal and refuses `--json console`.
|
|
160
|
-
For CI and scripting, use ordinary commands such as
|
|
161
|
-
`hookstride --json events list` instead.
|
|
162
|
-
|
|
163
|
-
## Output for scripts
|
|
164
|
-
|
|
165
|
-
Pass `--json` **before** the command:
|
|
166
|
-
|
|
167
|
-
```bash
|
|
168
|
-
hookstride --json projects list | jq .
|
|
169
|
-
hookstride --json events list --status failed | jq '.results'
|
|
170
|
-
hookstride --env production --json billing history
|
|
171
|
-
```
|
|
172
|
-
|
|
173
|
-
The raw structured response is printed to stdout without terminal formatting. Network and HTTP errors go to stderr and cause a nonzero exit status.
|
|
174
|
-
|
|
175
|
-
## Scope
|
|
176
|
-
|
|
177
|
-
Version 0.2 includes interactive access to project, destination, incoming-source, delivery-stat, activity and billing **reads**. It does not yet create destinations, emit events, replay events, rotate credentials, or trigger payments. Those workflows require additional confirmation and integration tests before exposing them in a CLI. Server-side authorization, project scoping, and response redaction remain enforced by the Control API.
|
|
178
|
-
|
|
179
|
-
Do not store passwords, signing secrets, private keys, or Paystack keys in Hookstride CLI configuration.
|
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
hookstride_cli/__init__.py,sha256=QF0aMDPMekvBntMFgFH6tE4BkHN7vXuw45c6RMu4BOw,55
|
|
2
|
-
hookstride_cli/console.py,sha256=uB4gqFlIwUaajYJ0Q_Mt4KOLImGEVbDFKBaDKJ2zgYU,18701
|
|
3
|
-
hookstride_cli/main.py,sha256=ZS07efEzUQrfTBtvfKjct6YqHiUTRSDmhPhIccQU7_s,26191
|
|
4
|
-
hookstride_cli-0.2.0.dist-info/METADATA,sha256=vC6z5xwcSjhr0loFni9yYJvvGo_rLqwurOssCG9h8Mg,7347
|
|
5
|
-
hookstride_cli-0.2.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
6
|
-
hookstride_cli-0.2.0.dist-info/entry_points.txt,sha256=E9bqvnCFVO-lYULID8x-HTp0gYnfrYprB2R8n-DKy6Q,56
|
|
7
|
-
hookstride_cli-0.2.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|