hop2 0.8.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.
Files changed (50) hide show
  1. package/.hop-tmux.conf +18 -0
  2. package/INTEGRATION.md +92 -0
  3. package/README.md +260 -0
  4. package/hay/CHANGELOG.md +46 -0
  5. package/hay/DEV_NOTES.md +39 -0
  6. package/hay/README.md +290 -0
  7. package/hay/apps/cli/package.json +25 -0
  8. package/hay/apps/cli/src/index.ts +1250 -0
  9. package/hay/apps/cli/tsconfig.json +8 -0
  10. package/hay/apps/server/package.json +37 -0
  11. package/hay/apps/server/src/index.ts +103 -0
  12. package/hay/apps/server/src/lib.ts +161 -0
  13. package/hay/apps/server/src/pty.ts +68 -0
  14. package/hay/apps/server/src/rooms.ts +421 -0
  15. package/hay/apps/server/src/utils.ts +20 -0
  16. package/hay/apps/server/test/rooms.test.ts +146 -0
  17. package/hay/apps/server/tsconfig.json +9 -0
  18. package/hay/apps/web/index.html +12 -0
  19. package/hay/apps/web/package.json +32 -0
  20. package/hay/apps/web/scripts/run-e2e.mjs +130 -0
  21. package/hay/apps/web/src/App.tsx +1302 -0
  22. package/hay/apps/web/src/components/MobileKeyboard.tsx +701 -0
  23. package/hay/apps/web/src/main.tsx +13 -0
  24. package/hay/apps/web/src/setupTests.ts +1 -0
  25. package/hay/apps/web/src/styles.css +1198 -0
  26. package/hay/apps/web/src/utils/optimisticEcho.ts +115 -0
  27. package/hay/apps/web/src/utils/presence.ts +24 -0
  28. package/hay/apps/web/src/vite-env.d.ts +10 -0
  29. package/hay/apps/web/test/optimisticEcho.test.ts +39 -0
  30. package/hay/apps/web/test/presence.test.ts +18 -0
  31. package/hay/apps/web/tsconfig.json +8 -0
  32. package/hay/apps/web/vite.config.ts +13 -0
  33. package/hay/apps/web/vitest.config.ts +12 -0
  34. package/hay/package-lock.json +5988 -0
  35. package/hay/package.json +18 -0
  36. package/hay/packages/shared/package.json +27 -0
  37. package/hay/packages/shared/src/colors.ts +16 -0
  38. package/hay/packages/shared/src/index.ts +2 -0
  39. package/hay/packages/shared/src/protocol.ts +90 -0
  40. package/hay/packages/shared/test/protocol.test.ts +33 -0
  41. package/hay/packages/shared/tsconfig.json +7 -0
  42. package/hay/tsconfig.base.json +15 -0
  43. package/hay-web/assets/index-CTTzApRp.css +32 -0
  44. package/hay-web/assets/index-FDfdolCs.js +60 -0
  45. package/hay-web/assets/index-FDfdolCs.js.map +1 -0
  46. package/hay-web/index.html +13 -0
  47. package/hop +3717 -0
  48. package/package.json +52 -0
  49. package/sessions.html +243 -0
  50. package/tunnel-shell.js +254 -0
package/hop ADDED
@@ -0,0 +1,3717 @@
1
+ #!/usr/bin/env node
2
+
3
+ const { spawn, execSync, execFileSync } = require('child_process');
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const crypto = require('crypto');
7
+ const http = require('http');
8
+ const httpProxy = require('http-proxy');
9
+ const { authenticator } = require('otplib');
10
+ const qrcode = require('qrcode-terminal');
11
+ const cookie = require('cookie');
12
+ const EventEmitter = require('events');
13
+ const url = require('url');
14
+ const { pathToFileURL } = require('url');
15
+ const net = require('net');
16
+ const WebSocket = require('ws');
17
+
18
+ const os = require('os');
19
+ const DAEMON_FLAG = process.argv.includes('--daemon');
20
+
21
+ // Hay integration for terminal sharing (built from ./hay)
22
+ const HAY_ROOT = path.join(__dirname, 'hay');
23
+ let didBuildHay = false;
24
+ let hay = null;
25
+ let hayRooms = null;
26
+
27
+ function ensureHayBuilt() {
28
+ if (didBuildHay) return;
29
+ if (!fs.existsSync(HAY_ROOT)) return;
30
+ const webDistDir = path.join(HAY_ROOT, 'apps/web/dist');
31
+ const serverDistFile = path.join(HAY_ROOT, 'apps/server/dist/lib.js');
32
+ const cliDistFile = path.join(HAY_ROOT, 'apps/cli/dist/index.js');
33
+ const sharedDep = path.join(HAY_ROOT, 'node_modules/hay-shared/package.json');
34
+ const needsInstall = !fs.existsSync(sharedDep);
35
+ const needsBuild = !fs.existsSync(webDistDir) || !fs.existsSync(cliDistFile) || !fs.existsSync(serverDistFile);
36
+ if (!needsInstall && !needsBuild) return;
37
+ try {
38
+ console.log(`[Dev] Building Hay from: ${HAY_ROOT}`);
39
+ if (needsInstall) {
40
+ execFileSync('npm', ['--prefix', HAY_ROOT, 'install'], { stdio: 'inherit' });
41
+ }
42
+ if (needsBuild) {
43
+ execFileSync('npm', ['--prefix', HAY_ROOT, 'run', 'build'], { stdio: 'inherit' });
44
+ }
45
+ didBuildHay = true;
46
+ } catch (e) {
47
+ console.error(`[Dev] Failed to build Hay from: ${HAY_ROOT}`);
48
+ }
49
+ }
50
+
51
+ async function ensureHayLoaded() {
52
+ if (hay && hayRooms) return;
53
+ ensureHayBuilt();
54
+ const libPath = path.join(HAY_ROOT, 'apps/server/dist/lib.js');
55
+ if (!fs.existsSync(libPath)) {
56
+ throw new Error('Hay server build missing. Run `npm --prefix hay run build`.');
57
+ }
58
+ hay = await import(pathToFileURL(libPath));
59
+ hayRooms = new hay.RoomManager(hay.createPty, process.cwd());
60
+ }
61
+
62
+ function resolveHayWebDir() {
63
+ // Environment variable override for development
64
+ if (process.env.HAY_DEV_DIR && fs.existsSync(process.env.HAY_DEV_DIR)) {
65
+ return process.env.HAY_DEV_DIR;
66
+ }
67
+ // If local hay source exists, ensure it is built
68
+ ensureHayBuilt();
69
+ // Check for hay dev build nearby
70
+ const devPaths = [
71
+ path.join(__dirname, 'hay/apps/web/dist'),
72
+ path.join(__dirname, '../hay/apps/web/dist'),
73
+ path.join(__dirname, '../../hay/apps/web/dist'),
74
+ path.resolve(__dirname, '../hay/apps/web/dist'),
75
+ ];
76
+ for (const p of devPaths) {
77
+ if (fs.existsSync(p)) {
78
+ console.log(`[Dev] Serving Hay from: ${p}`);
79
+ return p;
80
+ }
81
+ }
82
+ // Fallback to bundled
83
+ return path.join(__dirname, 'hay-web');
84
+ }
85
+ const HAY_WEB_DIR = resolveHayWebDir();
86
+
87
+ // Resolve hay CLI path for local terminal
88
+ function resolveHayCliPath() {
89
+ // Check for hay CLI nearby
90
+ const cliPaths = [
91
+ path.join(__dirname, 'hay/apps/cli/dist/index.js'),
92
+ path.join(__dirname, '../hay/apps/cli/dist/index.js'),
93
+ path.join(__dirname, '../../hay/apps/cli/dist/index.js'),
94
+ path.resolve(__dirname, '../hay/apps/cli/dist/index.js'),
95
+ ];
96
+ for (const p of cliPaths) {
97
+ if (fs.existsSync(p)) {
98
+ return p;
99
+ }
100
+ }
101
+ // Fallback to bundled (if we ever bundle it)
102
+ const bundled = path.join(__dirname, 'hay-cli.js');
103
+ if (fs.existsSync(bundled)) {
104
+ return bundled;
105
+ }
106
+ throw new Error('Hay CLI not found. Build hay/apps/cli first.');
107
+ }
108
+
109
+ // Configure TOTP to accept codes ±1 time step (handles clock drift)
110
+ authenticator.options = { window: 1 };
111
+
112
+ // --- Configuration ---
113
+ // Use HOP_HOME env var or default to .hop2 (separate from original hop)
114
+ const HOME_DIR = process.env.HOP_HOME || path.join(os.homedir(), '.hop2');
115
+ const PRIVATE_DIR_MODE = 0o700;
116
+ const PRIVATE_FILE_MODE = 0o600;
117
+
118
+ function ensurePrivateDir(dirPath) {
119
+ try {
120
+ if (!fs.existsSync(dirPath)) {
121
+ fs.mkdirSync(dirPath, { recursive: true, mode: PRIVATE_DIR_MODE });
122
+ } else {
123
+ fs.chmodSync(dirPath, PRIVATE_DIR_MODE);
124
+ }
125
+ } catch (e) { }
126
+ }
127
+
128
+ function ensurePrivateFile(filePath, mode = PRIVATE_FILE_MODE) {
129
+ try {
130
+ if (fs.existsSync(filePath)) {
131
+ fs.chmodSync(filePath, mode);
132
+ }
133
+ } catch (e) { }
134
+ }
135
+
136
+ function writePrivateFile(filePath, data, mode = PRIVATE_FILE_MODE) {
137
+ fs.writeFileSync(filePath, data, { mode });
138
+ ensurePrivateFile(filePath, mode);
139
+ }
140
+
141
+ ensurePrivateDir(HOME_DIR);
142
+
143
+ const STATE_FILE = path.join(HOME_DIR, '.tunnel-state');
144
+ const DEFAULT_SECRET_FILE = path.join(HOME_DIR, '.auth_secret');
145
+ const DOMAIN_CONFIG_FILE = path.join(HOME_DIR, '.domain-config.json');
146
+ const DEFAULT_PASSWORD_FILE = path.join(HOME_DIR, '.password_hash');
147
+ const SESSION_COOKIE_NAME = 'tunnel_session';
148
+ const LOG_DIR = path.join(HOME_DIR, 'logs');
149
+ const SESSION_NAME_RE = /^[a-zA-Z0-9_-]+$/;
150
+ let SECRET_FILE_PATH = DEFAULT_SECRET_FILE;
151
+ let PASSWORD_FILE_PATH = DEFAULT_PASSWORD_FILE;
152
+ const SESSIONS_FILE = path.join(HOME_DIR, '.sessions.json');
153
+
154
+ // Password hashing using scrypt (built-in, no external deps)
155
+ function hashPassword(password) {
156
+ const salt = crypto.randomBytes(16).toString('hex');
157
+ const hash = crypto.scryptSync(password, salt, 64).toString('hex');
158
+ return `${salt}:${hash}`;
159
+ }
160
+
161
+ function verifyPassword(password, stored) {
162
+ if (!stored) return false;
163
+ const [salt, hash] = stored.split(':');
164
+ if (!salt || !hash) return false;
165
+ const testHash = crypto.scryptSync(password, salt, 64).toString('hex');
166
+ return crypto.timingSafeEqual(Buffer.from(hash, 'hex'), Buffer.from(testHash, 'hex'));
167
+ }
168
+
169
+ function getStoredPassword() {
170
+ if (fs.existsSync(PASSWORD_FILE_PATH)) {
171
+ ensurePrivateFile(PASSWORD_FILE_PATH);
172
+ return fs.readFileSync(PASSWORD_FILE_PATH, 'utf8').trim();
173
+ }
174
+ return null;
175
+ }
176
+
177
+ function setPassword(password) {
178
+ const hashed = hashPassword(password);
179
+ writePrivateFile(PASSWORD_FILE_PATH, hashed);
180
+ }
181
+
182
+ function clearPassword() {
183
+ if (fs.existsSync(PASSWORD_FILE_PATH)) {
184
+ fs.unlinkSync(PASSWORD_FILE_PATH);
185
+ }
186
+ }
187
+
188
+ function isPasswordEnabled() {
189
+ return fs.existsSync(PASSWORD_FILE_PATH);
190
+ }
191
+ const HOP_SESSION_PREFIX = 'hop2-'; // Prefix for hop2 sessions (separate from hop which uses 'hop-')
192
+
193
+ ensurePrivateDir(LOG_DIR);
194
+
195
+ function toInternalSessionName(displayName) {
196
+ if (!displayName || typeof displayName !== 'string') return null;
197
+ if (!SESSION_NAME_RE.test(displayName)) return null;
198
+ return HOP_SESSION_PREFIX + displayName;
199
+ }
200
+
201
+ function toDisplaySessionName(internalName) {
202
+ if (!internalName || typeof internalName !== 'string') return '';
203
+ return internalName.slice(HOP_SESSION_PREFIX.length);
204
+ }
205
+
206
+ function loadSessionConfigs() {
207
+ if (fs.existsSync(SESSIONS_FILE)) {
208
+ try {
209
+ ensurePrivateFile(SESSIONS_FILE);
210
+ const data = JSON.parse(fs.readFileSync(SESSIONS_FILE, 'utf8'));
211
+ return data && typeof data === 'object' ? data : {};
212
+ } catch (e) {
213
+ return {};
214
+ }
215
+ }
216
+ return {};
217
+ }
218
+
219
+ function saveSessionConfigs() {
220
+ writePrivateFile(SESSIONS_FILE, JSON.stringify(sessionConfigs, null, 2));
221
+ }
222
+
223
+ let sessionConfigs = loadSessionConfigs();
224
+
225
+ function getSessionConfig(name) {
226
+ return sessionConfigs[name] || null;
227
+ }
228
+
229
+ function setSessionConfig(name, config) {
230
+ sessionConfigs[name] = config;
231
+ saveSessionConfigs();
232
+ }
233
+
234
+ function deleteSessionConfig(name) {
235
+ delete sessionConfigs[name];
236
+ saveSessionConfigs();
237
+ }
238
+
239
+ function ensureTerminalSessionConfig(name, cwd) {
240
+ const existing = getSessionConfig(name);
241
+ if (!existing) {
242
+ setSessionConfig(name, {
243
+ type: 'terminal',
244
+ createdAt: new Date().toISOString(),
245
+ updatedAt: new Date().toISOString(),
246
+ cwd: cwd || undefined
247
+ });
248
+ return;
249
+ }
250
+ if (cwd && existing.cwd !== cwd) {
251
+ setSessionConfig(name, {
252
+ ...existing,
253
+ cwd,
254
+ updatedAt: new Date().toISOString()
255
+ });
256
+ }
257
+ }
258
+
259
+ function setPortSessionConfig(name, port) {
260
+ const existing = getSessionConfig(name);
261
+ setSessionConfig(name, {
262
+ type: 'port',
263
+ port,
264
+ createdAt: existing?.createdAt || new Date().toISOString(),
265
+ updatedAt: new Date().toISOString()
266
+ });
267
+ }
268
+
269
+ // Load domain config if exists
270
+ function getDomainConfig() {
271
+ if (fs.existsSync(DOMAIN_CONFIG_FILE)) {
272
+ try {
273
+ return JSON.parse(fs.readFileSync(DOMAIN_CONFIG_FILE, 'utf8'));
274
+ } catch (e) {
275
+ return null;
276
+ }
277
+ }
278
+ return null;
279
+ }
280
+
281
+ function saveDomainConfig(config) {
282
+ fs.writeFileSync(DOMAIN_CONFIG_FILE, JSON.stringify(config, null, 2));
283
+ }
284
+
285
+ // Check if cloudflared is logged in
286
+ function isCloudflaredLoggedIn() {
287
+ const certPath = path.join(os.homedir(), '.cloudflared', 'cert.pem');
288
+ return fs.existsSync(certPath);
289
+ }
290
+
291
+ // Get existing tunnels
292
+ function listCloudflaredTunnels() {
293
+ try {
294
+ const output = execSync('cloudflared tunnel list -o json', { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] });
295
+ return JSON.parse(output);
296
+ } catch (e) {
297
+ return [];
298
+ }
299
+ }
300
+
301
+ // Setup custom domain
302
+ async function setupDomain(hostname) {
303
+ const tunnelName = 'hop';
304
+ let tunnelCreatedByUs = false; // Track if we created it (for cleanup)
305
+
306
+ log(`\n${BOLD}Setting up custom domain: ${hostname}${RESET}\n`, CYAN);
307
+
308
+ // Step 1: Check cloudflared login
309
+ log(`[1/4] Checking Cloudflare authentication...`, BOLD);
310
+ if (!isCloudflaredLoggedIn()) {
311
+ log(` Opening browser for Cloudflare login...`, YELLOW);
312
+ log(` Please authorize in your browser.\n`, RESET);
313
+ try {
314
+ execSync('cloudflared tunnel login', { stdio: 'inherit' });
315
+ } catch (e) {
316
+ log(`Failed to authenticate with Cloudflare.`, RED);
317
+ process.exit(1);
318
+ }
319
+ }
320
+ log(` Authenticated with Cloudflare.`, GREEN);
321
+
322
+ // Step 2: Check if tunnel exists, create if not
323
+ log(`[2/4] Setting up tunnel...`, BOLD);
324
+ const tunnels = listCloudflaredTunnels();
325
+ let tunnel = tunnels.find(t => t.name === tunnelName);
326
+
327
+ if (tunnel) {
328
+ log(` Using existing tunnel: ${tunnelName} (${tunnel.id})`, GREEN);
329
+ } else {
330
+ log(` Creating new tunnel: ${tunnelName}...`, YELLOW);
331
+ try {
332
+ execSync(`cloudflared tunnel create ${tunnelName}`, { encoding: 'utf8' });
333
+ log(` Tunnel created.`, GREEN);
334
+ tunnelCreatedByUs = true;
335
+ // Re-fetch to get the ID
336
+ const newTunnels = listCloudflaredTunnels();
337
+ tunnel = newTunnels.find(t => t.name === tunnelName);
338
+ } catch (e) {
339
+ log(`Failed to create tunnel: ${e.message}`, RED);
340
+ process.exit(1);
341
+ }
342
+ }
343
+
344
+ // Step 3: Setup DNS route - this validates domain ownership
345
+ log(`[3/4] Configuring DNS...`, BOLD);
346
+ let dnsSuccess = false;
347
+ try {
348
+ // Capture both stdout and stderr to check for errors
349
+ const result = execSync(`cloudflared tunnel route dns ${tunnelName} ${hostname} 2>&1`, {
350
+ encoding: 'utf8'
351
+ });
352
+
353
+ // Check for failure FIRST (before checking for "already exists" which can appear in error messages)
354
+ if (result.includes('Failed to add route') || result.includes('Failed to create record')) {
355
+ // This is an error, even if it contains "already exists"
356
+ log(`\n${RED}ERROR: DNS configuration failed.${RESET}`, RED);
357
+ log(`Details: ${result.trim()}`, YELLOW);
358
+ dnsSuccess = false;
359
+ } else if (result.includes('Added CNAME')) {
360
+ dnsSuccess = true;
361
+ log(` DNS configured: ${hostname} → tunnel`, GREEN);
362
+ } else if (result.includes('already exists') && !result.includes('Failed')) {
363
+ // Only treat as success if it's not part of a failure message
364
+ dnsSuccess = true;
365
+ log(` DNS already configured.`, GREEN);
366
+ } else {
367
+ // Unexpected output - treat as potential failure
368
+ log(` Unexpected response: ${result.trim()}`, YELLOW);
369
+ dnsSuccess = false;
370
+ }
371
+ } catch (e) {
372
+ const errorOutput = e.stdout || e.stderr || e.message || '';
373
+
374
+ // Check for failure indicators FIRST
375
+ if (errorOutput.includes('Failed to add route') || errorOutput.includes('Failed to create record')) {
376
+ log(`\n${RED}ERROR: DNS configuration failed.${RESET}`, RED);
377
+ log(`Details: ${errorOutput.trim()}`, YELLOW);
378
+ dnsSuccess = false;
379
+ } else if (errorOutput.includes('zone') || errorOutput.includes('not found') || errorOutput.includes('does not exist')) {
380
+ log(`\n${RED}ERROR: Domain "${hostname}" is not in your Cloudflare account.${RESET}`, RED);
381
+ log(`\nMake sure:`, YELLOW);
382
+ log(` 1. You own this domain`, RESET);
383
+ log(` 2. The domain is added to your Cloudflare account`, RESET);
384
+ log(` 3. You're using the correct Cloudflare account`, RESET);
385
+ dnsSuccess = false;
386
+ } else if (errorOutput.includes('authentication') || errorOutput.includes('unauthorized')) {
387
+ log(`\n${RED}ERROR: Authentication failed.${RESET}`, RED);
388
+ log(`Try: cloudflared tunnel login`, YELLOW);
389
+ dnsSuccess = false;
390
+ } else if (errorOutput.includes('already exists') && !errorOutput.includes('Failed')) {
391
+ // Only treat as success if it's a clean "already exists" not part of error
392
+ dnsSuccess = true;
393
+ log(` DNS already configured.`, GREEN);
394
+ } else {
395
+ log(`\n${RED}ERROR: DNS configuration failed.${RESET}`, RED);
396
+ log(`Details: ${errorOutput.trim()}`, YELLOW);
397
+ dnsSuccess = false;
398
+ }
399
+ }
400
+
401
+ // If DNS failed, clean up the tunnel we created
402
+ if (!dnsSuccess) {
403
+ if (tunnelCreatedByUs) {
404
+ log(`\nCleaning up...`, YELLOW);
405
+ try {
406
+ execSync(`cloudflared tunnel delete ${tunnelName}`, { stdio: 'ignore' });
407
+ log(` Deleted tunnel: ${tunnelName}`, RESET);
408
+ } catch (e) {
409
+ log(` Note: You may need to manually delete tunnel: cloudflared tunnel delete ${tunnelName}`, YELLOW);
410
+ }
411
+ }
412
+ log(`\nSetup failed. Please fix the issue and try again.`, RED);
413
+ process.exit(1);
414
+ }
415
+
416
+ // Step 4: Save config
417
+ log(`[4/4] Saving configuration...`, BOLD);
418
+ saveDomainConfig({
419
+ tunnelName,
420
+ tunnelId: tunnel ? tunnel.id : null,
421
+ hostname,
422
+ createdAt: new Date().toISOString()
423
+ });
424
+ log(` Configuration saved.`, GREEN);
425
+
426
+ log(`\n${BOLD}${GREEN}✓ Custom domain setup complete!${RESET}`, GREEN);
427
+ log(`\nYour hop URL: https://${hostname}`, BOLD);
428
+
429
+ // Require password for fixed domains
430
+ if (!isPasswordEnabled()) {
431
+ log(`\n${YELLOW}⚠ Password required for fixed domains.${RESET}`, YELLOW);
432
+ log(`A fixed URL is publicly known - password adds essential protection.\n`, RESET);
433
+
434
+ const readline = require('readline');
435
+ const stream = require('stream');
436
+
437
+ const askPassword = (prompt) => new Promise((resolve) => {
438
+ const rl = readline.createInterface({
439
+ input: process.stdin,
440
+ output: new stream.Writable({
441
+ write: (chunk, encoding, callback) => callback()
442
+ }),
443
+ terminal: true
444
+ });
445
+
446
+ process.stdout.write(prompt);
447
+ rl.question('', (answer) => {
448
+ rl.close();
449
+ process.stdout.write('\n');
450
+ resolve(answer);
451
+ });
452
+ });
453
+
454
+ const password = await askPassword('Set a password: ');
455
+ if (password.length < 4) {
456
+ log(`Password too short. Run ${CYAN}hop password set${RESET} to set later.`, RED);
457
+ } else {
458
+ const confirm = await askPassword('Confirm password: ');
459
+ if (password !== confirm) {
460
+ log(`Passwords don't match. Run ${CYAN}hop password set${RESET} to set later.`, RED);
461
+ } else {
462
+ setPassword(password);
463
+ log(`${GREEN}✓ Password set.${RESET}`, GREEN);
464
+ }
465
+ }
466
+ }
467
+
468
+ log(`\nRun ${CYAN}hop${RESET} to start with your custom domain.`, RESET);
469
+ }
470
+
471
+ // --- Multi-user management ---
472
+ const USERS_FILE = path.join(HOME_DIR, '.users.json');
473
+
474
+ function getUsers() {
475
+ if (fs.existsSync(USERS_FILE)) {
476
+ try {
477
+ return JSON.parse(fs.readFileSync(USERS_FILE, 'utf8'));
478
+ } catch (e) {
479
+ return {};
480
+ }
481
+ }
482
+ return {};
483
+ }
484
+
485
+ function saveUsers(users) {
486
+ fs.writeFileSync(USERS_FILE, JSON.stringify(users, null, 2));
487
+ }
488
+
489
+ // Get base domain from existing config (e.g., hop.example.com -> example.com)
490
+ function getBaseDomain() {
491
+ const config = getDomainConfig();
492
+ if (!config || !config.hostname) return null;
493
+ // Extract base domain: hop.example.com -> example.com
494
+ const parts = config.hostname.split('.');
495
+ if (parts.length >= 2) {
496
+ return parts.slice(-2).join('.');
497
+ }
498
+ return config.hostname;
499
+ }
500
+
501
+ // Get subdomain prefix (e.g., hop.example.com -> hop)
502
+ function getSubdomainPrefix() {
503
+ const config = getDomainConfig();
504
+ if (!config || !config.hostname) return 'hop';
505
+ const parts = config.hostname.split('.');
506
+ if (parts.length >= 3) {
507
+ return parts[0];
508
+ }
509
+ return 'hop';
510
+ }
511
+
512
+ async function addUser(username) {
513
+ // Validate username
514
+ if (!/^[a-zA-Z0-9_-]+$/.test(username)) {
515
+ log(`Invalid username. Use only letters, numbers, underscore, hyphen.`, RED);
516
+ process.exit(1);
517
+ }
518
+
519
+ const baseDomain = getBaseDomain();
520
+ const prefix = getSubdomainPrefix();
521
+
522
+ if (!baseDomain) {
523
+ log(`You must set up your own domain first.`, RED);
524
+ log(`Run: hop domain <your-subdomain.yourdomain.com>`, RESET);
525
+ process.exit(1);
526
+ }
527
+
528
+ if (!isCloudflaredLoggedIn()) {
529
+ log(`You must be logged in to Cloudflare.`, RED);
530
+ log(`Run: hop domain <hostname> to set up.`, RESET);
531
+ process.exit(1);
532
+ }
533
+
534
+ const users = getUsers();
535
+ if (users[username]) {
536
+ log(`User "${username}" already exists.`, YELLOW);
537
+ log(`Hostname: ${users[username].hostname}`, RESET);
538
+ process.exit(0);
539
+ }
540
+
541
+ const tunnelName = `hop-${username}`;
542
+ const hostname = `${username}.${prefix}.${baseDomain}`;
543
+ let tunnelCreatedByUs = false;
544
+
545
+ log(`\n${BOLD}Adding user: ${username}${RESET}\n`, CYAN);
546
+
547
+ // Step 1: Create tunnel
548
+ log(`[1/3] Creating tunnel...`, BOLD);
549
+ const tunnels = listCloudflaredTunnels();
550
+ let tunnel = tunnels.find(t => t.name === tunnelName);
551
+
552
+ if (tunnel) {
553
+ log(` Tunnel already exists: ${tunnelName}`, GREEN);
554
+ } else {
555
+ try {
556
+ execSync(`cloudflared tunnel create ${tunnelName}`, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] });
557
+ log(` Tunnel created: ${tunnelName}`, GREEN);
558
+ tunnelCreatedByUs = true;
559
+ // Re-fetch to get the ID
560
+ const newTunnels = listCloudflaredTunnels();
561
+ tunnel = newTunnels.find(t => t.name === tunnelName);
562
+ } catch (e) {
563
+ log(`Failed to create tunnel: ${e.message}`, RED);
564
+ process.exit(1);
565
+ }
566
+ }
567
+
568
+ // Step 2: Setup DNS
569
+ log(`[2/3] Configuring DNS...`, BOLD);
570
+ let dnsSuccess = false;
571
+ try {
572
+ const result = execSync(`cloudflared tunnel route dns ${tunnelName} ${hostname} 2>&1`, {
573
+ encoding: 'utf8'
574
+ });
575
+ // Check for failure FIRST
576
+ if (result.includes('Failed to add route') || result.includes('Failed to create record')) {
577
+ log(`\n${RED}ERROR: DNS failed: ${result.trim()}${RESET}`, RED);
578
+ dnsSuccess = false;
579
+ } else if (result.includes('Added CNAME')) {
580
+ dnsSuccess = true;
581
+ log(` DNS configured: ${hostname}`, GREEN);
582
+ } else if (result.includes('already exists') && !result.includes('Failed')) {
583
+ dnsSuccess = true;
584
+ log(` DNS already configured.`, GREEN);
585
+ } else {
586
+ log(` Unexpected response: ${result.trim()}`, YELLOW);
587
+ }
588
+ } catch (e) {
589
+ const errorOutput = e.stdout || e.stderr || e.message || '';
590
+ // Check for failure FIRST
591
+ if (errorOutput.includes('Failed to add route') || errorOutput.includes('Failed to create record')) {
592
+ log(`\n${RED}ERROR: DNS failed: ${errorOutput.trim()}${RESET}`, RED);
593
+ } else if (errorOutput.includes('already exists') && !errorOutput.includes('Failed')) {
594
+ dnsSuccess = true;
595
+ log(` DNS already configured.`, GREEN);
596
+ } else if (errorOutput.includes('zone') || errorOutput.includes('not found')) {
597
+ log(`\n${RED}ERROR: Domain not in your Cloudflare account.${RESET}`, RED);
598
+ } else {
599
+ log(`\n${RED}ERROR: DNS failed: ${errorOutput.trim()}${RESET}`, RED);
600
+ }
601
+ }
602
+
603
+ // If DNS failed, clean up
604
+ if (!dnsSuccess) {
605
+ if (tunnelCreatedByUs) {
606
+ log(`\nCleaning up...`, YELLOW);
607
+ try {
608
+ execSync(`cloudflared tunnel delete ${tunnelName}`, { stdio: 'ignore' });
609
+ log(` Deleted tunnel: ${tunnelName}`, RESET);
610
+ } catch (e) { }
611
+ }
612
+ process.exit(1);
613
+ }
614
+
615
+ // Step 3: Save user info
616
+ log(`[3/3] Saving user...`, BOLD);
617
+
618
+ // Find the credentials file
619
+ const cloudflaredDir = path.join(os.homedir(), '.cloudflared');
620
+ let credentialsFile = null;
621
+ if (tunnel && tunnel.id) {
622
+ credentialsFile = path.join(cloudflaredDir, `${tunnel.id}.json`);
623
+ }
624
+
625
+ users[username] = {
626
+ tunnelName,
627
+ tunnelId: tunnel ? tunnel.id : null,
628
+ hostname,
629
+ credentialsFile,
630
+ createdAt: new Date().toISOString()
631
+ };
632
+ saveUsers(users);
633
+ log(` User saved.`, GREEN);
634
+
635
+ log(`\n${BOLD}${GREEN}✓ User "${username}" added!${RESET}`, GREEN);
636
+ log(`\nTheir URL: https://${hostname}`, BOLD);
637
+ log(`\nTo export credentials for them:`, RESET);
638
+ log(` ${CYAN}hop user export ${username}${RESET}`, RESET);
639
+ }
640
+
641
+ async function removeUser(username) {
642
+ const users = getUsers();
643
+
644
+ if (!users[username]) {
645
+ log(`User "${username}" not found.`, RED);
646
+ process.exit(1);
647
+ }
648
+
649
+ const user = users[username];
650
+
651
+ log(`\n${BOLD}Removing user: ${username}${RESET}\n`, YELLOW);
652
+
653
+ // Delete tunnel
654
+ log(`[1/2] Deleting tunnel...`, BOLD);
655
+ try {
656
+ execSync(`cloudflared tunnel delete ${user.tunnelName}`, {
657
+ encoding: 'utf8',
658
+ stdio: ['pipe', 'pipe', 'pipe']
659
+ });
660
+ log(` Tunnel deleted: ${user.tunnelName}`, GREEN);
661
+ } catch (e) {
662
+ log(` Warning: Could not delete tunnel (may already be gone).`, YELLOW);
663
+ }
664
+
665
+ // Note: DNS record may remain, Cloudflare doesn't auto-delete it
666
+ log(`[2/2] Removing user record...`, BOLD);
667
+ delete users[username];
668
+ saveUsers(users);
669
+ log(` User removed.`, GREEN);
670
+
671
+ log(`\n${BOLD}${GREEN}✓ User "${username}" removed.${RESET}`, GREEN);
672
+ log(`\nNote: DNS record for ${user.hostname} may need manual removal in Cloudflare dashboard.`, YELLOW);
673
+ }
674
+
675
+ function listUsersCommand() {
676
+ const users = getUsers();
677
+ const userList = Object.entries(users);
678
+
679
+ if (userList.length === 0) {
680
+ log(`\nNo users configured.`, YELLOW);
681
+ log(`\nAdd a user: hop user add <username>`, RESET);
682
+ return;
683
+ }
684
+
685
+ log(`\n${BOLD}Configured users:${RESET}\n`, CYAN);
686
+
687
+ for (const [username, info] of userList) {
688
+ log(` ${BOLD}${username}${RESET}`, GREEN);
689
+ log(` URL: https://${info.hostname}`, RESET);
690
+ log(` Tunnel: ${info.tunnelName}`, RESET);
691
+ log(` Created: ${info.createdAt}`, RESET);
692
+ log('');
693
+ }
694
+ }
695
+
696
+ async function exportUserCredentials(username) {
697
+ const users = getUsers();
698
+
699
+ if (!users[username]) {
700
+ log(`User "${username}" not found.`, RED);
701
+ process.exit(1);
702
+ }
703
+
704
+ const user = users[username];
705
+ const cloudflaredDir = path.join(os.homedir(), '.cloudflared');
706
+ const certFile = path.join(cloudflaredDir, 'cert.pem');
707
+
708
+ // Find credentials file
709
+ let credentialsFile = user.credentialsFile;
710
+ if (!credentialsFile || !fs.existsSync(credentialsFile)) {
711
+ // Try to find by tunnel ID
712
+ if (user.tunnelId) {
713
+ credentialsFile = path.join(cloudflaredDir, `${user.tunnelId}.json`);
714
+ }
715
+ }
716
+
717
+ if (!credentialsFile || !fs.existsSync(credentialsFile)) {
718
+ log(`Credentials file not found for user "${username}".`, RED);
719
+ log(`The tunnel may need to be recreated.`, YELLOW);
720
+ process.exit(1);
721
+ }
722
+
723
+ // Create export directory
724
+ const exportDir = path.join(HOME_DIR, 'exports', username);
725
+ if (!fs.existsSync(exportDir)) {
726
+ fs.mkdirSync(exportDir, { recursive: true });
727
+ }
728
+
729
+ // Copy credentials
730
+ const exportCredentials = path.join(exportDir, 'credentials.json');
731
+ fs.copyFileSync(credentialsFile, exportCredentials);
732
+
733
+ // Create config.yml with hostname for client convenience
734
+ const exportConfig = `hostname: ${user.hostname}
735
+ tunnel: ${user.tunnelId || ''}
736
+ `;
737
+ const exportConfigPath = path.join(exportDir, 'config.yml');
738
+ writePrivateFile(exportConfigPath, exportConfig);
739
+
740
+ // Create README with hop client instructions
741
+ const readme = `# Hop Terminal for ${username}
742
+
743
+ Your hop URL: https://${user.hostname}
744
+
745
+ ## Quick Start
746
+
747
+ 1. Install hop globally:
748
+ npm install -g hop-shell
749
+
750
+ 2. Start hop with credentials:
751
+ hop client ./credentials.json
752
+
753
+ 3. On first run, you'll be prompted to:
754
+ - Set a password (required)
755
+ - Scan a QR code for 2FA (TOTP)
756
+
757
+ 4. Open your URL and log in:
758
+ https://${user.hostname}
759
+
760
+ ## Security
761
+
762
+ - credentials.json contains your tunnel secret - keep it safe!
763
+ - On first run, you MUST set up password + TOTP authentication
764
+ - Anyone accessing your URL will need YOUR password and TOTP code
765
+ - Your credentials are stored in ~/.hop-shell/clients/<tunnel-id>/
766
+
767
+ ## Troubleshooting
768
+
769
+ If you need to reset your password/TOTP:
770
+ rm -rf ~/.hop-shell/clients/
771
+
772
+ Then run hop client again to set up fresh credentials.
773
+ `;
774
+ const exportReadme = path.join(exportDir, 'README.md');
775
+ fs.writeFileSync(exportReadme, readme);
776
+
777
+ log(`\n${BOLD}${GREEN}✓ Credentials exported for "${username}"${RESET}`, GREEN);
778
+ log(`\nExport location: ${exportDir}`, BOLD);
779
+ log(`\nFiles created:`, RESET);
780
+ log(` - credentials.json (tunnel credentials - keep secret!)`, RESET);
781
+ log(` - config.yml (hostname config)`, RESET);
782
+ log(` - README.md (setup instructions)`, RESET);
783
+ log(`\n${YELLOW}Send this folder to ${username}.${RESET}`, YELLOW);
784
+ log(`\nThey should:`, RESET);
785
+ log(` 1. Install hop: ${CYAN}npm install -g hop-shell${RESET}`, RESET);
786
+ log(` 2. Run: ${CYAN}hop client ./credentials.json${RESET}`, RESET);
787
+ log(` 3. Set up their password + TOTP on first run`, RESET);
788
+ log(`\nTheir URL will be: https://${user.hostname}`, CYAN);
789
+ }
790
+
791
+ // Check if tunnel is already running
792
+ function isServerRunning() {
793
+ if (!fs.existsSync(STATE_FILE)) return null;
794
+ ensurePrivateFile(STATE_FILE);
795
+ try {
796
+ const state = JSON.parse(fs.readFileSync(STATE_FILE, 'utf8'));
797
+ // Check if the process is still alive
798
+ try {
799
+ process.kill(state.pid, 0);
800
+ return state;
801
+ } catch (e) {
802
+ // Process is dead, clean up
803
+ fs.unlinkSync(STATE_FILE);
804
+ return null;
805
+ }
806
+ } catch (e) {
807
+ return null;
808
+ }
809
+ }
810
+
811
+ // Generate or load session secret
812
+ let SESSION_SECRET;
813
+ const existingState = isServerRunning();
814
+ if (existingState) {
815
+ SESSION_SECRET = existingState.sessionSecret;
816
+ } else {
817
+ SESSION_SECRET = crypto.randomBytes(32).toString('hex');
818
+ }
819
+
820
+ // Persistence for Stable QR Code
821
+ let AUTH_SECRET;
822
+ function loadAuthSecret() {
823
+ if (fs.existsSync(SECRET_FILE_PATH)) {
824
+ AUTH_SECRET = fs.readFileSync(SECRET_FILE_PATH, 'utf8').trim();
825
+ ensurePrivateFile(SECRET_FILE_PATH);
826
+ } else {
827
+ AUTH_SECRET = authenticator.generateSecret();
828
+ writePrivateFile(SECRET_FILE_PATH, AUTH_SECRET);
829
+ }
830
+ }
831
+
832
+ // Port configuration
833
+ const BASE_PORT = existingState ? existingState.port : Math.floor(Math.random() * (50000 - 10000 + 1) + 10000);
834
+ const PROXY_PORT = BASE_PORT;
835
+
836
+ // Session Management (using hay server - no tmux/ttyd)
837
+ const activeSessions = {}; // { sessionName: { room: HayRoom } }
838
+ const startingSessions = new Set(); // session names being initialized
839
+ const sessionRenames = {}; // { oldName: newName } - track renames for styling updates
840
+ let nextPort = BASE_PORT + 1;
841
+
842
+ // --- Login Rate Limiting (Exponential Backoff) ---
843
+ const failedAttempts = new Map(); // ip -> { count: number, lastAttempt: number }
844
+ const FAILED_ATTEMPT_TTL_MS = 6 * 60 * 60 * 1000; // 6 hours
845
+ const FAILED_ATTEMPT_MAX = 1000;
846
+
847
+ function pruneFailedAttempts(now = Date.now()) {
848
+ for (const [ip, record] of failedAttempts.entries()) {
849
+ if (now - record.lastAttempt > FAILED_ATTEMPT_TTL_MS) {
850
+ failedAttempts.delete(ip);
851
+ }
852
+ }
853
+ if (failedAttempts.size > FAILED_ATTEMPT_MAX) {
854
+ const entries = Array.from(failedAttempts.entries())
855
+ .sort((a, b) => a[1].lastAttempt - b[1].lastAttempt);
856
+ const overflow = failedAttempts.size - FAILED_ATTEMPT_MAX;
857
+ for (let i = 0; i < overflow; i++) {
858
+ failedAttempts.delete(entries[i][0]);
859
+ }
860
+ }
861
+ }
862
+
863
+ function checkRateLimit(ip) {
864
+ pruneFailedAttempts();
865
+ const record = failedAttempts.get(ip);
866
+ if (!record || record.count < 5) return { allowed: true };
867
+
868
+ const now = Date.now();
869
+ // 5 failures = 30s, 6 = 60s, 7 = 120s... (30 * 2^(n-5))
870
+ const waitSeconds = 30 * Math.pow(2, record.count - 5);
871
+ const lockedUntil = record.lastAttempt + (waitSeconds * 1000);
872
+
873
+ if (now < lockedUntil) {
874
+ return { allowed: false, remaining: Math.ceil((lockedUntil - now) / 1000) };
875
+ }
876
+ return { allowed: true };
877
+ }
878
+
879
+ function recordFailure(ip) {
880
+ const now = Date.now();
881
+ pruneFailedAttempts(now);
882
+ const record = failedAttempts.get(ip) || { count: 0, lastAttempt: 0 };
883
+ record.count++;
884
+ record.lastAttempt = now;
885
+ failedAttempts.set(ip, record);
886
+ }
887
+
888
+ function resetFailure(ip) {
889
+ failedAttempts.delete(ip);
890
+ }
891
+
892
+ // Colors
893
+ const RESET = "\x1b[0m";
894
+ const GREEN = "\x1b[32m";
895
+ const YELLOW = "\x1b[33m";
896
+ const CYAN = "\x1b[36m";
897
+ const RED = "\x1b[31m";
898
+ const BOLD = "\x1b[1m";
899
+ const MAGENTA = "\x1b[35m";
900
+
901
+ function log(msg, color = RESET) {
902
+ console.log(`${color}${msg}${RESET}`);
903
+ }
904
+
905
+ async function runCommand(command, args, opts = {}) {
906
+ return new Promise((resolve, reject) => {
907
+ const proc = spawn(command, args, { stdio: 'inherit', ...opts });
908
+ proc.on('close', (code) => {
909
+ if (code === 0) resolve();
910
+ else reject(new Error(`Command ${command} exited with code ${code}`));
911
+ });
912
+ proc.on('error', (err) => reject(err));
913
+ });
914
+ }
915
+
916
+ async function checkAndInstallDependencies() {
917
+ log(`[1/4] Checking dependencies...`, BOLD);
918
+ try {
919
+ await runCommand('brew', ['--version'], { stdio: 'ignore' });
920
+ } catch (e) {
921
+ log(` - Homebrew not found.`, YELLOW);
922
+
923
+ const readline = require('readline');
924
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
925
+
926
+ const answer = await new Promise(resolve => {
927
+ rl.question(` Install Homebrew now? (y/n) `, resolve);
928
+ });
929
+ rl.close();
930
+
931
+ if (answer.toLowerCase() !== 'y') {
932
+ log(`Error: Homebrew required. Install from https://brew.sh`, RED);
933
+ process.exit(1);
934
+ }
935
+
936
+ log(` - Installing Homebrew...`, YELLOW);
937
+ try {
938
+ await runCommand('/bin/bash', ['-c',
939
+ 'curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh | bash'
940
+ ], { stdio: 'inherit' });
941
+
942
+ // Add brew to PATH for Apple Silicon
943
+ const brewPaths = ['/opt/homebrew/bin', '/usr/local/bin'];
944
+ for (const p of brewPaths) {
945
+ if (fs.existsSync(path.join(p, 'brew'))) {
946
+ process.env.PATH = `${p}:${process.env.PATH}`;
947
+ break;
948
+ }
949
+ }
950
+ log(` - Homebrew installed.`, GREEN);
951
+ } catch (err) {
952
+ log(`Error: Failed to install Homebrew.`, RED);
953
+ process.exit(1);
954
+ }
955
+ }
956
+
957
+ const deps = ['cloudflared']; // tmux/ttyd not needed - using node-pty via hay
958
+ for (const dep of deps) {
959
+ try {
960
+ await runCommand('which', [dep], { stdio: 'ignore' });
961
+ log(` - ${dep} found.`, GREEN);
962
+ } catch (e) {
963
+ log(` - ${dep} NOT found. Installing...`, YELLOW);
964
+ try {
965
+ await runCommand('brew', ['install', dep]);
966
+ log(` - ${dep} installed.`, GREEN);
967
+ } catch (err) {
968
+ log(` - Failed to install ${dep}. Check Xcode license?`, RED);
969
+ process.exit(1);
970
+ }
971
+ }
972
+ }
973
+ }
974
+
975
+ // List active hay rooms as sessions
976
+ function listActiveSessions() {
977
+ const sessions = [];
978
+ for (const [name, room] of hayRooms.rooms) {
979
+ sessions.push({
980
+ name,
981
+ windows: 1,
982
+ attached: room.clients.size > 0
983
+ });
984
+ }
985
+ return sessions;
986
+ }
987
+
988
+ function hopSessionExists(name) {
989
+ if (!name) return false;
990
+ return hayRooms.hasRoom(name) || activeSessions[name];
991
+ }
992
+
993
+ function listAllSessionEntries() {
994
+ const haySessions = listActiveSessions().filter(s => s.name.startsWith(HOP_SESSION_PREFIX));
995
+ const sessionMap = new Map();
996
+
997
+ for (const s of haySessions) {
998
+ const config = getSessionConfig(s.name);
999
+ const type = config?.type || 'terminal';
1000
+ sessionMap.set(s.name, {
1001
+ ...s,
1002
+ type,
1003
+ port: config?.port,
1004
+ displayName: toDisplaySessionName(s.name)
1005
+ });
1006
+ }
1007
+
1008
+ for (const [name, config] of Object.entries(sessionConfigs)) {
1009
+ if (!name.startsWith(HOP_SESSION_PREFIX)) continue;
1010
+ if (!sessionMap.has(name)) {
1011
+ sessionMap.set(name, {
1012
+ name,
1013
+ windows: null,
1014
+ attached: false,
1015
+ type: config.type || 'terminal',
1016
+ port: config.port,
1017
+ displayName: toDisplaySessionName(name)
1018
+ });
1019
+ } else if (config?.type === 'port') {
1020
+ const entry = sessionMap.get(name);
1021
+ entry.type = 'port';
1022
+ entry.port = config.port;
1023
+ }
1024
+ }
1025
+
1026
+ return Array.from(sessionMap.values()).sort((a, b) => a.displayName.localeCompare(b.displayName));
1027
+ }
1028
+
1029
+ // Light theme for hay terminal
1030
+ function getTheme() {
1031
+ return JSON.stringify({
1032
+ 'background': '#ffffff',
1033
+ 'foreground': '#18181b',
1034
+ 'cursor': '#7c3aed',
1035
+ 'selectionBackground': '#ddd6fe',
1036
+ 'black': '#18181b',
1037
+ 'red': '#dc2626',
1038
+ 'green': '#16a34a',
1039
+ 'yellow': '#ca8a04',
1040
+ 'blue': '#2563eb',
1041
+ 'magenta': '#9333ea',
1042
+ 'cyan': '#0891b2',
1043
+ 'white': '#f4f4f5'
1044
+ });
1045
+ }
1046
+
1047
+ // Start or get existing session (uses hay for terminal sharing)
1048
+ function getOrCreateSession(sessionName, cwd) {
1049
+ if (!sessionName || typeof sessionName !== 'string' || !SESSION_NAME_RE.test(sessionName)) {
1050
+ throw new Error('Invalid session name');
1051
+ }
1052
+ const existingConfig = getSessionConfig(sessionName);
1053
+ if (existingConfig && existingConfig.type === 'port') {
1054
+ throw new Error('Session is configured as a port proxy');
1055
+ }
1056
+ ensureTerminalSessionConfig(sessionName, cwd);
1057
+ if (activeSessions[sessionName]) {
1058
+ return activeSessions[sessionName];
1059
+ }
1060
+
1061
+ const config = getSessionConfig(sessionName);
1062
+ const effectiveCwd = cwd || config?.cwd;
1063
+
1064
+ // Create hay room for this session (with optional cwd override)
1065
+ const room = hayRooms.getRoom(sessionName, { cols: 80, rows: 24 }, effectiveCwd);
1066
+
1067
+ room.on('empty', () => {
1068
+ delete activeSessions[sessionName];
1069
+ const config = getSessionConfig(sessionName);
1070
+ if (config && config.type !== 'port') {
1071
+ deleteSessionConfig(sessionName);
1072
+ }
1073
+ log(`Session ${toDisplaySessionName(sessionName)} closed.`, YELLOW);
1074
+ });
1075
+
1076
+ room.on('exit', () => {
1077
+ delete activeSessions[sessionName];
1078
+ log(`Session ${toDisplaySessionName(sessionName)} exited.`, YELLOW);
1079
+ });
1080
+
1081
+ activeSessions[sessionName] = { room };
1082
+ log(` Started session "${toDisplaySessionName(sessionName)}" (hay)`, GREEN);
1083
+
1084
+ return activeSessions[sessionName];
1085
+ }
1086
+
1087
+ // Resolve current session name (follows rename chain)
1088
+ function resolveSessionName(name) {
1089
+ let current = name;
1090
+ while (sessionRenames[current]) {
1091
+ current = sessionRenames[current];
1092
+ }
1093
+ return current;
1094
+ }
1095
+
1096
+ function getSessionSwitcherOverlay() {
1097
+ return `
1098
+ <style id="hop-session-switcher-style">
1099
+ #hop-session-fab {
1100
+ position: fixed;
1101
+ left: 0;
1102
+ top: 0;
1103
+ z-index: 2147483647;
1104
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
1105
+ user-select: none;
1106
+ }
1107
+ #hop-session-fab-btn {
1108
+ width: 44px;
1109
+ height: 44px;
1110
+ border-radius: 999px;
1111
+ background: rgba(24, 24, 27, 0.9);
1112
+ color: #fff;
1113
+ display: flex;
1114
+ align-items: center;
1115
+ justify-content: center;
1116
+ border: 1px solid rgba(255,255,255,0.08);
1117
+ box-shadow: 0 6px 18px rgba(0,0,0,0.2);
1118
+ cursor: pointer;
1119
+ padding: 0;
1120
+ }
1121
+ #hop-session-fab-btn:active { transform: scale(0.97); }
1122
+ #hop-session-fab-btn svg { width: 20px; height: 20px; }
1123
+ #hop-session-fab-menu {
1124
+ position: absolute;
1125
+ right: 0;
1126
+ bottom: 52px;
1127
+ min-width: 200px;
1128
+ max-width: min(82vw, 320px);
1129
+ background: #fff;
1130
+ color: #18181b;
1131
+ border: 1px solid #e4e4e7;
1132
+ border-radius: 12px;
1133
+ box-shadow: 0 12px 28px rgba(0,0,0,0.16);
1134
+ padding: 8px;
1135
+ display: none;
1136
+ }
1137
+ #hop-session-fab.open #hop-session-fab-menu { display: block; }
1138
+ #hop-session-fab-menu .hop-title {
1139
+ font-size: 12px;
1140
+ font-weight: 600;
1141
+ color: #71717a;
1142
+ padding: 6px 8px;
1143
+ text-transform: uppercase;
1144
+ letter-spacing: 0.06em;
1145
+ }
1146
+ #hop-session-fab-menu .hop-item,
1147
+ #hop-session-fab-menu .hop-link {
1148
+ display: flex;
1149
+ align-items: center;
1150
+ gap: 8px;
1151
+ width: 100%;
1152
+ border: none;
1153
+ background: transparent;
1154
+ padding: 8px 10px;
1155
+ border-radius: 8px;
1156
+ font-size: 14px;
1157
+ color: #18181b;
1158
+ text-decoration: none;
1159
+ cursor: pointer;
1160
+ }
1161
+ #hop-session-fab-menu .hop-item:hover,
1162
+ #hop-session-fab-menu .hop-link:hover { background: #f4f4f5; }
1163
+ #hop-session-fab-menu .hop-item.current { font-weight: 600; }
1164
+ #hop-session-fab-menu .hop-dot {
1165
+ width: 7px;
1166
+ height: 7px;
1167
+ border-radius: 999px;
1168
+ background: #22c55e;
1169
+ }
1170
+ #hop-session-fab-menu .hop-subtle { color: #71717a; font-size: 12px; }
1171
+ </style>
1172
+ <div id="hop-session-fab" role="button" aria-label="Sessions">
1173
+ <button id="hop-session-fab-btn" aria-label="Sessions">
1174
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
1175
+ <rect x="3" y="4" width="18" height="13" rx="2"></rect>
1176
+ <path d="M8 21h8"></path>
1177
+ <path d="M12 17v4"></path>
1178
+ </svg>
1179
+ </button>
1180
+ <div id="hop-session-fab-menu" aria-hidden="true">
1181
+ <div class="hop-title">Sessions</div>
1182
+ <div id="hop-session-fab-list"></div>
1183
+ <a class="hop-link" href="/sessions">Session Picker</a>
1184
+ </div>
1185
+ </div>
1186
+ <script>
1187
+ (function() {
1188
+ var fab = document.getElementById('hop-session-fab');
1189
+ var btn = document.getElementById('hop-session-fab-btn');
1190
+ var menu = document.getElementById('hop-session-fab-menu');
1191
+ var list = document.getElementById('hop-session-fab-list');
1192
+ if (!fab || !btn || !menu || !list) return;
1193
+
1194
+ function clamp(v, min, max) { return Math.min(Math.max(v, min), max); }
1195
+ function getCurrentSession() {
1196
+ var path = window.location.pathname || '';
1197
+ if (path.indexOf('/s/') === 0) {
1198
+ var rest = path.slice(3);
1199
+ var parts = rest.split('/');
1200
+ try { return decodeURIComponent(parts[0] || ''); } catch (e) { return ''; }
1201
+ }
1202
+ return '';
1203
+ }
1204
+
1205
+ var stored = null;
1206
+ try { stored = JSON.parse(localStorage.getItem('hop-fab-pos') || 'null'); } catch (e) { stored = null; }
1207
+ function setPos(x, y) {
1208
+ fab.style.left = x + 'px';
1209
+ fab.style.top = y + 'px';
1210
+ }
1211
+ function initPos() {
1212
+ var pad = 12;
1213
+ var x = (stored && typeof stored.x === 'number') ? stored.x : (window.innerWidth - 56);
1214
+ var y = (stored && typeof stored.y === 'number') ? stored.y : (window.innerHeight - 120);
1215
+ x = clamp(x, pad, window.innerWidth - 56);
1216
+ y = clamp(y, pad, window.innerHeight - 56);
1217
+ setPos(x, y);
1218
+ }
1219
+ initPos();
1220
+
1221
+ var dragging = false;
1222
+ var moved = false;
1223
+ var startX = 0, startY = 0, origX = 0, origY = 0;
1224
+
1225
+ function onDown(e) {
1226
+ dragging = true;
1227
+ moved = false;
1228
+ var touch = e.touches ? e.touches[0] : e;
1229
+ startX = touch.clientX;
1230
+ startY = touch.clientY;
1231
+ var rect = fab.getBoundingClientRect();
1232
+ origX = rect.left;
1233
+ origY = rect.top;
1234
+ e.preventDefault();
1235
+ }
1236
+ function onMove(e) {
1237
+ if (!dragging) return;
1238
+ var touch = e.touches ? e.touches[0] : e;
1239
+ var dx = touch.clientX - startX;
1240
+ var dy = touch.clientY - startY;
1241
+ if (Math.abs(dx) > 4 || Math.abs(dy) > 4) moved = true;
1242
+ var x = clamp(origX + dx, 8, window.innerWidth - 56);
1243
+ var y = clamp(origY + dy, 8, window.innerHeight - 56);
1244
+ setPos(x, y);
1245
+ }
1246
+ function onUp() {
1247
+ if (!dragging) return;
1248
+ dragging = false;
1249
+ var rect = fab.getBoundingClientRect();
1250
+ try { localStorage.setItem('hop-fab-pos', JSON.stringify({ x: rect.left, y: rect.top })); } catch (e) { }
1251
+ }
1252
+
1253
+ btn.addEventListener('touchstart', onDown, { passive: false });
1254
+ btn.addEventListener('touchmove', onMove, { passive: false });
1255
+ btn.addEventListener('touchend', onUp);
1256
+ btn.addEventListener('mousedown', onDown);
1257
+ window.addEventListener('mousemove', onMove);
1258
+ window.addEventListener('mouseup', onUp);
1259
+
1260
+ function closeMenu() {
1261
+ fab.classList.remove('open');
1262
+ menu.setAttribute('aria-hidden', 'true');
1263
+ }
1264
+ function toggleMenu() {
1265
+ if (fab.classList.contains('open')) {
1266
+ closeMenu();
1267
+ return;
1268
+ }
1269
+ fab.classList.add('open');
1270
+ menu.setAttribute('aria-hidden', 'false');
1271
+ fetchSessions();
1272
+ }
1273
+
1274
+ btn.addEventListener('click', function(e) {
1275
+ if (moved) return;
1276
+ e.preventDefault();
1277
+ toggleMenu();
1278
+ });
1279
+
1280
+ document.addEventListener('click', function(e) {
1281
+ if (!fab.contains(e.target)) closeMenu();
1282
+ });
1283
+
1284
+ function fetchSessions() {
1285
+ fetch('/api/sessions')
1286
+ .then(function(r) { return r.json(); })
1287
+ .then(function(data) {
1288
+ list.innerHTML = '';
1289
+ var sessions = data.sessions || [];
1290
+ var active = data.active || [];
1291
+ if (!sessions.length) {
1292
+ var empty = document.createElement('div');
1293
+ empty.className = 'hop-item';
1294
+ empty.textContent = 'No sessions';
1295
+ list.appendChild(empty);
1296
+ return;
1297
+ }
1298
+ var current = getCurrentSession();
1299
+ sessions.forEach(function(s) {
1300
+ var item = document.createElement('button');
1301
+ item.className = 'hop-item';
1302
+ if (s.name === current) item.classList.add('current');
1303
+ if (active.indexOf(s.name) !== -1) {
1304
+ var dot = document.createElement('span');
1305
+ dot.className = 'hop-dot';
1306
+ item.appendChild(dot);
1307
+ }
1308
+ var label = document.createElement('span');
1309
+ var suffix = s.type === 'port' ? (s.port ? ' (PORT ' + s.port + ')' : ' (PORT)') : '';
1310
+ label.textContent = (s.displayName || s.name) + suffix;
1311
+ item.appendChild(label);
1312
+ item.addEventListener('click', function(ev) {
1313
+ ev.stopPropagation();
1314
+ window.location.href = '/s/' + encodeURIComponent(s.name) + '/';
1315
+ });
1316
+ list.appendChild(item);
1317
+ });
1318
+ })
1319
+ .catch(function() {
1320
+ list.innerHTML = '<div class="hop-item hop-subtle">Failed to load</div>';
1321
+ });
1322
+ }
1323
+ })();
1324
+ </script>
1325
+ `;
1326
+ }
1327
+
1328
+ function shouldInjectSessionSwitcher(req) {
1329
+ if (!req || req.method !== 'GET') return false;
1330
+ const accept = String(req.headers?.accept || '').toLowerCase();
1331
+ if (!accept.includes('text/html')) return false;
1332
+ const dest = String(req.headers?.['sec-fetch-dest'] || '');
1333
+ if (dest && dest !== 'document') return false;
1334
+ return true;
1335
+ }
1336
+
1337
+ function buildSessionPath(displayName) {
1338
+ return `/s/${encodeURIComponent(displayName)}/`;
1339
+ }
1340
+
1341
+ function stripSessionParam(search) {
1342
+ if (!search) return '';
1343
+ const params = new URLSearchParams(search);
1344
+ params.delete('session');
1345
+ const qs = params.toString();
1346
+ return qs ? `?${qs}` : '';
1347
+ }
1348
+
1349
+ function parseSessionFromRequest(req, parsedUrl) {
1350
+ const pathname = parsedUrl.pathname || '';
1351
+ if (pathname.startsWith('/s/')) {
1352
+ const rest = pathname.slice(3);
1353
+ const parts = rest.split('/');
1354
+ const rawName = parts.shift() || '';
1355
+ let displayName = '';
1356
+ try {
1357
+ displayName = decodeURIComponent(rawName);
1358
+ } catch (e) {
1359
+ return null;
1360
+ }
1361
+ if (!SESSION_NAME_RE.test(displayName)) return null;
1362
+ const internalName = toInternalSessionName(displayName);
1363
+ const needsSlash = pathname === `/s/${rawName}`;
1364
+ const strippedPathname = '/' + parts.join('/');
1365
+ const normalizedPathname = strippedPathname === '/' ? '/' : strippedPathname;
1366
+ const strippedUrl = normalizedPathname + (parsedUrl.search || '');
1367
+ return {
1368
+ displayName,
1369
+ internalName,
1370
+ strippedPathname: normalizedPathname,
1371
+ strippedUrl,
1372
+ source: 'path',
1373
+ needsSlash
1374
+ };
1375
+ }
1376
+
1377
+ const sessionParam = parsedUrl.query.session;
1378
+ if (sessionParam && SESSION_NAME_RE.test(sessionParam)) {
1379
+ const displayName = sessionParam;
1380
+ const internalName = toInternalSessionName(displayName);
1381
+ const strippedPathname = parsedUrl.pathname || '/';
1382
+ const strippedUrl = strippedPathname + stripSessionParam(parsedUrl.search || '');
1383
+ return {
1384
+ displayName,
1385
+ internalName,
1386
+ strippedPathname,
1387
+ strippedUrl,
1388
+ source: 'query'
1389
+ };
1390
+ }
1391
+
1392
+ return null;
1393
+ }
1394
+
1395
+ function shouldRedirectToSessionPath(req) {
1396
+ if (!req || req.method !== 'GET') return false;
1397
+ const accept = String(req.headers?.accept || '').toLowerCase();
1398
+ return accept.includes('text/html');
1399
+ }
1400
+
1401
+ // Helper to get client IP (handles proxies)
1402
+ function getClientIp(req) {
1403
+ return req.headers['cf-connecting-ip'] ||
1404
+ req.headers['x-forwarded-for'] ||
1405
+ req.socket.remoteAddress ||
1406
+ 'unknown';
1407
+ }
1408
+
1409
+ function waitForPort(port, host = '127.0.0.1', timeoutMs = 4000, intervalMs = 120) {
1410
+ const start = Date.now();
1411
+ return new Promise((resolve) => {
1412
+ const tryConnect = () => {
1413
+ const socket = net.connect({ port, host });
1414
+ socket.once('connect', () => {
1415
+ socket.end();
1416
+ resolve(true);
1417
+ });
1418
+ socket.once('error', () => {
1419
+ socket.destroy();
1420
+ if (Date.now() - start >= timeoutMs) {
1421
+ resolve(false);
1422
+ return;
1423
+ }
1424
+ setTimeout(tryConnect, intervalMs);
1425
+ });
1426
+ };
1427
+ tryConnect();
1428
+ });
1429
+ }
1430
+
1431
+ function sendStartingPage(res, displayName) {
1432
+ setNoCache(res);
1433
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
1434
+ res.end(`<!DOCTYPE html>
1435
+ <html>
1436
+ <head>
1437
+ <meta charset="UTF-8">
1438
+ <meta name="viewport" content="width=device-width, initial-scale=1">
1439
+ <meta http-equiv="refresh" content="1">
1440
+ <title>Starting Session</title>
1441
+ <style>
1442
+ body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #fafafa; color: #18181b; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; }
1443
+ .box { text-align: center; padding: 2rem; max-width: 420px; }
1444
+ .spinner { width: 28px; height: 28px; border: 3px solid #e4e4e7; border-top-color: #7c3aed; border-radius: 50%; animation: spin 1s linear infinite; margin: 0 auto 1rem; }
1445
+ @keyframes spin { to { transform: rotate(360deg); } }
1446
+ a { color: #7c3aed; text-decoration: none; }
1447
+ </style>
1448
+ </head>
1449
+ <body>
1450
+ <div class="box">
1451
+ <div class="spinner"></div>
1452
+ <h2>Starting session…</h2>
1453
+ <p>This can take a few seconds. Refreshing automatically.</p>
1454
+ <p><a href="${buildSessionPath(displayName)}">Click to retry</a></p>
1455
+ </div>
1456
+ </body>
1457
+ </html>`);
1458
+ }
1459
+
1460
+ function setNoCache(res) {
1461
+ res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
1462
+ res.setHeader('Pragma', 'no-cache');
1463
+ res.setHeader('Expires', '0');
1464
+ }
1465
+
1466
+ function appendSetCookie(res, cookies) {
1467
+ const existing = res.getHeader('Set-Cookie');
1468
+ if (!existing) {
1469
+ res.setHeader('Set-Cookie', cookies);
1470
+ return;
1471
+ }
1472
+ const merged = Array.isArray(existing) ? existing.concat(cookies) : [existing].concat(cookies);
1473
+ res.setHeader('Set-Cookie', merged);
1474
+ }
1475
+
1476
+ function clearAuthCookies(res) {
1477
+ const expires = new Date(0);
1478
+ appendSetCookie(res, [
1479
+ cookie.serialize(SESSION_COOKIE_NAME, '', {
1480
+ httpOnly: true,
1481
+ secure: true,
1482
+ sameSite: 'lax',
1483
+ path: '/',
1484
+ expires,
1485
+ maxAge: 0
1486
+ }),
1487
+ cookie.serialize(SESSION_COOKIE_NAME, '', {
1488
+ httpOnly: true,
1489
+ secure: true,
1490
+ sameSite: 'lax',
1491
+ path: '/api',
1492
+ expires,
1493
+ maxAge: 0
1494
+ })
1495
+ ]);
1496
+ }
1497
+
1498
+ function escapeHtml(value) {
1499
+ return String(value)
1500
+ .replace(/&/g, '&amp;')
1501
+ .replace(/</g, '&lt;')
1502
+ .replace(/>/g, '&gt;')
1503
+ .replace(/\"/g, '&quot;')
1504
+ .replace(/'/g, '&#39;');
1505
+ }
1506
+
1507
+ let lastAuthAt = 0;
1508
+ function recordAuth(authEmitter) {
1509
+ lastAuthAt = Date.now();
1510
+ if (authEmitter) authEmitter.emit('authenticated');
1511
+ }
1512
+
1513
+ function startAuthProxy(authEmitter) {
1514
+ log(`[2/4] Starting Session Manager on port ${PROXY_PORT}...`, BOLD);
1515
+
1516
+ const server = http.createServer((req, res) => {
1517
+ const parsedUrl = url.parse(req.url, true);
1518
+ const cookies = cookie.parse(req.headers.cookie || '');
1519
+ const ip = getClientIp(req);
1520
+ const sessionInfo = parseSessionFromRequest(req, parsedUrl);
1521
+
1522
+ // Check auth via cookie OR Authorization header (internal hop-to-hop only)
1523
+ // Bearer token is read from state file by other hop processes on same machine
1524
+ const authHeader = req.headers.authorization;
1525
+ const tokenFromHeader = authHeader && authHeader.startsWith('Bearer ')
1526
+ ? authHeader.substring(7)
1527
+ : null;
1528
+ const isAuthenticated = cookies[SESSION_COOKIE_NAME] === SESSION_SECRET
1529
+ || tokenFromHeader === SESSION_SECRET;
1530
+
1531
+ // API: Auth status (used by local CLI to wait for remote login)
1532
+ if (parsedUrl.pathname === '/api/auth' && req.method === 'GET') {
1533
+ if (!isAuthenticated) {
1534
+ res.writeHead(401, { 'Content-Type': 'application/json' });
1535
+ res.end(JSON.stringify({ error: 'Unauthorized' }));
1536
+ return;
1537
+ }
1538
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1539
+ res.end(JSON.stringify({ lastAuthAt }));
1540
+ return;
1541
+ }
1542
+
1543
+ // --- LOGIN PAGE ---
1544
+ if (!isAuthenticated) {
1545
+ const passwordRequired = isPasswordEnabled();
1546
+ const storedPasswordHash = getStoredPassword();
1547
+
1548
+ // API Login endpoint - authenticates via password (if enabled) + TOTP
1549
+ if (req.method === 'POST' && parsedUrl.pathname === '/api/login') {
1550
+ const limit = checkRateLimit(ip);
1551
+ if (!limit.allowed) {
1552
+ res.writeHead(429, { 'Content-Type': 'application/json' });
1553
+ res.end(JSON.stringify({ success: false, error: `Too many attempts. Try again in ${limit.remaining}s.` }));
1554
+ return;
1555
+ }
1556
+
1557
+ let body = '';
1558
+ req.on('data', chunk => body += chunk);
1559
+ req.on('end', () => {
1560
+ try {
1561
+ const { totp, password } = JSON.parse(body);
1562
+
1563
+ // Check password if enabled
1564
+ if (passwordRequired && !verifyPassword(password || '', storedPasswordHash)) {
1565
+ recordFailure(ip);
1566
+ res.writeHead(401, { 'Content-Type': 'application/json' });
1567
+ res.end(JSON.stringify({ success: false, error: 'Invalid password' }));
1568
+ return;
1569
+ }
1570
+
1571
+ // Check TOTP
1572
+ if (authenticator.check(totp, AUTH_SECRET)) {
1573
+ resetFailure(ip);
1574
+ appendSetCookie(res, [
1575
+ cookie.serialize(SESSION_COOKIE_NAME, SESSION_SECRET, {
1576
+ httpOnly: true,
1577
+ secure: true,
1578
+ sameSite: 'lax',
1579
+ path: '/',
1580
+ maxAge: 60 * 60 * 24 * 7
1581
+ })
1582
+ ]);
1583
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1584
+ res.end(JSON.stringify({ success: true }));
1585
+ recordAuth(authEmitter);
1586
+ } else {
1587
+ recordFailure(ip);
1588
+ res.writeHead(401, { 'Content-Type': 'application/json' });
1589
+ res.end(JSON.stringify({ success: false, error: 'Invalid code' }));
1590
+ }
1591
+ } catch (e) {
1592
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1593
+ res.end(JSON.stringify({ success: false, error: 'Invalid request' }));
1594
+ }
1595
+ });
1596
+ return;
1597
+ }
1598
+
1599
+ // Web form login (support / and /s/<name>/)
1600
+ if (req.method === 'POST' && (parsedUrl.pathname === '/' || parsedUrl.pathname.startsWith('/s/'))) {
1601
+ const limit = checkRateLimit(ip);
1602
+ if (!limit.allowed) {
1603
+ res.writeHead(429, { 'Content-Type': 'text/html; charset=utf-8' });
1604
+ res.end(`Too many attempts. Try again in ${limit.remaining}s. <a href="/">Back</a>`);
1605
+ return;
1606
+ }
1607
+
1608
+ let body = '';
1609
+ req.on('data', chunk => body += chunk);
1610
+ req.on('end', () => {
1611
+ const params = new URLSearchParams(body);
1612
+ const token = params.get('token');
1613
+ const password = params.get('password');
1614
+ const requestedDisplayName = params.get('session') || sessionInfo?.displayName;
1615
+ const requestedInternalName = requestedDisplayName
1616
+ ? toInternalSessionName(requestedDisplayName)
1617
+ : null;
1618
+
1619
+ try {
1620
+ // Check password if enabled
1621
+ if (passwordRequired && !verifyPassword(password || '', storedPasswordHash)) {
1622
+ recordFailure(ip);
1623
+ res.writeHead(401, { 'Content-Type': 'text/html; charset=utf-8' });
1624
+ res.end('Invalid password. <a href="/">Try Again</a>');
1625
+ return;
1626
+ }
1627
+
1628
+ // Check TOTP
1629
+ if (authenticator.check(token, AUTH_SECRET)) {
1630
+ resetFailure(ip);
1631
+ appendSetCookie(res, [
1632
+ cookie.serialize(SESSION_COOKIE_NAME, SESSION_SECRET, {
1633
+ httpOnly: true,
1634
+ secure: true,
1635
+ sameSite: 'lax',
1636
+ path: '/',
1637
+ maxAge: 60 * 60 * 24 * 7
1638
+ })
1639
+ ]);
1640
+ const redirectTarget = requestedInternalName
1641
+ ? buildSessionPath(requestedDisplayName)
1642
+ : '/sessions';
1643
+ res.writeHead(302, { 'Location': redirectTarget });
1644
+ res.end();
1645
+ recordAuth(authEmitter);
1646
+ } else {
1647
+ recordFailure(ip);
1648
+ res.writeHead(401, { 'Content-Type': 'text/html; charset=utf-8' });
1649
+ res.end('Invalid Code. <a href="/">Try Again</a>');
1650
+ }
1651
+ } catch (e) {
1652
+ res.writeHead(401);
1653
+ res.end('Error verifying code');
1654
+ }
1655
+ });
1656
+ return;
1657
+ }
1658
+
1659
+ // Show login page - with password field if enabled
1660
+ const passwordFieldHtml = passwordRequired ? `
1661
+ <div style="margin-bottom: 1.5rem;">
1662
+ <input type="password" name="password" id="passwordField" placeholder="Password"
1663
+ style="width: 100%; padding: 0.8rem 1rem; font-size: 1rem; border: 1px solid #e4e4e7; border-radius: 8px; background: #fff; color: #18181b; box-sizing: border-box;"
1664
+ required>
1665
+ </div>
1666
+ <p style="color: #71717a; margin-bottom: 1rem; font-size: 0.9rem;">Then enter your authenticator code:</p>
1667
+ ` : '';
1668
+
1669
+ const passwordCheckJs = passwordRequired ? `
1670
+ const passwordField = document.getElementById('passwordField');
1671
+ if (!passwordField.value) {
1672
+ passwordField.focus();
1673
+ return;
1674
+ }
1675
+ ` : '';
1676
+
1677
+ const requestedDisplayName = sessionInfo?.displayName;
1678
+ const requestedInternalName = requestedDisplayName
1679
+ ? toInternalSessionName(requestedDisplayName)
1680
+ : null;
1681
+ const sessionFieldHtml = requestedInternalName
1682
+ ? `<input type="hidden" name="session" value="${escapeHtml(requestedDisplayName)}">`
1683
+ : '';
1684
+
1685
+ clearAuthCookies(res);
1686
+ setNoCache(res);
1687
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
1688
+ res.end(`
1689
+ <html>
1690
+ <head>
1691
+ <meta charset="UTF-8">
1692
+ <meta name="viewport" content="width=device-width, initial-scale=1">
1693
+ <title>Verify Identity</title>
1694
+ <style>
1695
+ body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #fafafa; color: #18181b; display: flex; justify-content: center; align-items: center; height: 100vh; margin:0; }
1696
+ .box { text-align: center; max-width: 400px; padding: 2rem; }
1697
+ h2 { margin-bottom: 1.5rem; font-weight: 600; font-size: 1.5rem; }
1698
+ .code-inputs { display: flex; gap: 8px; justify-content: center; margin-bottom: 2rem; }
1699
+ .code-inputs input {
1700
+ width: 44px; height: 56px;
1701
+ font-size: 24px; text-align: center;
1702
+ border: 1px solid #e4e4e7; border-radius: 8px;
1703
+ background: #fff; color: #18181b;
1704
+ -moz-appearance: textfield;
1705
+ }
1706
+ .code-inputs input:focus { outline: none; border-color: #7c3aed; box-shadow: 0 0 0 2px rgba(124, 58, 237, 0.1); }
1707
+ .code-inputs input::-webkit-outer-spin-button,
1708
+ .code-inputs input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
1709
+ input[type="password"]:focus { outline: none; border-color: #7c3aed; box-shadow: 0 0 0 2px rgba(124, 58, 237, 0.1); }
1710
+
1711
+ button { font-size: 1rem; padding: 0.8rem 2.5rem; background: #18181b; color: #fff; border: none; border-radius: 8px; cursor: pointer; font-weight: 500; width: 100%; transition: opacity 0.2s; }
1712
+ button:hover { opacity: 0.9; }
1713
+ .hidden-submit { position: absolute; left: -9999px; }
1714
+ </style>
1715
+ </head>
1716
+ <body>
1717
+ <div class="box">
1718
+ <h2>Security Check</h2>
1719
+ ${passwordRequired ? '<p style="color: #71717a; margin-bottom: 1rem; font-size: 0.9rem;">Enter your password:</p>' : '<p style="color: #71717a; margin-bottom: 2rem; font-size: 0.9rem;">Please enter the verification code from your authenticator app to continue.</p>'}
1720
+ <form method="POST" id="authForm">
1721
+ ${passwordFieldHtml}
1722
+ ${sessionFieldHtml}
1723
+ <div class="code-inputs" id="inputs">
1724
+ <input type="text" maxlength="1" inputmode="numeric" pattern="[0-9]*" required>
1725
+ <input type="text" maxlength="1" inputmode="numeric" pattern="[0-9]*" required>
1726
+ <input type="text" maxlength="1" inputmode="numeric" pattern="[0-9]*" required>
1727
+ <input type="text" maxlength="1" inputmode="numeric" pattern="[0-9]*" required>
1728
+ <input type="text" maxlength="1" inputmode="numeric" pattern="[0-9]*" required>
1729
+ <input type="text" maxlength="1" inputmode="numeric" pattern="[0-9]*" required>
1730
+ </div>
1731
+ <input type="hidden" name="token" id="fullToken">
1732
+ <button type="submit">Verify</button>
1733
+ </form>
1734
+ </div>
1735
+ <script>
1736
+ const inputs = document.querySelectorAll('.code-inputs input');
1737
+ const form = document.getElementById('authForm');
1738
+ const hidden = document.getElementById('fullToken');
1739
+
1740
+ inputs.forEach((input, i) => {
1741
+ input.addEventListener('input', e => {
1742
+ if (e.target.value.length === 1) {
1743
+ if (i < inputs.length - 1) inputs[i + 1].focus();
1744
+ else submit();
1745
+ }
1746
+ });
1747
+ input.addEventListener('keydown', e => {
1748
+ if (e.key === 'Backspace' && !e.target.value && i > 0) {
1749
+ inputs[i - 1].focus();
1750
+ }
1751
+ });
1752
+ input.addEventListener('paste', e => {
1753
+ e.preventDefault();
1754
+ const text = (e.clipboardData || window.clipboardData).getData('text');
1755
+ const digits = text.replace(/[^0-9]/g, '').slice(0, 6);
1756
+ if (digits.length === 0) return;
1757
+ digits.split('').forEach((char, index) => {
1758
+ if (inputs[index]) inputs[index].value = char;
1759
+ });
1760
+ if (digits.length === 6) submit();
1761
+ else inputs[Math.min(digits.length, 5)].focus();
1762
+ });
1763
+ });
1764
+
1765
+ function submit() {
1766
+ ${passwordCheckJs}
1767
+ const code = Array.from(inputs).map(i => i.value).join('');
1768
+ if (code.length === 6) {
1769
+ hidden.value = code;
1770
+ form.submit();
1771
+ }
1772
+ }
1773
+
1774
+ form.addEventListener('submit', e => {
1775
+ const code = Array.from(inputs).map(i => i.value).join('');
1776
+ hidden.value = code;
1777
+ });
1778
+ </script>
1779
+ </body>
1780
+ </html>
1781
+ `);
1782
+ return;
1783
+ }
1784
+
1785
+ // --- AUTHENTICATED ROUTES ---
1786
+ const sessionInfoAuth = parseSessionFromRequest(req, parsedUrl);
1787
+ const sessionName = sessionInfoAuth ? sessionInfoAuth.internalName : null;
1788
+ const displayName = sessionInfoAuth ? sessionInfoAuth.displayName : null;
1789
+ const strippedUrl = sessionInfoAuth ? sessionInfoAuth.strippedUrl : req.url;
1790
+ const strippedPathname = sessionInfoAuth ? sessionInfoAuth.strippedPathname : (parsedUrl.pathname || '/');
1791
+
1792
+ if (sessionInfoAuth && sessionInfoAuth.source === 'query' && shouldRedirectToSessionPath(req)) {
1793
+ res.writeHead(302, { 'Location': buildSessionPath(displayName) });
1794
+ res.end();
1795
+ return;
1796
+ }
1797
+ if (sessionInfoAuth && sessionInfoAuth.source === 'path' && sessionInfoAuth.needsSlash && shouldRedirectToSessionPath(req)) {
1798
+ const target = buildSessionPath(displayName) + (parsedUrl.search || '');
1799
+ res.writeHead(302, { 'Location': target });
1800
+ res.end();
1801
+ return;
1802
+ }
1803
+
1804
+ // API: Ping (triggers auth event for mobile login)
1805
+ if (parsedUrl.pathname === '/api/ping' && req.method === 'POST') {
1806
+ recordAuth(authEmitter);
1807
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1808
+ res.end(JSON.stringify({ ok: true, message: 'Mobile login' }));
1809
+ return;
1810
+ }
1811
+
1812
+ // API: List sessions
1813
+ if (parsedUrl.pathname === '/api/sessions' && req.method === 'GET') {
1814
+ const sessions = listAllSessionEntries().map(s => ({
1815
+ name: s.displayName,
1816
+ displayName: s.displayName,
1817
+ windows: s.windows,
1818
+ attached: s.attached,
1819
+ type: s.type,
1820
+ port: s.port
1821
+ }));
1822
+ const activeSessionNames = Object.keys(activeSessions).map(toDisplaySessionName);
1823
+ const startingSessionNames = Array.from(startingSessions).map(toDisplaySessionName);
1824
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1825
+ res.end(JSON.stringify({ sessions, active: activeSessionNames, starting: startingSessionNames }));
1826
+ return;
1827
+ }
1828
+
1829
+ // API: Create session
1830
+ if (parsedUrl.pathname === '/api/sessions' && req.method === 'POST') {
1831
+ let body = '';
1832
+ req.on('data', chunk => body += chunk);
1833
+ req.on('end', () => {
1834
+ try {
1835
+ let { name, type, port, cwd } = JSON.parse(body);
1836
+ const displayName = name;
1837
+ const internalName = toInternalSessionName(displayName);
1838
+ if (!internalName) {
1839
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1840
+ res.end(JSON.stringify({ error: 'Invalid session name' }));
1841
+ return;
1842
+ }
1843
+ type = (type || 'terminal').toLowerCase();
1844
+ if (type !== 'terminal' && type !== 'port') {
1845
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1846
+ res.end(JSON.stringify({ error: 'Invalid session type' }));
1847
+ return;
1848
+ }
1849
+ if (type === 'port') {
1850
+ const parsedPort = parseInt(port, 10);
1851
+ if (!Number.isInteger(parsedPort) || parsedPort < 1 || parsedPort > 65535) {
1852
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1853
+ res.end(JSON.stringify({ error: 'Invalid port' }));
1854
+ return;
1855
+ }
1856
+ if (hopSessionExists(internalName)) {
1857
+ res.writeHead(409, { 'Content-Type': 'application/json' });
1858
+ res.end(JSON.stringify({ error: 'Terminal session already exists with this name' }));
1859
+ return;
1860
+ }
1861
+ const existingConfig = getSessionConfig(internalName);
1862
+ if (existingConfig && existingConfig.type === 'terminal') {
1863
+ res.writeHead(409, { 'Content-Type': 'application/json' });
1864
+ res.end(JSON.stringify({ error: 'Terminal session already exists with this name' }));
1865
+ return;
1866
+ }
1867
+ setPortSessionConfig(internalName, parsedPort);
1868
+ } else {
1869
+ const existingConfig = getSessionConfig(internalName);
1870
+ if (existingConfig && existingConfig.type === 'port') {
1871
+ res.writeHead(409, { 'Content-Type': 'application/json' });
1872
+ res.end(JSON.stringify({ error: 'Port session already exists with this name' }));
1873
+ return;
1874
+ }
1875
+ getOrCreateSession(internalName, cwd);
1876
+ }
1877
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1878
+ res.end(JSON.stringify({ ok: true, name: displayName, type }));
1879
+ } catch (e) {
1880
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1881
+ res.end(JSON.stringify({ error: 'Invalid request body' }));
1882
+ }
1883
+ });
1884
+ return;
1885
+ }
1886
+
1887
+ // API: Rename session
1888
+ if (parsedUrl.pathname === '/api/sessions/rename' && req.method === 'POST') {
1889
+ let body = '';
1890
+ req.on('data', chunk => body += chunk);
1891
+ req.on('end', () => {
1892
+ let { oldName, newName } = JSON.parse(body);
1893
+ const oldDisplayName = oldName;
1894
+ const newDisplayName = newName;
1895
+ oldName = toInternalSessionName(oldDisplayName);
1896
+ newName = toInternalSessionName(newDisplayName);
1897
+ // Validate BOTH names to prevent command injection
1898
+ if (!oldName) {
1899
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1900
+ res.end(JSON.stringify({ error: 'Invalid old name' }));
1901
+ return;
1902
+ }
1903
+ if (!newName) {
1904
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1905
+ res.end(JSON.stringify({ error: 'Invalid new name' }));
1906
+ return;
1907
+ }
1908
+ if (getSessionConfig(newName) || hopSessionExists(newName)) {
1909
+ res.writeHead(409, { 'Content-Type': 'application/json' });
1910
+ res.end(JSON.stringify({ error: 'Session name already in use' }));
1911
+ return;
1912
+ }
1913
+
1914
+ // Update config for rename
1915
+ try {
1916
+ const config = getSessionConfig(oldName);
1917
+ if (config && config.type === 'port') {
1918
+ deleteSessionConfig(oldName);
1919
+ setSessionConfig(newName, { ...config, updatedAt: new Date().toISOString() });
1920
+ } else {
1921
+ if (!hopSessionExists(oldName)) {
1922
+ res.writeHead(404, { 'Content-Type': 'application/json' });
1923
+ res.end(JSON.stringify({ error: 'Session not found' }));
1924
+ return;
1925
+ }
1926
+ // Note: hay rooms can't be renamed - just update config tracking
1927
+ if (config) {
1928
+ deleteSessionConfig(oldName);
1929
+ setSessionConfig(newName, { ...config, type: 'terminal', updatedAt: new Date().toISOString() });
1930
+ } else {
1931
+ deleteSessionConfig(oldName);
1932
+ ensureTerminalSessionConfig(newName);
1933
+ }
1934
+ }
1935
+
1936
+ // Update internal map if it's managed by us
1937
+ if (activeSessions[oldName]) {
1938
+ activeSessions[newName] = activeSessions[oldName];
1939
+ delete activeSessions[oldName];
1940
+ }
1941
+
1942
+ // Track rename so styling intervals can follow
1943
+ sessionRenames[oldName] = newName;
1944
+
1945
+ // Rename log/exit files if they exist (optional cleanup)
1946
+
1947
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1948
+ res.end(JSON.stringify({ ok: true }));
1949
+ } catch (e) {
1950
+ res.writeHead(500, { 'Content-Type': 'application/json' });
1951
+ res.end(JSON.stringify({ error: 'Rename failed' }));
1952
+ }
1953
+ });
1954
+ return;
1955
+ }
1956
+
1957
+ // API: Delete session
1958
+ if (parsedUrl.pathname === '/api/sessions/delete' && req.method === 'POST') {
1959
+ let body = '';
1960
+ req.on('data', chunk => body += chunk);
1961
+ req.on('end', () => {
1962
+ let { name } = JSON.parse(body);
1963
+ name = toInternalSessionName(name);
1964
+ // Validate name to prevent command injection
1965
+ if (!name) {
1966
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1967
+ res.end(JSON.stringify({ error: 'Invalid session name' }));
1968
+ return;
1969
+ }
1970
+
1971
+ try {
1972
+ const config = getSessionConfig(name);
1973
+ if (config && config.type === 'port') {
1974
+ deleteSessionConfig(name);
1975
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1976
+ res.end(JSON.stringify({ ok: true }));
1977
+ return;
1978
+ }
1979
+
1980
+ const exists = hopSessionExists(name);
1981
+ if (!exists && !config) {
1982
+ res.writeHead(404, { 'Content-Type': 'application/json' });
1983
+ res.end(JSON.stringify({ error: 'Session not found' }));
1984
+ return;
1985
+ }
1986
+
1987
+ // Close the hay room if it exists
1988
+ if (hayRooms.hasRoom(name)) {
1989
+ hayRooms.closeRoom(name);
1990
+ }
1991
+
1992
+ // Clean up internal tracking
1993
+ if (activeSessions[name]) {
1994
+ delete activeSessions[name];
1995
+ }
1996
+
1997
+ // Always remove session config if present
1998
+ if (config) {
1999
+ deleteSessionConfig(name);
2000
+ }
2001
+
2002
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2003
+ res.end(JSON.stringify({ ok: true }));
2004
+ } catch (e) {
2005
+ res.writeHead(500, { 'Content-Type': 'application/json' });
2006
+ res.end(JSON.stringify({ error: 'Delete failed' }));
2007
+ }
2008
+ });
2009
+ return;
2010
+ }
2011
+
2012
+ // If no session selected, show session picker
2013
+ if (parsedUrl.pathname === '/sessions') {
2014
+ const sessionsFile = path.join(__dirname, 'sessions.html');
2015
+ let sessionsHtml = '<!-- sessions.html not found -->';
2016
+ try {
2017
+ sessionsHtml = fs.readFileSync(sessionsFile, 'utf8');
2018
+ } catch (e) {
2019
+ log(`Warning: sessions.html not found`, YELLOW);
2020
+ }
2021
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
2022
+ res.end(sessionsHtml);
2023
+ return;
2024
+ }
2025
+ if (!sessionName) {
2026
+ const sessionsFile = path.join(__dirname, 'sessions.html');
2027
+ let sessionsHtml = '<!-- sessions.html not found -->';
2028
+ try {
2029
+ sessionsHtml = fs.readFileSync(sessionsFile, 'utf8');
2030
+ } catch (e) {
2031
+ log(`Warning: sessions.html not found`, YELLOW);
2032
+ }
2033
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
2034
+ res.end(sessionsHtml);
2035
+ return;
2036
+ }
2037
+ // --- CHECK FOR iOS - Intercept and inject virtual keyboard ---
2038
+ const userAgent = req.headers['user-agent'] || '';
2039
+ const isIOS = /iPad|iPhone|iPod/.test(userAgent);
2040
+ const isMobile = isIOS || /Android/i.test(userAgent);
2041
+ const sessionConfig = getSessionConfig(sessionName);
2042
+ if (sessionConfig && sessionConfig.type === 'port') {
2043
+ const targetPort = sessionConfig.port;
2044
+ if (!targetPort) {
2045
+ res.writeHead(500);
2046
+ res.end('Port session missing target port.');
2047
+ return;
2048
+ }
2049
+ const shouldInject = isMobile && shouldInjectSessionSwitcher(req);
2050
+ if (shouldInject) {
2051
+ waitForPort(targetPort).then((ready) => {
2052
+ if (!ready) {
2053
+ sendStartingPage(res, displayName);
2054
+ return;
2055
+ }
2056
+ const headers = { ...req.headers };
2057
+ delete headers['accept-encoding'];
2058
+ const proxyReq = http.request({
2059
+ hostname: '127.0.0.1',
2060
+ port: targetPort,
2061
+ method: req.method,
2062
+ path: strippedUrl,
2063
+ headers
2064
+ }, (proxyRes) => {
2065
+ const contentType = String(proxyRes.headers['content-type'] || '').toLowerCase();
2066
+ const contentEncoding = String(proxyRes.headers['content-encoding'] || '').toLowerCase();
2067
+ const isHtml = contentType.includes('text/html');
2068
+ const isPlain = contentType.includes('text/plain');
2069
+ const canModify = (isHtml || isPlain) && (!contentEncoding || contentEncoding === 'identity');
2070
+ if (!canModify) {
2071
+ res.writeHead(proxyRes.statusCode || 200, proxyRes.headers);
2072
+ proxyRes.pipe(res);
2073
+ return;
2074
+ }
2075
+ let chunks = [];
2076
+ proxyRes.on('data', chunk => chunks.push(chunk));
2077
+ proxyRes.on('end', () => {
2078
+ let html = Buffer.concat(chunks).toString('utf8');
2079
+ const overlay = getSessionSwitcherOverlay();
2080
+ if (isPlain && !html.includes('<html')) {
2081
+ const body = `<pre style="white-space:pre-wrap;font:14px/1.4 ui-monospace,Menlo,Monaco,Consolas,monospace;margin:24px;color:#18181b;">${escapeHtml(html)}</pre>`;
2082
+ html = `<!DOCTYPE html><html><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1"></head><body>${body}${overlay}</body></html>`;
2083
+ } else if (html.includes('</body>')) {
2084
+ html = html.split('</body>').join(overlay + '</body>');
2085
+ } else {
2086
+ html += overlay;
2087
+ }
2088
+ const outHeaders = { ...proxyRes.headers };
2089
+ delete outHeaders['content-length'];
2090
+ delete outHeaders['content-encoding'];
2091
+ if (isPlain) {
2092
+ outHeaders['content-type'] = 'text/html; charset=utf-8';
2093
+ }
2094
+ res.writeHead(proxyRes.statusCode || 200, outHeaders);
2095
+ res.end(html);
2096
+ });
2097
+ });
2098
+ proxyReq.on('error', () => {
2099
+ sendStartingPage(res, displayName);
2100
+ });
2101
+ req.pipe(proxyReq);
2102
+ });
2103
+ return;
2104
+ }
2105
+
2106
+ waitForPort(targetPort).then((ready) => {
2107
+ if (!ready) {
2108
+ sendStartingPage(res, displayName);
2109
+ return;
2110
+ }
2111
+ const proxy = httpProxy.createProxyServer({ target: `http://127.0.0.1:${targetPort}`, ws: false });
2112
+ const originalUrl = req.url;
2113
+ req.url = strippedUrl;
2114
+ proxy.web(req, res, {}, () => {
2115
+ sendStartingPage(res, displayName);
2116
+ });
2117
+ req.url = originalUrl;
2118
+ });
2119
+ return;
2120
+ }
2121
+
2122
+ // Ensure session exists (creates hay room if needed)
2123
+ try {
2124
+ getOrCreateSession(sessionName);
2125
+ } catch (e) {
2126
+ res.writeHead(500);
2127
+ res.end('Failed to create session');
2128
+ return;
2129
+ }
2130
+
2131
+ // Serve hay web client (has built-in mobile keyboard)
2132
+ const reqPath = strippedPathname || '/';
2133
+
2134
+ // Map request path to file
2135
+ let filePath;
2136
+ if (reqPath === '/' || reqPath === '') {
2137
+ filePath = path.join(HAY_WEB_DIR, 'index.html');
2138
+ } else if (reqPath.startsWith('/assets/')) {
2139
+ filePath = path.join(HAY_WEB_DIR, reqPath);
2140
+ } else {
2141
+ // Fallback to index.html for SPA routing
2142
+ filePath = path.join(HAY_WEB_DIR, 'index.html');
2143
+ }
2144
+
2145
+ // Check if file exists
2146
+ if (!fs.existsSync(filePath)) {
2147
+ res.writeHead(404);
2148
+ res.end('Not Found');
2149
+ return;
2150
+ }
2151
+
2152
+ // Determine content type
2153
+ const ext = path.extname(filePath).toLowerCase();
2154
+ const contentTypes = {
2155
+ '.html': 'text/html; charset=utf-8',
2156
+ '.js': 'application/javascript',
2157
+ '.css': 'text/css',
2158
+ '.json': 'application/json',
2159
+ '.png': 'image/png',
2160
+ '.jpg': 'image/jpeg',
2161
+ '.svg': 'image/svg+xml',
2162
+ '.ico': 'image/x-icon'
2163
+ };
2164
+ const contentType = contentTypes[ext] || 'application/octet-stream';
2165
+
2166
+ // Read and serve the file
2167
+ let content = fs.readFileSync(filePath);
2168
+
2169
+ // For HTML files, inject config and fix asset paths
2170
+ if (ext === '.html') {
2171
+ let html = content.toString('utf8');
2172
+ const sessionBasePath = `/s/${displayName}/`;
2173
+
2174
+ // Inject hop session configuration
2175
+ const injectScript = `<script>
2176
+ // Hop session configuration - use /ws endpoint with room as query param
2177
+ window.__HOP_SESSION__ = ${JSON.stringify({
2178
+ room: displayName,
2179
+ name: 'Guest',
2180
+ wsUrl: '/ws'
2181
+ })};
2182
+ </script>`;
2183
+ html = html.replace('<head>', '<head>' + injectScript);
2184
+
2185
+ // Convert absolute asset paths to relative (remove leading /)
2186
+ html = html.replace(/src="\/assets\//g, 'src="assets/');
2187
+ html = html.replace(/href="\/assets\//g, 'href="assets/');
2188
+
2189
+ content = Buffer.from(html, 'utf8');
2190
+ }
2191
+
2192
+ // No caching in dev mode (when serving from hay)
2193
+ const isDevMode = !HAY_WEB_DIR.includes('hay-web');
2194
+ const headers = {
2195
+ 'Content-Type': contentType,
2196
+ 'Content-Length': content.length
2197
+ };
2198
+ if (isDevMode) {
2199
+ headers['Cache-Control'] = 'no-cache, no-store, must-revalidate';
2200
+ headers['Pragma'] = 'no-cache';
2201
+ headers['Expires'] = '0';
2202
+ }
2203
+ res.writeHead(200, headers);
2204
+ res.end(content);
2205
+ });
2206
+
2207
+ // WebSocket upgrade for terminal
2208
+ server.on('upgrade', (req, socket, head) => {
2209
+ const parsedUrl = url.parse(req.url, true);
2210
+ const cookies = cookie.parse(req.headers.cookie || '');
2211
+
2212
+ // Auth via cookie, Bearer token, or query param (for local CLI connections)
2213
+ const authHeader = req.headers.authorization || '';
2214
+ const bearerToken = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : null;
2215
+ const tokenFromQuery = parsedUrl.query.token;
2216
+ const isAuthenticated = cookies[SESSION_COOKIE_NAME] === SESSION_SECRET ||
2217
+ bearerToken === SESSION_SECRET ||
2218
+ tokenFromQuery === SESSION_SECRET;
2219
+
2220
+ if (!isAuthenticated) {
2221
+ socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
2222
+ socket.destroy();
2223
+ return;
2224
+ }
2225
+
2226
+ // Support both /ws?room=X (hay CLI format) and /s/{session}/ws (legacy)
2227
+ let sessionName = null;
2228
+ let sessionInfo = null;
2229
+
2230
+ if (parsedUrl.pathname === '/ws') {
2231
+ // Hay CLI format: /ws?room=X
2232
+ const roomParam = parsedUrl.query.room;
2233
+ if (roomParam && typeof roomParam === 'string') {
2234
+ sessionName = toInternalSessionName(roomParam);
2235
+ }
2236
+ } else {
2237
+ // Legacy format: /s/{session}/ws
2238
+ sessionInfo = parseSessionFromRequest(req, parsedUrl);
2239
+ sessionName = sessionInfo ? sessionInfo.internalName : null;
2240
+ }
2241
+
2242
+ if (!sessionName) {
2243
+ socket.write('HTTP/1.1 404 Session Not Found\r\n\r\n');
2244
+ socket.destroy();
2245
+ return;
2246
+ }
2247
+
2248
+ const sessionConfig = getSessionConfig(sessionName);
2249
+ if (sessionConfig && sessionConfig.type === 'port') {
2250
+ const targetPort = sessionConfig.port;
2251
+ if (!targetPort) {
2252
+ socket.write('HTTP/1.1 502 Bad Gateway\r\n\r\n');
2253
+ socket.destroy();
2254
+ return;
2255
+ }
2256
+ const proxy = httpProxy.createProxyServer({ target: `http://127.0.0.1:${targetPort}`, ws: true });
2257
+ const originalUrl = req.url;
2258
+ req.url = sessionInfo ? sessionInfo.strippedUrl : req.url;
2259
+ proxy.ws(req, socket, head);
2260
+ req.url = originalUrl;
2261
+ return;
2262
+ }
2263
+
2264
+ // Get or create hay session
2265
+ let session = activeSessions[sessionName];
2266
+ if (!session) {
2267
+ try {
2268
+ session = getOrCreateSession(sessionName);
2269
+ } catch (e) {
2270
+ socket.write('HTTP/1.1 500 Server Error\r\n\r\n');
2271
+ socket.destroy();
2272
+ return;
2273
+ }
2274
+ }
2275
+
2276
+ // Create WebSocket server for this upgrade
2277
+ const wss = new WebSocket.Server({ noServer: true });
2278
+ wss.handleUpgrade(req, socket, head, (ws) => {
2279
+ // Parse query params for client info
2280
+ const wsUrl = new URL(req.url, 'http://localhost');
2281
+ const clientName = hay.sanitizeName(wsUrl.searchParams.get('name') || 'Anonymous');
2282
+ const cols = parseInt(wsUrl.searchParams.get('cols') || '80', 10);
2283
+ const rows = parseInt(wsUrl.searchParams.get('rows') || '24', 10);
2284
+
2285
+ const clientId = hay.generateClientId();
2286
+ const colorIndex = Math.floor(Math.random() * 1000);
2287
+
2288
+ // Attach client to hay room
2289
+ session.room.attachClient(
2290
+ { id: clientId, name: clientName, colorIndex, cols, rows },
2291
+ hay.createSocketAdapter(ws)
2292
+ );
2293
+ });
2294
+ });
2295
+
2296
+ server.listen(PROXY_PORT, '127.0.0.1');
2297
+ return server;
2298
+ }
2299
+
2300
+ // Tunnel manager - handles auto-restart and URL updates
2301
+ let currentTunnelProc = null;
2302
+ let currentPublicUrl = null;
2303
+ let shuttingDown = false; // Prevent restart during cleanup
2304
+ const tunnelEmitter = new EventEmitter();
2305
+ let CLIENT_TUNNEL_CONFIG = null; // { tunnelId, credentialsPath, hostname, configDir }
2306
+
2307
+ function startTunnel(isRestart = false) {
2308
+ const domainConfig = getDomainConfig();
2309
+ const useNamedTunnel = domainConfig && domainConfig.tunnelName && domainConfig.hostname;
2310
+
2311
+ if (!isRestart) {
2312
+ if (useNamedTunnel) {
2313
+ log(`[3/4] Opening Cloudflare Tunnel (${domainConfig.hostname})...`, BOLD);
2314
+ } else {
2315
+ log(`[3/4] Opening Cloudflare Tunnel...`, BOLD);
2316
+ }
2317
+ } else {
2318
+ log(`[Tunnel] Reconnecting...`, YELLOW);
2319
+ }
2320
+
2321
+ // Use named tunnel if configured, otherwise quick tunnel
2322
+ let tunnel;
2323
+ let publicUrl;
2324
+
2325
+ const localTunnelHost = '127.0.0.1';
2326
+
2327
+ if (useNamedTunnel) {
2328
+ // Named tunnel with custom domain - URL is fixed
2329
+ publicUrl = `https://${domainConfig.hostname}`;
2330
+
2331
+ // Create config file for the tunnel
2332
+ const tunnelConfigPath = path.join(HOME_DIR, '.tunnel-config.yml');
2333
+ const tunnelConfig = `
2334
+ url: http://${localTunnelHost}:${PROXY_PORT}
2335
+ tunnel: ${domainConfig.tunnelName}
2336
+ `;
2337
+ fs.writeFileSync(tunnelConfigPath, tunnelConfig);
2338
+
2339
+ tunnel = spawn('cloudflared', [
2340
+ 'tunnel',
2341
+ '--config', tunnelConfigPath,
2342
+ 'run', domainConfig.tunnelName
2343
+ ]);
2344
+ } else {
2345
+ // Quick tunnel - URL is random
2346
+ tunnel = spawn('cloudflared', ['tunnel', '--url', `http://${localTunnelHost}:${PROXY_PORT}`]);
2347
+ }
2348
+
2349
+ tunnel.on('close', (code) => {
2350
+ if (shuttingDown) return; // Don't restart during cleanup
2351
+ if (code !== null) {
2352
+ log(`[Tunnel] Disconnected (code ${code}). Restarting in 3s...`, YELLOW);
2353
+ setTimeout(() => {
2354
+ if (!shuttingDown) startTunnelWithRestart();
2355
+ }, 3000);
2356
+ }
2357
+ });
2358
+
2359
+ tunnel.on('error', (err) => {
2360
+ if (shuttingDown) return; // Don't restart during cleanup
2361
+ log(`[Tunnel] Error: ${err.message}. Restarting in 3s...`, RED);
2362
+ setTimeout(() => {
2363
+ if (!shuttingDown) startTunnelWithRestart();
2364
+ }, 3000);
2365
+ });
2366
+
2367
+ // For named tunnels, resolve immediately with the known URL
2368
+ if (useNamedTunnel) {
2369
+ currentTunnelProc = tunnel;
2370
+ currentPublicUrl = publicUrl;
2371
+
2372
+ // Wait a moment for tunnel to establish, then resolve
2373
+ return new Promise((resolve) => {
2374
+ let resolved = false;
2375
+
2376
+ // Listen for successful connection or error
2377
+ tunnel.stderr.on('data', (data) => {
2378
+ const output = data.toString();
2379
+ // Look for connection established message
2380
+ if (!resolved && (output.includes('Registered tunnel connection') || output.includes('Connection'))) {
2381
+ resolved = true;
2382
+ if (isRestart) {
2383
+ tunnelEmitter.emit('urlChanged', publicUrl);
2384
+ log(`[Tunnel] Reconnected: ${publicUrl}`, GREEN);
2385
+ }
2386
+ resolve({ tunnel, publicUrl });
2387
+ }
2388
+ });
2389
+
2390
+ // Timeout fallback - assume connected after 5 seconds
2391
+ setTimeout(() => {
2392
+ if (!resolved) {
2393
+ resolved = true;
2394
+ if (isRestart) {
2395
+ tunnelEmitter.emit('urlChanged', publicUrl);
2396
+ }
2397
+ resolve({ tunnel, publicUrl });
2398
+ }
2399
+ }, 5000);
2400
+ });
2401
+ }
2402
+
2403
+ // For quick tunnels, parse URL from output
2404
+ let urlFound = false;
2405
+ return new Promise((resolve) => {
2406
+ tunnel.stderr.on('data', (data) => {
2407
+ if (urlFound) return;
2408
+ const output = data.toString();
2409
+ const match = output.match(/https:\/\/[a-zA-Z0-9-]+\.trycloudflare\.com/);
2410
+
2411
+ if (match) {
2412
+ const parsedUrl = match[0];
2413
+ if (parsedUrl.includes('api.trycloudflare.com')) return;
2414
+
2415
+ urlFound = true;
2416
+ currentTunnelProc = tunnel;
2417
+ currentPublicUrl = parsedUrl;
2418
+
2419
+ // Emit URL change event for restarts
2420
+ if (isRestart) {
2421
+ tunnelEmitter.emit('urlChanged', parsedUrl);
2422
+ log(`[Tunnel] New URL: ${parsedUrl}`, GREEN);
2423
+ }
2424
+
2425
+ resolve({ tunnel, publicUrl: parsedUrl });
2426
+ }
2427
+ });
2428
+ });
2429
+ }
2430
+
2431
+ function startClientTunnel(isRestart = false) {
2432
+ if (!CLIENT_TUNNEL_CONFIG) {
2433
+ throw new Error('Client tunnel config missing');
2434
+ }
2435
+
2436
+ const { tunnelId, credentialsPath, hostname, configDir } = CLIENT_TUNNEL_CONFIG;
2437
+ const publicUrl = hostname ? `https://${hostname}` : null;
2438
+
2439
+ if (!isRestart) {
2440
+ log(`[3/4] Opening Cloudflare Tunnel${hostname ? ` (${hostname})` : ''}...`, BOLD);
2441
+ } else {
2442
+ log(`[Tunnel] Reconnecting...`, YELLOW);
2443
+ }
2444
+
2445
+ const tunnelConfigPath = path.join(configDir, 'tunnel-config.yml');
2446
+ const tunnelConfig = `
2447
+ url: http://127.0.0.1:${PROXY_PORT}
2448
+ tunnel: ${tunnelId}
2449
+ credentials-file: ${path.resolve(credentialsPath)}
2450
+ `;
2451
+ writePrivateFile(tunnelConfigPath, tunnelConfig);
2452
+
2453
+ const tunnel = spawn('cloudflared', ['tunnel', '--config', tunnelConfigPath, 'run'], {
2454
+ stdio: ['ignore', 'pipe', 'pipe']
2455
+ });
2456
+
2457
+ tunnel.on('close', (code) => {
2458
+ if (shuttingDown) return; // Don't restart during cleanup
2459
+ if (code !== null) {
2460
+ log(`[Tunnel] Disconnected (code ${code}). Restarting in 3s...`, YELLOW);
2461
+ setTimeout(() => {
2462
+ if (!shuttingDown) startTunnelWithRestart();
2463
+ }, 3000);
2464
+ }
2465
+ });
2466
+
2467
+ tunnel.on('error', (err) => {
2468
+ if (shuttingDown) return; // Don't restart during cleanup
2469
+ log(`[Tunnel] Error: ${err.message}. Restarting in 3s...`, RED);
2470
+ setTimeout(() => {
2471
+ if (!shuttingDown) startTunnelWithRestart();
2472
+ }, 3000);
2473
+ });
2474
+
2475
+ currentTunnelProc = tunnel;
2476
+ currentPublicUrl = publicUrl;
2477
+
2478
+ return new Promise((resolve) => {
2479
+ let resolved = false;
2480
+
2481
+ tunnel.stderr.on('data', (data) => {
2482
+ const output = data.toString();
2483
+ if (!resolved && (output.includes('Registered tunnel connection') || output.includes('Connection'))) {
2484
+ resolved = true;
2485
+ if (isRestart) {
2486
+ if (publicUrl) {
2487
+ tunnelEmitter.emit('urlChanged', publicUrl);
2488
+ log(`[Tunnel] Reconnected: ${publicUrl}`, GREEN);
2489
+ } else {
2490
+ log(`[Tunnel] Reconnected.`, GREEN);
2491
+ }
2492
+ }
2493
+ resolve({ tunnel, publicUrl });
2494
+ }
2495
+ });
2496
+
2497
+ // Timeout fallback - assume connected after 5 seconds
2498
+ setTimeout(() => {
2499
+ if (!resolved) {
2500
+ resolved = true;
2501
+ if (isRestart && publicUrl) {
2502
+ tunnelEmitter.emit('urlChanged', publicUrl);
2503
+ }
2504
+ resolve({ tunnel, publicUrl });
2505
+ }
2506
+ }, 5000);
2507
+ });
2508
+ }
2509
+
2510
+ async function startTunnelWithRestart() {
2511
+ try {
2512
+ const { tunnel, publicUrl } = CLIENT_TUNNEL_CONFIG
2513
+ ? await startClientTunnel(true)
2514
+ : await startTunnel(true);
2515
+
2516
+ // Update state file
2517
+ if (fs.existsSync(STATE_FILE)) {
2518
+ try {
2519
+ const state = JSON.parse(fs.readFileSync(STATE_FILE, 'utf8'));
2520
+ state.url = publicUrl;
2521
+ writePrivateFile(STATE_FILE, JSON.stringify(state));
2522
+ } catch (e) { }
2523
+ }
2524
+
2525
+ // Sessions are managed by hay, no tmux styling needed
2526
+ } catch (e) {
2527
+ log(`[Tunnel] Failed to restart: ${e.message}. Retrying in 5s...`, RED);
2528
+ setTimeout(startTunnelWithRestart, 5000);
2529
+ }
2530
+ }
2531
+
2532
+ function waitForStart(authEmitter, publicUrl, label) {
2533
+ return new Promise((resolve) => {
2534
+ console.clear();
2535
+ log(`\n==================================================`, MAGENTA);
2536
+ log(` >>> HOP - TUNNEL SHELL <<<`, BOLD + MAGENTA);
2537
+ log(`==================================================\n`, MAGENTA);
2538
+
2539
+ log(`TUNNEL URL:`, BOLD + GREEN);
2540
+ log(` ${publicUrl}`, GREEN);
2541
+ log(` Scan to open on mobile:`, RESET);
2542
+
2543
+ // Simple URL QR code - scan with any camera app
2544
+ qrcode.generate(publicUrl, { small: true });
2545
+
2546
+ log(`\n--------------------------------------------------`, RESET);
2547
+
2548
+ // Standardized TOTP QR Code using the helper function
2549
+ showQrCode(label || 'Hop');
2550
+
2551
+ log(`\n--------------------------------------------------`, RESET);
2552
+
2553
+ const readline = require('readline');
2554
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
2555
+
2556
+ log(`\n[READY]`, GREEN + BOLD);
2557
+ log(`Press ENTER to launch local session, or login remotely...`, RESET);
2558
+
2559
+ let resolved = false;
2560
+
2561
+ const finish = (reason) => {
2562
+ if (resolved) return;
2563
+ resolved = true;
2564
+ rl.close();
2565
+ log(`\n${reason}. Launching local session...`, GREEN);
2566
+ setTimeout(resolve, 800);
2567
+ };
2568
+
2569
+ rl.question('', () => finish('Manual Start'));
2570
+ authEmitter.once('authenticated', () => finish('Remote Login'));
2571
+ });
2572
+ }
2573
+
2574
+ function printStartupInfo(publicUrl, label) {
2575
+ console.clear();
2576
+ log(`\n==================================================`, MAGENTA);
2577
+ log(` >>> HOP - TUNNEL SHELL <<<`, BOLD + MAGENTA);
2578
+ log(`==================================================\n`, MAGENTA);
2579
+
2580
+ log(`TUNNEL URL:`, BOLD + GREEN);
2581
+ log(` ${publicUrl}`, GREEN);
2582
+ log(` Scan to open on mobile:`, RESET);
2583
+
2584
+ qrcode.generate(publicUrl, { small: true });
2585
+
2586
+ log(`\n--------------------------------------------------`, RESET);
2587
+ showQrCode(label || 'Hop');
2588
+ log(`\n--------------------------------------------------`, RESET);
2589
+ log(`\n[READY]`, GREEN + BOLD);
2590
+ log(`Daemon started. Run ${CYAN}hop${RESET} to attach a local session.`, RESET);
2591
+ }
2592
+
2593
+ function waitForStartPolling(state, label) {
2594
+ return new Promise((resolve) => {
2595
+ console.clear();
2596
+ log(`\n==================================================`, MAGENTA);
2597
+ log(` >>> HOP - TUNNEL SHELL <<<`, BOLD + MAGENTA);
2598
+ log(`==================================================\n`, MAGENTA);
2599
+
2600
+ log(`TUNNEL URL:`, BOLD + GREEN);
2601
+ log(` ${state.url}`, GREEN);
2602
+ log(` Scan to open on mobile:`, RESET);
2603
+ qrcode.generate(state.url, { small: true });
2604
+
2605
+ log(`\n--------------------------------------------------`, RESET);
2606
+ showQrCode(label || 'Hop');
2607
+ log(`\n--------------------------------------------------`, RESET);
2608
+
2609
+ const readline = require('readline');
2610
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
2611
+
2612
+ log(`\n[READY]`, GREEN + BOLD);
2613
+ log(`Press ENTER to launch local session, or login remotely...`, RESET);
2614
+
2615
+ let resolved = false;
2616
+ const startedAt = Date.now();
2617
+ let pollTimer = null;
2618
+
2619
+ const finish = (reason) => {
2620
+ if (resolved) return;
2621
+ resolved = true;
2622
+ if (pollTimer) clearInterval(pollTimer);
2623
+ rl.close();
2624
+ log(`\n${reason}. Launching local session...`, GREEN);
2625
+ setTimeout(resolve, 800);
2626
+ };
2627
+
2628
+ rl.question('', () => finish('Manual Start'));
2629
+
2630
+ const poll = () => {
2631
+ if (resolved) return;
2632
+ const opts = {
2633
+ hostname: '127.0.0.1',
2634
+ port: state.port,
2635
+ path: '/api/auth',
2636
+ method: 'GET',
2637
+ headers: { 'Authorization': `Bearer ${state.sessionSecret}` }
2638
+ };
2639
+ const req = http.request(opts, res => {
2640
+ let data = '';
2641
+ res.on('data', c => data += c);
2642
+ res.on('end', () => {
2643
+ if (res.statusCode !== 200) return;
2644
+ try {
2645
+ const payload = JSON.parse(data);
2646
+ if (payload.lastAuthAt && payload.lastAuthAt >= startedAt) {
2647
+ finish('Remote Login');
2648
+ }
2649
+ } catch (e) { }
2650
+ });
2651
+ });
2652
+ req.on('error', () => { });
2653
+ req.end();
2654
+ };
2655
+
2656
+ pollTimer = setInterval(poll, 1000);
2657
+ poll();
2658
+ });
2659
+ }
2660
+
2661
+ // Generate QR code and prompt for verification
2662
+ function showQrCode(label = 'Hop') {
2663
+ log(`[3/4] Setup Authenticator...`, BOLD);
2664
+
2665
+ // Manually construct URL for minimalist "Hop" label without username
2666
+ const otpauth = `otpauth://totp/${encodeURIComponent(label)}?secret=${AUTH_SECRET}&issuer=${encodeURIComponent(label)}`;
2667
+
2668
+ qrcode.generate(otpauth, { small: true });
2669
+
2670
+ log(`Scan this QR code with your Authenticator app.`, CYAN);
2671
+ }
2672
+
2673
+ function startDaemon() {
2674
+ const node = process.execPath;
2675
+ const script = __filename;
2676
+ const child = spawn(node, [script, '--daemon'], {
2677
+ detached: true,
2678
+ stdio: 'ignore',
2679
+ env: process.env
2680
+ });
2681
+ child.unref();
2682
+ }
2683
+
2684
+ function waitForDaemonReady(timeoutMs = 20000) {
2685
+ return new Promise((resolve, reject) => {
2686
+ const start = Date.now();
2687
+ const check = () => {
2688
+ const state = isServerRunning();
2689
+ if (state) return resolve(state);
2690
+ if (Date.now() - start > timeoutMs) {
2691
+ return reject(new Error('Timed out waiting for hop daemon'));
2692
+ }
2693
+ setTimeout(check, 250);
2694
+ };
2695
+ check();
2696
+ });
2697
+ }
2698
+
2699
+ async function startLocalSession(proxyServer, tunnelProc, publicUrl) {
2700
+ log(`[4/4] Attaching to session...`, BOLD);
2701
+
2702
+ // Check if there's already an active session (from remote login)
2703
+ const existingSessions = listActiveSessions();
2704
+ let displayName;
2705
+ let defaultSessionName;
2706
+
2707
+ let sessionCwd;
2708
+ if (existingSessions.length > 0) {
2709
+ // Connect to the first existing session (remote user already created it)
2710
+ defaultSessionName = existingSessions[0].name;
2711
+ displayName = toDisplaySessionName(defaultSessionName);
2712
+ log(`Connecting to existing session: ${displayName}`, RESET);
2713
+ } else {
2714
+ // Create new session - find next available name
2715
+ const cwdName = path.basename(process.cwd()).replace(/[^a-zA-Z0-9_-]/g, '-');
2716
+ const existingNames = new Set(existingSessions.map(s => s.name));
2717
+ let counter = 1;
2718
+ displayName = `${cwdName}-${counter}`;
2719
+ defaultSessionName = toInternalSessionName(displayName);
2720
+ while (existingNames.has(defaultSessionName)) {
2721
+ counter++;
2722
+ displayName = `${cwdName}-${counter}`;
2723
+ defaultSessionName = toInternalSessionName(displayName);
2724
+ }
2725
+ log(`Starting session: ${displayName}`, RESET);
2726
+ sessionCwd = process.cwd();
2727
+ getOrCreateSession(defaultSessionName, sessionCwd);
2728
+ }
2729
+
2730
+ const cleanup = () => {
2731
+ if (shuttingDown) return;
2732
+ shuttingDown = true; // Prevent tunnel auto-restart
2733
+ log(`Cleaning up...`, YELLOW);
2734
+
2735
+ // Remove state file
2736
+ if (fs.existsSync(STATE_FILE)) {
2737
+ try { fs.unlinkSync(STATE_FILE); } catch (e) { }
2738
+ }
2739
+
2740
+ // Remove session config for this local session
2741
+ try {
2742
+ const config = getSessionConfig(defaultSessionName);
2743
+ if (config && config.type !== 'port') {
2744
+ deleteSessionConfig(defaultSessionName);
2745
+ }
2746
+ } catch (e) { }
2747
+
2748
+ // Close all hay rooms
2749
+ hayRooms.closeAll();
2750
+
2751
+ // Kill cloudflared tunnel (use currentTunnelProc in case it was restarted)
2752
+ try { (currentTunnelProc || tunnelProc).kill('SIGTERM'); } catch (e) { }
2753
+
2754
+ // Close proxy server
2755
+ try { proxyServer.close(); } catch (e) { }
2756
+
2757
+ log(`Goodbye!`, GREEN);
2758
+ process.exit(0);
2759
+ };
2760
+
2761
+ const runLocalSessionLoop = () => {
2762
+ return new Promise((resolve) => {
2763
+ // Ensure session exists on server
2764
+ getOrCreateSession(defaultSessionName, sessionCwd);
2765
+
2766
+ // Spawn hay CLI to connect to the session
2767
+ const hayCli = resolveHayCliPath();
2768
+ const wsUrl = `ws://127.0.0.1:${BASE_PORT}/ws?token=${SESSION_SECRET}`;
2769
+
2770
+ const cliProc = spawn('node', [hayCli, '-s', wsUrl, '-r', displayName, '-n', 'Local'], {
2771
+ stdio: 'inherit',
2772
+ env: { ...process.env, FORCE_COLOR: '1', HAY_SHARE_URL: publicUrl || '' }
2773
+ });
2774
+
2775
+ cliProc.on('close', (code) => {
2776
+ resolve(code);
2777
+ });
2778
+
2779
+ cliProc.on('error', (err) => {
2780
+ log(`Failed to start hay CLI: ${err.message}`, RED);
2781
+ resolve(1);
2782
+ });
2783
+ });
2784
+ };
2785
+
2786
+ runLocalSessionLoop().then((code) => {
2787
+ // Ctrl+D (detach) exits hay CLI with code 10; keep tunnel alive.
2788
+ if (code === 10) {
2789
+ log(`\nDetached from local session. Tunnel is still running.`, YELLOW);
2790
+ log(`Press Ctrl+C to shut down.`, RESET);
2791
+ return;
2792
+ }
2793
+ log(`\nLocal session ended. Shutting down tunnel...`, YELLOW);
2794
+ cleanup();
2795
+ });
2796
+
2797
+ process.on('SIGINT', cleanup);
2798
+ process.on('SIGTERM', cleanup);
2799
+ process.on('exit', cleanup);
2800
+ }
2801
+
2802
+ async function attachToExistingTunnel(existingTunnel) {
2803
+ log(`Hop tunnel is already running!`, GREEN);
2804
+ log(`URL: ${existingTunnel.url}`, BOLD);
2805
+
2806
+ // Generate smart session name: dir-N
2807
+ const cwdName = path.basename(process.cwd()).replace(/[^a-zA-Z0-9_-]/g, '-');
2808
+ let displayName = `${cwdName}-1`;
2809
+ let sessionName = toInternalSessionName(displayName);
2810
+
2811
+ log(`\nFinding next available session...`, RESET);
2812
+
2813
+ try {
2814
+ // Get existing sessions first
2815
+ const sessionsRes = await new Promise((resolve, reject) => {
2816
+ const opts = {
2817
+ hostname: '127.0.0.1',
2818
+ port: existingTunnel.port,
2819
+ path: '/api/sessions',
2820
+ method: 'GET',
2821
+ headers: { 'Authorization': `Bearer ${existingTunnel.sessionSecret}` }
2822
+ };
2823
+ http.get(opts, res => {
2824
+ let data = '';
2825
+ res.on('data', c => data += c);
2826
+ res.on('end', () => resolve(JSON.parse(data)));
2827
+ }).on('error', reject);
2828
+ });
2829
+
2830
+ // Find next available number
2831
+ if (sessionsRes && sessionsRes.sessions && sessionsRes.active) {
2832
+ const allNames = new Set([...sessionsRes.active, ...sessionsRes.sessions.map(s => s.name)]);
2833
+ let counter = 1;
2834
+ while (allNames.has(`${cwdName}-${counter}`)) {
2835
+ counter++;
2836
+ }
2837
+ displayName = `${cwdName}-${counter}`;
2838
+ sessionName = toInternalSessionName(displayName);
2839
+ }
2840
+
2841
+ log(`Creating new session: ${displayName}...`, RESET);
2842
+
2843
+ // Register session with local server
2844
+ const res = await new Promise((resolve, reject) => {
2845
+ const opts = {
2846
+ hostname: '127.0.0.1',
2847
+ port: existingTunnel.port,
2848
+ path: '/api/sessions',
2849
+ method: 'POST',
2850
+ headers: {
2851
+ 'Content-Type': 'application/json',
2852
+ 'Authorization': `Bearer ${existingTunnel.sessionSecret}`
2853
+ }
2854
+ };
2855
+
2856
+ const req = http.request(opts, (response) => {
2857
+ let data = '';
2858
+ response.on('data', c => data += c);
2859
+ response.on('end', () => {
2860
+ try {
2861
+ resolve(JSON.parse(data));
2862
+ } catch (e) {
2863
+ // Server returned non-JSON (likely HTML login page)
2864
+ reject(new Error(`Server returned non-JSON (status ${response.statusCode})`));
2865
+ }
2866
+ });
2867
+ });
2868
+
2869
+ req.on('error', reject);
2870
+ req.write(JSON.stringify({ name: displayName, cwd: process.cwd() }));
2871
+ req.end();
2872
+ });
2873
+
2874
+ if (!res.ok && !res.success) throw new Error(res.error || 'Failed to create session');
2875
+
2876
+ } catch (e) {
2877
+ log(`Note: Could not register session with server: ${e.message}`, YELLOW);
2878
+ log(`Session will still work locally.`, RESET);
2879
+ }
2880
+
2881
+ log(`Attaching to ${displayName}...`, RESET);
2882
+
2883
+ // Spawn hay CLI to connect to session
2884
+ const hayCli = resolveHayCliPath();
2885
+ const wsUrl = `ws://127.0.0.1:${existingTunnel.port}/ws?token=${existingTunnel.sessionSecret}`;
2886
+
2887
+ const cliProc = spawn('node', [hayCli, '-s', wsUrl, '-r', displayName, '-n', 'Local'], {
2888
+ stdio: 'inherit',
2889
+ env: { ...process.env, FORCE_COLOR: '1', HAY_SHARE_URL: existingTunnel.url || '' }
2890
+ });
2891
+
2892
+ cliProc.on('close', (code) => {
2893
+ if (code === 10) {
2894
+ log(`\nDetached from local session.`, YELLOW);
2895
+ process.exit(0);
2896
+ }
2897
+ log(`\nSession disconnected.`, YELLOW);
2898
+ process.exit(code || 0);
2899
+ });
2900
+
2901
+ cliProc.on('error', (err) => {
2902
+ log(`\nConnection error: ${err.message}`, RED);
2903
+ process.exit(1);
2904
+ });
2905
+
2906
+ // Wait indefinitely (process will exit when CLI closes)
2907
+ await new Promise(() => {});
2908
+ }
2909
+
2910
+ async function fetchSessionList(existingTunnel) {
2911
+ const sessionsRes = await new Promise((resolve, reject) => {
2912
+ const opts = {
2913
+ hostname: '127.0.0.1',
2914
+ port: existingTunnel.port,
2915
+ path: '/api/sessions',
2916
+ method: 'GET',
2917
+ headers: { 'Authorization': `Bearer ${existingTunnel.sessionSecret}` }
2918
+ };
2919
+ http.get(opts, res => {
2920
+ let data = '';
2921
+ res.on('data', c => data += c);
2922
+ res.on('end', () => {
2923
+ try {
2924
+ resolve(JSON.parse(data));
2925
+ } catch (e) {
2926
+ reject(new Error('Failed to parse sessions response'));
2927
+ }
2928
+ });
2929
+ }).on('error', reject);
2930
+ });
2931
+ return sessionsRes || {};
2932
+ }
2933
+
2934
+ function pickSessionForDir(sessions, cwdName) {
2935
+ const prefix = `${cwdName}-`;
2936
+ const matches = sessions
2937
+ .filter(s => s.type !== 'port')
2938
+ .filter(s => s.displayName === cwdName || s.displayName.startsWith(prefix));
2939
+ if (matches.length === 0) return null;
2940
+ const scored = matches.map(s => {
2941
+ let score = Number.MAX_SAFE_INTEGER;
2942
+ if (s.displayName === cwdName) {
2943
+ score = 0;
2944
+ } else if (s.displayName.startsWith(prefix)) {
2945
+ const suffix = s.displayName.slice(prefix.length);
2946
+ const num = parseInt(suffix, 10);
2947
+ if (Number.isFinite(num)) score = num;
2948
+ }
2949
+ return { session: s, score };
2950
+ });
2951
+ scored.sort((a, b) => a.score - b.score || a.session.displayName.localeCompare(b.session.displayName));
2952
+ return scored[0].session;
2953
+ }
2954
+
2955
+ async function attachToNamedSession(existingTunnel, displayName) {
2956
+ const sessionsRes = await fetchSessionList(existingTunnel);
2957
+ const sessions = sessionsRes.sessions || [];
2958
+ const target = sessions.find(s => s.type !== 'port' && s.displayName === displayName);
2959
+ if (!target) {
2960
+ throw new Error(`Session not found: ${displayName}`);
2961
+ }
2962
+ log(`Attaching to ${displayName}...`, RESET);
2963
+
2964
+ const hayCli = resolveHayCliPath();
2965
+ const wsUrl = `ws://127.0.0.1:${existingTunnel.port}/ws?token=${existingTunnel.sessionSecret}`;
2966
+
2967
+ const cliProc = spawn('node', [hayCli, '-s', wsUrl, '-r', displayName, '-n', 'Local'], {
2968
+ stdio: 'inherit',
2969
+ env: { ...process.env, FORCE_COLOR: '1', HAY_SHARE_URL: existingTunnel.url || '' }
2970
+ });
2971
+
2972
+ cliProc.on('close', (code) => {
2973
+ if (code === 10) {
2974
+ log(`\nDetached from local session.`, YELLOW);
2975
+ process.exit(0);
2976
+ }
2977
+ log(`\nSession disconnected.`, YELLOW);
2978
+ process.exit(code || 0);
2979
+ });
2980
+
2981
+ cliProc.on('error', (err) => {
2982
+ log(`\nConnection error: ${err.message}`, RED);
2983
+ process.exit(1);
2984
+ });
2985
+
2986
+ await new Promise(() => {});
2987
+ }
2988
+
2989
+ async function attachToSessionForDir(existingTunnel) {
2990
+ const sessionsRes = await fetchSessionList(existingTunnel);
2991
+ const sessions = sessionsRes.sessions || [];
2992
+ const cwdName = path.basename(process.cwd()).replace(/[^a-zA-Z0-9_-]/g, '-');
2993
+ const target = pickSessionForDir(sessions, cwdName);
2994
+ if (!target) {
2995
+ throw new Error(`No existing session for ${cwdName}`);
2996
+ }
2997
+ return attachToNamedSession(existingTunnel, target.displayName);
2998
+ }
2999
+
3000
+ async function main() {
3001
+ // Handle `hop client` - run as a user with exported credentials
3002
+ if (process.argv[2] === 'client') {
3003
+ const credentialsArg = process.argv[3];
3004
+ const hostnameArgIndex = process.argv.indexOf('--hostname');
3005
+ const hostnameOverride = hostnameArgIndex !== -1 ? process.argv[hostnameArgIndex + 1] : null;
3006
+
3007
+ if (!credentialsArg || credentialsArg === '--help' || credentialsArg === '-h') {
3008
+ log(`\n${BOLD}Hop Client Mode${RESET}\n`, CYAN);
3009
+ log(`Run hop with credentials from your admin.\n`, RESET);
3010
+ log(`${BOLD}Usage:${RESET}`, RESET);
3011
+ log(` hop client <credentials.json> Start tunnel with credentials`, RESET);
3012
+ log(` hop client <folder> Start tunnel (folder contains credentials.json)`, RESET);
3013
+ log('');
3014
+ log(`${BOLD}Options:${RESET}`, RESET);
3015
+ log(` --hostname <name> Override hostname if config.yml is missing`, RESET);
3016
+ log('');
3017
+ log(`${BOLD}First run:${RESET}`, RESET);
3018
+ log(` You'll be prompted to set a password and scan a QR code`, RESET);
3019
+ log(` for your authenticator app.`, RESET);
3020
+ log('');
3021
+ log(`${BOLD}Example:${RESET}`, RESET);
3022
+ log(` hop client ./my-credentials.json`, RESET);
3023
+ log('');
3024
+ process.exit(0);
3025
+ }
3026
+
3027
+ // Find credentials file
3028
+ let credentialsPath = credentialsArg;
3029
+ if (fs.existsSync(credentialsArg) && fs.statSync(credentialsArg).isDirectory()) {
3030
+ credentialsPath = path.join(credentialsArg, 'credentials.json');
3031
+ }
3032
+
3033
+ if (!fs.existsSync(credentialsPath)) {
3034
+ log(`Credentials file not found: ${credentialsPath}`, RED);
3035
+ process.exit(1);
3036
+ }
3037
+
3038
+ // Load credentials
3039
+ let credentials;
3040
+ try {
3041
+ credentials = JSON.parse(fs.readFileSync(credentialsPath, 'utf8'));
3042
+ } catch (e) {
3043
+ log(`Invalid credentials file: ${e.message}`, RED);
3044
+ process.exit(1);
3045
+ }
3046
+
3047
+ // Client config is stored per-tunnel (based on tunnel ID)
3048
+ const tunnelId = credentials.TunnelID || credentials.tunnelId || path.basename(credentialsPath, '.json');
3049
+ const clientConfigDir = path.join(HOME_DIR, 'clients', tunnelId);
3050
+ ensurePrivateDir(clientConfigDir);
3051
+
3052
+ PASSWORD_FILE_PATH = path.join(clientConfigDir, '.password_hash');
3053
+ SECRET_FILE_PATH = path.join(clientConfigDir, '.auth_secret');
3054
+ const clientConfigFile = path.join(clientConfigDir, '.config.json');
3055
+
3056
+ // Load or create client config
3057
+ let clientConfig = {};
3058
+ if (fs.existsSync(clientConfigFile)) {
3059
+ try {
3060
+ clientConfig = JSON.parse(fs.readFileSync(clientConfigFile, 'utf8'));
3061
+ } catch (e) { }
3062
+ }
3063
+
3064
+ let hostname = clientConfig.hostname || null;
3065
+ if (hostnameOverride) {
3066
+ hostname = hostnameOverride;
3067
+ }
3068
+
3069
+ // Try to find hostname in config.yml in the export folder
3070
+ const configYmlPath = path.join(path.dirname(credentialsPath), 'config.yml');
3071
+ if (!hostname && fs.existsSync(configYmlPath)) {
3072
+ const configYml = fs.readFileSync(configYmlPath, 'utf8');
3073
+ const hostnameMatch = configYml.match(/hostname:\s*(\S+)/);
3074
+ if (hostnameMatch) hostname = hostnameMatch[1];
3075
+ }
3076
+
3077
+ if (!hostname) {
3078
+ log(`Hostname not found.`, RED);
3079
+ log(`Ask your admin to export a fresh bundle or pass --hostname.`, YELLOW);
3080
+ process.exit(1);
3081
+ }
3082
+
3083
+ if (clientConfig.hostname !== hostname) {
3084
+ clientConfig.hostname = hostname;
3085
+ writePrivateFile(clientConfigFile, JSON.stringify(clientConfig, null, 2));
3086
+ }
3087
+
3088
+ // First time setup - require password and TOTP
3089
+ const clientPasswordExists = isPasswordEnabled();
3090
+ const clientSecretExists = fs.existsSync(SECRET_FILE_PATH);
3091
+
3092
+ if (!clientPasswordExists || !clientSecretExists) {
3093
+ log(`\n${BOLD}${YELLOW}⚠ First time setup required${RESET}\n`, YELLOW);
3094
+ log(`Your tunnel URL will be publicly accessible.`, RESET);
3095
+ log(`Password + authenticator code protects your hop login.\n`, RESET);
3096
+
3097
+ const readline = require('readline');
3098
+ const stream = require('stream');
3099
+
3100
+ const askPassword = (prompt) => new Promise((resolve) => {
3101
+ const rl = readline.createInterface({
3102
+ input: process.stdin,
3103
+ output: new stream.Writable({
3104
+ write: (chunk, encoding, callback) => callback()
3105
+ }),
3106
+ terminal: true
3107
+ });
3108
+ process.stdout.write(prompt);
3109
+ rl.question('', (answer) => {
3110
+ rl.close();
3111
+ process.stdout.write('\n');
3112
+ resolve(answer);
3113
+ });
3114
+ });
3115
+
3116
+ // Set password
3117
+ if (!clientPasswordExists) {
3118
+ const password = await askPassword('Set a password: ');
3119
+ if (password.length < 4) {
3120
+ log(`Password too short (min 4 characters).`, RED);
3121
+ process.exit(1);
3122
+ }
3123
+ const confirm = await askPassword('Confirm password: ');
3124
+ if (password !== confirm) {
3125
+ log(`Passwords do not match.`, RED);
3126
+ process.exit(1);
3127
+ }
3128
+ setPassword(password);
3129
+ log(`${GREEN}✓ Password set.${RESET}`, GREEN);
3130
+ }
3131
+
3132
+ // Set up TOTP
3133
+ if (!clientSecretExists) {
3134
+ loadAuthSecret();
3135
+ log(`\n${BOLD}Scan this QR code with your authenticator app:${RESET}\n`, CYAN);
3136
+ const label = hostname ? `Hop-${hostname}` : `Hop-${tunnelId.slice(0, 8)}`;
3137
+ showQrCode(label);
3138
+ }
3139
+
3140
+ log(`\n${GREEN}✓ Setup complete!${RESET}\n`, GREEN);
3141
+ }
3142
+
3143
+ loadAuthSecret();
3144
+
3145
+ await checkAndInstallDependencies();
3146
+ await ensureHayLoaded();
3147
+ const authEmitter = new EventEmitter();
3148
+
3149
+ const proxyServer = startAuthProxy(authEmitter);
3150
+ CLIENT_TUNNEL_CONFIG = {
3151
+ tunnelId,
3152
+ credentialsPath,
3153
+ hostname,
3154
+ configDir: clientConfigDir
3155
+ };
3156
+ const { tunnel: tunnelProc, publicUrl } = await startClientTunnel();
3157
+
3158
+ // Save state for other terminals to find
3159
+ writePrivateFile(STATE_FILE, JSON.stringify({
3160
+ pid: process.pid,
3161
+ port: BASE_PORT,
3162
+ url: publicUrl,
3163
+ sessionSecret: SESSION_SECRET,
3164
+ startTime: Date.now()
3165
+ }));
3166
+
3167
+ await waitForStart(authEmitter, publicUrl, `Hop-${hostname}`);
3168
+
3169
+ await startLocalSession(proxyServer, tunnelProc, publicUrl);
3170
+ return;
3171
+ }
3172
+
3173
+ // Handle `hop start` command - start daemon and print QR codes
3174
+ if (process.argv[2] === 'start') {
3175
+ const existing = isServerRunning();
3176
+ if (!existing) {
3177
+ loadAuthSecret();
3178
+ await checkAndInstallDependencies();
3179
+ startDaemon();
3180
+ }
3181
+ const state = existing || await waitForDaemonReady();
3182
+ const domainConfig = getDomainConfig();
3183
+ const label = domainConfig?.hostname ? `Hop-${domainConfig.hostname}` : 'Hop';
3184
+ loadAuthSecret();
3185
+ printStartupInfo(state.url, label);
3186
+ process.exit(0);
3187
+ }
3188
+
3189
+ // Handle `hop attach` command - attach to existing session
3190
+ if (process.argv[2] === 'attach') {
3191
+ const existing = isServerRunning();
3192
+ if (!existing) {
3193
+ log(`Hop is not running.`, RED);
3194
+ process.exit(1);
3195
+ }
3196
+ const nameArg = process.argv[3];
3197
+ const displayName = nameArg && nameArg.startsWith(HOP_SESSION_PREFIX)
3198
+ ? toDisplaySessionName(nameArg)
3199
+ : nameArg;
3200
+ if (displayName) {
3201
+ await attachToNamedSession(existing, displayName);
3202
+ } else {
3203
+ await attachToSessionForDir(existing);
3204
+ }
3205
+ return;
3206
+ }
3207
+
3208
+ // Handle `hop stop` command - stop daemon
3209
+ if (process.argv[2] === 'stop') {
3210
+ const state = isServerRunning();
3211
+ if (!state) {
3212
+ log(`Hop is not running.`, RED);
3213
+ process.exit(1);
3214
+ }
3215
+ try {
3216
+ process.kill(state.pid, 'SIGTERM');
3217
+ log(`Hop stopped.`, GREEN);
3218
+ } catch (e) {
3219
+ log(`Failed to stop hop: ${e.message}`, RED);
3220
+ process.exit(1);
3221
+ }
3222
+ process.exit(0);
3223
+ }
3224
+
3225
+ // Handle `hop --help` or `hop help`
3226
+ if (process.argv[2] === '--help' || process.argv[2] === '-h' || process.argv[2] === 'help') {
3227
+ log(`\n${BOLD}Hop - Remote Terminal Access${RESET}\n`, CYAN);
3228
+ log(`${BOLD}Usage:${RESET}`, RESET);
3229
+ log(` hop Start tunnel (or attach to existing)`, RESET);
3230
+ log(` hop start Start hop daemon and print QR codes`, RESET);
3231
+ log(` hop attach [session] Attach to an existing session`, RESET);
3232
+ log(` hop stop Stop hop daemon`, RESET);
3233
+ log(` hop --help Show this help message`, RESET);
3234
+ log('');
3235
+ log(`${BOLD}URL & QR:${RESET}`, RESET);
3236
+ log(` hop url Print current tunnel URL`, RESET);
3237
+ log(` hop qr Show URL as QR code`, RESET);
3238
+ log('');
3239
+ log(`${BOLD}Custom Domain:${RESET}`, RESET);
3240
+ log(` hop domain Show current domain config`, RESET);
3241
+ log(` hop domain <hostname> Setup custom domain (e.g., hop.example.com)`, RESET);
3242
+ log(` hop domain-clear Remove custom domain, use random URLs`, RESET);
3243
+ log('');
3244
+ log(`${BOLD}Security:${RESET}`, RESET);
3245
+ log(` hop password Show password status`, RESET);
3246
+ log(` hop password set Set or change password`, RESET);
3247
+ log(` hop password clear Remove password protection`, RESET);
3248
+ log('');
3249
+ log(`${BOLD}Multi-user (admin):${RESET}`, RESET);
3250
+ log(` hop user list List all users`, RESET);
3251
+ log(` hop user add <name> Add a user (creates tunnel + DNS)`, RESET);
3252
+ log(` hop user remove <name> Remove a user`, RESET);
3253
+ log(` hop user export <name> Export credentials for a user`, RESET);
3254
+ log('');
3255
+ log(`${BOLD}Sessions:${RESET}`, RESET);
3256
+ log(` hop session list List sessions`, RESET);
3257
+ log(` hop session add <name> Add terminal session`, RESET);
3258
+ log(` hop session add <name> --port N Add port session (proxy)`, RESET);
3259
+ log(` hop session remove <name> Remove a session`, RESET);
3260
+ log('');
3261
+ log(`${BOLD}Client mode (for users):${RESET}`, RESET);
3262
+ log(` hop client <credentials> Run tunnel with exported credentials`, RESET);
3263
+ log('');
3264
+ log(`${BOLD}Maintenance:${RESET}`, RESET);
3265
+ log(` hop wipe Kill all hop sessions`, RESET);
3266
+ log('');
3267
+ process.exit(0);
3268
+ }
3269
+
3270
+ // Handle `hop url` command - prints current tunnel URL (easy to copy)
3271
+ if (process.argv[2] === 'url') {
3272
+ const state = isServerRunning();
3273
+ if (!state) {
3274
+ log(`Hop is not running.`, RED);
3275
+ process.exit(1);
3276
+ }
3277
+ // Print just the URL for easy copying/piping
3278
+ console.log(state.url);
3279
+ process.exit(0);
3280
+ }
3281
+
3282
+ // Handle `hop qr` command - shows QR code for current URL
3283
+ if (process.argv[2] === 'qr') {
3284
+ const state = isServerRunning();
3285
+ if (!state) {
3286
+ log(`Hop is not running.`, RED);
3287
+ process.exit(1);
3288
+ }
3289
+ log(`\nCurrent tunnel URL:`, BOLD);
3290
+ log(`${state.url}\n`, GREEN);
3291
+ qrcode.generate(state.url, { small: true });
3292
+ process.exit(0);
3293
+ }
3294
+
3295
+ // Handle `hop domain <hostname>` command - setup custom domain
3296
+ if (process.argv[2] === 'domain') {
3297
+ const hostname = process.argv[3];
3298
+ if (!hostname) {
3299
+ // Show current domain config
3300
+ const config = getDomainConfig();
3301
+ if (config) {
3302
+ log(`\nCurrent custom domain configuration:`, BOLD);
3303
+ log(` Hostname: ${config.hostname}`, GREEN);
3304
+ log(` Tunnel: ${config.tunnelName}`, RESET);
3305
+ log(`\nTo change: hop domain <new-hostname>`, RESET);
3306
+ log(`To remove: hop domain-clear`, RESET);
3307
+ } else {
3308
+ log(`\nNo custom domain configured.`, YELLOW);
3309
+ log(`\nUsage: hop domain <hostname>`, RESET);
3310
+ log(`Example: hop domain hop.yourdomain.com`, RESET);
3311
+ }
3312
+ process.exit(0);
3313
+ }
3314
+ // Validate hostname format
3315
+ if (!/^[a-zA-Z0-9]([a-zA-Z0-9-]*\.)+[a-zA-Z]{2,}$/.test(hostname)) {
3316
+ log(`Invalid hostname format: ${hostname}`, RED);
3317
+ log(`Example: hop.yourdomain.com`, RESET);
3318
+ process.exit(1);
3319
+ }
3320
+ await setupDomain(hostname);
3321
+ process.exit(0);
3322
+ }
3323
+
3324
+ // Handle `hop domain-clear` command - remove custom domain config
3325
+ if (process.argv[2] === 'domain-clear') {
3326
+ if (fs.existsSync(DOMAIN_CONFIG_FILE)) {
3327
+ fs.unlinkSync(DOMAIN_CONFIG_FILE);
3328
+ log(`Custom domain configuration removed.`, GREEN);
3329
+ log(`Hop will use random Cloudflare URLs again.`, RESET);
3330
+ } else {
3331
+ log(`No custom domain configured.`, YELLOW);
3332
+ }
3333
+ process.exit(0);
3334
+ }
3335
+
3336
+ // Handle `hop password` commands
3337
+ if (process.argv[2] === 'password') {
3338
+ const subcommand = process.argv[3];
3339
+ const readline = require('readline');
3340
+
3341
+ if (!subcommand || subcommand === 'status') {
3342
+ if (isPasswordEnabled()) {
3343
+ log(`Password protection: ${GREEN}ENABLED${RESET}`, BOLD);
3344
+ log(`\nTo change: hop password set`, RESET);
3345
+ log(`To remove: hop password clear`, RESET);
3346
+ } else {
3347
+ log(`Password protection: ${YELLOW}DISABLED${RESET}`, BOLD);
3348
+ log(`\nTo enable: hop password set`, RESET);
3349
+ }
3350
+ process.exit(0);
3351
+ }
3352
+
3353
+ if (subcommand === 'set') {
3354
+ // Use muted readline for password input
3355
+ const askPassword = (prompt) => new Promise((resolve) => {
3356
+ const rl = readline.createInterface({
3357
+ input: process.stdin,
3358
+ output: new (require('stream').Writable)({
3359
+ write: (chunk, encoding, callback) => callback()
3360
+ }),
3361
+ terminal: true
3362
+ });
3363
+
3364
+ process.stdout.write(prompt);
3365
+ rl.question('', (answer) => {
3366
+ rl.close();
3367
+ process.stdout.write('\n');
3368
+ resolve(answer);
3369
+ });
3370
+ });
3371
+
3372
+ const password = await askPassword('Enter new password: ');
3373
+ if (password.length < 4) {
3374
+ log(`Password too short (min 4 characters).`, RED);
3375
+ process.exit(1);
3376
+ }
3377
+
3378
+ const confirm = await askPassword('Confirm password: ');
3379
+ if (password !== confirm) {
3380
+ log(`Passwords do not match.`, RED);
3381
+ process.exit(1);
3382
+ }
3383
+
3384
+ setPassword(password);
3385
+ log(`\n${GREEN}✓ Password set successfully.${RESET}`, GREEN);
3386
+ log(`\nLogin will now require password + TOTP code.`, RESET);
3387
+ process.exit(0);
3388
+ }
3389
+
3390
+ if (subcommand === 'clear') {
3391
+ if (!isPasswordEnabled()) {
3392
+ log(`Password is not set.`, YELLOW);
3393
+ process.exit(0);
3394
+ }
3395
+ clearPassword();
3396
+ log(`${GREEN}✓ Password protection removed.${RESET}`, GREEN);
3397
+ log(`\nLogin will only require TOTP code.`, RESET);
3398
+ process.exit(0);
3399
+ }
3400
+
3401
+ // Unknown subcommand
3402
+ log(`\n${BOLD}Password commands:${RESET}\n`, CYAN);
3403
+ log(` hop password Show password status`, RESET);
3404
+ log(` hop password set Set or change password`, RESET);
3405
+ log(` hop password clear Remove password protection`, RESET);
3406
+ process.exit(0);
3407
+ }
3408
+
3409
+ // Handle `hop user` commands - multi-user management
3410
+ if (process.argv[2] === 'user') {
3411
+ const subcommand = process.argv[3];
3412
+ const username = process.argv[4];
3413
+
3414
+ if (!subcommand || subcommand === 'list') {
3415
+ listUsersCommand();
3416
+ process.exit(0);
3417
+ }
3418
+
3419
+ if (subcommand === 'add') {
3420
+ if (!username) {
3421
+ log(`Usage: hop user add <username>`, RED);
3422
+ process.exit(1);
3423
+ }
3424
+ await addUser(username);
3425
+ process.exit(0);
3426
+ }
3427
+
3428
+ if (subcommand === 'remove' || subcommand === 'rm' || subcommand === 'delete') {
3429
+ if (!username) {
3430
+ log(`Usage: hop user remove <username>`, RED);
3431
+ process.exit(1);
3432
+ }
3433
+ await removeUser(username);
3434
+ process.exit(0);
3435
+ }
3436
+
3437
+ if (subcommand === 'export') {
3438
+ if (!username) {
3439
+ log(`Usage: hop user export <username>`, RED);
3440
+ process.exit(1);
3441
+ }
3442
+ await exportUserCredentials(username);
3443
+ process.exit(0);
3444
+ }
3445
+
3446
+ // Unknown subcommand - show help
3447
+ log(`\n${BOLD}User management commands:${RESET}\n`, CYAN);
3448
+ log(` hop user list List all users`, RESET);
3449
+ log(` hop user add <username> Add a new user`, RESET);
3450
+ log(` hop user remove <username> Remove a user`, RESET);
3451
+ log(` hop user export <username> Export credentials for a user`, RESET);
3452
+ process.exit(0);
3453
+ }
3454
+
3455
+ // Handle `hop session` commands
3456
+ if (process.argv[2] === 'session') {
3457
+ await ensureHayLoaded();
3458
+ const subcommand = process.argv[3];
3459
+ const nameArg = process.argv[4];
3460
+
3461
+ if (!subcommand || subcommand === 'list') {
3462
+ const sessions = listAllSessionEntries();
3463
+ if (sessions.length === 0) {
3464
+ log(`No sessions configured.`, YELLOW);
3465
+ process.exit(0);
3466
+ }
3467
+ log(`\n${BOLD}Sessions:${RESET}\n`, CYAN);
3468
+ sessions.forEach(s => {
3469
+ if (s.type === 'port') {
3470
+ log(` ${BOLD}${s.displayName}${RESET} ${YELLOW}[PORT ${s.port}]${RESET}`, RESET);
3471
+ } else {
3472
+ log(` ${BOLD}${s.displayName}${RESET} ${GREEN}[TERMINAL]${RESET}`, RESET);
3473
+ }
3474
+ });
3475
+ process.exit(0);
3476
+ }
3477
+
3478
+ if (subcommand === 'add') {
3479
+ if (!nameArg) {
3480
+ log(`Usage: hop session add <name> [--port <number>]`, RED);
3481
+ process.exit(1);
3482
+ }
3483
+ const displayName = nameArg;
3484
+ const name = toInternalSessionName(displayName);
3485
+ if (!name) {
3486
+ log(`Invalid session name.`, RED);
3487
+ process.exit(1);
3488
+ }
3489
+ const portArgIndex = process.argv.indexOf('--port');
3490
+ if (portArgIndex !== -1) {
3491
+ const portArg = parseInt(process.argv[portArgIndex + 1], 10);
3492
+ if (!Number.isInteger(portArg) || portArg < 1 || portArg > 65535) {
3493
+ log(`Invalid port. Use 1-65535.`, RED);
3494
+ process.exit(1);
3495
+ }
3496
+ if (hopSessionExists(name)) {
3497
+ log(`Terminal session already exists with this name.`, RED);
3498
+ process.exit(1);
3499
+ }
3500
+ setPortSessionConfig(name, portArg);
3501
+ log(`Added port session "${displayName}" → localhost:${portArg}`, GREEN);
3502
+ process.exit(0);
3503
+ }
3504
+
3505
+ const existingConfig = getSessionConfig(name);
3506
+ if (existingConfig && existingConfig.type === 'port') {
3507
+ log(`Port session already exists with this name.`, RED);
3508
+ process.exit(1);
3509
+ }
3510
+ ensureTerminalSessionConfig(name);
3511
+ log(`Added terminal session "${displayName}".`, GREEN);
3512
+ process.exit(0);
3513
+ }
3514
+
3515
+ if (subcommand === 'remove' || subcommand === 'rm' || subcommand === 'delete') {
3516
+ if (!nameArg) {
3517
+ log(`Usage: hop session remove <name>`, RED);
3518
+ process.exit(1);
3519
+ }
3520
+ const displayName = nameArg;
3521
+ const name = toInternalSessionName(displayName);
3522
+ if (!name) {
3523
+ log(`Invalid session name.`, RED);
3524
+ process.exit(1);
3525
+ }
3526
+ const config = getSessionConfig(name);
3527
+ if (config && config.type === 'port') {
3528
+ deleteSessionConfig(name);
3529
+ log(`Removed port session "${displayName}".`, GREEN);
3530
+ process.exit(0);
3531
+ }
3532
+ // Close hay room if it exists
3533
+ if (hayRooms.hasRoom(name)) {
3534
+ hayRooms.closeRoom(name);
3535
+ }
3536
+ if (activeSessions[name]) {
3537
+ delete activeSessions[name];
3538
+ }
3539
+ deleteSessionConfig(name);
3540
+ log(`Removed terminal session "${displayName}".`, GREEN);
3541
+ process.exit(0);
3542
+ }
3543
+
3544
+ log(`\n${BOLD}Session commands:${RESET}\n`, CYAN);
3545
+ log(` hop session list List sessions`, RESET);
3546
+ log(` hop session add <name> Add terminal session`, RESET);
3547
+ log(` hop session add <name> --port N Add port session`, RESET);
3548
+ log(` hop session remove <name> Remove a session`, RESET);
3549
+ process.exit(0);
3550
+ }
3551
+
3552
+ // Handle `hop wipe` command - kills all hop sessions
3553
+ if (process.argv[2] === 'wipe') {
3554
+ const existingTunnel = isServerRunning();
3555
+
3556
+ if (existingTunnel) {
3557
+ // Server is running - call API to delete sessions
3558
+ const http = require('http');
3559
+
3560
+ // First get list of sessions
3561
+ const sessionsRes = await new Promise((resolve, reject) => {
3562
+ const opts = {
3563
+ hostname: '127.0.0.1',
3564
+ port: existingTunnel.port,
3565
+ path: '/api/sessions',
3566
+ method: 'GET',
3567
+ headers: { 'Authorization': `Bearer ${existingTunnel.sessionSecret}` }
3568
+ };
3569
+ const req = http.request(opts, res => {
3570
+ let data = '';
3571
+ res.on('data', chunk => data += chunk);
3572
+ res.on('end', () => resolve({ status: res.statusCode, data }));
3573
+ });
3574
+ req.on('error', reject);
3575
+ req.end();
3576
+ });
3577
+
3578
+ const sessionsData = JSON.parse(sessionsRes.data);
3579
+ const sessions = sessionsData.sessions || [];
3580
+
3581
+ if (sessions.length === 0) {
3582
+ log(`No hop sessions to clean up.`, GREEN);
3583
+ process.exit(0);
3584
+ }
3585
+
3586
+ log(`Killing ${sessions.length} hop session(s)...`, YELLOW);
3587
+
3588
+ for (const s of sessions) {
3589
+ try {
3590
+ await new Promise((resolve, reject) => {
3591
+ const opts = {
3592
+ hostname: '127.0.0.1',
3593
+ port: existingTunnel.port,
3594
+ path: '/api/sessions/delete',
3595
+ method: 'POST',
3596
+ headers: {
3597
+ 'Content-Type': 'application/json',
3598
+ 'Authorization': `Bearer ${existingTunnel.sessionSecret}`
3599
+ }
3600
+ };
3601
+ const req = http.request(opts, res => {
3602
+ let data = '';
3603
+ res.on('data', chunk => data += chunk);
3604
+ res.on('end', () => resolve({ status: res.statusCode, data }));
3605
+ });
3606
+ req.on('error', reject);
3607
+ req.write(JSON.stringify({ name: s.name }));
3608
+ req.end();
3609
+ });
3610
+ log(` - ${s.displayName || s.name}`, RESET);
3611
+ } catch (e) {
3612
+ log(` - ${s.displayName || s.name} (failed)`, YELLOW);
3613
+ }
3614
+ }
3615
+ log(`Done.`, GREEN);
3616
+ } else {
3617
+ // Server not running - just clean up config files
3618
+ const sessions = Object.keys(sessionConfigs).filter(s => s.startsWith(HOP_SESSION_PREFIX));
3619
+ if (sessions.length === 0) {
3620
+ log(`No hop sessions to clean up.`, GREEN);
3621
+ process.exit(0);
3622
+ }
3623
+ log(`Cleaning up ${sessions.length} session config(s)...`, YELLOW);
3624
+ for (const name of sessions) {
3625
+ deleteSessionConfig(name);
3626
+ log(` - ${toDisplaySessionName(name)}`, RESET);
3627
+ }
3628
+ log(`Done.`, GREEN);
3629
+ }
3630
+ process.exit(0);
3631
+ }
3632
+
3633
+ process.stdout.write('\x1Bc'); // Clear screen
3634
+
3635
+ // Only one instance allowed
3636
+ if (!DAEMON_FLAG) {
3637
+ const existingTunnel = isServerRunning();
3638
+ if (existingTunnel) {
3639
+ await attachToExistingTunnel(existingTunnel);
3640
+ return;
3641
+ }
3642
+
3643
+ // No server yet: start daemon and wait for it to come up
3644
+ loadAuthSecret();
3645
+ await checkAndInstallDependencies();
3646
+ startDaemon();
3647
+ const state = await waitForDaemonReady();
3648
+ const domainConfig = getDomainConfig();
3649
+ const label = domainConfig?.hostname ? `Hop-${domainConfig.hostname}` : 'Hop';
3650
+ loadAuthSecret();
3651
+ await waitForStartPolling(state, label);
3652
+ await attachToExistingTunnel(state);
3653
+ return;
3654
+ }
3655
+
3656
+ // SERVER MODE: Start new tunnel
3657
+
3658
+ // Require password for custom domains
3659
+ const domainConfig = getDomainConfig();
3660
+ if (domainConfig && domainConfig.hostname && !isPasswordEnabled()) {
3661
+ log(`\n${RED}ERROR: Password required for custom domain.${RESET}`, RED);
3662
+ log(`\nYour fixed URL (${domainConfig.hostname}) is publicly known.`, YELLOW);
3663
+ log(`Password protection is required for security.\n`, RESET);
3664
+ log(`Run: ${CYAN}hop password set${RESET}`, RESET);
3665
+ process.exit(1);
3666
+ }
3667
+
3668
+ loadAuthSecret();
3669
+ await checkAndInstallDependencies();
3670
+ await ensureHayLoaded();
3671
+ const authEmitter = new EventEmitter();
3672
+
3673
+ const proxyServer = startAuthProxy(authEmitter);
3674
+ const { tunnel: tunnelProc, publicUrl } = await startTunnel();
3675
+
3676
+ // Save state for other terminals to find
3677
+ writePrivateFile(STATE_FILE, JSON.stringify({
3678
+ pid: process.pid,
3679
+ port: BASE_PORT,
3680
+ url: publicUrl,
3681
+ sessionSecret: SESSION_SECRET,
3682
+ startTime: Date.now()
3683
+ }));
3684
+
3685
+ if (DAEMON_FLAG) {
3686
+ const cleanup = () => {
3687
+ if (shuttingDown) return;
3688
+ shuttingDown = true;
3689
+ log(`Cleaning up...`, YELLOW);
3690
+
3691
+ if (fs.existsSync(STATE_FILE)) {
3692
+ try { fs.unlinkSync(STATE_FILE); } catch (e) { }
3693
+ }
3694
+
3695
+ hayRooms.closeAll();
3696
+
3697
+ try { (currentTunnelProc || tunnelProc).kill('SIGTERM'); } catch (e) { }
3698
+ try { proxyServer.close(); } catch (e) { }
3699
+
3700
+ process.exit(0);
3701
+ };
3702
+
3703
+ process.on('SIGINT', cleanup);
3704
+ process.on('SIGTERM', cleanup);
3705
+ process.on('exit', cleanup);
3706
+ await new Promise(() => {});
3707
+ }
3708
+
3709
+ await waitForStart(authEmitter, publicUrl);
3710
+
3711
+ await startLocalSession(proxyServer, tunnelProc, publicUrl);
3712
+ }
3713
+
3714
+ main().catch(err => {
3715
+ log(`Fatal: ${err.message}`, RED);
3716
+ process.exit(1);
3717
+ });