9remote 0.1.48 → 0.1.49

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/cli/index.js ADDED
@@ -0,0 +1,848 @@
1
+ #!/usr/bin/env node
2
+
3
+ import inquirer from "inquirer";
4
+ import chalk from "chalk";
5
+ import qrcode from "qrcode-terminal";
6
+ import { spawn, execSync } from "child_process";
7
+ import path from "path";
8
+ import { fileURLToPath } from "url";
9
+ import fs from "fs";
10
+ import os from "os";
11
+ import dns from "dns";
12
+ import https from "https";
13
+ import { promisify } from "util";
14
+ import { getConsistentMachineId } from "./utils/machineId.js";
15
+ import { generateApiKeyWithMachine } from "./utils/apiKey.js";
16
+ import { loadKey, saveKey, loadState, saveState, clearState } from "./utils/state.js";
17
+ import { createTempKey } from "./utils/token.js";
18
+ import { checkAndUpdate } from "./utils/updateChecker.js";
19
+ import { ensureCloudflared, spawnCloudflared, killCloudflared, resetRestartCounter } from "./utils/cloudflared.js";
20
+
21
+ // DNS resolver using Cloudflare — ensures tunnel domains resolve immediately
22
+ const cfResolver = new dns.Resolver();
23
+ cfResolver.setServers(["1.1.1.1", "1.0.0.1"]);
24
+ const cfResolve4 = promisify(cfResolver.resolve4.bind(cfResolver));
25
+
26
+ /** Fetch via IP with correct TLS SNI — bypasses system DNS */
27
+ function fetchWithCfDns(url, timeoutMs = 5000) {
28
+ return new Promise(async (resolve, reject) => {
29
+ try {
30
+ const parsed = new URL(url);
31
+ const [ip] = await cfResolve4(parsed.hostname);
32
+ const req = https.request({
33
+ hostname: ip,
34
+ port: 443,
35
+ path: parsed.pathname + parsed.search,
36
+ method: "GET",
37
+ headers: { host: parsed.hostname },
38
+ servername: parsed.hostname,
39
+ rejectUnauthorized: true
40
+ }, (res) => {
41
+ let body = "";
42
+ res.on("data", d => { body += d; });
43
+ res.on("end", () => resolve({ ok: res.statusCode >= 200 && res.statusCode < 300, status: res.statusCode, body }));
44
+ });
45
+ req.setTimeout(timeoutMs, () => { req.destroy(); reject(new Error("timeout")); });
46
+ req.on("error", reject);
47
+ req.end();
48
+ } catch (err) {
49
+ reject(err);
50
+ }
51
+ });
52
+ }
53
+
54
+ // Parse --skip-update flag
55
+ const skipUpdate = process.argv.includes("--skip-update");
56
+
57
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
58
+ const PROJECT_ROOT = path.resolve(__dirname, "../..");
59
+ // When bundled (dist/cli.cjs), server.cjs is in same dist/ folder
60
+ // When dev (server/cli/index.js), use server/index.js directly
61
+ const STANDALONE_SERVER = path.resolve(__dirname, "../dist/server.cjs");
62
+ const DEV_SERVER = path.resolve(__dirname, "../index.js");
63
+ const WORKER_URL = "https://9remote.cc";
64
+ const SERVER_PORT = 2208;
65
+ const SHORT_ID_CHARS = "abcdefghijklmnpqrstuvwxyz23456789";
66
+ const MAX_RESTART_ATTEMPTS = 10;
67
+ const RESTART_WINDOW_MS = 60000; // 1 minute
68
+
69
+ // Orange color from gitbook (#E68A6E)
70
+ const ORANGE = chalk.rgb(230, 138, 110);
71
+ const ORANGE_DIM = chalk.rgb(200, 120, 95);
72
+
73
+ /**
74
+ * Get current version from package.json
75
+ */
76
+ function getVersion() {
77
+ // When bundled, version is injected at build time
78
+ if (typeof __CLI_VERSION__ !== "undefined") {
79
+ return __CLI_VERSION__;
80
+ }
81
+
82
+ // Dev mode: read from package.json
83
+ try {
84
+ const packagePath = path.join(__dirname, "..", "package.json");
85
+ const packageJson = JSON.parse(fs.readFileSync(packagePath, "utf-8"));
86
+ return packageJson.version;
87
+ } catch {
88
+ return "unknown";
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Show banner
94
+ */
95
+ function showBanner() {
96
+ const version = getVersion();
97
+ const width = Math.min(44, process.stdout.columns || 44);
98
+
99
+ console.log("");
100
+ console.log(ORANGE("╔" + "═".repeat(width - 2) + "╗"));
101
+ console.log(ORANGE("║") + " ".repeat(width - 2) + ORANGE("║"));
102
+
103
+ const title = `🚀 9Remote v${version}`;
104
+ const titlePadding = Math.floor((width - 2 - title.length) / 2);
105
+ console.log(
106
+ ORANGE("║") +
107
+ " ".repeat(titlePadding) +
108
+ ORANGE.bold(title) +
109
+ " ".repeat(width - 2 - titlePadding - title.length) +
110
+ ORANGE("║")
111
+ );
112
+
113
+ const subtitle = "Remote terminal access from anywhere";
114
+ const subtitlePadding = Math.floor((width - 2 - subtitle.length) / 2);
115
+ console.log(
116
+ ORANGE("║") +
117
+ " ".repeat(subtitlePadding) +
118
+ chalk.gray(subtitle) +
119
+ " ".repeat(width - 2 - subtitlePadding - subtitle.length) +
120
+ ORANGE("║")
121
+ );
122
+
123
+ console.log(ORANGE("║") + " ".repeat(width - 2) + ORANGE("║"));
124
+ console.log(ORANGE("╚" + "═".repeat(width - 2) + "╝"));
125
+ console.log("");
126
+ }
127
+
128
+ /**
129
+ * Generate short random ID for tunnel subdomain
130
+ */
131
+ function generateShortId() {
132
+ let result = "";
133
+ for (let i = 0; i < 6; i++) {
134
+ result += SHORT_ID_CHARS.charAt(Math.floor(Math.random() * SHORT_ID_CHARS.length));
135
+ }
136
+ return result;
137
+ }
138
+
139
+ /**
140
+ * Helper: Show QR code for connect URL
141
+ */
142
+ function showQRCode(url, title = "📱 Scan QR to connect:") {
143
+ console.log(ORANGE(`\n${title}`));
144
+ qrcode.generate(url, {
145
+ small: true,
146
+ type: 'terminal',
147
+ margin: 0,
148
+ }, qr => {
149
+ const lines = qr.trim().split('\n');
150
+ console.log(lines.join('\n'));
151
+ });
152
+ }
153
+
154
+ /**
155
+ * Helper: Show connection info
156
+ */
157
+ async function showConnectionInfo(selectedKey, tunnelUrl) {
158
+ const tempKeyData = await createTempKey(selectedKey, WORKER_URL);
159
+
160
+ if (!tempKeyData) {
161
+ console.log(chalk.red("❌ Failed to create temp key"));
162
+ return;
163
+ }
164
+
165
+ const connectUrl = `${WORKER_URL}/login?k=${tempKeyData.tempKey}`;
166
+ const width = Math.min(44, process.stdout.columns || 55);
167
+
168
+ // Push ready state to UI
169
+ pushUiState({
170
+ step: 3,
171
+ tunnelUrl,
172
+ oneTimeKey: tempKeyData.tempKey,
173
+ qrUrl: connectUrl,
174
+ });
175
+
176
+ showQRCode(connectUrl);
177
+
178
+ console.log(chalk.gray(`\nQR will expire in 30 minutes (one-time use)\n`));
179
+
180
+ console.log(ORANGE("═".repeat(width)));
181
+
182
+ // App URL
183
+ const appLabel = "App URL";
184
+ const appValue = `${WORKER_URL}/login`;
185
+ console.log(chalk.white(appLabel.padEnd(14)) + chalk.gray(appValue));
186
+
187
+ // One-Time Key
188
+ const keyLabel = "One-Time Key";
189
+ const keyValue = tempKeyData.tempKey;
190
+ console.log(chalk.white(keyLabel.padEnd(14)) + ORANGE.bold(keyValue));
191
+
192
+ // Permanent Key
193
+ const permLabel = "Key";
194
+ const permValue = selectedKey;
195
+ console.log(chalk.white(permLabel.padEnd(14)) + chalk.gray(permValue));
196
+
197
+ console.log(ORANGE("═".repeat(width)));
198
+ }
199
+
200
+ /**
201
+ * Kill process on specific port
202
+ */
203
+ function killProcessOnPort(port) {
204
+ try {
205
+ if (process.platform === "win32") {
206
+ execSync(`for /f "tokens=5" %a in ('netstat -aon ^| findstr :${port}') do taskkill /F /PID %a`, { stdio: "ignore" });
207
+ } else {
208
+ const nullDevice = "/dev/null";
209
+ execSync(`lsof -ti:${port} | xargs kill -9 2>${nullDevice} || true`, { stdio: "ignore" });
210
+ }
211
+ } catch { }
212
+ }
213
+
214
+ /**
215
+ * Start server with auto-restart on crash
216
+ */
217
+ function startServerWithRestart(onReady, onServerCrash) {
218
+ const restartTimes = [];
219
+ let currentProcess = null;
220
+ let isShuttingDown = false;
221
+ let isFirstStart = true;
222
+
223
+ const spawnServer = () => {
224
+ // Only kill port on first start, not on restart
225
+ if (isFirstStart) {
226
+ killProcessOnPort(SERVER_PORT);
227
+ isFirstStart = false;
228
+ } else {
229
+ }
230
+
231
+ // Use dev server if exists (development), otherwise use standalone (npm package)
232
+ const useDevServer = process.env.NODE_ENV === "development" && fs.existsSync(DEV_SERVER);
233
+ const serverPath = useDevServer ? DEV_SERVER : STANDALONE_SERVER;
234
+
235
+ if (!fs.existsSync(serverPath)) {
236
+ console.error(`❌ Server not found: ${serverPath}`);
237
+ process.exit(1);
238
+ }
239
+
240
+ currentProcess = spawn("node", [serverPath], {
241
+ cwd: path.dirname(serverPath),
242
+ stdio: ["ignore", "inherit", "inherit"],
243
+ detached: false,
244
+ env: { ...process.env, PORT: String(SERVER_PORT) }
245
+ });
246
+
247
+
248
+ currentProcess.on("exit", (code, signal) => {
249
+ if (isShuttingDown) {
250
+ return;
251
+ }
252
+
253
+ // Check if it's a crash (non-zero exit code or unexpected signal)
254
+ if (code !== 0 || signal) {
255
+ console.log(chalk.red(`\n💥 Server crashed (code: ${code}, signal: ${signal})`));
256
+
257
+ // Check restart limit
258
+ const now = Date.now();
259
+ restartTimes.push(now);
260
+
261
+ // Remove old restart times outside window
262
+ while (restartTimes.length > 0 && restartTimes[0] < now - RESTART_WINDOW_MS) {
263
+ restartTimes.shift();
264
+ }
265
+
266
+ if (restartTimes.length > MAX_RESTART_ATTEMPTS) {
267
+ console.log(chalk.red(`❌ Too many restarts (${MAX_RESTART_ATTEMPTS} in ${RESTART_WINDOW_MS / 1000}s). Giving up.`));
268
+ process.exit(1);
269
+ }
270
+
271
+ console.log(chalk.yellow(`🔄 Restarting server... (attempt ${restartTimes.length}/${MAX_RESTART_ATTEMPTS})`));
272
+ console.log(ORANGE_DIM("⚠️ [DEBUG] NOTE: Tunnel connection may be stale - will restart tunnel"));
273
+
274
+ // ✅ Callback để restart cloudflared
275
+ if (onServerCrash) {
276
+ console.log(chalk.yellow("✅ Restarting tunnel connection..."));
277
+ onServerCrash();
278
+ }
279
+
280
+ // Wait a bit before restart
281
+ setTimeout(() => {
282
+ spawnServer();
283
+ }, 1000);
284
+ } else {
285
+ }
286
+ });
287
+
288
+ currentProcess.on("error", (err) => {
289
+ console.log(chalk.red(`❌ Server error: ${err.message}`));
290
+ });
291
+
292
+ if (onReady) {
293
+ onReady(currentProcess);
294
+ }
295
+ };
296
+
297
+ spawnServer();
298
+
299
+ return {
300
+ getProcess: () => currentProcess,
301
+ shutdown: () => {
302
+ isShuttingDown = true;
303
+ if (currentProcess) {
304
+ currentProcess.kill();
305
+ }
306
+ }
307
+ };
308
+ }
309
+
310
+ /**
311
+ * Helper: Setup exit handler for server processes
312
+ */
313
+ let exitHandlerRegistered = false;
314
+
315
+ function setupExitHandler(serverManager, tunnelProcess, apiKey) {
316
+ if (exitHandlerRegistered) return;
317
+ exitHandlerRegistered = true;
318
+
319
+ process.on("SIGINT", async () => {
320
+ console.log(chalk.yellow("\n\n🛑 Stopping server..."));
321
+
322
+ serverManager.shutdown();
323
+ tunnelProcess.kill();
324
+ resetRestartCounter();
325
+ clearState();
326
+
327
+ console.log(chalk.green("✅ Server stopped"));
328
+ process.exit(0);
329
+ });
330
+ }
331
+
332
+ /**
333
+ * Get first non-internal LAN IPv4 address
334
+ */
335
+ function getLanIp() {
336
+ const interfaces = os.networkInterfaces();
337
+ for (const iface of Object.values(interfaces)) {
338
+ for (const addr of iface) {
339
+ if (addr.family === "IPv4" && !addr.internal) return addr.address;
340
+ }
341
+ }
342
+ return null;
343
+ }
344
+
345
+ /**
346
+ * Helper: Create Named Tunnel via Worker API
347
+ */
348
+ async function createNamedTunnel(apiKey) {
349
+ const response = await fetch(`${WORKER_URL}/api/tunnel/create`, {
350
+ method: "POST",
351
+ headers: { "Content-Type": "application/json" },
352
+ body: JSON.stringify({ apiKey })
353
+ });
354
+
355
+ if (!response.ok) {
356
+ const error = await response.json();
357
+ throw new Error(error.error || "Failed to create tunnel");
358
+ }
359
+
360
+ const data = await response.json();
361
+ return data;
362
+ }
363
+
364
+ /** Push UI state to server via HTTP */
365
+ async function pushUiState(data) {
366
+ try {
367
+ await fetch(`http://localhost:${SERVER_PORT}/api/ui/state`, {
368
+ method: "POST",
369
+ headers: { "Content-Type": "application/json" },
370
+ body: JSON.stringify(data),
371
+ });
372
+ } catch { /* server may not be ready yet */ }
373
+ }
374
+
375
+ /**
376
+ * Helper: Start server and tunnel
377
+ */
378
+ async function startServerAndTunnel(selectedKey) {
379
+ console.log(ORANGE("\n🚀 Starting server..."));
380
+ pushUiState({ step: 1 });
381
+
382
+ // Kill existing cloudflared process
383
+ try {
384
+ killCloudflared();
385
+ await new Promise(resolve => setTimeout(resolve, 1000));
386
+ } catch { }
387
+
388
+ // Reuse existing shortId or generate new one
389
+ const existingState = loadState();
390
+ const shortId = existingState?.shortId || generateShortId();
391
+
392
+ // Create session first
393
+ try {
394
+ const sessionResponse = await fetch(`${WORKER_URL}/api/session/create`, {
395
+ method: "POST",
396
+ headers: { "Content-Type": "application/json" },
397
+ body: JSON.stringify({ apiKey: selectedKey, shortId })
398
+ });
399
+
400
+ if (!sessionResponse.ok) {
401
+ const text = await sessionResponse.text();
402
+ console.log(chalk.red(`❌ Failed to create session: ${sessionResponse.status} ${sessionResponse.statusText}`));
403
+ console.log(chalk.yellow(`Response: ${text.substring(0, 200)}`));
404
+ return null;
405
+ }
406
+
407
+ const sessionData = await sessionResponse.json();
408
+ } catch (error) {
409
+ console.log(chalk.red(`❌ Failed to create session: ${error.message}`));
410
+ return null;
411
+ }
412
+
413
+ // tunnelProcess declared here so the serverManager crash callback can reference it
414
+ let tunnelProcess = null;
415
+
416
+ // Skip spawning server if already running (e.g. nodemon in dev mode)
417
+ const alreadyRunning = await isServerRunning();
418
+
419
+ // Start server with auto-restart (skip if already running)
420
+ const serverManager = alreadyRunning
421
+ ? { getProcess: () => null, shutdown: () => {} }
422
+ : startServerWithRestart(null, async () => {
423
+ // Callback khi server crash - đợi server ready rồi gửi SIGHUP
424
+
425
+ if (!tunnelProcess) {
426
+ return;
427
+ }
428
+
429
+ // Wait for server to be ready
430
+ const maxWait = 60000; // 60s
431
+ const checkInterval = 1000; // 1s
432
+ const maxRetries = Math.floor(maxWait / checkInterval);
433
+ let serverReady = false;
434
+
435
+ for (let i = 0; i < maxRetries; i++) {
436
+ try {
437
+ const response = await fetch(`http://localhost:${SERVER_PORT}/api/health`, {
438
+ method: "GET",
439
+ timeout: 2000
440
+ });
441
+
442
+ if (response.ok) {
443
+ const data = await response.json();
444
+ if (data.status === "ok") {
445
+ serverReady = true;
446
+ console.log(chalk.green(`✅ Server ready after ${i + 1}s`));
447
+ break;
448
+ }
449
+ }
450
+ } catch (err) {
451
+ // Server not ready yet
452
+ }
453
+
454
+ if (i % 5 === 0) {
455
+ }
456
+
457
+ await new Promise(resolve => setTimeout(resolve, checkInterval));
458
+ }
459
+
460
+ if (!serverReady) {
461
+ console.log(chalk.red("❌ Server not ready after 60s - skipping tunnel reconnect"));
462
+ return;
463
+ }
464
+
465
+ // Server ready - send SIGHUP to cloudflared to reconnect
466
+ try {
467
+ process.kill(tunnelProcess.pid, "SIGHUP");
468
+ console.log(chalk.green("✅ SIGHUP sent - cloudflared should reconnect"));
469
+ } catch (err) {
470
+ // Fallback: kill and restart
471
+ try {
472
+ tunnelProcess.kill();
473
+ await new Promise(resolve => setTimeout(resolve, 1000));
474
+ tunnelProcess = await startTunnel(token);
475
+ console.log(chalk.green("✅ Tunnel restarted"));
476
+ } catch (restartErr) {
477
+ console.log(chalk.red(`❌ Failed to restart tunnel: ${restartErr.message}`));
478
+ }
479
+ }
480
+ });
481
+
482
+ // Wait for server to start (skip if already running)
483
+ if (!alreadyRunning) await new Promise((resolve) => setTimeout(resolve, 2000));
484
+
485
+ console.log(ORANGE("✅ Creating tunnel..."));
486
+ pushUiState({ step: 2 });
487
+
488
+ // Ensure cloudflared binary
489
+ try {
490
+ await ensureCloudflared();
491
+ } catch (error) {
492
+ console.log(chalk.red(`❌ Failed to install cloudflared: ${error.message}`));
493
+ serverManager.shutdown();
494
+ return null;
495
+ }
496
+
497
+ // Create Named Tunnel via Worker API
498
+ let tunnelData;
499
+ try {
500
+ tunnelData = await createNamedTunnel(selectedKey);
501
+ console.log(ORANGE(`✅ Tunnel ID: ${tunnelData.tunnelId}`));
502
+ } catch (error) {
503
+ console.log(chalk.red(`❌ Failed to create tunnel: ${error.message}`));
504
+ serverManager.shutdown();
505
+ return null;
506
+ }
507
+
508
+ const { token, hostname: tunnelUrl } = tunnelData;
509
+
510
+ // Spawn cloudflared with token and auto-restart callback
511
+ console.log(ORANGE("✅ Starting tunnel..."));
512
+
513
+ const startTunnel = async (tunnelToken) => {
514
+ try {
515
+ tunnelProcess = await spawnCloudflared(tunnelToken, startTunnel);
516
+ return tunnelProcess;
517
+ } catch (error) {
518
+ console.log(chalk.red(`❌ Failed to start cloudflared: ${error.message}`));
519
+ return null;
520
+ }
521
+ };
522
+
523
+ tunnelProcess = await startTunnel(token);
524
+ if (!tunnelProcess) {
525
+ serverManager.shutdown();
526
+ return null;
527
+ }
528
+
529
+ // Verify tunnel reachable via Cloudflare DNS + https.request (bypasses system DNS)
530
+ const spinners = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
531
+ let tunnelReady = false;
532
+ for (let i = 0; i < 30; i++) {
533
+ try {
534
+ const res = await fetchWithCfDns(`${tunnelUrl}/api/health`);
535
+ if (res.ok) { tunnelReady = true; break; }
536
+ } catch { }
537
+ process.stdout.write(`\r Verifying tunnel ${spinners[i % spinners.length]} (${i * 2}s)`);
538
+ await new Promise(r => setTimeout(r, 2000));
539
+ }
540
+ process.stdout.write("\r" + " ".repeat(40) + "\r");
541
+
542
+ if (!tunnelReady) {
543
+ console.log(chalk.red("❌ Tunnel not reachable from outside"));
544
+ serverManager.shutdown();
545
+ tunnelProcess.kill();
546
+ return null;
547
+ }
548
+
549
+ console.log(ORANGE(`✅ Tunnel URL: ${tunnelUrl}`));
550
+ const lanIpLog = getLanIp();
551
+ if (lanIpLog) console.log(ORANGE(`✅ Local IP: ${lanIpLog}:${SERVER_PORT} (LAN direct available)`));
552
+ console.log(ORANGE(`✅ Connection established`));
553
+
554
+ // Update session with tunnelUrl + localIp (worker detects publicIp via CF-Connecting-IP)
555
+ const lanIp = lanIpLog;
556
+ try {
557
+ await fetch(`${WORKER_URL}/api/session/update`, {
558
+ method: "POST",
559
+ headers: { "Content-Type": "application/json" },
560
+ body: JSON.stringify({
561
+ apiKey: selectedKey,
562
+ tunnelUrl,
563
+ localIp: lanIp ? `${lanIp}:${SERVER_PORT}` : null
564
+ })
565
+ });
566
+ } catch { }
567
+
568
+ // Save state (persist shortId for reuse on restart)
569
+ saveState({
570
+ apiKey: selectedKey,
571
+ shortId,
572
+ tunnelUrl,
573
+ serverPid: serverManager.getProcess()?.pid,
574
+ tunnelPid: tunnelProcess.pid
575
+ });
576
+
577
+ return { serverManager, tunnelProcess, tunnelUrl };
578
+ }
579
+
580
+
581
+ /**
582
+ * Show main menu
583
+ */
584
+ async function mainMenu() {
585
+ console.clear();
586
+ showBanner();
587
+
588
+ const { action } = await inquirer.prompt([
589
+ {
590
+ type: "list",
591
+ name: "action",
592
+ message: "Select action:",
593
+ choices: [
594
+ { name: "🚀 Start Server", value: "start" },
595
+ { name: "🔑 Manage Key", value: "key" },
596
+ { name: "❌ Exit", value: "exit" }
597
+ ]
598
+ }
599
+ ]);
600
+
601
+ switch (action) {
602
+ case "start":
603
+ await startServer();
604
+ break;
605
+ case "key":
606
+ await manageKey();
607
+ break;
608
+ case "exit":
609
+ console.log(chalk.gray("Goodbye!"));
610
+ process.exit(0);
611
+ }
612
+ }
613
+
614
+ /**
615
+ * Start server with single key
616
+ */
617
+ async function startServer() {
618
+ const machineId = await getConsistentMachineId();
619
+ let keyData = loadKey();
620
+
621
+ // Auto create key if none exists
622
+ if (!keyData.key) {
623
+ console.log(chalk.yellow("\n⚠️ No key found. Creating default key..."));
624
+ const { key } = generateApiKeyWithMachine(machineId);
625
+ keyData = saveKey(machineId, key, "Default");
626
+ console.log(chalk.green("✅ Default key created!"));
627
+ }
628
+
629
+ const result = await startServerAndTunnel(keyData.key);
630
+ if (!result) {
631
+ await inquirer.prompt([{ type: "input", name: "c", message: "Press Enter to go back..." }]);
632
+ await mainMenu();
633
+ return;
634
+ }
635
+
636
+ const { serverManager, tunnelProcess, tunnelUrl } = result;
637
+
638
+ await showConnectionInfo(keyData.key, tunnelUrl);
639
+ setupExitHandler(serverManager, tunnelProcess, keyData.key);
640
+
641
+ // Keep process alive
642
+ await new Promise(() => { });
643
+ }
644
+
645
+ /**
646
+ * Manage single key menu
647
+ */
648
+ async function manageKey() {
649
+ const machineId = await getConsistentMachineId();
650
+ let keyData = loadKey();
651
+
652
+ // Auto create key if none exists
653
+ if (!keyData.key) {
654
+ console.log(chalk.yellow("\n⚠️ No key found. Creating default key..."));
655
+ const { key } = generateApiKeyWithMachine(machineId);
656
+ keyData = saveKey(machineId, key, "Default");
657
+ console.log(chalk.green("✅ Default key created!"));
658
+ }
659
+
660
+ console.log(ORANGE("\n🔑 Manage Key"));
661
+ console.log(chalk.gray("━".repeat(30)));
662
+ console.log(chalk.white(`Key: ${keyData.key}`));
663
+ console.log(chalk.gray(`Created: ${keyData.createdAt}\n`));
664
+
665
+ const { action } = await inquirer.prompt([
666
+ {
667
+ type: "list",
668
+ name: "action",
669
+ message: "Action:",
670
+ choices: [
671
+ { name: "🔐 Create One-Time Key", value: "oneTime" },
672
+ { name: "🔄 Regenerate Key", value: "regenerate" },
673
+ { name: chalk.gray("← Back"), value: "back" }
674
+ ]
675
+ }
676
+ ]);
677
+
678
+ if (action === "oneTime") {
679
+ console.log(chalk.gray("\nCreating one-time key..."));
680
+ const tempKeyData = await createTempKey(keyData.key, WORKER_URL);
681
+
682
+ if (tempKeyData) {
683
+ const connectUrl = `${WORKER_URL}/login?k=${tempKeyData.tempKey}`;
684
+ const width = Math.min(50, process.stdout.columns || 50);
685
+
686
+ showQRCode(connectUrl);
687
+
688
+ console.log(chalk.gray(`\nQR will expire in 30 minutes (one-time use)\n`));
689
+
690
+ console.log(ORANGE("═".repeat(width)));
691
+
692
+ // App URL
693
+ const appLabel = "App URL";
694
+ const appValue = `${WORKER_URL}/login`;
695
+ console.log(chalk.white(appLabel.padEnd(16)) + chalk.gray(appValue));
696
+
697
+ // One-Time Key
698
+ const keyLabel = "One-Time Key";
699
+ const keyValue = tempKeyData.tempKey;
700
+ console.log(chalk.white(keyLabel.padEnd(16)) + ORANGE.bold(keyValue));
701
+
702
+ console.log(ORANGE("═".repeat(width)));
703
+ } else {
704
+ console.log(chalk.red("❌ Failed to create one-time key"));
705
+ }
706
+ await inquirer.prompt([{ type: "input", name: "continue", message: "Press Enter to continue..." }]);
707
+ }
708
+
709
+ if (action === "regenerate") {
710
+ const { confirm } = await inquirer.prompt([
711
+ {
712
+ type: "confirm",
713
+ name: "confirm",
714
+ message: chalk.yellow("⚠️ This will replace your current key. Continue?"),
715
+ default: false
716
+ }
717
+ ]);
718
+
719
+ if (confirm) {
720
+ const { key } = generateApiKeyWithMachine(machineId);
721
+ keyData = saveKey(machineId, key, keyData.name);
722
+
723
+ console.log(chalk.green(`\n✅ Key regenerated: ${keyData.key}`));
724
+ await inquirer.prompt([{ type: "input", name: "continue", message: "Press Enter to continue..." }]);
725
+ }
726
+ }
727
+
728
+ await mainMenu();
729
+ }
730
+
731
+ /**
732
+ * Auto start dev server (--auto flag)
733
+ */
734
+ async function autoStartDev() {
735
+ showBanner();
736
+
737
+ const machineId = await getConsistentMachineId();
738
+ let keyData = loadKey();
739
+
740
+ // Auto create key if none exists
741
+ if (!keyData.key) {
742
+ console.log(chalk.yellow("⚠️ No key found. Creating default key..."));
743
+ const { key } = generateApiKeyWithMachine(machineId);
744
+ keyData = saveKey(machineId, key, "Default");
745
+ console.log(chalk.green("✅ Default key created!"));
746
+ }
747
+
748
+ console.log(chalk.gray(`Using key: ${keyData.key.slice(0, 20)}... (${keyData.name})`));
749
+
750
+ const result = await startServerAndTunnel(keyData.key);
751
+ if (!result) {
752
+ process.exit(1);
753
+ }
754
+
755
+ const { serverManager, tunnelProcess, tunnelUrl } = result;
756
+
757
+ await showConnectionInfo(keyData.key, tunnelUrl);
758
+ setupExitHandler(serverManager, tunnelProcess, keyData.key);
759
+
760
+ // Push stats to UI every 5s
761
+ const startTime = Date.now();
762
+ setInterval(() => {
763
+ const uptime = Math.floor((Date.now() - startTime) / 1000);
764
+ const h = Math.floor(uptime / 3600), m = Math.floor((uptime % 3600) / 60), s = uptime % 60;
765
+ pushUiState({ uptime: `${h}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}` });
766
+ }, 5000);
767
+
768
+ // Keep process alive
769
+ await new Promise(() => { });
770
+ }
771
+
772
+
773
+ /**
774
+ * Check if server is already running on SERVER_PORT
775
+ */
776
+ async function isServerRunning() {
777
+ try {
778
+ const res = await fetch(`http://localhost:${SERVER_PORT}/api/health`);
779
+ return res.ok;
780
+ } catch {
781
+ return false;
782
+ }
783
+ }
784
+
785
+ /**
786
+ * UI mode: start server (if not running) + tunnel + open browser
787
+ * Same as "Start Server" in TUI but auto-opens browser
788
+ */
789
+ async function startUiMode() {
790
+ showBanner();
791
+
792
+ const machineId = await getConsistentMachineId();
793
+ let keyData = loadKey();
794
+
795
+ if (!keyData.key) {
796
+ const { key } = generateApiKeyWithMachine(machineId);
797
+ keyData = saveKey(machineId, key, "Default");
798
+ }
799
+
800
+ const result = await startServerAndTunnel(keyData.key);
801
+ if (!result) process.exit(1);
802
+
803
+ const { serverManager, tunnelProcess, tunnelUrl } = result;
804
+
805
+ await showConnectionInfo(keyData.key, tunnelUrl);
806
+
807
+ // Open browser pointing to UI
808
+ const url = `http://localhost:${SERVER_PORT}`;
809
+ const openCmd = process.platform === "darwin" ? "open"
810
+ : process.platform === "win32" ? "start"
811
+ : "xdg-open";
812
+ spawn(openCmd, [url], { detached: true, stdio: "ignore" }).unref();
813
+ console.log(chalk.green(`\n🌐 UI ready at ${url}`));
814
+
815
+ setupExitHandler(serverManager, tunnelProcess, keyData.key);
816
+
817
+ // Push stats to UI every 5s
818
+ const startTime = Date.now();
819
+ setInterval(() => {
820
+ const uptime = Math.floor((Date.now() - startTime) / 1000);
821
+ const h = Math.floor(uptime / 3600), m = Math.floor((uptime % 3600) / 60), s = uptime % 60;
822
+ pushUiState({ uptime: `${h}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}` });
823
+ }, 5000);
824
+
825
+ await new Promise(() => { });
826
+ }
827
+
828
+ // Start app
829
+ async function start() {
830
+ // Check and auto-update (exits if update started)
831
+ const hasUpdate = await checkAndUpdate(skipUpdate);
832
+ if (hasUpdate) return;
833
+
834
+ const command = process.argv[2];
835
+
836
+ if (command === "ui") {
837
+ // UI mode: 9remote ui
838
+ await startUiMode();
839
+ } else if (command === "start" || process.argv.includes("--auto")) {
840
+ // Direct start: 9remote start
841
+ await autoStartDev();
842
+ } else {
843
+ // Menu mode: 9remote
844
+ await mainMenu();
845
+ }
846
+ }
847
+
848
+ start().catch(console.error);