@seflless/ghosttown 1.2.4 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/ghosttown.js CHANGED
@@ -1,710 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  /**
4
- * ghosttown CLI - Web-based terminal emulator
4
+ * ghosttown CLI - Primary command
5
5
  *
6
- * Starts a local HTTP server with WebSocket PTY support.
7
- *
8
- * Usage:
9
- * ghosttown [options]
10
- *
11
- * Options:
12
- * -p, --port <port> Port to listen on (default: 8080, or PORT env var)
13
- * -h, --help Show this help message
14
- *
15
- * Examples:
16
- * ghosttown
17
- * ghosttown -p 3000
18
- * ghosttown --port 3000
19
- * PORT=3000 ghosttown
6
+ * This is a thin wrapper that delegates to the shared CLI implementation.
7
+ * Aliases: gt, ght
20
8
  */
21
9
 
22
- import fs from 'fs';
23
- import http from 'http';
24
- import { homedir, networkInterfaces } from 'os';
25
- import path from 'path';
26
- import { fileURLToPath } from 'url';
27
-
28
- // Node-pty for cross-platform PTY support
29
- import pty from '@lydell/node-pty';
30
- // WebSocket server
31
- import { WebSocketServer } from 'ws';
32
- // ASCII art generator
33
- import { asciiArt } from './ascii.js';
34
-
35
- const __filename = fileURLToPath(import.meta.url);
36
- const __dirname = path.dirname(__filename);
37
-
38
- // ============================================================================
39
- // Parse CLI arguments
40
- // ============================================================================
41
-
42
- function parseArgs() {
43
- const args = process.argv.slice(2);
44
- let port = null;
45
-
46
- for (let i = 0; i < args.length; i++) {
47
- const arg = args[i];
48
-
49
- if (arg === '-h' || arg === '--help') {
50
- console.log(`
51
- Usage: ghosttown [options]
52
-
53
- Options:
54
- -p, --port <port> Port to listen on (default: 8080, or PORT env var)
55
- -h, --help Show this help message
56
-
57
- Examples:
58
- ghosttown
59
- ghosttown -p 3000
60
- ghosttown --port 3000
61
- PORT=3000 ghosttown
62
- `);
63
- process.exit(0);
64
- }
65
-
66
- if (arg === '-p' || arg === '--port') {
67
- const nextArg = args[i + 1];
68
- if (!nextArg || nextArg.startsWith('-')) {
69
- console.error(`Error: ${arg} requires a port number`);
70
- process.exit(1);
71
- }
72
- port = parseInt(nextArg, 10);
73
- if (isNaN(port) || port < 1 || port > 65535) {
74
- console.error(`Error: Invalid port number: ${nextArg}`);
75
- process.exit(1);
76
- }
77
- i++; // Skip the next argument since we consumed it
78
- }
79
- }
80
-
81
- return { port };
82
- }
83
-
84
- const cliArgs = parseArgs();
85
- const HTTP_PORT = cliArgs.port || process.env.PORT || 8080;
86
-
87
- // ============================================================================
88
- // Locate ghosttown assets
89
- // ============================================================================
90
-
91
- function findAssets() {
92
- // Assets are in the package root (one level up from bin/)
93
- const packageRoot = path.join(__dirname, '..');
94
- const distPath = path.join(packageRoot, 'dist');
95
- const wasmPath = path.join(packageRoot, 'ghostty-vt.wasm');
96
-
97
- if (!fs.existsSync(path.join(distPath, 'ghostty-web.js'))) {
98
- console.error('Error: dist/ghostty-web.js not found.');
99
- console.error('The package may not have been built correctly.');
100
- process.exit(1);
101
- }
102
-
103
- if (!fs.existsSync(wasmPath)) {
104
- console.error('Error: ghostty-vt.wasm not found.');
105
- console.error('The package may not have been built correctly.');
106
- process.exit(1);
107
- }
108
-
109
- return { distPath, wasmPath };
110
- }
111
-
112
- const { distPath, wasmPath } = findAssets();
113
-
114
- // ============================================================================
115
- // HTML Template
116
- // ============================================================================
117
-
118
- const HTML_TEMPLATE = `<!doctype html>
119
- <html lang="en">
120
- <head>
121
- <meta charset="UTF-8" />
122
- <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
123
- <title>ghosttown</title>
124
- <style>
125
- :root {
126
- --vvh: 100vh;
127
- --vv-offset-top: 0px;
128
- }
129
-
130
- * {
131
- margin: 0;
132
- padding: 0;
133
- box-sizing: border-box;
134
- }
135
-
136
- html, body {
137
- margin: 0;
138
- padding: 0;
139
- height: var(--vvh);
140
- overflow: hidden;
141
- overscroll-behavior: none;
142
- touch-action: none;
143
- transition: none;
144
- }
145
-
146
- body {
147
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
148
- background: #292c34;
149
- padding: 8px 8px 14px 8px;
150
- box-sizing: border-box;
151
- position: fixed;
152
- inset: 0;
153
- height: var(--vvh);
154
- }
155
-
156
- .terminal-window {
157
- width: 100%;
158
- height: 100%;
159
- background: #ededed;
160
- display: flex;
161
- flex-direction: column;
162
- overflow: hidden;
163
- box-shadow:
164
- 0 0 0 0.5px #65686e,
165
- 0 0 0 1px #74777c,
166
- 0 0 0 1.5px #020203,
167
- 0px 4px 10px 0px rgba(0, 0, 0, 0.5);
168
- border-radius: 8px;
169
- box-sizing: border-box;
170
- transform: translateY(calc(var(--vv-offset-top) * -1));
171
- transition: none;
172
- }
173
-
174
- .title-bar {
175
- background: #292c34;
176
- padding: 8px 16px 6px 10px;
177
- display: flex;
178
- align-items: center;
179
- gap: 12px;
180
- }
181
-
182
- .traffic-lights {
183
- display: flex;
184
- gap: 8px;
185
- }
186
-
187
- .light {
188
- width: 12px;
189
- height: 12px;
190
- border-radius: 50%;
191
- }
192
-
193
- .light.red { background: #ff5f56; }
194
- .light.yellow { background: #ffbd2e; }
195
- .light.green { background: #27c93f; }
196
-
197
- .title {
198
- color: #e5e5e5;
199
- font-size: 13px;
200
- font-weight: 500;
201
- letter-spacing: 0.3px;
202
- display: flex;
203
- align-items: center;
204
- gap: 8px;
205
- }
206
-
207
- .title-separator { color: #666; }
208
- .current-directory {
209
- color: #888;
210
- font-size: 12px;
211
- font-weight: 400;
212
- max-width: 400px;
213
- overflow: hidden;
214
- text-overflow: ellipsis;
215
- white-space: nowrap;
216
- }
217
-
218
- .connection-status {
219
- margin-left: auto;
220
- font-size: 11px;
221
- color: #888;
222
- display: flex;
223
- align-items: center;
224
- gap: 6px;
225
- }
226
-
227
- .connection-dot {
228
- width: 6px;
229
- height: 6px;
230
- border-radius: 50%;
231
- background: #666;
232
- }
233
-
234
- .connection-dot.connected { background: #27c93f; }
235
-
236
- #terminal-container {
237
- flex: 1;
238
- padding: 2px 0 2px 2px;
239
- background: #292c34;
240
- position: relative;
241
- overflow: hidden;
242
- min-height: 0;
243
- touch-action: none;
244
- }
245
-
246
- #terminal-container canvas {
247
- display: block;
248
- touch-action: none;
249
- }
250
- </style>
251
- </head>
252
- <body>
253
- <div class="terminal-window">
254
- <div class="title-bar">
255
- <div class="traffic-lights">
256
- <span class="light red"></span>
257
- <span class="light yellow"></span>
258
- <span class="light green"></span>
259
- </div>
260
- <div class="title">
261
- <span>ghosttown</span>
262
- <span class="title-separator" id="title-separator" style="display: none">•</span>
263
- <span class="current-directory" id="current-directory"></span>
264
- </div>
265
- <div class="connection-status">
266
- <span class="connection-dot" id="connection-dot"></span>
267
- <span id="connection-text">Disconnected</span>
268
- </div>
269
- </div>
270
- <div id="terminal-container"></div>
271
- </div>
272
-
273
- <script type="module">
274
- import { init, Terminal, FitAddon } from '/dist/ghostty-web.js';
275
-
276
- let term;
277
- let ws;
278
- let fitAddon;
279
-
280
- async function initTerminal() {
281
- await init();
282
-
283
- term = new Terminal({
284
- cursorBlink: true,
285
- fontSize: 12,
286
- fontFamily: 'Monaco, Menlo, "Courier New", monospace',
287
- theme: {
288
- background: '#292c34',
289
- foreground: '#d4d4d4',
290
- },
291
- smoothScrollDuration: 0,
292
- scrollback: 10000,
293
- scrollbarVisible: false,
294
- });
295
-
296
- fitAddon = new FitAddon();
297
- term.loadAddon(fitAddon);
298
-
299
- term.open(document.getElementById('terminal-container'));
300
- fitAddon.fit();
301
- fitAddon.observeResize();
302
-
303
- // Desktop: auto-focus. Mobile: focus only on tap.
304
- const isCoarsePointer = window.matchMedia && window.matchMedia('(pointer: coarse)').matches;
305
- if (!isCoarsePointer) {
306
- term.focus();
307
- }
308
-
309
- // Prevent page scroll on iOS
310
- document.addEventListener('touchmove', (e) => {
311
- const container = document.getElementById('terminal-container');
312
- if (container && !container.contains(e.target)) {
313
- e.preventDefault();
314
- }
315
- }, { passive: false });
316
-
317
- // Mobile keyboard handling via visualViewport
318
- {
319
- const root = document.documentElement;
320
- let watchRafId = null;
321
- let vvhCurrent = 0;
322
- let vvhTarget = 0;
323
- let offsetCurrent = 0;
324
- let offsetTarget = 0;
325
- let lastTick = 0;
326
-
327
- const readViewport = () => ({
328
- height: window.visualViewport?.height ?? window.innerHeight,
329
- offsetTop: window.visualViewport?.offsetTop ?? 0,
330
- });
331
-
332
- const applyVars = () => {
333
- root.style.setProperty('--vvh', vvhCurrent + 'px');
334
- root.style.setProperty('--vv-offset-top', offsetCurrent + 'px');
335
- };
336
-
337
- const startKeyboardWatch = () => {
338
- if (watchRafId !== null) return;
339
- lastTick = performance.now();
340
-
341
- const tick = () => {
342
- const now = performance.now();
343
- const dtMs = Math.max(1, now - lastTick);
344
- lastTick = now;
345
-
346
- const { height, offsetTop } = readViewport();
347
- vvhTarget = height;
348
- offsetTarget = offsetTop;
349
-
350
- if (vvhCurrent === 0) vvhCurrent = vvhTarget;
351
-
352
- const tauMs = 100;
353
- const alpha = 1 - Math.exp(-dtMs / tauMs);
354
- const deltaH = vvhTarget - vvhCurrent;
355
- const deltaO = offsetTarget - offsetCurrent;
356
-
357
- if (Math.abs(deltaH) > 1) vvhCurrent += deltaH * alpha;
358
- else vvhCurrent = vvhTarget;
359
-
360
- if (Math.abs(deltaO) > 0.5) offsetCurrent += deltaO * alpha;
361
- else offsetCurrent = offsetTarget;
362
-
363
- applyVars();
364
- fitAddon.fit();
365
-
366
- const stillAnimating = Math.abs(vvhTarget - vvhCurrent) > 0.5 || Math.abs(offsetTarget - offsetCurrent) > 0.5;
367
- if (stillAnimating) {
368
- watchRafId = requestAnimationFrame(tick);
369
- } else {
370
- watchRafId = null;
371
- }
372
- };
373
-
374
- watchRafId = requestAnimationFrame(tick);
375
- };
376
-
377
- // Initial setup
378
- const { height, offsetTop } = readViewport();
379
- vvhCurrent = height;
380
- vvhTarget = height;
381
- offsetCurrent = offsetTop;
382
- offsetTarget = offsetTop;
383
- applyVars();
384
-
385
- window.addEventListener('resize', startKeyboardWatch);
386
- window.addEventListener('focusin', startKeyboardWatch);
387
- window.addEventListener('focusout', startKeyboardWatch);
388
- window.visualViewport?.addEventListener('resize', startKeyboardWatch);
389
- }
390
-
391
- // Handle terminal resize
392
- term.onResize((size) => {
393
- if (ws && ws.readyState === WebSocket.OPEN) {
394
- ws.send(JSON.stringify({ type: 'resize', cols: size.cols, rows: size.rows }));
395
- }
396
- });
397
-
398
- // Handle user input
399
- term.onData((data) => {
400
- if (ws && ws.readyState === WebSocket.OPEN) {
401
- ws.send(data);
402
- }
403
- });
404
-
405
- // Handle directory changes
406
- const directoryElement = document.getElementById('current-directory');
407
- const separatorElement = document.getElementById('title-separator');
408
- term.onDirectoryChange((directory) => {
409
- if (directoryElement && separatorElement) {
410
- if (directory) {
411
- directoryElement.textContent = directory;
412
- separatorElement.style.display = 'inline';
413
- } else {
414
- directoryElement.textContent = '';
415
- separatorElement.style.display = 'none';
416
- }
417
- }
418
- });
419
-
420
- connectWebSocket();
421
- }
422
-
423
- function connectWebSocket() {
424
- const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
425
- const wsUrl = protocol + '//' + window.location.host + '/ws?cols=' + term.cols + '&rows=' + term.rows;
426
-
427
- ws = new WebSocket(wsUrl);
428
-
429
- ws.onopen = () => {
430
- updateConnectionStatus(true);
431
- };
432
-
433
- ws.onmessage = (event) => {
434
- term.write(event.data);
435
- };
436
-
437
- ws.onerror = () => {
438
- updateConnectionStatus(false);
439
- };
440
-
441
- ws.onclose = () => {
442
- updateConnectionStatus(false);
443
- setTimeout(() => {
444
- if (!ws || ws.readyState === WebSocket.CLOSED) {
445
- connectWebSocket();
446
- }
447
- }, 3000);
448
- };
449
- }
450
-
451
- function updateConnectionStatus(connected) {
452
- const dot = document.getElementById('connection-dot');
453
- const text = document.getElementById('connection-text');
454
- if (connected) {
455
- dot.classList.add('connected');
456
- text.textContent = 'Connected';
457
- } else {
458
- dot.classList.remove('connected');
459
- text.textContent = 'Disconnected';
460
- }
461
- }
462
-
463
- initTerminal();
464
- </script>
465
- </body>
466
- </html>`;
467
-
468
- // ============================================================================
469
- // MIME Types
470
- // ============================================================================
471
-
472
- const MIME_TYPES = {
473
- '.html': 'text/html',
474
- '.js': 'application/javascript',
475
- '.mjs': 'application/javascript',
476
- '.css': 'text/css',
477
- '.json': 'application/json',
478
- '.wasm': 'application/wasm',
479
- '.png': 'image/png',
480
- '.svg': 'image/svg+xml',
481
- '.ico': 'image/x-icon',
482
- };
483
-
484
- // ============================================================================
485
- // HTTP Server
486
- // ============================================================================
487
-
488
- const httpServer = http.createServer((req, res) => {
489
- const url = new URL(req.url, `http://${req.headers.host}`);
490
- const pathname = url.pathname;
491
-
492
- // Serve index page
493
- if (pathname === '/' || pathname === '/index.html') {
494
- res.writeHead(200, { 'Content-Type': 'text/html' });
495
- res.end(HTML_TEMPLATE);
496
- return;
497
- }
498
-
499
- // Serve dist files
500
- if (pathname.startsWith('/dist/')) {
501
- const filePath = path.join(distPath, pathname.slice(6));
502
- serveFile(filePath, res);
503
- return;
504
- }
505
-
506
- // Serve WASM file
507
- if (pathname === '/ghostty-vt.wasm') {
508
- serveFile(wasmPath, res);
509
- return;
510
- }
511
-
512
- // 404
513
- res.writeHead(404);
514
- res.end('Not Found');
515
- });
516
-
517
- function serveFile(filePath, res) {
518
- const ext = path.extname(filePath);
519
- const contentType = MIME_TYPES[ext] || 'application/octet-stream';
520
-
521
- fs.readFile(filePath, (err, data) => {
522
- if (err) {
523
- res.writeHead(404);
524
- res.end('Not Found');
525
- return;
526
- }
527
- res.writeHead(200, { 'Content-Type': contentType });
528
- res.end(data);
529
- });
530
- }
531
-
532
- // ============================================================================
533
- // WebSocket Server
534
- // ============================================================================
535
-
536
- const sessions = new Map();
537
-
538
- function getShell() {
539
- if (process.platform === 'win32') {
540
- return process.env.COMSPEC || 'cmd.exe';
541
- }
542
- return process.env.SHELL || '/bin/bash';
543
- }
544
-
545
- function createPtySession(cols, rows) {
546
- const shell = getShell();
547
-
548
- const ptyProcess = pty.spawn(shell, [], {
549
- name: 'xterm-256color',
550
- cols: cols,
551
- rows: rows,
552
- cwd: homedir(),
553
- env: {
554
- ...process.env,
555
- TERM: 'xterm-256color',
556
- COLORTERM: 'truecolor',
557
- },
558
- });
559
-
560
- return ptyProcess;
561
- }
562
-
563
- const wss = new WebSocketServer({ noServer: true });
564
-
565
- httpServer.on('upgrade', (req, socket, head) => {
566
- const url = new URL(req.url, `http://${req.headers.host}`);
567
-
568
- if (url.pathname === '/ws') {
569
- wss.handleUpgrade(req, socket, head, (ws) => {
570
- wss.emit('connection', ws, req);
571
- });
572
- } else {
573
- socket.destroy();
574
- }
575
- });
576
-
577
- wss.on('connection', (ws, req) => {
578
- const url = new URL(req.url, `http://${req.headers.host}`);
579
- const cols = Number.parseInt(url.searchParams.get('cols') || '80');
580
- const rows = Number.parseInt(url.searchParams.get('rows') || '24');
581
-
582
- const ptyProcess = createPtySession(cols, rows);
583
- sessions.set(ws, { pty: ptyProcess });
584
-
585
- ptyProcess.onData((data) => {
586
- if (ws.readyState === ws.OPEN) {
587
- ws.send(data);
588
- }
589
- });
590
-
591
- ptyProcess.onExit(({ exitCode }) => {
592
- if (ws.readyState === ws.OPEN) {
593
- ws.send(`\r\n\x1b[33mShell exited (code: ${exitCode})\x1b[0m\r\n`);
594
- ws.close();
595
- }
596
- });
597
-
598
- ws.on('message', (data) => {
599
- const message = data.toString('utf8');
600
-
601
- if (message.startsWith('{')) {
602
- try {
603
- const msg = JSON.parse(message);
604
- if (msg.type === 'resize') {
605
- ptyProcess.resize(msg.cols, msg.rows);
606
- return;
607
- }
608
- } catch (e) {
609
- // Not JSON, treat as input
610
- }
611
- }
612
-
613
- ptyProcess.write(message);
614
- });
615
-
616
- ws.on('close', () => {
617
- const session = sessions.get(ws);
618
- if (session) {
619
- session.pty.kill();
620
- sessions.delete(ws);
621
- }
622
- });
623
-
624
- ws.on('error', () => {
625
- // Ignore socket errors
626
- });
627
- });
628
-
629
- // ============================================================================
630
- // Startup
631
- // ============================================================================
632
-
633
- function getLocalIPs() {
634
- const interfaces = networkInterfaces();
635
- const ips = [];
636
- for (const name of Object.keys(interfaces)) {
637
- for (const iface of interfaces[name] || []) {
638
- if (iface.family === 'IPv4' && !iface.internal) {
639
- ips.push(iface.address);
640
- }
641
- }
642
- }
643
- return ips;
644
- }
645
-
646
- function printBanner(url) {
647
- const localIPs = getLocalIPs();
648
- // ANSI color codes
649
- const RESET = '\x1b[0m';
650
- const CYAN = '\x1b[36m'; // Labels
651
- const BEIGE = '\x1b[38;2;255;220;150m'; // Warm yellow/beige for values (RGB: 255,220,150)
652
- const DIM = '\x1b[2m'; // Dimmed text
653
-
654
- // console.log('');
655
-
656
- // Open: URL
657
- console.log(` ${CYAN}Open:${RESET} ${BEIGE}${url}${RESET}`);
658
-
659
- // Network: URLs
660
- const network = [` ${CYAN}Network: ${RESET}`];
661
- if (localIPs.length > 0) {
662
- let networkCount = 0;
663
- for (const ip of localIPs) {
664
- networkCount++;
665
- const spaces = networkCount !== 1 ? ' ' : '';
666
- network.push(`${spaces}${BEIGE}http://${ip}:${HTTP_PORT}${RESET}\n`);
667
- }
668
- }
669
- console.log(`\n${network.join('')} `);
670
-
671
- // Shell: path
672
- console.log(` ${CYAN}Shell:${RESET} ${BEIGE}${getShell()}${RESET}`);
673
-
674
- console.log('');
675
-
676
- // Home: path
677
- console.log(` ${CYAN}Home:${RESET} ${BEIGE}${homedir()}${RESET}`);
678
-
679
- console.log('');
680
- console.log(` ${DIM}Press Ctrl+C to stop.${RESET}\n`);
681
- }
682
-
683
- process.on('SIGINT', () => {
684
- console.log('\n\nShutting down...');
685
- for (const [ws, session] of sessions.entries()) {
686
- session.pty.kill();
687
- ws.close();
688
- }
689
- wss.close();
690
- process.exit(0);
691
- });
692
-
693
- httpServer.listen(HTTP_PORT, '0.0.0.0', async () => {
694
- // Display ASCII art banner
695
- try {
696
- const imagePath = path.join(__dirname, 'assets', 'ghosts.png');
697
- if (fs.existsSync(imagePath)) {
698
- // Welcome text with orange/yellow color (bright yellow, bold)
699
- console.log('\n \x1b[1;93mWelcome to Ghosttown!\x1b[0m\n');
700
- const art = await asciiArt(imagePath, { maxWidth: 80, maxHeight: 20 });
701
- console.log(art);
702
- console.log('');
703
- }
704
- } catch (err) {
705
- // Silently fail if ASCII art can't be displayed
706
- // This allows the server to start even if the image is missing
707
- }
10
+ import { run } from '../src/cli.js';
708
11
 
709
- printBanner(`http://localhost:${HTTP_PORT}`);
710
- });
12
+ run(process.argv);