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.
- package/LICENSE +674 -0
- package/README.md +258 -0
- package/assets/terminal.html +123 -0
- package/assets/ttyd-base.html +384 -0
- package/binaries/ttyd-darwin-arm64 +0 -0
- package/binaries/ttyd-darwin-x64 +0 -0
- package/binaries/ttyd-linux-arm64 +0 -0
- package/binaries/ttyd-linux-x64 +0 -0
- package/dist/assets/terminal.html +123 -0
- package/dist/assets/ttyd-base.html +384 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +81 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +21 -0
- package/dist/index.js.map +1 -0
- package/dist/session-manager.d.ts +21 -0
- package/dist/session-manager.d.ts.map +1 -0
- package/dist/session-manager.js +707 -0
- package/dist/session-manager.js.map +1 -0
- package/dist/setup.d.ts +2 -0
- package/dist/setup.d.ts.map +1 -0
- package/dist/setup.js +44 -0
- package/dist/setup.js.map +1 -0
- package/dist/types.d.ts +15 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/package.json +59 -0
|
@@ -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
|