tunnelhook 0.1.0

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.
Files changed (3) hide show
  1. package/README.md +64 -0
  2. package/package.json +44 -0
  3. package/src/index.tsx +1739 -0
package/src/index.tsx ADDED
@@ -0,0 +1,1739 @@
1
+ #!/usr/bin/env bun
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { homedir, hostname } from "node:os";
4
+ import { join } from "node:path";
5
+ import { createCliRenderer } from "@opentui/core";
6
+ import {
7
+ createRoot,
8
+ useKeyboard,
9
+ useRenderer,
10
+ useTerminalDimensions,
11
+ } from "@opentui/react";
12
+ import { createORPCClient } from "@orpc/client";
13
+ import { RPCLink } from "@orpc/client/fetch";
14
+ import type { AppRouterClient } from "@tunnelhook/api/routers/index";
15
+ import { useCallback, useEffect, useRef, useState } from "react";
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // Configuration
19
+ // ---------------------------------------------------------------------------
20
+
21
+ const SERVER_URL =
22
+ process.env.TUNNELHOOK_SERVER_URL ??
23
+ "https://tunnelhook-server-shkumbinhasani.shkumbinhasani20001439.workers.dev";
24
+ const WS_URL = SERVER_URL.replace(/^http/, "ws");
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // CLI argument parsing
28
+ // ---------------------------------------------------------------------------
29
+
30
+ interface CliArgs {
31
+ /** "login" subcommand */
32
+ command: "login" | "listen" | "interactive";
33
+ /** --forward / -f URL */
34
+ forwardUrl?: string;
35
+ /** --machine / -m name override */
36
+ machineName?: string;
37
+ /** Endpoint slug (positional arg for listen mode) */
38
+ slug?: string;
39
+ }
40
+
41
+ function parseArgs(): CliArgs {
42
+ const args = process.argv.slice(2);
43
+
44
+ if (args.length === 0) {
45
+ return { command: "interactive" };
46
+ }
47
+
48
+ if (args[0] === "login") {
49
+ return { command: "login" };
50
+ }
51
+
52
+ // tunnelhook <slug> --forward <url> [--machine <name>]
53
+ const slug = args[0];
54
+ let forwardUrl: string | undefined;
55
+ let machineName: string | undefined;
56
+
57
+ for (let i = 1; i < args.length; i++) {
58
+ const arg = args[i];
59
+ if ((arg === "--forward" || arg === "-f") && args[i + 1]) {
60
+ forwardUrl = args[i + 1];
61
+ i++;
62
+ } else if ((arg === "--machine" || arg === "-m") && args[i + 1]) {
63
+ machineName = args[i + 1];
64
+ i++;
65
+ }
66
+ }
67
+
68
+ if (!forwardUrl) {
69
+ console.error(
70
+ "Usage: tunnelhook <endpoint-slug> --forward <url> [--machine <name>]"
71
+ );
72
+ console.error(" tunnelhook login");
73
+ console.error(" tunnelhook (interactive mode)");
74
+ process.exit(1);
75
+ }
76
+
77
+ return { command: "listen", slug, forwardUrl, machineName };
78
+ }
79
+
80
+ const cliArgs = parseArgs();
81
+
82
+ // ---------------------------------------------------------------------------
83
+ // Types
84
+ // ---------------------------------------------------------------------------
85
+
86
+ interface Endpoint {
87
+ createdAt: string;
88
+ description: string | null;
89
+ enabled: boolean;
90
+ forwardUrl: string | null;
91
+ id: string;
92
+ name: string;
93
+ slug: string;
94
+ }
95
+
96
+ interface Machine {
97
+ endpointId: string;
98
+ forwardUrl: string;
99
+ id: string;
100
+ name: string;
101
+ status: string;
102
+ }
103
+
104
+ interface WebhookEvent {
105
+ body: string | null;
106
+ contentType: string | null;
107
+ createdAt: string;
108
+ deliveryId?: string;
109
+ endpointId?: string;
110
+ eventId?: string;
111
+ headers: string;
112
+ id?: string;
113
+ method: string;
114
+ query: string | null;
115
+ sourceIp: string | null;
116
+ }
117
+
118
+ interface DeliveryResult {
119
+ deliveryId: string;
120
+ duration: number | null;
121
+ error: string | null;
122
+ eventId: string;
123
+ machineId: string;
124
+ machineName: string;
125
+ responseBody: string | null;
126
+ responseStatus: number | null;
127
+ status: "delivered" | "failed";
128
+ }
129
+
130
+ type Screen =
131
+ | "login"
132
+ | "endpoints"
133
+ | "machine-setup"
134
+ | "monitor"
135
+ | "event-detail";
136
+
137
+ // ---------------------------------------------------------------------------
138
+ // Session persistence (~/.tunnelhook/session.json)
139
+ // ---------------------------------------------------------------------------
140
+
141
+ const CONFIG_DIR = join(homedir(), ".tunnelhook");
142
+ const SESSION_FILE = join(CONFIG_DIR, "session.json");
143
+
144
+ interface SessionData {
145
+ cookies: string;
146
+ serverUrl: string;
147
+ }
148
+
149
+ function loadSession(): string | null {
150
+ try {
151
+ if (!existsSync(SESSION_FILE)) {
152
+ return null;
153
+ }
154
+ const raw = readFileSync(SESSION_FILE, "utf-8");
155
+ const data = JSON.parse(raw) as SessionData;
156
+ // Only use session if it matches the current server URL
157
+ if (data.serverUrl === SERVER_URL && data.cookies) {
158
+ return data.cookies;
159
+ }
160
+ return null;
161
+ } catch {
162
+ return null;
163
+ }
164
+ }
165
+
166
+ function saveSession(cookies: string): void {
167
+ try {
168
+ if (!existsSync(CONFIG_DIR)) {
169
+ mkdirSync(CONFIG_DIR, { recursive: true });
170
+ }
171
+ const data: SessionData = { cookies, serverUrl: SERVER_URL };
172
+ writeFileSync(SESSION_FILE, JSON.stringify(data, null, 2), "utf-8");
173
+ } catch {
174
+ // Non-critical — session just won't persist
175
+ }
176
+ }
177
+
178
+ // ---------------------------------------------------------------------------
179
+ // Auth helpers
180
+ // ---------------------------------------------------------------------------
181
+
182
+ let authCookies: string | null = loadSession();
183
+
184
+ async function signIn(
185
+ email: string,
186
+ password: string
187
+ ): Promise<{ success: boolean; error?: string }> {
188
+ try {
189
+ const res = await fetch(`${SERVER_URL}/api/auth/sign-in/email`, {
190
+ method: "POST",
191
+ headers: { "Content-Type": "application/json" },
192
+ body: JSON.stringify({ email, password }),
193
+ redirect: "manual",
194
+ });
195
+
196
+ if (!res.ok) {
197
+ return { success: false, error: "Invalid credentials" };
198
+ }
199
+
200
+ const cookies = res.headers.getSetCookie?.() ?? [];
201
+ if (cookies.length > 0) {
202
+ authCookies = cookies.map((c: string) => c.split(";")[0]).join("; ");
203
+ saveSession(authCookies);
204
+ }
205
+
206
+ return { success: true };
207
+ } catch (err) {
208
+ return {
209
+ success: false,
210
+ error: err instanceof Error ? err.message : "Network error",
211
+ };
212
+ }
213
+ }
214
+
215
+ /** Validate the current session is still active by calling the auth session endpoint. */
216
+ async function validateSession(): Promise<boolean> {
217
+ if (!authCookies) {
218
+ return false;
219
+ }
220
+ try {
221
+ const res = await fetch(`${SERVER_URL}/api/auth/get-session`, {
222
+ headers: { Cookie: authCookies },
223
+ });
224
+ if (!res.ok) {
225
+ return false;
226
+ }
227
+ const data = (await res.json()) as { session?: unknown };
228
+ return Boolean(data.session);
229
+ } catch {
230
+ return false;
231
+ }
232
+ }
233
+
234
+ function getAuthHeaders(): Record<string, string> {
235
+ const headers: Record<string, string> = {};
236
+ if (authCookies) {
237
+ headers.Cookie = authCookies;
238
+ }
239
+ return headers;
240
+ }
241
+
242
+ // ---------------------------------------------------------------------------
243
+ // oRPC client
244
+ // ---------------------------------------------------------------------------
245
+
246
+ const link = new RPCLink({
247
+ url: `${SERVER_URL}/rpc`,
248
+ headers: () => getAuthHeaders(),
249
+ });
250
+
251
+ const rpcClient: AppRouterClient = createORPCClient(link);
252
+
253
+ // ---------------------------------------------------------------------------
254
+ // API helpers
255
+ // ---------------------------------------------------------------------------
256
+
257
+ async function fetchEndpoints(): Promise<Endpoint[]> {
258
+ const result = await rpcClient.endpoints.list({});
259
+ return result as unknown as Endpoint[];
260
+ }
261
+
262
+ async function fetchMachines(endpointId: string): Promise<Machine[]> {
263
+ const result = await rpcClient.machines.list({ endpointId });
264
+ return result as unknown as Machine[];
265
+ }
266
+
267
+ async function registerMachine(
268
+ endpointId: string,
269
+ name: string,
270
+ forwardUrl: string
271
+ ): Promise<Machine> {
272
+ const result = await rpcClient.machines.register({
273
+ endpointId,
274
+ name,
275
+ forwardUrl,
276
+ });
277
+ return result as unknown as Machine;
278
+ }
279
+
280
+ async function createEndpointApi(
281
+ name: string,
282
+ forwardUrl?: string
283
+ ): Promise<Endpoint> {
284
+ const result = await rpcClient.endpoints.create({
285
+ name,
286
+ forwardUrl: forwardUrl || undefined,
287
+ });
288
+ return result as unknown as Endpoint;
289
+ }
290
+
291
+ async function reportDeliveryResult(params: {
292
+ deliveryId: string;
293
+ duration: number | null;
294
+ error: string | null;
295
+ responseBody: string | null;
296
+ responseStatus: number | null;
297
+ status: "delivered" | "failed";
298
+ }): Promise<void> {
299
+ await rpcClient.machines.reportDelivery(params);
300
+ }
301
+
302
+ // ---------------------------------------------------------------------------
303
+ // Machine resolution (find or create a machine for direct CLI mode)
304
+ // ---------------------------------------------------------------------------
305
+
306
+ const LOCAL_SUFFIX_RE = /\.local$/;
307
+
308
+ function getMachineName(): string {
309
+ return hostname().replace(LOCAL_SUFFIX_RE, "");
310
+ }
311
+
312
+ async function findEndpointBySlug(slug: string): Promise<Endpoint> {
313
+ const endpoints = await fetchEndpoints();
314
+ const found = endpoints.find((ep) => ep.slug === slug);
315
+ if (found) {
316
+ return found;
317
+ }
318
+
319
+ // Auto-create the endpoint when it doesn't exist yet
320
+ const created = await rpcClient.endpoints.create({ name: slug, slug });
321
+ return created as unknown as Endpoint;
322
+ }
323
+
324
+ const MACHINE_NAME_SUFFIX_RE = /^(.+)-(\d+)$/;
325
+
326
+ async function findOrCreateMachine(
327
+ endpointId: string,
328
+ forwardUrl: string,
329
+ nameOverride?: string
330
+ ): Promise<Machine> {
331
+ const baseName = nameOverride ?? getMachineName();
332
+ const machines = await fetchMachines(endpointId);
333
+
334
+ // Find an existing offline machine with this base name (or base-N variant)
335
+ // that we can reuse, so we don't leak machine records.
336
+ const ownMachines = machines.filter((m) => {
337
+ if (m.name === baseName) {
338
+ return true;
339
+ }
340
+ const match = MACHINE_NAME_SUFFIX_RE.exec(m.name);
341
+ return match?.[1] === baseName;
342
+ });
343
+
344
+ const offlineMachine = ownMachines.find((m) => m.status === "offline");
345
+ if (offlineMachine) {
346
+ // Reuse an offline machine, updating forward URL if needed
347
+ if (offlineMachine.forwardUrl !== forwardUrl) {
348
+ const updated = await rpcClient.machines.update({
349
+ id: offlineMachine.id,
350
+ forwardUrl,
351
+ });
352
+ return updated as unknown as Machine;
353
+ }
354
+ return offlineMachine;
355
+ }
356
+
357
+ // All existing machines with this name are online — create a new one
358
+ // with an incremented suffix: MacBook-Pro-2, MacBook-Pro-3, etc.
359
+ let nextName = baseName;
360
+ if (ownMachines.length > 0) {
361
+ nextName = `${baseName}-${ownMachines.length + 1}`;
362
+ }
363
+
364
+ return registerMachine(endpointId, nextName, forwardUrl);
365
+ }
366
+
367
+ // ---------------------------------------------------------------------------
368
+ // CLI command handlers (non-interactive)
369
+ // ---------------------------------------------------------------------------
370
+
371
+ async function handleLoginCommand(): Promise<never> {
372
+ const readline = await import("node:readline");
373
+ const rl = readline.createInterface({
374
+ input: process.stdin,
375
+ output: process.stdout,
376
+ });
377
+
378
+ const ask = (q: string): Promise<string> =>
379
+ new Promise((resolve) => rl.question(q, resolve));
380
+
381
+ console.log("tunnelhook login");
382
+ console.log(`Server: ${SERVER_URL}\n`);
383
+
384
+ const email = await ask("Email: ");
385
+ const password = await ask("Password: ");
386
+ rl.close();
387
+
388
+ console.log("\nSigning in...");
389
+ const result = await signIn(email, password);
390
+
391
+ if (result.success) {
392
+ console.log(
393
+ "Logged in successfully. Session saved to ~/.tunnelhook/session.json"
394
+ );
395
+ } else {
396
+ console.error(`Login failed: ${result.error ?? "Unknown error"}`);
397
+ process.exit(1);
398
+ }
399
+
400
+ process.exit(0);
401
+ }
402
+
403
+ async function handleListenCommand(
404
+ slug: string,
405
+ forwardUrl: string,
406
+ machineNameOverride?: string
407
+ ): Promise<{ endpoint: Endpoint; machine: Machine }> {
408
+ // Validate session
409
+ const valid = await validateSession();
410
+ if (!valid) {
411
+ console.error(
412
+ "Not logged in or session expired. Run `tunnelhook login` first."
413
+ );
414
+ process.exit(1);
415
+ }
416
+
417
+ // Resolve endpoint
418
+ const endpoint = await findEndpointBySlug(slug);
419
+
420
+ // Find or create machine
421
+ const machine = await findOrCreateMachine(
422
+ endpoint.id,
423
+ forwardUrl,
424
+ machineNameOverride
425
+ );
426
+
427
+ return { endpoint, machine };
428
+ }
429
+
430
+ // ---------------------------------------------------------------------------
431
+ // WebSocket helpers
432
+ // ---------------------------------------------------------------------------
433
+
434
+ interface WebhookMessage {
435
+ body: string | null;
436
+ contentType: string | null;
437
+ createdAt: string;
438
+ deliveryId: string;
439
+ eventId: string;
440
+ headers: string;
441
+ method: string;
442
+ query: string | null;
443
+ sourceIp: string | null;
444
+ type: "webhook";
445
+ }
446
+
447
+ interface DeliveryResultMessage {
448
+ deliveryId: string;
449
+ duration: number | null;
450
+ error: string | null;
451
+ eventId: string;
452
+ machineId: string;
453
+ machineName: string;
454
+ responseBody: string | null;
455
+ responseStatus: number | null;
456
+ status: "delivered" | "failed";
457
+ type: "delivery-result";
458
+ }
459
+
460
+ interface MachineStatusMessage {
461
+ machineId: string;
462
+ machineName: string;
463
+ status: "online" | "offline";
464
+ type: "machine-status";
465
+ }
466
+
467
+ type ServerMessage =
468
+ | WebhookMessage
469
+ | DeliveryResultMessage
470
+ | MachineStatusMessage;
471
+
472
+ /**
473
+ * Forward a webhook to a local URL and return the result.
474
+ */
475
+ async function forwardWebhookLocally(
476
+ forwardUrl: string,
477
+ msg: WebhookMessage
478
+ ): Promise<{
479
+ duration: number;
480
+ error: string | null;
481
+ responseBody: string | null;
482
+ responseStatus: number | null;
483
+ status: "delivered" | "failed";
484
+ }> {
485
+ const startTime = Date.now();
486
+ try {
487
+ let parsedHeaders: Record<string, string> = {};
488
+ try {
489
+ parsedHeaders = JSON.parse(msg.headers) as Record<string, string>;
490
+ } catch {
491
+ // Use empty headers
492
+ }
493
+
494
+ // Remove host header to avoid conflicts
495
+ const { host: _host, ...forwardHeaders } = parsedHeaders;
496
+
497
+ const res = await fetch(forwardUrl, {
498
+ method: msg.method,
499
+ headers: {
500
+ ...forwardHeaders,
501
+ host: new URL(forwardUrl).host,
502
+ "x-tunnelhook-event-id": msg.eventId,
503
+ "x-tunnelhook-delivery-id": msg.deliveryId,
504
+ },
505
+ body:
506
+ msg.method !== "GET" && msg.method !== "HEAD" ? msg.body : undefined,
507
+ });
508
+
509
+ const duration = Date.now() - startTime;
510
+ let responseBody: string | null = null;
511
+ try {
512
+ responseBody = await res.text();
513
+ if (responseBody.length > 10_000) {
514
+ responseBody = responseBody.slice(0, 10_000);
515
+ }
516
+ } catch {
517
+ // No response body
518
+ }
519
+
520
+ return {
521
+ status: res.ok ? "delivered" : "failed",
522
+ responseStatus: res.status,
523
+ responseBody,
524
+ error: null,
525
+ duration,
526
+ };
527
+ } catch (err) {
528
+ const duration = Date.now() - startTime;
529
+ return {
530
+ status: "failed",
531
+ responseStatus: null,
532
+ responseBody: null,
533
+ error: err instanceof Error ? err.message : "Unknown error",
534
+ duration,
535
+ };
536
+ }
537
+ }
538
+
539
+ // ---------------------------------------------------------------------------
540
+ // Color theme
541
+ // ---------------------------------------------------------------------------
542
+
543
+ const COLORS = {
544
+ bg: "#0d1117",
545
+ panel: "#161b22",
546
+ border: "#30363d",
547
+ text: "#c9d1d9",
548
+ textDim: "#8b949e",
549
+ accent: "#58a6ff",
550
+ accentBright: "#79c0ff",
551
+ green: "#3fb950",
552
+ red: "#f85149",
553
+ yellow: "#d29922",
554
+ purple: "#bc8cff",
555
+ };
556
+
557
+ const METHOD_COLORS: Record<string, string> = {
558
+ GET: "#3fb950",
559
+ POST: "#58a6ff",
560
+ PUT: "#d29922",
561
+ PATCH: "#d29922",
562
+ DELETE: "#f85149",
563
+ };
564
+
565
+ function methodColor(method: string): string {
566
+ return METHOD_COLORS[method.toUpperCase()] ?? COLORS.text;
567
+ }
568
+
569
+ function statusColor(status: number | null): string {
570
+ if (status === null) {
571
+ return COLORS.textDim;
572
+ }
573
+ if (status >= 200 && status < 300) {
574
+ return COLORS.green;
575
+ }
576
+ if (status >= 300 && status < 400) {
577
+ return COLORS.yellow;
578
+ }
579
+ return COLORS.red;
580
+ }
581
+
582
+ // ---------------------------------------------------------------------------
583
+ // Components
584
+ // ---------------------------------------------------------------------------
585
+
586
+ function StatusBar({
587
+ screen,
588
+ endpointName,
589
+ }: {
590
+ screen: Screen;
591
+ endpointName?: string;
592
+ }) {
593
+ const breadcrumbMap: Record<Screen, string> = {
594
+ login: "Login",
595
+ endpoints: "Endpoints",
596
+ "machine-setup": `${endpointName ?? "..."} > Machine Setup`,
597
+ monitor: `${endpointName ?? "..."} > Live Monitor`,
598
+ "event-detail": `${endpointName ?? "..."} > Detail`,
599
+ };
600
+ const breadcrumb = breadcrumbMap[screen];
601
+
602
+ return (
603
+ <box
604
+ backgroundColor={COLORS.accent}
605
+ flexDirection="row"
606
+ height={1}
607
+ justifyContent="space-between"
608
+ paddingX={1}
609
+ >
610
+ <text fg="#ffffff">
611
+ <strong>tunnelhook</strong>
612
+ </text>
613
+ <text fg="#ffffff">{breadcrumb}</text>
614
+ <text fg="#ffffff">
615
+ {screen === "login" ? "Enter:submit Tab:switch" : "q:quit esc:back"}
616
+ </text>
617
+ </box>
618
+ );
619
+ }
620
+
621
+ function HelpBar({ screen }: { screen: Screen }) {
622
+ const hints: Record<Screen, string> = {
623
+ login: "Tab: switch fields Enter: submit",
624
+ endpoints: "j/k: navigate Enter: select n: new r: refresh q: quit",
625
+ "machine-setup": "j/k: navigate Enter: select n: new machine esc: back",
626
+ monitor: "j/k: navigate Enter: detail esc: back q: quit",
627
+ "event-detail":
628
+ "1: body 2: headers 3: deliveries Tab: switch esc: back",
629
+ };
630
+
631
+ return (
632
+ <box
633
+ backgroundColor={COLORS.panel}
634
+ flexDirection="row"
635
+ gap={2}
636
+ height={1}
637
+ paddingX={1}
638
+ >
639
+ <text fg={COLORS.textDim}>{hints[screen]}</text>
640
+ </box>
641
+ );
642
+ }
643
+
644
+ // ---------------------------------------------------------------------------
645
+ // Login Screen
646
+ // ---------------------------------------------------------------------------
647
+
648
+ function LoginScreen({ onLogin }: { onLogin: () => void }) {
649
+ const [email, setEmail] = useState("");
650
+ const [password, setPassword] = useState("");
651
+ const [focusField, setFocusField] = useState<"email" | "password">("email");
652
+ const [error, setError] = useState<string | null>(null);
653
+ const [loading, setLoading] = useState(false);
654
+
655
+ const handleSubmit = useCallback(() => {
656
+ if (loading) {
657
+ return;
658
+ }
659
+ setLoading(true);
660
+ setError(null);
661
+ signIn(email, password).then((result) => {
662
+ setLoading(false);
663
+ if (result.success) {
664
+ onLogin();
665
+ } else {
666
+ setError(result.error ?? "Login failed");
667
+ }
668
+ });
669
+ }, [email, password, loading, onLogin]);
670
+
671
+ useKeyboard((key) => {
672
+ if (key.name === "tab") {
673
+ setFocusField((prev: "email" | "password") =>
674
+ prev === "email" ? "password" : "email"
675
+ );
676
+ }
677
+ if ((key.name === "enter" || key.name === "return") && !loading) {
678
+ handleSubmit();
679
+ }
680
+ });
681
+
682
+ return (
683
+ <box
684
+ alignItems="center"
685
+ backgroundColor={COLORS.bg}
686
+ flexGrow={1}
687
+ justifyContent="center"
688
+ >
689
+ <box
690
+ backgroundColor={COLORS.panel}
691
+ border
692
+ borderColor={COLORS.border}
693
+ borderStyle="rounded"
694
+ padding={2}
695
+ width={50}
696
+ >
697
+ <box flexDirection="column" gap={1}>
698
+ <ascii-font color={COLORS.accent} font="tiny" text="tunnelhook" />
699
+ <text fg={COLORS.textDim}>
700
+ Sign in to manage your webhook endpoints
701
+ </text>
702
+
703
+ <box height={1} />
704
+
705
+ <text fg={COLORS.text}>Email</text>
706
+ <input
707
+ backgroundColor={COLORS.bg}
708
+ focused={focusField === "email"}
709
+ focusedBackgroundColor="#1c2128"
710
+ onChange={setEmail}
711
+ placeholder="you@example.com"
712
+ textColor={COLORS.text}
713
+ value={email}
714
+ width={40}
715
+ />
716
+
717
+ <text fg={COLORS.text}>Password</text>
718
+ <input
719
+ backgroundColor={COLORS.bg}
720
+ focused={focusField === "password"}
721
+ focusedBackgroundColor="#1c2128"
722
+ onChange={setPassword}
723
+ placeholder="password"
724
+ textColor={COLORS.text}
725
+ value={password}
726
+ width={40}
727
+ />
728
+
729
+ {error ? <text fg={COLORS.red}>{error}</text> : null}
730
+ {loading ? (
731
+ <text fg={COLORS.yellow}>Signing in...</text>
732
+ ) : (
733
+ <text fg={COLORS.textDim}>
734
+ Press Enter to sign in, Tab to switch fields
735
+ </text>
736
+ )}
737
+ </box>
738
+ </box>
739
+ </box>
740
+ );
741
+ }
742
+
743
+ // ---------------------------------------------------------------------------
744
+ // Endpoints List Screen
745
+ // ---------------------------------------------------------------------------
746
+
747
+ function EndpointsScreen({
748
+ onSelect,
749
+ onQuit,
750
+ }: {
751
+ onSelect: (ep: Endpoint) => void;
752
+ onQuit: () => void;
753
+ }) {
754
+ const [endpoints, setEndpoints] = useState<Endpoint[]>([]);
755
+ const [selectedIndex, setSelectedIndex] = useState(0);
756
+ const [loading, setLoading] = useState(true);
757
+ const [error, setError] = useState<string | null>(null);
758
+ const [creating, setCreating] = useState(false);
759
+ const [newName, setNewName] = useState("");
760
+
761
+ const loadEndpoints = useCallback(() => {
762
+ setLoading(true);
763
+ fetchEndpoints()
764
+ .then((eps: Endpoint[]) => {
765
+ setEndpoints(eps);
766
+ setError(null);
767
+ })
768
+ .catch((err: Error) => setError(err.message))
769
+ .finally(() => setLoading(false));
770
+ }, []);
771
+
772
+ useEffect(() => {
773
+ loadEndpoints();
774
+ }, [loadEndpoints]);
775
+
776
+ const handleCreatingKey = useCallback(
777
+ (key: { name: string }) => {
778
+ if (key.name === "escape") {
779
+ setCreating(false);
780
+ setNewName("");
781
+ }
782
+ if ((key.name === "enter" || key.name === "return") && newName.trim()) {
783
+ createEndpointApi(newName.trim())
784
+ .then(() => {
785
+ setCreating(false);
786
+ setNewName("");
787
+ loadEndpoints();
788
+ })
789
+ .catch((err: Error) => setError(err.message));
790
+ }
791
+ },
792
+ [newName, loadEndpoints]
793
+ );
794
+
795
+ const isEnter = (name: string) => name === "enter" || name === "return";
796
+
797
+ useKeyboard((key) => {
798
+ if (creating) {
799
+ handleCreatingKey(key);
800
+ return;
801
+ }
802
+
803
+ if (key.name === "q" || (key.ctrl && key.name === "c")) {
804
+ onQuit();
805
+ return;
806
+ }
807
+
808
+ if (key.name === "j" || key.name === "down") {
809
+ setSelectedIndex((idx: number) =>
810
+ Math.min(endpoints.length - 1, idx + 1)
811
+ );
812
+ }
813
+ if (key.name === "k" || key.name === "up") {
814
+ setSelectedIndex((idx: number) => Math.max(0, idx - 1));
815
+ }
816
+ if (isEnter(key.name) && endpoints[selectedIndex]) {
817
+ onSelect(endpoints[selectedIndex]);
818
+ }
819
+ if (key.name === "n") {
820
+ setCreating(true);
821
+ }
822
+ if (key.name === "r") {
823
+ loadEndpoints();
824
+ }
825
+ });
826
+
827
+ const { height } = useTerminalDimensions();
828
+
829
+ return (
830
+ <box backgroundColor={COLORS.bg} flexDirection="column" flexGrow={1}>
831
+ <box flexDirection="row" gap={2} height={3} paddingX={1} paddingY={1}>
832
+ <text fg={COLORS.text}>
833
+ <strong>Webhook Endpoints</strong>
834
+ </text>
835
+ <text fg={COLORS.textDim}>({endpoints.length} total)</text>
836
+ {loading ? <text fg={COLORS.yellow}>loading...</text> : null}
837
+ </box>
838
+
839
+ {creating ? (
840
+ <box flexDirection="row" gap={1} height={3} paddingX={1}>
841
+ <text fg={COLORS.accent}>New endpoint name:</text>
842
+ <input
843
+ backgroundColor={COLORS.bg}
844
+ focused
845
+ focusedBackgroundColor="#1c2128"
846
+ onChange={setNewName}
847
+ placeholder="My Webhook"
848
+ textColor={COLORS.text}
849
+ value={newName}
850
+ width={30}
851
+ />
852
+ <text fg={COLORS.textDim}>(enter to create, esc to cancel)</text>
853
+ </box>
854
+ ) : null}
855
+
856
+ {error ? (
857
+ <box height={1} paddingX={1}>
858
+ <text fg={COLORS.red}>Error: {error}</text>
859
+ </box>
860
+ ) : null}
861
+
862
+ <scrollbox focused={!creating} height={height - 8}>
863
+ {endpoints.map((ep: Endpoint, idx: number) => (
864
+ <box
865
+ alignItems="center"
866
+ backgroundColor={idx === selectedIndex ? "#1c2128" : "transparent"}
867
+ flexDirection="row"
868
+ gap={2}
869
+ height={3}
870
+ key={ep.id}
871
+ paddingX={2}
872
+ paddingY={0}
873
+ >
874
+ <text
875
+ fg={idx === selectedIndex ? COLORS.accent : COLORS.text}
876
+ width={3}
877
+ >
878
+ {idx === selectedIndex ? " > " : " "}
879
+ </text>
880
+ <box flexDirection="column" flexGrow={1}>
881
+ <text
882
+ fg={idx === selectedIndex ? COLORS.accentBright : COLORS.text}
883
+ >
884
+ <strong>{ep.name}</strong>
885
+ </text>
886
+ <text fg={COLORS.textDim}>
887
+ {SERVER_URL}/hooks/{ep.slug}
888
+ </text>
889
+ </box>
890
+ <text fg={ep.enabled ? COLORS.green : COLORS.red}>
891
+ {ep.enabled ? "active" : "disabled"}
892
+ </text>
893
+ </box>
894
+ ))}
895
+ {endpoints.length === 0 && !loading ? (
896
+ <box paddingX={2} paddingY={1}>
897
+ <text fg={COLORS.textDim}>
898
+ No endpoints yet. Press 'n' to create one.
899
+ </text>
900
+ </box>
901
+ ) : null}
902
+ </scrollbox>
903
+ </box>
904
+ );
905
+ }
906
+
907
+ // ---------------------------------------------------------------------------
908
+ // Machine Setup Screen
909
+ // ---------------------------------------------------------------------------
910
+
911
+ function MachineSetupScreen({
912
+ endpoint: ep,
913
+ onConnect,
914
+ onBack,
915
+ onQuit,
916
+ }: {
917
+ endpoint: Endpoint;
918
+ onConnect: (machine: Machine) => void;
919
+ onBack: () => void;
920
+ onQuit: () => void;
921
+ }) {
922
+ const [machines, setMachines] = useState<Machine[]>([]);
923
+ const [selectedIndex, setSelectedIndex] = useState(0);
924
+ const [loading, setLoading] = useState(true);
925
+ const [creating, setCreating] = useState(false);
926
+ const [newName, setNewName] = useState("");
927
+ const [newUrl, setNewUrl] = useState("http://localhost:3000/webhook");
928
+ const [createFocus, setCreateFocus] = useState<"name" | "url">("name");
929
+ const [error, setError] = useState<string | null>(null);
930
+
931
+ const loadMachines = useCallback(() => {
932
+ setLoading(true);
933
+ fetchMachines(ep.id)
934
+ .then((ms: Machine[]) => {
935
+ setMachines(ms);
936
+ setError(null);
937
+ })
938
+ .catch((err: Error) => setError(err.message))
939
+ .finally(() => setLoading(false));
940
+ }, [ep.id]);
941
+
942
+ useEffect(() => {
943
+ loadMachines();
944
+ }, [loadMachines]);
945
+
946
+ const handleCreateSubmit = useCallback(() => {
947
+ if (!(newName.trim() && newUrl.trim())) {
948
+ return;
949
+ }
950
+ setError(null);
951
+ registerMachine(ep.id, newName.trim(), newUrl.trim())
952
+ .then((m: Machine) => {
953
+ setCreating(false);
954
+ setNewName("");
955
+ setNewUrl("http://localhost:3000/webhook");
956
+ onConnect(m);
957
+ })
958
+ .catch((err: Error) => setError(err.message));
959
+ }, [ep.id, newName, newUrl, onConnect]);
960
+
961
+ const isEnter = (name: string) => name === "enter" || name === "return";
962
+
963
+ useKeyboard((key) => {
964
+ if (creating) {
965
+ if (key.name === "escape") {
966
+ setCreating(false);
967
+ setNewName("");
968
+ setNewUrl("http://localhost:3000/webhook");
969
+ return;
970
+ }
971
+ if (key.name === "tab") {
972
+ setCreateFocus((prev) => (prev === "name" ? "url" : "name"));
973
+ return;
974
+ }
975
+ if (isEnter(key.name)) {
976
+ handleCreateSubmit();
977
+ return;
978
+ }
979
+ return;
980
+ }
981
+
982
+ if (key.name === "q" || (key.ctrl && key.name === "c")) {
983
+ onQuit();
984
+ return;
985
+ }
986
+ if (key.name === "escape") {
987
+ onBack();
988
+ return;
989
+ }
990
+
991
+ if (key.name === "j" || key.name === "down") {
992
+ setSelectedIndex((idx: number) => Math.min(machines.length - 1, idx + 1));
993
+ }
994
+ if (key.name === "k" || key.name === "up") {
995
+ setSelectedIndex((idx: number) => Math.max(0, idx - 1));
996
+ }
997
+ if (isEnter(key.name) && machines[selectedIndex]) {
998
+ onConnect(machines[selectedIndex]);
999
+ }
1000
+ if (key.name === "n") {
1001
+ setCreating(true);
1002
+ }
1003
+ });
1004
+
1005
+ const { height } = useTerminalDimensions();
1006
+
1007
+ return (
1008
+ <box backgroundColor={COLORS.bg} flexDirection="column" flexGrow={1}>
1009
+ <box
1010
+ border
1011
+ borderColor={COLORS.border}
1012
+ flexDirection="column"
1013
+ height={4}
1014
+ paddingX={1}
1015
+ >
1016
+ <text fg={COLORS.text}>
1017
+ <strong>Select or create a machine for: {ep.name}</strong>
1018
+ </text>
1019
+ <text fg={COLORS.textDim}>
1020
+ Machines forward webhooks to a local URL on your computer
1021
+ </text>
1022
+ </box>
1023
+
1024
+ {creating ? (
1025
+ <box
1026
+ border
1027
+ borderColor={COLORS.accent}
1028
+ flexDirection="column"
1029
+ gap={1}
1030
+ marginX={1}
1031
+ marginY={1}
1032
+ padding={1}
1033
+ >
1034
+ <text fg={COLORS.accent}>
1035
+ <strong>Register New Machine</strong>
1036
+ </text>
1037
+ <text fg={COLORS.text}>Machine Name</text>
1038
+ <input
1039
+ backgroundColor={COLORS.bg}
1040
+ focused={createFocus === "name"}
1041
+ focusedBackgroundColor="#1c2128"
1042
+ onChange={setNewName}
1043
+ placeholder="My MacBook"
1044
+ textColor={COLORS.text}
1045
+ value={newName}
1046
+ width={40}
1047
+ />
1048
+ <text fg={COLORS.text}>Forward URL</text>
1049
+ <input
1050
+ backgroundColor={COLORS.bg}
1051
+ focused={createFocus === "url"}
1052
+ focusedBackgroundColor="#1c2128"
1053
+ onChange={setNewUrl}
1054
+ placeholder="http://localhost:3000/webhook"
1055
+ textColor={COLORS.text}
1056
+ value={newUrl}
1057
+ width={50}
1058
+ />
1059
+ {error ? <text fg={COLORS.red}>{error}</text> : null}
1060
+ <text fg={COLORS.textDim}>
1061
+ Enter: create and connect | Tab: switch fields | Esc: cancel
1062
+ </text>
1063
+ </box>
1064
+ ) : null}
1065
+
1066
+ <scrollbox focused={!creating} height={height - (creating ? 18 : 8)}>
1067
+ {machines.map((m: Machine, idx: number) => (
1068
+ <box
1069
+ alignItems="center"
1070
+ backgroundColor={idx === selectedIndex ? "#1c2128" : "transparent"}
1071
+ flexDirection="row"
1072
+ gap={2}
1073
+ height={3}
1074
+ key={m.id}
1075
+ paddingX={2}
1076
+ >
1077
+ <text
1078
+ fg={idx === selectedIndex ? COLORS.accent : COLORS.text}
1079
+ width={3}
1080
+ >
1081
+ {idx === selectedIndex ? " > " : " "}
1082
+ </text>
1083
+ <box flexDirection="column" flexGrow={1}>
1084
+ <text
1085
+ fg={idx === selectedIndex ? COLORS.accentBright : COLORS.text}
1086
+ >
1087
+ <strong>{m.name}</strong>
1088
+ </text>
1089
+ <text fg={COLORS.textDim}>{m.forwardUrl}</text>
1090
+ </box>
1091
+ <text fg={m.status === "online" ? COLORS.green : COLORS.textDim}>
1092
+ {m.status}
1093
+ </text>
1094
+ </box>
1095
+ ))}
1096
+ {machines.length === 0 && !loading ? (
1097
+ <box paddingX={2} paddingY={1}>
1098
+ <text fg={COLORS.textDim}>
1099
+ No machines registered. Press 'n' to create one.
1100
+ </text>
1101
+ </box>
1102
+ ) : null}
1103
+ {loading ? (
1104
+ <box paddingX={2} paddingY={1}>
1105
+ <text fg={COLORS.yellow}>Loading machines...</text>
1106
+ </box>
1107
+ ) : null}
1108
+ </scrollbox>
1109
+ </box>
1110
+ );
1111
+ }
1112
+
1113
+ // ---------------------------------------------------------------------------
1114
+ // Live Monitor Screen (WebSocket machine connection)
1115
+ // ---------------------------------------------------------------------------
1116
+
1117
+ interface MonitorEvent {
1118
+ deliveryId: string;
1119
+ deliveryResult?: DeliveryResult;
1120
+ event: WebhookEvent;
1121
+ eventId: string;
1122
+ }
1123
+
1124
+ function MonitorScreen({
1125
+ endpoint: ep,
1126
+ machine: mach,
1127
+ onBack,
1128
+ onSelectEvent,
1129
+ onQuit,
1130
+ }: {
1131
+ endpoint: Endpoint;
1132
+ machine: Machine;
1133
+ onBack: () => void;
1134
+ onSelectEvent: (evt: MonitorEvent) => void;
1135
+ onQuit: () => void;
1136
+ }) {
1137
+ const [events, setEvents] = useState<MonitorEvent[]>([]);
1138
+ const [selectedIndex, setSelectedIndex] = useState(0);
1139
+ const [connected, setConnected] = useState(false);
1140
+ const [wsStatus, setWsStatus] = useState<string>("connecting...");
1141
+ const wsRef = useRef<WebSocket | null>(null);
1142
+
1143
+ // Connect to WebSocket as a machine
1144
+ useEffect(() => {
1145
+ const wsUrl = `${WS_URL}/hooks/${ep.slug}/ws?role=machine&machineId=${mach.id}&machineName=${encodeURIComponent(mach.name)}`;
1146
+
1147
+ let ws: WebSocket;
1148
+ try {
1149
+ ws = new WebSocket(wsUrl, {
1150
+ headers: getAuthHeaders(),
1151
+ } as unknown as string[]);
1152
+ } catch {
1153
+ setWsStatus("failed to create WebSocket");
1154
+ return;
1155
+ }
1156
+
1157
+ wsRef.current = ws;
1158
+
1159
+ ws.addEventListener("open", () => {
1160
+ setConnected(true);
1161
+ setWsStatus("connected");
1162
+ });
1163
+
1164
+ ws.addEventListener("message", (msgEvent) => {
1165
+ const data = msgEvent.data;
1166
+ if (typeof data !== "string") {
1167
+ return;
1168
+ }
1169
+
1170
+ let parsed: ServerMessage;
1171
+ try {
1172
+ parsed = JSON.parse(data) as ServerMessage;
1173
+ } catch {
1174
+ return;
1175
+ }
1176
+
1177
+ if (parsed.type === "webhook") {
1178
+ const webhookMsg = parsed as WebhookMessage;
1179
+ const monitorEvent: MonitorEvent = {
1180
+ eventId: webhookMsg.eventId,
1181
+ deliveryId: webhookMsg.deliveryId,
1182
+ event: {
1183
+ method: webhookMsg.method,
1184
+ headers: webhookMsg.headers,
1185
+ body: webhookMsg.body,
1186
+ query: webhookMsg.query,
1187
+ contentType: webhookMsg.contentType,
1188
+ sourceIp: webhookMsg.sourceIp,
1189
+ createdAt: webhookMsg.createdAt,
1190
+ },
1191
+ };
1192
+
1193
+ setEvents((prev) => [monitorEvent, ...prev]);
1194
+
1195
+ // Forward locally and report back
1196
+ forwardWebhookLocally(mach.forwardUrl, webhookMsg).then((result) => {
1197
+ // Send delivery report back via WebSocket
1198
+ const report = {
1199
+ type: "delivery-report" as const,
1200
+ eventId: webhookMsg.eventId,
1201
+ deliveryId: webhookMsg.deliveryId,
1202
+ status: result.status,
1203
+ responseStatus: result.responseStatus,
1204
+ responseBody: result.responseBody,
1205
+ error: result.error,
1206
+ duration: result.duration,
1207
+ };
1208
+
1209
+ try {
1210
+ ws.send(JSON.stringify(report));
1211
+ } catch {
1212
+ // WebSocket closed
1213
+ }
1214
+
1215
+ // Also persist via oRPC
1216
+ reportDeliveryResult({
1217
+ deliveryId: webhookMsg.deliveryId,
1218
+ status: result.status,
1219
+ responseStatus: result.responseStatus,
1220
+ responseBody: result.responseBody,
1221
+ error: result.error,
1222
+ duration: result.duration,
1223
+ }).catch(() => {
1224
+ // Non-critical — delivery result already sent via WS
1225
+ });
1226
+
1227
+ // Update local state with delivery result
1228
+ setEvents((prev) =>
1229
+ prev.map((e) =>
1230
+ e.deliveryId === webhookMsg.deliveryId
1231
+ ? {
1232
+ ...e,
1233
+ deliveryResult: {
1234
+ deliveryId: webhookMsg.deliveryId,
1235
+ eventId: webhookMsg.eventId,
1236
+ machineId: mach.id,
1237
+ machineName: mach.name,
1238
+ status: result.status,
1239
+ responseStatus: result.responseStatus,
1240
+ responseBody: result.responseBody,
1241
+ error: result.error,
1242
+ duration: result.duration,
1243
+ },
1244
+ }
1245
+ : e
1246
+ )
1247
+ );
1248
+ });
1249
+ }
1250
+
1251
+ if (parsed.type === "delivery-result") {
1252
+ // Delivery result from another machine — update if we have the event
1253
+ const resultMsg = parsed as DeliveryResultMessage;
1254
+ setEvents((prev) =>
1255
+ prev.map((e) =>
1256
+ e.eventId === resultMsg.eventId && !e.deliveryResult
1257
+ ? {
1258
+ ...e,
1259
+ deliveryResult: {
1260
+ deliveryId: resultMsg.deliveryId,
1261
+ eventId: resultMsg.eventId,
1262
+ machineId: resultMsg.machineId,
1263
+ machineName: resultMsg.machineName,
1264
+ status: resultMsg.status,
1265
+ responseStatus: resultMsg.responseStatus,
1266
+ responseBody: resultMsg.responseBody,
1267
+ error: resultMsg.error,
1268
+ duration: resultMsg.duration,
1269
+ },
1270
+ }
1271
+ : e
1272
+ )
1273
+ );
1274
+ }
1275
+ });
1276
+
1277
+ ws.addEventListener("close", () => {
1278
+ setConnected(false);
1279
+ setWsStatus("disconnected");
1280
+ });
1281
+
1282
+ ws.addEventListener("error", () => {
1283
+ setWsStatus("connection error");
1284
+ });
1285
+
1286
+ return () => {
1287
+ ws.close();
1288
+ };
1289
+ }, [ep.slug, mach.id, mach.name, mach.forwardUrl]);
1290
+
1291
+ useKeyboard((key) => {
1292
+ if (key.name === "q" || (key.ctrl && key.name === "c")) {
1293
+ onQuit();
1294
+ return;
1295
+ }
1296
+ if (key.name === "escape") {
1297
+ onBack();
1298
+ return;
1299
+ }
1300
+ if (key.name === "j" || key.name === "down") {
1301
+ setSelectedIndex((idx: number) => Math.min(events.length - 1, idx + 1));
1302
+ }
1303
+ if (key.name === "k" || key.name === "up") {
1304
+ setSelectedIndex((idx: number) => Math.max(0, idx - 1));
1305
+ }
1306
+ if (
1307
+ (key.name === "enter" || key.name === "return") &&
1308
+ events[selectedIndex]
1309
+ ) {
1310
+ onSelectEvent(events[selectedIndex]);
1311
+ }
1312
+ });
1313
+
1314
+ const { width, height } = useTerminalDimensions();
1315
+ const webhookUrl = `${SERVER_URL}/hooks/${ep.slug}`;
1316
+
1317
+ return (
1318
+ <box backgroundColor={COLORS.bg} flexDirection="column" flexGrow={1}>
1319
+ {/* Header */}
1320
+ <box
1321
+ border
1322
+ borderColor={COLORS.border}
1323
+ flexDirection="column"
1324
+ height={6}
1325
+ paddingX={1}
1326
+ paddingY={1}
1327
+ >
1328
+ <box flexDirection="row" gap={2}>
1329
+ <text fg={COLORS.text}>
1330
+ <strong>{ep.name}</strong>
1331
+ </text>
1332
+ <text fg={connected ? COLORS.green : COLORS.yellow}>
1333
+ [ {wsStatus} ]
1334
+ </text>
1335
+ </box>
1336
+ <text fg={COLORS.accent}>{webhookUrl}</text>
1337
+ <box flexDirection="row" gap={2}>
1338
+ <text fg={COLORS.textDim}>
1339
+ Machine: <span fg={COLORS.purple}>{mach.name}</span>
1340
+ </text>
1341
+ <text fg={COLORS.textDim}>
1342
+ Forward: <span fg={COLORS.accent}>{mach.forwardUrl}</span>
1343
+ </text>
1344
+ </box>
1345
+ <text fg={COLORS.textDim}>
1346
+ {events.length} events received this session
1347
+ </text>
1348
+ </box>
1349
+
1350
+ {/* Events list */}
1351
+ <scrollbox focused height={height - 10}>
1352
+ {events.map((me: MonitorEvent, idx: number) => {
1353
+ const time = new Date(me.event.createdAt).toLocaleTimeString();
1354
+ const dr = me.deliveryResult;
1355
+
1356
+ let statusText = "pending";
1357
+ let statusFg = COLORS.yellow;
1358
+ if (dr) {
1359
+ statusText =
1360
+ dr.status === "delivered"
1361
+ ? `${String(dr.responseStatus ?? "?")} ${dr.duration ?? "?"}ms`
1362
+ : `failed${dr.error ? `: ${dr.error.slice(0, 30)}` : ""}`;
1363
+ statusFg =
1364
+ dr.status === "delivered"
1365
+ ? statusColor(dr.responseStatus)
1366
+ : COLORS.red;
1367
+ }
1368
+
1369
+ let bodyPreview = "";
1370
+ if (me.event.body) {
1371
+ try {
1372
+ const parsed = JSON.parse(me.event.body);
1373
+ bodyPreview = JSON.stringify(parsed).slice(
1374
+ 0,
1375
+ Math.max(0, width - 60)
1376
+ );
1377
+ } catch {
1378
+ bodyPreview = me.event.body.slice(0, Math.max(0, width - 60));
1379
+ }
1380
+ }
1381
+
1382
+ return (
1383
+ <box
1384
+ alignItems="center"
1385
+ backgroundColor={
1386
+ idx === selectedIndex ? "#1c2128" : "transparent"
1387
+ }
1388
+ flexDirection="row"
1389
+ gap={1}
1390
+ height={2}
1391
+ key={me.deliveryId}
1392
+ paddingX={2}
1393
+ >
1394
+ <text
1395
+ fg={idx === selectedIndex ? COLORS.accent : COLORS.textDim}
1396
+ width={3}
1397
+ >
1398
+ {idx === selectedIndex ? " > " : " "}
1399
+ </text>
1400
+ <text fg={methodColor(me.event.method)} width={7}>
1401
+ <strong>{me.event.method.padEnd(6)}</strong>
1402
+ </text>
1403
+ <text fg={COLORS.textDim} width={10}>
1404
+ {time}
1405
+ </text>
1406
+ <text fg={statusFg} width={20}>
1407
+ {statusText}
1408
+ </text>
1409
+ <text fg={COLORS.text}>{bodyPreview || "(no body)"}</text>
1410
+ </box>
1411
+ );
1412
+ })}
1413
+ {events.length === 0 ? (
1414
+ <box padding={2}>
1415
+ <text fg={COLORS.textDim}>
1416
+ Waiting for webhooks... Send a request to: {webhookUrl}
1417
+ </text>
1418
+ </box>
1419
+ ) : null}
1420
+ </scrollbox>
1421
+ </box>
1422
+ );
1423
+ }
1424
+
1425
+ // ---------------------------------------------------------------------------
1426
+ // Event Detail Screen
1427
+ // ---------------------------------------------------------------------------
1428
+
1429
+ function EventDetailScreen({
1430
+ monitorEvent,
1431
+ onBack,
1432
+ onQuit,
1433
+ }: {
1434
+ monitorEvent: MonitorEvent;
1435
+ onBack: () => void;
1436
+ onQuit: () => void;
1437
+ }) {
1438
+ const [tab, setTab] = useState<"body" | "headers" | "delivery">("body");
1439
+ const evt = monitorEvent.event;
1440
+ const dr = monitorEvent.deliveryResult;
1441
+
1442
+ useKeyboard((key) => {
1443
+ if (key.name === "q" || (key.ctrl && key.name === "c")) {
1444
+ onQuit();
1445
+ return;
1446
+ }
1447
+ if (key.name === "escape") {
1448
+ onBack();
1449
+ return;
1450
+ }
1451
+ if (key.name === "1") {
1452
+ setTab("body");
1453
+ }
1454
+ if (key.name === "2") {
1455
+ setTab("headers");
1456
+ }
1457
+ if (key.name === "3") {
1458
+ setTab("delivery");
1459
+ }
1460
+ if (key.name === "tab") {
1461
+ setTab((prev) => {
1462
+ if (prev === "body") {
1463
+ return "headers";
1464
+ }
1465
+ if (prev === "headers") {
1466
+ return "delivery";
1467
+ }
1468
+ return "body";
1469
+ });
1470
+ }
1471
+ });
1472
+
1473
+ const time = new Date(evt.createdAt).toLocaleString();
1474
+ let formattedBody = evt.body ?? "(no body)";
1475
+ try {
1476
+ if (evt.body) {
1477
+ formattedBody = JSON.stringify(JSON.parse(evt.body), null, 2);
1478
+ }
1479
+ } catch {
1480
+ // Use raw body
1481
+ }
1482
+
1483
+ let formattedHeaders = "{}";
1484
+ try {
1485
+ formattedHeaders = JSON.stringify(JSON.parse(evt.headers), null, 2);
1486
+ } catch {
1487
+ formattedHeaders = evt.headers;
1488
+ }
1489
+
1490
+ let deliveryContent = "No delivery result yet (pending)";
1491
+ if (dr) {
1492
+ const deliveryInfo = {
1493
+ deliveryId: dr.deliveryId,
1494
+ status: dr.status,
1495
+ machineId: dr.machineId,
1496
+ machineName: dr.machineName,
1497
+ responseStatus: dr.responseStatus,
1498
+ duration: dr.duration ? `${dr.duration}ms` : null,
1499
+ error: dr.error,
1500
+ responseBody: dr.responseBody,
1501
+ };
1502
+ deliveryContent = JSON.stringify(deliveryInfo, null, 2);
1503
+ }
1504
+
1505
+ let content: string;
1506
+ if (tab === "body") {
1507
+ content = formattedBody;
1508
+ } else if (tab === "headers") {
1509
+ content = formattedHeaders;
1510
+ } else {
1511
+ content = deliveryContent;
1512
+ }
1513
+
1514
+ const { height } = useTerminalDimensions();
1515
+
1516
+ return (
1517
+ <box backgroundColor={COLORS.bg} flexDirection="column" flexGrow={1}>
1518
+ {/* Meta info */}
1519
+ <box
1520
+ border
1521
+ borderColor={COLORS.border}
1522
+ flexDirection="column"
1523
+ height={5}
1524
+ paddingX={1}
1525
+ paddingY={1}
1526
+ >
1527
+ <box flexDirection="row" gap={2}>
1528
+ <text fg={methodColor(evt.method)}>
1529
+ <strong>{evt.method}</strong>
1530
+ </text>
1531
+ <text fg={COLORS.text}>{monitorEvent.eventId}</text>
1532
+ {dr ? (
1533
+ <text fg={dr.status === "delivered" ? COLORS.green : COLORS.red}>
1534
+ {dr.status} {dr.responseStatus ?? ""}{" "}
1535
+ {dr.duration ? `${dr.duration}ms` : ""}
1536
+ </text>
1537
+ ) : (
1538
+ <text fg={COLORS.yellow}>pending</text>
1539
+ )}
1540
+ </box>
1541
+ <box flexDirection="row" gap={2}>
1542
+ <text fg={COLORS.textDim}>{time}</text>
1543
+ <text fg={COLORS.textDim}>
1544
+ {evt.contentType ?? "no content-type"}
1545
+ </text>
1546
+ {evt.sourceIp ? (
1547
+ <text fg={COLORS.textDim}>from {evt.sourceIp}</text>
1548
+ ) : null}
1549
+ </box>
1550
+ <box flexDirection="row" gap={2}>
1551
+ <text fg={tab === "body" ? COLORS.accent : COLORS.textDim}>
1552
+ [1] Body
1553
+ </text>
1554
+ <text fg={tab === "headers" ? COLORS.accent : COLORS.textDim}>
1555
+ [2] Headers
1556
+ </text>
1557
+ <text fg={tab === "delivery" ? COLORS.accent : COLORS.textDim}>
1558
+ [3] Delivery
1559
+ </text>
1560
+ </box>
1561
+ </box>
1562
+
1563
+ {/* Content */}
1564
+ <scrollbox focused height={height - 9}>
1565
+ <box padding={1}>
1566
+ {content.split("\n").map((line: string, idx: number) => (
1567
+ <box flexDirection="row" key={`line-${String(idx)}`}>
1568
+ <text fg={COLORS.textDim} width={5}>
1569
+ {String(idx + 1).padStart(4)}
1570
+ </text>
1571
+ <text fg={COLORS.text}>{line}</text>
1572
+ </box>
1573
+ ))}
1574
+ </box>
1575
+ </scrollbox>
1576
+ </box>
1577
+ );
1578
+ }
1579
+
1580
+ // ---------------------------------------------------------------------------
1581
+ // App root
1582
+ // ---------------------------------------------------------------------------
1583
+
1584
+ interface AppProps {
1585
+ /** Pre-resolved endpoint for direct CLI mode */
1586
+ initialEndpoint?: Endpoint;
1587
+ /** Pre-resolved machine for direct CLI mode */
1588
+ initialMachine?: Machine;
1589
+ }
1590
+
1591
+ function getStartScreen(hasDirectMode: boolean): Screen {
1592
+ if (hasDirectMode) {
1593
+ return "monitor";
1594
+ }
1595
+ if (authCookies) {
1596
+ return "endpoints";
1597
+ }
1598
+ return "login";
1599
+ }
1600
+
1601
+ function App({ initialEndpoint, initialMachine }: AppProps) {
1602
+ const renderer = useRenderer();
1603
+
1604
+ // If we have initial endpoint + machine, skip straight to monitor
1605
+ const hasDirectMode = Boolean(initialEndpoint && initialMachine);
1606
+ const startScreen = getStartScreen(hasDirectMode);
1607
+
1608
+ const [screen, setScreen] = useState<Screen>(startScreen);
1609
+ const [selectedEndpoint, setSelectedEndpoint] = useState<Endpoint | null>(
1610
+ initialEndpoint ?? null
1611
+ );
1612
+ const [selectedMachine, setSelectedMachine] = useState<Machine | null>(
1613
+ initialMachine ?? null
1614
+ );
1615
+ const [selectedEvent, setSelectedEvent] = useState<MonitorEvent | null>(null);
1616
+ const [sessionChecked, setSessionChecked] = useState(hasDirectMode);
1617
+
1618
+ // On interactive mode, validate session before showing endpoints
1619
+ useEffect(() => {
1620
+ if (hasDirectMode || sessionChecked) {
1621
+ return;
1622
+ }
1623
+ if (!authCookies) {
1624
+ setSessionChecked(true);
1625
+ return;
1626
+ }
1627
+ validateSession().then((valid) => {
1628
+ if (valid) {
1629
+ setScreen("endpoints");
1630
+ } else {
1631
+ authCookies = null;
1632
+ setScreen("login");
1633
+ }
1634
+ setSessionChecked(true);
1635
+ });
1636
+ }, [hasDirectMode, sessionChecked]);
1637
+
1638
+ const handleQuit = useCallback(() => {
1639
+ renderer.destroy();
1640
+ }, [renderer]);
1641
+
1642
+ return (
1643
+ <box backgroundColor={COLORS.bg} flexDirection="column" flexGrow={1}>
1644
+ <StatusBar endpointName={selectedEndpoint?.name} screen={screen} />
1645
+
1646
+ {screen === "login" ? (
1647
+ <LoginScreen onLogin={() => setScreen("endpoints")} />
1648
+ ) : null}
1649
+
1650
+ {screen === "endpoints" ? (
1651
+ <EndpointsScreen
1652
+ onQuit={handleQuit}
1653
+ onSelect={(ep: Endpoint) => {
1654
+ setSelectedEndpoint(ep);
1655
+ setScreen("machine-setup");
1656
+ }}
1657
+ />
1658
+ ) : null}
1659
+
1660
+ {screen === "machine-setup" && selectedEndpoint ? (
1661
+ <MachineSetupScreen
1662
+ endpoint={selectedEndpoint}
1663
+ onBack={() => {
1664
+ setScreen("endpoints");
1665
+ setSelectedEndpoint(null);
1666
+ }}
1667
+ onConnect={(m: Machine) => {
1668
+ setSelectedMachine(m);
1669
+ setScreen("monitor");
1670
+ }}
1671
+ onQuit={handleQuit}
1672
+ />
1673
+ ) : null}
1674
+
1675
+ {screen === "monitor" && selectedEndpoint && selectedMachine ? (
1676
+ <MonitorScreen
1677
+ endpoint={selectedEndpoint}
1678
+ machine={selectedMachine}
1679
+ onBack={() => {
1680
+ // In direct mode, quit instead of going back
1681
+ if (hasDirectMode) {
1682
+ handleQuit();
1683
+ return;
1684
+ }
1685
+ setScreen("machine-setup");
1686
+ setSelectedMachine(null);
1687
+ }}
1688
+ onQuit={handleQuit}
1689
+ onSelectEvent={(evt: MonitorEvent) => {
1690
+ setSelectedEvent(evt);
1691
+ setScreen("event-detail");
1692
+ }}
1693
+ />
1694
+ ) : null}
1695
+
1696
+ {screen === "event-detail" && selectedEvent ? (
1697
+ <EventDetailScreen
1698
+ monitorEvent={selectedEvent}
1699
+ onBack={() => {
1700
+ setScreen("monitor");
1701
+ setSelectedEvent(null);
1702
+ }}
1703
+ onQuit={handleQuit}
1704
+ />
1705
+ ) : null}
1706
+
1707
+ <HelpBar screen={screen} />
1708
+ </box>
1709
+ );
1710
+ }
1711
+
1712
+ // ---------------------------------------------------------------------------
1713
+ // Bootstrap
1714
+ // ---------------------------------------------------------------------------
1715
+
1716
+ if (cliArgs.command === "login") {
1717
+ await handleLoginCommand();
1718
+ } else if (cliArgs.command === "listen" && cliArgs.slug && cliArgs.forwardUrl) {
1719
+ // Direct mode: resolve endpoint + machine, then launch TUI at monitor screen
1720
+ const { endpoint, machine } = await handleListenCommand(
1721
+ cliArgs.slug,
1722
+ cliArgs.forwardUrl,
1723
+ cliArgs.machineName
1724
+ );
1725
+
1726
+ console.log(
1727
+ `Forwarding ${SERVER_URL}/hooks/${endpoint.slug} -> ${machine.forwardUrl}`
1728
+ );
1729
+ console.log(`Machine: ${machine.name} (${machine.id})\n`);
1730
+
1731
+ const renderer = await createCliRenderer({ exitOnCtrlC: false });
1732
+ createRoot(renderer).render(
1733
+ <App initialEndpoint={endpoint} initialMachine={machine} />
1734
+ );
1735
+ } else {
1736
+ // Interactive TUI mode
1737
+ const renderer = await createCliRenderer({ exitOnCtrlC: false });
1738
+ createRoot(renderer).render(<App />);
1739
+ }