9remote 0.1.64 → 2.0.2

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
@@ -3,27 +3,28 @@
3
3
  import inquirer from "inquirer";
4
4
  import chalk from "chalk";
5
5
  import qrcode from "qrcode-terminal";
6
- import { spawn, execSync } from "child_process";
6
+ import { spawn, execSync, execFile } from "child_process";
7
7
  import path from "path";
8
8
  import { fileURLToPath } from "url";
9
9
  import fs from "fs";
10
10
  import os from "os";
11
+ import dns from "dns";
11
12
  import { getConsistentMachineId } from "./utils/machineId.js";
12
13
  import { generateApiKeyWithMachine } from "./utils/apiKey.js";
13
14
  import { loadKey, saveKey, loadState, saveState, clearState, readAndClearCmd, writeCmd } from "./utils/state.js";
14
15
  import { createTempKey } from "./utils/token.js";
15
- import { checkAndUpdate, checkLatestVersion } from "./utils/updateChecker.js";
16
+ import { checkAndUpdate, checkLatestVersion, stopRunningInstances } from "./utils/updateChecker.js";
17
+ import { writePid, clearPid } from "./utils/pids.js";
16
18
  import { spawnQuickTunnel, killCloudflared, resetRestartCounter, ensureCloudflared } from "./utils/cloudflared.js";
17
- import { showBanner, renderProgress, resetProgress, selectMenu, confirm as tuiConfirm, subscribeSSE, openPermissionPane } from "./utils/tui.js";
19
+ import { showBanner, getBannerText, renderProgress, resetProgress, updateProgressDesc, setProgressInfo, selectMenu, confirm as tuiConfirm, subscribeSSE, openPermissionPane, showDeviceApproval } from "./utils/tui.js";
18
20
  import { checkPermissions } from "./utils/permissions.js";
21
+ import { initTray, killTray, openBrowser, updateTrayTooltip, showTrayNotification } from "./utils/tray.js";
22
+ import { STEP, DEBUG, browserFetch } from "../lib/constants.js";
19
23
 
20
- // Parse --skip-update flag
21
24
  const skipUpdate = process.argv.includes("--skip-update");
22
25
 
23
26
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
24
27
  const PROJECT_ROOT = path.resolve(__dirname, "../..");
25
- // When bundled (dist/cli.cjs), server.cjs is in same dist/ folder
26
- // When dev (server/cli/index.js), use server/index.js directly
27
28
  const STANDALONE_SERVER = path.resolve(__dirname, "../dist/server.cjs");
28
29
  const DEV_SERVER = path.resolve(__dirname, "../index.js");
29
30
  const WORKER_URL = "https://9remote.cc";
@@ -31,20 +32,28 @@ const SERVER_PORT = 2208;
31
32
  const MAX_RESTART_ATTEMPTS = 10;
32
33
  const RESTART_WINDOW_MS = 60000; // 1 minute
33
34
 
34
- // Orange color from gitbook (#E68A6E)
35
35
  const ORANGE = chalk.rgb(230, 138, 110);
36
36
  const ORANGE_DIM = chalk.rgb(200, 120, 95);
37
37
 
38
- /**
39
- * Get current version from package.json
40
- */
38
+ // Submenus set this to receive SSE-driven refreshes (permissions, state, ...) while open
39
+ let activeSubmenuRefresh = null;
40
+
41
+ /** Ensure API key exists, create if missing */
42
+ async function ensureKeyData() {
43
+ const machineId = await getConsistentMachineId();
44
+ let keyData = loadKey();
45
+ if (!keyData.key) {
46
+ const { key } = generateApiKeyWithMachine(machineId);
47
+ keyData = saveKey(machineId, key, "Default");
48
+ }
49
+ return keyData;
50
+ }
51
+
41
52
  function getVersion() {
42
- // When bundled, version is injected at build time
43
53
  if (typeof __CLI_VERSION__ !== "undefined") {
44
54
  return __CLI_VERSION__;
45
55
  }
46
56
 
47
- // Dev mode: read from package.json
48
57
  try {
49
58
  const packagePath = path.join(__dirname, "..", "package.json");
50
59
  const packageJson = JSON.parse(fs.readFileSync(packagePath, "utf-8"));
@@ -54,10 +63,6 @@ function getVersion() {
54
63
  }
55
64
  }
56
65
 
57
-
58
- /**
59
- * Helper: Show QR code for connect URL
60
- */
61
66
  function showQRCode(url, title = "šŸ“± Scan QR to connect:") {
62
67
  console.log(ORANGE(`\n${title}`));
63
68
  qrcode.generate(url, { small: true, type: "terminal", margin: 0 }, (qr) => {
@@ -65,7 +70,6 @@ function showQRCode(url, title = "šŸ“± Scan QR to connect:") {
65
70
  });
66
71
  }
67
72
 
68
- /** Build QR block as string (for use in headerContent) */
69
73
  function buildQRString(url) {
70
74
  return new Promise((resolve) => {
71
75
  qrcode.generate(url, { small: true, type: "terminal", margin: 0 }, (qr) => {
@@ -74,9 +78,6 @@ function buildQRString(url) {
74
78
  });
75
79
  }
76
80
 
77
- /**
78
- * Helper: Show connection info
79
- */
80
81
  async function showConnectionInfo(selectedKey, tunnelUrl) {
81
82
  const tempKeyData = await createTempKey(selectedKey, WORKER_URL);
82
83
 
@@ -88,9 +89,7 @@ async function showConnectionInfo(selectedKey, tunnelUrl) {
88
89
  const connectUrl = `${WORKER_URL}/login?k=${tempKeyData.tempKey}`;
89
90
  const width = Math.min(44, process.stdout.columns || 55);
90
91
 
91
- // Push ready state to UI (include permanentKey, expiresAt, workerUrl for server-side key generation)
92
- pushUiState({
93
- step: 4,
92
+ await setStep(STEP.READY, {
94
93
  tunnelUrl,
95
94
  oneTimeKey: tempKeyData.tempKey,
96
95
  oneTimeKeyExpiresAt: tempKeyData.expiresAt,
@@ -105,17 +104,14 @@ async function showConnectionInfo(selectedKey, tunnelUrl) {
105
104
 
106
105
  console.log(ORANGE("═".repeat(width)));
107
106
 
108
- // App URL
109
107
  const appLabel = "App URL";
110
108
  const appValue = `${WORKER_URL}/login`;
111
109
  console.log(chalk.white(appLabel.padEnd(14)) + chalk.gray(appValue));
112
110
 
113
- // One-Time Key
114
111
  const keyLabel = "One-Time Key";
115
112
  const keyValue = tempKeyData.tempKey;
116
113
  console.log(chalk.white(keyLabel.padEnd(14)) + ORANGE.bold(keyValue));
117
114
 
118
- // Permanent Key
119
115
  const permLabel = "Key";
120
116
  const permValue = selectedKey;
121
117
  console.log(chalk.white(permLabel.padEnd(14)) + chalk.gray(permValue));
@@ -123,10 +119,7 @@ async function showConnectionInfo(selectedKey, tunnelUrl) {
123
119
  console.log(ORANGE("═".repeat(width)));
124
120
  }
125
121
 
126
- /**
127
- * Build full header string: QR + keys info (for selectMenu headerContent)
128
- */
129
- async function buildMenuHeader(oneTimeKey, permanentKey, connectUrl) {
122
+ async function buildMenuHeader(oneTimeKey, permanentKey, connectUrl, tunnelUrl = "") {
130
123
  const w = Math.min(44, process.stdout.columns || 44);
131
124
  const lines = [];
132
125
 
@@ -141,6 +134,13 @@ async function buildMenuHeader(oneTimeKey, permanentKey, connectUrl) {
141
134
  lines.push(
142
135
  ORANGE("═".repeat(w)),
143
136
  chalk.white("App URL".padEnd(14)) + chalk.gray(`${WORKER_URL}/login`),
137
+ );
138
+
139
+ if (DEBUG.showTunnelUrlInMenu) {
140
+ lines.push(chalk.white("Tunnel".padEnd(14)) + (tunnelUrl ? chalk.cyan(tunnelUrl) : chalk.gray("—")));
141
+ }
142
+
143
+ lines.push(
144
144
  chalk.white("One-Time Key".padEnd(14)) + (oneTimeKey ? ORANGE.bold(oneTimeKey) + chalk.dim(" (expires in 30m)") : chalk.gray("—")),
145
145
  chalk.white("Key".padEnd(14)) + chalk.dim(permanentKey),
146
146
  ORANGE("═".repeat(w)),
@@ -148,13 +148,10 @@ async function buildMenuHeader(oneTimeKey, permanentKey, connectUrl) {
148
148
  return lines.join("\n");
149
149
  }
150
150
 
151
- /**
152
- * Kill process on specific port
153
- */
154
151
  function killProcessOnPort(port) {
155
152
  try {
156
153
  if (process.platform === "win32") {
157
- execSync(`for /f "tokens=5" %a in ('netstat -aon ^| findstr :${port}') do taskkill /F /PID %a`, { stdio: "ignore" });
154
+ execSync(`for /f "tokens=5" %a in ('netstat -aon ^| findstr :${port}') do taskkill /F /PID %a`, { stdio: "ignore", windowsHide: true });
158
155
  } else {
159
156
  const nullDevice = "/dev/null";
160
157
  execSync(`lsof -ti:${port} | xargs kill -9 2>${nullDevice} || true`, { stdio: "ignore" });
@@ -162,9 +159,6 @@ function killProcessOnPort(port) {
162
159
  } catch { }
163
160
  }
164
161
 
165
- /**
166
- * Start server with auto-restart on crash
167
- */
168
162
  function startServerWithRestart(onReady, onServerCrash) {
169
163
  const restartTimes = [];
170
164
  let currentProcess = null;
@@ -172,15 +166,12 @@ function startServerWithRestart(onReady, onServerCrash) {
172
166
  let isFirstStart = true;
173
167
 
174
168
  const spawnServer = () => {
175
- // Only kill port on first start, not on restart
176
- if (isFirstStart) {
169
+ if (isFirstStart) {
177
170
  killProcessOnPort(SERVER_PORT);
178
171
  isFirstStart = false;
179
- } else {
180
172
  }
181
173
 
182
- // Use dev server if exists (development), otherwise use standalone (npm package)
183
- const useDevServer = process.env.NODE_ENV === "development" && fs.existsSync(DEV_SERVER);
174
+ const useDevServer = process.env.NODE_ENV === "development" && fs.existsSync(DEV_SERVER);
184
175
  const serverPath = useDevServer ? DEV_SERVER : STANDALONE_SERVER;
185
176
 
186
177
  if (!fs.existsSync(serverPath)) {
@@ -188,17 +179,18 @@ function startServerWithRestart(onReady, onServerCrash) {
188
179
  process.exit(1);
189
180
  }
190
181
 
191
- // Strip NODE_ENV=development when running standalone server (production build)
192
- const spawnEnv = { ...process.env, PORT: String(SERVER_PORT) };
182
+ const spawnEnv = { ...process.env, PORT: String(SERVER_PORT) };
193
183
  if (!useDevServer) delete spawnEnv.NODE_ENV;
194
184
 
195
185
  currentProcess = spawn("node", [serverPath], {
196
186
  cwd: path.dirname(serverPath),
197
- stdio: ["ignore", "inherit", "inherit"],
187
+ stdio: "inherit",
198
188
  detached: false,
189
+ windowsHide: true,
199
190
  env: spawnEnv,
200
191
  });
201
-
192
+ // No separate PID file: server is a direct child of agent. When the
193
+ // updater kills agent with `taskkill /F /T`, this child dies too.
202
194
 
203
195
  currentProcess.on("exit", (code, signal) => {
204
196
  if (isShuttingDown) {
@@ -213,18 +205,20 @@ function startServerWithRestart(onReady, onServerCrash) {
213
205
  const now = Date.now();
214
206
  restartTimes.push(now);
215
207
 
216
- // Remove old restart times outside window
217
- while (restartTimes.length > 0 && restartTimes[0] < now - RESTART_WINDOW_MS) {
208
+ while (restartTimes.length > 0 && restartTimes[0] < now - RESTART_WINDOW_MS) {
218
209
  restartTimes.shift();
219
210
  }
220
211
 
221
212
  if (restartTimes.length > MAX_RESTART_ATTEMPTS) {
222
213
  console.log(chalk.red(`āŒ Too many restarts (${MAX_RESTART_ATTEMPTS} in ${RESTART_WINDOW_MS / 1000}s). Giving up.`));
223
- process.exit(1);
214
+ // Don't bare-exit: cloudflared + tray would orphan and PID files
215
+ // would go stale. shutdownAll handles all of that.
216
+ isShuttingDown = true;
217
+ shutdownAll({ code: 1 });
218
+ return;
224
219
  }
225
220
 
226
221
  console.log(chalk.yellow(`šŸ”„ Restarting server... (attempt ${restartTimes.length}/${MAX_RESTART_ATTEMPTS})`));
227
- console.log(ORANGE_DIM("āš ļø [DEBUG] NOTE: Tunnel connection may be stale - will restart tunnel"));
228
222
 
229
223
  // āœ… Callback Ä‘į»ƒ restart cloudflared
230
224
  if (onServerCrash) {
@@ -232,11 +226,9 @@ function startServerWithRestart(onReady, onServerCrash) {
232
226
  onServerCrash();
233
227
  }
234
228
 
235
- // Wait a bit before restart
236
- setTimeout(() => {
229
+ setTimeout(() => {
237
230
  spawnServer();
238
231
  }, 1000);
239
- } else {
240
232
  }
241
233
  });
242
234
 
@@ -256,38 +248,125 @@ function startServerWithRestart(onReady, onServerCrash) {
256
248
  shutdown: () => {
257
249
  isShuttingDown = true;
258
250
  if (currentProcess) {
259
- currentProcess.kill();
251
+ // SIGKILL so Windows TerminateProcess fires immediately — SIGTERM
252
+ // on Windows is best-effort and leaves server node.exe orphaned
253
+ // when agent exits right after.
254
+ try { currentProcess.kill("SIGKILL"); } catch {}
260
255
  }
261
256
  }
262
257
  };
263
258
  }
264
259
 
265
260
  /**
266
- * Helper: Setup exit handler for server processes
261
+ * Tear down every 9remote resource and exit. Single source of truth for
262
+ * shutdown so Ctrl+C, menu Exit, tray "Shutdown" and IPC all behave the same:
263
+ * - stop server (in-memory manager + port safety net)
264
+ * - kill cloudflared (PID file + in-memory tunnel reference)
265
+ * - kill tray helper
266
+ * - clear PID files so next update doesn't hit stale entries
267
+ * - clear local state/IPC files
267
268
  */
269
+ function shutdownAll({ serverManager, tunnelProcess, exit = true, code = 0 } = {}) {
270
+ try { serverManager?.shutdown?.(); } catch {}
271
+ try { tunnelProcess?.kill?.(); } catch {}
272
+ try { killCloudflared(); } catch {}
273
+ try { killTray(); } catch {}
274
+ try { killProcessOnPort(SERVER_PORT); } catch {}
275
+ try { resetRestartCounter(); } catch {}
276
+ try { clearState(); } catch {}
277
+ try { clearPid("agent"); } catch {}
278
+ try { clearPid("cloudflared"); } catch {}
279
+ if (exit) {
280
+ // Delay so tray / HTTP replies flush AND Windows `taskkill /F` on port
281
+ // finishes killing orphan server node.exe before we exit. 200ms was
282
+ // too short on Windows and left 1 node.exe alive.
283
+ setTimeout(() => process.exit(code), 500);
284
+ }
285
+ }
286
+
268
287
  let exitHandlerRegistered = false;
269
288
 
289
+ /**
290
+ * Register cleanup for every "shutdown path" we can observe.
291
+ *
292
+ * Why so many signals:
293
+ * - SIGINT : Ctrl+C in terminal
294
+ * - SIGTERM : `kill <pid>`, service manager stop, Docker stop
295
+ * - SIGHUP : terminal closed on POSIX (parent shell died)
296
+ * - SIGBREAK : Ctrl+Break on Windows (Node's Windows-only signal)
297
+ * - Windows X button : Node emits SIGHUP on CTRL_CLOSE_EVENT when stdin is
298
+ * in raw mode OR when a readline interface is open.
299
+ * We force-enable it by creating a readline iface on
300
+ * Windows so closing the console window triggers
301
+ * cleanup instead of orphaning tray + cloudflared.
302
+ * - beforeExit : natural event-loop drain (last-resort cleanup)
303
+ * - uncaughtException/unhandledRejection: don't leak processes on crash
304
+ *
305
+ * Without this, closing the terminal with the X button on Windows (or
306
+ * kill -TERM) leaves tray_windows_release.exe + cloudflared.exe alive,
307
+ * which hold file handles inside node_modules\9remote\dist and cause
308
+ * `npm i -g 9remote@latest` to fail with EBUSY rename errors.
309
+ */
270
310
  function setupExitHandler(serverManager, tunnelProcess, apiKey) {
271
311
  if (exitHandlerRegistered) return;
272
312
  exitHandlerRegistered = true;
273
-
274
- process.on("SIGINT", async () => {
275
- console.log(chalk.yellow("\n\nšŸ›‘ Stopping server..."));
276
-
277
- serverManager.shutdown();
278
- if (tunnelProcess) tunnelProcess.kill();
279
- killProcessOnPort(SERVER_PORT);
280
- resetRestartCounter();
281
- clearState();
282
-
283
- console.log(chalk.green("āœ… Server stopped"));
284
- process.exit(0);
313
+
314
+ let shuttingDown = false;
315
+ const onSignal = (sig) => {
316
+ if (shuttingDown) return;
317
+ shuttingDown = true;
318
+ // Only log when attached to a TTY — if the console was closed (SIGHUP
319
+ // on window-close), stdout may already be gone and writes throw.
320
+ try {
321
+ if (process.stdout.isTTY) {
322
+ console.log(chalk.yellow(`\n\nšŸ›‘ Stopping 9Remote (${sig})...`));
323
+ }
324
+ } catch {}
325
+ shutdownAll({ serverManager, tunnelProcess });
326
+ try {
327
+ if (process.stdout.isTTY) {
328
+ console.log(chalk.green("āœ… Server stopped"));
329
+ }
330
+ } catch {}
331
+ };
332
+
333
+ // POSIX + Windows common signals
334
+ process.on("SIGINT", () => onSignal("SIGINT"));
335
+ process.on("SIGTERM", () => onSignal("SIGTERM"));
336
+ process.on("SIGHUP", () => onSignal("SIGHUP"));
337
+ // SIGBREAK only exists on Windows; registering it elsewhere is harmless
338
+ // but Node warns — guard it.
339
+ if (process.platform === "win32") {
340
+ process.on("SIGBREAK", () => onSignal("SIGBREAK"));
341
+
342
+ // Windows "X button" on the console window fires CTRL_CLOSE_EVENT. Node
343
+ // only translates this into SIGHUP if it has a readline interface open
344
+ // (see Node docs: "Signal Events" → Windows). Open a minimal one so the
345
+ // signal actually fires and our cleanup runs.
346
+ try {
347
+ // Lazy import so non-Windows platforms don't pay for it
348
+ import("readline").then(({ createInterface }) => {
349
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
350
+ rl.on("SIGINT", () => onSignal("SIGINT"));
351
+ // Detach from event loop — don't keep the process alive just for this
352
+ if (process.stdin.isTTY) process.stdin.unref?.();
353
+ }).catch(() => {});
354
+ } catch {}
355
+ }
356
+
357
+ // Last-resort cleanup on crash — don't leak tray/cloudflared if we throw.
358
+ process.on("uncaughtException", (err) => {
359
+ try { console.error(chalk.red("Uncaught exception:"), err?.message || err); } catch {}
360
+ onSignal("uncaughtException");
361
+ setTimeout(() => process.exit(1), 300);
362
+ });
363
+ process.on("unhandledRejection", (err) => {
364
+ try { console.error(chalk.red("Unhandled rejection:"), err?.message || err); } catch {}
365
+ onSignal("unhandledRejection");
366
+ setTimeout(() => process.exit(1), 300);
285
367
  });
286
368
  }
287
369
 
288
- /**
289
- * Get first non-internal LAN IPv4 address
290
- */
291
370
  function getLanIp() {
292
371
  const interfaces = os.networkInterfaces();
293
372
  for (const iface of Object.values(interfaces)) {
@@ -298,24 +377,49 @@ function getLanIp() {
298
377
  return null;
299
378
  }
300
379
 
301
- /** Push UI state to server via HTTP */
302
- async function pushUiState(data) {
380
+ /** Local API helpers */
381
+ async function apiPost(path, data) {
303
382
  try {
304
- await fetch(`http://localhost:${SERVER_PORT}/api/ui/state`, {
383
+ return await fetch(`http://localhost:${SERVER_PORT}${path}`, {
305
384
  method: "POST",
306
385
  headers: { "Content-Type": "application/json" },
307
386
  body: JSON.stringify(data),
308
387
  });
309
- } catch { /* server may not be ready yet */ }
388
+ } catch { return null; }
389
+ }
390
+
391
+ async function apiGet(path) {
392
+ try {
393
+ const res = await fetch(`http://localhost:${SERVER_PORT}${path}`);
394
+ return res.ok ? await res.json() : null;
395
+ } catch { return null; }
396
+ }
397
+
398
+ async function pushUiState(data) {
399
+ await apiPost("/api/ui/state", data);
400
+ }
401
+
402
+ // DRY: single source for step progression — updates both terminal progress + web UI state.
403
+ // Terminal rendering only active in TUI mode (guarded by isTuiActive flag).
404
+ let isTuiActive = false;
405
+ async function setStep(step, extra = {}) {
406
+ if (isTuiActive) renderProgress(step - 1, step > STEP.PREPARING);
407
+ await pushUiState({ step, stepDesc: "", ...extra });
408
+ }
409
+
410
+ // DRY: single handler for cloudflared binary progress → updates both TUI + web UI.
411
+ function onBinaryProgress({ phase, percent }) {
412
+ const text = phase === "download"
413
+ ? `Downloading tunnel binary ${percent ?? 0}%`
414
+ : "Extracting tunnel binary";
415
+ if (isTuiActive) updateProgressDesc(text);
416
+ pushUiState({ stepDesc: text });
310
417
  }
311
418
 
312
- /**
313
- * Update session tunnelUrl on worker + push to UI
314
- */
315
419
  async function updateTunnelUrl(selectedKey, tunnelUrl) {
316
420
  const lanIp = getLanIp();
317
421
  try {
318
- await fetch(`${WORKER_URL}/api/session/update`, {
422
+ await browserFetch(`${WORKER_URL}/api/session/update`, {
319
423
  method: "POST",
320
424
  headers: { "Content-Type": "application/json" },
321
425
  body: JSON.stringify({
@@ -327,12 +431,9 @@ async function updateTunnelUrl(selectedKey, tunnelUrl) {
327
431
  } catch { }
328
432
  }
329
433
 
330
- /**
331
- * Helper: Start server and quick tunnel
332
- */
333
434
  async function startServerAndTunnel(selectedKey) {
334
435
  console.log(ORANGE("\nšŸš€ Starting server..."));
335
- pushUiState({ step: 1 });
436
+ await setStep(STEP.PREPARING);
336
437
 
337
438
  // Kill existing cloudflared process
338
439
  try {
@@ -342,19 +443,14 @@ async function startServerAndTunnel(selectedKey) {
342
443
 
343
444
  // Create session on worker
344
445
  try {
345
- const sessionResponse = await fetch(`${WORKER_URL}/api/session/create`, {
446
+ const res = await browserFetch(`${WORKER_URL}/api/session/create`, {
346
447
  method: "POST",
347
448
  headers: { "Content-Type": "application/json" },
348
- body: JSON.stringify({ apiKey: selectedKey })
449
+ body: JSON.stringify({ apiKey: selectedKey }),
349
450
  });
350
- if (!sessionResponse.ok) {
351
- const text = await sessionResponse.text();
352
- console.log(chalk.red(`āŒ Failed to create session: ${sessionResponse.status} ${text.substring(0, 200)}`));
353
- return null;
354
- }
355
- } catch (error) {
356
- console.log(chalk.red(`āŒ Failed to create session: ${error.message}`));
357
- return null;
451
+ if (!res.ok) { console.log(chalk.red(`āŒ Session create failed: ${res.status}`)); return null; }
452
+ } catch (e) {
453
+ console.log(chalk.red(`āŒ Session create failed: ${e.message}`)); return null;
358
454
  }
359
455
 
360
456
  // Skip spawning server if already running (e.g. nodemon in dev mode)
@@ -366,7 +462,7 @@ async function startServerAndTunnel(selectedKey) {
366
462
  if (!alreadyRunning) await new Promise(resolve => setTimeout(resolve, 2000));
367
463
 
368
464
  console.log(ORANGE("āœ… Starting tunnel..."));
369
- pushUiState({ step: 2 });
465
+ await setStep(STEP.CONNECTING);
370
466
 
371
467
  // Spawn quick tunnel — URL comes directly from cloudflared stdout
372
468
  let tunnelProcess, tunnelUrl;
@@ -385,6 +481,11 @@ async function startServerAndTunnel(selectedKey) {
385
481
  return null;
386
482
  }
387
483
 
484
+ // Health check tunnel from outside before proceeding
485
+ const tunnelReady = await waitForTunnelReady(tunnelUrl);
486
+ if (!tunnelReady) {
487
+ console.log(chalk.yellow("\nāš ļø Tunnel health check timed out, proceeding anyway..."));
488
+ }
388
489
 
389
490
  // Save tunnelUrl to worker DB
390
491
  await updateTunnelUrl(selectedKey, tunnelUrl);
@@ -400,53 +501,33 @@ async function startServerAndTunnel(selectedKey) {
400
501
  return { serverManager, tunnelProcess, tunnelUrl };
401
502
  }
402
503
 
403
-
404
- /**
405
- * TUI mode — main flow for `9remote` (no subcommand)
406
- */
407
504
  async function tuiMode() {
408
505
  console.clear();
506
+ resetProgress();
507
+ isTuiActive = true;
508
+ await setStep(STEP.PREPARING);
409
509
 
410
- // ── 1. Parallel: check latest version + start server ──────────────────────
411
- const machineId = await getConsistentMachineId();
412
- let keyData = loadKey();
413
- if (!keyData.key) {
414
- const { key } = generateApiKeyWithMachine(machineId);
415
- keyData = saveKey(machineId, key, "Default");
416
- }
510
+ let keyData = await ensureKeyData();
417
511
 
418
- // Run update check & server start in parallel
419
512
  let tuiServerMgr = { getProcess: () => null, shutdown: () => {} };
420
- const [updateInfo] = await Promise.all([
421
- checkLatestVersion(),
422
- (async () => {
423
- const alreadyRunning = await isServerRunning();
424
- if (!alreadyRunning) {
425
- tuiServerMgr = startServerWithRestart(null, null);
426
- await new Promise((r) => setTimeout(r, 2000));
427
- }
428
- })(),
429
- ]);
430
-
431
- // ── 2. Show banner (with update notice if available) ──────────────────────
432
- const version = getVersion();
433
- showBanner(version, updateInfo?.latest ?? null);
434
-
435
- // ── 3. Progress: Preparing → Connecting → Tunneling → Ready ──────────────
436
- resetProgress();
437
- renderProgress(0); // Preparing
513
+ const alreadyRunning = await isServerRunning();
514
+ if (!alreadyRunning) {
515
+ tuiServerMgr = startServerWithRestart(null, null);
516
+ // Poll until server ready instead of fixed sleep
517
+ const deadline = Date.now() + 5000;
518
+ while (Date.now() < deadline && !(await isServerRunning())) {
519
+ await new Promise((r) => setTimeout(r, 200));
520
+ }
521
+ }
438
522
 
439
- // Kill stale cloudflared
440
523
  try { killCloudflared(); await new Promise((r) => setTimeout(r, 300)); } catch {}
441
524
 
442
- // Step 1 — Preparing (ensureCloudflared)
443
- await ensureCloudflared();
525
+ await ensureCloudflared(onBinaryProgress);
444
526
 
445
- renderProgress(1, true); // Connecting
527
+ await setStep(STEP.CONNECTING);
446
528
 
447
- // Step 2 — Connecting (create session)
448
529
  try {
449
- const res = await fetch(`${WORKER_URL}/api/session/create`, {
530
+ const res = await browserFetch(`${WORKER_URL}/api/session/create`, {
450
531
  method: "POST",
451
532
  headers: { "Content-Type": "application/json" },
452
533
  body: JSON.stringify({ apiKey: keyData.key }),
@@ -457,9 +538,8 @@ async function tuiMode() {
457
538
  process.exit(1);
458
539
  }
459
540
 
460
- renderProgress(2, true); // Starting tunnel
541
+ await setStep(STEP.TUNNELING);
461
542
 
462
- // Step 3 — Tunnel
463
543
  let tunnelProcess, tunnelUrl;
464
544
  try {
465
545
  const result = await spawnQuickTunnel(SERVER_PORT, async (newUrl) => {
@@ -473,17 +553,21 @@ async function tuiMode() {
473
553
  process.exit(1);
474
554
  }
475
555
 
556
+ await setStep(STEP.VERIFYING);
557
+ const tunnelReady = await waitForTunnelReady(tunnelUrl);
558
+ if (!tunnelReady) {
559
+ console.log(chalk.yellow("\nāš ļø Tunnel health check timed out, proceeding anyway..."));
560
+ }
561
+
476
562
  await updateTunnelUrl(keyData.key, tunnelUrl);
477
563
  saveState({ apiKey: keyData.key, tunnelUrl, tunnelPid: tunnelProcess.pid });
478
564
 
479
- // ── 4. Create temp key + push ready state ─────────────────────────────────
480
565
  const tempKeyData = await createTempKey(keyData.key, WORKER_URL);
481
566
  const connectUrl = tempKeyData
482
567
  ? `${WORKER_URL}/login?k=${tempKeyData.tempKey}`
483
568
  : `${WORKER_URL}/login`;
484
569
 
485
- await pushUiState({
486
- step: 4,
570
+ await setStep(STEP.READY, {
487
571
  tunnelUrl,
488
572
  oneTimeKey: tempKeyData?.tempKey || "",
489
573
  oneTimeKeyExpiresAt: tempKeyData?.expiresAt || null,
@@ -491,59 +575,111 @@ async function tuiMode() {
491
575
  qrUrl: connectUrl,
492
576
  workerUrl: WORKER_URL,
493
577
  });
578
+ await new Promise((r) => setTimeout(r, 1000));
494
579
 
495
- // ── 5. Load initial state from server ────────────────────────────────────
496
580
  let currentOneTimeKey = tempKeyData?.tempKey || "";
497
581
  let currentConnectUrl = connectUrl;
582
+ let currentTunnelUrl = tunnelUrl;
498
583
 
499
- // ── 6. Build initial header ───────────────────────────────────────────────
500
- let menuHeader = await buildMenuHeader(currentOneTimeKey, keyData.key, currentConnectUrl);
584
+ let menuHeader = await buildMenuHeader(currentOneTimeKey, keyData.key, currentConnectUrl, currentTunnelUrl);
501
585
 
502
- // Mutable ref for menu re-render callback (set by selectMenu)
503
586
  let triggerMenuRedraw = null;
587
+ const logBuffer = [];
588
+ const MAX_LOG_LINES = 200;
589
+
590
+ let deviceApprovalBusy = false;
591
+
592
+ // Skip redraws while device approval prompt is active — otherwise SSE-driven
593
+ // menu repaints (state/permissions/log) would clear the screen and overwrite
594
+ // the prompt, making it invisible to the user.
595
+ const safeRedraw = () => { if (!deviceApprovalBusy) triggerMenuRedraw?.(); };
596
+
597
+ // Handle pending approval — shared by SSE event and fallback poll.
598
+ // Fallback recovers from missed SSE events (reconnect, TUI in submenu, etc.)
599
+ const handlePendingApproval = async (socketId, deviceId, ip) => {
600
+ if (deviceApprovalBusy) return;
601
+ deviceApprovalBusy = true;
602
+ const approved = await showDeviceApproval(deviceId, ip);
603
+ const endpoint = approved ? "approve" : "reject";
604
+ await apiPost(`/api/device/${endpoint}`, { socketId });
605
+ deviceApprovalBusy = false;
606
+ triggerMenuRedraw?.();
607
+ };
504
608
 
505
- // ── 7. Subscribe SSE — auto-rebuild header on state changes ──────────────
506
609
  const stopSSE = subscribeSSE(SERVER_PORT, async (type, data) => {
507
- if (type === "state") {
610
+ if (type === "log" && data.message) {
611
+ logBuffer.push(data.message);
612
+ if (logBuffer.length > MAX_LOG_LINES) logBuffer.shift();
613
+ } else if (type === "state") {
508
614
  const newKey = data.permanentKey || keyData.key;
509
615
  // Explicit check: "" means cleared (one-time key consumed), preserve existing if undefined
510
616
  const newOtk = data.oneTimeKey !== undefined ? data.oneTimeKey : currentOneTimeKey;
511
617
  const newUrl = data.qrUrl !== undefined ? data.qrUrl : currentConnectUrl;
512
- if (newOtk !== currentOneTimeKey || newKey !== keyData.key) {
618
+ const newTunnel = data.tunnelUrl !== undefined ? data.tunnelUrl : currentTunnelUrl;
619
+ if (newOtk !== currentOneTimeKey || newKey !== keyData.key || newTunnel !== currentTunnelUrl) {
513
620
  currentOneTimeKey = newOtk;
514
621
  currentConnectUrl = newUrl;
622
+ currentTunnelUrl = newTunnel;
515
623
  if (data.permanentKey) keyData = { ...keyData, key: data.permanentKey };
516
- menuHeader = await buildMenuHeader(currentOneTimeKey, keyData.key, currentConnectUrl);
517
- triggerMenuRedraw?.();
624
+ menuHeader = await buildMenuHeader(currentOneTimeKey, keyData.key, currentConnectUrl, currentTunnelUrl);
625
+ safeRedraw();
518
626
  }
519
627
  } else if (type === "permissions") {
520
- // desktopEnabled changed — trigger redraw so menu label refreshes
521
- triggerMenuRedraw?.();
628
+ // desktopEnabled or permission values changed — refresh both main menu and active submenu
629
+ if (!deviceApprovalBusy) activeSubmenuRefresh?.();
630
+ safeRedraw();
631
+ } else if (type === "deviceApproval" && data.action === "pending") {
632
+ await handlePendingApproval(data.socketId, data.deviceId, data.ip);
522
633
  }
523
634
  });
524
635
 
636
+ // Fallback poll: recover from missed SSE pending events
637
+ const pendingPoll = setInterval(async () => {
638
+ if (deviceApprovalBusy) return;
639
+ const d = await apiGet("/api/device/pending");
640
+ const first = d?.pending?.[0];
641
+ if (first) await handlePendingApproval(first.socketId, first.deviceId, first.ip);
642
+ }, 3000);
643
+
525
644
  setupExitHandler({
526
645
  getProcess: tuiServerMgr.getProcess,
527
- shutdown: () => { tuiServerMgr.shutdown(); stopSSE(); }
646
+ shutdown: () => { tuiServerMgr.shutdown(); stopSSE(); clearInterval(pendingPoll); }
528
647
  }, tunnelProcess, keyData.key);
529
648
 
530
- // ── 8. Interactive menu loop ───────────────────────────────────────────────
649
+ // Handle web UI Start/Stop commands while TUI is running
650
+ let activeTunnel = tunnelProcess;
651
+ setupCmdPoller(() => activeTunnel, (t) => { activeTunnel = t; }, keyData.key);
652
+
653
+ const onShutdown = () => {
654
+ try { stopSSE(); } catch {}
655
+ try { clearInterval(pendingPoll); } catch {}
656
+ shutdownAll({
657
+ serverManager: tuiServerMgr,
658
+ tunnelProcess,
659
+ exit: false, // let the menu loop print Goodbye and exit itself
660
+ });
661
+ };
662
+
531
663
  await tuiMenuLoop(
532
664
  keyData, tunnelUrl,
533
665
  () => menuHeader,
534
666
  (h) => { menuHeader = h; },
535
667
  (cb) => { triggerMenuRedraw = cb; },
536
- () => { tuiServerMgr.shutdown(); killProcessOnPort(SERVER_PORT); stopSSE(); }
668
+ onShutdown,
669
+ logBuffer
537
670
  );
538
671
  }
539
672
 
540
- /** Fetch desktopEnabled from server (source of truth) */
541
- async function fetchDesktopEnabled() {
542
- try {
543
- const res = await fetch(`http://localhost:${SERVER_PORT}/api/ui/state`);
544
- if (res.ok) { const d = await res.json(); return !!d.desktopEnabled; }
545
- } catch {}
546
- return false;
673
+ async function fetchServerState() {
674
+ const [d, a] = await Promise.all([
675
+ apiGet("/api/ui/state"),
676
+ apiGet("/api/device/auto-approve"),
677
+ ]);
678
+ return {
679
+ desktopEnabled: !!d?.desktopEnabled,
680
+ remoteAvailable: !!d?.remoteAvailable,
681
+ autoApprove: !!a?.enabled,
682
+ };
547
683
  }
548
684
 
549
685
  /**
@@ -554,125 +690,136 @@ async function fetchDesktopEnabled() {
554
690
  * @param {(newHeader: string) => void} setHeader - update header from inside loop
555
691
  * @param {(cb: () => void) => void} onRedrawRegister - register redraw callback
556
692
  */
557
- async function tuiMenuLoop(keyData, tunnelUrl, getHeader = () => "", setHeader = () => {}, onRedrawRegister = () => {}, onCtrlC = null) {
693
+ async function tuiMenuLoop(keyData, tunnelUrl, getHeader = () => "", setHeader = () => {}, onRedrawRegister = () => {}, onCtrlC = null, logBuffer = []) {
558
694
  while (true) {
559
- const desktopOn = await fetchDesktopEnabled();
560
- const desktopLabel = `Remote Desktop: ${desktopOn ? chalk.green("ON") : chalk.gray("OFF")} ā–¶`;
695
+ const { desktopEnabled: desktopOn, remoteAvailable, autoApprove } = await fetchServerState();
696
+
561
697
  const items = [
562
- { label: "Open Web UI" },
563
- { label: "New One-Time Key" },
564
- { label: "Regenerate Permanent Key" },
565
- { label: desktopLabel },
566
- { label: chalk.gray("Exit") },
698
+ { label: "Open Web UI", action: "webui" },
699
+ { label: "New One-Time Key", action: "otk" },
700
+ { label: "Regenerate Permanent Key", action: "regen" },
567
701
  ];
702
+ if (remoteAvailable) {
703
+ const desktopLabel = `Remote Desktop: ${desktopOn ? chalk.green("ON") : chalk.gray("OFF")} ā–¶`;
704
+ items.push({ label: desktopLabel, action: "desktop" });
705
+ }
706
+ const autoLabel = autoApprove ? chalk.green("ON") : chalk.gray("OFF");
707
+ items.push(
708
+ { label: `Manage Devices \u25b6 ${chalk.dim("(Auto-approve:")} ${autoLabel}${chalk.dim(")")}`, action: "devices" },
709
+ { label: `View Logs (${logBuffer.length})`, action: "logs" },
710
+ { label: chalk.gray("Exit"), action: "exit" },
711
+ );
568
712
 
569
- // Register SSE-triggered redraw with selectMenu
570
713
  let redrawMenu = null;
571
714
  onRedrawRegister(() => redrawMenu?.());
572
715
 
573
- const idx = await selectMenu("Select action", items, 0, getHeader, (setRedraw) => {
716
+ const idx = await selectMenu("", items, 0, getHeader, (setRedraw) => {
574
717
  redrawMenu = setRedraw;
575
718
  }, onCtrlC);
576
719
 
577
- if (idx === 0) {
578
- // Open UI
720
+ const action = idx >= 0 ? items[idx].action : "exit";
721
+
722
+ if (action === "webui") {
579
723
  const url = `http://localhost:${SERVER_PORT}`;
580
- const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
581
- spawn(cmd, [url], { detached: true, stdio: "ignore" }).unref();
724
+ openBrowser(url);
582
725
  console.log(chalk.green(`\n🌐 Opening ${url}\n`));
583
726
 
584
- } else if (idx === 1) {
585
- // New One-Time Key — rebuild header with fresh QR
727
+ } else if (action === "otk") {
586
728
  const newTempKey = await createTempKey(keyData.key, WORKER_URL);
587
729
  if (newTempKey) {
588
730
  const newConnectUrl = `${WORKER_URL}/login?k=${newTempKey.tempKey}`;
589
- setHeader(await buildMenuHeader(newTempKey.tempKey, keyData.key, newConnectUrl));
731
+ setHeader(await buildMenuHeader(newTempKey.tempKey, keyData.key, newConnectUrl, tunnelUrl));
590
732
  await pushUiState({ oneTimeKey: newTempKey.tempKey, oneTimeKeyExpiresAt: newTempKey.expiresAt, qrUrl: newConnectUrl });
591
733
  }
592
734
 
593
- } else if (idx === 2) {
594
- // Regenerate Key
735
+ } else if (action === "regen") {
595
736
  const confirmed = await tuiConfirm(chalk.yellow("āš ļø Replace current key and disconnect all sessions? Continue?"));
596
737
  if (confirmed) {
597
738
  const machineId = await getConsistentMachineId();
598
739
  const { key } = generateApiKeyWithMachine(machineId);
599
740
  keyData = saveKey(machineId, key, keyData.name || "Default");
600
741
  await pushUiState({ permanentKey: keyData.key });
601
- // Rebuild header with new key
602
742
  const newTmp = await createTempKey(keyData.key, WORKER_URL);
603
743
  if (newTmp) {
604
744
  const newUrl = `${WORKER_URL}/login?k=${newTmp.tempKey}`;
605
- setHeader(await buildMenuHeader(newTmp.tempKey, keyData.key, newUrl));
745
+ setHeader(await buildMenuHeader(newTmp.tempKey, keyData.key, newUrl, tunnelUrl));
606
746
  await pushUiState({ oneTimeKey: newTmp.tempKey, oneTimeKeyExpiresAt: newTmp.expiresAt, qrUrl: newUrl });
607
747
  }
608
748
  }
609
749
 
610
- } else if (idx === 3) {
611
- // Remote Desktop submenu
750
+ } else if (action === "desktop") {
612
751
  await tuiDesktopMenu();
613
752
 
753
+ } else if (action === "devices") {
754
+ await tuiDevicesMenu();
755
+
756
+ } else if (action === "logs") {
757
+ await tuiLogsView(logBuffer);
758
+
614
759
  } else {
615
- // Exit — kill server child process before exiting
616
- killProcessOnPort(SERVER_PORT);
760
+ // Exit path — onCtrlC contains the full shutdownAll sequence
761
+ try { onCtrlC?.(); } catch {}
617
762
  console.log(chalk.gray("\nGoodbye!\n"));
618
763
  process.exit(0);
619
764
  }
620
765
  }
621
766
  }
622
767
 
768
+ /** View logs screen — scrollable, ESC to go back */
769
+ async function tuiLogsView(logBuffer) {
770
+ const header = logBuffer.length
771
+ ? logBuffer.join("\n")
772
+ : chalk.gray(" No logs yet");
773
+ await selectMenu("Logs", [{ label: chalk.gray("← Back") }], 0, header);
774
+ }
775
+
623
776
  /**
624
777
  * Remote Desktop submenu.
625
778
  * All state (desktopEnabled + permissions) fetched from server — no local tracking.
626
779
  */
627
780
  async function tuiDesktopMenu() {
628
781
  while (true) {
629
- // Always read from server
630
- let desktopOn = false, perms = { screenRecording: false, accessibility: false };
631
- try {
632
- const res = await fetch(`http://localhost:${SERVER_PORT}/api/ui/state`);
633
- if (res.ok) {
634
- const d = await res.json();
635
- desktopOn = !!d.desktopEnabled;
636
- perms = { screenRecording: !!d.screenRecording, accessibility: !!d.accessibility };
637
- }
638
- } catch {}
639
-
640
- const toggleLabel = `Toggle: ${desktopOn ? chalk.green("ON → turn OFF") : chalk.gray("OFF → turn ON")}`;
641
- const srLabel = `Screen Recording ${perms.screenRecording ? chalk.green("āœ“") : chalk.red("āœ— (click to grant)")}`;
642
- const axLabel = `Mouse & Keyboard control ${perms.accessibility ? chalk.green("āœ“") : chalk.red("āœ— (click to grant)")}`;
782
+ // State captured in closure — SSE may mutate items in-place while menu is open
783
+ let desktopOn = false;
784
+ let perms = { screenRecording: false, accessibility: false };
785
+
786
+ const buildLabels = () => ({
787
+ toggle: `Toggle: ${desktopOn ? chalk.green("ON → turn OFF") : chalk.gray("OFF → turn ON")}`,
788
+ sr: `Screen Recording ${perms.screenRecording ? chalk.green("āœ“") : chalk.red("āœ— (click to grant)")}`,
789
+ ax: `Mouse & Keyboard control ${perms.accessibility ? chalk.green("āœ“") : chalk.red("āœ— (click to grant)")}`,
790
+ });
643
791
 
644
- const idx = await selectMenu("Remote Desktop", [
645
- { label: toggleLabel },
646
- { label: srLabel },
647
- { label: axLabel },
792
+ const items = [
793
+ { label: "" },
794
+ { label: "" },
795
+ { label: "" },
648
796
  { label: chalk.gray("← Back") },
649
- ], 0);
797
+ ];
650
798
 
651
- if (idx === 0) {
652
- // Toggle — flip current value via server
653
- try {
654
- await fetch(`http://localhost:${SERVER_PORT}/api/desktop/toggle`, {
655
- method: "POST",
656
- headers: { "Content-Type": "application/json" },
657
- body: JSON.stringify({ enabled: !desktopOn }),
658
- });
659
- } catch {}
799
+ let redrawMenu = null;
800
+ const syncFromServer = async () => {
801
+ const s = await apiGet("/api/ui/state") || {};
802
+ desktopOn = !!s.desktopEnabled;
803
+ perms = { screenRecording: !!s.screenRecording, accessibility: !!s.accessibility };
804
+ const L = buildLabels();
805
+ items[0].label = L.toggle;
806
+ items[1].label = L.sr;
807
+ items[2].label = L.ax;
808
+ redrawMenu?.();
809
+ };
810
+
811
+ await syncFromServer();
812
+ // Register SSE-driven refresh while this submenu is active
813
+ activeSubmenuRefresh = syncFromServer;
814
+ const idx = await selectMenu("Remote Desktop", items, 0, "", (setRedraw) => { redrawMenu = setRedraw; });
815
+ activeSubmenuRefresh = null;
660
816
 
817
+ if (idx === 0) {
818
+ await apiPost("/api/desktop/toggle", { enabled: !desktopOn });
661
819
  } else if (idx === 1 && !perms.screenRecording) {
662
- try {
663
- await fetch(`http://localhost:${SERVER_PORT}/api/permissions/request`, {
664
- method: "POST", headers: { "Content-Type": "application/json" },
665
- body: JSON.stringify({ type: "screenRecording" }),
666
- });
667
- } catch { openPermissionPane("screenRecording"); }
668
-
820
+ if (!await apiPost("/api/permissions/request", { type: "screenRecording" })) openPermissionPane("screenRecording");
669
821
  } else if (idx === 2 && !perms.accessibility) {
670
- try {
671
- await fetch(`http://localhost:${SERVER_PORT}/api/permissions/request`, {
672
- method: "POST", headers: { "Content-Type": "application/json" },
673
- body: JSON.stringify({ type: "accessibility" }),
674
- });
675
- } catch { openPermissionPane("accessibility"); }
822
+ if (!await apiPost("/api/permissions/request", { type: "accessibility" })) openPermissionPane("accessibility");
676
823
 
677
824
  } else if (idx === 3 || idx === -1) {
678
825
  return; // Back
@@ -680,30 +827,52 @@ async function tuiDesktopMenu() {
680
827
  }
681
828
  }
682
829
 
830
+ async function tuiDevicesMenu() {
831
+ while (true) {
832
+ const [approvedData, autoData] = await Promise.all([
833
+ apiGet("/api/device/approved"),
834
+ apiGet("/api/device/auto-approve"),
835
+ ]);
836
+ const devices = approvedData?.devices || [];
837
+ const autoOn = !!autoData?.enabled;
838
+
839
+ const toggleLabel = `Auto-approve new devices: ${autoOn ? chalk.green("ON") : chalk.gray("OFF")}`;
840
+ const items = [
841
+ { label: toggleLabel, action: "toggle" },
842
+ ...devices.map((d) => {
843
+ const short = d.deviceId.slice(0, 8);
844
+ const date = d.approvedAt ? new Date(d.approvedAt).toLocaleString() : "unknown";
845
+ return { label: `${short}... ${chalk.dim(date)}`, action: "remove", deviceId: d.deviceId };
846
+ }),
847
+ { label: chalk.gray("\u2190 Back"), action: "back" },
848
+ ];
683
849
 
684
- /**
685
- * Auto start dev server (--auto flag)
686
- */
687
- async function autoStartDev() {
688
- showBanner(getVersion());
850
+ const title = `Approved Devices (${devices.length})`;
851
+ const idx = await selectMenu(title, items, 0);
689
852
 
690
- const machineId = await getConsistentMachineId();
691
- let keyData = loadKey();
853
+ if (idx === -1) return; // ESC
854
+ const sel = items[idx];
855
+ if (sel.action === "back") return;
692
856
 
693
- // Auto create key if none exists
694
- if (!keyData.key) {
695
- console.log(chalk.yellow("āš ļø No key found. Creating default key..."));
696
- const { key } = generateApiKeyWithMachine(machineId);
697
- keyData = saveKey(machineId, key, "Default");
698
- console.log(chalk.green("āœ… Default key created!"));
857
+ if (sel.action === "toggle") {
858
+ await apiPost("/api/device/auto-approve", { enabled: !autoOn });
859
+ continue;
860
+ }
861
+
862
+ if (sel.action === "remove") {
863
+ const confirmed = await tuiConfirm(chalk.yellow(`Remove device ${sel.deviceId.slice(0, 8)}...?`));
864
+ if (confirmed) await apiPost("/api/device/remove", { deviceId: sel.deviceId });
865
+ }
699
866
  }
867
+ }
700
868
 
869
+ async function autoStartDev() {
870
+ showBanner(getVersion());
871
+ let keyData = await ensureKeyData();
701
872
  console.log(chalk.gray(`Using key: ${keyData.key.slice(0, 20)}... (${keyData.name})`));
702
873
 
703
874
  const result = await startServerAndTunnel(keyData.key);
704
- if (!result) {
705
- process.exit(1);
706
- }
875
+ if (!result) process.exit(1);
707
876
 
708
877
  const { serverManager, tunnelProcess, tunnelUrl } = result;
709
878
 
@@ -721,14 +890,9 @@ async function autoStartDev() {
721
890
  pushUiState({ uptime: `${h}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}` });
722
891
  }, 5000);
723
892
 
724
- // Keep process alive
725
893
  await new Promise(() => { });
726
894
  }
727
895
 
728
-
729
- /**
730
- * Listen for key regen, stop-tunnel, start-tunnel from server UI
731
- */
732
896
  function setupCmdPoller(getActiveTunnel, setActiveTunnel, apiKey) {
733
897
  let busy = false;
734
898
  setInterval(async () => {
@@ -745,40 +909,47 @@ function setupCmdPoller(getActiveTunnel, setActiveTunnel, apiKey) {
745
909
  setActiveTunnel(null);
746
910
  console.log(chalk.yellow("šŸ›‘ Tunnel stopped"));
747
911
  }
748
- await pushUiState({ step: 0, tunnelUrl: "", oneTimeKey: "", oneTimeKeyExpiresAt: null });
912
+ await setStep(STEP.STOPPED, { tunnelUrl: "", oneTimeKey: "", oneTimeKeyExpiresAt: null });
913
+ updateTrayTooltip({ tunnelUrl: "", running: true });
749
914
  }
750
915
 
751
916
  if (cmd === "start-tunnel") {
752
917
  if (getActiveTunnel()) { busy = false; return; } // already running
753
918
  console.log(ORANGE("šŸš€ Starting tunnel..."));
754
919
  try {
755
- // Step 1: Preparing — check/download cloudflared binary
756
- await pushUiState({ step: 1 });
757
- await ensureCloudflared();
920
+ await setStep(STEP.PREPARING);
921
+ await ensureCloudflared(onBinaryProgress);
758
922
 
759
- // Step 2: Connecting — create session on worker
760
- await pushUiState({ step: 2 });
761
- const sessionResponse = await fetch(`${WORKER_URL}/api/session/create`, {
923
+ await setStep(STEP.CONNECTING);
924
+ const sessionResponse = await browserFetch(`${WORKER_URL}/api/session/create`, {
762
925
  method: "POST",
763
926
  headers: { "Content-Type": "application/json" },
764
927
  body: JSON.stringify({ apiKey }),
765
928
  });
766
929
  if (!sessionResponse.ok) throw new Error(`Session create failed: ${sessionResponse.status}`);
767
930
 
768
- // Step 3: Tunneling — spawn cloudflared
769
- await pushUiState({ step: 3 });
931
+ await setStep(STEP.TUNNELING);
770
932
  const result = await spawnQuickTunnel(SERVER_PORT, async (newUrl) => {
771
933
  await updateTunnelUrl(apiKey, newUrl);
772
934
  await pushUiState({ tunnelUrl: newUrl });
773
935
  });
774
936
  setActiveTunnel(result.child);
937
+
938
+ await setStep(STEP.VERIFYING);
939
+ const tunnelOk = await waitForTunnelReady(result.tunnelUrl);
940
+ if (!tunnelOk) {
941
+ console.log(chalk.yellow("\nāš ļø Tunnel health check timed out, proceeding anyway..."));
942
+ }
943
+
775
944
  await updateTunnelUrl(apiKey, result.tunnelUrl);
945
+ updateTrayTooltip({ tunnelUrl: result.tunnelUrl, running: true });
776
946
 
777
- // Step 4: Ready
947
+ // Hold for 3s so UI sees all steps complete before showing Ready screen
948
+ await new Promise(r => setTimeout(r, 2000));
778
949
  await showConnectionInfo(apiKey, result.tunnelUrl);
779
950
  } catch (err) {
780
951
  console.log(chalk.red(`āŒ Failed to start tunnel: ${err.message}`));
781
- await pushUiState({ step: 0 });
952
+ await setStep(STEP.STOPPED);
782
953
  }
783
954
  }
784
955
 
@@ -791,89 +962,368 @@ function setupCmdPoller(getActiveTunnel, setActiveTunnel, apiKey) {
791
962
  console.log(chalk.green(`āœ… Key regenerated: ${key}`));
792
963
  }
793
964
 
965
+ if (cmd === "shutdown") {
966
+ console.log(chalk.yellow("\nšŸ›‘ Shutting down 9Remote completely..."));
967
+ const tunnel = getActiveTunnel();
968
+ setActiveTunnel(null);
969
+ shutdownAll({ tunnelProcess: tunnel });
970
+ console.log(chalk.green("āœ… 9Remote stopped"));
971
+ }
972
+
794
973
  } finally {
795
974
  busy = false;
796
975
  }
797
976
  }, 1000);
798
977
  }
799
978
 
800
- /**
801
- * Check if server is already running on SERVER_PORT
802
- */
803
- async function isServerRunning() {
979
+ // Win DNS negative cache giữ ENOTFOUND lĆ¢u hĘ”n thį»i điểm Cloudflare publish subdomain.
980
+ // Flush trước mį»—i attempt Ä‘į»ƒ query đi thįŗ³ng upstream, trĆ”nh chį» TTL Ć¢m hįŗæt hįŗ”n.
981
+ // Async fire-and-forget + windowsHide → khĆ“ng block, khĆ“ng popup cmd window.
982
+ function flushWinDns() {
983
+ if (process.platform !== "win32") return;
984
+ execFile("ipconfig", ["/flushdns"], { windowsHide: true }, () => {});
985
+ }
986
+
987
+ // Resolve DNS trį»±c tiįŗæp qua Cloudflare 1.1.1.1 (bypass resolver hệ thống / ISP cache)
988
+ // → biįŗæt ngay subdomain đã propagate chʰa mĆ  khĆ“ng tốn 5s chį» fetch timeout.
989
+ const dnsResolver = new dns.promises.Resolver();
990
+ dnsResolver.setServers(["1.1.1.1", "1.0.0.1", "8.8.8.8"]);
991
+
992
+ async function resolveTunnelDns(hostname, timeoutMs = 2000) {
993
+ const t0 = Date.now();
804
994
  try {
805
- const res = await fetch(`http://localhost:${SERVER_PORT}/api/health`);
806
- return res.ok;
807
- } catch {
808
- return false;
995
+ const addrs = await Promise.race([
996
+ dnsResolver.resolve4(hostname),
997
+ new Promise((_, reject) =>
998
+ setTimeout(() => reject(Object.assign(new Error("DNS timeout"), { code: "ETIMEOUT" })), timeoutMs)
999
+ ),
1000
+ ]);
1001
+ return { ok: true, addrs, elapsedMs: Date.now() - t0 };
1002
+ } catch (err) {
1003
+ const code = err.code || err.message;
1004
+ return { ok: false, code, elapsedMs: Date.now() - t0 };
809
1005
  }
810
1006
  }
811
1007
 
812
- /**
813
- * UI mode: start server (if not running) + tunnel + open browser
814
- * Same as "Start Server" in TUI but auto-opens browser
815
- */
816
- async function startUiMode() {
817
- showBanner(getVersion());
1008
+ async function waitForTunnelReady(tunnelUrl, { intervalMs = 2000, timeoutMs = 180000 } = {}) {
1009
+ const healthUrl = `${tunnelUrl}/api/health`;
1010
+ const hostname = (() => {
1011
+ try { return new URL(tunnelUrl).hostname; } catch { return null; }
1012
+ })();
1013
+ const start = Date.now();
1014
+ let attempt = 0;
1015
+ const logs = [];
818
1016
 
819
- const machineId = await getConsistentMachineId();
820
- let keyData = loadKey();
1017
+ // Init terminal-like panel in web UI
1018
+ await pushUiState({
1019
+ healthCheck: { running: true, timeoutMs, startedAt: start, logs: [] },
1020
+ });
821
1021
 
822
- if (!keyData.key) {
823
- const { key } = generateApiKeyWithMachine(machineId);
824
- keyData = saveKey(machineId, key, "Default");
1022
+ const pushLog = (entry) => {
1023
+ logs.push(entry);
1024
+ // Keep last 80 entries to stay snappy
1025
+ const trimmed = logs.length > 80 ? logs.slice(-80) : logs;
1026
+ pushUiState({
1027
+ healthCheck: { running: true, timeoutMs, startedAt: start, logs: trimmed },
1028
+ });
1029
+ };
1030
+
1031
+ while (Date.now() - start < timeoutMs) {
1032
+ attempt++;
1033
+ flushWinDns();
1034
+
1035
+ // Bước 1: DNS probe qua 1.1.1.1 (timeout 2s) — biįŗæt sį»›m subdomain đã publish chʰa
1036
+ // Chỉ Ć”p dỄng khi có hostname hợp lệ (tunnel URL thį»±c tįŗæ).
1037
+ if (hostname) {
1038
+ const dnsRes = await resolveTunnelDns(hostname, 2000);
1039
+ if (!dnsRes.ok) {
1040
+ const isWaiting = dnsRes.code === "ENOTFOUND" || dnsRes.code === "ETIMEOUT" || dnsRes.code === "ESERVFAIL";
1041
+ const status = isWaiting ? "connecting..." : dnsRes.code;
1042
+ updateProgressDesc(`#${attempt} → ${status}`);
1043
+ pushLog({ attempt, status, elapsedMs: dnsRes.elapsedMs, ok: false, waiting: isWaiting, time: Date.now() });
1044
+ // Subdomain chʰa propagate → skip fetch 5s, chį» interval rồi thį»­ lįŗ”i.
1045
+ await new Promise((r) => setTimeout(r, intervalMs));
1046
+ continue;
1047
+ }
1048
+ }
1049
+
1050
+ // Bước 2: DNS OK (hoįŗ·c khĆ“ng có hostname) → fetch health endpoint
1051
+ const t0 = Date.now();
1052
+ try {
1053
+ const res = await browserFetch(healthUrl, {
1054
+ signal: AbortSignal.timeout(5000),
1055
+ });
1056
+ const elapsedMs = Date.now() - t0;
1057
+ updateProgressDesc(`#${attempt} → ${res.status}`);
1058
+ pushLog({ attempt, status: String(res.status), elapsedMs, ok: res.ok, time: Date.now() });
1059
+ if (res.ok) {
1060
+ // Clear logs immediately — UI jumps straight to Ready, no lingering log
1061
+ await pushUiState({
1062
+ healthCheck: { running: false, timeoutMs: 0, startedAt: null, logs: [] },
1063
+ });
1064
+ await new Promise(r => setTimeout(r, 2000));
1065
+ return true;
1066
+ }
1067
+ } catch (err) {
1068
+ const elapsedMs = Date.now() - t0;
1069
+ const code = err.cause?.code || err.code || err.message;
1070
+ // ENOTFOUND = tunnel DNS chʰa propagate → đây lĆ  trįŗ”ng thĆ”i "đang chį»", khĆ“ng phįŗ£i lį»—i
1071
+ const isWaiting = code === "ENOTFOUND";
1072
+ const status = isWaiting ? "connecting..." : code;
1073
+ updateProgressDesc(`#${attempt} → ${status}`);
1074
+ pushLog({ attempt, status, elapsedMs, ok: false, waiting: isWaiting, time: Date.now() });
1075
+ }
1076
+ await new Promise((r) => setTimeout(r, intervalMs));
1077
+ }
1078
+
1079
+ await pushUiState({
1080
+ healthCheck: { running: false, timeoutMs, startedAt: start, logs },
1081
+ });
1082
+ return false;
1083
+ }
1084
+
1085
+ async function isServerRunning() {
1086
+ return !!(await apiGet("/api/health"));
1087
+ }
1088
+
1089
+ /** Spawn background process with --tray flag, open browser, exit current process */
1090
+ async function launchBackground() {
1091
+ const uiUrl = `http://localhost:${SERVER_PORT}`;
1092
+
1093
+ // If an orphan server from a previous run is holding the port, kill it
1094
+ // so our fresh agent spawns a fresh server it actually owns. Skipping
1095
+ // this leaves alreadyRunning=true in startTrayMode → serverManager
1096
+ // becomes a no-op → Shutdown can't kill the orphan node.exe.
1097
+ if (await isServerRunning()) {
1098
+ killProcessOnPort(SERVER_PORT);
1099
+ await new Promise(r => setTimeout(r, 500));
1100
+ }
1101
+
1102
+ // Spawn CLI itself with --tray — tray logic lives in CLI, not server
1103
+ // Bundle: __dirname = dist/, entry = dist/cli.cjs
1104
+ // Dev: __dirname = agent/cli/, entry = agent/cli/index.js
1105
+ const scriptPath = typeof __CLI_VERSION__ !== "undefined"
1106
+ ? path.resolve(__dirname, "cli.cjs")
1107
+ : path.resolve(__dirname, "index.js");
1108
+ const bgArgs = [scriptPath, "--tray"];
1109
+
1110
+ const themeArg = process.argv.find(a => a.startsWith("--theme="));
1111
+ if (themeArg) bgArgs.push(themeArg);
1112
+
1113
+ // Redirect child stdout/stderr to log file for debugging crashes
1114
+ const logDir = path.join(os.homedir(), ".9remote");
1115
+ if (!fs.existsSync(logDir)) fs.mkdirSync(logDir, { recursive: true });
1116
+ const logPath = path.join(logDir, "bg.log");
1117
+
1118
+ // Log startup marker
1119
+ try {
1120
+ fs.appendFileSync(logPath, `\n\n=== ${new Date().toISOString()} spawn bg ===\n`);
1121
+ } catch {}
1122
+
1123
+ let bgPid = null;
1124
+
1125
+ // Unified detached spawn for all platforms.
1126
+ //
1127
+ // Windows specifics:
1128
+ // - node.exe is a console-subsystem binary. With plain `detached: true` Windows
1129
+ // would allocate a new console for the child and flash a black cmd window.
1130
+ // - `windowsHide: true` adds CREATE_NO_WINDOW, which combined with DETACHED_PROCESS
1131
+ // (from `detached: true`) + non-inherit stdio makes the launch fully silent —
1132
+ // no VBS/wscript wrapper needed.
1133
+ // - stdio MUST be either "ignore" or a file fd (not "inherit") or Windows will
1134
+ // still surface a console window tied to the parent.
1135
+ //
1136
+ // PID tracking:
1137
+ // - We write `agent.pid` here in the PARENT before the child even boots, so the
1138
+ // updater can always find it — even if the child crashes before `startTrayMode`
1139
+ // gets to call writePid() itself.
1140
+ try {
1141
+ const logFd = fs.openSync(logPath, "a");
1142
+ const bg = spawn(process.execPath, bgArgs, {
1143
+ detached: true,
1144
+ windowsHide: true,
1145
+ stdio: ["ignore", logFd, logFd],
1146
+ env: { ...process.env },
1147
+ });
1148
+ bg.unref();
1149
+ // Close our copy of the fd — the child has its own handle now.
1150
+ try { fs.closeSync(logFd); } catch {}
1151
+ bgPid = bg.pid;
1152
+ // Track background agent PID so the updater can release dist/cli.cjs lock
1153
+ // without touching other node.exe processes on the machine.
1154
+ if (bg.pid) writePid("agent", bg.pid);
1155
+ } catch (err) {
1156
+ console.log(chalk.red(`\nāŒ Failed to launch background: ${err.message}`));
1157
+ process.exit(1);
1158
+ }
1159
+
1160
+ // Wait and verify server came up; Windows + first-run cloudflared download can
1161
+ // be slow, so give it up to 15s before giving up.
1162
+ const deadline = Date.now() + 15000;
1163
+ let ready = false;
1164
+ while (Date.now() < deadline) {
1165
+ if (await isServerRunning()) { ready = true; break; }
1166
+ await new Promise(r => setTimeout(r, 300));
825
1167
  }
826
1168
 
827
- // Parse --theme flag
828
- const themeArg = process.argv.find(arg => arg.startsWith("--theme="));
1169
+ if (!ready) {
1170
+ console.log(chalk.red(`\nāŒ Background server failed to start.`));
1171
+ console.log(chalk.gray(` Check log: ${logPath}\n`));
1172
+ process.exit(1);
1173
+ }
1174
+
1175
+ openBrowser(uiUrl);
1176
+ const pidStr = bgPid ? ` (PID: ${bgPid})` : "";
1177
+ console.log(chalk.green(`\n🌐 9Remote running at ${uiUrl}${pidStr}`));
1178
+ console.log(chalk.gray(`šŸ’” Log: ${logPath}\n`));
1179
+ // Give the detached browser-launcher child a brief moment to actually spawn
1180
+ // before this parent exits — especially on Windows where cmd/start needs a tick.
1181
+ await new Promise(r => setTimeout(r, 400));
1182
+ process.exit(0);
1183
+ }
1184
+
1185
+ /** Tray mode: start server + system tray, no terminal UI */
1186
+ async function startTrayMode() {
1187
+ // Record own PID — this process holds dist/cli.cjs open in memory.
1188
+ // Needed so `npm i -g 9remote@latest` can kill us and rename node_modules\9remote.
1189
+ writePid("agent", process.pid);
1190
+
1191
+ let keyData = await ensureKeyData();
1192
+
1193
+ const themeArg = process.argv.find(a => a.startsWith("--theme="));
829
1194
  const theme = themeArg ? themeArg.split("=")[1] : null;
830
1195
 
831
- // Start server only (no tunnel yet — wait for UI Connect button)
832
1196
  const alreadyRunning = await isServerRunning();
833
1197
  const serverManager = alreadyRunning
834
1198
  ? { getProcess: () => null, shutdown: () => {} }
835
1199
  : startServerWithRestart(null, null);
836
1200
 
837
- if (!alreadyRunning) await new Promise(resolve => setTimeout(resolve, 2000));
1201
+ if (!alreadyRunning) await new Promise(r => setTimeout(r, 2000));
838
1202
 
839
- console.log(chalk.green(`\n🌐 UI ready at http://localhost:${SERVER_PORT}`));
1203
+ const uiUrl = `http://localhost:${SERVER_PORT}`;
840
1204
 
841
- // Mutable ref for active tunnel
842
1205
  let activeTunnel = null;
843
- const getActiveTunnel = () => activeTunnel;
844
- const setActiveTunnel = (t) => { activeTunnel = t; };
845
-
846
- // Push permanentKey + theme to UI so Welcome screen can display it
847
- await pushUiState({ permanentKey: keyData.key, step: 0, theme });
1206
+ await pushUiState({ permanentKey: keyData.key, step: STEP.STOPPED, theme });
1207
+
1208
+ const cleanup = () => {
1209
+ const tunnel = activeTunnel;
1210
+ activeTunnel = null;
1211
+ // Tray's own onClick handler calls process.exit after this returns,
1212
+ // so don't double-exit here.
1213
+ shutdownAll({ serverManager, tunnelProcess: tunnel, exit: false });
1214
+ };
848
1215
 
849
1216
  setupExitHandler(serverManager, null, keyData.key);
850
- setupCmdPoller(getActiveTunnel, setActiveTunnel, keyData.key);
1217
+ setupCmdPoller(() => activeTunnel, (t) => { activeTunnel = t; }, keyData.key);
1218
+
1219
+ if (process.argv.includes("--start")) writeCmd("start-tunnel");
1220
+
1221
+ const tray = await initTray({
1222
+ port: SERVER_PORT,
1223
+ onQuit: cleanup,
1224
+ onOpenUI: () => openBrowser(uiUrl),
1225
+ });
851
1226
 
852
- // Auto start tunnel if --start flag is passed
853
- if (process.argv.includes("--start")) {
854
- writeCmd("start-tunnel");
1227
+ // Show a one-shot balloon tip so the user knows the agent is running
1228
+ // in the background and where to find it (tray area + local URL).
1229
+ if (tray) {
1230
+ showTrayNotification({
1231
+ title: "9Remote is running",
1232
+ message: `Open ${uiUrl} or use the tray icon to manage.`,
1233
+ });
855
1234
  }
856
1235
 
857
- await new Promise(() => { });
1236
+ await new Promise(() => {});
1237
+ }
1238
+
1239
+ /** UI mode (9remote ui): start server + open browser, no tray */
1240
+ async function startUiMode() {
1241
+ showBanner(getVersion());
1242
+ let keyData = await ensureKeyData();
1243
+
1244
+ const themeArg = process.argv.find(a => a.startsWith("--theme="));
1245
+ const theme = themeArg ? themeArg.split("=")[1] : null;
1246
+
1247
+ const alreadyRunning = await isServerRunning();
1248
+ const serverManager = alreadyRunning
1249
+ ? { getProcess: () => null, shutdown: () => {} }
1250
+ : startServerWithRestart(null, null);
1251
+
1252
+ if (!alreadyRunning) await new Promise(r => setTimeout(r, 2000));
1253
+
1254
+ const uiUrl = `http://localhost:${SERVER_PORT}`;
1255
+ console.log(chalk.green(`\n🌐 UI ready at ${uiUrl}`));
1256
+
1257
+ let activeTunnel = null;
1258
+ await pushUiState({ permanentKey: keyData.key, step: STEP.STOPPED, theme });
1259
+
1260
+ setupExitHandler(serverManager, null, keyData.key);
1261
+ setupCmdPoller(() => activeTunnel, (t) => { activeTunnel = t; }, keyData.key);
1262
+
1263
+ if (process.argv.includes("--start")) writeCmd("start-tunnel");
1264
+
1265
+ await new Promise(() => {});
858
1266
  }
859
1267
 
860
1268
  // Start app
861
1269
  async function start() {
862
- // Disable auto-update - TUI already shows update notification
863
- // const hasUpdate = await checkAndUpdate(skipUpdate);
864
- // if (hasUpdate) return;
865
-
866
1270
  const command = process.argv[2];
867
-
1271
+
1272
+ // Kill any existing 9remote instance (agent + cloudflared) before starting
1273
+ // a new one. ptyDaemon is preserved to keep terminal sessions alive.
1274
+ // Skip when re-entering via --tray / --auto (child spawned by launchBackground),
1275
+ // otherwise the detached child would read its own PID from agent.pid and
1276
+ // kill itself right after spawn.
1277
+ const isChildRespawn = process.argv.includes("--tray") || process.argv.includes("--auto");
1278
+ if (!isChildRespawn) {
1279
+ stopRunningInstances();
1280
+ }
1281
+
868
1282
  if (command === "ui") {
869
- // UI mode: 9remote ui
870
1283
  await startUiMode();
871
1284
  } else if (command === "start" || process.argv.includes("--auto")) {
872
- // Direct start: 9remote start
873
1285
  await autoStartDev();
1286
+ } else if (process.argv.includes("--tray")) {
1287
+ await startTrayMode();
874
1288
  } else {
875
- // TUI mode: 9remote
1289
+ await startupMenu();
1290
+ }
1291
+ }
1292
+
1293
+ async function startupMenu() {
1294
+ const version = getVersion();
1295
+ const updateInfo = await checkLatestVersion();
1296
+ const banner = getBannerText(version, updateInfo?.latest ?? null);
1297
+
1298
+ const items = [];
1299
+ if (updateInfo?.latest) {
1300
+ items.push({ label: chalk.yellow(`Update to v${updateInfo.latest}`), action: "update" });
1301
+ }
1302
+ items.push(
1303
+ { label: "Open Web UI (background)", action: "ui" },
1304
+ { label: "Terminal UI", action: "tui" },
1305
+ { label: chalk.gray("Exit"), action: "exit" },
1306
+ );
1307
+
1308
+ const idx = await selectMenu("", items, 0, banner);
1309
+ const action = idx >= 0 ? items[idx].action : "exit";
1310
+
1311
+ if (action === "update") {
1312
+ const w = Math.min(44, process.stdout.columns || 44);
1313
+ // Stop running background instances so npm install can overwrite locked files
1314
+ stopRunningInstances();
1315
+ console.log(ORANGE("\n" + "═".repeat(w)));
1316
+ console.log(chalk.gray(" āœ“ Stopped running instances\n"));
1317
+ console.log(chalk.yellow(" ⬆ Run this command to update:\n"));
1318
+ console.log(chalk.white.bold(` npm i -g 9remote@latest\n`));
1319
+ console.log(ORANGE("═".repeat(w)) + "\n");
1320
+ process.exit(0);
1321
+ } else if (action === "ui") {
1322
+ await launchBackground();
1323
+ } else if (action === "tui") {
876
1324
  await tuiMode();
1325
+ } else {
1326
+ process.exit(0);
877
1327
  }
878
1328
  }
879
1329