ghcc-client 1.0.0

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.
@@ -0,0 +1,707 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.SessionManager = void 0;
7
+ const child_process_1 = require("child_process");
8
+ const util_1 = require("util");
9
+ const path_1 = __importDefault(require("path"));
10
+ const os_1 = __importDefault(require("os"));
11
+ const fs_1 = __importDefault(require("fs"));
12
+ const crypto_1 = __importDefault(require("crypto"));
13
+ const chalk_1 = __importDefault(require("chalk"));
14
+ const ora_1 = __importDefault(require("ora"));
15
+ const localtunnel_1 = __importDefault(require("localtunnel"));
16
+ const qrcode_terminal_1 = __importDefault(require("qrcode-terminal"));
17
+ const execAsync = (0, util_1.promisify)(child_process_1.exec);
18
+ const execFileAsync = (0, util_1.promisify)(child_process_1.execFile);
19
+ class SessionManager {
20
+ constructor() {
21
+ const platform = os_1.default.platform();
22
+ const arch = os_1.default.arch();
23
+ let binaryName;
24
+ if (platform === 'linux') {
25
+ binaryName = arch === 'arm64' ? 'ttyd-linux-arm64' : 'ttyd-linux-x64';
26
+ }
27
+ else if (platform === 'darwin') {
28
+ binaryName = arch === 'arm64' ? 'ttyd-darwin-arm64' : 'ttyd-darwin-x64';
29
+ }
30
+ else {
31
+ throw new Error(`Unsupported platform: ${platform}`);
32
+ }
33
+ this.ttydPath = path_1.default.join(__dirname, '..', 'binaries', binaryName);
34
+ if (!fs_1.default.existsSync(this.ttydPath)) {
35
+ console.error(chalk_1.default.red(`āœ— ttyd binary not found: ${this.ttydPath}`));
36
+ console.log(chalk_1.default.yellow('This should not happen. Please reinstall: npm install -g ghcc-client'));
37
+ process.exit(1);
38
+ }
39
+ }
40
+ // Security helper: Validate session name format
41
+ validateSessionName(session) {
42
+ // Only allow: letters, numbers, hyphens, and underscores
43
+ // Must start with letter or number
44
+ // Length: 3-64 characters
45
+ const sessionRegex = /^[a-zA-Z0-9][a-zA-Z0-9_-]{2,63}$/;
46
+ return sessionRegex.test(session);
47
+ }
48
+ // Security helper: Validate port number
49
+ validatePort(port) {
50
+ // Only allow non-privileged ports (1024-65535)
51
+ return Number.isInteger(port) && port >= 1024 && port <= 65535;
52
+ }
53
+ // Security helper: Generate secure random password
54
+ generateSecurePassword(length = 32) {
55
+ return crypto_1.default.randomBytes(length).toString('base64').slice(0, length);
56
+ }
57
+ // Security helper: Create secure temp file with restricted permissions
58
+ createSecureTempFile(prefix, extension = '.tmp') {
59
+ // Create unique temp directory with 0700 permissions
60
+ const tmpDir = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), `${prefix}-`));
61
+ fs_1.default.chmodSync(tmpDir, 0o700); // Only owner can read/write/execute
62
+ // Create file path
63
+ const filePath = path_1.default.join(tmpDir, `file${extension}`);
64
+ return filePath;
65
+ }
66
+ async sessionExists(sessionName) {
67
+ try {
68
+ await execAsync(`tmux has-session -t ${sessionName} 2>/dev/null`);
69
+ return true;
70
+ }
71
+ catch {
72
+ return false;
73
+ }
74
+ }
75
+ // Find ttyd PID for a session by searching running processes
76
+ async findTtydPid(sessionName) {
77
+ try {
78
+ const { stdout } = await execAsync(`pgrep -f "ttyd.*attach -t ${sessionName}$" 2>/dev/null || true`);
79
+ const pid = stdout.trim();
80
+ return pid || null;
81
+ }
82
+ catch {
83
+ return null;
84
+ }
85
+ }
86
+ // Batch method: Get all ttyd processes at once (performance optimization)
87
+ // Get port number from ttyd process
88
+ async getTtydPort(pid) {
89
+ try {
90
+ const { stdout } = await execAsync(`ps -p ${pid} -o args= 2>/dev/null`);
91
+ const match = stdout.match(/-p (\d+)/);
92
+ return match ? match[1] : null;
93
+ }
94
+ catch {
95
+ return null;
96
+ }
97
+ }
98
+ // Find tunnel process PID for a session
99
+ async findTunnelPid(sessionName) {
100
+ try {
101
+ // Look for localtunnel process with session name in args
102
+ const { stdout } = await execAsync(`pgrep -f "node.*localtunnel.*${sessionName}" 2>/dev/null || true`);
103
+ const pid = stdout.trim();
104
+ return pid || null;
105
+ }
106
+ catch {
107
+ return null;
108
+ }
109
+ }
110
+ // Extract public URL from tunnel process
111
+ async getTunnelUrl(sessionName) {
112
+ try {
113
+ // Check if tunnel PID file exists (we'll store URL there)
114
+ const urlFile = `/tmp/ghcc-${sessionName}-tunnel-url`;
115
+ if (fs_1.default.existsSync(urlFile)) {
116
+ const url = fs_1.default.readFileSync(urlFile, 'utf-8').trim();
117
+ return url || null;
118
+ }
119
+ return null;
120
+ }
121
+ catch {
122
+ return null;
123
+ }
124
+ }
125
+ async isPortInUse(port) {
126
+ try {
127
+ await execAsync(`lsof -i :${port} 2>/dev/null`);
128
+ return true;
129
+ }
130
+ catch {
131
+ return false;
132
+ }
133
+ }
134
+ async findAvailablePort(startPort = 7681) {
135
+ let port = startPort;
136
+ while (await this.isPortInUse(port)) {
137
+ port++;
138
+ if (port > 65535) {
139
+ throw new Error('No available ports found');
140
+ }
141
+ }
142
+ return port;
143
+ }
144
+ async cleanupOrphanedProcesses() {
145
+ try {
146
+ // OPTIMIZATION: Skip cleanup if run recently (within 5 minutes)
147
+ const cleanupMarker = '/tmp/ghcc-last-cleanup';
148
+ try {
149
+ const { stdout } = await execAsync(`stat -c %Y ${cleanupMarker} 2>/dev/null || echo 0`);
150
+ const lastCleanup = parseInt(stdout.trim());
151
+ const now = Math.floor(Date.now() / 1000);
152
+ const timeSinceCleanup = now - lastCleanup;
153
+ if (timeSinceCleanup < 300) { // 5 minutes = 300 seconds
154
+ // Skip cleanup, too recent
155
+ return;
156
+ }
157
+ }
158
+ catch {
159
+ // No marker file, proceed with cleanup
160
+ }
161
+ // Strategy: Use actual running processes as source of truth
162
+ // 1. Find all running ttyd processes for ghcc-session
163
+ const { stdout: ttydOutput } = await execAsync('pgrep -f "ttyd.*ghcc-session" 2>/dev/null || true');
164
+ const ttydPids = ttydOutput.trim().split('\n').filter(p => p);
165
+ for (const pid of ttydPids) {
166
+ if (!pid)
167
+ continue;
168
+ try {
169
+ // Get session name from command line
170
+ const { stdout: cmdline } = await execAsync(`ps -p ${pid} -o args= 2>/dev/null`);
171
+ const match = cmdline.match(/attach -t (ghcc-session[^\s]*)/);
172
+ if (match) {
173
+ const sessionName = match[1];
174
+ // Check if tmux session exists
175
+ if (!(await this.sessionExists(sessionName))) {
176
+ // ttyd running but tmux session gone - kill orphaned ttyd
177
+ await execAsync(`kill ${pid} 2>/dev/null || true`);
178
+ // Also kill associated tunnel
179
+ const tunnelPid = await this.findTunnelPid(sessionName);
180
+ if (tunnelPid) {
181
+ await execAsync(`kill ${tunnelPid} 2>/dev/null || true`);
182
+ }
183
+ // Clean up tunnel URL file
184
+ const urlFile = `/tmp/ghcc-${sessionName}-tunnel-url`;
185
+ if (fs_1.default.existsSync(urlFile)) {
186
+ fs_1.default.unlinkSync(urlFile);
187
+ }
188
+ // Clean up custom HTML file
189
+ const htmlFile = `/tmp/ghcc-${sessionName}.html`;
190
+ if (fs_1.default.existsSync(htmlFile)) {
191
+ fs_1.default.unlinkSync(htmlFile);
192
+ }
193
+ }
194
+ }
195
+ }
196
+ catch {
197
+ // Can't get command line, skip this process
198
+ }
199
+ }
200
+ // 2. Find all tmux sessions and check if they have ttyd
201
+ const { stdout: tmuxOutput } = await execAsync('tmux list-sessions -F "#{session_name}" 2>/dev/null || true');
202
+ const sessions = tmuxOutput.trim().split('\n').filter(s => s && s.startsWith('ghcc-session'));
203
+ for (const sessionName of sessions) {
204
+ // Check if there's a ttyd for this session
205
+ const ttydPid = await this.findTtydPid(sessionName);
206
+ if (!ttydPid) {
207
+ // Tmux session exists but no ttyd - kill orphaned session and tunnel
208
+ await execAsync(`tmux kill-session -t ${sessionName} 2>/dev/null || true`);
209
+ const tunnelPid = await this.findTunnelPid(sessionName);
210
+ if (tunnelPid) {
211
+ await execAsync(`kill ${tunnelPid} 2>/dev/null || true`);
212
+ }
213
+ const urlFile = `/tmp/ghcc-${sessionName}-tunnel-url`;
214
+ if (fs_1.default.existsSync(urlFile)) {
215
+ fs_1.default.unlinkSync(urlFile);
216
+ }
217
+ const htmlFile = `/tmp/ghcc-${sessionName}.html`;
218
+ if (fs_1.default.existsSync(htmlFile)) {
219
+ fs_1.default.unlinkSync(htmlFile);
220
+ }
221
+ }
222
+ }
223
+ // 3. Clean up any stale PID files (best effort, not critical)
224
+ try {
225
+ const { stdout: pidFiles } = await execAsync('ls /tmp/ghcc-*-ttyd.pid 2>/dev/null || true');
226
+ const files = pidFiles.trim().split('\n').filter(f => f);
227
+ for (const file of files) {
228
+ if (!file)
229
+ continue;
230
+ const sessionName = file.replace('/tmp/ghcc-', '').replace('-ttyd.pid', '');
231
+ // Remove PID file if session doesn't exist
232
+ if (!(await this.sessionExists(sessionName))) {
233
+ await execAsync(`rm -f ${file} 2>/dev/null || true`);
234
+ }
235
+ }
236
+ }
237
+ catch {
238
+ // Ignore PID file cleanup errors
239
+ }
240
+ // Update cleanup marker
241
+ await execAsync(`touch ${cleanupMarker} 2>/dev/null || true`);
242
+ }
243
+ catch {
244
+ // Error during cleanup, continue anyway
245
+ }
246
+ }
247
+ async start(options) {
248
+ let { port, session } = options;
249
+ console.log(chalk_1.default.cyan('šŸš€ Starting GitHub Copilot Remote Session...\n'));
250
+ // SECURITY: Validate session name format
251
+ if (!this.validateSessionName(session)) {
252
+ console.log(chalk_1.default.red('āœ— Invalid session name format!\n'));
253
+ console.log(chalk_1.default.yellow('Session names must:'));
254
+ console.log(chalk_1.default.white(' • Be 3-64 characters long'));
255
+ console.log(chalk_1.default.white(' • Start with letter or number'));
256
+ console.log(chalk_1.default.white(' • Contain only: letters, numbers, hyphens, underscores'));
257
+ process.exit(1);
258
+ }
259
+ const spinner1 = (0, ora_1.default)('Checking for Copilot CLI...').start();
260
+ try {
261
+ await execAsync('which copilot');
262
+ spinner1.succeed('Copilot CLI found');
263
+ }
264
+ catch (error) {
265
+ spinner1.fail('Copilot CLI not found');
266
+ console.log(chalk_1.default.yellow('\nPlease install GitHub Copilot CLI first:'));
267
+ console.log(chalk_1.default.white(' npm install -g @github/copilot'));
268
+ process.exit(1);
269
+ }
270
+ // Clean up ALL orphaned processes before starting
271
+ await this.cleanupOrphanedProcesses();
272
+ // Check if session name already exists
273
+ if (await this.sessionExists(session)) {
274
+ console.log(chalk_1.default.red(`āœ— Session "${session}" already exists!\n`));
275
+ console.log(chalk_1.default.yellow('This should not happen with auto-generated names.'));
276
+ console.log(chalk_1.default.yellow('Try running the command again.'));
277
+ process.exit(1);
278
+ }
279
+ // Auto-assign port if not specified
280
+ let finalPort;
281
+ if (port) {
282
+ finalPort = parseInt(port);
283
+ // SECURITY: Validate port number
284
+ if (!this.validatePort(finalPort)) {
285
+ console.log(chalk_1.default.red('āœ— Invalid port number!\n'));
286
+ console.log(chalk_1.default.yellow('Port must be between 1024-65535 (non-privileged range)'));
287
+ process.exit(1);
288
+ }
289
+ // Check if specified port is available
290
+ if (await this.isPortInUse(finalPort)) {
291
+ console.log(chalk_1.default.red(`āœ— Port ${finalPort} is already in use!\n`));
292
+ console.log(chalk_1.default.yellow('Try a different port or omit -p to auto-assign.'));
293
+ process.exit(1);
294
+ }
295
+ }
296
+ else {
297
+ finalPort = await this.findAvailablePort();
298
+ }
299
+ // Note: --continue doesn't work reliably in detached tmux sessions
300
+ // Users can use /resume command inside Copilot to switch sessions
301
+ const copilotCmd = 'copilot';
302
+ const spinner3 = (0, ora_1.default)('Starting Copilot in tmux...').start();
303
+ try {
304
+ await execAsync(`tmux new-session -d -s ${session} ${copilotCmd}`);
305
+ // Wait and verify session is still alive
306
+ await new Promise(resolve => setTimeout(resolve, 2000));
307
+ // Check if session still exists
308
+ try {
309
+ await execAsync(`tmux has-session -t ${session} 2>/dev/null`);
310
+ spinner3.succeed(`Copilot started in tmux session "${session}"`);
311
+ }
312
+ catch {
313
+ spinner3.fail(`Session "${session}" exited immediately`);
314
+ console.log(chalk_1.default.yellow('\nāš ļø Copilot session closed right after starting.'));
315
+ console.log(chalk_1.default.yellow('This might happen if:'));
316
+ console.log(chalk_1.default.white(' • You haven\'t logged in: Run "copilot login" first'));
317
+ console.log(chalk_1.default.white(' • Copilot crashed or had an error'));
318
+ process.exit(1);
319
+ }
320
+ }
321
+ catch (error) {
322
+ spinner3.fail('Failed to start tmux session');
323
+ console.error(chalk_1.default.red(error.message));
324
+ process.exit(1);
325
+ }
326
+ let publicUrl = ''; // Declare here for scope across ttyd and tunnel blocks
327
+ let ttydPassword = ''; // Store password for display
328
+ let tunnelPassword = ''; // Store tunnel password for display
329
+ let httpsEnabled = false; // Track if HTTPS is actually available
330
+ const spinner4 = (0, ora_1.default)(`Starting ttyd server on port ${finalPort}...`).start();
331
+ // SECURITY: Generate strong authentication password for ttyd
332
+ ttydPassword = this.generateSecurePassword(6);
333
+ // SECURITY: Create temp files with restricted permissions
334
+ const customHtmlPath = this.createSecureTempFile(`ghcc-${session}`, '.html');
335
+ const basePath = path_1.default.join(__dirname, '..', 'assets', 'ttyd-base.html');
336
+ try {
337
+ let html = fs_1.default.readFileSync(basePath, 'utf-8');
338
+ // Replace title
339
+ html = html.replace(/<title>.*?<\/title>/, `<title>GitHub Copilot - ${session}</title>`);
340
+ // Add viewport meta tag if not present (critical for mobile)
341
+ if (!html.includes('<meta name="viewport"')) {
342
+ html = html.replace('<head>', '<head><meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=5.0, user-scalable=yes, viewport-fit=cover">');
343
+ }
344
+ // SECURITY: Write with restricted permissions (0600 - owner read/write only)
345
+ fs_1.default.writeFileSync(customHtmlPath, html, { mode: 0o600 });
346
+ }
347
+ catch (error) {
348
+ console.log(chalk_1.default.yellow('Warning: Could not create mobile HTML, mobile portrait may not work'));
349
+ }
350
+ try {
351
+ // SECURITY: Generate self-signed certificate for HTTPS
352
+ const certDir = path_1.default.join(os_1.default.homedir(), '.ghcc-client', 'certs');
353
+ const certPath = path_1.default.join(certDir, 'cert.pem');
354
+ const keyPath = path_1.default.join(certDir, 'key.pem');
355
+ // Create cert directory if it doesn't exist
356
+ if (!fs_1.default.existsSync(certDir)) {
357
+ fs_1.default.mkdirSync(certDir, { recursive: true, mode: 0o700 });
358
+ }
359
+ // Generate self-signed cert if it doesn't exist
360
+ if (!fs_1.default.existsSync(certPath) || !fs_1.default.existsSync(keyPath)) {
361
+ spinner4.text = 'Generating HTTPS certificate (first-time only)...';
362
+ try {
363
+ await execAsync(`openssl req -x509 -newkey rsa:2048 -nodes -keyout "${keyPath}" -out "${certPath}" -days 365 -subj "/CN=ghcc-client" 2>/dev/null`);
364
+ fs_1.default.chmodSync(certPath, 0o600);
365
+ fs_1.default.chmodSync(keyPath, 0o600);
366
+ }
367
+ catch (certError) {
368
+ console.log(chalk_1.default.yellow('\nāš ļø Could not generate HTTPS certificate, falling back to HTTP'));
369
+ console.log(chalk_1.default.gray(' Install openssl for secure HTTPS connections'));
370
+ }
371
+ }
372
+ const ttydArgs = [
373
+ '-p', finalPort.toString(),
374
+ '-W', // Allow clients to write
375
+ '-c', `user:${ttydPassword}`, // SECURITY: Basic authentication
376
+ ];
377
+ // SECURITY: Add HTTPS if certificate exists
378
+ if (fs_1.default.existsSync(certPath) && fs_1.default.existsSync(keyPath)) {
379
+ ttydArgs.push('-S', '-C', certPath, '-K', keyPath);
380
+ httpsEnabled = true;
381
+ }
382
+ // Use custom HTML with mobile fixes if available
383
+ if (fs_1.default.existsSync(customHtmlPath)) {
384
+ ttydArgs.push('-I', customHtmlPath);
385
+ }
386
+ // Add client options for better UX
387
+ ttydArgs.push('-t', 'fontSize=14');
388
+ ttydArgs.push('-t', 'fontFamily=Consolas,Monaco,Courier New,monospace');
389
+ ttydArgs.push('-t', 'theme={"background":"#1e1e1e","foreground":"#d4d4d4","cursor":"#d4d4d4","selection":"#264f78"}');
390
+ ttydArgs.push('-t', `titleFixed=GitHub Copilot - ${session}`);
391
+ ttydArgs.push('-t', 'disableLeaveAlert=true');
392
+ ttydArgs.push('-t', 'disableResizeOverlay=true');
393
+ // Add tmux command
394
+ ttydArgs.push('tmux', 'attach', '-t', session);
395
+ const ttyd = (0, child_process_1.spawn)(this.ttydPath, ttydArgs, {
396
+ detached: true,
397
+ stdio: 'ignore'
398
+ });
399
+ const ttydPid = ttyd.pid;
400
+ // Note: Don't call ttyd.unref() - we want this process to keep the event loop alive
401
+ // Wait for ttyd to start
402
+ await new Promise(resolve => setTimeout(resolve, 1500));
403
+ // Verify ttyd process is still running
404
+ try {
405
+ await execAsync(`kill -0 ${ttydPid}`);
406
+ }
407
+ catch {
408
+ spinner4.fail('ttyd server failed to start');
409
+ console.log(chalk_1.default.red(`\nāœ— ttyd process (PID ${ttydPid}) exited immediately\n`));
410
+ console.log(chalk_1.default.yellow('Possible causes:'));
411
+ console.log(chalk_1.default.white(` • Port ${finalPort} is already in use`));
412
+ console.log(chalk_1.default.white(' • tmux session is inaccessible'));
413
+ console.log(chalk_1.default.white(' • ttyd binary is corrupted'));
414
+ console.log(chalk_1.default.yellow('\nCleaning up tmux session...'));
415
+ await execAsync(`tmux kill-session -t ${session} 2>/dev/null || true`);
416
+ process.exit(1);
417
+ }
418
+ // Verify port is actually listening
419
+ await new Promise(resolve => setTimeout(resolve, 500));
420
+ try {
421
+ await execAsync(`lsof -i :${finalPort} 2>/dev/null | grep -q LISTEN`);
422
+ }
423
+ catch {
424
+ spinner4.fail('ttyd server not listening on port');
425
+ console.log(chalk_1.default.red(`\nāœ— ttyd started but is not listening on port ${finalPort}\n`));
426
+ console.log(chalk_1.default.yellow('Cleaning up...'));
427
+ await execAsync(`kill ${ttydPid} 2>/dev/null || true`);
428
+ await execAsync(`tmux kill-session -t ${session} 2>/dev/null || true`);
429
+ process.exit(1);
430
+ }
431
+ spinner4.succeed(`ttyd server started on port ${finalPort}`);
432
+ // Set up tmux hook for automatic cleanup
433
+ // Note: Hook uses PID for simplicity (edge case cleanup only)
434
+ // Main architecture remains process-based
435
+ try {
436
+ const hookCmd = `run-shell "kill ${ttydPid} 2>/dev/null || true"`;
437
+ await execAsync(`tmux set-hook -t ${session} session-closed "${hookCmd}"`);
438
+ }
439
+ catch (error) {
440
+ // Log error for debugging but don't fail the start
441
+ console.log(chalk_1.default.yellow('\nāš ļø Warning: Failed to set up automatic cleanup hook'));
442
+ console.log(chalk_1.default.gray(` Error: ${error instanceof Error ? error.message : error}`));
443
+ console.log(chalk_1.default.gray(' (You may need to manually stop the session later)'));
444
+ }
445
+ // FINAL verification - check once more before declaring success
446
+ try {
447
+ await execAsync(`kill -0 ${ttydPid}`);
448
+ }
449
+ catch {
450
+ console.log(chalk_1.default.red('\nāœ— ttyd process died after initial startup\n'));
451
+ console.log(chalk_1.default.yellow('Cleaning up...'));
452
+ await execAsync(`tmux kill-session -t ${session} 2>/dev/null || true`);
453
+ process.exit(1);
454
+ }
455
+ // Create public tunnel ONLY if --public flag is provided
456
+ if (options.public) {
457
+ // Create public tunnel with timeout and retry logic
458
+ // Based on localtunnel GitHub issues: https://github.com/localtunnel/localtunnel/issues
459
+ // Common issue: Promise hangs when tunnel server is slow/unavailable
460
+ // Solution: Promise.race() with timeout + retry
461
+ const spinner5 = (0, ora_1.default)('Creating public URL tunnel...').start();
462
+ const createTunnelWithTimeout = async (port, subdomain, timeoutMs = 15000) => {
463
+ const tunnelPromise = (0, localtunnel_1.default)({
464
+ port,
465
+ subdomain
466
+ });
467
+ const timeoutPromise = new Promise((_, reject) => {
468
+ setTimeout(() => reject(new Error('Tunnel connection timeout after ' + (timeoutMs / 1000) + 's')), timeoutMs);
469
+ });
470
+ return Promise.race([tunnelPromise, timeoutPromise]);
471
+ };
472
+ try {
473
+ const subdomain = session.replace('ghcc-session', 'ghcc').replace(/[^a-zA-Z0-9-]/g, '');
474
+ const port = parseInt(finalPort.toString());
475
+ // Retry logic: Try up to 2 times
476
+ let tunnel;
477
+ let lastError;
478
+ for (let attempt = 1; attempt <= 2; attempt++) {
479
+ try {
480
+ tunnel = await createTunnelWithTimeout(port, subdomain, 15000);
481
+ break; // Success, exit retry loop
482
+ }
483
+ catch (error) {
484
+ lastError = error;
485
+ if (attempt < 2) {
486
+ spinner5.text = `Retrying tunnel connection (${attempt}/2)...`;
487
+ await new Promise(resolve => setTimeout(resolve, 1000)); // Wait 1s between retries
488
+ }
489
+ }
490
+ }
491
+ if (!tunnel) {
492
+ throw lastError;
493
+ }
494
+ publicUrl = tunnel.url;
495
+ // SECURITY: Store URL in secure file with restricted permissions
496
+ const urlFile = this.createSecureTempFile(`ghcc-${session}-tunnel-url`, '.txt');
497
+ fs_1.default.writeFileSync(urlFile, publicUrl, { mode: 0o600 });
498
+ // Set up tunnel error handler
499
+ tunnel.on('error', (err) => {
500
+ console.log(chalk_1.default.yellow(`\nāš ļø Tunnel error: ${err.message}`));
501
+ });
502
+ tunnel.on('close', () => {
503
+ // Clean up URL file when tunnel closes
504
+ const urlDir = path_1.default.dirname(urlFile);
505
+ if (fs_1.default.existsSync(urlFile)) {
506
+ fs_1.default.unlinkSync(urlFile);
507
+ }
508
+ if (fs_1.default.existsSync(urlDir)) {
509
+ fs_1.default.rmdirSync(urlDir);
510
+ }
511
+ });
512
+ spinner5.succeed(`Public URL created`);
513
+ // Get tunnel password (public IP) for user to share
514
+ try {
515
+ const { stdout: password } = await execAsync('curl -s https://loca.lt/mytunnelpassword');
516
+ tunnelPassword = password.trim();
517
+ }
518
+ catch {
519
+ // Ignore if we can't fetch the password
520
+ }
521
+ }
522
+ catch (error) {
523
+ spinner5.warn('Failed to create public tunnel');
524
+ console.log(chalk_1.default.yellow(' Session is available locally only'));
525
+ console.log(chalk_1.default.gray(` Error: ${error instanceof Error ? error.message : error}`));
526
+ console.log(chalk_1.default.gray(' Tip: Check https://loca.lt for service status'));
527
+ }
528
+ }
529
+ else {
530
+ // Local-only mode: show helpful tip
531
+ console.log(chalk_1.default.dim('šŸ’” Tip: Run with --public flag to enable internet access via QR code'));
532
+ }
533
+ }
534
+ catch (error) {
535
+ spinner4.fail('Failed to spawn ttyd process');
536
+ console.error(chalk_1.default.red('\nāœ— Error: ' + error.message));
537
+ console.log(chalk_1.default.yellow('\nCleaning up tmux session...'));
538
+ await execAsync(`tmux kill-session -t ${session} 2>/dev/null || true`);
539
+ process.exit(1);
540
+ }
541
+ console.log(chalk_1.default.green('\nāœ… Remote session is ready!\n'));
542
+ // Show QR code if public URL exists
543
+ if (publicUrl) {
544
+ console.log(chalk_1.default.white('šŸ“± Scan QR code to access from mobile:\n'));
545
+ qrcode_terminal_1.default.generate(publicUrl, { small: true });
546
+ console.log();
547
+ console.log(chalk_1.default.yellow('āš ļø Important: Visitors need TWO credentials'));
548
+ console.log(chalk_1.default.gray(` 1. Tunnel password: ${chalk_1.default.white(tunnelPassword)}`));
549
+ console.log(chalk_1.default.gray(' 2. Terminal credentials (shown below)'));
550
+ console.log(chalk_1.default.gray(' After tunnel auth, it works for 7 days from their IP'));
551
+ console.log();
552
+ }
553
+ // SECURITY: Display authentication credentials
554
+ console.log(chalk_1.default.white('šŸ” Terminal Session Credentials:\n'));
555
+ console.log(chalk_1.default.gray(' Username: ') + chalk_1.default.white('user'));
556
+ console.log(chalk_1.default.gray(' Password: ') + chalk_1.default.white(ttydPassword));
557
+ console.log();
558
+ this.showUrls(finalPort.toString(), session, publicUrl, httpsEnabled);
559
+ }
560
+ async stop(options) {
561
+ const { session, all } = options;
562
+ // Case 1: No flags - show help
563
+ if (!session && !all) {
564
+ console.log(chalk_1.default.yellow('āš ļø No session specified\n'));
565
+ console.log(chalk_1.default.white('To stop a session, you need to specify which one:\n'));
566
+ console.log(chalk_1.default.cyan(' ghcc-client status') + chalk_1.default.gray(' # List all running sessions'));
567
+ console.log(chalk_1.default.cyan(' ghcc-client stop -s <name>') + chalk_1.default.gray(' # Stop a specific session'));
568
+ console.log(chalk_1.default.cyan(' ghcc-client stop --all') + chalk_1.default.gray(' # Stop all sessions\n'));
569
+ return;
570
+ }
571
+ // Case 2: Stop all sessions
572
+ if (all) {
573
+ console.log(chalk_1.default.cyan('šŸ›‘ Stopping all sessions...\n'));
574
+ try {
575
+ const { stdout } = await execAsync('tmux list-sessions -F "#{session_name}" 2>/dev/null || true');
576
+ const allSessions = stdout.trim().split('\n').filter(s => s && s.startsWith('ghcc-session'));
577
+ if (allSessions.length === 0) {
578
+ console.log(chalk_1.default.yellow('ℹ No ghcc-session sessions found\n'));
579
+ return;
580
+ }
581
+ let stoppedCount = 0;
582
+ for (const sess of allSessions) {
583
+ // Find and kill ttyd (process-based)
584
+ const ttydPid = await this.findTtydPid(sess);
585
+ if (ttydPid) {
586
+ try {
587
+ await execAsync(`kill ${ttydPid} 2>/dev/null || true`);
588
+ }
589
+ catch {
590
+ // Already dead
591
+ }
592
+ }
593
+ // Find and kill tunnel (process-based)
594
+ const tunnelPid = await this.findTunnelPid(sess);
595
+ if (tunnelPid) {
596
+ try {
597
+ await execAsync(`kill ${tunnelPid} 2>/dev/null || true`);
598
+ }
599
+ catch {
600
+ // Already dead
601
+ }
602
+ }
603
+ // Kill tmux session
604
+ try {
605
+ await execAsync(`tmux kill-session -t ${sess} 2>/dev/null || true`);
606
+ }
607
+ catch {
608
+ // Already dead
609
+ }
610
+ // Clean up temp files
611
+ try {
612
+ await execAsync(`rm -f /tmp/ghcc-${sess}-tunnel-url /tmp/ghcc-${sess}.html`);
613
+ }
614
+ catch {
615
+ // Ignore cleanup errors
616
+ }
617
+ console.log(chalk_1.default.green(`āœ” ${sess} stopped`));
618
+ stoppedCount++;
619
+ }
620
+ console.log(chalk_1.default.green(`\nāœ… Stopped ${stoppedCount} session(s)\n`));
621
+ }
622
+ catch {
623
+ console.log(chalk_1.default.red('āœ— Error listing sessions\n'));
624
+ }
625
+ return;
626
+ }
627
+ // Case 3: Stop specific session (TypeScript knows session is defined here)
628
+ const sessionName = session;
629
+ let ttydStopped = false;
630
+ let sessionStopped = false;
631
+ // Find and kill ttyd process (process-based)
632
+ const ttydPid = await this.findTtydPid(sessionName);
633
+ if (ttydPid) {
634
+ try {
635
+ await execAsync(`kill ${ttydPid} 2>/dev/null || true`);
636
+ ttydStopped = true;
637
+ }
638
+ catch {
639
+ // Already dead
640
+ }
641
+ }
642
+ // Find and kill tunnel process
643
+ const tunnelPid = await this.findTunnelPid(sessionName);
644
+ if (tunnelPid) {
645
+ try {
646
+ await execAsync(`kill ${tunnelPid} 2>/dev/null || true`);
647
+ }
648
+ catch {
649
+ // Already dead
650
+ }
651
+ }
652
+ // Clean up tunnel URL file
653
+ const urlFile = `/tmp/ghcc-${sessionName}-tunnel-url`;
654
+ if (fs_1.default.existsSync(urlFile)) {
655
+ fs_1.default.unlinkSync(urlFile);
656
+ }
657
+ // Clean up custom HTML file
658
+ const htmlFile = `/tmp/ghcc-${sessionName}.html`;
659
+ if (fs_1.default.existsSync(htmlFile)) {
660
+ fs_1.default.unlinkSync(htmlFile);
661
+ }
662
+ // Kill tmux session if it exists
663
+ if (await this.sessionExists(sessionName)) {
664
+ try {
665
+ await execAsync(`tmux kill-session -t ${sessionName} 2>/dev/null`);
666
+ sessionStopped = true;
667
+ }
668
+ catch {
669
+ // Failed to kill
670
+ }
671
+ }
672
+ if (!ttydStopped && !sessionStopped) {
673
+ console.log(chalk_1.default.yellow(`\nℹ Session "${sessionName}" was not running\n`));
674
+ }
675
+ else {
676
+ console.log(chalk_1.default.green(`\nāœ… Session "${sessionName}" stopped\n`));
677
+ }
678
+ }
679
+ showUrls(port, session, publicUrl, httpsEnabled = false) {
680
+ const interfaces = os_1.default.networkInterfaces();
681
+ let localIp = 'localhost';
682
+ for (const name of Object.keys(interfaces)) {
683
+ const ifaces = interfaces[name];
684
+ if (!ifaces)
685
+ continue;
686
+ for (const iface of ifaces) {
687
+ if (iface.family === 'IPv4' && !iface.internal) {
688
+ localIp = iface.address;
689
+ break;
690
+ }
691
+ }
692
+ }
693
+ const protocol = httpsEnabled ? 'https' : 'http';
694
+ console.log(chalk_1.default.cyan('Access URLs:'));
695
+ console.log(chalk_1.default.white(` Local: ${chalk_1.default.underline(`${protocol}://localhost:${port}`)}`));
696
+ console.log(chalk_1.default.white(` Network: ${chalk_1.default.underline(`${protocol}://${localIp}:${port}`)}`));
697
+ if (publicUrl) {
698
+ console.log(chalk_1.default.white(` Public: ${chalk_1.default.underline(publicUrl)}`));
699
+ }
700
+ if (session) {
701
+ console.log(chalk_1.default.white(` Session: ${session}`));
702
+ }
703
+ console.log('');
704
+ }
705
+ }
706
+ exports.SessionManager = SessionManager;
707
+ //# sourceMappingURL=session-manager.js.map