uplink-cli 0.1.0-alpha.1

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,1222 @@
1
+ import { Command } from "commander";
2
+ import fetch from "node-fetch";
3
+ import { spawn, execSync } from "child_process";
4
+ import readline from "readline";
5
+ import { apiRequest } from "../http";
6
+ import { scanCommonPorts, testHttpPort } from "../utils/port-scanner";
7
+ import { homedir } from "os";
8
+ import { join } from "path";
9
+ import { existsSync, readFileSync, writeFileSync } from "fs";
10
+
11
+ type MenuChoice = {
12
+ label: string;
13
+ action?: () => Promise<string>;
14
+ subMenu?: MenuChoice[];
15
+ };
16
+
17
+ function promptLine(question: string): Promise<string> {
18
+ return new Promise((resolve) => {
19
+ try {
20
+ process.stdin.setRawMode(false);
21
+ } catch {
22
+ /* ignore */
23
+ }
24
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
25
+ rl.question(question, (answer) => {
26
+ rl.close();
27
+ resolve(answer);
28
+ });
29
+ });
30
+ }
31
+
32
+ function clearScreen() {
33
+ process.stdout.write("\x1b[2J\x1b[0f");
34
+ }
35
+
36
+ // ─────────────────────────────────────────────────────────────
37
+ // Color palette (Oxide-inspired)
38
+ // ─────────────────────────────────────────────────────────────
39
+ const c = {
40
+ reset: "\x1b[0m",
41
+ bold: "\x1b[1m",
42
+ dim: "\x1b[2m",
43
+ // Colors
44
+ cyan: "\x1b[36m",
45
+ green: "\x1b[32m",
46
+ yellow: "\x1b[33m",
47
+ red: "\x1b[31m",
48
+ magenta: "\x1b[35m",
49
+ white: "\x1b[97m",
50
+ gray: "\x1b[90m",
51
+ // Bright variants
52
+ brightCyan: "\x1b[96m",
53
+ brightGreen: "\x1b[92m",
54
+ brightYellow: "\x1b[93m",
55
+ brightWhite: "\x1b[97m",
56
+ };
57
+
58
+ function colorCyan(text: string) {
59
+ return `${c.brightCyan}${text}${c.reset}`;
60
+ }
61
+
62
+ function colorYellow(text: string) {
63
+ return `${c.yellow}${text}${c.reset}`;
64
+ }
65
+
66
+ function colorGreen(text: string) {
67
+ return `${c.brightGreen}${text}${c.reset}`;
68
+ }
69
+
70
+ function colorDim(text: string) {
71
+ return `${c.dim}${text}${c.reset}`;
72
+ }
73
+
74
+ function colorBold(text: string) {
75
+ return `${c.bold}${c.brightWhite}${text}${c.reset}`;
76
+ }
77
+
78
+ function colorRed(text: string) {
79
+ return `${c.red}${text}${c.reset}`;
80
+ }
81
+
82
+ function colorMagenta(text: string) {
83
+ return `${c.magenta}${text}${c.reset}`;
84
+ }
85
+
86
+ // ASCII banner with color styling
87
+ const ASCII_UPLINK = colorCyan([
88
+ "██╗ ██╗██████╗ ██╗ ██╗███╗ ██╗██╗ ██╗",
89
+ "██║ ██║██╔══██╗██║ ██║████╗ ██║██║ ██╔╝",
90
+ "██║ ██║██████╔╝██║ ██║██╔██╗ ██║█████╔╝ ",
91
+ "██║ ██║██╔═══╝ ██║ ██║██║╚██╗██║██╔═██╗ ",
92
+ "╚██████╔╝██║ ███████╗██║██║ ╚████║██║ ██╗",
93
+ " ╚═════╝ ╚═╝ ╚══════╝╚═╝╚═╝ ╚═══╝╚═╝ ╚═╝",
94
+ ].join("\n"));
95
+
96
+ function truncate(text: string, max: number) {
97
+ if (text.length <= max) return text;
98
+ return text.slice(0, max - 1) + "…";
99
+ }
100
+
101
+ function restoreRawMode() {
102
+ try {
103
+ process.stdin.setRawMode(true);
104
+ process.stdin.resume();
105
+ } catch {
106
+ /* ignore */
107
+ }
108
+ }
109
+
110
+ // Inline arrow-key selector (returns selected index, or -1 for "Back")
111
+ type SelectOption = { label: string; value: string | number | null };
112
+
113
+ async function inlineSelect(
114
+ title: string,
115
+ options: SelectOption[],
116
+ includeBack: boolean = true
117
+ ): Promise<{ index: number; value: string | number | null } | null> {
118
+ return new Promise((resolve) => {
119
+ // Add "Back" option if requested
120
+ const allOptions = includeBack
121
+ ? [...options, { label: "Back", value: null }]
122
+ : options;
123
+
124
+ let selected = 0;
125
+
126
+ const renderSelector = () => {
127
+ // Clear previous render (move cursor up and clear lines)
128
+ const linesToClear = allOptions.length + 3;
129
+ process.stdout.write(`\x1b[${linesToClear}A\x1b[0J`);
130
+
131
+ console.log();
132
+ console.log(colorDim(title));
133
+ console.log();
134
+
135
+ allOptions.forEach((opt, idx) => {
136
+ const isLast = idx === allOptions.length - 1;
137
+ const isSelected = idx === selected;
138
+ const branch = isLast ? "└─" : "├─";
139
+
140
+ let label: string;
141
+ let branchColor: string;
142
+
143
+ if (isSelected) {
144
+ branchColor = colorCyan(branch);
145
+ if (opt.label === "Back") {
146
+ label = colorDim(opt.label);
147
+ } else {
148
+ label = colorCyan(opt.label);
149
+ }
150
+ } else {
151
+ branchColor = colorDim(branch);
152
+ if (opt.label === "Back") {
153
+ label = colorDim(opt.label);
154
+ } else {
155
+ label = opt.label;
156
+ }
157
+ }
158
+
159
+ console.log(`${branchColor} ${label}`);
160
+ });
161
+ };
162
+
163
+ // Initial render - print blank lines first so we can clear them
164
+ console.log();
165
+ console.log(colorDim(title));
166
+ console.log();
167
+ allOptions.forEach((opt, idx) => {
168
+ const isLast = idx === allOptions.length - 1;
169
+ const branch = isLast ? "└─" : "├─";
170
+ const branchColor = idx === 0 ? colorCyan(branch) : colorDim(branch);
171
+ const label = idx === 0 ? colorCyan(opt.label) : (opt.label === "Back" ? colorDim(opt.label) : opt.label);
172
+ console.log(`${branchColor} ${label}`);
173
+ });
174
+
175
+ // Set up key handler
176
+ try {
177
+ process.stdin.setRawMode(true);
178
+ process.stdin.resume();
179
+ } catch {
180
+ /* ignore */
181
+ }
182
+
183
+ const keyHandler = (key: Buffer) => {
184
+ const str = key.toString();
185
+
186
+ if (str === "\u0003") {
187
+ // Ctrl+C
188
+ process.stdin.removeListener("data", keyHandler);
189
+ process.stdin.setRawMode(false);
190
+ process.stdin.pause();
191
+ process.exit(0);
192
+ } else if (str === "\u001b[A") {
193
+ // Up arrow
194
+ selected = (selected - 1 + allOptions.length) % allOptions.length;
195
+ renderSelector();
196
+ } else if (str === "\u001b[B") {
197
+ // Down arrow
198
+ selected = (selected + 1) % allOptions.length;
199
+ renderSelector();
200
+ } else if (str === "\u001b[D") {
201
+ // Left arrow - same as selecting "Back"
202
+ process.stdin.removeListener("data", keyHandler);
203
+ resolve(null);
204
+ } else if (str === "\r") {
205
+ // Enter
206
+ process.stdin.removeListener("data", keyHandler);
207
+ const selectedOption = allOptions[selected];
208
+ if (selectedOption.label === "Back" || selectedOption.value === null) {
209
+ resolve(null);
210
+ } else {
211
+ resolve({ index: selected, value: selectedOption.value });
212
+ }
213
+ }
214
+ };
215
+
216
+ process.stdin.on("data", keyHandler);
217
+ });
218
+ }
219
+
220
+ // Helper function to make unauthenticated requests (for signup)
221
+ async function unauthenticatedRequest(method: string, path: string, body?: unknown): Promise<any> {
222
+ const apiBase = process.env.AGENTCLOUD_API_BASE || "https://api.uplink.spot";
223
+ const response = await fetch(`${apiBase}${path}`, {
224
+ method,
225
+ headers: {
226
+ "Content-Type": "application/json",
227
+ },
228
+ body: body ? JSON.stringify(body) : undefined,
229
+ });
230
+
231
+ const json = await response.json().catch(() => ({}));
232
+ if (!response.ok) {
233
+ throw new Error(JSON.stringify(json, null, 2));
234
+ }
235
+ return json;
236
+ }
237
+
238
+ export const menuCommand = new Command("menu")
239
+ .description("Interactive terminal menu (arrow keys + enter)")
240
+ .action(async () => {
241
+ const apiBase = process.env.AGENTCLOUD_API_BASE || "https://api.uplink.spot";
242
+
243
+ // Determine role (admin or user) via /v1/me; check if auth failed
244
+ let isAdmin = false;
245
+ let authFailed = false;
246
+ try {
247
+ const me = await apiRequest("GET", "/v1/me");
248
+ isAdmin = me?.role === "admin";
249
+ } catch (err: any) {
250
+ // Check if it's an authentication error
251
+ const errorMsg = err?.message || String(err);
252
+ authFailed =
253
+ errorMsg.includes("UNAUTHORIZED") ||
254
+ errorMsg.includes("401") ||
255
+ errorMsg.includes("Missing or invalid token") ||
256
+ errorMsg.includes("Missing AGENTCLOUD_TOKEN");
257
+ isAdmin = false;
258
+ }
259
+
260
+ // Build menu structure dynamically by role and auth status
261
+ const mainMenu: MenuChoice[] = [];
262
+
263
+ // If authentication failed, show ONLY "Get Started" and "Exit"
264
+ if (authFailed) {
265
+ mainMenu.push({
266
+ label: "🚀 Get Started (Create Account)",
267
+ action: async () => {
268
+ restoreRawMode();
269
+ clearScreen();
270
+ try {
271
+ process.stdout.write("\n");
272
+ process.stdout.write(colorCyan("UPLINK") + colorDim(" │ ") + "Create Account\n");
273
+ process.stdout.write(colorDim("─".repeat(40)) + "\n\n");
274
+
275
+ const label = (await promptLine("Label (optional): ")).trim();
276
+ const expiresInput = (await promptLine("Expires in days (optional): ")).trim();
277
+ const expiresDays = expiresInput ? Number(expiresInput) : undefined;
278
+
279
+ if (expiresDays && (isNaN(expiresDays) || expiresDays <= 0)) {
280
+ restoreRawMode();
281
+ return "Invalid expiration days. Please enter a positive number or leave empty.";
282
+ }
283
+
284
+ process.stdout.write("\nCreating your token...\n");
285
+ process.stdout.write("");
286
+ let result;
287
+ try {
288
+ result = await unauthenticatedRequest("POST", "/v1/signup", {
289
+ label: label || undefined,
290
+ expiresInDays: expiresDays || undefined,
291
+ });
292
+ if (!result) {
293
+ restoreRawMode();
294
+ return "❌ Error: No response from server.";
295
+ }
296
+ } catch (err: any) {
297
+ restoreRawMode();
298
+ const errorMsg = err?.message || String(err);
299
+ console.error("\n❌ Signup error:", errorMsg);
300
+ if (errorMsg.includes("429") || errorMsg.includes("RATE_LIMIT")) {
301
+ return "⚠️ Too many signup attempts. Please try again later.";
302
+ }
303
+ return `❌ Error creating account: ${errorMsg}`;
304
+ }
305
+
306
+ if (!result || !result.token) {
307
+ restoreRawMode();
308
+ return "❌ Error: Invalid response from server. Token not received.";
309
+ }
310
+
311
+ const token = result.token;
312
+ const tokenId = result.id;
313
+ const userId = result.userId;
314
+
315
+ process.stdout.write("\n");
316
+ process.stdout.write(colorGreen("✓") + " Account created\n");
317
+ process.stdout.write("\n");
318
+ process.stdout.write(colorDim("├─") + " Token " + colorCyan(token) + "\n");
319
+ process.stdout.write(colorDim("├─") + " ID " + tokenId + "\n");
320
+ process.stdout.write(colorDim("├─") + " User " + userId + "\n");
321
+ process.stdout.write(colorDim("├─") + " Role " + result.role + "\n");
322
+ if (result.expiresAt) {
323
+ process.stdout.write(colorDim("└─") + " Expires " + result.expiresAt + "\n");
324
+ } else {
325
+ process.stdout.write(colorDim("└─") + " Expires " + colorDim("never") + "\n");
326
+ }
327
+ process.stdout.write("\n");
328
+ process.stdout.write(colorYellow("!") + " Save this token securely - shown only once\n");
329
+
330
+ // Try to automatically add token to shell config
331
+ const shell = process.env.SHELL || "";
332
+ const homeDir = homedir();
333
+ let configFile: string | null = null;
334
+ let shellName = "";
335
+
336
+ if (shell.includes("zsh")) {
337
+ configFile = join(homeDir, ".zshrc");
338
+ shellName = "zsh";
339
+ } else if (shell.includes("bash")) {
340
+ configFile = join(homeDir, ".bashrc");
341
+ shellName = "bash";
342
+ } else {
343
+ if (existsSync(join(homeDir, ".zshrc"))) {
344
+ configFile = join(homeDir, ".zshrc");
345
+ shellName = "zsh";
346
+ } else if (existsSync(join(homeDir, ".bashrc"))) {
347
+ configFile = join(homeDir, ".bashrc");
348
+ shellName = "bash";
349
+ }
350
+ }
351
+
352
+ let tokenAdded = false;
353
+ let tokenExists = false;
354
+
355
+ if (configFile) {
356
+ if (existsSync(configFile)) {
357
+ const configContent = readFileSync(configFile, "utf-8");
358
+ tokenExists = configContent.includes("AGENTCLOUD_TOKEN");
359
+ }
360
+ }
361
+
362
+ if (configFile) {
363
+ const promptText = tokenExists
364
+ ? `\n→ Update existing token in ~/.${shellName}rc? (Y/n): `
365
+ : `\n→ Add token to ~/.${shellName}rc? (Y/n): `;
366
+
367
+ const addToken = (await promptLine(promptText)).trim().toLowerCase();
368
+ if (addToken !== "n" && addToken !== "no") {
369
+ try {
370
+ if (tokenExists) {
371
+ const configContent = readFileSync(configFile, "utf-8");
372
+ const lines = configContent.split("\n");
373
+ const updatedLines = lines.map((line) => {
374
+ if (line.match(/^\s*export\s+AGENTCLOUD_TOKEN=/)) {
375
+ return `export AGENTCLOUD_TOKEN=${token}`;
376
+ }
377
+ return line;
378
+ });
379
+ const wasReplaced = updatedLines.some((line, idx) => line !== lines[idx]);
380
+ if (!wasReplaced) {
381
+ updatedLines.push(`export AGENTCLOUD_TOKEN=${token}`);
382
+ }
383
+ writeFileSync(configFile, updatedLines.join("\n"), { flag: "w", mode: 0o644 });
384
+ tokenAdded = true;
385
+ console.log(colorGreen(`\n✓ Token updated in ~/.${shellName}rc`));
386
+ const verifyContent = readFileSync(configFile, "utf-8");
387
+ if (!verifyContent.includes(`export AGENTCLOUD_TOKEN=${token}`)) {
388
+ console.log(colorYellow(`\n! Warning: Token may not have been written correctly. Please check ~/.${shellName}rc`));
389
+ }
390
+ } else {
391
+ const exportLine = `\n# Uplink API Token (added automatically)\nexport AGENTCLOUD_TOKEN=${token}\n`;
392
+ writeFileSync(configFile, exportLine, { flag: "a", mode: 0o644 });
393
+ tokenAdded = true;
394
+ console.log(colorGreen(`\n✓ Token added to ~/.${shellName}rc`));
395
+ const verifyContent = readFileSync(configFile, "utf-8");
396
+ if (!verifyContent.includes(`export AGENTCLOUD_TOKEN=${token}`)) {
397
+ console.log(colorYellow(`\n! Warning: Token may not have been written correctly. Please check ~/.${shellName}rc`));
398
+ }
399
+ }
400
+ } catch (err: any) {
401
+ console.log(colorYellow(`\n! Could not write to ~/.${shellName}rc: ${err.message}`));
402
+ console.log(`\n Please add manually:`);
403
+ console.log(colorDim(` echo 'export AGENTCLOUD_TOKEN=${token}' >> ~/.${shellName}rc`));
404
+ }
405
+ }
406
+ } else {
407
+ console.log(colorYellow(`\n→ Could not detect your shell. Add the token manually:`));
408
+ console.log(colorDim(` echo 'export AGENTCLOUD_TOKEN=${token}' >> ~/.zshrc # for zsh`));
409
+ console.log(colorDim(` echo 'export AGENTCLOUD_TOKEN=${token}' >> ~/.bashrc # for bash`));
410
+ }
411
+
412
+ if (!tokenAdded) {
413
+ process.stdout.write("\n");
414
+ process.stdout.write(colorYellow("!") + " Set this token as an environment variable:\n\n");
415
+ process.stdout.write(colorDim(" ") + "export AGENTCLOUD_TOKEN=" + token + "\n");
416
+ if (configFile) {
417
+ process.stdout.write(colorDim(`\n Or add to ~/.${shellName}rc:\n`));
418
+ process.stdout.write(colorDim(" ") + `echo 'export AGENTCLOUD_TOKEN=${token}' >> ~/.${shellName}rc\n`);
419
+ process.stdout.write(colorDim(" ") + `source ~/.${shellName}rc\n`);
420
+ }
421
+ process.stdout.write(colorDim("\n Then restart this menu.\n\n"));
422
+ }
423
+
424
+ restoreRawMode();
425
+
426
+ if (tokenAdded) {
427
+ process.env.AGENTCLOUD_TOKEN = token;
428
+ // Use stdout writes to avoid buffering/race with process.exit()
429
+ process.stdout.write(`\n${colorGreen("✓")} Token saved to ~/.${shellName}rc\n`);
430
+ process.stdout.write(`\n${colorYellow("→")} Next: run in your terminal:\n`);
431
+ process.stdout.write(colorDim(` source ~/.${shellName}rc && uplink\n\n`));
432
+
433
+ setTimeout(() => {
434
+ process.exit(0);
435
+ }, 3000);
436
+
437
+ return undefined as any;
438
+ }
439
+
440
+ console.log("\nPress Enter to continue...");
441
+ await promptLine("");
442
+ restoreRawMode();
443
+ return "Token created! Please set AGENTCLOUD_TOKEN environment variable and restart the menu.";
444
+ } catch (err: any) {
445
+ restoreRawMode();
446
+ const errorMsg = err?.message || String(err);
447
+ if (errorMsg.includes("429") || errorMsg.includes("RATE_LIMIT")) {
448
+ return "⚠️ Too many signup attempts. Please try again later.";
449
+ }
450
+ return `❌ Error creating account: ${errorMsg}`;
451
+ }
452
+ },
453
+ });
454
+
455
+ mainMenu.push({
456
+ label: "Exit",
457
+ action: async () => {
458
+ return "Goodbye!";
459
+ },
460
+ });
461
+ } else {
462
+ // Only show other menu items if authentication succeeded
463
+
464
+ if (isAdmin) {
465
+ mainMenu.push({
466
+ label: "System Status",
467
+ subMenu: [
468
+ {
469
+ label: "View Status",
470
+ action: async () => {
471
+ let health = "unknown";
472
+ try {
473
+ const res = await fetch(`${apiBase}/health`);
474
+ const json = await res.json().catch(() => ({}));
475
+ health = json.status || res.statusText || "unknown";
476
+ } catch {
477
+ health = "unreachable";
478
+ }
479
+
480
+ const stats = await apiRequest("GET", "/v1/admin/stats");
481
+ return [
482
+ `API health: ${health}`,
483
+ "Tunnels:",
484
+ ` Active ${stats.tunnels.active} | Inactive ${stats.tunnels.inactive} | Deleted ${stats.tunnels.deleted} | Total ${stats.tunnels.total}`,
485
+ ` Created last 24h: ${stats.tunnels.createdLast24h}`,
486
+ "Databases:",
487
+ ` Ready ${stats.databases.ready} | Provisioning ${stats.databases.provisioning} | Failed ${stats.databases.failed} | Deleted ${stats.databases.deleted} | Total ${stats.databases.total}`,
488
+ ` Created last 24h: ${stats.databases.createdLast24h}`,
489
+ ].join("\n");
490
+ },
491
+ },
492
+ {
493
+ label: "Test: Tunnel",
494
+ action: async () => {
495
+ await runSmoke("smoke:tunnel");
496
+ return "smoke:tunnel completed";
497
+ },
498
+ },
499
+ {
500
+ label: "Test: Database",
501
+ action: async () => {
502
+ await runSmoke("smoke:db");
503
+ return "smoke:db completed";
504
+ },
505
+ },
506
+ {
507
+ label: "Test: All",
508
+ action: async () => {
509
+ await runSmoke("smoke:all");
510
+ return "smoke:all completed";
511
+ },
512
+ },
513
+ {
514
+ label: "Test: Comprehensive",
515
+ action: async () => {
516
+ await runSmoke("test:comprehensive");
517
+ return "test:comprehensive completed";
518
+ },
519
+ },
520
+ ],
521
+ });
522
+ }
523
+
524
+ mainMenu.push({
525
+ label: "Manage Tunnels",
526
+ subMenu: [
527
+ {
528
+ label: "Start Tunnel",
529
+ action: async () => {
530
+ try {
531
+ // Scan for active ports
532
+ console.log(colorDim("\nScanning for active servers..."));
533
+
534
+ // Temporarily disable raw mode for scanning
535
+ try { process.stdin.setRawMode(false); } catch { /* ignore */ }
536
+ const activePorts = await scanCommonPorts();
537
+
538
+ if (activePorts.length === 0) {
539
+ // No ports found - show selector with just custom option and back
540
+ const options: SelectOption[] = [
541
+ { label: "Enter custom port", value: "custom" },
542
+ ];
543
+
544
+ const result = await inlineSelect("No active servers detected", options, true);
545
+
546
+ if (result === null) {
547
+ // User selected Back
548
+ restoreRawMode();
549
+ return ""; // Return empty to go back without message
550
+ }
551
+
552
+ // Custom port entry
553
+ try { process.stdin.setRawMode(false); } catch { /* ignore */ }
554
+ const answer = await promptLine("Enter port number (default 3000): ");
555
+ const port = Number(answer) || 3000;
556
+ restoreRawMode();
557
+ return await createAndStartTunnel(port);
558
+ }
559
+
560
+ // Build options from found ports
561
+ const options: SelectOption[] = activePorts.map((port) => ({
562
+ label: `Port ${port}`,
563
+ value: port,
564
+ }));
565
+ options.push({ label: "Enter custom port", value: "custom" });
566
+
567
+ const result = await inlineSelect("Select port to expose", options, true);
568
+
569
+ if (result === null) {
570
+ // User selected Back
571
+ restoreRawMode();
572
+ return ""; // Return empty to go back without message
573
+ }
574
+
575
+ let port: number;
576
+ if (result.value === "custom") {
577
+ // Custom port entry
578
+ try { process.stdin.setRawMode(false); } catch { /* ignore */ }
579
+ const answer = await promptLine("Enter port number (default 3000): ");
580
+ port = Number(answer) || 3000;
581
+ } else {
582
+ port = result.value as number;
583
+ }
584
+
585
+ restoreRawMode();
586
+ return await createAndStartTunnel(port);
587
+ } catch (err: any) {
588
+ restoreRawMode();
589
+ throw err;
590
+ }
591
+ },
592
+ },
593
+ {
594
+ label: "Stop Tunnel",
595
+ action: async () => {
596
+ try {
597
+ // Find running tunnel client processes
598
+ const processes = findTunnelClients();
599
+
600
+ if (processes.length === 0) {
601
+ restoreRawMode();
602
+ return "No running tunnel clients found.";
603
+ }
604
+
605
+ // Build options from running tunnels
606
+ const options: SelectOption[] = processes.map((p) => ({
607
+ label: `Port ${p.port} ${colorDim(`(${truncate(p.token, 8)})`)}`,
608
+ value: p.pid,
609
+ }));
610
+
611
+ // Add "Stop all" option if more than one tunnel
612
+ if (processes.length > 1) {
613
+ options.push({ label: colorRed("Stop all tunnels"), value: "all" });
614
+ }
615
+
616
+ const result = await inlineSelect("Select tunnel to stop", options, true);
617
+
618
+ if (result === null) {
619
+ // User selected Back
620
+ restoreRawMode();
621
+ return ""; // Return empty to go back without message
622
+ }
623
+
624
+ let killed = 0;
625
+ if (result.value === "all") {
626
+ // Kill all
627
+ for (const p of processes) {
628
+ try {
629
+ execSync(`kill -TERM ${p.pid}`, { stdio: "ignore" });
630
+ killed++;
631
+ } catch {
632
+ // Process might have already exited
633
+ }
634
+ }
635
+ } else {
636
+ // Kill specific client
637
+ const pid = result.value as number;
638
+ try {
639
+ execSync(`kill -TERM ${pid}`, { stdio: "ignore" });
640
+ killed = 1;
641
+ } catch (err: any) {
642
+ restoreRawMode();
643
+ throw new Error(`Failed to kill process ${pid}: ${err.message}`);
644
+ }
645
+ }
646
+
647
+ restoreRawMode();
648
+ return `✓ Stopped ${killed} tunnel client${killed !== 1 ? "s" : ""}`;
649
+ } catch (err: any) {
650
+ restoreRawMode();
651
+ throw err;
652
+ }
653
+ },
654
+ },
655
+ ],
656
+ });
657
+
658
+ mainMenu.push({
659
+ label: "Usage",
660
+ subMenu: [
661
+ {
662
+ label: isAdmin ? "List Tunnels (admin)" : "List My Tunnels",
663
+ action: async () => {
664
+ const runningClients = findTunnelClients();
665
+ const path = isAdmin ? "/v1/admin/tunnels?limit=20" : "/v1/tunnels";
666
+ const result = await apiRequest("GET", path);
667
+ const tunnels = result.tunnels || result?.items || [];
668
+ if (!tunnels || tunnels.length === 0) {
669
+ return "No tunnels found.";
670
+ }
671
+
672
+ const lines = tunnels.map(
673
+ (t: any) => {
674
+ const token = t.token || "";
675
+ const connectedFromApi = t.connected ?? false;
676
+ const connectedLocal = runningClients.some((c) => c.token === token);
677
+ const connectionStatus = isAdmin
678
+ ? (connectedFromApi ? "connected" : "disconnected")
679
+ : (connectedLocal ? "connected" : "unknown");
680
+
681
+ return `${truncate(t.id, 12)} ${truncate(token, 10).padEnd(12)} ${String(
682
+ t.target_port ?? t.targetPort ?? "-"
683
+ ).padEnd(5)} ${connectionStatus.padEnd(12)} ${truncate(
684
+ t.created_at ?? t.createdAt ?? "",
685
+ 19
686
+ )}`;
687
+ }
688
+ );
689
+ return ["ID Token Port Connection Created", "-".repeat(70), ...lines].join(
690
+ "\n"
691
+ );
692
+ },
693
+ },
694
+ {
695
+ label: isAdmin ? "List Databases (admin)" : "List My Databases",
696
+ action: async () => {
697
+ const path = isAdmin ? "/v1/admin/databases?limit=20" : "/v1/dbs";
698
+ const result = await apiRequest("GET", path);
699
+ const databases = result.databases || result.items || [];
700
+ if (!databases || databases.length === 0) {
701
+ return "No databases found.";
702
+ }
703
+ const lines = databases.map(
704
+ (db: any) =>
705
+ `${truncate(db.id, 12)} ${truncate(db.name ?? "-", 14).padEnd(14)} ${truncate(
706
+ db.provider ?? "-",
707
+ 8
708
+ ).padEnd(8)} ${truncate(db.region ?? "-", 10).padEnd(10)} ${truncate(
709
+ db.status ?? (db.ready ? "ready" : db.status ?? "unknown"),
710
+ 10
711
+ ).padEnd(10)} ${truncate(db.created_at ?? db.createdAt ?? "", 19)}`
712
+ );
713
+ return [
714
+ "ID Name Prov Region Status Created",
715
+ "-".repeat(80),
716
+ ...lines,
717
+ ].join("\n");
718
+ },
719
+ },
720
+ ],
721
+ });
722
+
723
+ // Admin-only: Manage Tokens
724
+ if (isAdmin) {
725
+ mainMenu.push({
726
+ label: "Manage Tokens (admin)",
727
+ subMenu: [
728
+ {
729
+ label: "List Tokens",
730
+ action: async () => {
731
+ const result = await apiRequest("GET", "/v1/admin/tokens");
732
+ const tokens = result.tokens || [];
733
+ if (!tokens.length) return "No tokens found.";
734
+ const lines = tokens.map(
735
+ (t: any) =>
736
+ `${truncate(t.id, 12)} ${truncate(t.token_prefix || t.tokenPrefix || "-", 10).padEnd(12)} ${truncate(
737
+ t.role ?? "-",
738
+ 6
739
+ ).padEnd(8)} ${truncate(t.label ?? "-", 20).padEnd(22)} ${truncate(
740
+ t.created_at ?? t.createdAt ?? "",
741
+ 19
742
+ )}`
743
+ );
744
+ return [
745
+ "ID Prefix Role Label Created",
746
+ "-".repeat(90),
747
+ ...lines,
748
+ ].join("\n");
749
+ },
750
+ },
751
+ {
752
+ label: "Create Token",
753
+ action: async () => {
754
+ const roleAnswer = await promptLine("Role (admin/user, default user): ");
755
+ const role = roleAnswer.trim().toLowerCase() === "admin" ? "admin" : "user";
756
+ const labelAnswer = await promptLine("Label (optional): ");
757
+ const label = labelAnswer.trim() || undefined;
758
+ const expiresAnswer = await promptLine("Expires in days (optional): ");
759
+ const expiresDays = expiresAnswer.trim() ? Number(expiresAnswer) : undefined;
760
+
761
+ restoreRawMode();
762
+
763
+ const body: Record<string, unknown> = { role };
764
+ if (label) body.label = label;
765
+ if (expiresDays && expiresDays > 0) body.expiresInDays = expiresDays;
766
+
767
+ const result = await apiRequest("POST", "/v1/admin/tokens", body);
768
+ const rawToken = result.token || "(no token returned)";
769
+ return [
770
+ "✓ Token created",
771
+ "",
772
+ `→ Token ${rawToken}`,
773
+ `→ ID ${result.id}`,
774
+ `→ Role ${result.role}`,
775
+ `→ Label ${result.label || "-"}`,
776
+ result.expiresAt ? `→ Expires ${result.expiresAt}` : "",
777
+ ]
778
+ .filter(Boolean)
779
+ .join("\n");
780
+ },
781
+ },
782
+ {
783
+ label: "Revoke Token",
784
+ action: async () => {
785
+ try {
786
+ // Fetch available tokens
787
+ const result = await apiRequest("GET", "/v1/admin/tokens");
788
+ const tokens = result.tokens || [];
789
+
790
+ if (tokens.length === 0) {
791
+ restoreRawMode();
792
+ return "No tokens found.";
793
+ }
794
+
795
+ // Build options from tokens
796
+ const options: SelectOption[] = tokens.map((t: any) => ({
797
+ label: `${truncate(t.id, 12)} ${colorDim(`${t.role || "user"} - ${t.label || "no label"}`)}`,
798
+ value: t.id,
799
+ }));
800
+
801
+ const selected = await inlineSelect("Select token to revoke", options, true);
802
+
803
+ if (selected === null) {
804
+ // User selected Back
805
+ restoreRawMode();
806
+ return "";
807
+ }
808
+
809
+ const tokenId = selected.value as string;
810
+ await apiRequest("DELETE", `/v1/admin/tokens/${tokenId}`);
811
+ restoreRawMode();
812
+ return `✓ Token ${truncate(tokenId, 12)} revoked`;
813
+ } catch (err: any) {
814
+ restoreRawMode();
815
+ throw err;
816
+ }
817
+ },
818
+ },
819
+ ],
820
+ });
821
+
822
+ // Admin-only: Stop ALL Tunnel Clients (kill switch)
823
+ mainMenu.push({
824
+ label: "⚠️ Stop ALL Tunnel Clients (kill switch)",
825
+ action: async () => {
826
+ const clients = findTunnelClients();
827
+ if (clients.length === 0) {
828
+ return "No running tunnel clients found.";
829
+ }
830
+ let killed = 0;
831
+ for (const client of clients) {
832
+ try {
833
+ execSync(`kill -TERM ${client.pid}`, { stdio: "ignore" });
834
+ killed++;
835
+ } catch {
836
+ // Process might have already exited
837
+ }
838
+ }
839
+ return `✓ Stopped ${killed} tunnel client${killed !== 1 ? "s" : ""}`;
840
+ },
841
+ });
842
+ }
843
+
844
+ mainMenu.push({
845
+ label: "Exit",
846
+ action: async () => "Goodbye!",
847
+ });
848
+ }
849
+
850
+ // Menu navigation state
851
+ const menuStack: MenuChoice[][] = [mainMenu];
852
+ const menuPath: string[] = [];
853
+ let selected = 0;
854
+ let message = "Use ↑/↓ and Enter. ← to go back. Ctrl+C to quit.";
855
+ let exiting = false;
856
+ let busy = false;
857
+
858
+ // Cache active tunnels info - only update at start or when returning to main menu
859
+ let cachedActiveTunnels = "";
860
+ let cachedRelayStatus = "";
861
+
862
+ const getCurrentMenu = () => menuStack[menuStack.length - 1];
863
+
864
+ const updateActiveTunnelsCache = () => {
865
+ const clients = findTunnelClients();
866
+ if (clients.length === 0) {
867
+ cachedActiveTunnels = "";
868
+ } else {
869
+ const domain = process.env.TUNNEL_DOMAIN || "t.uplink.spot";
870
+ const scheme = (process.env.TUNNEL_URL_SCHEME || "https").toLowerCase();
871
+
872
+ const tunnelLines = clients.map((client, idx) => {
873
+ const url = `${scheme}://${client.token}.${domain}`;
874
+ const isLast = idx === clients.length - 1;
875
+ const branch = isLast ? "└─" : "├─";
876
+ return colorDim(branch) + " " + colorGreen(url) + colorDim(" → ") + `localhost:${client.port}`;
877
+ });
878
+
879
+ cachedActiveTunnels = [
880
+ colorDim("├─") + " Active " + colorGreen(`${clients.length} tunnel${clients.length > 1 ? "s" : ""}`),
881
+ colorDim("│"),
882
+ ...tunnelLines,
883
+ ].join("\n");
884
+ }
885
+ };
886
+
887
+ const updateRelayStatusCache = async () => {
888
+ const relayHealthUrl = process.env.RELAY_HEALTH_URL || "";
889
+ if (!relayHealthUrl) {
890
+ cachedRelayStatus = "";
891
+ return;
892
+ }
893
+ const controller = new AbortController();
894
+ const timer = setTimeout(() => controller.abort(), 2000);
895
+ try {
896
+ const headers: Record<string, string> = {};
897
+ if (process.env.RELAY_INTERNAL_SECRET) {
898
+ headers["x-relay-internal-secret"] = process.env.RELAY_INTERNAL_SECRET;
899
+ }
900
+ const res = await fetch(relayHealthUrl, { signal: controller.signal, headers });
901
+ if (res.ok) {
902
+ cachedRelayStatus = "Relay: ok";
903
+ } else {
904
+ cachedRelayStatus = `Relay: unreachable (HTTP ${res.status})`;
905
+ }
906
+ } catch {
907
+ cachedRelayStatus = "Relay: unreachable";
908
+ } finally {
909
+ clearTimeout(timer);
910
+ }
911
+ };
912
+
913
+ const refreshMainMenuCaches = async () => {
914
+ updateActiveTunnelsCache();
915
+ await updateRelayStatusCache();
916
+ render();
917
+ };
918
+
919
+ const render = () => {
920
+ clearScreen();
921
+ console.log();
922
+ console.log(ASCII_UPLINK);
923
+ console.log();
924
+
925
+ // Status bar - relay and API status
926
+ if (menuStack.length === 1 && cachedRelayStatus) {
927
+ const statusColor = cachedRelayStatus.includes("ok") ? colorGreen : colorRed;
928
+ console.log(colorDim("├─") + " Status " + statusColor(cachedRelayStatus.replace("Relay: ", "")));
929
+ }
930
+
931
+ // Show active tunnels if we're at the main menu (use cached value, no scanning)
932
+ if (menuStack.length === 1 && cachedActiveTunnels) {
933
+ console.log(cachedActiveTunnels);
934
+ }
935
+
936
+ console.log();
937
+
938
+ const currentMenu = getCurrentMenu();
939
+
940
+ // Breadcrumb navigation
941
+ if (menuPath.length > 0) {
942
+ const breadcrumb = menuPath.map((p, i) =>
943
+ i === menuPath.length - 1 ? colorCyan(p) : colorDim(p)
944
+ ).join(colorDim(" › "));
945
+ console.log(breadcrumb);
946
+ console.log();
947
+ }
948
+
949
+ // Menu items with tree-style rendering
950
+ currentMenu.forEach((choice, idx) => {
951
+ const isLast = idx === currentMenu.length - 1;
952
+ const isSelected = idx === selected;
953
+ const branch = isLast ? "└─" : "├─";
954
+
955
+ // Clean up labels - remove emojis for cleaner look
956
+ let cleanLabel = choice.label
957
+ .replace(/^🚀\s*/, "")
958
+ .replace(/^⚠️\s*/, "")
959
+ .replace(/^✅\s*/, "")
960
+ .replace(/^❌\s*/, "");
961
+
962
+ // Style based on selection and type
963
+ let label: string;
964
+ let branchColor: string;
965
+
966
+ if (isSelected) {
967
+ branchColor = colorCyan(branch);
968
+ if (cleanLabel.toLowerCase().includes("exit")) {
969
+ label = colorDim(cleanLabel);
970
+ } else if (cleanLabel.toLowerCase().includes("stop all") || cleanLabel.toLowerCase().includes("kill")) {
971
+ label = colorRed(cleanLabel);
972
+ } else if (cleanLabel.toLowerCase().includes("get started")) {
973
+ label = colorGreen(cleanLabel);
974
+ } else {
975
+ label = colorCyan(cleanLabel);
976
+ }
977
+ } else {
978
+ branchColor = colorDim(branch);
979
+ if (cleanLabel.toLowerCase().includes("exit")) {
980
+ label = colorDim(cleanLabel);
981
+ } else if (cleanLabel.toLowerCase().includes("stop all") || cleanLabel.toLowerCase().includes("kill")) {
982
+ label = colorRed(cleanLabel);
983
+ } else if (cleanLabel.toLowerCase().includes("get started")) {
984
+ label = colorGreen(cleanLabel);
985
+ } else {
986
+ label = cleanLabel;
987
+ }
988
+ }
989
+
990
+ // Submenu indicator
991
+ const indicator = choice.subMenu ? colorDim(" ›") : "";
992
+
993
+ console.log(`${branchColor} ${label}${indicator}`);
994
+ });
995
+
996
+ // Message area
997
+ if (busy) {
998
+ console.log();
999
+ console.log(colorDim("│"));
1000
+ console.log(colorCyan("│ ") + colorDim("Working..."));
1001
+ } else if (message && message !== "Use ↑/↓ and Enter. ← to go back. Ctrl+C to quit.") {
1002
+ console.log();
1003
+ // Format multi-line messages nicely
1004
+ const lines = message.split("\n");
1005
+ lines.forEach((line) => {
1006
+ // Color success/error indicators
1007
+ let styledLine = line
1008
+ .replace(/^✅/, colorGreen("✓"))
1009
+ .replace(/^❌/, colorRed("✗"))
1010
+ .replace(/^⚠️/, colorYellow("!"))
1011
+ .replace(/^🔑/, colorCyan("→"))
1012
+ .replace(/^🌐/, colorCyan("→"))
1013
+ .replace(/^📡/, colorCyan("→"))
1014
+ .replace(/^💡/, colorYellow("→"));
1015
+ console.log(colorDim("│ ") + styledLine);
1016
+ });
1017
+ }
1018
+
1019
+ // Footer hints
1020
+ console.log();
1021
+ const hints = [
1022
+ colorDim("↑↓") + " navigate",
1023
+ colorDim("↵") + " select",
1024
+ ];
1025
+ if (menuStack.length > 1) {
1026
+ hints.push(colorDim("←") + " back");
1027
+ }
1028
+ hints.push(colorDim("^C") + " exit");
1029
+ console.log(colorDim(hints.join(" ")));
1030
+ };
1031
+
1032
+ const cleanup = () => {
1033
+ try {
1034
+ process.stdin.setRawMode(false);
1035
+ } catch {
1036
+ /* ignore */
1037
+ }
1038
+ process.stdin.pause();
1039
+ };
1040
+
1041
+ const handleAction = async () => {
1042
+ const currentMenu = getCurrentMenu();
1043
+ const choice = currentMenu[selected];
1044
+
1045
+ if (choice.subMenu) {
1046
+ // Navigate into sub-menu
1047
+ menuStack.push(choice.subMenu);
1048
+ menuPath.push(choice.label);
1049
+ selected = 0;
1050
+ message = ""; // Clear any displayed output when entering submenu
1051
+ // Invalidate caches when leaving main menu
1052
+ cachedActiveTunnels = "";
1053
+ cachedRelayStatus = "";
1054
+ render();
1055
+ return;
1056
+ }
1057
+
1058
+ if (!choice.action) {
1059
+ return;
1060
+ }
1061
+
1062
+ busy = true;
1063
+ render();
1064
+ try {
1065
+ const result = await choice.action();
1066
+ // If action returns undefined, it handled its own output/exit (e.g., signup flow)
1067
+ if (result === undefined) {
1068
+ return;
1069
+ }
1070
+ message = result;
1071
+ if (choice.label === "Exit") {
1072
+ exiting = true;
1073
+ }
1074
+ } catch (err: any) {
1075
+ message = `Error: ${err?.message || String(err)}`;
1076
+ } finally {
1077
+ busy = false;
1078
+ render();
1079
+ if (exiting) {
1080
+ cleanup();
1081
+ process.exit(0);
1082
+ }
1083
+ }
1084
+ };
1085
+
1086
+ const onKey = async (key: Buffer) => {
1087
+ if (busy) return;
1088
+ const str = key.toString();
1089
+ const currentMenu = getCurrentMenu();
1090
+
1091
+ if (str === "\u0003") {
1092
+ cleanup();
1093
+ process.exit(0);
1094
+ } else if (str === "\u001b[D") {
1095
+ // Left arrow - go back
1096
+ if (menuStack.length > 1) {
1097
+ menuStack.pop();
1098
+ menuPath.pop();
1099
+ selected = 0;
1100
+ message = ""; // Clear any displayed output when going back
1101
+ // Refresh caches when returning to main menu
1102
+ if (menuStack.length === 1) {
1103
+ await refreshMainMenuCaches();
1104
+ return;
1105
+ }
1106
+ render();
1107
+ }
1108
+ } else if (str === "\u001b[A") {
1109
+ // Up
1110
+ selected = (selected - 1 + currentMenu.length) % currentMenu.length;
1111
+ render();
1112
+ } else if (str === "\u001b[B") {
1113
+ // Down
1114
+ selected = (selected + 1) % currentMenu.length;
1115
+ render();
1116
+ } else if (str === "\r") {
1117
+ await handleAction();
1118
+ }
1119
+ };
1120
+
1121
+ // Initial scans for active tunnels and relay status at startup
1122
+ await refreshMainMenuCaches();
1123
+ process.stdin.setRawMode(true);
1124
+ process.stdin.resume();
1125
+ process.stdin.on("data", onKey);
1126
+ });
1127
+
1128
+ async function createAndStartTunnel(port: number): Promise<string> {
1129
+ // Create tunnel
1130
+ const result = await apiRequest("POST", "/v1/tunnels", { port });
1131
+ const url = result.url || "(no url)";
1132
+ const token = result.token || "(no token)";
1133
+ const ctrl = process.env.TUNNEL_CTRL || "tunnel.uplink.spot:7071";
1134
+
1135
+ // Start tunnel client in background
1136
+ const path = require("path");
1137
+ const projectRoot = path.join(__dirname, "../../..");
1138
+ const clientPath = path.join(projectRoot, "scripts/tunnel/client-improved.js");
1139
+ const clientProcess = spawn("node", [clientPath, "--token", token, "--port", String(port), "--ctrl", ctrl], {
1140
+ stdio: "ignore",
1141
+ detached: true,
1142
+ cwd: projectRoot,
1143
+ });
1144
+ clientProcess.unref();
1145
+
1146
+ // Wait a moment for client to connect
1147
+ await new Promise(resolve => setTimeout(resolve, 2000));
1148
+
1149
+ try {
1150
+ process.stdin.setRawMode(true);
1151
+ process.stdin.resume();
1152
+ } catch {
1153
+ /* ignore */
1154
+ }
1155
+
1156
+ return [
1157
+ `✓ Tunnel created and client started`,
1158
+ ``,
1159
+ `→ Public URL ${url}`,
1160
+ `→ Token ${token}`,
1161
+ `→ Local port ${port}`,
1162
+ ``,
1163
+ `Tunnel client running in background.`,
1164
+ `Use "Stop Tunnel" to disconnect.`,
1165
+ ].join("\n");
1166
+ }
1167
+
1168
+ function findTunnelClients(): Array<{ pid: number; port: number; token: string }> {
1169
+ try {
1170
+ // Find processes running client-improved.js (current user, match script path to avoid false positives)
1171
+ const user = process.env.USER || "";
1172
+ const psCmd = user
1173
+ ? `ps -u ${user} -o pid=,command=`
1174
+ : "ps -eo pid=,command=";
1175
+ const output = execSync(psCmd, { encoding: "utf-8" });
1176
+ const lines = output
1177
+ .trim()
1178
+ .split("\n")
1179
+ .filter((line) => line.includes("scripts/tunnel/client-improved.js"));
1180
+
1181
+ const clients: Array<{ pid: number; port: number; token: string }> = [];
1182
+
1183
+ for (const line of lines) {
1184
+ // Parse process line: PID COMMAND (pid may have leading whitespace)
1185
+ // Format: "56218 node /path/to/client-improved.js --token TOKEN --port PORT --ctrl CTRL"
1186
+ const pidMatch = line.match(/^\s*(\d+)/);
1187
+ const tokenMatch = line.match(/--token\s+(\S+)/);
1188
+ const portMatch = line.match(/--port\s+(\d+)/);
1189
+
1190
+ if (pidMatch && tokenMatch && portMatch) {
1191
+ clients.push({
1192
+ pid: parseInt(pidMatch[1], 10),
1193
+ port: parseInt(portMatch[1], 10),
1194
+ token: tokenMatch[1],
1195
+ });
1196
+ }
1197
+ }
1198
+
1199
+ return clients;
1200
+ } catch {
1201
+ return [];
1202
+ }
1203
+ }
1204
+
1205
+ function runSmoke(script: "smoke:tunnel" | "smoke:db" | "smoke:all" | "test:comprehensive") {
1206
+ return new Promise<void>((resolve, reject) => {
1207
+ const env = {
1208
+ ...process.env,
1209
+ AGENTCLOUD_API_BASE: process.env.AGENTCLOUD_API_BASE ?? "https://api.uplink.spot",
1210
+ AGENTCLOUD_TOKEN: process.env.AGENTCLOUD_TOKEN ?? "dev-token",
1211
+ };
1212
+ const child = spawn("npm", ["run", script], { stdio: "inherit", env });
1213
+ child.on("close", (code) => {
1214
+ if (code === 0) {
1215
+ resolve();
1216
+ } else {
1217
+ reject(new Error(`${script} failed with exit code ${code}`));
1218
+ }
1219
+ });
1220
+ child.on("error", (err) => reject(err));
1221
+ });
1222
+ }