@seflless/ghosttown 1.4.1 → 1.6.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/README.md +47 -1
- package/package.json +2 -1
- package/scripts/cli-publish.js +11 -2
- package/src/cli.js +2416 -182
- package/src/cli.test.sh +605 -0
package/src/cli.js
CHANGED
|
@@ -8,21 +8,39 @@
|
|
|
8
8
|
* ghosttown [options] [command]
|
|
9
9
|
*
|
|
10
10
|
* Options:
|
|
11
|
-
* -p, --port <port>
|
|
12
|
-
* -
|
|
11
|
+
* -p, --port <port> Port to listen on (default: 8080, or PORT env var)
|
|
12
|
+
* -n, --name <name> Give the session a custom name
|
|
13
|
+
* -k, --kill [session] Kill a session (current if inside one, or specify by name)
|
|
14
|
+
* -ka, --kill-all Kill all ghosttown sessions
|
|
15
|
+
* -v, --version Show version number
|
|
16
|
+
* -h, --help Show this help message
|
|
17
|
+
*
|
|
18
|
+
* Commands:
|
|
19
|
+
* list List all ghosttown tmux sessions
|
|
20
|
+
* attach <name|id> Attach to a ghosttown session
|
|
21
|
+
* detach Detach from current session
|
|
22
|
+
* rename [old] <new> Rename a session
|
|
23
|
+
* update Update ghosttown to the latest version
|
|
13
24
|
*
|
|
14
25
|
* Examples:
|
|
15
26
|
* ghosttown Start the web terminal server
|
|
16
27
|
* ghosttown -p 3000 Start the server on port 3000
|
|
17
28
|
* ghosttown vim Run vim in a new tmux session
|
|
18
|
-
* ghosttown
|
|
29
|
+
* ghosttown -n my-project vim Run vim in a named session
|
|
30
|
+
* ghosttown list List all sessions
|
|
31
|
+
* ghosttown attach my-project Attach to a session
|
|
32
|
+
* ghosttown rename new-name Rename current session
|
|
33
|
+
* ghosttown -k my-project Kill a specific session
|
|
34
|
+
* ghosttown -ka Kill all sessions
|
|
19
35
|
*/
|
|
20
36
|
|
|
21
37
|
import { execSync, spawn, spawnSync } from 'child_process';
|
|
38
|
+
import crypto from 'crypto';
|
|
22
39
|
import fs from 'fs';
|
|
23
40
|
import http from 'http';
|
|
41
|
+
import https from 'https';
|
|
24
42
|
import { createRequire } from 'module';
|
|
25
|
-
import { homedir, networkInterfaces } from 'os';
|
|
43
|
+
import { homedir, networkInterfaces, userInfo } from 'os';
|
|
26
44
|
import path from 'path';
|
|
27
45
|
import { fileURLToPath } from 'url';
|
|
28
46
|
|
|
@@ -41,6 +59,240 @@ const require = createRequire(import.meta.url);
|
|
|
41
59
|
const packageJson = require('../package.json');
|
|
42
60
|
const VERSION = packageJson.version;
|
|
43
61
|
|
|
62
|
+
// Special session name for background server (hidden from `gt list`)
|
|
63
|
+
const SERVER_SESSION_NAME = 'ghosttown-host';
|
|
64
|
+
|
|
65
|
+
// ============================================================================
|
|
66
|
+
// Authentication & Session Management
|
|
67
|
+
// ============================================================================
|
|
68
|
+
|
|
69
|
+
// Session expiration: 3 days in milliseconds
|
|
70
|
+
const SESSION_EXPIRATION_MS = 3 * 24 * 60 * 60 * 1000;
|
|
71
|
+
|
|
72
|
+
// In-memory session store: Map<token, { username, createdAt, lastActivity }>
|
|
73
|
+
const authSessions = new Map();
|
|
74
|
+
|
|
75
|
+
// Rate limiting for login attempts
|
|
76
|
+
// Map<ip, { attempts: number, firstAttempt: number, lockedUntil: number }>
|
|
77
|
+
const loginAttempts = new Map();
|
|
78
|
+
const RATE_LIMIT_WINDOW_MS = 15 * 60 * 1000; // 15 minutes
|
|
79
|
+
const RATE_LIMIT_MAX_ATTEMPTS = 5; // Max attempts per window
|
|
80
|
+
const RATE_LIMIT_LOCKOUT_MS = 15 * 60 * 1000; // 15 minute lockout after max attempts
|
|
81
|
+
|
|
82
|
+
function getClientIP(req) {
|
|
83
|
+
// Support reverse proxies
|
|
84
|
+
const forwarded = req.headers['x-forwarded-for'];
|
|
85
|
+
if (forwarded) {
|
|
86
|
+
return forwarded.split(',')[0].trim();
|
|
87
|
+
}
|
|
88
|
+
return req.socket?.remoteAddress || 'unknown';
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function checkRateLimit(ip) {
|
|
92
|
+
const now = Date.now();
|
|
93
|
+
const record = loginAttempts.get(ip);
|
|
94
|
+
|
|
95
|
+
if (!record) {
|
|
96
|
+
return { allowed: true };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Check if currently locked out
|
|
100
|
+
if (record.lockedUntil && now < record.lockedUntil) {
|
|
101
|
+
const remainingSeconds = Math.ceil((record.lockedUntil - now) / 1000);
|
|
102
|
+
return {
|
|
103
|
+
allowed: false,
|
|
104
|
+
error: `Too many login attempts. Try again in ${remainingSeconds} seconds.`,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Check if window has expired, reset if so
|
|
109
|
+
if (now - record.firstAttempt > RATE_LIMIT_WINDOW_MS) {
|
|
110
|
+
loginAttempts.delete(ip);
|
|
111
|
+
return { allowed: true };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Check if max attempts exceeded
|
|
115
|
+
if (record.attempts >= RATE_LIMIT_MAX_ATTEMPTS) {
|
|
116
|
+
record.lockedUntil = now + RATE_LIMIT_LOCKOUT_MS;
|
|
117
|
+
const remainingSeconds = Math.ceil(RATE_LIMIT_LOCKOUT_MS / 1000);
|
|
118
|
+
return {
|
|
119
|
+
allowed: false,
|
|
120
|
+
error: `Too many login attempts. Try again in ${remainingSeconds} seconds.`,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return { allowed: true };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function recordLoginAttempt(ip, success) {
|
|
128
|
+
const now = Date.now();
|
|
129
|
+
|
|
130
|
+
if (success) {
|
|
131
|
+
// Clear attempts on successful login
|
|
132
|
+
loginAttempts.delete(ip);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const record = loginAttempts.get(ip);
|
|
137
|
+
if (!record || now - record.firstAttempt > RATE_LIMIT_WINDOW_MS) {
|
|
138
|
+
loginAttempts.set(ip, { attempts: 1, firstAttempt: now, lockedUntil: 0 });
|
|
139
|
+
} else {
|
|
140
|
+
record.attempts++;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Clean up old rate limit records periodically
|
|
145
|
+
setInterval(
|
|
146
|
+
() => {
|
|
147
|
+
const now = Date.now();
|
|
148
|
+
for (const [ip, record] of loginAttempts.entries()) {
|
|
149
|
+
if (
|
|
150
|
+
now - record.firstAttempt > RATE_LIMIT_WINDOW_MS &&
|
|
151
|
+
(!record.lockedUntil || now > record.lockedUntil)
|
|
152
|
+
) {
|
|
153
|
+
loginAttempts.delete(ip);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
},
|
|
157
|
+
5 * 60 * 1000
|
|
158
|
+
); // Clean up every 5 minutes
|
|
159
|
+
|
|
160
|
+
// Generate a secure random token
|
|
161
|
+
function generateAuthToken() {
|
|
162
|
+
return crypto.randomBytes(32).toString('hex');
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Parse cookies from request headers
|
|
166
|
+
function parseCookies(cookieHeader) {
|
|
167
|
+
const cookies = {};
|
|
168
|
+
if (!cookieHeader) return cookies;
|
|
169
|
+
for (const cookie of cookieHeader.split(';')) {
|
|
170
|
+
const [name, ...rest] = cookie.split('=');
|
|
171
|
+
if (name && rest.length > 0) {
|
|
172
|
+
cookies[name.trim()] = rest.join('=').trim();
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return cookies;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Validate session token
|
|
179
|
+
function validateAuthSession(token) {
|
|
180
|
+
if (!token) return null;
|
|
181
|
+
const session = authSessions.get(token);
|
|
182
|
+
if (!session) return null;
|
|
183
|
+
|
|
184
|
+
// Check if session has expired
|
|
185
|
+
const now = Date.now();
|
|
186
|
+
if (now - session.createdAt > SESSION_EXPIRATION_MS) {
|
|
187
|
+
authSessions.delete(token);
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Update last activity
|
|
192
|
+
session.lastActivity = now;
|
|
193
|
+
return session;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Authenticate user against macOS local directory
|
|
197
|
+
async function authenticateUser(username, password) {
|
|
198
|
+
if (process.platform !== 'darwin') {
|
|
199
|
+
// On non-macOS, just check if the username matches the current user
|
|
200
|
+
const currentUser = userInfo().username;
|
|
201
|
+
if (username === currentUser) {
|
|
202
|
+
return { success: true, username };
|
|
203
|
+
}
|
|
204
|
+
return { success: false, error: 'Authentication only supported on macOS' };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return new Promise((resolve) => {
|
|
208
|
+
// Use dscl to verify credentials against macOS local directory
|
|
209
|
+
const dscl = spawn('dscl', ['/Local/Default', '-authonly', username, password], {
|
|
210
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
dscl.on('close', (code) => {
|
|
214
|
+
if (code === 0) {
|
|
215
|
+
resolve({ success: true, username });
|
|
216
|
+
} else {
|
|
217
|
+
resolve({ success: false, error: 'Invalid username or password' });
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
dscl.on('error', (err) => {
|
|
222
|
+
resolve({ success: false, error: 'Authentication failed: ' + err.message });
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Clean up expired sessions periodically
|
|
228
|
+
setInterval(
|
|
229
|
+
() => {
|
|
230
|
+
const now = Date.now();
|
|
231
|
+
for (const [token, session] of authSessions.entries()) {
|
|
232
|
+
if (now - session.createdAt > SESSION_EXPIRATION_MS) {
|
|
233
|
+
authSessions.delete(token);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
},
|
|
237
|
+
60 * 60 * 1000
|
|
238
|
+
); // Clean up every hour
|
|
239
|
+
|
|
240
|
+
// Helper to read POST body
|
|
241
|
+
function readBody(req) {
|
|
242
|
+
return new Promise((resolve, reject) => {
|
|
243
|
+
let body = '';
|
|
244
|
+
req.on('data', (chunk) => {
|
|
245
|
+
body += chunk.toString();
|
|
246
|
+
if (body.length > 1024 * 10) {
|
|
247
|
+
reject(new Error('Body too large'));
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
req.on('end', () => resolve(body));
|
|
251
|
+
req.on('error', reject);
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Helper to set auth cookie
|
|
256
|
+
function setAuthCookie(res, token) {
|
|
257
|
+
const maxAge = SESSION_EXPIRATION_MS / 1000;
|
|
258
|
+
res.setHeader(
|
|
259
|
+
'Set-Cookie',
|
|
260
|
+
`ghostty_session=${token}; HttpOnly; SameSite=Strict; Path=/; Max-Age=${maxAge}`
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Helper to clear auth cookie
|
|
265
|
+
function clearAuthCookie(res) {
|
|
266
|
+
res.setHeader('Set-Cookie', 'ghostty_session=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0');
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// ============================================================================
|
|
270
|
+
// Helpers
|
|
271
|
+
// ============================================================================
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Get the user's default shell
|
|
275
|
+
*/
|
|
276
|
+
function getShell() {
|
|
277
|
+
return process.env.SHELL || '/bin/sh';
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Get local IP addresses for network access
|
|
282
|
+
*/
|
|
283
|
+
function getLocalIPs() {
|
|
284
|
+
const interfaces = networkInterfaces();
|
|
285
|
+
const ips = [];
|
|
286
|
+
for (const name of Object.keys(interfaces)) {
|
|
287
|
+
for (const iface of interfaces[name] || []) {
|
|
288
|
+
if (iface.family === 'IPv4' && !iface.internal) {
|
|
289
|
+
ips.push(iface.address);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
return ips;
|
|
294
|
+
}
|
|
295
|
+
|
|
44
296
|
// ============================================================================
|
|
45
297
|
// Tmux Session Management
|
|
46
298
|
// ============================================================================
|
|
@@ -100,42 +352,198 @@ function getCurrentTmuxSessionName() {
|
|
|
100
352
|
}
|
|
101
353
|
|
|
102
354
|
/**
|
|
103
|
-
* Check if currently inside a ghosttown session (named
|
|
355
|
+
* Check if currently inside a ghosttown session (named gt-<N>)
|
|
104
356
|
*/
|
|
105
357
|
function isInsideGhosttownSession() {
|
|
106
358
|
const sessionName = getCurrentTmuxSessionName();
|
|
107
|
-
return sessionName && sessionName.startsWith('
|
|
359
|
+
return sessionName && sessionName.startsWith('gt-');
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Validate a session name
|
|
364
|
+
* Returns { valid: true } or { valid: false, error: string }
|
|
365
|
+
*/
|
|
366
|
+
function validateSessionName(name) {
|
|
367
|
+
if (!name || name.length === 0) {
|
|
368
|
+
return { valid: false, error: 'Session name cannot be empty' };
|
|
369
|
+
}
|
|
370
|
+
if (name.length > 50) {
|
|
371
|
+
return { valid: false, error: 'Session name too long (max 50 chars)' };
|
|
372
|
+
}
|
|
373
|
+
if (/^[0-9]+$/.test(name)) {
|
|
374
|
+
return {
|
|
375
|
+
valid: false,
|
|
376
|
+
error: 'Session name cannot be purely numeric (conflicts with auto-generated IDs)',
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
// Reserved name for background server
|
|
380
|
+
if (name === 'ghosttown-host') {
|
|
381
|
+
return { valid: false, error: "'ghosttown-host' is a reserved name" };
|
|
382
|
+
}
|
|
383
|
+
// tmux session names cannot contain: colon, period
|
|
384
|
+
// We also disallow spaces and special chars for URL safety
|
|
385
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
|
|
386
|
+
return {
|
|
387
|
+
valid: false,
|
|
388
|
+
error: 'Session name can only contain letters, numbers, hyphens, and underscores',
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
return { valid: true };
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Check if a tmux session with the given name exists
|
|
396
|
+
*/
|
|
397
|
+
function sessionExists(sessionName) {
|
|
398
|
+
try {
|
|
399
|
+
const output = execSync('tmux list-sessions -F "#{session_name}"', {
|
|
400
|
+
encoding: 'utf8',
|
|
401
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
402
|
+
});
|
|
403
|
+
return output
|
|
404
|
+
.split('\n')
|
|
405
|
+
.filter((s) => s.trim())
|
|
406
|
+
.includes(sessionName);
|
|
407
|
+
} catch (err) {
|
|
408
|
+
return false;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Check if a display name is already used by a ghosttown session
|
|
414
|
+
* Display name is the user-facing part after gt-<id>-
|
|
415
|
+
*/
|
|
416
|
+
function displayNameExists(displayName) {
|
|
417
|
+
try {
|
|
418
|
+
const output = execSync('tmux list-sessions -F "#{session_name}"', {
|
|
419
|
+
encoding: 'utf8',
|
|
420
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
421
|
+
});
|
|
422
|
+
return output
|
|
423
|
+
.split('\n')
|
|
424
|
+
.filter((s) => s.trim())
|
|
425
|
+
.some((name) => {
|
|
426
|
+
const parsed = parseSessionName(name);
|
|
427
|
+
return parsed && parsed.displayName === displayName;
|
|
428
|
+
});
|
|
429
|
+
} catch (err) {
|
|
430
|
+
return false;
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Parse a ghosttown session name into its components
|
|
436
|
+
* Format: gt-<stable-id>-<display-name>
|
|
437
|
+
* Returns { stableId, displayName, fullName } or null if not a valid gt session
|
|
438
|
+
*/
|
|
439
|
+
function parseSessionName(fullName) {
|
|
440
|
+
if (!fullName || !fullName.startsWith('gt-')) {
|
|
441
|
+
return null;
|
|
442
|
+
}
|
|
443
|
+
// Format: gt-<stable-id>-<display-name>
|
|
444
|
+
const match = fullName.match(/^gt-(\d+)-(.+)$/);
|
|
445
|
+
if (match) {
|
|
446
|
+
return {
|
|
447
|
+
stableId: match[1],
|
|
448
|
+
displayName: match[2],
|
|
449
|
+
fullName: fullName,
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
// Legacy format: gt-<name> (no stable ID)
|
|
453
|
+
// Treat the whole thing after gt- as the display name, no stable ID
|
|
454
|
+
const legacyName = fullName.slice(3);
|
|
455
|
+
return {
|
|
456
|
+
stableId: null,
|
|
457
|
+
displayName: legacyName,
|
|
458
|
+
fullName: fullName,
|
|
459
|
+
};
|
|
108
460
|
}
|
|
109
461
|
|
|
110
462
|
/**
|
|
111
|
-
*
|
|
112
|
-
*
|
|
463
|
+
* Find a ghosttown session by its stable ID
|
|
464
|
+
* Returns the full session name or null if not found
|
|
113
465
|
*/
|
|
114
|
-
function
|
|
466
|
+
function findSessionByStableId(stableId) {
|
|
467
|
+
try {
|
|
468
|
+
const output = execSync('tmux list-sessions -F "#{session_name}"', {
|
|
469
|
+
encoding: 'utf8',
|
|
470
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
471
|
+
});
|
|
472
|
+
const sessions = output.split('\n').filter((s) => s.trim() && s.startsWith('gt-'));
|
|
473
|
+
for (const name of sessions) {
|
|
474
|
+
const parsed = parseSessionName(name);
|
|
475
|
+
if (parsed && parsed.stableId === stableId) {
|
|
476
|
+
return name;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
return null;
|
|
480
|
+
} catch (err) {
|
|
481
|
+
return null;
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* Get the next available auto-generated display name number
|
|
487
|
+
* Scans existing ghosttown sessions and finds the next available numeric display name
|
|
488
|
+
*/
|
|
489
|
+
function getNextDisplayNumber() {
|
|
115
490
|
try {
|
|
116
|
-
// List all tmux sessions
|
|
117
491
|
const output = execSync('tmux list-sessions -F "#{session_name}"', {
|
|
118
492
|
encoding: 'utf8',
|
|
119
493
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
120
494
|
});
|
|
121
495
|
|
|
122
|
-
// Find all
|
|
123
|
-
const
|
|
496
|
+
// Find all numeric display names from gt sessions
|
|
497
|
+
const displayNumbers = output
|
|
124
498
|
.split('\n')
|
|
125
|
-
.filter((name) => name.startsWith('
|
|
499
|
+
.filter((name) => name.startsWith('gt-'))
|
|
126
500
|
.map((name) => {
|
|
127
|
-
const
|
|
128
|
-
|
|
501
|
+
const parsed = parseSessionName(name);
|
|
502
|
+
if (parsed && /^\d+$/.test(parsed.displayName)) {
|
|
503
|
+
return Number.parseInt(parsed.displayName, 10);
|
|
504
|
+
}
|
|
505
|
+
return 0;
|
|
129
506
|
});
|
|
130
507
|
|
|
131
|
-
|
|
132
|
-
return sessionIds.length > 0 ? Math.max(...sessionIds) + 1 : 1;
|
|
508
|
+
return displayNumbers.length > 0 ? Math.max(...displayNumbers) + 1 : 1;
|
|
133
509
|
} catch (err) {
|
|
134
|
-
// tmux not running or no sessions - start with 1
|
|
135
510
|
return 1;
|
|
136
511
|
}
|
|
137
512
|
}
|
|
138
513
|
|
|
514
|
+
/**
|
|
515
|
+
* Check if the background server is running and get its port
|
|
516
|
+
* @returns {{ running: boolean, port: number | null }}
|
|
517
|
+
*/
|
|
518
|
+
function getServerStatus() {
|
|
519
|
+
try {
|
|
520
|
+
const output = execSync('tmux list-sessions -F "#{session_name}"', {
|
|
521
|
+
encoding: 'utf8',
|
|
522
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
const isRunning = output.split('\n').includes(SERVER_SESSION_NAME);
|
|
526
|
+
|
|
527
|
+
if (!isRunning) {
|
|
528
|
+
return { running: false, port: null };
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// Get the port from tmux environment
|
|
532
|
+
try {
|
|
533
|
+
const envOutput = execSync(`tmux show-environment -t ${SERVER_SESSION_NAME} GHOSTTOWN_PORT`, {
|
|
534
|
+
encoding: 'utf8',
|
|
535
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
536
|
+
});
|
|
537
|
+
const port = Number.parseInt(envOutput.split('=')[1], 10);
|
|
538
|
+
return { running: true, port: Number.isNaN(port) ? 8080 : port };
|
|
539
|
+
} catch (e) {
|
|
540
|
+
return { running: true, port: 8080 }; // Default port
|
|
541
|
+
}
|
|
542
|
+
} catch (err) {
|
|
543
|
+
return { running: false, port: null };
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
139
547
|
/**
|
|
140
548
|
* List all ghosttown tmux sessions
|
|
141
549
|
*/
|
|
@@ -150,9 +558,9 @@ function listSessions() {
|
|
|
150
558
|
const BEIGE = '\x1b[38;2;255;220;150m';
|
|
151
559
|
|
|
152
560
|
try {
|
|
153
|
-
// Get detailed session information
|
|
561
|
+
// Get detailed session information including last activity time
|
|
154
562
|
const output = execSync(
|
|
155
|
-
'tmux list-sessions -F "#{session_name}|#{
|
|
563
|
+
'tmux list-sessions -F "#{session_name}|#{session_activity}|#{session_attached}|#{session_windows}"',
|
|
156
564
|
{
|
|
157
565
|
encoding: 'utf8',
|
|
158
566
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
@@ -161,12 +569,15 @@ function listSessions() {
|
|
|
161
569
|
|
|
162
570
|
const sessions = output
|
|
163
571
|
.split('\n')
|
|
164
|
-
.filter((line) => line.startsWith('
|
|
572
|
+
.filter((line) => line.startsWith('gt-'))
|
|
165
573
|
.map((line) => {
|
|
166
|
-
const [
|
|
574
|
+
const [fullName, activity, attached, windows] = line.split('|');
|
|
575
|
+
const parsed = parseSessionName(fullName);
|
|
167
576
|
return {
|
|
168
|
-
|
|
169
|
-
|
|
577
|
+
displayName: parsed ? parsed.displayName : fullName.replace('gt-', ''),
|
|
578
|
+
stableId: parsed ? parsed.stableId : null,
|
|
579
|
+
fullName,
|
|
580
|
+
activity: new Date(Number.parseInt(activity, 10) * 1000),
|
|
170
581
|
attached: attached === '1',
|
|
171
582
|
windows: Number.parseInt(windows, 10),
|
|
172
583
|
};
|
|
@@ -179,21 +590,24 @@ function listSessions() {
|
|
|
179
590
|
process.exit(0);
|
|
180
591
|
}
|
|
181
592
|
|
|
593
|
+
// Sort by last activity (most recent first)
|
|
594
|
+
sessions.sort((a, b) => b.activity.getTime() - a.activity.getTime());
|
|
595
|
+
|
|
182
596
|
// Print header
|
|
183
597
|
console.log('\n\x1b[1mGhosttown Sessions\x1b[0m\n');
|
|
184
598
|
console.log(
|
|
185
|
-
`${CYAN}${'Name'.padEnd(
|
|
599
|
+
`${CYAN}${'Name'.padEnd(20)} ${'Last Activity'.padEnd(22)} ${'Status'.padEnd(10)} Windows${RESET}`
|
|
186
600
|
);
|
|
187
601
|
|
|
188
602
|
// Print sessions
|
|
189
603
|
for (const session of sessions) {
|
|
190
|
-
const
|
|
604
|
+
const activityStr = session.activity.toLocaleString();
|
|
191
605
|
// Status has ANSI codes so we need to pad the visible text differently
|
|
192
606
|
const statusPadded = session.attached
|
|
193
607
|
? `\x1b[32m${'attached'.padEnd(10)}${RESET}`
|
|
194
608
|
: `\x1b[33m${'detached'.padEnd(10)}${RESET}`;
|
|
195
609
|
console.log(
|
|
196
|
-
`${session.
|
|
610
|
+
`${session.displayName.padEnd(20)} ${activityStr.padEnd(22)} ${statusPadded} ${session.windows}`
|
|
197
611
|
);
|
|
198
612
|
}
|
|
199
613
|
|
|
@@ -213,39 +627,69 @@ function listSessions() {
|
|
|
213
627
|
|
|
214
628
|
/**
|
|
215
629
|
* Create a new tmux session and run the command
|
|
630
|
+
* @param {string} command - The command to run in the session
|
|
631
|
+
* @param {string|null} customName - Optional custom name for the session (without gt- prefix)
|
|
216
632
|
*/
|
|
217
|
-
function createTmuxSession(command) {
|
|
633
|
+
function createTmuxSession(command, customName = null) {
|
|
218
634
|
// Check if tmux is installed
|
|
219
635
|
if (!checkTmuxInstalled()) {
|
|
220
636
|
printTmuxInstallHelp();
|
|
221
637
|
}
|
|
222
638
|
|
|
223
|
-
const
|
|
224
|
-
const
|
|
639
|
+
const RESET = '\x1b[0m';
|
|
640
|
+
const RED = '\x1b[31m';
|
|
641
|
+
|
|
642
|
+
// Determine display name
|
|
643
|
+
let displayName;
|
|
644
|
+
if (customName) {
|
|
645
|
+
const validation = validateSessionName(customName);
|
|
646
|
+
if (!validation.valid) {
|
|
647
|
+
console.log('');
|
|
648
|
+
console.log(` ${RED}Error:${RESET} ${validation.error}`);
|
|
649
|
+
console.log('');
|
|
650
|
+
process.exit(1);
|
|
651
|
+
}
|
|
652
|
+
// Check if display name already exists
|
|
653
|
+
if (displayNameExists(customName)) {
|
|
654
|
+
console.log('');
|
|
655
|
+
console.log(` ${RED}Error:${RESET} Session '${customName}' already exists.`);
|
|
656
|
+
console.log('');
|
|
657
|
+
process.exit(1);
|
|
658
|
+
}
|
|
659
|
+
displayName = customName;
|
|
660
|
+
} else {
|
|
661
|
+
displayName = String(getNextDisplayNumber());
|
|
662
|
+
}
|
|
225
663
|
|
|
226
664
|
try {
|
|
227
|
-
// Create
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
'
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
}
|
|
665
|
+
// Create a detached session with a temporary name to get its stable ID
|
|
666
|
+
const tempName = `gt-temp-${Date.now()}`;
|
|
667
|
+
const output = execSync(
|
|
668
|
+
`tmux new-session -d -s ${tempName} -x 200 -y 50 -P -F "#{session_id}"`,
|
|
669
|
+
{ encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }
|
|
670
|
+
);
|
|
671
|
+
|
|
672
|
+
// Extract the stable ID (e.g., "$10" -> "10")
|
|
673
|
+
const stableId = output.trim().replace('$', '');
|
|
674
|
+
|
|
675
|
+
// Rename to final name: gt-<stableId>-<displayName>
|
|
676
|
+
const sessionName = `gt-${stableId}-${displayName}`;
|
|
677
|
+
execSync(`tmux rename-session -t ${tempName} ${sessionName}`, { stdio: 'pipe' });
|
|
678
|
+
|
|
679
|
+
// Set status off
|
|
680
|
+
execSync(`tmux set-option -t ${sessionName} status off`, { stdio: 'pipe' });
|
|
681
|
+
|
|
682
|
+
// Send the command to run, followed by shell
|
|
683
|
+
execSync(
|
|
684
|
+
`tmux send-keys -t ${sessionName} '${command.replace(/'/g, "'\\''")}; exec $SHELL' Enter`,
|
|
685
|
+
{ stdio: 'pipe' }
|
|
247
686
|
);
|
|
248
687
|
|
|
688
|
+
// Now attach to the session
|
|
689
|
+
const attach = spawn('tmux', ['attach-session', '-t', sessionName], {
|
|
690
|
+
stdio: 'inherit',
|
|
691
|
+
});
|
|
692
|
+
|
|
249
693
|
attach.on('exit', (code) => {
|
|
250
694
|
process.exit(code || 0);
|
|
251
695
|
});
|
|
@@ -268,14 +712,18 @@ function printDetachMessage(sessionName) {
|
|
|
268
712
|
const formatHint = (label, command) =>
|
|
269
713
|
` ${CYAN}${label.padStart(labelWidth)}${RESET} ${BEIGE}${command}${RESET}`;
|
|
270
714
|
|
|
715
|
+
// Extract display name from session name
|
|
716
|
+
const parsed = parseSessionName(sessionName);
|
|
717
|
+
const displayName = parsed ? parsed.displayName : sessionName.replace('gt-', '');
|
|
718
|
+
|
|
271
719
|
return [
|
|
272
720
|
'',
|
|
273
721
|
` ${BOLD_YELLOW}You've detached from your ghosttown session.${RESET}`,
|
|
274
722
|
` ${DIM}It's now running in the background.${RESET}`,
|
|
275
723
|
'',
|
|
276
|
-
formatHint('To reattach:', `
|
|
724
|
+
formatHint('To reattach:', `gt attach ${displayName}`),
|
|
277
725
|
'',
|
|
278
|
-
formatHint('To list all sessions:', '
|
|
726
|
+
formatHint('To list all sessions:', 'gt list'),
|
|
279
727
|
'',
|
|
280
728
|
'',
|
|
281
729
|
].join('\n');
|
|
@@ -369,9 +817,9 @@ function attachToSession(sessionName) {
|
|
|
369
817
|
const RED = '\x1b[31m';
|
|
370
818
|
const BEIGE = '\x1b[38;2;255;220;150m';
|
|
371
819
|
|
|
372
|
-
// Add
|
|
373
|
-
if (!sessionName.startsWith('
|
|
374
|
-
sessionName = `
|
|
820
|
+
// Add gt- prefix if not present
|
|
821
|
+
if (!sessionName.startsWith('gt-')) {
|
|
822
|
+
sessionName = `gt-${sessionName}`;
|
|
375
823
|
}
|
|
376
824
|
|
|
377
825
|
// Check if session exists
|
|
@@ -521,9 +969,9 @@ function killSession(sessionName) {
|
|
|
521
969
|
sessionName = getCurrentTmuxSessionName();
|
|
522
970
|
}
|
|
523
971
|
|
|
524
|
-
// Add
|
|
525
|
-
if (!sessionName.startsWith('
|
|
526
|
-
sessionName = `
|
|
972
|
+
// Add gt- prefix if not present
|
|
973
|
+
if (!sessionName.startsWith('gt-')) {
|
|
974
|
+
sessionName = `gt-${sessionName}`;
|
|
527
975
|
}
|
|
528
976
|
|
|
529
977
|
// Check if session exists
|
|
@@ -557,10 +1005,14 @@ function killSession(sessionName) {
|
|
|
557
1005
|
stdio: 'pipe',
|
|
558
1006
|
});
|
|
559
1007
|
|
|
1008
|
+
// Extract display name from session name
|
|
1009
|
+
const parsed = parseSessionName(sessionName);
|
|
1010
|
+
const displayName = parsed ? parsed.displayName : sessionName.replace('gt-', '');
|
|
1011
|
+
|
|
560
1012
|
console.log('');
|
|
561
|
-
console.log(` ${BOLD_YELLOW}Session
|
|
1013
|
+
console.log(` ${BOLD_YELLOW}Session ${displayName} has been killed.${RESET}`);
|
|
562
1014
|
console.log('');
|
|
563
|
-
console.log(` ${CYAN}To list remaining:${RESET} ${BEIGE}
|
|
1015
|
+
console.log(` ${CYAN}To list remaining:${RESET} ${BEIGE}gt list${RESET}`);
|
|
564
1016
|
console.log('');
|
|
565
1017
|
process.exit(0);
|
|
566
1018
|
} catch (err) {
|
|
@@ -571,70 +1023,498 @@ function killSession(sessionName) {
|
|
|
571
1023
|
}
|
|
572
1024
|
}
|
|
573
1025
|
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
let handled = false;
|
|
583
|
-
|
|
584
|
-
for (let i = 0; i < args.length; i++) {
|
|
585
|
-
const arg = args[i];
|
|
586
|
-
|
|
587
|
-
if (arg === '-h' || arg === '--help') {
|
|
588
|
-
console.log(`
|
|
589
|
-
Usage: ghosttown [options] [command]
|
|
1026
|
+
/**
|
|
1027
|
+
* Kill all ghosttown sessions
|
|
1028
|
+
*/
|
|
1029
|
+
function killAllSessions() {
|
|
1030
|
+
// Check if tmux is installed
|
|
1031
|
+
if (!checkTmuxInstalled()) {
|
|
1032
|
+
printTmuxInstallHelp();
|
|
1033
|
+
}
|
|
590
1034
|
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
-h, --help Show this help message
|
|
1035
|
+
const RESET = '\x1b[0m';
|
|
1036
|
+
const RED = '\x1b[31m';
|
|
1037
|
+
const BEIGE = '\x1b[38;2;255;220;150m';
|
|
1038
|
+
const BOLD_YELLOW = '\x1b[1;93m';
|
|
596
1039
|
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
<command> Run command in a new tmux session (ghosttown-<id>)
|
|
1040
|
+
try {
|
|
1041
|
+
const output = execSync('tmux list-sessions -F "#{session_name}"', {
|
|
1042
|
+
encoding: 'utf8',
|
|
1043
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
1044
|
+
});
|
|
603
1045
|
|
|
604
|
-
|
|
605
|
-
ghosttown Start the web terminal server
|
|
606
|
-
ghosttown -p 3000 Start the server on port 3000
|
|
607
|
-
ghosttown list List all ghosttown sessions
|
|
608
|
-
ghosttown attach ghosttown-1 Attach to session ghosttown-1
|
|
609
|
-
ghosttown detach Detach from current session
|
|
610
|
-
ghosttown update Update to the latest version
|
|
611
|
-
ghosttown -k Kill current session (when inside one)
|
|
612
|
-
ghosttown -k ghosttown-1 Kill a specific session
|
|
613
|
-
ghosttown vim Run vim in a new tmux session
|
|
614
|
-
ghosttown "npm run dev" Run npm in a new tmux session
|
|
1046
|
+
const sessions = output.split('\n').filter((s) => s.trim() && s.startsWith('gt-'));
|
|
615
1047
|
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
`);
|
|
619
|
-
|
|
620
|
-
|
|
1048
|
+
if (sessions.length === 0) {
|
|
1049
|
+
console.log('');
|
|
1050
|
+
console.log(` ${BEIGE}No ghosttown sessions are currently running.${RESET}`);
|
|
1051
|
+
console.log('');
|
|
1052
|
+
process.exit(0);
|
|
621
1053
|
}
|
|
622
1054
|
|
|
623
|
-
//
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
1055
|
+
// Kill each session
|
|
1056
|
+
let killed = 0;
|
|
1057
|
+
for (const sessionName of sessions) {
|
|
1058
|
+
try {
|
|
1059
|
+
execSync(`tmux kill-session -t "${sessionName}"`, { stdio: 'pipe' });
|
|
1060
|
+
killed++;
|
|
1061
|
+
} catch (err) {
|
|
1062
|
+
// Session may have been killed by another process, continue
|
|
1063
|
+
}
|
|
628
1064
|
}
|
|
629
1065
|
|
|
630
|
-
|
|
631
|
-
if (
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
1066
|
+
console.log('');
|
|
1067
|
+
if (killed === 1) {
|
|
1068
|
+
console.log(` ${BOLD_YELLOW}Killed 1 session.${RESET}`);
|
|
1069
|
+
} else {
|
|
1070
|
+
console.log(` ${BOLD_YELLOW}Killed ${killed} sessions.${RESET}`);
|
|
635
1071
|
}
|
|
636
|
-
|
|
637
|
-
|
|
1072
|
+
console.log('');
|
|
1073
|
+
process.exit(0);
|
|
1074
|
+
} catch (err) {
|
|
1075
|
+
// No tmux sessions
|
|
1076
|
+
console.log('');
|
|
1077
|
+
console.log(` ${BEIGE}No ghosttown sessions are currently running.${RESET}`);
|
|
1078
|
+
console.log('');
|
|
1079
|
+
process.exit(0);
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
/**
|
|
1084
|
+
* Start the server in the background using a tmux session
|
|
1085
|
+
* @param {number} port - Port to listen on
|
|
1086
|
+
*/
|
|
1087
|
+
function startServerInBackground(port = 8080) {
|
|
1088
|
+
// Check if tmux is installed
|
|
1089
|
+
if (!checkTmuxInstalled()) {
|
|
1090
|
+
printTmuxInstallHelp();
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
const RESET = '\x1b[0m';
|
|
1094
|
+
const RED = '\x1b[31m';
|
|
1095
|
+
const BOLD_YELLOW = '\x1b[1;93m';
|
|
1096
|
+
const CYAN = '\x1b[36m';
|
|
1097
|
+
const BEIGE = '\x1b[38;2;255;220;150m';
|
|
1098
|
+
|
|
1099
|
+
// Check if server is already running
|
|
1100
|
+
const status = getServerStatus();
|
|
1101
|
+
if (status.running) {
|
|
1102
|
+
console.log('');
|
|
1103
|
+
console.log(` ${RED}Error:${RESET} A server is already running (port ${status.port}).`);
|
|
1104
|
+
console.log('');
|
|
1105
|
+
if (status.port !== port) {
|
|
1106
|
+
console.log(` To switch to port ${port}:`);
|
|
1107
|
+
console.log(` ${BEIGE}gt stop && gt start -p ${port}${RESET}`);
|
|
1108
|
+
} else {
|
|
1109
|
+
console.log(` ${CYAN}To stop:${RESET} ${BEIGE}gt stop${RESET}`);
|
|
1110
|
+
console.log(` ${CYAN}To check:${RESET} ${BEIGE}gt status${RESET}`);
|
|
1111
|
+
}
|
|
1112
|
+
console.log('');
|
|
1113
|
+
process.exit(1);
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
// Check if port is already in use by another process
|
|
1117
|
+
try {
|
|
1118
|
+
execSync(`lsof -i:${port}`, { stdio: 'pipe' });
|
|
1119
|
+
// If lsof succeeds, port is in use
|
|
1120
|
+
console.log('');
|
|
1121
|
+
console.log(` ${RED}Error:${RESET} Port ${port} is already in use.`);
|
|
1122
|
+
console.log('');
|
|
1123
|
+
console.log(` Try a different port: ${BEIGE}gt start -p <port>${RESET}`);
|
|
1124
|
+
console.log('');
|
|
1125
|
+
process.exit(1);
|
|
1126
|
+
} catch (e) {
|
|
1127
|
+
// lsof returns non-zero if port is free - this is what we want
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
try {
|
|
1131
|
+
// Get the path to the gt executable
|
|
1132
|
+
const gtPath = process.argv[1];
|
|
1133
|
+
|
|
1134
|
+
// Create a detached tmux session for the server
|
|
1135
|
+
execSync(`tmux new-session -d -s ${SERVER_SESSION_NAME} -x 200 -y 50`, {
|
|
1136
|
+
stdio: 'pipe',
|
|
1137
|
+
});
|
|
1138
|
+
|
|
1139
|
+
// Set the port environment variable in the tmux session
|
|
1140
|
+
execSync(`tmux set-environment -t ${SERVER_SESSION_NAME} GHOSTTOWN_PORT ${port}`, {
|
|
1141
|
+
stdio: 'pipe',
|
|
1142
|
+
});
|
|
1143
|
+
|
|
1144
|
+
// Disable status bar for cleaner look
|
|
1145
|
+
execSync(`tmux set-option -t ${SERVER_SESSION_NAME} status off`, { stdio: 'pipe' });
|
|
1146
|
+
|
|
1147
|
+
// Run the server with background mode flag
|
|
1148
|
+
execSync(
|
|
1149
|
+
`tmux send-keys -t ${SERVER_SESSION_NAME} 'node "${gtPath}" --server-background -p ${port}' Enter`,
|
|
1150
|
+
{ stdio: 'pipe' }
|
|
1151
|
+
);
|
|
1152
|
+
|
|
1153
|
+
// Get local IPs for display
|
|
1154
|
+
const localIPs = getLocalIPs();
|
|
1155
|
+
|
|
1156
|
+
console.log('');
|
|
1157
|
+
console.log(` ${BOLD_YELLOW}Ghosttown server started!${RESET}`);
|
|
1158
|
+
console.log('');
|
|
1159
|
+
console.log(` ${CYAN}Open:${RESET} ${BEIGE}http://localhost:${port}${RESET}`);
|
|
1160
|
+
if (localIPs.length > 0) {
|
|
1161
|
+
console.log(` ${CYAN}Network:${RESET} ${BEIGE}http://${localIPs[0]}:${port}${RESET}`);
|
|
1162
|
+
}
|
|
1163
|
+
console.log('');
|
|
1164
|
+
console.log(` ${CYAN}To stop:${RESET} ${BEIGE}gt stop${RESET}`);
|
|
1165
|
+
console.log(` ${CYAN}To check:${RESET} ${BEIGE}gt status${RESET}`);
|
|
1166
|
+
console.log('');
|
|
1167
|
+
} catch (err) {
|
|
1168
|
+
console.log('');
|
|
1169
|
+
console.log(` ${RED}Error:${RESET} Failed to start server.`);
|
|
1170
|
+
console.log(` ${err.message}`);
|
|
1171
|
+
console.log('');
|
|
1172
|
+
process.exit(1);
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
process.exit(0);
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
/**
|
|
1179
|
+
* Stop the background server
|
|
1180
|
+
*/
|
|
1181
|
+
function stopServer() {
|
|
1182
|
+
// Check if tmux is installed
|
|
1183
|
+
if (!checkTmuxInstalled()) {
|
|
1184
|
+
printTmuxInstallHelp();
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
const RESET = '\x1b[0m';
|
|
1188
|
+
const RED = '\x1b[31m';
|
|
1189
|
+
const BOLD_YELLOW = '\x1b[1;93m';
|
|
1190
|
+
const BEIGE = '\x1b[38;2;255;220;150m';
|
|
1191
|
+
|
|
1192
|
+
const status = getServerStatus();
|
|
1193
|
+
|
|
1194
|
+
if (!status.running) {
|
|
1195
|
+
console.log('');
|
|
1196
|
+
console.log(` ${BEIGE}No server is currently running.${RESET}`);
|
|
1197
|
+
console.log('');
|
|
1198
|
+
console.log(' To start: gt start');
|
|
1199
|
+
console.log('');
|
|
1200
|
+
process.exit(0);
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
try {
|
|
1204
|
+
execSync(`tmux kill-session -t ${SERVER_SESSION_NAME}`, { stdio: 'pipe' });
|
|
1205
|
+
|
|
1206
|
+
console.log('');
|
|
1207
|
+
console.log(` ${BOLD_YELLOW}Server stopped.${RESET}`);
|
|
1208
|
+
console.log('');
|
|
1209
|
+
} catch (err) {
|
|
1210
|
+
console.log('');
|
|
1211
|
+
console.log(` ${RED}Error:${RESET} Failed to stop server.`);
|
|
1212
|
+
console.log('');
|
|
1213
|
+
process.exit(1);
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
process.exit(0);
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
/**
|
|
1220
|
+
* Show server status
|
|
1221
|
+
*/
|
|
1222
|
+
function showServerStatus() {
|
|
1223
|
+
// Check if tmux is installed
|
|
1224
|
+
if (!checkTmuxInstalled()) {
|
|
1225
|
+
printTmuxInstallHelp();
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
const RESET = '\x1b[0m';
|
|
1229
|
+
const CYAN = '\x1b[36m';
|
|
1230
|
+
const BEIGE = '\x1b[38;2;255;220;150m';
|
|
1231
|
+
const GREEN = '\x1b[32m';
|
|
1232
|
+
const DIM = '\x1b[2m';
|
|
1233
|
+
|
|
1234
|
+
const status = getServerStatus();
|
|
1235
|
+
|
|
1236
|
+
console.log('');
|
|
1237
|
+
|
|
1238
|
+
if (status.running) {
|
|
1239
|
+
const localIPs = getLocalIPs();
|
|
1240
|
+
const port = status.port || 8080;
|
|
1241
|
+
|
|
1242
|
+
console.log(` ${GREEN}Server is running${RESET}`);
|
|
1243
|
+
console.log('');
|
|
1244
|
+
console.log(` ${CYAN}Open:${RESET} ${BEIGE}http://localhost:${port}${RESET}`);
|
|
1245
|
+
if (localIPs.length > 0) {
|
|
1246
|
+
console.log(` ${CYAN}Network:${RESET} ${BEIGE}http://${localIPs[0]}:${port}${RESET}`);
|
|
1247
|
+
}
|
|
1248
|
+
console.log('');
|
|
1249
|
+
console.log(` ${DIM}To stop: gt stop${RESET}`);
|
|
1250
|
+
} else {
|
|
1251
|
+
console.log(` ${DIM}Server is not running.${RESET}`);
|
|
1252
|
+
console.log('');
|
|
1253
|
+
console.log(' To start: gt start');
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
console.log('');
|
|
1257
|
+
process.exit(0);
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
/**
|
|
1261
|
+
* Print sessions without exiting (for use in error messages)
|
|
1262
|
+
*/
|
|
1263
|
+
function listSessionsInline() {
|
|
1264
|
+
const RESET = '\x1b[0m';
|
|
1265
|
+
const CYAN = '\x1b[36m';
|
|
1266
|
+
|
|
1267
|
+
try {
|
|
1268
|
+
const output = execSync(
|
|
1269
|
+
'tmux list-sessions -F "#{session_name}|#{session_activity}|#{session_attached}|#{session_windows}"',
|
|
1270
|
+
{
|
|
1271
|
+
encoding: 'utf8',
|
|
1272
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
1273
|
+
}
|
|
1274
|
+
);
|
|
1275
|
+
|
|
1276
|
+
const sessions = output
|
|
1277
|
+
.split('\n')
|
|
1278
|
+
.filter((line) => line.startsWith('gt-'))
|
|
1279
|
+
.map((line) => {
|
|
1280
|
+
const [fullName, activity, attached, windows] = line.split('|');
|
|
1281
|
+
const parsed = parseSessionName(fullName);
|
|
1282
|
+
return {
|
|
1283
|
+
displayName: parsed ? parsed.displayName : fullName.replace('gt-', ''),
|
|
1284
|
+
fullName,
|
|
1285
|
+
activity: new Date(Number.parseInt(activity, 10) * 1000),
|
|
1286
|
+
attached: attached === '1',
|
|
1287
|
+
windows: Number.parseInt(windows, 10),
|
|
1288
|
+
};
|
|
1289
|
+
});
|
|
1290
|
+
|
|
1291
|
+
if (sessions.length === 0) {
|
|
1292
|
+
console.log(' No ghosttown sessions are currently running.');
|
|
1293
|
+
return;
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
// Sort by last activity (most recent first)
|
|
1297
|
+
sessions.sort((a, b) => b.activity.getTime() - a.activity.getTime());
|
|
1298
|
+
|
|
1299
|
+
console.log(` ${CYAN}Available sessions:${RESET}`);
|
|
1300
|
+
for (const session of sessions) {
|
|
1301
|
+
const status = session.attached ? 'attached' : 'detached';
|
|
1302
|
+
console.log(` ${session.displayName} (${status})`);
|
|
1303
|
+
}
|
|
1304
|
+
} catch (err) {
|
|
1305
|
+
console.log(' No ghosttown sessions are currently running.');
|
|
1306
|
+
}
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
/**
|
|
1310
|
+
* Find a ghosttown session by display name
|
|
1311
|
+
* Returns the parsed session info or null if not found
|
|
1312
|
+
*/
|
|
1313
|
+
function findSessionByDisplayName(displayName) {
|
|
1314
|
+
try {
|
|
1315
|
+
const output = execSync('tmux list-sessions -F "#{session_name}"', {
|
|
1316
|
+
encoding: 'utf8',
|
|
1317
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
1318
|
+
});
|
|
1319
|
+
const sessions = output.split('\n').filter((s) => s.trim() && s.startsWith('gt-'));
|
|
1320
|
+
for (const fullName of sessions) {
|
|
1321
|
+
const parsed = parseSessionName(fullName);
|
|
1322
|
+
if (parsed && parsed.displayName === displayName) {
|
|
1323
|
+
return parsed;
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
return null;
|
|
1327
|
+
} catch (err) {
|
|
1328
|
+
return null;
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
/**
|
|
1333
|
+
* Rename a ghosttown session
|
|
1334
|
+
* Usage:
|
|
1335
|
+
* gt rename <existing> <new-name> - rename from outside session
|
|
1336
|
+
* gt rename <new-name> - rename current session (inside a gt session)
|
|
1337
|
+
*/
|
|
1338
|
+
function renameSession(renameArgs) {
|
|
1339
|
+
// Check if tmux is installed
|
|
1340
|
+
if (!checkTmuxInstalled()) {
|
|
1341
|
+
printTmuxInstallHelp();
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
const RESET = '\x1b[0m';
|
|
1345
|
+
const RED = '\x1b[31m';
|
|
1346
|
+
const BOLD_YELLOW = '\x1b[1;93m';
|
|
1347
|
+
|
|
1348
|
+
let oldFullName, oldDisplayName, stableId, newDisplayName;
|
|
1349
|
+
|
|
1350
|
+
if (renameArgs.length === 1) {
|
|
1351
|
+
// Inside session: gt rename <new-name>
|
|
1352
|
+
if (!isInsideGhosttownSession()) {
|
|
1353
|
+
console.log('');
|
|
1354
|
+
console.log(` ${RED}Error:${RESET} Not inside a ghosttown session.`);
|
|
1355
|
+
console.log('');
|
|
1356
|
+
console.log(' Usage: gt rename <existing-session> <new-name>');
|
|
1357
|
+
console.log(' gt rename <new-name> (when inside a session)');
|
|
1358
|
+
console.log('');
|
|
1359
|
+
listSessionsInline();
|
|
1360
|
+
console.log('');
|
|
1361
|
+
process.exit(1);
|
|
1362
|
+
}
|
|
1363
|
+
oldFullName = getCurrentTmuxSessionName();
|
|
1364
|
+
const parsed = parseSessionName(oldFullName);
|
|
1365
|
+
oldDisplayName = parsed ? parsed.displayName : oldFullName.replace('gt-', '');
|
|
1366
|
+
stableId = parsed ? parsed.stableId : null;
|
|
1367
|
+
newDisplayName = renameArgs[0];
|
|
1368
|
+
} else if (renameArgs.length === 2) {
|
|
1369
|
+
// Outside: gt rename <existing> <new-name>
|
|
1370
|
+
// User provides display name, we need to find the full name
|
|
1371
|
+
const searchName = renameArgs[0];
|
|
1372
|
+
const found = findSessionByDisplayName(searchName);
|
|
1373
|
+
if (!found) {
|
|
1374
|
+
console.log('');
|
|
1375
|
+
console.log(` ${RED}Error:${RESET} Session '${searchName}' not found.`);
|
|
1376
|
+
console.log('');
|
|
1377
|
+
listSessionsInline();
|
|
1378
|
+
console.log('');
|
|
1379
|
+
process.exit(1);
|
|
1380
|
+
}
|
|
1381
|
+
oldFullName = found.fullName;
|
|
1382
|
+
oldDisplayName = found.displayName;
|
|
1383
|
+
stableId = found.stableId;
|
|
1384
|
+
newDisplayName = renameArgs[1];
|
|
1385
|
+
} else {
|
|
1386
|
+
console.log('');
|
|
1387
|
+
console.log(` ${RED}Error:${RESET} Invalid arguments.`);
|
|
1388
|
+
console.log('');
|
|
1389
|
+
console.log(' Usage: gt rename <existing-session> <new-name>');
|
|
1390
|
+
console.log(' gt rename <new-name> (when inside a session)');
|
|
1391
|
+
console.log('');
|
|
1392
|
+
process.exit(1);
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
// Validate new name
|
|
1396
|
+
const validation = validateSessionName(newDisplayName);
|
|
1397
|
+
if (!validation.valid) {
|
|
1398
|
+
console.log('');
|
|
1399
|
+
console.log(` ${RED}Error:${RESET} ${validation.error}`);
|
|
1400
|
+
console.log('');
|
|
1401
|
+
listSessionsInline();
|
|
1402
|
+
console.log('');
|
|
1403
|
+
process.exit(1);
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
// Check new display name doesn't conflict
|
|
1407
|
+
if (displayNameExists(newDisplayName)) {
|
|
1408
|
+
console.log('');
|
|
1409
|
+
console.log(` ${RED}Error:${RESET} Session '${newDisplayName}' already exists.`);
|
|
1410
|
+
console.log('');
|
|
1411
|
+
listSessionsInline();
|
|
1412
|
+
console.log('');
|
|
1413
|
+
process.exit(1);
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
// Build new full name, preserving stable ID if present
|
|
1417
|
+
const newFullName = stableId ? `gt-${stableId}-${newDisplayName}` : `gt-${newDisplayName}`;
|
|
1418
|
+
|
|
1419
|
+
// Perform rename
|
|
1420
|
+
try {
|
|
1421
|
+
execSync(`tmux rename-session -t "${oldFullName}" "${newFullName}"`, { stdio: 'pipe' });
|
|
1422
|
+
console.log('');
|
|
1423
|
+
console.log(` ${BOLD_YELLOW}Session renamed: ${oldDisplayName} -> ${newDisplayName}${RESET}`);
|
|
1424
|
+
console.log('');
|
|
1425
|
+
process.exit(0);
|
|
1426
|
+
} catch (err) {
|
|
1427
|
+
console.log('');
|
|
1428
|
+
console.log(` ${RED}Error:${RESET} Failed to rename session.`);
|
|
1429
|
+
console.log('');
|
|
1430
|
+
process.exit(1);
|
|
1431
|
+
}
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
// ============================================================================
|
|
1435
|
+
// Parse CLI arguments
|
|
1436
|
+
// ============================================================================
|
|
1437
|
+
|
|
1438
|
+
function parseArgs(argv) {
|
|
1439
|
+
const args = argv.slice(2);
|
|
1440
|
+
let port = null;
|
|
1441
|
+
let command = null;
|
|
1442
|
+
let handled = false;
|
|
1443
|
+
let sessionName = null;
|
|
1444
|
+
let useHttp = false;
|
|
1445
|
+
let serverBackgroundMode = false;
|
|
1446
|
+
|
|
1447
|
+
for (let i = 0; i < args.length; i++) {
|
|
1448
|
+
const arg = args[i];
|
|
1449
|
+
|
|
1450
|
+
if (arg === '-h' || arg === '--help') {
|
|
1451
|
+
console.log(`
|
|
1452
|
+
Usage: ghosttown [options] [command]
|
|
1453
|
+
|
|
1454
|
+
Options:
|
|
1455
|
+
-p, --port <port> Port to listen on (default: 8080, or PORT env var)
|
|
1456
|
+
-n, --name <name> Give the session a custom name (use with a command)
|
|
1457
|
+
--http Use HTTP instead of HTTPS (default is HTTPS)
|
|
1458
|
+
-k, --kill [session] Kill a session (current if inside one, or specify by name/ID)
|
|
1459
|
+
-ka, --kill-all Kill all ghosttown sessions
|
|
1460
|
+
-v, --version Show version number
|
|
1461
|
+
-h, --help Show this help message
|
|
1462
|
+
|
|
1463
|
+
Commands:
|
|
1464
|
+
start [-p <port>] Start the server in the background (default port: 8080)
|
|
1465
|
+
stop Stop the background server
|
|
1466
|
+
status Show server status and URLs
|
|
1467
|
+
list List all ghosttown tmux sessions
|
|
1468
|
+
attach <name|id> Attach to a ghosttown session by name or ID
|
|
1469
|
+
detach Detach from current ghosttown session
|
|
1470
|
+
rename [old] <new> Rename a session (current session if only one arg given)
|
|
1471
|
+
update Update ghosttown to the latest version
|
|
1472
|
+
<command> Run command in a new tmux session
|
|
1473
|
+
|
|
1474
|
+
Examples:
|
|
1475
|
+
ghosttown start Start the server in the background
|
|
1476
|
+
ghosttown start -p 3000 Start on port 3000
|
|
1477
|
+
ghosttown stop Stop the background server
|
|
1478
|
+
ghosttown status Check if server is running
|
|
1479
|
+
ghosttown Start the web terminal server (foreground)
|
|
1480
|
+
ghosttown -p 3000 Start the server on port 3000 (foreground)
|
|
1481
|
+
ghosttown --http Use plain HTTP instead of HTTPS
|
|
1482
|
+
ghosttown list List all ghosttown sessions
|
|
1483
|
+
ghosttown attach 1 Attach to session gt-1
|
|
1484
|
+
ghosttown attach my-project Attach to session gt-my-project
|
|
1485
|
+
ghosttown detach Detach from current session
|
|
1486
|
+
ghosttown rename my-project Rename current session to 'my-project'
|
|
1487
|
+
ghosttown rename 1 my-project Rename session gt-1 to gt-my-project
|
|
1488
|
+
ghosttown update Update to the latest version
|
|
1489
|
+
ghosttown -k Kill current session (when inside one)
|
|
1490
|
+
ghosttown -k my-project Kill session gt-my-project
|
|
1491
|
+
ghosttown -ka Kill all ghosttown sessions
|
|
1492
|
+
ghosttown -n my-project vim Run vim in session named 'gt-my-project'
|
|
1493
|
+
ghosttown vim Run vim in auto-named session (gt-1, gt-2, etc.)
|
|
1494
|
+
ghosttown "npm run dev" Run npm in a new tmux session
|
|
1495
|
+
|
|
1496
|
+
Aliases:
|
|
1497
|
+
This CLI can also be invoked as 'gt' or 'ght'.
|
|
1498
|
+
`);
|
|
1499
|
+
handled = true;
|
|
1500
|
+
break;
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1503
|
+
// Handle version flag
|
|
1504
|
+
if (arg === '-v' || arg === '--version') {
|
|
1505
|
+
console.log(VERSION);
|
|
1506
|
+
handled = true;
|
|
1507
|
+
break;
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
// Handle list command
|
|
1511
|
+
if (arg === 'list') {
|
|
1512
|
+
handled = true;
|
|
1513
|
+
listSessions();
|
|
1514
|
+
// listSessions exits, so this won't be reached
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
// Handle detach command
|
|
638
1518
|
else if (arg === 'detach') {
|
|
639
1519
|
handled = true;
|
|
640
1520
|
detachFromTmux();
|
|
@@ -652,8 +1532,8 @@ Aliases:
|
|
|
652
1532
|
else if (arg === 'attach') {
|
|
653
1533
|
const sessionArg = args[i + 1];
|
|
654
1534
|
if (!sessionArg) {
|
|
655
|
-
console.error('Error: attach requires a session name');
|
|
656
|
-
console.error('Usage:
|
|
1535
|
+
console.error('Error: attach requires a session name or ID');
|
|
1536
|
+
console.error('Usage: gt attach <name|id>');
|
|
657
1537
|
handled = true;
|
|
658
1538
|
break;
|
|
659
1539
|
}
|
|
@@ -662,6 +1542,14 @@ Aliases:
|
|
|
662
1542
|
// attachToSession exits, so this won't be reached
|
|
663
1543
|
}
|
|
664
1544
|
|
|
1545
|
+
// Handle rename command
|
|
1546
|
+
else if (arg === 'rename') {
|
|
1547
|
+
const renameArgs = args.slice(i + 1);
|
|
1548
|
+
handled = true;
|
|
1549
|
+
renameSession(renameArgs);
|
|
1550
|
+
// renameSession exits, so this won't be reached
|
|
1551
|
+
}
|
|
1552
|
+
|
|
665
1553
|
// Handle kill command
|
|
666
1554
|
else if (arg === '-k' || arg === '--kill') {
|
|
667
1555
|
const nextArg = args[i + 1];
|
|
@@ -670,6 +1558,51 @@ Aliases:
|
|
|
670
1558
|
handled = true;
|
|
671
1559
|
killSession(sessionArg);
|
|
672
1560
|
// killSession exits, so this won't be reached
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1563
|
+
// Handle kill-all command
|
|
1564
|
+
else if (arg === '-ka' || arg === '--kill-all') {
|
|
1565
|
+
handled = true;
|
|
1566
|
+
killAllSessions();
|
|
1567
|
+
// killAllSessions exits, so this won't be reached
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
// Handle start command
|
|
1571
|
+
else if (arg === 'start') {
|
|
1572
|
+
// Check for -p flag after 'start'
|
|
1573
|
+
let startPort = 8080;
|
|
1574
|
+
if (args[i + 1] === '-p' || args[i + 1] === '--port') {
|
|
1575
|
+
const portArg = args[i + 2];
|
|
1576
|
+
if (portArg) {
|
|
1577
|
+
startPort = Number.parseInt(portArg, 10);
|
|
1578
|
+
if (Number.isNaN(startPort) || startPort < 1 || startPort > 65535) {
|
|
1579
|
+
console.error(`Error: Invalid port number: ${portArg}`);
|
|
1580
|
+
process.exit(1);
|
|
1581
|
+
}
|
|
1582
|
+
}
|
|
1583
|
+
}
|
|
1584
|
+
handled = true;
|
|
1585
|
+
startServerInBackground(startPort);
|
|
1586
|
+
// startServerInBackground exits, so this won't be reached
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1589
|
+
// Handle stop command
|
|
1590
|
+
else if (arg === 'stop') {
|
|
1591
|
+
handled = true;
|
|
1592
|
+
stopServer();
|
|
1593
|
+
// stopServer exits, so this won't be reached
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1596
|
+
// Handle status command
|
|
1597
|
+
else if (arg === 'status') {
|
|
1598
|
+
handled = true;
|
|
1599
|
+
showServerStatus();
|
|
1600
|
+
// showServerStatus exits, so this won't be reached
|
|
1601
|
+
}
|
|
1602
|
+
|
|
1603
|
+
// Handle hidden --server-background flag (used internally by gt start)
|
|
1604
|
+
else if (arg === '--server-background') {
|
|
1605
|
+
serverBackgroundMode = true;
|
|
673
1606
|
} else if (arg === '-p' || arg === '--port') {
|
|
674
1607
|
const nextArg = args[i + 1];
|
|
675
1608
|
if (!nextArg || nextArg.startsWith('-')) {
|
|
@@ -684,6 +1617,22 @@ Aliases:
|
|
|
684
1617
|
i++; // Skip the next argument since we consumed it
|
|
685
1618
|
}
|
|
686
1619
|
|
|
1620
|
+
// Handle name flag
|
|
1621
|
+
else if (arg === '-n' || arg === '--name') {
|
|
1622
|
+
const nextArg = args[i + 1];
|
|
1623
|
+
if (!nextArg || nextArg.startsWith('-')) {
|
|
1624
|
+
console.error(`Error: ${arg} requires a session name`);
|
|
1625
|
+
process.exit(1);
|
|
1626
|
+
}
|
|
1627
|
+
sessionName = nextArg;
|
|
1628
|
+
i++; // Skip the next argument since we consumed it
|
|
1629
|
+
}
|
|
1630
|
+
|
|
1631
|
+
// Handle http flag (disables HTTPS)
|
|
1632
|
+
else if (arg === '--http') {
|
|
1633
|
+
useHttp = true;
|
|
1634
|
+
}
|
|
1635
|
+
|
|
687
1636
|
// First non-flag argument starts the command
|
|
688
1637
|
// Capture it and all remaining arguments as the command
|
|
689
1638
|
else if (!arg.startsWith('-')) {
|
|
@@ -692,7 +1641,7 @@ Aliases:
|
|
|
692
1641
|
}
|
|
693
1642
|
}
|
|
694
1643
|
|
|
695
|
-
return { port, command, handled };
|
|
1644
|
+
return { port, command, handled, sessionName, useHttp, serverBackgroundMode };
|
|
696
1645
|
}
|
|
697
1646
|
|
|
698
1647
|
// ============================================================================
|
|
@@ -701,6 +1650,46 @@ Aliases:
|
|
|
701
1650
|
|
|
702
1651
|
function startWebServer(cliArgs) {
|
|
703
1652
|
const HTTP_PORT = cliArgs.port || process.env.PORT || 8080;
|
|
1653
|
+
const USE_HTTPS = !cliArgs.useHttp;
|
|
1654
|
+
const backgroundMode = cliArgs.serverBackgroundMode || false;
|
|
1655
|
+
|
|
1656
|
+
// Generate self-signed certificate for HTTPS
|
|
1657
|
+
function generateSelfSignedCert() {
|
|
1658
|
+
try {
|
|
1659
|
+
const certDir = path.join(homedir(), '.ghosttown');
|
|
1660
|
+
const keyPath = path.join(certDir, 'server.key');
|
|
1661
|
+
const certPath = path.join(certDir, 'server.crt');
|
|
1662
|
+
|
|
1663
|
+
// Check if certs already exist
|
|
1664
|
+
if (fs.existsSync(keyPath) && fs.existsSync(certPath)) {
|
|
1665
|
+
return {
|
|
1666
|
+
key: fs.readFileSync(keyPath),
|
|
1667
|
+
cert: fs.readFileSync(certPath),
|
|
1668
|
+
};
|
|
1669
|
+
}
|
|
1670
|
+
|
|
1671
|
+
// Create cert directory
|
|
1672
|
+
if (!fs.existsSync(certDir)) {
|
|
1673
|
+
fs.mkdirSync(certDir, { recursive: true });
|
|
1674
|
+
}
|
|
1675
|
+
|
|
1676
|
+
// Generate using openssl
|
|
1677
|
+
console.log('Generating self-signed certificate...');
|
|
1678
|
+
execSync(
|
|
1679
|
+
`openssl req -x509 -newkey rsa:2048 -keyout "${keyPath}" -out "${certPath}" -days 365 -nodes -subj "/CN=localhost"`,
|
|
1680
|
+
{ stdio: 'pipe' }
|
|
1681
|
+
);
|
|
1682
|
+
|
|
1683
|
+
return {
|
|
1684
|
+
key: fs.readFileSync(keyPath),
|
|
1685
|
+
cert: fs.readFileSync(certPath),
|
|
1686
|
+
};
|
|
1687
|
+
} catch (err) {
|
|
1688
|
+
console.error('Failed to generate SSL certificate.');
|
|
1689
|
+
console.error('Make sure openssl is installed: brew install openssl');
|
|
1690
|
+
process.exit(1);
|
|
1691
|
+
}
|
|
1692
|
+
}
|
|
704
1693
|
|
|
705
1694
|
// ============================================================================
|
|
706
1695
|
// Locate ghosttown assets
|
|
@@ -771,6 +1760,208 @@ function startWebServer(cliArgs) {
|
|
|
771
1760
|
height: var(--vvh);
|
|
772
1761
|
}
|
|
773
1762
|
|
|
1763
|
+
/* Session List View Styles */
|
|
1764
|
+
.session-list-view {
|
|
1765
|
+
display: none;
|
|
1766
|
+
width: 100%;
|
|
1767
|
+
height: 100%;
|
|
1768
|
+
justify-content: center;
|
|
1769
|
+
align-items: center;
|
|
1770
|
+
padding: 8px;
|
|
1771
|
+
box-sizing: border-box;
|
|
1772
|
+
}
|
|
1773
|
+
|
|
1774
|
+
.session-list-view.active {
|
|
1775
|
+
display: flex;
|
|
1776
|
+
}
|
|
1777
|
+
|
|
1778
|
+
.session-card {
|
|
1779
|
+
background: #1e2127;
|
|
1780
|
+
border-radius: 12px;
|
|
1781
|
+
padding: 24px;
|
|
1782
|
+
width: 100%;
|
|
1783
|
+
max-width: 600px;
|
|
1784
|
+
box-sizing: border-box;
|
|
1785
|
+
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
|
|
1786
|
+
}
|
|
1787
|
+
|
|
1788
|
+
.session-card h1 {
|
|
1789
|
+
color: #e5e5e5;
|
|
1790
|
+
font-size: 24px;
|
|
1791
|
+
font-weight: 600;
|
|
1792
|
+
margin-bottom: 20px;
|
|
1793
|
+
text-align: center;
|
|
1794
|
+
}
|
|
1795
|
+
|
|
1796
|
+
.session-table {
|
|
1797
|
+
width: 100%;
|
|
1798
|
+
border-collapse: collapse;
|
|
1799
|
+
margin-bottom: 20px;
|
|
1800
|
+
}
|
|
1801
|
+
|
|
1802
|
+
.session-table th {
|
|
1803
|
+
color: #888;
|
|
1804
|
+
font-size: 11px;
|
|
1805
|
+
font-weight: 500;
|
|
1806
|
+
text-transform: uppercase;
|
|
1807
|
+
letter-spacing: 0.5px;
|
|
1808
|
+
text-align: left;
|
|
1809
|
+
padding: 8px 12px;
|
|
1810
|
+
border-bottom: 1px solid #3a3f4b;
|
|
1811
|
+
}
|
|
1812
|
+
|
|
1813
|
+
.session-table td {
|
|
1814
|
+
color: #d4d4d4;
|
|
1815
|
+
font-size: 13px;
|
|
1816
|
+
padding: 12px;
|
|
1817
|
+
border-bottom: 1px solid #2a2f38;
|
|
1818
|
+
}
|
|
1819
|
+
|
|
1820
|
+
.session-table tr.session-row {
|
|
1821
|
+
transition: background 0.15s;
|
|
1822
|
+
}
|
|
1823
|
+
|
|
1824
|
+
.session-table tr.session-row:hover {
|
|
1825
|
+
background: #2a2f38;
|
|
1826
|
+
}
|
|
1827
|
+
|
|
1828
|
+
.session-table tr.session-row td {
|
|
1829
|
+
padding: 0;
|
|
1830
|
+
}
|
|
1831
|
+
|
|
1832
|
+
.session-table tr.session-row td a {
|
|
1833
|
+
display: block;
|
|
1834
|
+
padding: 12px;
|
|
1835
|
+
color: inherit;
|
|
1836
|
+
text-decoration: none;
|
|
1837
|
+
}
|
|
1838
|
+
|
|
1839
|
+
.session-table tr.session-row td.actions-cell {
|
|
1840
|
+
padding: 12px 8px;
|
|
1841
|
+
width: 40px;
|
|
1842
|
+
}
|
|
1843
|
+
|
|
1844
|
+
.copy-btn {
|
|
1845
|
+
background: transparent;
|
|
1846
|
+
border: 1px solid #3a3f48;
|
|
1847
|
+
border-radius: 4px;
|
|
1848
|
+
color: #8a8f98;
|
|
1849
|
+
cursor: pointer;
|
|
1850
|
+
padding: 4px 8px;
|
|
1851
|
+
font-size: 12px;
|
|
1852
|
+
transition: all 0.15s;
|
|
1853
|
+
min-width: 60px;
|
|
1854
|
+
}
|
|
1855
|
+
|
|
1856
|
+
.copy-btn:hover {
|
|
1857
|
+
background: #3a3f48;
|
|
1858
|
+
color: #fff;
|
|
1859
|
+
}
|
|
1860
|
+
|
|
1861
|
+
.copy-btn.copied {
|
|
1862
|
+
color: #4ade80;
|
|
1863
|
+
border-color: #4ade80;
|
|
1864
|
+
}
|
|
1865
|
+
|
|
1866
|
+
.session-status {
|
|
1867
|
+
display: inline-block;
|
|
1868
|
+
padding: 2px 8px;
|
|
1869
|
+
border-radius: 4px;
|
|
1870
|
+
font-size: 11px;
|
|
1871
|
+
font-weight: 500;
|
|
1872
|
+
}
|
|
1873
|
+
|
|
1874
|
+
.session-status.connected {
|
|
1875
|
+
background: rgba(39, 201, 63, 0.2);
|
|
1876
|
+
color: #27c93f;
|
|
1877
|
+
}
|
|
1878
|
+
|
|
1879
|
+
.session-status.disconnected {
|
|
1880
|
+
background: rgba(255, 189, 46, 0.2);
|
|
1881
|
+
color: #ffbd2e;
|
|
1882
|
+
}
|
|
1883
|
+
|
|
1884
|
+
.empty-state {
|
|
1885
|
+
text-align: center;
|
|
1886
|
+
padding: 40px 20px;
|
|
1887
|
+
color: #888;
|
|
1888
|
+
}
|
|
1889
|
+
|
|
1890
|
+
.empty-state p {
|
|
1891
|
+
margin-bottom: 20px;
|
|
1892
|
+
font-size: 14px;
|
|
1893
|
+
}
|
|
1894
|
+
|
|
1895
|
+
.create-btn {
|
|
1896
|
+
background: #3a7afe;
|
|
1897
|
+
color: white;
|
|
1898
|
+
border: none;
|
|
1899
|
+
padding: 10px 20px;
|
|
1900
|
+
border-radius: 6px;
|
|
1901
|
+
font-size: 14px;
|
|
1902
|
+
font-weight: 500;
|
|
1903
|
+
cursor: pointer;
|
|
1904
|
+
transition: background 0.15s;
|
|
1905
|
+
display: block;
|
|
1906
|
+
width: 100%;
|
|
1907
|
+
}
|
|
1908
|
+
|
|
1909
|
+
.create-btn:hover {
|
|
1910
|
+
background: #2563eb;
|
|
1911
|
+
}
|
|
1912
|
+
|
|
1913
|
+
.button-row {
|
|
1914
|
+
display: flex;
|
|
1915
|
+
gap: 12px;
|
|
1916
|
+
}
|
|
1917
|
+
|
|
1918
|
+
.button-row .create-btn {
|
|
1919
|
+
flex: 1;
|
|
1920
|
+
}
|
|
1921
|
+
|
|
1922
|
+
.logout-btn {
|
|
1923
|
+
background: transparent;
|
|
1924
|
+
color: #888;
|
|
1925
|
+
border: 1px solid #3a3f48;
|
|
1926
|
+
padding: 10px 16px;
|
|
1927
|
+
border-radius: 6px;
|
|
1928
|
+
font-size: 14px;
|
|
1929
|
+
font-weight: 500;
|
|
1930
|
+
cursor: pointer;
|
|
1931
|
+
transition: all 0.15s;
|
|
1932
|
+
}
|
|
1933
|
+
|
|
1934
|
+
.logout-btn:hover {
|
|
1935
|
+
background: #3a3f48;
|
|
1936
|
+
color: #fff;
|
|
1937
|
+
}
|
|
1938
|
+
|
|
1939
|
+
.error-message {
|
|
1940
|
+
background: rgba(255, 95, 86, 0.1);
|
|
1941
|
+
border: 1px solid rgba(255, 95, 86, 0.3);
|
|
1942
|
+
border-radius: 6px;
|
|
1943
|
+
padding: 12px 16px;
|
|
1944
|
+
margin-bottom: 16px;
|
|
1945
|
+
color: #ff5f56;
|
|
1946
|
+
font-size: 13px;
|
|
1947
|
+
display: none;
|
|
1948
|
+
}
|
|
1949
|
+
|
|
1950
|
+
.error-message.visible {
|
|
1951
|
+
display: block;
|
|
1952
|
+
}
|
|
1953
|
+
|
|
1954
|
+
/* Terminal View Styles */
|
|
1955
|
+
.terminal-view {
|
|
1956
|
+
display: none;
|
|
1957
|
+
width: 100%;
|
|
1958
|
+
height: 100%;
|
|
1959
|
+
}
|
|
1960
|
+
|
|
1961
|
+
.terminal-view.active {
|
|
1962
|
+
display: block;
|
|
1963
|
+
}
|
|
1964
|
+
|
|
774
1965
|
.terminal-window {
|
|
775
1966
|
width: 100%;
|
|
776
1967
|
height: 100%;
|
|
@@ -833,6 +2024,21 @@ function startWebServer(cliArgs) {
|
|
|
833
2024
|
white-space: nowrap;
|
|
834
2025
|
}
|
|
835
2026
|
|
|
2027
|
+
.back-link {
|
|
2028
|
+
color: #888;
|
|
2029
|
+
font-size: 12px;
|
|
2030
|
+
text-decoration: none;
|
|
2031
|
+
margin-right: 8px;
|
|
2032
|
+
padding: 4px 8px;
|
|
2033
|
+
border-radius: 4px;
|
|
2034
|
+
transition: background 0.15s, color 0.15s;
|
|
2035
|
+
}
|
|
2036
|
+
|
|
2037
|
+
.back-link:hover {
|
|
2038
|
+
background: #3a4049;
|
|
2039
|
+
color: #e5e5e5;
|
|
2040
|
+
}
|
|
2041
|
+
|
|
836
2042
|
.connection-status {
|
|
837
2043
|
margin-left: auto;
|
|
838
2044
|
font-size: 11px;
|
|
@@ -865,35 +2071,311 @@ function startWebServer(cliArgs) {
|
|
|
865
2071
|
display: block;
|
|
866
2072
|
touch-action: none;
|
|
867
2073
|
}
|
|
2074
|
+
|
|
2075
|
+
.connection-error-overlay {
|
|
2076
|
+
display: none;
|
|
2077
|
+
position: absolute;
|
|
2078
|
+
top: 0;
|
|
2079
|
+
left: 0;
|
|
2080
|
+
right: 0;
|
|
2081
|
+
bottom: 0;
|
|
2082
|
+
background: rgba(41, 44, 52, 0.95);
|
|
2083
|
+
z-index: 100;
|
|
2084
|
+
flex-direction: column;
|
|
2085
|
+
align-items: center;
|
|
2086
|
+
justify-content: center;
|
|
2087
|
+
padding: 20px;
|
|
2088
|
+
text-align: center;
|
|
2089
|
+
}
|
|
2090
|
+
|
|
2091
|
+
.connection-error-overlay.visible {
|
|
2092
|
+
display: flex;
|
|
2093
|
+
}
|
|
2094
|
+
|
|
2095
|
+
.connection-error-overlay h2 {
|
|
2096
|
+
color: #ff5f56;
|
|
2097
|
+
margin-bottom: 16px;
|
|
2098
|
+
font-size: 18px;
|
|
2099
|
+
}
|
|
2100
|
+
|
|
2101
|
+
.connection-error-overlay p {
|
|
2102
|
+
color: #d4d4d4;
|
|
2103
|
+
margin-bottom: 12px;
|
|
2104
|
+
line-height: 1.5;
|
|
2105
|
+
max-width: 300px;
|
|
2106
|
+
}
|
|
2107
|
+
|
|
2108
|
+
.connection-error-overlay a {
|
|
2109
|
+
color: #27c93f;
|
|
2110
|
+
text-decoration: underline;
|
|
2111
|
+
}
|
|
2112
|
+
|
|
2113
|
+
.connection-error-overlay .retry-btn {
|
|
2114
|
+
margin-top: 16px;
|
|
2115
|
+
padding: 8px 24px;
|
|
2116
|
+
background: #27c93f;
|
|
2117
|
+
color: #1e2127;
|
|
2118
|
+
border: none;
|
|
2119
|
+
border-radius: 6px;
|
|
2120
|
+
font-size: 14px;
|
|
2121
|
+
cursor: pointer;
|
|
2122
|
+
}
|
|
868
2123
|
</style>
|
|
869
2124
|
</head>
|
|
870
2125
|
<body>
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
2126
|
+
<!-- Session List View -->
|
|
2127
|
+
<div class="session-list-view" id="session-list-view">
|
|
2128
|
+
<div class="session-card">
|
|
2129
|
+
<h1>Ghost Town</h1>
|
|
2130
|
+
<div class="error-message" id="error-message"></div>
|
|
2131
|
+
<div id="session-list-content">
|
|
2132
|
+
<!-- Populated by JavaScript -->
|
|
2133
|
+
</div>
|
|
2134
|
+
<div class="button-row">
|
|
2135
|
+
<button class="create-btn" id="create-session-btn">Create Session</button>
|
|
2136
|
+
<button class="logout-btn" id="logout-btn">Logout</button>
|
|
877
2137
|
</div>
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
2138
|
+
</div>
|
|
2139
|
+
</div>
|
|
2140
|
+
|
|
2141
|
+
<!-- Terminal View -->
|
|
2142
|
+
<div class="terminal-view" id="terminal-view">
|
|
2143
|
+
<div class="terminal-window">
|
|
2144
|
+
<div class="title-bar">
|
|
2145
|
+
<div class="traffic-lights">
|
|
2146
|
+
<span class="light red"></span>
|
|
2147
|
+
<span class="light yellow"></span>
|
|
2148
|
+
<span class="light green"></span>
|
|
2149
|
+
</div>
|
|
2150
|
+
<a href="/" class="back-link" id="back-link">← Sessions</a>
|
|
2151
|
+
<div class="title">
|
|
2152
|
+
<span id="session-name">ghosttown</span>
|
|
2153
|
+
<span class="title-separator" id="title-separator" style="display: none">•</span>
|
|
2154
|
+
<span class="current-directory" id="current-directory"></span>
|
|
2155
|
+
</div>
|
|
2156
|
+
<div class="connection-status">
|
|
2157
|
+
<span class="connection-dot" id="connection-dot"></span>
|
|
2158
|
+
<span id="connection-text">Disconnected</span>
|
|
2159
|
+
</div>
|
|
882
2160
|
</div>
|
|
883
|
-
<div
|
|
884
|
-
|
|
885
|
-
<
|
|
2161
|
+
<div id="terminal-container"></div>
|
|
2162
|
+
<div class="connection-error-overlay" id="connection-error">
|
|
2163
|
+
<h2>Connection Failed</h2>
|
|
2164
|
+
<p>Unable to connect to the terminal.</p>
|
|
2165
|
+
<p id="connection-error-hint"></p>
|
|
2166
|
+
<button class="retry-btn" onclick="location.reload()">Retry</button>
|
|
886
2167
|
</div>
|
|
887
2168
|
</div>
|
|
888
|
-
<div id="terminal-container"></div>
|
|
889
2169
|
</div>
|
|
890
2170
|
|
|
2171
|
+
<script>
|
|
2172
|
+
// Server tells client if using self-signed certificate (for mobile SSE fallback)
|
|
2173
|
+
window.__SELF_SIGNED_CERT__ = ${USE_HTTPS};
|
|
2174
|
+
</script>
|
|
891
2175
|
<script type="module">
|
|
892
2176
|
import { init, Terminal, FitAddon } from '/dist/ghostty-web.js';
|
|
893
2177
|
|
|
894
2178
|
let term;
|
|
895
2179
|
let ws;
|
|
896
2180
|
let fitAddon;
|
|
2181
|
+
let currentSessionName = null;
|
|
2182
|
+
|
|
2183
|
+
// HTML escape to prevent XSS when inserting user data into templates
|
|
2184
|
+
function escapeHtml(str) {
|
|
2185
|
+
return String(str)
|
|
2186
|
+
.replace(/&/g, '&')
|
|
2187
|
+
.replace(/</g, '<')
|
|
2188
|
+
.replace(/>/g, '>')
|
|
2189
|
+
.replace(/"/g, '"')
|
|
2190
|
+
.replace(/'/g, ''');
|
|
2191
|
+
}
|
|
2192
|
+
|
|
2193
|
+
// Get session info from URL query parameters
|
|
2194
|
+
// Returns { name, stableId } or null
|
|
2195
|
+
function getSessionFromUrl() {
|
|
2196
|
+
const params = new URLSearchParams(window.location.search);
|
|
2197
|
+
const name = params.get('session');
|
|
2198
|
+
const stableId = params.get('id');
|
|
2199
|
+
if (name || stableId) {
|
|
2200
|
+
return { name, stableId };
|
|
2201
|
+
}
|
|
2202
|
+
return null;
|
|
2203
|
+
}
|
|
2204
|
+
|
|
2205
|
+
// Check which view to show
|
|
2206
|
+
async function initApp() {
|
|
2207
|
+
const sessionInfo = getSessionFromUrl();
|
|
2208
|
+
|
|
2209
|
+
if (sessionInfo && sessionInfo.stableId) {
|
|
2210
|
+
// Show terminal view using stable ID
|
|
2211
|
+
showTerminalView(sessionInfo.stableId);
|
|
2212
|
+
} else if (sessionInfo && sessionInfo.name) {
|
|
2213
|
+
// Legacy URL with just name - show terminal view
|
|
2214
|
+
showTerminalView(sessionInfo.name);
|
|
2215
|
+
} else {
|
|
2216
|
+
// Show session list view
|
|
2217
|
+
showSessionListView();
|
|
2218
|
+
}
|
|
2219
|
+
}
|
|
2220
|
+
|
|
2221
|
+
// Show session list view
|
|
2222
|
+
async function showSessionListView() {
|
|
2223
|
+
document.getElementById('session-list-view').classList.add('active');
|
|
2224
|
+
document.getElementById('terminal-view').classList.remove('active');
|
|
2225
|
+
document.title = 'Ghost Town';
|
|
2226
|
+
|
|
2227
|
+
await refreshSessionList();
|
|
2228
|
+
|
|
2229
|
+
// Set up create button
|
|
2230
|
+
document.getElementById('create-session-btn').onclick = createSession;
|
|
2231
|
+
|
|
2232
|
+
// Set up logout button
|
|
2233
|
+
document.getElementById('logout-btn').onclick = logout;
|
|
2234
|
+
}
|
|
2235
|
+
|
|
2236
|
+
// Logout and redirect to login page
|
|
2237
|
+
async function logout() {
|
|
2238
|
+
try {
|
|
2239
|
+
await fetch('/api/logout', { method: 'POST' });
|
|
2240
|
+
} catch (err) {
|
|
2241
|
+
// Ignore errors, redirect anyway
|
|
2242
|
+
}
|
|
2243
|
+
window.location.href = '/login';
|
|
2244
|
+
}
|
|
2245
|
+
|
|
2246
|
+
// Refresh the session list
|
|
2247
|
+
async function refreshSessionList() {
|
|
2248
|
+
try {
|
|
2249
|
+
const response = await fetch('/api/sessions');
|
|
2250
|
+
const data = await response.json();
|
|
2251
|
+
|
|
2252
|
+
const content = document.getElementById('session-list-content');
|
|
2253
|
+
|
|
2254
|
+
if (data.sessions.length === 0) {
|
|
2255
|
+
content.innerHTML = \`
|
|
2256
|
+
<div class="empty-state">
|
|
2257
|
+
<p>No sessions yet</p>
|
|
2258
|
+
</div>
|
|
2259
|
+
\`;
|
|
2260
|
+
} else {
|
|
2261
|
+
const rows = data.sessions.map(session => {
|
|
2262
|
+
const lastActivity = new Date(session.lastActivity).toLocaleString();
|
|
2263
|
+
const statusClass = session.attached ? 'connected' : 'disconnected';
|
|
2264
|
+
const statusText = session.attached ? 'attached' : 'detached';
|
|
2265
|
+
// URL format: /?session=<name>&id=<stableId>
|
|
2266
|
+
const sessionUrl = '/?session=' + encodeURIComponent(session.name) + (session.stableId ? '&id=' + encodeURIComponent(session.stableId) : '');
|
|
2267
|
+
return \`
|
|
2268
|
+
<tr class="session-row">
|
|
2269
|
+
<td><a href="\${sessionUrl}">\${escapeHtml(session.name)}</a></td>
|
|
2270
|
+
<td><a href="\${sessionUrl}">\${lastActivity}</a></td>
|
|
2271
|
+
<td><a href="\${sessionUrl}"><span class="session-status \${statusClass}">\${statusText}</span></a></td>
|
|
2272
|
+
<td class="actions-cell"><button class="copy-btn" onclick="copySessionUrl(event, '\${escapeHtml(session.name)}', '\${escapeHtml(session.stableId || '')}')" title="Copy URL">Copy</button></td>
|
|
2273
|
+
</tr>
|
|
2274
|
+
\`;
|
|
2275
|
+
}).join('');
|
|
2276
|
+
|
|
2277
|
+
content.innerHTML = \`
|
|
2278
|
+
<table class="session-table">
|
|
2279
|
+
<thead>
|
|
2280
|
+
<tr>
|
|
2281
|
+
<th>Name</th>
|
|
2282
|
+
<th>Last Activity</th>
|
|
2283
|
+
<th>Status</th>
|
|
2284
|
+
<th></th>
|
|
2285
|
+
</tr>
|
|
2286
|
+
</thead>
|
|
2287
|
+
<tbody>
|
|
2288
|
+
\${rows}
|
|
2289
|
+
</tbody>
|
|
2290
|
+
</table>
|
|
2291
|
+
\`;
|
|
2292
|
+
}
|
|
2293
|
+
} catch (err) {
|
|
2294
|
+
console.error('Failed to fetch sessions:', err);
|
|
2295
|
+
}
|
|
2296
|
+
}
|
|
2297
|
+
|
|
2298
|
+
// Copy session URL to clipboard (global for onclick handlers)
|
|
2299
|
+
window.copySessionUrl = function(event, sessionName, stableId) {
|
|
2300
|
+
event.preventDefault();
|
|
2301
|
+
event.stopPropagation();
|
|
2302
|
+
let url = window.location.origin + '/?session=' + encodeURIComponent(sessionName);
|
|
2303
|
+
if (stableId) {
|
|
2304
|
+
url += '&id=' + encodeURIComponent(stableId);
|
|
2305
|
+
}
|
|
2306
|
+
navigator.clipboard.writeText(url).then(() => {
|
|
2307
|
+
const btn = event.target;
|
|
2308
|
+
btn.textContent = 'Copied!';
|
|
2309
|
+
btn.classList.add('copied');
|
|
2310
|
+
setTimeout(() => {
|
|
2311
|
+
btn.textContent = 'Copy';
|
|
2312
|
+
btn.classList.remove('copied');
|
|
2313
|
+
}, 2000);
|
|
2314
|
+
}).catch(err => {
|
|
2315
|
+
console.error('Failed to copy:', err);
|
|
2316
|
+
});
|
|
2317
|
+
};
|
|
2318
|
+
|
|
2319
|
+
// Create a new session
|
|
2320
|
+
async function createSession() {
|
|
2321
|
+
try {
|
|
2322
|
+
const response = await fetch('/api/sessions/create', { method: 'POST' });
|
|
2323
|
+
const data = await response.json();
|
|
2324
|
+
// URL format: /?session=<name>&id=<stableId>
|
|
2325
|
+
window.location.href = '/?session=' + encodeURIComponent(data.name) + '&id=' + encodeURIComponent(data.stableId);
|
|
2326
|
+
} catch (err) {
|
|
2327
|
+
console.error('Failed to create session:', err);
|
|
2328
|
+
showError('Failed to create session');
|
|
2329
|
+
}
|
|
2330
|
+
}
|
|
2331
|
+
|
|
2332
|
+
// Show error message
|
|
2333
|
+
function showError(message) {
|
|
2334
|
+
const errorEl = document.getElementById('error-message');
|
|
2335
|
+
errorEl.textContent = message;
|
|
2336
|
+
errorEl.classList.add('visible');
|
|
2337
|
+
}
|
|
2338
|
+
|
|
2339
|
+
// Show terminal view
|
|
2340
|
+
// sessionId is the stable ID used for WebSocket connection
|
|
2341
|
+
async function showTerminalView(sessionId) {
|
|
2342
|
+
currentSessionName = sessionId; // Will be updated when session_info arrives
|
|
2343
|
+
document.getElementById('session-list-view').classList.remove('active');
|
|
2344
|
+
document.getElementById('terminal-view').classList.add('active');
|
|
2345
|
+
// Show loading state until we get the real name from session_info
|
|
2346
|
+
document.getElementById('session-name').textContent = 'Loading...';
|
|
2347
|
+
document.title = 'ghosttown';
|
|
2348
|
+
|
|
2349
|
+
// Fallback: if session_info doesn't arrive within 5 seconds, use the sessionId
|
|
2350
|
+
const loadingTimeout = setTimeout(() => {
|
|
2351
|
+
const nameEl = document.getElementById('session-name');
|
|
2352
|
+
if (nameEl && nameEl.textContent === 'Loading...') {
|
|
2353
|
+
nameEl.textContent = sessionId;
|
|
2354
|
+
document.title = sessionId + ' - ghosttown';
|
|
2355
|
+
}
|
|
2356
|
+
}, 5000);
|
|
2357
|
+
|
|
2358
|
+
try {
|
|
2359
|
+
await initTerminal();
|
|
2360
|
+
} catch (err) {
|
|
2361
|
+
clearTimeout(loadingTimeout);
|
|
2362
|
+
document.getElementById('session-name').textContent = sessionId;
|
|
2363
|
+
console.error('Terminal init failed:', err);
|
|
2364
|
+
}
|
|
2365
|
+
}
|
|
2366
|
+
|
|
2367
|
+
// Update session display name (called when session_info is received)
|
|
2368
|
+
function updateSessionDisplay(displayName, stableId) {
|
|
2369
|
+
currentSessionName = stableId;
|
|
2370
|
+
document.getElementById('session-name').textContent = displayName;
|
|
2371
|
+
document.title = displayName + ' - ghosttown';
|
|
2372
|
+
|
|
2373
|
+
// Update URL to reflect current session name (in case it was renamed)
|
|
2374
|
+
const newUrl = '/?session=' + encodeURIComponent(displayName) + '&id=' + encodeURIComponent(stableId);
|
|
2375
|
+
if (window.location.search !== newUrl) {
|
|
2376
|
+
history.replaceState(null, '', newUrl);
|
|
2377
|
+
}
|
|
2378
|
+
}
|
|
897
2379
|
|
|
898
2380
|
async function initTerminal() {
|
|
899
2381
|
await init();
|
|
@@ -1008,14 +2490,18 @@ function startWebServer(cliArgs) {
|
|
|
1008
2490
|
|
|
1009
2491
|
// Handle terminal resize
|
|
1010
2492
|
term.onResize((size) => {
|
|
1011
|
-
if (
|
|
2493
|
+
if (useSSE) {
|
|
2494
|
+
sendResizeViaPost(size.cols, size.rows);
|
|
2495
|
+
} else if (ws && ws.readyState === WebSocket.OPEN) {
|
|
1012
2496
|
ws.send(JSON.stringify({ type: 'resize', cols: size.cols, rows: size.rows }));
|
|
1013
2497
|
}
|
|
1014
2498
|
});
|
|
1015
2499
|
|
|
1016
2500
|
// Handle user input
|
|
1017
2501
|
term.onData((data) => {
|
|
1018
|
-
if (
|
|
2502
|
+
if (useSSE) {
|
|
2503
|
+
sendInputViaPost(data);
|
|
2504
|
+
} else if (ws && ws.readyState === WebSocket.OPEN) {
|
|
1019
2505
|
ws.send(data);
|
|
1020
2506
|
}
|
|
1021
2507
|
});
|
|
@@ -1035,20 +2521,152 @@ function startWebServer(cliArgs) {
|
|
|
1035
2521
|
}
|
|
1036
2522
|
});
|
|
1037
2523
|
|
|
1038
|
-
|
|
2524
|
+
// On mobile + self-signed cert, go straight to SSE (WebSocket won't work)
|
|
2525
|
+
const isMobile = window.matchMedia && window.matchMedia('(pointer: coarse)').matches;
|
|
2526
|
+
const isSelfSigned = window.__SELF_SIGNED_CERT__ === true;
|
|
2527
|
+
|
|
2528
|
+
if (isMobile && isSelfSigned) {
|
|
2529
|
+
console.log('Mobile + self-signed cert: using SSE transport');
|
|
2530
|
+
useSSE = true;
|
|
2531
|
+
connectSSE();
|
|
2532
|
+
} else {
|
|
2533
|
+
connectWebSocket();
|
|
2534
|
+
}
|
|
2535
|
+
}
|
|
2536
|
+
|
|
2537
|
+
let useSSE = false;
|
|
2538
|
+
let sseSource = null;
|
|
2539
|
+
let sseConnectionId = null;
|
|
2540
|
+
|
|
2541
|
+
function showConnectionError() {
|
|
2542
|
+
const overlay = document.getElementById('connection-error');
|
|
2543
|
+
const hint = document.getElementById('connection-error-hint');
|
|
2544
|
+
hint.textContent = 'Unable to establish connection. Check that the server is running.';
|
|
2545
|
+
overlay.classList.add('visible');
|
|
2546
|
+
}
|
|
2547
|
+
|
|
2548
|
+
function hideConnectionError() {
|
|
2549
|
+
document.getElementById('connection-error').classList.remove('visible');
|
|
2550
|
+
}
|
|
2551
|
+
|
|
2552
|
+
// SSE connection (for mobile with self-signed certs)
|
|
2553
|
+
function connectSSE() {
|
|
2554
|
+
const sseUrl = '/sse?cols=' + term.cols + '&rows=' + term.rows + '&session=' + encodeURIComponent(currentSessionName);
|
|
2555
|
+
|
|
2556
|
+
sseSource = new EventSource(sseUrl);
|
|
2557
|
+
|
|
2558
|
+
sseSource.onopen = () => {
|
|
2559
|
+
hideConnectionError();
|
|
2560
|
+
updateConnectionStatus(true);
|
|
2561
|
+
};
|
|
2562
|
+
|
|
2563
|
+
sseSource.onmessage = (event) => {
|
|
2564
|
+
try {
|
|
2565
|
+
const msg = JSON.parse(event.data);
|
|
2566
|
+
|
|
2567
|
+
if (msg.type === 'session_info') {
|
|
2568
|
+
sseConnectionId = msg.connectionId;
|
|
2569
|
+
updateSessionDisplay(msg.name, msg.stableId);
|
|
2570
|
+
return;
|
|
2571
|
+
}
|
|
2572
|
+
|
|
2573
|
+
if (msg.type === 'output') {
|
|
2574
|
+
const decoded = atob(msg.data);
|
|
2575
|
+
term.write(decoded);
|
|
2576
|
+
return;
|
|
2577
|
+
}
|
|
2578
|
+
|
|
2579
|
+
if (msg.type === 'error') {
|
|
2580
|
+
updateConnectionStatus(false);
|
|
2581
|
+
showError(msg.message);
|
|
2582
|
+
document.getElementById('terminal-view').classList.remove('active');
|
|
2583
|
+
document.getElementById('session-list-view').classList.add('active');
|
|
2584
|
+
refreshSessionList();
|
|
2585
|
+
return;
|
|
2586
|
+
}
|
|
2587
|
+
|
|
2588
|
+
if (msg.type === 'exit') {
|
|
2589
|
+
updateConnectionStatus(false);
|
|
2590
|
+
return;
|
|
2591
|
+
}
|
|
2592
|
+
} catch (e) {
|
|
2593
|
+
console.error('SSE message parse error:', e);
|
|
2594
|
+
}
|
|
2595
|
+
};
|
|
2596
|
+
|
|
2597
|
+
sseSource.onerror = () => {
|
|
2598
|
+
updateConnectionStatus(false);
|
|
2599
|
+
sseSource.close();
|
|
2600
|
+
setTimeout(() => {
|
|
2601
|
+
if (useSSE) {
|
|
2602
|
+
connectSSE();
|
|
2603
|
+
}
|
|
2604
|
+
}, 3000);
|
|
2605
|
+
};
|
|
2606
|
+
}
|
|
2607
|
+
|
|
2608
|
+
// Send input via POST (used with SSE)
|
|
2609
|
+
function sendInputViaPost(data) {
|
|
2610
|
+
if (!sseConnectionId) return;
|
|
2611
|
+
|
|
2612
|
+
fetch('/api/input', {
|
|
2613
|
+
method: 'POST',
|
|
2614
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2615
|
+
body: JSON.stringify({
|
|
2616
|
+
connectionId: sseConnectionId,
|
|
2617
|
+
data: btoa(data),
|
|
2618
|
+
}),
|
|
2619
|
+
credentials: 'same-origin',
|
|
2620
|
+
}).catch(() => {});
|
|
2621
|
+
}
|
|
2622
|
+
|
|
2623
|
+
// Send resize via POST (used with SSE)
|
|
2624
|
+
function sendResizeViaPost(cols, rows) {
|
|
2625
|
+
if (!sseConnectionId) return;
|
|
2626
|
+
|
|
2627
|
+
fetch('/api/input', {
|
|
2628
|
+
method: 'POST',
|
|
2629
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2630
|
+
body: JSON.stringify({
|
|
2631
|
+
connectionId: sseConnectionId,
|
|
2632
|
+
type: 'resize',
|
|
2633
|
+
cols,
|
|
2634
|
+
rows,
|
|
2635
|
+
}),
|
|
2636
|
+
credentials: 'same-origin',
|
|
2637
|
+
}).catch(() => {});
|
|
1039
2638
|
}
|
|
1040
2639
|
|
|
2640
|
+
// WebSocket-only connection (desktop or HTTP)
|
|
1041
2641
|
function connectWebSocket() {
|
|
1042
2642
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
1043
|
-
const wsUrl = protocol + '//' + window.location.host + '/ws?cols=' + term.cols + '&rows=' + term.rows;
|
|
2643
|
+
const wsUrl = protocol + '//' + window.location.host + '/ws?cols=' + term.cols + '&rows=' + term.rows + '&session=' + encodeURIComponent(currentSessionName);
|
|
1044
2644
|
|
|
1045
2645
|
ws = new WebSocket(wsUrl);
|
|
1046
2646
|
|
|
1047
2647
|
ws.onopen = () => {
|
|
2648
|
+
hideConnectionError();
|
|
1048
2649
|
updateConnectionStatus(true);
|
|
1049
2650
|
};
|
|
1050
2651
|
|
|
1051
2652
|
ws.onmessage = (event) => {
|
|
2653
|
+
if (event.data.startsWith('{')) {
|
|
2654
|
+
try {
|
|
2655
|
+
const msg = JSON.parse(event.data);
|
|
2656
|
+
if (msg.type === 'error') {
|
|
2657
|
+
updateConnectionStatus(false);
|
|
2658
|
+
showError(msg.message);
|
|
2659
|
+
document.getElementById('terminal-view').classList.remove('active');
|
|
2660
|
+
document.getElementById('session-list-view').classList.add('active');
|
|
2661
|
+
refreshSessionList();
|
|
2662
|
+
return;
|
|
2663
|
+
}
|
|
2664
|
+
if (msg.type === 'session_info') {
|
|
2665
|
+
updateSessionDisplay(msg.name, msg.stableId);
|
|
2666
|
+
return;
|
|
2667
|
+
}
|
|
2668
|
+
} catch (e) {}
|
|
2669
|
+
}
|
|
1052
2670
|
term.write(event.data);
|
|
1053
2671
|
};
|
|
1054
2672
|
|
|
@@ -1078,7 +2696,250 @@ function startWebServer(cliArgs) {
|
|
|
1078
2696
|
}
|
|
1079
2697
|
}
|
|
1080
2698
|
|
|
1081
|
-
|
|
2699
|
+
initApp();
|
|
2700
|
+
</script>
|
|
2701
|
+
</body>
|
|
2702
|
+
</html>`;
|
|
2703
|
+
|
|
2704
|
+
// ============================================================================
|
|
2705
|
+
// Login Page Template
|
|
2706
|
+
// ============================================================================
|
|
2707
|
+
|
|
2708
|
+
const LOGIN_TEMPLATE = `<!doctype html>
|
|
2709
|
+
<html lang="en">
|
|
2710
|
+
<head>
|
|
2711
|
+
<meta charset="UTF-8" />
|
|
2712
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
2713
|
+
<title>login - ghosttown</title>
|
|
2714
|
+
<style>
|
|
2715
|
+
* {
|
|
2716
|
+
margin: 0;
|
|
2717
|
+
padding: 0;
|
|
2718
|
+
box-sizing: border-box;
|
|
2719
|
+
}
|
|
2720
|
+
|
|
2721
|
+
body {
|
|
2722
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
2723
|
+
background: #292c34;
|
|
2724
|
+
min-height: 100vh;
|
|
2725
|
+
display: flex;
|
|
2726
|
+
align-items: center;
|
|
2727
|
+
justify-content: center;
|
|
2728
|
+
padding: 40px 20px;
|
|
2729
|
+
}
|
|
2730
|
+
|
|
2731
|
+
.login-window {
|
|
2732
|
+
width: 100%;
|
|
2733
|
+
max-width: 400px;
|
|
2734
|
+
background: #1e2127;
|
|
2735
|
+
border-radius: 12px;
|
|
2736
|
+
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
|
2737
|
+
overflow: hidden;
|
|
2738
|
+
}
|
|
2739
|
+
|
|
2740
|
+
.title-bar {
|
|
2741
|
+
background: #292c34;
|
|
2742
|
+
padding: 12px 16px;
|
|
2743
|
+
display: flex;
|
|
2744
|
+
align-items: center;
|
|
2745
|
+
gap: 12px;
|
|
2746
|
+
border-bottom: 1px solid #1a1a1a;
|
|
2747
|
+
}
|
|
2748
|
+
|
|
2749
|
+
.traffic-lights {
|
|
2750
|
+
display: flex;
|
|
2751
|
+
gap: 8px;
|
|
2752
|
+
}
|
|
2753
|
+
|
|
2754
|
+
.light {
|
|
2755
|
+
width: 12px;
|
|
2756
|
+
height: 12px;
|
|
2757
|
+
border-radius: 50%;
|
|
2758
|
+
}
|
|
2759
|
+
|
|
2760
|
+
.light.red { background: #ff5f56; }
|
|
2761
|
+
.light.yellow { background: #ffbd2e; }
|
|
2762
|
+
.light.green { background: #27c93f; }
|
|
2763
|
+
|
|
2764
|
+
.title {
|
|
2765
|
+
color: #e5e5e5;
|
|
2766
|
+
font-size: 13px;
|
|
2767
|
+
font-weight: 500;
|
|
2768
|
+
letter-spacing: 0.3px;
|
|
2769
|
+
}
|
|
2770
|
+
|
|
2771
|
+
.login-content {
|
|
2772
|
+
padding: 32px;
|
|
2773
|
+
}
|
|
2774
|
+
|
|
2775
|
+
.login-header {
|
|
2776
|
+
text-align: center;
|
|
2777
|
+
margin-bottom: 24px;
|
|
2778
|
+
}
|
|
2779
|
+
|
|
2780
|
+
.login-header h1 {
|
|
2781
|
+
color: #e5e5e5;
|
|
2782
|
+
font-size: 24px;
|
|
2783
|
+
font-weight: 600;
|
|
2784
|
+
margin-bottom: 8px;
|
|
2785
|
+
}
|
|
2786
|
+
|
|
2787
|
+
.login-header p {
|
|
2788
|
+
color: #888;
|
|
2789
|
+
font-size: 14px;
|
|
2790
|
+
}
|
|
2791
|
+
|
|
2792
|
+
.form-group {
|
|
2793
|
+
margin-bottom: 16px;
|
|
2794
|
+
}
|
|
2795
|
+
|
|
2796
|
+
.form-group label {
|
|
2797
|
+
display: block;
|
|
2798
|
+
color: #888;
|
|
2799
|
+
font-size: 12px;
|
|
2800
|
+
margin-bottom: 6px;
|
|
2801
|
+
text-transform: uppercase;
|
|
2802
|
+
letter-spacing: 0.5px;
|
|
2803
|
+
}
|
|
2804
|
+
|
|
2805
|
+
.form-group input {
|
|
2806
|
+
width: 100%;
|
|
2807
|
+
padding: 12px 14px;
|
|
2808
|
+
background: #292c34;
|
|
2809
|
+
border: 1px solid #3d3d3d;
|
|
2810
|
+
border-radius: 6px;
|
|
2811
|
+
color: #e5e5e5;
|
|
2812
|
+
font-size: 14px;
|
|
2813
|
+
transition: border-color 0.2s;
|
|
2814
|
+
}
|
|
2815
|
+
|
|
2816
|
+
.form-group input:focus {
|
|
2817
|
+
outline: none;
|
|
2818
|
+
border-color: #27c93f;
|
|
2819
|
+
}
|
|
2820
|
+
|
|
2821
|
+
.form-group input::placeholder {
|
|
2822
|
+
color: #666;
|
|
2823
|
+
}
|
|
2824
|
+
|
|
2825
|
+
.error-message {
|
|
2826
|
+
background: rgba(255, 95, 86, 0.1);
|
|
2827
|
+
border: 1px solid rgba(255, 95, 86, 0.3);
|
|
2828
|
+
color: #ff5f56;
|
|
2829
|
+
padding: 12px;
|
|
2830
|
+
border-radius: 6px;
|
|
2831
|
+
font-size: 13px;
|
|
2832
|
+
margin-bottom: 16px;
|
|
2833
|
+
display: none;
|
|
2834
|
+
}
|
|
2835
|
+
|
|
2836
|
+
.error-message.visible {
|
|
2837
|
+
display: block;
|
|
2838
|
+
}
|
|
2839
|
+
|
|
2840
|
+
.submit-btn {
|
|
2841
|
+
width: 100%;
|
|
2842
|
+
padding: 14px;
|
|
2843
|
+
background: #27c93f;
|
|
2844
|
+
border: none;
|
|
2845
|
+
border-radius: 6px;
|
|
2846
|
+
color: #1e1e1e;
|
|
2847
|
+
font-size: 14px;
|
|
2848
|
+
font-weight: 600;
|
|
2849
|
+
cursor: pointer;
|
|
2850
|
+
transition: background 0.2s;
|
|
2851
|
+
}
|
|
2852
|
+
|
|
2853
|
+
.submit-btn:hover {
|
|
2854
|
+
background: #2bd946;
|
|
2855
|
+
}
|
|
2856
|
+
|
|
2857
|
+
.submit-btn:disabled {
|
|
2858
|
+
background: #3d3d3d;
|
|
2859
|
+
color: #888;
|
|
2860
|
+
cursor: not-allowed;
|
|
2861
|
+
}
|
|
2862
|
+
|
|
2863
|
+
.hint {
|
|
2864
|
+
text-align: center;
|
|
2865
|
+
margin-top: 20px;
|
|
2866
|
+
color: #666;
|
|
2867
|
+
font-size: 12px;
|
|
2868
|
+
}
|
|
2869
|
+
</style>
|
|
2870
|
+
</head>
|
|
2871
|
+
<body>
|
|
2872
|
+
<div class="login-window">
|
|
2873
|
+
<div class="title-bar">
|
|
2874
|
+
<div class="traffic-lights">
|
|
2875
|
+
<div class="light red"></div>
|
|
2876
|
+
<div class="light yellow"></div>
|
|
2877
|
+
<div class="light green"></div>
|
|
2878
|
+
</div>
|
|
2879
|
+
<span class="title">Ghost Town</span>
|
|
2880
|
+
</div>
|
|
2881
|
+
<div class="login-content">
|
|
2882
|
+
<div class="login-header">
|
|
2883
|
+
<h1>Sign In</h1>
|
|
2884
|
+
<p>Use your macOS account credentials</p>
|
|
2885
|
+
</div>
|
|
2886
|
+
<div class="error-message" id="error-message"></div>
|
|
2887
|
+
<form id="login-form">
|
|
2888
|
+
<div class="form-group">
|
|
2889
|
+
<label for="username">Username</label>
|
|
2890
|
+
<input type="text" id="username" name="username" placeholder="macOS username" autocomplete="username" required autofocus />
|
|
2891
|
+
</div>
|
|
2892
|
+
<div class="form-group">
|
|
2893
|
+
<label for="password">Password</label>
|
|
2894
|
+
<input type="password" id="password" name="password" placeholder="macOS password" autocomplete="current-password" required />
|
|
2895
|
+
</div>
|
|
2896
|
+
<button type="submit" class="submit-btn" id="submit-btn">Sign In</button>
|
|
2897
|
+
</form>
|
|
2898
|
+
<p class="hint">Your session will expire after 3 days</p>
|
|
2899
|
+
</div>
|
|
2900
|
+
</div>
|
|
2901
|
+
|
|
2902
|
+
<script>
|
|
2903
|
+
const form = document.getElementById('login-form');
|
|
2904
|
+
const errorMessage = document.getElementById('error-message');
|
|
2905
|
+
const submitBtn = document.getElementById('submit-btn');
|
|
2906
|
+
|
|
2907
|
+
form.addEventListener('submit', async (e) => {
|
|
2908
|
+
e.preventDefault();
|
|
2909
|
+
|
|
2910
|
+
const username = document.getElementById('username').value;
|
|
2911
|
+
const password = document.getElementById('password').value;
|
|
2912
|
+
|
|
2913
|
+
submitBtn.disabled = true;
|
|
2914
|
+
submitBtn.textContent = 'Signing in...';
|
|
2915
|
+
errorMessage.classList.remove('visible');
|
|
2916
|
+
|
|
2917
|
+
try {
|
|
2918
|
+
const response = await fetch('/api/login', {
|
|
2919
|
+
method: 'POST',
|
|
2920
|
+
headers: {
|
|
2921
|
+
'Content-Type': 'application/json',
|
|
2922
|
+
},
|
|
2923
|
+
body: JSON.stringify({ username, password }),
|
|
2924
|
+
});
|
|
2925
|
+
|
|
2926
|
+
const data = await response.json();
|
|
2927
|
+
|
|
2928
|
+
if (data.success) {
|
|
2929
|
+
window.location.href = '/';
|
|
2930
|
+
} else {
|
|
2931
|
+
errorMessage.textContent = data.error || 'Invalid username or password';
|
|
2932
|
+
errorMessage.classList.add('visible');
|
|
2933
|
+
submitBtn.disabled = false;
|
|
2934
|
+
submitBtn.textContent = 'Sign In';
|
|
2935
|
+
}
|
|
2936
|
+
} catch (err) {
|
|
2937
|
+
errorMessage.textContent = 'Connection error. Please try again.';
|
|
2938
|
+
errorMessage.classList.add('visible');
|
|
2939
|
+
submitBtn.disabled = false;
|
|
2940
|
+
submitBtn.textContent = 'Sign In';
|
|
2941
|
+
}
|
|
2942
|
+
});
|
|
1082
2943
|
</script>
|
|
1083
2944
|
</body>
|
|
1084
2945
|
</html>`;
|
|
@@ -1100,37 +2961,345 @@ function startWebServer(cliArgs) {
|
|
|
1100
2961
|
};
|
|
1101
2962
|
|
|
1102
2963
|
// ============================================================================
|
|
1103
|
-
// HTTP Server
|
|
2964
|
+
// HTTP/HTTPS Server
|
|
1104
2965
|
// ============================================================================
|
|
1105
2966
|
|
|
1106
|
-
|
|
2967
|
+
// Get SSL options if HTTPS is enabled
|
|
2968
|
+
const sslOptions = USE_HTTPS ? generateSelfSignedCert() : null;
|
|
2969
|
+
|
|
2970
|
+
// Helper to set auth cookie (with Secure flag when HTTPS)
|
|
2971
|
+
function setAuthCookieSecure(res, token) {
|
|
2972
|
+
const maxAge = SESSION_EXPIRATION_MS / 1000;
|
|
2973
|
+
const secureFlag = USE_HTTPS ? ' Secure;' : '';
|
|
2974
|
+
res.setHeader(
|
|
2975
|
+
'Set-Cookie',
|
|
2976
|
+
`ghostty_session=${token}; HttpOnly;${secureFlag} SameSite=Strict; Path=/; Max-Age=${maxAge}`
|
|
2977
|
+
);
|
|
2978
|
+
}
|
|
2979
|
+
|
|
2980
|
+
const httpServer = USE_HTTPS
|
|
2981
|
+
? https.createServer(sslOptions, async (req, res) => {
|
|
2982
|
+
await handleRequest(req, res);
|
|
2983
|
+
})
|
|
2984
|
+
: http.createServer(async (req, res) => {
|
|
2985
|
+
await handleRequest(req, res);
|
|
2986
|
+
});
|
|
2987
|
+
|
|
2988
|
+
async function handleRequest(req, res) {
|
|
1107
2989
|
const url = new URL(req.url, `http://${req.headers.host}`);
|
|
1108
2990
|
const pathname = url.pathname;
|
|
2991
|
+
const cookies = parseCookies(req.headers.cookie);
|
|
2992
|
+
const session = validateAuthSession(cookies.ghostty_session);
|
|
2993
|
+
|
|
2994
|
+
// Handle login API endpoint
|
|
2995
|
+
if (pathname === '/api/login' && req.method === 'POST') {
|
|
2996
|
+
const clientIP = getClientIP(req);
|
|
2997
|
+
|
|
2998
|
+
// Check rate limit before processing
|
|
2999
|
+
const rateCheck = checkRateLimit(clientIP);
|
|
3000
|
+
if (!rateCheck.allowed) {
|
|
3001
|
+
res.writeHead(429, { 'Content-Type': 'application/json' });
|
|
3002
|
+
res.end(JSON.stringify({ success: false, error: rateCheck.error }));
|
|
3003
|
+
return;
|
|
3004
|
+
}
|
|
3005
|
+
|
|
3006
|
+
try {
|
|
3007
|
+
const body = await readBody(req);
|
|
3008
|
+
const { username, password } = JSON.parse(body);
|
|
3009
|
+
|
|
3010
|
+
if (!username || !password) {
|
|
3011
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
3012
|
+
res.end(JSON.stringify({ success: false, error: 'Username and password required' }));
|
|
3013
|
+
return;
|
|
3014
|
+
}
|
|
3015
|
+
|
|
3016
|
+
const result = await authenticateUser(username, password);
|
|
3017
|
+
|
|
3018
|
+
if (result.success) {
|
|
3019
|
+
recordLoginAttempt(clientIP, true); // Clear rate limit on success
|
|
3020
|
+
const token = generateAuthToken();
|
|
3021
|
+
const now = Date.now();
|
|
3022
|
+
authSessions.set(token, {
|
|
3023
|
+
username: result.username,
|
|
3024
|
+
createdAt: now,
|
|
3025
|
+
lastActivity: now,
|
|
3026
|
+
});
|
|
3027
|
+
|
|
3028
|
+
setAuthCookieSecure(res, token);
|
|
3029
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
3030
|
+
res.end(JSON.stringify({ success: true }));
|
|
3031
|
+
} else {
|
|
3032
|
+
recordLoginAttempt(clientIP, false); // Record failed attempt
|
|
3033
|
+
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
3034
|
+
res.end(JSON.stringify({ success: false, error: result.error }));
|
|
3035
|
+
}
|
|
3036
|
+
} catch (err) {
|
|
3037
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
3038
|
+
res.end(JSON.stringify({ success: false, error: 'Invalid request' }));
|
|
3039
|
+
}
|
|
3040
|
+
return;
|
|
3041
|
+
}
|
|
3042
|
+
|
|
3043
|
+
// Handle logout API endpoint
|
|
3044
|
+
if (pathname === '/api/logout' && req.method === 'POST') {
|
|
3045
|
+
if (cookies.ghostty_session) {
|
|
3046
|
+
authSessions.delete(cookies.ghostty_session);
|
|
3047
|
+
}
|
|
3048
|
+
clearAuthCookie(res);
|
|
3049
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
3050
|
+
res.end(JSON.stringify({ success: true }));
|
|
3051
|
+
return;
|
|
3052
|
+
}
|
|
3053
|
+
|
|
3054
|
+
// Serve login page (always accessible)
|
|
3055
|
+
if (pathname === '/login') {
|
|
3056
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
3057
|
+
res.end(LOGIN_TEMPLATE);
|
|
3058
|
+
return;
|
|
3059
|
+
}
|
|
1109
3060
|
|
|
1110
|
-
// Serve index page
|
|
3061
|
+
// Serve index page (requires authentication)
|
|
1111
3062
|
if (pathname === '/' || pathname === '/index.html') {
|
|
3063
|
+
if (!session) {
|
|
3064
|
+
res.writeHead(302, { Location: '/login' });
|
|
3065
|
+
res.end();
|
|
3066
|
+
return;
|
|
3067
|
+
}
|
|
1112
3068
|
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
1113
3069
|
res.end(HTML_TEMPLATE);
|
|
1114
3070
|
return;
|
|
1115
3071
|
}
|
|
1116
3072
|
|
|
1117
|
-
// Serve dist files
|
|
3073
|
+
// Serve dist files (requires authentication)
|
|
1118
3074
|
if (pathname.startsWith('/dist/')) {
|
|
3075
|
+
if (!session) {
|
|
3076
|
+
res.writeHead(401);
|
|
3077
|
+
res.end('Unauthorized');
|
|
3078
|
+
return;
|
|
3079
|
+
}
|
|
1119
3080
|
const filePath = path.join(distPath, pathname.slice(6));
|
|
1120
3081
|
serveFile(filePath, res);
|
|
1121
3082
|
return;
|
|
1122
3083
|
}
|
|
1123
3084
|
|
|
1124
|
-
// Serve WASM file
|
|
3085
|
+
// Serve WASM file (requires authentication)
|
|
1125
3086
|
if (pathname === '/ghostty-vt.wasm') {
|
|
3087
|
+
if (!session) {
|
|
3088
|
+
res.writeHead(401);
|
|
3089
|
+
res.end('Unauthorized');
|
|
3090
|
+
return;
|
|
3091
|
+
}
|
|
1126
3092
|
serveFile(wasmPath, res);
|
|
1127
3093
|
return;
|
|
1128
3094
|
}
|
|
1129
3095
|
|
|
3096
|
+
// API: List sessions (from tmux) - requires authentication
|
|
3097
|
+
if (pathname === '/api/sessions' && req.method === 'GET') {
|
|
3098
|
+
if (!session) {
|
|
3099
|
+
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
3100
|
+
res.end(JSON.stringify({ error: 'Unauthorized' }));
|
|
3101
|
+
return;
|
|
3102
|
+
}
|
|
3103
|
+
try {
|
|
3104
|
+
const output = execSync(
|
|
3105
|
+
'tmux list-sessions -F "#{session_name}|#{session_activity}|#{session_attached}|#{session_windows}"',
|
|
3106
|
+
{ encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }
|
|
3107
|
+
);
|
|
3108
|
+
|
|
3109
|
+
const sessionList = output
|
|
3110
|
+
.split('\n')
|
|
3111
|
+
.filter((line) => line.startsWith('gt-'))
|
|
3112
|
+
.map((line) => {
|
|
3113
|
+
const [fullName, activity, attached, windows] = line.split('|');
|
|
3114
|
+
const parsed = parseSessionName(fullName);
|
|
3115
|
+
return {
|
|
3116
|
+
name: parsed ? parsed.displayName : fullName.replace('gt-', ''),
|
|
3117
|
+
stableId: parsed ? parsed.stableId : null,
|
|
3118
|
+
fullName: fullName,
|
|
3119
|
+
lastActivity: Number.parseInt(activity, 10) * 1000,
|
|
3120
|
+
attached: attached === '1',
|
|
3121
|
+
windows: Number.parseInt(windows, 10),
|
|
3122
|
+
};
|
|
3123
|
+
});
|
|
3124
|
+
|
|
3125
|
+
// Sort by recent activity
|
|
3126
|
+
sessionList.sort((a, b) => b.lastActivity - a.lastActivity);
|
|
3127
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
3128
|
+
res.end(JSON.stringify({ sessions: sessionList }));
|
|
3129
|
+
} catch (err) {
|
|
3130
|
+
// No tmux sessions or tmux not running
|
|
3131
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
3132
|
+
res.end(JSON.stringify({ sessions: [] }));
|
|
3133
|
+
}
|
|
3134
|
+
return;
|
|
3135
|
+
}
|
|
3136
|
+
|
|
3137
|
+
// API: Create session (tmux session) - requires authentication
|
|
3138
|
+
if (pathname === '/api/sessions/create' && req.method === 'POST') {
|
|
3139
|
+
if (!session) {
|
|
3140
|
+
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
3141
|
+
res.end(JSON.stringify({ error: 'Unauthorized' }));
|
|
3142
|
+
return;
|
|
3143
|
+
}
|
|
3144
|
+
try {
|
|
3145
|
+
const displayNumber = getNextDisplayNumber();
|
|
3146
|
+
|
|
3147
|
+
// Create a detached session with a temporary name to get its stable ID
|
|
3148
|
+
const tempName = `gt-temp-${Date.now()}`;
|
|
3149
|
+
const output = execSync(
|
|
3150
|
+
`tmux new-session -d -s ${tempName} -x 200 -y 50 -P -F "#{session_id}"`,
|
|
3151
|
+
{ encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }
|
|
3152
|
+
);
|
|
3153
|
+
|
|
3154
|
+
// Extract the stable ID (e.g., "$10" -> "10")
|
|
3155
|
+
const stableId = output.trim().replace('$', '');
|
|
3156
|
+
|
|
3157
|
+
// Rename to final name: gt-<stableId>-<displayNumber>
|
|
3158
|
+
const sessionName = `gt-${stableId}-${displayNumber}`;
|
|
3159
|
+
execSync(`tmux rename-session -t ${tempName} ${sessionName}`, { stdio: 'pipe' });
|
|
3160
|
+
|
|
3161
|
+
// Disable status bar in the new session
|
|
3162
|
+
execSync(`tmux set-option -t ${sessionName} status off`, { stdio: 'pipe' });
|
|
3163
|
+
|
|
3164
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
3165
|
+
res.end(JSON.stringify({ name: String(displayNumber), stableId: stableId }));
|
|
3166
|
+
} catch (err) {
|
|
3167
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
3168
|
+
res.end(JSON.stringify({ error: 'Failed to create session' }));
|
|
3169
|
+
}
|
|
3170
|
+
return;
|
|
3171
|
+
}
|
|
3172
|
+
|
|
3173
|
+
// SSE endpoint for terminal output (fallback for mobile when WebSocket fails)
|
|
3174
|
+
if (pathname === '/sse' && req.method === 'GET') {
|
|
3175
|
+
if (!session) {
|
|
3176
|
+
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
3177
|
+
res.end(JSON.stringify({ error: 'Unauthorized' }));
|
|
3178
|
+
return;
|
|
3179
|
+
}
|
|
3180
|
+
|
|
3181
|
+
const cols = Number.parseInt(url.searchParams.get('cols') || '80');
|
|
3182
|
+
const rows = Number.parseInt(url.searchParams.get('rows') || '24');
|
|
3183
|
+
const requestedSession = url.searchParams.get('session');
|
|
3184
|
+
|
|
3185
|
+
if (!requestedSession) {
|
|
3186
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
3187
|
+
res.end(JSON.stringify({ error: 'Session parameter required' }));
|
|
3188
|
+
return;
|
|
3189
|
+
}
|
|
3190
|
+
|
|
3191
|
+
// Look up session by stable ID first, then by display name
|
|
3192
|
+
let sessionInfo = findSessionByStableIdWeb(requestedSession);
|
|
3193
|
+
if (!sessionInfo) {
|
|
3194
|
+
sessionInfo = findSessionByDisplayName(requestedSession);
|
|
3195
|
+
}
|
|
3196
|
+
if (!sessionInfo) {
|
|
3197
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
3198
|
+
res.end(JSON.stringify({ error: `Session '${requestedSession}' not found` }));
|
|
3199
|
+
return;
|
|
3200
|
+
}
|
|
3201
|
+
|
|
3202
|
+
// Generate unique connection ID
|
|
3203
|
+
const connectionId = `sse-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
3204
|
+
|
|
3205
|
+
// Set up SSE headers
|
|
3206
|
+
res.writeHead(200, {
|
|
3207
|
+
'Content-Type': 'text/event-stream',
|
|
3208
|
+
'Cache-Control': 'no-cache',
|
|
3209
|
+
Connection: 'keep-alive',
|
|
3210
|
+
'X-SSE-Connection-Id': connectionId,
|
|
3211
|
+
});
|
|
3212
|
+
|
|
3213
|
+
// Send session info as first event
|
|
3214
|
+
res.write(
|
|
3215
|
+
`data: ${JSON.stringify({ type: 'session_info', name: sessionInfo.displayName, stableId: sessionInfo.stableId, connectionId })}\n\n`
|
|
3216
|
+
);
|
|
3217
|
+
|
|
3218
|
+
// Create PTY attached to tmux session
|
|
3219
|
+
const ptyProcess = createTmuxAttachPty(sessionInfo.fullName, cols, rows);
|
|
3220
|
+
|
|
3221
|
+
// Store connection
|
|
3222
|
+
sseConnections.set(connectionId, {
|
|
3223
|
+
res,
|
|
3224
|
+
pty: ptyProcess,
|
|
3225
|
+
stableId: requestedSession,
|
|
3226
|
+
sessionInfo,
|
|
3227
|
+
});
|
|
3228
|
+
|
|
3229
|
+
// Stream terminal output as SSE events
|
|
3230
|
+
ptyProcess.onData((data) => {
|
|
3231
|
+
if (!res.writableEnded) {
|
|
3232
|
+
// Base64 encode to handle binary data safely
|
|
3233
|
+
const encoded = Buffer.from(data).toString('base64');
|
|
3234
|
+
res.write(`data: ${JSON.stringify({ type: 'output', data: encoded })}\n\n`);
|
|
3235
|
+
}
|
|
3236
|
+
});
|
|
3237
|
+
|
|
3238
|
+
ptyProcess.onExit(() => {
|
|
3239
|
+
if (!res.writableEnded) {
|
|
3240
|
+
res.write(`data: ${JSON.stringify({ type: 'exit' })}\n\n`);
|
|
3241
|
+
res.end();
|
|
3242
|
+
}
|
|
3243
|
+
sseConnections.delete(connectionId);
|
|
3244
|
+
});
|
|
3245
|
+
|
|
3246
|
+
// Handle client disconnect
|
|
3247
|
+
req.on('close', () => {
|
|
3248
|
+
const conn = sseConnections.get(connectionId);
|
|
3249
|
+
if (conn) {
|
|
3250
|
+
conn.pty.kill();
|
|
3251
|
+
sseConnections.delete(connectionId);
|
|
3252
|
+
}
|
|
3253
|
+
});
|
|
3254
|
+
|
|
3255
|
+
return;
|
|
3256
|
+
}
|
|
3257
|
+
|
|
3258
|
+
// POST endpoint for terminal input (used with SSE fallback)
|
|
3259
|
+
if (pathname === '/api/input' && req.method === 'POST') {
|
|
3260
|
+
if (!session) {
|
|
3261
|
+
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
3262
|
+
res.end(JSON.stringify({ error: 'Unauthorized' }));
|
|
3263
|
+
return;
|
|
3264
|
+
}
|
|
3265
|
+
|
|
3266
|
+
let body = '';
|
|
3267
|
+
req.on('data', (chunk) => {
|
|
3268
|
+
body += chunk;
|
|
3269
|
+
});
|
|
3270
|
+
req.on('end', () => {
|
|
3271
|
+
try {
|
|
3272
|
+
const { connectionId, data, type, cols, rows } = JSON.parse(body);
|
|
3273
|
+
|
|
3274
|
+
const conn = sseConnections.get(connectionId);
|
|
3275
|
+
if (!conn) {
|
|
3276
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
3277
|
+
res.end(JSON.stringify({ error: 'Connection not found' }));
|
|
3278
|
+
return;
|
|
3279
|
+
}
|
|
3280
|
+
|
|
3281
|
+
if (type === 'resize' && cols && rows) {
|
|
3282
|
+
conn.pty.resize(cols, rows);
|
|
3283
|
+
} else if (data) {
|
|
3284
|
+
// Decode base64 input
|
|
3285
|
+
const decoded = Buffer.from(data, 'base64').toString('utf8');
|
|
3286
|
+
conn.pty.write(decoded);
|
|
3287
|
+
}
|
|
3288
|
+
|
|
3289
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
3290
|
+
res.end(JSON.stringify({ ok: true }));
|
|
3291
|
+
} catch (err) {
|
|
3292
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
3293
|
+
res.end(JSON.stringify({ error: 'Invalid request' }));
|
|
3294
|
+
}
|
|
3295
|
+
});
|
|
3296
|
+
return;
|
|
3297
|
+
}
|
|
3298
|
+
|
|
1130
3299
|
// 404
|
|
1131
3300
|
res.writeHead(404);
|
|
1132
3301
|
res.end('Not Found');
|
|
1133
|
-
}
|
|
3302
|
+
}
|
|
1134
3303
|
|
|
1135
3304
|
function serveFile(filePath, res) {
|
|
1136
3305
|
const ext = path.extname(filePath);
|
|
@@ -1151,19 +3320,42 @@ function startWebServer(cliArgs) {
|
|
|
1151
3320
|
// WebSocket Server
|
|
1152
3321
|
// ============================================================================
|
|
1153
3322
|
|
|
1154
|
-
|
|
3323
|
+
// Track active WebSocket connections to tmux sessions
|
|
3324
|
+
// ws -> { pty, sessionName }
|
|
3325
|
+
const wsConnections = new Map();
|
|
3326
|
+
|
|
3327
|
+
// Track active SSE connections (fallback for mobile)
|
|
3328
|
+
// connectionId -> { res, pty, stableId, sessionInfo }
|
|
3329
|
+
const sseConnections = new Map();
|
|
1155
3330
|
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
3331
|
+
/**
|
|
3332
|
+
* Find a ghosttown session by stable ID (for web connections)
|
|
3333
|
+
* Returns { fullName, displayName, stableId } or null if not found
|
|
3334
|
+
*/
|
|
3335
|
+
function findSessionByStableIdWeb(stableId) {
|
|
3336
|
+
try {
|
|
3337
|
+
const output = execSync('tmux list-sessions -F "#{session_name}"', {
|
|
3338
|
+
encoding: 'utf8',
|
|
3339
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
3340
|
+
});
|
|
3341
|
+
const sessions = output.split('\n').filter((s) => s.trim() && s.startsWith('gt-'));
|
|
3342
|
+
for (const fullName of sessions) {
|
|
3343
|
+
const parsed = parseSessionName(fullName);
|
|
3344
|
+
if (parsed && parsed.stableId === stableId) {
|
|
3345
|
+
return parsed;
|
|
3346
|
+
}
|
|
3347
|
+
}
|
|
3348
|
+
return null;
|
|
3349
|
+
} catch (err) {
|
|
3350
|
+
return null;
|
|
1159
3351
|
}
|
|
1160
|
-
return process.env.SHELL || '/bin/bash';
|
|
1161
3352
|
}
|
|
1162
3353
|
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
3354
|
+
/**
|
|
3355
|
+
* Create a PTY that attaches to a tmux session
|
|
3356
|
+
*/
|
|
3357
|
+
function createTmuxAttachPty(fullSessionName, cols, rows) {
|
|
3358
|
+
const ptyProcess = pty.spawn('tmux', ['attach-session', '-t', fullSessionName], {
|
|
1167
3359
|
name: 'xterm-256color',
|
|
1168
3360
|
cols: cols,
|
|
1169
3361
|
rows: rows,
|
|
@@ -1184,7 +3376,19 @@ function startWebServer(cliArgs) {
|
|
|
1184
3376
|
const url = new URL(req.url, `http://${req.headers.host}`);
|
|
1185
3377
|
|
|
1186
3378
|
if (url.pathname === '/ws') {
|
|
3379
|
+
// Validate authentication before allowing WebSocket connection
|
|
3380
|
+
const cookies = parseCookies(req.headers.cookie);
|
|
3381
|
+
const session = validateAuthSession(cookies.ghostty_session);
|
|
3382
|
+
|
|
3383
|
+
if (!session) {
|
|
3384
|
+
// Reject unauthenticated WebSocket connections
|
|
3385
|
+
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
|
|
3386
|
+
socket.destroy();
|
|
3387
|
+
return;
|
|
3388
|
+
}
|
|
3389
|
+
|
|
1187
3390
|
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
3391
|
+
req.authSession = session;
|
|
1188
3392
|
wss.emit('connection', ws, req);
|
|
1189
3393
|
});
|
|
1190
3394
|
} else {
|
|
@@ -1196,9 +3400,42 @@ function startWebServer(cliArgs) {
|
|
|
1196
3400
|
const url = new URL(req.url, `http://${req.headers.host}`);
|
|
1197
3401
|
const cols = Number.parseInt(url.searchParams.get('cols') || '80');
|
|
1198
3402
|
const rows = Number.parseInt(url.searchParams.get('rows') || '24');
|
|
3403
|
+
const requestedSession = url.searchParams.get('session');
|
|
3404
|
+
|
|
3405
|
+
// Session parameter is required (should be stable ID)
|
|
3406
|
+
if (!requestedSession) {
|
|
3407
|
+
ws.send(JSON.stringify({ type: 'error', message: 'Session parameter required' }));
|
|
3408
|
+
ws.close();
|
|
3409
|
+
return;
|
|
3410
|
+
}
|
|
3411
|
+
|
|
3412
|
+
// Look up session by stable ID first, then by display name (for legacy URLs)
|
|
3413
|
+
let sessionInfo = findSessionByStableIdWeb(requestedSession);
|
|
3414
|
+
if (!sessionInfo) {
|
|
3415
|
+
// Try looking up by display name
|
|
3416
|
+
sessionInfo = findSessionByDisplayName(requestedSession);
|
|
3417
|
+
}
|
|
3418
|
+
if (!sessionInfo) {
|
|
3419
|
+
ws.send(
|
|
3420
|
+
JSON.stringify({ type: 'error', message: `Session '${requestedSession}' not found` })
|
|
3421
|
+
);
|
|
3422
|
+
ws.close();
|
|
3423
|
+
return;
|
|
3424
|
+
}
|
|
3425
|
+
|
|
3426
|
+
// Create a PTY that attaches to the tmux session
|
|
3427
|
+
const ptyProcess = createTmuxAttachPty(sessionInfo.fullName, cols, rows);
|
|
1199
3428
|
|
|
1200
|
-
|
|
1201
|
-
|
|
3429
|
+
wsConnections.set(ws, { pty: ptyProcess, stableId: requestedSession, sessionInfo });
|
|
3430
|
+
|
|
3431
|
+
// Send session info with current display name (may have been renamed)
|
|
3432
|
+
ws.send(
|
|
3433
|
+
JSON.stringify({
|
|
3434
|
+
type: 'session_info',
|
|
3435
|
+
name: sessionInfo.displayName,
|
|
3436
|
+
stableId: sessionInfo.stableId,
|
|
3437
|
+
})
|
|
3438
|
+
);
|
|
1202
3439
|
|
|
1203
3440
|
ptyProcess.onData((data) => {
|
|
1204
3441
|
if (ws.readyState === ws.OPEN) {
|
|
@@ -1206,11 +3443,11 @@ function startWebServer(cliArgs) {
|
|
|
1206
3443
|
}
|
|
1207
3444
|
});
|
|
1208
3445
|
|
|
1209
|
-
ptyProcess.onExit((
|
|
3446
|
+
ptyProcess.onExit(() => {
|
|
1210
3447
|
if (ws.readyState === ws.OPEN) {
|
|
1211
|
-
ws.send(`\r\n\x1b[33mShell exited (code: ${exitCode})\x1b[0m\r\n`);
|
|
1212
3448
|
ws.close();
|
|
1213
3449
|
}
|
|
3450
|
+
wsConnections.delete(ws);
|
|
1214
3451
|
});
|
|
1215
3452
|
|
|
1216
3453
|
ws.on('message', (data) => {
|
|
@@ -1232,15 +3469,19 @@ function startWebServer(cliArgs) {
|
|
|
1232
3469
|
});
|
|
1233
3470
|
|
|
1234
3471
|
ws.on('close', () => {
|
|
1235
|
-
const
|
|
1236
|
-
if (
|
|
1237
|
-
|
|
1238
|
-
|
|
3472
|
+
const conn = wsConnections.get(ws);
|
|
3473
|
+
if (conn) {
|
|
3474
|
+
conn.pty.kill();
|
|
3475
|
+
wsConnections.delete(ws);
|
|
1239
3476
|
}
|
|
1240
3477
|
});
|
|
1241
3478
|
|
|
1242
3479
|
ws.on('error', () => {
|
|
1243
|
-
|
|
3480
|
+
const conn = wsConnections.get(ws);
|
|
3481
|
+
if (conn) {
|
|
3482
|
+
conn.pty.kill();
|
|
3483
|
+
wsConnections.delete(ws);
|
|
3484
|
+
}
|
|
1244
3485
|
});
|
|
1245
3486
|
});
|
|
1246
3487
|
|
|
@@ -1248,20 +3489,7 @@ function startWebServer(cliArgs) {
|
|
|
1248
3489
|
// Startup
|
|
1249
3490
|
// ============================================================================
|
|
1250
3491
|
|
|
1251
|
-
function
|
|
1252
|
-
const interfaces = networkInterfaces();
|
|
1253
|
-
const ips = [];
|
|
1254
|
-
for (const name of Object.keys(interfaces)) {
|
|
1255
|
-
for (const iface of interfaces[name] || []) {
|
|
1256
|
-
if (iface.family === 'IPv4' && !iface.internal) {
|
|
1257
|
-
ips.push(iface.address);
|
|
1258
|
-
}
|
|
1259
|
-
}
|
|
1260
|
-
}
|
|
1261
|
-
return ips;
|
|
1262
|
-
}
|
|
1263
|
-
|
|
1264
|
-
function printBanner(url) {
|
|
3492
|
+
function printBanner(url, backgroundMode = false) {
|
|
1265
3493
|
const localIPs = getLocalIPs();
|
|
1266
3494
|
// ANSI color codes
|
|
1267
3495
|
const RESET = '\x1b[0m';
|
|
@@ -1281,7 +3509,8 @@ function startWebServer(cliArgs) {
|
|
|
1281
3509
|
for (const ip of localIPs) {
|
|
1282
3510
|
networkCount++;
|
|
1283
3511
|
const spaces = networkCount !== 1 ? ' ' : '';
|
|
1284
|
-
|
|
3512
|
+
const protocol = USE_HTTPS ? 'https' : 'http';
|
|
3513
|
+
network.push(`${spaces}${BEIGE}${protocol}://${ip}:${HTTP_PORT}${RESET}\n`);
|
|
1285
3514
|
}
|
|
1286
3515
|
}
|
|
1287
3516
|
console.log(`\n${network.join('')} `);
|
|
@@ -1295,13 +3524,17 @@ function startWebServer(cliArgs) {
|
|
|
1295
3524
|
console.log(` ${CYAN}Home:${RESET} ${BEIGE}${homedir()}${RESET}`);
|
|
1296
3525
|
|
|
1297
3526
|
console.log('');
|
|
1298
|
-
|
|
3527
|
+
if (backgroundMode) {
|
|
3528
|
+
console.log(` ${DIM}Run gt stop to stop.${RESET}\n`);
|
|
3529
|
+
} else {
|
|
3530
|
+
console.log(` ${DIM}Press ctrl+c to stop.${RESET}\n`);
|
|
3531
|
+
}
|
|
1299
3532
|
}
|
|
1300
3533
|
|
|
1301
3534
|
process.on('SIGINT', () => {
|
|
1302
3535
|
console.log('\n\nShutting down...');
|
|
1303
|
-
for (const [ws,
|
|
1304
|
-
|
|
3536
|
+
for (const [ws, conn] of wsConnections.entries()) {
|
|
3537
|
+
conn.pty.kill();
|
|
1305
3538
|
ws.close();
|
|
1306
3539
|
}
|
|
1307
3540
|
wss.close();
|
|
@@ -1314,7 +3547,7 @@ function startWebServer(cliArgs) {
|
|
|
1314
3547
|
const imagePath = path.join(__dirname, '..', 'bin', 'assets', 'ghosts.png');
|
|
1315
3548
|
if (fs.existsSync(imagePath)) {
|
|
1316
3549
|
// Welcome text with orange/yellow color (bright yellow, bold)
|
|
1317
|
-
console.log('\n \x1b[1;93mWelcome to
|
|
3550
|
+
console.log('\n \x1b[1;93mWelcome to Ghost Town!\x1b[0m\n');
|
|
1318
3551
|
const art = await asciiArt(imagePath, { maxWidth: 80, maxHeight: 20 });
|
|
1319
3552
|
console.log(art);
|
|
1320
3553
|
console.log('');
|
|
@@ -1324,7 +3557,8 @@ function startWebServer(cliArgs) {
|
|
|
1324
3557
|
// This allows the server to start even if the image is missing
|
|
1325
3558
|
}
|
|
1326
3559
|
|
|
1327
|
-
|
|
3560
|
+
const protocol = USE_HTTPS ? 'https' : 'http';
|
|
3561
|
+
printBanner(`${protocol}://localhost:${HTTP_PORT}`, backgroundMode);
|
|
1328
3562
|
});
|
|
1329
3563
|
}
|
|
1330
3564
|
|
|
@@ -1341,7 +3575,7 @@ export function run(argv) {
|
|
|
1341
3575
|
|
|
1342
3576
|
// If a command is provided, create a tmux session instead of starting server
|
|
1343
3577
|
if (cliArgs.command) {
|
|
1344
|
-
createTmuxSession(cliArgs.command);
|
|
3578
|
+
createTmuxSession(cliArgs.command, cliArgs.sessionName);
|
|
1345
3579
|
// createTmuxSession spawns tmux attach and waits for it to exit
|
|
1346
3580
|
// The script will exit when tmux attach exits (via the exit handler)
|
|
1347
3581
|
// We must not continue to server code, so we stop here
|