vibetunnel
Version:
Terminal sharing server with web interface - supports macOS, Linux, and headless environments
1,099 lines (1,088 loc) • 198 kB
JavaScript
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
var __exportStar = (this && this.__exportStar) || function(m, exports) {
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
};
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.isShuttingDown = isShuttingDown;
exports.setShuttingDown = setShuttingDown;
exports.createApp = createApp;
exports.startVibeTunnelServer = startVibeTunnelServer;
// VibeTunnel server entry point
const chalk_1 = __importDefault(require("chalk"));
const compression_1 = __importDefault(require("compression"));
const express_1 = __importDefault(require("express"));
const fs = __importStar(require("fs"));
const helmet_1 = __importDefault(require("helmet"));
const http_1 = require("http");
const os = __importStar(require("os"));
const path = __importStar(require("path"));
const uuid_1 = require("uuid");
const ws_1 = require("ws");
const types_js_1 = require("../shared/types.js");
const api_socket_server_js_1 = require("./api-socket-server.js");
const auth_js_1 = require("./middleware/auth.js");
const index_js_1 = require("./pty/index.js");
const auth_js_2 = require("./routes/auth.js");
const config_js_1 = require("./routes/config.js");
const control_js_1 = require("./routes/control.js");
const events_js_1 = require("./routes/events.js");
const files_js_1 = require("./routes/files.js");
const filesystem_js_1 = require("./routes/filesystem.js");
const git_js_1 = require("./routes/git.js");
const logs_js_1 = require("./routes/logs.js");
const multiplexer_js_1 = require("./routes/multiplexer.js");
const push_js_1 = require("./routes/push.js");
const remotes_js_1 = require("./routes/remotes.js");
const repositories_js_1 = require("./routes/repositories.js");
const sessions_js_1 = require("./routes/sessions.js");
const test_notification_js_1 = require("./routes/test-notification.js");
const tmux_js_1 = require("./routes/tmux.js");
const websocket_input_js_1 = require("./routes/websocket-input.js");
const worktrees_js_1 = require("./routes/worktrees.js");
const activity_monitor_js_1 = require("./services/activity-monitor.js");
const auth_service_js_1 = require("./services/auth-service.js");
const buffer_aggregator_js_1 = require("./services/buffer-aggregator.js");
const config_service_js_1 = require("./services/config-service.js");
const control_dir_watcher_js_1 = require("./services/control-dir-watcher.js");
const hq_client_js_1 = require("./services/hq-client.js");
const mdns_service_js_1 = require("./services/mdns-service.js");
const push_notification_service_js_1 = require("./services/push-notification-service.js");
const remote_registry_js_1 = require("./services/remote-registry.js");
const session_monitor_js_1 = require("./services/session-monitor.js");
const stream_watcher_js_1 = require("./services/stream-watcher.js");
const tailscale_serve_service_js_1 = require("./services/tailscale-serve-service.js");
const terminal_manager_js_1 = require("./services/terminal-manager.js");
const logger_js_1 = require("./utils/logger.js");
const vapid_manager_js_1 = require("./utils/vapid-manager.js");
const version_js_1 = require("./version.js");
const control_unix_handler_js_1 = require("./websocket/control-unix-handler.js");
const logger = (0, logger_js_1.createLogger)('server');
// Global shutdown state management
let shuttingDown = false;
function isShuttingDown() {
return shuttingDown;
}
function setShuttingDown(value) {
shuttingDown = value;
}
// Show help message
function showHelp() {
console.log(`
VibeTunnel Server - Terminal Multiplexer
Usage: vibetunnel-server [options]
Options:
--help Show this help message
--version Show version information
--port <number> Server port (default: 4020 or PORT env var)
--bind <address> Bind address (default: 0.0.0.0, all interfaces)
--enable-ssh-keys Enable SSH key authentication UI and functionality
--disallow-user-password Disable password auth, SSH keys only (auto-enables --enable-ssh-keys)
--no-auth Disable authentication (auto-login as current user)
--allow-local-bypass Allow localhost connections to bypass authentication
--local-auth-token <token> Token for localhost authentication bypass
--enable-tailscale-serve Enable Tailscale Serve integration (auto-manages proxy and auth)
--debug Enable debug logging
Push Notification Options:
--push-enabled Enable push notifications (default: enabled)
--push-disabled Disable push notifications
--vapid-email <email> Contact email for VAPID (or PUSH_CONTACT_EMAIL env var)
--generate-vapid-keys Generate new VAPID keys if none exist
Network Discovery Options:
--no-mdns Disable mDNS/Bonjour advertisement (enabled by default)
HQ Mode Options:
--hq Run as HQ (headquarters) server
Remote Server Options:
--hq-url <url> HQ server URL to register with
--hq-username <user> Username for HQ authentication
--hq-password <pass> Password for HQ authentication
--name <name> Unique name for this remote server
--allow-insecure-hq Allow HTTP URLs for HQ (default: HTTPS only)
--no-hq-auth Disable HQ authentication (for testing only)
Environment Variables:
PORT Default port if --port not specified
VIBETUNNEL_USERNAME Default username if --username not specified
VIBETUNNEL_PASSWORD Default password if --password not specified
VIBETUNNEL_CONTROL_DIR Control directory for session data
PUSH_CONTACT_EMAIL Contact email for VAPID configuration
Examples:
# Run a simple server with authentication
vibetunnel-server --username admin --password secret
# Run as HQ server
vibetunnel-server --hq --username hq-admin --password hq-secret
# Run as remote server registering with HQ
vibetunnel-server --username local --password local123 \\
--hq-url https://hq.example.com \\
--hq-username hq-admin --hq-password hq-secret \\
--name remote-1
`);
}
// Parse command line arguments
function parseArgs() {
const args = process.argv.slice(2);
const config = {
port: null,
bind: null,
enableSSHKeys: false,
disallowUserPassword: false,
noAuth: false,
isHQMode: false,
hqUrl: null,
hqUsername: null,
hqPassword: null,
remoteName: null,
allowInsecureHQ: false,
showHelp: false,
showVersion: false,
debug: false,
// Push notification configuration
pushEnabled: true, // Enable by default with auto-generation
vapidEmail: null,
generateVapidKeys: true, // Generate keys automatically
bellNotificationsEnabled: true, // Enable bell notifications by default
// Local bypass configuration
allowLocalBypass: false,
localAuthToken: null,
// Tailscale Serve integration (manages auth and proxy)
enableTailscaleServe: false,
// HQ auth bypass for testing
noHqAuth: false,
// mDNS advertisement
enableMDNS: true, // Enable mDNS by default
};
// Check for help flag first
if (args.includes('--help') || args.includes('-h')) {
config.showHelp = true;
return config;
}
// Check for version flag
if (args.includes('--version') || args.includes('-v')) {
config.showVersion = true;
return config;
}
// Check for command line arguments
for (let i = 0; i < args.length; i++) {
if (args[i] === '--port' && i + 1 < args.length) {
config.port = Number.parseInt(args[i + 1], 10);
i++; // Skip the port value in next iteration
}
else if (args[i] === '--bind' && i + 1 < args.length) {
config.bind = args[i + 1];
i++; // Skip the bind value in next iteration
}
else if (args[i] === '--enable-ssh-keys') {
config.enableSSHKeys = true;
}
else if (args[i] === '--disallow-user-password') {
config.disallowUserPassword = true;
config.enableSSHKeys = true; // Auto-enable SSH keys
}
else if (args[i] === '--no-auth') {
config.noAuth = true;
}
else if (args[i] === '--hq') {
config.isHQMode = true;
}
else if (args[i] === '--hq-url' && i + 1 < args.length) {
config.hqUrl = args[i + 1];
i++; // Skip the URL value in next iteration
}
else if (args[i] === '--hq-username' && i + 1 < args.length) {
config.hqUsername = args[i + 1];
i++; // Skip the username value in next iteration
}
else if (args[i] === '--hq-password' && i + 1 < args.length) {
config.hqPassword = args[i + 1];
i++; // Skip the password value in next iteration
}
else if (args[i] === '--name' && i + 1 < args.length) {
config.remoteName = args[i + 1];
i++; // Skip the name value in next iteration
}
else if (args[i] === '--allow-insecure-hq') {
config.allowInsecureHQ = true;
}
else if (args[i] === '--debug') {
config.debug = true;
}
else if (args[i] === '--push-enabled') {
config.pushEnabled = true;
}
else if (args[i] === '--push-disabled') {
config.pushEnabled = false;
}
else if (args[i] === '--vapid-email' && i + 1 < args.length) {
config.vapidEmail = args[i + 1];
i++; // Skip the email value in next iteration
}
else if (args[i] === '--generate-vapid-keys') {
config.generateVapidKeys = true;
}
else if (args[i] === '--allow-local-bypass') {
config.allowLocalBypass = true;
}
else if (args[i] === '--local-auth-token' && i + 1 < args.length) {
config.localAuthToken = args[i + 1];
i++; // Skip the token value in next iteration
}
else if (args[i] === '--enable-tailscale-serve') {
config.enableTailscaleServe = true;
}
else if (args[i] === '--no-hq-auth') {
config.noHqAuth = true;
}
else if (args[i] === '--no-mdns') {
config.enableMDNS = false;
}
else if (args[i].startsWith('--')) {
// Unknown argument
logger.error(`Unknown argument: ${args[i]}`);
logger.error('Use --help to see available options');
process.exit(1);
}
}
// Check environment variables for push notifications
if (!config.vapidEmail && process.env.PUSH_CONTACT_EMAIL) {
config.vapidEmail = process.env.PUSH_CONTACT_EMAIL;
}
return config;
}
// Validate configuration
function validateConfig(config) {
// Validate auth configuration
if (config.noAuth && (config.enableSSHKeys || config.disallowUserPassword)) {
logger.warn('--no-auth overrides all other authentication settings (authentication is disabled)');
}
if (config.disallowUserPassword && !config.enableSSHKeys) {
logger.warn('--disallow-user-password requires SSH keys, auto-enabling --enable-ssh-keys');
config.enableSSHKeys = true;
}
// Validate HQ registration configuration
if (config.hqUrl && (!config.hqUsername || !config.hqPassword) && !config.noHqAuth) {
logger.error('HQ username and password required when --hq-url is specified');
logger.error('Use --hq-username and --hq-password with --hq-url');
logger.error('Or use --no-hq-auth for testing without authentication');
process.exit(1);
}
// Validate remote name is provided when registering with HQ
if (config.hqUrl && !config.remoteName) {
logger.error('Remote name required when --hq-url is specified');
logger.error('Use --name to specify a unique name for this remote server');
process.exit(1);
}
// Validate HQ URL is HTTPS unless explicitly allowed
if (config.hqUrl && !config.hqUrl.startsWith('https://') && !config.allowInsecureHQ) {
logger.error('HQ URL must use HTTPS protocol');
logger.error('Use --allow-insecure-hq to allow HTTP for testing');
process.exit(1);
}
// Validate HQ registration configuration
if ((config.hqUrl || config.hqUsername || config.hqPassword) &&
(!config.hqUrl || !config.hqUsername || !config.hqPassword) &&
!config.noHqAuth) {
logger.error('All HQ parameters required: --hq-url, --hq-username, --hq-password');
logger.error('Or use --no-hq-auth for testing without authentication');
process.exit(1);
}
// Validate Tailscale configuration
if (config.enableTailscaleServe && config.bind === '0.0.0.0') {
logger.error('Security Error: Cannot bind to 0.0.0.0 when using Tailscale Serve');
logger.error('Tailscale Serve requires binding to localhost (127.0.0.1)');
logger.error('Use --bind 127.0.0.1 or disable Tailscale Serve');
process.exit(1);
}
// Can't be both HQ mode and register with HQ
if (config.isHQMode && config.hqUrl) {
logger.error('Cannot use --hq and --hq-url together');
logger.error('Use --hq to run as HQ server, or --hq-url to register with an HQ');
process.exit(1);
}
// Warn about no-hq-auth
if (config.noHqAuth && config.hqUrl) {
logger.warn('--no-hq-auth is enabled: Remote servers can register without authentication');
logger.warn('This should only be used for testing!');
}
}
// Track if app has been created
let appCreated = false;
async function createApp() {
// Prevent multiple app instances
if (appCreated) {
logger.error('App already created, preventing duplicate instance');
throw new Error('Duplicate app creation detected');
}
appCreated = true;
const config = parseArgs();
// Check if help was requested
if (config.showHelp) {
showHelp();
process.exit(0);
}
// Check if version was requested
if (config.showVersion) {
const versionInfo = (0, version_js_1.getVersionInfo)();
console.log(`VibeTunnel Server v${versionInfo.version}`);
console.log(`Built: ${versionInfo.buildDate}`);
console.log(`Platform: ${versionInfo.platform}/${versionInfo.arch}`);
console.log(`Node: ${versionInfo.nodeVersion}`);
process.exit(0);
}
// Print version banner on startup
(0, version_js_1.printVersionBanner)();
validateConfig(config);
logger.log('Initializing VibeTunnel server components');
const app = (0, express_1.default)();
const server = (0, http_1.createServer)(app);
const wss = new ws_1.WebSocketServer({ noServer: true, perMessageDeflate: true });
// Add security headers with Helmet
app.use((0, helmet_1.default)({
contentSecurityPolicy: false, // We handle CSP ourselves for the web terminal
crossOriginEmbedderPolicy: false, // Allow embedding in iframes for integrations
}));
logger.debug('Configured security headers with helmet');
// Add compression middleware with Brotli support
// Skip compression for SSE streams (asciicast and events)
app.use((0, compression_1.default)({
filter: (req, res) => {
// Skip compression for Server-Sent Events
if (req.path.match(/\/api\/sessions\/[^/]+\/stream$/) || req.path === '/api/events') {
return false;
}
// Use default filter for other requests
return compression_1.default.filter(req, res);
},
// Enable Brotli compression with highest priority
level: 6, // Balanced compression level
}));
logger.debug('Configured compression middleware (with SSE exclusion)');
// Add JSON body parser middleware with size limit
app.use(express_1.default.json({ limit: '10mb' }));
logger.debug('Configured express middleware');
// Control directory for session data
const CONTROL_DIR = process.env.VIBETUNNEL_CONTROL_DIR || path.join(os.homedir(), '.vibetunnel/control');
// Ensure control directory exists
if (!fs.existsSync(CONTROL_DIR)) {
fs.mkdirSync(CONTROL_DIR, { recursive: true });
logger.log(chalk_1.default.green(`Created control directory: ${CONTROL_DIR}`));
}
else {
logger.debug(`Using existing control directory: ${CONTROL_DIR}`);
}
// Initialize PTY manager with fallback support
await index_js_1.PtyManager.initialize();
const ptyManager = new index_js_1.PtyManager(CONTROL_DIR);
logger.debug('Initialized PTY manager');
// Clean up sessions from old VibeTunnel versions
const sessionManager = ptyManager.getSessionManager();
const cleanupResult = sessionManager.cleanupOldVersionSessions();
if (cleanupResult.versionChanged) {
logger.log(chalk_1.default.yellow(`Version change detected - cleaned up ${cleanupResult.cleanedCount} sessions from previous version`));
}
else if (cleanupResult.cleanedCount > 0) {
logger.log(chalk_1.default.yellow(`Cleaned up ${cleanupResult.cleanedCount} legacy sessions without version information`));
}
// Initialize Terminal Manager for server-side terminal state
const terminalManager = new terminal_manager_js_1.TerminalManager(CONTROL_DIR);
logger.debug('Initialized terminal manager');
// Initialize stream watcher for file-based streaming
const streamWatcher = new stream_watcher_js_1.StreamWatcher(sessionManager);
logger.debug('Initialized stream watcher');
// Initialize session monitor with PTY manager
const sessionMonitor = new session_monitor_js_1.SessionMonitor(ptyManager);
await sessionMonitor.initialize();
// Set the session monitor on PTY manager for data tracking
ptyManager.setSessionMonitor(sessionMonitor);
logger.debug('Initialized session monitor');
// Initialize activity monitor
const activityMonitor = new activity_monitor_js_1.ActivityMonitor(CONTROL_DIR);
logger.debug('Initialized activity monitor');
// Initialize configuration service
const configService = new config_service_js_1.ConfigService();
configService.startWatching();
logger.debug('Initialized configuration service');
// Initialize push notification services
let vapidManager = null;
let pushNotificationService = null;
if (config.pushEnabled) {
try {
logger.log('Initializing push notification services');
// Initialize VAPID manager with auto-generation
vapidManager = new vapid_manager_js_1.VapidManager();
await vapidManager.initialize({
contactEmail: config.vapidEmail || 'noreply@vibetunnel.local',
generateIfMissing: true, // Auto-generate keys if none exist
});
logger.log('VAPID keys initialized successfully');
// Initialize push notification service
pushNotificationService = new push_notification_service_js_1.PushNotificationService(vapidManager);
await pushNotificationService.initialize();
logger.log(chalk_1.default.green('Push notification services initialized'));
}
catch (error) {
logger.error('Failed to initialize push notification services:', error);
logger.warn('Continuing without push notifications');
vapidManager = null;
pushNotificationService = null;
}
}
else {
logger.debug('Push notifications disabled');
}
// Connect SessionMonitor to push notification service
if (sessionMonitor && pushNotificationService) {
logger.info('Connecting SessionMonitor to push notification service');
// Listen for session monitor notifications and send push notifications
sessionMonitor.on('notification', async (event) => {
try {
// Map event types to push notification data
let pushPayload = null;
switch (event.type) {
case types_js_1.ServerEventType.SessionStart:
pushPayload = {
type: 'session-start',
title: '🚀 Session Started',
body: event.sessionName || 'Terminal Session',
};
break;
case types_js_1.ServerEventType.SessionExit:
pushPayload = {
type: 'session-exit',
title: '🏁 Session Ended',
body: event.sessionName || 'Terminal Session',
data: { exitCode: event.exitCode },
};
break;
case types_js_1.ServerEventType.CommandFinished:
pushPayload = {
type: 'command-finished',
title: '✅ Your Turn',
body: event.command || 'Command completed',
data: { duration: event.duration },
};
break;
case types_js_1.ServerEventType.CommandError:
pushPayload = {
type: 'command-error',
title: '❌ Command Failed',
body: event.command || 'Command failed',
data: { exitCode: event.exitCode },
};
break;
case types_js_1.ServerEventType.Bell:
pushPayload = {
type: 'bell',
title: '🔔 Terminal Bell',
body: event.sessionName || 'Terminal',
};
break;
case types_js_1.ServerEventType.ClaudeTurn:
pushPayload = {
type: 'claude-turn',
title: '💬 Your Turn',
body: event.message || 'Claude has finished responding',
};
break;
case types_js_1.ServerEventType.TestNotification:
// Test notifications are already handled by the test endpoint
return;
default:
return; // Skip unknown event types
}
if (pushPayload) {
// Send push notification
const result = await pushNotificationService.sendNotification({
...pushPayload,
icon: '/apple-touch-icon.png',
badge: '/favicon-32.png',
tag: `vibetunnel-${pushPayload.type}`,
requireInteraction: pushPayload.type === 'command-error',
actions: [
{
action: 'view-session',
title: 'View Session',
},
{
action: 'dismiss',
title: 'Dismiss',
},
],
data: {
...pushPayload.data,
type: pushPayload.type,
sessionId: event.sessionId,
timestamp: event.timestamp,
},
});
logger.debug(`Push notification sent for ${event.type}: ${result.sent} successful, ${result.failed} failed`);
}
}
catch (error) {
logger.error('Failed to send push notification for SessionMonitor event:', error);
}
});
}
// Initialize HQ components
let remoteRegistry = null;
let hqClient = null;
let controlDirWatcher = null;
let bufferAggregator = null;
let remoteBearerToken = null;
if (config.isHQMode) {
remoteRegistry = new remote_registry_js_1.RemoteRegistry();
logger.log(chalk_1.default.green('Running in HQ mode'));
logger.debug('Initialized remote registry for HQ mode');
}
else if (config.hqUrl &&
config.remoteName &&
(config.noHqAuth || (config.hqUsername && config.hqPassword))) {
// Generate bearer token for this remote server
remoteBearerToken = (0, uuid_1.v4)();
logger.debug(`Generated bearer token for remote server: ${config.remoteName}`);
}
// Initialize authentication service
const authService = new auth_service_js_1.AuthService();
logger.debug('Initialized authentication service');
// Initialize buffer aggregator
bufferAggregator = new buffer_aggregator_js_1.BufferAggregator({
terminalManager,
remoteRegistry,
isHQMode: config.isHQMode,
});
logger.debug('Initialized buffer aggregator');
// Initialize WebSocket input handler
const websocketInputHandler = new websocket_input_js_1.WebSocketInputHandler({
ptyManager,
terminalManager,
activityMonitor,
remoteRegistry,
authService,
isHQMode: config.isHQMode,
});
logger.debug('Initialized WebSocket input handler');
// Set up authentication
const authMiddleware = (0, auth_js_1.createAuthMiddleware)({
enableSSHKeys: config.enableSSHKeys,
disallowUserPassword: config.disallowUserPassword,
noAuth: config.noAuth,
isHQMode: config.isHQMode,
bearerToken: remoteBearerToken || undefined, // Token that HQ must use to auth with us
authService, // Add enhanced auth service for JWT tokens
allowLocalBypass: config.allowLocalBypass,
localAuthToken: config.localAuthToken || undefined,
allowTailscaleAuth: config.enableTailscaleServe,
});
// Serve static files with .html extension handling and caching headers
// In production/bundled mode, use the package directory; in development, use cwd
const getPublicPath = () => {
// First check if BUILD_PUBLIC_PATH is set (used by Mac app bundle)
if (process.env.BUILD_PUBLIC_PATH) {
logger.info(`Using BUILD_PUBLIC_PATH: ${process.env.BUILD_PUBLIC_PATH}`);
return process.env.BUILD_PUBLIC_PATH;
}
// More precise npm package detection:
// 1. Check if we're explicitly in an npm package structure
// 2. The file should be in node_modules/vibetunnel/lib/
// 3. Or check for our specific package markers
const isNpmPackage = (() => {
// Most reliable: check if we're in node_modules/vibetunnel structure
if (__filename.includes(path.join('node_modules', 'vibetunnel', 'lib'))) {
return true;
}
// Check for Windows path variant
if (__filename.includes('node_modules\\vibetunnel\\lib')) {
return true;
}
// Secondary check: if we're in a lib directory, verify it's actually an npm package
// by checking for the existence of package.json in the parent directory
if (path.basename(__dirname) === 'lib') {
const parentDir = path.dirname(__dirname);
const packageJsonPath = path.join(parentDir, 'package.json');
try {
const packageJson = require(packageJsonPath);
// Verify this is actually our package
return packageJson.name === 'vibetunnel';
}
catch {
// Not a valid npm package structure
return false;
}
}
return false;
})();
if (process.env.VIBETUNNEL_BUNDLED === 'true' || process.env.BUILD_DATE || isNpmPackage) {
// In bundled/production/npm mode, find package root
// When bundled, __dirname is /path/to/package/dist, so go up one level
// When globally installed, we need to find the package root
let packageRoot = __dirname;
// If we're in the dist directory, go up one level
if (path.basename(packageRoot) === 'dist') {
packageRoot = path.dirname(packageRoot);
}
// For npm package context, if we're in lib directory, go up one level
if (path.basename(packageRoot) === 'lib') {
packageRoot = path.dirname(packageRoot);
}
// Look for package.json to confirm we're in the right place
const publicPath = path.join(packageRoot, 'public');
const indexPath = path.join(publicPath, 'index.html');
// If index.html exists, we found the right path
if (require('fs').existsSync(indexPath)) {
return publicPath;
}
// Fallback: try going up from the bundled CLI location
// The bundled CLI might be in node_modules/vibetunnel/dist/
return path.join(__dirname, '..', 'public');
}
else {
// In development mode, use current working directory
return path.join(process.cwd(), 'public');
}
};
const publicPath = getPublicPath();
const isDevelopment = !process.env.BUILD_DATE || process.env.NODE_ENV === 'development';
app.use(express_1.default.static(publicPath, {
extensions: ['html'], // This allows /logs to resolve to /logs.html
maxAge: isDevelopment ? 0 : '1d', // No cache in dev, 1 day in production
etag: !isDevelopment, // Disable ETag in development
lastModified: !isDevelopment, // Disable Last-Modified in development
setHeaders: (res, filePath) => {
if (isDevelopment) {
// Disable all caching in development
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
res.setHeader('Pragma', 'no-cache');
res.setHeader('Expires', '0');
}
else {
// Production caching rules
// Set longer cache for immutable assets
if (filePath.match(/\.(js|css|woff2?|ttf|eot|svg|png|jpg|jpeg|gif|ico)$/)) {
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
}
// Shorter cache for HTML files
else if (filePath.endsWith('.html')) {
res.setHeader('Cache-Control', 'public, max-age=3600'); // 1 hour
}
}
},
}));
logger.debug(`Serving static files from: ${publicPath} ${isDevelopment ? 'with caching disabled (dev mode)' : 'with caching headers'}`);
// Health check endpoint (no auth required)
app.get('/api/health', (_req, res) => {
const versionInfo = (0, version_js_1.getVersionInfo)();
res.json({
status: 'ok',
timestamp: new Date().toISOString(),
mode: config.isHQMode ? 'hq' : 'remote',
version: versionInfo.version,
buildDate: versionInfo.buildDate,
uptime: versionInfo.uptime,
pid: versionInfo.pid,
});
});
// Connect session exit notifications if push notifications are enabled
if (pushNotificationService) {
ptyManager.on('sessionExited', (sessionId) => {
// Load session info to get details
const sessionInfo = sessionManager.loadSessionInfo(sessionId);
const exitCode = sessionInfo?.exitCode ?? 0;
const sessionName = sessionInfo?.name || `Session ${sessionId}`;
// Determine notification type based on exit code
const notificationType = exitCode === 0 ? 'session-exit' : 'session-error';
const title = exitCode === 0 ? 'Session Ended' : 'Session Ended with Errors';
const body = exitCode === 0
? `${sessionName} has finished.`
: `${sessionName} exited with code ${exitCode}.`;
pushNotificationService
.sendNotification({
type: notificationType,
title,
body,
icon: '/apple-touch-icon.png',
badge: '/favicon-32.png',
tag: `vibetunnel-${notificationType}-${sessionId}`,
requireInteraction: false,
data: {
type: notificationType,
sessionId,
sessionName,
exitCode,
timestamp: new Date().toISOString(),
},
actions: [
{ action: 'view-logs', title: 'View Logs' },
{ action: 'dismiss', title: 'Dismiss' },
],
})
.catch((error) => {
logger.error('Failed to send session exit notification:', error);
});
});
logger.debug('Connected session exit notifications to PTY manager');
// Connect command finished notifications
ptyManager.on('commandFinished', ({ sessionId, command, exitCode, duration, timestamp }) => {
const isClaudeCommand = command.toLowerCase().includes('claude');
// Enhanced logging for Claude commands
if (isClaudeCommand) {
logger.log(chalk_1.default.magenta(`📬 Server received Claude commandFinished event: sessionId=${sessionId}, command="${command}", exitCode=${exitCode}, duration=${duration}ms`));
}
else {
logger.debug(`Server received commandFinished event for session ${sessionId}: "${command}"`);
}
// Determine notification type based on exit code
const notificationType = exitCode === 0 ? 'command-finished' : 'command-error';
const title = exitCode === 0 ? 'Command Completed' : 'Command Failed';
const body = exitCode === 0
? `${command} completed successfully`
: `${command} failed with exit code ${exitCode}`;
// Format duration for display
const durationStr = duration > 60000
? `${Math.round(duration / 60000)}m ${Math.round((duration % 60000) / 1000)}s`
: `${Math.round(duration / 1000)}s`;
logger.debug(`Sending push notification: type=${notificationType}, title="${title}", body="${body} (${durationStr})"`);
pushNotificationService
.sendNotification({
type: notificationType,
title,
body: `${body} (${durationStr})`,
icon: '/apple-touch-icon.png',
badge: '/favicon-32.png',
tag: `vibetunnel-command-${sessionId}-${Date.now()}`,
requireInteraction: false,
data: {
type: notificationType,
sessionId,
command,
exitCode,
duration,
timestamp,
},
actions: [
{ action: 'view-session', title: 'View Session' },
{ action: 'dismiss', title: 'Dismiss' },
],
})
.catch((error) => {
logger.error('Failed to send command finished notification:', error);
});
});
logger.debug('Connected command finished notifications to PTY manager');
// Connect Claude turn notifications
ptyManager.on('claudeTurn', (sessionId, sessionName) => {
logger.info(`🔔 NOTIFICATION DEBUG: Sending push notification for Claude turn - sessionId: ${sessionId}`);
pushNotificationService
.sendNotification({
type: 'claude-turn',
title: 'Claude Ready',
body: `${sessionName} is waiting for your input.`,
icon: '/apple-touch-icon.png',
badge: '/favicon-32.png',
tag: `vibetunnel-claude-turn-${sessionId}`,
requireInteraction: true,
data: {
type: 'claude-turn',
sessionId,
sessionName,
timestamp: new Date().toISOString(),
},
actions: [
{ action: 'view-session', title: 'View Session' },
{ action: 'dismiss', title: 'Dismiss' },
],
})
.catch((error) => {
logger.error('Failed to send Claude turn notification:', error);
});
});
logger.debug('Connected Claude turn notifications to PTY manager');
}
// Apply auth middleware to all API routes (including auth routes for Tailscale header detection)
app.use('/api', authMiddleware);
logger.debug('Applied authentication middleware to /api routes');
// Mount authentication routes (auth middleware will skip these but still check Tailscale headers)
app.use('/api/auth', (0, auth_js_2.createAuthRoutes)({
authService,
enableSSHKeys: config.enableSSHKeys,
disallowUserPassword: config.disallowUserPassword,
noAuth: config.noAuth,
}));
logger.debug('Mounted authentication routes');
// Mount routes
app.use('/api', (0, sessions_js_1.createSessionRoutes)({
ptyManager,
terminalManager,
streamWatcher,
remoteRegistry,
isHQMode: config.isHQMode,
activityMonitor,
}));
logger.debug('Mounted session routes');
app.use('/api', (0, remotes_js_1.createRemoteRoutes)({
remoteRegistry,
isHQMode: config.isHQMode,
}));
logger.debug('Mounted remote routes');
// Mount filesystem routes
app.use('/api', (0, filesystem_js_1.createFilesystemRoutes)());
logger.debug('Mounted filesystem routes');
// Mount log routes
app.use('/api', (0, logs_js_1.createLogRoutes)());
logger.debug('Mounted log routes');
// Mount file routes
app.use('/api', (0, files_js_1.createFileRoutes)());
logger.debug('Mounted file routes');
// Mount repository routes
app.use('/api', (0, repositories_js_1.createRepositoryRoutes)());
logger.debug('Mounted repository routes');
// Mount config routes
app.use('/api', (0, config_js_1.createConfigRoutes)({
configService,
}));
logger.debug('Mounted config routes');
// Mount Git routes
app.use('/api', (0, git_js_1.createGitRoutes)());
logger.debug('Mounted Git routes');
// Mount worktree routes
app.use('/api', (0, worktrees_js_1.createWorktreeRoutes)());
logger.debug('Mounted worktree routes');
// Mount control routes
app.use('/api', (0, control_js_1.createControlRoutes)());
logger.debug('Mounted control routes');
// Mount tmux routes
app.use('/api/tmux', (0, tmux_js_1.createTmuxRoutes)({ ptyManager }));
logger.debug('Mounted tmux routes');
// Mount multiplexer routes (unified tmux/zellij interface)
app.use('/api/multiplexer', (0, multiplexer_js_1.createMultiplexerRoutes)({ ptyManager }));
logger.debug('Mounted multiplexer routes');
// Mount push notification routes - always mount even if VAPID is not initialized
// This ensures proper error responses instead of 404s
app.use('/api', (0, push_js_1.createPushRoutes)({
vapidManager: vapidManager || new vapid_manager_js_1.VapidManager(), // Pass a dummy instance if null
pushNotificationService,
sessionMonitor,
}));
logger.debug('Mounted push notification routes');
// Mount events router for SSE streaming
app.use('/api', (0, events_js_1.createEventsRouter)(sessionMonitor));
logger.debug('Mounted events routes');
// Mount test notification router
app.use('/api', (0, test_notification_js_1.createTestNotificationRouter)({ sessionMonitor, pushNotificationService }));
logger.debug('Mounted test notification routes');
// Initialize control socket
try {
await control_unix_handler_js_1.controlUnixHandler.start();
logger.log(chalk_1.default.green('Control UNIX socket: READY'));
}
catch (error) {
logger.error('Failed to initialize control socket:', error);
logger.warn('Mac control features will not be available.');
// Depending on the desired behavior, you might want to exit here
// For now, we'll let the server continue without these features.
}
// Initialize API socket for CLI commands
try {
await api_socket_server_js_1.apiSocketServer.start();
logger.log(chalk_1.default.green('API socket server: READY'));
}
catch (error) {
logger.error('Failed to initialize API socket server:', error);
logger.warn('vt commands will not work via socket.');
}
// Handle WebSocket upgrade with authentication
server.on('upgrade', async (request, socket, head) => {
// Parse the URL to extract path and query parameters
const parsedUrl = new URL(request.url || '', `http://${request.headers.host || 'localhost'}`);
// Handle WebSocket paths
if (parsedUrl.pathname !== '/buffers' && parsedUrl.pathname !== '/ws/input') {
socket.write('HTTP/1.1 404 Not Found\r\n\r\n');
socket.destroy();
return;
}
// Check authentication and capture user info
const authResult = await new Promise((resolve) => {
// Track if promise has been resolved to prevent multiple resolutions
let resolved = false;
const safeResolve = (value) => {
if (!resolved) {
resolved = true;
resolve(value);
}
};
// Convert URLSearchParams to plain object for query parameters
const query = {};
parsedUrl.searchParams.forEach((value, key) => {
query[key] = value;
});
// Create a mock Express request/response to use auth middleware
const req = {
...request,
url: request.url,
path: parsedUrl.pathname,
userId: undefined,
authMethod: undefined,
query, // Include parsed query parameters for token-based auth
headers: request.headers,
ip: request.socket.remoteAddress || '',
socket: request.socket,
hostname: request.headers.host?.split(':')[0] || 'localhost',
// Add minimal Express-like methods needed by auth middleware
get: (header) => request.headers[header.toLowerCase()],
header: (header) => request.headers[header.toLowerCase()],
accepts: () => false,
acceptsCharsets: () => false,
acceptsEncodings: () => false,
acceptsLanguages: () => false,
};
let authFailed = false;
const res = {
status: (code) => {
// Only consider it a failure if it's an error status code
if (code >= 400) {
authFailed = true;
safeResolve({ authenticated: false });
}
return {
json: () => { },
send: () => { },
end: () => { },
};
},
setHeader: () => { },
send: () => { },
json: () => { },
end: () => { },
};
const next = (error) => {
// Authentication succeeds if next() is called without error and no auth failure was recorded
const authenticated = !error && !authFailed;
safeResolve({
authenticated,
userId: req.userId,
authMethod: req.authMethod,
});
};
// Add a timeout to prevent indefinite hanging
const timeoutId = setTimeout(() => {
logger.error('WebSocket auth timeout - auth middleware did not complete in time');
safeResolve({ authenticated: false });
}, 5000); // 5 second timeout
// Call authMiddleware and handle potential async errors
Promise.resolve(authMiddleware(req, res, next))
.then(() => {
clearTimeout(timeoutId);
})
.catch((error) => {
clearTimeout(timeoutId);
logger.error('Auth middleware error:', error);
safeResolve({ authenticated: false });
});
});
if (!authResult.authenticated) {
logger.debug('WebSocket connection rejected: unauthorized');
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
socket.destroy();
return;
}
// Handle the upgrade
wss.handleUpgrade(request, socket, head, (ws) => {
// Add path and auth information to the request for routing
const wsRequest = request;
wsRequest.pathname = parsedUrl.pathname;
wsRequest.searchParams = parsedUrl.searchParams;
wsRequest.userId = authResult.userId;
wsRequest.authMethod = authResult.authMethod;
wss.emit('connection', ws, wsRequest);
});
});
// WebSocket connection router
wss.on('connection', (ws, req) => {
const wsReq = req;
const pathname = wsReq.pathname;
const searchParams = wsReq.searchParams;
logger.log(`🔌 WebSocket connection to path: ${pathname}`);
logger.log(`👤 User ID: ${wsReq.userId || 'unknown'}`);
logger.log(`🔐 Auth method: ${wsReq.authMethod || 'unknown'}`);
if (pathname === '/buffers') {
logger.log('📊 Handling buffer WebSocket connection');
// Handle buffer updates WebSocket
if (bufferAggregator) {
bufferAggregator.handleClientConnection(ws);
}
else {
logger.error('BufferAggregator not initialized for WebSocket connection');
ws.close();
}
}
else if (pathname === '/ws/input') {
logger.log('⌨️ Handling input WebSocket connection');
// Handle input WebSocket
const sessionId = searchParams?.get('sessionId');
if (!sessionId) {
logger.error('WebSocket input connection missing sessionId parameter');
ws.close();
return;
}
// Extract user ID from the authenticated request
const userId = wsReq.userId || 'unknown';
websocketInputHandler.handleConnection(ws, sessionId, userId);
}
else {
logger.error(`❌ Unknown WebSocket path: ${pathname}`);
ws.close();
}
});
// Serve index.html for client-side routes (but not API routes)
app.get('/', (_req, res) => {
res.sendFile(path.join(publicPath, 'index.html'));
});
// Handle /session/:id routes by serving the same index.html
app.get('/session/:id', (_req, res) => {
res.sendFile(path.join(publicPath, 'index.html'));
});
// Handle /worktrees route by serving the same index.html
app.get('/worktrees', (_req, res) => {
res.sendFile(path.join(publicPath, 'index.html'));
});
// Handle /file-browser route by serving the same index.html
app.get('/file-browser', (_req, res) => {
res.sendFile(path.join(publicPath, 'index.html'));
});
// 404 handler for all other routes
app.use((req, res) => {
if (req.path.startsWith('/api/')) {
res.status(404).json({ error: 'API endpoint not found' });
}