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