@seflless/ghosttown 1.7.0 → 1.10.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/package.json +3 -2
- package/src/cli.js +216 -57
- package/src/session/history-replay.d.ts +118 -0
- package/src/session/history-replay.js +174 -0
- package/src/session/history-replay.js.map +1 -0
- package/src/session/index.d.ts +10 -0
- package/src/session/index.js +11 -0
- package/src/session/index.js.map +1 -0
- package/src/session/output-recorder.d.ts +131 -0
- package/src/session/output-recorder.js +247 -0
- package/src/session/output-recorder.js.map +1 -0
- package/src/session/session-manager.d.ts +147 -0
- package/src/session/session-manager.js +489 -0
- package/src/session/session-manager.js.map +1 -0
- package/src/session/types.d.ts +221 -0
- package/src/session/types.js +8 -0
- package/src/session/types.js.map +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@seflless/ghosttown",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.10.0",
|
|
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
|
"build": "bun run clean && bun run build:wasm && bun run build:lib && bun run build:wasm-copy",
|
|
69
69
|
"build:wasm": "./scripts/build-wasm.sh",
|
|
70
70
|
"build:lib": "vite build",
|
|
71
|
+
"build:cli": "tsc -p tsconfig.cli.json",
|
|
71
72
|
"build:wasm-copy": "cp ghostty-vt.wasm dist/",
|
|
72
73
|
"clean": "rm -rf dist",
|
|
73
74
|
"preview": "vite preview",
|
|
@@ -80,7 +81,7 @@
|
|
|
80
81
|
"test:e2e": "playwright test",
|
|
81
82
|
"test:e2e:headed": "playwright test --headed",
|
|
82
83
|
"test:e2e:ui": "playwright test --ui",
|
|
83
|
-
"prepublishOnly": "bun run build",
|
|
84
|
+
"prepublishOnly": "bun run build && bun run build:cli",
|
|
84
85
|
"cli:publish": "node scripts/cli-publish.js",
|
|
85
86
|
"kill:8080": "kill -9 $(lsof -ti :8080)"
|
|
86
87
|
},
|
package/src/cli.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* ghosttown [options] [command]
|
|
9
9
|
*
|
|
10
10
|
* Options:
|
|
11
|
-
* -p, --port <port> Port to listen on (default:
|
|
11
|
+
* -p, --port <port> Port to listen on (default: auto-discover available port)
|
|
12
12
|
* -n, --name <name> Give the session a custom name
|
|
13
13
|
* -k, --kill [session] Kill a session (current if inside one, or specify by name)
|
|
14
14
|
* -ka, --kill-all Kill all ghosttown sessions
|
|
@@ -53,7 +53,7 @@ import { asciiArt } from '../bin/ascii.js';
|
|
|
53
53
|
// Session name utilities
|
|
54
54
|
import { displayNameExists, findSession, validateSessionName } from './session-utils.js';
|
|
55
55
|
// Session management - custom PTY session manager (replaces tmux)
|
|
56
|
-
import { SessionManager } from './session/session-manager.
|
|
56
|
+
import { SessionManager } from './session/session-manager.js';
|
|
57
57
|
|
|
58
58
|
// Global SessionManager instance (lazy-initialized)
|
|
59
59
|
let sessionManager = null;
|
|
@@ -91,6 +91,64 @@ const SESSION_EXPIRATION_MS = 3 * 24 * 60 * 60 * 1000;
|
|
|
91
91
|
const SESSION_FILE_DIR = path.join(homedir(), '.config', 'ghosttown');
|
|
92
92
|
const SESSION_FILE_PATH = path.join(SESSION_FILE_DIR, 'sessions.json');
|
|
93
93
|
|
|
94
|
+
// Server config file for port discovery (allows CLI to find running server)
|
|
95
|
+
const SERVER_CONFIG_PATH = path.join(SESSION_FILE_DIR, 'server.json');
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Write server config to disk for port discovery
|
|
99
|
+
*/
|
|
100
|
+
function writeServerConfig(port) {
|
|
101
|
+
try {
|
|
102
|
+
if (!fs.existsSync(SESSION_FILE_DIR)) {
|
|
103
|
+
fs.mkdirSync(SESSION_FILE_DIR, { recursive: true });
|
|
104
|
+
}
|
|
105
|
+
fs.writeFileSync(
|
|
106
|
+
SERVER_CONFIG_PATH,
|
|
107
|
+
JSON.stringify({ port, pid: process.pid, startedAt: Date.now() }, null, 2)
|
|
108
|
+
);
|
|
109
|
+
} catch (err) {
|
|
110
|
+
console.error('Warning: Failed to write server config:', err.message);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Remove server config file on shutdown
|
|
116
|
+
*/
|
|
117
|
+
function removeServerConfig() {
|
|
118
|
+
try {
|
|
119
|
+
if (fs.existsSync(SERVER_CONFIG_PATH)) {
|
|
120
|
+
fs.unlinkSync(SERVER_CONFIG_PATH);
|
|
121
|
+
}
|
|
122
|
+
} catch (err) {
|
|
123
|
+
// Ignore errors during cleanup
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Preferred ports to try in order before falling back to auto-discovery (port 0)
|
|
128
|
+
// 64057 - strongest visual mapping to "GHOST"
|
|
129
|
+
// 6057 - subtle and clean
|
|
130
|
+
// 4040 - "ghost server not found" energy (404)
|
|
131
|
+
// 1313 - haunted house number energy
|
|
132
|
+
const PREFERRED_PORTS = [64057, 6057, 4040, 1313];
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Find an available port from the preferred list, or return 0 for auto-discovery
|
|
136
|
+
*/
|
|
137
|
+
function findPreferredPort() {
|
|
138
|
+
for (const port of PREFERRED_PORTS) {
|
|
139
|
+
try {
|
|
140
|
+
// Quick check if port is in use using lsof
|
|
141
|
+
execSync(`lsof -i:${port}`, { stdio: 'pipe' });
|
|
142
|
+
// If lsof succeeds, port is in use, try next
|
|
143
|
+
} catch (e) {
|
|
144
|
+
// lsof returns non-zero if port is free - use this port
|
|
145
|
+
return port;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
// All preferred ports in use, fall back to auto-discovery
|
|
149
|
+
return 0;
|
|
150
|
+
}
|
|
151
|
+
|
|
94
152
|
// In-memory session store: Map<token, { username, createdAt, lastActivity }>
|
|
95
153
|
const authSessions = new Map();
|
|
96
154
|
|
|
@@ -636,22 +694,41 @@ function getServerStatus() {
|
|
|
636
694
|
const isRunning = output.split('\n').includes(SERVER_SESSION_NAME);
|
|
637
695
|
|
|
638
696
|
if (!isRunning) {
|
|
697
|
+
// Also clean up stale server.json if tmux session doesn't exist
|
|
698
|
+
removeServerConfig();
|
|
639
699
|
return { running: false, port: null, useHttp: false };
|
|
640
700
|
}
|
|
641
701
|
|
|
642
|
-
// Get the port
|
|
643
|
-
let port =
|
|
702
|
+
// Get the port - prefer server.json (for auto port discovery), fall back to tmux env
|
|
703
|
+
let port = null;
|
|
644
704
|
try {
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
if (!Number.isNaN(parsedPort)) {
|
|
651
|
-
port = parsedPort;
|
|
705
|
+
if (fs.existsSync(SERVER_CONFIG_PATH)) {
|
|
706
|
+
const config = JSON.parse(fs.readFileSync(SERVER_CONFIG_PATH, 'utf8'));
|
|
707
|
+
if (config.port) {
|
|
708
|
+
port = config.port;
|
|
709
|
+
}
|
|
652
710
|
}
|
|
653
711
|
} catch (e) {
|
|
654
|
-
//
|
|
712
|
+
// Failed to read server.json, try tmux env
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
// Fall back to tmux environment if server.json doesn't have the port
|
|
716
|
+
if (port === null) {
|
|
717
|
+
try {
|
|
718
|
+
const envOutput = execSync(
|
|
719
|
+
`tmux show-environment -t ${SERVER_SESSION_NAME} GHOSTTOWN_PORT`,
|
|
720
|
+
{
|
|
721
|
+
encoding: 'utf8',
|
|
722
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
723
|
+
}
|
|
724
|
+
);
|
|
725
|
+
const parsedPort = Number.parseInt(envOutput.split('=')[1], 10);
|
|
726
|
+
if (!Number.isNaN(parsedPort) && parsedPort > 0) {
|
|
727
|
+
port = parsedPort;
|
|
728
|
+
}
|
|
729
|
+
} catch (e) {
|
|
730
|
+
// No port info available
|
|
731
|
+
}
|
|
655
732
|
}
|
|
656
733
|
|
|
657
734
|
// Get the protocol from tmux environment (default to HTTPS if not set)
|
|
@@ -1201,7 +1278,7 @@ function killAllSessions() {
|
|
|
1201
1278
|
* @param {number} port - Port to listen on
|
|
1202
1279
|
* @param {boolean} useHttp - Use HTTP instead of HTTPS (default: false, meaning HTTPS is used)
|
|
1203
1280
|
*/
|
|
1204
|
-
function startServerInBackground(port =
|
|
1281
|
+
async function startServerInBackground(port = 0, useHttp = false) {
|
|
1205
1282
|
// Check if tmux is installed
|
|
1206
1283
|
if (!checkTmuxInstalled()) {
|
|
1207
1284
|
printTmuxInstallHelp();
|
|
@@ -1219,7 +1296,7 @@ function startServerInBackground(port = 8080, useHttp = false) {
|
|
|
1219
1296
|
console.log('');
|
|
1220
1297
|
console.log(` ${RED}Error:${RESET} A server is already running (port ${status.port}).`);
|
|
1221
1298
|
console.log('');
|
|
1222
|
-
if (status.port !== port) {
|
|
1299
|
+
if (port !== 0 && status.port !== port) {
|
|
1223
1300
|
console.log(` To switch to port ${port}:`);
|
|
1224
1301
|
console.log(` ${BEIGE}gt stop && gt start -p ${port}${RESET}`);
|
|
1225
1302
|
} else {
|
|
@@ -1230,18 +1307,20 @@ function startServerInBackground(port = 8080, useHttp = false) {
|
|
|
1230
1307
|
process.exit(1);
|
|
1231
1308
|
}
|
|
1232
1309
|
|
|
1233
|
-
// Check if port is already in use by another process
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1310
|
+
// Check if specific port is already in use by another process (skip for port 0 auto-discovery)
|
|
1311
|
+
if (port !== 0) {
|
|
1312
|
+
try {
|
|
1313
|
+
execSync(`lsof -i:${port}`, { stdio: 'pipe' });
|
|
1314
|
+
// If lsof succeeds, port is in use
|
|
1315
|
+
console.log('');
|
|
1316
|
+
console.log(` ${RED}Error:${RESET} Port ${port} is already in use.`);
|
|
1317
|
+
console.log('');
|
|
1318
|
+
console.log(` Try a different port: ${BEIGE}gt start -p <port>${RESET}`);
|
|
1319
|
+
console.log('');
|
|
1320
|
+
process.exit(1);
|
|
1321
|
+
} catch (e) {
|
|
1322
|
+
// lsof returns non-zero if port is free - this is what we want
|
|
1323
|
+
}
|
|
1245
1324
|
}
|
|
1246
1325
|
|
|
1247
1326
|
try {
|
|
@@ -1280,6 +1359,27 @@ function startServerInBackground(port = 8080, useHttp = false) {
|
|
|
1280
1359
|
{ stdio: 'pipe' }
|
|
1281
1360
|
);
|
|
1282
1361
|
|
|
1362
|
+
// Wait for server to start and get actual port from server.json
|
|
1363
|
+
// The server writes to server.json once it starts listening
|
|
1364
|
+
let actualPort = port;
|
|
1365
|
+
let attempts = 0;
|
|
1366
|
+
const maxAttempts = 30; // 3 seconds max wait
|
|
1367
|
+
while (attempts < maxAttempts) {
|
|
1368
|
+
try {
|
|
1369
|
+
if (fs.existsSync(SERVER_CONFIG_PATH)) {
|
|
1370
|
+
const config = JSON.parse(fs.readFileSync(SERVER_CONFIG_PATH, 'utf8'));
|
|
1371
|
+
if (config.port) {
|
|
1372
|
+
actualPort = config.port;
|
|
1373
|
+
break;
|
|
1374
|
+
}
|
|
1375
|
+
}
|
|
1376
|
+
} catch (e) {
|
|
1377
|
+
// Ignore read errors, keep waiting
|
|
1378
|
+
}
|
|
1379
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
1380
|
+
attempts++;
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1283
1383
|
// Get local IPs for display
|
|
1284
1384
|
const localIPs = getLocalIPs();
|
|
1285
1385
|
const protocol = useHttp ? 'http' : 'https';
|
|
@@ -1287,9 +1387,11 @@ function startServerInBackground(port = 8080, useHttp = false) {
|
|
|
1287
1387
|
console.log('');
|
|
1288
1388
|
console.log(` ${BOLD_YELLOW}Ghosttown server started!${RESET}`);
|
|
1289
1389
|
console.log('');
|
|
1290
|
-
console.log(` ${CYAN}Open:${RESET} ${BEIGE}${protocol}://localhost:${
|
|
1390
|
+
console.log(` ${CYAN}Open:${RESET} ${BEIGE}${protocol}://localhost:${actualPort}${RESET}`);
|
|
1291
1391
|
if (localIPs.length > 0) {
|
|
1292
|
-
console.log(
|
|
1392
|
+
console.log(
|
|
1393
|
+
` ${CYAN}Network:${RESET} ${BEIGE}${protocol}://${localIPs[0]}:${actualPort}${RESET}`
|
|
1394
|
+
);
|
|
1293
1395
|
}
|
|
1294
1396
|
console.log('');
|
|
1295
1397
|
console.log(` ${CYAN}To stop:${RESET} ${BEIGE}gt stop${RESET}`);
|
|
@@ -1310,38 +1412,53 @@ function startServerInBackground(port = 8080, useHttp = false) {
|
|
|
1310
1412
|
* Stop the background server
|
|
1311
1413
|
*/
|
|
1312
1414
|
function stopServer() {
|
|
1313
|
-
// Check if tmux is installed
|
|
1314
|
-
if (!checkTmuxInstalled()) {
|
|
1315
|
-
printTmuxInstallHelp();
|
|
1316
|
-
}
|
|
1317
|
-
|
|
1318
1415
|
const RESET = '\x1b[0m';
|
|
1319
1416
|
const RED = '\x1b[31m';
|
|
1320
1417
|
const BOLD_YELLOW = '\x1b[1;93m';
|
|
1321
1418
|
const BEIGE = '\x1b[38;2;255;220;150m';
|
|
1322
1419
|
|
|
1323
|
-
|
|
1420
|
+
let stopped = false;
|
|
1324
1421
|
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1422
|
+
// First, try to stop using PID from server.json (works for foreground servers)
|
|
1423
|
+
try {
|
|
1424
|
+
if (fs.existsSync(SERVER_CONFIG_PATH)) {
|
|
1425
|
+
const config = JSON.parse(fs.readFileSync(SERVER_CONFIG_PATH, 'utf8'));
|
|
1426
|
+
if (config.pid) {
|
|
1427
|
+
try {
|
|
1428
|
+
process.kill(config.pid, 'SIGTERM');
|
|
1429
|
+
stopped = true;
|
|
1430
|
+
} catch (e) {
|
|
1431
|
+
// Process doesn't exist or can't be killed
|
|
1432
|
+
}
|
|
1433
|
+
}
|
|
1434
|
+
}
|
|
1435
|
+
} catch (e) {
|
|
1436
|
+
// Ignore errors reading config
|
|
1332
1437
|
}
|
|
1333
1438
|
|
|
1334
|
-
try
|
|
1335
|
-
|
|
1439
|
+
// Also try to kill tmux session (for background servers started with `gt start`)
|
|
1440
|
+
if (checkTmuxInstalled()) {
|
|
1441
|
+
try {
|
|
1442
|
+
execSync(`tmux kill-session -t ${SERVER_SESSION_NAME}`, { stdio: 'pipe' });
|
|
1443
|
+
stopped = true;
|
|
1444
|
+
} catch (err) {
|
|
1445
|
+
// tmux session doesn't exist or can't be killed
|
|
1446
|
+
}
|
|
1447
|
+
}
|
|
1336
1448
|
|
|
1449
|
+
// Clean up server.json
|
|
1450
|
+
removeServerConfig();
|
|
1451
|
+
|
|
1452
|
+
if (stopped) {
|
|
1337
1453
|
console.log('');
|
|
1338
1454
|
console.log(` ${BOLD_YELLOW}Server stopped.${RESET}`);
|
|
1339
1455
|
console.log('');
|
|
1340
|
-
}
|
|
1456
|
+
} else {
|
|
1341
1457
|
console.log('');
|
|
1342
|
-
console.log(` ${
|
|
1458
|
+
console.log(` ${BEIGE}No server is currently running.${RESET}`);
|
|
1459
|
+
console.log('');
|
|
1460
|
+
console.log(' To start: gt start');
|
|
1343
1461
|
console.log('');
|
|
1344
|
-
process.exit(1);
|
|
1345
1462
|
}
|
|
1346
1463
|
|
|
1347
1464
|
process.exit(0);
|
|
@@ -1544,7 +1661,7 @@ function renameSession(renameArgs) {
|
|
|
1544
1661
|
// Parse CLI arguments
|
|
1545
1662
|
// ============================================================================
|
|
1546
1663
|
|
|
1547
|
-
function parseArgs(argv) {
|
|
1664
|
+
async function parseArgs(argv) {
|
|
1548
1665
|
const args = argv.slice(2);
|
|
1549
1666
|
let port = null;
|
|
1550
1667
|
let command = null;
|
|
@@ -1562,7 +1679,7 @@ function parseArgs(argv) {
|
|
|
1562
1679
|
Usage: ghosttown [options] [command]
|
|
1563
1680
|
|
|
1564
1681
|
Options:
|
|
1565
|
-
-p, --port <port> Port to listen on (default:
|
|
1682
|
+
-p, --port <port> Port to listen on (default: auto-discover available port)
|
|
1566
1683
|
-n, --name <name> Give the session a custom name (use with a command)
|
|
1567
1684
|
--http Use HTTP instead of HTTPS (default is HTTPS)
|
|
1568
1685
|
--no-auth Disable authentication (binds to localhost only)
|
|
@@ -1572,7 +1689,7 @@ Options:
|
|
|
1572
1689
|
-h, --help Show this help message
|
|
1573
1690
|
|
|
1574
1691
|
Commands:
|
|
1575
|
-
start [-p <port>] Start the server in the background (
|
|
1692
|
+
start [-p <port>] Start the server in the background (auto-discovers available port)
|
|
1576
1693
|
stop Stop the background server
|
|
1577
1694
|
status Show server status and URLs
|
|
1578
1695
|
list List all ghosttown tmux sessions
|
|
@@ -1680,7 +1797,7 @@ Aliases:
|
|
|
1680
1797
|
// Handle start command
|
|
1681
1798
|
else if (arg === 'start') {
|
|
1682
1799
|
// Parse remaining args after 'start' for -p and --http flags
|
|
1683
|
-
let startPort =
|
|
1800
|
+
let startPort = findPreferredPort(); // Try preferred ports, fall back to auto-discovery
|
|
1684
1801
|
let startUseHttp = false;
|
|
1685
1802
|
const remainingArgs = args.slice(i + 1);
|
|
1686
1803
|
for (let j = 0; j < remainingArgs.length; j++) {
|
|
@@ -1700,7 +1817,7 @@ Aliases:
|
|
|
1700
1817
|
}
|
|
1701
1818
|
}
|
|
1702
1819
|
handled = true;
|
|
1703
|
-
startServerInBackground(startPort, startUseHttp);
|
|
1820
|
+
await startServerInBackground(startPort, startUseHttp);
|
|
1704
1821
|
// startServerInBackground exits, so this won't be reached
|
|
1705
1822
|
}
|
|
1706
1823
|
|
|
@@ -1772,7 +1889,41 @@ Aliases:
|
|
|
1772
1889
|
// ============================================================================
|
|
1773
1890
|
|
|
1774
1891
|
function startWebServer(cliArgs) {
|
|
1775
|
-
|
|
1892
|
+
// ANSI color codes for formatted output
|
|
1893
|
+
const RESET = '\x1b[0m';
|
|
1894
|
+
const RED = '\x1b[31m';
|
|
1895
|
+
const CYAN = '\x1b[36m';
|
|
1896
|
+
const BEIGE = '\x1b[38;2;255;220;150m';
|
|
1897
|
+
|
|
1898
|
+
// Check if a server is already running (prevents multiple instances)
|
|
1899
|
+
try {
|
|
1900
|
+
if (fs.existsSync(SERVER_CONFIG_PATH)) {
|
|
1901
|
+
const config = JSON.parse(fs.readFileSync(SERVER_CONFIG_PATH, 'utf8'));
|
|
1902
|
+
// Check if the PID is still alive
|
|
1903
|
+
if (config.pid) {
|
|
1904
|
+
try {
|
|
1905
|
+
process.kill(config.pid, 0); // Signal 0 = just check if process exists
|
|
1906
|
+
// Process exists - server already running
|
|
1907
|
+
console.log('');
|
|
1908
|
+
console.log(` ${RED}Error:${RESET} A gt server is already running.`);
|
|
1909
|
+
console.log('');
|
|
1910
|
+
console.log(` ${CYAN}To stop:${RESET} ${BEIGE}gt stop${RESET}`);
|
|
1911
|
+
console.log(` ${CYAN}To check:${RESET} ${BEIGE}gt status${RESET}`);
|
|
1912
|
+
console.log('');
|
|
1913
|
+
process.exit(1);
|
|
1914
|
+
} catch (e) {
|
|
1915
|
+
// Process doesn't exist - stale config, clean it up
|
|
1916
|
+
removeServerConfig();
|
|
1917
|
+
}
|
|
1918
|
+
}
|
|
1919
|
+
}
|
|
1920
|
+
} catch (e) {
|
|
1921
|
+
// Ignore errors reading config
|
|
1922
|
+
}
|
|
1923
|
+
|
|
1924
|
+
// Try preferred ports (64057, 6057, 4040) in order, fall back to auto-discovery (port 0)
|
|
1925
|
+
// if all are in use. User can still override with -p flag or PORT env var.
|
|
1926
|
+
const HTTP_PORT = cliArgs.port || process.env.PORT || findPreferredPort();
|
|
1776
1927
|
const USE_HTTPS = !cliArgs.useHttp;
|
|
1777
1928
|
const NO_AUTH = cliArgs.noAuth || false;
|
|
1778
1929
|
const backgroundMode = cliArgs.serverBackgroundMode || false;
|
|
@@ -3842,7 +3993,7 @@ function startWebServer(cliArgs) {
|
|
|
3842
3993
|
// Startup
|
|
3843
3994
|
// ============================================================================
|
|
3844
3995
|
|
|
3845
|
-
function printBanner(url, backgroundMode = false) {
|
|
3996
|
+
function printBanner(url, port, backgroundMode = false) {
|
|
3846
3997
|
const localIPs = getLocalIPs();
|
|
3847
3998
|
// ANSI color codes
|
|
3848
3999
|
const RESET = '\x1b[0m';
|
|
@@ -3863,7 +4014,7 @@ function startWebServer(cliArgs) {
|
|
|
3863
4014
|
networkCount++;
|
|
3864
4015
|
const spaces = networkCount !== 1 ? ' ' : '';
|
|
3865
4016
|
const protocol = USE_HTTPS ? 'https' : 'http';
|
|
3866
|
-
network.push(`${spaces}${BEIGE}${protocol}://${ip}:${
|
|
4017
|
+
network.push(`${spaces}${BEIGE}${protocol}://${ip}:${port}${RESET}\n`);
|
|
3867
4018
|
}
|
|
3868
4019
|
}
|
|
3869
4020
|
console.log(`\n${network.join('')} `);
|
|
@@ -3886,6 +4037,8 @@ function startWebServer(cliArgs) {
|
|
|
3886
4037
|
|
|
3887
4038
|
process.on('SIGINT', () => {
|
|
3888
4039
|
console.log('\n\nShutting down...');
|
|
4040
|
+
// Clean up server config file for port discovery
|
|
4041
|
+
removeServerConfig();
|
|
3889
4042
|
for (const [ws, conn] of wsConnections.entries()) {
|
|
3890
4043
|
conn.pty.kill();
|
|
3891
4044
|
ws.close();
|
|
@@ -3895,6 +4048,12 @@ function startWebServer(cliArgs) {
|
|
|
3895
4048
|
});
|
|
3896
4049
|
|
|
3897
4050
|
httpServer.listen(HTTP_PORT, BIND_ADDRESS, async () => {
|
|
4051
|
+
// Get actual port (important when HTTP_PORT is 0 for auto-discovery)
|
|
4052
|
+
const actualPort = httpServer.address().port;
|
|
4053
|
+
|
|
4054
|
+
// Write server config for port discovery by CLI commands
|
|
4055
|
+
writeServerConfig(actualPort);
|
|
4056
|
+
|
|
3898
4057
|
// Display ASCII art banner
|
|
3899
4058
|
try {
|
|
3900
4059
|
const imagePath = path.join(__dirname, '..', 'bin', 'assets', 'ghosts.png');
|
|
@@ -3911,7 +4070,7 @@ function startWebServer(cliArgs) {
|
|
|
3911
4070
|
}
|
|
3912
4071
|
|
|
3913
4072
|
const protocol = USE_HTTPS ? 'https' : 'http';
|
|
3914
|
-
printBanner(`${protocol}://localhost:${
|
|
4073
|
+
printBanner(`${protocol}://localhost:${actualPort}`, actualPort, backgroundMode);
|
|
3915
4074
|
|
|
3916
4075
|
// Show warning when auth is disabled
|
|
3917
4076
|
if (NO_AUTH) {
|
|
@@ -3924,8 +4083,8 @@ function startWebServer(cliArgs) {
|
|
|
3924
4083
|
// Main entry point
|
|
3925
4084
|
// ============================================================================
|
|
3926
4085
|
|
|
3927
|
-
export function run(argv) {
|
|
3928
|
-
const cliArgs = parseArgs(argv);
|
|
4086
|
+
export async function run(argv) {
|
|
4087
|
+
const cliArgs = await parseArgs(argv);
|
|
3929
4088
|
|
|
3930
4089
|
if (cliArgs.handled) {
|
|
3931
4090
|
return;
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* History Replay
|
|
3
|
+
*
|
|
4
|
+
* Streams scrollback history to clients in chunks.
|
|
5
|
+
* Handles large histories (10,000+ lines) without blocking.
|
|
6
|
+
*
|
|
7
|
+
* Replay is throttled to prevent overwhelming the client with data
|
|
8
|
+
* and to show a progress indicator for long replays.
|
|
9
|
+
*/
|
|
10
|
+
import { EventEmitter } from 'events';
|
|
11
|
+
import type { OutputChunk } from './output-recorder.js';
|
|
12
|
+
/**
|
|
13
|
+
* Configuration for HistoryReplay.
|
|
14
|
+
*/
|
|
15
|
+
export interface HistoryReplayConfig {
|
|
16
|
+
/** Chunks to send per batch (default: 100) */
|
|
17
|
+
chunkSize?: number;
|
|
18
|
+
/** Delay between batches in milliseconds (default: 10) */
|
|
19
|
+
batchDelay?: number;
|
|
20
|
+
/** Maximum replay time in milliseconds (default: 30000 = 30s) */
|
|
21
|
+
maxReplayTime?: number;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Progress information during replay.
|
|
25
|
+
*/
|
|
26
|
+
export interface ReplayProgress {
|
|
27
|
+
/** Number of chunks sent */
|
|
28
|
+
sent: number;
|
|
29
|
+
/** Total number of chunks */
|
|
30
|
+
total: number;
|
|
31
|
+
/** Percentage complete (0-100) */
|
|
32
|
+
percent: number;
|
|
33
|
+
/** Whether replay is complete */
|
|
34
|
+
complete: boolean;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Events emitted by HistoryReplay.
|
|
38
|
+
*/
|
|
39
|
+
export interface HistoryReplayEvents {
|
|
40
|
+
/** Emitted for each batch of chunks */
|
|
41
|
+
data: (data: string) => void;
|
|
42
|
+
/** Emitted with progress updates */
|
|
43
|
+
progress: (progress: ReplayProgress) => void;
|
|
44
|
+
/** Emitted when replay completes */
|
|
45
|
+
complete: () => void;
|
|
46
|
+
/** Emitted on error */
|
|
47
|
+
error: (error: Error) => void;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Replays scrollback history to a client.
|
|
51
|
+
*
|
|
52
|
+
* @example
|
|
53
|
+
* ```typescript
|
|
54
|
+
* const replay = new HistoryReplay({
|
|
55
|
+
* chunkSize: 100,
|
|
56
|
+
* batchDelay: 10
|
|
57
|
+
* });
|
|
58
|
+
*
|
|
59
|
+
* replay.on('data', (data) => {
|
|
60
|
+
* // Send data to client via WebSocket
|
|
61
|
+
* ws.send(JSON.stringify({ type: 'scrollback', data }));
|
|
62
|
+
* });
|
|
63
|
+
*
|
|
64
|
+
* replay.on('progress', (progress) => {
|
|
65
|
+
* // Update progress indicator
|
|
66
|
+
* console.log(`Replay: ${progress.percent}% complete`);
|
|
67
|
+
* });
|
|
68
|
+
*
|
|
69
|
+
* replay.on('complete', () => {
|
|
70
|
+
* console.log('Replay finished');
|
|
71
|
+
* });
|
|
72
|
+
*
|
|
73
|
+
* // Start replay
|
|
74
|
+
* await replay.start(chunks);
|
|
75
|
+
* ```
|
|
76
|
+
*/
|
|
77
|
+
export declare class HistoryReplay extends EventEmitter {
|
|
78
|
+
private config;
|
|
79
|
+
private aborted;
|
|
80
|
+
private startTime;
|
|
81
|
+
constructor(config?: HistoryReplayConfig);
|
|
82
|
+
/**
|
|
83
|
+
* Start replaying chunks.
|
|
84
|
+
* Emits 'data' events with concatenated output for each batch.
|
|
85
|
+
* Emits 'progress' events periodically.
|
|
86
|
+
* Emits 'complete' when done.
|
|
87
|
+
*
|
|
88
|
+
* @param chunks Array of output chunks to replay
|
|
89
|
+
* @returns Promise that resolves when replay completes or is aborted
|
|
90
|
+
*/
|
|
91
|
+
start(chunks: OutputChunk[]): Promise<void>;
|
|
92
|
+
/**
|
|
93
|
+
* Start replay from an async generator.
|
|
94
|
+
* Useful for streaming from disk without loading all chunks into memory.
|
|
95
|
+
*/
|
|
96
|
+
startFromGenerator(generator: AsyncGenerator<OutputChunk[], void, unknown>, total: number): Promise<void>;
|
|
97
|
+
/**
|
|
98
|
+
* Abort the replay.
|
|
99
|
+
*/
|
|
100
|
+
abort(): void;
|
|
101
|
+
/**
|
|
102
|
+
* Check if replay was aborted.
|
|
103
|
+
*/
|
|
104
|
+
isAborted(): boolean;
|
|
105
|
+
/**
|
|
106
|
+
* Emit progress event.
|
|
107
|
+
*/
|
|
108
|
+
private emitProgress;
|
|
109
|
+
/**
|
|
110
|
+
* Delay for a specified time.
|
|
111
|
+
*/
|
|
112
|
+
private delay;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Create an async generator that reads chunks in batches.
|
|
116
|
+
* Useful for memory-efficient replay of large scrollback files.
|
|
117
|
+
*/
|
|
118
|
+
export declare function createChunkGenerator(chunks: OutputChunk[], batchSize: number): AsyncGenerator<OutputChunk[], void, unknown>;
|