@seflless/ghosttown 1.0.3 → 1.1.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.
Files changed (2) hide show
  1. package/bin/ghosttown.js +333 -137
  2. package/package.json +2 -1
package/bin/ghosttown.js CHANGED
@@ -4,7 +4,19 @@
4
4
  * ghosttown CLI - Web-based terminal emulator
5
5
  *
6
6
  * Starts a local HTTP server with WebSocket PTY support.
7
- * Run with: npx @seflless/ghosttown
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
8
20
  */
9
21
 
10
22
  import fs from 'fs';
@@ -21,7 +33,54 @@ import { WebSocketServer } from 'ws';
21
33
  const __filename = fileURLToPath(import.meta.url);
22
34
  const __dirname = path.dirname(__filename);
23
35
 
24
- const HTTP_PORT = process.env.PORT || 8080;
36
+ // ============================================================================
37
+ // Parse CLI arguments
38
+ // ============================================================================
39
+
40
+ function parseArgs() {
41
+ const args = process.argv.slice(2);
42
+ let port = null;
43
+
44
+ for (let i = 0; i < args.length; i++) {
45
+ const arg = args[i];
46
+
47
+ if (arg === '-h' || arg === '--help') {
48
+ console.log(`
49
+ Usage: ghosttown [options]
50
+
51
+ Options:
52
+ -p, --port <port> Port to listen on (default: 8080, or PORT env var)
53
+ -h, --help Show this help message
54
+
55
+ Examples:
56
+ ghosttown
57
+ ghosttown -p 3000
58
+ ghosttown --port 3000
59
+ PORT=3000 ghosttown
60
+ `);
61
+ process.exit(0);
62
+ }
63
+
64
+ if (arg === '-p' || arg === '--port') {
65
+ const nextArg = args[i + 1];
66
+ if (!nextArg || nextArg.startsWith('-')) {
67
+ console.error(`Error: ${arg} requires a port number`);
68
+ process.exit(1);
69
+ }
70
+ port = parseInt(nextArg, 10);
71
+ if (isNaN(port) || port < 1 || port > 65535) {
72
+ console.error(`Error: Invalid port number: ${nextArg}`);
73
+ process.exit(1);
74
+ }
75
+ i++; // Skip the next argument since we consumed it
76
+ }
77
+ }
78
+
79
+ return { port };
80
+ }
81
+
82
+ const cliArgs = parseArgs();
83
+ const HTTP_PORT = cliArgs.port || process.env.PORT || 8080;
25
84
 
26
85
  // ============================================================================
27
86
  // Locate ghosttown assets
@@ -58,41 +117,64 @@ const HTML_TEMPLATE = `<!doctype html>
58
117
  <html lang="en">
59
118
  <head>
60
119
  <meta charset="UTF-8" />
61
- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
120
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
62
121
  <title>ghosttown</title>
63
122
  <style>
123
+ :root {
124
+ --vvh: 100vh;
125
+ --vv-offset-top: 0px;
126
+ }
127
+
64
128
  * {
65
129
  margin: 0;
66
130
  padding: 0;
67
131
  box-sizing: border-box;
68
132
  }
69
133
 
134
+ html, body {
135
+ margin: 0;
136
+ padding: 0;
137
+ height: var(--vvh);
138
+ overflow: hidden;
139
+ overscroll-behavior: none;
140
+ touch-action: none;
141
+ transition: none;
142
+ }
143
+
70
144
  body {
71
145
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
72
- background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%);
73
- min-height: 100vh;
74
- display: flex;
75
- align-items: center;
76
- justify-content: center;
77
- padding: 40px 20px;
146
+ background: #292c34;
147
+ padding: 8px 8px 14px 8px;
148
+ box-sizing: border-box;
149
+ position: fixed;
150
+ inset: 0;
151
+ height: var(--vvh);
78
152
  }
79
153
 
80
154
  .terminal-window {
81
155
  width: 100%;
82
- max-width: 1000px;
83
- background: #1e1e1e;
84
- border-radius: 12px;
85
- box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
156
+ height: 100%;
157
+ background: #ededed;
158
+ display: flex;
159
+ flex-direction: column;
86
160
  overflow: hidden;
161
+ box-shadow:
162
+ 0 0 0 0.5px #65686e,
163
+ 0 0 0 1px #74777c,
164
+ 0 0 0 1.5px #020203,
165
+ 0px 4px 10px 0px rgba(0, 0, 0, 0.5);
166
+ border-radius: 8px;
167
+ box-sizing: border-box;
168
+ transform: translateY(calc(var(--vv-offset-top) * -1));
169
+ transition: none;
87
170
  }
88
171
 
89
172
  .title-bar {
90
- background: #2d2d2d;
91
- padding: 12px 16px;
173
+ background: #292c34;
174
+ padding: 8px 16px 6px 10px;
92
175
  display: flex;
93
176
  align-items: center;
94
177
  gap: 12px;
95
- border-bottom: 1px solid #1a1a1a;
96
178
  }
97
179
 
98
180
  .traffic-lights {
@@ -115,6 +197,20 @@ const HTML_TEMPLATE = `<!doctype html>
115
197
  font-size: 13px;
116
198
  font-weight: 500;
117
199
  letter-spacing: 0.3px;
200
+ display: flex;
201
+ align-items: center;
202
+ gap: 8px;
203
+ }
204
+
205
+ .title-separator { color: #666; }
206
+ .current-directory {
207
+ color: #888;
208
+ font-size: 12px;
209
+ font-weight: 400;
210
+ max-width: 400px;
211
+ overflow: hidden;
212
+ text-overflow: ellipsis;
213
+ white-space: nowrap;
118
214
  }
119
215
 
120
216
  .connection-status {
@@ -126,38 +222,28 @@ const HTML_TEMPLATE = `<!doctype html>
126
222
  gap: 6px;
127
223
  }
128
224
 
129
- .status-dot {
130
- width: 8px;
131
- height: 8px;
225
+ .connection-dot {
226
+ width: 6px;
227
+ height: 6px;
132
228
  border-radius: 50%;
133
- background: #888;
229
+ background: #666;
134
230
  }
135
231
 
136
- .status-dot.connected { background: #27c93f; }
137
- .status-dot.disconnected { background: #ff5f56; }
138
- .status-dot.connecting { background: #ffbd2e; animation: pulse 1s infinite; }
139
-
140
- @keyframes pulse {
141
- 0%, 100% { opacity: 1; }
142
- 50% { opacity: 0.5; }
143
- }
232
+ .connection-dot.connected { background: #27c93f; }
144
233
 
145
- .terminal-content {
146
- height: 600px;
147
- padding: 16px;
148
- background: #1e1e1e;
234
+ #terminal-container {
235
+ flex: 1;
236
+ padding: 2px 0 2px 2px;
237
+ background: #292c34;
149
238
  position: relative;
150
239
  overflow: hidden;
240
+ min-height: 0;
241
+ touch-action: none;
151
242
  }
152
243
 
153
- .terminal-content canvas {
244
+ #terminal-container canvas {
154
245
  display: block;
155
- }
156
-
157
- @media (max-width: 768px) {
158
- .terminal-content {
159
- height: 500px;
160
- }
246
+ touch-action: none;
161
247
  }
162
248
  </style>
163
249
  </head>
@@ -165,120 +251,214 @@ const HTML_TEMPLATE = `<!doctype html>
165
251
  <div class="terminal-window">
166
252
  <div class="title-bar">
167
253
  <div class="traffic-lights">
168
- <div class="light red"></div>
169
- <div class="light yellow"></div>
170
- <div class="light green"></div>
254
+ <span class="light red"></span>
255
+ <span class="light yellow"></span>
256
+ <span class="light green"></span>
257
+ </div>
258
+ <div class="title">
259
+ <span>ghosttown</span>
260
+ <span class="title-separator" id="title-separator" style="display: none">•</span>
261
+ <span class="current-directory" id="current-directory"></span>
171
262
  </div>
172
- <span class="title">ghosttown</span>
173
263
  <div class="connection-status">
174
- <div class="status-dot connecting" id="status-dot"></div>
175
- <span id="status-text">Connecting...</span>
264
+ <span class="connection-dot" id="connection-dot"></span>
265
+ <span id="connection-text">Disconnected</span>
176
266
  </div>
177
267
  </div>
178
- <div class="terminal-content" id="terminal"></div>
268
+ <div id="terminal-container"></div>
179
269
  </div>
180
270
 
181
271
  <script type="module">
182
272
  import { init, Terminal, FitAddon } from '/dist/ghostty-web.js';
183
273
 
184
- await init();
185
- const term = new Terminal({
186
- cols: 80,
187
- rows: 24,
188
- fontFamily: 'JetBrains Mono, Menlo, Monaco, monospace',
189
- fontSize: 14,
190
- theme: {
191
- background: '#1e1e1e',
192
- foreground: '#d4d4d4',
193
- },
194
- });
195
-
196
- const fitAddon = new FitAddon();
197
- term.loadAddon(fitAddon);
198
-
199
- const container = document.getElementById('terminal');
200
- await term.open(container);
201
- fitAddon.fit();
202
- fitAddon.observeResize();
203
-
204
- const statusDot = document.getElementById('status-dot');
205
- const statusText = document.getElementById('status-text');
206
-
207
- function setStatus(status, text) {
208
- statusDot.className = 'status-dot ' + status;
209
- statusText.textContent = text;
274
+ let term;
275
+ let ws;
276
+ let fitAddon;
277
+
278
+ async function initTerminal() {
279
+ await init();
280
+
281
+ term = new Terminal({
282
+ cursorBlink: true,
283
+ fontSize: 12,
284
+ fontFamily: 'Monaco, Menlo, "Courier New", monospace',
285
+ theme: {
286
+ background: '#292c34',
287
+ foreground: '#d4d4d4',
288
+ },
289
+ smoothScrollDuration: 0,
290
+ scrollback: 10000,
291
+ scrollbarVisible: false,
292
+ });
293
+
294
+ fitAddon = new FitAddon();
295
+ term.loadAddon(fitAddon);
296
+
297
+ term.open(document.getElementById('terminal-container'));
298
+ fitAddon.fit();
299
+ fitAddon.observeResize();
300
+
301
+ // Desktop: auto-focus. Mobile: focus only on tap.
302
+ const isCoarsePointer = window.matchMedia && window.matchMedia('(pointer: coarse)').matches;
303
+ if (!isCoarsePointer) {
304
+ term.focus();
305
+ }
306
+
307
+ // Prevent page scroll on iOS
308
+ document.addEventListener('touchmove', (e) => {
309
+ const container = document.getElementById('terminal-container');
310
+ if (container && !container.contains(e.target)) {
311
+ e.preventDefault();
312
+ }
313
+ }, { passive: false });
314
+
315
+ // Mobile keyboard handling via visualViewport
316
+ {
317
+ const root = document.documentElement;
318
+ let watchRafId = null;
319
+ let vvhCurrent = 0;
320
+ let vvhTarget = 0;
321
+ let offsetCurrent = 0;
322
+ let offsetTarget = 0;
323
+ let lastTick = 0;
324
+
325
+ const readViewport = () => ({
326
+ height: window.visualViewport?.height ?? window.innerHeight,
327
+ offsetTop: window.visualViewport?.offsetTop ?? 0,
328
+ });
329
+
330
+ const applyVars = () => {
331
+ root.style.setProperty('--vvh', vvhCurrent + 'px');
332
+ root.style.setProperty('--vv-offset-top', offsetCurrent + 'px');
333
+ };
334
+
335
+ const startKeyboardWatch = () => {
336
+ if (watchRafId !== null) return;
337
+ lastTick = performance.now();
338
+
339
+ const tick = () => {
340
+ const now = performance.now();
341
+ const dtMs = Math.max(1, now - lastTick);
342
+ lastTick = now;
343
+
344
+ const { height, offsetTop } = readViewport();
345
+ vvhTarget = height;
346
+ offsetTarget = offsetTop;
347
+
348
+ if (vvhCurrent === 0) vvhCurrent = vvhTarget;
349
+
350
+ const tauMs = 100;
351
+ const alpha = 1 - Math.exp(-dtMs / tauMs);
352
+ const deltaH = vvhTarget - vvhCurrent;
353
+ const deltaO = offsetTarget - offsetCurrent;
354
+
355
+ if (Math.abs(deltaH) > 1) vvhCurrent += deltaH * alpha;
356
+ else vvhCurrent = vvhTarget;
357
+
358
+ if (Math.abs(deltaO) > 0.5) offsetCurrent += deltaO * alpha;
359
+ else offsetCurrent = offsetTarget;
360
+
361
+ applyVars();
362
+ fitAddon.fit();
363
+
364
+ const stillAnimating = Math.abs(vvhTarget - vvhCurrent) > 0.5 || Math.abs(offsetTarget - offsetCurrent) > 0.5;
365
+ if (stillAnimating) {
366
+ watchRafId = requestAnimationFrame(tick);
367
+ } else {
368
+ watchRafId = null;
369
+ }
370
+ };
371
+
372
+ watchRafId = requestAnimationFrame(tick);
373
+ };
374
+
375
+ // Initial setup
376
+ const { height, offsetTop } = readViewport();
377
+ vvhCurrent = height;
378
+ vvhTarget = height;
379
+ offsetCurrent = offsetTop;
380
+ offsetTarget = offsetTop;
381
+ applyVars();
382
+
383
+ window.addEventListener('resize', startKeyboardWatch);
384
+ window.addEventListener('focusin', startKeyboardWatch);
385
+ window.addEventListener('focusout', startKeyboardWatch);
386
+ window.visualViewport?.addEventListener('resize', startKeyboardWatch);
387
+ }
388
+
389
+ // Handle terminal resize
390
+ term.onResize((size) => {
391
+ if (ws && ws.readyState === WebSocket.OPEN) {
392
+ ws.send(JSON.stringify({ type: 'resize', cols: size.cols, rows: size.rows }));
393
+ }
394
+ });
395
+
396
+ // Handle user input
397
+ term.onData((data) => {
398
+ if (ws && ws.readyState === WebSocket.OPEN) {
399
+ ws.send(data);
400
+ }
401
+ });
402
+
403
+ // Handle directory changes
404
+ const directoryElement = document.getElementById('current-directory');
405
+ const separatorElement = document.getElementById('title-separator');
406
+ term.onDirectoryChange((directory) => {
407
+ if (directoryElement && separatorElement) {
408
+ if (directory) {
409
+ directoryElement.textContent = directory;
410
+ separatorElement.style.display = 'inline';
411
+ } else {
412
+ directoryElement.textContent = '';
413
+ separatorElement.style.display = 'none';
414
+ }
415
+ }
416
+ });
417
+
418
+ connectWebSocket();
210
419
  }
211
420
 
212
- const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
213
- const wsUrl = protocol + '//' + window.location.host + '/ws?cols=' + term.cols + '&rows=' + term.rows;
214
- let ws;
421
+ function connectWebSocket() {
422
+ const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
423
+ const wsUrl = protocol + '//' + window.location.host + '/ws?cols=' + term.cols + '&rows=' + term.rows;
215
424
 
216
- function connect() {
217
- setStatus('connecting', 'Connecting...');
218
425
  ws = new WebSocket(wsUrl);
219
426
 
220
427
  ws.onopen = () => {
221
- setStatus('connected', 'Connected');
428
+ updateConnectionStatus(true);
222
429
  };
223
430
 
224
431
  ws.onmessage = (event) => {
225
432
  term.write(event.data);
226
433
  };
227
434
 
228
- ws.onclose = () => {
229
- setStatus('disconnected', 'Disconnected');
230
- term.write('\\r\\n\\x1b[31mConnection closed. Reconnecting in 2s...\\x1b[0m\\r\\n');
231
- setTimeout(connect, 2000);
435
+ ws.onerror = () => {
436
+ updateConnectionStatus(false);
232
437
  };
233
438
 
234
- ws.onerror = () => {
235
- setStatus('disconnected', 'Error');
439
+ ws.onclose = () => {
440
+ updateConnectionStatus(false);
441
+ setTimeout(() => {
442
+ if (!ws || ws.readyState === WebSocket.CLOSED) {
443
+ connectWebSocket();
444
+ }
445
+ }, 3000);
236
446
  };
237
447
  }
238
448
 
239
- connect();
240
-
241
- term.onData((data) => {
242
- if (ws && ws.readyState === WebSocket.OPEN) {
243
- ws.send(data);
449
+ function updateConnectionStatus(connected) {
450
+ const dot = document.getElementById('connection-dot');
451
+ const text = document.getElementById('connection-text');
452
+ if (connected) {
453
+ dot.classList.add('connected');
454
+ text.textContent = 'Connected';
455
+ } else {
456
+ dot.classList.remove('connected');
457
+ text.textContent = 'Disconnected';
244
458
  }
245
- });
246
-
247
- term.onResize(({ cols, rows }) => {
248
- if (ws && ws.readyState === WebSocket.OPEN) {
249
- ws.send(JSON.stringify({ type: 'resize', cols, rows }));
250
- }
251
- });
252
-
253
- window.addEventListener('resize', () => {
254
- fitAddon.fit();
255
- });
256
-
257
- if (window.visualViewport) {
258
- const terminalContent = document.querySelector('.terminal-content');
259
- const terminalWindow = document.querySelector('.terminal-window');
260
- const originalHeight = terminalContent.style.height;
261
- const body = document.body;
262
-
263
- window.visualViewport.addEventListener('resize', () => {
264
- const keyboardHeight = window.innerHeight - window.visualViewport.height;
265
- if (keyboardHeight > 100) {
266
- body.style.padding = '0';
267
- body.style.alignItems = 'flex-start';
268
- terminalWindow.style.borderRadius = '0';
269
- terminalWindow.style.maxWidth = '100%';
270
- terminalContent.style.height = (window.visualViewport.height - 60) + 'px';
271
- window.scrollTo(0, 0);
272
- } else {
273
- body.style.padding = '40px 20px';
274
- body.style.alignItems = 'center';
275
- terminalWindow.style.borderRadius = '12px';
276
- terminalWindow.style.maxWidth = '1000px';
277
- terminalContent.style.height = originalHeight || '600px';
278
- }
279
- fitAddon.fit();
280
- });
281
459
  }
460
+
461
+ initTerminal();
282
462
  </script>
283
463
  </body>
284
464
  </html>`;
@@ -463,22 +643,38 @@ function getLocalIPs() {
463
643
 
464
644
  function printBanner(url) {
465
645
  const localIPs = getLocalIPs();
466
- console.log('\n' + '═'.repeat(50));
467
- console.log(' 👻 ghosttown');
468
- console.log(''.repeat(50));
469
- console.log(`\n Open: ${url}`);
646
+ // console.log('\n' + '═'.repeat(50));
647
+ console.log('');
648
+ // console.log(' 👻 ghosttown');
649
+ // console.log('═'.repeat(50));
650
+ const labels = [
651
+ [' Open:', url],
652
+ [' Network:', ''],
653
+ [' Shell:', getShell()],
654
+ [' Home:', homedir()],
655
+ ];
656
+
657
+ console.log(labels[0].join(' '));
658
+
659
+ const network = [labels[1].join(' ')];
660
+
470
661
  if (localIPs.length > 0) {
471
- console.log(` Network:`);
662
+ // console.log(``);
663
+ let networkCount = 0;
664
+ localIPs.push(localIPs[0]);
472
665
  for (const ip of localIPs) {
473
- console.log(` http://${ip}:${HTTP_PORT}`);
666
+ networkCount++;
667
+ const spaces = networkCount !== 1 ? ' ' : '';
668
+ network.push(`${spaces}http://${ip}:${HTTP_PORT}\n`);
474
669
  }
475
670
  }
476
- console.log(`\n Shell: ${getShell()}`);
477
- console.log(` Home: ${homedir()}`);
478
- console.log('\n Warning: This server provides shell access.');
479
- console.log(' Only use for local development.\n');
480
- console.log(''.repeat(50));
481
- console.log(' Press Ctrl+C to stop.\n');
671
+ console.log(`\n${network.join('')}`);
672
+ // Shell
673
+ console.log(labels[2].join(' '));
674
+ // Home
675
+ console.log(labels[3].join(' '));
676
+ console.log('');
677
+ console.log(' Press Ctrl+C to stop.\n');
482
678
  }
483
679
 
484
680
  process.on('SIGINT', () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@seflless/ghosttown",
3
- "version": "1.0.3",
3
+ "version": "1.1.1",
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",
@@ -68,6 +68,7 @@
68
68
  },
69
69
  "dependencies": {
70
70
  "@lydell/node-pty": "^1.0.1",
71
+ "image-to-ascii": "^3.3.0",
71
72
  "ws": "^8.18.0"
72
73
  },
73
74
  "devDependencies": {