@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/src/cli.js CHANGED
@@ -8,21 +8,39 @@
8
8
  * ghosttown [options] [command]
9
9
  *
10
10
  * Options:
11
- * -p, --port <port> Port to listen on (default: 8080, or PORT env var)
12
- * -h, --help Show this help message
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 "npm run dev" Run npm in a new tmux session
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
- * Get the next available ghosttown session ID
112
- * Scans existing tmux sessions named gt-<N> and returns max(N) + 1
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 getNextSessionId() {
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 gt-<N> sessions and extract IDs
123
- const sessionIds = output
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 id = Number.parseInt(name.replace('gt-', ''), 10);
128
- return Number.isNaN(id) ? 0 : id;
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
- // Return max + 1, or 1 if no sessions exist
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 [name, activity, attached, windows] = line.split('|');
167
- // Extract just the number from gt-<N>
168
- const id = name.replace('gt-', '');
574
+ const [fullName, activity, attached, windows] = line.split('|');
575
+ const parsed = parseSessionName(fullName);
169
576
  return {
170
- id,
171
- name,
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}${'ID'.padEnd(6)} ${'Last Activity'.padEnd(22)} ${'Status'.padEnd(10)} Windows${RESET}`
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.id.padEnd(6)} ${activityStr.padEnd(22)} ${statusPadded} ${session.windows}`
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 sessionId = getNextSessionId();
230
- const sessionName = `gt-${sessionId}`;
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 tmux session and attach directly (not detached)
234
- // Use -x and -y to set initial size, avoiding "terminal too small" issues
235
- // The command runs in the session, and we attach immediately
236
- const attach = spawn(
237
- 'tmux',
238
- [
239
- 'new-session',
240
- '-s',
241
- sessionName,
242
- '-x',
243
- '200',
244
- '-y',
245
- '50',
246
- // Set status off (no HUD), run the command, then start a shell
247
- // so the session stays open and interactive after command completes
248
- 'tmux set-option status off; ' + command + '; exec $SHELL',
249
- ],
250
- {
251
- stdio: 'inherit',
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 just the ID from gt-<N>
278
- const sessionId = sessionName.replace('gt-', '');
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 ${sessionId}`),
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 just the ID from gt-<N>
570
- const sessionId = sessionName.replace('gt-', '');
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 ${sessionId} has been killed.${RESET}`);
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
- // Parse CLI arguments
588
- // ============================================================================
589
-
590
- function parseArgs(argv) {
591
- const args = argv.slice(2);
592
- let port = null;
593
- let command = null;
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
- Options:
604
- -p, --port <port> Port to listen on (default: 8080, or PORT env var)
605
- -k, --kill [session] Kill a session (current if inside one, or specify by ID)
606
- -v, --version Show version number
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
- Commands:
610
- list List all ghosttown tmux sessions
611
- attach <id> Attach to a ghosttown session by ID (e.g., 'gt attach 1')
612
- detach Detach from current ghosttown session
613
- update Update ghosttown to the latest version
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
- Examples:
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
- Aliases:
629
- This CLI can also be invoked as 'gt' or 'ght'.
630
- `);
631
- handled = true;
632
- break;
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
- // Handle version flag
636
- if (arg === '-v' || arg === '--version') {
637
- console.log(VERSION);
638
- handled = true;
639
- break;
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
- // Handle list command
643
- if (arg === 'list') {
644
- handled = true;
645
- listSessions();
646
- // listSessions exits, so this won't be reached
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
- // Handle detach command
650
- else if (arg === 'detach') {
651
- handled = true;
652
- detachFromTmux();
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
- <div class="terminal-window">
884
- <div class="title-bar">
885
- <div class="traffic-lights">
886
- <span class="light red"></span>
887
- <span class="light yellow"></span>
888
- <span class="light green"></span>
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
- <div class="title">
891
- <span>ghosttown</span>
892
- <span class="title-separator" id="title-separator" style="display: none">•</span>
893
- <span class="current-directory" id="current-directory"></span>
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">&larr; 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 class="connection-status">
896
- <span class="connection-dot" id="connection-dot"></span>
897
- <span id="connection-text">Disconnected</span>
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, '&amp;')
2187
+ .replace(/</g, '&lt;')
2188
+ .replace(/>/g, '&gt;')
2189
+ .replace(/"/g, '&quot;')
2190
+ .replace(/'/g, '&#39;');
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 (ws && ws.readyState === WebSocket.OPEN) {
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 (ws && ws.readyState === WebSocket.OPEN) {
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
- connectWebSocket();
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
- initTerminal();
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
- const httpServer = http.createServer((req, res) => {
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
- const sessions = new Map();
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
- function getShell() {
1169
- if (process.platform === 'win32') {
1170
- return process.env.COMSPEC || 'cmd.exe';
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
- function createPtySession(cols, rows) {
1176
- const shell = getShell();
1177
-
1178
- const ptyProcess = pty.spawn(shell, [], {
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
- const ptyProcess = createPtySession(cols, rows);
1213
- sessions.set(ws, { pty: ptyProcess });
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(({ exitCode }) => {
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 session = sessions.get(ws);
1248
- if (session) {
1249
- session.pty.kill();
1250
- sessions.delete(ws);
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
- // Ignore socket errors
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 getLocalIPs() {
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
- network.push(`${spaces}${BEIGE}http://${ip}:${HTTP_PORT}${RESET}\n`);
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
- console.log(` ${DIM}Press ctrl+c to stop.${RESET}\n`);
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, session] of sessions.entries()) {
1316
- session.pty.kill();
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 Ghosttown!\x1b[0m\n');
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
- printBanner(`http://localhost:${HTTP_PORT}`);
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