@seflless/ghosttown 1.3.1 → 1.3.3

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