@seflless/ghosttown 1.3.1 → 1.3.2

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/bin/ghosttown.js CHANGED
File without changes
package/bin/ght.js CHANGED
File without changes
package/bin/gt.js CHANGED
File without changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@seflless/ghosttown",
3
- "version": "1.3.1",
3
+ "version": "1.3.2",
4
4
  "description": "Web-based terminal emulator using Ghostty's VT100 parser via WebAssembly",
5
5
  "type": "module",
6
6
  "main": "./dist/ghostty-web.umd.cjs",
@@ -11,6 +11,7 @@
11
11
  */
12
12
 
13
13
  import { execSync } from 'child_process';
14
+ import { readlinkSync } from 'fs';
14
15
 
15
16
  /**
16
17
  * Check if we're on a supported platform (macOS or Linux)
@@ -44,13 +45,37 @@ function isOurCommand(resolvedPath) {
44
45
 
45
46
  // Check if the path contains indicators that it's from our package
46
47
  const indicators = [
48
+ // Direct package installs
47
49
  'node_modules/ghosttown',
48
50
  'node_modules/@seflless/ghosttown',
51
+ // Local development (running from repo root)
49
52
  '/ghosttown/bin/gt',
53
+ // Scoped package reference
54
+ '@seflless/ghosttown',
50
55
  ];
51
56
 
52
57
  const normalizedPath = resolvedPath.toLowerCase();
53
- return indicators.some((indicator) => normalizedPath.includes(indicator.toLowerCase()));
58
+
59
+ // Direct indicator match
60
+ if (indicators.some((indicator) => normalizedPath.includes(indicator.toLowerCase()))) {
61
+ return true;
62
+ }
63
+
64
+ // If the path is in node_modules/.bin, it's likely a symlink created by npm/bun
65
+ // for this package. Check if it's a symlink and read its target.
66
+ if (normalizedPath.includes('node_modules/.bin/gt')) {
67
+ try {
68
+ const target = readlinkSync(resolvedPath);
69
+ // The symlink target will be something like ../@seflless/ghosttown/bin/gt.js
70
+ if (target.includes('@seflless/ghosttown') || target.includes('ghosttown/bin/gt')) {
71
+ return true;
72
+ }
73
+ } catch (err) {
74
+ // Not a symlink or can't read - continue with other checks
75
+ }
76
+ }
77
+
78
+ return false;
54
79
  }
55
80
 
56
81
  /**
package/src/cli.js CHANGED
@@ -18,7 +18,7 @@
18
18
  * ghosttown "npm run dev" Run npm in a new tmux session
19
19
  */
20
20
 
21
- import { execSync, spawn } from 'child_process';
21
+ import { execSync, spawn, spawnSync } from 'child_process';
22
22
  import fs from 'fs';
23
23
  import http from 'http';
24
24
  import { homedir, networkInterfaces } from 'os';
@@ -74,6 +74,37 @@ function printTmuxInstallHelp() {
74
74
  process.exit(1);
75
75
  }
76
76
 
77
+ /**
78
+ * Check if currently inside a tmux session
79
+ */
80
+ function isInsideTmux() {
81
+ return !!process.env.TMUX;
82
+ }
83
+
84
+ /**
85
+ * Get the name of the current tmux session
86
+ * Returns null if not inside tmux
87
+ */
88
+ function getCurrentTmuxSessionName() {
89
+ if (!isInsideTmux()) return null;
90
+ try {
91
+ return execSync('tmux display-message -p "#{session_name}"', {
92
+ encoding: 'utf8',
93
+ stdio: ['pipe', 'pipe', 'pipe'],
94
+ }).trim();
95
+ } catch (err) {
96
+ return null;
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Check if currently inside a ghosttown session (named ghosttown-<N>)
102
+ */
103
+ function isInsideGhosttownSession() {
104
+ const sessionName = getCurrentTmuxSessionName();
105
+ return sessionName && sessionName.startsWith('ghosttown-');
106
+ }
107
+
77
108
  /**
78
109
  * Get the next available ghosttown session ID
79
110
  * Scans existing tmux sessions named ghosttown-<N> and returns max(N) + 1
@@ -112,6 +143,10 @@ function listSessions() {
112
143
  printTmuxInstallHelp();
113
144
  }
114
145
 
146
+ const RESET = '\x1b[0m';
147
+ const CYAN = '\x1b[36m';
148
+ const BEIGE = '\x1b[38;2;255;220;150m';
149
+
115
150
  try {
116
151
  // Get detailed session information
117
152
  const output = execSync(
@@ -136,43 +171,39 @@ function listSessions() {
136
171
  });
137
172
 
138
173
  if (sessions.length === 0) {
139
- console.log('No ghosttown sessions running.');
140
- console.log('\nTo create a session, run:');
141
- console.log(' ghosttown <command>');
142
- console.log('\nExample:');
143
- console.log(' ghosttown vim');
174
+ console.log('');
175
+ console.log(` ${BEIGE}No ghosttown sessions are currently running.${RESET}`);
176
+ console.log('');
144
177
  process.exit(0);
145
178
  }
146
179
 
147
180
  // Print header
148
181
  console.log('\n\x1b[1mGhosttown Sessions\x1b[0m\n');
149
182
  console.log(
150
- `\x1b[36m${'Name'.padEnd(15)} ${'Created'.padEnd(22)} ${'Status'.padEnd(10)} Windows\x1b[0m`
183
+ `${CYAN}${'Name'.padEnd(15)} ${'Created'.padEnd(22)} ${'Status'.padEnd(10)} Windows${RESET}`
151
184
  );
152
- console.log('-'.repeat(65));
153
185
 
154
186
  // Print sessions
155
187
  for (const session of sessions) {
156
188
  const createdStr = session.created.toLocaleString();
157
189
  // Status has ANSI codes so we need to pad the visible text differently
158
190
  const statusPadded = session.attached
159
- ? `\x1b[32m${'attached'.padEnd(10)}\x1b[0m`
160
- : `\x1b[33m${'detached'.padEnd(10)}\x1b[0m`;
191
+ ? `\x1b[32m${'attached'.padEnd(10)}${RESET}`
192
+ : `\x1b[33m${'detached'.padEnd(10)}${RESET}`;
161
193
  console.log(
162
194
  `${session.name.padEnd(15)} ${createdStr.padEnd(22)} ${statusPadded} ${session.windows}`
163
195
  );
164
196
  }
165
197
 
166
198
  console.log('');
167
- console.log(`Total: ${sessions.length} session(s)`);
199
+ const sessionWord = sessions.length === 1 ? 'session' : 'sessions';
200
+ console.log(`Total: ${sessions.length} ${sessionWord}`);
168
201
  console.log('');
169
202
  } catch (err) {
170
203
  // tmux not running or no sessions
171
- console.log('No ghosttown sessions running.');
172
- console.log('\nTo create a session, run:');
173
- console.log(' ghosttown <command>');
174
- console.log('\nExample:');
175
- console.log(' ghosttown vim');
204
+ console.log('');
205
+ console.log(` ${BEIGE}No ghosttown sessions are currently running.${RESET}`);
206
+ console.log('');
176
207
  }
177
208
 
178
209
  process.exit(0);
@@ -222,6 +253,264 @@ function createTmuxSession(command) {
222
253
  }
223
254
  }
224
255
 
256
+ /**
257
+ * Print styled detach success message
258
+ */
259
+ function printDetachMessage(sessionName) {
260
+ const RESET = '\x1b[0m';
261
+ const CYAN = '\x1b[36m';
262
+ const BEIGE = '\x1b[38;2;255;220;150m';
263
+ const DIM = '\x1b[2m';
264
+ const BOLD_YELLOW = '\x1b[1;93m';
265
+ const labelWidth = Math.max('To reattach:'.length, 'To list all sessions:'.length);
266
+ const formatHint = (label, command) =>
267
+ ` ${CYAN}${label.padStart(labelWidth)}${RESET} ${BEIGE}${command}${RESET}`;
268
+
269
+ return [
270
+ '',
271
+ ` ${BOLD_YELLOW}You've detached from your ghosttown session.${RESET}`,
272
+ ` ${DIM}It's now running in the background.${RESET}`,
273
+ '',
274
+ formatHint('To reattach:', `ghosttown attach ${sessionName}`),
275
+ '',
276
+ formatHint('To list all sessions:', 'ghosttown list'),
277
+ '',
278
+ '',
279
+ ].join('\n');
280
+ }
281
+
282
+ /**
283
+ * Detach from the current ghosttown tmux session
284
+ */
285
+ function detachFromTmux() {
286
+ const RESET = '\x1b[0m';
287
+ const RED = '\x1b[31m';
288
+ const DIM = '\x1b[2m';
289
+ const BEIGE = '\x1b[38;2;255;220;150m';
290
+
291
+ // Check if we're inside tmux at all
292
+ if (!isInsideTmux()) {
293
+ console.log('');
294
+ console.log(` ${RED}Error:${RESET} Not inside a tmux session.`);
295
+ console.log('');
296
+ console.log(` ${BEIGE}No ghosttown sessions are currently running.${RESET}`);
297
+ console.log('');
298
+ process.exit(1);
299
+ }
300
+
301
+ // Check if we're inside a ghosttown session specifically
302
+ if (!isInsideGhosttownSession()) {
303
+ console.log('');
304
+ console.log(` ${RED}Error:${RESET} Not inside a ghosttown session.`);
305
+ console.log('');
306
+ console.log(` ${DIM}You're in a tmux session, but not one created by ghosttown.${RESET}`);
307
+ console.log(
308
+ ` ${DIM}To detach from this tmux session, use: tmux detach (or ctrl+b d)${RESET}`
309
+ );
310
+ console.log('');
311
+ process.exit(1);
312
+ }
313
+
314
+ // Get the session name for the message
315
+ const sessionName = getCurrentTmuxSessionName();
316
+ const message = printDetachMessage(sessionName);
317
+ const messageForShell = message.replace(/'/g, "'\\''");
318
+
319
+ // Detach only the current client (not all attached clients).
320
+ // Use client_tty so multiple attached windows aren't all detached.
321
+ let result = null;
322
+ let clientTty = null;
323
+ try {
324
+ clientTty = execSync('tmux display-message -p "#{client_tty}"', {
325
+ encoding: 'utf8',
326
+ stdio: ['pipe', 'pipe', 'pipe'],
327
+ }).trim();
328
+
329
+ if (clientTty) {
330
+ result = spawnSync(
331
+ 'tmux',
332
+ [
333
+ 'detach-client',
334
+ '-t',
335
+ clientTty,
336
+ '-E',
337
+ `printf %s '${messageForShell}'`,
338
+ ],
339
+ {
340
+ stdio: 'inherit',
341
+ }
342
+ );
343
+ }
344
+ } catch (err) {
345
+ // Fall back to generic detach below.
346
+ }
347
+
348
+ if (!result) {
349
+ // Detach using shell execution to avoid nesting warnings
350
+ result = spawnSync(process.env.SHELL || '/bin/sh', ['-c', 'exec tmux detach'], {
351
+ stdio: 'inherit',
352
+ });
353
+ }
354
+
355
+ // If we couldn't target a client tty, fall back to stdout.
356
+ if (!clientTty) {
357
+ process.stdout.write(message);
358
+ }
359
+
360
+ process.exit(result.status || 0);
361
+ }
362
+
363
+ /**
364
+ * Attach to a ghosttown session
365
+ */
366
+ function attachToSession(sessionName) {
367
+ // Check if tmux is installed
368
+ if (!checkTmuxInstalled()) {
369
+ printTmuxInstallHelp();
370
+ }
371
+
372
+ const RESET = '\x1b[0m';
373
+ const RED = '\x1b[31m';
374
+ const BEIGE = '\x1b[38;2;255;220;150m';
375
+
376
+ // Add ghosttown- prefix if not present
377
+ if (!sessionName.startsWith('ghosttown-')) {
378
+ sessionName = `ghosttown-${sessionName}`;
379
+ }
380
+
381
+ // Check if session exists
382
+ try {
383
+ const output = execSync('tmux list-sessions -F "#{session_name}"', {
384
+ encoding: 'utf8',
385
+ stdio: ['pipe', 'pipe', 'pipe'],
386
+ });
387
+
388
+ const sessions = output.split('\n').filter((s) => s.trim());
389
+ if (!sessions.includes(sessionName)) {
390
+ console.log('');
391
+ console.log(` ${RED}Error:${RESET} Session '${sessionName}' not found.`);
392
+ console.log('');
393
+ console.log(` ${BEIGE}No ghosttown sessions are currently running.${RESET}`);
394
+ console.log('');
395
+ process.exit(1);
396
+ }
397
+ } catch (err) {
398
+ console.log('');
399
+ console.log(` ${RED}Error:${RESET} No tmux sessions found.`);
400
+ console.log('');
401
+ console.log(` ${BEIGE}No ghosttown sessions are currently running.${RESET}`);
402
+ console.log('');
403
+ process.exit(1);
404
+ }
405
+
406
+ // Attach to the session using spawn with shell
407
+ // Use the same pattern as detachFromTmux which works
408
+ const result = spawnSync(
409
+ process.env.SHELL || '/bin/sh',
410
+ ['-c', `tmux attach-session -t ${sessionName}`],
411
+ {
412
+ stdio: 'inherit',
413
+ }
414
+ );
415
+
416
+ process.exit(result.status || 0);
417
+ }
418
+
419
+ /**
420
+ * Kill a ghosttown session
421
+ * If sessionName is null, kills the current session (if inside one)
422
+ */
423
+ function killSession(sessionName) {
424
+ // Check if tmux is installed
425
+ if (!checkTmuxInstalled()) {
426
+ printTmuxInstallHelp();
427
+ }
428
+
429
+ const RESET = '\x1b[0m';
430
+ const RED = '\x1b[31m';
431
+ const CYAN = '\x1b[36m';
432
+ const BEIGE = '\x1b[38;2;255;220;150m';
433
+ const DIM = '\x1b[2m';
434
+ const BOLD_YELLOW = '\x1b[1;93m';
435
+
436
+ // If no session specified, try to kill current session
437
+ if (!sessionName) {
438
+ if (!isInsideTmux()) {
439
+ console.log('');
440
+ console.log(` ${RED}Error:${RESET} No session specified and not inside a tmux session.`);
441
+ console.log('');
442
+ console.log(` ${DIM}Usage:${RESET}`);
443
+ console.log(` ${DIM} ghosttown -k <session> Kill a specific session${RESET}`);
444
+ console.log(
445
+ ` ${DIM} ghosttown -k Kill current session (when inside one)${RESET}`
446
+ );
447
+ console.log('');
448
+ process.exit(1);
449
+ }
450
+
451
+ if (!isInsideGhosttownSession()) {
452
+ console.log('');
453
+ console.log(` ${RED}Error:${RESET} Not inside a ghosttown session.`);
454
+ console.log('');
455
+ console.log(` ${DIM}You're in a tmux session, but not one created by ghosttown.${RESET}`);
456
+ console.log(` ${DIM}To kill this tmux session, use: tmux kill-session${RESET}`);
457
+ console.log('');
458
+ process.exit(1);
459
+ }
460
+
461
+ sessionName = getCurrentTmuxSessionName();
462
+ }
463
+
464
+ // Add ghosttown- prefix if not present
465
+ if (!sessionName.startsWith('ghosttown-')) {
466
+ sessionName = `ghosttown-${sessionName}`;
467
+ }
468
+
469
+ // Check if session exists
470
+ try {
471
+ const output = execSync('tmux list-sessions -F "#{session_name}"', {
472
+ encoding: 'utf8',
473
+ stdio: ['pipe', 'pipe', 'pipe'],
474
+ });
475
+
476
+ const sessions = output.split('\n').filter((s) => s.trim());
477
+ if (!sessions.includes(sessionName)) {
478
+ console.log('');
479
+ console.log(` ${RED}Error:${RESET} Session '${sessionName}' not found.`);
480
+ console.log('');
481
+ console.log(` ${BEIGE}No ghosttown sessions are currently running.${RESET}`);
482
+ console.log('');
483
+ process.exit(1);
484
+ }
485
+ } catch (err) {
486
+ console.log('');
487
+ console.log(` ${RED}Error:${RESET} No tmux sessions found.`);
488
+ console.log('');
489
+ console.log(` ${BEIGE}No ghosttown sessions are currently running.${RESET}`);
490
+ console.log('');
491
+ process.exit(1);
492
+ }
493
+
494
+ // Kill the session
495
+ try {
496
+ execSync(`tmux kill-session -t ${sessionName}`, {
497
+ stdio: 'pipe',
498
+ });
499
+
500
+ console.log('');
501
+ console.log(` ${BOLD_YELLOW}Session '${sessionName}' has been killed.${RESET}`);
502
+ console.log('');
503
+ console.log(` ${CYAN}To list remaining:${RESET} ${BEIGE}ghosttown list${RESET}`);
504
+ console.log('');
505
+ process.exit(0);
506
+ } catch (err) {
507
+ console.log('');
508
+ console.log(` ${RED}Error:${RESET} Failed to kill session '${sessionName}'.`);
509
+ console.log('');
510
+ process.exit(1);
511
+ }
512
+ }
513
+
225
514
  // ============================================================================
226
515
  // Parse CLI arguments
227
516
  // ============================================================================
@@ -230,6 +519,7 @@ function parseArgs(argv) {
230
519
  const args = argv.slice(2);
231
520
  let port = null;
232
521
  let command = null;
522
+ let handled = false;
233
523
 
234
524
  for (let i = 0; i < args.length; i++) {
235
525
  const arg = args[i];
@@ -239,33 +529,71 @@ function parseArgs(argv) {
239
529
  Usage: ghosttown [options] [command]
240
530
 
241
531
  Options:
242
- -p, --port <port> Port to listen on (default: 8080, or PORT env var)
243
- -h, --help Show this help message
532
+ -p, --port <port> Port to listen on (default: 8080, or PORT env var)
533
+ -k, --kill [session] Kill a session (current if inside one, or specify)
534
+ -h, --help Show this help message
244
535
 
245
536
  Commands:
246
537
  list List all ghosttown tmux sessions
538
+ attach <session> Attach to a ghosttown session
539
+ detach Detach from current ghosttown session
247
540
  <command> Run command in a new tmux session (ghosttown-<id>)
248
541
 
249
542
  Examples:
250
543
  ghosttown Start the web terminal server
251
544
  ghosttown -p 3000 Start the server on port 3000
252
545
  ghosttown list List all ghosttown sessions
546
+ ghosttown attach ghosttown-1 Attach to session ghosttown-1
547
+ ghosttown detach Detach from current session
548
+ ghosttown -k Kill current session (when inside one)
549
+ ghosttown -k ghosttown-1 Kill a specific session
253
550
  ghosttown vim Run vim in a new tmux session
254
551
  ghosttown "npm run dev" Run npm in a new tmux session
255
552
 
256
553
  Aliases:
257
554
  This CLI can also be invoked as 'gt' or 'ght'.
258
555
  `);
259
- process.exit(0);
556
+ handled = true;
557
+ break;
260
558
  }
261
559
 
262
560
  // Handle list command
263
561
  if (arg === 'list') {
562
+ handled = true;
264
563
  listSessions();
265
564
  // listSessions exits, so this won't be reached
266
565
  }
267
566
 
268
- if (arg === '-p' || arg === '--port') {
567
+ // Handle detach command
568
+ else if (arg === 'detach') {
569
+ handled = true;
570
+ detachFromTmux();
571
+ // detachFromTmux exits, so this won't be reached
572
+ }
573
+
574
+ // Handle attach command
575
+ else if (arg === 'attach') {
576
+ const sessionArg = args[i + 1];
577
+ if (!sessionArg) {
578
+ console.error('Error: attach requires a session name');
579
+ console.error('Usage: ghosttown attach <session>');
580
+ handled = true;
581
+ break;
582
+ }
583
+ handled = true;
584
+ attachToSession(sessionArg);
585
+ // attachToSession exits, so this won't be reached
586
+ }
587
+
588
+ // Handle kill command
589
+ else if (arg === '-k' || arg === '--kill') {
590
+ const nextArg = args[i + 1];
591
+ // Session name is optional - if not provided or is another flag, pass null
592
+ const sessionArg = nextArg && !nextArg.startsWith('-') ? nextArg : null;
593
+ handled = true;
594
+ killSession(sessionArg);
595
+ // killSession exits, so this won't be reached
596
+ } else if (arg === '-p' || arg === '--port') {
269
597
  const nextArg = args[i + 1];
270
598
  if (!nextArg || nextArg.startsWith('-')) {
271
599
  console.error(`Error: ${arg} requires a port number`);
@@ -277,18 +605,17 @@ Aliases:
277
605
  process.exit(1);
278
606
  }
279
607
  i++; // Skip the next argument since we consumed it
280
- continue;
281
608
  }
282
609
 
283
610
  // First non-flag argument starts the command
284
611
  // Capture it and all remaining arguments as the command
285
- if (!arg.startsWith('-')) {
612
+ else if (!arg.startsWith('-')) {
286
613
  command = args.slice(i).join(' ');
287
614
  break;
288
615
  }
289
616
  }
290
617
 
291
- return { port, command };
618
+ return { port, command, handled };
292
619
  }
293
620
 
294
621
  // ============================================================================
@@ -891,7 +1218,7 @@ function startWebServer(cliArgs) {
891
1218
  console.log(` ${CYAN}Home:${RESET} ${BEIGE}${homedir()}${RESET}`);
892
1219
 
893
1220
  console.log('');
894
- console.log(` ${DIM}Press Ctrl+C to stop.${RESET}\n`);
1221
+ console.log(` ${DIM}Press ctrl+c to stop.${RESET}\n`);
895
1222
  }
896
1223
 
897
1224
  process.on('SIGINT', () => {
@@ -931,6 +1258,10 @@ function startWebServer(cliArgs) {
931
1258
  export function run(argv) {
932
1259
  const cliArgs = parseArgs(argv);
933
1260
 
1261
+ if (cliArgs.handled) {
1262
+ return;
1263
+ }
1264
+
934
1265
  // If a command is provided, create a tmux session instead of starting server
935
1266
  if (cliArgs.command) {
936
1267
  createTmuxSession(cliArgs.command);