hookstride-cli 0.2.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.
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
"""Interactive, read-only Hookstride console.
|
|
2
|
+
|
|
3
|
+
The console intentionally does not execute shell commands. It uses the same
|
|
4
|
+
project-scoped Control API commands as the CLI, keeps tokens only in memory,
|
|
5
|
+
and stores no history on disk.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import contextlib
|
|
11
|
+
import getpass
|
|
12
|
+
import os
|
|
13
|
+
import shlex
|
|
14
|
+
import uuid
|
|
15
|
+
import warnings
|
|
16
|
+
from collections.abc import Iterator
|
|
17
|
+
|
|
18
|
+
import click
|
|
19
|
+
from prompt_toolkit import PromptSession
|
|
20
|
+
from prompt_toolkit.completion import WordCompleter
|
|
21
|
+
from prompt_toolkit.formatted_text import FormattedText
|
|
22
|
+
from prompt_toolkit.history import InMemoryHistory
|
|
23
|
+
from typer.main import get_command
|
|
24
|
+
|
|
25
|
+
from . import main as cli
|
|
26
|
+
|
|
27
|
+
# Only these typed commands may execute through the console.
|
|
28
|
+
# Future write actions must be explicitly reviewed, not implicitly unlocked.
|
|
29
|
+
READ_COMMANDS: dict[str, set[str]] = {
|
|
30
|
+
"auth": {"status"},
|
|
31
|
+
"projects": {"list", "use"},
|
|
32
|
+
"destinations": {"list"},
|
|
33
|
+
"keys": {"list"},
|
|
34
|
+
"events": {"list", "show"},
|
|
35
|
+
"billing": {"status", "history"},
|
|
36
|
+
"incoming": {"sources", "routes", "bindings", "registration-keys"},
|
|
37
|
+
"config": {"show"},
|
|
38
|
+
}
|
|
39
|
+
ROOT_COMMANDS = {"status", "stats"}
|
|
40
|
+
|
|
41
|
+
# Aliases that feel like a familiar operator console, but remain read-only.
|
|
42
|
+
SHOW_TARGETS: dict[str, tuple[str, ...] | None] = {
|
|
43
|
+
"options": None,
|
|
44
|
+
"projects": ("projects", "list"),
|
|
45
|
+
"destinations": ("destinations", "list"),
|
|
46
|
+
"keys": ("keys", "list"),
|
|
47
|
+
"events": ("events", "list"),
|
|
48
|
+
"billing": ("billing", "status"),
|
|
49
|
+
"payments": ("billing", "history"),
|
|
50
|
+
"sources": ("incoming", "sources"),
|
|
51
|
+
"routes": ("incoming", "routes"),
|
|
52
|
+
"bindings": ("incoming", "bindings"),
|
|
53
|
+
"registration-keys": ("incoming", "registration-keys"),
|
|
54
|
+
"stats": ("stats",),
|
|
55
|
+
"status": ("status",),
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
SAFE_HISTORY_COMMANDS = frozenset(
|
|
59
|
+
[
|
|
60
|
+
"help", "?", "clear", "exit", "quit", "back", "status", "stats",
|
|
61
|
+
"history", "auth login", "auth logout", "auth status",
|
|
62
|
+
"projects list", "destinations list", "keys list", "billing status",
|
|
63
|
+
"billing history", "events list", "incoming sources",
|
|
64
|
+
"incoming routes", "incoming bindings", "incoming registration-keys",
|
|
65
|
+
"config show",
|
|
66
|
+
*(f"show {name}" for name in SHOW_TARGETS),
|
|
67
|
+
"set env dev", "set env production",
|
|
68
|
+
]
|
|
69
|
+
)
|
|
70
|
+
COMMAND_COMPLETIONS = sorted(
|
|
71
|
+
{
|
|
72
|
+
*SAFE_HISTORY_COMMANDS,
|
|
73
|
+
"use ", "projects use ", "events show ", "events list --status ",
|
|
74
|
+
"events list --origin ", "events list --page ", "set project ",
|
|
75
|
+
"set env ", "json ", "show ", "auth ",
|
|
76
|
+
}
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _project_uuid(value: str) -> str:
|
|
81
|
+
try:
|
|
82
|
+
return str(uuid.UUID(value))
|
|
83
|
+
except (ValueError, AttributeError):
|
|
84
|
+
raise cli.CLIError("Expected a valid project UUID.") from None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _saved_project(environment: str) -> str | None:
|
|
88
|
+
settings = cli._read_config().get("projects", {})
|
|
89
|
+
value = settings.get(environment) if isinstance(settings, dict) else None
|
|
90
|
+
if not value:
|
|
91
|
+
return None
|
|
92
|
+
# An old/corrupt settings file should not crash the console.
|
|
93
|
+
try:
|
|
94
|
+
return _project_uuid(value)
|
|
95
|
+
except cli.CLIError:
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _history_safe(line: str) -> bool:
|
|
100
|
+
"""Only retain recognized non-sensitive commands in process memory."""
|
|
101
|
+
if len(line) > 180 or any(ord(ch) < 32 or ord(ch) > 126 for ch in line):
|
|
102
|
+
return False
|
|
103
|
+
line = line.strip()
|
|
104
|
+
if line in SAFE_HISTORY_COMMANDS:
|
|
105
|
+
return True
|
|
106
|
+
tokens = line.split()
|
|
107
|
+
if len(tokens) == 2 and tokens[0] == "use":
|
|
108
|
+
try:
|
|
109
|
+
_project_uuid(tokens[1])
|
|
110
|
+
return True
|
|
111
|
+
except cli.CLIError:
|
|
112
|
+
return False
|
|
113
|
+
if len(tokens) == 3 and tokens[:2] in (
|
|
114
|
+
["projects", "use"], ["set", "project"], ["events", "show"],
|
|
115
|
+
):
|
|
116
|
+
try:
|
|
117
|
+
_project_uuid(tokens[2])
|
|
118
|
+
return True
|
|
119
|
+
except cli.CLIError:
|
|
120
|
+
return False
|
|
121
|
+
return False
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class SafeMemoryHistory(InMemoryHistory):
|
|
125
|
+
"""Only safe, non-sensitive recognized commands enter arrow-key history."""
|
|
126
|
+
|
|
127
|
+
def append_string(self, string: str) -> None:
|
|
128
|
+
if _history_safe(string):
|
|
129
|
+
super().append_string(string)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class HookstrideConsole:
|
|
133
|
+
def __init__(self, session: cli.Session) -> None:
|
|
134
|
+
self.session = session
|
|
135
|
+
self.start_environment = session.environment
|
|
136
|
+
self.origins = dict(cli.ORIGINS)
|
|
137
|
+
self.origins[session.environment] = session.origin
|
|
138
|
+
self.project = session.project or _saved_project(session.environment)
|
|
139
|
+
self.session.project = self.project
|
|
140
|
+
self.tokens: dict[str, str] = {} # volatile; never persisted or printed
|
|
141
|
+
self.logged_out: set[str] = set()
|
|
142
|
+
self.history = SafeMemoryHistory()
|
|
143
|
+
self.click_command = get_command(cli.app)
|
|
144
|
+
self.completer = WordCompleter(
|
|
145
|
+
COMMAND_COMPLETIONS, ignore_case=True, sentence=True,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
@property
|
|
149
|
+
def environment(self) -> str:
|
|
150
|
+
return self.session.environment
|
|
151
|
+
|
|
152
|
+
def _current_token(self) -> str | None:
|
|
153
|
+
env = self.environment
|
|
154
|
+
if env in self.logged_out:
|
|
155
|
+
return None
|
|
156
|
+
if token := self.tokens.get(env):
|
|
157
|
+
return token
|
|
158
|
+
if token := os.getenv(f"HOOKSTRIDE_{env.upper()}_CONTROL_TOKEN"):
|
|
159
|
+
return token if token.startswith("whrctl_") else None
|
|
160
|
+
# The legacy generic token is bound ONLY to the initial environment.
|
|
161
|
+
# Do not forward a development token to production on "set env".
|
|
162
|
+
if env == self.start_environment:
|
|
163
|
+
token = os.getenv("HOOKSTRIDE_CONTROL_TOKEN", "")
|
|
164
|
+
return token if token.startswith("whrctl_") else None
|
|
165
|
+
return None
|
|
166
|
+
|
|
167
|
+
@contextlib.contextmanager
|
|
168
|
+
def _token_scope(self) -> Iterator[None]:
|
|
169
|
+
"""Temporarily expose just the selected environment's token to the CLI."""
|
|
170
|
+
previous = os.environ.get("HOOKSTRIDE_CONTROL_TOKEN")
|
|
171
|
+
current = self._current_token()
|
|
172
|
+
if current:
|
|
173
|
+
os.environ["HOOKSTRIDE_CONTROL_TOKEN"] = current
|
|
174
|
+
else:
|
|
175
|
+
os.environ.pop("HOOKSTRIDE_CONTROL_TOKEN", None)
|
|
176
|
+
try:
|
|
177
|
+
yield
|
|
178
|
+
finally:
|
|
179
|
+
if previous is None:
|
|
180
|
+
os.environ.pop("HOOKSTRIDE_CONTROL_TOKEN", None)
|
|
181
|
+
else:
|
|
182
|
+
os.environ["HOOKSTRIDE_CONTROL_TOKEN"] = previous
|
|
183
|
+
|
|
184
|
+
def _write(self, message: str) -> None:
|
|
185
|
+
# Message is trusted markup. Never interpolate raw API fields.
|
|
186
|
+
self.session.console.print(message)
|
|
187
|
+
|
|
188
|
+
def _error(self, message: str) -> None:
|
|
189
|
+
self._write(f"[red]✗[/red] {cli._escaped(message)}")
|
|
190
|
+
|
|
191
|
+
def _show_help(self) -> None:
|
|
192
|
+
self._write(
|
|
193
|
+
"\n"
|
|
194
|
+
"[bold]help[/bold] Show console help\n"
|
|
195
|
+
"[bold]show options[/bold] Display environment, project and authentication\n"
|
|
196
|
+
"[bold]show projects[/bold] List accessible projects\n"
|
|
197
|
+
"[bold]use UUID[/bold] Select a project (also saves preference)\n"
|
|
198
|
+
"[bold]back[/bold] Leave the active project context\n"
|
|
199
|
+
"[bold]set env dev[/bold] Switch to development API\n"
|
|
200
|
+
"[bold]set env production[/bold] Switch to production API\n"
|
|
201
|
+
"[bold]auth login[/bold] Enter a management token invisibly\n"
|
|
202
|
+
"[bold]auth logout[/bold] Clear the current environment's session token\n"
|
|
203
|
+
"[bold]auth status[/bold] Validate current token\n"
|
|
204
|
+
"[bold]show destinations[/bold] Inspect outgoing webhooks\n"
|
|
205
|
+
"[bold]show events[/bold] View recent events\n"
|
|
206
|
+
"[bold]events list --status failed[/bold] Filter delivery history\n"
|
|
207
|
+
"[bold]events show UUID[/bold] Inspect a delivery and attempts\n"
|
|
208
|
+
"[bold]show billing[/bold] View plan and credits\n"
|
|
209
|
+
"[bold]show payments[/bold] View payment history\n"
|
|
210
|
+
"[bold]show sources[/bold] Inspect incoming sources\n"
|
|
211
|
+
"[bold]show keys[/bold] List masked keys\n"
|
|
212
|
+
"[bold]json projects list[/bold] Print the complete JSON response\n"
|
|
213
|
+
"[bold]history[/bold] Recall recognized safe commands (memory only)\n"
|
|
214
|
+
"[bold]clear[/bold] Clear screen\n"
|
|
215
|
+
"[bold]exit[/bold] Close this console\n\n"
|
|
216
|
+
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
def _show_options(self) -> None:
|
|
220
|
+
project = self.project or "none"
|
|
221
|
+
rows = [
|
|
222
|
+
["Environment", self.environment],
|
|
223
|
+
["API", self.session.origin],
|
|
224
|
+
["Project", project],
|
|
225
|
+
["Authenticated token", "configured" if self._current_token() else "missing"],
|
|
226
|
+
["Mode", "read-only"],
|
|
227
|
+
]
|
|
228
|
+
cli._table(_FakeContext(self.session), "Session options", ["Setting", "Value"], rows)
|
|
229
|
+
|
|
230
|
+
def _history(self) -> None:
|
|
231
|
+
commands = list(self.history.get_strings())
|
|
232
|
+
if not commands:
|
|
233
|
+
self._write("[dim]No safe commands in history.[/dim]")
|
|
234
|
+
return
|
|
235
|
+
for i, command in enumerate(reversed(commands[-20:]), 1):
|
|
236
|
+
self._write(f"[dim]{i:>3}[/dim] {cli._escaped(command)}")
|
|
237
|
+
|
|
238
|
+
def _set_environment(self, target: str) -> None:
|
|
239
|
+
if target not in cli.ORIGINS:
|
|
240
|
+
raise cli.CLIError("Use 'set env dev' or 'set env production'.")
|
|
241
|
+
self.session.environment = target
|
|
242
|
+
self.session.origin = self.origins[target]
|
|
243
|
+
self.project = _saved_project(target)
|
|
244
|
+
self.session.project = self.project
|
|
245
|
+
self._write(
|
|
246
|
+
f"[green]✓[/green] Environment: [bold]{target}[/bold] "
|
|
247
|
+
f"({_env_hint(self._current_token())})"
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
def _login(self) -> None:
|
|
251
|
+
# getpass does not echo, and is not part of the PromptSession history.
|
|
252
|
+
try:
|
|
253
|
+
# getpass normally degrades to VISIBLE input on unsupported terminals.
|
|
254
|
+
# Treat that warning as an exception before it can read a credential.
|
|
255
|
+
with warnings.catch_warnings():
|
|
256
|
+
warnings.simplefilter("error", getpass.GetPassWarning)
|
|
257
|
+
token = getpass.getpass("Control token (hidden): ").strip()
|
|
258
|
+
except getpass.GetPassWarning:
|
|
259
|
+
raise cli.CLIError(
|
|
260
|
+
"Cannot hide token input on this terminal. Authentication cancelled."
|
|
261
|
+
) from None
|
|
262
|
+
except (KeyboardInterrupt, EOFError):
|
|
263
|
+
raise cli.CLIError("Authentication cancelled.") from None
|
|
264
|
+
if (
|
|
265
|
+
not token.startswith("whrctl_")
|
|
266
|
+
or not 16 <= len(token) <= 512
|
|
267
|
+
or any(ch.isspace() for ch in token)
|
|
268
|
+
):
|
|
269
|
+
raise cli.CLIError("Expected a valid Control API management token.")
|
|
270
|
+
previous = self.tokens.get(self.environment)
|
|
271
|
+
logged_out = self.environment in self.logged_out
|
|
272
|
+
self.tokens[self.environment] = token
|
|
273
|
+
self.logged_out.discard(self.environment)
|
|
274
|
+
try:
|
|
275
|
+
with self._token_scope():
|
|
276
|
+
info = cli._request(self.session, "/control/v1/me/")
|
|
277
|
+
except (cli.CLIError, ValueError, TypeError):
|
|
278
|
+
if previous is None:
|
|
279
|
+
self.tokens.pop(self.environment, None)
|
|
280
|
+
else:
|
|
281
|
+
self.tokens[self.environment] = previous
|
|
282
|
+
if logged_out:
|
|
283
|
+
self.logged_out.add(self.environment)
|
|
284
|
+
raise cli.CLIError("Token verification failed for this environment.") from None
|
|
285
|
+
account = (info.get("user") or {}).get("email") if isinstance(info, dict) else None
|
|
286
|
+
if account:
|
|
287
|
+
self._write(f"[green]✓[/green] Authenticated as {cli._escaped(account)}")
|
|
288
|
+
else:
|
|
289
|
+
self._write("[green]✓[/green] Management token verified.")
|
|
290
|
+
|
|
291
|
+
def _logout(self) -> None:
|
|
292
|
+
self.tokens.pop(self.environment, None)
|
|
293
|
+
self.logged_out.add(self.environment)
|
|
294
|
+
self._write("[green]✓[/green] Signed out for this console environment.")
|
|
295
|
+
|
|
296
|
+
def _execute_cli(self, args: list[str], *, json_output: bool = False) -> None:
|
|
297
|
+
"""Invoke existing read-only Click/Typer commands, never a shell."""
|
|
298
|
+
if not _allowed_cli(args):
|
|
299
|
+
raise cli.CLIError("Unknown or unavailable command. Type 'help'.")
|
|
300
|
+
group = args[0]
|
|
301
|
+
action = args[1] if len(args) > 1 else None
|
|
302
|
+
if _requires_project(group, action) and not self.project:
|
|
303
|
+
raise cli.CLIError("No project selected. Run 'show projects' then 'use UUID'.")
|
|
304
|
+
if _requires_token(group, action) and not self._current_token():
|
|
305
|
+
raise cli.CLIError("No management token for this environment. Run 'auth login'.")
|
|
306
|
+
options = ["--env", self.environment, "--api-url", self.session.origin]
|
|
307
|
+
if self.project:
|
|
308
|
+
options += ["--project", self.project]
|
|
309
|
+
if json_output:
|
|
310
|
+
options.append("--json")
|
|
311
|
+
try:
|
|
312
|
+
with self._token_scope():
|
|
313
|
+
self.click_command.main(
|
|
314
|
+
args=[*options, *args],
|
|
315
|
+
prog_name="hookstride",
|
|
316
|
+
standalone_mode=False,
|
|
317
|
+
)
|
|
318
|
+
except click.exceptions.Exit as exc:
|
|
319
|
+
# 'status' already displayed a red Offline panel. Do not add an
|
|
320
|
+
# unrelated error when returning to the interactive prompt.
|
|
321
|
+
if group == "status" and not json_output and exc.exit_code == 1:
|
|
322
|
+
return
|
|
323
|
+
raise cli.CLIError("Invalid command arguments. Type 'help'.") from None
|
|
324
|
+
except (click.ClickException, click.Abort):
|
|
325
|
+
# Click errors can quote user-provided arguments; don't echo them.
|
|
326
|
+
raise cli.CLIError("Invalid command arguments. Type 'help'.") from None
|
|
327
|
+
if args[:2] == ["projects", "use"]:
|
|
328
|
+
self.project = _project_uuid(args[2])
|
|
329
|
+
self.session.project = self.project
|
|
330
|
+
|
|
331
|
+
def execute_line(self, line: str) -> bool:
|
|
332
|
+
"""Run one input line. False means exit; all other outcomes continue."""
|
|
333
|
+
line = line.strip()
|
|
334
|
+
if not line:
|
|
335
|
+
return True
|
|
336
|
+
try:
|
|
337
|
+
parts = shlex.split(line, posix=True)
|
|
338
|
+
if not parts:
|
|
339
|
+
return True
|
|
340
|
+
command = parts[0].lower()
|
|
341
|
+
if command in {"exit", "quit"} and len(parts) == 1:
|
|
342
|
+
return False
|
|
343
|
+
if command in {"help", "?"} and len(parts) == 1:
|
|
344
|
+
self._show_help()
|
|
345
|
+
elif command == "clear" and len(parts) == 1:
|
|
346
|
+
self.session.console.clear()
|
|
347
|
+
elif command == "history" and len(parts) == 1:
|
|
348
|
+
self._history()
|
|
349
|
+
elif command == "back" and len(parts) == 1:
|
|
350
|
+
self.project = None
|
|
351
|
+
self.session.project = None
|
|
352
|
+
self._write("[dim]Project context cleared. Run 'use UUID' to select one.[/dim]")
|
|
353
|
+
elif command == "set" and len(parts) == 3 and parts[1].lower() == "env":
|
|
354
|
+
self._set_environment(parts[2].lower())
|
|
355
|
+
elif command == "set" and len(parts) == 3 and parts[1].lower() == "project":
|
|
356
|
+
self._execute_cli(["projects", "use", parts[2]])
|
|
357
|
+
elif command == "use" and len(parts) == 2:
|
|
358
|
+
self._execute_cli(["projects", "use", parts[1]])
|
|
359
|
+
elif command == "auth" and len(parts) == 2 and parts[1] == "login":
|
|
360
|
+
self._login()
|
|
361
|
+
elif command == "auth" and len(parts) == 2 and parts[1] == "logout":
|
|
362
|
+
self._logout()
|
|
363
|
+
elif command == "show" and len(parts) == 2:
|
|
364
|
+
target = SHOW_TARGETS.get(parts[1].lower(), ())
|
|
365
|
+
if target is None:
|
|
366
|
+
self._show_options()
|
|
367
|
+
elif target:
|
|
368
|
+
self._execute_cli(list(target))
|
|
369
|
+
else:
|
|
370
|
+
raise cli.CLIError("Unknown display target. Type 'help'.")
|
|
371
|
+
elif command == "json" and len(parts) > 1:
|
|
372
|
+
self._execute_cli(parts[1:], json_output=True)
|
|
373
|
+
else:
|
|
374
|
+
self._execute_cli(parts)
|
|
375
|
+
except ValueError:
|
|
376
|
+
self._error("Invalid command syntax. Type 'help'.")
|
|
377
|
+
except cli.CLIError as exc:
|
|
378
|
+
self._error(str(exc))
|
|
379
|
+
except (click.ClickException, click.Abort, click.exceptions.Exit):
|
|
380
|
+
self._error("Invalid command arguments. Type 'help'.")
|
|
381
|
+
return True
|
|
382
|
+
|
|
383
|
+
def run(self) -> None:
|
|
384
|
+
self._write(
|
|
385
|
+
"\n[bold cyan]HOOKSTRIDE[/bold cyan] [dim]CONSOLE v0.2[/dim]\n"
|
|
386
|
+
"[dim]Type 'help' for commands, 'auth login' for a token, "
|
|
387
|
+
"or 'exit' to quit.[/dim]\n"
|
|
388
|
+
)
|
|
389
|
+
terminal = PromptSession(
|
|
390
|
+
history=self.history,
|
|
391
|
+
completer=self.completer,
|
|
392
|
+
complete_while_typing=False,
|
|
393
|
+
)
|
|
394
|
+
try:
|
|
395
|
+
while True:
|
|
396
|
+
tag = self.environment
|
|
397
|
+
if self.project:
|
|
398
|
+
tag += ":" + self.project[:8]
|
|
399
|
+
prompt = FormattedText(
|
|
400
|
+
[("ansicyan bold", "hookstride"),
|
|
401
|
+
("", " ["), ("ansigreen", tag), ("", "] > ")]
|
|
402
|
+
)
|
|
403
|
+
try:
|
|
404
|
+
line = terminal.prompt(prompt)
|
|
405
|
+
except KeyboardInterrupt:
|
|
406
|
+
self._write("[dim]Interrupted; type 'exit' to quit.[/dim]")
|
|
407
|
+
continue
|
|
408
|
+
except EOFError:
|
|
409
|
+
break
|
|
410
|
+
if not self.execute_line(line):
|
|
411
|
+
break
|
|
412
|
+
finally:
|
|
413
|
+
self.tokens.clear()
|
|
414
|
+
self.logged_out.clear()
|
|
415
|
+
self._write("[dim]Console closed.[/dim]")
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
class _FakeContext:
|
|
419
|
+
def __init__(self, obj: cli.Session) -> None:
|
|
420
|
+
self.obj = obj
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def _env_hint(token: str | None) -> str:
|
|
424
|
+
return "token configured" if token else "run 'auth login' to authenticate"
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _allowed_cli(parts: list[str]) -> bool:
|
|
428
|
+
if not parts:
|
|
429
|
+
return False
|
|
430
|
+
if parts[0] in ROOT_COMMANDS:
|
|
431
|
+
return len(parts) == 1
|
|
432
|
+
return (
|
|
433
|
+
len(parts) >= 2
|
|
434
|
+
and parts[0] in READ_COMMANDS
|
|
435
|
+
and parts[1] in READ_COMMANDS[parts[0]]
|
|
436
|
+
)
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def _requires_project(group: str, action: str | None) -> bool:
|
|
440
|
+
return group in {
|
|
441
|
+
"destinations", "keys", "events", "billing", "incoming",
|
|
442
|
+
} or group == "stats"
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def _requires_token(group: str, action: str | None) -> bool:
|
|
446
|
+
return group != "status" and not (group == "config" and action == "show")
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def run_console(session: cli.Session) -> None:
|
|
450
|
+
if session.json_output:
|
|
451
|
+
raise cli.CLIError("Interactive console does not support --json; use 'json COMMAND' inside it.")
|
|
452
|
+
if not os.isatty(0):
|
|
453
|
+
raise cli.CLIError(
|
|
454
|
+
"Interactive console requires a terminal. Use direct Hookstride CLI commands in scripts."
|
|
455
|
+
)
|
|
456
|
+
HookstrideConsole(session).run()
|
hookstride_cli/main.py
ADDED
|
@@ -0,0 +1,687 @@
|
|
|
1
|
+
"""Hookstride CLI: developer-friendly, project-scoped Control API inspection.
|
|
2
|
+
|
|
3
|
+
Read-only commands use control tokens. This module never persists a token.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import contextlib
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import sys
|
|
12
|
+
import tempfile
|
|
13
|
+
import unicodedata
|
|
14
|
+
import uuid
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
from urllib.parse import urlsplit
|
|
18
|
+
|
|
19
|
+
import httpx
|
|
20
|
+
import typer
|
|
21
|
+
from rich.cells import cell_len
|
|
22
|
+
from rich.console import Console
|
|
23
|
+
from rich.markup import escape
|
|
24
|
+
from rich.panel import Panel
|
|
25
|
+
from rich.text import Text
|
|
26
|
+
|
|
27
|
+
VERSION = "0.2.0"
|
|
28
|
+
ORIGINS = {
|
|
29
|
+
"dev": "https://api-dev.hookstride.voteeasy.net",
|
|
30
|
+
"production": "https://api.hookstride.voteeasy.net",
|
|
31
|
+
}
|
|
32
|
+
app = typer.Typer(
|
|
33
|
+
help="Hookstride · inspect your projects and webhook deliveries.",
|
|
34
|
+
add_completion=False,
|
|
35
|
+
no_args_is_help=False,
|
|
36
|
+
pretty_exceptions_show_locals=False,
|
|
37
|
+
)
|
|
38
|
+
auth_app = typer.Typer(help="Check Control API authentication.")
|
|
39
|
+
projects_app = typer.Typer(help="List and select a workspace project.")
|
|
40
|
+
destinations_app = typer.Typer(help="Inspect configured webhook destinations.")
|
|
41
|
+
events_app = typer.Typer(help="Inspect delivery history and attempts.")
|
|
42
|
+
billing_app = typer.Typer(help="Inspect project-isolated billing.")
|
|
43
|
+
incoming_app = typer.Typer(help="Inspect incoming webhook sources.")
|
|
44
|
+
keys_app = typer.Typer(help="Inspect masked project API keys.")
|
|
45
|
+
config_app = typer.Typer(help="Inspect local CLI configuration.")
|
|
46
|
+
app.add_typer(auth_app, name="auth")
|
|
47
|
+
app.add_typer(projects_app, name="projects")
|
|
48
|
+
app.add_typer(destinations_app, name="destinations")
|
|
49
|
+
app.add_typer(events_app, name="events")
|
|
50
|
+
app.add_typer(billing_app, name="billing")
|
|
51
|
+
app.add_typer(incoming_app, name="incoming")
|
|
52
|
+
app.add_typer(keys_app, name="keys")
|
|
53
|
+
app.add_typer(config_app, name="config")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class CLIError(Exception):
|
|
57
|
+
"""An error safe to display without leaking credentials."""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class Session:
|
|
61
|
+
def __init__(
|
|
62
|
+
self, environment: str, api_url: str | None, project: str | None,
|
|
63
|
+
json_output: bool, no_color: bool,
|
|
64
|
+
) -> None:
|
|
65
|
+
if environment not in ORIGINS:
|
|
66
|
+
raise CLIError("Choose --env dev or --env production.")
|
|
67
|
+
self.environment = environment
|
|
68
|
+
self.origin = _origin(api_url or ORIGINS[environment])
|
|
69
|
+
self.project = _uuid(project) if project else None
|
|
70
|
+
self.json_output = json_output
|
|
71
|
+
self.console = Console(
|
|
72
|
+
stderr=False, no_color=no_color or json_output,
|
|
73
|
+
force_terminal=False if json_output else None,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _origin(value: str) -> str:
|
|
78
|
+
try:
|
|
79
|
+
parts = urlsplit(value)
|
|
80
|
+
# urlsplit accepts malformed ports unless the port property is accessed.
|
|
81
|
+
port = parts.port
|
|
82
|
+
hostname = parts.hostname
|
|
83
|
+
except ValueError:
|
|
84
|
+
raise CLIError("Invalid API origin. Specify a hostname and valid port.") from None
|
|
85
|
+
if (
|
|
86
|
+
not hostname or port == 0 or parts.username or parts.password
|
|
87
|
+
or parts.query or parts.fragment or parts.path not in ("", "/")
|
|
88
|
+
):
|
|
89
|
+
raise CLIError("API URL must be an origin, without path, credentials or query.")
|
|
90
|
+
if parts.scheme != "https" and not (
|
|
91
|
+
parts.scheme == "http" and hostname in ("localhost", "127.0.0.1", "::1")
|
|
92
|
+
):
|
|
93
|
+
raise CLIError("Use HTTPS for remote APIs; HTTP is allowed only on loopback.")
|
|
94
|
+
return value.rstrip("/")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _uuid(value: str) -> str:
|
|
98
|
+
try:
|
|
99
|
+
return str(uuid.UUID(value))
|
|
100
|
+
except (ValueError, AttributeError) as exc:
|
|
101
|
+
raise CLIError("Expected a valid project or event UUID.") from exc
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _config_file() -> Path:
|
|
105
|
+
configured = os.environ.get("HOOKSTRIDE_CONFIG_FILE")
|
|
106
|
+
return Path(configured).expanduser() if configured else (
|
|
107
|
+
Path.home() / ".config" / "hookstride" / "settings.json"
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _read_config() -> dict[str, Any]:
|
|
112
|
+
path = _config_file()
|
|
113
|
+
try:
|
|
114
|
+
if not path.exists():
|
|
115
|
+
return {}
|
|
116
|
+
parsed = json.loads(path.read_text(encoding="utf-8"))
|
|
117
|
+
return parsed if isinstance(parsed, dict) else {}
|
|
118
|
+
except (OSError, ValueError) as exc:
|
|
119
|
+
raise CLIError("Cannot read Hookstride CLI settings.") from exc
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _save_project(env: str, project_id: str) -> None:
|
|
123
|
+
settings = _read_config()
|
|
124
|
+
profiles = settings.setdefault("projects", {})
|
|
125
|
+
if not isinstance(profiles, dict):
|
|
126
|
+
profiles = settings["projects"] = {}
|
|
127
|
+
profiles[env] = project_id
|
|
128
|
+
path = _config_file()
|
|
129
|
+
temporary = None
|
|
130
|
+
try:
|
|
131
|
+
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
132
|
+
# Replace atomically: don't truncate a working config if interrupted.
|
|
133
|
+
# The temporary file is owner-only, and never contains auth tokens.
|
|
134
|
+
fd, temporary = tempfile.mkstemp(prefix=".settings-", dir=path.parent)
|
|
135
|
+
with os.fdopen(fd, "w", encoding="utf-8") as stream:
|
|
136
|
+
os.fchmod(stream.fileno(), 0o600)
|
|
137
|
+
json.dump(settings, stream, indent=2, sort_keys=True)
|
|
138
|
+
stream.write("\n")
|
|
139
|
+
os.replace(temporary, path)
|
|
140
|
+
except OSError as exc:
|
|
141
|
+
raise CLIError("Cannot save Hookstride CLI settings.") from exc
|
|
142
|
+
finally:
|
|
143
|
+
if temporary is not None:
|
|
144
|
+
with contextlib.suppress(FileNotFoundError):
|
|
145
|
+
os.unlink(temporary)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _session(ctx: typer.Context) -> Session:
|
|
149
|
+
return ctx.obj
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _project(ctx: typer.Context) -> str:
|
|
153
|
+
session = _session(ctx)
|
|
154
|
+
if session.project:
|
|
155
|
+
return session.project
|
|
156
|
+
saved = _read_config().get("projects", {})
|
|
157
|
+
selected = saved.get(session.environment) if isinstance(saved, dict) else None
|
|
158
|
+
if not selected:
|
|
159
|
+
raise CLIError("Select a project with 'hookstride projects use UUID' or --project UUID.")
|
|
160
|
+
return _uuid(selected)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _token() -> str:
|
|
164
|
+
token = os.environ.get("HOOKSTRIDE_CONTROL_TOKEN", "").strip()
|
|
165
|
+
if not token.startswith("whrctl_"):
|
|
166
|
+
raise CLIError(
|
|
167
|
+
"Control token missing. Set HOOKSTRIDE_CONTROL_TOKEN to an MFA-verified "
|
|
168
|
+
"management token from the Hookstride dashboard."
|
|
169
|
+
)
|
|
170
|
+
return token
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _request(
|
|
174
|
+
session: Session, path: str, *,
|
|
175
|
+
authenticate: bool = True, params: dict[str, Any] | None = None,
|
|
176
|
+
) -> Any:
|
|
177
|
+
headers = {"Accept": "application/json"}
|
|
178
|
+
if authenticate:
|
|
179
|
+
headers["Authorization"] = "Bearer " + _token()
|
|
180
|
+
try:
|
|
181
|
+
with httpx.Client(
|
|
182
|
+
base_url=session.origin, headers=headers, timeout=15,
|
|
183
|
+
follow_redirects=False,
|
|
184
|
+
) as client:
|
|
185
|
+
response = client.get(path, params=params)
|
|
186
|
+
except (httpx.HTTPError, httpx.InvalidURL, ValueError):
|
|
187
|
+
# Never print provider errors: transport exceptions may include headers.
|
|
188
|
+
raise CLIError("Unable to reach the Hookstride API. Check the URL and connection.") from None
|
|
189
|
+
if response.status_code == 401:
|
|
190
|
+
raise CLIError("Authentication rejected. Check that your control token is valid.")
|
|
191
|
+
if response.status_code == 403:
|
|
192
|
+
raise CLIError("Access denied for this account or project (HTTP 403).")
|
|
193
|
+
if response.status_code == 404:
|
|
194
|
+
raise CLIError("Resource not found or not enabled for this project (HTTP 404).")
|
|
195
|
+
if not 200 <= response.status_code < 300:
|
|
196
|
+
raise CLIError(f"API request failed (HTTP {response.status_code}).")
|
|
197
|
+
try:
|
|
198
|
+
return response.json()
|
|
199
|
+
except ValueError as exc:
|
|
200
|
+
raise CLIError("API returned a non-JSON response.") from exc
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _print(ctx: typer.Context, data: Any) -> None:
|
|
204
|
+
session = _session(ctx)
|
|
205
|
+
if session.json_output:
|
|
206
|
+
# ASCII escapes prevent C1/bidi controls from reaching the terminal
|
|
207
|
+
# while retaining all original JSON values for scripts.
|
|
208
|
+
typer.echo(json.dumps(data, indent=2, ensure_ascii=True))
|
|
209
|
+
else:
|
|
210
|
+
session.console.print_json(data=data)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _safe_terminal_text(value: Any) -> str:
|
|
214
|
+
"""Display untrusted values without executable terminal or bidi controls.
|
|
215
|
+
|
|
216
|
+
Rich escaping handles markup, but not ESC/C1/OSC/CSI control sequences.
|
|
217
|
+
Make control/format characters visible as literal Unicode escapes before
|
|
218
|
+
any response field reaches a console renderer. Keep API/JSON values intact.
|
|
219
|
+
"""
|
|
220
|
+
safe = []
|
|
221
|
+
for char in str(value):
|
|
222
|
+
number = ord(char)
|
|
223
|
+
if unicodedata.category(char) in {"Cc", "Cf", "Cs", "Zl", "Zp"}:
|
|
224
|
+
if number <= 0xFF:
|
|
225
|
+
safe.append(f"\\x{number:02x}")
|
|
226
|
+
elif number <= 0xFFFF:
|
|
227
|
+
safe.append(f"\\u{number:04x}")
|
|
228
|
+
else:
|
|
229
|
+
safe.append(f"\\U{number:08x}")
|
|
230
|
+
else:
|
|
231
|
+
safe.append(char)
|
|
232
|
+
return "".join(safe)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _plain(value: Any) -> Text:
|
|
236
|
+
return Text("—" if value is None or value == "" else _safe_terminal_text(value))
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _escaped(value: Any) -> str:
|
|
240
|
+
"""Render API-controlled values literally inside Rich markup templates."""
|
|
241
|
+
return escape(str(_plain(value)))
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _colored_status(value: Any) -> Text:
|
|
245
|
+
label = _safe_terminal_text(value or "unknown")
|
|
246
|
+
style = {
|
|
247
|
+
"active": "green", "delivered": "green", "succeeded": "green",
|
|
248
|
+
"success": "green", "failed": "red", "revoked": "red",
|
|
249
|
+
"abandoned": "yellow", "pending": "yellow", "delivering": "cyan",
|
|
250
|
+
}.get(label.lower(), "white")
|
|
251
|
+
return Text(label.upper(), style=style)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _table(
|
|
255
|
+
ctx: typer.Context, title: str, columns: list[str],
|
|
256
|
+
rows: list[list[Any]], *, status_column: int | None = None,
|
|
257
|
+
) -> None:
|
|
258
|
+
"""Render Metasploit-style columns without borders or wrapped identifiers."""
|
|
259
|
+
session = _session(ctx)
|
|
260
|
+
if not rows:
|
|
261
|
+
session.console.print(f"[dim]No {title.lower()} found.[/dim]")
|
|
262
|
+
return
|
|
263
|
+
|
|
264
|
+
styled_rows = [
|
|
265
|
+
[
|
|
266
|
+
_colored_status(value) if i == status_column else _plain(value)
|
|
267
|
+
for i, value in enumerate(values)
|
|
268
|
+
]
|
|
269
|
+
for values in rows
|
|
270
|
+
]
|
|
271
|
+
widths = [
|
|
272
|
+
max(cell_len(header), *(row[i].cell_len for row in styled_rows))
|
|
273
|
+
for i, header in enumerate(columns)
|
|
274
|
+
]
|
|
275
|
+
gap = 2
|
|
276
|
+
available = max(20, session.console.width - 1)
|
|
277
|
+
|
|
278
|
+
# Keep names, dates, plans and statuses readable. On narrow terminals,
|
|
279
|
+
# trim identifiers/references first instead of wrapping them across lines.
|
|
280
|
+
priority = [
|
|
281
|
+
100 if ("id" in header.lower() or "reference" in header.lower())
|
|
282
|
+
else 50 if header.lower() in {"url", "error"}
|
|
283
|
+
else 0
|
|
284
|
+
for header in columns
|
|
285
|
+
]
|
|
286
|
+
minimums = [
|
|
287
|
+
min(width, 8 if priority[i] == 100 else 4)
|
|
288
|
+
for i, width in enumerate(widths)
|
|
289
|
+
]
|
|
290
|
+
while sum(widths) + gap * (len(widths) - 1) > available:
|
|
291
|
+
candidates = [
|
|
292
|
+
i for i in range(len(widths)) if widths[i] > minimums[i]
|
|
293
|
+
]
|
|
294
|
+
if not candidates:
|
|
295
|
+
break
|
|
296
|
+
index = max(candidates, key=lambda i: (priority[i], widths[i] - minimums[i]))
|
|
297
|
+
widths[index] -= 1
|
|
298
|
+
|
|
299
|
+
session.console.print(title, highlight=False)
|
|
300
|
+
session.console.print("=" * cell_len(title), highlight=False)
|
|
301
|
+
|
|
302
|
+
def row_text(values: list[Text]) -> Text:
|
|
303
|
+
result = Text()
|
|
304
|
+
for i, value in enumerate(values):
|
|
305
|
+
if i:
|
|
306
|
+
result.append(" " * gap)
|
|
307
|
+
rendered = value.copy()
|
|
308
|
+
rendered.truncate(widths[i], overflow="ellipsis")
|
|
309
|
+
result.append(rendered)
|
|
310
|
+
if i < len(values) - 1:
|
|
311
|
+
result.append(" " * max(0, widths[i] - rendered.cell_len))
|
|
312
|
+
return result
|
|
313
|
+
|
|
314
|
+
session.console.print(row_text([Text(col) for col in columns]), highlight=False)
|
|
315
|
+
session.console.print(row_text([Text("-" * size) for size in widths]), highlight=False)
|
|
316
|
+
for values in styled_rows:
|
|
317
|
+
session.console.print(row_text(values), highlight=False)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _date(value: Any) -> str:
|
|
321
|
+
return str(value or "—")[:19].replace("T", " ")
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _entries(data: Any, label: str) -> list[dict[str, Any]]:
|
|
325
|
+
items = data.get("results") if isinstance(data, dict) else data
|
|
326
|
+
if not isinstance(items, list):
|
|
327
|
+
raise CLIError(f"Unexpected API response for {label}.")
|
|
328
|
+
return [entry for entry in items if isinstance(entry, dict)]
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _print_or_table(
|
|
332
|
+
ctx: typer.Context, data: Any, title: str, columns: list[str],
|
|
333
|
+
rows: list[list[Any]], *, status_column: int | None = None,
|
|
334
|
+
) -> None:
|
|
335
|
+
if _session(ctx).json_output:
|
|
336
|
+
_print(ctx, data)
|
|
337
|
+
else:
|
|
338
|
+
_table(ctx, title, columns, rows, status_column=status_column)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
@app.command("console")
|
|
342
|
+
def console_entry(ctx: typer.Context) -> None:
|
|
343
|
+
"""Start the interactive read-only Hookstride operator console."""
|
|
344
|
+
from .console import run_console
|
|
345
|
+
|
|
346
|
+
run_console(_session(ctx))
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
@app.callback(invoke_without_command=True)
|
|
350
|
+
def root(
|
|
351
|
+
ctx: typer.Context,
|
|
352
|
+
env: str = typer.Option("dev", "--env", help="dev or production."),
|
|
353
|
+
api_url: str | None = typer.Option(None, "--api-url", help="Override the API origin."),
|
|
354
|
+
project: str | None = typer.Option(None, "--project", help="Project UUID override."),
|
|
355
|
+
json_output: bool = typer.Option(False, "--json", help="Machine-readable JSON, no styling."),
|
|
356
|
+
no_color: bool = typer.Option(False, "--no-color", help="Disable ANSI colors."),
|
|
357
|
+
version: bool = typer.Option(False, "--version", is_eager=True, help="Show CLI version."),
|
|
358
|
+
) -> None:
|
|
359
|
+
if version:
|
|
360
|
+
typer.echo(f"hookstride {VERSION}")
|
|
361
|
+
raise typer.Exit()
|
|
362
|
+
try:
|
|
363
|
+
ctx.obj = Session(env, api_url, project, json_output, no_color)
|
|
364
|
+
except CLIError as exc:
|
|
365
|
+
typer.echo(f"Error: {exc}", err=True)
|
|
366
|
+
raise typer.Exit(2) from exc
|
|
367
|
+
if ctx.invoked_subcommand is None:
|
|
368
|
+
_session(ctx).console.print(
|
|
369
|
+
Panel.fit(
|
|
370
|
+
"[bold cyan]HOOKSTRIDE[/bold cyan] [dim]CLI[/dim]\n"
|
|
371
|
+
"[green]●[/green] Webhook delivery, from your terminal\n\n"
|
|
372
|
+
"Start with [bold]hookstride auth status[/bold] or "
|
|
373
|
+
"[bold]hookstride projects list[/bold].\n"
|
|
374
|
+
"Run [bold]hookstride --help[/bold] for all commands.",
|
|
375
|
+
border_style="cyan",
|
|
376
|
+
)
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
@app.command("status")
|
|
381
|
+
def status(ctx: typer.Context) -> None:
|
|
382
|
+
"""Check whether Hookstride is online."""
|
|
383
|
+
session = _session(ctx)
|
|
384
|
+
try:
|
|
385
|
+
checks = {
|
|
386
|
+
label: _request(session, f"/{label}/", authenticate=False)
|
|
387
|
+
for label in ("health", "ready")
|
|
388
|
+
}
|
|
389
|
+
health = checks["health"]
|
|
390
|
+
readiness = checks["ready"]
|
|
391
|
+
online = (
|
|
392
|
+
isinstance(health, dict)
|
|
393
|
+
and health.get("status") in {"ok", "healthy"}
|
|
394
|
+
and isinstance(readiness, dict)
|
|
395
|
+
and readiness.get("status") == "ready"
|
|
396
|
+
and (
|
|
397
|
+
not isinstance(readiness.get("checks"), dict)
|
|
398
|
+
or all(value is True for value in readiness["checks"].values())
|
|
399
|
+
)
|
|
400
|
+
)
|
|
401
|
+
except CLIError:
|
|
402
|
+
if session.json_output:
|
|
403
|
+
raise
|
|
404
|
+
online = False
|
|
405
|
+
if session.json_output:
|
|
406
|
+
_print(ctx, checks)
|
|
407
|
+
if not online:
|
|
408
|
+
raise typer.Exit(code=1)
|
|
409
|
+
return
|
|
410
|
+
color = "green" if online else "red"
|
|
411
|
+
label = "Online" if online else "Offline"
|
|
412
|
+
session.console.print(
|
|
413
|
+
Panel.fit(f"[{color}]● {label}[/{color}]", title="Hookstride", border_style=color)
|
|
414
|
+
)
|
|
415
|
+
if not online:
|
|
416
|
+
raise typer.Exit(code=1)
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
@auth_app.command("status")
|
|
420
|
+
def auth_status(ctx: typer.Context) -> None:
|
|
421
|
+
"""Verify your Control API token without printing it."""
|
|
422
|
+
data = _request(_session(ctx), "/control/v1/me/")
|
|
423
|
+
if _session(ctx).json_output:
|
|
424
|
+
# Authentication status never needs to dump membership/user PII.
|
|
425
|
+
_print(ctx, {"authenticated": True, "email": data.get("user", {}).get("email")})
|
|
426
|
+
return
|
|
427
|
+
user = data.get("user", {})
|
|
428
|
+
_session(ctx).console.print(Panel.fit(
|
|
429
|
+
f"[green]✓ Authenticated[/green]\n"
|
|
430
|
+
f"Account: {_escaped(user.get('email'))}\n"
|
|
431
|
+
f"Workspace memberships: {len(data.get('memberships', []))}",
|
|
432
|
+
title="Control API", border_style="green",
|
|
433
|
+
))
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
@projects_app.command("list")
|
|
437
|
+
def projects_list(ctx: typer.Context) -> None:
|
|
438
|
+
"""List projects accessible to the signed-in account."""
|
|
439
|
+
data = _request(_session(ctx), "/control/v1/projects/")
|
|
440
|
+
items = _entries(data, "projects")
|
|
441
|
+
_print_or_table(
|
|
442
|
+
ctx, data, "Projects", ["Name", "Workspace", "Role", "Plan", "Project ID"],
|
|
443
|
+
[[p.get("name"), p.get("account_name"), p.get("role"),
|
|
444
|
+
(p.get("subscription") or {}).get("plan_code"), p.get("id")]
|
|
445
|
+
for p in items],
|
|
446
|
+
)
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
@projects_app.command("use")
|
|
450
|
+
def projects_use(ctx: typer.Context, project_id: str = typer.Argument(..., help="Project UUID.")) -> None:
|
|
451
|
+
"""Choose a default project for this environment (only saves its UUID)."""
|
|
452
|
+
session = _session(ctx)
|
|
453
|
+
project_id = _uuid(project_id)
|
|
454
|
+
data = _request(session, f"/control/v1/projects/{project_id}/")
|
|
455
|
+
_save_project(session.environment, project_id)
|
|
456
|
+
if session.json_output:
|
|
457
|
+
_print(ctx, {"selected_project": project_id, "name": data.get("name")})
|
|
458
|
+
else:
|
|
459
|
+
session.console.print(
|
|
460
|
+
f"[green]✓[/green] Using [bold]{_escaped(data.get('name'))}[/bold] "
|
|
461
|
+
f"({project_id}) in {session.environment}."
|
|
462
|
+
)
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
@destinations_app.command("list")
|
|
466
|
+
def destinations_list(ctx: typer.Context, page: int = typer.Option(1, min=1)) -> None:
|
|
467
|
+
"""List the selected project's outgoing destinations."""
|
|
468
|
+
project = _project(ctx)
|
|
469
|
+
data = _request(_session(ctx), f"/control/v1/projects/{project}/destinations/", params={"page": page})
|
|
470
|
+
items = _entries(data, "destinations")
|
|
471
|
+
_print_or_table(
|
|
472
|
+
ctx, data, "Destinations", ["Name", "URL", "Active", "Destination ID"],
|
|
473
|
+
[[x.get("name"), x.get("url"), "yes" if x.get("is_active") else "no", x.get("id")]
|
|
474
|
+
for x in items],
|
|
475
|
+
)
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
@keys_app.command("list")
|
|
479
|
+
def keys_list(ctx: typer.Context) -> None:
|
|
480
|
+
"""List API key metadata; the server never returns the raw secret."""
|
|
481
|
+
project = _project(ctx)
|
|
482
|
+
data = _request(_session(ctx), f"/control/v1/projects/{project}/api-keys/")
|
|
483
|
+
items = _entries(data, "API keys")
|
|
484
|
+
_print_or_table(
|
|
485
|
+
ctx, data, "API keys",
|
|
486
|
+
["Name", "Masked key", "Enabled", "Expires", "Key ID"],
|
|
487
|
+
[[key.get("name"), key.get("masked_key"),
|
|
488
|
+
"yes" if key.get("is_active") else "no",
|
|
489
|
+
_date(key.get("expires_at")), key.get("id")]
|
|
490
|
+
for key in items],
|
|
491
|
+
)
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
@events_app.command("list")
|
|
495
|
+
def events_list(
|
|
496
|
+
ctx: typer.Context,
|
|
497
|
+
page: int = typer.Option(1, min=1),
|
|
498
|
+
status_filter: str | None = typer.Option(None, "--status", help="delivered, failed, pending..."),
|
|
499
|
+
origin: str | None = typer.Option(None, "--origin", help="api, incoming or replay."),
|
|
500
|
+
search: str | None = typer.Option(None, "--search", help="Event ID, type or destination."),
|
|
501
|
+
) -> None:
|
|
502
|
+
"""List recent delivery activity with optional filters."""
|
|
503
|
+
project = _project(ctx)
|
|
504
|
+
params: dict[str, Any] = {"page": page}
|
|
505
|
+
if status_filter:
|
|
506
|
+
params["status"] = status_filter
|
|
507
|
+
if origin:
|
|
508
|
+
params["origin"] = origin
|
|
509
|
+
if search:
|
|
510
|
+
params["q"] = search
|
|
511
|
+
data = _request(_session(ctx), f"/control/v1/projects/{project}/events/", params=params)
|
|
512
|
+
items = _entries(data, "events")
|
|
513
|
+
_print_or_table(
|
|
514
|
+
ctx, data, "Activity", ["Created", "Event", "Destination", "Status", "Attempts", "Event ID"],
|
|
515
|
+
[[_date(x.get("created_at")), x.get("event_type"), x.get("destination_name"),
|
|
516
|
+
x.get("status"), x.get("attempt_count"), x.get("id")] for x in items],
|
|
517
|
+
status_column=3,
|
|
518
|
+
)
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
@events_app.command("show")
|
|
522
|
+
def events_show(ctx: typer.Context, event_id: str = typer.Argument(...)) -> None:
|
|
523
|
+
"""Inspect an event and its delivery attempts (redacted by the API)."""
|
|
524
|
+
project = _project(ctx)
|
|
525
|
+
data = _request(_session(ctx), f"/control/v1/projects/{project}/events/{_uuid(event_id)}/")
|
|
526
|
+
if _session(ctx).json_output:
|
|
527
|
+
_print(ctx, data)
|
|
528
|
+
return
|
|
529
|
+
session = _session(ctx)
|
|
530
|
+
session.console.print(Panel.fit(
|
|
531
|
+
f"[bold]{_escaped(data.get('event_type'))}[/bold]\n"
|
|
532
|
+
f"Status: {_escaped(str(_colored_status(data.get('status'))))}\n"
|
|
533
|
+
f"Destination: {_escaped(data.get('destination_name'))}\n"
|
|
534
|
+
f"Event ID: {_escaped(data.get('id'))}",
|
|
535
|
+
title="Delivery", border_style="cyan",
|
|
536
|
+
))
|
|
537
|
+
_table(
|
|
538
|
+
ctx, "Delivery attempts", ["Attempt", "HTTP", "Duration (ms)", "Time", "Error"],
|
|
539
|
+
[[x.get("attempt_number"), x.get("status_code"), x.get("duration_ms"),
|
|
540
|
+
_date(x.get("created_at")), x.get("error")] for x in data.get("attempts", [])],
|
|
541
|
+
)
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
@billing_app.command("status")
|
|
545
|
+
def billing_status(ctx: typer.Context) -> None:
|
|
546
|
+
"""Show the selected project's plan and Flex credit balance."""
|
|
547
|
+
project = _project(ctx)
|
|
548
|
+
data = _request(_session(ctx), f"/control/v1/projects/{project}/billing/")
|
|
549
|
+
if _session(ctx).json_output:
|
|
550
|
+
_print(ctx, data)
|
|
551
|
+
return
|
|
552
|
+
plan = data.get("subscription") or {}
|
|
553
|
+
_session(ctx).console.print(Panel.fit(
|
|
554
|
+
f"Plan: [bold]{_escaped(plan.get('plan_code'))}[/bold]\n"
|
|
555
|
+
f"Status: {_escaped(str(_colored_status(plan.get('status'))))}\n"
|
|
556
|
+
f"Flex credits: {_escaped(plan.get('flex_credits'))}\n"
|
|
557
|
+
f"Current period ends: {_escaped(_date(plan.get('current_period_end')))}\n"
|
|
558
|
+
f"Payment attempts: {len(data.get('records', []))}",
|
|
559
|
+
title="Project Billing", border_style="cyan",
|
|
560
|
+
))
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
@billing_app.command("history")
|
|
564
|
+
def billing_history(ctx: typer.Context) -> None:
|
|
565
|
+
"""Display per-project Paystack payment attempts and their statuses."""
|
|
566
|
+
project = _project(ctx)
|
|
567
|
+
data = _request(_session(ctx), f"/control/v1/projects/{project}/billing/")
|
|
568
|
+
records = data.get("records", [])
|
|
569
|
+
_print_or_table(
|
|
570
|
+
ctx, records, "Payment history",
|
|
571
|
+
["Date", "Plan", "Amount", "Status", "Reference"],
|
|
572
|
+
[[_date(r.get("created_at")), r.get("plan_code"),
|
|
573
|
+
f"{r.get('currency', '')} {int(r.get('amount_minor', 0)) / 100:.2f}",
|
|
574
|
+
r.get("status"), r.get("reference")] for r in records],
|
|
575
|
+
status_column=3,
|
|
576
|
+
)
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
@incoming_app.command("sources")
|
|
580
|
+
def incoming_sources(ctx: typer.Context) -> None:
|
|
581
|
+
"""List configured incoming sources, when ingress is enabled."""
|
|
582
|
+
project = _project(ctx)
|
|
583
|
+
data = _request(_session(ctx), f"/control/v1/projects/{project}/incoming/sources/")
|
|
584
|
+
items = data.get("sources", [])
|
|
585
|
+
_print_or_table(
|
|
586
|
+
ctx, data, "Incoming sources", ["Name", "Provider", "Routing", "Active", "Source ID"],
|
|
587
|
+
[[s.get("name"), s.get("provider"), s.get("routing"),
|
|
588
|
+
"yes" if s.get("is_active") else "no", s.get("id")] for s in items],
|
|
589
|
+
)
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
@incoming_app.command("routes")
|
|
593
|
+
def incoming_routes(ctx: typer.Context) -> None:
|
|
594
|
+
"""List incoming event-routing rules for the selected project."""
|
|
595
|
+
project = _project(ctx)
|
|
596
|
+
data = _request(_session(ctx), f"/control/v1/projects/{project}/incoming/routes/")
|
|
597
|
+
routes = data.get("routes", [])
|
|
598
|
+
_print_or_table(
|
|
599
|
+
ctx, data, "Incoming routes",
|
|
600
|
+
["Source", "Destination", "Pattern", "Active", "Route ID"],
|
|
601
|
+
[[route.get("source"), route.get("endpoint"),
|
|
602
|
+
route.get("event_pattern"), "yes" if route.get("is_active") else "no",
|
|
603
|
+
route.get("id")] for route in routes],
|
|
604
|
+
)
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
@incoming_app.command("bindings")
|
|
608
|
+
def incoming_bindings(ctx: typer.Context) -> None:
|
|
609
|
+
"""List existing reference-to-destination bindings."""
|
|
610
|
+
project = _project(ctx)
|
|
611
|
+
data = _request(_session(ctx), f"/control/v1/projects/{project}/incoming/bindings/")
|
|
612
|
+
bindings = data.get("bindings", [])
|
|
613
|
+
_print_or_table(
|
|
614
|
+
ctx, data, "Incoming bindings",
|
|
615
|
+
["Reference", "Source", "Destination", "Binding ID"],
|
|
616
|
+
[[item.get("reference"), item.get("source"),
|
|
617
|
+
item.get("endpoint"), item.get("id")] for item in bindings],
|
|
618
|
+
)
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
@incoming_app.command("registration-keys")
|
|
622
|
+
def incoming_registration_keys(ctx: typer.Context) -> None:
|
|
623
|
+
"""List masked incoming registration keys (never raw credentials)."""
|
|
624
|
+
project = _project(ctx)
|
|
625
|
+
data = _request(
|
|
626
|
+
_session(ctx), f"/control/v1/projects/{project}/incoming/registration-keys/"
|
|
627
|
+
)
|
|
628
|
+
keys = data.get("keys", [])
|
|
629
|
+
_print_or_table(
|
|
630
|
+
ctx, data, "Registration keys",
|
|
631
|
+
["Name", "Masked key", "Active", "Expiry", "Key ID"],
|
|
632
|
+
[[item.get("name"), item.get("masked_key"),
|
|
633
|
+
"yes" if item.get("is_active") else "no",
|
|
634
|
+
_date(item.get("expires_at")), item.get("id")] for item in keys],
|
|
635
|
+
)
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
@app.command("stats")
|
|
639
|
+
def delivery_stats(ctx: typer.Context) -> None:
|
|
640
|
+
"""Show delivery success/failure totals for the selected project."""
|
|
641
|
+
project = _project(ctx)
|
|
642
|
+
data = _request(_session(ctx), f"/control/v1/projects/{project}/delivery-stats/")
|
|
643
|
+
if _session(ctx).json_output:
|
|
644
|
+
_print(ctx, data)
|
|
645
|
+
return
|
|
646
|
+
counts = data.get("events", {})
|
|
647
|
+
_table(
|
|
648
|
+
ctx, "Delivery overview", ["Total", "Delivered", "Failed", "Pending", "Success"],
|
|
649
|
+
[[counts.get("total"), counts.get("delivered"), counts.get("failed"),
|
|
650
|
+
counts.get("pending"), f"{counts.get('success_rate', 0)}%"]],
|
|
651
|
+
)
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
@config_app.command("show")
|
|
655
|
+
def config_show(ctx: typer.Context) -> None:
|
|
656
|
+
"""Print the active API/profile settings, never the token value."""
|
|
657
|
+
session = _session(ctx)
|
|
658
|
+
saved = _read_config().get("projects", {})
|
|
659
|
+
selected = session.project or (saved.get(session.environment) if isinstance(saved, dict) else None)
|
|
660
|
+
data = {
|
|
661
|
+
"environment": session.environment,
|
|
662
|
+
"api_url": session.origin,
|
|
663
|
+
"project": selected,
|
|
664
|
+
"token_configured": bool(os.environ.get("HOOKSTRIDE_CONTROL_TOKEN", "").startswith("whrctl_")),
|
|
665
|
+
"token_source": "environment" if os.environ.get("HOOKSTRIDE_CONTROL_TOKEN", "").startswith("whrctl_") else None,
|
|
666
|
+
}
|
|
667
|
+
if session.json_output:
|
|
668
|
+
_print(ctx, data)
|
|
669
|
+
else:
|
|
670
|
+
_table(ctx, "Configuration", ["Setting", "Value"], [[k, v] for k, v in data.items()])
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
def main() -> None:
|
|
674
|
+
try:
|
|
675
|
+
app()
|
|
676
|
+
except CLIError as exc:
|
|
677
|
+
# Error messages are intentionally generic. No auth tokens or response bodies.
|
|
678
|
+
is_json = "--json" in sys.argv[1:]
|
|
679
|
+
if is_json:
|
|
680
|
+
typer.echo(json.dumps({"error": str(exc)}), err=True)
|
|
681
|
+
else:
|
|
682
|
+
Console(stderr=True).print(f"[red]✗[/red] {exc}")
|
|
683
|
+
raise SystemExit(1) from exc
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
if __name__ == "__main__":
|
|
687
|
+
main()
|
|
@@ -0,0 +1,179 @@
|
|
|
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.
|
|
@@ -0,0 +1,7 @@
|
|
|
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,,
|