9remote 0.1.53 → 0.1.54

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 CHANGED
@@ -10,10 +10,12 @@ import fs from "fs";
10
10
  import os from "os";
11
11
  import { getConsistentMachineId } from "./utils/machineId.js";
12
12
  import { generateApiKeyWithMachine } from "./utils/apiKey.js";
13
- import { loadKey, saveKey, loadState, saveState, clearState } from "./utils/state.js";
13
+ import { loadKey, saveKey, loadState, saveState, clearState, readAndClearCmd } from "./utils/state.js";
14
14
  import { createTempKey } from "./utils/token.js";
15
- import { checkAndUpdate } from "./utils/updateChecker.js";
16
- import { spawnQuickTunnel, killCloudflared, resetRestartCounter } from "./utils/cloudflared.js";
15
+ import { checkAndUpdate, checkLatestVersion } from "./utils/updateChecker.js";
16
+ import { spawnQuickTunnel, killCloudflared, resetRestartCounter, ensureCloudflared } from "./utils/cloudflared.js";
17
+ import { showBanner, renderProgress, resetProgress, selectMenu, confirm as tuiConfirm, subscribeSSE, openPermissionPane } from "./utils/tui.js";
18
+ import { checkPermissions } from "./utils/permissions.js";
17
19
 
18
20
  // Parse --skip-update flag
19
21
  const skipUpdate = process.argv.includes("--skip-update");
@@ -52,54 +54,23 @@ function getVersion() {
52
54
  }
53
55
  }
54
56
 
55
- /**
56
- * Show banner
57
- */
58
- function showBanner() {
59
- const version = getVersion();
60
- const width = Math.min(44, process.stdout.columns || 44);
61
-
62
- console.log("");
63
- console.log(ORANGE("╔" + "═".repeat(width - 2) + "╗"));
64
- console.log(ORANGE("║") + " ".repeat(width - 2) + ORANGE("║"));
65
-
66
- const title = `🚀 9Remote v${version}`;
67
- const titlePadding = Math.floor((width - 2 - title.length) / 2);
68
- console.log(
69
- ORANGE("║") +
70
- " ".repeat(titlePadding) +
71
- ORANGE.bold(title) +
72
- " ".repeat(width - 2 - titlePadding - title.length) +
73
- ORANGE("║")
74
- );
75
-
76
- const subtitle = "Remote terminal access from anywhere";
77
- const subtitlePadding = Math.floor((width - 2 - subtitle.length) / 2);
78
- console.log(
79
- ORANGE("║") +
80
- " ".repeat(subtitlePadding) +
81
- chalk.gray(subtitle) +
82
- " ".repeat(width - 2 - subtitlePadding - subtitle.length) +
83
- ORANGE("║")
84
- );
85
-
86
- console.log(ORANGE("║") + " ".repeat(width - 2) + ORANGE("║"));
87
- console.log(ORANGE("╚" + "═".repeat(width - 2) + "╝"));
88
- console.log("");
89
- }
90
57
 
91
58
  /**
92
59
  * Helper: Show QR code for connect URL
93
60
  */
94
61
  function showQRCode(url, title = "📱 Scan QR to connect:") {
95
62
  console.log(ORANGE(`\n${title}`));
96
- qrcode.generate(url, {
97
- small: true,
98
- type: 'terminal',
99
- margin: 0,
100
- }, qr => {
101
- const lines = qr.trim().split('\n');
102
- console.log(lines.join('\n'));
63
+ qrcode.generate(url, { small: true, type: "terminal", margin: 0 }, (qr) => {
64
+ console.log(qr.trim());
65
+ });
66
+ }
67
+
68
+ /** Build QR block as string (for use in headerContent) */
69
+ function buildQRString(url) {
70
+ return new Promise((resolve) => {
71
+ qrcode.generate(url, { small: true, type: "terminal", margin: 0 }, (qr) => {
72
+ resolve(ORANGE_DIM("📱 Scan QR to connect:") + "\n" + qr.trim());
73
+ });
103
74
  });
104
75
  }
105
76
 
@@ -119,7 +90,7 @@ async function showConnectionInfo(selectedKey, tunnelUrl) {
119
90
 
120
91
  // Push ready state to UI (include permanentKey, expiresAt, workerUrl for server-side key generation)
121
92
  pushUiState({
122
- step: 3,
93
+ step: 4,
123
94
  tunnelUrl,
124
95
  oneTimeKey: tempKeyData.tempKey,
125
96
  oneTimeKeyExpiresAt: tempKeyData.expiresAt,
@@ -152,6 +123,31 @@ async function showConnectionInfo(selectedKey, tunnelUrl) {
152
123
  console.log(ORANGE("═".repeat(width)));
153
124
  }
154
125
 
126
+ /**
127
+ * Build full header string: QR + keys info (for selectMenu headerContent)
128
+ */
129
+ async function buildMenuHeader(oneTimeKey, permanentKey, connectUrl) {
130
+ const w = Math.min(44, process.stdout.columns || 44);
131
+ const lines = [];
132
+
133
+ if (oneTimeKey && connectUrl) {
134
+ const qrBlock = await buildQRString(connectUrl);
135
+ lines.push(qrBlock);
136
+ lines.push(chalk.gray("\nQR expires in 30 minutes (one-time use)\n"));
137
+ } else {
138
+ lines.push(chalk.gray("\n(One-time key used — generate a new one from menu)\n"));
139
+ }
140
+
141
+ lines.push(
142
+ ORANGE("═".repeat(w)),
143
+ chalk.white("App URL".padEnd(14)) + chalk.gray(`${WORKER_URL}/login`),
144
+ chalk.white("One-Time Key".padEnd(14)) + (oneTimeKey ? ORANGE.bold(oneTimeKey) + chalk.dim(" (expires in 30m)") : chalk.gray("—")),
145
+ chalk.white("Key".padEnd(14)) + chalk.dim(permanentKey),
146
+ ORANGE("═".repeat(w)),
147
+ );
148
+ return lines.join("\n");
149
+ }
150
+
155
151
  /**
156
152
  * Kill process on specific port
157
153
  */
@@ -279,7 +275,7 @@ function setupExitHandler(serverManager, tunnelProcess, apiKey) {
279
275
  console.log(chalk.yellow("\n\n🛑 Stopping server..."));
280
276
 
281
277
  serverManager.shutdown();
282
- tunnelProcess.kill();
278
+ if (tunnelProcess) tunnelProcess.kill();
283
279
  resetRestartCounter();
284
280
  clearState();
285
281
 
@@ -388,10 +384,6 @@ async function startServerAndTunnel(selectedKey) {
388
384
  return null;
389
385
  }
390
386
 
391
- console.log(ORANGE(`✅ Tunnel URL: ${tunnelUrl}`));
392
- const lanIp = getLanIp();
393
- if (lanIp) console.log(ORANGE(`✅ Local IP: ${lanIp}:${SERVER_PORT} (LAN direct available)`));
394
- console.log(ORANGE(`✅ Connection established`));
395
387
 
396
388
  // Save tunnelUrl to worker DB
397
389
  await updateTunnelUrl(selectedKey, tunnelUrl);
@@ -409,160 +401,284 @@ async function startServerAndTunnel(selectedKey) {
409
401
 
410
402
 
411
403
  /**
412
- * Show main menu
404
+ * TUI mode — main flow for `9remote` (no subcommand)
413
405
  */
414
- async function mainMenu() {
406
+ async function tuiMode() {
415
407
  console.clear();
416
- showBanner();
417
-
418
- const { action } = await inquirer.prompt([
419
- {
420
- type: "list",
421
- name: "action",
422
- message: "Select action:",
423
- choices: [
424
- { name: "🚀 Start Server", value: "start" },
425
- { name: "🔑 Manage Key", value: "key" },
426
- { name: "❌ Exit", value: "exit" }
427
- ]
428
- }
429
- ]);
430
408
 
431
- switch (action) {
432
- case "start":
433
- await startServer();
434
- break;
435
- case "key":
436
- await manageKey();
437
- break;
438
- case "exit":
439
- console.log(chalk.gray("Goodbye!"));
440
- process.exit(0);
441
- }
442
- }
443
-
444
- /**
445
- * Start server with single key
446
- */
447
- async function startServer() {
409
+ // ── 1. Parallel: check latest version + start server ──────────────────────
448
410
  const machineId = await getConsistentMachineId();
449
411
  let keyData = loadKey();
450
-
451
- // Auto create key if none exists
452
412
  if (!keyData.key) {
453
- console.log(chalk.yellow("\n⚠️ No key found. Creating default key..."));
454
413
  const { key } = generateApiKeyWithMachine(machineId);
455
414
  keyData = saveKey(machineId, key, "Default");
456
- console.log(chalk.green("✅ Default key created!"));
457
415
  }
458
416
 
459
- const result = await startServerAndTunnel(keyData.key);
460
- if (!result) {
461
- await inquirer.prompt([{ type: "input", name: "c", message: "Press Enter to go back..." }]);
462
- await mainMenu();
463
- return;
417
+ // Run update check & server start in parallel
418
+ const [updateInfo] = await Promise.all([
419
+ checkLatestVersion(),
420
+ (async () => {
421
+ const alreadyRunning = await isServerRunning();
422
+ if (!alreadyRunning) {
423
+ startServerWithRestart(null, null);
424
+ await new Promise((r) => setTimeout(r, 2000));
425
+ }
426
+ })(),
427
+ ]);
428
+
429
+ // ── 2. Show banner (with update notice if available) ──────────────────────
430
+ const version = getVersion();
431
+ showBanner(version, updateInfo?.latest ?? null);
432
+
433
+ // ── 3. Progress: Preparing → Connecting → Tunneling → Ready ──────────────
434
+ resetProgress();
435
+ renderProgress(0); // Preparing
436
+
437
+ // Kill stale cloudflared
438
+ try { killCloudflared(); await new Promise((r) => setTimeout(r, 300)); } catch {}
439
+
440
+ // Step 1 — Preparing (ensureCloudflared)
441
+ await ensureCloudflared();
442
+
443
+ renderProgress(1, true); // Connecting
444
+
445
+ // Step 2 — Connecting (create session)
446
+ try {
447
+ const res = await fetch(`${WORKER_URL}/api/session/create`, {
448
+ method: "POST",
449
+ headers: { "Content-Type": "application/json" },
450
+ body: JSON.stringify({ apiKey: keyData.key }),
451
+ });
452
+ if (!res.ok) throw new Error(`Session create failed: ${res.status}`);
453
+ } catch (err) {
454
+ console.log(chalk.red(`\n❌ Failed to connect: ${err.message}`));
455
+ process.exit(1);
464
456
  }
465
457
 
466
- const { serverManager, tunnelProcess, tunnelUrl } = result;
458
+ renderProgress(2, true); // Starting tunnel
467
459
 
468
- await showConnectionInfo(keyData.key, tunnelUrl);
469
- setupExitHandler(serverManager, tunnelProcess, keyData.key);
460
+ // Step 3 — Tunnel
461
+ let tunnelProcess, tunnelUrl;
462
+ try {
463
+ const result = await spawnQuickTunnel(SERVER_PORT, async (newUrl) => {
464
+ await updateTunnelUrl(keyData.key, newUrl);
465
+ await pushUiState({ tunnelUrl: newUrl });
466
+ });
467
+ tunnelProcess = result.child;
468
+ tunnelUrl = result.tunnelUrl;
469
+ } catch (err) {
470
+ console.log(chalk.red(`\n❌ Tunnel failed: ${err.message}`));
471
+ process.exit(1);
472
+ }
470
473
 
471
- // Keep process alive
472
- await new Promise(() => { });
474
+ await updateTunnelUrl(keyData.key, tunnelUrl);
475
+ saveState({ apiKey: keyData.key, tunnelUrl, tunnelPid: tunnelProcess.pid });
476
+
477
+ // ── 4. Create temp key + push ready state ─────────────────────────────────
478
+ const tempKeyData = await createTempKey(keyData.key, WORKER_URL);
479
+ const connectUrl = tempKeyData
480
+ ? `${WORKER_URL}/login?k=${tempKeyData.tempKey}`
481
+ : `${WORKER_URL}/login`;
482
+
483
+ await pushUiState({
484
+ step: 4,
485
+ tunnelUrl,
486
+ oneTimeKey: tempKeyData?.tempKey || "",
487
+ oneTimeKeyExpiresAt: tempKeyData?.expiresAt || null,
488
+ permanentKey: keyData.key,
489
+ qrUrl: connectUrl,
490
+ workerUrl: WORKER_URL,
491
+ });
492
+
493
+ // ── 5. Load initial state from server ────────────────────────────────────
494
+ let currentOneTimeKey = tempKeyData?.tempKey || "";
495
+ let currentConnectUrl = connectUrl;
496
+
497
+ // ── 6. Build initial header ───────────────────────────────────────────────
498
+ let menuHeader = await buildMenuHeader(currentOneTimeKey, keyData.key, currentConnectUrl);
499
+
500
+ // Mutable ref for menu re-render callback (set by selectMenu)
501
+ let triggerMenuRedraw = null;
502
+
503
+ // ── 7. Subscribe SSE — auto-rebuild header on state changes ──────────────
504
+ const stopSSE = subscribeSSE(SERVER_PORT, async (type, data) => {
505
+ if (type === "state") {
506
+ const newKey = data.permanentKey || keyData.key;
507
+ // Explicit check: "" means cleared (one-time key consumed), preserve existing if undefined
508
+ const newOtk = data.oneTimeKey !== undefined ? data.oneTimeKey : currentOneTimeKey;
509
+ const newUrl = data.qrUrl !== undefined ? data.qrUrl : currentConnectUrl;
510
+ if (newOtk !== currentOneTimeKey || newKey !== keyData.key) {
511
+ currentOneTimeKey = newOtk;
512
+ currentConnectUrl = newUrl;
513
+ if (data.permanentKey) keyData = { ...keyData, key: data.permanentKey };
514
+ menuHeader = await buildMenuHeader(currentOneTimeKey, keyData.key, currentConnectUrl);
515
+ triggerMenuRedraw?.();
516
+ }
517
+ } else if (type === "permissions") {
518
+ // desktopEnabled changed — trigger redraw so menu label refreshes
519
+ triggerMenuRedraw?.();
520
+ }
521
+ });
522
+
523
+ setupExitHandler({ getProcess: () => null, shutdown: () => { stopSSE(); } }, tunnelProcess, keyData.key);
524
+
525
+ // ── 8. Interactive menu loop ───────────────────────────────────────────────
526
+ await tuiMenuLoop(
527
+ keyData, tunnelUrl,
528
+ () => menuHeader,
529
+ (h) => { menuHeader = h; },
530
+ (cb) => { triggerMenuRedraw = cb; }
531
+ );
532
+ }
533
+
534
+ /** Fetch desktopEnabled from server (source of truth) */
535
+ async function fetchDesktopEnabled() {
536
+ try {
537
+ const res = await fetch(`http://localhost:${SERVER_PORT}/api/ui/state`);
538
+ if (res.ok) { const d = await res.json(); return !!d.desktopEnabled; }
539
+ } catch {}
540
+ return false;
473
541
  }
474
542
 
475
543
  /**
476
- * Manage single key menu
544
+ * Main menu loop after Ready.
545
+ * @param {object} keyData
546
+ * @param {string} tunnelUrl
547
+ * @param {() => string} getHeader - live header getter (SSE may update it)
548
+ * @param {(newHeader: string) => void} setHeader - update header from inside loop
549
+ * @param {(cb: () => void) => void} onRedrawRegister - register redraw callback
477
550
  */
478
- async function manageKey() {
479
- const machineId = await getConsistentMachineId();
480
- let keyData = loadKey();
551
+ async function tuiMenuLoop(keyData, tunnelUrl, getHeader = () => "", setHeader = () => {}, onRedrawRegister = () => {}) {
552
+ while (true) {
553
+ const desktopOn = await fetchDesktopEnabled();
554
+ const desktopLabel = `Remote Desktop: ${desktopOn ? chalk.green("ON") : chalk.gray("OFF")} ▶`;
555
+ const items = [
556
+ { label: "Open Web UI" },
557
+ { label: "New One-Time Key" },
558
+ { label: "Regenerate Permanent Key" },
559
+ { label: desktopLabel },
560
+ { label: chalk.gray("Exit") },
561
+ ];
562
+
563
+ // Register SSE-triggered redraw with selectMenu
564
+ let redrawMenu = null;
565
+ onRedrawRegister(() => redrawMenu?.());
566
+
567
+ const idx = await selectMenu("Select action", items, 0, getHeader, (setRedraw) => {
568
+ redrawMenu = setRedraw;
569
+ });
481
570
 
482
- // Auto create key if none exists
483
- if (!keyData.key) {
484
- console.log(chalk.yellow("\n⚠️ No key found. Creating default key..."));
485
- const { key } = generateApiKeyWithMachine(machineId);
486
- keyData = saveKey(machineId, key, "Default");
487
- console.log(chalk.green("✅ Default key created!"));
488
- }
571
+ if (idx === 0) {
572
+ // Open UI
573
+ const url = `http://localhost:${SERVER_PORT}`;
574
+ const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
575
+ spawn(cmd, [url], { detached: true, stdio: "ignore" }).unref();
576
+ console.log(chalk.green(`\n🌐 Opening ${url}\n`));
577
+
578
+ } else if (idx === 1) {
579
+ // New One-Time Key — rebuild header with fresh QR
580
+ const newTempKey = await createTempKey(keyData.key, WORKER_URL);
581
+ if (newTempKey) {
582
+ const newConnectUrl = `${WORKER_URL}/login?k=${newTempKey.tempKey}`;
583
+ setHeader(await buildMenuHeader(newTempKey.tempKey, keyData.key, newConnectUrl));
584
+ await pushUiState({ oneTimeKey: newTempKey.tempKey, oneTimeKeyExpiresAt: newTempKey.expiresAt, qrUrl: newConnectUrl });
585
+ }
489
586
 
490
- console.log(ORANGE("\n🔑 Manage Key"));
491
- console.log(chalk.gray("━".repeat(30)));
492
- console.log(chalk.white(`Key: ${keyData.key}`));
493
- console.log(chalk.gray(`Created: ${keyData.createdAt}\n`));
494
-
495
- const { action } = await inquirer.prompt([
496
- {
497
- type: "list",
498
- name: "action",
499
- message: "Action:",
500
- choices: [
501
- { name: "🔐 Create One-Time Key", value: "oneTime" },
502
- { name: "🔄 Regenerate Key", value: "regenerate" },
503
- { name: chalk.gray("← Back"), value: "back" }
504
- ]
505
- }
506
- ]);
587
+ } else if (idx === 2) {
588
+ // Regenerate Key
589
+ const confirmed = await tuiConfirm(chalk.yellow("⚠️ Replace current key and disconnect all sessions? Continue?"));
590
+ if (confirmed) {
591
+ const machineId = await getConsistentMachineId();
592
+ const { key } = generateApiKeyWithMachine(machineId);
593
+ keyData = saveKey(machineId, key, keyData.name || "Default");
594
+ await pushUiState({ permanentKey: keyData.key });
595
+ // Rebuild header with new key
596
+ const newTmp = await createTempKey(keyData.key, WORKER_URL);
597
+ if (newTmp) {
598
+ const newUrl = `${WORKER_URL}/login?k=${newTmp.tempKey}`;
599
+ setHeader(await buildMenuHeader(newTmp.tempKey, keyData.key, newUrl));
600
+ await pushUiState({ oneTimeKey: newTmp.tempKey, oneTimeKeyExpiresAt: newTmp.expiresAt, qrUrl: newUrl });
601
+ }
602
+ }
603
+
604
+ } else if (idx === 3) {
605
+ // Remote Desktop submenu
606
+ await tuiDesktopMenu();
507
607
 
508
- if (action === "oneTime") {
509
- console.log(chalk.gray("\nCreating one-time key..."));
510
- const tempKeyData = await createTempKey(keyData.key, WORKER_URL);
511
-
512
- if (tempKeyData) {
513
- const connectUrl = `${WORKER_URL}/login?k=${tempKeyData.tempKey}`;
514
- const width = Math.min(50, process.stdout.columns || 50);
515
-
516
- showQRCode(connectUrl);
517
-
518
- console.log(chalk.gray(`\nQR will expire in 30 minutes (one-time use)\n`));
519
-
520
- console.log(ORANGE("═".repeat(width)));
521
-
522
- // App URL
523
- const appLabel = "App URL";
524
- const appValue = `${WORKER_URL}/login`;
525
- console.log(chalk.white(appLabel.padEnd(16)) + chalk.gray(appValue));
526
-
527
- // One-Time Key
528
- const keyLabel = "One-Time Key";
529
- const keyValue = tempKeyData.tempKey;
530
- console.log(chalk.white(keyLabel.padEnd(16)) + ORANGE.bold(keyValue));
531
-
532
- console.log(ORANGE("═".repeat(width)));
533
608
  } else {
534
- console.log(chalk.red("❌ Failed to create one-time key"));
609
+ // Exit
610
+ console.log(chalk.gray("\nGoodbye!\n"));
611
+ process.exit(0);
535
612
  }
536
- await inquirer.prompt([{ type: "input", name: "continue", message: "Press Enter to continue..." }]);
537
613
  }
614
+ }
538
615
 
539
- if (action === "regenerate") {
540
- const { confirm } = await inquirer.prompt([
541
- {
542
- type: "confirm",
543
- name: "confirm",
544
- message: chalk.yellow("⚠️ This will replace your current key. Continue?"),
545
- default: false
616
+ /**
617
+ * Remote Desktop submenu.
618
+ * All state (desktopEnabled + permissions) fetched from server — no local tracking.
619
+ */
620
+ async function tuiDesktopMenu() {
621
+ while (true) {
622
+ // Always read from server
623
+ let desktopOn = false, perms = { screenRecording: false, accessibility: false };
624
+ try {
625
+ const res = await fetch(`http://localhost:${SERVER_PORT}/api/ui/state`);
626
+ if (res.ok) {
627
+ const d = await res.json();
628
+ desktopOn = !!d.desktopEnabled;
629
+ perms = { screenRecording: !!d.screenRecording, accessibility: !!d.accessibility };
546
630
  }
547
- ]);
548
-
549
- if (confirm) {
550
- const { key } = generateApiKeyWithMachine(machineId);
551
- keyData = saveKey(machineId, key, keyData.name);
552
-
553
- console.log(chalk.green(`\n✅ Key regenerated: ${keyData.key}`));
554
- await inquirer.prompt([{ type: "input", name: "continue", message: "Press Enter to continue..." }]);
631
+ } catch {}
632
+
633
+ const toggleLabel = `Toggle: ${desktopOn ? chalk.green("ON → turn OFF") : chalk.gray("OFF → turn ON")}`;
634
+ const srLabel = `Screen Recording ${perms.screenRecording ? chalk.green("✓") : chalk.red("✗ (click to grant)")}`;
635
+ const axLabel = `Mouse & Keyboard control ${perms.accessibility ? chalk.green("✓") : chalk.red("✗ (click to grant)")}`;
636
+
637
+ const idx = await selectMenu("Remote Desktop", [
638
+ { label: toggleLabel },
639
+ { label: srLabel },
640
+ { label: axLabel },
641
+ { label: chalk.gray("← Back") },
642
+ ], 0);
643
+
644
+ if (idx === 0) {
645
+ // Toggle — flip current value via server
646
+ try {
647
+ await fetch(`http://localhost:${SERVER_PORT}/api/desktop/toggle`, {
648
+ method: "POST",
649
+ headers: { "Content-Type": "application/json" },
650
+ body: JSON.stringify({ enabled: !desktopOn }),
651
+ });
652
+ } catch {}
653
+
654
+ } else if (idx === 1 && !perms.screenRecording) {
655
+ try {
656
+ await fetch(`http://localhost:${SERVER_PORT}/api/permissions/request`, {
657
+ method: "POST", headers: { "Content-Type": "application/json" },
658
+ body: JSON.stringify({ type: "screenRecording" }),
659
+ });
660
+ } catch { openPermissionPane("screenRecording"); }
661
+
662
+ } else if (idx === 2 && !perms.accessibility) {
663
+ try {
664
+ await fetch(`http://localhost:${SERVER_PORT}/api/permissions/request`, {
665
+ method: "POST", headers: { "Content-Type": "application/json" },
666
+ body: JSON.stringify({ type: "accessibility" }),
667
+ });
668
+ } catch { openPermissionPane("accessibility"); }
669
+
670
+ } else if (idx === 3 || idx === -1) {
671
+ return; // Back
555
672
  }
556
673
  }
557
-
558
- await mainMenu();
559
674
  }
560
675
 
676
+
561
677
  /**
562
678
  * Auto start dev server (--auto flag)
563
679
  */
564
680
  async function autoStartDev() {
565
- showBanner();
681
+ showBanner(getVersion());
566
682
 
567
683
  const machineId = await getConsistentMachineId();
568
684
  let keyData = loadKey();
@@ -602,17 +718,74 @@ async function autoStartDev() {
602
718
 
603
719
 
604
720
  /**
605
- * Listen for key regen request from server UI
721
+ * Listen for key regen, stop-tunnel, start-tunnel from server UI
606
722
  */
607
- function setupKeyRegenListener() {
608
- process.on("9remote:regenerate-key", async () => {
609
- const machineId = await getConsistentMachineId();
610
- const { key } = generateApiKeyWithMachine(machineId);
611
- const existing = loadKey();
612
- saveKey(machineId, key, existing?.name || "Default");
613
- pushUiState({ permanentKey: key });
614
- console.log(chalk.green(`✅ Key regenerated: ${key}`));
615
- });
723
+ function setupCmdPoller(getActiveTunnel, setActiveTunnel, apiKey) {
724
+ let busy = false;
725
+ setInterval(async () => {
726
+ if (busy) return;
727
+ const cmd = readAndClearCmd();
728
+ if (!cmd) return;
729
+ busy = true;
730
+ try {
731
+
732
+ if (cmd === "stop-tunnel") {
733
+ const tunnel = getActiveTunnel();
734
+ if (tunnel) {
735
+ tunnel.kill();
736
+ setActiveTunnel(null);
737
+ console.log(chalk.yellow("🛑 Tunnel stopped"));
738
+ }
739
+ await pushUiState({ step: 0, tunnelUrl: "", oneTimeKey: "", oneTimeKeyExpiresAt: null });
740
+ }
741
+
742
+ if (cmd === "start-tunnel") {
743
+ if (getActiveTunnel()) { busy = false; return; } // already running
744
+ console.log(ORANGE("🚀 Starting tunnel..."));
745
+ try {
746
+ // Step 1: Preparing — check/download cloudflared binary
747
+ await pushUiState({ step: 1 });
748
+ await ensureCloudflared();
749
+
750
+ // Step 2: Connecting — create session on worker
751
+ await pushUiState({ step: 2 });
752
+ const sessionResponse = await fetch(`${WORKER_URL}/api/session/create`, {
753
+ method: "POST",
754
+ headers: { "Content-Type": "application/json" },
755
+ body: JSON.stringify({ apiKey }),
756
+ });
757
+ if (!sessionResponse.ok) throw new Error(`Session create failed: ${sessionResponse.status}`);
758
+
759
+ // Step 3: Tunneling — spawn cloudflared
760
+ await pushUiState({ step: 3 });
761
+ const result = await spawnQuickTunnel(SERVER_PORT, async (newUrl) => {
762
+ await updateTunnelUrl(apiKey, newUrl);
763
+ await pushUiState({ tunnelUrl: newUrl });
764
+ });
765
+ setActiveTunnel(result.child);
766
+ await updateTunnelUrl(apiKey, result.tunnelUrl);
767
+
768
+ // Step 4: Ready
769
+ await showConnectionInfo(apiKey, result.tunnelUrl);
770
+ } catch (err) {
771
+ console.log(chalk.red(`❌ Failed to start tunnel: ${err.message}`));
772
+ await pushUiState({ step: 0 });
773
+ }
774
+ }
775
+
776
+ if (cmd === "regenerate-key") {
777
+ const machineId = await getConsistentMachineId();
778
+ const { key } = generateApiKeyWithMachine(machineId);
779
+ const existing = loadKey();
780
+ saveKey(machineId, key, existing?.name || "Default");
781
+ await pushUiState({ permanentKey: key });
782
+ console.log(chalk.green(`✅ Key regenerated: ${key}`));
783
+ }
784
+
785
+ } finally {
786
+ busy = false;
787
+ }
788
+ }, 1000);
616
789
  }
617
790
 
618
791
  /**
@@ -632,7 +805,7 @@ async function isServerRunning() {
632
805
  * Same as "Start Server" in TUI but auto-opens browser
633
806
  */
634
807
  async function startUiMode() {
635
- showBanner();
808
+ showBanner(getVersion());
636
809
 
637
810
  const machineId = await getConsistentMachineId();
638
811
  let keyData = loadKey();
@@ -642,12 +815,13 @@ async function startUiMode() {
642
815
  keyData = saveKey(machineId, key, "Default");
643
816
  }
644
817
 
645
- const result = await startServerAndTunnel(keyData.key);
646
- if (!result) process.exit(1);
647
-
648
- const { serverManager, tunnelProcess, tunnelUrl } = result;
818
+ // Start server only (no tunnel yet — wait for UI Connect button)
819
+ const alreadyRunning = await isServerRunning();
820
+ const serverManager = alreadyRunning
821
+ ? { getProcess: () => null, shutdown: () => {} }
822
+ : startServerWithRestart(null, null);
649
823
 
650
- await showConnectionInfo(keyData.key, tunnelUrl);
824
+ if (!alreadyRunning) await new Promise(resolve => setTimeout(resolve, 2000));
651
825
 
652
826
  // Open browser pointing to UI
653
827
  const url = `http://localhost:${SERVER_PORT}`;
@@ -657,16 +831,16 @@ async function startUiMode() {
657
831
  spawn(openCmd, [url], { detached: true, stdio: "ignore" }).unref();
658
832
  console.log(chalk.green(`\n🌐 UI ready at ${url}`));
659
833
 
660
- setupExitHandler(serverManager, tunnelProcess, keyData.key);
661
- setupKeyRegenListener();
834
+ // Mutable ref for active tunnel
835
+ let activeTunnel = null;
836
+ const getActiveTunnel = () => activeTunnel;
837
+ const setActiveTunnel = (t) => { activeTunnel = t; };
662
838
 
663
- // Push stats to UI every 5s
664
- const startTime = Date.now();
665
- setInterval(() => {
666
- const uptime = Math.floor((Date.now() - startTime) / 1000);
667
- const h = Math.floor(uptime / 3600), m = Math.floor((uptime % 3600) / 60), s = uptime % 60;
668
- pushUiState({ uptime: `${h}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}` });
669
- }, 5000);
839
+ // Push permanentKey to UI so Welcome screen can display it
840
+ await pushUiState({ permanentKey: keyData.key, step: 0 });
841
+
842
+ setupExitHandler(serverManager, null, keyData.key);
843
+ setupCmdPoller(getActiveTunnel, setActiveTunnel, keyData.key);
670
844
 
671
845
  await new Promise(() => { });
672
846
  }
@@ -686,8 +860,8 @@ async function start() {
686
860
  // Direct start: 9remote start
687
861
  await autoStartDev();
688
862
  } else {
689
- // Menu mode: 9remote
690
- await mainMenu();
863
+ // TUI mode: 9remote
864
+ await tuiMode();
691
865
  }
692
866
  }
693
867