@seflless/ghosttown 1.5.0 → 1.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +47 -1
- package/package.json +2 -1
- package/scripts/cli-publish.js +11 -2
- package/src/cli.js +2430 -170
- package/src/cli.test.sh +605 -0
- package/src/cli.test.ts +75 -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
|
// ============================================================================
|
|
@@ -108,34 +360,210 @@ function isInsideGhosttownSession() {
|
|
|
108
360
|
}
|
|
109
361
|
|
|
110
362
|
/**
|
|
111
|
-
*
|
|
112
|
-
*
|
|
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
|
+
};
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
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
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 and protocol
|
|
516
|
+
* @returns {{ running: boolean, port: number | null, useHttp: boolean }}
|
|
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, useHttp: false };
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// Get the port from tmux environment
|
|
532
|
+
let port = 8080;
|
|
533
|
+
try {
|
|
534
|
+
const envOutput = execSync(`tmux show-environment -t ${SERVER_SESSION_NAME} GHOSTTOWN_PORT`, {
|
|
535
|
+
encoding: 'utf8',
|
|
536
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
537
|
+
});
|
|
538
|
+
const parsedPort = Number.parseInt(envOutput.split('=')[1], 10);
|
|
539
|
+
if (!Number.isNaN(parsedPort)) {
|
|
540
|
+
port = parsedPort;
|
|
541
|
+
}
|
|
542
|
+
} catch (e) {
|
|
543
|
+
// Use default port
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// Get the protocol from tmux environment (default to HTTPS if not set)
|
|
547
|
+
let useHttp = false;
|
|
548
|
+
try {
|
|
549
|
+
const envOutput = execSync(
|
|
550
|
+
`tmux show-environment -t ${SERVER_SESSION_NAME} GHOSTTOWN_USE_HTTP`,
|
|
551
|
+
{
|
|
552
|
+
encoding: 'utf8',
|
|
553
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
554
|
+
}
|
|
555
|
+
);
|
|
556
|
+
useHttp = envOutput.split('=')[1]?.trim() === '1';
|
|
557
|
+
} catch (e) {
|
|
558
|
+
// Use default (HTTPS)
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
return { running: true, port, useHttp };
|
|
562
|
+
} catch (err) {
|
|
563
|
+
return { running: false, port: null, useHttp: false };
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
139
567
|
/**
|
|
140
568
|
* List all ghosttown tmux sessions
|
|
141
569
|
*/
|
|
@@ -163,12 +591,12 @@ function listSessions() {
|
|
|
163
591
|
.split('\n')
|
|
164
592
|
.filter((line) => line.startsWith('gt-'))
|
|
165
593
|
.map((line) => {
|
|
166
|
-
const [
|
|
167
|
-
|
|
168
|
-
const id = name.replace('gt-', '');
|
|
594
|
+
const [fullName, activity, attached, windows] = line.split('|');
|
|
595
|
+
const parsed = parseSessionName(fullName);
|
|
169
596
|
return {
|
|
170
|
-
|
|
171
|
-
|
|
597
|
+
displayName: parsed ? parsed.displayName : fullName.replace('gt-', ''),
|
|
598
|
+
stableId: parsed ? parsed.stableId : null,
|
|
599
|
+
fullName,
|
|
172
600
|
activity: new Date(Number.parseInt(activity, 10) * 1000),
|
|
173
601
|
attached: attached === '1',
|
|
174
602
|
windows: Number.parseInt(windows, 10),
|
|
@@ -188,7 +616,7 @@ function listSessions() {
|
|
|
188
616
|
// Print header
|
|
189
617
|
console.log('\n\x1b[1mGhosttown Sessions\x1b[0m\n');
|
|
190
618
|
console.log(
|
|
191
|
-
`${CYAN}${'
|
|
619
|
+
`${CYAN}${'Name'.padEnd(20)} ${'Last Activity'.padEnd(22)} ${'Status'.padEnd(10)} Windows${RESET}`
|
|
192
620
|
);
|
|
193
621
|
|
|
194
622
|
// Print sessions
|
|
@@ -199,7 +627,7 @@ function listSessions() {
|
|
|
199
627
|
? `\x1b[32m${'attached'.padEnd(10)}${RESET}`
|
|
200
628
|
: `\x1b[33m${'detached'.padEnd(10)}${RESET}`;
|
|
201
629
|
console.log(
|
|
202
|
-
`${session.
|
|
630
|
+
`${session.displayName.padEnd(20)} ${activityStr.padEnd(22)} ${statusPadded} ${session.windows}`
|
|
203
631
|
);
|
|
204
632
|
}
|
|
205
633
|
|
|
@@ -219,39 +647,69 @@ function listSessions() {
|
|
|
219
647
|
|
|
220
648
|
/**
|
|
221
649
|
* Create a new tmux session and run the command
|
|
650
|
+
* @param {string} command - The command to run in the session
|
|
651
|
+
* @param {string|null} customName - Optional custom name for the session (without gt- prefix)
|
|
222
652
|
*/
|
|
223
|
-
function createTmuxSession(command) {
|
|
653
|
+
function createTmuxSession(command, customName = null) {
|
|
224
654
|
// Check if tmux is installed
|
|
225
655
|
if (!checkTmuxInstalled()) {
|
|
226
656
|
printTmuxInstallHelp();
|
|
227
657
|
}
|
|
228
658
|
|
|
229
|
-
const
|
|
230
|
-
const
|
|
659
|
+
const RESET = '\x1b[0m';
|
|
660
|
+
const RED = '\x1b[31m';
|
|
661
|
+
|
|
662
|
+
// Determine display name
|
|
663
|
+
let displayName;
|
|
664
|
+
if (customName) {
|
|
665
|
+
const validation = validateSessionName(customName);
|
|
666
|
+
if (!validation.valid) {
|
|
667
|
+
console.log('');
|
|
668
|
+
console.log(` ${RED}Error:${RESET} ${validation.error}`);
|
|
669
|
+
console.log('');
|
|
670
|
+
process.exit(1);
|
|
671
|
+
}
|
|
672
|
+
// Check if display name already exists
|
|
673
|
+
if (displayNameExists(customName)) {
|
|
674
|
+
console.log('');
|
|
675
|
+
console.log(` ${RED}Error:${RESET} Session '${customName}' already exists.`);
|
|
676
|
+
console.log('');
|
|
677
|
+
process.exit(1);
|
|
678
|
+
}
|
|
679
|
+
displayName = customName;
|
|
680
|
+
} else {
|
|
681
|
+
displayName = String(getNextDisplayNumber());
|
|
682
|
+
}
|
|
231
683
|
|
|
232
684
|
try {
|
|
233
|
-
// Create
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
'
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
}
|
|
685
|
+
// Create a detached session with a temporary name to get its stable ID
|
|
686
|
+
const tempName = `gt-temp-${Date.now()}`;
|
|
687
|
+
const output = execSync(
|
|
688
|
+
`tmux new-session -d -s ${tempName} -x 200 -y 50 -P -F "#{session_id}"`,
|
|
689
|
+
{ encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }
|
|
690
|
+
);
|
|
691
|
+
|
|
692
|
+
// Extract the stable ID (e.g., "$10" -> "10")
|
|
693
|
+
const stableId = output.trim().replace('$', '');
|
|
694
|
+
|
|
695
|
+
// Rename to final name: gt-<stableId>-<displayName>
|
|
696
|
+
const sessionName = `gt-${stableId}-${displayName}`;
|
|
697
|
+
execSync(`tmux rename-session -t ${tempName} ${sessionName}`, { stdio: 'pipe' });
|
|
698
|
+
|
|
699
|
+
// Set status off
|
|
700
|
+
execSync(`tmux set-option -t ${sessionName} status off`, { stdio: 'pipe' });
|
|
701
|
+
|
|
702
|
+
// Send the command to run, followed by shell
|
|
703
|
+
execSync(
|
|
704
|
+
`tmux send-keys -t ${sessionName} '${command.replace(/'/g, "'\\''")}; exec $SHELL' Enter`,
|
|
705
|
+
{ stdio: 'pipe' }
|
|
253
706
|
);
|
|
254
707
|
|
|
708
|
+
// Now attach to the session
|
|
709
|
+
const attach = spawn('tmux', ['attach-session', '-t', sessionName], {
|
|
710
|
+
stdio: 'inherit',
|
|
711
|
+
});
|
|
712
|
+
|
|
255
713
|
attach.on('exit', (code) => {
|
|
256
714
|
process.exit(code || 0);
|
|
257
715
|
});
|
|
@@ -274,15 +732,16 @@ function printDetachMessage(sessionName) {
|
|
|
274
732
|
const formatHint = (label, command) =>
|
|
275
733
|
` ${CYAN}${label.padStart(labelWidth)}${RESET} ${BEIGE}${command}${RESET}`;
|
|
276
734
|
|
|
277
|
-
// Extract
|
|
278
|
-
const
|
|
735
|
+
// Extract display name from session name
|
|
736
|
+
const parsed = parseSessionName(sessionName);
|
|
737
|
+
const displayName = parsed ? parsed.displayName : sessionName.replace('gt-', '');
|
|
279
738
|
|
|
280
739
|
return [
|
|
281
740
|
'',
|
|
282
741
|
` ${BOLD_YELLOW}You've detached from your ghosttown session.${RESET}`,
|
|
283
742
|
` ${DIM}It's now running in the background.${RESET}`,
|
|
284
743
|
'',
|
|
285
|
-
formatHint('To reattach:', `gt attach ${
|
|
744
|
+
formatHint('To reattach:', `gt attach ${displayName}`),
|
|
286
745
|
'',
|
|
287
746
|
formatHint('To list all sessions:', 'gt list'),
|
|
288
747
|
'',
|
|
@@ -566,11 +1025,12 @@ function killSession(sessionName) {
|
|
|
566
1025
|
stdio: 'pipe',
|
|
567
1026
|
});
|
|
568
1027
|
|
|
569
|
-
// Extract
|
|
570
|
-
const
|
|
1028
|
+
// Extract display name from session name
|
|
1029
|
+
const parsed = parseSessionName(sessionName);
|
|
1030
|
+
const displayName = parsed ? parsed.displayName : sessionName.replace('gt-', '');
|
|
571
1031
|
|
|
572
1032
|
console.log('');
|
|
573
|
-
console.log(` ${BOLD_YELLOW}Session ${
|
|
1033
|
+
console.log(` ${BOLD_YELLOW}Session ${displayName} has been killed.${RESET}`);
|
|
574
1034
|
console.log('');
|
|
575
1035
|
console.log(` ${CYAN}To list remaining:${RESET} ${BEIGE}gt list${RESET}`);
|
|
576
1036
|
console.log('');
|
|
@@ -583,66 +1043,504 @@ function killSession(sessionName) {
|
|
|
583
1043
|
}
|
|
584
1044
|
}
|
|
585
1045
|
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
let handled = false;
|
|
595
|
-
|
|
596
|
-
for (let i = 0; i < args.length; i++) {
|
|
597
|
-
const arg = args[i];
|
|
598
|
-
|
|
599
|
-
if (arg === '-h' || arg === '--help') {
|
|
600
|
-
console.log(`
|
|
601
|
-
Usage: ghosttown [options] [command]
|
|
1046
|
+
/**
|
|
1047
|
+
* Kill all ghosttown sessions
|
|
1048
|
+
*/
|
|
1049
|
+
function killAllSessions() {
|
|
1050
|
+
// Check if tmux is installed
|
|
1051
|
+
if (!checkTmuxInstalled()) {
|
|
1052
|
+
printTmuxInstallHelp();
|
|
1053
|
+
}
|
|
602
1054
|
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
-h, --help Show this help message
|
|
1055
|
+
const RESET = '\x1b[0m';
|
|
1056
|
+
const RED = '\x1b[31m';
|
|
1057
|
+
const BEIGE = '\x1b[38;2;255;220;150m';
|
|
1058
|
+
const BOLD_YELLOW = '\x1b[1;93m';
|
|
608
1059
|
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
<command> Run command in a new tmux session (gt-<id>)
|
|
1060
|
+
try {
|
|
1061
|
+
const output = execSync('tmux list-sessions -F "#{session_name}"', {
|
|
1062
|
+
encoding: 'utf8',
|
|
1063
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
1064
|
+
});
|
|
615
1065
|
|
|
616
|
-
|
|
617
|
-
ghosttown Start the web terminal server
|
|
618
|
-
ghosttown -p 3000 Start the server on port 3000
|
|
619
|
-
ghosttown list List all ghosttown sessions
|
|
620
|
-
ghosttown attach 1 Attach to session gt-1
|
|
621
|
-
ghosttown detach Detach from current session
|
|
622
|
-
ghosttown update Update to the latest version
|
|
623
|
-
ghosttown -k Kill current session (when inside one)
|
|
624
|
-
ghosttown -k 1 Kill session gt-1
|
|
625
|
-
ghosttown vim Run vim in a new tmux session
|
|
626
|
-
ghosttown "npm run dev" Run npm in a new tmux session
|
|
1066
|
+
const sessions = output.split('\n').filter((s) => s.trim() && s.startsWith('gt-'));
|
|
627
1067
|
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
`);
|
|
631
|
-
|
|
632
|
-
|
|
1068
|
+
if (sessions.length === 0) {
|
|
1069
|
+
console.log('');
|
|
1070
|
+
console.log(` ${BEIGE}No ghosttown sessions are currently running.${RESET}`);
|
|
1071
|
+
console.log('');
|
|
1072
|
+
process.exit(0);
|
|
633
1073
|
}
|
|
634
1074
|
|
|
635
|
-
//
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
1075
|
+
// Kill each session
|
|
1076
|
+
let killed = 0;
|
|
1077
|
+
for (const sessionName of sessions) {
|
|
1078
|
+
try {
|
|
1079
|
+
execSync(`tmux kill-session -t "${sessionName}"`, { stdio: 'pipe' });
|
|
1080
|
+
killed++;
|
|
1081
|
+
} catch (err) {
|
|
1082
|
+
// Session may have been killed by another process, continue
|
|
1083
|
+
}
|
|
640
1084
|
}
|
|
641
1085
|
|
|
642
|
-
|
|
643
|
-
if (
|
|
644
|
-
|
|
645
|
-
|
|
1086
|
+
console.log('');
|
|
1087
|
+
if (killed === 1) {
|
|
1088
|
+
console.log(` ${BOLD_YELLOW}Killed 1 session.${RESET}`);
|
|
1089
|
+
} else {
|
|
1090
|
+
console.log(` ${BOLD_YELLOW}Killed ${killed} sessions.${RESET}`);
|
|
1091
|
+
}
|
|
1092
|
+
console.log('');
|
|
1093
|
+
process.exit(0);
|
|
1094
|
+
} catch (err) {
|
|
1095
|
+
// No tmux sessions
|
|
1096
|
+
console.log('');
|
|
1097
|
+
console.log(` ${BEIGE}No ghosttown sessions are currently running.${RESET}`);
|
|
1098
|
+
console.log('');
|
|
1099
|
+
process.exit(0);
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
/**
|
|
1104
|
+
* Start the server in the background using a tmux session
|
|
1105
|
+
* @param {number} port - Port to listen on
|
|
1106
|
+
* @param {boolean} useHttp - Use HTTP instead of HTTPS (default: false, meaning HTTPS is used)
|
|
1107
|
+
*/
|
|
1108
|
+
function startServerInBackground(port = 8080, useHttp = false) {
|
|
1109
|
+
// Check if tmux is installed
|
|
1110
|
+
if (!checkTmuxInstalled()) {
|
|
1111
|
+
printTmuxInstallHelp();
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
const RESET = '\x1b[0m';
|
|
1115
|
+
const RED = '\x1b[31m';
|
|
1116
|
+
const BOLD_YELLOW = '\x1b[1;93m';
|
|
1117
|
+
const CYAN = '\x1b[36m';
|
|
1118
|
+
const BEIGE = '\x1b[38;2;255;220;150m';
|
|
1119
|
+
|
|
1120
|
+
// Check if server is already running
|
|
1121
|
+
const status = getServerStatus();
|
|
1122
|
+
if (status.running) {
|
|
1123
|
+
console.log('');
|
|
1124
|
+
console.log(` ${RED}Error:${RESET} A server is already running (port ${status.port}).`);
|
|
1125
|
+
console.log('');
|
|
1126
|
+
if (status.port !== port) {
|
|
1127
|
+
console.log(` To switch to port ${port}:`);
|
|
1128
|
+
console.log(` ${BEIGE}gt stop && gt start -p ${port}${RESET}`);
|
|
1129
|
+
} else {
|
|
1130
|
+
console.log(` ${CYAN}To stop:${RESET} ${BEIGE}gt stop${RESET}`);
|
|
1131
|
+
console.log(` ${CYAN}To check:${RESET} ${BEIGE}gt status${RESET}`);
|
|
1132
|
+
}
|
|
1133
|
+
console.log('');
|
|
1134
|
+
process.exit(1);
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
// Check if port is already in use by another process
|
|
1138
|
+
try {
|
|
1139
|
+
execSync(`lsof -i:${port}`, { stdio: 'pipe' });
|
|
1140
|
+
// If lsof succeeds, port is in use
|
|
1141
|
+
console.log('');
|
|
1142
|
+
console.log(` ${RED}Error:${RESET} Port ${port} is already in use.`);
|
|
1143
|
+
console.log('');
|
|
1144
|
+
console.log(` Try a different port: ${BEIGE}gt start -p <port>${RESET}`);
|
|
1145
|
+
console.log('');
|
|
1146
|
+
process.exit(1);
|
|
1147
|
+
} catch (e) {
|
|
1148
|
+
// lsof returns non-zero if port is free - this is what we want
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
try {
|
|
1152
|
+
// Get the path to the gt executable
|
|
1153
|
+
const gtPath = process.argv[1];
|
|
1154
|
+
|
|
1155
|
+
// Create a detached tmux session for the server
|
|
1156
|
+
execSync(`tmux new-session -d -s ${SERVER_SESSION_NAME} -x 200 -y 50`, {
|
|
1157
|
+
stdio: 'pipe',
|
|
1158
|
+
});
|
|
1159
|
+
|
|
1160
|
+
// Set the port environment variable in the tmux session
|
|
1161
|
+
execSync(`tmux set-environment -t ${SERVER_SESSION_NAME} GHOSTTOWN_PORT ${port}`, {
|
|
1162
|
+
stdio: 'pipe',
|
|
1163
|
+
});
|
|
1164
|
+
|
|
1165
|
+
// Set the protocol environment variable in the tmux session
|
|
1166
|
+
execSync(
|
|
1167
|
+
`tmux set-environment -t ${SERVER_SESSION_NAME} GHOSTTOWN_USE_HTTP ${useHttp ? '1' : '0'}`,
|
|
1168
|
+
{
|
|
1169
|
+
stdio: 'pipe',
|
|
1170
|
+
}
|
|
1171
|
+
);
|
|
1172
|
+
|
|
1173
|
+
// Disable status bar for cleaner look
|
|
1174
|
+
execSync(`tmux set-option -t ${SERVER_SESSION_NAME} status off`, { stdio: 'pipe' });
|
|
1175
|
+
|
|
1176
|
+
// Run the server with background mode flag (add --http if needed)
|
|
1177
|
+
const httpFlag = useHttp ? ' --http' : '';
|
|
1178
|
+
execSync(
|
|
1179
|
+
`tmux send-keys -t ${SERVER_SESSION_NAME} 'node "${gtPath}" --server-background -p ${port}${httpFlag}' Enter`,
|
|
1180
|
+
{ stdio: 'pipe' }
|
|
1181
|
+
);
|
|
1182
|
+
|
|
1183
|
+
// Get local IPs for display
|
|
1184
|
+
const localIPs = getLocalIPs();
|
|
1185
|
+
const protocol = useHttp ? 'http' : 'https';
|
|
1186
|
+
|
|
1187
|
+
console.log('');
|
|
1188
|
+
console.log(` ${BOLD_YELLOW}Ghosttown server started!${RESET}`);
|
|
1189
|
+
console.log('');
|
|
1190
|
+
console.log(` ${CYAN}Open:${RESET} ${BEIGE}${protocol}://localhost:${port}${RESET}`);
|
|
1191
|
+
if (localIPs.length > 0) {
|
|
1192
|
+
console.log(` ${CYAN}Network:${RESET} ${BEIGE}${protocol}://${localIPs[0]}:${port}${RESET}`);
|
|
1193
|
+
}
|
|
1194
|
+
console.log('');
|
|
1195
|
+
console.log(` ${CYAN}To stop:${RESET} ${BEIGE}gt stop${RESET}`);
|
|
1196
|
+
console.log(` ${CYAN}To check:${RESET} ${BEIGE}gt status${RESET}`);
|
|
1197
|
+
console.log('');
|
|
1198
|
+
} catch (err) {
|
|
1199
|
+
console.log('');
|
|
1200
|
+
console.log(` ${RED}Error:${RESET} Failed to start server.`);
|
|
1201
|
+
console.log(` ${err.message}`);
|
|
1202
|
+
console.log('');
|
|
1203
|
+
process.exit(1);
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
process.exit(0);
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
/**
|
|
1210
|
+
* Stop the background server
|
|
1211
|
+
*/
|
|
1212
|
+
function stopServer() {
|
|
1213
|
+
// Check if tmux is installed
|
|
1214
|
+
if (!checkTmuxInstalled()) {
|
|
1215
|
+
printTmuxInstallHelp();
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
const RESET = '\x1b[0m';
|
|
1219
|
+
const RED = '\x1b[31m';
|
|
1220
|
+
const BOLD_YELLOW = '\x1b[1;93m';
|
|
1221
|
+
const BEIGE = '\x1b[38;2;255;220;150m';
|
|
1222
|
+
|
|
1223
|
+
const status = getServerStatus();
|
|
1224
|
+
|
|
1225
|
+
if (!status.running) {
|
|
1226
|
+
console.log('');
|
|
1227
|
+
console.log(` ${BEIGE}No server is currently running.${RESET}`);
|
|
1228
|
+
console.log('');
|
|
1229
|
+
console.log(' To start: gt start');
|
|
1230
|
+
console.log('');
|
|
1231
|
+
process.exit(0);
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
try {
|
|
1235
|
+
execSync(`tmux kill-session -t ${SERVER_SESSION_NAME}`, { stdio: 'pipe' });
|
|
1236
|
+
|
|
1237
|
+
console.log('');
|
|
1238
|
+
console.log(` ${BOLD_YELLOW}Server stopped.${RESET}`);
|
|
1239
|
+
console.log('');
|
|
1240
|
+
} catch (err) {
|
|
1241
|
+
console.log('');
|
|
1242
|
+
console.log(` ${RED}Error:${RESET} Failed to stop server.`);
|
|
1243
|
+
console.log('');
|
|
1244
|
+
process.exit(1);
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
process.exit(0);
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
/**
|
|
1251
|
+
* Show server status
|
|
1252
|
+
*/
|
|
1253
|
+
function showServerStatus() {
|
|
1254
|
+
// Check if tmux is installed
|
|
1255
|
+
if (!checkTmuxInstalled()) {
|
|
1256
|
+
printTmuxInstallHelp();
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
const RESET = '\x1b[0m';
|
|
1260
|
+
const CYAN = '\x1b[36m';
|
|
1261
|
+
const BEIGE = '\x1b[38;2;255;220;150m';
|
|
1262
|
+
const GREEN = '\x1b[32m';
|
|
1263
|
+
const DIM = '\x1b[2m';
|
|
1264
|
+
|
|
1265
|
+
const status = getServerStatus();
|
|
1266
|
+
|
|
1267
|
+
console.log('');
|
|
1268
|
+
|
|
1269
|
+
if (status.running) {
|
|
1270
|
+
const localIPs = getLocalIPs();
|
|
1271
|
+
const port = status.port || 8080;
|
|
1272
|
+
const protocol = status.useHttp ? 'http' : 'https';
|
|
1273
|
+
|
|
1274
|
+
console.log(` ${GREEN}Server is running${RESET}`);
|
|
1275
|
+
console.log('');
|
|
1276
|
+
console.log(` ${CYAN}Open:${RESET} ${BEIGE}${protocol}://localhost:${port}${RESET}`);
|
|
1277
|
+
if (localIPs.length > 0) {
|
|
1278
|
+
console.log(` ${CYAN}Network:${RESET} ${BEIGE}${protocol}://${localIPs[0]}:${port}${RESET}`);
|
|
1279
|
+
}
|
|
1280
|
+
console.log('');
|
|
1281
|
+
console.log(` ${DIM}To stop: gt stop${RESET}`);
|
|
1282
|
+
} else {
|
|
1283
|
+
console.log(` ${DIM}Server is not running.${RESET}`);
|
|
1284
|
+
console.log('');
|
|
1285
|
+
console.log(' To start: gt start');
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
console.log('');
|
|
1289
|
+
process.exit(0);
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
/**
|
|
1293
|
+
* Print sessions without exiting (for use in error messages)
|
|
1294
|
+
*/
|
|
1295
|
+
function listSessionsInline() {
|
|
1296
|
+
const RESET = '\x1b[0m';
|
|
1297
|
+
const CYAN = '\x1b[36m';
|
|
1298
|
+
|
|
1299
|
+
try {
|
|
1300
|
+
const output = execSync(
|
|
1301
|
+
'tmux list-sessions -F "#{session_name}|#{session_activity}|#{session_attached}|#{session_windows}"',
|
|
1302
|
+
{
|
|
1303
|
+
encoding: 'utf8',
|
|
1304
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
1305
|
+
}
|
|
1306
|
+
);
|
|
1307
|
+
|
|
1308
|
+
const sessions = output
|
|
1309
|
+
.split('\n')
|
|
1310
|
+
.filter((line) => line.startsWith('gt-'))
|
|
1311
|
+
.map((line) => {
|
|
1312
|
+
const [fullName, activity, attached, windows] = line.split('|');
|
|
1313
|
+
const parsed = parseSessionName(fullName);
|
|
1314
|
+
return {
|
|
1315
|
+
displayName: parsed ? parsed.displayName : fullName.replace('gt-', ''),
|
|
1316
|
+
fullName,
|
|
1317
|
+
activity: new Date(Number.parseInt(activity, 10) * 1000),
|
|
1318
|
+
attached: attached === '1',
|
|
1319
|
+
windows: Number.parseInt(windows, 10),
|
|
1320
|
+
};
|
|
1321
|
+
});
|
|
1322
|
+
|
|
1323
|
+
if (sessions.length === 0) {
|
|
1324
|
+
console.log(' No ghosttown sessions are currently running.');
|
|
1325
|
+
return;
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
// Sort by last activity (most recent first)
|
|
1329
|
+
sessions.sort((a, b) => b.activity.getTime() - a.activity.getTime());
|
|
1330
|
+
|
|
1331
|
+
console.log(` ${CYAN}Available sessions:${RESET}`);
|
|
1332
|
+
for (const session of sessions) {
|
|
1333
|
+
const status = session.attached ? 'attached' : 'detached';
|
|
1334
|
+
console.log(` ${session.displayName} (${status})`);
|
|
1335
|
+
}
|
|
1336
|
+
} catch (err) {
|
|
1337
|
+
console.log(' No ghosttown sessions are currently running.');
|
|
1338
|
+
}
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
/**
|
|
1342
|
+
* Find a ghosttown session by display name
|
|
1343
|
+
* Returns the parsed session info or null if not found
|
|
1344
|
+
*/
|
|
1345
|
+
function findSessionByDisplayName(displayName) {
|
|
1346
|
+
try {
|
|
1347
|
+
const output = execSync('tmux list-sessions -F "#{session_name}"', {
|
|
1348
|
+
encoding: 'utf8',
|
|
1349
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
1350
|
+
});
|
|
1351
|
+
const sessions = output.split('\n').filter((s) => s.trim() && s.startsWith('gt-'));
|
|
1352
|
+
for (const fullName of sessions) {
|
|
1353
|
+
const parsed = parseSessionName(fullName);
|
|
1354
|
+
if (parsed && parsed.displayName === displayName) {
|
|
1355
|
+
return parsed;
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
return null;
|
|
1359
|
+
} catch (err) {
|
|
1360
|
+
return null;
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
/**
|
|
1365
|
+
* Rename a ghosttown session
|
|
1366
|
+
* Usage:
|
|
1367
|
+
* gt rename <existing> <new-name> - rename from outside session
|
|
1368
|
+
* gt rename <new-name> - rename current session (inside a gt session)
|
|
1369
|
+
*/
|
|
1370
|
+
function renameSession(renameArgs) {
|
|
1371
|
+
// Check if tmux is installed
|
|
1372
|
+
if (!checkTmuxInstalled()) {
|
|
1373
|
+
printTmuxInstallHelp();
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
const RESET = '\x1b[0m';
|
|
1377
|
+
const RED = '\x1b[31m';
|
|
1378
|
+
const BOLD_YELLOW = '\x1b[1;93m';
|
|
1379
|
+
|
|
1380
|
+
let oldFullName, oldDisplayName, stableId, newDisplayName;
|
|
1381
|
+
|
|
1382
|
+
if (renameArgs.length === 1) {
|
|
1383
|
+
// Inside session: gt rename <new-name>
|
|
1384
|
+
if (!isInsideGhosttownSession()) {
|
|
1385
|
+
console.log('');
|
|
1386
|
+
console.log(` ${RED}Error:${RESET} Not inside a ghosttown session.`);
|
|
1387
|
+
console.log('');
|
|
1388
|
+
console.log(' Usage: gt rename <existing-session> <new-name>');
|
|
1389
|
+
console.log(' gt rename <new-name> (when inside a session)');
|
|
1390
|
+
console.log('');
|
|
1391
|
+
listSessionsInline();
|
|
1392
|
+
console.log('');
|
|
1393
|
+
process.exit(1);
|
|
1394
|
+
}
|
|
1395
|
+
oldFullName = getCurrentTmuxSessionName();
|
|
1396
|
+
const parsed = parseSessionName(oldFullName);
|
|
1397
|
+
oldDisplayName = parsed ? parsed.displayName : oldFullName.replace('gt-', '');
|
|
1398
|
+
stableId = parsed ? parsed.stableId : null;
|
|
1399
|
+
newDisplayName = renameArgs[0];
|
|
1400
|
+
} else if (renameArgs.length === 2) {
|
|
1401
|
+
// Outside: gt rename <existing> <new-name>
|
|
1402
|
+
// User provides display name, we need to find the full name
|
|
1403
|
+
const searchName = renameArgs[0];
|
|
1404
|
+
const found = findSessionByDisplayName(searchName);
|
|
1405
|
+
if (!found) {
|
|
1406
|
+
console.log('');
|
|
1407
|
+
console.log(` ${RED}Error:${RESET} Session '${searchName}' not found.`);
|
|
1408
|
+
console.log('');
|
|
1409
|
+
listSessionsInline();
|
|
1410
|
+
console.log('');
|
|
1411
|
+
process.exit(1);
|
|
1412
|
+
}
|
|
1413
|
+
oldFullName = found.fullName;
|
|
1414
|
+
oldDisplayName = found.displayName;
|
|
1415
|
+
stableId = found.stableId;
|
|
1416
|
+
newDisplayName = renameArgs[1];
|
|
1417
|
+
} else {
|
|
1418
|
+
console.log('');
|
|
1419
|
+
console.log(` ${RED}Error:${RESET} Invalid arguments.`);
|
|
1420
|
+
console.log('');
|
|
1421
|
+
console.log(' Usage: gt rename <existing-session> <new-name>');
|
|
1422
|
+
console.log(' gt rename <new-name> (when inside a session)');
|
|
1423
|
+
console.log('');
|
|
1424
|
+
process.exit(1);
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
// Validate new name
|
|
1428
|
+
const validation = validateSessionName(newDisplayName);
|
|
1429
|
+
if (!validation.valid) {
|
|
1430
|
+
console.log('');
|
|
1431
|
+
console.log(` ${RED}Error:${RESET} ${validation.error}`);
|
|
1432
|
+
console.log('');
|
|
1433
|
+
listSessionsInline();
|
|
1434
|
+
console.log('');
|
|
1435
|
+
process.exit(1);
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
// Check new display name doesn't conflict
|
|
1439
|
+
if (displayNameExists(newDisplayName)) {
|
|
1440
|
+
console.log('');
|
|
1441
|
+
console.log(` ${RED}Error:${RESET} Session '${newDisplayName}' already exists.`);
|
|
1442
|
+
console.log('');
|
|
1443
|
+
listSessionsInline();
|
|
1444
|
+
console.log('');
|
|
1445
|
+
process.exit(1);
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
// Build new full name, preserving stable ID if present
|
|
1449
|
+
const newFullName = stableId ? `gt-${stableId}-${newDisplayName}` : `gt-${newDisplayName}`;
|
|
1450
|
+
|
|
1451
|
+
// Perform rename
|
|
1452
|
+
try {
|
|
1453
|
+
execSync(`tmux rename-session -t "${oldFullName}" "${newFullName}"`, { stdio: 'pipe' });
|
|
1454
|
+
console.log('');
|
|
1455
|
+
console.log(` ${BOLD_YELLOW}Session renamed: ${oldDisplayName} -> ${newDisplayName}${RESET}`);
|
|
1456
|
+
console.log('');
|
|
1457
|
+
process.exit(0);
|
|
1458
|
+
} catch (err) {
|
|
1459
|
+
console.log('');
|
|
1460
|
+
console.log(` ${RED}Error:${RESET} Failed to rename session.`);
|
|
1461
|
+
console.log('');
|
|
1462
|
+
process.exit(1);
|
|
1463
|
+
}
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
// ============================================================================
|
|
1467
|
+
// Parse CLI arguments
|
|
1468
|
+
// ============================================================================
|
|
1469
|
+
|
|
1470
|
+
function parseArgs(argv) {
|
|
1471
|
+
const args = argv.slice(2);
|
|
1472
|
+
let port = null;
|
|
1473
|
+
let command = null;
|
|
1474
|
+
let handled = false;
|
|
1475
|
+
let sessionName = null;
|
|
1476
|
+
let useHttp = false;
|
|
1477
|
+
let serverBackgroundMode = false;
|
|
1478
|
+
|
|
1479
|
+
for (let i = 0; i < args.length; i++) {
|
|
1480
|
+
const arg = args[i];
|
|
1481
|
+
|
|
1482
|
+
if (arg === '-h' || arg === '--help') {
|
|
1483
|
+
console.log(`
|
|
1484
|
+
Usage: ghosttown [options] [command]
|
|
1485
|
+
|
|
1486
|
+
Options:
|
|
1487
|
+
-p, --port <port> Port to listen on (default: 8080, or PORT env var)
|
|
1488
|
+
-n, --name <name> Give the session a custom name (use with a command)
|
|
1489
|
+
--http Use HTTP instead of HTTPS (default is HTTPS)
|
|
1490
|
+
-k, --kill [session] Kill a session (current if inside one, or specify by name/ID)
|
|
1491
|
+
-ka, --kill-all Kill all ghosttown sessions
|
|
1492
|
+
-v, --version Show version number
|
|
1493
|
+
-h, --help Show this help message
|
|
1494
|
+
|
|
1495
|
+
Commands:
|
|
1496
|
+
start [-p <port>] Start the server in the background (default port: 8080)
|
|
1497
|
+
stop Stop the background server
|
|
1498
|
+
status Show server status and URLs
|
|
1499
|
+
list List all ghosttown tmux sessions
|
|
1500
|
+
attach <name|id> Attach to a ghosttown session by name or ID
|
|
1501
|
+
detach Detach from current ghosttown session
|
|
1502
|
+
rename [old] <new> Rename a session (current session if only one arg given)
|
|
1503
|
+
update Update ghosttown to the latest version
|
|
1504
|
+
<command> Run command in a new tmux session
|
|
1505
|
+
|
|
1506
|
+
Examples:
|
|
1507
|
+
ghosttown start Start the server in the background
|
|
1508
|
+
ghosttown start -p 3000 Start on port 3000
|
|
1509
|
+
ghosttown stop Stop the background server
|
|
1510
|
+
ghosttown status Check if server is running
|
|
1511
|
+
ghosttown Start the web terminal server (foreground)
|
|
1512
|
+
ghosttown -p 3000 Start the server on port 3000 (foreground)
|
|
1513
|
+
ghosttown --http Use plain HTTP instead of HTTPS
|
|
1514
|
+
ghosttown list List all ghosttown sessions
|
|
1515
|
+
ghosttown attach 1 Attach to session gt-1
|
|
1516
|
+
ghosttown attach my-project Attach to session gt-my-project
|
|
1517
|
+
ghosttown detach Detach from current session
|
|
1518
|
+
ghosttown rename my-project Rename current session to 'my-project'
|
|
1519
|
+
ghosttown rename 1 my-project Rename session gt-1 to gt-my-project
|
|
1520
|
+
ghosttown update Update to the latest version
|
|
1521
|
+
ghosttown -k Kill current session (when inside one)
|
|
1522
|
+
ghosttown -k my-project Kill session gt-my-project
|
|
1523
|
+
ghosttown -ka Kill all ghosttown sessions
|
|
1524
|
+
ghosttown -n my-project vim Run vim in session named 'gt-my-project'
|
|
1525
|
+
ghosttown vim Run vim in auto-named session (gt-1, gt-2, etc.)
|
|
1526
|
+
ghosttown "npm run dev" Run npm in a new tmux session
|
|
1527
|
+
|
|
1528
|
+
Aliases:
|
|
1529
|
+
This CLI can also be invoked as 'gt' or 'ght'.
|
|
1530
|
+
`);
|
|
1531
|
+
process.exit(0);
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
// Handle version flag
|
|
1535
|
+
if (arg === '-v' || arg === '--version') {
|
|
1536
|
+
console.log(VERSION);
|
|
1537
|
+
process.exit(0);
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1540
|
+
// Handle list command
|
|
1541
|
+
if (arg === 'list') {
|
|
1542
|
+
handled = true;
|
|
1543
|
+
listSessions();
|
|
646
1544
|
// listSessions exits, so this won't be reached
|
|
647
1545
|
}
|
|
648
1546
|
|
|
@@ -664,8 +1562,8 @@ Aliases:
|
|
|
664
1562
|
else if (arg === 'attach') {
|
|
665
1563
|
const sessionArg = args[i + 1];
|
|
666
1564
|
if (!sessionArg) {
|
|
667
|
-
console.error('Error: attach requires a session ID');
|
|
668
|
-
console.error('Usage: gt attach <id>');
|
|
1565
|
+
console.error('Error: attach requires a session name or ID');
|
|
1566
|
+
console.error('Usage: gt attach <name|id>');
|
|
669
1567
|
handled = true;
|
|
670
1568
|
break;
|
|
671
1569
|
}
|
|
@@ -674,6 +1572,14 @@ Aliases:
|
|
|
674
1572
|
// attachToSession exits, so this won't be reached
|
|
675
1573
|
}
|
|
676
1574
|
|
|
1575
|
+
// Handle rename command
|
|
1576
|
+
else if (arg === 'rename') {
|
|
1577
|
+
const renameArgs = args.slice(i + 1);
|
|
1578
|
+
handled = true;
|
|
1579
|
+
renameSession(renameArgs);
|
|
1580
|
+
// renameSession exits, so this won't be reached
|
|
1581
|
+
}
|
|
1582
|
+
|
|
677
1583
|
// Handle kill command
|
|
678
1584
|
else if (arg === '-k' || arg === '--kill') {
|
|
679
1585
|
const nextArg = args[i + 1];
|
|
@@ -682,6 +1588,59 @@ Aliases:
|
|
|
682
1588
|
handled = true;
|
|
683
1589
|
killSession(sessionArg);
|
|
684
1590
|
// killSession exits, so this won't be reached
|
|
1591
|
+
}
|
|
1592
|
+
|
|
1593
|
+
// Handle kill-all command
|
|
1594
|
+
else if (arg === '-ka' || arg === '--kill-all') {
|
|
1595
|
+
handled = true;
|
|
1596
|
+
killAllSessions();
|
|
1597
|
+
// killAllSessions exits, so this won't be reached
|
|
1598
|
+
}
|
|
1599
|
+
|
|
1600
|
+
// Handle start command
|
|
1601
|
+
else if (arg === 'start') {
|
|
1602
|
+
// Parse remaining args after 'start' for -p and --http flags
|
|
1603
|
+
let startPort = 8080;
|
|
1604
|
+
let startUseHttp = false;
|
|
1605
|
+
const remainingArgs = args.slice(i + 1);
|
|
1606
|
+
for (let j = 0; j < remainingArgs.length; j++) {
|
|
1607
|
+
const subArg = remainingArgs[j];
|
|
1608
|
+
if (subArg === '-p' || subArg === '--port') {
|
|
1609
|
+
const portArg = remainingArgs[j + 1];
|
|
1610
|
+
if (portArg) {
|
|
1611
|
+
startPort = Number.parseInt(portArg, 10);
|
|
1612
|
+
if (Number.isNaN(startPort) || startPort < 1 || startPort > 65535) {
|
|
1613
|
+
console.error(`Error: Invalid port number: ${portArg}`);
|
|
1614
|
+
process.exit(1);
|
|
1615
|
+
}
|
|
1616
|
+
}
|
|
1617
|
+
j++; // Skip the port value
|
|
1618
|
+
} else if (subArg === '--http') {
|
|
1619
|
+
startUseHttp = true;
|
|
1620
|
+
}
|
|
1621
|
+
}
|
|
1622
|
+
handled = true;
|
|
1623
|
+
startServerInBackground(startPort, startUseHttp);
|
|
1624
|
+
// startServerInBackground exits, so this won't be reached
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1627
|
+
// Handle stop command
|
|
1628
|
+
else if (arg === 'stop') {
|
|
1629
|
+
handled = true;
|
|
1630
|
+
stopServer();
|
|
1631
|
+
// stopServer exits, so this won't be reached
|
|
1632
|
+
}
|
|
1633
|
+
|
|
1634
|
+
// Handle status command
|
|
1635
|
+
else if (arg === 'status') {
|
|
1636
|
+
handled = true;
|
|
1637
|
+
showServerStatus();
|
|
1638
|
+
// showServerStatus exits, so this won't be reached
|
|
1639
|
+
}
|
|
1640
|
+
|
|
1641
|
+
// Handle hidden --server-background flag (used internally by gt start)
|
|
1642
|
+
else if (arg === '--server-background') {
|
|
1643
|
+
serverBackgroundMode = true;
|
|
685
1644
|
} else if (arg === '-p' || arg === '--port') {
|
|
686
1645
|
const nextArg = args[i + 1];
|
|
687
1646
|
if (!nextArg || nextArg.startsWith('-')) {
|
|
@@ -696,6 +1655,22 @@ Aliases:
|
|
|
696
1655
|
i++; // Skip the next argument since we consumed it
|
|
697
1656
|
}
|
|
698
1657
|
|
|
1658
|
+
// Handle name flag
|
|
1659
|
+
else if (arg === '-n' || arg === '--name') {
|
|
1660
|
+
const nextArg = args[i + 1];
|
|
1661
|
+
if (!nextArg || nextArg.startsWith('-')) {
|
|
1662
|
+
console.error(`Error: ${arg} requires a session name`);
|
|
1663
|
+
process.exit(1);
|
|
1664
|
+
}
|
|
1665
|
+
sessionName = nextArg;
|
|
1666
|
+
i++; // Skip the next argument since we consumed it
|
|
1667
|
+
}
|
|
1668
|
+
|
|
1669
|
+
// Handle http flag (disables HTTPS)
|
|
1670
|
+
else if (arg === '--http') {
|
|
1671
|
+
useHttp = true;
|
|
1672
|
+
}
|
|
1673
|
+
|
|
699
1674
|
// First non-flag argument starts the command
|
|
700
1675
|
// Capture it and all remaining arguments as the command
|
|
701
1676
|
else if (!arg.startsWith('-')) {
|
|
@@ -704,7 +1679,7 @@ Aliases:
|
|
|
704
1679
|
}
|
|
705
1680
|
}
|
|
706
1681
|
|
|
707
|
-
return { port, command, handled };
|
|
1682
|
+
return { port, command, handled, sessionName, useHttp, serverBackgroundMode };
|
|
708
1683
|
}
|
|
709
1684
|
|
|
710
1685
|
// ============================================================================
|
|
@@ -713,6 +1688,46 @@ Aliases:
|
|
|
713
1688
|
|
|
714
1689
|
function startWebServer(cliArgs) {
|
|
715
1690
|
const HTTP_PORT = cliArgs.port || process.env.PORT || 8080;
|
|
1691
|
+
const USE_HTTPS = !cliArgs.useHttp;
|
|
1692
|
+
const backgroundMode = cliArgs.serverBackgroundMode || false;
|
|
1693
|
+
|
|
1694
|
+
// Generate self-signed certificate for HTTPS
|
|
1695
|
+
function generateSelfSignedCert() {
|
|
1696
|
+
try {
|
|
1697
|
+
const certDir = path.join(homedir(), '.ghosttown');
|
|
1698
|
+
const keyPath = path.join(certDir, 'server.key');
|
|
1699
|
+
const certPath = path.join(certDir, 'server.crt');
|
|
1700
|
+
|
|
1701
|
+
// Check if certs already exist
|
|
1702
|
+
if (fs.existsSync(keyPath) && fs.existsSync(certPath)) {
|
|
1703
|
+
return {
|
|
1704
|
+
key: fs.readFileSync(keyPath),
|
|
1705
|
+
cert: fs.readFileSync(certPath),
|
|
1706
|
+
};
|
|
1707
|
+
}
|
|
1708
|
+
|
|
1709
|
+
// Create cert directory
|
|
1710
|
+
if (!fs.existsSync(certDir)) {
|
|
1711
|
+
fs.mkdirSync(certDir, { recursive: true });
|
|
1712
|
+
}
|
|
1713
|
+
|
|
1714
|
+
// Generate using openssl
|
|
1715
|
+
console.log('Generating self-signed certificate...');
|
|
1716
|
+
execSync(
|
|
1717
|
+
`openssl req -x509 -newkey rsa:2048 -keyout "${keyPath}" -out "${certPath}" -days 365 -nodes -subj "/CN=localhost"`,
|
|
1718
|
+
{ stdio: 'pipe' }
|
|
1719
|
+
);
|
|
1720
|
+
|
|
1721
|
+
return {
|
|
1722
|
+
key: fs.readFileSync(keyPath),
|
|
1723
|
+
cert: fs.readFileSync(certPath),
|
|
1724
|
+
};
|
|
1725
|
+
} catch (err) {
|
|
1726
|
+
console.error('Failed to generate SSL certificate.');
|
|
1727
|
+
console.error('Make sure openssl is installed: brew install openssl');
|
|
1728
|
+
process.exit(1);
|
|
1729
|
+
}
|
|
1730
|
+
}
|
|
716
1731
|
|
|
717
1732
|
// ============================================================================
|
|
718
1733
|
// Locate ghosttown assets
|
|
@@ -783,6 +1798,208 @@ function startWebServer(cliArgs) {
|
|
|
783
1798
|
height: var(--vvh);
|
|
784
1799
|
}
|
|
785
1800
|
|
|
1801
|
+
/* Session List View Styles */
|
|
1802
|
+
.session-list-view {
|
|
1803
|
+
display: none;
|
|
1804
|
+
width: 100%;
|
|
1805
|
+
height: 100%;
|
|
1806
|
+
justify-content: center;
|
|
1807
|
+
align-items: center;
|
|
1808
|
+
padding: 8px;
|
|
1809
|
+
box-sizing: border-box;
|
|
1810
|
+
}
|
|
1811
|
+
|
|
1812
|
+
.session-list-view.active {
|
|
1813
|
+
display: flex;
|
|
1814
|
+
}
|
|
1815
|
+
|
|
1816
|
+
.session-card {
|
|
1817
|
+
background: #1e2127;
|
|
1818
|
+
border-radius: 12px;
|
|
1819
|
+
padding: 24px;
|
|
1820
|
+
width: 100%;
|
|
1821
|
+
max-width: 600px;
|
|
1822
|
+
box-sizing: border-box;
|
|
1823
|
+
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
|
|
1824
|
+
}
|
|
1825
|
+
|
|
1826
|
+
.session-card h1 {
|
|
1827
|
+
color: #e5e5e5;
|
|
1828
|
+
font-size: 24px;
|
|
1829
|
+
font-weight: 600;
|
|
1830
|
+
margin-bottom: 20px;
|
|
1831
|
+
text-align: center;
|
|
1832
|
+
}
|
|
1833
|
+
|
|
1834
|
+
.session-table {
|
|
1835
|
+
width: 100%;
|
|
1836
|
+
border-collapse: collapse;
|
|
1837
|
+
margin-bottom: 20px;
|
|
1838
|
+
}
|
|
1839
|
+
|
|
1840
|
+
.session-table th {
|
|
1841
|
+
color: #888;
|
|
1842
|
+
font-size: 11px;
|
|
1843
|
+
font-weight: 500;
|
|
1844
|
+
text-transform: uppercase;
|
|
1845
|
+
letter-spacing: 0.5px;
|
|
1846
|
+
text-align: left;
|
|
1847
|
+
padding: 8px 12px;
|
|
1848
|
+
border-bottom: 1px solid #3a3f4b;
|
|
1849
|
+
}
|
|
1850
|
+
|
|
1851
|
+
.session-table td {
|
|
1852
|
+
color: #d4d4d4;
|
|
1853
|
+
font-size: 13px;
|
|
1854
|
+
padding: 12px;
|
|
1855
|
+
border-bottom: 1px solid #2a2f38;
|
|
1856
|
+
}
|
|
1857
|
+
|
|
1858
|
+
.session-table tr.session-row {
|
|
1859
|
+
transition: background 0.15s;
|
|
1860
|
+
}
|
|
1861
|
+
|
|
1862
|
+
.session-table tr.session-row:hover {
|
|
1863
|
+
background: #2a2f38;
|
|
1864
|
+
}
|
|
1865
|
+
|
|
1866
|
+
.session-table tr.session-row td {
|
|
1867
|
+
padding: 0;
|
|
1868
|
+
}
|
|
1869
|
+
|
|
1870
|
+
.session-table tr.session-row td a {
|
|
1871
|
+
display: block;
|
|
1872
|
+
padding: 12px;
|
|
1873
|
+
color: inherit;
|
|
1874
|
+
text-decoration: none;
|
|
1875
|
+
}
|
|
1876
|
+
|
|
1877
|
+
.session-table tr.session-row td.actions-cell {
|
|
1878
|
+
padding: 12px 8px;
|
|
1879
|
+
width: 40px;
|
|
1880
|
+
}
|
|
1881
|
+
|
|
1882
|
+
.copy-btn {
|
|
1883
|
+
background: transparent;
|
|
1884
|
+
border: 1px solid #3a3f48;
|
|
1885
|
+
border-radius: 4px;
|
|
1886
|
+
color: #8a8f98;
|
|
1887
|
+
cursor: pointer;
|
|
1888
|
+
padding: 4px 8px;
|
|
1889
|
+
font-size: 12px;
|
|
1890
|
+
transition: all 0.15s;
|
|
1891
|
+
min-width: 60px;
|
|
1892
|
+
}
|
|
1893
|
+
|
|
1894
|
+
.copy-btn:hover {
|
|
1895
|
+
background: #3a3f48;
|
|
1896
|
+
color: #fff;
|
|
1897
|
+
}
|
|
1898
|
+
|
|
1899
|
+
.copy-btn.copied {
|
|
1900
|
+
color: #4ade80;
|
|
1901
|
+
border-color: #4ade80;
|
|
1902
|
+
}
|
|
1903
|
+
|
|
1904
|
+
.session-status {
|
|
1905
|
+
display: inline-block;
|
|
1906
|
+
padding: 2px 8px;
|
|
1907
|
+
border-radius: 4px;
|
|
1908
|
+
font-size: 11px;
|
|
1909
|
+
font-weight: 500;
|
|
1910
|
+
}
|
|
1911
|
+
|
|
1912
|
+
.session-status.connected {
|
|
1913
|
+
background: rgba(39, 201, 63, 0.2);
|
|
1914
|
+
color: #27c93f;
|
|
1915
|
+
}
|
|
1916
|
+
|
|
1917
|
+
.session-status.disconnected {
|
|
1918
|
+
background: rgba(255, 189, 46, 0.2);
|
|
1919
|
+
color: #ffbd2e;
|
|
1920
|
+
}
|
|
1921
|
+
|
|
1922
|
+
.empty-state {
|
|
1923
|
+
text-align: center;
|
|
1924
|
+
padding: 40px 20px;
|
|
1925
|
+
color: #888;
|
|
1926
|
+
}
|
|
1927
|
+
|
|
1928
|
+
.empty-state p {
|
|
1929
|
+
margin-bottom: 20px;
|
|
1930
|
+
font-size: 14px;
|
|
1931
|
+
}
|
|
1932
|
+
|
|
1933
|
+
.create-btn {
|
|
1934
|
+
background: #3a7afe;
|
|
1935
|
+
color: white;
|
|
1936
|
+
border: none;
|
|
1937
|
+
padding: 10px 20px;
|
|
1938
|
+
border-radius: 6px;
|
|
1939
|
+
font-size: 14px;
|
|
1940
|
+
font-weight: 500;
|
|
1941
|
+
cursor: pointer;
|
|
1942
|
+
transition: background 0.15s;
|
|
1943
|
+
display: block;
|
|
1944
|
+
width: 100%;
|
|
1945
|
+
}
|
|
1946
|
+
|
|
1947
|
+
.create-btn:hover {
|
|
1948
|
+
background: #2563eb;
|
|
1949
|
+
}
|
|
1950
|
+
|
|
1951
|
+
.button-row {
|
|
1952
|
+
display: flex;
|
|
1953
|
+
gap: 12px;
|
|
1954
|
+
}
|
|
1955
|
+
|
|
1956
|
+
.button-row .create-btn {
|
|
1957
|
+
flex: 1;
|
|
1958
|
+
}
|
|
1959
|
+
|
|
1960
|
+
.logout-btn {
|
|
1961
|
+
background: transparent;
|
|
1962
|
+
color: #888;
|
|
1963
|
+
border: 1px solid #3a3f48;
|
|
1964
|
+
padding: 10px 16px;
|
|
1965
|
+
border-radius: 6px;
|
|
1966
|
+
font-size: 14px;
|
|
1967
|
+
font-weight: 500;
|
|
1968
|
+
cursor: pointer;
|
|
1969
|
+
transition: all 0.15s;
|
|
1970
|
+
}
|
|
1971
|
+
|
|
1972
|
+
.logout-btn:hover {
|
|
1973
|
+
background: #3a3f48;
|
|
1974
|
+
color: #fff;
|
|
1975
|
+
}
|
|
1976
|
+
|
|
1977
|
+
.error-message {
|
|
1978
|
+
background: rgba(255, 95, 86, 0.1);
|
|
1979
|
+
border: 1px solid rgba(255, 95, 86, 0.3);
|
|
1980
|
+
border-radius: 6px;
|
|
1981
|
+
padding: 12px 16px;
|
|
1982
|
+
margin-bottom: 16px;
|
|
1983
|
+
color: #ff5f56;
|
|
1984
|
+
font-size: 13px;
|
|
1985
|
+
display: none;
|
|
1986
|
+
}
|
|
1987
|
+
|
|
1988
|
+
.error-message.visible {
|
|
1989
|
+
display: block;
|
|
1990
|
+
}
|
|
1991
|
+
|
|
1992
|
+
/* Terminal View Styles */
|
|
1993
|
+
.terminal-view {
|
|
1994
|
+
display: none;
|
|
1995
|
+
width: 100%;
|
|
1996
|
+
height: 100%;
|
|
1997
|
+
}
|
|
1998
|
+
|
|
1999
|
+
.terminal-view.active {
|
|
2000
|
+
display: block;
|
|
2001
|
+
}
|
|
2002
|
+
|
|
786
2003
|
.terminal-window {
|
|
787
2004
|
width: 100%;
|
|
788
2005
|
height: 100%;
|
|
@@ -845,6 +2062,21 @@ function startWebServer(cliArgs) {
|
|
|
845
2062
|
white-space: nowrap;
|
|
846
2063
|
}
|
|
847
2064
|
|
|
2065
|
+
.back-link {
|
|
2066
|
+
color: #888;
|
|
2067
|
+
font-size: 12px;
|
|
2068
|
+
text-decoration: none;
|
|
2069
|
+
margin-right: 8px;
|
|
2070
|
+
padding: 4px 8px;
|
|
2071
|
+
border-radius: 4px;
|
|
2072
|
+
transition: background 0.15s, color 0.15s;
|
|
2073
|
+
}
|
|
2074
|
+
|
|
2075
|
+
.back-link:hover {
|
|
2076
|
+
background: #3a4049;
|
|
2077
|
+
color: #e5e5e5;
|
|
2078
|
+
}
|
|
2079
|
+
|
|
848
2080
|
.connection-status {
|
|
849
2081
|
margin-left: auto;
|
|
850
2082
|
font-size: 11px;
|
|
@@ -877,35 +2109,311 @@ function startWebServer(cliArgs) {
|
|
|
877
2109
|
display: block;
|
|
878
2110
|
touch-action: none;
|
|
879
2111
|
}
|
|
2112
|
+
|
|
2113
|
+
.connection-error-overlay {
|
|
2114
|
+
display: none;
|
|
2115
|
+
position: absolute;
|
|
2116
|
+
top: 0;
|
|
2117
|
+
left: 0;
|
|
2118
|
+
right: 0;
|
|
2119
|
+
bottom: 0;
|
|
2120
|
+
background: rgba(41, 44, 52, 0.95);
|
|
2121
|
+
z-index: 100;
|
|
2122
|
+
flex-direction: column;
|
|
2123
|
+
align-items: center;
|
|
2124
|
+
justify-content: center;
|
|
2125
|
+
padding: 20px;
|
|
2126
|
+
text-align: center;
|
|
2127
|
+
}
|
|
2128
|
+
|
|
2129
|
+
.connection-error-overlay.visible {
|
|
2130
|
+
display: flex;
|
|
2131
|
+
}
|
|
2132
|
+
|
|
2133
|
+
.connection-error-overlay h2 {
|
|
2134
|
+
color: #ff5f56;
|
|
2135
|
+
margin-bottom: 16px;
|
|
2136
|
+
font-size: 18px;
|
|
2137
|
+
}
|
|
2138
|
+
|
|
2139
|
+
.connection-error-overlay p {
|
|
2140
|
+
color: #d4d4d4;
|
|
2141
|
+
margin-bottom: 12px;
|
|
2142
|
+
line-height: 1.5;
|
|
2143
|
+
max-width: 300px;
|
|
2144
|
+
}
|
|
2145
|
+
|
|
2146
|
+
.connection-error-overlay a {
|
|
2147
|
+
color: #27c93f;
|
|
2148
|
+
text-decoration: underline;
|
|
2149
|
+
}
|
|
2150
|
+
|
|
2151
|
+
.connection-error-overlay .retry-btn {
|
|
2152
|
+
margin-top: 16px;
|
|
2153
|
+
padding: 8px 24px;
|
|
2154
|
+
background: #27c93f;
|
|
2155
|
+
color: #1e2127;
|
|
2156
|
+
border: none;
|
|
2157
|
+
border-radius: 6px;
|
|
2158
|
+
font-size: 14px;
|
|
2159
|
+
cursor: pointer;
|
|
2160
|
+
}
|
|
880
2161
|
</style>
|
|
881
2162
|
</head>
|
|
882
2163
|
<body>
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
2164
|
+
<!-- Session List View -->
|
|
2165
|
+
<div class="session-list-view" id="session-list-view">
|
|
2166
|
+
<div class="session-card">
|
|
2167
|
+
<h1>Ghost Town</h1>
|
|
2168
|
+
<div class="error-message" id="error-message"></div>
|
|
2169
|
+
<div id="session-list-content">
|
|
2170
|
+
<!-- Populated by JavaScript -->
|
|
2171
|
+
</div>
|
|
2172
|
+
<div class="button-row">
|
|
2173
|
+
<button class="create-btn" id="create-session-btn">Create Session</button>
|
|
2174
|
+
<button class="logout-btn" id="logout-btn">Logout</button>
|
|
889
2175
|
</div>
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
2176
|
+
</div>
|
|
2177
|
+
</div>
|
|
2178
|
+
|
|
2179
|
+
<!-- Terminal View -->
|
|
2180
|
+
<div class="terminal-view" id="terminal-view">
|
|
2181
|
+
<div class="terminal-window">
|
|
2182
|
+
<div class="title-bar">
|
|
2183
|
+
<div class="traffic-lights">
|
|
2184
|
+
<span class="light red"></span>
|
|
2185
|
+
<span class="light yellow"></span>
|
|
2186
|
+
<span class="light green"></span>
|
|
2187
|
+
</div>
|
|
2188
|
+
<a href="/" class="back-link" id="back-link">← Sessions</a>
|
|
2189
|
+
<div class="title">
|
|
2190
|
+
<span id="session-name">ghosttown</span>
|
|
2191
|
+
<span class="title-separator" id="title-separator" style="display: none">•</span>
|
|
2192
|
+
<span class="current-directory" id="current-directory"></span>
|
|
2193
|
+
</div>
|
|
2194
|
+
<div class="connection-status">
|
|
2195
|
+
<span class="connection-dot" id="connection-dot"></span>
|
|
2196
|
+
<span id="connection-text">Disconnected</span>
|
|
2197
|
+
</div>
|
|
894
2198
|
</div>
|
|
895
|
-
<div
|
|
896
|
-
|
|
897
|
-
<
|
|
2199
|
+
<div id="terminal-container"></div>
|
|
2200
|
+
<div class="connection-error-overlay" id="connection-error">
|
|
2201
|
+
<h2>Connection Failed</h2>
|
|
2202
|
+
<p>Unable to connect to the terminal.</p>
|
|
2203
|
+
<p id="connection-error-hint"></p>
|
|
2204
|
+
<button class="retry-btn" onclick="location.reload()">Retry</button>
|
|
898
2205
|
</div>
|
|
899
2206
|
</div>
|
|
900
|
-
<div id="terminal-container"></div>
|
|
901
2207
|
</div>
|
|
902
2208
|
|
|
2209
|
+
<script>
|
|
2210
|
+
// Server tells client if using self-signed certificate (for mobile SSE fallback)
|
|
2211
|
+
window.__SELF_SIGNED_CERT__ = ${USE_HTTPS};
|
|
2212
|
+
</script>
|
|
903
2213
|
<script type="module">
|
|
904
2214
|
import { init, Terminal, FitAddon } from '/dist/ghostty-web.js';
|
|
905
2215
|
|
|
906
2216
|
let term;
|
|
907
2217
|
let ws;
|
|
908
2218
|
let fitAddon;
|
|
2219
|
+
let currentSessionName = null;
|
|
2220
|
+
|
|
2221
|
+
// HTML escape to prevent XSS when inserting user data into templates
|
|
2222
|
+
function escapeHtml(str) {
|
|
2223
|
+
return String(str)
|
|
2224
|
+
.replace(/&/g, '&')
|
|
2225
|
+
.replace(/</g, '<')
|
|
2226
|
+
.replace(/>/g, '>')
|
|
2227
|
+
.replace(/"/g, '"')
|
|
2228
|
+
.replace(/'/g, ''');
|
|
2229
|
+
}
|
|
2230
|
+
|
|
2231
|
+
// Get session info from URL query parameters
|
|
2232
|
+
// Returns { name, stableId } or null
|
|
2233
|
+
function getSessionFromUrl() {
|
|
2234
|
+
const params = new URLSearchParams(window.location.search);
|
|
2235
|
+
const name = params.get('session');
|
|
2236
|
+
const stableId = params.get('id');
|
|
2237
|
+
if (name || stableId) {
|
|
2238
|
+
return { name, stableId };
|
|
2239
|
+
}
|
|
2240
|
+
return null;
|
|
2241
|
+
}
|
|
2242
|
+
|
|
2243
|
+
// Check which view to show
|
|
2244
|
+
async function initApp() {
|
|
2245
|
+
const sessionInfo = getSessionFromUrl();
|
|
2246
|
+
|
|
2247
|
+
if (sessionInfo && sessionInfo.stableId) {
|
|
2248
|
+
// Show terminal view using stable ID
|
|
2249
|
+
showTerminalView(sessionInfo.stableId);
|
|
2250
|
+
} else if (sessionInfo && sessionInfo.name) {
|
|
2251
|
+
// Legacy URL with just name - show terminal view
|
|
2252
|
+
showTerminalView(sessionInfo.name);
|
|
2253
|
+
} else {
|
|
2254
|
+
// Show session list view
|
|
2255
|
+
showSessionListView();
|
|
2256
|
+
}
|
|
2257
|
+
}
|
|
2258
|
+
|
|
2259
|
+
// Show session list view
|
|
2260
|
+
async function showSessionListView() {
|
|
2261
|
+
document.getElementById('session-list-view').classList.add('active');
|
|
2262
|
+
document.getElementById('terminal-view').classList.remove('active');
|
|
2263
|
+
document.title = 'Ghost Town';
|
|
2264
|
+
|
|
2265
|
+
await refreshSessionList();
|
|
2266
|
+
|
|
2267
|
+
// Set up create button
|
|
2268
|
+
document.getElementById('create-session-btn').onclick = createSession;
|
|
2269
|
+
|
|
2270
|
+
// Set up logout button
|
|
2271
|
+
document.getElementById('logout-btn').onclick = logout;
|
|
2272
|
+
}
|
|
2273
|
+
|
|
2274
|
+
// Logout and redirect to login page
|
|
2275
|
+
async function logout() {
|
|
2276
|
+
try {
|
|
2277
|
+
await fetch('/api/logout', { method: 'POST' });
|
|
2278
|
+
} catch (err) {
|
|
2279
|
+
// Ignore errors, redirect anyway
|
|
2280
|
+
}
|
|
2281
|
+
window.location.href = '/login';
|
|
2282
|
+
}
|
|
2283
|
+
|
|
2284
|
+
// Refresh the session list
|
|
2285
|
+
async function refreshSessionList() {
|
|
2286
|
+
try {
|
|
2287
|
+
const response = await fetch('/api/sessions');
|
|
2288
|
+
const data = await response.json();
|
|
2289
|
+
|
|
2290
|
+
const content = document.getElementById('session-list-content');
|
|
2291
|
+
|
|
2292
|
+
if (data.sessions.length === 0) {
|
|
2293
|
+
content.innerHTML = \`
|
|
2294
|
+
<div class="empty-state">
|
|
2295
|
+
<p>No sessions yet</p>
|
|
2296
|
+
</div>
|
|
2297
|
+
\`;
|
|
2298
|
+
} else {
|
|
2299
|
+
const rows = data.sessions.map(session => {
|
|
2300
|
+
const lastActivity = new Date(session.lastActivity).toLocaleString();
|
|
2301
|
+
const statusClass = session.attached ? 'connected' : 'disconnected';
|
|
2302
|
+
const statusText = session.attached ? 'attached' : 'detached';
|
|
2303
|
+
// URL format: /?session=<name>&id=<stableId>
|
|
2304
|
+
const sessionUrl = '/?session=' + encodeURIComponent(session.name) + (session.stableId ? '&id=' + encodeURIComponent(session.stableId) : '');
|
|
2305
|
+
return \`
|
|
2306
|
+
<tr class="session-row">
|
|
2307
|
+
<td><a href="\${sessionUrl}">\${escapeHtml(session.name)}</a></td>
|
|
2308
|
+
<td><a href="\${sessionUrl}">\${lastActivity}</a></td>
|
|
2309
|
+
<td><a href="\${sessionUrl}"><span class="session-status \${statusClass}">\${statusText}</span></a></td>
|
|
2310
|
+
<td class="actions-cell"><button class="copy-btn" onclick="copySessionUrl(event, '\${escapeHtml(session.name)}', '\${escapeHtml(session.stableId || '')}')" title="Copy URL">Copy</button></td>
|
|
2311
|
+
</tr>
|
|
2312
|
+
\`;
|
|
2313
|
+
}).join('');
|
|
2314
|
+
|
|
2315
|
+
content.innerHTML = \`
|
|
2316
|
+
<table class="session-table">
|
|
2317
|
+
<thead>
|
|
2318
|
+
<tr>
|
|
2319
|
+
<th>Name</th>
|
|
2320
|
+
<th>Last Activity</th>
|
|
2321
|
+
<th>Status</th>
|
|
2322
|
+
<th></th>
|
|
2323
|
+
</tr>
|
|
2324
|
+
</thead>
|
|
2325
|
+
<tbody>
|
|
2326
|
+
\${rows}
|
|
2327
|
+
</tbody>
|
|
2328
|
+
</table>
|
|
2329
|
+
\`;
|
|
2330
|
+
}
|
|
2331
|
+
} catch (err) {
|
|
2332
|
+
console.error('Failed to fetch sessions:', err);
|
|
2333
|
+
}
|
|
2334
|
+
}
|
|
2335
|
+
|
|
2336
|
+
// Copy session URL to clipboard (global for onclick handlers)
|
|
2337
|
+
window.copySessionUrl = function(event, sessionName, stableId) {
|
|
2338
|
+
event.preventDefault();
|
|
2339
|
+
event.stopPropagation();
|
|
2340
|
+
let url = window.location.origin + '/?session=' + encodeURIComponent(sessionName);
|
|
2341
|
+
if (stableId) {
|
|
2342
|
+
url += '&id=' + encodeURIComponent(stableId);
|
|
2343
|
+
}
|
|
2344
|
+
navigator.clipboard.writeText(url).then(() => {
|
|
2345
|
+
const btn = event.target;
|
|
2346
|
+
btn.textContent = 'Copied!';
|
|
2347
|
+
btn.classList.add('copied');
|
|
2348
|
+
setTimeout(() => {
|
|
2349
|
+
btn.textContent = 'Copy';
|
|
2350
|
+
btn.classList.remove('copied');
|
|
2351
|
+
}, 2000);
|
|
2352
|
+
}).catch(err => {
|
|
2353
|
+
console.error('Failed to copy:', err);
|
|
2354
|
+
});
|
|
2355
|
+
};
|
|
2356
|
+
|
|
2357
|
+
// Create a new session
|
|
2358
|
+
async function createSession() {
|
|
2359
|
+
try {
|
|
2360
|
+
const response = await fetch('/api/sessions/create', { method: 'POST' });
|
|
2361
|
+
const data = await response.json();
|
|
2362
|
+
// URL format: /?session=<name>&id=<stableId>
|
|
2363
|
+
window.location.href = '/?session=' + encodeURIComponent(data.name) + '&id=' + encodeURIComponent(data.stableId);
|
|
2364
|
+
} catch (err) {
|
|
2365
|
+
console.error('Failed to create session:', err);
|
|
2366
|
+
showError('Failed to create session');
|
|
2367
|
+
}
|
|
2368
|
+
}
|
|
2369
|
+
|
|
2370
|
+
// Show error message
|
|
2371
|
+
function showError(message) {
|
|
2372
|
+
const errorEl = document.getElementById('error-message');
|
|
2373
|
+
errorEl.textContent = message;
|
|
2374
|
+
errorEl.classList.add('visible');
|
|
2375
|
+
}
|
|
2376
|
+
|
|
2377
|
+
// Show terminal view
|
|
2378
|
+
// sessionId is the stable ID used for WebSocket connection
|
|
2379
|
+
async function showTerminalView(sessionId) {
|
|
2380
|
+
currentSessionName = sessionId; // Will be updated when session_info arrives
|
|
2381
|
+
document.getElementById('session-list-view').classList.remove('active');
|
|
2382
|
+
document.getElementById('terminal-view').classList.add('active');
|
|
2383
|
+
// Show loading state until we get the real name from session_info
|
|
2384
|
+
document.getElementById('session-name').textContent = 'Loading...';
|
|
2385
|
+
document.title = 'ghosttown';
|
|
2386
|
+
|
|
2387
|
+
// Fallback: if session_info doesn't arrive within 5 seconds, use the sessionId
|
|
2388
|
+
const loadingTimeout = setTimeout(() => {
|
|
2389
|
+
const nameEl = document.getElementById('session-name');
|
|
2390
|
+
if (nameEl && nameEl.textContent === 'Loading...') {
|
|
2391
|
+
nameEl.textContent = sessionId;
|
|
2392
|
+
document.title = sessionId + ' - ghosttown';
|
|
2393
|
+
}
|
|
2394
|
+
}, 5000);
|
|
2395
|
+
|
|
2396
|
+
try {
|
|
2397
|
+
await initTerminal();
|
|
2398
|
+
} catch (err) {
|
|
2399
|
+
clearTimeout(loadingTimeout);
|
|
2400
|
+
document.getElementById('session-name').textContent = sessionId;
|
|
2401
|
+
console.error('Terminal init failed:', err);
|
|
2402
|
+
}
|
|
2403
|
+
}
|
|
2404
|
+
|
|
2405
|
+
// Update session display name (called when session_info is received)
|
|
2406
|
+
function updateSessionDisplay(displayName, stableId) {
|
|
2407
|
+
currentSessionName = stableId;
|
|
2408
|
+
document.getElementById('session-name').textContent = displayName;
|
|
2409
|
+
document.title = displayName + ' - ghosttown';
|
|
2410
|
+
|
|
2411
|
+
// Update URL to reflect current session name (in case it was renamed)
|
|
2412
|
+
const newUrl = '/?session=' + encodeURIComponent(displayName) + '&id=' + encodeURIComponent(stableId);
|
|
2413
|
+
if (window.location.search !== newUrl) {
|
|
2414
|
+
history.replaceState(null, '', newUrl);
|
|
2415
|
+
}
|
|
2416
|
+
}
|
|
909
2417
|
|
|
910
2418
|
async function initTerminal() {
|
|
911
2419
|
await init();
|
|
@@ -1020,14 +2528,18 @@ function startWebServer(cliArgs) {
|
|
|
1020
2528
|
|
|
1021
2529
|
// Handle terminal resize
|
|
1022
2530
|
term.onResize((size) => {
|
|
1023
|
-
if (
|
|
2531
|
+
if (useSSE) {
|
|
2532
|
+
sendResizeViaPost(size.cols, size.rows);
|
|
2533
|
+
} else if (ws && ws.readyState === WebSocket.OPEN) {
|
|
1024
2534
|
ws.send(JSON.stringify({ type: 'resize', cols: size.cols, rows: size.rows }));
|
|
1025
2535
|
}
|
|
1026
2536
|
});
|
|
1027
2537
|
|
|
1028
2538
|
// Handle user input
|
|
1029
2539
|
term.onData((data) => {
|
|
1030
|
-
if (
|
|
2540
|
+
if (useSSE) {
|
|
2541
|
+
sendInputViaPost(data);
|
|
2542
|
+
} else if (ws && ws.readyState === WebSocket.OPEN) {
|
|
1031
2543
|
ws.send(data);
|
|
1032
2544
|
}
|
|
1033
2545
|
});
|
|
@@ -1047,20 +2559,152 @@ function startWebServer(cliArgs) {
|
|
|
1047
2559
|
}
|
|
1048
2560
|
});
|
|
1049
2561
|
|
|
1050
|
-
|
|
2562
|
+
// On mobile + self-signed cert, go straight to SSE (WebSocket won't work)
|
|
2563
|
+
const isMobile = window.matchMedia && window.matchMedia('(pointer: coarse)').matches;
|
|
2564
|
+
const isSelfSigned = window.__SELF_SIGNED_CERT__ === true;
|
|
2565
|
+
|
|
2566
|
+
if (isMobile && isSelfSigned) {
|
|
2567
|
+
console.log('Mobile + self-signed cert: using SSE transport');
|
|
2568
|
+
useSSE = true;
|
|
2569
|
+
connectSSE();
|
|
2570
|
+
} else {
|
|
2571
|
+
connectWebSocket();
|
|
2572
|
+
}
|
|
2573
|
+
}
|
|
2574
|
+
|
|
2575
|
+
let useSSE = false;
|
|
2576
|
+
let sseSource = null;
|
|
2577
|
+
let sseConnectionId = null;
|
|
2578
|
+
|
|
2579
|
+
function showConnectionError() {
|
|
2580
|
+
const overlay = document.getElementById('connection-error');
|
|
2581
|
+
const hint = document.getElementById('connection-error-hint');
|
|
2582
|
+
hint.textContent = 'Unable to establish connection. Check that the server is running.';
|
|
2583
|
+
overlay.classList.add('visible');
|
|
2584
|
+
}
|
|
2585
|
+
|
|
2586
|
+
function hideConnectionError() {
|
|
2587
|
+
document.getElementById('connection-error').classList.remove('visible');
|
|
2588
|
+
}
|
|
2589
|
+
|
|
2590
|
+
// SSE connection (for mobile with self-signed certs)
|
|
2591
|
+
function connectSSE() {
|
|
2592
|
+
const sseUrl = '/sse?cols=' + term.cols + '&rows=' + term.rows + '&session=' + encodeURIComponent(currentSessionName);
|
|
2593
|
+
|
|
2594
|
+
sseSource = new EventSource(sseUrl);
|
|
2595
|
+
|
|
2596
|
+
sseSource.onopen = () => {
|
|
2597
|
+
hideConnectionError();
|
|
2598
|
+
updateConnectionStatus(true);
|
|
2599
|
+
};
|
|
2600
|
+
|
|
2601
|
+
sseSource.onmessage = (event) => {
|
|
2602
|
+
try {
|
|
2603
|
+
const msg = JSON.parse(event.data);
|
|
2604
|
+
|
|
2605
|
+
if (msg.type === 'session_info') {
|
|
2606
|
+
sseConnectionId = msg.connectionId;
|
|
2607
|
+
updateSessionDisplay(msg.name, msg.stableId);
|
|
2608
|
+
return;
|
|
2609
|
+
}
|
|
2610
|
+
|
|
2611
|
+
if (msg.type === 'output') {
|
|
2612
|
+
const decoded = atob(msg.data);
|
|
2613
|
+
term.write(decoded);
|
|
2614
|
+
return;
|
|
2615
|
+
}
|
|
2616
|
+
|
|
2617
|
+
if (msg.type === 'error') {
|
|
2618
|
+
updateConnectionStatus(false);
|
|
2619
|
+
showError(msg.message);
|
|
2620
|
+
document.getElementById('terminal-view').classList.remove('active');
|
|
2621
|
+
document.getElementById('session-list-view').classList.add('active');
|
|
2622
|
+
refreshSessionList();
|
|
2623
|
+
return;
|
|
2624
|
+
}
|
|
2625
|
+
|
|
2626
|
+
if (msg.type === 'exit') {
|
|
2627
|
+
updateConnectionStatus(false);
|
|
2628
|
+
return;
|
|
2629
|
+
}
|
|
2630
|
+
} catch (e) {
|
|
2631
|
+
console.error('SSE message parse error:', e);
|
|
2632
|
+
}
|
|
2633
|
+
};
|
|
2634
|
+
|
|
2635
|
+
sseSource.onerror = () => {
|
|
2636
|
+
updateConnectionStatus(false);
|
|
2637
|
+
sseSource.close();
|
|
2638
|
+
setTimeout(() => {
|
|
2639
|
+
if (useSSE) {
|
|
2640
|
+
connectSSE();
|
|
2641
|
+
}
|
|
2642
|
+
}, 3000);
|
|
2643
|
+
};
|
|
2644
|
+
}
|
|
2645
|
+
|
|
2646
|
+
// Send input via POST (used with SSE)
|
|
2647
|
+
function sendInputViaPost(data) {
|
|
2648
|
+
if (!sseConnectionId) return;
|
|
2649
|
+
|
|
2650
|
+
fetch('/api/input', {
|
|
2651
|
+
method: 'POST',
|
|
2652
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2653
|
+
body: JSON.stringify({
|
|
2654
|
+
connectionId: sseConnectionId,
|
|
2655
|
+
data: btoa(data),
|
|
2656
|
+
}),
|
|
2657
|
+
credentials: 'same-origin',
|
|
2658
|
+
}).catch(() => {});
|
|
2659
|
+
}
|
|
2660
|
+
|
|
2661
|
+
// Send resize via POST (used with SSE)
|
|
2662
|
+
function sendResizeViaPost(cols, rows) {
|
|
2663
|
+
if (!sseConnectionId) return;
|
|
2664
|
+
|
|
2665
|
+
fetch('/api/input', {
|
|
2666
|
+
method: 'POST',
|
|
2667
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2668
|
+
body: JSON.stringify({
|
|
2669
|
+
connectionId: sseConnectionId,
|
|
2670
|
+
type: 'resize',
|
|
2671
|
+
cols,
|
|
2672
|
+
rows,
|
|
2673
|
+
}),
|
|
2674
|
+
credentials: 'same-origin',
|
|
2675
|
+
}).catch(() => {});
|
|
1051
2676
|
}
|
|
1052
2677
|
|
|
2678
|
+
// WebSocket-only connection (desktop or HTTP)
|
|
1053
2679
|
function connectWebSocket() {
|
|
1054
2680
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
1055
|
-
const wsUrl = protocol + '//' + window.location.host + '/ws?cols=' + term.cols + '&rows=' + term.rows;
|
|
2681
|
+
const wsUrl = protocol + '//' + window.location.host + '/ws?cols=' + term.cols + '&rows=' + term.rows + '&session=' + encodeURIComponent(currentSessionName);
|
|
1056
2682
|
|
|
1057
2683
|
ws = new WebSocket(wsUrl);
|
|
1058
2684
|
|
|
1059
2685
|
ws.onopen = () => {
|
|
2686
|
+
hideConnectionError();
|
|
1060
2687
|
updateConnectionStatus(true);
|
|
1061
2688
|
};
|
|
1062
2689
|
|
|
1063
2690
|
ws.onmessage = (event) => {
|
|
2691
|
+
if (event.data.startsWith('{')) {
|
|
2692
|
+
try {
|
|
2693
|
+
const msg = JSON.parse(event.data);
|
|
2694
|
+
if (msg.type === 'error') {
|
|
2695
|
+
updateConnectionStatus(false);
|
|
2696
|
+
showError(msg.message);
|
|
2697
|
+
document.getElementById('terminal-view').classList.remove('active');
|
|
2698
|
+
document.getElementById('session-list-view').classList.add('active');
|
|
2699
|
+
refreshSessionList();
|
|
2700
|
+
return;
|
|
2701
|
+
}
|
|
2702
|
+
if (msg.type === 'session_info') {
|
|
2703
|
+
updateSessionDisplay(msg.name, msg.stableId);
|
|
2704
|
+
return;
|
|
2705
|
+
}
|
|
2706
|
+
} catch (e) {}
|
|
2707
|
+
}
|
|
1064
2708
|
term.write(event.data);
|
|
1065
2709
|
};
|
|
1066
2710
|
|
|
@@ -1090,7 +2734,250 @@ function startWebServer(cliArgs) {
|
|
|
1090
2734
|
}
|
|
1091
2735
|
}
|
|
1092
2736
|
|
|
1093
|
-
|
|
2737
|
+
initApp();
|
|
2738
|
+
</script>
|
|
2739
|
+
</body>
|
|
2740
|
+
</html>`;
|
|
2741
|
+
|
|
2742
|
+
// ============================================================================
|
|
2743
|
+
// Login Page Template
|
|
2744
|
+
// ============================================================================
|
|
2745
|
+
|
|
2746
|
+
const LOGIN_TEMPLATE = `<!doctype html>
|
|
2747
|
+
<html lang="en">
|
|
2748
|
+
<head>
|
|
2749
|
+
<meta charset="UTF-8" />
|
|
2750
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
2751
|
+
<title>login - ghosttown</title>
|
|
2752
|
+
<style>
|
|
2753
|
+
* {
|
|
2754
|
+
margin: 0;
|
|
2755
|
+
padding: 0;
|
|
2756
|
+
box-sizing: border-box;
|
|
2757
|
+
}
|
|
2758
|
+
|
|
2759
|
+
body {
|
|
2760
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
2761
|
+
background: #292c34;
|
|
2762
|
+
min-height: 100vh;
|
|
2763
|
+
display: flex;
|
|
2764
|
+
align-items: center;
|
|
2765
|
+
justify-content: center;
|
|
2766
|
+
padding: 40px 20px;
|
|
2767
|
+
}
|
|
2768
|
+
|
|
2769
|
+
.login-window {
|
|
2770
|
+
width: 100%;
|
|
2771
|
+
max-width: 400px;
|
|
2772
|
+
background: #1e2127;
|
|
2773
|
+
border-radius: 12px;
|
|
2774
|
+
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
|
2775
|
+
overflow: hidden;
|
|
2776
|
+
}
|
|
2777
|
+
|
|
2778
|
+
.title-bar {
|
|
2779
|
+
background: #292c34;
|
|
2780
|
+
padding: 12px 16px;
|
|
2781
|
+
display: flex;
|
|
2782
|
+
align-items: center;
|
|
2783
|
+
gap: 12px;
|
|
2784
|
+
border-bottom: 1px solid #1a1a1a;
|
|
2785
|
+
}
|
|
2786
|
+
|
|
2787
|
+
.traffic-lights {
|
|
2788
|
+
display: flex;
|
|
2789
|
+
gap: 8px;
|
|
2790
|
+
}
|
|
2791
|
+
|
|
2792
|
+
.light {
|
|
2793
|
+
width: 12px;
|
|
2794
|
+
height: 12px;
|
|
2795
|
+
border-radius: 50%;
|
|
2796
|
+
}
|
|
2797
|
+
|
|
2798
|
+
.light.red { background: #ff5f56; }
|
|
2799
|
+
.light.yellow { background: #ffbd2e; }
|
|
2800
|
+
.light.green { background: #27c93f; }
|
|
2801
|
+
|
|
2802
|
+
.title {
|
|
2803
|
+
color: #e5e5e5;
|
|
2804
|
+
font-size: 13px;
|
|
2805
|
+
font-weight: 500;
|
|
2806
|
+
letter-spacing: 0.3px;
|
|
2807
|
+
}
|
|
2808
|
+
|
|
2809
|
+
.login-content {
|
|
2810
|
+
padding: 32px;
|
|
2811
|
+
}
|
|
2812
|
+
|
|
2813
|
+
.login-header {
|
|
2814
|
+
text-align: center;
|
|
2815
|
+
margin-bottom: 24px;
|
|
2816
|
+
}
|
|
2817
|
+
|
|
2818
|
+
.login-header h1 {
|
|
2819
|
+
color: #e5e5e5;
|
|
2820
|
+
font-size: 24px;
|
|
2821
|
+
font-weight: 600;
|
|
2822
|
+
margin-bottom: 8px;
|
|
2823
|
+
}
|
|
2824
|
+
|
|
2825
|
+
.login-header p {
|
|
2826
|
+
color: #888;
|
|
2827
|
+
font-size: 14px;
|
|
2828
|
+
}
|
|
2829
|
+
|
|
2830
|
+
.form-group {
|
|
2831
|
+
margin-bottom: 16px;
|
|
2832
|
+
}
|
|
2833
|
+
|
|
2834
|
+
.form-group label {
|
|
2835
|
+
display: block;
|
|
2836
|
+
color: #888;
|
|
2837
|
+
font-size: 12px;
|
|
2838
|
+
margin-bottom: 6px;
|
|
2839
|
+
text-transform: uppercase;
|
|
2840
|
+
letter-spacing: 0.5px;
|
|
2841
|
+
}
|
|
2842
|
+
|
|
2843
|
+
.form-group input {
|
|
2844
|
+
width: 100%;
|
|
2845
|
+
padding: 12px 14px;
|
|
2846
|
+
background: #292c34;
|
|
2847
|
+
border: 1px solid #3d3d3d;
|
|
2848
|
+
border-radius: 6px;
|
|
2849
|
+
color: #e5e5e5;
|
|
2850
|
+
font-size: 14px;
|
|
2851
|
+
transition: border-color 0.2s;
|
|
2852
|
+
}
|
|
2853
|
+
|
|
2854
|
+
.form-group input:focus {
|
|
2855
|
+
outline: none;
|
|
2856
|
+
border-color: #27c93f;
|
|
2857
|
+
}
|
|
2858
|
+
|
|
2859
|
+
.form-group input::placeholder {
|
|
2860
|
+
color: #666;
|
|
2861
|
+
}
|
|
2862
|
+
|
|
2863
|
+
.error-message {
|
|
2864
|
+
background: rgba(255, 95, 86, 0.1);
|
|
2865
|
+
border: 1px solid rgba(255, 95, 86, 0.3);
|
|
2866
|
+
color: #ff5f56;
|
|
2867
|
+
padding: 12px;
|
|
2868
|
+
border-radius: 6px;
|
|
2869
|
+
font-size: 13px;
|
|
2870
|
+
margin-bottom: 16px;
|
|
2871
|
+
display: none;
|
|
2872
|
+
}
|
|
2873
|
+
|
|
2874
|
+
.error-message.visible {
|
|
2875
|
+
display: block;
|
|
2876
|
+
}
|
|
2877
|
+
|
|
2878
|
+
.submit-btn {
|
|
2879
|
+
width: 100%;
|
|
2880
|
+
padding: 14px;
|
|
2881
|
+
background: #27c93f;
|
|
2882
|
+
border: none;
|
|
2883
|
+
border-radius: 6px;
|
|
2884
|
+
color: #1e1e1e;
|
|
2885
|
+
font-size: 14px;
|
|
2886
|
+
font-weight: 600;
|
|
2887
|
+
cursor: pointer;
|
|
2888
|
+
transition: background 0.2s;
|
|
2889
|
+
}
|
|
2890
|
+
|
|
2891
|
+
.submit-btn:hover {
|
|
2892
|
+
background: #2bd946;
|
|
2893
|
+
}
|
|
2894
|
+
|
|
2895
|
+
.submit-btn:disabled {
|
|
2896
|
+
background: #3d3d3d;
|
|
2897
|
+
color: #888;
|
|
2898
|
+
cursor: not-allowed;
|
|
2899
|
+
}
|
|
2900
|
+
|
|
2901
|
+
.hint {
|
|
2902
|
+
text-align: center;
|
|
2903
|
+
margin-top: 20px;
|
|
2904
|
+
color: #666;
|
|
2905
|
+
font-size: 12px;
|
|
2906
|
+
}
|
|
2907
|
+
</style>
|
|
2908
|
+
</head>
|
|
2909
|
+
<body>
|
|
2910
|
+
<div class="login-window">
|
|
2911
|
+
<div class="title-bar">
|
|
2912
|
+
<div class="traffic-lights">
|
|
2913
|
+
<div class="light red"></div>
|
|
2914
|
+
<div class="light yellow"></div>
|
|
2915
|
+
<div class="light green"></div>
|
|
2916
|
+
</div>
|
|
2917
|
+
<span class="title">Ghost Town</span>
|
|
2918
|
+
</div>
|
|
2919
|
+
<div class="login-content">
|
|
2920
|
+
<div class="login-header">
|
|
2921
|
+
<h1>Sign In</h1>
|
|
2922
|
+
<p>Use your macOS account credentials</p>
|
|
2923
|
+
</div>
|
|
2924
|
+
<div class="error-message" id="error-message"></div>
|
|
2925
|
+
<form id="login-form">
|
|
2926
|
+
<div class="form-group">
|
|
2927
|
+
<label for="username">Username</label>
|
|
2928
|
+
<input type="text" id="username" name="username" placeholder="macOS username" autocomplete="username" required autofocus />
|
|
2929
|
+
</div>
|
|
2930
|
+
<div class="form-group">
|
|
2931
|
+
<label for="password">Password</label>
|
|
2932
|
+
<input type="password" id="password" name="password" placeholder="macOS password" autocomplete="current-password" required />
|
|
2933
|
+
</div>
|
|
2934
|
+
<button type="submit" class="submit-btn" id="submit-btn">Sign In</button>
|
|
2935
|
+
</form>
|
|
2936
|
+
<p class="hint">Your session will expire after 3 days</p>
|
|
2937
|
+
</div>
|
|
2938
|
+
</div>
|
|
2939
|
+
|
|
2940
|
+
<script>
|
|
2941
|
+
const form = document.getElementById('login-form');
|
|
2942
|
+
const errorMessage = document.getElementById('error-message');
|
|
2943
|
+
const submitBtn = document.getElementById('submit-btn');
|
|
2944
|
+
|
|
2945
|
+
form.addEventListener('submit', async (e) => {
|
|
2946
|
+
e.preventDefault();
|
|
2947
|
+
|
|
2948
|
+
const username = document.getElementById('username').value;
|
|
2949
|
+
const password = document.getElementById('password').value;
|
|
2950
|
+
|
|
2951
|
+
submitBtn.disabled = true;
|
|
2952
|
+
submitBtn.textContent = 'Signing in...';
|
|
2953
|
+
errorMessage.classList.remove('visible');
|
|
2954
|
+
|
|
2955
|
+
try {
|
|
2956
|
+
const response = await fetch('/api/login', {
|
|
2957
|
+
method: 'POST',
|
|
2958
|
+
headers: {
|
|
2959
|
+
'Content-Type': 'application/json',
|
|
2960
|
+
},
|
|
2961
|
+
body: JSON.stringify({ username, password }),
|
|
2962
|
+
});
|
|
2963
|
+
|
|
2964
|
+
const data = await response.json();
|
|
2965
|
+
|
|
2966
|
+
if (data.success) {
|
|
2967
|
+
window.location.href = '/';
|
|
2968
|
+
} else {
|
|
2969
|
+
errorMessage.textContent = data.error || 'Invalid username or password';
|
|
2970
|
+
errorMessage.classList.add('visible');
|
|
2971
|
+
submitBtn.disabled = false;
|
|
2972
|
+
submitBtn.textContent = 'Sign In';
|
|
2973
|
+
}
|
|
2974
|
+
} catch (err) {
|
|
2975
|
+
errorMessage.textContent = 'Connection error. Please try again.';
|
|
2976
|
+
errorMessage.classList.add('visible');
|
|
2977
|
+
submitBtn.disabled = false;
|
|
2978
|
+
submitBtn.textContent = 'Sign In';
|
|
2979
|
+
}
|
|
2980
|
+
});
|
|
1094
2981
|
</script>
|
|
1095
2982
|
</body>
|
|
1096
2983
|
</html>`;
|
|
@@ -1112,37 +2999,345 @@ function startWebServer(cliArgs) {
|
|
|
1112
2999
|
};
|
|
1113
3000
|
|
|
1114
3001
|
// ============================================================================
|
|
1115
|
-
// HTTP Server
|
|
3002
|
+
// HTTP/HTTPS Server
|
|
1116
3003
|
// ============================================================================
|
|
1117
3004
|
|
|
1118
|
-
|
|
3005
|
+
// Get SSL options if HTTPS is enabled
|
|
3006
|
+
const sslOptions = USE_HTTPS ? generateSelfSignedCert() : null;
|
|
3007
|
+
|
|
3008
|
+
// Helper to set auth cookie (with Secure flag when HTTPS)
|
|
3009
|
+
function setAuthCookieSecure(res, token) {
|
|
3010
|
+
const maxAge = SESSION_EXPIRATION_MS / 1000;
|
|
3011
|
+
const secureFlag = USE_HTTPS ? ' Secure;' : '';
|
|
3012
|
+
res.setHeader(
|
|
3013
|
+
'Set-Cookie',
|
|
3014
|
+
`ghostty_session=${token}; HttpOnly;${secureFlag} SameSite=Strict; Path=/; Max-Age=${maxAge}`
|
|
3015
|
+
);
|
|
3016
|
+
}
|
|
3017
|
+
|
|
3018
|
+
const httpServer = USE_HTTPS
|
|
3019
|
+
? https.createServer(sslOptions, async (req, res) => {
|
|
3020
|
+
await handleRequest(req, res);
|
|
3021
|
+
})
|
|
3022
|
+
: http.createServer(async (req, res) => {
|
|
3023
|
+
await handleRequest(req, res);
|
|
3024
|
+
});
|
|
3025
|
+
|
|
3026
|
+
async function handleRequest(req, res) {
|
|
1119
3027
|
const url = new URL(req.url, `http://${req.headers.host}`);
|
|
1120
3028
|
const pathname = url.pathname;
|
|
3029
|
+
const cookies = parseCookies(req.headers.cookie);
|
|
3030
|
+
const session = validateAuthSession(cookies.ghostty_session);
|
|
3031
|
+
|
|
3032
|
+
// Handle login API endpoint
|
|
3033
|
+
if (pathname === '/api/login' && req.method === 'POST') {
|
|
3034
|
+
const clientIP = getClientIP(req);
|
|
3035
|
+
|
|
3036
|
+
// Check rate limit before processing
|
|
3037
|
+
const rateCheck = checkRateLimit(clientIP);
|
|
3038
|
+
if (!rateCheck.allowed) {
|
|
3039
|
+
res.writeHead(429, { 'Content-Type': 'application/json' });
|
|
3040
|
+
res.end(JSON.stringify({ success: false, error: rateCheck.error }));
|
|
3041
|
+
return;
|
|
3042
|
+
}
|
|
3043
|
+
|
|
3044
|
+
try {
|
|
3045
|
+
const body = await readBody(req);
|
|
3046
|
+
const { username, password } = JSON.parse(body);
|
|
3047
|
+
|
|
3048
|
+
if (!username || !password) {
|
|
3049
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
3050
|
+
res.end(JSON.stringify({ success: false, error: 'Username and password required' }));
|
|
3051
|
+
return;
|
|
3052
|
+
}
|
|
3053
|
+
|
|
3054
|
+
const result = await authenticateUser(username, password);
|
|
3055
|
+
|
|
3056
|
+
if (result.success) {
|
|
3057
|
+
recordLoginAttempt(clientIP, true); // Clear rate limit on success
|
|
3058
|
+
const token = generateAuthToken();
|
|
3059
|
+
const now = Date.now();
|
|
3060
|
+
authSessions.set(token, {
|
|
3061
|
+
username: result.username,
|
|
3062
|
+
createdAt: now,
|
|
3063
|
+
lastActivity: now,
|
|
3064
|
+
});
|
|
3065
|
+
|
|
3066
|
+
setAuthCookieSecure(res, token);
|
|
3067
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
3068
|
+
res.end(JSON.stringify({ success: true }));
|
|
3069
|
+
} else {
|
|
3070
|
+
recordLoginAttempt(clientIP, false); // Record failed attempt
|
|
3071
|
+
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
3072
|
+
res.end(JSON.stringify({ success: false, error: result.error }));
|
|
3073
|
+
}
|
|
3074
|
+
} catch (err) {
|
|
3075
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
3076
|
+
res.end(JSON.stringify({ success: false, error: 'Invalid request' }));
|
|
3077
|
+
}
|
|
3078
|
+
return;
|
|
3079
|
+
}
|
|
3080
|
+
|
|
3081
|
+
// Handle logout API endpoint
|
|
3082
|
+
if (pathname === '/api/logout' && req.method === 'POST') {
|
|
3083
|
+
if (cookies.ghostty_session) {
|
|
3084
|
+
authSessions.delete(cookies.ghostty_session);
|
|
3085
|
+
}
|
|
3086
|
+
clearAuthCookie(res);
|
|
3087
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
3088
|
+
res.end(JSON.stringify({ success: true }));
|
|
3089
|
+
return;
|
|
3090
|
+
}
|
|
3091
|
+
|
|
3092
|
+
// Serve login page (always accessible)
|
|
3093
|
+
if (pathname === '/login') {
|
|
3094
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
3095
|
+
res.end(LOGIN_TEMPLATE);
|
|
3096
|
+
return;
|
|
3097
|
+
}
|
|
1121
3098
|
|
|
1122
|
-
// Serve index page
|
|
3099
|
+
// Serve index page (requires authentication)
|
|
1123
3100
|
if (pathname === '/' || pathname === '/index.html') {
|
|
3101
|
+
if (!session) {
|
|
3102
|
+
res.writeHead(302, { Location: '/login' });
|
|
3103
|
+
res.end();
|
|
3104
|
+
return;
|
|
3105
|
+
}
|
|
1124
3106
|
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
1125
3107
|
res.end(HTML_TEMPLATE);
|
|
1126
3108
|
return;
|
|
1127
3109
|
}
|
|
1128
3110
|
|
|
1129
|
-
// Serve dist files
|
|
3111
|
+
// Serve dist files (requires authentication)
|
|
1130
3112
|
if (pathname.startsWith('/dist/')) {
|
|
3113
|
+
if (!session) {
|
|
3114
|
+
res.writeHead(401);
|
|
3115
|
+
res.end('Unauthorized');
|
|
3116
|
+
return;
|
|
3117
|
+
}
|
|
1131
3118
|
const filePath = path.join(distPath, pathname.slice(6));
|
|
1132
3119
|
serveFile(filePath, res);
|
|
1133
3120
|
return;
|
|
1134
3121
|
}
|
|
1135
3122
|
|
|
1136
|
-
// Serve WASM file
|
|
3123
|
+
// Serve WASM file (requires authentication)
|
|
1137
3124
|
if (pathname === '/ghostty-vt.wasm') {
|
|
3125
|
+
if (!session) {
|
|
3126
|
+
res.writeHead(401);
|
|
3127
|
+
res.end('Unauthorized');
|
|
3128
|
+
return;
|
|
3129
|
+
}
|
|
1138
3130
|
serveFile(wasmPath, res);
|
|
1139
3131
|
return;
|
|
1140
3132
|
}
|
|
1141
3133
|
|
|
3134
|
+
// API: List sessions (from tmux) - requires authentication
|
|
3135
|
+
if (pathname === '/api/sessions' && req.method === 'GET') {
|
|
3136
|
+
if (!session) {
|
|
3137
|
+
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
3138
|
+
res.end(JSON.stringify({ error: 'Unauthorized' }));
|
|
3139
|
+
return;
|
|
3140
|
+
}
|
|
3141
|
+
try {
|
|
3142
|
+
const output = execSync(
|
|
3143
|
+
'tmux list-sessions -F "#{session_name}|#{session_activity}|#{session_attached}|#{session_windows}"',
|
|
3144
|
+
{ encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }
|
|
3145
|
+
);
|
|
3146
|
+
|
|
3147
|
+
const sessionList = output
|
|
3148
|
+
.split('\n')
|
|
3149
|
+
.filter((line) => line.startsWith('gt-'))
|
|
3150
|
+
.map((line) => {
|
|
3151
|
+
const [fullName, activity, attached, windows] = line.split('|');
|
|
3152
|
+
const parsed = parseSessionName(fullName);
|
|
3153
|
+
return {
|
|
3154
|
+
name: parsed ? parsed.displayName : fullName.replace('gt-', ''),
|
|
3155
|
+
stableId: parsed ? parsed.stableId : null,
|
|
3156
|
+
fullName: fullName,
|
|
3157
|
+
lastActivity: Number.parseInt(activity, 10) * 1000,
|
|
3158
|
+
attached: attached === '1',
|
|
3159
|
+
windows: Number.parseInt(windows, 10),
|
|
3160
|
+
};
|
|
3161
|
+
});
|
|
3162
|
+
|
|
3163
|
+
// Sort by recent activity
|
|
3164
|
+
sessionList.sort((a, b) => b.lastActivity - a.lastActivity);
|
|
3165
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
3166
|
+
res.end(JSON.stringify({ sessions: sessionList }));
|
|
3167
|
+
} catch (err) {
|
|
3168
|
+
// No tmux sessions or tmux not running
|
|
3169
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
3170
|
+
res.end(JSON.stringify({ sessions: [] }));
|
|
3171
|
+
}
|
|
3172
|
+
return;
|
|
3173
|
+
}
|
|
3174
|
+
|
|
3175
|
+
// API: Create session (tmux session) - requires authentication
|
|
3176
|
+
if (pathname === '/api/sessions/create' && req.method === 'POST') {
|
|
3177
|
+
if (!session) {
|
|
3178
|
+
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
3179
|
+
res.end(JSON.stringify({ error: 'Unauthorized' }));
|
|
3180
|
+
return;
|
|
3181
|
+
}
|
|
3182
|
+
try {
|
|
3183
|
+
const displayNumber = getNextDisplayNumber();
|
|
3184
|
+
|
|
3185
|
+
// Create a detached session with a temporary name to get its stable ID
|
|
3186
|
+
const tempName = `gt-temp-${Date.now()}`;
|
|
3187
|
+
const output = execSync(
|
|
3188
|
+
`tmux new-session -d -s ${tempName} -x 200 -y 50 -P -F "#{session_id}"`,
|
|
3189
|
+
{ encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }
|
|
3190
|
+
);
|
|
3191
|
+
|
|
3192
|
+
// Extract the stable ID (e.g., "$10" -> "10")
|
|
3193
|
+
const stableId = output.trim().replace('$', '');
|
|
3194
|
+
|
|
3195
|
+
// Rename to final name: gt-<stableId>-<displayNumber>
|
|
3196
|
+
const sessionName = `gt-${stableId}-${displayNumber}`;
|
|
3197
|
+
execSync(`tmux rename-session -t ${tempName} ${sessionName}`, { stdio: 'pipe' });
|
|
3198
|
+
|
|
3199
|
+
// Disable status bar in the new session
|
|
3200
|
+
execSync(`tmux set-option -t ${sessionName} status off`, { stdio: 'pipe' });
|
|
3201
|
+
|
|
3202
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
3203
|
+
res.end(JSON.stringify({ name: String(displayNumber), stableId: stableId }));
|
|
3204
|
+
} catch (err) {
|
|
3205
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
3206
|
+
res.end(JSON.stringify({ error: 'Failed to create session' }));
|
|
3207
|
+
}
|
|
3208
|
+
return;
|
|
3209
|
+
}
|
|
3210
|
+
|
|
3211
|
+
// SSE endpoint for terminal output (fallback for mobile when WebSocket fails)
|
|
3212
|
+
if (pathname === '/sse' && req.method === 'GET') {
|
|
3213
|
+
if (!session) {
|
|
3214
|
+
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
3215
|
+
res.end(JSON.stringify({ error: 'Unauthorized' }));
|
|
3216
|
+
return;
|
|
3217
|
+
}
|
|
3218
|
+
|
|
3219
|
+
const cols = Number.parseInt(url.searchParams.get('cols') || '80');
|
|
3220
|
+
const rows = Number.parseInt(url.searchParams.get('rows') || '24');
|
|
3221
|
+
const requestedSession = url.searchParams.get('session');
|
|
3222
|
+
|
|
3223
|
+
if (!requestedSession) {
|
|
3224
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
3225
|
+
res.end(JSON.stringify({ error: 'Session parameter required' }));
|
|
3226
|
+
return;
|
|
3227
|
+
}
|
|
3228
|
+
|
|
3229
|
+
// Look up session by stable ID first, then by display name
|
|
3230
|
+
let sessionInfo = findSessionByStableIdWeb(requestedSession);
|
|
3231
|
+
if (!sessionInfo) {
|
|
3232
|
+
sessionInfo = findSessionByDisplayName(requestedSession);
|
|
3233
|
+
}
|
|
3234
|
+
if (!sessionInfo) {
|
|
3235
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
3236
|
+
res.end(JSON.stringify({ error: `Session '${requestedSession}' not found` }));
|
|
3237
|
+
return;
|
|
3238
|
+
}
|
|
3239
|
+
|
|
3240
|
+
// Generate unique connection ID
|
|
3241
|
+
const connectionId = `sse-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
3242
|
+
|
|
3243
|
+
// Set up SSE headers
|
|
3244
|
+
res.writeHead(200, {
|
|
3245
|
+
'Content-Type': 'text/event-stream',
|
|
3246
|
+
'Cache-Control': 'no-cache',
|
|
3247
|
+
Connection: 'keep-alive',
|
|
3248
|
+
'X-SSE-Connection-Id': connectionId,
|
|
3249
|
+
});
|
|
3250
|
+
|
|
3251
|
+
// Send session info as first event
|
|
3252
|
+
res.write(
|
|
3253
|
+
`data: ${JSON.stringify({ type: 'session_info', name: sessionInfo.displayName, stableId: sessionInfo.stableId, connectionId })}\n\n`
|
|
3254
|
+
);
|
|
3255
|
+
|
|
3256
|
+
// Create PTY attached to tmux session
|
|
3257
|
+
const ptyProcess = createTmuxAttachPty(sessionInfo.fullName, cols, rows);
|
|
3258
|
+
|
|
3259
|
+
// Store connection
|
|
3260
|
+
sseConnections.set(connectionId, {
|
|
3261
|
+
res,
|
|
3262
|
+
pty: ptyProcess,
|
|
3263
|
+
stableId: requestedSession,
|
|
3264
|
+
sessionInfo,
|
|
3265
|
+
});
|
|
3266
|
+
|
|
3267
|
+
// Stream terminal output as SSE events
|
|
3268
|
+
ptyProcess.onData((data) => {
|
|
3269
|
+
if (!res.writableEnded) {
|
|
3270
|
+
// Base64 encode to handle binary data safely
|
|
3271
|
+
const encoded = Buffer.from(data).toString('base64');
|
|
3272
|
+
res.write(`data: ${JSON.stringify({ type: 'output', data: encoded })}\n\n`);
|
|
3273
|
+
}
|
|
3274
|
+
});
|
|
3275
|
+
|
|
3276
|
+
ptyProcess.onExit(() => {
|
|
3277
|
+
if (!res.writableEnded) {
|
|
3278
|
+
res.write(`data: ${JSON.stringify({ type: 'exit' })}\n\n`);
|
|
3279
|
+
res.end();
|
|
3280
|
+
}
|
|
3281
|
+
sseConnections.delete(connectionId);
|
|
3282
|
+
});
|
|
3283
|
+
|
|
3284
|
+
// Handle client disconnect
|
|
3285
|
+
req.on('close', () => {
|
|
3286
|
+
const conn = sseConnections.get(connectionId);
|
|
3287
|
+
if (conn) {
|
|
3288
|
+
conn.pty.kill();
|
|
3289
|
+
sseConnections.delete(connectionId);
|
|
3290
|
+
}
|
|
3291
|
+
});
|
|
3292
|
+
|
|
3293
|
+
return;
|
|
3294
|
+
}
|
|
3295
|
+
|
|
3296
|
+
// POST endpoint for terminal input (used with SSE fallback)
|
|
3297
|
+
if (pathname === '/api/input' && req.method === 'POST') {
|
|
3298
|
+
if (!session) {
|
|
3299
|
+
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
3300
|
+
res.end(JSON.stringify({ error: 'Unauthorized' }));
|
|
3301
|
+
return;
|
|
3302
|
+
}
|
|
3303
|
+
|
|
3304
|
+
let body = '';
|
|
3305
|
+
req.on('data', (chunk) => {
|
|
3306
|
+
body += chunk;
|
|
3307
|
+
});
|
|
3308
|
+
req.on('end', () => {
|
|
3309
|
+
try {
|
|
3310
|
+
const { connectionId, data, type, cols, rows } = JSON.parse(body);
|
|
3311
|
+
|
|
3312
|
+
const conn = sseConnections.get(connectionId);
|
|
3313
|
+
if (!conn) {
|
|
3314
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
3315
|
+
res.end(JSON.stringify({ error: 'Connection not found' }));
|
|
3316
|
+
return;
|
|
3317
|
+
}
|
|
3318
|
+
|
|
3319
|
+
if (type === 'resize' && cols && rows) {
|
|
3320
|
+
conn.pty.resize(cols, rows);
|
|
3321
|
+
} else if (data) {
|
|
3322
|
+
// Decode base64 input
|
|
3323
|
+
const decoded = Buffer.from(data, 'base64').toString('utf8');
|
|
3324
|
+
conn.pty.write(decoded);
|
|
3325
|
+
}
|
|
3326
|
+
|
|
3327
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
3328
|
+
res.end(JSON.stringify({ ok: true }));
|
|
3329
|
+
} catch (err) {
|
|
3330
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
3331
|
+
res.end(JSON.stringify({ error: 'Invalid request' }));
|
|
3332
|
+
}
|
|
3333
|
+
});
|
|
3334
|
+
return;
|
|
3335
|
+
}
|
|
3336
|
+
|
|
1142
3337
|
// 404
|
|
1143
3338
|
res.writeHead(404);
|
|
1144
3339
|
res.end('Not Found');
|
|
1145
|
-
}
|
|
3340
|
+
}
|
|
1146
3341
|
|
|
1147
3342
|
function serveFile(filePath, res) {
|
|
1148
3343
|
const ext = path.extname(filePath);
|
|
@@ -1163,19 +3358,42 @@ function startWebServer(cliArgs) {
|
|
|
1163
3358
|
// WebSocket Server
|
|
1164
3359
|
// ============================================================================
|
|
1165
3360
|
|
|
1166
|
-
|
|
3361
|
+
// Track active WebSocket connections to tmux sessions
|
|
3362
|
+
// ws -> { pty, sessionName }
|
|
3363
|
+
const wsConnections = new Map();
|
|
3364
|
+
|
|
3365
|
+
// Track active SSE connections (fallback for mobile)
|
|
3366
|
+
// connectionId -> { res, pty, stableId, sessionInfo }
|
|
3367
|
+
const sseConnections = new Map();
|
|
1167
3368
|
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
3369
|
+
/**
|
|
3370
|
+
* Find a ghosttown session by stable ID (for web connections)
|
|
3371
|
+
* Returns { fullName, displayName, stableId } or null if not found
|
|
3372
|
+
*/
|
|
3373
|
+
function findSessionByStableIdWeb(stableId) {
|
|
3374
|
+
try {
|
|
3375
|
+
const output = execSync('tmux list-sessions -F "#{session_name}"', {
|
|
3376
|
+
encoding: 'utf8',
|
|
3377
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
3378
|
+
});
|
|
3379
|
+
const sessions = output.split('\n').filter((s) => s.trim() && s.startsWith('gt-'));
|
|
3380
|
+
for (const fullName of sessions) {
|
|
3381
|
+
const parsed = parseSessionName(fullName);
|
|
3382
|
+
if (parsed && parsed.stableId === stableId) {
|
|
3383
|
+
return parsed;
|
|
3384
|
+
}
|
|
3385
|
+
}
|
|
3386
|
+
return null;
|
|
3387
|
+
} catch (err) {
|
|
3388
|
+
return null;
|
|
1171
3389
|
}
|
|
1172
|
-
return process.env.SHELL || '/bin/bash';
|
|
1173
3390
|
}
|
|
1174
3391
|
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
3392
|
+
/**
|
|
3393
|
+
* Create a PTY that attaches to a tmux session
|
|
3394
|
+
*/
|
|
3395
|
+
function createTmuxAttachPty(fullSessionName, cols, rows) {
|
|
3396
|
+
const ptyProcess = pty.spawn('tmux', ['attach-session', '-t', fullSessionName], {
|
|
1179
3397
|
name: 'xterm-256color',
|
|
1180
3398
|
cols: cols,
|
|
1181
3399
|
rows: rows,
|
|
@@ -1196,7 +3414,19 @@ function startWebServer(cliArgs) {
|
|
|
1196
3414
|
const url = new URL(req.url, `http://${req.headers.host}`);
|
|
1197
3415
|
|
|
1198
3416
|
if (url.pathname === '/ws') {
|
|
3417
|
+
// Validate authentication before allowing WebSocket connection
|
|
3418
|
+
const cookies = parseCookies(req.headers.cookie);
|
|
3419
|
+
const session = validateAuthSession(cookies.ghostty_session);
|
|
3420
|
+
|
|
3421
|
+
if (!session) {
|
|
3422
|
+
// Reject unauthenticated WebSocket connections
|
|
3423
|
+
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
|
|
3424
|
+
socket.destroy();
|
|
3425
|
+
return;
|
|
3426
|
+
}
|
|
3427
|
+
|
|
1199
3428
|
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
3429
|
+
req.authSession = session;
|
|
1200
3430
|
wss.emit('connection', ws, req);
|
|
1201
3431
|
});
|
|
1202
3432
|
} else {
|
|
@@ -1208,9 +3438,42 @@ function startWebServer(cliArgs) {
|
|
|
1208
3438
|
const url = new URL(req.url, `http://${req.headers.host}`);
|
|
1209
3439
|
const cols = Number.parseInt(url.searchParams.get('cols') || '80');
|
|
1210
3440
|
const rows = Number.parseInt(url.searchParams.get('rows') || '24');
|
|
3441
|
+
const requestedSession = url.searchParams.get('session');
|
|
3442
|
+
|
|
3443
|
+
// Session parameter is required (should be stable ID)
|
|
3444
|
+
if (!requestedSession) {
|
|
3445
|
+
ws.send(JSON.stringify({ type: 'error', message: 'Session parameter required' }));
|
|
3446
|
+
ws.close();
|
|
3447
|
+
return;
|
|
3448
|
+
}
|
|
3449
|
+
|
|
3450
|
+
// Look up session by stable ID first, then by display name (for legacy URLs)
|
|
3451
|
+
let sessionInfo = findSessionByStableIdWeb(requestedSession);
|
|
3452
|
+
if (!sessionInfo) {
|
|
3453
|
+
// Try looking up by display name
|
|
3454
|
+
sessionInfo = findSessionByDisplayName(requestedSession);
|
|
3455
|
+
}
|
|
3456
|
+
if (!sessionInfo) {
|
|
3457
|
+
ws.send(
|
|
3458
|
+
JSON.stringify({ type: 'error', message: `Session '${requestedSession}' not found` })
|
|
3459
|
+
);
|
|
3460
|
+
ws.close();
|
|
3461
|
+
return;
|
|
3462
|
+
}
|
|
3463
|
+
|
|
3464
|
+
// Create a PTY that attaches to the tmux session
|
|
3465
|
+
const ptyProcess = createTmuxAttachPty(sessionInfo.fullName, cols, rows);
|
|
1211
3466
|
|
|
1212
|
-
|
|
1213
|
-
|
|
3467
|
+
wsConnections.set(ws, { pty: ptyProcess, stableId: requestedSession, sessionInfo });
|
|
3468
|
+
|
|
3469
|
+
// Send session info with current display name (may have been renamed)
|
|
3470
|
+
ws.send(
|
|
3471
|
+
JSON.stringify({
|
|
3472
|
+
type: 'session_info',
|
|
3473
|
+
name: sessionInfo.displayName,
|
|
3474
|
+
stableId: sessionInfo.stableId,
|
|
3475
|
+
})
|
|
3476
|
+
);
|
|
1214
3477
|
|
|
1215
3478
|
ptyProcess.onData((data) => {
|
|
1216
3479
|
if (ws.readyState === ws.OPEN) {
|
|
@@ -1218,11 +3481,11 @@ function startWebServer(cliArgs) {
|
|
|
1218
3481
|
}
|
|
1219
3482
|
});
|
|
1220
3483
|
|
|
1221
|
-
ptyProcess.onExit((
|
|
3484
|
+
ptyProcess.onExit(() => {
|
|
1222
3485
|
if (ws.readyState === ws.OPEN) {
|
|
1223
|
-
ws.send(`\r\n\x1b[33mShell exited (code: ${exitCode})\x1b[0m\r\n`);
|
|
1224
3486
|
ws.close();
|
|
1225
3487
|
}
|
|
3488
|
+
wsConnections.delete(ws);
|
|
1226
3489
|
});
|
|
1227
3490
|
|
|
1228
3491
|
ws.on('message', (data) => {
|
|
@@ -1244,15 +3507,19 @@ function startWebServer(cliArgs) {
|
|
|
1244
3507
|
});
|
|
1245
3508
|
|
|
1246
3509
|
ws.on('close', () => {
|
|
1247
|
-
const
|
|
1248
|
-
if (
|
|
1249
|
-
|
|
1250
|
-
|
|
3510
|
+
const conn = wsConnections.get(ws);
|
|
3511
|
+
if (conn) {
|
|
3512
|
+
conn.pty.kill();
|
|
3513
|
+
wsConnections.delete(ws);
|
|
1251
3514
|
}
|
|
1252
3515
|
});
|
|
1253
3516
|
|
|
1254
3517
|
ws.on('error', () => {
|
|
1255
|
-
|
|
3518
|
+
const conn = wsConnections.get(ws);
|
|
3519
|
+
if (conn) {
|
|
3520
|
+
conn.pty.kill();
|
|
3521
|
+
wsConnections.delete(ws);
|
|
3522
|
+
}
|
|
1256
3523
|
});
|
|
1257
3524
|
});
|
|
1258
3525
|
|
|
@@ -1260,20 +3527,7 @@ function startWebServer(cliArgs) {
|
|
|
1260
3527
|
// Startup
|
|
1261
3528
|
// ============================================================================
|
|
1262
3529
|
|
|
1263
|
-
function
|
|
1264
|
-
const interfaces = networkInterfaces();
|
|
1265
|
-
const ips = [];
|
|
1266
|
-
for (const name of Object.keys(interfaces)) {
|
|
1267
|
-
for (const iface of interfaces[name] || []) {
|
|
1268
|
-
if (iface.family === 'IPv4' && !iface.internal) {
|
|
1269
|
-
ips.push(iface.address);
|
|
1270
|
-
}
|
|
1271
|
-
}
|
|
1272
|
-
}
|
|
1273
|
-
return ips;
|
|
1274
|
-
}
|
|
1275
|
-
|
|
1276
|
-
function printBanner(url) {
|
|
3530
|
+
function printBanner(url, backgroundMode = false) {
|
|
1277
3531
|
const localIPs = getLocalIPs();
|
|
1278
3532
|
// ANSI color codes
|
|
1279
3533
|
const RESET = '\x1b[0m';
|
|
@@ -1293,7 +3547,8 @@ function startWebServer(cliArgs) {
|
|
|
1293
3547
|
for (const ip of localIPs) {
|
|
1294
3548
|
networkCount++;
|
|
1295
3549
|
const spaces = networkCount !== 1 ? ' ' : '';
|
|
1296
|
-
|
|
3550
|
+
const protocol = USE_HTTPS ? 'https' : 'http';
|
|
3551
|
+
network.push(`${spaces}${BEIGE}${protocol}://${ip}:${HTTP_PORT}${RESET}\n`);
|
|
1297
3552
|
}
|
|
1298
3553
|
}
|
|
1299
3554
|
console.log(`\n${network.join('')} `);
|
|
@@ -1307,13 +3562,17 @@ function startWebServer(cliArgs) {
|
|
|
1307
3562
|
console.log(` ${CYAN}Home:${RESET} ${BEIGE}${homedir()}${RESET}`);
|
|
1308
3563
|
|
|
1309
3564
|
console.log('');
|
|
1310
|
-
|
|
3565
|
+
if (backgroundMode) {
|
|
3566
|
+
console.log(` ${DIM}Run gt stop to stop.${RESET}\n`);
|
|
3567
|
+
} else {
|
|
3568
|
+
console.log(` ${DIM}Press ctrl+c to stop.${RESET}\n`);
|
|
3569
|
+
}
|
|
1311
3570
|
}
|
|
1312
3571
|
|
|
1313
3572
|
process.on('SIGINT', () => {
|
|
1314
3573
|
console.log('\n\nShutting down...');
|
|
1315
|
-
for (const [ws,
|
|
1316
|
-
|
|
3574
|
+
for (const [ws, conn] of wsConnections.entries()) {
|
|
3575
|
+
conn.pty.kill();
|
|
1317
3576
|
ws.close();
|
|
1318
3577
|
}
|
|
1319
3578
|
wss.close();
|
|
@@ -1326,7 +3585,7 @@ function startWebServer(cliArgs) {
|
|
|
1326
3585
|
const imagePath = path.join(__dirname, '..', 'bin', 'assets', 'ghosts.png');
|
|
1327
3586
|
if (fs.existsSync(imagePath)) {
|
|
1328
3587
|
// Welcome text with orange/yellow color (bright yellow, bold)
|
|
1329
|
-
console.log('\n \x1b[1;93mWelcome to
|
|
3588
|
+
console.log('\n \x1b[1;93mWelcome to Ghost Town!\x1b[0m\n');
|
|
1330
3589
|
const art = await asciiArt(imagePath, { maxWidth: 80, maxHeight: 20 });
|
|
1331
3590
|
console.log(art);
|
|
1332
3591
|
console.log('');
|
|
@@ -1336,7 +3595,8 @@ function startWebServer(cliArgs) {
|
|
|
1336
3595
|
// This allows the server to start even if the image is missing
|
|
1337
3596
|
}
|
|
1338
3597
|
|
|
1339
|
-
|
|
3598
|
+
const protocol = USE_HTTPS ? 'https' : 'http';
|
|
3599
|
+
printBanner(`${protocol}://localhost:${HTTP_PORT}`, backgroundMode);
|
|
1340
3600
|
});
|
|
1341
3601
|
}
|
|
1342
3602
|
|
|
@@ -1353,7 +3613,7 @@ export function run(argv) {
|
|
|
1353
3613
|
|
|
1354
3614
|
// If a command is provided, create a tmux session instead of starting server
|
|
1355
3615
|
if (cliArgs.command) {
|
|
1356
|
-
createTmuxSession(cliArgs.command);
|
|
3616
|
+
createTmuxSession(cliArgs.command, cliArgs.sessionName);
|
|
1357
3617
|
// createTmuxSession spawns tmux attach and waits for it to exit
|
|
1358
3618
|
// The script will exit when tmux attach exits (via the exit handler)
|
|
1359
3619
|
// We must not continue to server code, so we stop here
|