@ns_nitin/devtunnel-cli 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/package.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "@ns_nitin/devtunnel-cli",
3
+ "version": "1.0.1",
4
+ "description": "DevTunnel+ CLI - Connect your local server to the internet",
5
+ "main": "src/index.js",
6
+ "bin": {
7
+ "devtunnel": "./src/index.js"
8
+ },
9
+ "scripts": {
10
+ "start": "node src/index.js",
11
+ "dev": "node --watch src/index.js",
12
+ "test": "jest --passWithNoTests"
13
+ },
14
+ "dependencies": {
15
+ "@devtunnel/shared": "*",
16
+ "commander": "^11.1.0",
17
+ "ws": "^8.16.0",
18
+ "chalk": "^4.1.2",
19
+ "ora": "^5.4.1",
20
+ "boxen": "^5.1.2",
21
+ "axios": "^1.6.5"
22
+ },
23
+ "devDependencies": {
24
+ "jest": "^29.7.0"
25
+ }
26
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Status Command - Check gateway server status
3
+ */
4
+
5
+ const axios = require('axios');
6
+ const chalk = require('chalk');
7
+ const ora = require('ora');
8
+
9
+ async function execute(options) {
10
+ const spinner = ora('Checking gateway status...').start();
11
+ const url = `http://${options.host}:${options.port}/health`;
12
+
13
+ try {
14
+ const response = await axios.get(url, { timeout: 5000 });
15
+ const data = response.data;
16
+
17
+ spinner.succeed('Gateway is online');
18
+ console.log('');
19
+ console.log(chalk.cyan(' Status: ') + chalk.green(data.status));
20
+ console.log(chalk.cyan(' Tunnels: ') + chalk.white(data.tunnels));
21
+ console.log(chalk.cyan(' Uptime: ') + chalk.white(`${Math.floor(data.uptime)}s`));
22
+ console.log('');
23
+ } catch (error) {
24
+ spinner.fail('Gateway is offline or unreachable');
25
+ console.log(chalk.red(` Error: ${error.message}`));
26
+ process.exit(1);
27
+ }
28
+ }
29
+
30
+ module.exports = { execute };
@@ -0,0 +1,253 @@
1
+ /**
2
+ * Tunnel Command Module
3
+ *
4
+ * This file re-exports the TunnelClient for programmatic use
5
+ * while the main CLI is in index.js
6
+ */
7
+
8
+ const WebSocket = require('ws');
9
+ const chalk = require('chalk');
10
+ const ora = require('ora');
11
+ const boxen = require('boxen');
12
+ const http = require('http');
13
+ const {
14
+ createTunnelRegisterMessage,
15
+ createHttpResponseMessage,
16
+ createHttpErrorMessage,
17
+ parseMessage,
18
+ serializeMessage,
19
+ decodeBody,
20
+ MessageType,
21
+ } = require('@devtunnel/shared');
22
+
23
+ /**
24
+ * TunnelClient - Programmatic API for creating tunnels
25
+ */
26
+ class TunnelClient {
27
+ constructor(localPort, options = {}) {
28
+ this.localPort = parseInt(localPort, 10);
29
+ this.localHost = options.localHost || 'localhost';
30
+ this.gatewayHost = options.host || 'localhost';
31
+ this.gatewayPort = options.gatewayPort || 3001;
32
+ this.subdomain = options.subdomain || null;
33
+
34
+ this.ws = null;
35
+ this.tunnelId = null;
36
+ this.publicUrl = null;
37
+ this.isConnected = false;
38
+ this.shouldReconnect = true;
39
+ this.reconnectAttempts = 0;
40
+ this.maxReconnectAttempts = 10;
41
+
42
+ this.requestCount = 0;
43
+ this.successCount = 0;
44
+ this.errorCount = 0;
45
+ }
46
+
47
+ /**
48
+ * Connects to gateway and starts tunnel
49
+ */
50
+ async connect() {
51
+ const spinner = ora('Connecting to gateway...').start();
52
+
53
+ return new Promise((resolve, reject) => {
54
+ const wsUrl = `ws://${this.gatewayHost}:${this.gatewayPort}`;
55
+ this.ws = new WebSocket(wsUrl);
56
+
57
+ this.ws.on('open', () => {
58
+ spinner.text = 'Registering tunnel...';
59
+ this.ws.send(serializeMessage(createTunnelRegisterMessage({
60
+ subdomain: this.subdomain,
61
+ localPort: this.localPort,
62
+ })));
63
+ });
64
+
65
+ this.ws.on('message', (data) => {
66
+ const message = parseMessage(data);
67
+ if (!message) return;
68
+
69
+ if (message.type === MessageType.TUNNEL_REGISTERED) {
70
+ this.tunnelId = message.payload.tunnelId;
71
+ this.publicUrl = message.payload.publicUrl;
72
+ this.subdomain = message.payload.subdomain;
73
+ this.isConnected = true;
74
+ spinner.succeed('Tunnel established!');
75
+ this.printBanner();
76
+ resolve();
77
+ } else if (message.type === MessageType.HTTP_REQUEST) {
78
+ this.handleHttpRequest(message.payload);
79
+ } else if (message.type === MessageType.ERROR) {
80
+ spinner.fail(`Error: ${message.payload.error}`);
81
+ reject(new Error(message.payload.error));
82
+ } else if (message.type === MessageType.PING) {
83
+ this.ws.send(serializeMessage({ type: MessageType.PONG, payload: { timestamp: Date.now() } }));
84
+ }
85
+ });
86
+
87
+ this.ws.on('error', (error) => {
88
+ if (spinner.isSpinning) spinner.fail(`Connection failed: ${error.message}`);
89
+ reject(error);
90
+ });
91
+
92
+ this.ws.on('close', () => {
93
+ this.isConnected = false;
94
+ });
95
+
96
+ setTimeout(() => {
97
+ if (!this.isConnected) {
98
+ spinner.fail('Connection timeout');
99
+ this.ws.close();
100
+ reject(new Error('Connection timeout'));
101
+ }
102
+ }, 10000);
103
+ });
104
+ }
105
+
106
+ printBanner() {
107
+ const info = [
108
+ '',
109
+ chalk.bold.green(' DevTunnel+ is running!'),
110
+ '',
111
+ ` ${chalk.cyan('Public URL:')} ${chalk.bold(this.publicUrl)}`,
112
+ ` ${chalk.cyan('Subdomain:')} ${this.subdomain}`,
113
+ ` ${chalk.cyan('Forwarding to:')} http://${this.localHost}:${this.localPort}`,
114
+ ` ${chalk.cyan('Tunnel ID:')} ${this.tunnelId}`,
115
+ '',
116
+ chalk.gray(' Press Ctrl+C to stop'),
117
+ '',
118
+ ].join('\n');
119
+
120
+ console.log(boxen(info, {
121
+ padding: 1,
122
+ margin: 1,
123
+ borderStyle: 'round',
124
+ borderColor: 'cyan',
125
+ }));
126
+
127
+ console.log(chalk.gray(' Waiting for requests...\n'));
128
+ }
129
+
130
+ async handleHttpRequest(payload) {
131
+ const { requestId, method, path, headers, body, bodyEncoding } = payload;
132
+ const startTime = Date.now();
133
+ this.requestCount++;
134
+
135
+ console.log(
136
+ chalk.gray(`[${new Date().toLocaleTimeString()}]`) +
137
+ ` ${this.colorMethod(method)} ${path}`
138
+ );
139
+
140
+ try {
141
+ const requestBody = body ? decodeBody(body, bodyEncoding || 'base64') : null;
142
+ const response = await this.forwardToLocal({ method, path, headers, body: requestBody });
143
+ const duration = Date.now() - startTime;
144
+ this.successCount++;
145
+
146
+ this.ws.send(serializeMessage(createHttpResponseMessage({
147
+ requestId,
148
+ statusCode: response.statusCode,
149
+ headers: response.headers,
150
+ body: response.body,
151
+ })));
152
+
153
+ console.log(
154
+ chalk.gray(`[${new Date().toLocaleTimeString()}]`) +
155
+ ` ${this.colorStatus(response.statusCode)} ${chalk.gray(`${duration}ms`)}`
156
+ );
157
+ } catch (error) {
158
+ const duration = Date.now() - startTime;
159
+ this.errorCount++;
160
+
161
+ this.ws.send(serializeMessage(createHttpErrorMessage({
162
+ requestId,
163
+ error: error.message,
164
+ code: error.code === 'ECONNREFUSED' ? 'CONNECTION_REFUSED' : 'LOCAL_SERVER_ERROR',
165
+ statusCode: error.code === 'ECONNREFUSED' ? 503 : 502,
166
+ })));
167
+
168
+ console.log(
169
+ chalk.gray(`[${new Date().toLocaleTimeString()}]`) +
170
+ ` ${chalk.red('ERR')} ${error.message} ${chalk.gray(`${duration}ms`)}`
171
+ );
172
+ }
173
+ }
174
+
175
+ forwardToLocal({ method, path, headers, body }) {
176
+ return new Promise((resolve, reject) => {
177
+ const url = new URL(path, `http://${this.localHost}:${this.localPort}`);
178
+ const localHeaders = { ...headers, host: `${this.localHost}:${this.localPort}` };
179
+ delete localHeaders['connection'];
180
+ if (body) localHeaders['content-length'] = String(body.length);
181
+
182
+ const req = http.request({
183
+ hostname: this.localHost,
184
+ port: this.localPort,
185
+ path: url.pathname + url.search,
186
+ method,
187
+ headers: localHeaders,
188
+ }, (res) => {
189
+ const chunks = [];
190
+ res.on('data', (chunk) => chunks.push(chunk));
191
+ res.on('end', () => {
192
+ resolve({
193
+ statusCode: res.statusCode,
194
+ headers: res.headers,
195
+ body: Buffer.concat(chunks).toString('base64'),
196
+ });
197
+ });
198
+ });
199
+
200
+ req.on('error', reject);
201
+ req.setTimeout(30000, () => {
202
+ req.destroy();
203
+ reject(Object.assign(new Error('Timeout'), { code: 'ETIMEDOUT' }));
204
+ });
205
+ if (body) req.write(body);
206
+ req.end();
207
+ });
208
+ }
209
+
210
+ colorMethod(method) {
211
+ const colors = { GET: chalk.green, POST: chalk.blue, PUT: chalk.yellow, DELETE: chalk.red };
212
+ return (colors[method] || chalk.white)(method.padEnd(7));
213
+ }
214
+
215
+ colorStatus(status) {
216
+ if (status >= 500) return chalk.red(status);
217
+ if (status >= 400) return chalk.yellow(status);
218
+ if (status >= 200) return chalk.green(status);
219
+ return chalk.white(status);
220
+ }
221
+
222
+ close() {
223
+ this.shouldReconnect = false;
224
+ if (this.ws) this.ws.close();
225
+ }
226
+
227
+ getStats() {
228
+ return {
229
+ tunnelId: this.tunnelId,
230
+ publicUrl: this.publicUrl,
231
+ requestCount: this.requestCount,
232
+ successCount: this.successCount,
233
+ errorCount: this.errorCount,
234
+ };
235
+ }
236
+ }
237
+
238
+ /**
239
+ * Execute tunnel (for backward compatibility with http command)
240
+ */
241
+ async function execute(port, options = {}) {
242
+ const client = new TunnelClient(port, options);
243
+
244
+ process.on('SIGINT', () => {
245
+ console.log(chalk.yellow('\nShutting down tunnel...'));
246
+ client.close();
247
+ process.exit(0);
248
+ });
249
+
250
+ await client.connect();
251
+ }
252
+
253
+ module.exports = { execute, TunnelClient };
package/src/index.js ADDED
@@ -0,0 +1,425 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * DevTunnel+ CLI
5
+ *
6
+ * Usage: devtunnel start <port> [options]
7
+ *
8
+ * Creates a tunnel from public URL to localhost:<port>
9
+ */
10
+
11
+ const { program } = require('commander');
12
+ const WebSocket = require('ws');
13
+ const chalk = require('chalk');
14
+ const ora = require('ora');
15
+ const boxen = require('boxen');
16
+ const http = require('http');
17
+ const https = require('https');
18
+ const {
19
+ createTunnelRegisterMessage,
20
+ createHttpResponseMessage,
21
+ createHttpErrorMessage,
22
+ parseMessage,
23
+ serializeMessage,
24
+ decodeBody,
25
+ MessageType,
26
+ } = require('@devtunnel/shared');
27
+
28
+ // Package info
29
+ const pkg = require('../package.json');
30
+
31
+ /**
32
+ * TunnelClient - Real tunneling client with reconnect support
33
+ */
34
+ class TunnelClient {
35
+ constructor(port, options = {}) {
36
+ this.localPort = parseInt(port, 10);
37
+ this.localHost = options.localHost || 'localhost';
38
+ this.gatewayHost = options.host || 'localhost';
39
+ this.gatewayPort = options.gatewayPort || 3001;
40
+ this.subdomain = options.subdomain || null;
41
+
42
+ this.ws = null;
43
+ this.tunnelId = null;
44
+ this.publicUrl = null;
45
+ this.isConnected = false;
46
+ this.shouldReconnect = true;
47
+ this.reconnectAttempts = 0;
48
+ this.maxReconnectAttempts = 10;
49
+ this.reconnectDelay = 1000;
50
+
51
+ // Stats
52
+ this.requestCount = 0;
53
+ this.successCount = 0;
54
+ this.errorCount = 0;
55
+ this.startTime = Date.now();
56
+ }
57
+
58
+ /**
59
+ * Starts the tunnel with auto-reconnect
60
+ */
61
+ async start() {
62
+ console.log(chalk.cyan('\nšŸš€ DevTunnel+ Starting...\n'));
63
+
64
+ while (this.shouldReconnect) {
65
+ try {
66
+ await this.connect();
67
+ // Connection closed, will retry if shouldReconnect is true
68
+ } catch (error) {
69
+ if (!this.shouldReconnect) break;
70
+
71
+ this.reconnectAttempts++;
72
+ if (this.reconnectAttempts >= this.maxReconnectAttempts) {
73
+ console.error(chalk.red(`\nāŒ Failed after ${this.maxReconnectAttempts} attempts. Giving up.`));
74
+ break;
75
+ }
76
+
77
+ const delay = Math.min(this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1), 30000);
78
+ console.log(chalk.yellow(`\nāš ļø Reconnecting in ${delay / 1000}s (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})...`));
79
+ await this.sleep(delay);
80
+ }
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Connects to gateway and registers tunnel
86
+ */
87
+ connect() {
88
+ return new Promise((resolve, reject) => {
89
+ const spinner = ora('Connecting to gateway...').start();
90
+ const wsUrl = `ws://${this.gatewayHost}:${this.gatewayPort}`;
91
+
92
+ this.ws = new WebSocket(wsUrl);
93
+
94
+ this.ws.on('open', () => {
95
+ spinner.text = 'Registering tunnel...';
96
+ this.ws.send(serializeMessage(createTunnelRegisterMessage({
97
+ subdomain: this.subdomain,
98
+ localPort: this.localPort,
99
+ })));
100
+ });
101
+
102
+ this.ws.on('message', (data) => {
103
+ const message = parseMessage(data);
104
+ if (!message) return;
105
+
106
+ if (message.type === MessageType.TUNNEL_REGISTERED) {
107
+ spinner.succeed('Tunnel established!');
108
+ this.handleRegistered(message.payload);
109
+ this.reconnectAttempts = 0; // Reset on successful connection
110
+ } else if (message.type === MessageType.HTTP_REQUEST) {
111
+ this.handleHttpRequest(message.payload);
112
+ } else if (message.type === MessageType.ERROR) {
113
+ spinner.fail(`Error: ${message.payload.error}`);
114
+ reject(new Error(message.payload.error));
115
+ } else if (message.type === MessageType.PING) {
116
+ this.ws.send(serializeMessage({ type: MessageType.PONG, payload: { timestamp: Date.now() } }));
117
+ }
118
+ });
119
+
120
+ this.ws.on('error', (error) => {
121
+ if (spinner.isSpinning) {
122
+ spinner.fail(`Connection failed: ${error.message}`);
123
+ }
124
+ this.isConnected = false;
125
+ });
126
+
127
+ this.ws.on('close', (code) => {
128
+ this.isConnected = false;
129
+ if (this.tunnelId && this.shouldReconnect) {
130
+ console.log(chalk.yellow(`\nāš ļø Connection lost (code: ${code})`));
131
+ }
132
+ resolve(); // Resolve to allow reconnect loop
133
+ });
134
+
135
+ // Connection timeout
136
+ setTimeout(() => {
137
+ if (!this.isConnected && spinner.isSpinning) {
138
+ spinner.fail('Connection timeout');
139
+ this.ws.close();
140
+ reject(new Error('Connection timeout'));
141
+ }
142
+ }, 10000);
143
+ });
144
+ }
145
+
146
+ /**
147
+ * Handles successful tunnel registration
148
+ */
149
+ handleRegistered(payload) {
150
+ this.tunnelId = payload.tunnelId;
151
+ this.publicUrl = payload.publicUrl;
152
+ this.subdomain = payload.subdomain;
153
+ this.isConnected = true;
154
+
155
+ this.printBanner();
156
+ }
157
+
158
+ /**
159
+ * Prints the tunnel info banner
160
+ */
161
+ printBanner() {
162
+ const info = [
163
+ '',
164
+ chalk.bold.green(' āœ“ Tunnel is live!'),
165
+ '',
166
+ ` ${chalk.gray('Public URL →')} ${chalk.bold.cyan(this.publicUrl)}`,
167
+ ` ${chalk.gray('Forwarding →')} ${chalk.white(`http://${this.localHost}:${this.localPort}`)}`,
168
+ '',
169
+ ` ${chalk.gray('Tunnel ID:')} ${chalk.dim(this.tunnelId)}`,
170
+ '',
171
+ ].join('\n');
172
+
173
+ console.log(boxen(info, {
174
+ padding: 1,
175
+ margin: { top: 1, bottom: 1, left: 2, right: 2 },
176
+ borderStyle: 'round',
177
+ borderColor: 'green',
178
+ }));
179
+
180
+ console.log(chalk.gray(' Waiting for requests...\n'));
181
+ }
182
+
183
+ /**
184
+ * Handles incoming HTTP request from gateway
185
+ */
186
+ async handleHttpRequest(payload) {
187
+ const { requestId, method, path, headers, body, bodyEncoding } = payload;
188
+ const startTime = Date.now();
189
+ this.requestCount++;
190
+
191
+ // Log incoming request
192
+ process.stdout.write(
193
+ chalk.gray(` ${this.formatTime()} `) +
194
+ this.colorMethod(method) +
195
+ chalk.white(` ${this.truncate(path, 50)}`)
196
+ );
197
+
198
+ try {
199
+ // Decode request body
200
+ const requestBody = body ? decodeBody(body, bodyEncoding || 'base64') : null;
201
+
202
+ // Forward to local server
203
+ const response = await this.forwardToLocal({ method, path, headers, body: requestBody });
204
+ const duration = Date.now() - startTime;
205
+ this.successCount++;
206
+
207
+ // Send response back through WebSocket
208
+ this.ws.send(serializeMessage(createHttpResponseMessage({
209
+ requestId,
210
+ statusCode: response.statusCode,
211
+ headers: response.headers,
212
+ body: response.body,
213
+ })));
214
+
215
+ // Log response
216
+ console.log(` → ${this.colorStatus(response.statusCode)} ${chalk.gray(`${duration}ms`)}`);
217
+
218
+ } catch (error) {
219
+ const duration = Date.now() - startTime;
220
+ this.errorCount++;
221
+
222
+ // Determine error code
223
+ let statusCode = 502;
224
+ let errorCode = 'LOCAL_SERVER_ERROR';
225
+
226
+ if (error.code === 'ECONNREFUSED') {
227
+ statusCode = 503;
228
+ errorCode = 'CONNECTION_REFUSED';
229
+ } else if (error.code === 'ETIMEDOUT') {
230
+ statusCode = 504;
231
+ errorCode = 'TIMEOUT';
232
+ }
233
+
234
+ // Send error response
235
+ this.ws.send(serializeMessage(createHttpErrorMessage({
236
+ requestId,
237
+ error: error.message,
238
+ code: errorCode,
239
+ statusCode,
240
+ })));
241
+
242
+ // Log error
243
+ console.log(` → ${chalk.red('ERR')} ${chalk.gray(error.code || error.message)} ${chalk.gray(`${duration}ms`)}`);
244
+ }
245
+ }
246
+
247
+ /**
248
+ * Makes real HTTP request to local server
249
+ */
250
+ forwardToLocal({ method, path, headers, body }) {
251
+ return new Promise((resolve, reject) => {
252
+ const url = new URL(path, `http://${this.localHost}:${this.localPort}`);
253
+
254
+ // Prepare headers
255
+ const localHeaders = { ...headers };
256
+ localHeaders['host'] = `${this.localHost}:${this.localPort}`;
257
+ delete localHeaders['connection'];
258
+
259
+ if (body) {
260
+ localHeaders['content-length'] = String(body.length);
261
+ }
262
+
263
+ const options = {
264
+ hostname: this.localHost,
265
+ port: this.localPort,
266
+ path: url.pathname + url.search,
267
+ method,
268
+ headers: localHeaders,
269
+ };
270
+
271
+ const req = http.request(options, (res) => {
272
+ const chunks = [];
273
+ res.on('data', (chunk) => chunks.push(chunk));
274
+ res.on('end', () => {
275
+ const responseBody = Buffer.concat(chunks);
276
+ resolve({
277
+ statusCode: res.statusCode,
278
+ headers: res.headers,
279
+ body: responseBody.toString('base64'),
280
+ });
281
+ });
282
+ res.on('error', reject);
283
+ });
284
+
285
+ req.on('error', reject);
286
+ req.setTimeout(30000, () => {
287
+ req.destroy();
288
+ const err = new Error('Request timeout');
289
+ err.code = 'ETIMEDOUT';
290
+ reject(err);
291
+ });
292
+
293
+ if (body) req.write(body);
294
+ req.end();
295
+ });
296
+ }
297
+
298
+ // Utility methods
299
+ formatTime() {
300
+ return new Date().toLocaleTimeString('en-US', { hour12: false });
301
+ }
302
+
303
+ truncate(str, len) {
304
+ return str.length > len ? str.substring(0, len - 3) + '...' : str;
305
+ }
306
+
307
+ colorMethod(method) {
308
+ const colors = {
309
+ GET: chalk.green,
310
+ POST: chalk.blue,
311
+ PUT: chalk.yellow,
312
+ PATCH: chalk.yellow,
313
+ DELETE: chalk.red,
314
+ HEAD: chalk.cyan,
315
+ OPTIONS: chalk.magenta,
316
+ };
317
+ return (colors[method] || chalk.white)(method.padEnd(7));
318
+ }
319
+
320
+ colorStatus(status) {
321
+ if (status >= 500) return chalk.red(status);
322
+ if (status >= 400) return chalk.yellow(status);
323
+ if (status >= 300) return chalk.cyan(status);
324
+ if (status >= 200) return chalk.green(status);
325
+ return chalk.white(status);
326
+ }
327
+
328
+ sleep(ms) {
329
+ return new Promise(resolve => setTimeout(resolve, ms));
330
+ }
331
+
332
+ /**
333
+ * Stops the tunnel
334
+ */
335
+ stop() {
336
+ this.shouldReconnect = false;
337
+ if (this.ws) {
338
+ this.ws.close();
339
+ }
340
+ this.printStats();
341
+ }
342
+
343
+ /**
344
+ * Prints session statistics
345
+ */
346
+ printStats() {
347
+ const uptime = Math.floor((Date.now() - this.startTime) / 1000);
348
+ console.log(chalk.cyan('\nšŸ“Š Session Stats:'));
349
+ console.log(chalk.gray(` Uptime: ${Math.floor(uptime / 60)}m ${uptime % 60}s`));
350
+ console.log(chalk.gray(` Requests: ${this.requestCount} (${this.successCount} ok, ${this.errorCount} failed)`));
351
+ console.log('');
352
+ }
353
+ }
354
+
355
+ // CLI Setup
356
+ program
357
+ .name('devtunnel')
358
+ .description('DevTunnel+ - Expose your localhost to the world')
359
+ .version(pkg.version);
360
+
361
+ program
362
+ .command('start <port>')
363
+ .description('Start a tunnel to localhost:<port>')
364
+ .option('-s, --subdomain <name>', 'Request a specific subdomain')
365
+ .option('-h, --host <host>', 'Gateway host', 'localhost')
366
+ .option('-p, --gateway-port <port>', 'Gateway WebSocket port', '3001')
367
+ .option('-l, --local-host <host>', 'Local host to forward to', 'localhost')
368
+ .action(async (port, options) => {
369
+ // Validate port
370
+ const portNum = parseInt(port, 10);
371
+ if (isNaN(portNum) || portNum < 1 || portNum > 65535) {
372
+ console.error(chalk.red(`Error: Invalid port "${port}". Must be 1-65535.`));
373
+ process.exit(1);
374
+ }
375
+
376
+ const client = new TunnelClient(portNum, {
377
+ subdomain: options.subdomain,
378
+ host: options.host,
379
+ gatewayPort: parseInt(options.gatewayPort, 10),
380
+ localHost: options.localHost,
381
+ });
382
+
383
+ // Graceful shutdown
384
+ const shutdown = () => {
385
+ console.log(chalk.yellow('\n\nšŸ‘‹ Shutting down tunnel...'));
386
+ client.stop();
387
+ process.exit(0);
388
+ };
389
+
390
+ process.on('SIGINT', shutdown);
391
+ process.on('SIGTERM', shutdown);
392
+
393
+ try {
394
+ await client.start();
395
+ } catch (error) {
396
+ console.error(chalk.red(`\nāŒ Fatal error: ${error.message}`));
397
+ process.exit(1);
398
+ }
399
+ });
400
+
401
+ program
402
+ .command('status')
403
+ .description('Check gateway status')
404
+ .option('-h, --host <host>', 'Gateway host', 'localhost')
405
+ .option('-p, --port <port>', 'Gateway HTTP port', '3000')
406
+ .action(async (options) => {
407
+ try {
408
+ const res = await fetch(`http://${options.host}:${options.port}/health`);
409
+ const data = await res.json();
410
+ console.log(chalk.green('āœ“ Gateway is running'));
411
+ console.log(chalk.gray(` Tunnels: ${data.tunnels}`));
412
+ console.log(chalk.gray(` Uptime: ${Math.floor(data.uptime)}s`));
413
+ } catch (error) {
414
+ console.error(chalk.red('āœ— Gateway is not reachable'));
415
+ console.error(chalk.gray(` ${error.message}`));
416
+ }
417
+ });
418
+
419
+ // Parse arguments
420
+ program.parse();
421
+
422
+ // If no command provided, show help
423
+ if (!process.argv.slice(2).length) {
424
+ program.outputHelp();
425
+ }