@seflless/ghosttown 1.5.0 → 1.6.1

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