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.
- package/README.md +205 -0
- package/cli/bin/uplink.js +55 -0
- package/cli/src/http.ts +60 -0
- package/cli/src/index.ts +32 -0
- package/cli/src/subcommands/admin.ts +351 -0
- package/cli/src/subcommands/db.ts +117 -0
- package/cli/src/subcommands/dev.ts +86 -0
- package/cli/src/subcommands/menu.ts +1222 -0
- package/cli/src/utils/port-scanner.ts +98 -0
- package/package.json +71 -0
- package/scripts/tunnel/client-improved.js +404 -0
|
@@ -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
|
+
}
|