@seflless/ghosttown 1.4.1 → 1.6.0

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