@simbatech/simbatechsite 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.js ADDED
@@ -0,0 +1,991 @@
1
+ #!/usr/bin/env node
2
+
3
+ import axios from "axios";
4
+ import { spawn } from "child_process";
5
+ import chalk from "chalk";
6
+ import ora from "ora";
7
+ import fs from "fs";
8
+ import path from "path";
9
+ import { fileURLToPath } from "url";
10
+ import { createRequire } from "module";
11
+ import { createInterface } from "readline";
12
+ import os from "os";
13
+ import { analytics } from "./analytics.js";
14
+
15
+ // ============================================================================
16
+ // Module Setup & Constants
17
+ // ============================================================================
18
+
19
+ const __filename = fileURLToPath(import.meta.url);
20
+ const __dirname = path.dirname(__filename);
21
+ const require = createRequire(import.meta.url);
22
+ const packageJson = require("./package.json");
23
+
24
+ // Application constants
25
+ const CONFIG = {
26
+ PACKAGE_NAME: packageJson.name,
27
+ CURRENT_VERSION: packageJson.version,
28
+ BACKEND_URL: "https://simbatechsite.wanolink.workers.dev",
29
+ DEFAULT_PORT: 8080,
30
+ SUBDOMAIN_PREFIX: "user-",
31
+ TUNNEL_TIMEOUT_HOURS: 4,
32
+ UPDATE_CHECK_TIMEOUT: 3000,
33
+ };
34
+
35
+ // Platform-specific configuration
36
+ const PLATFORM = {
37
+ IS_WINDOWS: process.platform === "win32",
38
+ BIN_NAME: process.platform === "win32" ? "cloudflared.exe" : "cloudflared",
39
+ };
40
+
41
+ // Paths
42
+ const PATHS = {
43
+ BIN_DIR: path.join(__dirname, "bin"),
44
+ BIN_PATH: path.join(__dirname, "bin", PLATFORM.BIN_NAME),
45
+ CONFIG_DIR: path.join(os.homedir(), ".simbatechsite"),
46
+ CONFIG_FILE: path.join(os.homedir(), ".simbatechsite", "config.json"),
47
+ };
48
+
49
+ // Log patterns for filtering cloudflared output
50
+ const LOG_PATTERNS = {
51
+ SUCCESS: ["Registered tunnel connection"],
52
+ ERROR: ["ERR", "error"],
53
+ IGNORE: [
54
+ "Cannot determine default origin certificate path",
55
+ "No file cert.pem",
56
+ "origincert option",
57
+ "TUNNEL_ORIGIN_CERT",
58
+ "context canceled",
59
+ "failed to run the datagram handler",
60
+ "failed to serve tunnel connection",
61
+ "Connection terminated",
62
+ "no more connections active and exiting",
63
+ "Serve tunnel error",
64
+ "accept stream listener encountered a failure",
65
+ "Retrying connection",
66
+ "icmp router terminated",
67
+ "use of closed network connection",
68
+ "Application error 0x0",
69
+ "Failed to fetch features",
70
+ "Failed to initialize DNS",
71
+ "i/o timeout",
72
+ "argotunnel.com",
73
+ ],
74
+ };
75
+
76
+ // Helper function to compute timeout MS from hours (0 = unlimited)
77
+ function computeTimeoutMs(hours) {
78
+ if (hours === 0 || hours === null) return null; // Unlimited
79
+ return hours * 60 * 60 * 1000;
80
+ }
81
+
82
+ // ============================================================================
83
+ // Token Manager
84
+ // ============================================================================
85
+
86
+ class TokenManager {
87
+ static loadConfig() {
88
+ try {
89
+ if (fs.existsSync(PATHS.CONFIG_FILE)) {
90
+ const content = fs.readFileSync(PATHS.CONFIG_FILE, "utf-8");
91
+ return JSON.parse(content);
92
+ }
93
+ } catch (error) {
94
+ // Config file corrupt or unreadable
95
+ }
96
+ return {};
97
+ }
98
+
99
+ static saveConfig(config) {
100
+ try {
101
+ // Create config directory if it doesn't exist
102
+ if (!fs.existsSync(PATHS.CONFIG_DIR)) {
103
+ fs.mkdirSync(PATHS.CONFIG_DIR, { recursive: true });
104
+ }
105
+ fs.writeFileSync(PATHS.CONFIG_FILE, JSON.stringify(config, null, 2));
106
+ return true;
107
+ } catch (error) {
108
+ console.error(chalk.red(`Failed to save config: ${error.message}`));
109
+ return false;
110
+ }
111
+ }
112
+
113
+ static getToken() {
114
+ const config = this.loadConfig();
115
+ return config.token || null;
116
+ }
117
+
118
+ static setToken(token) {
119
+ const config = this.loadConfig();
120
+ config.token = token;
121
+ return this.saveConfig(config);
122
+ }
123
+
124
+ static clearToken() {
125
+ const config = this.loadConfig();
126
+ delete config.token;
127
+ return this.saveConfig(config);
128
+ }
129
+
130
+ static async promptToken() {
131
+ const rl = createInterface({
132
+ input: process.stdin,
133
+ output: process.stdout,
134
+ });
135
+
136
+ return new Promise((resolve) => {
137
+ rl.question(chalk.cyan("🔐 Enter token: "), (answer) => {
138
+ rl.close();
139
+ resolve(answer.trim());
140
+ });
141
+ });
142
+ }
143
+
144
+ static async handleAuth() {
145
+ console.log(chalk.bold("\n🔑 Simbatechsite Authentication\n"));
146
+
147
+ const existingToken = this.getToken();
148
+ if (existingToken) {
149
+ console.log(chalk.gray(`Current token: ${existingToken.substring(0, 8)}...`));
150
+ }
151
+
152
+ const token = await this.promptToken();
153
+
154
+ if (!token) {
155
+ console.log(chalk.red("❌ No token provided."));
156
+ process.exit(1);
157
+ }
158
+
159
+ if (this.setToken(token)) {
160
+ console.log(chalk.green("\n✔ Token saved successfully!"));
161
+ console.log(chalk.gray(` Config: ${PATHS.CONFIG_FILE}`));
162
+ } else {
163
+ console.log(chalk.red("\n❌ Failed to save token."));
164
+ process.exit(1);
165
+ }
166
+ }
167
+
168
+ static handleLogout() {
169
+ const existingToken = this.getToken();
170
+
171
+ if (!existingToken) {
172
+ console.log(chalk.yellow("No token found. Already logged out."));
173
+ return;
174
+ }
175
+
176
+ if (this.clearToken()) {
177
+ console.log(chalk.green("✔ Logged out successfully. Token removed."));
178
+ } else {
179
+ console.log(chalk.red("❌ Failed to remove token."));
180
+ }
181
+ }
182
+ }
183
+
184
+ // ============================================================================
185
+ // Connection Progress Tracker
186
+ // ============================================================================
187
+
188
+ class ConnectionProgressTracker {
189
+ constructor() {
190
+ this.startTime = null;
191
+ this.intervalId = null;
192
+ this.isActive = false;
193
+ this.spinnerFrames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
194
+ this.currentFrame = 0;
195
+ this.bouncePosition = 0;
196
+ this.bounceDirection = 1;
197
+ this.barWidth = 20;
198
+ this.blockWidth = 4;
199
+ }
200
+
201
+ start() {
202
+ if (this.isActive) return;
203
+
204
+ this.isActive = true;
205
+ this.startTime = Date.now();
206
+ this.currentFrame = 0;
207
+ this.bouncePosition = 0;
208
+ this.bounceDirection = 1;
209
+
210
+ // Clear line and show initial state
211
+ this.render();
212
+
213
+ // Update every 80ms for smooth animation
214
+ this.intervalId = setInterval(() => {
215
+ this.currentFrame = (this.currentFrame + 1) % this.spinnerFrames.length;
216
+
217
+ // Bounce animation - move block back and forth
218
+ this.bouncePosition += this.bounceDirection;
219
+ if (this.bouncePosition >= this.barWidth - this.blockWidth) {
220
+ this.bounceDirection = -1;
221
+ } else if (this.bouncePosition <= 0) {
222
+ this.bounceDirection = 1;
223
+ }
224
+
225
+ this.render();
226
+ }, 80);
227
+ }
228
+
229
+ formatElapsedTime(ms) {
230
+ const totalSeconds = Math.floor(ms / 1000);
231
+ const minutes = Math.floor(totalSeconds / 60);
232
+ const seconds = totalSeconds % 60;
233
+ const tenths = Math.floor((ms % 1000) / 100);
234
+
235
+ if (minutes > 0) {
236
+ return `${minutes}:${seconds.toString().padStart(2, "0")}.${tenths}`;
237
+ }
238
+ return `${seconds}.${tenths}s`;
239
+ }
240
+
241
+ getIndeterminateBar() {
242
+ // Create bouncing block animation
243
+ const emptyChar = "░";
244
+ const blockChar = "█";
245
+
246
+ const before = emptyChar.repeat(this.bouncePosition);
247
+ const block = blockChar.repeat(this.blockWidth);
248
+ const after = emptyChar.repeat(this.barWidth - this.bouncePosition - this.blockWidth);
249
+
250
+ return before + block + after;
251
+ }
252
+
253
+ render() {
254
+ if (!this.isActive) return;
255
+
256
+ const elapsed = Date.now() - this.startTime;
257
+ const spinner = this.spinnerFrames[this.currentFrame];
258
+ const elapsedFormatted = this.formatElapsedTime(elapsed);
259
+ const bar = this.getIndeterminateBar();
260
+
261
+ // Clear line and move cursor to beginning
262
+ process.stdout.write("\r\x1b[K");
263
+
264
+ // Render professional progress line with indeterminate animation
265
+ const line = chalk.cyan(spinner) +
266
+ chalk.gray(" Connecting to global network") +
267
+ chalk.gray(" │ ") +
268
+ chalk.blue(bar) +
269
+ chalk.gray(" │ ") +
270
+ chalk.gray(`⏱ ${elapsedFormatted}`);
271
+
272
+ process.stdout.write(line);
273
+ }
274
+
275
+ stop(success = true) {
276
+ if (!this.isActive) return;
277
+
278
+ this.isActive = false;
279
+
280
+ if (this.intervalId) {
281
+ clearInterval(this.intervalId);
282
+ this.intervalId = null;
283
+ }
284
+
285
+ const elapsed = Date.now() - this.startTime;
286
+ const elapsedFormatted = this.formatElapsedTime(elapsed);
287
+
288
+ // Clear the progress line
289
+ process.stdout.write("\r\x1b[K");
290
+
291
+ // Show final status
292
+ if (success) {
293
+ console.log(
294
+ chalk.green("✔") +
295
+ chalk.green(" Connected to global network") +
296
+ chalk.gray(" │ ") +
297
+ chalk.gray(`Completed in ${elapsedFormatted}`)
298
+ );
299
+ } else {
300
+ console.log(
301
+ chalk.red("✖") +
302
+ chalk.red(" Connection failed") +
303
+ chalk.gray(" │ ") +
304
+ chalk.gray(`Duration: ${elapsedFormatted}`)
305
+ );
306
+ }
307
+ }
308
+ }
309
+
310
+ const progressTracker = new ConnectionProgressTracker();
311
+
312
+ // ============================================================================
313
+ // Application State
314
+ // ============================================================================
315
+
316
+ class TunnelState {
317
+ constructor() {
318
+ this.tunnelId = null;
319
+ this.subdomain = null;
320
+ this.port = null;
321
+ this.tunnelProcess = null;
322
+ this.timeoutId = null;
323
+ this.connectionCount = 0;
324
+ this.startTime = null;
325
+ }
326
+
327
+ setTunnel(tunnelId, subdomain, port) {
328
+ this.tunnelId = tunnelId;
329
+ this.subdomain = subdomain;
330
+ this.port = port;
331
+ if (!this.startTime) {
332
+ this.startTime = Date.now();
333
+ }
334
+ }
335
+
336
+ setProcess(process) {
337
+ this.tunnelProcess = process;
338
+ }
339
+
340
+ setTimeout(timeoutId) {
341
+ this.timeoutId = timeoutId;
342
+ }
343
+
344
+ clearTimeout() {
345
+ if (this.timeoutId) {
346
+ clearTimeout(this.timeoutId);
347
+ this.timeoutId = null;
348
+ }
349
+ }
350
+
351
+ incrementConnection() {
352
+ this.connectionCount++;
353
+ return this.connectionCount;
354
+ }
355
+
356
+ hasTunnel() {
357
+ return this.tunnelId !== null;
358
+ }
359
+
360
+ hasProcess() {
361
+ return this.tunnelProcess && !this.tunnelProcess.killed;
362
+ }
363
+
364
+ getDurationSeconds() {
365
+ if (!this.startTime) return 0;
366
+ return (Date.now() - this.startTime) / 1000;
367
+ }
368
+
369
+ reset() {
370
+ this.clearTimeout();
371
+ this.tunnelId = null;
372
+ this.subdomain = null;
373
+ this.port = null;
374
+ this.tunnelProcess = null;
375
+ this.connectionCount = 0;
376
+ this.startTime = null;
377
+ }
378
+ }
379
+
380
+ const state = new TunnelState();
381
+
382
+ // ============================================================================
383
+ // Argument Parsing
384
+ // ============================================================================
385
+
386
+ class ArgumentParser {
387
+ static parse(argv) {
388
+ // Check for help flag
389
+ if (argv.includes("--help") || argv.includes("-h")) {
390
+ this.displayHelp();
391
+ process.exit(0);
392
+ }
393
+
394
+ const port = this.parsePort(argv);
395
+ const subdomain = this.parseSubdomain(argv);
396
+ const timeout = this.parseTimeout(argv);
397
+ return { port, subdomain, timeout };
398
+ }
399
+
400
+ static displayHelp() {
401
+ console.log(`
402
+ ${chalk.bold("Simbatechsite")} - Tunnel localhost to the internet via Cloudflare Edge
403
+
404
+ ${chalk.yellow("Usage:")}
405
+ simbatechsite <port> [options]
406
+ simbatechsite <command>
407
+
408
+ ${chalk.yellow("Commands:")}
409
+ ${chalk.cyan("auth")} Authenticate with a token
410
+ ${chalk.cyan("logout")} Remove saved token
411
+
412
+ ${chalk.yellow("Options:")}
413
+ ${chalk.cyan("-s, --subdomain <name>")} Custom subdomain (default: random)
414
+ ${chalk.cyan("-t, --timeout <hours>")} Timeout in hours, 0 for unlimited (default: ${CONFIG.TUNNEL_TIMEOUT_HOURS})
415
+ ${chalk.cyan("-h, --help")} Show this help message
416
+
417
+ ${chalk.yellow("Examples:")}
418
+ ${chalk.gray("# First time: authenticate")}
419
+ simbatechsite auth
420
+
421
+ ${chalk.gray("# Basic usage with default timeout (4 hours)")}
422
+ simbatechsite 3000
423
+
424
+ ${chalk.gray("# Custom subdomain")}
425
+ simbatechsite 3000 -s myapp
426
+
427
+ ${chalk.gray("# Custom timeout (2 hours)")}
428
+ simbatechsite 3000 --timeout 2
429
+
430
+ ${chalk.gray("# Unlimited timeout (no auto-shutdown)")}
431
+ simbatechsite 3000 -t 0
432
+
433
+ ${chalk.gray("# Combine all options")}
434
+ simbatechsite 3000 -s myapp -t 0
435
+ `);
436
+ }
437
+
438
+ static parsePort(argv) {
439
+ const portArg = parseInt(argv[0]);
440
+ return portArg || CONFIG.DEFAULT_PORT;
441
+ }
442
+
443
+ static parseSubdomain(argv) {
444
+ // Try all subdomain formats
445
+ const formats = [
446
+ () => this.findFlagWithEquals(argv, "--subdomain="),
447
+ () => this.findFlagWithEquals(argv, "-s="),
448
+ () => this.findFlagWithValue(argv, "--subdomain"),
449
+ () => this.findFlagWithValue(argv, "-s"),
450
+ ];
451
+
452
+ for (const format of formats) {
453
+ const subdomain = format();
454
+ if (subdomain) return subdomain;
455
+ }
456
+
457
+ return this.generateRandomSubdomain();
458
+ }
459
+
460
+ static findFlagWithEquals(argv, flag) {
461
+ const arg = argv.find((a) => a.startsWith(flag));
462
+ return arg ? arg.split("=")[1] : null;
463
+ }
464
+
465
+ static findFlagWithValue(argv, flag) {
466
+ const index = argv.indexOf(flag);
467
+ return index !== -1 && argv[index + 1] ? argv[index + 1] : null;
468
+ }
469
+
470
+ static generateRandomSubdomain() {
471
+ return `${CONFIG.SUBDOMAIN_PREFIX}${Math.floor(Math.random() * 10000)}`;
472
+ }
473
+
474
+ static parseTimeout(argv) {
475
+ // Try all timeout formats
476
+ const formats = [
477
+ () => this.findFlagWithEquals(argv, "--timeout="),
478
+ () => this.findFlagWithEquals(argv, "-t="),
479
+ () => this.findFlagWithValue(argv, "--timeout"),
480
+ () => this.findFlagWithValue(argv, "-t"),
481
+ ];
482
+
483
+ for (const format of formats) {
484
+ const value = format();
485
+ if (value !== null) {
486
+ const parsed = parseFloat(value);
487
+ if (!isNaN(parsed) && parsed >= 0) {
488
+ return parsed;
489
+ }
490
+ }
491
+ }
492
+
493
+ return CONFIG.TUNNEL_TIMEOUT_HOURS; // Default
494
+ }
495
+ }
496
+
497
+ // ============================================================================
498
+ // Binary Management
499
+ // ============================================================================
500
+
501
+ class BinaryManager {
502
+ static validate(binaryPath) {
503
+ if (fs.existsSync(binaryPath)) {
504
+ return true;
505
+ }
506
+
507
+ console.error(
508
+ chalk.red(`\n❌ Error: Cloudflared binary not found at: ${binaryPath}`)
509
+ );
510
+ console.error(
511
+ chalk.yellow(
512
+ "👉 Please run 'npm install' again to download the binary.\n"
513
+ )
514
+ );
515
+ return false;
516
+ }
517
+
518
+ static spawn(binaryPath, token, port) {
519
+ return spawn(binaryPath, [
520
+ "tunnel",
521
+ "run",
522
+ "--token",
523
+ token,
524
+ "--url",
525
+ `http://localhost:${port}`,
526
+ ]);
527
+ }
528
+
529
+ static attachHandlers(process, spinner = null) {
530
+ process.stderr.on("data", (chunk) => this.handleStderr(chunk));
531
+ process.on("error", (err) => this.handleError(err, spinner));
532
+ process.on("close", (code) => this.handleClose(code));
533
+ }
534
+
535
+ static handleStderr(chunk) {
536
+ const msg = chunk.toString();
537
+
538
+ // Skip harmless warnings
539
+ if (LOG_PATTERNS.IGNORE.some((pattern) => msg.includes(pattern))) {
540
+ return;
541
+ }
542
+
543
+ // Show success messages with connection count
544
+ if (LOG_PATTERNS.SUCCESS.some((pattern) => msg.includes(pattern))) {
545
+ const count = state.incrementConnection();
546
+
547
+ // Stop progress tracker on first connection
548
+ if (count === 1) {
549
+ progressTracker.stop(true);
550
+ }
551
+
552
+ const messages = [
553
+ "✔ Connection established [1/4] - Establishing redundancy...",
554
+ "✔ Connection established [2/4] - Building tunnel network...",
555
+ "✔ Connection established [3/4] - Almost there...",
556
+ "✔ Connection established [4/4] - Tunnel is fully active! 🚀",
557
+ ];
558
+
559
+ if (count <= 4) {
560
+ console.log(chalk.blueBright(messages[count - 1]));
561
+ }
562
+ return;
563
+ }
564
+
565
+ // Show critical errors only
566
+ if (LOG_PATTERNS.ERROR.some((pattern) => msg.includes(pattern))) {
567
+ // Stop progress tracker if still running (connection failed)
568
+ if (progressTracker.isActive) {
569
+ progressTracker.stop(false);
570
+ }
571
+ console.error(chalk.red(`[Cloudflared] ${msg.trim()}`));
572
+ }
573
+ }
574
+
575
+ static handleError(err, spinner) {
576
+ // Stop progress tracker if still running
577
+ if (progressTracker.isActive) {
578
+ progressTracker.stop(false);
579
+ }
580
+ if (spinner) {
581
+ spinner.fail("Failed to spawn cloudflared process.");
582
+ }
583
+ console.error(chalk.red(`Process Error: ${err.message}`));
584
+ }
585
+
586
+ static handleClose(code) {
587
+ // Stop progress tracker if still running (unexpected exit)
588
+ if (progressTracker.isActive) {
589
+ progressTracker.stop(false);
590
+ }
591
+ if (code !== 0 && code !== null) {
592
+ console.log(chalk.red(`Tunnel process exited with code ${code}`));
593
+ }
594
+ }
595
+ }
596
+
597
+ // ============================================================================
598
+ // API Client
599
+ // ============================================================================
600
+
601
+ class APIClient {
602
+ static async createTunnel(subdomain) {
603
+ const token = TokenManager.getToken();
604
+
605
+ if (!token) {
606
+ throw new Error(
607
+ chalk.red("❌ Authentication required.\n\n") +
608
+ chalk.yellow("Please run: ") + chalk.cyan("simbatechsite auth") + chalk.yellow(" to authenticate.")
609
+ );
610
+ }
611
+
612
+ try {
613
+ const { data } = await axios.post(CONFIG.BACKEND_URL, { subdomain, token });
614
+
615
+ if (!data.success) {
616
+ throw new Error(data.error || "Unknown error from backend");
617
+ }
618
+
619
+ return {
620
+ tunnelId: data.tunnelId,
621
+ tunnelToken: data.tunnelToken,
622
+ url: data.url,
623
+ };
624
+ } catch (error) {
625
+ throw this.handleError(error, subdomain);
626
+ }
627
+ }
628
+
629
+ static async deleteTunnel(subdomain, tunnelId) {
630
+ const token = TokenManager.getToken();
631
+ await axios.delete(CONFIG.BACKEND_URL, {
632
+ data: { subdomain, tunnelId, token },
633
+ });
634
+ }
635
+
636
+ static handleError(error, subdomain) {
637
+ if (error.response?.data?.error) {
638
+ const errorMsg = error.response.data.error;
639
+
640
+ // Check for auth errors
641
+ if (errorMsg.includes("AUTH_REQUIRED") || errorMsg.includes("AUTH_INVALID")) {
642
+ return new Error(
643
+ chalk.red("❌ Authentication failed.\n\n") +
644
+ chalk.yellow("Your token is invalid or has been revoked.\n") +
645
+ chalk.white("Please run: ") + chalk.cyan("simbatechsite auth") + chalk.white(" to re-authenticate.\n")
646
+ );
647
+ }
648
+
649
+ // Check for subdomain in use (active tunnel)
650
+ if (
651
+ errorMsg.includes("SUBDOMAIN_IN_USE:") ||
652
+ errorMsg.includes("currently in use") ||
653
+ errorMsg.includes("already exists and is currently active")
654
+ ) {
655
+ return new Error(
656
+ chalk.red(`✗ Subdomain "${subdomain}" is already in use!\n\n`) +
657
+ chalk.yellow(`💡 This subdomain is currently being used by another active tunnel.\n\n`) +
658
+ chalk.white(`Choose a different subdomain:\n`) +
659
+ chalk.gray(` 1. Add a suffix: `) +
660
+ chalk.cyan(`wanolink ${state.port || CONFIG.DEFAULT_PORT} -s ${subdomain}-2\n`) +
661
+ chalk.gray(` 2. Try a variation: `) +
662
+ chalk.cyan(`wanolink ${state.port || CONFIG.DEFAULT_PORT} -s my-${subdomain}\n`) +
663
+ chalk.gray(` 3. Use random name: `) +
664
+ chalk.cyan(`wanolink ${state.port || CONFIG.DEFAULT_PORT}\n`)
665
+ );
666
+ }
667
+
668
+ // Check for duplicate tunnel error (other Cloudflare errors)
669
+ if (
670
+ errorMsg.includes("already have a tunnel") ||
671
+ errorMsg.includes("[1013]")
672
+ ) {
673
+ return new Error(
674
+ `Subdomain "${subdomain}" is already taken or in use.\n\n` +
675
+ chalk.yellow(`💡 Try one of these options:\n`) +
676
+ chalk.gray(` 1. Choose a different subdomain: `) +
677
+ chalk.cyan(`wanolink ${state.port || CONFIG.DEFAULT_PORT} -s ${subdomain}-v2\n`) +
678
+ chalk.gray(` 2. Use a random subdomain: `) +
679
+ chalk.cyan(`wanolink ${state.port || CONFIG.DEFAULT_PORT}\n`) +
680
+ chalk.gray(
681
+ ` 3. Wait a few minutes and retry if you just stopped a tunnel with this name`
682
+ )
683
+ );
684
+ }
685
+
686
+ return new Error(`Backend Error: ${errorMsg}`);
687
+ }
688
+
689
+ if (error.response) {
690
+ const errorMsg = JSON.stringify(error.response.data, null, 2);
691
+ return new Error(`Backend Error: ${errorMsg}`);
692
+ }
693
+
694
+ return error;
695
+ }
696
+ }
697
+
698
+ // ============================================================================
699
+ // Version Management
700
+ // ============================================================================
701
+
702
+ class VersionManager {
703
+ static async checkForUpdates() {
704
+ try {
705
+ const response = await axios.get(
706
+ `https://registry.npmjs.org/${CONFIG.PACKAGE_NAME}/latest`,
707
+ { timeout: CONFIG.UPDATE_CHECK_TIMEOUT }
708
+ );
709
+
710
+ const latestVersion = response.data.version;
711
+ const shouldUpdate =
712
+ this.compareVersions(latestVersion, CONFIG.CURRENT_VERSION) > 0;
713
+
714
+ // Track update notification if available
715
+ if (shouldUpdate) {
716
+ analytics.trackUpdateAvailable(CONFIG.CURRENT_VERSION, latestVersion);
717
+ }
718
+
719
+ return {
720
+ current: CONFIG.CURRENT_VERSION,
721
+ latest: latestVersion,
722
+ shouldUpdate,
723
+ };
724
+ } catch (error) {
725
+ // Silently fail if can't check for updates
726
+ return null;
727
+ }
728
+ }
729
+
730
+ static compareVersions(v1, v2) {
731
+ const parts1 = v1.split(".").map(Number);
732
+ const parts2 = v2.split(".").map(Number);
733
+
734
+ // Compare up to the maximum length of both version arrays
735
+ const maxLength = Math.max(parts1.length, parts2.length);
736
+
737
+ for (let i = 0; i < maxLength; i++) {
738
+ // Treat missing parts as 0 (e.g., "1.0" is "1.0.0")
739
+ const part1 = parts1[i] || 0;
740
+ const part2 = parts2[i] || 0;
741
+
742
+ if (part1 > part2) return 1;
743
+ if (part1 < part2) return -1;
744
+ }
745
+
746
+ return 0;
747
+ }
748
+ }
749
+
750
+ // ============================================================================
751
+ // UI Display
752
+ // ============================================================================
753
+
754
+ class UI {
755
+ static displayUpdateNotification(updateInfo) {
756
+ if (!updateInfo || !updateInfo.shouldUpdate) return;
757
+
758
+ const border = "═".repeat(59);
759
+ const boxWidth = 59;
760
+
761
+ // Calculate padding dynamically
762
+ const currentVersionText = ` Current version: v${updateInfo.current}`;
763
+ const latestVersionText = ` Latest version: v${updateInfo.latest}`;
764
+ const runCommandText = ` Run: npm install -g ${CONFIG.PACKAGE_NAME}@latest`;
765
+
766
+ console.log(chalk.yellow(`\n╔${border}╗`));
767
+ console.log(
768
+ chalk.yellow("║") +
769
+ chalk.bold.yellow(" 📦 Update Available!") +
770
+ " ".repeat(37) +
771
+ chalk.yellow("║")
772
+ );
773
+ console.log(chalk.yellow(`╠${border}╣`));
774
+ console.log(
775
+ chalk.yellow("║") +
776
+ chalk.gray(` Current version: `) +
777
+ chalk.red(`v${updateInfo.current}`) +
778
+ " ".repeat(boxWidth - currentVersionText.length) +
779
+ chalk.yellow("║")
780
+ );
781
+ console.log(
782
+ chalk.yellow("║") +
783
+ chalk.gray(` Latest version: `) +
784
+ chalk.green(`v${updateInfo.latest}`) +
785
+ " ".repeat(boxWidth - latestVersionText.length) +
786
+ chalk.yellow("║")
787
+ );
788
+ console.log(chalk.yellow(`╠${border}╣`));
789
+ console.log(
790
+ chalk.yellow("║") +
791
+ chalk.cyan(` Run: `) +
792
+ chalk.bold(`npm install -g ${CONFIG.PACKAGE_NAME}@latest`) +
793
+ " ".repeat(boxWidth - runCommandText.length) +
794
+ chalk.yellow("║")
795
+ );
796
+ console.log(chalk.yellow(`╚${border}╝\n`));
797
+ }
798
+
799
+ static displayStartupBanner(port) {
800
+ console.log(`🚀 Starting Tunnel for port ${port}...`);
801
+ }
802
+
803
+ static displayTunnelSuccess(url, timeout) {
804
+ console.log(chalk.bold(`🌍 Public URL: ${url}`));
805
+ if (timeout === 0) {
806
+ console.log(chalk.gray(` No auto-cleanup (unlimited duration)`));
807
+ } else {
808
+ console.log(
809
+ chalk.gray(` Auto-cleanup in ${timeout} hour${timeout !== 1 ? 's' : ''}`)
810
+ );
811
+ }
812
+ console.log(""); // Empty line before progress
813
+ // Start the progress tracker instead of static message
814
+ progressTracker.start();
815
+ }
816
+
817
+ static displayTimeoutWarning(timeout) {
818
+ console.log(
819
+ chalk.yellow(
820
+ `\n⏰ Tunnel has been running for ${timeout} hour${timeout !== 1 ? 's' : ''}.`
821
+ )
822
+ );
823
+ console.log(chalk.yellow(" Automatically shutting down..."));
824
+ }
825
+
826
+ static displayError(error, spinner = null) {
827
+ if (spinner) {
828
+ spinner.fail("Failed to connect to server.");
829
+ }
830
+ console.error(chalk.red(error.message));
831
+ }
832
+
833
+ static displayCleanupStart() {
834
+ console.log(
835
+ chalk.yellow("\n\n🛑 Shutting down... Cleaning up resources...")
836
+ );
837
+ }
838
+
839
+ static displayCleanupSuccess() {
840
+ console.log(chalk.green("✔ Cleanup successful. Subdomain released."));
841
+ }
842
+
843
+ static displayCleanupError() {
844
+ console.error(
845
+ chalk.red("✖ Cleanup failed (Server might be down or busy).")
846
+ );
847
+ }
848
+ }
849
+
850
+ // ============================================================================
851
+ // Tunnel Orchestrator
852
+ // ============================================================================
853
+
854
+ class TunnelOrchestrator {
855
+ static async start(config) {
856
+ state.setTunnel(null, config.subdomain, config.port);
857
+
858
+ // Initialize analytics
859
+ await analytics.initialize();
860
+
861
+ // Track CLI start
862
+ analytics.trackCliStart(config.port, config.subdomain, CONFIG.CURRENT_VERSION);
863
+
864
+ // Display UI
865
+ UI.displayStartupBanner(config.port);
866
+
867
+ // Check for updates
868
+ const updateInfo = await VersionManager.checkForUpdates();
869
+ UI.displayUpdateNotification(updateInfo);
870
+
871
+ // Validate binary
872
+ if (!BinaryManager.validate(PATHS.BIN_PATH)) {
873
+ analytics.trackTunnelError("binary_missing", "Cloudflared binary not found");
874
+ // Give analytics a moment to send before exiting
875
+ await new Promise(resolve => setTimeout(resolve, 100));
876
+ process.exit(1);
877
+ }
878
+
879
+ const spinner = ora("Requesting access...").start();
880
+
881
+ try {
882
+ // Create tunnel
883
+ const tunnel = await APIClient.createTunnel(config.subdomain);
884
+ state.setTunnel(tunnel.tunnelId, config.subdomain, config.port);
885
+
886
+ // Track successful tunnel creation
887
+ analytics.trackTunnelCreated(config.subdomain, config.port);
888
+
889
+ spinner.succeed(chalk.green("Tunnel created!"));
890
+ UI.displayTunnelSuccess(tunnel.url, config.timeout);
891
+
892
+ // Spawn cloudflared
893
+ const process = BinaryManager.spawn(
894
+ PATHS.BIN_PATH,
895
+ tunnel.tunnelToken,
896
+ config.port
897
+ );
898
+ state.setProcess(process);
899
+ BinaryManager.attachHandlers(process, spinner);
900
+
901
+ // Set timeout only if not unlimited (timeout > 0)
902
+ const timeoutMs = computeTimeoutMs(config.timeout);
903
+ if (timeoutMs !== null) {
904
+ const timeoutId = setTimeout(() => {
905
+ UI.displayTimeoutWarning(config.timeout);
906
+ this.cleanup("timeout");
907
+ }, timeoutMs);
908
+ state.setTimeout(timeoutId);
909
+ }
910
+ } catch (error) {
911
+ // Track tunnel creation error
912
+ const errorType = error.message.includes("already taken")
913
+ ? "subdomain_taken"
914
+ : "tunnel_creation_failed";
915
+ analytics.trackTunnelError(errorType, error.message);
916
+
917
+ UI.displayError(error, spinner);
918
+ // Give analytics a moment to send before exiting
919
+ await new Promise(resolve => setTimeout(resolve, 100));
920
+ process.exit(1);
921
+ }
922
+ }
923
+
924
+ static async cleanup(reason = "manual") {
925
+ state.clearTimeout();
926
+
927
+ if (!state.hasTunnel()) {
928
+ process.exit(0);
929
+ }
930
+
931
+ UI.displayCleanupStart();
932
+
933
+ // Track tunnel shutdown with duration
934
+ const duration = state.getDurationSeconds();
935
+ analytics.trackTunnelShutdown(reason, duration);
936
+
937
+ try {
938
+ // Kill process
939
+ if (state.hasProcess()) {
940
+ state.tunnelProcess.kill();
941
+ }
942
+
943
+ // Delete tunnel
944
+ await APIClient.deleteTunnel(state.subdomain, state.tunnelId);
945
+ UI.displayCleanupSuccess();
946
+ } catch (err) {
947
+ UI.displayCleanupError();
948
+ }
949
+
950
+ // Give analytics a moment to send (non-blocking)
951
+ await new Promise(resolve => setTimeout(resolve, 100));
952
+
953
+ process.exit(0);
954
+ }
955
+ }
956
+
957
+ // ============================================================================
958
+ // Application Entry Point
959
+ // ============================================================================
960
+
961
+ async function main() {
962
+ const args = process.argv.slice(2);
963
+ const command = args[0];
964
+
965
+ // Handle special commands
966
+ if (command === "auth") {
967
+ await TokenManager.handleAuth();
968
+ process.exit(0);
969
+ }
970
+
971
+ if (command === "logout") {
972
+ TokenManager.handleLogout();
973
+ process.exit(0);
974
+ }
975
+
976
+ // Normal tunnel flow
977
+ try {
978
+ const config = ArgumentParser.parse(args);
979
+ await TunnelOrchestrator.start(config);
980
+ } catch (error) {
981
+ console.error(chalk.red(`Fatal Error: ${error.message}`));
982
+ process.exit(1);
983
+ }
984
+ }
985
+
986
+ // Register cleanup handlers
987
+ process.on("SIGINT", () => TunnelOrchestrator.cleanup());
988
+ process.on("SIGTERM", () => TunnelOrchestrator.cleanup());
989
+
990
+ // Start application
991
+ main();