9remote 0.1.52 → 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 +425 -405
- package/cli/utils/apiKey.js +13 -8
- package/cli/utils/cloudflared.js +106 -0
- package/cli/utils/permissions.js +45 -0
- package/cli/utils/state.js +23 -0
- package/cli/utils/tui.js +250 -0
- package/cli/utils/updateChecker.js +22 -0
- package/dist/cli.cjs +44 -95
- package/dist/server.cjs +52 -47
- package/dist/ui/assets/index-CMD-4YxV.js +8 -0
- package/dist/ui/assets/index-D4GJ1wNn.css +1 -0
- package/dist/ui/index.html +2 -2
- package/index.js +265 -5
- package/package.json +6 -4
- package/dist/ui/assets/index-0y2xzwGf.css +0 -1
- package/dist/ui/assets/index-Cu8zxWgo.js +0 -8
package/cli/index.js
CHANGED
|
@@ -8,48 +8,14 @@ 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";
|
|
12
|
-
import https from "https";
|
|
13
|
-
import { promisify } from "util";
|
|
14
11
|
import { getConsistentMachineId } from "./utils/machineId.js";
|
|
15
12
|
import { generateApiKeyWithMachine } from "./utils/apiKey.js";
|
|
16
|
-
import { loadKey, saveKey, loadState, saveState, clearState } from "./utils/state.js";
|
|
13
|
+
import { loadKey, saveKey, loadState, saveState, clearState, readAndClearCmd } from "./utils/state.js";
|
|
17
14
|
import { createTempKey } from "./utils/token.js";
|
|
18
|
-
import { checkAndUpdate } from "./utils/updateChecker.js";
|
|
19
|
-
import {
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
}
|
|
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";
|
|
53
19
|
|
|
54
20
|
// Parse --skip-update flag
|
|
55
21
|
const skipUpdate = process.argv.includes("--skip-update");
|
|
@@ -62,7 +28,6 @@ const STANDALONE_SERVER = path.resolve(__dirname, "../dist/server.cjs");
|
|
|
62
28
|
const DEV_SERVER = path.resolve(__dirname, "../index.js");
|
|
63
29
|
const WORKER_URL = "https://9remote.cc";
|
|
64
30
|
const SERVER_PORT = 2208;
|
|
65
|
-
const SHORT_ID_CHARS = "abcdefghijklmnpqrstuvwxyz23456789";
|
|
66
31
|
const MAX_RESTART_ATTEMPTS = 10;
|
|
67
32
|
const RESTART_WINDOW_MS = 60000; // 1 minute
|
|
68
33
|
|
|
@@ -89,65 +54,23 @@ function getVersion() {
|
|
|
89
54
|
}
|
|
90
55
|
}
|
|
91
56
|
|
|
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
57
|
|
|
139
58
|
/**
|
|
140
59
|
* Helper: Show QR code for connect URL
|
|
141
60
|
*/
|
|
142
61
|
function showQRCode(url, title = "📱 Scan QR to connect:") {
|
|
143
62
|
console.log(ORANGE(`\n${title}`));
|
|
144
|
-
qrcode.generate(url, {
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
+
});
|
|
151
74
|
});
|
|
152
75
|
}
|
|
153
76
|
|
|
@@ -165,12 +88,15 @@ async function showConnectionInfo(selectedKey, tunnelUrl) {
|
|
|
165
88
|
const connectUrl = `${WORKER_URL}/login?k=${tempKeyData.tempKey}`;
|
|
166
89
|
const width = Math.min(44, process.stdout.columns || 55);
|
|
167
90
|
|
|
168
|
-
// Push ready state to UI
|
|
91
|
+
// Push ready state to UI (include permanentKey, expiresAt, workerUrl for server-side key generation)
|
|
169
92
|
pushUiState({
|
|
170
|
-
step:
|
|
93
|
+
step: 4,
|
|
171
94
|
tunnelUrl,
|
|
172
95
|
oneTimeKey: tempKeyData.tempKey,
|
|
96
|
+
oneTimeKeyExpiresAt: tempKeyData.expiresAt,
|
|
97
|
+
permanentKey: selectedKey,
|
|
173
98
|
qrUrl: connectUrl,
|
|
99
|
+
workerUrl: WORKER_URL,
|
|
174
100
|
});
|
|
175
101
|
|
|
176
102
|
showQRCode(connectUrl);
|
|
@@ -197,6 +123,31 @@ async function showConnectionInfo(selectedKey, tunnelUrl) {
|
|
|
197
123
|
console.log(ORANGE("═".repeat(width)));
|
|
198
124
|
}
|
|
199
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
|
+
|
|
200
151
|
/**
|
|
201
152
|
* Kill process on specific port
|
|
202
153
|
*/
|
|
@@ -237,11 +188,15 @@ function startServerWithRestart(onReady, onServerCrash) {
|
|
|
237
188
|
process.exit(1);
|
|
238
189
|
}
|
|
239
190
|
|
|
191
|
+
// Strip NODE_ENV=development when running standalone server (production build)
|
|
192
|
+
const spawnEnv = { ...process.env, PORT: String(SERVER_PORT) };
|
|
193
|
+
if (!useDevServer) delete spawnEnv.NODE_ENV;
|
|
194
|
+
|
|
240
195
|
currentProcess = spawn("node", [serverPath], {
|
|
241
196
|
cwd: path.dirname(serverPath),
|
|
242
197
|
stdio: ["ignore", "inherit", "inherit"],
|
|
243
198
|
detached: false,
|
|
244
|
-
env:
|
|
199
|
+
env: spawnEnv,
|
|
245
200
|
});
|
|
246
201
|
|
|
247
202
|
|
|
@@ -320,7 +275,7 @@ function setupExitHandler(serverManager, tunnelProcess, apiKey) {
|
|
|
320
275
|
console.log(chalk.yellow("\n\n🛑 Stopping server..."));
|
|
321
276
|
|
|
322
277
|
serverManager.shutdown();
|
|
323
|
-
tunnelProcess.kill();
|
|
278
|
+
if (tunnelProcess) tunnelProcess.kill();
|
|
324
279
|
resetRestartCounter();
|
|
325
280
|
clearState();
|
|
326
281
|
|
|
@@ -342,25 +297,6 @@ function getLanIp() {
|
|
|
342
297
|
return null;
|
|
343
298
|
}
|
|
344
299
|
|
|
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
300
|
/** Push UI state to server via HTTP */
|
|
365
301
|
async function pushUiState(data) {
|
|
366
302
|
try {
|
|
@@ -373,7 +309,25 @@ async function pushUiState(data) {
|
|
|
373
309
|
}
|
|
374
310
|
|
|
375
311
|
/**
|
|
376
|
-
*
|
|
312
|
+
* Update session tunnelUrl on worker + push to UI
|
|
313
|
+
*/
|
|
314
|
+
async function updateTunnelUrl(selectedKey, tunnelUrl) {
|
|
315
|
+
const lanIp = getLanIp();
|
|
316
|
+
try {
|
|
317
|
+
await fetch(`${WORKER_URL}/api/session/update`, {
|
|
318
|
+
method: "POST",
|
|
319
|
+
headers: { "Content-Type": "application/json" },
|
|
320
|
+
body: JSON.stringify({
|
|
321
|
+
apiKey: selectedKey,
|
|
322
|
+
tunnelUrl,
|
|
323
|
+
localIp: lanIp ? `${lanIp}:${SERVER_PORT}` : null
|
|
324
|
+
})
|
|
325
|
+
});
|
|
326
|
+
} catch { }
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Helper: Start server and quick tunnel
|
|
377
331
|
*/
|
|
378
332
|
async function startServerAndTunnel(selectedKey) {
|
|
379
333
|
console.log(ORANGE("\n🚀 Starting server..."));
|
|
@@ -382,193 +336,61 @@ async function startServerAndTunnel(selectedKey) {
|
|
|
382
336
|
// Kill existing cloudflared process
|
|
383
337
|
try {
|
|
384
338
|
killCloudflared();
|
|
385
|
-
await new Promise(resolve => setTimeout(resolve,
|
|
339
|
+
await new Promise(resolve => setTimeout(resolve, 500));
|
|
386
340
|
} catch { }
|
|
387
341
|
|
|
388
|
-
//
|
|
389
|
-
const existingState = loadState();
|
|
390
|
-
const shortId = existingState?.shortId || generateShortId();
|
|
391
|
-
|
|
392
|
-
// Create session first
|
|
342
|
+
// Create session on worker
|
|
393
343
|
try {
|
|
394
344
|
const sessionResponse = await fetch(`${WORKER_URL}/api/session/create`, {
|
|
395
345
|
method: "POST",
|
|
396
346
|
headers: { "Content-Type": "application/json" },
|
|
397
|
-
body: JSON.stringify({ apiKey: selectedKey
|
|
347
|
+
body: JSON.stringify({ apiKey: selectedKey })
|
|
398
348
|
});
|
|
399
|
-
|
|
400
349
|
if (!sessionResponse.ok) {
|
|
401
350
|
const text = await sessionResponse.text();
|
|
402
|
-
console.log(chalk.red(`❌ Failed to create session: ${sessionResponse.status} ${
|
|
403
|
-
console.log(chalk.yellow(`Response: ${text.substring(0, 200)}`));
|
|
351
|
+
console.log(chalk.red(`❌ Failed to create session: ${sessionResponse.status} ${text.substring(0, 200)}`));
|
|
404
352
|
return null;
|
|
405
353
|
}
|
|
406
|
-
|
|
407
|
-
const sessionData = await sessionResponse.json();
|
|
408
354
|
} catch (error) {
|
|
409
355
|
console.log(chalk.red(`❌ Failed to create session: ${error.message}`));
|
|
410
356
|
return null;
|
|
411
357
|
}
|
|
412
358
|
|
|
413
|
-
// tunnelProcess declared here so the serverManager crash callback can reference it
|
|
414
|
-
let tunnelProcess = null;
|
|
415
|
-
|
|
416
359
|
// Skip spawning server if already running (e.g. nodemon in dev mode)
|
|
417
360
|
const alreadyRunning = await isServerRunning();
|
|
418
|
-
|
|
419
|
-
// Start server with auto-restart (skip if already running)
|
|
420
361
|
const serverManager = alreadyRunning
|
|
421
362
|
? { getProcess: () => null, shutdown: () => {} }
|
|
422
|
-
: startServerWithRestart(null,
|
|
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
|
-
});
|
|
363
|
+
: startServerWithRestart(null, null);
|
|
481
364
|
|
|
482
|
-
|
|
483
|
-
if (!alreadyRunning) await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
365
|
+
if (!alreadyRunning) await new Promise(resolve => setTimeout(resolve, 2000));
|
|
484
366
|
|
|
485
|
-
console.log(ORANGE("✅
|
|
367
|
+
console.log(ORANGE("✅ Starting tunnel..."));
|
|
486
368
|
pushUiState({ step: 2 });
|
|
487
369
|
|
|
488
|
-
//
|
|
370
|
+
// Spawn quick tunnel — URL comes directly from cloudflared stdout
|
|
371
|
+
let tunnelProcess, tunnelUrl;
|
|
489
372
|
try {
|
|
490
|
-
await
|
|
373
|
+
const result = await spawnQuickTunnel(SERVER_PORT, async (newUrl) => {
|
|
374
|
+
// URL rotated — update worker + UI
|
|
375
|
+
console.log(ORANGE(`🔄 Tunnel URL rotated: ${newUrl}`));
|
|
376
|
+
await updateTunnelUrl(selectedKey, newUrl);
|
|
377
|
+
pushUiState({ tunnelUrl: newUrl });
|
|
378
|
+
});
|
|
379
|
+
tunnelProcess = result.child;
|
|
380
|
+
tunnelUrl = result.tunnelUrl;
|
|
491
381
|
} catch (error) {
|
|
492
|
-
console.log(chalk.red(`❌ Failed to
|
|
382
|
+
console.log(chalk.red(`❌ Failed to start tunnel: ${error.message}`));
|
|
493
383
|
serverManager.shutdown();
|
|
494
384
|
return null;
|
|
495
385
|
}
|
|
496
386
|
|
|
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
387
|
|
|
508
|
-
|
|
388
|
+
// Save tunnelUrl to worker DB
|
|
389
|
+
await updateTunnelUrl(selectedKey, tunnelUrl);
|
|
509
390
|
|
|
510
|
-
//
|
|
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)
|
|
391
|
+
// Save local state
|
|
569
392
|
saveState({
|
|
570
393
|
apiKey: selectedKey,
|
|
571
|
-
shortId,
|
|
572
394
|
tunnelUrl,
|
|
573
395
|
serverPid: serverManager.getProcess()?.pid,
|
|
574
396
|
tunnelPid: tunnelProcess.pid
|
|
@@ -579,160 +401,284 @@ async function startServerAndTunnel(selectedKey) {
|
|
|
579
401
|
|
|
580
402
|
|
|
581
403
|
/**
|
|
582
|
-
*
|
|
404
|
+
* TUI mode — main flow for `9remote` (no subcommand)
|
|
583
405
|
*/
|
|
584
|
-
async function
|
|
406
|
+
async function tuiMode() {
|
|
585
407
|
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
408
|
|
|
601
|
-
|
|
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() {
|
|
409
|
+
// ── 1. Parallel: check latest version + start server ──────────────────────
|
|
618
410
|
const machineId = await getConsistentMachineId();
|
|
619
411
|
let keyData = loadKey();
|
|
620
|
-
|
|
621
|
-
// Auto create key if none exists
|
|
622
412
|
if (!keyData.key) {
|
|
623
|
-
console.log(chalk.yellow("\n⚠️ No key found. Creating default key..."));
|
|
624
413
|
const { key } = generateApiKeyWithMachine(machineId);
|
|
625
414
|
keyData = saveKey(machineId, key, "Default");
|
|
626
|
-
console.log(chalk.green("✅ Default key created!"));
|
|
627
415
|
}
|
|
628
416
|
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
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);
|
|
634
456
|
}
|
|
635
457
|
|
|
636
|
-
|
|
458
|
+
renderProgress(2, true); // Starting tunnel
|
|
637
459
|
|
|
638
|
-
|
|
639
|
-
|
|
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
|
+
}
|
|
640
473
|
|
|
641
|
-
|
|
642
|
-
|
|
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;
|
|
643
541
|
}
|
|
644
542
|
|
|
645
543
|
/**
|
|
646
|
-
*
|
|
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
|
|
647
550
|
*/
|
|
648
|
-
async function
|
|
649
|
-
|
|
650
|
-
|
|
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
|
+
});
|
|
651
570
|
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
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
|
+
}
|
|
659
586
|
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
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();
|
|
677
607
|
|
|
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
608
|
} else {
|
|
704
|
-
|
|
609
|
+
// Exit
|
|
610
|
+
console.log(chalk.gray("\nGoodbye!\n"));
|
|
611
|
+
process.exit(0);
|
|
705
612
|
}
|
|
706
|
-
await inquirer.prompt([{ type: "input", name: "continue", message: "Press Enter to continue..." }]);
|
|
707
613
|
}
|
|
614
|
+
}
|
|
708
615
|
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
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 };
|
|
716
630
|
}
|
|
717
|
-
|
|
631
|
+
} catch {}
|
|
718
632
|
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
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
|
|
725
672
|
}
|
|
726
673
|
}
|
|
727
|
-
|
|
728
|
-
await mainMenu();
|
|
729
674
|
}
|
|
730
675
|
|
|
676
|
+
|
|
731
677
|
/**
|
|
732
678
|
* Auto start dev server (--auto flag)
|
|
733
679
|
*/
|
|
734
680
|
async function autoStartDev() {
|
|
735
|
-
showBanner();
|
|
681
|
+
showBanner(getVersion());
|
|
736
682
|
|
|
737
683
|
const machineId = await getConsistentMachineId();
|
|
738
684
|
let keyData = loadKey();
|
|
@@ -756,6 +702,7 @@ async function autoStartDev() {
|
|
|
756
702
|
|
|
757
703
|
await showConnectionInfo(keyData.key, tunnelUrl);
|
|
758
704
|
setupExitHandler(serverManager, tunnelProcess, keyData.key);
|
|
705
|
+
setupKeyRegenListener();
|
|
759
706
|
|
|
760
707
|
// Push stats to UI every 5s
|
|
761
708
|
const startTime = Date.now();
|
|
@@ -770,6 +717,77 @@ async function autoStartDev() {
|
|
|
770
717
|
}
|
|
771
718
|
|
|
772
719
|
|
|
720
|
+
/**
|
|
721
|
+
* Listen for key regen, stop-tunnel, start-tunnel from server UI
|
|
722
|
+
*/
|
|
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);
|
|
789
|
+
}
|
|
790
|
+
|
|
773
791
|
/**
|
|
774
792
|
* Check if server is already running on SERVER_PORT
|
|
775
793
|
*/
|
|
@@ -787,7 +805,7 @@ async function isServerRunning() {
|
|
|
787
805
|
* Same as "Start Server" in TUI but auto-opens browser
|
|
788
806
|
*/
|
|
789
807
|
async function startUiMode() {
|
|
790
|
-
showBanner();
|
|
808
|
+
showBanner(getVersion());
|
|
791
809
|
|
|
792
810
|
const machineId = await getConsistentMachineId();
|
|
793
811
|
let keyData = loadKey();
|
|
@@ -797,12 +815,13 @@ async function startUiMode() {
|
|
|
797
815
|
keyData = saveKey(machineId, key, "Default");
|
|
798
816
|
}
|
|
799
817
|
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
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);
|
|
804
823
|
|
|
805
|
-
await
|
|
824
|
+
if (!alreadyRunning) await new Promise(resolve => setTimeout(resolve, 2000));
|
|
806
825
|
|
|
807
826
|
// Open browser pointing to UI
|
|
808
827
|
const url = `http://localhost:${SERVER_PORT}`;
|
|
@@ -812,15 +831,16 @@ async function startUiMode() {
|
|
|
812
831
|
spawn(openCmd, [url], { detached: true, stdio: "ignore" }).unref();
|
|
813
832
|
console.log(chalk.green(`\n🌐 UI ready at ${url}`));
|
|
814
833
|
|
|
815
|
-
|
|
834
|
+
// Mutable ref for active tunnel
|
|
835
|
+
let activeTunnel = null;
|
|
836
|
+
const getActiveTunnel = () => activeTunnel;
|
|
837
|
+
const setActiveTunnel = (t) => { activeTunnel = t; };
|
|
816
838
|
|
|
817
|
-
// Push
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
pushUiState({ uptime: `${h}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}` });
|
|
823
|
-
}, 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);
|
|
824
844
|
|
|
825
845
|
await new Promise(() => { });
|
|
826
846
|
}
|
|
@@ -840,8 +860,8 @@ async function start() {
|
|
|
840
860
|
// Direct start: 9remote start
|
|
841
861
|
await autoStartDev();
|
|
842
862
|
} else {
|
|
843
|
-
//
|
|
844
|
-
await
|
|
863
|
+
// TUI mode: 9remote
|
|
864
|
+
await tuiMode();
|
|
845
865
|
}
|
|
846
866
|
}
|
|
847
867
|
|